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