omp-conductor 0.3.25 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1098 @@
1
+ /**
2
+ * The privileged half of the mutation verbs (#126): every check, in the daemon.
3
+ *
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.
9
+ *
10
+ * Four rules shape the order of the checks, and each is here because prose
11
+ * alone did not hold it on `main`:
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.
17
+ * - **Compared against the configured holder, never against one forbidden
18
+ * value.** `authority` has exactly two holders, so `!== "human"` would let a
19
+ * worker release. Merge and release both ask "is the caller the holder".
20
+ * - **Stop always wins.** Pause state, the config and the run's own row are
21
+ * re-read *inside* the verb, after the model decided to call it. A green
22
+ * check a second ago is not a green check now.
23
+ * - **Fail closed.** An unreadable config, an unknown run, a head nobody can
24
+ * resolve: refuse with a named reason. Never allow on doubt.
25
+ */
26
+
27
+ import { randomUUID } from "node:crypto";
28
+ import { createServer, type Server, type Socket } from "node:net";
29
+
30
+ import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
31
+ import { releaseRefusal } from "../release-policy.ts";
32
+ import { LIVE_STATES } from "../store.ts";
33
+ import type {
34
+ OpenCloser,
35
+ PrState,
36
+ PrVerification,
37
+ ProjectConfig,
38
+ ReleaseRequirement,
39
+ ReleaseShape,
40
+ RepoTarget,
41
+ RunRecord,
42
+ Store,
43
+ Tracker,
44
+ VerbLedgerEntry,
45
+ VerbName,
46
+ VerbRefusal,
47
+ } from "../types.ts";
48
+ import { parseVerbRequest, roleRefusal, VERB_SPECS, type VerbReply } from "./protocol.ts";
49
+ import {
50
+ peerVerdict,
51
+ secureBoundSocket,
52
+ socketFd,
53
+ unlinkStaleSocket,
54
+ traversalProblem,
55
+ validateSocketPath,
56
+ type PeerReader,
57
+ type SocketOwnership,
58
+ } from "./socket.ts";
59
+
60
+ /**
61
+ * The OS principal a run was allocated (#125).
62
+ *
63
+ * Imported as a type from the credential boundary rather than redeclared, so
64
+ * there is one definition of "which uid is this run": a second copy is a second
65
+ * place identity is decided, which is the bug both issues exist to remove.
66
+ */
67
+ import type { SlotPrincipal } from "../credentials.ts";
68
+
69
+ /**
70
+ * How long a merge lock may be held before another daemon may break it.
71
+ *
72
+ * Generous, because the window it covers is a real `gh pr merge` against a
73
+ * repository with branch protection. Breaking it is safe rather than merely
74
+ * tolerable: the second merge re-reads the live head before acting, so a stolen
75
+ * lock cannot turn into a merge of something nobody looked at.
76
+ */
77
+ export const MERGE_LOCK_STALE_MS = 10 * 60_000;
78
+
79
+ /** A hostile local client must not be able to make the daemon buffer forever. */
80
+ const MAX_REQUEST_BYTES = 64 * 1024;
81
+
82
+ /** A connection that opens and says nothing is a connection that is holding a slot. */
83
+ const REQUEST_TIMEOUT_MS = 30_000;
84
+
85
+ /**
86
+ * Who is on the other end of one socket, decided by the daemon at dispatch.
87
+ *
88
+ * A discriminated union rather than a bag of optionals, because the two kinds
89
+ * differ in exactly the way the verbs care about: a run channel names one run
90
+ * and can name no other, and the orchestrator's names a project and no run at
91
+ * all. Nothing in a payload can move a call from one to the other.
92
+ */
93
+ export type VerbChannel =
94
+ | {
95
+ kind: "run";
96
+ /** Absolute socket path. Also this channel's identity in the log. */
97
+ path: string;
98
+ project: string;
99
+ role: "worker";
100
+ runId: string;
101
+ issue: number;
102
+ /** The routed checkout, for the privileged push/PR half. */
103
+ repo: RepoTarget;
104
+ /** The run's own repository on disk (#125's per-run clone). */
105
+ runRepoPath: string;
106
+ branch: string;
107
+ principal?: SlotPrincipal;
108
+ }
109
+ | {
110
+ kind: "orchestrator";
111
+ path: string;
112
+ project: string;
113
+ role: "orchestrator";
114
+ principal?: SlotPrincipal;
115
+ };
116
+
117
+ export type ActionOutcome = { ok: true; sha?: string; detail?: string } | { ok: false; stderr: string };
118
+
119
+ /** What one release asks the privileged half to do, after policy said yes. */
120
+ export interface ReleaseExecution {
121
+ shape: ReleaseShape;
122
+ repo: RepoTarget;
123
+ tag?: string;
124
+ artefact?: string;
125
+ environment?: string;
126
+ }
127
+
128
+ /**
129
+ * The privileged half, as an interface so every check above it is testable
130
+ * without a repository, a credential or a network.
131
+ *
132
+ * `releasableShapes` is declared by the implementation rather than assumed by
133
+ * the server: this daemon holds a GitHub credential, not an npm token and not a
134
+ * deploy key, and a release shape it cannot actually cut must be refused by
135
+ * name rather than attempted and reported as a command failure.
136
+ */
137
+ export interface VerbActions {
138
+ releasableShapes: readonly ReleaseShape[];
139
+ push(run: { repo: RepoTarget; runRepoPath: string; branch: string }): Promise<ActionOutcome>;
140
+ createPr(
141
+ run: { repo: RepoTarget; runRepoPath: string; branch: string },
142
+ opts: { title: string; body: string; base: string },
143
+ ): Promise<{ ok: true; url: string } | { ok: false; stderr: string }>;
144
+ updatePrBranch(prUrl: string): Promise<ActionOutcome>;
145
+ mergePr(prUrl: string, headSha: string): Promise<ActionOutcome>;
146
+ setLabel(issue: number, label: string, action: "add" | "remove"): Promise<ActionOutcome>;
147
+ release(execution: ReleaseExecution): Promise<ActionOutcome>;
148
+ }
149
+
150
+ export interface VerbDeps {
151
+ /**
152
+ * The project config, **re-read per call**. A thunk rather than a value
153
+ * because "unreadable config refuses the call" is only true if the call is
154
+ * the thing that reads it: a config resolved once at boot cannot become
155
+ * unreadable, and cannot pick up an operator's edit either.
156
+ */
157
+ project: () => ProjectConfig;
158
+ store: Store;
159
+ tracker: Tracker;
160
+ actions: VerbActions;
161
+ /**
162
+ * Why the fleet is stopped right now, or `undefined`. Called inside the verb,
163
+ * after the model decided to call it. `hold` and `halt` both set the pause
164
+ * sentinel alongside disarming ticks, so one read covers both.
165
+ */
166
+ fleetStop: () => string | undefined;
167
+ log: (message: string) => void;
168
+ now: () => number;
169
+ }
170
+
171
+ /**
172
+ * The labels a session may change on this project's issues.
173
+ *
174
+ * Two lists, not one filter, because the interesting refusal is the second:
175
+ * `agent:in-progress` is a *real* label of this project that a session still
176
+ * may not touch, since the loop's orphan detection is only trustworthy while
177
+ * every lifecycle label on the tracker was written by the conductor (#26).
178
+ * Telling a caller "not in the vocabulary" about a label it can plainly see on
179
+ * the issue teaches it to try harder; telling it "that transition is the
180
+ * daemon's" tells it to stop.
181
+ */
182
+ export interface LabelVocabulary {
183
+ grantable: string[];
184
+ lifecycle: string[];
185
+ }
186
+
187
+ export function labelVocabulary(p: ProjectConfig): LabelVocabulary {
188
+ return {
189
+ grantable: [
190
+ p.queueLabel,
191
+ ...Object.keys(p.routing.repos).map((name) => `${p.routing.labelPrefix}${name}`),
192
+ ],
193
+ lifecycle: [p.stateLabels.inProgress, p.stateLabels.blocked, p.stateLabels.failed],
194
+ };
195
+ }
196
+
197
+ /** `owner/repo` and the number, from a fully-qualified issue URL. */
198
+ const ISSUE_URL = /^https?:\/\/[^\s/]+\/([^\s/]+\/[^\s/]+)\/issues\/(\d+)\/?$/;
199
+
200
+ /**
201
+ * Parse an issue reference the caller fully qualified.
202
+ *
203
+ * Fully qualified for the reason `PR_URL` in the GitHub adapter is: `gh` also
204
+ * accepts a bare number and resolves it against whatever repository the current
205
+ * directory belongs to, so an unqualified reference is not refused — it is
206
+ * answered, confidently, about some other repository's issue.
207
+ */
208
+ export function issueRefFrom(url: string): { repo: string; issue: number } | undefined {
209
+ const match = ISSUE_URL.exec(url);
210
+ if (match === null) return undefined;
211
+ const issue = Number.parseInt(match[2] ?? "", 10);
212
+ return Number.isSafeInteger(issue) && issue > 0 ? { repo: match[1] ?? "", issue } : undefined;
213
+ }
214
+
215
+ /**
216
+ * Whether a `verifyPr` refusal is the exact-head mismatch rather than a check
217
+ * verdict.
218
+ *
219
+ * Matched on the adapter's own wording (`prVerificationFrom`, `tracker/
220
+ * github.ts`), which already names both SHAs — reusing it is what #126 asks
221
+ * for, and re-deriving the comparison here would be a second implementation of
222
+ * the one rule that must not drift. Pinned by a test against that function's
223
+ * real output, so a reworded reason fails a test rather than silently
224
+ * downgrading a stale head into "checks not green".
225
+ */
226
+ export function isHeadMismatch(reason: string): boolean {
227
+ return reason.startsWith("PR head changed");
228
+ }
229
+
230
+ /**
231
+ * Which declared release requirements are not satisfied.
232
+ *
233
+ * Pure, and fail-closed on anything it cannot settle: a requirement the daemon
234
+ * cannot prove is a requirement that has not been met. `epic-children-closed`
235
+ * is the honest example — nothing in a release request names an epic, so the
236
+ * daemon cannot check it and says so rather than waving it through.
237
+ */
238
+ export interface ReleaseFacts {
239
+ /** Runs still occupying an issue: live workers and unmerged green PRs. */
240
+ unsettledRuns: number;
241
+ /** Of those, the ones with a pull request still open. */
242
+ openPrs: number;
243
+ /** Queue depth, or `undefined` when the tracker could not be read. */
244
+ queueDepth: number | undefined;
245
+ }
246
+
247
+ export function releaseRequirementRefusal(
248
+ requires: readonly ReleaseRequirement[],
249
+ facts: ReleaseFacts,
250
+ ): string | undefined {
251
+ for (const requirement of requires) {
252
+ if (requirement === "runs-settled" && facts.unsettledRuns > 0) {
253
+ return `policy.release.requires includes runs-settled and ${facts.unsettledRuns} run(s) have not settled`;
254
+ }
255
+ if (requirement === "no-open-prs" && facts.openPrs > 0) {
256
+ return `policy.release.requires includes no-open-prs and ${facts.openPrs} pull request(s) are still open`;
257
+ }
258
+ if (requirement === "queue-drained") {
259
+ if (facts.queueDepth === undefined) {
260
+ return "policy.release.requires includes queue-drained and the tracker could not be read; refusing rather than assuming the queue is empty";
261
+ }
262
+ if (facts.queueDepth > 0) {
263
+ return `policy.release.requires includes queue-drained and ${facts.queueDepth} issue(s) are still queued`;
264
+ }
265
+ }
266
+ if (requirement === "epic-children-closed") {
267
+ return "policy.release.requires includes epic-children-closed, which nothing in a release request names an epic for. The daemon cannot settle it, so it refuses: take the requirement off this project's policy, or cut this release by hand.";
268
+ }
269
+ }
270
+ return undefined;
271
+ }
272
+
273
+ /** Everything one decided call writes down, whichever way it went. */
274
+ interface Verdict {
275
+ reply: VerbReply;
276
+ entry?: VerbLedgerEntry;
277
+ }
278
+
279
+ function record(
280
+ deps: VerbDeps,
281
+ channel: VerbChannel,
282
+ verb: VerbName,
283
+ args: Record<string, unknown>,
284
+ outcome:
285
+ | { decision: "allowed"; detail: string; sha?: string }
286
+ | { decision: "refused"; refusal: VerbRefusal; detail: string },
287
+ issue?: number,
288
+ ): Verdict {
289
+ // Reads are not ledgered. The ledger is the record of what a session *did* and
290
+ // tried to do; a status poll every thirty seconds would bury the refusals it
291
+ // exists to surface under its own noise.
292
+ if (!VERB_SPECS[verb].mutating) {
293
+ return {
294
+ reply: {
295
+ ok: outcome.decision === "allowed",
296
+ verb,
297
+ ...(outcome.decision === "refused" ? { refusal: outcome.refusal } : {}),
298
+ text: outcome.detail,
299
+ },
300
+ };
301
+ }
302
+ const entry = deps.store.appendVerbLedger({
303
+ project: channel.project,
304
+ ...(channel.kind === "run" ? { runId: channel.runId } : {}),
305
+ ...(issue === undefined ? {} : { issue }),
306
+ verb,
307
+ role: channel.role,
308
+ args,
309
+ decision: outcome.decision,
310
+ ...(outcome.decision === "refused" ? { refusal: outcome.refusal } : {}),
311
+ detail: outcome.detail,
312
+ ...(outcome.decision === "allowed" && outcome.sha !== undefined ? { sha: outcome.sha } : {}),
313
+ });
314
+ return {
315
+ entry,
316
+ reply: {
317
+ ok: outcome.decision === "allowed",
318
+ verb,
319
+ ...(outcome.decision === "refused" ? { refusal: outcome.refusal } : {}),
320
+ text: outcome.detail,
321
+ ...(outcome.decision === "allowed" && outcome.sha !== undefined ? { sha: outcome.sha } : {}),
322
+ },
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Decide and execute one verb call. Never throws: a thrown error inside a check
328
+ * would close the connection with no ledger entry, which is exactly the shape
329
+ * an attacker would like an unhandled case to have.
330
+ */
331
+ export async function handleVerbCall(
332
+ deps: VerbDeps,
333
+ channel: VerbChannel,
334
+ raw: unknown,
335
+ ): Promise<VerbReply> {
336
+ try {
337
+ return (await decide(deps, channel, raw)).reply;
338
+ } catch (err) {
339
+ const detail = err instanceof Error ? err.message : String(err);
340
+ deps.log(`verb call on ${channel.path} failed unexpectedly: ${detail}`);
341
+ return { ok: false, refusal: "config-unreadable", text: `refused: the daemon errored (${detail}).` };
342
+ }
343
+ }
344
+
345
+ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promise<Verdict> {
346
+ const parsed = parseVerbRequest(raw);
347
+ if (!parsed.ok) {
348
+ // A payload whose verb never resolved cannot be filed under a verb. It is
349
+ // still answered — the caller learns exactly what it sent wrong — but the
350
+ // ledger stays a record of calls to real verbs rather than of noise.
351
+ if (parsed.verb === undefined) {
352
+ return { reply: { ok: false, refusal: parsed.refusal, text: parsed.detail } };
353
+ }
354
+ return record(deps, channel, parsed.verb, {}, { decision: "refused", refusal: parsed.refusal, detail: parsed.detail });
355
+ }
356
+
357
+ const { verb, args } = parsed.request;
358
+ const spec = VERB_SPECS[verb];
359
+ const issue = channel.kind === "run" ? channel.issue : undefined;
360
+ const refuse = (refusal: VerbRefusal, detail: string, at = issue): Verdict =>
361
+ record(deps, channel, verb, args, { decision: "refused", refusal, detail }, at);
362
+ const allow = (detail: string, sha?: string, at = issue): Verdict =>
363
+ record(deps, channel, verb, args, { decision: "allowed", detail, ...(sha === undefined ? {} : { sha }) }, at);
364
+
365
+ // 1. Closed by default. Before anything else reads config or touches the
366
+ // network: a caller of the wrong kind learns nothing about this project.
367
+ const wrongRole = roleRefusal(spec, channel.role);
368
+ if (wrongRole !== undefined) return refuse("role-not-allowed", `refused: ${wrongRole}`);
369
+
370
+ // 2. Fail closed on config. Read now, per call, so an operator's edit and an
371
+ // unreadable file both take effect on the next verb rather than the next
372
+ // daemon restart.
373
+ let project: ProjectConfig;
374
+ try {
375
+ project = deps.project();
376
+ } catch (err) {
377
+ const why = err instanceof Error ? err.message : String(err);
378
+ return refuse("config-unreadable", `refused: this project's config could not be read (${why}).`);
379
+ }
380
+ if (project.name !== channel.project) {
381
+ return refuse(
382
+ "config-unreadable",
383
+ `refused: this socket belongs to project ${channel.project}, and the config now resolves ${project.name}.`,
384
+ );
385
+ }
386
+
387
+ // 3. Stop always wins, and is re-read here rather than remembered. Reads are
388
+ // exempt: a run finishing its report during a `hold` still has to be able
389
+ // to say what it saw, and refusing that would only make it guess.
390
+ const stopped = spec.mutating ? deps.fleetStop() : undefined;
391
+ if (stopped !== undefined) {
392
+ return refuse("fleet-paused", `refused: ${stopped}. Mutating verbs are refused while the fleet is stopped.`);
393
+ }
394
+
395
+ // 4. The run's own row, re-read. A worker whose run was killed, orphaned or
396
+ // settled between its last turn and this call is no longer holding
397
+ // anything, and a push from it would land work nothing is tracking.
398
+ let run: RunRecord | undefined;
399
+ if (channel.kind === "run") {
400
+ run = deps.store.getRun(channel.runId);
401
+ if (run === undefined) {
402
+ return refuse("run-not-live", `refused: run ${channel.runId} is no longer in the store.`);
403
+ }
404
+ if (!LIVE_STATES.some((state) => state === run?.state)) {
405
+ return refuse("run-not-live", `refused: this run is ${run.state}, not live. Its mutations are over.`);
406
+ }
407
+ }
408
+
409
+ switch (verb) {
410
+ case "conductor_push":
411
+ return pushVerb(deps, channel, args, refuse, allow);
412
+ case "conductor_pr_create":
413
+ return prCreateVerb(deps, project, channel, args, refuse, allow);
414
+ case "conductor_pr_update_branch":
415
+ return prUpdateBranchVerb(deps, project, channel, args, refuse, allow);
416
+ case "conductor_pr_merge":
417
+ return prMergeVerb(deps, project, channel, args, refuse, allow);
418
+ case "conductor_label":
419
+ return labelVerb(deps, project, channel, args, refuse, allow);
420
+ case "conductor_release":
421
+ return releaseVerb(deps, project, channel, args, refuse, allow);
422
+ case "conductor_pr_status":
423
+ return prStatusVerb(deps, project, channel, args, refuse, allow);
424
+ }
425
+ }
426
+
427
+ type Refuse = (refusal: VerbRefusal, detail: string, issue?: number) => Verdict;
428
+ type Allow = (detail: string, sha?: string, issue?: number) => Verdict;
429
+
430
+ /** A run channel, or the refusal for a verb that needs one. Narrows the union. */
431
+ function runChannel(
432
+ channel: VerbChannel,
433
+ refuse: Refuse,
434
+ ): { ok: true; channel: Extract<VerbChannel, { kind: "run" }> } | { ok: false; verdict: Verdict } {
435
+ if (channel.kind === "run") return { ok: true, channel };
436
+ return {
437
+ ok: false,
438
+ verdict: refuse("role-not-allowed", "refused: this verb acts on a run, and this socket belongs to no run."),
439
+ };
440
+ }
441
+
442
+ async function pushVerb(
443
+ deps: VerbDeps,
444
+ channel: VerbChannel,
445
+ args: Record<string, unknown>,
446
+ refuse: Refuse,
447
+ allow: Allow,
448
+ ): Promise<Verdict> {
449
+ const scoped = runChannel(channel, refuse);
450
+ if (!scoped.ok) return scoped.verdict;
451
+ const run = scoped.channel;
452
+
453
+ // The only ref this verb has. `ref` is an assertion the caller may make about
454
+ // it, never a selector: a mismatch is refused rather than redirected, and
455
+ // there is no `force` argument to reject because none is declared.
456
+ const expected = `refs/heads/${run.branch}`;
457
+ const ref = args["ref"];
458
+ if (typeof ref === "string" && ref !== expected) {
459
+ return refuse(
460
+ "ref-not-run-branch",
461
+ `refused: this run pushes ${expected} and nothing else. You named "${ref}". ` +
462
+ "There is no force path and no way to name another ref: the daemon derives the ref from the run.",
463
+ );
464
+ }
465
+
466
+ const outcome = await deps.actions.push({
467
+ repo: run.repo,
468
+ runRepoPath: run.runRepoPath,
469
+ branch: run.branch,
470
+ });
471
+ if (!outcome.ok) {
472
+ return refuse(
473
+ "action-failed",
474
+ `refused: the push was rejected. git said:\n${outcome.stderr}\n\n` +
475
+ "This push is fast-forward only. A non-fast-forward rejection means the remote branch moved — " +
476
+ "fetch and rebase in your own checkout, then push again.",
477
+ );
478
+ }
479
+ return allow(`pushed ${expected}${outcome.sha === undefined ? "" : ` at ${outcome.sha}`}.`, outcome.sha);
480
+ }
481
+
482
+ async function prCreateVerb(
483
+ deps: VerbDeps,
484
+ project: ProjectConfig,
485
+ channel: VerbChannel,
486
+ args: Record<string, unknown>,
487
+ refuse: Refuse,
488
+ allow: Allow,
489
+ ): Promise<Verdict> {
490
+ const scoped = runChannel(channel, refuse);
491
+ if (!scoped.ok) return scoped.verdict;
492
+ const run = scoped.channel;
493
+ const base = run.repo.defaultBranch;
494
+
495
+ const claimedBase = args["base"];
496
+ if (typeof claimedBase === "string" && claimedBase !== base) {
497
+ return refuse(
498
+ "base-not-default-branch",
499
+ `refused: ${run.repo.name} targets ${base}, and you named "${claimedBase}". ` +
500
+ "The base is the repo's configured defaultBranch, not an argument.",
501
+ );
502
+ }
503
+ const claimedHead = args["head"];
504
+ if (typeof claimedHead === "string" && claimedHead !== run.branch) {
505
+ return refuse(
506
+ "ref-not-run-branch",
507
+ `refused: this run's head is ${run.branch}, and you named "${claimedHead}".`,
508
+ );
509
+ }
510
+
511
+ // #25's guard, asked of the tracker rather than the store: the store only
512
+ // knows work this database recorded, and a restored or relocated state
513
+ // directory presents finished work as an untouched queue item.
514
+ const existing = deps.store.getRun(run.runId)?.prUrl;
515
+ if (existing !== undefined) {
516
+ return refuse("open-pr-exists", `refused: this run already opened ${existing}.`);
517
+ }
518
+ let closer: OpenCloser | undefined;
519
+ try {
520
+ closer = await deps.tracker.openCloserFor(run.issue);
521
+ } catch (err) {
522
+ const why = err instanceof Error ? err.message : String(err);
523
+ return refuse(
524
+ "open-pr-lookup-error",
525
+ `refused: the tracker could not say whether #${run.issue} already has an open pull request (${why}). ` +
526
+ "Refusing rather than opening a second one.",
527
+ );
528
+ }
529
+ if (closer !== undefined) {
530
+ return refuse("open-pr-exists", `refused: ${closer.url} is already open and already closes #${run.issue}.`);
531
+ }
532
+
533
+ const title = args["title"];
534
+ const body = args["body"];
535
+ const outcome = await deps.actions.createPr(
536
+ { repo: run.repo, runRepoPath: run.runRepoPath, branch: run.branch },
537
+ { title: typeof title === "string" ? title : "", body: typeof body === "string" ? body : "", base },
538
+ );
539
+ if (!outcome.ok) {
540
+ return refuse("action-failed", `refused: gh could not open the pull request:\n${outcome.stderr}`);
541
+ }
542
+ // Recorded now rather than at settlement: the merge verb resolves a run from
543
+ // its PR URL, and a PR the store never learned about is one the orchestrator
544
+ // cannot act on through any verb.
545
+ deps.store.updateRun(run.runId, { prUrl: outcome.url });
546
+ return allow(`opened ${outcome.url} (${run.branch} → ${base}).`);
547
+ }
548
+
549
+ /**
550
+ * The run one pull request belongs to, within this project.
551
+ *
552
+ * How far back to look is the recent-history cutoff `status` already uses: a
553
+ * pull request older than that is not one an orchestrator is mid-flight on.
554
+ */
555
+ const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
556
+
557
+ function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | undefined {
558
+ return deps.store
559
+ .recentRuns(project, deps.now() - PR_LOOKUP_WINDOW_MS)
560
+ .find((candidate) => candidate.prUrl === prUrl);
561
+ }
562
+
563
+ async function prUpdateBranchVerb(
564
+ deps: VerbDeps,
565
+ project: ProjectConfig,
566
+ channel: VerbChannel,
567
+ args: Record<string, unknown>,
568
+ refuse: Refuse,
569
+ allow: Allow,
570
+ ): Promise<Verdict> {
571
+ const prUrl = String(args["prUrl"]);
572
+
573
+ let target: RunRecord | undefined;
574
+ if (channel.kind === "run") {
575
+ const own = deps.store.getRun(channel.runId);
576
+ if (own?.prUrl === undefined) {
577
+ return refuse("pr-missing", "refused: this run has no pull request yet.");
578
+ }
579
+ if (own.prUrl !== prUrl) {
580
+ return refuse(
581
+ "pr-not-this-run",
582
+ `refused: this socket belongs to the run for #${channel.issue}, whose pull request is ${own.prUrl}. ` +
583
+ "A worker can only act on its own run's PR — the daemon resolves the run from the socket, not from what you sent.",
584
+ );
585
+ }
586
+ target = own;
587
+ } else {
588
+ target = runForPr(deps, project.name, prUrl);
589
+ if (target === undefined) {
590
+ return refuse("pr-not-this-run", `refused: ${prUrl} is not a pull request any run in ${project.name} opened.`);
591
+ }
592
+ }
593
+
594
+ let state: PrState | undefined;
595
+ try {
596
+ state = await deps.tracker.prState(prUrl);
597
+ } catch (err) {
598
+ state = undefined;
599
+ deps.log(`verb pr_update_branch could not read ${prUrl}: ${err instanceof Error ? err.message : String(err)}`);
600
+ }
601
+ if (state !== "open") {
602
+ return refuse(
603
+ "pr-not-open",
604
+ state === undefined
605
+ ? `refused: the tracker could not say whether ${prUrl} is open. Refusing rather than acting on an unknown state.`
606
+ : `refused: ${prUrl} is ${state}, not open.`,
607
+ target.issue,
608
+ );
609
+ }
610
+
611
+ const outcome = await deps.actions.updatePrBranch(prUrl);
612
+ if (!outcome.ok) {
613
+ return refuse("action-failed", `refused: gh could not update the branch:\n${outcome.stderr}`, target.issue);
614
+ }
615
+ return allow(`updated ${prUrl} with its base branch. Re-check its checks before merging.`, outcome.sha, target.issue);
616
+ }
617
+
618
+ async function prMergeVerb(
619
+ deps: VerbDeps,
620
+ project: ProjectConfig,
621
+ channel: VerbChannel,
622
+ args: Record<string, unknown>,
623
+ refuse: Refuse,
624
+ allow: Allow,
625
+ ): Promise<Verdict> {
626
+ // The grant says *who*. Compared against the caller rather than tested for a
627
+ // single forbidden value: `authority` has two holders, so `!== "human"` would
628
+ // have let a worker through, and `"human"` has to refuse the orchestrator too.
629
+ const holder = project.authority.merge;
630
+ if (holder !== channel.role) {
631
+ return refuse(
632
+ "authority-holder",
633
+ `refused: merge authority is the ${holder}'s for ${project.name}, and this is a ${channel.role} session. ` +
634
+ (holder === "human"
635
+ ? "No session merges under this config. Report the PR as ready and stop."
636
+ : "Ask the operator which session is meant to hold it."),
637
+ );
638
+ }
639
+
640
+ const prUrl = String(args["prUrl"]);
641
+ const headSha = String(args["headSha"]);
642
+ const target = runForPr(deps, project.name, prUrl);
643
+ if (target === undefined) {
644
+ return refuse("pr-not-this-run", `refused: ${prUrl} is not a pull request any run in ${project.name} opened.`);
645
+ }
646
+
647
+ // Taken before any network call, so two concurrent callers contend here
648
+ // rather than both spending a `gh` round trip and racing at the merge.
649
+ const holderId = randomUUID();
650
+ const lock = deps.store.acquireMergeLock(project.name, holderId, prUrl, deps.now(), MERGE_LOCK_STALE_MS);
651
+ if (lock === undefined) {
652
+ const live = deps.store.mergeLock(project.name);
653
+ return refuse(
654
+ "merge-in-flight",
655
+ `refused: a merge is already in flight for ${project.name}` +
656
+ (live === undefined ? "" : ` (${live.prUrl})`) +
657
+ ". PRs land one at a time, each re-checked against its base: two agent merges at once is how they clobber each other.",
658
+ target.issue,
659
+ );
660
+ }
661
+
662
+ try {
663
+ // Exact-head, re-read at mutation time. Any push since the caller observed
664
+ // green invalidates that observation, and `verifyPr` is the one place that
665
+ // comparison is implemented.
666
+ let verification: PrVerification | undefined;
667
+ try {
668
+ verification = await deps.tracker.verifyPr(prUrl, headSha);
669
+ } catch (err) {
670
+ const why = err instanceof Error ? err.message : String(err);
671
+ return refuse("head-unresolvable", `refused: the live head of ${prUrl} could not be read (${why}).`, target.issue);
672
+ }
673
+ if (verification === undefined) {
674
+ return refuse(
675
+ "head-unresolvable",
676
+ `refused: the live head of ${prUrl} could not be resolved. Refusing rather than merging what you last saw.`,
677
+ target.issue,
678
+ );
679
+ }
680
+ if (verification.status !== "green") {
681
+ const stale = verification.status === "failed" && isHeadMismatch(verification.reason);
682
+ return refuse(
683
+ stale ? "head-stale" : "checks-not-green",
684
+ stale
685
+ ? `refused: ${verification.reason}. You asked to merge ${headSha}; that is not what is on the branch now. ` +
686
+ "Re-read the head, re-check the checks at it, and call again."
687
+ : `refused: ${prUrl} is not mergeable at ${headSha} — ${verification.reason}.`,
688
+ target.issue,
689
+ );
690
+ }
691
+
692
+ const outcome = await deps.actions.mergePr(prUrl, headSha);
693
+ if (!outcome.ok) {
694
+ return refuse("action-failed", `refused: gh could not merge:\n${outcome.stderr}`, target.issue);
695
+ }
696
+ return allow(
697
+ `merged ${prUrl} at ${headSha} (${verification.reason}).`,
698
+ outcome.sha ?? headSha,
699
+ target.issue,
700
+ );
701
+ } finally {
702
+ deps.store.releaseMergeLock(project.name, holderId);
703
+ }
704
+ }
705
+
706
+ async function labelVerb(
707
+ deps: VerbDeps,
708
+ project: ProjectConfig,
709
+ channel: VerbChannel,
710
+ args: Record<string, unknown>,
711
+ refuse: Refuse,
712
+ allow: Allow,
713
+ ): Promise<Verdict> {
714
+ const ref = issueRefFrom(String(args["issueUrl"]));
715
+ if (ref === undefined) {
716
+ return refuse(
717
+ "malformed-argument",
718
+ `refused: "${String(args["issueUrl"])}" is not a full issue URL. A bare number resolves against whatever ` +
719
+ "repository the daemon happens to be in, which is how a label lands on the wrong project's issue.",
720
+ );
721
+ }
722
+ if (ref.repo !== project.tracker.repo) {
723
+ return refuse(
724
+ "malformed-argument",
725
+ `refused: ${ref.repo} is not this project's tracker (${project.tracker.repo}).`,
726
+ );
727
+ }
728
+
729
+ const label = String(args["label"]);
730
+ const vocabulary = labelVocabulary(project);
731
+ if (vocabulary.lifecycle.includes(label)) {
732
+ return refuse(
733
+ "label-is-lifecycle",
734
+ `refused: ${label} is a lifecycle label. Those transitions stay the daemon's — orphan detection is only ` +
735
+ "trustworthy while every state label on the tracker was written by the conductor. Use omp-conductor unblock.",
736
+ ref.issue,
737
+ );
738
+ }
739
+ if (!vocabulary.grantable.includes(label)) {
740
+ return refuse(
741
+ "label-not-in-vocabulary",
742
+ `refused: ${label} is not in ${project.name}'s own vocabulary. It declares ${vocabulary.grantable.join(", ")}.`,
743
+ ref.issue,
744
+ );
745
+ }
746
+
747
+ const action = args["action"] === "remove" ? "remove" : "add";
748
+ const outcome = await deps.actions.setLabel(ref.issue, label, action);
749
+ if (!outcome.ok) {
750
+ return refuse("action-failed", `refused: the tracker rejected the label change:\n${outcome.stderr}`, ref.issue);
751
+ }
752
+ return allow(`${action === "add" ? "added" : "removed"} ${label} on #${ref.issue}.`, undefined, ref.issue);
753
+ }
754
+
755
+ async function releaseVerb(
756
+ deps: VerbDeps,
757
+ project: ProjectConfig,
758
+ channel: VerbChannel,
759
+ args: Record<string, unknown>,
760
+ refuse: Refuse,
761
+ allow: Allow,
762
+ ): Promise<Verdict> {
763
+ // Same rule as merge, written twice on purpose. A worker never reaches here
764
+ // — the role gate refused it — and an orchestrator still has to *be* the
765
+ // configured holder.
766
+ const holder = project.authority.release;
767
+ if (holder !== channel.role) {
768
+ return refuse(
769
+ "authority-holder",
770
+ `refused: release authority is the ${holder}'s for ${project.name}, and this is a ${channel.role} session.` +
771
+ (holder === "human" ? " No session releases under this config." : ""),
772
+ );
773
+ }
774
+
775
+ const shape = args["shape"] as ReleaseShape;
776
+ const grants = resolveReleaseGrants(project);
777
+ const granted = releaseRefusal(grants, channel.role, shape);
778
+ if (granted !== undefined) {
779
+ return refuse("release-not-granted", `refused: ${granted.reason}`);
780
+ }
781
+
782
+ const repoName = String(args["repo"]);
783
+ const repo: RepoTarget | undefined = project.routing.repos[repoName];
784
+ if (repo === undefined) {
785
+ return refuse(
786
+ "malformed-argument",
787
+ `refused: ${project.name} routes no repository called "${repoName}". It has ${Object.keys(project.routing.repos).join(", ")}.`,
788
+ );
789
+ }
790
+
791
+ const policy = resolvePolicy(project);
792
+ const artefact = args["artefact"];
793
+ const environment = args["environment"];
794
+ if (shape === "package-publish") {
795
+ if (typeof artefact !== "string" || !policy.release.artefacts.includes(artefact)) {
796
+ return refuse(
797
+ "release-target-not-declared",
798
+ `refused: ${project.name} declares ${policy.release.artefacts.length === 0 ? "no artefacts" : `artefacts ${policy.release.artefacts.join(", ")}`}, ` +
799
+ `and you named ${JSON.stringify(artefact)}. An undeclared artefact is one nobody authorised publishing.`,
800
+ );
801
+ }
802
+ }
803
+ if (shape === "deploy") {
804
+ if (typeof environment !== "string" || !policy.release.environments.includes(environment)) {
805
+ return refuse(
806
+ "release-target-not-declared",
807
+ `refused: ${project.name} declares ${policy.release.environments.length === 0 ? "no environments" : `environments ${policy.release.environments.join(", ")}`}, ` +
808
+ `and you named ${JSON.stringify(environment)}. An undeclared environment is one nobody authorised deploying to.`,
809
+ );
810
+ }
811
+ }
812
+
813
+ const active = deps.store.activeRuns(project.name);
814
+ const wantsQueue = policy.release.requires.includes("queue-drained");
815
+ let queueDepth: number | undefined;
816
+ if (wantsQueue) {
817
+ try {
818
+ queueDepth = (await deps.tracker.listReady()).length;
819
+ } catch {
820
+ queueDepth = undefined;
821
+ }
822
+ }
823
+ const unmet = releaseRequirementRefusal(policy.release.requires, {
824
+ unsettledRuns: active.length,
825
+ openPrs: active.filter((r) => r.prUrl !== undefined).length,
826
+ queueDepth,
827
+ });
828
+ if (unmet !== undefined) return refuse("release-not-granted", `refused: ${unmet}`);
829
+
830
+ if (!deps.actions.releasableShapes.includes(shape)) {
831
+ return refuse(
832
+ "release-shape-not-executable",
833
+ `refused: this daemon cannot cut a ${shape}. It holds a GitHub credential and nothing else — no npm token, ` +
834
+ "no deploy key — and #125 exists to keep it that way. It can do " +
835
+ `${deps.actions.releasableShapes.join(", ")}. Escalate this one to your operator.`,
836
+ );
837
+ }
838
+
839
+ const tag = args["tag"];
840
+ const outcome = await deps.actions.release({
841
+ shape,
842
+ repo,
843
+ ...(typeof tag === "string" ? { tag } : {}),
844
+ ...(typeof artefact === "string" ? { artefact } : {}),
845
+ ...(typeof environment === "string" ? { environment } : {}),
846
+ });
847
+ if (!outcome.ok) {
848
+ return refuse("action-failed", `refused: the release command failed:\n${outcome.stderr}`);
849
+ }
850
+ return allow(
851
+ `cut ${shape} for ${repo.name}${outcome.detail === undefined ? "" : ` — ${outcome.detail}`}.`,
852
+ outcome.sha,
853
+ );
854
+ }
855
+
856
+
857
+ /**
858
+ * The one read verb, and the reason a credential-less worker can still report
859
+ * honestly (#125 took `gh pr view` away from it).
860
+ *
861
+ * Reuses `Tracker.verifyPr`, so the answer is literally the merge gate's own
862
+ * verdict rather than a second opinion assembled here — including the
863
+ * moved-head case, whose reason already names both shas. A worker that quotes
864
+ * this has read a fact; a worker that says "should be green" has not.
865
+ */
866
+ async function prStatusVerb(
867
+ deps: VerbDeps,
868
+ project: ProjectConfig,
869
+ channel: VerbChannel,
870
+ args: Record<string, unknown>,
871
+ refuse: Refuse,
872
+ allow: Allow,
873
+ ): Promise<Verdict> {
874
+ const asked = args["prUrl"];
875
+ let prUrl: string;
876
+ let issue: number | undefined;
877
+
878
+ if (channel.kind === "run") {
879
+ // A worker reads its own run's PR and no other. Omitting `prUrl` is the
880
+ // normal path; naming one is an assertion, checked, never a selector.
881
+ const own = deps.store.getRun(channel.runId);
882
+ if (own?.prUrl === undefined) {
883
+ return refuse("pr-missing", "refused: this run has no pull request yet. Open one with conductor_pr_create.");
884
+ }
885
+ if (typeof asked === "string" && asked !== own.prUrl) {
886
+ return refuse(
887
+ "pr-not-this-run",
888
+ `refused: this socket belongs to the run for #${channel.issue}, whose pull request is ${own.prUrl}. ` +
889
+ "A worker reads only its own run's PR — the daemon resolves the run from the socket.",
890
+ );
891
+ }
892
+ prUrl = own.prUrl;
893
+ issue = channel.issue;
894
+ } else {
895
+ if (typeof asked !== "string") {
896
+ return refuse("malformed-argument", "refused: conductor_pr_status needs prUrl when it has no run to infer one from.");
897
+ }
898
+ const target = runForPr(deps, project.name, asked);
899
+ if (target === undefined) {
900
+ return refuse("pr-not-this-run", `refused: ${asked} is not a pull request any run in ${project.name} opened.`);
901
+ }
902
+ prUrl = asked;
903
+ issue = target.issue;
904
+ }
905
+
906
+ const headSha = String(args["headSha"]);
907
+ let verification: PrVerification | undefined;
908
+ try {
909
+ verification = await deps.tracker.verifyPr(prUrl, headSha);
910
+ } catch (err) {
911
+ verification = undefined;
912
+ deps.log(`verb pr_status could not read ${prUrl}: ${err instanceof Error ? err.message : String(err)}`);
913
+ }
914
+ if (verification === undefined) {
915
+ return refuse(
916
+ "head-unresolvable",
917
+ `refused: ${prUrl} could not be read. That is not "green" and not "red" — wait and ask again, ` +
918
+ "and report blocked rather than guessing if it stays unreadable.",
919
+ issue,
920
+ );
921
+ }
922
+ return allow(`${prUrl} at ${headSha}: ${verification.status} — ${verification.reason}`, undefined, issue);
923
+ }
924
+
925
+ // ------------------------------------------------------------------ the listener
926
+
927
+ export interface VerbListener {
928
+ path: string;
929
+ ownership: SocketOwnership;
930
+ close(): Promise<void>;
931
+ }
932
+
933
+ /**
934
+ * Refusing to bind is refusing to dispatch. Thrown rather than returned so no
935
+ * caller can accidentally continue past it into a run with no boundary.
936
+ */
937
+ export class VerbSocketRefusal extends Error {
938
+ constructor(message: string) {
939
+ super(message);
940
+ this.name = "VerbSocketRefusal";
941
+ }
942
+ }
943
+
944
+ export interface ListenOptions {
945
+ peerReader?: PeerReader;
946
+ daemonUid?: number;
947
+ /** Injected in tests; the real one chowns, which needs privilege. */
948
+ secure?: typeof secureBoundSocket;
949
+ /**
950
+ * Injected in tests for the same reason `secure` is: the traversal rule is
951
+ * about the *deployment's* directory chain, and a test that needs a bound
952
+ * socket in a developer's `0750` home is not the case it is asserting. The
953
+ * rule itself is pinned directly in `socket.test.ts`.
954
+ */
955
+ traversal?: typeof traversalProblem;
956
+ }
957
+
958
+ /**
959
+ * Bind one channel's socket, after proving nobody else could have tampered with
960
+ * where it is going.
961
+ *
962
+ * The validation is not defensive tidiness: a socket under a directory a third
963
+ * party can write is a socket a third party can unlink and re-bind, and the
964
+ * daemon would then be handing a run's verbs to whoever won that race. A failed
965
+ * check refuses dispatch rather than degrading, because the degraded mode here
966
+ * is "no boundary at all, silently".
967
+ */
968
+ export async function listenVerbChannel(
969
+ deps: VerbDeps,
970
+ channel: VerbChannel,
971
+ opts: ListenOptions = {},
972
+ ): Promise<VerbListener> {
973
+ const daemonUid = opts.daemonUid ?? process.getuid?.() ?? 0;
974
+ const problem = validateSocketPath(channel.path, daemonUid);
975
+ if (problem !== undefined) {
976
+ throw new VerbSocketRefusal(
977
+ `dispatch refused: ${problem.message}. The verb socket layout requires every component to be ` +
978
+ "owned by the daemon (or root), free of symlinks, and unwritable by anyone else.",
979
+ );
980
+ }
981
+ // Traversal, proven rather than assumed, and only when it can actually fail:
982
+ // a run with its own principal reaches this socket by searching every
983
+ // component, and the daemon's state directory is 0700. Refusing here names
984
+ // the directory and the fix; letting it bind would produce an EACCES inside
985
+ // a worker turn, about a path nobody was thinking about.
986
+ if (channel.principal !== undefined) {
987
+ const blocked = (opts.traversal ?? traversalProblem)(channel.path);
988
+ if (blocked !== undefined) {
989
+ throw new VerbSocketRefusal(
990
+ `dispatch refused: ${blocked.message}. This run has its own OS principal, so it must be able to ` +
991
+ "traverse to its socket; binding one it cannot reach would take its verbs away silently.",
992
+ );
993
+ }
994
+ }
995
+ // Only the daemon ever unlinks these, and it has just proven the parent is
996
+ // not writable by anyone else — which is what makes unlink-then-bind safe
997
+ // here and a race in a world-writable directory.
998
+ unlinkStaleSocket(channel.path);
999
+
1000
+ const server: Server = createServer((socket) => {
1001
+ handleConnection(deps, channel, socket, opts.peerReader);
1002
+ });
1003
+ server.on("error", (err) => {
1004
+ deps.log(`verb socket ${channel.path} errored: ${err.message}`);
1005
+ });
1006
+
1007
+ await new Promise<void>((resolve, reject) => {
1008
+ server.once("error", reject);
1009
+ server.listen(channel.path, () => {
1010
+ server.off("error", reject);
1011
+ resolve();
1012
+ });
1013
+ });
1014
+
1015
+ const secure = opts.secure ?? secureBoundSocket;
1016
+ const ownership = secure(channel.path, channel.principal);
1017
+
1018
+ return {
1019
+ path: channel.path,
1020
+ ownership,
1021
+ close: async () => {
1022
+ await new Promise<void>((resolve) => {
1023
+ server.close(() => {
1024
+ resolve();
1025
+ });
1026
+ });
1027
+ unlinkStaleSocket(channel.path);
1028
+ },
1029
+ };
1030
+ }
1031
+
1032
+ function handleConnection(
1033
+ deps: VerbDeps,
1034
+ channel: VerbChannel,
1035
+ socket: Socket,
1036
+ peerReader: PeerReader | undefined,
1037
+ ): void {
1038
+ const fd = socketFd(socket);
1039
+ const peer = fd === undefined || peerReader === undefined ? undefined : peerReader(fd);
1040
+ const verdict = peerVerdict(channel.principal, peer);
1041
+ if (!verdict.ok) {
1042
+ // Not a client error, and deliberately not answered: a caller who is not
1043
+ // who the socket was allocated to gets no reply to calibrate against.
1044
+ deps.log(
1045
+ `IMPERSONATION on ${channel.path}: ${verdict.detail}. Connection closed without a reply. ` +
1046
+ `This socket belongs to ${channel.kind === "run" ? `run ${channel.runId} (#${channel.issue})` : "the orchestrator"} in ${channel.project}.`,
1047
+ );
1048
+ // `end`, not `destroy`: the caller gets no reply either way, but a clean
1049
+ // close means it learns *now* instead of sitting out its whole timeout. An
1050
+ // impersonation attempt that also hangs the honest client behind it is a
1051
+ // denial of service the refusal handed out for free.
1052
+ socket.end();
1053
+ return;
1054
+ }
1055
+
1056
+ socket.setTimeout(REQUEST_TIMEOUT_MS, () => {
1057
+ socket.destroy();
1058
+ });
1059
+
1060
+ let buffer = "";
1061
+ let answered = false;
1062
+ const answer = async (raw: unknown): Promise<void> => {
1063
+ if (answered) return;
1064
+ answered = true;
1065
+ const reply = await handleVerbCall(deps, channel, raw);
1066
+ socket.end(`${JSON.stringify(reply)}\n`);
1067
+ };
1068
+
1069
+ socket.on("data", (chunk) => {
1070
+ if (answered) return;
1071
+ buffer += chunk.toString("utf8");
1072
+ if (buffer.length > MAX_REQUEST_BYTES) {
1073
+ answered = true;
1074
+ socket.end(
1075
+ `${JSON.stringify({ ok: false, refusal: "malformed-argument", text: `refused: a verb request may not exceed ${MAX_REQUEST_BYTES} bytes.` })}\n`,
1076
+ );
1077
+ return;
1078
+ }
1079
+ const newline = buffer.indexOf("\n");
1080
+ if (newline < 0) return;
1081
+ void answer(parseJson(buffer.slice(0, newline)));
1082
+ });
1083
+ socket.on("end", () => {
1084
+ if (!answered && buffer.trim().length > 0) void answer(parseJson(buffer));
1085
+ });
1086
+ socket.on("error", () => {
1087
+ socket.destroy();
1088
+ });
1089
+ }
1090
+
1091
+ /** A body that is not JSON is `undefined`, which the parser refuses by name. */
1092
+ function parseJson(text: string): unknown {
1093
+ try {
1094
+ return JSON.parse(text);
1095
+ } catch {
1096
+ return undefined;
1097
+ }
1098
+ }