openlatch-client 0.1.14

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_INSTALL_FAILED};

const SERVICE_NAME: &str = "openlatch.service";

pub fn is_systemd_available() -> bool {
    Path::new("/run/systemd/system").exists()
}

/// Invoke `systemctl <args>` and map non-zero exits into an `OlError`.
///
/// The most common failure here is "Failed to connect to bus" when the
/// caller has no user DBus session — the Linux analogue of the Windows
/// Task Scheduler "Access is denied" surprise. We surface the stderr
/// verbatim so the operator can tell apart "no session" from a genuine
/// unit problem.
fn run_systemctl(args: &[&str]) -> Result<(), OlError> {
    let out = std::process::Command::new("systemctl").args(args).output();
    match out {
        Ok(o) if o.status.success() => Ok(()),
        Ok(o) => {
            let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
            Err(OlError::new(
                ERR_SUPERVISION_INSTALL_FAILED,
                format!("systemctl {} failed: {}", args.join(" "), stderr),
            ))
        }
        Err(e) => Err(OlError::new(
            ERR_SUPERVISION_INSTALL_FAILED,
            format!("Cannot run systemctl: {e}"),
        )),
    }
}

pub struct SystemdSupervisor {
    unit_path: PathBuf,
}

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

impl SystemdSupervisor {
    pub fn new() -> Self {
        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
        Self {
            unit_path: home.join(".config/systemd/user").join(SERVICE_NAME),
        }
    }

    fn generate_unit(&self, binary_path: &Path) -> String {
        let bin = binary_path.display();
        let home = dirs::home_dir()
            .unwrap_or_else(|| PathBuf::from("/home/user"))
            .display()
            .to_string();
        format!(
            r#"[Unit]
Description=OpenLatch AI Agent Security Daemon
After=network.target

[Service]
Type=simple
ExecStart={bin} daemon start --foreground
Restart=on-failure
RestartSec=10
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths={home}/.openlatch {home}/.claude

[Install]
WantedBy=default.target
"#
        )
    }
}

impl Supervisor for SystemdSupervisor {
    fn kind(&self) -> SupervisorKind {
        SupervisorKind::Systemd
    }

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

        let unit = self.generate_unit(binary_path);
        std::fs::write(&self.unit_path, &unit).map_err(|e| {
            OlError::new(
                ERR_SUPERVISION_INSTALL_FAILED,
                format!("Cannot write systemd unit file: {e}"),
            )
        })?;

        // Every systemctl call can fail for two distinct reasons:
        //   1. No user DBus session — happens over SSH without
        //      `loginctl enable-linger` or when the caller is not actually
        //      logged in. Error: "Failed to connect to bus: No such file
        //      or directory". This is the Linux analogue of the Windows
        //      "Access is denied" surprise.
        //   2. The unit is genuinely broken. Rare at install time (we
        //      wrote the unit ourselves).
        // Both cases need to bubble out so init.rs marks the state as
        // deferred instead of active — silently swallowing the error would
        // leave the config lying about whether persistence is actually on.
        run_systemctl(&["--user", "daemon-reload"])?;
        run_systemctl(&["--user", "enable", "--now", "openlatch.service"])?;

        Ok(())
    }

    fn uninstall(&self) -> Result<(), OlError> {
        let _ = std::process::Command::new("systemctl")
            .args(["--user", "stop", "openlatch.service"])
            .output();
        let _ = std::process::Command::new("systemctl")
            .args(["--user", "disable", "openlatch.service"])
            .output();
        let _ = std::fs::remove_file(&self.unit_path);
        let _ = std::process::Command::new("systemctl")
            .args(["--user", "daemon-reload"])
            .output();
        Ok(())
    }

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

        let output = std::process::Command::new("systemctl")
            .args(["--user", "is-active", "openlatch.service"])
            .output();

        let running = output.as_ref().is_ok_and(|o| o.status.success());

        Ok(SupervisorStatus {
            installed: true,
            running,
            description: if running {
                "systemd-user (Restart=on-failure active)".into()
            } else {
                "systemd-user (unit present, not running)".into()
            },
        })
    }
}

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

    #[test]
    fn unit_file_has_restart_on_failure() {
        let sup = SystemdSupervisor::new();
        let unit = sup.generate_unit(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(unit.contains("Restart=on-failure"));
    }

    #[test]
    fn unit_file_has_sandboxing() {
        let sup = SystemdSupervisor::new();
        let unit = sup.generate_unit(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(unit.contains("NoNewPrivileges=true"));
        assert!(unit.contains("ProtectSystem=strict"));
        assert!(unit.contains("ProtectHome=read-only"));
    }

    #[test]
    fn unit_file_has_read_write_paths() {
        let sup = SystemdSupervisor::new();
        let unit = sup.generate_unit(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(unit.contains("ReadWritePaths="));
        assert!(unit.contains(".openlatch"));
        assert!(unit.contains(".claude"));
    }
}