@gethmy/harness 1.4.0 → 1.6.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 +736 -154
- package/dist/index.js +886 -424
- package/package.json +2 -2
- package/src/ci-failure.ts +10 -5
- package/src/exec-types.ts +69 -7
- package/src/gate-collectors.ts +30 -4
- package/src/git-diff-stat.ts +2 -1
- package/src/git-pr.ts +59 -25
- package/src/index.ts +1 -0
- package/src/pm.ts +8 -3
- package/src/revert-guard.ts +9 -2
- package/src/run-containment.ts +1312 -0
- package/src/sdk-agent-runner.ts +19 -0
- package/src/verification.ts +295 -66
- package/src/worktree.ts +149 -57
package/src/sdk-agent-runner.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
type McpServerConfig,
|
|
37
37
|
type Options,
|
|
38
38
|
query,
|
|
39
|
+
type SandboxSettings,
|
|
39
40
|
type SDKMessage,
|
|
40
41
|
type SettingSource,
|
|
41
42
|
type SpawnedProcess,
|
|
@@ -143,6 +144,18 @@ export interface SdkRunnerConfig {
|
|
|
143
144
|
* Only the sizing preflight sets this. Every other spawn keeps CLI parity.
|
|
144
145
|
*/
|
|
145
146
|
gateEveryToolCall?: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* OS-level isolation for COMMAND EXECUTION — Seatbelt on macOS, bubblewrap on
|
|
149
|
+
* Linux (#988). Built by `implementRunContainment` (`run-containment.ts`),
|
|
150
|
+
* which is where the reasoning for each field lives.
|
|
151
|
+
*
|
|
152
|
+
* This is the only containment knob that leaves `Bash` usable. `canUseTool` +
|
|
153
|
+
* `confineToRepo` gate the agent's own TOOL calls, so they can confine a
|
|
154
|
+
* spawn that only reads and edits; they say nothing about what a command that
|
|
155
|
+
* spawn runs may then reach. An implement run has to run commands, so it
|
|
156
|
+
* needs the bound one layer down.
|
|
157
|
+
*/
|
|
158
|
+
sandbox?: SandboxSettings;
|
|
146
159
|
/**
|
|
147
160
|
* Hands the spawned process-group leader to the worker so its existing
|
|
148
161
|
* pause/resume/cancel paths can keep operating on `this.process`.
|
|
@@ -322,6 +335,12 @@ export class SdkAgentRunner implements AgentRunner {
|
|
|
322
335
|
: {}),
|
|
323
336
|
...(this.cfg.mcpServers ? { mcpServers: this.cfg.mcpServers } : {}),
|
|
324
337
|
...(this.cfg.strictMcpConfig ? { strictMcpConfig: true } : {}),
|
|
338
|
+
// Execution sandbox (#988). Omitted entirely when unset, so every spawn
|
|
339
|
+
// that has not opted in keeps its current behaviour — the same shape as
|
|
340
|
+
// `canUseTool` above. When it IS set, `failIfUnavailable` inside it means
|
|
341
|
+
// an unsupported host fails the run rather than quietly running it
|
|
342
|
+
// uncontained, so there is no silent third state.
|
|
343
|
+
...(this.cfg.sandbox ? { sandbox: this.cfg.sandbox } : {}),
|
|
325
344
|
stderr: (data) => {
|
|
326
345
|
this.capturedStderr += data;
|
|
327
346
|
},
|
package/src/verification.ts
CHANGED
|
@@ -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
|
|
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,7 +14,13 @@ import {
|
|
|
10
14
|
supportsDevServer,
|
|
11
15
|
testCommand,
|
|
12
16
|
} from "./project-type.js";
|
|
17
|
+
import { runInSandbox, sandboxAvailable } from "./repair-sandbox.js";
|
|
13
18
|
import { findDeletedTestFiles } from "./revert-guard.js";
|
|
19
|
+
import {
|
|
20
|
+
containedEnv,
|
|
21
|
+
GIT_NO_HOOKS,
|
|
22
|
+
implementRunContainmentCliArgs,
|
|
23
|
+
} from "./run-containment.js";
|
|
14
24
|
|
|
15
25
|
const TAG = "verification";
|
|
16
26
|
|
|
@@ -58,6 +68,11 @@ export async function runVerification(
|
|
|
58
68
|
revertWarnings: [],
|
|
59
69
|
};
|
|
60
70
|
|
|
71
|
+
// Resolved once (#1036). Undefined unless the operator configured an image,
|
|
72
|
+
// in which case the four script-resolved steps run in a container instead of
|
|
73
|
+
// in this process. The two dev servers below are deliberately not passed it.
|
|
74
|
+
const sandbox = verificationSandbox(config);
|
|
75
|
+
|
|
61
76
|
if (config.verification.revertGuard) {
|
|
62
77
|
log.info(TAG, `[worker:${workerId}] Checking for reverted merged work...`);
|
|
63
78
|
const deletedTests = findDeletedTestFiles(
|
|
@@ -84,7 +99,11 @@ export async function runVerification(
|
|
|
84
99
|
|
|
85
100
|
if (config.verification.build) {
|
|
86
101
|
log.info(TAG, `[worker:${workerId}] Running build...`);
|
|
87
|
-
result.buildErrors = runBuild(
|
|
102
|
+
result.buildErrors = await runBuild(
|
|
103
|
+
worktreePath,
|
|
104
|
+
config.verification.timeout,
|
|
105
|
+
sandbox,
|
|
106
|
+
);
|
|
88
107
|
if (result.buildErrors.length > 0) {
|
|
89
108
|
log.warn(
|
|
90
109
|
TAG,
|
|
@@ -102,9 +121,10 @@ export async function runVerification(
|
|
|
102
121
|
// still gates the branch.
|
|
103
122
|
if (config.verification.test && result.buildErrors.length === 0) {
|
|
104
123
|
log.info(TAG, `[worker:${workerId}] Running tests...`);
|
|
105
|
-
result.testFailures = runTests(
|
|
124
|
+
result.testFailures = await runTests(
|
|
106
125
|
worktreePath,
|
|
107
126
|
config.verification.testTimeout,
|
|
127
|
+
sandbox,
|
|
108
128
|
);
|
|
109
129
|
if (result.testFailures.length > 0) {
|
|
110
130
|
log.warn(
|
|
@@ -120,7 +140,11 @@ export async function runVerification(
|
|
|
120
140
|
|
|
121
141
|
if (config.verification.lint) {
|
|
122
142
|
log.info(TAG, `[worker:${workerId}] Running lint...`);
|
|
123
|
-
result.lintWarnings = runLint(
|
|
143
|
+
result.lintWarnings = await runLint(
|
|
144
|
+
worktreePath,
|
|
145
|
+
config.verification.timeout,
|
|
146
|
+
sandbox,
|
|
147
|
+
);
|
|
124
148
|
if (result.lintWarnings.length > 0) {
|
|
125
149
|
log.warn(
|
|
126
150
|
TAG,
|
|
@@ -148,34 +172,198 @@ export async function runVerification(
|
|
|
148
172
|
return result;
|
|
149
173
|
}
|
|
150
174
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
175
|
+
/**
|
|
176
|
+
* ## The verification steps run the RUN'S OWN scripts — contained since #1036
|
|
177
|
+
*
|
|
178
|
+
* `runBuild`, `runTests`, `runFormatFix` and `runLint` resolve their command
|
|
179
|
+
* through `spawnRunArgs` — `bun run <script>` out of the worktree's own
|
|
180
|
+
* `package.json`. The worktree is in `sandbox.filesystem.allowWrite` by design,
|
|
181
|
+
* so a contained implement run can rewrite `"build"` to anything it likes, and
|
|
182
|
+
* before #1036 the daemon then ran it **in its own process**, as the operator,
|
|
183
|
+
* with network. No failure was needed to trigger it; verification runs on every
|
|
184
|
+
* successful run.
|
|
185
|
+
*
|
|
186
|
+
* `env: containedEnv()` (#988) removed the secret-shaped variables and was
|
|
187
|
+
* stated there as a reduction and NOT a fix — the command still read `~/.ssh`
|
|
188
|
+
* and `~/.harmony-mcp/config.json` off disk. **#1036 closes the rest, for these
|
|
189
|
+
* four, when `verification.sandboxImage` is set:** `execStep` routes them into
|
|
190
|
+
* `repair-sandbox.ts`'s container, whose only mount is the worktree and which
|
|
191
|
+
* has no network. There is no host fallback, because falling back would return
|
|
192
|
+
* exactly the exposure the image was configured to remove.
|
|
193
|
+
*
|
|
194
|
+
* It is opt-in, and unlike #988 that is a COST decision as well as a capability
|
|
195
|
+
* one. #988 measured no penalty for its own containment. This one, measured on
|
|
196
|
+
* macOS / Docker Desktop 29.7.2 with a warm `node:22-alpine` and a ~0.35 s
|
|
197
|
+
* workload:
|
|
198
|
+
*
|
|
199
|
+
* uncontained 0.38 / 0.35 / 0.34 s
|
|
200
|
+
* contained 5.53 / 5.42 / 5.41 s -> ~5.1 s added per command
|
|
201
|
+
*
|
|
202
|
+
* The workload is 0.35 s, so that is essentially all container start-up rather
|
|
203
|
+
* than a slower command — which means it is a FIXED cost paid per step, about
|
|
204
|
+
* 20 s on a run that builds, tests, lints and formats. On a Linux daemon host
|
|
205
|
+
* with no VM in the path it should be well below this; nobody has measured that,
|
|
206
|
+
* and this comment is the only record either way. That is the second reason
|
|
207
|
+
* there is no default: a fixed ~20 s per run is not a cost to start spending on
|
|
208
|
+
* an operator's behalf, and the first is that a guessed image reports every run
|
|
209
|
+
* as unverified — the reason `types.ts` already records for the repair path.
|
|
210
|
+
*
|
|
211
|
+
* **The image must match the repo's DETECTED package manager, not merely carry
|
|
212
|
+
* "a toolchain".** `spawnRunArgs` resolves through `detectPackageManager`, so a
|
|
213
|
+
* repo that resolves to `npm` fails in an `oven/bun:1` image with a raw OCI
|
|
214
|
+
* error (`exec: "npm": executable file not found in $PATH`). That surfaces as a
|
|
215
|
+
* `sandboxError` — the step reports it did not run — which is the right
|
|
216
|
+
* outcome, but the message is Docker's and not a friendly one. Found while
|
|
217
|
+
* taking the measurement above.
|
|
218
|
+
*
|
|
219
|
+
* The `build_green` gate collector re-runs the same build and lint scripts and
|
|
220
|
+
* takes the same sandbox, wired at the worker's registry call site. One config
|
|
221
|
+
* value, both paths.
|
|
222
|
+
*
|
|
223
|
+
* ## Still uncontained: the two `dev` servers, and the standalone CLI
|
|
224
|
+
*
|
|
225
|
+
* `runDeepReview`'s and the review worker's. Both are the same `spawnRunArgs`
|
|
226
|
+
* shape, and both are **probed over HTTP from the daemon**, so a `--network=none`
|
|
227
|
+
* container is unreachable by construction. They keep `containedEnv()` and stay
|
|
228
|
+
* named in `run-containment.ts`'s residual list rather than being quietly
|
|
229
|
+
* counted as covered. `harmony-harness`'s own CLI builds the same gate
|
|
230
|
+
* collector and passes no image, because the motor holds no operator config —
|
|
231
|
+
* a person running it against their own checkout, not board text.
|
|
232
|
+
*
|
|
233
|
+
* That distinction is stated rather than left implied because the first version
|
|
234
|
+
* of both lists named the four verification steps only, and the two dev servers
|
|
235
|
+
* then shipped with the daemon's full environment, unlisted and unnoticed
|
|
236
|
+
* through every gate. `spawn-run-containment.test.ts` asserts the environment
|
|
237
|
+
* property over call sites, and `verification-sandbox.test.ts` asserts that a
|
|
238
|
+
* configured image takes the four out of the daemon entirely.
|
|
239
|
+
*/
|
|
240
|
+
/**
|
|
241
|
+
* Resolve the sandbox for the verification steps out of the daemon's config
|
|
242
|
+
* (#1036). Returns `undefined` when no image is configured, which is the
|
|
243
|
+
* default and means host execution exactly as before.
|
|
244
|
+
*
|
|
245
|
+
* An empty string is "not configured", never an image literally named `""` —
|
|
246
|
+
* that is the same reading `ci-patch.ts` gives `sandboxImage` on the repair
|
|
247
|
+
* path, and the two must not diverge.
|
|
248
|
+
*/
|
|
249
|
+
export function verificationSandbox(
|
|
250
|
+
config: VerificationConfig,
|
|
251
|
+
): VerificationSandbox | undefined {
|
|
252
|
+
const image = config.verification.sandboxImage?.trim();
|
|
253
|
+
if (!image) return undefined;
|
|
254
|
+
return { image };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One verification command, run in the container when one is configured and in
|
|
259
|
+
* the daemon process when it is not.
|
|
260
|
+
*
|
|
261
|
+
* ## Why the outcome has three shapes and not two
|
|
262
|
+
*
|
|
263
|
+
* `sandboxError` is not `ok: false`. "The command failed" and "the container
|
|
264
|
+
* never started" must not both read as a bad branch, because only the first is
|
|
265
|
+
* the branch's fault — the same distinction `repair-sandbox.ts` draws for the
|
|
266
|
+
* repair path, and for the same reason.
|
|
267
|
+
*
|
|
268
|
+
* ## Why there is no host fallback
|
|
269
|
+
*
|
|
270
|
+
* An operator who configured an image asked for these commands to run without
|
|
271
|
+
* their credentials in scope. Silently running them on the host when Docker is
|
|
272
|
+
* unreachable would hand back precisely the exposure the image was set to
|
|
273
|
+
* remove, and would do it invisibly. So an unavailable runtime is reported, not
|
|
274
|
+
* routed around. `repair-sandbox.ts`'s header states the general form: a
|
|
275
|
+
* half-sandbox on the host is the one option that trades the property for
|
|
276
|
+
* convenience, so it is not offered.
|
|
277
|
+
*/
|
|
278
|
+
async function execStep(
|
|
279
|
+
command: { cmd: string; args: string[] },
|
|
280
|
+
args: {
|
|
281
|
+
worktreePath: string;
|
|
282
|
+
timeout: number;
|
|
283
|
+
sandbox: VerificationSandbox | undefined;
|
|
284
|
+
},
|
|
285
|
+
): Promise<{ ok: boolean; err?: unknown; sandboxError?: string }> {
|
|
286
|
+
const { worktreePath, timeout } = args;
|
|
287
|
+
// An empty image is "not configured", never an image named `""` — and the
|
|
288
|
+
// check is here as well as in `verificationSandbox` because these four are
|
|
289
|
+
// exported and a caller can hand-build the object. Docker reads `""` as a
|
|
290
|
+
// missing argument and would fail in a way that reads like a broken runtime.
|
|
291
|
+
const sandbox = args.sandbox?.image.trim() ? args.sandbox : undefined;
|
|
292
|
+
|
|
293
|
+
if (sandbox) {
|
|
294
|
+
if (!(await sandboxAvailable())) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
sandboxError:
|
|
298
|
+
`verification.sandboxImage is set to "${sandbox.image}" but no container runtime answered — ` +
|
|
299
|
+
"start Docker or unset the image to verify on the host",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const result = await runInSandbox({
|
|
303
|
+
image: sandbox.image,
|
|
304
|
+
worktree: worktreePath,
|
|
305
|
+
command,
|
|
306
|
+
// The STEP's own cap, plus what the container costs to start. Not a
|
|
307
|
+
// separate sandbox-wide timeout: build/lint and the test suite carry
|
|
308
|
+
// different caps on purpose, and one shared knob would silently replace
|
|
309
|
+
// both the moment an image is configured.
|
|
310
|
+
timeoutMs: timeout + SANDBOX_STARTUP_GRACE_MS,
|
|
311
|
+
});
|
|
312
|
+
if (result.sandboxError) {
|
|
313
|
+
return { ok: false, sandboxError: result.sandboxError };
|
|
314
|
+
}
|
|
315
|
+
if (result.passed) return { ok: true };
|
|
316
|
+
// Re-shape into what the existing parsers read, so the container path and
|
|
317
|
+
// the host path produce identical diagnostics from identical output.
|
|
318
|
+
return { ok: false, err: { stdout: result.output, stderr: "" } };
|
|
159
319
|
}
|
|
320
|
+
|
|
160
321
|
try {
|
|
161
322
|
execFileSync(command.cmd, command.args, {
|
|
162
323
|
cwd: worktreePath,
|
|
163
324
|
timeout,
|
|
164
325
|
stdio: "pipe",
|
|
165
326
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
327
|
+
env: containedEnv(),
|
|
166
328
|
});
|
|
167
|
-
return
|
|
329
|
+
return { ok: true };
|
|
168
330
|
} catch (err: unknown) {
|
|
169
|
-
return
|
|
331
|
+
return { ok: false, err };
|
|
170
332
|
}
|
|
171
333
|
}
|
|
172
334
|
|
|
335
|
+
export async function runBuild(
|
|
336
|
+
worktreePath: string,
|
|
337
|
+
timeout: number,
|
|
338
|
+
sandbox?: VerificationSandbox,
|
|
339
|
+
): Promise<string[]> {
|
|
340
|
+
const command = buildCommand(worktreePath);
|
|
341
|
+
if (!command) {
|
|
342
|
+
log.warn(
|
|
343
|
+
TAG,
|
|
344
|
+
`No known build toolchain for ${worktreePath} — skipping build`,
|
|
345
|
+
);
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
const outcome = await execStep(command, { worktreePath, timeout, sandbox });
|
|
349
|
+
if (outcome.ok) return [];
|
|
350
|
+
if (outcome.sandboxError) {
|
|
351
|
+
log.error(TAG, `Build not verified: ${outcome.sandboxError}`);
|
|
352
|
+
return [`Build did not run: ${outcome.sandboxError}`];
|
|
353
|
+
}
|
|
354
|
+
return parseErrorOutput(outcome.err);
|
|
355
|
+
}
|
|
356
|
+
|
|
173
357
|
/**
|
|
174
358
|
* Run the repo's own test suite. Returns the failures (empty = passed, or no
|
|
175
359
|
* suite to run). A non-zero exit ALWAYS yields at least one entry — an
|
|
176
360
|
* unparsable failure must never read as a pass (#688).
|
|
177
361
|
*/
|
|
178
|
-
export function runTests(
|
|
362
|
+
export async function runTests(
|
|
363
|
+
worktreePath: string,
|
|
364
|
+
timeout: number,
|
|
365
|
+
sandbox?: VerificationSandbox,
|
|
366
|
+
): Promise<string[]> {
|
|
179
367
|
const command = testCommand(worktreePath);
|
|
180
368
|
if (!command) {
|
|
181
369
|
log.warn(
|
|
@@ -184,24 +372,23 @@ export function runTests(worktreePath: string, timeout: number): string[] {
|
|
|
184
372
|
);
|
|
185
373
|
return [];
|
|
186
374
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
});
|
|
194
|
-
return [];
|
|
195
|
-
} catch (err: unknown) {
|
|
196
|
-
// The suite's own output is the diagnostic — put its tail in the run log,
|
|
197
|
-
// not only the parsed lines that become subtasks.
|
|
198
|
-
const output = combineOutput(err);
|
|
199
|
-
log.warn(
|
|
200
|
-
TAG,
|
|
201
|
-
`Test run failed:\n${output.slice(-4000) || "(no output captured)"}`,
|
|
202
|
-
);
|
|
203
|
-
return parseTestFailures(err, timeout);
|
|
375
|
+
const outcome = await execStep(command, { worktreePath, timeout, sandbox });
|
|
376
|
+
if (outcome.ok) return [];
|
|
377
|
+
if (outcome.sandboxError) {
|
|
378
|
+
// #688's rule — a non-pass must never yield [] — with the reason being the
|
|
379
|
+
// sandbox rather than a test that never ran. Reporting a phantom assertion
|
|
380
|
+
// here would send someone to read a suite that produced no output.
|
|
381
|
+
log.error(TAG, `Tests not verified: ${outcome.sandboxError}`);
|
|
382
|
+
return [`Test run did not happen: ${outcome.sandboxError}`];
|
|
204
383
|
}
|
|
384
|
+
// The suite's own output is the diagnostic — put its tail in the run log,
|
|
385
|
+
// not only the parsed lines that become subtasks.
|
|
386
|
+
const output = combineOutput(outcome.err);
|
|
387
|
+
log.warn(
|
|
388
|
+
TAG,
|
|
389
|
+
`Test run failed:\n${output.slice(-4000) || "(no output captured)"}`,
|
|
390
|
+
);
|
|
391
|
+
return parseTestFailures(outcome.err, timeout);
|
|
205
392
|
}
|
|
206
393
|
|
|
207
394
|
/**
|
|
@@ -216,35 +403,42 @@ export function runTests(worktreePath: string, timeout: number): string[] {
|
|
|
216
403
|
* rule) is logged, never thrown. This does NOT change the lint-warn-only
|
|
217
404
|
* policy — the later `runLint` still reports whatever the fixer left behind.
|
|
218
405
|
*/
|
|
219
|
-
export function runFormatFix(
|
|
406
|
+
export async function runFormatFix(
|
|
220
407
|
worktreePath: string,
|
|
221
408
|
timeout: number,
|
|
222
409
|
workerId: number,
|
|
223
|
-
|
|
410
|
+
sandbox?: VerificationSandbox,
|
|
411
|
+
): Promise<void> {
|
|
224
412
|
const command = formatFixCommand(worktreePath);
|
|
225
413
|
if (!command) return;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
cwd: worktreePath,
|
|
229
|
-
timeout,
|
|
230
|
-
stdio: "pipe",
|
|
231
|
-
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
232
|
-
});
|
|
414
|
+
const outcome = await execStep(command, { worktreePath, timeout, sandbox });
|
|
415
|
+
if (outcome.ok) {
|
|
233
416
|
log.info(
|
|
234
417
|
TAG,
|
|
235
418
|
`[worker:${workerId}] Auto-formatted worktree before commit/push`,
|
|
236
419
|
);
|
|
237
|
-
|
|
238
|
-
log.warn(
|
|
239
|
-
TAG,
|
|
240
|
-
`[worker:${workerId}] Auto-format step exited non-zero (non-fatal): ${
|
|
241
|
-
err instanceof Error ? err.message : String(err)
|
|
242
|
-
}`,
|
|
243
|
-
);
|
|
420
|
+
return;
|
|
244
421
|
}
|
|
422
|
+
// Best-effort by contract: this step is tied to the lint step and never
|
|
423
|
+
// blocks. A broken container is one more reason it did not run, reported at
|
|
424
|
+
// the same level as a fixer that exited non-zero — the later `runLint` still
|
|
425
|
+
// reports whatever was left behind.
|
|
426
|
+
const why = outcome.sandboxError
|
|
427
|
+
? outcome.sandboxError
|
|
428
|
+
: outcome.err instanceof Error
|
|
429
|
+
? outcome.err.message
|
|
430
|
+
: String(outcome.err);
|
|
431
|
+
log.warn(
|
|
432
|
+
TAG,
|
|
433
|
+
`[worker:${workerId}] Auto-format step did not complete (non-fatal): ${why}`,
|
|
434
|
+
);
|
|
245
435
|
}
|
|
246
436
|
|
|
247
|
-
export function runLint(
|
|
437
|
+
export async function runLint(
|
|
438
|
+
worktreePath: string,
|
|
439
|
+
timeout: number,
|
|
440
|
+
sandbox?: VerificationSandbox,
|
|
441
|
+
): Promise<string[]> {
|
|
248
442
|
const command = lintCommand(worktreePath);
|
|
249
443
|
if (!command) {
|
|
250
444
|
log.info(
|
|
@@ -253,17 +447,16 @@ export function runLint(worktreePath: string, timeout: number): string[] {
|
|
|
253
447
|
);
|
|
254
448
|
return [];
|
|
255
449
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
});
|
|
263
|
-
return [];
|
|
264
|
-
} catch (err: unknown) {
|
|
265
|
-
return parseErrorOutput(err);
|
|
450
|
+
const outcome = await execStep(command, { worktreePath, timeout, sandbox });
|
|
451
|
+
if (outcome.ok) return [];
|
|
452
|
+
if (outcome.sandboxError) {
|
|
453
|
+
// Lint is warn-only, so this is reported as a warning line rather than
|
|
454
|
+
// blocking — but it must still say the step did not run, because an empty
|
|
455
|
+
// lint result is indistinguishable from a clean one.
|
|
456
|
+
log.error(TAG, `Lint not verified: ${outcome.sandboxError}`);
|
|
457
|
+
return [`Lint did not run: ${outcome.sandboxError}`];
|
|
266
458
|
}
|
|
459
|
+
return parseErrorOutput(outcome.err);
|
|
267
460
|
}
|
|
268
461
|
|
|
269
462
|
export async function runDeepReview(
|
|
@@ -290,6 +483,19 @@ export async function runDeepReview(
|
|
|
290
483
|
devServer = spawn(cmd, args, {
|
|
291
484
|
cwd: worktreePath,
|
|
292
485
|
stdio: ["ignore", "pipe", "pipe"],
|
|
486
|
+
// Contained (#988), and it was the one spawn in this file that was not.
|
|
487
|
+
// `spawnRunArgs("dev", …)` resolves the worktree's OWN `dev` script,
|
|
488
|
+
// which the contained implement run may rewrite, so this is the same
|
|
489
|
+
// attacker-choosable command as `runBuild`/`runTests` — except it ran
|
|
490
|
+
// with the daemon's full environment, `HARMONY_API_KEY` and
|
|
491
|
+
// `GITHUB_PERSONAL_ACCESS_TOKEN` included, while `changelog.ts` told
|
|
492
|
+
// users the verification commands run "without your credentials".
|
|
493
|
+
//
|
|
494
|
+
// A reduction, not a fix: the command still executes outside the sandbox
|
|
495
|
+
// with network. That is why the dev server is now NAMED in the residual
|
|
496
|
+
// list in `run-containment.ts` and in `docs/agent-daemon.md`, instead of
|
|
497
|
+
// being left implied by a list of four other scripts.
|
|
498
|
+
env: containedEnv(),
|
|
293
499
|
});
|
|
294
500
|
|
|
295
501
|
// Wait for dev server to be ready, then confirm it answers HTTP.
|
|
@@ -311,7 +517,7 @@ export async function runDeepReview(
|
|
|
311
517
|
// "(unable to retrieve diff)" — reviewing the change with no change (#701).
|
|
312
518
|
diff = execFileSync(
|
|
313
519
|
"git",
|
|
314
|
-
["diff", `origin/${config.worktree.baseBranch}..HEAD`],
|
|
520
|
+
[...GIT_NO_HOOKS, "diff", `origin/${config.worktree.baseBranch}..HEAD`],
|
|
315
521
|
{
|
|
316
522
|
cwd: worktreePath,
|
|
317
523
|
encoding: "utf-8",
|
|
@@ -336,7 +542,6 @@ export async function runDeepReview(
|
|
|
336
542
|
"```",
|
|
337
543
|
].join("\n");
|
|
338
544
|
|
|
339
|
-
const leanSources = config.claude.leanSettingSources;
|
|
340
545
|
const output = execFileSync(
|
|
341
546
|
"claude",
|
|
342
547
|
[
|
|
@@ -345,8 +550,16 @@ export async function runDeepReview(
|
|
|
345
550
|
"sonnet",
|
|
346
551
|
"--max-turns",
|
|
347
552
|
"10",
|
|
348
|
-
//
|
|
349
|
-
|
|
553
|
+
// Contained (#988). This spawn reads a diff the implement run wrote, in
|
|
554
|
+
// that run's own worktree, so it is downstream of untrusted text by
|
|
555
|
+
// construction — and it used to run with no sandbox and no credential
|
|
556
|
+
// deny at all. It has no `--allowedTools` of its own, which bounds it
|
|
557
|
+
// less than it looks: the default set still reads files.
|
|
558
|
+
...implementRunContainmentCliArgs({
|
|
559
|
+
worktree: worktreePath,
|
|
560
|
+
// Reads a diff and reports findings; it writes nothing.
|
|
561
|
+
readOnly: true,
|
|
562
|
+
}),
|
|
350
563
|
"--",
|
|
351
564
|
reviewPrompt,
|
|
352
565
|
],
|
|
@@ -356,6 +569,7 @@ export async function runDeepReview(
|
|
|
356
569
|
timeout: config.verification.timeout,
|
|
357
570
|
stdio: "pipe",
|
|
358
571
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
572
|
+
env: containedEnv(),
|
|
359
573
|
},
|
|
360
574
|
);
|
|
361
575
|
|
|
@@ -396,7 +610,6 @@ export function attemptAutoFix(
|
|
|
396
610
|
"```",
|
|
397
611
|
].join("\n");
|
|
398
612
|
|
|
399
|
-
const leanSources = config.claude.leanSettingSources;
|
|
400
613
|
const args = [
|
|
401
614
|
"--print",
|
|
402
615
|
"--model",
|
|
@@ -405,8 +618,23 @@ export function attemptAutoFix(
|
|
|
405
618
|
"50",
|
|
406
619
|
"--allowedTools",
|
|
407
620
|
"Bash,Read,Write,Edit,Glob,Grep",
|
|
408
|
-
//
|
|
409
|
-
|
|
621
|
+
// Contained exactly like the implement run (#988). This spawn used to pass
|
|
622
|
+
// `--setting-sources local,user` and nothing else: no sandbox, no
|
|
623
|
+
// credential denies, no env strip — measured reading
|
|
624
|
+
// `~/.harmony-mcp/config.json` (the Harmony API key), reaching the open
|
|
625
|
+
// internet and writing to `$HOME`.
|
|
626
|
+
//
|
|
627
|
+
// It is not an edge path. `maxFixAttempts` defaults to 1, so a failing
|
|
628
|
+
// build reaches here on an ordinary run — and whether the build fails is
|
|
629
|
+
// under the CONTAINED run's control, which made this the cheap way out of
|
|
630
|
+
// the containment: break the build, get an uncontained spawn in the same
|
|
631
|
+
// worktree, on the same branch.
|
|
632
|
+
//
|
|
633
|
+
// `leanSettingSources` is deliberately no longer read here. It exists to
|
|
634
|
+
// drop the project docs an auto-fix does not need (#348), and the
|
|
635
|
+
// containment's `settingSources` already excludes `user`; honouring both
|
|
636
|
+
// would let the operator's config widen a boundary.
|
|
637
|
+
...implementRunContainmentCliArgs({ worktree: worktreePath }),
|
|
410
638
|
"--",
|
|
411
639
|
fixPrompt,
|
|
412
640
|
];
|
|
@@ -419,6 +647,7 @@ export function attemptAutoFix(
|
|
|
419
647
|
timeout: config.verification.timeout,
|
|
420
648
|
stdio: "pipe",
|
|
421
649
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
650
|
+
env: containedEnv(),
|
|
422
651
|
});
|
|
423
652
|
}
|
|
424
653
|
|