openlatch-client 0.6.22

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
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". A stale artifact is rewritten by
/// `openlatch start` / `restart` (the CLI migration) and, on launchd and Task
/// Scheduler, by the daemon's own startup through
/// [`Supervisor::refresh_in_place`] — the latter is how an auto-updated host
/// picks up a new generation with nobody at the keyboard.
///
/// | 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. |
/// | 4 | systemd `ReadWritePaths` agent dirs made optional (`-~/.claude`, plus `~/.codex` and `~/.cline`), so a host without Claude Code no longer fails every start at `226/NAMESPACE`. **systemd only**; the others are re-stamped for the reason given at 3. |
/// | 5 | Watchdog: a deliberate stop (`openlatch stop`, `systemctl --user stop`, `launchctl bootout`, `schtasks /End`) no longer lasts until the next login. systemd gains `openlatch-watchdog.timer`, launchd the `ai.openlatch.client.watchdog` agent, Task Scheduler a repeating time trigger on the existing task — each re-activates the job within [`WATCHDOG_INTERVAL_SECS`]. |
/// | 6 | launchd watchdog restores a deleted main plist from a copy it carries, while the binary it names still exists. **launchd only**; the others are re-stamped for the reason given at 3. |
pub const UNIT_VERSION: u32 = 6;

/// How long a deliberately stopped daemon stays down before the OS watchdog
/// brings it back. One constant, rendered per backend: systemd `5min`, Task
/// Scheduler `PT5M`, launchd `300`.
///
/// The watchdog never starts a second daemon and never runs an `openlatch`
/// command — it re-activates the supervised job, which is a no-op while that
/// job runs. Only `system supervision disable` or an uninstall removes it.
pub const WATCHDOG_INTERVAL_SECS: u64 = 300;

/// 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,
}

/// The `disabled_reason` `uninstall` leaves behind. Every `init` path rewrites
/// the reason, so it stands exactly from an uninstall to the next `init`.
pub const UNINSTALLED_REASON: &str = "uninstalled";

#[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 SupervisionConfig {
    /// Whether `uninstall` took this install out and no `init` has put it back.
    /// A daemon started in that window must not re-write into agent config
    /// what the uninstall removed (#325).
    pub fn marked_uninstalled(&self) -> bool {
        self.disabled_reason.as_deref() == Some(UNINSTALLED_REASON)
    }
}

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_MODEL_RELAY_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 system 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 and the watchdog stays armed, so the daemon
    /// returns within [`WATCHDOG_INTERVAL_SECS`].
    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>;

    /// Write the current artifacts — watchdog included — **without stopping or
    /// restarting the running daemon**. The daemon's own startup calls this to
    /// roll a new [`UNIT_VERSION`] out to an auto-updated host, so it must
    /// never end the process calling it.
    ///
    /// Not reachable on systemd from the daemon: the unit's sandbox keeps the
    /// unit directory read-only (see `read_write_paths_never_cover_the_unit_directory`),
    /// so there the CLI migration is the only path.
    fn refresh_in_place(&self, binary_path: &std::path::Path) -> 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 system 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()
}

/// Should the daemon's startup rewrite the supervisor artifacts in place?
///
/// This is how a new [`UNIT_VERSION`] reaches an auto-updated host: the update
/// exits the daemon, the supervisor starts the new binary, and that binary
/// finds an artifact older than itself. Every condition is a refusal:
///
/// - **Not the lifecycle owner** (`active` is false): there is no unit to
///   refresh, or the user turned it off.
/// - **Not the machine's install** (`owns_machine` false, or `divergent`
///   non-empty): the unit is machine-global, and an isolated instance writing
///   it would supervise the real install with this one's binary.
/// - **systemd**: the daemon runs inside its unit's sandbox, which keeps the
///   unit directory read-only on purpose. There the CLI migration in
///   `openlatch start` / `restart` and `doctor --fix` is the only path.
/// - **Already current**, or not installed at all.
///
/// Pure, so the table of host states is testable without a supervisor.
pub fn refresh_in_place_wanted(
    kind: &SupervisorKind,
    active: bool,
    owns_machine: bool,
    divergent: &[&'static str],
    status: &SupervisorStatus,
) -> bool {
    active
        && owns_machine
        && divergent.is_empty()
        && !matches!(kind, SupervisorKind::Systemd | SupervisorKind::None)
        && status.installed
        && !status.unit_current
}

/// The daemon-startup half of the artifact migration: refresh a stale unit
/// without restarting the daemon running under it. Blocking (it shells out to
/// the supervisor), never on the hook path, no network. A failure is a warning
/// only — `doctor` reports the stale unit as it always has.
pub fn refresh_stale_artifacts_from_daemon(cfg: &SupervisionConfig) {
    let Some(sup) = lifecycle_owner(cfg) else {
        return;
    };
    let Ok(status) = sup.status() else { return };
    if !refresh_in_place_wanted(
        &sup.kind(),
        true,
        owns_machine_supervision(),
        &unreproducible_environment(),
        &status,
    ) {
        return;
    }
    let Ok(exe) = std::env::current_exe() else {
        return;
    };
    match sup.refresh_in_place(&exe) {
        Ok(()) => tracing::info!(
            exe = %exe.display(),
            version = UNIT_VERSION,
            "supervisor artifacts predated this binary; refreshed them in place, watchdog armed"
        ),
        Err(e) => tracing::warn!(
            error = %e.message, code = e.code,
            "could not refresh the stale supervisor artifacts; they keep their previous semantics"
        ),
    }
}

#[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 the_daemon_refreshes_only_a_stale_machine_unit_it_can_write() {
        let status = |installed, unit_current| SupervisorStatus {
            installed,
            running: true,
            unit_current,
            description: String::new(),
        };
        let stale = status(true, false);
        let launchd = SupervisorKind::Launchd;
        let tasks = SupervisorKind::TaskScheduler;
        let systemd = SupervisorKind::Systemd;
        let none: &[&'static str] = &[];
        let sandbox: &[&'static str] = &["OPENLATCH_DIR"];

        // (kind, active, owns machine, divergent, status) → refresh?
        let table = [
            (&launchd, true, true, none, &stale, true),
            (&tasks, true, true, none, &stale, true),
            // Already current: nothing to do.
            (&launchd, true, true, none, &status(true, true), false),
            // Not installed: nothing to refresh.
            (&tasks, true, true, none, &status(false, false), false),
            // Supervision deferred or disabled.
            (&launchd, false, true, none, &stale, false),
            // An isolated instance never writes the machine's unit.
            (&launchd, true, false, none, &stale, false),
            (&tasks, true, true, sandbox, &stale, false),
            // systemd: the unit directory is read-only inside the sandbox.
            (&systemd, true, true, none, &stale, false),
        ];
        for (i, (kind, active, owns, divergent, status, want)) in table.into_iter().enumerate() {
            assert_eq!(
                refresh_in_place_wanted(kind, active, owns, divergent, status),
                want,
                "row {i}: {kind:?} active={active} owns={owns} divergent={divergent:?}"
            );
        }
    }

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