@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,5 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { SANDBOX_MOUNT, sandboxRunArgs } from "./repair-sandbox.js";
2
+ import {
3
+ devServerContainerName,
4
+ devServerSandboxArgs,
5
+ SANDBOX_DEV_SERVER_BIND,
6
+ SANDBOX_MOUNT,
7
+ sandboxRunArgs,
8
+ } from "./repair-sandbox.js";
3
9
 
4
10
  const IMAGE = "oven/bun:1";
5
11
  const WORKTREE = "/repo/.harmony-worktrees/review-agent-x";
@@ -39,6 +45,26 @@ describe("sandboxRunArgs", () => {
39
45
  expect(mounts).toEqual([`${WORKTREE}:${SANDBOX_MOUNT}`]);
40
46
  });
41
47
 
48
+ it("adds a caller's extra mount, and only where it was asked for", () => {
49
+ // #1021 needs the runner's report to reach the motor, so `mounts` exists.
50
+ // The default stays exactly one mount — every other case above reads the
51
+ // no-argument form, so a mount that leaked into the default would fail them
52
+ // rather than passing quietly here.
53
+ const withReport = sandboxRunArgs(IMAGE, WORKTREE, CMD, undefined, [
54
+ { host: "/private/var/folders/x/report", container: "/harmony-report" },
55
+ ]);
56
+ const mounts = withReport
57
+ .map((a, i) => (a === "--volume" ? withReport[i + 1] : null))
58
+ .filter((v): v is string => v !== null);
59
+ expect(mounts).toEqual([
60
+ `${WORKTREE}:${SANDBOX_MOUNT}`,
61
+ "/private/var/folders/x/report:/harmony-report",
62
+ ]);
63
+ // The extra mount is a HOLE, so it must not weaken anything else.
64
+ expect(withReport).toContain("--network=none");
65
+ expect(withReport).toContain("--cap-drop=ALL");
66
+ });
67
+
42
68
  it("never mounts the Docker socket", () => {
43
69
  // A container that can reach the daemon socket is root on the host, which
44
70
  // would invert the entire point of running the verification in one.
@@ -114,3 +140,142 @@ describe("sandboxRunArgs", () => {
114
140
  expect(argv()).toContain("--network=none");
115
141
  });
116
142
  });
143
+
144
+ /**
145
+ * #1037 — the dev server's container, which is the SAME container as above with
146
+ * one bound removed.
147
+ *
148
+ * The cases below are written to make that sentence checkable rather than
149
+ * merely written down: the first asserts the difference is exactly the network
150
+ * flags, so a future edit that also drops, say, `--cap-drop=ALL` from the dev
151
+ * server fails here with a diff naming the capability it lost.
152
+ */
153
+ describe("devServerSandboxArgs", () => {
154
+ const PORT = 4300;
155
+ const NAME = "harmony-devserver-4300-abc";
156
+ const devArgv = () =>
157
+ devServerSandboxArgs({
158
+ image: IMAGE,
159
+ worktree: WORKTREE,
160
+ command: { cmd: "bun", args: ["run", "dev", "--port", "4300"] },
161
+ port: PORT,
162
+ name: NAME,
163
+ });
164
+
165
+ it("differs from the verification container only in reachability and output", () => {
166
+ // The whole claim of #1037 in one assertion. Every SECURITY bound —
167
+ // capabilities, mounts, uid, env, memory, pids, workdir — is identical, and
168
+ // a drop on either side shows up here as an unexpected entry.
169
+ //
170
+ // Exactly two flags are allowed to differ, and both are subtracted by name
171
+ // rather than by a loose filter, so a third difference fails: the network
172
+ // (`--network=none` vs `--publish`) and `--quiet`, which this container
173
+ // needs because its output is parsed for readiness. Neither is a bound.
174
+ const named = sandboxRunArgs(IMAGE, WORKTREE, CMD, NAME);
175
+ const shared = (a: string[]) =>
176
+ a.filter(
177
+ (x, i) =>
178
+ x !== "--network=none" &&
179
+ x !== "--quiet" &&
180
+ x !== "--publish" &&
181
+ a[i - 1] !== "--publish" &&
182
+ // The command tail differs by construction (`test` vs `dev`).
183
+ i <= a.indexOf(IMAGE),
184
+ );
185
+ expect(shared(devArgv())).toEqual(shared(named));
186
+ // Non-vacuity: an over-eager filter that emptied both sides would make the
187
+ // line above pass while comparing nothing. The shared part is most of the
188
+ // argv, so it is checked to actually still carry the bounds it exists for.
189
+ expect(shared(devArgv())).toContain("--cap-drop=ALL");
190
+ expect(shared(devArgv())).toContain("--security-opt=no-new-privileges");
191
+ expect(shared(devArgv()).length).toBeGreaterThan(10);
192
+ });
193
+
194
+ it("cannot use --network=none, and does not pretend to", () => {
195
+ // Not an oversight: a namespace with no interfaces cannot be connected to,
196
+ // and the entire purpose of this container is that something outside it
197
+ // connects. The residual is egress, and it is documented at the source.
198
+ expect(devArgv()).not.toContain("--network=none");
199
+ });
200
+
201
+ it("publishes on the loopback interface and nowhere else", () => {
202
+ // The bare `-p 4300:4300` form binds 0.0.0.0 and offers the run's own dev
203
+ // script to the whole LAN. The host half is pinned so the only thing that
204
+ // can reach it is the daemon that asked for it.
205
+ const a = devArgv();
206
+ const published = a
207
+ .map((x, i) => (x === "--publish" ? a[i + 1] : null))
208
+ .filter((v): v is string => v !== null);
209
+ expect(published).toEqual([`127.0.0.1:${PORT}:${PORT}`]);
210
+ for (const p of published) expect(p.startsWith("127.0.0.1:")).toBe(true);
211
+ });
212
+
213
+ it("still mounts the worktree and nothing else", () => {
214
+ const a = devArgv();
215
+ const mounts = a
216
+ .map((x, i) => (x === "--volume" ? a[i + 1] : null))
217
+ .filter((v): v is string => v !== null);
218
+ expect(mounts).toEqual([`${WORKTREE}:${SANDBOX_MOUNT}`]);
219
+ const joined = a.join(" ");
220
+ expect(joined).not.toContain("docker.sock");
221
+ expect(joined).not.toContain("/var/run");
222
+ expect(joined).not.toContain(".ssh");
223
+ expect(joined).not.toContain(".harmony-mcp");
224
+ });
225
+
226
+ it("always names the container, because this one never exits on its own", () => {
227
+ // `--name` is optional for the one-shot and mandatory here: `--rm` collects
228
+ // a container that EXITS, and a dev server only ever ends by being killed.
229
+ // The name is the only handle that stops it.
230
+ const a = devArgv();
231
+ expect(a[a.indexOf("--name") + 1]).toBe(NAME);
232
+ });
233
+
234
+ it("derives the container name from the port, so a predecessor is findable", () => {
235
+ // NOT randomised, and that is the fix for a real race. A review worker's
236
+ // port is fixed for its whole life and reused for every card it picks up,
237
+ // and `killDevServer` is reached from a synchronous `cleanup()` that cannot
238
+ // await the removal it starts. With a random name the next run has no
239
+ // handle on the container still holding the published port; with this one
240
+ // it can simply remove that name first. It also collects a container
241
+ // orphaned by a killed daemon, which no in-process handle outlives.
242
+ expect(devServerContainerName(PORT)).toBe(devServerContainerName(PORT));
243
+ expect(devServerContainerName(PORT)).toContain(String(PORT));
244
+ });
245
+
246
+ it("still separates workers, because they are separated by port", () => {
247
+ // The thing a random suffix was protecting against. Two containers on one
248
+ // port cannot coexist whatever they are called — the port is the
249
+ // constraint — and every worker slot has its own.
250
+ expect(devServerContainerName(4300)).not.toBe(devServerContainerName(4301));
251
+ });
252
+
253
+ it("would actually notice a lost flag", () => {
254
+ // Mutation check: the comparison above must fail when a real bound is
255
+ // dropped from the dev-server side, not merely pass on identical inputs.
256
+ const stripped = devArgv().filter((x) => x !== "--cap-drop=ALL");
257
+ expect(stripped).not.toContain("--cap-drop=ALL");
258
+ expect(devArgv()).toContain("--cap-drop=ALL");
259
+ });
260
+
261
+ it("keeps docker's pull progress off the stream that is parsed", () => {
262
+ // `waitForDevServer` reads this stream for the server announcing itself,
263
+ // and containing the server put a SECOND writer on it. The word-boundary
264
+ // `DEV_SERVER_READY` is the primary defence (it is what makes docker's
265
+ // "Already exists" safe); this is the second, kept because the two fail
266
+ // differently — a regex bounds the strings we thought of, `--quiet` removes
267
+ // the writer. It hides progress only: a missing image still errors and
268
+ // still exits 125.
269
+ expect(devArgv()).toContain("--quiet");
270
+ // NOT on the one-shot: its output is captured and shown, never parsed, and
271
+ // there the pull progress is context a reader may want.
272
+ expect(sandboxRunArgs(IMAGE, WORKTREE, CMD)).not.toContain("--quiet");
273
+ });
274
+
275
+ it("binds a routable address inside the container", () => {
276
+ // Loopback inside the container is not the loopback the published port
277
+ // forwards to, so a server bound there is unreachable and the probe fails
278
+ // with a connection refused that names nothing.
279
+ expect(SANDBOX_DEV_SERVER_BIND).toBe("0.0.0.0");
280
+ });
281
+ });
@@ -91,6 +91,28 @@ export interface SandboxCommand {
91
91
  args: string[];
92
92
  }
93
93
 
94
+ /**
95
+ * One extra bind mount beside the worktree.
96
+ *
97
+ * There is exactly one consumer (#1021's held-test run, which needs the
98
+ * runner's report file to reach the motor) and it is deliberately a
99
+ * parameter rather than a second argv builder: the flag set below is the
100
+ * boundary, and a copy of it that drifts is the failure this module exists to
101
+ * prevent. Every caller therefore gets `--network=none`, `--cap-drop=ALL` and
102
+ * the rest by construction, and only the mount list differs.
103
+ *
104
+ * A mount is a HOLE in the container, so adding one is never free. The
105
+ * held-test run's is a directory the motor created and owns, holding nothing
106
+ * but the report the runner writes — see `oracle.ts`, which also states why the
107
+ * held test being able to write it is not what the container is closing.
108
+ */
109
+ export interface SandboxMount {
110
+ /** Absolute path on the host. Realpath it first — `/tmp` is a symlink on macOS. */
111
+ host: string;
112
+ /** Absolute path inside the container. */
113
+ container: string;
114
+ }
115
+
94
116
  export interface SandboxResult {
95
117
  /** Did the command exit 0? */
96
118
  passed: boolean;
@@ -146,11 +168,31 @@ export function __resetSandboxProbe(): void {
146
168
  * still bind-mounted — once per repair attempt, and the repair loop retries.
147
169
  *
148
170
  * Best-effort and short: if the daemon is gone the container is too.
171
+ *
172
+ * EXPORTED for the held-test run (#1021), which supervises its own `docker`
173
+ * child through `spawnInGroup` rather than through {@link runInSandbox} — it
174
+ * needs the exit code and the two streams apart, which this module's
175
+ * `{ passed, output }` shape does not carry. Its timeout kills the same CLI and
176
+ * strands the same container, so it needs the same teardown, and a second copy
177
+ * of it would be one more thing to keep in step.
178
+ *
179
+ * **And for the dev servers (#1037), where it is not an edge path but the
180
+ * ORDINARY one.** A one-shot container exits on its own and `--rm` collects it;
181
+ * a dev server never exits, so every contained dev server is torn down by name,
182
+ * on the success path as much as the failure path — and the NEXT one removes
183
+ * its predecessor by name before starting, which is why the name is derived
184
+ * from the port rather than randomised. It never throws and never rejects, so a
185
+ * synchronous caller (the review worker's `killDevServer`) may leave the
186
+ * promise unawaited without risking an unhandled rejection.
149
187
  */
150
- async function removeContainer(name: string): Promise<void> {
188
+ export async function removeSandboxContainer(name: string): Promise<void> {
151
189
  try {
152
190
  await dockerExec(["rm", "--force", name], { timeout: PROBE_TIMEOUT_MS });
153
- log.warn(TAG, `removed the timed-out sandbox container ${name}`);
191
+ // Not "timed-out": since #1037 this is also the ordinary teardown for a
192
+ // dev server, which never exits on its own, and logging every one of those
193
+ // as a timeout would invent an incident once per review. `info`, for the
194
+ // same reason — the caller logs the timeout itself when there was one.
195
+ log.info(TAG, `removed sandbox container ${name}`);
154
196
  } catch {
155
197
  // Already gone, or the daemon is unreachable. Nothing further to do.
156
198
  }
@@ -172,8 +214,11 @@ async function removeContainer(name: string): Promise<void> {
172
214
  * - `--env` is passed ONLY as `HOME=/tmp`. Docker does not inherit the host
173
215
  * environment, so this is belt-and-braces: it stops a toolchain resolving
174
216
  * `$HOME` to `/root` and finding a mounted credential that is not there.
175
- * - `-v <worktree>:/repo` — the ONLY mount. Not the Docker socket, not the
176
- * host home, not `/var/run`. A container with the socket is root on the host.
217
+ * - `-v <worktree>:/repo` — the only mount a caller gets for free. Not the
218
+ * Docker socket, not the host home, not `/var/run`. A container with the
219
+ * socket is root on the host. A caller may add `mounts` (#1021 needs the
220
+ * runner's report to reach the motor), and each one is a hole it has to
221
+ * justify at its own call site — see {@link SandboxMount}.
177
222
  * - `--workdir /repo` and `--entrypoint` — run the command as given, ignoring
178
223
  * whatever entrypoint the image declares.
179
224
  *
@@ -186,12 +231,39 @@ export function sandboxRunArgs(
186
231
  worktree: string,
187
232
  command: SandboxCommand,
188
233
  name?: string,
234
+ mounts: readonly SandboxMount[] = [],
189
235
  ): string[] {
190
236
  return [
191
237
  "run",
192
238
  "--rm",
193
239
  ...(name ? ["--name", name] : []),
194
240
  "--network=none",
241
+ ...sandboxHardeningArgs(worktree, mounts),
242
+ "--entrypoint",
243
+ command.cmd,
244
+ image,
245
+ ...command.args,
246
+ ];
247
+ }
248
+
249
+ /**
250
+ * Everything both container shapes share — that is, every bound EXCEPT the
251
+ * network one, which is the only place they differ (#1037).
252
+ *
253
+ * Factored so the dev-server shape cannot quietly drift from the verification
254
+ * shape. Before this the flags were a literal list in one function; a second
255
+ * list beside it is how one of them ends up a capability short, and nothing
256
+ * would have failed.
257
+ *
258
+ * `mounts` is #1021's parameter, threaded rather than duplicated: a dev server
259
+ * has no use for one today, but a second mount mechanism beside it is exactly
260
+ * the drift this function exists to prevent.
261
+ */
262
+ function sandboxHardeningArgs(
263
+ worktree: string,
264
+ mounts: readonly SandboxMount[] = [],
265
+ ): string[] {
266
+ return [
195
267
  "--cap-drop=ALL",
196
268
  "--security-opt=no-new-privileges",
197
269
  `--memory=${SANDBOX_MEMORY}`,
@@ -210,12 +282,135 @@ export function sandboxRunArgs(
210
282
  "HOME=/tmp",
211
283
  "--volume",
212
284
  `${worktree}:${SANDBOX_MOUNT}`,
285
+ ...mounts.flatMap((m) => ["--volume", `${m.host}:${m.container}`]),
213
286
  "--workdir",
214
287
  SANDBOX_MOUNT,
288
+ ];
289
+ }
290
+
291
+ /**
292
+ * The address a contained dev server must bind INSIDE the container.
293
+ *
294
+ * A dev server that binds loopback binds the CONTAINER's loopback, which the
295
+ * published port never reaches — the mapping forwards to the container's
296
+ * external interface. Vite, Next, Astro and webpack-dev-server all default to
297
+ * localhost, so without this the container starts, the server reports itself
298
+ * ready on its own stdout, and the probe then fails with a connection refused
299
+ * that names nothing. Measured: with `--host 0.0.0.0` the published port
300
+ * answered in ~0.25 s; without it, never.
301
+ */
302
+ export const SANDBOX_DEV_SERVER_BIND = "0.0.0.0";
303
+
304
+ /**
305
+ * The container name for a dev server on `port`, and the handle that kills it.
306
+ *
307
+ * **Derived from the port, deliberately, rather than randomised.** A review
308
+ * worker's port is fixed for the worker's whole life (`review.devServerPort +
309
+ * slot`) and reused for every card that worker ever picks up, so the container
310
+ * that has to be gone before the next one starts is always *the one on this
311
+ * port*. A random name cannot be used to find it, which leaves the next run
312
+ * racing a `docker rm` it has no handle on; a derived one turns the teardown
313
+ * into something the next start can simply wait for.
314
+ *
315
+ * It introduces no collision. Two containers on one port cannot coexist
316
+ * whatever they are called — the port is the constraint — and the review and
317
+ * verification pools occupy separate port ranges by config
318
+ * (`review.devServerPort` 4300, `verification.devServerBasePort` 4200), one
319
+ * slot each.
320
+ *
321
+ * It also picks up the case a random name loses entirely: a container orphaned
322
+ * by a daemon that was killed, which no in-process handle survives to remove.
323
+ */
324
+ export function devServerContainerName(port: number): string {
325
+ return `harmony-devserver-${port}`;
326
+ }
327
+
328
+ /**
329
+ * The `docker run` argv for a LONG-LIVED dev server, published on the host's
330
+ * loopback (#1037).
331
+ *
332
+ * ## Why this is not `sandboxRunArgs` with a port added
333
+ *
334
+ * It is the same container in every respect but one, and that one cannot be
335
+ * fixed: **it has egress.** `--network=none` is not available to a dev server,
336
+ * because the whole point of the server is that something OUTSIDE the container
337
+ * connects to it — `probeDevServer` from the daemon, and then the reviewer that
338
+ * looks at the page. A namespace with no interfaces cannot be connected to.
339
+ *
340
+ * That is a strictly weaker property than the four verification steps get, and
341
+ * it is stated here rather than left for someone to infer from a missing flag.
342
+ *
343
+ * ## What was measured before settling for it
344
+ *
345
+ * Two shapes that would have kept both halves were tried on the daemon's own
346
+ * platform (macOS, Docker Desktop 29.7.2) and neither works:
347
+ *
348
+ * - `docker network create --internal` — egress is gone, and so is the
349
+ * published port: `curl 127.0.0.1:<port>` never answers. Internal networks
350
+ * drop the published-port path along with the external one, so this trades
351
+ * the server away to protect it.
352
+ * - a user-defined bridge with `enable_ip_masquerade=false` — the port
353
+ * publishes, but egress SURVIVES (`registry.npmjs.org` and a raw
354
+ * `https://1.1.1.1` both answered). Docker Desktop routes through its own
355
+ * VM gateway, so the flag does not bite the way it does on native Linux.
356
+ * Worse, that network reached a service on the operator's own host
357
+ * (`host.docker.internal`) which the DEFAULT bridge could not.
358
+ *
359
+ * So the default bridge is chosen deliberately, not by omission: of the two
360
+ * bridges that publish a port, it is the one that could not reach the
361
+ * operator's other local services.
362
+ *
363
+ * ## What the container still takes away
364
+ *
365
+ * Egress is what is left, and it is worth being precise about what egress is
366
+ * worth here. The command is the run's own `dev` script and it still runs, but
367
+ * it now runs with the worktree as its ONLY mount and `HOME=/tmp`: no
368
+ * `~/.ssh`, no `~/.harmony-mcp/config.json`, no `~/.claude.json`, no
369
+ * capabilities, and no route back to acquiring any. What it can still send is
370
+ * the worktree — which is the run's own output — and what it can still fetch is
371
+ * a second stage, which lands inside the same bounds. The valuable half of
372
+ * "network plus the operator's home directory" is the half this removes.
373
+ *
374
+ * `--publish 127.0.0.1:<port>:<port>` and not `<port>:<port>`: the bare form
375
+ * binds `0.0.0.0` and puts the run's dev server on every interface of the
376
+ * operator's machine, offering it to the whole LAN. The host half is pinned to
377
+ * loopback so the only thing that can reach it is the daemon that asked for it.
378
+ */
379
+ export function devServerSandboxArgs(args: {
380
+ image: string;
381
+ worktree: string;
382
+ command: SandboxCommand;
383
+ port: number;
384
+ name: string;
385
+ }): string[] {
386
+ return [
387
+ "run",
388
+ // Still a one-shot: nothing of the server outlives the review that wanted
389
+ // it. `--rm` collects it when it exits, and `removeSandboxContainer` is
390
+ // what collects it when it does not — which, for a dev server, is always.
391
+ "--rm",
392
+ // Suppress docker's own pull progress, because this container's output is
393
+ // PARSED, not merely logged: `waitForDevServer` reads it for the server
394
+ // announcing itself, and containing the server put a SECOND writer on that
395
+ // stream. `DEV_SERVER_READY` is the primary defence and it is what makes
396
+ // "Already exists" safe (`\bready\b` does not match inside "already"); this
397
+ // is the second, and it is worth having because the two fail differently —
398
+ // a regex bounds the strings we thought of, while `--quiet` removes the
399
+ // writer. Docker's vocabulary is not ours to pin.
400
+ //
401
+ // It hides progress only: a missing image still writes its error and still
402
+ // exits 125, which is what `runInSandbox` reads to tell "the sandbox could
403
+ // not start" from "the command failed". Measured both ways.
404
+ "--quiet",
405
+ "--name",
406
+ args.name,
407
+ "--publish",
408
+ `127.0.0.1:${args.port}:${args.port}`,
409
+ ...sandboxHardeningArgs(args.worktree),
215
410
  "--entrypoint",
216
- command.cmd,
217
- image,
218
- ...command.args,
411
+ args.command.cmd,
412
+ args.image,
413
+ ...args.command.args,
219
414
  ];
220
415
  }
221
416
 
@@ -277,7 +472,7 @@ export async function runInSandbox(args: {
277
472
  // that launched it.
278
473
  if (typeof e.code !== "number") {
279
474
  const timedOut = e.killed === true || e.signal != null;
280
- if (timedOut) await removeContainer(name);
475
+ if (timedOut) await removeSandboxContainer(name);
281
476
  return {
282
477
  passed: false,
283
478
  output,