Skip to main content

leviath_cli/commands/
add.rs

1//! `lev add` - Install an agent package
2
3use clap::Args;
4use std::path::Path;
5
6#[derive(Args)]
7pub struct AddArgs {
8    /// Path to an agent directory or a .leviath-bundle file
9    #[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    // Best-effort, unlike `lev list`: a config that will not parse is a reason
21    // to say less about the package being installed, never a reason to refuse
22    // to install it.
23    let config = crate::config::Config::load().ok();
24    execute_with(&args, &installer, &agents_dir, config.as_ref()).await
25}
26
27/// Resolve `~/.leviath/agents`, the install root for `lev add`.
28///
29/// A thin wrapper over [`agents_dir_or_error`] supplying the real resolved
30/// directory. The `#[cfg(test)]` guard below only lets tests force the
31/// "no home directory" error arm of `execute()` deterministically - the real
32/// the shared resolver can't be made to return `None` in any environment a
33/// test may safely create (on macOS `dirs::home_dir()` falls back to a
34/// passwd-database lookup independent of `$HOME`). It does NOT hide the real
35/// body from coverage: with the toggle off, `agents_dir_or_error(
36/// leviath_core::paths::agents_dir())` runs (and is measured) in every ordinary test. This
37/// only computes a `PathBuf`; the `None` arm of `agents_dir_or_error` is
38/// covered directly by `agents_dir_or_error_none_returns_error`.
39fn 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    /// Test-only toggle letting `execute_returns_err_when_agents_dir_unresolvable`
50    /// force `resolve_agents_dir`'s `Err` arm deterministically.
51    static FORCE_AGENTS_DIR_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
52}
53
54/// Core `lev add` logic, parameterized by installer + agents base directory
55/// so it can be tested against tempdirs instead of the real
56/// `~/.leviath/agents`.
57async 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        // Directory install: copy directory into <agents_dir>/<name>/
69        install_from_dir(package_path, agents_dir, config)?;
70    } else if package_path.exists() || args.package.ends_with(".leviath-bundle") {
71        // Bundle file installation
72        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        // Only local installs exist: agent directories and .leviath-bundle
86        // files. Fail with a clear message rather than guessing at intent.
87        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
97/// The security-relevant things an agent package carries, as human-readable
98/// lines.
99///
100/// A bare "Installed agent 'x' to …" would never tell the user that the
101/// package ships executable `.rhai` tool scripts, pre-approves its own `shell`,
102/// turns the sandbox off, or runs a command at spawn before any prompt. Every
103/// one of those is a decision the user is making by installing, so `lev add`
104/// must surface them.
105///
106/// Empty means the package declares nothing unusual - a plain prompt-and-stages
107/// agent - in which case there is nothing to warn about and we stay quiet.
108///
109/// Pure over `(manifest_toml, dir_entries, read_paths)` so the whole table is
110/// testable without a filesystem or an installed agent. `read_paths` is the
111/// grant report for this package under the active config, when one could be
112/// built; without it the `[read_paths]` line falls back to stating the rule.
113pub(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    // `toml::from_str`, not `manifest_toml.parse::<toml::Value>()`. In toml 1.x
120    // `FromStr for Value` parses a single *value*, not a document - so a real
121    // manifest starting with `[agent]` reads as an array literal followed by
122    // junk and fails. It still compiles, so the change is silent; the tests are
123    // what caught it.
124    let Ok(value) = toml::from_str::<toml::Value>(manifest_toml) else {
125        // An unparseable manifest is reported by the installer itself; there is
126        // nothing to inventory.
127        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    // Tool permissions the package grants itself, at agent or stage level.
139    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    // Script host functions it grants itself.
164    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    // A sandbox opt-out.
187    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    // Read paths beyond the workdir. Declaring is not granting - the entries
197    // are inert until the user's config grants them - but the ask itself is
198    // exactly what this inventory exists to surface.
199    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        // With the active config in hand, say which of them are actually live
210        // rather than repeating the rule and leaving the user to work it out.
211        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    // Command seeds run at spawn, before the first inference and therefore
229    // before any approval prompt - the one place a manifest executes something
230    // without being asked.
231    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
241/// Every `seed = { command = "..." }` in a manifest, from agent-level and
242/// stage-level `[context.regions]` blocks alike.
243fn 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
267/// Print the capability inventory for a freshly installed agent, if it has one.
268fn 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
283/// The `[read_paths]` grant report for a just-installed manifest, when there is
284/// a config to judge it against and the manifest parses.
285///
286/// The workdir a relative entry resolves against is the directory a `lev run`
287/// would default to, which at install time is the one `lev add` was run from.
288/// A broken grant list yields no report: the inventory falls back to stating
289/// the rule, and `lev validate` says what is wrong with the config.
290fn 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
300/// Names of the `.rhai` tool scripts an installed agent ships.
301fn 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        // `DirEntry::file_name` rather than `path().file_name()`: the latter
307        // returns an `Option` that a directory entry can never actually be
308        // missing, leaving an arm no test can reach.
309        .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
318/// Copy a plain agent directory into `<agents_dir>/<name>/`.
319///
320/// The agent name is read from `agent.leviath` in the directory (falling back
321/// to the directory's own name).
322fn 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    // Read the manifest to extract the agent name
336    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    /// Test-only toggle letting a test force the `Err` arm of a
361    /// mid-iteration `ReadDir` entry deterministically (see
362    /// [`unwrap_dir_entry`]) - the real failure mode (the directory handle
363    /// becoming invalid mid-iteration: deleted out from under the process,
364    /// an NFS ESTALE, or similar) is a genuine OS-level race that can't be
365    /// reproduced deterministically across Linux/macOS/Windows CI.
366    static FORCE_DIR_ENTRY_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
367}
368
369/// Unwrap one `ReadDir` iteration result, with a test-only failure-injection
370/// toggle (see [`FORCE_DIR_ENTRY_ERROR`]) so the `Err` arm - `ReadDir::next()`
371/// failing after `read_dir` already succeeded in opening the directory --
372/// can be exercised deterministically without needing to actually race the
373/// filesystem.
374fn 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
384/// Recursively copy a directory tree.
385fn 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
400/// Parse the agent name from an `agent.leviath` manifest (first `name = "..."` line).
401fn 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    /// The inventory with no config to judge `[read_paths]` against - the
420    /// fallback wording, and what every test here predating grant reporting
421    /// assumed. The grant-aware tests below pass a real report.
422    fn describe_capabilities(manifest_toml: &str, script_tools: &[String]) -> Vec<String> {
423        super::describe_capabilities(manifest_toml, script_tools, None)
424    }
425
426    /// A plain agent declares nothing unusual, so the inventory stays quiet -
427    /// a warning that fires on everything teaches people to skip it.
428    #[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    /// The case that matters most: a package that pre-approves its own shell.
447    /// Under the permission floor a user's explicit config still wins, but where
448    /// the user has said nothing this is a real grant they should see.
449    #[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        // `ask` is the default posture, not a grant.
458        assert!(!findings[0].contains("read_file"));
459    }
460
461    /// A `[tool_script_permissions]` table that only *tightens* is not a grant,
462    /// so it must not appear in the inventory - the same "quiet unless there is
463    /// something to say" rule the ordinary-agent case establishes.
464    #[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    /// A command seed runs at spawn - before the first inference and therefore
495    /// before any approval prompt. It is the one thing a manifest executes
496    /// without being asked, so the exact command is shown.
497    #[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    /// A sandbox the manifest *opts into* is not a warning - only opting out is.
518    #[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    /// `[read_paths]` is an ask to see beyond the workdir - listed verbatim,
525    /// with the reminder that it stays inert until the user's config grants it.
526    #[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    /// With a config to judge against, the inventory says which entries are
549    /// live instead of restating the rule. This is what someone installing an
550    /// agent on a fresh machine needs to know.
551    #[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    /// An empty `allow` array asks for nothing - stay quiet.
583    #[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    /// Every way the grant report can be unavailable at install time: no
590    /// config to judge against, and a manifest the parser refuses. Both fall
591    /// back to stating the rule rather than guessing.
592    #[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        // A package that declares nothing has nothing to report either.
608        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        // Nor does one whose grants cannot be compiled to judge it against.
614        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        // And the ordinary case, so the fallbacks are not the only path tested.
619        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    /// The `tools/` scan that feeds the inventory: only `.rhai` files count, and
625    /// they come back sorted so the message is stable between runs.
626    #[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    /// An agent with no `tools/` directory at all - the common case.
641    #[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    /// The end-to-end printer, over a directory rather than a string: it must
648    /// stay silent for an ordinary agent and speak for a demanding one.
649    #[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            // And the quiet path: a plain agent prints nothing.
664            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    /// The installers with no config to judge `[read_paths]` against, which is
686    /// what every test here predating grant reporting assumed.
687    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    // ─── agents_dir_or_error ─────────────────────────────────────────────
700
701    #[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    // ─── parse_agent_name ──────────────────────────────────────────────────
717
718    #[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    // ─── copy_dir_recursive ────────────────────────────────────────────────
755
756    #[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        // Copying a file onto a destination path that already exists as a
816        // directory fails on every platform (EISDIR / ERROR_ACCESS_DENIED),
817        // exercising the `std::fs::copy(...)?` error arm.
818        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        // Pre-create dst/clash as a directory so the file copy collides.
824        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        // Exercises the recursive-call error-propagation branch OS-agnostically:
833        // the destination already has a *file* where the recursion needs to
834        // create a subdirectory, so the nested `create_dir_all` fails on every
835        // platform and that `Err` bubbles up through the parent's
836        // `copy_dir_recursive(...)?`.
837        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        // Block the recursion's create_dir_all(dst/sub) with a file at that path.
846        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        // Deterministically exercises `unwrap_dir_entry`'s `Err` arm (a real
855        // `ReadDir::next()` failure mid-iteration) without racing the
856        // filesystem, via the FORCE_DIR_ENTRY_ERROR test toggle.
857        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        // `unwrap_dir_entry`'s own `Ok(entry?)` `?` still has a real error
873        // arm distinct from the `FORCE_DIR_ENTRY_ERROR`-triggered early
874        // `bail!` above it (that toggle short-circuits *before* this line
875        // is ever reached) - `DirEntry` isn't constructible directly, but
876        // its `Result` wrapper doesn't need a real one to test the `Err`
877        // case: pass a synthetic `io::Error` straight in.
878        let result = unwrap_dir_entry(Err(std::io::Error::other("synthetic entry error")));
879        assert!(result.is_err());
880    }
881
882    // ─── install_from_dir ──────────────────────────────────────────────────
883
884    #[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        // Pre-create an existing install with a stale file that should be wiped.
935        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        // The existing install target is a *file*, so `exists()` passes the
958        // reinstall guard but `remove_dir_all` (which requires a directory)
959        // fails on every platform, exercising that `?` arm.
960        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        // `agents_dir` is itself a *file*, so `copy_dir_recursive`'s
977        // `create_dir_all` for the install target (a child path of a file)
978        // fails on every platform, and that `Err` propagates through
979        // `install_from_dir`'s `copy_dir_recursive(...)?`.
980        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    // ─── execute_with: directory + bundle-file paths ───────────────────────
997
998    #[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(); // no agent.leviath inside
1032                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                // AgentInstaller::install() derives the agent name from the
1085                // bundle *filename* (not the manifest content), so name it
1086                // to match what we assert on below.
1087                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        // A package that is neither a local directory nor a .leviath-bundle
1135        // file must fail with a clear message, never a network attempt.
1136        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    // ─── path detection ────────────────────────────────────────────────────
1159
1160    #[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    // ─── parse_agent_name additional ──────────────────────────────────────
1182
1183    #[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        // toml uses double quotes, but our parser uses trim_matches('"')
1196        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    // ─── copy_dir_recursive with nested dirs ──────────────────────────────
1213
1214    #[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    // ─── execute(): real entry point wrapper ───────────────────────────────
1233
1234    #[test]
1235    fn execute_real_wrapper_fails_fast_without_touching_real_agents_dir() {
1236        // Drives the real `execute()` (dirs::home_dir() + AgentInstaller::new()
1237        // + delegation to execute_with) - safe because a nonexistent
1238        // ".leviath-bundle" path bails out in execute_with's "Package file
1239        // not found" check before any real file under ~/.leviath/agents is
1240        // ever touched.
1241        let rt = tokio::runtime::Runtime::new().unwrap();
1242        with_tracing(|| {
1243            rt.block_on(async {
1244                // Isolated: `execute` reads the active config, to report
1245                // `[read_paths]` grant status on what it installs.
1246                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        // Drives `execute`'s `resolve_agents_dir()?` error-propagation
1261        // branch for real via the test-only `FORCE_AGENTS_DIR_ERROR` toggle
1262        // on `resolve_agents_dir`'s twin (see its doc comment for why the
1263        // real implementation's failure can't be forced directly).
1264        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    // ─── install_from_dir with valid manifest ─────────────────────────────
1282
1283    #[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}