@gethmy/harness 1.5.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 +218 -94
- package/dist/index.js +235 -205
- package/package.json +1 -1
- package/src/exec-types.ts +64 -0
- package/src/gate-collectors.ts +30 -4
- package/src/run-containment.ts +38 -18
- package/src/verification.ts +240 -88
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Execution motor for Harmony playbook stages. Runs exactly one stage per invocation: worktree, role-separated subagents, held oracle, gate evidence. It never routes, never judges, never pushes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
package/src/exec-types.ts
CHANGED
|
@@ -95,9 +95,73 @@ export interface VerificationConfig {
|
|
|
95
95
|
* report a timeout as a test failure.
|
|
96
96
|
*/
|
|
97
97
|
testTimeout: number;
|
|
98
|
+
/**
|
|
99
|
+
* Container image for the verification steps (#1036). Empty — the default —
|
|
100
|
+
* runs them in the daemon process as before.
|
|
101
|
+
*
|
|
102
|
+
* `runBuild` / `runTests` / `runFormatFix` / `runLint` execute
|
|
103
|
+
* `bun run <script>` out of the WORKTREE'S OWN `package.json`, which a
|
|
104
|
+
* contained implement run is allowed to rewrite (the worktree is inside
|
|
105
|
+
* `sandbox.filesystem.allowWrite` by design). #988 gave those spawns
|
|
106
|
+
* `containedEnv()`, which removes the secret-shaped variables and was
|
|
107
|
+
* stated there as a reduction and not a fix: the command still ran as the
|
|
108
|
+
* operator and could read `~/.ssh` and `~/.harmony-mcp/config.json` off
|
|
109
|
+
* disk. Setting this moves them into `repair-sandbox.ts`'s container, where
|
|
110
|
+
* the only mount is the worktree and there is no network.
|
|
111
|
+
*
|
|
112
|
+
* **Opt-in on purpose, unlike #988's own containment.** That one could ship
|
|
113
|
+
* on by default because the SDK sandbox is inside the CLI. This one needs a
|
|
114
|
+
* reachable Docker AND an image carrying the repo's toolchain, so a default
|
|
115
|
+
* would stop verification dead on every host that has neither. There is
|
|
116
|
+
* deliberately no guessed image, for the reason `types.ts` already records
|
|
117
|
+
* for the repair path: a container that cannot build anything reports every
|
|
118
|
+
* run as unverified.
|
|
119
|
+
*
|
|
120
|
+
* The image must carry the toolchain — the container has no network, so
|
|
121
|
+
* nothing installs at verification time. Dependencies are already present:
|
|
122
|
+
* `worktree.ts` installs them at worktree creation with `--ignore-scripts`.
|
|
123
|
+
* Point this at whatever your CI uses.
|
|
124
|
+
*
|
|
125
|
+
* Does NOT cover the two `dev` servers (`runDeepReview` and the review
|
|
126
|
+
* worker's). Those are probed over HTTP from the daemon, so a no-network
|
|
127
|
+
* container is unreachable by construction; they remain in
|
|
128
|
+
* `run-containment.ts`'s residual list.
|
|
129
|
+
*/
|
|
130
|
+
sandboxImage: string;
|
|
98
131
|
};
|
|
99
132
|
}
|
|
100
133
|
|
|
134
|
+
/**
|
|
135
|
+
* A configured verification container (#1036), or `undefined` for host
|
|
136
|
+
* execution. Resolved once by the caller from {@link VerificationConfig} so the
|
|
137
|
+
* four steps need no config import and stay unit-testable.
|
|
138
|
+
*/
|
|
139
|
+
export interface VerificationSandbox {
|
|
140
|
+
/** Image carrying the repo's toolchain. Empty means "not configured". */
|
|
141
|
+
image: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Head-room added to a step's own timeout when it runs in a container (#1036).
|
|
146
|
+
*
|
|
147
|
+
* There is deliberately NO separate sandbox timeout knob. Each step already
|
|
148
|
+
* carries the cap its operator chose — `verification.timeout` for build, lint
|
|
149
|
+
* and format, `verification.testTimeout` for the suite, and those two are
|
|
150
|
+
* separate precisely because a real suite outruns a build. A single
|
|
151
|
+
* container-wide cap would silently replace all four: with the values this repo
|
|
152
|
+
* ships, turning an image on would have taken build and lint from 2 minutes to
|
|
153
|
+
* 10, and would have ignored a `testTimeout` an operator had narrowed for a
|
|
154
|
+
* fast suite. Switching on containment must not change what any other setting
|
|
155
|
+
* means.
|
|
156
|
+
*
|
|
157
|
+
* So the step's own timeout wins, and this covers only what the container adds
|
|
158
|
+
* on top: image resolution and process start. Measured at ~5.1 s on macOS /
|
|
159
|
+
* Docker Desktop with a warm image; 60 s is that with an order of magnitude of
|
|
160
|
+
* head-room for a slower host or a cold layer, and it is not a budget for the
|
|
161
|
+
* command, which has already been capped by the time it is added.
|
|
162
|
+
*/
|
|
163
|
+
export const SANDBOX_STARTUP_GRACE_MS = 60_000;
|
|
164
|
+
|
|
101
165
|
/** The slice of the daemon's config that the git helpers actually read. */
|
|
102
166
|
export interface WorktreeConfig {
|
|
103
167
|
worktree: VerificationConfig["worktree"];
|
package/src/gate-collectors.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
CommandMetricCollector,
|
|
47
47
|
type CommandMetricDeps,
|
|
48
48
|
} from "./command-metric.js";
|
|
49
|
+
import type { VerificationSandbox } from "./exec-types.js";
|
|
49
50
|
import { log } from "./log.js";
|
|
50
51
|
import type { OracleDeps } from "./oracle.js";
|
|
51
52
|
import { OracleCollector, OracleRedCollector } from "./oracle-collector.js";
|
|
@@ -152,10 +153,25 @@ export interface BuildGreenDeps {
|
|
|
152
153
|
worktreePath: string;
|
|
153
154
|
buildTimeout: number;
|
|
154
155
|
lintTimeout: number;
|
|
156
|
+
/**
|
|
157
|
+
* Verification container (#1036). Passed straight through to `runBuild` /
|
|
158
|
+
* `runLint`, so the gate's build runs wherever the daemon's own verification
|
|
159
|
+
* runs — a gate that ran the worktree's scripts in this process while
|
|
160
|
+
* `runVerification` contained them would be the hole under a different name.
|
|
161
|
+
*/
|
|
162
|
+
sandbox?: VerificationSandbox;
|
|
155
163
|
/** Inject for tests; defaults to verification.ts `runBuild`. Returns error lines. */
|
|
156
|
-
runBuild?: (
|
|
164
|
+
runBuild?: (
|
|
165
|
+
worktreePath: string,
|
|
166
|
+
timeout: number,
|
|
167
|
+
sandbox?: VerificationSandbox,
|
|
168
|
+
) => string[] | Promise<string[]>;
|
|
157
169
|
/** Inject for tests; defaults to verification.ts `runLint`. Returns warning lines. */
|
|
158
|
-
runLint?: (
|
|
170
|
+
runLint?: (
|
|
171
|
+
worktreePath: string,
|
|
172
|
+
timeout: number,
|
|
173
|
+
sandbox?: VerificationSandbox,
|
|
174
|
+
) => string[] | Promise<string[]>;
|
|
159
175
|
}
|
|
160
176
|
|
|
161
177
|
/**
|
|
@@ -192,8 +208,18 @@ export class BuildGreenCollector implements GateEvidenceCollector {
|
|
|
192
208
|
async collect(_context: GateEvidenceContext): Promise<GateEvidence> {
|
|
193
209
|
const doBuild = this.deps.runBuild ?? runBuild;
|
|
194
210
|
const doLint = this.deps.runLint ?? runLint;
|
|
195
|
-
|
|
196
|
-
|
|
211
|
+
// Awaited: both are async since #1036, and an injected test double may
|
|
212
|
+
// still be sync — `await` accepts either.
|
|
213
|
+
const buildErrors = await doBuild(
|
|
214
|
+
this.deps.worktreePath,
|
|
215
|
+
this.deps.buildTimeout,
|
|
216
|
+
this.deps.sandbox,
|
|
217
|
+
);
|
|
218
|
+
const lintWarnings = await doLint(
|
|
219
|
+
this.deps.worktreePath,
|
|
220
|
+
this.deps.lintTimeout,
|
|
221
|
+
this.deps.sandbox,
|
|
222
|
+
);
|
|
197
223
|
const buildPassed = buildErrors.length === 0;
|
|
198
224
|
const lintPassed = lintWarnings.length === 0;
|
|
199
225
|
// Match verification.ts: only a failing BUILD makes the gate red. Lint
|
package/src/run-containment.ts
CHANGED
|
@@ -78,24 +78,44 @@
|
|
|
78
78
|
* was found by a review round rather than by reading the code, and a residual
|
|
79
79
|
* nobody wrote down is indistinguishable from one nobody saw.
|
|
80
80
|
*
|
|
81
|
-
* - **
|
|
82
|
-
*
|
|
83
|
-
* `runTests`, `runFormatFix` and `runLint`
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* the
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
81
|
+
* - **The two `dev` servers, plus the standalone harness CLI.** #1036 closed
|
|
82
|
+
* the four verification steps AND the `build_green` gate collector, which
|
|
83
|
+
* re-runs the same two scripts: `runBuild`, `runTests`, `runFormatFix` and `runLint`
|
|
84
|
+
* now run inside `repair-sandbox.ts`'s container when
|
|
85
|
+
* `verification.sandboxImage` is set — worktree the only mount, no network,
|
|
86
|
+
* no host fallback. What is left of this bullet is `runDeepReview`'s dev
|
|
87
|
+
* server and the review worker's own. Both are the same `spawnRunArgs`
|
|
88
|
+
* shape, and both are **probed over HTTP from the daemon**, so
|
|
89
|
+
* `--network=none` makes them unreachable by construction: containing them
|
|
90
|
+
* needs a different answer, not the same one applied twice. They keep
|
|
91
|
+
* `containedEnv()`, so the secret-shaped environment is gone; the command is
|
|
92
|
+
* still the run's to choose and still executes in the daemon process.
|
|
93
|
+
*
|
|
94
|
+
* The containment for the four is **opt-in**, so on a host with no
|
|
95
|
+
* `sandboxImage` all six remain as described above. That is a deliberate
|
|
96
|
+
* asymmetry with this module's own containment, which defaults on: the SDK
|
|
97
|
+
* sandbox is inside the CLI, whereas a container needs a reachable Docker
|
|
98
|
+
* and an image carrying the repo's toolchain.
|
|
99
|
+
*
|
|
100
|
+
* `packages/harmony-harness/src/cli.ts` builds the same collector and passes
|
|
101
|
+
* no image, because the motor holds no operator config of its own — there is
|
|
102
|
+
* nothing for it to read. That path is driven by a person running the CLI
|
|
103
|
+
* against their own checkout rather than by board text, so it is a different
|
|
104
|
+
* threat model, but it is named here rather than left to be rediscovered.
|
|
105
|
+
*
|
|
106
|
+
* The count is in this bullet on purpose, and it has now been wrong twice.
|
|
107
|
+
* The first version named the four verification steps and stopped, and both
|
|
108
|
+
* dev servers then shipped with the daemon's whole environment while the
|
|
109
|
+
* changelog told users these commands run "without your credentials". The
|
|
110
|
+
* second version — #1036's own — said "the two dev servers" while the
|
|
111
|
+
* `build_green` gate ran the worktree's script on the host for any operator
|
|
112
|
+
* who had set an image precisely to stop that. Both were caught by a reader,
|
|
113
|
+
* not by a gate.
|
|
114
|
+
* `spawn-run-containment.test.ts` scans for the environment property instead
|
|
115
|
+
* of trusting this prose, and `verification-sandbox.test.ts` asserts that a
|
|
116
|
+
* configured image takes the four out of this process entirely — because the
|
|
117
|
+
* residual is a completeness claim over CALL SITES and a hand-list has
|
|
118
|
+
* already lost it once.
|
|
99
119
|
* - **The MCP surface.** The sandbox governs commands. MCP stdio servers are
|
|
100
120
|
* hosted by the CLI and are not sandboxed, so `mcp__harmony__*` is bounded by
|
|
101
121
|
* the tool allow-list and the daemon-owned denials (#525/#576), not by this.
|
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,6 +14,7 @@ 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";
|
|
14
19
|
import {
|
|
15
20
|
containedEnv,
|
|
@@ -63,6 +68,11 @@ export async function runVerification(
|
|
|
63
68
|
revertWarnings: [],
|
|
64
69
|
};
|
|
65
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
|
+
|
|
66
76
|
if (config.verification.revertGuard) {
|
|
67
77
|
log.info(TAG, `[worker:${workerId}] Checking for reverted merged work...`);
|
|
68
78
|
const deletedTests = findDeletedTestFiles(
|
|
@@ -89,7 +99,11 @@ export async function runVerification(
|
|
|
89
99
|
|
|
90
100
|
if (config.verification.build) {
|
|
91
101
|
log.info(TAG, `[worker:${workerId}] Running build...`);
|
|
92
|
-
result.buildErrors = runBuild(
|
|
102
|
+
result.buildErrors = await runBuild(
|
|
103
|
+
worktreePath,
|
|
104
|
+
config.verification.timeout,
|
|
105
|
+
sandbox,
|
|
106
|
+
);
|
|
93
107
|
if (result.buildErrors.length > 0) {
|
|
94
108
|
log.warn(
|
|
95
109
|
TAG,
|
|
@@ -107,9 +121,10 @@ export async function runVerification(
|
|
|
107
121
|
// still gates the branch.
|
|
108
122
|
if (config.verification.test && result.buildErrors.length === 0) {
|
|
109
123
|
log.info(TAG, `[worker:${workerId}] Running tests...`);
|
|
110
|
-
result.testFailures = runTests(
|
|
124
|
+
result.testFailures = await runTests(
|
|
111
125
|
worktreePath,
|
|
112
126
|
config.verification.testTimeout,
|
|
127
|
+
sandbox,
|
|
113
128
|
);
|
|
114
129
|
if (result.testFailures.length > 0) {
|
|
115
130
|
log.warn(
|
|
@@ -125,7 +140,11 @@ export async function runVerification(
|
|
|
125
140
|
|
|
126
141
|
if (config.verification.lint) {
|
|
127
142
|
log.info(TAG, `[worker:${workerId}] Running lint...`);
|
|
128
|
-
result.lintWarnings = runLint(
|
|
143
|
+
result.lintWarnings = await runLint(
|
|
144
|
+
worktreePath,
|
|
145
|
+
config.verification.timeout,
|
|
146
|
+
sandbox,
|
|
147
|
+
);
|
|
129
148
|
if (result.lintWarnings.length > 0) {
|
|
130
149
|
log.warn(
|
|
131
150
|
TAG,
|
|
@@ -154,46 +173,151 @@ export async function runVerification(
|
|
|
154
173
|
}
|
|
155
174
|
|
|
156
175
|
/**
|
|
157
|
-
* ## The verification steps run the RUN'S OWN scripts
|
|
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.
|
|
158
185
|
*
|
|
159
|
-
* `
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* then run it, outside the sandbox, with full network. No failure is needed to
|
|
167
|
-
* trigger this; verification runs on every successful run.
|
|
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.
|
|
168
193
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* reach the network.
|
|
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:
|
|
174
198
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* solved for the CI repair with a container, and it is a piece of work of its
|
|
178
|
-
* own rather than a line in this one. Until then this is listed in
|
|
179
|
-
* `run-containment.ts`'s "what this does not cover" and in `docs/agent-daemon.md`,
|
|
180
|
-
* because a residual nobody wrote down is indistinguishable from one nobody saw.
|
|
199
|
+
* uncontained 0.38 / 0.35 / 0.34 s
|
|
200
|
+
* contained 5.53 / 5.42 / 5.41 s -> ~5.1 s added per command
|
|
181
201
|
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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.
|
|
187
239
|
*/
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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: "" } };
|
|
196
319
|
}
|
|
320
|
+
|
|
197
321
|
try {
|
|
198
322
|
execFileSync(command.cmd, command.args, {
|
|
199
323
|
cwd: worktreePath,
|
|
@@ -202,18 +326,44 @@ export function runBuild(worktreePath: string, timeout: number): string[] {
|
|
|
202
326
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
203
327
|
env: containedEnv(),
|
|
204
328
|
});
|
|
205
|
-
return
|
|
329
|
+
return { ok: true };
|
|
206
330
|
} catch (err: unknown) {
|
|
207
|
-
return
|
|
331
|
+
return { ok: false, err };
|
|
208
332
|
}
|
|
209
333
|
}
|
|
210
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
|
+
|
|
211
357
|
/**
|
|
212
358
|
* Run the repo's own test suite. Returns the failures (empty = passed, or no
|
|
213
359
|
* suite to run). A non-zero exit ALWAYS yields at least one entry — an
|
|
214
360
|
* unparsable failure must never read as a pass (#688).
|
|
215
361
|
*/
|
|
216
|
-
export function runTests(
|
|
362
|
+
export async function runTests(
|
|
363
|
+
worktreePath: string,
|
|
364
|
+
timeout: number,
|
|
365
|
+
sandbox?: VerificationSandbox,
|
|
366
|
+
): Promise<string[]> {
|
|
217
367
|
const command = testCommand(worktreePath);
|
|
218
368
|
if (!command) {
|
|
219
369
|
log.warn(
|
|
@@ -222,25 +372,23 @@ export function runTests(worktreePath: string, timeout: number): string[] {
|
|
|
222
372
|
);
|
|
223
373
|
return [];
|
|
224
374
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
return [];
|
|
234
|
-
} catch (err: unknown) {
|
|
235
|
-
// The suite's own output is the diagnostic — put its tail in the run log,
|
|
236
|
-
// not only the parsed lines that become subtasks.
|
|
237
|
-
const output = combineOutput(err);
|
|
238
|
-
log.warn(
|
|
239
|
-
TAG,
|
|
240
|
-
`Test run failed:\n${output.slice(-4000) || "(no output captured)"}`,
|
|
241
|
-
);
|
|
242
|
-
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}`];
|
|
243
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);
|
|
244
392
|
}
|
|
245
393
|
|
|
246
394
|
/**
|
|
@@ -255,36 +403,42 @@ export function runTests(worktreePath: string, timeout: number): string[] {
|
|
|
255
403
|
* rule) is logged, never thrown. This does NOT change the lint-warn-only
|
|
256
404
|
* policy — the later `runLint` still reports whatever the fixer left behind.
|
|
257
405
|
*/
|
|
258
|
-
export function runFormatFix(
|
|
406
|
+
export async function runFormatFix(
|
|
259
407
|
worktreePath: string,
|
|
260
408
|
timeout: number,
|
|
261
409
|
workerId: number,
|
|
262
|
-
|
|
410
|
+
sandbox?: VerificationSandbox,
|
|
411
|
+
): Promise<void> {
|
|
263
412
|
const command = formatFixCommand(worktreePath);
|
|
264
413
|
if (!command) return;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
cwd: worktreePath,
|
|
268
|
-
timeout,
|
|
269
|
-
stdio: "pipe",
|
|
270
|
-
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
271
|
-
env: containedEnv(),
|
|
272
|
-
});
|
|
414
|
+
const outcome = await execStep(command, { worktreePath, timeout, sandbox });
|
|
415
|
+
if (outcome.ok) {
|
|
273
416
|
log.info(
|
|
274
417
|
TAG,
|
|
275
418
|
`[worker:${workerId}] Auto-formatted worktree before commit/push`,
|
|
276
419
|
);
|
|
277
|
-
|
|
278
|
-
log.warn(
|
|
279
|
-
TAG,
|
|
280
|
-
`[worker:${workerId}] Auto-format step exited non-zero (non-fatal): ${
|
|
281
|
-
err instanceof Error ? err.message : String(err)
|
|
282
|
-
}`,
|
|
283
|
-
);
|
|
420
|
+
return;
|
|
284
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
|
+
);
|
|
285
435
|
}
|
|
286
436
|
|
|
287
|
-
export function runLint(
|
|
437
|
+
export async function runLint(
|
|
438
|
+
worktreePath: string,
|
|
439
|
+
timeout: number,
|
|
440
|
+
sandbox?: VerificationSandbox,
|
|
441
|
+
): Promise<string[]> {
|
|
288
442
|
const command = lintCommand(worktreePath);
|
|
289
443
|
if (!command) {
|
|
290
444
|
log.info(
|
|
@@ -293,18 +447,16 @@ export function runLint(worktreePath: string, timeout: number): string[] {
|
|
|
293
447
|
);
|
|
294
448
|
return [];
|
|
295
449
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
return [];
|
|
305
|
-
} catch (err: unknown) {
|
|
306
|
-
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}`];
|
|
307
458
|
}
|
|
459
|
+
return parseErrorOutput(outcome.err);
|
|
308
460
|
}
|
|
309
461
|
|
|
310
462
|
export async function runDeepReview(
|