Skip to main content

layover_tower/
spawn.rs

1//! Starting one agent CLI and watching it finish.
2//!
3//! # Why the transcript is a file and not a buffer
4//!
5//! A run's output is the only account of what an agent actually did, and it is worth money. Held
6//! in memory it is lost the moment the supervisor dies — which is the case where it is most needed,
7//! because that is the run somebody will have to reconstruct. Streamed to a file it survives, it
8//! can be tailed live, and it costs nothing to keep.
9//!
10//! # Why the environment is built rather than inherited
11//!
12//! The child gets exactly the variables its agent named in `env_from`, and nothing else. Handing a
13//! child the supervisor's whole environment would give the telemetry agent the publishing
14//! credentials and the reviewer the cloud keys, so a single prompt injection anywhere would reach
15//! all of them. A variable that was named but is not set is an error, not a shrug: an agent
16//! starting without the credential it declared will fail somewhere further away, having already
17//! cost money.
18
19use std::collections::BTreeMap;
20use std::fmt;
21use std::fs::{self, File};
22use std::io;
23use std::path::{Path, PathBuf};
24use std::process::{Command, Stdio};
25
26use jiff::Timestamp;
27use layover_core::agent::AgentName;
28use layover_core::config::Runner;
29
30/// Everything needed to start one run.
31///
32/// Assembled by the caller from a factory definition and a flight; this module does not read
33/// configuration or decide anything about routing.
34#[derive(Debug, Clone)]
35pub struct Plan {
36    /// Which agent is running.
37    pub agent: AgentName,
38    /// How to invoke its CLI.
39    pub runner: Runner,
40    /// The model to pass, when the agent declared one.
41    pub model: Option<String>,
42    /// The composed payload, from `layover_core::payload::compose`.
43    pub payload: String,
44    /// Where this run's files go: the payload, the transcript, the state record.
45    pub hangar: PathBuf,
46    /// The directory the child runs in.
47    pub work_dir: PathBuf,
48    /// Variables to pass, already resolved from the supervisor's own environment.
49    pub env: BTreeMap<String, String>,
50    /// The MCP configuration written for this run, when the factory is serving one.
51    ///
52    /// Held as a path rather than as content because the flag a CLI takes names a file, and the
53    /// file has to outlive this struct — it is read by the child, after the spawn.
54    pub mcp_config: Option<PathBuf>,
55}
56
57/// A process that has been started and recorded.
58#[derive(Debug)]
59pub struct Started {
60    /// The running child.
61    child: std::process::Child,
62    /// Where its output is being written.
63    transcript: PathBuf,
64    /// When it began.
65    began: Timestamp,
66    /// Its process identifier, as recorded before the spawn.
67    identifier: u32,
68}
69
70impl Started {
71    /// The process identifier.
72    #[must_use]
73    pub const fn pid(&self) -> u32 {
74        self.identifier
75    }
76
77    /// Where the child's output is accumulating.
78    #[must_use]
79    pub fn transcript(&self) -> &Path {
80        &self.transcript
81    }
82
83    /// When the run began.
84    #[must_use]
85    pub const fn started_at(&self) -> Timestamp {
86        self.began
87    }
88
89    /// Whether the child has exited, without blocking.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`SpawnError::Io`] when the child cannot be checked.
94    pub fn try_wait(&mut self) -> Result<Option<std::process::ExitStatus>, SpawnError> {
95        self.child.try_wait().map_err(SpawnError::Io)
96    }
97
98    /// Reaps a child that has been killed, so it does not linger as a zombie.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`SpawnError::Io`] when the child cannot be waited on.
103    pub fn wait_after_kill(&mut self) -> Result<std::process::ExitStatus, SpawnError> {
104        self.child.wait().map_err(SpawnError::Io)
105    }
106
107    /// Turns a finished child into the record of how it ended.
108    #[must_use]
109    pub fn into_finished(self, status: std::process::ExitStatus) -> Finished {
110        Finished {
111            exit_code: status.code(),
112            finished_at: Timestamp::now(),
113            transcript: self.transcript,
114            started_at: self.began,
115        }
116    }
117
118    /// Waits for the child and reports how it ended.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`SpawnError::Io`] when the child cannot be waited on.
123    pub fn wait(mut self) -> Result<Finished, SpawnError> {
124        let status = self.child.wait().map_err(SpawnError::Io)?;
125        Ok(self.into_finished(status))
126    }
127}
128
129/// How a run ended.
130#[derive(Debug, Clone)]
131pub struct Finished {
132    /// The child's exit code, when it had one. `None` means it was killed by a signal.
133    pub exit_code: Option<i32>,
134    /// When it ended.
135    pub finished_at: Timestamp,
136    /// Where its output was written.
137    pub transcript: PathBuf,
138    /// When it began.
139    pub started_at: Timestamp,
140}
141
142impl Finished {
143    /// Whether the child reported success.
144    ///
145    /// A child killed by a signal has no exit code and did not succeed. Treating the absence of a
146    /// code as success would make a killed run indistinguishable from a clean one.
147    #[must_use]
148    pub fn succeeded(&self) -> bool {
149        self.exit_code == Some(0)
150    }
151}
152
153/// What can go wrong before a child is running.
154#[derive(Debug)]
155pub enum SpawnError {
156    /// A variable the agent declared is not set in the supervisor's environment.
157    MissingEnv {
158        /// The variable that is not set.
159        name: String,
160    },
161    /// The runner names no command at all.
162    EmptyCommand,
163    /// The filesystem or the process refused.
164    Io(io::Error),
165}
166
167impl fmt::Display for SpawnError {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        match self {
170            Self::MissingEnv { name } => write!(
171                f,
172                "`{name}` is named in `env_from` but is not set; the run would start without the \
173                 credential it declared and fail somewhere further away"
174            ),
175            Self::EmptyCommand => f.write_str("the runner names no command to run"),
176            Self::Io(error) => write!(f, "{error}"),
177        }
178    }
179}
180
181impl std::error::Error for SpawnError {
182    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
183        match self {
184            Self::Io(error) => Some(error),
185            _ => None,
186        }
187    }
188}
189
190impl From<io::Error> for SpawnError {
191    fn from(error: io::Error) -> Self {
192        Self::Io(error)
193    }
194}
195
196/// The file a run's composed instructions are written to.
197pub const PAYLOAD_FILE: &str = "prompt.md";
198
199/// The file a run's output is streamed to.
200pub const TRANSCRIPT_FILE: &str = "transcript.log";
201
202/// Variables a process needs to exist at all, as opposed to variables an agent was given.
203///
204/// `env_from` isolates *credentials*: the telemetry agent should not hold the publishing token.
205/// It was never meant to stop a child finding its own shell. Clearing the environment outright
206/// does exactly that — on Windows a command interpreter without `SystemRoot` cannot start, and on
207/// any platform a child without `PATH` cannot find the programs it shells out to. The symptom is
208/// a run that exits instantly with no useful output, which reads like the agent failing rather
209/// than like the supervisor having made it impossible to succeed.
210///
211/// So the child gets this, and what its agent declared, and nothing else. Nothing here carries a
212/// secret; every one of them is a fact about the machine.
213const BASE_ENV: [&str; 9] = [
214    "PATH",
215    // Windows: a command interpreter will not start without these two.
216    "SystemRoot",
217    "COMSPEC",
218    // Both: where a process is allowed to write scratch files.
219    "TEMP",
220    "TMP",
221    "TMPDIR",
222    // Unix: tools that look up the current user, and anything reading a dotfile.
223    "HOME",
224    "USER",
225    "LOGNAME",
226];
227
228/// Builds the environment a child receives: the machine's basics, plus what the agent declared.
229///
230/// Declared variables win, so a factory that deliberately overrides `PATH` for an agent gets the
231/// `PATH` it asked for.
232fn environment(declared: &BTreeMap<String, String>) -> BTreeMap<String, String> {
233    let mut env: BTreeMap<String, String> = BASE_ENV
234        .iter()
235        .filter_map(|name| {
236            std::env::var(name)
237                .ok()
238                .map(|value| ((*name).to_owned(), value))
239        })
240        .collect();
241
242    env.extend(declared.iter().map(|(k, v)| (k.clone(), v.clone())));
243    env
244}
245
246/// Starts the run described by `plan`.
247///
248/// The payload is written to the hangar first, so that it exists whether or not the runner wants a
249/// path to it — a run whose instructions cannot be read afterwards is a run nobody can explain.
250///
251/// # Errors
252///
253/// Returns [`SpawnError`] when a declared variable is unset, the command is empty, or the process
254/// cannot be started.
255pub fn start(plan: &Plan) -> Result<Started, SpawnError> {
256    if plan.runner.command.is_empty() {
257        return Err(SpawnError::EmptyCommand);
258    }
259
260    fs::create_dir_all(&plan.hangar)?;
261
262    // Written whether or not this runner takes a path. The transcript explains what the agent did;
263    // only this explains what it was asked.
264    let payload_path = plan.hangar.join(PAYLOAD_FILE);
265    fs::write(&payload_path, &plan.payload)?;
266
267    let payload_arg = plan
268        .runner
269        .takes_prompt_path()
270        .then(|| payload_path.display().to_string());
271
272    let mcp_arg = plan
273        .mcp_config
274        .as_ref()
275        .map(|path| path.display().to_string());
276
277    let argv = plan.runner.invocation_with_mcp(
278        payload_arg.as_deref(),
279        plan.model.as_deref(),
280        mcp_arg.as_deref(),
281    );
282    let (program, arguments) = argv.split_first().ok_or(SpawnError::EmptyCommand)?;
283
284    let transcript = plan.hangar.join(TRANSCRIPT_FILE);
285    // Both streams into one file, in the order the child produced them. Splitting them makes an
286    // error impossible to place against the work that caused it.
287    let sink = File::create(&transcript)?;
288    let sink_for_stderr = sink.try_clone()?;
289
290    let mut command = Command::new(program);
291    command
292        .args(arguments)
293        .current_dir(&plan.work_dir)
294        .env_clear()
295        .envs(environment(&plan.env))
296        .stdin(Stdio::piped())
297        .stdout(Stdio::from(sink))
298        .stderr(Stdio::from(sink_for_stderr));
299
300    let started_at = Timestamp::now();
301    let mut child = command.spawn()?;
302
303    // The payload goes to stdin even when the runner also took a path: a CLI that reads a file
304    // still needs its stdin closed, and one that does not needs the text.
305    if let Some(mut stdin) = child.stdin.take() {
306        use std::io::Write as _;
307        // A child that exits before reading closes the pipe, which is not an error here — the
308        // run has ended, and how it ended is the exit code's business.
309        let _ = stdin.write_all(plan.payload.as_bytes());
310    }
311
312    let identifier = child.id();
313
314    Ok(Started {
315        child,
316        transcript,
317        began: started_at,
318        identifier,
319    })
320}
321
322/// Resolves the variables an agent named, from the supervisor's own environment.
323///
324/// # Errors
325///
326/// Returns [`SpawnError::MissingEnv`] for the first name that is not set.
327pub fn env_from(names: &[String]) -> Result<BTreeMap<String, String>, SpawnError> {
328    let mut out = BTreeMap::new();
329
330    for name in names {
331        let value =
332            std::env::var(name).map_err(|_| SpawnError::MissingEnv { name: name.clone() })?;
333        out.insert(name.clone(), value);
334    }
335
336    Ok(out)
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    /// A command that exists on every platform CI and development run on, so the tests exercise a
344    /// real process rather than a mock of one.
345    fn echoing(text: &str) -> Runner {
346        let command = if cfg!(windows) {
347            vec!["cmd".to_owned(), "/c".to_owned(), format!("echo {text}")]
348        } else {
349            vec!["sh".to_owned(), "-c".to_owned(), format!("echo {text}")]
350        };
351
352        toml::from_str(&format!(
353            "command = [{}]",
354            command
355                .iter()
356                .map(|a| format!("{a:?}"))
357                .collect::<Vec<_>>()
358                .join(", ")
359        ))
360        .expect("parses")
361    }
362
363    fn failing() -> Runner {
364        let command = if cfg!(windows) {
365            r#"["cmd", "/c", "exit 3"]"#
366        } else {
367            r#"["sh", "-c", "exit 3"]"#
368        };
369        toml::from_str(&format!("command = {command}")).expect("parses")
370    }
371
372    struct Temp(PathBuf);
373
374    impl Temp {
375        fn new(name: &str) -> Self {
376            let path =
377                std::env::temp_dir().join(format!("layover-tower-{name}-{}", std::process::id()));
378            let _ = fs::remove_dir_all(&path);
379            fs::create_dir_all(&path).expect("temp dir");
380            Self(path)
381        }
382    }
383
384    impl Drop for Temp {
385        fn drop(&mut self) {
386            let _ = fs::remove_dir_all(&self.0);
387        }
388    }
389
390    fn plan(temp: &Temp, runner: Runner, payload: &str) -> Plan {
391        Plan {
392            agent: AgentName::new("tester"),
393            runner,
394            model: None,
395            payload: payload.to_owned(),
396            hangar: temp.0.join("hangar"),
397            work_dir: temp.0.clone(),
398            env: BTreeMap::new(),
399            mcp_config: None,
400        }
401    }
402
403    #[test]
404    fn a_run_really_starts_a_process_and_reports_how_it_ended() {
405        let temp = Temp::new("ok");
406        let started = start(&plan(&temp, echoing("hello"), "do the thing")).expect("starts");
407
408        assert!(started.pid() > 0, "a started run has a process identifier");
409
410        let finished = started.wait().expect("waits");
411        assert!(finished.succeeded(), "{:?}", finished.exit_code);
412        assert!(finished.finished_at >= finished.started_at);
413    }
414
415    #[test]
416    fn the_transcript_holds_what_the_child_wrote() {
417        let temp = Temp::new("transcript");
418        let finished = start(&plan(&temp, echoing("marker-42"), "go"))
419            .expect("starts")
420            .wait()
421            .expect("waits");
422
423        let text = fs::read_to_string(&finished.transcript).expect("transcript exists");
424        assert!(text.contains("marker-42"), "{text:?}");
425    }
426
427    #[test]
428    fn the_payload_is_written_where_it_can_be_read_afterwards() {
429        // The transcript says what the agent did. Only this says what it was asked, and without it
430        // a run that went wrong cannot be explained.
431        let temp = Temp::new("payload");
432        let asked = plan(&temp, echoing("x"), "You are `tester`.\n\nDo the thing.");
433        start(&asked).expect("starts").wait().expect("waits");
434
435        let written = fs::read_to_string(asked.hangar.join(PAYLOAD_FILE)).expect("payload exists");
436        assert_eq!(written, asked.payload);
437    }
438
439    #[test]
440    fn a_non_zero_exit_is_not_a_success() {
441        let temp = Temp::new("fail");
442        let finished = start(&plan(&temp, failing(), "go"))
443            .expect("starts")
444            .wait()
445            .expect("waits");
446
447        assert_eq!(finished.exit_code, Some(3));
448        assert!(!finished.succeeded());
449    }
450
451    #[test]
452    fn a_declared_variable_that_is_not_set_stops_the_run_before_it_costs_anything() {
453        // Starting without a credential the agent declared means failing somewhere further away,
454        // after the money has been spent.
455        let name = format!("LAYOVER_TEST_ABSENT_{}", std::process::id());
456        let error = env_from(std::slice::from_ref(&name)).expect_err("should refuse");
457
458        assert!(matches!(&error, SpawnError::MissingEnv { name: n } if *n == name));
459        assert!(error.to_string().contains("env_from"), "{error}");
460    }
461
462    #[test]
463    fn only_the_named_variables_are_resolved() {
464        // Safety-Q25: inheriting the supervisor's environment would hand every agent every other
465        // agent's credentials. `PATH` is used because it is always set, so the test needs to
466        // mutate no environment of its own.
467        let resolved = env_from(&["PATH".to_owned()]).expect("PATH is always set");
468
469        assert_eq!(resolved.len(), 1, "nothing else should come along");
470        assert!(resolved.contains_key("PATH"));
471    }
472
473    #[test]
474    fn nothing_named_means_an_empty_environment_not_an_inherited_one() {
475        let resolved = env_from(&[]).expect("resolves");
476        assert!(
477            resolved.is_empty(),
478            "an agent that declared no variables gets none, not all of them"
479        );
480    }
481
482    #[test]
483    fn a_child_gets_enough_environment_to_actually_run() {
484        // Found the hard way: clearing the environment outright leaves a child unable to start at
485        // all -- on Windows a command interpreter without `SystemRoot` simply exits -- and the
486        // symptom reads like the agent failing rather than like the supervisor having made
487        // success impossible.
488        let base = environment(&BTreeMap::new());
489
490        assert!(
491            base.contains_key("PATH"),
492            "a child cannot find anything without PATH"
493        );
494        if cfg!(windows) {
495            assert!(
496                base.contains_key("SystemRoot"),
497                "cmd.exe will not start without SystemRoot"
498            );
499        }
500    }
501
502    #[test]
503    fn a_declared_variable_overrides_the_machine_default() {
504        let mut declared = BTreeMap::new();
505        declared.insert("PATH".to_owned(), "/only/this".to_owned());
506
507        assert_eq!(
508            environment(&declared).get("PATH").map(String::as_str),
509            Some("/only/this"),
510            "a factory that deliberately sets PATH for an agent should get it"
511        );
512    }
513
514    #[test]
515    fn the_base_environment_carries_nothing_secret() {
516        // The point of `env_from` is that the telemetry agent does not hold the publishing token.
517        // That holds only while the base set stays facts-about-the-machine.
518        //
519        // The markers are the redactor's, and anchored for the same reason: a bare "PAT" matches
520        // "PATH", which is how this test failed the first time it was written.
521        for name in BASE_ENV {
522            let upper = name.to_ascii_uppercase();
523            for marker in [
524                "TOKEN",
525                "SECRET",
526                "PASSWORD",
527                "APIKEY",
528                "_PAT",
529                "CREDENTIAL",
530            ] {
531                assert!(
532                    !upper.contains(marker),
533                    "`{name}` looks like a credential and must not be passed by default"
534                );
535            }
536        }
537    }
538
539    #[test]
540    fn an_empty_command_is_refused_rather_than_panicking() {
541        let temp = Temp::new("empty");
542        let runner: Runner = toml::from_str("command = []").expect("parses");
543
544        assert!(matches!(
545            start(&plan(&temp, runner, "go")),
546            Err(SpawnError::EmptyCommand)
547        ));
548    }
549}