@gethmy/harness 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +376 -219
- package/dist/index.js +293 -146
- package/package.json +2 -2
- package/src/cli.ts +34 -2
- package/src/exec-types.ts +33 -4
- package/src/git-pr.ts +53 -5
- package/src/oracle-collector.ts +38 -8
- package/src/oracle.ts +464 -53
- package/src/repair-sandbox.test.ts +166 -1
- package/src/repair-sandbox.ts +203 -8
- package/src/run-containment.ts +191 -37
- package/src/stage-cli.ts +32 -8
- package/src/verification.ts +200 -21
- package/src/worktree.ts +19 -2
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
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
|
+
});
|
package/src/repair-sandbox.ts
CHANGED
|
@@ -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
|
|
188
|
+
export async function removeSandboxContainer(name: string): Promise<void> {
|
|
151
189
|
try {
|
|
152
190
|
await dockerExec(["rm", "--force", name], { timeout: PROBE_TIMEOUT_MS });
|
|
153
|
-
|
|
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
|
|
176
|
-
* host home, not `/var/run`. A container with the
|
|
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
|
|
475
|
+
if (timedOut) await removeSandboxContainer(name);
|
|
281
476
|
return {
|
|
282
477
|
passed: false,
|
|
283
478
|
output,
|