//! Helpers for end-to-end tests: a hermetic environment, the owner's //! delegation as `oxy identity delegate add` makes it, agents added through //! the CLI, and the daemon started and stopped as a real process. #![allow(dead_code)] use oxy_core::durable::{write_atomic, write_atomic_private}; use oxy_core::identity::{Delegate, SecretKey}; use spectra_core::keys::KeyPair; use spectra_core::principals::DELEGATION_SCOPE; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Output}; pub fn hermetic(dir: &Path) -> Command { let mut c = Command::new(env!("CARGO_BIN_EXE_spectra")); c.env("SPECTRA_DATA", dir.join("data")) .env("OXY_HOME", dir.join("owner")) .env("HOME", dir) .env("XDG_DATA_HOME", dir.join("xdg")) .env("NO_COLOR", "1"); c } pub fn run(dir: &Path, args: &[&str]) -> Output { hermetic(dir).args(args).output().unwrap() } pub fn text(o: &Output) -> String { format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) } /// `oxy identity delegate add` in the owner's home. pub fn delegate(owner_home: &Path, agent: &KeyPair) { let cur = KeyPair::load_home(owner_home).unwrap(); let keys = owner_home.join("keys"); let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap(); let mut d = cur.identity.delegates.clone(); d.push(Delegate { key: agent.key, scope: DELEGATION_SCOPE, expires: None }); let next = cur.identity.successor(d, None, spectra_core::ids::now_ms()); let k = next.hash(); write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap(); write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap(); oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap(); } /// Make an agent's home, delegate to it, and add it. pub fn add_agent(d: &Path, name: &str, extra: &[&str]) -> KeyPair { let home = d.join("agents").join(name); let pair = KeyPair::create_home(&home, name, "").unwrap(); delegate(&d.join("owner"), &pair); let mut args = vec!["agent", "add", name, "--kind", "claude-code", "--project", "oxygen", "--home", home.to_str().unwrap(), "--wait", "0"]; args.extend_from_slice(extra); let o = run(d, &args); assert!(o.status.success(), "{}", text(&o)); pair } pub struct Serving { pub child: Child, pub socket: PathBuf, } impl Serving { pub fn start(d: &Path) -> Serving { let mut child = hermetic(d).args(["serve", "--bind", "127.0.0.1:0"]).stdout(std::process::Stdio::piped()).spawn().unwrap(); let socket = d.join("data/spectra.sock"); for _ in 0..200 { if std::os::unix::net::UnixStream::connect(&socket).is_ok() { return Serving { child, socket }; } std::thread::sleep(std::time::Duration::from_millis(25)); } child.kill().ok(); child.wait().ok(); panic!("spectra serve did not start"); } /// Serve with TLS on a loopback port of its own; returns the port. pub fn start_tls(d: &Path) -> (Serving, u16) { use std::io::BufRead; let mut child = hermetic(d).args(["serve", "--bind", "127.0.0.1:0", "--tls", "on"]).stdout(std::process::Stdio::piped()).spawn().unwrap(); let out = child.stdout.take().unwrap(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { for line in std::io::BufReader::new(out).lines().map_while(Result::ok) { if let Some(url) = line.split_whitespace().find(|w| w.starts_with("https://")) { let port: u16 = url.rsplit(':').next().unwrap().parse().unwrap(); let _ = tx.send(port); } } }); let port = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("spectra serve printed its https address"); (Serving { child, socket: d.join("data/spectra.sock") }, port) } pub fn stop(mut self) { unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) }; let status = self.child.wait().unwrap(); assert!(status.success(), "a clean stop on SIGTERM"); assert!(!self.socket.exists(), "the socket is removed"); } } /// A test that fails mid-way must not leave its daemon running: an orphan /// holds the test's output open and looks like a hang. impl Drop for Serving { fn drop(&mut self) { if let Ok(None) = self.child.try_wait() { let _ = self.child.kill(); let _ = self.child.wait(); } } } pub fn setup() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); KeyPair::create_home(&d.join("owner"), "Andy McCall", "").unwrap(); assert!(run(d, &["init"]).status.success()); std::fs::create_dir_all(d.join("oxygen")).unwrap(); assert!(run(d, &["project", "add", d.join("oxygen").to_str().unwrap()]).status.success()); dir }