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