@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/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 { lstatSync, readFileSync, statSync } from "node:fs";
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 { DEFAULT_METRIC_TIMEOUT_MS } from "./exec-types.js";
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 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`.
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 {@link DEFAULT_METRIC_TIMEOUT_MS}
773
- * rather than a second convention; the parameter exists so a test can use a
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
- timeoutMs: number = DEFAULT_METRIC_TIMEOUT_MS,
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: 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.
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(spec, repoPath, oracle, reportPath, timeoutMs);
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, so the check below is one shape of an open class.
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. 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.
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
- reportPath: string,
927
- timeoutMs: number,
928
- ): Promise<{
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 { command, args: baseArgs } = spec.argv(argvPath(oracle.path));
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
- const args = [...baseArgs, ...spec.report.flags(reportPath)];
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, or the file shows
1026
- * signs of tampering. Never a pass.
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
- report = spec.report.parse(readFileSync(reportPath, "utf8"));
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
  );