@gethmy/harness 1.6.0 → 1.7.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 +376 -219
- package/dist/index.js +293 -146
- package/package.json +2 -2
- package/src/cli.ts +34 -2
- package/src/exec-types.ts +33 -4
- package/src/git-pr.ts +53 -5
- package/src/oracle-collector.ts +38 -8
- package/src/oracle.ts +464 -53
- package/src/repair-sandbox.test.ts +166 -1
- package/src/repair-sandbox.ts +203 -8
- package/src/run-containment.ts +191 -37
- package/src/stage-cli.ts +32 -8
- package/src/verification.ts +200 -21
- package/src/worktree.ts +19 -2
package/src/oracle.ts
CHANGED
|
@@ -64,7 +64,8 @@
|
|
|
64
64
|
* Failing the gate rather than writing is the point.
|
|
65
65
|
*/
|
|
66
66
|
import type { ChildProcess } from "node:child_process";
|
|
67
|
-
import {
|
|
67
|
+
import { randomUUID } from "node:crypto";
|
|
68
|
+
import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
68
69
|
import {
|
|
69
70
|
chmod,
|
|
70
71
|
lstat,
|
|
@@ -77,9 +78,18 @@ import {
|
|
|
77
78
|
import { tmpdir } from "node:os";
|
|
78
79
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
79
80
|
import { StringDecoder } from "node:string_decoder";
|
|
80
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
DEFAULT_METRIC_TIMEOUT_MS,
|
|
83
|
+
SANDBOX_STARTUP_GRACE_MS,
|
|
84
|
+
} from "./exec-types.js";
|
|
81
85
|
import { log } from "./log.js";
|
|
82
86
|
import { reapGroup, spawnInGroup, terminateGroup } from "./process-group.js";
|
|
87
|
+
import {
|
|
88
|
+
removeSandboxContainer,
|
|
89
|
+
sandboxAvailable,
|
|
90
|
+
sandboxRunArgs,
|
|
91
|
+
} from "./repair-sandbox.js";
|
|
92
|
+
import { heldTestEnvKeysToStrip } from "./run-containment.js";
|
|
83
93
|
|
|
84
94
|
const TAG = "oracle";
|
|
85
95
|
|
|
@@ -96,8 +106,67 @@ export interface HeldOracle {
|
|
|
96
106
|
runnerHint: string | null;
|
|
97
107
|
}
|
|
98
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The container the held test runs in, or `undefined` for the host (#1021).
|
|
111
|
+
*
|
|
112
|
+
* Same shape and same config value as `VerificationSandbox` — one
|
|
113
|
+
* `verification.sandboxImage` covers every command the gate machinery runs
|
|
114
|
+
* against a worktree, because they all need exactly the same thing from the
|
|
115
|
+
* image: the repo's toolchain and nothing else. A second knob would let an
|
|
116
|
+
* operator contain the build and leave the held test on the host, which is the
|
|
117
|
+
* combination with the worst reasoning behind it.
|
|
118
|
+
*
|
|
119
|
+
* A distinct type rather than an import of `VerificationSandbox` would be two
|
|
120
|
+
* names for one thing; a shared one is used instead.
|
|
121
|
+
*/
|
|
122
|
+
export type HeldOracleSandbox = { image: string };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The held test could not be RUN AS CONFIGURED — the container runtime is not
|
|
126
|
+
* answering, or the image cannot start / does not carry the runner (#1021).
|
|
127
|
+
*
|
|
128
|
+
* A distinct class because the collector has to mark it (#823 rule 1): this is
|
|
129
|
+
* static operator configuration, so re-running the stage cannot change the
|
|
130
|
+
* answer, and an unmarked `blocked` spends the card's whole attempt budget
|
|
131
|
+
* rediscovering it — which is exactly the failure #823 exists to prevent, and
|
|
132
|
+
* which containment would otherwise have introduced a fresh instance of.
|
|
133
|
+
*
|
|
134
|
+
* "Docker is not running" is static in the only sense that matters here: no
|
|
135
|
+
* number of retries fixes it, a person does.
|
|
136
|
+
*/
|
|
137
|
+
export class OracleSandboxConfigError extends Error {
|
|
138
|
+
constructor(message: string) {
|
|
139
|
+
super(message);
|
|
140
|
+
this.name = "OracleSandboxConfigError";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Options for {@link runHeldOracle}. */
|
|
145
|
+
export interface RunHeldOracleOptions {
|
|
146
|
+
/** Wall-clock cap. Defaults to {@link DEFAULT_METRIC_TIMEOUT_MS}. */
|
|
147
|
+
timeoutMs?: number;
|
|
148
|
+
/**
|
|
149
|
+
* Run the held test in this container instead of in the motor's own process
|
|
150
|
+
* tree (#1021). `undefined` ⇒ the host, which is the default.
|
|
151
|
+
*/
|
|
152
|
+
sandbox?: HeldOracleSandbox;
|
|
153
|
+
}
|
|
154
|
+
|
|
99
155
|
export interface OracleDeps {
|
|
100
156
|
repoPath: string;
|
|
157
|
+
/**
|
|
158
|
+
* The container the held test runs in (#1021), threaded from the driver's
|
|
159
|
+
* `--sandbox-image`. Absent ⇒ the host path, which is what a person running
|
|
160
|
+
* the CLI against their own checkout gets.
|
|
161
|
+
*
|
|
162
|
+
* It lives on the DEPS rather than being bound into `run` at the call site,
|
|
163
|
+
* because a bound closure is a wiring that only a source scan can check. Here
|
|
164
|
+
* the collector reads `this.deps.sandbox` and passes it, so a test that
|
|
165
|
+
* injects a fake `run` sees whether the image actually arrived — the
|
|
166
|
+
* distinction between a parameter existing and a parameter being passed,
|
|
167
|
+
* which this repo has already shipped wrong once (#1036).
|
|
168
|
+
*/
|
|
169
|
+
sandbox?: HeldOracleSandbox;
|
|
101
170
|
/**
|
|
102
171
|
* The card's ACTIVE agent session id — see the module doc comment above for
|
|
103
172
|
* why this lives here rather than on `GateEvidenceContext`. Threaded straight
|
|
@@ -139,6 +208,7 @@ export interface OracleDeps {
|
|
|
139
208
|
run(
|
|
140
209
|
repoPath: string,
|
|
141
210
|
oracle: HeldOracle,
|
|
211
|
+
options?: RunHeldOracleOptions,
|
|
142
212
|
): Promise<{
|
|
143
213
|
exitCode: number;
|
|
144
214
|
/** stdout + stderr merged, for the operator's log. Never the verdict. */
|
|
@@ -429,27 +499,69 @@ const ORACLE_RUNNERS: Record<string, OracleRunnerSpec> = {
|
|
|
429
499
|
* `reproduced` red gate AND a passing green gate from one file.
|
|
430
500
|
*
|
|
431
501
|
* {@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
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
502
|
+
* it stick change a mode the motor set. It does NOT close the class, and it is
|
|
503
|
+
* not a boundary: `statSync` follows symlinks, so REPLACING the directory
|
|
504
|
+
* (rmdir + mkdir 0700, or a symlink) leaves every mode the motor set intact, and
|
|
505
|
+
* a mode change is restorable by a grandchild that waits for the runner to exit
|
|
506
|
+
* and chmods back.
|
|
507
|
+
*
|
|
508
|
+
* ---
|
|
509
|
+
*
|
|
510
|
+
* WHAT #1021 CHANGED, and it is two separate things because the two attacks are
|
|
511
|
+
* not one attack.
|
|
512
|
+
*
|
|
513
|
+
* **1. A report the exit code contradicts is refused** ({@link
|
|
514
|
+
* assertConsistentWithExit}). Measured against both allow-listed runners: a run
|
|
515
|
+
* in which the runner reports a failing test exits NON-ZERO. So `failed > 0`
|
|
516
|
+
* from a process that exited 0 is a state neither runner produces, whoever wrote
|
|
517
|
+
* the file and whenever they wrote it — which kills the `process.exit(0)`
|
|
518
|
+
* pre-write and the sprayer with one rule, on the host as well as in the
|
|
519
|
+
* container, needing no configuration at all. `oracle-run.test.ts` runs both as
|
|
520
|
+
* real hostile held tests.
|
|
521
|
+
*
|
|
522
|
+
* This is a CLAIM check and deliberately not an authentication check, because
|
|
523
|
+
* authentication is unavailable here in principle: the held test executes INSIDE
|
|
524
|
+
* the runner's process, so any secret the motor handed the runner to sign its
|
|
525
|
+
* report with would be readable by the held test — a hash, an HMAC or a nonce
|
|
526
|
+
* buys nothing.
|
|
527
|
+
*
|
|
528
|
+
* What it costs the attacker, exactly: it does NOT make the report authentic. A
|
|
529
|
+
* held test can still pre-write `failed = 1` and exit non-zero, which is
|
|
530
|
+
* self-consistent. What it removes is the FREE version — one file satisfying the
|
|
531
|
+
* red gate (`reproduced`) and the green gate (exit 0) out of a single run. After
|
|
532
|
+
* it, a held test that wants both polarities must exit differently before and
|
|
533
|
+
* after the fix, i.e. be sensitive to the tree, which is the whole content of
|
|
534
|
+
* the red-green contract.
|
|
535
|
+
*
|
|
536
|
+
* **2. The run can be moved off the motor's host** (`spawnHeldOracle`, opt-in
|
|
537
|
+
* behind `verification.sandboxImage`). Both polarities then run inside
|
|
538
|
+
* `repair-sandbox.ts`'s container: the worktree and the report directory are the
|
|
539
|
+
* only mounts, and there is no network, no host home, no credential file and no
|
|
540
|
+
* Docker socket. #988 could not use that container for the agent spawn because
|
|
541
|
+
* an agent needs the model API and the first flag is `--network=none`; the held
|
|
542
|
+
* test needs no network, which is exactly what makes it fit here. It also ends
|
|
543
|
+
* the straggler class by construction: a `setsid` grandchild escapes the motor's
|
|
544
|
+
* process group on the host and can rewrite the report at any later moment,
|
|
545
|
+
* while inside the container it is killed with the PID namespace before
|
|
546
|
+
* `docker run` returns.
|
|
547
|
+
*
|
|
548
|
+
* The container does NOT make the report file unforgeable, and nothing here
|
|
549
|
+
* should be read as saying so — the held test holds every capability the runner
|
|
550
|
+
* holds inside it too. Containment relocates the shared channel; it does not
|
|
551
|
+
* unshare it. That is what (1) is for.
|
|
552
|
+
*
|
|
553
|
+
* MEASURED COST, macOS / Docker Desktop 29.7.2, warm `oven/bun:1`, the real
|
|
554
|
+
* worktree bind-mounted: a trivial held test 0.01 s on the host and 0.14–0.18 s
|
|
555
|
+
* contained (~0.14 s added); one that resolves a dependency with the repo's real
|
|
556
|
+
* `node_modules` mounted, 0.05–0.08 s against 1.37–1.89 s (~1.4 s added). Two
|
|
557
|
+
* gate evaluations per card, so ~0.3 s to ~3 s per card, scaling with what the
|
|
558
|
+
* held test's imports touch over the virtualized filesystem.
|
|
559
|
+
*
|
|
560
|
+
* So the accurate summary, and what the docs say: the gate now claims "the
|
|
561
|
+
* authored test ran, and the runner's own report said it failed, on a run whose
|
|
562
|
+
* exit code agrees". A test that fakes tree-sensitivity by reading the diff
|
|
563
|
+
* rather than the behaviour is a bad test, and no gate mechanism grades test
|
|
564
|
+
* quality. See `docs/agent-daemon.md`.
|
|
453
565
|
*/
|
|
454
566
|
|
|
455
567
|
/**
|
|
@@ -769,20 +881,38 @@ function argvPath(path: string): string {
|
|
|
769
881
|
* `reapGroup` when the promise settles — clean exit included.
|
|
770
882
|
* 3. **Non-blocking.** Nothing here occupies the event loop.
|
|
771
883
|
*
|
|
772
|
-
* `timeoutMs` defaults to the motor's existing
|
|
773
|
-
* rather than a second convention; the
|
|
774
|
-
* short cap.
|
|
884
|
+
* `options.timeoutMs` defaults to the motor's existing
|
|
885
|
+
* {@link DEFAULT_METRIC_TIMEOUT_MS} rather than a second convention; the option
|
|
886
|
+
* exists so a test can use a short cap. `options.sandbox` moves the whole run
|
|
887
|
+
* into a container (#1021) — see {@link spawnHeldOracle}.
|
|
775
888
|
*/
|
|
776
889
|
export async function runHeldOracle(
|
|
777
890
|
repoPath: string,
|
|
778
891
|
oracle: HeldOracle,
|
|
779
|
-
|
|
892
|
+
options: RunHeldOracleOptions = {},
|
|
780
893
|
): Promise<{
|
|
781
894
|
exitCode: number;
|
|
782
895
|
output: string;
|
|
783
896
|
verdict: string;
|
|
784
897
|
report: OracleRunSummary | null;
|
|
785
898
|
}> {
|
|
899
|
+
const heldTestTimeoutMs = options.timeoutMs ?? DEFAULT_METRIC_TIMEOUT_MS;
|
|
900
|
+
// Refuse BEFORE the temp directory, for the same reason the runner is
|
|
901
|
+
// resolved first: an operator who configured an image asked for the held test
|
|
902
|
+
// to run without their credentials in scope, and running it on the host
|
|
903
|
+
// because Docker happened to be down would hand back exactly the exposure the
|
|
904
|
+
// image was set to remove — invisibly. `execStep` (verification.ts) refuses
|
|
905
|
+
// the same way and says so in the same words; the two must not diverge.
|
|
906
|
+
//
|
|
907
|
+
// A rejection is `blocked` at the collector, never `failed`: the branch is
|
|
908
|
+
// not at fault for the operator's container runtime.
|
|
909
|
+
const sandbox = options.sandbox?.image.trim() ? options.sandbox : undefined;
|
|
910
|
+
if (sandbox && !(await sandboxAvailable())) {
|
|
911
|
+
throw new OracleSandboxConfigError(
|
|
912
|
+
`the held test is configured to run in "${sandbox.image}" but no container runtime answered — ` +
|
|
913
|
+
"start Docker or unset verification.sandboxImage to run it on the host",
|
|
914
|
+
);
|
|
915
|
+
}
|
|
786
916
|
// Resolve the runner BEFORE creating anything: an unknown hint must reject
|
|
787
917
|
// without leaving a temp directory behind (and without spawning, which the
|
|
788
918
|
// "rejects an unknown hint before it spawns anything" test pins).
|
|
@@ -795,16 +925,34 @@ export async function runHeldOracle(
|
|
|
795
925
|
// all.
|
|
796
926
|
//
|
|
797
927
|
// `mkdtemp` randomizes the SUFFIX and creates the directory 0700. Neither
|
|
798
|
-
// hides the path from the held test
|
|
799
|
-
// `readdirSync(tmpdir())` enumerates the
|
|
800
|
-
// (the held test runs as the motor's own
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
// the
|
|
928
|
+
// hides the path from the held test, on the host or in the container: on the
|
|
929
|
+
// host the prefix is a fixed string and `readdirSync(tmpdir())` enumerates the
|
|
930
|
+
// directory in one call, 0700 included (the held test runs as the motor's own
|
|
931
|
+
// user, who owns it); in the container the path is a fixed mount point AND is
|
|
932
|
+
// spelled out in the runner's own argv, which the held test can simply read.
|
|
933
|
+
// This was measured, not assumed. So the randomness is not a secret and
|
|
934
|
+
// nothing here should be read as one — see `assertConsistentWithExit` for what
|
|
935
|
+
// actually defends the VERDICT, and the comment above `OracleRunnerSpec` for
|
|
936
|
+
// what remains open.
|
|
804
937
|
const reportDir = await mkdtemp(join(tmpdir(), reportDirPrefix()));
|
|
805
938
|
const reportPath = join(reportDir, spec.report.file);
|
|
806
939
|
try {
|
|
807
|
-
return await spawnHeldOracle(
|
|
940
|
+
return await spawnHeldOracle({
|
|
941
|
+
spec,
|
|
942
|
+
repoPath,
|
|
943
|
+
oracle,
|
|
944
|
+
reportPath,
|
|
945
|
+
// The HELD TEST'S own cap, plus what the container costs to start — the
|
|
946
|
+
// same shape `execStep` uses, and for the same reason: the cap an
|
|
947
|
+
// operator (or a test) chose governs the test, and turning containment on
|
|
948
|
+
// must not silently redefine it. Measured at ~5.4 s of start-up on macOS
|
|
949
|
+
// / Docker Desktop with a warm image; the grace is that with an order of
|
|
950
|
+
// magnitude of head-room, not a budget.
|
|
951
|
+
timeoutMs: sandbox
|
|
952
|
+
? heldTestTimeoutMs + SANDBOX_STARTUP_GRACE_MS
|
|
953
|
+
: heldTestTimeoutMs,
|
|
954
|
+
sandbox,
|
|
955
|
+
});
|
|
808
956
|
} finally {
|
|
809
957
|
await removeReportDir(reportDir);
|
|
810
958
|
}
|
|
@@ -844,16 +992,18 @@ function reportDirPrefix(): string {
|
|
|
844
992
|
* `process.exit(0)` in the held test skips the reporter's write entirely and
|
|
845
993
|
* leaves the forgery as the only file on disk, at ordinary modes inside a
|
|
846
994
|
* still-0700 directory. Measured under both runners. This is CHEAPER than
|
|
847
|
-
* the attack above,
|
|
995
|
+
* the attack above, and it is what {@link assertConsistentWithExit} closes
|
|
996
|
+
* (#1021) — by reading the report's CLAIM against the exit code rather than
|
|
997
|
+
* by looking at the file at all.
|
|
848
998
|
* - **Replacing the directory.** `statSync` follows symlinks, so an rmdir +
|
|
849
999
|
* mkdir 0700, or a symlink to the attacker's own directory, changes no mode
|
|
850
1000
|
* the motor set.
|
|
851
1001
|
* - **Restoring the mode.** A grandchild that waits for the runner to exit and
|
|
852
1002
|
* chmods back defeats the file-mode arm.
|
|
853
1003
|
*
|
|
854
|
-
* Mode checks raise the cost of one attack; they are not a boundary.
|
|
855
|
-
*
|
|
856
|
-
*
|
|
1004
|
+
* Mode checks raise the cost of one attack; they are not a boundary. See the
|
|
1005
|
+
* comment above `OracleRunnerSpec` for what is, and for why the container this
|
|
1006
|
+
* can now run in does not make the FILE unforgeable either.
|
|
857
1007
|
*
|
|
858
1008
|
* The exact-0700 requirement is deliberate but is a false-positive risk worth
|
|
859
1009
|
* knowing about: `mkdtemp` asks for 0700 through `mkdir`, so an exotic umask
|
|
@@ -862,6 +1012,13 @@ function reportDirPrefix(): string {
|
|
|
862
1012
|
* runners were measured writing the report 0644 inside a 0700 directory under a
|
|
863
1013
|
* normal umask. `captureReport` logs the refusal reason so that case is
|
|
864
1014
|
* diagnosable rather than silent.
|
|
1015
|
+
*
|
|
1016
|
+
* One more caveat since #1021: on the CONTAINER path the report directory is a
|
|
1017
|
+
* bind mount, and a `chmod` from inside the container is not guaranteed to
|
|
1018
|
+
* reach the host inode on a virtualized filesystem (Docker Desktop's). So this
|
|
1019
|
+
* check may simply see nothing there. That is a reason not to lean on it, not a
|
|
1020
|
+
* regression — `assertConsistentWithExit` is the one that does not depend on
|
|
1021
|
+
* how the filesystem is plumbed.
|
|
865
1022
|
*/
|
|
866
1023
|
function assertUntampered(reportPath: string): void {
|
|
867
1024
|
const dir = statSync(dirname(reportPath));
|
|
@@ -884,6 +1041,66 @@ function assertUntampered(reportPath: string): void {
|
|
|
884
1041
|
}
|
|
885
1042
|
}
|
|
886
1043
|
|
|
1044
|
+
/**
|
|
1045
|
+
* Refuse a report whose CLAIM the run's own exit code contradicts (#1021).
|
|
1046
|
+
*
|
|
1047
|
+
* ## Why a claim check and not an authentication check
|
|
1048
|
+
*
|
|
1049
|
+
* The held test executes INSIDE the runner's process — `bun test` and `vitest`
|
|
1050
|
+
* both load the file into themselves — so it holds every capability the runner
|
|
1051
|
+
* holds: the same uid, the same open files, the same argv, the same memory.
|
|
1052
|
+
* There is therefore no secret the motor can hand the runner that the held test
|
|
1053
|
+
* cannot read back, which is why a hash, an HMAC or a nonce over the report
|
|
1054
|
+
* file buys nothing here: whatever would sign the real report signs a forged one
|
|
1055
|
+
* just as happily. A container does not change that either — it relocates the
|
|
1056
|
+
* shared channel, it does not unshare it.
|
|
1057
|
+
*
|
|
1058
|
+
* What the motor CAN check is agreement between two things the held test
|
|
1059
|
+
* controls but cannot make agree in its own favour:
|
|
1060
|
+
*
|
|
1061
|
+
* - the report's claim that N tests failed, and
|
|
1062
|
+
* - the exit code the process actually left with, which the OS reports and
|
|
1063
|
+
* nothing in the run gets to rewrite afterwards.
|
|
1064
|
+
*
|
|
1065
|
+
* Measured against both allow-listed runners: a run in which the runner
|
|
1066
|
+
* reports a failing test exits NON-ZERO. So `failed > 0` from a process that
|
|
1067
|
+
* exited 0 is a state neither runner produces, and the only way to reach it is
|
|
1068
|
+
* for something other than the runner to have written the file. The cheap
|
|
1069
|
+
* forgery — pre-write the report, then `process.exit(0)` so the reporter never
|
|
1070
|
+
* runs — lands exactly there, and so does the sprayer that overwrites the real
|
|
1071
|
+
* report of a green run.
|
|
1072
|
+
*
|
|
1073
|
+
* ## What it costs the attacker, stated exactly
|
|
1074
|
+
*
|
|
1075
|
+
* It does NOT make the report authentic. A held test can still pre-write
|
|
1076
|
+
* `failed = 1` and exit NON-ZERO, and that pair is self-consistent. What it
|
|
1077
|
+
* loses is the free version: one file that satisfies the red gate (`reproduced`)
|
|
1078
|
+
* and the green gate (exit 0) out of a single run. After this, a held test that
|
|
1079
|
+
* wants both polarities must exit differently before and after the fix — i.e.
|
|
1080
|
+
* it must be sensitive to the tree, which is the whole content of the red-green
|
|
1081
|
+
* contract. A test that fakes that sensitivity (by reading the diff rather than
|
|
1082
|
+
* the behaviour) is a bad test, and no gate mechanism can grade test quality.
|
|
1083
|
+
*
|
|
1084
|
+
* ## Why the safe direction is a refusal
|
|
1085
|
+
*
|
|
1086
|
+
* `null` ⇒ `no_verdict` ⇒ `blocked`, which can never satisfy a gate. If a
|
|
1087
|
+
* future runner ever legitimately exits 0 with a failing test in its report
|
|
1088
|
+
* (none does today), this refuses a run it should have graded — a cost, never
|
|
1089
|
+
* an over-grant. `oracle_passed` reads the exit code alone and is unaffected.
|
|
1090
|
+
*/
|
|
1091
|
+
function assertConsistentWithExit(
|
|
1092
|
+
summary: OracleRunSummary | null,
|
|
1093
|
+
exitCode: number | null,
|
|
1094
|
+
): void {
|
|
1095
|
+
if (!summary) return;
|
|
1096
|
+
if (summary.failed > 0 && exitCode === 0) {
|
|
1097
|
+
throw new Error(
|
|
1098
|
+
`the oracle report claims ${summary.failed} of ${summary.total} test(s) failed, but the runner exited 0 — ` +
|
|
1099
|
+
"neither allow-listed runner produces that pair, so the report is not the runner's; refusing it",
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
887
1104
|
/**
|
|
888
1105
|
* Delete the report directory. Attempted on EVERY path — completion, rejection,
|
|
889
1106
|
* timeout — because the report is secret-bearing for one of the two runners:
|
|
@@ -914,24 +1131,149 @@ async function removeReportDir(reportDir: string): Promise<void> {
|
|
|
914
1131
|
}
|
|
915
1132
|
}
|
|
916
1133
|
|
|
1134
|
+
/**
|
|
1135
|
+
* Where the report directory is mounted inside the sandbox (#1021).
|
|
1136
|
+
*
|
|
1137
|
+
* A fixed path, and deliberately not under `/tmp`: the container's `HOME` is
|
|
1138
|
+
* `/tmp` (see `sandboxRunArgs`), and a report living under a directory the
|
|
1139
|
+
* runner's own toolchain writes into is one more thing that can collide.
|
|
1140
|
+
*
|
|
1141
|
+
* It is NOT a secret, and nothing here should be read as treating it as one —
|
|
1142
|
+
* the path is spelled out in the runner's argv, which the held test can read
|
|
1143
|
+
* from `process.argv` without looking at the filesystem at all.
|
|
1144
|
+
*/
|
|
1145
|
+
const ORACLE_REPORT_MOUNT = "/harmony-oracle-report";
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* `docker run`'s own failure codes, as opposed to the command's.
|
|
1149
|
+
*
|
|
1150
|
+
* 125 is docker refusing to start a container (unknown image, bad flag); 126
|
|
1151
|
+
* and 127 are the container starting and the ENTRYPOINT being unusable or
|
|
1152
|
+
* absent — which for us means the image does not carry `bun` / `npx`. All three
|
|
1153
|
+
* are the operator's container setup, not the branch's code, so they REJECT
|
|
1154
|
+
* (⇒ `blocked`) rather than returning an exit code the green gate would read as
|
|
1155
|
+
* `failed` and charge to the implementer.
|
|
1156
|
+
*
|
|
1157
|
+
* A held test can reach these codes deliberately with `process.exit(125)`, and
|
|
1158
|
+
* that is fine: it converts its own run into `blocked`, which can never satisfy
|
|
1159
|
+
* either gate. Trading a `failed` for a `blocked` is the safe direction.
|
|
1160
|
+
*/
|
|
1161
|
+
const DOCKER_SELF_FAILURE_CODES = new Set([125, 126, 127]);
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* The `docker run` argv for a held-test run, as a PURE function so the two
|
|
1165
|
+
* things a test has to be able to see are visible without a container:
|
|
1166
|
+
*
|
|
1167
|
+
* 1. the report directory is mounted, and
|
|
1168
|
+
* 2. the runner is told to write to the MOUNT POINT, not to the host path,
|
|
1169
|
+
* which does not exist inside the container and would silently produce
|
|
1170
|
+
* "the runner wrote no report" ⇒ a permanently blocked gate.
|
|
1171
|
+
*
|
|
1172
|
+
* The flag set itself is `sandboxRunArgs`' and is tested there — this adds a
|
|
1173
|
+
* mount and nothing else. Paths must already be realpath'd by the caller: on
|
|
1174
|
+
* macOS `os.tmpdir()` is reached through a symlink, and Docker resolves mount
|
|
1175
|
+
* sources rather than following them.
|
|
1176
|
+
*/
|
|
1177
|
+
export function heldOracleSandboxArgv(args: {
|
|
1178
|
+
image: string;
|
|
1179
|
+
/** Realpath'd worktree. */
|
|
1180
|
+
worktree: string;
|
|
1181
|
+
/** Realpath'd host directory holding the report. */
|
|
1182
|
+
reportDir: string;
|
|
1183
|
+
/** The report's file name inside that directory. */
|
|
1184
|
+
reportFile: string;
|
|
1185
|
+
/** The runner's own command and args, WITHOUT the report flags. */
|
|
1186
|
+
runner: OracleRunnerArgv;
|
|
1187
|
+
/** Extra argv the runner needs to write its report, given a path. */
|
|
1188
|
+
reportFlags: (reportPath: string) => string[];
|
|
1189
|
+
containerName?: string;
|
|
1190
|
+
}): { command: string; args: string[]; reportPathInContainer: string } {
|
|
1191
|
+
const reportPathInContainer = join(ORACLE_REPORT_MOUNT, args.reportFile);
|
|
1192
|
+
return {
|
|
1193
|
+
command: "docker",
|
|
1194
|
+
args: sandboxRunArgs(
|
|
1195
|
+
args.image,
|
|
1196
|
+
args.worktree,
|
|
1197
|
+
{
|
|
1198
|
+
cmd: args.runner.command,
|
|
1199
|
+
args: [...args.runner.args, ...args.reportFlags(reportPathInContainer)],
|
|
1200
|
+
},
|
|
1201
|
+
args.containerName,
|
|
1202
|
+
[{ host: args.reportDir, container: ORACLE_REPORT_MOUNT }],
|
|
1203
|
+
),
|
|
1204
|
+
reportPathInContainer,
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
917
1208
|
/**
|
|
918
1209
|
* The supervised spawn half of {@link runHeldOracle}, split out so the report
|
|
919
1210
|
* directory's lifetime is a plain `try`/`finally` in one place rather than a
|
|
920
1211
|
* cleanup duplicated down every settle path.
|
|
1212
|
+
*
|
|
1213
|
+
* ## The container (#1021), and what it is and is not for
|
|
1214
|
+
*
|
|
1215
|
+
* With `sandbox` set, the argv becomes `docker run …` around the same runner
|
|
1216
|
+
* argv, built by `repair-sandbox.ts`'s `sandboxRunArgs` so the flag set is the
|
|
1217
|
+
* one that module documents and tests — `--network=none`, `--cap-drop=ALL`,
|
|
1218
|
+
* `--security-opt=no-new-privileges`, memory and pid caps, no Docker socket, no
|
|
1219
|
+
* host home. #988 could not use that container for the agent spawn because the
|
|
1220
|
+
* agent needs the model API and the first flag is `--network=none`. The held
|
|
1221
|
+
* test needs no network at all, which is exactly what makes the same container
|
|
1222
|
+
* fit here.
|
|
1223
|
+
*
|
|
1224
|
+
* Everything else about this function is unchanged, and that is the point of
|
|
1225
|
+
* doing it this way rather than through `runInSandbox`: the group leadership,
|
|
1226
|
+
* the timeout escalation, the drain window, the per-runner stream split and the
|
|
1227
|
+
* at-exit report read all still apply, because only `command` and `args`
|
|
1228
|
+
* differ. `runInSandbox` returns `{ passed, output }`, which drops the exit code
|
|
1229
|
+
* the green gate grades and merges the two streams the operator's log keeps
|
|
1230
|
+
* apart.
|
|
1231
|
+
*
|
|
1232
|
+
* What the container buys, stated exactly so it is not read as more:
|
|
1233
|
+
*
|
|
1234
|
+
* - The held test can no longer reach the MOTOR. No credential file, no other
|
|
1235
|
+
* worktree, no `~/.ssh`, no daemon process — the mounts are the worktree and
|
|
1236
|
+
* the report directory, and nothing else exists.
|
|
1237
|
+
* - No straggler survives it. A detached grandchild `setsid`s out of the
|
|
1238
|
+
* motor's process group on the host and can rewrite the report at any later
|
|
1239
|
+
* moment; inside the container it is in a PID namespace that the kernel
|
|
1240
|
+
* tears down when PID 1 exits, and `docker run` does not return until that
|
|
1241
|
+
* has happened. The spray window closes by construction rather than by
|
|
1242
|
+
* timing.
|
|
1243
|
+
* - It does NOT make the report file unforgeable. The held test runs inside
|
|
1244
|
+
* the runner process, so it can write the report in the container exactly as
|
|
1245
|
+
* it could on the host. {@link assertConsistentWithExit} is what answers
|
|
1246
|
+
* that, on both paths.
|
|
1247
|
+
*
|
|
1248
|
+
* TWO MOUNTS, both read-write, and both earn it: the worktree because a runner
|
|
1249
|
+
* writes caches and the held test lives there, and the report directory because
|
|
1250
|
+
* the runner writes the report and the motor reads it from outside. The
|
|
1251
|
+
* container's report path is the mount point; the motor's is the host directory,
|
|
1252
|
+
* and the two are kept apart in the signature below rather than by convention.
|
|
921
1253
|
*/
|
|
922
|
-
async function spawnHeldOracle(
|
|
923
|
-
spec: OracleRunnerSpec
|
|
924
|
-
repoPath: string
|
|
925
|
-
oracle: HeldOracle
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1254
|
+
async function spawnHeldOracle(args_: {
|
|
1255
|
+
spec: OracleRunnerSpec;
|
|
1256
|
+
repoPath: string;
|
|
1257
|
+
oracle: HeldOracle;
|
|
1258
|
+
/** The report's path ON THE HOST — what the motor stats, reads and cleans up. */
|
|
1259
|
+
reportPath: string;
|
|
1260
|
+
timeoutMs: number;
|
|
1261
|
+
sandbox: HeldOracleSandbox | undefined;
|
|
1262
|
+
}): Promise<{
|
|
929
1263
|
exitCode: number;
|
|
930
1264
|
output: string;
|
|
931
1265
|
verdict: string;
|
|
932
1266
|
report: OracleRunSummary | null;
|
|
933
1267
|
}> {
|
|
934
|
-
const {
|
|
1268
|
+
const { spec, repoPath, oracle, reportPath, timeoutMs, sandbox } = args_;
|
|
1269
|
+
const runner = spec.argv(argvPath(oracle.path));
|
|
1270
|
+
|
|
1271
|
+
// Named so a timed-out container can be torn down: the timeout kills the
|
|
1272
|
+
// `docker` CLI, and `--rm` only cleans up a container that EXITS. Without the
|
|
1273
|
+
// name there is no handle left on a container still running with the
|
|
1274
|
+
// operator's worktree mounted.
|
|
1275
|
+
const containerName = sandbox ? `harmony-oracle-${randomUUID()}` : null;
|
|
1276
|
+
|
|
935
1277
|
// The report flags ride on BOTH gate polarities, not just the red one. #921
|
|
936
1278
|
// kept the two polarities on one argv on purpose, so the security-reviewed
|
|
937
1279
|
// allow-list stays a single shape per runner; making the report conditional
|
|
@@ -944,7 +1286,27 @@ async function spawnHeldOracle(
|
|
|
944
1286
|
// problem with the motor's temp dir rather than with the diff. That has not
|
|
945
1287
|
// been seen outside a deliberately broken temp dir, and the alternative (two
|
|
946
1288
|
// argv shapes) was judged worse, but it is the trade being made.
|
|
947
|
-
|
|
1289
|
+
//
|
|
1290
|
+
// `realpathSync` on the mount sources: on macOS `os.tmpdir()` sits under
|
|
1291
|
+
// `/var/folders/...`, reached through the `/var` -> `/private/var` symlink,
|
|
1292
|
+
// and Docker's file sharing resolves mount sources rather than following
|
|
1293
|
+
// them. A symlinked source is the difference between a working mount and an
|
|
1294
|
+
// empty directory in the container, which would read as "the runner wrote no
|
|
1295
|
+
// report" — safe, but a mystery to debug.
|
|
1296
|
+
const { command, args } = sandbox
|
|
1297
|
+
? heldOracleSandboxArgv({
|
|
1298
|
+
image: sandbox.image,
|
|
1299
|
+
worktree: realpathSync(repoPath),
|
|
1300
|
+
reportDir: realpathSync(dirname(reportPath)),
|
|
1301
|
+
reportFile: spec.report.file,
|
|
1302
|
+
runner,
|
|
1303
|
+
reportFlags: spec.report.flags,
|
|
1304
|
+
containerName: containerName ?? undefined,
|
|
1305
|
+
})
|
|
1306
|
+
: {
|
|
1307
|
+
command: runner.command,
|
|
1308
|
+
args: [...runner.args, ...spec.report.flags(reportPath)],
|
|
1309
|
+
};
|
|
948
1310
|
|
|
949
1311
|
return await new Promise<{
|
|
950
1312
|
exitCode: number;
|
|
@@ -957,6 +1319,19 @@ async function spawnHeldOracle(
|
|
|
957
1319
|
child = spawnInGroup(command, args, {
|
|
958
1320
|
cwd: repoPath,
|
|
959
1321
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1322
|
+
// The held test is arbitrary code from an untrusted author, and this
|
|
1323
|
+
// spawn used to pass no `env` at all — so it inherited the daemon's
|
|
1324
|
+
// whole environment (#1021).
|
|
1325
|
+
//
|
|
1326
|
+
// `stripEnvKeys`, NOT a narrowed `env`: `spawnInGroup` merges
|
|
1327
|
+
// `process.env` back on top of whatever `env` it is handed, so an `env`
|
|
1328
|
+
// built by removing keys is INERT there. That is not hypothetical —
|
|
1329
|
+
// `review-worker.ts` shipped exactly that spelling, and its dev server
|
|
1330
|
+
// carried the daemon's credentials for as long as it did.
|
|
1331
|
+
//
|
|
1332
|
+
// On the container path this governs the DOCKER CLI's environment; the
|
|
1333
|
+
// container itself gets only `HOME=/tmp`, per `sandboxRunArgs`.
|
|
1334
|
+
stripEnvKeys: heldTestEnvKeysToStrip(),
|
|
960
1335
|
});
|
|
961
1336
|
} catch (err) {
|
|
962
1337
|
// A synchronous spawn throw (unusable cwd, bad argv) — no group exists.
|
|
@@ -1022,14 +1397,23 @@ async function spawnHeldOracle(
|
|
|
1022
1397
|
* Every failure is `null`, which `gradeOracleRed` maps to `no_verdict` ⇒
|
|
1023
1398
|
* `blocked`: the runner never wrote a report (measured — bun writes none at
|
|
1024
1399
|
* all for an unmatched path or a file with no tests), the content is
|
|
1025
|
-
* malformed, the parser does not recognize the shape,
|
|
1026
|
-
*
|
|
1400
|
+
* malformed, the parser does not recognize the shape, the file shows signs
|
|
1401
|
+
* of tampering, or its CLAIM contradicts the exit code the run left with
|
|
1402
|
+
* (#1021). Never a pass.
|
|
1403
|
+
*
|
|
1404
|
+
* The exit code is the reason this takes a parameter. It is the one channel
|
|
1405
|
+
* the held test cannot rewrite after the fact, so it is what the report is
|
|
1406
|
+
* checked against — see {@link assertConsistentWithExit}.
|
|
1027
1407
|
*/
|
|
1028
1408
|
let report: OracleRunSummary | null = null;
|
|
1029
|
-
const captureReport = (): void => {
|
|
1409
|
+
const captureReport = (exitCode: number | null): void => {
|
|
1030
1410
|
try {
|
|
1031
1411
|
assertUntampered(reportPath);
|
|
1032
|
-
|
|
1412
|
+
const parsed = spec.report.parse(readFileSync(reportPath, "utf8"));
|
|
1413
|
+
// BEFORE the assignment, so a refused report leaves `report` null
|
|
1414
|
+
// rather than briefly holding forged counts.
|
|
1415
|
+
assertConsistentWithExit(parsed, exitCode);
|
|
1416
|
+
report = parsed;
|
|
1033
1417
|
} catch (err) {
|
|
1034
1418
|
report = null;
|
|
1035
1419
|
// Never silent. This is the module's only tamper DETECTOR, and a
|
|
@@ -1138,6 +1522,22 @@ async function spawnHeldOracle(
|
|
|
1138
1522
|
settle(new Error(`the held test was terminated by signal ${signal}`));
|
|
1139
1523
|
return;
|
|
1140
1524
|
}
|
|
1525
|
+
if (sandbox && DOCKER_SELF_FAILURE_CODES.has(code)) {
|
|
1526
|
+
// The container never ran the runner: no image, an unusable flag, or an
|
|
1527
|
+
// image with no `bun` / `npx` on it. That is the operator's setup and
|
|
1528
|
+
// not the branch's code, so it must not reach the green gate as an exit
|
|
1529
|
+
// code — `oracle_passed` reads exit codes alone and would report
|
|
1530
|
+
// `failed`, blaming the implementer and spending an attempt. A
|
|
1531
|
+
// rejection is `blocked` instead, which holds. Same distinction
|
|
1532
|
+
// `runInSandbox`'s `sandboxError` draws for the repair path.
|
|
1533
|
+
settle(
|
|
1534
|
+
new OracleSandboxConfigError(
|
|
1535
|
+
`the held test's sandbox ("${sandbox.image}") exited ${code} without running the runner — ` +
|
|
1536
|
+
"the image is missing, unusable, or does not carry the runner; the motor's local log has docker's own message",
|
|
1537
|
+
),
|
|
1538
|
+
);
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1141
1541
|
settle(null, {
|
|
1142
1542
|
exitCode: code,
|
|
1143
1543
|
output: finalOutput(),
|
|
@@ -1154,7 +1554,7 @@ async function spawnHeldOracle(
|
|
|
1154
1554
|
// FIRST, before anything that yields: the runner has written its report
|
|
1155
1555
|
// and exited, so this is the moment the file is both complete and not yet
|
|
1156
1556
|
// reachable by a straggler. See `captureReport`.
|
|
1157
|
-
captureReport();
|
|
1557
|
+
captureReport(code);
|
|
1158
1558
|
// The timeout governs the RUN, which is over. Disarm it so a slow pipe
|
|
1159
1559
|
// drain cannot turn a finished run into a reported timeout.
|
|
1160
1560
|
if (timer) clearTimeout(timer);
|
|
@@ -1179,7 +1579,18 @@ async function spawnHeldOracle(
|
|
|
1179
1579
|
// terminateGroup swallows its own signal errors; guard anyway so a
|
|
1180
1580
|
// rejection can never strand the promise unsettled.
|
|
1181
1581
|
})
|
|
1182
|
-
.then(() => {
|
|
1582
|
+
.then(async () => {
|
|
1583
|
+
// On the container path what just died is the `docker` CLI, not the
|
|
1584
|
+
// container it launched — `--rm` only reclaims one that EXITS. Left
|
|
1585
|
+
// alone, a timed-out held test keeps running with the operator's
|
|
1586
|
+
// worktree mounted, once per gate evaluation, and the gate retries.
|
|
1587
|
+
// `repair-sandbox.ts` documents the same failure for the repair path;
|
|
1588
|
+
// the teardown is shared rather than re-implemented.
|
|
1589
|
+
//
|
|
1590
|
+
// Awaited before the settle so the removal is not racing the caller's
|
|
1591
|
+
// own cleanup of the report directory, which the container still has
|
|
1592
|
+
// mounted.
|
|
1593
|
+
if (containerName) await removeSandboxContainer(containerName);
|
|
1183
1594
|
settle(
|
|
1184
1595
|
new Error(`the held test did not finish within ${timeoutMs}ms`),
|
|
1185
1596
|
);
|