1use std::path::{Path, PathBuf};
4use std::process::Stdio;
5use std::time::{Duration, Instant};
6
7use futures_util::future::BoxFuture;
8use serde_json::{Value, json};
9use tokio::io::AsyncReadExt;
10use tokio_util::sync::CancellationToken;
11use yapi_agent::{Tool, UpdateSink};
12use yapi_types::event::ToolResult;
13use yapi_types::message::ToolDeclaration;
14use yapi_types::sync::lock;
15
16use super::truncate::{
17 DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, TruncatedBy, Truncation, format_size, tail_bytes,
18 truncate_tail,
19};
20use super::{ToolEnv, declaration, sleep_until_opt, text_result};
21
22const MAX_TIMEOUT_MS: f64 = 2_147_483_647.0;
23const STRUCTURED_OUTPUT_MAX_BYTES: usize = 1024 * 1024;
24const UPDATE_THROTTLE: Duration = Duration::from_millis(100);
25const EXIT_STDIO_GRACE: Duration = Duration::from_millis(100);
26
27pub struct Bash {
29 env: ToolEnv,
30 declaration: ToolDeclaration,
31 output_schema: Value,
32}
33
34impl Bash {
35 pub fn new(env: ToolEnv) -> Bash {
37 Bash {
38 env,
39 declaration: declaration(
40 "bash",
41 format!(
42 "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.",
43 DEFAULT_MAX_BYTES / 1024
44 ),
45 json!({"type":"object","required":["command"],"properties":{
46 "command":{"type":"string","description":"Shell command to execute"},
47 "timeout":{"type":"number","description":"Timeout in seconds (optional, no default timeout)"}}}),
48 ),
49 output_schema: json!({"type":"object","required":["output","truncated","exit_code","wall_time_seconds"],"properties":{
51 "output":{"type":"string","description":"Combined stdout and stderr, possibly truncated"},
52 "truncated":{"type":"boolean"},
53 "full_output_path":{"type":"string","description":"Full output, when truncated"},
54 "exit_code":{"type":"number"},
55 "wall_time_seconds":{"type":"number"}}}),
56 }
57 }
58}
59
60pub fn shell() -> (String, Vec<String>) {
62 if std::path::Path::new("/bin/bash").exists() {
63 return ("/bin/bash".into(), vec!["-c".into()]);
64 }
65 if let Some(paths) = std::env::var_os("PATH") {
66 for dir in std::env::split_paths(&paths) {
67 let candidate = dir.join("bash");
68 if candidate.is_file() {
69 return (candidate.to_string_lossy().into_owned(), vec!["-c".into()]);
70 }
71 }
72 }
73 ("sh".into(), vec!["-c".into()])
74}
75
76pub fn shell_with(custom: Option<&str>) -> Result<(String, Vec<String>), String> {
79 match custom
80 .filter(|path| !path.is_empty())
81 .map(super::path::expand)
82 {
83 Some(path) if std::path::Path::new(&path).exists() => Ok((path, vec!["-c".into()])),
84 Some(path) => Err(format!("Custom shell path not found: {path}")),
85 None => Ok(shell()),
86 }
87}
88
89pub fn with_prefix(prefix: Option<&str>, command: &str) -> String {
92 match prefix {
93 Some(prefix) if !prefix.is_empty() => format!("{prefix}\n{command}"),
94 _ => command.to_owned(),
95 }
96}
97
98pub(crate) fn shell_command(
102 (shell, shell_args): (String, Vec<String>),
103 command: &str,
104 cwd: &std::path::Path,
105 bin_dir: &std::path::Path,
106) -> tokio::process::Command {
107 let mut process = tokio::process::Command::new(shell);
108 process
109 .envs(crate::config::child_env())
110 .args(shell_args)
111 .arg(command)
112 .current_dir(cwd)
113 .stdin(Stdio::null())
114 .stdout(Stdio::piped())
115 .stderr(Stdio::piped())
116 .kill_on_drop(false);
117 #[cfg(unix)]
118 process.process_group(0);
119 let path = std::env::var_os("PATH").unwrap_or_default();
121 let mut entries: Vec<std::path::PathBuf> = std::env::split_paths(&path).collect();
122 if !entries.iter().any(|entry| entry == bin_dir) {
123 entries.insert(0, bin_dir.to_path_buf());
124 if let Ok(joined) =
125 std::env::join_paths(entries.iter().filter(|p| !p.as_os_str().is_empty()))
126 {
127 process.env("PATH", joined);
128 }
129 }
130 for name in [
131 "PI_SESSION_ID",
132 "PI_SESSION_FILE",
133 "PI_PROVIDER",
134 "PI_MODEL",
135 "PI_REASONING_LEVEL",
136 ] {
137 process.env_remove(name);
138 }
139 process
140}
141
142struct Accumulator {
145 raw: Vec<u8>,
146 pending: Vec<u8>,
147 tail: String,
148 tail_at_line_start: bool,
149 total_bytes: usize,
150 completed_lines: usize,
151 open_line: bool,
152 current_line_bytes: usize,
153 file: Option<(PathBuf, std::fs::File)>,
154}
155
156impl Accumulator {
157 fn new() -> Accumulator {
158 Accumulator {
159 raw: Vec::new(),
160 pending: Vec::new(),
161 tail: String::new(),
162 tail_at_line_start: true,
163 total_bytes: 0,
164 completed_lines: 0,
165 open_line: false,
166 current_line_bytes: 0,
167 file: None,
168 }
169 }
170
171 fn total_lines(&self) -> usize {
172 self.completed_lines + usize::from(self.open_line)
173 }
174
175 fn over_limits(&self) -> bool {
176 self.total_bytes > DEFAULT_MAX_BYTES || self.total_lines() > DEFAULT_MAX_LINES
177 }
178
179 fn append(&mut self, data: &[u8]) {
180 use std::io::Write;
181 let decoded = yapi_types::js::decode_utf8_stream(&mut self.pending, data);
182 self.append_text(&decoded);
183 if self.file.is_some() || self.over_limits() {
184 self.spill();
185 if let Some((_, file)) = &mut self.file {
186 let _ = file.write_all(data);
187 }
188 } else {
189 self.raw.extend_from_slice(data);
190 }
191 }
192
193 fn finish(&mut self) {
194 if !self.pending.is_empty() {
195 let rest = String::from_utf8_lossy(&std::mem::take(&mut self.pending)).into_owned();
196 self.append_text(&rest);
197 }
198 if self.over_limits() {
199 self.spill();
200 }
201 }
202
203 fn append_text(&mut self, text: &str) {
204 if text.is_empty() {
205 return;
206 }
207 self.total_bytes += text.len();
208 self.tail.push_str(text);
209 let rolling = DEFAULT_MAX_BYTES * 2;
210 if self.tail.len() > rolling * 2 {
211 let kept = tail_bytes(&self.tail, rolling);
212 let start = self.tail.len() - kept.len();
213 self.tail_at_line_start = start == 0 || self.tail.as_bytes()[start - 1] == b'\n';
214 self.tail = kept.to_owned();
215 }
216 match text.rfind('\n') {
217 None => {
218 self.current_line_bytes += text.len();
219 self.open_line = true;
220 }
221 Some(last) => {
222 self.completed_lines += text.matches('\n').count();
223 self.current_line_bytes = text.len() - last - 1;
224 self.open_line = last + 1 < text.len();
225 }
226 }
227 }
228
229 fn spill(&mut self) {
230 use std::io::Write;
231 if self.file.is_some() {
232 return;
233 }
234 if let Some((path, mut file)) = create_log() {
235 let _ = file.write_all(&std::mem::take(&mut self.raw));
236 self.file = Some((path, file));
237 }
238 }
239
240 fn snapshot(&mut self, persist: bool) -> (Truncation, Option<PathBuf>) {
241 let text = if self.tail_at_line_start {
242 self.tail.as_str()
243 } else {
244 self.tail
245 .find('\n')
246 .map_or(self.tail.as_str(), |index| &self.tail[index + 1..])
247 };
248 let mut truncation = truncate_tail(text, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
249 let truncated = self.over_limits();
250 truncation.truncated = truncated;
251 truncation.truncated_by = if truncated {
252 truncation
253 .truncated_by
254 .or(Some(if self.total_bytes > DEFAULT_MAX_BYTES {
255 TruncatedBy::Bytes
256 } else {
257 TruncatedBy::Lines
258 }))
259 } else {
260 None
261 };
262 truncation.total_lines = self.total_lines();
263 truncation.total_bytes = self.total_bytes;
264 if persist && truncated {
265 self.spill();
266 }
267 (truncation, self.file.as_ref().map(|(path, _)| path.clone()))
268 }
269
270 fn full_output(&self) -> (String, bool) {
271 let Some((path, _)) = &self.file else {
272 return (String::from_utf8_lossy(&self.raw).into_owned(), false);
273 };
274 Self::read_log(path).unwrap_or_default()
275 }
276
277 fn read_log(path: &Path) -> std::io::Result<(String, bool)> {
280 use std::io::{Read, Seek, SeekFrom};
281 let mut file = std::fs::File::open(path)?;
282 let len = usize::try_from(file.metadata()?.len()).unwrap_or(usize::MAX);
283 if len <= STRUCTURED_OUTPUT_MAX_BYTES {
284 let mut bytes = Vec::with_capacity(len);
285 file.read_to_end(&mut bytes)?;
286 return Ok((String::from_utf8_lossy(&bytes).into_owned(), false));
287 }
288 let head_len = STRUCTURED_OUTPUT_MAX_BYTES / 2;
289 let tail_len = STRUCTURED_OUTPUT_MAX_BYTES - head_len;
290 let mut head = vec![0; head_len + 1];
292 file.read_exact(&mut head)?;
293 let mut tail = vec![0; tail_len];
294 file.seek(SeekFrom::End(-(tail_len as i64)))?;
295 file.read_exact(&mut tail)?;
296 let mut head_end = head_len;
297 while head_end > 0 && (head[head_end] & 0xc0) == 0x80 {
298 head_end -= 1;
299 }
300 let mut tail_start = 0;
301 while tail_start < tail.len() && (tail[tail_start] & 0xc0) == 0x80 {
302 tail_start += 1;
303 }
304 let omitted = len - head_len - tail_len;
305 Ok((
306 format!(
307 "{}\n\n[... {omitted} bytes omitted ...]\n\n{}",
308 String::from_utf8_lossy(&head[..head_end]),
309 String::from_utf8_lossy(&tail[tail_start..])
310 ),
311 true,
312 ))
313 }
314}
315
316fn format_output(
317 truncation: &Truncation,
318 path: &Option<PathBuf>,
319 last_line_bytes: usize,
320 empty: &str,
321) -> (String, Option<Value>) {
322 let mut text = if truncation.content.is_empty() {
323 empty.to_owned()
324 } else {
325 truncation.content.clone()
326 };
327 if !truncation.truncated {
328 return (text, None);
329 }
330 let path_text = path
331 .as_ref()
332 .map(|path| path.display().to_string())
333 .unwrap_or_default();
334 let start = truncation.total_lines - truncation.output_lines + 1;
335 let end = truncation.total_lines;
336 if truncation.last_line_partial {
337 text += &format!(
338 "\n\n[Showing last {} of line {end} (line is {}). Full output: {path_text}]",
339 format_size(truncation.output_bytes),
340 format_size(last_line_bytes)
341 );
342 } else if truncation.truncated_by == Some(TruncatedBy::Lines) {
343 text += &format!(
344 "\n\n[Showing lines {start}-{end} of {}. Full output: {path_text}]",
345 truncation.total_lines
346 );
347 } else {
348 text += &format!(
349 "\n\n[Showing lines {start}-{end} of {} ({} limit). Full output: {path_text}]",
350 truncation.total_lines,
351 format_size(DEFAULT_MAX_BYTES)
352 );
353 }
354 let mut details = json!({ "truncation": truncation });
355 if let Some(path) = path {
356 details["fullOutputPath"] = json!(path.display().to_string());
357 }
358 (text, Some(details))
359}
360
361fn progress(truncation: &Truncation, path: Option<&Path>) -> ToolResult {
363 let mut details = json!({});
364 if truncation.truncated {
365 details["truncation"] = json!(truncation);
366 }
367 if let Some(path) = path {
368 details["fullOutputPath"] = json!(path.display().to_string());
369 }
370 text_result(truncation.content.clone(), Some(details))
371}
372
373fn with_status(text: &str, status: &str) -> String {
374 if text.is_empty() {
375 status.to_owned()
376 } else {
377 format!("{text}\n\n{status}")
378 }
379}
380
381static TRACKED: std::sync::Mutex<Vec<u32>> = std::sync::Mutex::new(Vec::new());
384
385pub(crate) struct TrackedChild(Option<u32>);
387
388impl TrackedChild {
389 pub(crate) fn new(pid: Option<u32>) -> TrackedChild {
391 if let Some(pid) = pid {
392 lock(&TRACKED).push(pid);
393 }
394 TrackedChild(pid)
395 }
396}
397
398impl Drop for TrackedChild {
399 fn drop(&mut self) {
400 if let Some(pid) = self.0 {
401 lock(&TRACKED).retain(|tracked| *tracked != pid);
402 }
403 }
404}
405
406pub fn kill_tracked_children() {
409 let tracked = std::mem::take(&mut *lock(&TRACKED));
410 for pid in tracked {
411 kill_tree(Some(pid));
412 }
413}
414
415#[cfg(unix)]
417pub(crate) fn signal_tree(pid: Option<u32>, signal: rustix::process::Signal) {
418 use rustix::process::{Pid, kill_process, kill_process_group};
419 let Some(pid) = pid.and_then(|pid| Pid::from_raw(pid as i32)) else {
420 return;
421 };
422 if kill_process_group(pid, signal).is_err() {
423 let _ = kill_process(pid, signal);
424 }
425}
426
427#[cfg(unix)]
428pub(crate) fn kill_tree(pid: Option<u32>) {
429 signal_tree(pid, rustix::process::Signal::KILL);
430}
431
432#[cfg(not(unix))]
433pub(crate) fn kill_tree(_pid: Option<u32>) {}
434
435pub(crate) struct Pipes {
437 stdout: Option<tokio::process::ChildStdout>,
438 stderr: Option<tokio::process::ChildStderr>,
439 buffers: [Vec<u8>; 2],
440}
441
442impl Pipes {
443 pub(crate) fn take(child: &mut tokio::process::Child) -> Pipes {
445 Pipes {
446 stdout: child.stdout.take(),
447 stderr: child.stderr.take(),
448 buffers: [vec![0; 8192], vec![0; 8192]],
449 }
450 }
451
452 pub(crate) fn is_open(&self) -> bool {
454 self.stdout.is_some() || self.stderr.is_some()
455 }
456
457 pub(crate) async fn read(&mut self) -> &[u8] {
462 let [out, err] = &mut self.buffers;
463 let (read, buffer) = tokio::select! {
464 biased;
465 read = async { self.stdout.as_mut().unwrap_or_else(|| unreachable!()).read(out).await }, if self.stdout.is_some() => {
466 if !matches!(read, Ok(1..)) {
467 self.stdout = None;
468 }
469 (read, &self.buffers[0])
470 }
471 read = async { self.stderr.as_mut().unwrap_or_else(|| unreachable!()).read(err).await }, if self.stderr.is_some() => {
472 if !matches!(read, Ok(1..)) {
473 self.stderr = None;
474 }
475 (read, &self.buffers[1])
476 }
477 else => return &[],
478 };
479 &buffer[..read.unwrap_or(0)]
480 }
481}
482
483pub(crate) enum Event<'a> {
485 Output(&'a [u8]),
487 Tick,
489}
490
491pub(crate) enum Stop {
493 Aborted,
494 TimedOut,
495}
496
497pub(crate) async fn run_child(
504 mut child: tokio::process::Child,
505 cancel: &CancellationToken,
506 deadline: Option<Instant>,
507 mut on: impl FnMut(Event<'_>) -> Option<Instant>,
508) -> (Option<std::process::ExitStatus>, Option<Stop>) {
509 let pid = child.id();
510 let _tracked = TrackedChild::new(pid);
511 let mut pipes = Pipes::take(&mut child);
512 let (mut status, mut stop, mut tick) = (None, None, None);
513 let mut exited_at: Option<Instant> = None;
514 loop {
515 if exited_at.is_some() && !pipes.is_open() {
516 break;
517 }
518 let grace = exited_at.map(|at| at + EXIT_STDIO_GRACE);
519 tokio::select! {
520 biased;
521 () = cancel.cancelled(), if stop.is_none() => {
522 stop = Some(Stop::Aborted);
523 kill_tree(pid);
524 }
525 () = sleep_until_opt(deadline), if stop.is_none() => {
526 stop = Some(Stop::TimedOut);
527 kill_tree(pid);
528 }
529 () = sleep_until_opt(tick) => tick = on(Event::Tick),
530 bytes = pipes.read(), if pipes.is_open() => {
531 if !bytes.is_empty() {
532 tick = on(Event::Output(bytes));
533 if let Some(at) = &mut exited_at { *at = Instant::now(); }
534 }
535 }
536 result = child.wait(), if exited_at.is_none() => {
537 status = result.ok();
538 exited_at = Some(Instant::now());
539 }
540 () = sleep_until_opt(grace) => break,
541 }
542 }
543 (status, stop)
544}
545
546pub(crate) fn exit_code_of(status: std::process::ExitStatus) -> Option<i32> {
548 #[cfg(unix)]
549 {
550 use std::os::unix::process::ExitStatusExt;
551 status
552 .code()
553 .or_else(|| status.signal().map(|signal| 128 + signal))
554 }
555 #[cfg(not(unix))]
556 {
557 status.code()
558 }
559}
560
561pub(crate) fn create_log() -> Option<(PathBuf, std::fs::File)> {
564 let path = std::env::temp_dir().join(format!("yapi-bash-{}.log", crate::time::random_hex(8)));
565 std::fs::File::create(&path).ok().map(|file| (path, file))
566}
567
568impl Tool for Bash {
569 fn declaration(&self) -> &ToolDeclaration {
570 &self.declaration
571 }
572
573 fn output_schema(&self) -> Option<&Value> {
574 Some(&self.output_schema)
575 }
576
577 fn execute(
578 &self,
579 _call_id: String,
580 args: Value,
581 cancel: CancellationToken,
582 updates: UpdateSink,
583 ) -> BoxFuture<'_, Result<ToolResult, String>> {
584 Box::pin(async move {
585 let command = args["command"].as_str().unwrap_or_default().to_owned();
586 let timeout = match args.get("timeout").and_then(Value::as_f64) {
587 None => None,
588 Some(seconds) if !seconds.is_finite() || seconds <= 0.0 => {
589 return Err("Invalid timeout: must be a finite number of seconds".into());
590 }
591 Some(seconds) if seconds * 1000.0 > MAX_TIMEOUT_MS => {
592 return Err(format!(
593 "Invalid timeout: maximum is {} seconds",
594 MAX_TIMEOUT_MS / 1000.0
595 ));
596 }
597 Some(seconds) => Some(seconds),
598 };
599 let cwd = &self.env.cwd;
600 if cancel.is_cancelled() {
601 return Err("Command aborted".into());
602 }
603 let runtime = self.env.runtime();
604 let shell = shell_with(runtime.shell_path.as_deref())?;
605 if !cwd.exists() {
606 return Err(format!(
607 "Working directory does not exist: {}\nCannot execute bash commands.",
608 cwd.display()
609 ));
610 }
611
612 let command = with_prefix(runtime.shell_command_prefix.as_deref(), &command);
613 let mut process = shell_command(shell, &command, cwd, &self.env.bin_dir);
614 if let Some(id) = &runtime.session_id {
615 process.env("PI_SESSION_ID", id);
616 }
617 if let Some(file) = &runtime.session_file {
618 process.env("PI_SESSION_FILE", file);
619 }
620 if let Some(model) = &runtime.model {
621 process.env("PI_PROVIDER", &model.provider);
622 process.env("PI_MODEL", &model.id);
623 }
624 if let Some(level) = runtime.thinking_level {
625 process.env("PI_REASONING_LEVEL", level.as_str());
626 }
627 let child = process.spawn().map_err(|err| err.to_string())?;
628
629 updates(ToolResult::default());
630 let started = Instant::now();
631 let mut output = Accumulator::new();
632 let mut last_update = Instant::now() - UPDATE_THROTTLE;
633 let mut dirty = false;
634 let deadline = timeout.map(|seconds| started + Duration::from_secs_f64(seconds));
635 let (status, stop) = run_child(child, &cancel, deadline, |event| match event {
636 Event::Output(bytes) => {
637 output.append(bytes);
638 dirty = true;
639 Some(last_update + UPDATE_THROTTLE)
640 }
641 Event::Tick => {
642 dirty = false;
643 last_update = Instant::now();
644 let (truncation, path) = output.snapshot(true);
645 updates(progress(&truncation, path.as_deref()));
646 None
647 }
648 })
649 .await;
650 output.finish();
651 let (truncation, path) = output.snapshot(true);
652 if dirty {
654 updates(progress(&truncation, path.as_deref()));
655 }
656 match stop {
657 Some(Stop::Aborted) => {
658 let (text, _) =
659 format_output(&truncation, &path, output.current_line_bytes, "");
660 Err(with_status(&text, "Command aborted"))
661 }
662 Some(Stop::TimedOut) => {
663 let (text, _) =
664 format_output(&truncation, &path, output.current_line_bytes, "");
665 let seconds = yapi_types::json::to_string(&timeout.unwrap_or_default())
666 .unwrap_or_default();
667 Err(with_status(
668 &text,
669 &format!("Command timed out after {seconds} seconds"),
670 ))
671 }
672 None => {
673 let (text, details) =
674 format_output(&truncation, &path, output.current_line_bytes, "(no output)");
675 let Some(code) = status.and_then(exit_code_of) else {
676 return Err(with_status(
677 &text,
678 "Command terminated without an exit code",
679 ));
680 };
681 let wall = (started.elapsed().as_secs_f64() * 10.0).round() / 10.0;
682 let (full, full_truncated) = output.full_output();
683 let mut structured = json!({"output": full, "truncated": full_truncated});
684 if full_truncated && let Some(path) = &path {
685 structured["full_output_path"] = json!(path.display().to_string());
686 }
687 structured["exit_code"] = json!(code);
688 structured["wall_time_seconds"] = json!(wall);
689 let mut result = text_result(
690 if code == 0 {
691 text
692 } else {
693 with_status(&text, &format!("Command exited with code {code}"))
694 },
695 details,
696 );
697 result.structured_content = Some(structured);
698 if code != 0 {
699 result.is_error = Some(true);
700 }
701 Ok(result)
702 }
703 }
704 })
705 }
706}