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
//! 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 model relay
// 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.
//
// `ReadWritePaths`: every path listed must exist when the namespace is
// built, or the unit fails at `226/NAMESPACE` before `ExecStart` runs —
// on every restart, forever. `.openlatch` is the daemon's own state and
// every caller of `install` has already written its config there, so it
// stays mandatory. The agent config dirs are the daemon's to write
// (hook reconcile, relay wiring) only when that agent is installed, and
// most hosts have one agent, not three — hence the `-` prefix, systemd's
// "ignore if missing". A Codex-only host used to crash-loop on the
// absent `~/.claude`.
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 -{home}/.codex -{home}/.cline
[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 model relay'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"));
}
/// A Codex-only host has no `~/.claude`, and a missing `ReadWritePaths`
/// entry fails the unit at `226/NAMESPACE` before `ExecStart` — on every
/// restart, so the daemon never survived a logout. Every agent config dir
/// is optional (`-`); only the daemon's own state dir, which the CLI
/// creates before it installs the unit, is required.
#[test]
fn read_write_paths_tolerate_absent_agent_dirs() {
let sup = SystemdSupervisor::new();
let unit = sup.generate_unit(Path::new("/opt/openlatch/bin/openlatch"));
let rw = unit
.lines()
.find_map(|l| l.strip_prefix("ReadWritePaths="))
.expect("unit must declare ReadWritePaths");
let entries: Vec<&str> = rw.split_whitespace().collect();
let state = entries
.iter()
.find(|e| e.ends_with("/.openlatch"))
.expect("the daemon's state dir must be writable");
assert!(
!state.starts_with('-'),
"state dir must stay mandatory: {rw}"
);
// The daemon writes each agent's config: hook reconcile and relay
// wiring for Claude Code and Codex, plugin and provider slots for Cline.
for agent_dir in ["/.claude", "/.codex", "/.cline"] {
let entry = entries
.iter()
.find(|e| e.ends_with(agent_dir))
.unwrap_or_else(|| panic!("{agent_dir} must be writable: {rw}"));
assert!(
entry.starts_with('-'),
"{agent_dir} must be optional or a host without that agent crash-loops: {rw}"
);
}
}
/// 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}"
);
}
}
}