1use clap::Args;
4use std::path::Path;
5
6#[derive(Args)]
7pub struct AddArgs {
8 #[arg(value_name = "PACKAGE")]
10 pub package: String,
11}
12
13fn agents_dir_or_error(dir: Option<std::path::PathBuf>) -> anyhow::Result<std::path::PathBuf> {
14 dir.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
15}
16
17pub async fn execute(args: AddArgs) -> anyhow::Result<()> {
18 let installer = leviath_package::AgentInstaller::new();
19 let agents_dir = resolve_agents_dir()?;
20 let config = crate::config::Config::load().ok();
24 execute_with(&args, &installer, &agents_dir, config.as_ref()).await
25}
26
27fn resolve_agents_dir() -> anyhow::Result<std::path::PathBuf> {
40 #[cfg(test)]
41 if FORCE_AGENTS_DIR_ERROR.with(|f| f.get()) {
42 anyhow::bail!("Could not determine home directory");
43 }
44 agents_dir_or_error(leviath_core::paths::agents_dir())
45}
46
47#[cfg(test)]
48thread_local! {
49 static FORCE_AGENTS_DIR_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
52}
53
54async fn execute_with(
58 args: &AddArgs,
59 installer: &leviath_package::AgentInstaller,
60 agents_dir: &Path,
61 config: Option<&crate::config::Config>,
62) -> anyhow::Result<()> {
63 tracing::info!("Installing agent package");
64
65 let package_path = Path::new(&args.package);
66
67 if package_path.is_dir() {
68 install_from_dir(package_path, agents_dir, config)?;
70 } else if package_path.exists() || args.package.ends_with(".leviath-bundle") {
71 if !package_path.exists() {
73 anyhow::bail!("Package file not found: {}", args.package);
74 }
75 println!("Installing from bundle: {}", args.package);
76 let installed = installer.install(package_path)?;
77 println!(
78 "Installed agent '{}' v{} to {}",
79 installed.name,
80 installed.version,
81 installed.path.display()
82 );
83 print_capabilities(&installed.name, &installed.path, config);
84 } else {
85 anyhow::bail!(
88 "'{}' is not a local agent directory or a .leviath-bundle file - \
89 pass a path to one of those instead.",
90 args.package
91 );
92 }
93
94 Ok(())
95}
96
97pub(crate) fn describe_capabilities(
114 manifest_toml: &str,
115 script_tools: &[String],
116 read_paths: Option<&crate::read_path_report::GrantReport>,
117) -> Vec<String> {
118 let mut findings = Vec::new();
119 let Ok(value) = toml::from_str::<toml::Value>(manifest_toml) else {
125 return findings;
128 };
129
130 if !script_tools.is_empty() {
131 findings.push(format!(
132 "ships {} executable script tool(s): {}",
133 script_tools.len(),
134 script_tools.join(", ")
135 ));
136 }
137
138 let mut granted: Vec<String> = Vec::new();
140 let mut collect_grants = |table: Option<&toml::Value>| {
141 if let Some(t) = table.and_then(|v| v.as_table()) {
142 for (tool, policy) in t {
143 if policy.as_str() == Some("allow") && !granted.contains(tool) {
144 granted.push(tool.clone());
145 }
146 }
147 }
148 };
149 collect_grants(value.get("tool_permissions"));
150 if let Some(stages) = value.get("stages").and_then(|v| v.as_table()) {
151 for stage in stages.values() {
152 collect_grants(stage.get("tool_permissions"));
153 }
154 }
155 if !granted.is_empty() {
156 granted.sort();
157 findings.push(format!(
158 "pre-approves these tools (no prompt at run time): {}",
159 granted.join(", ")
160 ));
161 }
162
163 if let Some(t) = value
165 .get("tool_script_permissions")
166 .and_then(|v| v.as_table())
167 {
168 let mut allowed: Vec<&String> = t
169 .iter()
170 .filter(|(_, v)| v.as_str() == Some("allow"))
171 .map(|(k, _)| k)
172 .collect();
173 if !allowed.is_empty() {
174 allowed.sort();
175 findings.push(format!(
176 "requests script host access: {}",
177 allowed
178 .iter()
179 .map(|s| s.as_str())
180 .collect::<Vec<_>>()
181 .join(", ")
182 ));
183 }
184 }
185
186 if let Some(kind) = value
188 .get("sandbox")
189 .and_then(|v| v.get("kind"))
190 .and_then(|v| v.as_str())
191 && kind == "none"
192 {
193 findings.push("asks to run tools directly on the host (sandbox = none)".to_string());
194 }
195
196 if let Some(entries) = value
200 .get("read_paths")
201 .and_then(|v| v.get("allow"))
202 .and_then(|v| v.as_array())
203 && !entries.is_empty()
204 {
205 let listed: Vec<String> = entries
206 .iter()
207 .filter_map(|e| e.as_str().map(str::to_string))
208 .collect();
209 let status = match read_paths {
212 Some(report) if report.has_ungranted() => format!(
213 "; {} - grant the rest with [agent_read_paths.{}] in your config",
214 report.summary(),
215 report.agent
216 ),
217 Some(report) => format!("; {}, all granted by your config", report.summary()),
218 None => "; inert unless you grant it via [security] read_paths / \
219 allow_blueprint_read_paths or [agent_read_paths.<name>] in your config"
220 .to_string(),
221 };
222 findings.push(format!(
223 "asks to read outside its workdir (read-only): {}{status}",
224 listed.join(", ")
225 ));
226 }
227
228 let seed_commands = collect_seed_commands(&value);
232 for command in seed_commands {
233 findings.push(format!(
234 "runs this command at startup, before any prompt: `{command}`"
235 ));
236 }
237
238 findings
239}
240
241fn collect_seed_commands(value: &toml::Value) -> Vec<String> {
244 let mut out = Vec::new();
245 let mut scan = |regions: Option<&toml::Value>| {
246 if let Some(t) = regions.and_then(|v| v.as_table()) {
247 for region in t.values() {
248 if let Some(cmd) = region
249 .get("seed")
250 .and_then(|s| s.get("command"))
251 .and_then(|c| c.as_str())
252 {
253 out.push(cmd.to_string());
254 }
255 }
256 }
257 };
258 scan(value.get("context").and_then(|c| c.get("regions")));
259 if let Some(stages) = value.get("stages").and_then(|v| v.as_table()) {
260 for stage in stages.values() {
261 scan(stage.get("context").and_then(|c| c.get("regions")));
262 }
263 }
264 out
265}
266
267fn print_capabilities(name: &str, install_dir: &Path, config: Option<&crate::config::Config>) {
269 let manifest = std::fs::read_to_string(install_dir.join("agent.leviath")).unwrap_or_default();
270 let scripts = script_tool_names(install_dir);
271 let report = read_path_report(&manifest, config);
272 let findings = describe_capabilities(&manifest, &scripts, report.as_ref());
273 if findings.is_empty() {
274 return;
275 }
276 println!("\n '{name}' asks for the following. Review before running it:");
277 for finding in &findings {
278 println!(" - {finding}");
279 }
280 println!(" Inspect it with: lev validate {name}");
281}
282
283fn read_path_report(
291 manifest_toml: &str,
292 config: Option<&crate::config::Config>,
293) -> Option<crate::read_path_report::GrantReport> {
294 let config = config?;
295 let blueprint = leviath_core::manifest::parse_manifest(manifest_toml).ok()?;
296 let workdir = crate::commands::resolve_cwd().unwrap_or_default();
297 crate::read_path_report::build(&blueprint, config, &workdir)?.ok()
298}
299
300fn script_tool_names(install_dir: &Path) -> Vec<String> {
302 let mut names: Vec<String> = std::fs::read_dir(install_dir.join("tools"))
303 .into_iter()
304 .flatten()
305 .flatten()
306 .filter_map(|e| {
310 let name = e.file_name().to_string_lossy().into_owned();
311 name.ends_with(".rhai").then_some(name)
312 })
313 .collect();
314 names.sort();
315 names
316}
317
318fn install_from_dir(
323 src: &Path,
324 agents_dir: &Path,
325 config: Option<&crate::config::Config>,
326) -> anyhow::Result<()> {
327 let manifest_path = src.join("agent.leviath");
328 if !manifest_path.exists() {
329 anyhow::bail!(
330 "No agent.leviath found in '{}'. Is this an agent directory?",
331 src.display()
332 );
333 }
334
335 let content = std::fs::read_to_string(&manifest_path)?;
337 let name = parse_agent_name(&content).unwrap_or_else(|| {
338 src.file_name()
339 .and_then(|n| n.to_str())
340 .unwrap_or("unknown")
341 .to_string()
342 });
343
344 let install_dir = agents_dir.join(&name);
345
346 if install_dir.exists() {
347 println!("Reinstalling agent '{}' (replacing existing)", name);
348 std::fs::remove_dir_all(&install_dir)?;
349 }
350
351 copy_dir_recursive(src, &install_dir)?;
352 println!("Installed agent '{}' to {}", name, install_dir.display());
353 print_capabilities(&name, &install_dir, config);
354 println!("Run with: lev run {} --task \"...\"", name);
355 Ok(())
356}
357
358#[cfg(test)]
359thread_local! {
360 static FORCE_DIR_ENTRY_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
367}
368
369fn unwrap_dir_entry(
375 entry: std::io::Result<std::fs::DirEntry>,
376) -> anyhow::Result<std::fs::DirEntry> {
377 #[cfg(test)]
378 if FORCE_DIR_ENTRY_ERROR.with(|f| f.get()) {
379 anyhow::bail!("forced dir-entry error for testing");
380 }
381 Ok(entry?)
382}
383
384fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
386 std::fs::create_dir_all(dst)?;
387 for entry in std::fs::read_dir(src)? {
388 let entry = unwrap_dir_entry(entry)?;
389 let src_path = entry.path();
390 let dst_path = dst.join(entry.file_name());
391 if src_path.is_dir() {
392 copy_dir_recursive(&src_path, &dst_path)?;
393 } else {
394 std::fs::copy(&src_path, &dst_path)?;
395 }
396 }
397 Ok(())
398}
399
400fn parse_agent_name(content: &str) -> Option<String> {
402 for line in content.lines() {
403 let trimmed = line.trim();
404 if let Some(rest) = trimmed.strip_prefix("name") {
405 let rest = rest.trim_start_matches(|c: char| c.is_whitespace() || c == '=');
406 let name = rest.trim().trim_matches('"');
407 if !name.is_empty() {
408 return Some(name.to_string());
409 }
410 }
411 }
412 None
413}
414
415#[cfg(test)]
416mod capability_tests {
417 use std::path::Path;
418
419 fn describe_capabilities(manifest_toml: &str, script_tools: &[String]) -> Vec<String> {
423 super::describe_capabilities(manifest_toml, script_tools, None)
424 }
425
426 #[test]
429 fn an_ordinary_agent_has_nothing_to_report() {
430 let manifest = "[agent]\nname = \"x\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
431 [stages.main]\nprompt = \"p\"\n";
432 assert!(describe_capabilities(manifest, &[]).is_empty());
433 }
434
435 #[test]
436 fn script_tools_are_listed_by_name() {
437 let findings = describe_capabilities(
438 "[agent]\nname = \"x\"\n",
439 &["web_fetch.rhai".to_string(), "post.rhai".to_string()],
440 );
441 assert_eq!(findings.len(), 1);
442 assert!(findings[0].contains("2 executable script tool"));
443 assert!(findings[0].contains("web_fetch.rhai"));
444 }
445
446 #[test]
450 fn self_granted_tool_permissions_are_reported() {
451 let manifest = "[agent]\nname = \"x\"\n\n\
452 [tool_permissions]\nshell = \"allow\"\nread_file = \"ask\"\n";
453 let findings = describe_capabilities(manifest, &[]);
454 assert_eq!(findings.len(), 1);
455 assert!(findings[0].contains("pre-approves"));
456 assert!(findings[0].contains("shell"));
457 assert!(!findings[0].contains("read_file"));
459 }
460
461 #[test]
465 fn a_script_permission_table_that_grants_nothing_is_not_reported() {
466 let manifest = "[agent]\nname = \"x\"\n\n\
467 [tool_script_permissions]\nenv_var = \"deny\"\nhttp_get = \"ask\"\n";
468 assert!(
469 describe_capabilities(manifest, &[]).is_empty(),
470 "denying host access is not a capability to warn about"
471 );
472 }
473
474 #[test]
475 fn stage_level_grants_are_reported_too() {
476 let manifest = "[agent]\nname = \"x\"\n\n\
477 [stages.build.tool_permissions]\nwrite_file = \"allow\"\n";
478 let findings = describe_capabilities(manifest, &[]);
479 assert!(findings[0].contains("write_file"), "{findings:?}");
480 }
481
482 #[test]
483 fn script_host_grants_and_sandbox_opt_out_are_reported() {
484 let manifest = "[agent]\nname = \"x\"\n\n\
485 [tool_script_permissions]\nshell = \"allow\"\nhttp_post = \"allow\"\n\n\
486 [sandbox]\nkind = \"none\"\n";
487 let findings = describe_capabilities(manifest, &[]);
488 let joined = findings.join(" | ");
489 assert!(joined.contains("script host access"), "{joined}");
490 assert!(joined.contains("http_post"), "{joined}");
491 assert!(joined.contains("sandbox = none"), "{joined}");
492 }
493
494 #[test]
498 fn command_seeds_are_reported_verbatim() {
499 let manifest = "[agent]\nname = \"x\"\n\n\
500 [context.regions]\n\
501 repo = { kind = \"pinned\", seed = { command = \"git ls-files\" } }\n";
502 let findings = describe_capabilities(manifest, &[]);
503 assert_eq!(findings.len(), 1);
504 assert!(findings[0].contains("before any prompt"), "{findings:?}");
505 assert!(findings[0].contains("git ls-files"), "{findings:?}");
506 }
507
508 #[test]
509 fn stage_level_command_seeds_are_reported() {
510 let manifest = "[agent]\nname = \"x\"\n\n\
511 [stages.discover.context.regions]\n\
512 env = { kind = \"pinned\", seed = { command = \"curl https://evil\" } }\n";
513 let findings = describe_capabilities(manifest, &[]);
514 assert!(findings[0].contains("curl https://evil"), "{findings:?}");
515 }
516
517 #[test]
519 fn opting_into_a_sandbox_is_not_reported() {
520 let manifest = "[agent]\nname = \"x\"\n\n[sandbox]\nkind = \"container\"\n";
521 assert!(describe_capabilities(manifest, &[]).is_empty());
522 }
523
524 #[test]
527 fn read_path_declarations_are_reported() {
528 let manifest = "[agent]\nname = \"x\"\n\n\
529 [read_paths]\n\
530 allow = [\"~/.leviath/runs\", \"glob:~/design-docs/**\"]\n";
531 let findings = describe_capabilities(manifest, &[]);
532 assert_eq!(findings.len(), 1);
533 assert!(
534 findings[0].contains("read outside its workdir"),
535 "{findings:?}"
536 );
537 assert!(findings[0].contains("~/.leviath/runs"), "{findings:?}");
538 assert!(
539 findings[0].contains("glob:~/design-docs/**"),
540 "{findings:?}"
541 );
542 assert!(
543 findings[0].contains("inert unless you grant it"),
544 "{findings:?}"
545 );
546 }
547
548 #[test]
552 fn read_path_declarations_carry_their_grant_status() {
553 let manifest = "[agent]\nname = \"cto\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
554 [stages.main]\nmode = \"autonomous\"\n\n\
555 [context.regions]\nsystem = { kind = \"pinned\", max_tokens = 1000 }\n\n\
556 [read_paths]\nallow = [\"/data/runs\", \"/data/docs\"]\n";
557 let blueprint = leviath_core::manifest::parse_manifest(manifest).expect("parses");
558
559 let mut config = crate::config::Config::default();
560 config.security.read_paths = vec!["/data/runs".to_string()];
561 let partial = crate::read_path_report::build(&blueprint, &config, Path::new("/work"))
562 .expect("declares read paths")
563 .expect("grants compile");
564 let findings = super::describe_capabilities(manifest, &[], Some(&partial));
565 assert!(
566 findings[0].contains("2 declared, 1 granted"),
567 "{findings:?}"
568 );
569 assert!(
570 findings[0].contains("[agent_read_paths.cto]"),
571 "{findings:?}"
572 );
573
574 config.security.read_paths.push("/data/docs".to_string());
575 let full = crate::read_path_report::build(&blueprint, &config, Path::new("/work"))
576 .expect("declares read paths")
577 .expect("grants compile");
578 let findings = super::describe_capabilities(manifest, &[], Some(&full));
579 assert!(findings[0].contains("all granted"), "{findings:?}");
580 }
581
582 #[test]
584 fn an_empty_read_paths_block_is_not_reported() {
585 let manifest = "[agent]\nname = \"x\"\n\n[read_paths]\nallow = []\n";
586 assert!(describe_capabilities(manifest, &[]).is_empty());
587 }
588
589 #[test]
593 fn no_grant_report_is_built_without_a_config_or_a_parseable_manifest() {
594 let manifest = "[agent]\nname = \"x\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
595 [stages.main]\nmode = \"autonomous\"\n\n\
596 [context.regions]\nsystem = { kind = \"pinned\", max_tokens = 1000 }\n\n\
597 [read_paths]\nallow = [\"/data/runs\"]\n";
598 assert!(super::read_path_report(manifest, None).is_none());
599 assert!(
600 super::read_path_report(
601 "not valid toml [[[",
602 Some(&crate::config::Config::default())
603 )
604 .is_none()
605 );
606
607 let plain = "[agent]\nname = \"x\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
609 [stages.main]\nmode = \"autonomous\"\n\n\
610 [context.regions]\nsystem = { kind = \"pinned\", max_tokens = 1000 }\n";
611 assert!(super::read_path_report(plain, Some(&crate::config::Config::default())).is_none());
612
613 let mut broken = crate::config::Config::default();
615 broken.security.read_paths = vec!["regex:relative/.*".to_string()];
616 assert!(super::read_path_report(manifest, Some(&broken)).is_none());
617
618 let report = super::read_path_report(manifest, Some(&crate::config::Config::default()))
620 .expect("a parseable manifest and a config give a report");
621 assert_eq!(report.declared(), 1);
622 }
623
624 #[test]
627 fn script_tool_names_lists_only_rhai_files_sorted() {
628 let dir = tempfile::tempdir().unwrap();
629 let tools = dir.path().join("tools");
630 std::fs::create_dir(&tools).unwrap();
631 for name in ["zeta.rhai", "alpha.rhai", "README.md", "notes.txt"] {
632 std::fs::write(tools.join(name), "x").unwrap();
633 }
634 assert_eq!(
635 super::script_tool_names(dir.path()),
636 vec!["alpha.rhai".to_string(), "zeta.rhai".to_string()]
637 );
638 }
639
640 #[test]
642 fn script_tool_names_is_empty_without_a_tools_directory() {
643 let dir = tempfile::tempdir().unwrap();
644 assert!(super::script_tool_names(dir.path()).is_empty());
645 }
646
647 #[test]
650 fn print_capabilities_reads_the_installed_directory() {
651 crate::test_support::with_tracing(|| {
652 let dir = tempfile::tempdir().unwrap();
653 std::fs::write(
654 dir.path().join("agent.leviath"),
655 "[agent]\nname = \"q\"\n\n[tool_permissions]\nshell = \"allow\"\n",
656 )
657 .unwrap();
658 let tools = dir.path().join("tools");
659 std::fs::create_dir(&tools).unwrap();
660 std::fs::write(tools.join("t.rhai"), "// @tool t\n").unwrap();
661 super::print_capabilities("q", dir.path(), None);
662
663 let plain = tempfile::tempdir().unwrap();
665 std::fs::write(
666 plain.path().join("agent.leviath"),
667 "[agent]\nname = \"p\"\n\n[stages.main]\nprompt = \"p\"\n",
668 )
669 .unwrap();
670 super::print_capabilities("p", plain.path(), None);
671 });
672 }
673
674 #[test]
675 fn an_unparseable_manifest_reports_nothing() {
676 assert!(describe_capabilities("{ not toml", &[]).is_empty());
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683 use crate::test_support::{with_tracing, write_test_agent};
684
685 async fn execute_with(
688 args: &AddArgs,
689 installer: &leviath_package::AgentInstaller,
690 agents_dir: &Path,
691 ) -> anyhow::Result<()> {
692 super::execute_with(args, installer, agents_dir, None).await
693 }
694
695 fn install_from_dir(src: &Path, agents_dir: &Path) -> anyhow::Result<()> {
696 super::install_from_dir(src, agents_dir, None)
697 }
698
699 #[test]
702 fn agents_dir_or_error_some_returns_path() {
703 let dir = std::path::PathBuf::from("/home/testuser/.leviath/agents");
704 assert_eq!(agents_dir_or_error(Some(dir.clone())).unwrap(), dir);
705 }
706
707 #[test]
708 fn agents_dir_or_error_none_returns_error() {
709 let err = agents_dir_or_error(None).unwrap_err();
710 assert!(
711 err.to_string()
712 .contains("Could not determine home directory")
713 );
714 }
715
716 #[test]
719 fn parse_agent_name_standard() {
720 let content = r#"
721name = "my-agent"
722version = "1.0"
723"#;
724 assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
725 }
726
727 #[test]
728 fn parse_agent_name_no_quotes() {
729 let content = r#"name = my-agent"#;
730 assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
731 }
732
733 #[test]
734 fn parse_agent_name_extra_whitespace() {
735 let content = r#" name = "spacy-agent" "#;
736 assert_eq!(parse_agent_name(content), Some("spacy-agent".to_string()));
737 }
738
739 #[test]
740 fn parse_agent_name_missing() {
741 let content = r#"
742version = "1.0"
743description = "test"
744"#;
745 assert_eq!(parse_agent_name(content), None);
746 }
747
748 #[test]
749 fn parse_agent_name_empty_value() {
750 let content = r#"name = """#;
751 assert_eq!(parse_agent_name(content), None);
752 }
753
754 #[test]
757 fn copy_dir_recursive_copies_files() {
758 let src_dir = tempfile::tempdir().unwrap();
759 let dst_dir = tempfile::tempdir().unwrap();
760 let dst_path = dst_dir.path().join("copy");
761
762 std::fs::write(src_dir.path().join("file1.txt"), "hello").unwrap();
763 std::fs::create_dir_all(src_dir.path().join("sub")).unwrap();
764 std::fs::write(src_dir.path().join("sub/file2.txt"), "world").unwrap();
765
766 copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
767
768 assert!(dst_path.join("file1.txt").exists());
769 assert!(dst_path.join("sub/file2.txt").exists());
770 assert_eq!(
771 std::fs::read_to_string(dst_path.join("file1.txt")).unwrap(),
772 "hello"
773 );
774 assert_eq!(
775 std::fs::read_to_string(dst_path.join("sub/file2.txt")).unwrap(),
776 "world"
777 );
778 }
779
780 #[test]
781 fn copy_dir_recursive_empty_dir() {
782 let src_dir = tempfile::tempdir().unwrap();
783 let dst_dir = tempfile::tempdir().unwrap();
784 let dst_path = dst_dir.path().join("empty-copy");
785
786 copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
787 assert!(dst_path.exists());
788 assert!(dst_path.is_dir());
789 }
790
791 #[test]
792 fn copy_dir_recursive_nonexistent_src_errors() {
793 let dst_dir = tempfile::tempdir().unwrap();
794 let dst_path = dst_dir.path().join("dst");
795 let missing_src = dst_dir.path().join("does-not-exist");
796
797 let result = copy_dir_recursive(&missing_src, &dst_path);
798 assert!(result.is_err());
799 }
800
801 #[test]
802 fn copy_dir_recursive_dst_parent_is_file_errors() {
803 let tmp = tempfile::tempdir().unwrap();
804 let file_path = tmp.path().join("not-a-dir");
805 std::fs::write(&file_path, "x").unwrap();
806 let src = tempfile::tempdir().unwrap();
807 let dst = file_path.join("child");
808
809 let result = copy_dir_recursive(src.path(), &dst);
810 assert!(result.is_err());
811 }
812
813 #[test]
814 fn copy_dir_recursive_file_over_existing_dir_errors() {
815 let src_dir = tempfile::tempdir().unwrap();
819 std::fs::write(src_dir.path().join("clash"), "top secret").unwrap();
820
821 let dst_dir = tempfile::tempdir().unwrap();
822 let dst_path = dst_dir.path().join("copy");
823 std::fs::create_dir_all(dst_path.join("clash")).unwrap();
825
826 let result = copy_dir_recursive(src_dir.path(), &dst_path);
827 assert!(result.is_err());
828 }
829
830 #[test]
831 fn copy_dir_recursive_recursion_error_propagates() {
832 let src_dir = tempfile::tempdir().unwrap();
838 let sub = src_dir.path().join("sub");
839 std::fs::create_dir_all(&sub).unwrap();
840 std::fs::write(sub.join("file.txt"), "data").unwrap();
841
842 let dst_dir = tempfile::tempdir().unwrap();
843 let dst_path = dst_dir.path().join("copy");
844 std::fs::create_dir_all(&dst_path).unwrap();
845 std::fs::write(dst_path.join("sub"), "i am a file").unwrap();
847
848 let result = copy_dir_recursive(src_dir.path(), &dst_path);
849 assert!(result.is_err());
850 }
851
852 #[test]
853 fn copy_dir_recursive_forced_mid_iteration_entry_error() {
854 let src_dir = tempfile::tempdir().unwrap();
858 std::fs::write(src_dir.path().join("file.txt"), "data").unwrap();
859
860 let dst_dir = tempfile::tempdir().unwrap();
861 let dst_path = dst_dir.path().join("copy");
862
863 FORCE_DIR_ENTRY_ERROR.with(|f| f.set(true));
864 let result = copy_dir_recursive(src_dir.path(), &dst_path);
865 FORCE_DIR_ENTRY_ERROR.with(|f| f.set(false));
866
867 assert!(result.is_err());
868 }
869
870 #[test]
871 fn unwrap_dir_entry_propagates_a_real_err_argument() {
872 let result = unwrap_dir_entry(Err(std::io::Error::other("synthetic entry error")));
879 assert!(result.is_err());
880 }
881
882 #[test]
885 fn install_from_dir_no_manifest_errors() {
886 let dir = tempfile::tempdir().unwrap();
887 let agents_dir = tempfile::tempdir().unwrap();
888 let result = install_from_dir(dir.path(), agents_dir.path());
889 assert!(result.is_err());
890 assert!(result.unwrap_err().to_string().contains("agent.leviath"));
891 }
892
893 #[test]
894 fn install_from_dir_copies_and_names_from_manifest() {
895 let src = tempfile::tempdir().unwrap();
896 let agents_dir = tempfile::tempdir().unwrap();
897 std::fs::write(
898 src.path().join("agent.leviath"),
899 "[agent]\nname = \"my-agent\"\n",
900 )
901 .unwrap();
902 std::fs::write(src.path().join("extra.txt"), "data").unwrap();
903
904 install_from_dir(src.path(), agents_dir.path()).unwrap();
905
906 let installed_dir = agents_dir.path().join("my-agent");
907 assert!(installed_dir.join("agent.leviath").exists());
908 assert!(installed_dir.join("extra.txt").exists());
909 }
910
911 #[test]
912 fn install_from_dir_falls_back_to_dirname_when_name_missing() {
913 let src = tempfile::tempdir().unwrap();
914 let agent_dir = src.path().join("my-dir-name");
915 std::fs::create_dir_all(&agent_dir).unwrap();
916 std::fs::write(agent_dir.join("agent.leviath"), "version = \"1.0\"\n").unwrap();
917 let agents_dir = tempfile::tempdir().unwrap();
918
919 install_from_dir(&agent_dir, agents_dir.path()).unwrap();
920
921 assert!(agents_dir.path().join("my-dir-name").exists());
922 }
923
924 #[test]
925 fn install_from_dir_reinstalls_existing() {
926 let src = tempfile::tempdir().unwrap();
927 std::fs::write(
928 src.path().join("agent.leviath"),
929 "[agent]\nname = \"dup-agent\"\n",
930 )
931 .unwrap();
932 let agents_dir = tempfile::tempdir().unwrap();
933
934 let existing = agents_dir.path().join("dup-agent");
936 std::fs::create_dir_all(&existing).unwrap();
937 std::fs::write(existing.join("stale.txt"), "old").unwrap();
938
939 install_from_dir(src.path(), agents_dir.path()).unwrap();
940
941 assert!(!existing.join("stale.txt").exists());
942 assert!(existing.join("agent.leviath").exists());
943 }
944
945 #[test]
946 fn install_from_dir_invalid_utf8_manifest_errors() {
947 let dir = tempfile::tempdir().unwrap();
948 std::fs::write(dir.path().join("agent.leviath"), [0xFF, 0xFE, 0xFA]).unwrap();
949 let agents_dir = tempfile::tempdir().unwrap();
950
951 let result = install_from_dir(dir.path(), agents_dir.path());
952 assert!(result.is_err());
953 }
954
955 #[test]
956 fn install_from_dir_remove_dir_all_failure_errors() {
957 let src = tempfile::tempdir().unwrap();
961 std::fs::write(
962 src.path().join("agent.leviath"),
963 "[agent]\nname = \"file-agent\"\n",
964 )
965 .unwrap();
966
967 let agents_dir = tempfile::tempdir().unwrap();
968 std::fs::write(agents_dir.path().join("file-agent"), "not a dir").unwrap();
969
970 let result = install_from_dir(src.path(), agents_dir.path());
971 assert!(result.is_err());
972 }
973
974 #[test]
975 fn install_from_dir_copy_failure_propagates() {
976 let src = tempfile::tempdir().unwrap();
981 std::fs::write(
982 src.path().join("agent.leviath"),
983 "[agent]\nname = \"broken-copy-agent\"\n",
984 )
985 .unwrap();
986 std::fs::write(src.path().join("extra.txt"), "data").unwrap();
987
988 let tmp = tempfile::tempdir().unwrap();
989 let agents_file = tmp.path().join("agents-is-a-file");
990 std::fs::write(&agents_file, "not a dir").unwrap();
991
992 let result = install_from_dir(src.path(), &agents_file);
993 assert!(result.is_err());
994 }
995
996 #[test]
999 fn execute_with_directory_package_installs() {
1000 let rt = tokio::runtime::Runtime::new().unwrap();
1001 with_tracing(|| {
1002 rt.block_on(async {
1003 let src = tempfile::tempdir().unwrap();
1004 std::fs::write(
1005 src.path().join("agent.leviath"),
1006 "[agent]\nname = \"dir-pkg\"\n",
1007 )
1008 .unwrap();
1009 let agents_dir = tempfile::tempdir().unwrap();
1010 let installer = leviath_package::AgentInstaller::with_install_dir(
1011 agents_dir.path().to_path_buf(),
1012 );
1013 let args = AddArgs {
1014 package: src.path().to_str().unwrap().to_string(),
1015 };
1016
1017 execute_with(&args, &installer, agents_dir.path())
1018 .await
1019 .unwrap();
1020
1021 assert!(agents_dir.path().join("dir-pkg").exists());
1022 })
1023 });
1024 }
1025
1026 #[test]
1027 fn execute_with_directory_without_manifest_errors() {
1028 let rt = tokio::runtime::Runtime::new().unwrap();
1029 with_tracing(|| {
1030 rt.block_on(async {
1031 let src = tempfile::tempdir().unwrap(); let agents_dir = tempfile::tempdir().unwrap();
1033 let installer = leviath_package::AgentInstaller::with_install_dir(
1034 agents_dir.path().to_path_buf(),
1035 );
1036 let args = AddArgs {
1037 package: src.path().to_str().unwrap().to_string(),
1038 };
1039
1040 let err = execute_with(&args, &installer, agents_dir.path())
1041 .await
1042 .unwrap_err();
1043 assert!(err.to_string().contains("agent.leviath"));
1044 })
1045 });
1046 }
1047
1048 #[test]
1049 fn execute_with_missing_bundle_file_errors() {
1050 let rt = tokio::runtime::Runtime::new().unwrap();
1051 with_tracing(|| {
1052 rt.block_on(async {
1053 let agents_dir = tempfile::tempdir().unwrap();
1054 let installer = leviath_package::AgentInstaller::with_install_dir(
1055 agents_dir.path().to_path_buf(),
1056 );
1057 let args = AddArgs {
1058 package: "nonexistent.leviath-bundle".to_string(),
1059 };
1060
1061 let err = execute_with(&args, &installer, agents_dir.path())
1062 .await
1063 .unwrap_err();
1064 assert!(err.to_string().contains("Package file not found"));
1065 })
1066 });
1067 }
1068
1069 #[test]
1070 fn execute_with_bundle_file_installs() {
1071 let rt = tokio::runtime::Runtime::new().unwrap();
1072 with_tracing(|| {
1073 rt.block_on(async {
1074 let project_dir = tempfile::tempdir().unwrap();
1075 std::fs::write(
1076 project_dir.path().join("agent.leviath"),
1077 "[agent]\nname = \"bundled-pkg\"\nversion = \"1.0.0\"\ndescription = \"d\"\n",
1078 )
1079 .unwrap();
1080 let bundle_bytes = leviath_package::AgentBundler::new()
1081 .bundle(project_dir.path())
1082 .unwrap();
1083 let bundle_dir = tempfile::tempdir().unwrap();
1084 let bundle_path = bundle_dir.path().join("bundled-pkg.leviath-bundle");
1088 std::fs::write(&bundle_path, bundle_bytes).unwrap();
1089
1090 let agents_dir = tempfile::tempdir().unwrap();
1091 let installer = leviath_package::AgentInstaller::with_install_dir(
1092 agents_dir.path().to_path_buf(),
1093 );
1094 let args = AddArgs {
1095 package: bundle_path.to_str().unwrap().to_string(),
1096 };
1097
1098 execute_with(&args, &installer, agents_dir.path())
1099 .await
1100 .unwrap();
1101
1102 assert!(agents_dir.path().join("bundled-pkg").exists());
1103 })
1104 });
1105 }
1106
1107 #[test]
1108 fn execute_with_corrupt_bundle_file_errors() {
1109 let rt = tokio::runtime::Runtime::new().unwrap();
1110 with_tracing(|| {
1111 rt.block_on(async {
1112 let bundle_dir = tempfile::tempdir().unwrap();
1113 let bundle_path = bundle_dir.path().join("broken.leviath-bundle");
1114 std::fs::write(&bundle_path, b"not a valid gzip archive").unwrap();
1115
1116 let agents_dir = tempfile::tempdir().unwrap();
1117 let installer = leviath_package::AgentInstaller::with_install_dir(
1118 agents_dir.path().to_path_buf(),
1119 );
1120 let args = AddArgs {
1121 package: bundle_path.to_str().unwrap().to_string(),
1122 };
1123
1124 let err = execute_with(&args, &installer, agents_dir.path())
1125 .await
1126 .unwrap_err();
1127 assert!(err.to_string().contains("Failed to extract package"));
1128 })
1129 });
1130 }
1131
1132 #[test]
1133 fn execute_with_unrecognized_package_reports_local_only() {
1134 let rt = tokio::runtime::Runtime::new().unwrap();
1137 with_tracing(|| {
1138 rt.block_on(async {
1139 let agents_dir = tempfile::tempdir().unwrap();
1140 let installer = leviath_package::AgentInstaller::with_install_dir(
1141 agents_dir.path().to_path_buf(),
1142 );
1143 let args = AddArgs {
1144 package: "some-registry-agent".to_string(),
1145 };
1146 let err = execute_with(&args, &installer, agents_dir.path())
1147 .await
1148 .unwrap_err();
1149 assert!(
1150 err.to_string()
1151 .contains("not a local agent directory or a .leviath-bundle file"),
1152 "expected the v1-cut message, got: {err}"
1153 );
1154 })
1155 });
1156 }
1157
1158 #[test]
1161 fn bundle_extension_detected() {
1162 let package = "my-agent-1.0.leviath-bundle";
1163 assert!(package.ends_with(".leviath-bundle"));
1164 }
1165
1166 #[test]
1167 fn directory_path_detected() {
1168 let dir = tempfile::tempdir().unwrap();
1169 let package_path = Path::new(dir.path().to_str().unwrap());
1170 assert!(package_path.is_dir());
1171 }
1172
1173 #[test]
1174 fn registry_name_not_dir_not_bundle() {
1175 let package = "my-cool-agent";
1176 let package_path = Path::new(package);
1177 assert!(!package_path.is_dir());
1178 assert!(!package.ends_with(".leviath-bundle"));
1179 }
1180
1181 #[test]
1184 fn parse_agent_name_in_section() {
1185 let content = r#"
1186[agent]
1187name = "my-agent"
1188version = "1.0"
1189"#;
1190 assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
1191 }
1192
1193 #[test]
1194 fn parse_agent_name_with_single_quotes() {
1195 let content = r#"name = my-agent-no-quotes"#;
1197 assert_eq!(
1198 parse_agent_name(content),
1199 Some("my-agent-no-quotes".to_string())
1200 );
1201 }
1202
1203 #[test]
1204 fn parse_agent_name_multiple_name_fields_returns_first() {
1205 let content = r#"
1206name = "first"
1207name = "second"
1208"#;
1209 assert_eq!(parse_agent_name(content), Some("first".to_string()));
1210 }
1211
1212 #[test]
1215 fn copy_dir_recursive_deeply_nested() {
1216 let src_dir = tempfile::tempdir().unwrap();
1217 let dst_dir = tempfile::tempdir().unwrap();
1218 let dst_path = dst_dir.path().join("deep-copy");
1219
1220 std::fs::create_dir_all(src_dir.path().join("a/b/c")).unwrap();
1221 std::fs::write(src_dir.path().join("a/b/c/deep.txt"), "deep").unwrap();
1222
1223 copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
1224
1225 assert!(dst_path.join("a/b/c/deep.txt").exists());
1226 assert_eq!(
1227 std::fs::read_to_string(dst_path.join("a/b/c/deep.txt")).unwrap(),
1228 "deep"
1229 );
1230 }
1231
1232 #[test]
1235 fn execute_real_wrapper_fails_fast_without_touching_real_agents_dir() {
1236 let rt = tokio::runtime::Runtime::new().unwrap();
1242 with_tracing(|| {
1243 rt.block_on(async {
1244 crate::config::with_isolated_config_path_async("add-real-wrapper", |_fake| async {
1247 let args = AddArgs {
1248 package: "definitely-not-a-real-bundle-xyz.leviath-bundle".to_string(),
1249 };
1250 let err = execute(args).await.unwrap_err();
1251 assert!(err.to_string().contains("Package file not found"));
1252 })
1253 .await;
1254 })
1255 });
1256 }
1257
1258 #[test]
1259 fn execute_returns_err_when_agents_dir_unresolvable() {
1260 let rt = tokio::runtime::Runtime::new().unwrap();
1265 FORCE_AGENTS_DIR_ERROR.with(|f| f.set(true));
1266 let result = rt.block_on(async {
1267 let args = AddArgs {
1268 package: "whatever.leviath-bundle".to_string(),
1269 };
1270 execute(args).await
1271 });
1272 FORCE_AGENTS_DIR_ERROR.with(|f| f.set(false));
1273
1274 let err = result.unwrap_err();
1275 assert!(
1276 err.to_string()
1277 .contains("Could not determine home directory")
1278 );
1279 }
1280
1281 #[test]
1284 fn install_from_dir_with_manifest_runs() {
1285 let dir = tempfile::tempdir().unwrap();
1286 let manifest = r#"
1287[agent]
1288name = "test-install-agent-xyz"
1289version = "0.1.0"
1290description = "test"
1291"#;
1292 write_test_agent(dir.path(), manifest);
1293 std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1294
1295 let agents_dir = tempfile::tempdir().unwrap();
1296 install_from_dir(dir.path(), agents_dir.path()).unwrap();
1297
1298 let install_dir = agents_dir.path().join("test-install-agent-xyz");
1299 assert!(install_dir.join("agent.leviath").exists());
1300 assert!(install_dir.join("readme.txt").exists());
1301 }
1302}