1use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex, PoisonError};
23use std::time::Duration;
24
25use async_trait::async_trait;
26use serde_json::{Value, json};
27
28use rhema_core::{
29 OutputOnly, ToolCall, ToolContext, ToolProgress, ToolResolver, ToolResult, ToolSpec,
30};
31
32use crate::approval::Approval;
33use crate::exec::{Executed, ShellError, ShellExecutor};
34
35const BASH_OUTPUT_LIMIT: usize = 50 * 1024;
39const BASH_OUTPUT_LINES: usize = 2000;
40const BASH_TIMEOUT: Duration = Duration::from_secs(120);
42
43pub struct ShellTools {
46 workspace: PathBuf,
48 exec: Arc<dyn ShellExecutor>,
51 vision_inbox: Option<PathBuf>,
57 pending: Mutex<Option<Arc<dyn Approval>>>,
60}
61
62impl ShellTools {
63 pub fn builtin_specs() -> Vec<ToolSpec> {
68 vec![ToolSpec::new(
69 "bash",
70 "Run a shell command (`bash -c`) with the workspace root as \
71 working directory. Reading, listing, and editing files are \
72 shell work. Output is capped and the exit status reported; a \
73 non-zero exit comes back as an error. Requires user approval.",
74 json!({
75 "type": "object",
76 "properties": {
77 "command": {
78 "type": "string",
79 "description": "The shell command to run."
80 }
81 },
82 "required": ["command"]
83 }),
84 )]
85 }
86
87 pub fn new(workspace: impl Into<PathBuf>) -> Self {
94 Self::with_executor(workspace, crate::exec::default_executor())
95 }
96
97 pub fn with_env(workspace: impl Into<PathBuf>, env: &BTreeMap<String, String>) -> Self {
106 Self::with_executor(workspace, crate::exec::default_executor_with(env))
107 .with_vision_inbox(env.get(crate::vision::VAR_STAGING).map(PathBuf::from))
108 }
109
110 pub fn with_executor(workspace: impl Into<PathBuf>, exec: Arc<dyn ShellExecutor>) -> Self {
113 Self {
114 workspace: canonicalized(workspace.into()),
115 exec,
116 vision_inbox: None,
117 pending: Mutex::new(None),
118 }
119 }
120
121 pub fn with_vision_inbox(mut self, inbox: Option<PathBuf>) -> Self {
126 self.vision_inbox = inbox;
127 self
128 }
129
130 pub fn vision_inbox(&self) -> Option<&Path> {
133 self.vision_inbox.as_deref()
134 }
135
136 pub fn workspace(&self) -> &Path {
138 &self.workspace
139 }
140
141 pub fn executor(&self) -> Arc<dyn ShellExecutor> {
146 self.exec.clone()
147 }
148
149 pub fn install_approval(&self, approval: Arc<dyn Approval>) {
153 *self.pending.lock().unwrap_or_else(PoisonError::into_inner) = Some(approval);
154 }
155
156 pub fn current_approval(&self) -> Option<Arc<dyn Approval>> {
161 self.pending
162 .lock()
163 .unwrap_or_else(PoisonError::into_inner)
164 .clone()
165 }
166
167 pub fn clear_approval(&self) {
170 *self.pending.lock().unwrap_or_else(PoisonError::into_inner) = None;
171 }
172
173 fn approval_gate(&self, call: &ToolCall) -> Option<ToolResult> {
175 let approved = self
176 .pending
177 .lock()
178 .unwrap_or_else(PoisonError::into_inner)
179 .as_ref()
180 .is_some_and(|approval| approval.approve(call));
181 if approved {
182 None
183 } else {
184 Some(ToolResult::failure(&call.id, "rejected by user approval"))
185 }
186 }
187
188 async fn bash(
189 &self,
190 call: ToolCall,
191 cancelled: &AtomicBool,
192 progress: &mut dyn ToolProgress,
193 ) -> ToolResult {
194 if let Some(rejection) = self.approval_gate(&call) {
195 return rejection;
196 }
197 let Some(command) = arg_str(&call.args, "command") else {
198 return ToolResult::failure(call.id, "missing required argument: command");
199 };
200 match self
203 .exec
204 .run(
205 &command,
206 &self.workspace,
207 BASH_TIMEOUT,
208 cancelled,
209 &mut |chunk: &str| progress.output(chunk),
210 )
211 .await
212 {
213 Ok(executed) => {
214 let mut result = finished(call.id, executed);
215 if let Some(staging) = &self.vision_inbox {
216 crate::vision::attach(staging, &mut result);
217 }
218 result
219 }
220 Err(ShellError::Timeout(secs)) => ToolResult::failure(
221 call.id,
222 format!("command timed out after {secs}s and was killed"),
223 ),
224 Err(ShellError::Cancelled) => ToolResult::failure(call.id, "cancelled"),
225 Err(err) => ToolResult::failure(call.id, err.to_string()),
226 }
227 }
228}
229
230fn finished(id: String, executed: Executed) -> ToolResult {
233 let content = format_command_output(executed.code, &executed.stdout, &executed.stderr);
234 let result = if executed.code == Some(0) {
235 ToolResult::success(id, content)
236 } else {
237 ToolResult::failure(id, content)
238 };
239 match executed.code {
240 Some(code) => result.with_code(code),
241 None => result,
242 }
243}
244
245#[async_trait]
246impl ToolResolver for ShellTools {
247 fn specs(&self) -> Vec<ToolSpec> {
248 Self::builtin_specs()
249 }
250
251 async fn resolve(&self, call: ToolCall, ctx: &ToolContext) -> ToolResult {
252 self.resolve_streaming(call, ctx, &mut OutputOnly(silent))
255 .await
256 }
257
258 async fn resolve_streaming(
259 &self,
260 call: ToolCall,
261 ctx: &ToolContext,
262 progress: &mut dyn ToolProgress,
263 ) -> ToolResult {
264 if ctx.cancelled.load(Ordering::Relaxed) {
265 return ToolResult::failure(call.id, "cancelled");
266 }
267 let name = call.name.clone();
269 match name.as_str() {
270 "bash" => self.bash(call, &ctx.cancelled, progress).await,
271 other => ToolResult::failure(call.id, format!("unknown tool: {other}")),
272 }
273 }
274}
275
276fn arg_str(args: &Value, key: &str) -> Option<String> {
277 args.get(key).and_then(Value::as_str).map(str::to_owned)
278}
279
280fn silent(_: &str) {}
283
284fn canonicalized(path: PathBuf) -> PathBuf {
287 std::fs::canonicalize(&path).unwrap_or(path)
288}
289
290fn render_truncated(bytes: &[u8], limit: usize, max_lines: usize) -> String {
296 let line_count = bytes.iter().filter(|&&byte| byte == b'\n').count();
297 if bytes.len() <= limit && line_count <= max_lines {
298 return String::from_utf8_lossy(bytes).into_owned();
299 }
300 let byte_cut = bytes.len().saturating_sub(limit);
301 let byte_cut = match bytes[..byte_cut].iter().rposition(|&byte| byte == b'\n') {
305 Some(pos) => pos + 1,
306 None => byte_cut,
307 };
308 let mut line_cut = 0usize;
311 let mut seen = 0usize;
312 for (index, &byte) in bytes.iter().enumerate().rev() {
313 if byte == b'\n' {
314 seen += 1;
315 if seen == max_lines {
316 line_cut = index + 1;
317 break;
318 }
319 }
320 }
321 let cut = byte_cut.max(line_cut);
322 let mut text = format!("[output truncated — {} bytes elided]\n", cut);
323 text.push_str(&String::from_utf8_lossy(&bytes[cut..]));
324 text
325}
326
327fn format_command_output(code: Option<i32>, stdout: &[u8], stderr: &[u8]) -> String {
328 let code = match code {
329 Some(code) => format!("exit code {code}"),
330 None => "terminated by signal".to_string(),
331 };
332 format!(
333 "{code}\n--- stdout ---\n{}\n--- stderr ---\n{}",
334 render_truncated(stdout, BASH_OUTPUT_LIMIT, BASH_OUTPUT_LINES),
335 render_truncated(stderr, BASH_OUTPUT_LIMIT, BASH_OUTPUT_LINES),
336 )
337}
338
339#[cfg(test)]
340mod truncation_tests {
341 use super::*;
342
343 #[test]
344 fn truncation_keeps_the_tail_and_counts_the_elided_head() {
345 let bytes = b"head line\nerror: the real failure\n".to_vec();
346 let text = render_truncated(&bytes, 10, 2000);
347 assert!(text.starts_with("[output truncated"), "{text}");
348 assert!(text.contains("error: the real failure"), "{text}");
349 assert!(!text.contains("head line"), "{text}");
351 }
352
353 #[test]
354 fn a_single_line_longer_than_the_budget_is_kept_partially() {
355 let bytes = vec![b'a'; 100_000];
359 let text = render_truncated(&bytes, 1024, 2000);
360 assert!(
361 text.starts_with("[output truncated — 98976 bytes elided]"),
362 "{text}"
363 );
364 let kept = text.split_once('\n').map(|(_, tail)| tail).unwrap_or("");
365 assert_eq!(kept.len(), 1024, "kept tail: {} bytes", kept.len());
366 }
367
368 #[test]
369 fn the_line_limit_cuts_more_rows_than_the_byte_limit() {
370 let bytes = (0..3000).map(|i| format!("row {i}\n")).collect::<String>();
371 let text = render_truncated(bytes.as_bytes(), 50 * 1024, 2000);
372 assert!(text.starts_with("[output truncated — "), "{text}");
375 assert!(text.contains("row 1001\n"), "{text}");
376 assert!(text.contains("row 2999\n"), "{text}");
377 assert!(!text.contains("row 1000\n"), "{text}");
378 assert!(!text.contains("row 999\n"), "{text}");
379 }
380
381 #[test]
382 fn output_within_the_limits_passes_untouched() {
383 assert_eq!(render_truncated(b"clean\n", 10, 2000), "clean\n");
384 }
385
386 #[test]
387 fn a_signalled_command_reports_termination_not_an_exit_code() {
388 let text = format_command_output(None, b"partial\n", b"");
389 assert!(text.starts_with("terminated by signal"), "{text}");
390 }
391}
392
393#[cfg(test)]
394mod executor_tests {
395 use super::*;
396 use crate::approval::AllowAll;
397 use serde_json::json;
398 use std::sync::Mutex;
399
400 struct Scripted {
403 outcome: Result<Executed, ShellError>,
404 commands: Mutex<Vec<String>>,
405 streamed: Mutex<Vec<String>>,
406 }
407
408 impl Scripted {
409 fn ok(stdout: &str) -> Self {
410 Self {
411 outcome: Ok(Executed {
412 stdout: stdout.as_bytes().to_vec(),
413 stderr: Vec::new(),
414 code: Some(0),
415 }),
416 commands: Mutex::new(Vec::new()),
417 streamed: Mutex::new(Vec::new()),
418 }
419 }
420 }
421
422 #[async_trait]
423 impl ShellExecutor for Scripted {
424 async fn run(
425 &self,
426 command: &str,
427 _cwd: &Path,
428 _timeout: Duration,
429 _cancelled: &AtomicBool,
430 progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
431 ) -> Result<Executed, ShellError> {
432 self.commands
433 .lock()
434 .unwrap_or_else(PoisonError::into_inner)
435 .push(command.to_owned());
436 progress("streamed line\n");
437 self.streamed
438 .lock()
439 .unwrap_or_else(PoisonError::into_inner)
440 .push("streamed line\n".to_owned());
441 self.outcome.clone()
442 }
443 }
444
445 fn call(command: &str) -> ToolCall {
446 ToolCall {
447 id: "call_1".into(),
448 name: "bash".into(),
449 args: json!({ "command": command }),
450 provider_data: Value::Null,
451 }
452 }
453
454 fn streamed(executor: &Scripted) -> Vec<String> {
455 executor
456 .streamed
457 .lock()
458 .unwrap_or_else(PoisonError::into_inner)
459 .clone()
460 }
461
462 #[tokio::test]
463 async fn a_result_streams_progress_and_carries_the_output() {
464 let executor = Arc::new(Scripted::ok("out\n"));
465 let tools = ShellTools::with_executor("/tmp", executor.clone());
466 tools.install_approval(Arc::new(AllowAll));
467 let mut progress = OutputOnly(silent);
468 let result = tools
469 .resolve_streaming(call("echo hi"), &ToolContext::default(), &mut progress)
470 .await;
471 assert!(!result.is_error);
472 assert_eq!(result.code, Some(0));
473 assert!(
474 result.content.contains("--- stdout ---\nout\n"),
475 "{result:?}"
476 );
477 assert_eq!(executor.commands.lock().unwrap().as_slice(), ["echo hi"]);
480 assert_eq!(streamed(&executor), vec!["streamed line\n"]);
481 }
482
483 #[tokio::test]
484 async fn every_shell_error_lands_as_an_in_band_failure() {
485 let cases: Vec<(ShellError, &str)> = vec![
486 (
487 ShellError::Timeout(120),
488 "command timed out after 120s and was killed",
489 ),
490 (ShellError::Cancelled, "cancelled"),
491 (
492 ShellError::Failed("host unreachable".into()),
493 "failed to run bash: host unreachable",
494 ),
495 ];
496 for (outcome, expected) in cases {
497 let executor = Arc::new(Scripted {
498 outcome: Err(outcome),
499 commands: Mutex::new(Vec::new()),
500 streamed: Mutex::new(Vec::new()),
501 });
502 let tools = ShellTools::with_executor("/tmp", executor);
503 tools.install_approval(Arc::new(AllowAll));
504 let result = tools.resolve(call("x"), &ToolContext::default()).await;
505 assert!(result.is_error, "{expected}");
506 assert_eq!(result.content, expected);
507 }
508 }
509
510 #[tokio::test]
511 async fn a_non_zero_exit_is_an_error_result_with_the_code() {
512 let executor = Arc::new(Scripted {
513 outcome: Ok(Executed {
514 stdout: b"boom\n".to_vec(),
515 stderr: b"trace\n".to_vec(),
516 code: Some(3),
517 }),
518 commands: Mutex::new(Vec::new()),
519 streamed: Mutex::new(Vec::new()),
520 });
521 let tools = ShellTools::with_executor("/tmp", executor);
522 tools.install_approval(Arc::new(AllowAll));
523 let result = tools.resolve(call("exit 3"), &ToolContext::default()).await;
524 assert!(result.is_error);
525 assert_eq!(result.code, Some(3));
526 assert!(result.content.contains("exit code 3"));
527 assert!(result.content.contains("--- stderr ---\ntrace\n"));
528 }
529
530 #[tokio::test]
531 async fn the_inner_shell_shares_the_executor_of_its_parent() {
532 let executor = Arc::new(Scripted::ok(""));
535 let tools = ShellTools::with_executor("/tmp", executor);
536 let inner = ShellTools::with_executor(tools.workspace().to_path_buf(), tools.executor());
537 assert_eq!(inner.workspace(), tools.workspace());
538 assert!(Arc::ptr_eq(&inner.executor(), &tools.executor()));
539 }
540
541 #[tokio::test]
542 async fn staged_images_attach_to_the_command_that_staged_them() {
543 let staging = tempfile::tempdir().unwrap();
544 std::fs::write(staging.path().join("1.img"), b"\x89PNG\r\n\x1a\nrest").unwrap();
547 std::fs::write(staging.path().join("2.img"), b"not an image").unwrap();
548 let mut env = BTreeMap::new();
549 env.insert(
550 crate::vision::VAR_STAGING.to_owned(),
551 staging.path().display().to_string(),
552 );
553 let tools = ShellTools::with_env("/tmp", &env);
554 tools.install_approval(Arc::new(AllowAll));
555 let result = tools.resolve(call("see x"), &ToolContext::default()).await;
556
557 assert_eq!(result.images.len(), 1, "{}", result.content);
558 assert_eq!(result.images[0].media_type, rhema_core::ImageMediaType::Png);
559 assert!(
560 result
561 .content
562 .contains("1 image(s) attached and visible to you"),
563 "{}",
564 result.content
565 );
566 assert!(
567 result.content.contains("not a recognized image"),
568 "{}",
569 result.content
570 );
571 assert!(tools.vision_inbox().is_some());
572 assert!(
573 std::fs::read_dir(staging.path()).unwrap().next().is_none(),
574 "the staging dir drained"
575 );
576
577 let tools = ShellTools::with_env("/tmp", &BTreeMap::new());
579 assert_eq!(tools.vision_inbox(), None);
580 }
581}
582
583#[cfg(all(test, feature = "local-shell"))]
584mod local_process_tests {
585 use super::*;
586 use crate::approval::AllowAll;
587 use serde_json::json;
588
589 fn call(command: &str) -> ToolCall {
590 ToolCall {
591 id: "call_1".into(),
592 name: "bash".into(),
593 args: json!({ "command": command }),
594 provider_data: Value::Null,
595 }
596 }
597
598 #[tokio::test]
599 async fn a_command_streams_its_lines_before_its_result() {
600 let tools = ShellTools::new(std::env::temp_dir());
601 tools.install_approval(Arc::new(AllowAll));
602 let streamed = Arc::new(Mutex::new(Vec::new()));
603 let collector = streamed.clone();
604 let mut progress = OutputOnly(move |chunk: &str| {
605 collector
606 .lock()
607 .unwrap_or_else(PoisonError::into_inner)
608 .push(chunk.to_owned());
609 });
610 let result = tools
611 .resolve_streaming(
612 call("printf 'one\\ntwo\\n'; echo three"),
613 &ToolContext::default(),
614 &mut progress,
615 )
616 .await;
617
618 assert_eq!(
620 *streamed.lock().unwrap_or_else(PoisonError::into_inner),
621 vec!["one\n".to_owned(), "two\n".to_owned(), "three\n".to_owned()]
622 );
623 assert!(!result.is_error);
626 assert!(result.content.contains("--- stdout ---\n"));
627 assert!(result.content.contains("one\ntwo\nthree"));
628 tools.clear_approval();
629 }
630
631 #[tokio::test]
632 async fn stderr_streams_too_and_the_exit_code_rides_the_result() {
633 let tools = ShellTools::new(std::env::temp_dir());
634 tools.install_approval(Arc::new(AllowAll));
635 let streamed = Arc::new(Mutex::new(Vec::new()));
636 let collector = streamed.clone();
637 let mut progress = OutputOnly(move |chunk: &str| {
638 collector
639 .lock()
640 .unwrap_or_else(PoisonError::into_inner)
641 .push(chunk.to_owned());
642 });
643 let result = tools
644 .resolve_streaming(
645 call("echo oops >&2; exit 3"),
646 &ToolContext::default(),
647 &mut progress,
648 )
649 .await;
650
651 assert_eq!(
652 *streamed.lock().unwrap_or_else(PoisonError::into_inner),
653 vec!["oops\n".to_owned()]
654 );
655 assert!(result.is_error);
656 assert_eq!(result.code, Some(3));
657 assert!(result.content.contains("oops"));
658 tools.clear_approval();
659 }
660
661 #[tokio::test]
662 async fn a_cancelled_command_stops_mid_run() {
663 let tools = ShellTools::new(std::env::temp_dir());
664 tools.install_approval(Arc::new(AllowAll));
665 let ctx = ToolContext::default();
666 ctx.cancelled.store(true, Ordering::Relaxed);
667 let result = tools.resolve(call("sleep 30"), &ctx).await;
668 assert!(result.is_error);
669 assert_eq!(result.content, "cancelled");
670 tools.clear_approval();
671 }
672
673 #[test]
674 fn the_installed_hook_reads_back_until_cleared() {
675 struct Spy;
676 impl Approval for Spy {
677 fn approve(&self, _call: &ToolCall) -> bool {
678 true
679 }
680 }
681
682 let tools = ShellTools::new("/tmp");
683 assert!(
684 tools.current_approval().is_none(),
685 "no hook installed: none readable"
686 );
687 let hook: Arc<dyn Approval> = Arc::new(Spy);
688 tools.install_approval(hook.clone());
689 let read = tools.current_approval().unwrap();
690 let probe = ToolCall {
692 name: "bash".into(),
693 ..ToolCall::default()
694 };
695 assert!(read.approve(&probe));
696 tools.clear_approval();
697 assert!(tools.current_approval().is_none());
698 }
699}