@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.
@@ -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 type { VerificationConfig } from "./exec-types.js";
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(worktreePath, config.verification.timeout);
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(worktreePath, config.verification.timeout);
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, and that is a known hole
158
- *
159
- * `runBuild`, `runTests`, `runFormatFix`, `runLint` and `runDeepReview`'s dev
160
- * server all resolve their command through `spawnRunArgs` — `bun run <script>`
161
- * out of the worktree's own `package.json` and execute it **in the daemon
162
- * process**. (`review-worker.ts` spawns the sixth, its own `dev` server, for the
163
- * same reason and with the same exposure.)
164
- * The worktree is in `sandbox.filesystem.allowWrite` by design, so a contained
165
- * implement run can rewrite `"build"` to anything it likes and the daemon will
166
- * then run it, outside the sandbox, with full network. No failure is needed to
167
- * trigger this; verification runs on every successful run.
168
- *
169
- * `env: containedEnv()` (#988) is a real reduction and NOT a fix: it removes
170
- * the secret-shaped variables, so `HARMONY_API_KEY`, `GITHUB_PERSONAL_ACCESS_TOKEN`
171
- * and their neighbours are gone from the child. What remains is a command of
172
- * the run's choosing, executing as the operator, able to read `~/.ssh` and
173
- * reach the network.
174
- *
175
- * Closing it properly means running the repo's own build and test somewhere the
176
- * operator's credentials are not which is the problem `repair-sandbox.ts`
177
- * solved for the CI repair with a container, and it is a piece of work of its
178
- * own rather than a line in this one. Until then this is listed in
179
- * `run-containment.ts`'s "what this does not cover" and in `docs/agent-daemon.md`,
180
- * because a residual nobody wrote down is indistinguishable from one nobody saw.
181
- *
182
- * That is not a figure of speech here. The first version of both lists named
183
- * the four verification steps only, and the two `dev` servers which are the
184
- * same `spawnRunArgs` shapethen shipped with the daemon's full environment,
185
- * unlisted and unnoticed through every gate. `spawn-run-containment.test.ts`
186
- * now asserts the property over call sites rather than over a written list.
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
- export function runBuild(worktreePath: string, timeout: number): string[] {
189
- const command = buildCommand(worktreePath);
190
- if (!command) {
191
- log.warn(
192
- TAG,
193
- `No known build toolchain for ${worktreePath} skipping build`,
194
- );
195
- return [];
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 parseErrorOutput(err);
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(worktreePath: string, timeout: number): string[] {
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
- try {
226
- execFileSync(command.cmd, command.args, {
227
- cwd: worktreePath,
228
- timeout,
229
- stdio: "pipe",
230
- maxBuffer: MAX_OUTPUT_BUFFER,
231
- env: containedEnv(),
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
- ): void {
456
+ sandbox?: VerificationSandbox,
457
+ ): Promise<void> {
263
458
  const command = formatFixCommand(worktreePath);
264
459
  if (!command) return;
265
- try {
266
- execFileSync(command.cmd, command.args, {
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
- } catch (err: unknown) {
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(worktreePath: string, timeout: number): string[] {
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
- try {
297
- execFileSync(command.cmd, command.args, {
298
- cwd: worktreePath,
299
- timeout,
300
- stdio: "pipe",
301
- maxBuffer: MAX_OUTPUT_BUFFER,
302
- env: containedEnv(),
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
- const [cmd, args] = spawnRunArgs("dev", "--port", String(port));
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
- // `spawnRunArgs("dev", …)` resolves the worktree's OWN `dev` script,
336
- // which the contained implement run may rewrite, so this is the same
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
- // A reduction, not a fix: the command still executes outside the sandbox
343
- // with network. That is why the dev server is now NAMED in the residual
344
- // list in `run-containment.ts` and in `docs/agent-daemon.md`, instead of
345
- // being left implied by a list of four other scripts.
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
- text.includes("ready") ||
1064
+ DEV_SERVER_READY.test(text) ||
734
1065
  text.includes("localhost") ||
735
1066
  text.includes("Local:")
736
1067
  ) {