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/host.ts CHANGED
@@ -187,34 +187,7 @@ export function rssBytesFromHealthz(body: string | undefined): number | undefine
187
187
  }
188
188
  }
189
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";
190
+ // ------------------------------------------------------- harness resolution --
218
191
 
219
192
  /**
220
193
  * The harness package omp-conductor loads a session from. Held here, beside
@@ -227,36 +200,14 @@ export const OMP_HARNESS_PACKAGE = "@oh-my-pi/pi-coding-agent";
227
200
  export const OMP_NATIVES_PACKAGE = "@oh-my-pi/pi-natives";
228
201
 
229
202
  /**
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).
203
+ * The mount point releases 0.18.1-era bound the operator's install at, kept
204
+ * only as the retirement target `setup host` and `reconcile-units` remove
205
+ * (#895). Nothing mounts it any more: worker sessions launch under the fleet
206
+ * account again (#894), so the harness resolves through ordinary Node/Bun
207
+ * resolution from this package's own install root.
253
208
  */
254
209
  export const WORKER_HARNESS_DIR = "/var/lib/omp-worker-harness";
255
210
 
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
211
  /**
261
212
  * The install root a module path sits in — its nearest ancestor named
262
213
  * `node_modules` — or `undefined` when it has none, which is this package
@@ -269,403 +220,3 @@ export function packageNodeModulesRoot(modulePath: string): string | undefined {
269
220
  const idx = parts.lastIndexOf("node_modules");
270
221
  return idx < 0 ? undefined : `/${parts.slice(0, idx + 1).join("/")}`;
271
222
  }
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
- }
@@ -36,6 +36,25 @@ import type { ProjectConfig } from "./types.ts";
36
36
  /** The overlay filename inside a run's session directory. */
37
37
  export const OMP_SETTINGS_FILE = "omp-settings.yml";
38
38
 
39
+ /**
40
+ * The OMP model-role names one settings map declares — the keys of its
41
+ * `modelRoles` stanza (#875). This is the one grammar every surface that
42
+ * names an adjudicator role reads: omp's own `modelRoles` config, whether it
43
+ * sits in the daemon account's global settings, a project's overlay, or both.
44
+ * Anything that is not a flat string-map names no roles at all — an overlay
45
+ * whose `modelRoles` is, say, a YAML list is omp's to reject, and offering
46
+ * garbage role names here would only train the setup dialog on values OMP
47
+ * would refuse. `null` and any other non-object input — including an empty
48
+ * or comments-only settings file whose YAML parses to `null` — is zero
49
+ * roles, never a fault.
50
+ */
51
+ export function modelRolesIn(settings: unknown): readonly string[] {
52
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings)) return [];
53
+ const roles = (settings as Record<string, unknown>)["modelRoles"];
54
+ if (roles === null || typeof roles !== "object" || Array.isArray(roles)) return [];
55
+ return Object.keys(roles as Record<string, unknown>);
56
+ }
57
+
39
58
  /**
40
59
  * The effective overlay map for a project: the opaque `ompSettings` map plus
41
60
  * the retry keys (`retry.modelFallback`, `retry.fallbackChains.default`)
package/src/omp.ts CHANGED
@@ -8,20 +8,13 @@
8
8
  * `createAgentSession`, `subscribe` or `abort`, exactly one file breaks.
9
9
  */
10
10
 
11
- import { chmodSync, chownSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
11
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
12
12
  import { createServer, type Server, type Socket } from "node:net";
13
13
  import { tmpdir } from "node:os";
14
14
  import { dirname, join } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
 
17
- import {
18
- OMP_HARNESS_PACKAGE,
19
- packageNodeModulesRoot,
20
- workerHarnessPath,
21
- workerLaunchArgv,
22
- workerSessionEnv,
23
- type WorkerIdentity,
24
- } from "./host.ts";
17
+ import { OMP_HARNESS_PACKAGE, packageNodeModulesRoot } from "./host.ts";
25
18
  import { harnessVersion, resolveHarnessEntry } from "./harness-loader.ts";
26
19
  import { readOnlySession, worktreeConfinement } from "./confinement.ts";
27
20
  import { observeGraphTools } from "./graph.ts";
@@ -615,18 +608,6 @@ export interface CreateSessionOptions {
615
608
  model?: string;
616
609
  resume?: boolean;
617
610
  role: SessionRole;
618
- /**
619
- * The worker identity this session must run under (#798): when set, the
620
- * child is launched through {@link workerLaunchArgv}'s identity transition
621
- * (setpriv) before any of its code runs, its environment is re-pointed at
622
- * the worker's own home/agent path, the control socket is granted to that
623
- * uid, and the child refuses to build a session unless its kernel uid/gid
624
- * match. The daemon resolves this identity once and threads it through every
625
- * worker launch; a worker session is never launched without it. Absent, the
626
- * session runs as this process's own identity — the orchestrator and every
627
- * test surface.
628
- */
629
- identity?: WorkerIdentity;
630
611
  releaseGrants?: ResolvedGrants;
631
612
  onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
632
613
  /**
@@ -660,6 +641,24 @@ export interface CreateSessionOptions {
660
641
  * its verb channel to this pid, and a channel accepts nothing until it is bound.
661
642
  */
662
643
  onSpawn?: (pid: number) => void;
644
+ /**
645
+ * The session's Herdr representation (#840), opened from the exact pid the
646
+ * spawn below reports and released when the child is gone.
647
+ *
648
+ * Injected rather than imported so this module keeps knowing nothing about
649
+ * Herdr: `createSession` owns the process, and whoever launches a *worker*
650
+ * owns whether that process should be visible in an operator's workspace. A
651
+ * caller that passes nothing gets today's behaviour exactly.
652
+ *
653
+ * `open` is called with the same pid, at the same instant, as
654
+ * {@link CreateSessionOptions.onSpawn} — the representation is of the
655
+ * authoritative child or of nothing. It must never throw and never block the
656
+ * launch: visibility is not a precondition for work.
657
+ */
658
+ pane?: {
659
+ open(pid: number): void;
660
+ release(): void;
661
+ };
663
662
  /**
664
663
  * Pre-spawn admission gate (#374). Consulted once, immediately after the
665
664
  * socket bind await and immediately before `Bun.spawn` — the last window a
@@ -682,12 +681,6 @@ export interface CreateSessionOptions {
682
681
  * without a live harness or a model bill.
683
682
  */
684
683
  hostModule?: string;
685
- /**
686
- * Test-only worker binding root. Production always uses
687
- * `WORKER_HARNESS_NODE_MODULES`; the Linux privilege regression supplies an
688
- * inode-identical temporary binding so it never mutates the live host mount.
689
- */
690
- harnessNodeModules?: string;
691
684
  /**
692
685
  * Deny every tool but reading and searching (#307).
693
686
  *
@@ -780,23 +773,15 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
780
773
  "daemon shutdown began while the session socket was binding",
781
774
  );
782
775
  }
783
- // The socket is the run's own channel, and the daemon's own uid is the only
784
- // one that speaks on itunless this session runs under the worker
785
- // identity, in which case the child (and only the child) is the worker uid,
786
- // and the socket must be granted to it or the very first connect fails
787
- // before the identity could matter.
776
+ // The socket is the run's own channel, and this process's own uid the
777
+ // fleet account every session runs as (#894) is the only one that speaks
778
+ // on it.
788
779
  chmodSync(socketPath, 0o600);
789
- if (opts.identity !== undefined) {
790
- chownSync(socketPath, opts.identity.uid, opts.identity.gid);
791
- }
792
780
 
793
781
  const spec: SessionHostSpec = {
794
782
  socket: socketPath,
795
783
  cwd: opts.cwd,
796
784
  role: opts.role,
797
- ...(opts.identity === undefined
798
- ? {}
799
- : { identity: { uid: opts.identity.uid, gid: opts.identity.gid } }),
800
785
  ...(opts.sessionDir === undefined ? {} : { sessionDir: opts.sessionDir }),
801
786
  ...(opts.model === undefined ? {} : { model: opts.model }),
802
787
  ...(opts.resume === undefined ? {} : { resume: opts.resume }),
@@ -811,30 +796,18 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
811
796
 
812
797
  const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
813
798
  const hostModule = opts.hostModule ?? SESSION_HOST;
814
- // Under the worker identity the child launches from the read-only binding of
815
- // the operator's install (#828). The child then resolves the harness from an
816
- // explicit root and runs with `--no-install`; the entry remap and resolver
817
- // anchor therefore name the same tree, while Bun has no ambient-cache
818
- // fallback for either the peer or its transitive/native imports.
819
- const packageRoot = packageNodeModulesRoot(hostModule);
820
- const boundEntry =
821
- opts.harnessNodeModules === undefined
822
- ? workerHarnessPath(hostModule, packageRoot)
823
- : workerHarnessPath(hostModule, packageRoot, opts.harnessNodeModules);
824
- const entryModule = opts.identity === undefined ? hostModule : boundEntry ?? hostModule;
825
- const payloadArgv = [process.execPath, "--no-install", entryModule, JSON.stringify(spec)];
826
- const argv = workerLaunchArgv(payloadArgv, opts.identity);
799
+ // The child is the same installed tree this process runs from, launched
800
+ // under the fleet account (#894). `--no-install` keeps Bun off its ambient
801
+ // auto-install cache, so the peer and its native addon resolve from the
802
+ // operator's exact install or fail loudly.
803
+ const argv = [process.execPath, "--no-install", hostModule, JSON.stringify(spec)];
827
804
  // The session's own role is stamped on the child, so a process the agent
828
805
  // runs — `omp-conductor report` from its sandbox — can tell a worker session
829
806
  // from the operator's shell. Direct CLI runs outside a spawned session
830
- // inherit nothing and stay the orchestrator surface. Under the worker
831
- // identity, the child's HOME and agent/state roots are re-pointed at the
832
- // worker account's own, so the harness it boots writes worker state, never
833
- // the daemon's.
807
+ // inherit nothing and stay the orchestrator surface.
834
808
  let env: Record<string, string | undefined> | undefined;
835
809
  if (opts.role !== undefined) {
836
810
  env = { ...process.env, [SESSION_ROLE_ENV]: opts.role };
837
- if (opts.identity !== undefined) env = workerSessionEnv(env, opts.identity);
838
811
  }
839
812
  const child = Bun.spawn(argv, {
840
813
  cwd: opts.cwd,
@@ -850,6 +823,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
850
823
  // Synchronously, before a single await: the child cannot have connected yet, so
851
824
  // the caller's channel is bound before it can be reached.
852
825
  if (child.pid !== undefined) opts.onSpawn?.(child.pid);
826
+ // The workspace representation is of THIS pid or of nothing (#840). After the
827
+ // verb-channel bind above, because a pane is a convenience and the bind is a
828
+ // correctness boundary — and never before the child exists, which is what
829
+ // makes "bound to the exact session-host pid" true rather than nominal.
830
+ if (child.pid !== undefined) opts.pane?.open(child.pid);
853
831
 
854
832
  let stderrTail = "";
855
833
  const drain = async (stream: ReadableStream<Uint8Array> | undefined, prefix: string): Promise<void> => {
@@ -1010,6 +988,12 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
1010
988
  onExit();
1011
989
  const exitCode = typeof code === "number" ? code : null;
1012
990
  emitSessionExit(exitCode);
991
+ // The pid this pane spoke for is gone, so conductor stops speaking for it
992
+ // (#840) — on every exit path, crash and clean dispose alike, because a
993
+ // representation that outlives its process is a worker the workspace still
994
+ // shows as live. What happens to the pane itself is #841's policy; this only
995
+ // gives back the authority.
996
+ opts.pane?.release();
1013
997
  // A child that cannot start writes `start-error` over the socket, not the
1014
998
  // pipes — and its exit can be dispatched before the accept of a connection
1015
999
  // that already completed in the kernel. Let the socket settle before the