omp-conductor 0.5.4 → 0.5.6

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 CHANGED
@@ -2166,8 +2166,9 @@ refuses a connection from anything else without answering it, logged the way an
2166
2166
  impersonation is. Until a channel is bound it refuses everything, because the
2167
2167
  socket necessarily exists before the child that connects to it. The kernel
2168
2168
  supplies the pid: `SO_PEERCRED` on Linux, `LOCAL_PEERPID` on macOS. A host where
2169
- neither can be asked refuses every connection and says so at startup rather than
2170
- falling back to the uid, which would be no check at all.
2169
+ neither can be asked no loadable libc, or the call refused refuses every
2170
+ connection on a bound channel and says so at startup, rather than falling back to
2171
+ the uid, which under one shared uid is no check at all.
2171
2172
 
2172
2173
  The residual is narrow, real, and worth stating: one uid can `ptrace` and signal
2173
2174
  its siblings, so a determined session can still interfere with the process that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
package/src/omp.ts CHANGED
@@ -361,6 +361,13 @@ const START_TIMEOUT_MS = 180_000;
361
361
  /** Bounded tail of the child's stderr, so a crash is legible without unbounded buffering. */
362
362
  const STDERR_TAIL = 8_000;
363
363
 
364
+ /**
365
+ * How long a dying child's pipes get to finish before its failure is reported
366
+ * without them. Generous because it only ever delays an already-failed startup,
367
+ * and the streams normally reach EOF the instant the process does.
368
+ */
369
+ const DRAIN_GRACE_MS = 2_000;
370
+
364
371
  /** How long a disposed child gets to exit before it is signalled. */
365
372
  const DISPOSE_GRACE_MS = 5_000;
366
373
 
@@ -511,8 +518,35 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
511
518
  }
512
519
  }
513
520
  };
514
- void drain(child.stdout, "session: ");
515
- void drain(child.stderr, "session: ");
521
+ // Kept, not discarded: the child's own stderr is the whole value of the startup
522
+ // failure path, and it is a *race* against `child.exited` rather than a
523
+ // guarantee. A child that dies before connecting resolves `exited` as soon as
524
+ // the process is gone, which can be before these loops have read what it
525
+ // printed on the way out — and then the operator gets "exited 1" instead of the
526
+ // reason, which is precisely the diagnosis this path exists to give. Observed
527
+ // failing on ubuntu and passing on darwin, which is what a race looks like.
528
+ const drained = Promise.all([drain(child.stdout, "session: "), drain(child.stderr, "session: ")]);
529
+ drained.catch(() => undefined);
530
+
531
+ /**
532
+ * The child's output, once the pipes have actually finished — the whole point of
533
+ * every failure path below.
534
+ *
535
+ * Bounded rather than awaited outright: a grandchild that inherited stderr keeps
536
+ * the pipe open after the child is gone, and a failure nobody reports is worse
537
+ * than one reported without its tail. Both exit routes go through here so they
538
+ * cannot drift apart again; the pre-connect one did, which is how a release run
539
+ * caught this.
540
+ */
541
+ const settledTail = async (): Promise<string> => {
542
+ await Promise.race([
543
+ drained,
544
+ new Promise<void>((resolve) => {
545
+ setTimeout(resolve, DRAIN_GRACE_MS).unref?.();
546
+ }),
547
+ ]);
548
+ return stderrTail.trim();
549
+ };
516
550
 
517
551
  const cleanup = (): void => {
518
552
  server.close();
@@ -544,7 +578,7 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
544
578
  pending.clear();
545
579
  };
546
580
 
547
- void child.exited.then((code) => {
581
+ void child.exited.then(async (code) => {
548
582
  onExit();
549
583
  cleanup();
550
584
  // A child that exits during teardown exited because we asked it to. Only an
@@ -552,6 +586,8 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
552
586
  // to reach whoever is awaiting a prompt, or the dispatcher waits forever
553
587
  // for a turn from a process that is gone.
554
588
  if (disposing) return;
589
+ // The tail first, so the message carries the child's own words.
590
+ await settledTail();
555
591
  fail(`omp-conductor session child exited ${String(code)} before the session ended`);
556
592
  });
557
593
 
@@ -577,7 +613,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
577
613
  } catch (err) {
578
614
  child.kill("SIGKILL");
579
615
  cleanup();
580
- const tail = stderrTail.trim();
616
+ // Killed first, so the pipes are already closing, and only then read. This is
617
+ // the route a child that dies before connecting takes — a missing peer
618
+ // dependency, say — and reading `stderrTail` synchronously here raced the
619
+ // drain loops and reported a bare exit code instead of the reason.
620
+ const tail = await settledTail();
581
621
  throw new Error(
582
622
  `${err instanceof Error ? err.message : String(err)}${tail === "" ? "" : `\nchild output:\n${tail}`}`,
583
623
  );
@@ -413,10 +413,29 @@ export function peerVerdict(
413
413
  };
414
414
  }
415
415
  if (peer === undefined) {
416
+ // A channel bound to a process and no way to identify the caller is the one
417
+ // combination that must never pass. Under one uid the mode says nothing about
418
+ // *which* session is connecting, so allowing here would hand any session the
419
+ // authority of whichever channel it reached — on exactly the hosts where the
420
+ // check silently could not run. This ordering was the bug: the previous
421
+ // release returned `socket-ownership` here before ever looking at the
422
+ // expected pid, so a host whose libc would not load failed OPEN while the
423
+ // startup banner claimed it refused everything.
424
+ if (expected.pid !== undefined) {
425
+ return {
426
+ ok: false,
427
+ peerUid: -1,
428
+ expectedUid: expected.uid,
429
+ detail:
430
+ `this channel belongs to pid ${expected.pid} and this host reports no peer credentials at all, ` +
431
+ `so the caller cannot be identified — every session here shares one uid, and the socket mode ` +
432
+ `cannot tell them apart`,
433
+ };
434
+ }
416
435
  return {
417
436
  ok: true,
418
437
  basis: "socket-ownership",
419
- why: "this host exposes no peer-credential call, so the 0600 socket under the daemon-owned 0711 parent is all that keeps other local accounts out",
438
+ why: "this host exposes no peer-credential call and no pid was expected, so the 0600 socket under the daemon-owned 0711 parent is all that keeps other local accounts out",
420
439
  };
421
440
  }
422
441
  if (peer.uid !== expected.uid) {
@@ -457,7 +476,7 @@ export function peerVerdict(
457
476
  export function transportBanner(dir: string, peerReader: PeerReader | undefined): string {
458
477
  const peers =
459
478
  peerReader === undefined
460
- ? `no peer-credential call on ${process.platform} — channels REFUSE every connection`
479
+ ? `no peer-credential call on ${process.platform} — every bound channel REFUSES every connection`
461
480
  : process.platform === "darwin"
462
481
  ? "caller pid+uid asserted with getpeereid and LOCAL_PEERPID; each channel bound to the session the daemon launched"
463
482
  : "caller pid+uid asserted with SO_PEERCRED; each channel bound to the session the daemon launched";