omp-conductor 0.18.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
package/src/setup-host.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, type Stats } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { homedir, userInfo } from "node:os";
4
- import { dirname, join, relative, resolve } from "node:path";
4
+ import { dirname, join, relative, resolve, sep } from "node:path";
5
5
  import { configPath, loadConfig, resolveCaps, stateDir } from "./config.ts";
6
6
  import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
7
7
  import { ORCHESTRATOR_BRIEF_NAME } from "./brief-upgrade.ts";
@@ -12,11 +12,7 @@ import {
12
12
  resolveHerdrSessionWithBridge,
13
13
  } from "./fleet.ts";
14
14
  import {
15
- harnessBindingProblem,
16
- WORKER_ACCOUNT,
17
15
  WORKER_HARNESS_DIR,
18
- WORKER_HARNESS_NODE_MODULES,
19
- WORKER_HOME_DIR,
20
16
  packageNodeModulesRoot,
21
17
  } from "./host.ts";
22
18
  import {
@@ -79,9 +75,14 @@ export const RECOVER_SCRIPT_FILE = "omp-conductor-recover.sh";
79
75
  export const RECOVER_SCRIPT_INSTALL_PATH = "/usr/local/sbin/omp-conductor-recover";
80
76
 
81
77
  /**
82
- * The systemd unit that binds the operator's install read-only at
83
- * {@link WORKER_HARNESS_NODE_MODULES} so worker sessions resolve the harness
84
- * the operator installed (#828).
78
+ * The mount unit releases 0.18.1-era installed to bind the operator's install
79
+ * read-only for a separate worker account (#828), kept **only** so this
80
+ * version can retire it (#895).
81
+ *
82
+ * Nothing renders or installs it any more: worker sessions launch under the
83
+ * fleet account again (#894), so the bind serves nobody, and a `.mount` unit
84
+ * an earlier release enabled stays enabled until something disables it. This
85
+ * constant is that something's target — see {@link HostRuntimePlan.retire}.
85
86
  *
86
87
  * The name is not a choice: systemd derives a mount unit's name from its mount
87
88
  * point and refuses to load one under any other, so this is the escaped form of
@@ -90,6 +91,23 @@ export const RECOVER_SCRIPT_INSTALL_PATH = "/usr/local/sbin/omp-conductor-recove
90
91
  */
91
92
  export const HARNESS_MOUNT_UNIT_NAME = "var-lib-omp\\x2dworker\\x2dharness-node_modules.mount";
92
93
 
94
+ /**
95
+ * Host state a previous release installed that this one retires (#895).
96
+ *
97
+ * Kept as one value rather than three so a caller cannot execute the steps
98
+ * without being able to say what they retire (the CLI prints `units`/`staged`,
99
+ * the reconcile reports them, and `formatHostRuntimePlan` shows them before
100
+ * the confirm).
101
+ */
102
+ export interface HostRetirement {
103
+ /** Installed unit paths this version no longer ships, still on disk. */
104
+ units: readonly string[];
105
+ /** Staged renders of those units still sitting in the state directory. */
106
+ staged: readonly string[];
107
+ /** The ordered steps that retire them: disable/unmount, remove, reload. */
108
+ steps: readonly PrivilegedStep[];
109
+ }
110
+
93
111
  /**
94
112
  * The symlink the session cwd loads as its brief. omp auto-loads `AGENTS.md`
95
113
  * from the session cwd, so the fleet pane's cwd needs a link at this name
@@ -147,15 +165,6 @@ export interface HostRuntimePlan {
147
165
  * rendering that provisions it (see {@link renderHerdrUnit}).
148
166
  */
149
167
  herdrUnit?: PlannedWrite<string>;
150
- /**
151
- * The worker harness binding's mount unit (#828): a read-only bind of the
152
- * operator's install at {@link WORKER_HARNESS_NODE_MODULES}, which is the
153
- * only path a worker session can resolve the harness through. Present
154
- * exactly when the caller planned a worker identity this host can establish
155
- * — the binding has no purpose without one, and a host that cannot run the
156
- * account machinery has nothing to bind it for.
157
- */
158
- harnessMount?: PlannedWrite<string>;
159
168
  /**
160
169
  * The fleet recovery oneshot (#485): staged always, because the daemon unit
161
170
  * (which every fleet has) carries `OnFailure=` to it. Rendered by
@@ -204,6 +213,13 @@ export interface HostRuntimePlan {
204
213
  herdrEnv?: PlannedWrite<string>;
205
214
  /** The live plugin config.env {@link herdrEnv} merges into, post-consent. */
206
215
  herdrEnvTarget?: string;
216
+ /**
217
+ * The Herdr session this host's unit serves, present exactly when
218
+ * {@link herdrUnit} is. Carried on the plan because the install has to know
219
+ * which session to prove supervision of (#893), and re-deriving it there
220
+ * would let the unit's `--session` and the session probed disagree.
221
+ */
222
+ herdrSession?: string;
207
223
  /**
208
224
  * When {@link planTick} rewrote a shared-default `agentName`, the live herdr
209
225
  * pane still carries the old identity — the ticking decline that names the
@@ -262,12 +278,21 @@ export interface HostRuntimePlan {
262
278
  */
263
279
  drift: readonly string[];
264
280
  /**
265
- * The worker identity plan (#798) when the caller asked for one: the
266
- * account/agent-bind/grant steps prepended to {@link steps}, and the
267
- * verdict that feeds {@link currentInstall}. Absent on the historical
268
- * surface (callers that plan no identity).
281
+ * Host state this version no longer ships but a previous one installed, and
282
+ * the ordered steps that retire it (#895). Absent when there is nothing to
283
+ * retire, which is every host that never ran the separate worker boundary
284
+ * and every host that has already been converged.
285
+ *
286
+ * Retirement is planned rather than merely dropped because deleting the
287
+ * provisioning code cannot unmount anything: the `.mount` unit an earlier
288
+ * release enabled stays enabled across upgrades, and a bind of the
289
+ * operator's install would keep being mounted at a path nothing reads. So
290
+ * the steps run FIRST in {@link steps}, and while any of them are owed
291
+ * {@link currentInstall} is false — a re-run that reported "already current"
292
+ * with the obsolete mount still active is exactly the half-applied
293
+ * retirement this exists to prevent.
269
294
  */
270
- workerIdentity?: WorkerIdentityPlan;
295
+ retire?: HostRetirement;
271
296
  /**
272
297
  * True when every file the privileged install steps would write is already
273
298
  * at its destination with the current bytes. `runHostInstall` uses it to
@@ -473,17 +498,136 @@ function refusal(
473
498
  ].join("\n");
474
499
  }
475
500
 
501
+ /**
502
+ * Whether a planned step list restarts the Herdr session unit — read off the
503
+ * exact argv the transaction would run, never a step title, so a renamed step
504
+ * cannot silently disarm the guard below.
505
+ */
506
+ function restartsHerdrUnit(steps: readonly PrivilegedStep[]): boolean {
507
+ return steps.some(
508
+ (step) =>
509
+ step.argv[0] === "systemctl" &&
510
+ step.argv[1] === "restart" &&
511
+ step.argv[2] === DEFAULT_HERDR_UNIT,
512
+ );
513
+ }
514
+
515
+ /**
516
+ * Refuses a host transaction that would kill the shell running it (#834).
517
+ *
518
+ * Herdr panes are children of {@link DEFAULT_HERDR_UNIT}, so the step list's
519
+ * `systemctl restart` of that unit terminates every pane in the session —
520
+ * including the one that invoked `setup host`. Measured 2026-08-20: the install
521
+ * reached `[20/20] restart herdr-fleet.service` and completed, while the calling
522
+ * OMP pane died at exit 143 with `stopReason: aborted`. The install was fine and
523
+ * *looked* like a failure, because the process that would have reported and
524
+ * verified it no longer existed. `upgrade` already refuses this context
525
+ * outright; this is the same refusal for the other transaction that restarts
526
+ * the same unit.
527
+ *
528
+ * Two conditions, both required, so the refusal is exactly as narrow as the
529
+ * hazard:
530
+ *
531
+ * - the plan actually restarts the session unit (a host with no herdr unit,
532
+ * or a no-op re-run that installs nothing, cannot kill anybody);
533
+ * - the caller is inside a Herdr pane (`$HERDR_ENV`), which is the same
534
+ * signal {@link upgradeConductor} keys on.
535
+ *
536
+ * Refusing *before* any mutation is what makes it safe to refuse at all:
537
+ * nothing is staged, nothing is installed, and the operator re-runs the whole
538
+ * transaction from a context that survives it.
539
+ */
540
+ export function checkSessionRestartContext(
541
+ verb: string,
542
+ steps: readonly PrivilegedStep[],
543
+ env: Record<string, string | undefined> = process.env,
544
+ ): EscalationVerdict {
545
+ const inSession = env["HERDR_ENV"] !== undefined && env["HERDR_ENV"] !== "";
546
+ if (!inSession || !restartsHerdrUnit(steps)) return { kind: "ok" };
547
+ return {
548
+ kind: "refuse",
549
+ message: [
550
+ `omp-conductor: run ${verb} from a shell outside the target Herdr session.`,
551
+ `This transaction restarts ${DEFAULT_HERDR_UNIT}, and this shell is a pane inside it`,
552
+ "($HERDR_ENV is set). The restart would kill this process mid-install, before it could",
553
+ "report the outcome or verify anything — a completed install that looks like a failure.",
554
+ "Nothing has been staged or installed.",
555
+ "",
556
+ "Run the same command from a context the restart cannot reach:",
557
+ ` - a plain login shell on this host (ssh, or a terminal outside herdr), or`,
558
+ ` - a detached transient unit: systemd-run --collect --unit=omp-conductor-setup-host \\`,
559
+ ` --property=Type=oneshot --uid="$(id -un)" omp-conductor ${verb} …`,
560
+ "",
561
+ "The fleet keeps running until you do; this refusal changed nothing.",
562
+ ].join("\n"),
563
+ };
564
+ }
565
+
566
+
567
+ /**
568
+ * Where the fleet's own binaries are looked for, independent of whoever invoked
569
+ * this process (#890).
570
+ *
571
+ * `Bun.which` searches `process.env.PATH` by default, which is how #879's fix
572
+ * stayed half-done: the staged PATH stopped *containing* caller entries, but
573
+ * the two directories it derives from `omp-conductor` and `herdr` were still
574
+ * resolved through the caller's PATH. Under a reduced startup PATH — a systemd
575
+ * unit, a cron shell, `ssh host '<cmd>'` — `herdr` resolves to null, the staged
576
+ * unit silently loses its directory (and its `herdr` line entirely), and
577
+ * `doctor` re-deriving the render in a richer shell reports drift on an install
578
+ * nobody touched.
579
+ *
580
+ * So resolution answers to the host's install layout instead, in this order:
581
+ *
582
+ * 1. the directory of the Bun that is executing right now — a global `bun add`
583
+ * install puts `omp-conductor` beside it;
584
+ * 2. this package's own `node_modules/.bin`, when it is installed as a package
585
+ * rather than run from a checkout — the binaries that ship with *this* code;
586
+ * 3. the two conventional per-user bin directories;
587
+ * 4. the standard system directories the staged unit already carries.
588
+ *
589
+ * The caller's PATH is deliberately absent: including it as a fallback would
590
+ * make the resolved value depend on the invoking shell again, which is the whole
591
+ * defect. A binary installed somewhere none of these cover is not resolved, and
592
+ * that renders identically from every shell — a wrong-but-stable answer a
593
+ * `doctor` finding can name, rather than an answer that changes per caller.
594
+ */
595
+ export function canonicalBinSearchPath(home: string = homedir()): string {
596
+ const packageBin = ((): string[] => {
597
+ // `<root>/node_modules/omp-conductor/omp/src` → `<root>/node_modules/.bin`
598
+ const marker = `${sep}node_modules${sep}`;
599
+ const at = import.meta.dir.lastIndexOf(marker);
600
+ return at === -1 ? [] : [join(import.meta.dir.slice(0, at), "node_modules", ".bin")];
601
+ })();
602
+ return [
603
+ dirname(process.execPath),
604
+ ...packageBin,
605
+ join(home, ".bun", "bin"),
606
+ join(home, ".local", "bin"),
607
+ ...SERVICE_SYSTEM_PATH.split(":"),
608
+ ]
609
+ .filter((value, index, all) => value.length > 0 && all.indexOf(value) === index)
610
+ .join(":");
611
+ }
476
612
 
477
613
  export function defaultServiceRuntime(
478
614
  telegramStateDir: string,
479
615
  // Bridge-resolved session name. Passed in so tests stay hermetic (no herdr
480
616
  // spawn) and the renderer never shells out — same threading as telegramStateDir.
481
617
  herdrSession: string = resolveHerdrSessionWithBridge(),
618
+ // The canonical search path (#890), injectable for the same reason: a test
619
+ // must be able to prove the render does not move when the CALLER's PATH does,
620
+ // which means controlling what "installed here" means without touching the
621
+ // process environment the assertion is about.
622
+ searchPath: string = canonicalBinSearchPath(),
482
623
  ): ServiceRuntime {
483
624
  const home = homedir();
484
625
  const bun = process.execPath;
485
- const globalCli = Bun.which("omp-conductor");
486
- const herdr = Bun.which("herdr");
626
+ // Resolved against the canonical install layout, never `process.env.PATH`:
627
+ // the whole point of #890 is that these two directories are facts about the
628
+ // host, not about the shell that happened to run setup or doctor.
629
+ const globalCli = Bun.which("omp-conductor", { PATH: searchPath });
630
+ const herdr = Bun.which("herdr", { PATH: searchPath });
487
631
  const loginShell = userInfo().shell;
488
632
  // #879: the staged PATH is a canonical host-runtime value — the directories
489
633
  // of the binaries this runtime resolved plus SERVICE_SYSTEM_PATH — never the
@@ -661,60 +805,6 @@ export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string |
661
805
  ].join("\n");
662
806
  }
663
807
 
664
- /**
665
- * The worker harness binding (#828): a read-only bind of the operator's
666
- * `node_modules` at {@link WORKER_HARNESS_NODE_MODULES}, which is how a worker
667
- * session reaches the exact harness build installed alongside omp-conductor.
668
- *
669
- * A bind rather than a copy, because the two must never diverge: it shares
670
- * inodes with the source, so an operator who upgrades the harness upgrades what
671
- * every worker loads, with no mirror to re-materialise and no window in which a
672
- * worker runs last week's build.
673
- *
674
- * `ro` because a session has no business writing the install; `nosuid,nodev`
675
- * because a tree the worker account reads should carry neither. Never `noexec`:
676
- * the harness dlopens its native addon out of this tree, and `noexec` refuses
677
- * the executable mapping that needs.
678
- *
679
- * The ordering edge lives here rather than in the daemon unit, and that is not
680
- * a style choice. Both units are pulled in by `multi-user.target`, so without
681
- * an edge a reboot can start the daemon first — and although dispatch
682
- * re-resolves the binding per launch (so the fleet recovers on the next tick
683
- * rather than needing a restart), ordering removes the window instead of
684
- * tolerating it. It is declared as `Before=` on the mount because the *daemon*
685
- * unit's name needs no escaping: systemd does not resolve a `\x2d`-escaped unit
686
- * name written into a dependency setting back to the real unit, so the reverse
687
- * spelling silently binds nothing (verified on a live host).
688
- *
689
- * Deliberately not `Requires=`/`RequiresMountsFor=` from the daemon: those
690
- * would keep the control plane down whenever the bind failed, and a daemon that
691
- * is up and refusing worker launches with the reason is what an operator can
692
- * actually diagnose.
693
- */
694
- export function renderHarnessMountUnit(packageRoot: string): string {
695
- return [
696
- "[Unit]",
697
- "Description=omp-conductor worker harness binding",
698
- "Documentation=https://github.com/TerrifiedBug/conductor",
699
- // The source is a directory on some filesystem; systemd must have that
700
- // filesystem before it can bind anything out of it.
701
- `RequiresMountsFor=${systemdPath(packageRoot)}`,
702
- // Ordering only, never a requirement: the daemon must not be started before
703
- // the bind exists, but a failed bind must not keep the control plane down.
704
- `Before=${STAGED_SERVICE_NAME}`,
705
- "",
706
- "[Mount]",
707
- `What=${systemdPath(packageRoot)}`,
708
- `Where=${systemdPath(WORKER_HARNESS_NODE_MODULES)}`,
709
- "Type=none",
710
- "Options=bind,ro,nosuid,nodev",
711
- "",
712
- "[Install]",
713
- "WantedBy=multi-user.target",
714
- "",
715
- ].join("\n");
716
- }
717
-
718
808
  /**
719
809
  * Whether a login-shell value may be pinned into herdr's config at all.
720
810
  *
@@ -1117,963 +1207,164 @@ function planBriefLink(project: ProjectConfig): BriefLinkPlan {
1117
1207
  };
1118
1208
  }
1119
1209
 
1120
- // -------------------------------------------------- worker identity plan (#798) --
1121
-
1122
- /** The fleet agent-config files the worker home binds (when they exist):
1123
- * provided read-only, never copied — the fleet stays the single source, and
1124
- * the worker cannot edit what it only reads. `.env` is where MCP secrets
1125
- * live; `AGENTS.md` is the mandatory host policy every session must read. */
1126
- const WORKER_AGENT_LINK_FILES = ["config.yml", "models.yml", "mcp.json", ".env", "AGENTS.md"] as const;
1127
-
1128
- /** The fleet agent-config directories the worker home binds (when they exist):
1129
- * skills, managed skills, extensions and scripts are the harness's read-only
1130
- * discovery roots. */
1131
- const WORKER_AGENT_LINK_DIRS = ["commands", "prompts", "skills", "managed-skills", "extensions", "scripts"] as const;
1132
-
1133
1210
  /**
1134
- * Read-only facts the identity plan is computed from, all injectable so tests
1135
- * pin the plan hermetically. Production defaults probe the real host: the
1136
- * account's presence in /etc/passwd, the on-disk agent config, and (through
1137
- * `getfacl`) whether the grants are already in place.
1211
+ * Read-only facts about who is serving the fleet's Herdr session.
1212
+ *
1213
+ * Deliberately two independent reads rather than one: herdr answers whether the
1214
+ * *session* is served, systemd answers whether the *unit* is serving anything,
1215
+ * and the incident this exists for is exactly the case where those two
1216
+ * disagree.
1138
1217
  */
1139
- export interface WorkerIdentityProbes {
1140
- /** Whether this host can run the account/ACL machinery at all (Linux). */
1141
- linux?: boolean;
1142
- /** Whether the worker account already exists. */
1143
- accountExists?: boolean;
1144
- /** Whether `setfacl` is installed. */
1145
- setfaclInstalled?: boolean;
1146
- /** The fleet agent-config files to bind into the worker home and grant read
1147
- * access to (existing files only — absent ones are nothing to bind). */
1148
- configFiles?: readonly string[];
1149
- dirExists?(path: string): boolean;
1150
- /** Whether a path is searchable by other accounts (mode `o+x`). */
1151
- searchable?(path: string): boolean;
1152
- /** Whether `path` is a symlink resolving to `target`. */
1153
- linkCurrent?(path: string, target: string): boolean;
1154
- /** Whether the worker account already holds *effective* `perms` on `path`
1155
- * through an ACL — the named entry intersected with the mask, never the
1156
- * named entry alone (#835). */
1157
- aclCurrent?(path: string, perms: "x" | "r"): boolean;
1158
- /** Whether the operator's auth database (`<agentDir>/agent.db`) exists to
1159
- * copy into the worker's runtime. */
1160
- agentDbPresent?: boolean;
1161
- /** Whether the worker's own writable copy of the auth database exists. */
1162
- workerAuthDbCurrent?: boolean;
1163
- /** Whether the operator's `~/.config/gh` credentials exist to copy. */
1164
- ghConfigPresent?: boolean;
1165
- /** Whether the worker's copy of the gh credentials exists. */
1166
- workerGhConfigCurrent?: boolean;
1167
- /** Whether the operator's `~/.gitconfig` exists to copy. */
1168
- gitconfigPresent?: boolean;
1169
- /** Whether the worker's copy of the git identity exists. */
1170
- workerGitconfigCurrent?: boolean;
1171
- /**
1172
- * Whether {@link WORKER_HARNESS_DIR} is currently restricted exactly as the
1173
- * worker needs it (#831): owned by root, its group the worker account's live
1174
- * primary gid, group read+execute, and no `other` permissions. False when it
1175
- * is drifted — wrong owner or group, missing group access, world-readable —
1176
- * including when it is absent (systemd would create a missing mount point at
1177
- * 0755) or unverifiable. Replaces the earlier other-bits-only check, which
1178
- * read `0750 root:root` as current because it had no `other` bits while the
1179
- * worker could not traverse it.
1180
- */
1181
- harnessDirRestricted?: boolean;
1218
+ export interface HerdrOwnershipFacts {
1182
1219
  /**
1183
- * Why the worker harness binding is not usable, or `undefined` when it is
1184
- * live (#828). A function, not a value, so the non-Linux plan never pays for
1185
- * a probe it will not consult; production reads the host through
1186
- * {@link harnessBindingProblem}.
1220
+ * Whether the target session has a live server, from herdr's own session
1221
+ * list. `undefined` when that could not be read never `false`, because
1222
+ * "herdr did not answer" and "no server" lead to opposite actions.
1187
1223
  */
1188
- harnessProblem?: () => string | undefined;
1189
- }
1190
-
1191
- /** The worker account's presence in /etc/passwd, the resolution's source of
1192
- * truth; unreadable passwd means "not present" for planning purposes. */
1193
- function passwdHasAccount(): boolean {
1194
- try {
1195
- return readFileSync("/etc/passwd", "utf8")
1196
- .split("\n")
1197
- .some((entry) => entry.startsWith(`${WORKER_ACCOUNT}:`));
1198
- } catch {
1199
- return false;
1200
- }
1201
- }
1202
-
1203
- /**
1204
- * Whether a `getfacl -p -c` dump proves that `account`'s **effective**
1205
- * permissions on one path include `perms`. The named entry alone is not
1206
- * enough: a later `chmod` of the path rewrites the ACL mask, and an entry the
1207
- * mask strips reads `user:omp-worker:--x #effective:---` while its granted
1208
- * bits still carry the letter — the post-restart shape #835 fell into. The
1209
- * effective permissions are the named entry intersected with the mask — the
1210
- * same result getfacl renders as the `#effective:` annotation, computed here
1211
- * so a host whose getfacl omits the annotation cannot slip a
1212
- * named-but-ineffective ACL past the verdict, and an entry for any other
1213
- * account never counts as this account's grant.
1214
- */
1215
- export function aclEffectivePermits(dump: string, account: string, perms: "x" | "r"): boolean {
1216
- let entryPerms: string | undefined;
1217
- let maskPerms: string | undefined;
1218
- for (const raw of dump.split("\n")) {
1219
- const line = raw.trim();
1220
- if (line.startsWith("#")) continue;
1221
- // `user:omp-worker:r-x`; the trailing `#effective:...` annotation (when
1222
- // present) is after the perms and irrelevant to the match.
1223
- if (line.startsWith(`user:${account}:`)) {
1224
- entryPerms = /^([r-][w-][x-])/.exec(line.slice(`user:${account}:`.length))?.[1];
1225
- } else if (line.startsWith("mask:")) {
1226
- // A mask line renders as `mask::r-x` (the name field is empty).
1227
- maskPerms = /^([r-][w-][x-])/.exec(line.slice("mask:".length).replace(/^:+/u, ""))?.[1];
1228
- }
1229
- }
1230
- if (entryPerms === undefined) return false;
1231
- const effective =
1232
- maskPerms === undefined
1233
- ? entryPerms
1234
- : [0, 1, 2]
1235
- .map((i) => (entryPerms[i] === "-" || maskPerms[i] === "-" ? "-" : maskPerms[i]) as string)
1236
- .join("");
1237
- return perms === "r" ? effective[0] === "r" : effective[2] === "x";
1238
- }
1239
-
1240
- /** Whether one path already grants the worker's *effective* ACL permissions
1241
- * for `perms`, read through `getfacl -p -c` and judged by the mask
1242
- * intersection ({@link aclEffectivePermits}). A host without getfacl, an
1243
- * unreadable path, or a path whose ACL entry is masked off all report "not
1244
- * current" — the grant steps then run, the safe direction for an unverifiable
1245
- * grant. Exported so `doctor` reads the live host through the same probe the
1246
- * identity plan plans with (#835). */
1247
- export function workerAclProbe(path: string, perms: "x" | "r"): boolean {
1248
- const ran = spawnSync("getfacl", ["-p", "-c", path], { encoding: "utf8" });
1249
- if (ran.status !== 0 || ran.stdout === null) return false;
1250
- return aclEffectivePermits(ran.stdout, WORKER_ACCOUNT, perms);
1251
- }
1252
-
1253
- /** The linked worker config paths' effective-ACL verdict for the dedicated
1254
- * worker account: how many paths were checkable, and which of them do not
1255
- * currently grant the worker's needed effective access. */
1256
- export interface WorkerAclHealth {
1257
- /** The agent config dir plus every existing linked config file. */
1258
- checkable: number;
1259
- /** The checkable paths whose grant is not effective — the named entry is
1260
- * absent, the path is unreadable, or the ACL mask strips the entry's
1261
- * effective permissions (#835). */
1262
- missing: readonly string[];
1263
- }
1264
-
1265
- /**
1266
- * The linked worker config paths (search `x` on the agent config dir and its
1267
- * existing ancestors that other accounts cannot already traverse, read `r` on
1268
- * every existing linked config file) with each path's effective-ACL verdict
1269
- * for the worker account. Pure of any privilege: the paths are read-only and
1270
- * every verdict is {@link workerAclProbe}'s own read, so `doctor` and the
1271
- * identity plan cannot disagree about an ACL (#835).
1272
- */
1273
- export function workerAclHealth(agentDir: string): WorkerAclHealth {
1274
- const targets: [string, "x" | "r"][] = [];
1275
- // The same ancestor rule the identity plan's grantPaths applies to this
1276
- // root: only the existing segments other accounts cannot already traverse
1277
- // (mode `o+x`) need an ACL at all. The harness chmods the leaf back to 0700
1278
- // on every open, so the leaf is virtually always in this set.
1279
- const parts = resolve(agentDir)
1280
- .split("/")
1281
- .filter((part) => part !== "");
1282
- let cur = "/";
1283
- for (const part of parts) {
1284
- cur = cur === "/" ? `/${part}` : `${cur}/${part}`;
1285
- let st: Stats | undefined;
1286
- try {
1287
- st = statSync(cur);
1288
- } catch {
1289
- break;
1290
- }
1291
- if (!st.isDirectory()) break;
1292
- if ((st.mode & 0o001) === 0) targets.push([cur, "x"]);
1293
- }
1294
- for (const name of WORKER_AGENT_LINK_FILES) {
1295
- const file = join(agentDir, name);
1296
- if (existsSync(file)) targets.push([file, "r"]);
1297
- }
1298
- const missing = targets
1299
- .filter(([path, perms]) => !workerAclProbe(path, perms))
1300
- .map(([path]) => path);
1301
- return { checkable: targets.length, missing };
1224
+ sessionRunning: boolean | undefined;
1225
+ /** The canonical unit as systemd reports it, or `undefined` when unreadable. */
1226
+ unit:
1227
+ | {
1228
+ activeState: string;
1229
+ subState: string;
1230
+ mainPid: number;
1231
+ /** How many times systemd has restarted it — a loop's own tell. */
1232
+ nRestarts: number;
1233
+ }
1234
+ | undefined;
1302
1235
  }
1303
1236
 
1304
- // --------------------------------------------------- pane settle gate (#835) --
1237
+ export type HerdrOwnership =
1238
+ /** The canonical unit is up and is the only thing that can be serving it. */
1239
+ | { kind: "unit"; detail: string }
1240
+ /** A live session the canonical unit is not serving: the #893 collision. */
1241
+ | { kind: "unmanaged"; detail: string }
1242
+ /** No live session at all — a fresh host, or one whose server is down. */
1243
+ | { kind: "absent"; detail: string }
1244
+ /** A read failed. Never treated as either of the decided answers. */
1245
+ | { kind: "unknown"; detail: string };
1305
1246
 
1306
1247
  /**
1307
- * How many 1-second probes the {@link paneSettleScript} gate may take before
1308
- * declaring the restored pane's startup chmod unsettled. A herdr pane boots in
1309
- * well under this on a healthy host; every extra probe delays setup by one
1310
- * second, so the bound is deliberately short.
1311
- */
1312
- export const AGENT_SETTLE_PROBE_STEPS = 12;
1313
-
1314
- /**
1315
- * How long a settled mask must stay settled before the gate lets the grants
1316
- * through: two consecutive reads a probe-interval apart, so a boot churn of
1317
- * two panes still reads unsettled.
1318
- */
1319
- export const AGENT_SETTLE_PROBE_INTERVAL_S = 1;
1320
-
1321
- /**
1322
- * The bounded readiness barrier the final ACL grants wait on. `systemctl
1323
- * restart herdr-fleet.service` returns when herdr's own (Type=simple) server
1324
- * process is up — the restored OMP panes boot *after* that, and each pane's
1325
- * AgentStorage ctor chmods the fleet agent config dir back to 0700 on open. A
1326
- * chmod rewrites the ACL mask, so a grant that lands before that chmod is
1327
- * `user:omp-worker:--x #effective:---` again within seconds (#835).
1248
+ * Who owns the fleet session (#893).
1328
1249
  *
1329
- * The gate reads two things: the agent dir's ACL mask, and the live herdr
1330
- * agent list. A mask is *settled* when it is zeroed (`---`, what a startup
1331
- * chmod leaves) or absent altogether (an ACL-less dir the shape of every
1332
- * first-ever `setup host`, whose grants have not run yet because they run
1333
- * *after* this gate). Neither reading, on its own, says anything about *this*
1334
- * restart: the #835 host starts with a zeroed mask, because a previous grant
1335
- * plus a previous pane's chmod already left one, and an absent mask cannot
1336
- * render a chmod at all. So on a herdr host a settled mask releases the grants
1337
- * only once this restart has positively observed a live agent: herdr lists a
1338
- * pane as an agent when that pane's OMP process registers, which is after the
1339
- * `AgentStorage` open that chmods, so a live row is the one witness available
1340
- * that the chmod is behind us. With the witness in hand the gate wants the
1341
- * mask settled on two consecutive probes (nothing is churning) and exits 0.
1250
+ * `setup host` can complete its whole transaction successfully while
1251
+ * `herdr-fleet.service` sits in `activating (auto-restart)`: an unmanaged
1252
+ * `herdr --session <name> server` already holds the session, so every start of
1253
+ * the unit exits 1, the panes stay under the unmanaged process, and dispatch
1254
+ * stays paused after a green setup. Measured 2026-08-22T09:01:31Z: exit 0,
1255
+ * `NRestarts=2`, `ExecStart` status 1, the real fleet under
1256
+ * `herdr session attach` ancestry. A `systemctl restart` returning 0 is
1257
+ * therefore not evidence that supervision transferred `Type=simple` returns
1258
+ * before the child discovers the collision and dies.
1342
1259
  *
1343
- * Both halves of that rule were learned the hard way. Demanding a literal
1344
- * `mask::---` line deadlocked first-run setup outright, since that line cannot
1345
- * appear before the grants that create it (#835 review 3); releasing on a
1346
- * settled mask without the witness let a pane that registered late chmod after
1347
- * the grants had already verified — #835 again, first for the absent mask
1348
- * (review 4) and then for the stale zeroed one (review 5).
1260
+ * The reasoning behind the verdicts, since only one of them is an observation:
1349
1261
  *
1350
- * What the gate still cannot promise, the grant says for itself: each grant
1351
- * step verifies its own effective result and fails the transaction when a
1352
- * chmod lands inside its window, and a chmod later than that makes the
1353
- * identity pending again`doctor`'s `worker-acl` finding names it and an
1354
- * idempotent `setup host` re-run re-grants behind the full gate. A `getfacl`
1355
- * that could not read the path is not a settled mask it prints no mask
1356
- * because it printed nothing so it stays unsettled. A host whose herdr
1357
- * restores panes as plain shells — the `resume_agents_on_restore = false`
1358
- * headless shape, which the pane-shell merge writes and `doctor` blesses —
1359
- * restores *no* agent process, so no witness will ever come and nothing will
1360
- * ever chmod: the gate reads the agent list through its CLI envelope,
1361
- * `{"id":"cli:agent:list","result":{"agents":[…]}}`, the shape
1362
- * `parseHerdrAgents` reads, and treats a window in which `result.agents` was
1363
- * explicitly empty on *every* probe as a legitimate pass — the full window,
1364
- * never an early release. A list it could not read is not that window: a
1365
- * nonzero `herdr` exit or an unrecognized schema proves nothing about whether
1366
- * a pane is about to chmod, so it fails the gate on its own (#835 review 2)
1367
- * rather than buying the agentless pass. A host with no herdr at all restores
1368
- * no pane and needs no witness, so there a settled mask releases on its own
1369
- * two probes. If an agent is live but the mask never settles within the probe
1370
- * bound — a still-booting pane, or a mask that keeps changing — the gate exits
1371
- * 1 and the transaction stops: no grant is bet against a still-booting pane
1372
- * (fail closed, per #835 review 1).
1262
+ * - A live session with the unit **not** active is decided: something outside
1263
+ * the unit is serving it. Nothing else can be true.
1264
+ * - A live session with the unit active *is* the unit's, because two servers
1265
+ * cannot hold one sessionthat mutual exclusion is precisely what makes
1266
+ * the collision fatal, so it is also what makes this inference sound.
1267
+ * - Either read failing is `unknown`, which callers must treat as a refusal
1268
+ * rather than as "probably fine": the destructive remedy here is stopping
1269
+ * somebody's live session.
1373
1270
  */
1374
- export function paneSettleScript(
1375
- agentDir: string,
1376
- herdr: string | undefined,
1377
- session: string,
1378
- steps: number = AGENT_SETTLE_PROBE_STEPS,
1379
- intervalS: number = AGENT_SETTLE_PROBE_INTERVAL_S,
1380
- ): string {
1381
- const sq = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
1382
- const agentProbe = herdr === undefined ? "" : `${sq(herdr)} --session ${sq(session)} agent list`;
1383
- return [
1384
- `# The herdr restart returned once herdr's own process was up; the restored`,
1385
- `# OMP panes boot after it and each chmods the agent config dir to 0700 in`,
1386
- `# AgentStorage -- a chmod that rewrites the ACL mask. The grants that follow`,
1387
- `# this gate may only land once that startup chmod is behind us (#835).`,
1388
- `# A mask is settled when it is zeroed ('---', what a chmod leaves) or`,
1389
- `# absent (a dir with no extended ACL: nothing has been granted yet, so`,
1390
- `# there is no named entry a later chmod could strip -- #835 review 3).`,
1391
- `# Neither reading identifies *this* restart on its own: this host starts`,
1392
- `# with a zeroed mask left by an earlier grant and an earlier pane, and an`,
1393
- `# absent mask cannot render a chmod at all. So wherever a pane can exist,`,
1394
- `# a settled mask releases the grants only with a live agent to witness the`,
1395
- `# chmod -- see the loop below (#835 reviews 4 and 5). The command's status`,
1396
- `# is kept outside the pipe so a getfacl that cannot read the path stays`,
1397
- `# unsettled rather than passing as a mask-less dir.`,
1398
- `probe() { # prints the mask's perms; empty output = the ACL carries no mask`,
1399
- ` if ! acl=$(getfacl -p -c ${sq(agentDir)} 2>/dev/null); then return 1; fi`,
1400
- ` printf '%s\\n' "$acl" | sed -nE 's/^mask::([r-][w-][x-])$/\\1/p'`,
1401
- `}`,
1402
- ...(herdr === undefined
1403
- ? []
1404
- : [
1405
- `# 'herdr agent list' answers with its CLI envelope --`,
1406
- `# {"id":"cli:agent:list","result":{"agents":[...]}} -- and never a bare`,
1407
- `# array, so an explicitly empty list still prints a non-empty object and`,
1408
- `# a substring test for '[]' would read every host as agent-bearing. The`,
1409
- `# project's own parser (parseHerdrAgents) reads that envelope, but it is`,
1410
- `# TypeScript and this gate is a privileged shell step, so its contract is`,
1411
- `# reproduced here: result.agents non-empty is a live agent, an explicitly`,
1412
- `# empty result.agents is an agentless host, and everything else -- a`,
1413
- `# nonzero exit, no output, an unrecognized schema -- is unreadable. The`,
1414
- `# command's status is captured outside a pipe so a failure stays a`,
1415
- `# failure: an unreadable list is never an authoritative empty success,`,
1416
- `# because it cannot prove no pane will chmod the mask (#835 review 2).`,
1417
- `agents() { # 0 = an agent is live, 1 = an explicitly empty list, 2 = unreadable`,
1418
- ` if ! out=$(${agentProbe} 2>/dev/null); then return 2; fi`,
1419
- ` out=$(printf '%s' "$out" | tr -d ' \\t\\r\\n')`,
1420
- ` case "$out" in`,
1421
- ` *'"agents":[]'*) return 1 ;;`,
1422
- ` *'"agents":['*) return 0 ;;`,
1423
- ` *) return 2 ;;`,
1424
- ` esac`,
1425
- `}`,
1426
- ]),
1427
- `zero=0`,
1428
- ...(herdr === undefined ? [] : [`agents_seen=0`, `list_unreadable=0`]),
1429
- `for _ in $(seq 1 ${String(steps)}); do`,
1430
- ...(herdr === undefined
1431
- ? []
1432
- : [
1433
- ` # The agent list is read before the mask is judged: a settled mask is`,
1434
- ` # evidence only once a live agent has been observed, so the read that`,
1435
- ` # can establish that must come first (#835 reviews 4 and 5).`,
1436
- ` agents; rc=$?`,
1437
- ` if [ "$rc" -eq 0 ]; then agents_seen=1; elif [ "$rc" -eq 2 ]; then list_unreadable=1; fi`,
1438
- ]),
1439
- ` if mask=$(probe); then`,
1440
- ` case "$mask" in`,
1441
- // A zeroed mask and an absent one are the same verdict — settled — and on
1442
- // a herdr host they carry the same burden of proof.
1443
- ` ''|'---')`,
1444
- ...(herdr === undefined
1445
- ? [
1446
- // No herdr is no restored pane and no process that could ever chmod,
1447
- // so there is no witness to want: the mask speaks for itself.
1448
- ` zero=$((zero + 1))`,
1449
- ` if [ "$zero" -ge 2 ]; then exit 0; fi`,
1450
- ]
1451
- : [
1452
- ` # A settled mask needs a witness that the startup chmod is behind`,
1453
- ` # us, because neither settled shape can show one: this host begins`,
1454
- ` # with a mask an earlier grant and an earlier pane already zeroed,`,
1455
- ` # and an ACL-less dir has no mask to zero. A live agent is that`,
1456
- ` # witness -- herdr lists a pane as an agent once that pane's OMP`,
1457
- ` # process has registered, which is after the AgentStorage open that`,
1458
- ` # chmods. A list that has only ever been empty is indistinguishable`,
1459
- ` # from a pane that has not started yet, so it releases nothing`,
1460
- ` # (#835 reviews 4 and 5); a window that stays empty for its whole`,
1461
- ` # length is the headless pass below instead.`,
1462
- ` if [ "$agents_seen" = 1 ]; then`,
1463
- ` zero=$((zero + 1))`,
1464
- ` if [ "$zero" -ge 2 ]; then exit 0; fi`,
1465
- ` else`,
1466
- ` zero=0`,
1467
- ` fi`,
1468
- ]),
1469
- ` ;;`,
1470
- ` *) zero=0 ;;`,
1471
- ` esac`,
1472
- ` else`,
1473
- ` zero=0`,
1474
- ` fi`,
1475
- ` sleep ${String(intervalS)}`,
1476
- `done`,
1477
- ...(herdr === undefined
1478
- ? []
1479
- : [
1480
- // No agent existed at any probe, every probe read the list, and the
1481
- // mask never settled: the restart restored nothing that could chmod,
1482
- // so the grants are safe to land — the ever-clean shape is a pass,
1483
- // not a failure.
1484
- `if [ "$agents_seen" = 0 ] && [ "$list_unreadable" = 0 ]; then exit 0; fi`,
1485
- // A list we could not read is its own failure, distinct from an empty
1486
- // one: it proves nothing about whether a pane is about to chmod, so
1487
- // it may not buy the agentless pass.
1488
- `if [ "$agents_seen" = 0 ]; then`,
1489
- ` printf '%s\n' "could not read the live herdr agent list (${agentProbe} exited nonzero or answered without a result.agents array) -- refusing to treat an unreadable agent list as an agentless host and grant an ACL a restored pane may zero" >&2`,
1490
- ` exit 1`,
1491
- `fi`,
1492
- ]),
1493
- `printf '%s\n' "the restored fleet agent pane never settled: ${sq(agentDir)} did not hold a settled ACL mask${herdr === undefined ? "" : " with a live herdr agent to witness the pane's startup chmod"} within ${String(steps * intervalS)}s -- refusing to grant an ACL a still-booting pane may zero again" >&2`,
1494
- `exit 1`,
1495
- ].join("\n");
1496
- }
1497
-
1498
- /**
1499
- * The worker account's primary gid on this host, resolved live from
1500
- * `/etc/passwd` — the account line's fourth field (`name:x:uid:gid:…`) — or
1501
- * `undefined` when it cannot be determined: an absent account, a malformed or
1502
- * unreadable passwd. `undefined` is not a gid: the harness directory can never
1503
- * be "current" while we cannot say which group it must accept.
1504
- */
1505
- export function resolveWorkerGroupGid(): number | undefined {
1506
- let line: string | undefined;
1507
- try {
1508
- line = readFileSync("/etc/passwd", "utf8")
1509
- .split("\n")
1510
- .find((entry) => entry.startsWith(`${WORKER_ACCOUNT}:`));
1511
- } catch {
1512
- return undefined;
1271
+ export function herdrOwnership(facts: HerdrOwnershipFacts): HerdrOwnership {
1272
+ if (facts.sessionRunning === undefined) {
1273
+ return {
1274
+ kind: "unknown",
1275
+ detail: "herdr could not say whether the fleet session has a live server",
1276
+ };
1513
1277
  }
1514
- if (line === undefined) return undefined;
1515
- const gid = Number.parseInt(line.split(":")[3] ?? "", 10);
1516
- return Number.isInteger(gid) && gid >= 0 ? gid : undefined;
1517
- }
1518
-
1519
- /** The live ownership/access facts the {@link WORKER_HARNESS_DIR} verdict
1520
- * compares and only those, so the decision stays pure of stat and
1521
- * privilege and every drift shape can be pinned hermetically. */
1522
- export interface HarnessDirFacts {
1523
- uid: number;
1524
- gid: number;
1525
- mode: number;
1526
- }
1527
-
1528
- /**
1529
- * Read {@link WORKER_HARNESS_DIR}'s facts for the currentness verdict, or
1530
- * `undefined` when the path is missing, unreadable, or not a directory —
1531
- * each of which must read pending (#831).
1532
- */
1533
- export function harnessDirFacts(dir: string = WORKER_HARNESS_DIR): HarnessDirFacts | undefined {
1534
- let st: Stats;
1535
- try {
1536
- st = statSync(dir);
1537
- } catch {
1538
- return undefined;
1278
+ if (facts.unit === undefined) {
1279
+ return {
1280
+ kind: "unknown",
1281
+ detail: "systemctl could not say what state the fleet session unit is in",
1282
+ };
1283
+ }
1284
+ const { activeState, subState, mainPid, nRestarts } = facts.unit;
1285
+ const unitServing = activeState === "active" && subState === "running" && mainPid > 0;
1286
+ if (!facts.sessionRunning) {
1287
+ return {
1288
+ kind: "absent",
1289
+ detail: unitServing
1290
+ ? `the unit is ${activeState} (${subState}) but no session server answers — it is coming up`
1291
+ : `no live fleet session server (unit ${activeState}/${subState})`,
1292
+ };
1293
+ }
1294
+ if (unitServing) {
1295
+ return { kind: "unit", detail: `served by ${DEFAULT_HERDR_UNIT} (pid ${mainPid})` };
1539
1296
  }
1540
- if (!st.isDirectory()) return undefined;
1541
- return { uid: st.uid, gid: st.gid, mode: st.mode };
1542
- }
1543
-
1544
- /**
1545
- * Whether the harness directory is restricted exactly as the worker needs it
1546
- * (#831): owned by root (uid 0), its group the worker account's live primary
1547
- * gid, group read+execute, and no `other` bits — the `0750 root:<worker gid>`
1548
- * shape the install-d step converges to. `facts` `undefined` (a missing,
1549
- * unreadable, or non-directory path) or a `workerGid` `undefined` (an absent
1550
- * account or unreadable passwd) both read `false`: an unverifiable directory
1551
- * is pending, never "current". The old check read only the `other` bits and
1552
- * called `0750 root:root` current; this compares every dimension the worker's
1553
- * traversal depends on.
1554
- */
1555
- export function harnessDirRestricted(
1556
- facts: HarnessDirFacts | undefined,
1557
- workerGid: number | undefined,
1558
- ): boolean {
1559
- if (facts === undefined || workerGid === undefined) return false;
1560
- if (facts.uid !== 0 || facts.gid !== workerGid) return false;
1561
- if ((facts.mode & 0o050) !== 0o050) return false;
1562
- if ((facts.mode & 0o007) !== 0) return false;
1563
- return true;
1564
- }
1565
-
1566
- /**
1567
- * The production probes: the real host, read-only. Built from the fleet
1568
- * runtime so the agent-config source is the fleet account's own agent dir.
1569
- */
1570
- export function defaultIdentityProbes(runtime: { home: string; bun?: string }): WorkerIdentityProbes {
1571
- const agentDir = join(runtime.home, ".omp", "agent");
1572
- const workerAgentDir = join(WORKER_HOME_DIR, ".omp", "agent");
1573
- const dirExists = (path: string): boolean => {
1574
- try {
1575
- return statSync(path).isDirectory();
1576
- } catch {
1577
- return false;
1578
- }
1579
- };
1580
1297
  return {
1581
- linux: process.platform === "linux",
1582
- accountExists: passwdHasAccount(),
1583
- setfaclInstalled: existsSync("/usr/bin/setfacl"),
1584
- configFiles: WORKER_AGENT_LINK_FILES.filter((name) => existsSync(join(agentDir, name))).map(
1585
- (name) => join(agentDir, name),
1586
- ),
1587
- dirExists,
1588
- searchable: (path) => {
1589
- try {
1590
- return (statSync(path).mode & 0o001) !== 0;
1591
- } catch {
1592
- return false;
1593
- }
1594
- },
1595
- linkCurrent: (path, target) => {
1596
- try {
1597
- return lstatSync(path).isSymbolicLink() && readlinkSync(path) === target;
1598
- } catch {
1599
- return false;
1600
- }
1601
- },
1602
- aclCurrent: workerAclProbe,
1603
- // The provisioned runtime (#798 review 2): the worker owns a copy of the
1604
- // auth database (the harness writes perf/settings into it — a link would
1605
- // let the worker mutate the operator's), scoped copies of the gh
1606
- // credentials and git identity, and read-only binds for everything else.
1607
- agentDbPresent: existsSync(join(agentDir, "agent.db")),
1608
- workerAuthDbCurrent: existsSync(join(workerAgentDir, "agent.db")),
1609
- ghConfigPresent: existsSync(join(runtime.home, ".config", "gh", "hosts.yml")),
1610
- workerGhConfigCurrent: existsSync(join(WORKER_HOME_DIR, ".config", "gh", "hosts.yml")),
1611
- gitconfigPresent: existsSync(join(runtime.home, ".gitconfig")),
1612
- workerGitconfigCurrent: existsSync(join(WORKER_HOME_DIR, ".gitconfig")),
1613
- // Setup currentness is the binding's inode identity (#828). The old
1614
- // second half — executing the loader through the worker HOME/setpriv
1615
- // transition — retired with the runtime identity (#894): sessions launch
1616
- // under the fleet account, so there is no transition left to probe.
1617
- harnessProblem: () => harnessBindingProblem(),
1618
- // The bound tree's reach (#828 review), hardened to the directory's whole
1619
- // shape (#831): the verdict is a live ownership/access check read fresh on
1620
- // every planning run — root-owned, group the worker's live primary gid,
1621
- // group read+execute, and no `other` bits. A re-run of `setup host` after
1622
- // an operator chown/chmod'd the mount point re-checks rather than trusting
1623
- // a stale verdict, and absent or unverifiable reads pending (the safe
1624
- // direction): systemd would create a missing mount point at 0755, so "not
1625
- // there yet" and "world-readable" have always shared one step.
1626
- harnessDirRestricted: harnessDirRestricted(harnessDirFacts(), resolveWorkerGroupGid()),
1298
+ kind: "unmanaged",
1299
+ detail:
1300
+ `the fleet session has a live server, but ${DEFAULT_HERDR_UNIT} is ${activeState} (${subState})` +
1301
+ `${nRestarts > 0 ? `, restarted ${nRestarts} time(s)` : ""} with no main pid — ` +
1302
+ "the session is held outside systemd, so every start of the unit exits 1",
1627
1303
  };
1628
1304
  }
1629
1305
 
1630
- export interface WorkerIdentityPlanOptions {
1631
- /** The daemon's state root (worktrees, mirrors, sessions live under it). */
1632
- stateRoot: string;
1633
- /** The fleet account's agent config dir. */
1634
- agentDir: string;
1635
- /** The directory holding the bun binary the worker sessions exec. */
1636
- bunRoot: string;
1637
- /** The node_modules root of the installed conductor package. */
1638
- packageRoot: string;
1639
- /** The fleet account's home: the source of the gh credentials and git
1640
- * identity the worker runtime is provisioned from. */
1641
- home: string;
1642
- probes?: WorkerIdentityProbes;
1643
- }
1644
-
1645
- /**
1646
- * The identity plan's outcome: the steps that establish it, and why any are
1647
- * still pending. {@link steps} and {@link grantSteps} together are the single
1648
- * source the install renders and executes, exactly like the host runtime's
1649
- * own steps.
1650
- */
1651
- export interface WorkerIdentityPlan {
1652
- account: string;
1653
- home: string;
1654
- /** The worker's harness agent dir (where the fleet config is bound). */
1655
- agentDir: string;
1656
- /** Dirs granted search access (`o+x`-style ACL), for the plan display. */
1657
- grantPaths: readonly string[];
1658
- /** Fleet agent config files granted read access, for the plan display. */
1659
- configFiles: readonly string[];
1660
- /** The steps that establish the identity before the daemon restart can
1661
- * resolve it: account creation, the agent-config bind, and the runtime
1662
- * provision. Prepended to the host plan's own steps. */
1663
- steps: readonly PrivilegedStep[];
1664
- /**
1665
- * The ACL grant steps, deliberately **not** in {@link steps}: they must
1666
- * execute *after* the transaction's final daemon and herdr restarts, behind
1667
- * the bounded pane settle gate {@link planHostRuntime} places ahead of them.
1668
- * An OMP startup chmods its agent config dir back to 0700 on every open —
1669
- * the harness's own `AgentStorage` boot — and a chmod rewrites the ACL
1670
- * mask, so a grant applied before the restarts is `user:omp-worker:--x
1671
- * #effective:---` by the time a worker reads it, and a grant applied before
1672
- * a restored pane has finished booting can still be zeroed moments later
1673
- * (#835). Each grant step also verifies its own effective result through
1674
- * getfacl and exits nonzero — stopping the transaction — when the ACL mask
1675
- * strips the worker's entry.
1676
- */
1677
- grantSteps: readonly PrivilegedStep[];
1678
- /** One item per pending change, in the order the steps would make them. */
1679
- pending: readonly string[];
1680
- /** True when every step is already satisfied — nothing to do. */
1681
- current: boolean;
1682
- }
1683
-
1684
1306
  /**
1685
- * The plan that establishes the dedicated worker identity on this host
1686
- * (#798), in the same shape as the rest of the host plan: privileged steps
1687
- * with human titles, a read-only "current" verdict, and the reasons anything
1688
- * is pending. The steps are idempotent by construction — the account step is
1689
- * guarded by `getent`, the bind uses `ln -sfn`, and `setfacl -m` converges —
1690
- * so a re-run of `setup host` converges rather than failing on a half-applied
1691
- * prior run.
1307
+ * What this version retires from the host, read from disk (#895).
1692
1308
  *
1693
- * The grants are deliberate and narrow: search access (`x`, never `r` or `w`)
1694
- * down the existing ancestor chains of the state root, the bun install and
1695
- * the package root (their own modes are already world-searchable), plus read
1696
- * access on the fleet's 0600 agent config files. The worker's writable world
1697
- * is what dispatch chowns to it per run — worktree and session dir — and
1698
- * nothing else. It cannot write daemon state (no grant writes), and it cannot
1699
- * migrate itself into a daemon-owned cgroup (every cgroup.procs is
1700
- * root-owned).
1701
- */
1702
- /**
1703
- * A shell guard the privileged worker-identity steps run first (#816): the
1704
- * worker owns everything under its home between setups, so a worker-planted
1705
- * symlink on any component of a directory root is about to create or hand
1706
- * back would redirect that mkdir / cp / chown to an arbitrary target — `chown
1707
- * omp-worker /etc`, or a copy written into it. Every component of each named
1708
- * target is checked, and the first symlink fails the whole step, naming the
1709
- * component, before anything follows it.
1309
+ * One entry today the worker harness bind mount 0.18.1 installed and the
1310
+ * shape is deliberately a list, because "the release that removes a unit" is a
1311
+ * recurring event and the alternative is a bespoke check each time.
1710
1312
  *
1711
- * Executed verbatim in the step scripts, so the definition lives here, in the
1712
- * shape the tests read.
1313
+ * Ordering is the whole content of the step list:
1314
+ *
1315
+ * 1. `disable --now` first, which stops (for a `.mount`: unmounts) and drops
1316
+ * the enablement symlinks in one call. Doing it before the file is gone
1317
+ * matters — systemd cannot disable a unit whose file it can no longer
1318
+ * read, so removing first would strand the enablement symlink and leave
1319
+ * the mount live until reboot.
1320
+ * 2. Then remove the installed file, then one `daemon-reload` so systemd
1321
+ * forgets the unit rather than keeping it loaded-but-absent.
1322
+ * 3. Then `rmdir` the empty mount point, best-effort: it refuses a non-empty
1323
+ * directory by construction, so it can never destroy anything, and a host
1324
+ * where the unmount left files behind keeps them for an operator to see.
1325
+ *
1326
+ * Nothing here touches the `omp-worker` account or its home. An unused account
1327
+ * is inert, and deleting a Unix account (with whatever a previous release put
1328
+ * in its home) to tidy up is a destructive act this retirement deliberately
1329
+ * refuses — the same judgement #895 records.
1713
1330
  */
1714
- const REFUSE_SYMLINKED_TARGETS_SH = [
1715
- "refuse_symlink() {",
1716
- ' [ "$#" -eq 1 ] || return 1',
1717
- " target=$1",
1718
- " rest=${target#/}",
1719
- " cur=",
1720
- ' while [ -n "$rest" ]; do',
1721
- ' case "$rest" in',
1722
- " */*) next=${rest%%/*}; rest=${rest#*/} ;;",
1723
- " *) next=$rest; rest= ;;",
1724
- " esac",
1725
- " cur=$cur/$next",
1726
- ' if [ -L "$cur" ]; then printf "refusing: %s is a symlink\\n" "$cur" >&2; exit 1; fi',
1727
- " done",
1728
- "}",
1729
- ].join("\n");
1730
-
1731
- export function planWorkerIdentity(options: WorkerIdentityPlanOptions): WorkerIdentityPlan {
1732
- const probes = options.probes ?? {};
1733
- const linux = probes.linux ?? process.platform === "linux";
1734
- if (!linux) {
1735
- // The account/ACL machinery is util-linux: a non-Linux host cannot
1736
- // establish the identity, and dispatch there fails closed per launch. The
1737
- // plan says so rather than pretending nothing is missing.
1738
- return {
1739
- account: WORKER_ACCOUNT,
1740
- home: WORKER_HOME_DIR,
1741
- agentDir: options.agentDir,
1742
- grantPaths: [],
1743
- configFiles: [],
1744
- steps: [],
1745
- grantSteps: [],
1746
- pending: ["worker identity requires Linux (useradd/setfacl/setpriv); worker sessions fail closed here"],
1747
- current: false,
1748
- };
1749
- }
1750
- const dirExists = probes.dirExists ?? ((path: string) => existsSync(path));
1751
- const searchable =
1752
- probes.searchable ??
1753
- ((path: string) => {
1754
- try {
1755
- return (statSync(path).mode & 0o001) !== 0;
1756
- } catch {
1757
- return false;
1758
- }
1759
- });
1760
- const linkCurrent =
1761
- probes.linkCurrent ??
1762
- ((path: string, target: string) => {
1763
- try {
1764
- return lstatSync(path).isSymbolicLink() && readlinkSync(path) === target;
1765
- } catch {
1766
- return false;
1767
- }
1768
- });
1769
- const aclCurrent = probes.aclCurrent ?? workerAclProbe;
1770
-
1771
- const account = WORKER_ACCOUNT;
1772
- const home = WORKER_HOME_DIR;
1773
- const workerAgentDir = join(home, ".omp", "agent");
1774
- const configFiles = [...(probes.configFiles ?? [])];
1775
-
1776
- // The dirs that must be searchable by the worker: the existing ancestor
1777
- // chain of each root down to the first component that is not there yet (the
1778
- // daemon creates its own dirs searchable). This is what turns "cannot even
1779
- // reach /root" into "can reach exactly the granted trees".
1780
- const grantRoots = [options.stateRoot, options.agentDir, options.bunRoot, options.packageRoot];
1781
- const grantPaths: string[] = [];
1782
- for (const root of grantRoots) {
1783
- const parts = resolve(root)
1784
- .split("/")
1785
- .filter((part) => part !== "");
1786
- let cur = "/";
1787
- for (const part of parts) {
1788
- cur = cur === "/" ? `/${part}` : `${cur}/${part}`;
1789
- if (!dirExists(cur)) break;
1790
- if (!searchable(cur) && !grantPaths.includes(cur)) grantPaths.push(cur);
1791
- }
1792
- }
1793
-
1794
- const pending: string[] = [];
1795
- const accountExists = probes.accountExists ?? passwdHasAccount();
1796
- const setfaclInstalled = probes.setfaclInstalled ?? existsSync("/usr/bin/setfacl");
1797
-
1798
- if (!accountExists) {
1799
- pending.push(`the ${account} account does not exist yet (created as a system account with no login)`);
1800
- }
1801
- if (accountExists && !dirExists(workerAgentDir)) {
1802
- pending.push(`the worker agent dir ${workerAgentDir} is not bound to the fleet agent config yet`);
1803
- }
1804
- for (const name of WORKER_AGENT_LINK_FILES) {
1805
- if (!configFiles.some((f) => f === join(options.agentDir, name))) continue;
1806
- const link = join(workerAgentDir, name);
1807
- if (!linkCurrent(link, join(options.agentDir, name))) {
1808
- pending.push(`${link} does not resolve to the fleet's ${name}`);
1809
- }
1810
- }
1811
- // The provisioned worker runtime (#798 review 2): the worker owns a writable
1812
- // copy of the auth database (the harness writes into it — a read-only view
1813
- // would fail, a link would mutate the operator's), scoped copies of the gh
1814
- // credentials (HOME-scoped, 0600) and the git identity. A missing worker
1815
- // copy is pending whether or not the source exists: the message says which.
1816
- const workerGhHosts = join(home, ".config", "gh", "hosts.yml");
1817
- const workerGitconfig = join(home, ".gitconfig");
1818
- const workerAuthDb = join(workerAgentDir, "agent.db");
1819
- const authSource = join(options.agentDir, "agent.db");
1820
- const ghSource = join(options.home, ".config", "gh");
1821
- const gitSource = join(options.home, ".gitconfig");
1822
- const agentDbPresent = probes.agentDbPresent ?? existsSync(authSource);
1823
- const workerAuthDbCurrent = probes.workerAuthDbCurrent ?? existsSync(workerAuthDb);
1824
- const ghConfigPresent = probes.ghConfigPresent ?? existsSync(join(ghSource, "hosts.yml"));
1825
- const workerGhConfigCurrent = probes.workerGhConfigCurrent ?? existsSync(workerGhHosts);
1826
- const gitconfigPresent = probes.gitconfigPresent ?? existsSync(gitSource);
1827
- const workerGitconfigCurrent = probes.workerGitconfigCurrent ?? existsSync(workerGitconfig);
1828
- if (!workerAuthDbCurrent) {
1829
- pending.push(
1830
- `the worker auth database ${workerAuthDb} is not provisioned yet ` +
1831
- `(source ${authSource} ${agentDbPresent ? "exists" : "does not exist"})`,
1832
- );
1833
- }
1834
- if (!workerGhConfigCurrent) {
1835
- pending.push(
1836
- `the worker gh credentials ${workerGhHosts} are not provisioned yet ` +
1837
- `(source ${join(ghSource, "hosts.yml")} ${ghConfigPresent ? "exists" : "does not exist"})`,
1838
- );
1839
- }
1840
- if (!workerGitconfigCurrent) {
1841
- pending.push(
1842
- `the worker git identity ${workerGitconfig} is not provisioned yet ` +
1843
- `(source ${gitSource} ${gitconfigPresent ? "exists" : "does not exist"})`,
1844
- );
1845
- }
1846
- if (!setfaclInstalled) {
1847
- pending.push("/usr/bin/setfacl is not installed — the worker's path grants cannot be applied");
1848
- } else {
1849
- for (const path of grantPaths) {
1850
- if (!aclCurrent(path, "x")) pending.push(`search access for ${account} is not granted on ${path}`);
1851
- }
1852
- for (const file of configFiles) {
1853
- if (!aclCurrent(file, "r")) pending.push(`read access for ${account} is not granted on ${file}`);
1854
- }
1855
- }
1856
- // The harness binding (#828). Pending like any other missing piece of the
1857
- // identity, and for the same reason: without it a launched worker resolves a
1858
- // harness the operator never installed, so a host whose binding is absent is
1859
- // not a ready host however complete its account and grants are.
1860
- const harnessProblem =
1861
- probes.harnessProblem === undefined ? harnessBindingProblem() : probes.harnessProblem();
1862
- if (harnessProblem !== undefined) pending.push(harnessProblem);
1863
- // The bound tree's reach (#828 review), hardened to the directory's whole
1864
- // shape (#831). A bind shows its source's mode, so the mount point's parent
1865
- // is the only place to narrow it — and it is only "restricted" when root
1866
- // owns it, its group is the live worker group, the group can read+execute,
1867
- // and no other account can reach it (the `0750 root:<account>` shape). An
1868
- // open parent hands every local account the whole dependency tree, which
1869
- // the #798 boundary did not; a `0750 root:root` parent locks the worker out
1870
- // of the very install it must traverse. Both read pending.
1871
- if (!(probes.harnessDirRestricted ?? false)) {
1872
- pending.push(
1873
- `${WORKER_HARNESS_DIR} is not restricted to root and the ${account} group ` +
1874
- `(owner root, group ${account} with read+execute, and no other permissions, ` +
1875
- `so the worker can traverse the bound install and nobody else can)`,
1876
- );
1877
- }
1878
-
1879
- const sq = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
1880
- const refuseSymlink = (path: string): string => `refuse_symlink ${sq(path)}`;
1881
- const steps: PrivilegedStep[] = [];
1882
- // Account first: the bind and the grants name the account, so it must exist
1883
- // before they run; the daemon restart later in the batch then comes up
1884
- // already able to resolve it.
1885
- steps.push({
1886
- title: `create the ${account} worker account (unprivileged, no login, home ${home}) if missing`,
1887
- argv: [
1888
- "sh",
1889
- "-c",
1890
- `getent passwd ${account} >/dev/null 2>&1 || useradd --system --create-home ` +
1891
- `--home-dir ${sq(home)} --shell /usr/sbin/nologin --user-group ${account}`,
1892
- ],
1893
- });
1894
- steps.push({
1895
- title: `bind the fleet agent config into ${workerAgentDir}`,
1896
- argv: [
1897
- "sh",
1898
- "-c",
1899
- // One statement per link, never an `&&` chain: an absent optional
1900
- // fleet file skips only its own link and cannot abort the later links
1901
- // or the ownership hand-back below. The chown is unconditional — it is
1902
- // the step's point, not a trailing detail.
1903
- [
1904
- // The worker owns everything under its home, so any component of a
1905
- // directory root is about to mkdir or hand back may already be a
1906
- // worker-planted symlink; root follows it otherwise and redirects the
1907
- // whole step to the target (#816). Refused first, naming the link.
1908
- REFUSE_SYMLINKED_TARGETS_SH,
1909
- refuseSymlink(home),
1910
- refuseSymlink(join(home, ".omp")),
1911
- refuseSymlink(workerAgentDir),
1912
- `mkdir -p ${sq(workerAgentDir)}`,
1913
- `cd ${sq(workerAgentDir)}`,
1914
- ...WORKER_AGENT_LINK_FILES.map(
1915
- (name) =>
1916
- `if [ -f ${sq(join(options.agentDir, name))} ]; then ln -sfn ${sq(join(options.agentDir, name))} ${sq(name)}; fi`,
1917
- ),
1918
- ...WORKER_AGENT_LINK_DIRS.map(
1919
- (name) =>
1920
- `if [ -d ${sq(join(options.agentDir, name))} ]; then ln -sfn ${sq(join(options.agentDir, name))} ${sq(name)}; fi`,
1921
- ),
1922
- // The bind ran as root, so the freshly created dirs are root's — hand
1923
- // the worker's own state dirs back to the account (never -R: the
1924
- // bound links stay root-owned, and the fleet files they point at must
1925
- // not gain a writable owner through them). `-h` re-owns a symlink
1926
- // itself rather than the directory it points at (#816).
1927
- `chown -h ${account} ${sq(join(home, ".omp"))} ${sq(workerAgentDir)}`,
1928
- ].join("\n"),
1929
- ],
1930
- });
1931
- // The worker runtime (#798 review 2): writable worker-owned copies of the
1932
- // auth database (the harness persists credentials, usage and settings into
1933
- // it), the gh credentials and the git identity — everything a session needs
1934
- // to call a model, read the tracker through gh and commit. Each copy is
1935
- // guarded: an absent source waits for the operator, it never aborts the
1936
- // other copies or the ownership hand-back, and a re-run re-copies (converges).
1937
- const workerGhDir = join(home, ".config", "gh");
1938
- const own = (file: string): string => `if [ -e ${sq(file)} ]; then chown -h ${account} ${sq(file)}; fi`;
1939
- steps.push({
1940
- title: `provision the worker runtime: auth database, gh credentials, git identity`,
1941
- argv: [
1942
- "sh",
1943
- "-c",
1944
- [
1945
- // Every directory root this step creates, copies into or hands back,
1946
- // checked before anything follows (#816): the worker owns its home, so
1947
- // `.omp`, `.config` or `.cache` can already be a symlink pointing at
1948
- // an arbitrary root-owned target.
1949
- REFUSE_SYMLINKED_TARGETS_SH,
1950
- refuseSymlink(home),
1951
- refuseSymlink(join(home, ".omp")),
1952
- refuseSymlink(workerAgentDir),
1953
- refuseSymlink(join(home, ".config")),
1954
- refuseSymlink(workerGhDir),
1955
- refuseSymlink(join(home, ".cache")),
1956
- `mkdir -p ${sq(workerGhDir)} ${sq(join(home, ".cache", "gh"))}`,
1957
- // `--remove-destination` unlinks a worker-planted *destination*
1958
- // symlink before copying, so a copy lands as a fresh regular file
1959
- // instead of following the link to an arbitrary target (#816).
1960
- `if [ -f ${sq(authSource)} ]; then cp -f -p --remove-destination ${sq(authSource)} ${sq(workerAuthDb)}; fi`,
1961
- `if [ -f ${sq(`${authSource}-wal`)} ]; then cp -f -p --remove-destination ${sq(`${authSource}-wal`)} ${sq(`${workerAuthDb}-wal`)}; fi`,
1962
- `if [ -f ${sq(`${authSource}-shm`)} ]; then cp -f -p --remove-destination ${sq(`${authSource}-shm`)} ${sq(`${workerAuthDb}-shm`)}; fi`,
1963
- `if [ -f ${sq(join(ghSource, "hosts.yml"))} ]; then cp -f -p --remove-destination ${sq(join(ghSource, "hosts.yml"))} ${sq(workerGhHosts)}; fi`,
1964
- `if [ -f ${sq(join(ghSource, "config.yml"))} ]; then cp -f -p --remove-destination ${sq(join(ghSource, "config.yml"))} ${sq(join(workerGhDir, "config.yml"))}; fi`,
1965
- `if [ -f ${sq(gitSource)} ]; then cp -f -p --remove-destination ${sq(gitSource)} ${sq(workerGitconfig)}; fi`,
1966
- // The copies are the worker's own — never root's: the account writes
1967
- // its auth/usage data and runs gh/git under its own identity. `-h`:
1968
- // a worker-planted symlink at a directory is re-owned, not followed
1969
- // (#816).
1970
- `chown -h ${account} ${sq(join(home, ".config"))} ${sq(workerGhDir)} ${sq(join(home, ".cache"))}`,
1971
- own(workerAuthDb),
1972
- own(`${workerAuthDb}-wal`),
1973
- own(`${workerAuthDb}-shm`),
1974
- own(workerGhHosts),
1975
- own(join(workerGhDir, "config.yml")),
1976
- own(workerGitconfig),
1977
- ].join("\n"),
1978
- ],
1979
- });
1980
- // The ACL grants live in their own list, kept apart from the identity's
1981
- // pre-restart steps: they are the transaction's *last* executed steps, after
1982
- // the daemon and herdr restarts and the pane settle gate below, because an
1983
- // OMP startup chmods the agent config dir back to 0700 on every open and a
1984
- // chmod rewrites the ACL mask — a grant that runs before that is
1985
- // `#effective:---` by the time a worker is admitted (#835). The mask is set
1986
- // explicitly (`m::x` / `m::r`) so the re-grant defeats a zeroed mask without
1987
- // relying on setfacl recalculation, and the named entry is the worker
1988
- // account alone — no other local account gains anything. Each grant step
1989
- // *verifies* its own result afterwards through getfacl: the worker's entry
1990
- // must carry the granted bits and carry no `#effective:` annotation on the
1991
- // worker's own line (the mask stripping it — a pane that chmod'd after the
1992
- // settle gate). A grant
1993
- // that does not verify exits nonzero, which stops the setup transaction
1994
- // rather than leaving workers to be admitted under a dead ACL.
1995
- const grantSteps: PrivilegedStep[] = [];
1996
- if (grantPaths.length > 0) {
1997
- const all = grantPaths.map(sq).join(" ");
1998
- grantSteps.push({
1999
- title: `grant ${account} search access to the fleet paths a worker session needs`,
2000
- argv: [
2001
- "sh",
2002
- "-c",
2003
- [
2004
- `setfacl -m ${sq(`m::x,u:${account}:x`)} ${all}`,
2005
- `for p in ${all}; do`,
2006
- ` [ -e "$p" ] || continue`,
2007
- ` out=$(getfacl -p -c "$p" 2>/dev/null)`,
2008
- // The `#effective:` probe is scoped to the worker's own entry line:
2009
- // a group or other named entry that the explicit mask narrows also
2010
- // renders an annotation (`group::r-x #effective:--x`) without the
2011
- // worker's grant being dead — matching the whole dump would fail a
2012
- // perfectly effective grant on any path whose owner group class
2013
- // carries bits the mask narrows.
2014
- ` if [ -z "$out" ] || ! printf '%s\\n' "$out" | grep -q "user:${account}:--x" || printf '%s\\n' "$out" | grep -qE "user:${account}:--x[[:space:]]+#effective:"; then`,
2015
- ` printf '%s\n' "worker search access on $p is not effective after the grant (the ACL mask strips ${account}'s entry -- a pane chmod may have landed after the settle gate); refusing to admit workers under a dead grant" >&2`,
2016
- ` exit 1`,
2017
- ` fi`,
2018
- `done`,
2019
- ].join("\n"),
2020
- ],
2021
- });
2022
- }
2023
- if (configFiles.length > 0) {
2024
- const all = configFiles.map(sq).join(" ");
2025
- grantSteps.push({
2026
- title: `grant ${account} read access to the fleet agent config files`,
2027
- argv: [
2028
- "sh",
2029
- "-c",
2030
- // One statement per file, never an `&&` chain: a fleet file that
2031
- // vanished since the plan was rendered skips only its own grant and
2032
- // cannot abort the grants for the files that are still there. Same
2033
- // verification as the search grant: the pass is only a pass when the
2034
- // worker's read entry is effective, never when the mask keeps it out.
2035
- [
2036
- ...configFiles.map(
2037
- (file) =>
2038
- `if [ -f ${sq(file)} ]; then setfacl -m ${sq(`m::r,u:${account}:r`)} ${sq(file)}; fi`,
2039
- ),
2040
- `for p in ${all}; do`,
2041
- ` [ -f "$p" ] || continue`,
2042
- ` out=$(getfacl -p -c "$p" 2>/dev/null)`,
2043
- // Same worker-entry scoping as the search grant: an annotation on
2044
- // another line never fails the worker's own effective read.
2045
- ` if [ -z "$out" ] || ! printf '%s\\n' "$out" | grep -q "user:${account}:r--" || printf '%s\\n' "$out" | grep -qE "user:${account}:r--[[:space:]]+#effective:"; then`,
2046
- ` printf '%s\n' "worker read access on $p is not effective after the grant (the ACL mask strips ${account}'s entry -- a pane chmod may have landed after the settle gate); refusing to admit workers under a dead grant" >&2`,
2047
- ` exit 1`,
2048
- ` fi`,
2049
- `done`,
2050
- ].join("\n"),
2051
- ],
2052
- });
2053
- }
2054
-
1331
+ function planRetirement(unitDir: string): HostRetirement | undefined {
1332
+ const installed = join(unitDir, HARNESS_MOUNT_UNIT_NAME);
1333
+ const staged = join(stateDir(), HARNESS_MOUNT_UNIT_NAME);
1334
+ const units = existsSync(installed) ? [installed] : [];
1335
+ const stagedFiles = existsSync(staged) ? [staged] : [];
1336
+ if (units.length === 0 && stagedFiles.length === 0) return undefined;
2055
1337
  return {
2056
- account,
2057
- home,
2058
- agentDir: workerAgentDir,
2059
- grantPaths,
2060
- configFiles,
2061
- steps,
2062
- grantSteps,
2063
- pending,
2064
- current: pending.length === 0,
1338
+ units,
1339
+ staged: stagedFiles,
1340
+ steps: [
1341
+ ...(units.length === 0
1342
+ ? []
1343
+ : [
1344
+ {
1345
+ title: `disable and unmount ${HARNESS_MOUNT_UNIT_NAME} (retired: worker sessions run as the fleet account again)`,
1346
+ argv: ["systemctl", "disable", "--now", HARNESS_MOUNT_UNIT_NAME],
1347
+ },
1348
+ { title: `remove ${installed}`, argv: ["rm", "-f", installed] },
1349
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
1350
+ {
1351
+ title: `remove the now-unused mount point ${WORKER_HARNESS_DIR}`,
1352
+ argv: ["rmdir", "--ignore-fail-on-non-empty", WORKER_HARNESS_DIR],
1353
+ bestEffort: true,
1354
+ },
1355
+ ]),
1356
+ // The staged render is the fleet account's own file, so it needs no
1357
+ // escalation — and it goes last: a failed disable must not leave the
1358
+ // state directory looking converged.
1359
+ ...stagedFiles.map((path) => ({
1360
+ title: `remove the staged ${HARNESS_MOUNT_UNIT_NAME} render`,
1361
+ argv: ["rm", "-f", path],
1362
+ unprivileged: true,
1363
+ })),
1364
+ ],
2065
1365
  };
2066
1366
  }
2067
1367
 
2068
- /** The identity plan as the host runtime plan carries it for display. */
2069
- export interface WorkerIdentityPlanState {
2070
- account: string;
2071
- home: string;
2072
- agentDir: string;
2073
- current: boolean;
2074
- pending: readonly string[];
2075
- }
2076
-
2077
1368
  function planTick(project: ProjectConfig, telegramStateDir: string): {
2078
1369
  write: PlannedWrite<TickConfig>;
2079
1370
  /** Set when the config was restamped from the shared default: the live herdr
@@ -2150,11 +1441,6 @@ export function planHostRuntime(
2150
1441
  // host-global, so on a multi-project host it must not encode one project's
2151
1442
  // name — and a no-project install never does (#510/#530).
2152
1443
  multiProject: boolean = false,
2153
- // The worker identity plan (#798). `undefined`/`null` plans none (the
2154
- // historical surface — every existing caller); `runHostInstall` passes the
2155
- // real probes (or an explicit disable), so the consent the operator
2156
- // approves names every account and grant change the install will make.
2157
- identityProbes?: WorkerIdentityProbes | null,
2158
1444
  ): HostRuntimePlan {
2159
1445
  const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
2160
1446
  const serviceContent = renderDaemonService(runtime, totalWorkers);
@@ -2302,87 +1588,14 @@ export function planHostRuntime(
2302
1588
  // The worker identity plan (#798): account creation, the agent-config bind,
2303
1589
  // the runtime provision, and the search/read ACL grants are host changes
2304
1590
  // the same install consent covers, so they join the same single step list.
2305
- // The identity's own steps are prepended the account must exist before
2306
- // the bind and provision name it, and before the daemon restart below comes
2307
- // up resolving it. The ACL grants are appended *after* the transaction's
2308
- // final restarts instead — behind a bounded settle gate, because an OMP
2309
- // startup chmods the agent config dir back to 0700 (resetting the ACL
2310
- // mask), so a grant placed before the restarts is named-but-ineffective by
2311
- // the time a worker reads it, and a grant placed before a pane has finished
2312
- // booting can still be zeroed moments later (#835, review 1). A re-run
2313
- // whose only pending work is the identity still runs the (idempotent) steps
2314
- // and restarts — and ends with the grants effective again.
2315
- const fleetAgentDir = join(runtime.home, ".omp", "agent");
2316
- const harnessSource = packageRootOf(runtime.packageCli);
2317
- // The bind's source must be a real install root, not just "wherever the CLI
2318
- // lives": a source checkout has no `node_modules` ancestor, and binding its
2319
- // `src/` at a path named `node_modules` would produce a resolution root with
2320
- // no packages in it. There the binding cannot be established at all, which is
2321
- // what the identity plan's pending reason says — so plan no unit rather than
2322
- // install one that could never work.
2323
- const harnessInstallRoot = packageNodeModulesRoot(runtime.packageCli);
2324
- const identityPlan: WorkerIdentityPlan | undefined =
2325
- identityProbes === undefined || identityProbes === null
2326
- ? undefined
2327
- : planWorkerIdentity({
2328
- stateRoot: runtime.conductorHome,
2329
- agentDir: fleetAgentDir,
2330
- bunRoot: dirname(runtime.bun),
2331
- packageRoot: harnessSource,
2332
- home: runtime.home,
2333
- probes: identityProbes,
2334
- });
2335
- // The harness binding (#828) exists only to serve that identity, so it is
2336
- // planned exactly when the identity can be established at all — a host that
2337
- // cannot run the account machinery (the non-Linux plan, which stages no
2338
- // steps) has nothing to bind it for.
2339
- const harnessMountPath = join(stateDir(), HARNESS_MOUNT_UNIT_NAME);
2340
- const harnessMount: PlannedWrite<string> | undefined =
2341
- harnessInstallRoot === undefined || identityPlan === undefined || identityPlan.steps.length === 0
2342
- ? undefined
2343
- : (() => {
2344
- const content = renderHarnessMountUnit(harnessInstallRoot);
2345
- return {
2346
- path: harnessMountPath,
2347
- action: actionFor(harnessMountPath, content),
2348
- content,
2349
- value: content,
2350
- };
2351
- })();
1591
+ // What an earlier release left on this host and this one takes away (#895),
1592
+ // read from disk exactly like every other currentness fact in this plan.
1593
+ const retire = planRetirement(unitDir);
2352
1594
  // Recovery first: the daemon unit that follows names it in OnFailure=, so
2353
1595
  // the restart below must never point at a unit systemd cannot load. This is
2354
1596
  // the single list `runHostInstall` executes and {@link installCommands}
2355
1597
  // renders from, so no step can be in one and missing from the other (#509).
2356
1598
  const installSteps: PrivilegedStep[] = [
2357
- // The binding first: everything after it may restart the daemon, and a
2358
- // daemon that comes up before the mount exists refuses every worker launch
2359
- // until something restarts it again (#828). `enable --now` mounts it in the
2360
- // same step it makes persistent; both halves are idempotent, and neither
2361
- // unmounts a live bind out from under a running worker.
2362
- ...(harnessMount === undefined
2363
- ? []
2364
- : [
2365
- // The mount point's own directory, created before systemd would
2366
- // create it 0755. A bind shows the *source* directory's mode, so the
2367
- // only place the bound tree's reach can be narrowed is the parent:
2368
- // `0750 root:omp-worker` leaves it enumerable by root and the worker
2369
- // account and by nobody else, which is the #798 posture the binding
2370
- // must not widen. `install -d` applies owner and mode to a directory
2371
- // that already exists, so a re-run converges.
2372
- {
2373
- title: `restrict ${WORKER_HARNESS_DIR} to root and the ${WORKER_ACCOUNT} account`,
2374
- argv: ["install", "-d", "-o", "root", "-g", WORKER_ACCOUNT, "-m", "0750", WORKER_HARNESS_DIR],
2375
- },
2376
- {
2377
- title: `install ${HARNESS_MOUNT_UNIT_NAME} (binds ${harnessInstallRoot} read-only at ${WORKER_HARNESS_NODE_MODULES})`,
2378
- argv: ["install", "-m", "0644", harnessMountPath, join(unitDir, HARNESS_MOUNT_UNIT_NAME)],
2379
- },
2380
- { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
2381
- {
2382
- title: `enable and mount ${HARNESS_MOUNT_UNIT_NAME}`,
2383
- argv: ["systemctl", "enable", "--now", HARNESS_MOUNT_UNIT_NAME],
2384
- },
2385
- ]),
2386
1599
  {
2387
1600
  title: "install the recovery playbook",
2388
1601
  argv: ["install", "-m", "0755", recoverScriptPath, recoverScriptInstallPath],
@@ -2442,38 +1655,11 @@ export function planHostRuntime(
2442
1655
  ]),
2443
1656
  ];
2444
1657
  const installStepsAll: PrivilegedStep[] = [
2445
- ...(identityPlan === undefined ? [] : identityPlan.steps),
1658
+ // Retirement first (#895): the obsolete bind is unmounted before anything
1659
+ // restarts, so no unit comes up while a mount nothing reads is still live,
1660
+ // and an operator watching the plan sees the removal before the installs.
1661
+ ...(retire === undefined ? [] : retire.steps),
2446
1662
  ...installSteps,
2447
- // The ACL grants ride *last* — after the daemon and herdr restarts above —
2448
- // because an OMP startup chmods its agent config dir back to 0700 and a
2449
- // chmod rewrites the ACL mask: a grant applied before the restart is
2450
- // `user:omp-worker:--x #effective:---` by the time a worker reads it (#835).
2451
- // The herdr restart returns once herdr's own process is up, not once its
2452
- // restored OMP panes have booted — so on a herdr host the grants wait on a
2453
- // bounded settle gate that observes the pane's startup chmod in the ACL
2454
- // mask before any setfacl may run (#835 review 1). The grant steps then
2455
- // verify their own effective result, and a dead grant stops the
2456
- // transaction rather than admitting workers under it.
2457
- ...(identityPlan === undefined || identityPlan.grantSteps.length === 0
2458
- ? []
2459
- : [
2460
- ...(runtime.herdr === undefined
2461
- ? []
2462
- : [
2463
- {
2464
- title: `wait for the fleet agent panes to settle after the herdr restart (bounded readiness gate)`,
2465
- // The pane settle gate is a required step: a pane whose
2466
- // startup chmod does not settle must fail the transaction
2467
- // rather than let a grant land under a still-booting pane.
2468
- argv: [
2469
- "sh",
2470
- "-c",
2471
- paneSettleScript(fleetAgentDir, runtime.herdr, runtime.herdrSession ?? DEFAULT_HERDR_SESSION),
2472
- ],
2473
- },
2474
- ]),
2475
- ...identityPlan.grantSteps,
2476
- ]),
2477
1663
  ];
2478
1664
  // Everything the privileged steps install is already at its destination with
2479
1665
  // the current bytes, so a re-run of `setup host` has nothing to install and
@@ -2492,10 +1678,6 @@ export function planHostRuntime(
2492
1678
  noteDrift(join(unitDir, RECOVER_SERVICE_NAME), recoverUnitContent);
2493
1679
  noteDrift(recoverScriptInstallPath, recoverScriptContent);
2494
1680
  if (herdrUnit !== undefined) noteDrift(installedHerdr, herdrUnit.content);
2495
- // The harness binding's own unit: a host whose mount unit is missing or
2496
- // rewritten by a release is not a current install, however current every
2497
- // other destination is (#828).
2498
- if (harnessMount !== undefined) noteDrift(join(unitDir, HARNESS_MOUNT_UNIT_NAME), harnessMount.content);
2499
1681
  // The pane-shell file is a destination like the units: a plan with all
2500
1682
  // units current but the config merge still pending must not report
2501
1683
  // "nothing to install" and skip the very write it exists to make.
@@ -2503,22 +1685,24 @@ export function planHostRuntime(
2503
1685
  // Same for the herdr-conductor config.env: a pending env merge is pending
2504
1686
  // work, not an already-current install.
2505
1687
  if (herdrEnv !== undefined) noteDrift(herdrEnvTarget, herdrEnv.content);
2506
- // A pending worker identity is pending work like any drifted file: the
2507
- // install gate must not call a host "current" whose worker sessions would
2508
- // fail closed the moment they dispatch (#798).
2509
- const currentInstall = drift.length === 0 && (identityPlan?.current ?? true);
1688
+ // Owed retirement is pending work like any drifted file (#895): a host whose
1689
+ // obsolete mount is still enabled has something left to do, and a re-run that
1690
+ // reported "already current" there would be the half-applied retirement this
1691
+ // slice exists to prevent.
1692
+ const currentInstall = drift.length === 0 && retire === undefined;
2510
1693
  return {
2511
1694
  service,
2512
1695
  // The herdr unit and pane-shell config stand and fall together: no herdr, no
2513
1696
  // session to supervise, nothing for a pane shell to belong to. (The pane
2514
1697
  // shell is omitted too when the login shell is unusable or the rendered
2515
1698
  // config would not parse — those plans carry {@link herdrConfigProblem}.)
2516
- ...(herdrUnit === undefined ? {} : { herdrUnit }),
1699
+ ...(herdrUnit === undefined
1700
+ ? {}
1701
+ : { herdrUnit, herdrSession: runtime.herdrSession ?? DEFAULT_HERDR_SESSION }),
2517
1702
  ...(herdrConfig === undefined ? {} : { herdrConfig, herdrConfigTarget }),
2518
1703
  ...(herdrConfigProblem === undefined ? {} : { herdrConfigProblem }),
2519
1704
  ...(herdrConfigWarning === undefined ? {} : { herdrConfigWarning }),
2520
1705
  ...(herdrEnv === undefined ? {} : { herdrEnv, herdrEnvTarget }),
2521
- ...(harnessMount === undefined ? {} : { harnessMount }),
2522
1706
  recoverUnit,
2523
1707
  recoverScript,
2524
1708
  ...(project === undefined
@@ -2561,7 +1745,7 @@ export function planHostRuntime(
2561
1745
  installedPath,
2562
1746
  installedAction,
2563
1747
  drift,
2564
- ...(identityPlan === undefined ? {} : { workerIdentity: identityPlan }),
1748
+ ...(retire === undefined ? {} : { retire }),
2565
1749
  currentInstall,
2566
1750
  };
2567
1751
  }
@@ -2573,9 +1757,12 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
2573
1757
  ` daemon entry ${plan.cliSource === "global" ? "installed omp-conductor CLI" : "current installed plugin"}`,
2574
1758
  ` recovery ${plan.recoverUnit.action} ${plan.recoverUnit.path}`,
2575
1759
  ` recovery exec ${plan.recoverScript.action} ${plan.recoverScript.path} -> ${RECOVER_SCRIPT_INSTALL_PATH}`,
2576
- ...(plan.harnessMount === undefined
1760
+ ...(plan.retire === undefined
2577
1761
  ? []
2578
- : [` harness bind ${plan.harnessMount.action} ${plan.harnessMount.path} -> ${WORKER_HARNESS_NODE_MODULES}`]),
1762
+ : [
1763
+ ` retire ${[...plan.retire.units, ...plan.retire.staged].join(", ")}`,
1764
+ ` (the ${HARNESS_MOUNT_UNIT_NAME.replace(/\\x2d/g, "-")} bind an earlier release installed; worker sessions run as the fleet account)`,
1765
+ ]),
2579
1766
  ...(plan.herdrUnit === undefined
2580
1767
  ? [" herdr session skipped — herdr not installed on this host"]
2581
1768
  : [
@@ -2615,27 +1802,10 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
2615
1802
  : ` brief link ${plan.briefLink.action} ${plan.briefLink.path} -> ${plan.briefLink.target}`,
2616
1803
  );
2617
1804
  }
2618
- if (plan.workerIdentity !== undefined) {
2619
- lines.push(
2620
- plan.workerIdentity.current
2621
- ? ` worker identity ${plan.workerIdentity.account} (home ${plan.workerIdentity.home}) — present, worker paths granted`
2622
- : ` worker identity ${plan.workerIdentity.account} (home ${plan.workerIdentity.home}) — pending: ${plan.workerIdentity.pending.join("; ")}`,
2623
- );
2624
- }
2625
1805
  lines.push(" install staged only; the final result prints the systemd install commands");
2626
1806
  return lines.join("\n");
2627
1807
  }
2628
1808
 
2629
- /** The node_modules root an installed package CLI lives under — the ancestor
2630
- * of `packageCli` named `node_modules`. This is the root the worker needs
2631
- * search access to and the source of its harness binding; granting the one
2632
- * package dir would miss its dependency tree, and granting the parent of
2633
- * node_modules would grant far more. A source checkout has no such ancestor,
2634
- * and the package's own directory is the closest honest answer. */
2635
- function packageRootOf(packageCli: string): string {
2636
- return packageNodeModulesRoot(packageCli) ?? dirname(resolve(packageCli));
2637
- }
2638
-
2639
1809
  function atomicWrite(path: string, content: string, mode: number): void {
2640
1810
  mkdirSync(dirname(path), { recursive: true });
2641
1811
  const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
@@ -2669,10 +1839,6 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
2669
1839
  atomicWrite(plan.herdrUnit.path, plan.herdrUnit.content, 0o644);
2670
1840
  wrote.push(plan.herdrUnit.path);
2671
1841
  }
2672
- if (plan.harnessMount !== undefined && plan.harnessMount.action !== "keep") {
2673
- atomicWrite(plan.harnessMount.path, plan.harnessMount.content, 0o644);
2674
- wrote.push(plan.harnessMount.path);
2675
- }
2676
1842
  if (plan.herdrConfig !== undefined && plan.herdrConfig.action !== "keep") {
2677
1843
  atomicWrite(plan.herdrConfig.path, plan.herdrConfig.content, 0o644);
2678
1844
  wrote.push(plan.herdrConfig.path);