Skip to main content

leviath_cli/daemon/
client.rs

1//! Client-side helpers for talking to the shared-world daemon: building a spawn
2//! request from local inputs and exchanging it over the control socket. Shared by
3//! `lev run` (and reusable by other clients). The socket-path resolution + connect
4//! live in the binary; these cores are unit-testable against a fake socket server.
5
6use std::collections::HashMap;
7
8use anyhow::bail;
9use leviath_core::layout::RegionSeed;
10use leviath_runtime::control_socket::{ControlClient, ControlResponse};
11use leviath_runtime::host::SpawnArgs;
12
13use crate::commands::run::manifest::find_manifest;
14use crate::commands::run::task::{read_region_value, resolve_task};
15use crate::runstate::new_run_id;
16
17/// Everything a spawn request needs from the agent's own files.
18pub struct AgentSource {
19    /// The resolved `agent.leviath` path.
20    pub manifest: std::path::PathBuf,
21    /// The manifest's parent directory name, which the run id is minted from.
22    /// Deliberately not `blueprint.name`: the run id is what `lev ps` shows and
23    /// what identifies the checkout on disk, while the blueprint's own name is
24    /// what the agent calls itself.
25    pub run_stem: String,
26    pub blueprint: leviath_core::Blueprint,
27}
28
29/// Find the agent's manifest and parse it, once.
30///
31/// The parse is unconditional. It used to happen only when there were region
32/// flags to validate, but the blueprint's name and description are now needed
33/// for the editor template too, and parsing here is strictly better regardless:
34/// it is the same parser the daemon runs on the same file moments later, so a
35/// manifest that fails here would have failed there, and `parse manifest: <toml
36/// error>` before the daemon is contacted beats a spawn rejection after.
37pub fn load_agent_source(path: &str) -> anyhow::Result<AgentSource> {
38    let found = find_manifest(path)?;
39    // Absolute, because this path is about to be handed to the daemon, which
40    // has its own working directory. `lev run .` and `lev run ./demo` resolve
41    // fine here and then arrive there as `./agent.leviath`, which the daemon
42    // reads relative to wherever it happens to have been started - so the spawn
43    // failed with "read manifest './agent.leviath': No such file or directory".
44    // `lev create` prints `lev run .` as its next step, so this was the first
45    // thing a new user hit.
46    //
47    // Best-effort rather than fallible: `find_manifest` only returns paths it
48    // has already confirmed resolve, so a failure here needs the file to vanish
49    // between the two calls. Falling back to what it found leaves the old
50    // behavior, which is a legible daemon-side error, rather than inventing an
51    // error arm no test can reach.
52    let manifest = std::fs::canonicalize(&found).unwrap_or(found);
53    let run_stem = manifest
54        .parent()
55        .and_then(|p| p.file_name())
56        .and_then(|n| n.to_str())
57        .unwrap_or("agent")
58        .to_string();
59    let content = std::fs::read_to_string(&manifest)
60        .map_err(|e| anyhow::anyhow!("read manifest '{}': {e}", manifest.display()))?;
61    let blueprint = leviath_core::manifest::parse_manifest(&content)
62        .map_err(|e| anyhow::anyhow!("parse manifest: {e}"))?;
63    Ok(AgentSource {
64        manifest,
65        run_stem,
66        blueprint,
67    })
68}
69
70/// Validate and resolve the dynamic `--<region>` flag values against the
71/// blueprint's declared caller-input regions.
72///
73/// An unknown region name (one the blueprint doesn't read as caller input) is a
74/// hard error - fast, local typo protection before the daemon is contacted.
75fn resolve_regions(
76    blueprint: &leviath_core::Blueprint,
77    regions: HashMap<String, String>,
78) -> anyhow::Result<HashMap<String, String>> {
79    let declared: Vec<String> = blueprint
80        .context_layout
81        .regions
82        .iter()
83        .filter_map(|r| match &r.seed {
84            Some(RegionSeed::CallerInput { name }) => Some(name.clone()),
85            _ => None,
86        })
87        .collect();
88    let mut out = HashMap::new();
89    for (name, raw) in regions {
90        if !declared.contains(&name) {
91            bail!(
92                "unknown region '--{name}'; this agent's caller-input regions are: {}",
93                if declared.is_empty() {
94                    "(none)".to_string()
95                } else {
96                    declared.join(", ")
97                }
98            );
99        }
100        out.insert(name, read_region_value(&raw)?);
101    }
102    Ok(out)
103}
104
105/// The stdin probe for callers that build a spawn request from inside the
106/// daemon: fan-out workers and sub-agents. There is no terminal there, and an
107/// editor launched from a background process would block it forever with
108/// nobody to close the window.
109///
110/// Those callers always have a task in hand, so the probe is never actually
111/// consulted; passing this rather than a bare `|| false` states the reason at
112/// each call site.
113pub fn never_interactive() -> bool {
114    false
115}
116
117/// Resolve the local inputs of a spawn request: find and parse the manifest,
118/// resolve the `--<region>` flags, resolve the task, and mint a run id from the
119/// agent's directory name.
120///
121/// `task` is what `--task` was given, if anything. Left off, [`resolve_task`]
122/// opens the user's editor, which is why `stdin_is_terminal` is threaded
123/// through: the probe itself is real I/O and belongs to the binary, so callers
124/// inject it (tests pass a `fn` that always says no).
125///
126/// Regions are resolved *before* the task on purpose. A typo'd `--foo` has to
127/// fail before the user is dropped into an editor and types a paragraph they
128/// are about to lose.
129#[allow(clippy::too_many_arguments)]
130pub fn resolve_spawn_args(
131    path: &str,
132    task: Option<&str>,
133    stdin_is_terminal: &dyn Fn() -> bool,
134    model: Option<String>,
135    workdir: &str,
136    yolo: bool,
137    allow: Vec<String>,
138    max_depth: Option<usize>,
139    regions: HashMap<String, String>,
140    no_seed_commands: bool,
141) -> anyhow::Result<SpawnArgs> {
142    let source = load_agent_source(path)?;
143    let resolved_regions = resolve_regions(&source.blueprint, regions)?;
144    let task = resolve_task(
145        task,
146        &source.blueprint.name,
147        &source.blueprint.description,
148        stdin_is_terminal,
149    )?;
150
151    Ok(SpawnArgs {
152        run_id: new_run_id(&source.run_stem),
153        blueprint_path: source.manifest.to_string_lossy().to_string(),
154        task,
155        regions: resolved_regions,
156        model,
157        workdir: workdir.to_string(),
158        metadata: Default::default(),
159        callback_url: None,
160        callback_secret: None,
161        yolo,
162        no_seed_commands,
163        allow,
164        max_depth,
165        // A top-level run (sub-agents/fan-out set this on the host side).
166        parent_run_id: None,
167    })
168}
169
170/// Warn, on stderr, when the agent about to run declares `[read_paths]` the
171/// active config does not grant.
172///
173/// The daemon already logs this at spawn, but into its own log, where the
174/// person who just typed `lev run` never sees it - so the first sign of a
175/// missing grant was a refused read partway through a run. Everything needed to
176/// say it here is local: `lev run` resolves the manifest itself, and the config
177/// is the same file the daemon reads.
178///
179/// Best-effort by design. An unreadable manifest or config is the daemon's to
180/// report, and it will: this must never be the reason a run does not start.
181fn warn_ungranted_read_paths(spawn_args: &SpawnArgs) {
182    for line in read_path_warning_for_spawn(spawn_args) {
183        eprintln!("{line}");
184    }
185}
186
187/// The warning for a spawn request, read from the real manifest and config.
188/// Empty when there is nothing to say, and empty when either file cannot be
189/// read: see [`warn_ungranted_read_paths`] for why that is not an error here.
190fn read_path_warning_for_spawn(spawn_args: &SpawnArgs) -> Vec<String> {
191    let Ok(content) = std::fs::read_to_string(&spawn_args.blueprint_path) else {
192        return Vec::new();
193    };
194    let Ok(blueprint) = leviath_core::manifest::parse_manifest(&content) else {
195        return Vec::new();
196    };
197    let Ok(config) = crate::config::Config::load() else {
198        return Vec::new();
199    };
200    spawn_warning_lines(
201        &blueprint,
202        &config,
203        std::path::Path::new(&spawn_args.workdir),
204    )
205}
206
207/// The warning itself: one line saying what is refused, then the stanza that
208/// would grant it. Pure, so the wording is testable without a daemon.
209fn spawn_warning_lines(
210    blueprint: &leviath_core::Blueprint,
211    config: &crate::config::Config,
212    workdir: &std::path::Path,
213) -> Vec<String> {
214    let Some(Ok(report)) = crate::read_path_report::build(blueprint, config, workdir) else {
215        return Vec::new();
216    };
217    let Some(warning) = report.warning_line() else {
218        return Vec::new();
219    };
220    let mut lines = vec![warning];
221    lines.push("  add to your config.toml:".to_string());
222    lines.extend(
223        report
224            .grant_stanza()
225            .into_iter()
226            .map(|l| format!("    {l}")),
227    );
228    lines
229}
230
231/// Send a resolved spawn request to the daemon and report the outcome, printing
232/// the new run id on success.
233pub async fn send_spawn(client: &ControlClient, spawn_args: SpawnArgs) -> anyhow::Result<()> {
234    warn_ungranted_read_paths(&spawn_args);
235    match client.spawn(spawn_args).await {
236        Ok(ControlResponse::Spawned { run_id }) => {
237            println!("spawned {run_id}");
238            Ok(())
239        }
240        Ok(ControlResponse::Error { message }) => bail!("spawn failed: {message}"),
241        Ok(other) => bail!("unexpected daemon response: {other:?}"),
242        Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use leviath_runtime::control_socket::{ControlId, bind_control_listener, control_id};
250    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
251    use tokio::task::JoinHandle;
252
253    fn write_manifest(dir: &std::path::Path) -> std::path::PathBuf {
254        std::fs::write(
255            dir.join("agent.leviath"),
256            crate::test_support::inline_coder_manifest(),
257        )
258        .unwrap();
259        dir.join("agent.leviath")
260    }
261
262    #[test]
263    fn resolve_spawn_args_finds_manifest_and_builds_request() {
264        let dir = tempfile::tempdir().unwrap();
265        let agent_dir = dir.path().join("my-agent");
266        std::fs::create_dir_all(&agent_dir).unwrap();
267        let manifest = write_manifest(&agent_dir);
268
269        let args = resolve_spawn_args(
270            manifest.to_str().unwrap(),
271            Some("do it"),
272            &never_interactive,
273            Some("m".to_string()),
274            "/work",
275            false,
276            Vec::new(),
277            None,
278            HashMap::new(),
279            false,
280        )
281        .unwrap();
282        assert!(args.run_id.contains("my-agent"));
283        assert_eq!(args.task, "do it");
284        assert_eq!(args.model.as_deref(), Some("m"));
285        assert_eq!(
286            args.blueprint_path,
287            std::fs::canonicalize(&manifest).unwrap().to_string_lossy()
288        );
289        assert_eq!(args.workdir, "/work");
290    }
291
292    /// The daemon has its own working directory, so a relative `PATH` has to be
293    /// resolved before the request leaves. `lev run .` used to reach the daemon
294    /// as `./agent.leviath` and fail there, which is the very command
295    /// `lev create` prints as the next step.
296    #[test]
297    fn resolve_spawn_args_sends_an_absolute_blueprint_path_for_a_relative_input() {
298        // Reading the CWD is enough to race the tests that *move* it: one of
299        // them chdirs into a directory it then deletes, and a relative path
300        // resolved against that instant cannot be found. Take the same lock
301        // they do, so this only ever reads a CWD that is standing still.
302        let _guard = crate::config::isolate_cwd_for_test();
303        // Rooted in the current directory rather than the system temp dir, so
304        // the relative path is trivially expressible. A temp dir is not
305        // guaranteed to share a drive with the cwd, and on the Windows runner
306        // it does not: the checkout is on D: and TEMP is on C:, between which
307        // no relative path exists at all.
308        let dir = tempfile::Builder::new()
309            .prefix("lev-relpath-")
310            .tempdir_in(".")
311            .unwrap();
312        let agent_dir = dir.path().join("my-agent");
313        std::fs::create_dir_all(&agent_dir).unwrap();
314        write_manifest(&agent_dir);
315
316        // `tempdir_in` hands back an absolute path even for a relative base, so
317        // the relative form is rebuilt from its name.
318        let relative = std::path::Path::new(".")
319            .join(dir.path().file_name().unwrap())
320            .join("my-agent");
321        // A static message on purpose: a `relative.display()` in here is only
322        // evaluated when the assertion fails, which leaves it as a permanently
323        // uncovered region under the 100% gate.
324        assert!(relative.is_relative(), "expected a relative path");
325
326        let args = resolve_spawn_args(
327            relative.to_str().unwrap(),
328            Some("do it"),
329            &never_interactive,
330            None,
331            "/work",
332            false,
333            Vec::new(),
334            None,
335            HashMap::new(),
336            false,
337        )
338        .unwrap();
339        assert!(
340            std::path::Path::new(&args.blueprint_path).is_absolute(),
341            "got: {}",
342            args.blueprint_path
343        );
344        assert!(args.blueprint_path.ends_with("agent.leviath"));
345    }
346
347    #[test]
348    fn resolve_spawn_args_errors_on_missing_manifest() {
349        assert!(
350            resolve_spawn_args(
351                "/no/such/agent",
352                Some("t"),
353                &never_interactive,
354                None,
355                "/work",
356                false,
357                Vec::new(),
358                None,
359                HashMap::new(),
360                false,
361            )
362            .is_err()
363        );
364    }
365
366    /// `--task <file>` end to end through the real wiring, not just through
367    /// `resolve_task` in isolation.
368    #[test]
369    fn resolve_spawn_args_reads_the_task_from_a_file() {
370        let dir = tempfile::tempdir().unwrap();
371        let agent_dir = dir.path().join("my-agent");
372        std::fs::create_dir_all(&agent_dir).unwrap();
373        let manifest = write_manifest(&agent_dir);
374        let task_file = dir.path().join("task.md");
375        std::fs::write(&task_file, "  summarize the README  \n").unwrap();
376
377        let args = resolve_spawn_args(
378            manifest.to_str().unwrap(),
379            Some(task_file.to_str().unwrap()),
380            &never_interactive,
381            None,
382            "/work",
383            false,
384            Vec::new(),
385            None,
386            HashMap::new(),
387            false,
388        )
389        .unwrap();
390        assert_eq!(args.task, "summarize the README");
391    }
392
393    /// No `--task` and no terminal to open an editor on: the run is refused
394    /// here, before the daemon is contacted.
395    #[test]
396    fn resolve_spawn_args_without_a_task_errors_when_stdin_is_not_a_tty() {
397        let dir = tempfile::tempdir().unwrap();
398        let agent_dir = dir.path().join("my-agent");
399        std::fs::create_dir_all(&agent_dir).unwrap();
400        let manifest = write_manifest(&agent_dir);
401
402        let err = resolve_spawn_args(
403            manifest.to_str().unwrap(),
404            None,
405            &never_interactive,
406            None,
407            "/work",
408            false,
409            Vec::new(),
410            None,
411            HashMap::new(),
412            false,
413        )
414        .unwrap_err();
415        assert!(err.to_string().contains("No task provided"), "got: {err}");
416    }
417
418    /// Pins the ordering: a typo'd region flag must fail *before* the user is
419    /// dropped into an editor, or they type a paragraph and then lose it.
420    #[test]
421    fn resolve_spawn_args_rejects_a_bad_region_before_it_looks_at_the_task() {
422        let dir = tempfile::tempdir().unwrap();
423        let manifest = write_region_manifest(&dir.path().join("reviewer"));
424        let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
425
426        let err = resolve_spawn_args(
427            manifest.to_str().unwrap(),
428            None,
429            &never_interactive,
430            None,
431            "/work",
432            false,
433            Vec::new(),
434            None,
435            regions,
436            false,
437        )
438        .unwrap_err();
439        assert!(err.to_string().contains("unknown region"), "got: {err}");
440    }
441
442    /// Write a manifest declaring a `criteria` caller-input region, returning its
443    /// path.
444    fn write_region_manifest(dir: &std::path::Path) -> std::path::PathBuf {
445        std::fs::create_dir_all(dir).unwrap();
446        std::fs::write(
447            dir.join("agent.leviath"),
448            r#"
449[agent]
450name = "reviewer"
451
452[stages.main]
453mode = "autonomous"
454
455[stages.main.model]
456provider = "anthropic"
457model = "claude-sonnet-5"
458
459[context.regions]
460task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
461criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }
462conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
463"#,
464        )
465        .unwrap();
466        dir.join("agent.leviath")
467    }
468
469    #[test]
470    fn resolve_spawn_args_resolves_declared_region_and_reads_at_path() {
471        let dir = tempfile::tempdir().unwrap();
472        let manifest = write_region_manifest(&dir.path().join("reviewer"));
473        let policy = dir.path().join("policy.md");
474        std::fs::write(&policy, "  focus on safety  ").unwrap();
475
476        let regions = HashMap::from([(
477            "criteria".to_string(),
478            format!("@{}", policy.to_string_lossy()),
479        )]);
480        let args = resolve_spawn_args(
481            manifest.to_str().unwrap(),
482            Some("review it"),
483            &never_interactive,
484            None,
485            "/work",
486            false,
487            Vec::new(),
488            None,
489            regions,
490            false,
491        )
492        .unwrap();
493        // `@path` was read and trimmed.
494        assert_eq!(
495            args.regions.get("criteria").map(String::as_str),
496            Some("focus on safety")
497        );
498    }
499
500    #[test]
501    fn resolve_spawn_args_unknown_region_reports_none_when_no_caller_inputs() {
502        // A blueprint with zero caller-input regions: the error lists "(none)".
503        let dir = tempfile::tempdir().unwrap();
504        let agent_dir = dir.path().join("noinput");
505        std::fs::create_dir_all(&agent_dir).unwrap();
506        std::fs::write(
507            agent_dir.join("agent.leviath"),
508            r#"
509[agent]
510name = "noinput"
511
512[stages.main]
513mode = "autonomous"
514
515[stages.main.model]
516provider = "anthropic"
517model = "claude-sonnet-5"
518
519[context.regions]
520data = { kind = "pinned", max_tokens = 2000 }
521conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
522"#,
523        )
524        .unwrap();
525        let manifest = agent_dir.join("agent.leviath");
526        let regions = HashMap::from([("foo".to_string(), "x".to_string())]);
527        let err = resolve_spawn_args(
528            manifest.to_str().unwrap(),
529            Some("t"),
530            &never_interactive,
531            None,
532            "/work",
533            false,
534            Vec::new(),
535            None,
536            regions,
537            false,
538        )
539        .unwrap_err();
540        assert!(err.to_string().contains("(none)"), "got: {err}");
541    }
542
543    #[test]
544    fn resolve_spawn_args_manifest_read_error_surfaces() {
545        // `find_manifest` accepts a dir whose `agent.leviath` merely *exists*; when
546        // that entry is itself a directory, the client-side read fails (EISDIR).
547        let dir = tempfile::tempdir().unwrap();
548        let agent_dir = dir.path().join("dirmanifest");
549        std::fs::create_dir_all(agent_dir.join("agent.leviath")).unwrap();
550        let regions = HashMap::from([("x".to_string(), "y".to_string())]);
551        let err = resolve_spawn_args(
552            agent_dir.to_str().unwrap(),
553            Some("t"),
554            &never_interactive,
555            None,
556            "/work",
557            false,
558            Vec::new(),
559            None,
560            regions,
561            false,
562        )
563        .unwrap_err();
564        assert!(err.to_string().contains("read manifest"), "got: {err}");
565    }
566
567    #[test]
568    fn resolve_spawn_args_manifest_parse_error_surfaces() {
569        let dir = tempfile::tempdir().unwrap();
570        let agent_dir = dir.path().join("badtoml");
571        std::fs::create_dir_all(&agent_dir).unwrap();
572        std::fs::write(
573            agent_dir.join("agent.leviath"),
574            "this is : not = valid toml [[[",
575        )
576        .unwrap();
577        let regions = HashMap::from([("x".to_string(), "y".to_string())]);
578        let err = resolve_spawn_args(
579            agent_dir.join("agent.leviath").to_str().unwrap(),
580            Some("t"),
581            &never_interactive,
582            None,
583            "/work",
584            false,
585            Vec::new(),
586            None,
587            regions,
588            false,
589        )
590        .unwrap_err();
591        assert!(err.to_string().contains("parse manifest"), "got: {err}");
592    }
593
594    #[test]
595    fn resolve_spawn_args_region_value_bad_file_errors() {
596        // A declared region whose `@file` value can't be read → the error from
597        // read_region_value propagates out of resolve_spawn_args.
598        let dir = tempfile::tempdir().unwrap();
599        let manifest = write_region_manifest(&dir.path().join("reviewer"));
600        let regions = HashMap::from([("criteria".to_string(), "@/no/such/file.md".to_string())]);
601        let err = resolve_spawn_args(
602            manifest.to_str().unwrap(),
603            Some("review it"),
604            &never_interactive,
605            None,
606            "/work",
607            false,
608            Vec::new(),
609            None,
610            regions,
611            false,
612        )
613        .unwrap_err();
614        assert!(
615            err.to_string().contains("Failed to read region file"),
616            "got: {err}"
617        );
618    }
619
620    #[test]
621    fn resolve_spawn_args_rejects_unknown_region_flag() {
622        let dir = tempfile::tempdir().unwrap();
623        let manifest = write_region_manifest(&dir.path().join("reviewer"));
624        let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
625        let err = resolve_spawn_args(
626            manifest.to_str().unwrap(),
627            Some("review it"),
628            &never_interactive,
629            None,
630            "/work",
631            false,
632            Vec::new(),
633            None,
634            regions,
635            false,
636        )
637        .unwrap_err();
638        assert!(
639            err.to_string().contains("unknown region '--bogus'"),
640            "got: {err}"
641        );
642    }
643
644    /// Bind a control listener at a fresh id under `dir` and serve one canned
645    /// response, returning the id clients connect to and the server task.
646    fn fake_daemon(
647        dir: &std::path::Path,
648        response_line: &'static str,
649    ) -> (ControlId, JoinHandle<()>) {
650        let id = control_id(dir);
651        let mut listener = bind_control_listener(&id).unwrap();
652        let handle = tokio::spawn(async move {
653            let stream = listener
654                .accept()
655                .await
656                .expect("accept succeeds")
657                .expect("our own connection is admitted");
658            let (read_half, mut write_half) = tokio::io::split(stream);
659            let mut lines = BufReader::new(read_half).lines();
660            let _request = lines.next_line().await.unwrap();
661            write_half
662                .write_all(response_line.as_bytes())
663                .await
664                .unwrap();
665            write_half.write_all(b"\n").await.unwrap();
666        });
667        (id, handle)
668    }
669
670    async fn send(response_line: &'static str) -> anyhow::Result<()> {
671        let dir = tempfile::tempdir().unwrap();
672        let (id, server) = fake_daemon(dir.path(), response_line);
673        let result = send_spawn(&ControlClient::new(id), SpawnArgs::default()).await;
674        server.await.unwrap();
675        result
676    }
677
678    // ─── the client-side [read_paths] warning ──────────────────────────
679
680    /// A blueprint declaring one absolute read path, so the same entry
681    /// compiles on every OS.
682    fn read_paths_blueprint() -> leviath_core::Blueprint {
683        leviath_core::manifest::parse_manifest(
684            r#"
685[agent]
686name = "cto"
687version = "0.1.0"
688description = "test"
689
690[stages.main]
691mode = "autonomous"
692
693[context.regions]
694system = { kind = "pinned", max_tokens = 1000 }
695
696[read_paths]
697allow = ["/data/runs"]
698"#,
699        )
700        .expect("blueprint parses")
701    }
702
703    /// The point of warning here at all: the person who typed `lev run` learns
704    /// the declaration is inert now, not at the first refused read.
705    #[test]
706    fn an_ungranted_declaration_warns_with_the_stanza_to_add() {
707        let lines = spawn_warning_lines(
708            &read_paths_blueprint(),
709            &crate::config::Config::default(),
710            std::path::Path::new("/work"),
711        );
712        let joined = lines.join("\n");
713        assert!(joined.contains("agent 'cto'"), "{joined}");
714        assert!(joined.contains("[agent_read_paths.cto]"), "{joined}");
715        assert!(joined.contains(r#"allow = ["/data/runs"]"#), "{joined}");
716    }
717
718    #[test]
719    fn a_granted_declaration_says_nothing() {
720        let mut config = crate::config::Config::default();
721        config.security.read_paths = vec!["/data/runs".to_string()];
722        assert!(
723            spawn_warning_lines(
724                &read_paths_blueprint(),
725                &config,
726                std::path::Path::new("/work")
727            )
728            .is_empty()
729        );
730    }
731
732    /// No declaration, nothing to say - and a config whose own grant list is
733    /// broken is the daemon's error to report, not a warning to guess at.
734    #[test]
735    fn nothing_to_warn_about_produces_no_lines() {
736        let plain =
737            leviath_core::manifest::parse_manifest(&crate::test_support::inline_coder_manifest())
738                .expect("blueprint parses");
739        assert!(
740            spawn_warning_lines(
741                &plain,
742                &crate::config::Config::default(),
743                std::path::Path::new("/work")
744            )
745            .is_empty()
746        );
747
748        let mut broken = crate::config::Config::default();
749        broken.security.read_paths = vec!["regex:relative/.*".to_string()];
750        assert!(
751            spawn_warning_lines(
752                &read_paths_blueprint(),
753                &broken,
754                std::path::Path::new("/work")
755            )
756            .is_empty()
757        );
758    }
759
760    /// End to end over the real files: a manifest on disk plus an isolated
761    /// config that grants nothing.
762    #[tokio::test]
763    async fn the_warning_reads_the_manifest_and_the_active_config() {
764        let dir = tempfile::tempdir().unwrap();
765        let manifest = dir.path().join("agent.leviath");
766        std::fs::write(
767            &manifest,
768            crate::test_support::inline_coder_manifest()
769                + "\n[read_paths]\nallow = [\"/data/runs\"]\n",
770        )
771        .unwrap();
772        let args = SpawnArgs {
773            blueprint_path: manifest.to_string_lossy().into_owned(),
774            workdir: dir.path().to_string_lossy().into_owned(),
775            ..SpawnArgs::default()
776        };
777        let lines = crate::config::with_isolated_config_path_async(
778            "spawn-warn-read-paths",
779            |_fake| async move {
780                let lines = read_path_warning_for_spawn(&args);
781                warn_ungranted_read_paths(&args);
782                lines
783            },
784        )
785        .await;
786        let joined = lines.join("\n");
787        assert!(joined.contains("1 declared, 0 granted"), "{joined}");
788        assert!(joined.contains("[agent_read_paths.coder]"), "{joined}");
789    }
790
791    /// Every way the warning can decline to run: a manifest that will not
792    /// parse, and a config that will not load. Neither may stop a spawn.
793    #[test]
794    fn the_warning_gives_up_quietly_on_a_broken_manifest_or_config() {
795        let dir = tempfile::tempdir().unwrap();
796        let manifest = dir.path().join("agent.leviath");
797        std::fs::write(&manifest, "not valid toml [[[").unwrap();
798        assert!(
799            read_path_warning_for_spawn(&SpawnArgs {
800                blueprint_path: manifest.to_string_lossy().into_owned(),
801                ..SpawnArgs::default()
802            })
803            .is_empty()
804        );
805
806        std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
807        crate::config::with_isolated_config_path("spawn-warn-broken-config", |fake_dir| {
808            std::fs::write(fake_dir.join("config.toml"), "not = valid = toml").unwrap();
809            assert!(
810                read_path_warning_for_spawn(&SpawnArgs {
811                    blueprint_path: manifest.to_string_lossy().into_owned(),
812                    ..SpawnArgs::default()
813                })
814                .is_empty()
815            );
816        });
817    }
818
819    #[tokio::test]
820    async fn send_spawn_reports_success() {
821        assert!(
822            send(r#"{"result":"spawned","run_id":"run-9"}"#)
823                .await
824                .is_ok()
825        );
826    }
827
828    #[tokio::test]
829    async fn send_spawn_reports_daemon_error() {
830        let err = send(r#"{"result":"error","message":"boom"}"#)
831            .await
832            .unwrap_err();
833        assert!(err.to_string().contains("boom"));
834    }
835
836    #[tokio::test]
837    async fn send_spawn_reports_unexpected_response() {
838        let err = send(r#"{"result":"ok","ok":true}"#).await.unwrap_err();
839        assert!(err.to_string().contains("unexpected"));
840    }
841
842    #[tokio::test]
843    async fn send_spawn_errors_when_daemon_absent() {
844        let dir = tempfile::tempdir().unwrap();
845        // A control id with no daemon bound to it.
846        let id = control_id(&dir.path().join("no-daemon"));
847        let err = send_spawn(&ControlClient::new(id), SpawnArgs::default())
848            .await
849            .unwrap_err();
850        assert!(err.to_string().contains("not reachable"));
851    }
852}