@gethmy/harness 1.2.1 → 1.3.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 +489 -225
- package/dist/index.js +1222 -401
- package/package.json +2 -2
- package/src/ci-failure.ts +465 -0
- package/src/cli.ts +11 -1
- package/src/confine-to-repo.test.ts +324 -1
- package/src/confine-to-repo.ts +274 -22
- package/src/error-classifier.ts +52 -1
- package/src/gate-collectors.ts +11 -3
- package/src/git-pr.ts +461 -8
- package/src/index.ts +2 -0
- package/src/model-tier.test.ts +11 -6
- package/src/model-tier.ts +4 -4
- package/src/oracle-collector.ts +244 -23
- package/src/oracle.ts +856 -108
- package/src/pm.ts +15 -5
- package/src/repair-sandbox.test.ts +116 -0
- package/src/repair-sandbox.ts +303 -0
- package/src/run-sizing.test.ts +264 -66
- package/src/run-sizing.ts +146 -26
- package/src/sdk-agent-runner.ts +22 -1
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
|
|
60
|
+
return `bun install --frozen-lockfile${skip}`;
|
|
51
61
|
case "pnpm":
|
|
52
|
-
return
|
|
62
|
+
return `pnpm install --frozen-lockfile${skip}`;
|
|
53
63
|
case "yarn":
|
|
54
|
-
return
|
|
64
|
+
return `yarn install --frozen-lockfile${skip}`;
|
|
55
65
|
case "npm":
|
|
56
|
-
return
|
|
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
|
+
}
|