omp-conductor 0.18.1 → 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 (69) hide show
  1. package/README.md +106 -41
  2. package/REFERENCE.md +866 -31
  3. package/agents/to-spec.md +6 -2
  4. package/package.json +1 -1
  5. package/schema/config.schema.json +32 -1
  6. package/src/admission.ts +212 -26
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +288 -1
  9. package/src/briefs/orchestrator.md +27 -13
  10. package/src/briefs/to-spec.md +6 -2
  11. package/src/cli.ts +127 -2
  12. package/src/command-help.ts +9 -1
  13. package/src/command-manifest.ts +52 -8
  14. package/src/commands/arm.ts +6 -2
  15. package/src/commands/context.ts +2 -0
  16. package/src/commands/intake.ts +4 -19
  17. package/src/commands/message.ts +26 -2
  18. package/src/commands/reconcile-units.ts +104 -0
  19. package/src/commands/release-composition.ts +232 -0
  20. package/src/commands/resume.ts +2 -27
  21. package/src/commands/setup.ts +101 -16
  22. package/src/commands/stats.ts +11 -30
  23. package/src/commands/tail.ts +31 -1
  24. package/src/commands/upgrade.ts +20 -3
  25. package/src/commands/verb.ts +2 -1
  26. package/src/commands/watch.ts +4 -17
  27. package/src/config-schema.ts +38 -6
  28. package/src/config.ts +103 -8
  29. package/src/credential-class.ts +366 -0
  30. package/src/daemon.ts +1368 -529
  31. package/src/dashboard/app.js +504 -2
  32. package/src/dashboard/controls.ts +336 -0
  33. package/src/dashboard/index.html +30 -0
  34. package/src/dashboard/server.ts +271 -30
  35. package/src/dashboard/style.css +116 -0
  36. package/src/dashboard/transcript.ts +173 -0
  37. package/src/decisions.ts +19 -11
  38. package/src/doctor.ts +431 -148
  39. package/src/escalate.ts +22 -11
  40. package/src/failure-class.ts +59 -0
  41. package/src/fleet.ts +587 -230
  42. package/src/host.ts +6 -455
  43. package/src/omp-settings.ts +19 -0
  44. package/src/omp.ts +40 -56
  45. package/src/orchestrator-tick.ts +564 -121
  46. package/src/pause.ts +233 -0
  47. package/src/session-host.ts +6 -41
  48. package/src/settlement.ts +159 -2
  49. package/src/setup-answers.ts +97 -0
  50. package/src/setup-host.ts +343 -1160
  51. package/src/setup-install.ts +204 -27
  52. package/src/setup-wizard.ts +252 -51
  53. package/src/setup.ts +87 -4
  54. package/src/spend-telemetry.ts +117 -0
  55. package/src/stats.ts +35 -0
  56. package/src/status-render.ts +485 -19
  57. package/src/store.ts +1229 -55
  58. package/src/telegram-freshness.ts +269 -0
  59. package/src/to-spec.ts +50 -2
  60. package/src/types.ts +759 -10
  61. package/src/unblock.ts +22 -0
  62. package/src/unit-reconcile.ts +303 -0
  63. package/src/upgrade-verify.ts +8 -1
  64. package/src/upgrade.ts +299 -12
  65. package/src/verbs/actions.ts +124 -10
  66. package/src/verbs/protocol.ts +70 -2
  67. package/src/verbs/server.ts +485 -11
  68. package/src/wake.ts +48 -0
  69. package/src/worker.ts +401 -14
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,12 +12,7 @@ import {
12
12
  resolveHerdrSessionWithBridge,
13
13
  } from "./fleet.ts";
14
14
  import {
15
- harnessBindingProblem,
16
- workerHarnessImportProblem,
17
- WORKER_ACCOUNT,
18
15
  WORKER_HARNESS_DIR,
19
- WORKER_HARNESS_NODE_MODULES,
20
- WORKER_HOME_DIR,
21
16
  packageNodeModulesRoot,
22
17
  } from "./host.ts";
23
18
  import {
@@ -48,6 +43,16 @@ export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
48
43
  export const STAGED_SERVICE_NAME = "omp-conductor.service";
49
44
  export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
50
45
 
46
+ /**
47
+ * The system directories every rendered service PATH ends with, in systemd's
48
+ * own default order: git, gh and the other tools the fleet spawns by name
49
+ * live here on every target host. The service PATH as a whole is canonical
50
+ * (#879) — these directories plus the resolved fleet-binary directories — so
51
+ * `setup host` stages and `doctor` compares one value, whichever process
52
+ * renders it and whatever the invoking shell happened to carry.
53
+ */
54
+ export const SERVICE_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
55
+
51
56
  /**
52
57
  * The one recovery unit every fleet unit's `OnFailure=` points at (#485).
53
58
  *
@@ -70,9 +75,14 @@ export const RECOVER_SCRIPT_FILE = "omp-conductor-recover.sh";
70
75
  export const RECOVER_SCRIPT_INSTALL_PATH = "/usr/local/sbin/omp-conductor-recover";
71
76
 
72
77
  /**
73
- * The systemd unit that binds the operator's install read-only at
74
- * {@link WORKER_HARNESS_NODE_MODULES} so worker sessions resolve the harness
75
- * 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}.
76
86
  *
77
87
  * The name is not a choice: systemd derives a mount unit's name from its mount
78
88
  * point and refuses to load one under any other, so this is the escaped form of
@@ -81,6 +91,23 @@ export const RECOVER_SCRIPT_INSTALL_PATH = "/usr/local/sbin/omp-conductor-recove
81
91
  */
82
92
  export const HARNESS_MOUNT_UNIT_NAME = "var-lib-omp\\x2dworker\\x2dharness-node_modules.mount";
83
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
+
84
111
  /**
85
112
  * The symlink the session cwd loads as its brief. omp auto-loads `AGENTS.md`
86
113
  * from the session cwd, so the fleet pane's cwd needs a link at this name
@@ -138,15 +165,6 @@ export interface HostRuntimePlan {
138
165
  * rendering that provisions it (see {@link renderHerdrUnit}).
139
166
  */
140
167
  herdrUnit?: PlannedWrite<string>;
141
- /**
142
- * The worker harness binding's mount unit (#828): a read-only bind of the
143
- * operator's install at {@link WORKER_HARNESS_NODE_MODULES}, which is the
144
- * only path a worker session can resolve the harness through. Present
145
- * exactly when the caller planned a worker identity this host can establish
146
- * — the binding has no purpose without one, and a host that cannot run the
147
- * account machinery has nothing to bind it for.
148
- */
149
- harnessMount?: PlannedWrite<string>;
150
168
  /**
151
169
  * The fleet recovery oneshot (#485): staged always, because the daemon unit
152
170
  * (which every fleet has) carries `OnFailure=` to it. Rendered by
@@ -195,6 +213,13 @@ export interface HostRuntimePlan {
195
213
  herdrEnv?: PlannedWrite<string>;
196
214
  /** The live plugin config.env {@link herdrEnv} merges into, post-consent. */
197
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;
198
223
  /**
199
224
  * When {@link planTick} rewrote a shared-default `agentName`, the live herdr
200
225
  * pane still carries the old identity — the ticking decline that names the
@@ -253,12 +278,21 @@ export interface HostRuntimePlan {
253
278
  */
254
279
  drift: readonly string[];
255
280
  /**
256
- * The worker identity plan (#798) when the caller asked for one: the
257
- * account/agent-bind/grant steps prepended to {@link steps}, and the
258
- * verdict that feeds {@link currentInstall}. Absent on the historical
259
- * 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.
260
294
  */
261
- workerIdentity?: WorkerIdentityPlan;
295
+ retire?: HostRetirement;
262
296
  /**
263
297
  * True when every file the privileged install steps would write is already
264
298
  * at its destination with the current bytes. `runHostInstall` uses it to
@@ -464,21 +498,149 @@ function refusal(
464
498
  ].join("\n");
465
499
  }
466
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
+ }
467
612
 
468
613
  export function defaultServiceRuntime(
469
614
  telegramStateDir: string,
470
615
  // Bridge-resolved session name. Passed in so tests stay hermetic (no herdr
471
616
  // spawn) and the renderer never shells out — same threading as telegramStateDir.
472
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(),
473
623
  ): ServiceRuntime {
474
624
  const home = homedir();
475
625
  const bun = process.execPath;
476
- const globalCli = Bun.which("omp-conductor");
477
- 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 });
478
631
  const loginShell = userInfo().shell;
479
- const pathParts = [dirname(bun), ...(process.env["PATH"] ?? "").split(":")].filter(
480
- (value, index, all) => value.length > 0 && all.indexOf(value) === index,
481
- );
632
+ // #879: the staged PATH is a canonical host-runtime value — the directories
633
+ // of the binaries this runtime resolved plus SERVICE_SYSTEM_PATH never the
634
+ // invoking shell's PATH. Caller entries (/root/bin, /opt/puppetlabs/bin, …)
635
+ // once leaked into the staged units, so `doctor` re-deriving the render in
636
+ // its own process reported PATH drift against byte-identical installs, and
637
+ // rerunning setup reproduced the finding its own fix text prescribes away.
638
+ const pathParts = [
639
+ dirname(bun),
640
+ ...(globalCli === null ? [] : [dirname(globalCli)]),
641
+ ...(herdr === null ? [] : [dirname(herdr)]),
642
+ ...SERVICE_SYSTEM_PATH.split(":"),
643
+ ].filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
482
644
  return {
483
645
  username: userInfo().username,
484
646
  home,
@@ -643,60 +805,6 @@ export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string |
643
805
  ].join("\n");
644
806
  }
645
807
 
646
- /**
647
- * The worker harness binding (#828): a read-only bind of the operator's
648
- * `node_modules` at {@link WORKER_HARNESS_NODE_MODULES}, which is how a worker
649
- * session reaches the exact harness build installed alongside omp-conductor.
650
- *
651
- * A bind rather than a copy, because the two must never diverge: it shares
652
- * inodes with the source, so an operator who upgrades the harness upgrades what
653
- * every worker loads, with no mirror to re-materialise and no window in which a
654
- * worker runs last week's build.
655
- *
656
- * `ro` because a session has no business writing the install; `nosuid,nodev`
657
- * because a tree the worker account reads should carry neither. Never `noexec`:
658
- * the harness dlopens its native addon out of this tree, and `noexec` refuses
659
- * the executable mapping that needs.
660
- *
661
- * The ordering edge lives here rather than in the daemon unit, and that is not
662
- * a style choice. Both units are pulled in by `multi-user.target`, so without
663
- * an edge a reboot can start the daemon first — and although dispatch
664
- * re-resolves the binding per launch (so the fleet recovers on the next tick
665
- * rather than needing a restart), ordering removes the window instead of
666
- * tolerating it. It is declared as `Before=` on the mount because the *daemon*
667
- * unit's name needs no escaping: systemd does not resolve a `\x2d`-escaped unit
668
- * name written into a dependency setting back to the real unit, so the reverse
669
- * spelling silently binds nothing (verified on a live host).
670
- *
671
- * Deliberately not `Requires=`/`RequiresMountsFor=` from the daemon: those
672
- * would keep the control plane down whenever the bind failed, and a daemon that
673
- * is up and refusing worker launches with the reason is what an operator can
674
- * actually diagnose.
675
- */
676
- export function renderHarnessMountUnit(packageRoot: string): string {
677
- return [
678
- "[Unit]",
679
- "Description=omp-conductor worker harness binding",
680
- "Documentation=https://github.com/TerrifiedBug/conductor",
681
- // The source is a directory on some filesystem; systemd must have that
682
- // filesystem before it can bind anything out of it.
683
- `RequiresMountsFor=${systemdPath(packageRoot)}`,
684
- // Ordering only, never a requirement: the daemon must not be started before
685
- // the bind exists, but a failed bind must not keep the control plane down.
686
- `Before=${STAGED_SERVICE_NAME}`,
687
- "",
688
- "[Mount]",
689
- `What=${systemdPath(packageRoot)}`,
690
- `Where=${systemdPath(WORKER_HARNESS_NODE_MODULES)}`,
691
- "Type=none",
692
- "Options=bind,ro,nosuid,nodev",
693
- "",
694
- "[Install]",
695
- "WantedBy=multi-user.target",
696
- "",
697
- ].join("\n");
698
- }
699
-
700
808
  /**
701
809
  * Whether a login-shell value may be pinned into herdr's config at all.
702
810
  *
@@ -1099,964 +1207,164 @@ function planBriefLink(project: ProjectConfig): BriefLinkPlan {
1099
1207
  };
1100
1208
  }
1101
1209
 
1102
- // -------------------------------------------------- worker identity plan (#798) --
1103
-
1104
- /** The fleet agent-config files the worker home binds (when they exist):
1105
- * provided read-only, never copied — the fleet stays the single source, and
1106
- * the worker cannot edit what it only reads. `.env` is where MCP secrets
1107
- * live; `AGENTS.md` is the mandatory host policy every session must read. */
1108
- const WORKER_AGENT_LINK_FILES = ["config.yml", "models.yml", "mcp.json", ".env", "AGENTS.md"] as const;
1109
-
1110
- /** The fleet agent-config directories the worker home binds (when they exist):
1111
- * skills, managed skills, extensions and scripts are the harness's read-only
1112
- * discovery roots. */
1113
- const WORKER_AGENT_LINK_DIRS = ["commands", "prompts", "skills", "managed-skills", "extensions", "scripts"] as const;
1114
-
1115
1210
  /**
1116
- * Read-only facts the identity plan is computed from, all injectable so tests
1117
- * pin the plan hermetically. Production defaults probe the real host: the
1118
- * account's presence in /etc/passwd, the on-disk agent config, and (through
1119
- * `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.
1120
1217
  */
1121
- export interface WorkerIdentityProbes {
1122
- /** Whether this host can run the account/ACL machinery at all (Linux). */
1123
- linux?: boolean;
1124
- /** Whether the worker account already exists. */
1125
- accountExists?: boolean;
1126
- /** Whether `setfacl` is installed. */
1127
- setfaclInstalled?: boolean;
1128
- /** The fleet agent-config files to bind into the worker home and grant read
1129
- * access to (existing files only — absent ones are nothing to bind). */
1130
- configFiles?: readonly string[];
1131
- dirExists?(path: string): boolean;
1132
- /** Whether a path is searchable by other accounts (mode `o+x`). */
1133
- searchable?(path: string): boolean;
1134
- /** Whether `path` is a symlink resolving to `target`. */
1135
- linkCurrent?(path: string, target: string): boolean;
1136
- /** Whether the worker account already holds *effective* `perms` on `path`
1137
- * through an ACL — the named entry intersected with the mask, never the
1138
- * named entry alone (#835). */
1139
- aclCurrent?(path: string, perms: "x" | "r"): boolean;
1140
- /** Whether the operator's auth database (`<agentDir>/agent.db`) exists to
1141
- * copy into the worker's runtime. */
1142
- agentDbPresent?: boolean;
1143
- /** Whether the worker's own writable copy of the auth database exists. */
1144
- workerAuthDbCurrent?: boolean;
1145
- /** Whether the operator's `~/.config/gh` credentials exist to copy. */
1146
- ghConfigPresent?: boolean;
1147
- /** Whether the worker's copy of the gh credentials exists. */
1148
- workerGhConfigCurrent?: boolean;
1149
- /** Whether the operator's `~/.gitconfig` exists to copy. */
1150
- gitconfigPresent?: boolean;
1151
- /** Whether the worker's copy of the git identity exists. */
1152
- workerGitconfigCurrent?: boolean;
1153
- /**
1154
- * Whether {@link WORKER_HARNESS_DIR} is currently restricted exactly as the
1155
- * worker needs it (#831): owned by root, its group the worker account's live
1156
- * primary gid, group read+execute, and no `other` permissions. False when it
1157
- * is drifted — wrong owner or group, missing group access, world-readable —
1158
- * including when it is absent (systemd would create a missing mount point at
1159
- * 0755) or unverifiable. Replaces the earlier other-bits-only check, which
1160
- * read `0750 root:root` as current because it had no `other` bits while the
1161
- * worker could not traverse it.
1162
- */
1163
- harnessDirRestricted?: boolean;
1218
+ export interface HerdrOwnershipFacts {
1164
1219
  /**
1165
- * Why the worker harness binding is not usable, or `undefined` when it is
1166
- * live (#828). A function, not a value, so the non-Linux plan never pays for
1167
- * a probe it will not consult; production reads the host through
1168
- * {@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.
1169
1223
  */
1170
- harnessProblem?: () => string | undefined;
1171
- }
1172
-
1173
- /** The worker account's presence in /etc/passwd, the resolution's source of
1174
- * truth; unreadable passwd means "not present" for planning purposes. */
1175
- function passwdHasAccount(): boolean {
1176
- try {
1177
- return readFileSync("/etc/passwd", "utf8")
1178
- .split("\n")
1179
- .some((entry) => entry.startsWith(`${WORKER_ACCOUNT}:`));
1180
- } catch {
1181
- return false;
1182
- }
1183
- }
1184
-
1185
- /**
1186
- * Whether a `getfacl -p -c` dump proves that `account`'s **effective**
1187
- * permissions on one path include `perms`. The named entry alone is not
1188
- * enough: a later `chmod` of the path rewrites the ACL mask, and an entry the
1189
- * mask strips reads `user:omp-worker:--x #effective:---` while its granted
1190
- * bits still carry the letter — the post-restart shape #835 fell into. The
1191
- * effective permissions are the named entry intersected with the mask — the
1192
- * same result getfacl renders as the `#effective:` annotation, computed here
1193
- * so a host whose getfacl omits the annotation cannot slip a
1194
- * named-but-ineffective ACL past the verdict, and an entry for any other
1195
- * account never counts as this account's grant.
1196
- */
1197
- export function aclEffectivePermits(dump: string, account: string, perms: "x" | "r"): boolean {
1198
- let entryPerms: string | undefined;
1199
- let maskPerms: string | undefined;
1200
- for (const raw of dump.split("\n")) {
1201
- const line = raw.trim();
1202
- if (line.startsWith("#")) continue;
1203
- // `user:omp-worker:r-x`; the trailing `#effective:...` annotation (when
1204
- // present) is after the perms and irrelevant to the match.
1205
- if (line.startsWith(`user:${account}:`)) {
1206
- entryPerms = /^([r-][w-][x-])/.exec(line.slice(`user:${account}:`.length))?.[1];
1207
- } else if (line.startsWith("mask:")) {
1208
- // A mask line renders as `mask::r-x` (the name field is empty).
1209
- maskPerms = /^([r-][w-][x-])/.exec(line.slice("mask:".length).replace(/^:+/u, ""))?.[1];
1210
- }
1211
- }
1212
- if (entryPerms === undefined) return false;
1213
- const effective =
1214
- maskPerms === undefined
1215
- ? entryPerms
1216
- : [0, 1, 2]
1217
- .map((i) => (entryPerms[i] === "-" || maskPerms[i] === "-" ? "-" : maskPerms[i]) as string)
1218
- .join("");
1219
- return perms === "r" ? effective[0] === "r" : effective[2] === "x";
1220
- }
1221
-
1222
- /** Whether one path already grants the worker's *effective* ACL permissions
1223
- * for `perms`, read through `getfacl -p -c` and judged by the mask
1224
- * intersection ({@link aclEffectivePermits}). A host without getfacl, an
1225
- * unreadable path, or a path whose ACL entry is masked off all report "not
1226
- * current" — the grant steps then run, the safe direction for an unverifiable
1227
- * grant. Exported so `doctor` reads the live host through the same probe the
1228
- * identity plan plans with (#835). */
1229
- export function workerAclProbe(path: string, perms: "x" | "r"): boolean {
1230
- const ran = spawnSync("getfacl", ["-p", "-c", path], { encoding: "utf8" });
1231
- if (ran.status !== 0 || ran.stdout === null) return false;
1232
- return aclEffectivePermits(ran.stdout, WORKER_ACCOUNT, perms);
1233
- }
1234
-
1235
- /** The linked worker config paths' effective-ACL verdict for the dedicated
1236
- * worker account: how many paths were checkable, and which of them do not
1237
- * currently grant the worker's needed effective access. */
1238
- export interface WorkerAclHealth {
1239
- /** The agent config dir plus every existing linked config file. */
1240
- checkable: number;
1241
- /** The checkable paths whose grant is not effective — the named entry is
1242
- * absent, the path is unreadable, or the ACL mask strips the entry's
1243
- * effective permissions (#835). */
1244
- missing: readonly string[];
1245
- }
1246
-
1247
- /**
1248
- * The linked worker config paths (search `x` on the agent config dir and its
1249
- * existing ancestors that other accounts cannot already traverse, read `r` on
1250
- * every existing linked config file) with each path's effective-ACL verdict
1251
- * for the worker account. Pure of any privilege: the paths are read-only and
1252
- * every verdict is {@link workerAclProbe}'s own read, so `doctor` and the
1253
- * identity plan cannot disagree about an ACL (#835).
1254
- */
1255
- export function workerAclHealth(agentDir: string): WorkerAclHealth {
1256
- const targets: [string, "x" | "r"][] = [];
1257
- // The same ancestor rule the identity plan's grantPaths applies to this
1258
- // root: only the existing segments other accounts cannot already traverse
1259
- // (mode `o+x`) need an ACL at all. The harness chmods the leaf back to 0700
1260
- // on every open, so the leaf is virtually always in this set.
1261
- const parts = resolve(agentDir)
1262
- .split("/")
1263
- .filter((part) => part !== "");
1264
- let cur = "/";
1265
- for (const part of parts) {
1266
- cur = cur === "/" ? `/${part}` : `${cur}/${part}`;
1267
- let st: Stats | undefined;
1268
- try {
1269
- st = statSync(cur);
1270
- } catch {
1271
- break;
1272
- }
1273
- if (!st.isDirectory()) break;
1274
- if ((st.mode & 0o001) === 0) targets.push([cur, "x"]);
1275
- }
1276
- for (const name of WORKER_AGENT_LINK_FILES) {
1277
- const file = join(agentDir, name);
1278
- if (existsSync(file)) targets.push([file, "r"]);
1279
- }
1280
- const missing = targets
1281
- .filter(([path, perms]) => !workerAclProbe(path, perms))
1282
- .map(([path]) => path);
1283
- 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;
1284
1235
  }
1285
1236
 
1286
- // --------------------------------------------------- pane settle gate (#835) --
1287
-
1288
- /**
1289
- * How many 1-second probes the {@link paneSettleScript} gate may take before
1290
- * declaring the restored pane's startup chmod unsettled. A herdr pane boots in
1291
- * well under this on a healthy host; every extra probe delays setup by one
1292
- * second, so the bound is deliberately short.
1293
- */
1294
- export const AGENT_SETTLE_PROBE_STEPS = 12;
1295
-
1296
- /**
1297
- * How long a settled mask must stay settled before the gate lets the grants
1298
- * through: two consecutive reads a probe-interval apart, so a boot churn of
1299
- * two panes still reads unsettled.
1300
- */
1301
- export const AGENT_SETTLE_PROBE_INTERVAL_S = 1;
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 };
1302
1246
 
1303
1247
  /**
1304
- * The bounded readiness barrier the final ACL grants wait on. `systemctl
1305
- * restart herdr-fleet.service` returns when herdr's own (Type=simple) server
1306
- * process is up — the restored OMP panes boot *after* that, and each pane's
1307
- * AgentStorage ctor chmods the fleet agent config dir back to 0700 on open. A
1308
- * chmod rewrites the ACL mask, so a grant that lands before that chmod is
1309
- * `user:omp-worker:--x #effective:---` again within seconds (#835).
1248
+ * Who owns the fleet session (#893).
1310
1249
  *
1311
- * The gate reads two things: the agent dir's ACL mask, and the live herdr
1312
- * agent list. A mask is *settled* when it is zeroed (`---`, what a startup
1313
- * chmod leaves) or absent altogether (an ACL-less dir the shape of every
1314
- * first-ever `setup host`, whose grants have not run yet because they run
1315
- * *after* this gate). Neither reading, on its own, says anything about *this*
1316
- * restart: the #835 host starts with a zeroed mask, because a previous grant
1317
- * plus a previous pane's chmod already left one, and an absent mask cannot
1318
- * render a chmod at all. So on a herdr host a settled mask releases the grants
1319
- * only once this restart has positively observed a live agent: herdr lists a
1320
- * pane as an agent when that pane's OMP process registers, which is after the
1321
- * `AgentStorage` open that chmods, so a live row is the one witness available
1322
- * that the chmod is behind us. With the witness in hand the gate wants the
1323
- * 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.
1324
1259
  *
1325
- * Both halves of that rule were learned the hard way. Demanding a literal
1326
- * `mask::---` line deadlocked first-run setup outright, since that line cannot
1327
- * appear before the grants that create it (#835 review 3); releasing on a
1328
- * settled mask without the witness let a pane that registered late chmod after
1329
- * the grants had already verified — #835 again, first for the absent mask
1330
- * (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:
1331
1261
  *
1332
- * What the gate still cannot promise, the grant says for itself: each grant
1333
- * step verifies its own effective result and fails the transaction when a
1334
- * chmod lands inside its window, and a chmod later than that makes the
1335
- * identity pending again`doctor`'s `worker-acl` finding names it and an
1336
- * idempotent `setup host` re-run re-grants behind the full gate. A `getfacl`
1337
- * that could not read the path is not a settled mask it prints no mask
1338
- * because it printed nothing so it stays unsettled. A host whose herdr
1339
- * restores panes as plain shells — the `resume_agents_on_restore = false`
1340
- * headless shape, which the pane-shell merge writes and `doctor` blesses —
1341
- * restores *no* agent process, so no witness will ever come and nothing will
1342
- * ever chmod: the gate reads the agent list through its CLI envelope,
1343
- * `{"id":"cli:agent:list","result":{"agents":[…]}}`, the shape
1344
- * `parseHerdrAgents` reads, and treats a window in which `result.agents` was
1345
- * explicitly empty on *every* probe as a legitimate pass — the full window,
1346
- * never an early release. A list it could not read is not that window: a
1347
- * nonzero `herdr` exit or an unrecognized schema proves nothing about whether
1348
- * a pane is about to chmod, so it fails the gate on its own (#835 review 2)
1349
- * rather than buying the agentless pass. A host with no herdr at all restores
1350
- * no pane and needs no witness, so there a settled mask releases on its own
1351
- * two probes. If an agent is live but the mask never settles within the probe
1352
- * bound — a still-booting pane, or a mask that keeps changing — the gate exits
1353
- * 1 and the transaction stops: no grant is bet against a still-booting pane
1354
- * (fail closed, per #835 review 1).
1355
- */
1356
- export function paneSettleScript(
1357
- agentDir: string,
1358
- herdr: string | undefined,
1359
- session: string,
1360
- steps: number = AGENT_SETTLE_PROBE_STEPS,
1361
- intervalS: number = AGENT_SETTLE_PROBE_INTERVAL_S,
1362
- ): string {
1363
- const sq = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
1364
- const agentProbe = herdr === undefined ? "" : `${sq(herdr)} --session ${sq(session)} agent list`;
1365
- return [
1366
- `# The herdr restart returned once herdr's own process was up; the restored`,
1367
- `# OMP panes boot after it and each chmods the agent config dir to 0700 in`,
1368
- `# AgentStorage -- a chmod that rewrites the ACL mask. The grants that follow`,
1369
- `# this gate may only land once that startup chmod is behind us (#835).`,
1370
- `# A mask is settled when it is zeroed ('---', what a chmod leaves) or`,
1371
- `# absent (a dir with no extended ACL: nothing has been granted yet, so`,
1372
- `# there is no named entry a later chmod could strip -- #835 review 3).`,
1373
- `# Neither reading identifies *this* restart on its own: this host starts`,
1374
- `# with a zeroed mask left by an earlier grant and an earlier pane, and an`,
1375
- `# absent mask cannot render a chmod at all. So wherever a pane can exist,`,
1376
- `# a settled mask releases the grants only with a live agent to witness the`,
1377
- `# chmod -- see the loop below (#835 reviews 4 and 5). The command's status`,
1378
- `# is kept outside the pipe so a getfacl that cannot read the path stays`,
1379
- `# unsettled rather than passing as a mask-less dir.`,
1380
- `probe() { # prints the mask's perms; empty output = the ACL carries no mask`,
1381
- ` if ! acl=$(getfacl -p -c ${sq(agentDir)} 2>/dev/null); then return 1; fi`,
1382
- ` printf '%s\\n' "$acl" | sed -nE 's/^mask::([r-][w-][x-])$/\\1/p'`,
1383
- `}`,
1384
- ...(herdr === undefined
1385
- ? []
1386
- : [
1387
- `# 'herdr agent list' answers with its CLI envelope --`,
1388
- `# {"id":"cli:agent:list","result":{"agents":[...]}} -- and never a bare`,
1389
- `# array, so an explicitly empty list still prints a non-empty object and`,
1390
- `# a substring test for '[]' would read every host as agent-bearing. The`,
1391
- `# project's own parser (parseHerdrAgents) reads that envelope, but it is`,
1392
- `# TypeScript and this gate is a privileged shell step, so its contract is`,
1393
- `# reproduced here: result.agents non-empty is a live agent, an explicitly`,
1394
- `# empty result.agents is an agentless host, and everything else -- a`,
1395
- `# nonzero exit, no output, an unrecognized schema -- is unreadable. The`,
1396
- `# command's status is captured outside a pipe so a failure stays a`,
1397
- `# failure: an unreadable list is never an authoritative empty success,`,
1398
- `# because it cannot prove no pane will chmod the mask (#835 review 2).`,
1399
- `agents() { # 0 = an agent is live, 1 = an explicitly empty list, 2 = unreadable`,
1400
- ` if ! out=$(${agentProbe} 2>/dev/null); then return 2; fi`,
1401
- ` out=$(printf '%s' "$out" | tr -d ' \\t\\r\\n')`,
1402
- ` case "$out" in`,
1403
- ` *'"agents":[]'*) return 1 ;;`,
1404
- ` *'"agents":['*) return 0 ;;`,
1405
- ` *) return 2 ;;`,
1406
- ` esac`,
1407
- `}`,
1408
- ]),
1409
- `zero=0`,
1410
- ...(herdr === undefined ? [] : [`agents_seen=0`, `list_unreadable=0`]),
1411
- `for _ in $(seq 1 ${String(steps)}); do`,
1412
- ...(herdr === undefined
1413
- ? []
1414
- : [
1415
- ` # The agent list is read before the mask is judged: a settled mask is`,
1416
- ` # evidence only once a live agent has been observed, so the read that`,
1417
- ` # can establish that must come first (#835 reviews 4 and 5).`,
1418
- ` agents; rc=$?`,
1419
- ` if [ "$rc" -eq 0 ]; then agents_seen=1; elif [ "$rc" -eq 2 ]; then list_unreadable=1; fi`,
1420
- ]),
1421
- ` if mask=$(probe); then`,
1422
- ` case "$mask" in`,
1423
- // A zeroed mask and an absent one are the same verdict — settled — and on
1424
- // a herdr host they carry the same burden of proof.
1425
- ` ''|'---')`,
1426
- ...(herdr === undefined
1427
- ? [
1428
- // No herdr is no restored pane and no process that could ever chmod,
1429
- // so there is no witness to want: the mask speaks for itself.
1430
- ` zero=$((zero + 1))`,
1431
- ` if [ "$zero" -ge 2 ]; then exit 0; fi`,
1432
- ]
1433
- : [
1434
- ` # A settled mask needs a witness that the startup chmod is behind`,
1435
- ` # us, because neither settled shape can show one: this host begins`,
1436
- ` # with a mask an earlier grant and an earlier pane already zeroed,`,
1437
- ` # and an ACL-less dir has no mask to zero. A live agent is that`,
1438
- ` # witness -- herdr lists a pane as an agent once that pane's OMP`,
1439
- ` # process has registered, which is after the AgentStorage open that`,
1440
- ` # chmods. A list that has only ever been empty is indistinguishable`,
1441
- ` # from a pane that has not started yet, so it releases nothing`,
1442
- ` # (#835 reviews 4 and 5); a window that stays empty for its whole`,
1443
- ` # length is the headless pass below instead.`,
1444
- ` if [ "$agents_seen" = 1 ]; then`,
1445
- ` zero=$((zero + 1))`,
1446
- ` if [ "$zero" -ge 2 ]; then exit 0; fi`,
1447
- ` else`,
1448
- ` zero=0`,
1449
- ` fi`,
1450
- ]),
1451
- ` ;;`,
1452
- ` *) zero=0 ;;`,
1453
- ` esac`,
1454
- ` else`,
1455
- ` zero=0`,
1456
- ` fi`,
1457
- ` sleep ${String(intervalS)}`,
1458
- `done`,
1459
- ...(herdr === undefined
1460
- ? []
1461
- : [
1462
- // No agent existed at any probe, every probe read the list, and the
1463
- // mask never settled: the restart restored nothing that could chmod,
1464
- // so the grants are safe to land — the ever-clean shape is a pass,
1465
- // not a failure.
1466
- `if [ "$agents_seen" = 0 ] && [ "$list_unreadable" = 0 ]; then exit 0; fi`,
1467
- // A list we could not read is its own failure, distinct from an empty
1468
- // one: it proves nothing about whether a pane is about to chmod, so
1469
- // it may not buy the agentless pass.
1470
- `if [ "$agents_seen" = 0 ]; then`,
1471
- ` 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`,
1472
- ` exit 1`,
1473
- `fi`,
1474
- ]),
1475
- `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`,
1476
- `exit 1`,
1477
- ].join("\n");
1478
- }
1479
-
1480
- /**
1481
- * The worker account's primary gid on this host, resolved live from
1482
- * `/etc/passwd` — the account line's fourth field (`name:x:uid:gid:…`) — or
1483
- * `undefined` when it cannot be determined: an absent account, a malformed or
1484
- * unreadable passwd. `undefined` is not a gid: the harness directory can never
1485
- * be "current" while we cannot say which group it must accept.
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.
1486
1270
  */
1487
- export function resolveWorkerGroupGid(): number | undefined {
1488
- let line: string | undefined;
1489
- try {
1490
- line = readFileSync("/etc/passwd", "utf8")
1491
- .split("\n")
1492
- .find((entry) => entry.startsWith(`${WORKER_ACCOUNT}:`));
1493
- } catch {
1494
- 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
+ };
1495
1277
  }
1496
- if (line === undefined) return undefined;
1497
- const gid = Number.parseInt(line.split(":")[3] ?? "", 10);
1498
- return Number.isInteger(gid) && gid >= 0 ? gid : undefined;
1499
- }
1500
-
1501
- /** The live ownership/access facts the {@link WORKER_HARNESS_DIR} verdict
1502
- * compares and only those, so the decision stays pure of stat and
1503
- * privilege and every drift shape can be pinned hermetically. */
1504
- export interface HarnessDirFacts {
1505
- uid: number;
1506
- gid: number;
1507
- mode: number;
1508
- }
1509
-
1510
- /**
1511
- * Read {@link WORKER_HARNESS_DIR}'s facts for the currentness verdict, or
1512
- * `undefined` when the path is missing, unreadable, or not a directory —
1513
- * each of which must read pending (#831).
1514
- */
1515
- export function harnessDirFacts(dir: string = WORKER_HARNESS_DIR): HarnessDirFacts | undefined {
1516
- let st: Stats;
1517
- try {
1518
- st = statSync(dir);
1519
- } catch {
1520
- 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})` };
1521
1296
  }
1522
- if (!st.isDirectory()) return undefined;
1523
- return { uid: st.uid, gid: st.gid, mode: st.mode };
1524
- }
1525
-
1526
- /**
1527
- * Whether the harness directory is restricted exactly as the worker needs it
1528
- * (#831): owned by root (uid 0), its group the worker account's live primary
1529
- * gid, group read+execute, and no `other` bits — the `0750 root:<worker gid>`
1530
- * shape the install-d step converges to. `facts` `undefined` (a missing,
1531
- * unreadable, or non-directory path) or a `workerGid` `undefined` (an absent
1532
- * account or unreadable passwd) both read `false`: an unverifiable directory
1533
- * is pending, never "current". The old check read only the `other` bits and
1534
- * called `0750 root:root` current; this compares every dimension the worker's
1535
- * traversal depends on.
1536
- */
1537
- export function harnessDirRestricted(
1538
- facts: HarnessDirFacts | undefined,
1539
- workerGid: number | undefined,
1540
- ): boolean {
1541
- if (facts === undefined || workerGid === undefined) return false;
1542
- if (facts.uid !== 0 || facts.gid !== workerGid) return false;
1543
- if ((facts.mode & 0o050) !== 0o050) return false;
1544
- if ((facts.mode & 0o007) !== 0) return false;
1545
- return true;
1546
- }
1547
-
1548
- /**
1549
- * The production probes: the real host, read-only. Built from the fleet
1550
- * runtime so the agent-config source is the fleet account's own agent dir.
1551
- */
1552
- export function defaultIdentityProbes(runtime: { home: string; bun?: string }): WorkerIdentityProbes {
1553
- const agentDir = join(runtime.home, ".omp", "agent");
1554
- const workerAgentDir = join(WORKER_HOME_DIR, ".omp", "agent");
1555
- const dirExists = (path: string): boolean => {
1556
- try {
1557
- return statSync(path).isDirectory();
1558
- } catch {
1559
- return false;
1560
- }
1561
- };
1562
1297
  return {
1563
- linux: process.platform === "linux",
1564
- accountExists: passwdHasAccount(),
1565
- setfaclInstalled: existsSync("/usr/bin/setfacl"),
1566
- configFiles: WORKER_AGENT_LINK_FILES.filter((name) => existsSync(join(agentDir, name))).map(
1567
- (name) => join(agentDir, name),
1568
- ),
1569
- dirExists,
1570
- searchable: (path) => {
1571
- try {
1572
- return (statSync(path).mode & 0o001) !== 0;
1573
- } catch {
1574
- return false;
1575
- }
1576
- },
1577
- linkCurrent: (path, target) => {
1578
- try {
1579
- return lstatSync(path).isSymbolicLink() && readlinkSync(path) === target;
1580
- } catch {
1581
- return false;
1582
- }
1583
- },
1584
- aclCurrent: workerAclProbe,
1585
- // The provisioned runtime (#798 review 2): the worker owns a copy of the
1586
- // auth database (the harness writes perf/settings into it — a link would
1587
- // let the worker mutate the operator's), scoped copies of the gh
1588
- // credentials and git identity, and read-only binds for everything else.
1589
- agentDbPresent: existsSync(join(agentDir, "agent.db")),
1590
- workerAuthDbCurrent: existsSync(join(workerAgentDir, "agent.db")),
1591
- ghConfigPresent: existsSync(join(runtime.home, ".config", "gh", "hosts.yml")),
1592
- workerGhConfigCurrent: existsSync(join(WORKER_HOME_DIR, ".config", "gh", "hosts.yml")),
1593
- gitconfigPresent: existsSync(join(runtime.home, ".gitconfig")),
1594
- workerGitconfigCurrent: existsSync(join(WORKER_HOME_DIR, ".gitconfig")),
1595
- // Setup currentness is the effective import contract, not just mount
1596
- // metadata: after the inode check passes, execute the same anchored loader
1597
- // with --no-install through the real HOME/setpriv transition. A mounted
1598
- // tree whose Bun resolution still reaches the worker cache is pending.
1599
- harnessProblem: () =>
1600
- harnessBindingProblem() ?? workerHarnessImportProblem({ bun: runtime.bun ?? process.execPath }),
1601
- // The bound tree's reach (#828 review), hardened to the directory's whole
1602
- // shape (#831): the verdict is a live ownership/access check read fresh on
1603
- // every planning run — root-owned, group the worker's live primary gid,
1604
- // group read+execute, and no `other` bits. A re-run of `setup host` after
1605
- // an operator chown/chmod'd the mount point re-checks rather than trusting
1606
- // a stale verdict, and absent or unverifiable reads pending (the safe
1607
- // direction): systemd would create a missing mount point at 0755, so "not
1608
- // there yet" and "world-readable" have always shared one step.
1609
- 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",
1610
1303
  };
1611
1304
  }
1612
1305
 
1613
- export interface WorkerIdentityPlanOptions {
1614
- /** The daemon's state root (worktrees, mirrors, sessions live under it). */
1615
- stateRoot: string;
1616
- /** The fleet account's agent config dir. */
1617
- agentDir: string;
1618
- /** The directory holding the bun binary the worker sessions exec. */
1619
- bunRoot: string;
1620
- /** The node_modules root of the installed conductor package. */
1621
- packageRoot: string;
1622
- /** The fleet account's home: the source of the gh credentials and git
1623
- * identity the worker runtime is provisioned from. */
1624
- home: string;
1625
- probes?: WorkerIdentityProbes;
1626
- }
1627
-
1628
- /**
1629
- * The identity plan's outcome: the steps that establish it, and why any are
1630
- * still pending. {@link steps} and {@link grantSteps} together are the single
1631
- * source the install renders and executes, exactly like the host runtime's
1632
- * own steps.
1633
- */
1634
- export interface WorkerIdentityPlan {
1635
- account: string;
1636
- home: string;
1637
- /** The worker's harness agent dir (where the fleet config is bound). */
1638
- agentDir: string;
1639
- /** Dirs granted search access (`o+x`-style ACL), for the plan display. */
1640
- grantPaths: readonly string[];
1641
- /** Fleet agent config files granted read access, for the plan display. */
1642
- configFiles: readonly string[];
1643
- /** The steps that establish the identity before the daemon restart can
1644
- * resolve it: account creation, the agent-config bind, and the runtime
1645
- * provision. Prepended to the host plan's own steps. */
1646
- steps: readonly PrivilegedStep[];
1647
- /**
1648
- * The ACL grant steps, deliberately **not** in {@link steps}: they must
1649
- * execute *after* the transaction's final daemon and herdr restarts, behind
1650
- * the bounded pane settle gate {@link planHostRuntime} places ahead of them.
1651
- * An OMP startup chmods its agent config dir back to 0700 on every open —
1652
- * the harness's own `AgentStorage` boot — and a chmod rewrites the ACL
1653
- * mask, so a grant applied before the restarts is `user:omp-worker:--x
1654
- * #effective:---` by the time a worker reads it, and a grant applied before
1655
- * a restored pane has finished booting can still be zeroed moments later
1656
- * (#835). Each grant step also verifies its own effective result through
1657
- * getfacl and exits nonzero — stopping the transaction — when the ACL mask
1658
- * strips the worker's entry.
1659
- */
1660
- grantSteps: readonly PrivilegedStep[];
1661
- /** One item per pending change, in the order the steps would make them. */
1662
- pending: readonly string[];
1663
- /** True when every step is already satisfied — nothing to do. */
1664
- current: boolean;
1665
- }
1666
-
1667
1306
  /**
1668
- * The plan that establishes the dedicated worker identity on this host
1669
- * (#798), in the same shape as the rest of the host plan: privileged steps
1670
- * with human titles, a read-only "current" verdict, and the reasons anything
1671
- * is pending. The steps are idempotent by construction — the account step is
1672
- * guarded by `getent`, the bind uses `ln -sfn`, and `setfacl -m` converges —
1673
- * so a re-run of `setup host` converges rather than failing on a half-applied
1674
- * prior run.
1307
+ * What this version retires from the host, read from disk (#895).
1675
1308
  *
1676
- * The grants are deliberate and narrow: search access (`x`, never `r` or `w`)
1677
- * down the existing ancestor chains of the state root, the bun install and
1678
- * the package root (their own modes are already world-searchable), plus read
1679
- * access on the fleet's 0600 agent config files. The worker's writable world
1680
- * is what dispatch chowns to it per run — worktree and session dir — and
1681
- * nothing else. It cannot write daemon state (no grant writes), and it cannot
1682
- * migrate itself into a daemon-owned cgroup (every cgroup.procs is
1683
- * root-owned).
1684
- */
1685
- /**
1686
- * A shell guard the privileged worker-identity steps run first (#816): the
1687
- * worker owns everything under its home between setups, so a worker-planted
1688
- * symlink on any component of a directory root is about to create or hand
1689
- * back would redirect that mkdir / cp / chown to an arbitrary target — `chown
1690
- * omp-worker /etc`, or a copy written into it. Every component of each named
1691
- * target is checked, and the first symlink fails the whole step, naming the
1692
- * 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.
1312
+ *
1313
+ * Ordering is the whole content of the step list:
1693
1314
  *
1694
- * Executed verbatim in the step scripts, so the definition lives here, in the
1695
- * shape the tests read.
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.
1696
1330
  */
1697
- const REFUSE_SYMLINKED_TARGETS_SH = [
1698
- "refuse_symlink() {",
1699
- ' [ "$#" -eq 1 ] || return 1',
1700
- " target=$1",
1701
- " rest=${target#/}",
1702
- " cur=",
1703
- ' while [ -n "$rest" ]; do',
1704
- ' case "$rest" in',
1705
- " */*) next=${rest%%/*}; rest=${rest#*/} ;;",
1706
- " *) next=$rest; rest= ;;",
1707
- " esac",
1708
- " cur=$cur/$next",
1709
- ' if [ -L "$cur" ]; then printf "refusing: %s is a symlink\\n" "$cur" >&2; exit 1; fi',
1710
- " done",
1711
- "}",
1712
- ].join("\n");
1713
-
1714
- export function planWorkerIdentity(options: WorkerIdentityPlanOptions): WorkerIdentityPlan {
1715
- const probes = options.probes ?? {};
1716
- const linux = probes.linux ?? process.platform === "linux";
1717
- if (!linux) {
1718
- // The account/ACL machinery is util-linux: a non-Linux host cannot
1719
- // establish the identity, and dispatch there fails closed per launch. The
1720
- // plan says so rather than pretending nothing is missing.
1721
- return {
1722
- account: WORKER_ACCOUNT,
1723
- home: WORKER_HOME_DIR,
1724
- agentDir: options.agentDir,
1725
- grantPaths: [],
1726
- configFiles: [],
1727
- steps: [],
1728
- grantSteps: [],
1729
- pending: ["worker identity requires Linux (useradd/setfacl/setpriv); worker sessions fail closed here"],
1730
- current: false,
1731
- };
1732
- }
1733
- const dirExists = probes.dirExists ?? ((path: string) => existsSync(path));
1734
- const searchable =
1735
- probes.searchable ??
1736
- ((path: string) => {
1737
- try {
1738
- return (statSync(path).mode & 0o001) !== 0;
1739
- } catch {
1740
- return false;
1741
- }
1742
- });
1743
- const linkCurrent =
1744
- probes.linkCurrent ??
1745
- ((path: string, target: string) => {
1746
- try {
1747
- return lstatSync(path).isSymbolicLink() && readlinkSync(path) === target;
1748
- } catch {
1749
- return false;
1750
- }
1751
- });
1752
- const aclCurrent = probes.aclCurrent ?? workerAclProbe;
1753
-
1754
- const account = WORKER_ACCOUNT;
1755
- const home = WORKER_HOME_DIR;
1756
- const workerAgentDir = join(home, ".omp", "agent");
1757
- const configFiles = [...(probes.configFiles ?? [])];
1758
-
1759
- // The dirs that must be searchable by the worker: the existing ancestor
1760
- // chain of each root down to the first component that is not there yet (the
1761
- // daemon creates its own dirs searchable). This is what turns "cannot even
1762
- // reach /root" into "can reach exactly the granted trees".
1763
- const grantRoots = [options.stateRoot, options.agentDir, options.bunRoot, options.packageRoot];
1764
- const grantPaths: string[] = [];
1765
- for (const root of grantRoots) {
1766
- const parts = resolve(root)
1767
- .split("/")
1768
- .filter((part) => part !== "");
1769
- let cur = "/";
1770
- for (const part of parts) {
1771
- cur = cur === "/" ? `/${part}` : `${cur}/${part}`;
1772
- if (!dirExists(cur)) break;
1773
- if (!searchable(cur) && !grantPaths.includes(cur)) grantPaths.push(cur);
1774
- }
1775
- }
1776
-
1777
- const pending: string[] = [];
1778
- const accountExists = probes.accountExists ?? passwdHasAccount();
1779
- const setfaclInstalled = probes.setfaclInstalled ?? existsSync("/usr/bin/setfacl");
1780
-
1781
- if (!accountExists) {
1782
- pending.push(`the ${account} account does not exist yet (created as a system account with no login)`);
1783
- }
1784
- if (accountExists && !dirExists(workerAgentDir)) {
1785
- pending.push(`the worker agent dir ${workerAgentDir} is not bound to the fleet agent config yet`);
1786
- }
1787
- for (const name of WORKER_AGENT_LINK_FILES) {
1788
- if (!configFiles.some((f) => f === join(options.agentDir, name))) continue;
1789
- const link = join(workerAgentDir, name);
1790
- if (!linkCurrent(link, join(options.agentDir, name))) {
1791
- pending.push(`${link} does not resolve to the fleet's ${name}`);
1792
- }
1793
- }
1794
- // The provisioned worker runtime (#798 review 2): the worker owns a writable
1795
- // copy of the auth database (the harness writes into it — a read-only view
1796
- // would fail, a link would mutate the operator's), scoped copies of the gh
1797
- // credentials (HOME-scoped, 0600) and the git identity. A missing worker
1798
- // copy is pending whether or not the source exists: the message says which.
1799
- const workerGhHosts = join(home, ".config", "gh", "hosts.yml");
1800
- const workerGitconfig = join(home, ".gitconfig");
1801
- const workerAuthDb = join(workerAgentDir, "agent.db");
1802
- const authSource = join(options.agentDir, "agent.db");
1803
- const ghSource = join(options.home, ".config", "gh");
1804
- const gitSource = join(options.home, ".gitconfig");
1805
- const agentDbPresent = probes.agentDbPresent ?? existsSync(authSource);
1806
- const workerAuthDbCurrent = probes.workerAuthDbCurrent ?? existsSync(workerAuthDb);
1807
- const ghConfigPresent = probes.ghConfigPresent ?? existsSync(join(ghSource, "hosts.yml"));
1808
- const workerGhConfigCurrent = probes.workerGhConfigCurrent ?? existsSync(workerGhHosts);
1809
- const gitconfigPresent = probes.gitconfigPresent ?? existsSync(gitSource);
1810
- const workerGitconfigCurrent = probes.workerGitconfigCurrent ?? existsSync(workerGitconfig);
1811
- if (!workerAuthDbCurrent) {
1812
- pending.push(
1813
- `the worker auth database ${workerAuthDb} is not provisioned yet ` +
1814
- `(source ${authSource} ${agentDbPresent ? "exists" : "does not exist"})`,
1815
- );
1816
- }
1817
- if (!workerGhConfigCurrent) {
1818
- pending.push(
1819
- `the worker gh credentials ${workerGhHosts} are not provisioned yet ` +
1820
- `(source ${join(ghSource, "hosts.yml")} ${ghConfigPresent ? "exists" : "does not exist"})`,
1821
- );
1822
- }
1823
- if (!workerGitconfigCurrent) {
1824
- pending.push(
1825
- `the worker git identity ${workerGitconfig} is not provisioned yet ` +
1826
- `(source ${gitSource} ${gitconfigPresent ? "exists" : "does not exist"})`,
1827
- );
1828
- }
1829
- if (!setfaclInstalled) {
1830
- pending.push("/usr/bin/setfacl is not installed — the worker's path grants cannot be applied");
1831
- } else {
1832
- for (const path of grantPaths) {
1833
- if (!aclCurrent(path, "x")) pending.push(`search access for ${account} is not granted on ${path}`);
1834
- }
1835
- for (const file of configFiles) {
1836
- if (!aclCurrent(file, "r")) pending.push(`read access for ${account} is not granted on ${file}`);
1837
- }
1838
- }
1839
- // The harness binding (#828). Pending like any other missing piece of the
1840
- // identity, and for the same reason: without it a launched worker resolves a
1841
- // harness the operator never installed, so a host whose binding is absent is
1842
- // not a ready host however complete its account and grants are.
1843
- const harnessProblem =
1844
- probes.harnessProblem === undefined ? harnessBindingProblem() : probes.harnessProblem();
1845
- if (harnessProblem !== undefined) pending.push(harnessProblem);
1846
- // The bound tree's reach (#828 review), hardened to the directory's whole
1847
- // shape (#831). A bind shows its source's mode, so the mount point's parent
1848
- // is the only place to narrow it — and it is only "restricted" when root
1849
- // owns it, its group is the live worker group, the group can read+execute,
1850
- // and no other account can reach it (the `0750 root:<account>` shape). An
1851
- // open parent hands every local account the whole dependency tree, which
1852
- // the #798 boundary did not; a `0750 root:root` parent locks the worker out
1853
- // of the very install it must traverse. Both read pending.
1854
- if (!(probes.harnessDirRestricted ?? false)) {
1855
- pending.push(
1856
- `${WORKER_HARNESS_DIR} is not restricted to root and the ${account} group ` +
1857
- `(owner root, group ${account} with read+execute, and no other permissions, ` +
1858
- `so the worker can traverse the bound install and nobody else can)`,
1859
- );
1860
- }
1861
-
1862
- const sq = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
1863
- const refuseSymlink = (path: string): string => `refuse_symlink ${sq(path)}`;
1864
- const steps: PrivilegedStep[] = [];
1865
- // Account first: the bind and the grants name the account, so it must exist
1866
- // before they run; the daemon restart later in the batch then comes up
1867
- // already able to resolve it.
1868
- steps.push({
1869
- title: `create the ${account} worker account (unprivileged, no login, home ${home}) if missing`,
1870
- argv: [
1871
- "sh",
1872
- "-c",
1873
- `getent passwd ${account} >/dev/null 2>&1 || useradd --system --create-home ` +
1874
- `--home-dir ${sq(home)} --shell /usr/sbin/nologin --user-group ${account}`,
1875
- ],
1876
- });
1877
- steps.push({
1878
- title: `bind the fleet agent config into ${workerAgentDir}`,
1879
- argv: [
1880
- "sh",
1881
- "-c",
1882
- // One statement per link, never an `&&` chain: an absent optional
1883
- // fleet file skips only its own link and cannot abort the later links
1884
- // or the ownership hand-back below. The chown is unconditional — it is
1885
- // the step's point, not a trailing detail.
1886
- [
1887
- // The worker owns everything under its home, so any component of a
1888
- // directory root is about to mkdir or hand back may already be a
1889
- // worker-planted symlink; root follows it otherwise and redirects the
1890
- // whole step to the target (#816). Refused first, naming the link.
1891
- REFUSE_SYMLINKED_TARGETS_SH,
1892
- refuseSymlink(home),
1893
- refuseSymlink(join(home, ".omp")),
1894
- refuseSymlink(workerAgentDir),
1895
- `mkdir -p ${sq(workerAgentDir)}`,
1896
- `cd ${sq(workerAgentDir)}`,
1897
- ...WORKER_AGENT_LINK_FILES.map(
1898
- (name) =>
1899
- `if [ -f ${sq(join(options.agentDir, name))} ]; then ln -sfn ${sq(join(options.agentDir, name))} ${sq(name)}; fi`,
1900
- ),
1901
- ...WORKER_AGENT_LINK_DIRS.map(
1902
- (name) =>
1903
- `if [ -d ${sq(join(options.agentDir, name))} ]; then ln -sfn ${sq(join(options.agentDir, name))} ${sq(name)}; fi`,
1904
- ),
1905
- // The bind ran as root, so the freshly created dirs are root's — hand
1906
- // the worker's own state dirs back to the account (never -R: the
1907
- // bound links stay root-owned, and the fleet files they point at must
1908
- // not gain a writable owner through them). `-h` re-owns a symlink
1909
- // itself rather than the directory it points at (#816).
1910
- `chown -h ${account} ${sq(join(home, ".omp"))} ${sq(workerAgentDir)}`,
1911
- ].join("\n"),
1912
- ],
1913
- });
1914
- // The worker runtime (#798 review 2): writable worker-owned copies of the
1915
- // auth database (the harness persists credentials, usage and settings into
1916
- // it), the gh credentials and the git identity — everything a session needs
1917
- // to call a model, read the tracker through gh and commit. Each copy is
1918
- // guarded: an absent source waits for the operator, it never aborts the
1919
- // other copies or the ownership hand-back, and a re-run re-copies (converges).
1920
- const workerGhDir = join(home, ".config", "gh");
1921
- const own = (file: string): string => `if [ -e ${sq(file)} ]; then chown -h ${account} ${sq(file)}; fi`;
1922
- steps.push({
1923
- title: `provision the worker runtime: auth database, gh credentials, git identity`,
1924
- argv: [
1925
- "sh",
1926
- "-c",
1927
- [
1928
- // Every directory root this step creates, copies into or hands back,
1929
- // checked before anything follows (#816): the worker owns its home, so
1930
- // `.omp`, `.config` or `.cache` can already be a symlink pointing at
1931
- // an arbitrary root-owned target.
1932
- REFUSE_SYMLINKED_TARGETS_SH,
1933
- refuseSymlink(home),
1934
- refuseSymlink(join(home, ".omp")),
1935
- refuseSymlink(workerAgentDir),
1936
- refuseSymlink(join(home, ".config")),
1937
- refuseSymlink(workerGhDir),
1938
- refuseSymlink(join(home, ".cache")),
1939
- `mkdir -p ${sq(workerGhDir)} ${sq(join(home, ".cache", "gh"))}`,
1940
- // `--remove-destination` unlinks a worker-planted *destination*
1941
- // symlink before copying, so a copy lands as a fresh regular file
1942
- // instead of following the link to an arbitrary target (#816).
1943
- `if [ -f ${sq(authSource)} ]; then cp -f -p --remove-destination ${sq(authSource)} ${sq(workerAuthDb)}; fi`,
1944
- `if [ -f ${sq(`${authSource}-wal`)} ]; then cp -f -p --remove-destination ${sq(`${authSource}-wal`)} ${sq(`${workerAuthDb}-wal`)}; fi`,
1945
- `if [ -f ${sq(`${authSource}-shm`)} ]; then cp -f -p --remove-destination ${sq(`${authSource}-shm`)} ${sq(`${workerAuthDb}-shm`)}; fi`,
1946
- `if [ -f ${sq(join(ghSource, "hosts.yml"))} ]; then cp -f -p --remove-destination ${sq(join(ghSource, "hosts.yml"))} ${sq(workerGhHosts)}; fi`,
1947
- `if [ -f ${sq(join(ghSource, "config.yml"))} ]; then cp -f -p --remove-destination ${sq(join(ghSource, "config.yml"))} ${sq(join(workerGhDir, "config.yml"))}; fi`,
1948
- `if [ -f ${sq(gitSource)} ]; then cp -f -p --remove-destination ${sq(gitSource)} ${sq(workerGitconfig)}; fi`,
1949
- // The copies are the worker's own — never root's: the account writes
1950
- // its auth/usage data and runs gh/git under its own identity. `-h`:
1951
- // a worker-planted symlink at a directory is re-owned, not followed
1952
- // (#816).
1953
- `chown -h ${account} ${sq(join(home, ".config"))} ${sq(workerGhDir)} ${sq(join(home, ".cache"))}`,
1954
- own(workerAuthDb),
1955
- own(`${workerAuthDb}-wal`),
1956
- own(`${workerAuthDb}-shm`),
1957
- own(workerGhHosts),
1958
- own(join(workerGhDir, "config.yml")),
1959
- own(workerGitconfig),
1960
- ].join("\n"),
1961
- ],
1962
- });
1963
- // The ACL grants live in their own list, kept apart from the identity's
1964
- // pre-restart steps: they are the transaction's *last* executed steps, after
1965
- // the daemon and herdr restarts and the pane settle gate below, because an
1966
- // OMP startup chmods the agent config dir back to 0700 on every open and a
1967
- // chmod rewrites the ACL mask — a grant that runs before that is
1968
- // `#effective:---` by the time a worker is admitted (#835). The mask is set
1969
- // explicitly (`m::x` / `m::r`) so the re-grant defeats a zeroed mask without
1970
- // relying on setfacl recalculation, and the named entry is the worker
1971
- // account alone — no other local account gains anything. Each grant step
1972
- // *verifies* its own result afterwards through getfacl: the worker's entry
1973
- // must carry the granted bits and carry no `#effective:` annotation on the
1974
- // worker's own line (the mask stripping it — a pane that chmod'd after the
1975
- // settle gate). A grant
1976
- // that does not verify exits nonzero, which stops the setup transaction
1977
- // rather than leaving workers to be admitted under a dead ACL.
1978
- const grantSteps: PrivilegedStep[] = [];
1979
- if (grantPaths.length > 0) {
1980
- const all = grantPaths.map(sq).join(" ");
1981
- grantSteps.push({
1982
- title: `grant ${account} search access to the fleet paths a worker session needs`,
1983
- argv: [
1984
- "sh",
1985
- "-c",
1986
- [
1987
- `setfacl -m ${sq(`m::x,u:${account}:x`)} ${all}`,
1988
- `for p in ${all}; do`,
1989
- ` [ -e "$p" ] || continue`,
1990
- ` out=$(getfacl -p -c "$p" 2>/dev/null)`,
1991
- // The `#effective:` probe is scoped to the worker's own entry line:
1992
- // a group or other named entry that the explicit mask narrows also
1993
- // renders an annotation (`group::r-x #effective:--x`) without the
1994
- // worker's grant being dead — matching the whole dump would fail a
1995
- // perfectly effective grant on any path whose owner group class
1996
- // carries bits the mask narrows.
1997
- ` if [ -z "$out" ] || ! printf '%s\\n' "$out" | grep -q "user:${account}:--x" || printf '%s\\n' "$out" | grep -qE "user:${account}:--x[[:space:]]+#effective:"; then`,
1998
- ` 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`,
1999
- ` exit 1`,
2000
- ` fi`,
2001
- `done`,
2002
- ].join("\n"),
2003
- ],
2004
- });
2005
- }
2006
- if (configFiles.length > 0) {
2007
- const all = configFiles.map(sq).join(" ");
2008
- grantSteps.push({
2009
- title: `grant ${account} read access to the fleet agent config files`,
2010
- argv: [
2011
- "sh",
2012
- "-c",
2013
- // One statement per file, never an `&&` chain: a fleet file that
2014
- // vanished since the plan was rendered skips only its own grant and
2015
- // cannot abort the grants for the files that are still there. Same
2016
- // verification as the search grant: the pass is only a pass when the
2017
- // worker's read entry is effective, never when the mask keeps it out.
2018
- [
2019
- ...configFiles.map(
2020
- (file) =>
2021
- `if [ -f ${sq(file)} ]; then setfacl -m ${sq(`m::r,u:${account}:r`)} ${sq(file)}; fi`,
2022
- ),
2023
- `for p in ${all}; do`,
2024
- ` [ -f "$p" ] || continue`,
2025
- ` out=$(getfacl -p -c "$p" 2>/dev/null)`,
2026
- // Same worker-entry scoping as the search grant: an annotation on
2027
- // another line never fails the worker's own effective read.
2028
- ` if [ -z "$out" ] || ! printf '%s\\n' "$out" | grep -q "user:${account}:r--" || printf '%s\\n' "$out" | grep -qE "user:${account}:r--[[:space:]]+#effective:"; then`,
2029
- ` 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`,
2030
- ` exit 1`,
2031
- ` fi`,
2032
- `done`,
2033
- ].join("\n"),
2034
- ],
2035
- });
2036
- }
2037
-
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;
2038
1337
  return {
2039
- account,
2040
- home,
2041
- agentDir: workerAgentDir,
2042
- grantPaths,
2043
- configFiles,
2044
- steps,
2045
- grantSteps,
2046
- pending,
2047
- 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
+ ],
2048
1365
  };
2049
1366
  }
2050
1367
 
2051
- /** The identity plan as the host runtime plan carries it for display. */
2052
- export interface WorkerIdentityPlanState {
2053
- account: string;
2054
- home: string;
2055
- agentDir: string;
2056
- current: boolean;
2057
- pending: readonly string[];
2058
- }
2059
-
2060
1368
  function planTick(project: ProjectConfig, telegramStateDir: string): {
2061
1369
  write: PlannedWrite<TickConfig>;
2062
1370
  /** Set when the config was restamped from the shared default: the live herdr
@@ -2133,11 +1441,6 @@ export function planHostRuntime(
2133
1441
  // host-global, so on a multi-project host it must not encode one project's
2134
1442
  // name — and a no-project install never does (#510/#530).
2135
1443
  multiProject: boolean = false,
2136
- // The worker identity plan (#798). `undefined`/`null` plans none (the
2137
- // historical surface — every existing caller); `runHostInstall` passes the
2138
- // real probes (or an explicit disable), so the consent the operator
2139
- // approves names every account and grant change the install will make.
2140
- identityProbes?: WorkerIdentityProbes | null,
2141
1444
  ): HostRuntimePlan {
2142
1445
  const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
2143
1446
  const serviceContent = renderDaemonService(runtime, totalWorkers);
@@ -2285,87 +1588,14 @@ export function planHostRuntime(
2285
1588
  // The worker identity plan (#798): account creation, the agent-config bind,
2286
1589
  // the runtime provision, and the search/read ACL grants are host changes
2287
1590
  // the same install consent covers, so they join the same single step list.
2288
- // The identity's own steps are prepended the account must exist before
2289
- // the bind and provision name it, and before the daemon restart below comes
2290
- // up resolving it. The ACL grants are appended *after* the transaction's
2291
- // final restarts instead — behind a bounded settle gate, because an OMP
2292
- // startup chmods the agent config dir back to 0700 (resetting the ACL
2293
- // mask), so a grant placed before the restarts is named-but-ineffective by
2294
- // the time a worker reads it, and a grant placed before a pane has finished
2295
- // booting can still be zeroed moments later (#835, review 1). A re-run
2296
- // whose only pending work is the identity still runs the (idempotent) steps
2297
- // and restarts — and ends with the grants effective again.
2298
- const fleetAgentDir = join(runtime.home, ".omp", "agent");
2299
- const harnessSource = packageRootOf(runtime.packageCli);
2300
- // The bind's source must be a real install root, not just "wherever the CLI
2301
- // lives": a source checkout has no `node_modules` ancestor, and binding its
2302
- // `src/` at a path named `node_modules` would produce a resolution root with
2303
- // no packages in it. There the binding cannot be established at all, which is
2304
- // what the identity plan's pending reason says — so plan no unit rather than
2305
- // install one that could never work.
2306
- const harnessInstallRoot = packageNodeModulesRoot(runtime.packageCli);
2307
- const identityPlan: WorkerIdentityPlan | undefined =
2308
- identityProbes === undefined || identityProbes === null
2309
- ? undefined
2310
- : planWorkerIdentity({
2311
- stateRoot: runtime.conductorHome,
2312
- agentDir: fleetAgentDir,
2313
- bunRoot: dirname(runtime.bun),
2314
- packageRoot: harnessSource,
2315
- home: runtime.home,
2316
- probes: identityProbes,
2317
- });
2318
- // The harness binding (#828) exists only to serve that identity, so it is
2319
- // planned exactly when the identity can be established at all — a host that
2320
- // cannot run the account machinery (the non-Linux plan, which stages no
2321
- // steps) has nothing to bind it for.
2322
- const harnessMountPath = join(stateDir(), HARNESS_MOUNT_UNIT_NAME);
2323
- const harnessMount: PlannedWrite<string> | undefined =
2324
- harnessInstallRoot === undefined || identityPlan === undefined || identityPlan.steps.length === 0
2325
- ? undefined
2326
- : (() => {
2327
- const content = renderHarnessMountUnit(harnessInstallRoot);
2328
- return {
2329
- path: harnessMountPath,
2330
- action: actionFor(harnessMountPath, content),
2331
- content,
2332
- value: content,
2333
- };
2334
- })();
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);
2335
1594
  // Recovery first: the daemon unit that follows names it in OnFailure=, so
2336
1595
  // the restart below must never point at a unit systemd cannot load. This is
2337
1596
  // the single list `runHostInstall` executes and {@link installCommands}
2338
1597
  // renders from, so no step can be in one and missing from the other (#509).
2339
1598
  const installSteps: PrivilegedStep[] = [
2340
- // The binding first: everything after it may restart the daemon, and a
2341
- // daemon that comes up before the mount exists refuses every worker launch
2342
- // until something restarts it again (#828). `enable --now` mounts it in the
2343
- // same step it makes persistent; both halves are idempotent, and neither
2344
- // unmounts a live bind out from under a running worker.
2345
- ...(harnessMount === undefined
2346
- ? []
2347
- : [
2348
- // The mount point's own directory, created before systemd would
2349
- // create it 0755. A bind shows the *source* directory's mode, so the
2350
- // only place the bound tree's reach can be narrowed is the parent:
2351
- // `0750 root:omp-worker` leaves it enumerable by root and the worker
2352
- // account and by nobody else, which is the #798 posture the binding
2353
- // must not widen. `install -d` applies owner and mode to a directory
2354
- // that already exists, so a re-run converges.
2355
- {
2356
- title: `restrict ${WORKER_HARNESS_DIR} to root and the ${WORKER_ACCOUNT} account`,
2357
- argv: ["install", "-d", "-o", "root", "-g", WORKER_ACCOUNT, "-m", "0750", WORKER_HARNESS_DIR],
2358
- },
2359
- {
2360
- title: `install ${HARNESS_MOUNT_UNIT_NAME} (binds ${harnessInstallRoot} read-only at ${WORKER_HARNESS_NODE_MODULES})`,
2361
- argv: ["install", "-m", "0644", harnessMountPath, join(unitDir, HARNESS_MOUNT_UNIT_NAME)],
2362
- },
2363
- { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
2364
- {
2365
- title: `enable and mount ${HARNESS_MOUNT_UNIT_NAME}`,
2366
- argv: ["systemctl", "enable", "--now", HARNESS_MOUNT_UNIT_NAME],
2367
- },
2368
- ]),
2369
1599
  {
2370
1600
  title: "install the recovery playbook",
2371
1601
  argv: ["install", "-m", "0755", recoverScriptPath, recoverScriptInstallPath],
@@ -2425,38 +1655,11 @@ export function planHostRuntime(
2425
1655
  ]),
2426
1656
  ];
2427
1657
  const installStepsAll: PrivilegedStep[] = [
2428
- ...(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),
2429
1662
  ...installSteps,
2430
- // The ACL grants ride *last* — after the daemon and herdr restarts above —
2431
- // because an OMP startup chmods its agent config dir back to 0700 and a
2432
- // chmod rewrites the ACL mask: a grant applied before the restart is
2433
- // `user:omp-worker:--x #effective:---` by the time a worker reads it (#835).
2434
- // The herdr restart returns once herdr's own process is up, not once its
2435
- // restored OMP panes have booted — so on a herdr host the grants wait on a
2436
- // bounded settle gate that observes the pane's startup chmod in the ACL
2437
- // mask before any setfacl may run (#835 review 1). The grant steps then
2438
- // verify their own effective result, and a dead grant stops the
2439
- // transaction rather than admitting workers under it.
2440
- ...(identityPlan === undefined || identityPlan.grantSteps.length === 0
2441
- ? []
2442
- : [
2443
- ...(runtime.herdr === undefined
2444
- ? []
2445
- : [
2446
- {
2447
- title: `wait for the fleet agent panes to settle after the herdr restart (bounded readiness gate)`,
2448
- // The pane settle gate is a required step: a pane whose
2449
- // startup chmod does not settle must fail the transaction
2450
- // rather than let a grant land under a still-booting pane.
2451
- argv: [
2452
- "sh",
2453
- "-c",
2454
- paneSettleScript(fleetAgentDir, runtime.herdr, runtime.herdrSession ?? DEFAULT_HERDR_SESSION),
2455
- ],
2456
- },
2457
- ]),
2458
- ...identityPlan.grantSteps,
2459
- ]),
2460
1663
  ];
2461
1664
  // Everything the privileged steps install is already at its destination with
2462
1665
  // the current bytes, so a re-run of `setup host` has nothing to install and
@@ -2475,10 +1678,6 @@ export function planHostRuntime(
2475
1678
  noteDrift(join(unitDir, RECOVER_SERVICE_NAME), recoverUnitContent);
2476
1679
  noteDrift(recoverScriptInstallPath, recoverScriptContent);
2477
1680
  if (herdrUnit !== undefined) noteDrift(installedHerdr, herdrUnit.content);
2478
- // The harness binding's own unit: a host whose mount unit is missing or
2479
- // rewritten by a release is not a current install, however current every
2480
- // other destination is (#828).
2481
- if (harnessMount !== undefined) noteDrift(join(unitDir, HARNESS_MOUNT_UNIT_NAME), harnessMount.content);
2482
1681
  // The pane-shell file is a destination like the units: a plan with all
2483
1682
  // units current but the config merge still pending must not report
2484
1683
  // "nothing to install" and skip the very write it exists to make.
@@ -2486,22 +1685,24 @@ export function planHostRuntime(
2486
1685
  // Same for the herdr-conductor config.env: a pending env merge is pending
2487
1686
  // work, not an already-current install.
2488
1687
  if (herdrEnv !== undefined) noteDrift(herdrEnvTarget, herdrEnv.content);
2489
- // A pending worker identity is pending work like any drifted file: the
2490
- // install gate must not call a host "current" whose worker sessions would
2491
- // fail closed the moment they dispatch (#798).
2492
- 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;
2493
1693
  return {
2494
1694
  service,
2495
1695
  // The herdr unit and pane-shell config stand and fall together: no herdr, no
2496
1696
  // session to supervise, nothing for a pane shell to belong to. (The pane
2497
1697
  // shell is omitted too when the login shell is unusable or the rendered
2498
1698
  // config would not parse — those plans carry {@link herdrConfigProblem}.)
2499
- ...(herdrUnit === undefined ? {} : { herdrUnit }),
1699
+ ...(herdrUnit === undefined
1700
+ ? {}
1701
+ : { herdrUnit, herdrSession: runtime.herdrSession ?? DEFAULT_HERDR_SESSION }),
2500
1702
  ...(herdrConfig === undefined ? {} : { herdrConfig, herdrConfigTarget }),
2501
1703
  ...(herdrConfigProblem === undefined ? {} : { herdrConfigProblem }),
2502
1704
  ...(herdrConfigWarning === undefined ? {} : { herdrConfigWarning }),
2503
1705
  ...(herdrEnv === undefined ? {} : { herdrEnv, herdrEnvTarget }),
2504
- ...(harnessMount === undefined ? {} : { harnessMount }),
2505
1706
  recoverUnit,
2506
1707
  recoverScript,
2507
1708
  ...(project === undefined
@@ -2544,7 +1745,7 @@ export function planHostRuntime(
2544
1745
  installedPath,
2545
1746
  installedAction,
2546
1747
  drift,
2547
- ...(identityPlan === undefined ? {} : { workerIdentity: identityPlan }),
1748
+ ...(retire === undefined ? {} : { retire }),
2548
1749
  currentInstall,
2549
1750
  };
2550
1751
  }
@@ -2556,9 +1757,12 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
2556
1757
  ` daemon entry ${plan.cliSource === "global" ? "installed omp-conductor CLI" : "current installed plugin"}`,
2557
1758
  ` recovery ${plan.recoverUnit.action} ${plan.recoverUnit.path}`,
2558
1759
  ` recovery exec ${plan.recoverScript.action} ${plan.recoverScript.path} -> ${RECOVER_SCRIPT_INSTALL_PATH}`,
2559
- ...(plan.harnessMount === undefined
1760
+ ...(plan.retire === undefined
2560
1761
  ? []
2561
- : [` 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
+ ]),
2562
1766
  ...(plan.herdrUnit === undefined
2563
1767
  ? [" herdr session skipped — herdr not installed on this host"]
2564
1768
  : [
@@ -2598,27 +1802,10 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
2598
1802
  : ` brief link ${plan.briefLink.action} ${plan.briefLink.path} -> ${plan.briefLink.target}`,
2599
1803
  );
2600
1804
  }
2601
- if (plan.workerIdentity !== undefined) {
2602
- lines.push(
2603
- plan.workerIdentity.current
2604
- ? ` worker identity ${plan.workerIdentity.account} (home ${plan.workerIdentity.home}) — present, worker paths granted`
2605
- : ` worker identity ${plan.workerIdentity.account} (home ${plan.workerIdentity.home}) — pending: ${plan.workerIdentity.pending.join("; ")}`,
2606
- );
2607
- }
2608
1805
  lines.push(" install staged only; the final result prints the systemd install commands");
2609
1806
  return lines.join("\n");
2610
1807
  }
2611
1808
 
2612
- /** The node_modules root an installed package CLI lives under — the ancestor
2613
- * of `packageCli` named `node_modules`. This is the root the worker needs
2614
- * search access to and the source of its harness binding; granting the one
2615
- * package dir would miss its dependency tree, and granting the parent of
2616
- * node_modules would grant far more. A source checkout has no such ancestor,
2617
- * and the package's own directory is the closest honest answer. */
2618
- function packageRootOf(packageCli: string): string {
2619
- return packageNodeModulesRoot(packageCli) ?? dirname(resolve(packageCli));
2620
- }
2621
-
2622
1809
  function atomicWrite(path: string, content: string, mode: number): void {
2623
1810
  mkdirSync(dirname(path), { recursive: true });
2624
1811
  const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
@@ -2652,10 +1839,6 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
2652
1839
  atomicWrite(plan.herdrUnit.path, plan.herdrUnit.content, 0o644);
2653
1840
  wrote.push(plan.herdrUnit.path);
2654
1841
  }
2655
- if (plan.harnessMount !== undefined && plan.harnessMount.action !== "keep") {
2656
- atomicWrite(plan.harnessMount.path, plan.harnessMount.content, 0o644);
2657
- wrote.push(plan.harnessMount.path);
2658
- }
2659
1842
  if (plan.herdrConfig !== undefined && plan.herdrConfig.action !== "keep") {
2660
1843
  atomicWrite(plan.herdrConfig.path, plan.herdrConfig.content, 0o644);
2661
1844
  wrote.push(plan.herdrConfig.path);