use std::collections::BTreeMap;
use std::fmt;
use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use jiff::Timestamp;
use layover_core::agent::AgentName;
use layover_core::config::Runner;
#[derive(Debug, Clone)]
pub struct Plan {
pub agent: AgentName,
pub runner: Runner,
pub model: Option<String>,
pub payload: String,
pub hangar: PathBuf,
pub work_dir: PathBuf,
pub env: BTreeMap<String, String>,
pub mcp_config: Option<PathBuf>,
}
#[derive(Debug)]
pub struct Started {
child: std::process::Child,
transcript: PathBuf,
began: Timestamp,
identifier: u32,
}
impl Started {
#[must_use]
pub const fn pid(&self) -> u32 {
self.identifier
}
#[must_use]
pub fn transcript(&self) -> &Path {
&self.transcript
}
#[must_use]
pub const fn started_at(&self) -> Timestamp {
self.began
}
pub fn try_wait(&mut self) -> Result<Option<std::process::ExitStatus>, SpawnError> {
self.child.try_wait().map_err(SpawnError::Io)
}
pub fn wait_after_kill(&mut self) -> Result<std::process::ExitStatus, SpawnError> {
self.child.wait().map_err(SpawnError::Io)
}
#[must_use]
pub fn into_finished(self, status: std::process::ExitStatus) -> Finished {
Finished {
exit_code: status.code(),
finished_at: Timestamp::now(),
transcript: self.transcript,
started_at: self.began,
}
}
pub fn wait(mut self) -> Result<Finished, SpawnError> {
let status = self.child.wait().map_err(SpawnError::Io)?;
Ok(self.into_finished(status))
}
}
#[derive(Debug, Clone)]
pub struct Finished {
pub exit_code: Option<i32>,
pub finished_at: Timestamp,
pub transcript: PathBuf,
pub started_at: Timestamp,
}
impl Finished {
#[must_use]
pub fn succeeded(&self) -> bool {
self.exit_code == Some(0)
}
}
#[derive(Debug)]
pub enum SpawnError {
MissingEnv {
name: String,
},
EmptyCommand,
Io(io::Error),
}
impl fmt::Display for SpawnError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingEnv { name } => write!(
f,
"`{name}` is named in `env_from` but is not set; the run would start without the \
credential it declared and fail somewhere further away"
),
Self::EmptyCommand => f.write_str("the runner names no command to run"),
Self::Io(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for SpawnError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) => Some(error),
_ => None,
}
}
}
impl From<io::Error> for SpawnError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
pub const PAYLOAD_FILE: &str = "prompt.md";
pub const TRANSCRIPT_FILE: &str = "transcript.log";
const BASE_ENV: [&str; 9] = [
"PATH",
"SystemRoot",
"COMSPEC",
"TEMP",
"TMP",
"TMPDIR",
"HOME",
"USER",
"LOGNAME",
];
fn environment(declared: &BTreeMap<String, String>) -> BTreeMap<String, String> {
let mut env: BTreeMap<String, String> = BASE_ENV
.iter()
.filter_map(|name| {
std::env::var(name)
.ok()
.map(|value| ((*name).to_owned(), value))
})
.collect();
env.extend(declared.iter().map(|(k, v)| (k.clone(), v.clone())));
env
}
pub fn start(plan: &Plan) -> Result<Started, SpawnError> {
if plan.runner.command.is_empty() {
return Err(SpawnError::EmptyCommand);
}
fs::create_dir_all(&plan.hangar)?;
let payload_path = plan.hangar.join(PAYLOAD_FILE);
fs::write(&payload_path, &plan.payload)?;
let payload_arg = plan
.runner
.takes_prompt_path()
.then(|| payload_path.display().to_string());
let mcp_arg = plan
.mcp_config
.as_ref()
.map(|path| path.display().to_string());
let argv = plan.runner.invocation_with_mcp(
payload_arg.as_deref(),
plan.model.as_deref(),
mcp_arg.as_deref(),
);
let (program, arguments) = argv.split_first().ok_or(SpawnError::EmptyCommand)?;
let transcript = plan.hangar.join(TRANSCRIPT_FILE);
let sink = File::create(&transcript)?;
let sink_for_stderr = sink.try_clone()?;
let mut command = Command::new(program);
command
.args(arguments)
.current_dir(&plan.work_dir)
.env_clear()
.envs(environment(&plan.env))
.stdin(Stdio::piped())
.stdout(Stdio::from(sink))
.stderr(Stdio::from(sink_for_stderr));
let started_at = Timestamp::now();
let mut child = command.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write as _;
let _ = stdin.write_all(plan.payload.as_bytes());
}
let identifier = child.id();
Ok(Started {
child,
transcript,
began: started_at,
identifier,
})
}
pub fn env_from(names: &[String]) -> Result<BTreeMap<String, String>, SpawnError> {
let mut out = BTreeMap::new();
for name in names {
let value =
std::env::var(name).map_err(|_| SpawnError::MissingEnv { name: name.clone() })?;
out.insert(name.clone(), value);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn echoing(text: &str) -> Runner {
let command = if cfg!(windows) {
vec!["cmd".to_owned(), "/c".to_owned(), format!("echo {text}")]
} else {
vec!["sh".to_owned(), "-c".to_owned(), format!("echo {text}")]
};
toml::from_str(&format!(
"command = [{}]",
command
.iter()
.map(|a| format!("{a:?}"))
.collect::<Vec<_>>()
.join(", ")
))
.expect("parses")
}
fn failing() -> Runner {
let command = if cfg!(windows) {
r#"["cmd", "/c", "exit 3"]"#
} else {
r#"["sh", "-c", "exit 3"]"#
};
toml::from_str(&format!("command = {command}")).expect("parses")
}
struct Temp(PathBuf);
impl Temp {
fn new(name: &str) -> Self {
let path =
std::env::temp_dir().join(format!("layover-tower-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("temp dir");
Self(path)
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn plan(temp: &Temp, runner: Runner, payload: &str) -> Plan {
Plan {
agent: AgentName::new("tester"),
runner,
model: None,
payload: payload.to_owned(),
hangar: temp.0.join("hangar"),
work_dir: temp.0.clone(),
env: BTreeMap::new(),
mcp_config: None,
}
}
#[test]
fn a_run_really_starts_a_process_and_reports_how_it_ended() {
let temp = Temp::new("ok");
let started = start(&plan(&temp, echoing("hello"), "do the thing")).expect("starts");
assert!(started.pid() > 0, "a started run has a process identifier");
let finished = started.wait().expect("waits");
assert!(finished.succeeded(), "{:?}", finished.exit_code);
assert!(finished.finished_at >= finished.started_at);
}
#[test]
fn the_transcript_holds_what_the_child_wrote() {
let temp = Temp::new("transcript");
let finished = start(&plan(&temp, echoing("marker-42"), "go"))
.expect("starts")
.wait()
.expect("waits");
let text = fs::read_to_string(&finished.transcript).expect("transcript exists");
assert!(text.contains("marker-42"), "{text:?}");
}
#[test]
fn the_payload_is_written_where_it_can_be_read_afterwards() {
let temp = Temp::new("payload");
let asked = plan(&temp, echoing("x"), "You are `tester`.\n\nDo the thing.");
start(&asked).expect("starts").wait().expect("waits");
let written = fs::read_to_string(asked.hangar.join(PAYLOAD_FILE)).expect("payload exists");
assert_eq!(written, asked.payload);
}
#[test]
fn a_non_zero_exit_is_not_a_success() {
let temp = Temp::new("fail");
let finished = start(&plan(&temp, failing(), "go"))
.expect("starts")
.wait()
.expect("waits");
assert_eq!(finished.exit_code, Some(3));
assert!(!finished.succeeded());
}
#[test]
fn a_declared_variable_that_is_not_set_stops_the_run_before_it_costs_anything() {
let name = format!("LAYOVER_TEST_ABSENT_{}", std::process::id());
let error = env_from(std::slice::from_ref(&name)).expect_err("should refuse");
assert!(matches!(&error, SpawnError::MissingEnv { name: n } if *n == name));
assert!(error.to_string().contains("env_from"), "{error}");
}
#[test]
fn only_the_named_variables_are_resolved() {
let resolved = env_from(&["PATH".to_owned()]).expect("PATH is always set");
assert_eq!(resolved.len(), 1, "nothing else should come along");
assert!(resolved.contains_key("PATH"));
}
#[test]
fn nothing_named_means_an_empty_environment_not_an_inherited_one() {
let resolved = env_from(&[]).expect("resolves");
assert!(
resolved.is_empty(),
"an agent that declared no variables gets none, not all of them"
);
}
#[test]
fn a_child_gets_enough_environment_to_actually_run() {
let base = environment(&BTreeMap::new());
assert!(
base.contains_key("PATH"),
"a child cannot find anything without PATH"
);
if cfg!(windows) {
assert!(
base.contains_key("SystemRoot"),
"cmd.exe will not start without SystemRoot"
);
}
}
#[test]
fn a_declared_variable_overrides_the_machine_default() {
let mut declared = BTreeMap::new();
declared.insert("PATH".to_owned(), "/only/this".to_owned());
assert_eq!(
environment(&declared).get("PATH").map(String::as_str),
Some("/only/this"),
"a factory that deliberately sets PATH for an agent should get it"
);
}
#[test]
fn the_base_environment_carries_nothing_secret() {
for name in BASE_ENV {
let upper = name.to_ascii_uppercase();
for marker in [
"TOKEN",
"SECRET",
"PASSWORD",
"APIKEY",
"_PAT",
"CREDENTIAL",
] {
assert!(
!upper.contains(marker),
"`{name}` looks like a credential and must not be passed by default"
);
}
}
}
#[test]
fn an_empty_command_is_refused_rather_than_panicking() {
let temp = Temp::new("empty");
let runner: Runner = toml::from_str("command = []").expect("parses");
assert!(matches!(
start(&plan(&temp, runner, "go")),
Err(SpawnError::EmptyCommand)
));
}
}