返回 CodeWhale
shell.rs
根目录 / crates / tui / src / tools / shell.rs
1 //! Advanced shell execution with background process support and sandboxing.
2 //!
3 //! Provides:
4 //! - Synchronous command execution with timeout
5 //! - Background process execution
6 //! - Process output retrieval
7 //! - Process termination
8 //! - Sandbox support (macOS Seatbelt and opt-in Linux bubblewrap)
9 //! - Streaming output (future)
10
11 use anyhow::{Context, Result, anyhow};
12 use base64::Engine as _;
13 use serde::{Deserialize, Serialize};
14 use std::collections::HashMap;
15 use std::fs::File;
16 use std::io;
17 use std::io::{Read, Write};
18 use std::path::{Path, PathBuf};
19 use std::process::{Child, ChildStdin, Command, Stdio};
20 use std::sync::{Arc, Mutex};
21 use std::time::{Duration, Instant};
22 use uuid::Uuid;
23 use wait_timeout::ChildExt;
24
25 #[cfg(unix)]
26 use std::os::fd::FromRawFd;
27 #[cfg(unix)]
28 use std::os::unix::process::CommandExt;
29 #[cfg(windows)]
30 use std::os::windows::io::AsRawHandle;
31 #[cfg(windows)]
32 use std::os::windows::io::FromRawHandle;
33 #[cfg(windows)]
34 use windows::Win32::Foundation::{CloseHandle, HANDLE};
35 #[cfg(windows)]
36 use windows::Win32::System::JobObjects::{
37 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
38 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
39 SetInformationJobObject, TerminateJobObject,
40 };
41 #[cfg(windows)]
42 use windows::core::PCWSTR;
43
44 #[cfg(not(target_env = "ohos"))]
45 use portable_pty::{CommandBuilder, PtySize, native_pty_system};
46
47 mod guidance;
48 mod output;
49
50 use super::shell_output::{summarize_output, truncate_with_meta};
51 use crate::child_env;
52 use crate::sandbox::{
53 CommandSpec,
54 ExecEnv,
55 SandboxManager,
56 SandboxPolicy as ExecutionSandboxPolicy, // Rename to avoid conflict with spec::SandboxPolicy
57 SandboxType,
58 };
59 use crate::tools::resource_admission::{
60 CommandExpense, HeavyCommandPermit, MemoryPressure, acquire_heavy_command_permit,
61 infer_command_expense,
62 };
63 use crate::work_graph::{
64 EvidenceKind, EvidenceRef, OperationIntent, OperationOwnerSnapshot, OwnerState,
65 SharedWorkRuntime,
66 };
67 use crate::worker_profile::ShellPolicy;
68 use output::{
69 BoundedOutputAccumulator, BoundedOutputSnapshot, RAW_STREAM_SETTLED_TAIL_BYTES,
70 RawOutputBuffer, SharedRawOutput, new_shared_raw_output, tail_from_buffer, tail_text,
71 take_delta_from_buffer,
72 };
73
74 const READONLY_ENV_MARKER: &str = "CODEWHALE_INTERNAL_READONLY_ARGV";
75
76 #[cfg(unix)]
77 static PENDING_PERSISTENT_PROCESS_GROUPS: std::sync::OnceLock<
78 Mutex<std::collections::HashSet<u32>>,
79 > = std::sync::OnceLock::new();
80
81 #[cfg(unix)]
82 fn pending_persistent_process_groups() -> &'static Mutex<std::collections::HashSet<u32>> {
83 PENDING_PERSISTENT_PROCESS_GROUPS.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
84 }
85
86 #[cfg(unix)]
87 fn register_pending_persistent_process_group(process_group_id: u32) {
88 let mut groups = pending_persistent_process_groups()
89 .lock()
90 .unwrap_or_else(std::sync::PoisonError::into_inner);
91 groups.insert(process_group_id);
92 }
93
94 #[cfg(unix)]
95 fn unregister_pending_persistent_process_group(process_group_id: u32) {
96 let mut groups = pending_persistent_process_groups()
97 .lock()
98 .unwrap_or_else(std::sync::PoisonError::into_inner);
99 groups.remove(&process_group_id);
100 }
101
102 /// Kill services that were staged for ownership transfer but have not yet
103 /// been released. The process-wide signal path calls this immediately before
104 /// `process::exit`, where Rust destructors cannot run.
105 #[cfg(unix)]
106 pub(crate) fn abort_pending_persistent_process_groups_for_exit() {
107 let groups = {
108 let mut groups = pending_persistent_process_groups()
109 .lock()
110 .unwrap_or_else(std::sync::PoisonError::into_inner);
111 groups.drain().collect::<Vec<_>>()
112 };
113 for process_group_id in groups {
114 if let Ok(process_group_id) = i32::try_from(process_group_id) {
115 // SAFETY: the id was captured from a child spawned with
116 // `process_group(0)`. A negative pid targets that child's process
117 // group, never Codewhale's own group.
118 unsafe {
119 libc::kill(-process_group_id, libc::SIGKILL);
120 }
121 }
122 }
123 }
124
125 fn validate_shell_working_dir(path: &Path, inherited_session_workspace: bool) -> Result<()> {
126 let metadata = std::fs::metadata(path).with_context(|| {
127 let source = if inherited_session_workspace {
128 "saved session workspace"
129 } else {
130 "requested working directory"
131 };
132 format!(
133 "{source} is unavailable: {}. Restore or remap that directory, resume/fork the session from an existing workspace, or pass an explicit `working_dir`/`cwd` to exec_shell",
134 path.display()
135 )
136 })?;
137 if !metadata.is_dir() {
138 let source = if inherited_session_workspace {
139 "saved session workspace"
140 } else {
141 "requested working directory"
142 };
143 return Err(anyhow!(
144 "{source} is not a directory: {}. Resume/fork from an existing workspace or pass an explicit `working_dir`/`cwd`",
145 path.display()
146 ));
147 }
148 Ok(())
149 }
150
151 /// Status of a shell process.
152 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153 pub enum ShellStatus {
154 Running,
155 Completed,
156 Failed,
157 Killed,
158 TimedOut,
159 }
160
161 /// Result from a shell command execution.
162 #[derive(Debug, Clone, Serialize, Deserialize)]
163 pub struct ShellResult {
164 pub task_id: Option<String>,
165 pub status: ShellStatus,
166 /// Lossless process exit status. Windows exception/NTSTATUS values use
167 /// the full unsigned 32-bit range, so an i32 would corrupt them.
168 pub exit_code: Option<i64>,
169 pub stdout: String,
170 pub stderr: String,
171 pub duration_ms: u64,
172 /// Original stdout length in bytes.
173 #[serde(default)]
174 pub stdout_len: usize,
175 /// Original stderr length in bytes.
176 #[serde(default)]
177 pub stderr_len: usize,
178 /// Bytes omitted from stdout due to truncation.
179 #[serde(default)]
180 pub stdout_omitted: usize,
181 /// Bytes omitted from stderr due to truncation.
182 #[serde(default)]
183 pub stderr_omitted: usize,
184 /// Whether stdout was truncated.
185 #[serde(default)]
186 pub stdout_truncated: bool,
187 /// Whether stderr was truncated.
188 #[serde(default)]
189 pub stderr_truncated: bool,
190 /// Whether the command was executed in a sandbox.
191 #[serde(default)]
192 pub sandboxed: bool,
193 /// Type of sandbox used (if any).
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub sandbox_type: Option<String>,
196 /// Whether the command was blocked by sandbox restrictions.
197 #[serde(default)]
198 pub sandbox_denied: bool,
199 }
200
201 /// Compact, UI-oriented view of a tracked background shell job.
202 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203 pub struct ShellJobSnapshot {
204 pub id: String,
205 pub job_id: String,
206 pub command: String,
207 pub cwd: PathBuf,
208 pub status: ShellStatus,
209 pub exit_code: Option<i64>,
210 pub elapsed_ms: u64,
211 pub stdout_tail: String,
212 pub stderr_tail: String,
213 pub stdout_len: usize,
214 pub stderr_len: usize,
215 pub stdin_available: bool,
216 pub stale: bool,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub elapsed_since_output_ms: Option<u64>,
219 pub linked_task_id: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub owner_agent_id: Option<String>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub owner_agent_name: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub origin_tool_call_id: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub origin_turn_id: Option<String>,
228 /// Immutable root session that launched the job. Empty legacy records are
229 /// intentionally hidden from session-scoped completion drains.
230 #[serde(default, skip_serializing_if = "String::is_empty")]
231 pub owner_session_id: String,
232 }
233
234 /// Once-only completion event for a tracked background shell job.
235 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
236 pub struct ShellCompletionEvent {
237 pub task_id: String,
238 pub command: String,
239 pub status: ShellStatus,
240 pub exit_code: Option<i64>,
241 pub duration_ms: u64,
242 pub stdout_tail: String,
243 pub stderr_tail: String,
244 #[serde(default)]
245 pub stdout_len: usize,
246 #[serde(default)]
247 pub stderr_len: usize,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub evidence_ref: Option<String>,
250 pub linked_task_id: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub owner_agent_id: Option<String>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub owner_agent_name: Option<String>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub origin_tool_call_id: Option<String>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub origin_turn_id: Option<String>,
259 #[serde(default, skip_serializing_if = "String::is_empty")]
260 pub owner_session_id: String,
261 }
262
263 /// Byte evidence captured alongside a bounded completion event. Exact unless
264 /// the stream exceeded the in-memory retention ceiling, in which case the
265 /// omission is declared per stream rather than presented as complete.
266 #[derive(Debug, Clone)]
267 pub(crate) struct ShellCompletionEvidence {
268 pub event: ShellCompletionEvent,
269 stdout: Vec<u8>,
270 stderr: Vec<u8>,
271 stdout_omitted: usize,
272 stderr_omitted: usize,
273 }
274
275 impl ShellCompletionEvidence {
276 /// Encode each stream losslessly. UTF-8 remains readable; arbitrary bytes
277 /// use base64 so `retrieve_tool_result` can still recover exact output.
278 pub(crate) fn artifact_bytes(&self) -> Vec<u8> {
279 fn stream(bytes: &[u8], omitted: usize) -> serde_json::Value {
280 let mut value = match std::str::from_utf8(bytes) {
281 Ok(content) => serde_json::json!({
282 "encoding": "utf-8",
283 "byte_length": bytes.len(),
284 "content": content,
285 }),
286 Err(_) => serde_json::json!({
287 "encoding": "base64",
288 "byte_length": bytes.len(),
289 "content": base64::engine::general_purpose::STANDARD.encode(bytes),
290 }),
291 };
292 // Additive and only present when something was actually dropped, so
293 // the common case stays byte-identical to the v1 artifact readers
294 // already parse.
295 if omitted > 0
296 && let Some(object) = value.as_object_mut()
297 {
298 object.insert("leading_bytes_omitted".into(), omitted.into());
299 object.insert(
300 "total_byte_length".into(),
301 bytes.len().saturating_add(omitted).into(),
302 );
303 }
304 value
305 }
306
307 serde_json::json!({
308 "schema": "codewhale.shell_completion.evidence.v1",
309 "task_id": self.event.task_id,
310 "command": self.event.command,
311 "status": format!("{:?}", self.event.status),
312 "exit_code": self.event.exit_code,
313 "duration_ms": self.event.duration_ms,
314 "origin_tool_call_id": self.event.origin_tool_call_id,
315 "origin_turn_id": self.event.origin_turn_id,
316 "stdout": stream(&self.stdout, self.stdout_omitted),
317 "stderr": stream(&self.stderr, self.stderr_omitted),
318 })
319 .to_string()
320 .into_bytes()
321 }
322 }
323
324 // Keep the two inline streams at a 2 KiB combined hard ceiling. The durable
325 // artifact carries the exact bytes beyond these diagnostic tails.
326 const SHELL_COMPLETION_TAIL_BYTES: usize = 1_024;
327
328 /// How long a finished shell record stays listed in `/jobs`.
329 const FINISHED_SHELL_MAX_AGE: Duration = Duration::from_secs(3600);
330 /// Ceiling on finished records kept for the jobs panel. A long automation run
331 /// makes hundreds of `Bash` calls per hour; the panel is only useful for the
332 /// recent ones (#5472).
333 const MAX_FINISHED_SHELL_RECORDS: usize = 128;
334 /// Ceiling on bytes still held across all finished records. Each settled record
335 /// releases down to a 64 KiB tail, so this only binds when many large outputs
336 /// finish inside the same window.
337 const MAX_FINISHED_SHELL_BYTES: usize = 8 * 1024 * 1024;
338
339 fn bounded_completion_tail(buffer: &SharedRawOutput, max_bytes: usize) -> (usize, String) {
340 let (total, candidate) = tail_from_buffer(buffer, max_bytes);
341 if candidate.len() <= max_bytes {
342 return (total, candidate);
343 }
344 let content_budget = max_bytes.saturating_sub(3);
345 let mut start = candidate.len().saturating_sub(content_budget);
346 while start < candidate.len() && !candidate.is_char_boundary(start) {
347 start += 1;
348 }
349 (total, format!("...{}", &candidate[start..]))
350 }
351
352 /// Optional owner attribution for background shell work.
353 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
354 pub struct ShellJobOwner {
355 pub agent_id: String,
356 pub agent_name: String,
357 }
358
359 /// Full output view used by `/jobs show <id>`.
360 #[derive(Debug, Clone, Serialize, Deserialize)]
361 pub struct ShellJobDetail {
362 pub snapshot: ShellJobSnapshot,
363 pub stdout: String,
364 pub stderr: String,
365 }
366
367 pub struct ShellDeltaResult {
368 pub command: String,
369 pub result: ShellResult,
370 pub stdout_total_len: usize,
371 pub stderr_total_len: usize,
372 }
373
374 /// Which of a job's raw output streams to read. Stderr is a separate stream
375 /// only for piped jobs; PTY and merged modes fold it into stdout.
376 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
377 pub enum ShellOutputStream {
378 Stdout,
379 Stderr,
380 }
381
382 /// A non-consuming window of a job's raw output stream at absolute byte
383 /// offsets. Unlike [`ShellManager::get_output_delta`], reading a chunk never
384 /// advances anyone else's cursor, so several HTTP clients can follow the same
385 /// job without splitting the stream.
386 pub struct ShellOutputChunk {
387 /// Absolute offset of `bytes[0]`. Exceeds the requested cursor when the
388 /// bounded buffer already discarded that prefix — the gap is reported via
389 /// `dropped`, never silently re-sent.
390 pub offset: usize,
391 /// Raw stream bytes. Output is arbitrary bytes, not guaranteed UTF-8.
392 pub bytes: Vec<u8>,
393 /// Absolute offset just past the last returned byte; the next cursor.
394 pub next_offset: usize,
395 /// Total bytes this stream has produced, including discarded bytes.
396 pub total: usize,
397 /// Leading bytes permanently discarded by the in-flight bound.
398 pub dropped: usize,
399 pub status: ShellStatus,
400 pub exit_code: Option<i64>,
401 }
402
403 enum ShellChild {
404 Process(Child),
405 #[cfg(not(target_env = "ohos"))]
406 Pty(Box<dyn portable_pty::Child + Send>),
407 }
408 #[cfg(unix)]
409 impl ShellChild {
410 fn process_id(&self) -> Option<u32> {
411 match self {
412 Self::Process(child) => Some(child.id()),
413 #[cfg(not(target_env = "ohos"))]
414 Self::Pty(_) => None,
415 }
416 }
417 }
418
419 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
420 enum ShellOwnership {
421 Managed,
422 PersistPending,
423 Released,
424 }
425
426 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
427 pub struct PersistentServiceReceipt {
428 pub task_id: String,
429 pub pid: u32,
430 pub process_group_id: u32,
431 pub ownership: String,
432 }
433
434 #[cfg(unix)]
435 fn signal_child_process_group(child: &Child, signal: libc::c_int) -> std::io::Result<()> {
436 let pgid = child.id() as libc::pid_t;
437 if pgid <= 0 {
438 return Ok(());
439 }
440
441 // SAFETY: kill(2) dereferences no pointers.
442 let result = unsafe { libc::kill(-pgid, signal) };
443 if result == 0 {
444 Ok(())
445 } else {
446 let err = std::io::Error::last_os_error();
447 if err.raw_os_error() == Some(libc::ESRCH) {
448 // The group is already gone (or never formed); nothing to signal.
449 Ok(())
450 } else {
451 Err(err)
452 }
453 }
454 }
455
456 #[cfg(unix)]
457 fn kill_child_process_group(child: &mut Child) -> std::io::Result<()> {
458 let pgid = child.id() as libc::pid_t;
459 if pgid <= 0 {
460 return child.kill();
461 }
462
463 signal_child_process_group(child, libc::SIGKILL).or_else(|_| child.kill())
464 }
465
466 /// Bounded wait for the direct child to exit. Returns true once the child was
467 /// reaped (or the wait errored), false when the grace elapsed first. Unlike
468 /// `Child::wait`, this can never wedge the caller behind a child stuck in
469 /// uninterruptible sleep.
470 #[cfg(unix)]
471 fn wait_child_bounded(child: &mut Child, grace: Duration) -> bool {
472 let deadline = Instant::now() + grace;
473 loop {
474 match child.try_wait() {
475 Ok(Some(_)) | Err(_) => return true,
476 Ok(None) => {}
477 }
478 if Instant::now() >= deadline {
479 return false;
480 }
481 std::thread::sleep(Duration::from_millis(10));
482 }
483 }
484
485 /// Terminate a shell's whole process group with a bounded SIGTERM → SIGKILL
486 /// escalation (#52). The previous kill path SIGKILLed only the direct child
487 /// and then joined output-reader threads with no timeout, so the tool
488 /// returned whenever the command's descendants felt like exiting — observed
489 /// as a 120s foreground timeout returning after 300s. Every step here is
490 /// bounded: the tool returns at ~timeout + grace.
491 #[cfg(unix)]
492 fn terminate_child_process_group(child: &mut Child) -> std::io::Result<()> {
493 // Cooperative stop first so shells and their children can run traps and
494 // clean up; bounded so a SIGTERM-ignoring command cannot stall the caller.
495 let _ = signal_child_process_group(child, libc::SIGTERM);
496 if wait_child_bounded(child, KILL_TERM_GRACE) {
497 // The leader exited on SIGTERM; descendants may linger, so SIGKILL
498 // the rest of the group (ESRCH when it is already empty).
499 kill_child_process_group(child)?;
500 return Ok(());
501 }
502 kill_child_process_group(child)?;
503 let _ = wait_child_bounded(child, KILL_REAP_GRACE);
504 Ok(())
505 }
506
507 /// Configure parent-death signaling so shell-spawned children are reaped when
508 /// the TUI dies abnormally (#421). On Linux this installs
509 /// `PR_SET_PDEATHSIG(SIGTERM)` via `pre_exec` — the kernel then sends SIGTERM
510 /// to the child the moment the parent process exits, even on SIGKILL of the
511 /// TUI. The cancellation path already SIGKILLs the whole process group, so
512 /// this only fires when the parent dies without running its drop / cleanup
513 /// code (panic during shutdown, OOM, hardware crash, etc.).
514 ///
515 /// On macOS / Windows there's no kernel equivalent. The existing graceful
516 /// path (`kill_child_process_group` from the cancellation token) still
517 /// handles normal shutdown; abnormal exit can leak children — tracked as a
518 /// follow-up watchdog item per the original issue's acceptance criteria.
519 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
520 fn install_parent_death_signal(cmd: &mut Command) {
521 use std::os::unix::process::CommandExt;
522 // SAFETY: `pre_exec` runs in the child between fork and exec. The closure
523 // only calls `libc::prctl` with stack-allocated constant arguments and
524 // does not touch heap memory or the parent's locks. Both requirements
525 // (async-signal-safe + no allocation in the post-fork window) are met.
526 unsafe {
527 cmd.pre_exec(|| {
528 let result = libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0);
529 if result == -1 {
530 // Surface the errno but do not abort the spawn — the child
531 // will simply lose the parent-death cleanup safety net.
532 Err(std::io::Error::last_os_error())
533 } else {
534 Ok(())
535 }
536 });
537 }
538 }
539
540 /// Attach `args` to a `std::process::Command`, honoring shell-quoting on
541 /// Windows.
542 ///
543 /// Issue #1691: on Windows the shell command is invoked as
544 /// `cmd /C "chcp 65001 >NUL & <command>"`. Rust's `Command::arg` applies
545 /// MSVCRT (`CommandLineToArgvW`) escaping, turning the embedded `"` in a
546 /// quoted argument (e.g. `git commit -m "feat: complete sub-pages"`) into
547 /// `\"`. `cmd.exe` does NOT use MSVCRT parsing — it treats `\` literally and
548 /// `"` as a bare quote toggle — so the escaped payload is mis-tokenized and
549 /// `git` receives `feat:`, `complete`, `sub-pages"` as separate pathspecs
550 /// (the reported `pathspec 'sub-pages"' did not match` symptom). Passing the
551 /// `cmd /C` payload through `CommandExt::raw_arg` suppresses std's escaping so
552 /// the string reaches `cmd.exe` verbatim, exactly as a terminal would.
553 #[cfg(windows)]
554 fn push_shell_args(cmd: &mut Command, program: &str, args: &[String]) {
555 use std::os::windows::process::CommandExt;
556 // The `cmd /C <payload>` shape is the only place std's per-arg escaping
557 // corrupts a quoted command. Pass `/C` and the payload raw so the quotes
558 // survive; any other program keeps normal (correct) escaping. Match `cmd`
559 // by file stem so a full path (`C:\Windows\System32\cmd.exe`) or `.exe`
560 // suffix still triggers the raw-arg path.
561 let is_cmd = std::path::Path::new(program)
562 .file_stem()
563 .and_then(|s| s.to_str())
564 .map(|s| s.eq_ignore_ascii_case("cmd"))
565 .unwrap_or(false);
566 if is_cmd && args.len() == 2 && args[0].eq_ignore_ascii_case("/C") {
567 cmd.raw_arg(&args[0]);
568 cmd.raw_arg(&args[1]);
569 } else {
570 cmd.args(args);
571 }
572 }
573
574 #[cfg(not(windows))]
575 fn push_shell_args(cmd: &mut Command, _program: &str, args: &[String]) {
576 // Unix delegates tokenization entirely to `sh -c <command>`; the command
577 // string is passed as a single argv entry and never split by us.
578 cmd.args(args);
579 }
580
581 #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
582 fn install_parent_death_signal(_cmd: &mut Command) {
583 // No kernel-level equivalent on macOS / Windows. The cooperative
584 // cancellation + process_group SIGKILL path covers normal shutdown;
585 // abnormal exit (panic without unwind, SIGKILL of the TUI) can still
586 // leak children on those platforms — tracked as a follow-up.
587 }
588
589 #[cfg(windows)]
590 #[derive(Debug)]
591 struct WindowsJob {
592 handle: HANDLE,
593 }
594
595 #[cfg(windows)]
596 // SAFETY: Windows job handles are process-wide kernel handles. Moving the
597 // wrapper between threads does not invalidate the handle, and access is
598 // externally synchronized by ShellManager's mutex.
599 unsafe impl Send for WindowsJob {}
600 #[cfg(windows)]
601 // SAFETY: The wrapper exposes only terminate/drop operations around a kernel
602 // handle; concurrent use is guarded by ShellManager.
603 unsafe impl Sync for WindowsJob {}
604
605 #[cfg(windows)]
606 impl WindowsJob {
607 fn attach_to_child(child: &Child) -> std::io::Result<Self> {
608 // SAFETY: returned handle is owned by the new wrapper.
609 let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? };
610 let job = Self { handle };
611
612 let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
613 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
614
615 // SAFETY: `limits` is live with matching size; both handles are live.
616 unsafe {
617 SetInformationJobObject(
618 job.handle,
619 JobObjectExtendedLimitInformation,
620 &limits as *const _ as *const core::ffi::c_void,
621 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
622 )
623 .map_err(windows_io_error)?;
624
625 let process_handle = HANDLE(child.as_raw_handle());
626 AssignProcessToJobObject(job.handle, process_handle).map_err(windows_io_error)?;
627 }
628
629 Ok(job)
630 }
631
632 fn terminate(&self) -> std::io::Result<()> {
633 // SAFETY: `self.handle` is a live owned job handle.
634 unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) }
635 }
636 }
637
638 #[cfg(windows)]
639 impl Drop for WindowsJob {
640 fn drop(&mut self) {
641 // SAFETY: `self.handle` is owned here; Drop runs once.
642 unsafe {
643 let _ = CloseHandle(self.handle);
644 }
645 }
646 }
647
648 #[cfg(windows)]
649 fn windows_io_error(error: windows::core::Error) -> std::io::Error {
650 std::io::Error::other(error)
651 }
652
653 #[cfg(windows)]
654 fn terminate_windows_job(job: Option<&WindowsJob>, child: &mut Child) -> std::io::Result<()> {
655 if let Some(job) = job {
656 match job.terminate() {
657 Ok(()) => return Ok(()),
658 Err(error) => {
659 tracing::warn!(
660 ?error,
661 "failed to terminate Windows job object; falling back to immediate child kill"
662 );
663 }
664 }
665 }
666 child.kill()
667 }
668
669 #[cfg(windows)]
670 fn terminate_and_close_windows_job(windows_job: Option<WindowsJob>) {
671 if let Some(job) = windows_job.as_ref()
672 && let Err(err) = job.terminate()
673 {
674 tracing::warn!(
675 ?err,
676 "failed to terminate Windows shell job before closing job handle"
677 );
678 }
679 drop(windows_job);
680 }
681
682 #[cfg(windows)]
683 fn terminate_child_and_close_windows_job(
684 windows_job: Option<WindowsJob>,
685 child: &mut Child,
686 ) -> std::io::Result<()> {
687 let result = terminate_windows_job(windows_job.as_ref(), child);
688 drop(windows_job);
689 result
690 }
691
692 #[cfg(windows)]
693 fn attach_windows_job(child: &Child, command: &str) -> Option<WindowsJob> {
694 match WindowsJob::attach_to_child(child) {
695 Ok(job) => Some(job),
696 Err(error) => {
697 tracing::warn!(
698 ?error,
699 command,
700 "failed to attach Windows shell process to job object; descendant cleanup degraded"
701 );
702 None
703 }
704 }
705 }
706
707 #[cfg(windows)]
708 fn terminate_unregistered_process(child: &mut Child, job: Option<&WindowsJob>) {
709 let _ = terminate_windows_job(job, child);
710 let _ = child.wait();
711 }
712
713 #[cfg(not(windows))]
714 fn terminate_unregistered_process(child: &mut Child) {
715 #[cfg(unix)]
716 {
717 let _ = kill_child_process_group(child);
718 let _ = wait_child_bounded(child, KILL_REAP_GRACE);
719 }
720 #[cfg(not(unix))]
721 {
722 let _ = child.kill();
723 let _ = child.wait();
724 }
725 }
726
727 #[derive(Clone, Copy, Debug)]
728 struct ShellExitStatus {
729 code: Option<i64>,
730 success: bool,
731 }
732
733 impl ShellExitStatus {
734 fn from_std(status: std::process::ExitStatus) -> Self {
735 Self {
736 code: status.code().map(std_exit_code_i64),
737 success: status.success(),
738 }
739 }
740
741 #[cfg(not(target_env = "ohos"))]
742 fn from_pty(status: portable_pty::ExitStatus) -> Self {
743 Self {
744 code: Some(i64::from(status.exit_code())),
745 success: status.success(),
746 }
747 }
748 }
749
750 #[cfg(windows)]
751 fn std_exit_code_i64(code: i32) -> i64 {
752 // std exposes Windows DWORD process statuses through i32. Reinterpret
753 // negative values as their original unsigned bit pattern so codes such
754 // as 0xC0000005 survive JSON, persistence, and diagnostics unchanged.
755 i64::from(code as u32)
756 }
757
758 #[cfg(not(windows))]
759 fn std_exit_code_i64(code: i32) -> i64 {
760 i64::from(code)
761 }
762
763 impl ShellChild {
764 fn try_wait(&mut self) -> std::io::Result<Option<ShellExitStatus>> {
765 match self {
766 ShellChild::Process(child) => child
767 .try_wait()
768 .map(|status| status.map(ShellExitStatus::from_std)),
769 #[cfg(not(target_env = "ohos"))]
770 ShellChild::Pty(child) => child
771 .try_wait()
772 .map(|status| status.map(ShellExitStatus::from_pty)),
773 }
774 }
775
776 #[cfg(not(windows))]
777 fn kill(&mut self) -> std::io::Result<()> {
778 match self {
779 #[cfg(unix)]
780 ShellChild::Process(child) => kill_child_process_group(child),
781 #[cfg(not(unix))]
782 ShellChild::Process(child) => child.kill(),
783 #[cfg(not(target_env = "ohos"))]
784 ShellChild::Pty(child) => child.kill(),
785 }
786 }
787 }
788
789 enum StdinWriter {
790 Pipe(ChildStdin),
791 #[cfg(not(target_env = "ohos"))]
792 Pty(Box<dyn Write + Send>),
793 }
794
795 impl StdinWriter {
796 fn write_all(&mut self, data: &[u8]) -> std::io::Result<()> {
797 match self {
798 StdinWriter::Pipe(stdin) => stdin.write_all(data),
799 #[cfg(not(target_env = "ohos"))]
800 StdinWriter::Pty(writer) => writer.write_all(data),
801 }
802 }
803
804 fn flush(&mut self) -> std::io::Result<()> {
805 match self {
806 StdinWriter::Pipe(stdin) => stdin.flush(),
807 #[cfg(not(target_env = "ohos"))]
808 StdinWriter::Pty(writer) => writer.flush(),
809 }
810 }
811 }
812
813 fn spawn_reader_thread<R: Read + Send + 'static>(
814 mut reader: R,
815 buffer: SharedRawOutput,
816 ) -> std::thread::JoinHandle<()> {
817 std::thread::spawn(move || {
818 let mut chunk = [0u8; 4096];
819 loop {
820 match reader.read(&mut chunk) {
821 Ok(0) => break,
822 Ok(n) => {
823 // `RawOutputBuffer::append` enforces the in-flight ceiling
824 // here, at the only writer, so a chatty command cannot grow
825 // the process without bound while it runs (#5472). It
826 // returns false once the stream has been abandoned, which
827 // is this thread's only exit when a descendant holds the
828 // pipe open and EOF never arrives.
829 let keep_reading = buffer
830 .lock()
831 .unwrap_or_else(|e| e.into_inner())
832 .append(&chunk[..n]);
833 if !keep_reading {
834 break;
835 }
836 }
837 Err(_) => break,
838 }
839 }
840 })
841 }
842
843 fn spawn_bounded_reader_thread<R: Read + Send + 'static>(
844 mut reader: R,
845 output: Arc<Mutex<BoundedOutputAccumulator>>,
846 ) -> std::thread::JoinHandle<()> {
847 std::thread::spawn(move || {
848 let mut chunk = [0u8; 4096];
849 loop {
850 match reader.read(&mut chunk) {
851 Ok(0) => break,
852 Ok(n) => {
853 let mut guard = output.lock().unwrap_or_else(|error| error.into_inner());
854 if let Err(error) = guard.append(&chunk[..n]) {
855 guard.record_error(&error);
856 return;
857 }
858 }
859 Err(error) => {
860 output
861 .lock()
862 .unwrap_or_else(|poison| poison.into_inner())
863 .record_error(&error);
864 return;
865 }
866 }
867 }
868 let mut guard = output.lock().unwrap_or_else(|error| error.into_inner());
869 if let Err(error) = guard.finish() {
870 guard.record_error(&error);
871 }
872 })
873 }
874
875 #[cfg(unix)]
876 fn shared_output_pipe() -> io::Result<(File, File, File)> {
877 let mut descriptors = [0; 2];
878 // SAFETY: `pipe` initializes both descriptors on success. Each descriptor
879 // is immediately transferred into exactly one owned `File`.
880 if unsafe { libc::pipe(descriptors.as_mut_ptr()) } != 0 {
881 return Err(io::Error::last_os_error());
882 }
883 // SAFETY: successful `pipe` returned two live, uniquely owned descriptors.
884 let reader = unsafe { File::from_raw_fd(descriptors[0]) };
885 let writer = unsafe { File::from_raw_fd(descriptors[1]) };
886 let stderr_writer = writer.try_clone()?;
887 Ok((reader, writer, stderr_writer))
888 }
889
890 #[cfg(windows)]
891 fn shared_output_pipe() -> io::Result<(File, File, File)> {
892 let mut read_handle = std::ptr::null_mut();
893 let mut write_handle = std::ptr::null_mut();
894 // SAFETY: CreatePipe initializes both handles on success; ownership is
895 // transferred to `File` immediately below.
896 if unsafe {
897 windows_sys::Win32::System::Pipes::CreatePipe(
898 &mut read_handle,
899 &mut write_handle,
900 std::ptr::null(),
901 0,
902 )
903 } == 0
904 {
905 return Err(io::Error::last_os_error());
906 }
907 // SAFETY: successful CreatePipe returned two live, uniquely owned handles.
908 let reader = unsafe { File::from_raw_handle(read_handle.cast()) };
909 let writer = unsafe { File::from_raw_handle(write_handle.cast()) };
910 let stderr_writer = writer.try_clone()?;
911 Ok((reader, writer, stderr_writer))
912 }
913
914 const SYNC_READER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
915 const STALE_NO_OUTPUT_AFTER: Duration = Duration::from_secs(60);
916
917 /// Grace between SIGTERM and SIGKILL on the shell kill path (timeout,
918 /// cancel, drop). Bounded so a SIGTERM-ignoring command is force-killed
919 /// instead of stalling the tool (#52).
920 #[cfg(unix)]
921 const KILL_TERM_GRACE: Duration = Duration::from_millis(500);
922 /// Bounded reap wait after SIGKILL; a child stuck in uninterruptible sleep
923 /// must not wedge the caller behind an unbounded `wait`.
924 #[cfg(unix)]
925 const KILL_REAP_GRACE: Duration = Duration::from_millis(1_000);
926 /// Bounded join for output-reader threads after the process group is killed.
927 /// A descendant that escaped the group (its own session/process group) keeps
928 /// its inherited pipe write-end open, so the reader cannot see EOF until that
929 /// descendant exits on its own — an unbounded join held the shell-manager
930 /// lock for minutes and overshot the tool timeout (#52).
931 const READER_JOIN_GRACE: Duration = Duration::from_millis(2_000);
932
933 fn spawn_sync_reader_thread<R: Read + Send + 'static>(
934 mut reader: R,
935 ) -> std::sync::mpsc::Receiver<Vec<u8>> {
936 let (tx, rx) = std::sync::mpsc::channel();
937 std::thread::spawn(move || {
938 // Bounded, unlike the `read_to_end` this replaces (#5472 finding 2).
939 // `recv_sync_reader_output` gives up after 5 s, but the thread lives as
940 // long as the pipe does — an interactive command that keeps printing
941 // grew this Vec without limit, for a result nobody was still waiting
942 // for. The tail is what the caller renders, so keep the tail.
943 let mut buf = RawOutputBuffer::new();
944 let mut chunk = [0u8; 4096];
945 loop {
946 match reader.read(&mut chunk) {
947 Ok(0) => break,
948 Ok(n) => {
949 if !buf.append(&chunk[..n]) {
950 break;
951 }
952 }
953 Err(_) => break,
954 }
955 }
956 tx.send(buf.retained().to_vec()).ok();
957 });
958 rx
959 }
960
961 fn recv_sync_reader_output(rx: &std::sync::mpsc::Receiver<Vec<u8>>) -> Vec<u8> {
962 rx.recv_timeout(SYNC_READER_DRAIN_TIMEOUT)
963 .unwrap_or_default()
964 }
965
966 /// Cell dimensions accepted by the existing PTY owner. Pixel sizes remain
967 /// unspecified; callers must not allocate an unbounded terminal grid.
968 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
969 #[serde(deny_unknown_fields)]
970 pub struct PtyDimensions {
971 pub rows: u16,
972 pub cols: u16,
973 }
974
975 impl Default for PtyDimensions {
976 fn default() -> Self {
977 Self { rows: 24, cols: 80 }
978 }
979 }
980
981 impl PtyDimensions {
982 pub fn validate(self) -> Result<Self> {
983 anyhow::ensure!(
984 (1..=1000).contains(&self.rows) && (1..=1000).contains(&self.cols),
985 "PTY rows and columns must each be between 1 and 1000"
986 );
987 Ok(self)
988 }
989 }
990
991 /// A background shell process being tracked
992 pub struct BackgroundShell {
993 pub id: String,
994 pub command: String,
995 pub working_dir: PathBuf,
996 pub status: ShellStatus,
997 pub exit_code: Option<i64>,
998 pub started_at: Instant,
999 /// When the job reached a terminal status. A finished job reports the
1000 /// duration it finished with; without this, `started_at.elapsed()` kept
1001 /// growing and `/jobs` showed "2m 07s" for a 12-second command (#5478).
1002 finished_at: Option<Instant>,
1003 last_output_at: Instant,
1004 last_observed_output_len: usize,
1005 pub sandbox_type: SandboxType,
1006 pub linked_task_id: Option<String>,
1007 pub owner_agent: Option<ShellJobOwner>,
1008 owner_session_id: String,
1009 origin_tool_call_id: Option<String>,
1010 origin_turn_id: Option<String>,
1011 ownership: ShellOwnership,
1012 stdout_buffer: SharedRawOutput,
1013 stderr_buffer: Option<SharedRawOutput>,
1014 /// Lowercase `bash` streams one combined process pipe through a bounded
1015 /// small-contract-compatible accumulator while persisting the complete output.
1016 bounded_output: Option<Arc<Mutex<BoundedOutputAccumulator>>>,
1017 heavy_permit: Option<HeavyCommandPermit>,
1018 stdout_cursor: usize,
1019 stderr_cursor: usize,
1020 completion_reported: bool,
1021 stdin: Option<StdinWriter>,
1022 /// Retain the existing PTY owner for resize; never create another session.
1023 #[cfg(not(target_env = "ohos"))]
1024 pty_master: Option<Box<dyn portable_pty::MasterPty + Send>>,
1025 terminal_size: Option<PtyDimensions>,
1026 child: Option<ShellChild>,
1027 #[cfg(windows)]
1028 windows_job: Option<WindowsJob>,
1029 stdout_thread: Option<std::thread::JoinHandle<()>>,
1030 stderr_thread: Option<std::thread::JoinHandle<()>>,
1031 work_lifecycle: Option<ShellWorkLifecycle>,
1032 lifecycle_seq: u64,
1033 last_lifecycle_status: Option<ShellStatus>,
1034 last_lifecycle_bytes: usize,
1035 }
1036
1037 #[derive(Clone)]
1038 struct ShellWorkLifecycle {
1039 work: SharedWorkRuntime,
1040 session_id: String,
1041 }
1042
1043 impl ShellWorkLifecycle {
1044 fn register(&self, id: &str, command: &str) -> Result<()> {
1045 self.work
1046 .register_operation(
1047 &self.session_id,
1048 OperationIntent::new(
1049 format!("shell:{id}"),
1050 format!("Shell · {command}"),
1051 false,
1052 "exec_shell",
1053 id,
1054 ),
1055 )
1056 .map(|_| ())
1057 .map_err(anyhow::Error::msg)
1058 }
1059
1060 fn observe(&self, id: &str, status: &ShellStatus, seq: u64, raw_bytes: usize) -> Result<()> {
1061 let owner_state = match status {
1062 ShellStatus::Running => OwnerState::Running,
1063 ShellStatus::Completed => OwnerState::Completed,
1064 ShellStatus::Failed | ShellStatus::TimedOut => OwnerState::Failed,
1065 ShellStatus::Killed => OwnerState::Cancelled,
1066 };
1067 let raw_bytes = u64::try_from(raw_bytes).unwrap_or(u64::MAX);
1068 let output = EvidenceRef::new(
1069 EvidenceKind::Receipt {
1070 owner: "shell".to_string(),
1071 },
1072 format!("shell:{id}:output"),
1073 Some(raw_bytes),
1074 false,
1075 )
1076 .map_err(|err| anyhow!(err.to_string()))?;
1077 self.work
1078 .reconcile_operation(
1079 &self.session_id,
1080 OperationOwnerSnapshot::new(
1081 format!("shell:{id}"),
1082 owner_state,
1083 seq,
1084 lifecycle_now_ms(),
1085 )
1086 .with_output(output),
1087 )
1088 .map(|_| ())
1089 .map_err(anyhow::Error::msg)
1090 }
1091 }
1092
1093 struct ShellSpawnIntentGuard {
1094 lifecycle: Option<ShellWorkLifecycle>,
1095 id: String,
1096 armed: bool,
1097 }
1098
1099 struct ShellSpawnContext {
1100 owner_agent: Option<ShellJobOwner>,
1101 owner_session_id: String,
1102 origin_tool_call_id: Option<String>,
1103 origin_turn_id: Option<String>,
1104 work_lifecycle: Option<ShellWorkLifecycle>,
1105 }
1106
1107 impl ShellSpawnIntentGuard {
1108 /// Register the spawn intent with the Work graph.
1109 ///
1110 /// Registration is observability bookkeeping — the same subsystem already
1111 /// treats the `observe` half as best-effort (a graph-write failure must
1112 /// not relabel a completed command) — so a transiently busy To-do/Plan
1113 /// state must not veto the command itself. Live sessions hit this: a
1114 /// shell call issued right after another tool call failed outright with
1115 /// "To-do state is busy; operation was not registered" because
1116 /// `register_operation` gives the lock only a short try-lock spin.
1117 ///
1118 /// On failure the guard goes inert: the shell still runs, and no later
1119 /// `observe` pretends the operation was bound.
1120 fn new(lifecycle: Option<ShellWorkLifecycle>, id: &str, command: &str) -> Self {
1121 let lifecycle = lifecycle.and_then(|lifecycle| match lifecycle.register(id, command) {
1122 Ok(()) => Some(lifecycle),
1123 Err(err) => {
1124 tracing::warn!(
1125 shell_id = %id,
1126 error = %err,
1127 "shell work-graph registration skipped; running without a bound operation"
1128 );
1129 None
1130 }
1131 });
1132 Self {
1133 lifecycle,
1134 id: id.to_string(),
1135 armed: true,
1136 }
1137 }
1138
1139 fn disarm(&mut self) {
1140 self.armed = false;
1141 }
1142 }
1143
1144 impl Drop for ShellSpawnIntentGuard {
1145 fn drop(&mut self) {
1146 if self.armed
1147 && let Some(lifecycle) = self.lifecycle.as_ref()
1148 && let Err(err) = lifecycle.observe(&self.id, &ShellStatus::Failed, 1, 0)
1149 {
1150 tracing::warn!(shell_id = %self.id, error = %err, "failed to record shell spawn failure");
1151 }
1152 }
1153 }
1154
1155 impl BackgroundShell {
1156 /// Wall time to report: elapsed while running, frozen once finished.
1157 fn wall_duration(&self) -> Duration {
1158 self.finished_at
1159 .unwrap_or_else(Instant::now)
1160 .saturating_duration_since(self.started_at)
1161 }
1162
1163 fn wall_millis(&self) -> u64 {
1164 u64::try_from(self.wall_duration().as_millis()).unwrap_or(u64::MAX)
1165 }
1166
1167 /// Stamp the finish instant the first time a terminal status is observed.
1168 /// Idempotent: a later poll must not restate when the job ended.
1169 fn mark_finished(&mut self) {
1170 if self.finished_at.is_none() && self.status != ShellStatus::Running {
1171 self.finished_at = Some(Instant::now());
1172 }
1173 }
1174
1175 /// Check if the process has completed and update status
1176 fn poll(&mut self) -> bool {
1177 self.refresh_output_activity();
1178 if self.status != ShellStatus::Running {
1179 self.mark_finished();
1180 self.publish_lifecycle_best_effort();
1181 return true;
1182 }
1183
1184 #[cfg(unix)]
1185 let pending_process_group = (self.ownership == ShellOwnership::PersistPending)
1186 .then(|| self.child.as_ref().and_then(ShellChild::process_id))
1187 .flatten();
1188 let completed = if let Some(ref mut child) = self.child {
1189 match child.try_wait() {
1190 Ok(Some(status)) => {
1191 self.exit_code = status.code;
1192 self.status = if status.success {
1193 ShellStatus::Completed
1194 } else {
1195 ShellStatus::Failed
1196 };
1197 self.heavy_permit.take();
1198 self.collect_output();
1199 true
1200 }
1201 Ok(None) => false, // Still running
1202 Err(_) => {
1203 self.status = ShellStatus::Failed;
1204 self.heavy_permit.take();
1205 self.collect_output();
1206 true
1207 }
1208 }
1209 } else {
1210 true
1211 };
1212 #[cfg(unix)]
1213 if completed && let Some(process_group_id) = pending_process_group {
1214 unregister_pending_persistent_process_group(process_group_id);
1215 }
1216 self.mark_finished();
1217 self.publish_lifecycle_best_effort();
1218 completed
1219 }
1220
1221 fn publish_lifecycle(&mut self) -> Result<()> {
1222 let bytes = self.observed_output_len();
1223 if self.last_lifecycle_status.as_ref() == Some(&self.status)
1224 && self.last_lifecycle_bytes == bytes
1225 {
1226 return Ok(());
1227 }
1228 let next_seq = self.lifecycle_seq.saturating_add(1);
1229 if let Some(lifecycle) = self.work_lifecycle.as_ref() {
1230 lifecycle.observe(&self.id, &self.status, next_seq, bytes)?;
1231 }
1232 self.lifecycle_seq = next_seq;
1233 self.last_lifecycle_status = Some(self.status.clone());
1234 self.last_lifecycle_bytes = bytes;
1235 Ok(())
1236 }
1237
1238 fn publish_lifecycle_best_effort(&mut self) {
1239 if let Err(err) = self.publish_lifecycle() {
1240 tracing::warn!(shell_id = %self.id, error = %err, "failed to reconcile shell lifecycle");
1241 }
1242 }
1243
1244 fn refresh_output_activity(&mut self) {
1245 let observed_len = self.observed_output_len();
1246 if observed_len != self.last_observed_output_len {
1247 self.last_observed_output_len = observed_len;
1248 self.last_output_at = Instant::now();
1249 }
1250 }
1251
1252 fn observed_output_len(&self) -> usize {
1253 if let Some(output) = self.bounded_output.as_ref() {
1254 return output
1255 .lock()
1256 .map(|output| output.total_bytes())
1257 .unwrap_or(0);
1258 }
1259 let stdout_len = self
1260 .stdout_buffer
1261 .lock()
1262 .map(|data| data.total_len())
1263 .unwrap_or(0);
1264 let stderr_len = self
1265 .stderr_buffer
1266 .as_ref()
1267 .and_then(|buffer| buffer.lock().ok().map(|data| data.total_len()))
1268 .unwrap_or(0);
1269 stdout_len.saturating_add(stderr_len)
1270 }
1271
1272 /// Drop everything but a bounded tail of both raw streams.
1273 ///
1274 /// Called only once a job is terminal **and** its bytes have already been
1275 /// delivered — either returned as the foreground tool result, or written to
1276 /// its durable completion artifact. Before that the full bytes are a
1277 /// contract; after it they are pure residency for the up-to-1 h the
1278 /// finished record stays listed, which is what took the owner's host to
1279 /// 11 GB of swap (#5472 finding 1).
1280 fn release_delivered_output(&mut self) {
1281 if self.status == ShellStatus::Running {
1282 return;
1283 }
1284 for buffer in [Some(&self.stdout_buffer), self.stderr_buffer.as_ref()]
1285 .into_iter()
1286 .flatten()
1287 {
1288 buffer
1289 .lock()
1290 .unwrap_or_else(|poison| poison.into_inner())
1291 .release_to_tail(RAW_STREAM_SETTLED_TAIL_BYTES);
1292 }
1293 }
1294
1295 /// Bytes still held in memory for this job, for the eviction accounting in
1296 /// [`ShellManager::cleanup`] and for tests that assert the bound.
1297 fn retained_output_bytes(&self) -> usize {
1298 let stdout = self
1299 .stdout_buffer
1300 .lock()
1301 .map(|data| data.retained().len())
1302 .unwrap_or(0);
1303 let stderr = self
1304 .stderr_buffer
1305 .as_ref()
1306 .and_then(|buffer| buffer.lock().ok().map(|data| data.retained().len()))
1307 .unwrap_or(0);
1308 stdout.saturating_add(stderr)
1309 }
1310
1311 /// Collect output from the background threads
1312 fn collect_output(&mut self) {
1313 // Kill the whole process group before joining reader threads.
1314 // When the shell spawned persistent background jobs (e.g. `nohup curl`),
1315 // those subprocesses keep the pipe write-ends open after the shell exits.
1316 // Without this kill, the reader join would block until the descendant
1317 // exits, freezing the UI event loop that calls list_jobs() → poll() →
1318 // collect_output(). The joins themselves are additionally bounded
1319 // (READER_JOIN_GRACE) because a descendant in its own session/process
1320 // group escapes even the group kill (#52).
1321 #[cfg(unix)]
1322 if let Some(child) = self.child.as_mut() {
1323 match child {
1324 ShellChild::Process(proc) => {
1325 let _ = kill_child_process_group(proc);
1326 }
1327 #[cfg(not(target_env = "ohos"))]
1328 ShellChild::Pty(_) => {}
1329 }
1330 }
1331 #[cfg(windows)]
1332 terminate_and_close_windows_job(self.windows_job.take());
1333 if let Some(handle) = self.stdout_thread.take() {
1334 finish_background_reader(handle, &self.status, Some(&self.stdout_buffer));
1335 }
1336 if let Some(handle) = self.stderr_thread.take() {
1337 finish_background_reader(handle, &self.status, self.stderr_buffer.as_ref());
1338 }
1339 self.stdin = None;
1340 #[cfg(not(target_env = "ohos"))]
1341 {
1342 self.pty_master = None;
1343 }
1344 self.child = None;
1345 }
1346
1347 fn write_stdin(&mut self, input: &str, close: bool) -> Result<()> {
1348 self.write_stdin_bytes(input.as_bytes(), close)
1349 }
1350
1351 fn write_stdin_bytes(&mut self, input: &[u8], close: bool) -> Result<()> {
1352 if let Some(stdin) = self.stdin.as_mut() {
1353 if !input.is_empty() {
1354 stdin.write_all(input).context("Failed to write to stdin")?;
1355 stdin.flush().context("Failed to flush stdin")?;
1356 }
1357 if close {
1358 self.stdin = None;
1359 }
1360 return Ok(());
1361 }
1362
1363 if input.is_empty() && close {
1364 return Ok(());
1365 }
1366
1367 Err(anyhow!("stdin is not available for task {}", self.id))
1368 }
1369
1370 fn full_output(&self) -> (String, String, usize, usize) {
1371 if let Some(snapshot) = self.bounded_output_snapshot(false).ok().flatten() {
1372 return (snapshot.content, String::new(), snapshot.total_bytes, 0);
1373 }
1374 let (stdout_bytes, stderr_bytes, stdout_omitted, stderr_omitted) =
1375 self.retained_output_bytes_with_omissions();
1376 // Report what the stream produced, not what is still held.
1377 let stdout_len = stdout_bytes.len().saturating_add(stdout_omitted);
1378 let stderr_len = stderr_bytes.len().saturating_add(stderr_omitted);
1379
1380 (
1381 String::from_utf8_lossy(&stdout_bytes).to_string(),
1382 String::from_utf8_lossy(&stderr_bytes).to_string(),
1383 stdout_len,
1384 stderr_len,
1385 )
1386 }
1387
1388 /// Retained bytes for both streams plus how many leading bytes the memory
1389 /// bound discarded. Callers that publish these bytes as evidence must
1390 /// declare the omission rather than presenting a clipped stream as exact.
1391 fn retained_output_bytes_with_omissions(&self) -> (Vec<u8>, Vec<u8>, usize, usize) {
1392 if let Some(snapshot) = self.bounded_output_snapshot(false).ok().flatten() {
1393 let omitted = snapshot.total_bytes.saturating_sub(snapshot.retained_bytes);
1394 return (snapshot.content.into_bytes(), Vec::new(), omitted, 0);
1395 }
1396 let (stdout_bytes, stdout_omitted) = self
1397 .stdout_buffer
1398 .lock()
1399 .map(|data| (data.retained().to_vec(), data.dropped()))
1400 .unwrap_or_default();
1401 let (stderr_bytes, stderr_omitted) = self
1402 .stderr_buffer
1403 .as_ref()
1404 .and_then(|buffer| {
1405 buffer
1406 .lock()
1407 .ok()
1408 .map(|data| (data.retained().to_vec(), data.dropped()))
1409 })
1410 .unwrap_or_default();
1411 (stdout_bytes, stderr_bytes, stdout_omitted, stderr_omitted)
1412 }
1413
1414 fn take_delta(&mut self) -> (String, String, usize, usize, usize, usize) {
1415 if let Some(snapshot) = self.bounded_output_snapshot(false).ok().flatten() {
1416 let changed = snapshot.total_bytes != self.stdout_cursor;
1417 self.stdout_cursor = snapshot.total_bytes;
1418 if changed {
1419 self.last_output_at = Instant::now();
1420 self.last_observed_output_len = snapshot.total_bytes;
1421 let delta_len = snapshot.content.len();
1422 return (
1423 snapshot.content,
1424 String::new(),
1425 delta_len,
1426 0,
1427 snapshot.total_bytes,
1428 0,
1429 );
1430 }
1431 return (String::new(), String::new(), 0, 0, snapshot.total_bytes, 0);
1432 }
1433 let (stdout_delta, stdout_total) =
1434 take_delta_from_buffer(&self.stdout_buffer, &mut self.stdout_cursor);
1435 let (stderr_delta, stderr_total) = if let Some(buffer) = self.stderr_buffer.as_ref() {
1436 take_delta_from_buffer(buffer, &mut self.stderr_cursor)
1437 } else {
1438 (Vec::new(), 0)
1439 };
1440
1441 let stdout_delta_len = stdout_delta.len();
1442 let stderr_delta_len = stderr_delta.len();
1443
1444 if stdout_delta_len > 0 || stderr_delta_len > 0 {
1445 self.last_output_at = Instant::now();
1446 self.last_observed_output_len = stdout_total.saturating_add(stderr_total);
1447 }
1448
1449 (
1450 String::from_utf8_lossy(&stdout_delta).to_string(),
1451 String::from_utf8_lossy(&stderr_delta).to_string(),
1452 stdout_delta_len,
1453 stderr_delta_len,
1454 stdout_total,
1455 stderr_total,
1456 )
1457 }
1458
1459 fn sandbox_denied(&self) -> bool {
1460 if matches!(self.status, ShellStatus::Running) {
1461 return false;
1462 }
1463 let (_, stderr_full, _, _) = self.full_output();
1464 SandboxManager::was_denied(
1465 self.sandbox_type,
1466 self.exit_code
1467 .and_then(|code| i32::try_from(code).ok())
1468 .unwrap_or(-1),
1469 &stderr_full,
1470 )
1471 }
1472
1473 /// Kill the process
1474 fn kill(&mut self) -> Result<()> {
1475 #[cfg(unix)]
1476 if self.ownership == ShellOwnership::PersistPending
1477 && let Some(process_group_id) = self.child.as_ref().and_then(ShellChild::process_id)
1478 {
1479 unregister_pending_persistent_process_group(process_group_id);
1480 }
1481 if let Some(ref mut child) = self.child {
1482 match child {
1483 ShellChild::Process(proc) => {
1484 #[cfg(windows)]
1485 {
1486 terminate_windows_job(self.windows_job.as_ref(), proc)
1487 .context("Failed to kill process tree")?;
1488 let _ = proc.wait();
1489 }
1490 #[cfg(all(not(windows), unix))]
1491 {
1492 // Bounded SIGTERM → SIGKILL escalation against the
1493 // whole process group; returns within ~grace even if
1494 // the command ignores SIGTERM (#52).
1495 terminate_child_process_group(proc).context("Failed to kill process")?;
1496 }
1497 #[cfg(all(not(windows), not(unix)))]
1498 {
1499 proc.kill().context("Failed to kill process")?;
1500 let _ = proc.wait();
1501 }
1502 }
1503 #[cfg(not(target_env = "ohos"))]
1504 ShellChild::Pty(child) => {
1505 child.kill().context("Failed to kill process")?;
1506 let _ = child.wait();
1507 }
1508 }
1509 }
1510 self.status = ShellStatus::Killed;
1511 self.mark_finished();
1512 self.heavy_permit.take();
1513 self.collect_output();
1514 self.publish_lifecycle_best_effort();
1515 Ok(())
1516 }
1517
1518 /// Get a snapshot of the current state
1519 pub fn snapshot(&self) -> Result<ShellResult> {
1520 let sandboxed = !matches!(self.sandbox_type, SandboxType::None);
1521 if let Some(snapshot) = self.bounded_output_snapshot(self.status != ShellStatus::Running)? {
1522 return Ok(ShellResult {
1523 task_id: Some(self.id.clone()),
1524 status: self.status.clone(),
1525 exit_code: self.exit_code,
1526 stdout: snapshot.content,
1527 stderr: String::new(),
1528 duration_ms: self.wall_millis(),
1529 stdout_len: snapshot.total_bytes,
1530 stderr_len: 0,
1531 stdout_omitted: snapshot.total_bytes.saturating_sub(snapshot.retained_bytes),
1532 stderr_omitted: 0,
1533 stdout_truncated: snapshot.truncated,
1534 stderr_truncated: false,
1535 sandboxed,
1536 sandbox_type: sandboxed.then(|| self.sandbox_type.to_string()),
1537 sandbox_denied: false,
1538 });
1539 }
1540 let (stdout_full, stderr_full, stdout_total, stderr_total) = self.full_output();
1541 let (stdout, stdout_meta) = truncate_with_meta(&stdout_full);
1542 let (stderr, stderr_meta) = truncate_with_meta(&stderr_full);
1543 // `truncate_with_meta` can only see the bytes still held. Fold in what
1544 // the in-memory bound dropped so a >16 MiB stream reports its real
1545 // length and its real omission instead of silently shrinking (#5472).
1546 let stdout_dropped = stdout_total.saturating_sub(stdout_meta.original_len);
1547 let stderr_dropped = stderr_total.saturating_sub(stderr_meta.original_len);
1548 Ok(ShellResult {
1549 task_id: Some(self.id.clone()),
1550 status: self.status.clone(),
1551 exit_code: self.exit_code,
1552 stdout,
1553 stderr,
1554 duration_ms: self.wall_millis(),
1555 stdout_len: stdout_total,
1556 stderr_len: stderr_total,
1557 stdout_omitted: stdout_meta.omitted.saturating_add(stdout_dropped),
1558 stderr_omitted: stderr_meta.omitted.saturating_add(stderr_dropped),
1559 stdout_truncated: stdout_meta.truncated || stdout_dropped > 0,
1560 stderr_truncated: stderr_meta.truncated || stderr_dropped > 0,
1561 sandboxed,
1562 sandbox_type: if sandboxed {
1563 Some(self.sandbox_type.to_string())
1564 } else {
1565 None
1566 },
1567 sandbox_denied: self.sandbox_denied(),
1568 })
1569 }
1570
1571 fn bounded_output_snapshot(&self, finalize: bool) -> Result<Option<BoundedOutputSnapshot>> {
1572 self.bounded_output
1573 .as_ref()
1574 .map(|output| {
1575 output
1576 .lock()
1577 .unwrap_or_else(|error| error.into_inner())
1578 .snapshot(finalize)
1579 .map_err(anyhow::Error::from)
1580 })
1581 .transpose()
1582 }
1583
1584 fn job_snapshot(&self) -> ShellJobSnapshot {
1585 // Use tail_from_buffer instead of full_output so we never clone the
1586 // entire accumulated stdout/stderr for display purposes. full_output
1587 // is O(total_bytes_written), which caused the ShellManager mutex to be
1588 // held for an arbitrarily long time during list_jobs() calls from the
1589 // TUI event loop — freezing input handling on long automation runs.
1590 let (stdout_len, stdout_tail) =
1591 if let Some(snapshot) = self.bounded_output_snapshot(false).ok().flatten() {
1592 (snapshot.total_bytes, tail_text(&snapshot.content, 1_200))
1593 } else {
1594 tail_from_buffer(&self.stdout_buffer, 1200)
1595 };
1596 let (stderr_len, stderr_tail) = self
1597 .stderr_buffer
1598 .as_ref()
1599 .map(|buf| tail_from_buffer(buf, 1200))
1600 .unwrap_or((0, String::new()));
1601 let elapsed_since_output_ms = (self.status == ShellStatus::Running)
1602 .then(|| u64::try_from(self.last_output_at.elapsed().as_millis()).unwrap_or(u64::MAX));
1603 let stale = elapsed_since_output_ms.is_some_and(|elapsed| {
1604 elapsed >= u64::try_from(STALE_NO_OUTPUT_AFTER.as_millis()).unwrap_or(u64::MAX)
1605 });
1606 ShellJobSnapshot {
1607 id: self.id.clone(),
1608 job_id: self.id.clone(),
1609 command: self.command.clone(),
1610 cwd: self.working_dir.clone(),
1611 status: self.status.clone(),
1612 exit_code: self.exit_code,
1613 elapsed_ms: self.wall_millis(),
1614 stdout_tail,
1615 stderr_tail,
1616 stdout_len,
1617 stderr_len,
1618 stdin_available: self.stdin.is_some() && self.status == ShellStatus::Running,
1619 stale,
1620 elapsed_since_output_ms,
1621 linked_task_id: self.linked_task_id.clone(),
1622 owner_agent_id: self
1623 .owner_agent
1624 .as_ref()
1625 .map(|owner| owner.agent_id.clone()),
1626 owner_agent_name: self
1627 .owner_agent
1628 .as_ref()
1629 .map(|owner| owner.agent_name.clone()),
1630 origin_tool_call_id: self.origin_tool_call_id.clone(),
1631 origin_turn_id: self.origin_turn_id.clone(),
1632 owner_session_id: self.owner_session_id.clone(),
1633 }
1634 }
1635
1636 fn completion_event(&self) -> ShellCompletionEvent {
1637 let snapshot = self.job_snapshot();
1638 let (stdout_len, stdout_tail) =
1639 if let Some(output) = self.bounded_output_snapshot(false).ok().flatten() {
1640 (
1641 output.total_bytes,
1642 tail_text(&output.content, SHELL_COMPLETION_TAIL_BYTES),
1643 )
1644 } else {
1645 bounded_completion_tail(&self.stdout_buffer, SHELL_COMPLETION_TAIL_BYTES)
1646 };
1647 let (stderr_len, stderr_tail) = self
1648 .stderr_buffer
1649 .as_ref()
1650 .map(|buffer| bounded_completion_tail(buffer, SHELL_COMPLETION_TAIL_BYTES))
1651 .unwrap_or((0, String::new()));
1652 ShellCompletionEvent {
1653 task_id: snapshot.id,
1654 command: snapshot.command,
1655 status: snapshot.status,
1656 exit_code: snapshot.exit_code,
1657 duration_ms: snapshot.elapsed_ms,
1658 stdout_tail,
1659 stderr_tail,
1660 stdout_len,
1661 stderr_len,
1662 evidence_ref: None,
1663 linked_task_id: snapshot.linked_task_id,
1664 owner_agent_id: snapshot.owner_agent_id,
1665 owner_agent_name: snapshot.owner_agent_name,
1666 origin_tool_call_id: snapshot.origin_tool_call_id,
1667 origin_turn_id: snapshot.origin_turn_id,
1668 owner_session_id: snapshot.owner_session_id,
1669 }
1670 }
1671
1672 fn completion_evidence(&self) -> ShellCompletionEvidence {
1673 let event = self.completion_event();
1674 let (stdout, stderr, stdout_omitted, stderr_omitted) =
1675 self.retained_output_bytes_with_omissions();
1676 ShellCompletionEvidence {
1677 event,
1678 stdout,
1679 stderr,
1680 stdout_omitted,
1681 stderr_omitted,
1682 }
1683 }
1684
1685 fn job_detail(&self) -> ShellJobDetail {
1686 let (stdout, stderr, _, _) = self.full_output();
1687 ShellJobDetail {
1688 snapshot: self.job_snapshot(),
1689 stdout,
1690 stderr,
1691 }
1692 }
1693 }
1694
1695 fn finish_background_reader(
1696 handle: std::thread::JoinHandle<()>,
1697 status: &ShellStatus,
1698 buffer: Option<&SharedRawOutput>,
1699 ) {
1700 // A killed Windows process can leave a pipe reader blocked even after its
1701 // Job Object has been closed. Cancellation must return promptly instead of
1702 // waiting for that reader to observe EOF. Other terminal states still join
1703 // so their final output is collected before the shell is discarded.
1704 #[cfg(windows)]
1705 if *status == ShellStatus::Killed {
1706 drop(handle);
1707 return;
1708 }
1709
1710 #[cfg(not(windows))]
1711 let _ = status;
1712
1713 // Bounded join (#52): after the process group is killed the reader
1714 // normally sees EOF immediately, but a descendant that escaped the group
1715 // (its own session/process group) keeps its inherited pipe write-end
1716 // open, so the reader stays blocked until that descendant exits on its
1717 // own. Joining unboundedly froze the foreground shell — and, through the
1718 // shell-manager lock, every other shell — for minutes. On timeout the
1719 // join is handed to a helper thread and we return; the reader thread
1720 // still finishes on its own once the pipe finally closes.
1721 let (done_tx, done_rx) = std::sync::mpsc::channel();
1722 std::thread::spawn(move || {
1723 let _ = handle.join();
1724 let _ = done_tx.send(());
1725 });
1726 if done_rx.recv_timeout(READER_JOIN_GRACE).is_ok() {
1727 return;
1728 }
1729 // The reader is still blocked in `read()` on a pipe a descendant refuses to
1730 // close. Previously both it and the helper thread above stayed alive for the
1731 // life of the process, the reader still appending into a buffer nobody would
1732 // ever read (#5472 finding 2). Abandoning the stream releases what it holds
1733 // and gives the reader an exit on its next wakeup, which also lets the
1734 // helper's `join` return.
1735 if let Some(buffer) = buffer {
1736 buffer
1737 .lock()
1738 .unwrap_or_else(|poison| poison.into_inner())
1739 .abandon();
1740 }
1741 }
1742
1743 impl Drop for BackgroundShell {
1744 fn drop(&mut self) {
1745 #[cfg(unix)]
1746 if self.ownership == ShellOwnership::PersistPending
1747 && let Some(process_group_id) = self.child.as_ref().and_then(ShellChild::process_id)
1748 {
1749 unregister_pending_persistent_process_group(process_group_id);
1750 }
1751 if self.ownership != ShellOwnership::Released
1752 && self.status == ShellStatus::Running
1753 && let Some(ref mut child) = self.child
1754 {
1755 #[cfg(windows)]
1756 match child {
1757 ShellChild::Process(proc) => {
1758 let _ = terminate_windows_job(self.windows_job.as_ref(), proc);
1759 }
1760 #[cfg(not(target_env = "ohos"))]
1761 ShellChild::Pty(child) => {
1762 let _ = child.kill();
1763 }
1764 }
1765 #[cfg(all(not(windows), unix))]
1766 {
1767 let _ = child.kill();
1768 match child {
1769 ShellChild::Process(proc) => {
1770 let _ = wait_child_bounded(proc, KILL_REAP_GRACE);
1771 }
1772 #[cfg(not(target_env = "ohos"))]
1773 ShellChild::Pty(child) => {
1774 let _ = child.wait();
1775 }
1776 }
1777 }
1778 #[cfg(all(not(windows), not(unix)))]
1779 {
1780 let _ = child.kill();
1781 let _ = child.wait();
1782 }
1783 }
1784 }
1785 }
1786
1787 #[cfg(all(unix, not(target_env = "ohos")))]
1788 pub(crate) fn inherited_interactive_terminal_refusal() -> Option<&'static str> {
1789 Some(
1790 "Inherited interactive terminal takeover is unavailable on Unix because foreground TTY \
1791 ownership cannot be transferred safely. Use Bash with `background: true, tty: true`, \
1792 then continue it with `action: \"interact\"` and the returned `task_id`; alternatively \
1793 use `terminal/run` and `terminal/send`, or launch the command in a new terminal. For \
1794 non-interactive work, omit `interactive: true`.",
1795 )
1796 }
1797
1798 #[cfg(all(unix, target_env = "ohos"))]
1799 pub(crate) fn inherited_interactive_terminal_refusal() -> Option<&'static str> {
1800 Some(
1801 "Inherited interactive terminal takeover is unavailable on Unix because foreground TTY \
1802 ownership cannot be transferred safely. Launch the command in a new terminal, or omit \
1803 `interactive: true` for non-interactive work.",
1804 )
1805 }
1806
1807 #[cfg(not(unix))]
1808 pub(crate) fn inherited_interactive_terminal_refusal() -> Option<&'static str> {
1809 None
1810 }
1811
1812 /// Manages background shell processes with optional sandboxing.
1813 pub struct ShellManager {
1814 processes: HashMap<String, BackgroundShell>,
1815 stale_jobs: HashMap<String, ShellJobSnapshot>,
1816 default_workspace: PathBuf,
1817 sandbox_manager: SandboxManager,
1818 sandbox_policy: ExecutionSandboxPolicy,
1819 foreground_background_requested: bool,
1820 /// Directory for lowercase-`bash` complete-output spill files
1821 /// (`None` = process temp dir). Overridable so tests can fault-inject a
1822 /// missing/unwritable spill location.
1823 output_spill_dir: Option<PathBuf>,
1824 }
1825
1826 impl std::fmt::Debug for ShellManager {
1827 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1828 f.debug_struct("ShellManager")
1829 .field("processes", &self.processes.len())
1830 .field("stale_jobs", &self.stale_jobs.len())
1831 .field("default_workspace", &self.default_workspace)
1832 .field("sandbox_policy", &self.sandbox_policy)
1833 .field(
1834 "foreground_background_requested",
1835 &self.foreground_background_requested,
1836 )
1837 .finish()
1838 }
1839 }
1840
1841 impl ShellManager {
1842 fn require_session_owner(&self, task_id: &str, active_session_id: &str) -> Result<()> {
1843 let owned = self.processes.get(task_id).is_some_and(|shell| {
1844 !active_session_id.is_empty() && shell.owner_session_id == active_session_id
1845 }) || self.stale_jobs.get(task_id).is_some_and(|job| {
1846 !active_session_id.is_empty() && job.owner_session_id == active_session_id
1847 });
1848 if owned {
1849 Ok(())
1850 } else {
1851 // Do not disclose whether the id exists in another session.
1852 Err(anyhow!("Job {task_id} not found"))
1853 }
1854 }
1855
1856 /// Create a new `ShellManager` with default (no sandbox) policy.
1857 pub fn new(workspace: PathBuf) -> Self {
1858 Self {
1859 processes: HashMap::new(),
1860 stale_jobs: HashMap::new(),
1861 default_workspace: workspace,
1862 sandbox_manager: SandboxManager::new(),
1863 sandbox_policy: ExecutionSandboxPolicy::default(),
1864 foreground_background_requested: false,
1865 output_spill_dir: None,
1866 }
1867 }
1868
1869 /// Point lowercase-`bash` complete-output spill files at `dir` instead of
1870 /// the process temp dir. Tests use a nonexistent dir to simulate a full or
1871 /// broken temp volume. (unix-only: the regression test that uses it drives
1872 /// a POSIX shell loop.)
1873 #[cfg(all(test, unix))]
1874 pub(crate) fn set_output_spill_dir_for_test(&mut self, dir: Option<PathBuf>) {
1875 self.output_spill_dir = dir;
1876 }
1877
1878 /// Insert a finished job without spawning. Count-bound tests would
1879 /// otherwise pay for 100+ live shells.
1880 #[cfg(test)]
1881 pub(crate) fn seed_finished_record_for_test(&mut self, id: impl Into<String>, age: Duration) {
1882 let now = Instant::now();
1883 let started_at = now.checked_sub(age).unwrap_or(now);
1884 let id = id.into();
1885 self.processes.insert(
1886 id.clone(),
1887 BackgroundShell {
1888 id,
1889 command: String::new(),
1890 working_dir: self.default_workspace.clone(),
1891 status: ShellStatus::Completed,
1892 exit_code: Some(0),
1893 started_at,
1894 finished_at: Some(now),
1895 last_output_at: now,
1896 last_observed_output_len: 0,
1897 sandbox_type: SandboxType::None,
1898 linked_task_id: None,
1899 owner_agent: None,
1900 owner_session_id: String::new(),
1901 origin_tool_call_id: None,
1902 origin_turn_id: None,
1903 ownership: ShellOwnership::Managed,
1904 stdout_buffer: new_shared_raw_output(),
1905 stderr_buffer: Some(new_shared_raw_output()),
1906 bounded_output: None,
1907 heavy_permit: None,
1908 stdout_cursor: 0,
1909 stderr_cursor: 0,
1910 completion_reported: false,
1911 stdin: None,
1912 #[cfg(not(target_env = "ohos"))]
1913 pty_master: None,
1914 terminal_size: None,
1915 child: None,
1916 #[cfg(windows)]
1917 windows_job: None,
1918 stdout_thread: None,
1919 stderr_thread: None,
1920 work_lifecycle: None,
1921 lifecycle_seq: 0,
1922 last_lifecycle_status: None,
1923 last_lifecycle_bytes: 0,
1924 },
1925 );
1926 }
1927
1928 /// Test-only observation of the workspace selected by runtime rebuilds.
1929 #[cfg(test)]
1930 pub(crate) fn default_workspace(&self) -> &Path {
1931 &self.default_workspace
1932 }
1933
1934 /// Enable or disable bubblewrap passthrough (#2184).
1935 ///
1936 /// When enabled and `/usr/bin/bwrap` is executable on Linux, exec_shell
1937 /// commands are routed through bubblewrap for filesystem isolation.
1938 pub fn set_prefer_bwrap(&mut self, prefer: bool) {
1939 self.sandbox_manager.set_prefer_bwrap(prefer);
1940 }
1941
1942 /// Move the fallback working directory. Callers that pass an explicit
1943 /// `working_dir` are unaffected; this only keeps `None` honest when a
1944 /// thread's workspace changes while its jobs are still tracked here.
1945 pub fn set_default_workspace(&mut self, workspace: PathBuf) {
1946 self.default_workspace = workspace;
1947 }
1948
1949 /// Set user-configured bwrap mount extensions (#5410): extra read-only
1950 /// roots and writable device nodes such as `/dev/null`.
1951 pub fn set_bwrap_extensions(&mut self, extensions: crate::sandbox::BwrapMountExtensions) {
1952 self.sandbox_manager.set_bwrap_extensions(extensions);
1953 }
1954
1955 /// Forward the opt-in sandbox read deny-list (S1, #5568) to the sandbox
1956 /// manager; `~` prefixes expand there.
1957 pub fn set_denied_read_subpaths(&mut self, paths: Vec<std::path::PathBuf>) {
1958 self.sandbox_manager.set_denied_read_subpaths(paths);
1959 }
1960
1961 /// Return the OS sandbox wrapper this shell manager is configured and able
1962 /// to apply to commands.
1963 pub fn configured_sandbox_type(&self) -> Option<SandboxType> {
1964 self.sandbox_manager.configured_sandbox()
1965 }
1966
1967 /// Request that the active foreground shell wait detach and leave its
1968 /// process running in the background job table.
1969 pub fn request_foreground_background(&mut self) {
1970 self.foreground_background_requested = true;
1971 }
1972
1973 #[cfg(test)]
1974 pub(crate) fn foreground_background_requested_for_test(&self) -> bool {
1975 self.foreground_background_requested
1976 }
1977
1978 fn clear_foreground_background_request(&mut self) {
1979 self.foreground_background_requested = false;
1980 }
1981
1982 fn take_foreground_background_request(&mut self) -> bool {
1983 let requested = self.foreground_background_requested;
1984 self.foreground_background_requested = false;
1985 requested
1986 }
1987
1988 /// Execute a shell command with stdin/TTY options plus an extra env-var map
1989 /// that is merged into the spawned process environment. Used by the
1990 /// `shell_env` hook injection path (#456).
1991 #[allow(clippy::too_many_arguments)]
1992 #[cfg(test)]
1993 pub fn execute_with_options_env(
1994 &mut self,
1995 command: &str,
1996 working_dir: Option<&str>,
1997 timeout_ms: u64,
1998 background: bool,
1999 stdin_data: Option<&str>,
2000 tty: bool,
2001 policy_override: Option<ExecutionSandboxPolicy>,
2002 extra_env: HashMap<String, String>,
2003 ) -> Result<ShellResult> {
2004 self.execute_with_options_env_for_owner(
2005 command,
2006 working_dir,
2007 timeout_ms,
2008 background,
2009 stdin_data,
2010 tty,
2011 policy_override,
2012 extra_env,
2013 None,
2014 )
2015 }
2016
2017 /// Launch a parent-owned job stamped to the immutable root session.
2018 #[allow(clippy::too_many_arguments)]
2019 pub fn execute_with_options_env_for_session(
2020 &mut self,
2021 command: &str,
2022 working_dir: Option<&str>,
2023 timeout_ms: u64,
2024 background: bool,
2025 stdin_data: Option<&str>,
2026 tty: bool,
2027 policy_override: Option<ExecutionSandboxPolicy>,
2028 extra_env: HashMap<String, String>,
2029 owner_session_id: &str,
2030 ) -> Result<ShellResult> {
2031 self.execute_with_options_env_for_owner_and_work(
2032 command,
2033 working_dir,
2034 timeout_ms,
2035 background,
2036 stdin_data,
2037 tty,
2038 policy_override,
2039 extra_env,
2040 None,
2041 owner_session_id.to_string(),
2042 None,
2043 None,
2044 None,
2045 None,
2046 false,
2047 (1_000, 600_000),
2048 )
2049 }
2050
2051 /// Same as `execute_with_options_env`, with optional background-job owner
2052 /// attribution for sub-agent launched jobs.
2053 #[allow(clippy::too_many_arguments)]
2054 #[cfg(test)]
2055 pub fn execute_with_options_env_for_owner(
2056 &mut self,
2057 command: &str,
2058 working_dir: Option<&str>,
2059 timeout_ms: u64,
2060 background: bool,
2061 stdin_data: Option<&str>,
2062 tty: bool,
2063 policy_override: Option<ExecutionSandboxPolicy>,
2064 extra_env: HashMap<String, String>,
2065 owner_agent: Option<ShellJobOwner>,
2066 ) -> Result<ShellResult> {
2067 self.execute_with_options_env_for_owner_and_work(
2068 command,
2069 working_dir,
2070 timeout_ms,
2071 background,
2072 stdin_data,
2073 tty,
2074 policy_override,
2075 extra_env,
2076 owner_agent,
2077 String::new(),
2078 None,
2079 None,
2080 None,
2081 None,
2082 false,
2083 (1_000, 600_000),
2084 )
2085 }
2086
2087 /// Test-only owner-aware launch with an explicit immutable session owner.
2088 #[allow(clippy::too_many_arguments)]
2089 #[cfg(test)]
2090 pub fn execute_with_options_env_for_owner_and_session(
2091 &mut self,
2092 command: &str,
2093 working_dir: Option<&str>,
2094 timeout_ms: u64,
2095 background: bool,
2096 stdin_data: Option<&str>,
2097 tty: bool,
2098 policy_override: Option<ExecutionSandboxPolicy>,
2099 extra_env: HashMap<String, String>,
2100 owner_agent: Option<ShellJobOwner>,
2101 owner_session_id: &str,
2102 ) -> Result<ShellResult> {
2103 self.execute_with_options_env_for_owner_and_work(
2104 command,
2105 working_dir,
2106 timeout_ms,
2107 background,
2108 stdin_data,
2109 tty,
2110 policy_override,
2111 extra_env,
2112 owner_agent,
2113 owner_session_id.to_string(),
2114 None,
2115 None,
2116 None,
2117 None,
2118 false,
2119 (1_000, 600_000),
2120 )
2121 }
2122
2123 /// Owner-aware execution with an optional Work Graph lifecycle sink.
2124 #[allow(clippy::too_many_arguments)]
2125 fn execute_with_options_env_for_owner_and_work(
2126 &mut self,
2127 command: &str,
2128 working_dir: Option<&str>,
2129 timeout_ms: u64,
2130 background: bool,
2131 stdin_data: Option<&str>,
2132 tty: bool,
2133 policy_override: Option<ExecutionSandboxPolicy>,
2134 extra_env: HashMap<String, String>,
2135 owner_agent: Option<ShellJobOwner>,
2136 owner_session_id: String,
2137 origin_tool_call_id: Option<String>,
2138 origin_turn_id: Option<String>,
2139 work_lifecycle: Option<ShellWorkLifecycle>,
2140 readonly_workspace: Option<&std::path::Path>,
2141 persist_pending: bool,
2142 timeout_bounds_ms: (u64, u64),
2143 ) -> Result<ShellResult> {
2144 // Log execution via ShellDispatcher when SHELL_DISPATCHER_LOG is set.
2145 crate::shell_dispatcher::ShellDispatcher::log_exec(command);
2146
2147 let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from);
2148 validate_shell_working_dir(&work_dir, working_dir.is_none())?;
2149
2150 let timeout_ms = timeout_ms.clamp(timeout_bounds_ms.0, timeout_bounds_ms.1);
2151
2152 // Use override policy if provided, otherwise use the manager's policy
2153 let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone());
2154
2155 // Create command spec and prepare sandboxed environment
2156 let spec = if let Some(workspace) = readonly_workspace {
2157 if command.contains('|') {
2158 let piped = hardened_readonly_pipeline(command, workspace)?;
2159 CommandSpec::shell(&piped, work_dir.clone(), Duration::from_millis(timeout_ms))
2160 } else {
2161 let (program, args) = hardened_readonly_argv(command)?;
2162 let program = resolve_readonly_program(&program, workspace)?;
2163 CommandSpec::program(
2164 program
2165 .to_str()
2166 .ok_or_else(|| anyhow!("read-only executable path is not valid UTF-8"))?,
2167 args,
2168 work_dir.clone(),
2169 Duration::from_millis(timeout_ms),
2170 )
2171 }
2172 } else {
2173 CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms))
2174 };
2175 let spec = spec.with_policy(policy).with_env(extra_env);
2176 let exec_env = self.sandbox_manager.prepare(&spec);
2177 if matches!(spec.sandbox_policy, ExecutionSandboxPolicy::ReadOnly)
2178 && readonly_workspace.is_none()
2179 {
2180 // Arbitrary code with a read-only policy needs kernel enforcement;
2181 // only the separately hardened argv subset may run without it.
2182 require_native_readonly_execution(&exec_env)?;
2183 }
2184
2185 if background {
2186 let bounded_output = timeout_bounds_ms == (1, BASH_MAX_TIMEOUT_MS);
2187 self.spawn_background_sandboxed(
2188 command,
2189 &work_dir,
2190 &exec_env,
2191 None,
2192 stdin_data,
2193 tty,
2194 ShellSpawnContext {
2195 owner_agent,
2196 owner_session_id,
2197 origin_tool_call_id,
2198 origin_turn_id,
2199 work_lifecycle,
2200 },
2201 persist_pending,
2202 bounded_output,
2203 )
2204 } else {
2205 if tty {
2206 return Err(anyhow!(
2207 "TTY mode requires background execution (set background: true)."
2208 ));
2209 }
2210 Self::execute_sync_sandboxed(command, &work_dir, timeout_ms, stdin_data, &exec_env)
2211 }
2212 }
2213
2214 /// Interactive variant that accepts extra env vars (#456 shell_env hook).
2215 pub fn execute_interactive_with_policy_env(
2216 &mut self,
2217 command: &str,
2218 working_dir: Option<&str>,
2219 timeout_ms: u64,
2220 policy_override: Option<ExecutionSandboxPolicy>,
2221 extra_env: HashMap<String, String>,
2222 ) -> Result<ShellResult> {
2223 crate::shell_dispatcher::ShellDispatcher::log_exec(command);
2224
2225 // A new Unix process group that inherits the terminal is not its
2226 // foreground owner. Letting it read stdin triggers SIGTTIN; sharing
2227 // Codewhale's group instead would make cooked-mode Ctrl+C terminate
2228 // both parent and child. Until this path owns a complete POSIX job-
2229 // control lease, fail closed before spawning. Persistent PTY tools
2230 // already provide a safe interactive lane without taking over the
2231 // operator's live terminal.
2232 if let Some(message) = inherited_interactive_terminal_refusal() {
2233 return Err(anyhow!(message));
2234 }
2235
2236 let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from);
2237 validate_shell_working_dir(&work_dir, working_dir.is_none())?;
2238
2239 let timeout_ms = timeout_ms.clamp(1000, 600_000);
2240 let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone());
2241
2242 let spec = CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms))
2243 .with_policy(policy)
2244 .with_env(extra_env);
2245 let exec_env = self.sandbox_manager.prepare(&spec);
2246
2247 Self::execute_interactive_sandboxed(command, &work_dir, timeout_ms, &exec_env)
2248 }
2249
2250 /// Execute command synchronously with timeout (sandboxed).
2251 fn execute_sync_sandboxed(
2252 original_command: &str,
2253 working_dir: &std::path::Path,
2254 timeout_ms: u64,
2255 stdin_data: Option<&str>,
2256 exec_env: &ExecEnv,
2257 ) -> Result<ShellResult> {
2258 let started = Instant::now();
2259 let timeout = Duration::from_millis(timeout_ms);
2260 let sandbox_type = exec_env.sandbox_type;
2261 let sandboxed = exec_env.is_sandboxed();
2262
2263 // Build the command from ExecEnv
2264 let program = exec_env.program();
2265 let args = exec_env.args();
2266
2267 let mut cmd = Command::new(program);
2268 crate::utils::suppress_console_window(&mut cmd);
2269 push_shell_args(&mut cmd, program, args);
2270 cmd.current_dir(working_dir)
2271 .stdout(Stdio::piped())
2272 .stderr(Stdio::piped());
2273 #[cfg(unix)]
2274 {
2275 cmd.process_group(0);
2276 }
2277 install_parent_death_signal(&mut cmd);
2278
2279 if stdin_data.is_some() {
2280 cmd.stdin(Stdio::piped());
2281 }
2282
2283 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
2284 remove_readonly_redirect_env(&mut cmd, &exec_env.env);
2285
2286 // Disable raw mode before spawn; restore only if raw mode was active
2287 // on entry (issue #1690).
2288 let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
2289 if raw_mode_was_enabled {
2290 let _ = crossterm::terminal::disable_raw_mode();
2291 }
2292 struct SyncRawModeGuard {
2293 restore: bool,
2294 }
2295 impl Drop for SyncRawModeGuard {
2296 fn drop(&mut self) {
2297 if self.restore {
2298 let _ = crossterm::terminal::enable_raw_mode();
2299 }
2300 }
2301 }
2302 let _guard = SyncRawModeGuard {
2303 restore: raw_mode_was_enabled,
2304 };
2305
2306 let mut child = cmd
2307 .spawn()
2308 .with_context(|| format!("Failed to execute: {original_command}"))?;
2309 #[cfg(windows)]
2310 let windows_job = attach_windows_job(&child, original_command);
2311
2312 if let Some(input) = stdin_data
2313 && let Some(mut stdin) = child.stdin.take()
2314 {
2315 stdin
2316 .write_all(input.as_bytes())
2317 .context("Failed to write to stdin")?;
2318 stdin.flush().ok();
2319 }
2320
2321 let stdout_handle = child.stdout.take().context("Failed to capture stdout")?;
2322 let stderr_handle = child.stderr.take().context("Failed to capture stderr")?;
2323
2324 // Spawn threads to read output. Use bounded receives below so a killed
2325 // or detached descendant that keeps pipe handles open cannot wedge the
2326 // foreground shell path while the global tool lock is held (#2571).
2327 let stdout_rx = spawn_sync_reader_thread(stdout_handle);
2328 let stderr_rx = spawn_sync_reader_thread(stderr_handle);
2329
2330 // Wait with timeout
2331 if let Some(status) = child.wait_timeout(timeout)? {
2332 let status = ShellExitStatus::from_std(status);
2333 #[cfg(unix)]
2334 let _ = kill_child_process_group(&mut child);
2335 #[cfg(windows)]
2336 terminate_and_close_windows_job(windows_job);
2337 let stdout = recv_sync_reader_output(&stdout_rx);
2338 let stderr = recv_sync_reader_output(&stderr_rx);
2339 let stdout_str = String::from_utf8_lossy(&stdout).to_string();
2340 let stderr_str = String::from_utf8_lossy(&stderr).to_string();
2341 let exit_code = status
2342 .code
2343 .and_then(|code| i32::try_from(code).ok())
2344 .unwrap_or(-1);
2345
2346 // Check if sandbox denied the operation
2347 let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
2348 let (stdout, stdout_meta) = truncate_with_meta(&stdout_str);
2349 let (stderr, stderr_meta) = truncate_with_meta(&stderr_str);
2350
2351 Ok(ShellResult {
2352 task_id: None,
2353 status: if status.success {
2354 ShellStatus::Completed
2355 } else {
2356 ShellStatus::Failed
2357 },
2358 exit_code: status.code,
2359 stdout,
2360 stderr,
2361 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
2362 stdout_len: stdout_meta.original_len,
2363 stderr_len: stderr_meta.original_len,
2364 stdout_omitted: stdout_meta.omitted,
2365 stderr_omitted: stderr_meta.omitted,
2366 stdout_truncated: stdout_meta.truncated,
2367 stderr_truncated: stderr_meta.truncated,
2368 sandboxed,
2369 sandbox_type: if sandboxed {
2370 Some(sandbox_type.to_string())
2371 } else {
2372 None
2373 },
2374 sandbox_denied,
2375 })
2376 } else {
2377 // Timeout - kill the process
2378 #[cfg(unix)]
2379 let _ = kill_child_process_group(&mut child);
2380 #[cfg(windows)]
2381 let _ = terminate_child_and_close_windows_job(windows_job, &mut child);
2382 #[cfg(all(not(unix), not(windows)))]
2383 let _ = child.kill();
2384 let status = child.wait().ok();
2385 let stdout = recv_sync_reader_output(&stdout_rx);
2386 let stderr = recv_sync_reader_output(&stderr_rx);
2387 let stdout_str = String::from_utf8_lossy(&stdout).to_string();
2388 let stderr_str = String::from_utf8_lossy(&stderr).to_string();
2389 let (stdout, stdout_meta) = truncate_with_meta(&stdout_str);
2390 let (stderr, stderr_meta) = truncate_with_meta(&stderr_str);
2391
2392 Ok(ShellResult {
2393 task_id: None,
2394 status: ShellStatus::TimedOut,
2395 exit_code: status
2396 .map(ShellExitStatus::from_std)
2397 .and_then(|status| status.code),
2398 stdout,
2399 stderr,
2400 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
2401 stdout_len: stdout_meta.original_len,
2402 stderr_len: stderr_meta.original_len,
2403 stdout_omitted: stdout_meta.omitted,
2404 stderr_omitted: stderr_meta.omitted,
2405 stdout_truncated: stdout_meta.truncated,
2406 stderr_truncated: stderr_meta.truncated,
2407 sandboxed,
2408 sandbox_type: if sandboxed {
2409 Some(sandbox_type.to_string())
2410 } else {
2411 None
2412 },
2413 sandbox_denied: false,
2414 })
2415 }
2416 }
2417
2418 /// Execute command interactively with timeout (sandboxed).
2419 fn execute_interactive_sandboxed(
2420 original_command: &str,
2421 working_dir: &std::path::Path,
2422 timeout_ms: u64,
2423 exec_env: &ExecEnv,
2424 ) -> Result<ShellResult> {
2425 let started = Instant::now();
2426 let timeout = Duration::from_millis(timeout_ms);
2427 let sandbox_type = exec_env.sandbox_type;
2428 let sandboxed = exec_env.is_sandboxed();
2429
2430 let program = exec_env.program();
2431 let args = exec_env.args();
2432
2433 let mut cmd = Command::new(program);
2434 crate::utils::suppress_console_window(&mut cmd);
2435 push_shell_args(&mut cmd, program, args);
2436 cmd.current_dir(working_dir)
2437 .stdin(Stdio::inherit())
2438 .stdout(Stdio::inherit())
2439 .stderr(Stdio::inherit());
2440 #[cfg(unix)]
2441 {
2442 cmd.process_group(0);
2443 }
2444 install_parent_death_signal(&mut cmd);
2445
2446 // Disable raw mode before spawn; restore only if raw mode was active
2447 // on entry (issue #1690).
2448 let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
2449 if raw_mode_was_enabled {
2450 let _ = crossterm::terminal::disable_raw_mode();
2451 }
2452 struct InteractiveRawModeGuard {
2453 restore: bool,
2454 }
2455 impl Drop for InteractiveRawModeGuard {
2456 fn drop(&mut self) {
2457 if self.restore {
2458 let _ = crossterm::terminal::enable_raw_mode();
2459 }
2460 }
2461 }
2462 let _guard = InteractiveRawModeGuard {
2463 restore: raw_mode_was_enabled,
2464 };
2465
2466 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
2467
2468 let mut child = cmd
2469 .spawn()
2470 .with_context(|| format!("Failed to execute: {original_command}"))?;
2471 #[cfg(windows)]
2472 let windows_job = attach_windows_job(&child, original_command);
2473
2474 if let Some(status) = child.wait_timeout(timeout)? {
2475 let status = ShellExitStatus::from_std(status);
2476 #[cfg(windows)]
2477 terminate_and_close_windows_job(windows_job);
2478 Ok(ShellResult {
2479 task_id: None,
2480 status: if status.success {
2481 ShellStatus::Completed
2482 } else {
2483 ShellStatus::Failed
2484 },
2485 exit_code: status.code,
2486 stdout: String::new(),
2487 stderr: String::new(),
2488 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
2489 stdout_len: 0,
2490 stderr_len: 0,
2491 stdout_omitted: 0,
2492 stderr_omitted: 0,
2493 stdout_truncated: false,
2494 stderr_truncated: false,
2495 sandboxed,
2496 sandbox_type: if sandboxed {
2497 Some(sandbox_type.to_string())
2498 } else {
2499 None
2500 },
2501 sandbox_denied: false,
2502 })
2503 } else {
2504 #[cfg(unix)]
2505 let _ = kill_child_process_group(&mut child);
2506 #[cfg(windows)]
2507 let _ = terminate_child_and_close_windows_job(windows_job, &mut child);
2508 #[cfg(all(not(unix), not(windows)))]
2509 let _ = child.kill();
2510 let status = child.wait().ok();
2511
2512 Ok(ShellResult {
2513 task_id: None,
2514 status: ShellStatus::TimedOut,
2515 exit_code: status
2516 .map(ShellExitStatus::from_std)
2517 .and_then(|status| status.code),
2518 stdout: String::new(),
2519 stderr: String::new(),
2520 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
2521 stdout_len: 0,
2522 stderr_len: 0,
2523 stdout_omitted: 0,
2524 stderr_omitted: 0,
2525 stdout_truncated: false,
2526 stderr_truncated: false,
2527 sandboxed,
2528 sandbox_type: if sandboxed {
2529 Some(sandbox_type.to_string())
2530 } else {
2531 None
2532 },
2533 sandbox_denied: false,
2534 })
2535 }
2536 }
2537
2538 /// Spawn a background process (sandboxed).
2539 #[allow(clippy::too_many_arguments)]
2540 fn spawn_background_sandboxed(
2541 &mut self,
2542 original_command: &str,
2543 working_dir: &std::path::Path,
2544 exec_env: &ExecEnv,
2545 heavy_permit: Option<HeavyCommandPermit>,
2546 stdin_data: Option<&str>,
2547 tty: bool,
2548 spawn_context: ShellSpawnContext,
2549 persist_pending: bool,
2550 small_contract_mode: bool,
2551 ) -> Result<ShellResult> {
2552 let ShellSpawnContext {
2553 owner_agent,
2554 owner_session_id,
2555 origin_tool_call_id,
2556 origin_turn_id,
2557 work_lifecycle,
2558 } = spawn_context;
2559 let task_id = format!("shell_{}", &Uuid::new_v4().to_string()[..8]);
2560 let mut spawn_guard =
2561 ShellSpawnIntentGuard::new(work_lifecycle.clone(), &task_id, original_command);
2562 let started = Instant::now();
2563 let sandbox_type = exec_env.sandbox_type;
2564 let sandboxed = exec_env.is_sandboxed();
2565
2566 // Build the command from ExecEnv
2567 let program = exec_env.program();
2568 let args = exec_env.args();
2569
2570 #[cfg(target_env = "ohos")]
2571 if tty {
2572 return Err(anyhow!(
2573 "TTY shell mode is not supported on HarmonyOS/OpenHarmony yet."
2574 ));
2575 }
2576
2577 let stdout_buffer = new_shared_raw_output();
2578 let stderr_buffer = if tty || persist_pending || small_contract_mode {
2579 None
2580 } else {
2581 Some(new_shared_raw_output())
2582 };
2583 // The spill file is best-effort: a full disk or exhausted descriptor
2584 // table must not make `echo ok` unrunnable (that is exactly how the
2585 // owner's session got wedged under swap exhaustion).
2586 let bounded_output = small_contract_mode.then(|| {
2587 Arc::new(Mutex::new(BoundedOutputAccumulator::new_in(
2588 self.output_spill_dir.as_deref(),
2589 )))
2590 });
2591
2592 #[cfg(windows)]
2593 let mut windows_job = None;
2594
2595 #[cfg(not(target_env = "ohos"))]
2596 let mut pty_master = None;
2597 let (child, stdin, stdout_thread, stderr_thread) = if tty {
2598 #[cfg(target_env = "ohos")]
2599 unreachable!("OHOS TTY mode returns before PTY setup");
2600
2601 #[cfg(not(target_env = "ohos"))]
2602 {
2603 let pty_system = native_pty_system();
2604 let pair = pty_system
2605 .openpty(PtySize {
2606 rows: 24,
2607 cols: 80,
2608 pixel_width: 0,
2609 pixel_height: 0,
2610 })
2611 .context("Failed to open PTY")?;
2612
2613 let mut cmd = CommandBuilder::new(program);
2614 for arg in args {
2615 cmd.arg(arg);
2616 }
2617 cmd.cwd(working_dir);
2618 child_env::apply_to_pty_command(&mut cmd, child_env::string_map_env(&exec_env.env));
2619
2620 let mut child = pair
2621 .slave
2622 .spawn_command(cmd)
2623 .with_context(|| format!("Failed to spawn PTY command: {original_command}"))?;
2624 drop(pair.slave);
2625
2626 let reader = match pair.master.try_clone_reader() {
2627 Ok(reader) => reader,
2628 Err(err) => {
2629 let _ = child.kill();
2630 let _ = child.wait();
2631 return Err(err).context("Failed to clone PTY reader");
2632 }
2633 };
2634 let writer = match pair.master.take_writer() {
2635 Ok(writer) => writer,
2636 Err(err) => {
2637 let _ = child.kill();
2638 let _ = child.wait();
2639 return Err(err).context("Failed to take PTY writer");
2640 }
2641 };
2642 let stdout_thread = Some(spawn_reader_thread(reader, Arc::clone(&stdout_buffer)));
2643 pty_master = Some(pair.master);
2644
2645 (
2646 ShellChild::Pty(child),
2647 Some(StdinWriter::Pty(writer)),
2648 stdout_thread,
2649 None,
2650 )
2651 }
2652 } else if persist_pending {
2653 let mut cmd = Command::new(program);
2654 crate::utils::suppress_console_window(&mut cmd);
2655 push_shell_args(&mut cmd, program, args);
2656 cmd.current_dir(working_dir)
2657 .stdin(Stdio::null())
2658 .stdout(Stdio::null())
2659 .stderr(Stdio::null());
2660 #[cfg(unix)]
2661 {
2662 cmd.process_group(0);
2663 }
2664
2665 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
2666 remove_readonly_redirect_env(&mut cmd, &exec_env.env);
2667
2668 let child = cmd.spawn().with_context(|| {
2669 format!("Failed to spawn persistent service: {original_command}")
2670 })?;
2671 (ShellChild::Process(child), None, None, None)
2672 } else {
2673 let mut cmd = Command::new(program);
2674 crate::utils::suppress_console_window(&mut cmd);
2675 push_shell_args(&mut cmd, program, args);
2676 cmd.current_dir(working_dir).stdin(Stdio::piped());
2677 let combined_reader = if small_contract_mode {
2678 let (reader, stdout, stderr) =
2679 shared_output_pipe().context("Failed to create combined shell output pipe")?;
2680 cmd.stdout(Stdio::from(stdout)).stderr(Stdio::from(stderr));
2681 Some(reader)
2682 } else {
2683 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2684 None
2685 };
2686 #[cfg(unix)]
2687 {
2688 cmd.process_group(0);
2689 }
2690
2691 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
2692 remove_readonly_redirect_env(&mut cmd, &exec_env.env);
2693
2694 let mut child = cmd
2695 .spawn()
2696 .with_context(|| format!("Failed to spawn background: {original_command}"))?;
2697 #[cfg(windows)]
2698 {
2699 windows_job = attach_windows_job(&child, original_command);
2700 }
2701
2702 let stdin_handle = child.stdin.take().map(StdinWriter::Pipe);
2703
2704 let (stdout_thread, stderr_thread) =
2705 if let (Some(reader), Some(output)) = (combined_reader, bounded_output.as_ref()) {
2706 (
2707 Some(spawn_bounded_reader_thread(reader, Arc::clone(output))),
2708 None,
2709 )
2710 } else {
2711 let stdout_handle = child.stdout.take().ok_or_else(|| {
2712 #[cfg(windows)]
2713 terminate_unregistered_process(&mut child, windows_job.as_ref());
2714 #[cfg(not(windows))]
2715 terminate_unregistered_process(&mut child);
2716 anyhow!("Failed to capture stdout")
2717 })?;
2718 let stderr_handle = child.stderr.take().ok_or_else(|| {
2719 #[cfg(windows)]
2720 terminate_unregistered_process(&mut child, windows_job.as_ref());
2721 #[cfg(not(windows))]
2722 terminate_unregistered_process(&mut child);
2723 anyhow!("Failed to capture stderr")
2724 })?;
2725 (
2726 Some(spawn_reader_thread(
2727 stdout_handle,
2728 Arc::clone(&stdout_buffer),
2729 )),
2730 stderr_buffer
2731 .as_ref()
2732 .map(|buffer| spawn_reader_thread(stderr_handle, Arc::clone(buffer))),
2733 )
2734 };
2735
2736 (
2737 ShellChild::Process(child),
2738 stdin_handle,
2739 stdout_thread,
2740 stderr_thread,
2741 )
2742 };
2743
2744 let mut bg_shell = BackgroundShell {
2745 id: task_id.clone(),
2746 command: original_command.to_string(),
2747 working_dir: working_dir.to_path_buf(),
2748 status: ShellStatus::Running,
2749 exit_code: None,
2750 started_at: started,
2751 finished_at: None,
2752 last_output_at: started,
2753 last_observed_output_len: 0,
2754 sandbox_type,
2755 linked_task_id: None,
2756 owner_agent,
2757 owner_session_id,
2758 origin_tool_call_id,
2759 origin_turn_id,
2760 ownership: if persist_pending {
2761 ShellOwnership::PersistPending
2762 } else {
2763 ShellOwnership::Managed
2764 },
2765 stdout_buffer,
2766 stderr_buffer,
2767 bounded_output,
2768 heavy_permit,
2769 stdout_cursor: 0,
2770 stderr_cursor: 0,
2771 completion_reported: false,
2772 stdin,
2773 #[cfg(not(target_env = "ohos"))]
2774 pty_master,
2775 terminal_size: tty.then(PtyDimensions::default),
2776 child: Some(child),
2777 #[cfg(windows)]
2778 windows_job,
2779 stdout_thread,
2780 stderr_thread,
2781 work_lifecycle,
2782 lifecycle_seq: 0,
2783 last_lifecycle_status: None,
2784 last_lifecycle_bytes: 0,
2785 };
2786
2787 #[cfg(unix)]
2788 if persist_pending {
2789 let process_group_id = bg_shell
2790 .child
2791 .as_ref()
2792 .and_then(ShellChild::process_id)
2793 .ok_or_else(|| anyhow!("Persistent service has no process group id"))?;
2794 register_pending_persistent_process_group(process_group_id);
2795 }
2796
2797 if let Some(input) = stdin_data
2798 && let Err(err) = bg_shell.write_stdin(input, false)
2799 {
2800 let _ = bg_shell.kill();
2801 return Err(err);
2802 }
2803
2804 if let Err(err) = bg_shell.publish_lifecycle() {
2805 let _ = bg_shell.kill();
2806 return Err(err);
2807 }
2808
2809 self.processes.insert(task_id.clone(), bg_shell);
2810 spawn_guard.disarm();
2811 // Evict here, not only from `list_jobs()`: retention must not depend on
2812 // the user opening the jobs panel, and every spawn is exactly the moment
2813 // the previous calls' records became one call staler (#5472).
2814 self.cleanup(FINISHED_SHELL_MAX_AGE);
2815
2816 Ok(ShellResult {
2817 task_id: Some(task_id),
2818 status: ShellStatus::Running,
2819 exit_code: None,
2820 stdout: String::new(),
2821 stderr: String::new(),
2822 duration_ms: 0,
2823 stdout_len: 0,
2824 stderr_len: 0,
2825 stdout_omitted: 0,
2826 stderr_omitted: 0,
2827 stdout_truncated: false,
2828 stderr_truncated: false,
2829 sandboxed,
2830 sandbox_type: if sandboxed {
2831 Some(sandbox_type.to_string())
2832 } else {
2833 None
2834 },
2835 sandbox_denied: false,
2836 })
2837 }
2838
2839 /// Get output from a background process
2840 pub fn get_output(
2841 &mut self,
2842 task_id: &str,
2843 block: bool,
2844 timeout_ms: u64,
2845 ) -> Result<ShellResult> {
2846 let shell = self
2847 .processes
2848 .get_mut(task_id)
2849 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
2850
2851 if block && shell.status == ShellStatus::Running {
2852 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
2853 let deadline = Instant::now() + timeout;
2854
2855 while shell.status == ShellStatus::Running && Instant::now() < deadline {
2856 if shell.poll() {
2857 break;
2858 }
2859 std::thread::sleep(Duration::from_millis(100));
2860 }
2861
2862 // If still running after timeout
2863 if shell.status == ShellStatus::Running {
2864 return shell.snapshot();
2865 }
2866 } else {
2867 shell.poll();
2868 }
2869
2870 shell.snapshot()
2871 }
2872
2873 /// Poll a job and return only its status.
2874 ///
2875 /// The foreground wait loop ticks every 100 ms and discards the snapshot
2876 /// unless the job is terminal, but `get_output` → `snapshot` clones both
2877 /// raw buffers to build it. On a command printing 50 MB that was ~1.5 GB of
2878 /// allocate-and-drop churn per wait, all of it thrown away (#5472 finding 1,
2879 /// the transient term). Status is what the loop actually needs.
2880 fn poll_status(&mut self, task_id: &str) -> Result<ShellStatus> {
2881 let shell = self
2882 .processes
2883 .get_mut(task_id)
2884 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
2885 shell.poll();
2886 Ok(shell.status.clone())
2887 }
2888
2889 /// Write data to stdin of a background process.
2890 pub fn write_stdin(&mut self, task_id: &str, input: &str, close: bool) -> Result<()> {
2891 self.write_stdin_bytes(task_id, input.as_bytes(), close)
2892 }
2893
2894 /// Exact input bytes from an authenticated client; never round-trip through UTF-8.
2895 pub fn write_stdin_bytes(&mut self, task_id: &str, input: &[u8], close: bool) -> Result<()> {
2896 let shell = self
2897 .processes
2898 .get_mut(task_id)
2899 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
2900 shell.write_stdin_bytes(input, close)?;
2901 Ok(())
2902 }
2903
2904 /// Historical evicted records have no terminal-size evidence.
2905 pub fn job_terminal_size(&self, task_id: &str) -> Option<PtyDimensions> {
2906 self.processes
2907 .get(task_id)
2908 .and_then(|shell| shell.terminal_size)
2909 }
2910
2911 pub fn resize_pty(&mut self, task_id: &str, size: PtyDimensions) -> Result<()> {
2912 let size = size.validate()?;
2913 let shell = self
2914 .processes
2915 .get_mut(task_id)
2916 .ok_or_else(|| anyhow!("Job {task_id} not found"))?;
2917 shell.poll();
2918 anyhow::ensure!(
2919 shell.status == ShellStatus::Running,
2920 "PTY job is no longer running"
2921 );
2922 #[cfg(not(target_env = "ohos"))]
2923 {
2924 let master = shell.pty_master.as_ref().context("Job is not a PTY")?;
2925 master
2926 .resize(PtySize {
2927 rows: size.rows,
2928 cols: size.cols,
2929 pixel_width: 0,
2930 pixel_height: 0,
2931 })
2932 .context("Failed to resize PTY")?;
2933 shell.terminal_size = Some(size);
2934 Ok(())
2935 }
2936 #[cfg(target_env = "ohos")]
2937 {
2938 let _ = size;
2939 Err(anyhow!("PTY resize is unavailable on this platform"))
2940 }
2941 }
2942
2943 pub fn write_stdin_for_session(
2944 &mut self,
2945 active_session_id: &str,
2946 task_id: &str,
2947 input: &str,
2948 close: bool,
2949 ) -> Result<()> {
2950 self.require_session_owner(task_id, active_session_id)?;
2951 self.write_stdin(task_id, input, close)
2952 }
2953
2954 /// Get incremental output from a background process, consuming any new output.
2955 fn get_output_delta(
2956 &mut self,
2957 task_id: &str,
2958 wait: bool,
2959 timeout_ms: u64,
2960 ) -> Result<ShellDeltaResult> {
2961 let shell = self
2962 .processes
2963 .get_mut(task_id)
2964 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
2965
2966 if wait && shell.status == ShellStatus::Running {
2967 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
2968 let deadline = Instant::now() + timeout;
2969
2970 while shell.status == ShellStatus::Running && Instant::now() < deadline {
2971 if shell.poll() {
2972 break;
2973 }
2974 std::thread::sleep(Duration::from_millis(100));
2975 }
2976 } else {
2977 shell.poll();
2978 }
2979
2980 let (
2981 stdout_delta,
2982 stderr_delta,
2983 stdout_delta_len,
2984 stderr_delta_len,
2985 stdout_total,
2986 stderr_total,
2987 ) = shell.take_delta();
2988 let (stdout, stdout_meta) = truncate_with_meta(&stdout_delta);
2989 let (stderr, stderr_meta) = truncate_with_meta(&stderr_delta);
2990 let sandboxed = !matches!(shell.sandbox_type, SandboxType::None);
2991
2992 let command = shell.command.clone();
2993 let result = ShellResult {
2994 task_id: Some(shell.id.clone()),
2995 status: shell.status.clone(),
2996 exit_code: shell.exit_code,
2997 stdout,
2998 stderr,
2999 duration_ms: u64::try_from(shell.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
3000 stdout_len: stdout_meta.original_len.max(stdout_delta_len),
3001 stderr_len: stderr_meta.original_len.max(stderr_delta_len),
3002 stdout_omitted: stdout_meta.omitted,
3003 stderr_omitted: stderr_meta.omitted,
3004 stdout_truncated: stdout_meta.truncated,
3005 stderr_truncated: stderr_meta.truncated,
3006 sandboxed,
3007 sandbox_type: if sandboxed {
3008 Some(shell.sandbox_type.to_string())
3009 } else {
3010 None
3011 },
3012 sandbox_denied: shell.sandbox_denied(),
3013 };
3014
3015 Ok(ShellDeltaResult {
3016 command,
3017 result,
3018 stdout_total_len: stdout_total,
3019 stderr_total_len: stderr_total,
3020 })
3021 }
3022
3023 fn attach_heavy_permit(&mut self, task_id: &str, permit: HeavyCommandPermit) -> Result<()> {
3024 let shell = self
3025 .processes
3026 .get_mut(task_id)
3027 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
3028 shell.heavy_permit = Some(permit);
3029 Ok(())
3030 }
3031
3032 /// Kill a running background process
3033 pub fn kill(&mut self, task_id: &str) -> Result<ShellResult> {
3034 let shell = self
3035 .processes
3036 .get_mut(task_id)
3037 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
3038
3039 shell.kill()?;
3040 shell.snapshot()
3041 }
3042
3043 pub fn kill_for_session(
3044 &mut self,
3045 active_session_id: &str,
3046 task_id: &str,
3047 ) -> Result<ShellResult> {
3048 self.require_session_owner(task_id, active_session_id)?;
3049 self.kill(task_id)
3050 }
3051
3052 /// Kill every currently running background shell process.
3053 #[cfg(test)]
3054 pub fn kill_running(&mut self) -> Result<Vec<ShellResult>> {
3055 let ids = self
3056 .processes
3057 .iter()
3058 .filter(|(_, shell)| shell.status == ShellStatus::Running)
3059 .map(|(id, _)| id.clone())
3060 .collect::<Vec<_>>();
3061
3062 let mut results = Vec::with_capacity(ids.len());
3063 for id in ids {
3064 results.push(self.kill(&id)?);
3065 }
3066 Ok(results)
3067 }
3068
3069 pub fn kill_running_for_session(
3070 &mut self,
3071 active_session_id: &str,
3072 ) -> Result<Vec<ShellResult>> {
3073 let ids = self
3074 .processes
3075 .iter()
3076 .filter(|(_, shell)| {
3077 shell.status == ShellStatus::Running
3078 && !active_session_id.is_empty()
3079 && shell.owner_session_id == active_session_id
3080 })
3081 .map(|(id, _)| id.clone())
3082 .collect::<Vec<_>>();
3083 let mut results = Vec::with_capacity(ids.len());
3084 for id in ids {
3085 results.push(self.kill(&id)?);
3086 }
3087 Ok(results)
3088 }
3089
3090 /// Transfer every still-running `persist:true` process out of Codewhale's
3091 /// ownership. This is called only by the real headless exec host after the
3092 /// enclosing turn has completed successfully.
3093 #[cfg(unix)]
3094 pub fn commit_persistent_services(&mut self) -> Result<Vec<PersistentServiceReceipt>> {
3095 let mut ids = self
3096 .processes
3097 .iter()
3098 .filter(|(_, shell)| shell.ownership == ShellOwnership::PersistPending)
3099 .map(|(id, _)| id.clone())
3100 .collect::<Vec<_>>();
3101 ids.sort();
3102
3103 for id in &ids {
3104 let shell = self
3105 .processes
3106 .get_mut(id)
3107 .ok_or_else(|| anyhow!("Persistent service {id} disappeared before commit"))?;
3108 shell.poll();
3109 if shell.status != ShellStatus::Running {
3110 return Err(anyhow!(
3111 "Persistent service {id} exited before ownership transfer (status {:?}, exit code {:?})",
3112 shell.status,
3113 shell.exit_code
3114 ));
3115 }
3116 if shell
3117 .child
3118 .as_ref()
3119 .and_then(ShellChild::process_id)
3120 .is_none()
3121 {
3122 return Err(anyhow!(
3123 "Persistent service {id} has no releasable process id"
3124 ));
3125 }
3126 }
3127
3128 let mut receipts = Vec::with_capacity(ids.len());
3129 for id in ids {
3130 let mut shell = self
3131 .processes
3132 .remove(&id)
3133 .ok_or_else(|| anyhow!("Persistent service {id} disappeared during commit"))?;
3134 let pid = shell
3135 .child
3136 .as_ref()
3137 .and_then(ShellChild::process_id)
3138 .ok_or_else(|| anyhow!("Persistent service {id} lost its process id"))?;
3139 unregister_pending_persistent_process_group(pid);
3140 shell.ownership = ShellOwnership::Released;
3141 shell.stdin = None;
3142 shell.heavy_permit.take();
3143 shell.work_lifecycle = None;
3144 receipts.push(PersistentServiceReceipt {
3145 task_id: id,
3146 pid,
3147 process_group_id: pid,
3148 ownership: "external".to_string(),
3149 });
3150 }
3151 Ok(receipts)
3152 }
3153
3154 /// Kill only services waiting for a successful exec ownership transfer.
3155 /// Ordinary background jobs retain their existing manager lifetime.
3156 pub fn abort_persistent_services(&mut self) {
3157 let ids = self
3158 .processes
3159 .iter()
3160 .filter(|(_, shell)| shell.ownership == ShellOwnership::PersistPending)
3161 .map(|(id, _)| id.clone())
3162 .collect::<Vec<_>>();
3163 for id in ids {
3164 if let Err(error) = self.kill(&id) {
3165 tracing::warn!(shell_id = %id, %error, "failed to abort pending persistent service");
3166 }
3167 }
3168 }
3169
3170 /// Poll a background process and return incremental output.
3171 #[cfg(test)]
3172 pub fn poll_delta(
3173 &mut self,
3174 task_id: &str,
3175 wait: bool,
3176 timeout_ms: u64,
3177 ) -> Result<ShellDeltaResult> {
3178 self.get_output_delta(task_id, wait, timeout_ms)
3179 }
3180
3181 pub fn poll_delta_for_session(
3182 &mut self,
3183 active_session_id: &str,
3184 task_id: &str,
3185 wait: bool,
3186 timeout_ms: u64,
3187 ) -> Result<ShellDeltaResult> {
3188 self.require_session_owner(task_id, active_session_id)?;
3189 self.get_output_delta(task_id, wait, timeout_ms)
3190 }
3191
3192 fn get_output_delta_for_session(
3193 &mut self,
3194 active_session_id: &str,
3195 task_id: &str,
3196 wait: bool,
3197 timeout_ms: u64,
3198 ) -> Result<ShellDeltaResult> {
3199 self.require_session_owner(task_id, active_session_id)?;
3200 self.get_output_delta(task_id, wait, timeout_ms)
3201 }
3202
3203 /// Read a job's raw stream at an absolute byte offset without consuming
3204 /// anything. This is the `/v1/jobs` byte-stream contract: HTTP clients hold
3205 /// the cursor, so reads must not disturb the engine's own delta consumer.
3206 ///
3207 /// `cursor` is a byte offset into the stream's lifetime output (matching
3208 /// `total`). When the bounded buffer has already discarded `[0, dropped)`,
3209 /// the window starts at `dropped` instead and the caller sees the gap in
3210 /// the response rather than a replayed tail. With `wait_ms > 0` on a
3211 /// running job, polls up to that bound for new bytes past `cursor` before
3212 /// answering — long-poll instead of a hot loop.
3213 pub fn read_output_chunk(
3214 &mut self,
3215 task_id: &str,
3216 stream: ShellOutputStream,
3217 cursor: usize,
3218 max_bytes: usize,
3219 wait_ms: u64,
3220 ) -> Result<ShellOutputChunk> {
3221 let Some(shell) = self.processes.get_mut(task_id) else {
3222 // Evicted jobs retain only their snapshot tails. Serve that tail as
3223 // the final retained window so a late reader still gets the ending
3224 // of the stream instead of a bare not-found.
3225 let snapshot = self
3226 .stale_jobs
3227 .get(task_id)
3228 .ok_or_else(|| anyhow!("Job {task_id} not found"))?;
3229 let (tail, total) = match stream {
3230 ShellOutputStream::Stdout => (&snapshot.stdout_tail, snapshot.stdout_len),
3231 ShellOutputStream::Stderr => (&snapshot.stderr_tail, snapshot.stderr_len),
3232 };
3233 let tail_start = total.saturating_sub(tail.len());
3234 let offset = cursor.max(tail_start).min(total);
3235 let next_offset = offset.saturating_add(max_bytes).min(total);
3236 return Ok(ShellOutputChunk {
3237 offset,
3238 bytes: tail.as_bytes()[offset - tail_start..next_offset - tail_start].to_vec(),
3239 next_offset,
3240 total,
3241 dropped: tail_start,
3242 status: snapshot.status.clone(),
3243 exit_code: snapshot.exit_code,
3244 });
3245 };
3246 let buffer = match stream {
3247 ShellOutputStream::Stdout => shell.stdout_buffer.clone(),
3248 ShellOutputStream::Stderr => shell
3249 .stderr_buffer
3250 .clone()
3251 .ok_or_else(|| anyhow!("Job {task_id} merges stderr into stdout"))?,
3252 };
3253
3254 let wait_deadline = (wait_ms > 0 && shell.status == ShellStatus::Running)
3255 .then(|| Instant::now() + Duration::from_millis(wait_ms.clamp(50, 30_000)));
3256 loop {
3257 shell.poll();
3258 let total = buffer.lock().map(|guard| guard.total_len()).unwrap_or(0);
3259 let done_waiting = total > cursor
3260 || shell.status != ShellStatus::Running
3261 || wait_deadline.is_none_or(|deadline| Instant::now() >= deadline);
3262 if done_waiting {
3263 break;
3264 }
3265 std::thread::sleep(Duration::from_millis(50));
3266 }
3267
3268 let (bytes, offset, next_offset, total, dropped) = {
3269 let guard = buffer.lock().unwrap_or_else(|e| e.into_inner());
3270 let total = guard.total_len();
3271 let dropped = guard.dropped();
3272 let offset = cursor.max(dropped).min(total);
3273 let next_offset = offset.saturating_add(max_bytes).min(total);
3274 let retained = guard.retained();
3275 let bytes = retained[offset - dropped..next_offset - dropped].to_vec();
3276 (bytes, offset, next_offset, total, dropped)
3277 };
3278 Ok(ShellOutputChunk {
3279 offset,
3280 bytes,
3281 next_offset,
3282 total,
3283 dropped,
3284 status: shell.status.clone(),
3285 exit_code: shell.exit_code,
3286 })
3287 }
3288
3289 /// Attach durable task context to a live shell job.
3290 pub fn tag_linked_task(&mut self, task_id: &str, linked_task_id: Option<String>) -> Result<()> {
3291 let shell = self
3292 .processes
3293 .get_mut(task_id)
3294 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
3295 shell.linked_task_id = linked_task_id;
3296 Ok(())
3297 }
3298
3299 /// Inspect full output for a live or stale job.
3300 pub fn inspect_job(&mut self, task_id: &str) -> Result<ShellJobDetail> {
3301 if let Some(shell) = self.processes.get_mut(task_id) {
3302 shell.poll();
3303 return Ok(shell.job_detail());
3304 }
3305 if let Some(snapshot) = self.stale_jobs.get(task_id) {
3306 return Ok(ShellJobDetail {
3307 snapshot: snapshot.clone(),
3308 stdout: snapshot.stdout_tail.clone(),
3309 stderr: snapshot.stderr_tail.clone(),
3310 });
3311 }
3312 Err(anyhow!("Job {task_id} not found"))
3313 }
3314
3315 pub fn inspect_job_for_session(
3316 &mut self,
3317 active_session_id: &str,
3318 task_id: &str,
3319 ) -> Result<ShellJobDetail> {
3320 self.require_session_owner(task_id, active_session_id)?;
3321 self.inspect_job(task_id)
3322 }
3323
3324 /// List all live and known-stale background shell jobs for the TUI.
3325 pub fn list_jobs(&mut self) -> Vec<ShellJobSnapshot> {
3326 for shell in self.processes.values_mut() {
3327 shell.poll();
3328 }
3329 // Evict completed processes older than 1 hour to bound memory growth.
3330 self.cleanup(FINISHED_SHELL_MAX_AGE);
3331
3332 let mut jobs = self
3333 .processes
3334 .values()
3335 .map(BackgroundShell::job_snapshot)
3336 .collect::<Vec<_>>();
3337 jobs.extend(self.stale_jobs.values().cloned());
3338 jobs.sort_by(|a, b| {
3339 job_status_rank(&a.status, a.stale)
3340 .cmp(&job_status_rank(&b.status, b.stale))
3341 .then_with(|| a.id.cmp(&b.id))
3342 });
3343 jobs
3344 }
3345
3346 pub fn list_jobs_for_session(&mut self, active_session_id: &str) -> Vec<ShellJobSnapshot> {
3347 if active_session_id.is_empty() {
3348 return Vec::new();
3349 }
3350 self.list_jobs()
3351 .into_iter()
3352 .filter(|job| job.owner_session_id == active_session_id)
3353 .collect()
3354 }
3355
3356 /// Whether a finished parent-owned job's completion is waiting to be
3357 /// claimed. Unlike
3358 /// [`Self::may_have_undelivered_completion`] this polls, so it reports
3359 /// readiness the moment the process exits; the engine's idle shell wake
3360 /// uses it to fire exactly when evidence exists.
3361 #[cfg(test)]
3362 pub(crate) fn has_finished_unreported_jobs(&mut self) -> bool {
3363 self.processes.values_mut().any(|shell| {
3364 shell.poll();
3365 shell.owner_agent.is_none()
3366 && shell.status != ShellStatus::Running
3367 && !shell.completion_reported
3368 })
3369 }
3370
3371 pub(crate) fn has_finished_unreported_jobs_for_session(
3372 &mut self,
3373 active_session_id: &str,
3374 ) -> bool {
3375 !active_session_id.is_empty()
3376 && self.processes.values_mut().any(|shell| {
3377 shell.poll();
3378 shell.owner_session_id == active_session_id
3379 && shell.owner_agent.is_none()
3380 && shell.status != ShellStatus::Running
3381 && !shell.completion_reported
3382 })
3383 }
3384
3385 /// Drain once-only completion events together with lossless stream bytes.
3386 /// The engine publishes the bytes outside this manager's mutex and puts
3387 /// only the bounded event plus resulting handle into model context.
3388 #[cfg(test)]
3389 pub(crate) fn drain_finished_jobs_with_evidence(&mut self) -> Vec<ShellCompletionEvidence> {
3390 self.drain_finished_jobs_with_evidence_inner(None)
3391 }
3392
3393 pub(crate) fn drain_finished_jobs_with_evidence_for_session(
3394 &mut self,
3395 active_session_id: &str,
3396 ) -> Vec<ShellCompletionEvidence> {
3397 self.drain_finished_jobs_with_evidence_inner(Some(active_session_id))
3398 }
3399
3400 fn drain_finished_jobs_with_evidence_inner(
3401 &mut self,
3402 active_session_id: Option<&str>,
3403 ) -> Vec<ShellCompletionEvidence> {
3404 let mut completions = Vec::new();
3405 for shell in self.processes.values_mut() {
3406 shell.poll();
3407 let owned = active_session_id.is_none_or(|session_id| {
3408 !session_id.is_empty() && shell.owner_session_id == session_id
3409 });
3410 if owned && shell.status != ShellStatus::Running && !shell.completion_reported {
3411 shell.completion_reported = true;
3412 completions.push(shell.completion_evidence());
3413 // The bytes are now in the caller's hands (they become a durable
3414 // session artifact). Holding a second copy here for the rest of
3415 // the retention hour is what #5472 measured.
3416 shell.release_delivered_output();
3417 }
3418 }
3419 completions.sort_by(|a, b| a.event.task_id.cmp(&b.event.task_id));
3420 completions
3421 }
3422
3423 /// A terminal foreground result is already returned as the tool result;
3424 /// do not emit it again through the background-completion channel.
3425 fn acknowledge_foreground_completion(&mut self, task_id: &str) {
3426 if let Some(shell) = self.processes.get_mut(task_id) {
3427 shell.completion_reported = true;
3428 // The caller already holds this job's `ShellResult`; the record only
3429 // stays listed so `/jobs` can show it. A 1,200-char tail is all any
3430 // remaining consumer reads, so the rest is released now instead of
3431 // at the 1 h `cleanup` (#5472 finding 1 — the dominant term: every
3432 // uppercase `Bash` call, foreground included, went through here).
3433 shell.release_delivered_output();
3434 }
3435 }
3436
3437 /// Whether the next production turn may inject a parent-owned shell
3438 /// completion event.
3439 ///
3440 /// This deliberately does not poll processes or flip
3441 /// `completion_reported`: preview is read-only. A running job counts as
3442 /// pending because it can finish before production drains completions; in
3443 /// that race an exact request body cannot be proved without mutation.
3444 #[cfg(test)]
3445 pub fn may_have_undelivered_completion(&self) -> bool {
3446 self.processes
3447 .values()
3448 .any(|shell| shell.owner_agent.is_none() && !shell.completion_reported)
3449 }
3450
3451 pub fn may_have_undelivered_completion_for_session(&self, active_session_id: &str) -> bool {
3452 !active_session_id.is_empty()
3453 && self.processes.values().any(|shell| {
3454 shell.owner_session_id == active_session_id
3455 && shell.owner_agent.is_none()
3456 && !shell.completion_reported
3457 })
3458 }
3459
3460 /// Return agent owners whose tracked shell work is still running. The
3461 /// engine uses this to keep a worker's heartbeat alive while its only
3462 /// pending work is an explicitly tracked background shell task.
3463 #[cfg(test)]
3464 pub fn running_owner_agent_ids(&mut self) -> Vec<String> {
3465 self.running_owner_agent_ids_inner(None)
3466 }
3467
3468 pub fn running_owner_agent_ids_for_session(&mut self, active_session_id: &str) -> Vec<String> {
3469 self.running_owner_agent_ids_inner(Some(active_session_id))
3470 }
3471
3472 fn running_owner_agent_ids_inner(&mut self, active_session_id: Option<&str>) -> Vec<String> {
3473 let mut owners = self
3474 .processes
3475 .values_mut()
3476 .filter_map(|shell| {
3477 shell.poll();
3478 (shell.status == ShellStatus::Running
3479 && active_session_id.is_none_or(|session_id| {
3480 !session_id.is_empty() && shell.owner_session_id == session_id
3481 }))
3482 .then(|| {
3483 shell
3484 .owner_agent
3485 .as_ref()
3486 .map(|owner| owner.agent_id.clone())
3487 })
3488 .flatten()
3489 })
3490 .collect::<Vec<_>>();
3491 owners.sort();
3492 owners.dedup();
3493 owners
3494 }
3495
3496 /// Remember a restart-stale job so the UI can show it instead of hiding it.
3497 #[cfg(test)]
3498 pub fn remember_stale_job(
3499 &mut self,
3500 id: impl Into<String>,
3501 command: impl Into<String>,
3502 cwd: PathBuf,
3503 linked_task_id: Option<String>,
3504 ) {
3505 let id = id.into();
3506 self.stale_jobs.insert(
3507 id.clone(),
3508 ShellJobSnapshot {
3509 id: id.clone(),
3510 job_id: id,
3511 command: command.into(),
3512 cwd,
3513 status: ShellStatus::Killed,
3514 exit_code: None,
3515 elapsed_ms: 0,
3516 stdout_tail: String::new(),
3517 stderr_tail: "Process is no longer attached to this TUI session.".to_string(),
3518 stdout_len: 0,
3519 stderr_len: 0,
3520 stdin_available: false,
3521 stale: true,
3522 elapsed_since_output_ms: None,
3523 linked_task_id,
3524 owner_agent_id: None,
3525 owner_agent_name: None,
3526 origin_tool_call_id: None,
3527 origin_turn_id: None,
3528 owner_session_id: String::new(),
3529 },
3530 );
3531 }
3532
3533 /// Clean up completed processes older than the given duration, then enforce
3534 /// the count and byte ceilings on what is left.
3535 ///
3536 /// Age alone is not a bound: it only fired from `list_jobs()`, so a session
3537 /// that never opened the jobs panel evicted nothing, and 500 finished
3538 /// records inside one hour were all retained regardless of size (#5472).
3539 pub fn cleanup(&mut self, max_age: Duration) {
3540 self.processes.retain(|_, shell| {
3541 if shell.status == ShellStatus::Running {
3542 true
3543 } else {
3544 shell.started_at.elapsed() < max_age
3545 }
3546 });
3547 self.enforce_finished_job_bounds();
3548 }
3549
3550 /// Bytes still held across every tracked job. The retention bound in #5472
3551 /// is stated in these terms, so the tests assert on them directly.
3552 #[cfg(all(test, unix))]
3553 pub(crate) fn retained_output_bytes_total(&self) -> usize {
3554 self.processes
3555 .values()
3556 .map(BackgroundShell::retained_output_bytes)
3557 .fold(0, usize::saturating_add)
3558 }
3559
3560 #[cfg(test)]
3561 pub(crate) fn tracked_job_count(&self) -> usize {
3562 self.processes.len()
3563 }
3564
3565 /// Drop the oldest finished records once they exceed either ceiling.
3566 /// Running jobs are never evicted — killing the handle would orphan a live
3567 /// process — and a job whose completion has not been delivered yet is
3568 /// evicted last, since dropping it loses the only copy of its result.
3569 fn enforce_finished_job_bounds(&mut self) {
3570 let mut finished = self
3571 .processes
3572 .iter()
3573 .filter(|(_, shell)| shell.status != ShellStatus::Running)
3574 .map(|(id, shell)| {
3575 (
3576 id.clone(),
3577 shell.completion_reported,
3578 shell.started_at,
3579 shell.retained_output_bytes(),
3580 )
3581 })
3582 .collect::<Vec<_>>();
3583 let total_bytes: usize = finished
3584 .iter()
3585 .map(|(_, _, _, bytes)| *bytes)
3586 .fold(0, usize::saturating_add);
3587 if finished.len() <= MAX_FINISHED_SHELL_RECORDS && total_bytes <= MAX_FINISHED_SHELL_BYTES {
3588 return;
3589 }
3590 // Undelivered completions last, then oldest first.
3591 finished.sort_by(|a, b| a.1.cmp(&b.1).reverse().then_with(|| a.2.cmp(&b.2)));
3592 let mut remaining_count = finished.len();
3593 let mut remaining_bytes = total_bytes;
3594 for (id, _, _, bytes) in finished {
3595 if remaining_count <= MAX_FINISHED_SHELL_RECORDS
3596 && remaining_bytes <= MAX_FINISHED_SHELL_BYTES
3597 {
3598 break;
3599 }
3600 self.processes.remove(&id);
3601 remaining_count -= 1;
3602 remaining_bytes = remaining_bytes.saturating_sub(bytes);
3603 }
3604 }
3605 }
3606
3607 fn job_status_rank(status: &ShellStatus, stale: bool) -> u8 {
3608 if stale {
3609 return 4;
3610 }
3611 match status {
3612 ShellStatus::Running => 0,
3613 ShellStatus::Failed | ShellStatus::TimedOut => 1,
3614 ShellStatus::Killed => 2,
3615 ShellStatus::Completed => 3,
3616 }
3617 }
3618
3619 /// Thread-safe wrapper for `ShellManager`
3620 pub type SharedShellManager = Arc<Mutex<ShellManager>>;
3621
3622 /// Create a new shared shell manager with default sandbox policy.
3623 pub fn new_shared_shell_manager(workspace: PathBuf) -> SharedShellManager {
3624 Arc::new(Mutex::new(ShellManager::new(workspace)))
3625 }
3626
3627 // === ToolSpec Implementations ===
3628
3629 use crate::features::Feature;
3630 use crate::tools::cargo_failure_summary::summarize_cargo_failure;
3631 use crate::tools::spec::{
3632 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
3633 optional_bool, optional_str, optional_u64, required_str, type_mismatch,
3634 };
3635 use async_trait::async_trait;
3636 use codewhale_execpolicy::command_safety::{
3637 SafetyLevel, analyze_command, extract_primary_command, is_agent_readonly_shell_command,
3638 is_github_readonly_command, is_parallel_readonly_command, normalize_windows_command_paths,
3639 };
3640 use codewhale_execpolicy::toml_rules::{ExecPolicyConfig, RuleDecision};
3641 use serde_json::json;
3642
3643 /// The TOML execpolicy file lives in the user config home; the rule engine
3644 /// itself is `codewhale_execpolicy::toml_rules`.
3645 fn default_execpolicy_path() -> Option<std::path::PathBuf> {
3646 crate::config::effective_home_dir().map(|home| home.join(".deepseek").join("execpolicy.toml"))
3647 }
3648
3649 fn load_default_policy() -> anyhow::Result<Option<ExecPolicyConfig>> {
3650 /// A parsed rules file, tagged with the identity it was parsed from.
3651 type PolicyKey = (std::path::PathBuf, u64, Option<std::time::SystemTime>);
3652
3653 let Some(path) = default_execpolicy_path() else {
3654 return Ok(None);
3655 };
3656 // An unreadable or missing file (including a permissions error, which
3657 // `exists()` also swallows) means "no file rules" — the same answer as
3658 // before, just reached with one `stat` instead of an existence check plus a
3659 // full read.
3660 let Ok(metadata) = std::fs::metadata(&path) else {
3661 return Ok(None);
3662 };
3663 let key: PolicyKey = (path.clone(), metadata.len(), metadata.modified().ok());
3664
3665 // #6208: this runs on every shell execution, so the read and TOML parse
3666 // happen only when the file's identity changes. Length joins the timestamp
3667 // because a coarse-mtime filesystem can hand back the same instant for two
3668 // different revisions.
3669 static CACHE: std::sync::OnceLock<std::sync::Mutex<Option<(PolicyKey, ExecPolicyConfig)>>> =
3670 std::sync::OnceLock::new();
3671 let cache = CACHE.get_or_init(|| std::sync::Mutex::new(None));
3672 let mut cache = cache
3673 .lock()
3674 .unwrap_or_else(|poisoned| poisoned.into_inner());
3675 if let Some((cached_key, config)) = cache.as_ref()
3676 && *cached_key == key
3677 {
3678 return Ok(Some(config.clone()));
3679 }
3680
3681 let config = ExecPolicyConfig::from_path(&path)?;
3682 *cache = Some((key, config.clone()));
3683 Ok(Some(config))
3684 }
3685
3686 const FOREGROUND_TIMEOUT_RECOVERY_HINT: &str = "Foreground Bash is for bounded commands. \
3687 The timed-out process was killed; rerun long work as Bash action=\"run\" background=true, \
3688 then poll with Bash action=\"wait\" task_id=\"<id>\".";
3689
3690 const MACOS_PROVENANCE_HINT: &str = "Docker buildx failed to update its activity file due to a macOS \
3691 com.apple.provenance restriction. Files created by Docker Desktop's signed process carry a \
3692 kernel-enforced provenance tag that blocks writes from child processes (including the TUI \
3693 shell sandbox). Workarounds: (1) run the Docker build from a regular terminal outside the \
3694 TUI, or (2) disable BuildKit with DOCKER_BUILDKIT=0 (only works if your Dockerfiles do not \
3695 use RUN --mount directives).";
3696
3697 /// Human-readable exit status for a shell result: the numeric code when the
3698 /// process returned one, or "terminated by signal" when it did not (rather
3699 /// than leaking `Some(127)` / `None` Debug output to the user).
3700 fn exit_code_label(code: Option<i64>) -> String {
3701 match (code, exit_code_hex(code)) {
3702 (Some(code), Some(hex)) => format!("exit code {code} ({hex})"),
3703 (Some(code), None) => format!("exit code {code}"),
3704 (None, _) => "terminated by signal".to_string(),
3705 }
3706 }
3707
3708 fn exit_code_hex(code: Option<i64>) -> Option<String> {
3709 code.filter(|code| *code > i64::from(i32::MAX) && *code <= i64::from(u32::MAX))
3710 .map(|code| format!("0x{code:08X}"))
3711 }
3712 const PYTHON_BUILD_DEPENDENCY_HINT: &str = "Python build dependency missing: setuptools is not \
3713 available in the active environment. Install the declared build requirements first, for example \
3714 `python -m pip install -U pip setuptools wheel build`, then rerun the build command.";
3715
3716 fn attach_cargo_failure_summary(
3717 metadata: &mut serde_json::Value,
3718 command: &str,
3719 result: &ShellResult,
3720 ) {
3721 if let Some(summary) = summarize_cargo_failure(
3722 command,
3723 &result.stdout,
3724 &result.stderr,
3725 result.exit_code.and_then(|code| i32::try_from(code).ok()),
3726 ) {
3727 metadata["cargo_failure_summary"] = summary.to_metadata_value();
3728 }
3729 }
3730
3731 fn attach_python_build_dependency_hint(
3732 metadata: &mut serde_json::Value,
3733 hint: Option<&'static str>,
3734 ) {
3735 if let Some(hint) = hint {
3736 metadata["python_build_dependency_hint"] = json!({
3737 "kind": "missing_setuptools",
3738 "hint": hint,
3739 "recommended_first_step": "python -m pip install -U pip setuptools wheel build",
3740 });
3741 }
3742 }
3743
3744 pub(crate) fn looks_like_macos_provenance_failure(result: &ShellResult) -> bool {
3745 if matches!(result.status, ShellStatus::Completed) && result.exit_code == Some(0) {
3746 return false;
3747 }
3748 let combined = format!("{}\n{}", result.stdout, result.stderr).to_ascii_lowercase();
3749 combined.contains("com.apple.provenance")
3750 || combined.contains("update builder last activity")
3751 || (combined.contains("buildx/activity") && combined.contains("operation not permitted"))
3752 }
3753
3754 fn macos_provenance_hint(result: &ShellResult) -> Option<&'static str> {
3755 if looks_like_macos_provenance_failure(result) {
3756 Some(MACOS_PROVENANCE_HINT)
3757 } else {
3758 None
3759 }
3760 }
3761
3762 fn python_build_dependency_hint(command: &str, result: &ShellResult) -> Option<&'static str> {
3763 if matches!(result.status, ShellStatus::Completed) && result.exit_code == Some(0) {
3764 return None;
3765 }
3766
3767 let command = command.to_ascii_lowercase();
3768 let combined = format!("{}\n{}", result.stdout, result.stderr).to_ascii_lowercase();
3769 let mentions_missing_setuptools = [
3770 "no module named 'setuptools'",
3771 "no module named \"setuptools\"",
3772 "setuptools is not available",
3773 "cannot import 'setuptools",
3774 "cannot import \"setuptools",
3775 "missing dependencies",
3776 ]
3777 .iter()
3778 .any(|needle| combined.contains(needle))
3779 && combined.contains("setuptools");
3780 if !mentions_missing_setuptools {
3781 return None;
3782 }
3783
3784 let pythonish_command = [
3785 "python",
3786 "pip",
3787 "pytest",
3788 "tox",
3789 "nox",
3790 "cython",
3791 "setup.py",
3792 "build_ext",
3793 ]
3794 .iter()
3795 .any(|needle| command.contains(needle));
3796 let pythonish_output = [
3797 "setup.py",
3798 "pyproject.toml",
3799 "build_meta",
3800 "build_ext",
3801 "pep 517",
3802 "cython",
3803 ]
3804 .iter()
3805 .any(|needle| combined.contains(needle));
3806
3807 if pythonish_command || pythonish_output {
3808 Some(PYTHON_BUILD_DEPENDENCY_HINT)
3809 } else {
3810 None
3811 }
3812 }
3813
3814 fn command_likely_needs_network(command: &str) -> bool {
3815 let normalized = command.to_ascii_lowercase();
3816 let Some(primary) = extract_primary_command(&normalized) else {
3817 return false;
3818 };
3819 let primary = primary.rsplit(['/', '\\']).next().unwrap_or(primary);
3820
3821 match primary {
3822 "curl" | "wget" | "fetch" | "nc" | "netcat" | "ncat" | "ssh" | "scp" | "sftp" | "rsync"
3823 | "ftp" | "ping" | "traceroute" | "nslookup" | "dig" | "host" | "nmap" | "gh" | "hub" => {
3824 true
3825 }
3826 "git" => [
3827 " fetch",
3828 " pull",
3829 " clone",
3830 " ls-remote",
3831 " submodule",
3832 " push",
3833 ]
3834 .iter()
3835 .any(|needle| normalized.contains(needle)),
3836 "cargo" => [" install", " fetch", " update", " publish", " search"]
3837 .iter()
3838 .any(|needle| normalized.contains(needle)),
3839 "npm" | "pnpm" | "yarn" => [" install", " i", " add", " update", " publish"]
3840 .iter()
3841 .any(|needle| normalized.contains(needle)),
3842 "pip" | "pip3" | "uv" | "poetry" => [" install", " add", " sync", " update"]
3843 .iter()
3844 .any(|needle| normalized.contains(needle)),
3845 "brew" | "apt" | "apt-get" | "yum" | "dnf" | "pacman" => true,
3846 "go" => [" get", " install", " mod download"]
3847 .iter()
3848 .any(|needle| normalized.contains(needle)),
3849 _ => false,
3850 }
3851 }
3852
3853 fn looks_like_network_blocked_failure(result: &ShellResult) -> bool {
3854 if matches!(result.status, ShellStatus::Completed | ShellStatus::Running)
3855 || result.exit_code == Some(0)
3856 {
3857 return false;
3858 }
3859
3860 if result.stdout.trim() == "000" {
3861 return true;
3862 }
3863 if result.sandboxed && result.stdout.is_empty() && result.stderr.is_empty() {
3864 return true;
3865 }
3866
3867 let output = format!("{}\n{}", result.stdout, result.stderr).to_ascii_lowercase();
3868 [
3869 "operation not permitted",
3870 "network is unreachable",
3871 "could not resolve host",
3872 "couldn't resolve host",
3873 "failed to resolve",
3874 "temporary failure in name resolution",
3875 "name or service not known",
3876 "nodename nor servname provided",
3877 "no address associated",
3878 "failed to connect",
3879 "couldn't connect",
3880 "connection timed out",
3881 "connection reset",
3882 ]
3883 .iter()
3884 .any(|pattern| output.contains(pattern))
3885 }
3886
3887 fn shell_network_restricted_hint<'a>(
3888 context: &'a ToolContext,
3889 command: &str,
3890 result: &ShellResult,
3891 ) -> Option<&'a str> {
3892 let hint = context.shell_network_denied_hint.as_deref()?;
3893 let policy_blocks_network = context
3894 .elevated_sandbox_policy
3895 .as_ref()
3896 .is_some_and(|policy| !policy.has_network_access());
3897 if !policy_blocks_network || !command_likely_needs_network(command) {
3898 return None;
3899 }
3900 if result.sandbox_denied || looks_like_network_blocked_failure(result) {
3901 Some(hint)
3902 } else {
3903 None
3904 }
3905 }
3906
3907 /// Coaching line when the execution sandbox denied a command and the
3908 /// Plan-mode network hint did not already explain it. Most often a write under
3909 /// a read-only posture: name the effective posture and the Ask-only retry shape
3910 /// so other postures do not mistake it for autonomous authority.
3911 fn shell_sandbox_denied_hint(context: &ToolContext, result: &ShellResult) -> Option<String> {
3912 if !result.sandbox_denied {
3913 return None;
3914 }
3915 let policy = context.elevated_sandbox_policy.as_ref()?;
3916 Some(format!(
3917 "The execution sandbox blocked this command. Effective sandbox posture: {}. [sandbox: Ask-only escalation — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]",
3918 policy.posture_label()
3919 ))
3920 }
3921
3922 fn shell_job_owner_from_context(context: &ToolContext) -> Option<ShellJobOwner> {
3923 let agent_id = context
3924 .owner_agent_id
3925 .as_deref()
3926 .map(str::trim)
3927 .filter(|value| !value.is_empty())?;
3928 let agent_name = context
3929 .owner_agent_name
3930 .as_deref()
3931 .map(str::trim)
3932 .filter(|value| !value.is_empty())
3933 .unwrap_or(agent_id);
3934 Some(ShellJobOwner {
3935 agent_id: agent_id.to_string(),
3936 agent_name: agent_name.to_string(),
3937 })
3938 }
3939
3940 fn shell_work_lifecycle_from_context(context: &ToolContext) -> Option<ShellWorkLifecycle> {
3941 context
3942 .runtime
3943 .work
3944 .as_ref()
3945 .map(|work| ShellWorkLifecycle {
3946 work: work.clone(),
3947 session_id: context.state_namespace.clone(),
3948 })
3949 }
3950
3951 fn lifecycle_now_ms() -> i64 {
3952 std::time::SystemTime::now()
3953 .duration_since(std::time::UNIX_EPOCH)
3954 .unwrap_or_default()
3955 .as_millis()
3956 .try_into()
3957 .unwrap_or(i64::MAX)
3958 }
3959
3960 fn attach_shell_owner_metadata(metadata: &mut serde_json::Value, context: &ToolContext) {
3961 let Some(owner) = shell_job_owner_from_context(context) else {
3962 return;
3963 };
3964 metadata["owner_agent_id"] = json!(owner.agent_id);
3965 metadata["owner_agent_name"] = json!(owner.agent_name);
3966 }
3967
3968 /// NUL bytes cannot cross the `exec` boundary: `Command` panics on them.
3969 /// Refuse with the byte offset before anything spawns (#5529).
3970 fn require_no_nul<'a>(value: &'a str, field: &str) -> Result<&'a str, ToolError> {
3971 if let Some(offset) = value.find('\0') {
3972 return Err(ToolError::invalid_input(format!(
3973 "Shell {field} contains a NUL byte at byte offset {offset}; it cannot cross the exec boundary. Remove it (usually a truncated heredoc or binary paste) and re-send."
3974 )));
3975 }
3976 Ok(value)
3977 }
3978
3979 fn enforce_readonly_github_network_policy(
3980 command: &str,
3981 context: &ToolContext,
3982 ) -> Result<(), ToolError> {
3983 if !is_github_readonly_command(command) {
3984 return Ok(());
3985 }
3986 let Some(decider) = context.network_policy.as_ref() else {
3987 return Ok(());
3988 };
3989
3990 use crate::network_policy::Decision;
3991 match decider.evaluate("api.github.com", "Bash") {
3992 Decision::Allow => Ok(()),
3993 Decision::Deny => Err(ToolError::permission_denied(
3994 "Read-only GitHub CLI access to 'api.github.com' is blocked by the active network policy."
3995 .to_string(),
3996 )),
3997 Decision::Prompt => Err(ToolError::permission_denied(
3998 "Read-only GitHub CLI access to 'api.github.com' requires network approval; allow that host in the parent session or network policy before dispatching the scout."
3999 .to_string(),
4000 )),
4001 }
4002 }
4003
4004 /// This is a request for mandatory filesystem/network isolation, not a claim
4005 /// about what the command does. The executor refuses it without a native
4006 /// enforcing sandbox and checks the prepared environment again before spawn.
4007 fn enforced_readonly_input(input: &serde_json::Value) -> bool {
4008 let Some(fields) = input.as_object() else {
4009 return false;
4010 };
4011 fields.get("read_only").and_then(serde_json::Value::as_bool) == Some(true)
4012 && fields.keys().all(|key| {
4013 matches!(
4014 key.as_str(),
4015 "action" | "command" | "cwd" | "timeout_ms" | "read_only"
4016 )
4017 })
4018 && fields
4019 .get("action")
4020 .is_none_or(|action| action.as_str() == Some("run"))
4021 && fields
4022 .get("command")
4023 .and_then(serde_json::Value::as_str)
4024 .is_some_and(|command| !command.trim().is_empty())
4025 }
4026
4027 fn is_native_readonly_sandbox(sandbox_type: SandboxType) -> bool {
4028 match sandbox_type {
4029 #[cfg(target_os = "macos")]
4030 SandboxType::MacosSeatbelt => true,
4031 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
4032 SandboxType::LinuxBubblewrap => true,
4033 _ => false,
4034 }
4035 }
4036
4037 fn require_native_readonly_execution(exec_env: &ExecEnv) -> Result<()> {
4038 if matches!(exec_env.policy, ExecutionSandboxPolicy::ReadOnly)
4039 && is_native_readonly_sandbox(exec_env.sandbox_type)
4040 {
4041 Ok(())
4042 } else {
4043 Err(anyhow!(
4044 "read_only execution requires an enforcing native read-only sandbox; nothing was run"
4045 ))
4046 }
4047 }
4048
4049 /// `exec_shell_input_is_parallel_readonly` with the agent-posture classifier:
4050 /// same input-shape restrictions (run action only, no background/tty/stdin),
4051 /// but commands are judged by [`is_agent_readonly_shell_command`] so
4052 /// `ShellPolicy::ReadOnly` agents keep a usable inspection surface
4053 /// (pipelines, globs, `git -C`, `find`, `sed -n`, `npm view`).
4054 fn exec_shell_input_agent_readonly(input: &serde_json::Value) -> bool {
4055 if enforced_readonly_input(input) {
4056 return true;
4057 }
4058 if !exec_shell_input_is_parallel_readonly_shape(input) {
4059 return false;
4060 }
4061 let command = input
4062 .get("command")
4063 .and_then(serde_json::Value::as_str)
4064 .expect("shape check established a command string");
4065 is_agent_readonly_shell_command(command)
4066 }
4067
4068 /// `exec_shell_input_agent_readonly` is also the gate-side predicate for the
4069 /// subagent posture check (#5426): the catalog carve-out that admits
4070 /// canonical `bash` to Scout/Reviewer/Planner must judge the same call the
4071 /// `BashTool::execute` `ShellPolicy::ReadOnly` branch will judge, so the
4072 /// posture gate can admit a proven-readonly call without ever widening past
4073 /// the execute-time refusal.
4074 pub(crate) fn agent_readonly_bash_input(input: &serde_json::Value) -> bool {
4075 // Canonical lowercase `bash` advertises `timeout` in seconds, while the
4076 // internal executor contract uses `timeout_ms`. Normalize through the same
4077 // translator the concrete tool uses so posture, session approval, envelope,
4078 // and execute judge one input (#5595). Legacy/internal shapes fall back to
4079 // their existing direct classification.
4080 let translated = contract_bash_legacy_input(input).unwrap_or_else(|_| input.clone());
4081 exec_shell_input_agent_readonly(&translated)
4082 }
4083
4084 fn exec_shell_input_is_parallel_readonly(input: &serde_json::Value) -> bool {
4085 if enforced_readonly_input(input) {
4086 return true;
4087 }
4088 if !exec_shell_input_is_parallel_readonly_shape(input) {
4089 return false;
4090 }
4091 let command = input
4092 .get("command")
4093 .and_then(serde_json::Value::as_str)
4094 .expect("shape check established a command string");
4095 is_parallel_readonly_command(command)
4096 }
4097
4098 fn exec_shell_input_is_parallel_readonly_shape(input: &serde_json::Value) -> bool {
4099 let Some(fields) = input.as_object() else {
4100 return false;
4101 };
4102 if fields
4103 .keys()
4104 .any(|key| !matches!(key.as_str(), "action" | "command" | "cwd" | "timeout_ms"))
4105 {
4106 return false;
4107 }
4108 match input.get("action") {
4109 None | Some(serde_json::Value::Null) => {}
4110 Some(serde_json::Value::String(action)) if action == "run" => {}
4111 Some(_) => return false,
4112 }
4113 if ["background", "interactive", "tty", "combined_output"]
4114 .iter()
4115 .any(|key| {
4116 !matches!(
4117 input.get(*key),
4118 None | Some(serde_json::Value::Null | serde_json::Value::Bool(false))
4119 )
4120 })
4121 {
4122 return false;
4123 }
4124 if ["stdin", "input", "data"]
4125 .iter()
4126 .any(|key| input.get(*key).is_some())
4127 {
4128 return false;
4129 }
4130 if ["task_id", "id", "wait", "block", "close_stdin", "all"]
4131 .iter()
4132 .any(|key| input.get(*key).is_some())
4133 {
4134 return false;
4135 }
4136
4137 input
4138 .get("command")
4139 .and_then(serde_json::Value::as_str)
4140 .is_some()
4141 }
4142
4143 fn hardened_readonly_pipeline(command: &str, workspace: &std::path::Path) -> Result<String> {
4144 use crate::shell_dispatcher::ShellKind;
4145 // POSIX quoting must never be passed to a different command interpreter.
4146 let supported = match crate::shell_dispatcher::global_dispatcher().kind() {
4147 ShellKind::Bash => true,
4148 ShellKind::Custom { binary, .. } => matches!(
4149 std::path::Path::new(binary)
4150 .file_name()
4151 .and_then(|name| name.to_str()),
4152 Some("bash" | "zsh")
4153 ),
4154 _ => false,
4155 };
4156 if !supported {
4157 return Err(anyhow!(
4158 "read-only pipelines require bash or zsh; run each read separately"
4159 ));
4160 }
4161 if !is_agent_readonly_shell_command(command) {
4162 return Err(anyhow!(
4163 "pipeline contains a command outside the read-only policy"
4164 ));
4165 }
4166 let segments = command
4167 .split('|')
4168 .map(|segment| {
4169 let (program, args) = hardened_readonly_argv(segment)?;
4170 let program = resolve_readonly_program(&program, workspace)?;
4171 let program = program
4172 .to_str()
4173 .ok_or_else(|| anyhow!("read-only executable path is not valid UTF-8"))?;
4174 Ok(std::iter::once(program)
4175 .chain(args.iter().map(String::as_str))
4176 .map(|arg| shell_words::quote(arg).into_owned())
4177 .collect::<Vec<_>>()
4178 .join(" "))
4179 })
4180 .collect::<Result<Vec<_>>>()?;
4181 // The only shell operators are our pipes. Filenames cannot expand into
4182 // options or unvalidated symlinks; every Git stage retains helper guards.
4183 Ok(format!("set -o pipefail; {}", segments.join(" | ")))
4184 }
4185
4186 fn hardened_readonly_argv(command: &str) -> Result<(String, Vec<String>)> {
4187 let mut argv = shell_words::split(&normalize_windows_command_paths(command))
4188 .map_err(|error| anyhow!("could not parse classifier-approved read command: {error}"))?;
4189 if argv.is_empty() {
4190 return Err(anyhow!("classifier-approved read command was empty"));
4191 }
4192
4193 // Even when repository/user configuration names a diff or signature
4194 // helper, these flags make Git keep the read inside its own process.
4195 if argv.first().is_some_and(|program| program == "git") {
4196 // The agent read-only classifier admits `git -C <dir>` and
4197 // `git --no-pager` before the subcommand; keep the preamble but
4198 // locate the subcommand after it so the hardening flags splice in
4199 // the right place. `-C` targets were already workspace-checked by
4200 // `enforce_readonly_workspace_operands`.
4201 let mut subcommand_index = 1;
4202 while let Some(flag) = argv.get(subcommand_index) {
4203 match flag.as_str() {
4204 "--no-pager" => subcommand_index += 1,
4205 "-C" => subcommand_index += 2,
4206 _ => break,
4207 }
4208 }
4209 let subcommand = argv
4210 .get(subcommand_index)
4211 .map(String::as_str)
4212 .ok_or_else(|| {
4213 anyhow!("classifier-approved Git read was missing its literal subcommand")
4214 })?;
4215 match subcommand {
4216 "diff" => {
4217 let at = subcommand_index + 1;
4218 argv.splice(
4219 at..at,
4220 ["--no-ext-diff".to_string(), "--no-textconv".to_string()],
4221 );
4222 }
4223 "log" | "show" => {
4224 let at = subcommand_index + 1;
4225 argv.splice(
4226 at..at,
4227 [
4228 "--no-ext-diff".to_string(),
4229 "--no-textconv".to_string(),
4230 "--no-show-signature".to_string(),
4231 ],
4232 );
4233 }
4234 "status" | "ls-files" | "blame" | "grep" => {}
4235 _ => {
4236 return Err(anyhow!(
4237 "classifier-approved Git read did not keep its subcommand in argv[1]"
4238 ));
4239 }
4240 }
4241 }
4242
4243 let program = argv.remove(0);
4244 Ok((program, argv))
4245 }
4246
4247 fn enforce_readonly_workspace_operands(
4248 command: &str,
4249 workspace: &std::path::Path,
4250 effective_cwd: &std::path::Path,
4251 ) -> Result<(), ToolError> {
4252 let argv = shell_words::split(&normalize_windows_command_paths(command)).map_err(|error| {
4253 ToolError::invalid_input(format!(
4254 "Could not parse read-only command arguments: {error}"
4255 ))
4256 })?;
4257 if argv.first().is_some_and(|program| program == "gh") {
4258 // High-level gh reads do not consume local path operands. Their host
4259 // is pinned and evaluated separately by the network-policy guard.
4260 return Ok(());
4261 }
4262 let workspace = workspace.canonicalize().map_err(|error| {
4263 ToolError::execution_failed(format!(
4264 "Could not resolve the Scout workspace before shell dispatch: {error}"
4265 ))
4266 })?;
4267 let effective_cwd = effective_cwd.canonicalize().map_err(|error| {
4268 ToolError::permission_denied(format!(
4269 "Could not prove the read-only shell working directory stays in the workspace: {error}"
4270 ))
4271 })?;
4272 if !effective_cwd.starts_with(&workspace) {
4273 return Err(ToolError::permission_denied(
4274 "[shell.readonly.cwd.outside_workspace] Read-only Scout shell working directory resolves outside the workspace.",
4275 ));
4276 }
4277
4278 for token in argv.iter().skip(1) {
4279 if token.starts_with('-') && (token.contains('/') || token.contains('\\')) {
4280 return Err(ToolError::permission_denied(format!(
4281 "[shell.readonly.option.attached_path] Read-only Scout shell options may not carry attached paths; refused {token:?}. Use the bounded File read/search actions for project evidence."
4282 )));
4283 }
4284 let value = token
4285 .split_once('=')
4286 .map_or(token.as_str(), |(_, value)| value)
4287 .trim();
4288 // Windows `Path::canonicalize` returns verbatim device paths
4289 // (`\\?\C:\...`), which `Path::is_absolute` does not recognize; without
4290 // stripping the prefix the operand falls into the shape refusal and
4291 // every canonical absolute read is denied on Windows. The stripped
4292 // form resolves to the same location, so location-based judgement is
4293 // unchanged. On unix hosts such spellings stay fail-closed below.
4294 let value = value
4295 .strip_prefix(r"\\?\")
4296 .or_else(|| value.strip_prefix(r"\\.\"))
4297 .unwrap_or(value);
4298 if value.is_empty() || value == "-" {
4299 continue;
4300 }
4301 let candidate = std::path::Path::new(value);
4302 let bytes = value.as_bytes();
4303 let windows_prefixed =
4304 (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
4305 || value.starts_with("\\\\")
4306 || candidate
4307 .components()
4308 .any(|component| matches!(component, std::path::Component::Prefix(_)));
4309
4310 // #5595: an absolute operand is safe by resolved location, not by
4311 // spelling. This is the canonical `git -C /absolute/workspace log`
4312 // case. Requiring canonicalization also keeps nonexistent output-like
4313 // targets fail-closed, while symlinks are judged by their destination.
4314 if candidate.is_absolute() {
4315 let resolved = candidate.canonicalize().map_err(|error| {
4316 ToolError::permission_denied(format!(
4317 "[shell.readonly.operand.unresolved] Could not prove absolute read-only operand {value:?} stays inside the workspace because it could not be resolved: {error}"
4318 ))
4319 })?;
4320 if !resolved.starts_with(&workspace) {
4321 return Err(ToolError::permission_denied(format!(
4322 "[shell.readonly.operand.outside_workspace] Read-only Scout shell operand {value:?} resolves outside the workspace. Use the bounded File read/search actions for project evidence."
4323 )));
4324 }
4325 continue;
4326 }
4327
4328 if value.starts_with('~')
4329 || value.contains('\\')
4330 || windows_prefixed
4331 || candidate.has_root()
4332 || candidate
4333 .components()
4334 .any(|component| matches!(component, std::path::Component::ParentDir))
4335 {
4336 return Err(ToolError::permission_denied(format!(
4337 "[shell.readonly.operand.shape] Read-only Scout shell operands must stay inside the workspace; refused {value:?}. Use the bounded File read/search actions for project evidence."
4338 )));
4339 }
4340
4341 let joined = effective_cwd.join(candidate);
4342 if joined.exists() {
4343 let resolved = joined.canonicalize().map_err(|error| {
4344 ToolError::permission_denied(format!(
4345 "[shell.readonly.operand.unresolved] Could not prove read-only operand {value:?} stays in the workspace: {error}"
4346 ))
4347 })?;
4348 if !resolved.starts_with(&workspace) {
4349 return Err(ToolError::permission_denied(format!(
4350 "[shell.readonly.operand.outside_workspace] Read-only Scout shell operand {value:?} resolves outside the workspace. Use the bounded File read/search actions for project evidence."
4351 )));
4352 }
4353 }
4354 }
4355 Ok(())
4356 }
4357
4358 fn readonly_sanitized_path_from(
4359 workspace: &std::path::Path,
4360 path: &std::ffi::OsStr,
4361 ) -> Option<std::ffi::OsString> {
4362 let workspace = workspace.canonicalize().ok()?;
4363 let safe = std::env::split_paths(path).filter_map(|entry| {
4364 if !entry.is_absolute() {
4365 return None;
4366 }
4367 let resolved = entry.canonicalize().ok()?;
4368 (!resolved.starts_with(&workspace)).then_some(resolved)
4369 });
4370 std::env::join_paths(safe).ok()
4371 }
4372
4373 fn readonly_sanitized_path(workspace: &std::path::Path) -> Option<String> {
4374 let path = std::env::var_os("PATH")?;
4375 readonly_sanitized_path_from(workspace, &path).map(|value| value.to_string_lossy().into_owned())
4376 }
4377
4378 fn resolve_readonly_program(program: &str, workspace: &std::path::Path) -> Result<PathBuf> {
4379 let path = std::env::var_os("PATH")
4380 .ok_or_else(|| anyhow!("no executable search path is configured"))?;
4381 resolve_readonly_program_from_path(program, workspace, &path)
4382 }
4383
4384 fn resolve_readonly_program_from_path(
4385 program: &str,
4386 workspace: &std::path::Path,
4387 path: &std::ffi::OsStr,
4388 ) -> Result<PathBuf> {
4389 let workspace = workspace.canonicalize()?;
4390 if std::path::Path::new(program).components().count() != 1 {
4391 return Err(anyhow!(
4392 "read-only command must name a bare allowlisted executable"
4393 ));
4394 }
4395 let safe_path = readonly_sanitized_path_from(&workspace, path).ok_or_else(|| {
4396 anyhow!("no trusted executable search path remains outside the workspace")
4397 })?;
4398 let names = if cfg!(windows) {
4399 vec![format!("{program}.exe"), format!("{program}.com")]
4400 } else {
4401 vec![program.to_string()]
4402 };
4403 for directory in std::env::split_paths(&safe_path) {
4404 for name in &names {
4405 let candidate = directory.join(name);
4406 if !candidate.is_file() {
4407 continue;
4408 }
4409 #[cfg(unix)]
4410 {
4411 use std::os::unix::fs::PermissionsExt as _;
4412 if candidate.metadata()?.permissions().mode() & 0o111 == 0 {
4413 continue;
4414 }
4415 }
4416 let resolved = candidate.canonicalize()?;
4417 if resolved.is_absolute() && !resolved.starts_with(&workspace) {
4418 return Ok(resolved);
4419 }
4420 }
4421 }
4422 Err(anyhow!(
4423 "allowlisted read-only executable {program:?} was not found at a canonical path outside the workspace"
4424 ))
4425 }
4426
4427 fn remove_readonly_redirect_env(cmd: &mut Command, env: &HashMap<String, String>) {
4428 if env.get(READONLY_ENV_MARKER).map(String::as_str) != Some("1") {
4429 return;
4430 }
4431 cmd.env_remove(READONLY_ENV_MARKER);
4432 let removals = cmd
4433 .get_envs()
4434 .filter_map(|(key, _)| {
4435 let upper = key.to_string_lossy().to_ascii_uppercase();
4436 let guarded = upper.starts_with("GIT_")
4437 || upper.starts_with("GH_")
4438 || upper.starts_with("GITHUB_");
4439 let safe = matches!(
4440 upper.as_str(),
4441 "GIT_OPTIONAL_LOCKS"
4442 | "GIT_NO_LAZY_FETCH"
4443 | "GIT_PAGER"
4444 | "GIT_CONFIG_NOSYSTEM"
4445 | "GIT_CONFIG_GLOBAL"
4446 | "GIT_CONFIG_PARAMETERS"
4447 | "GIT_EXTERNAL_DIFF"
4448 | "GIT_ATTR_NOSYSTEM"
4449 | "GIT_CONFIG_COUNT"
4450 | "GH_PAGER"
4451 | "GH_PROMPT_DISABLED"
4452 | "GH_NO_UPDATE_NOTIFIER"
4453 | "GH_HOST"
4454 | "GH_REPO"
4455 ) || upper.starts_with("GIT_CONFIG_KEY_")
4456 || upper.starts_with("GIT_CONFIG_VALUE_");
4457 (guarded && !safe).then(|| key.to_os_string())
4458 })
4459 .collect::<Vec<_>>();
4460 for key in removals {
4461 cmd.env_remove(key);
4462 }
4463 }
4464
4465 fn exec_shell_input_starts_detached(input: &serde_json::Value) -> bool {
4466 input
4467 .get("command")
4468 .and_then(serde_json::Value::as_str)
4469 .is_some()
4470 && input
4471 .get("interactive")
4472 .and_then(serde_json::Value::as_bool)
4473 != Some(true)
4474 && (input.get("background").and_then(serde_json::Value::as_bool) == Some(true)
4475 || input.get("tty").and_then(serde_json::Value::as_bool) == Some(true))
4476 }
4477
4478 fn persistent_services_enabled_for(context: &ToolContext) -> bool {
4479 #[cfg(unix)]
4480 {
4481 context.persist_services_enabled
4482 && context.owner_agent_id.is_none()
4483 && context.tool_authority.is_none()
4484 && context.sandbox_backend.is_none()
4485 && matches!(context.shell_policy, ShellPolicy::Full)
4486 && matches!(
4487 context.elevated_sandbox_policy,
4488 Some(ExecutionSandboxPolicy::DangerFullAccess)
4489 )
4490 }
4491 #[cfg(not(unix))]
4492 {
4493 let _ = context;
4494 false
4495 }
4496 }
4497
4498 #[allow(clippy::too_many_arguments)]
4499 async fn execute_foreground_via_background(
4500 context: &ToolContext,
4501 command: &str,
4502 heavy_permit: Option<HeavyCommandPermit>,
4503 working_dir: Option<String>,
4504 timeout_ms: Option<u64>,
4505 stdin_data: Option<&str>,
4506 tty: bool,
4507 policy_override: Option<ExecutionSandboxPolicy>,
4508 extra_env: HashMap<String, String>,
4509 direct_argv: bool,
4510 timeout_bounds_ms: (u64, u64),
4511 ) -> Result<ShellResult> {
4512 let timeout_ms =
4513 timeout_ms.map(|timeout| timeout.clamp(timeout_bounds_ms.0, timeout_bounds_ms.1));
4514 let spawn_timeout_ms = timeout_ms.unwrap_or(timeout_bounds_ms.1);
4515 let spawned = {
4516 let mut manager = context
4517 .shell_manager
4518 .lock()
4519 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
4520 manager.clear_foreground_background_request();
4521 let owner = shell_job_owner_from_context(context);
4522 let lifecycle = shell_work_lifecycle_from_context(context);
4523 manager.execute_with_options_env_for_owner_and_work(
4524 command,
4525 working_dir.as_deref(),
4526 spawn_timeout_ms,
4527 true,
4528 stdin_data,
4529 tty,
4530 policy_override,
4531 extra_env,
4532 owner,
4533 context.state_namespace.clone(),
4534 context.origin_tool_call_id.clone(),
4535 context.origin_turn_id.clone(),
4536 lifecycle,
4537 direct_argv.then_some(context.workspace.as_path()),
4538 false,
4539 timeout_bounds_ms,
4540 )?
4541 };
4542 let task_id = spawned
4543 .task_id
4544 .ok_or_else(|| anyhow!("foreground shell did not return a process id"))?;
4545 let mut foreground = ForegroundShellGuard {
4546 manager: context.shell_manager.clone(),
4547 task_id: task_id.clone(),
4548 armed: true,
4549 };
4550 if let Some(permit) = heavy_permit {
4551 let mut manager = context
4552 .shell_manager
4553 .lock()
4554 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
4555 manager.attach_heavy_permit(&task_id, permit)?;
4556 }
4557
4558 if stdin_data.is_some() {
4559 let mut manager = context
4560 .shell_manager
4561 .lock()
4562 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
4563 manager.write_stdin(&task_id, "", true)?;
4564 }
4565
4566 let deadline = timeout_ms.map(|timeout| Instant::now() + Duration::from_millis(timeout));
4567 // Adaptive poll cadence: fast commands (the common case — grep, wc, echo)
4568 // finish in single-digit milliseconds, and a fixed 100ms tick made every
4569 // foreground call pay that full quantum before completion was noticed.
4570 // Start fine-grained and back off to the 100ms cap for long-running work.
4571 let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS;
4572 loop {
4573 if context
4574 .cancel_token
4575 .as_ref()
4576 .is_some_and(|token| token.is_cancelled())
4577 {
4578 let mut manager = context
4579 .shell_manager
4580 .lock()
4581 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
4582 let result = manager.kill(&task_id);
4583 if result.is_ok() {
4584 manager.acknowledge_foreground_completion(&task_id);
4585 foreground.armed = false;
4586 }
4587 return result;
4588 }
4589
4590 // Poll status only. The snapshot — and the buffer clones behind it — is
4591 // built once, when there is actually a result to return (#5472). Both
4592 // happen under one lock acquisition so the record cannot be evicted
4593 // between observing that it finished and reading its result.
4594 let finished = {
4595 let mut manager = context
4596 .shell_manager
4597 .lock()
4598 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
4599 if manager.take_foreground_background_request() {
4600 let snapshot = manager.get_output(&task_id, false, 0)?;
4601 foreground.armed = false;
4602 return Ok(snapshot);
4603 }
4604 if manager.poll_status(&task_id)? == ShellStatus::Running {
4605 None
4606 } else {
4607 let snapshot = manager.get_output(&task_id, false, 0)?;
4608 // Ordering matters: the snapshot is taken before the
4609 // acknowledgement releases the retained bytes.
4610 manager.acknowledge_foreground_completion(&task_id);
4611 Some(snapshot)
4612 }
4613 };
4614
4615 if let Some(snapshot) = finished {
4616 foreground.armed = false;
4617 return Ok(snapshot);
4618 }
4619
4620 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
4621 let mut manager = context
4622 .shell_manager
4623 .lock()
4624 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
4625 let mut result = manager.kill(&task_id)?;
4626 manager.acknowledge_foreground_completion(&task_id);
4627 result.status = ShellStatus::TimedOut;
4628 foreground.armed = false;
4629 return Ok(result);
4630 }
4631
4632 tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await;
4633 poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS);
4634 }
4635 }
4636
4637 /// A foreground wait owns its process even if its caller drops the future
4638 /// before cooperative cancellation can be polled. Only an explicit transfer
4639 /// to /jobs releases that ownership while the process is still running.
4640 struct ForegroundShellGuard {
4641 manager: SharedShellManager,
4642 task_id: String,
4643 armed: bool,
4644 }
4645
4646 impl Drop for ForegroundShellGuard {
4647 fn drop(&mut self) {
4648 if !self.armed {
4649 return;
4650 }
4651 let mut manager = self
4652 .manager
4653 .lock()
4654 .unwrap_or_else(std::sync::PoisonError::into_inner);
4655 let result = manager.poll_status(&self.task_id).and_then(|status| {
4656 if status == ShellStatus::Running {
4657 manager.kill(&self.task_id).map(|_| ())
4658 } else {
4659 Ok(())
4660 }
4661 });
4662 if let Err(error) = result {
4663 tracing::warn!(shell_id = %self.task_id, %error, "foreground shell cleanup failed");
4664 }
4665 if let Some(shell) = manager.processes.get_mut(&self.task_id) {
4666 // No result received these bytes. Retain them for /jobs inspection,
4667 // but do not let an abandoned foreground wait wake a new model turn.
4668 shell.completion_reported = true;
4669 }
4670 }
4671 }
4672
4673 const BASH_MAX_TIMEOUT_MS: u64 = i32::MAX as u64;
4674
4675 /// Initial cadence for foreground-via-background completion polling. Fast
4676 /// commands dominate real agent traffic; detection latency on `true`-class
4677 /// commands drops from ~100ms to ~10ms, while long commands reach the
4678 /// 100ms cap within one doubling step.
4679 const FOREGROUND_POLL_INITIAL_MS: u64 = 10;
4680 /// Poll-cadence ceiling; matches the previous fixed tick so long-running
4681 /// command overhead is unchanged.
4682 const FOREGROUND_POLL_MAX_MS: u64 = 100;
4683
4684 /// Default foreground lifetime for a contract-`bash` `action=run` that names
4685 /// no `timeout_ms`. Matches the value the tool's own input schema advertises;
4686 /// before this existed the omitted case fell through to
4687 /// `BASH_MAX_TIMEOUT_MS`.
4688 const CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS: u64 = 120_000;
4689
4690 /// Resolve the lifetime for one `bash` run.
4691 ///
4692 /// A foreground contract-`bash` run that names no timeout used to inherit
4693 /// `BASH_MAX_TIMEOUT_MS` (~24.8 days), so a command that blocked on an
4694 /// interactive prompt or a hung network call pinned the turn indefinitely —
4695 /// the tool row just counted seconds while the model waited. The tool's own
4696 /// schema already promises `action=run 120000`, and its description already
4697 /// says foreground is for bounded commands, so honor that: an omitted
4698 /// timeout takes the advertised default, which lets
4699 /// `FOREGROUND_TIMEOUT_RECOVERY_HINT` kill the process and tell the model to
4700 /// rerun with `background=true`.
4701 ///
4702 /// An explicit `timeout_ms` is still honored up to the full contract ceiling,
4703 /// and background and interactive runs keep their own lifetimes: their
4704 /// processes are meant to outlive the call, so bounding them here would kill
4705 /// long-lived jobs the model deliberately detached.
4706 fn contract_bash_timeout_ms(
4707 optional_timeout: bool,
4708 requested_ms: Option<u64>,
4709 background: bool,
4710 interactive: bool,
4711 ) -> Option<u64> {
4712 if optional_timeout && requested_ms.is_none() && !background && !interactive {
4713 return Some(CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS);
4714 }
4715 requested_ms
4716 }
4717
4718 fn contract_bash_error_status(result: &ShellResult, timeout_ms: Option<u64>) -> String {
4719 match result.status {
4720 ShellStatus::TimedOut => {
4721 let millis = timeout_ms.unwrap_or(BASH_MAX_TIMEOUT_MS);
4722 let seconds = if millis.is_multiple_of(1_000) {
4723 (millis / 1_000).to_string()
4724 } else {
4725 format!("{}", millis as f64 / 1_000.0)
4726 };
4727 format!("Command timed out after {seconds} seconds")
4728 }
4729 ShellStatus::Killed => "Command aborted".to_string(),
4730 ShellStatus::Failed | ShellStatus::Completed | ShellStatus::Running => format!(
4731 "Command exited with code {}",
4732 result.exit_code.unwrap_or(-1)
4733 ),
4734 }
4735 }
4736
4737 fn finish_contract_bash_result(
4738 result: ShellResult,
4739 timeout_ms: Option<u64>,
4740 context: &ToolContext,
4741 ) -> Result<ToolResult, ToolError> {
4742 let sandbox_denied_hint = shell_sandbox_denied_hint(context, &result);
4743 let mut output = result.stdout.clone();
4744 output.push_str(&result.stderr);
4745 if let Some(hint) = sandbox_denied_hint {
4746 output = if output.is_empty() {
4747 hint
4748 } else {
4749 format!("{hint}\n\n{output}")
4750 };
4751 }
4752 let metadata = json!({
4753 "evidence_routing": "inline", "exit_code": result.exit_code,
4754 "status": format!("{:?}", result.status), "duration_ms": result.duration_ms,
4755 "sandboxed": result.sandboxed, "sandbox_type": result.sandbox_type,
4756 "task_id": result.task_id, "backgrounded": result.status == ShellStatus::Running,
4757 });
4758 if result.status == ShellStatus::Running {
4759 let task_id = result.task_id.as_deref().unwrap_or("unknown");
4760 let partial = (!output.is_empty()).then(|| format!("\n\nOutput so far:\n{output}"));
4761 return Ok(ToolResult::success(format!(
4762 "Foreground shell wait moved to /jobs: {task_id}{}\n\nThe command is still running; completion will appear as a runtime event.",
4763 partial.as_deref().unwrap_or_default()
4764 )).with_metadata(metadata));
4765 }
4766 if result.status != ShellStatus::Completed {
4767 let status = contract_bash_error_status(&result, timeout_ms);
4768 return Err(ToolError::execution_failed(if output.is_empty() {
4769 status
4770 } else {
4771 format!("{output}\n\n{status}")
4772 }));
4773 }
4774
4775 Ok(ToolResult::success(if output.is_empty() {
4776 "(no output)".to_string()
4777 } else {
4778 output
4779 })
4780 .with_metadata(metadata))
4781 }
4782
4783 /// Small foreground-only shell surface shown to new model turns.
4784 pub struct LowercaseBashTool;
4785
4786 #[async_trait]
4787 impl ToolSpec for LowercaseBashTool {
4788 fn name(&self) -> &'static str {
4789 "bash"
4790 }
4791
4792 fn description(&self) -> &'static str {
4793 guidance::foreground_description()
4794 }
4795
4796 fn input_schema(&self) -> serde_json::Value {
4797 json!({
4798 "type": "object",
4799 "properties": {
4800 "command": { "type": "string", "description": guidance::runtime_command_guidance() },
4801 "timeout": { "type": "number", "description": "Optional timeout in seconds; when omitted the command is killed after 120 seconds." },
4802 "read_only": { "type": "boolean", "description": "Set true to run analysis code (including Python/SQLite) with mandatory native filesystem read-only isolation and no network. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation." },
4803 "sandbox_permissions": {
4804 "type": "string",
4805 "enum": ["workspace-write", "danger-full-access"],
4806 "description": "The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask."
4807 },
4808 "justification": {
4809 "type": "string",
4810 "description": "Required with sandbox_permissions: one sentence explaining why this exact command needs wider access."
4811 }
4812 },
4813 "required": ["command"],
4814 "additionalProperties": false
4815 })
4816 }
4817
4818 fn capabilities(&self) -> Vec<ToolCapability> {
4819 BashTool::contract_delegate().capabilities()
4820 }
4821
4822 fn approval_requirement(&self) -> ApprovalRequirement {
4823 ApprovalRequirement::Required
4824 }
4825
4826 fn approval_requirement_for(&self, input: &serde_json::Value) -> ApprovalRequirement {
4827 let translated = contract_bash_legacy_input(input).unwrap_or_else(|_| input.clone());
4828 BashTool::contract_delegate().approval_requirement_for(&translated)
4829 }
4830
4831 fn is_read_only_for(&self, input: &serde_json::Value) -> bool {
4832 contract_bash_legacy_input(input)
4833 .is_ok_and(|translated| BashTool::contract_delegate().is_read_only_for(&translated))
4834 }
4835
4836 fn supports_parallel_for(&self, input: &serde_json::Value) -> bool {
4837 self.is_read_only_for(input)
4838 }
4839
4840 async fn execute(
4841 &self,
4842 input: serde_json::Value,
4843 context: &ToolContext,
4844 ) -> Result<ToolResult, ToolError> {
4845 let translated = contract_bash_legacy_input(&input)?;
4846 BashTool::contract_delegate()
4847 .execute(translated, context)
4848 .await
4849 }
4850 }
4851
4852 fn contract_bash_legacy_input(input: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
4853 let object = input
4854 .as_object()
4855 .ok_or_else(|| ToolError::invalid_input("bash input must be an object"))?;
4856 let unexpected = object
4857 .keys()
4858 .filter(|key| {
4859 !matches!(
4860 key.as_str(),
4861 "command" | "timeout" | "read_only" | "sandbox_permissions" | "justification"
4862 )
4863 })
4864 .cloned()
4865 .collect::<Vec<_>>();
4866 if !unexpected.is_empty() {
4867 return Err(ToolError::invalid_input(format!(
4868 "unexpected bash parameter(s): {}",
4869 unexpected.join(", ")
4870 )));
4871 }
4872 let command = required_str(input, "command")?;
4873 let mut translated = json!({"command": command});
4874 if let Some(timeout) = input.get("timeout") {
4875 let seconds = timeout.as_f64().ok_or_else(|| {
4876 ToolError::invalid_input("Invalid timeout: expected a finite number of seconds")
4877 })?;
4878 if !seconds.is_finite() || seconds <= 0.0 {
4879 return Err(ToolError::invalid_input(
4880 "Invalid timeout: expected a positive finite number of seconds",
4881 ));
4882 }
4883 let millis = seconds * 1000.0;
4884 if millis > BASH_MAX_TIMEOUT_MS as f64 {
4885 return Err(ToolError::invalid_input(format!(
4886 "Invalid timeout: maximum is {} seconds",
4887 BASH_MAX_TIMEOUT_MS as f64 / 1000.0
4888 )));
4889 }
4890 translated["timeout_ms"] = json!((millis as u64).max(1));
4891 }
4892 if let Some(value) = input.get("read_only") {
4893 if !value.is_boolean() {
4894 return Err(type_mismatch("read_only", value, "a boolean"));
4895 }
4896 translated["read_only"] = value.clone();
4897 }
4898 for field in ["sandbox_permissions", "justification"] {
4899 if let Some(value) = input.get(field) {
4900 translated[field] = value.clone();
4901 }
4902 }
4903 Ok(translated)
4904 }
4905
4906 /// Compatibility shell tool retained for saved v0.9.x transcripts and the
4907 /// background/session control surface. It is hidden from new model catalogs.
4908 pub struct BashTool {
4909 name: &'static str,
4910 forced_action: Option<&'static str>,
4911 read_only: bool,
4912 optional_timeout: bool,
4913 }
4914
4915 pub(crate) fn readonly_bash_input_schema() -> serde_json::Value {
4916 json!({
4917 "type": "object",
4918 "properties": {
4919 "action": { "type": "string", "enum": ["run"] },
4920 "command": { "type": "string", "description": "A classifier-approved read command, or analysis code with read_only=true" },
4921 "read_only": { "type": "boolean", "description": "Require native filesystem read-only and no-network enforcement for analysis code; unavailable sandboxes fail closed." },
4922 "cwd": { "type": "string", "description": "Workspace-relative working directory" },
4923 "timeout_ms": { "type": "integer", "description": "Timeout in milliseconds (1000-600000)" }
4924 },
4925 "required": ["command"],
4926 "additionalProperties": false
4927 })
4928 }
4929
4930 impl BashTool {
4931 pub const fn new(name: &'static str) -> Self {
4932 Self {
4933 name,
4934 forced_action: None,
4935 read_only: false,
4936 optional_timeout: false,
4937 }
4938 }
4939
4940 pub const fn read_only(name: &'static str) -> Self {
4941 Self {
4942 name,
4943 forced_action: None,
4944 read_only: true,
4945 optional_timeout: false,
4946 }
4947 }
4948
4949 pub const fn alias(name: &'static str, action: &'static str) -> Self {
4950 Self {
4951 name,
4952 forced_action: Some(action),
4953 read_only: false,
4954 optional_timeout: false,
4955 }
4956 }
4957
4958 const fn contract_delegate() -> Self {
4959 Self {
4960 name: "bash",
4961 forced_action: Some("run"),
4962 read_only: false,
4963 optional_timeout: true,
4964 }
4965 }
4966 }
4967
4968 #[async_trait]
4969 impl ToolSpec for BashTool {
4970 fn name(&self) -> &'static str {
4971 self.name
4972 }
4973
4974 fn model_visible(&self) -> bool {
4975 false
4976 }
4977
4978 fn description(&self) -> &'static str {
4979 if self.read_only {
4980 "Inspect with classifier-bounded commands run directly as argv, never through a shell. For analysis code, read_only=true instead requires a native filesystem read-only and no-network sandbox. Only foreground action=run with command, cwd, timeout_ms, and read_only is accepted."
4981 } else {
4982 guidance::description()
4983 }
4984 }
4985
4986 fn input_schema(&self) -> serde_json::Value {
4987 if self.read_only {
4988 return readonly_bash_input_schema();
4989 }
4990 json!({
4991 "type": "object",
4992 "properties": {
4993 "action": {
4994 "type": "string",
4995 "enum": ["run", "wait", "interact", "cancel"],
4996 "description": "Action to perform (default: run)"
4997 },
4998 "command": {
4999 "type": "string",
5000 "description": guidance::runtime_command_guidance()
5001 },
5002 "read_only": { "type": "boolean", "description": "Set true to require native filesystem read-only and no-network execution. Only foreground run with command, cwd and timeout_ms; unavailable enforcement fails closed." },
5003 "timeout_ms": {
5004 "type": "integer",
5005 "description": "Timeout in milliseconds. The default depends on the action: action=run 120000 (the standalone Bash tool caps it at 600000), action=wait 30000, action=interact 1000. A foreground action=run that omits this is bounded by that default and killed with a background-rerun hint; pass an explicit value for longer foreground work, or background=true. For action=wait, `timeout_secs` (seconds) and `timeout` (milliseconds) are accepted aliases."
5006 },
5007 "background": {
5008 "type": "boolean",
5009 "description": "Temporary background; killed at session exit. Surviving headless services need background:true,persist:true. It is not killed at timeout_ms; plan to poll it with action=wait or stop it with action=cancel."
5010 },
5011 "interactive": {
5012 "type": "boolean",
5013 "description": "Run interactively with terminal IO (default: false)"
5014 },
5015 "stdin": {
5016 "type": "string",
5017 "description": "Stdin data to send (action=run: before waiting; action=interact: to the background task). Also accepted as `input` or `data` — send only one."
5018 },
5019 "input": {
5020 "type": "string",
5021 "description": "Alias for `stdin`."
5022 },
5023 "data": {
5024 "type": "string",
5025 "description": "Alias for `stdin`."
5026 },
5027 "cwd": {
5028 "type": "string",
5029 "description": "Optional working directory for the command"
5030 },
5031 "tty": {
5032 "type": "boolean",
5033 "description": "Allocate a pseudo-terminal for interactive programs (implies background)"
5034 },
5035 "combined_output": {
5036 "type": "boolean",
5037 "description": "Capture stdout and stderr as one chronological PTY stream (default false)"
5038 },
5039 "task_id": {
5040 "type": "string",
5041 "description": "Task ID for action=wait/interact/cancel. Also accepted as `id`."
5042 },
5043 "task_ids": {
5044 "type": "array",
5045 "items": { "type": "string" },
5046 "description": "For action=wait: wait on several background task ids at once (alternative to `task_id`)."
5047 },
5048 "until": {
5049 "type": "string",
5050 "enum": ["any", "all"],
5051 "description": "For action=wait with `task_ids`: return as soon as any task is terminal (`any`) or when every task is terminal (`all`, default)."
5052 },
5053 "id": {
5054 "type": "string",
5055 "description": "Alias for `task_id`."
5056 },
5057 "wait": {
5058 "type": "boolean",
5059 "description": "For action=wait, block until the task completes or timeout elapses (default: true). Pass false for a nonblocking snapshot; `block` is an accepted alias."
5060 },
5061 "close_stdin": {
5062 "type": "boolean",
5063 "description": "Close stdin after sending (action=interact)"
5064 },
5065 "all": {
5066 "type": "boolean",
5067 "description": "Cancel all running background tasks (action=cancel)"
5068 },
5069 "persist": {
5070 "type": "boolean",
5071 "description": "Keep this background service running after a successful headless exec (default: false). Requires background:true and explicit danger-full-access. Run the service itself in the foreground; do not use nohup or a trailing `&`."
5072 },
5073 "sandbox_permissions": {
5074 "type": "string",
5075 "enum": ["workspace-write", "danger-full-access"],
5076 "description": "The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask."
5077 },
5078 "justification": {
5079 "type": "string",
5080 "description": "Required with sandbox_permissions: one sentence explaining why this exact command needs wider access."
5081 }
5082 },
5083 // The schema used to declare nothing required at all, so
5084 // `Bash{}` was schema-valid for the tool that runs shell
5085 // commands. What is required is per-action and cannot be spelled
5086 // as a flat `required` list: `run` needs `command`,
5087 // `wait`/`interact`/`cancel` need `task_id` (or its `id` alias),
5088 // and `cancel` needs `all` instead when cancelling everything.
5089 // A root `anyOf` of `required` groups is how this repo already
5090 // spells that (`finance`, `apply_patch`), and `schema_sanitize`
5091 // knows the shape: providers that reject root composition get the
5092 // groups merged and the constraint restated as a description note
5093 // (`root_composition_constraint_note`). The cost is that
5094 // `strict_schema_supported` rejects a root `anyOf`, so `Bash`
5095 // opts out of DeepSeek strict mode — as `finance` already does on
5096 // the same default agent surface, which turns strict mode off for
5097 // the whole tool set regardless.
5098 "anyOf": [
5099 { "required": ["command"] },
5100 { "required": ["task_id"] },
5101 { "required": ["id"] },
5102 { "required": ["all"] }
5103 ]
5104 })
5105 }
5106
5107 fn capabilities(&self) -> Vec<ToolCapability> {
5108 vec![
5109 ToolCapability::ExecutesCode,
5110 ToolCapability::Sandboxable,
5111 ToolCapability::RequiresApproval,
5112 ]
5113 }
5114
5115 fn approval_requirement(&self) -> ApprovalRequirement {
5116 ApprovalRequirement::Required
5117 }
5118
5119 fn approval_requirement_for(&self, input: &serde_json::Value) -> ApprovalRequirement {
5120 if exec_shell_input_is_parallel_readonly(input) {
5121 ApprovalRequirement::Auto
5122 } else {
5123 self.approval_requirement()
5124 }
5125 }
5126
5127 fn is_read_only_for(&self, input: &serde_json::Value) -> bool {
5128 exec_shell_input_is_parallel_readonly(input)
5129 }
5130
5131 fn supports_parallel_for(&self, input: &serde_json::Value) -> bool {
5132 exec_shell_input_is_parallel_readonly(input)
5133 }
5134
5135 fn starts_detached_for(&self, input: &serde_json::Value) -> bool {
5136 exec_shell_input_starts_detached(input)
5137 }
5138
5139 async fn execute(
5140 &self,
5141 input: serde_json::Value,
5142 context: &ToolContext,
5143 ) -> Result<ToolResult, ToolError> {
5144 // `and_then(as_str).unwrap_or("run")` treated *any* non-string
5145 // `action` as absent and fell through to the branch that runs
5146 // arbitrary code: `Bash{action: 3, command: "…"}` executed the
5147 // command. Every sibling family refuses a non-string action
5148 // (`canonical_action::required_action`), and `Bash` cannot be the
5149 // lenient one. `optional_str` is the type-strictness lane's extractor:
5150 // absent or `null` takes the documented `run` default, anything else
5151 // is a `type_mismatch` naming the field and the type it needed.
5152 let action = match self.forced_action {
5153 Some(forced) => forced,
5154 None => optional_str(&input, "action")?.unwrap_or("run"),
5155 };
5156 let enforced_readonly = match input.get("read_only") {
5157 None => false,
5158 Some(serde_json::Value::Bool(enabled)) => *enabled,
5159 Some(value) => return Err(type_mismatch("read_only", value, "a boolean")),
5160 };
5161 if enforced_readonly && (!enforced_readonly_input(&input) || action != "run") {
5162 return Err(ToolError::invalid_input(
5163 "read_only=true accepts only foreground run with command, cwd and timeout_ms; background, input, interactive modes and sandbox escalation are incompatible",
5164 ));
5165 }
5166 if enforced_readonly {
5167 if context.sandbox_backend.is_some() {
5168 return Err(ToolError::permission_denied(
5169 "read_only execution requires a native enforcing sandbox; external backends cannot attest this policy",
5170 ));
5171 }
5172 let manager = context
5173 .shell_manager
5174 .lock()
5175 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
5176 if !manager
5177 .configured_sandbox_type()
5178 .is_some_and(is_native_readonly_sandbox)
5179 {
5180 return Err(ToolError::not_available(
5181 "read_only execution requires native read-only enforcement (macOS Seatbelt or configured Linux bubblewrap); nothing was run",
5182 ));
5183 }
5184 }
5185 let mut policy_input = input.clone();
5186 if let Some(object) = policy_input.as_object_mut() {
5187 object.insert("action".into(), json!(action));
5188 }
5189 crate::core::engine::tool_catalog::enforce_tool_denial(
5190 context,
5191 self.name(),
5192 &policy_input,
5193 )?;
5194 if action == "interact" && context.shell_policy != ShellPolicy::Full {
5195 return Err(ToolError::permission_denied(
5196 "Sending shell input requires full shell permission.",
5197 ));
5198 }
5199 match action {
5200 "wait" => return self.execute_wait(&input, context).await,
5201 "interact" => return self.execute_interact(&input, context).await,
5202 "cancel" => return self.execute_cancel(&input, context).await,
5203 "run" => {}
5204 // Bash was the only action wrapper whose catch-all fell through to
5205 // its most dangerous branch: `{"action":"kill", "command":…}` ran
5206 // the command instead of cancelling, and a mis-cased "Cancel" did
5207 // the same. Every sibling (`File`, `Git`, `Web`, `Run`) already
5208 // refuses an unknown action; the tool that executes arbitrary code
5209 // should not be the lenient one.
5210 other => {
5211 return Err(ToolError::invalid_input(format!(
5212 "Unknown Bash action \"{other}\"; nothing was run. Pass one of: run, wait, interact, cancel."
5213 )));
5214 }
5215 }
5216 let command = require_no_nul(required_str(&input, "command")?, "command")?;
5217 match context.shell_policy {
5218 ShellPolicy::None => {
5219 return Ok(ToolResult::error(
5220 "Shell tools are disabled by the active permission profile.",
5221 ));
5222 }
5223 ShellPolicy::ReadOnly if !exec_shell_input_agent_readonly(&input) => {
5224 // #6298: a child has no mode to switch to, so the parent's
5225 // `/mode work` advice is unreachable. Name the child's own
5226 // alternatives instead, plus the escalation path.
5227 let message = if context.owner_agent_id.is_some() {
5228 "Shell command blocked by read-only shell policy. As a sub-agent you cannot switch modes: read files with read_file/grep_files, inspect Git with fetch/log/show (merge_tree for merge results), run checks with Run tests/verifiers (pass `cwd` when the checks live in a subdirectory), and report the blocked probe to the parent instead of working around it."
5229 } else {
5230 "Shell command blocked by read-only shell policy. Use a non-mutating, non-background inspection command, or switch to Work mode (`/mode work`) for write-capable shell work."
5231 };
5232 return Ok(ToolResult::error(message));
5233 }
5234 ShellPolicy::ReadOnly | ShellPolicy::Full => {}
5235 }
5236 enforce_readonly_github_network_policy(command, context)?;
5237 let requested_timeout_ms = if self.optional_timeout {
5238 input
5239 .get("timeout_ms")
5240 .map(|value| {
5241 value
5242 .as_u64()
5243 .ok_or_else(|| type_mismatch("timeout_ms", value, "a positive integer"))
5244 })
5245 .transpose()?
5246 } else {
5247 Some(optional_u64(&input, "timeout_ms", 120_000)?.min(600_000))
5248 };
5249 let background = optional_bool(&input, "background", false)?;
5250 let interactive = optional_bool(&input, "interactive", false)?;
5251 let combined_output = optional_bool(&input, "combined_output", false)?;
5252 let tty = optional_bool(&input, "tty", false)? || (combined_output && background);
5253 let timeout_ms = contract_bash_timeout_ms(
5254 self.optional_timeout,
5255 requested_timeout_ms,
5256 background,
5257 interactive,
5258 );
5259 let timeout_value_ms = timeout_ms.unwrap_or(BASH_MAX_TIMEOUT_MS);
5260 // Strict types (2026-08-04 review): a non-string here used to be
5261 // silently dropped — the command then ran with NO stdin and reported
5262 // success, the exact silent-drop failure the alias hardening closed
5263 // for misspelled names. A wrong type is an error, never a no-op.
5264 let stdin_data = match first_present_field(&input, &["stdin", "input", "data"]) {
5265 None => None,
5266 Some((name, value)) => Some(
5267 value
5268 .as_str()
5269 .ok_or_else(|| type_mismatch(name, value, "a string"))?
5270 .to_string(),
5271 ),
5272 };
5273
5274 if interactive && background {
5275 return Ok(ToolResult::error(
5276 "Interactive commands cannot run in background mode.",
5277 ));
5278 }
5279 if interactive && (tty || combined_output) {
5280 return Ok(ToolResult::error(
5281 "Interactive mode cannot be combined with TTY or combined_output sessions.",
5282 ));
5283 }
5284 if interactive && stdin_data.is_some() {
5285 return Ok(ToolResult::error(
5286 "Interactive mode cannot be combined with stdin data.",
5287 ));
5288 }
5289
5290 let persist = optional_bool(&input, "persist", false)?;
5291 if persist {
5292 if !background {
5293 return Err(ToolError::invalid_input(
5294 "persist:true requires background:true; a persisted service must be started as a background task.",
5295 ));
5296 }
5297 if interactive || tty {
5298 return Err(ToolError::invalid_input(
5299 "persist:true cannot be combined with interactive or TTY modes.",
5300 ));
5301 }
5302 if stdin_data.is_some() {
5303 return Err(ToolError::invalid_input(
5304 "persist:true spawns the service with null stdio; stdin data is not accepted.",
5305 ));
5306 }
5307 if !persistent_services_enabled_for(context) {
5308 return Err(ToolError::not_available(
5309 "persistent background services (persist:true) are only available on Unix in the real headless `codewhale exec` host under an explicit danger-full-access / full shell authority. They are rejected in interactive sessions, desktop/app-server hosts, Fleet/sub-agents, restricted or external sandboxes, and TTY/interactive/stdin modes.",
5310 ));
5311 }
5312 }
5313
5314 let background = background || tty;
5315
5316 let mut execpolicy_decision: Option<RuleDecision> = None;
5317 if context.features.enabled(Feature::ExecPolicy)
5318 && let Some(policy) = tokio::task::spawn_blocking(load_default_policy)
5319 .await
5320 .map_err(|e| {
5321 ToolError::execution_failed(format!("execpolicy load task failed: {e}"))
5322 })?
5323 .map_err(|e| ToolError::execution_failed(format!("execpolicy load failed: {e}")))?
5324 {
5325 let decision = policy.evaluate(command);
5326 execpolicy_decision = Some(decision.clone());
5327 if let RuleDecision::Deny(reason) = decision {
5328 return Ok(ToolResult {
5329 content: format!("BLOCKED: {reason}"),
5330 success: false,
5331 metadata: Some(json!({
5332 "execpolicy": {
5333 "decision": "deny",
5334 "reason": reason,
5335 }
5336 })),
5337 });
5338 }
5339 }
5340
5341 // Safety analysis (always run for metadata, but only block when not in YOLO mode)
5342 let safety = analyze_command(command);
5343 if !context.auto_approve {
5344 match safety.level {
5345 SafetyLevel::Dangerous => {
5346 let reasons = safety.reasons.join("; ");
5347 let suggestions = if safety.suggestions.is_empty() {
5348 String::new()
5349 } else {
5350 format!("\nSuggestions: {}", safety.suggestions.join("; "))
5351 };
5352 return Ok(ToolResult {
5353 content: format!(
5354 "BLOCKED: This command was blocked for safety reasons.\n\nReasons: {reasons}{suggestions}\n\nNote: allow_shell=true exposes shell tools, but it does not disable built-in shell safety validation."
5355 ),
5356 success: false,
5357 metadata: Some(json!({
5358 "safety_level": "dangerous",
5359 "blocked": true,
5360 "reasons": safety.reasons,
5361 "suggestions": safety.suggestions,
5362 })),
5363 });
5364 }
5365 SafetyLevel::RequiresApproval | SafetyLevel::Safe | SafetyLevel::WorkspaceSafe => {
5366 // Proceed normally
5367 }
5368 }
5369 }
5370
5371 // This explicit mode only narrows the caller's posture. No approval or
5372 // inherited full-access override can disable the required sandbox.
5373 let policy_override = if enforced_readonly {
5374 Some(ExecutionSandboxPolicy::ReadOnly)
5375 } else {
5376 context.elevated_sandbox_policy.clone()
5377 };
5378 // Strict types: a non-string cwd used to silently run the command in
5379 // the workspace default instead of erroring (2026-08-04 review).
5380 let working_dir = match first_present_field(&input, &["cwd", "working_dir"])
5381 .map(|(name, value)| {
5382 value
5383 .as_str()
5384 .ok_or_else(|| type_mismatch(name, value, "a string"))
5385 .and_then(|dir| require_no_nul(dir, name))
5386 })
5387 .transpose()?
5388 {
5389 Some(dir) => {
5390 // Validate cwd against workspace boundary (same as file tools)
5391 let resolved = context.resolve_path(dir)?;
5392 Some(resolved.to_string_lossy().to_string())
5393 }
5394 // Default to the tool context's workspace (which reflects the
5395 // child agent's worktree when `worktree: true` was used), not the
5396 // shared ShellManager's parent-workspace default_workspace.
5397 None => Some(context.workspace.display().to_string()),
5398 };
5399 if matches!(context.shell_policy, ShellPolicy::ReadOnly) && !enforced_readonly {
5400 let effective_cwd = working_dir
5401 .as_deref()
5402 .map(std::path::Path::new)
5403 .unwrap_or(&context.workspace);
5404 enforce_readonly_workspace_operands(command, &context.workspace, effective_cwd)?;
5405 }
5406
5407 // #456 — collect env from any configured `shell_env` hooks. Runs
5408 // synchronously, captures stdout, parses `KEY=VAL` lines, audit-logs
5409 // the keys (never the values). Empty / no-op when no hook is
5410 // configured.
5411 let read_only_shell =
5412 matches!(context.shell_policy, ShellPolicy::ReadOnly) || enforced_readonly;
5413 let mut extra_env = if read_only_shell {
5414 // shell_env hooks are arbitrary operator-configured processes.
5415 // They cannot run inside the evidence-only execution boundary.
5416 HashMap::new()
5417 } else if let Some(hook_executor) = &context.runtime.hook_executor {
5418 let hook_ctx = crate::hooks::HookContext::new()
5419 .with_tool_name("exec_shell")
5420 .with_tool_args(&input);
5421 hook_executor.collect_shell_env(&hook_ctx)
5422 } else {
5423 std::collections::HashMap::new()
5424 };
5425 if read_only_shell {
5426 let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" };
5427 let inert_git_helper = if cfg!(windows) {
5428 "cmd.exe /d /c exit 1"
5429 } else {
5430 "/usr/bin/false"
5431 };
5432 // Read-only Bash is intentionally a small inspection surface. Git
5433 // can otherwise invoke operator/repository configured helpers
5434 // while performing nominal reads (a pager or fsmonitor), and it
5435 // may opportunistically refresh the index. These environment
5436 // overrides make those reads non-interactive and suppress the
5437 // optional mutation/extension seams; the command classifier and
5438 // machine authority gate remain authoritative as well.
5439 extra_env.insert("GIT_OPTIONAL_LOCKS".to_string(), "0".to_string());
5440 extra_env.insert("GIT_NO_LAZY_FETCH".to_string(), "1".to_string());
5441 extra_env.insert("GIT_PAGER".to_string(), String::new());
5442 extra_env.insert("GH_PAGER".to_string(), String::new());
5443 extra_env.insert("GH_PROMPT_DISABLED".to_string(), "1".to_string());
5444 extra_env.insert("GH_NO_UPDATE_NOTIFIER".to_string(), "1".to_string());
5445 // The classifier rejects explicit GHES repo/URL targets. Pin the
5446 // implicit environment side too, so inherited GH_HOST/GH_REPO
5447 // cannot redirect the call after api.github.com was approved.
5448 extra_env.insert("GH_HOST".to_string(), "github.com".to_string());
5449 extra_env.insert("GH_REPO".to_string(), String::new());
5450 extra_env.insert("PAGER".to_string(), String::new());
5451 extra_env.insert("ENV".to_string(), String::new());
5452 extra_env.insert("BASH_ENV".to_string(), String::new());
5453 extra_env.insert("CDPATH".to_string(), String::new());
5454 extra_env.insert("RIPGREP_CONFIG_PATH".to_string(), String::new());
5455 // Ignore user/system Git configuration and replace any repository
5456 // external diff helper with a fixed inert executable. Repository
5457 // config and attributes are attacker-controlled evidence inputs;
5458 // a nominal `git diff/log/show` must not turn them into programs.
5459 extra_env.insert("GIT_CONFIG_NOSYSTEM".to_string(), "1".to_string());
5460 extra_env.insert("GIT_CONFIG_GLOBAL".to_string(), null_device.to_string());
5461 extra_env.insert("GIT_CONFIG_PARAMETERS".to_string(), String::new());
5462 extra_env.insert(
5463 "GIT_EXTERNAL_DIFF".to_string(),
5464 inert_git_helper.to_string(),
5465 );
5466 extra_env.insert("GIT_ATTR_NOSYSTEM".to_string(), "1".to_string());
5467 if let Some(path) = readonly_sanitized_path(&context.workspace) {
5468 extra_env.insert("PATH".to_string(), path);
5469 }
5470 extra_env.insert("GIT_CONFIG_COUNT".to_string(), "3".to_string());
5471 extra_env.insert("GIT_CONFIG_KEY_0".to_string(), "core.fsmonitor".to_string());
5472 extra_env.insert("GIT_CONFIG_VALUE_0".to_string(), "false".to_string());
5473 extra_env.insert("GIT_CONFIG_KEY_1".to_string(), "core.hooksPath".to_string());
5474 extra_env.insert("GIT_CONFIG_VALUE_1".to_string(), null_device.to_string());
5475 extra_env.insert(
5476 "GIT_CONFIG_KEY_2".to_string(),
5477 "log.showSignature".to_string(),
5478 );
5479 extra_env.insert("GIT_CONFIG_VALUE_2".to_string(), "false".to_string());
5480 extra_env.insert(READONLY_ENV_MARKER.to_string(), "1".to_string());
5481 }
5482
5483 let command_expense = infer_command_expense(command);
5484 let heavy_permit = acquire_heavy_command_permit(command, context.cancel_token.as_ref())
5485 .await
5486 .map_err(|error| ToolError::execution_failed(error.to_string()))?;
5487 let admission_wait_ms = heavy_permit
5488 .as_ref()
5489 .map(|permit| u64::try_from(permit.queued_for().as_millis()).unwrap_or(u64::MAX));
5490 let admission_limit = heavy_permit.as_ref().map(HeavyCommandPermit::limit);
5491 let admission_memory = heavy_permit
5492 .as_ref()
5493 .map(HeavyCommandPermit::memory_pressure);
5494
5495 // Route through external sandbox backend when configured.
5496 if let Some(backend) = &context.sandbox_backend {
5497 if self.optional_timeout {
5498 return Err(ToolError::not_available(
5499 "bash is unavailable with this external sandbox backend because it cannot preserve combined streaming output and timeout semantics. Use the native sandbox or search for the backend-specific shell tool.",
5500 ));
5501 }
5502 if matches!(context.shell_policy, ShellPolicy::ReadOnly) {
5503 return Err(ToolError::permission_denied(
5504 "Read-only Scout shell cannot use an external sandbox backend because that interface accepts a raw command string rather than the classifier-approved argv. Use File read/search, or run this Scout without the external backend.",
5505 ));
5506 }
5507 if interactive {
5508 return Ok(ToolResult::error(
5509 "Interactive mode is not supported with external sandbox backends.",
5510 ));
5511 }
5512 if background {
5513 return Ok(ToolResult::error(
5514 "Background mode is not supported with external sandbox backends.",
5515 ));
5516 }
5517 if tty {
5518 return Ok(ToolResult::error(
5519 "TTY mode is not supported with external sandbox backends.",
5520 ));
5521 }
5522
5523 let started = std::time::Instant::now();
5524 let backend_result = backend.exec(command, &extra_env).await;
5525
5526 let result = match backend_result {
5527 Ok(output) => {
5528 let (stdout, stdout_meta) = truncate_with_meta(&output.stdout);
5529 let (stderr, stderr_meta) = truncate_with_meta(&output.stderr);
5530 ShellResult {
5531 task_id: None,
5532 status: if output.exit_code == 0 {
5533 ShellStatus::Completed
5534 } else {
5535 ShellStatus::Failed
5536 },
5537 exit_code: Some(i64::from(output.exit_code)),
5538 stdout,
5539 stderr,
5540 duration_ms: u64::try_from(started.elapsed().as_millis())
5541 .unwrap_or(u64::MAX),
5542 stdout_len: stdout_meta.original_len,
5543 stderr_len: stderr_meta.original_len,
5544 stdout_omitted: stdout_meta.omitted,
5545 stderr_omitted: stderr_meta.omitted,
5546 stdout_truncated: stdout_meta.truncated,
5547 stderr_truncated: stderr_meta.truncated,
5548 sandboxed: true,
5549 sandbox_type: Some("opensandbox".to_string()),
5550 sandbox_denied: false,
5551 }
5552 }
5553 Err(e) => {
5554 return Ok(ToolResult::error(format!("Sandbox backend error: {e}")));
5555 }
5556 };
5557
5558 // Build result (reuse the existing output rendering below).
5559 let stdout_summary = summarize_output(&result.stdout);
5560 let stderr_summary = summarize_output(&result.stderr);
5561 let summary = if !stderr_summary.is_empty() {
5562 stderr_summary.clone()
5563 } else {
5564 stdout_summary.clone()
5565 };
5566 let python_dependency_hint = python_build_dependency_hint(command, &result);
5567 let mut output = if result.stdout.is_empty() && result.stderr.is_empty() {
5568 "(no output)".to_string()
5569 } else if result.stderr.is_empty() {
5570 result.stdout.clone()
5571 } else {
5572 format!("{}\n\nSTDERR:\n{}", result.stdout, result.stderr)
5573 };
5574 if let Some(hint) = python_dependency_hint {
5575 output = format!("{hint}\n\n{output}");
5576 }
5577
5578 let mut metadata = json!({
5579 "exit_code": result.exit_code,
5580 "exit_code_hex": exit_code_hex(result.exit_code),
5581 "status": format!("{:?}", result.status),
5582 "duration_ms": result.duration_ms,
5583 "sandboxed": true,
5584 "sandbox_type": "opensandbox",
5585 "sandbox_denied": false,
5586 "task_id": result.task_id,
5587 "stdout_len": result.stdout_len,
5588 "stderr_len": result.stderr_len,
5589 "stdout_truncated": result.stdout_truncated,
5590 "stderr_truncated": result.stderr_truncated,
5591 "stdout_omitted": result.stdout_omitted,
5592 "stderr_omitted": result.stderr_omitted,
5593 "summary": summary,
5594 "stdout_summary": stdout_summary,
5595 "stderr_summary": stderr_summary,
5596 "safety_level": format!("{:?}", safety.level),
5597 "interactive": false,
5598 "canceled": false,
5599 "sandbox_backend": backend.kind().as_str(),
5600 "expense_class": match command_expense {
5601 CommandExpense::Heavy => "heavy",
5602 CommandExpense::Normal => "normal",
5603 },
5604 "resource_admission_wait_ms": admission_wait_ms,
5605 "resource_admission_limit": admission_limit,
5606 });
5607 attach_shell_owner_metadata(&mut metadata, context);
5608 attach_cargo_failure_summary(&mut metadata, command, &result);
5609 attach_python_build_dependency_hint(&mut metadata, python_dependency_hint);
5610
5611 return Ok(ToolResult {
5612 content: output,
5613 success: result.status == ShellStatus::Completed,
5614 metadata: Some(metadata),
5615 });
5616 }
5617
5618 let mut lifecycle_warning = None;
5619 let result = if interactive {
5620 let mut manager = context
5621 .shell_manager
5622 .lock()
5623 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
5624 let work_lifecycle = shell_work_lifecycle_from_context(context);
5625 let task_id = format!("shell_{}", &Uuid::new_v4().to_string()[..8]);
5626 let mut spawn_guard =
5627 ShellSpawnIntentGuard::new(work_lifecycle.clone(), &task_id, command);
5628 let result = manager.execute_interactive_with_policy_env(
5629 command,
5630 working_dir.as_deref(),
5631 timeout_value_ms,
5632 policy_override,
5633 extra_env,
5634 );
5635 match result {
5636 Ok(result) => {
5637 // The process result is authoritative once execution has
5638 // completed. Disarm before observing it so a graph-write
5639 // failure cannot relabel a successful command as Failed.
5640 spawn_guard.disarm();
5641 if let Some(lifecycle) = work_lifecycle.as_ref() {
5642 let raw_bytes = result.stdout_len.saturating_add(result.stderr_len);
5643 if let Err(err) = lifecycle.observe(&task_id, &result.status, 1, raw_bytes)
5644 {
5645 tracing::warn!(shell_id = %task_id, error = %err, "interactive shell completed but Work lifecycle reconciliation failed");
5646 lifecycle_warning = Some(err.to_string());
5647 }
5648 }
5649 Ok(result)
5650 }
5651 Err(err) => Err(err),
5652 }
5653 } else if background {
5654 let mut manager = context
5655 .shell_manager
5656 .lock()
5657 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
5658 let result = manager.execute_with_options_env_for_owner_and_work(
5659 command,
5660 working_dir.as_deref(),
5661 timeout_value_ms,
5662 true,
5663 stdin_data.as_deref(),
5664 tty,
5665 policy_override,
5666 extra_env,
5667 shell_job_owner_from_context(context),
5668 context.state_namespace.clone(),
5669 context.origin_tool_call_id.clone(),
5670 context.origin_turn_id.clone(),
5671 shell_work_lifecycle_from_context(context),
5672 None,
5673 persist,
5674 (1_000, 600_000),
5675 );
5676 if let (Ok(result), Some(permit)) = (&result, heavy_permit)
5677 && let Some(task_id) = result.task_id.as_deref()
5678 {
5679 manager
5680 .attach_heavy_permit(task_id, permit)
5681 .map_err(|error| ToolError::execution_failed(error.to_string()))?;
5682 }
5683 result
5684 } else {
5685 execute_foreground_via_background(
5686 context,
5687 command,
5688 heavy_permit,
5689 working_dir,
5690 timeout_ms,
5691 stdin_data.as_deref(),
5692 combined_output,
5693 policy_override,
5694 extra_env,
5695 matches!(context.shell_policy, ShellPolicy::ReadOnly) && !enforced_readonly,
5696 if self.optional_timeout {
5697 (1, BASH_MAX_TIMEOUT_MS)
5698 } else {
5699 (1_000, 600_000)
5700 },
5701 )
5702 .await
5703 };
5704
5705 match result {
5706 Ok(result) => {
5707 let backgrounded_foreground =
5708 !background && !interactive && result.status == ShellStatus::Running;
5709 if (background || backgrounded_foreground)
5710 && let (Some(shell_id), Some(task_id)) = (
5711 result.task_id.as_deref(),
5712 context.runtime.active_task_id.clone(),
5713 )
5714 && let Ok(mut manager) = context.shell_manager.lock()
5715 {
5716 let _ = manager.tag_linked_task(shell_id, Some(task_id));
5717 }
5718
5719 let was_cancelled = context
5720 .cancel_token
5721 .as_ref()
5722 .is_some_and(|token| token.is_cancelled());
5723 if self.optional_timeout {
5724 return finish_contract_bash_result(result, timeout_ms, context);
5725 }
5726 let task_id_str = result.task_id.clone().unwrap_or_default();
5727 let stdout_summary = summarize_output(&result.stdout);
5728 let stderr_summary = summarize_output(&result.stderr);
5729 let summary = if !stderr_summary.is_empty() {
5730 stderr_summary.clone()
5731 } else {
5732 stdout_summary.clone()
5733 };
5734 let network_restricted_hint =
5735 shell_network_restricted_hint(context, command, &result).map(str::to_string);
5736 let sandbox_denied_hint = if network_restricted_hint.is_none() {
5737 shell_sandbox_denied_hint(context, &result)
5738 } else {
5739 None
5740 };
5741 let provenance_hint = macos_provenance_hint(&result);
5742 let python_dependency_hint = python_build_dependency_hint(command, &result);
5743 let mut output = if interactive {
5744 format!(
5745 "Interactive command completed (exit code: {:?})",
5746 result.exit_code
5747 )
5748 } else if result.status == ShellStatus::Completed {
5749 if result.stdout.is_empty() && result.stderr.is_empty() {
5750 "(no output)".to_string()
5751 } else if result.stderr.is_empty() {
5752 result.stdout.clone()
5753 } else {
5754 format!("{}\n\nSTDERR:\n{}", result.stdout, result.stderr)
5755 }
5756 } else if persist && result.status == ShellStatus::Running {
5757 format!(
5758 "Persistent service staged: {task_id_str}. Probe readiness with a separate command. Codewhale will transfer ownership only if this exec finishes successfully."
5759 )
5760 } else if result.status == ShellStatus::Running {
5761 let completion_contract = if context.owner_agent_id.is_some() {
5762 "completion stays in task/status and is not injected into the parent model."
5763 } else {
5764 "completion is delivered to the model as an internal runtime event and shown in task/status state."
5765 };
5766 if backgrounded_foreground {
5767 format!(
5768 "Foreground shell wait moved to /jobs: {task_id_str}\n\nReturns immediately; {completion_contract} Keep working; call Bash action=\"wait\" task_id=\"{task_id_str}\" at a true dependency to block until completion or timeout."
5769 )
5770 } else {
5771 format!(
5772 "Background task started: {task_id_str}\n\nReturns immediately; {completion_contract} Codewhale terminates this task when the session exits. If a service must survive a successful headless exec, start it with background=true and persist=true. Keep working; call Bash action=\"wait\" task_id=\"{task_id_str}\" at a true dependency to block until completion or timeout."
5773 )
5774 }
5775 } else if result.status == ShellStatus::Killed && was_cancelled {
5776 format!(
5777 "Command canceled; process killed.\n\nSTDOUT:\n{}\n\nSTDERR:\n{}",
5778 result.stdout, result.stderr
5779 )
5780 } else if result.status == ShellStatus::TimedOut {
5781 format!(
5782 "Command timed out after {timeout_value_ms}ms; process killed.\n\n{FOREGROUND_TIMEOUT_RECOVERY_HINT}\n\nSTDOUT:\n{}\n\nSTDERR:\n{}",
5783 result.stdout, result.stderr
5784 )
5785 } else {
5786 format!(
5787 "Command failed ({})\n\nSTDOUT:\n{}\n\nSTDERR:\n{}",
5788 exit_code_label(result.exit_code),
5789 result.stdout,
5790 result.stderr
5791 )
5792 };
5793 if let Some(hint) = network_restricted_hint.as_deref() {
5794 output = format!("{hint}\n\n{output}");
5795 }
5796 if let Some(hint) = sandbox_denied_hint.as_deref() {
5797 output = format!("{hint}\n\n{output}");
5798 }
5799 if let Some(hint) = provenance_hint {
5800 output = format!("{hint}\n\n{output}");
5801 }
5802 if let Some(hint) = python_dependency_hint {
5803 output = format!("{hint}\n\n{output}");
5804 }
5805
5806 let mut metadata = json!({
5807 "exit_code": result.exit_code,
5808 "exit_code_hex": exit_code_hex(result.exit_code),
5809 "status": format!("{:?}", result.status),
5810 "duration_ms": result.duration_ms,
5811 "sandboxed": result.sandboxed,
5812 "sandbox_type": result.sandbox_type,
5813 "sandbox_denied": result.sandbox_denied,
5814 "task_id": result.task_id,
5815 "stdout_len": result.stdout_len,
5816 "stderr_len": result.stderr_len,
5817 "stdout_truncated": result.stdout_truncated,
5818 "stderr_truncated": result.stderr_truncated,
5819 "stdout_omitted": result.stdout_omitted,
5820 "stderr_omitted": result.stderr_omitted,
5821 "lifecycle_warning": lifecycle_warning,
5822 "expense_class": match command_expense {
5823 CommandExpense::Heavy => "heavy",
5824 CommandExpense::Normal => "normal",
5825 },
5826 "resource_admission_wait_ms": admission_wait_ms,
5827 "resource_admission_limit": admission_limit,
5828 "resource_admission_memory": match admission_memory {
5829 Some(MemoryPressure::Critical) => "critical",
5830 Some(MemoryPressure::Constrained) => "constrained",
5831 Some(MemoryPressure::Nominal) | None => "nominal",
5832 Some(MemoryPressure::Unknown) => "unknown",
5833 },
5834 "summary": summary,
5835 "stdout_summary": stdout_summary,
5836 "stderr_summary": stderr_summary,
5837 "safety_level": format!("{:?}", safety.level),
5838 "interactive": interactive,
5839 "combined_output": combined_output,
5840 "canceled": was_cancelled,
5841 "execpolicy": execpolicy_decision.as_ref().map(|decision| match decision {
5842 RuleDecision::Allow => json!({
5843 "decision": "allow",
5844 }),
5845 RuleDecision::Deny(reason) => json!({
5846 "decision": "deny",
5847 "reason": reason,
5848 }),
5849 RuleDecision::AskUser(reason) => json!({
5850 "decision": "ask_user",
5851 "reason": reason,
5852 }),
5853 }),
5854 });
5855 metadata["backgrounded"] = json!(background || backgrounded_foreground);
5856 if persist {
5857 metadata["persist_requested"] = json!(true);
5858 metadata["ownership"] = json!("managed_pending_exec_success");
5859 metadata["background_policy"] = json!("pending_ownership_transfer");
5860 metadata["auto_resume_on_completion"] = json!(false);
5861 metadata["completion_surface"] = json!("headless_exec_release_receipt");
5862 } else if background || backgrounded_foreground {
5863 let child_owned = context.owner_agent_id.is_some();
5864 metadata["auto_resume_on_completion"] = json!(!child_owned);
5865 metadata["completion_surface"] = if child_owned {
5866 json!("task_status_and_explicit_wait")
5867 } else {
5868 json!("runtime_event_and_task_status")
5869 };
5870 metadata["background_policy"] = json!("nonblocking");
5871 }
5872 if result.status == ShellStatus::TimedOut && !background && !interactive {
5873 metadata["foreground_timeout_recovery"] = json!({
5874 "process_killed": true,
5875 "hint": FOREGROUND_TIMEOUT_RECOVERY_HINT,
5876 "recommended_tools": ["Bash", "task_shell_start", "task_shell_wait"],
5877 "rerun_as": {"tool": "Bash", "action": "run", "background": true},
5878 "poll_with": [
5879 {"tool": "Bash", "action": "wait"},
5880 {"tool": "task_shell_wait"}
5881 ]
5882 });
5883 }
5884 if let Some(hint) = network_restricted_hint {
5885 metadata["sandbox_network_restricted"] = json!(true);
5886 metadata["sandbox_network_denied_hint"] = json!(hint);
5887 }
5888 if let Some(hint) = sandbox_denied_hint {
5889 metadata["sandbox_denied_hint"] = json!(hint);
5890 }
5891 if provenance_hint.is_some() {
5892 metadata["macos_provenance_restricted"] = json!(true);
5893 }
5894 attach_shell_owner_metadata(&mut metadata, context);
5895 attach_cargo_failure_summary(&mut metadata, command, &result);
5896 attach_python_build_dependency_hint(&mut metadata, python_dependency_hint);
5897
5898 Ok(ToolResult {
5899 content: output,
5900 success: result.status == ShellStatus::Completed
5901 || result.status == ShellStatus::Running,
5902 metadata: Some(metadata),
5903 })
5904 }
5905 Err(e) => Ok(ToolResult::error(shell_execution_failed_message(&e))),
5906 }
5907 }
5908 }
5909
5910 /// Render a spawn/stream failure for the model and the user: the full cause
5911 /// chain (an anyhow context alone hides the `ENOSPC`/`EMFILE` underneath) plus,
5912 /// when the innermost error looks like host resource exhaustion, what to do
5913 /// about it. The shell tool keeps no state from a failed spawn, so retrying is
5914 /// always safe.
5915 fn shell_execution_failed_message(error: &anyhow::Error) -> String {
5916 let hint = error
5917 .chain()
5918 .filter_map(|cause| cause.downcast_ref::<io::Error>())
5919 .find_map(output::resource_exhaustion_hint);
5920 match hint {
5921 Some(hint) => format!(
5922 "Shell execution failed: {error:#}. Likely host resource exhaustion — {hint}. The shell tool itself is still usable; the next call starts fresh."
5923 ),
5924 None => format!("Shell execution failed: {error:#}"),
5925 }
5926 }
5927
5928 /// Maximum deliberate dependency-barrier wait accepted by `exec_shell_wait`.
5929 pub(crate) const EXEC_SHELL_WAIT_MAX_TIMEOUT_MS: u64 = 600_000;
5930
5931 impl BashTool {
5932 async fn execute_wait(
5933 &self,
5934 input: &serde_json::Value,
5935 context: &ToolContext,
5936 ) -> Result<ToolResult, ToolError> {
5937 // Multi-task wait (#5549): task_ids + until=any|all. The validated
5938 // single-task path stays unchanged below when task_ids is absent.
5939 if let Some(task_ids) = input.get("task_ids") {
5940 let ids = task_ids
5941 .as_array()
5942 .ok_or_else(|| type_mismatch("task_ids", task_ids, "an array of strings"))?;
5943 let ids = ids
5944 .iter()
5945 .map(|value| {
5946 value
5947 .as_str()
5948 .ok_or_else(|| type_mismatch("task_ids", value, "strings"))
5949 .map(str::to_string)
5950 })
5951 .collect::<Result<Vec<_>, _>>()?;
5952 return self.execute_wait_many(input, context, &ids).await;
5953 }
5954 let task_id = required_task_id(input)?;
5955 let wait = match first_present_field(input, &["wait", "block"]) {
5956 None => true,
5957 Some((name, value)) => value
5958 .as_bool()
5959 .ok_or_else(|| type_mismatch(name, value, "a boolean"))?,
5960 };
5961 let timeout_ms = wait_timeout_ms(input)?;
5962
5963 let (delta, wait_canceled) = if wait {
5964 wait_for_shell_delta_cancellable(context, task_id, timeout_ms).await?
5965 } else {
5966 let mut manager = context
5967 .shell_manager
5968 .lock()
5969 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
5970 let delta = manager
5971 .get_output_delta_for_session(&context.state_namespace, task_id, false, timeout_ms)
5972 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
5973 (delta, false)
5974 };
5975
5976 let status = delta.result.status.clone();
5977 let mut result = build_shell_delta_tool_result(delta, context);
5978 if let Some(metadata) = result.metadata.as_mut()
5979 && let Some(object) = metadata.as_object_mut()
5980 {
5981 object.insert("wait_timeout_ms".to_string(), json!(timeout_ms));
5982 }
5983 if wait_canceled {
5984 if matches!(status, ShellStatus::Running) {
5985 result.content = format!(
5986 "Wait canceled; background shell task {task_id} is still running.\n\n{}",
5987 result.content
5988 );
5989 }
5990 if let Some(metadata) = result.metadata.as_mut()
5991 && let Some(object) = metadata.as_object_mut()
5992 {
5993 object.insert("wait_canceled".to_string(), json!(true));
5994 }
5995 }
5996
5997 Ok(result)
5998 }
5999
6000 /// Wait on several background tasks at once (#5549).
6001 ///
6002 /// `until` selects the completion condition: `any` returns as soon as one
6003 /// task is terminal, `all` waits for every one. Unknown task ids fail the
6004 /// call (same contract as the single-task path); a timeout reports the
6005 /// still-running ids so the caller can cancel or wait again.
6006 async fn execute_wait_many(
6007 &self,
6008 input: &serde_json::Value,
6009 context: &ToolContext,
6010 task_ids: &[String],
6011 ) -> Result<ToolResult, ToolError> {
6012 let until = match input.get("until").and_then(serde_json::Value::as_str) {
6013 None | Some("all") => "all",
6014 Some("any") => "any",
6015 Some(other) => {
6016 return Err(ToolError::invalid_input(format!(
6017 "until must be \"any\" or \"all\", got {other}"
6018 )));
6019 }
6020 };
6021 let wait = match first_present_field(input, &["wait", "block"]) {
6022 None => true,
6023 Some((name, value)) => value
6024 .as_bool()
6025 .ok_or_else(|| type_mismatch(name, value, "a boolean"))?,
6026 };
6027 let timeout_ms = wait_timeout_ms(input)?;
6028 let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
6029 let mut timed_out = false;
6030 let mut wait_canceled = false;
6031
6032 let snapshot =
6033 |manager: &mut ShellManager| -> Result<Vec<(String, ShellStatus)>, ToolError> {
6034 task_ids
6035 .iter()
6036 .map(|id| {
6037 let detail = manager
6038 .inspect_job(id)
6039 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
6040 Ok((id.clone(), detail.snapshot.status))
6041 })
6042 .collect()
6043 };
6044
6045 let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS;
6046 let statuses = loop {
6047 let current = {
6048 let mut manager = context
6049 .shell_manager
6050 .lock()
6051 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
6052 snapshot(&mut manager)?
6053 };
6054 if context
6055 .cancel_token
6056 .as_ref()
6057 .is_some_and(|token| token.is_cancelled())
6058 {
6059 wait_canceled = true;
6060 break current;
6061 }
6062 let running = current
6063 .iter()
6064 .filter(|(_, status)| *status == ShellStatus::Running)
6065 .count();
6066 let terminal = current.len().saturating_sub(running);
6067 let satisfied = if until == "any" {
6068 terminal >= 1
6069 } else {
6070 running == 0
6071 };
6072 if !wait || satisfied {
6073 break current;
6074 }
6075 if std::time::Instant::now() >= deadline {
6076 timed_out = true;
6077 break current;
6078 }
6079 tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await;
6080 poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS);
6081 };
6082
6083 let running_after = statuses
6084 .iter()
6085 .filter(|(_, status)| *status == ShellStatus::Running)
6086 .count();
6087 let settled = statuses.len().saturating_sub(running_after);
6088 let lines: Vec<String> = statuses
6089 .iter()
6090 .map(|(id, status)| format!("{id}: {status:?}"))
6091 .collect();
6092 let still_running: Vec<&str> = statuses
6093 .iter()
6094 .filter(|(_, status)| *status == ShellStatus::Running)
6095 .map(|(id, _)| id.as_str())
6096 .collect();
6097 let summary = if timed_out {
6098 format!(
6099 "wait timed out after {}ms; still running: {}",
6100 timeout_ms,
6101 if still_running.is_empty() {
6102 "none".to_string()
6103 } else {
6104 still_running.join(", ")
6105 }
6106 )
6107 } else if wait_canceled {
6108 format!(
6109 "wait canceled; still running: {}",
6110 if still_running.is_empty() {
6111 "none".to_string()
6112 } else {
6113 still_running.join(", ")
6114 }
6115 )
6116 } else {
6117 format!(
6118 "{settled} of {} background command{} settled; remaining running: {}",
6119 statuses.len(),
6120 if statuses.len() == 1 { "" } else { "s" },
6121 if still_running.is_empty() {
6122 "none".to_string()
6123 } else {
6124 still_running.join(", ")
6125 }
6126 )
6127 };
6128 let content = format!("{summary}\n{}\n", lines.join("\n"));
6129 let mut metadata = serde_json::Map::new();
6130 metadata.insert(
6131 "statuses".to_string(),
6132 serde_json::Value::Object(
6133 statuses
6134 .iter()
6135 .map(|(id, status)| (id.clone(), serde_json::json!(format!("{status:?}"))))
6136 .collect::<serde_json::Map<_, _>>(),
6137 ),
6138 );
6139 metadata.insert("wait_timeout_ms".to_string(), json!(timeout_ms));
6140 metadata.insert("until".to_string(), json!(until));
6141 metadata.insert("timed_out".to_string(), json!(timed_out));
6142 if wait_canceled {
6143 metadata.insert("wait_canceled".to_string(), json!(true));
6144 }
6145 Ok(ToolResult {
6146 content: content.trim().to_string(),
6147 success: true,
6148 metadata: Some(serde_json::Value::Object(metadata)),
6149 })
6150 }
6151
6152 async fn execute_interact(
6153 &self,
6154 input: &serde_json::Value,
6155 context: &ToolContext,
6156 ) -> Result<ToolResult, ToolError> {
6157 let task_id = required_task_id(input)?;
6158 let close_stdin = optional_bool(input, "close_stdin", false)?;
6159 let timeout_ms = optional_u64(input, "timeout_ms", 1_000)?;
6160 // Same strict-type contract as `run` (2026-08-04): a non-string here
6161 // was silently dropped, so an `interact` call reported success while
6162 // writing nothing to the child's stdin. Alias order also matches
6163 // `run` now — `stdin` first — so the same payload reaches the same
6164 // place whichever spelling the model uses.
6165 let interaction_input = match first_present_field(input, &["stdin", "input", "data"]) {
6166 None => "",
6167 Some((name, value)) => value
6168 .as_str()
6169 .ok_or_else(|| type_mismatch(name, value, "a string"))?,
6170 };
6171
6172 {
6173 let mut manager = context
6174 .shell_manager
6175 .lock()
6176 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
6177 if !interaction_input.is_empty() || close_stdin {
6178 manager
6179 .write_stdin_for_session(
6180 &context.state_namespace,
6181 task_id,
6182 interaction_input,
6183 close_stdin,
6184 )
6185 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
6186 }
6187 }
6188
6189 let mut elapsed = 0u64;
6190 loop {
6191 if context
6192 .cancel_token
6193 .as_ref()
6194 .is_some_and(|token| token.is_cancelled())
6195 {
6196 let mut manager = context
6197 .shell_manager
6198 .lock()
6199 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
6200 let delta = manager
6201 .get_output_delta_for_session(&context.state_namespace, task_id, false, 0)
6202 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
6203 let mut result = build_shell_delta_tool_result(delta, context);
6204 if let Some(metadata) = result.metadata.as_mut()
6205 && let Some(object) = metadata.as_object_mut()
6206 {
6207 object.insert("wait_canceled".to_string(), json!(true));
6208 }
6209 return Ok(result);
6210 }
6211
6212 let delta = {
6213 let mut manager = context
6214 .shell_manager
6215 .lock()
6216 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
6217 manager
6218 .get_output_delta_for_session(&context.state_namespace, task_id, false, 0)
6219 .map_err(|err| ToolError::execution_failed(err.to_string()))?
6220 };
6221
6222 if !delta.result.stdout.is_empty()
6223 || !delta.result.stderr.is_empty()
6224 || delta.result.status != ShellStatus::Running
6225 || elapsed >= timeout_ms
6226 {
6227 return Ok(build_shell_delta_tool_result(delta, context));
6228 }
6229
6230 tokio::time::sleep(Duration::from_millis(50)).await;
6231 elapsed = elapsed.saturating_add(50);
6232 }
6233 }
6234
6235 async fn execute_cancel(
6236 &self,
6237 input: &serde_json::Value,
6238 context: &ToolContext,
6239 ) -> Result<ToolResult, ToolError> {
6240 let cancel_all = optional_bool(input, "all", false)?;
6241 let mut manager = context
6242 .shell_manager
6243 .lock()
6244 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
6245
6246 if cancel_all {
6247 let results = manager
6248 .kill_running_for_session(&context.state_namespace)
6249 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
6250 if results.is_empty() {
6251 return Ok(ToolResult {
6252 content: "No running background commands.".to_string(),
6253 success: true,
6254 metadata: Some(json!({
6255 "status": "Noop",
6256 "canceled": 0,
6257 "task_ids": [],
6258 })),
6259 });
6260 }
6261
6262 let task_ids = results
6263 .iter()
6264 .filter_map(|result| result.task_id.clone())
6265 .collect::<Vec<_>>();
6266 return Ok(ToolResult {
6267 content: format!(
6268 "Canceled {} background command{}: {}",
6269 task_ids.len(),
6270 if task_ids.len() == 1 { "" } else { "s" },
6271 task_ids.join(", ")
6272 ),
6273 success: true,
6274 metadata: Some(json!({
6275 "status": "Killed",
6276 "canceled": task_ids.len(),
6277 "task_ids": task_ids,
6278 })),
6279 });
6280 }
6281
6282 let task_id = required_task_id(input)?;
6283 let result = manager
6284 .kill_for_session(&context.state_namespace, task_id)
6285 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
6286 let task_id = result
6287 .task_id
6288 .clone()
6289 .unwrap_or_else(|| task_id.to_string());
6290 Ok(ToolResult {
6291 content: format!("Canceled background command: {task_id}"),
6292 success: true,
6293 metadata: Some(json!({
6294 "status": format!("{:?}", result.status),
6295 "task_id": task_id,
6296 "exit_code": result.exit_code,
6297 "duration_ms": result.duration_ms,
6298 })),
6299 })
6300 }
6301 }
6302
6303 fn required_task_id(input: &serde_json::Value) -> Result<&str, ToolError> {
6304 // A present-but-non-string task_id is a type error, not a missing field:
6305 // "missing required field" sends the model's retry in the wrong
6306 // direction when it already supplied `task_id: 42` (2026-08-04 review).
6307 match first_present_field(input, &["task_id", "id"]) {
6308 None => Err(ToolError::missing_field("task_id")),
6309 Some((name, value)) => value
6310 .as_str()
6311 .ok_or_else(|| type_mismatch(name, value, "a string")),
6312 }
6313 }
6314
6315 /// First PRESENT value among aliased spellings of one field. `null` counts
6316 /// as absent, matching the `is_absent` rule the shared typed helpers use.
6317 fn first_present_field<'a>(
6318 input: &'a serde_json::Value,
6319 names: &[&'static str],
6320 ) -> Option<(&'static str, &'a serde_json::Value)> {
6321 names.iter().find_map(|name| match input.get(*name) {
6322 None | Some(serde_json::Value::Null) => None,
6323 Some(value) => Some((*name, value)),
6324 })
6325 }
6326
6327 /// Effective `action=wait` timeout in milliseconds. `timeout_ms` is
6328 /// canonical; `timeout_secs` (seconds) and bare `timeout` (milliseconds) are
6329 /// honored so a habit formed on other wait tools gets the duration it asked
6330 /// for instead of silently falling back to the 30 s default.
6331 fn wait_timeout_ms(input: &serde_json::Value) -> Result<u64, ToolError> {
6332 match first_present_field(input, &["timeout_ms", "timeout_secs", "timeout"]) {
6333 None => Ok(30_000),
6334 Some(("timeout_secs", value)) => {
6335 let secs = value
6336 .as_u64()
6337 .ok_or_else(|| type_mismatch("timeout_secs", value, "an integer"))?;
6338 Ok(secs.saturating_mul(1_000))
6339 }
6340 Some((name, value)) => value
6341 .as_u64()
6342 .ok_or_else(|| type_mismatch(name, value, "an integer")),
6343 }
6344 }
6345
6346 fn build_shell_delta_tool_result(delta: ShellDeltaResult, context: &ToolContext) -> ToolResult {
6347 let result = delta.result;
6348 let network_restricted_hint =
6349 shell_network_restricted_hint(context, &delta.command, &result).map(str::to_string);
6350 let sandbox_denied_hint = if network_restricted_hint.is_none() {
6351 shell_sandbox_denied_hint(context, &result)
6352 } else {
6353 None
6354 };
6355 let provenance_hint = macos_provenance_hint(&result);
6356 let python_dependency_hint = python_build_dependency_hint(&delta.command, &result);
6357 let stdout_summary = summarize_output(&result.stdout);
6358 let stderr_summary = summarize_output(&result.stderr);
6359 let summary = if !stderr_summary.is_empty() {
6360 stderr_summary.clone()
6361 } else {
6362 stdout_summary.clone()
6363 };
6364
6365 let mut output = if result.stdout.is_empty() && result.stderr.is_empty() {
6366 match result.status {
6367 ShellStatus::Running => "Background task running (no new output).".to_string(),
6368 ShellStatus::Completed => "(no new output)".to_string(),
6369 ShellStatus::Failed => {
6370 format!("Command failed ({})", exit_code_label(result.exit_code))
6371 }
6372 ShellStatus::TimedOut => "Command timed out (no new output).".to_string(),
6373 ShellStatus::Killed => "Command killed (no new output).".to_string(),
6374 }
6375 } else if result.stderr.is_empty() {
6376 result.stdout.clone()
6377 } else {
6378 format!("{}\n\nSTDERR:\n{}", result.stdout, result.stderr)
6379 };
6380 // The model cannot see metadata, so surface the real elapsed time in the
6381 // visible content. Without it every wait result looks identical whether
6382 // the task just started or has been running for minutes, which biases the
6383 // model into busy-polling short waits and misjudging long ones.
6384 output = format!("{}\n\n{output}", wait_timing_line(&result));
6385
6386 if let Some(hint) = network_restricted_hint.as_deref() {
6387 output = format!("{hint}\n\n{output}");
6388 }
6389 if let Some(hint) = sandbox_denied_hint.as_deref() {
6390 output = format!("{hint}\n\n{output}");
6391 }
6392 if let Some(hint) = provenance_hint {
6393 output = format!("{hint}\n\n{output}");
6394 }
6395 if let Some(hint) = python_dependency_hint {
6396 output = format!("{hint}\n\n{output}");
6397 }
6398
6399 let mut metadata = json!({
6400 "exit_code": result.exit_code,
6401 "exit_code_hex": exit_code_hex(result.exit_code),
6402 "status": format!("{:?}", result.status),
6403 "duration_ms": result.duration_ms,
6404 "sandboxed": result.sandboxed,
6405 "sandbox_type": result.sandbox_type,
6406 "sandbox_denied": result.sandbox_denied,
6407 "task_id": result.task_id,
6408 "stdout_len": result.stdout_len,
6409 "stderr_len": result.stderr_len,
6410 "stdout_truncated": result.stdout_truncated,
6411 "stderr_truncated": result.stderr_truncated,
6412 "stdout_omitted": result.stdout_omitted,
6413 "stderr_omitted": result.stderr_omitted,
6414 "stdout_total_len": delta.stdout_total_len,
6415 "stderr_total_len": delta.stderr_total_len,
6416 "summary": summary,
6417 "stdout_summary": stdout_summary,
6418 "stderr_summary": stderr_summary,
6419 "command": delta.command,
6420 "stream_delta": true,
6421 });
6422 attach_shell_owner_metadata(&mut metadata, context);
6423 attach_cargo_failure_summary(&mut metadata, &delta.command, &result);
6424 attach_python_build_dependency_hint(&mut metadata, python_dependency_hint);
6425
6426 let mut tool_result = ToolResult {
6427 content: output,
6428 success: matches!(result.status, ShellStatus::Completed | ShellStatus::Running),
6429 metadata: Some(metadata),
6430 };
6431 if let Some(hint) = network_restricted_hint
6432 && let Some(metadata) = tool_result.metadata.as_mut()
6433 && let Some(object) = metadata.as_object_mut()
6434 {
6435 object.insert("sandbox_network_restricted".to_string(), json!(true));
6436 object.insert("sandbox_network_denied_hint".to_string(), json!(hint));
6437 }
6438 if let Some(hint) = sandbox_denied_hint
6439 && let Some(metadata) = tool_result.metadata.as_mut()
6440 && let Some(object) = metadata.as_object_mut()
6441 {
6442 object.insert("sandbox_denied_hint".to_string(), json!(hint));
6443 }
6444 if provenance_hint.is_some()
6445 && let Some(metadata) = tool_result.metadata.as_mut()
6446 && let Some(object) = metadata.as_object_mut()
6447 {
6448 object.insert("macos_provenance_restricted".to_string(), json!(true));
6449 }
6450 tool_result
6451 }
6452
6453 /// Human-readable elapsed time for a shell task ("450 ms", "12.3 s", "2m5s").
6454 fn format_elapsed_ms(ms: u64) -> String {
6455 if ms < 1_000 {
6456 format!("{ms} ms")
6457 } else if ms < 60_000 {
6458 let secs = ms as f64 / 1_000.0;
6459 format!("{secs} s")
6460 } else {
6461 let total_secs = ms / 1_000;
6462 format!("{}m{}s", total_secs / 60, total_secs % 60)
6463 }
6464 }
6465
6466 /// One-line status + elapsed summary for wait/delta results, placed at the top
6467 /// of the visible content so the model can judge how long it actually waited.
6468 fn wait_timing_line(result: &ShellResult) -> String {
6469 let status_phrase = match result.status {
6470 ShellStatus::Running => "still running",
6471 ShellStatus::Completed => "completed",
6472 ShellStatus::Failed => "failed",
6473 ShellStatus::Killed => "killed",
6474 ShellStatus::TimedOut => "timed out",
6475 };
6476 let elapsed = format_elapsed_ms(result.duration_ms);
6477 match result.task_id.as_deref() {
6478 Some(task_id) => format!("Task {task_id} {status_phrase} after {elapsed}."),
6479 None => format!("Task {status_phrase} after {elapsed}."),
6480 }
6481 }
6482
6483 async fn wait_for_shell_delta_cancellable(
6484 context: &ToolContext,
6485 task_id: &str,
6486 timeout_ms: u64,
6487 ) -> Result<(ShellDeltaResult, bool), ToolError> {
6488 let timeout_ms = timeout_ms.clamp(1000, EXEC_SHELL_WAIT_MAX_TIMEOUT_MS);
6489 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
6490 let mut stdout_accum = String::new();
6491 let mut stderr_accum = String::new();
6492
6493 let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS;
6494 let (command, result, stdout_total_len, stderr_total_len) = loop {
6495 if context
6496 .cancel_token
6497 .as_ref()
6498 .is_some_and(|token| token.is_cancelled())
6499 {
6500 let mut manager = context
6501 .shell_manager
6502 .lock()
6503 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
6504 let delta = manager
6505 .get_output_delta_for_session(&context.state_namespace, task_id, false, 0)
6506 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
6507 append_shell_delta_output(&mut stdout_accum, &mut stderr_accum, &delta.result);
6508 return Ok((
6509 shell_delta_with_accumulated_output(
6510 delta.command,
6511 delta.result,
6512 &stdout_accum,
6513 &stderr_accum,
6514 delta.stdout_total_len,
6515 delta.stderr_total_len,
6516 ),
6517 true,
6518 ));
6519 }
6520
6521 let delta = {
6522 let mut manager = context
6523 .shell_manager
6524 .lock()
6525 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
6526 manager
6527 .get_output_delta_for_session(&context.state_namespace, task_id, false, 0)
6528 .map_err(|err| ToolError::execution_failed(err.to_string()))?
6529 };
6530
6531 let stdout_total_len = delta.stdout_total_len;
6532 let stderr_total_len = delta.stderr_total_len;
6533 let command = delta.command.clone();
6534 append_shell_delta_output(&mut stdout_accum, &mut stderr_accum, &delta.result);
6535
6536 let status = delta.result.status.clone();
6537 if status != ShellStatus::Running || Instant::now() >= deadline {
6538 break (command, delta.result, stdout_total_len, stderr_total_len);
6539 }
6540
6541 tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await;
6542 poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS);
6543 };
6544
6545 Ok((
6546 shell_delta_with_accumulated_output(
6547 command,
6548 result,
6549 &stdout_accum,
6550 &stderr_accum,
6551 stdout_total_len,
6552 stderr_total_len,
6553 ),
6554 false,
6555 ))
6556 }
6557
6558 fn append_shell_delta_output(
6559 stdout_accum: &mut String,
6560 stderr_accum: &mut String,
6561 result: &ShellResult,
6562 ) {
6563 if !result.stdout.is_empty() {
6564 stdout_accum.push_str(&result.stdout);
6565 }
6566 if !result.stderr.is_empty() {
6567 stderr_accum.push_str(&result.stderr);
6568 }
6569 }
6570
6571 fn shell_delta_with_accumulated_output(
6572 command: String,
6573 mut result: ShellResult,
6574 stdout_accum: &str,
6575 stderr_accum: &str,
6576 stdout_total_len: usize,
6577 stderr_total_len: usize,
6578 ) -> ShellDeltaResult {
6579 let (stdout, stdout_meta) = truncate_with_meta(stdout_accum);
6580 let (stderr, stderr_meta) = truncate_with_meta(stderr_accum);
6581 result.stdout = stdout;
6582 result.stderr = stderr;
6583 result.stdout_len = stdout_meta.original_len;
6584 result.stderr_len = stderr_meta.original_len;
6585 result.stdout_omitted = stdout_meta.omitted;
6586 result.stderr_omitted = stderr_meta.omitted;
6587 result.stdout_truncated = stdout_meta.truncated;
6588 result.stderr_truncated = stderr_meta.truncated;
6589
6590 ShellDeltaResult {
6591 command,
6592 result,
6593 stdout_total_len,
6594 stderr_total_len,
6595 }
6596 }
6597
6598 /// Tool for appending notes to a notes file.
6599 pub struct NoteTool;
6600
6601 #[async_trait]
6602 impl ToolSpec for NoteTool {
6603 fn name(&self) -> &'static str {
6604 "note"
6605 }
6606
6607 fn description(&self) -> &'static str {
6608 "Append a note to the agent notes file for persistent context across sessions."
6609 }
6610
6611 fn input_schema(&self) -> serde_json::Value {
6612 json!({
6613 "type": "object",
6614 "properties": {
6615 "content": {
6616 "type": "string",
6617 "description": "The note content to append"
6618 }
6619 },
6620 "required": ["content"]
6621 })
6622 }
6623
6624 fn capabilities(&self) -> Vec<ToolCapability> {
6625 vec![ToolCapability::WritesFiles]
6626 }
6627
6628 fn approval_requirement(&self) -> ApprovalRequirement {
6629 ApprovalRequirement::Auto // Notes are low-risk
6630 }
6631
6632 async fn execute(
6633 &self,
6634 input: serde_json::Value,
6635 context: &ToolContext,
6636 ) -> Result<ToolResult, ToolError> {
6637 let note_content = required_str(&input, "content")?;
6638
6639 // Ensure parent directory exists. Tool handlers run on the Tokio
6640 // runtime, so filesystem calls use tokio::fs (blocking-call
6641 // convention, #6149).
6642 if let Some(parent) = context.notes_path.parent() {
6643 tokio::fs::create_dir_all(parent).await.map_err(|e| {
6644 ToolError::execution_failed(format!("Failed to create notes directory: {e}"))
6645 })?;
6646 }
6647
6648 // Append to notes file
6649 let mut file = tokio::fs::OpenOptions::new()
6650 .create(true)
6651 .append(true)
6652 .open(&context.notes_path)
6653 .await
6654 .map_err(|e| ToolError::execution_failed(format!("Failed to open notes file: {e}")))?;
6655
6656 use tokio::io::AsyncWriteExt;
6657 file.write_all(format!("\n---\n{note_content}\n").as_bytes())
6658 .await
6659 .map_err(|e| ToolError::execution_failed(format!("Failed to write note: {e}")))?;
6660
6661 Ok(ToolResult::success(format!(
6662 "Note appended to {}",
6663 context.notes_path.display()
6664 )))
6665 }
6666 }
6667
6668 #[cfg(test)]
6669 #[path = "shell/tests/enforced_readonly.rs"]
6670 mod enforced_readonly_tests;
6671 #[cfg(test)]
6672 mod tests;
6673
6673 lines RUST