Skip to main content

leviath_tools/
lib.rs

1//! Native built-in tools for Leviath agents.
2//!
3//! Provides file system and shell tools sandboxed to a working directory.
4
5use leviath_core::resolves_within;
6use leviath_providers::Tool;
7use serde_json::{Value, json};
8use std::collections::{HashMap, HashSet};
9use std::path::{Component, Path, PathBuf};
10use std::sync::{Arc, Mutex, PoisonError};
11use tokio::process::Command;
12use tokio::time::{Duration, timeout};
13
14// The tool families, one module per concern; lib.rs keeps the struct and
15// its constructors.
16mod context;
17mod defs;
18mod exec;
19mod platform;
20pub use context::*;
21pub use platform::*;
22
23/// Built-in tools: read_file, write_file, edit_file, list_dir, shell.
24///
25/// Carries the [`PlatformCapabilities`] of the current platform; tools whose
26/// [`tool_required_capabilities`] aren't satisfied are dropped from
27/// [`tool_defs`](Self::tool_defs), [`names`](Self::names), and rejected by
28/// [`execute`](Self::execute).
29pub struct BuiltinTools {
30    ctx: ToolContext,
31    platform: PlatformCapabilities,
32    /// When set, shell commands run through this sandbox instead of the host.
33    shell_executor: Option<Arc<dyn ShellExecutor>>,
34}
35
36impl BuiltinTools {
37    /// Create a new BuiltinTools instance with the given sandbox context,
38    /// filtering tools against the current platform's capabilities.
39    pub fn new(ctx: ToolContext) -> Self {
40        Self {
41            ctx,
42            platform: PlatformCapabilities::current(),
43            shell_executor: None,
44        }
45    }
46
47    /// Route this agent's shell execution through `executor` (a container /
48    /// namespace sandbox) instead of the host.
49    pub fn with_shell_executor(mut self, executor: Arc<dyn ShellExecutor>) -> Self {
50        self.shell_executor = Some(executor);
51        self
52    }
53
54    /// Create a BuiltinTools instance with an explicit platform capability set,
55    /// for tests or hosts that need to override the compile-time default.
56    pub fn with_capabilities(ctx: ToolContext, platform: PlatformCapabilities) -> Self {
57        Self {
58            ctx,
59            platform,
60            shell_executor: None,
61        }
62    }
63
64    /// Whether a built-in named `canonical_name` is available on this platform.
65    fn available(&self, canonical_name: &str) -> bool {
66        self.platform
67            .satisfies(tool_required_capabilities(canonical_name))
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use std::fs;
75
76    fn make_tools(dir: &std::path::Path) -> BuiltinTools {
77        BuiltinTools::new(ToolContext::new(dir.to_path_buf()))
78    }
79
80    /// Built-ins over a mobile capability set (no `ProcessSpawn`), so the
81    /// `shell` tool and its `bash` alias are filtered out.
82    fn make_mobile_tools(dir: &std::path::Path) -> BuiltinTools {
83        BuiltinTools::with_capabilities(
84            ToolContext::new(dir.to_path_buf()),
85            PlatformCapabilities::mobile(),
86        )
87    }
88
89    // ── Tool definitions ──────────────────────────────────────────────────
90
91    #[test]
92    fn tool_defs_returns_sixteen_tools() {
93        let dir = std::env::temp_dir();
94        let tools = make_tools(&dir);
95        let defs = tools.tool_defs();
96        assert_eq!(defs.len(), 16);
97    }
98
99    #[test]
100    fn tool_defs_names_are_correct() {
101        let dir = std::env::temp_dir();
102        let tools = make_tools(&dir);
103        let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
104        assert!(names.contains(&"read_file".to_string()));
105        assert!(names.contains(&"read_files".to_string()));
106        assert!(names.contains(&"write_file".to_string()));
107        assert!(names.contains(&"edit_file".to_string()));
108        assert!(names.contains(&"list_dir".to_string()));
109        assert!(names.contains(&"shell".to_string()));
110        assert!(names.contains(&"present_for_review".to_string()));
111        assert!(names.contains(&"ask_user_text".to_string()));
112        assert!(names.contains(&"ask_user_choice".to_string()));
113        assert!(names.contains(&"ask_user_confirm".to_string()));
114        assert!(names.contains(&"edit_document".to_string()));
115        assert!(names.contains(&"context_write".to_string()));
116        assert!(names.contains(&"context_append".to_string()));
117        assert!(names.contains(&"context_read".to_string()));
118        assert!(names.contains(&"context_delete".to_string()));
119        assert!(names.contains(&"context_list".to_string()));
120    }
121
122    #[test]
123    fn tool_defs_edit_document_requires_content() {
124        let dir = std::env::temp_dir();
125        let tools = make_tools(&dir);
126        let def = tools
127            .tool_defs()
128            .into_iter()
129            .find(|t| t.name == "edit_document")
130            .expect("edit_document tool def must exist");
131        let required = def.parameters["required"].as_array().unwrap();
132        assert!(required.iter().any(|v| v == "content"));
133        assert_eq!(def.parameters["properties"]["content"]["type"], "string");
134        // Also present in the builtin name list.
135        assert!(tools.names().contains(&"edit_document".to_string()));
136    }
137
138    #[test]
139    fn tool_defs_ask_user_choice_has_options_array() {
140        let dir = std::env::temp_dir();
141        let tools = make_tools(&dir);
142        let def = tools
143            .tool_defs()
144            .into_iter()
145            .find(|t| t.name == "ask_user_choice")
146            .unwrap();
147        let required = def.parameters["required"].as_array().unwrap();
148        assert!(required.iter().any(|v| v == "prompt"));
149        assert!(required.iter().any(|v| v == "options"));
150        assert_eq!(def.parameters["properties"]["options"]["type"], "array");
151    }
152
153    #[tokio::test]
154    async fn context_tools_return_runtime_error() {
155        let dir = std::env::temp_dir();
156        let tools = make_tools(&dir);
157        for name in [
158            "context_write",
159            "context_append",
160            "context_read",
161            "context_delete",
162            "context_list",
163        ] {
164            let result = tools.execute(name, serde_json::json!({})).await;
165            assert!(result.contains("context tools must be handled by the runtime"));
166        }
167    }
168
169    #[tokio::test]
170    async fn ask_user_tools_not_handled_by_builtin_execute() {
171        // ask_user_* tools are intercepted upstream (worker.rs/foreground.rs),
172        // exactly like present_for_review - execute() must never run them.
173        let dir = std::env::temp_dir();
174        let tools = make_tools(&dir);
175        for name in [
176            "ask_user_text",
177            "ask_user_choice",
178            "ask_user_confirm",
179            "edit_document",
180        ] {
181            let result = tools.execute(name, serde_json::json!({})).await;
182            assert!(result.contains("Unknown built-in tool"));
183        }
184    }
185
186    #[test]
187    fn context_tool_descriptions_mention_key_concepts() {
188        let dir = std::env::temp_dir();
189        let tools = make_tools(&dir);
190        let defs = tools.tool_defs();
191
192        let write_def = defs.iter().find(|t| t.name == "context_write").unwrap();
193        assert!(
194            write_def.description.contains("system prompt"),
195            "context_write should mention system prompt: {}",
196            write_def.description
197        );
198        assert!(
199            write_def.description.contains("replaced"),
200            "context_write should mention replacement: {}",
201            write_def.description
202        );
203
204        let read_def = defs.iter().find(|t| t.name == "context_read").unwrap();
205        assert!(
206            read_def.description.contains("summary"),
207            "context_read should mention summary: {}",
208            read_def.description
209        );
210
211        let list_def = defs.iter().find(|t| t.name == "context_list").unwrap();
212        assert!(
213            list_def.description.contains("token"),
214            "context_list should mention tokens: {}",
215            list_def.description
216        );
217
218        let append_def = defs.iter().find(|t| t.name == "context_append").unwrap();
219        assert!(
220            append_def.description.contains("without replacing"),
221            "context_append should mention 'without replacing': {}",
222            append_def.description
223        );
224    }
225
226    fn assert_has_description(name: &str, description: &str) {
227        assert!(
228            !description.is_empty(),
229            "tool {} has empty description",
230            name
231        );
232    }
233
234    fn assert_has_object_params(name: &str, params: &serde_json::Value) {
235        assert!(params.is_object(), "tool {} has non-object params", name);
236    }
237
238    #[test]
239    fn tool_defs_have_descriptions() {
240        let dir = std::env::temp_dir();
241        let tools = make_tools(&dir);
242        for def in tools.tool_defs() {
243            assert_has_description(&def.name, &def.description);
244        }
245    }
246
247    #[test]
248    #[should_panic(expected = "tool bogus has empty description")]
249    fn tool_defs_have_descriptions_panics_on_empty_description() {
250        assert_has_description("bogus", "");
251    }
252
253    #[test]
254    fn tool_defs_have_parameters() {
255        let dir = std::env::temp_dir();
256        let tools = make_tools(&dir);
257        for def in tools.tool_defs() {
258            assert_has_object_params(&def.name, &def.parameters);
259        }
260    }
261
262    #[test]
263    #[should_panic(expected = "tool bogus has non-object params")]
264    fn tool_defs_have_parameters_panics_on_non_object_params() {
265        assert_has_object_params("bogus", &serde_json::Value::Null);
266    }
267
268    // ── names() ───────────────────────────────────────────────────────────
269
270    #[test]
271    fn names_includes_bash_alias() {
272        let dir = std::env::temp_dir();
273        let tools = make_tools(&dir);
274        let names = tools.names();
275        assert!(names.contains(&"bash".to_string()));
276        assert!(names.contains(&"shell".to_string()));
277    }
278
279    #[test]
280    fn canonical_tool_name_resolves_aliases_and_passes_others_through() {
281        // An alias resolves to its canonical name.
282        assert_eq!(canonical_tool_name("bash"), "shell");
283        // A canonical built-in is unchanged.
284        assert_eq!(canonical_tool_name("shell"), "shell");
285        assert_eq!(canonical_tool_name("read_file"), "read_file");
286        // An unknown name (e.g. an MCP tool whose server may not be installed)
287        // passes through untouched, so it is matched/omitted as-is.
288        assert_eq!(canonical_tool_name("acme__do_thing"), "acme__do_thing");
289        // Every alias in the table round-trips to a real canonical name.
290        for (alias, canonical) in TOOL_ALIASES {
291            assert_eq!(canonical_tool_name(alias), *canonical);
292        }
293    }
294
295    #[test]
296    fn names_returns_seventeen_entries() {
297        let dir = std::env::temp_dir();
298        let tools = make_tools(&dir);
299        assert_eq!(tools.names().len(), 17);
300    }
301
302    // ── Sub-agent tool definitions ────────────────────────────────────────
303
304    #[test]
305    fn subagent_tool_defs_returns_five_tools() {
306        let defs = BuiltinTools::subagent_tool_defs();
307        assert_eq!(defs.len(), 5);
308    }
309
310    #[test]
311    fn subagent_tool_names_returns_five_names() {
312        let names = BuiltinTools::subagent_tool_names();
313        assert_eq!(names.len(), 5);
314        assert!(names.contains(&"spawn_agent".to_string()));
315        assert!(names.contains(&"check_agent".to_string()));
316        assert!(names.contains(&"wait_for_agent".to_string()));
317        assert!(names.contains(&"send_to_agent".to_string()));
318        assert!(names.contains(&"kill_agent".to_string()));
319    }
320
321    #[test]
322    fn subagent_tool_defs_names_match_subagent_tool_names() {
323        let defs = BuiltinTools::subagent_tool_defs();
324        let names = BuiltinTools::subagent_tool_names();
325        let def_names: Vec<String> = defs.iter().map(|d| d.name.clone()).collect();
326        assert_eq!(def_names, names);
327    }
328
329    // ── resolve() ─────────────────────────────────────────────────────────
330
331    #[test]
332    fn resolve_relative_path() {
333        let dir = std::env::temp_dir();
334        let tools = make_tools(&dir);
335        let result = tools.resolve("hello.txt").unwrap();
336        assert!(result.starts_with(&tools.ctx.workdir));
337        assert!(result.ends_with("hello.txt"));
338    }
339
340    #[test]
341    fn resolve_rejects_path_escape() {
342        let dir = std::env::temp_dir().join("leviath_test_sandbox");
343        fs::create_dir_all(&dir).ok();
344        let tools = make_tools(&dir);
345        let result = tools.resolve("../../etc/passwd");
346        assert!(result.is_err());
347    }
348
349    /// The escape a lexical check cannot see. `<workdir>/link -> /` makes
350    /// `link/etc/passwd` textually contained the whole way, and the old
351    /// `starts_with` containment let `fs::read_to_string` follow it straight out.
352    ///
353    /// This matters most where the containment is load-bearing: Leviath's file
354    /// tools run on the *host* over the bind-mounted workdir even when the
355    /// The containment refusal itself, driven through the injected predicate so
356    /// it is exercised on every platform. The `#[cfg(unix)]` tests below prove
357    /// the same refusal against a real symlink; this one proves the arm exists
358    /// and fires on Windows too, where a test cannot create one.
359    #[test]
360    fn resolve_refuses_a_path_that_does_not_resolve_within_the_workdir() {
361        fn escapes(_: &Path, _: &Path) -> bool {
362            false
363        }
364        let dir = tempfile::tempdir().unwrap();
365        let err = BuiltinTools::resolve_within("notes.txt", dir.path(), escapes)
366            .expect_err("a path that resolves outside must be refused");
367        assert!(err.to_string().contains("symlink"), "{err}");
368    }
369
370    /// The converse, so the test above is not passing merely because everything
371    /// is refused: with the real predicate an ordinary path resolves.
372    #[test]
373    fn resolve_admits_an_ordinary_path_within_the_workdir() {
374        let dir = tempfile::tempdir().unwrap();
375        let resolved =
376            BuiltinTools::resolve_within("notes.txt", dir.path(), leviath_core::resolves_within)
377                .expect("an ordinary path resolves");
378        assert!(resolved.ends_with("notes.txt"));
379    }
380
381    /// stage's `shell` is confined to a container, so a symlink the agent made
382    /// inside the container escaped the container through these tools. It is also
383    /// reachable from a checked-in symlink in a freshly cloned repository, which
384    /// is exactly what a coding agent is pointed at.
385    #[cfg(unix)]
386    #[tokio::test]
387    async fn resolve_rejects_symlink_escape() {
388        let dir = tempfile::tempdir().unwrap();
389        let workdir = dir.path().join("workspace");
390        fs::create_dir(&workdir).unwrap();
391        std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
392        let tools = make_tools(&workdir);
393
394        // Precondition: this is textually inside the workdir, so a lexical
395        // `starts_with` containment check alone would pass it.
396        // Built from `ctx.workdir` rather than `workdir` because the context
397        // canonicalizes (on macOS `/var` becomes `/private/var`).
398        let normalized = tools.ctx.workdir.join("link/etc/hosts");
399        assert!(normalized.starts_with(&tools.ctx.workdir));
400
401        let err = tools.resolve("link/etc/hosts").unwrap_err().to_string();
402        assert!(err.contains("symlink"), "got: {err}");
403
404        // And the tool itself refuses rather than returning the file.
405        let out = tools.read_file(&json!({ "path": "link/etc/hosts" })).await;
406        assert!(out.contains("[error]"), "got: {out}");
407    }
408
409    /// A write through an escaping symlink is refused too - this was the path
410    /// that could overwrite `~/.ssh/authorized_keys`.
411    #[cfg(unix)]
412    #[tokio::test]
413    async fn write_file_rejects_symlink_escape() {
414        let dir = tempfile::tempdir().unwrap();
415        let outside = tempfile::tempdir().unwrap();
416        let workdir = dir.path().join("workspace");
417        fs::create_dir(&workdir).unwrap();
418        std::os::unix::fs::symlink(outside.path(), workdir.join("link")).unwrap();
419        let tools = make_tools(&workdir);
420
421        let out = tools
422            .write_file(&json!({ "path": "link/pwned.txt", "content": "x" }))
423            .await;
424        assert!(out.contains("[error]"), "got: {out}");
425        assert!(
426            !outside.path().join("pwned.txt").exists(),
427            "nothing may be written outside the workdir"
428        );
429    }
430
431    /// A symlink that stays *inside* the workdir keeps working - the rule is
432    /// about where the path lands, not whether a symlink was involved. Agents
433    /// operate on real repositories, which contain plenty of internal symlinks.
434    #[cfg(unix)]
435    #[tokio::test]
436    async fn resolve_allows_symlink_within_workdir() {
437        let dir = tempfile::tempdir().unwrap();
438        let workdir = dir.path().join("workspace");
439        fs::create_dir(&workdir).unwrap();
440        fs::create_dir(workdir.join("real")).unwrap();
441        fs::write(workdir.join("real/file.txt"), "contents").unwrap();
442        std::os::unix::fs::symlink(workdir.join("real"), workdir.join("link")).unwrap();
443        let tools = make_tools(&workdir);
444
445        assert!(tools.resolve("link/file.txt").is_ok());
446        let out = tools.read_file(&json!({ "path": "link/file.txt" })).await;
447        assert_eq!(out, "contents");
448    }
449
450    #[test]
451    fn resolve_dot_stays_in_workdir() {
452        let dir = std::env::temp_dir();
453        let tools = make_tools(&dir);
454        let result = tools.resolve("./foo/./bar.txt").unwrap();
455        assert!(result.starts_with(&tools.ctx.workdir));
456        assert!(result.ends_with("foo/bar.txt"));
457    }
458
459    // ── execute() with file I/O (async) ───────────────────────────────────
460
461    #[tokio::test]
462    async fn execute_unknown_tool_returns_error() {
463        let dir = std::env::temp_dir();
464        let tools = make_tools(&dir);
465        let result = tools.execute("nonexistent", json!({})).await;
466        assert!(result.contains("[error]"));
467        assert!(result.contains("Unknown built-in tool"));
468    }
469
470    #[tokio::test]
471    async fn read_file_missing_path_arg() {
472        let dir = std::env::temp_dir();
473        let tools = make_tools(&dir);
474        let result = tools.execute("read_file", json!({})).await;
475        assert!(result.contains("[error]"));
476        assert!(result.contains("missing 'path'"));
477    }
478
479    #[tokio::test]
480    async fn write_and_read_file_roundtrip() {
481        let dir = tempfile::tempdir().unwrap();
482        let tools = make_tools(dir.path());
483
484        let write_result = tools
485            .execute(
486                "write_file",
487                json!({"path": "test.txt", "content": "hello world"}),
488            )
489            .await;
490        assert!(write_result.contains("Successfully wrote"));
491        assert!(write_result.contains("11 bytes"));
492
493        let read_result = tools
494            .execute("read_file", json!({"path": "test.txt"}))
495            .await;
496        assert_eq!(read_result, "hello world");
497    }
498
499    #[tokio::test]
500    async fn write_file_creates_parent_dirs() {
501        let dir = tempfile::tempdir().unwrap();
502        let tools = make_tools(dir.path());
503
504        let result = tools
505            .execute(
506                "write_file",
507                json!({"path": "sub/dir/file.txt", "content": "nested"}),
508            )
509            .await;
510        assert!(result.contains("Successfully wrote"));
511        assert!(dir.path().join("sub/dir/file.txt").exists());
512    }
513
514    #[tokio::test]
515    async fn write_tools_refuse_to_resurrect_a_deleted_workspace() {
516        // Issue #107: an external harness deletes the workspace mid-run.
517        // `create_dir_all` would happily recreate it and let the agent write
518        // into an empty tree that no longer resembles the checkout it reasoned
519        // about - and the runtime's health check, which just stats the workdir,
520        // would never see it was gone.
521        let dir = tempfile::tempdir().unwrap();
522        let workdir = dir.path().join("workspace");
523        fs::create_dir(&workdir).unwrap();
524        fs::write(workdir.join("a.txt"), "before").unwrap();
525        let tools = make_tools(&workdir);
526        fs::remove_dir_all(&workdir).unwrap();
527
528        for (tool, args) in [
529            ("write_file", json!({"path": "a.txt", "content": "after"})),
530            (
531                "edit_file",
532                json!({"path": "a.txt", "old_str": "before", "new_str": "after"}),
533            ),
534        ] {
535            let result = tools.execute(tool, args).await;
536            assert!(
537                result.contains("workspace") && result.contains("no longer accessible"),
538                "{tool} got: {result}"
539            );
540        }
541        assert!(!workdir.exists(), "the workspace must stay gone");
542    }
543
544    #[tokio::test]
545    async fn write_file_missing_content_arg() {
546        let dir = tempfile::tempdir().unwrap();
547        let tools = make_tools(dir.path());
548        let result = tools.execute("write_file", json!({"path": "f.txt"})).await;
549        assert!(result.contains("missing 'content'"));
550    }
551
552    #[tokio::test]
553    async fn write_file_missing_path_arg() {
554        let dir = tempfile::tempdir().unwrap();
555        let tools = make_tools(dir.path());
556        let result = tools.execute("write_file", json!({"content": "x"})).await;
557        assert!(result.contains("missing 'path'"));
558    }
559
560    #[test]
561    fn resolve_rejects_excessive_parent_dir_traversal() {
562        // A *relative, nonexistent* workdir keeps `resolve`'s accumulator free
563        // of any platform-specific leading root/drive/prefix components:
564        // `canonicalize` fails for a path that doesn't exist (on every OS), so
565        // `ToolContext::new` keeps the raw relative `PathBuf` verbatim. The
566        // request then decomposes into exactly `[Normal(workdir), ParentDir,
567        // ParentDir, ...]`; the first `..` pops the single workdir component and
568        // the second `..` calls `normalized.pop()` on an *empty* accumulator,
569        // which returns `false` - firing the "escapes the working directory"
570        // bail deterministically on every OS.
571        //
572        // (An empty "" workdir is not portable here: on Windows `canonicalize("")`
573        // can succeed and yield an absolute cwd whose Prefix/RootDir components
574        // absorb the `..`, so `pop()` never fails and this bail is never hit --
575        // which is exactly why this branch was Windows-uncovered before.)
576        let tools = BuiltinTools::new(ToolContext::new(PathBuf::from(
577            "leviath-nonexistent-relative-workdir",
578        )));
579        let result = tools.resolve("../../etc/passwd");
580        assert!(result.is_err());
581        assert!(
582            result
583                .unwrap_err()
584                .to_string()
585                .contains("escapes the working directory")
586        );
587    }
588
589    #[tokio::test]
590    async fn edit_file_successful_replacement() {
591        let dir = tempfile::tempdir().unwrap();
592        let tools = make_tools(dir.path());
593
594        tools
595            .execute(
596                "write_file",
597                json!({"path": "e.txt", "content": "foo bar baz"}),
598            )
599            .await;
600
601        let result = tools
602            .execute(
603                "edit_file",
604                json!({"path": "e.txt", "old_str": "bar", "new_str": "qux"}),
605            )
606            .await;
607        assert!(result.contains("Successfully edited"));
608
609        let content = tools.execute("read_file", json!({"path": "e.txt"})).await;
610        assert_eq!(content, "foo qux baz");
611    }
612
613    #[tokio::test]
614    async fn edit_file_string_not_found() {
615        let dir = tempfile::tempdir().unwrap();
616        let tools = make_tools(dir.path());
617
618        tools
619            .execute("write_file", json!({"path": "e.txt", "content": "abc"}))
620            .await;
621
622        let result = tools
623            .execute(
624                "edit_file",
625                json!({"path": "e.txt", "old_str": "xyz", "new_str": "123"}),
626            )
627            .await;
628        assert!(result.contains("String not found"));
629    }
630
631    #[tokio::test]
632    async fn edit_file_missing_file_returns_read_error() {
633        let dir = tempfile::tempdir().unwrap();
634        let tools = make_tools(dir.path());
635
636        let result = tools
637            .execute(
638                "edit_file",
639                json!({"path": "does-not-exist.txt", "old_str": "a", "new_str": "b"}),
640            )
641            .await;
642        assert!(result.contains("[error]"));
643        assert!(result.contains("Failed to read"));
644    }
645
646    #[tokio::test]
647    async fn edit_file_multiple_occurrences() {
648        let dir = tempfile::tempdir().unwrap();
649        let tools = make_tools(dir.path());
650
651        tools
652            .execute("write_file", json!({"path": "e.txt", "content": "aaa aaa"}))
653            .await;
654
655        let result = tools
656            .execute(
657                "edit_file",
658                json!({"path": "e.txt", "old_str": "aaa", "new_str": "bbb"}),
659            )
660            .await;
661        assert!(result.contains("2 occurrences"));
662        assert!(result.contains("must be unique"));
663    }
664
665    #[tokio::test]
666    async fn edit_file_missing_args() {
667        let dir = tempfile::tempdir().unwrap();
668        let tools = make_tools(dir.path());
669
670        let r1 = tools.execute("edit_file", json!({})).await;
671        assert!(r1.contains("missing 'path'"));
672
673        let r2 = tools.execute("edit_file", json!({"path": "f.txt"})).await;
674        assert!(r2.contains("missing 'old_str'"));
675
676        let r3 = tools
677            .execute("edit_file", json!({"path": "f.txt", "old_str": "x"}))
678            .await;
679        assert!(r3.contains("missing 'new_str'"));
680    }
681
682    #[tokio::test]
683    async fn list_dir_contents() {
684        let dir = tempfile::tempdir().unwrap();
685        let tools = make_tools(dir.path());
686
687        fs::write(dir.path().join("a.txt"), "hello").unwrap();
688        fs::create_dir(dir.path().join("subdir")).unwrap();
689
690        let result = tools.execute("list_dir", json!({})).await;
691        assert!(result.contains("a.txt"));
692        assert!(result.contains("subdir/"));
693    }
694
695    #[tokio::test]
696    async fn list_dir_empty() {
697        let dir = tempfile::tempdir().unwrap();
698        let tools = make_tools(dir.path());
699        let result = tools.execute("list_dir", json!({})).await;
700        assert!(result.contains("empty directory"));
701    }
702
703    #[tokio::test]
704    async fn list_dir_with_path() {
705        let dir = tempfile::tempdir().unwrap();
706        let tools = make_tools(dir.path());
707
708        fs::create_dir(dir.path().join("sub")).unwrap();
709        fs::write(dir.path().join("sub/inner.txt"), "data").unwrap();
710
711        let result = tools.execute("list_dir", json!({"path": "sub"})).await;
712        assert!(result.contains("inner.txt"));
713    }
714
715    #[tokio::test]
716    async fn read_file_nonexistent() {
717        let dir = tempfile::tempdir().unwrap();
718        let tools = make_tools(dir.path());
719        let result = tools
720            .execute("read_file", json!({"path": "nope.txt"}))
721            .await;
722        assert!(result.contains("[error]"));
723        assert!(result.contains("Failed to read"));
724    }
725
726    // ── read_files (batch reads) ────────────────────────────────────────────
727
728    #[tokio::test]
729    async fn read_files_multiple_valid_files() {
730        let dir = tempfile::tempdir().unwrap();
731        let tools = make_tools(dir.path());
732        fs::write(dir.path().join("a.txt"), "alpha").unwrap();
733        fs::write(dir.path().join("b.txt"), "beta").unwrap();
734
735        let result = tools
736            .execute("read_files", json!({"paths": ["a.txt", "b.txt"]}))
737            .await;
738        assert!(result.contains("### [a.txt]"));
739        assert!(result.contains("alpha"));
740        assert!(result.contains("### [b.txt]"));
741        assert!(result.contains("beta"));
742        // Results are joined with a blank line between entries.
743        assert!(result.contains("\n\n"));
744    }
745
746    #[tokio::test]
747    async fn read_files_missing_paths_arg() {
748        let dir = tempfile::tempdir().unwrap();
749        let tools = make_tools(dir.path());
750        let result = tools.execute("read_files", json!({})).await;
751        assert!(result.contains("[error]"));
752        assert!(result.contains("missing 'paths'"));
753    }
754
755    #[tokio::test]
756    async fn read_files_non_array_paths_arg() {
757        let dir = tempfile::tempdir().unwrap();
758        let tools = make_tools(dir.path());
759        // A string (not an array) → as_array() returns None → same error path.
760        let result = tools.execute("read_files", json!({"paths": "a.txt"})).await;
761        assert!(result.contains("[error]"));
762        assert!(result.contains("missing 'paths'"));
763    }
764
765    #[tokio::test]
766    async fn read_files_empty_paths_array() {
767        let dir = tempfile::tempdir().unwrap();
768        let tools = make_tools(dir.path());
769        let result = tools.execute("read_files", json!({"paths": []})).await;
770        assert!(result.contains("[error]"));
771        assert!(result.contains("empty"));
772    }
773
774    #[tokio::test]
775    async fn read_files_missing_file_reports_per_file_error() {
776        let dir = tempfile::tempdir().unwrap();
777        let tools = make_tools(dir.path());
778        fs::write(dir.path().join("present.txt"), "here").unwrap();
779
780        let result = tools
781            .execute(
782                "read_files",
783                json!({"paths": ["present.txt", "absent.txt"]}),
784            )
785            .await;
786        // Valid file still returned…
787        assert!(result.contains("### [present.txt]"));
788        assert!(result.contains("here"));
789        // …while the missing one produces a per-file error under its header.
790        assert!(result.contains("### [absent.txt]"));
791        assert!(result.contains("Failed to read"));
792    }
793
794    #[tokio::test]
795    async fn read_files_non_string_element_reports_error() {
796        let dir = tempfile::tempdir().unwrap();
797        let tools = make_tools(dir.path());
798        fs::write(dir.path().join("ok.txt"), "content").unwrap();
799
800        let result = tools
801            .execute("read_files", json!({"paths": ["ok.txt", 42]}))
802            .await;
803        assert!(result.contains("content"));
804        assert!(result.contains("non-string path in array"));
805    }
806
807    #[tokio::test]
808    async fn read_files_path_escape_reported_per_file() {
809        let dir = tempfile::tempdir().unwrap();
810        let tools = make_tools(dir.path());
811        let result = tools
812            .execute("read_files", json!({"paths": ["../../etc/passwd"]}))
813            .await;
814        assert!(result.contains("### [../../etc/passwd]"));
815        assert!(result.contains("escape"));
816    }
817
818    // ── resolve() absolute paths ────────────────────────────────────────────
819
820    #[test]
821    fn resolve_absolute_path_inside_workdir() {
822        let dir = tempfile::tempdir().unwrap();
823        let tools = make_tools(dir.path());
824        // Build the absolute path from the tool's own (canonicalized) workdir
825        // rather than `dir.path()` directly - on macOS `/tmp`/`/var` are
826        // symlinks, so the two can differ even though they're the same place.
827        let abs = tools.ctx.workdir.join("inside.txt");
828        let result = tools.resolve(abs.to_str().unwrap()).unwrap();
829        assert_eq!(result, abs);
830    }
831
832    #[test]
833    fn resolve_rejects_absolute_path_outside_workdir() {
834        let dir = tempfile::tempdir().unwrap();
835        let tools = make_tools(dir.path());
836        let result = tools.resolve("/etc/passwd");
837        assert!(result.is_err());
838    }
839
840    // ── path-escape rejection propagates through each tool ─────────────────
841
842    #[tokio::test]
843    async fn read_file_path_escape_rejected() {
844        let dir = tempfile::tempdir().unwrap();
845        let tools = make_tools(dir.path());
846        let result = tools
847            .execute("read_file", json!({"path": "../../etc/passwd"}))
848            .await;
849        assert!(result.contains("[error]"));
850        assert!(result.contains("escape"));
851    }
852
853    #[tokio::test]
854    async fn write_file_path_escape_rejected() {
855        let dir = tempfile::tempdir().unwrap();
856        let tools = make_tools(dir.path());
857        let result = tools
858            .execute(
859                "write_file",
860                json!({"path": "../../evil.txt", "content": "x"}),
861            )
862            .await;
863        assert!(result.contains("[error]"));
864        assert!(result.contains("escape"));
865    }
866
867    #[tokio::test]
868    async fn edit_file_path_escape_rejected() {
869        let dir = tempfile::tempdir().unwrap();
870        let tools = make_tools(dir.path());
871        let result = tools
872            .execute(
873                "edit_file",
874                json!({"path": "../../evil.txt", "old_str": "a", "new_str": "b"}),
875            )
876            .await;
877        assert!(result.contains("[error]"));
878        assert!(result.contains("escape"));
879    }
880
881    #[tokio::test]
882    async fn list_dir_path_escape_rejected() {
883        let dir = tempfile::tempdir().unwrap();
884        let tools = make_tools(dir.path());
885        let result = tools.execute("list_dir", json!({"path": "../../"})).await;
886        assert!(result.contains("[error]"));
887        assert!(result.contains("escape"));
888    }
889
890    // ── filesystem failure branches ─────────────────────────────────────────
891
892    #[tokio::test]
893    async fn write_file_fails_when_path_is_a_directory() {
894        let dir = tempfile::tempdir().unwrap();
895        let tools = make_tools(dir.path());
896        fs::create_dir(dir.path().join("adir")).unwrap();
897
898        let result = tools
899            .execute("write_file", json!({"path": "adir", "content": "x"}))
900            .await;
901        assert!(result.contains("[error]"));
902        assert!(result.contains("Failed to write"));
903    }
904
905    #[tokio::test]
906    async fn write_file_parent_dir_creation_fails_when_blocked_by_file() {
907        let dir = tempfile::tempdir().unwrap();
908        let tools = make_tools(dir.path());
909        // "blocker" exists as a plain file, so create_dir_all("blocker") must fail.
910        fs::write(dir.path().join("blocker"), "im a file").unwrap();
911
912        let result = tools
913            .execute(
914                "write_file",
915                json!({"path": "blocker/nested.txt", "content": "x"}),
916            )
917            .await;
918        assert!(result.contains("[error]"));
919        assert!(result.contains("Failed to create directories"));
920    }
921
922    #[tokio::test]
923    async fn read_file_fails_when_path_is_a_directory() {
924        let dir = tempfile::tempdir().unwrap();
925        let tools = make_tools(dir.path());
926        fs::create_dir(dir.path().join("adir")).unwrap();
927
928        let result = tools.execute("read_file", json!({"path": "adir"})).await;
929        assert!(result.contains("[error]"));
930        assert!(result.contains("Failed to read"));
931    }
932
933    #[tokio::test]
934    async fn list_dir_fails_when_path_is_a_file() {
935        let dir = tempfile::tempdir().unwrap();
936        let tools = make_tools(dir.path());
937        fs::write(dir.path().join("afile.txt"), "content").unwrap();
938
939        let result = tools
940            .execute("list_dir", json!({"path": "afile.txt"}))
941            .await;
942        assert!(result.contains("[error]"));
943        assert!(result.contains("Failed to read directory"));
944    }
945
946    // `set_readonly(false)` widens Unix perms beyond the original, but here it
947    // only re-enables cleanup of a throwaway tempdir file, which is exactly
948    // what we want.
949    #[allow(clippy::permissions_set_readonly_false)]
950    #[tokio::test]
951    async fn edit_file_write_failure_after_successful_match() {
952        let dir = tempfile::tempdir().unwrap();
953        let tools = make_tools(dir.path());
954        let file_path = dir.path().join("ro.txt");
955        fs::write(&file_path, "hello world").unwrap();
956
957        // Make the file read-only so the read succeeds but the write-back
958        // fails. `set_readonly(true)` is cross-platform (clears the write bits
959        // on Unix; sets the read-only attribute on Windows), so the write
960        // error arm is exercised on every OS.
961        let mut perms = fs::metadata(&file_path).unwrap().permissions();
962        perms.set_readonly(true);
963        fs::set_permissions(&file_path, perms).unwrap();
964
965        let result = tools
966            .execute(
967                "edit_file",
968                json!({"path": "ro.txt", "old_str": "hello", "new_str": "goodbye"}),
969            )
970            .await;
971
972        // Restore permissions so tempdir cleanup can remove the file.
973        let mut perms = fs::metadata(&file_path).unwrap().permissions();
974        perms.set_readonly(false);
975        fs::set_permissions(&file_path, perms).unwrap();
976
977        assert!(result.contains("[error]"));
978        assert!(result.contains("Failed to write"));
979    }
980
981    #[tokio::test]
982    async fn shell_echo_command() {
983        let dir = tempfile::tempdir().unwrap();
984        let tools = make_tools(dir.path());
985        let result = tools
986            .execute("shell", json!({"command": "echo hello"}))
987            .await;
988        assert!(result.trim().contains("hello"));
989    }
990
991    #[tokio::test]
992    async fn bash_alias_works() {
993        let dir = tempfile::tempdir().unwrap();
994        let tools = make_tools(dir.path());
995        let result = tools
996            .execute("bash", json!({"command": "echo alias_test"}))
997            .await;
998        assert!(result.contains("alias_test"));
999    }
1000
1001    #[tokio::test]
1002    async fn shell_missing_command_arg() {
1003        let dir = tempfile::tempdir().unwrap();
1004        let tools = make_tools(dir.path());
1005        let result = tools.execute("shell", json!({})).await;
1006        assert!(result.contains("missing 'command'"));
1007    }
1008
1009    /// A `ShellExecutor` that ignores the requested command and instead runs a
1010    /// fixed marker command - proof that shell execution is routed through it.
1011    struct RedirectExecutor;
1012    impl ShellExecutor for RedirectExecutor {
1013        fn build_command(
1014            &self,
1015            shell: &str,
1016            flag: &str,
1017            _command: &str,
1018            workdir: &Path,
1019        ) -> Command {
1020            let mut c = Command::new(shell);
1021            c.arg(flag).arg("echo SANDBOXED").current_dir(workdir);
1022            c
1023        }
1024    }
1025
1026    #[tokio::test]
1027    async fn shell_routes_through_executor_when_present() {
1028        let dir = tempfile::tempdir().unwrap();
1029        let tools = BuiltinTools::new(ToolContext::new(dir.path().to_path_buf()))
1030            .with_shell_executor(Arc::new(RedirectExecutor));
1031        // The agent asked for `echo host`, but the executor redirects it.
1032        let result = tools
1033            .execute("shell", json!({"command": "echo host"}))
1034            .await;
1035        assert!(result.contains("SANDBOXED"), "got: {result}");
1036        assert!(!result.contains("host"));
1037    }
1038
1039    #[tokio::test]
1040    async fn shell_failing_command() {
1041        let dir = tempfile::tempdir().unwrap();
1042        let tools = make_tools(dir.path());
1043        let result = tools.execute("shell", json!({"command": "false"})).await;
1044        assert!(result.contains("[exit code"));
1045    }
1046
1047    #[tokio::test]
1048    async fn shell_successful_command_with_no_output() {
1049        let dir = tempfile::tempdir().unwrap();
1050        let tools = make_tools(dir.path());
1051        let result = tools.execute("shell", json!({"command": "true"})).await;
1052        assert_eq!(result, "(command succeeded with no output)");
1053    }
1054
1055    // The stdout+stderr non-zero-exit formatting is asserted directly against
1056    // `format_command_output` (below) rather than via a real shell command:
1057    // producing stdout, stderr, and a non-zero exit in a single command needs
1058    // shell-specific syntax (`;`/`1>&2` on `sh`, `&`/redirection on `cmd.exe`)
1059    // that isn't portable, and this session already hit real Windows CI
1060    // failures from insufficiently-verified platform-specific test commands.
1061    #[test]
1062    fn format_command_output_non_zero_exit_reports_stdout_and_stderr() {
1063        let result = BuiltinTools::format_command_output(b"out-line\n", b"err-line\n", false, 1);
1064        assert!(result.contains("[exit code 1]"));
1065        assert!(result.contains("stdout:"));
1066        assert!(result.contains("out-line"));
1067        assert!(result.contains("stderr:"));
1068        assert!(result.contains("err-line"));
1069    }
1070
1071    #[test]
1072    fn format_command_output_non_zero_exit_omits_empty_streams() {
1073        // Whitespace-only streams are treated as empty and neither the
1074        // stdout: nor stderr: block is emitted.
1075        let result = BuiltinTools::format_command_output(b"   \n", b"", false, 2);
1076        assert_eq!(result, "[exit code 2]\n");
1077    }
1078
1079    #[test]
1080    fn format_command_output_success_with_output_returns_stdout() {
1081        let result = BuiltinTools::format_command_output(b"hello\n", b"", true, 0);
1082        assert_eq!(result, "hello\n");
1083    }
1084
1085    #[test]
1086    fn format_command_output_success_no_output() {
1087        let result = BuiltinTools::format_command_output(b"   ", b"noise", true, 0);
1088        assert_eq!(result, "(command succeeded with no output)");
1089    }
1090
1091    #[tokio::test]
1092    async fn shell_with_timeout_fires_on_slow_command() {
1093        let dir = tempfile::tempdir().unwrap();
1094        let tools = make_tools(dir.path());
1095        let result = tools
1096            .shell_with_timeout(&json!({"command": "sleep 5"}), Duration::from_millis(100))
1097            .await;
1098        assert!(result.contains("[timed out]"));
1099    }
1100
1101    /// A timed-out (or cancelled) command takes its *grandchildren* with it.
1102    ///
1103    /// `kill_on_drop` only reaps the shell. Anything the shell started is
1104    /// reparented to init and keeps running - a cancelled agent's `sleep`
1105    /// outliving the run that spawned it. Verified by writing a marker file
1106    /// after a delay: if the grandchild survived, the marker appears.
1107    #[cfg(unix)]
1108    #[tokio::test]
1109    async fn a_timed_out_command_kills_its_grandchildren() {
1110        let dir = tempfile::tempdir().unwrap();
1111        let marker = dir.path().join("survived");
1112        let tools = make_tools(dir.path());
1113
1114        // A *backgrounded subshell* is the grandchild, and it is what writes the
1115        // marker. Chaining (`sleep 2 && touch`) would not test anything: the
1116        // `touch` is run by the shell itself, so killing the shell suppresses it
1117        // whether or not the group was signalled.
1118        let cmd = format!("( sleep 2; touch {} ) & sleep 30", marker.display());
1119        let result = tools
1120            .shell_with_timeout(&json!({ "command": cmd }), Duration::from_millis(100))
1121            .await;
1122        assert!(result.contains("[timed out]"), "got: {result}");
1123
1124        // Well past when the grandchild would have written it.
1125        tokio::time::sleep(Duration::from_secs(3)).await;
1126        assert!(
1127            !marker.exists(),
1128            "the grandchild outlived the command that started it"
1129        );
1130    }
1131
1132    #[tokio::test]
1133    async fn shell_spawn_failure_when_workdir_missing() {
1134        // A workdir that doesn't exist on disk makes Command::output() fail
1135        // before the shell ever runs (current_dir() can't chdir into it).
1136        // canonicalize() fails for a nonexistent path, so ToolContext::new()
1137        // falls back to keeping the raw (nonexistent) path as-is.
1138        let tools = make_tools(std::path::Path::new(
1139            "/definitely/does/not/exist/leviath-test",
1140        ));
1141        let result = tools.execute("shell", json!({"command": "echo hi"})).await;
1142        assert!(result.contains("[error]"));
1143        assert!(result.contains("Failed to spawn shell"));
1144    }
1145
1146    // ── ToolContext ────────────────────────────────────────────────────────
1147
1148    #[test]
1149    fn tool_context_new_canonicalizes() {
1150        let dir = std::env::temp_dir();
1151        let ctx = ToolContext::new(dir.clone());
1152        // Canonicalized path should be absolute
1153        assert!(ctx.workdir.is_absolute());
1154    }
1155
1156    #[test]
1157    fn tool_context_new_with_nonexistent_dir() {
1158        let ctx = ToolContext::new(PathBuf::from("/nonexistent/path/unlikely"));
1159        // Falls back to the original path when canonicalization fails
1160        assert_eq!(ctx.workdir, PathBuf::from("/nonexistent/path/unlikely"));
1161    }
1162
1163    // ── detect_shell ──────────────────────────────────────────────────────
1164
1165    /// Windows' `detect_shell()` branch is a plain, unconditional constant
1166    /// return (no env/filesystem dependence to inject) - the
1167    /// platform-agnostic `detect_shell_returns_valid_shell` test below
1168    /// already exercises it on Windows CI, but this asserts the exact
1169    /// documented return value directly.
1170    #[cfg(windows)]
1171    #[test]
1172    fn detect_shell_returns_cmd_exe() {
1173        let (shell, flag) = BuiltinTools::detect_shell();
1174        assert_eq!(shell, "cmd.exe");
1175        assert_eq!(flag, "/C");
1176    }
1177
1178    #[test]
1179    fn detect_shell_returns_valid_shell() {
1180        // Pure reader: `detect_shell()` always returns a non-empty shell (and the
1181        // "-c" flag on non-Windows) regardless of $SHELL, so it is robust to a
1182        // concurrent temp-env writer and needs no serialization of its own.
1183        let (shell, flag) = BuiltinTools::detect_shell();
1184        assert!(!shell.is_empty());
1185        assert!(!flag.is_empty());
1186        #[cfg(not(windows))]
1187        assert_eq!(flag, "-c");
1188    }
1189
1190    /// Forces `detect_shell()` to exercise the real `shell_exists` closure by
1191    /// temporarily setting $SHELL to an unrecognized path, causing the candidate
1192    /// loop (and the closure) to be reached. `temp_env::with_var` sets the var,
1193    /// runs the closure, and restores it - serialized against every other
1194    /// temp-env test process-wide, so no hand-rolled lock is needed.
1195    #[cfg(not(windows))]
1196    #[test]
1197    fn detect_shell_queries_real_filesystem_for_unrecognized_shell() {
1198        let (shell, flag) =
1199            temp_env::with_var("SHELL", Some("/opt/not-a-recognized-shell"), || {
1200                BuiltinTools::detect_shell()
1201            });
1202        assert_eq!(flag, "-c");
1203        assert!(
1204            [
1205                "/bin/bash",
1206                "/usr/bin/bash",
1207                "/bin/zsh",
1208                "/usr/bin/zsh",
1209                "/bin/sh"
1210            ]
1211            .contains(&shell)
1212        );
1213    }
1214
1215    // ── detect_shell_impl() - inject env and filesystem for full branch coverage ──
1216
1217    #[cfg(not(windows))]
1218    #[test]
1219    fn detect_shell_impl_returns_zsh_from_env() {
1220        // `$SHELL` is trusted only when it exists on disk.
1221        let (shell, flag) =
1222            BuiltinTools::detect_shell_impl(Some("/usr/local/bin/zsh".to_string()), &|s| {
1223                s == "/usr/local/bin/zsh"
1224            });
1225        assert_eq!(shell, "/usr/local/bin/zsh");
1226        assert_eq!(flag, "-c");
1227    }
1228
1229    #[cfg(not(windows))]
1230    #[test]
1231    fn detect_shell_impl_returns_bash_from_env() {
1232        let (shell, flag) =
1233            BuiltinTools::detect_shell_impl(Some("/usr/local/bin/bash".to_string()), &|s| {
1234                s == "/usr/local/bin/bash"
1235            });
1236        assert_eq!(shell, "/usr/local/bin/bash");
1237        assert_eq!(flag, "-c");
1238    }
1239
1240    #[cfg(not(windows))]
1241    #[test]
1242    fn detect_shell_impl_returns_sh_from_env() {
1243        let (shell, flag) =
1244            BuiltinTools::detect_shell_impl(Some("/usr/bin/sh".to_string()), &|s| {
1245                s == "/usr/bin/sh"
1246            });
1247        assert_eq!(shell, "/usr/bin/sh");
1248        assert_eq!(flag, "-c");
1249    }
1250
1251    #[cfg(not(windows))]
1252    #[test]
1253    fn detect_shell_impl_falls_back_when_env_shell_is_missing() {
1254        // Regression for #79: `$SHELL` is a recognized shell name but does not
1255        // exist on disk (a stale or sandbox-missing `/bin/zsh`). It must NOT be
1256        // returned - fall through to an available fallback instead of failing
1257        // every shell call with "No such file or directory".
1258        let (shell, flag) =
1259            BuiltinTools::detect_shell_impl(Some("/bin/zsh".to_string()), &|s| s == "/bin/sh");
1260        assert_eq!(shell, "/bin/sh");
1261        assert_eq!(flag, "-c");
1262    }
1263
1264    #[cfg(not(windows))]
1265    #[test]
1266    fn detect_shell_impl_falls_through_when_env_unrecognized() {
1267        // /opt/fish doesn't end with /zsh, /bash, or /sh → falls to candidate loop
1268        let (shell, flag) =
1269            BuiltinTools::detect_shell_impl(Some("/opt/fish".to_string()), &|s| s == "/bin/bash");
1270        assert_eq!(shell, "/bin/bash");
1271        assert_eq!(flag, "-c");
1272    }
1273
1274    #[cfg(not(windows))]
1275    #[test]
1276    fn detect_shell_impl_skips_missing_candidates_and_finds_zsh() {
1277        // bash paths return false; /bin/zsh exists - covers shell_exists false branch
1278        let (shell, flag) = BuiltinTools::detect_shell_impl(None, &|s| s == "/bin/zsh");
1279        assert_eq!(shell, "/bin/zsh");
1280        assert_eq!(flag, "-c");
1281    }
1282
1283    #[cfg(not(windows))]
1284    #[test]
1285    fn detect_shell_impl_returns_last_resort_when_nothing_exists() {
1286        let (shell, flag) = BuiltinTools::detect_shell_impl(None, &|_| false);
1287        assert_eq!(shell, "sh");
1288        assert_eq!(flag, "-c");
1289    }
1290
1291    #[tokio::test]
1292    async fn concurrent_edits_same_file_serialize_no_lost_update() {
1293        // Two workers edit different unique strings in the SAME file at once.
1294        // The per-path lock serializes the read-modify-write, so both edits
1295        // land; without it, the second write would clobber the first.
1296        let dir = tempfile::tempdir().unwrap();
1297        std::fs::write(dir.path().join("f.txt"), "A\nB\n").unwrap();
1298        let tools = std::sync::Arc::new(make_tools(dir.path()));
1299
1300        let t1 = {
1301            let t = tools.clone();
1302            tokio::spawn(async move {
1303                t.execute(
1304                    "edit_file",
1305                    json!({"path": "f.txt", "old_str": "A", "new_str": "A1"}),
1306                )
1307                .await
1308            })
1309        };
1310        let t2 = {
1311            let t = tools.clone();
1312            tokio::spawn(async move {
1313                t.execute(
1314                    "edit_file",
1315                    json!({"path": "f.txt", "old_str": "B", "new_str": "B2"}),
1316                )
1317                .await
1318            })
1319        };
1320        let (r1, r2) = tokio::join!(t1, t2);
1321        assert!(!r1.unwrap().starts_with("[error]"));
1322        assert!(!r2.unwrap().starts_with("[error]"));
1323
1324        let final_content = std::fs::read_to_string(dir.path().join("f.txt")).unwrap();
1325        assert_eq!(
1326            final_content, "A1\nB2\n",
1327            "both concurrent edits must apply (no lost update)"
1328        );
1329    }
1330
1331    #[tokio::test]
1332    async fn concurrent_writes_different_files_both_succeed() {
1333        // Different files never contend on the per-path lock.
1334        let dir = tempfile::tempdir().unwrap();
1335        let tools = std::sync::Arc::new(make_tools(dir.path()));
1336
1337        let a = {
1338            let t = tools.clone();
1339            tokio::spawn(async move {
1340                t.execute("write_file", json!({"path": "a.txt", "content": "AAA"}))
1341                    .await
1342            })
1343        };
1344        let b = {
1345            let t = tools.clone();
1346            tokio::spawn(async move {
1347                t.execute("write_file", json!({"path": "b.txt", "content": "BBB"}))
1348                    .await
1349            })
1350        };
1351        let (ra, rb) = tokio::join!(a, b);
1352        assert!(!ra.unwrap().starts_with("[error]"));
1353        assert!(!rb.unwrap().starts_with("[error]"));
1354        assert_eq!(
1355            std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
1356            "AAA"
1357        );
1358        assert_eq!(
1359            std::fs::read_to_string(dir.path().join("b.txt")).unwrap(),
1360            "BBB"
1361        );
1362    }
1363
1364    // ── Platform capabilities ─────────────────────────────────────────────
1365
1366    #[test]
1367    fn desktop_supports_all_capabilities() {
1368        let caps = PlatformCapabilities::desktop();
1369        assert!(caps.supports(ToolCapability::ProcessSpawn));
1370        assert!(caps.supports(ToolCapability::FileSystem));
1371        assert!(caps.supports(ToolCapability::Network));
1372    }
1373
1374    #[test]
1375    fn mobile_lacks_process_spawn() {
1376        let caps = PlatformCapabilities::mobile();
1377        assert!(!caps.supports(ToolCapability::ProcessSpawn));
1378        assert!(caps.supports(ToolCapability::FileSystem));
1379        assert!(caps.supports(ToolCapability::Network));
1380    }
1381
1382    #[test]
1383    fn current_matches_desktop_and_is_the_default() {
1384        // Only desktop targets are built today.
1385        assert_eq!(
1386            PlatformCapabilities::current(),
1387            PlatformCapabilities::desktop()
1388        );
1389        assert_eq!(
1390            PlatformCapabilities::default(),
1391            PlatformCapabilities::desktop()
1392        );
1393    }
1394
1395    #[test]
1396    fn satisfies_requires_all_and_empty_is_always_met() {
1397        let caps = PlatformCapabilities::mobile();
1398        assert!(caps.satisfies(&[]));
1399        assert!(caps.satisfies(&[ToolCapability::FileSystem]));
1400        assert!(!caps.satisfies(&[ToolCapability::ProcessSpawn]));
1401        // All-or-nothing: one unmet requirement fails the whole set.
1402        assert!(!caps.satisfies(&[ToolCapability::FileSystem, ToolCapability::ProcessSpawn]));
1403    }
1404
1405    #[test]
1406    fn from_capabilities_builds_explicit_set() {
1407        let caps = PlatformCapabilities::from_capabilities([ToolCapability::Network]);
1408        assert!(caps.supports(ToolCapability::Network));
1409        assert!(!caps.supports(ToolCapability::FileSystem));
1410    }
1411
1412    #[test]
1413    fn tool_required_capabilities_by_name() {
1414        assert_eq!(
1415            tool_required_capabilities("shell"),
1416            &[ToolCapability::ProcessSpawn]
1417        );
1418        assert_eq!(
1419            tool_required_capabilities("read_file"),
1420            &[ToolCapability::FileSystem]
1421        );
1422        // Runtime-handled / platform-agnostic tools require nothing.
1423        assert!(tool_required_capabilities("context_write").is_empty());
1424        assert!(tool_required_capabilities("present_for_review").is_empty());
1425        assert!(tool_required_capabilities("unknown_tool").is_empty());
1426    }
1427
1428    #[test]
1429    fn mobile_tool_defs_omit_shell_but_keep_the_rest() {
1430        let dir = std::env::temp_dir();
1431        let tools = make_mobile_tools(&dir);
1432        let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
1433        assert!(!names.contains(&"shell".to_string()));
1434        // The other 15 built-ins remain.
1435        assert_eq!(tools.tool_defs().len(), 15);
1436        assert!(names.contains(&"read_file".to_string()));
1437        assert!(names.contains(&"context_write".to_string()));
1438        assert!(names.contains(&"present_for_review".to_string()));
1439    }
1440
1441    #[test]
1442    fn desktop_tool_defs_include_shell() {
1443        let dir = std::env::temp_dir();
1444        let tools = make_tools(&dir);
1445        let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
1446        assert!(names.contains(&"shell".to_string()));
1447    }
1448
1449    #[test]
1450    fn mobile_names_omit_shell_and_bash_alias() {
1451        let dir = std::env::temp_dir();
1452        let tools = make_mobile_tools(&dir);
1453        let names = tools.names();
1454        assert!(!names.contains(&"shell".to_string()));
1455        assert!(!names.contains(&"bash".to_string()));
1456        // File + context tools still recognized.
1457        assert!(names.contains(&"read_file".to_string()));
1458        assert!(names.contains(&"context_write".to_string()));
1459    }
1460
1461    #[test]
1462    fn desktop_names_include_shell_and_bash_alias() {
1463        let dir = std::env::temp_dir();
1464        let tools = make_tools(&dir);
1465        let names = tools.names();
1466        assert!(names.contains(&"shell".to_string()));
1467        assert!(names.contains(&"bash".to_string()));
1468    }
1469
1470    #[tokio::test]
1471    async fn mobile_execute_shell_is_rejected() {
1472        let dir = tempfile::tempdir().unwrap();
1473        let tools = make_mobile_tools(dir.path());
1474        let out = tools.execute("shell", json!({"command": "echo hi"})).await;
1475        assert!(out.contains("not available on this platform"), "got: {out}");
1476        // The `bash` alias resolves to `shell` and is rejected the same way.
1477        let out = tools.execute("bash", json!({"command": "echo hi"})).await;
1478        assert!(out.contains("not available on this platform"), "got: {out}");
1479    }
1480
1481    #[tokio::test]
1482    async fn mobile_execute_file_tool_still_works() {
1483        let dir = tempfile::tempdir().unwrap();
1484        let tools = make_mobile_tools(dir.path());
1485        let out = tools
1486            .execute("write_file", json!({"path": "x.txt", "content": "hi"}))
1487            .await;
1488        assert!(!out.starts_with("[error]"), "got: {out}");
1489        assert_eq!(
1490            std::fs::read_to_string(dir.path().join("x.txt")).unwrap(),
1491            "hi"
1492        );
1493    }
1494}