1use 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#[derive(Debug, Clone)]
35pub struct Plan {
36 pub agent: AgentName,
38 pub runner: Runner,
40 pub model: Option<String>,
42 pub payload: String,
44 pub hangar: PathBuf,
46 pub work_dir: PathBuf,
48 pub env: BTreeMap<String, String>,
50 pub mcp_config: Option<PathBuf>,
55}
56
57#[derive(Debug)]
59pub struct Started {
60 child: std::process::Child,
62 transcript: PathBuf,
64 began: Timestamp,
66 identifier: u32,
68}
69
70impl Started {
71 #[must_use]
73 pub const fn pid(&self) -> u32 {
74 self.identifier
75 }
76
77 #[must_use]
79 pub fn transcript(&self) -> &Path {
80 &self.transcript
81 }
82
83 #[must_use]
85 pub const fn started_at(&self) -> Timestamp {
86 self.began
87 }
88
89 pub fn try_wait(&mut self) -> Result<Option<std::process::ExitStatus>, SpawnError> {
95 self.child.try_wait().map_err(SpawnError::Io)
96 }
97
98 pub fn wait_after_kill(&mut self) -> Result<std::process::ExitStatus, SpawnError> {
104 self.child.wait().map_err(SpawnError::Io)
105 }
106
107 #[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 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#[derive(Debug, Clone)]
131pub struct Finished {
132 pub exit_code: Option<i32>,
134 pub finished_at: Timestamp,
136 pub transcript: PathBuf,
138 pub started_at: Timestamp,
140}
141
142impl Finished {
143 #[must_use]
148 pub fn succeeded(&self) -> bool {
149 self.exit_code == Some(0)
150 }
151}
152
153#[derive(Debug)]
155pub enum SpawnError {
156 MissingEnv {
158 name: String,
160 },
161 EmptyCommand,
163 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
196pub const PAYLOAD_FILE: &str = "prompt.md";
198
199pub const TRANSCRIPT_FILE: &str = "transcript.log";
201
202const BASE_ENV: [&str; 9] = [
214 "PATH",
215 "SystemRoot",
217 "COMSPEC",
218 "TEMP",
220 "TMP",
221 "TMPDIR",
222 "HOME",
224 "USER",
225 "LOGNAME",
226];
227
228fn 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
246pub 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 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 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 if let Some(mut stdin) = child.stdin.take() {
306 use std::io::Write as _;
307 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
322pub 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 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 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 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 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 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 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}