omp-conductor 0.4.4 → 0.5.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.
@@ -57,15 +57,6 @@ import {
57
57
  type SocketOwnership,
58
58
  } from "./socket.ts";
59
59
 
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
60
  /**
70
61
  * How long a merge lock may be held before another daemon may break it.
71
62
  *
@@ -101,17 +92,15 @@ export type VerbChannel =
101
92
  issue: number;
102
93
  /** The routed checkout, for the privileged push/PR half. */
103
94
  repo: RepoTarget;
104
- /** The run's own repository on disk (#125's per-run clone). */
95
+ /** The run's own repository on disk. */
105
96
  runRepoPath: string;
106
97
  branch: string;
107
- principal?: SlotPrincipal;
108
98
  }
109
99
  | {
110
100
  kind: "orchestrator";
111
101
  path: string;
112
102
  project: string;
113
103
  role: "orchestrator";
114
- principal?: SlotPrincipal;
115
104
  };
116
105
 
117
106
  export type ActionOutcome = { ok: true; sha?: string; detail?: string } | { ok: false; stderr: string };
@@ -978,20 +967,6 @@ export async function listenVerbChannel(
978
967
  "owned by the daemon (or root), free of symlinks, and unwritable by anyone else.",
979
968
  );
980
969
  }
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
970
  // Only the daemon ever unlinks these, and it has just proven the parent is
996
971
  // not writable by anyone else — which is what makes unlink-then-bind safe
997
972
  // here and a race in a world-writable directory.
@@ -1013,7 +988,7 @@ export async function listenVerbChannel(
1013
988
  });
1014
989
 
1015
990
  const secure = opts.secure ?? secureBoundSocket;
1016
- const ownership = secure(channel.path, channel.principal);
991
+ const ownership = secure(channel.path);
1017
992
 
1018
993
  return {
1019
994
  path: channel.path,
@@ -1037,7 +1012,7 @@ function handleConnection(
1037
1012
  ): void {
1038
1013
  const fd = socketFd(socket);
1039
1014
  const peer = fd === undefined || peerReader === undefined ? undefined : peerReader(fd);
1040
- const verdict = peerVerdict(channel.principal, peer);
1015
+ const verdict = peerVerdict({ uid: process.getuid?.() ?? 0 }, peer);
1041
1016
  if (!verdict.ok) {
1042
1017
  // Not a client error, and deliberately not answered: a caller who is not
1043
1018
  // who the socket was allocated to gets no reply to calibrate against.
@@ -256,26 +256,22 @@ export function unlinkStaleSocket(path: string): void {
256
256
  }
257
257
 
258
258
  /**
259
- * Hand a bound socket to its run's principal.
259
+ * Restrict a bound socket to the daemon's own uid.
260
260
  *
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.
261
+ * Returns the guarantee in force, because #126 asks the daemon to *state* its
262
+ * mechanism at startup rather than guess: the socket is `0600` under a `0711`
263
+ * daemon-owned parent, so it is one-user, and the unguessable suffix plus that
264
+ * parent are the whole story. Saying so is the difference between an audited
265
+ * boundary and a hopeful one.
266
266
  */
267
- export type SocketOwnership = "run-principal" | "daemon-user";
267
+ export type SocketOwnership = "daemon-user";
268
268
 
269
269
  export function secureBoundSocket(
270
270
  path: string,
271
- principal: { uid: number; gid: number } | undefined,
272
271
  chmod: (p: string, mode: number) => void = chmodSync,
273
- chown: (p: string, uid: number, gid: number) => void = chownSync,
274
272
  ): SocketOwnership {
275
273
  chmod(path, VERB_SOCKET_MODE);
276
- if (principal === undefined) return "daemon-user";
277
- chown(path, principal.uid, principal.gid);
278
- return "run-principal";
274
+ return "daemon-user";
279
275
  }
280
276
 
281
277
  export interface PeerCredentials {
@@ -406,14 +402,14 @@ export function peerVerdict(
406
402
  return {
407
403
  ok: true,
408
404
  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",
405
+ why: "no uid was named for this socket, so the 0600 mode under the daemon-owned 0711 parent is the whole boundary",
410
406
  };
411
407
  }
412
408
  if (peer === undefined) {
413
409
  return {
414
410
  ok: true,
415
411
  basis: "socket-ownership",
416
- why: "this host exposes no peer credentials, so the 0600 socket owned by the run principal is the whole boundary",
412
+ why: "this host exposes no peer credentials, so the 0600 socket under the daemon-owned 0711 parent is the whole boundary",
417
413
  };
418
414
  }
419
415
  if (peer.uid !== expected.uid) {
@@ -428,19 +424,15 @@ export function peerVerdict(
428
424
  }
429
425
 
430
426
  /** 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 {
427
+ export function transportBanner(dir: string, peerReader: PeerReader | undefined): string {
436
428
  const peers =
437
429
  peerReader === undefined
438
430
  ? `no peer-credential call on ${process.platform}`
439
431
  : process.platform === "darwin"
440
432
  ? "peer uid asserted with getpeereid"
441
433
  : "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}`;
434
+ return (
435
+ `verb sockets in ${dir} (mode ${VERB_DIR_MODE.toString(8)}); ` +
436
+ `each socket 0600 under the daemon's own uid; ${peers}`
437
+ );
446
438
  }
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,7 +312,7 @@ 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
315
+ // 0711: searchable, so a session reaches its own checkout by name; not
316
316
  // listable, so it cannot enumerate its siblings; not writable, so it cannot
317
317
  // create or unlink one. Same reasoning as the socket parent directory.
318
318
  mkdirSync(workspaceRoot, { recursive: true, mode: 0o711 });
@@ -418,7 +418,7 @@ export async function addRunRepo(
418
418
  *
419
419
  * Declared here as a function type rather than imported as a concrete
420
420
  * 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
421
+ * and salvage decide *what* to publish, `gitops.ts` is the only place that
422
422
  * decides *how*, and the daemon is the only thing that holds both.
423
423
  */
424
424
  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,35 +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
- AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
125
- CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
126
-
127
41
  WorkingDirectory=/home/fleet
128
42
 
129
43
  # Foreground daemon so systemd tracks MainPID. `omp-conductor start` backgrounds;