1use 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
14mod context;
17mod defs;
18mod exec;
19mod platform;
20pub use context::*;
21pub use platform::*;
22
23pub struct BuiltinTools {
30 ctx: ToolContext,
31 platform: PlatformCapabilities,
32 shell_executor: Option<Arc<dyn ShellExecutor>>,
34}
35
36impl BuiltinTools {
37 pub fn new(ctx: ToolContext) -> Self {
40 Self {
41 ctx,
42 platform: PlatformCapabilities::current(),
43 shell_executor: None,
44 }
45 }
46
47 pub fn with_shell_executor(mut self, executor: Arc<dyn ShellExecutor>) -> Self {
50 self.shell_executor = Some(executor);
51 self
52 }
53
54 pub fn with_capabilities(ctx: ToolContext, platform: PlatformCapabilities) -> Self {
57 Self {
58 ctx,
59 platform,
60 shell_executor: None,
61 }
62 }
63
64 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 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 #[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 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 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 #[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 assert_eq!(canonical_tool_name("bash"), "shell");
283 assert_eq!(canonical_tool_name("shell"), "shell");
285 assert_eq!(canonical_tool_name("read_file"), "read_file");
286 assert_eq!(canonical_tool_name("acme__do_thing"), "acme__do_thing");
289 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 #[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 #[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 #[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 #[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 #[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 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 let out = tools.read_file(&json!({ "path": "link/etc/hosts" })).await;
406 assert!(out.contains("[error]"), "got: {out}");
407 }
408
409 #[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 #[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 #[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 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 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 #[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 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 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 assert!(result.contains("### [present.txt]"));
788 assert!(result.contains("here"));
789 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 #[test]
821 fn resolve_absolute_path_inside_workdir() {
822 let dir = tempfile::tempdir().unwrap();
823 let tools = make_tools(dir.path());
824 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 #[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 #[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 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 #[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 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 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 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 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 #[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 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 #[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 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 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 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 #[test]
1149 fn tool_context_new_canonicalizes() {
1150 let dir = std::env::temp_dir();
1151 let ctx = ToolContext::new(dir.clone());
1152 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 assert_eq!(ctx.workdir, PathBuf::from("/nonexistent/path/unlikely"));
1161 }
1162
1163 #[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 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 #[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 #[cfg(not(windows))]
1218 #[test]
1219 fn detect_shell_impl_returns_zsh_from_env() {
1220 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 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 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 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 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 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 #[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 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 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 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 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 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 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}