omp-conductor 0.15.11 → 0.15.12

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.
@@ -58,7 +58,7 @@ export interface ReleaseBlockContext {
58
58
  export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
59
59
  project: string;
60
60
  source: "worker" | "orchestrator";
61
- shape: ReleaseShape;
61
+ shape: GateShape;
62
62
  at: string;
63
63
  issue?: number;
64
64
  runId?: string;
@@ -66,12 +66,108 @@ export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
66
66
 
67
67
  export type ReleaseDecision = { block: true; reason: string };
68
68
 
69
+ /**
70
+ * The shared-host heavy-gate shape (#428): whole-package test runs and the
71
+ * shell suites a worker must be *unable* to run on the operator's shared host,
72
+ * rather than merely told not to.
73
+ *
74
+ * Deliberately **not** a {@link ReleaseShape}: no grant covers it. An operator
75
+ * is never refused — the operator owns the host, and CI owns the full suite —
76
+ * and a worker is always, whatever the config says. So it stays out of the
77
+ * configurable grant vocabulary (`releaseShapeEnum` is drawn straight from
78
+ * {@link RELEASE_SHAPES}); it only rides the release-policy classifier and the
79
+ * block ledger so one seam sees it and its refusals land where the next tick
80
+ * already reads drift.
81
+ */
82
+ export const SHARED_HOST_SHAPE = "shared-host-gate" as const;
83
+ export type SharedHostShape = typeof SHARED_HOST_SHAPE;
84
+
85
+ /** Every shape the classifier reports, grantable or not. */
86
+ export type GateShape = ReleaseShape | SharedHostShape;
87
+
69
88
  const GIT_PUSH_TAG_SHAPE =
70
89
  /(?:--tags\b|--follow-tags\b|refs\/tags\/|(?:^|\s)(?:tag\s+)?v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?=[:\s]|$))/;
71
90
 
91
+ /** The separators the original quote-blind splitter divides a command on. Kept
92
+ * for the unterminated-quote fallback, which must reproduce today's behaviour
93
+ * rather than silently pass a command through. */
94
+ const COMMAND_SEPARATORS = /(?:&&|\|\||[;\n|])/;
95
+
96
+ /**
97
+ * Split a shell command on its operators, ignoring ones inside single or
98
+ * double quotes. The old splitter was quote-blind, so a `|` inside a quoted
99
+ * argument (a grep alternation's `\|`, a `--jq` filter, a heredoc) shredded
100
+ * the command into phantom segments and a gated verb inside a string literal
101
+ * was refused as a release attempt (#526). A small scanner is enough: no shell
102
+ * parser, no dependency. An unterminated quote falls back to the original
103
+ * quote-blind split, because a gated command must not slip through merely
104
+ * because a quote never closed.
105
+ */
106
+ function splitCommandSegments(command: string): string[] {
107
+ const segments: string[] = [];
108
+ let current = "";
109
+ let quote: "'" | '"' | null = null;
110
+
111
+ const push = () => {
112
+ segments.push(current);
113
+ current = "";
114
+ };
115
+
116
+ for (let i = 0; i < command.length; i++) {
117
+ const ch = command[i];
118
+
119
+ // Inside a quote only its closing quote matters — separators are data. A
120
+ // `\"` inside double quotes is a literal quote and must not close it.
121
+ if (quote !== null) {
122
+ current += ch;
123
+ if (ch === "\\" && quote === '"') {
124
+ if (i + 1 < command.length) current += command[++i];
125
+ continue;
126
+ }
127
+ if (ch === quote) quote = null;
128
+ continue;
129
+ }
130
+
131
+ // Outside quotes a backslash escapes the next character, so a literal
132
+ // `\|`, `\;` or `\"` is data, never an operator or the start of a quote.
133
+ if (ch === "\\") {
134
+ current += ch;
135
+ if (i + 1 < command.length) current += command[++i];
136
+ continue;
137
+ }
138
+
139
+ if (ch === "'") {
140
+ quote = "'";
141
+ current += ch;
142
+ continue;
143
+ }
144
+ if (ch === '"') {
145
+ quote = '"';
146
+ current += ch;
147
+ continue;
148
+ }
149
+
150
+ if ((ch === "&" && command[i + 1] === "&") || (ch === "|" && command[i + 1] === "|")) {
151
+ push();
152
+ i++;
153
+ continue;
154
+ }
155
+ if (ch === "|" || ch === ";" || ch === "\n") {
156
+ push();
157
+ continue;
158
+ }
159
+
160
+ current += ch;
161
+ }
162
+
163
+ if (quote !== null) return command.split(COMMAND_SEPARATORS);
164
+
165
+ push();
166
+ return segments;
167
+ }
168
+
72
169
  function commandSegments(command: string): string[] {
73
- return command
74
- .split(/(?:&&|\|\||[;\n|])/)
170
+ return splitCommandSegments(command)
75
171
  .map((segment) => segment.trim())
76
172
  .filter((segment) => segment.length > 0);
77
173
  }
@@ -84,17 +180,74 @@ function stripCommandPrefix(segment: string): string {
84
180
  }
85
181
 
86
182
  /** Recognise the explicit release/deploy command shapes this policy promises to gate. */
87
- export function releaseShapeFromCommand(command: string): ReleaseShape | undefined {
183
+ export function releaseShapeFromCommand(command: string): GateShape | undefined {
88
184
  return releaseCommandMatch(command)?.shape;
89
185
  }
90
186
 
187
+ /**
188
+ * A whole-package `bun test`: `bun test` with no positional naming a specific
189
+ * test file. Flags alone — or nothing at all — still run the whole project,
190
+ * which is the gate (#428), and so do the path spellings of "everything":
191
+ * `bun test .`, `bun test ./`, `bun test src`. A command whose positional
192
+ * targets are test files — basename carrying the `*.test.*` / `*_test.*`
193
+ * marker bun itself keys on — is a focused run, a worker's proof path, and
194
+ * must stay available. A flag's own value is not a focused target: a
195
+ * `--preload x.ts` run with no file runs the whole package with a preload,
196
+ * while `--preload ./setup.ts src/daemon.test.ts` is still a test-file run.
197
+ */
198
+ function wholePackageBunTest(segment: string): boolean {
199
+ const head = /^bun\s+test\b/.exec(segment);
200
+ if (head === null) return false;
201
+ const rest = segment.slice(head[0].length);
202
+ return !rest
203
+ .split(/\s+/)
204
+ .filter((token) => token.length > 0)
205
+ .filter((token) => !token.startsWith("-"))
206
+ .some((token) => /\.test\.|_test\./.test(token.slice(token.lastIndexOf("/") + 1)));
207
+ }
208
+
209
+ /**
210
+ * The shared-host run/shell suites a worker must not execute on the operator's
211
+ * host (#428): `herdr/test/recover-test.sh`, `test/setup-test.sh` and
212
+ * `setup.sh`, each under the invocation prefixes a worker actually uses —
213
+ * plain `bash`/`sh`/`zsh`, POSIX `. ` and `source` (all so the script loads),
214
+ * and a bare or `./` path. Returns the script path that fired, for the
215
+ * matched-segment ledger record.
216
+ *
217
+ * `bash -n` is deliberately not a match: parsing a script never executes it,
218
+ * and the pre-push parse gate every worker brief runs is
219
+ * `bash -n herdr/bin/recover.sh herdr/test/recover-test.sh setup.sh
220
+ * test/setup-test.sh` — blocking that would make the gate unfollowable without
221
+ * buying a single byte of protection. A flag beyond `-n` (`bash -x`, `-e`) is
222
+ * a real execution and is not covered either: the incidents that overloaded
223
+ * the host were all plain invocations, and refusing the sanctioned parse check
224
+ * would teach workers to skip it rather than stop the load.
225
+ */
226
+ const SHARED_HOST_SCRIPTS = [
227
+ "herdr/test/recover-test.sh",
228
+ "test/setup-test.sh",
229
+ "setup.sh",
230
+ ];
231
+
232
+ const SHARED_HOST_PREFIX = /^(?:(?:bash|sh|zsh|source)\s+|\.\s+)?\.?\/?/;
233
+
234
+ function sharedHostScriptMatch(segment: string): string | undefined {
235
+ const path = segment.replace(SHARED_HOST_PREFIX, "");
236
+ for (const script of SHARED_HOST_SCRIPTS) {
237
+ if (path === script || path.startsWith(`${script} `) || path.startsWith(`${script}\t`)) {
238
+ return script;
239
+ }
240
+ }
241
+ return undefined;
242
+ }
243
+
91
244
  /**
92
245
  * Classify a shell command and the segment that fired. The segment is what a
93
246
  * triager reads: `bun test src/foo.test.ts && npm publish` matches on its
94
247
  * `npm publish` half, and the record says so instead of folding the whole
95
248
  * command into the shape.
96
249
  */
97
- function releaseCommandMatch(command: string): { shape: ReleaseShape; matched: string } | undefined {
250
+ function releaseCommandMatch(command: string): { shape: GateShape; matched: string } | undefined {
98
251
  for (const raw of commandSegments(command)) {
99
252
  const segment = stripCommandPrefix(raw);
100
253
  // Patching the running conductor is gated like a release act: `upgrade`,
@@ -121,6 +274,13 @@ function releaseCommandMatch(command: string): { shape: ReleaseShape; matched: s
121
274
  ) {
122
275
  return { shape: "deploy", matched: segment };
123
276
  }
277
+ // The shared-host gate (#428). Disjoint from every release shape above, so
278
+ // it is checked last without disturbing any of them. `matched` names the
279
+ // script path for a suite and the offending segment for a whole-package
280
+ // `bun test`, so the ledger and the digest say exactly which one fired.
281
+ if (wholePackageBunTest(segment)) return { shape: SHARED_HOST_SHAPE, matched: segment };
282
+ const script = sharedHostScriptMatch(segment);
283
+ if (script !== undefined) return { shape: SHARED_HOST_SHAPE, matched: script };
124
284
  }
125
285
  return undefined;
126
286
  }
@@ -256,7 +416,7 @@ function releaseTokenMatch(
256
416
  export function releaseToolMatch(
257
417
  toolName: string,
258
418
  input: Record<string, unknown> | undefined,
259
- ): { shape: ReleaseShape; matched: string } | undefined {
419
+ ): { shape: GateShape; matched: string } | undefined {
260
420
  if (input === undefined) return undefined;
261
421
  if (toolName === "bash") {
262
422
  return typeof input.command === "string" ? releaseCommandMatch(input.command) : undefined;
@@ -278,10 +438,31 @@ export function releaseToolMatch(
278
438
  export function releaseShapeFromTool(
279
439
  toolName: string,
280
440
  input: Record<string, unknown> | undefined,
281
- ): ReleaseShape | undefined {
441
+ ): GateShape | undefined {
282
442
  return releaseToolMatch(toolName, input)?.shape;
283
443
  }
284
444
 
445
+ /**
446
+ * The refusal wording for the shared-host gate (#428). The whole-package
447
+ * `bun test` form names the focused alternative (a guard that only denies
448
+ * teaches nothing and gets worked around); the shell suites get their own
449
+ * because there is no focused form of a suite script.
450
+ */
451
+ function sharedHostRefusalReason(matched: string | undefined): string {
452
+ if (matched !== undefined && /^bun\s+test\b/.test(matched)) {
453
+ return (
454
+ "Blocked by sharedHostPolicy: the whole-package `bun test` is not a worker's proof path on this shared " +
455
+ "host (it overloads the 4-core VPS and, before #399, SIGTERMed the production daemon). " +
456
+ "Run a focused `bun test <file>.test.ts` instead."
457
+ );
458
+ }
459
+ return (
460
+ "Blocked by sharedHostPolicy: this shell suite is not a worker's proof path on this shared host " +
461
+ "(it overloads the 4-core VPS that also runs Langfuse and the fleet). " +
462
+ "Run focused `bun test <file>.test.ts` unit tests instead."
463
+ );
464
+ }
465
+
285
466
  /**
286
467
  * The refusal a `role` session gets for `shape` under `grants`, or `undefined`
287
468
  * when the grant covers it.
@@ -296,8 +477,16 @@ export function releaseShapeFromTool(
296
477
  export function releaseRefusal(
297
478
  grants: ResolvedGrants,
298
479
  role: SessionRole,
299
- shape: ReleaseShape,
480
+ shape: GateShape,
300
481
  ): ReleaseDecision | undefined {
482
+ // The shared-host gate is not a grant anyone holds: a worker is always
483
+ // refused — the shared operator host cannot absorb a whole-package suite,
484
+ // which is the exact load that stopped it — and an operator session is
485
+ // never, because the operator owns the host and CI owns the full suite.
486
+ if (shape === SHARED_HOST_SHAPE) {
487
+ if (role !== "worker") return undefined;
488
+ return { block: true, reason: sharedHostRefusalReason(undefined) };
489
+ }
301
490
  const holder = grants[shape];
302
491
  if (holder === role) return undefined;
303
492
  return {
@@ -319,11 +508,22 @@ export function releaseDecision(
319
508
  role: SessionRole,
320
509
  toolName: string,
321
510
  input: Record<string, unknown>,
322
- ): { shape: ReleaseShape; matched: string; decision: ReleaseDecision } | undefined {
511
+ ): { shape: GateShape; matched: string; decision: ReleaseDecision } | undefined {
323
512
  const match = releaseToolMatch(toolName, input);
324
513
  if (match === undefined) return undefined;
325
514
  const decision = releaseRefusal(grants, role, match.shape);
326
- return decision === undefined ? undefined : { shape: match.shape, matched: match.matched, decision };
515
+ if (decision === undefined) return undefined;
516
+ // The shared-host gate's wording is matched-specific (name the focused form
517
+ // for a whole-package `bun test`, name the suite for a script), so it is
518
+ // read off the offending segment here rather than from `releaseRefusal`.
519
+ if (match.shape === SHARED_HOST_SHAPE) {
520
+ return {
521
+ shape: match.shape,
522
+ matched: match.matched,
523
+ decision: { block: true, reason: sharedHostRefusalReason(match.matched) },
524
+ };
525
+ }
526
+ return { shape: match.shape, matched: match.matched, decision };
327
527
  }
328
528
 
329
529
  const RELEASE_ARG_ALLOWLIST = new Set([
@@ -424,7 +624,7 @@ interface ReleasePolicyPi {
424
624
  export function releasePolicyTripwire(
425
625
  grants: ResolvedGrants,
426
626
  role: SessionRole,
427
- onBlocked: (shape: ReleaseShape, context: ReleaseBlockContext) => void = () => {},
627
+ onBlocked: (shape: GateShape, context: ReleaseBlockContext) => void = () => {},
428
628
  ): (pi: ReleasePolicyPi) => void {
429
629
  return (pi) => {
430
630
  pi.on("tool_call", (event) => {
@@ -450,7 +650,7 @@ export function releasePolicyTripwire(
450
650
  export function recordReleaseBlock(
451
651
  project: string,
452
652
  source: ReleaseBlock["source"],
453
- shape: ReleaseShape,
653
+ shape: GateShape,
454
654
  details: Omit<ReleaseBlock, "project" | "source" | "shape" | "at"> = {},
455
655
  root = stateDir(),
456
656
  now = new Date(),
@@ -22,9 +22,8 @@
22
22
  import { connect } from "node:net";
23
23
 
24
24
  import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
25
- import type { ReleaseBlockContext } from "./release-policy.ts";
26
-
27
- import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
25
+ import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
26
+ import type { ResolvedGrants, SessionRole } from "./types.ts";
28
27
 
29
28
  /**
30
29
  * Everything the child needs to build the session. Plain JSON on purpose:
@@ -68,7 +67,7 @@ export type HostToParent =
68
67
  | { t: "session-file"; path: string }
69
68
  | { t: "prompt-result"; id: number; ok: boolean; error?: string }
70
69
  | { t: "park-result"; id: number; ok: boolean; error?: string }
71
- | ({ t: "release-blocked"; shape: ReleaseShape } & ReleaseBlockContext);
70
+ | ({ t: "release-blocked"; shape: GateShape } & ReleaseBlockContext);
72
71
 
73
72
  /**
74
73
  * Depth at which a harness event stops being copied for the wire.
@@ -196,7 +195,14 @@ export async function runSessionHost(
196
195
  });
197
196
  } catch (err) {
198
197
  send({ t: "start-error", message: err instanceof Error ? err.message : String(err) });
199
- socket.end();
198
+ // The frame must reach the parent before this process exits: `process.exit`
199
+ // does not flush the socket, and a parent that only observes the exit used
200
+ // to report a bare exit code instead of the child's own words.
201
+ await new Promise<void>((resolve) => {
202
+ socket.once("finish", () => resolve());
203
+ socket.once("error", () => resolve());
204
+ socket.end();
205
+ });
200
206
  return;
201
207
  }
202
208