@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/dist/cli.js +497 -216
- package/dist/index.js +431 -254
- package/package.json +2 -2
- package/src/cli.ts +34 -2
- package/src/exec-types.ts +93 -0
- package/src/gate-collectors.ts +30 -4
- package/src/git-pr.ts +53 -5
- package/src/oracle-collector.ts +38 -8
- package/src/oracle.ts +464 -53
- package/src/repair-sandbox.test.ts +166 -1
- package/src/repair-sandbox.ts +203 -8
- package/src/run-containment.ts +194 -20
- package/src/stage-cli.ts +32 -8
- package/src/verification.ts +432 -101
- package/src/worktree.ts +19 -2
package/src/run-containment.ts
CHANGED
|
@@ -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
|
-
* - **
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* `
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* and
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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(
|
|
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`
|
|
33
|
-
*
|
|
34
|
-
* `
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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<
|
|
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
|
|