openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! launchd LaunchAgent supervisor — macOS only.
//!
//! portability-ok: every path below is a launchd convention
//! (`~/Library/LaunchAgents`), and `select_supervisor_impl` only constructs
//! this type behind `#[cfg(target_os = "macos")]`. The module itself stays
//! uncfg'd so its plist-generation tests run on every host.

use std::path::{Path, PathBuf};

use crate::error::OlError;

use super::{
    Supervisor, SupervisorKind, SupervisorStatus, ERR_SUPERVISION_BOOTSTRAP_FAILED,
    ERR_SUPERVISION_CONTROL_FAILED, ERR_SUPERVISION_INSTALL_FAILED,
};

const LABEL: &str = "ai.openlatch.client";

#[cfg(unix)]
fn get_uid() -> u32 {
    unsafe { libc::getuid() }
}

#[cfg(not(unix))]
fn get_uid() -> u32 {
    0
}

enum BootstrapError {
    AlreadyBootstrapped,
    NoGuiSession,
    Other(String),
}

/// Invoke `launchctl bootstrap <domain> <plist>`, classifying the failure so
/// the caller can pick an alternate domain.
///
/// `gui/$UID` needs the Aqua session to be attached (a graphical login).
/// Over SSH or headless CI launchd returns `Input/output error` — that's
/// the signal to retry against `user/$UID`, which does not require it.
fn bootstrap(plist: &Path, domain: &str) -> Result<(), BootstrapError> {
    let out = std::process::Command::new("launchctl")
        .args(["bootstrap", domain, &plist.display().to_string()])
        .output();
    match out {
        Ok(o) if o.status.success() => Ok(()),
        Ok(o) => {
            let stderr = String::from_utf8_lossy(&o.stderr).to_string();
            if stderr.contains("already bootstrapped") {
                Err(BootstrapError::AlreadyBootstrapped)
            } else if stderr.contains("Input/output error")
                || stderr.contains("Could not find domain")
            {
                Err(BootstrapError::NoGuiSession)
            } else {
                Err(BootstrapError::Other(stderr))
            }
        }
        Err(e) => Err(BootstrapError::Other(format!("Cannot run launchctl: {e}"))),
    }
}

pub struct LaunchdSupervisor {
    plist_path: PathBuf,
}

impl Default for LaunchdSupervisor {
    fn default() -> Self {
        Self::new()
    }
}

impl LaunchdSupervisor {
    pub fn new() -> Self {
        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
        Self {
            plist_path: home
                .join("Library/LaunchAgents")
                .join(format!("{LABEL}.plist")),
        }
    }

    fn generate_plist(&self, binary_path: &Path) -> String {
        let bin = binary_path.display();
        let marker = super::unit_version_marker();
        // A bare `<true/>` KeepAlive, not the `SuccessfulExit=false` dict:
        // `openlatch stop` exits 0, and launchd read that dict as "any exit
        // launchd considers successful means don't restart" — so a daemon that
        // died in almost any way stayed dead. Agents route their model traffic
        // through this process; unconditional relaunch is the honest policy,
        // and `launchctl bootout` still stops it for good.
        //
        // ThrottleInterval 10 → 2 for the same reason systemd's RestartSec
        // dropped: it bounds the dead-port window the boundary leaves behind.
        // launchd clamps sub-second values, so 2 is the practical floor here.
        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">
<!-- {marker} -->
<dict>
    <key>Label</key>
    <string>{LABEL}</string>
    <key>ProgramArguments</key>
    <array>
        <string>{bin}</string>
        <string>daemon</string>
        <string>start</string>
        <string>--foreground</string>
    </array>
    <key>KeepAlive</key>
    <true/>
    <key>ThrottleInterval</key>
    <integer>2</integer>
    <key>ExitTimeOut</key>
    <integer>20</integer>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/openlatch-stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/openlatch-stderr.log</string>
</dict>
</plist>"#
        )
    }
}

impl Supervisor for LaunchdSupervisor {
    fn kind(&self) -> SupervisorKind {
        SupervisorKind::Launchd
    }

    fn install(&self, binary_path: &Path) -> Result<(), OlError> {
        if let Some(parent) = self.plist_path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| {
                OlError::new(
                    ERR_SUPERVISION_INSTALL_FAILED,
                    format!("Cannot create LaunchAgents directory: {e}"),
                )
            })?;
        }

        let plist = self.generate_plist(binary_path);
        std::fs::write(&self.plist_path, &plist).map_err(|e| {
            OlError::new(
                ERR_SUPERVISION_INSTALL_FAILED,
                format!("Cannot write plist: {e}"),
            )
        })?;

        // Try gui/$UID first (the canonical per-user domain for LaunchAgents
        // started by the Aqua login session). If it fails with the specific
        // "Input/output error" that launchd returns when no GUI session is
        // attached — e.g. over SSH or in a headless CI runner — retry with
        // user/$UID, which does not require a logged-in Aqua session.
        let uid = get_uid();
        match bootstrap(&self.plist_path, &format!("gui/{uid}")) {
            Ok(()) => Ok(()),
            Err(BootstrapError::AlreadyBootstrapped) => Ok(()),
            Err(BootstrapError::NoGuiSession) => {
                match bootstrap(&self.plist_path, &format!("user/{uid}")) {
                    Ok(()) => Ok(()),
                    Err(BootstrapError::AlreadyBootstrapped) => Ok(()),
                    Err(BootstrapError::NoGuiSession) => Err(OlError::new(
                        ERR_SUPERVISION_BOOTSTRAP_FAILED,
                        "launchctl bootstrap failed in both gui/ and user/ domains (no session)"
                            .to_string(),
                    )),
                    Err(BootstrapError::Other(msg)) if msg.is_empty() => Err(OlError::new(
                        ERR_SUPERVISION_BOOTSTRAP_FAILED,
                        "launchctl bootstrap failed in both gui/ and user/ domains".to_string(),
                    )),
                    Err(BootstrapError::Other(msg)) => Err(OlError::new(
                        ERR_SUPERVISION_BOOTSTRAP_FAILED,
                        format!("launchctl bootstrap failed: {msg}"),
                    )),
                }
            }
            Err(BootstrapError::Other(msg)) => Err(OlError::new(
                ERR_SUPERVISION_BOOTSTRAP_FAILED,
                format!("launchctl bootstrap failed: {msg}"),
            )),
        }
    }

    fn uninstall(&self) -> Result<(), OlError> {
        let uid = get_uid();
        // Bootout from BOTH domains — install() falls back from gui/ to
        // user/ when no GUI session is attached, so uninstall has to undo
        // whichever one succeeded. Each call is best-effort: "not
        // bootstrapped" in one domain is benign.
        let _ = std::process::Command::new("launchctl")
            .args(["bootout", &format!("gui/{uid}/{LABEL}")])
            .output();
        let _ = std::process::Command::new("launchctl")
            .args(["bootout", &format!("user/{uid}/{LABEL}")])
            .output();
        let _ = std::fs::remove_file(&self.plist_path);
        Ok(())
    }

    fn status(&self) -> Result<SupervisorStatus, OlError> {
        if !self.plist_path.exists() {
            return Ok(SupervisorStatus {
                installed: false,
                running: false,
                unit_current: false,
                description: "not installed".into(),
            });
        }

        let unit_current = std::fs::read_to_string(&self.plist_path)
            .map(|c| super::unit_is_current(&c))
            .unwrap_or(false);

        let output = std::process::Command::new("launchctl")
            .args(["list", LABEL])
            .output();

        match output {
            Ok(o) if o.status.success() => Ok(SupervisorStatus {
                installed: true,
                running: true,
                unit_current,
                description: if unit_current {
                    "launchd (KeepAlive active)".into()
                } else {
                    "launchd (running an outdated plist — reinstall to get unconditional KeepAlive)"
                        .into()
                },
            }),
            _ => Ok(SupervisorStatus {
                installed: true,
                running: false,
                unit_current,
                description: "launchd (plist present, not running)".into(),
            }),
        }
    }

    fn start(&self) -> Result<(), OlError> {
        // `bootstrap` both loads the job and, with `RunAtLoad`, starts it —
        // and it is already the gui/ → user/ fallback `install` relies on, so
        // starting after a `stop` takes the same path that installing does.
        // "Already bootstrapped" is the idempotent success case: the job is
        // loaded, which is exactly what was asked for.
        let uid = get_uid();
        match bootstrap(&self.plist_path, &format!("gui/{uid}")) {
            Ok(()) | Err(BootstrapError::AlreadyBootstrapped) => Ok(()),
            Err(BootstrapError::NoGuiSession) => {
                match bootstrap(&self.plist_path, &format!("user/{uid}")) {
                    Ok(()) | Err(BootstrapError::AlreadyBootstrapped) => Ok(()),
                    Err(BootstrapError::NoGuiSession) => Err(OlError::new(
                        ERR_SUPERVISION_CONTROL_FAILED,
                        "launchctl bootstrap failed in both gui/ and user/ domains (no session)"
                            .to_string(),
                    )),
                    Err(BootstrapError::Other(msg)) => Err(OlError::new(
                        ERR_SUPERVISION_CONTROL_FAILED,
                        format!("launchctl bootstrap failed: {msg}"),
                    )),
                }
            }
            Err(BootstrapError::Other(msg)) => Err(OlError::new(
                ERR_SUPERVISION_CONTROL_FAILED,
                format!("launchctl bootstrap failed: {msg}"),
            )),
        }
    }

    fn stop(&self) -> Result<(), OlError> {
        // `bootout`, not `launchctl kill`: an unconditional `KeepAlive` means
        // a signalled job is relaunched after `ThrottleInterval`, so signalling
        // it is not a stop at all. Booting the job out of the domain unloads
        // it — and because the plist stays in ~/Library/LaunchAgents, it loads
        // again at the next login, matching `systemctl stop` on a still-enabled
        // unit.
        //
        // Both domains, because `start`/`install` fall back from gui/ to user/
        // and only one of them holds the job. Success in EITHER is a stop;
        // failure in both is a real failure.
        let uid = get_uid();
        let mut last_err = String::new();
        for domain in [format!("gui/{uid}"), format!("user/{uid}")] {
            let out = std::process::Command::new("launchctl")
                .args(["bootout", &format!("{domain}/{LABEL}")])
                .output();
            match out {
                Ok(o) if o.status.success() => return Ok(()),
                Ok(o) => last_err = String::from_utf8_lossy(&o.stderr).trim().to_string(),
                Err(e) => last_err = format!("cannot run launchctl: {e}"),
            }
        }
        Err(OlError::new(
            ERR_SUPERVISION_CONTROL_FAILED,
            format!("launchctl bootout failed in gui/ and user/ domains: {last_err}"),
        ))
    }

    fn restart(&self) -> Result<(), OlError> {
        // `kickstart -k` kills the running instance and starts a fresh one in
        // a single launchd-mediated step — no window for a caller-spawned
        // replacement to race the relaunch.
        let uid = get_uid();
        for domain in [format!("gui/{uid}"), format!("user/{uid}")] {
            let out = std::process::Command::new("launchctl")
                .args(["kickstart", "-k", &format!("{domain}/{LABEL}")])
                .output();
            if out.is_ok_and(|o| o.status.success()) {
                return Ok(());
            }
        }
        // Not loaded in either domain — a restart of something that is not
        // running is a start.
        self.start()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The `SuccessfulExit=false` dict was the launchd analogue of systemd's
    /// `Restart=on-failure`: the daemon exited 0 even when it crashed, so
    /// launchd read every death as intentional and never relaunched. A bare
    /// `<true/>` relaunches unconditionally; `launchctl bootout` still stops it
    /// for good, so the user has not lost the off switch.
    #[test]
    fn plist_keep_alive_is_unconditional() {
        let sup = LaunchdSupervisor::new();
        let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(
            plist.contains("<key>KeepAlive</key>\n    <true/>"),
            "{plist}"
        );
        assert!(
            !plist.contains("<key>SuccessfulExit</key>"),
            "the conditional KeepAlive dict is what stopped relaunches:\n{plist}"
        );
    }

    #[test]
    fn plist_has_throttle_interval() {
        let sup = LaunchdSupervisor::new();
        let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(plist.contains("<key>ThrottleInterval</key>"));
        // 2s, not 10s — bounds the boundary listener's dead-port window.
        assert!(plist.contains("<integer>2</integer>"), "{plist}");
    }

    #[test]
    fn plist_carries_the_version_marker() {
        let sup = LaunchdSupervisor::new();
        let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(super::super::unit_is_current(&plist), "{plist}");
    }

    #[test]
    fn plist_contains_label() {
        let sup = LaunchdSupervisor::new();
        let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(plist.contains(LABEL));
    }

    #[test]
    fn plist_declares_utf8() {
        // plist bytes are written UTF-8; the XML prolog must say UTF-8 so
        // strict parsers (and future OS validators) don't reject it — same
        // class of failure as the Windows Task Scheduler UTF-16 mismatch.
        let sup = LaunchdSupervisor::new();
        let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(plist.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
    }
}