Skip to main content

everruns_core/capabilities/bashkit_shell/
mod.rs

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