返回 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::io::{Read, Write};
16 use std::path::{Path, PathBuf};
17 use std::process::{Child, ChildStdin, Command, Stdio};
18 use std::sync::{Arc, Mutex};
19 use std::time::{Duration, Instant};
20 use uuid::Uuid;
21 use wait_timeout::ChildExt;
22
23 #[cfg(unix)]
24 use std::os::unix::process::CommandExt;
25 #[cfg(windows)]
26 use std::os::windows::io::AsRawHandle;
27 #[cfg(windows)]
28 use windows::Win32::Foundation::{CloseHandle, HANDLE};
29 #[cfg(windows)]
30 use windows::Win32::System::JobObjects::{
31 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
32 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
33 SetInformationJobObject, TerminateJobObject,
34 };
35 #[cfg(windows)]
36 use windows::core::PCWSTR;
37
38 #[cfg(not(target_env = "ohos"))]
39 use portable_pty::{CommandBuilder, PtySize, native_pty_system};
40
41 mod output;
42
43 use super::shell_output::{summarize_output, truncate_with_meta};
44 use crate::child_env;
45 use crate::sandbox::{
46 CommandSpec,
47 ExecEnv,
48 SandboxManager,
49 SandboxPolicy as ExecutionSandboxPolicy, // Rename to avoid conflict with spec::SandboxPolicy
50 SandboxType,
51 };
52 use crate::tools::resource_admission::{
53 CommandExpense, HeavyCommandPermit, MemoryPressure, acquire_heavy_command_permit,
54 infer_command_expense,
55 };
56 use crate::work_graph::{
57 EvidenceKind, EvidenceRef, OperationIntent, OperationOwnerSnapshot, OwnerState,
58 SharedWorkRuntime,
59 };
60 use crate::worker_profile::ShellPolicy;
61 use output::{tail_from_buffer, take_delta_from_buffer};
62
63 fn validate_shell_working_dir(path: &Path, inherited_session_workspace: bool) -> Result<()> {
64 let metadata = std::fs::metadata(path).with_context(|| {
65 let source = if inherited_session_workspace {
66 "saved session workspace"
67 } else {
68 "requested working directory"
69 };
70 format!(
71 "{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",
72 path.display()
73 )
74 })?;
75 if !metadata.is_dir() {
76 let source = if inherited_session_workspace {
77 "saved session workspace"
78 } else {
79 "requested working directory"
80 };
81 return Err(anyhow!(
82 "{source} is not a directory: {}. Resume/fork from an existing workspace or pass an explicit `working_dir`/`cwd`",
83 path.display()
84 ));
85 }
86 Ok(())
87 }
88
89 /// Status of a shell process
90 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91 pub enum ShellStatus {
92 Running,
93 Completed,
94 Failed,
95 Killed,
96 TimedOut,
97 }
98
99 /// Result from a shell command execution
100 #[derive(Debug, Clone, Serialize, Deserialize)]
101
102 pub struct ShellResult {
103 pub task_id: Option<String>,
104 pub status: ShellStatus,
105 /// Lossless process exit status. Windows exception/NTSTATUS values use
106 /// the full unsigned 32-bit range, so an i32 would corrupt them.
107 pub exit_code: Option<i64>,
108 pub stdout: String,
109 pub stderr: String,
110 pub duration_ms: u64,
111 /// Original stdout length in bytes.
112 #[serde(default)]
113 pub stdout_len: usize,
114 /// Original stderr length in bytes.
115 #[serde(default)]
116 pub stderr_len: usize,
117 /// Bytes omitted from stdout due to truncation.
118 #[serde(default)]
119 pub stdout_omitted: usize,
120 /// Bytes omitted from stderr due to truncation.
121 #[serde(default)]
122 pub stderr_omitted: usize,
123 /// Whether stdout was truncated.
124 #[serde(default)]
125 pub stdout_truncated: bool,
126 /// Whether stderr was truncated.
127 #[serde(default)]
128 pub stderr_truncated: bool,
129 /// Whether the command was executed in a sandbox.
130 #[serde(default)]
131 pub sandboxed: bool,
132 /// Type of sandbox used (if any).
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub sandbox_type: Option<String>,
135 /// Whether the command was blocked by sandbox restrictions.
136 #[serde(default)]
137 pub sandbox_denied: bool,
138 }
139
140 /// Compact, UI-oriented view of a tracked background shell job.
141 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142 pub struct ShellJobSnapshot {
143 pub id: String,
144 pub job_id: String,
145 pub command: String,
146 pub cwd: PathBuf,
147 pub status: ShellStatus,
148 pub exit_code: Option<i64>,
149 pub elapsed_ms: u64,
150 pub stdout_tail: String,
151 pub stderr_tail: String,
152 pub stdout_len: usize,
153 pub stderr_len: usize,
154 pub stdin_available: bool,
155 pub stale: bool,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub elapsed_since_output_ms: Option<u64>,
158 pub linked_task_id: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub owner_agent_id: Option<String>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub owner_agent_name: Option<String>,
163 }
164
165 /// Once-only completion event for a tracked background shell job.
166 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
167 pub struct ShellCompletionEvent {
168 pub task_id: String,
169 pub command: String,
170 pub status: ShellStatus,
171 pub exit_code: Option<i64>,
172 pub duration_ms: u64,
173 pub stdout_tail: String,
174 pub stderr_tail: String,
175 #[serde(default)]
176 pub stdout_len: usize,
177 #[serde(default)]
178 pub stderr_len: usize,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub evidence_ref: Option<String>,
181 pub linked_task_id: Option<String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub owner_agent_id: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub owner_agent_name: Option<String>,
186 }
187
188 /// Exact byte evidence captured alongside a bounded completion event.
189 #[derive(Debug, Clone)]
190 pub(crate) struct ShellCompletionEvidence {
191 pub event: ShellCompletionEvent,
192 stdout: Vec<u8>,
193 stderr: Vec<u8>,
194 }
195
196 impl ShellCompletionEvidence {
197 /// Encode each stream losslessly. UTF-8 remains readable; arbitrary bytes
198 /// use base64 so `retrieve_tool_result` can still recover exact output.
199 pub(crate) fn artifact_bytes(&self) -> Vec<u8> {
200 fn stream(bytes: &[u8]) -> serde_json::Value {
201 match std::str::from_utf8(bytes) {
202 Ok(content) => serde_json::json!({
203 "encoding": "utf-8",
204 "byte_length": bytes.len(),
205 "content": content,
206 }),
207 Err(_) => serde_json::json!({
208 "encoding": "base64",
209 "byte_length": bytes.len(),
210 "content": base64::engine::general_purpose::STANDARD.encode(bytes),
211 }),
212 }
213 }
214
215 serde_json::json!({
216 "schema": "codewhale.shell_completion.evidence.v1",
217 "task_id": self.event.task_id,
218 "command": self.event.command,
219 "status": format!("{:?}", self.event.status),
220 "exit_code": self.event.exit_code,
221 "duration_ms": self.event.duration_ms,
222 "stdout": stream(&self.stdout),
223 "stderr": stream(&self.stderr),
224 })
225 .to_string()
226 .into_bytes()
227 }
228 }
229
230 // Keep the two inline streams at a 2 KiB combined hard ceiling. The durable
231 // artifact carries the exact bytes beyond these diagnostic tails.
232 const SHELL_COMPLETION_TAIL_BYTES: usize = 1_024;
233
234 fn bounded_completion_tail(buffer: &Arc<Mutex<Vec<u8>>>, max_bytes: usize) -> (usize, String) {
235 let (total, candidate) = tail_from_buffer(buffer, max_bytes);
236 if candidate.len() <= max_bytes {
237 return (total, candidate);
238 }
239 let content_budget = max_bytes.saturating_sub(3);
240 let mut start = candidate.len().saturating_sub(content_budget);
241 while start < candidate.len() && !candidate.is_char_boundary(start) {
242 start += 1;
243 }
244 (total, format!("...{}", &candidate[start..]))
245 }
246
247 /// Optional owner attribution for background shell work.
248 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
249 pub struct ShellJobOwner {
250 pub agent_id: String,
251 pub agent_name: String,
252 }
253
254 /// Full output view used by `/jobs show <id>`.
255 #[derive(Debug, Clone, Serialize, Deserialize)]
256 pub struct ShellJobDetail {
257 pub snapshot: ShellJobSnapshot,
258 pub stdout: String,
259 pub stderr: String,
260 }
261
262 pub struct ShellDeltaResult {
263 pub command: String,
264 pub result: ShellResult,
265 pub stdout_total_len: usize,
266 pub stderr_total_len: usize,
267 }
268
269 enum ShellChild {
270 Process(Child),
271 #[cfg(not(target_env = "ohos"))]
272 Pty(Box<dyn portable_pty::Child + Send>),
273 }
274
275 #[cfg(unix)]
276 fn signal_child_process_group(child: &Child, signal: libc::c_int) -> std::io::Result<()> {
277 let pgid = child.id() as libc::pid_t;
278 if pgid <= 0 {
279 return Ok(());
280 }
281
282 let result = unsafe { libc::kill(-pgid, signal) };
283 if result == 0 {
284 Ok(())
285 } else {
286 let err = std::io::Error::last_os_error();
287 if err.raw_os_error() == Some(libc::ESRCH) {
288 // The group is already gone (or never formed); nothing to signal.
289 Ok(())
290 } else {
291 Err(err)
292 }
293 }
294 }
295
296 #[cfg(unix)]
297 fn kill_child_process_group(child: &mut Child) -> std::io::Result<()> {
298 let pgid = child.id() as libc::pid_t;
299 if pgid <= 0 {
300 return child.kill();
301 }
302
303 signal_child_process_group(child, libc::SIGKILL).or_else(|_| child.kill())
304 }
305
306 /// Bounded wait for the direct child to exit. Returns true once the child was
307 /// reaped (or the wait errored), false when the grace elapsed first. Unlike
308 /// `Child::wait`, this can never wedge the caller behind a child stuck in
309 /// uninterruptible sleep.
310 #[cfg(unix)]
311 fn wait_child_bounded(child: &mut Child, grace: Duration) -> bool {
312 let deadline = Instant::now() + grace;
313 loop {
314 match child.try_wait() {
315 Ok(Some(_)) | Err(_) => return true,
316 Ok(None) => {}
317 }
318 if Instant::now() >= deadline {
319 return false;
320 }
321 std::thread::sleep(Duration::from_millis(10));
322 }
323 }
324
325 /// Terminate a shell's whole process group with a bounded SIGTERM → SIGKILL
326 /// escalation (#52). The previous kill path SIGKILLed only the direct child
327 /// and then joined output-reader threads with no timeout, so the tool
328 /// returned whenever the command's descendants felt like exiting — observed
329 /// as a 120s foreground timeout returning after 300s. Every step here is
330 /// bounded: the tool returns at ~timeout + grace.
331 #[cfg(unix)]
332 fn terminate_child_process_group(child: &mut Child) -> std::io::Result<()> {
333 // Cooperative stop first so shells and their children can run traps and
334 // clean up; bounded so a SIGTERM-ignoring command cannot stall the caller.
335 let _ = signal_child_process_group(child, libc::SIGTERM);
336 if wait_child_bounded(child, KILL_TERM_GRACE) {
337 // The leader exited on SIGTERM; descendants may linger, so SIGKILL
338 // the rest of the group (ESRCH when it is already empty).
339 kill_child_process_group(child)?;
340 return Ok(());
341 }
342 kill_child_process_group(child)?;
343 let _ = wait_child_bounded(child, KILL_REAP_GRACE);
344 Ok(())
345 }
346
347 /// Configure parent-death signaling so shell-spawned children are reaped when
348 /// the TUI dies abnormally (#421). On Linux this installs
349 /// `PR_SET_PDEATHSIG(SIGTERM)` via `pre_exec` — the kernel then sends SIGTERM
350 /// to the child the moment the parent process exits, even on SIGKILL of the
351 /// TUI. The cancellation path already SIGKILLs the whole process group, so
352 /// this only fires when the parent dies without running its drop / cleanup
353 /// code (panic during shutdown, OOM, hardware crash, etc.).
354 ///
355 /// On macOS / Windows there's no kernel equivalent. The existing graceful
356 /// path (`kill_child_process_group` from the cancellation token) still
357 /// handles normal shutdown; abnormal exit can leak children — tracked as a
358 /// follow-up watchdog item per the original issue's acceptance criteria.
359 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
360 fn install_parent_death_signal(cmd: &mut Command) {
361 use std::os::unix::process::CommandExt;
362 // SAFETY: `pre_exec` runs in the child between fork and exec. The closure
363 // only calls `libc::prctl` with stack-allocated constant arguments and
364 // does not touch heap memory or the parent's locks. Both requirements
365 // (async-signal-safe + no allocation in the post-fork window) are met.
366 unsafe {
367 cmd.pre_exec(|| {
368 let result = libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0);
369 if result == -1 {
370 // Surface the errno but do not abort the spawn — the child
371 // will simply lose the parent-death cleanup safety net.
372 Err(std::io::Error::last_os_error())
373 } else {
374 Ok(())
375 }
376 });
377 }
378 }
379
380 /// Attach `args` to a `std::process::Command`, honoring shell-quoting on
381 /// Windows.
382 ///
383 /// Issue #1691: on Windows the shell command is invoked as
384 /// `cmd /C "chcp 65001 >NUL & <command>"`. Rust's `Command::arg` applies
385 /// MSVCRT (`CommandLineToArgvW`) escaping, turning the embedded `"` in a
386 /// quoted argument (e.g. `git commit -m "feat: complete sub-pages"`) into
387 /// `\"`. `cmd.exe` does NOT use MSVCRT parsing — it treats `\` literally and
388 /// `"` as a bare quote toggle — so the escaped payload is mis-tokenized and
389 /// `git` receives `feat:`, `complete`, `sub-pages"` as separate pathspecs
390 /// (the reported `pathspec 'sub-pages"' did not match` symptom). Passing the
391 /// `cmd /C` payload through `CommandExt::raw_arg` suppresses std's escaping so
392 /// the string reaches `cmd.exe` verbatim, exactly as a terminal would.
393 #[cfg(windows)]
394 fn push_shell_args(cmd: &mut Command, program: &str, args: &[String]) {
395 use std::os::windows::process::CommandExt;
396 // The `cmd /C <payload>` shape is the only place std's per-arg escaping
397 // corrupts a quoted command. Pass `/C` and the payload raw so the quotes
398 // survive; any other program keeps normal (correct) escaping. Match `cmd`
399 // by file stem so a full path (`C:\Windows\System32\cmd.exe`) or `.exe`
400 // suffix still triggers the raw-arg path.
401 let is_cmd = std::path::Path::new(program)
402 .file_stem()
403 .and_then(|s| s.to_str())
404 .map(|s| s.eq_ignore_ascii_case("cmd"))
405 .unwrap_or(false);
406 if is_cmd && args.len() == 2 && args[0].eq_ignore_ascii_case("/C") {
407 cmd.raw_arg(&args[0]);
408 cmd.raw_arg(&args[1]);
409 } else {
410 cmd.args(args);
411 }
412 }
413
414 #[cfg(not(windows))]
415 fn push_shell_args(cmd: &mut Command, _program: &str, args: &[String]) {
416 // Unix delegates tokenization entirely to `sh -c <command>`; the command
417 // string is passed as a single argv entry and never split by us.
418 cmd.args(args);
419 }
420
421 #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
422 fn install_parent_death_signal(_cmd: &mut Command) {
423 // No kernel-level equivalent on macOS / Windows. The cooperative
424 // cancellation + process_group SIGKILL path covers normal shutdown;
425 // abnormal exit (panic without unwind, SIGKILL of the TUI) can still
426 // leak children on those platforms — tracked as a follow-up.
427 }
428
429 #[cfg(windows)]
430 #[derive(Debug)]
431 struct WindowsJob {
432 handle: HANDLE,
433 }
434
435 #[cfg(windows)]
436 // SAFETY: Windows job handles are process-wide kernel handles. Moving the
437 // wrapper between threads does not invalidate the handle, and access is
438 // externally synchronized by ShellManager's mutex.
439 unsafe impl Send for WindowsJob {}
440 #[cfg(windows)]
441 // SAFETY: The wrapper exposes only terminate/drop operations around a kernel
442 // handle; concurrent use is guarded by ShellManager.
443 unsafe impl Sync for WindowsJob {}
444
445 #[cfg(windows)]
446 impl WindowsJob {
447 fn attach_to_child(child: &Child) -> std::io::Result<Self> {
448 let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? };
449 let job = Self { handle };
450
451 let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
452 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
453
454 unsafe {
455 SetInformationJobObject(
456 job.handle,
457 JobObjectExtendedLimitInformation,
458 &limits as *const _ as *const core::ffi::c_void,
459 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
460 )
461 .map_err(windows_io_error)?;
462
463 let process_handle = HANDLE(child.as_raw_handle());
464 AssignProcessToJobObject(job.handle, process_handle).map_err(windows_io_error)?;
465 }
466
467 Ok(job)
468 }
469
470 fn terminate(&self) -> std::io::Result<()> {
471 unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) }
472 }
473 }
474
475 #[cfg(windows)]
476 impl Drop for WindowsJob {
477 fn drop(&mut self) {
478 unsafe {
479 let _ = CloseHandle(self.handle);
480 }
481 }
482 }
483
484 #[cfg(windows)]
485 fn windows_io_error(error: windows::core::Error) -> std::io::Error {
486 std::io::Error::other(error)
487 }
488
489 #[cfg(windows)]
490 fn terminate_windows_job(job: Option<&WindowsJob>, child: &mut Child) -> std::io::Result<()> {
491 if let Some(job) = job {
492 match job.terminate() {
493 Ok(()) => return Ok(()),
494 Err(error) => {
495 tracing::warn!(
496 ?error,
497 "failed to terminate Windows job object; falling back to immediate child kill"
498 );
499 }
500 }
501 }
502 child.kill()
503 }
504
505 #[cfg(windows)]
506 fn terminate_and_close_windows_job(windows_job: Option<WindowsJob>) {
507 if let Some(job) = windows_job.as_ref()
508 && let Err(err) = job.terminate()
509 {
510 tracing::warn!(
511 ?err,
512 "failed to terminate Windows shell job before closing job handle"
513 );
514 }
515 drop(windows_job);
516 }
517
518 #[cfg(windows)]
519 fn terminate_child_and_close_windows_job(
520 windows_job: Option<WindowsJob>,
521 child: &mut Child,
522 ) -> std::io::Result<()> {
523 let result = terminate_windows_job(windows_job.as_ref(), child);
524 drop(windows_job);
525 result
526 }
527
528 #[cfg(windows)]
529 fn attach_windows_job(child: &Child, command: &str) -> Option<WindowsJob> {
530 match WindowsJob::attach_to_child(child) {
531 Ok(job) => Some(job),
532 Err(error) => {
533 tracing::warn!(
534 ?error,
535 command,
536 "failed to attach Windows shell process to job object; descendant cleanup degraded"
537 );
538 None
539 }
540 }
541 }
542
543 #[cfg(windows)]
544 fn terminate_unregistered_process(child: &mut Child, job: Option<&WindowsJob>) {
545 let _ = terminate_windows_job(job, child);
546 let _ = child.wait();
547 }
548
549 #[cfg(not(windows))]
550 fn terminate_unregistered_process(child: &mut Child) {
551 #[cfg(unix)]
552 {
553 let _ = kill_child_process_group(child);
554 let _ = wait_child_bounded(child, KILL_REAP_GRACE);
555 }
556 #[cfg(not(unix))]
557 {
558 let _ = child.kill();
559 let _ = child.wait();
560 }
561 }
562
563 #[derive(Clone, Copy, Debug)]
564 struct ShellExitStatus {
565 code: Option<i64>,
566 success: bool,
567 }
568
569 impl ShellExitStatus {
570 fn from_std(status: std::process::ExitStatus) -> Self {
571 Self {
572 code: status.code().map(std_exit_code_i64),
573 success: status.success(),
574 }
575 }
576
577 #[cfg(not(target_env = "ohos"))]
578 fn from_pty(status: portable_pty::ExitStatus) -> Self {
579 Self {
580 code: Some(i64::from(status.exit_code())),
581 success: status.success(),
582 }
583 }
584 }
585
586 #[cfg(windows)]
587 fn std_exit_code_i64(code: i32) -> i64 {
588 // std exposes Windows DWORD process statuses through i32. Reinterpret
589 // negative values as their original unsigned bit pattern so codes such
590 // as 0xC0000005 survive JSON, persistence, and diagnostics unchanged.
591 i64::from(code as u32)
592 }
593
594 #[cfg(not(windows))]
595 fn std_exit_code_i64(code: i32) -> i64 {
596 i64::from(code)
597 }
598
599 impl ShellChild {
600 fn try_wait(&mut self) -> std::io::Result<Option<ShellExitStatus>> {
601 match self {
602 ShellChild::Process(child) => child
603 .try_wait()
604 .map(|status| status.map(ShellExitStatus::from_std)),
605 #[cfg(not(target_env = "ohos"))]
606 ShellChild::Pty(child) => child
607 .try_wait()
608 .map(|status| status.map(ShellExitStatus::from_pty)),
609 }
610 }
611
612 #[cfg(not(windows))]
613 fn kill(&mut self) -> std::io::Result<()> {
614 match self {
615 #[cfg(unix)]
616 ShellChild::Process(child) => kill_child_process_group(child),
617 #[cfg(not(unix))]
618 ShellChild::Process(child) => child.kill(),
619 #[cfg(not(target_env = "ohos"))]
620 ShellChild::Pty(child) => child.kill(),
621 }
622 }
623 }
624
625 enum StdinWriter {
626 Pipe(ChildStdin),
627 #[cfg(not(target_env = "ohos"))]
628 Pty(Box<dyn Write + Send>),
629 }
630
631 impl StdinWriter {
632 fn write_all(&mut self, data: &[u8]) -> std::io::Result<()> {
633 match self {
634 StdinWriter::Pipe(stdin) => stdin.write_all(data),
635 #[cfg(not(target_env = "ohos"))]
636 StdinWriter::Pty(writer) => writer.write_all(data),
637 }
638 }
639
640 fn flush(&mut self) -> std::io::Result<()> {
641 match self {
642 StdinWriter::Pipe(stdin) => stdin.flush(),
643 #[cfg(not(target_env = "ohos"))]
644 StdinWriter::Pty(writer) => writer.flush(),
645 }
646 }
647 }
648
649 fn spawn_reader_thread<R: Read + Send + 'static>(
650 mut reader: R,
651 buffer: Arc<Mutex<Vec<u8>>>,
652 ) -> std::thread::JoinHandle<()> {
653 std::thread::spawn(move || {
654 let mut chunk = [0u8; 4096];
655 loop {
656 match reader.read(&mut chunk) {
657 Ok(0) => break,
658 Ok(n) => {
659 if let Ok(mut guard) = buffer.lock() {
660 guard.extend_from_slice(&chunk[..n]);
661 }
662 }
663 Err(_) => break,
664 }
665 }
666 })
667 }
668
669 const SYNC_READER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
670 const STALE_NO_OUTPUT_AFTER: Duration = Duration::from_secs(60);
671
672 /// Grace between SIGTERM and SIGKILL on the shell kill path (timeout,
673 /// cancel, drop). Bounded so a SIGTERM-ignoring command is force-killed
674 /// instead of stalling the tool (#52).
675 #[cfg(unix)]
676 const KILL_TERM_GRACE: Duration = Duration::from_millis(500);
677 /// Bounded reap wait after SIGKILL; a child stuck in uninterruptible sleep
678 /// must not wedge the caller behind an unbounded `wait`.
679 #[cfg(unix)]
680 const KILL_REAP_GRACE: Duration = Duration::from_millis(1_000);
681 /// Bounded join for output-reader threads after the process group is killed.
682 /// A descendant that escaped the group (its own session/process group) keeps
683 /// its inherited pipe write-end open, so the reader cannot see EOF until that
684 /// descendant exits on its own — an unbounded join held the shell-manager
685 /// lock for minutes and overshot the tool timeout (#52).
686 const READER_JOIN_GRACE: Duration = Duration::from_millis(2_000);
687
688 fn spawn_sync_reader_thread<R: Read + Send + 'static>(
689 mut reader: R,
690 ) -> std::sync::mpsc::Receiver<Vec<u8>> {
691 let (tx, rx) = std::sync::mpsc::channel();
692 std::thread::spawn(move || {
693 let mut buf = Vec::new();
694 let _ = reader.read_to_end(&mut buf);
695 tx.send(buf).ok();
696 });
697 rx
698 }
699
700 fn recv_sync_reader_output(rx: &std::sync::mpsc::Receiver<Vec<u8>>) -> Vec<u8> {
701 rx.recv_timeout(SYNC_READER_DRAIN_TIMEOUT)
702 .unwrap_or_default()
703 }
704
705 /// A background shell process being tracked
706 pub struct BackgroundShell {
707 pub id: String,
708 pub command: String,
709 pub working_dir: PathBuf,
710 pub status: ShellStatus,
711 pub exit_code: Option<i64>,
712 pub started_at: Instant,
713 last_output_at: Instant,
714 last_observed_output_len: usize,
715 pub sandbox_type: SandboxType,
716 pub linked_task_id: Option<String>,
717 pub owner_agent: Option<ShellJobOwner>,
718 stdout_buffer: Arc<Mutex<Vec<u8>>>,
719 stderr_buffer: Option<Arc<Mutex<Vec<u8>>>>,
720 heavy_permit: Option<HeavyCommandPermit>,
721 stdout_cursor: usize,
722 stderr_cursor: usize,
723 completion_reported: bool,
724 stdin: Option<StdinWriter>,
725 child: Option<ShellChild>,
726 #[cfg(windows)]
727 windows_job: Option<WindowsJob>,
728 stdout_thread: Option<std::thread::JoinHandle<()>>,
729 stderr_thread: Option<std::thread::JoinHandle<()>>,
730 work_lifecycle: Option<ShellWorkLifecycle>,
731 lifecycle_seq: u64,
732 last_lifecycle_status: Option<ShellStatus>,
733 last_lifecycle_bytes: usize,
734 }
735
736 #[derive(Clone)]
737 struct ShellWorkLifecycle {
738 work: SharedWorkRuntime,
739 session_id: String,
740 }
741
742 impl ShellWorkLifecycle {
743 fn register(&self, id: &str, command: &str) -> Result<()> {
744 self.work
745 .register_operation(
746 &self.session_id,
747 OperationIntent::new(
748 format!("shell:{id}"),
749 format!("Shell · {command}"),
750 false,
751 "exec_shell",
752 id,
753 ),
754 )
755 .map(|_| ())
756 .map_err(anyhow::Error::msg)
757 }
758
759 fn observe(&self, id: &str, status: &ShellStatus, seq: u64, raw_bytes: usize) -> Result<()> {
760 let owner_state = match status {
761 ShellStatus::Running => OwnerState::Running,
762 ShellStatus::Completed => OwnerState::Completed,
763 ShellStatus::Failed | ShellStatus::TimedOut => OwnerState::Failed,
764 ShellStatus::Killed => OwnerState::Cancelled,
765 };
766 let raw_bytes = u64::try_from(raw_bytes).unwrap_or(u64::MAX);
767 let output = EvidenceRef::new(
768 EvidenceKind::Receipt {
769 owner: "shell".to_string(),
770 },
771 format!("shell:{id}:output"),
772 Some(raw_bytes),
773 false,
774 )
775 .map_err(|err| anyhow!(err.to_string()))?;
776 self.work
777 .reconcile_operation(
778 &self.session_id,
779 OperationOwnerSnapshot::new(
780 format!("shell:{id}"),
781 owner_state,
782 seq,
783 lifecycle_now_ms(),
784 )
785 .with_output(output),
786 )
787 .map(|_| ())
788 .map_err(anyhow::Error::msg)
789 }
790 }
791
792 struct ShellSpawnIntentGuard {
793 lifecycle: Option<ShellWorkLifecycle>,
794 id: String,
795 armed: bool,
796 }
797
798 struct ShellSpawnContext {
799 owner_agent: Option<ShellJobOwner>,
800 work_lifecycle: Option<ShellWorkLifecycle>,
801 }
802
803 impl ShellSpawnIntentGuard {
804 fn new(lifecycle: Option<ShellWorkLifecycle>, id: &str, command: &str) -> Result<Self> {
805 if let Some(lifecycle) = lifecycle.as_ref() {
806 lifecycle.register(id, command)?;
807 }
808 Ok(Self {
809 lifecycle,
810 id: id.to_string(),
811 armed: true,
812 })
813 }
814
815 fn disarm(&mut self) {
816 self.armed = false;
817 }
818 }
819
820 impl Drop for ShellSpawnIntentGuard {
821 fn drop(&mut self) {
822 if self.armed
823 && let Some(lifecycle) = self.lifecycle.as_ref()
824 && let Err(err) = lifecycle.observe(&self.id, &ShellStatus::Failed, 1, 0)
825 {
826 tracing::warn!(shell_id = %self.id, error = %err, "failed to record shell spawn failure");
827 }
828 }
829 }
830
831 impl BackgroundShell {
832 /// Check if the process has completed and update status
833 fn poll(&mut self) -> bool {
834 self.refresh_output_activity();
835 if self.status != ShellStatus::Running {
836 self.publish_lifecycle_best_effort();
837 return true;
838 }
839
840 let completed = if let Some(ref mut child) = self.child {
841 match child.try_wait() {
842 Ok(Some(status)) => {
843 self.exit_code = status.code;
844 self.status = if status.success {
845 ShellStatus::Completed
846 } else {
847 ShellStatus::Failed
848 };
849 self.heavy_permit.take();
850 self.collect_output();
851 true
852 }
853 Ok(None) => false, // Still running
854 Err(_) => {
855 self.status = ShellStatus::Failed;
856 self.heavy_permit.take();
857 self.collect_output();
858 true
859 }
860 }
861 } else {
862 true
863 };
864 self.publish_lifecycle_best_effort();
865 completed
866 }
867
868 fn publish_lifecycle(&mut self) -> Result<()> {
869 let bytes = self.observed_output_len();
870 if self.last_lifecycle_status.as_ref() == Some(&self.status)
871 && self.last_lifecycle_bytes == bytes
872 {
873 return Ok(());
874 }
875 let next_seq = self.lifecycle_seq.saturating_add(1);
876 if let Some(lifecycle) = self.work_lifecycle.as_ref() {
877 lifecycle.observe(&self.id, &self.status, next_seq, bytes)?;
878 }
879 self.lifecycle_seq = next_seq;
880 self.last_lifecycle_status = Some(self.status.clone());
881 self.last_lifecycle_bytes = bytes;
882 Ok(())
883 }
884
885 fn publish_lifecycle_best_effort(&mut self) {
886 if let Err(err) = self.publish_lifecycle() {
887 tracing::warn!(shell_id = %self.id, error = %err, "failed to reconcile shell lifecycle");
888 }
889 }
890
891 fn refresh_output_activity(&mut self) {
892 let observed_len = self.observed_output_len();
893 if observed_len != self.last_observed_output_len {
894 self.last_observed_output_len = observed_len;
895 self.last_output_at = Instant::now();
896 }
897 }
898
899 fn observed_output_len(&self) -> usize {
900 let stdout_len = self
901 .stdout_buffer
902 .lock()
903 .map(|data| data.len())
904 .unwrap_or(0);
905 let stderr_len = self
906 .stderr_buffer
907 .as_ref()
908 .and_then(|buffer| buffer.lock().ok().map(|data| data.len()))
909 .unwrap_or(0);
910 stdout_len.saturating_add(stderr_len)
911 }
912
913 /// Collect output from the background threads
914 fn collect_output(&mut self) {
915 // Kill the whole process group before joining reader threads.
916 // When the shell spawned persistent background jobs (e.g. `nohup curl`),
917 // those subprocesses keep the pipe write-ends open after the shell exits.
918 // Without this kill, the reader join would block until the descendant
919 // exits, freezing the UI event loop that calls list_jobs() → poll() →
920 // collect_output(). The joins themselves are additionally bounded
921 // (READER_JOIN_GRACE) because a descendant in its own session/process
922 // group escapes even the group kill (#52).
923 #[cfg(unix)]
924 if let Some(child) = self.child.as_mut() {
925 match child {
926 ShellChild::Process(proc) => {
927 let _ = kill_child_process_group(proc);
928 }
929 #[cfg(not(target_env = "ohos"))]
930 ShellChild::Pty(_) => {}
931 }
932 }
933 #[cfg(windows)]
934 terminate_and_close_windows_job(self.windows_job.take());
935 if let Some(handle) = self.stdout_thread.take() {
936 finish_background_reader(handle, &self.status);
937 }
938 if let Some(handle) = self.stderr_thread.take() {
939 finish_background_reader(handle, &self.status);
940 }
941 self.stdin = None;
942 self.child = None;
943 }
944
945 fn write_stdin(&mut self, input: &str, close: bool) -> Result<()> {
946 if let Some(stdin) = self.stdin.as_mut() {
947 if !input.is_empty() {
948 stdin
949 .write_all(input.as_bytes())
950 .context("Failed to write to stdin")?;
951 stdin.flush().ok();
952 }
953 if close {
954 self.stdin = None;
955 }
956 return Ok(());
957 }
958
959 if input.is_empty() && close {
960 return Ok(());
961 }
962
963 Err(anyhow!("stdin is not available for task {}", self.id))
964 }
965
966 fn full_output(&self) -> (String, String, usize, usize) {
967 let (stdout_bytes, stderr_bytes) = self.full_output_bytes();
968 let stdout_len = stdout_bytes.len();
969 let stderr_len = stderr_bytes.len();
970
971 (
972 String::from_utf8_lossy(&stdout_bytes).to_string(),
973 String::from_utf8_lossy(&stderr_bytes).to_string(),
974 stdout_len,
975 stderr_len,
976 )
977 }
978
979 fn full_output_bytes(&self) -> (Vec<u8>, Vec<u8>) {
980 let stdout_bytes = self
981 .stdout_buffer
982 .lock()
983 .map(|data| data.clone())
984 .unwrap_or_default();
985 let stderr_bytes = self
986 .stderr_buffer
987 .as_ref()
988 .and_then(|buffer| buffer.lock().ok().map(|data| data.clone()))
989 .unwrap_or_default();
990 (stdout_bytes, stderr_bytes)
991 }
992
993 fn take_delta(&mut self) -> (String, String, usize, usize, usize, usize) {
994 let (stdout_delta, stdout_total) =
995 take_delta_from_buffer(&self.stdout_buffer, &mut self.stdout_cursor);
996 let (stderr_delta, stderr_total) = if let Some(buffer) = self.stderr_buffer.as_ref() {
997 take_delta_from_buffer(buffer, &mut self.stderr_cursor)
998 } else {
999 (Vec::new(), 0)
1000 };
1001
1002 let stdout_delta_len = stdout_delta.len();
1003 let stderr_delta_len = stderr_delta.len();
1004
1005 if stdout_delta_len > 0 || stderr_delta_len > 0 {
1006 self.last_output_at = Instant::now();
1007 self.last_observed_output_len = stdout_total.saturating_add(stderr_total);
1008 }
1009
1010 (
1011 String::from_utf8_lossy(&stdout_delta).to_string(),
1012 String::from_utf8_lossy(&stderr_delta).to_string(),
1013 stdout_delta_len,
1014 stderr_delta_len,
1015 stdout_total,
1016 stderr_total,
1017 )
1018 }
1019
1020 fn sandbox_denied(&self) -> bool {
1021 if matches!(self.status, ShellStatus::Running) {
1022 return false;
1023 }
1024 let (_, stderr_full, _, _) = self.full_output();
1025 SandboxManager::was_denied(
1026 self.sandbox_type,
1027 self.exit_code
1028 .and_then(|code| i32::try_from(code).ok())
1029 .unwrap_or(-1),
1030 &stderr_full,
1031 )
1032 }
1033
1034 /// Kill the process
1035 fn kill(&mut self) -> Result<()> {
1036 if let Some(ref mut child) = self.child {
1037 match child {
1038 ShellChild::Process(proc) => {
1039 #[cfg(windows)]
1040 {
1041 terminate_windows_job(self.windows_job.as_ref(), proc)
1042 .context("Failed to kill process tree")?;
1043 let _ = proc.wait();
1044 }
1045 #[cfg(all(not(windows), unix))]
1046 {
1047 // Bounded SIGTERM → SIGKILL escalation against the
1048 // whole process group; returns within ~grace even if
1049 // the command ignores SIGTERM (#52).
1050 terminate_child_process_group(proc).context("Failed to kill process")?;
1051 }
1052 #[cfg(all(not(windows), not(unix)))]
1053 {
1054 proc.kill().context("Failed to kill process")?;
1055 let _ = proc.wait();
1056 }
1057 }
1058 #[cfg(not(target_env = "ohos"))]
1059 ShellChild::Pty(child) => {
1060 child.kill().context("Failed to kill process")?;
1061 let _ = child.wait();
1062 }
1063 }
1064 }
1065 self.status = ShellStatus::Killed;
1066 self.heavy_permit.take();
1067 self.collect_output();
1068 self.publish_lifecycle_best_effort();
1069 Ok(())
1070 }
1071
1072 /// Get a snapshot of the current state
1073 #[allow(dead_code)]
1074 pub fn snapshot(&self) -> ShellResult {
1075 let sandboxed = !matches!(self.sandbox_type, SandboxType::None);
1076 let (stdout_full, stderr_full, _, _) = self.full_output();
1077 let (stdout, stdout_meta) = truncate_with_meta(&stdout_full);
1078 let (stderr, stderr_meta) = truncate_with_meta(&stderr_full);
1079 ShellResult {
1080 task_id: Some(self.id.clone()),
1081 status: self.status.clone(),
1082 exit_code: self.exit_code,
1083 stdout,
1084 stderr,
1085 duration_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
1086 stdout_len: stdout_meta.original_len,
1087 stderr_len: stderr_meta.original_len,
1088 stdout_omitted: stdout_meta.omitted,
1089 stderr_omitted: stderr_meta.omitted,
1090 stdout_truncated: stdout_meta.truncated,
1091 stderr_truncated: stderr_meta.truncated,
1092 sandboxed,
1093 sandbox_type: if sandboxed {
1094 Some(self.sandbox_type.to_string())
1095 } else {
1096 None
1097 },
1098 sandbox_denied: self.sandbox_denied(),
1099 }
1100 }
1101
1102 fn job_snapshot(&self) -> ShellJobSnapshot {
1103 // Use tail_from_buffer instead of full_output so we never clone the
1104 // entire accumulated stdout/stderr for display purposes. full_output
1105 // is O(total_bytes_written), which caused the ShellManager mutex to be
1106 // held for an arbitrarily long time during list_jobs() calls from the
1107 // TUI event loop — freezing input handling on long automation runs.
1108 let (stdout_len, stdout_tail) = tail_from_buffer(&self.stdout_buffer, 1200);
1109 let (stderr_len, stderr_tail) = self
1110 .stderr_buffer
1111 .as_ref()
1112 .map(|buf| tail_from_buffer(buf, 1200))
1113 .unwrap_or((0, String::new()));
1114 let elapsed_since_output_ms = (self.status == ShellStatus::Running)
1115 .then(|| u64::try_from(self.last_output_at.elapsed().as_millis()).unwrap_or(u64::MAX));
1116 let stale = elapsed_since_output_ms.is_some_and(|elapsed| {
1117 elapsed >= u64::try_from(STALE_NO_OUTPUT_AFTER.as_millis()).unwrap_or(u64::MAX)
1118 });
1119 ShellJobSnapshot {
1120 id: self.id.clone(),
1121 job_id: self.id.clone(),
1122 command: self.command.clone(),
1123 cwd: self.working_dir.clone(),
1124 status: self.status.clone(),
1125 exit_code: self.exit_code,
1126 elapsed_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
1127 stdout_tail,
1128 stderr_tail,
1129 stdout_len,
1130 stderr_len,
1131 stdin_available: self.stdin.is_some() && self.status == ShellStatus::Running,
1132 stale,
1133 elapsed_since_output_ms,
1134 linked_task_id: self.linked_task_id.clone(),
1135 owner_agent_id: self
1136 .owner_agent
1137 .as_ref()
1138 .map(|owner| owner.agent_id.clone()),
1139 owner_agent_name: self
1140 .owner_agent
1141 .as_ref()
1142 .map(|owner| owner.agent_name.clone()),
1143 }
1144 }
1145
1146 fn completion_event(&self) -> ShellCompletionEvent {
1147 let snapshot = self.job_snapshot();
1148 let (stdout_len, stdout_tail) =
1149 bounded_completion_tail(&self.stdout_buffer, SHELL_COMPLETION_TAIL_BYTES);
1150 let (stderr_len, stderr_tail) = self
1151 .stderr_buffer
1152 .as_ref()
1153 .map(|buffer| bounded_completion_tail(buffer, SHELL_COMPLETION_TAIL_BYTES))
1154 .unwrap_or((0, String::new()));
1155 ShellCompletionEvent {
1156 task_id: snapshot.id,
1157 command: snapshot.command,
1158 status: snapshot.status,
1159 exit_code: snapshot.exit_code,
1160 duration_ms: snapshot.elapsed_ms,
1161 stdout_tail,
1162 stderr_tail,
1163 stdout_len,
1164 stderr_len,
1165 evidence_ref: None,
1166 linked_task_id: snapshot.linked_task_id,
1167 owner_agent_id: snapshot.owner_agent_id,
1168 owner_agent_name: snapshot.owner_agent_name,
1169 }
1170 }
1171
1172 fn completion_evidence(&self) -> ShellCompletionEvidence {
1173 let event = self.completion_event();
1174 let (stdout, stderr) = self.full_output_bytes();
1175 ShellCompletionEvidence {
1176 event,
1177 stdout,
1178 stderr,
1179 }
1180 }
1181
1182 fn job_detail(&self) -> ShellJobDetail {
1183 let (stdout, stderr, _, _) = self.full_output();
1184 ShellJobDetail {
1185 snapshot: self.job_snapshot(),
1186 stdout,
1187 stderr,
1188 }
1189 }
1190 }
1191
1192 fn finish_background_reader(handle: std::thread::JoinHandle<()>, status: &ShellStatus) {
1193 // A killed Windows process can leave a pipe reader blocked even after its
1194 // Job Object has been closed. Cancellation must return promptly instead of
1195 // waiting for that reader to observe EOF. Other terminal states still join
1196 // so their final output is collected before the shell is discarded.
1197 #[cfg(windows)]
1198 if *status == ShellStatus::Killed {
1199 drop(handle);
1200 return;
1201 }
1202
1203 #[cfg(not(windows))]
1204 let _ = status;
1205
1206 // Bounded join (#52): after the process group is killed the reader
1207 // normally sees EOF immediately, but a descendant that escaped the group
1208 // (its own session/process group) keeps its inherited pipe write-end
1209 // open, so the reader stays blocked until that descendant exits on its
1210 // own. Joining unboundedly froze the foreground shell — and, through the
1211 // shell-manager lock, every other shell — for minutes. On timeout the
1212 // join is handed to a helper thread and we return; the reader thread
1213 // still finishes on its own once the pipe finally closes.
1214 let (done_tx, done_rx) = std::sync::mpsc::channel();
1215 std::thread::spawn(move || {
1216 let _ = handle.join();
1217 let _ = done_tx.send(());
1218 });
1219 let _ = done_rx.recv_timeout(READER_JOIN_GRACE);
1220 }
1221
1222 impl Drop for BackgroundShell {
1223 fn drop(&mut self) {
1224 if self.status == ShellStatus::Running
1225 && let Some(ref mut child) = self.child
1226 {
1227 #[cfg(windows)]
1228 match child {
1229 ShellChild::Process(proc) => {
1230 let _ = terminate_windows_job(self.windows_job.as_ref(), proc);
1231 }
1232 #[cfg(not(target_env = "ohos"))]
1233 ShellChild::Pty(child) => {
1234 let _ = child.kill();
1235 }
1236 }
1237 #[cfg(all(not(windows), unix))]
1238 {
1239 let _ = child.kill();
1240 match child {
1241 ShellChild::Process(proc) => {
1242 let _ = wait_child_bounded(proc, KILL_REAP_GRACE);
1243 }
1244 #[cfg(not(target_env = "ohos"))]
1245 ShellChild::Pty(child) => {
1246 let _ = child.wait();
1247 }
1248 }
1249 }
1250 #[cfg(all(not(windows), not(unix)))]
1251 {
1252 let _ = child.kill();
1253 let _ = child.wait();
1254 }
1255 }
1256 }
1257 }
1258
1259 /// Manages background shell processes with optional sandboxing.
1260 pub struct ShellManager {
1261 processes: HashMap<String, BackgroundShell>,
1262 stale_jobs: HashMap<String, ShellJobSnapshot>,
1263 default_workspace: PathBuf,
1264 sandbox_manager: SandboxManager,
1265 sandbox_policy: ExecutionSandboxPolicy,
1266 foreground_background_requested: bool,
1267 }
1268
1269 impl std::fmt::Debug for ShellManager {
1270 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1271 f.debug_struct("ShellManager")
1272 .field("processes", &self.processes.len())
1273 .field("stale_jobs", &self.stale_jobs.len())
1274 .field("default_workspace", &self.default_workspace)
1275 .field("sandbox_policy", &self.sandbox_policy)
1276 .field(
1277 "foreground_background_requested",
1278 &self.foreground_background_requested,
1279 )
1280 .finish()
1281 }
1282 }
1283
1284 impl ShellManager {
1285 /// Create a new `ShellManager` with default (no sandbox) policy.
1286 pub fn new(workspace: PathBuf) -> Self {
1287 Self {
1288 processes: HashMap::new(),
1289 stale_jobs: HashMap::new(),
1290 default_workspace: workspace,
1291 sandbox_manager: SandboxManager::new(),
1292 sandbox_policy: ExecutionSandboxPolicy::default(),
1293 foreground_background_requested: false,
1294 }
1295 }
1296
1297 /// Create a new `ShellManager` with a specific sandbox policy.
1298 #[allow(dead_code)]
1299 pub fn with_sandbox(workspace: PathBuf, policy: ExecutionSandboxPolicy) -> Self {
1300 Self {
1301 processes: HashMap::new(),
1302 stale_jobs: HashMap::new(),
1303 default_workspace: workspace,
1304 sandbox_manager: SandboxManager::new(),
1305 sandbox_policy: policy,
1306 foreground_background_requested: false,
1307 }
1308 }
1309
1310 /// Set the sandbox policy for future commands.
1311 #[allow(dead_code)]
1312 pub fn set_sandbox_policy(&mut self, policy: ExecutionSandboxPolicy) {
1313 self.sandbox_policy = policy;
1314 }
1315
1316 /// Get the current sandbox policy.
1317 #[allow(dead_code)]
1318 pub fn sandbox_policy(&self) -> &ExecutionSandboxPolicy {
1319 &self.sandbox_policy
1320 }
1321
1322 /// Enable or disable bubblewrap passthrough (#2184).
1323 ///
1324 /// When enabled and `/usr/bin/bwrap` is executable on Linux, exec_shell
1325 /// commands are routed through bubblewrap for filesystem isolation.
1326 pub fn set_prefer_bwrap(&mut self, prefer: bool) {
1327 self.sandbox_manager.set_prefer_bwrap(prefer);
1328 }
1329
1330 /// Return the OS sandbox wrapper this shell manager is configured and able
1331 /// to apply to commands.
1332 pub fn configured_sandbox_type(&self) -> Option<SandboxType> {
1333 self.sandbox_manager.configured_sandbox()
1334 }
1335
1336 /// Request that the active foreground shell wait detach and leave its
1337 /// process running in the background job table.
1338 pub fn request_foreground_background(&mut self) {
1339 self.foreground_background_requested = true;
1340 }
1341
1342 #[cfg(test)]
1343 pub(crate) fn foreground_background_requested_for_test(&self) -> bool {
1344 self.foreground_background_requested
1345 }
1346
1347 fn clear_foreground_background_request(&mut self) {
1348 self.foreground_background_requested = false;
1349 }
1350
1351 fn take_foreground_background_request(&mut self) -> bool {
1352 let requested = self.foreground_background_requested;
1353 self.foreground_background_requested = false;
1354 requested
1355 }
1356
1357 /// Check if sandboxing is available on this platform.
1358 #[allow(dead_code)]
1359 pub fn is_sandbox_available(&mut self) -> bool {
1360 self.sandbox_manager.is_available()
1361 }
1362
1363 #[allow(dead_code)]
1364 pub fn default_workspace(&self) -> &Path {
1365 &self.default_workspace
1366 }
1367
1368 /// Execute a shell command with the configured sandbox policy.
1369 #[allow(dead_code)]
1370 pub fn execute(
1371 &mut self,
1372 command: &str,
1373 working_dir: Option<&str>,
1374 timeout_ms: u64,
1375 background: bool,
1376 ) -> Result<ShellResult> {
1377 self.execute_with_policy(command, working_dir, timeout_ms, background, None)
1378 }
1379
1380 /// Execute a shell command with a specific sandbox policy (overrides default).
1381 #[allow(dead_code)]
1382 pub fn execute_with_policy(
1383 &mut self,
1384 command: &str,
1385 working_dir: Option<&str>,
1386 timeout_ms: u64,
1387 background: bool,
1388 policy_override: Option<ExecutionSandboxPolicy>,
1389 ) -> Result<ShellResult> {
1390 self.execute_with_options(
1391 command,
1392 working_dir,
1393 timeout_ms,
1394 background,
1395 None,
1396 false,
1397 policy_override,
1398 )
1399 }
1400
1401 /// Execute a shell command with stdin/TTY options.
1402 #[allow(clippy::too_many_arguments)]
1403 pub fn execute_with_options(
1404 &mut self,
1405 command: &str,
1406 working_dir: Option<&str>,
1407 timeout_ms: u64,
1408 background: bool,
1409 stdin_data: Option<&str>,
1410 tty: bool,
1411 policy_override: Option<ExecutionSandboxPolicy>,
1412 ) -> Result<ShellResult> {
1413 self.execute_with_options_env(
1414 command,
1415 working_dir,
1416 timeout_ms,
1417 background,
1418 stdin_data,
1419 tty,
1420 policy_override,
1421 HashMap::new(),
1422 )
1423 }
1424
1425 /// Same as `execute_with_options`, plus an extra env-var map that is
1426 /// merged into the spawned process environment. Used by the `shell_env`
1427 /// hook injection path (#456); other callers should use the simpler
1428 /// wrapper above.
1429 #[allow(clippy::too_many_arguments)]
1430 pub fn execute_with_options_env(
1431 &mut self,
1432 command: &str,
1433 working_dir: Option<&str>,
1434 timeout_ms: u64,
1435 background: bool,
1436 stdin_data: Option<&str>,
1437 tty: bool,
1438 policy_override: Option<ExecutionSandboxPolicy>,
1439 extra_env: HashMap<String, String>,
1440 ) -> Result<ShellResult> {
1441 self.execute_with_options_env_for_owner(
1442 command,
1443 working_dir,
1444 timeout_ms,
1445 background,
1446 stdin_data,
1447 tty,
1448 policy_override,
1449 extra_env,
1450 None,
1451 )
1452 }
1453
1454 /// Same as `execute_with_options_env`, with optional background-job owner
1455 /// attribution for sub-agent launched jobs.
1456 #[allow(clippy::too_many_arguments)]
1457 pub fn execute_with_options_env_for_owner(
1458 &mut self,
1459 command: &str,
1460 working_dir: Option<&str>,
1461 timeout_ms: u64,
1462 background: bool,
1463 stdin_data: Option<&str>,
1464 tty: bool,
1465 policy_override: Option<ExecutionSandboxPolicy>,
1466 extra_env: HashMap<String, String>,
1467 owner_agent: Option<ShellJobOwner>,
1468 ) -> Result<ShellResult> {
1469 self.execute_with_options_env_for_owner_and_work(
1470 command,
1471 working_dir,
1472 timeout_ms,
1473 background,
1474 stdin_data,
1475 tty,
1476 policy_override,
1477 extra_env,
1478 owner_agent,
1479 None,
1480 )
1481 }
1482
1483 /// Owner-aware execution with an optional Work Graph lifecycle sink.
1484 #[allow(clippy::too_many_arguments)]
1485 fn execute_with_options_env_for_owner_and_work(
1486 &mut self,
1487 command: &str,
1488 working_dir: Option<&str>,
1489 timeout_ms: u64,
1490 background: bool,
1491 stdin_data: Option<&str>,
1492 tty: bool,
1493 policy_override: Option<ExecutionSandboxPolicy>,
1494 extra_env: HashMap<String, String>,
1495 owner_agent: Option<ShellJobOwner>,
1496 work_lifecycle: Option<ShellWorkLifecycle>,
1497 ) -> Result<ShellResult> {
1498 // Log execution via ShellDispatcher when SHELL_DISPATCHER_LOG is set.
1499 crate::shell_dispatcher::ShellDispatcher::log_exec(command);
1500
1501 let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from);
1502 validate_shell_working_dir(&work_dir, working_dir.is_none())?;
1503
1504 // Clamp timeout to max 10 minutes (600000ms)
1505 let timeout_ms = timeout_ms.clamp(1000, 600_000);
1506
1507 // Use override policy if provided, otherwise use the manager's policy
1508 let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone());
1509
1510 // Create command spec and prepare sandboxed environment
1511 let spec = CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms))
1512 .with_policy(policy)
1513 .with_env(extra_env);
1514 let exec_env = self.sandbox_manager.prepare(&spec);
1515
1516 if background {
1517 self.spawn_background_sandboxed(
1518 command,
1519 &work_dir,
1520 &exec_env,
1521 None,
1522 stdin_data,
1523 tty,
1524 ShellSpawnContext {
1525 owner_agent,
1526 work_lifecycle,
1527 },
1528 )
1529 } else {
1530 if tty {
1531 return Err(anyhow!(
1532 "TTY mode requires background execution (set background: true)."
1533 ));
1534 }
1535 Self::execute_sync_sandboxed(command, &work_dir, timeout_ms, stdin_data, &exec_env)
1536 }
1537 }
1538
1539 /// Execute a shell command interactively (stdin/stdout/stderr inherit from terminal).
1540 #[allow(dead_code)]
1541 pub fn execute_interactive(
1542 &mut self,
1543 command: &str,
1544 working_dir: Option<&str>,
1545 timeout_ms: u64,
1546 ) -> Result<ShellResult> {
1547 self.execute_interactive_with_policy(command, working_dir, timeout_ms, None)
1548 }
1549
1550 /// Execute a shell command interactively with a specific sandbox policy override.
1551 pub fn execute_interactive_with_policy(
1552 &mut self,
1553 command: &str,
1554 working_dir: Option<&str>,
1555 timeout_ms: u64,
1556 policy_override: Option<ExecutionSandboxPolicy>,
1557 ) -> Result<ShellResult> {
1558 self.execute_interactive_with_policy_env(
1559 command,
1560 working_dir,
1561 timeout_ms,
1562 policy_override,
1563 HashMap::new(),
1564 )
1565 }
1566
1567 /// Interactive variant that accepts extra env vars (#456 shell_env hook).
1568 pub fn execute_interactive_with_policy_env(
1569 &mut self,
1570 command: &str,
1571 working_dir: Option<&str>,
1572 timeout_ms: u64,
1573 policy_override: Option<ExecutionSandboxPolicy>,
1574 extra_env: HashMap<String, String>,
1575 ) -> Result<ShellResult> {
1576 crate::shell_dispatcher::ShellDispatcher::log_exec(command);
1577
1578 let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from);
1579 validate_shell_working_dir(&work_dir, working_dir.is_none())?;
1580
1581 let timeout_ms = timeout_ms.clamp(1000, 600_000);
1582 let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone());
1583
1584 let spec = CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms))
1585 .with_policy(policy)
1586 .with_env(extra_env);
1587 let exec_env = self.sandbox_manager.prepare(&spec);
1588
1589 Self::execute_interactive_sandboxed(command, &work_dir, timeout_ms, &exec_env)
1590 }
1591
1592 /// Execute command synchronously with timeout (sandboxed).
1593 fn execute_sync_sandboxed(
1594 original_command: &str,
1595 working_dir: &std::path::Path,
1596 timeout_ms: u64,
1597 stdin_data: Option<&str>,
1598 exec_env: &ExecEnv,
1599 ) -> Result<ShellResult> {
1600 let started = Instant::now();
1601 let timeout = Duration::from_millis(timeout_ms);
1602 let sandbox_type = exec_env.sandbox_type;
1603 let sandboxed = exec_env.is_sandboxed();
1604
1605 // Build the command from ExecEnv
1606 let program = exec_env.program();
1607 let args = exec_env.args();
1608
1609 let mut cmd = Command::new(program);
1610 crate::utils::suppress_console_window(&mut cmd);
1611 push_shell_args(&mut cmd, program, args);
1612 cmd.current_dir(working_dir)
1613 .stdout(Stdio::piped())
1614 .stderr(Stdio::piped());
1615 #[cfg(unix)]
1616 {
1617 cmd.process_group(0);
1618 }
1619 install_parent_death_signal(&mut cmd);
1620
1621 if stdin_data.is_some() {
1622 cmd.stdin(Stdio::piped());
1623 }
1624
1625 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
1626
1627 // Disable raw mode before spawn; restore only if raw mode was active
1628 // on entry (issue #1690).
1629 let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
1630 if raw_mode_was_enabled {
1631 let _ = crossterm::terminal::disable_raw_mode();
1632 }
1633 struct SyncRawModeGuard {
1634 restore: bool,
1635 }
1636 impl Drop for SyncRawModeGuard {
1637 fn drop(&mut self) {
1638 if self.restore {
1639 let _ = crossterm::terminal::enable_raw_mode();
1640 }
1641 }
1642 }
1643 let _guard = SyncRawModeGuard {
1644 restore: raw_mode_was_enabled,
1645 };
1646
1647 let mut child = cmd
1648 .spawn()
1649 .with_context(|| format!("Failed to execute: {original_command}"))?;
1650 #[cfg(windows)]
1651 let windows_job = attach_windows_job(&child, original_command);
1652
1653 if let Some(input) = stdin_data
1654 && let Some(mut stdin) = child.stdin.take()
1655 {
1656 stdin
1657 .write_all(input.as_bytes())
1658 .context("Failed to write to stdin")?;
1659 stdin.flush().ok();
1660 }
1661
1662 let stdout_handle = child.stdout.take().context("Failed to capture stdout")?;
1663 let stderr_handle = child.stderr.take().context("Failed to capture stderr")?;
1664
1665 // Spawn threads to read output. Use bounded receives below so a killed
1666 // or detached descendant that keeps pipe handles open cannot wedge the
1667 // foreground shell path while the global tool lock is held (#2571).
1668 let stdout_rx = spawn_sync_reader_thread(stdout_handle);
1669 let stderr_rx = spawn_sync_reader_thread(stderr_handle);
1670
1671 // Wait with timeout
1672 if let Some(status) = child.wait_timeout(timeout)? {
1673 let status = ShellExitStatus::from_std(status);
1674 #[cfg(unix)]
1675 let _ = kill_child_process_group(&mut child);
1676 #[cfg(windows)]
1677 terminate_and_close_windows_job(windows_job);
1678 let stdout = recv_sync_reader_output(&stdout_rx);
1679 let stderr = recv_sync_reader_output(&stderr_rx);
1680 let stdout_str = String::from_utf8_lossy(&stdout).to_string();
1681 let stderr_str = String::from_utf8_lossy(&stderr).to_string();
1682 let exit_code = status
1683 .code
1684 .and_then(|code| i32::try_from(code).ok())
1685 .unwrap_or(-1);
1686
1687 // Check if sandbox denied the operation
1688 let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
1689 let (stdout, stdout_meta) = truncate_with_meta(&stdout_str);
1690 let (stderr, stderr_meta) = truncate_with_meta(&stderr_str);
1691
1692 Ok(ShellResult {
1693 task_id: None,
1694 status: if status.success {
1695 ShellStatus::Completed
1696 } else {
1697 ShellStatus::Failed
1698 },
1699 exit_code: status.code,
1700 stdout,
1701 stderr,
1702 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
1703 stdout_len: stdout_meta.original_len,
1704 stderr_len: stderr_meta.original_len,
1705 stdout_omitted: stdout_meta.omitted,
1706 stderr_omitted: stderr_meta.omitted,
1707 stdout_truncated: stdout_meta.truncated,
1708 stderr_truncated: stderr_meta.truncated,
1709 sandboxed,
1710 sandbox_type: if sandboxed {
1711 Some(sandbox_type.to_string())
1712 } else {
1713 None
1714 },
1715 sandbox_denied,
1716 })
1717 } else {
1718 // Timeout - kill the process
1719 #[cfg(unix)]
1720 let _ = kill_child_process_group(&mut child);
1721 #[cfg(windows)]
1722 let _ = terminate_child_and_close_windows_job(windows_job, &mut child);
1723 #[cfg(all(not(unix), not(windows)))]
1724 let _ = child.kill();
1725 let status = child.wait().ok();
1726 let stdout = recv_sync_reader_output(&stdout_rx);
1727 let stderr = recv_sync_reader_output(&stderr_rx);
1728 let stdout_str = String::from_utf8_lossy(&stdout).to_string();
1729 let stderr_str = String::from_utf8_lossy(&stderr).to_string();
1730 let (stdout, stdout_meta) = truncate_with_meta(&stdout_str);
1731 let (stderr, stderr_meta) = truncate_with_meta(&stderr_str);
1732
1733 Ok(ShellResult {
1734 task_id: None,
1735 status: ShellStatus::TimedOut,
1736 exit_code: status
1737 .map(ShellExitStatus::from_std)
1738 .and_then(|status| status.code),
1739 stdout,
1740 stderr,
1741 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
1742 stdout_len: stdout_meta.original_len,
1743 stderr_len: stderr_meta.original_len,
1744 stdout_omitted: stdout_meta.omitted,
1745 stderr_omitted: stderr_meta.omitted,
1746 stdout_truncated: stdout_meta.truncated,
1747 stderr_truncated: stderr_meta.truncated,
1748 sandboxed,
1749 sandbox_type: if sandboxed {
1750 Some(sandbox_type.to_string())
1751 } else {
1752 None
1753 },
1754 sandbox_denied: false,
1755 })
1756 }
1757 }
1758
1759 /// Execute command interactively with timeout (sandboxed).
1760 fn execute_interactive_sandboxed(
1761 original_command: &str,
1762 working_dir: &std::path::Path,
1763 timeout_ms: u64,
1764 exec_env: &ExecEnv,
1765 ) -> Result<ShellResult> {
1766 let started = Instant::now();
1767 let timeout = Duration::from_millis(timeout_ms);
1768 let sandbox_type = exec_env.sandbox_type;
1769 let sandboxed = exec_env.is_sandboxed();
1770
1771 let program = exec_env.program();
1772 let args = exec_env.args();
1773
1774 let mut cmd = Command::new(program);
1775 crate::utils::suppress_console_window(&mut cmd);
1776 push_shell_args(&mut cmd, program, args);
1777 cmd.current_dir(working_dir)
1778 .stdin(Stdio::inherit())
1779 .stdout(Stdio::inherit())
1780 .stderr(Stdio::inherit());
1781 #[cfg(unix)]
1782 {
1783 cmd.process_group(0);
1784 }
1785 install_parent_death_signal(&mut cmd);
1786
1787 // Disable raw mode before spawn; restore only if raw mode was active
1788 // on entry (issue #1690).
1789 let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
1790 if raw_mode_was_enabled {
1791 let _ = crossterm::terminal::disable_raw_mode();
1792 }
1793 struct InteractiveRawModeGuard {
1794 restore: bool,
1795 }
1796 impl Drop for InteractiveRawModeGuard {
1797 fn drop(&mut self) {
1798 if self.restore {
1799 let _ = crossterm::terminal::enable_raw_mode();
1800 }
1801 }
1802 }
1803 let _guard = InteractiveRawModeGuard {
1804 restore: raw_mode_was_enabled,
1805 };
1806
1807 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
1808
1809 let mut child = cmd
1810 .spawn()
1811 .with_context(|| format!("Failed to execute: {original_command}"))?;
1812 #[cfg(windows)]
1813 let windows_job = attach_windows_job(&child, original_command);
1814
1815 if let Some(status) = child.wait_timeout(timeout)? {
1816 let status = ShellExitStatus::from_std(status);
1817 #[cfg(windows)]
1818 terminate_and_close_windows_job(windows_job);
1819 Ok(ShellResult {
1820 task_id: None,
1821 status: if status.success {
1822 ShellStatus::Completed
1823 } else {
1824 ShellStatus::Failed
1825 },
1826 exit_code: status.code,
1827 stdout: String::new(),
1828 stderr: String::new(),
1829 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
1830 stdout_len: 0,
1831 stderr_len: 0,
1832 stdout_omitted: 0,
1833 stderr_omitted: 0,
1834 stdout_truncated: false,
1835 stderr_truncated: false,
1836 sandboxed,
1837 sandbox_type: if sandboxed {
1838 Some(sandbox_type.to_string())
1839 } else {
1840 None
1841 },
1842 sandbox_denied: false,
1843 })
1844 } else {
1845 #[cfg(unix)]
1846 let _ = kill_child_process_group(&mut child);
1847 #[cfg(windows)]
1848 let _ = terminate_child_and_close_windows_job(windows_job, &mut child);
1849 #[cfg(all(not(unix), not(windows)))]
1850 let _ = child.kill();
1851 let status = child.wait().ok();
1852
1853 Ok(ShellResult {
1854 task_id: None,
1855 status: ShellStatus::TimedOut,
1856 exit_code: status
1857 .map(ShellExitStatus::from_std)
1858 .and_then(|status| status.code),
1859 stdout: String::new(),
1860 stderr: String::new(),
1861 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
1862 stdout_len: 0,
1863 stderr_len: 0,
1864 stdout_omitted: 0,
1865 stderr_omitted: 0,
1866 stdout_truncated: false,
1867 stderr_truncated: false,
1868 sandboxed,
1869 sandbox_type: if sandboxed {
1870 Some(sandbox_type.to_string())
1871 } else {
1872 None
1873 },
1874 sandbox_denied: false,
1875 })
1876 }
1877 }
1878
1879 /// Spawn a background process (sandboxed).
1880 #[allow(clippy::too_many_arguments)]
1881 fn spawn_background_sandboxed(
1882 &mut self,
1883 original_command: &str,
1884 working_dir: &std::path::Path,
1885 exec_env: &ExecEnv,
1886 heavy_permit: Option<HeavyCommandPermit>,
1887 stdin_data: Option<&str>,
1888 tty: bool,
1889 spawn_context: ShellSpawnContext,
1890 ) -> Result<ShellResult> {
1891 let ShellSpawnContext {
1892 owner_agent,
1893 work_lifecycle,
1894 } = spawn_context;
1895 let task_id = format!("shell_{}", &Uuid::new_v4().to_string()[..8]);
1896 let mut spawn_guard =
1897 ShellSpawnIntentGuard::new(work_lifecycle.clone(), &task_id, original_command)?;
1898 let started = Instant::now();
1899 let sandbox_type = exec_env.sandbox_type;
1900 let sandboxed = exec_env.is_sandboxed();
1901
1902 // Build the command from ExecEnv
1903 let program = exec_env.program();
1904 let args = exec_env.args();
1905
1906 #[cfg(target_env = "ohos")]
1907 if tty {
1908 return Err(anyhow!(
1909 "TTY shell mode is not supported on HarmonyOS/OpenHarmony yet."
1910 ));
1911 }
1912
1913 let stdout_buffer = Arc::new(Mutex::new(Vec::new()));
1914 let stderr_buffer = if tty {
1915 None
1916 } else {
1917 Some(Arc::new(Mutex::new(Vec::new())))
1918 };
1919
1920 #[cfg(windows)]
1921 let mut windows_job = None;
1922
1923 let (child, stdin, stdout_thread, stderr_thread) = if tty {
1924 #[cfg(target_env = "ohos")]
1925 unreachable!("OHOS TTY mode returns before PTY setup");
1926
1927 #[cfg(not(target_env = "ohos"))]
1928 {
1929 let pty_system = native_pty_system();
1930 let pair = pty_system
1931 .openpty(PtySize {
1932 rows: 24,
1933 cols: 80,
1934 pixel_width: 0,
1935 pixel_height: 0,
1936 })
1937 .context("Failed to open PTY")?;
1938
1939 let mut cmd = CommandBuilder::new(program);
1940 for arg in args {
1941 cmd.arg(arg);
1942 }
1943 cmd.cwd(working_dir);
1944 child_env::apply_to_pty_command(&mut cmd, child_env::string_map_env(&exec_env.env));
1945
1946 let mut child = pair
1947 .slave
1948 .spawn_command(cmd)
1949 .with_context(|| format!("Failed to spawn PTY command: {original_command}"))?;
1950 drop(pair.slave);
1951
1952 let reader = match pair.master.try_clone_reader() {
1953 Ok(reader) => reader,
1954 Err(err) => {
1955 let _ = child.kill();
1956 let _ = child.wait();
1957 return Err(err).context("Failed to clone PTY reader");
1958 }
1959 };
1960 let writer = match pair.master.take_writer() {
1961 Ok(writer) => writer,
1962 Err(err) => {
1963 let _ = child.kill();
1964 let _ = child.wait();
1965 return Err(err).context("Failed to take PTY writer");
1966 }
1967 };
1968 let stdout_thread = Some(spawn_reader_thread(reader, Arc::clone(&stdout_buffer)));
1969
1970 (
1971 ShellChild::Pty(child),
1972 Some(StdinWriter::Pty(writer)),
1973 stdout_thread,
1974 None,
1975 )
1976 }
1977 } else {
1978 let mut cmd = Command::new(program);
1979 crate::utils::suppress_console_window(&mut cmd);
1980 push_shell_args(&mut cmd, program, args);
1981 cmd.current_dir(working_dir)
1982 .stdin(Stdio::piped())
1983 .stdout(Stdio::piped())
1984 .stderr(Stdio::piped());
1985 #[cfg(unix)]
1986 {
1987 cmd.process_group(0);
1988 }
1989
1990 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
1991
1992 let mut child = cmd
1993 .spawn()
1994 .with_context(|| format!("Failed to spawn background: {original_command}"))?;
1995 #[cfg(windows)]
1996 {
1997 windows_job = attach_windows_job(&child, original_command);
1998 }
1999
2000 let stdout_handle = match child.stdout.take() {
2001 Some(stdout) => stdout,
2002 None => {
2003 #[cfg(windows)]
2004 terminate_unregistered_process(&mut child, windows_job.as_ref());
2005 #[cfg(not(windows))]
2006 terminate_unregistered_process(&mut child);
2007 return Err(anyhow!("Failed to capture stdout"));
2008 }
2009 };
2010 let stderr_handle = match child.stderr.take() {
2011 Some(stderr) => stderr,
2012 None => {
2013 #[cfg(windows)]
2014 terminate_unregistered_process(&mut child, windows_job.as_ref());
2015 #[cfg(not(windows))]
2016 terminate_unregistered_process(&mut child);
2017 return Err(anyhow!("Failed to capture stderr"));
2018 }
2019 };
2020 let stdin_handle = child.stdin.take().map(StdinWriter::Pipe);
2021
2022 let stdout_thread = Some(spawn_reader_thread(
2023 stdout_handle,
2024 Arc::clone(&stdout_buffer),
2025 ));
2026 let stderr_thread = stderr_buffer
2027 .as_ref()
2028 .map(|buffer| spawn_reader_thread(stderr_handle, Arc::clone(buffer)));
2029
2030 (
2031 ShellChild::Process(child),
2032 stdin_handle,
2033 stdout_thread,
2034 stderr_thread,
2035 )
2036 };
2037
2038 let mut bg_shell = BackgroundShell {
2039 id: task_id.clone(),
2040 command: original_command.to_string(),
2041 working_dir: working_dir.to_path_buf(),
2042 status: ShellStatus::Running,
2043 exit_code: None,
2044 started_at: started,
2045 last_output_at: started,
2046 last_observed_output_len: 0,
2047 sandbox_type,
2048 linked_task_id: None,
2049 owner_agent,
2050 stdout_buffer,
2051 stderr_buffer,
2052 heavy_permit,
2053 stdout_cursor: 0,
2054 stderr_cursor: 0,
2055 completion_reported: false,
2056 stdin,
2057 child: Some(child),
2058 #[cfg(windows)]
2059 windows_job,
2060 stdout_thread,
2061 stderr_thread,
2062 work_lifecycle,
2063 lifecycle_seq: 0,
2064 last_lifecycle_status: None,
2065 last_lifecycle_bytes: 0,
2066 };
2067
2068 if let Some(input) = stdin_data
2069 && let Err(err) = bg_shell.write_stdin(input, false)
2070 {
2071 let _ = bg_shell.kill();
2072 return Err(err);
2073 }
2074
2075 if let Err(err) = bg_shell.publish_lifecycle() {
2076 let _ = bg_shell.kill();
2077 return Err(err);
2078 }
2079
2080 self.processes.insert(task_id.clone(), bg_shell);
2081 spawn_guard.disarm();
2082
2083 Ok(ShellResult {
2084 task_id: Some(task_id),
2085 status: ShellStatus::Running,
2086 exit_code: None,
2087 stdout: String::new(),
2088 stderr: String::new(),
2089 duration_ms: 0,
2090 stdout_len: 0,
2091 stderr_len: 0,
2092 stdout_omitted: 0,
2093 stderr_omitted: 0,
2094 stdout_truncated: false,
2095 stderr_truncated: false,
2096 sandboxed,
2097 sandbox_type: if sandboxed {
2098 Some(sandbox_type.to_string())
2099 } else {
2100 None
2101 },
2102 sandbox_denied: false,
2103 })
2104 }
2105
2106 /// Get output from a background process
2107 #[allow(dead_code)]
2108 pub fn get_output(
2109 &mut self,
2110 task_id: &str,
2111 block: bool,
2112 timeout_ms: u64,
2113 ) -> Result<ShellResult> {
2114 let shell = self
2115 .processes
2116 .get_mut(task_id)
2117 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
2118
2119 if block && shell.status == ShellStatus::Running {
2120 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
2121 let deadline = Instant::now() + timeout;
2122
2123 while shell.status == ShellStatus::Running && Instant::now() < deadline {
2124 if shell.poll() {
2125 break;
2126 }
2127 std::thread::sleep(Duration::from_millis(100));
2128 }
2129
2130 // If still running after timeout
2131 if shell.status == ShellStatus::Running {
2132 return Ok(shell.snapshot());
2133 }
2134 } else {
2135 shell.poll();
2136 }
2137
2138 Ok(shell.snapshot())
2139 }
2140
2141 /// Write data to stdin of a background process.
2142 pub fn write_stdin(&mut self, task_id: &str, input: &str, close: bool) -> Result<()> {
2143 let shell = self
2144 .processes
2145 .get_mut(task_id)
2146 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
2147 shell.write_stdin(input, close)?;
2148 Ok(())
2149 }
2150
2151 /// Get incremental output from a background process, consuming any new output.
2152 fn get_output_delta(
2153 &mut self,
2154 task_id: &str,
2155 wait: bool,
2156 timeout_ms: u64,
2157 ) -> Result<ShellDeltaResult> {
2158 let shell = self
2159 .processes
2160 .get_mut(task_id)
2161 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
2162
2163 if wait && shell.status == ShellStatus::Running {
2164 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
2165 let deadline = Instant::now() + timeout;
2166
2167 while shell.status == ShellStatus::Running && Instant::now() < deadline {
2168 if shell.poll() {
2169 break;
2170 }
2171 std::thread::sleep(Duration::from_millis(100));
2172 }
2173 } else {
2174 shell.poll();
2175 }
2176
2177 let (
2178 stdout_delta,
2179 stderr_delta,
2180 stdout_delta_len,
2181 stderr_delta_len,
2182 stdout_total,
2183 stderr_total,
2184 ) = shell.take_delta();
2185 let (stdout, stdout_meta) = truncate_with_meta(&stdout_delta);
2186 let (stderr, stderr_meta) = truncate_with_meta(&stderr_delta);
2187 let sandboxed = !matches!(shell.sandbox_type, SandboxType::None);
2188
2189 let command = shell.command.clone();
2190 let result = ShellResult {
2191 task_id: Some(shell.id.clone()),
2192 status: shell.status.clone(),
2193 exit_code: shell.exit_code,
2194 stdout,
2195 stderr,
2196 duration_ms: u64::try_from(shell.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
2197 stdout_len: stdout_meta.original_len.max(stdout_delta_len),
2198 stderr_len: stderr_meta.original_len.max(stderr_delta_len),
2199 stdout_omitted: stdout_meta.omitted,
2200 stderr_omitted: stderr_meta.omitted,
2201 stdout_truncated: stdout_meta.truncated,
2202 stderr_truncated: stderr_meta.truncated,
2203 sandboxed,
2204 sandbox_type: if sandboxed {
2205 Some(shell.sandbox_type.to_string())
2206 } else {
2207 None
2208 },
2209 sandbox_denied: shell.sandbox_denied(),
2210 };
2211
2212 Ok(ShellDeltaResult {
2213 command,
2214 result,
2215 stdout_total_len: stdout_total,
2216 stderr_total_len: stderr_total,
2217 })
2218 }
2219
2220 fn attach_heavy_permit(&mut self, task_id: &str, permit: HeavyCommandPermit) -> Result<()> {
2221 let shell = self
2222 .processes
2223 .get_mut(task_id)
2224 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
2225 shell.heavy_permit = Some(permit);
2226 Ok(())
2227 }
2228
2229 /// Kill a running background process
2230 pub fn kill(&mut self, task_id: &str) -> Result<ShellResult> {
2231 let shell = self
2232 .processes
2233 .get_mut(task_id)
2234 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
2235
2236 shell.kill()?;
2237 Ok(shell.snapshot())
2238 }
2239
2240 /// Kill every currently running background shell process.
2241 pub fn kill_running(&mut self) -> Result<Vec<ShellResult>> {
2242 let ids = self
2243 .processes
2244 .iter()
2245 .filter(|(_, shell)| shell.status == ShellStatus::Running)
2246 .map(|(id, _)| id.clone())
2247 .collect::<Vec<_>>();
2248
2249 let mut results = Vec::with_capacity(ids.len());
2250 for id in ids {
2251 results.push(self.kill(&id)?);
2252 }
2253 Ok(results)
2254 }
2255
2256 /// Poll a background process and return incremental output.
2257 pub fn poll_delta(
2258 &mut self,
2259 task_id: &str,
2260 wait: bool,
2261 timeout_ms: u64,
2262 ) -> Result<ShellDeltaResult> {
2263 self.get_output_delta(task_id, wait, timeout_ms)
2264 }
2265
2266 /// Attach durable task context to a live shell job.
2267 pub fn tag_linked_task(&mut self, task_id: &str, linked_task_id: Option<String>) -> Result<()> {
2268 let shell = self
2269 .processes
2270 .get_mut(task_id)
2271 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
2272 shell.linked_task_id = linked_task_id;
2273 Ok(())
2274 }
2275
2276 /// Inspect full output for a live or stale job.
2277 pub fn inspect_job(&mut self, task_id: &str) -> Result<ShellJobDetail> {
2278 if let Some(shell) = self.processes.get_mut(task_id) {
2279 shell.poll();
2280 return Ok(shell.job_detail());
2281 }
2282 if let Some(snapshot) = self.stale_jobs.get(task_id) {
2283 return Ok(ShellJobDetail {
2284 snapshot: snapshot.clone(),
2285 stdout: snapshot.stdout_tail.clone(),
2286 stderr: snapshot.stderr_tail.clone(),
2287 });
2288 }
2289 Err(anyhow!("Task {task_id} not found"))
2290 }
2291
2292 /// List all live and known-stale background shell jobs for the TUI.
2293 pub fn list_jobs(&mut self) -> Vec<ShellJobSnapshot> {
2294 for shell in self.processes.values_mut() {
2295 shell.poll();
2296 }
2297 // Evict completed processes older than 1 hour to bound memory growth.
2298 self.cleanup(Duration::from_secs(3600));
2299
2300 let mut jobs = self
2301 .processes
2302 .values()
2303 .map(BackgroundShell::job_snapshot)
2304 .collect::<Vec<_>>();
2305 jobs.extend(self.stale_jobs.values().cloned());
2306 jobs.sort_by(|a, b| {
2307 job_status_rank(&a.status, a.stale)
2308 .cmp(&job_status_rank(&b.status, b.stale))
2309 .then_with(|| a.id.cmp(&b.id))
2310 });
2311 jobs
2312 }
2313
2314 /// Whether a finished job's completion is waiting to be claimed. Unlike
2315 /// [`Self::may_have_undelivered_completion`] this polls, so it reports
2316 /// readiness the moment the process exits; the engine's idle shell wake
2317 /// uses it to fire exactly when evidence exists.
2318 pub(crate) fn has_finished_unreported_jobs(&mut self) -> bool {
2319 self.processes.values_mut().any(|shell| {
2320 shell.poll();
2321 shell.status != ShellStatus::Running && !shell.completion_reported
2322 })
2323 }
2324
2325 /// Drain once-only completion events together with lossless stream bytes.
2326 /// The engine publishes the bytes outside this manager's mutex and puts
2327 /// only the bounded event plus resulting handle into model context.
2328 pub(crate) fn drain_finished_jobs_with_evidence(&mut self) -> Vec<ShellCompletionEvidence> {
2329 let mut completions = Vec::new();
2330 for shell in self.processes.values_mut() {
2331 shell.poll();
2332 if shell.status != ShellStatus::Running && !shell.completion_reported {
2333 shell.completion_reported = true;
2334 completions.push(shell.completion_evidence());
2335 }
2336 }
2337 completions.sort_by(|a, b| a.event.task_id.cmp(&b.event.task_id));
2338 completions
2339 }
2340
2341 /// A terminal foreground result is already returned as the tool result;
2342 /// do not emit it again through the background-completion channel.
2343 fn acknowledge_foreground_completion(&mut self, task_id: &str) {
2344 if let Some(shell) = self.processes.get_mut(task_id) {
2345 shell.completion_reported = true;
2346 }
2347 }
2348
2349 /// Whether the next production turn may inject a shell completion event.
2350 ///
2351 /// This deliberately does not poll processes or flip
2352 /// `completion_reported`: preview is read-only. A running job counts as
2353 /// pending because it can finish before production drains completions; in
2354 /// that race an exact request body cannot be proved without mutation.
2355 pub fn may_have_undelivered_completion(&self) -> bool {
2356 self.processes
2357 .values()
2358 .any(|shell| !shell.completion_reported)
2359 }
2360
2361 /// Return agent owners whose tracked shell work is still running. The
2362 /// engine uses this to keep a worker's heartbeat alive while its only
2363 /// pending work is an explicitly tracked background shell task.
2364 pub fn running_owner_agent_ids(&mut self) -> Vec<String> {
2365 let mut owners = self
2366 .processes
2367 .values_mut()
2368 .filter_map(|shell| {
2369 shell.poll();
2370 (shell.status == ShellStatus::Running)
2371 .then(|| {
2372 shell
2373 .owner_agent
2374 .as_ref()
2375 .map(|owner| owner.agent_id.clone())
2376 })
2377 .flatten()
2378 })
2379 .collect::<Vec<_>>();
2380 owners.sort();
2381 owners.dedup();
2382 owners
2383 }
2384
2385 /// Remember a restart-stale job so the UI can show it instead of hiding it.
2386 #[allow(dead_code)]
2387 pub fn remember_stale_job(
2388 &mut self,
2389 id: impl Into<String>,
2390 command: impl Into<String>,
2391 cwd: PathBuf,
2392 linked_task_id: Option<String>,
2393 ) {
2394 let id = id.into();
2395 self.stale_jobs.insert(
2396 id.clone(),
2397 ShellJobSnapshot {
2398 id: id.clone(),
2399 job_id: id,
2400 command: command.into(),
2401 cwd,
2402 status: ShellStatus::Killed,
2403 exit_code: None,
2404 elapsed_ms: 0,
2405 stdout_tail: String::new(),
2406 stderr_tail: "Process is no longer attached to this TUI session.".to_string(),
2407 stdout_len: 0,
2408 stderr_len: 0,
2409 stdin_available: false,
2410 stale: true,
2411 elapsed_since_output_ms: None,
2412 linked_task_id,
2413 owner_agent_id: None,
2414 owner_agent_name: None,
2415 },
2416 );
2417 }
2418
2419 /// Clean up completed processes older than the given duration
2420 pub fn cleanup(&mut self, max_age: Duration) {
2421 let _now = Instant::now();
2422 self.processes.retain(|_, shell| {
2423 if shell.status == ShellStatus::Running {
2424 true
2425 } else {
2426 shell.started_at.elapsed() < max_age
2427 }
2428 });
2429 }
2430 }
2431
2432 fn job_status_rank(status: &ShellStatus, stale: bool) -> u8 {
2433 if stale {
2434 return 4;
2435 }
2436 match status {
2437 ShellStatus::Running => 0,
2438 ShellStatus::Failed | ShellStatus::TimedOut => 1,
2439 ShellStatus::Killed => 2,
2440 ShellStatus::Completed => 3,
2441 }
2442 }
2443
2444 /// Thread-safe wrapper for `ShellManager`
2445 pub type SharedShellManager = Arc<Mutex<ShellManager>>;
2446
2447 /// Create a new shared shell manager with default sandbox policy.
2448 pub fn new_shared_shell_manager(workspace: PathBuf) -> SharedShellManager {
2449 Arc::new(Mutex::new(ShellManager::new(workspace)))
2450 }
2451
2452 // === ToolSpec Implementations ===
2453
2454 use crate::command_safety::{
2455 SafetyLevel, analyze_command, extract_primary_command, is_parallel_readonly_command,
2456 };
2457 use crate::execpolicy::{ExecPolicyDecision, load_default_policy};
2458 use crate::features::Feature;
2459 use crate::tools::cargo_failure_summary::summarize_cargo_failure;
2460 use crate::tools::spec::{
2461 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
2462 optional_bool, optional_str, optional_u64, required_str, type_mismatch,
2463 };
2464 use async_trait::async_trait;
2465 use serde_json::json;
2466
2467 const FOREGROUND_TIMEOUT_RECOVERY_HINT: &str = "Foreground Bash is for bounded commands. \
2468 The timed-out process was killed; rerun long work as Bash action=\"run\" background=true, \
2469 then poll with Bash action=\"wait\" task_id=\"<id>\".";
2470
2471 const MACOS_PROVENANCE_HINT: &str = "Docker buildx failed to update its activity file due to a macOS \
2472 com.apple.provenance restriction. Files created by Docker Desktop's signed process carry a \
2473 kernel-enforced provenance tag that blocks writes from child processes (including the TUI \
2474 shell sandbox). Workarounds: (1) run the Docker build from a regular terminal outside the \
2475 TUI, or (2) disable BuildKit with DOCKER_BUILDKIT=0 (only works if your Dockerfiles do not \
2476 use RUN --mount directives).";
2477
2478 /// Human-readable exit status for a shell result: the numeric code when the
2479 /// process returned one, or "terminated by signal" when it did not (rather
2480 /// than leaking `Some(127)` / `None` Debug output to the user).
2481 fn exit_code_label(code: Option<i64>) -> String {
2482 match (code, exit_code_hex(code)) {
2483 (Some(code), Some(hex)) => format!("exit code {code} ({hex})"),
2484 (Some(code), None) => format!("exit code {code}"),
2485 (None, _) => "terminated by signal".to_string(),
2486 }
2487 }
2488
2489 fn exit_code_hex(code: Option<i64>) -> Option<String> {
2490 code.filter(|code| *code > i64::from(i32::MAX) && *code <= i64::from(u32::MAX))
2491 .map(|code| format!("0x{code:08X}"))
2492 }
2493 const PYTHON_BUILD_DEPENDENCY_HINT: &str = "Python build dependency missing: setuptools is not \
2494 available in the active environment. Install the declared build requirements first, for example \
2495 `python -m pip install -U pip setuptools wheel build`, then rerun the build command.";
2496
2497 fn attach_cargo_failure_summary(
2498 metadata: &mut serde_json::Value,
2499 command: &str,
2500 result: &ShellResult,
2501 ) {
2502 if let Some(summary) = summarize_cargo_failure(
2503 command,
2504 &result.stdout,
2505 &result.stderr,
2506 result.exit_code.and_then(|code| i32::try_from(code).ok()),
2507 ) {
2508 metadata["cargo_failure_summary"] = summary.to_metadata_value();
2509 }
2510 }
2511
2512 fn attach_python_build_dependency_hint(
2513 metadata: &mut serde_json::Value,
2514 hint: Option<&'static str>,
2515 ) {
2516 if let Some(hint) = hint {
2517 metadata["python_build_dependency_hint"] = json!({
2518 "kind": "missing_setuptools",
2519 "hint": hint,
2520 "recommended_first_step": "python -m pip install -U pip setuptools wheel build",
2521 });
2522 }
2523 }
2524
2525 pub(crate) fn looks_like_macos_provenance_failure(result: &ShellResult) -> bool {
2526 if matches!(result.status, ShellStatus::Completed) && result.exit_code == Some(0) {
2527 return false;
2528 }
2529 let combined = format!("{}\n{}", result.stdout, result.stderr).to_ascii_lowercase();
2530 combined.contains("com.apple.provenance")
2531 || combined.contains("update builder last activity")
2532 || (combined.contains("buildx/activity") && combined.contains("operation not permitted"))
2533 }
2534
2535 fn macos_provenance_hint(result: &ShellResult) -> Option<&'static str> {
2536 if looks_like_macos_provenance_failure(result) {
2537 Some(MACOS_PROVENANCE_HINT)
2538 } else {
2539 None
2540 }
2541 }
2542
2543 fn python_build_dependency_hint(command: &str, result: &ShellResult) -> Option<&'static str> {
2544 if matches!(result.status, ShellStatus::Completed) && result.exit_code == Some(0) {
2545 return None;
2546 }
2547
2548 let command = command.to_ascii_lowercase();
2549 let combined = format!("{}\n{}", result.stdout, result.stderr).to_ascii_lowercase();
2550 let mentions_missing_setuptools = [
2551 "no module named 'setuptools'",
2552 "no module named \"setuptools\"",
2553 "setuptools is not available",
2554 "cannot import 'setuptools",
2555 "cannot import \"setuptools",
2556 "missing dependencies",
2557 ]
2558 .iter()
2559 .any(|needle| combined.contains(needle))
2560 && combined.contains("setuptools");
2561 if !mentions_missing_setuptools {
2562 return None;
2563 }
2564
2565 let pythonish_command = [
2566 "python",
2567 "pip",
2568 "pytest",
2569 "tox",
2570 "nox",
2571 "cython",
2572 "setup.py",
2573 "build_ext",
2574 ]
2575 .iter()
2576 .any(|needle| command.contains(needle));
2577 let pythonish_output = [
2578 "setup.py",
2579 "pyproject.toml",
2580 "build_meta",
2581 "build_ext",
2582 "pep 517",
2583 "cython",
2584 ]
2585 .iter()
2586 .any(|needle| combined.contains(needle));
2587
2588 if pythonish_command || pythonish_output {
2589 Some(PYTHON_BUILD_DEPENDENCY_HINT)
2590 } else {
2591 None
2592 }
2593 }
2594
2595 fn command_likely_needs_network(command: &str) -> bool {
2596 let normalized = command.to_ascii_lowercase();
2597 let Some(primary) = extract_primary_command(&normalized) else {
2598 return false;
2599 };
2600 let primary = primary.rsplit(['/', '\\']).next().unwrap_or(primary);
2601
2602 match primary {
2603 "curl" | "wget" | "fetch" | "nc" | "netcat" | "ncat" | "ssh" | "scp" | "sftp" | "rsync"
2604 | "ftp" | "ping" | "traceroute" | "nslookup" | "dig" | "host" | "nmap" | "gh" | "hub" => {
2605 true
2606 }
2607 "git" => [
2608 " fetch",
2609 " pull",
2610 " clone",
2611 " ls-remote",
2612 " submodule",
2613 " push",
2614 ]
2615 .iter()
2616 .any(|needle| normalized.contains(needle)),
2617 "cargo" => [" install", " fetch", " update", " publish", " search"]
2618 .iter()
2619 .any(|needle| normalized.contains(needle)),
2620 "npm" | "pnpm" | "yarn" => [" install", " i", " add", " update", " publish"]
2621 .iter()
2622 .any(|needle| normalized.contains(needle)),
2623 "pip" | "pip3" | "uv" | "poetry" => [" install", " add", " sync", " update"]
2624 .iter()
2625 .any(|needle| normalized.contains(needle)),
2626 "brew" | "apt" | "apt-get" | "yum" | "dnf" | "pacman" => true,
2627 "go" => [" get", " install", " mod download"]
2628 .iter()
2629 .any(|needle| normalized.contains(needle)),
2630 _ => false,
2631 }
2632 }
2633
2634 fn looks_like_network_blocked_failure(result: &ShellResult) -> bool {
2635 if matches!(result.status, ShellStatus::Completed | ShellStatus::Running)
2636 || result.exit_code == Some(0)
2637 {
2638 return false;
2639 }
2640
2641 if result.stdout.trim() == "000" {
2642 return true;
2643 }
2644 if result.sandboxed && result.stdout.is_empty() && result.stderr.is_empty() {
2645 return true;
2646 }
2647
2648 let output = format!("{}\n{}", result.stdout, result.stderr).to_ascii_lowercase();
2649 [
2650 "operation not permitted",
2651 "network is unreachable",
2652 "could not resolve host",
2653 "couldn't resolve host",
2654 "failed to resolve",
2655 "temporary failure in name resolution",
2656 "name or service not known",
2657 "nodename nor servname provided",
2658 "no address associated",
2659 "failed to connect",
2660 "couldn't connect",
2661 "connection timed out",
2662 "connection reset",
2663 ]
2664 .iter()
2665 .any(|pattern| output.contains(pattern))
2666 }
2667
2668 fn shell_network_restricted_hint<'a>(
2669 context: &'a ToolContext,
2670 command: &str,
2671 result: &ShellResult,
2672 ) -> Option<&'a str> {
2673 let hint = context.shell_network_denied_hint.as_deref()?;
2674 let policy_blocks_network = context
2675 .elevated_sandbox_policy
2676 .as_ref()
2677 .is_some_and(|policy| !policy.has_network_access());
2678 if !policy_blocks_network || !command_likely_needs_network(command) {
2679 return None;
2680 }
2681 if result.sandbox_denied || looks_like_network_blocked_failure(result) {
2682 Some(hint)
2683 } else {
2684 None
2685 }
2686 }
2687
2688 /// Coaching line when the execution sandbox denied a command and the
2689 /// Plan-mode network hint did not already explain it. Most often a write
2690 /// under a read-only posture: name the effective posture and state that a
2691 /// tool-approval grant does not lift the sandbox, so the model reports the
2692 /// restriction instead of debugging the command (DGF-02, dogfood
2693 /// 2026-08-02).
2694 fn shell_sandbox_denied_hint(context: &ToolContext, result: &ShellResult) -> Option<String> {
2695 if !result.sandbox_denied {
2696 return None;
2697 }
2698 let policy = context.elevated_sandbox_policy.as_ref()?;
2699 Some(format!(
2700 "The execution sandbox blocked this command. Effective sandbox posture: {}. This posture is session policy — user approval of a tool call does not lift it. If the task needs more access, say which access is missing instead of retrying command variants.",
2701 policy.posture_label()
2702 ))
2703 }
2704
2705 fn shell_job_owner_from_context(context: &ToolContext) -> Option<ShellJobOwner> {
2706 let agent_id = context
2707 .owner_agent_id
2708 .as_deref()
2709 .map(str::trim)
2710 .filter(|value| !value.is_empty())?;
2711 let agent_name = context
2712 .owner_agent_name
2713 .as_deref()
2714 .map(str::trim)
2715 .filter(|value| !value.is_empty())
2716 .unwrap_or(agent_id);
2717 Some(ShellJobOwner {
2718 agent_id: agent_id.to_string(),
2719 agent_name: agent_name.to_string(),
2720 })
2721 }
2722
2723 fn shell_work_lifecycle_from_context(context: &ToolContext) -> Option<ShellWorkLifecycle> {
2724 context
2725 .runtime
2726 .work
2727 .as_ref()
2728 .map(|work| ShellWorkLifecycle {
2729 work: work.clone(),
2730 session_id: context.state_namespace.clone(),
2731 })
2732 }
2733
2734 fn lifecycle_now_ms() -> i64 {
2735 std::time::SystemTime::now()
2736 .duration_since(std::time::UNIX_EPOCH)
2737 .unwrap_or_default()
2738 .as_millis()
2739 .try_into()
2740 .unwrap_or(i64::MAX)
2741 }
2742
2743 fn attach_shell_owner_metadata(metadata: &mut serde_json::Value, context: &ToolContext) {
2744 let Some(owner) = shell_job_owner_from_context(context) else {
2745 return;
2746 };
2747 metadata["owner_agent_id"] = json!(owner.agent_id);
2748 metadata["owner_agent_name"] = json!(owner.agent_name);
2749 }
2750
2751 fn exec_shell_input_is_parallel_readonly(input: &serde_json::Value) -> bool {
2752 let Some(command) = input.get("command").and_then(serde_json::Value::as_str) else {
2753 return false;
2754 };
2755 if ["background", "interactive", "tty", "combined_output"]
2756 .iter()
2757 .any(|key| input.get(*key).and_then(serde_json::Value::as_bool) == Some(true))
2758 {
2759 return false;
2760 }
2761 if ["stdin", "input", "data"]
2762 .iter()
2763 .any(|key| input.get(*key).is_some())
2764 {
2765 return false;
2766 }
2767
2768 is_parallel_readonly_command(command)
2769 }
2770
2771 fn exec_shell_input_starts_detached(input: &serde_json::Value) -> bool {
2772 input
2773 .get("command")
2774 .and_then(serde_json::Value::as_str)
2775 .is_some()
2776 && input
2777 .get("interactive")
2778 .and_then(serde_json::Value::as_bool)
2779 != Some(true)
2780 && (input.get("background").and_then(serde_json::Value::as_bool) == Some(true)
2781 || input.get("tty").and_then(serde_json::Value::as_bool) == Some(true))
2782 }
2783
2784 #[allow(clippy::too_many_arguments)]
2785 async fn execute_foreground_via_background(
2786 context: &ToolContext,
2787 command: &str,
2788 heavy_permit: Option<HeavyCommandPermit>,
2789 working_dir: Option<String>,
2790 timeout_ms: u64,
2791 stdin_data: Option<&str>,
2792 tty: bool,
2793 policy_override: Option<ExecutionSandboxPolicy>,
2794 extra_env: HashMap<String, String>,
2795 ) -> Result<ShellResult> {
2796 let timeout_ms = timeout_ms.clamp(1000, 600_000);
2797 let spawned = {
2798 let mut manager = context
2799 .shell_manager
2800 .lock()
2801 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
2802 manager.clear_foreground_background_request();
2803 manager.execute_with_options_env_for_owner_and_work(
2804 command,
2805 working_dir.as_deref(),
2806 timeout_ms,
2807 true,
2808 stdin_data,
2809 tty,
2810 policy_override,
2811 extra_env,
2812 shell_job_owner_from_context(context),
2813 shell_work_lifecycle_from_context(context),
2814 )?
2815 };
2816 let task_id = spawned
2817 .task_id
2818 .ok_or_else(|| anyhow!("foreground shell did not return a process id"))?;
2819 if let Some(permit) = heavy_permit {
2820 let mut manager = context
2821 .shell_manager
2822 .lock()
2823 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
2824 manager.attach_heavy_permit(&task_id, permit)?;
2825 }
2826
2827 if stdin_data.is_some() {
2828 let mut manager = context
2829 .shell_manager
2830 .lock()
2831 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
2832 manager.write_stdin(&task_id, "", true)?;
2833 }
2834
2835 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
2836 loop {
2837 if context
2838 .cancel_token
2839 .as_ref()
2840 .is_some_and(|token| token.is_cancelled())
2841 {
2842 let mut manager = context
2843 .shell_manager
2844 .lock()
2845 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
2846 let result = manager.kill(&task_id);
2847 if result.is_ok() {
2848 manager.acknowledge_foreground_completion(&task_id);
2849 }
2850 return result;
2851 }
2852
2853 let snapshot = {
2854 let mut manager = context
2855 .shell_manager
2856 .lock()
2857 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
2858 if manager.take_foreground_background_request() {
2859 return manager.get_output(&task_id, false, 0);
2860 }
2861 let snapshot = manager.get_output(&task_id, false, 0)?;
2862 if snapshot.status != ShellStatus::Running {
2863 manager.acknowledge_foreground_completion(&task_id);
2864 }
2865 snapshot
2866 };
2867
2868 if snapshot.status != ShellStatus::Running {
2869 return Ok(snapshot);
2870 }
2871
2872 if Instant::now() >= deadline {
2873 let mut manager = context
2874 .shell_manager
2875 .lock()
2876 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
2877 let mut result = manager.kill(&task_id)?;
2878 manager.acknowledge_foreground_completion(&task_id);
2879 result.status = ShellStatus::TimedOut;
2880 return Ok(result);
2881 }
2882
2883 tokio::time::sleep(Duration::from_millis(100)).await;
2884 }
2885 }
2886
2887 /// Unified shell tool (#4625).
2888 ///
2889 /// The model sees one tool: `Bash` with an `action` parameter
2890 /// (run | wait | interact | cancel). The per-action `exec_shell*` execution
2891 /// aliases were removed in v0.9.3.
2892 pub struct BashTool {
2893 name: &'static str,
2894 forced_action: Option<&'static str>,
2895 }
2896
2897 impl BashTool {
2898 pub const fn new(name: &'static str) -> Self {
2899 Self {
2900 name,
2901 forced_action: None,
2902 }
2903 }
2904
2905 pub const fn alias(name: &'static str, action: &'static str) -> Self {
2906 Self {
2907 name,
2908 forced_action: Some(action),
2909 }
2910 }
2911 }
2912
2913 #[async_trait]
2914 impl ToolSpec for BashTool {
2915 fn name(&self) -> &'static str {
2916 self.name
2917 }
2918
2919 fn model_visible(&self) -> bool {
2920 self.name == "Bash"
2921 }
2922
2923 fn description(&self) -> &'static str {
2924 "Execute a shell command in the workspace. Action \"run\" (default) executes a command; \"wait\" polls a background task; \"interact\" sends stdin to a background task; \"cancel\" kills a background task. Foreground mode is for bounded commands; use background=true for work expected to take >5 seconds. Commands run via the user's login shell ($SHELL); when that shell is zsh, a bare word starting with `=` undergoes `=command` PATH expansion (e.g. `echo ===` fails) — quote such arguments, e.g. `echo '==='`."
2925 }
2926
2927 fn input_schema(&self) -> serde_json::Value {
2928 json!({
2929 "type": "object",
2930 "properties": {
2931 "action": {
2932 "type": "string",
2933 "enum": ["run", "wait", "interact", "cancel"],
2934 "description": "Action to perform (default: run)"
2935 },
2936 "command": {
2937 "type": "string",
2938 "description": "The shell command to execute (action=run)"
2939 },
2940 "timeout_ms": {
2941 "type": "integer",
2942 "description": "Timeout in milliseconds. The default depends on the action: action=run 120000 (capped at 600000), action=wait 30000, action=interact 1000."
2943 },
2944 "background": {
2945 "type": "boolean",
2946 "description": "Run in background and return task_id (default: false). Prefer this for commands expected to take >5 seconds."
2947 },
2948 "interactive": {
2949 "type": "boolean",
2950 "description": "Run interactively with terminal IO (default: false)"
2951 },
2952 "stdin": {
2953 "type": "string",
2954 "description": "Stdin data to send (action=run: before waiting; action=interact: to the background task). Also accepted as `input` or `data` — send only one."
2955 },
2956 "input": {
2957 "type": "string",
2958 "description": "Alias for `stdin`."
2959 },
2960 "data": {
2961 "type": "string",
2962 "description": "Alias for `stdin`."
2963 },
2964 "cwd": {
2965 "type": "string",
2966 "description": "Optional working directory for the command"
2967 },
2968 "tty": {
2969 "type": "boolean",
2970 "description": "Allocate a pseudo-terminal for interactive programs (implies background)"
2971 },
2972 "combined_output": {
2973 "type": "boolean",
2974 "description": "Capture stdout and stderr as one chronological PTY stream (default false)"
2975 },
2976 "task_id": {
2977 "type": "string",
2978 "description": "Task ID for action=wait/interact/cancel. Also accepted as `id`."
2979 },
2980 "id": {
2981 "type": "string",
2982 "description": "Alias for `task_id`."
2983 },
2984 "wait": {
2985 "type": "boolean",
2986 "description": "Block until task completes (action=wait, default: false)"
2987 },
2988 "close_stdin": {
2989 "type": "boolean",
2990 "description": "Close stdin after sending (action=interact)"
2991 },
2992 "all": {
2993 "type": "boolean",
2994 "description": "Cancel all running background tasks (action=cancel)"
2995 }
2996 },
2997 // The schema used to declare nothing required at all, so
2998 // `Bash{}` was schema-valid for the tool that runs shell
2999 // commands. What is required is per-action and cannot be spelled
3000 // as a flat `required` list: `run` needs `command`,
3001 // `wait`/`interact`/`cancel` need `task_id` (or its `id` alias),
3002 // and `cancel` needs `all` instead when cancelling everything.
3003 // A root `anyOf` of `required` groups is how this repo already
3004 // spells that (`finance`, `apply_patch`), and `schema_sanitize`
3005 // knows the shape: providers that reject root composition get the
3006 // groups merged and the constraint restated as a description note
3007 // (`root_composition_constraint_note`). The cost is that
3008 // `strict_schema_supported` rejects a root `anyOf`, so `Bash`
3009 // opts out of DeepSeek strict mode — as `finance` already does on
3010 // the same default agent surface, which turns strict mode off for
3011 // the whole tool set regardless.
3012 "anyOf": [
3013 { "required": ["command"] },
3014 { "required": ["task_id"] },
3015 { "required": ["id"] },
3016 { "required": ["all"] }
3017 ]
3018 })
3019 }
3020
3021 fn capabilities(&self) -> Vec<ToolCapability> {
3022 vec![
3023 ToolCapability::ExecutesCode,
3024 ToolCapability::Sandboxable,
3025 ToolCapability::RequiresApproval,
3026 ]
3027 }
3028
3029 fn approval_requirement(&self) -> ApprovalRequirement {
3030 ApprovalRequirement::Required
3031 }
3032
3033 fn approval_requirement_for(&self, input: &serde_json::Value) -> ApprovalRequirement {
3034 if exec_shell_input_is_parallel_readonly(input) {
3035 ApprovalRequirement::Auto
3036 } else {
3037 self.approval_requirement()
3038 }
3039 }
3040
3041 fn is_read_only_for(&self, input: &serde_json::Value) -> bool {
3042 exec_shell_input_is_parallel_readonly(input)
3043 }
3044
3045 fn supports_parallel_for(&self, input: &serde_json::Value) -> bool {
3046 exec_shell_input_is_parallel_readonly(input)
3047 }
3048
3049 fn starts_detached_for(&self, input: &serde_json::Value) -> bool {
3050 exec_shell_input_starts_detached(input)
3051 }
3052
3053 async fn execute(
3054 &self,
3055 input: serde_json::Value,
3056 context: &ToolContext,
3057 ) -> Result<ToolResult, ToolError> {
3058 // `and_then(as_str).unwrap_or("run")` treated *any* non-string
3059 // `action` as absent and fell through to the branch that runs
3060 // arbitrary code: `Bash{action: 3, command: "…"}` executed the
3061 // command. Every sibling family refuses a non-string action
3062 // (`canonical_action::required_action`), and `Bash` cannot be the
3063 // lenient one. `optional_str` is the type-strictness lane's extractor:
3064 // absent or `null` takes the documented `run` default, anything else
3065 // is a `type_mismatch` naming the field and the type it needed.
3066 let action = match self.forced_action {
3067 Some(forced) => forced,
3068 None => optional_str(&input, "action")?.unwrap_or("run"),
3069 };
3070 match action {
3071 "wait" => return self.execute_wait(&input, context).await,
3072 "interact" => return self.execute_interact(&input, context).await,
3073 "cancel" => return self.execute_cancel(&input, context).await,
3074 "run" => {}
3075 // Bash was the only action wrapper whose catch-all fell through to
3076 // its most dangerous branch: `{"action":"kill", "command":…}` ran
3077 // the command instead of cancelling, and a mis-cased "Cancel" did
3078 // the same. Every sibling (`File`, `Git`, `Web`, `Run`) already
3079 // refuses an unknown action; the tool that executes arbitrary code
3080 // should not be the lenient one.
3081 other => {
3082 return Err(ToolError::invalid_input(format!(
3083 "Unknown Bash action \"{other}\"; nothing was run. Pass one of: run, wait, interact, cancel."
3084 )));
3085 }
3086 }
3087 let command = required_str(&input, "command")?;
3088 match context.shell_policy {
3089 ShellPolicy::None => {
3090 return Ok(ToolResult::error(
3091 "Shell tools are disabled by the active permission profile.",
3092 ));
3093 }
3094 ShellPolicy::ReadOnly if !exec_shell_input_is_parallel_readonly(&input) => {
3095 return Ok(ToolResult::error(
3096 "Shell command blocked by read-only shell policy. Use a non-mutating, non-background inspection command, or switch to Act mode (`/mode act`) for write-capable shell work.",
3097 ));
3098 }
3099 ShellPolicy::ReadOnly | ShellPolicy::Full => {}
3100 }
3101 let timeout_ms = optional_u64(&input, "timeout_ms", 120_000)?.min(600_000);
3102 let background = optional_bool(&input, "background", false)?;
3103 let interactive = optional_bool(&input, "interactive", false)?;
3104 let combined_output = optional_bool(&input, "combined_output", false)?;
3105 let tty = optional_bool(&input, "tty", false)? || (combined_output && background);
3106 // Strict types (2026-08-04 review): a non-string here used to be
3107 // silently dropped — the command then ran with NO stdin and reported
3108 // success, the exact silent-drop failure the alias hardening closed
3109 // for misspelled names. A wrong type is an error, never a no-op.
3110 let stdin_data = match first_present_field(&input, &["stdin", "input", "data"]) {
3111 None => None,
3112 Some((name, value)) => Some(
3113 value
3114 .as_str()
3115 .ok_or_else(|| type_mismatch(name, value, "a string"))?
3116 .to_string(),
3117 ),
3118 };
3119
3120 if interactive && background {
3121 return Ok(ToolResult::error(
3122 "Interactive commands cannot run in background mode.",
3123 ));
3124 }
3125 if interactive && (tty || combined_output) {
3126 return Ok(ToolResult::error(
3127 "Interactive mode cannot be combined with TTY or combined_output sessions.",
3128 ));
3129 }
3130 if interactive && stdin_data.is_some() {
3131 return Ok(ToolResult::error(
3132 "Interactive mode cannot be combined with stdin data.",
3133 ));
3134 }
3135
3136 let background = background || tty;
3137
3138 let mut execpolicy_decision: Option<ExecPolicyDecision> = None;
3139 if context.features.enabled(Feature::ExecPolicy)
3140 && let Some(policy) = load_default_policy()
3141 .map_err(|e| ToolError::execution_failed(format!("execpolicy load failed: {e}")))?
3142 {
3143 let decision = policy.evaluate(command);
3144 execpolicy_decision = Some(decision.clone());
3145 if let ExecPolicyDecision::Deny(reason) = decision {
3146 return Ok(ToolResult {
3147 content: format!("BLOCKED: {reason}"),
3148 success: false,
3149 metadata: Some(json!({
3150 "execpolicy": {
3151 "decision": "deny",
3152 "reason": reason,
3153 }
3154 })),
3155 });
3156 }
3157 }
3158
3159 // Safety analysis (always run for metadata, but only block when not in YOLO mode)
3160 let safety = analyze_command(command);
3161 if !context.auto_approve {
3162 match safety.level {
3163 SafetyLevel::Dangerous => {
3164 let reasons = safety.reasons.join("; ");
3165 let suggestions = if safety.suggestions.is_empty() {
3166 String::new()
3167 } else {
3168 format!("\nSuggestions: {}", safety.suggestions.join("; "))
3169 };
3170 return Ok(ToolResult {
3171 content: format!(
3172 "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."
3173 ),
3174 success: false,
3175 metadata: Some(json!({
3176 "safety_level": "dangerous",
3177 "blocked": true,
3178 "reasons": safety.reasons,
3179 "suggestions": safety.suggestions,
3180 })),
3181 });
3182 }
3183 SafetyLevel::RequiresApproval | SafetyLevel::Safe | SafetyLevel::WorkspaceSafe => {
3184 // Proceed normally
3185 }
3186 }
3187 }
3188
3189 let policy_override = context.elevated_sandbox_policy.clone();
3190 // Strict types: a non-string cwd used to silently run the command in
3191 // the workspace default instead of erroring (2026-08-04 review).
3192 let working_dir = match first_present_field(&input, &["cwd", "working_dir"])
3193 .map(|(name, value)| {
3194 value
3195 .as_str()
3196 .ok_or_else(|| type_mismatch(name, value, "a string"))
3197 })
3198 .transpose()?
3199 {
3200 Some(dir) => {
3201 // Validate cwd against workspace boundary (same as file tools)
3202 let resolved = context.resolve_path(dir)?;
3203 Some(resolved.to_string_lossy().to_string())
3204 }
3205 // Default to the tool context's workspace (which reflects the
3206 // child agent's worktree when `worktree: true` was used), not the
3207 // shared ShellManager's parent-workspace default_workspace.
3208 None => Some(context.workspace.display().to_string()),
3209 };
3210
3211 // #456 — collect env from any configured `shell_env` hooks. Runs
3212 // synchronously, captures stdout, parses `KEY=VAL` lines, audit-logs
3213 // the keys (never the values). Empty / no-op when no hook is
3214 // configured.
3215 let extra_env = if let Some(hook_executor) = &context.runtime.hook_executor {
3216 let hook_ctx = crate::hooks::HookContext::new()
3217 .with_tool_name("exec_shell")
3218 .with_tool_args(&input);
3219 hook_executor.collect_shell_env(&hook_ctx)
3220 } else {
3221 std::collections::HashMap::new()
3222 };
3223
3224 let command_expense = infer_command_expense(command);
3225 let heavy_permit = acquire_heavy_command_permit(command, context.cancel_token.as_ref())
3226 .await
3227 .map_err(|error| ToolError::execution_failed(error.to_string()))?;
3228 let admission_wait_ms = heavy_permit
3229 .as_ref()
3230 .map(|permit| u64::try_from(permit.queued_for().as_millis()).unwrap_or(u64::MAX));
3231 let admission_limit = heavy_permit.as_ref().map(HeavyCommandPermit::limit);
3232 let admission_memory = heavy_permit
3233 .as_ref()
3234 .map(HeavyCommandPermit::memory_pressure);
3235
3236 // Route through external sandbox backend when configured.
3237 if let Some(backend) = &context.sandbox_backend {
3238 if interactive {
3239 return Ok(ToolResult::error(
3240 "Interactive mode is not supported with external sandbox backends.",
3241 ));
3242 }
3243 if background {
3244 return Ok(ToolResult::error(
3245 "Background mode is not supported with external sandbox backends.",
3246 ));
3247 }
3248 if tty {
3249 return Ok(ToolResult::error(
3250 "TTY mode is not supported with external sandbox backends.",
3251 ));
3252 }
3253
3254 let started = std::time::Instant::now();
3255 let backend_result = backend.exec(command, &extra_env).await;
3256
3257 let result = match backend_result {
3258 Ok(output) => {
3259 let (stdout, stdout_meta) = truncate_with_meta(&output.stdout);
3260 let (stderr, stderr_meta) = truncate_with_meta(&output.stderr);
3261 ShellResult {
3262 task_id: None,
3263 status: if output.exit_code == 0 {
3264 ShellStatus::Completed
3265 } else {
3266 ShellStatus::Failed
3267 },
3268 exit_code: Some(i64::from(output.exit_code)),
3269 stdout,
3270 stderr,
3271 duration_ms: u64::try_from(started.elapsed().as_millis())
3272 .unwrap_or(u64::MAX),
3273 stdout_len: stdout_meta.original_len,
3274 stderr_len: stderr_meta.original_len,
3275 stdout_omitted: stdout_meta.omitted,
3276 stderr_omitted: stderr_meta.omitted,
3277 stdout_truncated: stdout_meta.truncated,
3278 stderr_truncated: stderr_meta.truncated,
3279 sandboxed: true,
3280 sandbox_type: Some("opensandbox".to_string()),
3281 sandbox_denied: false,
3282 }
3283 }
3284 Err(e) => {
3285 return Ok(ToolResult::error(format!("Sandbox backend error: {e}")));
3286 }
3287 };
3288
3289 // Build result (reuse the existing output rendering below).
3290 let stdout_summary = summarize_output(&result.stdout);
3291 let stderr_summary = summarize_output(&result.stderr);
3292 let summary = if !stderr_summary.is_empty() {
3293 stderr_summary.clone()
3294 } else {
3295 stdout_summary.clone()
3296 };
3297 let python_dependency_hint = python_build_dependency_hint(command, &result);
3298 let mut output = if result.stdout.is_empty() && result.stderr.is_empty() {
3299 "(no output)".to_string()
3300 } else if result.stderr.is_empty() {
3301 result.stdout.clone()
3302 } else {
3303 format!("{}\n\nSTDERR:\n{}", result.stdout, result.stderr)
3304 };
3305 if let Some(hint) = python_dependency_hint {
3306 output = format!("{hint}\n\n{output}");
3307 }
3308
3309 let mut metadata = json!({
3310 "exit_code": result.exit_code,
3311 "exit_code_hex": exit_code_hex(result.exit_code),
3312 "status": format!("{:?}", result.status),
3313 "duration_ms": result.duration_ms,
3314 "sandboxed": true,
3315 "sandbox_type": "opensandbox",
3316 "sandbox_denied": false,
3317 "task_id": result.task_id,
3318 "stdout_len": result.stdout_len,
3319 "stderr_len": result.stderr_len,
3320 "stdout_truncated": result.stdout_truncated,
3321 "stderr_truncated": result.stderr_truncated,
3322 "stdout_omitted": result.stdout_omitted,
3323 "stderr_omitted": result.stderr_omitted,
3324 "summary": summary,
3325 "stdout_summary": stdout_summary,
3326 "stderr_summary": stderr_summary,
3327 "safety_level": format!("{:?}", safety.level),
3328 "interactive": false,
3329 "canceled": false,
3330 "sandbox_backend": "opensandbox",
3331 "expense_class": match command_expense {
3332 CommandExpense::Heavy => "heavy",
3333 CommandExpense::Normal => "normal",
3334 },
3335 "resource_admission_wait_ms": admission_wait_ms,
3336 "resource_admission_limit": admission_limit,
3337 });
3338 attach_shell_owner_metadata(&mut metadata, context);
3339 attach_cargo_failure_summary(&mut metadata, command, &result);
3340 attach_python_build_dependency_hint(&mut metadata, python_dependency_hint);
3341
3342 return Ok(ToolResult {
3343 content: output,
3344 success: result.status == ShellStatus::Completed,
3345 metadata: Some(metadata),
3346 });
3347 }
3348
3349 let mut lifecycle_warning = None;
3350 let result = if interactive {
3351 let mut manager = context
3352 .shell_manager
3353 .lock()
3354 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
3355 let work_lifecycle = shell_work_lifecycle_from_context(context);
3356 let task_id = format!("shell_{}", &Uuid::new_v4().to_string()[..8]);
3357 let mut spawn_guard =
3358 ShellSpawnIntentGuard::new(work_lifecycle.clone(), &task_id, command)
3359 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
3360 let result = manager.execute_interactive_with_policy_env(
3361 command,
3362 working_dir.as_deref(),
3363 timeout_ms,
3364 policy_override,
3365 extra_env,
3366 );
3367 match result {
3368 Ok(result) => {
3369 // The process result is authoritative once execution has
3370 // completed. Disarm before observing it so a graph-write
3371 // failure cannot relabel a successful command as Failed.
3372 spawn_guard.disarm();
3373 if let Some(lifecycle) = work_lifecycle.as_ref() {
3374 let raw_bytes = result.stdout_len.saturating_add(result.stderr_len);
3375 if let Err(err) = lifecycle.observe(&task_id, &result.status, 1, raw_bytes)
3376 {
3377 tracing::warn!(shell_id = %task_id, error = %err, "interactive shell completed but Work lifecycle reconciliation failed");
3378 lifecycle_warning = Some(err.to_string());
3379 }
3380 }
3381 Ok(result)
3382 }
3383 Err(err) => Err(err),
3384 }
3385 } else if background {
3386 let mut manager = context
3387 .shell_manager
3388 .lock()
3389 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
3390 let result = manager.execute_with_options_env_for_owner_and_work(
3391 command,
3392 working_dir.as_deref(),
3393 timeout_ms,
3394 true,
3395 stdin_data.as_deref(),
3396 tty,
3397 policy_override,
3398 extra_env,
3399 shell_job_owner_from_context(context),
3400 shell_work_lifecycle_from_context(context),
3401 );
3402 if let (Ok(result), Some(permit)) = (&result, heavy_permit)
3403 && let Some(task_id) = result.task_id.as_deref()
3404 {
3405 manager
3406 .attach_heavy_permit(task_id, permit)
3407 .map_err(|error| ToolError::execution_failed(error.to_string()))?;
3408 }
3409 result
3410 } else {
3411 execute_foreground_via_background(
3412 context,
3413 command,
3414 heavy_permit,
3415 working_dir,
3416 timeout_ms,
3417 stdin_data.as_deref(),
3418 combined_output,
3419 policy_override,
3420 extra_env,
3421 )
3422 .await
3423 };
3424
3425 match result {
3426 Ok(result) => {
3427 let backgrounded_foreground =
3428 !background && !interactive && result.status == ShellStatus::Running;
3429 if (background || backgrounded_foreground)
3430 && let (Some(shell_id), Some(task_id)) = (
3431 result.task_id.as_deref(),
3432 context.runtime.active_task_id.clone(),
3433 )
3434 && let Ok(mut manager) = context.shell_manager.lock()
3435 {
3436 let _ = manager.tag_linked_task(shell_id, Some(task_id));
3437 }
3438
3439 let was_cancelled = context
3440 .cancel_token
3441 .as_ref()
3442 .is_some_and(|token| token.is_cancelled());
3443 let task_id_str = result.task_id.clone().unwrap_or_default();
3444 let stdout_summary = summarize_output(&result.stdout);
3445 let stderr_summary = summarize_output(&result.stderr);
3446 let summary = if !stderr_summary.is_empty() {
3447 stderr_summary.clone()
3448 } else {
3449 stdout_summary.clone()
3450 };
3451 let network_restricted_hint =
3452 shell_network_restricted_hint(context, command, &result).map(str::to_string);
3453 let sandbox_denied_hint = if network_restricted_hint.is_none() {
3454 shell_sandbox_denied_hint(context, &result)
3455 } else {
3456 None
3457 };
3458 let provenance_hint = macos_provenance_hint(&result);
3459 let python_dependency_hint = python_build_dependency_hint(command, &result);
3460 let mut output = if interactive {
3461 format!(
3462 "Interactive command completed (exit code: {:?})",
3463 result.exit_code
3464 )
3465 } else if result.status == ShellStatus::Completed {
3466 if result.stdout.is_empty() && result.stderr.is_empty() {
3467 "(no output)".to_string()
3468 } else if result.stderr.is_empty() {
3469 result.stdout.clone()
3470 } else {
3471 format!("{}\n\nSTDERR:\n{}", result.stdout, result.stderr)
3472 }
3473 } else if result.status == ShellStatus::Running {
3474 if backgrounded_foreground {
3475 format!(
3476 "Foreground shell wait moved to /jobs: {task_id_str}\n\nReturns immediately; completion is delivered to the model as an internal runtime event and shown in task/status state. Keep working; call Bash action=\"wait\" task_id=\"{task_id_str}\" only if you need early output, final output, or wait=true at a true dependency."
3477 )
3478 } else {
3479 format!(
3480 "Background task started: {task_id_str}\n\nReturns immediately; completion is delivered to the model as an internal runtime event and shown in task/status state. Keep working; call Bash action=\"wait\" task_id=\"{task_id_str}\" only if you need early output, final output, or wait=true at a true dependency."
3481 )
3482 }
3483 } else if result.status == ShellStatus::Killed && was_cancelled {
3484 format!(
3485 "Command canceled; process killed.\n\nSTDOUT:\n{}\n\nSTDERR:\n{}",
3486 result.stdout, result.stderr
3487 )
3488 } else if result.status == ShellStatus::TimedOut {
3489 format!(
3490 "Command timed out after {timeout_ms}ms; process killed.\n\n{FOREGROUND_TIMEOUT_RECOVERY_HINT}\n\nSTDOUT:\n{}\n\nSTDERR:\n{}",
3491 result.stdout, result.stderr
3492 )
3493 } else {
3494 format!(
3495 "Command failed ({})\n\nSTDOUT:\n{}\n\nSTDERR:\n{}",
3496 exit_code_label(result.exit_code),
3497 result.stdout,
3498 result.stderr
3499 )
3500 };
3501 if let Some(hint) = network_restricted_hint.as_deref() {
3502 output = format!("{hint}\n\n{output}");
3503 }
3504 if let Some(hint) = sandbox_denied_hint.as_deref() {
3505 output = format!("{hint}\n\n{output}");
3506 }
3507 if let Some(hint) = provenance_hint {
3508 output = format!("{hint}\n\n{output}");
3509 }
3510 if let Some(hint) = python_dependency_hint {
3511 output = format!("{hint}\n\n{output}");
3512 }
3513
3514 let mut metadata = json!({
3515 "exit_code": result.exit_code,
3516 "exit_code_hex": exit_code_hex(result.exit_code),
3517 "status": format!("{:?}", result.status),
3518 "duration_ms": result.duration_ms,
3519 "sandboxed": result.sandboxed,
3520 "sandbox_type": result.sandbox_type,
3521 "sandbox_denied": result.sandbox_denied,
3522 "task_id": result.task_id,
3523 "stdout_len": result.stdout_len,
3524 "stderr_len": result.stderr_len,
3525 "stdout_truncated": result.stdout_truncated,
3526 "stderr_truncated": result.stderr_truncated,
3527 "stdout_omitted": result.stdout_omitted,
3528 "stderr_omitted": result.stderr_omitted,
3529 "lifecycle_warning": lifecycle_warning,
3530 "expense_class": match command_expense {
3531 CommandExpense::Heavy => "heavy",
3532 CommandExpense::Normal => "normal",
3533 },
3534 "resource_admission_wait_ms": admission_wait_ms,
3535 "resource_admission_limit": admission_limit,
3536 "resource_admission_memory": match admission_memory {
3537 Some(MemoryPressure::Critical) => "critical",
3538 Some(MemoryPressure::Constrained) => "constrained",
3539 Some(MemoryPressure::Nominal) | None => "nominal",
3540 Some(MemoryPressure::Unknown) => "unknown",
3541 },
3542 "summary": summary,
3543 "stdout_summary": stdout_summary,
3544 "stderr_summary": stderr_summary,
3545 "safety_level": format!("{:?}", safety.level),
3546 "interactive": interactive,
3547 "combined_output": combined_output,
3548 "canceled": was_cancelled,
3549 "execpolicy": execpolicy_decision.as_ref().map(|decision| match decision {
3550 ExecPolicyDecision::Allow => json!({
3551 "decision": "allow",
3552 }),
3553 ExecPolicyDecision::Deny(reason) => json!({
3554 "decision": "deny",
3555 "reason": reason,
3556 }),
3557 ExecPolicyDecision::AskUser(reason) => json!({
3558 "decision": "ask_user",
3559 "reason": reason,
3560 }),
3561 }),
3562 });
3563 metadata["backgrounded"] = json!(background || backgrounded_foreground);
3564 if background || backgrounded_foreground {
3565 metadata["auto_resume_on_completion"] = json!(true);
3566 metadata["completion_surface"] = json!("runtime_event_and_task_status");
3567 metadata["background_policy"] = json!("nonblocking");
3568 }
3569 if result.status == ShellStatus::TimedOut && !background && !interactive {
3570 metadata["foreground_timeout_recovery"] = json!({
3571 "process_killed": true,
3572 "hint": FOREGROUND_TIMEOUT_RECOVERY_HINT,
3573 "recommended_tools": ["Bash", "task_shell_start", "task_shell_wait"],
3574 "rerun_as": {"tool": "Bash", "action": "run", "background": true},
3575 "poll_with": [
3576 {"tool": "Bash", "action": "wait"},
3577 {"tool": "task_shell_wait"}
3578 ]
3579 });
3580 }
3581 if let Some(hint) = network_restricted_hint {
3582 metadata["sandbox_network_restricted"] = json!(true);
3583 metadata["sandbox_network_denied_hint"] = json!(hint);
3584 }
3585 if let Some(hint) = sandbox_denied_hint {
3586 metadata["sandbox_denied_hint"] = json!(hint);
3587 }
3588 if provenance_hint.is_some() {
3589 metadata["macos_provenance_restricted"] = json!(true);
3590 }
3591 attach_shell_owner_metadata(&mut metadata, context);
3592 attach_cargo_failure_summary(&mut metadata, command, &result);
3593 attach_python_build_dependency_hint(&mut metadata, python_dependency_hint);
3594
3595 Ok(ToolResult {
3596 content: output,
3597 success: result.status == ShellStatus::Completed
3598 || result.status == ShellStatus::Running,
3599 metadata: Some(metadata),
3600 })
3601 }
3602 Err(e) => Ok(ToolResult::error(format!("Shell execution failed: {e}"))),
3603 }
3604 }
3605 }
3606
3607 /// Maximum deliberate dependency-barrier wait accepted by `exec_shell_wait`.
3608 pub(crate) const EXEC_SHELL_WAIT_MAX_TIMEOUT_MS: u64 = 600_000;
3609
3610 impl BashTool {
3611 async fn execute_wait(
3612 &self,
3613 input: &serde_json::Value,
3614 context: &ToolContext,
3615 ) -> Result<ToolResult, ToolError> {
3616 let task_id = required_task_id(input)?;
3617 let wait = optional_bool(input, "wait", false)?;
3618 let timeout_ms = optional_u64(input, "timeout_ms", 30_000)?;
3619
3620 let (delta, wait_canceled) = if wait {
3621 wait_for_shell_delta_cancellable(context, task_id, timeout_ms).await?
3622 } else {
3623 let mut manager = context
3624 .shell_manager
3625 .lock()
3626 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
3627 let delta = manager
3628 .get_output_delta(task_id, false, timeout_ms)
3629 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
3630 (delta, false)
3631 };
3632
3633 let status = delta.result.status.clone();
3634 let mut result = build_shell_delta_tool_result(delta, context);
3635 if wait_canceled {
3636 if matches!(status, ShellStatus::Running) {
3637 result.content = format!(
3638 "Wait canceled; background shell task {task_id} is still running.\n\n{}",
3639 result.content
3640 );
3641 }
3642 if let Some(metadata) = result.metadata.as_mut()
3643 && let Some(object) = metadata.as_object_mut()
3644 {
3645 object.insert("wait_canceled".to_string(), json!(true));
3646 }
3647 }
3648
3649 Ok(result)
3650 }
3651
3652 async fn execute_interact(
3653 &self,
3654 input: &serde_json::Value,
3655 context: &ToolContext,
3656 ) -> Result<ToolResult, ToolError> {
3657 let task_id = required_task_id(input)?;
3658 let close_stdin = optional_bool(input, "close_stdin", false)?;
3659 let timeout_ms = optional_u64(input, "timeout_ms", 1_000)?;
3660 // Same strict-type contract as `run` (2026-08-04): a non-string here
3661 // was silently dropped, so an `interact` call reported success while
3662 // writing nothing to the child's stdin. Alias order also matches
3663 // `run` now — `stdin` first — so the same payload reaches the same
3664 // place whichever spelling the model uses.
3665 let interaction_input = match first_present_field(input, &["stdin", "input", "data"]) {
3666 None => "",
3667 Some((name, value)) => value
3668 .as_str()
3669 .ok_or_else(|| type_mismatch(name, value, "a string"))?,
3670 };
3671
3672 {
3673 let mut manager = context
3674 .shell_manager
3675 .lock()
3676 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
3677 if !interaction_input.is_empty() || close_stdin {
3678 manager
3679 .write_stdin(task_id, interaction_input, close_stdin)
3680 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
3681 }
3682 }
3683
3684 let mut elapsed = 0u64;
3685 loop {
3686 if context
3687 .cancel_token
3688 .as_ref()
3689 .is_some_and(|token| token.is_cancelled())
3690 {
3691 let mut manager = context
3692 .shell_manager
3693 .lock()
3694 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
3695 let delta = manager
3696 .get_output_delta(task_id, false, 0)
3697 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
3698 let mut result = build_shell_delta_tool_result(delta, context);
3699 if let Some(metadata) = result.metadata.as_mut()
3700 && let Some(object) = metadata.as_object_mut()
3701 {
3702 object.insert("wait_canceled".to_string(), json!(true));
3703 }
3704 return Ok(result);
3705 }
3706
3707 let delta = {
3708 let mut manager = context
3709 .shell_manager
3710 .lock()
3711 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
3712 manager
3713 .get_output_delta(task_id, false, 0)
3714 .map_err(|err| ToolError::execution_failed(err.to_string()))?
3715 };
3716
3717 if !delta.result.stdout.is_empty()
3718 || !delta.result.stderr.is_empty()
3719 || delta.result.status != ShellStatus::Running
3720 || elapsed >= timeout_ms
3721 {
3722 return Ok(build_shell_delta_tool_result(delta, context));
3723 }
3724
3725 tokio::time::sleep(Duration::from_millis(50)).await;
3726 elapsed = elapsed.saturating_add(50);
3727 }
3728 }
3729
3730 async fn execute_cancel(
3731 &self,
3732 input: &serde_json::Value,
3733 context: &ToolContext,
3734 ) -> Result<ToolResult, ToolError> {
3735 let cancel_all = optional_bool(input, "all", false)?;
3736 let mut manager = context
3737 .shell_manager
3738 .lock()
3739 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
3740
3741 if cancel_all {
3742 let results = manager
3743 .kill_running()
3744 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
3745 if results.is_empty() {
3746 return Ok(ToolResult {
3747 content: "No running background commands.".to_string(),
3748 success: true,
3749 metadata: Some(json!({
3750 "status": "Noop",
3751 "canceled": 0,
3752 "task_ids": [],
3753 })),
3754 });
3755 }
3756
3757 let task_ids = results
3758 .iter()
3759 .filter_map(|result| result.task_id.clone())
3760 .collect::<Vec<_>>();
3761 return Ok(ToolResult {
3762 content: format!(
3763 "Canceled {} background command{}: {}",
3764 task_ids.len(),
3765 if task_ids.len() == 1 { "" } else { "s" },
3766 task_ids.join(", ")
3767 ),
3768 success: true,
3769 metadata: Some(json!({
3770 "status": "Killed",
3771 "canceled": task_ids.len(),
3772 "task_ids": task_ids,
3773 })),
3774 });
3775 }
3776
3777 let task_id = required_task_id(input)?;
3778 let result = manager
3779 .kill(task_id)
3780 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
3781 let task_id = result
3782 .task_id
3783 .clone()
3784 .unwrap_or_else(|| task_id.to_string());
3785 Ok(ToolResult {
3786 content: format!("Canceled background command: {task_id}"),
3787 success: true,
3788 metadata: Some(json!({
3789 "status": format!("{:?}", result.status),
3790 "task_id": task_id,
3791 "exit_code": result.exit_code,
3792 "duration_ms": result.duration_ms,
3793 })),
3794 })
3795 }
3796 }
3797
3798 fn required_task_id(input: &serde_json::Value) -> Result<&str, ToolError> {
3799 // A present-but-non-string task_id is a type error, not a missing field:
3800 // "missing required field" sends the model's retry in the wrong
3801 // direction when it already supplied `task_id: 42` (2026-08-04 review).
3802 match first_present_field(input, &["task_id", "id"]) {
3803 None => Err(ToolError::missing_field("task_id")),
3804 Some((name, value)) => value
3805 .as_str()
3806 .ok_or_else(|| type_mismatch(name, value, "a string")),
3807 }
3808 }
3809
3810 /// First PRESENT value among aliased spellings of one field. `null` counts
3811 /// as absent, matching the `is_absent` rule the shared typed helpers use.
3812 fn first_present_field<'a>(
3813 input: &'a serde_json::Value,
3814 names: &[&'static str],
3815 ) -> Option<(&'static str, &'a serde_json::Value)> {
3816 names.iter().find_map(|name| match input.get(*name) {
3817 None | Some(serde_json::Value::Null) => None,
3818 Some(value) => Some((*name, value)),
3819 })
3820 }
3821
3822 fn build_shell_delta_tool_result(delta: ShellDeltaResult, context: &ToolContext) -> ToolResult {
3823 let result = delta.result;
3824 let network_restricted_hint =
3825 shell_network_restricted_hint(context, &delta.command, &result).map(str::to_string);
3826 let sandbox_denied_hint = if network_restricted_hint.is_none() {
3827 shell_sandbox_denied_hint(context, &result)
3828 } else {
3829 None
3830 };
3831 let provenance_hint = macos_provenance_hint(&result);
3832 let python_dependency_hint = python_build_dependency_hint(&delta.command, &result);
3833 let stdout_summary = summarize_output(&result.stdout);
3834 let stderr_summary = summarize_output(&result.stderr);
3835 let summary = if !stderr_summary.is_empty() {
3836 stderr_summary.clone()
3837 } else {
3838 stdout_summary.clone()
3839 };
3840
3841 let mut output = if result.stdout.is_empty() && result.stderr.is_empty() {
3842 match result.status {
3843 ShellStatus::Running => "Background task running (no new output).".to_string(),
3844 ShellStatus::Completed => "(no new output)".to_string(),
3845 ShellStatus::Failed => {
3846 format!("Command failed ({})", exit_code_label(result.exit_code))
3847 }
3848 ShellStatus::TimedOut => "Command timed out (no new output).".to_string(),
3849 ShellStatus::Killed => "Command killed (no new output).".to_string(),
3850 }
3851 } else if result.stderr.is_empty() {
3852 result.stdout.clone()
3853 } else {
3854 format!("{}\n\nSTDERR:\n{}", result.stdout, result.stderr)
3855 };
3856 // The model cannot see metadata, so surface the real elapsed time in the
3857 // visible content. Without it every wait result looks identical whether
3858 // the task just started or has been running for minutes, which biases the
3859 // model into busy-polling short waits and misjudging long ones.
3860 output = format!("{}\n\n{output}", wait_timing_line(&result));
3861
3862 if let Some(hint) = network_restricted_hint.as_deref() {
3863 output = format!("{hint}\n\n{output}");
3864 }
3865 if let Some(hint) = sandbox_denied_hint.as_deref() {
3866 output = format!("{hint}\n\n{output}");
3867 }
3868 if let Some(hint) = provenance_hint {
3869 output = format!("{hint}\n\n{output}");
3870 }
3871 if let Some(hint) = python_dependency_hint {
3872 output = format!("{hint}\n\n{output}");
3873 }
3874
3875 let mut metadata = json!({
3876 "exit_code": result.exit_code,
3877 "exit_code_hex": exit_code_hex(result.exit_code),
3878 "status": format!("{:?}", result.status),
3879 "duration_ms": result.duration_ms,
3880 "sandboxed": result.sandboxed,
3881 "sandbox_type": result.sandbox_type,
3882 "sandbox_denied": result.sandbox_denied,
3883 "task_id": result.task_id,
3884 "stdout_len": result.stdout_len,
3885 "stderr_len": result.stderr_len,
3886 "stdout_truncated": result.stdout_truncated,
3887 "stderr_truncated": result.stderr_truncated,
3888 "stdout_omitted": result.stdout_omitted,
3889 "stderr_omitted": result.stderr_omitted,
3890 "stdout_total_len": delta.stdout_total_len,
3891 "stderr_total_len": delta.stderr_total_len,
3892 "summary": summary,
3893 "stdout_summary": stdout_summary,
3894 "stderr_summary": stderr_summary,
3895 "command": delta.command,
3896 "stream_delta": true,
3897 });
3898 attach_shell_owner_metadata(&mut metadata, context);
3899 attach_cargo_failure_summary(&mut metadata, &delta.command, &result);
3900 attach_python_build_dependency_hint(&mut metadata, python_dependency_hint);
3901
3902 let mut tool_result = ToolResult {
3903 content: output,
3904 success: matches!(result.status, ShellStatus::Completed | ShellStatus::Running),
3905 metadata: Some(metadata),
3906 };
3907 if let Some(hint) = network_restricted_hint
3908 && let Some(metadata) = tool_result.metadata.as_mut()
3909 && let Some(object) = metadata.as_object_mut()
3910 {
3911 object.insert("sandbox_network_restricted".to_string(), json!(true));
3912 object.insert("sandbox_network_denied_hint".to_string(), json!(hint));
3913 }
3914 if let Some(hint) = sandbox_denied_hint
3915 && let Some(metadata) = tool_result.metadata.as_mut()
3916 && let Some(object) = metadata.as_object_mut()
3917 {
3918 object.insert("sandbox_denied_hint".to_string(), json!(hint));
3919 }
3920 if provenance_hint.is_some()
3921 && let Some(metadata) = tool_result.metadata.as_mut()
3922 && let Some(object) = metadata.as_object_mut()
3923 {
3924 object.insert("macos_provenance_restricted".to_string(), json!(true));
3925 }
3926 tool_result
3927 }
3928
3929 /// Human-readable elapsed time for a shell task ("450 ms", "12.3 s", "2m5s").
3930 fn format_elapsed_ms(ms: u64) -> String {
3931 if ms < 1_000 {
3932 format!("{ms} ms")
3933 } else if ms < 60_000 {
3934 let secs = ms as f64 / 1_000.0;
3935 format!("{secs} s")
3936 } else {
3937 let total_secs = ms / 1_000;
3938 format!("{}m{}s", total_secs / 60, total_secs % 60)
3939 }
3940 }
3941
3942 /// One-line status + elapsed summary for wait/delta results, placed at the top
3943 /// of the visible content so the model can judge how long it actually waited.
3944 fn wait_timing_line(result: &ShellResult) -> String {
3945 let status_phrase = match result.status {
3946 ShellStatus::Running => "still running",
3947 ShellStatus::Completed => "completed",
3948 ShellStatus::Failed => "failed",
3949 ShellStatus::Killed => "killed",
3950 ShellStatus::TimedOut => "timed out",
3951 };
3952 let elapsed = format_elapsed_ms(result.duration_ms);
3953 match result.task_id.as_deref() {
3954 Some(task_id) => format!("Task {task_id} {status_phrase} after {elapsed}."),
3955 None => format!("Task {status_phrase} after {elapsed}."),
3956 }
3957 }
3958
3959 async fn wait_for_shell_delta_cancellable(
3960 context: &ToolContext,
3961 task_id: &str,
3962 timeout_ms: u64,
3963 ) -> Result<(ShellDeltaResult, bool), ToolError> {
3964 let timeout_ms = timeout_ms.clamp(1000, EXEC_SHELL_WAIT_MAX_TIMEOUT_MS);
3965 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
3966 let mut stdout_accum = String::new();
3967 let mut stderr_accum = String::new();
3968
3969 let (command, result, stdout_total_len, stderr_total_len) = loop {
3970 if context
3971 .cancel_token
3972 .as_ref()
3973 .is_some_and(|token| token.is_cancelled())
3974 {
3975 let mut manager = context
3976 .shell_manager
3977 .lock()
3978 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
3979 let delta = manager
3980 .get_output_delta(task_id, false, 0)
3981 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
3982 append_shell_delta_output(&mut stdout_accum, &mut stderr_accum, &delta.result);
3983 return Ok((
3984 shell_delta_with_accumulated_output(
3985 delta.command,
3986 delta.result,
3987 &stdout_accum,
3988 &stderr_accum,
3989 delta.stdout_total_len,
3990 delta.stderr_total_len,
3991 ),
3992 true,
3993 ));
3994 }
3995
3996 let delta = {
3997 let mut manager = context
3998 .shell_manager
3999 .lock()
4000 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
4001 manager
4002 .get_output_delta(task_id, false, 0)
4003 .map_err(|err| ToolError::execution_failed(err.to_string()))?
4004 };
4005
4006 let stdout_total_len = delta.stdout_total_len;
4007 let stderr_total_len = delta.stderr_total_len;
4008 let command = delta.command.clone();
4009 append_shell_delta_output(&mut stdout_accum, &mut stderr_accum, &delta.result);
4010
4011 let status = delta.result.status.clone();
4012 if status != ShellStatus::Running || Instant::now() >= deadline {
4013 break (command, delta.result, stdout_total_len, stderr_total_len);
4014 }
4015
4016 tokio::time::sleep(Duration::from_millis(100)).await;
4017 };
4018
4019 Ok((
4020 shell_delta_with_accumulated_output(
4021 command,
4022 result,
4023 &stdout_accum,
4024 &stderr_accum,
4025 stdout_total_len,
4026 stderr_total_len,
4027 ),
4028 false,
4029 ))
4030 }
4031
4032 fn append_shell_delta_output(
4033 stdout_accum: &mut String,
4034 stderr_accum: &mut String,
4035 result: &ShellResult,
4036 ) {
4037 if !result.stdout.is_empty() {
4038 stdout_accum.push_str(&result.stdout);
4039 }
4040 if !result.stderr.is_empty() {
4041 stderr_accum.push_str(&result.stderr);
4042 }
4043 }
4044
4045 fn shell_delta_with_accumulated_output(
4046 command: String,
4047 mut result: ShellResult,
4048 stdout_accum: &str,
4049 stderr_accum: &str,
4050 stdout_total_len: usize,
4051 stderr_total_len: usize,
4052 ) -> ShellDeltaResult {
4053 let (stdout, stdout_meta) = truncate_with_meta(stdout_accum);
4054 let (stderr, stderr_meta) = truncate_with_meta(stderr_accum);
4055 result.stdout = stdout;
4056 result.stderr = stderr;
4057 result.stdout_len = stdout_meta.original_len;
4058 result.stderr_len = stderr_meta.original_len;
4059 result.stdout_omitted = stdout_meta.omitted;
4060 result.stderr_omitted = stderr_meta.omitted;
4061 result.stdout_truncated = stdout_meta.truncated;
4062 result.stderr_truncated = stderr_meta.truncated;
4063
4064 ShellDeltaResult {
4065 command,
4066 result,
4067 stdout_total_len,
4068 stderr_total_len,
4069 }
4070 }
4071
4072 /// Tool for appending notes to a notes file.
4073 pub struct NoteTool;
4074
4075 #[async_trait]
4076 impl ToolSpec for NoteTool {
4077 fn name(&self) -> &'static str {
4078 "note"
4079 }
4080
4081 fn description(&self) -> &'static str {
4082 "Append a note to the agent notes file for persistent context across sessions."
4083 }
4084
4085 fn input_schema(&self) -> serde_json::Value {
4086 json!({
4087 "type": "object",
4088 "properties": {
4089 "content": {
4090 "type": "string",
4091 "description": "The note content to append"
4092 }
4093 },
4094 "required": ["content"]
4095 })
4096 }
4097
4098 fn capabilities(&self) -> Vec<ToolCapability> {
4099 vec![ToolCapability::WritesFiles]
4100 }
4101
4102 fn approval_requirement(&self) -> ApprovalRequirement {
4103 ApprovalRequirement::Auto // Notes are low-risk
4104 }
4105
4106 async fn execute(
4107 &self,
4108 input: serde_json::Value,
4109 context: &ToolContext,
4110 ) -> Result<ToolResult, ToolError> {
4111 let note_content = required_str(&input, "content")?;
4112
4113 // Ensure parent directory exists
4114 if let Some(parent) = context.notes_path.parent() {
4115 std::fs::create_dir_all(parent).map_err(|e| {
4116 ToolError::execution_failed(format!("Failed to create notes directory: {e}"))
4117 })?;
4118 }
4119
4120 // Append to notes file
4121 let mut file = std::fs::OpenOptions::new()
4122 .create(true)
4123 .append(true)
4124 .open(&context.notes_path)
4125 .map_err(|e| ToolError::execution_failed(format!("Failed to open notes file: {e}")))?;
4126
4127 writeln!(file, "\n---\n{note_content}")
4128 .map_err(|e| ToolError::execution_failed(format!("Failed to write note: {e}")))?;
4129
4130 Ok(ToolResult::success(format!(
4131 "Note appended to {}",
4132 context.notes_path.display()
4133 )))
4134 }
4135 }
4136
4137 #[cfg(test)]
4138 mod tests;
4139
4139 lines RUST