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::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 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
337 ToolExecutionResult::tool_error(
338 "bash requires context. This tool must be executed with session context.",
339 )
340 }
341
342 async fn execute_with_context(
343 &self,
344 arguments: Value,
345 context: &ToolContext,
346 ) -> ToolExecutionResult {
347 let command = match arguments.get("commands").and_then(|v| v.as_str()) {
348 Some(c) => c,
349 None => {
350 return ToolExecutionResult::tool_error("Missing required parameter: commands");
351 }
352 };
353
354 let file_store = match &context.file_store {
355 Some(store) => store.clone(),
356 None => {
357 return ToolExecutionResult::tool_error(
358 "File system not available in this context",
359 );
360 }
361 };
362
363 let (working_dir, workspace_env) = resolve_shell_workspace(
364 file_store.as_ref(),
365 arguments.get("working_dir").and_then(|v| v.as_str()),
366 );
367
368 let timeout_ms = arguments
369 .get("timeout_ms")
370 .and_then(|v| v.as_u64())
371 .unwrap_or(30000)
372 .min(60000);
373
374 let output_mode = arguments
377 .get("output")
378 .and_then(|v| v.as_str())
379 .unwrap_or("auto");
380
381 let session_fs = Arc::new(SessionFileSystemAdapter::new(
383 context.session_id,
384 file_store,
385 ));
386
387 let locale = context.locale.as_deref().unwrap_or("en-US");
389
390 let builder = Bash::builder()
394 .fs(session_fs)
395 .cwd(working_dir.as_str())
396 .username("everruns")
397 .hostname("everruns")
398 .env("HOME", "/home/agent")
399 .env("SHELL", "/bin/bash")
400 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
401 .env("WORKSPACE", workspace_env.as_str())
402 .env("LANG", locale)
403 .limits(execution_limits())
404 .max_memory(10 * 1024 * 1024) .trace_mode(TraceMode::Redacted);
406 let builder = install_observability_hooks(builder, context.session_id);
407 let builder = configure_http(builder, self.enable_http, context);
408 let mut bash = builder.build();
409
410 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(String, String)>();
416 let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
417
418 let output_callback: OutputCallback =
419 Box::new(move |stdout_chunk: &str, stderr_chunk: &str| {
420 let _ = tx.send((stdout_chunk.to_string(), stderr_chunk.to_string()));
422 let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
424 });
425
426 let emit_context = context.clone();
428 let emit_task = tokio::spawn(async move {
429 while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
430 if !stdout_chunk.is_empty() {
431 emit_context
432 .emit_tool_output("bash", &stdout_chunk, "stdout")
433 .await;
434 }
435 if !stderr_chunk.is_empty() {
436 emit_context
437 .emit_tool_output("bash", &stderr_chunk, "stderr")
438 .await;
439 }
440 }
441 });
442
443 let cancel_token = bash.cancellation_token();
445
446 let exec_start = std::time::Instant::now();
450 let result = tokio::time::timeout(
451 std::time::Duration::from_millis(timeout_ms),
452 bash.exec_streaming(command, output_callback),
453 )
454 .await;
455 let exec_duration = exec_start.elapsed();
456
457 let _ = emit_task.await;
459
460 match result {
461 Ok(Ok(output)) => {
462 let commands_executed = output
464 .events
465 .iter()
466 .filter(|e| e.kind == TraceEventKind::CommandExit)
467 .count();
468 let fs_reads = output
469 .events
470 .iter()
471 .filter(|e| e.kind == TraceEventKind::FileAccess)
472 .count();
473 let fs_writes = output
474 .events
475 .iter()
476 .filter(|e| e.kind == TraceEventKind::FileMutation)
477 .count();
478
479 tracing::info!(
480 tool = "bash",
481 duration_ms = exec_duration.as_millis() as u64,
482 exit_code = output.exit_code,
483 commands_executed,
484 fs_reads,
485 fs_writes,
486 stdout_bytes = output.stdout.len(),
487 stderr_bytes = output.stderr.len(),
488 "bashkit execution completed"
489 );
490
491 let payload = ExecToolResultPayload::new(
492 &output.stdout,
493 &output.stderr,
494 output.exit_code,
495 output_mode,
496 );
497 let ExecToolResultPayload {
498 stdout,
499 stderr,
500 exit_code,
501 success,
502 truncated,
503 total_lines,
504 raw_output,
505 } = payload;
506 ToolExecutionResult::success_with_raw_output(
507 json!({
508 "stdout": stdout,
509 "stderr": stderr,
510 "exit_code": exit_code,
511 "success": success,
512 "truncated": truncated,
513 "total_lines": total_lines,
514 }),
515 raw_output,
516 )
517 }
518 Ok(Err(e)) => {
519 ToolExecutionResult::tool_error(format!("Bash execution error: {}", e))
521 }
522 Err(_) => {
523 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
527
528 let partial = collect_partial_output(partial_rx);
529 if partial.is_empty() {
530 ToolExecutionResult::tool_error(format!(
531 "Command timed out after {}ms",
532 timeout_ms
533 ))
534 } else {
535 use crate::tool_output_sanitizer::{
536 clean_exec_output, output_verbosity_budget, priority_aware_truncate,
537 resolve_auto_mode,
538 };
539 let effective = resolve_auto_mode(output_mode, 1);
542 let clean = clean_exec_output(&partial);
543 let truncated = if let Some(budget) = output_verbosity_budget(effective) {
544 priority_aware_truncate(&clean, budget)
545 } else {
546 clean.clone()
547 };
548 ToolExecutionResult::tool_error(format!(
549 "Command timed out after {}ms. Partial output:\n{}",
550 timeout_ms, truncated
551 ))
552 }
553 }
554 }
555 }
556
557 fn requires_context(&self) -> bool {
558 true
559 }
560
561 fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
562 Some(self)
563 }
564}
565
566#[async_trait]
567impl BackgroundExecutableTool for BashTool {
568 async fn execute_background(
569 &self,
570 arguments: Value,
571 context: ToolContext,
572 sink: Arc<dyn BackgroundEventSink>,
573 ) -> Result<BackgroundOutcome, ToolExecutionResult> {
574 let command = match arguments.get("commands").and_then(|v| v.as_str()) {
575 Some(c) => c,
576 None => {
577 return Err(ToolExecutionResult::tool_error(
578 "Missing required parameter: commands",
579 ));
580 }
581 };
582
583 let file_store = match &context.file_store {
584 Some(store) => store.clone(),
585 None => {
586 return Err(ToolExecutionResult::tool_error(
587 "File system not available in this context",
588 ));
589 }
590 };
591
592 let (working_dir, workspace_env) = resolve_shell_workspace(
593 file_store.as_ref(),
594 arguments.get("working_dir").and_then(|v| v.as_str()),
595 );
596
597 let timeout_ms = arguments
598 .get("timeout_ms")
599 .and_then(|v| v.as_u64())
600 .unwrap_or(30000)
601 .min(60000);
602
603 let output_mode = arguments
605 .get("output")
606 .and_then(|v| v.as_str())
607 .unwrap_or("auto");
608
609 let session_fs = Arc::new(SessionFileSystemAdapter::new(
610 context.session_id,
611 file_store,
612 ));
613 let locale = context.locale.as_deref().unwrap_or("en-US");
614
615 let builder = Bash::builder()
616 .fs(session_fs)
617 .cwd(working_dir.as_str())
618 .username("everruns")
619 .hostname("everruns")
620 .env("HOME", "/home/agent")
621 .env("SHELL", "/bin/bash")
622 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
623 .env("WORKSPACE", workspace_env.as_str())
624 .env("LANG", locale)
625 .limits(execution_limits())
626 .max_memory(10 * 1024 * 1024)
627 .trace_mode(TraceMode::Redacted);
628 let builder = install_observability_hooks(builder, context.session_id);
629 let builder = configure_http(builder, self.enable_http, &context);
630 let mut bash = builder.build();
631
632 let (tx, mut rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
633 let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
634 let sink_for_output = sink.clone();
635 let dropped_chunks = Arc::new(AtomicUsize::new(0));
636 let dropped_chunks_for_callback = dropped_chunks.clone();
637 let output_callback: OutputCallback =
638 Box::new(move |stdout_chunk: &str, stderr_chunk: &str| {
639 if tx
640 .try_send((stdout_chunk.to_string(), stderr_chunk.to_string()))
641 .is_err()
642 {
643 dropped_chunks_for_callback.fetch_add(1, Ordering::Relaxed);
644 }
645 let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
646 });
647
648 let emit_task = tokio::spawn(async move {
649 while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
650 if !stdout_chunk.is_empty() {
651 let _ = sink_for_output.output("stdout", &stdout_chunk).await;
652 }
653 if !stderr_chunk.is_empty() {
654 let _ = sink_for_output.output("stderr", &stderr_chunk).await;
655 }
656 }
657 });
658
659 let _ = sink.status("Running bash command").await;
660 let cancel_token = bash.cancellation_token();
661 let exec_start = std::time::Instant::now();
662 let result = tokio::time::timeout(
663 std::time::Duration::from_millis(timeout_ms),
664 bash.exec_streaming(command, output_callback),
665 )
666 .await;
667 let exec_duration = exec_start.elapsed();
668 let _ = emit_task.await;
669 let dropped_chunks = dropped_chunks.load(Ordering::Relaxed);
670 if dropped_chunks > 0 {
671 let _ = sink
672 .output(
673 "stderr",
674 &format!(
675 "[system] dropped {dropped_chunks} background output chunk(s) due to backpressure\n"
676 ),
677 )
678 .await;
679 }
680
681 match result {
682 Ok(Ok(output)) => {
683 let payload = ExecToolResultPayload::new(
684 &output.stdout,
685 &output.stderr,
686 output.exit_code,
687 output_mode,
688 );
689 let ExecToolResultPayload {
690 stdout,
691 stderr,
692 exit_code,
693 success,
694 truncated,
695 total_lines,
696 raw_output,
697 } = payload;
698 let _ = sink
699 .progress(BackgroundProgress {
700 current: Some(exec_duration.as_millis() as u64),
701 total: None,
702 unit: Some("ms".to_string()),
703 label: Some("runtime".to_string()),
704 })
705 .await;
706 Ok(BackgroundOutcome {
707 summary: format!(
708 "Bash command exited with code {} after {} ms",
709 exit_code,
710 exec_duration.as_millis()
711 ),
712 result: json!({
713 "stdout": stdout,
714 "stderr": stderr,
715 "exit_code": exit_code,
716 "success": success,
717 "truncated": truncated,
718 "total_lines": total_lines,
719 }),
720 raw_output: Some(raw_output),
721 })
722 }
723 Ok(Err(e)) => Err(ToolExecutionResult::tool_error(format!(
724 "Bash execution error: {}",
725 e
726 ))),
727 Err(_) => {
728 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
729
730 let partial = collect_partial_output(partial_rx);
731 if partial.is_empty() {
732 Err(ToolExecutionResult::tool_error(format!(
733 "Command timed out after {}ms",
734 timeout_ms
735 )))
736 } else {
737 use crate::tool_output_sanitizer::{
738 clean_exec_output, output_verbosity_budget, priority_aware_truncate,
739 resolve_auto_mode,
740 };
741 let effective = resolve_auto_mode(output_mode, 1);
744 let clean = clean_exec_output(&partial);
745 let truncated = if let Some(budget) = output_verbosity_budget(effective) {
746 priority_aware_truncate(&clean, budget)
747 } else {
748 clean.clone()
749 };
750 Err(ToolExecutionResult::tool_error(format!(
751 "Command timed out after {}ms. Partial output:\n{}",
752 timeout_ms, truncated
753 )))
754 }
755 }
756 }
757 }
758}
759
760fn install_observability_hooks(builder: BashBuilder, session_id: SessionId) -> BashBuilder {
770 use bashkit::hooks::{ErrorEvent, HookAction, ToolEvent, ToolResult};
771 builder
772 .before_tool(Box::new(move |ev: ToolEvent| {
773 tracing::debug!(
774 target: "bashkit.hook",
775 capability = "bashkit_shell",
776 session_id = %session_id,
777 event = "before_tool",
778 tool = %ev.name,
779 arg_count = ev.args.len(),
780 "builtin invoked"
781 );
782 HookAction::Continue(ev)
783 }))
784 .after_tool(Box::new(move |res: ToolResult| {
785 tracing::debug!(
786 target: "bashkit.hook",
787 capability = "bashkit_shell",
788 session_id = %session_id,
789 event = "after_tool",
790 tool = %res.name,
791 exit_code = res.exit_code,
792 stdout_bytes = res.stdout.len(),
793 "builtin completed"
794 );
795 HookAction::Continue(res)
796 }))
797 .on_error(Box::new(move |ev: ErrorEvent| {
798 let preview = truncate_for_log(&ev.message, 256);
799 tracing::warn!(
800 target: "bashkit.hook",
801 capability = "bashkit_shell",
802 session_id = %session_id,
803 event = "on_error",
804 message = %preview,
805 "interpreter error"
806 );
807 HookAction::Continue(ev)
808 }))
809}
810
811fn configure_http(builder: BashBuilder, enable_http: bool, context: &ToolContext) -> BashBuilder {
832 if !enable_http {
833 return builder;
834 }
835 let Some(egress) = context.egress_service.clone() else {
836 tracing::warn!(
837 capability = "bashkit_shell",
838 session_id = %context.session_id,
839 "enable_http set but no egress service in context; shell HTTP stays disabled"
840 );
841 return builder;
842 };
843 let session_id = context.session_id;
844 let mut builder = builder
845 .network(NetworkAllowlist::allow_all())
846 .http_transport(Arc::new(egress_transport::BashkitEgressTransport::new(
847 egress,
848 context.network_access.clone(),
849 )))
850 .before_http(Box::new(move |ev: bashkit::hooks::HttpRequestEvent| {
853 tracing::debug!(
854 target: "bashkit.hook",
855 capability = "bashkit_shell",
856 session_id = %session_id,
857 event = "before_http",
858 method = %ev.method,
859 header_count = ev.headers.len(),
860 "outbound http request"
861 );
862 bashkit::hooks::HookAction::Continue(ev)
863 }))
864 .after_http(Box::new(move |ev: bashkit::hooks::HttpResponseEvent| {
865 tracing::debug!(
866 target: "bashkit.hook",
867 capability = "bashkit_shell",
868 session_id = %session_id,
869 event = "after_http",
870 status = ev.status,
871 "outbound http response"
872 );
873 bashkit::hooks::HookAction::Continue(ev)
874 }));
875 if let Some(bot_auth) = bot_auth_config_from_env() {
876 builder = builder.bot_auth(bot_auth);
877 }
878 builder
879}
880
881fn bot_auth_config_from_env() -> Option<bashkit::BotAuthConfig> {
885 static CONFIG: LazyLock<Option<bashkit::BotAuthConfig>> = LazyLock::new(|| {
886 let seed = std::env::var("BOT_AUTH_SIGNING_KEY_SEED").ok()?;
887 let mut config = match bashkit::BotAuthConfig::from_base64_seed(&seed) {
888 Ok(config) => config,
889 Err(e) => {
890 tracing::warn!(error = %e, "invalid BOT_AUTH_SIGNING_KEY_SEED, bashkit bot-auth disabled");
891 return None;
892 }
893 };
894 if let Ok(fqdn) = std::env::var("BOT_AUTH_AGENT_FQDN") {
895 config = config.with_agent_fqdn(&fqdn);
896 }
897 if let Ok(secs) = std::env::var("BOT_AUTH_VALIDITY_SECS")
898 && let Ok(secs) = secs.parse::<u64>()
899 {
900 config = config.with_validity_secs(secs);
901 }
902 Some(config)
903 });
904 CONFIG.clone()
905}
906
907fn truncate_for_log(msg: &str, max_bytes: usize) -> String {
912 const MARKER: &str = "…[truncated]";
913 if msg.len() <= max_bytes {
914 return msg.to_string();
915 }
916 let budget = max_bytes.saturating_sub(MARKER.len());
917 let mut cut = budget.min(msg.len());
918 while cut > 0 && !msg.is_char_boundary(cut) {
919 cut -= 1;
920 }
921 if max_bytes > MARKER.len() {
922 format!("{}{}", &msg[..cut], MARKER)
923 } else {
924 let mut cut = max_bytes.min(msg.len());
926 while cut > 0 && !msg.is_char_boundary(cut) {
927 cut -= 1;
928 }
929 msg[..cut].to_string()
930 }
931}
932
933fn collect_partial_output(mut rx: tokio::sync::mpsc::Receiver<(String, String)>) -> String {
936 let mut stdout_buf = String::new();
937 let mut stderr_buf = String::new();
938 while let Ok((stdout, stderr)) = rx.try_recv() {
939 stdout_buf.push_str(&stdout);
940 stderr_buf.push_str(&stderr);
941 }
942 let mut partial = stdout_buf;
943 if !stderr_buf.is_empty() {
944 if !partial.is_empty() && !partial.ends_with('\n') {
945 partial.push('\n');
946 }
947 partial.push_str("--- stderr ---\n");
948 partial.push_str(&stderr_buf);
949 }
950 partial
951}
952
953pub struct SessionFileSystemAdapter {
962 session_id: SessionId,
963 store: Arc<dyn SessionFileSystem>,
964}
965
966impl SessionFileSystemAdapter {
967 pub fn new(session_id: SessionId, store: Arc<dyn SessionFileSystem>) -> Self {
968 Self { session_id, store }
969 }
970
971 fn store_path(path: &Path) -> String {
981 path.to_string_lossy().into_owned()
982 }
983
984 async fn directory_has_entries(&self, session_path: &str) -> bool {
990 self.store
991 .list_directory(self.session_id, session_path)
992 .await
993 .is_ok_and(|entries| !entries.is_empty())
994 }
995}
996
997#[async_trait]
998impl FileSystemExt for SessionFileSystemAdapter {}
999
1000#[async_trait]
1001impl FileSystem for SessionFileSystemAdapter {
1002 async fn read_file(&self, path: &Path) -> bashkit::Result<Vec<u8>> {
1003 let session_path = Self::store_path(path);
1004
1005 match self.store.read_file(self.session_id, &session_path).await {
1006 Ok(Some(file)) => {
1007 let content = file.content.unwrap_or_default();
1008 SessionFile::decode_content(&content, &file.encoding)
1009 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1010 }
1011 Ok(None) => Err(bashkit::Error::Io(std::io::Error::new(
1012 std::io::ErrorKind::NotFound,
1013 format!("File not found: {}", path.display()),
1014 ))),
1015 Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
1016 }
1017 }
1018
1019 async fn write_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
1020 let session_path = Self::store_path(path);
1021
1022 let (encoded, encoding) = SessionFile::encode_content(content);
1023
1024 self.store
1025 .write_file(self.session_id, &session_path, &encoded, &encoding)
1026 .await
1027 .map(|_| ())
1028 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1029 }
1030
1031 async fn append_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
1032 let session_path = Self::store_path(path);
1033
1034 let mut existing = match self.store.read_file(self.session_id, &session_path).await {
1036 Ok(Some(file)) => {
1037 let content = file.content.unwrap_or_default();
1038 SessionFile::decode_content(&content, &file.encoding)
1039 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?
1040 }
1041 Ok(None) => Vec::new(),
1042 Err(e) => return Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
1043 };
1044
1045 existing.extend_from_slice(content);
1047
1048 let (encoded, encoding) = SessionFile::encode_content(&existing);
1050 self.store
1051 .write_file(self.session_id, &session_path, &encoded, &encoding)
1052 .await
1053 .map(|_| ())
1054 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1055 }
1056
1057 async fn mkdir(&self, path: &Path, _recursive: bool) -> bashkit::Result<()> {
1058 let session_path = Self::store_path(path);
1059
1060 self.store
1061 .create_directory(self.session_id, &session_path)
1062 .await
1063 .map(|_| ())
1064 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1065 }
1066
1067 async fn remove(&self, path: &Path, recursive: bool) -> bashkit::Result<()> {
1068 let session_path = Self::store_path(path);
1069
1070 self.store
1071 .delete_file(self.session_id, &session_path, recursive)
1072 .await
1073 .map(|_| ())
1074 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1075 }
1076
1077 async fn stat(&self, path: &Path) -> bashkit::Result<Metadata> {
1078 if path.to_string_lossy() == "/workspace" {
1080 let now = SystemTime::now();
1081 return Ok(Metadata {
1082 file_type: FileType::Directory,
1083 size: 0,
1084 mode: 0o755,
1085 modified: now,
1086 created: now,
1087 });
1088 }
1089
1090 let session_path = Self::store_path(path);
1091
1092 match self.store.read_file(self.session_id, &session_path).await {
1094 Ok(Some(file)) => {
1095 let now = SystemTime::now();
1096
1097 let file_type = if file.is_directory {
1098 FileType::Directory
1099 } else {
1100 FileType::File
1101 };
1102
1103 Ok(Metadata {
1107 file_type,
1108 size: file.size_bytes as u64,
1109 mode: 0o755,
1110 modified: now,
1111 created: now,
1112 })
1113 }
1114 Ok(None) => {
1115 if self.directory_has_entries(&session_path).await {
1121 let now = SystemTime::now();
1122 Ok(Metadata {
1123 file_type: FileType::Directory,
1124 size: 0,
1125 mode: 0o755,
1126 modified: now,
1127 created: now,
1128 })
1129 } else {
1130 Err(bashkit::Error::Io(std::io::Error::new(
1131 std::io::ErrorKind::NotFound,
1132 format!("Path not found: {}", path.display()),
1133 )))
1134 }
1135 }
1136 Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
1137 }
1138 }
1139
1140 async fn read_dir(&self, path: &Path) -> bashkit::Result<Vec<DirEntry>> {
1141 let session_path = Self::store_path(path);
1142
1143 let entries = self
1144 .store
1145 .list_directory(self.session_id, &session_path)
1146 .await
1147 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1148
1149 let now = SystemTime::now();
1150
1151 Ok(entries
1152 .into_iter()
1153 .map(|e| {
1154 let file_type = if e.is_directory {
1155 FileType::Directory
1156 } else {
1157 FileType::File
1158 };
1159
1160 DirEntry {
1161 name: e.name,
1162 metadata: Metadata {
1163 file_type,
1164 size: e.size_bytes as u64,
1165 mode: 0o755,
1166 modified: now,
1167 created: now,
1168 },
1169 }
1170 })
1171 .collect())
1172 }
1173
1174 async fn exists(&self, path: &Path) -> bashkit::Result<bool> {
1175 if path.to_string_lossy() == "/workspace" {
1177 return Ok(true);
1178 }
1179
1180 let session_path = Self::store_path(path);
1181
1182 if let Ok(Some(_)) = self.store.read_file(self.session_id, &session_path).await {
1184 return Ok(true);
1185 }
1186
1187 Ok(self.directory_has_entries(&session_path).await)
1190 }
1191
1192 async fn rename(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
1193 let from_session = Self::store_path(from);
1194
1195 let content = self.read_file(from).await?;
1197
1198 self.write_file(to, &content).await?;
1200
1201 self.store
1203 .delete_file(self.session_id, &from_session, false)
1204 .await
1205 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1206
1207 Ok(())
1208 }
1209
1210 async fn copy(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
1211 let content = self.read_file(from).await?;
1212 self.write_file(to, &content).await
1213 }
1214
1215 async fn symlink(&self, _target: &Path, _link: &Path) -> bashkit::Result<()> {
1216 Err(bashkit::Error::Io(std::io::Error::new(
1218 std::io::ErrorKind::Unsupported,
1219 "Symlinks not supported in session filesystem",
1220 )))
1221 }
1222
1223 async fn read_link(&self, path: &Path) -> bashkit::Result<PathBuf> {
1224 Err(bashkit::Error::Io(std::io::Error::new(
1226 std::io::ErrorKind::Unsupported,
1227 format!("Symlinks not supported: {}", path.display()),
1228 )))
1229 }
1230
1231 async fn chmod(&self, _path: &Path, _mode: u32) -> bashkit::Result<()> {
1232 Ok(())
1234 }
1235
1236 async fn set_modified_time(&self, _path: &Path, _time: SystemTime) -> bashkit::Result<()> {
1241 Ok(())
1242 }
1243
1244 fn as_search_capable(&self) -> Option<&dyn SearchCapable> {
1245 Some(self)
1246 }
1247}
1248
1249impl SearchCapable for SessionFileSystemAdapter {
1254 fn search_provider(&self, _path: &Path) -> Option<Box<dyn SearchProvider>> {
1255 Some(Box::new(SessionSearchProvider {
1258 session_id: self.session_id,
1259 store: self.store.clone(),
1260 }))
1261 }
1262}
1263
1264struct SessionSearchProvider {
1269 session_id: SessionId,
1270 store: Arc<dyn SessionFileSystem>,
1271}
1272
1273impl SearchProvider for SessionSearchProvider {
1274 fn search(&self, query: &SearchQuery) -> bashkit::Result<SearchResults> {
1275 let session_id = self.session_id;
1276 let store = self.store.clone();
1277 let root = query.root.to_string_lossy().into_owned();
1278 let max_results = query.max_results;
1279
1280 let pattern = if query.case_insensitive {
1282 format!("(?i){}", query.pattern)
1283 } else {
1284 query.pattern.clone()
1285 };
1286
1287 let path_pattern = if root == crate::mount_fs::WORKSPACE_MOUNT || root == "/" {
1292 None
1293 } else {
1294 Some(root)
1295 };
1296
1297 let matches = std::thread::scope(|s| {
1301 s.spawn(|| {
1302 let rt = tokio::runtime::Builder::new_current_thread()
1303 .enable_all()
1304 .build()
1305 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1306 rt.block_on(async {
1307 store
1308 .grep_files(session_id, &pattern, path_pattern.as_deref())
1309 .await
1310 })
1311 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1312 })
1313 .join()
1314 .unwrap_or_else(|_| {
1315 Err(bashkit::Error::Io(std::io::Error::other(
1316 "search thread panicked",
1317 )))
1318 })
1319 })?;
1320
1321 let truncated = max_results.is_some_and(|max| matches.len() > max);
1322 let matches: Vec<BashkitSearchMatch> = matches
1323 .into_iter()
1324 .take(max_results.unwrap_or(usize::MAX))
1325 .map(|m| {
1326 let vfs_path = self.store.display_path(&m.path);
1330 BashkitSearchMatch {
1331 path: PathBuf::from(vfs_path),
1332 line_number: m.line_number,
1333 line_content: m.line,
1334 }
1335 })
1336 .collect();
1337
1338 Ok(SearchResults { matches, truncated })
1339 }
1340
1341 fn capabilities(&self) -> SearchCapabilities {
1342 SearchCapabilities {
1343 regex: true,
1344 glob_filter: false,
1345 content_search: true,
1346 filename_search: false,
1347 }
1348 }
1349}
1350
1351#[cfg(test)]
1352mod tests {
1353 use super::*;
1354 use crate::session_file::FileInfo;
1355 use crate::traits::SessionFileSystem;
1356 use crate::typed_id::SessionId;
1357 use crate::{FileStat, GrepMatch, Result};
1358 use std::collections::HashMap;
1359 use std::sync::Mutex;
1360
1361 struct MockFileStore {
1367 files: Mutex<HashMap<(SessionId, String), (String, String)>>, directories: Mutex<HashMap<(SessionId, String), bool>>,
1369 }
1370
1371 impl MockFileStore {
1372 fn new() -> Self {
1373 Self {
1374 files: Mutex::new(HashMap::new()),
1375 directories: Mutex::new(HashMap::new()),
1376 }
1377 }
1378
1379 fn normalize_path(path: &str) -> String {
1380 let mut normalized = path.trim().to_string();
1381 if !normalized.starts_with('/') {
1382 normalized = format!("/{}", normalized);
1383 }
1384 if normalized.len() > 1 && normalized.ends_with('/') {
1385 normalized.pop();
1386 }
1387 normalized
1388 }
1389 }
1390
1391 #[async_trait]
1392 impl SessionFileSystem for MockFileStore {
1393 fn is_mount_resolver(&self) -> bool {
1394 false
1395 }
1396
1397 async fn read_file(
1398 &self,
1399 session_id: SessionId,
1400 path: &str,
1401 ) -> Result<Option<SessionFile>> {
1402 let path = Self::normalize_path(path);
1403 let files = self.files.lock().unwrap();
1404 if let Some((content, encoding)) = files.get(&(session_id, path.clone())) {
1405 Ok(Some(SessionFile {
1406 id: uuid::Uuid::new_v4(),
1407 session_id: session_id.into(),
1408 path: path.clone(),
1409 name: path.split('/').next_back().unwrap_or("").to_string(),
1410 is_directory: false,
1411 is_readonly: false,
1412 content: Some(content.clone()),
1413 encoding: encoding.clone(),
1414 size_bytes: content.len() as i64,
1415 created_at: chrono::Utc::now(),
1416 updated_at: chrono::Utc::now(),
1417 }))
1418 } else if self
1419 .directories
1420 .lock()
1421 .unwrap()
1422 .contains_key(&(session_id, path.clone()))
1423 {
1424 Ok(Some(SessionFile {
1428 id: uuid::Uuid::new_v4(),
1429 session_id: session_id.into(),
1430 path: path.clone(),
1431 name: path.split('/').next_back().unwrap_or("").to_string(),
1432 is_directory: true,
1433 is_readonly: false,
1434 content: None,
1435 encoding: "text".to_string(),
1436 size_bytes: 0,
1437 created_at: chrono::Utc::now(),
1438 updated_at: chrono::Utc::now(),
1439 }))
1440 } else {
1441 Ok(None)
1442 }
1443 }
1444
1445 async fn write_file(
1446 &self,
1447 session_id: SessionId,
1448 path: &str,
1449 content: &str,
1450 encoding: &str,
1451 ) -> Result<SessionFile> {
1452 let path = Self::normalize_path(path);
1453 let mut files = self.files.lock().unwrap();
1454 files.insert(
1455 (session_id, path.clone()),
1456 (content.to_string(), encoding.to_string()),
1457 );
1458 Ok(SessionFile {
1459 id: uuid::Uuid::new_v4(),
1460 session_id: session_id.into(),
1461 path: path.clone(),
1462 name: path.split('/').next_back().unwrap_or("").to_string(),
1463 is_directory: false,
1464 is_readonly: false,
1465 content: Some(content.to_string()),
1466 encoding: encoding.to_string(),
1467 size_bytes: content.len() as i64,
1468 created_at: chrono::Utc::now(),
1469 updated_at: chrono::Utc::now(),
1470 })
1471 }
1472
1473 async fn delete_file(
1474 &self,
1475 session_id: SessionId,
1476 path: &str,
1477 _recursive: bool,
1478 ) -> Result<bool> {
1479 let path = Self::normalize_path(path);
1480 let mut files = self.files.lock().unwrap();
1481 Ok(files.remove(&(session_id, path)).is_some())
1482 }
1483
1484 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
1485 let path = Self::normalize_path(path);
1486 let files = self.files.lock().unwrap();
1487 let dirs = self.directories.lock().unwrap();
1488 let mut entries = Vec::new();
1489
1490 let is_root = path == "/";
1492
1493 for ((sid, file_path), (content, _)) in files.iter() {
1494 if *sid != session_id {
1495 continue;
1496 }
1497
1498 let parent = if let Some(idx) = file_path.rfind('/') {
1500 if idx == 0 {
1501 "/".to_string()
1502 } else {
1503 file_path[..idx].to_string()
1504 }
1505 } else {
1506 "/".to_string()
1507 };
1508
1509 if parent == path {
1510 entries.push(FileInfo {
1511 id: uuid::Uuid::new_v4(),
1512 session_id: session_id.into(),
1513 path: file_path.clone(),
1514 name: file_path.split('/').next_back().unwrap_or("").to_string(),
1515 is_directory: false,
1516 is_readonly: false,
1517 size_bytes: content.len() as i64,
1518 created_at: chrono::Utc::now(),
1519 updated_at: chrono::Utc::now(),
1520 });
1521 }
1522 }
1523
1524 let prefix = if is_root {
1528 "/".to_string()
1529 } else {
1530 format!("{path}/")
1531 };
1532 let mut child_dirs: std::collections::BTreeSet<String> =
1533 std::collections::BTreeSet::new();
1534 let descendant_paths = files
1535 .keys()
1536 .chain(dirs.keys())
1537 .filter(|(sid, _)| *sid == session_id)
1538 .map(|(_, p)| p);
1539 for candidate in descendant_paths {
1540 if let Some(rest) = candidate.strip_prefix(&prefix)
1541 && let Some(name) = rest.split('/').next()
1542 && rest.contains('/')
1543 && !name.is_empty()
1544 {
1545 child_dirs.insert(name.to_string());
1546 }
1547 }
1548 for name in child_dirs {
1549 entries.push(FileInfo {
1550 id: uuid::Uuid::new_v4(),
1551 session_id: session_id.into(),
1552 path: format!("{prefix}{name}"),
1553 name,
1554 is_directory: true,
1555 is_readonly: false,
1556 size_bytes: 0,
1557 created_at: chrono::Utc::now(),
1558 updated_at: chrono::Utc::now(),
1559 });
1560 }
1561
1562 if !is_root && entries.is_empty() && !dirs.contains_key(&(session_id, path.clone())) {
1565 let has_children = files
1567 .keys()
1568 .any(|(sid, fp)| *sid == session_id && fp.starts_with(&format!("{}/", path)));
1569 if !has_children {
1570 return Err(anyhow::anyhow!("Directory not found: {}", path).into());
1571 }
1572 }
1573
1574 Ok(entries)
1575 }
1576
1577 async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
1578 let path = Self::normalize_path(path);
1579 let files = self.files.lock().unwrap();
1580 if let Some((content, _)) = files.get(&(session_id, path.clone())) {
1581 Ok(Some(FileStat {
1582 path: path.clone(),
1583 name: path.split('/').next_back().unwrap_or("").to_string(),
1584 is_directory: false,
1585 is_readonly: false,
1586 size_bytes: content.len() as i64,
1587 created_at: chrono::Utc::now(),
1588 updated_at: chrono::Utc::now(),
1589 }))
1590 } else {
1591 Ok(None)
1592 }
1593 }
1594
1595 async fn grep_files(
1596 &self,
1597 session_id: SessionId,
1598 pattern: &str,
1599 path_pattern: Option<&str>,
1600 ) -> Result<Vec<GrepMatch>> {
1601 let regex = regex::Regex::new(pattern)
1602 .map_err(|e| anyhow::anyhow!("invalid pattern: {}", e))?;
1603 let files = self.files.lock().unwrap();
1604 let mut matches = Vec::new();
1605 for ((sid, file_path), (content, _)) in files.iter() {
1606 if *sid != session_id {
1607 continue;
1608 }
1609 if let Some(pp) = path_pattern
1610 && !file_path.starts_with(pp)
1611 {
1612 continue;
1613 }
1614 let decoded = SessionFile::decode_content(content, "utf-8")
1615 .unwrap_or_else(|_| content.as_bytes().to_vec());
1616 let text = String::from_utf8_lossy(&decoded);
1617 for (i, line) in text.lines().enumerate() {
1618 if regex.is_match(line) {
1619 matches.push(GrepMatch {
1620 path: file_path.clone(),
1621 line_number: i + 1,
1622 line: line.to_string(),
1623 });
1624 }
1625 }
1626 }
1627 matches.sort_by(|a, b| a.path.cmp(&b.path).then(a.line_number.cmp(&b.line_number)));
1628 Ok(matches)
1629 }
1630
1631 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
1632 let path = Self::normalize_path(path);
1633 let mut dirs = self.directories.lock().unwrap();
1634 dirs.insert((session_id, path.clone()), true);
1635 Ok(FileInfo {
1636 id: uuid::Uuid::new_v4(),
1637 session_id: session_id.into(),
1638 path: path.clone(),
1639 name: path.split('/').next_back().unwrap_or("").to_string(),
1640 is_directory: true,
1641 is_readonly: false,
1642 size_bytes: 0,
1643 created_at: chrono::Utc::now(),
1644 updated_at: chrono::Utc::now(),
1645 })
1646 }
1647 }
1648
1649 #[test]
1659 fn description_advertises_builtin_help_and_version() {
1660 let description = BashkitShellCapability.description();
1661 assert!(
1662 description.contains("`<command> --help`"),
1663 "description should advertise built-in help, got: {description}"
1664 );
1665 assert!(
1666 description.contains("`<command> --version`"),
1667 "description should advertise built-in version support, got: {description}"
1668 );
1669 }
1670
1671 #[test]
1672 fn test_capability_has_system_prompt() {
1673 let cap = BashkitShellCapability;
1674 let prompt = cap.system_prompt_addition().unwrap();
1675 assert!(!prompt.is_empty(), "System prompt should not be empty");
1677 assert!(
1679 prompt.contains("everruns"),
1680 "System prompt should contain configured identity"
1681 );
1682 }
1683
1684 fn mount_adapter() -> SessionFileSystemAdapter {
1694 let store = crate::mount_fs::MountFs::wrap(Arc::new(MockFileStore::new()));
1695 SessionFileSystemAdapter::new(SessionId::new(), store)
1696 }
1697
1698 #[tokio::test]
1699 async fn adapter_maps_workspace_alias_to_backend_root() {
1700 let adapter = mount_adapter();
1701 adapter
1702 .write_file(Path::new("/workspace/file.txt"), b"hi")
1703 .await
1704 .unwrap();
1705 assert_eq!(
1708 adapter
1709 .read_file(Path::new("/workspace/file.txt"))
1710 .await
1711 .unwrap(),
1712 b"hi"
1713 );
1714 assert_eq!(
1715 adapter.read_file(Path::new("/file.txt")).await.unwrap(),
1716 b"hi"
1717 );
1718 }
1719
1720 #[tokio::test]
1721 async fn adapter_addresses_any_path_from_root() {
1722 let adapter = mount_adapter();
1726 adapter
1727 .write_file(Path::new("/tmp/file.txt"), b"x")
1728 .await
1729 .unwrap();
1730 assert_eq!(
1731 adapter.read_file(Path::new("/tmp/file.txt")).await.unwrap(),
1732 b"x"
1733 );
1734 }
1735
1736 #[tokio::test]
1741 async fn test_bash_without_context() {
1742 let tool = BashTool::default();
1743 let result = tool.execute(json!({"commands": "echo hello"})).await;
1744
1745 if let ToolExecutionResult::ToolError(msg) = result {
1746 assert!(msg.contains("requires context"));
1747 } else {
1748 panic!("Expected tool error");
1749 }
1750 }
1751
1752 #[tokio::test]
1753 async fn test_bash_missing_command() {
1754 let tool = BashTool::default();
1755 let context = ToolContext::new(SessionId::new());
1756
1757 let result = tool.execute_with_context(json!({}), &context).await;
1758
1759 if let ToolExecutionResult::ToolError(msg) = result {
1760 assert!(msg.contains("Missing required parameter"));
1761 } else {
1762 panic!("Expected tool error for missing command");
1763 }
1764 }
1765
1766 #[tokio::test]
1767 async fn test_bash_no_file_store() {
1768 let tool = BashTool::default();
1769 let context = ToolContext::new(SessionId::new());
1770
1771 let result = tool
1772 .execute_with_context(json!({"commands": "echo hello"}), &context)
1773 .await;
1774
1775 if let ToolExecutionResult::ToolError(msg) = result {
1776 assert!(msg.contains("not available"));
1777 } else {
1778 panic!("Expected tool error for missing file store");
1779 }
1780 }
1781
1782 fn create_context_with_mock_store() -> (ToolContext, SessionId) {
1787 let session_id = SessionId::new();
1788 let store = crate::mount_fs::MountFs::wrap(Arc::new(MockFileStore::new()));
1791 let mut context = ToolContext::new(session_id);
1792 context.file_store = Some(store);
1793 (context, session_id)
1794 }
1795
1796 #[tokio::test]
1797 async fn test_bash_echo_command() {
1798 let (context, _) = create_context_with_mock_store();
1799 let tool = BashTool::default();
1800
1801 let result = tool
1802 .execute_with_context(json!({"commands": "echo hello world"}), &context)
1803 .await;
1804
1805 if let ToolExecutionResult::Success(output) = result {
1806 assert_eq!(output["stdout"], "hello world\n");
1807 assert_eq!(output["exit_code"], 0);
1808 assert_eq!(output["success"], true);
1809 } else {
1810 panic!("Expected success result, got: {:?}", result);
1811 }
1812 }
1813
1814 #[tokio::test]
1815 async fn test_bash_pwd_default_workspace() {
1816 let (context, _) = create_context_with_mock_store();
1817 let tool = BashTool::default();
1818
1819 let result = tool
1820 .execute_with_context(json!({"commands": "pwd"}), &context)
1821 .await;
1822
1823 if let ToolExecutionResult::Success(output) = result {
1824 assert_eq!(output["stdout"], "/workspace\n");
1825 assert_eq!(output["exit_code"], 0);
1826 } else {
1827 panic!("Expected success result, got: {:?}", result);
1828 }
1829 }
1830
1831 #[tokio::test]
1832 async fn test_bash_env_variables() {
1833 let (context, _) = create_context_with_mock_store();
1834 let tool = BashTool::default();
1835
1836 let result = tool
1838 .execute_with_context(json!({"commands": "echo $HOME"}), &context)
1839 .await;
1840 if let ToolExecutionResult::Success(output) = result {
1841 assert_eq!(output["stdout"], "/home/agent\n");
1842 } else {
1843 panic!("Expected success");
1844 }
1845
1846 let result = tool
1848 .execute_with_context(json!({"commands": "echo $WORKSPACE"}), &context)
1849 .await;
1850 if let ToolExecutionResult::Success(output) = result {
1851 assert_eq!(output["stdout"], "/workspace\n");
1852 } else {
1853 panic!("Expected success");
1854 }
1855
1856 let result = tool
1858 .execute_with_context(json!({"commands": "echo $USER"}), &context)
1859 .await;
1860 if let ToolExecutionResult::Success(output) = result {
1861 assert_eq!(output["stdout"], "everruns\n");
1862 } else {
1863 panic!("Expected success");
1864 }
1865 }
1866
1867 #[tokio::test]
1868 async fn test_bash_lang_env_default() {
1869 let (context, _) = create_context_with_mock_store();
1870 let tool = BashTool::default();
1871
1872 let result = tool
1874 .execute_with_context(json!({"commands": "echo $LANG"}), &context)
1875 .await;
1876 if let ToolExecutionResult::Success(output) = result {
1877 assert_eq!(output["stdout"], "en-US\n");
1878 } else {
1879 panic!("Expected success");
1880 }
1881 }
1882
1883 #[tokio::test]
1884 async fn test_bash_lang_env_from_context_locale() {
1885 let (mut context, _) = create_context_with_mock_store();
1886 context.locale = Some("uk-UA".to_string());
1887 let tool = BashTool::default();
1888
1889 let result = tool
1890 .execute_with_context(json!({"commands": "echo $LANG"}), &context)
1891 .await;
1892 if let ToolExecutionResult::Success(output) = result {
1893 assert_eq!(output["stdout"], "uk-UA\n");
1894 } else {
1895 panic!("Expected success");
1896 }
1897 }
1898
1899 #[tokio::test]
1900 async fn test_bash_write_and_read_file() {
1901 let (context, _) = create_context_with_mock_store();
1902 let tool = BashTool::default();
1903
1904 let result = tool
1906 .execute_with_context(
1907 json!({"commands": "echo 'test content' > /workspace/test.txt"}),
1908 &context,
1909 )
1910 .await;
1911 assert!(matches!(result, ToolExecutionResult::Success(_)));
1912
1913 let result = tool
1915 .execute_with_context(json!({"commands": "cat /workspace/test.txt"}), &context)
1916 .await;
1917 if let ToolExecutionResult::Success(output) = result {
1918 assert_eq!(output["stdout"], "test content\n");
1919 } else {
1920 panic!("Expected success result");
1921 }
1922 }
1923
1924 #[tokio::test]
1925 async fn test_bash_recursive_walk_descends_into_subdirectories() {
1926 let (context, _guard) = create_context_with_mock_store();
1927 let tool = BashTool::default();
1928
1929 let result = tool
1930 .execute_with_context(
1931 json!({"commands": "mkdir -p /workspace/a/b && echo needle > /workspace/a/b/c.txt \
1932 && ls /workspace/a && find /workspace/a -name '*.txt'"}),
1933 &context,
1934 )
1935 .await;
1936
1937 match result {
1938 ToolExecutionResult::Success(output) => {
1939 let stdout = output["stdout"].as_str().unwrap_or("");
1940 assert_eq!(output["exit_code"], 0, "stderr: {}", output["stderr"]);
1941 assert!(
1942 stdout.contains("b\n"),
1943 "expected subdir in ls, got {stdout:?}"
1944 );
1945 assert!(
1946 stdout.contains("/workspace/a/b/c.txt"),
1947 "expected find to descend, got {stdout:?}"
1948 );
1949 }
1950 other => panic!("Expected success result, got {other:?}"),
1951 }
1952 }
1953
1954 #[tokio::test]
1955 async fn test_adapter_reports_absent_paths_as_missing() {
1956 let session_id = SessionId::new();
1957 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
1958 let adapter = SessionFileSystemAdapter::new(session_id, store);
1959
1960 assert!(
1964 !adapter
1965 .exists(Path::new("/workspace/nope.txt"))
1966 .await
1967 .unwrap()
1968 );
1969 assert!(
1970 adapter
1971 .stat(Path::new("/workspace/nope.txt"))
1972 .await
1973 .is_err()
1974 );
1975
1976 adapter
1977 .mkdir(Path::new("/workspace/realdir"), false)
1978 .await
1979 .unwrap();
1980 assert!(
1981 adapter
1982 .exists(Path::new("/workspace/realdir"))
1983 .await
1984 .unwrap()
1985 );
1986 }
1987
1988 #[tokio::test]
1989 async fn test_bash_touch_creates_and_updates_files() {
1990 let (context, _guard) = create_context_with_mock_store();
1991 let tool = BashTool::default();
1992
1993 let result = tool
1996 .execute_with_context(
1997 json!({"commands": "touch /workspace/a.txt && touch /workspace/a.txt && ls /workspace"}),
1998 &context,
1999 )
2000 .await;
2001
2002 match result {
2003 ToolExecutionResult::Success(output) => {
2004 assert_eq!(output["exit_code"], 0, "stderr: {}", output["stderr"]);
2005 assert!(
2006 output["stdout"].as_str().unwrap_or("").contains("a.txt"),
2007 "expected a.txt in listing, got {:?}",
2008 output["stdout"]
2009 );
2010 }
2011 other => panic!("Expected success result, got {other:?}"),
2012 }
2013 }
2014
2015 #[tokio::test]
2016 async fn test_bash_pipe_command() {
2017 let (context, _) = create_context_with_mock_store();
2018 let tool = BashTool::default();
2019
2020 let result = tool
2021 .execute_with_context(json!({"commands": "echo hello | cat"}), &context)
2022 .await;
2023
2024 if let ToolExecutionResult::Success(output) = result {
2025 assert_eq!(output["stdout"], "hello\n");
2026 assert_eq!(output["exit_code"], 0);
2027 } else {
2028 panic!("Expected success result");
2029 }
2030 }
2031
2032 #[tokio::test]
2033 async fn test_bash_arithmetic() {
2034 let (context, _) = create_context_with_mock_store();
2035 let tool = BashTool::default();
2036
2037 let result = tool
2038 .execute_with_context(json!({"commands": "echo $((2 + 3 * 4))"}), &context)
2039 .await;
2040
2041 if let ToolExecutionResult::Success(output) = result {
2042 assert_eq!(output["stdout"], "14\n");
2043 } else {
2044 panic!("Expected success result");
2045 }
2046 }
2047
2048 #[tokio::test]
2049 async fn test_bash_command_substitution() {
2050 let (context, _) = create_context_with_mock_store();
2051 let tool = BashTool::default();
2052
2053 let result = tool
2054 .execute_with_context(json!({"commands": "echo $(echo nested)"}), &context)
2055 .await;
2056
2057 if let ToolExecutionResult::Success(output) = result {
2058 assert_eq!(output["stdout"], "nested\n");
2059 } else {
2060 panic!("Expected success result");
2061 }
2062 }
2063
2064 #[tokio::test]
2074 async fn test_bash_write_from_root_succeeds() {
2075 let (context, _) = create_context_with_mock_store();
2076 let tool = BashTool::default();
2077
2078 let result = tool
2080 .execute_with_context(
2081 json!({"commands": "echo hi > /tmp/note.txt && cat /tmp/note.txt"}),
2082 &context,
2083 )
2084 .await;
2085
2086 match result {
2087 ToolExecutionResult::Success(output) => {
2088 assert_eq!(output["exit_code"], 0, "got: {:?}", output);
2089 assert_eq!(output["stdout"], "hi\n");
2090 }
2091 other => panic!("expected success, got: {:?}", other),
2092 }
2093 }
2094
2095 #[tokio::test]
2096 async fn test_bash_read_missing_file_fails_as_not_found() {
2097 let (context, _) = create_context_with_mock_store();
2098 let tool = BashTool::default();
2099
2100 let result = tool
2103 .execute_with_context(json!({"commands": "cat /etc/passwd"}), &context)
2104 .await;
2105
2106 match result {
2107 ToolExecutionResult::Success(output) => {
2108 assert_ne!(
2109 output["exit_code"], 0,
2110 "missing file should fail: {:?}",
2111 output
2112 );
2113 }
2114 ToolExecutionResult::ToolError(msg) => {
2115 assert!(
2116 msg.contains("not found") || msg.contains("No such"),
2117 "got: {}",
2118 msg
2119 );
2120 }
2121 _ => panic!("Unexpected result type"),
2122 }
2123 }
2124
2125 #[tokio::test]
2126 async fn test_bash_mkdir_from_root_succeeds() {
2127 let (context, _) = create_context_with_mock_store();
2128 let tool = BashTool::default();
2129
2130 let result = tool
2131 .execute_with_context(json!({"commands": "mkdir /tmp/sub && echo done"}), &context)
2132 .await;
2133
2134 match result {
2135 ToolExecutionResult::Success(output) => {
2136 assert_eq!(output["exit_code"], 0, "got: {:?}", output);
2137 assert_eq!(output["stdout"], "done\n");
2138 }
2139 other => panic!("expected success, got: {:?}", other),
2140 }
2141 }
2142
2143 #[tokio::test]
2148 async fn test_bash_custom_working_dir() {
2149 let (context, _) = create_context_with_mock_store();
2150 let tool = BashTool::default();
2151
2152 let result = tool
2154 .execute_with_context(json!({"commands": "mkdir -p /workspace/mydir"}), &context)
2155 .await;
2156 assert!(matches!(result, ToolExecutionResult::Success(_)));
2157
2158 let result = tool
2160 .execute_with_context(
2161 json!({
2162 "commands": "pwd",
2163 "working_dir": "/workspace/mydir"
2164 }),
2165 &context,
2166 )
2167 .await;
2168
2169 if let ToolExecutionResult::Success(output) = result {
2170 assert_eq!(output["stdout"], "/workspace/mydir\n");
2171 } else {
2172 panic!("Expected success result");
2173 }
2174 }
2175
2176 #[tokio::test]
2181 async fn test_bash_false_command_exit_code() {
2182 let (context, _) = create_context_with_mock_store();
2183 let tool = BashTool::default();
2184
2185 let result = tool
2186 .execute_with_context(json!({"commands": "false"}), &context)
2187 .await;
2188
2189 if let ToolExecutionResult::Success(output) = result {
2190 assert_eq!(output["exit_code"], 1);
2191 assert_eq!(output["success"], false);
2192 } else {
2193 panic!("Expected success result with non-zero exit code");
2194 }
2195 }
2196
2197 #[tokio::test]
2198 async fn test_bash_true_command_exit_code() {
2199 let (context, _) = create_context_with_mock_store();
2200 let tool = BashTool::default();
2201
2202 let result = tool
2203 .execute_with_context(json!({"commands": "true"}), &context)
2204 .await;
2205
2206 if let ToolExecutionResult::Success(output) = result {
2207 assert_eq!(output["exit_code"], 0);
2208 assert_eq!(output["success"], true);
2209 } else {
2210 panic!("Expected success result");
2211 }
2212 }
2213
2214 #[tokio::test]
2219 async fn test_adapter_read_write_workspace_file() {
2220 let session_id = SessionId::new();
2221 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2222 let adapter = SessionFileSystemAdapter::new(session_id, store);
2223
2224 adapter
2226 .write_file(Path::new("/workspace/test.txt"), b"hello")
2227 .await
2228 .unwrap();
2229
2230 let content = adapter
2232 .read_file(Path::new("/workspace/test.txt"))
2233 .await
2234 .unwrap();
2235 assert_eq!(content, b"hello");
2236 }
2237
2238 #[tokio::test]
2239 async fn test_adapter_read_missing_file_is_not_found() {
2240 let adapter = mount_adapter();
2241
2242 let result = adapter.read_file(Path::new("/tmp/file.txt")).await;
2245 let err = result.unwrap_err();
2246 assert!(
2247 matches!(&err, bashkit::Error::Io(io) if io.kind() == std::io::ErrorKind::NotFound),
2248 "expected NotFound, got: {err}"
2249 );
2250 }
2251
2252 #[tokio::test]
2253 async fn test_adapter_write_from_root_succeeds() {
2254 let adapter = mount_adapter();
2255
2256 adapter
2258 .write_file(Path::new("/tmp/file.txt"), b"data")
2259 .await
2260 .unwrap();
2261 assert_eq!(
2262 adapter.read_file(Path::new("/tmp/file.txt")).await.unwrap(),
2263 b"data"
2264 );
2265 }
2266
2267 #[tokio::test]
2268 async fn test_adapter_stat_workspace_root() {
2269 let session_id = SessionId::new();
2270 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2271 let adapter = SessionFileSystemAdapter::new(session_id, store);
2272
2273 let stat = adapter.stat(Path::new("/workspace")).await.unwrap();
2274 assert!(stat.file_type.is_dir());
2275 }
2276
2277 #[tokio::test]
2278 async fn test_adapter_stat_directory_returns_dir_type() {
2279 let session_id = SessionId::new();
2280 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2281 let adapter = SessionFileSystemAdapter::new(session_id, store);
2282
2283 adapter
2285 .mkdir(Path::new("/workspace/mydir"), false)
2286 .await
2287 .unwrap();
2288
2289 let stat = adapter.stat(Path::new("/workspace/mydir")).await.unwrap();
2291 assert!(
2292 stat.file_type.is_dir(),
2293 "Expected directory but got file type for /workspace/mydir"
2294 );
2295 }
2296
2297 #[tokio::test]
2298 async fn test_adapter_stat_file_returns_file_type() {
2299 let session_id = SessionId::new();
2300 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2301 let adapter = SessionFileSystemAdapter::new(session_id, store);
2302
2303 adapter
2305 .write_file(Path::new("/workspace/test.txt"), b"hello")
2306 .await
2307 .unwrap();
2308
2309 let stat = adapter
2311 .stat(Path::new("/workspace/test.txt"))
2312 .await
2313 .unwrap();
2314 assert!(
2315 stat.file_type.is_file(),
2316 "Expected file but got directory type for /workspace/test.txt"
2317 );
2318 assert_eq!(stat.size, 5);
2319 }
2320
2321 #[tokio::test]
2322 async fn test_adapter_exists_workspace() {
2323 let session_id = SessionId::new();
2324 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2325 let adapter = SessionFileSystemAdapter::new(session_id, store);
2326
2327 assert!(adapter.exists(Path::new("/workspace")).await.unwrap());
2329
2330 assert!(!adapter.exists(Path::new("/tmp")).await.unwrap());
2332 }
2333
2334 #[tokio::test]
2335 async fn test_adapter_mkdir_and_list() {
2336 let session_id = SessionId::new();
2337 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2338 let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
2339
2340 adapter
2342 .mkdir(Path::new("/workspace/mydir"), false)
2343 .await
2344 .unwrap();
2345
2346 adapter
2348 .write_file(Path::new("/workspace/mydir/file.txt"), b"content")
2349 .await
2350 .unwrap();
2351
2352 let entries = adapter
2354 .read_dir(Path::new("/workspace/mydir"))
2355 .await
2356 .unwrap();
2357 assert_eq!(entries.len(), 1);
2358 assert_eq!(entries[0].name, "file.txt");
2359 }
2360
2361 #[tokio::test]
2362 async fn test_adapter_rename_file() {
2363 let session_id = SessionId::new();
2364 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2365 let adapter = SessionFileSystemAdapter::new(session_id, store);
2366
2367 adapter
2369 .write_file(Path::new("/workspace/old.txt"), b"data")
2370 .await
2371 .unwrap();
2372
2373 adapter
2375 .rename(
2376 Path::new("/workspace/old.txt"),
2377 Path::new("/workspace/new.txt"),
2378 )
2379 .await
2380 .unwrap();
2381
2382 let old_result = adapter.read_file(Path::new("/workspace/old.txt")).await;
2384 assert!(old_result.is_err());
2385
2386 let new_content = adapter
2388 .read_file(Path::new("/workspace/new.txt"))
2389 .await
2390 .unwrap();
2391 assert_eq!(new_content, b"data");
2392 }
2393
2394 #[tokio::test]
2395 async fn test_adapter_copy_file() {
2396 let session_id = SessionId::new();
2397 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2398 let adapter = SessionFileSystemAdapter::new(session_id, store);
2399
2400 adapter
2402 .write_file(Path::new("/workspace/source.txt"), b"copy me")
2403 .await
2404 .unwrap();
2405
2406 adapter
2408 .copy(
2409 Path::new("/workspace/source.txt"),
2410 Path::new("/workspace/dest.txt"),
2411 )
2412 .await
2413 .unwrap();
2414
2415 let source = adapter
2417 .read_file(Path::new("/workspace/source.txt"))
2418 .await
2419 .unwrap();
2420 let dest = adapter
2421 .read_file(Path::new("/workspace/dest.txt"))
2422 .await
2423 .unwrap();
2424 assert_eq!(source, dest);
2425 assert_eq!(source, b"copy me");
2426 }
2427
2428 #[tokio::test]
2429 async fn test_adapter_append_file() {
2430 let session_id = SessionId::new();
2431 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2432 let adapter = SessionFileSystemAdapter::new(session_id, store);
2433
2434 adapter
2436 .write_file(Path::new("/workspace/log.txt"), b"line1\n")
2437 .await
2438 .unwrap();
2439
2440 adapter
2442 .append_file(Path::new("/workspace/log.txt"), b"line2\n")
2443 .await
2444 .unwrap();
2445
2446 let content = adapter
2448 .read_file(Path::new("/workspace/log.txt"))
2449 .await
2450 .unwrap();
2451 assert_eq!(content, b"line1\nline2\n");
2452 }
2453
2454 #[tokio::test]
2455 async fn test_adapter_symlink_not_supported() {
2456 let session_id = SessionId::new();
2457 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2458 let adapter = SessionFileSystemAdapter::new(session_id, store);
2459
2460 let result = adapter
2461 .symlink(Path::new("/workspace/target"), Path::new("/workspace/link"))
2462 .await;
2463 assert!(result.is_err());
2464 assert!(result.unwrap_err().to_string().contains("not supported"));
2465 }
2466
2467 #[tokio::test]
2468 async fn test_adapter_chmod_is_noop() {
2469 let session_id = SessionId::new();
2470 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2471 let adapter = SessionFileSystemAdapter::new(session_id, store);
2472
2473 let result = adapter.chmod(Path::new("/workspace/file.txt"), 0o755).await;
2475 assert!(result.is_ok());
2476 }
2477
2478 #[tokio::test]
2483 async fn test_bash_max_input_bytes_limit() {
2484 let (context, _) = create_context_with_mock_store();
2485 let tool = BashTool::default();
2486
2487 let large_script = "echo ".to_string() + &"x".repeat(1_100_000);
2489
2490 let result = tool
2491 .execute_with_context(json!({"commands": large_script}), &context)
2492 .await;
2493
2494 match result {
2496 ToolExecutionResult::ToolError(msg) => {
2497 assert!(
2498 msg.contains("too large") || msg.contains("input") || msg.contains("limit"),
2499 "Expected input size error, got: {}",
2500 msg
2501 );
2502 }
2503 ToolExecutionResult::Success(output) => {
2504 panic!(
2505 "Expected error for oversized script, got success: {:?}",
2506 output
2507 );
2508 }
2509 _ => panic!("Unexpected result type"),
2510 }
2511 }
2512
2513 #[tokio::test]
2514 async fn test_bash_loop_within_limit() {
2515 let (context, _) = create_context_with_mock_store();
2516 let tool = BashTool::default();
2517
2518 let command = "i=0; while [ $i -lt 100 ]; do i=$((i + 1)); done; echo $i";
2520
2521 let result = tool
2522 .execute_with_context(json!({"commands": command}), &context)
2523 .await;
2524
2525 if let ToolExecutionResult::Success(output) = result {
2527 assert_eq!(output["exit_code"], 0);
2528 assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "100");
2529 } else {
2530 panic!("Expected success for loop within limit: {:?}", result);
2531 }
2532 }
2533
2534 #[tokio::test]
2535 async fn test_bash_function_calls() {
2536 let (context, _) = create_context_with_mock_store();
2537 let tool = BashTool::default();
2538
2539 let command = r#"
2541 greet() {
2542 echo "Hello, $1!"
2543 }
2544 greet world
2545 "#;
2546
2547 let result = tool
2548 .execute_with_context(json!({"commands": command}), &context)
2549 .await;
2550
2551 if let ToolExecutionResult::Success(output) = result {
2553 assert_eq!(output["exit_code"], 0);
2554 assert!(
2555 output["stdout"]
2556 .as_str()
2557 .unwrap_or("")
2558 .contains("Hello, world!")
2559 );
2560 } else {
2561 panic!("Expected success for function call: {:?}", result);
2562 }
2563 }
2564
2565 #[tokio::test]
2566 async fn test_bash_arithmetic_expressions() {
2567 let (context, _) = create_context_with_mock_store();
2568 let tool = BashTool::default();
2569
2570 let command = "echo $((1 + 2 * 3))";
2572
2573 let result = tool
2574 .execute_with_context(json!({"commands": command}), &context)
2575 .await;
2576
2577 if let ToolExecutionResult::Success(output) = result {
2579 assert_eq!(output["exit_code"], 0);
2580 assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "7");
2581 } else {
2582 panic!("Expected success for arithmetic expression: {:?}", result);
2583 }
2584 }
2585
2586 #[tokio::test]
2587 async fn test_bash_commands_within_limit() {
2588 let (context, _) = create_context_with_mock_store();
2589 let tool = BashTool::default();
2590
2591 let command = "for i in $(seq 1 100); do true; done; echo done";
2593
2594 let result = tool
2595 .execute_with_context(json!({"commands": command}), &context)
2596 .await;
2597
2598 if let ToolExecutionResult::Success(output) = result {
2600 assert_eq!(output["exit_code"], 0);
2601 assert!(output["stdout"].as_str().unwrap_or("").contains("done"));
2602 } else {
2603 panic!("Expected success for commands within limit: {:?}", result);
2604 }
2605 }
2606
2607 #[tokio::test]
2612 async fn test_bash_execute_script_by_absolute_path() {
2613 let (context, _) = create_context_with_mock_store();
2614 let tool = BashTool::default();
2615
2616 let result = tool
2618 .execute_with_context(
2619 json!({"commands": "cat > /workspace/test.sh << 'EOF'\n#!/bin/bash\necho hello\nEOF"}),
2620 &context,
2621 )
2622 .await;
2623 assert!(
2624 matches!(result, ToolExecutionResult::Success(_)),
2625 "Failed to create script: {:?}",
2626 result
2627 );
2628
2629 let result = tool
2631 .execute_with_context(json!({"commands": "/workspace/test.sh"}), &context)
2632 .await;
2633
2634 if let ToolExecutionResult::Success(output) = result {
2635 assert_eq!(output["exit_code"], 0);
2636 assert_eq!(output["stdout"], "hello\n");
2637 } else {
2638 panic!("Expected success, got: {:?}", result);
2639 }
2640 }
2641
2642 #[tokio::test]
2643 async fn test_bash_execute_script_with_args() {
2644 let (context, _) = create_context_with_mock_store();
2645 let tool = BashTool::default();
2646
2647 let result = tool
2649 .execute_with_context(
2650 json!({"commands": "cat > /workspace/greet.sh << 'EOF'\n#!/bin/bash\necho \"Hello, $1! You are $2.\"\nEOF"}),
2651 &context,
2652 )
2653 .await;
2654 assert!(matches!(result, ToolExecutionResult::Success(_)));
2655
2656 let result = tool
2658 .execute_with_context(
2659 json!({"commands": "/workspace/greet.sh world awesome"}),
2660 &context,
2661 )
2662 .await;
2663
2664 if let ToolExecutionResult::Success(output) = result {
2665 assert_eq!(output["exit_code"], 0);
2666 assert_eq!(output["stdout"], "Hello, world! You are awesome.\n");
2667 } else {
2668 panic!("Expected success, got: {:?}", result);
2669 }
2670 }
2671
2672 #[tokio::test]
2673 async fn test_bash_execute_script_without_shebang() {
2674 let (context, _) = create_context_with_mock_store();
2675 let tool = BashTool::default();
2676
2677 let result = tool
2679 .execute_with_context(
2680 json!({"commands": "cat > /workspace/simple.sh << 'EOF'\necho simple\nEOF"}),
2681 &context,
2682 )
2683 .await;
2684 assert!(matches!(result, ToolExecutionResult::Success(_)));
2685
2686 let result = tool
2688 .execute_with_context(json!({"commands": "/workspace/simple.sh"}), &context)
2689 .await;
2690
2691 if let ToolExecutionResult::Success(output) = result {
2692 assert_eq!(output["exit_code"], 0);
2693 assert_eq!(output["stdout"], "simple\n");
2694 } else {
2695 panic!("Expected success, got: {:?}", result);
2696 }
2697 }
2698
2699 #[tokio::test]
2700 async fn test_bash_execute_nonexistent_script() {
2701 let (context, _) = create_context_with_mock_store();
2702 let tool = BashTool::default();
2703
2704 let result = tool
2706 .execute_with_context(json!({"commands": "/workspace/nonexistent.sh"}), &context)
2707 .await;
2708
2709 if let ToolExecutionResult::Success(output) = result {
2710 assert_ne!(output["exit_code"], 0, "Should fail with non-zero exit");
2711 let stderr = output["stderr"].as_str().unwrap_or("");
2712 assert!(
2713 stderr.contains("No such file") || stderr.contains("not found"),
2714 "Expected file not found error, got stderr: {}",
2715 stderr
2716 );
2717 } else {
2718 panic!(
2719 "Expected success result with error output, got: {:?}",
2720 result
2721 );
2722 }
2723 }
2724
2725 #[tokio::test]
2726 async fn test_bash_execute_script_in_nested_dir() {
2727 let (context, _) = create_context_with_mock_store();
2728 let tool = BashTool::default();
2729
2730 let setup = tool
2732 .execute_with_context(
2733 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"}),
2734 &context,
2735 )
2736 .await;
2737 assert!(matches!(setup, ToolExecutionResult::Success(_)));
2738
2739 let result = tool
2741 .execute_with_context(
2742 json!({"commands": "/workspace/.agents/skills/nav/scripts/nav.sh dist"}),
2743 &context,
2744 )
2745 .await;
2746
2747 if let ToolExecutionResult::Success(output) = result {
2748 assert_eq!(output["exit_code"], 0);
2749 assert_eq!(output["stdout"], "navigating dist\n");
2750 } else {
2751 panic!("Expected success, got: {:?}", result);
2752 }
2753 }
2754
2755 #[tokio::test]
2756 async fn test_bash_file_mode_is_executable() {
2757 let (context, _) = create_context_with_mock_store();
2758 let tool = BashTool::default();
2759
2760 let result = tool
2762 .execute_with_context(
2763 json!({"commands": "echo 'echo hi' > /workspace/check.sh && test -x /workspace/check.sh && echo 'executable' || echo 'not executable'"}),
2764 &context,
2765 )
2766 .await;
2767
2768 if let ToolExecutionResult::Success(output) = result {
2769 assert_eq!(output["exit_code"], 0);
2770 assert!(
2771 output["stdout"]
2772 .as_str()
2773 .unwrap_or("")
2774 .contains("executable"),
2775 "File should be reported as executable, got: {}",
2776 output["stdout"]
2777 );
2778 } else {
2779 panic!("Expected success, got: {:?}", result);
2780 }
2781 }
2782
2783 #[tokio::test]
2784 async fn test_bash_execute_script_with_exit_code() {
2785 let (context, _) = create_context_with_mock_store();
2786 let tool = BashTool::default();
2787
2788 let result = tool
2790 .execute_with_context(
2791 json!({"commands": "cat > /workspace/fail.sh << 'EOF'\n#!/bin/bash\necho failing\nexit 42\nEOF"}),
2792 &context,
2793 )
2794 .await;
2795 assert!(matches!(result, ToolExecutionResult::Success(_)));
2796
2797 let result = tool
2799 .execute_with_context(
2800 json!({"commands": "/workspace/fail.sh; echo \"code: $?\""}),
2801 &context,
2802 )
2803 .await;
2804
2805 if let ToolExecutionResult::Success(output) = result {
2806 let stdout = output["stdout"].as_str().unwrap_or("");
2807 assert!(stdout.contains("failing"), "Script should have run");
2808 assert!(
2809 stdout.contains("code: 42"),
2810 "Exit code should propagate, got: {}",
2811 stdout
2812 );
2813 } else {
2814 panic!("Expected success, got: {:?}", result);
2815 }
2816 }
2817
2818 #[tokio::test]
2823 async fn test_bash_overwrite_existing_file() {
2824 let (context, _) = create_context_with_mock_store();
2825 let tool = BashTool::default();
2826
2827 let result = tool
2829 .execute_with_context(
2830 json!({"commands": "echo 'first' > /workspace/overwrite.txt"}),
2831 &context,
2832 )
2833 .await;
2834 assert!(matches!(result, ToolExecutionResult::Success(_)));
2835
2836 let result = tool
2838 .execute_with_context(
2839 json!({"commands": "echo 'second' > /workspace/overwrite.txt"}),
2840 &context,
2841 )
2842 .await;
2843 if let ToolExecutionResult::Success(output) = &result {
2844 assert_eq!(output["exit_code"], 0, "Overwrite should succeed");
2845 } else {
2846 panic!("Expected success on overwrite, got: {:?}", result);
2847 }
2848
2849 let result = tool
2851 .execute_with_context(
2852 json!({"commands": "cat /workspace/overwrite.txt"}),
2853 &context,
2854 )
2855 .await;
2856 if let ToolExecutionResult::Success(output) = result {
2857 assert_eq!(output["stdout"], "second\n");
2858 } else {
2859 panic!("Expected success on read, got: {:?}", result);
2860 }
2861 }
2862
2863 #[tokio::test]
2864 async fn test_bash_append_to_existing_file() {
2865 let (context, _) = create_context_with_mock_store();
2866 let tool = BashTool::default();
2867
2868 let result = tool
2870 .execute_with_context(
2871 json!({"commands": "echo 'line1' > /workspace/append.txt"}),
2872 &context,
2873 )
2874 .await;
2875 assert!(matches!(result, ToolExecutionResult::Success(_)));
2876
2877 let result = tool
2879 .execute_with_context(
2880 json!({"commands": "echo 'line2' >> /workspace/append.txt"}),
2881 &context,
2882 )
2883 .await;
2884 if let ToolExecutionResult::Success(output) = &result {
2885 assert_eq!(output["exit_code"], 0, "Append should succeed");
2886 } else {
2887 panic!("Expected success on append, got: {:?}", result);
2888 }
2889
2890 let result = tool
2892 .execute_with_context(json!({"commands": "cat /workspace/append.txt"}), &context)
2893 .await;
2894 if let ToolExecutionResult::Success(output) = result {
2895 assert_eq!(output["stdout"], "line1\nline2\n");
2896 } else {
2897 panic!("Expected success on read");
2898 }
2899 }
2900
2901 #[tokio::test]
2902 async fn test_adapter_overwrite_existing_file() {
2903 let session_id = SessionId::new();
2904 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2905 let adapter = SessionFileSystemAdapter::new(session_id, store);
2906
2907 adapter
2909 .write_file(Path::new("/workspace/ow.txt"), b"original")
2910 .await
2911 .unwrap();
2912
2913 adapter
2915 .write_file(Path::new("/workspace/ow.txt"), b"updated")
2916 .await
2917 .unwrap();
2918
2919 let content = adapter
2921 .read_file(Path::new("/workspace/ow.txt"))
2922 .await
2923 .unwrap();
2924 assert_eq!(content, b"updated");
2925 }
2926
2927 #[tokio::test]
2928 async fn test_adapter_append_to_existing_file() {
2929 let session_id = SessionId::new();
2930 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2931 let adapter = SessionFileSystemAdapter::new(session_id, store);
2932
2933 adapter
2935 .write_file(Path::new("/workspace/ap.txt"), b"AAA")
2936 .await
2937 .unwrap();
2938
2939 adapter
2941 .append_file(Path::new("/workspace/ap.txt"), b"BBB")
2942 .await
2943 .unwrap();
2944
2945 let content = adapter
2947 .read_file(Path::new("/workspace/ap.txt"))
2948 .await
2949 .unwrap();
2950 assert_eq!(content, b"AAABBB");
2951 }
2952
2953 #[tokio::test]
2954 async fn test_bash_redirect_creates_parent_dirs() {
2955 let (context, _) = create_context_with_mock_store();
2956 let tool = BashTool::default();
2957
2958 let result = tool
2960 .execute_with_context(
2961 json!({"commands": "echo 'deep' > /workspace/a/b/c/deep.txt"}),
2962 &context,
2963 )
2964 .await;
2965 if let ToolExecutionResult::Success(output) = &result {
2966 assert_eq!(output["exit_code"], 0, "Nested write should succeed");
2967 } else {
2968 panic!("Expected success, got: {:?}", result);
2969 }
2970
2971 let result = tool
2973 .execute_with_context(
2974 json!({"commands": "cat /workspace/a/b/c/deep.txt"}),
2975 &context,
2976 )
2977 .await;
2978 if let ToolExecutionResult::Success(output) = result {
2979 assert_eq!(output["stdout"], "deep\n");
2980 } else {
2981 panic!("Expected success on read");
2982 }
2983 }
2984
2985 #[test]
2990 fn test_bashkit_tool_description_is_nonempty() {
2991 let desc = BASHKIT_TOOL.description();
2992 assert!(
2993 !desc.is_empty(),
2994 "bashkit tool description should not be empty"
2995 );
2996 assert!(
2998 desc.to_lowercase().contains("bash") || desc.to_lowercase().contains("command"),
2999 "description should mention bash or command, got: {}",
3000 desc
3001 );
3002 }
3003
3004 #[test]
3005 fn test_bashkit_tool_system_prompt_is_nonempty() {
3006 let prompt = BASHKIT_TOOL.system_prompt();
3007 assert!(
3008 !prompt.is_empty(),
3009 "bashkit system prompt should not be empty"
3010 );
3011 assert!(
3012 prompt.contains("everruns"),
3013 "system prompt should contain configured identity 'everruns', got: {}",
3014 prompt
3015 );
3016 }
3017
3018 #[test]
3019 fn test_bashkit_static_description_matches_tool() {
3020 let direct_desc = BASHKIT_TOOL.description();
3022 let static_desc: &str = &TOOL_DESCRIPTION;
3023 assert_eq!(static_desc, direct_desc);
3024
3025 let direct_prompt = BASHKIT_TOOL.system_prompt();
3026 let static_prompt: &str = &TOOL_SYSTEM_PROMPT;
3027 assert!(
3029 static_prompt.starts_with(&direct_prompt),
3030 "system prompt should start with bashkit prompt"
3031 );
3032 assert!(
3033 static_prompt.contains("Output economy"),
3034 "system prompt should include output economy hint"
3035 );
3036 }
3037
3038 #[test]
3039 fn test_bashkit_tool_builder_configuration() {
3040 let _desc = BASHKIT_TOOL.description();
3043 let _prompt = BASHKIT_TOOL.system_prompt();
3044 }
3046
3047 #[test]
3048 fn test_bash_tool_display_name() {
3049 let tool = BashTool::default();
3050 assert_eq!(tool.display_name(), Some("Bash"));
3051 }
3052
3053 #[test]
3054 fn test_bash_tool_parameters_schema_structure() {
3055 let tool = BashTool::default();
3056 let schema = tool.parameters_schema();
3057
3058 assert_eq!(schema["type"], "object");
3060 assert!(schema["properties"]["commands"].is_object());
3061
3062 assert!(schema["properties"]["working_dir"].is_object());
3064 assert!(schema["properties"]["timeout_ms"].is_object());
3065
3066 let required = schema["required"].as_array().unwrap();
3068 assert!(required.contains(&json!("commands")));
3069 }
3070
3071 #[test]
3072 fn test_execution_limits_configuration() {
3073 let limits = execution_limits();
3074 let _ = limits;
3077 }
3078
3079 #[test]
3084 fn test_adapter_is_search_capable() {
3085 let session_id = SessionId::new();
3086 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
3087 let adapter = SessionFileSystemAdapter::new(session_id, store);
3088
3089 let sc = adapter.as_search_capable();
3090 assert!(
3091 sc.is_some(),
3092 "SessionFileSystemAdapter should be SearchCapable"
3093 );
3094
3095 let provider = sc.unwrap().search_provider(Path::new("/workspace"));
3096 assert!(provider.is_some(), "Should return a SearchProvider");
3097
3098 let caps = provider.unwrap().capabilities();
3099 assert!(caps.content_search, "Should support content search");
3100 assert!(caps.regex, "Should support regex patterns");
3101 }
3102
3103 #[tokio::test]
3104 async fn test_search_provider_returns_grep_results() {
3105 let session_id = SessionId::new();
3106 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
3107 let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
3108
3109 adapter
3111 .write_file(
3112 Path::new("/workspace/hello.txt"),
3113 b"hello world\ngoodbye world",
3114 )
3115 .await
3116 .unwrap();
3117 adapter
3118 .write_file(Path::new("/workspace/other.txt"), b"no match here")
3119 .await
3120 .unwrap();
3121
3122 let sc = adapter.as_search_capable().unwrap();
3123 let provider = sc.search_provider(Path::new("/workspace")).unwrap();
3124
3125 let results = provider
3126 .search(&SearchQuery {
3127 pattern: "hello".into(),
3128 is_regex: false,
3129 case_insensitive: false,
3130 root: PathBuf::from("/workspace"),
3131 glob_filter: None,
3132 max_results: None,
3133 })
3134 .unwrap();
3135
3136 assert_eq!(results.matches.len(), 1);
3137 assert_eq!(
3138 results.matches[0].path,
3139 PathBuf::from("/workspace/hello.txt")
3140 );
3141 assert_eq!(results.matches[0].line_number, 1);
3142 assert_eq!(results.matches[0].line_content, "hello world");
3143 }
3144
3145 #[tokio::test]
3146 async fn test_search_provider_truncates_at_max_results() {
3147 let session_id = SessionId::new();
3148 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
3149 let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
3150
3151 adapter
3152 .write_file(
3153 Path::new("/workspace/many.txt"),
3154 b"match line 1\nmatch line 2\nmatch line 3\nmatch line 4",
3155 )
3156 .await
3157 .unwrap();
3158
3159 let sc = adapter.as_search_capable().unwrap();
3160 let provider = sc.search_provider(Path::new("/workspace")).unwrap();
3161
3162 let results = provider
3163 .search(&SearchQuery {
3164 pattern: "match".into(),
3165 is_regex: false,
3166 case_insensitive: false,
3167 root: PathBuf::from("/workspace"),
3168 glob_filter: None,
3169 max_results: Some(2),
3170 })
3171 .unwrap();
3172
3173 assert_eq!(results.matches.len(), 2);
3174 assert!(results.truncated);
3175 }
3176
3177 #[tokio::test]
3178 async fn test_bash_grep_uses_indexed_search() {
3179 let (context, _) = create_context_with_mock_store();
3180 let tool = BashTool::default();
3181
3182 tool.execute_with_context(
3184 json!({"commands": "mkdir -p /workspace/src && echo 'fn main() { println!(\"hello\"); }' > /workspace/src/main.rs && echo 'fn test() {}' > /workspace/src/test.rs"}),
3185 &context,
3186 )
3187 .await;
3188
3189 let result = tool
3191 .execute_with_context(json!({"commands": "grep -r 'fn' /workspace/src"}), &context)
3192 .await;
3193
3194 if let ToolExecutionResult::Success(output) = result {
3195 assert_eq!(output["exit_code"], 0);
3196 let stdout = output["stdout"].as_str().unwrap_or("");
3197 assert!(
3198 stdout.contains("fn main") || stdout.contains("fn test"),
3199 "grep -r should find matches via indexed search, got: {}",
3200 stdout
3201 );
3202 } else {
3203 panic!("Expected success result, got: {:?}", result);
3204 }
3205 }
3206
3207 #[test]
3208 fn test_parameters_schema_delegates_to_bashkit() {
3209 let tool = BashTool::default();
3210 let schema = tool.parameters_schema();
3211 let bashkit_schema = BASHKIT_TOOL.input_schema();
3212
3213 let bashkit_props = bashkit_schema["properties"].as_object().unwrap();
3215 let our_props = schema["properties"].as_object().unwrap();
3216 for key in bashkit_props.keys() {
3217 assert!(
3218 our_props.contains_key(key),
3219 "bashkit property '{key}' missing from parameters_schema"
3220 );
3221 }
3222
3223 let bashkit_required = bashkit_schema["required"].as_array().unwrap();
3225 let our_required = schema["required"].as_array().unwrap();
3226 for req in bashkit_required {
3227 assert!(
3228 our_required.contains(req),
3229 "bashkit required field {req} missing from parameters_schema"
3230 );
3231 }
3232
3233 assert!(
3235 our_props.contains_key("working_dir"),
3236 "working_dir must be in parameters_schema"
3237 );
3238 }
3239
3240 #[test]
3245 fn truncate_for_log_returns_short_strings_unchanged() {
3246 assert_eq!(truncate_for_log("hello", 100), "hello");
3247 assert_eq!(truncate_for_log("", 100), "");
3248 }
3249
3250 #[test]
3251 fn truncate_for_log_stays_within_budget_and_marks() {
3252 let input = "a".repeat(500);
3253 let out = truncate_for_log(&input, 100);
3254 assert!(
3255 out.len() <= 100,
3256 "output exceeded budget: {} bytes",
3257 out.len()
3258 );
3259 assert!(out.ends_with("…[truncated]"));
3260 assert!(out.starts_with('a'));
3261 }
3262
3263 #[test]
3264 fn truncate_for_log_respects_utf8_boundaries() {
3265 let input = "🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀";
3268 let out = truncate_for_log(input, 20);
3269 assert!(out.len() <= 20);
3270 assert!(out.starts_with('🦀'));
3271 assert!(out.ends_with("…[truncated]"));
3272 }
3273
3274 #[test]
3275 fn truncate_for_log_omits_marker_when_budget_is_too_small() {
3276 let input = "abcdefghijklmnop";
3279 let out = truncate_for_log(input, 4);
3280 assert_eq!(out, "abcd");
3281 assert!(out.len() <= 4);
3282 }
3283
3284 #[tokio::test]
3285 async fn install_observability_hooks_fires_on_builtin_and_preserves_exit() {
3286 use bashkit::hooks::{HookAction, ToolResult};
3287 use std::sync::Arc;
3288 use std::sync::atomic::{AtomicU64, Ordering};
3289
3290 let tool_calls = Arc::new(AtomicU64::new(0));
3291 let counter = tool_calls.clone();
3292
3293 let session_id: SessionId = "session_0197a4a4c0c0780180000000000000ff".parse().unwrap();
3297 let builder = install_observability_hooks(Bash::builder(), session_id).after_tool(
3298 Box::new(move |r: ToolResult| {
3299 counter.fetch_add(1, Ordering::Relaxed);
3300 HookAction::Continue(r)
3301 }),
3302 );
3303
3304 let mut bash = builder.build();
3305 let result = bash.exec("echo hook-smoke").await.unwrap();
3306
3307 assert_eq!(result.exit_code, 0);
3308 assert_eq!(result.stdout.trim(), "hook-smoke");
3309 assert!(
3310 tool_calls.load(Ordering::Relaxed) >= 1,
3311 "after_tool hook should fire at least once for `echo`"
3312 );
3313 }
3314
3315 mod http_tests {
3320 use super::*;
3321 use crate::capabilities::bashkit_shell::egress_transport::tests::MockEgress;
3322 use crate::egress::{EgressError, EgressRequestKind, EgressSigning};
3323 use crate::network_access::NetworkAccessList;
3324
3325 fn http_context(egress: Option<Arc<MockEgress>>) -> ToolContext {
3326 let (mut context, _) = create_context_with_mock_store();
3327 if let Some(egress) = egress {
3328 context.egress_service = Some(egress);
3329 }
3330 context
3331 }
3332
3333 #[tokio::test]
3334 async fn http_disabled_by_default_even_with_egress_available() {
3335 let egress = Arc::new(MockEgress::with_responses(vec![]));
3336 let context = http_context(Some(egress.clone()));
3337 let tool = BashTool::default();
3338
3339 let result = tool
3340 .execute_with_context(
3341 json!({"commands": "curl -s http://93.184.216.34/ 2>&1; echo rc=$?"}),
3342 &context,
3343 )
3344 .await;
3345
3346 let ToolExecutionResult::Success(output) = result else {
3347 panic!("expected success result");
3348 };
3349 let combined = format!("{}{}", output["stdout"], output["stderr"]);
3350 assert!(
3351 !combined.contains("rc=0"),
3352 "curl must fail without enable_http, got: {combined}"
3353 );
3354 assert!(
3355 egress.requests.lock().unwrap().is_empty(),
3356 "no request may reach egress when HTTP is disabled"
3357 );
3358 }
3359
3360 #[tokio::test]
3361 async fn http_enable_without_egress_service_stays_offline() {
3362 let context = http_context(None);
3363 let tool = BashTool { enable_http: true };
3364
3365 let result = tool
3366 .execute_with_context(
3367 json!({"commands": "curl -s http://93.184.216.34/ 2>&1; echo rc=$?"}),
3368 &context,
3369 )
3370 .await;
3371
3372 let ToolExecutionResult::Success(output) = result else {
3373 panic!("expected success result");
3374 };
3375 let combined = format!("{}{}", output["stdout"], output["stderr"]);
3376 assert!(
3377 !combined.contains("rc=0"),
3378 "curl must fail without an egress service, got: {combined}"
3379 );
3380 }
3381
3382 #[tokio::test]
3383 async fn curl_routes_through_egress_and_forwards_policy_metadata() {
3384 let egress = Arc::new(MockEgress::with_responses(vec![MockEgress::ok(
3385 200,
3386 &[("content-type", "text/plain")],
3387 "egress-ok",
3388 )]));
3389 let acl = NetworkAccessList::allow_only(["93.184.216.34"]);
3390 let mut context = http_context(Some(egress.clone()));
3391 context.network_access = Some(acl.clone());
3392 let tool = BashTool { enable_http: true };
3393
3394 let result = tool
3395 .execute_with_context(
3396 json!({"commands": "curl -s http://93.184.216.34/data"}),
3397 &context,
3398 )
3399 .await;
3400
3401 let ToolExecutionResult::Success(output) = result else {
3402 panic!("expected success result");
3403 };
3404 assert_eq!(output["exit_code"], 0, "stderr: {}", output["stderr"]);
3405 assert!(
3406 output["stdout"].as_str().unwrap().contains("egress-ok"),
3407 "stdout: {}",
3408 output["stdout"]
3409 );
3410
3411 assert_eq!(*egress.send_calls.lock().unwrap(), 0);
3412 assert_eq!(*egress.stream_calls.lock().unwrap(), 1);
3413 let requests = egress.requests.lock().unwrap();
3414 assert_eq!(requests.len(), 1);
3415 let request = &requests[0];
3416 assert_eq!(request.method, "GET");
3417 assert_eq!(request.url, "http://93.184.216.34/data");
3418 assert_eq!(request.kind, EgressRequestKind::Capability);
3419 assert_eq!(request.signing, EgressSigning::PlatformDefault);
3420 assert_eq!(request.network_access, Some(acl));
3421 assert!(request.timeout_ms.is_some(), "deadline must be forwarded");
3422 let (host, addrs) = request.pinned_addrs.as_ref().expect("pinned addrs");
3425 assert_eq!(host, "93.184.216.34");
3426 assert_eq!(addrs[0].ip().to_string(), "93.184.216.34");
3427 assert_eq!(addrs[0].port(), 80);
3428 }
3429
3430 #[tokio::test]
3431 async fn egress_denial_surfaces_as_curl_access_denied_exit_7() {
3432 let egress = Arc::new(MockEgress::with_responses(vec![Err(
3433 EgressError::NetworkAccessDenied {
3434 url: "http://93.184.216.34/blocked".to_string(),
3435 },
3436 )]));
3437 let context = http_context(Some(egress));
3438 let tool = BashTool { enable_http: true };
3439
3440 let result = tool
3441 .execute_with_context(
3442 json!({"commands": "curl -s http://93.184.216.34/blocked"}),
3443 &context,
3444 )
3445 .await;
3446
3447 let ToolExecutionResult::Success(output) = result else {
3448 panic!("expected success result");
3449 };
3450 assert_eq!(output["exit_code"], 7, "stderr: {}", output["stderr"]);
3451 assert!(
3452 output["stderr"].as_str().unwrap().contains("access denied"),
3453 "stderr: {}",
3454 output["stderr"]
3455 );
3456 assert!(
3457 output["stderr"]
3458 .as_str()
3459 .unwrap()
3460 .contains("blocked by network policy"),
3461 "stderr: {}",
3462 output["stderr"]
3463 );
3464 }
3465
3466 #[tokio::test]
3467 async fn oversized_egress_response_surfaces_as_curl_exit_63() {
3468 let big = "x".repeat(11 * 1024 * 1024);
3471 let egress = Arc::new(MockEgress::with_responses(vec![MockEgress::ok(
3472 200,
3473 &[("content-type", "text/plain")],
3474 &big,
3475 )]));
3476 let context = http_context(Some(egress.clone()));
3477 let tool = BashTool { enable_http: true };
3478
3479 let result = tool
3480 .execute_with_context(
3481 json!({"commands": "curl -s http://93.184.216.34/huge"}),
3482 &context,
3483 )
3484 .await;
3485
3486 let ToolExecutionResult::Success(output) = result else {
3487 panic!("expected success result");
3488 };
3489 assert_eq!(*egress.send_calls.lock().unwrap(), 0);
3490 assert_eq!(*egress.stream_calls.lock().unwrap(), 1);
3491 assert_eq!(output["exit_code"], 63, "stderr: {}", output["stderr"]);
3492 assert!(
3493 output["stderr"]
3494 .as_str()
3495 .unwrap()
3496 .contains("response too large"),
3497 "stderr: {}",
3498 output["stderr"]
3499 );
3500 }
3501
3502 #[test]
3503 fn validate_config_accepts_bool_and_rejects_other_types() {
3504 let cap = BashkitShellCapability;
3505 assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
3506 assert!(cap.validate_config(&json!({})).is_ok());
3507 assert!(cap.validate_config(&json!({"enable_http": true})).is_ok());
3508 assert!(cap.validate_config(&json!({"enable_http": "yes"})).is_err());
3509 assert!(cap.validate_config(&json!("nope")).is_err());
3510 }
3511
3512 #[test]
3513 fn config_schema_exposes_enable_http() {
3514 let schema = BashkitShellCapability.config_schema().unwrap();
3515 assert!(schema["properties"]["enable_http"]["type"] == "boolean");
3516 }
3517 }
3518}