@gethmy/harness 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pm.ts CHANGED
@@ -42,18 +42,28 @@ export function detectPackageManager(): PackageManager {
42
42
 
43
43
  /**
44
44
  * Return the install command string for the detected package manager.
45
+ *
46
+ * `ignoreScripts` adds `--ignore-scripts`, which every one of these supports.
47
+ * It exists for the red-CI repair (#1015): that checkout is of a branch whose
48
+ * head may ALREADY carry a machine-authored repair commit — `maxAttempts`
49
+ * defaults to 2 — so a `postinstall` there would run unreviewed code on the
50
+ * daemon's host, as the daemon user, with its credentials. The diff gate covers
51
+ * `package.json` itself, but not the file a script it already declares points
52
+ * at, and no path list can: which file an install script reaches is a property
53
+ * of the repo, not of this code. Not running them is the bound.
45
54
  */
46
- export function installCommand(): string {
55
+ export function installCommand(ignoreScripts = false): string {
47
56
  const pm = detectPackageManager();
57
+ const skip = ignoreScripts ? " --ignore-scripts" : "";
48
58
  switch (pm) {
49
59
  case "bun":
50
- return "bun install --frozen-lockfile";
60
+ return `bun install --frozen-lockfile${skip}`;
51
61
  case "pnpm":
52
- return "pnpm install --frozen-lockfile";
62
+ return `pnpm install --frozen-lockfile${skip}`;
53
63
  case "yarn":
54
- return "yarn install --frozen-lockfile";
64
+ return `yarn install --frozen-lockfile${skip}`;
55
65
  case "npm":
56
- return "npm ci";
66
+ return `npm ci${skip}`;
57
67
  }
58
68
  }
59
69
 
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { SANDBOX_MOUNT, sandboxRunArgs } from "./repair-sandbox.js";
3
+
4
+ const IMAGE = "oven/bun:1";
5
+ const WORKTREE = "/repo/.harmony-worktrees/review-agent-x";
6
+ const CMD = { cmd: "bun", args: ["run", "test"] };
7
+
8
+ const argv = () => sandboxRunArgs(IMAGE, WORKTREE, CMD);
9
+
10
+ /**
11
+ * These are not style assertions. Each flag below is the only thing standing
12
+ * between "a container ran the repair's tests" and "the repair's tests ran with
13
+ * the daemon's credentials", so each gets its own named case — a dropped flag
14
+ * should fail a test that says what was lost, not a snapshot diff.
15
+ */
16
+ describe("sandboxRunArgs", () => {
17
+ it("has no network", () => {
18
+ // Exfiltration does not need a shell. A test file the repair wrote is
19
+ // executed by the suite, and with egress it can post anything it read.
20
+ expect(argv()).toContain("--network=none");
21
+ });
22
+
23
+ it("drops every capability and cannot regain one", () => {
24
+ expect(argv()).toContain("--cap-drop=ALL");
25
+ expect(argv()).toContain("--security-opt=no-new-privileges");
26
+ });
27
+
28
+ it("caps memory and pids", () => {
29
+ // A repair that spins parks the card; it does not take down the host the
30
+ // daemon shares with its operator.
31
+ expect(argv().some((a) => a.startsWith("--memory="))).toBe(true);
32
+ expect(argv().some((a) => a.startsWith("--pids-limit="))).toBe(true);
33
+ });
34
+
35
+ it("mounts the worktree and NOTHING else", () => {
36
+ const mounts = argv()
37
+ .map((a, i) => (a === "--volume" ? argv()[i + 1] : null))
38
+ .filter((v): v is string => v !== null);
39
+ expect(mounts).toEqual([`${WORKTREE}:${SANDBOX_MOUNT}`]);
40
+ });
41
+
42
+ it("never mounts the Docker socket", () => {
43
+ // A container that can reach the daemon socket is root on the host, which
44
+ // would invert the entire point of running the verification in one.
45
+ const joined = argv().join(" ");
46
+ expect(joined).not.toContain("docker.sock");
47
+ expect(joined).not.toContain("/var/run");
48
+ });
49
+
50
+ it("never mounts a host home directory", () => {
51
+ const joined = argv().join(" ");
52
+ expect(joined).not.toContain("/Users/");
53
+ expect(joined).not.toContain("/home/");
54
+ expect(joined).not.toContain(".claude");
55
+ expect(joined).not.toContain(".harmony-mcp");
56
+ expect(joined).not.toContain(".ssh");
57
+ });
58
+
59
+ it("passes exactly one env var, and it is not a credential", () => {
60
+ const envs = argv()
61
+ .map((a, i) => (a === "--env" ? argv()[i + 1] : null))
62
+ .filter((v): v is string => v !== null);
63
+ expect(envs).toEqual(["HOME=/tmp"]);
64
+ });
65
+
66
+ it("is a one-shot", () => {
67
+ expect(argv()).toContain("--rm");
68
+ });
69
+
70
+ it("runs as the daemon's own uid, not the image's root", () => {
71
+ // Otherwise build output in the bind mount is root-owned on Linux, and the
72
+ // `git add` and the worktree cleanup that follow run as the daemon user.
73
+ const a = argv();
74
+ if (typeof process.getuid !== "function") return; // Windows: not a concept
75
+ expect(a).toContain("--user");
76
+ expect(a[a.indexOf("--user") + 1]).toBe(
77
+ `${process.getuid()}:${process.getgid?.()}`,
78
+ );
79
+ });
80
+
81
+ it("names the container, so a timed-out one can be torn down", () => {
82
+ // `--rm` cleans up a container that EXITS. A timeout kills the docker CLI,
83
+ // not the container it launched, so the name is the only handle left.
84
+ const a = sandboxRunArgs(IMAGE, WORKTREE, CMD, "harmony-repair-abc");
85
+ expect(a[a.indexOf("--name") + 1]).toBe("harmony-repair-abc");
86
+ // And it stays optional, so the flag pins above hold either way.
87
+ expect(argv()).not.toContain("--name");
88
+ });
89
+
90
+ it("runs the command as given, ignoring the image's own entrypoint", () => {
91
+ const a = argv();
92
+ expect(a).toContain("--entrypoint");
93
+ expect(a[a.indexOf("--entrypoint") + 1]).toBe("bun");
94
+ // The image name separates docker's flags from the command's arguments, so
95
+ // everything after it is argv for the command and nothing else.
96
+ expect(a.slice(a.indexOf(IMAGE) + 1)).toEqual(["run", "test"]);
97
+ });
98
+
99
+ it("works out of the mount point, not the host path", () => {
100
+ const a = argv();
101
+ expect(a[a.indexOf("--workdir") + 1]).toBe(SANDBOX_MOUNT);
102
+ // The host path appears ONLY as the source half of the bind mount.
103
+ expect(a.filter((x) => x.includes(WORKTREE))).toEqual([
104
+ `${WORKTREE}:${SANDBOX_MOUNT}`,
105
+ ]);
106
+ });
107
+
108
+ it("would actually notice a missing flag", () => {
109
+ // Mutation check for the cases above: they read a real argv, so a guard
110
+ // that could never fail is not a guard. Dropping a flag from the list must
111
+ // make the corresponding assertion false.
112
+ const withoutNetwork = argv().filter((a) => a !== "--network=none");
113
+ expect(withoutNetwork).not.toContain("--network=none");
114
+ expect(argv()).toContain("--network=none");
115
+ });
116
+ });
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Run a command against a worktree with none of the daemon's credentials in
3
+ * scope (#1015).
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Verifying a repair means RUNNING code the repair wrote. That is not a path
8
+ * problem and no denylist fixes it: a test file the suite imports is executed
9
+ * too, and so is anything the build entrypoint reaches. Review of #981 found the
10
+ * cheap version of this — write a `pretest` script into the worktree's own
11
+ * `package.json` and wait for the verification step to run it, as the daemon
12
+ * user, on a host holding `gh` credentials and the Harmony API key. Removing
13
+ * `Bash` from the spawn did not help, because the spawn never needed a shell of
14
+ * its own; it needed the daemon to run one for it.
15
+ *
16
+ * So the bound is not "which files may it write" but "what does the thing that
17
+ * executes them have". Here: a container with no network, no host home
18
+ * directory, no environment, no capabilities, and no way to acquire more.
19
+ *
20
+ * ## Why absence is the fallback, not a weaker sandbox
21
+ *
22
+ * A daemon host without Docker gets no local verification at all rather than a
23
+ * best-effort one. `ciRepair.patch` then still repairs — it commits and pushes,
24
+ * and CI runs the branch on GitHub's runner instead. That keeps the same
25
+ * property by a different route: the code the repair wrote is executed
26
+ * somewhere the operator's credentials are not. A half-sandbox on the host
27
+ * would be the one option that trades the property for convenience, so it is
28
+ * not offered.
29
+ *
30
+ * What the push path needs INSTEAD of this module is `buildExecutedChanges`
31
+ * (`ci-patch.ts`), because a repair that rewrote `.github/workflows/*` would be
32
+ * executed by Actions with the repository's own secrets. The two guards cover
33
+ * two different executors and neither replaces the other.
34
+ *
35
+ * ## What this module is not
36
+ *
37
+ * It is not a general sandbox and does not try to be one. It runs one command,
38
+ * once, and reports whether it passed. It deliberately mounts nothing but the
39
+ * worktree, and in particular never the Docker socket — a container that can
40
+ * reach the daemon socket is root on the host, which would invert the whole
41
+ * point.
42
+ */
43
+ import { randomUUID } from "node:crypto";
44
+ import { promisify } from "node:util";
45
+ import { log } from "./log.js";
46
+
47
+ const TAG = "repair-sandbox";
48
+
49
+ /**
50
+ * Run `docker` asynchronously, importing `execFile` LAZILY.
51
+ *
52
+ * The laziness is not incidental. A top-level `promisify(execFile)` puts
53
+ * `execFile` in this module's import bindings, and this module is re-exported
54
+ * from the package barrel — so every test anywhere that partially mocks
55
+ * `node:child_process` with only `execFileSync` then fails at LOAD time with
56
+ * "No execFile export is defined on the mock", in files that have nothing to do
57
+ * with sandboxes. It broke two pre-existing agent suites when it was written
58
+ * statically. `worktree.ts` → `git-pr.ts` hit exactly this and was fixed the
59
+ * same way; the module system caches the import, so the cost is one lookup.
60
+ */
61
+ async function dockerExec(
62
+ argv: string[],
63
+ opts: { timeout: number; maxBuffer?: number },
64
+ ): Promise<{ stdout: string; stderr: string }> {
65
+ const { execFile } = await import("node:child_process");
66
+ return promisify(execFile)("docker", argv, {
67
+ encoding: "utf-8",
68
+ ...opts,
69
+ }) as unknown as Promise<{ stdout: string; stderr: string }>;
70
+ }
71
+
72
+ /** Output cap, matching `verification.ts` — a verbose suite clears 1 MB easily. */
73
+ const MAX_OUTPUT_BUFFER = 20 * 1024 * 1024;
74
+
75
+ /** How long `docker version` may take before the host counts as sandbox-less. */
76
+ const PROBE_TIMEOUT_MS = 10_000;
77
+
78
+ /** Where the worktree is mounted inside the container. */
79
+ export const SANDBOX_MOUNT = "/repo";
80
+
81
+ /**
82
+ * Hard resource caps. A repair that spins is a repair that parks, not one that
83
+ * takes the host down with it: the daemon is a long-lived process on a machine
84
+ * someone else is using.
85
+ */
86
+ const SANDBOX_MEMORY = "4g";
87
+ const SANDBOX_PIDS = "512";
88
+
89
+ export interface SandboxCommand {
90
+ cmd: string;
91
+ args: string[];
92
+ }
93
+
94
+ export interface SandboxResult {
95
+ /** Did the command exit 0? */
96
+ passed: boolean;
97
+ /** Combined stdout+stderr, capped. Empty when the run never started. */
98
+ output: string;
99
+ /**
100
+ * Set when the sandbox itself failed rather than the command under it —
101
+ * no Docker, image missing, timeout. Distinct from `passed: false`, which
102
+ * means the repair genuinely did not verify.
103
+ */
104
+ sandboxError?: string;
105
+ }
106
+
107
+ let dockerProbe: boolean | null = null;
108
+
109
+ /**
110
+ * Is a usable container runtime reachable?
111
+ *
112
+ * Cached for the daemon's lifetime. `docker version` (not `--version`) is the
113
+ * probe because it contacts the daemon: the CLI being on PATH says nothing
114
+ * about whether Docker Desktop is actually running, and the difference decides
115
+ * whether a repair verifies locally or leans on CI.
116
+ */
117
+ export async function sandboxAvailable(): Promise<boolean> {
118
+ // Only a POSITIVE result is cached. A daemon that starts before Docker
119
+ // Desktop is up would otherwise treat "not available" as settled for its
120
+ // whole lifetime — pushing every later repair unverified, with one `info`
121
+ // line as the only trace. A negative costs one probe per repair attempt,
122
+ // which is bounded by `maxAttempts` and is the cheap direction to be wrong in.
123
+ if (dockerProbe === true) return true;
124
+ try {
125
+ await dockerExec(["version", "--format", "{{.Server.Version}}"], {
126
+ timeout: PROBE_TIMEOUT_MS,
127
+ });
128
+ dockerProbe = true;
129
+ } catch {
130
+ dockerProbe = false;
131
+ }
132
+ return dockerProbe;
133
+ }
134
+
135
+ /** Test seam. Not for production use — the probe is cached on purpose. */
136
+ export function __resetSandboxProbe(): void {
137
+ dockerProbe = null;
138
+ }
139
+
140
+ /**
141
+ * Tear down a container the `docker` CLI was killed out from under.
142
+ *
143
+ * `--rm` cleans up a container that EXITS; it does nothing for one still
144
+ * running when its launching CLI was killed on a timeout. Without this a
145
+ * timed-out verification leaves a build running with the operator's worktree
146
+ * still bind-mounted — once per repair attempt, and the repair loop retries.
147
+ *
148
+ * Best-effort and short: if the daemon is gone the container is too.
149
+ */
150
+ async function removeContainer(name: string): Promise<void> {
151
+ try {
152
+ await dockerExec(["rm", "--force", name], { timeout: PROBE_TIMEOUT_MS });
153
+ log.warn(TAG, `removed the timed-out sandbox container ${name}`);
154
+ } catch {
155
+ // Already gone, or the daemon is unreachable. Nothing further to do.
156
+ }
157
+ }
158
+
159
+ /**
160
+ * The `docker run` argv, as a pure function so the flags are pinned by a test
161
+ * rather than by reading them.
162
+ *
163
+ * Every flag below is load-bearing, and a test names each one:
164
+ *
165
+ * - `--rm` — the container is a one-shot; nothing of it outlives the check.
166
+ * - `--network=none` — no egress. The repair cannot phone home with what it
167
+ * read, and cannot fetch a second stage.
168
+ * - `--cap-drop=ALL` + `--security-opt=no-new-privileges` — no capabilities,
169
+ * and no setuid path back to acquiring any.
170
+ * - `--memory` / `--pids-limit` — a fork bomb or a leak parks the card instead
171
+ * of taking the daemon's host with it.
172
+ * - `--env` is passed ONLY as `HOME=/tmp`. Docker does not inherit the host
173
+ * environment, so this is belt-and-braces: it stops a toolchain resolving
174
+ * `$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.
177
+ * - `--workdir /repo` and `--entrypoint` — run the command as given, ignoring
178
+ * whatever entrypoint the image declares.
179
+ *
180
+ * The worktree is mounted read-WRITE because a build writes: `dist/`, a test
181
+ * cache, coverage. That is the point of the container — those writes land in a
182
+ * throwaway checkout, and the process making them holds nothing.
183
+ */
184
+ export function sandboxRunArgs(
185
+ image: string,
186
+ worktree: string,
187
+ command: SandboxCommand,
188
+ name?: string,
189
+ ): string[] {
190
+ return [
191
+ "run",
192
+ "--rm",
193
+ ...(name ? ["--name", name] : []),
194
+ "--network=none",
195
+ "--cap-drop=ALL",
196
+ "--security-opt=no-new-privileges",
197
+ `--memory=${SANDBOX_MEMORY}`,
198
+ `--pids-limit=${SANDBOX_PIDS}`,
199
+ // Run as the daemon's own uid/gid, so build output in the bind mount stays
200
+ // owned by the user who has to `git add` and delete it afterwards. Without
201
+ // this the container runs as the image's default user — root for most CI
202
+ // images — and on Linux leaves root-owned files behind in the operator's
203
+ // worktree. `process.getuid` is absent on Windows, where the concept does
204
+ // not apply and the flag is simply omitted.
205
+ ...(typeof process.getuid === "function" &&
206
+ typeof process.getgid === "function"
207
+ ? ["--user", `${process.getuid()}:${process.getgid()}`]
208
+ : []),
209
+ "--env",
210
+ "HOME=/tmp",
211
+ "--volume",
212
+ `${worktree}:${SANDBOX_MOUNT}`,
213
+ "--workdir",
214
+ SANDBOX_MOUNT,
215
+ "--entrypoint",
216
+ command.cmd,
217
+ image,
218
+ ...command.args,
219
+ ];
220
+ }
221
+
222
+ /**
223
+ * Run one command against `worktree` inside a container.
224
+ *
225
+ * **Async, and that is not a style choice.** The daemon is one event loop
226
+ * serving the realtime watcher, `/health`, the reconcile heartbeat and every
227
+ * pool worker. A synchronous `execFileSync` here would freeze all of them for
228
+ * up to `timeoutMs` — ten minutes by default. `command-metric.ts` had exactly
229
+ * this defect and #823 fixed it; repeating it would be the same outage under a
230
+ * different name.
231
+ *
232
+ * On a timeout the child that dies is the `docker` CLI, not the container it
233
+ * started, so the container is torn down BY NAME afterwards — otherwise a
234
+ * timed-out verification leaves a build running with the worktree still
235
+ * mounted, once per repair attempt.
236
+ *
237
+ * Never throws: a sandbox that failed to start reports `sandboxError` and the
238
+ * caller decides. That distinction matters — "the repair does not build" and
239
+ * "the image is missing" must not both read as a bad repair, because only the
240
+ * first is the repair's fault.
241
+ */
242
+ export async function runInSandbox(args: {
243
+ image: string;
244
+ worktree: string;
245
+ command: SandboxCommand;
246
+ timeoutMs: number;
247
+ }): Promise<SandboxResult> {
248
+ const name = `harmony-repair-${randomUUID()}`;
249
+ const argv = sandboxRunArgs(args.image, args.worktree, args.command, name);
250
+ log.info(
251
+ TAG,
252
+ `sandbox: ${args.command.cmd} ${args.command.args.join(" ")} (image ${args.image})`,
253
+ );
254
+ try {
255
+ const { stdout } = await dockerExec(argv, {
256
+ timeout: args.timeoutMs,
257
+ maxBuffer: MAX_OUTPUT_BUFFER,
258
+ });
259
+ return { passed: true, output: stdout ?? "" };
260
+ } catch (err) {
261
+ // `promisify(execFile)` reports the exit status as `code`, NOT `status` —
262
+ // the field `execFileSync` uses. A string `code` is a spawn-level errno
263
+ // (`ENOENT` when docker is not installed), never an exit status.
264
+ const e = err as {
265
+ code?: number | string | null;
266
+ killed?: boolean;
267
+ signal?: string | null;
268
+ stdout?: string;
269
+ stderr?: string;
270
+ message?: string;
271
+ };
272
+ const output = `${e.stdout ?? ""}${e.stderr ?? ""}`;
273
+
274
+ // The `docker` CLI died without reporting an exit status: killed on
275
+ // timeout, or never started. Neither is a verdict on the repair — and a
276
+ // timeout leaves the CONTAINER running, because what was killed is the CLI
277
+ // that launched it.
278
+ if (typeof e.code !== "number") {
279
+ const timedOut = e.killed === true || e.signal != null;
280
+ if (timedOut) await removeContainer(name);
281
+ return {
282
+ passed: false,
283
+ output,
284
+ sandboxError: timedOut
285
+ ? `the sandbox timed out after ${args.timeoutMs}ms`
286
+ : `the sandbox did not run: ${e.message ?? "unknown error"}`,
287
+ };
288
+ }
289
+
290
+ // `docker run` reports 125 for its own failure to start a container
291
+ // (unknown image, bad flag) — as opposed to 126/127 (command not
292
+ // executable / not found) and any other code, which come from the command.
293
+ if (e.code === 125) {
294
+ return {
295
+ passed: false,
296
+ output,
297
+ sandboxError: `the sandbox could not start (image "${args.image}" missing or unusable)`,
298
+ };
299
+ }
300
+
301
+ return { passed: false, output };
302
+ }
303
+ }