@gethmy/harness 1.5.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 +497 -216
- package/dist/index.js +431 -254
- package/package.json +2 -2
- package/src/cli.ts +34 -2
- package/src/exec-types.ts +93 -0
- package/src/gate-collectors.ts +30 -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 +194 -20
- package/src/stage-cli.ts +32 -8
- package/src/verification.ts +432 -101
- package/src/worktree.ts +19 -2
package/src/verification.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { type ChildProcess, execFileSync, spawn } from "node:child_process";
|
|
2
2
|
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
SANDBOX_STARTUP_GRACE_MS,
|
|
5
|
+
type VerificationConfig,
|
|
6
|
+
type VerificationSandbox,
|
|
7
|
+
} from "./exec-types.js";
|
|
4
8
|
import { log } from "./log.js";
|
|
5
9
|
import { spawnRunArgs } from "./pm.js";
|
|
6
10
|
import {
|
|
@@ -10,6 +14,14 @@ import {
|
|
|
10
14
|
supportsDevServer,
|
|
11
15
|
testCommand,
|
|
12
16
|
} from "./project-type.js";
|
|
17
|
+
import {
|
|
18
|
+
devServerContainerName,
|
|
19
|
+
devServerSandboxArgs,
|
|
20
|
+
removeSandboxContainer,
|
|
21
|
+
runInSandbox,
|
|
22
|
+
SANDBOX_DEV_SERVER_BIND,
|
|
23
|
+
sandboxAvailable,
|
|
24
|
+
} from "./repair-sandbox.js";
|
|
13
25
|
import { findDeletedTestFiles } from "./revert-guard.js";
|
|
14
26
|
import {
|
|
15
27
|
containedEnv,
|
|
@@ -63,6 +75,13 @@ export async function runVerification(
|
|
|
63
75
|
revertWarnings: [],
|
|
64
76
|
};
|
|
65
77
|
|
|
78
|
+
// Resolved once (#1036). Undefined unless the operator configured an image,
|
|
79
|
+
// in which case the four script-resolved steps run in a container instead of
|
|
80
|
+
// in this process. `runDeepReview` resolves the same value for its dev server
|
|
81
|
+
// (#1037) rather than taking it from here, because it is also called directly
|
|
82
|
+
// by the review path — one key, six commands.
|
|
83
|
+
const sandbox = verificationSandbox(config);
|
|
84
|
+
|
|
66
85
|
if (config.verification.revertGuard) {
|
|
67
86
|
log.info(TAG, `[worker:${workerId}] Checking for reverted merged work...`);
|
|
68
87
|
const deletedTests = findDeletedTestFiles(
|
|
@@ -89,7 +108,11 @@ export async function runVerification(
|
|
|
89
108
|
|
|
90
109
|
if (config.verification.build) {
|
|
91
110
|
log.info(TAG, `[worker:${workerId}] Running build...`);
|
|
92
|
-
result.buildErrors = runBuild(
|
|
111
|
+
result.buildErrors = await runBuild(
|
|
112
|
+
worktreePath,
|
|
113
|
+
config.verification.timeout,
|
|
114
|
+
sandbox,
|
|
115
|
+
);
|
|
93
116
|
if (result.buildErrors.length > 0) {
|
|
94
117
|
log.warn(
|
|
95
118
|
TAG,
|
|
@@ -107,9 +130,10 @@ export async function runVerification(
|
|
|
107
130
|
// still gates the branch.
|
|
108
131
|
if (config.verification.test && result.buildErrors.length === 0) {
|
|
109
132
|
log.info(TAG, `[worker:${workerId}] Running tests...`);
|
|
110
|
-
result.testFailures = runTests(
|
|
133
|
+
result.testFailures = await runTests(
|
|
111
134
|
worktreePath,
|
|
112
135
|
config.verification.testTimeout,
|
|
136
|
+
sandbox,
|
|
113
137
|
);
|
|
114
138
|
if (result.testFailures.length > 0) {
|
|
115
139
|
log.warn(
|
|
@@ -125,7 +149,11 @@ export async function runVerification(
|
|
|
125
149
|
|
|
126
150
|
if (config.verification.lint) {
|
|
127
151
|
log.info(TAG, `[worker:${workerId}] Running lint...`);
|
|
128
|
-
result.lintWarnings = runLint(
|
|
152
|
+
result.lintWarnings = await runLint(
|
|
153
|
+
worktreePath,
|
|
154
|
+
config.verification.timeout,
|
|
155
|
+
sandbox,
|
|
156
|
+
);
|
|
129
157
|
if (result.lintWarnings.length > 0) {
|
|
130
158
|
log.warn(
|
|
131
159
|
TAG,
|
|
@@ -154,46 +182,188 @@ export async function runVerification(
|
|
|
154
182
|
}
|
|
155
183
|
|
|
156
184
|
/**
|
|
157
|
-
* ## The verification steps run the RUN'S OWN scripts
|
|
158
|
-
*
|
|
159
|
-
* `runBuild`, `runTests`, `runFormatFix
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
185
|
+
* ## The verification steps run the RUN'S OWN scripts — contained since #1036
|
|
186
|
+
*
|
|
187
|
+
* `runBuild`, `runTests`, `runFormatFix` and `runLint` resolve their command
|
|
188
|
+
* through `spawnRunArgs` — `bun run <script>` out of the worktree's own
|
|
189
|
+
* `package.json`. The worktree is in `sandbox.filesystem.allowWrite` by design,
|
|
190
|
+
* so a contained implement run can rewrite `"build"` to anything it likes, and
|
|
191
|
+
* before #1036 the daemon then ran it **in its own process**, as the operator,
|
|
192
|
+
* with network. No failure was needed to trigger it; verification runs on every
|
|
193
|
+
* successful run.
|
|
194
|
+
*
|
|
195
|
+
* `env: containedEnv()` (#988) removed the secret-shaped variables and was
|
|
196
|
+
* stated there as a reduction and NOT a fix — the command still read `~/.ssh`
|
|
197
|
+
* and `~/.harmony-mcp/config.json` off disk. **#1036 closes the rest, for these
|
|
198
|
+
* four, when `verification.sandboxImage` is set:** `execStep` routes them into
|
|
199
|
+
* `repair-sandbox.ts`'s container, whose only mount is the worktree and which
|
|
200
|
+
* has no network. There is no host fallback, because falling back would return
|
|
201
|
+
* exactly the exposure the image was configured to remove.
|
|
202
|
+
*
|
|
203
|
+
* It is opt-in, and unlike #988 that is a COST decision as well as a capability
|
|
204
|
+
* one. #988 measured no penalty for its own containment. This one, measured on
|
|
205
|
+
* macOS / Docker Desktop 29.7.2 with a warm `node:22-alpine` and a ~0.35 s
|
|
206
|
+
* workload:
|
|
207
|
+
*
|
|
208
|
+
* uncontained 0.38 / 0.35 / 0.34 s
|
|
209
|
+
* contained 5.53 / 5.42 / 5.41 s -> ~5.1 s added per command
|
|
210
|
+
*
|
|
211
|
+
* The workload is 0.35 s, so that is essentially all container start-up rather
|
|
212
|
+
* than a slower command — which means it is a FIXED cost paid per step, about
|
|
213
|
+
* 20 s on a run that builds, tests, lints and formats. On a Linux daemon host
|
|
214
|
+
* with no VM in the path it should be well below this; nobody has measured that,
|
|
215
|
+
* and this comment is the only record either way. That is the second reason
|
|
216
|
+
* there is no default: a fixed ~20 s per run is not a cost to start spending on
|
|
217
|
+
* an operator's behalf, and the first is that a guessed image reports every run
|
|
218
|
+
* as unverified — the reason `types.ts` already records for the repair path.
|
|
219
|
+
*
|
|
220
|
+
* **The image must match the repo's DETECTED package manager, not merely carry
|
|
221
|
+
* "a toolchain".** `spawnRunArgs` resolves through `detectPackageManager`, so a
|
|
222
|
+
* repo that resolves to `npm` fails in an `oven/bun:1` image with a raw OCI
|
|
223
|
+
* error (`exec: "npm": executable file not found in $PATH`). That surfaces as a
|
|
224
|
+
* `sandboxError` — the step reports it did not run — which is the right
|
|
225
|
+
* outcome, but the message is Docker's and not a friendly one. Found while
|
|
226
|
+
* taking the measurement above.
|
|
227
|
+
*
|
|
228
|
+
* The `build_green` gate collector re-runs the same build and lint scripts and
|
|
229
|
+
* takes the same sandbox, wired at the worker's registry call site. One config
|
|
230
|
+
* value, both paths.
|
|
231
|
+
*
|
|
232
|
+
* ## The two `dev` servers: contained too, on a weaker network (#1037)
|
|
233
|
+
*
|
|
234
|
+
* `runDeepReview`'s and the review worker's take the SAME `sandboxImage` and go
|
|
235
|
+
* through one `devServerLaunch`, so they can no longer be fixed one at a time —
|
|
236
|
+
* which is how #988 came to fix one of them and ship the other.
|
|
237
|
+
*
|
|
238
|
+
* **That is not a hypothetical, and #1037's card got it wrong.** The card says
|
|
239
|
+
* both dev servers already carried a working `containedEnv()`. The review
|
|
240
|
+
* worker's did not: it passed `env: containedEnv()` to `spawnInGroup`, which
|
|
241
|
+
* merges `process.env` back on top, so the strip removed NOTHING for as long as
|
|
242
|
+
* it shipped — and `spawn-run-containment.test.ts` reported the property as held
|
|
243
|
+
* because it read the spelling. #1021 fixed the call and made the scan
|
|
244
|
+
* spawn-function aware. So on that server the credential half closed one commit
|
|
245
|
+
* before this one, not in #988.
|
|
246
|
+
*
|
|
247
|
+
* Their container differs from the four steps' in exactly one flag, and it
|
|
248
|
+
* cannot be otherwise: a dev server exists to be connected to, so it publishes
|
|
249
|
+
* `127.0.0.1:<port>` instead of taking `--network=none`, and therefore has
|
|
250
|
+
* egress. Every other bound is identical — worktree the only mount, `HOME=/tmp`,
|
|
251
|
+
* no capabilities. `devServerSandboxArgs` records the two alternative network
|
|
252
|
+
* shapes that were measured and rejected.
|
|
253
|
+
*
|
|
254
|
+
* The cost lands very differently from the four steps', and this was measured
|
|
255
|
+
* rather than carried over. A verification step pays container start-up per
|
|
256
|
+
* COMMAND; a dev server pays it once and then lives for the whole review.
|
|
257
|
+
* Measured on macOS / Docker Desktop 29.7.2 with a warm `oven/bun:1`, as
|
|
258
|
+
* time-to-first-HTTP-response — what `probeDevServer` actually waits for:
|
|
259
|
+
*
|
|
260
|
+
* host 0.11 / 0.11 / 0.25 s
|
|
261
|
+
* contained 0.24 / 0.26 / 0.26 s -> ~0.15 s added, once per run
|
|
262
|
+
*
|
|
263
|
+
* That is noise beside the 10–20 s a real dev server takes to boot, which is
|
|
264
|
+
* why this one is not a cost argument the way #1036's was. (Taking the
|
|
265
|
+
* measurement also failed to reproduce #1036's own ~5.1 s figure on this host:
|
|
266
|
+
* its one-shot shape measures 0.50 s contained against 0.37 s on the host, with
|
|
267
|
+
* or without the bind mount. Left as a note rather than an edit, because the
|
|
268
|
+
* conditions that produced 5.1 s cannot be reconstructed from here — but the
|
|
269
|
+
* number is load-bearing for "opt-in, not default" and is worth re-taking.)
|
|
270
|
+
*
|
|
271
|
+
* `harmony-harness`'s own CLI no longer passes no image: #1021 gave it
|
|
272
|
+
* `--sandbox-image`, threaded from this same config by the daemon's motor
|
|
273
|
+
* driver, so a daemon-driven stage contains the `build_green` gate and the held
|
|
274
|
+
* test alike. A person running the CLI by hand still passes none and still runs
|
|
275
|
+
* on their own host, which is a different threat model. The dev servers are not
|
|
276
|
+
* on that path at all — neither gate starts one.
|
|
277
|
+
*
|
|
278
|
+
* That distinction is stated rather than left implied because the first version
|
|
279
|
+
* of both lists named the four verification steps only, and the two dev servers
|
|
280
|
+
* then shipped with the daemon's full environment, unlisted and unnoticed
|
|
281
|
+
* through every gate. `spawn-run-containment.test.ts` asserts the environment
|
|
282
|
+
* property over call sites AND that both dev-server files are still in its
|
|
283
|
+
* scan; `verification-sandbox.test.ts` asserts that a configured image takes
|
|
284
|
+
* the four out of the daemon entirely.
|
|
187
285
|
*/
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Resolve the sandbox for the verification steps out of the daemon's config
|
|
288
|
+
* (#1036). Returns `undefined` when no image is configured, which is the
|
|
289
|
+
* default and means host execution exactly as before.
|
|
290
|
+
*
|
|
291
|
+
* An empty string is "not configured", never an image literally named `""` —
|
|
292
|
+
* that is the same reading `ci-patch.ts` gives `sandboxImage` on the repair
|
|
293
|
+
* path, and the two must not diverge.
|
|
294
|
+
*/
|
|
295
|
+
export function verificationSandbox(
|
|
296
|
+
config: VerificationConfig,
|
|
297
|
+
): VerificationSandbox | undefined {
|
|
298
|
+
const image = config.verification.sandboxImage?.trim();
|
|
299
|
+
if (!image) return undefined;
|
|
300
|
+
return { image };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* One verification command, run in the container when one is configured and in
|
|
305
|
+
* the daemon process when it is not.
|
|
306
|
+
*
|
|
307
|
+
* ## Why the outcome has three shapes and not two
|
|
308
|
+
*
|
|
309
|
+
* `sandboxError` is not `ok: false`. "The command failed" and "the container
|
|
310
|
+
* never started" must not both read as a bad branch, because only the first is
|
|
311
|
+
* the branch's fault — the same distinction `repair-sandbox.ts` draws for the
|
|
312
|
+
* repair path, and for the same reason.
|
|
313
|
+
*
|
|
314
|
+
* ## Why there is no host fallback
|
|
315
|
+
*
|
|
316
|
+
* An operator who configured an image asked for these commands to run without
|
|
317
|
+
* their credentials in scope. Silently running them on the host when Docker is
|
|
318
|
+
* unreachable would hand back precisely the exposure the image was set to
|
|
319
|
+
* remove, and would do it invisibly. So an unavailable runtime is reported, not
|
|
320
|
+
* routed around. `repair-sandbox.ts`'s header states the general form: a
|
|
321
|
+
* half-sandbox on the host is the one option that trades the property for
|
|
322
|
+
* convenience, so it is not offered.
|
|
323
|
+
*/
|
|
324
|
+
async function execStep(
|
|
325
|
+
command: { cmd: string; args: string[] },
|
|
326
|
+
args: {
|
|
327
|
+
worktreePath: string;
|
|
328
|
+
timeout: number;
|
|
329
|
+
sandbox: VerificationSandbox | undefined;
|
|
330
|
+
},
|
|
331
|
+
): Promise<{ ok: boolean; err?: unknown; sandboxError?: string }> {
|
|
332
|
+
const { worktreePath, timeout } = args;
|
|
333
|
+
// An empty image is "not configured", never an image named `""` — and the
|
|
334
|
+
// check is here as well as in `verificationSandbox` because these four are
|
|
335
|
+
// exported and a caller can hand-build the object. Docker reads `""` as a
|
|
336
|
+
// missing argument and would fail in a way that reads like a broken runtime.
|
|
337
|
+
const sandbox = args.sandbox?.image.trim() ? args.sandbox : undefined;
|
|
338
|
+
|
|
339
|
+
if (sandbox) {
|
|
340
|
+
if (!(await sandboxAvailable())) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
sandboxError:
|
|
344
|
+
`verification.sandboxImage is set to "${sandbox.image}" but no container runtime answered — ` +
|
|
345
|
+
"start Docker or unset the image to verify on the host",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const result = await runInSandbox({
|
|
349
|
+
image: sandbox.image,
|
|
350
|
+
worktree: worktreePath,
|
|
351
|
+
command,
|
|
352
|
+
// The STEP's own cap, plus what the container costs to start. Not a
|
|
353
|
+
// separate sandbox-wide timeout: build/lint and the test suite carry
|
|
354
|
+
// different caps on purpose, and one shared knob would silently replace
|
|
355
|
+
// both the moment an image is configured.
|
|
356
|
+
timeoutMs: timeout + SANDBOX_STARTUP_GRACE_MS,
|
|
357
|
+
});
|
|
358
|
+
if (result.sandboxError) {
|
|
359
|
+
return { ok: false, sandboxError: result.sandboxError };
|
|
360
|
+
}
|
|
361
|
+
if (result.passed) return { ok: true };
|
|
362
|
+
// Re-shape into what the existing parsers read, so the container path and
|
|
363
|
+
// the host path produce identical diagnostics from identical output.
|
|
364
|
+
return { ok: false, err: { stdout: result.output, stderr: "" } };
|
|
196
365
|
}
|
|
366
|
+
|
|
197
367
|
try {
|
|
198
368
|
execFileSync(command.cmd, command.args, {
|
|
199
369
|
cwd: worktreePath,
|
|
@@ -202,10 +372,32 @@ export function runBuild(worktreePath: string, timeout: number): string[] {
|
|
|
202
372
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
203
373
|
env: containedEnv(),
|
|
204
374
|
});
|
|
205
|
-
return
|
|
375
|
+
return { ok: true };
|
|
206
376
|
} catch (err: unknown) {
|
|
207
|
-
return
|
|
377
|
+
return { ok: false, err };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function runBuild(
|
|
382
|
+
worktreePath: string,
|
|
383
|
+
timeout: number,
|
|
384
|
+
sandbox?: VerificationSandbox,
|
|
385
|
+
): Promise<string[]> {
|
|
386
|
+
const command = buildCommand(worktreePath);
|
|
387
|
+
if (!command) {
|
|
388
|
+
log.warn(
|
|
389
|
+
TAG,
|
|
390
|
+
`No known build toolchain for ${worktreePath} — skipping build`,
|
|
391
|
+
);
|
|
392
|
+
return [];
|
|
208
393
|
}
|
|
394
|
+
const outcome = await execStep(command, { worktreePath, timeout, sandbox });
|
|
395
|
+
if (outcome.ok) return [];
|
|
396
|
+
if (outcome.sandboxError) {
|
|
397
|
+
log.error(TAG, `Build not verified: ${outcome.sandboxError}`);
|
|
398
|
+
return [`Build did not run: ${outcome.sandboxError}`];
|
|
399
|
+
}
|
|
400
|
+
return parseErrorOutput(outcome.err);
|
|
209
401
|
}
|
|
210
402
|
|
|
211
403
|
/**
|
|
@@ -213,7 +405,11 @@ export function runBuild(worktreePath: string, timeout: number): string[] {
|
|
|
213
405
|
* suite to run). A non-zero exit ALWAYS yields at least one entry — an
|
|
214
406
|
* unparsable failure must never read as a pass (#688).
|
|
215
407
|
*/
|
|
216
|
-
export function runTests(
|
|
408
|
+
export async function runTests(
|
|
409
|
+
worktreePath: string,
|
|
410
|
+
timeout: number,
|
|
411
|
+
sandbox?: VerificationSandbox,
|
|
412
|
+
): Promise<string[]> {
|
|
217
413
|
const command = testCommand(worktreePath);
|
|
218
414
|
if (!command) {
|
|
219
415
|
log.warn(
|
|
@@ -222,25 +418,23 @@ export function runTests(worktreePath: string, timeout: number): string[] {
|
|
|
222
418
|
);
|
|
223
419
|
return [];
|
|
224
420
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
return [];
|
|
234
|
-
} catch (err: unknown) {
|
|
235
|
-
// The suite's own output is the diagnostic — put its tail in the run log,
|
|
236
|
-
// not only the parsed lines that become subtasks.
|
|
237
|
-
const output = combineOutput(err);
|
|
238
|
-
log.warn(
|
|
239
|
-
TAG,
|
|
240
|
-
`Test run failed:\n${output.slice(-4000) || "(no output captured)"}`,
|
|
241
|
-
);
|
|
242
|
-
return parseTestFailures(err, timeout);
|
|
421
|
+
const outcome = await execStep(command, { worktreePath, timeout, sandbox });
|
|
422
|
+
if (outcome.ok) return [];
|
|
423
|
+
if (outcome.sandboxError) {
|
|
424
|
+
// #688's rule — a non-pass must never yield [] — with the reason being the
|
|
425
|
+
// sandbox rather than a test that never ran. Reporting a phantom assertion
|
|
426
|
+
// here would send someone to read a suite that produced no output.
|
|
427
|
+
log.error(TAG, `Tests not verified: ${outcome.sandboxError}`);
|
|
428
|
+
return [`Test run did not happen: ${outcome.sandboxError}`];
|
|
243
429
|
}
|
|
430
|
+
// The suite's own output is the diagnostic — put its tail in the run log,
|
|
431
|
+
// not only the parsed lines that become subtasks.
|
|
432
|
+
const output = combineOutput(outcome.err);
|
|
433
|
+
log.warn(
|
|
434
|
+
TAG,
|
|
435
|
+
`Test run failed:\n${output.slice(-4000) || "(no output captured)"}`,
|
|
436
|
+
);
|
|
437
|
+
return parseTestFailures(outcome.err, timeout);
|
|
244
438
|
}
|
|
245
439
|
|
|
246
440
|
/**
|
|
@@ -255,36 +449,42 @@ export function runTests(worktreePath: string, timeout: number): string[] {
|
|
|
255
449
|
* rule) is logged, never thrown. This does NOT change the lint-warn-only
|
|
256
450
|
* policy — the later `runLint` still reports whatever the fixer left behind.
|
|
257
451
|
*/
|
|
258
|
-
export function runFormatFix(
|
|
452
|
+
export async function runFormatFix(
|
|
259
453
|
worktreePath: string,
|
|
260
454
|
timeout: number,
|
|
261
455
|
workerId: number,
|
|
262
|
-
|
|
456
|
+
sandbox?: VerificationSandbox,
|
|
457
|
+
): Promise<void> {
|
|
263
458
|
const command = formatFixCommand(worktreePath);
|
|
264
459
|
if (!command) return;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
cwd: worktreePath,
|
|
268
|
-
timeout,
|
|
269
|
-
stdio: "pipe",
|
|
270
|
-
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
271
|
-
env: containedEnv(),
|
|
272
|
-
});
|
|
460
|
+
const outcome = await execStep(command, { worktreePath, timeout, sandbox });
|
|
461
|
+
if (outcome.ok) {
|
|
273
462
|
log.info(
|
|
274
463
|
TAG,
|
|
275
464
|
`[worker:${workerId}] Auto-formatted worktree before commit/push`,
|
|
276
465
|
);
|
|
277
|
-
|
|
278
|
-
log.warn(
|
|
279
|
-
TAG,
|
|
280
|
-
`[worker:${workerId}] Auto-format step exited non-zero (non-fatal): ${
|
|
281
|
-
err instanceof Error ? err.message : String(err)
|
|
282
|
-
}`,
|
|
283
|
-
);
|
|
466
|
+
return;
|
|
284
467
|
}
|
|
468
|
+
// Best-effort by contract: this step is tied to the lint step and never
|
|
469
|
+
// blocks. A broken container is one more reason it did not run, reported at
|
|
470
|
+
// the same level as a fixer that exited non-zero — the later `runLint` still
|
|
471
|
+
// reports whatever was left behind.
|
|
472
|
+
const why = outcome.sandboxError
|
|
473
|
+
? outcome.sandboxError
|
|
474
|
+
: outcome.err instanceof Error
|
|
475
|
+
? outcome.err.message
|
|
476
|
+
: String(outcome.err);
|
|
477
|
+
log.warn(
|
|
478
|
+
TAG,
|
|
479
|
+
`[worker:${workerId}] Auto-format step did not complete (non-fatal): ${why}`,
|
|
480
|
+
);
|
|
285
481
|
}
|
|
286
482
|
|
|
287
|
-
export function runLint(
|
|
483
|
+
export async function runLint(
|
|
484
|
+
worktreePath: string,
|
|
485
|
+
timeout: number,
|
|
486
|
+
sandbox?: VerificationSandbox,
|
|
487
|
+
): Promise<string[]> {
|
|
288
488
|
const command = lintCommand(worktreePath);
|
|
289
489
|
if (!command) {
|
|
290
490
|
log.info(
|
|
@@ -293,18 +493,16 @@ export function runLint(worktreePath: string, timeout: number): string[] {
|
|
|
293
493
|
);
|
|
294
494
|
return [];
|
|
295
495
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
return [];
|
|
305
|
-
} catch (err: unknown) {
|
|
306
|
-
return parseErrorOutput(err);
|
|
496
|
+
const outcome = await execStep(command, { worktreePath, timeout, sandbox });
|
|
497
|
+
if (outcome.ok) return [];
|
|
498
|
+
if (outcome.sandboxError) {
|
|
499
|
+
// Lint is warn-only, so this is reported as a warning line rather than
|
|
500
|
+
// blocking — but it must still say the step did not run, because an empty
|
|
501
|
+
// lint result is indistinguishable from a clean one.
|
|
502
|
+
log.error(TAG, `Lint not verified: ${outcome.sandboxError}`);
|
|
503
|
+
return [`Lint did not run: ${outcome.sandboxError}`];
|
|
307
504
|
}
|
|
505
|
+
return parseErrorOutput(outcome.err);
|
|
308
506
|
}
|
|
309
507
|
|
|
310
508
|
export async function runDeepReview(
|
|
@@ -324,25 +522,44 @@ export async function runDeepReview(
|
|
|
324
522
|
|
|
325
523
|
const port = config.verification.devServerBasePort + workerId;
|
|
326
524
|
let devServer: ChildProcess | null = null;
|
|
525
|
+
// Resolved before the `try`, so the `finally` can tear the container down
|
|
526
|
+
// even if the spawn itself throws — `--rm` does not collect a server that
|
|
527
|
+
// never exits, and this one never does.
|
|
528
|
+
const launch = devServerLaunch({
|
|
529
|
+
worktreePath,
|
|
530
|
+
port,
|
|
531
|
+
sandbox: verificationSandbox(config),
|
|
532
|
+
});
|
|
327
533
|
|
|
328
534
|
try {
|
|
535
|
+
// Clear a predecessor on this port BEFORE starting, and await it. The
|
|
536
|
+
// teardown below cannot be awaited everywhere it is reached, and a
|
|
537
|
+
// container still shutting down still holds the published port — so the
|
|
538
|
+
// next start would fail with "port is already allocated" through no fault
|
|
539
|
+
// of the branch. Also collects a container orphaned by a killed daemon,
|
|
540
|
+
// which no in-process handle survives to remove. No-op when nothing is
|
|
541
|
+
// there; `removeSandboxContainer` swallows "No such container".
|
|
542
|
+
if (launch.containerName) {
|
|
543
|
+
await removeSandboxContainer(launch.containerName);
|
|
544
|
+
}
|
|
545
|
+
|
|
329
546
|
// Start dev server in background
|
|
330
|
-
|
|
331
|
-
devServer = spawn(cmd, args, {
|
|
547
|
+
devServer = spawn(launch.cmd, launch.args, {
|
|
332
548
|
cwd: worktreePath,
|
|
333
549
|
stdio: ["ignore", "pipe", "pipe"],
|
|
334
550
|
// Contained (#988), and it was the one spawn in this file that was not.
|
|
335
|
-
// `
|
|
336
|
-
//
|
|
551
|
+
// `devServerLaunch` resolves the worktree's OWN `dev` script, which the
|
|
552
|
+
// contained implement run may rewrite, so this is the same
|
|
337
553
|
// attacker-choosable command as `runBuild`/`runTests` — except it ran
|
|
338
554
|
// with the daemon's full environment, `HARMONY_API_KEY` and
|
|
339
555
|
// `GITHUB_PERSONAL_ACCESS_TOKEN` included, while `changelog.ts` told
|
|
340
556
|
// users the verification commands run "without your credentials".
|
|
341
557
|
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
558
|
+
// #988 removed the environment; #1037 removes the rest of the daemon
|
|
559
|
+
// process, when `verification.sandboxImage` is set. The env strip stays
|
|
560
|
+
// either way: it is what covers the host path, which is still the
|
|
561
|
+
// default, and it is what `docker` itself is spawned with on the
|
|
562
|
+
// contained one.
|
|
346
563
|
env: containedEnv(),
|
|
347
564
|
});
|
|
348
565
|
|
|
@@ -432,6 +649,14 @@ export async function runDeepReview(
|
|
|
432
649
|
if (devServer && !devServer.killed) {
|
|
433
650
|
devServer.kill("SIGTERM");
|
|
434
651
|
}
|
|
652
|
+
// Killing the `docker run` CLI is not the same as stopping the container it
|
|
653
|
+
// launched — the trap `runInSandbox` already documents for its timeout
|
|
654
|
+
// path, except here it is every path, because a dev server only ever ends
|
|
655
|
+
// by being killed. Without this a deep review leaves a server running with
|
|
656
|
+
// the operator's worktree bind-mounted, once per run.
|
|
657
|
+
if (launch.containerName) {
|
|
658
|
+
await removeSandboxContainer(launch.containerName);
|
|
659
|
+
}
|
|
435
660
|
}
|
|
436
661
|
}
|
|
437
662
|
|
|
@@ -684,6 +909,104 @@ export class DevServerReadinessError extends Error {
|
|
|
684
909
|
}
|
|
685
910
|
}
|
|
686
911
|
|
|
912
|
+
/** How a dev server is started, and what has to be torn down afterwards. */
|
|
913
|
+
export interface DevServerLaunch {
|
|
914
|
+
/** Command for `spawn` / `spawnInGroup`. */
|
|
915
|
+
cmd: string;
|
|
916
|
+
args: string[];
|
|
917
|
+
/**
|
|
918
|
+
* The container to force-remove once the server is no longer wanted. Absent
|
|
919
|
+
* on the host path, where killing the process is the whole teardown.
|
|
920
|
+
*/
|
|
921
|
+
containerName?: string;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Resolve how to start the worktree's `dev` server — in a container when one is
|
|
926
|
+
* configured, in the daemon process when one is not (#1037).
|
|
927
|
+
*
|
|
928
|
+
* ## Why both dev servers come through here
|
|
929
|
+
*
|
|
930
|
+
* There are two: `runDeepReview`'s, below, and the review worker's
|
|
931
|
+
* (`review-worker.ts`, in the daemon package). They are the same command with
|
|
932
|
+
* the same exposure, and they have already drifted apart once — #988 found the
|
|
933
|
+
* first, shipped the fix, and the second went out uncontained in the same
|
|
934
|
+
* release because nothing tied them together. A shared resolver is what makes
|
|
935
|
+
* "both dev servers" a fact about the code instead of a claim in a comment.
|
|
936
|
+
*
|
|
937
|
+
* `spawn-run-containment.test.ts` scans for this function by name alongside
|
|
938
|
+
* `spawnRunArgs`, so moving the resolution in here does not take the review
|
|
939
|
+
* worker's spawn out of that scan.
|
|
940
|
+
*
|
|
941
|
+
* ## What the container does and does not buy
|
|
942
|
+
*
|
|
943
|
+
* The `dev` script is resolved out of the worktree's OWN `package.json`, which
|
|
944
|
+
* a contained implement run is allowed to rewrite — the worktree is inside
|
|
945
|
+
* `sandbox.filesystem.allowWrite` by design. So this is the same
|
|
946
|
+
* attacker-choosable command as `runBuild` and `runTests`, and until now it ran
|
|
947
|
+
* in the daemon process as the operator. Contained, it loses the operator's
|
|
948
|
+
* home directory, every capability, and the daemon's uid on anything outside
|
|
949
|
+
* the worktree. It does NOT lose egress: see `devServerSandboxArgs`, which
|
|
950
|
+
* records the two shapes that were measured and rejected.
|
|
951
|
+
*
|
|
952
|
+
* ## Opt-in, behind the SAME key as #1036
|
|
953
|
+
*
|
|
954
|
+
* `verification.sandboxImage`, and deliberately not a second key. The image
|
|
955
|
+
* requirement is identical — it has to carry the repo's own toolchain — and an
|
|
956
|
+
* operator who set one key but not the other would get a half-contained daemon,
|
|
957
|
+
* which is the failure this module keeps having to write down. The cost of
|
|
958
|
+
* being wrong is bounded and already designed for: a contained dev server that
|
|
959
|
+
* cannot start fails exactly as an unstartable host one does, and the review
|
|
960
|
+
* worker already treats that as infrastructure rather than as the branch's
|
|
961
|
+
* fault — it keeps the card in Review and labels it for a human.
|
|
962
|
+
*
|
|
963
|
+
* `--host` is appended only on the contained path, because only there does the
|
|
964
|
+
* bind address matter. See `SANDBOX_DEV_SERVER_BIND`.
|
|
965
|
+
*/
|
|
966
|
+
export function devServerLaunch(args: {
|
|
967
|
+
worktreePath: string;
|
|
968
|
+
port: number;
|
|
969
|
+
sandbox?: VerificationSandbox;
|
|
970
|
+
}): DevServerLaunch {
|
|
971
|
+
const [cmd, runArgs] = spawnRunArgs(
|
|
972
|
+
"dev",
|
|
973
|
+
"--port",
|
|
974
|
+
String(args.port),
|
|
975
|
+
...(args.sandbox ? ["--host", SANDBOX_DEV_SERVER_BIND] : []),
|
|
976
|
+
);
|
|
977
|
+
if (!args.sandbox) return { cmd, args: runArgs };
|
|
978
|
+
const containerName = devServerContainerName(args.port);
|
|
979
|
+
return {
|
|
980
|
+
cmd: "docker",
|
|
981
|
+
args: devServerSandboxArgs({
|
|
982
|
+
image: args.sandbox.image,
|
|
983
|
+
worktree: args.worktreePath,
|
|
984
|
+
command: { cmd, args: runArgs },
|
|
985
|
+
port: args.port,
|
|
986
|
+
name: containerName,
|
|
987
|
+
}),
|
|
988
|
+
containerName,
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* The word "ready", and NOT the tail of "already" (#1037).
|
|
994
|
+
*
|
|
995
|
+
* A substring test read `already` as a readiness signal, which stopped being
|
|
996
|
+
* academic the moment the server moved into a container: `docker run` refuses a
|
|
997
|
+
* duplicate with `Conflict. The container name "…" is **already** in use`, so a
|
|
998
|
+
* failed start announced itself as a successful one. Reproduced end to end — the
|
|
999
|
+
* probe that followed was answered by the PREVIOUS run's container, still up on
|
|
1000
|
+
* that port, and the review would have graded the previous card's page believing
|
|
1001
|
+
* it was looking at this one. A wrong answer, not a slow one.
|
|
1002
|
+
*
|
|
1003
|
+
* `\b` is what fixes it: `already` has a word character before `ready`, so it no
|
|
1004
|
+
* longer matches, while `ready in 320 ms` and `Ready in 2.3s` both still do —
|
|
1005
|
+
* the second only since this became case-insensitive, which is a small widening
|
|
1006
|
+
* in the direction the check was always reaching for.
|
|
1007
|
+
*/
|
|
1008
|
+
const DEV_SERVER_READY = /\bready\b/i;
|
|
1009
|
+
|
|
687
1010
|
/**
|
|
688
1011
|
* Wait for a dev server to signal readiness on stdout/stderr.
|
|
689
1012
|
*
|
|
@@ -691,6 +1014,14 @@ export class DevServerReadinessError extends Error {
|
|
|
691
1014
|
* need the server to be live for correctness (e.g. the review worker)
|
|
692
1015
|
* must not proceed without a confirmed signal. If the server dies before
|
|
693
1016
|
* becoming ready, we reject with the exit details.
|
|
1017
|
+
*
|
|
1018
|
+
* **A match here is not proof the server is up**, and the caller's
|
|
1019
|
+
* `probeDevServer` is not redundant: this reads text the child chose to print.
|
|
1020
|
+
* The container path adds a second writer to that stream — docker itself — and
|
|
1021
|
+
* the two are indistinguishable once they are bytes. That is why the contained
|
|
1022
|
+
* path both suppresses docker's progress output (`--quiet`) and removes the
|
|
1023
|
+
* previous container before starting, so a stale server cannot be the thing
|
|
1024
|
+
* that answers the probe.
|
|
694
1025
|
*/
|
|
695
1026
|
export function waitForDevServer(
|
|
696
1027
|
proc: ChildProcess,
|
|
@@ -730,7 +1061,7 @@ export function waitForDevServer(
|
|
|
730
1061
|
const onData = (data: Buffer) => {
|
|
731
1062
|
const text = data.toString();
|
|
732
1063
|
if (
|
|
733
|
-
|
|
1064
|
+
DEV_SERVER_READY.test(text) ||
|
|
734
1065
|
text.includes("localhost") ||
|
|
735
1066
|
text.includes("Local:")
|
|
736
1067
|
) {
|