use std::io;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
pub const LABEL: &str = "ai.zakuro.agent";
pub type Launchctl<'a> = &'a dyn Fn(&[&str]) -> io::Result<ExitStatus>;
pub fn plist_path(home: &Path) -> PathBuf {
home.join("Library")
.join("LaunchAgents")
.join(format!("{LABEL}.plist"))
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub const CARRIED_ENV: [&str; 5] = [
"ZAKURO_HOME",
"ZAKURO_AGENT_PORT",
"ZAKURO_WORKER_DIR",
"ZAKURO_AGENT_MAX_WORKERS",
"ZAKURO_AGENT_WORKER_CMD",
];
pub fn carried_env(lookup: impl Fn(&str) -> Option<String>) -> Vec<(&'static str, String)> {
CARRIED_ENV
.iter()
.filter_map(|&k| lookup(k).filter(|v| !v.is_empty()).map(|v| (k, v)))
.collect()
}
pub fn render_plist(zc_path: &str, home: &str, env: &[(&str, String)], log_path: &str) -> String {
let path = format!(
"/opt/homebrew/bin:/usr/local/bin:{home}/.local/bin:{home}/.cargo/bin:/usr/bin:/bin"
);
let mut vars = format!(
"\t\t<key>PATH</key>\n\t\t<string>{}</string>\n",
xml_escape(&path)
);
for (key, value) in env {
vars.push_str(&format!(
"\t\t<key>{}</key>\n\t\t<string>{}</string>\n",
xml_escape(key),
xml_escape(value)
));
}
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{zc}</string>
<string>agent</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ExitTimeOut</key>
<integer>60</integer>
<key>EnvironmentVariables</key>
<dict>
{vars} </dict>
<key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
</dict>
</plist>
"#,
zc = xml_escape(zc_path),
log = xml_escape(log_path),
)
}
#[cfg(target_os = "macos")]
pub fn gui_domain() -> String {
format!("gui/{}", unsafe { libc::getuid() })
}
#[cfg(target_os = "macos")]
pub fn run_launchctl(args: &[&str]) -> io::Result<ExitStatus> {
std::process::Command::new("launchctl").args(args).status()
}
pub fn install(
launchctl: Launchctl,
domain: &str,
zc_path: &Path,
home: &Path,
env: &[(&str, String)],
agent_dir: &Path,
port: u16,
) -> Result<(), String> {
crate::agent::files::load_or_init_agent_file(agent_dir, port, true)
.map_err(|e| e.to_string())?;
let plist = plist_path(home);
std::fs::create_dir_all(plist.parent().expect("LaunchAgents has a parent"))
.map_err(|e| e.to_string())?;
let log = agent_dir.join(crate::agent::files::LOG_FILE);
std::fs::write(
&plist,
render_plist(
&zc_path.to_string_lossy(),
&home.to_string_lossy(),
env,
&log.to_string_lossy(),
),
)
.map_err(|e| e.to_string())?;
let _ = launchctl(&["bootout", &format!("{domain}/{LABEL}")]);
let st =
launchctl(&["bootstrap", domain, &plist.to_string_lossy()]).map_err(|e| e.to_string())?;
if st.success() {
Ok(())
} else {
Err(format!("launchctl bootstrap failed ({st})"))
}
}
pub fn uninstall(launchctl: Launchctl, domain: &str, home: &Path) -> Result<(), String> {
let _ = launchctl(&["bootout", &format!("{domain}/{LABEL}")]);
let p = plist_path(home);
if p.exists() {
std::fs::remove_file(&p).map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plist_runs_zc_agent_run_under_launchd_with_the_spec_settings() {
let p = render_plist(
"/usr/local/bin/zc",
"/Users/j",
&[],
"/Users/j/.zakuro/agent/agent.log",
);
assert!(p.contains("<string>ai.zakuro.agent</string>"));
assert!(p.contains("<string>/usr/local/bin/zc</string>\n\t\t<string>agent</string>\n\t\t<string>run</string>"));
assert!(p.contains("<key>RunAtLoad</key>\n\t<true/>"));
assert!(p.contains("<key>KeepAlive</key>\n\t<true/>"));
assert!(p.contains("<key>ExitTimeOut</key>\n\t<integer>60</integer>"));
assert!(p.contains("<string>/opt/homebrew/bin:/usr/local/bin:/Users/j/.local/bin:/Users/j/.cargo/bin:/usr/bin:/bin</string>"));
for key in CARRIED_ENV {
assert!(
!p.contains(key),
"{key} is only written when the installing shell set it"
);
}
assert_eq!(
p.matches("<string>/Users/j/.zakuro/agent/agent.log</string>")
.count(),
2,
"stdout and stderr"
);
}
#[test]
fn plist_carries_zakuro_home_and_escapes_xml() {
let p = render_plist(
"/opt/a&b/zc",
"/Users/j",
&[("ZAKURO_HOME", "/Volumes/z<1>".to_string())],
"/tmp/agent.log",
);
assert!(p.contains("<string>/opt/a&b/zc</string>"));
assert!(p.contains("<key>ZAKURO_HOME</key>\n\t\t<string>/Volumes/z<1></string>"));
}
fn plist_with(key: &'static str, value: &str) -> String {
render_plist(
"/usr/local/bin/zc",
"/Users/j",
&[(key, value.to_string())],
"/tmp/agent.log",
)
}
fn entry(key: &str, escaped_value: &str) -> String {
format!("\t\t<key>{key}</key>\n\t\t<string>{escaped_value}</string>\n")
}
#[test]
fn plist_carries_the_agent_port() {
assert!(
plist_with("ZAKURO_AGENT_PORT", "5000").contains(&entry("ZAKURO_AGENT_PORT", "5000"))
);
}
#[test]
fn plist_carries_the_worker_dir_escaped() {
assert!(plist_with("ZAKURO_WORKER_DIR", "/Users/j/R&D/zak-zakuro")
.contains(&entry("ZAKURO_WORKER_DIR", "/Users/j/R&D/zak-zakuro")));
}
#[test]
fn plist_carries_the_worker_cap() {
assert!(plist_with("ZAKURO_AGENT_MAX_WORKERS", "3")
.contains(&entry("ZAKURO_AGENT_MAX_WORKERS", "3")));
}
#[test]
fn plist_carries_the_worker_command_escaped() {
assert!(plist_with(
"ZAKURO_AGENT_WORKER_CMD",
r#"run-worker --tag "<a&b>" {port}"#
)
.contains(&entry(
"ZAKURO_AGENT_WORKER_CMD",
"run-worker --tag "<a&b>" {port}"
)));
}
#[test]
fn carried_env_takes_the_agents_settings_that_are_set_in_order() {
let env = carried_env(|k| match k {
"ZAKURO_AGENT_WORKER_CMD" => Some("run-worker {port}".into()),
"ZAKURO_AGENT_PORT" => Some("5000".into()),
"ZAKURO_WORKER_DIR" => Some(String::new()), "PATH" | "HOME" | "ZAKURO_P2P" => Some("x".into()), _ => None,
});
assert_eq!(
env,
vec![
("ZAKURO_AGENT_PORT", "5000".to_string()),
("ZAKURO_AGENT_WORKER_CMD", "run-worker {port}".to_string()),
]
);
}
#[test]
fn plist_lives_in_the_users_launch_agents() {
assert_eq!(
plist_path(std::path::Path::new("/Users/j")),
std::path::PathBuf::from("/Users/j/Library/LaunchAgents/ai.zakuro.agent.plist")
);
}
#[cfg(target_os = "macos")]
#[test]
fn plutil_accepts_the_plist() {
let dir = crate::agent::files::tests::tmp("plist");
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("a.plist");
std::fs::write(
&f,
render_plist(
"/usr/local/bin/zc",
"/Users/j",
&carried_env(|k| Some(format!("{k}-<&>\"value"))),
"/tmp/a.log",
),
)
.unwrap();
let out = std::process::Command::new("plutil")
.arg("-lint")
.arg(&f)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stdout)
);
}
struct FakeLaunchctl {
calls: std::sync::Mutex<Vec<Vec<String>>>,
fail_bootstrap: bool,
}
fn exit_status(success: bool) -> std::process::ExitStatus {
std::process::Command::new(if success { "true" } else { "false" })
.status()
.unwrap()
}
impl FakeLaunchctl {
fn new(fail_bootstrap: bool) -> Self {
Self {
calls: std::sync::Mutex::new(vec![]),
fail_bootstrap,
}
}
fn call(&self, args: &[&str]) -> std::io::Result<std::process::ExitStatus> {
self.calls
.lock()
.unwrap()
.push(args.iter().map(|s| s.to_string()).collect());
let fail = self.fail_bootstrap && args.first() == Some(&"bootstrap");
Ok(exit_status(!fail))
}
fn calls(&self) -> Vec<Vec<String>> {
self.calls.lock().unwrap().clone()
}
}
#[test]
fn install_writes_the_plist_rotates_the_token_and_boots_out_before_bootstrap() {
let home = crate::agent::files::tests::tmp("launchd-install-home");
let agent_dir = crate::agent::files::tests::tmp("launchd-install-agent");
std::fs::create_dir_all(&home).unwrap();
let existing =
crate::agent::files::load_or_init_agent_file(&agent_dir, 4720, false).unwrap();
let fake = FakeLaunchctl::new(false);
let zc_path = std::path::Path::new("/usr/local/bin/zc");
install(
&|a| fake.call(a),
"gui/501",
zc_path,
&home,
&[],
&agent_dir,
4720,
)
.expect("install succeeds");
let plist = plist_path(&home);
assert!(plist.exists(), "plist written");
let contents = std::fs::read_to_string(&plist).unwrap();
assert!(contents.contains("/usr/local/bin/zc"));
let rotated = crate::agent::files::load_json::<crate::agent::files::AgentFile>(
&agent_dir.join(crate::agent::files::AGENT_FILE),
)
.unwrap();
assert_ne!(rotated.token, existing.token, "install rotates the token");
let calls = fake.calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0], vec!["bootout", "gui/501/ai.zakuro.agent"]);
assert_eq!(
calls[1],
vec!["bootstrap", "gui/501", &plist.to_string_lossy()]
);
}
#[test]
fn a_bootout_failure_is_ignored_but_a_bootstrap_failure_is_reported() {
let home = crate::agent::files::tests::tmp("launchd-install-fail-home");
let agent_dir = crate::agent::files::tests::tmp("launchd-install-fail-agent");
std::fs::create_dir_all(&home).unwrap();
let fake = FakeLaunchctl::new(true);
let zc_path = std::path::Path::new("/usr/local/bin/zc");
let err = install(
&|a| fake.call(a),
"gui/501",
zc_path,
&home,
&[],
&agent_dir,
4720,
)
.unwrap_err();
assert!(err.contains("bootstrap"), "{err}");
}
#[test]
fn uninstall_boots_out_and_removes_the_plist_but_keeps_the_agent_dir() {
let home = crate::agent::files::tests::tmp("launchd-uninstall-home");
let agent_dir = crate::agent::files::tests::tmp("launchd-uninstall-agent");
std::fs::create_dir_all(&home).unwrap();
crate::agent::files::load_or_init_agent_file(&agent_dir, 4720, false).unwrap();
let fake = FakeLaunchctl::new(false);
let zc_path = std::path::Path::new("/usr/local/bin/zc");
install(
&|a| fake.call(a),
"gui/501",
zc_path,
&home,
&[],
&agent_dir,
4720,
)
.unwrap();
assert!(plist_path(&home).exists());
uninstall(&|a| fake.call(a), "gui/501", &home).expect("uninstall succeeds");
assert!(!plist_path(&home).exists(), "plist removed");
assert!(
agent_dir.join(crate::agent::files::AGENT_FILE).exists(),
"agent dir kept"
);
let calls = fake.calls();
assert_eq!(
calls.last().unwrap(),
&vec!["bootout", "gui/501/ai.zakuro.agent"]
);
}
}