1mod egress_transport;
44
45use super::{Capability, CapabilityLocalization, CapabilityStatus, RiskLevel};
46use crate::background::{
47 BackgroundEventSink, BackgroundExecutableTool, BackgroundOutcome, BackgroundProgress,
48};
49use crate::exec_tool_result::ExecToolResultPayload;
50use crate::session_file::SessionFile;
51use crate::tool_types::{DeferrablePolicy, ToolHints};
52use crate::tools::{Tool, ToolExecutionResult};
53use crate::traits::{SessionFileSystem, ToolContext};
54use crate::typed_id::SessionId;
55use async_trait::async_trait;
56use bashkit::{
57 Bash, BashBuilder, BashTool as BashkitTool, DirEntry, ExecutionLimits, FileSystem,
58 FileSystemExt, FileType, Metadata, NetworkAllowlist, OutputCallback, SearchCapabilities,
59 SearchCapable, SearchMatch as BashkitSearchMatch, SearchProvider, SearchQuery, SearchResults,
60 Tool as BashkitToolTrait, TraceEventKind, TraceMode,
61};
62use serde_json::{Value, json};
63use std::path::{Path, PathBuf};
64use std::sync::atomic::{AtomicUsize, Ordering};
65use std::sync::{Arc, LazyLock};
66use std::time::SystemTime;
67
68fn execution_limits() -> ExecutionLimits {
74 ExecutionLimits::new()
75 .max_commands(1000)
76 .max_loop_iterations(10000)
77 .max_function_depth(100)
78 .max_input_bytes(1_000_000) .max_ast_depth(100)
80 .parser_timeout(std::time::Duration::from_secs(5))
81}
82
83fn resolve_shell_workspace(
93 store: &dyn SessionFileSystem,
94 working_dir_arg: Option<&str>,
95) -> (String, String) {
96 let workspace_env = store.display_root();
97 let cwd = match working_dir_arg {
98 Some(arg) => store.resolve_path(arg),
99 None => workspace_env.clone(),
100 };
101 (cwd, workspace_env)
102}
103
104static BASHKIT_TOOL: LazyLock<BashkitTool> = LazyLock::new(|| {
106 BashkitTool::builder()
107 .username("everruns")
108 .hostname("everruns")
109 .limits(execution_limits())
110 .env("HOME", "/home/agent")
111 .env("SHELL", "/bin/bash")
112 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
113 .env("WORKSPACE", "/workspace")
114 .build()
115});
116
117static TOOL_DESCRIPTION: LazyLock<String> =
119 LazyLock::new(|| BASHKIT_TOOL.description().to_string());
120
121static TOOL_SYSTEM_PROMPT: LazyLock<String> = LazyLock::new(|| {
123 let mut prompt = BASHKIT_TOOL.system_prompt().to_string();
124 prompt.push_str(crate::tool_output_sanitizer::EXEC_OUTPUT_HINT);
125 prompt
126});
127
128static TOOL_INPUT_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
131 let mut schema = BASHKIT_TOOL.input_schema();
132 if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
134 if !props.contains_key("working_dir") {
135 props.insert(
136 "working_dir".to_string(),
137 json!({
138 "type": "string",
139 "default": "/workspace",
140 "description": "Working directory for command execution"
141 }),
142 );
143 }
144 if !props.contains_key("output") {
145 props.insert(
146 "output".to_string(),
147 crate::tool_output_sanitizer::output_verbosity_schema(),
148 );
149 }
150 }
151 schema
152});
153
154pub const BASHKIT_SHELL_CAPABILITY_ID: &str = "bashkit_shell";
155
156pub struct BashkitShellCapability;
158
159impl Capability for BashkitShellCapability {
160 fn id(&self) -> &str {
161 BASHKIT_SHELL_CAPABILITY_ID
162 }
163
164 fn aliases(&self) -> Vec<&'static str> {
165 vec!["virtual_bash"]
167 }
168
169 fn name(&self) -> &str {
170 "Bashkit Shell"
171 }
172
173 fn description(&self) -> &str {
174 r#"Execute bash commands in an isolated, sandboxed environment.
175
176> [!NOTE]
177> Commands run in a virtual environment with no access to the host system.
178> The session filesystem is mounted at root, so you can read and write session files.
179
180> [!TIP]
181> Use standard Unix commands like `ls`, `cat`, `grep`, `echo`, and shell features
182> like pipes, redirections, and command substitution. Built-in commands support
183> `<command> --help`, and many also support `<command> --version`."#
184 }
185
186 fn localizations(&self) -> Vec<CapabilityLocalization> {
187 vec![CapabilityLocalization::text(
188 "uk",
189 "Оболонка Bashkit",
190 r#"Виконуйте bash-команди в ізольованому середовищі-пісочниці.
191
192> [!NOTE]
193> Команди виконуються у віртуальному середовищі без доступу до хост-системи.
194> Файлова система сесії змонтована в корені, тож можна читати й записувати файли сесії.
195
196> [!TIP]
197> Використовуйте стандартні Unix-команди, як-от `ls`, `cat`, `grep`, `echo`, і можливості оболонки
198> на кшталт конвеєрів, перенаправлень і підстановки команд. Вбудовані команди підтримують
199> `<command> --help`, а багато з них також `<command> --version`."#,
200 )]
201 }
202
203 fn status(&self) -> CapabilityStatus {
204 CapabilityStatus::Available
205 }
206
207 fn risk_level(&self) -> RiskLevel {
208 RiskLevel::High
209 }
210
211 fn icon(&self) -> Option<&str> {
212 Some("terminal")
213 }
214
215 fn category(&self) -> Option<&str> {
216 Some("Execution")
217 }
218
219 fn system_prompt_addition(&self) -> Option<&str> {
220 Some(&TOOL_SYSTEM_PROMPT)
221 }
222
223 fn tools(&self) -> Vec<Box<dyn Tool>> {
224 vec![Box::new(BashTool::default())]
225 }
226
227 fn tools_with_config(&self, config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
228 let enable_http = config
229 .get("enable_http")
230 .and_then(|v| v.as_bool())
231 .unwrap_or(false);
232 vec![Box::new(BashTool { enable_http })]
233 }
234
235 fn config_schema(&self) -> Option<serde_json::Value> {
236 Some(serde_json::json!({
237 "type": "object",
238 "properties": {
239 "enable_http": {
240 "type": "boolean",
241 "title": "Allow outbound HTTP (curl/wget)",
242 "description": "Let shell scripts make outbound HTTP requests. Every \
243 request is routed through the platform egress boundary, \
244 where the network access list and system allowlist are \
245 enforced.",
246 "default": false
247 }
248 }
249 }))
250 }
251
252 fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
253 if config.is_null() {
254 return Ok(());
255 }
256 if !config.is_object() {
257 return Err("bashkit_shell config must be an object".to_string());
258 }
259 match config.get("enable_http") {
260 None | Some(serde_json::Value::Bool(_)) => Ok(()),
261 Some(other) => Err(format!("enable_http must be a boolean, got {other}")),
262 }
263 }
264
265 fn dependencies(&self) -> Vec<&'static str> {
266 vec!["session_file_system"]
268 }
269
270 fn features(&self) -> Vec<&'static str> {
271 vec!["file_system"]
272 }
273}
274
275#[derive(Default)]
281pub struct BashTool {
282 enable_http: bool,
286}
287
288#[async_trait]
289impl Tool for BashTool {
290 fn narrate(
291 &self,
292 tool_call: &crate::tool_types::ToolCall,
293 phase: crate::tool_narration::ToolNarrationPhase,
294 locale: Option<&str>,
295 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
296 ) -> Option<String> {
297 let fallback = self.display_name().unwrap_or("Bash");
298 Some(crate::tool_narration::narrate_shell_exec(
299 &tool_call.arguments,
300 fallback,
301 phase,
302 locale,
303 ))
304 }
305
306 fn name(&self) -> &str {
307 "bash"
308 }
309
310 fn display_name(&self) -> Option<&str> {
311 Some("Bash")
312 }
313
314 fn description(&self) -> &str {
315 &TOOL_DESCRIPTION
316 }
317
318 fn parameters_schema(&self) -> Value {
319 TOOL_INPUT_SCHEMA.clone()
320 }
321
322 fn hints(&self) -> ToolHints {
323 ToolHints::default()
324 .with_long_running(true)
325 .with_open_world(true)
326 .with_persist_output(true)
327 .with_supports_background(true)
328 .with_concurrency_class("session_workspace")
333 .with_cpu_bound(true)
334 }
335
336 fn deferrable_policy(&self) -> DeferrablePolicy {
337 DeferrablePolicy::Never
341 }
342
343 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
344 ToolExecutionResult::tool_error(
345 "bash requires context. This tool must be executed with session context.",
346 )
347 }
348
349 async fn execute_with_context(
350 &self,
351 arguments: Value,
352 context: &ToolContext,
353 ) -> ToolExecutionResult {
354 let command = match arguments.get("commands").and_then(|v| v.as_str()) {
355 Some(c) => c,
356 None => {
357 return ToolExecutionResult::tool_error("Missing required parameter: commands");
358 }
359 };
360
361 let file_store = match &context.file_store {
362 Some(store) => store.clone(),
363 None => {
364 return ToolExecutionResult::tool_error(
365 "File system not available in this context",
366 );
367 }
368 };
369
370 let (working_dir, workspace_env) = resolve_shell_workspace(
371 file_store.as_ref(),
372 arguments.get("working_dir").and_then(|v| v.as_str()),
373 );
374
375 let timeout_ms = arguments
376 .get("timeout_ms")
377 .and_then(|v| v.as_u64())
378 .unwrap_or(30000)
379 .min(60000);
380
381 let output_mode = arguments
384 .get("output")
385 .and_then(|v| v.as_str())
386 .unwrap_or("auto");
387
388 let session_fs = Arc::new(SessionFileSystemAdapter::new(
390 context.session_id,
391 file_store,
392 ));
393
394 let locale = context.locale.as_deref().unwrap_or("en-US");
396
397 let builder = Bash::builder()
401 .fs(session_fs)
402 .cwd(working_dir.as_str())
403 .username("everruns")
404 .hostname("everruns")
405 .env("HOME", "/home/agent")
406 .env("SHELL", "/bin/bash")
407 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
408 .env("WORKSPACE", workspace_env.as_str())
409 .env("LANG", locale)
410 .limits(execution_limits())
411 .max_memory(10 * 1024 * 1024) .trace_mode(TraceMode::Redacted);
413 let builder = install_observability_hooks(builder, context.session_id);
414 let builder = configure_http(builder, self.enable_http, context);
415 let mut bash = builder.build();
416
417 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(String, String)>();
423 let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
424
425 let output_callback: OutputCallback = Box::new(move |stdout_chunk, stderr_chunk| {
426 let _ = tx.send((stdout_chunk.to_string(), stderr_chunk.to_string()));
430 let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
432 });
433
434 let emit_context = context.clone();
436 let emit_task = tokio::spawn(async move {
437 while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
438 if !stdout_chunk.is_empty() {
439 emit_context
440 .emit_tool_output("bash", &stdout_chunk, "stdout")
441 .await;
442 }
443 if !stderr_chunk.is_empty() {
444 emit_context
445 .emit_tool_output("bash", &stderr_chunk, "stderr")
446 .await;
447 }
448 }
449 });
450
451 let cancel_token = bash.cancellation_token();
453
454 let exec_start = std::time::Instant::now();
458 let result = tokio::time::timeout(
459 std::time::Duration::from_millis(timeout_ms),
460 bash.exec_streaming(command, output_callback),
461 )
462 .await;
463 let exec_duration = exec_start.elapsed();
464
465 let _ = emit_task.await;
467
468 match result {
469 Ok(Ok(output)) => {
470 let commands_executed = output
472 .events
473 .iter()
474 .filter(|e| e.kind == TraceEventKind::CommandExit)
475 .count();
476 let fs_reads = output
477 .events
478 .iter()
479 .filter(|e| e.kind == TraceEventKind::FileAccess)
480 .count();
481 let fs_writes = output
482 .events
483 .iter()
484 .filter(|e| e.kind == TraceEventKind::FileMutation)
485 .count();
486
487 tracing::info!(
488 tool = "bash",
489 duration_ms = exec_duration.as_millis() as u64,
490 exit_code = output.exit_code,
491 commands_executed,
492 fs_reads,
493 fs_writes,
494 stdout_bytes = output.stdout.len(),
495 stderr_bytes = output.stderr.len(),
496 "bashkit execution completed"
497 );
498
499 let payload = ExecToolResultPayload::new(
500 &output.stdout,
501 &output.stderr,
502 output.exit_code,
503 output_mode,
504 );
505 let ExecToolResultPayload {
506 stdout,
507 stderr,
508 exit_code,
509 success,
510 truncated,
511 total_lines,
512 raw_output,
513 } = payload;
514 ToolExecutionResult::success_with_raw_output(
515 json!({
516 "stdout": stdout,
517 "stderr": stderr,
518 "exit_code": exit_code,
519 "success": success,
520 "truncated": truncated,
521 "total_lines": total_lines,
522 }),
523 raw_output,
524 )
525 }
526 Ok(Err(e)) => {
527 ToolExecutionResult::tool_error(format!("Bash execution error: {}", e))
529 }
530 Err(_) => {
531 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
535
536 let partial = collect_partial_output(partial_rx);
537 if partial.is_empty() {
538 ToolExecutionResult::tool_error(format!(
539 "Command timed out after {}ms",
540 timeout_ms
541 ))
542 } else {
543 use crate::tool_output_sanitizer::{
544 clean_exec_output, output_verbosity_budget, priority_aware_truncate,
545 resolve_auto_mode,
546 };
547 let effective = resolve_auto_mode(output_mode, 1);
550 let clean = clean_exec_output(&partial);
551 let truncated = if let Some(budget) = output_verbosity_budget(effective) {
552 priority_aware_truncate(&clean, budget)
553 } else {
554 clean.clone()
555 };
556 ToolExecutionResult::tool_error(format!(
557 "Command timed out after {}ms. Partial output:\n{}",
558 timeout_ms, truncated
559 ))
560 }
561 }
562 }
563 }
564
565 fn requires_context(&self) -> bool {
566 true
567 }
568
569 fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
570 Some(self)
571 }
572}
573
574#[async_trait]
575impl BackgroundExecutableTool for BashTool {
576 async fn execute_background(
577 &self,
578 arguments: Value,
579 context: ToolContext,
580 sink: Arc<dyn BackgroundEventSink>,
581 ) -> Result<BackgroundOutcome, ToolExecutionResult> {
582 let command = match arguments.get("commands").and_then(|v| v.as_str()) {
583 Some(c) => c,
584 None => {
585 return Err(ToolExecutionResult::tool_error(
586 "Missing required parameter: commands",
587 ));
588 }
589 };
590
591 let file_store = match &context.file_store {
592 Some(store) => store.clone(),
593 None => {
594 return Err(ToolExecutionResult::tool_error(
595 "File system not available in this context",
596 ));
597 }
598 };
599
600 let (working_dir, workspace_env) = resolve_shell_workspace(
601 file_store.as_ref(),
602 arguments.get("working_dir").and_then(|v| v.as_str()),
603 );
604
605 let timeout_ms = arguments
606 .get("timeout_ms")
607 .and_then(|v| v.as_u64())
608 .unwrap_or(30000)
609 .min(60000);
610
611 let output_mode = arguments
613 .get("output")
614 .and_then(|v| v.as_str())
615 .unwrap_or("auto");
616
617 let session_fs = Arc::new(SessionFileSystemAdapter::new(
618 context.session_id,
619 file_store,
620 ));
621 let locale = context.locale.as_deref().unwrap_or("en-US");
622
623 let builder = Bash::builder()
624 .fs(session_fs)
625 .cwd(working_dir.as_str())
626 .username("everruns")
627 .hostname("everruns")
628 .env("HOME", "/home/agent")
629 .env("SHELL", "/bin/bash")
630 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
631 .env("WORKSPACE", workspace_env.as_str())
632 .env("LANG", locale)
633 .limits(execution_limits())
634 .max_memory(10 * 1024 * 1024)
635 .trace_mode(TraceMode::Redacted);
636 let builder = install_observability_hooks(builder, context.session_id);
637 let builder = configure_http(builder, self.enable_http, &context);
638 let mut bash = builder.build();
639
640 let (tx, mut rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
641 let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
642 let sink_for_output = sink.clone();
643 let dropped_chunks = Arc::new(AtomicUsize::new(0));
644 let dropped_chunks_for_callback = dropped_chunks.clone();
645 let output_callback: OutputCallback = Box::new(move |stdout_chunk, stderr_chunk| {
646 if tx
649 .try_send((stdout_chunk.to_string(), stderr_chunk.to_string()))
650 .is_err()
651 {
652 dropped_chunks_for_callback.fetch_add(1, Ordering::Relaxed);
653 }
654 let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
655 });
656
657 let emit_task = tokio::spawn(async move {
658 while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
659 if !stdout_chunk.is_empty() {
660 let _ = sink_for_output.output("stdout", &stdout_chunk).await;
661 }
662 if !stderr_chunk.is_empty() {
663 let _ = sink_for_output.output("stderr", &stderr_chunk).await;
664 }
665 }
666 });
667
668 let _ = sink.status("Running bash command").await;
669 let cancel_token = bash.cancellation_token();
670 let exec_start = std::time::Instant::now();
671 let result = tokio::time::timeout(
672 std::time::Duration::from_millis(timeout_ms),
673 bash.exec_streaming(command, output_callback),
674 )
675 .await;
676 let exec_duration = exec_start.elapsed();
677 let _ = emit_task.await;
678 let dropped_chunks = dropped_chunks.load(Ordering::Relaxed);
679 if dropped_chunks > 0 {
680 let _ = sink
681 .output(
682 "stderr",
683 &format!(
684 "[system] dropped {dropped_chunks} background output chunk(s) due to backpressure\n"
685 ),
686 )
687 .await;
688 }
689
690 match result {
691 Ok(Ok(output)) => {
692 let payload = ExecToolResultPayload::new(
693 &output.stdout,
694 &output.stderr,
695 output.exit_code,
696 output_mode,
697 );
698 let ExecToolResultPayload {
699 stdout,
700 stderr,
701 exit_code,
702 success,
703 truncated,
704 total_lines,
705 raw_output,
706 } = payload;
707 let _ = sink
708 .progress(BackgroundProgress {
709 current: Some(exec_duration.as_millis() as u64),
710 total: None,
711 unit: Some("ms".to_string()),
712 label: Some("runtime".to_string()),
713 })
714 .await;
715 Ok(BackgroundOutcome {
716 summary: format!(
717 "Bash command exited with code {} after {} ms",
718 exit_code,
719 exec_duration.as_millis()
720 ),
721 result: json!({
722 "stdout": stdout,
723 "stderr": stderr,
724 "exit_code": exit_code,
725 "success": success,
726 "truncated": truncated,
727 "total_lines": total_lines,
728 }),
729 raw_output: Some(raw_output),
730 })
731 }
732 Ok(Err(e)) => Err(ToolExecutionResult::tool_error(format!(
733 "Bash execution error: {}",
734 e
735 ))),
736 Err(_) => {
737 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
738
739 let partial = collect_partial_output(partial_rx);
740 if partial.is_empty() {
741 Err(ToolExecutionResult::tool_error(format!(
742 "Command timed out after {}ms",
743 timeout_ms
744 )))
745 } else {
746 use crate::tool_output_sanitizer::{
747 clean_exec_output, output_verbosity_budget, priority_aware_truncate,
748 resolve_auto_mode,
749 };
750 let effective = resolve_auto_mode(output_mode, 1);
753 let clean = clean_exec_output(&partial);
754 let truncated = if let Some(budget) = output_verbosity_budget(effective) {
755 priority_aware_truncate(&clean, budget)
756 } else {
757 clean.clone()
758 };
759 Err(ToolExecutionResult::tool_error(format!(
760 "Command timed out after {}ms. Partial output:\n{}",
761 timeout_ms, truncated
762 )))
763 }
764 }
765 }
766 }
767}
768
769fn install_observability_hooks(builder: BashBuilder, session_id: SessionId) -> BashBuilder {
779 use bashkit::hooks::{ErrorEvent, HookAction, ToolEvent, ToolResult};
780 builder
781 .before_tool(Box::new(move |ev: ToolEvent| {
782 tracing::debug!(
783 target: "bashkit.hook",
784 capability = "bashkit_shell",
785 session_id = %session_id,
786 event = "before_tool",
787 tool = %ev.name,
788 arg_count = ev.args.len(),
789 "builtin invoked"
790 );
791 HookAction::Continue(ev)
792 }))
793 .after_tool(Box::new(move |res: ToolResult| {
794 tracing::debug!(
795 target: "bashkit.hook",
796 capability = "bashkit_shell",
797 session_id = %session_id,
798 event = "after_tool",
799 tool = %res.name,
800 exit_code = res.exit_code,
801 stdout_bytes = res.stdout.len(),
802 "builtin completed"
803 );
804 HookAction::Continue(res)
805 }))
806 .on_error(Box::new(move |ev: ErrorEvent| {
807 let preview = truncate_for_log(&ev.message, 256);
808 tracing::warn!(
809 target: "bashkit.hook",
810 capability = "bashkit_shell",
811 session_id = %session_id,
812 event = "on_error",
813 message = %preview,
814 "interpreter error"
815 );
816 HookAction::Continue(ev)
817 }))
818}
819
820fn configure_http(builder: BashBuilder, enable_http: bool, context: &ToolContext) -> BashBuilder {
841 if !enable_http {
842 return builder;
843 }
844 let Some(egress) = context.egress_service.clone() else {
845 tracing::warn!(
846 capability = "bashkit_shell",
847 session_id = %context.session_id,
848 "enable_http set but no egress service in context; shell HTTP stays disabled"
849 );
850 return builder;
851 };
852 let session_id = context.session_id;
853 let mut builder = builder
854 .network(NetworkAllowlist::allow_all())
855 .http_transport(Arc::new(egress_transport::BashkitEgressTransport::new(
856 egress,
857 context.network_access.clone(),
858 )))
859 .before_http(Box::new(move |ev: bashkit::hooks::HttpRequestEvent| {
862 tracing::debug!(
863 target: "bashkit.hook",
864 capability = "bashkit_shell",
865 session_id = %session_id,
866 event = "before_http",
867 method = %ev.method,
868 header_count = ev.headers.len(),
869 "outbound http request"
870 );
871 bashkit::hooks::HookAction::Continue(ev)
872 }))
873 .after_http(Box::new(move |ev: bashkit::hooks::HttpResponseEvent| {
874 tracing::debug!(
875 target: "bashkit.hook",
876 capability = "bashkit_shell",
877 session_id = %session_id,
878 event = "after_http",
879 status = ev.status,
880 "outbound http response"
881 );
882 bashkit::hooks::HookAction::Continue(ev)
883 }));
884 if let Some(bot_auth) = bot_auth_config_from_env() {
885 builder = builder.bot_auth(bot_auth);
886 }
887 builder
888}
889
890fn bot_auth_config_from_env() -> Option<bashkit::BotAuthConfig> {
894 static CONFIG: LazyLock<Option<bashkit::BotAuthConfig>> = LazyLock::new(|| {
895 let seed = std::env::var("BOT_AUTH_SIGNING_KEY_SEED").ok()?;
896 let mut config = match bashkit::BotAuthConfig::from_base64_seed(&seed) {
897 Ok(config) => config,
898 Err(e) => {
899 tracing::warn!(error = %e, "invalid BOT_AUTH_SIGNING_KEY_SEED, bashkit bot-auth disabled");
900 return None;
901 }
902 };
903 if let Ok(fqdn) = std::env::var("BOT_AUTH_AGENT_FQDN") {
904 config = config.with_agent_fqdn(&fqdn);
905 }
906 if let Ok(secs) = std::env::var("BOT_AUTH_VALIDITY_SECS")
907 && let Ok(secs) = secs.parse::<u64>()
908 {
909 config = config.with_validity_secs(secs);
910 }
911 Some(config)
912 });
913 CONFIG.clone()
914}
915
916fn truncate_for_log(msg: &str, max_bytes: usize) -> String {
921 const MARKER: &str = "…[truncated]";
922 if msg.len() <= max_bytes {
923 return msg.to_string();
924 }
925 let budget = max_bytes.saturating_sub(MARKER.len());
926 let mut cut = budget.min(msg.len());
927 while cut > 0 && !msg.is_char_boundary(cut) {
928 cut -= 1;
929 }
930 if max_bytes > MARKER.len() {
931 format!("{}{}", &msg[..cut], MARKER)
932 } else {
933 let mut cut = max_bytes.min(msg.len());
935 while cut > 0 && !msg.is_char_boundary(cut) {
936 cut -= 1;
937 }
938 msg[..cut].to_string()
939 }
940}
941
942fn collect_partial_output(mut rx: tokio::sync::mpsc::Receiver<(String, String)>) -> String {
945 let mut stdout_buf = String::new();
946 let mut stderr_buf = String::new();
947 while let Ok((stdout, stderr)) = rx.try_recv() {
948 stdout_buf.push_str(&stdout);
949 stderr_buf.push_str(&stderr);
950 }
951 let mut partial = stdout_buf;
952 if !stderr_buf.is_empty() {
953 if !partial.is_empty() && !partial.ends_with('\n') {
954 partial.push('\n');
955 }
956 partial.push_str("--- stderr ---\n");
957 partial.push_str(&stderr_buf);
958 }
959 partial
960}
961
962pub struct SessionFileSystemAdapter {
971 session_id: SessionId,
972 store: Arc<dyn SessionFileSystem>,
973}
974
975impl SessionFileSystemAdapter {
976 pub fn new(session_id: SessionId, store: Arc<dyn SessionFileSystem>) -> Self {
977 Self { session_id, store }
978 }
979
980 fn store_path(path: &Path) -> String {
990 path.to_string_lossy().into_owned()
991 }
992
993 async fn directory_has_entries(&self, session_path: &str) -> bool {
999 self.store
1000 .list_directory(self.session_id, session_path)
1001 .await
1002 .is_ok_and(|entries| !entries.is_empty())
1003 }
1004}
1005
1006#[async_trait]
1007impl FileSystemExt for SessionFileSystemAdapter {}
1008
1009#[async_trait]
1010impl FileSystem for SessionFileSystemAdapter {
1011 async fn read_file(&self, path: &Path) -> bashkit::Result<Vec<u8>> {
1012 let session_path = Self::store_path(path);
1013
1014 match self.store.read_file(self.session_id, &session_path).await {
1015 Ok(Some(file)) => {
1016 let content = file.content.unwrap_or_default();
1017 SessionFile::decode_content(&content, &file.encoding)
1018 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1019 }
1020 Ok(None) => Err(bashkit::Error::Io(std::io::Error::new(
1021 std::io::ErrorKind::NotFound,
1022 format!("File not found: {}", path.display()),
1023 ))),
1024 Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
1025 }
1026 }
1027
1028 async fn write_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
1029 let session_path = Self::store_path(path);
1030
1031 let (encoded, encoding) = SessionFile::encode_content(content);
1032
1033 self.store
1034 .write_file(self.session_id, &session_path, &encoded, &encoding)
1035 .await
1036 .map(|_| ())
1037 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1038 }
1039
1040 async fn append_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
1041 let session_path = Self::store_path(path);
1042
1043 let mut existing = match self.store.read_file(self.session_id, &session_path).await {
1045 Ok(Some(file)) => {
1046 let content = file.content.unwrap_or_default();
1047 SessionFile::decode_content(&content, &file.encoding)
1048 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?
1049 }
1050 Ok(None) => Vec::new(),
1051 Err(e) => return Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
1052 };
1053
1054 existing.extend_from_slice(content);
1056
1057 let (encoded, encoding) = SessionFile::encode_content(&existing);
1059 self.store
1060 .write_file(self.session_id, &session_path, &encoded, &encoding)
1061 .await
1062 .map(|_| ())
1063 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1064 }
1065
1066 async fn mkdir(&self, path: &Path, _recursive: bool) -> bashkit::Result<()> {
1067 let session_path = Self::store_path(path);
1068
1069 self.store
1070 .create_directory(self.session_id, &session_path)
1071 .await
1072 .map(|_| ())
1073 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1074 }
1075
1076 async fn remove(&self, path: &Path, recursive: bool) -> bashkit::Result<()> {
1077 let session_path = Self::store_path(path);
1078
1079 self.store
1080 .delete_file(self.session_id, &session_path, recursive)
1081 .await
1082 .map(|_| ())
1083 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1084 }
1085
1086 async fn stat(&self, path: &Path) -> bashkit::Result<Metadata> {
1087 if path.to_string_lossy() == "/workspace" {
1089 let now = SystemTime::now();
1090 return Ok(Metadata {
1091 file_type: FileType::Directory,
1092 size: 0,
1093 mode: 0o755,
1094 modified: now,
1095 created: now,
1096 });
1097 }
1098
1099 let session_path = Self::store_path(path);
1100
1101 match self.store.read_file(self.session_id, &session_path).await {
1103 Ok(Some(file)) => {
1104 let now = SystemTime::now();
1105
1106 let file_type = if file.is_directory {
1107 FileType::Directory
1108 } else {
1109 FileType::File
1110 };
1111
1112 Ok(Metadata {
1116 file_type,
1117 size: file.size_bytes as u64,
1118 mode: 0o755,
1119 modified: now,
1120 created: now,
1121 })
1122 }
1123 Ok(None) => {
1124 if self.directory_has_entries(&session_path).await {
1130 let now = SystemTime::now();
1131 Ok(Metadata {
1132 file_type: FileType::Directory,
1133 size: 0,
1134 mode: 0o755,
1135 modified: now,
1136 created: now,
1137 })
1138 } else {
1139 Err(bashkit::Error::Io(std::io::Error::new(
1140 std::io::ErrorKind::NotFound,
1141 format!("Path not found: {}", path.display()),
1142 )))
1143 }
1144 }
1145 Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
1146 }
1147 }
1148
1149 async fn read_dir(&self, path: &Path) -> bashkit::Result<Vec<DirEntry>> {
1150 let session_path = Self::store_path(path);
1151
1152 let entries = self
1153 .store
1154 .list_directory(self.session_id, &session_path)
1155 .await
1156 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1157
1158 let now = SystemTime::now();
1159
1160 Ok(entries
1161 .into_iter()
1162 .map(|e| {
1163 let file_type = if e.is_directory {
1164 FileType::Directory
1165 } else {
1166 FileType::File
1167 };
1168
1169 DirEntry {
1170 name: e.name,
1171 metadata: Metadata {
1172 file_type,
1173 size: e.size_bytes as u64,
1174 mode: 0o755,
1175 modified: now,
1176 created: now,
1177 },
1178 }
1179 })
1180 .collect())
1181 }
1182
1183 async fn exists(&self, path: &Path) -> bashkit::Result<bool> {
1184 if path.to_string_lossy() == "/workspace" {
1186 return Ok(true);
1187 }
1188
1189 let session_path = Self::store_path(path);
1190
1191 if let Ok(Some(_)) = self.store.read_file(self.session_id, &session_path).await {
1193 return Ok(true);
1194 }
1195
1196 Ok(self.directory_has_entries(&session_path).await)
1199 }
1200
1201 async fn rename(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
1202 let from_session = Self::store_path(from);
1203
1204 let content = self.read_file(from).await?;
1206
1207 self.write_file(to, &content).await?;
1209
1210 self.store
1212 .delete_file(self.session_id, &from_session, false)
1213 .await
1214 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1215
1216 Ok(())
1217 }
1218
1219 async fn copy(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
1220 let content = self.read_file(from).await?;
1221 self.write_file(to, &content).await
1222 }
1223
1224 async fn symlink(&self, _target: &Path, _link: &Path) -> bashkit::Result<()> {
1225 Err(bashkit::Error::Io(std::io::Error::new(
1227 std::io::ErrorKind::Unsupported,
1228 "Symlinks not supported in session filesystem",
1229 )))
1230 }
1231
1232 async fn read_link(&self, path: &Path) -> bashkit::Result<PathBuf> {
1233 Err(bashkit::Error::Io(std::io::Error::new(
1235 std::io::ErrorKind::Unsupported,
1236 format!("Symlinks not supported: {}", path.display()),
1237 )))
1238 }
1239
1240 async fn chmod(&self, _path: &Path, _mode: u32) -> bashkit::Result<()> {
1241 Ok(())
1243 }
1244
1245 async fn set_modified_time(&self, _path: &Path, _time: SystemTime) -> bashkit::Result<()> {
1250 Ok(())
1251 }
1252
1253 fn as_search_capable(&self) -> Option<&dyn SearchCapable> {
1254 Some(self)
1255 }
1256}
1257
1258impl SearchCapable for SessionFileSystemAdapter {
1263 fn search_provider(&self, _path: &Path) -> Option<Box<dyn SearchProvider>> {
1264 Some(Box::new(SessionSearchProvider {
1267 session_id: self.session_id,
1268 store: self.store.clone(),
1269 }))
1270 }
1271}
1272
1273struct SessionSearchProvider {
1278 session_id: SessionId,
1279 store: Arc<dyn SessionFileSystem>,
1280}
1281
1282impl SearchProvider for SessionSearchProvider {
1283 fn search(&self, query: &SearchQuery) -> bashkit::Result<SearchResults> {
1284 let session_id = self.session_id;
1285 let store = self.store.clone();
1286 let root = query.root.to_string_lossy().into_owned();
1287 let max_results = query.max_results;
1288
1289 let pattern = if query.case_insensitive {
1291 format!("(?i){}", query.pattern)
1292 } else {
1293 query.pattern.clone()
1294 };
1295
1296 let path_pattern = if root == crate::mount_fs::WORKSPACE_MOUNT || root == "/" {
1301 None
1302 } else {
1303 Some(root)
1304 };
1305
1306 let matches = std::thread::scope(|s| {
1310 s.spawn(|| {
1311 let rt = tokio::runtime::Builder::new_current_thread()
1312 .enable_all()
1313 .build()
1314 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1315 rt.block_on(async {
1316 store
1317 .grep_files(session_id, &pattern, path_pattern.as_deref())
1318 .await
1319 })
1320 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1321 })
1322 .join()
1323 .unwrap_or_else(|_| {
1324 Err(bashkit::Error::Io(std::io::Error::other(
1325 "search thread panicked",
1326 )))
1327 })
1328 })?;
1329
1330 let truncated = max_results.is_some_and(|max| matches.len() > max);
1331 let matches: Vec<BashkitSearchMatch> = matches
1332 .into_iter()
1333 .take(max_results.unwrap_or(usize::MAX))
1334 .map(|m| {
1335 let vfs_path = self.store.display_path(&m.path);
1339 BashkitSearchMatch {
1340 path: PathBuf::from(vfs_path),
1341 line_number: m.line_number,
1342 line_content: m.line,
1343 }
1344 })
1345 .collect();
1346
1347 Ok(SearchResults { matches, truncated })
1348 }
1349
1350 fn capabilities(&self) -> SearchCapabilities {
1351 SearchCapabilities {
1352 regex: true,
1353 glob_filter: false,
1354 content_search: true,
1355 filename_search: false,
1356 }
1357 }
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362 use super::*;
1363 use crate::session_file::FileInfo;
1364 use crate::traits::SessionFileSystem;
1365 use crate::typed_id::SessionId;
1366 use crate::{FileStat, GrepMatch, Result};
1367 use std::collections::HashMap;
1368 use std::sync::Mutex;
1369
1370 struct MockFileStore {
1376 files: Mutex<HashMap<(SessionId, String), (String, String)>>, directories: Mutex<HashMap<(SessionId, String), bool>>,
1378 }
1379
1380 impl MockFileStore {
1381 fn new() -> Self {
1382 Self {
1383 files: Mutex::new(HashMap::new()),
1384 directories: Mutex::new(HashMap::new()),
1385 }
1386 }
1387
1388 fn normalize_path(path: &str) -> String {
1389 let mut normalized = path.trim().to_string();
1390 if !normalized.starts_with('/') {
1391 normalized = format!("/{}", normalized);
1392 }
1393 if normalized.len() > 1 && normalized.ends_with('/') {
1394 normalized.pop();
1395 }
1396 normalized
1397 }
1398 }
1399
1400 #[async_trait]
1401 impl SessionFileSystem for MockFileStore {
1402 fn is_mount_resolver(&self) -> bool {
1403 false
1404 }
1405
1406 async fn read_file(
1407 &self,
1408 session_id: SessionId,
1409 path: &str,
1410 ) -> Result<Option<SessionFile>> {
1411 let path = Self::normalize_path(path);
1412 let files = self.files.lock().unwrap();
1413 if let Some((content, encoding)) = files.get(&(session_id, path.clone())) {
1414 Ok(Some(SessionFile {
1415 id: uuid::Uuid::new_v4(),
1416 session_id: session_id.into(),
1417 path: path.clone(),
1418 name: path.split('/').next_back().unwrap_or("").to_string(),
1419 is_directory: false,
1420 is_readonly: false,
1421 content: Some(content.clone()),
1422 encoding: encoding.clone(),
1423 size_bytes: content.len() as i64,
1424 created_at: chrono::Utc::now(),
1425 updated_at: chrono::Utc::now(),
1426 }))
1427 } else if self
1428 .directories
1429 .lock()
1430 .unwrap()
1431 .contains_key(&(session_id, path.clone()))
1432 {
1433 Ok(Some(SessionFile {
1437 id: uuid::Uuid::new_v4(),
1438 session_id: session_id.into(),
1439 path: path.clone(),
1440 name: path.split('/').next_back().unwrap_or("").to_string(),
1441 is_directory: true,
1442 is_readonly: false,
1443 content: None,
1444 encoding: "text".to_string(),
1445 size_bytes: 0,
1446 created_at: chrono::Utc::now(),
1447 updated_at: chrono::Utc::now(),
1448 }))
1449 } else {
1450 Ok(None)
1451 }
1452 }
1453
1454 async fn write_file(
1455 &self,
1456 session_id: SessionId,
1457 path: &str,
1458 content: &str,
1459 encoding: &str,
1460 ) -> Result<SessionFile> {
1461 let path = Self::normalize_path(path);
1462 let mut files = self.files.lock().unwrap();
1463 files.insert(
1464 (session_id, path.clone()),
1465 (content.to_string(), encoding.to_string()),
1466 );
1467 Ok(SessionFile {
1468 id: uuid::Uuid::new_v4(),
1469 session_id: session_id.into(),
1470 path: path.clone(),
1471 name: path.split('/').next_back().unwrap_or("").to_string(),
1472 is_directory: false,
1473 is_readonly: false,
1474 content: Some(content.to_string()),
1475 encoding: encoding.to_string(),
1476 size_bytes: content.len() as i64,
1477 created_at: chrono::Utc::now(),
1478 updated_at: chrono::Utc::now(),
1479 })
1480 }
1481
1482 async fn delete_file(
1483 &self,
1484 session_id: SessionId,
1485 path: &str,
1486 _recursive: bool,
1487 ) -> Result<bool> {
1488 let path = Self::normalize_path(path);
1489 let mut files = self.files.lock().unwrap();
1490 Ok(files.remove(&(session_id, path)).is_some())
1491 }
1492
1493 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
1494 let path = Self::normalize_path(path);
1495 let files = self.files.lock().unwrap();
1496 let dirs = self.directories.lock().unwrap();
1497 let mut entries = Vec::new();
1498
1499 let is_root = path == "/";
1501
1502 for ((sid, file_path), (content, _)) in files.iter() {
1503 if *sid != session_id {
1504 continue;
1505 }
1506
1507 let parent = if let Some(idx) = file_path.rfind('/') {
1509 if idx == 0 {
1510 "/".to_string()
1511 } else {
1512 file_path[..idx].to_string()
1513 }
1514 } else {
1515 "/".to_string()
1516 };
1517
1518 if parent == path {
1519 entries.push(FileInfo {
1520 id: uuid::Uuid::new_v4(),
1521 session_id: session_id.into(),
1522 path: file_path.clone(),
1523 name: file_path.split('/').next_back().unwrap_or("").to_string(),
1524 is_directory: false,
1525 is_readonly: false,
1526 size_bytes: content.len() as i64,
1527 created_at: chrono::Utc::now(),
1528 updated_at: chrono::Utc::now(),
1529 });
1530 }
1531 }
1532
1533 let prefix = if is_root {
1537 "/".to_string()
1538 } else {
1539 format!("{path}/")
1540 };
1541 let mut child_dirs: std::collections::BTreeSet<String> =
1542 std::collections::BTreeSet::new();
1543 let descendant_paths = files
1544 .keys()
1545 .chain(dirs.keys())
1546 .filter(|(sid, _)| *sid == session_id)
1547 .map(|(_, p)| p);
1548 for candidate in descendant_paths {
1549 if let Some(rest) = candidate.strip_prefix(&prefix)
1550 && let Some(name) = rest.split('/').next()
1551 && rest.contains('/')
1552 && !name.is_empty()
1553 {
1554 child_dirs.insert(name.to_string());
1555 }
1556 }
1557 for name in child_dirs {
1558 entries.push(FileInfo {
1559 id: uuid::Uuid::new_v4(),
1560 session_id: session_id.into(),
1561 path: format!("{prefix}{name}"),
1562 name,
1563 is_directory: true,
1564 is_readonly: false,
1565 size_bytes: 0,
1566 created_at: chrono::Utc::now(),
1567 updated_at: chrono::Utc::now(),
1568 });
1569 }
1570
1571 if !is_root && entries.is_empty() && !dirs.contains_key(&(session_id, path.clone())) {
1574 let has_children = files
1576 .keys()
1577 .any(|(sid, fp)| *sid == session_id && fp.starts_with(&format!("{}/", path)));
1578 if !has_children {
1579 return Err(anyhow::anyhow!("Directory not found: {}", path).into());
1580 }
1581 }
1582
1583 Ok(entries)
1584 }
1585
1586 async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
1587 let path = Self::normalize_path(path);
1588 let files = self.files.lock().unwrap();
1589 if let Some((content, _)) = files.get(&(session_id, path.clone())) {
1590 Ok(Some(FileStat {
1591 path: path.clone(),
1592 name: path.split('/').next_back().unwrap_or("").to_string(),
1593 is_directory: false,
1594 is_readonly: false,
1595 size_bytes: content.len() as i64,
1596 created_at: chrono::Utc::now(),
1597 updated_at: chrono::Utc::now(),
1598 }))
1599 } else {
1600 Ok(None)
1601 }
1602 }
1603
1604 async fn grep_files(
1605 &self,
1606 session_id: SessionId,
1607 pattern: &str,
1608 path_pattern: Option<&str>,
1609 ) -> Result<Vec<GrepMatch>> {
1610 let regex = regex::Regex::new(pattern)
1611 .map_err(|e| anyhow::anyhow!("invalid pattern: {}", e))?;
1612 let files = self.files.lock().unwrap();
1613 let mut matches = Vec::new();
1614 for ((sid, file_path), (content, _)) in files.iter() {
1615 if *sid != session_id {
1616 continue;
1617 }
1618 if let Some(pp) = path_pattern
1619 && !file_path.starts_with(pp)
1620 {
1621 continue;
1622 }
1623 let decoded = SessionFile::decode_content(content, "utf-8")
1624 .unwrap_or_else(|_| content.as_bytes().to_vec());
1625 let text = String::from_utf8_lossy(&decoded);
1626 for (i, line) in text.lines().enumerate() {
1627 if regex.is_match(line) {
1628 matches.push(GrepMatch {
1629 path: file_path.clone(),
1630 line_number: i + 1,
1631 line: line.to_string(),
1632 });
1633 }
1634 }
1635 }
1636 matches.sort_by(|a, b| a.path.cmp(&b.path).then(a.line_number.cmp(&b.line_number)));
1637 Ok(matches)
1638 }
1639
1640 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
1641 let path = Self::normalize_path(path);
1642 let mut dirs = self.directories.lock().unwrap();
1643 dirs.insert((session_id, path.clone()), true);
1644 Ok(FileInfo {
1645 id: uuid::Uuid::new_v4(),
1646 session_id: session_id.into(),
1647 path: path.clone(),
1648 name: path.split('/').next_back().unwrap_or("").to_string(),
1649 is_directory: true,
1650 is_readonly: false,
1651 size_bytes: 0,
1652 created_at: chrono::Utc::now(),
1653 updated_at: chrono::Utc::now(),
1654 })
1655 }
1656 }
1657
1658 #[test]
1668 fn description_advertises_builtin_help_and_version() {
1669 let description = BashkitShellCapability.description();
1670 assert!(
1671 description.contains("`<command> --help`"),
1672 "description should advertise built-in help, got: {description}"
1673 );
1674 assert!(
1675 description.contains("`<command> --version`"),
1676 "description should advertise built-in version support, got: {description}"
1677 );
1678 }
1679
1680 #[test]
1681 fn test_capability_has_system_prompt() {
1682 let cap = BashkitShellCapability;
1683 let prompt = cap.system_prompt_addition().unwrap();
1684 assert!(!prompt.is_empty(), "System prompt should not be empty");
1686 assert!(
1688 prompt.contains("everruns"),
1689 "System prompt should contain configured identity"
1690 );
1691 }
1692
1693 fn mount_adapter() -> SessionFileSystemAdapter {
1703 let store = crate::mount_fs::MountFs::wrap(Arc::new(MockFileStore::new()));
1704 SessionFileSystemAdapter::new(SessionId::new(), store)
1705 }
1706
1707 #[tokio::test]
1708 async fn adapter_maps_workspace_alias_to_backend_root() {
1709 let adapter = mount_adapter();
1710 adapter
1711 .write_file(Path::new("/workspace/file.txt"), b"hi")
1712 .await
1713 .unwrap();
1714 assert_eq!(
1717 adapter
1718 .read_file(Path::new("/workspace/file.txt"))
1719 .await
1720 .unwrap(),
1721 b"hi"
1722 );
1723 assert_eq!(
1724 adapter.read_file(Path::new("/file.txt")).await.unwrap(),
1725 b"hi"
1726 );
1727 }
1728
1729 #[tokio::test]
1730 async fn adapter_addresses_any_path_from_root() {
1731 let adapter = mount_adapter();
1735 adapter
1736 .write_file(Path::new("/tmp/file.txt"), b"x")
1737 .await
1738 .unwrap();
1739 assert_eq!(
1740 adapter.read_file(Path::new("/tmp/file.txt")).await.unwrap(),
1741 b"x"
1742 );
1743 }
1744
1745 #[tokio::test]
1750 async fn test_bash_without_context() {
1751 let tool = BashTool::default();
1752 let result = tool.execute(json!({"commands": "echo hello"})).await;
1753
1754 if let ToolExecutionResult::ToolError(msg) = result {
1755 assert!(msg.contains("requires context"));
1756 } else {
1757 panic!("Expected tool error");
1758 }
1759 }
1760
1761 #[tokio::test]
1762 async fn test_bash_missing_command() {
1763 let tool = BashTool::default();
1764 let context = ToolContext::new(SessionId::new());
1765
1766 let result = tool.execute_with_context(json!({}), &context).await;
1767
1768 if let ToolExecutionResult::ToolError(msg) = result {
1769 assert!(msg.contains("Missing required parameter"));
1770 } else {
1771 panic!("Expected tool error for missing command");
1772 }
1773 }
1774
1775 #[tokio::test]
1776 async fn test_bash_no_file_store() {
1777 let tool = BashTool::default();
1778 let context = ToolContext::new(SessionId::new());
1779
1780 let result = tool
1781 .execute_with_context(json!({"commands": "echo hello"}), &context)
1782 .await;
1783
1784 if let ToolExecutionResult::ToolError(msg) = result {
1785 assert!(msg.contains("not available"));
1786 } else {
1787 panic!("Expected tool error for missing file store");
1788 }
1789 }
1790
1791 fn create_context_with_mock_store() -> (ToolContext, SessionId) {
1796 let session_id = SessionId::new();
1797 let store = crate::mount_fs::MountFs::wrap(Arc::new(MockFileStore::new()));
1800 let mut context = ToolContext::new(session_id);
1801 context.file_store = Some(store);
1802 (context, session_id)
1803 }
1804
1805 #[tokio::test]
1806 async fn test_bash_echo_command() {
1807 let (context, _) = create_context_with_mock_store();
1808 let tool = BashTool::default();
1809
1810 let result = tool
1811 .execute_with_context(json!({"commands": "echo hello world"}), &context)
1812 .await;
1813
1814 if let ToolExecutionResult::Success(output) = result {
1815 assert_eq!(output["stdout"], "hello world\n");
1816 assert_eq!(output["exit_code"], 0);
1817 assert_eq!(output["success"], true);
1818 } else {
1819 panic!("Expected success result, got: {:?}", result);
1820 }
1821 }
1822
1823 #[tokio::test]
1824 async fn test_bash_pwd_default_workspace() {
1825 let (context, _) = create_context_with_mock_store();
1826 let tool = BashTool::default();
1827
1828 let result = tool
1829 .execute_with_context(json!({"commands": "pwd"}), &context)
1830 .await;
1831
1832 if let ToolExecutionResult::Success(output) = result {
1833 assert_eq!(output["stdout"], "/workspace\n");
1834 assert_eq!(output["exit_code"], 0);
1835 } else {
1836 panic!("Expected success result, got: {:?}", result);
1837 }
1838 }
1839
1840 #[tokio::test]
1841 async fn test_bash_env_variables() {
1842 let (context, _) = create_context_with_mock_store();
1843 let tool = BashTool::default();
1844
1845 let result = tool
1847 .execute_with_context(json!({"commands": "echo $HOME"}), &context)
1848 .await;
1849 if let ToolExecutionResult::Success(output) = result {
1850 assert_eq!(output["stdout"], "/home/agent\n");
1851 } else {
1852 panic!("Expected success");
1853 }
1854
1855 let result = tool
1857 .execute_with_context(json!({"commands": "echo $WORKSPACE"}), &context)
1858 .await;
1859 if let ToolExecutionResult::Success(output) = result {
1860 assert_eq!(output["stdout"], "/workspace\n");
1861 } else {
1862 panic!("Expected success");
1863 }
1864
1865 let result = tool
1867 .execute_with_context(json!({"commands": "echo $USER"}), &context)
1868 .await;
1869 if let ToolExecutionResult::Success(output) = result {
1870 assert_eq!(output["stdout"], "everruns\n");
1871 } else {
1872 panic!("Expected success");
1873 }
1874 }
1875
1876 #[tokio::test]
1877 async fn test_bash_lang_env_default() {
1878 let (context, _) = create_context_with_mock_store();
1879 let tool = BashTool::default();
1880
1881 let result = tool
1883 .execute_with_context(json!({"commands": "echo $LANG"}), &context)
1884 .await;
1885 if let ToolExecutionResult::Success(output) = result {
1886 assert_eq!(output["stdout"], "en-US\n");
1887 } else {
1888 panic!("Expected success");
1889 }
1890 }
1891
1892 #[tokio::test]
1893 async fn test_bash_lang_env_from_context_locale() {
1894 let (mut context, _) = create_context_with_mock_store();
1895 context.locale = Some("uk-UA".to_string());
1896 let tool = BashTool::default();
1897
1898 let result = tool
1899 .execute_with_context(json!({"commands": "echo $LANG"}), &context)
1900 .await;
1901 if let ToolExecutionResult::Success(output) = result {
1902 assert_eq!(output["stdout"], "uk-UA\n");
1903 } else {
1904 panic!("Expected success");
1905 }
1906 }
1907
1908 #[tokio::test]
1909 async fn test_bash_write_and_read_file() {
1910 let (context, _) = create_context_with_mock_store();
1911 let tool = BashTool::default();
1912
1913 let result = tool
1915 .execute_with_context(
1916 json!({"commands": "echo 'test content' > /workspace/test.txt"}),
1917 &context,
1918 )
1919 .await;
1920 assert!(matches!(result, ToolExecutionResult::Success(_)));
1921
1922 let result = tool
1924 .execute_with_context(json!({"commands": "cat /workspace/test.txt"}), &context)
1925 .await;
1926 if let ToolExecutionResult::Success(output) = result {
1927 assert_eq!(output["stdout"], "test content\n");
1928 } else {
1929 panic!("Expected success result");
1930 }
1931 }
1932
1933 #[tokio::test]
1934 async fn test_bash_recursive_walk_descends_into_subdirectories() {
1935 let (context, _guard) = create_context_with_mock_store();
1936 let tool = BashTool::default();
1937
1938 let result = tool
1939 .execute_with_context(
1940 json!({"commands": "mkdir -p /workspace/a/b && echo needle > /workspace/a/b/c.txt \
1941 && ls /workspace/a && find /workspace/a -name '*.txt'"}),
1942 &context,
1943 )
1944 .await;
1945
1946 match result {
1947 ToolExecutionResult::Success(output) => {
1948 let stdout = output["stdout"].as_str().unwrap_or("");
1949 assert_eq!(output["exit_code"], 0, "stderr: {}", output["stderr"]);
1950 assert!(
1951 stdout.contains("b\n"),
1952 "expected subdir in ls, got {stdout:?}"
1953 );
1954 assert!(
1955 stdout.contains("/workspace/a/b/c.txt"),
1956 "expected find to descend, got {stdout:?}"
1957 );
1958 }
1959 other => panic!("Expected success result, got {other:?}"),
1960 }
1961 }
1962
1963 #[tokio::test]
1964 async fn test_adapter_reports_absent_paths_as_missing() {
1965 let session_id = SessionId::new();
1966 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
1967 let adapter = SessionFileSystemAdapter::new(session_id, store);
1968
1969 assert!(
1973 !adapter
1974 .exists(Path::new("/workspace/nope.txt"))
1975 .await
1976 .unwrap()
1977 );
1978 assert!(
1979 adapter
1980 .stat(Path::new("/workspace/nope.txt"))
1981 .await
1982 .is_err()
1983 );
1984
1985 adapter
1986 .mkdir(Path::new("/workspace/realdir"), false)
1987 .await
1988 .unwrap();
1989 assert!(
1990 adapter
1991 .exists(Path::new("/workspace/realdir"))
1992 .await
1993 .unwrap()
1994 );
1995 }
1996
1997 #[tokio::test]
1998 async fn test_bash_touch_creates_and_updates_files() {
1999 let (context, _guard) = create_context_with_mock_store();
2000 let tool = BashTool::default();
2001
2002 let result = tool
2005 .execute_with_context(
2006 json!({"commands": "touch /workspace/a.txt && touch /workspace/a.txt && ls /workspace"}),
2007 &context,
2008 )
2009 .await;
2010
2011 match result {
2012 ToolExecutionResult::Success(output) => {
2013 assert_eq!(output["exit_code"], 0, "stderr: {}", output["stderr"]);
2014 assert!(
2015 output["stdout"].as_str().unwrap_or("").contains("a.txt"),
2016 "expected a.txt in listing, got {:?}",
2017 output["stdout"]
2018 );
2019 }
2020 other => panic!("Expected success result, got {other:?}"),
2021 }
2022 }
2023
2024 #[tokio::test]
2025 async fn test_bash_pipe_command() {
2026 let (context, _) = create_context_with_mock_store();
2027 let tool = BashTool::default();
2028
2029 let result = tool
2030 .execute_with_context(json!({"commands": "echo hello | cat"}), &context)
2031 .await;
2032
2033 if let ToolExecutionResult::Success(output) = result {
2034 assert_eq!(output["stdout"], "hello\n");
2035 assert_eq!(output["exit_code"], 0);
2036 } else {
2037 panic!("Expected success result");
2038 }
2039 }
2040
2041 #[tokio::test]
2042 async fn test_bash_arithmetic() {
2043 let (context, _) = create_context_with_mock_store();
2044 let tool = BashTool::default();
2045
2046 let result = tool
2047 .execute_with_context(json!({"commands": "echo $((2 + 3 * 4))"}), &context)
2048 .await;
2049
2050 if let ToolExecutionResult::Success(output) = result {
2051 assert_eq!(output["stdout"], "14\n");
2052 } else {
2053 panic!("Expected success result");
2054 }
2055 }
2056
2057 #[tokio::test]
2058 async fn test_bash_command_substitution() {
2059 let (context, _) = create_context_with_mock_store();
2060 let tool = BashTool::default();
2061
2062 let result = tool
2063 .execute_with_context(json!({"commands": "echo $(echo nested)"}), &context)
2064 .await;
2065
2066 if let ToolExecutionResult::Success(output) = result {
2067 assert_eq!(output["stdout"], "nested\n");
2068 } else {
2069 panic!("Expected success result");
2070 }
2071 }
2072
2073 #[tokio::test]
2083 async fn test_bash_write_from_root_succeeds() {
2084 let (context, _) = create_context_with_mock_store();
2085 let tool = BashTool::default();
2086
2087 let result = tool
2089 .execute_with_context(
2090 json!({"commands": "echo hi > /tmp/note.txt && cat /tmp/note.txt"}),
2091 &context,
2092 )
2093 .await;
2094
2095 match result {
2096 ToolExecutionResult::Success(output) => {
2097 assert_eq!(output["exit_code"], 0, "got: {:?}", output);
2098 assert_eq!(output["stdout"], "hi\n");
2099 }
2100 other => panic!("expected success, got: {:?}", other),
2101 }
2102 }
2103
2104 #[tokio::test]
2105 async fn test_bash_read_missing_file_fails_as_not_found() {
2106 let (context, _) = create_context_with_mock_store();
2107 let tool = BashTool::default();
2108
2109 let result = tool
2112 .execute_with_context(json!({"commands": "cat /etc/passwd"}), &context)
2113 .await;
2114
2115 match result {
2116 ToolExecutionResult::Success(output) => {
2117 assert_ne!(
2118 output["exit_code"], 0,
2119 "missing file should fail: {:?}",
2120 output
2121 );
2122 }
2123 ToolExecutionResult::ToolError(msg) => {
2124 assert!(
2125 msg.contains("not found") || msg.contains("No such"),
2126 "got: {}",
2127 msg
2128 );
2129 }
2130 _ => panic!("Unexpected result type"),
2131 }
2132 }
2133
2134 #[tokio::test]
2135 async fn test_bash_mkdir_from_root_succeeds() {
2136 let (context, _) = create_context_with_mock_store();
2137 let tool = BashTool::default();
2138
2139 let result = tool
2140 .execute_with_context(json!({"commands": "mkdir /tmp/sub && echo done"}), &context)
2141 .await;
2142
2143 match result {
2144 ToolExecutionResult::Success(output) => {
2145 assert_eq!(output["exit_code"], 0, "got: {:?}", output);
2146 assert_eq!(output["stdout"], "done\n");
2147 }
2148 other => panic!("expected success, got: {:?}", other),
2149 }
2150 }
2151
2152 #[tokio::test]
2157 async fn test_bash_custom_working_dir() {
2158 let (context, _) = create_context_with_mock_store();
2159 let tool = BashTool::default();
2160
2161 let result = tool
2163 .execute_with_context(json!({"commands": "mkdir -p /workspace/mydir"}), &context)
2164 .await;
2165 assert!(matches!(result, ToolExecutionResult::Success(_)));
2166
2167 let result = tool
2169 .execute_with_context(
2170 json!({
2171 "commands": "pwd",
2172 "working_dir": "/workspace/mydir"
2173 }),
2174 &context,
2175 )
2176 .await;
2177
2178 if let ToolExecutionResult::Success(output) = result {
2179 assert_eq!(output["stdout"], "/workspace/mydir\n");
2180 } else {
2181 panic!("Expected success result");
2182 }
2183 }
2184
2185 #[tokio::test]
2190 async fn test_bash_false_command_exit_code() {
2191 let (context, _) = create_context_with_mock_store();
2192 let tool = BashTool::default();
2193
2194 let result = tool
2195 .execute_with_context(json!({"commands": "false"}), &context)
2196 .await;
2197
2198 if let ToolExecutionResult::Success(output) = result {
2199 assert_eq!(output["exit_code"], 1);
2200 assert_eq!(output["success"], false);
2201 } else {
2202 panic!("Expected success result with non-zero exit code");
2203 }
2204 }
2205
2206 #[tokio::test]
2207 async fn test_bash_true_command_exit_code() {
2208 let (context, _) = create_context_with_mock_store();
2209 let tool = BashTool::default();
2210
2211 let result = tool
2212 .execute_with_context(json!({"commands": "true"}), &context)
2213 .await;
2214
2215 if let ToolExecutionResult::Success(output) = result {
2216 assert_eq!(output["exit_code"], 0);
2217 assert_eq!(output["success"], true);
2218 } else {
2219 panic!("Expected success result");
2220 }
2221 }
2222
2223 #[tokio::test]
2228 async fn test_adapter_read_write_workspace_file() {
2229 let session_id = SessionId::new();
2230 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2231 let adapter = SessionFileSystemAdapter::new(session_id, store);
2232
2233 adapter
2235 .write_file(Path::new("/workspace/test.txt"), b"hello")
2236 .await
2237 .unwrap();
2238
2239 let content = adapter
2241 .read_file(Path::new("/workspace/test.txt"))
2242 .await
2243 .unwrap();
2244 assert_eq!(content, b"hello");
2245 }
2246
2247 #[tokio::test]
2248 async fn test_adapter_read_missing_file_is_not_found() {
2249 let adapter = mount_adapter();
2250
2251 let result = adapter.read_file(Path::new("/tmp/file.txt")).await;
2254 let err = result.unwrap_err();
2255 assert!(
2256 matches!(&err, bashkit::Error::Io(io) if io.kind() == std::io::ErrorKind::NotFound),
2257 "expected NotFound, got: {err}"
2258 );
2259 }
2260
2261 #[tokio::test]
2262 async fn test_adapter_write_from_root_succeeds() {
2263 let adapter = mount_adapter();
2264
2265 adapter
2267 .write_file(Path::new("/tmp/file.txt"), b"data")
2268 .await
2269 .unwrap();
2270 assert_eq!(
2271 adapter.read_file(Path::new("/tmp/file.txt")).await.unwrap(),
2272 b"data"
2273 );
2274 }
2275
2276 #[tokio::test]
2277 async fn test_adapter_stat_workspace_root() {
2278 let session_id = SessionId::new();
2279 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2280 let adapter = SessionFileSystemAdapter::new(session_id, store);
2281
2282 let stat = adapter.stat(Path::new("/workspace")).await.unwrap();
2283 assert!(stat.file_type.is_dir());
2284 }
2285
2286 #[tokio::test]
2287 async fn test_adapter_stat_directory_returns_dir_type() {
2288 let session_id = SessionId::new();
2289 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2290 let adapter = SessionFileSystemAdapter::new(session_id, store);
2291
2292 adapter
2294 .mkdir(Path::new("/workspace/mydir"), false)
2295 .await
2296 .unwrap();
2297
2298 let stat = adapter.stat(Path::new("/workspace/mydir")).await.unwrap();
2300 assert!(
2301 stat.file_type.is_dir(),
2302 "Expected directory but got file type for /workspace/mydir"
2303 );
2304 }
2305
2306 #[tokio::test]
2307 async fn test_adapter_stat_file_returns_file_type() {
2308 let session_id = SessionId::new();
2309 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2310 let adapter = SessionFileSystemAdapter::new(session_id, store);
2311
2312 adapter
2314 .write_file(Path::new("/workspace/test.txt"), b"hello")
2315 .await
2316 .unwrap();
2317
2318 let stat = adapter
2320 .stat(Path::new("/workspace/test.txt"))
2321 .await
2322 .unwrap();
2323 assert!(
2324 stat.file_type.is_file(),
2325 "Expected file but got directory type for /workspace/test.txt"
2326 );
2327 assert_eq!(stat.size, 5);
2328 }
2329
2330 #[tokio::test]
2331 async fn test_adapter_exists_workspace() {
2332 let session_id = SessionId::new();
2333 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2334 let adapter = SessionFileSystemAdapter::new(session_id, store);
2335
2336 assert!(adapter.exists(Path::new("/workspace")).await.unwrap());
2338
2339 assert!(!adapter.exists(Path::new("/tmp")).await.unwrap());
2341 }
2342
2343 #[tokio::test]
2344 async fn test_adapter_mkdir_and_list() {
2345 let session_id = SessionId::new();
2346 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2347 let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
2348
2349 adapter
2351 .mkdir(Path::new("/workspace/mydir"), false)
2352 .await
2353 .unwrap();
2354
2355 adapter
2357 .write_file(Path::new("/workspace/mydir/file.txt"), b"content")
2358 .await
2359 .unwrap();
2360
2361 let entries = adapter
2363 .read_dir(Path::new("/workspace/mydir"))
2364 .await
2365 .unwrap();
2366 assert_eq!(entries.len(), 1);
2367 assert_eq!(entries[0].name, "file.txt");
2368 }
2369
2370 #[tokio::test]
2371 async fn test_adapter_rename_file() {
2372 let session_id = SessionId::new();
2373 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2374 let adapter = SessionFileSystemAdapter::new(session_id, store);
2375
2376 adapter
2378 .write_file(Path::new("/workspace/old.txt"), b"data")
2379 .await
2380 .unwrap();
2381
2382 adapter
2384 .rename(
2385 Path::new("/workspace/old.txt"),
2386 Path::new("/workspace/new.txt"),
2387 )
2388 .await
2389 .unwrap();
2390
2391 let old_result = adapter.read_file(Path::new("/workspace/old.txt")).await;
2393 assert!(old_result.is_err());
2394
2395 let new_content = adapter
2397 .read_file(Path::new("/workspace/new.txt"))
2398 .await
2399 .unwrap();
2400 assert_eq!(new_content, b"data");
2401 }
2402
2403 #[tokio::test]
2404 async fn test_adapter_copy_file() {
2405 let session_id = SessionId::new();
2406 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2407 let adapter = SessionFileSystemAdapter::new(session_id, store);
2408
2409 adapter
2411 .write_file(Path::new("/workspace/source.txt"), b"copy me")
2412 .await
2413 .unwrap();
2414
2415 adapter
2417 .copy(
2418 Path::new("/workspace/source.txt"),
2419 Path::new("/workspace/dest.txt"),
2420 )
2421 .await
2422 .unwrap();
2423
2424 let source = adapter
2426 .read_file(Path::new("/workspace/source.txt"))
2427 .await
2428 .unwrap();
2429 let dest = adapter
2430 .read_file(Path::new("/workspace/dest.txt"))
2431 .await
2432 .unwrap();
2433 assert_eq!(source, dest);
2434 assert_eq!(source, b"copy me");
2435 }
2436
2437 #[tokio::test]
2438 async fn test_adapter_append_file() {
2439 let session_id = SessionId::new();
2440 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2441 let adapter = SessionFileSystemAdapter::new(session_id, store);
2442
2443 adapter
2445 .write_file(Path::new("/workspace/log.txt"), b"line1\n")
2446 .await
2447 .unwrap();
2448
2449 adapter
2451 .append_file(Path::new("/workspace/log.txt"), b"line2\n")
2452 .await
2453 .unwrap();
2454
2455 let content = adapter
2457 .read_file(Path::new("/workspace/log.txt"))
2458 .await
2459 .unwrap();
2460 assert_eq!(content, b"line1\nline2\n");
2461 }
2462
2463 #[tokio::test]
2464 async fn test_adapter_symlink_not_supported() {
2465 let session_id = SessionId::new();
2466 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2467 let adapter = SessionFileSystemAdapter::new(session_id, store);
2468
2469 let result = adapter
2470 .symlink(Path::new("/workspace/target"), Path::new("/workspace/link"))
2471 .await;
2472 assert!(result.is_err());
2473 assert!(result.unwrap_err().to_string().contains("not supported"));
2474 }
2475
2476 #[tokio::test]
2477 async fn test_adapter_chmod_is_noop() {
2478 let session_id = SessionId::new();
2479 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2480 let adapter = SessionFileSystemAdapter::new(session_id, store);
2481
2482 let result = adapter.chmod(Path::new("/workspace/file.txt"), 0o755).await;
2484 assert!(result.is_ok());
2485 }
2486
2487 #[tokio::test]
2492 async fn test_bash_max_input_bytes_limit() {
2493 let (context, _) = create_context_with_mock_store();
2494 let tool = BashTool::default();
2495
2496 let large_script = "echo ".to_string() + &"x".repeat(1_100_000);
2498
2499 let result = tool
2500 .execute_with_context(json!({"commands": large_script}), &context)
2501 .await;
2502
2503 match result {
2505 ToolExecutionResult::ToolError(msg) => {
2506 assert!(
2507 msg.contains("too large") || msg.contains("input") || msg.contains("limit"),
2508 "Expected input size error, got: {}",
2509 msg
2510 );
2511 }
2512 ToolExecutionResult::Success(output) => {
2513 panic!(
2514 "Expected error for oversized script, got success: {:?}",
2515 output
2516 );
2517 }
2518 _ => panic!("Unexpected result type"),
2519 }
2520 }
2521
2522 #[tokio::test]
2523 async fn test_bash_loop_within_limit() {
2524 let (context, _) = create_context_with_mock_store();
2525 let tool = BashTool::default();
2526
2527 let command = "i=0; while [ $i -lt 100 ]; do i=$((i + 1)); done; echo $i";
2529
2530 let result = tool
2531 .execute_with_context(json!({"commands": command}), &context)
2532 .await;
2533
2534 if let ToolExecutionResult::Success(output) = result {
2536 assert_eq!(output["exit_code"], 0);
2537 assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "100");
2538 } else {
2539 panic!("Expected success for loop within limit: {:?}", result);
2540 }
2541 }
2542
2543 #[tokio::test]
2544 async fn test_bash_function_calls() {
2545 let (context, _) = create_context_with_mock_store();
2546 let tool = BashTool::default();
2547
2548 let command = r#"
2550 greet() {
2551 echo "Hello, $1!"
2552 }
2553 greet world
2554 "#;
2555
2556 let result = tool
2557 .execute_with_context(json!({"commands": command}), &context)
2558 .await;
2559
2560 if let ToolExecutionResult::Success(output) = result {
2562 assert_eq!(output["exit_code"], 0);
2563 assert!(
2564 output["stdout"]
2565 .as_str()
2566 .unwrap_or("")
2567 .contains("Hello, world!")
2568 );
2569 } else {
2570 panic!("Expected success for function call: {:?}", result);
2571 }
2572 }
2573
2574 #[tokio::test]
2575 async fn test_bash_arithmetic_expressions() {
2576 let (context, _) = create_context_with_mock_store();
2577 let tool = BashTool::default();
2578
2579 let command = "echo $((1 + 2 * 3))";
2581
2582 let result = tool
2583 .execute_with_context(json!({"commands": command}), &context)
2584 .await;
2585
2586 if let ToolExecutionResult::Success(output) = result {
2588 assert_eq!(output["exit_code"], 0);
2589 assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "7");
2590 } else {
2591 panic!("Expected success for arithmetic expression: {:?}", result);
2592 }
2593 }
2594
2595 #[tokio::test]
2596 async fn test_bash_commands_within_limit() {
2597 let (context, _) = create_context_with_mock_store();
2598 let tool = BashTool::default();
2599
2600 let command = "for i in $(seq 1 100); do true; done; echo done";
2602
2603 let result = tool
2604 .execute_with_context(json!({"commands": command}), &context)
2605 .await;
2606
2607 if let ToolExecutionResult::Success(output) = result {
2609 assert_eq!(output["exit_code"], 0);
2610 assert!(output["stdout"].as_str().unwrap_or("").contains("done"));
2611 } else {
2612 panic!("Expected success for commands within limit: {:?}", result);
2613 }
2614 }
2615
2616 #[tokio::test]
2621 async fn test_bash_execute_script_by_absolute_path() {
2622 let (context, _) = create_context_with_mock_store();
2623 let tool = BashTool::default();
2624
2625 let result = tool
2627 .execute_with_context(
2628 json!({"commands": "cat > /workspace/test.sh << 'EOF'\n#!/bin/bash\necho hello\nEOF"}),
2629 &context,
2630 )
2631 .await;
2632 assert!(
2633 matches!(result, ToolExecutionResult::Success(_)),
2634 "Failed to create script: {:?}",
2635 result
2636 );
2637
2638 let result = tool
2640 .execute_with_context(json!({"commands": "/workspace/test.sh"}), &context)
2641 .await;
2642
2643 if let ToolExecutionResult::Success(output) = result {
2644 assert_eq!(output["exit_code"], 0);
2645 assert_eq!(output["stdout"], "hello\n");
2646 } else {
2647 panic!("Expected success, got: {:?}", result);
2648 }
2649 }
2650
2651 #[tokio::test]
2652 async fn test_bash_execute_script_with_args() {
2653 let (context, _) = create_context_with_mock_store();
2654 let tool = BashTool::default();
2655
2656 let result = tool
2658 .execute_with_context(
2659 json!({"commands": "cat > /workspace/greet.sh << 'EOF'\n#!/bin/bash\necho \"Hello, $1! You are $2.\"\nEOF"}),
2660 &context,
2661 )
2662 .await;
2663 assert!(matches!(result, ToolExecutionResult::Success(_)));
2664
2665 let result = tool
2667 .execute_with_context(
2668 json!({"commands": "/workspace/greet.sh world awesome"}),
2669 &context,
2670 )
2671 .await;
2672
2673 if let ToolExecutionResult::Success(output) = result {
2674 assert_eq!(output["exit_code"], 0);
2675 assert_eq!(output["stdout"], "Hello, world! You are awesome.\n");
2676 } else {
2677 panic!("Expected success, got: {:?}", result);
2678 }
2679 }
2680
2681 #[tokio::test]
2682 async fn test_bash_execute_script_without_shebang() {
2683 let (context, _) = create_context_with_mock_store();
2684 let tool = BashTool::default();
2685
2686 let result = tool
2688 .execute_with_context(
2689 json!({"commands": "cat > /workspace/simple.sh << 'EOF'\necho simple\nEOF"}),
2690 &context,
2691 )
2692 .await;
2693 assert!(matches!(result, ToolExecutionResult::Success(_)));
2694
2695 let result = tool
2697 .execute_with_context(json!({"commands": "/workspace/simple.sh"}), &context)
2698 .await;
2699
2700 if let ToolExecutionResult::Success(output) = result {
2701 assert_eq!(output["exit_code"], 0);
2702 assert_eq!(output["stdout"], "simple\n");
2703 } else {
2704 panic!("Expected success, got: {:?}", result);
2705 }
2706 }
2707
2708 #[tokio::test]
2709 async fn test_bash_execute_nonexistent_script() {
2710 let (context, _) = create_context_with_mock_store();
2711 let tool = BashTool::default();
2712
2713 let result = tool
2715 .execute_with_context(json!({"commands": "/workspace/nonexistent.sh"}), &context)
2716 .await;
2717
2718 if let ToolExecutionResult::Success(output) = result {
2719 assert_ne!(output["exit_code"], 0, "Should fail with non-zero exit");
2720 let stderr = output["stderr"].as_str().unwrap_or("");
2721 assert!(
2722 stderr.contains("No such file") || stderr.contains("not found"),
2723 "Expected file not found error, got stderr: {}",
2724 stderr
2725 );
2726 } else {
2727 panic!(
2728 "Expected success result with error output, got: {:?}",
2729 result
2730 );
2731 }
2732 }
2733
2734 #[tokio::test]
2735 async fn test_bash_execute_script_in_nested_dir() {
2736 let (context, _) = create_context_with_mock_store();
2737 let tool = BashTool::default();
2738
2739 let setup = tool
2741 .execute_with_context(
2742 json!({"commands": "mkdir -p /workspace/.agents/skills/nav/scripts && cat > /workspace/.agents/skills/nav/scripts/nav.sh << 'EOF'\n#!/bin/bash\necho \"navigating $1\"\nEOF"}),
2743 &context,
2744 )
2745 .await;
2746 assert!(matches!(setup, ToolExecutionResult::Success(_)));
2747
2748 let result = tool
2750 .execute_with_context(
2751 json!({"commands": "/workspace/.agents/skills/nav/scripts/nav.sh dist"}),
2752 &context,
2753 )
2754 .await;
2755
2756 if let ToolExecutionResult::Success(output) = result {
2757 assert_eq!(output["exit_code"], 0);
2758 assert_eq!(output["stdout"], "navigating dist\n");
2759 } else {
2760 panic!("Expected success, got: {:?}", result);
2761 }
2762 }
2763
2764 #[tokio::test]
2765 async fn test_bash_file_mode_is_executable() {
2766 let (context, _) = create_context_with_mock_store();
2767 let tool = BashTool::default();
2768
2769 let result = tool
2771 .execute_with_context(
2772 json!({"commands": "echo 'echo hi' > /workspace/check.sh && test -x /workspace/check.sh && echo 'executable' || echo 'not executable'"}),
2773 &context,
2774 )
2775 .await;
2776
2777 if let ToolExecutionResult::Success(output) = result {
2778 assert_eq!(output["exit_code"], 0);
2779 assert!(
2780 output["stdout"]
2781 .as_str()
2782 .unwrap_or("")
2783 .contains("executable"),
2784 "File should be reported as executable, got: {}",
2785 output["stdout"]
2786 );
2787 } else {
2788 panic!("Expected success, got: {:?}", result);
2789 }
2790 }
2791
2792 #[tokio::test]
2793 async fn test_bash_execute_script_with_exit_code() {
2794 let (context, _) = create_context_with_mock_store();
2795 let tool = BashTool::default();
2796
2797 let result = tool
2799 .execute_with_context(
2800 json!({"commands": "cat > /workspace/fail.sh << 'EOF'\n#!/bin/bash\necho failing\nexit 42\nEOF"}),
2801 &context,
2802 )
2803 .await;
2804 assert!(matches!(result, ToolExecutionResult::Success(_)));
2805
2806 let result = tool
2808 .execute_with_context(
2809 json!({"commands": "/workspace/fail.sh; echo \"code: $?\""}),
2810 &context,
2811 )
2812 .await;
2813
2814 if let ToolExecutionResult::Success(output) = result {
2815 let stdout = output["stdout"].as_str().unwrap_or("");
2816 assert!(stdout.contains("failing"), "Script should have run");
2817 assert!(
2818 stdout.contains("code: 42"),
2819 "Exit code should propagate, got: {}",
2820 stdout
2821 );
2822 } else {
2823 panic!("Expected success, got: {:?}", result);
2824 }
2825 }
2826
2827 #[tokio::test]
2832 async fn test_bash_overwrite_existing_file() {
2833 let (context, _) = create_context_with_mock_store();
2834 let tool = BashTool::default();
2835
2836 let result = tool
2838 .execute_with_context(
2839 json!({"commands": "echo 'first' > /workspace/overwrite.txt"}),
2840 &context,
2841 )
2842 .await;
2843 assert!(matches!(result, ToolExecutionResult::Success(_)));
2844
2845 let result = tool
2847 .execute_with_context(
2848 json!({"commands": "echo 'second' > /workspace/overwrite.txt"}),
2849 &context,
2850 )
2851 .await;
2852 if let ToolExecutionResult::Success(output) = &result {
2853 assert_eq!(output["exit_code"], 0, "Overwrite should succeed");
2854 } else {
2855 panic!("Expected success on overwrite, got: {:?}", result);
2856 }
2857
2858 let result = tool
2860 .execute_with_context(
2861 json!({"commands": "cat /workspace/overwrite.txt"}),
2862 &context,
2863 )
2864 .await;
2865 if let ToolExecutionResult::Success(output) = result {
2866 assert_eq!(output["stdout"], "second\n");
2867 } else {
2868 panic!("Expected success on read, got: {:?}", result);
2869 }
2870 }
2871
2872 #[tokio::test]
2873 async fn test_bash_append_to_existing_file() {
2874 let (context, _) = create_context_with_mock_store();
2875 let tool = BashTool::default();
2876
2877 let result = tool
2879 .execute_with_context(
2880 json!({"commands": "echo 'line1' > /workspace/append.txt"}),
2881 &context,
2882 )
2883 .await;
2884 assert!(matches!(result, ToolExecutionResult::Success(_)));
2885
2886 let result = tool
2888 .execute_with_context(
2889 json!({"commands": "echo 'line2' >> /workspace/append.txt"}),
2890 &context,
2891 )
2892 .await;
2893 if let ToolExecutionResult::Success(output) = &result {
2894 assert_eq!(output["exit_code"], 0, "Append should succeed");
2895 } else {
2896 panic!("Expected success on append, got: {:?}", result);
2897 }
2898
2899 let result = tool
2901 .execute_with_context(json!({"commands": "cat /workspace/append.txt"}), &context)
2902 .await;
2903 if let ToolExecutionResult::Success(output) = result {
2904 assert_eq!(output["stdout"], "line1\nline2\n");
2905 } else {
2906 panic!("Expected success on read");
2907 }
2908 }
2909
2910 #[tokio::test]
2911 async fn test_adapter_overwrite_existing_file() {
2912 let session_id = SessionId::new();
2913 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2914 let adapter = SessionFileSystemAdapter::new(session_id, store);
2915
2916 adapter
2918 .write_file(Path::new("/workspace/ow.txt"), b"original")
2919 .await
2920 .unwrap();
2921
2922 adapter
2924 .write_file(Path::new("/workspace/ow.txt"), b"updated")
2925 .await
2926 .unwrap();
2927
2928 let content = adapter
2930 .read_file(Path::new("/workspace/ow.txt"))
2931 .await
2932 .unwrap();
2933 assert_eq!(content, b"updated");
2934 }
2935
2936 #[tokio::test]
2937 async fn test_adapter_append_to_existing_file() {
2938 let session_id = SessionId::new();
2939 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2940 let adapter = SessionFileSystemAdapter::new(session_id, store);
2941
2942 adapter
2944 .write_file(Path::new("/workspace/ap.txt"), b"AAA")
2945 .await
2946 .unwrap();
2947
2948 adapter
2950 .append_file(Path::new("/workspace/ap.txt"), b"BBB")
2951 .await
2952 .unwrap();
2953
2954 let content = adapter
2956 .read_file(Path::new("/workspace/ap.txt"))
2957 .await
2958 .unwrap();
2959 assert_eq!(content, b"AAABBB");
2960 }
2961
2962 #[tokio::test]
2963 async fn test_bash_redirect_creates_parent_dirs() {
2964 let (context, _) = create_context_with_mock_store();
2965 let tool = BashTool::default();
2966
2967 let result = tool
2969 .execute_with_context(
2970 json!({"commands": "echo 'deep' > /workspace/a/b/c/deep.txt"}),
2971 &context,
2972 )
2973 .await;
2974 if let ToolExecutionResult::Success(output) = &result {
2975 assert_eq!(output["exit_code"], 0, "Nested write should succeed");
2976 } else {
2977 panic!("Expected success, got: {:?}", result);
2978 }
2979
2980 let result = tool
2982 .execute_with_context(
2983 json!({"commands": "cat /workspace/a/b/c/deep.txt"}),
2984 &context,
2985 )
2986 .await;
2987 if let ToolExecutionResult::Success(output) = result {
2988 assert_eq!(output["stdout"], "deep\n");
2989 } else {
2990 panic!("Expected success on read");
2991 }
2992 }
2993
2994 #[test]
2999 fn test_bashkit_tool_description_is_nonempty() {
3000 let desc = BASHKIT_TOOL.description();
3001 assert!(
3002 !desc.is_empty(),
3003 "bashkit tool description should not be empty"
3004 );
3005 assert!(
3007 desc.to_lowercase().contains("bash") || desc.to_lowercase().contains("command"),
3008 "description should mention bash or command, got: {}",
3009 desc
3010 );
3011 }
3012
3013 #[test]
3014 fn test_bashkit_tool_system_prompt_is_nonempty() {
3015 let prompt = BASHKIT_TOOL.system_prompt();
3016 assert!(
3017 !prompt.is_empty(),
3018 "bashkit system prompt should not be empty"
3019 );
3020 assert!(
3021 prompt.contains("everruns"),
3022 "system prompt should contain configured identity 'everruns', got: {}",
3023 prompt
3024 );
3025 }
3026
3027 #[test]
3028 fn test_bashkit_static_description_matches_tool() {
3029 let direct_desc = BASHKIT_TOOL.description();
3031 let static_desc: &str = &TOOL_DESCRIPTION;
3032 assert_eq!(static_desc, direct_desc);
3033
3034 let direct_prompt = BASHKIT_TOOL.system_prompt();
3035 let static_prompt: &str = &TOOL_SYSTEM_PROMPT;
3036 assert!(
3038 static_prompt.starts_with(&direct_prompt),
3039 "system prompt should start with bashkit prompt"
3040 );
3041 assert!(
3042 static_prompt.contains("Output economy"),
3043 "system prompt should include output economy hint"
3044 );
3045 }
3046
3047 #[test]
3048 fn test_bashkit_tool_builder_configuration() {
3049 let _desc = BASHKIT_TOOL.description();
3052 let _prompt = BASHKIT_TOOL.system_prompt();
3053 }
3055
3056 #[test]
3057 fn test_bash_tool_display_name() {
3058 let tool = BashTool::default();
3059 assert_eq!(tool.display_name(), Some("Bash"));
3060 }
3061
3062 #[test]
3063 fn test_bash_tool_is_never_deferred() {
3064 let tool = BashTool::default();
3065 assert_eq!(tool.deferrable_policy(), DeferrablePolicy::Never);
3066 }
3067
3068 #[test]
3069 fn test_bash_tool_parameters_schema_structure() {
3070 let tool = BashTool::default();
3071 let schema = tool.parameters_schema();
3072
3073 assert_eq!(schema["type"], "object");
3075 assert!(schema["properties"]["commands"].is_object());
3076
3077 assert!(schema["properties"]["working_dir"].is_object());
3079 assert!(schema["properties"]["timeout_ms"].is_object());
3080
3081 let required = schema["required"].as_array().unwrap();
3083 assert!(required.contains(&json!("commands")));
3084 }
3085
3086 #[test]
3087 fn test_execution_limits_configuration() {
3088 let limits = execution_limits();
3089 let _ = limits;
3092 }
3093
3094 #[test]
3099 fn test_adapter_is_search_capable() {
3100 let session_id = SessionId::new();
3101 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
3102 let adapter = SessionFileSystemAdapter::new(session_id, store);
3103
3104 let sc = adapter.as_search_capable();
3105 assert!(
3106 sc.is_some(),
3107 "SessionFileSystemAdapter should be SearchCapable"
3108 );
3109
3110 let provider = sc.unwrap().search_provider(Path::new("/workspace"));
3111 assert!(provider.is_some(), "Should return a SearchProvider");
3112
3113 let caps = provider.unwrap().capabilities();
3114 assert!(caps.content_search, "Should support content search");
3115 assert!(caps.regex, "Should support regex patterns");
3116 }
3117
3118 #[tokio::test]
3119 async fn test_search_provider_returns_grep_results() {
3120 let session_id = SessionId::new();
3121 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
3122 let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
3123
3124 adapter
3126 .write_file(
3127 Path::new("/workspace/hello.txt"),
3128 b"hello world\ngoodbye world",
3129 )
3130 .await
3131 .unwrap();
3132 adapter
3133 .write_file(Path::new("/workspace/other.txt"), b"no match here")
3134 .await
3135 .unwrap();
3136
3137 let sc = adapter.as_search_capable().unwrap();
3138 let provider = sc.search_provider(Path::new("/workspace")).unwrap();
3139
3140 let results = provider
3141 .search(&SearchQuery {
3142 pattern: "hello".into(),
3143 is_regex: false,
3144 case_insensitive: false,
3145 root: PathBuf::from("/workspace"),
3146 glob_filter: None,
3147 max_results: None,
3148 })
3149 .unwrap();
3150
3151 assert_eq!(results.matches.len(), 1);
3152 assert_eq!(
3153 results.matches[0].path,
3154 PathBuf::from("/workspace/hello.txt")
3155 );
3156 assert_eq!(results.matches[0].line_number, 1);
3157 assert_eq!(results.matches[0].line_content, "hello world");
3158 }
3159
3160 #[tokio::test]
3161 async fn test_search_provider_truncates_at_max_results() {
3162 let session_id = SessionId::new();
3163 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
3164 let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
3165
3166 adapter
3167 .write_file(
3168 Path::new("/workspace/many.txt"),
3169 b"match line 1\nmatch line 2\nmatch line 3\nmatch line 4",
3170 )
3171 .await
3172 .unwrap();
3173
3174 let sc = adapter.as_search_capable().unwrap();
3175 let provider = sc.search_provider(Path::new("/workspace")).unwrap();
3176
3177 let results = provider
3178 .search(&SearchQuery {
3179 pattern: "match".into(),
3180 is_regex: false,
3181 case_insensitive: false,
3182 root: PathBuf::from("/workspace"),
3183 glob_filter: None,
3184 max_results: Some(2),
3185 })
3186 .unwrap();
3187
3188 assert_eq!(results.matches.len(), 2);
3189 assert!(results.truncated);
3190 }
3191
3192 #[tokio::test]
3193 async fn test_bash_grep_uses_indexed_search() {
3194 let (context, _) = create_context_with_mock_store();
3195 let tool = BashTool::default();
3196
3197 tool.execute_with_context(
3199 json!({"commands": "mkdir -p /workspace/src && echo 'fn main() { println!(\"hello\"); }' > /workspace/src/main.rs && echo 'fn test() {}' > /workspace/src/test.rs"}),
3200 &context,
3201 )
3202 .await;
3203
3204 let result = tool
3206 .execute_with_context(json!({"commands": "grep -r 'fn' /workspace/src"}), &context)
3207 .await;
3208
3209 if let ToolExecutionResult::Success(output) = result {
3210 assert_eq!(output["exit_code"], 0);
3211 let stdout = output["stdout"].as_str().unwrap_or("");
3212 assert!(
3213 stdout.contains("fn main") || stdout.contains("fn test"),
3214 "grep -r should find matches via indexed search, got: {}",
3215 stdout
3216 );
3217 } else {
3218 panic!("Expected success result, got: {:?}", result);
3219 }
3220 }
3221
3222 #[test]
3223 fn test_parameters_schema_delegates_to_bashkit() {
3224 let tool = BashTool::default();
3225 let schema = tool.parameters_schema();
3226 let bashkit_schema = BASHKIT_TOOL.input_schema();
3227
3228 let bashkit_props = bashkit_schema["properties"].as_object().unwrap();
3230 let our_props = schema["properties"].as_object().unwrap();
3231 for key in bashkit_props.keys() {
3232 assert!(
3233 our_props.contains_key(key),
3234 "bashkit property '{key}' missing from parameters_schema"
3235 );
3236 }
3237
3238 let bashkit_required = bashkit_schema["required"].as_array().unwrap();
3240 let our_required = schema["required"].as_array().unwrap();
3241 for req in bashkit_required {
3242 assert!(
3243 our_required.contains(req),
3244 "bashkit required field {req} missing from parameters_schema"
3245 );
3246 }
3247
3248 assert!(
3250 our_props.contains_key("working_dir"),
3251 "working_dir must be in parameters_schema"
3252 );
3253 }
3254
3255 #[test]
3260 fn truncate_for_log_returns_short_strings_unchanged() {
3261 assert_eq!(truncate_for_log("hello", 100), "hello");
3262 assert_eq!(truncate_for_log("", 100), "");
3263 }
3264
3265 #[test]
3266 fn truncate_for_log_stays_within_budget_and_marks() {
3267 let input = "a".repeat(500);
3268 let out = truncate_for_log(&input, 100);
3269 assert!(
3270 out.len() <= 100,
3271 "output exceeded budget: {} bytes",
3272 out.len()
3273 );
3274 assert!(out.ends_with("…[truncated]"));
3275 assert!(out.starts_with('a'));
3276 }
3277
3278 #[test]
3279 fn truncate_for_log_respects_utf8_boundaries() {
3280 let input = "🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀";
3283 let out = truncate_for_log(input, 20);
3284 assert!(out.len() <= 20);
3285 assert!(out.starts_with('🦀'));
3286 assert!(out.ends_with("…[truncated]"));
3287 }
3288
3289 #[test]
3290 fn truncate_for_log_omits_marker_when_budget_is_too_small() {
3291 let input = "abcdefghijklmnop";
3294 let out = truncate_for_log(input, 4);
3295 assert_eq!(out, "abcd");
3296 assert!(out.len() <= 4);
3297 }
3298
3299 #[tokio::test]
3300 async fn install_observability_hooks_fires_on_builtin_and_preserves_exit() {
3301 use bashkit::hooks::{HookAction, ToolResult};
3302 use std::sync::Arc;
3303 use std::sync::atomic::{AtomicU64, Ordering};
3304
3305 let tool_calls = Arc::new(AtomicU64::new(0));
3306 let counter = tool_calls.clone();
3307
3308 let session_id: SessionId = "session_0197a4a4c0c0780180000000000000ff".parse().unwrap();
3312 let builder = install_observability_hooks(Bash::builder(), session_id).after_tool(
3313 Box::new(move |r: ToolResult| {
3314 counter.fetch_add(1, Ordering::Relaxed);
3315 HookAction::Continue(r)
3316 }),
3317 );
3318
3319 let mut bash = builder.build();
3320 let result = bash.exec("echo hook-smoke").await.unwrap();
3321
3322 assert_eq!(result.exit_code, 0);
3323 assert_eq!(result.stdout.trim(), "hook-smoke");
3324 assert!(
3325 tool_calls.load(Ordering::Relaxed) >= 1,
3326 "after_tool hook should fire at least once for `echo`"
3327 );
3328 }
3329
3330 mod http_tests {
3335 use super::*;
3336 use crate::capabilities::bashkit_shell::egress_transport::tests::MockEgress;
3337 use crate::egress::{EgressError, EgressRequestKind, EgressSigning};
3338 use crate::network_access::NetworkAccessList;
3339
3340 fn http_context(egress: Option<Arc<MockEgress>>) -> ToolContext {
3341 let (mut context, _) = create_context_with_mock_store();
3342 if let Some(egress) = egress {
3343 context.egress_service = Some(egress);
3344 }
3345 context
3346 }
3347
3348 #[tokio::test]
3349 async fn http_disabled_by_default_even_with_egress_available() {
3350 let egress = Arc::new(MockEgress::with_responses(vec![]));
3351 let context = http_context(Some(egress.clone()));
3352 let tool = BashTool::default();
3353
3354 let result = tool
3355 .execute_with_context(
3356 json!({"commands": "curl -s http://93.184.216.34/ 2>&1; echo rc=$?"}),
3357 &context,
3358 )
3359 .await;
3360
3361 let ToolExecutionResult::Success(output) = result else {
3362 panic!("expected success result");
3363 };
3364 let combined = format!("{}{}", output["stdout"], output["stderr"]);
3365 assert!(
3366 !combined.contains("rc=0"),
3367 "curl must fail without enable_http, got: {combined}"
3368 );
3369 assert!(
3370 egress.requests.lock().unwrap().is_empty(),
3371 "no request may reach egress when HTTP is disabled"
3372 );
3373 }
3374
3375 #[tokio::test]
3376 async fn http_enable_without_egress_service_stays_offline() {
3377 let context = http_context(None);
3378 let tool = BashTool { enable_http: true };
3379
3380 let result = tool
3381 .execute_with_context(
3382 json!({"commands": "curl -s http://93.184.216.34/ 2>&1; echo rc=$?"}),
3383 &context,
3384 )
3385 .await;
3386
3387 let ToolExecutionResult::Success(output) = result else {
3388 panic!("expected success result");
3389 };
3390 let combined = format!("{}{}", output["stdout"], output["stderr"]);
3391 assert!(
3392 !combined.contains("rc=0"),
3393 "curl must fail without an egress service, got: {combined}"
3394 );
3395 }
3396
3397 #[tokio::test]
3398 async fn curl_routes_through_egress_and_forwards_policy_metadata() {
3399 let egress = Arc::new(MockEgress::with_responses(vec![MockEgress::ok(
3400 200,
3401 &[("content-type", "text/plain")],
3402 "egress-ok",
3403 )]));
3404 let acl = NetworkAccessList::allow_only(["93.184.216.34"]);
3405 let mut context = http_context(Some(egress.clone()));
3406 context.network_access = Some(acl.clone());
3407 let tool = BashTool { enable_http: true };
3408
3409 let result = tool
3410 .execute_with_context(
3411 json!({"commands": "curl -s http://93.184.216.34/data"}),
3412 &context,
3413 )
3414 .await;
3415
3416 let ToolExecutionResult::Success(output) = result else {
3417 panic!("expected success result");
3418 };
3419 assert_eq!(output["exit_code"], 0, "stderr: {}", output["stderr"]);
3420 assert!(
3421 output["stdout"].as_str().unwrap().contains("egress-ok"),
3422 "stdout: {}",
3423 output["stdout"]
3424 );
3425
3426 assert_eq!(*egress.send_calls.lock().unwrap(), 0);
3427 assert_eq!(*egress.stream_calls.lock().unwrap(), 1);
3428 let requests = egress.requests.lock().unwrap();
3429 assert_eq!(requests.len(), 1);
3430 let request = &requests[0];
3431 assert_eq!(request.method, "GET");
3432 assert_eq!(request.url, "http://93.184.216.34/data");
3433 assert_eq!(request.kind, EgressRequestKind::Capability);
3434 assert_eq!(request.signing, EgressSigning::PlatformDefault);
3435 assert_eq!(request.network_access, Some(acl));
3436 assert!(request.timeout_ms.is_some(), "deadline must be forwarded");
3437 let (host, addrs) = request.pinned_addrs.as_ref().expect("pinned addrs");
3440 assert_eq!(host, "93.184.216.34");
3441 assert_eq!(addrs[0].ip().to_string(), "93.184.216.34");
3442 assert_eq!(addrs[0].port(), 80);
3443 }
3444
3445 #[tokio::test]
3446 async fn egress_denial_surfaces_as_curl_access_denied_exit_7() {
3447 let egress = Arc::new(MockEgress::with_responses(vec![Err(
3448 EgressError::NetworkAccessDenied {
3449 url: "http://93.184.216.34/blocked".to_string(),
3450 },
3451 )]));
3452 let context = http_context(Some(egress));
3453 let tool = BashTool { enable_http: true };
3454
3455 let result = tool
3456 .execute_with_context(
3457 json!({"commands": "curl -s http://93.184.216.34/blocked"}),
3458 &context,
3459 )
3460 .await;
3461
3462 let ToolExecutionResult::Success(output) = result else {
3463 panic!("expected success result");
3464 };
3465 assert_eq!(output["exit_code"], 7, "stderr: {}", output["stderr"]);
3466 assert!(
3467 output["stderr"].as_str().unwrap().contains("access denied"),
3468 "stderr: {}",
3469 output["stderr"]
3470 );
3471 assert!(
3472 output["stderr"]
3473 .as_str()
3474 .unwrap()
3475 .contains("blocked by network policy"),
3476 "stderr: {}",
3477 output["stderr"]
3478 );
3479 }
3480
3481 #[tokio::test]
3482 async fn oversized_egress_response_surfaces_as_curl_exit_63() {
3483 let big = "x".repeat(11 * 1024 * 1024);
3486 let egress = Arc::new(MockEgress::with_responses(vec![MockEgress::ok(
3487 200,
3488 &[("content-type", "text/plain")],
3489 &big,
3490 )]));
3491 let context = http_context(Some(egress.clone()));
3492 let tool = BashTool { enable_http: true };
3493
3494 let result = tool
3495 .execute_with_context(
3496 json!({"commands": "curl -s http://93.184.216.34/huge"}),
3497 &context,
3498 )
3499 .await;
3500
3501 let ToolExecutionResult::Success(output) = result else {
3502 panic!("expected success result");
3503 };
3504 assert_eq!(*egress.send_calls.lock().unwrap(), 0);
3505 assert_eq!(*egress.stream_calls.lock().unwrap(), 1);
3506 assert_eq!(output["exit_code"], 63, "stderr: {}", output["stderr"]);
3507 assert!(
3508 output["stderr"]
3509 .as_str()
3510 .unwrap()
3511 .contains("response too large"),
3512 "stderr: {}",
3513 output["stderr"]
3514 );
3515 }
3516
3517 #[test]
3518 fn validate_config_accepts_bool_and_rejects_other_types() {
3519 let cap = BashkitShellCapability;
3520 assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
3521 assert!(cap.validate_config(&json!({})).is_ok());
3522 assert!(cap.validate_config(&json!({"enable_http": true})).is_ok());
3523 assert!(cap.validate_config(&json!({"enable_http": "yes"})).is_err());
3524 assert!(cap.validate_config(&json!("nope")).is_err());
3525 }
3526
3527 #[test]
3528 fn config_schema_exposes_enable_http() {
3529 let schema = BashkitShellCapability.config_schema().unwrap();
3530 assert!(schema["properties"]["enable_http"]["type"] == "boolean");
3531 }
3532 }
3533}