1use crate::builtins::Builtin;
35use crate::error::Error;
36use crate::{Bash, ExecResult, ExecutionLimits};
37use async_trait::async_trait;
38use schemars::{schema_for, JsonSchema};
39use serde::{Deserialize, Serialize};
40
41pub const VERSION: &str = env!("CARGO_PKG_VERSION");
43
44const BUILTINS: &str = "echo cat grep sed awk jq curl head tail sort uniq cut tr wc date sleep mkdir rm cp mv touch chmod printf test [ true false exit cd pwd ls find xargs basename dirname env export read";
46
47const BASE_HELP: &str = r#"BASH(1) User Commands BASH(1)
49
50NAME
51 bashkit - sandboxed bash-like interpreter with virtual filesystem
52
53SYNOPSIS
54 {"commands": "<bash commands>"}
55
56DESCRIPTION
57 Bashkit executes bash commands in an isolated sandbox with a virtual
58 filesystem. All file operations are contained within the sandbox.
59
60 Supports full bash syntax including variables, pipelines, redirects,
61 loops, conditionals, functions, and arrays.
62
63BUILTINS
64 echo, cat, grep, sed, awk, jq, curl, head, tail, sort, uniq, cut, tr,
65 wc, date, sleep, mkdir, rm, cp, mv, touch, chmod, printf, test, [,
66 true, false, exit, cd, pwd, ls, find, xargs, basename, dirname, env,
67 export, read
68
69INPUT
70 commands Bash commands to execute (like bash -c "commands")
71
72OUTPUT
73 stdout Standard output from the commands
74 stderr Standard error from the commands
75 exit_code Exit status (0 = success)
76
77EXAMPLES
78 Simple echo:
79 {"commands": "echo 'Hello, World!'"}
80
81 Arithmetic:
82 {"commands": "x=5; y=3; echo $((x + y))"}
83
84 Pipeline:
85 {"commands": "echo -e 'apple\nbanana' | grep a"}
86
87 JSON processing:
88 {"commands": "echo '{\"n\":1}' | jq '.n'"}
89
90 File operations (virtual):
91 {"commands": "echo data > /tmp/f.txt && cat /tmp/f.txt"}
92
93 Run script from VFS:
94 {"commands": "source /path/to/script.sh"}
95
96EXIT STATUS
97 0 Success
98 1-125 Command-specific error
99 126 Command not executable
100 127 Command not found
101
102SEE ALSO
103 bash(1), sh(1)
104"#;
105
106#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
110pub struct ToolRequest {
111 pub commands: String,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
117pub struct ToolResponse {
118 pub stdout: String,
120 pub stderr: String,
122 pub exit_code: i32,
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub error: Option<String>,
127}
128
129impl From<ExecResult> for ToolResponse {
130 fn from(result: ExecResult) -> Self {
131 Self {
132 stdout: result.stdout,
133 stderr: result.stderr,
134 exit_code: result.exit_code,
135 error: None,
136 }
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ToolStatus {
143 pub phase: String,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub message: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub percent_complete: Option<f32>,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub eta_ms: Option<u64>,
154}
155
156impl ToolStatus {
157 pub fn new(phase: impl Into<String>) -> Self {
159 Self {
160 phase: phase.into(),
161 message: None,
162 percent_complete: None,
163 eta_ms: None,
164 }
165 }
166
167 pub fn with_message(mut self, message: impl Into<String>) -> Self {
169 self.message = Some(message.into());
170 self
171 }
172
173 pub fn with_percent(mut self, percent: f32) -> Self {
175 self.percent_complete = Some(percent);
176 self
177 }
178
179 pub fn with_eta(mut self, eta_ms: u64) -> Self {
181 self.eta_ms = Some(eta_ms);
182 self
183 }
184}
185
186#[async_trait]
204pub trait Tool: Send + Sync {
205 fn name(&self) -> &str;
207
208 fn short_description(&self) -> &str;
210
211 fn description(&self) -> String;
213
214 fn help(&self) -> String;
216
217 fn system_prompt(&self) -> String;
219
220 fn input_schema(&self) -> serde_json::Value;
222
223 fn output_schema(&self) -> serde_json::Value;
225
226 fn version(&self) -> &str;
228
229 async fn execute(&mut self, req: ToolRequest) -> ToolResponse;
231
232 async fn execute_with_status(
234 &mut self,
235 req: ToolRequest,
236 status_callback: Box<dyn FnMut(ToolStatus) + Send>,
237 ) -> ToolResponse;
238}
239
240#[derive(Default)]
246pub struct BashToolBuilder {
247 username: Option<String>,
249 hostname: Option<String>,
251 limits: Option<ExecutionLimits>,
253 env_vars: Vec<(String, String)>,
255 builtins: Vec<(String, Box<dyn Builtin>)>,
257}
258
259impl BashToolBuilder {
260 pub fn new() -> Self {
262 Self::default()
263 }
264
265 pub fn username(mut self, username: impl Into<String>) -> Self {
267 self.username = Some(username.into());
268 self
269 }
270
271 pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
273 self.hostname = Some(hostname.into());
274 self
275 }
276
277 pub fn limits(mut self, limits: ExecutionLimits) -> Self {
279 self.limits = Some(limits);
280 self
281 }
282
283 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
285 self.env_vars.push((key.into(), value.into()));
286 self
287 }
288
289 pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
296 self.builtins.push((name.into(), builtin));
297 self
298 }
299
300 #[cfg(feature = "python")]
307 pub fn python(self) -> Self {
308 self.python_with_limits(crate::builtins::PythonLimits::default())
309 }
310
311 #[cfg(feature = "python")]
313 pub fn python_with_limits(self, limits: crate::builtins::PythonLimits) -> Self {
314 use crate::builtins::Python;
315 self.builtin("python", Box::new(Python::with_limits(limits.clone())))
316 .builtin("python3", Box::new(Python::with_limits(limits)))
317 }
318
319 pub fn build(self) -> BashTool {
321 let builtin_names: Vec<String> = self.builtins.iter().map(|(n, _)| n.clone()).collect();
322
323 let mut builtin_hints: Vec<String> = self
325 .builtins
326 .iter()
327 .filter_map(|(_, b)| b.llm_hint().map(String::from))
328 .collect();
329 builtin_hints.sort();
330 builtin_hints.dedup();
331
332 BashTool {
333 username: self.username,
334 hostname: self.hostname,
335 limits: self.limits,
336 env_vars: self.env_vars,
337 builtins: self.builtins,
338 builtin_names,
339 builtin_hints,
340 }
341 }
342}
343
344#[derive(Default)]
346pub struct BashTool {
347 username: Option<String>,
348 hostname: Option<String>,
349 limits: Option<ExecutionLimits>,
350 env_vars: Vec<(String, String)>,
351 builtins: Vec<(String, Box<dyn Builtin>)>,
352 builtin_names: Vec<String>,
354 builtin_hints: Vec<String>,
356}
357
358impl BashTool {
359 pub fn builder() -> BashToolBuilder {
361 BashToolBuilder::new()
362 }
363
364 fn create_bash(&mut self) -> Bash {
366 let mut builder = Bash::builder();
367
368 if let Some(ref username) = self.username {
369 builder = builder.username(username);
370 }
371 if let Some(ref hostname) = self.hostname {
372 builder = builder.hostname(hostname);
373 }
374 if let Some(ref limits) = self.limits {
375 builder = builder.limits(limits.clone());
376 }
377 for (key, value) in &self.env_vars {
378 builder = builder.env(key, value);
379 }
380 for (name, builtin) in std::mem::take(&mut self.builtins) {
382 builder = builder.builtin(name, builtin);
383 }
384
385 builder.build()
386 }
387
388 fn build_description(&self) -> String {
390 let mut desc = String::from(
391 "Sandboxed bash-like interpreter with virtual filesystem. Supported tools: ",
392 );
393 desc.push_str(BUILTINS);
394 if !self.builtin_names.is_empty() {
395 desc.push(' ');
396 desc.push_str(&self.builtin_names.join(" "));
397 }
398 desc
399 }
400
401 fn build_help(&self) -> String {
403 let mut doc = BASE_HELP.to_string();
404
405 let has_config = !self.builtin_names.is_empty()
407 || self.username.is_some()
408 || self.hostname.is_some()
409 || self.limits.is_some()
410 || !self.env_vars.is_empty();
411
412 if has_config {
413 doc.push_str("\nCONFIGURATION\n");
414
415 if !self.builtin_names.is_empty() {
416 doc.push_str(" Custom commands: ");
417 doc.push_str(&self.builtin_names.join(", "));
418 doc.push('\n');
419 }
420
421 if let Some(ref username) = self.username {
422 doc.push_str(&format!(" User: {} (whoami)\n", username));
423 }
424 if let Some(ref hostname) = self.hostname {
425 doc.push_str(&format!(" Host: {} (hostname)\n", hostname));
426 }
427
428 if let Some(ref limits) = self.limits {
429 doc.push_str(&format!(
430 " Limits: {} commands, {} iterations, {} depth\n",
431 limits.max_commands, limits.max_loop_iterations, limits.max_function_depth
432 ));
433 }
434
435 if !self.env_vars.is_empty() {
436 doc.push_str(" Environment: ");
437 let keys: Vec<&str> = self.env_vars.iter().map(|(k, _)| k.as_str()).collect();
438 doc.push_str(&keys.join(", "));
439 doc.push('\n');
440 }
441 }
442
443 if !self.builtin_hints.is_empty() {
445 doc.push_str("\nNOTES\n");
446 for hint in &self.builtin_hints {
447 doc.push_str(&format!(" {hint}\n"));
448 }
449 }
450
451 if let Some(warning) = self.language_warning() {
453 doc.push_str(&format!("\nWARNINGS\n {warning}\n"));
454 }
455
456 doc
457 }
458
459 fn language_warning(&self) -> Option<String> {
462 let mut missing = Vec::new();
463
464 let has_perl = self.builtin_names.iter().any(|n| n == "perl");
465 if !has_perl {
466 missing.push("perl");
467 }
468
469 let has_python = self
470 .builtin_names
471 .iter()
472 .any(|n| n == "python" || n == "python3");
473 if !has_python {
474 missing.push("python/python3");
475 }
476
477 if missing.is_empty() {
478 None
479 } else {
480 Some(format!("{} not available.", missing.join(", ")))
481 }
482 }
483
484 fn build_system_prompt(&self) -> String {
486 let mut prompt = String::from("# Bash Tool\n\n");
487
488 prompt.push_str("Sandboxed bash-like interpreter with virtual filesystem.\n");
490
491 if let Some(ref username) = self.username {
493 prompt.push_str(&format!("Home: /home/{}\n", username));
494 }
495
496 prompt.push('\n');
497
498 prompt.push_str("Input: {\"commands\": \"<bash commands>\"}\n");
500 prompt.push_str("Output: {stdout, stderr, exit_code}\n");
501
502 if !self.builtin_hints.is_empty() {
504 prompt.push('\n');
505 for hint in &self.builtin_hints {
506 prompt.push_str(&format!("Note: {hint}\n"));
507 }
508 }
509
510 if let Some(warning) = self.language_warning() {
512 prompt.push_str(&format!("\nWarning: {warning}\n"));
513 }
514
515 prompt
516 }
517}
518
519#[async_trait]
520impl Tool for BashTool {
521 fn name(&self) -> &str {
522 "bashkit"
523 }
524
525 fn short_description(&self) -> &str {
526 "Sandboxed bash interpreter with virtual filesystem"
527 }
528
529 fn description(&self) -> String {
530 self.build_description()
531 }
532
533 fn help(&self) -> String {
534 self.build_help()
535 }
536
537 fn system_prompt(&self) -> String {
538 self.build_system_prompt()
539 }
540
541 fn input_schema(&self) -> serde_json::Value {
542 let schema = schema_for!(ToolRequest);
543 serde_json::to_value(schema).unwrap_or_default()
544 }
545
546 fn output_schema(&self) -> serde_json::Value {
547 let schema = schema_for!(ToolResponse);
548 serde_json::to_value(schema).unwrap_or_default()
549 }
550
551 fn version(&self) -> &str {
552 VERSION
553 }
554
555 async fn execute(&mut self, req: ToolRequest) -> ToolResponse {
556 if req.commands.is_empty() {
557 return ToolResponse {
558 stdout: String::new(),
559 stderr: String::new(),
560 exit_code: 0,
561 error: None,
562 };
563 }
564
565 let mut bash = self.create_bash();
566
567 match bash.exec(&req.commands).await {
568 Ok(result) => result.into(),
569 Err(e) => ToolResponse {
570 stdout: String::new(),
571 stderr: e.to_string(),
572 exit_code: 1,
573 error: Some(error_kind(&e)),
574 },
575 }
576 }
577
578 async fn execute_with_status(
579 &mut self,
580 req: ToolRequest,
581 mut status_callback: Box<dyn FnMut(ToolStatus) + Send>,
582 ) -> ToolResponse {
583 status_callback(ToolStatus::new("validate").with_percent(0.0));
584
585 if req.commands.is_empty() {
586 status_callback(ToolStatus::new("complete").with_percent(100.0));
587 return ToolResponse {
588 stdout: String::new(),
589 stderr: String::new(),
590 exit_code: 0,
591 error: None,
592 };
593 }
594
595 status_callback(ToolStatus::new("parse").with_percent(10.0));
596
597 let mut bash = self.create_bash();
598
599 status_callback(ToolStatus::new("execute").with_percent(20.0));
600
601 let response = match bash.exec(&req.commands).await {
602 Ok(result) => result.into(),
603 Err(e) => ToolResponse {
604 stdout: String::new(),
605 stderr: e.to_string(),
606 exit_code: 1,
607 error: Some(error_kind(&e)),
608 },
609 };
610
611 status_callback(ToolStatus::new("complete").with_percent(100.0));
612
613 response
614 }
615}
616
617fn error_kind(e: &Error) -> String {
619 match e {
620 Error::Parse(_) | Error::ParseAt { .. } => "parse_error".to_string(),
621 Error::Execution(_) => "execution_error".to_string(),
622 Error::Io(_) => "io_error".to_string(),
623 Error::CommandNotFound(_) => "command_not_found".to_string(),
624 Error::ResourceLimit(_) => "resource_limit".to_string(),
625 Error::Network(_) => "network_error".to_string(),
626 Error::Internal(_) => "internal_error".to_string(),
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 #[test]
635 fn test_bash_tool_builder() {
636 let tool = BashTool::builder()
637 .username("testuser")
638 .hostname("testhost")
639 .env("FOO", "bar")
640 .limits(ExecutionLimits::new().max_commands(100))
641 .build();
642
643 assert_eq!(tool.username, Some("testuser".to_string()));
644 assert_eq!(tool.hostname, Some("testhost".to_string()));
645 assert_eq!(tool.env_vars, vec![("FOO".to_string(), "bar".to_string())]);
646 }
647
648 #[test]
649 fn test_tool_trait_methods() {
650 let tool = BashTool::default();
651
652 assert_eq!(tool.name(), "bashkit");
654 assert_eq!(
655 tool.short_description(),
656 "Sandboxed bash interpreter with virtual filesystem"
657 );
658 assert!(tool
659 .description()
660 .contains("Sandboxed bash-like interpreter"));
661 assert!(tool.description().contains("Supported tools:"));
662 assert!(tool.help().contains("BASH(1)"));
663 assert!(tool.help().contains("SYNOPSIS"));
664 assert!(tool.system_prompt().contains("# Bash Tool"));
665 assert_eq!(tool.version(), VERSION);
666 }
667
668 #[test]
669 fn test_tool_description_with_config() {
670 let tool = BashTool::builder()
671 .username("agent")
672 .hostname("sandbox")
673 .env("API_KEY", "secret")
674 .limits(ExecutionLimits::new().max_commands(50))
675 .build();
676
677 let helptext = tool.help();
679 assert!(helptext.contains("CONFIGURATION"));
680 assert!(helptext.contains("User: agent"));
681 assert!(helptext.contains("Host: sandbox"));
682 assert!(helptext.contains("50 commands"));
683 assert!(helptext.contains("API_KEY"));
684
685 let sysprompt = tool.system_prompt();
687 assert!(sysprompt.contains("# Bash Tool"));
688 assert!(sysprompt.contains("Home: /home/agent"));
689 }
690
691 #[test]
692 fn test_tool_schemas() {
693 let tool = BashTool::default();
694 let input_schema = tool.input_schema();
695 let output_schema = tool.output_schema();
696
697 assert!(input_schema["properties"]["commands"].is_object());
699
700 assert!(output_schema["properties"]["stdout"].is_object());
702 assert!(output_schema["properties"]["stderr"].is_object());
703 assert!(output_schema["properties"]["exit_code"].is_object());
704 }
705
706 #[test]
707 fn test_tool_status() {
708 let status = ToolStatus::new("execute")
709 .with_message("Running commands")
710 .with_percent(50.0)
711 .with_eta(5000);
712
713 assert_eq!(status.phase, "execute");
714 assert_eq!(status.message, Some("Running commands".to_string()));
715 assert_eq!(status.percent_complete, Some(50.0));
716 assert_eq!(status.eta_ms, Some(5000));
717 }
718
719 #[tokio::test]
720 async fn test_tool_execute_empty() {
721 let mut tool = BashTool::default();
722 let req = ToolRequest {
723 commands: String::new(),
724 };
725 let resp = tool.execute(req).await;
726 assert_eq!(resp.exit_code, 0);
727 assert!(resp.error.is_none());
728 }
729
730 #[tokio::test]
731 async fn test_tool_execute_echo() {
732 let mut tool = BashTool::default();
733 let req = ToolRequest {
734 commands: "echo hello".to_string(),
735 };
736 let resp = tool.execute(req).await;
737 assert_eq!(resp.stdout, "hello\n");
738 assert_eq!(resp.exit_code, 0);
739 assert!(resp.error.is_none());
740 }
741
742 #[test]
743 fn test_builtin_hints_in_help_and_system_prompt() {
744 use crate::builtins::Builtin;
745 use crate::error::Result;
746 use crate::interpreter::ExecResult;
747
748 struct HintedBuiltin;
749
750 #[async_trait]
751 impl Builtin for HintedBuiltin {
752 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
753 Ok(ExecResult::ok(String::new()))
754 }
755 fn llm_hint(&self) -> Option<&'static str> {
756 Some("mycommand: Processes CSV. Max 10MB. No streaming.")
757 }
758 }
759
760 let tool = BashTool::builder()
761 .builtin("mycommand", Box::new(HintedBuiltin))
762 .build();
763
764 let helptext = tool.help();
766 assert!(helptext.contains("NOTES"), "help should have NOTES section");
767 assert!(
768 helptext.contains("mycommand: Processes CSV"),
769 "help should contain the hint"
770 );
771
772 let sysprompt = tool.system_prompt();
774 assert!(
775 sysprompt.contains("mycommand: Processes CSV"),
776 "system_prompt should contain the hint"
777 );
778 }
779
780 #[test]
781 fn test_no_hints_without_hinted_builtins() {
782 let tool = BashTool::default();
783
784 let helptext = tool.help();
785 assert!(
786 !helptext.contains("NOTES"),
787 "help should not have NOTES without hinted builtins"
788 );
789
790 let sysprompt = tool.system_prompt();
791 assert!(
792 !sysprompt.contains("Note:"),
793 "system_prompt should not have notes without hinted builtins"
794 );
795 }
796
797 #[test]
798 fn test_language_warning_default() {
799 let tool = BashTool::default();
800
801 let sysprompt = tool.system_prompt();
802 assert!(
803 sysprompt.contains("Warning: perl, python/python3 not available."),
804 "system_prompt should have single combined warning"
805 );
806
807 let helptext = tool.help();
808 assert!(
809 helptext.contains("WARNINGS"),
810 "help should have WARNINGS section"
811 );
812 assert!(
813 helptext.contains("perl, python/python3 not available."),
814 "help should have single combined warning"
815 );
816 }
817
818 #[test]
819 fn test_language_warning_suppressed_by_custom_builtins() {
820 use crate::builtins::Builtin;
821 use crate::error::Result;
822 use crate::interpreter::ExecResult;
823
824 struct NoopBuiltin;
825
826 #[async_trait]
827 impl Builtin for NoopBuiltin {
828 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
829 Ok(ExecResult::ok(String::new()))
830 }
831 }
832
833 let tool = BashTool::builder()
834 .builtin("python", Box::new(NoopBuiltin))
835 .builtin("perl", Box::new(NoopBuiltin))
836 .build();
837
838 let sysprompt = tool.system_prompt();
839 assert!(
840 !sysprompt.contains("Warning:"),
841 "no warning when all languages registered"
842 );
843
844 let helptext = tool.help();
845 assert!(
846 !helptext.contains("WARNINGS"),
847 "no WARNINGS section when all languages registered"
848 );
849 }
850
851 #[test]
852 fn test_language_warning_partial() {
853 use crate::builtins::Builtin;
854 use crate::error::Result;
855 use crate::interpreter::ExecResult;
856
857 struct NoopBuiltin;
858
859 #[async_trait]
860 impl Builtin for NoopBuiltin {
861 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
862 Ok(ExecResult::ok(String::new()))
863 }
864 }
865
866 let tool = BashTool::builder()
868 .builtin("python3", Box::new(NoopBuiltin))
869 .build();
870
871 let sysprompt = tool.system_prompt();
872 assert!(
873 sysprompt.contains("Warning: perl not available."),
874 "should warn about perl only"
875 );
876 assert!(
877 !sysprompt.contains("python/python3"),
878 "python warning suppressed when python3 registered"
879 );
880 }
881
882 #[test]
883 fn test_duplicate_hints_deduplicated() {
884 use crate::builtins::Builtin;
885 use crate::error::Result;
886 use crate::interpreter::ExecResult;
887
888 struct SameHint;
889
890 #[async_trait]
891 impl Builtin for SameHint {
892 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
893 Ok(ExecResult::ok(String::new()))
894 }
895 fn llm_hint(&self) -> Option<&'static str> {
896 Some("same hint")
897 }
898 }
899
900 let tool = BashTool::builder()
901 .builtin("cmd1", Box::new(SameHint))
902 .builtin("cmd2", Box::new(SameHint))
903 .build();
904
905 let helptext = tool.help();
906 assert_eq!(
908 helptext.matches("same hint").count(),
909 1,
910 "Duplicate hints should be deduplicated"
911 );
912 }
913
914 #[cfg(feature = "python")]
915 #[test]
916 fn test_python_hint_via_builder() {
917 let tool = BashTool::builder().python().build();
918
919 let helptext = tool.help();
920 assert!(helptext.contains("python"), "help should mention python");
921 assert!(
922 helptext.contains("no open()"),
923 "help should document open() limitation"
924 );
925 assert!(
926 helptext.contains("No HTTP"),
927 "help should document HTTP limitation"
928 );
929
930 let sysprompt = tool.system_prompt();
931 assert!(
932 sysprompt.contains("python"),
933 "system_prompt should mention python"
934 );
935
936 assert!(
938 !sysprompt.contains("python/python3 not available"),
939 "python warning should not appear when Monty python enabled"
940 );
941 }
942
943 #[tokio::test]
944 async fn test_tool_execute_with_status() {
945 use std::sync::{Arc, Mutex};
946
947 let mut tool = BashTool::default();
948 let req = ToolRequest {
949 commands: "echo test".to_string(),
950 };
951
952 let phases = Arc::new(Mutex::new(Vec::new()));
953 let phases_clone = phases.clone();
954
955 let resp = tool
956 .execute_with_status(
957 req,
958 Box::new(move |status| {
959 phases_clone
960 .lock()
961 .expect("lock poisoned")
962 .push(status.phase.clone());
963 }),
964 )
965 .await;
966
967 assert_eq!(resp.stdout, "test\n");
968 let phases = phases.lock().expect("lock poisoned");
969 assert!(phases.contains(&"validate".to_string()));
970 assert!(phases.contains(&"complete".to_string()));
971 }
972}