omp-conductor 0.5.3 → 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
@@ -2028,7 +2028,7 @@ dispatcher. The brief is explicit about the boundary:
2028
2028
  | Add or update tests for behaviour it introduced. | Suppress a warning, delete an assertion, or special-case an input to make a check pass. |
2029
2029
  | Run the repo's configured cheap gates, each from its listed `cwd`, over the whole tree. | Run docker or image builds, production builds, browser/e2e suites, or the full test suite on the shared host — CI owns the heavy gates. |
2030
2030
  | Review its whole diff, then commit and publish once with `conductor_push`. One corrective push if CI is red. | Force-push, `git add -f`, or add AI/co-author attribution. There is no force path to reach: `conductor_push` publishes that run's branch fast-forward only and takes no other ref. Red twice means stop and report, not push a third time. |
2031
- | Open a PR with `conductor_pr_create`, and poll CI to a verdict with `conductor_pr_status`. | Run `gh pr merge` — or reach `conductor_pr_merge`, which refuses a worker session mechanically. **A worker is never authorised to merge**, whoever else holds the authority, so PRs land one at a time with a freshness re-check; two workers merging concurrently is how agent PRs clobber each other. Who *may* merge is the [`authority`](#configuration) answer, and it is never the worker. The verb refusal is mechanical; reaching for `gh` instead is a prohibition, and one that shows up as a merge absent from the [ledger](#the-ledger). |
2031
+ | Open a PR with `conductor_pr_create`, and poll CI to a verdict with `conductor_pr_status`. | Run `gh pr merge` — or reach `conductor_pr_merge`, which refuses a worker session mechanically. **A worker is never authorised to merge**, whoever else holds the authority, so PRs land one at a time with a freshness re-check; two workers merging concurrently is how agent PRs clobber each other. Who *may* merge is the [`authority`](#configuration) answer, and it is never the worker. The verb refusal is mechanical, and so is the channel: each one is bound to the pid the daemon spawned, so reaching for the orchestrator's socket is refused rather than honoured (see [The transport](#the-transport)). Shelling out to `gh` remains a prohibition, not an impossibility — a session shares the daemon's credentials. |
2032
2032
  | Escalate: ambiguity, a cross-repo contract, a needed credential, a product or data-migration decision, a blocking existing test, CI red twice, or most of the wall-clock budget burned. | Cut a release, push a tag, publish to npm, edit a deployment pin, deploy, or touch infrastructure or secrets. `conductor_release` and `conductor_label` refuse a worker whatever `releasePolicy` says, because the check compares the caller against the configured holder rather than ruling one value out. The in-session tripwire still blocks recognised release/deploy tool calls early and audits the attempt, but it is [defence in depth](#the-mediated-verbs-126), not the gate. |
2033
2033
 
2034
2034
  The worker ends with a seven-line evidence report (issue, PR, observed head SHA,
@@ -2149,10 +2149,31 @@ every release shape under the most permissive config there is.
2149
2149
  ### The transport
2150
2150
 
2151
2151
  Identity is never an argument. `project`, `run`, `issue` and the caller's role
2152
- come from **which socket the call arrived on** plus the verified peer uid, and a
2153
- request carrying any of those field names is refused outright, named. So a worker
2154
- on run X cannot *ask* to merge run Y's PR: on its own channel that request is
2155
- unexpressible, and a merge is refused for a worker session on any channel.
2152
+ come from **which socket the call arrived on**, and a request carrying any of
2153
+ those field names is refused outright, named. So a worker on run X cannot *ask*
2154
+ to merge run Y's PR on its own channel that request is unexpressible.
2155
+
2156
+ **Each channel is bound to one process, because the modes cannot tell sessions
2157
+ apart.** Every session runs as the daemon's own uid, so it matches the *owner*
2158
+ class here: it can list this directory and connect to any socket in it, including
2159
+ the orchestrator's. Authorisation and the ledger both read the role from the
2160
+ channel, so a worker doing that would have been authorised as the orchestrator
2161
+ (under `authority.merge: "orchestrator"`) *and recorded as* the orchestrator. No
2162
+ file mode closes that — the owner bits belong to the uid the session already has.
2163
+
2164
+ So the daemon binds each channel to the **pid it spawned for that session**, and
2165
+ refuses a connection from anything else without answering it, logged the way an
2166
+ impersonation is. Until a channel is bound it refuses everything, because the
2167
+ socket necessarily exists before the child that connects to it. The kernel
2168
+ supplies the pid: `SO_PEERCRED` on Linux, `LOCAL_PEERPID` on macOS. A host where
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.
2172
+
2173
+ The residual is narrow, real, and worth stating: one uid can `ptrace` and signal
2174
+ its siblings, so a determined session can still interfere with the process that
2175
+ *is* bound. That is a far higher bar than connecting to a socket, and closing it
2176
+ needs separate OS principals.
2156
2177
 
2157
2178
  ```
2158
2179
  <state dir>/verbs/ daemon-owned, mode 0711
@@ -2298,9 +2319,12 @@ Known and deliberate in this version:
2298
2319
  - **Workers stop at green PRs.** They are never authorised to merge, release or
2299
2320
  deploy: those actions default to a human, and while setup may grant either to
2300
2321
  the orchestrator, `authority` never grants them to a worker or the dispatch
2301
- daemon. The verbs refuse a worker mechanically. A session that shells out
2302
- instead is violating its brief rather than defeating a gate sessions inherit
2303
- the daemon's credentials and the missing ledger entry is the evidence.
2322
+ daemon. The verbs refuse a worker mechanically, and a worker reaching for
2323
+ another session's channel is refused too each channel is bound to the pid the
2324
+ daemon spawned for it. What remains a prohibition rather than a gate is shelling
2325
+ out to `gh` directly: sessions inherit the daemon's credentials. That shows up as
2326
+ a mutation with no matching ledger entry, which is a mismatch an operator can
2327
+ find.
2304
2328
  - **The worker gate is partial, and the orchestrator has none.** A worker's
2305
2329
  structured `write` / `edit` / `read` / `grep` / `glob` calls are gated to its
2306
2330
  worktree by an inline harness extension; `bash` is not, so a shell one-liner
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.5.3",
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/daemon.ts CHANGED
@@ -976,6 +976,14 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
976
976
  // a child process of the daemon reaches it directly.
977
977
  socketPath: join(sessionDir, "ipc.sock"),
978
978
  verbSocketPath: verbListener.path,
979
+ // Names this run's channel the instant the child exists. Until this
980
+ // fires the channel refuses every connection, because a session that
981
+ // reached it first would be one nobody had identified — and every
982
+ // session shares this daemon's uid, so the socket alone cannot tell
983
+ // them apart (#163).
984
+ onSpawn: (pid) => {
985
+ verbListener?.bindPid(pid);
986
+ },
979
987
  onChildLog: (line) => {
980
988
  log(`#${issue} ${line}`);
981
989
  },
@@ -3045,10 +3053,14 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
3045
3053
  const orchCwd = join(orchTreeRoot, "orchestrator");
3046
3054
  mkdirSync(orchCwd, { recursive: true });
3047
3055
  // A third socket, distinct from every run's, in the same daemon-owned
3048
- // 0711 parent. This is what makes "merge authority is the orchestrator's"
3049
- // a property of the channel: the daemon knows which session is speaking
3050
- // because of where the connection arrived, and no payload can move a
3051
- // worker's call onto this one (#126).
3056
+ // 0711 parent. It makes the orchestrator's authority a property of the
3057
+ // channel rather than of a payload: no argument list can move a worker's
3058
+ // call onto this one (#126).
3059
+ //
3060
+ // It does NOT authenticate the session. Sessions share this daemon's uid,
3061
+ // so one could list this directory and connect here, and the ledger would
3062
+ // record the orchestrator's role because that is the channel's. See
3063
+ // conductor#163 for the pid binding that would close it.
3052
3064
  orchestratorVerbs = await listenVerbChannel(
3053
3065
  verbDeps({ project, store, tracker, verbActions }),
3054
3066
  {
@@ -3065,6 +3077,12 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
3065
3077
  releaseGrants,
3066
3078
  socketPath: join(orchCwd, "ipc.sock"),
3067
3079
  verbSocketPath: orchestratorVerbs.path,
3080
+ // The orchestrator's channel is the one that matters most: it is the
3081
+ // session authorised to merge, so an unbound channel here is a worker's
3082
+ // route to that authority (#163).
3083
+ onSpawn: (pid) => {
3084
+ orchestratorVerbs?.bindPid(pid);
3085
+ },
3068
3086
  onChildLog: (line) => {
3069
3087
  log(`orchestrator ${line}`);
3070
3088
  },
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
 
@@ -388,6 +395,14 @@ export interface CreateSessionOptions {
388
395
  * session plumbing an attack surface for verbs.
389
396
  */
390
397
  verbSocketPath?: string;
398
+ /**
399
+ * The child's pid, the instant it exists.
400
+ *
401
+ * Called synchronously after `Bun.spawn` and before the child could have
402
+ * connected to anything, because that ordering is the point: the daemon binds
403
+ * its verb channel to this pid, and a channel accepts nothing until it is bound.
404
+ */
405
+ onSpawn?: (pid: number) => void;
391
406
  /** Child stderr, line by line. Defaults to the process's own stderr. */
392
407
  onChildLog?: (line: string) => void;
393
408
  startupTimeoutMs?: number;
@@ -483,6 +498,10 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
483
498
  stderr: "pipe",
484
499
  });
485
500
 
501
+ // Synchronously, before a single await: the child cannot have connected yet, so
502
+ // the caller's channel is bound before it can be reached.
503
+ if (child.pid !== undefined) opts.onSpawn?.(child.pid);
504
+
486
505
  let stderrTail = "";
487
506
  const drain = async (stream: ReadableStream<Uint8Array> | undefined, prefix: string): Promise<void> => {
488
507
  if (stream === undefined) return;
@@ -499,8 +518,35 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
499
518
  }
500
519
  }
501
520
  };
502
- void drain(child.stdout, "session: ");
503
- 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
+ };
504
550
 
505
551
  const cleanup = (): void => {
506
552
  server.close();
@@ -532,7 +578,7 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
532
578
  pending.clear();
533
579
  };
534
580
 
535
- void child.exited.then((code) => {
581
+ void child.exited.then(async (code) => {
536
582
  onExit();
537
583
  cleanup();
538
584
  // A child that exits during teardown exited because we asked it to. Only an
@@ -540,6 +586,8 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
540
586
  // to reach whoever is awaiting a prompt, or the dispatcher waits forever
541
587
  // for a turn from a process that is gone.
542
588
  if (disposing) return;
589
+ // The tail first, so the message carries the child's own words.
590
+ await settledTail();
543
591
  fail(`omp-conductor session child exited ${String(code)} before the session ended`);
544
592
  });
545
593
 
@@ -565,7 +613,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
565
613
  } catch (err) {
566
614
  child.kill("SIGKILL");
567
615
  cleanup();
568
- 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();
569
621
  throw new Error(
570
622
  `${err instanceof Error ? err.message : String(err)}${tail === "" ? "" : `\nchild output:\n${tail}`}`,
571
623
  );
@@ -47,6 +47,7 @@ export type CreateSessionFn = (opts: {
47
47
  role: SessionRole;
48
48
  releaseGrants?: ResolvedGrants;
49
49
  onReleaseBlocked?: (shape: ReleaseShape) => void;
50
+ onSpawn?: (pid: number) => void;
50
51
  socketPath?: string;
51
52
  verbSocketPath?: string;
52
53
  onChildLog?: (line: string) => void;
@@ -84,6 +85,8 @@ export interface OrchestratorOpts {
84
85
  model?: string;
85
86
  releaseGrants?: ResolvedGrants;
86
87
  onReleaseBlocked?: (shape: ReleaseShape) => void;
88
+ /** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
89
+ onSpawn?: (pid: number) => void;
87
90
  /** Control socket for the session child, beside its own working directory. */
88
91
  socketPath?: string;
89
92
  /**
@@ -180,6 +183,7 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
180
183
  role: "orchestrator",
181
184
  ...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
182
185
  ...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
186
+ ...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
183
187
  ...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
184
188
  ...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
185
189
  ...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
@@ -10,10 +10,24 @@
10
10
  * Four rules shape the order of the checks, and each is here because prose
11
11
  * alone did not hold it on `main`:
12
12
  *
13
- * - **Identity is the channel's.** `project`, `run`, `issue` and role come from
14
- * {@link VerbChannel} — which socket the call arrived on, plus the verified
15
- * peer uid. A worker's channel names exactly one run, so "merge run Y's PR"
16
- * is not a request this daemon can be sent from run X. It is unexpressible.
13
+ * - **Identity is the channel's, and the channel is bound to one process.**
14
+ * `project`, `run`, `issue` and role come from {@link VerbChannel} — which
15
+ * socket the call arrived on. No payload can name them, so a *request* to act
16
+ * as another run is unexpressible.
17
+ *
18
+ * That alone was never authentication: every session runs as the daemon's own
19
+ * uid, so any of them could list the socket directory and connect to another
20
+ * channel — including the orchestrator's, which holds merge under
21
+ * `authority.merge: "orchestrator"` — and the ledger would have recorded the
22
+ * channel's role, attributing it to the orchestrator. A file mode cannot fix
23
+ * that, because the owner bits belong to the uid the session already has.
24
+ *
25
+ * So each channel is **bound to the pid the daemon spawned for it**, and a
26
+ * connection from any other process is refused unanswered and logged. Until it
27
+ * is bound it refuses everything, because the socket necessarily exists before
28
+ * the child does. The residual is narrow and real: one uid can still `ptrace`
29
+ * or signal its siblings, so a determined session can interfere with the
30
+ * process that *is* bound — a much higher bar than connecting to a socket.
17
31
  * - **Compared against the configured holder, never against one forbidden
18
32
  * value.** `authority` has exactly two holders, so `!== "human"` would let a
19
33
  * worker release. Merge and release both ask "is the caller the holder".
@@ -53,6 +67,7 @@ import {
53
67
  unlinkStaleSocket,
54
68
  validateSocketPath,
55
69
  type PeerReader,
70
+ type PeerVerdict,
56
71
  type SocketOwnership,
57
72
  } from "./socket.ts";
58
73
 
@@ -915,6 +930,17 @@ async function prStatusVerb(
915
930
  export interface VerbListener {
916
931
  path: string;
917
932
  ownership: SocketOwnership;
933
+ /**
934
+ * Name the process this channel belongs to.
935
+ *
936
+ * Called with the child's pid the moment the daemon has spawned it, and the
937
+ * channel refuses every connection until it is: the socket has to exist before
938
+ * the child can connect to it, so there is a window between `listen` and the
939
+ * spawn, and a session that connected in that window would be one nobody had
940
+ * identified. `Bun.spawn` returns the pid synchronously, so the window closes
941
+ * before the child has finished booting.
942
+ */
943
+ bindPid(pid: number): void;
918
944
  close(): Promise<void>;
919
945
  }
920
946
 
@@ -964,8 +990,13 @@ export async function listenVerbChannel(
964
990
  // here and a race in a world-writable directory.
965
991
  unlinkStaleSocket(channel.path);
966
992
 
993
+ // Unbound until the daemon names the child. Read through a closure rather than
994
+ // captured by value so the connection handler sees the binding that exists when
995
+ // a connection arrives, not the one that existed when the server was created.
996
+ let boundPid: number | undefined;
997
+
967
998
  const server: Server = createServer((socket) => {
968
- handleConnection(deps, channel, socket, opts.peerReader);
999
+ handleConnection(deps, channel, socket, opts.peerReader, () => boundPid);
969
1000
  });
970
1001
  server.on("error", (err) => {
971
1002
  deps.log(`verb socket ${channel.path} errored: ${err.message}`);
@@ -985,6 +1016,9 @@ export async function listenVerbChannel(
985
1016
  return {
986
1017
  path: channel.path,
987
1018
  ownership,
1019
+ bindPid: (pid) => {
1020
+ boundPid = pid;
1021
+ },
988
1022
  close: async () => {
989
1023
  await new Promise<void>((resolve) => {
990
1024
  server.close(() => {
@@ -1001,10 +1035,29 @@ function handleConnection(
1001
1035
  channel: VerbChannel,
1002
1036
  socket: Socket,
1003
1037
  peerReader: PeerReader | undefined,
1038
+ boundPid: () => number | undefined,
1004
1039
  ): void {
1005
1040
  const fd = socketFd(socket);
1006
1041
  const peer = fd === undefined || peerReader === undefined ? undefined : peerReader(fd);
1007
- const verdict = peerVerdict({ uid: process.getuid?.() ?? 0 }, peer);
1042
+ const expectedPid = boundPid();
1043
+ // Refused until the daemon has named the process this channel belongs to.
1044
+ //
1045
+ // The socket must exist before the child can connect to it, so there is a real
1046
+ // window between `listen` and the spawn — and any session already running shares
1047
+ // this daemon's uid, so one of them could reach a freshly bound orchestrator
1048
+ // channel in that window and be authorised as the orchestrator. Nothing about
1049
+ // the socket distinguishes them; only the pid does (#163).
1050
+ const verdict: PeerVerdict =
1051
+ expectedPid === undefined
1052
+ ? {
1053
+ ok: false,
1054
+ peerUid: peer?.uid ?? -1,
1055
+ expectedUid: process.getuid?.() ?? 0,
1056
+ detail:
1057
+ "this channel has not been bound to a process yet, so the caller cannot be identified — " +
1058
+ "every session on this host shares the daemon's uid",
1059
+ }
1060
+ : peerVerdict({ uid: process.getuid?.() ?? 0, pid: expectedPid }, peer);
1008
1061
  if (!verdict.ok) {
1009
1062
  // Not a client error, and deliberately not answered: a caller who is not
1010
1063
  // who the socket was allocated to gets no reply to calibrate against.
@@ -236,6 +236,18 @@ export function secureBoundSocket(
236
236
  export interface PeerCredentials {
237
237
  uid: number;
238
238
  gid: number;
239
+ /**
240
+ * The connecting process, as the kernel reports it.
241
+ *
242
+ * The uid alone stopped meaning anything the moment sessions began sharing the
243
+ * daemon's account: every session satisfies it. The pid is what distinguishes
244
+ * them, because the daemon spawned each one and knows which is which.
245
+ *
246
+ * `undefined` only when the platform call failed. It is not optional in the
247
+ * sense of "nice to have" — {@link peerVerdict} refuses a channel whose pid it
248
+ * expected and could not read.
249
+ */
250
+ pid?: number;
239
251
  }
240
252
 
241
253
  /**
@@ -243,9 +255,9 @@ export interface PeerCredentials {
243
255
  * `undefined` where the platform (or this runtime's socket object) does not
244
256
  * expose them.
245
257
  *
246
- * `getpeereid` on darwin, `SO_PEERCRED` on Linuxboth asked of the kernel,
247
- * which is the point: the answer cannot be forged by the peer, unlike anything
248
- * the peer could put in a payload.
258
+ * `SO_PEERCRED` on Linux, `getpeereid` plus `LOCAL_PEERPID` on darwinall asked
259
+ * of the kernel, which is the point: the answer cannot be forged by the peer,
260
+ * unlike anything the peer could put in a payload.
249
261
  */
250
262
  export type PeerReader = (fd: number) => PeerCredentials | undefined;
251
263
 
@@ -258,6 +270,10 @@ interface LibcSymbols {
258
270
  const SOL_SOCKET_LINUX = 1;
259
271
  const SO_PEERCRED_LINUX = 17;
260
272
 
273
+ /** `SOL_LOCAL` / `LOCAL_PEERPID` from darwin's `sys/un.h`. Also ABI constants. */
274
+ const SOL_LOCAL_DARWIN = 0;
275
+ const LOCAL_PEERPID_DARWIN = 0x002;
276
+
261
277
  /**
262
278
  * The peer reader for this host, or `undefined` when there is none.
263
279
  *
@@ -271,7 +287,14 @@ const SO_PEERCRED_LINUX = 17;
271
287
  export function peerCredentialReader(): PeerReader | undefined {
272
288
  const declarations: Record<string, { args: FFIType[]; returns: FFIType }> =
273
289
  process.platform === "darwin"
274
- ? { getpeereid: { args: [FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 } }
290
+ ? {
291
+ getpeereid: { args: [FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
292
+ // For LOCAL_PEERPID: `getpeereid` answers uid and gid, and nothing else.
293
+ getsockopt: {
294
+ args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.ptr, FFIType.ptr],
295
+ returns: FFIType.i32,
296
+ },
297
+ }
275
298
  : {
276
299
  getsockopt: {
277
300
  args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.ptr, FFIType.ptr],
@@ -302,12 +325,21 @@ export function peerCredentialReader(): PeerReader | undefined {
302
325
 
303
326
  if (process.platform === "darwin") {
304
327
  const getpeereid = symbols.getpeereid;
328
+ const getsockoptDarwin = symbols.getsockopt;
305
329
  if (getpeereid === undefined) return undefined;
306
330
  return (fd) => {
307
331
  const uid = new Uint32Array(1);
308
332
  const gid = new Uint32Array(1);
309
333
  if (getpeereid(fd, ptr(uid), ptr(gid)) !== 0) return undefined;
310
- return { uid: uid[0] ?? -1, gid: gid[0] ?? -1 };
334
+ let pid: number | undefined;
335
+ if (getsockoptDarwin !== undefined) {
336
+ const out = new Uint32Array(1);
337
+ const len = new Uint32Array([out.byteLength]);
338
+ if (getsockoptDarwin(fd, SOL_LOCAL_DARWIN, LOCAL_PEERPID_DARWIN, ptr(out), ptr(len)) === 0) {
339
+ pid = out[0];
340
+ }
341
+ }
342
+ return { uid: uid[0] ?? -1, gid: gid[0] ?? -1, ...(pid === undefined ? {} : { pid }) };
311
343
  };
312
344
  }
313
345
 
@@ -320,7 +352,8 @@ export function peerCredentialReader(): PeerReader | undefined {
320
352
  if (getsockopt(fd, SOL_SOCKET_LINUX, SO_PEERCRED_LINUX, ptr(cred), ptr(len)) !== 0) {
321
353
  return undefined;
322
354
  }
323
- return { uid: cred[1] ?? -1, gid: cred[2] ?? -1 };
355
+ // struct ucred's first field is the pid, and it has been read all along.
356
+ return { uid: cred[1] ?? -1, gid: cred[2] ?? -1, ...(cred[0] === undefined ? {} : { pid: cred[0] }) };
324
357
  };
325
358
  }
326
359
 
@@ -339,22 +372,37 @@ export function socketFd(socket: unknown): number | undefined {
339
372
  }
340
373
 
341
374
  export type PeerVerdict =
375
+ | { ok: true; basis: "peer-pid"; uid: number; pid: number }
342
376
  | { ok: true; basis: "peer-uid"; uid: number }
343
377
  | { ok: true; basis: "socket-ownership"; why: string }
344
378
  | { ok: false; detail: string; peerUid: number; expectedUid: number };
345
379
 
346
380
  /**
347
- * Compare the kernel-reported peer against the uid the daemon allocated.
381
+ * Compare the kernel-reported peer against the session the daemon launched.
348
382
  *
349
- * The two `ok` bases are not the same guarantee and are deliberately not
350
- * collapsed. `peer-uid` means the kernel vouched for the caller. `socket-
351
- * ownership` means nobody could vouch and the whole guarantee is the `0600`
352
- * socket under an unlistable, daemon-owned, non-writable parent which is
353
- * real, but it does not distinguish two runs sharing one uid. A caller that
354
- * printed "verified" for both would be claiming something it never checked.
383
+ * The three `ok` bases are different guarantees and are deliberately not
384
+ * collapsed:
385
+ *
386
+ * - `peer-pid` the kernel says this connection comes from the exact process the
387
+ * daemon spawned for this channel. This is the only basis that distinguishes one
388
+ * session from another, because they all share the daemon's uid.
389
+ * - `peer-uid` — the uid matched but no pid was expected for this channel. True of
390
+ * a channel nothing was bound to, which in production is a bug and in a test is
391
+ * the case under test.
392
+ * - `socket-ownership` — nobody could vouch at all, and the whole guarantee is the
393
+ * `0600` socket under an unlistable, daemon-owned parent. Real against other
394
+ * accounts, and no help between sessions.
395
+ *
396
+ * A caller that printed "verified" for all three would be claiming something it
397
+ * never checked, which is exactly how the channel came to be described as
398
+ * authentication when it was only ever context.
399
+ *
400
+ * **Fail closed on doubt.** If a pid was expected and the kernel would not answer,
401
+ * that is a refusal: on a host where the check normally works, "could not ask"
402
+ * arriving on the one connection that matters is not a reason to allow it.
355
403
  */
356
404
  export function peerVerdict(
357
- expected: { uid: number } | undefined,
405
+ expected: { uid: number; pid?: number } | undefined,
358
406
  peer: PeerCredentials | undefined,
359
407
  ): PeerVerdict {
360
408
  if (expected === undefined) {
@@ -365,10 +413,29 @@ export function peerVerdict(
365
413
  };
366
414
  }
367
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
+ }
368
435
  return {
369
436
  ok: true,
370
437
  basis: "socket-ownership",
371
- 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",
372
439
  };
373
440
  }
374
441
  if (peer.uid !== expected.uid) {
@@ -379,6 +446,29 @@ export function peerVerdict(
379
446
  detail: `uid ${peer.uid} connected to a socket allocated to uid ${expected.uid}`,
380
447
  };
381
448
  }
449
+ if (expected.pid !== undefined) {
450
+ if (peer.pid === undefined) {
451
+ return {
452
+ ok: false,
453
+ peerUid: peer.uid,
454
+ expectedUid: expected.uid,
455
+ detail:
456
+ `this channel belongs to pid ${expected.pid} and the kernel would not report the caller's pid, ` +
457
+ `so the session could not be identified — every session shares this uid`,
458
+ };
459
+ }
460
+ if (peer.pid !== expected.pid) {
461
+ return {
462
+ ok: false,
463
+ peerUid: peer.uid,
464
+ expectedUid: expected.uid,
465
+ detail:
466
+ `pid ${peer.pid} connected to a channel belonging to pid ${expected.pid} — same uid, ` +
467
+ `different session, so this is another session assuming this one's authority`,
468
+ };
469
+ }
470
+ return { ok: true, basis: "peer-pid", uid: peer.uid, pid: peer.pid };
471
+ }
382
472
  return { ok: true, basis: "peer-uid", uid: peer.uid };
383
473
  }
384
474
 
@@ -386,10 +476,10 @@ export function peerVerdict(
386
476
  export function transportBanner(dir: string, peerReader: PeerReader | undefined): string {
387
477
  const peers =
388
478
  peerReader === undefined
389
- ? `no peer-credential call on ${process.platform}`
479
+ ? `no peer-credential call on ${process.platform} — every bound channel REFUSES every connection`
390
480
  : process.platform === "darwin"
391
- ? "peer uid asserted with getpeereid"
392
- : "peer uid asserted with SO_PEERCRED";
481
+ ? "caller pid+uid asserted with getpeereid and LOCAL_PEERPID; each channel bound to the session the daemon launched"
482
+ : "caller pid+uid asserted with SO_PEERCRED; each channel bound to the session the daemon launched";
393
483
  return (
394
484
  `verb sockets in ${dir} (mode ${VERB_DIR_MODE.toString(8)}); ` +
395
485
  `each socket 0600 under the daemon's own uid; ${peers}`
package/src/worker.ts CHANGED
@@ -53,6 +53,8 @@ export interface WorkerOpts {
53
53
  releaseGrants?: ResolvedGrants;
54
54
  /** Durable audit sink for rejected release/deploy calls. */
55
55
  onReleaseBlocked?: (shape: ReleaseShape) => void;
56
+ /** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
57
+ onSpawn?: (pid: number) => void;
56
58
  /** Control socket for that child, beside the run's own session directory. */
57
59
  socketPath?: string;
58
60
  /**
@@ -195,6 +197,7 @@ export async function runWorker(
195
197
  role: "worker",
196
198
  ...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
197
199
  ...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
200
+ ...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
198
201
  ...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
199
202
  ...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
200
203
  ...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),