omp-conductor 0.5.2 → 0.5.4

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
@@ -8,7 +8,7 @@ tiers: first to an orchestrator session that can re-brief the worker, then to yo
8
8
 
9
9
  You label an issue. Within one tick the conductor claims it on the tracker, cuts a
10
10
  worktree, hands one omp worker a self-contained brief, and watches it to a green
11
- PR. The worker then stops: it never merges, tags, publishes or deploys.
11
+ PR. The worker then stops: it does not merge, tag, publish or deploy.
12
12
 
13
13
  ### Scope
14
14
 
@@ -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 never merges** absolute, 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. |
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,9 +2149,30 @@ 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. A worker
2154
- on run X asking to merge run Y's PR is not merely wrong, it is unexpressible.
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 refuses every connection and says so at startup rather than
2170
+ falling back to the uid, which would be no check at all.
2171
+
2172
+ The residual is narrow, real, and worth stating: one uid can `ptrace` and signal
2173
+ its siblings, so a determined session can still interfere with the process that
2174
+ *is* bound. That is a far higher bar than connecting to a socket, and closing it
2175
+ needs separate OS principals.
2155
2176
 
2156
2177
  ```
2157
2178
  <state dir>/verbs/ daemon-owned, mode 0711
@@ -2294,9 +2315,15 @@ Known and deliberate in this version:
2294
2315
  than a silently dropped page. `delivered` means Telegram accepted an attempt,
2295
2316
  not that exactly one message exists. See
2296
2317
  [Report delivery](#report-delivery-the-outbox).
2297
- - **Workers stop at green PRs.** They never merge, release or deploy. Those
2298
- actions default to a human, but setup may grant either to the orchestrator;
2299
- `authority` never grants them to a worker or the dispatch daemon.
2318
+ - **Workers stop at green PRs.** They are never authorised to merge, release or
2319
+ deploy: those actions default to a human, and while setup may grant either to
2320
+ the orchestrator, `authority` never grants them to a worker or the dispatch
2321
+ daemon. The verbs refuse a worker mechanically, and a worker reaching for
2322
+ another session's channel is refused too — each channel is bound to the pid the
2323
+ daemon spawned for it. What remains a prohibition rather than a gate is shelling
2324
+ out to `gh` directly: sessions inherit the daemon's credentials. That shows up as
2325
+ a mutation with no matching ledger entry, which is a mismatch an operator can
2326
+ find.
2300
2327
  - **The worker gate is partial, and the orchestrator has none.** A worker's
2301
2328
  structured `write` / `edit` / `read` / `grep` / `glob` calls are gated to its
2302
2329
  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.2",
3
+ "version": "0.5.4",
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/config.ts CHANGED
@@ -816,7 +816,7 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
816
816
  const credentialInUrl = cloneUrlCredentialProblem(cloneUrl);
817
817
  if (credentialInUrl !== undefined) {
818
818
  problems.push(
819
- `${label}: routing.repos.${key}.cloneUrl ${credentialInUrl} (#125). Use an SSH URL, or an https URL ` +
819
+ `${label}: routing.repos.${key}.cloneUrl ${credentialInUrl}. Use an SSH URL, or an https URL ` +
820
820
  `backed by the daemon's own credential helper.`,
821
821
  );
822
822
  continue;
package/src/daemon.ts CHANGED
@@ -140,7 +140,9 @@ interface Deps {
140
140
  * Reads the connecting uid off a verb socket (#126). Resolved once at startup
141
141
  * so the mechanism is logged before the first socket exists; absent on a host
142
142
  * exposing no peer-credential call, where the `0600` socket under the
143
- * daemon-owned `0711` parent is the whole boundary and `status` says so.
143
+ * daemon-owned `0711` parent is all that keeps other local accounts out, and
144
+ * `status` says so. It never told two sessions apart — they share the daemon's
145
+ * uid.
144
146
  */
145
147
  verbPeerReader?: PeerReader;
146
148
  /**
@@ -564,7 +566,7 @@ export async function settleWorktree(
564
566
  branch: string;
565
567
  /**
566
568
  * Publishes the run branch on the privileged side. Required rather than
567
- * optional: since #125 the run's commits live in a repository of its own,
569
+ * optional: the run's commits live in a repository of its own,
568
570
  * so a removal that did not publish first would delete the only copy —
569
571
  * which is #121's data loss with one extra step. `undefined` is a visible
570
572
  * decision at the call site, never an omission.
@@ -974,6 +976,14 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
974
976
  // a child process of the daemon reaches it directly.
975
977
  socketPath: join(sessionDir, "ipc.sock"),
976
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
+ },
977
987
  onChildLog: (line) => {
978
988
  log(`#${issue} ${line}`);
979
989
  },
@@ -3043,10 +3053,14 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
3043
3053
  const orchCwd = join(orchTreeRoot, "orchestrator");
3044
3054
  mkdirSync(orchCwd, { recursive: true });
3045
3055
  // A third socket, distinct from every run's, in the same daemon-owned
3046
- // 0711 parent. This is what makes "merge authority is the orchestrator's"
3047
- // a property of the channel: the daemon knows which session is speaking
3048
- // because of where the connection arrived, and no payload can move a
3049
- // 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.
3050
3064
  orchestratorVerbs = await listenVerbChannel(
3051
3065
  verbDeps({ project, store, tracker, verbActions }),
3052
3066
  {
@@ -3063,6 +3077,12 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
3063
3077
  releaseGrants,
3064
3078
  socketPath: join(orchCwd, "ipc.sock"),
3065
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
+ },
3066
3086
  onChildLog: (line) => {
3067
3087
  log(`orchestrator ${line}`);
3068
3088
  },
package/src/gitops.ts CHANGED
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * The daemon's own git and `gh` operations.
3
3
  *
4
- * Everything here runs as the daemon, with the operator's credentials, and it
5
- * is the only place in this package that spawns a credentialed process. That is
6
- * the whole organising idea: "what can reach the operator's `gh` credential" is
7
- * answered by this file's call sites rather than by auditing every `Bun.spawn`
8
- * in the tree.
4
+ * Everything here runs as the daemon, with the operator's credentials, and it is
5
+ * the only place this package **constructs** credential material for a git or
6
+ * `gh` call of its own. That is the organising idea: "what does the conductor
7
+ * itself do with the operator's credential" is answered by this file's call sites
8
+ * rather than by auditing every `Bun.spawn` in the tree.
9
9
  *
10
- * Sessions are not gated at the OS level a worker is a child process of the
11
- * daemon and inherits its environment. What keeps a run's publication honest is
12
- * that the dispatcher performs it: a push or a pull request the daemon did not
13
- * make is a run it cannot account for. The mediated verbs route a worker's
14
- * intent through here so the settlement record and the branch cannot disagree.
10
+ * It is emphatically **not** a claim that nothing else can reach that credential.
11
+ * Sessions are child processes of the daemon and inherit its environment, so a
12
+ * session can reach whatever the daemon can `omp.ts` spawns them and does not
13
+ * scrub anything. What keeps a run's publication accountable is that the
14
+ * dispatcher performs it: a push or a pull request the daemon did not make is a
15
+ * run it cannot account for, and is absent from the verb ledger. The mediated
16
+ * verbs route a worker's intent through here so the settlement record and the
17
+ * branch cannot disagree.
15
18
  */
16
19
 
17
20
  import { join } from "node:path";
package/src/omp.ts CHANGED
@@ -111,12 +111,13 @@ const disposers = new WeakMap<AgentSessionLike, () => Promise<void>>();
111
111
  * Start one omp coding session **in this process**, rooted at `cwd`, with a
112
112
  * private agent registry and a file-backed transcript of its own.
113
113
  *
114
- * This is the far side of the boundary, not the dispatcher's entry point.
115
114
  * Production callers want {@link createSession}, which runs this same function
116
- * inside a child process under the run's own OS principal (#125); the only
117
- * caller of this one is `session-host.ts`, plus the tests that pin what it
118
- * builds. Calling it directly from the daemon puts the session back in the
119
- * credential-holding process, which is the thing #125 exists to stop.
115
+ * inside a **child process**; the only caller of this one is `session-host.ts`,
116
+ * plus the tests that pin what it builds. Calling it directly from the daemon
117
+ * puts the harness back inside the dispatcher, where a crash, a hang or an
118
+ * exhausted heap takes the whole fleet down with it and no `MemoryMax` bounds
119
+ * it — that is what the child process buys. It is not a security boundary: the
120
+ * child runs as the daemon's own user.
120
121
  *
121
122
  * `sessionDir` chooses the directory the harness writes that transcript into;
122
123
  * omitted, the harness picks its default location for `cwd`. Either way the
@@ -387,6 +388,14 @@ export interface CreateSessionOptions {
387
388
  * session plumbing an attack surface for verbs.
388
389
  */
389
390
  verbSocketPath?: string;
391
+ /**
392
+ * The child's pid, the instant it exists.
393
+ *
394
+ * Called synchronously after `Bun.spawn` and before the child could have
395
+ * connected to anything, because that ordering is the point: the daemon binds
396
+ * its verb channel to this pid, and a channel accepts nothing until it is bound.
397
+ */
398
+ onSpawn?: (pid: number) => void;
390
399
  /** Child stderr, line by line. Defaults to the process's own stderr. */
391
400
  onChildLog?: (line: string) => void;
392
401
  startupTimeoutMs?: number;
@@ -482,6 +491,10 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
482
491
  stderr: "pipe",
483
492
  });
484
493
 
494
+ // Synchronously, before a single await: the child cannot have connected yet, so
495
+ // the caller's channel is bound before it can be reached.
496
+ if (child.pid !== undefined) opts.onSpawn?.(child.pid);
497
+
485
498
  let stderrTail = "";
486
499
  const drain = async (stream: ReadableStream<Uint8Array> | undefined, prefix: string): Promise<void> => {
487
500
  if (stream === undefined) return;
@@ -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,13 +85,9 @@ export interface OrchestratorOpts {
84
85
  model?: string;
85
86
  releaseGrants?: ResolvedGrants;
86
87
  onReleaseBlocked?: (shape: ReleaseShape) => void;
87
- /**
88
- * The OS principal this session runs as (#125). Its own, distinct from every
89
- * worker slot: the orchestrator reads the state directory and its briefs, and
90
- * must have no read or write access to any run checkout — a property the
91
- * adversarial probe asserts rather than assumes.
92
- */
93
- /** Control socket for the session child. See {@link OrchestratorOpts.boundary}. */
88
+ /** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
89
+ onSpawn?: (pid: number) => void;
90
+ /** Control socket for the session child, beside its own working directory. */
94
91
  socketPath?: string;
95
92
  /**
96
93
  * The orchestrator's own verb socket (#126) — a third, distinct one, never
@@ -186,6 +183,7 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
186
183
  role: "orchestrator",
187
184
  ...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
188
185
  ...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
186
+ ...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
189
187
  ...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
190
188
  ...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
191
189
  ...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
@@ -8,11 +8,12 @@
8
8
  * It was the only mechanism there was, and it was never the one anybody should
9
9
  * have had to rely on.
10
10
  *
11
- * What actually stops a release now is that the session cannot perform one. It
12
- * holds no credential (#125) and its only route to a mutation is the mediated
13
- * verbs, whose checks run in the daemon against the configured holder
14
- * (`verbs/server.ts`). A worker that talks its way past every regex below still
15
- * has nothing to push with.
11
+ * What actually stops a release now is that the daemon performs every one, and
12
+ * checks it first: the mediated verbs run their checks against the configured
13
+ * holder (`verbs/server.ts`), on the daemon's side of the socket. A worker that
14
+ * talks its way past every regex below has still not cut a release — and if it
15
+ * reaches for `gh` directly instead, the release is not in the ledger, which is
16
+ * the discrepancy an operator reads.
16
17
  *
17
18
  * It stays installed anyway, for the two things a tripwire is good at and a
18
19
  * boundary is not: it refuses *early*, in the session, with an explanation the
@@ -5,13 +5,13 @@
5
5
  * daemon never handles a token itself: credentials stay in the user's keychain
6
6
  * or `gh` config and are never passed as argv, written to a file, or logged.
7
7
  *
8
- * That reachability is exactly what #125 confines. The environment this adapter
9
- * hands `gh` comes from `credentialedEnv()` the one named construction site
10
- * of credential material in the daemon so "which processes can reach the
11
- * operator's GitHub write credential" is answered by that function's call
12
- * sites, not by auditing every spawn in the tree. Sessions get the opposite
13
- * environment, and on a `per-run` host they are a different OS principal
14
- * entirely and could not use this credential even if they found it.
8
+ * The environment this adapter hands `gh` comes from `credentialedEnv()` the
9
+ * one named construction site of credential material in the daemon so "what in
10
+ * this package reaches the operator's GitHub credential" is answered by that
11
+ * function's call sites rather than by auditing every spawn in the tree. It is a
12
+ * legibility property, not a boundary: sessions run as the daemon's own user and
13
+ * can reach the same credential. What keeps a run's mutations accountable is
14
+ * that the daemon performs them, and records them.
15
15
  *
16
16
  * ponytail: shelling out to `gh` is the deliberate simplification. The ceiling
17
17
  * is per-call cost (one process spawn plus one TLS handshake per operation,
@@ -151,10 +151,9 @@ async function gh(argv: string[], stdin?: string): Promise<string> {
151
151
  stdin: new Blob([stdin ?? ""]),
152
152
  stdout: "pipe",
153
153
  stderr: "pipe",
154
- // The privileged side of the boundary (#125). Explicit rather than
155
- // inherited so that adding a credential-scrubbing default to this process
156
- // could never silently break the tracker, and so that the grep for
157
- // `credentialedEnv` finds this call.
154
+ // Explicit rather than inherited, so that a future change to this process's
155
+ // own environment could never silently break the tracker, and so that the
156
+ // grep for `credentialedEnv` finds this call.
158
157
  env: credentialedEnv(),
159
158
  });
160
159
 
package/src/types.ts CHANGED
@@ -1324,9 +1324,9 @@ export const VERB_NAMES = [
1324
1324
  "conductor_pr_merge",
1325
1325
  "conductor_label",
1326
1326
  "conductor_release",
1327
- /** The one read verb. A session with no credential cannot otherwise observe
1328
- * the pull request it just pushed, and "pushed-green" would go back to being
1329
- * a claim a worker makes about itself. */
1327
+ /** The one read verb. It answers with the merge gate's own verdict, so
1328
+ * "pushed-green" is the dispatcher's reading of the PR rather than a claim the
1329
+ * worker makes about itself from whatever it happened to run. */
1330
1330
  "conductor_pr_status",
1331
1331
  ] as const;
1332
1332
 
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * The privileged half of the verbs, wired to real commands (#126).
3
3
  *
4
- * This is the *only* module in the verb path that touches a credential, and it
5
- * runs in the daemon. Push and pull-request creation are delegated to the
6
- * credential boundary (#125) rather than re-shelled here, so there is one
7
- * implementation of "how does a run's work reach the remote" and it is the one
8
- * that scrubs the environment. The remaining verbs shell `gh` through
9
- * {@link credentialedEnv} for the same reason.
4
+ * This is the only module in the verb path that performs a mutation, and it runs
5
+ * in the daemon. Push and pull-request creation are delegated to `gitops.ts`
6
+ * rather than re-shelled here, so there is one implementation of "how does a
7
+ * run's work reach the remote" fast-forward only, no force path. The remaining
8
+ * verbs shell `gh` through {@link credentialedEnv} for the same reason: one
9
+ * named construction site, so its call sites are the audit.
10
10
  *
11
11
  * Kept apart from `server.ts` deliberately: every policy check above it is
12
12
  * testable against the {@link VerbActions} interface with no repository, no
@@ -23,8 +23,8 @@ import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
23
23
  *
24
24
  * The three GitHub-shaped ones, and deliberately not `package-publish` or
25
25
  * `deploy`: the daemon holds a GitHub credential and nothing else — no npm
26
- * token, no registry login, no deploy key — and #125 exists to keep it that
27
- * way. A shape outside this list is refused by name in `server.ts` rather than
26
+ * token, no registry login, no deploy key — and that is deliberate. A shape
27
+ * outside this list is refused by name in `server.ts` rather than
28
28
  * attempted and reported as a command failure, because "we tried and it did not
29
29
  * work" and "we were never able to do this" are different answers and only one
30
30
  * of them tells an operator to go and do it themselves.
@@ -87,10 +87,10 @@ export function githubVerbActions(project: ProjectConfig, run: CommandRunner = s
87
87
  return {
88
88
  releasableShapes: GITHUB_RELEASABLE_SHAPES,
89
89
 
90
- // Both of these are #125's, not this module's: they know the run's own
91
- // repository layout and they are the two paths that must never see an
92
- // ambient credential. Re-shelling `git push` here would be a second
93
- // implementation of the one thing the credential boundary exists to own.
90
+ // Both live in `gitops.ts`, not here: they know the run's own repository
91
+ // layout, and they are the two paths whose fast-forward-only rule must have
92
+ // exactly one implementation. Re-shelling `git push` here would be a second
93
+ // one, and the second one is where a `--force` eventually appears.
94
94
  push: (target) => pushRunBranch(project, target),
95
95
 
96
96
  createPr: (target, opts) => openRunPr(project, target, opts),
@@ -2,10 +2,9 @@
2
2
  * The child-side half of the mutation verbs (#126): a thin client, and nothing
3
3
  * else.
4
4
  *
5
- * This module runs **inside the untrusted per-run process**. Under #125 that is
6
- * a different OS principal with no access to the daemon's `gh` config, keychain
7
- * or SSH keys; before #125 it is the same user. Either way it is code the model
8
- * can read, and on a bad day rewrite — so it decides nothing:
5
+ * This module runs **inside the session process**, as the daemon's own user, so
6
+ * it is code the model can read and on a bad day rewrite — and it can reach the
7
+ * same credentials the daemon can. That is exactly why it decides nothing:
9
8
  *
10
9
  * - **No policy branch.** It does not know what `authority.merge` says, which
11
10
  * release shapes are granted, or whether the fleet is paused. Every answer
@@ -15,8 +14,8 @@
15
14
  * There is nothing here to steal.
16
15
  * - **No local fallback.** No socket means the verb fails, loudly, with an
17
16
  * explanation. A client that fell back to `git push` when the daemon was
18
- * unreachable would make the boundary optional, and an optional boundary is
19
- * one that is missing precisely when something has gone wrong.
17
+ * unreachable would leave the settlement record silently incomplete, and it
18
+ * would do so precisely when something had already gone wrong.
20
19
  *
21
20
  * The identity fields are absent by construction rather than stripped: this
22
21
  * client has no project, run or role to send, because it was never told one.
@@ -2,18 +2,32 @@
2
2
  * The privileged half of the mutation verbs (#126): every check, in the daemon.
3
3
  *
4
4
  * The child-side tool handler forwards arguments and renders an answer. It
5
- * holds no credential, evaluates no policy and writes no ledger entry, because
6
- * under #125 it runs inside the untrusted per-run process anything it decided
7
- * would be decided by code the model can rewrite. Everything below runs on the
8
- * other side of the socket.
5
+ * evaluates no policy and writes no ledger entry, because it runs inside the
6
+ * session process — anything it decided would be decided by code the model can
7
+ * rewrite, and a session runs as the daemon's own user. Everything below runs on
8
+ * the other side of the socket, which is what makes the checks real.
9
9
  *
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
 
@@ -819,7 +834,7 @@ async function releaseVerb(
819
834
  return refuse(
820
835
  "release-shape-not-executable",
821
836
  `refused: this daemon cannot cut a ${shape}. It holds a GitHub credential and nothing else — no npm token, ` +
822
- "no deploy key — and #125 exists to keep it that way. It can do " +
837
+ "no deploy key — and that is deliberate. It can do " +
823
838
  `${deps.actions.releasableShapes.join(", ")}. Escalate this one to your operator.`,
824
839
  );
825
840
  }
@@ -843,8 +858,8 @@ async function releaseVerb(
843
858
 
844
859
 
845
860
  /**
846
- * The one read verb, and the reason a credential-less worker can still report
847
- * honestly (#125 took `gh pr view` away from it).
861
+ * The one read verb, and the reason a worker reports a PR's real state rather
862
+ * than its own recollection.
848
863
  *
849
864
  * Reuses `Tracker.verifyPr`, so the answer is literally the merge gate's own
850
865
  * verdict rather than a second opinion assembled here — including the
@@ -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,36 +372,51 @@ 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.
382
+ *
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.
348
399
  *
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.
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) {
361
409
  return {
362
410
  ok: true,
363
411
  basis: "socket-ownership",
364
- why: "no uid was named for this socket, so the 0600 mode under the daemon-owned 0711 parent is the whole boundary",
412
+ why: "no uid was named for this socket, so the 0600 mode under the daemon-owned 0711 parent is all that keeps other local accounts out",
365
413
  };
366
414
  }
367
415
  if (peer === undefined) {
368
416
  return {
369
417
  ok: true,
370
418
  basis: "socket-ownership",
371
- why: "this host exposes no peer credentials, so the 0600 socket under the daemon-owned 0711 parent is the whole boundary",
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",
372
420
  };
373
421
  }
374
422
  if (peer.uid !== expected.uid) {
@@ -379,6 +427,29 @@ export function peerVerdict(
379
427
  detail: `uid ${peer.uid} connected to a socket allocated to uid ${expected.uid}`,
380
428
  };
381
429
  }
430
+ if (expected.pid !== undefined) {
431
+ if (peer.pid === undefined) {
432
+ return {
433
+ ok: false,
434
+ peerUid: peer.uid,
435
+ expectedUid: expected.uid,
436
+ detail:
437
+ `this channel belongs to pid ${expected.pid} and the kernel would not report the caller's pid, ` +
438
+ `so the session could not be identified — every session shares this uid`,
439
+ };
440
+ }
441
+ if (peer.pid !== expected.pid) {
442
+ return {
443
+ ok: false,
444
+ peerUid: peer.uid,
445
+ expectedUid: expected.uid,
446
+ detail:
447
+ `pid ${peer.pid} connected to a channel belonging to pid ${expected.pid} — same uid, ` +
448
+ `different session, so this is another session assuming this one's authority`,
449
+ };
450
+ }
451
+ return { ok: true, basis: "peer-pid", uid: peer.uid, pid: peer.pid };
452
+ }
382
453
  return { ok: true, basis: "peer-uid", uid: peer.uid };
383
454
  }
384
455
 
@@ -386,10 +457,10 @@ export function peerVerdict(
386
457
  export function transportBanner(dir: string, peerReader: PeerReader | undefined): string {
387
458
  const peers =
388
459
  peerReader === undefined
389
- ? `no peer-credential call on ${process.platform}`
460
+ ? `no peer-credential call on ${process.platform} — channels REFUSE every connection`
390
461
  : process.platform === "darwin"
391
- ? "peer uid asserted with getpeereid"
392
- : "peer uid asserted with SO_PEERCRED";
462
+ ? "caller pid+uid asserted with getpeereid and LOCAL_PEERPID; each channel bound to the session the daemon launched"
463
+ : "caller pid+uid asserted with SO_PEERCRED; each channel bound to the session the daemon launched";
393
464
  return (
394
465
  `verb sockets in ${dir} (mode ${VERB_DIR_MODE.toString(8)}); ` +
395
466
  `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 }),
package/src/worktree.ts CHANGED
@@ -6,11 +6,12 @@
6
6
  * once and refreshed, rather than a fresh full clone per issue: a module repo's
7
7
  * history is fetched one time and every later run pays only for the delta.
8
8
  *
9
- * **A run gets its own repository, not a linked worktree of the mirror, and
10
- * that is a security property rather than a preference (#125 1b).** A linked
11
- * worktree shares the mirror's common dir — refs, objects, index locks — so it
12
- * needs *write* access to it, which hands every run write access to every other
13
- * run's refs. Here the objects arrive read-only through git's alternates
9
+ * **A run gets its own repository, not a linked worktree of the mirror, and that
10
+ * is load-bearing rather than a preference.** A linked worktree shares the
11
+ * mirror's common dir — refs, objects, index locks — so every run would be
12
+ * writing into one store: concurrent runs contend on the same index lock, a
13
+ * `git gc` in one can pull objects from under another, and a run's refs are one
14
+ * `git update-ref` away from a sibling's. Here the objects arrive read-only through git's alternates
14
15
  * mechanism and the run's refs, index and commits live in its own git dir. The
15
16
  * documented residual is that a run can still *read* another run's objects out
16
17
  * of the shared store: bounded, same source, no write path, no credential, and
@@ -61,9 +62,9 @@ async function runGit(
61
62
  stdin: "ignore",
62
63
  stdout: "pipe",
63
64
  stderr: "pipe",
64
- // Every git call in this module runs on the *privileged* sidethe daemon
65
- // provisioning, salvaging or publishing — so it goes through the one named
66
- // construction site of credential material (#125). It also carries
65
+ // Every git call in this module is the daemon's ownprovisioning,
66
+ // salvaging or publishing — so it goes through the one named construction
67
+ // site of credential material. It also carries
67
68
  // GIT_TERMINAL_PROMPT=0, because an unattended dispatcher must fail loudly
68
69
  // rather than block forever on a prompt nobody is there to answer.
69
70
  env: credentialedEnv(),
@@ -287,8 +288,8 @@ export async function ensureMirror(
287
288
  * git's own read-only borrowing mechanism — so provisioning still costs one
288
289
  * fetch of the delta rather than a full clone, exactly as the linked worktree
289
290
  * it replaces did. What changed is who can write what: the run's refs, index
290
- * and commits are its own, and nothing it does can reach a sibling run's refs
291
- * or the mirror (#125 1b).
291
+ * and commits are its own, and no ordinary git operation in the checkout reaches
292
+ * a sibling run's refs or the mirror.
292
293
  *
293
294
  * `origin` is deliberately the real clone URL and not the mirror path. Pointing
294
295
  * it at the mirror would make a worker's ordinary `git push origin HEAD`
@@ -553,9 +554,9 @@ function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string
553
554
  * commit and re-runs its checks. That is the price of work outliving its host.
554
555
  *
555
556
  * `publish` is a **required** parameter rather than an optional one with a
556
- * no-op default, and that is the whole design. The salvage commit no longer
557
- * pushes for itself: the run's repository holds no credential (#125), so the
558
- * network hop is the daemon's, through `pushRunBranch`. A defaulted parameter
557
+ * no-op default, and that is the whole design. The salvage commit does not push
558
+ * for itself: the network hop is the daemon's, through `pushRunBranch`, so the
559
+ * sha that reaches the remote is the sha the dispatcher recorded. A defaulted parameter
559
560
  * is how a future call site silently stops publishing salvaged work and nobody
560
561
  * finds out until the host that held it is gone — which is #121's failure with
561
562
  * one extra step. Passing `undefined` is allowed, and is a visible decision at
@@ -676,7 +677,8 @@ export async function removeWorktree(
676
677
  if (existsSync(worktreePath)) {
677
678
  // A run repo is a plain directory now, so removal is a plain removal — but
678
679
  // the `worktree remove` is still attempted first, because a fleet upgrading
679
- // into #125 has live trees that ARE linked worktrees of this mirror, and
680
+ // from a release that used linked worktrees has live trees that ARE linked
681
+ // worktrees of this mirror, and
680
682
  // deleting one of those without deregistering it leaves the mirror
681
683
  // believing the branch is still checked out. It refuses `worktree add` and,
682
684
  // worse, refuses to delete the branch at cleanup. The failure is expected