openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
pub mod launchd;
pub mod systemd;
/// In-process task supervision — restarts subsystems *inside* the daemon, the
/// same job the OS supervisors above do for the process itself. Never reached
/// by the lean `openlatch-hook` build: the whole `core` module is
/// `#[cfg(feature = "full-cli")]` in `src/lib.rs`, which is what keeps
/// `cargo build --no-default-features --bin openlatch-hook` unchanged.
pub mod task;
pub mod task_scheduler;

use crate::error::{OlError, ERR_NO_SUPERVISOR};

pub const ERR_SUPERVISION_UNSUPPORTED_OS: &str = "OL-1950";
pub const ERR_SUPERVISION_INSTALL_FAILED: &str = "OL-1951";
pub const ERR_SUPERVISION_BOOTSTRAP_FAILED: &str = "OL-1952";
/// A `start` / `stop` / `restart` handed to the OS supervisor failed — the
/// unit is registered but `systemctl` / `launchctl` / `schtasks` refused the
/// control verb. Callers fall back to acting on the process directly, which
/// is the pre-supervision behaviour, so this is a degraded path and not a
/// hard failure.
pub const ERR_SUPERVISION_CONTROL_FAILED: &str = "OL-1953";

/// Generation number of the generated supervisor artifacts (unit / plist /
/// task XML).
///
/// Every generator stamps `openlatch-unit-version: <N>` into its output and
/// [`unit_is_current`] compares an on-disk artifact against it. Bump this
/// whenever the generated restart semantics change, so `openlatch doctor` can
/// tell "installed, correct" from "installed years ago with the old
/// `Restart=on-failure` that never fired". Rewriting the artifact is
/// deliberately left to the user via `openlatch supervision install` —
/// silently rewriting an OS-registered unit under someone is not this
/// command's business.
///
/// | Version | Change |
/// |---|---|
/// | 1 | Original units: `Restart=on-failure`, `KeepAlive{SuccessfulExit=false}` — neither fired, because the daemon exited 0 even when it crashed. |
/// | 2 | Restart-always semantics: systemd `Restart=always`/`RestartSec=2`/`StartLimitIntervalSec=0`, launchd bare `KeepAlive`/`ThrottleInterval 2`, Task Scheduler `RestartOnFailure` aligned to the same cadence. |
/// | 3 | systemd `RestartPreventExitStatus=5`, so an already-running refusal (`OL-1501` → exit 5) ends the job instead of being restarted forever. **systemd only** — launchd has no per-exit-code equivalent to `KeepAlive` and Task Scheduler never restarts a completed run, so their artifacts are byte-identical to v2 apart from this marker. They are re-stamped anyway: the marker is one constant for all three backends, and a per-backend version buys nothing but a way for them to disagree. |
pub const UNIT_VERSION: u32 = 3;

/// The version marker line embedded in every generated artifact.
pub fn unit_version_marker() -> String {
    format!("openlatch-unit-version: {UNIT_VERSION}")
}

/// Does `contents` carry the current [`UNIT_VERSION`] marker?
///
/// `false` for both a stale generation and an artifact predating the marker
/// entirely — either way the answer to "is this what we generate today" is no.
pub fn unit_is_current(contents: &str) -> bool {
    contents.contains(&unit_version_marker())
}

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SupervisorKind {
    Launchd,
    Systemd,
    TaskScheduler,
    None,
}

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SupervisionMode {
    Active,
    Deferred,
    Disabled,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SupervisionConfig {
    pub mode: SupervisionMode,
    pub backend: SupervisorKind,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub disabled_reason: Option<String>,
}

impl Default for SupervisionConfig {
    fn default() -> Self {
        Self {
            mode: SupervisionMode::Disabled,
            backend: SupervisorKind::None,
            disabled_reason: Some("not_initialized".into()),
        }
    }
}

/// Did the user ASK for there to be no OS supervisor?
///
/// `user_opt_out` (`--no-persistence` / `supervision disable`),
/// `foreground_session` and `no_start` are all things someone typed. Everything
/// else — `unsupported_os`, `headless_install_no_gui`, `not_initialized`, an
/// install that deferred — is a state the machine landed in.
///
/// The distinction is the whole content of the report: an absence you chose is
/// not a finding, and an absence you did not choose is not a failure either. One
/// function so `doctor` and the daemon log can never classify the same reason
/// differently.
pub fn absence_is_deliberate(reason: Option<&str>) -> bool {
    matches!(
        reason,
        Some("user_opt_out" | "foreground_session" | "no_start")
    )
}

/// Which parts of this process's environment an OS supervisor cannot reproduce.
///
/// Empty means the unit it installs would supervise exactly the install you are
/// standing in. Non-empty names the variables that would be lost.
///
/// **The unit carries no environment.** It is one machine-global artifact —
/// `openlatch.service`, `ai.openlatch.client`, one scheduled task — with a
/// hardcoded `ExecStart` and nothing else, so whatever it starts resolves the
/// default state directory, the default agent config and the default ports.
/// Run from a sandbox shell, `supervision enable` therefore installed a unit
/// pointed at the machine's REAL install and, because `ExecStart` is
/// `current_exe()`, at the developer's work-in-progress binary. Observed
/// exactly once: it only failed to do damage because a stale process held 7443
/// and the `OL-1501` → exit 5 → `RestartPreventExitStatus=5` chain stopped it.
///
/// Baking the environment into the unit is the obvious alternative and it is
/// worse: the name is machine-global, so a sandbox would take the machine's
/// supervision slot, and two sandboxes would fight over it. An isolated
/// instance is a session of work — it has no business surviving a reboot.
pub fn unreproducible_environment() -> Vec<&'static str> {
    let mut divergent = divergent_directories();
    // Ports come from `config.toml` inside the state directory, which the unit
    // does read — only an env override is lost.
    for var in ["OPENLATCH_PORT", "OPENLATCH_BOUNDARY_PORT"] {
        if std::env::var_os(var).is_some_and(|v| !v.is_empty()) {
            divergent.push(var);
        }
    }
    divergent
}

/// The directory half of [`unreproducible_environment`]: the variables that
/// change *which install* the machine-global unit would resolve.
///
/// Compared resolved, so a variable pointed deliberately at the default — via
/// a symlink, or with a trailing slash — is not a divergence.
fn divergent_directories() -> Vec<&'static str> {
    let mut divergent = Vec::new();
    let canonical =
        |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
    if canonical(&crate::config::openlatch_dir())
        != canonical(&crate::config::default_openlatch_dir())
    {
        divergent.push("OPENLATCH_DIR");
    }
    if !crate::hooks::claude_code::config_is_machine_global() {
        divergent.push("CLAUDE_CONFIG_DIR");
    }
    divergent
}

/// Is the machine's supervisor unit this install's to tear down?
///
/// [`unreproducible_environment`] already stops `supervision enable` from
/// *installing* a unit from an isolated shell. Nothing stopped the reverse:
/// `openlatch uninstall`, `supervision uninstall` and `supervision disable` all
/// called `Supervisor::uninstall()` unconditionally, so tidying up a sandbox
/// deregistered the **machine's** daemon — the one artifact a sandbox is
/// guaranteed not to own. Enable refusing and disable obliging is not a policy,
/// it is a hole.
///
/// Only the directories decide. The unit carries no environment and its
/// `ExecStart` resolves the default state directory and the default agent
/// config, so those two answer "whose unit is this?"; the ports do not — the
/// unit reads them from `config.toml` in that same default directory, and an
/// exported `OPENLATCH_PORT` must not stop the machine's own install from
/// removing its own unit. That is the whole difference between this predicate
/// and the one `enable` uses, which asks the harder question "would the unit
/// reproduce *this* environment?".
pub fn owns_machine_supervision() -> bool {
    divergent_directories().is_empty()
}

/// The error an explicit `supervision install` / `enable` returns when the
/// environment cannot be reproduced.
///
/// A command whose whole job is "make this install survive a reboot" must not
/// quietly arrange for a *different* install to survive one instead.
pub fn unreproducible_environment_error(divergent: &[&'static str]) -> OlError {
    OlError::new(
        ERR_NO_SUPERVISOR,
        format!(
            "This install is isolated ({}), and an OS supervisor is machine-global",
            divergent.join(", ")
        ),
    )
    .with_suggestion(
        "The unit carries no environment, so it would supervise the machine's default \
         install instead of this one. Run `openlatch supervision enable` from an ordinary \
         shell, or leave an isolated instance unsupervised — `openlatch start` is the way \
         to bring it up.",
    )
    .with_docs("https://docs.openlatch.ai/errors/OL-1513")
}

pub trait Supervisor: Send + Sync {
    fn kind(&self) -> SupervisorKind;
    fn install(&self, binary_path: &std::path::Path) -> Result<(), OlError>;
    fn uninstall(&self) -> Result<(), OlError>;
    fn status(&self) -> Result<SupervisorStatus, OlError>;

    /// Start the supervised daemon now. Idempotent — a daemon that is already
    /// running stays running and this returns `Ok`.
    fn start(&self) -> Result<(), OlError>;

    /// Stop the supervised daemon and keep it stopped.
    ///
    /// The distinction that matters: the supervisor must record this as an
    /// *intentional* stop. Killing the process behind the supervisor's back —
    /// which is what `POST /shutdown` and SIGTERM do — reads as a death, and
    /// `Restart=always` / `KeepAlive` brings the daemon back within seconds.
    /// The unit stays registered, so the daemon returns at the next login.
    fn stop(&self) -> Result<(), OlError>;

    /// Stop-then-start in one supervisor-mediated step, without the window
    /// where the supervisor would race a caller-spawned replacement.
    fn restart(&self) -> Result<(), OlError>;
}

#[derive(Debug, Clone)]
pub struct SupervisorStatus {
    pub installed: bool,
    pub running: bool,
    /// `true` when the installed artifact carries the current
    /// [`UNIT_VERSION`] marker. `false` means a unit installed by an older
    /// release is still in place — it is registered and probably running, but
    /// with the restart semantics of that release. `openlatch doctor` reports
    /// the drift and points at `openlatch supervision install`.
    ///
    /// Meaningless (and always `false`) when `installed` is `false`.
    pub unit_current: bool,
    pub description: String,
}

pub fn select_supervisor() -> Option<Box<dyn Supervisor>> {
    select_supervisor_impl()
}

/// The OS supervisor when it — and not the calling process — owns the daemon's
/// lifecycle.
///
/// Returns `Some` only when the config says supervision is [`SupervisionMode::Active`]
/// **and** this OS has a supervisor. Every CLI path that would otherwise spawn
/// or kill the daemon itself asks this first.
///
/// The reason is that a registered supervisor is not a passive bystander. With
/// `Restart=always` (systemd) or `KeepAlive` (launchd), a daemon that this
/// process stops comes back within `RestartSec`, and a daemon this process
/// spawns races the one the supervisor already started — both binding the same
/// port, one of them losing. That is two owners with no arbitration, and it is
/// how `openlatch init` produced a 130-restart loop: `install()` started the
/// daemon via `enable --now`, then `init` spawned a second one, and whichever
/// lost the race exited 0 into `Restart=always`.
///
/// Callers treat a `Some` that then fails its control verb as a *degraded*
/// signal, not a hard error: a config claiming `active` next to a unit someone
/// deleted by hand must still let `openlatch stop` fall back to stopping the
/// process directly.
pub fn lifecycle_owner(cfg: &SupervisionConfig) -> Option<Box<dyn Supervisor>> {
    if cfg.mode != SupervisionMode::Active {
        return None;
    }
    select_supervisor()
}

#[cfg(target_os = "macos")]
fn select_supervisor_impl() -> Option<Box<dyn Supervisor>> {
    Some(Box::new(launchd::LaunchdSupervisor::new()))
}

#[cfg(target_os = "linux")]
fn select_supervisor_impl() -> Option<Box<dyn Supervisor>> {
    if systemd::is_systemd_available() {
        Some(Box::new(systemd::SystemdSupervisor::new()))
    } else {
        None
    }
}

#[cfg(target_os = "windows")]
fn select_supervisor_impl() -> Option<Box<dyn Supervisor>> {
    Some(Box::new(task_scheduler::TaskSchedulerSupervisor::new()))
}

#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn select_supervisor_impl() -> Option<Box<dyn Supervisor>> {
    None
}

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

    #[test]
    fn default_supervision_config_is_disabled() {
        let c = SupervisionConfig::default();
        assert_eq!(c.mode, SupervisionMode::Disabled);
        assert_eq!(c.backend, SupervisorKind::None);
    }

    #[test]
    fn select_supervisor_returns_something_on_this_os() {
        let sup = select_supervisor();
        #[cfg(any(target_os = "macos", target_os = "windows"))]
        assert!(sup.is_some());
        let _ = sup;
    }

    /// Serialises the env-var mutation below; `cargo test` runs this module's
    /// tests on threads of one process.
    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    #[test]
    fn an_isolated_install_reports_what_a_unit_cannot_carry() {
        // The unit has no `Environment=`. Every variable named here is one it
        // would silently drop, leaving it supervising the machine's default
        // install — which is what `supervision enable` did from a sandbox
        // shell, pointing the machine's unit at a worktree dev binary.
        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let previous = std::env::var_os("OPENLATCH_PORT");
        std::env::set_var("OPENLATCH_PORT", "17400");
        let divergent = unreproducible_environment();
        match previous {
            Some(v) => std::env::set_var("OPENLATCH_PORT", v),
            None => std::env::remove_var("OPENLATCH_PORT"),
        }

        assert!(
            divergent.contains(&"OPENLATCH_PORT"),
            "a port override the unit cannot carry must be reported: {divergent:?}"
        );
    }

    #[test]
    fn the_refusal_names_every_divergence_and_a_way_forward() {
        let err = unreproducible_environment_error(&["OPENLATCH_DIR", "CLAUDE_CONFIG_DIR"]);
        assert_eq!(err.code, ERR_NO_SUPERVISOR);
        assert!(err.message.contains("OPENLATCH_DIR"));
        assert!(err.message.contains("CLAUDE_CONFIG_DIR"));
        // A refusal that does not say what to do instead is a dead end.
        assert!(err
            .suggestion
            .as_deref()
            .is_some_and(|s| s.contains("openlatch start")));
    }
}