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/// Arguments for `lev add`.
7#[derive(Args)]
8pub struct AddArgs {
9    /// Path to an agent directory or a .leviath-bundle file
10    #[arg(value_name = "PACKAGE")]
11    pub package: String,
12}
13
14fn agents_dir_or_error(dir: Option<std::path::PathBuf>) -> anyhow::Result<std::path::PathBuf> {
15    dir.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
16}
17
18/// Run `lev add`: install an agent from a directory or a bundle file.
19pub async fn execute(args: AddArgs) -> anyhow::Result<()> {
20    let installer = leviath_package::AgentInstaller::new();
21    let agents_dir = resolve_agents_dir()?;
22    // Best-effort, unlike `lev list`: a config that will not parse is a reason
23    // to say less about the package being installed, never a reason to refuse
24    // to install it.
25    let config = crate::config::Config::load().ok();
26    execute_with(&args, &installer, &agents_dir, config.as_ref()).await
27}
28
29/// Resolve `~/.leviath/agents`, the install root for `lev add`.
30///
31/// A thin wrapper over [`agents_dir_or_error`] supplying the real resolved
32/// directory. The `#[cfg(test)]` guard below only lets tests force the
33/// "no home directory" error arm of `execute()` deterministically - the real
34/// the shared resolver can't be made to return `None` in any environment a
35/// test may safely create (on macOS `dirs::home_dir()` falls back to a
36/// passwd-database lookup independent of `$HOME`). It does NOT hide the real
37/// body from coverage: with the toggle off, `agents_dir_or_error(
38/// leviath_core::paths::agents_dir())` runs (and is measured) in every ordinary test. This
39/// only computes a `PathBuf`; the `None` arm of `agents_dir_or_error` is
40/// covered directly by `agents_dir_or_error_none_returns_error`.
41fn resolve_agents_dir() -> anyhow::Result<std::path::PathBuf> {
42    #[cfg(test)]
43    if FORCE_AGENTS_DIR_ERROR.with(|f| f.get()) {
44        anyhow::bail!("Could not determine home directory");
45    }
46    agents_dir_or_error(leviath_core::paths::agents_dir())
47}
48
49#[cfg(test)]
50thread_local! {
51    /// Test-only toggle letting `execute_returns_err_when_agents_dir_unresolvable`
52    /// force `resolve_agents_dir`'s `Err` arm deterministically.
53    static FORCE_AGENTS_DIR_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
54}
55
56/// Core `lev add` logic, parameterized by installer + agents base directory
57/// so it can be tested against tempdirs instead of the real
58/// `~/.leviath/agents`.
59async fn execute_with(
60    args: &AddArgs,
61    installer: &leviath_package::AgentInstaller,
62    agents_dir: &Path,
63    config: Option<&crate::config::Config>,
64) -> anyhow::Result<()> {
65    tracing::info!("Installing agent package");
66
67    let package_path = Path::new(&args.package);
68
69    if package_path.is_dir() {
70        // Directory install: copy directory into <agents_dir>/<name>/
71        install_from_dir(package_path, agents_dir, config)?;
72    } else if package_path.exists() || args.package.ends_with(".leviath-bundle") {
73        // Bundle file installation
74        if !package_path.exists() {
75            anyhow::bail!("Package file not found: {}", args.package);
76        }
77        println!("Installing from bundle: {}", args.package);
78        let installed = installer.install(package_path)?;
79        println!(
80            "Installed agent '{}' v{} to {}",
81            installed.name,
82            installed.version,
83            installed.path.display()
84        );
85        print_capabilities(&installed.name, &installed.path, config);
86    } else {
87        // Only local installs exist: agent directories and .leviath-bundle
88        // files. Fail with a clear message rather than guessing at intent.
89        anyhow::bail!(
90            "'{}' is not a local agent directory or a .leviath-bundle file - \
91             pass a path to one of those instead.",
92            args.package
93        );
94    }
95
96    Ok(())
97}
98
99/// The security-relevant things an agent package carries, as human-readable
100/// lines.
101///
102/// A bare "Installed agent 'x' to …" would never tell the user that the
103/// package ships executable `.rhai` tool scripts, pre-approves its own `shell`,
104/// turns the sandbox off, or runs a command at spawn before any prompt. Every
105/// one of those is a decision the user is making by installing, so `lev add`
106/// must surface them.
107///
108/// Empty means the package declares nothing unusual - a plain prompt-and-stages
109/// agent - in which case there is nothing to warn about and we stay quiet.
110///
111/// Pure over `(manifest_toml, dir_entries, read_paths)` so the whole table is
112/// testable without a filesystem or an installed agent. `read_paths` is the
113/// grant report for this package under the active config, when one could be
114/// built; without it the `[read_paths]` line falls back to stating the rule.
115pub(crate) fn describe_capabilities(
116    manifest_toml: &str,
117    script_tools: &[String],
118    read_paths: Option<&crate::read_path_report::GrantReport>,
119) -> Vec<String> {
120    let mut findings = Vec::new();
121    // `toml::from_str`, not `manifest_toml.parse::<toml::Value>()`. In toml 1.x
122    // `FromStr for Value` parses a single *value*, not a document - so a real
123    // manifest starting with `[agent]` reads as an array literal followed by
124    // junk and fails. It still compiles, so the change is silent; the tests are
125    // what caught it.
126    let Ok(value) = toml::from_str::<toml::Value>(manifest_toml) else {
127        // An unparseable manifest is reported by the installer itself; there is
128        // nothing to inventory.
129        return findings;
130    };
131
132    if !script_tools.is_empty() {
133        findings.push(format!(
134            "ships {} executable script tool(s): {}",
135            script_tools.len(),
136            script_tools.join(", ")
137        ));
138    }
139
140    // Tool permissions the package grants itself, at agent or stage level.
141    let mut granted: Vec<String> = Vec::new();
142    let mut collect_grants = |table: Option<&toml::Value>| {
143        if let Some(t) = table.and_then(|v| v.as_table()) {
144            for (tool, policy) in t {
145                if policy.as_str() == Some("allow") && !granted.contains(tool) {
146                    granted.push(tool.clone());
147                }
148            }
149        }
150    };
151    collect_grants(value.get("tool_permissions"));
152    if let Some(stages) = value.get("stages").and_then(|v| v.as_table()) {
153        for stage in stages.values() {
154            collect_grants(stage.get("tool_permissions"));
155        }
156    }
157    if !granted.is_empty() {
158        granted.sort();
159        findings.push(format!(
160            "pre-approves these tools (no prompt at run time): {}",
161            granted.join(", ")
162        ));
163    }
164
165    // Script host functions it grants itself.
166    if let Some(t) = value
167        .get("tool_script_permissions")
168        .and_then(|v| v.as_table())
169    {
170        let mut allowed: Vec<&String> = t
171            .iter()
172            .filter(|(_, v)| v.as_str() == Some("allow"))
173            .map(|(k, _)| k)
174            .collect();
175        if !allowed.is_empty() {
176            allowed.sort();
177            findings.push(format!(
178                "requests script host access: {}",
179                allowed
180                    .iter()
181                    .map(|s| s.as_str())
182                    .collect::<Vec<_>>()
183                    .join(", ")
184            ));
185        }
186    }
187
188    // A sandbox opt-out.
189    if let Some(kind) = value
190        .get("sandbox")
191        .and_then(|v| v.get("kind"))
192        .and_then(|v| v.as_str())
193        && kind == "none"
194    {
195        findings.push("asks to run tools directly on the host (sandbox = none)".to_string());
196    }
197
198    // Read paths beyond the workdir. Declaring is not granting - the entries
199    // are inert until the user's config grants them - but the ask itself is
200    // exactly what this inventory exists to surface.
201    if let Some(entries) = value
202        .get("read_paths")
203        .and_then(|v| v.get("allow"))
204        .and_then(|v| v.as_array())
205        && !entries.is_empty()
206    {
207        let listed: Vec<String> = entries
208            .iter()
209            .filter_map(|e| e.as_str().map(str::to_string))
210            .collect();
211        // With the active config in hand, say which of them are actually live
212        // rather than repeating the rule and leaving the user to work it out.
213        let status = match read_paths {
214            Some(report) if report.has_ungranted() => format!(
215                "; {} - grant the rest with [agent_read_paths.{}] in your config",
216                report.summary(),
217                report.agent
218            ),
219            Some(report) => format!("; {}, all granted by your config", report.summary()),
220            None => "; inert unless you grant it via [security] read_paths / \
221                     allow_blueprint_read_paths or [agent_read_paths.<name>] in your config"
222                .to_string(),
223        };
224        findings.push(format!(
225            "asks to read outside its workdir (read-only): {}{status}",
226            listed.join(", ")
227        ));
228    }
229
230    // Command seeds run at spawn, before the first inference and therefore
231    // before any approval prompt - the one place a manifest executes something
232    // without being asked.
233    let seed_commands = collect_seed_commands(&value);
234    for command in seed_commands {
235        findings.push(format!(
236            "runs this command at startup, before any prompt: `{command}`"
237        ));
238    }
239
240    findings
241}
242
243/// Every `seed = { command = "..." }` in a manifest, from agent-level and
244/// stage-level `[context.regions]` blocks alike.
245fn collect_seed_commands(value: &toml::Value) -> Vec<String> {
246    let mut out = Vec::new();
247    let mut scan = |regions: Option<&toml::Value>| {
248        if let Some(t) = regions.and_then(|v| v.as_table()) {
249            for region in t.values() {
250                if let Some(cmd) = region
251                    .get("seed")
252                    .and_then(|s| s.get("command"))
253                    .and_then(|c| c.as_str())
254                {
255                    out.push(cmd.to_string());
256                }
257            }
258        }
259    };
260    scan(value.get("context").and_then(|c| c.get("regions")));
261    if let Some(stages) = value.get("stages").and_then(|v| v.as_table()) {
262        for stage in stages.values() {
263            scan(stage.get("context").and_then(|c| c.get("regions")));
264        }
265    }
266    out
267}
268
269/// Print the capability inventory for a freshly installed agent, if it has one.
270fn print_capabilities(name: &str, install_dir: &Path, config: Option<&crate::config::Config>) {
271    let manifest = std::fs::read_to_string(install_dir.join("agent.leviath")).unwrap_or_default();
272    let scripts = script_tool_names(install_dir);
273    let report = read_path_report(&manifest, config);
274    let findings = describe_capabilities(&manifest, &scripts, report.as_ref());
275    if findings.is_empty() {
276        return;
277    }
278    println!("\n  '{name}' asks for the following. Review before running it:");
279    for finding in &findings {
280        println!("    - {finding}");
281    }
282    println!("  Inspect it with:  lev validate {name}");
283}
284
285/// The `[read_paths]` grant report for a just-installed manifest, when there is
286/// a config to judge it against and the manifest parses.
287///
288/// The workdir a relative entry resolves against is the directory a `lev run`
289/// would default to, which at install time is the one `lev add` was run from.
290/// A broken grant list yields no report: the inventory falls back to stating
291/// the rule, and `lev validate` says what is wrong with the config.
292fn read_path_report(
293    manifest_toml: &str,
294    config: Option<&crate::config::Config>,
295) -> Option<crate::read_path_report::GrantReport> {
296    let config = config?;
297    let blueprint = leviath_core::manifest::parse_manifest(manifest_toml).ok()?;
298    let workdir = crate::commands::resolve_cwd().unwrap_or_default();
299    crate::read_path_report::build(&blueprint, config, &workdir)?.ok()
300}
301
302/// Names of the `.rhai` tool scripts an installed agent ships.
303fn script_tool_names(install_dir: &Path) -> Vec<String> {
304    let mut names: Vec<String> = std::fs::read_dir(install_dir.join("tools"))
305        .into_iter()
306        .flatten()
307        .flatten()
308        // `DirEntry::file_name` rather than `path().file_name()`: the latter
309        // returns an `Option` that a directory entry can never actually be
310        // missing, leaving an arm no test can reach.
311        .filter_map(|e| {
312            let name = e.file_name().to_string_lossy().into_owned();
313            name.ends_with(".rhai").then_some(name)
314        })
315        .collect();
316    names.sort();
317    names
318}
319
320/// Copy a plain agent directory into `<agents_dir>/<name>/`.
321///
322/// The agent name is read from `agent.leviath` in the directory (falling back
323/// to the directory's own name).
324fn install_from_dir(
325    src: &Path,
326    agents_dir: &Path,
327    config: Option<&crate::config::Config>,
328) -> anyhow::Result<()> {
329    let manifest_path = src.join("agent.leviath");
330    if !manifest_path.exists() {
331        anyhow::bail!(
332            "No agent.leviath found in '{}'. Is this an agent directory?",
333            src.display()
334        );
335    }
336
337    // Read the manifest to extract the agent name
338    let content = std::fs::read_to_string(&manifest_path)?;
339    let name = parse_agent_name(&content).unwrap_or_else(|| {
340        src.file_name()
341            .and_then(|n| n.to_str())
342            .unwrap_or("unknown")
343            .to_string()
344    });
345
346    let install_dir = agents_dir.join(&name);
347
348    if install_dir.exists() {
349        println!("Reinstalling agent '{}' (replacing existing)", name);
350        std::fs::remove_dir_all(&install_dir)?;
351    }
352
353    copy_dir_recursive(src, &install_dir)?;
354    println!("Installed agent '{}' to {}", name, install_dir.display());
355    print_capabilities(&name, &install_dir, config);
356    println!("Run with:  lev run {} --task \"...\"", name);
357    Ok(())
358}
359
360#[cfg(test)]
361thread_local! {
362    /// Test-only toggle letting a test force the `Err` arm of a
363    /// mid-iteration `ReadDir` entry deterministically (see
364    /// [`unwrap_dir_entry`]) - the real failure mode (the directory handle
365    /// becoming invalid mid-iteration: deleted out from under the process,
366    /// an NFS ESTALE, or similar) is a genuine OS-level race that can't be
367    /// reproduced deterministically across Linux/macOS/Windows CI.
368    static FORCE_DIR_ENTRY_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
369}
370
371/// Unwrap one `ReadDir` iteration result, with a test-only failure-injection
372/// toggle (see [`FORCE_DIR_ENTRY_ERROR`]) so the `Err` arm - `ReadDir::next()`
373/// failing after `read_dir` already succeeded in opening the directory --
374/// can be exercised deterministically without needing to actually race the
375/// filesystem.
376fn unwrap_dir_entry(
377    entry: std::io::Result<std::fs::DirEntry>,
378) -> anyhow::Result<std::fs::DirEntry> {
379    #[cfg(test)]
380    if FORCE_DIR_ENTRY_ERROR.with(|f| f.get()) {
381        anyhow::bail!("forced dir-entry error for testing");
382    }
383    Ok(entry?)
384}
385
386/// Recursively copy a directory tree.
387fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
388    std::fs::create_dir_all(dst)?;
389    for entry in std::fs::read_dir(src)? {
390        let entry = unwrap_dir_entry(entry)?;
391        let src_path = entry.path();
392        let dst_path = dst.join(entry.file_name());
393        if src_path.is_dir() {
394            copy_dir_recursive(&src_path, &dst_path)?;
395        } else {
396            std::fs::copy(&src_path, &dst_path)?;
397        }
398    }
399    Ok(())
400}
401
402/// Parse the agent name from an `agent.leviath` manifest (first `name = "..."` line).
403fn parse_agent_name(content: &str) -> Option<String> {
404    for line in content.lines() {
405        let trimmed = line.trim();
406        if let Some(rest) = trimmed.strip_prefix("name") {
407            let rest = rest.trim_start_matches(|c: char| c.is_whitespace() || c == '=');
408            let name = rest.trim().trim_matches('"');
409            if !name.is_empty() {
410                return Some(name.to_string());
411            }
412        }
413    }
414    None
415}
416
417#[cfg(test)]
418mod capability_tests {
419    use std::path::Path;
420
421    /// The inventory with no config to judge `[read_paths]` against - the
422    /// fallback wording, and what every test here predating grant reporting
423    /// assumed. The grant-aware tests below pass a real report.
424    fn describe_capabilities(manifest_toml: &str, script_tools: &[String]) -> Vec<String> {
425        super::describe_capabilities(manifest_toml, script_tools, None)
426    }
427
428    /// A plain agent declares nothing unusual, so the inventory stays quiet -
429    /// a warning that fires on everything teaches people to skip it.
430    #[test]
431    fn an_ordinary_agent_has_nothing_to_report() {
432        let manifest = "[agent]\nname = \"x\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
433                        [stages.main]\nsystem_prompt = \"p\"\n";
434        assert!(describe_capabilities(manifest, &[]).is_empty());
435    }
436
437    #[test]
438    fn script_tools_are_listed_by_name() {
439        let findings = describe_capabilities(
440            "[agent]\nname = \"x\"\n",
441            &["web_fetch.rhai".to_string(), "post.rhai".to_string()],
442        );
443        assert_eq!(findings.len(), 1);
444        assert!(findings[0].contains("2 executable script tool"));
445        assert!(findings[0].contains("web_fetch.rhai"));
446    }
447
448    /// The case that matters most: a package that pre-approves its own shell.
449    /// Under the permission floor a user's explicit config still wins, but where
450    /// the user has said nothing this is a real grant they should see.
451    #[test]
452    fn self_granted_tool_permissions_are_reported() {
453        let manifest = "[agent]\nname = \"x\"\n\n\
454                        [tool_permissions]\nshell = \"allow\"\nread_file = \"ask\"\n";
455        let findings = describe_capabilities(manifest, &[]);
456        assert_eq!(findings.len(), 1);
457        assert!(findings[0].contains("pre-approves"));
458        assert!(findings[0].contains("shell"));
459        // `ask` is the default posture, not a grant.
460        assert!(!findings[0].contains("read_file"));
461    }
462
463    /// A `[tool_script_permissions]` table that only *tightens* is not a grant,
464    /// so it must not appear in the inventory - the same "quiet unless there is
465    /// something to say" rule the ordinary-agent case establishes.
466    #[test]
467    fn a_script_permission_table_that_grants_nothing_is_not_reported() {
468        let manifest = "[agent]\nname = \"x\"\n\n\
469                        [tool_script_permissions]\nenv_var = \"deny\"\nhttp_get = \"ask\"\n";
470        assert!(
471            describe_capabilities(manifest, &[]).is_empty(),
472            "denying host access is not a capability to warn about"
473        );
474    }
475
476    #[test]
477    fn stage_level_grants_are_reported_too() {
478        let manifest = "[agent]\nname = \"x\"\n\n\
479                        [stages.build.tool_permissions]\nwrite_file = \"allow\"\n";
480        let findings = describe_capabilities(manifest, &[]);
481        assert!(findings[0].contains("write_file"), "{findings:?}");
482    }
483
484    #[test]
485    fn script_host_grants_and_sandbox_opt_out_are_reported() {
486        let manifest = "[agent]\nname = \"x\"\n\n\
487                        [tool_script_permissions]\nshell = \"allow\"\nhttp_post = \"allow\"\n\n\
488                        [sandbox]\nkind = \"none\"\n";
489        let findings = describe_capabilities(manifest, &[]);
490        let joined = findings.join(" | ");
491        assert!(joined.contains("script host access"), "{joined}");
492        assert!(joined.contains("http_post"), "{joined}");
493        assert!(joined.contains("sandbox = none"), "{joined}");
494    }
495
496    /// A command seed runs at spawn - before the first inference and therefore
497    /// before any approval prompt. It is the one thing a manifest executes
498    /// without being asked, so the exact command is shown.
499    #[test]
500    fn command_seeds_are_reported_verbatim() {
501        let manifest = "[agent]\nname = \"x\"\n\n\
502                        [context.regions]\n\
503                        repo = { kind = \"pinned\", seed = { command = \"git ls-files\" } }\n";
504        let findings = describe_capabilities(manifest, &[]);
505        assert_eq!(findings.len(), 1);
506        assert!(findings[0].contains("before any prompt"), "{findings:?}");
507        assert!(findings[0].contains("git ls-files"), "{findings:?}");
508    }
509
510    #[test]
511    fn stage_level_command_seeds_are_reported() {
512        let manifest = "[agent]\nname = \"x\"\n\n\
513                        [stages.discover.context.regions]\n\
514                        env = { kind = \"pinned\", seed = { command = \"curl https://evil\" } }\n";
515        let findings = describe_capabilities(manifest, &[]);
516        assert!(findings[0].contains("curl https://evil"), "{findings:?}");
517    }
518
519    /// A sandbox the manifest *opts into* is not a warning - only opting out is.
520    #[test]
521    fn opting_into_a_sandbox_is_not_reported() {
522        let manifest = "[agent]\nname = \"x\"\n\n[sandbox]\nkind = \"container\"\n";
523        assert!(describe_capabilities(manifest, &[]).is_empty());
524    }
525
526    /// `[read_paths]` is an ask to see beyond the workdir - listed verbatim,
527    /// with the reminder that it stays inert until the user's config grants it.
528    #[test]
529    fn read_path_declarations_are_reported() {
530        let manifest = "[agent]\nname = \"x\"\n\n\
531                        [read_paths]\n\
532                        allow = [\"~/.leviath/runs\", \"glob:~/design-docs/**\"]\n";
533        let findings = describe_capabilities(manifest, &[]);
534        assert_eq!(findings.len(), 1);
535        assert!(
536            findings[0].contains("read outside its workdir"),
537            "{findings:?}"
538        );
539        assert!(findings[0].contains("~/.leviath/runs"), "{findings:?}");
540        assert!(
541            findings[0].contains("glob:~/design-docs/**"),
542            "{findings:?}"
543        );
544        assert!(
545            findings[0].contains("inert unless you grant it"),
546            "{findings:?}"
547        );
548    }
549
550    /// With a config to judge against, the inventory says which entries are
551    /// live instead of restating the rule. This is what someone installing an
552    /// agent on a fresh machine needs to know.
553    #[test]
554    fn read_path_declarations_carry_their_grant_status() {
555        let manifest = "[agent]\nname = \"cto\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
556                        [stages.main]\nmode = \"autonomous\"\n\n\
557                        [context.regions]\nsystem = { kind = \"pinned\", max_tokens = 1000 }\n\n\
558                        [read_paths]\nallow = [\"/data/runs\", \"/data/docs\"]\n";
559        let blueprint = leviath_core::manifest::parse_manifest(manifest).expect("parses");
560
561        let mut config = crate::config::Config::default();
562        config.security.read_paths = vec!["/data/runs".to_string()];
563        let partial = crate::read_path_report::build(&blueprint, &config, Path::new("/work"))
564            .expect("declares read paths")
565            .expect("grants compile");
566        let findings = super::describe_capabilities(manifest, &[], Some(&partial));
567        assert!(
568            findings[0].contains("2 declared, 1 granted"),
569            "{findings:?}"
570        );
571        assert!(
572            findings[0].contains("[agent_read_paths.cto]"),
573            "{findings:?}"
574        );
575
576        config.security.read_paths.push("/data/docs".to_string());
577        let full = crate::read_path_report::build(&blueprint, &config, Path::new("/work"))
578            .expect("declares read paths")
579            .expect("grants compile");
580        let findings = super::describe_capabilities(manifest, &[], Some(&full));
581        assert!(findings[0].contains("all granted"), "{findings:?}");
582    }
583
584    /// An empty `allow` array asks for nothing - stay quiet.
585    #[test]
586    fn an_empty_read_paths_block_is_not_reported() {
587        let manifest = "[agent]\nname = \"x\"\n\n[read_paths]\nallow = []\n";
588        assert!(describe_capabilities(manifest, &[]).is_empty());
589    }
590
591    /// Every way the grant report can be unavailable at install time: no
592    /// config to judge against, and a manifest the parser refuses. Both fall
593    /// back to stating the rule rather than guessing.
594    #[test]
595    fn no_grant_report_is_built_without_a_config_or_a_parseable_manifest() {
596        let manifest = "[agent]\nname = \"x\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
597                        [stages.main]\nmode = \"autonomous\"\n\n\
598                        [context.regions]\nsystem = { kind = \"pinned\", max_tokens = 1000 }\n\n\
599                        [read_paths]\nallow = [\"/data/runs\"]\n";
600        assert!(super::read_path_report(manifest, None).is_none());
601        assert!(
602            super::read_path_report(
603                "not valid toml [[[",
604                Some(&crate::config::Config::default())
605            )
606            .is_none()
607        );
608
609        // A package that declares nothing has nothing to report either.
610        let plain = "[agent]\nname = \"x\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
611                     [stages.main]\nmode = \"autonomous\"\n\n\
612                     [context.regions]\nsystem = { kind = \"pinned\", max_tokens = 1000 }\n";
613        assert!(super::read_path_report(plain, Some(&crate::config::Config::default())).is_none());
614
615        // Nor does one whose grants cannot be compiled to judge it against.
616        let mut broken = crate::config::Config::default();
617        broken.security.read_paths = vec!["regex:relative/.*".to_string()];
618        assert!(super::read_path_report(manifest, Some(&broken)).is_none());
619
620        // And the ordinary case, so the fallbacks are not the only path tested.
621        let report = super::read_path_report(manifest, Some(&crate::config::Config::default()))
622            .expect("a parseable manifest and a config give a report");
623        assert_eq!(report.declared(), 1);
624    }
625
626    /// The `tools/` scan that feeds the inventory: only `.rhai` files count, and
627    /// they come back sorted so the message is stable between runs.
628    #[test]
629    fn script_tool_names_lists_only_rhai_files_sorted() {
630        let dir = tempfile::tempdir().unwrap();
631        let tools = dir.path().join("tools");
632        std::fs::create_dir(&tools).unwrap();
633        for name in ["zeta.rhai", "alpha.rhai", "README.md", "notes.txt"] {
634            std::fs::write(tools.join(name), "x").unwrap();
635        }
636        assert_eq!(
637            super::script_tool_names(dir.path()),
638            vec!["alpha.rhai".to_string(), "zeta.rhai".to_string()]
639        );
640    }
641
642    /// An agent with no `tools/` directory at all - the common case.
643    #[test]
644    fn script_tool_names_is_empty_without_a_tools_directory() {
645        let dir = tempfile::tempdir().unwrap();
646        assert!(super::script_tool_names(dir.path()).is_empty());
647    }
648
649    /// The end-to-end printer, over a directory rather than a string: it must
650    /// stay silent for an ordinary agent and speak for a demanding one.
651    #[test]
652    fn print_capabilities_reads_the_installed_directory() {
653        crate::test_support::with_tracing(|| {
654            let dir = tempfile::tempdir().unwrap();
655            std::fs::write(
656                dir.path().join("agent.leviath"),
657                "[agent]\nname = \"q\"\n\n[tool_permissions]\nshell = \"allow\"\n",
658            )
659            .unwrap();
660            let tools = dir.path().join("tools");
661            std::fs::create_dir(&tools).unwrap();
662            std::fs::write(tools.join("t.rhai"), "// @tool t\n").unwrap();
663            super::print_capabilities("q", dir.path(), None);
664
665            // And the quiet path: a plain agent prints nothing.
666            let plain = tempfile::tempdir().unwrap();
667            std::fs::write(
668                plain.path().join("agent.leviath"),
669                "[agent]\nname = \"p\"\n\n[stages.main]\nsystem_prompt = \"p\"\n",
670            )
671            .unwrap();
672            super::print_capabilities("p", plain.path(), None);
673        });
674    }
675
676    #[test]
677    fn an_unparseable_manifest_reports_nothing() {
678        assert!(describe_capabilities("{ not toml", &[]).is_empty());
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685    use crate::test_support::{with_tracing, write_test_agent};
686
687    /// The installers with no config to judge `[read_paths]` against, which is
688    /// what every test here predating grant reporting assumed.
689    async fn execute_with(
690        args: &AddArgs,
691        installer: &leviath_package::AgentInstaller,
692        agents_dir: &Path,
693    ) -> anyhow::Result<()> {
694        super::execute_with(args, installer, agents_dir, None).await
695    }
696
697    fn install_from_dir(src: &Path, agents_dir: &Path) -> anyhow::Result<()> {
698        super::install_from_dir(src, agents_dir, None)
699    }
700
701    // ─── agents_dir_or_error ─────────────────────────────────────────────
702
703    #[test]
704    fn agents_dir_or_error_some_returns_path() {
705        let dir = std::path::PathBuf::from("/home/testuser/.leviath/agents");
706        assert_eq!(agents_dir_or_error(Some(dir.clone())).unwrap(), dir);
707    }
708
709    #[test]
710    fn agents_dir_or_error_none_returns_error() {
711        let err = agents_dir_or_error(None).unwrap_err();
712        assert!(
713            err.to_string()
714                .contains("Could not determine home directory")
715        );
716    }
717
718    // ─── parse_agent_name ──────────────────────────────────────────────────
719
720    #[test]
721    fn parse_agent_name_standard() {
722        let content = r#"
723name = "my-agent"
724version = "1.0"
725"#;
726        assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
727    }
728
729    #[test]
730    fn parse_agent_name_no_quotes() {
731        let content = r#"name = my-agent"#;
732        assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
733    }
734
735    #[test]
736    fn parse_agent_name_extra_whitespace() {
737        let content = r#"  name   =   "spacy-agent"  "#;
738        assert_eq!(parse_agent_name(content), Some("spacy-agent".to_string()));
739    }
740
741    #[test]
742    fn parse_agent_name_missing() {
743        let content = r#"
744version = "1.0"
745description = "test"
746"#;
747        assert_eq!(parse_agent_name(content), None);
748    }
749
750    #[test]
751    fn parse_agent_name_empty_value() {
752        let content = r#"name = """#;
753        assert_eq!(parse_agent_name(content), None);
754    }
755
756    // ─── copy_dir_recursive ────────────────────────────────────────────────
757
758    #[test]
759    fn copy_dir_recursive_copies_files() {
760        let src_dir = tempfile::tempdir().unwrap();
761        let dst_dir = tempfile::tempdir().unwrap();
762        let dst_path = dst_dir.path().join("copy");
763
764        std::fs::write(src_dir.path().join("file1.txt"), "hello").unwrap();
765        std::fs::create_dir_all(src_dir.path().join("sub")).unwrap();
766        std::fs::write(src_dir.path().join("sub/file2.txt"), "world").unwrap();
767
768        copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
769
770        assert!(dst_path.join("file1.txt").exists());
771        assert!(dst_path.join("sub/file2.txt").exists());
772        assert_eq!(
773            std::fs::read_to_string(dst_path.join("file1.txt")).unwrap(),
774            "hello"
775        );
776        assert_eq!(
777            std::fs::read_to_string(dst_path.join("sub/file2.txt")).unwrap(),
778            "world"
779        );
780    }
781
782    #[test]
783    fn copy_dir_recursive_empty_dir() {
784        let src_dir = tempfile::tempdir().unwrap();
785        let dst_dir = tempfile::tempdir().unwrap();
786        let dst_path = dst_dir.path().join("empty-copy");
787
788        copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
789        assert!(dst_path.exists());
790        assert!(dst_path.is_dir());
791    }
792
793    #[test]
794    fn copy_dir_recursive_nonexistent_src_errors() {
795        let dst_dir = tempfile::tempdir().unwrap();
796        let dst_path = dst_dir.path().join("dst");
797        let missing_src = dst_dir.path().join("does-not-exist");
798
799        let result = copy_dir_recursive(&missing_src, &dst_path);
800        assert!(result.is_err());
801    }
802
803    #[test]
804    fn copy_dir_recursive_dst_parent_is_file_errors() {
805        let tmp = tempfile::tempdir().unwrap();
806        let file_path = tmp.path().join("not-a-dir");
807        std::fs::write(&file_path, "x").unwrap();
808        let src = tempfile::tempdir().unwrap();
809        let dst = file_path.join("child");
810
811        let result = copy_dir_recursive(src.path(), &dst);
812        assert!(result.is_err());
813    }
814
815    #[test]
816    fn copy_dir_recursive_file_over_existing_dir_errors() {
817        // Copying a file onto a destination path that already exists as a
818        // directory fails on every platform (EISDIR / ERROR_ACCESS_DENIED),
819        // exercising the `std::fs::copy(...)?` error arm.
820        let src_dir = tempfile::tempdir().unwrap();
821        std::fs::write(src_dir.path().join("clash"), "top secret").unwrap();
822
823        let dst_dir = tempfile::tempdir().unwrap();
824        let dst_path = dst_dir.path().join("copy");
825        // Pre-create dst/clash as a directory so the file copy collides.
826        std::fs::create_dir_all(dst_path.join("clash")).unwrap();
827
828        let result = copy_dir_recursive(src_dir.path(), &dst_path);
829        assert!(result.is_err());
830    }
831
832    #[test]
833    fn copy_dir_recursive_recursion_error_propagates() {
834        // Exercises the recursive-call error-propagation branch OS-agnostically:
835        // the destination already has a *file* where the recursion needs to
836        // create a subdirectory, so the nested `create_dir_all` fails on every
837        // platform and that `Err` bubbles up through the parent's
838        // `copy_dir_recursive(...)?`.
839        let src_dir = tempfile::tempdir().unwrap();
840        let sub = src_dir.path().join("sub");
841        std::fs::create_dir_all(&sub).unwrap();
842        std::fs::write(sub.join("file.txt"), "data").unwrap();
843
844        let dst_dir = tempfile::tempdir().unwrap();
845        let dst_path = dst_dir.path().join("copy");
846        std::fs::create_dir_all(&dst_path).unwrap();
847        // Block the recursion's create_dir_all(dst/sub) with a file at that path.
848        std::fs::write(dst_path.join("sub"), "i am a file").unwrap();
849
850        let result = copy_dir_recursive(src_dir.path(), &dst_path);
851        assert!(result.is_err());
852    }
853
854    #[test]
855    fn copy_dir_recursive_forced_mid_iteration_entry_error() {
856        // Deterministically exercises `unwrap_dir_entry`'s `Err` arm (a real
857        // `ReadDir::next()` failure mid-iteration) without racing the
858        // filesystem, via the FORCE_DIR_ENTRY_ERROR test toggle.
859        let src_dir = tempfile::tempdir().unwrap();
860        std::fs::write(src_dir.path().join("file.txt"), "data").unwrap();
861
862        let dst_dir = tempfile::tempdir().unwrap();
863        let dst_path = dst_dir.path().join("copy");
864
865        FORCE_DIR_ENTRY_ERROR.with(|f| f.set(true));
866        let result = copy_dir_recursive(src_dir.path(), &dst_path);
867        FORCE_DIR_ENTRY_ERROR.with(|f| f.set(false));
868
869        assert!(result.is_err());
870    }
871
872    #[test]
873    fn unwrap_dir_entry_propagates_a_real_err_argument() {
874        // `unwrap_dir_entry`'s own `Ok(entry?)` `?` still has a real error
875        // arm distinct from the `FORCE_DIR_ENTRY_ERROR`-triggered early
876        // `bail!` above it (that toggle short-circuits *before* this line
877        // is ever reached) - `DirEntry` isn't constructible directly, but
878        // its `Result` wrapper doesn't need a real one to test the `Err`
879        // case: pass a synthetic `io::Error` straight in.
880        let result = unwrap_dir_entry(Err(std::io::Error::other("synthetic entry error")));
881        assert!(result.is_err());
882    }
883
884    // ─── install_from_dir ──────────────────────────────────────────────────
885
886    #[test]
887    fn install_from_dir_no_manifest_errors() {
888        let dir = tempfile::tempdir().unwrap();
889        let agents_dir = tempfile::tempdir().unwrap();
890        let result = install_from_dir(dir.path(), agents_dir.path());
891        assert!(result.is_err());
892        assert!(result.unwrap_err().to_string().contains("agent.leviath"));
893    }
894
895    #[test]
896    fn install_from_dir_copies_and_names_from_manifest() {
897        let src = tempfile::tempdir().unwrap();
898        let agents_dir = tempfile::tempdir().unwrap();
899        std::fs::write(
900            src.path().join("agent.leviath"),
901            "[agent]\nname = \"my-agent\"\n",
902        )
903        .unwrap();
904        std::fs::write(src.path().join("extra.txt"), "data").unwrap();
905
906        install_from_dir(src.path(), agents_dir.path()).unwrap();
907
908        let installed_dir = agents_dir.path().join("my-agent");
909        assert!(installed_dir.join("agent.leviath").exists());
910        assert!(installed_dir.join("extra.txt").exists());
911    }
912
913    #[test]
914    fn install_from_dir_falls_back_to_dirname_when_name_missing() {
915        let src = tempfile::tempdir().unwrap();
916        let agent_dir = src.path().join("my-dir-name");
917        std::fs::create_dir_all(&agent_dir).unwrap();
918        std::fs::write(agent_dir.join("agent.leviath"), "version = \"1.0\"\n").unwrap();
919        let agents_dir = tempfile::tempdir().unwrap();
920
921        install_from_dir(&agent_dir, agents_dir.path()).unwrap();
922
923        assert!(agents_dir.path().join("my-dir-name").exists());
924    }
925
926    #[test]
927    fn install_from_dir_reinstalls_existing() {
928        let src = tempfile::tempdir().unwrap();
929        std::fs::write(
930            src.path().join("agent.leviath"),
931            "[agent]\nname = \"dup-agent\"\n",
932        )
933        .unwrap();
934        let agents_dir = tempfile::tempdir().unwrap();
935
936        // Pre-create an existing install with a stale file that should be wiped.
937        let existing = agents_dir.path().join("dup-agent");
938        std::fs::create_dir_all(&existing).unwrap();
939        std::fs::write(existing.join("stale.txt"), "old").unwrap();
940
941        install_from_dir(src.path(), agents_dir.path()).unwrap();
942
943        assert!(!existing.join("stale.txt").exists());
944        assert!(existing.join("agent.leviath").exists());
945    }
946
947    #[test]
948    fn install_from_dir_invalid_utf8_manifest_errors() {
949        let dir = tempfile::tempdir().unwrap();
950        std::fs::write(dir.path().join("agent.leviath"), [0xFF, 0xFE, 0xFA]).unwrap();
951        let agents_dir = tempfile::tempdir().unwrap();
952
953        let result = install_from_dir(dir.path(), agents_dir.path());
954        assert!(result.is_err());
955    }
956
957    #[test]
958    fn install_from_dir_remove_dir_all_failure_errors() {
959        // The existing install target is a *file*, so `exists()` passes the
960        // reinstall guard but `remove_dir_all` (which requires a directory)
961        // fails on every platform, exercising that `?` arm.
962        let src = tempfile::tempdir().unwrap();
963        std::fs::write(
964            src.path().join("agent.leviath"),
965            "[agent]\nname = \"file-agent\"\n",
966        )
967        .unwrap();
968
969        let agents_dir = tempfile::tempdir().unwrap();
970        std::fs::write(agents_dir.path().join("file-agent"), "not a dir").unwrap();
971
972        let result = install_from_dir(src.path(), agents_dir.path());
973        assert!(result.is_err());
974    }
975
976    #[test]
977    fn install_from_dir_copy_failure_propagates() {
978        // `agents_dir` is itself a *file*, so `copy_dir_recursive`'s
979        // `create_dir_all` for the install target (a child path of a file)
980        // fails on every platform, and that `Err` propagates through
981        // `install_from_dir`'s `copy_dir_recursive(...)?`.
982        let src = tempfile::tempdir().unwrap();
983        std::fs::write(
984            src.path().join("agent.leviath"),
985            "[agent]\nname = \"broken-copy-agent\"\n",
986        )
987        .unwrap();
988        std::fs::write(src.path().join("extra.txt"), "data").unwrap();
989
990        let tmp = tempfile::tempdir().unwrap();
991        let agents_file = tmp.path().join("agents-is-a-file");
992        std::fs::write(&agents_file, "not a dir").unwrap();
993
994        let result = install_from_dir(src.path(), &agents_file);
995        assert!(result.is_err());
996    }
997
998    // ─── execute_with: directory + bundle-file paths ───────────────────────
999
1000    #[test]
1001    fn execute_with_directory_package_installs() {
1002        let rt = tokio::runtime::Runtime::new().unwrap();
1003        with_tracing(|| {
1004            rt.block_on(async {
1005                let src = tempfile::tempdir().unwrap();
1006                std::fs::write(
1007                    src.path().join("agent.leviath"),
1008                    "[agent]\nname = \"dir-pkg\"\n",
1009                )
1010                .unwrap();
1011                let agents_dir = tempfile::tempdir().unwrap();
1012                let installer = leviath_package::AgentInstaller::with_install_dir(
1013                    agents_dir.path().to_path_buf(),
1014                );
1015                let args = AddArgs {
1016                    package: src.path().to_str().unwrap().to_string(),
1017                };
1018
1019                execute_with(&args, &installer, agents_dir.path())
1020                    .await
1021                    .unwrap();
1022
1023                assert!(agents_dir.path().join("dir-pkg").exists());
1024            })
1025        });
1026    }
1027
1028    #[test]
1029    fn execute_with_directory_without_manifest_errors() {
1030        let rt = tokio::runtime::Runtime::new().unwrap();
1031        with_tracing(|| {
1032            rt.block_on(async {
1033                let src = tempfile::tempdir().unwrap(); // no agent.leviath inside
1034                let agents_dir = tempfile::tempdir().unwrap();
1035                let installer = leviath_package::AgentInstaller::with_install_dir(
1036                    agents_dir.path().to_path_buf(),
1037                );
1038                let args = AddArgs {
1039                    package: src.path().to_str().unwrap().to_string(),
1040                };
1041
1042                let err = execute_with(&args, &installer, agents_dir.path())
1043                    .await
1044                    .unwrap_err();
1045                assert!(err.to_string().contains("agent.leviath"));
1046            })
1047        });
1048    }
1049
1050    #[test]
1051    fn execute_with_missing_bundle_file_errors() {
1052        let rt = tokio::runtime::Runtime::new().unwrap();
1053        with_tracing(|| {
1054            rt.block_on(async {
1055                let agents_dir = tempfile::tempdir().unwrap();
1056                let installer = leviath_package::AgentInstaller::with_install_dir(
1057                    agents_dir.path().to_path_buf(),
1058                );
1059                let args = AddArgs {
1060                    package: "nonexistent.leviath-bundle".to_string(),
1061                };
1062
1063                let err = execute_with(&args, &installer, agents_dir.path())
1064                    .await
1065                    .unwrap_err();
1066                assert!(err.to_string().contains("Package file not found"));
1067            })
1068        });
1069    }
1070
1071    #[test]
1072    fn execute_with_bundle_file_installs() {
1073        let rt = tokio::runtime::Runtime::new().unwrap();
1074        with_tracing(|| {
1075            rt.block_on(async {
1076                let project_dir = tempfile::tempdir().unwrap();
1077                std::fs::write(
1078                    project_dir.path().join("agent.leviath"),
1079                    "[agent]\nname = \"bundled-pkg\"\nversion = \"1.0.0\"\ndescription = \"d\"\n",
1080                )
1081                .unwrap();
1082                let bundle_bytes = leviath_package::AgentBundler::new()
1083                    .bundle(project_dir.path())
1084                    .unwrap();
1085                let bundle_dir = tempfile::tempdir().unwrap();
1086                // AgentInstaller::install() derives the agent name from the
1087                // bundle *filename* (not the manifest content), so name it
1088                // to match what we assert on below.
1089                let bundle_path = bundle_dir.path().join("bundled-pkg.leviath-bundle");
1090                std::fs::write(&bundle_path, bundle_bytes).unwrap();
1091
1092                let agents_dir = tempfile::tempdir().unwrap();
1093                let installer = leviath_package::AgentInstaller::with_install_dir(
1094                    agents_dir.path().to_path_buf(),
1095                );
1096                let args = AddArgs {
1097                    package: bundle_path.to_str().unwrap().to_string(),
1098                };
1099
1100                execute_with(&args, &installer, agents_dir.path())
1101                    .await
1102                    .unwrap();
1103
1104                assert!(agents_dir.path().join("bundled-pkg").exists());
1105            })
1106        });
1107    }
1108
1109    #[test]
1110    fn execute_with_corrupt_bundle_file_errors() {
1111        let rt = tokio::runtime::Runtime::new().unwrap();
1112        with_tracing(|| {
1113            rt.block_on(async {
1114                let bundle_dir = tempfile::tempdir().unwrap();
1115                let bundle_path = bundle_dir.path().join("broken.leviath-bundle");
1116                std::fs::write(&bundle_path, b"not a valid gzip archive").unwrap();
1117
1118                let agents_dir = tempfile::tempdir().unwrap();
1119                let installer = leviath_package::AgentInstaller::with_install_dir(
1120                    agents_dir.path().to_path_buf(),
1121                );
1122                let args = AddArgs {
1123                    package: bundle_path.to_str().unwrap().to_string(),
1124                };
1125
1126                let err = execute_with(&args, &installer, agents_dir.path())
1127                    .await
1128                    .unwrap_err();
1129                assert!(err.to_string().contains("Failed to extract package"));
1130            })
1131        });
1132    }
1133
1134    #[test]
1135    fn execute_with_unrecognized_package_reports_local_only() {
1136        // A package that is neither a local directory nor a .leviath-bundle
1137        // file must fail with a clear message, never a network attempt.
1138        let rt = tokio::runtime::Runtime::new().unwrap();
1139        with_tracing(|| {
1140            rt.block_on(async {
1141                let agents_dir = tempfile::tempdir().unwrap();
1142                let installer = leviath_package::AgentInstaller::with_install_dir(
1143                    agents_dir.path().to_path_buf(),
1144                );
1145                let args = AddArgs {
1146                    package: "some-registry-agent".to_string(),
1147                };
1148                let err = execute_with(&args, &installer, agents_dir.path())
1149                    .await
1150                    .unwrap_err();
1151                assert!(
1152                    err.to_string()
1153                        .contains("not a local agent directory or a .leviath-bundle file"),
1154                    "expected the v1-cut message, got: {err}"
1155                );
1156            })
1157        });
1158    }
1159
1160    // ─── path detection ────────────────────────────────────────────────────
1161
1162    #[test]
1163    fn bundle_extension_detected() {
1164        let package = "my-agent-1.0.leviath-bundle";
1165        assert!(package.ends_with(".leviath-bundle"));
1166    }
1167
1168    #[test]
1169    fn directory_path_detected() {
1170        let dir = tempfile::tempdir().unwrap();
1171        let package_path = Path::new(dir.path().to_str().unwrap());
1172        assert!(package_path.is_dir());
1173    }
1174
1175    #[test]
1176    fn registry_name_not_dir_not_bundle() {
1177        let package = "my-cool-agent";
1178        let package_path = Path::new(package);
1179        assert!(!package_path.is_dir());
1180        assert!(!package.ends_with(".leviath-bundle"));
1181    }
1182
1183    // ─── parse_agent_name additional ──────────────────────────────────────
1184
1185    #[test]
1186    fn parse_agent_name_in_section() {
1187        let content = r#"
1188[agent]
1189name = "my-agent"
1190version = "1.0"
1191"#;
1192        assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
1193    }
1194
1195    #[test]
1196    fn parse_agent_name_with_single_quotes() {
1197        // toml uses double quotes, but our parser uses trim_matches('"')
1198        let content = r#"name = my-agent-no-quotes"#;
1199        assert_eq!(
1200            parse_agent_name(content),
1201            Some("my-agent-no-quotes".to_string())
1202        );
1203    }
1204
1205    #[test]
1206    fn parse_agent_name_multiple_name_fields_returns_first() {
1207        let content = r#"
1208name = "first"
1209name = "second"
1210"#;
1211        assert_eq!(parse_agent_name(content), Some("first".to_string()));
1212    }
1213
1214    // ─── copy_dir_recursive with nested dirs ──────────────────────────────
1215
1216    #[test]
1217    fn copy_dir_recursive_deeply_nested() {
1218        let src_dir = tempfile::tempdir().unwrap();
1219        let dst_dir = tempfile::tempdir().unwrap();
1220        let dst_path = dst_dir.path().join("deep-copy");
1221
1222        std::fs::create_dir_all(src_dir.path().join("a/b/c")).unwrap();
1223        std::fs::write(src_dir.path().join("a/b/c/deep.txt"), "deep").unwrap();
1224
1225        copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
1226
1227        assert!(dst_path.join("a/b/c/deep.txt").exists());
1228        assert_eq!(
1229            std::fs::read_to_string(dst_path.join("a/b/c/deep.txt")).unwrap(),
1230            "deep"
1231        );
1232    }
1233
1234    // ─── execute(): real entry point wrapper ───────────────────────────────
1235
1236    #[test]
1237    fn execute_real_wrapper_fails_fast_without_touching_real_agents_dir() {
1238        // Drives the real `execute()` (dirs::home_dir() + AgentInstaller::new()
1239        // + delegation to execute_with) - safe because a nonexistent
1240        // ".leviath-bundle" path bails out in execute_with's "Package file
1241        // not found" check before any real file under ~/.leviath/agents is
1242        // ever touched.
1243        let rt = tokio::runtime::Runtime::new().unwrap();
1244        with_tracing(|| {
1245            rt.block_on(async {
1246                // Isolated: `execute` reads the active config, to report
1247                // `[read_paths]` grant status on what it installs.
1248                crate::config::with_isolated_config_path_async("add-real-wrapper", |_fake| async {
1249                    let args = AddArgs {
1250                        package: "definitely-not-a-real-bundle-xyz.leviath-bundle".to_string(),
1251                    };
1252                    let err = execute(args).await.unwrap_err();
1253                    assert!(err.to_string().contains("Package file not found"));
1254                })
1255                .await;
1256            })
1257        });
1258    }
1259
1260    #[test]
1261    fn execute_returns_err_when_agents_dir_unresolvable() {
1262        // Drives `execute`'s `resolve_agents_dir()?` error-propagation
1263        // branch for real via the test-only `FORCE_AGENTS_DIR_ERROR` toggle
1264        // on `resolve_agents_dir`'s twin (see its doc comment for why the
1265        // real implementation's failure can't be forced directly).
1266        let rt = tokio::runtime::Runtime::new().unwrap();
1267        FORCE_AGENTS_DIR_ERROR.with(|f| f.set(true));
1268        let result = rt.block_on(async {
1269            let args = AddArgs {
1270                package: "whatever.leviath-bundle".to_string(),
1271            };
1272            execute(args).await
1273        });
1274        FORCE_AGENTS_DIR_ERROR.with(|f| f.set(false));
1275
1276        let err = result.unwrap_err();
1277        assert!(
1278            err.to_string()
1279                .contains("Could not determine home directory")
1280        );
1281    }
1282
1283    // ─── install_from_dir with valid manifest ─────────────────────────────
1284
1285    #[test]
1286    fn install_from_dir_with_manifest_runs() {
1287        let dir = tempfile::tempdir().unwrap();
1288        let manifest = r#"
1289[agent]
1290name = "test-install-agent-xyz"
1291version = "0.1.0"
1292description = "test"
1293"#;
1294        write_test_agent(dir.path(), manifest);
1295        std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1296
1297        let agents_dir = tempfile::tempdir().unwrap();
1298        install_from_dir(dir.path(), agents_dir.path()).unwrap();
1299
1300        let install_dir = agents_dir.path().join("test-install-agent-xyz");
1301        assert!(install_dir.join("agent.leviath").exists());
1302        assert!(install_dir.join("readme.txt").exists());
1303    }
1304}