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;
20mod validate;
21pub use context::*;
22pub use defs::{SUBAGENT_TOOLS, is_subagent_tool};
23pub use platform::*;
24pub use validate::*;
25
26pub struct BuiltinTools {
33 ctx: ToolContext,
34 platform: PlatformCapabilities,
35 shell_executor: Option<Arc<dyn ShellExecutor>>,
37}
38
39impl BuiltinTools {
40 pub fn new(ctx: ToolContext) -> Self {
43 Self {
44 ctx,
45 platform: PlatformCapabilities::current(),
46 shell_executor: None,
47 }
48 }
49
50 pub fn with_shell_executor(mut self, executor: Arc<dyn ShellExecutor>) -> Self {
53 self.shell_executor = Some(executor);
54 self
55 }
56
57 pub fn with_capabilities(ctx: ToolContext, platform: PlatformCapabilities) -> Self {
60 Self {
61 ctx,
62 platform,
63 shell_executor: None,
64 }
65 }
66
67 fn available(&self, canonical_name: &str) -> bool {
69 self.platform
70 .satisfies(tool_required_capabilities(canonical_name))
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77 use std::fs;
78
79 fn make_tools(dir: &std::path::Path) -> BuiltinTools {
80 BuiltinTools::new(ToolContext::new(dir.to_path_buf()))
81 }
82
83 fn make_mobile_tools(dir: &std::path::Path) -> BuiltinTools {
86 BuiltinTools::with_capabilities(
87 ToolContext::new(dir.to_path_buf()),
88 PlatformCapabilities::mobile(),
89 )
90 }
91
92 #[test]
93 fn the_shell_tool_advertises_the_shell_this_host_resolved() {
94 let dir = tempfile::tempdir().unwrap();
98 let defs = make_tools(dir.path()).tool_defs();
99 let shell = defs
100 .iter()
101 .find(|t| t.name == "shell")
102 .expect("shell is advertised on a desktop capability set");
103 let (resolved, _) = BuiltinTools::detect_shell();
104 assert!(
105 shell.description.contains(resolved),
106 "description {:?} does not name the resolved shell {resolved:?}",
107 shell.description
108 );
109
110 assert!(crate::defs::shell_tool_description("cmd.exe").contains("`cmd.exe`"));
112 assert!(crate::defs::shell_tool_description("/bin/zsh").contains("`/bin/zsh`"));
113 }
114
115 #[test]
116 fn subagent_predicate_covers_the_five_names_and_nothing_else() {
117 for name in SUBAGENT_TOOLS {
118 assert!(is_subagent_tool(name));
119 }
120 assert!(!is_subagent_tool("read_file"));
121 }
122
123 #[test]
126 fn tool_defs_returns_sixteen_tools() {
127 let dir = std::env::temp_dir();
128 let tools = make_tools(&dir);
129 let defs = tools.tool_defs();
130 assert_eq!(defs.len(), 16);
131 }
132
133 #[test]
134 fn tool_defs_names_are_correct() {
135 let dir = std::env::temp_dir();
136 let tools = make_tools(&dir);
137 let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
138 assert!(names.contains(&"read_file".to_string()));
139 assert!(names.contains(&"read_files".to_string()));
140 assert!(names.contains(&"write_file".to_string()));
141 assert!(names.contains(&"edit_file".to_string()));
142 assert!(names.contains(&"list_dir".to_string()));
143 assert!(names.contains(&"shell".to_string()));
144 assert!(names.contains(&"present_for_review".to_string()));
145 assert!(names.contains(&"ask_user_text".to_string()));
146 assert!(names.contains(&"ask_user_choice".to_string()));
147 assert!(names.contains(&"ask_user_confirm".to_string()));
148 assert!(names.contains(&"edit_document".to_string()));
149 assert!(names.contains(&"context_write".to_string()));
150 assert!(names.contains(&"context_append".to_string()));
151 assert!(names.contains(&"context_read".to_string()));
152 assert!(names.contains(&"context_delete".to_string()));
153 assert!(names.contains(&"context_list".to_string()));
154 }
155
156 #[test]
157 fn tool_defs_edit_document_requires_content() {
158 let dir = std::env::temp_dir();
159 let tools = make_tools(&dir);
160 let def = tools
161 .tool_defs()
162 .into_iter()
163 .find(|t| t.name == "edit_document")
164 .expect("edit_document tool def must exist");
165 let required = def.parameters["required"].as_array().unwrap();
166 assert!(required.iter().any(|v| v == "content"));
167 assert_eq!(def.parameters["properties"]["content"]["type"], "string");
168 assert!(tools.names().contains(&"edit_document".to_string()));
170 }
171
172 #[test]
173 fn tool_defs_ask_user_choice_has_options_array() {
174 let dir = std::env::temp_dir();
175 let tools = make_tools(&dir);
176 let def = tools
177 .tool_defs()
178 .into_iter()
179 .find(|t| t.name == "ask_user_choice")
180 .unwrap();
181 let required = def.parameters["required"].as_array().unwrap();
182 assert!(required.iter().any(|v| v == "prompt"));
183 assert!(required.iter().any(|v| v == "options"));
184 assert_eq!(def.parameters["properties"]["options"]["type"], "array");
185 }
186
187 #[tokio::test]
188 async fn context_tools_return_runtime_error() {
189 let dir = std::env::temp_dir();
190 let tools = make_tools(&dir);
191 for name in [
192 "context_write",
193 "context_append",
194 "context_read",
195 "context_delete",
196 "context_list",
197 ] {
198 let result = tools.execute(name, serde_json::json!({})).await;
199 assert!(result.contains("context tools must be handled by the runtime"));
200 }
201 }
202
203 #[tokio::test]
204 async fn ask_user_tools_not_handled_by_builtin_execute() {
205 let dir = std::env::temp_dir();
208 let tools = make_tools(&dir);
209 for name in [
210 "ask_user_text",
211 "ask_user_choice",
212 "ask_user_confirm",
213 "edit_document",
214 ] {
215 let result = tools.execute(name, serde_json::json!({})).await;
216 assert!(result.contains("Unknown built-in tool"));
217 }
218 }
219
220 #[test]
221 fn context_tool_descriptions_mention_key_concepts() {
222 let dir = std::env::temp_dir();
223 let tools = make_tools(&dir);
224 let defs = tools.tool_defs();
225
226 let write_def = defs.iter().find(|t| t.name == "context_write").unwrap();
227 assert!(
228 write_def.description.contains("system prompt"),
229 "context_write should mention system prompt: {}",
230 write_def.description
231 );
232 assert!(
233 write_def.description.contains("replaced"),
234 "context_write should mention replacement: {}",
235 write_def.description
236 );
237
238 let read_def = defs.iter().find(|t| t.name == "context_read").unwrap();
239 assert!(
240 read_def.description.contains("summary"),
241 "context_read should mention summary: {}",
242 read_def.description
243 );
244
245 let list_def = defs.iter().find(|t| t.name == "context_list").unwrap();
246 assert!(
247 list_def.description.contains("token"),
248 "context_list should mention tokens: {}",
249 list_def.description
250 );
251
252 let append_def = defs.iter().find(|t| t.name == "context_append").unwrap();
253 assert!(
254 append_def.description.contains("without replacing"),
255 "context_append should mention 'without replacing': {}",
256 append_def.description
257 );
258 }
259
260 fn assert_has_description(name: &str, description: &str) {
261 assert!(
262 !description.is_empty(),
263 "tool {} has empty description",
264 name
265 );
266 }
267
268 fn assert_has_object_params(name: &str, params: &serde_json::Value) {
269 assert!(params.is_object(), "tool {} has non-object params", name);
270 }
271
272 #[test]
273 fn tool_defs_have_descriptions() {
274 let dir = std::env::temp_dir();
275 let tools = make_tools(&dir);
276 for def in tools.tool_defs() {
277 assert_has_description(&def.name, &def.description);
278 }
279 }
280
281 #[test]
282 #[should_panic(expected = "tool bogus has empty description")]
283 fn tool_defs_have_descriptions_panics_on_empty_description() {
284 assert_has_description("bogus", "");
285 }
286
287 #[test]
288 fn tool_defs_have_parameters() {
289 let dir = std::env::temp_dir();
290 let tools = make_tools(&dir);
291 for def in tools.tool_defs() {
292 assert_has_object_params(&def.name, &def.parameters);
293 }
294 }
295
296 #[test]
297 #[should_panic(expected = "tool bogus has non-object params")]
298 fn tool_defs_have_parameters_panics_on_non_object_params() {
299 assert_has_object_params("bogus", &serde_json::Value::Null);
300 }
301
302 #[test]
305 fn names_includes_bash_alias() {
306 let dir = std::env::temp_dir();
307 let tools = make_tools(&dir);
308 let names = tools.names();
309 assert!(names.contains(&"bash".to_string()));
310 assert!(names.contains(&"shell".to_string()));
311 }
312
313 #[test]
314 fn canonical_tool_name_resolves_aliases_and_passes_others_through() {
315 assert_eq!(canonical_tool_name("bash"), "shell");
317 assert_eq!(canonical_tool_name("shell"), "shell");
319 assert_eq!(canonical_tool_name("read_file"), "read_file");
320 assert_eq!(canonical_tool_name("acme__do_thing"), "acme__do_thing");
323 for (alias, canonical) in TOOL_ALIASES {
325 assert_eq!(canonical_tool_name(alias), *canonical);
326 }
327 }
328
329 #[test]
330 fn names_returns_seventeen_entries() {
331 let dir = std::env::temp_dir();
332 let tools = make_tools(&dir);
333 assert_eq!(tools.names().len(), 17);
334 }
335
336 #[test]
339 fn subagent_tool_defs_returns_five_tools() {
340 let defs = BuiltinTools::subagent_tool_defs();
341 assert_eq!(defs.len(), 5);
342 }
343
344 #[test]
345 fn subagent_tool_names_returns_five_names() {
346 let names = BuiltinTools::subagent_tool_names();
347 assert_eq!(names.len(), 5);
348 assert!(names.contains(&"spawn_agent".to_string()));
349 assert!(names.contains(&"check_agent".to_string()));
350 assert!(names.contains(&"wait_for_agent".to_string()));
351 assert!(names.contains(&"send_to_agent".to_string()));
352 assert!(names.contains(&"kill_agent".to_string()));
353 }
354
355 #[test]
356 fn subagent_tool_defs_names_match_subagent_tool_names() {
357 let defs = BuiltinTools::subagent_tool_defs();
358 let names = BuiltinTools::subagent_tool_names();
359 let def_names: Vec<String> = defs.iter().map(|d| d.name.clone()).collect();
360 assert_eq!(def_names, names);
361 }
362
363 #[test]
366 fn resolve_relative_path() {
367 let dir = std::env::temp_dir();
368 let tools = make_tools(&dir);
369 let result = tools.resolve("hello.txt").unwrap();
370 assert!(result.starts_with(&tools.ctx.workdir));
371 assert!(result.ends_with("hello.txt"));
372 }
373
374 #[test]
375 fn resolve_rejects_path_escape() {
376 let dir = std::env::temp_dir().join("leviath_test_sandbox");
377 fs::create_dir_all(&dir).ok();
378 let tools = make_tools(&dir);
379 let result = tools.resolve("../../etc/passwd");
380 assert!(result.is_err());
381 }
382
383 #[test]
394 fn resolve_refuses_a_path_that_does_not_resolve_within_the_workdir() {
395 fn escapes(_: &Path, _: &Path) -> bool {
396 false
397 }
398 let dir = tempfile::tempdir().unwrap();
399 let err = BuiltinTools::resolve_within("notes.txt", dir.path(), escapes)
400 .expect_err("a path that resolves outside must be refused");
401 assert!(err.to_string().contains("symlink"), "{err}");
402 }
403
404 #[test]
407 fn resolve_admits_an_ordinary_path_within_the_workdir() {
408 let dir = tempfile::tempdir().unwrap();
409 let resolved =
410 BuiltinTools::resolve_within("notes.txt", dir.path(), leviath_core::resolves_within)
411 .expect("an ordinary path resolves");
412 assert!(resolved.ends_with("notes.txt"));
413 }
414
415 #[cfg(unix)]
420 #[tokio::test]
421 async fn resolve_rejects_symlink_escape() {
422 let dir = tempfile::tempdir().unwrap();
423 let workdir = dir.path().join("workspace");
424 fs::create_dir(&workdir).unwrap();
425 std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
426 let tools = make_tools(&workdir);
427
428 let normalized = tools.ctx.workdir.join("link/etc/hosts");
433 assert!(normalized.starts_with(&tools.ctx.workdir));
434
435 let err = tools.resolve("link/etc/hosts").unwrap_err().to_string();
436 assert!(err.contains("symlink"), "got: {err}");
437
438 let out = tools.read_file(&json!({ "path": "link/etc/hosts" })).await;
440 assert!(out.contains("[error]"), "got: {out}");
441 }
442
443 #[cfg(unix)]
446 #[tokio::test]
447 async fn write_file_rejects_symlink_escape() {
448 let dir = tempfile::tempdir().unwrap();
449 let outside = tempfile::tempdir().unwrap();
450 let workdir = dir.path().join("workspace");
451 fs::create_dir(&workdir).unwrap();
452 std::os::unix::fs::symlink(outside.path(), workdir.join("link")).unwrap();
453 let tools = make_tools(&workdir);
454
455 let out = tools
456 .write_file(&json!({ "path": "link/pwned.txt", "content": "x" }))
457 .await;
458 assert!(out.contains("[error]"), "got: {out}");
459 assert!(
460 !outside.path().join("pwned.txt").exists(),
461 "nothing may be written outside the workdir"
462 );
463 }
464
465 fn make_tools_with_read_paths(
471 workdir: &std::path::Path,
472 blueprint: &[&str],
473 grants: &[&str],
474 allow_blueprint: bool,
475 ) -> BuiltinTools {
476 let compile = |entries: &[&str]| {
477 let raw: Vec<String> = entries.iter().map(|s| s.to_string()).collect();
478 leviath_core::ReadPathSet::compile(&raw, workdir, None, false)
479 .expect("test entries compile")
480 };
481 let policy = leviath_core::ReadPathPolicy {
482 agent: "tester".into(),
483 blueprint: compile(blueprint),
484 grants: compile(grants),
485 allow_blueprint,
486 };
487 BuiltinTools::new(ToolContext::new(workdir.to_path_buf()).with_read_paths(policy))
488 }
489
490 #[tokio::test]
493 async fn read_tools_reach_a_declared_and_granted_outside_path() {
494 let dir = tempfile::tempdir().unwrap();
495 let outside = tempfile::tempdir().unwrap();
496 fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
497 let entry = outside.path().to_str().unwrap();
498 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
499
500 let target = outside.path().join("doc.md");
501 let target = target.to_str().unwrap();
502 let out = tools.read_file(&json!({ "path": target })).await;
503 assert_eq!(out, "outside contents");
504
505 let listed = tools
506 .list_dir(&json!({ "path": outside.path().to_str().unwrap() }))
507 .await;
508 assert!(listed.contains("doc.md"), "got: {listed}");
509
510 fs::write(dir.path().join("inside.txt"), "inside contents").unwrap();
512 let out = tools
513 .read_files(&json!({ "paths": ["inside.txt", target] }))
514 .await;
515 assert!(out.contains("inside contents"), "got: {out}");
516 assert!(out.contains("outside contents"), "got: {out}");
517 }
518
519 #[tokio::test]
523 async fn write_and_edit_stay_confined_despite_read_grants() {
524 let dir = tempfile::tempdir().unwrap();
525 let outside = tempfile::tempdir().unwrap();
526 fs::write(outside.path().join("doc.md"), "original").unwrap();
527 let entry = outside.path().to_str().unwrap();
528 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
529
530 let target = outside.path().join("doc.md");
531 let target = target.to_str().unwrap();
532 let out = tools
533 .write_file(&json!({ "path": target, "content": "clobbered" }))
534 .await;
535 assert!(out.contains("[error]"), "got: {out}");
536 let out = tools
537 .edit_file(&json!({ "path": target, "old_str": "original", "new_str": "x" }))
538 .await;
539 assert!(out.contains("[error]"), "got: {out}");
540 assert_eq!(
541 fs::read_to_string(outside.path().join("doc.md")).unwrap(),
542 "original",
543 "a read grant must never permit a write"
544 );
545 }
546
547 #[tokio::test]
550 async fn an_ungranted_declaration_is_refused_with_guidance() {
551 let dir = tempfile::tempdir().unwrap();
552 let outside = tempfile::tempdir().unwrap();
553 fs::write(outside.path().join("doc.md"), "secret").unwrap();
554 let entry = outside.path().to_str().unwrap();
555 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[], false);
556
557 let target = outside.path().join("doc.md");
558 let out = tools
559 .read_file(&json!({ "path": target.to_str().unwrap() }))
560 .await;
561 assert!(out.contains("[error]"), "got: {out}");
562 assert!(out.contains("does not grant"), "got: {out}");
563 assert!(out.contains("[agent_read_paths.tester]"), "got: {out}");
564 assert!(!out.contains("secret"), "content must not leak");
565 }
566
567 #[tokio::test]
570 async fn the_blanket_override_honors_declarations() {
571 let dir = tempfile::tempdir().unwrap();
572 let outside = tempfile::tempdir().unwrap();
573 fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
574 let entry = outside.path().to_str().unwrap();
575 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[], true);
576
577 let target = outside.path().join("doc.md");
578 let out = tools
579 .read_file(&json!({ "path": target.to_str().unwrap() }))
580 .await;
581 assert_eq!(out, "outside contents");
582
583 let undeclared = tempfile::tempdir().unwrap();
585 fs::write(undeclared.path().join("x.txt"), "x").unwrap();
586 let out = tools
587 .read_file(&json!({ "path": undeclared.path().join("x.txt").to_str().unwrap() }))
588 .await;
589 assert!(
590 out.contains("not in this agent's [read_paths]"),
591 "got: {out}"
592 );
593 }
594
595 #[tokio::test]
598 async fn an_inactive_policy_keeps_the_workdir_error() {
599 let dir = tempfile::tempdir().unwrap();
600 let tools = make_tools(dir.path());
601 let out = tools.read_file(&json!({ "path": "/etc/hosts" })).await;
602 assert!(
603 out.contains("would escape the working directory"),
604 "got: {out}"
605 );
606 }
607
608 #[tokio::test]
612 async fn a_relative_request_reaches_a_relative_grant() {
613 let parent = tempfile::tempdir().unwrap();
614 let workdir = parent.path().join("work");
615 let shared = parent.path().join("shared");
616 fs::create_dir_all(&workdir).unwrap();
617 fs::create_dir_all(&shared).unwrap();
618 fs::write(shared.join("doc.md"), "shared contents").unwrap();
619 let tools = make_tools_with_read_paths(&workdir, &["../shared"], &["../shared"], false);
620
621 let out = tools
622 .read_file(&json!({ "path": "../shared/doc.md" }))
623 .await;
624 assert_eq!(out, "shared contents");
625 }
626
627 #[tokio::test]
631 async fn a_dot_component_is_folded_in_the_fallback() {
632 let dir = tempfile::tempdir().unwrap();
633 let outside = tempfile::tempdir().unwrap();
634 fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
635 let entry = outside.path().to_str().unwrap();
636 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
637
638 let target = format!("{}/./doc.md", outside.path().to_str().unwrap());
639 let out = tools.read_file(&json!({ "path": target })).await;
640 assert_eq!(out, "outside contents");
641 }
642
643 #[test]
652 fn folding_past_the_root_is_unresolvable() {
653 let policy = leviath_core::ReadPathPolicy {
654 agent: "tester".into(),
655 allow_blueprint: true,
656 ..Default::default()
657 };
658 let err = BuiltinTools::resolve_outside(
659 "../../x",
660 Path::new("wd"),
661 &policy,
662 leviath_core::canonicalize_for_match,
663 )
664 .expect_err("popping past the top must be refused");
665 assert!(err.to_string().contains("cannot be resolved"), "{err}");
666 }
667
668 #[test]
672 fn an_unverifiable_path_is_refused() {
673 fn unverifiable(_: &Path) -> Option<PathBuf> {
674 None
675 }
676 let policy = leviath_core::ReadPathPolicy {
677 agent: "tester".into(),
678 allow_blueprint: true,
679 ..Default::default()
680 };
681 let err =
682 BuiltinTools::resolve_outside("/outside/x", Path::new("/w"), &policy, unverifiable)
683 .expect_err("an unverifiable path must be refused");
684 assert!(err.to_string().contains("cannot be verified"), "{err}");
685 }
686
687 #[cfg(unix)]
691 #[tokio::test]
692 async fn a_symlink_inside_a_granted_directory_cannot_escape_it() {
693 let dir = tempfile::tempdir().unwrap();
694 let granted = tempfile::tempdir().unwrap();
695 let secret_home = tempfile::tempdir().unwrap();
696 fs::write(secret_home.path().join("id_rsa"), "PRIVATE KEY").unwrap();
697 std::os::unix::fs::symlink(
698 secret_home.path().join("id_rsa"),
699 granted.path().join("innocent.md"),
700 )
701 .unwrap();
702 let entry = granted.path().to_str().unwrap();
703 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
704
705 let out = tools
706 .read_file(&json!({ "path": granted.path().join("innocent.md").to_str().unwrap() }))
707 .await;
708 assert!(out.contains("[error]"), "got: {out}");
709 assert!(!out.contains("PRIVATE KEY"), "content must not leak");
710 }
711
712 #[cfg(unix)]
716 #[tokio::test]
717 async fn a_glob_grant_is_symlink_safe() {
718 let dir = tempfile::tempdir().unwrap();
719 let granted = tempfile::tempdir().unwrap();
720 let secret_home = tempfile::tempdir().unwrap();
721 fs::write(secret_home.path().join("id_rsa"), "PRIVATE KEY").unwrap();
722 std::os::unix::fs::symlink(
723 secret_home.path().join("id_rsa"),
724 granted.path().join("innocent.md"),
725 )
726 .unwrap();
727 let canonical = fs::canonicalize(granted.path()).unwrap();
729 let entry = format!("glob:{}/**", canonical.display());
730 let tools = make_tools_with_read_paths(dir.path(), &[&entry], &[&entry], false);
731
732 let out = tools
733 .read_file(&json!({ "path": granted.path().join("innocent.md").to_str().unwrap() }))
734 .await;
735 assert!(out.contains("[error]"), "got: {out}");
736 assert!(!out.contains("PRIVATE KEY"), "content must not leak");
737
738 fs::write(granted.path().join("real.md"), "real contents").unwrap();
741 let out = tools
742 .read_file(&json!({ "path": granted.path().join("real.md").to_str().unwrap() }))
743 .await;
744 assert_eq!(out, "real contents");
745 }
746
747 #[cfg(unix)]
750 #[tokio::test]
751 async fn a_symlink_within_a_granted_directory_is_readable() {
752 let dir = tempfile::tempdir().unwrap();
753 let granted = tempfile::tempdir().unwrap();
754 fs::create_dir(granted.path().join("real")).unwrap();
755 fs::write(granted.path().join("real/doc.md"), "granted contents").unwrap();
756 std::os::unix::fs::symlink(granted.path().join("real"), granted.path().join("link"))
757 .unwrap();
758 let entry = granted.path().to_str().unwrap();
759 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
760
761 let out = tools
762 .read_file(&json!({ "path": granted.path().join("link/doc.md").to_str().unwrap() }))
763 .await;
764 assert_eq!(out, "granted contents");
765 }
766
767 #[cfg(unix)]
771 #[tokio::test]
772 async fn resolve_allows_symlink_within_workdir() {
773 let dir = tempfile::tempdir().unwrap();
774 let workdir = dir.path().join("workspace");
775 fs::create_dir(&workdir).unwrap();
776 fs::create_dir(workdir.join("real")).unwrap();
777 fs::write(workdir.join("real/file.txt"), "contents").unwrap();
778 std::os::unix::fs::symlink(workdir.join("real"), workdir.join("link")).unwrap();
779 let tools = make_tools(&workdir);
780
781 assert!(tools.resolve("link/file.txt").is_ok());
782 let out = tools.read_file(&json!({ "path": "link/file.txt" })).await;
783 assert_eq!(out, "contents");
784 }
785
786 #[test]
787 fn resolve_dot_stays_in_workdir() {
788 let dir = std::env::temp_dir();
789 let tools = make_tools(&dir);
790 let result = tools.resolve("./foo/./bar.txt").unwrap();
791 assert!(result.starts_with(&tools.ctx.workdir));
792 assert!(result.ends_with("foo/bar.txt"));
793 }
794
795 #[tokio::test]
798 async fn execute_unknown_tool_returns_error() {
799 let dir = std::env::temp_dir();
800 let tools = make_tools(&dir);
801 let result = tools.execute("nonexistent", json!({})).await;
802 assert!(result.contains("[error]"));
803 assert!(result.contains("Unknown built-in tool"));
804 }
805
806 #[tokio::test]
807 async fn read_file_missing_path_arg() {
808 let dir = std::env::temp_dir();
809 let tools = make_tools(&dir);
810 let result = tools.execute("read_file", json!({})).await;
811 assert!(result.contains("[error]"));
812 assert!(result.contains("missing 'path'"));
813 }
814
815 #[tokio::test]
816 async fn write_and_read_file_roundtrip() {
817 let dir = tempfile::tempdir().unwrap();
818 let tools = make_tools(dir.path());
819
820 let write_result = tools
821 .execute(
822 "write_file",
823 json!({"path": "test.txt", "content": "hello world"}),
824 )
825 .await;
826 assert!(write_result.contains("Successfully wrote"));
827 assert!(write_result.contains("11 bytes"));
828
829 let read_result = tools
830 .execute("read_file", json!({"path": "test.txt"}))
831 .await;
832 assert_eq!(read_result, "hello world");
833 }
834
835 #[tokio::test]
836 async fn write_file_creates_parent_dirs() {
837 let dir = tempfile::tempdir().unwrap();
838 let tools = make_tools(dir.path());
839
840 let result = tools
841 .execute(
842 "write_file",
843 json!({"path": "sub/dir/file.txt", "content": "nested"}),
844 )
845 .await;
846 assert!(result.contains("Successfully wrote"));
847 assert!(dir.path().join("sub/dir/file.txt").exists());
848 }
849
850 #[tokio::test]
851 async fn write_tools_refuse_to_resurrect_a_deleted_workspace() {
852 let dir = tempfile::tempdir().unwrap();
858 let workdir = dir.path().join("workspace");
859 fs::create_dir(&workdir).unwrap();
860 fs::write(workdir.join("a.txt"), "before").unwrap();
861 let tools = make_tools(&workdir);
862 fs::remove_dir_all(&workdir).unwrap();
863
864 for (tool, args) in [
865 ("write_file", json!({"path": "a.txt", "content": "after"})),
866 (
867 "edit_file",
868 json!({"path": "a.txt", "old_str": "before", "new_str": "after"}),
869 ),
870 ] {
871 let result = tools.execute(tool, args).await;
872 assert!(
873 result.contains("workspace") && result.contains("no longer accessible"),
874 "{tool} got: {result}"
875 );
876 }
877 assert!(!workdir.exists(), "the workspace must stay gone");
878 }
879
880 #[tokio::test]
881 async fn write_file_missing_content_arg() {
882 let dir = tempfile::tempdir().unwrap();
883 let tools = make_tools(dir.path());
884 let result = tools.execute("write_file", json!({"path": "f.txt"})).await;
885 assert!(result.contains("missing 'content'"));
886 }
887
888 #[tokio::test]
889 async fn write_file_missing_path_arg() {
890 let dir = tempfile::tempdir().unwrap();
891 let tools = make_tools(dir.path());
892 let result = tools.execute("write_file", json!({"content": "x"})).await;
893 assert!(result.contains("missing 'path'"));
894 }
895
896 #[test]
897 fn resolve_rejects_excessive_parent_dir_traversal() {
898 let tools = BuiltinTools::new(ToolContext::new(PathBuf::from(
913 "leviath-nonexistent-relative-workdir",
914 )));
915 let result = tools.resolve("../../etc/passwd");
916 assert!(result.is_err());
917 assert!(
918 result
919 .unwrap_err()
920 .to_string()
921 .contains("escapes the working directory")
922 );
923 }
924
925 #[tokio::test]
926 async fn edit_file_successful_replacement() {
927 let dir = tempfile::tempdir().unwrap();
928 let tools = make_tools(dir.path());
929
930 tools
931 .execute(
932 "write_file",
933 json!({"path": "e.txt", "content": "foo bar baz"}),
934 )
935 .await;
936
937 let result = tools
938 .execute(
939 "edit_file",
940 json!({"path": "e.txt", "old_str": "bar", "new_str": "qux"}),
941 )
942 .await;
943 assert!(result.contains("Successfully edited"));
944
945 let content = tools.execute("read_file", json!({"path": "e.txt"})).await;
946 assert_eq!(content, "foo qux baz");
947 }
948
949 #[tokio::test]
950 async fn edit_file_string_not_found() {
951 let dir = tempfile::tempdir().unwrap();
952 let tools = make_tools(dir.path());
953
954 tools
955 .execute("write_file", json!({"path": "e.txt", "content": "abc"}))
956 .await;
957
958 let result = tools
959 .execute(
960 "edit_file",
961 json!({"path": "e.txt", "old_str": "xyz", "new_str": "123"}),
962 )
963 .await;
964 assert!(result.contains("String not found"));
965 }
966
967 #[tokio::test]
968 async fn edit_file_missing_file_returns_read_error() {
969 let dir = tempfile::tempdir().unwrap();
970 let tools = make_tools(dir.path());
971
972 let result = tools
973 .execute(
974 "edit_file",
975 json!({"path": "does-not-exist.txt", "old_str": "a", "new_str": "b"}),
976 )
977 .await;
978 assert!(result.contains("[error]"));
979 assert!(result.contains("Failed to read"));
980 }
981
982 #[tokio::test]
983 async fn edit_file_multiple_occurrences() {
984 let dir = tempfile::tempdir().unwrap();
985 let tools = make_tools(dir.path());
986
987 tools
988 .execute("write_file", json!({"path": "e.txt", "content": "aaa aaa"}))
989 .await;
990
991 let result = tools
992 .execute(
993 "edit_file",
994 json!({"path": "e.txt", "old_str": "aaa", "new_str": "bbb"}),
995 )
996 .await;
997 assert!(result.contains("2 occurrences"));
998 assert!(result.contains("must be unique"));
999 }
1000
1001 #[tokio::test]
1002 async fn edit_file_missing_args() {
1003 let dir = tempfile::tempdir().unwrap();
1004 let tools = make_tools(dir.path());
1005
1006 let r1 = tools.execute("edit_file", json!({})).await;
1007 assert!(r1.contains("missing 'path'"));
1008
1009 let r2 = tools.execute("edit_file", json!({"path": "f.txt"})).await;
1010 assert!(r2.contains("missing 'old_str'"));
1011
1012 let r3 = tools
1013 .execute("edit_file", json!({"path": "f.txt", "old_str": "x"}))
1014 .await;
1015 assert!(r3.contains("missing 'new_str'"));
1016 }
1017
1018 #[tokio::test]
1019 async fn list_dir_contents() {
1020 let dir = tempfile::tempdir().unwrap();
1021 let tools = make_tools(dir.path());
1022
1023 fs::write(dir.path().join("a.txt"), "hello").unwrap();
1024 fs::create_dir(dir.path().join("subdir")).unwrap();
1025
1026 let result = tools.execute("list_dir", json!({})).await;
1027 assert!(result.contains("a.txt"));
1028 assert!(result.contains("subdir/"));
1029 }
1030
1031 #[tokio::test]
1032 async fn list_dir_empty() {
1033 let dir = tempfile::tempdir().unwrap();
1034 let tools = make_tools(dir.path());
1035 let result = tools.execute("list_dir", json!({})).await;
1036 assert!(result.contains("empty directory"));
1037 }
1038
1039 #[tokio::test]
1040 async fn list_dir_with_path() {
1041 let dir = tempfile::tempdir().unwrap();
1042 let tools = make_tools(dir.path());
1043
1044 fs::create_dir(dir.path().join("sub")).unwrap();
1045 fs::write(dir.path().join("sub/inner.txt"), "data").unwrap();
1046
1047 let result = tools.execute("list_dir", json!({"path": "sub"})).await;
1048 assert!(result.contains("inner.txt"));
1049 }
1050
1051 #[tokio::test]
1052 async fn read_file_nonexistent() {
1053 let dir = tempfile::tempdir().unwrap();
1054 let tools = make_tools(dir.path());
1055 let result = tools
1056 .execute("read_file", json!({"path": "nope.txt"}))
1057 .await;
1058 assert!(result.contains("[error]"));
1059 assert!(result.contains("Failed to read"));
1060 }
1061
1062 #[tokio::test]
1065 async fn read_files_multiple_valid_files() {
1066 let dir = tempfile::tempdir().unwrap();
1067 let tools = make_tools(dir.path());
1068 fs::write(dir.path().join("a.txt"), "alpha").unwrap();
1069 fs::write(dir.path().join("b.txt"), "beta").unwrap();
1070
1071 let result = tools
1072 .execute("read_files", json!({"paths": ["a.txt", "b.txt"]}))
1073 .await;
1074 assert!(result.contains("### [a.txt]"));
1075 assert!(result.contains("alpha"));
1076 assert!(result.contains("### [b.txt]"));
1077 assert!(result.contains("beta"));
1078 assert!(result.contains("\n\n"));
1080 }
1081
1082 #[tokio::test]
1083 async fn read_files_missing_paths_arg() {
1084 let dir = tempfile::tempdir().unwrap();
1085 let tools = make_tools(dir.path());
1086 let result = tools.execute("read_files", json!({})).await;
1087 assert!(result.contains("[error]"));
1088 assert!(result.contains("missing 'paths'"));
1089 }
1090
1091 #[tokio::test]
1092 async fn read_files_non_array_paths_arg() {
1093 let dir = tempfile::tempdir().unwrap();
1094 let tools = make_tools(dir.path());
1095 let result = tools.execute("read_files", json!({"paths": "a.txt"})).await;
1097 assert!(result.contains("[error]"));
1098 assert!(result.contains("missing 'paths'"));
1099 }
1100
1101 #[tokio::test]
1102 async fn read_files_empty_paths_array() {
1103 let dir = tempfile::tempdir().unwrap();
1104 let tools = make_tools(dir.path());
1105 let result = tools.execute("read_files", json!({"paths": []})).await;
1106 assert!(result.contains("[error]"));
1107 assert!(result.contains("empty"));
1108 }
1109
1110 #[tokio::test]
1111 async fn read_files_missing_file_reports_per_file_error() {
1112 let dir = tempfile::tempdir().unwrap();
1113 let tools = make_tools(dir.path());
1114 fs::write(dir.path().join("present.txt"), "here").unwrap();
1115
1116 let result = tools
1117 .execute(
1118 "read_files",
1119 json!({"paths": ["present.txt", "absent.txt"]}),
1120 )
1121 .await;
1122 assert!(result.contains("### [present.txt]"));
1124 assert!(result.contains("here"));
1125 assert!(result.contains("### [absent.txt]"));
1127 assert!(result.contains("Failed to read"));
1128 }
1129
1130 #[tokio::test]
1131 async fn read_files_non_string_element_reports_error() {
1132 let dir = tempfile::tempdir().unwrap();
1133 let tools = make_tools(dir.path());
1134 fs::write(dir.path().join("ok.txt"), "content").unwrap();
1135
1136 let result = tools
1137 .execute("read_files", json!({"paths": ["ok.txt", 42]}))
1138 .await;
1139 assert!(result.contains("content"));
1140 assert!(result.contains("non-string path in array"));
1141 }
1142
1143 #[tokio::test]
1144 async fn read_files_path_escape_reported_per_file() {
1145 let dir = tempfile::tempdir().unwrap();
1146 let tools = make_tools(dir.path());
1147 let result = tools
1148 .execute("read_files", json!({"paths": ["../../etc/passwd"]}))
1149 .await;
1150 assert!(result.contains("### [../../etc/passwd]"));
1151 assert!(result.contains("escape"));
1152 }
1153
1154 #[test]
1157 fn resolve_absolute_path_inside_workdir() {
1158 let dir = tempfile::tempdir().unwrap();
1159 let tools = make_tools(dir.path());
1160 let abs = tools.ctx.workdir.join("inside.txt");
1164 let result = tools.resolve(abs.to_str().unwrap()).unwrap();
1165 assert_eq!(result, abs);
1166 }
1167
1168 #[test]
1169 fn resolve_rejects_absolute_path_outside_workdir() {
1170 let dir = tempfile::tempdir().unwrap();
1171 let tools = make_tools(dir.path());
1172 let result = tools.resolve("/etc/passwd");
1173 assert!(result.is_err());
1174 }
1175
1176 #[tokio::test]
1179 async fn read_file_path_escape_rejected() {
1180 let dir = tempfile::tempdir().unwrap();
1181 let tools = make_tools(dir.path());
1182 let result = tools
1183 .execute("read_file", json!({"path": "../../etc/passwd"}))
1184 .await;
1185 assert!(result.contains("[error]"));
1186 assert!(result.contains("escape"));
1187 }
1188
1189 #[tokio::test]
1190 async fn write_file_path_escape_rejected() {
1191 let dir = tempfile::tempdir().unwrap();
1192 let tools = make_tools(dir.path());
1193 let result = tools
1194 .execute(
1195 "write_file",
1196 json!({"path": "../../evil.txt", "content": "x"}),
1197 )
1198 .await;
1199 assert!(result.contains("[error]"));
1200 assert!(result.contains("escape"));
1201 }
1202
1203 #[tokio::test]
1204 async fn edit_file_path_escape_rejected() {
1205 let dir = tempfile::tempdir().unwrap();
1206 let tools = make_tools(dir.path());
1207 let result = tools
1208 .execute(
1209 "edit_file",
1210 json!({"path": "../../evil.txt", "old_str": "a", "new_str": "b"}),
1211 )
1212 .await;
1213 assert!(result.contains("[error]"));
1214 assert!(result.contains("escape"));
1215 }
1216
1217 #[tokio::test]
1218 async fn list_dir_path_escape_rejected() {
1219 let dir = tempfile::tempdir().unwrap();
1220 let tools = make_tools(dir.path());
1221 let result = tools.execute("list_dir", json!({"path": "../../"})).await;
1222 assert!(result.contains("[error]"));
1223 assert!(result.contains("escape"));
1224 }
1225
1226 #[tokio::test]
1229 async fn write_file_fails_when_path_is_a_directory() {
1230 let dir = tempfile::tempdir().unwrap();
1231 let tools = make_tools(dir.path());
1232 fs::create_dir(dir.path().join("adir")).unwrap();
1233
1234 let result = tools
1235 .execute("write_file", json!({"path": "adir", "content": "x"}))
1236 .await;
1237 assert!(result.contains("[error]"));
1238 assert!(result.contains("Failed to write"));
1239 }
1240
1241 #[tokio::test]
1242 async fn write_file_parent_dir_creation_fails_when_blocked_by_file() {
1243 let dir = tempfile::tempdir().unwrap();
1244 let tools = make_tools(dir.path());
1245 fs::write(dir.path().join("blocker"), "im a file").unwrap();
1247
1248 let result = tools
1249 .execute(
1250 "write_file",
1251 json!({"path": "blocker/nested.txt", "content": "x"}),
1252 )
1253 .await;
1254 assert!(result.contains("[error]"));
1255 assert!(result.contains("Failed to create directories"));
1256 }
1257
1258 #[tokio::test]
1259 async fn read_file_fails_when_path_is_a_directory() {
1260 let dir = tempfile::tempdir().unwrap();
1261 let tools = make_tools(dir.path());
1262 fs::create_dir(dir.path().join("adir")).unwrap();
1263
1264 let result = tools.execute("read_file", json!({"path": "adir"})).await;
1265 assert!(result.contains("[error]"));
1266 assert!(result.contains("Failed to read"));
1267 }
1268
1269 #[tokio::test]
1270 async fn list_dir_fails_when_path_is_a_file() {
1271 let dir = tempfile::tempdir().unwrap();
1272 let tools = make_tools(dir.path());
1273 fs::write(dir.path().join("afile.txt"), "content").unwrap();
1274
1275 let result = tools
1276 .execute("list_dir", json!({"path": "afile.txt"}))
1277 .await;
1278 assert!(result.contains("[error]"));
1279 assert!(result.contains("Failed to read directory"));
1280 }
1281
1282 #[allow(clippy::permissions_set_readonly_false)]
1286 #[tokio::test]
1287 async fn edit_file_write_failure_after_successful_match() {
1288 let dir = tempfile::tempdir().unwrap();
1289 let tools = make_tools(dir.path());
1290 let file_path = dir.path().join("ro.txt");
1291 fs::write(&file_path, "hello world").unwrap();
1292
1293 let mut perms = fs::metadata(&file_path).unwrap().permissions();
1298 perms.set_readonly(true);
1299 fs::set_permissions(&file_path, perms).unwrap();
1300
1301 let result = tools
1302 .execute(
1303 "edit_file",
1304 json!({"path": "ro.txt", "old_str": "hello", "new_str": "goodbye"}),
1305 )
1306 .await;
1307
1308 let mut perms = fs::metadata(&file_path).unwrap().permissions();
1310 perms.set_readonly(false);
1311 fs::set_permissions(&file_path, perms).unwrap();
1312
1313 assert!(result.contains("[error]"));
1314 assert!(result.contains("Failed to write"));
1315 }
1316
1317 #[tokio::test]
1318 async fn shell_echo_command() {
1319 let dir = tempfile::tempdir().unwrap();
1320 let tools = make_tools(dir.path());
1321 let result = tools
1322 .execute("shell", json!({"command": "echo hello"}))
1323 .await;
1324 assert!(result.trim().contains("hello"));
1325 }
1326
1327 #[tokio::test]
1328 async fn bash_alias_works() {
1329 let dir = tempfile::tempdir().unwrap();
1330 let tools = make_tools(dir.path());
1331 let result = tools
1332 .execute("bash", json!({"command": "echo alias_test"}))
1333 .await;
1334 assert!(result.contains("alias_test"));
1335 }
1336
1337 #[tokio::test]
1338 async fn shell_missing_command_arg() {
1339 let dir = tempfile::tempdir().unwrap();
1340 let tools = make_tools(dir.path());
1341 let result = tools.execute("shell", json!({})).await;
1342 assert!(result.contains("missing 'command'"));
1343 }
1344
1345 struct RedirectExecutor;
1348 impl ShellExecutor for RedirectExecutor {
1349 fn build_command(
1350 &self,
1351 shell: &str,
1352 flag: &str,
1353 _command: &str,
1354 workdir: &Path,
1355 ) -> Command {
1356 let mut c = Command::new(shell);
1357 c.arg(flag).arg("echo SANDBOXED").current_dir(workdir);
1358 c
1359 }
1360 }
1361
1362 #[tokio::test]
1363 async fn shell_routes_through_executor_when_present() {
1364 let dir = tempfile::tempdir().unwrap();
1365 let tools = BuiltinTools::new(ToolContext::new(dir.path().to_path_buf()))
1366 .with_shell_executor(Arc::new(RedirectExecutor));
1367 let result = tools
1369 .execute("shell", json!({"command": "echo host"}))
1370 .await;
1371 assert!(result.contains("SANDBOXED"), "got: {result}");
1372 assert!(!result.contains("host"));
1373 }
1374
1375 #[tokio::test]
1376 async fn shell_failing_command() {
1377 let dir = tempfile::tempdir().unwrap();
1378 let tools = make_tools(dir.path());
1379 let result = tools.execute("shell", json!({"command": "false"})).await;
1380 assert!(result.contains("[exit code"));
1381 }
1382
1383 #[tokio::test]
1384 async fn shell_successful_command_with_no_output() {
1385 let dir = tempfile::tempdir().unwrap();
1386 let tools = make_tools(dir.path());
1387 let result = tools.execute("shell", json!({"command": "true"})).await;
1388 assert_eq!(result, "(command succeeded with no output)");
1389 }
1390
1391 #[test]
1398 fn format_command_output_non_zero_exit_reports_stdout_and_stderr() {
1399 let result = BuiltinTools::format_command_output(b"out-line\n", b"err-line\n", false, 1);
1400 assert!(result.contains("[exit code 1]"));
1401 assert!(result.contains("stdout:"));
1402 assert!(result.contains("out-line"));
1403 assert!(result.contains("stderr:"));
1404 assert!(result.contains("err-line"));
1405 }
1406
1407 #[test]
1408 fn format_command_output_non_zero_exit_omits_empty_streams() {
1409 let result = BuiltinTools::format_command_output(b" \n", b"", false, 2);
1412 assert_eq!(result, "[exit code 2]\n");
1413 }
1414
1415 #[test]
1416 fn format_command_output_success_with_output_returns_stdout() {
1417 let result = BuiltinTools::format_command_output(b"hello\n", b"", true, 0);
1418 assert_eq!(result, "hello\n");
1419 }
1420
1421 #[test]
1422 fn format_command_output_success_no_output() {
1423 let result = BuiltinTools::format_command_output(b" ", b"noise", true, 0);
1424 assert_eq!(result, "(command succeeded with no output)");
1425 }
1426
1427 #[tokio::test]
1428 async fn shell_with_timeout_fires_on_slow_command() {
1429 let dir = tempfile::tempdir().unwrap();
1430 let tools = make_tools(dir.path());
1431 let result = tools
1432 .shell_with_timeout(&json!({"command": "sleep 5"}), Duration::from_millis(100))
1433 .await;
1434 assert!(result.contains("[timed out]"));
1435 }
1436
1437 #[cfg(unix)]
1444 #[tokio::test]
1445 async fn a_timed_out_command_kills_its_grandchildren() {
1446 let dir = tempfile::tempdir().unwrap();
1447 let marker = dir.path().join("survived");
1448 let tools = make_tools(dir.path());
1449
1450 let cmd = format!("( sleep 2; touch {} ) & sleep 30", marker.display());
1455 let result = tools
1456 .shell_with_timeout(&json!({ "command": cmd }), Duration::from_millis(100))
1457 .await;
1458 assert!(result.contains("[timed out]"), "got: {result}");
1459
1460 tokio::time::sleep(Duration::from_secs(3)).await;
1462 assert!(
1463 !marker.exists(),
1464 "the grandchild outlived the command that started it"
1465 );
1466 }
1467
1468 #[tokio::test]
1469 async fn shell_spawn_failure_when_workdir_missing() {
1470 let tools = make_tools(std::path::Path::new(
1475 "/definitely/does/not/exist/leviath-test",
1476 ));
1477 let result = tools.execute("shell", json!({"command": "echo hi"})).await;
1478 assert!(result.contains("[error]"));
1479 assert!(result.contains("Failed to spawn shell"));
1480 }
1481
1482 #[test]
1485 fn tool_context_new_canonicalizes() {
1486 let dir = std::env::temp_dir();
1487 let ctx = ToolContext::new(dir.clone());
1488 assert!(ctx.workdir.is_absolute());
1490 }
1491
1492 #[test]
1493 fn tool_context_new_with_nonexistent_dir() {
1494 let ctx = ToolContext::new(PathBuf::from("/nonexistent/path/unlikely"));
1495 assert_eq!(ctx.workdir, PathBuf::from("/nonexistent/path/unlikely"));
1497 }
1498
1499 #[test]
1506 fn detect_shell_returns_cmd_exe_on_windows() {
1507 let (shell, flag) = BuiltinTools::detect_shell_for("windows", None, &|_| true);
1508 assert_eq!(shell, "cmd.exe");
1509 assert_eq!(flag, "/C");
1510
1511 let (shell, _) =
1512 BuiltinTools::detect_shell_for("windows", Some("/usr/bin/bash".to_string()), &|_| true);
1513 assert_eq!(shell, "cmd.exe", "$SHELL is not consulted on Windows");
1514 }
1515
1516 #[test]
1517 fn detect_shell_returns_valid_shell() {
1518 let (shell, flag) = BuiltinTools::detect_shell();
1522 assert!(!shell.is_empty());
1523 assert!(!flag.is_empty());
1524 #[cfg(not(windows))]
1525 assert_eq!(flag, "-c");
1526 }
1527
1528 #[test]
1538 fn detect_shell_queries_the_real_filesystem_for_an_unrecognized_shell() {
1539 let (shell, flag) = BuiltinTools::detect_shell_for(
1540 "linux",
1541 Some("/opt/not-a-recognized-shell".to_string()),
1542 &BuiltinTools::shell_path_exists,
1543 );
1544 assert_eq!(flag, "-c");
1545 assert!(
1546 [
1547 "/bin/bash",
1548 "/usr/bin/bash",
1549 "/bin/zsh",
1550 "/usr/bin/zsh",
1551 "/bin/sh",
1552 "sh",
1553 ]
1554 .contains(&shell),
1555 "unexpected shell: {shell}"
1556 );
1557 }
1558
1559 #[test]
1562 fn detect_shell_for_returns_zsh_from_env() {
1563 let (shell, flag) =
1565 BuiltinTools::detect_shell_for("linux", Some("/usr/local/bin/zsh".to_string()), &|s| {
1566 s == "/usr/local/bin/zsh"
1567 });
1568 assert_eq!(shell, "/usr/local/bin/zsh");
1569 assert_eq!(flag, "-c");
1570 }
1571
1572 #[test]
1573 fn detect_shell_for_returns_bash_from_env() {
1574 let (shell, flag) = BuiltinTools::detect_shell_for(
1575 "macos",
1576 Some("/usr/local/bin/bash".to_string()),
1577 &|s| s == "/usr/local/bin/bash",
1578 );
1579 assert_eq!(shell, "/usr/local/bin/bash");
1580 assert_eq!(flag, "-c");
1581 }
1582
1583 #[test]
1584 fn detect_shell_for_returns_sh_from_env() {
1585 let (shell, flag) =
1588 BuiltinTools::detect_shell_for("freebsd", Some("/usr/bin/sh".to_string()), &|s| {
1589 s == "/usr/bin/sh"
1590 });
1591 assert_eq!(shell, "/usr/bin/sh");
1592 assert_eq!(flag, "-c");
1593 }
1594
1595 #[test]
1596 fn detect_shell_for_falls_back_when_env_shell_is_missing() {
1597 let (shell, flag) =
1602 BuiltinTools::detect_shell_for("linux", Some("/bin/zsh".to_string()), &|s| {
1603 s == "/bin/sh"
1604 });
1605 assert_eq!(shell, "/bin/sh");
1606 assert_eq!(flag, "-c");
1607 }
1608
1609 #[test]
1610 fn detect_shell_for_falls_through_when_env_unrecognized() {
1611 let (shell, flag) =
1613 BuiltinTools::detect_shell_for("linux", Some("/opt/fish".to_string()), &|s| {
1614 s == "/bin/bash"
1615 });
1616 assert_eq!(shell, "/bin/bash");
1617 assert_eq!(flag, "-c");
1618 }
1619
1620 #[test]
1621 fn detect_shell_for_skips_missing_candidates_and_finds_zsh() {
1622 let (shell, flag) = BuiltinTools::detect_shell_for("linux", None, &|s| s == "/bin/zsh");
1624 assert_eq!(shell, "/bin/zsh");
1625 assert_eq!(flag, "-c");
1626 }
1627
1628 #[test]
1629 fn detect_shell_for_returns_last_resort_when_nothing_exists() {
1630 let (shell, flag) = BuiltinTools::detect_shell_for("linux", None, &|_| false);
1631 assert_eq!(shell, "sh");
1632 assert_eq!(flag, "-c");
1633 }
1634
1635 #[tokio::test]
1636 async fn concurrent_edits_same_file_serialize_no_lost_update() {
1637 let dir = tempfile::tempdir().unwrap();
1641 std::fs::write(dir.path().join("f.txt"), "A\nB\n").unwrap();
1642 let tools = std::sync::Arc::new(make_tools(dir.path()));
1643
1644 let t1 = {
1645 let t = tools.clone();
1646 tokio::spawn(async move {
1647 t.execute(
1648 "edit_file",
1649 json!({"path": "f.txt", "old_str": "A", "new_str": "A1"}),
1650 )
1651 .await
1652 })
1653 };
1654 let t2 = {
1655 let t = tools.clone();
1656 tokio::spawn(async move {
1657 t.execute(
1658 "edit_file",
1659 json!({"path": "f.txt", "old_str": "B", "new_str": "B2"}),
1660 )
1661 .await
1662 })
1663 };
1664 let (r1, r2) = tokio::join!(t1, t2);
1665 assert!(!r1.unwrap().starts_with("[error]"));
1666 assert!(!r2.unwrap().starts_with("[error]"));
1667
1668 let final_content = std::fs::read_to_string(dir.path().join("f.txt")).unwrap();
1669 assert_eq!(
1670 final_content, "A1\nB2\n",
1671 "both concurrent edits must apply (no lost update)"
1672 );
1673 }
1674
1675 #[tokio::test]
1676 async fn concurrent_writes_different_files_both_succeed() {
1677 let dir = tempfile::tempdir().unwrap();
1679 let tools = std::sync::Arc::new(make_tools(dir.path()));
1680
1681 let a = {
1682 let t = tools.clone();
1683 tokio::spawn(async move {
1684 t.execute("write_file", json!({"path": "a.txt", "content": "AAA"}))
1685 .await
1686 })
1687 };
1688 let b = {
1689 let t = tools.clone();
1690 tokio::spawn(async move {
1691 t.execute("write_file", json!({"path": "b.txt", "content": "BBB"}))
1692 .await
1693 })
1694 };
1695 let (ra, rb) = tokio::join!(a, b);
1696 assert!(!ra.unwrap().starts_with("[error]"));
1697 assert!(!rb.unwrap().starts_with("[error]"));
1698 assert_eq!(
1699 std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
1700 "AAA"
1701 );
1702 assert_eq!(
1703 std::fs::read_to_string(dir.path().join("b.txt")).unwrap(),
1704 "BBB"
1705 );
1706 }
1707
1708 #[test]
1711 fn desktop_supports_all_capabilities() {
1712 let caps = PlatformCapabilities::desktop();
1713 assert!(caps.supports(ToolCapability::ProcessSpawn));
1714 assert!(caps.supports(ToolCapability::FileSystem));
1715 assert!(caps.supports(ToolCapability::Network));
1716 }
1717
1718 #[test]
1719 fn mobile_lacks_process_spawn() {
1720 let caps = PlatformCapabilities::mobile();
1721 assert!(!caps.supports(ToolCapability::ProcessSpawn));
1722 assert!(caps.supports(ToolCapability::FileSystem));
1723 assert!(caps.supports(ToolCapability::Network));
1724 }
1725
1726 #[test]
1727 fn current_matches_desktop_and_is_the_default() {
1728 assert_eq!(
1730 PlatformCapabilities::current(),
1731 PlatformCapabilities::desktop()
1732 );
1733 assert_eq!(
1734 PlatformCapabilities::default(),
1735 PlatformCapabilities::desktop()
1736 );
1737 }
1738
1739 #[test]
1740 fn satisfies_requires_all_and_empty_is_always_met() {
1741 let caps = PlatformCapabilities::mobile();
1742 assert!(caps.satisfies(&[]));
1743 assert!(caps.satisfies(&[ToolCapability::FileSystem]));
1744 assert!(!caps.satisfies(&[ToolCapability::ProcessSpawn]));
1745 assert!(!caps.satisfies(&[ToolCapability::FileSystem, ToolCapability::ProcessSpawn]));
1747 }
1748
1749 #[test]
1750 fn from_capabilities_builds_explicit_set() {
1751 let caps = PlatformCapabilities::from_capabilities([ToolCapability::Network]);
1752 assert!(caps.supports(ToolCapability::Network));
1753 assert!(!caps.supports(ToolCapability::FileSystem));
1754 }
1755
1756 #[test]
1757 fn tool_required_capabilities_by_name() {
1758 assert_eq!(
1759 tool_required_capabilities("shell"),
1760 &[ToolCapability::ProcessSpawn]
1761 );
1762 assert_eq!(
1763 tool_required_capabilities("read_file"),
1764 &[ToolCapability::FileSystem]
1765 );
1766 assert!(tool_required_capabilities("context_write").is_empty());
1768 assert!(tool_required_capabilities("present_for_review").is_empty());
1769 assert!(tool_required_capabilities("unknown_tool").is_empty());
1770 }
1771
1772 #[test]
1773 fn mobile_tool_defs_omit_shell_but_keep_the_rest() {
1774 let dir = std::env::temp_dir();
1775 let tools = make_mobile_tools(&dir);
1776 let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
1777 assert!(!names.contains(&"shell".to_string()));
1778 assert_eq!(tools.tool_defs().len(), 15);
1780 assert!(names.contains(&"read_file".to_string()));
1781 assert!(names.contains(&"context_write".to_string()));
1782 assert!(names.contains(&"present_for_review".to_string()));
1783 }
1784
1785 #[test]
1786 fn desktop_tool_defs_include_shell() {
1787 let dir = std::env::temp_dir();
1788 let tools = make_tools(&dir);
1789 let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
1790 assert!(names.contains(&"shell".to_string()));
1791 }
1792
1793 #[test]
1794 fn mobile_names_omit_shell_and_bash_alias() {
1795 let dir = std::env::temp_dir();
1796 let tools = make_mobile_tools(&dir);
1797 let names = tools.names();
1798 assert!(!names.contains(&"shell".to_string()));
1799 assert!(!names.contains(&"bash".to_string()));
1800 assert!(names.contains(&"read_file".to_string()));
1802 assert!(names.contains(&"context_write".to_string()));
1803 }
1804
1805 #[test]
1806 fn desktop_names_include_shell_and_bash_alias() {
1807 let dir = std::env::temp_dir();
1808 let tools = make_tools(&dir);
1809 let names = tools.names();
1810 assert!(names.contains(&"shell".to_string()));
1811 assert!(names.contains(&"bash".to_string()));
1812 }
1813
1814 #[tokio::test]
1815 async fn mobile_execute_shell_is_rejected() {
1816 let dir = tempfile::tempdir().unwrap();
1817 let tools = make_mobile_tools(dir.path());
1818 let out = tools.execute("shell", json!({"command": "echo hi"})).await;
1819 assert!(out.contains("not available on this platform"), "got: {out}");
1820 let out = tools.execute("bash", json!({"command": "echo hi"})).await;
1822 assert!(out.contains("not available on this platform"), "got: {out}");
1823 }
1824
1825 #[tokio::test]
1826 async fn mobile_execute_file_tool_still_works() {
1827 let dir = tempfile::tempdir().unwrap();
1828 let tools = make_mobile_tools(dir.path());
1829 let out = tools
1830 .execute("write_file", json!({"path": "x.txt", "content": "hi"}))
1831 .await;
1832 assert!(!out.starts_with("[error]"), "got: {out}");
1833 assert_eq!(
1834 std::fs::read_to_string(dir.path().join("x.txt")).unwrap(),
1835 "hi"
1836 );
1837 }
1838}