返回 DeepSeek-TUI-2026
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)
9 //! - Streaming output (future)
10
11 use anyhow::{Context, Result, anyhow};
12 use serde::{Deserialize, Serialize};
13 use std::collections::HashMap;
14 use std::io::{Read, Write};
15 use std::path::PathBuf;
16 use std::process::{Child, ChildStdin, Command, Stdio};
17 use std::sync::{Arc, Mutex};
18 use std::time::{Duration, Instant};
19 use uuid::Uuid;
20 use wait_timeout::ChildExt;
21
22 #[cfg(unix)]
23 use std::os::unix::process::CommandExt;
24
25 use portable_pty::{CommandBuilder, PtySize, native_pty_system};
26
27 use super::shell_output::{summarize_output, truncate_with_meta};
28 use crate::sandbox::{
29 CommandSpec,
30 ExecEnv,
31 SandboxManager,
32 SandboxPolicy as ExecutionSandboxPolicy, // Rename to avoid conflict with spec::SandboxPolicy
33 SandboxType,
34 };
35
36 /// Status of a shell process
37 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38 pub enum ShellStatus {
39 Running,
40 Completed,
41 Failed,
42 Killed,
43 TimedOut,
44 }
45
46 /// Result from a shell command execution
47 #[derive(Debug, Clone, Serialize, Deserialize)]
48 pub struct ShellResult {
49 pub task_id: Option<String>,
50 pub status: ShellStatus,
51 pub exit_code: Option<i32>,
52 pub stdout: String,
53 pub stderr: String,
54 pub duration_ms: u64,
55 /// Original stdout length in bytes.
56 #[serde(default)]
57 pub stdout_len: usize,
58 /// Original stderr length in bytes.
59 #[serde(default)]
60 pub stderr_len: usize,
61 /// Bytes omitted from stdout due to truncation.
62 #[serde(default)]
63 pub stdout_omitted: usize,
64 /// Bytes omitted from stderr due to truncation.
65 #[serde(default)]
66 pub stderr_omitted: usize,
67 /// Whether stdout was truncated.
68 #[serde(default)]
69 pub stdout_truncated: bool,
70 /// Whether stderr was truncated.
71 #[serde(default)]
72 pub stderr_truncated: bool,
73 /// Whether the command was executed in a sandbox.
74 #[serde(default)]
75 pub sandboxed: bool,
76 /// Type of sandbox used (if any).
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub sandbox_type: Option<String>,
79 /// Whether the command was blocked by sandbox restrictions.
80 #[serde(default)]
81 pub sandbox_denied: bool,
82 }
83
84 /// Compact, UI-oriented view of a tracked background shell job.
85 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86 pub struct ShellJobSnapshot {
87 pub id: String,
88 pub job_id: String,
89 pub command: String,
90 pub cwd: PathBuf,
91 pub status: ShellStatus,
92 pub exit_code: Option<i32>,
93 pub elapsed_ms: u64,
94 pub stdout_tail: String,
95 pub stderr_tail: String,
96 pub stdout_len: usize,
97 pub stderr_len: usize,
98 pub stdin_available: bool,
99 pub stale: bool,
100 pub linked_task_id: Option<String>,
101 }
102
103 /// Full output view used by `/jobs show <id>`.
104 #[derive(Debug, Clone, Serialize, Deserialize)]
105 pub struct ShellJobDetail {
106 pub snapshot: ShellJobSnapshot,
107 pub stdout: String,
108 pub stderr: String,
109 }
110
111 pub struct ShellDeltaResult {
112 pub result: ShellResult,
113 pub stdout_total_len: usize,
114 pub stderr_total_len: usize,
115 }
116
117 enum ShellChild {
118 Process(Child),
119 Pty(Box<dyn portable_pty::Child + Send>),
120 }
121
122 #[cfg(unix)]
123 fn kill_child_process_group(child: &mut Child) -> std::io::Result<()> {
124 let pgid = child.id() as libc::pid_t;
125 if pgid <= 0 {
126 return child.kill();
127 }
128
129 let result = unsafe { libc::kill(-pgid, libc::SIGKILL) };
130 if result == 0 {
131 Ok(())
132 } else {
133 let err = std::io::Error::last_os_error();
134 if err.raw_os_error() == Some(libc::ESRCH) {
135 Ok(())
136 } else {
137 child.kill()
138 }
139 }
140 }
141
142 /// Configure parent-death signaling so shell-spawned children are reaped when
143 /// the TUI dies abnormally (#421). On Linux this installs
144 /// `PR_SET_PDEATHSIG(SIGTERM)` via `pre_exec` — the kernel then sends SIGTERM
145 /// to the child the moment the parent process exits, even on SIGKILL of the
146 /// TUI. The cancellation path already SIGKILLs the whole process group, so
147 /// this only fires when the parent dies without running its drop / cleanup
148 /// code (panic during shutdown, OOM, hardware crash, etc.).
149 ///
150 /// On macOS / Windows there's no kernel equivalent. The existing graceful
151 /// path (`kill_child_process_group` from the cancellation token) still
152 /// handles normal shutdown; abnormal exit can leak children — tracked as a
153 /// follow-up watchdog item per the original issue's acceptance criteria.
154 #[cfg(target_os = "linux")]
155 fn install_parent_death_signal(cmd: &mut Command) {
156 use std::os::unix::process::CommandExt;
157 // SAFETY: `pre_exec` runs in the child between fork and exec. The closure
158 // only calls `libc::prctl` with stack-allocated constant arguments and
159 // does not touch heap memory or the parent's locks. Both requirements
160 // (async-signal-safe + no allocation in the post-fork window) are met.
161 unsafe {
162 cmd.pre_exec(|| {
163 let result = libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0);
164 if result == -1 {
165 // Surface the errno but do not abort the spawn — the child
166 // will simply lose the parent-death cleanup safety net.
167 Err(std::io::Error::last_os_error())
168 } else {
169 Ok(())
170 }
171 });
172 }
173 }
174
175 #[cfg(not(target_os = "linux"))]
176 fn install_parent_death_signal(_cmd: &mut Command) {
177 // No kernel-level equivalent on macOS / Windows. The cooperative
178 // cancellation + process_group SIGKILL path covers normal shutdown;
179 // abnormal exit (panic without unwind, SIGKILL of the TUI) can still
180 // leak children on those platforms — tracked as a follow-up.
181 }
182
183 #[derive(Clone, Copy, Debug)]
184 struct ShellExitStatus {
185 code: Option<i32>,
186 success: bool,
187 }
188
189 impl ShellExitStatus {
190 fn from_std(status: std::process::ExitStatus) -> Self {
191 Self {
192 code: status.code(),
193 success: status.success(),
194 }
195 }
196
197 fn from_pty(status: portable_pty::ExitStatus) -> Self {
198 let code = i32::try_from(status.exit_code()).unwrap_or(i32::MAX);
199 Self {
200 code: Some(code),
201 success: status.success(),
202 }
203 }
204 }
205
206 impl ShellChild {
207 fn try_wait(&mut self) -> std::io::Result<Option<ShellExitStatus>> {
208 match self {
209 ShellChild::Process(child) => child
210 .try_wait()
211 .map(|status| status.map(ShellExitStatus::from_std)),
212 ShellChild::Pty(child) => child
213 .try_wait()
214 .map(|status| status.map(ShellExitStatus::from_pty)),
215 }
216 }
217
218 fn wait(&mut self) -> std::io::Result<ShellExitStatus> {
219 match self {
220 ShellChild::Process(child) => child.wait().map(ShellExitStatus::from_std),
221 ShellChild::Pty(child) => child.wait().map(ShellExitStatus::from_pty),
222 }
223 }
224
225 fn kill(&mut self) -> std::io::Result<()> {
226 match self {
227 #[cfg(unix)]
228 ShellChild::Process(child) => kill_child_process_group(child),
229 #[cfg(not(unix))]
230 ShellChild::Process(child) => child.kill(),
231 ShellChild::Pty(child) => child.kill(),
232 }
233 }
234 }
235
236 enum StdinWriter {
237 Pipe(ChildStdin),
238 Pty(Box<dyn Write + Send>),
239 }
240
241 impl StdinWriter {
242 fn write_all(&mut self, data: &[u8]) -> std::io::Result<()> {
243 match self {
244 StdinWriter::Pipe(stdin) => stdin.write_all(data),
245 StdinWriter::Pty(writer) => writer.write_all(data),
246 }
247 }
248
249 fn flush(&mut self) -> std::io::Result<()> {
250 match self {
251 StdinWriter::Pipe(stdin) => stdin.flush(),
252 StdinWriter::Pty(writer) => writer.flush(),
253 }
254 }
255 }
256
257 fn spawn_reader_thread<R: Read + Send + 'static>(
258 mut reader: R,
259 buffer: Arc<Mutex<Vec<u8>>>,
260 ) -> std::thread::JoinHandle<()> {
261 std::thread::spawn(move || {
262 let mut chunk = [0u8; 4096];
263 loop {
264 match reader.read(&mut chunk) {
265 Ok(0) => break,
266 Ok(n) => {
267 if let Ok(mut guard) = buffer.lock() {
268 guard.extend_from_slice(&chunk[..n]);
269 }
270 }
271 Err(_) => break,
272 }
273 }
274 })
275 }
276
277 /// A background shell process being tracked
278 pub struct BackgroundShell {
279 pub id: String,
280 pub command: String,
281 pub working_dir: PathBuf,
282 pub status: ShellStatus,
283 pub exit_code: Option<i32>,
284 pub started_at: Instant,
285 pub sandbox_type: SandboxType,
286 pub linked_task_id: Option<String>,
287 stdout_buffer: Arc<Mutex<Vec<u8>>>,
288 stderr_buffer: Option<Arc<Mutex<Vec<u8>>>>,
289 stdout_cursor: usize,
290 stderr_cursor: usize,
291 stdin: Option<StdinWriter>,
292 child: Option<ShellChild>,
293 stdout_thread: Option<std::thread::JoinHandle<()>>,
294 stderr_thread: Option<std::thread::JoinHandle<()>>,
295 }
296
297 impl BackgroundShell {
298 /// Check if the process has completed and update status
299 fn poll(&mut self) -> bool {
300 if self.status != ShellStatus::Running {
301 return true;
302 }
303
304 if let Some(ref mut child) = self.child {
305 match child.try_wait() {
306 Ok(Some(status)) => {
307 self.exit_code = status.code;
308 self.status = if status.success {
309 ShellStatus::Completed
310 } else {
311 ShellStatus::Failed
312 };
313 self.collect_output();
314 true
315 }
316 Ok(None) => false, // Still running
317 Err(_) => {
318 self.status = ShellStatus::Failed;
319 self.collect_output();
320 true
321 }
322 }
323 } else {
324 true
325 }
326 }
327
328 /// Collect output from the background threads
329 fn collect_output(&mut self) {
330 if let Some(handle) = self.stdout_thread.take() {
331 let _ = handle.join();
332 }
333 if let Some(handle) = self.stderr_thread.take() {
334 let _ = handle.join();
335 }
336 self.stdin = None;
337 self.child = None;
338 }
339
340 fn write_stdin(&mut self, input: &str, close: bool) -> Result<()> {
341 if let Some(stdin) = self.stdin.as_mut() {
342 if !input.is_empty() {
343 stdin
344 .write_all(input.as_bytes())
345 .context("Failed to write to stdin")?;
346 stdin.flush().ok();
347 }
348 if close {
349 self.stdin = None;
350 }
351 return Ok(());
352 }
353
354 if input.is_empty() && close {
355 return Ok(());
356 }
357
358 Err(anyhow!("stdin is not available for task {}", self.id))
359 }
360
361 fn full_output(&self) -> (String, String, usize, usize) {
362 let stdout_bytes = self
363 .stdout_buffer
364 .lock()
365 .map(|data| data.clone())
366 .unwrap_or_default();
367 let stderr_bytes = self
368 .stderr_buffer
369 .as_ref()
370 .and_then(|buffer| buffer.lock().ok().map(|data| data.clone()))
371 .unwrap_or_default();
372
373 let stdout_len = stdout_bytes.len();
374 let stderr_len = stderr_bytes.len();
375
376 (
377 String::from_utf8_lossy(&stdout_bytes).to_string(),
378 String::from_utf8_lossy(&stderr_bytes).to_string(),
379 stdout_len,
380 stderr_len,
381 )
382 }
383
384 fn take_delta(&mut self) -> (String, String, usize, usize, usize, usize) {
385 let (stdout_delta, stdout_total) =
386 take_delta_from_buffer(&self.stdout_buffer, &mut self.stdout_cursor);
387 let (stderr_delta, stderr_total) = if let Some(buffer) = self.stderr_buffer.as_ref() {
388 take_delta_from_buffer(buffer, &mut self.stderr_cursor)
389 } else {
390 (Vec::new(), 0)
391 };
392
393 let stdout_delta_len = stdout_delta.len();
394 let stderr_delta_len = stderr_delta.len();
395
396 (
397 String::from_utf8_lossy(&stdout_delta).to_string(),
398 String::from_utf8_lossy(&stderr_delta).to_string(),
399 stdout_delta_len,
400 stderr_delta_len,
401 stdout_total,
402 stderr_total,
403 )
404 }
405
406 fn sandbox_denied(&self) -> bool {
407 if matches!(self.status, ShellStatus::Running) {
408 return false;
409 }
410 let (_, stderr_full, _, _) = self.full_output();
411 SandboxManager::was_denied(
412 self.sandbox_type,
413 self.exit_code.unwrap_or(-1),
414 &stderr_full,
415 )
416 }
417
418 /// Kill the process
419 #[allow(dead_code)]
420 fn kill(&mut self) -> Result<()> {
421 if let Some(ref mut child) = self.child {
422 child.kill().context("Failed to kill process")?;
423 let _ = child.wait();
424 }
425 self.status = ShellStatus::Killed;
426 self.collect_output();
427 Ok(())
428 }
429
430 /// Get a snapshot of the current state
431 #[allow(dead_code)]
432 pub fn snapshot(&self) -> ShellResult {
433 let sandboxed = !matches!(self.sandbox_type, SandboxType::None);
434 let (stdout_full, stderr_full, _, _) = self.full_output();
435 let (stdout, stdout_meta) = truncate_with_meta(&stdout_full);
436 let (stderr, stderr_meta) = truncate_with_meta(&stderr_full);
437 ShellResult {
438 task_id: Some(self.id.clone()),
439 status: self.status.clone(),
440 exit_code: self.exit_code,
441 stdout,
442 stderr,
443 duration_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
444 stdout_len: stdout_meta.original_len,
445 stderr_len: stderr_meta.original_len,
446 stdout_omitted: stdout_meta.omitted,
447 stderr_omitted: stderr_meta.omitted,
448 stdout_truncated: stdout_meta.truncated,
449 stderr_truncated: stderr_meta.truncated,
450 sandboxed,
451 sandbox_type: if sandboxed {
452 Some(self.sandbox_type.to_string())
453 } else {
454 None
455 },
456 sandbox_denied: self.sandbox_denied(),
457 }
458 }
459
460 fn job_snapshot(&self) -> ShellJobSnapshot {
461 let (stdout_full, stderr_full, stdout_len, stderr_len) = self.full_output();
462 ShellJobSnapshot {
463 id: self.id.clone(),
464 job_id: self.id.clone(),
465 command: self.command.clone(),
466 cwd: self.working_dir.clone(),
467 status: self.status.clone(),
468 exit_code: self.exit_code,
469 elapsed_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
470 stdout_tail: tail_text(&stdout_full, 1200),
471 stderr_tail: tail_text(&stderr_full, 1200),
472 stdout_len,
473 stderr_len,
474 stdin_available: self.stdin.is_some() && self.status == ShellStatus::Running,
475 stale: false,
476 linked_task_id: self.linked_task_id.clone(),
477 }
478 }
479
480 fn job_detail(&self) -> ShellJobDetail {
481 let (stdout, stderr, _, _) = self.full_output();
482 ShellJobDetail {
483 snapshot: self.job_snapshot(),
484 stdout,
485 stderr,
486 }
487 }
488 }
489
490 impl Drop for BackgroundShell {
491 fn drop(&mut self) {
492 if self.status == ShellStatus::Running
493 && let Some(ref mut child) = self.child
494 {
495 let _ = child.kill();
496 let _ = child.wait();
497 }
498 }
499 }
500
501 /// Manages background shell processes with optional sandboxing.
502 pub struct ShellManager {
503 processes: HashMap<String, BackgroundShell>,
504 stale_jobs: HashMap<String, ShellJobSnapshot>,
505 default_workspace: PathBuf,
506 sandbox_manager: SandboxManager,
507 sandbox_policy: ExecutionSandboxPolicy,
508 foreground_background_requested: bool,
509 }
510
511 impl std::fmt::Debug for ShellManager {
512 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513 f.debug_struct("ShellManager")
514 .field("processes", &self.processes.len())
515 .field("stale_jobs", &self.stale_jobs.len())
516 .field("default_workspace", &self.default_workspace)
517 .field("sandbox_policy", &self.sandbox_policy)
518 .field(
519 "foreground_background_requested",
520 &self.foreground_background_requested,
521 )
522 .finish()
523 }
524 }
525
526 impl ShellManager {
527 /// Create a new `ShellManager` with default (no sandbox) policy.
528 pub fn new(workspace: PathBuf) -> Self {
529 Self {
530 processes: HashMap::new(),
531 stale_jobs: HashMap::new(),
532 default_workspace: workspace,
533 sandbox_manager: SandboxManager::new(),
534 sandbox_policy: ExecutionSandboxPolicy::default(),
535 foreground_background_requested: false,
536 }
537 }
538
539 /// Create a new `ShellManager` with a specific sandbox policy.
540 #[allow(dead_code)]
541 pub fn with_sandbox(workspace: PathBuf, policy: ExecutionSandboxPolicy) -> Self {
542 Self {
543 processes: HashMap::new(),
544 stale_jobs: HashMap::new(),
545 default_workspace: workspace,
546 sandbox_manager: SandboxManager::new(),
547 sandbox_policy: policy,
548 foreground_background_requested: false,
549 }
550 }
551
552 /// Set the sandbox policy for future commands.
553 #[allow(dead_code)]
554 pub fn set_sandbox_policy(&mut self, policy: ExecutionSandboxPolicy) {
555 self.sandbox_policy = policy;
556 }
557
558 /// Get the current sandbox policy.
559 #[allow(dead_code)]
560 pub fn sandbox_policy(&self) -> &ExecutionSandboxPolicy {
561 &self.sandbox_policy
562 }
563
564 /// Request that the active foreground shell wait detach and leave its
565 /// process running in the background job table.
566 pub fn request_foreground_background(&mut self) {
567 self.foreground_background_requested = true;
568 }
569
570 fn clear_foreground_background_request(&mut self) {
571 self.foreground_background_requested = false;
572 }
573
574 fn take_foreground_background_request(&mut self) -> bool {
575 let requested = self.foreground_background_requested;
576 self.foreground_background_requested = false;
577 requested
578 }
579
580 /// Check if sandboxing is available on this platform.
581 #[allow(dead_code)]
582 pub fn is_sandbox_available(&mut self) -> bool {
583 self.sandbox_manager.is_available()
584 }
585
586 /// Execute a shell command with the configured sandbox policy.
587 #[allow(dead_code)]
588 pub fn execute(
589 &mut self,
590 command: &str,
591 working_dir: Option<&str>,
592 timeout_ms: u64,
593 background: bool,
594 ) -> Result<ShellResult> {
595 self.execute_with_policy(command, working_dir, timeout_ms, background, None)
596 }
597
598 /// Execute a shell command with a specific sandbox policy (overrides default).
599 #[allow(dead_code)]
600 pub fn execute_with_policy(
601 &mut self,
602 command: &str,
603 working_dir: Option<&str>,
604 timeout_ms: u64,
605 background: bool,
606 policy_override: Option<ExecutionSandboxPolicy>,
607 ) -> Result<ShellResult> {
608 self.execute_with_options(
609 command,
610 working_dir,
611 timeout_ms,
612 background,
613 None,
614 false,
615 policy_override,
616 )
617 }
618
619 /// Execute a shell command with stdin/TTY options.
620 #[allow(clippy::too_many_arguments)]
621 pub fn execute_with_options(
622 &mut self,
623 command: &str,
624 working_dir: Option<&str>,
625 timeout_ms: u64,
626 background: bool,
627 stdin_data: Option<&str>,
628 tty: bool,
629 policy_override: Option<ExecutionSandboxPolicy>,
630 ) -> Result<ShellResult> {
631 self.execute_with_options_env(
632 command,
633 working_dir,
634 timeout_ms,
635 background,
636 stdin_data,
637 tty,
638 policy_override,
639 HashMap::new(),
640 )
641 }
642
643 /// Same as `execute_with_options`, plus an extra env-var map that is
644 /// merged into the spawned process environment. Used by the `shell_env`
645 /// hook injection path (#456); other callers should use the simpler
646 /// wrapper above.
647 #[allow(clippy::too_many_arguments)]
648 pub fn execute_with_options_env(
649 &mut self,
650 command: &str,
651 working_dir: Option<&str>,
652 timeout_ms: u64,
653 background: bool,
654 stdin_data: Option<&str>,
655 tty: bool,
656 policy_override: Option<ExecutionSandboxPolicy>,
657 extra_env: HashMap<String, String>,
658 ) -> Result<ShellResult> {
659 let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from);
660
661 // Clamp timeout to max 10 minutes (600000ms)
662 let timeout_ms = timeout_ms.clamp(1000, 600_000);
663
664 // Use override policy if provided, otherwise use the manager's policy
665 let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone());
666
667 // Create command spec and prepare sandboxed environment
668 let spec = CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms))
669 .with_policy(policy)
670 .with_env(extra_env);
671 let exec_env = self.sandbox_manager.prepare(&spec);
672
673 if background {
674 self.spawn_background_sandboxed(command, &work_dir, &exec_env, stdin_data, tty)
675 } else {
676 if tty {
677 return Err(anyhow!(
678 "TTY mode requires background execution (set background: true)."
679 ));
680 }
681 Self::execute_sync_sandboxed(command, &work_dir, timeout_ms, stdin_data, &exec_env)
682 }
683 }
684
685 /// Execute a shell command interactively (stdin/stdout/stderr inherit from terminal).
686 #[allow(dead_code)]
687 pub fn execute_interactive(
688 &mut self,
689 command: &str,
690 working_dir: Option<&str>,
691 timeout_ms: u64,
692 ) -> Result<ShellResult> {
693 self.execute_interactive_with_policy(command, working_dir, timeout_ms, None)
694 }
695
696 /// Execute a shell command interactively with a specific sandbox policy override.
697 pub fn execute_interactive_with_policy(
698 &mut self,
699 command: &str,
700 working_dir: Option<&str>,
701 timeout_ms: u64,
702 policy_override: Option<ExecutionSandboxPolicy>,
703 ) -> Result<ShellResult> {
704 self.execute_interactive_with_policy_env(
705 command,
706 working_dir,
707 timeout_ms,
708 policy_override,
709 HashMap::new(),
710 )
711 }
712
713 /// Interactive variant that accepts extra env vars (#456 shell_env hook).
714 pub fn execute_interactive_with_policy_env(
715 &mut self,
716 command: &str,
717 working_dir: Option<&str>,
718 timeout_ms: u64,
719 policy_override: Option<ExecutionSandboxPolicy>,
720 extra_env: HashMap<String, String>,
721 ) -> Result<ShellResult> {
722 let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from);
723
724 let timeout_ms = timeout_ms.clamp(1000, 600_000);
725 let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone());
726
727 let spec = CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms))
728 .with_policy(policy)
729 .with_env(extra_env);
730 let exec_env = self.sandbox_manager.prepare(&spec);
731
732 Self::execute_interactive_sandboxed(command, &work_dir, timeout_ms, &exec_env)
733 }
734
735 /// Execute command synchronously with timeout (sandboxed).
736 fn execute_sync_sandboxed(
737 original_command: &str,
738 working_dir: &std::path::Path,
739 timeout_ms: u64,
740 stdin_data: Option<&str>,
741 exec_env: &ExecEnv,
742 ) -> Result<ShellResult> {
743 let started = Instant::now();
744 let timeout = Duration::from_millis(timeout_ms);
745 let sandbox_type = exec_env.sandbox_type;
746 let sandboxed = exec_env.is_sandboxed();
747
748 // Build the command from ExecEnv
749 let program = exec_env.program();
750 let args = exec_env.args();
751
752 let mut cmd = Command::new(program);
753 cmd.args(args)
754 .current_dir(working_dir)
755 .stdout(Stdio::piped())
756 .stderr(Stdio::piped());
757 #[cfg(unix)]
758 {
759 cmd.process_group(0);
760 }
761 install_parent_death_signal(&mut cmd);
762
763 if stdin_data.is_some() {
764 cmd.stdin(Stdio::piped());
765 }
766
767 // Set environment variables from exec_env
768 for (key, value) in &exec_env.env {
769 cmd.env(key, value);
770 }
771
772 let mut child = cmd
773 .spawn()
774 .with_context(|| format!("Failed to execute: {original_command}"))?;
775
776 if let Some(input) = stdin_data
777 && let Some(mut stdin) = child.stdin.take()
778 {
779 stdin
780 .write_all(input.as_bytes())
781 .context("Failed to write to stdin")?;
782 stdin.flush().ok();
783 }
784
785 let stdout_handle = child.stdout.take().context("Failed to capture stdout")?;
786 let stderr_handle = child.stderr.take().context("Failed to capture stderr")?;
787
788 // Spawn threads to read output
789 let stdout_thread = std::thread::spawn(move || {
790 let mut reader = stdout_handle;
791 let mut buf = Vec::new();
792 let _ = reader.read_to_end(&mut buf);
793 buf
794 });
795
796 let stderr_thread = std::thread::spawn(move || {
797 let mut reader = stderr_handle;
798 let mut buf = Vec::new();
799 let _ = reader.read_to_end(&mut buf);
800 buf
801 });
802
803 // Wait with timeout
804 if let Some(status) = child.wait_timeout(timeout)? {
805 let stdout = stdout_thread.join().unwrap_or_default();
806 let stderr = stderr_thread.join().unwrap_or_default();
807 let stdout_str = String::from_utf8_lossy(&stdout).to_string();
808 let stderr_str = String::from_utf8_lossy(&stderr).to_string();
809 let exit_code = status.code().unwrap_or(-1);
810
811 // Check if sandbox denied the operation
812 let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
813 let (stdout, stdout_meta) = truncate_with_meta(&stdout_str);
814 let (stderr, stderr_meta) = truncate_with_meta(&stderr_str);
815
816 Ok(ShellResult {
817 task_id: None,
818 status: if status.success() {
819 ShellStatus::Completed
820 } else {
821 ShellStatus::Failed
822 },
823 exit_code: status.code(),
824 stdout,
825 stderr,
826 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
827 stdout_len: stdout_meta.original_len,
828 stderr_len: stderr_meta.original_len,
829 stdout_omitted: stdout_meta.omitted,
830 stderr_omitted: stderr_meta.omitted,
831 stdout_truncated: stdout_meta.truncated,
832 stderr_truncated: stderr_meta.truncated,
833 sandboxed,
834 sandbox_type: if sandboxed {
835 Some(sandbox_type.to_string())
836 } else {
837 None
838 },
839 sandbox_denied,
840 })
841 } else {
842 // Timeout - kill the process
843 #[cfg(unix)]
844 let _ = kill_child_process_group(&mut child);
845 #[cfg(not(unix))]
846 let _ = child.kill();
847 let status = child.wait().ok();
848 let stdout = stdout_thread.join().unwrap_or_default();
849 let stderr = stderr_thread.join().unwrap_or_default();
850 let stdout_str = String::from_utf8_lossy(&stdout).to_string();
851 let stderr_str = String::from_utf8_lossy(&stderr).to_string();
852 let (stdout, stdout_meta) = truncate_with_meta(&stdout_str);
853 let (stderr, stderr_meta) = truncate_with_meta(&stderr_str);
854
855 Ok(ShellResult {
856 task_id: None,
857 status: ShellStatus::TimedOut,
858 exit_code: status.and_then(|s| s.code()),
859 stdout,
860 stderr,
861 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
862 stdout_len: stdout_meta.original_len,
863 stderr_len: stderr_meta.original_len,
864 stdout_omitted: stdout_meta.omitted,
865 stderr_omitted: stderr_meta.omitted,
866 stdout_truncated: stdout_meta.truncated,
867 stderr_truncated: stderr_meta.truncated,
868 sandboxed,
869 sandbox_type: if sandboxed {
870 Some(sandbox_type.to_string())
871 } else {
872 None
873 },
874 sandbox_denied: false,
875 })
876 }
877 }
878
879 /// Execute command interactively with timeout (sandboxed).
880 fn execute_interactive_sandboxed(
881 original_command: &str,
882 working_dir: &std::path::Path,
883 timeout_ms: u64,
884 exec_env: &ExecEnv,
885 ) -> Result<ShellResult> {
886 let started = Instant::now();
887 let timeout = Duration::from_millis(timeout_ms);
888 let sandbox_type = exec_env.sandbox_type;
889 let sandboxed = exec_env.is_sandboxed();
890
891 let program = exec_env.program();
892 let args = exec_env.args();
893
894 let mut cmd = Command::new(program);
895 cmd.args(args)
896 .current_dir(working_dir)
897 .stdin(Stdio::inherit())
898 .stdout(Stdio::inherit())
899 .stderr(Stdio::inherit());
900 #[cfg(unix)]
901 {
902 cmd.process_group(0);
903 }
904 install_parent_death_signal(&mut cmd);
905
906 for (key, value) in &exec_env.env {
907 cmd.env(key, value);
908 }
909
910 let mut child = cmd
911 .spawn()
912 .with_context(|| format!("Failed to execute: {original_command}"))?;
913
914 if let Some(status) = child.wait_timeout(timeout)? {
915 Ok(ShellResult {
916 task_id: None,
917 status: if status.success() {
918 ShellStatus::Completed
919 } else {
920 ShellStatus::Failed
921 },
922 exit_code: status.code(),
923 stdout: String::new(),
924 stderr: String::new(),
925 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
926 stdout_len: 0,
927 stderr_len: 0,
928 stdout_omitted: 0,
929 stderr_omitted: 0,
930 stdout_truncated: false,
931 stderr_truncated: false,
932 sandboxed,
933 sandbox_type: if sandboxed {
934 Some(sandbox_type.to_string())
935 } else {
936 None
937 },
938 sandbox_denied: false,
939 })
940 } else {
941 #[cfg(unix)]
942 let _ = kill_child_process_group(&mut child);
943 #[cfg(not(unix))]
944 let _ = child.kill();
945 let status = child.wait().ok();
946
947 Ok(ShellResult {
948 task_id: None,
949 status: ShellStatus::TimedOut,
950 exit_code: status.and_then(|s| s.code()),
951 stdout: String::new(),
952 stderr: String::new(),
953 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
954 stdout_len: 0,
955 stderr_len: 0,
956 stdout_omitted: 0,
957 stderr_omitted: 0,
958 stdout_truncated: false,
959 stderr_truncated: false,
960 sandboxed,
961 sandbox_type: if sandboxed {
962 Some(sandbox_type.to_string())
963 } else {
964 None
965 },
966 sandbox_denied: false,
967 })
968 }
969 }
970
971 /// Spawn a background process (sandboxed).
972 fn spawn_background_sandboxed(
973 &mut self,
974 original_command: &str,
975 working_dir: &std::path::Path,
976 exec_env: &ExecEnv,
977 stdin_data: Option<&str>,
978 tty: bool,
979 ) -> Result<ShellResult> {
980 let task_id = format!("shell_{}", &Uuid::new_v4().to_string()[..8]);
981 let started = Instant::now();
982 let sandbox_type = exec_env.sandbox_type;
983 let sandboxed = exec_env.is_sandboxed();
984
985 // Build the command from ExecEnv
986 let program = exec_env.program();
987 let args = exec_env.args();
988
989 let stdout_buffer = Arc::new(Mutex::new(Vec::new()));
990 let stderr_buffer = if tty {
991 None
992 } else {
993 Some(Arc::new(Mutex::new(Vec::new())))
994 };
995
996 let (child, stdin, stdout_thread, stderr_thread) = if tty {
997 let pty_system = native_pty_system();
998 let pair = pty_system
999 .openpty(PtySize {
1000 rows: 24,
1001 cols: 80,
1002 pixel_width: 0,
1003 pixel_height: 0,
1004 })
1005 .context("Failed to open PTY")?;
1006
1007 let mut cmd = CommandBuilder::new(program);
1008 for arg in args {
1009 cmd.arg(arg);
1010 }
1011 cmd.cwd(working_dir);
1012 for (key, value) in &exec_env.env {
1013 cmd.env(key, value);
1014 }
1015
1016 let child = pair
1017 .slave
1018 .spawn_command(cmd)
1019 .with_context(|| format!("Failed to spawn PTY command: {original_command}"))?;
1020 drop(pair.slave);
1021
1022 let reader = pair
1023 .master
1024 .try_clone_reader()
1025 .context("Failed to clone PTY reader")?;
1026 let stdout_thread = Some(spawn_reader_thread(reader, Arc::clone(&stdout_buffer)));
1027 let writer = pair
1028 .master
1029 .take_writer()
1030 .context("Failed to take PTY writer")?;
1031
1032 (
1033 ShellChild::Pty(child),
1034 Some(StdinWriter::Pty(writer)),
1035 stdout_thread,
1036 None,
1037 )
1038 } else {
1039 let mut cmd = Command::new(program);
1040 cmd.args(args)
1041 .current_dir(working_dir)
1042 .stdin(Stdio::piped())
1043 .stdout(Stdio::piped())
1044 .stderr(Stdio::piped());
1045 #[cfg(unix)]
1046 {
1047 cmd.process_group(0);
1048 }
1049
1050 for (key, value) in &exec_env.env {
1051 cmd.env(key, value);
1052 }
1053
1054 let mut child = cmd
1055 .spawn()
1056 .with_context(|| format!("Failed to spawn background: {original_command}"))?;
1057
1058 let stdout_handle = child.stdout.take().context("Failed to capture stdout")?;
1059 let stderr_handle = child.stderr.take().context("Failed to capture stderr")?;
1060 let stdin_handle = child.stdin.take().map(StdinWriter::Pipe);
1061
1062 let stdout_thread = Some(spawn_reader_thread(
1063 stdout_handle,
1064 Arc::clone(&stdout_buffer),
1065 ));
1066 let stderr_thread = stderr_buffer
1067 .as_ref()
1068 .map(|buffer| spawn_reader_thread(stderr_handle, Arc::clone(buffer)));
1069
1070 (
1071 ShellChild::Process(child),
1072 stdin_handle,
1073 stdout_thread,
1074 stderr_thread,
1075 )
1076 };
1077
1078 let mut bg_shell = BackgroundShell {
1079 id: task_id.clone(),
1080 command: original_command.to_string(),
1081 working_dir: working_dir.to_path_buf(),
1082 status: ShellStatus::Running,
1083 exit_code: None,
1084 started_at: started,
1085 sandbox_type,
1086 linked_task_id: None,
1087 stdout_buffer,
1088 stderr_buffer,
1089 stdout_cursor: 0,
1090 stderr_cursor: 0,
1091 stdin,
1092 child: Some(child),
1093 stdout_thread,
1094 stderr_thread,
1095 };
1096
1097 if let Some(input) = stdin_data {
1098 bg_shell.write_stdin(input, false)?;
1099 }
1100
1101 self.processes.insert(task_id.clone(), bg_shell);
1102
1103 Ok(ShellResult {
1104 task_id: Some(task_id),
1105 status: ShellStatus::Running,
1106 exit_code: None,
1107 stdout: String::new(),
1108 stderr: String::new(),
1109 duration_ms: 0,
1110 stdout_len: 0,
1111 stderr_len: 0,
1112 stdout_omitted: 0,
1113 stderr_omitted: 0,
1114 stdout_truncated: false,
1115 stderr_truncated: false,
1116 sandboxed,
1117 sandbox_type: if sandboxed {
1118 Some(sandbox_type.to_string())
1119 } else {
1120 None
1121 },
1122 sandbox_denied: false,
1123 })
1124 }
1125
1126 /// Get output from a background process
1127 #[allow(dead_code)]
1128 pub fn get_output(
1129 &mut self,
1130 task_id: &str,
1131 block: bool,
1132 timeout_ms: u64,
1133 ) -> Result<ShellResult> {
1134 let shell = self
1135 .processes
1136 .get_mut(task_id)
1137 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
1138
1139 if block && shell.status == ShellStatus::Running {
1140 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
1141 let deadline = Instant::now() + timeout;
1142
1143 while shell.status == ShellStatus::Running && Instant::now() < deadline {
1144 if shell.poll() {
1145 break;
1146 }
1147 std::thread::sleep(Duration::from_millis(100));
1148 }
1149
1150 // If still running after timeout
1151 if shell.status == ShellStatus::Running {
1152 return Ok(shell.snapshot());
1153 }
1154 } else {
1155 shell.poll();
1156 }
1157
1158 Ok(shell.snapshot())
1159 }
1160
1161 /// Write data to stdin of a background process.
1162 pub fn write_stdin(&mut self, task_id: &str, input: &str, close: bool) -> Result<()> {
1163 let shell = self
1164 .processes
1165 .get_mut(task_id)
1166 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
1167 shell.write_stdin(input, close)?;
1168 Ok(())
1169 }
1170
1171 /// Get incremental output from a background process, consuming any new output.
1172 fn get_output_delta(
1173 &mut self,
1174 task_id: &str,
1175 wait: bool,
1176 timeout_ms: u64,
1177 ) -> Result<ShellDeltaResult> {
1178 let shell = self
1179 .processes
1180 .get_mut(task_id)
1181 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
1182
1183 if wait && shell.status == ShellStatus::Running {
1184 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
1185 let deadline = Instant::now() + timeout;
1186
1187 while shell.status == ShellStatus::Running && Instant::now() < deadline {
1188 if shell.poll() {
1189 break;
1190 }
1191 std::thread::sleep(Duration::from_millis(100));
1192 }
1193 } else {
1194 shell.poll();
1195 }
1196
1197 let (
1198 stdout_delta,
1199 stderr_delta,
1200 stdout_delta_len,
1201 stderr_delta_len,
1202 stdout_total,
1203 stderr_total,
1204 ) = shell.take_delta();
1205 let (stdout, stdout_meta) = truncate_with_meta(&stdout_delta);
1206 let (stderr, stderr_meta) = truncate_with_meta(&stderr_delta);
1207 let sandboxed = !matches!(shell.sandbox_type, SandboxType::None);
1208
1209 let result = ShellResult {
1210 task_id: Some(shell.id.clone()),
1211 status: shell.status.clone(),
1212 exit_code: shell.exit_code,
1213 stdout,
1214 stderr,
1215 duration_ms: u64::try_from(shell.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
1216 stdout_len: stdout_meta.original_len.max(stdout_delta_len),
1217 stderr_len: stderr_meta.original_len.max(stderr_delta_len),
1218 stdout_omitted: stdout_meta.omitted,
1219 stderr_omitted: stderr_meta.omitted,
1220 stdout_truncated: stdout_meta.truncated,
1221 stderr_truncated: stderr_meta.truncated,
1222 sandboxed,
1223 sandbox_type: if sandboxed {
1224 Some(shell.sandbox_type.to_string())
1225 } else {
1226 None
1227 },
1228 sandbox_denied: shell.sandbox_denied(),
1229 };
1230
1231 Ok(ShellDeltaResult {
1232 result,
1233 stdout_total_len: stdout_total,
1234 stderr_total_len: stderr_total,
1235 })
1236 }
1237
1238 /// Kill a running background process
1239 #[allow(dead_code)]
1240 pub fn kill(&mut self, task_id: &str) -> Result<ShellResult> {
1241 let shell = self
1242 .processes
1243 .get_mut(task_id)
1244 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
1245
1246 shell.kill()?;
1247 Ok(shell.snapshot())
1248 }
1249
1250 /// Kill every currently running background shell process.
1251 pub fn kill_running(&mut self) -> Result<Vec<ShellResult>> {
1252 let ids = self
1253 .processes
1254 .iter()
1255 .filter(|(_, shell)| shell.status == ShellStatus::Running)
1256 .map(|(id, _)| id.clone())
1257 .collect::<Vec<_>>();
1258
1259 let mut results = Vec::with_capacity(ids.len());
1260 for id in ids {
1261 results.push(self.kill(&id)?);
1262 }
1263 Ok(results)
1264 }
1265
1266 /// Poll a background process and return incremental output.
1267 pub fn poll_delta(
1268 &mut self,
1269 task_id: &str,
1270 wait: bool,
1271 timeout_ms: u64,
1272 ) -> Result<ShellDeltaResult> {
1273 self.get_output_delta(task_id, wait, timeout_ms)
1274 }
1275
1276 /// Attach durable task context to a live shell job.
1277 pub fn tag_linked_task(&mut self, task_id: &str, linked_task_id: Option<String>) -> Result<()> {
1278 let shell = self
1279 .processes
1280 .get_mut(task_id)
1281 .ok_or_else(|| anyhow!("Task {task_id} not found"))?;
1282 shell.linked_task_id = linked_task_id;
1283 Ok(())
1284 }
1285
1286 /// Inspect full output for a live or stale job.
1287 pub fn inspect_job(&mut self, task_id: &str) -> Result<ShellJobDetail> {
1288 if let Some(shell) = self.processes.get_mut(task_id) {
1289 shell.poll();
1290 return Ok(shell.job_detail());
1291 }
1292 if let Some(snapshot) = self.stale_jobs.get(task_id) {
1293 return Ok(ShellJobDetail {
1294 snapshot: snapshot.clone(),
1295 stdout: snapshot.stdout_tail.clone(),
1296 stderr: snapshot.stderr_tail.clone(),
1297 });
1298 }
1299 Err(anyhow!("Task {task_id} not found"))
1300 }
1301
1302 /// List all live and known-stale background shell jobs for the TUI.
1303 pub fn list_jobs(&mut self) -> Vec<ShellJobSnapshot> {
1304 for shell in self.processes.values_mut() {
1305 shell.poll();
1306 }
1307
1308 let mut jobs = self
1309 .processes
1310 .values()
1311 .map(BackgroundShell::job_snapshot)
1312 .collect::<Vec<_>>();
1313 jobs.extend(self.stale_jobs.values().cloned());
1314 jobs.sort_by(|a, b| {
1315 job_status_rank(&a.status, a.stale)
1316 .cmp(&job_status_rank(&b.status, b.stale))
1317 .then_with(|| a.id.cmp(&b.id))
1318 });
1319 jobs
1320 }
1321
1322 /// Remember a restart-stale job so the UI can show it instead of hiding it.
1323 #[allow(dead_code)]
1324 pub fn remember_stale_job(
1325 &mut self,
1326 id: impl Into<String>,
1327 command: impl Into<String>,
1328 cwd: PathBuf,
1329 linked_task_id: Option<String>,
1330 ) {
1331 let id = id.into();
1332 self.stale_jobs.insert(
1333 id.clone(),
1334 ShellJobSnapshot {
1335 id: id.clone(),
1336 job_id: id,
1337 command: command.into(),
1338 cwd,
1339 status: ShellStatus::Killed,
1340 exit_code: None,
1341 elapsed_ms: 0,
1342 stdout_tail: String::new(),
1343 stderr_tail: "Process is no longer attached to this TUI session.".to_string(),
1344 stdout_len: 0,
1345 stderr_len: 0,
1346 stdin_available: false,
1347 stale: true,
1348 linked_task_id,
1349 },
1350 );
1351 }
1352
1353 /// Clean up completed processes older than the given duration
1354 #[allow(dead_code)]
1355 pub fn cleanup(&mut self, max_age: Duration) {
1356 let _now = Instant::now();
1357 self.processes.retain(|_, shell| {
1358 if shell.status == ShellStatus::Running {
1359 true
1360 } else {
1361 shell.started_at.elapsed() < max_age
1362 }
1363 });
1364 }
1365 }
1366
1367 fn take_delta_from_buffer(buffer: &Arc<Mutex<Vec<u8>>>, cursor: &mut usize) -> (Vec<u8>, usize) {
1368 let data = buffer.lock().map(|d| d.clone()).unwrap_or_default();
1369 let start = (*cursor).min(data.len());
1370 let delta = data[start..].to_vec();
1371 *cursor = data.len();
1372 (delta, data.len())
1373 }
1374
1375 fn tail_text(text: &str, max_chars: usize) -> String {
1376 if text.chars().count() <= max_chars {
1377 return text.to_string();
1378 }
1379 let tail = text
1380 .chars()
1381 .rev()
1382 .take(max_chars)
1383 .collect::<Vec<_>>()
1384 .into_iter()
1385 .rev()
1386 .collect::<String>();
1387 format!("...{tail}")
1388 }
1389
1390 fn job_status_rank(status: &ShellStatus, stale: bool) -> u8 {
1391 if stale {
1392 return 4;
1393 }
1394 match status {
1395 ShellStatus::Running => 0,
1396 ShellStatus::Failed | ShellStatus::TimedOut => 1,
1397 ShellStatus::Killed => 2,
1398 ShellStatus::Completed => 3,
1399 }
1400 }
1401
1402 /// Thread-safe wrapper for `ShellManager`
1403 pub type SharedShellManager = Arc<Mutex<ShellManager>>;
1404
1405 /// Create a new shared shell manager with default sandbox policy.
1406 pub fn new_shared_shell_manager(workspace: PathBuf) -> SharedShellManager {
1407 Arc::new(Mutex::new(ShellManager::new(workspace)))
1408 }
1409
1410 // === ToolSpec Implementations ===
1411
1412 use crate::command_safety::{SafetyLevel, analyze_command};
1413 use crate::execpolicy::{ExecPolicyDecision, load_default_policy};
1414 use crate::features::Feature;
1415 use crate::tools::spec::{
1416 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
1417 optional_bool, optional_u64, required_str,
1418 };
1419 use async_trait::async_trait;
1420 use serde_json::json;
1421
1422 const FOREGROUND_TIMEOUT_RECOVERY_HINT: &str = "Foreground exec_shell is for bounded commands. \
1423 The timed-out process was killed; rerun long work with task_shell_start or exec_shell with \
1424 background: true, then poll with task_shell_wait or exec_shell_wait.";
1425
1426 async fn execute_foreground_via_background(
1427 context: &ToolContext,
1428 command: &str,
1429 timeout_ms: u64,
1430 stdin_data: Option<&str>,
1431 policy_override: Option<ExecutionSandboxPolicy>,
1432 extra_env: HashMap<String, String>,
1433 ) -> Result<ShellResult> {
1434 let timeout_ms = timeout_ms.clamp(1000, 600_000);
1435 let spawned = {
1436 let mut manager = context
1437 .shell_manager
1438 .lock()
1439 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
1440 manager.clear_foreground_background_request();
1441 manager.execute_with_options_env(
1442 command,
1443 None,
1444 timeout_ms,
1445 true,
1446 stdin_data,
1447 false,
1448 policy_override,
1449 extra_env,
1450 )?
1451 };
1452 let task_id = spawned
1453 .task_id
1454 .ok_or_else(|| anyhow!("foreground shell did not return a process id"))?;
1455
1456 if stdin_data.is_some() {
1457 let mut manager = context
1458 .shell_manager
1459 .lock()
1460 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
1461 manager.write_stdin(&task_id, "", true)?;
1462 }
1463
1464 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
1465 loop {
1466 if context
1467 .cancel_token
1468 .as_ref()
1469 .is_some_and(|token| token.is_cancelled())
1470 {
1471 let mut manager = context
1472 .shell_manager
1473 .lock()
1474 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
1475 return manager.kill(&task_id);
1476 }
1477
1478 let snapshot = {
1479 let mut manager = context
1480 .shell_manager
1481 .lock()
1482 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
1483 if manager.take_foreground_background_request() {
1484 return manager.get_output(&task_id, false, 0);
1485 }
1486 manager.get_output(&task_id, false, 0)?
1487 };
1488
1489 if snapshot.status != ShellStatus::Running {
1490 return Ok(snapshot);
1491 }
1492
1493 if Instant::now() >= deadline {
1494 let mut manager = context
1495 .shell_manager
1496 .lock()
1497 .map_err(|_| anyhow!("shell manager lock poisoned"))?;
1498 let mut result = manager.kill(&task_id)?;
1499 result.status = ShellStatus::TimedOut;
1500 return Ok(result);
1501 }
1502
1503 tokio::time::sleep(Duration::from_millis(100)).await;
1504 }
1505 }
1506
1507 /// Tool for executing shell commands.
1508 pub struct ExecShellTool;
1509
1510 #[async_trait]
1511 impl ToolSpec for ExecShellTool {
1512 fn name(&self) -> &'static str {
1513 "exec_shell"
1514 }
1515
1516 fn description(&self) -> &'static str {
1517 "Execute a shell command in the workspace directory. Foreground mode is for bounded commands; use background=true or task_shell_start for long-running work, then poll/wait."
1518 }
1519
1520 fn input_schema(&self) -> serde_json::Value {
1521 json!({
1522 "type": "object",
1523 "properties": {
1524 "command": {
1525 "type": "string",
1526 "description": "The shell command to execute"
1527 },
1528 "timeout_ms": {
1529 "type": "integer",
1530 "description": "Timeout in milliseconds (default: 120000, max: 600000)"
1531 },
1532 "background": {
1533 "type": "boolean",
1534 "description": "Run in background and return task_id (default: false). Prefer true for commands that may run for minutes; poll with exec_shell_wait or task_shell_wait."
1535 },
1536 "interactive": {
1537 "type": "boolean",
1538 "description": "Run interactively with terminal IO (default: false)"
1539 },
1540 "stdin": {
1541 "type": "string",
1542 "description": "Optional stdin data to send before waiting (non-interactive only)"
1543 },
1544 "cwd": {
1545 "type": "string",
1546 "description": "Optional working directory for the command"
1547 },
1548 "tty": {
1549 "type": "boolean",
1550 "description": "Allocate a pseudo-terminal for interactive programs (implies background)"
1551 }
1552 },
1553 "required": ["command"]
1554 })
1555 }
1556
1557 fn capabilities(&self) -> Vec<ToolCapability> {
1558 vec![
1559 ToolCapability::ExecutesCode,
1560 ToolCapability::Sandboxable,
1561 ToolCapability::RequiresApproval,
1562 ]
1563 }
1564
1565 fn approval_requirement(&self) -> ApprovalRequirement {
1566 ApprovalRequirement::Required
1567 }
1568
1569 async fn execute(
1570 &self,
1571 input: serde_json::Value,
1572 context: &ToolContext,
1573 ) -> Result<ToolResult, ToolError> {
1574 let command = required_str(&input, "command")?;
1575 let timeout_ms = optional_u64(&input, "timeout_ms", 120_000).min(600_000);
1576 let background = optional_bool(&input, "background", false);
1577 let interactive = optional_bool(&input, "interactive", false);
1578 let tty = optional_bool(&input, "tty", false);
1579 let stdin_data = input
1580 .get("stdin")
1581 .or_else(|| input.get("input"))
1582 .or_else(|| input.get("data"))
1583 .and_then(serde_json::Value::as_str)
1584 .map(str::to_string);
1585
1586 if interactive && background {
1587 return Ok(ToolResult::error(
1588 "Interactive commands cannot run in background mode.",
1589 ));
1590 }
1591 if interactive && tty {
1592 return Ok(ToolResult::error(
1593 "Interactive mode cannot be combined with TTY sessions.",
1594 ));
1595 }
1596 if interactive && stdin_data.is_some() {
1597 return Ok(ToolResult::error(
1598 "Interactive mode cannot be combined with stdin data.",
1599 ));
1600 }
1601
1602 let background = background || tty;
1603
1604 let mut execpolicy_decision: Option<ExecPolicyDecision> = None;
1605 if context.features.enabled(Feature::ExecPolicy)
1606 && let Some(policy) = load_default_policy()
1607 .map_err(|e| ToolError::execution_failed(format!("execpolicy load failed: {e}")))?
1608 {
1609 let decision = policy.evaluate(command);
1610 execpolicy_decision = Some(decision.clone());
1611 if let ExecPolicyDecision::Deny(reason) = decision {
1612 return Ok(ToolResult {
1613 content: format!("BLOCKED: {reason}"),
1614 success: false,
1615 metadata: Some(json!({
1616 "execpolicy": {
1617 "decision": "deny",
1618 "reason": reason,
1619 }
1620 })),
1621 });
1622 }
1623 }
1624
1625 // Safety analysis (always run for metadata, but only block when not in YOLO mode)
1626 let safety = analyze_command(command);
1627 if !context.auto_approve {
1628 match safety.level {
1629 SafetyLevel::Dangerous => {
1630 let reasons = safety.reasons.join("; ");
1631 let suggestions = if safety.suggestions.is_empty() {
1632 String::new()
1633 } else {
1634 format!("\nSuggestions: {}", safety.suggestions.join("; "))
1635 };
1636 return Ok(ToolResult {
1637 content: format!(
1638 "BLOCKED: This command was blocked for safety reasons.\n\nReasons: {reasons}{suggestions}"
1639 ),
1640 success: false,
1641 metadata: Some(json!({
1642 "safety_level": "dangerous",
1643 "blocked": true,
1644 "reasons": safety.reasons,
1645 "suggestions": safety.suggestions,
1646 })),
1647 });
1648 }
1649 SafetyLevel::RequiresApproval | SafetyLevel::Safe | SafetyLevel::WorkspaceSafe => {
1650 // Proceed normally
1651 }
1652 }
1653 }
1654
1655 let policy_override = context.elevated_sandbox_policy.clone();
1656 let working_dir = match input
1657 .get("cwd")
1658 .or_else(|| input.get("working_dir"))
1659 .and_then(serde_json::Value::as_str)
1660 {
1661 Some(dir) => {
1662 // Validate cwd against workspace boundary (same as file tools)
1663 let resolved = context.resolve_path(dir)?;
1664 Some(resolved.to_string_lossy().to_string())
1665 }
1666 None => None,
1667 };
1668
1669 // #456 — collect env from any configured `shell_env` hooks. Runs
1670 // synchronously, captures stdout, parses `KEY=VAL` lines, audit-logs
1671 // the keys (never the values). Empty / no-op when no hook is
1672 // configured.
1673 let extra_env = if let Some(hook_executor) = &context.runtime.hook_executor {
1674 let hook_ctx = crate::hooks::HookContext::new()
1675 .with_tool_name("exec_shell")
1676 .with_tool_args(&input);
1677 hook_executor.collect_shell_env(&hook_ctx)
1678 } else {
1679 std::collections::HashMap::new()
1680 };
1681
1682 // Route through external sandbox backend when configured.
1683 if let Some(backend) = &context.sandbox_backend {
1684 if interactive {
1685 return Ok(ToolResult::error(
1686 "Interactive mode is not supported with external sandbox backends.",
1687 ));
1688 }
1689 if background {
1690 return Ok(ToolResult::error(
1691 "Background mode is not supported with external sandbox backends.",
1692 ));
1693 }
1694 if tty {
1695 return Ok(ToolResult::error(
1696 "TTY mode is not supported with external sandbox backends.",
1697 ));
1698 }
1699
1700 let started = std::time::Instant::now();
1701 let backend_result = backend.exec(command, &extra_env).await;
1702
1703 let result = match backend_result {
1704 Ok(output) => {
1705 let (stdout, stdout_meta) = truncate_with_meta(&output.stdout);
1706 let (stderr, stderr_meta) = truncate_with_meta(&output.stderr);
1707 ShellResult {
1708 task_id: None,
1709 status: if output.exit_code == 0 {
1710 ShellStatus::Completed
1711 } else {
1712 ShellStatus::Failed
1713 },
1714 exit_code: Some(output.exit_code),
1715 stdout,
1716 stderr,
1717 duration_ms: u64::try_from(started.elapsed().as_millis())
1718 .unwrap_or(u64::MAX),
1719 stdout_len: stdout_meta.original_len,
1720 stderr_len: stderr_meta.original_len,
1721 stdout_omitted: stdout_meta.omitted,
1722 stderr_omitted: stderr_meta.omitted,
1723 stdout_truncated: stdout_meta.truncated,
1724 stderr_truncated: stderr_meta.truncated,
1725 sandboxed: true,
1726 sandbox_type: Some("opensandbox".to_string()),
1727 sandbox_denied: false,
1728 }
1729 }
1730 Err(e) => {
1731 return Ok(ToolResult::error(format!("Sandbox backend error: {e}")));
1732 }
1733 };
1734
1735 // Build result (reuse the existing output rendering below).
1736 let stdout_summary = summarize_output(&result.stdout);
1737 let stderr_summary = summarize_output(&result.stderr);
1738 let summary = if !stderr_summary.is_empty() {
1739 stderr_summary.clone()
1740 } else {
1741 stdout_summary.clone()
1742 };
1743 let output = if result.stdout.is_empty() && result.stderr.is_empty() {
1744 "(no output)".to_string()
1745 } else if result.stderr.is_empty() {
1746 result.stdout.clone()
1747 } else {
1748 format!("{}\n\nSTDERR:\n{}", result.stdout, result.stderr)
1749 };
1750
1751 let metadata = json!({
1752 "exit_code": result.exit_code,
1753 "status": format!("{:?}", result.status),
1754 "duration_ms": result.duration_ms,
1755 "sandboxed": true,
1756 "sandbox_type": "opensandbox",
1757 "sandbox_denied": false,
1758 "task_id": result.task_id,
1759 "stdout_len": result.stdout_len,
1760 "stderr_len": result.stderr_len,
1761 "stdout_truncated": result.stdout_truncated,
1762 "stderr_truncated": result.stderr_truncated,
1763 "stdout_omitted": result.stdout_omitted,
1764 "stderr_omitted": result.stderr_omitted,
1765 "summary": summary,
1766 "stdout_summary": stdout_summary,
1767 "stderr_summary": stderr_summary,
1768 "safety_level": format!("{:?}", safety.level),
1769 "interactive": false,
1770 "canceled": false,
1771 "sandbox_backend": "opensandbox",
1772 });
1773
1774 return Ok(ToolResult {
1775 content: output,
1776 success: result.status == ShellStatus::Completed,
1777 metadata: Some(metadata),
1778 });
1779 }
1780
1781 let result = if interactive {
1782 let mut manager = context
1783 .shell_manager
1784 .lock()
1785 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
1786 manager.execute_interactive_with_policy_env(
1787 command,
1788 working_dir.as_deref(),
1789 timeout_ms,
1790 policy_override,
1791 extra_env,
1792 )
1793 } else if background {
1794 let mut manager = context
1795 .shell_manager
1796 .lock()
1797 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
1798 manager.execute_with_options_env(
1799 command,
1800 working_dir.as_deref(),
1801 timeout_ms,
1802 true,
1803 stdin_data.as_deref(),
1804 tty,
1805 policy_override,
1806 extra_env,
1807 )
1808 } else {
1809 execute_foreground_via_background(
1810 context,
1811 command,
1812 timeout_ms,
1813 stdin_data.as_deref(),
1814 policy_override,
1815 extra_env,
1816 )
1817 .await
1818 };
1819
1820 match result {
1821 Ok(result) => {
1822 let backgrounded_foreground =
1823 !background && !interactive && result.status == ShellStatus::Running;
1824 if (background || backgrounded_foreground)
1825 && let (Some(shell_id), Some(task_id)) = (
1826 result.task_id.as_deref(),
1827 context.runtime.active_task_id.clone(),
1828 )
1829 && let Ok(mut manager) = context.shell_manager.lock()
1830 {
1831 let _ = manager.tag_linked_task(shell_id, Some(task_id));
1832 }
1833
1834 let was_cancelled = context
1835 .cancel_token
1836 .as_ref()
1837 .is_some_and(|token| token.is_cancelled());
1838 let task_id_str = result.task_id.clone().unwrap_or_default();
1839 let stdout_summary = summarize_output(&result.stdout);
1840 let stderr_summary = summarize_output(&result.stderr);
1841 let summary = if !stderr_summary.is_empty() {
1842 stderr_summary.clone()
1843 } else {
1844 stdout_summary.clone()
1845 };
1846 let output = if interactive {
1847 format!(
1848 "Interactive command completed (exit code: {:?})",
1849 result.exit_code
1850 )
1851 } else if result.status == ShellStatus::Completed {
1852 if result.stdout.is_empty() && result.stderr.is_empty() {
1853 "(no output)".to_string()
1854 } else if result.stderr.is_empty() {
1855 result.stdout.clone()
1856 } else {
1857 format!("{}\n\nSTDERR:\n{}", result.stdout, result.stderr)
1858 }
1859 } else if result.status == ShellStatus::Running {
1860 if backgrounded_foreground {
1861 format!(
1862 "Command moved to background: {task_id_str}\n\nPoll with exec_shell_wait or cancel with exec_shell_cancel."
1863 )
1864 } else {
1865 format!("Background task started: {task_id_str}")
1866 }
1867 } else if result.status == ShellStatus::Killed && was_cancelled {
1868 format!(
1869 "Command canceled; process killed.\n\nSTDOUT:\n{}\n\nSTDERR:\n{}",
1870 result.stdout, result.stderr
1871 )
1872 } else if result.status == ShellStatus::TimedOut {
1873 format!(
1874 "Command timed out after {timeout_ms}ms; process killed.\n\n{FOREGROUND_TIMEOUT_RECOVERY_HINT}\n\nSTDOUT:\n{}\n\nSTDERR:\n{}",
1875 result.stdout, result.stderr
1876 )
1877 } else {
1878 format!(
1879 "Command failed (exit code: {:?})\n\nSTDOUT:\n{}\n\nSTDERR:\n{}",
1880 result.exit_code, result.stdout, result.stderr
1881 )
1882 };
1883
1884 let mut metadata = json!({
1885 "exit_code": result.exit_code,
1886 "status": format!("{:?}", result.status),
1887 "duration_ms": result.duration_ms,
1888 "sandboxed": result.sandboxed,
1889 "sandbox_type": result.sandbox_type,
1890 "sandbox_denied": result.sandbox_denied,
1891 "task_id": result.task_id,
1892 "stdout_len": result.stdout_len,
1893 "stderr_len": result.stderr_len,
1894 "stdout_truncated": result.stdout_truncated,
1895 "stderr_truncated": result.stderr_truncated,
1896 "stdout_omitted": result.stdout_omitted,
1897 "stderr_omitted": result.stderr_omitted,
1898 "summary": summary,
1899 "stdout_summary": stdout_summary,
1900 "stderr_summary": stderr_summary,
1901 "safety_level": format!("{:?}", safety.level),
1902 "interactive": interactive,
1903 "canceled": was_cancelled,
1904 "execpolicy": execpolicy_decision.as_ref().map(|decision| match decision {
1905 ExecPolicyDecision::Allow => json!({
1906 "decision": "allow",
1907 }),
1908 ExecPolicyDecision::Deny(reason) => json!({
1909 "decision": "deny",
1910 "reason": reason,
1911 }),
1912 ExecPolicyDecision::AskUser(reason) => json!({
1913 "decision": "ask_user",
1914 "reason": reason,
1915 }),
1916 }),
1917 });
1918 metadata["backgrounded"] = json!(background || backgrounded_foreground);
1919 if result.status == ShellStatus::TimedOut && !background && !interactive {
1920 metadata["foreground_timeout_recovery"] = json!({
1921 "process_killed": true,
1922 "hint": FOREGROUND_TIMEOUT_RECOVERY_HINT,
1923 "recommended_tools": [
1924 "task_shell_start",
1925 "task_shell_wait",
1926 "exec_shell",
1927 "exec_shell_wait"
1928 ],
1929 "exec_shell_background": true,
1930 "poll_with": ["task_shell_wait", "exec_shell_wait"]
1931 });
1932 }
1933
1934 Ok(ToolResult {
1935 content: output,
1936 success: result.status == ShellStatus::Completed
1937 || result.status == ShellStatus::Running,
1938 metadata: Some(metadata),
1939 })
1940 }
1941 Err(e) => Ok(ToolResult::error(format!("Shell execution failed: {e}"))),
1942 }
1943 }
1944 }
1945
1946 pub struct ShellWaitTool {
1947 name: &'static str,
1948 }
1949
1950 impl ShellWaitTool {
1951 pub const fn new(name: &'static str) -> Self {
1952 Self { name }
1953 }
1954 }
1955
1956 pub struct ShellInteractTool {
1957 name: &'static str,
1958 }
1959
1960 impl ShellInteractTool {
1961 pub const fn new(name: &'static str) -> Self {
1962 Self { name }
1963 }
1964 }
1965
1966 fn required_task_id(input: &serde_json::Value) -> Result<&str, ToolError> {
1967 input
1968 .get("task_id")
1969 .or_else(|| input.get("id"))
1970 .and_then(serde_json::Value::as_str)
1971 .ok_or_else(|| ToolError::missing_field("task_id"))
1972 }
1973
1974 fn build_shell_delta_tool_result(delta: ShellDeltaResult) -> ToolResult {
1975 let result = delta.result;
1976 let stdout_summary = summarize_output(&result.stdout);
1977 let stderr_summary = summarize_output(&result.stderr);
1978 let summary = if !stderr_summary.is_empty() {
1979 stderr_summary.clone()
1980 } else {
1981 stdout_summary.clone()
1982 };
1983
1984 let output = if result.stdout.is_empty() && result.stderr.is_empty() {
1985 match result.status {
1986 ShellStatus::Running => "Background task running (no new output).".to_string(),
1987 ShellStatus::Completed => "(no new output)".to_string(),
1988 ShellStatus::Failed => format!("Command failed (exit code: {:?})", result.exit_code),
1989 ShellStatus::TimedOut => "Command timed out (no new output).".to_string(),
1990 ShellStatus::Killed => "Command killed (no new output).".to_string(),
1991 }
1992 } else if result.stderr.is_empty() {
1993 result.stdout.clone()
1994 } else {
1995 format!("{}\n\nSTDERR:\n{}", result.stdout, result.stderr)
1996 };
1997
1998 ToolResult {
1999 content: output,
2000 success: matches!(result.status, ShellStatus::Completed | ShellStatus::Running),
2001 metadata: Some(json!({
2002 "exit_code": result.exit_code,
2003 "status": format!("{:?}", result.status),
2004 "duration_ms": result.duration_ms,
2005 "sandboxed": result.sandboxed,
2006 "sandbox_type": result.sandbox_type,
2007 "sandbox_denied": result.sandbox_denied,
2008 "task_id": result.task_id,
2009 "stdout_len": result.stdout_len,
2010 "stderr_len": result.stderr_len,
2011 "stdout_truncated": result.stdout_truncated,
2012 "stderr_truncated": result.stderr_truncated,
2013 "stdout_omitted": result.stdout_omitted,
2014 "stderr_omitted": result.stderr_omitted,
2015 "stdout_total_len": delta.stdout_total_len,
2016 "stderr_total_len": delta.stderr_total_len,
2017 "summary": summary,
2018 "stdout_summary": stdout_summary,
2019 "stderr_summary": stderr_summary,
2020 "stream_delta": true,
2021 })),
2022 }
2023 }
2024
2025 async fn wait_for_shell_delta_cancellable(
2026 context: &ToolContext,
2027 task_id: &str,
2028 timeout_ms: u64,
2029 ) -> Result<(ShellDeltaResult, bool), ToolError> {
2030 let timeout_ms = timeout_ms.clamp(1000, 600_000);
2031 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
2032 let mut stdout_accum = String::new();
2033 let mut stderr_accum = String::new();
2034
2035 let (result, stdout_total_len, stderr_total_len) = loop {
2036 if context
2037 .cancel_token
2038 .as_ref()
2039 .is_some_and(|token| token.is_cancelled())
2040 {
2041 let mut manager = context
2042 .shell_manager
2043 .lock()
2044 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
2045 let delta = manager
2046 .get_output_delta(task_id, false, 0)
2047 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
2048 append_shell_delta_output(&mut stdout_accum, &mut stderr_accum, &delta.result);
2049 return Ok((
2050 shell_delta_with_accumulated_output(
2051 delta.result,
2052 &stdout_accum,
2053 &stderr_accum,
2054 delta.stdout_total_len,
2055 delta.stderr_total_len,
2056 ),
2057 true,
2058 ));
2059 }
2060
2061 let delta = {
2062 let mut manager = context
2063 .shell_manager
2064 .lock()
2065 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
2066 manager
2067 .get_output_delta(task_id, false, 0)
2068 .map_err(|err| ToolError::execution_failed(err.to_string()))?
2069 };
2070
2071 let stdout_total_len = delta.stdout_total_len;
2072 let stderr_total_len = delta.stderr_total_len;
2073 append_shell_delta_output(&mut stdout_accum, &mut stderr_accum, &delta.result);
2074
2075 let status = delta.result.status.clone();
2076 if status != ShellStatus::Running || Instant::now() >= deadline {
2077 break (delta.result, stdout_total_len, stderr_total_len);
2078 }
2079
2080 tokio::time::sleep(Duration::from_millis(100)).await;
2081 };
2082
2083 Ok((
2084 shell_delta_with_accumulated_output(
2085 result,
2086 &stdout_accum,
2087 &stderr_accum,
2088 stdout_total_len,
2089 stderr_total_len,
2090 ),
2091 false,
2092 ))
2093 }
2094
2095 fn append_shell_delta_output(
2096 stdout_accum: &mut String,
2097 stderr_accum: &mut String,
2098 result: &ShellResult,
2099 ) {
2100 if !result.stdout.is_empty() {
2101 stdout_accum.push_str(&result.stdout);
2102 }
2103 if !result.stderr.is_empty() {
2104 stderr_accum.push_str(&result.stderr);
2105 }
2106 }
2107
2108 fn shell_delta_with_accumulated_output(
2109 mut result: ShellResult,
2110 stdout_accum: &str,
2111 stderr_accum: &str,
2112 stdout_total_len: usize,
2113 stderr_total_len: usize,
2114 ) -> ShellDeltaResult {
2115 let (stdout, stdout_meta) = truncate_with_meta(stdout_accum);
2116 let (stderr, stderr_meta) = truncate_with_meta(stderr_accum);
2117 result.stdout = stdout;
2118 result.stderr = stderr;
2119 result.stdout_len = stdout_meta.original_len;
2120 result.stderr_len = stderr_meta.original_len;
2121 result.stdout_omitted = stdout_meta.omitted;
2122 result.stderr_omitted = stderr_meta.omitted;
2123 result.stdout_truncated = stdout_meta.truncated;
2124 result.stderr_truncated = stderr_meta.truncated;
2125
2126 ShellDeltaResult {
2127 result,
2128 stdout_total_len,
2129 stderr_total_len,
2130 }
2131 }
2132
2133 pub struct ShellCancelTool;
2134
2135 #[async_trait]
2136 impl ToolSpec for ShellCancelTool {
2137 fn name(&self) -> &'static str {
2138 "exec_shell_cancel"
2139 }
2140
2141 fn description(&self) -> &'static str {
2142 "Cancel a running background shell task by task_id, or cancel all running background shell tasks with all=true."
2143 }
2144
2145 fn input_schema(&self) -> serde_json::Value {
2146 json!({
2147 "type": "object",
2148 "properties": {
2149 "task_id": {
2150 "type": "string",
2151 "description": "Task ID returned by exec_shell or task_shell_start"
2152 },
2153 "id": {
2154 "type": "string",
2155 "description": "Alias for task_id"
2156 },
2157 "all": {
2158 "type": "boolean",
2159 "description": "Cancel all currently running background shell tasks"
2160 }
2161 }
2162 })
2163 }
2164
2165 fn capabilities(&self) -> Vec<ToolCapability> {
2166 vec![ToolCapability::RequiresApproval]
2167 }
2168
2169 fn approval_requirement(&self) -> ApprovalRequirement {
2170 ApprovalRequirement::Required
2171 }
2172
2173 async fn execute(
2174 &self,
2175 input: serde_json::Value,
2176 context: &ToolContext,
2177 ) -> Result<ToolResult, ToolError> {
2178 let cancel_all = optional_bool(&input, "all", false);
2179 let mut manager = context
2180 .shell_manager
2181 .lock()
2182 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
2183
2184 if cancel_all {
2185 let results = manager
2186 .kill_running()
2187 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
2188 if results.is_empty() {
2189 return Ok(ToolResult {
2190 content: "No running background shell jobs.".to_string(),
2191 success: true,
2192 metadata: Some(json!({
2193 "status": "Noop",
2194 "canceled": 0,
2195 "task_ids": [],
2196 })),
2197 });
2198 }
2199
2200 let task_ids = results
2201 .iter()
2202 .filter_map(|result| result.task_id.clone())
2203 .collect::<Vec<_>>();
2204 return Ok(ToolResult {
2205 content: format!(
2206 "Canceled {} background shell job{}: {}",
2207 task_ids.len(),
2208 if task_ids.len() == 1 { "" } else { "s" },
2209 task_ids.join(", ")
2210 ),
2211 success: true,
2212 metadata: Some(json!({
2213 "status": "Killed",
2214 "canceled": task_ids.len(),
2215 "task_ids": task_ids,
2216 })),
2217 });
2218 }
2219
2220 let task_id = required_task_id(&input)?;
2221 let result = manager
2222 .kill(task_id)
2223 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
2224 let task_id = result
2225 .task_id
2226 .clone()
2227 .unwrap_or_else(|| task_id.to_string());
2228 Ok(ToolResult {
2229 content: format!("Canceled background shell job: {task_id}"),
2230 success: true,
2231 metadata: Some(json!({
2232 "status": format!("{:?}", result.status),
2233 "task_id": task_id,
2234 "exit_code": result.exit_code,
2235 "duration_ms": result.duration_ms,
2236 })),
2237 })
2238 }
2239 }
2240
2241 #[async_trait]
2242 impl ToolSpec for ShellWaitTool {
2243 fn name(&self) -> &'static str {
2244 self.name
2245 }
2246
2247 fn description(&self) -> &'static str {
2248 "Wait for a background shell task and return incremental output. Turn cancellation stops waiting but leaves the background task running."
2249 }
2250
2251 fn input_schema(&self) -> serde_json::Value {
2252 json!({
2253 "type": "object",
2254 "properties": {
2255 "task_id": {
2256 "type": "string",
2257 "description": "Task ID returned by exec_shell"
2258 },
2259 "timeout_ms": {
2260 "type": "integer",
2261 "description": "Timeout in milliseconds (default: 5000)"
2262 },
2263 "wait": {
2264 "type": "boolean",
2265 "description": "Wait for completion before returning (default: true)"
2266 }
2267 },
2268 "required": ["task_id"]
2269 })
2270 }
2271
2272 fn capabilities(&self) -> Vec<ToolCapability> {
2273 vec![ToolCapability::ReadOnly]
2274 }
2275
2276 fn approval_requirement(&self) -> ApprovalRequirement {
2277 ApprovalRequirement::Auto
2278 }
2279
2280 async fn execute(
2281 &self,
2282 input: serde_json::Value,
2283 context: &ToolContext,
2284 ) -> Result<ToolResult, ToolError> {
2285 let task_id = required_task_id(&input)?;
2286 let wait = optional_bool(&input, "wait", true);
2287 let timeout_ms = optional_u64(&input, "timeout_ms", 5_000);
2288
2289 let (delta, wait_canceled) = if wait {
2290 wait_for_shell_delta_cancellable(context, task_id, timeout_ms).await?
2291 } else {
2292 let mut manager = context
2293 .shell_manager
2294 .lock()
2295 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
2296 let delta = manager
2297 .get_output_delta(task_id, false, timeout_ms)
2298 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
2299 (delta, false)
2300 };
2301
2302 let status = delta.result.status.clone();
2303 let mut result = build_shell_delta_tool_result(delta);
2304 if wait_canceled {
2305 if matches!(status, ShellStatus::Running) {
2306 result.content = format!(
2307 "Wait canceled; background shell task {task_id} is still running.\n\n{}",
2308 result.content
2309 );
2310 }
2311 if let Some(metadata) = result.metadata.as_mut()
2312 && let Some(object) = metadata.as_object_mut()
2313 {
2314 object.insert("wait_canceled".to_string(), json!(true));
2315 }
2316 }
2317
2318 Ok(result)
2319 }
2320 }
2321
2322 #[async_trait]
2323 impl ToolSpec for ShellInteractTool {
2324 fn name(&self) -> &'static str {
2325 self.name
2326 }
2327
2328 fn description(&self) -> &'static str {
2329 "Send input to a background shell task and return incremental output."
2330 }
2331
2332 fn input_schema(&self) -> serde_json::Value {
2333 json!({
2334 "type": "object",
2335 "properties": {
2336 "task_id": {
2337 "type": "string",
2338 "description": "Task ID returned by exec_shell"
2339 },
2340 "input": {
2341 "type": "string",
2342 "description": "Input to send to the task's stdin"
2343 },
2344 "stdin": {
2345 "type": "string",
2346 "description": "Alias for input"
2347 },
2348 "data": {
2349 "type": "string",
2350 "description": "Alias for input"
2351 },
2352 "timeout_ms": {
2353 "type": "integer",
2354 "description": "Wait for output after sending input (default: 1000)"
2355 },
2356 "close_stdin": {
2357 "type": "boolean",
2358 "description": "Close stdin after sending input"
2359 }
2360 },
2361 "required": ["task_id"]
2362 })
2363 }
2364
2365 fn capabilities(&self) -> Vec<ToolCapability> {
2366 vec![ToolCapability::ExecutesCode]
2367 }
2368
2369 fn approval_requirement(&self) -> ApprovalRequirement {
2370 ApprovalRequirement::Auto
2371 }
2372
2373 async fn execute(
2374 &self,
2375 input: serde_json::Value,
2376 context: &ToolContext,
2377 ) -> Result<ToolResult, ToolError> {
2378 let task_id = required_task_id(&input)?;
2379 let close_stdin = optional_bool(&input, "close_stdin", false);
2380 let timeout_ms = optional_u64(&input, "timeout_ms", 1_000);
2381 let interaction_input = input
2382 .get("input")
2383 .or_else(|| input.get("stdin"))
2384 .or_else(|| input.get("data"))
2385 .and_then(serde_json::Value::as_str)
2386 .unwrap_or("");
2387
2388 {
2389 let mut manager = context
2390 .shell_manager
2391 .lock()
2392 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
2393 if !interaction_input.is_empty() || close_stdin {
2394 manager
2395 .write_stdin(task_id, interaction_input, close_stdin)
2396 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
2397 }
2398 }
2399
2400 let mut elapsed = 0u64;
2401 loop {
2402 if context
2403 .cancel_token
2404 .as_ref()
2405 .is_some_and(|token| token.is_cancelled())
2406 {
2407 let mut manager = context
2408 .shell_manager
2409 .lock()
2410 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
2411 let delta = manager
2412 .get_output_delta(task_id, false, 0)
2413 .map_err(|err| ToolError::execution_failed(err.to_string()))?;
2414 let mut result = build_shell_delta_tool_result(delta);
2415 if let Some(metadata) = result.metadata.as_mut()
2416 && let Some(object) = metadata.as_object_mut()
2417 {
2418 object.insert("wait_canceled".to_string(), json!(true));
2419 }
2420 return Ok(result);
2421 }
2422
2423 let delta = {
2424 let mut manager = context
2425 .shell_manager
2426 .lock()
2427 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
2428 manager
2429 .get_output_delta(task_id, false, 0)
2430 .map_err(|err| ToolError::execution_failed(err.to_string()))?
2431 };
2432
2433 if !delta.result.stdout.is_empty()
2434 || !delta.result.stderr.is_empty()
2435 || delta.result.status != ShellStatus::Running
2436 || elapsed >= timeout_ms
2437 {
2438 return Ok(build_shell_delta_tool_result(delta));
2439 }
2440
2441 tokio::time::sleep(Duration::from_millis(50)).await;
2442 elapsed = elapsed.saturating_add(50);
2443 }
2444 }
2445 }
2446
2447 /// Tool for appending notes to a notes file.
2448 pub struct NoteTool;
2449
2450 #[async_trait]
2451 impl ToolSpec for NoteTool {
2452 fn name(&self) -> &'static str {
2453 "note"
2454 }
2455
2456 fn description(&self) -> &'static str {
2457 "Append a note to the agent notes file for persistent context across sessions."
2458 }
2459
2460 fn input_schema(&self) -> serde_json::Value {
2461 json!({
2462 "type": "object",
2463 "properties": {
2464 "content": {
2465 "type": "string",
2466 "description": "The note content to append"
2467 }
2468 },
2469 "required": ["content"]
2470 })
2471 }
2472
2473 fn capabilities(&self) -> Vec<ToolCapability> {
2474 vec![ToolCapability::WritesFiles]
2475 }
2476
2477 fn approval_requirement(&self) -> ApprovalRequirement {
2478 ApprovalRequirement::Auto // Notes are low-risk
2479 }
2480
2481 async fn execute(
2482 &self,
2483 input: serde_json::Value,
2484 context: &ToolContext,
2485 ) -> Result<ToolResult, ToolError> {
2486 let note_content = required_str(&input, "content")?;
2487
2488 // Ensure parent directory exists
2489 if let Some(parent) = context.notes_path.parent() {
2490 std::fs::create_dir_all(parent).map_err(|e| {
2491 ToolError::execution_failed(format!("Failed to create notes directory: {e}"))
2492 })?;
2493 }
2494
2495 // Append to notes file
2496 let mut file = std::fs::OpenOptions::new()
2497 .create(true)
2498 .append(true)
2499 .open(&context.notes_path)
2500 .map_err(|e| ToolError::execution_failed(format!("Failed to open notes file: {e}")))?;
2501
2502 writeln!(file, "\n---\n{note_content}")
2503 .map_err(|e| ToolError::execution_failed(format!("Failed to write note: {e}")))?;
2504
2505 Ok(ToolResult::success(format!(
2506 "Note appended to {}",
2507 context.notes_path.display()
2508 )))
2509 }
2510 }
2511
2512 #[cfg(test)]
2513 mod tests;
2514
2514 lines RUST