omp-conductor 0.4.5 → 0.5.1

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.
package/src/upgrade.ts CHANGED
@@ -7,11 +7,8 @@ import { configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from
7
7
  import { renderBriefForProject } from "./setup.ts";
8
8
  import {
9
9
  STAGED_SERVICE_NAME,
10
- parseEffectiveUnit,
11
10
  planHostRuntime,
12
- unitDriftReason,
13
11
  writeHostRuntime,
14
- type EffectiveUnit,
15
12
  } from "./setup-host.ts";
16
13
 
17
14
  const PACKAGE = "omp-conductor";
@@ -41,15 +38,6 @@ export interface UpgradeResult {
41
38
  dispatch: DispatchLayer;
42
39
  }
43
40
 
44
- /** What the unit check reads, and how it repairs what it can. */
45
- export interface UnitFiles {
46
- rendered: string;
47
- /** What systemd loaded, not what is on disk. Undefined means no systemd here. */
48
- effective: EffectiveUnit | undefined;
49
- /** Writes the corrected unit to the state dir; returns the commands root must run. */
50
- stage?(): readonly string[];
51
- }
52
-
53
41
  export interface UpgradeDeps {
54
42
  run(command: string, args: readonly string[]): Promise<UpgradeCommandResult>;
55
43
  snapshot(project?: string): { liveWorkers: number };
@@ -61,12 +49,6 @@ export interface UpgradeDeps {
61
49
  sleep(ms: number): Promise<void>;
62
50
  env: NodeJS.ProcessEnv;
63
51
  log(message: string): void;
64
- /**
65
- * What this version would render as the unit, and what systemd actually
66
- * booted from. Injected so the drift check is testable without a systemd host
67
- * — and defaulted, so no production caller has to know it exists.
68
- */
69
- unitFiles?(): Promise<UnitFiles | undefined> | UnitFiles | undefined;
70
52
  }
71
53
 
72
54
  async function runCommand(command: string, args: readonly string[]): Promise<UpgradeCommandResult> {
@@ -450,51 +432,6 @@ async function rollbackUpgrade(
450
432
  if (failures.length > 0) throw new Error(failures.join("; "));
451
433
  }
452
434
 
453
- /**
454
- * The rendered-vs-installed pair for {@link unitDriftReason}.
455
- *
456
- * Best-effort by design: a host with no config, no project, or no systemd is
457
- * not a host with a broken unit, and an upgrade must not fail because it could
458
- * not answer a question that does not apply there.
459
- */
460
- async function defaultUnitFiles(deps: UpgradeDeps): Promise<UnitFiles | undefined> {
461
- try {
462
- const cfg = loadConfig();
463
- const project = cfg.projects[0];
464
- if (project === undefined) return undefined;
465
- const plan = planHostRuntime(project, resolveCaps(project, cfg.defaults), telegramStateDir());
466
- const shown = await deps.run("systemctl", [
467
- "show",
468
- STAGED_SERVICE_NAME,
469
- "-p",
470
- "AmbientCapabilities",
471
- "-p",
472
- "CapabilityBoundingSet",
473
- "-p",
474
- "SupplementaryGroups",
475
- "-p",
476
- "Environment",
477
- "-p",
478
- "NeedDaemonReload",
479
- ]);
480
- return {
481
- rendered: plan.service.content,
482
- effective: shown.code === 0 ? parseEffectiveUnit(shown.stdout) : undefined,
483
- // Staging needs no privilege, so the repair is reduced to the two lines
484
- // that genuinely do. Installing the unit is root's, and this command runs
485
- // as the fleet user by design — so it goes as far as it can and then says
486
- // exactly what is left, rather than sending the operator back through a
487
- // wizard to regenerate a file it could write itself.
488
- stage: () => {
489
- writeHostRuntime(plan);
490
- return plan.installCommands;
491
- },
492
- };
493
- } catch {
494
- return undefined;
495
- }
496
- }
497
-
498
435
  export async function upgradeConductor(
499
436
  options: UpgradeOptions = {},
500
437
  overrides: Partial<UpgradeDeps> = {},
@@ -521,22 +458,6 @@ export async function upgradeConductor(
521
458
  const surfaces = await inspectSurfaces(deps);
522
459
  const brief = deps.brief(project);
523
460
  const installNeeded = !surfacesCurrent(surfaces, release.version, release.gitHead);
524
- // Computed BEFORE the no-op decision, not inside verification, and that
525
- // placement is the whole point. The upgrade that installs a version is run by
526
- // the *previous* CLI, so a check living only in the new code never executes
527
- // for the release that introduces it — and re-running afterwards would take
528
- // the already-current early return and skip it forever. A fleet whose unit is
529
- // stale is not current, whatever its package identities say.
530
- const files = await (deps.unitFiles === undefined ? defaultUnitFiles(deps) : deps.unitFiles());
531
- const drift = files === undefined ? undefined : unitDriftReason(files.rendered, files.effective);
532
- if (drift !== undefined) {
533
- const commands = files?.stage?.() ?? [];
534
- throw new Error(
535
- commands.length === 0
536
- ? drift
537
- : `${drift}\n\nThe corrected unit has been staged. Run:\n${commands.map((c) => ` ${c}`).join("\n")}`,
538
- );
539
- }
540
461
  if (!installNeeded && brief.current) {
541
462
  return {
542
463
  previousVersion: surfaces.cliVersion,
@@ -631,17 +552,6 @@ export async function upgradeConductor(
631
552
  const active = await mustRun(deps, "systemctl", ["is-active", HERDR_UNIT]);
632
553
  if (active.stdout.trim() !== "active") throw new Error(`${HERDR_UNIT} is not active after restart`);
633
554
  }
634
- // The unit is the one surface `upgrade` never looked at, and since 0.4.0 it
635
- // is the difference between a working per-run fleet and one that refuses
636
- // every issue. Fatal rather than a warning: the whole point of this command
637
- // is that a fleet is either upgraded or explicitly left paused, and a
638
- // "verified" fleet that cannot dispatch is the worse outcome.
639
- // Re-read rather than trust the earlier pass: the daemon has restarted
640
- // since, and an operator who installed a unit mid-upgrade should not get a
641
- // green verification for a file nobody checked.
642
- const after = await (deps.unitFiles === undefined ? defaultUnitFiles(deps) : deps.unitFiles());
643
- const stillDrifted = after === undefined ? undefined : unitDriftReason(after.rendered, after.effective);
644
- if (stillDrifted !== undefined) throw new Error(stillDrifted);
645
555
  await waitForRecovery(deps, initial, project);
646
556
  deps.log("verify 2/2: recovered fleet remains stable");
647
557
  await deps.sleep(1_000);
@@ -14,7 +14,7 @@
14
14
  * enough to actually write.
15
15
  */
16
16
 
17
- import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor } from "../credentials.ts";
17
+ import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor } from "../gitops.ts";
18
18
  import type { ProjectConfig, ReleaseShape } from "../types.ts";
19
19
  import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
20
20
 
@@ -51,21 +51,11 @@ import {
51
51
  secureBoundSocket,
52
52
  socketFd,
53
53
  unlinkStaleSocket,
54
- traversalProblem,
55
54
  validateSocketPath,
56
55
  type PeerReader,
57
56
  type SocketOwnership,
58
57
  } from "./socket.ts";
59
58
 
60
- /**
61
- * The OS principal a run was allocated (#125).
62
- *
63
- * Imported as a type from the credential boundary rather than redeclared, so
64
- * there is one definition of "which uid is this run": a second copy is a second
65
- * place identity is decided, which is the bug both issues exist to remove.
66
- */
67
- import type { SlotPrincipal } from "../credentials.ts";
68
-
69
59
  /**
70
60
  * How long a merge lock may be held before another daemon may break it.
71
61
  *
@@ -101,17 +91,15 @@ export type VerbChannel =
101
91
  issue: number;
102
92
  /** The routed checkout, for the privileged push/PR half. */
103
93
  repo: RepoTarget;
104
- /** The run's own repository on disk (#125's per-run clone). */
94
+ /** The run's own repository on disk. */
105
95
  runRepoPath: string;
106
96
  branch: string;
107
- principal?: SlotPrincipal;
108
97
  }
109
98
  | {
110
99
  kind: "orchestrator";
111
100
  path: string;
112
101
  project: string;
113
102
  role: "orchestrator";
114
- principal?: SlotPrincipal;
115
103
  };
116
104
 
117
105
  export type ActionOutcome = { ok: true; sha?: string; detail?: string } | { ok: false; stderr: string };
@@ -946,13 +934,6 @@ export interface ListenOptions {
946
934
  daemonUid?: number;
947
935
  /** Injected in tests; the real one chowns, which needs privilege. */
948
936
  secure?: typeof secureBoundSocket;
949
- /**
950
- * Injected in tests for the same reason `secure` is: the traversal rule is
951
- * about the *deployment's* directory chain, and a test that needs a bound
952
- * socket in a developer's `0750` home is not the case it is asserting. The
953
- * rule itself is pinned directly in `socket.test.ts`.
954
- */
955
- traversal?: typeof traversalProblem;
956
937
  }
957
938
 
958
939
  /**
@@ -978,20 +959,6 @@ export async function listenVerbChannel(
978
959
  "owned by the daemon (or root), free of symlinks, and unwritable by anyone else.",
979
960
  );
980
961
  }
981
- // Traversal, proven rather than assumed, and only when it can actually fail:
982
- // a run with its own principal reaches this socket by searching every
983
- // component, and the daemon's state directory is 0700. Refusing here names
984
- // the directory and the fix; letting it bind would produce an EACCES inside
985
- // a worker turn, about a path nobody was thinking about.
986
- if (channel.principal !== undefined) {
987
- const blocked = (opts.traversal ?? traversalProblem)(channel.path);
988
- if (blocked !== undefined) {
989
- throw new VerbSocketRefusal(
990
- `dispatch refused: ${blocked.message}. This run has its own OS principal, so it must be able to ` +
991
- "traverse to its socket; binding one it cannot reach would take its verbs away silently.",
992
- );
993
- }
994
- }
995
962
  // Only the daemon ever unlinks these, and it has just proven the parent is
996
963
  // not writable by anyone else — which is what makes unlink-then-bind safe
997
964
  // here and a race in a world-writable directory.
@@ -1013,7 +980,7 @@ export async function listenVerbChannel(
1013
980
  });
1014
981
 
1015
982
  const secure = opts.secure ?? secureBoundSocket;
1016
- const ownership = secure(channel.path, channel.principal);
983
+ const ownership = secure(channel.path);
1017
984
 
1018
985
  return {
1019
986
  path: channel.path,
@@ -1037,7 +1004,7 @@ function handleConnection(
1037
1004
  ): void {
1038
1005
  const fd = socketFd(socket);
1039
1006
  const peer = fd === undefined || peerReader === undefined ? undefined : peerReader(fd);
1040
- const verdict = peerVerdict(channel.principal, peer);
1007
+ const verdict = peerVerdict({ uid: process.getuid?.() ?? 0 }, peer);
1041
1008
  if (!verdict.ok) {
1042
1009
  // Not a client error, and deliberately not answered: a caller who is not
1043
1010
  // who the socket was allocated to gets no reply to calibrate against.
@@ -1,28 +1,34 @@
1
1
  /**
2
2
  * The authenticated local transport the mutation verbs ride on (#126).
3
3
  *
4
- * The permission layout is the whole security argument, and the obvious one
5
- * does not work. A socket `chmod 0600` and `chown`ed to a run's principal is
6
- * *unreachable* if its parent is the daemon's usual `0700`: connecting needs
7
- * **search** (`+x`) on every path component, not read. So:
4
+ * **What the permission layout does and does not buy, stated plainly.** Every
5
+ * session is a child process running as the daemon's own uid. So the modes here
6
+ * keep *other local accounts* out; they do not restrain a session, because a
7
+ * session matches the owner class and owner bits are `rwx`. A session that
8
+ * wanted to list the socket directory, unlink a sibling's socket and bind an
9
+ * impostor in its place could do so, and nothing in this file would stop it.
8
10
  *
9
- * - the parent directory is daemon-owned and mode **`0711`**searchable by
10
- * run principals, listable by none, and **writable by none but the daemon**;
11
- * - each socket is `0600`, owned by the principal of the run it belongs to, so
12
- * exactly one uid can connect, and the orchestrator's is a third distinct one;
13
- * - the parent being non-writable is what stops a run unlinking a sibling's
14
- * socket or binding an impostor listener in its place. A per-run directory
15
- * owned by the run principal would hand back exactly that power, and is why
16
- * the obvious layout is not used here.
11
+ * That is not a hole this file is hiding it is the consequence of running
12
+ * sessions as the daemon. What restrains a session is the mechanical worktree
13
+ * gate on its structured tools plus the fact that publication only happens
14
+ * through the daemon, and what makes a bad push *visible* is the verb ledger.
17
15
  *
18
- * Two further rules make that layout hold rather than merely describe it:
19
- * {@link validateSocketPath} refuses to bind under a path anyone else could
20
- * have tampered with, and {@link peerVerdict} compares the connecting uid the
21
- * kernel reports against the uid the daemon allocated for that run.
16
+ * So the layout is:
22
17
  *
23
- * Every decision in this file is a pure function of `lstat` results or of a
24
- * credentials struct, so the adversarial cases are testable without root and
25
- * without a second uid.
18
+ * - the parent directory is daemon-owned and mode **`0711`** searchable so a
19
+ * caller reaches a socket it can already name, not listable and not writable
20
+ * by any account other than the daemon's;
21
+ * - each socket is `0600`, so only the daemon's uid can connect at all;
22
+ * - the per-socket suffix is random, so the path is not guessable by a foreign
23
+ * account that cannot list the directory.
24
+ *
25
+ * Two further rules make that hold rather than merely describe it:
26
+ * {@link validateSocketPath} refuses to bind under a path anyone else could have
27
+ * tampered with, and {@link peerVerdict} compares the connecting uid the kernel
28
+ * reports against the daemon's own.
29
+ *
30
+ * Every decision in this file is a pure function of `lstat` results, so the
31
+ * adversarial cases are testable without root and without a second uid.
26
32
  */
27
33
 
28
34
  import { dlopen, FFIType, ptr, suffix } from "bun:ffi";
@@ -31,10 +37,10 @@ import { chmodSync, chownSync, lstatSync, mkdirSync, rmSync } from "node:fs";
31
37
  import type { Stats } from "node:fs";
32
38
  import { dirname, isAbsolute, join, resolve } from "node:path";
33
39
 
34
- /** Searchable by run principals, listable by none, writable only by the daemon. */
40
+ /** Searchable, but not listable or writable by any account but the daemon's. */
35
41
  export const VERB_DIR_MODE = 0o711;
36
42
 
37
- /** One uid may connect. The parent's `0711` is what makes that reachable. */
43
+ /** Only the daemon's uid may connect. The parent's `0711` keeps that reachable. */
38
44
  export const VERB_SOCKET_MODE = 0o600;
39
45
 
40
46
  /**
@@ -196,53 +202,6 @@ export function validateSocketPath(
196
202
  return undefined;
197
203
  }
198
204
 
199
- /**
200
- * The first path component a *foreign* uid could not search through, or
201
- * `undefined` when the whole chain is traversable.
202
- *
203
- * Checked rather than assumed, because this is the failure the layout in this
204
- * file is most likely to hit in the field and the least likely to be noticed:
205
- * connecting to a socket needs `+x` on **every** component, and the daemon's
206
- * own state directory is `0700`. A run principal would then be refused at the
207
- * state directory rather than at the socket, with an `EACCES` that names a
208
- * directory nobody was thinking about.
209
- *
210
- * Only meaningful when the run has a distinct principal. Under one uid the
211
- * daemon is the caller, so `0700` is traversable by definition — which is
212
- * exactly why this cannot be left to be discovered on the first fleet that
213
- * turns per-run principals on.
214
- *
215
- * Deliberately reported rather than repaired: the fix is `chmod o+x` on a
216
- * directory that may be the operator's home, and a daemon that silently
217
- * widened `$HOME` would be trading a legible refusal for a surprise.
218
- */
219
- export function traversalProblem(
220
- path: string,
221
- lstat: (p: string) => Stats = lstatSync,
222
- ): SocketPathProblem | undefined {
223
- for (const component of ancestors(path)) {
224
- let stat: Stats;
225
- try {
226
- stat = lstat(component);
227
- } catch {
228
- return {
229
- component,
230
- fault: "missing",
231
- message: `verb socket parent ${component} does not exist or cannot be read`,
232
- };
233
- }
234
- if ((stat.mode & 0o001) === 0) {
235
- return {
236
- component,
237
- fault: "not-searchable",
238
- message:
239
- `verb socket parent ${component} is mode ${(stat.mode & 0o7777).toString(8)}, which a run principal ` +
240
- `cannot search through. Connecting needs +x on every component. Run: chmod o+x ${component}`,
241
- };
242
- }
243
- }
244
- return undefined;
245
- }
246
205
 
247
206
  /**
248
207
  * Remove a stale socket at `path`, and only ever one the daemon itself placed.
@@ -256,26 +215,22 @@ export function unlinkStaleSocket(path: string): void {
256
215
  }
257
216
 
258
217
  /**
259
- * Hand a bound socket to its run's principal.
218
+ * Restrict a bound socket to the daemon's own uid.
260
219
  *
261
- * Returns which guarantee is actually in force, because #126 asks the daemon to
262
- * *state* its mechanism at startup rather than guess: with a principal the
263
- * socket is one-uid; without one it is one-user, and the parent's `0711` plus
264
- * the unguessable suffix are the whole story. Saying which is the difference
265
- * between an audited boundary and a hopeful one.
220
+ * Returns the guarantee in force, because #126 asks the daemon to *state* its
221
+ * mechanism at startup rather than guess: the socket is `0600` under a `0711`
222
+ * daemon-owned parent, so it is one-user, and the unguessable suffix plus that
223
+ * parent are the whole story. Saying so is the difference between an audited
224
+ * boundary and a hopeful one.
266
225
  */
267
- export type SocketOwnership = "run-principal" | "daemon-user";
226
+ export type SocketOwnership = "daemon-user";
268
227
 
269
228
  export function secureBoundSocket(
270
229
  path: string,
271
- principal: { uid: number; gid: number } | undefined,
272
230
  chmod: (p: string, mode: number) => void = chmodSync,
273
- chown: (p: string, uid: number, gid: number) => void = chownSync,
274
231
  ): SocketOwnership {
275
232
  chmod(path, VERB_SOCKET_MODE);
276
- if (principal === undefined) return "daemon-user";
277
- chown(path, principal.uid, principal.gid);
278
- return "run-principal";
233
+ return "daemon-user";
279
234
  }
280
235
 
281
236
  export interface PeerCredentials {
@@ -406,14 +361,14 @@ export function peerVerdict(
406
361
  return {
407
362
  ok: true,
408
363
  basis: "socket-ownership",
409
- why: "this run has no distinct OS principal, so peer uid cannot tell two runs apart; the 0600 socket under the daemon-owned 0711 parent is the whole boundary",
364
+ why: "no uid was named for this socket, so the 0600 mode under the daemon-owned 0711 parent is the whole boundary",
410
365
  };
411
366
  }
412
367
  if (peer === undefined) {
413
368
  return {
414
369
  ok: true,
415
370
  basis: "socket-ownership",
416
- why: "this host exposes no peer credentials, so the 0600 socket owned by the run principal is the whole boundary",
371
+ why: "this host exposes no peer credentials, so the 0600 socket under the daemon-owned 0711 parent is the whole boundary",
417
372
  };
418
373
  }
419
374
  if (peer.uid !== expected.uid) {
@@ -428,19 +383,15 @@ export function peerVerdict(
428
383
  }
429
384
 
430
385
  /** One line naming what the transport can actually enforce on this host. */
431
- export function transportBanner(
432
- dir: string,
433
- peerReader: PeerReader | undefined,
434
- principals: boolean,
435
- ): string {
386
+ export function transportBanner(dir: string, peerReader: PeerReader | undefined): string {
436
387
  const peers =
437
388
  peerReader === undefined
438
389
  ? `no peer-credential call on ${process.platform}`
439
390
  : process.platform === "darwin"
440
391
  ? "peer uid asserted with getpeereid"
441
392
  : "peer uid asserted with SO_PEERCRED";
442
- const owners = principals
443
- ? "each socket 0600 and chowned to its run principal"
444
- : "each socket 0600 under the daemon's own uid (no per-run principals on this host)";
445
- return `verb sockets in ${dir} (mode ${VERB_DIR_MODE.toString(8)}); ${owners}; ${peers}`;
393
+ return (
394
+ `verb sockets in ${dir} (mode ${VERB_DIR_MODE.toString(8)}); ` +
395
+ `each socket 0600 under the daemon's own uid; ${peers}`
396
+ );
446
397
  }
package/src/worker.ts CHANGED
@@ -10,7 +10,6 @@
10
10
  * sliding into a merge queue.
11
11
  */
12
12
 
13
- import type { SessionBoundary } from "./credentials.ts";
14
13
  import { createSession, disposeSession } from "./omp.ts";
15
14
  import type { Caps, ReleaseShape, ResolvedGrants, RunState } from "./types.ts";
16
15
 
@@ -54,17 +53,7 @@ export interface WorkerOpts {
54
53
  releaseGrants?: ResolvedGrants;
55
54
  /** Durable audit sink for rejected release/deploy calls. */
56
55
  onReleaseBlocked?: (shape: ReleaseShape) => void;
57
- /**
58
- * The OS principal this run's session executes as (#125). The worker does not
59
- * interpret it — it hands it to {@link createSession}, which is where the
60
- * launcher lives. Omitted, the session is still a child process but runs as
61
- * the daemon's own user: that is A1's shape and carries no security claim.
62
- */
63
- boundary?: SessionBoundary;
64
- /**
65
- * Control socket for that child. The daemon puts it inside the run's own
66
- * boundary root, because a slot principal has to be able to reach it.
67
- */
56
+ /** Control socket for that child, beside the run's own session directory. */
68
57
  socketPath?: string;
69
58
  /**
70
59
  * The run's conductor verb socket (#126) — its only route to a push, a PR or
@@ -206,7 +195,6 @@ export async function runWorker(
206
195
  role: "worker",
207
196
  ...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
208
197
  ...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
209
- ...(o.boundary === undefined ? {} : { boundary: o.boundary }),
210
198
  ...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
211
199
  ...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
212
200
  ...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
package/src/worktree.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * the price of not cloning the repo per run.
18
18
  *
19
19
  * Publishing is therefore the daemon's job, never the worker's — see
20
- * `pushRunBranch` in `credentials.ts`. The run repo's `origin` deliberately
20
+ * `pushRunBranch` in `gitops.ts`. The run repo's `origin` deliberately
21
21
  * names the real clone URL rather than the mirror, so a worker's
22
22
  * `git push origin HEAD` attempts the network and fails on credentials instead
23
23
  * of quietly succeeding into the shared object store.
@@ -26,7 +26,7 @@
26
26
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
27
  import { dirname, join } from "node:path";
28
28
 
29
- import { credentialedEnv, scrubUserinfo } from "./credentials.ts";
29
+ import { credentialedEnv, scrubUserinfo } from "./gitops.ts";
30
30
  import type { RepoTarget } from "./types.ts";
31
31
 
32
32
  /**
@@ -312,9 +312,11 @@ export async function addRunRepo(
312
312
  branch: string,
313
313
  ): Promise<{ path: string; reattached: boolean }> {
314
314
  const mirrorPath = await ensureMirror(repo, mirrorRoot);
315
- // 0711: searchable, so a slot principal can reach its own checkout; not
316
- // listable, so it cannot enumerate its siblings; not writable, so it cannot
317
- // create or unlink one. Same reasoning as the socket parent directory.
315
+ // 0711: searchable but not listable, so no *other local account* can
316
+ // enumerate the fleet's checkouts. It does not stop a session reaching a
317
+ // sibling's tree: sessions run as the daemon's own uid, so the owner bits are
318
+ // theirs. What confines a worker to its own checkout is the mechanical
319
+ // worktree gate on its tools, not this mode.
318
320
  mkdirSync(workspaceRoot, { recursive: true, mode: 0o711 });
319
321
 
320
322
  const runRepo = worktreePathFor(workspaceRoot, issue);
@@ -418,7 +420,7 @@ export async function addRunRepo(
418
420
  *
419
421
  * Declared here as a function type rather than imported as a concrete
420
422
  * implementation so this module stays free of the credential path: provisioning
421
- * and salvage decide *what* to publish, `credentials.ts` is the only place that
423
+ * and salvage decide *what* to publish, `gitops.ts` is the only place that
422
424
  * decides *how*, and the daemon is the only thing that holds both.
423
425
  */
424
426
  export type RunPublisher = (
@@ -1,13 +1,12 @@
1
1
  # Example unit for a supervised omp-conductor daemon.
2
2
  #
3
3
  # Why MemoryMax exists: worker and orchestrator sessions are child processes of
4
- # this service (#125 moved them out of the daemon's own process so they can run
5
- # as a different OS principal). They stay in this unit's cgroup, so MemoryMax
6
- # still governs the fleet's total footprint it is just no longer a ceiling on
7
- # one process. With the default two workers plus the orchestrator session,
8
- # journald on a reference 7.6 GB host recorded Memory peaks of ~3.2–4.2 GB for
9
- # this unit (issue #51). That is expected load, not a leak — and on a shared VPS
10
- # it is enough to thrash swap or OOM the daemon mid-flight (orphan path).
4
+ # this service and stay inside its cgroup, so MemoryMax governs the whole
5
+ # fleet's footprint rather than one process. With the default two workers plus
6
+ # the orchestrator session, journald on a reference 7.6 GB host recorded Memory
7
+ # peaks of ~3.2–4.2 GB for this unit (issue #51). That is expected load, not a
8
+ # leak — and on a shared VPS it is enough to thrash swap or OOM the daemon
9
+ # mid-flight (orphan path).
11
10
  #
12
11
  # Before enabling:
13
12
  # 1. Set User=/Group=/HOME=/PATH for the account that owns ~/.omp/conductor.
@@ -16,66 +15,6 @@
16
15
  # consider MemoryMax=3G.
17
16
  # 4. Do not co-locate ClickHouse + other multi-GB services beside a 2-worker
18
17
  # fleet on a ≤8 GB box.
19
- # 5. For credentials.isolation=per-run, do the one-time provisioning below.
20
- # Without it the daemon runs unprotected (isolation=none) and says so in
21
- # `omp-conductor status`.
22
- #
23
- # One-time provisioning for credentials.isolation=per-run (#125)
24
- # --------------------------------------------------------------
25
- # Model-executed code must not run as the account that holds the GitHub
26
- # credential. Accounts, groups, a shared root and the daemon's own home mode
27
- # are what make that true; environment scrubbing is only accident-prevention
28
- # and does not survive a determined session running as the same uid.
29
- #
30
- # Do NOT hand-copy the steps. `omp-conductor boundary-setup` prints the exact
31
- # idempotent commands, generated from the same constants the startup probe then
32
- # checks — a hand-maintained copy here would drift from what the daemon demands
33
- # and fail at first dispatch instead of at provisioning time. It needs no config
34
- # and must be run BEFORE `setup`, because setup writes worktree and mirror paths
35
- # into the shared root it creates:
36
- #
37
- # omp-conductor boundary-setup --slots 2 # read what it will do
38
- # omp-conductor boundary-setup --slots 2 | sudo bash
39
- # sudo systemctl restart omp-conductor.service
40
- #
41
- # What it establishes, and why each part is load-bearing:
42
- #
43
- # * conductor-agent-<n> per concurrent slot, plus conductor-agent-orch. The
44
- # orchestrator's is distinct so it cannot reach a run checkout, and it is
45
- # launched with no supplementary group at all.
46
- # * conductor-daemon — the daemon account ONLY. Lets it fetch a run branch,
47
- # salvage a killed run and reclaim the tree. A slot principal must NEVER be
48
- # in it; that absence is what keeps sibling runs apart, and the probe suite
49
- # asserts it from a live session.
50
- # * conductor-runs — every slot. Read-only access to the shared mirror that
51
- # run repos borrow objects from. A run being able to READ another run's
52
- # objects there is the documented residual of sharing one object store.
53
- # * /var/lib/omp-conductor, mode 0711 — worktrees, mirrors, per-run session
54
- # transcripts and per-run boundary homes. OUTSIDE the state directory,
55
- # because that stays 0700 (it holds conductor.db and the WAL SQLite keeps
56
- # recreating, so a searchable parent would publish fleet history to every
57
- # local account), and outside $HOME for the reason below.
58
- # * $HOME at 0711 with credential leaves closed (.ssh, .config/gh 0700;
59
- # .npmrc, .git-credentials 0600). Searchable because the runtime and the
60
- # installed package live in it — a 0700 home kills every worker before it
61
- # connects, and no shell-based probe notices — and closed at the leaves
62
- # because that is where the boundary actually rests. The daemon re-checks
63
- # this empirically at dispatch and refuses if a slot can read any
64
- # credential path.
65
- #
66
- # The restart is REQUIRED, not tidiness: supplementary group membership is fixed
67
- # when a process starts, so without it the daemon's live credentials lack
68
- # conductor-daemon even though `getent` shows it, and it would chown every run
69
- # repo to a group it cannot itself use.
70
- #
71
- # # util-linux, for the privilege-dropping launcher. Without it the probe
72
- # # reports mechanism `none` — there is deliberately no hand-rolled
73
- # # spawn({uid,gid}) fallback, because a child launched that way can hold
74
- # # CAP_SETUID and setuid() straight back to a sibling run or to the daemon.
75
- # sudo apt-get install -y util-linux
76
- #
77
- # Verify with `omp-conductor status`: the `boundary` row names the mechanism
78
- # that is actually live and lists what it does not close.
79
18
  #
80
19
  # Install:
81
20
  # sudo install -m 0644 omp-conductor.service.example /etc/systemd/system/omp-conductor.service
@@ -95,43 +34,10 @@ Type=simple
95
34
  User=fleet
96
35
  Group=fleet
97
36
  Environment=HOME=/home/fleet
98
- # systemd's default PATH has no user installs; include wherever `omp` /
99
- # `omp-conductor`, `gh` and `setpriv` live on this host.
37
+ # systemd's default PATH has no user installs; include wherever `omp`,
38
+ # `omp-conductor` and `gh` live on this host.
100
39
  Environment=PATH=/home/fleet/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin
101
40
 
102
- # These capabilities exist to be DROPPED INTO run children, never inherited by
103
- # them (#125). The daemon stays the unprivileged `fleet` account — a capability
104
- # grant on an existing account is a narrower blast radius than running as root
105
- # or shipping a setuid binary, both of which widen exactly what this exists to
106
- # narrow.
107
- #
108
- # Every run child is launched through `setpriv`, which sets the group list, then
109
- # the gid, then the uid, empties the permitted/effective/inheritable/ambient
110
- # capability sets, drops the bounding set, sets PR_SET_NO_NEW_PRIVS, and only
111
- # then execs. DO NOT "simplify" this into a raw spawn({uid,gid}): ambient
112
- # capabilities survive execve for ordinary binaries, so a child launched that
113
- # way holds CAP_SETUID itself and can setuid() back to another principal —
114
- # including a sibling run's. That voids the entire boundary while appearing to
115
- # work, which is the worst possible outcome for a security change.
116
- #
117
- # CAP_SETPCAP is present solely so the launcher can empty the child's capability
118
- # BOUNDING set (PR_CAPBSET_DROP requires it in the caller's own permitted set).
119
- # It is dropped along with everything else before the session child execs. A
120
- # host that refuses to grant it takes the documented fallback instead: the
121
- # launcher omits --bounding-set=-all, the child still ends with every other set
122
- # empty behind NoNewPrivs, and `status` reports the non-empty CapBnd as a named
123
- # residual rather than ignoring it.
124
- # Supplementary groups are fixed when a process starts, so a daemon that was
125
- # already running when these accounts were provisioned can never join them --
126
- # and a unit with no User= gets no initgroups call at all, so it comes up with
127
- # `Groups: 0` even once /etc/group is correct. Declared here so membership is a
128
- # property of the unit rather than of how it happened to be started; after a
129
- # `daemon-reload` and a restart, `omp-conductor status` reports the probed
130
- # mechanism as uid-pool.
131
- SupplementaryGroups=conductor-daemon conductor-runs
132
- AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
133
- CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
134
-
135
41
  WorkingDirectory=/home/fleet
136
42
 
137
43
  # Foreground daemon so systemd tracks MainPID. `omp-conductor start` backgrounds;