Skip to main content

everruns_core/capabilities/
virtual_bash.rs

1//! Virtual Bash Capability
2//!
3//! This capability provides a sandboxed bash interpreter using bashkit.
4//! The bash environment uses a custom FileSystem adapter that bridges
5//! directly to the session file store.
6//!
7//! Design decisions:
8//! - SessionFileSystemAdapter implements bashkit's FileSystem trait
9//! - Direct delegation to SessionFileSystem - no sync overhead
10//! - Live visibility: files written by other tools are immediately visible
11//! - Resource limits prevent runaway scripts (max commands, loop iterations)
12//! - Context-aware tool that requires session filesystem access
13//! - SearchCapable impl delegates grep to SessionFileSystem::grep_files for
14//!   single-query indexed search instead of per-file linear scan
15//!
16//! Trust boundary (TM-AGENT-005, TM-BASH-001..016):
17//! - `risk_level()` returns `High`. Per the capability admin-only tier contract
18//!   (`specs/capabilities.md`, `specs/permissions.md`), assigning `virtual_bash`
19//!   to an agent requires `OrgRole::Admin`; the canonical create/update gate is
20//!   `check_high_risk_caps` in `crates/server/src/domains/agents/commands.rs`
21//!   (invoked from `CreateAgent::execute`, `UpdateAgent::execute`, and
22//!   `UpsertAgent::execute`). The sibling `require_admin_for_high_risk` helper
23//!   in `crates/server/src/api/agents.rs` enforces the same contract on
24//!   agent-import / copy paths. Existing member-owned agents that already had
25//!   `virtual_bash` before the elevation continue to run (gate is
26//!   creation/update only, not runtime). New assignments by non-admin members
27//!   are rejected with HTTP 403.
28//! - Rationale: `virtual_bash` exposes scripted code execution. Even though
29//!   the bashkit sandbox provides workspace-only filesystem access and no
30//!   real network, the combination of arbitrary command composition + LLM-
31//!   driven invocation makes this a meaningful trust elevation versus
32//!   single-purpose tools. Org admins are the only principals expected to
33//!   accept that surface for shared agents.
34
35use super::{Capability, CapabilityStatus, RiskLevel};
36use crate::background::{
37    BackgroundEventSink, BackgroundExecutableTool, BackgroundOutcome, BackgroundProgress,
38};
39use crate::exec_tool_result::ExecToolResultPayload;
40use crate::session_file::SessionFile;
41use crate::tool_types::ToolHints;
42use crate::tools::{Tool, ToolExecutionResult};
43use crate::traits::{SessionFileSystem, ToolContext};
44use crate::typed_id::SessionId;
45use async_trait::async_trait;
46use bashkit::{
47    Bash, BashBuilder, BashTool as BashkitTool, DirEntry, ExecutionLimits, FileSystem,
48    FileSystemExt, FileType, Metadata, OutputCallback, SearchCapabilities, SearchCapable,
49    SearchMatch as BashkitSearchMatch, SearchProvider, SearchQuery, SearchResults,
50    Tool as BashkitToolTrait, TraceEventKind, TraceMode,
51};
52use serde_json::{Value, json};
53use std::path::{Path, PathBuf};
54use std::sync::atomic::{AtomicUsize, Ordering};
55use std::sync::{Arc, LazyLock};
56use std::time::SystemTime;
57
58// ============================================================================
59// Static configuration
60// ============================================================================
61
62/// Shared execution limits for bashkit.
63fn execution_limits() -> ExecutionLimits {
64    ExecutionLimits::new()
65        .max_commands(1000)
66        .max_loop_iterations(10000)
67        .max_function_depth(100)
68        .max_input_bytes(1_000_000) // 1MB max script size
69        .max_ast_depth(100)
70        .parser_timeout(std::time::Duration::from_secs(5))
71}
72
73/// Configured bashkit tool instance with everruns settings.
74static BASHKIT_TOOL: LazyLock<BashkitTool> = LazyLock::new(|| {
75    BashkitTool::builder()
76        .username("everruns")
77        .hostname("everruns")
78        .limits(execution_limits())
79        .env("HOME", "/home/agent")
80        .env("SHELL", "/bin/bash")
81        .env("PATH", "/usr/local/bin:/usr/bin:/bin")
82        .env("WORKSPACE", "/workspace")
83        .build()
84});
85
86/// Tool description from bashkit library.
87static TOOL_DESCRIPTION: LazyLock<String> =
88    LazyLock::new(|| BASHKIT_TOOL.description().to_string());
89
90/// System prompt addition from bashkit library + output economy hint.
91static TOOL_SYSTEM_PROMPT: LazyLock<String> = LazyLock::new(|| {
92    let mut prompt = BASHKIT_TOOL.system_prompt().to_string();
93    prompt.push_str(crate::tool_output_sanitizer::EXEC_OUTPUT_HINT);
94    prompt
95});
96
97/// Input schema from bashkit library, extended with everruns-specific `working_dir`.
98/// Delegating to bashkit avoids schema drift when bashkit adds/changes parameters.
99static TOOL_INPUT_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
100    let mut schema = BASHKIT_TOOL.input_schema();
101    // Add everruns-specific working_dir param only if bashkit does not already define it.
102    if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
103        if !props.contains_key("working_dir") {
104            props.insert(
105                "working_dir".to_string(),
106                json!({
107                    "type": "string",
108                    "default": "/workspace",
109                    "description": "Working directory for command execution"
110                }),
111            );
112        }
113        if !props.contains_key("output") {
114            props.insert(
115                "output".to_string(),
116                crate::tool_output_sanitizer::output_verbosity_schema(),
117            );
118        }
119    }
120    schema
121});
122
123/// Virtual Bash capability - execute bash commands in a sandboxed environment
124pub struct VirtualBashCapability;
125
126impl Capability for VirtualBashCapability {
127    fn id(&self) -> &str {
128        "virtual_bash"
129    }
130
131    fn name(&self) -> &str {
132        "Virtual Bash"
133    }
134
135    fn description(&self) -> &str {
136        r#"Execute bash commands in an isolated, sandboxed environment.
137
138> [!NOTE]
139> Commands run in a virtual environment with no access to the host system.
140> The session filesystem is mounted at root, so you can read and write session files.
141
142> [!TIP]
143> Use standard Unix commands like `ls`, `cat`, `grep`, `echo`, and shell features
144> like pipes, redirections, and command substitution. Built-in commands support
145> `<command> --help`, and many also support `<command> --version`."#
146    }
147
148    fn status(&self) -> CapabilityStatus {
149        CapabilityStatus::Available
150    }
151
152    fn risk_level(&self) -> RiskLevel {
153        RiskLevel::High
154    }
155
156    fn icon(&self) -> Option<&str> {
157        Some("terminal")
158    }
159
160    fn category(&self) -> Option<&str> {
161        Some("Execution")
162    }
163
164    fn system_prompt_addition(&self) -> Option<&str> {
165        Some(&TOOL_SYSTEM_PROMPT)
166    }
167
168    fn tools(&self) -> Vec<Box<dyn Tool>> {
169        vec![Box::new(BashTool)]
170    }
171
172    fn dependencies(&self) -> Vec<&'static str> {
173        // Depends on session filesystem for file access
174        vec!["session_file_system"]
175    }
176
177    fn features(&self) -> Vec<&'static str> {
178        vec!["file_system"]
179    }
180}
181
182// ============================================================================
183// BashTool
184// ============================================================================
185
186/// Tool to execute bash commands in a sandboxed environment
187pub struct BashTool;
188
189#[async_trait]
190impl Tool for BashTool {
191    fn name(&self) -> &str {
192        "bash"
193    }
194
195    fn display_name(&self) -> Option<&str> {
196        Some("Bash")
197    }
198
199    fn description(&self) -> &str {
200        &TOOL_DESCRIPTION
201    }
202
203    fn parameters_schema(&self) -> Value {
204        TOOL_INPUT_SCHEMA.clone()
205    }
206
207    fn hints(&self) -> ToolHints {
208        ToolHints::default()
209            .with_long_running(true)
210            .with_open_world(true)
211            .with_persist_output(true)
212            .with_supports_background(true)
213            // Mutates the shared session workspace: serialize concurrent bash
214            // calls in a batch so they don't race on the filesystem. Runs an
215            // in-process interpreter, so offload to its own task to avoid
216            // starving I/O-bound tools sharing the act batch.
217            .with_concurrency_class("session_workspace")
218            .with_cpu_bound(true)
219    }
220
221    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
222        ToolExecutionResult::tool_error(
223            "bash requires context. This tool must be executed with session context.",
224        )
225    }
226
227    async fn execute_with_context(
228        &self,
229        arguments: Value,
230        context: &ToolContext,
231    ) -> ToolExecutionResult {
232        let command = match arguments.get("commands").and_then(|v| v.as_str()) {
233            Some(c) => c,
234            None => {
235                return ToolExecutionResult::tool_error("Missing required parameter: commands");
236            }
237        };
238
239        let working_dir = arguments
240            .get("working_dir")
241            .and_then(|v| v.as_str())
242            .unwrap_or("/workspace");
243
244        let timeout_ms = arguments
245            .get("timeout_ms")
246            .and_then(|v| v.as_u64())
247            .unwrap_or(30000)
248            .min(60000);
249
250        // EVE-489: persistence-first default. `auto` returns a compact
251        // summary on success and a `normal`-sized diagnostic window on failure.
252        let output_mode = arguments
253            .get("output")
254            .and_then(|v| v.as_str())
255            .unwrap_or("auto");
256
257        let file_store = match &context.file_store {
258            Some(store) => store.clone(),
259            None => {
260                return ToolExecutionResult::tool_error(
261                    "File system not available in this context",
262                );
263            }
264        };
265
266        // Create filesystem adapter that bridges to session file store
267        let session_fs = Arc::new(SessionFileSystemAdapter::new(
268            context.session_id,
269            file_store,
270        ));
271
272        // Resolve locale from context (defaults to en-US).
273        let locale = context.locale.as_deref().unwrap_or("en-US");
274
275        // Configure bash with resource limits (uses shared execution_limits).
276        // Observability hooks are installed last so per-builtin / error telemetry
277        // is available without changing any existing limits or boundaries.
278        let builder = Bash::builder()
279            .fs(session_fs)
280            .cwd(working_dir)
281            .username("everruns")
282            .hostname("everruns")
283            .env("HOME", "/home/agent")
284            .env("SHELL", "/bin/bash")
285            .env("PATH", "/usr/local/bin:/usr/bin:/bin")
286            .env("WORKSPACE", "/workspace")
287            .env("LANG", locale)
288            .limits(execution_limits())
289            .max_memory(10 * 1024 * 1024) // 10 MB — prevent OOM from untrusted input
290            .trace_mode(TraceMode::Redacted);
291        let mut bash = install_observability_hooks(builder, context.session_id).build();
292
293        // Stream output via tool.output.delta events for live UI/CLI rendering.
294        // bashkit's exec_streaming calls OutputCallback with (stdout_chunk, stderr_chunk)
295        // after each command completes. We bridge to async emit via a channel.
296        // A bounded channel collects partial output for cancellation recovery
297        // without allowing unbounded memory growth.
298        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(String, String)>();
299        let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
300
301        let output_callback: OutputCallback =
302            Box::new(move |stdout_chunk: &str, stderr_chunk: &str| {
303                // Best-effort: if receiver dropped, we just ignore
304                let _ = tx.send((stdout_chunk.to_string(), stderr_chunk.to_string()));
305                // Bounded: drop if full rather than growing without bound.
306                let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
307            });
308
309        // Spawn a task that reads chunks from the channel and emits events
310        let emit_context = context.clone();
311        let emit_task = tokio::spawn(async move {
312            while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
313                if !stdout_chunk.is_empty() {
314                    emit_context
315                        .emit_tool_output("bash", &stdout_chunk, "stdout")
316                        .await;
317                }
318                if !stderr_chunk.is_empty() {
319                    emit_context
320                        .emit_tool_output("bash", &stderr_chunk, "stderr")
321                        .await;
322                }
323            }
324        });
325
326        // Grab the cancellation token so we can signal graceful abort on timeout.
327        let cancel_token = bash.cancellation_token();
328
329        // Execute with timeout. On timeout, signal cancellation via the token
330        // so bashkit aborts at the next command boundary and we can collect
331        // partial output instead of discarding everything.
332        let exec_start = std::time::Instant::now();
333        let result = tokio::time::timeout(
334            std::time::Duration::from_millis(timeout_ms),
335            bash.exec_streaming(command, output_callback),
336        )
337        .await;
338        let exec_duration = exec_start.elapsed();
339
340        // Wait for all buffered chunks to be emitted (sender dropped when exec completes)
341        let _ = emit_task.await;
342
343        match result {
344            Ok(Ok(output)) => {
345                // Extract metadata from trace events (EVE-240)
346                let commands_executed = output
347                    .events
348                    .iter()
349                    .filter(|e| e.kind == TraceEventKind::CommandExit)
350                    .count();
351                let fs_reads = output
352                    .events
353                    .iter()
354                    .filter(|e| e.kind == TraceEventKind::FileAccess)
355                    .count();
356                let fs_writes = output
357                    .events
358                    .iter()
359                    .filter(|e| e.kind == TraceEventKind::FileMutation)
360                    .count();
361
362                tracing::info!(
363                    tool = "bash",
364                    duration_ms = exec_duration.as_millis() as u64,
365                    exit_code = output.exit_code,
366                    commands_executed,
367                    fs_reads,
368                    fs_writes,
369                    stdout_bytes = output.stdout.len(),
370                    stderr_bytes = output.stderr.len(),
371                    "bashkit execution completed"
372                );
373
374                let payload = ExecToolResultPayload::new(
375                    &output.stdout,
376                    &output.stderr,
377                    output.exit_code,
378                    output_mode,
379                );
380                let ExecToolResultPayload {
381                    stdout,
382                    stderr,
383                    exit_code,
384                    success,
385                    truncated,
386                    total_lines,
387                    raw_output,
388                } = payload;
389                ToolExecutionResult::success_with_raw_output(
390                    json!({
391                        "stdout": stdout,
392                        "stderr": stderr,
393                        "exit_code": exit_code,
394                        "success": success,
395                        "truncated": truncated,
396                        "total_lines": total_lines,
397                    }),
398                    raw_output,
399                )
400            }
401            Ok(Err(e)) => {
402                // Execution error (syntax error, resource limit, etc.)
403                ToolExecutionResult::tool_error(format!("Bash execution error: {}", e))
404            }
405            Err(_) => {
406                // Timeout — signal cancellation for the in-flight execution so any
407                // underlying bashkit work stops promptly, then collect whatever
408                // partial output the streaming callback captured.
409                cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
410
411                let partial = collect_partial_output(partial_rx);
412                if partial.is_empty() {
413                    ToolExecutionResult::tool_error(format!(
414                        "Command timed out after {}ms",
415                        timeout_ms
416                    ))
417                } else {
418                    use crate::tool_output_sanitizer::{
419                        clean_exec_output, output_verbosity_budget, priority_aware_truncate,
420                        resolve_auto_mode,
421                    };
422                    // EVE-489: a timeout is a failure — `auto` resolves to
423                    // `normal` so the model gets useful diagnostics.
424                    let effective = resolve_auto_mode(output_mode, 1);
425                    let clean = clean_exec_output(&partial);
426                    let truncated = if let Some(budget) = output_verbosity_budget(effective) {
427                        priority_aware_truncate(&clean, budget)
428                    } else {
429                        clean.clone()
430                    };
431                    ToolExecutionResult::tool_error(format!(
432                        "Command timed out after {}ms. Partial output:\n{}",
433                        timeout_ms, truncated
434                    ))
435                }
436            }
437        }
438    }
439
440    fn requires_context(&self) -> bool {
441        true
442    }
443
444    fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
445        Some(self)
446    }
447}
448
449#[async_trait]
450impl BackgroundExecutableTool for BashTool {
451    async fn execute_background(
452        &self,
453        arguments: Value,
454        context: ToolContext,
455        sink: Arc<dyn BackgroundEventSink>,
456    ) -> Result<BackgroundOutcome, ToolExecutionResult> {
457        let command = match arguments.get("commands").and_then(|v| v.as_str()) {
458            Some(c) => c,
459            None => {
460                return Err(ToolExecutionResult::tool_error(
461                    "Missing required parameter: commands",
462                ));
463            }
464        };
465
466        let working_dir = arguments
467            .get("working_dir")
468            .and_then(|v| v.as_str())
469            .unwrap_or("/workspace");
470
471        let timeout_ms = arguments
472            .get("timeout_ms")
473            .and_then(|v| v.as_u64())
474            .unwrap_or(30000)
475            .min(60000);
476
477        // EVE-489: persistence-first default for background execution as well.
478        let output_mode = arguments
479            .get("output")
480            .and_then(|v| v.as_str())
481            .unwrap_or("auto");
482
483        let file_store = match &context.file_store {
484            Some(store) => store.clone(),
485            None => {
486                return Err(ToolExecutionResult::tool_error(
487                    "File system not available in this context",
488                ));
489            }
490        };
491
492        let session_fs = Arc::new(SessionFileSystemAdapter::new(
493            context.session_id,
494            file_store,
495        ));
496        let locale = context.locale.as_deref().unwrap_or("en-US");
497
498        let builder = Bash::builder()
499            .fs(session_fs)
500            .cwd(working_dir)
501            .username("everruns")
502            .hostname("everruns")
503            .env("HOME", "/home/agent")
504            .env("SHELL", "/bin/bash")
505            .env("PATH", "/usr/local/bin:/usr/bin:/bin")
506            .env("WORKSPACE", "/workspace")
507            .env("LANG", locale)
508            .limits(execution_limits())
509            .max_memory(10 * 1024 * 1024)
510            .trace_mode(TraceMode::Redacted);
511        let mut bash = install_observability_hooks(builder, context.session_id).build();
512
513        let (tx, mut rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
514        let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
515        let sink_for_output = sink.clone();
516        let dropped_chunks = Arc::new(AtomicUsize::new(0));
517        let dropped_chunks_for_callback = dropped_chunks.clone();
518        let output_callback: OutputCallback =
519            Box::new(move |stdout_chunk: &str, stderr_chunk: &str| {
520                if tx
521                    .try_send((stdout_chunk.to_string(), stderr_chunk.to_string()))
522                    .is_err()
523                {
524                    dropped_chunks_for_callback.fetch_add(1, Ordering::Relaxed);
525                }
526                let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
527            });
528
529        let emit_task = tokio::spawn(async move {
530            while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
531                if !stdout_chunk.is_empty() {
532                    let _ = sink_for_output.output("stdout", &stdout_chunk).await;
533                }
534                if !stderr_chunk.is_empty() {
535                    let _ = sink_for_output.output("stderr", &stderr_chunk).await;
536                }
537            }
538        });
539
540        let _ = sink.status("Running bash command").await;
541        let cancel_token = bash.cancellation_token();
542        let exec_start = std::time::Instant::now();
543        let result = tokio::time::timeout(
544            std::time::Duration::from_millis(timeout_ms),
545            bash.exec_streaming(command, output_callback),
546        )
547        .await;
548        let exec_duration = exec_start.elapsed();
549        let _ = emit_task.await;
550        let dropped_chunks = dropped_chunks.load(Ordering::Relaxed);
551        if dropped_chunks > 0 {
552            let _ = sink
553                .output(
554                    "stderr",
555                    &format!(
556                        "[system] dropped {dropped_chunks} background output chunk(s) due to backpressure\n"
557                    ),
558                )
559                .await;
560        }
561
562        match result {
563            Ok(Ok(output)) => {
564                let payload = ExecToolResultPayload::new(
565                    &output.stdout,
566                    &output.stderr,
567                    output.exit_code,
568                    output_mode,
569                );
570                let ExecToolResultPayload {
571                    stdout,
572                    stderr,
573                    exit_code,
574                    success,
575                    truncated,
576                    total_lines,
577                    raw_output,
578                } = payload;
579                let _ = sink
580                    .progress(BackgroundProgress {
581                        current: Some(exec_duration.as_millis() as u64),
582                        total: None,
583                        unit: Some("ms".to_string()),
584                        label: Some("runtime".to_string()),
585                    })
586                    .await;
587                Ok(BackgroundOutcome {
588                    summary: format!(
589                        "Bash command exited with code {} after {} ms",
590                        exit_code,
591                        exec_duration.as_millis()
592                    ),
593                    result: json!({
594                        "stdout": stdout,
595                        "stderr": stderr,
596                        "exit_code": exit_code,
597                        "success": success,
598                        "truncated": truncated,
599                        "total_lines": total_lines,
600                    }),
601                    raw_output: Some(raw_output),
602                })
603            }
604            Ok(Err(e)) => Err(ToolExecutionResult::tool_error(format!(
605                "Bash execution error: {}",
606                e
607            ))),
608            Err(_) => {
609                cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
610
611                let partial = collect_partial_output(partial_rx);
612                if partial.is_empty() {
613                    Err(ToolExecutionResult::tool_error(format!(
614                        "Command timed out after {}ms",
615                        timeout_ms
616                    )))
617                } else {
618                    use crate::tool_output_sanitizer::{
619                        clean_exec_output, output_verbosity_budget, priority_aware_truncate,
620                        resolve_auto_mode,
621                    };
622                    // EVE-489: a timeout is a failure — resolve `auto` to
623                    // `normal` so the model gets useful diagnostics.
624                    let effective = resolve_auto_mode(output_mode, 1);
625                    let clean = clean_exec_output(&partial);
626                    let truncated = if let Some(budget) = output_verbosity_budget(effective) {
627                        priority_aware_truncate(&clean, budget)
628                    } else {
629                        clean.clone()
630                    };
631                    Err(ToolExecutionResult::tool_error(format!(
632                        "Command timed out after {}ms. Partial output:\n{}",
633                        timeout_ms, truncated
634                    )))
635                }
636            }
637        }
638    }
639}
640
641// Observational-only. Emits `tracing` events for each bashkit builtin
642// invocation and interpreter error, tagged with the active `session_id` for
643// audit correlation. Every hook returns `HookAction::Continue`; none widen
644// bashkit's existing limits or sandbox (TM-BASH). Hook callbacks log
645// structural metadata (tool name, arg count, exit code, byte lengths) but
646// never the argument values or builtin stdout — those surfaces can carry
647// tenant paths, URLs, or embedded secrets. HTTP hooks (`before_http` /
648// `after_http`) require bashkit's `http_client` feature, which everruns does
649// not enable for `virtual_bash` (TM-BASH-003: no network builtins).
650fn install_observability_hooks(builder: BashBuilder, session_id: SessionId) -> BashBuilder {
651    use bashkit::hooks::{ErrorEvent, HookAction, ToolEvent, ToolResult};
652    builder
653        .before_tool(Box::new(move |ev: ToolEvent| {
654            tracing::debug!(
655                target: "bashkit.hook",
656                capability = "virtual_bash",
657                session_id = %session_id,
658                event = "before_tool",
659                tool = %ev.name,
660                arg_count = ev.args.len(),
661                "builtin invoked"
662            );
663            HookAction::Continue(ev)
664        }))
665        .after_tool(Box::new(move |res: ToolResult| {
666            tracing::debug!(
667                target: "bashkit.hook",
668                capability = "virtual_bash",
669                session_id = %session_id,
670                event = "after_tool",
671                tool = %res.name,
672                exit_code = res.exit_code,
673                stdout_bytes = res.stdout.len(),
674                "builtin completed"
675            );
676            HookAction::Continue(res)
677        }))
678        .on_error(Box::new(move |ev: ErrorEvent| {
679            let preview = truncate_for_log(&ev.message, 256);
680            tracing::warn!(
681                target: "bashkit.hook",
682                capability = "virtual_bash",
683                session_id = %session_id,
684                event = "on_error",
685                message = %preview,
686                "interpreter error"
687            );
688            HookAction::Continue(ev)
689        }))
690}
691
692/// Bounded diagnostic preview for hook log fields. The return value is
693/// guaranteed to be no longer than `max_bytes` and to end on a valid UTF-8
694/// char boundary. When there is room, a trailing marker is appended so
695/// truncated entries remain visible in logs without exceeding the budget.
696fn truncate_for_log(msg: &str, max_bytes: usize) -> String {
697    const MARKER: &str = "…[truncated]";
698    if msg.len() <= max_bytes {
699        return msg.to_string();
700    }
701    let budget = max_bytes.saturating_sub(MARKER.len());
702    let mut cut = budget.min(msg.len());
703    while cut > 0 && !msg.is_char_boundary(cut) {
704        cut -= 1;
705    }
706    if max_bytes > MARKER.len() {
707        format!("{}{}", &msg[..cut], MARKER)
708    } else {
709        // Budget too small to fit the marker; return just the bounded slice.
710        let mut cut = max_bytes.min(msg.len());
711        while cut > 0 && !msg.is_char_boundary(cut) {
712            cut -= 1;
713        }
714        msg[..cut].to_string()
715    }
716}
717
718/// Drain all buffered chunks from the partial output channel into a single string.
719/// Keeps stdout and stderr separated with the same delimiter convention used elsewhere.
720fn collect_partial_output(mut rx: tokio::sync::mpsc::Receiver<(String, String)>) -> String {
721    let mut stdout_buf = String::new();
722    let mut stderr_buf = String::new();
723    while let Ok((stdout, stderr)) = rx.try_recv() {
724        stdout_buf.push_str(&stdout);
725        stderr_buf.push_str(&stderr);
726    }
727    let mut partial = stdout_buf;
728    if !stderr_buf.is_empty() {
729        if !partial.is_empty() && !partial.ends_with('\n') {
730            partial.push('\n');
731        }
732        partial.push_str("--- stderr ---\n");
733        partial.push_str(&stderr_buf);
734    }
735    partial
736}
737
738// ============================================================================
739// SessionFileSystemAdapter
740// ============================================================================
741
742/// Adapter that implements bashkit's FileSystem trait by delegating to SessionFileSystem.
743///
744/// This provides live visibility of session files during bash execution - any files
745/// written by other tools are immediately visible, and vice versa.
746pub struct SessionFileSystemAdapter {
747    session_id: SessionId,
748    store: Arc<dyn SessionFileSystem>,
749}
750
751impl SessionFileSystemAdapter {
752    pub fn new(session_id: SessionId, store: Arc<dyn SessionFileSystem>) -> Self {
753        Self { session_id, store }
754    }
755
756    /// Workspace mount point in the virtual filesystem
757    const WORKSPACE_PREFIX: &'static str = "/workspace";
758
759    /// Convert bash path to session file store path.
760    /// Paths under /workspace are mapped to session store.
761    /// Returns None for paths outside /workspace.
762    fn to_session_path(path: &Path) -> Option<String> {
763        let path_str = path.to_string_lossy();
764
765        // Normalize to absolute path
766        let abs_path = if path_str.starts_with('/') {
767            path_str.to_string()
768        } else {
769            format!("/{}", path_str)
770        };
771
772        // Check if path is under /workspace
773        if abs_path == Self::WORKSPACE_PREFIX {
774            // Root of workspace maps to root of session store
775            Some("/".to_string())
776        } else if let Some(stripped) = abs_path.strip_prefix(Self::WORKSPACE_PREFIX) {
777            // /workspace/foo -> /foo
778            if stripped.starts_with('/') {
779                Some(stripped.to_string())
780            } else {
781                // /workspacefoo is not valid
782                None
783            }
784        } else {
785            // Path is outside /workspace
786            None
787        }
788    }
789}
790
791#[async_trait]
792impl FileSystemExt for SessionFileSystemAdapter {}
793
794#[async_trait]
795impl FileSystem for SessionFileSystemAdapter {
796    async fn read_file(&self, path: &Path) -> bashkit::Result<Vec<u8>> {
797        let session_path = Self::to_session_path(path).ok_or_else(|| {
798            bashkit::Error::Io(std::io::Error::new(
799                std::io::ErrorKind::NotFound,
800                format!("Path not in workspace: {}", path.display()),
801            ))
802        })?;
803
804        match self.store.read_file(self.session_id, &session_path).await {
805            Ok(Some(file)) => {
806                let content = file.content.unwrap_or_default();
807                SessionFile::decode_content(&content, &file.encoding)
808                    .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
809            }
810            Ok(None) => Err(bashkit::Error::Io(std::io::Error::new(
811                std::io::ErrorKind::NotFound,
812                format!("File not found: {}", path.display()),
813            ))),
814            Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
815        }
816    }
817
818    async fn write_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
819        let session_path = Self::to_session_path(path).ok_or_else(|| {
820            bashkit::Error::Io(std::io::Error::new(
821                std::io::ErrorKind::PermissionDenied,
822                format!("Cannot write outside workspace: {}", path.display()),
823            ))
824        })?;
825
826        let (encoded, encoding) = SessionFile::encode_content(content);
827
828        self.store
829            .write_file(self.session_id, &session_path, &encoded, &encoding)
830            .await
831            .map(|_| ())
832            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
833    }
834
835    async fn append_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
836        let session_path = Self::to_session_path(path).ok_or_else(|| {
837            bashkit::Error::Io(std::io::Error::new(
838                std::io::ErrorKind::PermissionDenied,
839                format!("Cannot write outside workspace: {}", path.display()),
840            ))
841        })?;
842
843        // Read existing content
844        let mut existing = match self.store.read_file(self.session_id, &session_path).await {
845            Ok(Some(file)) => {
846                let content = file.content.unwrap_or_default();
847                SessionFile::decode_content(&content, &file.encoding)
848                    .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?
849            }
850            Ok(None) => Vec::new(),
851            Err(e) => return Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
852        };
853
854        // Append new content
855        existing.extend_from_slice(content);
856
857        // Write back
858        let (encoded, encoding) = SessionFile::encode_content(&existing);
859        self.store
860            .write_file(self.session_id, &session_path, &encoded, &encoding)
861            .await
862            .map(|_| ())
863            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
864    }
865
866    async fn mkdir(&self, path: &Path, _recursive: bool) -> bashkit::Result<()> {
867        let session_path = Self::to_session_path(path).ok_or_else(|| {
868            bashkit::Error::Io(std::io::Error::new(
869                std::io::ErrorKind::PermissionDenied,
870                format!(
871                    "Cannot create directory outside workspace: {}",
872                    path.display()
873                ),
874            ))
875        })?;
876
877        self.store
878            .create_directory(self.session_id, &session_path)
879            .await
880            .map(|_| ())
881            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
882    }
883
884    async fn remove(&self, path: &Path, recursive: bool) -> bashkit::Result<()> {
885        let session_path = Self::to_session_path(path).ok_or_else(|| {
886            bashkit::Error::Io(std::io::Error::new(
887                std::io::ErrorKind::PermissionDenied,
888                format!("Cannot delete outside workspace: {}", path.display()),
889            ))
890        })?;
891
892        self.store
893            .delete_file(self.session_id, &session_path, recursive)
894            .await
895            .map(|_| ())
896            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
897    }
898
899    async fn stat(&self, path: &Path) -> bashkit::Result<Metadata> {
900        // Handle /workspace itself
901        if path.to_string_lossy() == "/workspace" {
902            let now = SystemTime::now();
903            return Ok(Metadata {
904                file_type: FileType::Directory,
905                size: 0,
906                mode: 0o755,
907                modified: now,
908                created: now,
909            });
910        }
911
912        let session_path = Self::to_session_path(path).ok_or_else(|| {
913            bashkit::Error::Io(std::io::Error::new(
914                std::io::ErrorKind::NotFound,
915                format!("Path not in workspace: {}", path.display()),
916            ))
917        })?;
918
919        // Check if it's a file
920        match self.store.read_file(self.session_id, &session_path).await {
921            Ok(Some(file)) => {
922                let now = SystemTime::now();
923
924                let file_type = if file.is_directory {
925                    FileType::Directory
926                } else {
927                    FileType::File
928                };
929
930                // Use 0o755 so files are executable by default in the virtual filesystem.
931                // The session filesystem doesn't track Unix permissions, and scripts
932                // stored in /workspace need to be directly executable.
933                Ok(Metadata {
934                    file_type,
935                    size: file.size_bytes as u64,
936                    mode: 0o755,
937                    modified: now,
938                    created: now,
939                })
940            }
941            Ok(None) => {
942                // Check if it's a directory by listing it
943                match self
944                    .store
945                    .list_directory(self.session_id, &session_path)
946                    .await
947                {
948                    Ok(_entries) => {
949                        let now = SystemTime::now();
950                        Ok(Metadata {
951                            file_type: FileType::Directory,
952                            size: 0,
953                            mode: 0o755,
954                            modified: now,
955                            created: now,
956                        })
957                    }
958                    Err(_) => Err(bashkit::Error::Io(std::io::Error::new(
959                        std::io::ErrorKind::NotFound,
960                        format!("Path not found: {}", path.display()),
961                    ))),
962                }
963            }
964            Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
965        }
966    }
967
968    async fn read_dir(&self, path: &Path) -> bashkit::Result<Vec<DirEntry>> {
969        let session_path = Self::to_session_path(path).ok_or_else(|| {
970            bashkit::Error::Io(std::io::Error::new(
971                std::io::ErrorKind::NotFound,
972                format!("Path not in workspace: {}", path.display()),
973            ))
974        })?;
975
976        let entries = self
977            .store
978            .list_directory(self.session_id, &session_path)
979            .await
980            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
981
982        let now = SystemTime::now();
983
984        Ok(entries
985            .into_iter()
986            .map(|e| {
987                let file_type = if e.is_directory {
988                    FileType::Directory
989                } else {
990                    FileType::File
991                };
992
993                DirEntry {
994                    name: e.name,
995                    metadata: Metadata {
996                        file_type,
997                        size: e.size_bytes as u64,
998                        mode: 0o755,
999                        modified: now,
1000                        created: now,
1001                    },
1002                }
1003            })
1004            .collect())
1005    }
1006
1007    async fn exists(&self, path: &Path) -> bashkit::Result<bool> {
1008        // /workspace always exists
1009        if path.to_string_lossy() == "/workspace" {
1010            return Ok(true);
1011        }
1012
1013        let session_path = match Self::to_session_path(path) {
1014            Some(p) => p,
1015            None => return Ok(false), // Paths outside workspace don't exist
1016        };
1017
1018        // Check file
1019        if let Ok(Some(_)) = self.store.read_file(self.session_id, &session_path).await {
1020            return Ok(true);
1021        }
1022
1023        // Check directory
1024        if self
1025            .store
1026            .list_directory(self.session_id, &session_path)
1027            .await
1028            .is_ok()
1029        {
1030            return Ok(true);
1031        }
1032
1033        Ok(false)
1034    }
1035
1036    async fn rename(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
1037        let from_session = Self::to_session_path(from).ok_or_else(|| {
1038            bashkit::Error::Io(std::io::Error::new(
1039                std::io::ErrorKind::NotFound,
1040                format!("Source not in workspace: {}", from.display()),
1041            ))
1042        })?;
1043
1044        // Read source file
1045        let content = self.read_file(from).await?;
1046
1047        // Write to destination
1048        self.write_file(to, &content).await?;
1049
1050        // Delete source
1051        self.store
1052            .delete_file(self.session_id, &from_session, false)
1053            .await
1054            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1055
1056        Ok(())
1057    }
1058
1059    async fn copy(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
1060        let content = self.read_file(from).await?;
1061        self.write_file(to, &content).await
1062    }
1063
1064    async fn symlink(&self, _target: &Path, _link: &Path) -> bashkit::Result<()> {
1065        // Session filesystem doesn't support symlinks
1066        Err(bashkit::Error::Io(std::io::Error::new(
1067            std::io::ErrorKind::Unsupported,
1068            "Symlinks not supported in session filesystem",
1069        )))
1070    }
1071
1072    async fn read_link(&self, path: &Path) -> bashkit::Result<PathBuf> {
1073        // Session filesystem doesn't support symlinks
1074        Err(bashkit::Error::Io(std::io::Error::new(
1075            std::io::ErrorKind::Unsupported,
1076            format!("Symlinks not supported: {}", path.display()),
1077        )))
1078    }
1079
1080    async fn chmod(&self, _path: &Path, _mode: u32) -> bashkit::Result<()> {
1081        // chmod is a no-op - session filesystem doesn't track permissions
1082        Ok(())
1083    }
1084
1085    fn as_search_capable(&self) -> Option<&dyn SearchCapable> {
1086        Some(self)
1087    }
1088}
1089
1090// ============================================================================
1091// SearchCapable / SearchProvider — indexed search via SessionFileSystem
1092// ============================================================================
1093
1094impl SearchCapable for SessionFileSystemAdapter {
1095    fn search_provider(&self, path: &Path) -> Option<Box<dyn SearchProvider>> {
1096        // Only provide indexed search for paths inside /workspace
1097        Self::to_session_path(path)?;
1098        Some(Box::new(SessionSearchProvider {
1099            session_id: self.session_id,
1100            store: self.store.clone(),
1101        }))
1102    }
1103}
1104
1105/// Bridges bashkit's synchronous `SearchProvider` to `SessionFileSystem::grep_files`.
1106///
1107/// Uses a scoped thread with a dedicated tokio runtime to call the async
1108/// store method from the sync trait, avoiding nested `block_on` calls.
1109struct SessionSearchProvider {
1110    session_id: SessionId,
1111    store: Arc<dyn SessionFileSystem>,
1112}
1113
1114impl SearchProvider for SessionSearchProvider {
1115    fn search(&self, query: &SearchQuery) -> bashkit::Result<SearchResults> {
1116        let session_id = self.session_id;
1117        let store = self.store.clone();
1118        let root = query.root.to_string_lossy().into_owned();
1119        let max_results = query.max_results;
1120
1121        // Honor case_insensitive flag via inline regex flag
1122        let pattern = if query.case_insensitive {
1123            format!("(?i){}", query.pattern)
1124        } else {
1125            query.pattern.clone()
1126        };
1127
1128        // Convert root path from bash VFS path to session store path.
1129        // search_provider already guards against paths outside /workspace,
1130        // so to_session_path should always succeed here.
1131        let session_root =
1132            SessionFileSystemAdapter::to_session_path(Path::new(&root)).ok_or_else(|| {
1133                bashkit::Error::Io(std::io::Error::new(
1134                    std::io::ErrorKind::NotFound,
1135                    format!("Path not in workspace: {}", root),
1136                ))
1137            })?;
1138        let path_pattern = if session_root == "/" {
1139            None
1140        } else {
1141            Some(session_root)
1142        };
1143
1144        // Bridge async grep_files to sync SearchProvider::search.
1145        // Run on a dedicated thread with its own runtime to avoid nesting
1146        // block_on calls within the caller's tokio runtime.
1147        let matches = std::thread::scope(|s| {
1148            s.spawn(|| {
1149                let rt = tokio::runtime::Builder::new_current_thread()
1150                    .enable_all()
1151                    .build()
1152                    .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1153                rt.block_on(async {
1154                    store
1155                        .grep_files(session_id, &pattern, path_pattern.as_deref())
1156                        .await
1157                })
1158                .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1159            })
1160            .join()
1161            .unwrap_or_else(|_| {
1162                Err(bashkit::Error::Io(std::io::Error::other(
1163                    "search thread panicked",
1164                )))
1165            })
1166        })?;
1167
1168        let truncated = max_results.is_some_and(|max| matches.len() > max);
1169        let matches: Vec<BashkitSearchMatch> = matches
1170            .into_iter()
1171            .take(max_results.unwrap_or(usize::MAX))
1172            .map(|m| {
1173                // Convert session store path back to bash VFS path
1174                let vfs_path = format!("{}{}", SessionFileSystemAdapter::WORKSPACE_PREFIX, m.path);
1175                BashkitSearchMatch {
1176                    path: PathBuf::from(vfs_path),
1177                    line_number: m.line_number,
1178                    line_content: m.line,
1179                }
1180            })
1181            .collect();
1182
1183        Ok(SearchResults { matches, truncated })
1184    }
1185
1186    fn capabilities(&self) -> SearchCapabilities {
1187        SearchCapabilities {
1188            regex: true,
1189            glob_filter: false,
1190            content_search: true,
1191            filename_search: false,
1192        }
1193    }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199    use crate::session_file::FileInfo;
1200    use crate::traits::SessionFileSystem;
1201    use crate::typed_id::SessionId;
1202    use crate::{FileStat, GrepMatch, Result};
1203    use std::collections::HashMap;
1204    use std::sync::Mutex;
1205
1206    // ========================================================================
1207    // MockFileStore for testing
1208    // ========================================================================
1209
1210    /// In-memory file store for testing
1211    struct MockFileStore {
1212        files: Mutex<HashMap<(SessionId, String), (String, String)>>, // (content, encoding)
1213        directories: Mutex<HashMap<(SessionId, String), bool>>,
1214    }
1215
1216    impl MockFileStore {
1217        fn new() -> Self {
1218            Self {
1219                files: Mutex::new(HashMap::new()),
1220                directories: Mutex::new(HashMap::new()),
1221            }
1222        }
1223
1224        fn normalize_path(path: &str) -> String {
1225            let mut normalized = path.trim().to_string();
1226            if !normalized.starts_with('/') {
1227                normalized = format!("/{}", normalized);
1228            }
1229            if normalized.len() > 1 && normalized.ends_with('/') {
1230                normalized.pop();
1231            }
1232            normalized
1233        }
1234    }
1235
1236    #[async_trait]
1237    impl SessionFileSystem for MockFileStore {
1238        async fn read_file(
1239            &self,
1240            session_id: SessionId,
1241            path: &str,
1242        ) -> Result<Option<SessionFile>> {
1243            let path = Self::normalize_path(path);
1244            let files = self.files.lock().unwrap();
1245            if let Some((content, encoding)) = files.get(&(session_id, path.clone())) {
1246                Ok(Some(SessionFile {
1247                    id: uuid::Uuid::new_v4(),
1248                    session_id: session_id.into(),
1249                    path: path.clone(),
1250                    name: path.split('/').next_back().unwrap_or("").to_string(),
1251                    is_directory: false,
1252                    is_readonly: false,
1253                    content: Some(content.clone()),
1254                    encoding: encoding.clone(),
1255                    size_bytes: content.len() as i64,
1256                    created_at: chrono::Utc::now(),
1257                    updated_at: chrono::Utc::now(),
1258                }))
1259            } else {
1260                Ok(None)
1261            }
1262        }
1263
1264        async fn write_file(
1265            &self,
1266            session_id: SessionId,
1267            path: &str,
1268            content: &str,
1269            encoding: &str,
1270        ) -> Result<SessionFile> {
1271            let path = Self::normalize_path(path);
1272            let mut files = self.files.lock().unwrap();
1273            files.insert(
1274                (session_id, path.clone()),
1275                (content.to_string(), encoding.to_string()),
1276            );
1277            Ok(SessionFile {
1278                id: uuid::Uuid::new_v4(),
1279                session_id: session_id.into(),
1280                path: path.clone(),
1281                name: path.split('/').next_back().unwrap_or("").to_string(),
1282                is_directory: false,
1283                is_readonly: false,
1284                content: Some(content.to_string()),
1285                encoding: encoding.to_string(),
1286                size_bytes: content.len() as i64,
1287                created_at: chrono::Utc::now(),
1288                updated_at: chrono::Utc::now(),
1289            })
1290        }
1291
1292        async fn delete_file(
1293            &self,
1294            session_id: SessionId,
1295            path: &str,
1296            _recursive: bool,
1297        ) -> Result<bool> {
1298            let path = Self::normalize_path(path);
1299            let mut files = self.files.lock().unwrap();
1300            Ok(files.remove(&(session_id, path)).is_some())
1301        }
1302
1303        async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
1304            let path = Self::normalize_path(path);
1305            let files = self.files.lock().unwrap();
1306            let dirs = self.directories.lock().unwrap();
1307            let mut entries = Vec::new();
1308
1309            // Root directory always exists
1310            let is_root = path == "/";
1311
1312            for ((sid, file_path), (content, _)) in files.iter() {
1313                if *sid != session_id {
1314                    continue;
1315                }
1316
1317                // Check if file is directly under this path
1318                let parent = if let Some(idx) = file_path.rfind('/') {
1319                    if idx == 0 {
1320                        "/".to_string()
1321                    } else {
1322                        file_path[..idx].to_string()
1323                    }
1324                } else {
1325                    "/".to_string()
1326                };
1327
1328                if parent == path {
1329                    entries.push(FileInfo {
1330                        id: uuid::Uuid::new_v4(),
1331                        session_id: session_id.into(),
1332                        path: file_path.clone(),
1333                        name: file_path.split('/').next_back().unwrap_or("").to_string(),
1334                        is_directory: false,
1335                        is_readonly: false,
1336                        size_bytes: content.len() as i64,
1337                        created_at: chrono::Utc::now(),
1338                        updated_at: chrono::Utc::now(),
1339                    });
1340                }
1341            }
1342
1343            // Return error if directory doesn't exist (not root, not explicitly created,
1344            // and no files have it as parent)
1345            if !is_root && entries.is_empty() && !dirs.contains_key(&(session_id, path.clone())) {
1346                // Also check if any file has this as an ancestor (implicit directory)
1347                let has_children = files
1348                    .keys()
1349                    .any(|(sid, fp)| *sid == session_id && fp.starts_with(&format!("{}/", path)));
1350                if !has_children {
1351                    return Err(anyhow::anyhow!("Directory not found: {}", path).into());
1352                }
1353            }
1354
1355            Ok(entries)
1356        }
1357
1358        async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
1359            let path = Self::normalize_path(path);
1360            let files = self.files.lock().unwrap();
1361            if let Some((content, _)) = files.get(&(session_id, path.clone())) {
1362                Ok(Some(FileStat {
1363                    path: path.clone(),
1364                    name: path.split('/').next_back().unwrap_or("").to_string(),
1365                    is_directory: false,
1366                    is_readonly: false,
1367                    size_bytes: content.len() as i64,
1368                    created_at: chrono::Utc::now(),
1369                    updated_at: chrono::Utc::now(),
1370                }))
1371            } else {
1372                Ok(None)
1373            }
1374        }
1375
1376        async fn grep_files(
1377            &self,
1378            session_id: SessionId,
1379            pattern: &str,
1380            path_pattern: Option<&str>,
1381        ) -> Result<Vec<GrepMatch>> {
1382            let regex = regex::Regex::new(pattern)
1383                .map_err(|e| anyhow::anyhow!("invalid pattern: {}", e))?;
1384            let files = self.files.lock().unwrap();
1385            let mut matches = Vec::new();
1386            for ((sid, file_path), (content, _)) in files.iter() {
1387                if *sid != session_id {
1388                    continue;
1389                }
1390                if let Some(pp) = path_pattern
1391                    && !file_path.starts_with(pp)
1392                {
1393                    continue;
1394                }
1395                let decoded = SessionFile::decode_content(content, "utf-8")
1396                    .unwrap_or_else(|_| content.as_bytes().to_vec());
1397                let text = String::from_utf8_lossy(&decoded);
1398                for (i, line) in text.lines().enumerate() {
1399                    if regex.is_match(line) {
1400                        matches.push(GrepMatch {
1401                            path: file_path.clone(),
1402                            line_number: i + 1,
1403                            line: line.to_string(),
1404                        });
1405                    }
1406                }
1407            }
1408            matches.sort_by(|a, b| a.path.cmp(&b.path).then(a.line_number.cmp(&b.line_number)));
1409            Ok(matches)
1410        }
1411
1412        async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
1413            let path = Self::normalize_path(path);
1414            let mut dirs = self.directories.lock().unwrap();
1415            dirs.insert((session_id, path.clone()), true);
1416            Ok(FileInfo {
1417                id: uuid::Uuid::new_v4(),
1418                session_id: session_id.into(),
1419                path: path.clone(),
1420                name: path.split('/').next_back().unwrap_or("").to_string(),
1421                is_directory: true,
1422                is_readonly: false,
1423                size_bytes: 0,
1424                created_at: chrono::Utc::now(),
1425                updated_at: chrono::Utc::now(),
1426            })
1427        }
1428    }
1429
1430    // ========================================================================
1431    // Capability metadata tests
1432    // ========================================================================
1433
1434    #[test]
1435    fn test_capability_metadata() {
1436        let cap = VirtualBashCapability;
1437        assert_eq!(cap.id(), "virtual_bash");
1438        assert_eq!(cap.name(), "Virtual Bash");
1439        assert_eq!(cap.status(), CapabilityStatus::Available);
1440        assert_eq!(cap.risk_level(), RiskLevel::High);
1441        assert_eq!(cap.icon(), Some("terminal"));
1442        assert_eq!(cap.category(), Some("Execution"));
1443        let description = cap.description();
1444        assert!(
1445            description.contains("`<command> --help`"),
1446            "description should advertise built-in help, got: {}",
1447            description
1448        );
1449        assert!(
1450            description.contains("`<command> --version`"),
1451            "description should advertise built-in version support, got: {}",
1452            description
1453        );
1454    }
1455
1456    #[test]
1457    fn test_capability_has_tools() {
1458        let cap = VirtualBashCapability;
1459        let tools = cap.tools();
1460
1461        assert_eq!(tools.len(), 1);
1462        assert_eq!(tools[0].name(), "bash");
1463    }
1464
1465    #[test]
1466    fn test_capability_has_system_prompt() {
1467        let cap = VirtualBashCapability;
1468        let prompt = cap.system_prompt_addition().unwrap();
1469        // System prompt is now provided by bashkit library
1470        assert!(!prompt.is_empty(), "System prompt should not be empty");
1471        // Should contain the configured username/hostname
1472        assert!(
1473            prompt.contains("everruns"),
1474            "System prompt should contain configured identity"
1475        );
1476    }
1477
1478    #[test]
1479    fn test_capability_has_dependencies() {
1480        let cap = VirtualBashCapability;
1481        let deps = cap.dependencies();
1482        assert_eq!(deps.len(), 1);
1483        assert_eq!(deps[0], "session_file_system");
1484    }
1485
1486    #[test]
1487    fn test_tool_requires_context() {
1488        assert!(BashTool.requires_context());
1489    }
1490
1491    // ========================================================================
1492    // Path translation tests
1493    // ========================================================================
1494
1495    #[test]
1496    fn test_to_session_path_workspace_root() {
1497        let result = SessionFileSystemAdapter::to_session_path(Path::new("/workspace"));
1498        assert_eq!(result, Some("/".to_string()));
1499    }
1500
1501    #[test]
1502    fn test_to_session_path_workspace_file() {
1503        let result = SessionFileSystemAdapter::to_session_path(Path::new("/workspace/file.txt"));
1504        assert_eq!(result, Some("/file.txt".to_string()));
1505    }
1506
1507    #[test]
1508    fn test_to_session_path_workspace_nested() {
1509        let result =
1510            SessionFileSystemAdapter::to_session_path(Path::new("/workspace/dir/subdir/file.txt"));
1511        assert_eq!(result, Some("/dir/subdir/file.txt".to_string()));
1512    }
1513
1514    #[test]
1515    fn test_to_session_path_outside_workspace() {
1516        let result = SessionFileSystemAdapter::to_session_path(Path::new("/tmp/file.txt"));
1517        assert_eq!(result, None);
1518    }
1519
1520    #[test]
1521    fn test_to_session_path_home_outside_workspace() {
1522        let result = SessionFileSystemAdapter::to_session_path(Path::new("/home/agent/file.txt"));
1523        assert_eq!(result, None);
1524    }
1525
1526    #[test]
1527    fn test_to_session_path_workspacefoo_invalid() {
1528        // /workspacefoo is NOT under /workspace
1529        let result = SessionFileSystemAdapter::to_session_path(Path::new("/workspacefoo"));
1530        assert_eq!(result, None);
1531    }
1532
1533    #[test]
1534    fn test_to_session_path_relative_path() {
1535        // Relative path gets normalized to absolute
1536        let result = SessionFileSystemAdapter::to_session_path(Path::new("workspace/file.txt"));
1537        // /workspace/file.txt would be valid, but "workspace/file.txt" -> "/workspace/file.txt"
1538        // Wait, that's not right. Let me check the logic again.
1539        // normalize adds "/" prefix: "workspace/file.txt" -> "/workspace/file.txt"
1540        // Then it checks if it starts with "/workspace" - yes it does
1541        // So this should return Some("/file.txt")
1542        assert_eq!(result, Some("/file.txt".to_string()));
1543    }
1544
1545    // ========================================================================
1546    // Tool error handling tests
1547    // ========================================================================
1548
1549    #[tokio::test]
1550    async fn test_bash_without_context() {
1551        let tool = BashTool;
1552        let result = tool.execute(json!({"commands": "echo hello"})).await;
1553
1554        if let ToolExecutionResult::ToolError(msg) = result {
1555            assert!(msg.contains("requires context"));
1556        } else {
1557            panic!("Expected tool error");
1558        }
1559    }
1560
1561    #[tokio::test]
1562    async fn test_bash_missing_command() {
1563        let tool = BashTool;
1564        let context = ToolContext::new(SessionId::new());
1565
1566        let result = tool.execute_with_context(json!({}), &context).await;
1567
1568        if let ToolExecutionResult::ToolError(msg) = result {
1569            assert!(msg.contains("Missing required parameter"));
1570        } else {
1571            panic!("Expected tool error for missing command");
1572        }
1573    }
1574
1575    #[tokio::test]
1576    async fn test_bash_no_file_store() {
1577        let tool = BashTool;
1578        let context = ToolContext::new(SessionId::new());
1579
1580        let result = tool
1581            .execute_with_context(json!({"commands": "echo hello"}), &context)
1582            .await;
1583
1584        if let ToolExecutionResult::ToolError(msg) = result {
1585            assert!(msg.contains("not available"));
1586        } else {
1587            panic!("Expected tool error for missing file store");
1588        }
1589    }
1590
1591    // ========================================================================
1592    // Bash execution tests with MockFileStore
1593    // ========================================================================
1594
1595    fn create_context_with_mock_store() -> (ToolContext, SessionId) {
1596        let session_id = SessionId::new();
1597        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
1598        let mut context = ToolContext::new(session_id);
1599        context.file_store = Some(store);
1600        (context, session_id)
1601    }
1602
1603    #[tokio::test]
1604    async fn test_bash_echo_command() {
1605        let (context, _) = create_context_with_mock_store();
1606        let tool = BashTool;
1607
1608        let result = tool
1609            .execute_with_context(json!({"commands": "echo hello world"}), &context)
1610            .await;
1611
1612        if let ToolExecutionResult::Success(output) = result {
1613            assert_eq!(output["stdout"], "hello world\n");
1614            assert_eq!(output["exit_code"], 0);
1615            assert_eq!(output["success"], true);
1616        } else {
1617            panic!("Expected success result, got: {:?}", result);
1618        }
1619    }
1620
1621    #[tokio::test]
1622    async fn test_bash_pwd_default_workspace() {
1623        let (context, _) = create_context_with_mock_store();
1624        let tool = BashTool;
1625
1626        let result = tool
1627            .execute_with_context(json!({"commands": "pwd"}), &context)
1628            .await;
1629
1630        if let ToolExecutionResult::Success(output) = result {
1631            assert_eq!(output["stdout"], "/workspace\n");
1632            assert_eq!(output["exit_code"], 0);
1633        } else {
1634            panic!("Expected success result, got: {:?}", result);
1635        }
1636    }
1637
1638    #[tokio::test]
1639    async fn test_bash_env_variables() {
1640        let (context, _) = create_context_with_mock_store();
1641        let tool = BashTool;
1642
1643        // Test HOME
1644        let result = tool
1645            .execute_with_context(json!({"commands": "echo $HOME"}), &context)
1646            .await;
1647        if let ToolExecutionResult::Success(output) = result {
1648            assert_eq!(output["stdout"], "/home/agent\n");
1649        } else {
1650            panic!("Expected success");
1651        }
1652
1653        // Test WORKSPACE
1654        let result = tool
1655            .execute_with_context(json!({"commands": "echo $WORKSPACE"}), &context)
1656            .await;
1657        if let ToolExecutionResult::Success(output) = result {
1658            assert_eq!(output["stdout"], "/workspace\n");
1659        } else {
1660            panic!("Expected success");
1661        }
1662
1663        // Test USER (set by bashkit from username)
1664        let result = tool
1665            .execute_with_context(json!({"commands": "echo $USER"}), &context)
1666            .await;
1667        if let ToolExecutionResult::Success(output) = result {
1668            assert_eq!(output["stdout"], "everruns\n");
1669        } else {
1670            panic!("Expected success");
1671        }
1672    }
1673
1674    #[tokio::test]
1675    async fn test_bash_lang_env_default() {
1676        let (context, _) = create_context_with_mock_store();
1677        let tool = BashTool;
1678
1679        // Default locale (None) should set LANG to en-US
1680        let result = tool
1681            .execute_with_context(json!({"commands": "echo $LANG"}), &context)
1682            .await;
1683        if let ToolExecutionResult::Success(output) = result {
1684            assert_eq!(output["stdout"], "en-US\n");
1685        } else {
1686            panic!("Expected success");
1687        }
1688    }
1689
1690    #[tokio::test]
1691    async fn test_bash_lang_env_from_context_locale() {
1692        let (mut context, _) = create_context_with_mock_store();
1693        context.locale = Some("uk-UA".to_string());
1694        let tool = BashTool;
1695
1696        let result = tool
1697            .execute_with_context(json!({"commands": "echo $LANG"}), &context)
1698            .await;
1699        if let ToolExecutionResult::Success(output) = result {
1700            assert_eq!(output["stdout"], "uk-UA\n");
1701        } else {
1702            panic!("Expected success");
1703        }
1704    }
1705
1706    #[tokio::test]
1707    async fn test_bash_write_and_read_file() {
1708        let (context, _) = create_context_with_mock_store();
1709        let tool = BashTool;
1710
1711        // Write a file
1712        let result = tool
1713            .execute_with_context(
1714                json!({"commands": "echo 'test content' > /workspace/test.txt"}),
1715                &context,
1716            )
1717            .await;
1718        assert!(matches!(result, ToolExecutionResult::Success(_)));
1719
1720        // Read it back
1721        let result = tool
1722            .execute_with_context(json!({"commands": "cat /workspace/test.txt"}), &context)
1723            .await;
1724        if let ToolExecutionResult::Success(output) = result {
1725            assert_eq!(output["stdout"], "test content\n");
1726        } else {
1727            panic!("Expected success result");
1728        }
1729    }
1730
1731    #[tokio::test]
1732    async fn test_bash_pipe_command() {
1733        let (context, _) = create_context_with_mock_store();
1734        let tool = BashTool;
1735
1736        let result = tool
1737            .execute_with_context(json!({"commands": "echo hello | cat"}), &context)
1738            .await;
1739
1740        if let ToolExecutionResult::Success(output) = result {
1741            assert_eq!(output["stdout"], "hello\n");
1742            assert_eq!(output["exit_code"], 0);
1743        } else {
1744            panic!("Expected success result");
1745        }
1746    }
1747
1748    #[tokio::test]
1749    async fn test_bash_arithmetic() {
1750        let (context, _) = create_context_with_mock_store();
1751        let tool = BashTool;
1752
1753        let result = tool
1754            .execute_with_context(json!({"commands": "echo $((2 + 3 * 4))"}), &context)
1755            .await;
1756
1757        if let ToolExecutionResult::Success(output) = result {
1758            assert_eq!(output["stdout"], "14\n");
1759        } else {
1760            panic!("Expected success result");
1761        }
1762    }
1763
1764    #[tokio::test]
1765    async fn test_bash_command_substitution() {
1766        let (context, _) = create_context_with_mock_store();
1767        let tool = BashTool;
1768
1769        let result = tool
1770            .execute_with_context(json!({"commands": "echo $(echo nested)"}), &context)
1771            .await;
1772
1773        if let ToolExecutionResult::Success(output) = result {
1774            assert_eq!(output["stdout"], "nested\n");
1775        } else {
1776            panic!("Expected success result");
1777        }
1778    }
1779
1780    // ========================================================================
1781    // Negative tests - paths outside workspace
1782    // ========================================================================
1783
1784    #[tokio::test]
1785    async fn test_bash_write_outside_workspace_fails() {
1786        let (context, _) = create_context_with_mock_store();
1787        let tool = BashTool;
1788
1789        // Try to write to /tmp (outside workspace)
1790        let result = tool
1791            .execute_with_context(json!({"commands": "echo 'hack' > /tmp/evil.txt"}), &context)
1792            .await;
1793
1794        // This should fail because /tmp is outside /workspace
1795        if let ToolExecutionResult::ToolError(msg) = result {
1796            assert!(
1797                msg.contains("outside workspace") || msg.contains("Permission"),
1798                "Expected workspace error, got: {}",
1799                msg
1800            );
1801        } else if let ToolExecutionResult::Success(output) = result {
1802            // If bashkit doesn't error, the write should silently fail
1803            // Let's verify by trying to read it
1804            let read_result = tool
1805                .execute_with_context(json!({"commands": "cat /tmp/evil.txt"}), &context)
1806                .await;
1807            // Should not find the file
1808            assert!(
1809                matches!(read_result, ToolExecutionResult::ToolError(_))
1810                    || matches!(&read_result, ToolExecutionResult::Success(o) if o["exit_code"] != 0),
1811                "File should not exist outside workspace"
1812            );
1813            // Also check that /tmp read fails
1814            assert!(
1815                output["stderr"]
1816                    .as_str()
1817                    .unwrap_or("")
1818                    .contains("Permission")
1819                    || output["stderr"]
1820                        .as_str()
1821                        .unwrap_or("")
1822                        .contains("workspace")
1823                    || output["exit_code"] != 0,
1824                "Write outside workspace should fail or be blocked"
1825            );
1826        } else {
1827            // Either behavior is acceptable - error or silent failure
1828        }
1829    }
1830
1831    #[tokio::test]
1832    async fn test_bash_read_outside_workspace_fails() {
1833        let (context, _) = create_context_with_mock_store();
1834        let tool = BashTool;
1835
1836        // Try to read from /etc (outside workspace)
1837        let result = tool
1838            .execute_with_context(json!({"commands": "cat /etc/passwd"}), &context)
1839            .await;
1840
1841        // Should fail - either as tool error or with non-zero exit code
1842        match result {
1843            ToolExecutionResult::ToolError(msg) => {
1844                assert!(
1845                    msg.contains("workspace") || msg.contains("not found"),
1846                    "Expected workspace error, got: {}",
1847                    msg
1848                );
1849            }
1850            ToolExecutionResult::Success(output) => {
1851                // Exit code should be non-zero since file doesn't exist
1852                assert_ne!(
1853                    output["exit_code"], 0,
1854                    "Reading /etc/passwd should fail with non-zero exit"
1855                );
1856            }
1857            _ => panic!("Unexpected result type"),
1858        }
1859    }
1860
1861    #[tokio::test]
1862    async fn test_bash_mkdir_outside_workspace_fails() {
1863        let (context, _) = create_context_with_mock_store();
1864        let tool = BashTool;
1865
1866        // Try to create directory in /tmp
1867        let result = tool
1868            .execute_with_context(json!({"commands": "mkdir /tmp/evil_dir"}), &context)
1869            .await;
1870
1871        // Should fail
1872        match result {
1873            ToolExecutionResult::ToolError(msg) => {
1874                assert!(
1875                    msg.contains("workspace") || msg.contains("Permission"),
1876                    "Got: {}",
1877                    msg
1878                );
1879            }
1880            ToolExecutionResult::Success(output) => {
1881                // If it "succeeds", the directory shouldn't actually exist
1882                // Or exit code should be non-zero
1883                assert!(
1884                    output["exit_code"] != 0
1885                        || output["stderr"]
1886                            .as_str()
1887                            .unwrap_or("")
1888                            .contains("Permission"),
1889                    "mkdir outside workspace should fail"
1890                );
1891            }
1892            _ => {}
1893        }
1894    }
1895
1896    // ========================================================================
1897    // Working directory tests
1898    // ========================================================================
1899
1900    #[tokio::test]
1901    async fn test_bash_custom_working_dir() {
1902        let (context, _) = create_context_with_mock_store();
1903        let tool = BashTool;
1904
1905        // First create the directory
1906        let result = tool
1907            .execute_with_context(json!({"commands": "mkdir -p /workspace/mydir"}), &context)
1908            .await;
1909        assert!(matches!(result, ToolExecutionResult::Success(_)));
1910
1911        // Run pwd with custom working directory
1912        let result = tool
1913            .execute_with_context(
1914                json!({
1915                    "commands": "pwd",
1916                    "working_dir": "/workspace/mydir"
1917                }),
1918                &context,
1919            )
1920            .await;
1921
1922        if let ToolExecutionResult::Success(output) = result {
1923            assert_eq!(output["stdout"], "/workspace/mydir\n");
1924        } else {
1925            panic!("Expected success result");
1926        }
1927    }
1928
1929    // ========================================================================
1930    // Exit code tests
1931    // ========================================================================
1932
1933    #[tokio::test]
1934    async fn test_bash_false_command_exit_code() {
1935        let (context, _) = create_context_with_mock_store();
1936        let tool = BashTool;
1937
1938        let result = tool
1939            .execute_with_context(json!({"commands": "false"}), &context)
1940            .await;
1941
1942        if let ToolExecutionResult::Success(output) = result {
1943            assert_eq!(output["exit_code"], 1);
1944            assert_eq!(output["success"], false);
1945        } else {
1946            panic!("Expected success result with non-zero exit code");
1947        }
1948    }
1949
1950    #[tokio::test]
1951    async fn test_bash_true_command_exit_code() {
1952        let (context, _) = create_context_with_mock_store();
1953        let tool = BashTool;
1954
1955        let result = tool
1956            .execute_with_context(json!({"commands": "true"}), &context)
1957            .await;
1958
1959        if let ToolExecutionResult::Success(output) = result {
1960            assert_eq!(output["exit_code"], 0);
1961            assert_eq!(output["success"], true);
1962        } else {
1963            panic!("Expected success result");
1964        }
1965    }
1966
1967    // ========================================================================
1968    // FileSystem adapter direct tests
1969    // ========================================================================
1970
1971    #[tokio::test]
1972    async fn test_adapter_read_write_workspace_file() {
1973        let session_id = SessionId::new();
1974        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
1975        let adapter = SessionFileSystemAdapter::new(session_id, store);
1976
1977        // Write a file
1978        adapter
1979            .write_file(Path::new("/workspace/test.txt"), b"hello")
1980            .await
1981            .unwrap();
1982
1983        // Read it back
1984        let content = adapter
1985            .read_file(Path::new("/workspace/test.txt"))
1986            .await
1987            .unwrap();
1988        assert_eq!(content, b"hello");
1989    }
1990
1991    #[tokio::test]
1992    async fn test_adapter_read_outside_workspace_fails() {
1993        let session_id = SessionId::new();
1994        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
1995        let adapter = SessionFileSystemAdapter::new(session_id, store);
1996
1997        let result = adapter.read_file(Path::new("/tmp/file.txt")).await;
1998        assert!(result.is_err());
1999
2000        let err = result.unwrap_err();
2001        assert!(err.to_string().contains("workspace"));
2002    }
2003
2004    #[tokio::test]
2005    async fn test_adapter_write_outside_workspace_fails() {
2006        let session_id = SessionId::new();
2007        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2008        let adapter = SessionFileSystemAdapter::new(session_id, store);
2009
2010        let result = adapter
2011            .write_file(Path::new("/tmp/file.txt"), b"data")
2012            .await;
2013        assert!(result.is_err());
2014
2015        let err = result.unwrap_err();
2016        assert!(err.to_string().contains("workspace"));
2017    }
2018
2019    #[tokio::test]
2020    async fn test_adapter_stat_workspace_root() {
2021        let session_id = SessionId::new();
2022        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2023        let adapter = SessionFileSystemAdapter::new(session_id, store);
2024
2025        let stat = adapter.stat(Path::new("/workspace")).await.unwrap();
2026        assert!(stat.file_type.is_dir());
2027    }
2028
2029    #[tokio::test]
2030    async fn test_adapter_stat_directory_returns_dir_type() {
2031        let session_id = SessionId::new();
2032        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2033        let adapter = SessionFileSystemAdapter::new(session_id, store);
2034
2035        // Create a directory
2036        adapter
2037            .mkdir(Path::new("/workspace/mydir"), false)
2038            .await
2039            .unwrap();
2040
2041        // stat should report it as a directory, not a file
2042        let stat = adapter.stat(Path::new("/workspace/mydir")).await.unwrap();
2043        assert!(
2044            stat.file_type.is_dir(),
2045            "Expected directory but got file type for /workspace/mydir"
2046        );
2047    }
2048
2049    #[tokio::test]
2050    async fn test_adapter_stat_file_returns_file_type() {
2051        let session_id = SessionId::new();
2052        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2053        let adapter = SessionFileSystemAdapter::new(session_id, store);
2054
2055        // Write a file
2056        adapter
2057            .write_file(Path::new("/workspace/test.txt"), b"hello")
2058            .await
2059            .unwrap();
2060
2061        // stat should report it as a file
2062        let stat = adapter
2063            .stat(Path::new("/workspace/test.txt"))
2064            .await
2065            .unwrap();
2066        assert!(
2067            stat.file_type.is_file(),
2068            "Expected file but got directory type for /workspace/test.txt"
2069        );
2070        assert_eq!(stat.size, 5);
2071    }
2072
2073    #[tokio::test]
2074    async fn test_adapter_exists_workspace() {
2075        let session_id = SessionId::new();
2076        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2077        let adapter = SessionFileSystemAdapter::new(session_id, store);
2078
2079        // /workspace always exists
2080        assert!(adapter.exists(Path::new("/workspace")).await.unwrap());
2081
2082        // /tmp does not exist (outside workspace)
2083        assert!(!adapter.exists(Path::new("/tmp")).await.unwrap());
2084    }
2085
2086    #[tokio::test]
2087    async fn test_adapter_mkdir_and_list() {
2088        let session_id = SessionId::new();
2089        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2090        let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
2091
2092        // Create a directory
2093        adapter
2094            .mkdir(Path::new("/workspace/mydir"), false)
2095            .await
2096            .unwrap();
2097
2098        // Write a file in it
2099        adapter
2100            .write_file(Path::new("/workspace/mydir/file.txt"), b"content")
2101            .await
2102            .unwrap();
2103
2104        // List should include the file
2105        let entries = adapter
2106            .read_dir(Path::new("/workspace/mydir"))
2107            .await
2108            .unwrap();
2109        assert_eq!(entries.len(), 1);
2110        assert_eq!(entries[0].name, "file.txt");
2111    }
2112
2113    #[tokio::test]
2114    async fn test_adapter_rename_file() {
2115        let session_id = SessionId::new();
2116        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2117        let adapter = SessionFileSystemAdapter::new(session_id, store);
2118
2119        // Write original file
2120        adapter
2121            .write_file(Path::new("/workspace/old.txt"), b"data")
2122            .await
2123            .unwrap();
2124
2125        // Rename it
2126        adapter
2127            .rename(
2128                Path::new("/workspace/old.txt"),
2129                Path::new("/workspace/new.txt"),
2130            )
2131            .await
2132            .unwrap();
2133
2134        // Old file should not exist
2135        let old_result = adapter.read_file(Path::new("/workspace/old.txt")).await;
2136        assert!(old_result.is_err());
2137
2138        // New file should have the content
2139        let new_content = adapter
2140            .read_file(Path::new("/workspace/new.txt"))
2141            .await
2142            .unwrap();
2143        assert_eq!(new_content, b"data");
2144    }
2145
2146    #[tokio::test]
2147    async fn test_adapter_copy_file() {
2148        let session_id = SessionId::new();
2149        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2150        let adapter = SessionFileSystemAdapter::new(session_id, store);
2151
2152        // Write original file
2153        adapter
2154            .write_file(Path::new("/workspace/source.txt"), b"copy me")
2155            .await
2156            .unwrap();
2157
2158        // Copy it
2159        adapter
2160            .copy(
2161                Path::new("/workspace/source.txt"),
2162                Path::new("/workspace/dest.txt"),
2163            )
2164            .await
2165            .unwrap();
2166
2167        // Both files should exist with same content
2168        let source = adapter
2169            .read_file(Path::new("/workspace/source.txt"))
2170            .await
2171            .unwrap();
2172        let dest = adapter
2173            .read_file(Path::new("/workspace/dest.txt"))
2174            .await
2175            .unwrap();
2176        assert_eq!(source, dest);
2177        assert_eq!(source, b"copy me");
2178    }
2179
2180    #[tokio::test]
2181    async fn test_adapter_append_file() {
2182        let session_id = SessionId::new();
2183        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2184        let adapter = SessionFileSystemAdapter::new(session_id, store);
2185
2186        // Write initial content
2187        adapter
2188            .write_file(Path::new("/workspace/log.txt"), b"line1\n")
2189            .await
2190            .unwrap();
2191
2192        // Append more
2193        adapter
2194            .append_file(Path::new("/workspace/log.txt"), b"line2\n")
2195            .await
2196            .unwrap();
2197
2198        // Read combined content
2199        let content = adapter
2200            .read_file(Path::new("/workspace/log.txt"))
2201            .await
2202            .unwrap();
2203        assert_eq!(content, b"line1\nline2\n");
2204    }
2205
2206    #[tokio::test]
2207    async fn test_adapter_symlink_not_supported() {
2208        let session_id = SessionId::new();
2209        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2210        let adapter = SessionFileSystemAdapter::new(session_id, store);
2211
2212        let result = adapter
2213            .symlink(Path::new("/workspace/target"), Path::new("/workspace/link"))
2214            .await;
2215        assert!(result.is_err());
2216        assert!(result.unwrap_err().to_string().contains("not supported"));
2217    }
2218
2219    #[tokio::test]
2220    async fn test_adapter_chmod_is_noop() {
2221        let session_id = SessionId::new();
2222        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2223        let adapter = SessionFileSystemAdapter::new(session_id, store);
2224
2225        // chmod should succeed as a no-op
2226        let result = adapter.chmod(Path::new("/workspace/file.txt"), 0o755).await;
2227        assert!(result.is_ok());
2228    }
2229
2230    // ========================================================================
2231    // Security limit tests (bashkit 0.1.0)
2232    // ========================================================================
2233
2234    #[tokio::test]
2235    async fn test_bash_max_input_bytes_limit() {
2236        let (context, _) = create_context_with_mock_store();
2237        let tool = BashTool;
2238
2239        // Create a script larger than 1MB limit
2240        let large_script = "echo ".to_string() + &"x".repeat(1_100_000);
2241
2242        let result = tool
2243            .execute_with_context(json!({"commands": large_script}), &context)
2244            .await;
2245
2246        // Should fail due to input size limit
2247        match result {
2248            ToolExecutionResult::ToolError(msg) => {
2249                assert!(
2250                    msg.contains("too large") || msg.contains("input") || msg.contains("limit"),
2251                    "Expected input size error, got: {}",
2252                    msg
2253                );
2254            }
2255            ToolExecutionResult::Success(output) => {
2256                panic!(
2257                    "Expected error for oversized script, got success: {:?}",
2258                    output
2259                );
2260            }
2261            _ => panic!("Unexpected result type"),
2262        }
2263    }
2264
2265    #[tokio::test]
2266    async fn test_bash_loop_within_limit() {
2267        let (context, _) = create_context_with_mock_store();
2268        let tool = BashTool;
2269
2270        // Execute a loop within the 10000 iteration limit
2271        let command = "i=0; while [ $i -lt 100 ]; do i=$((i + 1)); done; echo $i";
2272
2273        let result = tool
2274            .execute_with_context(json!({"commands": command}), &context)
2275            .await;
2276
2277        // Should succeed within limits
2278        if let ToolExecutionResult::Success(output) = result {
2279            assert_eq!(output["exit_code"], 0);
2280            assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "100");
2281        } else {
2282            panic!("Expected success for loop within limit: {:?}", result);
2283        }
2284    }
2285
2286    #[tokio::test]
2287    async fn test_bash_function_calls() {
2288        let (context, _) = create_context_with_mock_store();
2289        let tool = BashTool;
2290
2291        // Test basic function definition and calls (non-recursive to avoid stack issues)
2292        let command = r#"
2293            greet() {
2294                echo "Hello, $1!"
2295            }
2296            greet world
2297        "#;
2298
2299        let result = tool
2300            .execute_with_context(json!({"commands": command}), &context)
2301            .await;
2302
2303        // Should succeed
2304        if let ToolExecutionResult::Success(output) = result {
2305            assert_eq!(output["exit_code"], 0);
2306            assert!(
2307                output["stdout"]
2308                    .as_str()
2309                    .unwrap_or("")
2310                    .contains("Hello, world!")
2311            );
2312        } else {
2313            panic!("Expected success for function call: {:?}", result);
2314        }
2315    }
2316
2317    #[tokio::test]
2318    async fn test_bash_arithmetic_expressions() {
2319        let (context, _) = create_context_with_mock_store();
2320        let tool = BashTool;
2321
2322        // Test various arithmetic expressions (shallow nesting to avoid stack issues)
2323        let command = "echo $((1 + 2 * 3))";
2324
2325        let result = tool
2326            .execute_with_context(json!({"commands": command}), &context)
2327            .await;
2328
2329        // Should succeed
2330        if let ToolExecutionResult::Success(output) = result {
2331            assert_eq!(output["exit_code"], 0);
2332            assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "7");
2333        } else {
2334            panic!("Expected success for arithmetic expression: {:?}", result);
2335        }
2336    }
2337
2338    #[tokio::test]
2339    async fn test_bash_commands_within_limit() {
2340        let (context, _) = create_context_with_mock_store();
2341        let tool = BashTool;
2342
2343        // Execute multiple commands within the 1000 command limit
2344        let command = "for i in $(seq 1 100); do true; done; echo done";
2345
2346        let result = tool
2347            .execute_with_context(json!({"commands": command}), &context)
2348            .await;
2349
2350        // Should succeed within limits
2351        if let ToolExecutionResult::Success(output) = result {
2352            assert_eq!(output["exit_code"], 0);
2353            assert!(output["stdout"].as_str().unwrap_or("").contains("done"));
2354        } else {
2355            panic!("Expected success for commands within limit: {:?}", result);
2356        }
2357    }
2358
2359    // ========================================================================
2360    // Script file execution tests
2361    // ========================================================================
2362
2363    #[tokio::test]
2364    async fn test_bash_execute_script_by_absolute_path() {
2365        let (context, _) = create_context_with_mock_store();
2366        let tool = BashTool;
2367
2368        // Create a script file
2369        let result = tool
2370            .execute_with_context(
2371                json!({"commands": "cat > /workspace/test.sh << 'EOF'\n#!/bin/bash\necho hello\nEOF"}),
2372                &context,
2373            )
2374            .await;
2375        assert!(
2376            matches!(result, ToolExecutionResult::Success(_)),
2377            "Failed to create script: {:?}",
2378            result
2379        );
2380
2381        // Execute by absolute path
2382        let result = tool
2383            .execute_with_context(json!({"commands": "/workspace/test.sh"}), &context)
2384            .await;
2385
2386        if let ToolExecutionResult::Success(output) = result {
2387            assert_eq!(output["exit_code"], 0);
2388            assert_eq!(output["stdout"], "hello\n");
2389        } else {
2390            panic!("Expected success, got: {:?}", result);
2391        }
2392    }
2393
2394    #[tokio::test]
2395    async fn test_bash_execute_script_with_args() {
2396        let (context, _) = create_context_with_mock_store();
2397        let tool = BashTool;
2398
2399        // Create a script that uses arguments
2400        let result = tool
2401            .execute_with_context(
2402                json!({"commands": "cat > /workspace/greet.sh << 'EOF'\n#!/bin/bash\necho \"Hello, $1! You are $2.\"\nEOF"}),
2403                &context,
2404            )
2405            .await;
2406        assert!(matches!(result, ToolExecutionResult::Success(_)));
2407
2408        // Execute with arguments
2409        let result = tool
2410            .execute_with_context(
2411                json!({"commands": "/workspace/greet.sh world awesome"}),
2412                &context,
2413            )
2414            .await;
2415
2416        if let ToolExecutionResult::Success(output) = result {
2417            assert_eq!(output["exit_code"], 0);
2418            assert_eq!(output["stdout"], "Hello, world! You are awesome.\n");
2419        } else {
2420            panic!("Expected success, got: {:?}", result);
2421        }
2422    }
2423
2424    #[tokio::test]
2425    async fn test_bash_execute_script_without_shebang() {
2426        let (context, _) = create_context_with_mock_store();
2427        let tool = BashTool;
2428
2429        // Create a script without shebang
2430        let result = tool
2431            .execute_with_context(
2432                json!({"commands": "cat > /workspace/simple.sh << 'EOF'\necho simple\nEOF"}),
2433                &context,
2434            )
2435            .await;
2436        assert!(matches!(result, ToolExecutionResult::Success(_)));
2437
2438        // Execute - should still work
2439        let result = tool
2440            .execute_with_context(json!({"commands": "/workspace/simple.sh"}), &context)
2441            .await;
2442
2443        if let ToolExecutionResult::Success(output) = result {
2444            assert_eq!(output["exit_code"], 0);
2445            assert_eq!(output["stdout"], "simple\n");
2446        } else {
2447            panic!("Expected success, got: {:?}", result);
2448        }
2449    }
2450
2451    #[tokio::test]
2452    async fn test_bash_execute_nonexistent_script() {
2453        let (context, _) = create_context_with_mock_store();
2454        let tool = BashTool;
2455
2456        // Try to execute a script that doesn't exist
2457        let result = tool
2458            .execute_with_context(json!({"commands": "/workspace/nonexistent.sh"}), &context)
2459            .await;
2460
2461        if let ToolExecutionResult::Success(output) = result {
2462            assert_ne!(output["exit_code"], 0, "Should fail with non-zero exit");
2463            let stderr = output["stderr"].as_str().unwrap_or("");
2464            assert!(
2465                stderr.contains("No such file") || stderr.contains("not found"),
2466                "Expected file not found error, got stderr: {}",
2467                stderr
2468            );
2469        } else {
2470            panic!(
2471                "Expected success result with error output, got: {:?}",
2472                result
2473            );
2474        }
2475    }
2476
2477    #[tokio::test]
2478    async fn test_bash_execute_script_in_nested_dir() {
2479        let (context, _) = create_context_with_mock_store();
2480        let tool = BashTool;
2481
2482        // Create nested directory structure and script
2483        let setup = tool
2484            .execute_with_context(
2485                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"}),
2486                &context,
2487            )
2488            .await;
2489        assert!(matches!(setup, ToolExecutionResult::Success(_)));
2490
2491        // Execute by absolute path (the exact scenario from the bug report)
2492        let result = tool
2493            .execute_with_context(
2494                json!({"commands": "/workspace/.agents/skills/nav/scripts/nav.sh dist"}),
2495                &context,
2496            )
2497            .await;
2498
2499        if let ToolExecutionResult::Success(output) = result {
2500            assert_eq!(output["exit_code"], 0);
2501            assert_eq!(output["stdout"], "navigating dist\n");
2502        } else {
2503            panic!("Expected success, got: {:?}", result);
2504        }
2505    }
2506
2507    #[tokio::test]
2508    async fn test_bash_file_mode_is_executable() {
2509        let (context, _) = create_context_with_mock_store();
2510        let tool = BashTool;
2511
2512        // Write a file and check that test -x reports it as executable
2513        let result = tool
2514            .execute_with_context(
2515                json!({"commands": "echo 'echo hi' > /workspace/check.sh && test -x /workspace/check.sh && echo 'executable' || echo 'not executable'"}),
2516                &context,
2517            )
2518            .await;
2519
2520        if let ToolExecutionResult::Success(output) = result {
2521            assert_eq!(output["exit_code"], 0);
2522            assert!(
2523                output["stdout"]
2524                    .as_str()
2525                    .unwrap_or("")
2526                    .contains("executable"),
2527                "File should be reported as executable, got: {}",
2528                output["stdout"]
2529            );
2530        } else {
2531            panic!("Expected success, got: {:?}", result);
2532        }
2533    }
2534
2535    #[tokio::test]
2536    async fn test_bash_execute_script_with_exit_code() {
2537        let (context, _) = create_context_with_mock_store();
2538        let tool = BashTool;
2539
2540        // Create a script that exits with a specific code
2541        let result = tool
2542            .execute_with_context(
2543                json!({"commands": "cat > /workspace/fail.sh << 'EOF'\n#!/bin/bash\necho failing\nexit 42\nEOF"}),
2544                &context,
2545            )
2546            .await;
2547        assert!(matches!(result, ToolExecutionResult::Success(_)));
2548
2549        // Execute and check exit code propagation
2550        let result = tool
2551            .execute_with_context(
2552                json!({"commands": "/workspace/fail.sh; echo \"code: $?\""}),
2553                &context,
2554            )
2555            .await;
2556
2557        if let ToolExecutionResult::Success(output) = result {
2558            let stdout = output["stdout"].as_str().unwrap_or("");
2559            assert!(stdout.contains("failing"), "Script should have run");
2560            assert!(
2561                stdout.contains("code: 42"),
2562                "Exit code should propagate, got: {}",
2563                stdout
2564            );
2565        } else {
2566            panic!("Expected success, got: {:?}", result);
2567        }
2568    }
2569
2570    // ========================================================================
2571    // Overwrite / existing-file tests
2572    // ========================================================================
2573
2574    #[tokio::test]
2575    async fn test_bash_overwrite_existing_file() {
2576        let (context, _) = create_context_with_mock_store();
2577        let tool = BashTool;
2578
2579        // Write a file
2580        let result = tool
2581            .execute_with_context(
2582                json!({"commands": "echo 'first' > /workspace/overwrite.txt"}),
2583                &context,
2584            )
2585            .await;
2586        assert!(matches!(result, ToolExecutionResult::Success(_)));
2587
2588        // Overwrite with new content
2589        let result = tool
2590            .execute_with_context(
2591                json!({"commands": "echo 'second' > /workspace/overwrite.txt"}),
2592                &context,
2593            )
2594            .await;
2595        if let ToolExecutionResult::Success(output) = &result {
2596            assert_eq!(output["exit_code"], 0, "Overwrite should succeed");
2597        } else {
2598            panic!("Expected success on overwrite, got: {:?}", result);
2599        }
2600
2601        // Read back — should have new content
2602        let result = tool
2603            .execute_with_context(
2604                json!({"commands": "cat /workspace/overwrite.txt"}),
2605                &context,
2606            )
2607            .await;
2608        if let ToolExecutionResult::Success(output) = result {
2609            assert_eq!(output["stdout"], "second\n");
2610        } else {
2611            panic!("Expected success on read, got: {:?}", result);
2612        }
2613    }
2614
2615    #[tokio::test]
2616    async fn test_bash_append_to_existing_file() {
2617        let (context, _) = create_context_with_mock_store();
2618        let tool = BashTool;
2619
2620        // Create file
2621        let result = tool
2622            .execute_with_context(
2623                json!({"commands": "echo 'line1' > /workspace/append.txt"}),
2624                &context,
2625            )
2626            .await;
2627        assert!(matches!(result, ToolExecutionResult::Success(_)));
2628
2629        // Append
2630        let result = tool
2631            .execute_with_context(
2632                json!({"commands": "echo 'line2' >> /workspace/append.txt"}),
2633                &context,
2634            )
2635            .await;
2636        if let ToolExecutionResult::Success(output) = &result {
2637            assert_eq!(output["exit_code"], 0, "Append should succeed");
2638        } else {
2639            panic!("Expected success on append, got: {:?}", result);
2640        }
2641
2642        // Verify combined content
2643        let result = tool
2644            .execute_with_context(json!({"commands": "cat /workspace/append.txt"}), &context)
2645            .await;
2646        if let ToolExecutionResult::Success(output) = result {
2647            assert_eq!(output["stdout"], "line1\nline2\n");
2648        } else {
2649            panic!("Expected success on read");
2650        }
2651    }
2652
2653    #[tokio::test]
2654    async fn test_adapter_overwrite_existing_file() {
2655        let session_id = SessionId::new();
2656        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2657        let adapter = SessionFileSystemAdapter::new(session_id, store);
2658
2659        // Write initial
2660        adapter
2661            .write_file(Path::new("/workspace/ow.txt"), b"original")
2662            .await
2663            .unwrap();
2664
2665        // Overwrite
2666        adapter
2667            .write_file(Path::new("/workspace/ow.txt"), b"updated")
2668            .await
2669            .unwrap();
2670
2671        // Verify new content
2672        let content = adapter
2673            .read_file(Path::new("/workspace/ow.txt"))
2674            .await
2675            .unwrap();
2676        assert_eq!(content, b"updated");
2677    }
2678
2679    #[tokio::test]
2680    async fn test_adapter_append_to_existing_file() {
2681        let session_id = SessionId::new();
2682        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2683        let adapter = SessionFileSystemAdapter::new(session_id, store);
2684
2685        // Write initial
2686        adapter
2687            .write_file(Path::new("/workspace/ap.txt"), b"AAA")
2688            .await
2689            .unwrap();
2690
2691        // Append
2692        adapter
2693            .append_file(Path::new("/workspace/ap.txt"), b"BBB")
2694            .await
2695            .unwrap();
2696
2697        // Verify combined
2698        let content = adapter
2699            .read_file(Path::new("/workspace/ap.txt"))
2700            .await
2701            .unwrap();
2702        assert_eq!(content, b"AAABBB");
2703    }
2704
2705    #[tokio::test]
2706    async fn test_bash_redirect_creates_parent_dirs() {
2707        let (context, _) = create_context_with_mock_store();
2708        let tool = BashTool;
2709
2710        // Write to a nested path — parent dirs should be auto-created
2711        let result = tool
2712            .execute_with_context(
2713                json!({"commands": "echo 'deep' > /workspace/a/b/c/deep.txt"}),
2714                &context,
2715            )
2716            .await;
2717        if let ToolExecutionResult::Success(output) = &result {
2718            assert_eq!(output["exit_code"], 0, "Nested write should succeed");
2719        } else {
2720            panic!("Expected success, got: {:?}", result);
2721        }
2722
2723        // Read back
2724        let result = tool
2725            .execute_with_context(
2726                json!({"commands": "cat /workspace/a/b/c/deep.txt"}),
2727                &context,
2728            )
2729            .await;
2730        if let ToolExecutionResult::Success(output) = result {
2731            assert_eq!(output["stdout"], "deep\n");
2732        } else {
2733            panic!("Expected success on read");
2734        }
2735    }
2736
2737    // ========================================================================
2738    // bashkit API smoke tests
2739    // ========================================================================
2740
2741    #[test]
2742    fn test_bashkit_tool_description_is_nonempty() {
2743        let desc = BASHKIT_TOOL.description();
2744        assert!(
2745            !desc.is_empty(),
2746            "bashkit tool description should not be empty"
2747        );
2748        // Should mention bash or command execution
2749        assert!(
2750            desc.to_lowercase().contains("bash") || desc.to_lowercase().contains("command"),
2751            "description should mention bash or command, got: {}",
2752            desc
2753        );
2754    }
2755
2756    #[test]
2757    fn test_bashkit_tool_system_prompt_is_nonempty() {
2758        let prompt = BASHKIT_TOOL.system_prompt();
2759        assert!(
2760            !prompt.is_empty(),
2761            "bashkit system prompt should not be empty"
2762        );
2763        assert!(
2764            prompt.contains("everruns"),
2765            "system prompt should contain configured identity 'everruns', got: {}",
2766            prompt
2767        );
2768    }
2769
2770    #[test]
2771    fn test_bashkit_static_description_matches_tool() {
2772        // Verify the LazyLock statics produce the same values as direct calls
2773        let direct_desc = BASHKIT_TOOL.description();
2774        let static_desc: &str = &TOOL_DESCRIPTION;
2775        assert_eq!(static_desc, direct_desc);
2776
2777        let direct_prompt = BASHKIT_TOOL.system_prompt();
2778        let static_prompt: &str = &TOOL_SYSTEM_PROMPT;
2779        // TOOL_SYSTEM_PROMPT = bashkit prompt + EXEC_OUTPUT_HINT (EVE-223)
2780        assert!(
2781            static_prompt.starts_with(&direct_prompt),
2782            "system prompt should start with bashkit prompt"
2783        );
2784        assert!(
2785            static_prompt.contains("Output economy"),
2786            "system prompt should include output economy hint"
2787        );
2788    }
2789
2790    #[test]
2791    fn test_bashkit_tool_builder_configuration() {
2792        // Verify the static BASHKIT_TOOL was built with our custom settings
2793        // by checking that description/system_prompt are accessible (non-panicking)
2794        let _desc = BASHKIT_TOOL.description();
2795        let _prompt = BASHKIT_TOOL.system_prompt();
2796        // If we got here without panic, the builder configuration is valid
2797    }
2798
2799    #[test]
2800    fn test_bash_tool_display_name() {
2801        let tool = BashTool;
2802        assert_eq!(tool.display_name(), Some("Bash"));
2803    }
2804
2805    #[test]
2806    fn test_bash_tool_parameters_schema_structure() {
2807        let tool = BashTool;
2808        let schema = tool.parameters_schema();
2809
2810        // Verify required fields
2811        assert_eq!(schema["type"], "object");
2812        assert!(schema["properties"]["commands"].is_object());
2813
2814        // Verify optional fields
2815        assert!(schema["properties"]["working_dir"].is_object());
2816        assert!(schema["properties"]["timeout_ms"].is_object());
2817
2818        // Verify "commands" is required
2819        let required = schema["required"].as_array().unwrap();
2820        assert!(required.contains(&json!("commands")));
2821    }
2822
2823    #[test]
2824    fn test_execution_limits_configuration() {
2825        let limits = execution_limits();
2826        // Just verify it doesn't panic and returns a valid object
2827        // The limits are used by both BASHKIT_TOOL and per-execution Bash instances
2828        let _ = limits;
2829    }
2830
2831    // ========================================================================
2832    // SearchCapable / indexed search tests
2833    // ========================================================================
2834
2835    #[test]
2836    fn test_adapter_is_search_capable() {
2837        let session_id = SessionId::new();
2838        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2839        let adapter = SessionFileSystemAdapter::new(session_id, store);
2840
2841        let sc = adapter.as_search_capable();
2842        assert!(
2843            sc.is_some(),
2844            "SessionFileSystemAdapter should be SearchCapable"
2845        );
2846
2847        let provider = sc.unwrap().search_provider(Path::new("/workspace"));
2848        assert!(provider.is_some(), "Should return a SearchProvider");
2849
2850        let caps = provider.unwrap().capabilities();
2851        assert!(caps.content_search, "Should support content search");
2852        assert!(caps.regex, "Should support regex patterns");
2853    }
2854
2855    #[tokio::test]
2856    async fn test_search_provider_returns_grep_results() {
2857        let session_id = SessionId::new();
2858        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2859        let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
2860
2861        // Write files via the adapter
2862        adapter
2863            .write_file(
2864                Path::new("/workspace/hello.txt"),
2865                b"hello world\ngoodbye world",
2866            )
2867            .await
2868            .unwrap();
2869        adapter
2870            .write_file(Path::new("/workspace/other.txt"), b"no match here")
2871            .await
2872            .unwrap();
2873
2874        let sc = adapter.as_search_capable().unwrap();
2875        let provider = sc.search_provider(Path::new("/workspace")).unwrap();
2876
2877        let results = provider
2878            .search(&SearchQuery {
2879                pattern: "hello".into(),
2880                is_regex: false,
2881                case_insensitive: false,
2882                root: PathBuf::from("/workspace"),
2883                glob_filter: None,
2884                max_results: None,
2885            })
2886            .unwrap();
2887
2888        assert_eq!(results.matches.len(), 1);
2889        assert_eq!(
2890            results.matches[0].path,
2891            PathBuf::from("/workspace/hello.txt")
2892        );
2893        assert_eq!(results.matches[0].line_number, 1);
2894        assert_eq!(results.matches[0].line_content, "hello world");
2895    }
2896
2897    #[tokio::test]
2898    async fn test_search_provider_truncates_at_max_results() {
2899        let session_id = SessionId::new();
2900        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2901        let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
2902
2903        adapter
2904            .write_file(
2905                Path::new("/workspace/many.txt"),
2906                b"match line 1\nmatch line 2\nmatch line 3\nmatch line 4",
2907            )
2908            .await
2909            .unwrap();
2910
2911        let sc = adapter.as_search_capable().unwrap();
2912        let provider = sc.search_provider(Path::new("/workspace")).unwrap();
2913
2914        let results = provider
2915            .search(&SearchQuery {
2916                pattern: "match".into(),
2917                is_regex: false,
2918                case_insensitive: false,
2919                root: PathBuf::from("/workspace"),
2920                glob_filter: None,
2921                max_results: Some(2),
2922            })
2923            .unwrap();
2924
2925        assert_eq!(results.matches.len(), 2);
2926        assert!(results.truncated);
2927    }
2928
2929    #[tokio::test]
2930    async fn test_bash_grep_uses_indexed_search() {
2931        let (context, _) = create_context_with_mock_store();
2932        let tool = BashTool;
2933
2934        // Create files
2935        tool.execute_with_context(
2936            json!({"commands": "mkdir -p /workspace/src && echo 'fn main() { println!(\"hello\"); }' > /workspace/src/main.rs && echo 'fn test() {}' > /workspace/src/test.rs"}),
2937            &context,
2938        )
2939        .await;
2940
2941        // Run grep -r which should use indexed search via SearchCapable
2942        let result = tool
2943            .execute_with_context(json!({"commands": "grep -r 'fn' /workspace/src"}), &context)
2944            .await;
2945
2946        if let ToolExecutionResult::Success(output) = result {
2947            assert_eq!(output["exit_code"], 0);
2948            let stdout = output["stdout"].as_str().unwrap_or("");
2949            assert!(
2950                stdout.contains("fn main") || stdout.contains("fn test"),
2951                "grep -r should find matches via indexed search, got: {}",
2952                stdout
2953            );
2954        } else {
2955            panic!("Expected success result, got: {:?}", result);
2956        }
2957    }
2958
2959    #[test]
2960    fn test_parameters_schema_delegates_to_bashkit() {
2961        let tool = BashTool;
2962        let schema = tool.parameters_schema();
2963        let bashkit_schema = BASHKIT_TOOL.input_schema();
2964
2965        // All bashkit properties must be present in our schema
2966        let bashkit_props = bashkit_schema["properties"].as_object().unwrap();
2967        let our_props = schema["properties"].as_object().unwrap();
2968        for key in bashkit_props.keys() {
2969            assert!(
2970                our_props.contains_key(key),
2971                "bashkit property '{key}' missing from parameters_schema"
2972            );
2973        }
2974
2975        // Required fields from bashkit must be preserved
2976        let bashkit_required = bashkit_schema["required"].as_array().unwrap();
2977        let our_required = schema["required"].as_array().unwrap();
2978        for req in bashkit_required {
2979            assert!(
2980                our_required.contains(req),
2981                "bashkit required field {req} missing from parameters_schema"
2982            );
2983        }
2984
2985        // Everruns extension: working_dir must be present
2986        assert!(
2987            our_props.contains_key("working_dir"),
2988            "working_dir must be in parameters_schema"
2989        );
2990    }
2991
2992    // ========================================================================
2993    // Observability hooks (EVE-299)
2994    // ========================================================================
2995
2996    #[test]
2997    fn truncate_for_log_returns_short_strings_unchanged() {
2998        assert_eq!(truncate_for_log("hello", 100), "hello");
2999        assert_eq!(truncate_for_log("", 100), "");
3000    }
3001
3002    #[test]
3003    fn truncate_for_log_stays_within_budget_and_marks() {
3004        let input = "a".repeat(500);
3005        let out = truncate_for_log(&input, 100);
3006        assert!(
3007            out.len() <= 100,
3008            "output exceeded budget: {} bytes",
3009            out.len()
3010        );
3011        assert!(out.ends_with("…[truncated]"));
3012        assert!(out.starts_with('a'));
3013    }
3014
3015    #[test]
3016    fn truncate_for_log_respects_utf8_boundaries() {
3017        // Each '🦀' is 4 bytes; marker is 14 bytes. Budget 20 leaves 6 for content,
3018        // which backs off to a 4-byte char boundary (one crab).
3019        let input = "🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀";
3020        let out = truncate_for_log(input, 20);
3021        assert!(out.len() <= 20);
3022        assert!(out.starts_with('🦀'));
3023        assert!(out.ends_with("…[truncated]"));
3024    }
3025
3026    #[test]
3027    fn truncate_for_log_omits_marker_when_budget_is_too_small() {
3028        // Budget smaller than the marker -> marker is dropped, content is still
3029        // cut on a valid UTF-8 boundary and fits within max_bytes.
3030        let input = "abcdefghijklmnop";
3031        let out = truncate_for_log(input, 4);
3032        assert_eq!(out, "abcd");
3033        assert!(out.len() <= 4);
3034    }
3035
3036    #[tokio::test]
3037    async fn install_observability_hooks_fires_on_builtin_and_preserves_exit() {
3038        use bashkit::hooks::{HookAction, ToolResult};
3039        use std::sync::Arc;
3040        use std::sync::atomic::{AtomicU64, Ordering};
3041
3042        let tool_calls = Arc::new(AtomicU64::new(0));
3043        let counter = tool_calls.clone();
3044
3045        // Start from the shared hook installer, then stack a test observer.
3046        // This proves the installer leaves the builtin pipeline intact and
3047        // that additional hooks compose cleanly.
3048        let session_id: SessionId = "session_0197a4a4c0c0780180000000000000ff".parse().unwrap();
3049        let builder = install_observability_hooks(Bash::builder(), session_id).after_tool(
3050            Box::new(move |r: ToolResult| {
3051                counter.fetch_add(1, Ordering::Relaxed);
3052                HookAction::Continue(r)
3053            }),
3054        );
3055
3056        let mut bash = builder.build();
3057        let result = bash.exec("echo hook-smoke").await.unwrap();
3058
3059        assert_eq!(result.exit_code, 0);
3060        assert_eq!(result.stdout.trim(), "hook-smoke");
3061        assert!(
3062            tool_calls.load(Ordering::Relaxed) >= 1,
3063            "after_tool hook should fire at least once for `echo`"
3064        );
3065    }
3066}