@gethmy/harness 1.2.1 → 1.3.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.
- package/dist/cli.js +489 -225
- package/dist/index.js +1222 -401
- package/package.json +2 -2
- package/src/ci-failure.ts +465 -0
- package/src/cli.ts +11 -1
- package/src/confine-to-repo.test.ts +324 -1
- package/src/confine-to-repo.ts +274 -22
- package/src/error-classifier.ts +52 -1
- package/src/gate-collectors.ts +11 -3
- package/src/git-pr.ts +461 -8
- package/src/index.ts +2 -0
- package/src/model-tier.test.ts +11 -6
- package/src/model-tier.ts +4 -4
- package/src/oracle-collector.ts +244 -23
- package/src/oracle.ts +856 -108
- package/src/pm.ts +15 -5
- package/src/repair-sandbox.test.ts +116 -0
- package/src/repair-sandbox.ts +303 -0
- package/src/run-sizing.test.ts +264 -66
- package/src/run-sizing.ts +146 -26
- package/src/sdk-agent-runner.ts +22 -1
package/src/oracle.ts
CHANGED
|
@@ -64,11 +64,25 @@
|
|
|
64
64
|
* Failing the gate rather than writing is the point.
|
|
65
65
|
*/
|
|
66
66
|
import type { ChildProcess } from "node:child_process";
|
|
67
|
-
import {
|
|
68
|
-
import {
|
|
67
|
+
import { lstatSync, readFileSync, statSync } from "node:fs";
|
|
68
|
+
import {
|
|
69
|
+
chmod,
|
|
70
|
+
lstat,
|
|
71
|
+
mkdir,
|
|
72
|
+
mkdtemp,
|
|
73
|
+
realpath,
|
|
74
|
+
rm,
|
|
75
|
+
writeFile,
|
|
76
|
+
} from "node:fs/promises";
|
|
77
|
+
import { tmpdir } from "node:os";
|
|
78
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
79
|
+
import { StringDecoder } from "node:string_decoder";
|
|
69
80
|
import { DEFAULT_METRIC_TIMEOUT_MS } from "./exec-types.js";
|
|
81
|
+
import { log } from "./log.js";
|
|
70
82
|
import { reapGroup, spawnInGroup, terminateGroup } from "./process-group.js";
|
|
71
83
|
|
|
84
|
+
const TAG = "oracle";
|
|
85
|
+
|
|
72
86
|
export interface HeldOracle {
|
|
73
87
|
/**
|
|
74
88
|
* The `stage_oracles` row id, when the API returns one (card #927 — older
|
|
@@ -90,6 +104,24 @@ export interface OracleDeps {
|
|
|
90
104
|
* into `fetchOracle`'s third argument.
|
|
91
105
|
*/
|
|
92
106
|
sessionId: string;
|
|
107
|
+
/**
|
|
108
|
+
* The stage the held oracle is KEYED TO — the downstream stage whose gate is
|
|
109
|
+
* `oracle_passed`, as resolved by `findOracleTargetStage`. Needed only by the
|
|
110
|
+
* `oracle_red` gate (#921) and `null` when there is no such stage.
|
|
111
|
+
*
|
|
112
|
+
* A green gate does not use this: the stage that runs `oracle_passed` IS the
|
|
113
|
+
* target, so `context.stageId` already addresses the row. A RED gate is the
|
|
114
|
+
* author's own stage, one stage EARLIER, and the row is keyed to the target
|
|
115
|
+
* rather than to the writer (see the `/stage-oracle` route: "The row is keyed
|
|
116
|
+
* to the TARGET"). So the red collector must ask for the target's id, or it
|
|
117
|
+
* would look up an oracle under its own stage id and find nothing.
|
|
118
|
+
*
|
|
119
|
+
* Keying stays single: ONE held test, written once by the author, read by both
|
|
120
|
+
* gates. That is also why no edge-side authorization change was needed — both
|
|
121
|
+
* reads name a target stage that declares `oracle_passed`, which is exactly
|
|
122
|
+
* what `mayReadOracle` requires.
|
|
123
|
+
*/
|
|
124
|
+
targetStageId?: string | null;
|
|
93
125
|
/** Reads via POST /stage-oracle/fetch with purpose "gate_evaluation". */
|
|
94
126
|
fetchOracle(
|
|
95
127
|
cardId: string,
|
|
@@ -107,7 +139,23 @@ export interface OracleDeps {
|
|
|
107
139
|
run(
|
|
108
140
|
repoPath: string,
|
|
109
141
|
oracle: HeldOracle,
|
|
110
|
-
): Promise<{
|
|
142
|
+
): Promise<{
|
|
143
|
+
exitCode: number;
|
|
144
|
+
/** stdout + stderr merged, for the operator's log. Never the verdict. */
|
|
145
|
+
output: string;
|
|
146
|
+
/**
|
|
147
|
+
* The runner's OWN structured report of what it executed — the only thing
|
|
148
|
+
* the `oracle_red` gate grades (#1019). `null` ⇒ no report this recognizes,
|
|
149
|
+
* which the red gate must treat as `blocked`, never as a pass.
|
|
150
|
+
*
|
|
151
|
+
* Read from a file the runner writes OUTSIDE the worktree, never from the
|
|
152
|
+
* child's stdout or stderr. See {@link OracleReportSpec} for why no
|
|
153
|
+
* in-band channel can carry this: the held test shares stdout and stderr
|
|
154
|
+
* with the runner, and the drain window after the leader exits is exactly
|
|
155
|
+
* where a detached grandchild gets to write the last word.
|
|
156
|
+
*/
|
|
157
|
+
report: OracleRunSummary | null;
|
|
158
|
+
}>;
|
|
111
159
|
}
|
|
112
160
|
|
|
113
161
|
/**
|
|
@@ -215,14 +263,287 @@ export async function remove(
|
|
|
215
263
|
* already have: an absent runner must fail the gate, not fetch a package onto
|
|
216
264
|
* the motor's host mid-stage.
|
|
217
265
|
*/
|
|
218
|
-
const ORACLE_RUNNERS: Record<string,
|
|
219
|
-
vitest:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
266
|
+
const ORACLE_RUNNERS: Record<string, OracleRunnerSpec> = {
|
|
267
|
+
vitest: {
|
|
268
|
+
argv: (path) => ({
|
|
269
|
+
command: "npx",
|
|
270
|
+
args: ["--no-install", "vitest", "run", path],
|
|
271
|
+
}),
|
|
272
|
+
// Measured: vitest prints its human summary block on STDOUT. Diagnostic
|
|
273
|
+
// only since #1019 — nothing grades this stream.
|
|
274
|
+
verdictStream: "stdout",
|
|
275
|
+
report: {
|
|
276
|
+
file: "report.json",
|
|
277
|
+
// `--reporter=default` is listed FIRST and is not decoration: measured
|
|
278
|
+
// against vitest 4.1.11, `--reporter=json` ALONE replaces the default
|
|
279
|
+
// reporter, so stdout loses the `Tests N failed (N)` block entirely and
|
|
280
|
+
// the operator's log is left with one line saying a file was written.
|
|
281
|
+
// Naming both reporters keeps the human log AND gets the machine report.
|
|
282
|
+
flags: (reportPath) => [
|
|
283
|
+
"--reporter=default",
|
|
284
|
+
"--reporter=json",
|
|
285
|
+
`--outputFile=${reportPath}`,
|
|
286
|
+
],
|
|
287
|
+
// Measured against vitest 4.1.11:
|
|
288
|
+
// genuine mixed failure → numTotalTests 3, numFailedTests 2
|
|
289
|
+
// clean pass → numTotalTests N, numFailedTests 0
|
|
290
|
+
// syntax/collect error → numTotalTests 0 (`Tests no tests`)
|
|
291
|
+
// file with no tests → numTotalTests 0
|
|
292
|
+
// path matched nothing → numTotalTests 0
|
|
293
|
+
// runner absent → NO FILE AT ALL, so `null` before this runs
|
|
294
|
+
// Every one of the zero-total cases exits non-zero and is a "could not
|
|
295
|
+
// run", which `gradeOracleRed` maps to `no_verdict` on `total === 0`.
|
|
296
|
+
parse: (content) => {
|
|
297
|
+
const parsed: unknown = JSON.parse(content);
|
|
298
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
299
|
+
const { numTotalTests, numFailedTests } = parsed as Record<
|
|
300
|
+
string,
|
|
301
|
+
unknown
|
|
302
|
+
>;
|
|
303
|
+
// Both counts must be present and finite. A report missing either is a
|
|
304
|
+
// shape we do not know, and `null` (⇒ blocked) is the safe reading —
|
|
305
|
+
// never "assume it ran".
|
|
306
|
+
if (
|
|
307
|
+
typeof numTotalTests !== "number" ||
|
|
308
|
+
typeof numFailedTests !== "number" ||
|
|
309
|
+
!Number.isFinite(numTotalTests) ||
|
|
310
|
+
!Number.isFinite(numFailedTests)
|
|
311
|
+
) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
return { total: numTotalTests, failed: numFailedTests };
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
bun: {
|
|
319
|
+
argv: (path) => ({ command: "bun", args: ["test", path] }),
|
|
320
|
+
// Measured: bun test prints ` N fail` and `Ran N tests…` on STDERR, not
|
|
321
|
+
// stdout (stdout carries only its 28-byte version banner). Diagnostic only
|
|
322
|
+
// since #1019 — nothing grades this stream.
|
|
323
|
+
verdictStream: "stderr",
|
|
324
|
+
report: {
|
|
325
|
+
file: "report.xml",
|
|
326
|
+
// #921 rejected `--reporter=junit` because bun refuses it without
|
|
327
|
+
// `--reporter-outfile`, and a report file INSIDE the worktree is one the
|
|
328
|
+
// completion path could commit — handing the implementer the held test's
|
|
329
|
+
// shape. #1019 answers that objection rather than living with it: the
|
|
330
|
+
// outfile goes to a temp dir outside the worktree (see
|
|
331
|
+
// `runHeldOracle`), so there is nothing in the tree to commit. Measured:
|
|
332
|
+
// bun keeps printing its human stderr summary with this flag on.
|
|
333
|
+
flags: (reportPath) => [
|
|
334
|
+
"--reporter=junit",
|
|
335
|
+
`--reporter-outfile=${reportPath}`,
|
|
336
|
+
],
|
|
337
|
+
// Measured against bun 1.3.13. The root element carries the run-wide
|
|
338
|
+
// counts:
|
|
339
|
+
// `<testsuites name="bun test" tests="2" assertions="2" failures="1" …>`
|
|
340
|
+
// A thrown non-assertion error counts under `failures` too (measured),
|
|
341
|
+
// and the XML carries NO assertion text — only `type="AssertionError"`.
|
|
342
|
+
// An unmatched path and a file with no tests both write NO FILE at all
|
|
343
|
+
// (measured), so those reach `gradeOracleRed` as `null` ⇒ `no_verdict`
|
|
344
|
+
// without this parser running.
|
|
345
|
+
parse: (content) => {
|
|
346
|
+
// The ROOT `<testsuites>` element only — never a per-file
|
|
347
|
+
// `<testsuite>`. Anchoring on the run-wide totals means a multi-file
|
|
348
|
+
// report cannot be read as just its first file's counts.
|
|
349
|
+
const root = /<testsuites\b[^>]*>/.exec(content);
|
|
350
|
+
if (!root) return null;
|
|
351
|
+
const total = /\btests="(\d+)"/.exec(root[0]);
|
|
352
|
+
const failed = /\bfailures="(\d+)"/.exec(root[0]);
|
|
353
|
+
if (!total || !failed) return null;
|
|
354
|
+
return { total: Number(total[1]), failed: Number(failed[1]) };
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
},
|
|
224
358
|
};
|
|
225
359
|
|
|
360
|
+
/**
|
|
361
|
+
* WHY THE VERDICT IS NOT READ FROM stdout/stderr AT ALL (#1019).
|
|
362
|
+
*
|
|
363
|
+
* Until #1019 the red gate parsed the runner's human summary out of the child's
|
|
364
|
+
* verdict stream, taking the LAST match on the reasoning that both runners print
|
|
365
|
+
* their summary last. Three forgeries were closed in that order, and the third
|
|
366
|
+
* could not be:
|
|
367
|
+
*
|
|
368
|
+
* 1. **First-match forgery** (#921). A held test that PASSES while printing
|
|
369
|
+
* `Tests 7 failed (7)` was measured reading as 7 failures. Closed by
|
|
370
|
+
* reading the last match, since anything the test prints precedes the
|
|
371
|
+
* runner's own summary. This was the accidental case too — an honest test
|
|
372
|
+
* that merely logs.
|
|
373
|
+
* 2. **Cross-pipe forgery** (PR #721's security review, MEDIUM
|
|
374
|
+
* `authorization_bypass`). stdout and stderr are appended by two
|
|
375
|
+
* independent `data` handlers with no ordering guarantee BETWEEN the pipes,
|
|
376
|
+
* so a forged line on the stream the runner does not use could land after
|
|
377
|
+
* the real summary. Closed by reading one measured stream per runner.
|
|
378
|
+
* 3. **The detached grandchild** — not closable in-band, which is why the
|
|
379
|
+
* channel moved. `runHeldOracle` waits {@link ORACLE_DRAIN_GRACE_MS} after
|
|
380
|
+
* the leader exits so a slow pipe can still deliver legitimate trailing
|
|
381
|
+
* output, and `reapGroup` addresses the group by number, so it cannot
|
|
382
|
+
* reach a grandchild that called `setsid` to leave it. A held test that
|
|
383
|
+
* spawns a detached writer inheriting the runner's verdict fd therefore
|
|
384
|
+
* gets to emit into that same stream AFTER the runner's real summary:
|
|
385
|
+
*
|
|
386
|
+
* spawn('sh', ['-c', 'sleep 0.1; echo "Ran 9 tests across 9 files."'],
|
|
387
|
+
* { detached: true, stdio: 'inherit' });
|
|
388
|
+
*
|
|
389
|
+
* Read as `{ total: 9, failed: 9 }` ⇒ `reproduced`, on a run where nothing
|
|
390
|
+
* failed. The same test passes the green gate on exit 0, so red-green is
|
|
391
|
+
* satisfied end to end by a contract that proves nothing.
|
|
392
|
+
*
|
|
393
|
+
* Every in-band tightening trades the hole for a worse property, which is what
|
|
394
|
+
* makes this a channel problem rather than a parser problem:
|
|
395
|
+
*
|
|
396
|
+
* - **Snapshot the buffer at leader exit.** The drain window exists BECAUSE
|
|
397
|
+
* the summary may not have been delivered yet, so this trades an
|
|
398
|
+
* adversarial hole for a flaky gate. It is also defeatable: a sprayer that
|
|
399
|
+
* writes continuously lands a line in the window between the runner's own
|
|
400
|
+
* summary and its exit, which is inside any such snapshot.
|
|
401
|
+
* - **Kill the group harder first.** `setsid` is precisely what puts the
|
|
402
|
+
* grandchild out of reach — there is nothing left to signal.
|
|
403
|
+
* - **Require the summary to be the final line, or preceded by the runner's
|
|
404
|
+
* banner.** Legitimate trailing output exists (coverage tables, teardown
|
|
405
|
+
* warnings), so ordinary runs would report `blocked` — and a sprayer can
|
|
406
|
+
* emit a banner too.
|
|
407
|
+
*
|
|
408
|
+
* So the graded channel is a FILE the runner writes, in a temp dir outside the
|
|
409
|
+
* worktree (see {@link OracleReportSpec} and `runHeldOracle`). The streams are
|
|
410
|
+
* still captured for the operator's log, and nothing parses them.
|
|
411
|
+
*
|
|
412
|
+
* WHAT THAT DOES AND DOES NOT BUY, measured rather than assumed. It closes the
|
|
413
|
+
* three stream forgeries above outright: nothing written to stdout or stderr
|
|
414
|
+
* changes a verdict any more, so the accidental case, the first-match case and
|
|
415
|
+
* the detached-grandchild case are all gone.
|
|
416
|
+
*
|
|
417
|
+
* It does NOT make the verdict unforgeable, and nothing here should be read as
|
|
418
|
+
* saying so. The held test is arbitrary code running as the motor's own user, on
|
|
419
|
+
* the same host, so it can write the report file itself:
|
|
420
|
+
*
|
|
421
|
+
* - The directory is NOT secret. `mkdtemp` randomizes the suffix, but the
|
|
422
|
+
* prefix is a fixed string and `readdirSync(os.tmpdir())` enumerates the
|
|
423
|
+
* directory in one call — measured from inside a real held test, under both
|
|
424
|
+
* runners. 0700 does not hide it either; the held test is the owner.
|
|
425
|
+
* - The forgery does NOT need to win a race. A held test can write its own
|
|
426
|
+
* report and then drop write permission (on the file, or on the directory)
|
|
427
|
+
* so the runner's later write cannot land. Measured: a trivially GREEN bun
|
|
428
|
+
* held test doing this produced `{ total: 1, failed: 1 }` and exit 0 — a
|
|
429
|
+
* `reproduced` red gate AND a passing green gate from one file.
|
|
430
|
+
*
|
|
431
|
+
* {@link assertUntampered} refuses that one shape, because both ways of making
|
|
432
|
+
* it stick change a mode the motor set. It does NOT close the class, and the
|
|
433
|
+
* cheapest bypass needs no mode change and no race at all: the held test
|
|
434
|
+
* pre-writes the report and then stops the runner from writing its own.
|
|
435
|
+
* `process.exit(0)` in the test body is enough — measured, 3 runs of 3 under
|
|
436
|
+
* bun, giving `{ total: 1, failed: 1 }` with exit 0, which is `reproduced` from
|
|
437
|
+
* the red gate AND a pass from the green one. Under vitest the same trick yields
|
|
438
|
+
* the same forged counts. `oracle-run.test.ts` pins that this is still open, so
|
|
439
|
+
* the description cannot quietly drift back to "closed".
|
|
440
|
+
*
|
|
441
|
+
* Two other reasons not to read the check as a boundary: `statSync` follows
|
|
442
|
+
* symlinks, so REPLACING the directory (rmdir + mkdir 0700, or a symlink) leaves
|
|
443
|
+
* every mode the motor set intact; and a mode change is restorable by a
|
|
444
|
+
* grandchild that waits for the runner to exit and chmods back.
|
|
445
|
+
*
|
|
446
|
+
* So the accurate summary, and what the docs say: this gate is evidence against
|
|
447
|
+
* a CARELESS author, not against a hostile one, and the mode check raises the
|
|
448
|
+
* cost of one attack rather than establishing a property. Anything short of not
|
|
449
|
+
* running the held test as the motor's own user is a mitigation — the sandbox
|
|
450
|
+
* `repair-sandbox.ts` (#1015) already uses for untrusted code, with the report
|
|
451
|
+
* read from outside it, is the actual fix. Tracked at card #1021; see
|
|
452
|
+
* `docs/agent-daemon.md`.
|
|
453
|
+
*/
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* What a runner reported about the tests it actually executed. This exists for
|
|
457
|
+
* the `oracle_red` gate (#921) and for nothing else: `oracle_passed` needs only
|
|
458
|
+
* the exit code, because a green gate that misreads "could not run" as `failed`
|
|
459
|
+
* merely under-grants, which is safe.
|
|
460
|
+
*
|
|
461
|
+
* A red gate has no such luxury. It passes on FAILURE, so reading "could not
|
|
462
|
+
* run" as a failure would over-grant — and the three cases are indistinguishable
|
|
463
|
+
* by exit code alone. All of these exit 1, measured against the real runners:
|
|
464
|
+
*
|
|
465
|
+
* - a genuine assertion failure (the signal the gate wants)
|
|
466
|
+
* - `npx --no-install vitest`, absent (the runner never started)
|
|
467
|
+
* - `bun test ./unmatched-path` (nothing was collected)
|
|
468
|
+
*
|
|
469
|
+
* So the red gate requires the runner's own count of executed and failed tests,
|
|
470
|
+
* and reports `blocked` whenever it cannot read one.
|
|
471
|
+
*/
|
|
472
|
+
export interface OracleRunSummary {
|
|
473
|
+
/** Tests the runner reported having EXECUTED. */
|
|
474
|
+
total: number;
|
|
475
|
+
/** Of those, how many the runner reported as FAILING. */
|
|
476
|
+
failed: number;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* How one allow-listed runner writes a MACHINE report, and how to read it.
|
|
481
|
+
*
|
|
482
|
+
* This is the red gate's only graded channel (#1019). The point is not that the
|
|
483
|
+
* format is structured — it is that the held test does not share the channel.
|
|
484
|
+
* A file the runner writes outside the worktree is not something an inherited
|
|
485
|
+
* stdio fd reaches, so the detached-grandchild forgery
|
|
486
|
+
* that no in-band parser could refuse has nowhere to land. The long comment
|
|
487
|
+
* above `OracleRunnerSpec` has the full account.
|
|
488
|
+
*
|
|
489
|
+
* `flags` must keep the runner's HUMAN output intact as well: the operator's log
|
|
490
|
+
* is how a person understands a blocked gate, and a reporter flag that replaces
|
|
491
|
+
* the default reporter (measured: vitest's `--reporter=json` does exactly that)
|
|
492
|
+
* would silently empty it.
|
|
493
|
+
*/
|
|
494
|
+
interface OracleReportSpec {
|
|
495
|
+
/**
|
|
496
|
+
* The report's filename inside the dir `runHeldOracle` creates. A plain fixed
|
|
497
|
+
* name on purpose: the directory's random suffix is not a secret either (the
|
|
498
|
+
* held test can enumerate it — see `runHeldOracle`), so a random filename
|
|
499
|
+
* would buy nothing and only make the operator's log harder to read.
|
|
500
|
+
*/
|
|
501
|
+
file: string;
|
|
502
|
+
/** Extra argv elements that make the runner write its report to `reportPath`. */
|
|
503
|
+
flags(reportPath: string): string[];
|
|
504
|
+
/**
|
|
505
|
+
* Read `{ total, failed }` out of the runner's own report, or `null` when the
|
|
506
|
+
* content carries no report this recognizes.
|
|
507
|
+
*
|
|
508
|
+
* `null` is load-bearing and must stay the DEFAULT for anything unfamiliar:
|
|
509
|
+
* the red gate maps it to `blocked`, so a runner that changes its report
|
|
510
|
+
* format degrades to "we could not tell" rather than to a false red. Never
|
|
511
|
+
* widen one of these to "assume it ran" — that inverts the safe direction.
|
|
512
|
+
*
|
|
513
|
+
* May throw (a JSON parse error is the obvious one); `runHeldOracle` catches
|
|
514
|
+
* and treats a throw exactly as `null`.
|
|
515
|
+
*
|
|
516
|
+
* The formats are pinned by tests in `oracle-red.test.ts` against captured
|
|
517
|
+
* real-runner reports, so drift fails a test instead of going quiet — and by
|
|
518
|
+
* an end-to-end test against the real runners in `oracle-run.test.ts`, so a
|
|
519
|
+
* runner that stops honoring the flag fails there rather than leaving the gate
|
|
520
|
+
* permanently `blocked` in production.
|
|
521
|
+
*/
|
|
522
|
+
parse(content: string): OracleRunSummary | null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** One allow-listed runner: the argv to run it, and how to read its report. */
|
|
526
|
+
interface OracleRunnerSpec {
|
|
527
|
+
argv(path: string): OracleRunnerArgv;
|
|
528
|
+
/**
|
|
529
|
+
* WHICH stream this runner prints its human summary on.
|
|
530
|
+
*
|
|
531
|
+
* DIAGNOSTIC ONLY since #1019 — nothing grades it. `runHeldOracle` keeps this
|
|
532
|
+
* stream in a buffer of its own so the operator's log can show the runner's
|
|
533
|
+
* own summary apart from whatever the held test printed, and so the
|
|
534
|
+
* end-to-end tests can pin which fd each runner uses. A verdict is never
|
|
535
|
+
* parsed out of it: see the comment above `OracleRunnerSpec` for why no
|
|
536
|
+
* in-band channel can carry one.
|
|
537
|
+
*
|
|
538
|
+
* Measured per runner, because they differ and the difference is not
|
|
539
|
+
* guessable: **vitest** prints `Tests N failed (N)` on **stdout**, while
|
|
540
|
+
* **bun** prints ` N fail` / `Ran N tests…` on **stderr**.
|
|
541
|
+
*/
|
|
542
|
+
verdictStream: "stdout" | "stderr";
|
|
543
|
+
/** The machine report: the gate's ONLY graded channel. */
|
|
544
|
+
report: OracleReportSpec;
|
|
545
|
+
}
|
|
546
|
+
|
|
226
547
|
/** The hints {@link resolveOracleRunner} accepts, for the refusal message. */
|
|
227
548
|
export const ORACLE_RUNNER_HINTS: readonly string[] =
|
|
228
549
|
Object.keys(ORACLE_RUNNERS).sort();
|
|
@@ -240,6 +561,14 @@ export interface OracleRunnerArgv {
|
|
|
240
561
|
* way `runMetricCommand` must (there, stdout IS the measurement, so a partial
|
|
241
562
|
* read would corrupt the number). Memory stays bounded either way, and the
|
|
242
563
|
* timeout still bounds the process.
|
|
564
|
+
*
|
|
565
|
+
* Truncation keeps the TAIL, and that outlived its original reason. #921 made
|
|
566
|
+
* it security-critical (the red gate parsed the runner's summary block, printed
|
|
567
|
+
* LAST, out of this very buffer, so trimming the end destroyed the gate's only
|
|
568
|
+
* signal on a verbose held test). #1019 moved the verdict to a report file, so
|
|
569
|
+
* the tail is no longer load-bearing for GRADING — but it stays, because the
|
|
570
|
+
* runner's summary is still the most useful thing in an operator's log and the
|
|
571
|
+
* head of a verbose run is the least. See `append` in {@link runHeldOracle}.
|
|
243
572
|
*/
|
|
244
573
|
const ORACLE_OUTPUT_LIMIT = 64 * 1024;
|
|
245
574
|
|
|
@@ -252,7 +581,13 @@ const ORACLE_SIGTERM_GRACE_MS = 3_000;
|
|
|
252
581
|
/** How long to wait after the leader exits for its stdio pipes to deliver the
|
|
253
582
|
* tail of the output. Bounded for the same reason `command-metric.ts` bounds
|
|
254
583
|
* its own: a grandchild that left the group with `setsid()` holds the pipe open
|
|
255
|
-
* forever, and a run that already produced its exit code must not wait on it.
|
|
584
|
+
* forever, and a run that already produced its exit code must not wait on it.
|
|
585
|
+
*
|
|
586
|
+
* This window used to be the red gate's exposure (#1019): a detached grandchild
|
|
587
|
+
* could write a forged summary into the verdict stream during it and win the
|
|
588
|
+
* parse. Since the verdict comes from a report file read AT the `exit` event,
|
|
589
|
+
* anything appended during this window reaches the operator's log and nothing
|
|
590
|
+
* else — so the window can stay as generous as legitimate late output needs. */
|
|
256
591
|
const ORACLE_DRAIN_GRACE_MS = 500;
|
|
257
592
|
|
|
258
593
|
/**
|
|
@@ -276,20 +611,128 @@ const ORACLE_DRAIN_GRACE_MS = 500;
|
|
|
276
611
|
* `failed` instead, so this follows the outcome it named, not the mechanism.
|
|
277
612
|
*/
|
|
278
613
|
export function resolveOracleRunner(oracle: HeldOracle): OracleRunnerArgv {
|
|
614
|
+
return resolveOracleRunnerSpec(oracle).argv(argvPath(oracle.path));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The whole allow-list entry for a held test's `runnerHint` — argv AND summary
|
|
619
|
+
* reader. Throws on an unknown or absent hint, exactly as
|
|
620
|
+
* {@link resolveOracleRunner} does (they share this function), so the refusal
|
|
621
|
+
* message and the `blocked` outcome are identical for both gate polarities.
|
|
622
|
+
*/
|
|
623
|
+
function resolveOracleRunnerSpec(oracle: HeldOracle): OracleRunnerSpec {
|
|
279
624
|
const hint = oracle.runnerHint?.trim().toLowerCase() ?? "";
|
|
280
625
|
// `Object.hasOwn`, never a bare lookup: an inherited key ("constructor",
|
|
281
626
|
// "toString") would otherwise resolve to a function and be called.
|
|
282
|
-
const
|
|
627
|
+
const spec = Object.hasOwn(ORACLE_RUNNERS, hint)
|
|
283
628
|
? ORACLE_RUNNERS[hint]
|
|
284
629
|
: undefined;
|
|
285
|
-
if (!
|
|
630
|
+
if (!spec) {
|
|
286
631
|
throw new Error(
|
|
287
632
|
`refusing to run the held test: runner_hint ${JSON.stringify(
|
|
288
633
|
oracle.runnerHint,
|
|
289
634
|
)} is not in the motor's allow-list (${ORACLE_RUNNER_HINTS.join(", ")})`,
|
|
290
635
|
);
|
|
291
636
|
}
|
|
292
|
-
return
|
|
637
|
+
return spec;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Read the runner's own test counts out of a machine report's CONTENT, through
|
|
642
|
+
* the same allow-list that chose the argv. `null` ⇒ no report this recognizes,
|
|
643
|
+
* which the red gate must treat as `blocked` (never as a pass). A parser that
|
|
644
|
+
* throws (malformed JSON, most obviously) is `null` too — the safe reading of
|
|
645
|
+
* "we could not tell".
|
|
646
|
+
*
|
|
647
|
+
* EXPORTED AS A TEST SEAM, and has no production caller by design: production
|
|
648
|
+
* reads the report through `runHeldOracle`, which owns the file's lifetime.
|
|
649
|
+
* The export exists so `oracle-red.test.ts` can pin each runner's report format
|
|
650
|
+
* against captured real output without going near the filesystem, which is the
|
|
651
|
+
* only thing that makes a format drift fail loudly instead of degrading the gate
|
|
652
|
+
* to a silent `blocked`. Nothing else should be handed report content: it is
|
|
653
|
+
* secret-bearing for one of the two runners (vitest's JSON carries
|
|
654
|
+
* `failureMessages`, measured — see `captureReport`).
|
|
655
|
+
*
|
|
656
|
+
* The green gate never calls this: `oracle_passed` decides on the exit code
|
|
657
|
+
* alone, and widening it to consult a report would be a behavior change to a
|
|
658
|
+
* security-reviewed gate for no gain — a green gate that misreads "could not
|
|
659
|
+
* run" as `failed` under-grants, which is already the safe direction.
|
|
660
|
+
*/
|
|
661
|
+
export function summarizeOracleReport(
|
|
662
|
+
oracle: HeldOracle,
|
|
663
|
+
content: string,
|
|
664
|
+
): OracleRunSummary | null {
|
|
665
|
+
// Resolved OUTSIDE the try, so an unknown `runner_hint` still THROWS. The two
|
|
666
|
+
// failures are not the same thing and must not collapse: an unrecognized
|
|
667
|
+
// report is "we could not tell" (`null` ⇒ blocked), while an unknown hint is a
|
|
668
|
+
// defect in the oracle's own declaration that `OracleCollector` reports as
|
|
669
|
+
// `blocked` with the refusal message attached. Swallowing the throw here
|
|
670
|
+
// would lose the message that names the hint and the allow-list.
|
|
671
|
+
const spec = resolveOracleRunnerSpec(oracle);
|
|
672
|
+
try {
|
|
673
|
+
return spec.report.parse(content) ?? null;
|
|
674
|
+
} catch {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The three outcomes a RED reading of a held-test run can have. Pure, and
|
|
681
|
+
* separated from the collector so the decision table is testable on its own.
|
|
682
|
+
*
|
|
683
|
+
* `no_verdict` is the outcome that makes the gate honest: it is the answer
|
|
684
|
+
* whenever the run cannot be shown to have executed the held test and found it
|
|
685
|
+
* failing. It maps to `blocked`, which can never satisfy a gate.
|
|
686
|
+
*/
|
|
687
|
+
export type OracleRedOutcome =
|
|
688
|
+
/** The held test ran and reported at least one failing test — the bug is reproduced. */
|
|
689
|
+
| { outcome: "reproduced"; summary: OracleRunSummary }
|
|
690
|
+
/** The held test ran and nothing failed — this does not reproduce the bug. */
|
|
691
|
+
| { outcome: "not_reproduced"; summary: OracleRunSummary }
|
|
692
|
+
/** The run produced no trustworthy red/green answer at all. */
|
|
693
|
+
| { outcome: "no_verdict"; reason: string };
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Grade a completed held-test run RED.
|
|
697
|
+
*
|
|
698
|
+
* The order of these checks is the safety property, not a style choice. Every
|
|
699
|
+
* "could not run" case exits non-zero exactly like a genuine assertion failure
|
|
700
|
+
* (see {@link OracleRunSummary}), so the exit code is consulted LAST and only
|
|
701
|
+
* ever to reject — never to grant. A pass requires a positive count of failing
|
|
702
|
+
* tests from the runner's own summary.
|
|
703
|
+
*/
|
|
704
|
+
export function gradeOracleRed(
|
|
705
|
+
summary: OracleRunSummary | null,
|
|
706
|
+
exitCode: number,
|
|
707
|
+
): OracleRedOutcome {
|
|
708
|
+
if (!summary) {
|
|
709
|
+
return {
|
|
710
|
+
outcome: "no_verdict",
|
|
711
|
+
reason:
|
|
712
|
+
`the runner exited ${exitCode} but the motor has no report it could read — absent, unreadable, or refused, ` +
|
|
713
|
+
"so it cannot be shown to have run the held test (an absent runner and a failing test share this exit code). " +
|
|
714
|
+
"The report is a file the runner writes outside the worktree; the motor's local log says which of the three it was",
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
if (summary.total === 0) {
|
|
718
|
+
return {
|
|
719
|
+
outcome: "no_verdict",
|
|
720
|
+
reason: `the runner started but executed no tests (exit ${exitCode}), so the held test produced no verdict`,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
if (summary.failed > 0) {
|
|
724
|
+
return { outcome: "reproduced", summary };
|
|
725
|
+
}
|
|
726
|
+
if (exitCode === 0) {
|
|
727
|
+
return { outcome: "not_reproduced", summary };
|
|
728
|
+
}
|
|
729
|
+
// Every test the runner ran passed, yet it still failed overall — a coverage
|
|
730
|
+
// threshold, a post-run hook, an unhandled rejection. That is not a
|
|
731
|
+
// reproduction of the card's bug, and it is not a clean green either.
|
|
732
|
+
return {
|
|
733
|
+
outcome: "no_verdict",
|
|
734
|
+
reason: `all ${summary.total} test(s) passed but the runner exited ${exitCode}, so the failure is not the held test's`,
|
|
735
|
+
};
|
|
293
736
|
}
|
|
294
737
|
|
|
295
738
|
/**
|
|
@@ -334,108 +777,413 @@ export async function runHeldOracle(
|
|
|
334
777
|
repoPath: string,
|
|
335
778
|
oracle: HeldOracle,
|
|
336
779
|
timeoutMs: number = DEFAULT_METRIC_TIMEOUT_MS,
|
|
337
|
-
): Promise<{
|
|
338
|
-
|
|
780
|
+
): Promise<{
|
|
781
|
+
exitCode: number;
|
|
782
|
+
output: string;
|
|
783
|
+
verdict: string;
|
|
784
|
+
report: OracleRunSummary | null;
|
|
785
|
+
}> {
|
|
786
|
+
// Resolve the runner BEFORE creating anything: an unknown hint must reject
|
|
787
|
+
// without leaving a temp directory behind (and without spawning, which the
|
|
788
|
+
// "rejects an unknown hint before it spawns anything" test pins).
|
|
789
|
+
const spec = resolveOracleRunnerSpec(oracle);
|
|
790
|
+
|
|
791
|
+
// The graded channel (#1019). OUTSIDE the worktree, because a report file
|
|
792
|
+
// inside the tree is one the completion path could commit — handing the
|
|
793
|
+
// implementer the held test's shape, the exact hazard `removeBestEffort`
|
|
794
|
+
// exists to prevent, and the objection that made #921 avoid a report file at
|
|
795
|
+
// all.
|
|
796
|
+
//
|
|
797
|
+
// `mkdtemp` randomizes the SUFFIX and creates the directory 0700. Neither
|
|
798
|
+
// hides the path from the held test: the prefix is a fixed string and
|
|
799
|
+
// `readdirSync(tmpdir())` enumerates the directory in one call, 0700 included
|
|
800
|
+
// (the held test runs as the motor's own user, who owns it). This was measured,
|
|
801
|
+
// not assumed. So the randomness is not a secret and nothing here should be
|
|
802
|
+
// read as one — see `assertUntampered` for what actually defends the file, and
|
|
803
|
+
// the comment above `OracleRunnerSpec` for what remains open.
|
|
804
|
+
const reportDir = await mkdtemp(join(tmpdir(), reportDirPrefix()));
|
|
805
|
+
const reportPath = join(reportDir, spec.report.file);
|
|
806
|
+
try {
|
|
807
|
+
return await spawnHeldOracle(spec, repoPath, oracle, reportPath, timeoutMs);
|
|
808
|
+
} finally {
|
|
809
|
+
await removeReportDir(reportDir);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* The report directory's fixed name prefix. Deliberately a constant and not a
|
|
815
|
+
* secret (see `runHeldOracle`); it exists so an operator can recognize a
|
|
816
|
+
* stranded directory as this module's, and so {@link removeReportDir} logs
|
|
817
|
+
* something identifiable.
|
|
818
|
+
*/
|
|
819
|
+
function reportDirPrefix(): string {
|
|
820
|
+
return "harmony-oracle-report-";
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Refuse a report whose file or directory shows that something other than the
|
|
825
|
+
* runner has been at it. THROWS on any doubt; `captureReport` turns a throw into
|
|
826
|
+
* `null` ⇒ `blocked`, which can never satisfy a gate.
|
|
827
|
+
*
|
|
828
|
+
* What this catches, and it is worth being exact because the rest is open: a
|
|
829
|
+
* held test can find this directory (the prefix is fixed and `readdir` lists
|
|
830
|
+
* it), so the cheapest forgery is to write the report ITSELF and then remove
|
|
831
|
+
* write permission — from the file, or from the directory — so the runner's own
|
|
832
|
+
* write cannot land on top of it. That variant is deterministic and needs no
|
|
833
|
+
* race. Both halves of it change a mode the motor set, so both are visible here:
|
|
834
|
+
*
|
|
835
|
+
* - the directory must still be exactly 0700, as `mkdtemp` created it
|
|
836
|
+
* - the report must be a REGULAR file (never a symlink, which would redirect
|
|
837
|
+
* the read somewhere the motor did not create)
|
|
838
|
+
* - the report must still be writable by its owner — a 0444 report is one the
|
|
839
|
+
* runner could not have overwritten, so it is not necessarily the runner's
|
|
840
|
+
*
|
|
841
|
+
* What this does NOT catch, stated so nobody reads it as more than it is:
|
|
842
|
+
*
|
|
843
|
+
* - **Pre-write, then stop the runner writing.** No mode change, no race:
|
|
844
|
+
* `process.exit(0)` in the held test skips the reporter's write entirely and
|
|
845
|
+
* leaves the forgery as the only file on disk, at ordinary modes inside a
|
|
846
|
+
* still-0700 directory. Measured under both runners. This is CHEAPER than
|
|
847
|
+
* the attack above, so the check below is one shape of an open class.
|
|
848
|
+
* - **Replacing the directory.** `statSync` follows symlinks, so an rmdir +
|
|
849
|
+
* mkdir 0700, or a symlink to the attacker's own directory, changes no mode
|
|
850
|
+
* the motor set.
|
|
851
|
+
* - **Restoring the mode.** A grandchild that waits for the runner to exit and
|
|
852
|
+
* chmods back defeats the file-mode arm.
|
|
853
|
+
*
|
|
854
|
+
* Mode checks raise the cost of one attack; they are not a boundary. The only
|
|
855
|
+
* boundary is not running the held test as the motor's user at all — see the
|
|
856
|
+
* comment above `OracleRunnerSpec` and card #1021.
|
|
857
|
+
*
|
|
858
|
+
* The exact-0700 requirement is deliberate but is a false-positive risk worth
|
|
859
|
+
* knowing about: `mkdtemp` asks for 0700 through `mkdir`, so an exotic umask
|
|
860
|
+
* that clears an owner bit, or a `TMPDIR` on a filesystem that does not carry
|
|
861
|
+
* POSIX modes, would refuse every report and block the gate permanently. Both
|
|
862
|
+
* runners were measured writing the report 0644 inside a 0700 directory under a
|
|
863
|
+
* normal umask. `captureReport` logs the refusal reason so that case is
|
|
864
|
+
* diagnosable rather than silent.
|
|
865
|
+
*/
|
|
866
|
+
function assertUntampered(reportPath: string): void {
|
|
867
|
+
const dir = statSync(dirname(reportPath));
|
|
868
|
+
// `& 0o777` drops the file-type bits; only the permission bits are the claim.
|
|
869
|
+
if ((dir.mode & 0o777) !== 0o700) {
|
|
870
|
+
throw new Error(
|
|
871
|
+
`the oracle report directory's mode changed to ${(dir.mode & 0o777).toString(8)} — refusing the report`,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
// `lstat`, never `stat`: a symlink is exactly what a redirect would look like,
|
|
875
|
+
// and `stat` would follow it and report the target as a fine regular file.
|
|
876
|
+
const file = lstatSync(reportPath);
|
|
877
|
+
if (!file.isFile()) {
|
|
878
|
+
throw new Error("the oracle report is not a regular file — refusing it");
|
|
879
|
+
}
|
|
880
|
+
if ((file.mode & 0o200) === 0) {
|
|
881
|
+
throw new Error(
|
|
882
|
+
`the oracle report is not owner-writable (mode ${(file.mode & 0o777).toString(8)}), so the runner could not have written it last — refusing it`,
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
339
886
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
887
|
+
/**
|
|
888
|
+
* Delete the report directory. Attempted on EVERY path — completion, rejection,
|
|
889
|
+
* timeout — because the report is secret-bearing for one of the two runners:
|
|
890
|
+
* vitest's JSON carries `failureMessages` with the assertion text verbatim
|
|
891
|
+
* (measured), which is itself a partial oracle.
|
|
892
|
+
*
|
|
893
|
+
* BEST EFFORT, and said so out loud rather than in a silent `catch`. A held test
|
|
894
|
+
* that chmods the directory to remove write permission makes the removal fail,
|
|
895
|
+
* and a stranded directory then accumulates on a long-lived daemon host with the
|
|
896
|
+
* assertion text still in it. So this restores the mode first — the motor owns
|
|
897
|
+
* the directory, so it may — and WARNS when the removal still fails, giving the
|
|
898
|
+
* operator the path to clean up. It never throws: a directory we could not
|
|
899
|
+
* delete must not turn a completed gate into a blocked one.
|
|
900
|
+
*/
|
|
901
|
+
async function removeReportDir(reportDir: string): Promise<void> {
|
|
902
|
+
try {
|
|
903
|
+
// A held test may have chmod'ed this to make its forged report stick. We
|
|
904
|
+
// own it, so take the permission back before removing.
|
|
905
|
+
await chmod(reportDir, 0o700).catch(() => {});
|
|
906
|
+
await rm(reportDir, { recursive: true, force: true });
|
|
907
|
+
} catch (err) {
|
|
908
|
+
log.warn(
|
|
909
|
+
TAG,
|
|
910
|
+
`Could not remove the oracle report directory ${reportDir} (${
|
|
911
|
+
err instanceof Error ? err.message : String(err)
|
|
912
|
+
}) — it may still hold the runner's report, which carries assertion text for vitest. Remove it by hand.`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* The supervised spawn half of {@link runHeldOracle}, split out so the report
|
|
919
|
+
* directory's lifetime is a plain `try`/`finally` in one place rather than a
|
|
920
|
+
* cleanup duplicated down every settle path.
|
|
921
|
+
*/
|
|
922
|
+
async function spawnHeldOracle(
|
|
923
|
+
spec: OracleRunnerSpec,
|
|
924
|
+
repoPath: string,
|
|
925
|
+
oracle: HeldOracle,
|
|
926
|
+
reportPath: string,
|
|
927
|
+
timeoutMs: number,
|
|
928
|
+
): Promise<{
|
|
929
|
+
exitCode: number;
|
|
930
|
+
output: string;
|
|
931
|
+
verdict: string;
|
|
932
|
+
report: OracleRunSummary | null;
|
|
933
|
+
}> {
|
|
934
|
+
const { command, args: baseArgs } = spec.argv(argvPath(oracle.path));
|
|
935
|
+
// The report flags ride on BOTH gate polarities, not just the red one. #921
|
|
936
|
+
// kept the two polarities on one argv on purpose, so the security-reviewed
|
|
937
|
+
// allow-list stays a single shape per runner; making the report conditional
|
|
938
|
+
// would have split it. The green gate simply ignores the report it produces.
|
|
939
|
+
//
|
|
940
|
+
// The cost of that choice, so it is not a surprise: the green gate grades on
|
|
941
|
+
// the EXIT CODE alone, and a runner that cannot write its report may exit
|
|
942
|
+
// non-zero for that reason (measured: vitest does). `oracle_passed` would then
|
|
943
|
+
// report `failed` — blaming the implementer and spending an attempt — for a
|
|
944
|
+
// problem with the motor's temp dir rather than with the diff. That has not
|
|
945
|
+
// been seen outside a deliberately broken temp dir, and the alternative (two
|
|
946
|
+
// argv shapes) was judged worse, but it is the trade being made.
|
|
947
|
+
const args = [...baseArgs, ...spec.report.flags(reportPath)];
|
|
948
|
+
|
|
949
|
+
return await new Promise<{
|
|
950
|
+
exitCode: number;
|
|
951
|
+
output: string;
|
|
952
|
+
verdict: string;
|
|
953
|
+
report: OracleRunSummary | null;
|
|
954
|
+
}>((settleOk, settleErr) => {
|
|
955
|
+
let child: ChildProcess;
|
|
956
|
+
try {
|
|
957
|
+
child = spawnInGroup(command, args, {
|
|
958
|
+
cwd: repoPath,
|
|
959
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
960
|
+
});
|
|
961
|
+
} catch (err) {
|
|
962
|
+
// A synchronous spawn throw (unusable cwd, bad argv) — no group exists.
|
|
963
|
+
settleErr(err);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Record the pgid AT SPAWN: `reapGroup` addresses the group by number, so
|
|
968
|
+
// it still sweeps stragglers once the leader handle is spent.
|
|
969
|
+
const pgid = child.pid;
|
|
970
|
+
let output = "";
|
|
971
|
+
let truncated = false;
|
|
972
|
+
// ONE DECODER PER PIPE, not one shared: each pipe delivers its own byte
|
|
973
|
+
// stream, so a shared decoder would hand a partial character from stdout
|
|
974
|
+
// to the next stderr chunk and corrupt both.
|
|
975
|
+
const outDecoder = new StringDecoder("utf8");
|
|
976
|
+
const errDecoder = new StringDecoder("utf8");
|
|
977
|
+
/** Only the runner's own stream. Diagnostic since #1019; nothing parses it. */
|
|
978
|
+
let verdict = "";
|
|
979
|
+
/**
|
|
980
|
+
* The buffer as the caller sees it: the kept tail, labelled if trimmed.
|
|
981
|
+
*
|
|
982
|
+
* Flushes the decoder first, so bytes it is still holding for an
|
|
983
|
+
* incomplete character at end-of-stream are not silently dropped. Called
|
|
984
|
+
* once — `settle` is guarded by `settled` — and guarded here too, because
|
|
985
|
+
* `decoder.end()` is not idempotent.
|
|
986
|
+
*/
|
|
987
|
+
let flushed = false;
|
|
988
|
+
const flush = (): void => {
|
|
989
|
+
if (flushed) return;
|
|
990
|
+
flushed = true;
|
|
991
|
+
const outTail = outDecoder.end();
|
|
992
|
+
const errTail = errDecoder.end();
|
|
993
|
+
output += outTail + errTail;
|
|
994
|
+
verdict += spec.verdictStream === "stdout" ? outTail : errTail;
|
|
995
|
+
};
|
|
996
|
+
const finalOutput = (): string => {
|
|
997
|
+
flush();
|
|
998
|
+
return truncated
|
|
999
|
+
? `… earlier output dropped; kept the last ${ORACLE_OUTPUT_LIMIT} characters\n${output}`
|
|
1000
|
+
: output;
|
|
1001
|
+
};
|
|
1002
|
+
/**
|
|
1003
|
+
* The verdict stream, flushed. The operator's per-runner view of what the
|
|
1004
|
+
* runner itself said; NOT parsed for a verdict (#1019).
|
|
1005
|
+
*/
|
|
1006
|
+
const finalVerdict = (): string => {
|
|
1007
|
+
flush();
|
|
1008
|
+
return verdict;
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* The runner's machine report, captured AT THE `exit` EVENT.
|
|
1013
|
+
*
|
|
1014
|
+
* `readFileSync`, deliberately, in the `exit` handler — not an awaited
|
|
1015
|
+
* async read. Both runners write this file and then exit, so at `exit` it is
|
|
1016
|
+
* complete; reading it synchronously right there leaves no gap in which
|
|
1017
|
+
* anything else could rewrite it. An async read would hand the held test's
|
|
1018
|
+
* detached grandchild a scheduling window to overwrite the file, which is
|
|
1019
|
+
* the same shape of race the drain window used to be. The file is a few KB
|
|
1020
|
+
* and the run is already over, so blocking the loop here costs nothing.
|
|
1021
|
+
*
|
|
1022
|
+
* Every failure is `null`, which `gradeOracleRed` maps to `no_verdict` ⇒
|
|
1023
|
+
* `blocked`: the runner never wrote a report (measured — bun writes none at
|
|
1024
|
+
* all for an unmatched path or a file with no tests), the content is
|
|
1025
|
+
* malformed, the parser does not recognize the shape, or the file shows
|
|
1026
|
+
* signs of tampering. Never a pass.
|
|
1027
|
+
*/
|
|
1028
|
+
let report: OracleRunSummary | null = null;
|
|
1029
|
+
const captureReport = (): void => {
|
|
343
1030
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
347
|
-
});
|
|
1031
|
+
assertUntampered(reportPath);
|
|
1032
|
+
report = spec.report.parse(readFileSync(reportPath, "utf8"));
|
|
348
1033
|
} catch (err) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
1034
|
+
report = null;
|
|
1035
|
+
// Never silent. This is the module's only tamper DETECTOR, and a
|
|
1036
|
+
// detection that logs nothing is not a detection an operator can act
|
|
1037
|
+
// on. It is also the one place a false positive can produce a
|
|
1038
|
+
// permanently blocked gate (see `assertUntampered` on the exact-0700
|
|
1039
|
+
// requirement), and the gate's own reason cannot name which case it
|
|
1040
|
+
// was — so the message that distinguishes "no report at all" from
|
|
1041
|
+
// "a report we refused" has to land here, in the motor's local log.
|
|
1042
|
+
//
|
|
1043
|
+
// ENOENT is the ordinary "runner wrote nothing" path (measured: bun
|
|
1044
|
+
// writes no report for an unmatched path or a file with no tests), so
|
|
1045
|
+
// it is info, not a warning — it is not evidence of anything wrong.
|
|
1046
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1047
|
+
const absent =
|
|
1048
|
+
err instanceof Error &&
|
|
1049
|
+
(err as NodeJS.ErrnoException).code === "ENOENT";
|
|
1050
|
+
if (absent) {
|
|
1051
|
+
log.info(TAG, `No oracle report at ${reportPath} — no verdict.`);
|
|
1052
|
+
} else {
|
|
1053
|
+
log.warn(
|
|
1054
|
+
TAG,
|
|
1055
|
+
`Refusing the oracle report at ${reportPath}: ${message} — the gate will report no verdict.`,
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
352
1058
|
}
|
|
1059
|
+
};
|
|
1060
|
+
let settled = false;
|
|
1061
|
+
let killing = false;
|
|
1062
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1063
|
+
let drainTimer: ReturnType<typeof setTimeout> | undefined;
|
|
353
1064
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
1065
|
+
/** Settle once, and never without sweeping the group. */
|
|
1066
|
+
const settle = (
|
|
1067
|
+
failure: Error | null,
|
|
1068
|
+
result?: {
|
|
1069
|
+
exitCode: number;
|
|
1070
|
+
output: string;
|
|
1071
|
+
verdict: string;
|
|
1072
|
+
report: OracleRunSummary | null;
|
|
1073
|
+
},
|
|
1074
|
+
): void => {
|
|
1075
|
+
if (settled) return;
|
|
1076
|
+
settled = true;
|
|
1077
|
+
if (timer) clearTimeout(timer);
|
|
1078
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
1079
|
+
reapGroup(pgid);
|
|
1080
|
+
if (failure) settleErr(failure);
|
|
1081
|
+
else settleOk(result!);
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
// stdout and stderr both land in one buffer: a test runner splits its
|
|
1085
|
+
// report across them.
|
|
1086
|
+
//
|
|
1087
|
+
// Truncation keeps the TAIL, not the head. Both runners print their
|
|
1088
|
+
// summary block LAST, so the tail is the useful end of an operator's log
|
|
1089
|
+
// and the head is the least useful. This used to be load-bearing for
|
|
1090
|
+
// GRADING — the red gate parsed that block out of this buffer, and keeping
|
|
1091
|
+
// the head instead left the gate permanently `blocked` on any verbose held
|
|
1092
|
+
// test — but since #1019 the verdict comes from the report file, so a
|
|
1093
|
+
// truncated buffer costs a reader context and costs the gate nothing.
|
|
1094
|
+
//
|
|
1095
|
+
// Re-slicing on every append keeps memory bounded to ~the cap without
|
|
1096
|
+
// holding the whole stream, and the notice is composed at settle time
|
|
1097
|
+
// rather than stored, so repeated trims cannot mangle it.
|
|
1098
|
+
// Decoded through a StringDecoder, NOT `chunk.toString("utf8")` per
|
|
1099
|
+
// chunk: a multi-byte sequence split across a chunk boundary decodes to
|
|
1100
|
+
// U+FFFD that way. Now that nothing parses these buffers this is a
|
|
1101
|
+
// legibility property rather than a correctness one — a replacement
|
|
1102
|
+
// character in the operator's log is merely ugly — but it is still what
|
|
1103
|
+
// keeps a UTF-8 test name readable. The decoder holds the partial bytes
|
|
1104
|
+
// until the next chunk completes them.
|
|
1105
|
+
const append = (stream: "stdout" | "stderr", chunk: Buffer): void => {
|
|
1106
|
+
const text = (stream === "stdout" ? outDecoder : errDecoder).write(chunk);
|
|
1107
|
+
output += text;
|
|
1108
|
+
if (output.length > ORACLE_OUTPUT_LIMIT) {
|
|
1109
|
+
output = output.slice(-ORACLE_OUTPUT_LIMIT);
|
|
1110
|
+
truncated = true;
|
|
1111
|
+
}
|
|
1112
|
+
// The runner's own stream is ALSO kept unmerged, so an operator can read
|
|
1113
|
+
// what the RUNNER said apart from what the held test printed. Nothing
|
|
1114
|
+
// grades it (see `verdictStream`); it exists for the log and for the
|
|
1115
|
+
// end-to-end tests that pin which fd each runner uses.
|
|
1116
|
+
if (stream === spec.verdictStream) {
|
|
1117
|
+
verdict += text;
|
|
1118
|
+
if (verdict.length > ORACLE_OUTPUT_LIMIT) {
|
|
1119
|
+
verdict = verdict.slice(-ORACLE_OUTPUT_LIMIT);
|
|
403
1120
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
child.stdout?.on("data", (chunk: Buffer) => append("stdout", chunk));
|
|
1124
|
+
child.stderr?.on("data", (chunk: Buffer) => append("stderr", chunk));
|
|
1125
|
+
|
|
1126
|
+
// A missing binary surfaces as an async `error` event (an async spawn
|
|
1127
|
+
// never throws it) — no verdict, so it rejects.
|
|
1128
|
+
child.once("error", (err) => settle(err));
|
|
1129
|
+
|
|
1130
|
+
const settleFromExit = (
|
|
1131
|
+
code: number | null,
|
|
1132
|
+
signal: NodeJS.Signals | null,
|
|
1133
|
+
): void => {
|
|
1134
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
1135
|
+
if (code === null) {
|
|
1136
|
+
// A signal nobody here sent (an operator's `kill`, the OOM killer).
|
|
1137
|
+
// The held test produced no verdict, so this is not a `failed` gate.
|
|
1138
|
+
settle(new Error(`the held test was terminated by signal ${signal}`));
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
settle(null, {
|
|
1142
|
+
exitCode: code,
|
|
1143
|
+
output: finalOutput(),
|
|
1144
|
+
verdict: finalVerdict(),
|
|
1145
|
+
// Captured at `exit`, never re-read here: `settleFromExit` runs once
|
|
1146
|
+
// the pipes drain, up to ORACLE_DRAIN_GRACE_MS later, and re-reading
|
|
1147
|
+
// then would reopen the very window the at-exit capture closes.
|
|
1148
|
+
report,
|
|
420
1149
|
});
|
|
1150
|
+
};
|
|
421
1151
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1152
|
+
child.once("exit", (code, signal) => {
|
|
1153
|
+
if (killing) return; // the timeout path owns this outcome
|
|
1154
|
+
// FIRST, before anything that yields: the runner has written its report
|
|
1155
|
+
// and exited, so this is the moment the file is both complete and not yet
|
|
1156
|
+
// reachable by a straggler. See `captureReport`.
|
|
1157
|
+
captureReport();
|
|
1158
|
+
// The timeout governs the RUN, which is over. Disarm it so a slow pipe
|
|
1159
|
+
// drain cannot turn a finished run into a reported timeout.
|
|
1160
|
+
if (timer) clearTimeout(timer);
|
|
1161
|
+
// Reap before waiting on the pipes: a backgrounded grandchild inherits
|
|
1162
|
+
// the stdout pipe, so `close` cannot fire while it lives.
|
|
1163
|
+
reapGroup(pgid);
|
|
1164
|
+
drainTimer = setTimeout(
|
|
1165
|
+
() => settleFromExit(code, signal),
|
|
1166
|
+
ORACLE_DRAIN_GRACE_MS,
|
|
1167
|
+
);
|
|
1168
|
+
child.once("close", () => settleFromExit(code, signal));
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
timer = setTimeout(() => {
|
|
1172
|
+
if (settled) return;
|
|
1173
|
+
killing = true;
|
|
1174
|
+
terminateGroup(child, {
|
|
1175
|
+
sigintTimeoutMs: ORACLE_SIGINT_GRACE_MS,
|
|
1176
|
+
sigtermTimeoutMs: ORACLE_SIGTERM_GRACE_MS,
|
|
1177
|
+
})
|
|
1178
|
+
.catch(() => {
|
|
1179
|
+
// terminateGroup swallows its own signal errors; guard anyway so a
|
|
1180
|
+
// rejection can never strand the promise unsettled.
|
|
428
1181
|
})
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
);
|
|
437
|
-
});
|
|
438
|
-
}, timeoutMs);
|
|
439
|
-
},
|
|
440
|
-
);
|
|
1182
|
+
.then(() => {
|
|
1183
|
+
settle(
|
|
1184
|
+
new Error(`the held test did not finish within ${timeoutMs}ms`),
|
|
1185
|
+
);
|
|
1186
|
+
});
|
|
1187
|
+
}, timeoutMs);
|
|
1188
|
+
});
|
|
441
1189
|
}
|