openlatch-client 0.1.13

The open-source security layer for AI agents — client forwarder
use std::path::{Path, PathBuf};

use crate::error::OlError;

use super::{
    Supervisor, SupervisorKind, SupervisorStatus, ERR_SUPERVISION_BOOTSTRAP_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();
        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>{bin}</string>
        <string>daemon</string>
        <string>start</string>
        <string>--foreground</string>
    </array>
    <key>KeepAlive</key>
    <dict>
        <key>SuccessfulExit</key>
        <false/>
        <key>Crashed</key>
        <true/>
    </dict>
    <key>ThrottleInterval</key>
    <integer>10</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,
                description: "not installed".into(),
            });
        }

        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,
                description: "launchd (KeepAlive active)".into(),
            }),
            _ => Ok(SupervisorStatus {
                installed: true,
                running: false,
                description: "launchd (plist present, not running)".into(),
            }),
        }
    }
}

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

    #[test]
    fn plist_uses_dict_keep_alive_not_bool() {
        let sup = LaunchdSupervisor::new();
        let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(plist.contains("<key>KeepAlive</key>"));
        assert!(plist.contains("<key>SuccessfulExit</key>"));
        assert!(plist.contains("<false/>"));
        assert!(plist.contains("<key>Crashed</key>"));
        assert!(
            !plist.contains("<true/>\n</dict>\n    <key>ThrottleInterval</key>"),
            "KeepAlive must be a dict, not a bare <true/>"
        );
    }

    #[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>"));
        assert!(plist.contains("<integer>10</integer>"));
    }

    #[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\"?>"));
    }
}