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/// What `lev run --json` prints on a successful spawn.
232///
233/// `lev run` hands the agent to the daemon and returns, so the run id is the
234/// only handle a caller gets on the work it just started. Parsing it back out of
235/// `spawned <id>` meant a caller had to match on prose; this is the same
236/// information in a shape that does not change when the sentence does.
237#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
238pub struct SpawnedRun {
239    /// The run id to poll with `lev ps --json` and stop with `lev cancel`.
240    pub run_id: String,
241    /// The manifest the run was resolved from.
242    pub blueprint_path: String,
243    /// The directory the agent's file tools are confined to.
244    pub workdir: String,
245    /// Whether the run was started unattended.
246    pub yolo: bool,
247}
248
249/// Render a spawn outcome for printing: JSON when `json`, else the sentence.
250///
251/// Split from [`send_spawn`] so both shapes are testable without a daemon.
252pub fn spawn_report(spawned: &SpawnedRun, json: bool) -> String {
253    match json {
254        // Four owned scalars with no map keys to reject, so this cannot fail.
255        true => serde_json::to_string_pretty(spawned).expect("a spawn report serializes"),
256        false => format!("spawned {}", spawned.run_id),
257    }
258}
259
260/// Send a resolved spawn request to the daemon and report the outcome, printing
261/// the new run id on success.
262///
263/// Warnings go to stderr, so `--json` leaves stdout parseable on its own.
264pub async fn send_spawn(
265    client: &ControlClient,
266    spawn_args: SpawnArgs,
267    json: bool,
268) -> anyhow::Result<()> {
269    warn_ungranted_read_paths(&spawn_args);
270    let blueprint_path = spawn_args.blueprint_path.clone();
271    let workdir = spawn_args.workdir.clone();
272    let yolo = spawn_args.yolo;
273    match client.spawn(spawn_args).await {
274        Ok(ControlResponse::Spawned { run_id }) => {
275            let spawned = SpawnedRun {
276                run_id,
277                blueprint_path,
278                workdir,
279                yolo,
280            };
281            println!("{}", spawn_report(&spawned, json));
282            Ok(())
283        }
284        Ok(ControlResponse::Error { message }) => bail!("spawn failed: {message}"),
285        Ok(other) => bail!("unexpected daemon response: {other:?}"),
286        Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use leviath_runtime::control_socket::{ControlId, bind_control_listener, control_id};
294    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
295    use tokio::task::JoinHandle;
296
297    fn write_manifest(dir: &std::path::Path) -> std::path::PathBuf {
298        std::fs::write(
299            dir.join("agent.leviath"),
300            crate::test_support::inline_coder_manifest(),
301        )
302        .unwrap();
303        dir.join("agent.leviath")
304    }
305
306    #[test]
307    fn resolve_spawn_args_finds_manifest_and_builds_request() {
308        let dir = tempfile::tempdir().unwrap();
309        let agent_dir = dir.path().join("my-agent");
310        std::fs::create_dir_all(&agent_dir).unwrap();
311        let manifest = write_manifest(&agent_dir);
312
313        let args = resolve_spawn_args(
314            manifest.to_str().unwrap(),
315            Some("do it"),
316            &never_interactive,
317            Some("m".to_string()),
318            "/work",
319            false,
320            Vec::new(),
321            None,
322            HashMap::new(),
323            false,
324        )
325        .unwrap();
326        assert!(args.run_id.contains("my-agent"));
327        assert_eq!(args.task, "do it");
328        assert_eq!(args.model.as_deref(), Some("m"));
329        assert_eq!(
330            args.blueprint_path,
331            std::fs::canonicalize(&manifest).unwrap().to_string_lossy()
332        );
333        assert_eq!(args.workdir, "/work");
334    }
335
336    /// The daemon has its own working directory, so a relative `PATH` has to be
337    /// resolved before the request leaves. `lev run .` used to reach the daemon
338    /// as `./agent.leviath` and fail there, which is the very command
339    /// `lev create` prints as the next step.
340    #[test]
341    fn resolve_spawn_args_sends_an_absolute_blueprint_path_for_a_relative_input() {
342        // Reading the CWD is enough to race the tests that *move* it: one of
343        // them chdirs into a directory it then deletes, and a relative path
344        // resolved against that instant cannot be found. Take the same lock
345        // they do, so this only ever reads a CWD that is standing still.
346        let _guard = crate::config::isolate_cwd_for_test();
347        // Rooted in the current directory rather than the system temp dir, so
348        // the relative path is trivially expressible. A temp dir is not
349        // guaranteed to share a drive with the cwd, and on the Windows runner
350        // it does not: the checkout is on D: and TEMP is on C:, between which
351        // no relative path exists at all.
352        let dir = tempfile::Builder::new()
353            .prefix("lev-relpath-")
354            .tempdir_in(".")
355            .unwrap();
356        let agent_dir = dir.path().join("my-agent");
357        std::fs::create_dir_all(&agent_dir).unwrap();
358        write_manifest(&agent_dir);
359
360        // `tempdir_in` hands back an absolute path even for a relative base, so
361        // the relative form is rebuilt from its name.
362        let relative = std::path::Path::new(".")
363            .join(dir.path().file_name().unwrap())
364            .join("my-agent");
365        // A static message on purpose: a `relative.display()` in here is only
366        // evaluated when the assertion fails, which leaves it as a permanently
367        // uncovered region under the 100% gate.
368        assert!(relative.is_relative(), "expected a relative path");
369
370        let args = resolve_spawn_args(
371            relative.to_str().unwrap(),
372            Some("do it"),
373            &never_interactive,
374            None,
375            "/work",
376            false,
377            Vec::new(),
378            None,
379            HashMap::new(),
380            false,
381        )
382        .unwrap();
383        assert!(
384            std::path::Path::new(&args.blueprint_path).is_absolute(),
385            "got: {}",
386            args.blueprint_path
387        );
388        assert!(args.blueprint_path.ends_with("agent.leviath"));
389    }
390
391    #[test]
392    fn resolve_spawn_args_errors_on_missing_manifest() {
393        assert!(
394            resolve_spawn_args(
395                "/no/such/agent",
396                Some("t"),
397                &never_interactive,
398                None,
399                "/work",
400                false,
401                Vec::new(),
402                None,
403                HashMap::new(),
404                false,
405            )
406            .is_err()
407        );
408    }
409
410    /// `--task <file>` end to end through the real wiring, not just through
411    /// `resolve_task` in isolation.
412    #[test]
413    fn resolve_spawn_args_reads_the_task_from_a_file() {
414        let dir = tempfile::tempdir().unwrap();
415        let agent_dir = dir.path().join("my-agent");
416        std::fs::create_dir_all(&agent_dir).unwrap();
417        let manifest = write_manifest(&agent_dir);
418        let task_file = dir.path().join("task.md");
419        std::fs::write(&task_file, "  summarize the README  \n").unwrap();
420
421        let args = resolve_spawn_args(
422            manifest.to_str().unwrap(),
423            Some(task_file.to_str().unwrap()),
424            &never_interactive,
425            None,
426            "/work",
427            false,
428            Vec::new(),
429            None,
430            HashMap::new(),
431            false,
432        )
433        .unwrap();
434        assert_eq!(args.task, "summarize the README");
435    }
436
437    /// No `--task` and no terminal to open an editor on: the run is refused
438    /// here, before the daemon is contacted.
439    #[test]
440    fn resolve_spawn_args_without_a_task_errors_when_stdin_is_not_a_tty() {
441        let dir = tempfile::tempdir().unwrap();
442        let agent_dir = dir.path().join("my-agent");
443        std::fs::create_dir_all(&agent_dir).unwrap();
444        let manifest = write_manifest(&agent_dir);
445
446        let err = resolve_spawn_args(
447            manifest.to_str().unwrap(),
448            None,
449            &never_interactive,
450            None,
451            "/work",
452            false,
453            Vec::new(),
454            None,
455            HashMap::new(),
456            false,
457        )
458        .unwrap_err();
459        assert!(err.to_string().contains("No task provided"), "got: {err}");
460    }
461
462    /// Pins the ordering: a typo'd region flag must fail *before* the user is
463    /// dropped into an editor, or they type a paragraph and then lose it.
464    #[test]
465    fn resolve_spawn_args_rejects_a_bad_region_before_it_looks_at_the_task() {
466        let dir = tempfile::tempdir().unwrap();
467        let manifest = write_region_manifest(&dir.path().join("reviewer"));
468        let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
469
470        let err = resolve_spawn_args(
471            manifest.to_str().unwrap(),
472            None,
473            &never_interactive,
474            None,
475            "/work",
476            false,
477            Vec::new(),
478            None,
479            regions,
480            false,
481        )
482        .unwrap_err();
483        assert!(err.to_string().contains("unknown region"), "got: {err}");
484    }
485
486    /// Write a manifest declaring a `criteria` caller-input region, returning its
487    /// path.
488    fn write_region_manifest(dir: &std::path::Path) -> std::path::PathBuf {
489        std::fs::create_dir_all(dir).unwrap();
490        std::fs::write(
491            dir.join("agent.leviath"),
492            r#"
493[agent]
494name = "reviewer"
495
496[stages.main]
497mode = "autonomous"
498
499[stages.main.model]
500provider = "anthropic"
501model = "claude-sonnet-5"
502
503[context.regions]
504task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
505criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }
506conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
507"#,
508        )
509        .unwrap();
510        dir.join("agent.leviath")
511    }
512
513    #[test]
514    fn resolve_spawn_args_resolves_declared_region_and_reads_at_path() {
515        let dir = tempfile::tempdir().unwrap();
516        let manifest = write_region_manifest(&dir.path().join("reviewer"));
517        let policy = dir.path().join("policy.md");
518        std::fs::write(&policy, "  focus on safety  ").unwrap();
519
520        let regions = HashMap::from([(
521            "criteria".to_string(),
522            format!("@{}", policy.to_string_lossy()),
523        )]);
524        let args = resolve_spawn_args(
525            manifest.to_str().unwrap(),
526            Some("review it"),
527            &never_interactive,
528            None,
529            "/work",
530            false,
531            Vec::new(),
532            None,
533            regions,
534            false,
535        )
536        .unwrap();
537        // `@path` was read and trimmed.
538        assert_eq!(
539            args.regions.get("criteria").map(String::as_str),
540            Some("focus on safety")
541        );
542    }
543
544    #[test]
545    fn resolve_spawn_args_unknown_region_reports_none_when_no_caller_inputs() {
546        // A blueprint with zero caller-input regions: the error lists "(none)".
547        let dir = tempfile::tempdir().unwrap();
548        let agent_dir = dir.path().join("noinput");
549        std::fs::create_dir_all(&agent_dir).unwrap();
550        std::fs::write(
551            agent_dir.join("agent.leviath"),
552            r#"
553[agent]
554name = "noinput"
555
556[stages.main]
557mode = "autonomous"
558
559[stages.main.model]
560provider = "anthropic"
561model = "claude-sonnet-5"
562
563[context.regions]
564data = { kind = "pinned", max_tokens = 2000 }
565conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
566"#,
567        )
568        .unwrap();
569        let manifest = agent_dir.join("agent.leviath");
570        let regions = HashMap::from([("foo".to_string(), "x".to_string())]);
571        let err = resolve_spawn_args(
572            manifest.to_str().unwrap(),
573            Some("t"),
574            &never_interactive,
575            None,
576            "/work",
577            false,
578            Vec::new(),
579            None,
580            regions,
581            false,
582        )
583        .unwrap_err();
584        assert!(err.to_string().contains("(none)"), "got: {err}");
585    }
586
587    #[test]
588    fn resolve_spawn_args_manifest_read_error_surfaces() {
589        // `find_manifest` accepts a dir whose `agent.leviath` merely *exists*; when
590        // that entry is itself a directory, the client-side read fails (EISDIR).
591        let dir = tempfile::tempdir().unwrap();
592        let agent_dir = dir.path().join("dirmanifest");
593        std::fs::create_dir_all(agent_dir.join("agent.leviath")).unwrap();
594        let regions = HashMap::from([("x".to_string(), "y".to_string())]);
595        let err = resolve_spawn_args(
596            agent_dir.to_str().unwrap(),
597            Some("t"),
598            &never_interactive,
599            None,
600            "/work",
601            false,
602            Vec::new(),
603            None,
604            regions,
605            false,
606        )
607        .unwrap_err();
608        assert!(err.to_string().contains("read manifest"), "got: {err}");
609    }
610
611    #[test]
612    fn resolve_spawn_args_manifest_parse_error_surfaces() {
613        let dir = tempfile::tempdir().unwrap();
614        let agent_dir = dir.path().join("badtoml");
615        std::fs::create_dir_all(&agent_dir).unwrap();
616        std::fs::write(
617            agent_dir.join("agent.leviath"),
618            "this is : not = valid toml [[[",
619        )
620        .unwrap();
621        let regions = HashMap::from([("x".to_string(), "y".to_string())]);
622        let err = resolve_spawn_args(
623            agent_dir.join("agent.leviath").to_str().unwrap(),
624            Some("t"),
625            &never_interactive,
626            None,
627            "/work",
628            false,
629            Vec::new(),
630            None,
631            regions,
632            false,
633        )
634        .unwrap_err();
635        assert!(err.to_string().contains("parse manifest"), "got: {err}");
636    }
637
638    #[test]
639    fn resolve_spawn_args_region_value_bad_file_errors() {
640        // A declared region whose `@file` value can't be read → the error from
641        // read_region_value propagates out of resolve_spawn_args.
642        let dir = tempfile::tempdir().unwrap();
643        let manifest = write_region_manifest(&dir.path().join("reviewer"));
644        let regions = HashMap::from([("criteria".to_string(), "@/no/such/file.md".to_string())]);
645        let err = resolve_spawn_args(
646            manifest.to_str().unwrap(),
647            Some("review it"),
648            &never_interactive,
649            None,
650            "/work",
651            false,
652            Vec::new(),
653            None,
654            regions,
655            false,
656        )
657        .unwrap_err();
658        assert!(
659            err.to_string().contains("Failed to read region file"),
660            "got: {err}"
661        );
662    }
663
664    #[test]
665    fn resolve_spawn_args_rejects_unknown_region_flag() {
666        let dir = tempfile::tempdir().unwrap();
667        let manifest = write_region_manifest(&dir.path().join("reviewer"));
668        let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
669        let err = resolve_spawn_args(
670            manifest.to_str().unwrap(),
671            Some("review it"),
672            &never_interactive,
673            None,
674            "/work",
675            false,
676            Vec::new(),
677            None,
678            regions,
679            false,
680        )
681        .unwrap_err();
682        assert!(
683            err.to_string().contains("unknown region '--bogus'"),
684            "got: {err}"
685        );
686    }
687
688    /// Bind a control listener at a fresh id under `dir` and serve one canned
689    /// response, returning the id clients connect to and the server task.
690    fn fake_daemon(
691        dir: &std::path::Path,
692        response_line: &'static str,
693    ) -> (ControlId, JoinHandle<()>) {
694        let id = control_id(dir);
695        let mut listener = bind_control_listener(&id).unwrap();
696        let handle = tokio::spawn(async move {
697            let stream = listener
698                .accept()
699                .await
700                .expect("accept succeeds")
701                .expect("our own connection is admitted");
702            let (read_half, mut write_half) = tokio::io::split(stream);
703            let mut lines = BufReader::new(read_half).lines();
704            let _request = lines.next_line().await.unwrap();
705            write_half
706                .write_all(response_line.as_bytes())
707                .await
708                .unwrap();
709            write_half.write_all(b"\n").await.unwrap();
710        });
711        (id, handle)
712    }
713
714    async fn send(response_line: &'static str) -> anyhow::Result<()> {
715        let dir = tempfile::tempdir().unwrap();
716        let (id, server) = fake_daemon(dir.path(), response_line);
717        let result = send_spawn(&ControlClient::new(id), SpawnArgs::default(), false).await;
718        server.await.unwrap();
719        result
720    }
721
722    fn spawned() -> SpawnedRun {
723        SpawnedRun {
724            run_id: "run-abc".to_string(),
725            blueprint_path: "/agents/coder/agent.leviath".to_string(),
726            workdir: "/work".to_string(),
727            yolo: true,
728        }
729    }
730
731    #[test]
732    fn spawn_report_without_json_is_the_sentence() {
733        assert_eq!(spawn_report(&spawned(), false), "spawned run-abc");
734    }
735
736    #[test]
737    fn spawn_report_with_json_round_trips_every_field() {
738        // Parsing it back is the assertion that matters: a caller reads this to
739        // learn the id it has to poll, so the keys are the contract.
740        let parsed: SpawnedRun =
741            serde_json::from_str(&spawn_report(&spawned(), true)).expect("valid JSON");
742        assert_eq!(parsed, spawned());
743    }
744
745    // ─── the client-side [read_paths] warning ──────────────────────────
746
747    /// A blueprint declaring one absolute read path, so the same entry
748    /// compiles on every OS.
749    fn read_paths_blueprint() -> leviath_core::Blueprint {
750        leviath_core::manifest::parse_manifest(
751            r#"
752[agent]
753name = "cto"
754version = "0.1.0"
755description = "test"
756
757[stages.main]
758mode = "autonomous"
759
760[context.regions]
761system = { kind = "pinned", max_tokens = 1000 }
762
763[read_paths]
764allow = ["/data/runs"]
765"#,
766        )
767        .expect("blueprint parses")
768    }
769
770    /// The point of warning here at all: the person who typed `lev run` learns
771    /// the declaration is inert now, not at the first refused read.
772    #[test]
773    fn an_ungranted_declaration_warns_with_the_stanza_to_add() {
774        let lines = spawn_warning_lines(
775            &read_paths_blueprint(),
776            &crate::config::Config::default(),
777            std::path::Path::new("/work"),
778        );
779        let joined = lines.join("\n");
780        assert!(joined.contains("agent 'cto'"), "{joined}");
781        assert!(joined.contains("[agent_read_paths.cto]"), "{joined}");
782        assert!(joined.contains(r#"allow = ["/data/runs"]"#), "{joined}");
783    }
784
785    #[test]
786    fn a_granted_declaration_says_nothing() {
787        let mut config = crate::config::Config::default();
788        config.security.read_paths = vec!["/data/runs".to_string()];
789        assert!(
790            spawn_warning_lines(
791                &read_paths_blueprint(),
792                &config,
793                std::path::Path::new("/work")
794            )
795            .is_empty()
796        );
797    }
798
799    /// No declaration, nothing to say - and a config whose own grant list is
800    /// broken is the daemon's error to report, not a warning to guess at.
801    #[test]
802    fn nothing_to_warn_about_produces_no_lines() {
803        let plain =
804            leviath_core::manifest::parse_manifest(&crate::test_support::inline_coder_manifest())
805                .expect("blueprint parses");
806        assert!(
807            spawn_warning_lines(
808                &plain,
809                &crate::config::Config::default(),
810                std::path::Path::new("/work")
811            )
812            .is_empty()
813        );
814
815        let mut broken = crate::config::Config::default();
816        broken.security.read_paths = vec!["regex:relative/.*".to_string()];
817        assert!(
818            spawn_warning_lines(
819                &read_paths_blueprint(),
820                &broken,
821                std::path::Path::new("/work")
822            )
823            .is_empty()
824        );
825    }
826
827    /// End to end over the real files: a manifest on disk plus an isolated
828    /// config that grants nothing.
829    #[tokio::test]
830    async fn the_warning_reads_the_manifest_and_the_active_config() {
831        let dir = tempfile::tempdir().unwrap();
832        let manifest = dir.path().join("agent.leviath");
833        std::fs::write(
834            &manifest,
835            crate::test_support::inline_coder_manifest()
836                + "\n[read_paths]\nallow = [\"/data/runs\"]\n",
837        )
838        .unwrap();
839        let args = SpawnArgs {
840            blueprint_path: manifest.to_string_lossy().into_owned(),
841            workdir: dir.path().to_string_lossy().into_owned(),
842            ..SpawnArgs::default()
843        };
844        let lines = crate::config::with_isolated_config_path_async(
845            "spawn-warn-read-paths",
846            |_fake| async move {
847                let lines = read_path_warning_for_spawn(&args);
848                warn_ungranted_read_paths(&args);
849                lines
850            },
851        )
852        .await;
853        let joined = lines.join("\n");
854        assert!(joined.contains("1 declared, 0 granted"), "{joined}");
855        assert!(joined.contains("[agent_read_paths.coder]"), "{joined}");
856    }
857
858    /// Every way the warning can decline to run: a manifest that will not
859    /// parse, and a config that will not load. Neither may stop a spawn.
860    #[test]
861    fn the_warning_gives_up_quietly_on_a_broken_manifest_or_config() {
862        let dir = tempfile::tempdir().unwrap();
863        let manifest = dir.path().join("agent.leviath");
864        std::fs::write(&manifest, "not valid toml [[[").unwrap();
865        assert!(
866            read_path_warning_for_spawn(&SpawnArgs {
867                blueprint_path: manifest.to_string_lossy().into_owned(),
868                ..SpawnArgs::default()
869            })
870            .is_empty()
871        );
872
873        std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
874        crate::config::with_isolated_config_path("spawn-warn-broken-config", |fake_dir| {
875            std::fs::write(fake_dir.join("config.toml"), "not = valid = toml").unwrap();
876            assert!(
877                read_path_warning_for_spawn(&SpawnArgs {
878                    blueprint_path: manifest.to_string_lossy().into_owned(),
879                    ..SpawnArgs::default()
880                })
881                .is_empty()
882            );
883        });
884    }
885
886    #[tokio::test]
887    async fn send_spawn_reports_success() {
888        assert!(
889            send(r#"{"result":"spawned","run_id":"run-9"}"#)
890                .await
891                .is_ok()
892        );
893    }
894
895    #[tokio::test]
896    async fn send_spawn_reports_daemon_error() {
897        let err = send(r#"{"result":"error","message":"boom"}"#)
898            .await
899            .unwrap_err();
900        assert!(err.to_string().contains("boom"));
901    }
902
903    #[tokio::test]
904    async fn send_spawn_reports_unexpected_response() {
905        let err = send(r#"{"result":"ok","ok":true}"#).await.unwrap_err();
906        assert!(err.to_string().contains("unexpected"));
907    }
908
909    #[tokio::test]
910    async fn send_spawn_errors_when_daemon_absent() {
911        let dir = tempfile::tempdir().unwrap();
912        // A control id with no daemon bound to it.
913        let id = control_id(&dir.path().join("no-daemon"));
914        let err = send_spawn(&ControlClient::new(id), SpawnArgs::default(), false)
915            .await
916            .unwrap_err();
917        assert!(err.to_string().contains("not reachable"));
918    }
919}