omp-conductor 0.18.0 → 0.18.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/README.md +34 -0
- package/REFERENCE.md +60 -10
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +29 -0
- package/src/admission.ts +204 -75
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +42 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +22 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +50 -2
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +24 -0
- package/src/config.ts +42 -1
- package/src/daemon.ts +965 -36
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +235 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +146 -22
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp.ts +227 -20
- package/src/orchestrator-tick.ts +1386 -15
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +99 -5
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +13 -2
- package/src/setup.ts +29 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +78 -11
- package/src/store.ts +443 -42
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +343 -13
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +730 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +25 -2
- package/src/worktree.ts +29 -12
package/src/host.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* or the tracker. Pure so tests pin the thresholds without a real machine.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { readFileSync } from "node:fs";
|
|
6
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
-
import {
|
|
8
|
+
import { availableParallelism } from "node:os";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { DEFAULT_CAPS, type HostConstraints } from "./types.ts";
|
|
9
11
|
|
|
10
12
|
/** 16 GiB — below this, two in-process omp sessions plus the orchestrator are
|
|
11
13
|
* a measured swap risk on a shared VPS (issue #51: 3–4GB peaks on 7.6GB). */
|
|
@@ -78,6 +80,86 @@ export function workerOvercommit(total: number, ram?: number | undefined): strin
|
|
|
78
80
|
: undefined;
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/**
|
|
84
|
+
* CPU cores this process can actually use — affinity- and quota-aware, which
|
|
85
|
+
* is the load a worker would really get on a shared host. `undefined` mirrors
|
|
86
|
+
* {@link hostRamBytes}: the renderer simply omits the count.
|
|
87
|
+
*/
|
|
88
|
+
export function hostCoreCount(): number | undefined {
|
|
89
|
+
try {
|
|
90
|
+
return availableParallelism();
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What {@link hostFacts} reads off the host; injectable for render tests. */
|
|
97
|
+
export interface HostFacts {
|
|
98
|
+
cores?: number;
|
|
99
|
+
ramBytes?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The host's numbers through the module's own readers — the same
|
|
104
|
+
* {@link hostRamBytes} the capacity decision and the wizard use, plus the
|
|
105
|
+
* core count — so the rendered brief, the worker-count recommendation and
|
|
106
|
+
* the status surfaces can never disagree about the box they run on. This is
|
|
107
|
+
* the default the brief renderer reaches for; tests inject facts instead.
|
|
108
|
+
*/
|
|
109
|
+
export function hostFacts(): HostFacts {
|
|
110
|
+
return { cores: hostCoreCount(), ramBytes: hostRamBytes() };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The usable-size phrase the brief folds into the host line (`4 cores,
|
|
115
|
+
* 3.2 GB`); empty when the host will not say either number.
|
|
116
|
+
*/
|
|
117
|
+
function hostSizePhrase(facts: HostFacts): string {
|
|
118
|
+
const parts: string[] = [];
|
|
119
|
+
if (facts.cores !== undefined) parts.push(`${facts.cores} cores`);
|
|
120
|
+
if (facts.ramBytes !== undefined) parts.push(formatRss(facts.ramBytes));
|
|
121
|
+
return parts.join(", ");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The worker-brief host-constraints paragraph (#721): the typed replacement
|
|
126
|
+
* for host facts hand-written into an untracked agent context file. Cores and
|
|
127
|
+
* RAM come from {@link hostFacts} (the module's own readers) and are folded
|
|
128
|
+
* into the operator's description; the PATH and per-repo convention lines are
|
|
129
|
+
* typed config. It never names a guarded command — the shared-host notice,
|
|
130
|
+
* derived from `SHARED_HOST_SCRIPTS`, stays the only list of refused suites.
|
|
131
|
+
*
|
|
132
|
+
* Empty when nothing renders — no section, and the brief is byte-for-byte
|
|
133
|
+
* today's for a fleet that never fills the config field. `facts` is a
|
|
134
|
+
* parameter so tests pin the rendering deterministically; the dispatch default
|
|
135
|
+
* is {@link hostFacts}. The return has no trailing newline and leads with one
|
|
136
|
+
* when non-empty, so the template can render it on the line below the
|
|
137
|
+
* shared-host notice without gluing the two paragraphs.
|
|
138
|
+
*/
|
|
139
|
+
export function hostConstraintsNotice(
|
|
140
|
+
host: HostConstraints | undefined,
|
|
141
|
+
repoSlug: string,
|
|
142
|
+
facts: HostFacts = hostFacts(),
|
|
143
|
+
): string {
|
|
144
|
+
if (host === undefined) return "";
|
|
145
|
+
|
|
146
|
+
const lines: string[] = [];
|
|
147
|
+
const description = host.description?.trim();
|
|
148
|
+
if (description !== undefined && description !== "") {
|
|
149
|
+
const size = hostSizePhrase(facts);
|
|
150
|
+
lines.push(`**Host:** ${description}${size === "" ? "" : ` (${size})`}`);
|
|
151
|
+
}
|
|
152
|
+
const path = host.path?.trim();
|
|
153
|
+
if (path !== undefined && path !== "") {
|
|
154
|
+
lines.push(`**PATH:** non-interactive invocations (scripts and \`ssh host '<cmd>'\`) must export PATH="${path}".`);
|
|
155
|
+
}
|
|
156
|
+
const convention = host.conventions?.[repoSlug]?.trim();
|
|
157
|
+
if (convention !== undefined && convention !== "") {
|
|
158
|
+
lines.push(`**Convention (${repoSlug}):** ${convention}`);
|
|
159
|
+
}
|
|
160
|
+
return lines.length === 0 ? "" : `\n${lines.join("\n\n")}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
81
163
|
/** Compact binary units for status lines (`3.2 GB`, `430 MB`). */
|
|
82
164
|
export function formatRss(bytes: number): string {
|
|
83
165
|
if (!Number.isFinite(bytes) || bytes < 0) return "?";
|
|
@@ -104,3 +186,486 @@ export function rssBytesFromHealthz(body: string | undefined): number | undefine
|
|
|
104
186
|
return undefined;
|
|
105
187
|
}
|
|
106
188
|
}
|
|
189
|
+
|
|
190
|
+
// ------------------------------------------------------- worker identity (#798) --
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The dedicated least-privilege account every worker session runs under.
|
|
194
|
+
*
|
|
195
|
+
* `setup host` creates it and grants it exactly the paths a worker session
|
|
196
|
+
* needs; the daemon resolves it before every worker launch and refuses to
|
|
197
|
+
* dispatch when it cannot be established. A worker under this uid is a kernel
|
|
198
|
+
* identity, not a stamp: it cannot write the daemon's state (every path it
|
|
199
|
+
* needs is either chowned to it at dispatch or granted read/search-only), and
|
|
200
|
+
* it cannot migrate itself into a daemon-owned cgroup (every cgroup.procs on
|
|
201
|
+
* the host is root-owned, and cgroupfs directory ownership is what v2 gates
|
|
202
|
+
* writes with). This is the closure the rejected cgroup-namespace boundary
|
|
203
|
+
* named as required: kuid 0 with CAP_SYS_ADMIN could re-enter the initial
|
|
204
|
+
* cgroup namespace and fabricate a top-level cgroup, and a different uid is
|
|
205
|
+
* what takes that capability away.
|
|
206
|
+
*/
|
|
207
|
+
export const WORKER_ACCOUNT = "omp-worker";
|
|
208
|
+
|
|
209
|
+
/** The worker account's system home: its harness config, caches and state. */
|
|
210
|
+
export const WORKER_HOME_DIR = "/var/lib/omp-worker";
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The util-linux launcher worker sessions are spawned through. Pinned to an
|
|
214
|
+
* absolute path, never PATH-resolved: a launcher found later in the search
|
|
215
|
+
* order is a launcher an operator's environment could shadow.
|
|
216
|
+
*/
|
|
217
|
+
export const WORKER_SETPRIV_PATH = "/usr/bin/setpriv";
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The harness package omp-conductor loads a session from. Held here, beside
|
|
221
|
+
* the identity that has to reach it, so `omp.ts` (which imports it) and the
|
|
222
|
+
* binding checks below can never name two different packages.
|
|
223
|
+
*/
|
|
224
|
+
export const OMP_HARNESS_PACKAGE = "@oh-my-pi/pi-coding-agent";
|
|
225
|
+
|
|
226
|
+
/** Native addon package the harness imports at runtime. */
|
|
227
|
+
export const OMP_NATIVES_PACKAGE = "@oh-my-pi/pi-natives";
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Where `setup host` binds the operator's install so a worker session can
|
|
231
|
+
* resolve it (#828).
|
|
232
|
+
*
|
|
233
|
+
* Bun's node_modules resolution needs **read** permission — not merely search
|
|
234
|
+
* — on the directory that holds a `node_modules`: it enumerates the directory
|
|
235
|
+
* to decide whether the child is there. The fleet account's home is granted to
|
|
236
|
+
* the worker search-only by design (#798), so a bare `@oh-my-pi/pi-coding-agent`
|
|
237
|
+
* import from `<fleet home>/node_modules/omp-conductor` did not find the
|
|
238
|
+
* operator's install at all and fell through to Bun's auto-install, which
|
|
239
|
+
* downloaded a *different* harness version into the worker's own cache whose
|
|
240
|
+
* native addon then failed to load — every dispatch on the host stopped before
|
|
241
|
+
* session start.
|
|
242
|
+
*
|
|
243
|
+
* The fix is a read-only bind of the operator's `node_modules` at a path whose
|
|
244
|
+
* every ancestor is world-readable. Worker children are launched from it, so
|
|
245
|
+
* the entry module, the peer import and every transitive import resolve inside
|
|
246
|
+
* one tree the worker can enumerate — and, because a bind shares inodes with
|
|
247
|
+
* its source, it is the operator's exact build rather than a copy that can
|
|
248
|
+
* drift.
|
|
249
|
+
*
|
|
250
|
+
* Deliberately **not** under {@link WORKER_HOME_DIR}: the worker owns its home
|
|
251
|
+
* between setups, and a symlink planted where a root-run mount point goes would
|
|
252
|
+
* redirect that mount to an arbitrary target (#816).
|
|
253
|
+
*/
|
|
254
|
+
export const WORKER_HARNESS_DIR = "/var/lib/omp-worker-harness";
|
|
255
|
+
|
|
256
|
+
/** The bound `node_modules` itself. The leaf name is load-bearing: it is what
|
|
257
|
+
* Node/Bun resolution looks for walking up from the entry module. */
|
|
258
|
+
export const WORKER_HARNESS_NODE_MODULES = join(WORKER_HARNESS_DIR, "node_modules");
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The install root a module path sits in — its nearest ancestor named
|
|
262
|
+
* `node_modules` — or `undefined` when it has none, which is this package
|
|
263
|
+
* running from a source checkout rather than an install.
|
|
264
|
+
*/
|
|
265
|
+
export function packageNodeModulesRoot(modulePath: string): string | undefined {
|
|
266
|
+
const parts = resolve(modulePath)
|
|
267
|
+
.split("/")
|
|
268
|
+
.filter((part) => part !== "");
|
|
269
|
+
const idx = parts.lastIndexOf("node_modules");
|
|
270
|
+
return idx < 0 ? undefined : `/${parts.slice(0, idx + 1).join("/")}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* `path` as a worker session sees it through the harness binding, or
|
|
275
|
+
* `undefined` when it is not inside `packageRoot` — a test seam pointing at a
|
|
276
|
+
* file elsewhere, or a source checkout with no install root at all.
|
|
277
|
+
*
|
|
278
|
+
* Production uses {@link WORKER_HARNESS_NODE_MODULES}; an explicit binding
|
|
279
|
+
* root lets the Linux regression build the same inode-sharing tree under its
|
|
280
|
+
* private temporary directory instead of touching the live host mount.
|
|
281
|
+
*/
|
|
282
|
+
export function workerHarnessPath(
|
|
283
|
+
path: string,
|
|
284
|
+
packageRoot: string | undefined,
|
|
285
|
+
bindingRoot: string = WORKER_HARNESS_NODE_MODULES,
|
|
286
|
+
): string | undefined {
|
|
287
|
+
if (packageRoot === undefined) return undefined;
|
|
288
|
+
const absolute = resolve(path);
|
|
289
|
+
const prefix = `${packageRoot}/`;
|
|
290
|
+
if (!absolute.startsWith(prefix)) return undefined;
|
|
291
|
+
return join(bindingRoot, absolute.slice(prefix.length));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** One path's filesystem identity. Identity rather than bytes, because that is
|
|
295
|
+
* exactly what distinguishes a live bind of the operator's install (same
|
|
296
|
+
* device and inode) from an empty mount point or a copy that has drifted. */
|
|
297
|
+
function pathIdentity(path: string): string | undefined {
|
|
298
|
+
try {
|
|
299
|
+
const st = statSync(path);
|
|
300
|
+
return `${st.dev}:${st.ino}`;
|
|
301
|
+
} catch {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Read-only facts {@link harnessBindingProblem} decides on; injected by tests. */
|
|
307
|
+
export interface HarnessBindingDeps {
|
|
308
|
+
/** This module's own directory — the install root is derived from it. */
|
|
309
|
+
moduleDir?: string;
|
|
310
|
+
/** `dev:ino` of one path, or `undefined` when it cannot be stat'ed. */
|
|
311
|
+
identity?: (path: string) => string | undefined;
|
|
312
|
+
/** Alternate binding root for the isolated Linux currentness regression. */
|
|
313
|
+
bindingRoot?: string;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Why a worker session could not load the operator's harness through the
|
|
318
|
+
* binding, or `undefined` when it can.
|
|
319
|
+
*
|
|
320
|
+
* Both halves of the launch are checked, by filesystem identity: this package's
|
|
321
|
+
* own directory (the child's entry module comes from it) and the harness
|
|
322
|
+
* package directory (its peer import resolves to it). A mount point that is
|
|
323
|
+
* empty, stale, or bound to some other tree fails on the identity comparison
|
|
324
|
+
* rather than being taken on faith — which is the whole point, since the
|
|
325
|
+
* symptom this replaces was a *successful* import of the wrong build.
|
|
326
|
+
*/
|
|
327
|
+
export function harnessBindingProblem(deps: HarnessBindingDeps = {}): string | undefined {
|
|
328
|
+
const moduleDir = deps.moduleDir ?? import.meta.dir;
|
|
329
|
+
const identity = deps.identity ?? pathIdentity;
|
|
330
|
+
const bindingRoot = deps.bindingRoot ?? WORKER_HARNESS_NODE_MODULES;
|
|
331
|
+
const packageRoot = packageNodeModulesRoot(moduleDir);
|
|
332
|
+
if (packageRoot === undefined) {
|
|
333
|
+
return (
|
|
334
|
+
`omp-conductor is running from ${moduleDir}, which is not inside a node_modules install root — ` +
|
|
335
|
+
"a worker session resolves its harness through a read-only bind of that root, so worker dispatch " +
|
|
336
|
+
"needs the installed package (install omp-conductor with its harness peer, then re-run `omp-conductor setup host`)"
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
for (const dir of [moduleDir, join(packageRoot, OMP_HARNESS_PACKAGE)]) {
|
|
340
|
+
const source = identity(dir);
|
|
341
|
+
if (source === undefined) {
|
|
342
|
+
return (
|
|
343
|
+
`${dir} is missing or unreadable — ${OMP_HARNESS_PACKAGE} must be installed alongside omp-conductor ` +
|
|
344
|
+
"for a worker session to load the same harness build the operator runs"
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
// Non-null by construction: `dir` is inside `packageRoot`.
|
|
348
|
+
const bound = workerHarnessPath(dir, packageRoot, bindingRoot) ?? dir;
|
|
349
|
+
if (identity(bound) !== source) {
|
|
350
|
+
return (
|
|
351
|
+
`${bound} does not resolve to ${dir} — the worker harness binding of ${packageRoot} at ` +
|
|
352
|
+
`${bindingRoot} is missing or stale, so a worker session would resolve a different ` +
|
|
353
|
+
"harness build (or none). Run `omp-conductor setup host` to establish it"
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** One resolved worker identity: the account exactly as the host knows it. */
|
|
361
|
+
export interface WorkerIdentity {
|
|
362
|
+
account: string;
|
|
363
|
+
uid: number;
|
|
364
|
+
gid: number;
|
|
365
|
+
/** The account's passwd home. */
|
|
366
|
+
home: string;
|
|
367
|
+
/** Absolute path of the identity-transition launcher. */
|
|
368
|
+
setpriv: string;
|
|
369
|
+
/** The harness agent dir for this identity: `<home>/.omp/agent`. */
|
|
370
|
+
agentDir: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export type WorkerIdentityResolution =
|
|
374
|
+
| { ok: true; identity: WorkerIdentity }
|
|
375
|
+
| { ok: false; reason: string };
|
|
376
|
+
|
|
377
|
+
export interface WorkerIdentityDeps {
|
|
378
|
+
/** The resolving process's own uid; injected so tests pin the verdict. */
|
|
379
|
+
daemonUid?: number;
|
|
380
|
+
/** Whether the transition launcher exists; injected so tests pin the verdict. */
|
|
381
|
+
setprivInstalled?: boolean;
|
|
382
|
+
/** The contents of /etc/passwd; injected so tests pin the verdict. */
|
|
383
|
+
passwd?: string;
|
|
384
|
+
/**
|
|
385
|
+
* Why the worker harness binding is unusable, or `undefined` when it is
|
|
386
|
+
* live. Injected so tests pin the verdict without a real mount; production
|
|
387
|
+
* reads the host through {@link harnessBindingProblem}.
|
|
388
|
+
*/
|
|
389
|
+
harnessProblem?: () => string | undefined;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Is a process uid "unprivileged" for the worker identity's purposes — never
|
|
393
|
+
* the daemon's uid, and never root: an account that keeps uid 0 is not a
|
|
394
|
+
* boundary, it is a costume. */
|
|
395
|
+
function usableWorkerUid(uid: number, daemonUid: number): string | undefined {
|
|
396
|
+
if (uid === 0) return "the worker account must not be uid 0 (root) — an identity transition to root is not a boundary";
|
|
397
|
+
if (uid === daemonUid) return `the worker account must not share the daemon's uid ${daemonUid}`;
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Resolve the worker identity the daemon launches worker sessions under.
|
|
403
|
+
*
|
|
404
|
+
* This is the launch gate's input, resolved before every worker launch and at
|
|
405
|
+
* daemon startup for the banner. Every failure mode is a *reason*, never a
|
|
406
|
+
* throw: dispatch turns a missing account into a failed closed run (with this
|
|
407
|
+
* reason in the report and the escalation), so the operator hears exactly
|
|
408
|
+
* which host change is missing. A daemon that cannot transition — not running
|
|
409
|
+
* as root, or a host without setpriv — refuses to launch workers rather than
|
|
410
|
+
* running them unbound.
|
|
411
|
+
*/
|
|
412
|
+
export function resolveWorkerIdentity(deps: WorkerIdentityDeps = {}): WorkerIdentityResolution {
|
|
413
|
+
const daemonUid = deps.daemonUid ?? process.getuid?.() ?? 0;
|
|
414
|
+
if (daemonUid !== 0) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
reason:
|
|
418
|
+
`the daemon runs as uid ${daemonUid}; only root can transition a child to another uid, ` +
|
|
419
|
+
"so worker sessions cannot be launched under the dedicated identity",
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const setpriv = WORKER_SETPRIV_PATH;
|
|
423
|
+
const setprivInstalled = deps.setprivInstalled ?? existsSync(setpriv);
|
|
424
|
+
if (!setprivInstalled) {
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
reason: `${setpriv} (util-linux setpriv) is not installed on this host — worker sessions cannot be launched under the dedicated identity`,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
let passwd: string;
|
|
431
|
+
try {
|
|
432
|
+
passwd = deps.passwd ?? readFileSync("/etc/passwd", "utf8");
|
|
433
|
+
} catch {
|
|
434
|
+
return { ok: false, reason: `cannot read /etc/passwd while resolving the ${WORKER_ACCOUNT} identity` };
|
|
435
|
+
}
|
|
436
|
+
// `user:passwd:uid:gid:gecos:home:shell` — the whole line, never a prefix
|
|
437
|
+
// match, so a friendly lookalike account cannot satisfy the resolution.
|
|
438
|
+
const line = passwd.split("\n").find((entry) => entry.startsWith(`${WORKER_ACCOUNT}:`));
|
|
439
|
+
if (line === undefined) {
|
|
440
|
+
return {
|
|
441
|
+
ok: false,
|
|
442
|
+
reason:
|
|
443
|
+
`the ${WORKER_ACCOUNT} account does not exist on this host — run \`omp-conductor setup host\` ` +
|
|
444
|
+
"to create the dedicated worker identity",
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const fields = line.split(":");
|
|
448
|
+
const uid = Number(fields[2]);
|
|
449
|
+
const gid = Number(fields[3]);
|
|
450
|
+
const home = fields[5] ?? "";
|
|
451
|
+
if (!Number.isInteger(uid) || uid < 0) {
|
|
452
|
+
return { ok: false, reason: `the ${WORKER_ACCOUNT} account has an unusable uid in /etc/passwd` };
|
|
453
|
+
}
|
|
454
|
+
if (!Number.isInteger(gid) || gid < 0) {
|
|
455
|
+
return { ok: false, reason: `the ${WORKER_ACCOUNT} account has an unusable gid in /etc/passwd` };
|
|
456
|
+
}
|
|
457
|
+
const problem = usableWorkerUid(uid, daemonUid);
|
|
458
|
+
if (problem !== undefined) return { ok: false, reason: problem };
|
|
459
|
+
if (home === "" || !home.startsWith("/")) {
|
|
460
|
+
return { ok: false, reason: `the ${WORKER_ACCOUNT} account has no absolute home in /etc/passwd` };
|
|
461
|
+
}
|
|
462
|
+
// Last, because it is the check the operator can only act on once the
|
|
463
|
+
// account is there: without a live bind of the operator's install, a worker
|
|
464
|
+
// child resolves its harness from Bun's auto-install cache instead — a
|
|
465
|
+
// different build whose native addon does not load (#828). Refusing here
|
|
466
|
+
// fails the launch closed with the fix, rather than letting every dispatch
|
|
467
|
+
// die inside a session it already paid to start.
|
|
468
|
+
const harnessProblem = deps.harnessProblem === undefined ? harnessBindingProblem() : deps.harnessProblem();
|
|
469
|
+
if (harnessProblem !== undefined) return { ok: false, reason: harnessProblem };
|
|
470
|
+
return {
|
|
471
|
+
ok: true,
|
|
472
|
+
identity: {
|
|
473
|
+
account: WORKER_ACCOUNT,
|
|
474
|
+
uid,
|
|
475
|
+
gid,
|
|
476
|
+
home,
|
|
477
|
+
setpriv,
|
|
478
|
+
agentDir: join(home, ".omp", "agent"),
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The argv that launches a payload under the worker identity — the transition
|
|
485
|
+
* happens in the kernel at setpriv, BEFORE the first byte of any payload code
|
|
486
|
+
* runs, and setpriv does not exec the payload unless the transition succeeded
|
|
487
|
+
* (a failure exits non-zero with the reason on stderr, and the calling run
|
|
488
|
+
* settles failed with no live child). `--` protects a payload argv[0] that
|
|
489
|
+
* starts with `-`.
|
|
490
|
+
*
|
|
491
|
+
* A payload whose target uid/gid already equal this process's is returned
|
|
492
|
+
* unchanged: the identity is already in force, and spawning setpriv would
|
|
493
|
+
* only fail needlessly on a host without setuid privileges. That is also the
|
|
494
|
+
* only way the parity suite can drive the full spawn path as a non-root CI
|
|
495
|
+
* user.
|
|
496
|
+
*/
|
|
497
|
+
export function workerLaunchArgv(
|
|
498
|
+
payloadArgv: readonly string[],
|
|
499
|
+
identity: WorkerIdentity | undefined,
|
|
500
|
+
currentUid: number = process.getuid?.() ?? 0,
|
|
501
|
+
currentGid: number = process.getgid?.() ?? 0,
|
|
502
|
+
): string[] {
|
|
503
|
+
if (identity === undefined) return [...payloadArgv];
|
|
504
|
+
if (identity.uid === currentUid && identity.gid === currentGid) return [...payloadArgv];
|
|
505
|
+
// `--init-groups` initialises the supplementary groups from the identity
|
|
506
|
+
// being dropped to. It deliberately takes NO account operand (util-linux
|
|
507
|
+
// 2.39; newer versions accept an optional one): appending the account after
|
|
508
|
+
// it would be parsed as the program to exec on 2.39 and the launch would
|
|
509
|
+
// die with "failed to execute <account>" before any identity was dropped.
|
|
510
|
+
return [
|
|
511
|
+
identity.setpriv,
|
|
512
|
+
"--reuid",
|
|
513
|
+
identity.account,
|
|
514
|
+
"--regid",
|
|
515
|
+
identity.account,
|
|
516
|
+
"--init-groups",
|
|
517
|
+
"--",
|
|
518
|
+
...payloadArgv,
|
|
519
|
+
];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The environment a worker session child runs with: the daemon's, with the
|
|
524
|
+
* worker's own HOME (a worker under /root's HOME would try to write the
|
|
525
|
+
* daemon's home), its own harness agent dir (so config discovery is
|
|
526
|
+
* deterministic whatever the parent's environment says), and its own
|
|
527
|
+
* conductor state root (the worker's `omp-conductor` CLI reads its own empty
|
|
528
|
+
* store instead of the daemon's — the daemon's DB is daemon state, not
|
|
529
|
+
* worker input).
|
|
530
|
+
*/
|
|
531
|
+
export function workerSessionEnv(
|
|
532
|
+
base: Record<string, string | undefined>,
|
|
533
|
+
identity: WorkerIdentity,
|
|
534
|
+
): Record<string, string | undefined> {
|
|
535
|
+
return {
|
|
536
|
+
...base,
|
|
537
|
+
HOME: identity.home,
|
|
538
|
+
PI_CODING_AGENT_DIR: identity.agentDir,
|
|
539
|
+
OMP_CONDUCTOR_HOME: join(identity.home, ".omp", "conductor"),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export interface WorkerHarnessImportOptions {
|
|
544
|
+
bun?: string;
|
|
545
|
+
moduleDir?: string;
|
|
546
|
+
bindingRoot?: string;
|
|
547
|
+
identity?: WorkerIdentity;
|
|
548
|
+
timeoutMs?: number;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Run the anchored harness loader through the worker's real HOME and setpriv
|
|
553
|
+
* transition. Setup uses this after the inode checks: a mount can be current
|
|
554
|
+
* while ambient Bun resolution still chooses its install cache (#828).
|
|
555
|
+
*/
|
|
556
|
+
export function workerHarnessImportProblem(
|
|
557
|
+
options: WorkerHarnessImportOptions = {},
|
|
558
|
+
): string | undefined {
|
|
559
|
+
const bun = options.bun ?? process.execPath;
|
|
560
|
+
const moduleDir = options.moduleDir ?? import.meta.dir;
|
|
561
|
+
const bindingRoot = options.bindingRoot ?? WORKER_HARNESS_NODE_MODULES;
|
|
562
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
563
|
+
const packageRoot = packageNodeModulesRoot(moduleDir);
|
|
564
|
+
if (packageRoot === undefined) {
|
|
565
|
+
return `cannot probe the worker harness import because ${moduleDir} is not inside a node_modules install root`;
|
|
566
|
+
}
|
|
567
|
+
let identity = options.identity;
|
|
568
|
+
if (identity === undefined) {
|
|
569
|
+
const resolved = resolveWorkerIdentity({ harnessProblem: () => undefined });
|
|
570
|
+
if (!resolved.ok) return resolved.reason;
|
|
571
|
+
identity = resolved.identity;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let sourceHarness: string;
|
|
575
|
+
let sourceNative: string;
|
|
576
|
+
let expectedVersion: string;
|
|
577
|
+
try {
|
|
578
|
+
sourceHarness = Bun.resolveSync(OMP_HARNESS_PACKAGE, moduleDir);
|
|
579
|
+
sourceNative = Bun.resolveSync(OMP_NATIVES_PACKAGE, dirname(sourceHarness));
|
|
580
|
+
const parsed: unknown = JSON.parse(
|
|
581
|
+
readFileSync(join(packageRoot, OMP_HARNESS_PACKAGE, "package.json"), "utf8"),
|
|
582
|
+
);
|
|
583
|
+
if (parsed === null || typeof parsed !== "object") throw new Error("package metadata is not an object");
|
|
584
|
+
const version = Reflect.get(parsed, "version");
|
|
585
|
+
if (typeof version !== "string" || version === "") throw new Error("package version is absent");
|
|
586
|
+
expectedVersion = version;
|
|
587
|
+
} catch (cause) {
|
|
588
|
+
return `cannot resolve the operator's installed harness for the worker probe: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const probe = workerHarnessPath(join(moduleDir, "harness-loader.ts"), packageRoot, bindingRoot);
|
|
592
|
+
const expectedHarness = workerHarnessPath(sourceHarness, packageRoot, bindingRoot);
|
|
593
|
+
const expectedNative = workerHarnessPath(sourceNative, packageRoot, bindingRoot);
|
|
594
|
+
if (probe === undefined || expectedHarness === undefined || expectedNative === undefined) {
|
|
595
|
+
return "cannot map the installed harness probe through the worker binding";
|
|
596
|
+
}
|
|
597
|
+
const argv = workerLaunchArgv([bun, "--no-install", probe], identity);
|
|
598
|
+
const command = argv[0];
|
|
599
|
+
if (command === undefined) return "cannot launch the worker harness probe: its argv is empty";
|
|
600
|
+
const ran = spawnSync(command, argv.slice(1), {
|
|
601
|
+
cwd: identity.home,
|
|
602
|
+
env: workerSessionEnv(process.env, identity),
|
|
603
|
+
encoding: "utf8",
|
|
604
|
+
timeout: timeoutMs,
|
|
605
|
+
killSignal: "SIGKILL",
|
|
606
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
607
|
+
});
|
|
608
|
+
const errorCode = ran.error === undefined ? undefined : Reflect.get(ran.error, "code");
|
|
609
|
+
if (errorCode === "ETIMEDOUT") {
|
|
610
|
+
return `the worker harness import probe timed out after ${timeoutMs} ms`;
|
|
611
|
+
}
|
|
612
|
+
if (ran.status !== 0) {
|
|
613
|
+
const detail =
|
|
614
|
+
`${ran.error?.message ?? ""}\n${ran.stderr ?? ""}${ran.stdout ?? ""}`.trim();
|
|
615
|
+
return (
|
|
616
|
+
"the worker identity cannot import the operator's harness and native addon through the binding" +
|
|
617
|
+
(detail === "" ? "" : `: ${detail}`)
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
let report: unknown;
|
|
622
|
+
try {
|
|
623
|
+
const line = (ran.stdout ?? "").trim().split("\n").at(-1);
|
|
624
|
+
report = JSON.parse(line ?? "");
|
|
625
|
+
} catch {
|
|
626
|
+
return "the worker harness import probe returned no readable attestation";
|
|
627
|
+
}
|
|
628
|
+
if (report === null || typeof report !== "object") {
|
|
629
|
+
return "the worker harness import probe returned no readable attestation";
|
|
630
|
+
}
|
|
631
|
+
const loadedPath = Reflect.get(report, "path");
|
|
632
|
+
const loadedVersion = Reflect.get(report, "version");
|
|
633
|
+
const nativePath = Reflect.get(report, "nativePath");
|
|
634
|
+
if (
|
|
635
|
+
loadedPath !== expectedHarness ||
|
|
636
|
+
loadedVersion !== expectedVersion ||
|
|
637
|
+
nativePath !== expectedNative
|
|
638
|
+
) {
|
|
639
|
+
return (
|
|
640
|
+
`the worker harness import resolved ${String(loadedPath)} at version ${String(loadedVersion)} ` +
|
|
641
|
+
`with native addon ${String(nativePath)}, expected ${expectedHarness} at version ${expectedVersion} ` +
|
|
642
|
+
`with ${expectedNative}`
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Why the current process does not satisfy the identity its launch spec
|
|
650
|
+
* required, or `undefined` when it does. The session child's first act, as
|
|
651
|
+
* early as there is a socket to report over: a child whose kernel identity is
|
|
652
|
+
* not the one it was launched for proves the boundary did not hold and must
|
|
653
|
+
* refuse to run worker code. The uid/gid getters default to this process's
|
|
654
|
+
* own, and are parameters so the check is testable without spawning.
|
|
655
|
+
*/
|
|
656
|
+
export function identityMismatch(
|
|
657
|
+
expected: { uid: number; gid: number } | undefined,
|
|
658
|
+
getuid: () => number = () => process.getuid?.() ?? 0,
|
|
659
|
+
getgid: () => number = () => process.getgid?.() ?? 0,
|
|
660
|
+
): string | undefined {
|
|
661
|
+
if (expected === undefined) return undefined;
|
|
662
|
+
const uid = getuid();
|
|
663
|
+
const gid = getgid();
|
|
664
|
+
if (uid !== expected.uid || gid !== expected.gid) {
|
|
665
|
+
return (
|
|
666
|
+
`worker identity mismatch: running as uid ${uid} gid ${gid}, but this session was launched ` +
|
|
667
|
+
`for uid ${expected.uid} gid ${expected.gid} — the identity transition did not hold, refusing to start`
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
return undefined;
|
|
671
|
+
}
|