@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.
@@ -78,24 +78,85 @@
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
- * - **Everything resolved through `spawnRunArgs` runs the run's own
82
- * `package.json` scripts.** Six call sites, not four: `runBuild`,
83
- * `runTests`, `runFormatFix` and `runLint` (`verification.ts`), plus the two
84
- * `dev` servers `runDeepReview` (`verification.ts`) and the review
85
- * worker's own (`review-worker.ts`). Each executes `bun run <script>` from
86
- * the worktree, which the run may rewrite, in the DAEMON process and outside
87
- * the sandbox with network. All six now carry `containedEnv()`, so the
88
- * secret-shaped variables are gone; the command itself is still the run's to
89
- * choose. Closing that means running the repo's build somewhere the
90
- * operator's credentials are not; that is `repair-sandbox.ts`'s problem shape
91
- * and its own piece of work.
92
- *
93
- * The count is in this bullet on purpose. The first version of this list
94
- * named the four verification steps and stopped, and both dev servers then
95
- * shipped with the daemon's whole environment while the changelog told users
96
- * these commands run "without your credentials". `spawn-run-containment.test.ts`
97
- * scans for the property instead of trusting this prose, because the residual
98
- * is a completeness claim over CALL SITES and a hand-list has already lost it.
81
+ * - **Egress from the two `dev` servers, plus the standalone harness CLI.**
82
+ * #1036 closed the four verification steps AND the `build_green` gate
83
+ * collector, which re-runs the same two scripts: `runBuild`, `runTests`,
84
+ * `runFormatFix` and `runLint` run inside `repair-sandbox.ts`'s container
85
+ * when `verification.sandboxImage` is set worktree the only mount, no
86
+ * network, no host fallback. #1037 took the same key and moved the two dev
87
+ * servers in behind it: `runDeepReview`'s and the review worker's, both
88
+ * through one `devServerLaunch`, so they can no longer be fixed one at a
89
+ * time the way #988 fixed one and shipped the other.
90
+ *
91
+ * **What is left of this bullet is egress, and only egress.** A dev server
92
+ * exists to be connected to, so `--network=none` is not available to it: the
93
+ * container publishes `127.0.0.1:<port>` on a default bridge, which gives it
94
+ * a route out. That is a strictly weaker property than the four verification
95
+ * steps get, and it is named here rather than left to be inferred from a
96
+ * missing flag. Two shapes that would have kept both halves were measured on
97
+ * the daemon's own platform and neither works `--internal` drops the
98
+ * published port along with the egress, and a bridge with
99
+ * `enable_ip_masquerade=false` keeps the egress and additionally reaches the
100
+ * operator's own host services. `devServerSandboxArgs` carries the numbers.
101
+ *
102
+ * What the container still takes is the part that made the command worth
103
+ * attacking: the run's `dev` script no longer executes in the daemon
104
+ * process, no longer reads `~/.ssh` or `~/.harmony-mcp/config.json`, and no
105
+ * longer holds a capability. What egress can still carry out is the
106
+ * worktree, which is the run's own output.
107
+ *
108
+ * The containment is **opt-in**, so on a host with no `sandboxImage` all six
109
+ * commands still run in the daemon process, with `containedEnv()` as the
110
+ * only bound. That is a deliberate asymmetry with this module's own
111
+ * containment, which defaults on: the SDK sandbox is inside the CLI, whereas
112
+ * a container needs a reachable Docker and an image carrying the repo's
113
+ * toolchain.
114
+ *
115
+ * One correction to the severity this was first written with. The dev
116
+ * servers were called the rare path because `verification.deepReview`
117
+ * defaults to false. That is true of `runDeepReview` and **false of the
118
+ * review worker's**, which has no such gate and no toolchain check:
119
+ * `review.enabled` defaults to true, so that server starts on every card
120
+ * that reaches Review. Of the two, the one on the default path was the one
121
+ * described as conditional.
122
+ *
123
+ * `packages/harmony-harness/src/cli.ts` used to build the same collector and
124
+ * pass no image, because the motor held no operator config of its own. #1021
125
+ * gave it `--sandbox-image`, threaded from the daemon's same
126
+ * `verification.sandboxImage`, so a daemon-driven stage now contains the
127
+ * `build_green` gate and the HELD TEST alike. A person running the CLI by
128
+ * hand against their own checkout still passes no image and still runs on
129
+ * their host — a different threat model, and named here rather than left to
130
+ * be rediscovered.
131
+ *
132
+ * The count is in this bullet on purpose, and it has now been wrong three
133
+ * times. The first version named the four verification steps and stopped, and
134
+ * both dev servers then shipped with the daemon's whole environment while the
135
+ * changelog told users these commands run "without your credentials". The
136
+ * second version — #1036's own — said "the two dev servers" while the
137
+ * `build_green` gate ran the worktree's script on the host for any operator
138
+ * who had set an image precisely to stop that. The third omission was not a
139
+ * missing NAME but a wrong SPELLING, which is worse: `review-worker.ts`'s dev
140
+ * server carried `env: containedEnv()` on a `spawnInGroup` call, and
141
+ * `spawnInGroup` merges `process.env` back over whatever `env` it is handed —
142
+ * so that strip removed nothing for as long as it shipped, and the source
143
+ * scan reported the property as held because the scan read the spelling.
144
+ * Every one of the three was caught by a reader, not by a gate.
145
+ *
146
+ * A fourth was caught by the gate, which is the point of having one. #1037
147
+ * consolidated the two dev servers behind `devServerLaunch` — the right move,
148
+ * and it instantly took `review-worker.ts` OUT of the scan, because the file
149
+ * stopped containing the word `spawnRunArgs` and that list was how the walk
150
+ * chose which files to open. The property assertion would have gone on
151
+ * passing over the very file whose inert strip had just been fixed. The scan
152
+ * now names both dev-server files explicitly, so a file leaving the walk
153
+ * fails instead of quietly shrinking the count.
154
+ *
155
+ * `spawn-run-containment.test.ts` is therefore spawn-function aware, covers
156
+ * the held-test spawn, and checks its own coverage; and
157
+ * `verification-sandbox.test.ts` asserts that a configured image takes the
158
+ * four out of this process entirely — because the residual is a completeness
159
+ * claim over CALL SITES and a hand-list has already lost it twice.
99
160
  * - **The MCP surface.** The sandbox governs commands. MCP stdio servers are
100
161
  * hosted by the CLI and are not sandboxed, so `mcp__harmony__*` is bounded by
101
162
  * the tool allow-list and the daemon-owned denials (#525/#576), not by this.
@@ -578,6 +639,53 @@ export function containedEnv(
578
639
  return out;
579
640
  }
580
641
 
642
+ /**
643
+ * The model credentials {@link containedEnv} deliberately KEEPS, named so a
644
+ * spawn that is not a model call can drop them.
645
+ *
646
+ * They are in {@link KEEP_ENV_KEYS} because an implement run IS the model call
647
+ * — stripping them stops the run existing rather than containing it. Nothing
648
+ * generalizes from that: it is a statement about one spawn.
649
+ */
650
+ const MODEL_CREDENTIAL_KEYS: readonly string[] = [
651
+ "ANTHROPIC_API_KEY",
652
+ "ANTHROPIC_AUTH_TOKEN",
653
+ "CLAUDE_CODE_OAUTH_TOKEN",
654
+ ];
655
+
656
+ /**
657
+ * Environment keys the HELD-TEST run must not carry (#1021): everything
658
+ * {@link secretEnvKeysToStrip} names, plus the model credentials.
659
+ *
660
+ * A held test is arbitrary code an untrusted `author` subagent wrote, and
661
+ * before this the oracle spawn passed no `env` at all — so it inherited the
662
+ * daemon's entire environment, `HARMONY_API_KEY` and `ANTHROPIC_API_KEY`
663
+ * included. The first class is `secretEnvKeysToStrip`'s. The second is kept by
664
+ * `containedEnv` on purpose, which is right for the spawn that one was written
665
+ * for and wrong here: a test file has no model call to make, so a live billing
666
+ * credential in its environment is spend and reach it has no use for.
667
+ *
668
+ * **KEY NAMES, not an environment, and that is the whole point.** The oracle
669
+ * spawns through `spawnInGroup`, which MERGES `process.env` back on top of any
670
+ * `env` it is handed — so passing a narrowed object there strips nothing. The
671
+ * removal has to be a `stripEnvKeys` DELETE. `sdk-agent-runner.ts` records the
672
+ * same trap for the same reason; `review-worker.ts` shipped the inert spelling.
673
+ *
674
+ * This is a REDUCTION, not the boundary — the same thing #988 said about
675
+ * `containedEnv()` and for the same reason: the command still runs as the
676
+ * operator and can read `~/.ssh` and `~/.harmony-mcp/config.json` off disk. The
677
+ * boundary is the container, which `oracle.ts` uses when
678
+ * `verification.sandboxImage` is configured. This is what the DEFAULT path gets,
679
+ * and the default path is the one most operators are on.
680
+ */
681
+ export function heldTestEnvKeysToStrip(
682
+ parentEnv: Record<string, string | undefined> = process.env,
683
+ ): string[] {
684
+ return [
685
+ ...new Set([...secretEnvKeysToStrip(parentEnv), ...MODEL_CREDENTIAL_KEYS]),
686
+ ];
687
+ }
688
+
581
689
  /**
582
690
  * Git metadata the contained run may not write — the escape the worktree grant
583
691
  * would otherwise hand it.
@@ -1028,8 +1136,36 @@ export const IMPLEMENT_ALLOWED_DOMAINS: readonly string[] = [
1028
1136
  * lockstep with its own release, and a contained run reaching for `@latest`
1029
1137
  * would fetch a version this daemon was never tested against — over a network
1030
1138
  * the containment then has to allow the registry for.
1139
+ *
1140
+ * ## `run` — how the agent inside the run learns its own session (#1035)
1141
+ *
1142
+ * A daemon-run agent cannot call `harmony_start_agent_session`: the daemon owns
1143
+ * the lifecycle and denies the tool (`STAGE_DAEMON_OWNED_TOOLS`). So the MCP
1144
+ * process it talks to used to know nothing about the run it serves, and
1145
+ * `harmony_add_comment` had to leave the attribution to the API — which could
1146
+ * only ask "does this ACCOUNT have a session on this card?", true of every
1147
+ * concurrent run of the same account. That is the borrowed attribution measured
1148
+ * on card #1029.
1149
+ *
1150
+ * Passing `run` closes it at the source: the run's `card_agent_context` id
1151
+ * reaches the MCP server as its own environment, so the comment names the
1152
+ * session instead of the server inferring one.
1153
+ *
1154
+ * **The `env` field, not the spawn environment, and that is the point.** The
1155
+ * CLI hosts a stdio MCP server with `{...its own env, ...this env}` — measured
1156
+ * 2026-09-05 against `claude 2.1.261` with a probe server that dumped its
1157
+ * environment: both halves arrive, and `PATH`/`HOME` survive. Putting the id
1158
+ * here rather than on the run's own spawn keeps it out of the model's shell, so
1159
+ * the agent cannot read it, echo it, or pass a different one.
1160
+ *
1161
+ * An OLDER `@gethmy/mcp` — one published before #1035 — simply ignores two
1162
+ * unknown environment keys, so this is safe to ship ahead of the pin bump. A
1163
+ * CLI FLAG would not be: `cli.js serve` parses with commander, which exits 1 on
1164
+ * an unknown option, and the run would lose `mcp__harmony__*` entirely.
1031
1165
  */
1032
- export function harmonyMcpServer(): Record<string, McpServerConfig> {
1166
+ export function harmonyMcpServer(
1167
+ run?: HarmonyMcpRunBinding,
1168
+ ): Record<string, McpServerConfig> {
1033
1169
  const require = createRequire(import.meta.url);
1034
1170
  // The package's `exports` map publishes "." → dist/index.js and no bin
1035
1171
  // subpath, so the CLI is named as its sibling rather than resolved directly.
@@ -1041,10 +1177,37 @@ export function harmonyMcpServer(): Record<string, McpServerConfig> {
1041
1177
  // and no chance of a different Node than the one that was tested.
1042
1178
  command: process.execPath,
1043
1179
  args: [cli, "serve"],
1180
+ // Both keys or neither: the card id is what lets the MCP server tell
1181
+ // "this comment is on the card I am running" from "this comment is on a
1182
+ // neighbour", and without it the session id would be claimed on every
1183
+ // card the run comments on.
1184
+ ...(run
1185
+ ? {
1186
+ env: {
1187
+ HARMONY_AGENT_CARD_ID: run.cardId,
1188
+ HARMONY_AGENT_SESSION_ID: run.agentSessionId,
1189
+ },
1190
+ }
1191
+ : {}),
1044
1192
  },
1045
1193
  };
1046
1194
  }
1047
1195
 
1196
+ /**
1197
+ * The run whose `card_agent_context` session the MCP server may claim (#1035).
1198
+ *
1199
+ * Both fields are required together — see {@link harmonyMcpServer}. A caller
1200
+ * that has no live session (a verification or review spawn that never opened
1201
+ * one) passes nothing, and the MCP server then posts sessionless rather than
1202
+ * borrowing.
1203
+ */
1204
+ export interface HarmonyMcpRunBinding {
1205
+ /** `cards.id` — the card this run holds its session on. */
1206
+ cardId: string;
1207
+ /** `card_agent_context.id` — the run's own session row. */
1208
+ agentSessionId: string;
1209
+ }
1210
+
1048
1211
  /** The fields {@link implementRunContainment} contributes to an `SdkRunnerConfig`. */
1049
1212
  export interface RunContainment {
1050
1213
  sandbox: SandboxSettings;
@@ -1087,6 +1250,12 @@ export function implementRunContainment(args: {
1087
1250
  * it says instead of being auto-approved because the run is sandboxed.
1088
1251
  */
1089
1252
  readOnly?: boolean;
1253
+ /**
1254
+ * The board session this spawn IS, so its agent can name it on a comment
1255
+ * instead of the API inferring one (#1035). Omit when the spawn holds no
1256
+ * session — the agent then comments sessionless, which is the honest answer.
1257
+ */
1258
+ run?: HarmonyMcpRunBinding;
1090
1259
  }): RunContainment {
1091
1260
  // Inside the builder rather than at the call sites, so a future spawn cannot
1092
1261
  // adopt the containment and skip the check that makes it hold. Throws — see
@@ -1209,7 +1378,7 @@ export function implementRunContainment(args: {
1209
1378
  // Handed back explicitly, because the line above took it away — see
1210
1379
  // `harmonyMcpServer`. Without this the run loses `mcp__harmony__*` and can
1211
1380
  // no longer report its own progress.
1212
- mcpServers: harmonyMcpServer(),
1381
+ mcpServers: harmonyMcpServer(args.run),
1213
1382
  // Paired with the two above: the run sees only the server declared to it,
1214
1383
  // and no MCP config the operator happens to have registered for themselves.
1215
1384
  strictMcpConfig: true,
@@ -1268,6 +1437,11 @@ export function implementRunContainmentCliArgs(args: {
1268
1437
  * it says instead of being auto-approved because the run is sandboxed.
1269
1438
  */
1270
1439
  readOnly?: boolean;
1440
+ /**
1441
+ * The board session this spawn IS (#1035) — travels inside `--mcp-config`,
1442
+ * the same way the SDK path travels it inside `mcpServers`.
1443
+ */
1444
+ run?: HarmonyMcpRunBinding;
1271
1445
  }): string[] {
1272
1446
  const containment = implementRunContainment(args);
1273
1447
  return [
package/src/stage-cli.ts CHANGED
@@ -26,14 +26,19 @@ import { buildRoleLaunch, envKeysDroppedByLaunch } from "./runner.js";
26
26
  import type { SdkRunnerConfig } from "./sdk-agent-runner.js";
27
27
 
28
28
  export const STAGE_RUN_USAGE =
29
- "usage: harmony-harness stage run --card <id> --stage <id> --workspace <id> --repo <path> --session <id> [--metrics <json-path>]";
29
+ "usage: harmony-harness stage run --card <id> --stage <id> --workspace <id> --repo <path> --session <id> [--metrics <json-path>] [--sandbox-image <image>]";
30
30
 
31
31
  /** The five identifiers one stage run needs. Every one is required.
32
- * `metricsPath` is the one optional input: a JSON file holding the DRIVER's
33
- * metric allowlist for `custom` gates (same shape as the daemon operator's
34
- * `agent.playbooks.metrics`). The motor itself stays config-free without the
35
- * flag a `custom` gate reports "metric not declared" and blocks, exactly as
36
- * before. */
32
+ * `metricsPath` and `sandboxImage` are the optional inputs, and both carry the
33
+ * DRIVER's operator config into a motor that holds none of its own:
34
+ * - `metricsPath` is a JSON file holding the metric allowlist for `custom`
35
+ * gates (same shape as the daemon operator's `agent.playbooks.metrics`).
36
+ * Without it a `custom` gate reports "metric not declared" and blocks,
37
+ * exactly as before.
38
+ * - `sandboxImage` is the daemon's `verification.sandboxImage` (#1021): the
39
+ * container the held test and the gate's build/lint commands run in. Without
40
+ * it both run in the motor's own process tree, which is what a person
41
+ * running this CLI against their own checkout gets. */
37
42
  export interface StageRunArgs {
38
43
  cardId: string;
39
44
  stageId: string;
@@ -41,6 +46,7 @@ export interface StageRunArgs {
41
46
  repoPath: string;
42
47
  sessionId: string;
43
48
  metricsPath: string | null;
49
+ sandboxImage: string | null;
44
50
  }
45
51
 
46
52
  export type StageRunArgsResult =
@@ -78,14 +84,16 @@ export function parseStageRunArgs(argv: string[]): StageRunArgsResult {
78
84
  };
79
85
  }
80
86
 
81
- const fields: Array<[Exclude<keyof StageRunArgs, "metricsPath">, string]> = [
87
+ const fields: Array<
88
+ [Exclude<keyof StageRunArgs, "metricsPath" | "sandboxImage">, string]
89
+ > = [
82
90
  ["cardId", "card"],
83
91
  ["stageId", "stage"],
84
92
  ["workspaceId", "workspace"],
85
93
  ["repoPath", "repo"],
86
94
  ["sessionId", "session"],
87
95
  ];
88
- const args = { metricsPath: null } as StageRunArgs;
96
+ const args = { metricsPath: null, sandboxImage: null } as StageRunArgs;
89
97
  for (const [field, flag] of fields) {
90
98
  const value = readFlag(argv, flag);
91
99
  if (value === null) {
@@ -105,6 +113,22 @@ export function parseStageRunArgs(argv: string[]): StageRunArgsResult {
105
113
  }
106
114
  args.metricsPath = metricsPath;
107
115
  }
116
+
117
+ // Optional: --sandbox-image <image> (#1021). Same absent/valueless split as
118
+ // --metrics, and the refusal matters MORE here: a driver that passed the flag
119
+ // asked for the held test to run without its credentials in scope, and
120
+ // silently running it on the host would hand back exactly the exposure the
121
+ // flag exists to remove. There is no host fallback anywhere on this path.
122
+ if (argv.includes("--sandbox-image")) {
123
+ const sandboxImage = readFlag(argv, "sandbox-image");
124
+ if (sandboxImage === null) {
125
+ return {
126
+ ok: false,
127
+ message: "missing value for --sandbox-image <image>",
128
+ };
129
+ }
130
+ args.sandboxImage = sandboxImage;
131
+ }
108
132
  return { ok: true, args };
109
133
  }
110
134