openlatch-client 0.1.18

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

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

use crate::error::OlError;

use super::{
    Supervisor, SupervisorKind, SupervisorStatus, ERR_SUPERVISION_CONTROL_FAILED,
    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();
        let marker = super::unit_version_marker();
        // `Restart=always`, not `on-failure`: `openlatch stop` goes through
        // `/shutdown`, which is a clean exit, and systemd would treat a daemon
        // killed by anything that produces a 0 exit as "it meant to stop".
        // Agents route their model traffic through this process — the honest
        // policy is that it comes back no matter how it died, and the user's
        // explicit `systemctl --user stop` still wins over `Restart=always`.
        //
        // `RestartSec=2` (was 10) bounds the dead-port window the boundary
        // listener leaves behind on a process restart.
        //
        // `StartLimitIntervalSec=0` disables the start-rate limiter outright.
        // The default (5 starts / 10 s → give up permanently) is precisely the
        // wrong behaviour here: a crash loop caused by something transient —
        // a full disk, a port still in TIME_WAIT — would leave the host with
        // no daemon at all and no further attempts, which is worse than
        // retrying forever every 2 s.
        //
        // `RestartPreventExitStatus=5` is what makes that safe. Exit 5 is the
        // ONLY code the daemon uses for "another daemon already holds this
        // machine" (`OL-1501`, see `OlError::exit_code`) — a condition no
        // amount of restarting can resolve, because the thing in the way is a
        // healthy daemon. Without it, `ExecStart` refusing in 30 ms against an
        // unlimited restarter is an infinite loop that never reports a failure.
        // Every other exit code, crashes included, still restarts forever; that
        // is the whole point of the pairing, and it is why a *dedicated* code
        // was needed rather than the generic exit 1 every `Err` produces.
        format!(
            r#"[Unit]
Description=OpenLatch runtime enforcement node
Documentation=https://docs.openlatch.ai
After=network.target
# {marker}
StartLimitIntervalSec=0

[Service]
Type=simple
ExecStart={bin} daemon start --foreground
Restart=always
RestartSec=2
RestartPreventExitStatus=5
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,
                unit_current: 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());
        // `is-active` prints the state on stdout and this call already paid for
        // it — throwing it away is what made a restart loop indistinguishable
        // from a stopped unit. `activating` (systemd's word for
        // "auto-restart") and `failed` are the two states an operator most
        // needs to tell apart, and neither survives a bare `running: false`.
        let state = output
            .as_ref()
            .ok()
            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
            .filter(|s| !s.is_empty())
            .unwrap_or_else(|| "unknown".into());
        let unit_current = std::fs::read_to_string(&self.unit_path)
            .map(|c| super::unit_is_current(&c))
            .unwrap_or(false);

        Ok(SupervisorStatus {
            installed: true,
            running,
            unit_current,
            description: match (running, unit_current) {
                (true, true) => "systemd-user (Restart=always active)".into(),
                (true, false) => {
                    "systemd-user (running an outdated unit — reinstall to get Restart=always)"
                        .into()
                }
                (false, _) => format!("systemd-user (unit present, state: {state})"),
            },
        })
    }

    fn start(&self) -> Result<(), OlError> {
        run_control(&["--user", "start", SERVICE_NAME])
    }

    fn stop(&self) -> Result<(), OlError> {
        // `systemctl stop` is a job, not a kill: systemd records the stop as
        // intentional, so `Restart=always` does not undo it two seconds later
        // the way `POST /shutdown` does. The unit stays enabled, so the daemon
        // still returns at the next login — which is what `openlatch stop`
        // has always meant.
        run_control(&["--user", "stop", SERVICE_NAME])
    }

    fn restart(&self) -> Result<(), OlError> {
        run_control(&["--user", "restart", SERVICE_NAME])
    }
}

/// Invoke a `systemctl` control verb, mapping failure to
/// [`ERR_SUPERVISION_CONTROL_FAILED`] so callers can tell "the supervisor
/// refused" from "the supervisor is not there" and fall back accordingly.
fn run_control(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) => Err(OlError::new(
            ERR_SUPERVISION_CONTROL_FAILED,
            format!(
                "systemctl {} failed: {}",
                args.join(" "),
                String::from_utf8_lossy(&o.stderr).trim()
            ),
        )),
        Err(e) => Err(OlError::new(
            ERR_SUPERVISION_CONTROL_FAILED,
            format!("Cannot run systemctl: {e}"),
        )),
    }
}

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

    /// `Restart=on-failure` never fired: the daemon exited 0 even when it
    /// crashed, so systemd read every death as intentional. The unit must ask
    /// for `always` and back off fast enough that the boundary's dead-port
    /// window stays short.
    #[test]
    fn unit_file_restarts_always_and_never_gives_up() {
        let sup = SystemdSupervisor::new();
        let unit = sup.generate_unit(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(unit.contains("Restart=always"), "unit:\n{unit}");
        assert!(!unit.contains("Restart=on-failure"), "unit:\n{unit}");
        assert!(unit.contains("RestartSec=2"), "unit:\n{unit}");
        // Without this, 5 crashes in 10s makes systemd give up permanently.
        assert!(unit.contains("StartLimitIntervalSec=0"), "unit:\n{unit}");
    }

    /// The pairing that bounds the loop: unlimited restarts are safe only
    /// because the one unrecoverable exit is exempted from them.
    ///
    /// Exit 5 is `OL-1501` — "another daemon already holds this machine". No
    /// number of restarts fixes that, and without this line `ExecStart`
    /// refusing in 30 ms against `Restart=always` + `StartLimitIntervalSec=0`
    /// is an unbounded loop that never reports a failure. Observed: 130
    /// restarts in five minutes against a healthy daemon.
    #[test]
    fn unit_file_does_not_restart_the_already_running_refusal() {
        let sup = SystemdSupervisor::new();
        let unit = sup.generate_unit(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(
            unit.contains("RestartPreventExitStatus=5"),
            "exit 5 (OL-1501) must not be restarted:\n{unit}"
        );
        // It must exempt ONLY 5. Exempting 1 would cover every `Err` the CLI
        // can produce — including the serve-error crash supervision exists to
        // recover from.
        assert!(
            !unit.contains("RestartPreventExitStatus=1"),
            "exempting exit 1 would stop restarts on genuine crashes:\n{unit}"
        );
    }

    #[test]
    fn unit_file_carries_the_version_marker() {
        let sup = SystemdSupervisor::new();
        let unit = sup.generate_unit(Path::new("/opt/openlatch/bin/openlatch"));
        assert!(
            super::super::unit_is_current(&unit),
            "generated unit must carry the current marker:\n{unit}"
        );
        // A unit from before the marker existed must read as stale.
        assert!(!super::super::unit_is_current(
            "[Service]\nRestart=on-failure\n"
        ));
    }

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

    /// The unit directory stays outside `ReadWritePaths`, and that exclusion is
    /// load-bearing in two directions.
    ///
    /// It is why the stale-unit migration runs from the CLI rather than the
    /// daemon: a daemon running under this unit gets `EROFS` writing the file
    /// it would be trying to replace. Someone hitting that will be tempted to
    /// "fix" it by adding `%h/.config/systemd/user` here.
    ///
    /// That trade is refused. An enforcement daemon able to rewrite its own
    /// startup unit is one that can make itself persistent once compromised,
    /// and this line is what denies it. The inconvenience is the smaller cost.
    ///
    /// Asserted rather than left to review because the failure is silent: the
    /// widened unit works, every test passes, and only the security property
    /// quietly disappears.
    ///
    /// Matched as a set of spellings rather than one, because the assertion has
    /// to deny a *directory* and systemd offers several ways to name it. A
    /// guard that knew only the literal path would wave the others through, and
    /// whoever wrote them would have every reason to believe the rule was
    /// satisfied.
    ///
    /// The user manager reads units from two trees — verified with
    /// `systemd-analyze --user unit-paths`:
    ///
    /// | Spelling | Expands to |
    /// |---|---|
    /// | `%h/.config/systemd/user`, literal | `~/.config/systemd/user` |
    /// | `%E/systemd/user` | same — `%E` is the config root, `~/.config` |
    /// | `%h/.local/share/systemd/user`, literal | `~/.local/share/systemd/user` |
    /// | `%D/systemd/user` | same — `%D` is the shared-data root |
    ///
    /// `~/.config/systemd/user.control` is covered by the first prefix.
    /// Deliberately absent: `%S` (`~/.local/state`) and `%C` (cache) are not
    /// unit directories, so denying them would only suggest they were.
    #[test]
    fn read_write_paths_never_cover_the_unit_directory() {
        let sup = SystemdSupervisor::new();
        let unit = sup.generate_unit(Path::new("/opt/openlatch/bin/openlatch"));
        let rw = unit
            .lines()
            .find(|l| l.starts_with("ReadWritePaths="))
            .expect("unit must declare ReadWritePaths");
        for spelling in [
            ".config/systemd/user",
            "%E/systemd/user",
            ".local/share/systemd/user",
            "%D/systemd/user",
        ] {
            assert!(
                !rw.contains(spelling),
                "ReadWritePaths must never cover a unit directory (matched '{spelling}') — \
                 that would let a compromised daemon rewrite its own startup unit. Migration \
                 belongs in the CLI (see migrate_supervisor_artifact_if_stale). Got: {rw}"
            );
        }
    }
}