@gethmy/harness 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/harness",
3
- "version": "1.5.0",
3
+ "version": "1.7.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",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@anthropic-ai/claude-agent-sdk": "^0.3.178",
58
- "@gethmy/mcp": "3.2.0",
58
+ "@gethmy/mcp": "3.3.0",
59
59
  "@supabase/supabase-js": "2.95.3"
60
60
  },
61
61
  "devDependencies": {
package/src/cli.ts CHANGED
@@ -241,8 +241,28 @@ async function main(): Promise<void> {
241
241
  process.stderr.write(`${parsed.message}\n${STAGE_RUN_USAGE}\n`);
242
242
  process.exit(2);
243
243
  }
244
- const { cardId, stageId, workspaceId, repoPath, sessionId, metricsPath } =
245
- parsed.args;
244
+ const {
245
+ cardId,
246
+ stageId,
247
+ workspaceId,
248
+ repoPath,
249
+ sessionId,
250
+ metricsPath,
251
+ sandboxImage,
252
+ } = parsed.args;
253
+
254
+ // The DRIVER's container for everything this motor runs against the worktree
255
+ // (#1021): the held test, and the `build_green` gate's build and lint. One
256
+ // value, every gate command — an operator who set an image to keep untrusted
257
+ // code off their host did not mean "except this one".
258
+ //
259
+ // Absent ⇒ `undefined`, which is host execution exactly as before. Trimmed and
260
+ // re-checked for emptiness here as well as in the flag parser, because docker
261
+ // reads an empty image name as a missing argument and fails in a way that
262
+ // reads like a broken runtime.
263
+ const gateSandbox = sandboxImage?.trim()
264
+ ? { image: sandboxImage.trim() }
265
+ : undefined;
246
266
 
247
267
  // The driver's metric allowlist for `custom` gates (--metrics, optional).
248
268
  // Read + validated BEFORE anything runs: a driver that passed the flag wants
@@ -344,6 +364,12 @@ async function main(): Promise<void> {
344
364
  worktreePath: req.repoPath,
345
365
  buildTimeout: GATE_VERIFICATION_TIMEOUT_MS,
346
366
  lintTimeout: GATE_VERIFICATION_TIMEOUT_MS,
367
+ // #1021 closes the residual #1036 recorded here: the motor used to
368
+ // hold no operator config, so this gate re-ran the worktree's own
369
+ // build and lint scripts in the motor process while the daemon's
370
+ // identical gate ran them in a container. The driver passes the image
371
+ // now, so both paths honour one setting.
372
+ sandbox: gateSandbox,
347
373
  },
348
374
  // The metric allowlist is the DRIVER's, passed via --metrics — the
349
375
  // motor still holds no operator config of its own. Without the flag
@@ -361,6 +387,12 @@ async function main(): Promise<void> {
361
387
  oracle: {
362
388
  repoPath: req.repoPath,
363
389
  sessionId: req.sessionId,
390
+ // #1021. The held test is arbitrary code an untrusted author wrote,
391
+ // and this is the only place a container can be put around it — the
392
+ // oracle gates run in the motor and nowhere else (the daemon's own
393
+ // registry wires no oracle collector, by design: it cannot provide
394
+ // the actor separation these gates bind on).
395
+ sandbox: gateSandbox,
364
396
  // Only `oracle_red` reads this (its own stage is not the key); the
365
397
  // green collector addresses the row by `context.stageId`.
366
398
  targetStageId: oracleTargetStageId,
package/src/exec-types.ts CHANGED
@@ -95,9 +95,102 @@ 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
+ * **It covers the HELD TEST too since #1021**, and that is the one command
126
+ * here whose *input* is written by an untrusted author rather than being a
127
+ * script a contained run may have rewritten. The daemon forwards this value
128
+ * to the harness motor (`--sandbox-image`), which is where both held-test
129
+ * gates run, so one setting covers every command the gate machinery executes
130
+ * against a worktree. The image must carry the RUNNER the oracle's
131
+ * `runner_hint` names, not merely a toolchain — measured, `oven/bun:1` has
132
+ * `bun` and `node` but no `npx`, so a `vitest`-hinted held test in it reports
133
+ * the gate `blocked` with a message naming the image.
134
+ *
135
+ * Cost of the held-test half, measured on macOS / Docker Desktop with a warm
136
+ * image: ~0.14 s added for a trivial held test and ~1.4 s for one that
137
+ * resolves a dependency out of a real `node_modules`, twice per card. Far
138
+ * below the ~5.1 s per verification command above, because a held test reads
139
+ * one file and its imports rather than running the repo's whole build.
140
+ *
141
+ * **And the two `dev` servers since #1037** (`runDeepReview`'s and the
142
+ * review worker's) — one key, every command the daemon runs against a
143
+ * worktree, because the image requirement is the same one and an operator
144
+ * who set a second key but not this one would get a half-contained daemon.
145
+ *
146
+ * Their container is the same in every respect but the network: a dev
147
+ * server exists to be connected to, so it publishes `127.0.0.1:<port>`
148
+ * rather than taking `--network=none`, and therefore keeps egress. That is
149
+ * the one place this setting buys less than it does for the four
150
+ * verification steps and the held test, and `devServerSandboxArgs` says why
151
+ * no flag combination avoids it.
152
+ *
153
+ * Setting this makes Docker load-bearing for review, not only for
154
+ * verification: the dev server then starts through `docker run`, and a host
155
+ * that cannot start it fails the same way an unstartable host server
156
+ * already does — the review worker keeps the card in Review and labels it
157
+ * for a human rather than bouncing it back for rework.
158
+ */
159
+ sandboxImage: string;
98
160
  };
99
161
  }
100
162
 
163
+ /**
164
+ * A configured verification container (#1036), or `undefined` for host
165
+ * execution. Resolved once by the caller from {@link VerificationConfig} so the
166
+ * four steps need no config import and stay unit-testable.
167
+ */
168
+ export interface VerificationSandbox {
169
+ /** Image carrying the repo's toolchain. Empty means "not configured". */
170
+ image: string;
171
+ }
172
+
173
+ /**
174
+ * Head-room added to a step's own timeout when it runs in a container (#1036).
175
+ *
176
+ * There is deliberately NO separate sandbox timeout knob. Each step already
177
+ * carries the cap its operator chose — `verification.timeout` for build, lint
178
+ * and format, `verification.testTimeout` for the suite, and those two are
179
+ * separate precisely because a real suite outruns a build. A single
180
+ * container-wide cap would silently replace all four: with the values this repo
181
+ * ships, turning an image on would have taken build and lint from 2 minutes to
182
+ * 10, and would have ignored a `testTimeout` an operator had narrowed for a
183
+ * fast suite. Switching on containment must not change what any other setting
184
+ * means.
185
+ *
186
+ * So the step's own timeout wins, and this covers only what the container adds
187
+ * on top: image resolution and process start. Measured at ~5.1 s on macOS /
188
+ * Docker Desktop with a warm image; 60 s is that with an order of magnitude of
189
+ * head-room for a slower host or a cold layer, and it is not a budget for the
190
+ * command, which has already been capped by the time it is added.
191
+ */
192
+ export const SANDBOX_STARTUP_GRACE_MS = 60_000;
193
+
101
194
  /** The slice of the daemon's config that the git helpers actually read. */
102
195
  export interface WorktreeConfig {
103
196
  worktree: VerificationConfig["worktree"];
@@ -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?: (worktreePath: string, timeout: number) => string[];
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?: (worktreePath: string, timeout: number) => string[];
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
- const buildErrors = doBuild(this.deps.worktreePath, this.deps.buildTimeout);
196
- const lintWarnings = doLint(this.deps.worktreePath, this.deps.lintTimeout);
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/git-pr.ts CHANGED
@@ -246,6 +246,19 @@ function isNamedCheck(
246
246
  * reads `success` rather than `unknown`, because `unknown` means "this repo
247
247
  * configures no checks at all" — a repo whose only check is the review has a
248
248
  * green build by that repo's own definition, and the review gates it below.
249
+ *
250
+ * ## SKIPPED is a pass HERE, and that is correct — measured, card #903
251
+ *
252
+ * Folding `SKIPPED` in with `SUCCESS` is right for everything this function is
253
+ * still allowed to speak for. After the exclusion above, that is the build and
254
+ * test jobs, where a skip is a **path filter** doing its job: on the 45 PR heads
255
+ * sampled for #903, `test`, `build` and `EAS Build & TestFlight Submit` all
256
+ * report `SKIPPED` on scope-filtered PRs that are green by every definition
257
+ * their repo has. Failing on those would block every docs-only PR.
258
+ *
259
+ * The reason this is safe is the exclusion, NOT the fold: a review that never
260
+ * fired is a skip that must HOLD, and it does — because its check name is taken
261
+ * out of this verdict and answered by the two readers below.
249
262
  */
250
263
  export function deriveCiStatus(
251
264
  rollup: unknown,
@@ -289,14 +302,21 @@ export function deriveCiStatus(
289
302
  * Whether an INDEPENDENT review — one produced outside the daemon that wrote the
290
303
  * code — has run against the PR's current head (card #1003).
291
304
  *
292
- * `deriveCiStatus` above cannot answer this, and that is the whole bug: it folds
293
- * `SKIPPED` in with `SUCCESS` (see its `["SUCCESS", "NEUTRAL", "SKIPPED"]`
294
- * line). A daemon PR on an `agent/` branch has its review job skipped by the
295
- * workflow's own branch rule, so the rollup carries a real check run reading
305
+ * `deriveCiStatus` above cannot answer this, because it folds `SKIPPED` in with
306
+ * `SUCCESS` (see its `["SUCCESS", "NEUTRAL", "SKIPPED"]` line). A daemon PR on
307
+ * an `agent/` branch has its review job skipped by the workflow's own branch
308
+ * rule, so the rollup carries a real check run reading
296
309
  * `{name: "review", conclusion: "SKIPPED"}` — and the collapsed verdict for that
297
- * PR is `"success"`. The merge gate could not tell "reviewed and clean" from
310
+ * PR was `"success"`. The merge gate could not tell "reviewed and clean" from
298
311
  * "nobody looked".
299
312
  *
313
+ * That was a live bug until #1018, and it is **fixed by the exclusion, not by a
314
+ * change to the fold**: `readPrChecks` takes both review check names out of
315
+ * `deriveCiStatus`, so a skipped review no longer reaches the collapsed word at
316
+ * all. The fold stays, and stays correct, for the build/test jobs it still
317
+ * speaks for. Do not "reconcile" the three answers — see #903 on the ruling
318
+ * that they are three different questions and must keep three different answers.
319
+ *
300
320
  * `absent` and `skipped` are kept apart on purpose. `skipped` means the workflow
301
321
  * exists and declined this branch, which a `ci-review` request can fix. `absent`
302
322
  * means no such check is configured on this repo at all — a request would be
@@ -322,6 +342,28 @@ export type IndependentReviewState =
322
342
  * independent did read this head, which is the question being asked; a pending
323
343
  * re-run outranks the failed run it is retrying, so the gate waits for the
324
344
  * answer instead of acting on the stale one.
345
+ *
346
+ * ## Why "several runs share the name" is the normal case (measured, #903)
347
+ *
348
+ * `statusCheckRollup` **retains every check run on a SHA**. It does not collapse
349
+ * to the most recent run of a name, and nothing supersedes anything. Two
350
+ * mechanisms decide whether a re-review adds an entry or overwrites one:
351
+ *
352
+ * - a **new triggering event** (`labeled` for `ci-review`, `reopened`, …)
353
+ * starts a new workflow run, which publishes a NEW check run of the same
354
+ * name. Both entries persist, forever, side by side.
355
+ * - `gh run rerun` REUSES the run's `databaseId` and UPDATES its check run in
356
+ * place, so that one leaves a single entry.
357
+ *
358
+ * Observed on PR 719 (`134d0a62`), where `review` reads `CANCELLED` and
359
+ * `SUCCESS` together — the cancel came from the review workflow's own
360
+ * `cancel-in-progress` concurrency group, and the success is the re-review that
361
+ * followed it. Note it did not REPLACE it, which is the point. This precedence
362
+ * is what makes that PR readable as `passed`
363
+ * instead of `failed`, and the #1018 exclusion is what keeps the stale
364
+ * `CANCELLED` out of `ciStatus`. On the 45 PR heads sampled, PR 719 is the ONE
365
+ * case where the exclusion changes the collapsed verdict — without it that PR
366
+ * reads `failure` on a branch whose review had actually succeeded.
325
367
  */
326
368
  export function deriveIndependentReviewState(
327
369
  rollup: unknown,
@@ -433,6 +475,12 @@ export type ReviewVerdictState =
433
475
  * because a finding does not stop being real when a later pass over the same
434
476
  * head fails to repeat it. The remedy for a blocking verdict is a commit that
435
477
  * fixes it, which moves the head and asks again with a clean slate.
478
+ *
479
+ * That remedy is not a preference, it is the only one that works. Because the
480
+ * rollup retains every run (measured, #903 — see the note on the reader above),
481
+ * a `blocking` verdict CANNOT be cleared by re-requesting a review on the same
482
+ * head: the re-request publishes a second check run and the failing one stays
483
+ * beside it. Moving the head is the exit.
436
484
  */
437
485
  export function deriveReviewVerdictState(
438
486
  rollup: unknown,
@@ -44,6 +44,7 @@ import {
44
44
  type HeldOracle,
45
45
  type OracleDeps,
46
46
  type OracleRunSummary,
47
+ OracleSandboxConfigError,
47
48
  } from "./oracle.js";
48
49
 
49
50
  const TAG = "oracle-collector";
@@ -142,6 +143,11 @@ abstract class HeldOracleCollector {
142
143
  const { exitCode, output, report } = await this.deps.run(
143
144
  this.deps.repoPath,
144
145
  oracle,
146
+ // #1021. Both polarities take the same container: `oracle_passed` runs
147
+ // the same untrusted file as `oracle_red`, so containing one and not
148
+ // the other would leave the hole under the other gate's name. Only the
149
+ // GRADING differs between them, and that stays where it is.
150
+ { sandbox: this.deps.sandbox },
145
151
  );
146
152
  // Full output is for the human operator's local log only — never the
147
153
  // persisted evidence. See the class doc comment: oracle_passed is a
@@ -161,12 +167,31 @@ abstract class HeldOracleCollector {
161
167
  });
162
168
  } catch (err) {
163
169
  const message = errText(err);
164
- log.warn(TAG, `Oracle run threw: ${message} blocked`);
170
+ // #823 rule 1: opt in, and ONLY for static config. A sandbox that is not
171
+ // answering, or an image that cannot start or does not carry the runner,
172
+ // is the operator's configuration — re-running the stage cannot change the
173
+ // answer, so an unmarked `blocked` would spend the card's whole attempt
174
+ // budget rediscovering it. Every other throw here (a bad path, a timeout,
175
+ // a runner that died) keeps the retryable default, because a next attempt
176
+ // genuinely can differ.
177
+ const configError = err instanceof OracleSandboxConfigError;
178
+ log.warn(
179
+ TAG,
180
+ `Oracle run threw: ${message} — blocked${configError ? " (config)" : ""}`,
181
+ );
165
182
  return {
166
183
  result: "blocked",
167
184
  structured: {
168
185
  oracle: { path: oracle.path, ...identity },
169
186
  error: message,
187
+ // `reason` as well as `error`, and only on this branch: it is the
188
+ // field `gateConfigErrorReason` reads, so without it the board renders
189
+ // the generic "the gate cannot be measured as configured" for a hold
190
+ // whose whole value is naming the image the operator has to fix.
191
+ // `error` stays for every existing reader of this shape.
192
+ ...(configError
193
+ ? { reason: message, ...GATE_CONFIG_ERROR_MARK }
194
+ : {}),
170
195
  },
171
196
  };
172
197
  } finally {
@@ -293,13 +318,18 @@ export class OracleCollector
293
318
  * the forgery.
294
319
  *
295
320
  * HOW FAR THAT GOES: it closes the stream forgeries outright, and it does NOT
296
- * make the verdict unforgeable. The held test runs as the motor's own user and
297
- * can write the report file itself the directory is findable by a fixed name
298
- * prefix, and pre-writing the report then dropping write permission needs no
299
- * race at all. `runHeldOracle`'s mode check refuses that shape; a writer that
300
- * leaves the modes alone still wins. This gate is evidence against a careless
301
- * author, not a hostile one. `oracle.ts` has the full account, measurements
302
- * included, above `OracleRunnerSpec`.
321
+ * make the report FILE unforgeable the held test executes inside the runner's
322
+ * process, so it can write that file on the host and in the container alike.
323
+ * What #1021 added is a claim check rather than an authentication one: neither
324
+ * allow-listed runner exits 0 while reporting a failing test, so a report saying
325
+ * `failed > 0` from a run that exited 0 is refused. That removes the free
326
+ * forgery one file satisfying the red gate and the green gate out of a single
327
+ * run and leaves a held test that wants both polarities having to exit
328
+ * differently before and after the fix. With `verification.sandboxImage` set the
329
+ * run also happens inside `repair-sandbox.ts`'s container, where the held test
330
+ * cannot reach the motor's host at all and no straggler outlives the run.
331
+ * `oracle.ts` has the full account, measurements included, above
332
+ * `OracleRunnerSpec`.
303
333
  *
304
334
  * Secrecy is inherited whole. The report is parsed in memory and only its two
305
335
  * counts reach the persisted evidence — never the assertion text, which is itself