omp-conductor 0.15.11 → 0.15.13
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/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/release-policy.ts
CHANGED
|
@@ -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:
|
|
61
|
+
shape: GateShape;
|
|
62
62
|
at: string;
|
|
63
63
|
issue?: number;
|
|
64
64
|
runId?: string;
|
|
@@ -66,37 +66,258 @@ 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. Its refusals land in the same ledger the
|
|
80
|
+
* tick reads, but the tick reports them apart from release drift (#562):
|
|
81
|
+
* a refusal here is the interlock working, never a release-policy violation
|
|
82
|
+
* and never something a grant could have permitted.
|
|
83
|
+
*/
|
|
84
|
+
export const SHARED_HOST_SHAPE = "shared-host-gate" as const;
|
|
85
|
+
export type SharedHostShape = typeof SHARED_HOST_SHAPE;
|
|
86
|
+
|
|
87
|
+
/** Every shape the classifier reports, grantable or not. */
|
|
88
|
+
export type GateShape = ReleaseShape | SharedHostShape;
|
|
89
|
+
|
|
69
90
|
const GIT_PUSH_TAG_SHAPE =
|
|
70
91
|
/(?:--tags\b|--follow-tags\b|refs\/tags\/|(?:^|\s)(?:tag\s+)?v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?=[:\s]|$))/;
|
|
71
92
|
|
|
93
|
+
/** The separators the original quote-blind splitter divides a command on. Kept
|
|
94
|
+
* for the unterminated-quote fallback, which must reproduce today's behaviour
|
|
95
|
+
* rather than silently pass a command through. */
|
|
96
|
+
const COMMAND_SEPARATORS = /(?:&&|\|\||[;\n|])/;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Split a shell command on its operators, ignoring ones inside single or
|
|
100
|
+
* double quotes. The old splitter was quote-blind, so a `|` inside a quoted
|
|
101
|
+
* argument (a grep alternation's `\|`, a `--jq` filter, a heredoc) shredded
|
|
102
|
+
* the command into phantom segments and a gated verb inside a string literal
|
|
103
|
+
* was refused as a release attempt (#526). A small scanner is enough: no shell
|
|
104
|
+
* parser, no dependency. An unterminated quote falls back to the original
|
|
105
|
+
* quote-blind split, because a gated command must not slip through merely
|
|
106
|
+
* because a quote never closed.
|
|
107
|
+
*/
|
|
108
|
+
function splitCommandSegments(command: string): string[] {
|
|
109
|
+
const segments: string[] = [];
|
|
110
|
+
let current = "";
|
|
111
|
+
let quote: "'" | '"' | null = null;
|
|
112
|
+
|
|
113
|
+
const push = () => {
|
|
114
|
+
segments.push(current);
|
|
115
|
+
current = "";
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
for (let i = 0; i < command.length; i++) {
|
|
119
|
+
const ch = command[i];
|
|
120
|
+
|
|
121
|
+
// Inside a quote only its closing quote matters — separators are data. A
|
|
122
|
+
// `\"` inside double quotes is a literal quote and must not close it.
|
|
123
|
+
if (quote !== null) {
|
|
124
|
+
current += ch;
|
|
125
|
+
if (ch === "\\" && quote === '"') {
|
|
126
|
+
if (i + 1 < command.length) current += command[++i];
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (ch === quote) quote = null;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Outside quotes a backslash escapes the next character, so a literal
|
|
134
|
+
// `\|`, `\;` or `\"` is data, never an operator or the start of a quote.
|
|
135
|
+
if (ch === "\\") {
|
|
136
|
+
current += ch;
|
|
137
|
+
if (i + 1 < command.length) current += command[++i];
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (ch === "'") {
|
|
142
|
+
quote = "'";
|
|
143
|
+
current += ch;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (ch === '"') {
|
|
147
|
+
quote = '"';
|
|
148
|
+
current += ch;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if ((ch === "&" && command[i + 1] === "&") || (ch === "|" && command[i + 1] === "|")) {
|
|
153
|
+
push();
|
|
154
|
+
i++;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (ch === "|" || ch === ";" || ch === "\n") {
|
|
158
|
+
push();
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
current += ch;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (quote !== null) return command.split(COMMAND_SEPARATORS);
|
|
166
|
+
|
|
167
|
+
push();
|
|
168
|
+
return segments;
|
|
169
|
+
}
|
|
170
|
+
|
|
72
171
|
function commandSegments(command: string): string[] {
|
|
73
|
-
return command
|
|
74
|
-
.split(/(?:&&|\|\||[;\n|])/)
|
|
172
|
+
return splitCommandSegments(command)
|
|
75
173
|
.map((segment) => segment.trim())
|
|
76
174
|
.filter((segment) => segment.length > 0);
|
|
77
175
|
}
|
|
78
176
|
|
|
177
|
+
/** A leading wrapper command, matched and consumed in a chain (#558). `env`
|
|
178
|
+
* was already stripped; `timeout`, `nice`, `stdbuf` and a shell `-c` were
|
|
179
|
+
* not, so a whole-package run became allowed the moment it picked up a
|
|
180
|
+
* wrapper — the incident ran `timeout 300 bun test` and the guard never
|
|
181
|
+
* fired. Each alternative owns the arguments that belong to it (the
|
|
182
|
+
* duration, the niceness, the option run), so stripping never swallows the
|
|
183
|
+
* command's own words. Ordered longest-first so `timeout` is consumed
|
|
184
|
+
* before `time` and `env` before a bare `VAR=…` chain. */
|
|
185
|
+
const COMMAND_WRAPPER_PREFIX = new RegExp(
|
|
186
|
+
[
|
|
187
|
+
"^sudo(?:\\s+-[a-z][a-z0-9-]*)*\\s+",
|
|
188
|
+
"^env\\s+",
|
|
189
|
+
"^(?:[A-Za-z_][A-Za-z0-9_]*=\\S+\\s+)+",
|
|
190
|
+
"^timeout(?:\\s+--?[a-z][a-z0-9-]*(?:=\\S+|\\s+\\S+)?)*\\s+(?:inf|infinity|\\d+(?:\\.\\d+)?[smhd]?)\\s+",
|
|
191
|
+
"^nice(?:\\s+-n\\s+-?\\d+|\\s+-\\d+|\\s+--adjustment\\s*=\\s*-?\\d+)?\\s+",
|
|
192
|
+
"^ionice(?:\\s+--?[a-z][a-z0-9-]*(?:\\s+\\d+)?)*\\s+",
|
|
193
|
+
"^stdbuf(?:\\s+--?[a-z][a-z0-9-]*(?:=\\S+|[A-Za-z0-9]+|\\s+[A-Za-z0-9])?)*\\s+",
|
|
194
|
+
"^xargs(?:\\s+--?[A-Za-z][A-Za-z0-9-]*(?:=\\S+|\\s+\\S+)?)*\\s+",
|
|
195
|
+
"^time\\s+",
|
|
196
|
+
].join("|"),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
/** `bash -c`, `sh -c`, `zsh -c` with optional preceding flags — `-lc` and
|
|
200
|
+
* friends parse as combined short options, so the `c` may ride in the same
|
|
201
|
+
* token as the flags before it. A `bash -n` parse check is deliberately not
|
|
202
|
+
* one: it executes nothing. */
|
|
203
|
+
const SHELL_DASH_C = /^(?:bash|sh|zsh)\s+(?:-\S+\s+)*?-[A-Za-z]*c\b\s+/;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The command a `bash -c '<cmd>'` / `sh -c "<cmd>"` segment executes is the
|
|
207
|
+
* quoted string, not the `bash` invocation, so a wrapper command that reaches
|
|
208
|
+
* for a shell must be resolved to the string inside the quotes before
|
|
209
|
+
* classifying. Extra `$0`.. arguments after the closing quote are ignored:
|
|
210
|
+
* they are not the command.
|
|
211
|
+
*/
|
|
212
|
+
function shellCommandInner(segment: string): string | undefined {
|
|
213
|
+
const shell = SHELL_DASH_C.exec(segment);
|
|
214
|
+
if (shell === null) return undefined;
|
|
215
|
+
const rest = segment.slice(shell[0].length);
|
|
216
|
+
const quote = rest[0];
|
|
217
|
+
if (quote !== "'" && quote !== '"') return undefined;
|
|
218
|
+
const close = rest.indexOf(quote, 1);
|
|
219
|
+
if (close < 0) return undefined;
|
|
220
|
+
return rest.slice(1, close);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Normalise a segment before classification: consume every leading wrapper
|
|
224
|
+
* command (#558). The old stripper recognised `env` and `sudo` and nothing
|
|
225
|
+
* else, so a whole-package `bun test` became allowed the moment a worker
|
|
226
|
+
* wrapped it in `timeout`/`nice`/`bash -c`. Chains matter — `timeout 300
|
|
227
|
+
* nice bun test` is one command with two wrappers — so the table is matched
|
|
228
|
+
* repeatedly until the leading word belongs to the command itself.
|
|
229
|
+
*/
|
|
79
230
|
function stripCommandPrefix(segment: string): string {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.
|
|
231
|
+
let stripped = segment;
|
|
232
|
+
let match: RegExpExecArray | null;
|
|
233
|
+
while ((match = COMMAND_WRAPPER_PREFIX.exec(stripped)) !== null) {
|
|
234
|
+
stripped = stripped.slice(match[0].length);
|
|
235
|
+
}
|
|
236
|
+
return stripped;
|
|
84
237
|
}
|
|
85
238
|
|
|
86
239
|
/** Recognise the explicit release/deploy command shapes this policy promises to gate. */
|
|
87
|
-
export function releaseShapeFromCommand(command: string):
|
|
240
|
+
export function releaseShapeFromCommand(command: string): GateShape | undefined {
|
|
88
241
|
return releaseCommandMatch(command)?.shape;
|
|
89
242
|
}
|
|
90
243
|
|
|
244
|
+
/**
|
|
245
|
+
* A whole-package `bun test`: `bun test` with no positional naming a specific
|
|
246
|
+
* test file. Flags alone — or nothing at all — still run the whole project,
|
|
247
|
+
* which is the gate (#428), and so do the path spellings of "everything":
|
|
248
|
+
* `bun test .`, `bun test ./`, `bun test src`. A command whose positional
|
|
249
|
+
* targets are test files — basename carrying the `*.test.*` / `*_test.*`
|
|
250
|
+
* marker bun itself keys on — is a focused run, a worker's proof path, and
|
|
251
|
+
* must stay available. A flag's own value is not a focused target: a
|
|
252
|
+
* `--preload x.ts` run with no file runs the whole package with a preload,
|
|
253
|
+
* while `--preload ./setup.ts src/daemon.test.ts` is still a test-file run.
|
|
254
|
+
*/
|
|
255
|
+
function wholePackageBunTest(segment: string): boolean {
|
|
256
|
+
const head = /^bun\s+test\b/.exec(segment);
|
|
257
|
+
if (head === null) return false;
|
|
258
|
+
const rest = segment.slice(head[0].length);
|
|
259
|
+
return !rest
|
|
260
|
+
.split(/\s+/)
|
|
261
|
+
.filter((token) => token.length > 0)
|
|
262
|
+
.filter((token) => !token.startsWith("-"))
|
|
263
|
+
.some((token) => /\.test\.|_test\./.test(token.slice(token.lastIndexOf("/") + 1)));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The shared-host run/shell suites a worker must not execute on the operator's
|
|
268
|
+
* host (#428): `herdr/test/recover-test.sh`, `test/setup-test.sh` and
|
|
269
|
+
* `setup.sh`, each under the invocation prefixes a worker actually uses —
|
|
270
|
+
* plain `bash`/`sh`/`zsh`, POSIX `. ` and `source` (all so the script loads),
|
|
271
|
+
* and a bare or `./` path. Returns the script path that fired, for the
|
|
272
|
+
* matched-segment ledger record.
|
|
273
|
+
*
|
|
274
|
+
* `bash -n` is deliberately not a match: parsing a script never executes it,
|
|
275
|
+
* and the pre-push parse gate every worker brief runs is
|
|
276
|
+
* `bash -n herdr/bin/recover.sh herdr/test/recover-test.sh setup.sh
|
|
277
|
+
* test/setup-test.sh` — blocking that would make the gate unfollowable without
|
|
278
|
+
* buying a single byte of protection. A flag beyond `-n` (`bash -x`, `-e`) is
|
|
279
|
+
* a real execution and is not covered either: the incidents that overloaded
|
|
280
|
+
* the host were all plain invocations, and refusing the sanctioned parse check
|
|
281
|
+
* would teach workers to skip it rather than stop the load.
|
|
282
|
+
*/
|
|
283
|
+
const SHARED_HOST_SCRIPTS = [
|
|
284
|
+
"herdr/test/recover-test.sh",
|
|
285
|
+
"test/setup-test.sh",
|
|
286
|
+
"setup.sh",
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
const SHARED_HOST_PREFIX = /^(?:(?:bash|sh|zsh|source)\s+|\.\s+)?\.?\/?/;
|
|
290
|
+
|
|
291
|
+
function sharedHostScriptMatch(segment: string): string | undefined {
|
|
292
|
+
const path = segment.replace(SHARED_HOST_PREFIX, "");
|
|
293
|
+
for (const script of SHARED_HOST_SCRIPTS) {
|
|
294
|
+
if (path === script || path.startsWith(`${script} `) || path.startsWith(`${script}\t`)) {
|
|
295
|
+
return script;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
91
301
|
/**
|
|
92
302
|
* Classify a shell command and the segment that fired. The segment is what a
|
|
93
303
|
* triager reads: `bun test src/foo.test.ts && npm publish` matches on its
|
|
94
304
|
* `npm publish` half, and the record says so instead of folding the whole
|
|
95
305
|
* command into the shape.
|
|
96
306
|
*/
|
|
97
|
-
function releaseCommandMatch(command: string): { shape:
|
|
307
|
+
function releaseCommandMatch(command: string): { shape: GateShape; matched: string } | undefined {
|
|
98
308
|
for (const raw of commandSegments(command)) {
|
|
99
309
|
const segment = stripCommandPrefix(raw);
|
|
310
|
+
// A `bash -c '<cmd>'` / `sh -c "<cmd>"` wrapper executes the quoted
|
|
311
|
+
// string, so classify that string as a command of its own: a whole
|
|
312
|
+
// package inside the quotes is refused (#558), a focused run inside
|
|
313
|
+
// the quotes stays a focused run, and a harmless `-c` script body
|
|
314
|
+
// (the pre-push `bash -n` parse gate) never unwraps to begin with.
|
|
315
|
+
const inner = shellCommandInner(segment);
|
|
316
|
+
if (inner !== undefined) {
|
|
317
|
+
const nested = releaseCommandMatch(inner);
|
|
318
|
+
if (nested !== undefined) return nested;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
100
321
|
// Patching the running conductor is gated like a release act: `upgrade`,
|
|
101
322
|
// the detached `upgrade-install`/`upgrade-rollback` executor entries and
|
|
102
323
|
// any future spelling under the upgrade family all need the install shape,
|
|
@@ -121,6 +342,13 @@ function releaseCommandMatch(command: string): { shape: ReleaseShape; matched: s
|
|
|
121
342
|
) {
|
|
122
343
|
return { shape: "deploy", matched: segment };
|
|
123
344
|
}
|
|
345
|
+
// The shared-host gate (#428). Disjoint from every release shape above, so
|
|
346
|
+
// it is checked last without disturbing any of them. `matched` names the
|
|
347
|
+
// script path for a suite and the offending segment for a whole-package
|
|
348
|
+
// `bun test`, so the ledger and the digest say exactly which one fired.
|
|
349
|
+
if (wholePackageBunTest(segment)) return { shape: SHARED_HOST_SHAPE, matched: segment };
|
|
350
|
+
const script = sharedHostScriptMatch(segment);
|
|
351
|
+
if (script !== undefined) return { shape: SHARED_HOST_SHAPE, matched: script };
|
|
124
352
|
}
|
|
125
353
|
return undefined;
|
|
126
354
|
}
|
|
@@ -256,7 +484,7 @@ function releaseTokenMatch(
|
|
|
256
484
|
export function releaseToolMatch(
|
|
257
485
|
toolName: string,
|
|
258
486
|
input: Record<string, unknown> | undefined,
|
|
259
|
-
): { shape:
|
|
487
|
+
): { shape: GateShape; matched: string } | undefined {
|
|
260
488
|
if (input === undefined) return undefined;
|
|
261
489
|
if (toolName === "bash") {
|
|
262
490
|
return typeof input.command === "string" ? releaseCommandMatch(input.command) : undefined;
|
|
@@ -278,10 +506,31 @@ export function releaseToolMatch(
|
|
|
278
506
|
export function releaseShapeFromTool(
|
|
279
507
|
toolName: string,
|
|
280
508
|
input: Record<string, unknown> | undefined,
|
|
281
|
-
):
|
|
509
|
+
): GateShape | undefined {
|
|
282
510
|
return releaseToolMatch(toolName, input)?.shape;
|
|
283
511
|
}
|
|
284
512
|
|
|
513
|
+
/**
|
|
514
|
+
* The refusal wording for the shared-host gate (#428). The whole-package
|
|
515
|
+
* `bun test` form names the focused alternative (a guard that only denies
|
|
516
|
+
* teaches nothing and gets worked around); the shell suites get their own
|
|
517
|
+
* because there is no focused form of a suite script.
|
|
518
|
+
*/
|
|
519
|
+
function sharedHostRefusalReason(matched: string | undefined): string {
|
|
520
|
+
if (matched !== undefined && /^bun\s+test\b/.test(matched)) {
|
|
521
|
+
return (
|
|
522
|
+
"Blocked by sharedHostPolicy: the whole-package `bun test` is not a worker's proof path on this shared " +
|
|
523
|
+
"host (it overloads the 4-core VPS and, before #399, SIGTERMed the production daemon). " +
|
|
524
|
+
"Run a focused `bun test <file>.test.ts` instead."
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
return (
|
|
528
|
+
"Blocked by sharedHostPolicy: this shell suite is not a worker's proof path on this shared host " +
|
|
529
|
+
"(it overloads the 4-core VPS that also runs Langfuse and the fleet). " +
|
|
530
|
+
"Run focused `bun test <file>.test.ts` unit tests instead."
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
285
534
|
/**
|
|
286
535
|
* The refusal a `role` session gets for `shape` under `grants`, or `undefined`
|
|
287
536
|
* when the grant covers it.
|
|
@@ -296,8 +545,16 @@ export function releaseShapeFromTool(
|
|
|
296
545
|
export function releaseRefusal(
|
|
297
546
|
grants: ResolvedGrants,
|
|
298
547
|
role: SessionRole,
|
|
299
|
-
shape:
|
|
548
|
+
shape: GateShape,
|
|
300
549
|
): ReleaseDecision | undefined {
|
|
550
|
+
// The shared-host gate is not a grant anyone holds: a worker is always
|
|
551
|
+
// refused — the shared operator host cannot absorb a whole-package suite,
|
|
552
|
+
// which is the exact load that stopped it — and an operator session is
|
|
553
|
+
// never, because the operator owns the host and CI owns the full suite.
|
|
554
|
+
if (shape === SHARED_HOST_SHAPE) {
|
|
555
|
+
if (role !== "worker") return undefined;
|
|
556
|
+
return { block: true, reason: sharedHostRefusalReason(undefined) };
|
|
557
|
+
}
|
|
301
558
|
const holder = grants[shape];
|
|
302
559
|
if (holder === role) return undefined;
|
|
303
560
|
return {
|
|
@@ -319,11 +576,22 @@ export function releaseDecision(
|
|
|
319
576
|
role: SessionRole,
|
|
320
577
|
toolName: string,
|
|
321
578
|
input: Record<string, unknown>,
|
|
322
|
-
): { shape:
|
|
579
|
+
): { shape: GateShape; matched: string; decision: ReleaseDecision } | undefined {
|
|
323
580
|
const match = releaseToolMatch(toolName, input);
|
|
324
581
|
if (match === undefined) return undefined;
|
|
325
582
|
const decision = releaseRefusal(grants, role, match.shape);
|
|
326
|
-
|
|
583
|
+
if (decision === undefined) return undefined;
|
|
584
|
+
// The shared-host gate's wording is matched-specific (name the focused form
|
|
585
|
+
// for a whole-package `bun test`, name the suite for a script), so it is
|
|
586
|
+
// read off the offending segment here rather than from `releaseRefusal`.
|
|
587
|
+
if (match.shape === SHARED_HOST_SHAPE) {
|
|
588
|
+
return {
|
|
589
|
+
shape: match.shape,
|
|
590
|
+
matched: match.matched,
|
|
591
|
+
decision: { block: true, reason: sharedHostRefusalReason(match.matched) },
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return { shape: match.shape, matched: match.matched, decision };
|
|
327
595
|
}
|
|
328
596
|
|
|
329
597
|
const RELEASE_ARG_ALLOWLIST = new Set([
|
|
@@ -424,7 +692,7 @@ interface ReleasePolicyPi {
|
|
|
424
692
|
export function releasePolicyTripwire(
|
|
425
693
|
grants: ResolvedGrants,
|
|
426
694
|
role: SessionRole,
|
|
427
|
-
onBlocked: (shape:
|
|
695
|
+
onBlocked: (shape: GateShape, context: ReleaseBlockContext) => void = () => {},
|
|
428
696
|
): (pi: ReleasePolicyPi) => void {
|
|
429
697
|
return (pi) => {
|
|
430
698
|
pi.on("tool_call", (event) => {
|
|
@@ -450,7 +718,7 @@ export function releasePolicyTripwire(
|
|
|
450
718
|
export function recordReleaseBlock(
|
|
451
719
|
project: string,
|
|
452
720
|
source: ReleaseBlock["source"],
|
|
453
|
-
shape:
|
|
721
|
+
shape: GateShape,
|
|
454
722
|
details: Omit<ReleaseBlock, "project" | "source" | "shape" | "at"> = {},
|
|
455
723
|
root = stateDir(),
|
|
456
724
|
now = new Date(),
|
|
@@ -477,21 +745,21 @@ export interface ReleaseDriftSummary {
|
|
|
477
745
|
latest: ReleaseBlock;
|
|
478
746
|
}
|
|
479
747
|
|
|
480
|
-
/**
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
748
|
+
/**
|
|
749
|
+
* The `release-policy-blocks.jsonl` rows recorded for `project` on `now`'s
|
|
750
|
+
* UTC day, in file order. One scan backs both day summaries, so the release
|
|
751
|
+
* drift counter and the shared-host counter can never disagree about which
|
|
752
|
+
* ledger rows are in play (#562).
|
|
753
|
+
*/
|
|
754
|
+
function auditRowsToday(project: string, root: string, now: Date): ReleaseBlock[] {
|
|
486
755
|
let text: string;
|
|
487
756
|
try {
|
|
488
757
|
text = readFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), "utf8");
|
|
489
758
|
} catch {
|
|
490
|
-
return
|
|
759
|
+
return [];
|
|
491
760
|
}
|
|
492
761
|
const day = now.toISOString().slice(0, 10);
|
|
493
|
-
|
|
494
|
-
let latest: ReleaseBlock | undefined;
|
|
762
|
+
const rows: ReleaseBlock[] = [];
|
|
495
763
|
for (const line of text.split("\n")) {
|
|
496
764
|
if (line.length === 0) continue;
|
|
497
765
|
try {
|
|
@@ -503,14 +771,58 @@ export function releaseDriftToday(
|
|
|
503
771
|
(value.source === "worker" || value.source === "orchestrator") &&
|
|
504
772
|
typeof value.shape === "string"
|
|
505
773
|
) {
|
|
506
|
-
|
|
507
|
-
latest = value as ReleaseBlock;
|
|
774
|
+
rows.push(value as ReleaseBlock);
|
|
508
775
|
}
|
|
509
776
|
} catch {
|
|
510
777
|
// One torn line does not hide later valid audit records.
|
|
511
778
|
}
|
|
512
779
|
}
|
|
513
|
-
return
|
|
780
|
+
return rows;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Aggregate today's blocked release/deploy attempts for the orchestrator's
|
|
785
|
+
* daily digest. The shared-host gate (#428) is deliberately excluded (#562):
|
|
786
|
+
* a guard refusal is the interlock working, never a divergence from release
|
|
787
|
+
* policy, and no grant could ever have permitted the command — so counting a
|
|
788
|
+
* refusal here would report a success as a violation. {@link
|
|
789
|
+
* sharedHostRefusalsToday} reads the same ledger and reports those rows on
|
|
790
|
+
* their own line, so nothing is dropped.
|
|
791
|
+
*/
|
|
792
|
+
export function releaseDriftToday(
|
|
793
|
+
project: string,
|
|
794
|
+
root = stateDir(),
|
|
795
|
+
now = new Date(),
|
|
796
|
+
): ReleaseDriftSummary | undefined {
|
|
797
|
+
const drift = auditRowsToday(project, root, now).filter((row) => row.shape !== SHARED_HOST_SHAPE);
|
|
798
|
+
const latest = drift[drift.length - 1];
|
|
799
|
+
if (latest === undefined) return undefined;
|
|
800
|
+
return { count: drift.length, latest };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export interface SharedHostGuardSummary {
|
|
804
|
+
count: number;
|
|
805
|
+
latest: ReleaseBlock;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Aggregate today's shared-host gate refusals (#562), apart from release
|
|
810
|
+
* drift. Every row records a worker being correctly stopped — the whole
|
|
811
|
+
* package runs and host shell suites that once SIGTERMed the production
|
|
812
|
+
* daemon — so the count and the latest {@link ReleaseBlock#matched matched
|
|
813
|
+
* command} are the operator's signal when a worker keeps pushing at the gate
|
|
814
|
+
* (tonight's clustering is how #558's wrapper evasion was found), and they
|
|
815
|
+
* must never ride the release-policy line.
|
|
816
|
+
*/
|
|
817
|
+
export function sharedHostRefusalsToday(
|
|
818
|
+
project: string,
|
|
819
|
+
root = stateDir(),
|
|
820
|
+
now = new Date(),
|
|
821
|
+
): SharedHostGuardSummary | undefined {
|
|
822
|
+
const blocked = auditRowsToday(project, root, now).filter((row) => row.shape === SHARED_HOST_SHAPE);
|
|
823
|
+
const latest = blocked[blocked.length - 1];
|
|
824
|
+
if (latest === undefined) return undefined;
|
|
825
|
+
return { count: blocked.length, latest };
|
|
514
826
|
}
|
|
515
827
|
|
|
516
828
|
export function releaseDriftDigestLine(
|
|
@@ -535,3 +847,36 @@ export function releaseDriftDigestLine(
|
|
|
535
847
|
"Include this divergence from releasePolicy=none in today's digest."
|
|
536
848
|
);
|
|
537
849
|
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* The tick/digest line for the shared-host guard (#562), deliberately apart
|
|
853
|
+
* from {@link releaseDriftDigestLine}: a shared-host refusal is a worker
|
|
854
|
+
* being correctly stopped, never a release-policy divergence, and naming a
|
|
855
|
+
* release grant here would misattribute an event no grant covers (#549). A
|
|
856
|
+
* working guard reads as a working guard; the guard name and the matched
|
|
857
|
+
* command are the genuinely useful parts, because a clustering of refusals
|
|
858
|
+
* is itself a signal.
|
|
859
|
+
*/
|
|
860
|
+
export function sharedHostGuardDigestLine(
|
|
861
|
+
project: string,
|
|
862
|
+
root = stateDir(),
|
|
863
|
+
now = new Date(),
|
|
864
|
+
): string | undefined {
|
|
865
|
+
const refusal = sharedHostRefusalsToday(project, root, now);
|
|
866
|
+
if (refusal === undefined) return undefined;
|
|
867
|
+
const latest = refusal.latest;
|
|
868
|
+
const attribution = [
|
|
869
|
+
`${latest.source} ${latest.shape} at ${latest.at}`,
|
|
870
|
+
...(latest.issue === undefined ? [] : [`issue #${latest.issue}`]),
|
|
871
|
+
...(latest.runId === undefined ? [] : [`run ${latest.runId}`]),
|
|
872
|
+
...(latest.tool === undefined ? [] : [`tool ${latest.tool}`]),
|
|
873
|
+
...(latest.invocation === undefined ? [] : [`attempted "${latest.invocation}"`]),
|
|
874
|
+
...(latest.matched === undefined ? [] : [`matched "${latest.matched}"`]),
|
|
875
|
+
].join(", ");
|
|
876
|
+
return (
|
|
877
|
+
`The shared-host guard refused ${refusal.count} worker command(s) today ` +
|
|
878
|
+
`(latest: ${attribution}). ` +
|
|
879
|
+
"This is the shared-host interlock, not a release-policy divergence: a worker " +
|
|
880
|
+
"is refused these whatever grants it holds; the operator owns the host and CI owns the full suite."
|
|
881
|
+
);
|
|
882
|
+
}
|
package/src/session-host.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|