agent-dealer 1.3.0 → 1.3.1
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/bundle/server/dist/coordinator/admission.test.js +115 -0
- package/bundle/server/dist/coordinator/reviewer-browser-probe.js +1004 -0
- package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +680 -0
- package/bundle/server/dist/coordinator/worker-loop.js +1 -1
- package/bundle/server/dist/repository/admission-settings.js +5 -5
- package/bundle/server/dist/repository/admission-settings.test.js +71 -15
- package/bundle/server/dist/routes/queue.js +1 -1
- package/bundle/server/dist/routes/queue.test.js +55 -20
- package/bundle/server/dist/runners/process-registry.js +1 -1
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-Bm74Vq_X.js → index-Bz_c-UbO.js} +13 -13
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/queue-entries.d.ts +5 -4
- package/bundle/shared/dist/queue-entries.js +5 -4
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/templates/prod.env.example +5 -2
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
// packages/server/src/coordinator/reviewer-browser-probe.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-380 acceptance: focused unit tests cover result parsing and fail-closed
|
|
4
|
+
// behavior for missing fields or an unexecuted probe.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { MUSE_PROBE_BLOCKED_REASON, REVIEWER_ATTEMPTED_CONTROL_IDS, REVIEWER_BROWSER_PROBE_SCHEMA_VERSION, REVIEWER_BROWSER_VIEWPORTS, REVIEWER_NEGATIVE_CONTROL_IDS, buildReviewerBrowserProbePrompt, canonicalProbeJson, computeProbeStatus, overlayPlaywrightMcpConfig, parseReviewerBrowserProbeReport, probeManifestFilename, resolveProbeArgv, runReviewerBrowserProbe, sanitizeProbeManifest, unexecutedProbeManifest, withManifestHash, } from "./reviewer-browser-probe.js";
|
|
12
|
+
import { assertReviewerReadOnly } from "./permissions.js";
|
|
13
|
+
import { roleCeiling } from "@agent-dealer/shared";
|
|
14
|
+
const HEAD = "a".repeat(40);
|
|
15
|
+
const OTHER_HEAD = "b".repeat(40);
|
|
16
|
+
const SHOT = "c".repeat(64);
|
|
17
|
+
function deniedControl(id) {
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
action: `attempted ${id}`,
|
|
21
|
+
expected: "deny",
|
|
22
|
+
observed: "denied by policy",
|
|
23
|
+
denied: true,
|
|
24
|
+
exitStatus: null,
|
|
25
|
+
detail: "policy denial",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function validReport(overrides = {}) {
|
|
29
|
+
return {
|
|
30
|
+
headSha: HEAD,
|
|
31
|
+
runtimeVersion: "claude 2.1.0",
|
|
32
|
+
loopback: { attempted: true, succeeded: true, detail: "listened on 127.0.0.1:0" },
|
|
33
|
+
browser: {
|
|
34
|
+
attempted: true,
|
|
35
|
+
launched: true,
|
|
36
|
+
binary: "/tmp/probe/chromium",
|
|
37
|
+
version: "chromium 1.2.3",
|
|
38
|
+
detail: "launched headless",
|
|
39
|
+
},
|
|
40
|
+
viewports: [...REVIEWER_BROWSER_VIEWPORTS].map((v) => ({
|
|
41
|
+
width: v.width,
|
|
42
|
+
height: v.height,
|
|
43
|
+
screenshotPath: `/tmp/probe/shot-${v.width}.png`,
|
|
44
|
+
screenshotSha256: SHOT,
|
|
45
|
+
// A pass requires the interactive state at EVERY required viewport.
|
|
46
|
+
interactionStateReached: true,
|
|
47
|
+
detail: "captured after interaction",
|
|
48
|
+
})),
|
|
49
|
+
appPath: { route: "/issues", interaction: "opened first issue", mocksFree: true },
|
|
50
|
+
negativeControls: [...REVIEWER_ATTEMPTED_CONTROL_IDS].map(deniedControl),
|
|
51
|
+
artifacts: [{ path: "/tmp/probe/shot-1440.png", sha256: SHOT, bytes: 1234 }],
|
|
52
|
+
notes: "ok",
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function cleanCleanup() {
|
|
57
|
+
return {
|
|
58
|
+
tempDirRemoved: true,
|
|
59
|
+
childProcessesRemaining: 0,
|
|
60
|
+
previewServerStopped: true,
|
|
61
|
+
detail: "clean",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
test("parse: a fenced valid report parses", () => {
|
|
65
|
+
const transcript = `Some preamble.\n\`\`\`json\n${JSON.stringify(validReport())}\n\`\`\`\n`;
|
|
66
|
+
const parsed = parseReviewerBrowserProbeReport(transcript);
|
|
67
|
+
assert.ok(parsed);
|
|
68
|
+
assert.equal(parsed.headSha, HEAD);
|
|
69
|
+
assert.equal(parsed.viewports.length, 2);
|
|
70
|
+
});
|
|
71
|
+
test("parse: raw JSON without a fence parses", () => {
|
|
72
|
+
const parsed = parseReviewerBrowserProbeReport(JSON.stringify(validReport()));
|
|
73
|
+
assert.ok(parsed);
|
|
74
|
+
assert.equal(parsed.browser.launched, true);
|
|
75
|
+
});
|
|
76
|
+
test("parse fail-closed: empty transcript is an unexecuted probe", () => {
|
|
77
|
+
assert.equal(parseReviewerBrowserProbeReport(""), null);
|
|
78
|
+
assert.equal(parseReviewerBrowserProbeReport(" \n "), null);
|
|
79
|
+
});
|
|
80
|
+
test("parse fail-closed: garbage transcript is an unexecuted probe", () => {
|
|
81
|
+
assert.equal(parseReviewerBrowserProbeReport("not json at all"), null);
|
|
82
|
+
assert.equal(parseReviewerBrowserProbeReport("```json\n{nope}\n```"), null);
|
|
83
|
+
});
|
|
84
|
+
test("parse fail-closed: missing fields never yield a partial pass", () => {
|
|
85
|
+
const report = validReport();
|
|
86
|
+
for (const field of ["headSha", "loopback", "browser", "viewports", "appPath", "negativeControls", "artifacts", "notes"]) {
|
|
87
|
+
const { [field]: _drop, ...rest } = report;
|
|
88
|
+
assert.equal(parseReviewerBrowserProbeReport(JSON.stringify(rest)), null, `missing ${field} must not parse`);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
test("parse fail-closed: malformed headSha is rejected", () => {
|
|
92
|
+
assert.equal(parseReviewerBrowserProbeReport(JSON.stringify(validReport({ headSha: "xyz" }))), null);
|
|
93
|
+
});
|
|
94
|
+
test("parse fail-closed: malformed screenshot sha is rejected", () => {
|
|
95
|
+
const report = validReport();
|
|
96
|
+
report.viewports[0] = { ...report.viewports[0], screenshotSha256: "not-a-sha" };
|
|
97
|
+
assert.equal(parseReviewerBrowserProbeReport(JSON.stringify(report)), null);
|
|
98
|
+
});
|
|
99
|
+
test("parse fail-closed: empty viewports / controls arrays are rejected", () => {
|
|
100
|
+
assert.equal(parseReviewerBrowserProbeReport(JSON.stringify(validReport({ viewports: [] }))), null);
|
|
101
|
+
assert.equal(parseReviewerBrowserProbeReport(JSON.stringify(validReport({ negativeControls: [] }))), null);
|
|
102
|
+
});
|
|
103
|
+
test("status: executed clean browser run passes", () => {
|
|
104
|
+
const { status } = computeProbeStatus({
|
|
105
|
+
report: validReport(),
|
|
106
|
+
contract: "direct",
|
|
107
|
+
expectedHeadSha: HEAD,
|
|
108
|
+
headShaVerified: true,
|
|
109
|
+
cleanup: cleanCleanup(),
|
|
110
|
+
timedOut: false,
|
|
111
|
+
cancelled: false,
|
|
112
|
+
});
|
|
113
|
+
assert.equal(status, "pass");
|
|
114
|
+
});
|
|
115
|
+
test("status: null report is not_run, never pass", () => {
|
|
116
|
+
for (const [timedOut, cancelled, reason] of [
|
|
117
|
+
[false, false, "no parseable reviewer report"],
|
|
118
|
+
[true, false, "timed out"],
|
|
119
|
+
[false, true, "cancelled"],
|
|
120
|
+
]) {
|
|
121
|
+
const { status, reason: got } = computeProbeStatus({
|
|
122
|
+
report: null,
|
|
123
|
+
contract: "direct",
|
|
124
|
+
expectedHeadSha: HEAD,
|
|
125
|
+
headShaVerified: false,
|
|
126
|
+
cleanup: cleanCleanup(),
|
|
127
|
+
timedOut,
|
|
128
|
+
cancelled,
|
|
129
|
+
});
|
|
130
|
+
assert.equal(status, "not_run");
|
|
131
|
+
assert.match(got, new RegExp(reason));
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
test("status: head-SHA mismatch fails even when everything else is clean", () => {
|
|
135
|
+
const { status } = computeProbeStatus({
|
|
136
|
+
report: validReport({ headSha: OTHER_HEAD }),
|
|
137
|
+
contract: "direct",
|
|
138
|
+
expectedHeadSha: HEAD,
|
|
139
|
+
headShaVerified: true,
|
|
140
|
+
cleanup: cleanCleanup(),
|
|
141
|
+
timedOut: false,
|
|
142
|
+
cancelled: false,
|
|
143
|
+
});
|
|
144
|
+
assert.equal(status, "fail");
|
|
145
|
+
const unverified = computeProbeStatus({
|
|
146
|
+
report: validReport(),
|
|
147
|
+
contract: "direct",
|
|
148
|
+
expectedHeadSha: HEAD,
|
|
149
|
+
headShaVerified: false,
|
|
150
|
+
cleanup: cleanCleanup(),
|
|
151
|
+
timedOut: false,
|
|
152
|
+
cancelled: false,
|
|
153
|
+
});
|
|
154
|
+
assert.equal(unverified.status, "fail");
|
|
155
|
+
});
|
|
156
|
+
test("status: any allowed negative control fails and names the control", () => {
|
|
157
|
+
const report = validReport();
|
|
158
|
+
report.negativeControls[1] = { ...report.negativeControls[1], denied: false, observed: "navigated to example.com" };
|
|
159
|
+
const { status, reason } = computeProbeStatus({
|
|
160
|
+
report,
|
|
161
|
+
contract: "direct",
|
|
162
|
+
expectedHeadSha: HEAD,
|
|
163
|
+
headShaVerified: true,
|
|
164
|
+
cleanup: cleanCleanup(),
|
|
165
|
+
timedOut: false,
|
|
166
|
+
cancelled: false,
|
|
167
|
+
});
|
|
168
|
+
assert.equal(status, "fail");
|
|
169
|
+
assert.match(reason, /external-navigation/);
|
|
170
|
+
});
|
|
171
|
+
test("status: leaked child process or leftover temp dir fails", () => {
|
|
172
|
+
const leaked = computeProbeStatus({
|
|
173
|
+
report: validReport(),
|
|
174
|
+
contract: "direct",
|
|
175
|
+
expectedHeadSha: HEAD,
|
|
176
|
+
headShaVerified: true,
|
|
177
|
+
cleanup: { ...cleanCleanup(), childProcessesRemaining: 1 },
|
|
178
|
+
timedOut: false,
|
|
179
|
+
cancelled: false,
|
|
180
|
+
});
|
|
181
|
+
assert.equal(leaked.status, "fail");
|
|
182
|
+
const leftover = computeProbeStatus({
|
|
183
|
+
report: validReport(),
|
|
184
|
+
contract: "direct",
|
|
185
|
+
expectedHeadSha: HEAD,
|
|
186
|
+
headShaVerified: true,
|
|
187
|
+
cleanup: { ...cleanCleanup(), tempDirRemoved: false },
|
|
188
|
+
timedOut: false,
|
|
189
|
+
cancelled: false,
|
|
190
|
+
});
|
|
191
|
+
assert.equal(leftover.status, "fail");
|
|
192
|
+
});
|
|
193
|
+
test("status: missing required viewport fails", () => {
|
|
194
|
+
const report = validReport({ viewports: [validReport().viewports[0]] });
|
|
195
|
+
const { status, reason } = computeProbeStatus({
|
|
196
|
+
report,
|
|
197
|
+
contract: "direct",
|
|
198
|
+
expectedHeadSha: HEAD,
|
|
199
|
+
headShaVerified: true,
|
|
200
|
+
cleanup: cleanCleanup(),
|
|
201
|
+
timedOut: false,
|
|
202
|
+
cancelled: false,
|
|
203
|
+
});
|
|
204
|
+
assert.equal(status, "fail");
|
|
205
|
+
assert.match(reason, /390x800/);
|
|
206
|
+
});
|
|
207
|
+
test("status: an omitted attempted control fails and names the missing id", () => {
|
|
208
|
+
const single = computeProbeStatus({
|
|
209
|
+
report: validReport({ negativeControls: [deniedControl("source-write")] }),
|
|
210
|
+
contract: "direct",
|
|
211
|
+
expectedHeadSha: HEAD,
|
|
212
|
+
headShaVerified: true,
|
|
213
|
+
cleanup: cleanCleanup(),
|
|
214
|
+
timedOut: false,
|
|
215
|
+
cancelled: false,
|
|
216
|
+
});
|
|
217
|
+
assert.equal(single.status, "fail");
|
|
218
|
+
assert.match(single.reason, /not attempted/);
|
|
219
|
+
assert.match(single.reason, /external-navigation/);
|
|
220
|
+
// One dropped id out of five still fails, naming exactly that id.
|
|
221
|
+
const dropped = validReport({
|
|
222
|
+
negativeControls: [...REVIEWER_ATTEMPTED_CONTROL_IDS]
|
|
223
|
+
.filter((id) => id !== "personal-profile")
|
|
224
|
+
.map(deniedControl),
|
|
225
|
+
});
|
|
226
|
+
const partial = computeProbeStatus({
|
|
227
|
+
report: dropped,
|
|
228
|
+
contract: "direct",
|
|
229
|
+
expectedHeadSha: HEAD,
|
|
230
|
+
headShaVerified: true,
|
|
231
|
+
cleanup: cleanCleanup(),
|
|
232
|
+
timedOut: false,
|
|
233
|
+
cancelled: false,
|
|
234
|
+
});
|
|
235
|
+
assert.equal(partial.status, "fail");
|
|
236
|
+
assert.match(partial.reason, /personal-profile/);
|
|
237
|
+
});
|
|
238
|
+
test("status: interaction at only one viewport is blocked, never pass", () => {
|
|
239
|
+
const oneViewport = validReport();
|
|
240
|
+
oneViewport.viewports[1] = { ...oneViewport.viewports[1], interactionStateReached: false, detail: "static route only" };
|
|
241
|
+
const direct = computeProbeStatus({
|
|
242
|
+
report: oneViewport,
|
|
243
|
+
contract: "direct",
|
|
244
|
+
expectedHeadSha: HEAD,
|
|
245
|
+
headShaVerified: true,
|
|
246
|
+
cleanup: cleanCleanup(),
|
|
247
|
+
timedOut: false,
|
|
248
|
+
cancelled: false,
|
|
249
|
+
});
|
|
250
|
+
assert.equal(direct.status, "blocked");
|
|
251
|
+
assert.match(direct.reason, /interactive state/);
|
|
252
|
+
const preview = computeProbeStatus({
|
|
253
|
+
report: validReport({
|
|
254
|
+
browser: { attempted: false, launched: false, binary: null, version: null, detail: "coordinator-owned" },
|
|
255
|
+
viewports: oneViewport.viewports,
|
|
256
|
+
}),
|
|
257
|
+
contract: "coordinator-preview",
|
|
258
|
+
expectedHeadSha: HEAD,
|
|
259
|
+
headShaVerified: true,
|
|
260
|
+
cleanup: cleanCleanup(),
|
|
261
|
+
timedOut: false,
|
|
262
|
+
cancelled: false,
|
|
263
|
+
});
|
|
264
|
+
assert.equal(preview.status, "blocked");
|
|
265
|
+
assert.match(preview.reason, /interactive state/);
|
|
266
|
+
});
|
|
267
|
+
test("status: clean run without a browser launch is blocked, not fail", () => {
|
|
268
|
+
const report = validReport({
|
|
269
|
+
browser: { attempted: true, launched: false, binary: null, version: null, detail: "no Bash to launch" },
|
|
270
|
+
viewports: [...REVIEWER_BROWSER_VIEWPORTS].map((v) => ({
|
|
271
|
+
width: v.width,
|
|
272
|
+
height: v.height,
|
|
273
|
+
screenshotPath: null,
|
|
274
|
+
screenshotSha256: null,
|
|
275
|
+
interactionStateReached: false,
|
|
276
|
+
detail: "no browser",
|
|
277
|
+
})),
|
|
278
|
+
});
|
|
279
|
+
const { status } = computeProbeStatus({
|
|
280
|
+
report,
|
|
281
|
+
contract: "direct",
|
|
282
|
+
expectedHeadSha: HEAD,
|
|
283
|
+
headShaVerified: true,
|
|
284
|
+
cleanup: cleanCleanup(),
|
|
285
|
+
timedOut: false,
|
|
286
|
+
cancelled: false,
|
|
287
|
+
});
|
|
288
|
+
assert.equal(status, "blocked");
|
|
289
|
+
});
|
|
290
|
+
test("status: coordinator-preview with no artifacts is blocked; with interaction it passes", () => {
|
|
291
|
+
const empty = computeProbeStatus({
|
|
292
|
+
report: validReport({
|
|
293
|
+
browser: { attempted: false, launched: false, binary: null, version: null, detail: "coordinator-owned" },
|
|
294
|
+
artifacts: [],
|
|
295
|
+
viewports: [...REVIEWER_BROWSER_VIEWPORTS].map((v) => ({
|
|
296
|
+
width: v.width,
|
|
297
|
+
height: v.height,
|
|
298
|
+
screenshotPath: null,
|
|
299
|
+
screenshotSha256: null,
|
|
300
|
+
interactionStateReached: false,
|
|
301
|
+
detail: "nothing to judge",
|
|
302
|
+
})),
|
|
303
|
+
}),
|
|
304
|
+
contract: "coordinator-preview",
|
|
305
|
+
expectedHeadSha: HEAD,
|
|
306
|
+
headShaVerified: true,
|
|
307
|
+
cleanup: cleanCleanup(),
|
|
308
|
+
timedOut: false,
|
|
309
|
+
cancelled: false,
|
|
310
|
+
});
|
|
311
|
+
assert.equal(empty.status, "blocked");
|
|
312
|
+
const judged = computeProbeStatus({
|
|
313
|
+
report: validReport({
|
|
314
|
+
browser: { attempted: false, launched: false, binary: null, version: null, detail: "coordinator-owned" },
|
|
315
|
+
}),
|
|
316
|
+
contract: "coordinator-preview",
|
|
317
|
+
expectedHeadSha: HEAD,
|
|
318
|
+
headShaVerified: true,
|
|
319
|
+
cleanup: cleanCleanup(),
|
|
320
|
+
timedOut: false,
|
|
321
|
+
cancelled: false,
|
|
322
|
+
});
|
|
323
|
+
assert.equal(judged.status, "pass");
|
|
324
|
+
});
|
|
325
|
+
test("unexecuted manifest is deterministic not_run with unattempted capabilities", () => {
|
|
326
|
+
const launch = {
|
|
327
|
+
bin: "claude",
|
|
328
|
+
argv: ["-p", "x"],
|
|
329
|
+
cwd: "/tmp/wt",
|
|
330
|
+
mcpConfigPath: null,
|
|
331
|
+
mcpEnvKeys: [],
|
|
332
|
+
policy: roleCeiling("reviewer"),
|
|
333
|
+
};
|
|
334
|
+
const manifest = unexecutedProbeManifest({
|
|
335
|
+
probeId: "probe-1",
|
|
336
|
+
runtime: "claude_code",
|
|
337
|
+
contract: "direct",
|
|
338
|
+
headSha: HEAD,
|
|
339
|
+
startedAt: "2026-10-08T00:00:00.000Z",
|
|
340
|
+
endedAt: "2026-10-08T00:01:00.000Z",
|
|
341
|
+
timedOut: false,
|
|
342
|
+
cancelled: false,
|
|
343
|
+
exitCode: null,
|
|
344
|
+
launch,
|
|
345
|
+
cleanup: cleanCleanup(),
|
|
346
|
+
headShaVerified: false,
|
|
347
|
+
reason: "no parseable reviewer report (unexecuted probe)",
|
|
348
|
+
});
|
|
349
|
+
assert.equal(manifest.status, "not_run");
|
|
350
|
+
assert.equal(manifest.schemaVersion, REVIEWER_BROWSER_PROBE_SCHEMA_VERSION);
|
|
351
|
+
assert.equal(manifest.browser.launched, false);
|
|
352
|
+
assert.equal(manifest.browser.attempted, false);
|
|
353
|
+
assert.equal(manifest.loopback.succeeded, false);
|
|
354
|
+
assert.ok(manifest.viewports.every((v) => !v.interactionStateReached));
|
|
355
|
+
assert.ok(manifest.verdictBinding.manifestSha256);
|
|
356
|
+
// Deterministic: same input → same canonical bytes → same hash.
|
|
357
|
+
const again = unexecutedProbeManifest({
|
|
358
|
+
probeId: "probe-1",
|
|
359
|
+
runtime: "claude_code",
|
|
360
|
+
contract: "direct",
|
|
361
|
+
headSha: HEAD,
|
|
362
|
+
startedAt: "2026-10-08T00:00:00.000Z",
|
|
363
|
+
endedAt: "2026-10-08T00:01:00.000Z",
|
|
364
|
+
timedOut: false,
|
|
365
|
+
cancelled: false,
|
|
366
|
+
exitCode: null,
|
|
367
|
+
launch,
|
|
368
|
+
cleanup: cleanCleanup(),
|
|
369
|
+
headShaVerified: false,
|
|
370
|
+
reason: "no parseable reviewer report (unexecuted probe)",
|
|
371
|
+
});
|
|
372
|
+
assert.equal(canonicalProbeJson(manifest), canonicalProbeJson(again));
|
|
373
|
+
});
|
|
374
|
+
test("manifest hash verifies over the canonical bytes excluding itself", () => {
|
|
375
|
+
const launch = {
|
|
376
|
+
bin: "claude",
|
|
377
|
+
argv: [],
|
|
378
|
+
cwd: "/tmp/wt",
|
|
379
|
+
mcpConfigPath: null,
|
|
380
|
+
mcpEnvKeys: [],
|
|
381
|
+
policy: roleCeiling("reviewer"),
|
|
382
|
+
};
|
|
383
|
+
const manifest = unexecutedProbeManifest({
|
|
384
|
+
probeId: "p",
|
|
385
|
+
runtime: "codex_local",
|
|
386
|
+
contract: "direct",
|
|
387
|
+
headSha: HEAD,
|
|
388
|
+
startedAt: "2026-10-08T00:00:00.000Z",
|
|
389
|
+
endedAt: "2026-10-08T00:00:01.000Z",
|
|
390
|
+
timedOut: false,
|
|
391
|
+
cancelled: false,
|
|
392
|
+
exitCode: null,
|
|
393
|
+
launch,
|
|
394
|
+
cleanup: cleanCleanup(),
|
|
395
|
+
headShaVerified: false,
|
|
396
|
+
reason: "r",
|
|
397
|
+
});
|
|
398
|
+
const recomputed = createHash("sha256")
|
|
399
|
+
.update(canonicalProbeJson({ ...manifest, verdictBinding: { ...manifest.verdictBinding, manifestSha256: null } }), "utf8")
|
|
400
|
+
.digest("hex");
|
|
401
|
+
assert.equal(manifest.verdictBinding.manifestSha256, recomputed);
|
|
402
|
+
});
|
|
403
|
+
test("sanitize replaces the home dir and re-hashes", () => {
|
|
404
|
+
const launch = {
|
|
405
|
+
bin: "/Users/op/.local/bin/claude",
|
|
406
|
+
argv: [],
|
|
407
|
+
cwd: "/Users/op/work/wt",
|
|
408
|
+
mcpConfigPath: "/Users/op/.config/deck.json",
|
|
409
|
+
mcpEnvKeys: ["CODEX_HOME"],
|
|
410
|
+
policy: roleCeiling("reviewer"),
|
|
411
|
+
};
|
|
412
|
+
const manifest = unexecutedProbeManifest({
|
|
413
|
+
probeId: "p",
|
|
414
|
+
runtime: "claude_code",
|
|
415
|
+
contract: "direct",
|
|
416
|
+
headSha: HEAD,
|
|
417
|
+
startedAt: "2026-10-08T00:00:00.000Z",
|
|
418
|
+
endedAt: "2026-10-08T00:00:01.000Z",
|
|
419
|
+
timedOut: false,
|
|
420
|
+
cancelled: false,
|
|
421
|
+
exitCode: null,
|
|
422
|
+
launch,
|
|
423
|
+
cleanup: cleanCleanup(),
|
|
424
|
+
headShaVerified: false,
|
|
425
|
+
reason: "r",
|
|
426
|
+
});
|
|
427
|
+
const sanitized = sanitizeProbeManifest(manifest, "/Users/op");
|
|
428
|
+
assert.equal(sanitized.launch.bin, "~/.local/bin/claude");
|
|
429
|
+
assert.equal(sanitized.launch.cwd, "~/work/wt");
|
|
430
|
+
assert.ok(!canonicalProbeJson(sanitized).includes("/Users/op"));
|
|
431
|
+
// The committed bytes verify as written.
|
|
432
|
+
const again = sanitizeProbeManifest(manifest, "/Users/op");
|
|
433
|
+
assert.equal(canonicalProbeJson(sanitized), canonicalProbeJson(again));
|
|
434
|
+
});
|
|
435
|
+
test("prompt pins the head SHA, viewports, controls, and disposable dir", () => {
|
|
436
|
+
const prompt = buildReviewerBrowserProbePrompt({
|
|
437
|
+
headSha: HEAD,
|
|
438
|
+
contract: "direct",
|
|
439
|
+
runtime: "claude_code",
|
|
440
|
+
tempDir: "/tmp/probe-123",
|
|
441
|
+
approvedRoots: ["/tmp/wt", "/tmp/probe-123"],
|
|
442
|
+
appRoute: "/issues",
|
|
443
|
+
appInteraction: "open the first issue",
|
|
444
|
+
});
|
|
445
|
+
assert.ok(prompt.includes(HEAD));
|
|
446
|
+
assert.ok(prompt.includes("1440x900"));
|
|
447
|
+
assert.ok(prompt.includes("390x800"));
|
|
448
|
+
assert.ok(prompt.includes("/tmp/probe-123"));
|
|
449
|
+
for (const id of REVIEWER_ATTEMPTED_CONTROL_IDS)
|
|
450
|
+
assert.ok(prompt.includes(id), `prompt names ${id}`);
|
|
451
|
+
assert.ok(prompt.includes("mcp__agent-deck__call_service_tool"));
|
|
452
|
+
assert.ok(prompt.includes("```json"));
|
|
453
|
+
});
|
|
454
|
+
test("prompt covers all registered negative-control ids across the manifest lifecycle", () => {
|
|
455
|
+
// The 5 reviewer-attempted controls ride in the prompt; the 2 lifecycle
|
|
456
|
+
// controls are harness-observed and appended to the manifest (see the runner
|
|
457
|
+
// test below). Together they cover every registered id exactly once.
|
|
458
|
+
assert.deepEqual([...REVIEWER_NEGATIVE_CONTROL_IDS].sort(), ["cancel-cleanup", "external-navigation", "out-of-root-file", "personal-profile", "service-tool-mutation", "source-write", "timeout-cleanup"].sort());
|
|
459
|
+
assert.equal(REVIEWER_ATTEMPTED_CONTROL_IDS.length, 5);
|
|
460
|
+
});
|
|
461
|
+
test("probe argv is the production reviewer argv and passes the read-only preflight", () => {
|
|
462
|
+
const prompt = buildReviewerBrowserProbePrompt({
|
|
463
|
+
headSha: HEAD,
|
|
464
|
+
contract: "direct",
|
|
465
|
+
runtime: "claude_code",
|
|
466
|
+
tempDir: "/tmp/probe",
|
|
467
|
+
approvedRoots: ["/tmp/wt", "/tmp/probe"],
|
|
468
|
+
appRoute: "/issues",
|
|
469
|
+
appInteraction: "open the first issue",
|
|
470
|
+
});
|
|
471
|
+
const claudeArgv = resolveProbeArgv({ runtime: "claude_code", prompt, mcpConfigPath: "/tmp/deck.json" });
|
|
472
|
+
assert.doesNotThrow(() => assertReviewerReadOnly(claudeArgv, { mcpConfigPath: "/tmp/deck.json" }));
|
|
473
|
+
const codexArgv = resolveProbeArgv({ runtime: "codex_local", prompt });
|
|
474
|
+
assert.ok(codexArgv.includes("--ignore-user-config"));
|
|
475
|
+
assert.doesNotThrow(() => assertReviewerReadOnly(codexArgv));
|
|
476
|
+
});
|
|
477
|
+
test("playwright overlay refuses cursor (in-worktree config) and preserves the deck server", () => {
|
|
478
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "probe-overlay-"));
|
|
479
|
+
try {
|
|
480
|
+
const refused = overlayPlaywrightMcpConfig({
|
|
481
|
+
runtime: "cursor_local",
|
|
482
|
+
mcpConfigPath: path.join(dir, ".cursor", "mcp.json"),
|
|
483
|
+
tempDir: dir,
|
|
484
|
+
server: { command: "npx", args: ["x"] },
|
|
485
|
+
});
|
|
486
|
+
assert.equal(refused.ok, false);
|
|
487
|
+
const claudeCfg = path.join(dir, "claude.json");
|
|
488
|
+
fs.writeFileSync(claudeCfg, JSON.stringify({ mcpServers: { "agent-deck": { url: "http://x/mcp" } } }));
|
|
489
|
+
const overlaid = overlayPlaywrightMcpConfig({
|
|
490
|
+
runtime: "claude_code",
|
|
491
|
+
mcpConfigPath: claudeCfg,
|
|
492
|
+
tempDir: dir,
|
|
493
|
+
server: { command: "npx", args: ["@playwright/mcp@1.2.3"] },
|
|
494
|
+
});
|
|
495
|
+
assert.equal(overlaid.ok, true);
|
|
496
|
+
if (overlaid.ok) {
|
|
497
|
+
const parsed = JSON.parse(fs.readFileSync(overlaid.mcpConfigPath, "utf8"));
|
|
498
|
+
assert.ok("agent-deck" in parsed.mcpServers);
|
|
499
|
+
assert.ok("playwright" in parsed.mcpServers);
|
|
500
|
+
// Original untouched.
|
|
501
|
+
assert.ok(!("playwright" in JSON.parse(fs.readFileSync(claudeCfg, "utf8")).mcpServers));
|
|
502
|
+
}
|
|
503
|
+
const deckless = path.join(dir, "deckless.json");
|
|
504
|
+
fs.writeFileSync(deckless, JSON.stringify({ mcpServers: {} }));
|
|
505
|
+
assert.equal(overlayPlaywrightMcpConfig({ runtime: "claude_code", mcpConfigPath: deckless, tempDir: dir, server: { command: "npx", args: [] } }).ok, false);
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
test("playwright overlay extends a scoped codex home without touching the original", () => {
|
|
512
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "probe-codex-"));
|
|
513
|
+
try {
|
|
514
|
+
const home = path.join(dir, "codex-home");
|
|
515
|
+
fs.mkdirSync(home);
|
|
516
|
+
fs.writeFileSync(path.join(home, "config.toml"), '[mcp_servers.agent-deck]\nurl = "http://127.0.0.1:1110/mcp"\ndisabled_tools = ["call_service_tool"]\n');
|
|
517
|
+
const overlaid = overlayPlaywrightMcpConfig({
|
|
518
|
+
runtime: "codex_local",
|
|
519
|
+
mcpConfigPath: home,
|
|
520
|
+
tempDir: dir,
|
|
521
|
+
server: { command: "npx", args: ["@playwright/mcp@1.2.3"] },
|
|
522
|
+
});
|
|
523
|
+
assert.equal(overlaid.ok, true);
|
|
524
|
+
if (overlaid.ok) {
|
|
525
|
+
assert.equal(overlaid.mcpEnv?.CODEX_HOME, overlaid.mcpConfigPath);
|
|
526
|
+
const raw = fs.readFileSync(path.join(overlaid.mcpConfigPath, "config.toml"), "utf8");
|
|
527
|
+
assert.ok(raw.includes("[mcp_servers.agent-deck]"));
|
|
528
|
+
assert.ok(raw.includes("[mcp_servers.playwright]"));
|
|
529
|
+
assert.ok(raw.includes('disabled_tools = ["call_service_tool"]'));
|
|
530
|
+
assert.ok(!fs.readFileSync(path.join(home, "config.toml"), "utf8").includes("playwright"));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
test("runner uses injected seams and appends lifecycle controls (no real spawn)", async () => {
|
|
538
|
+
const report = validReport();
|
|
539
|
+
const manifest = await runReviewerBrowserProbe({
|
|
540
|
+
runtime: "claude_code",
|
|
541
|
+
contract: "direct",
|
|
542
|
+
repoPath: "/tmp/fake-repo",
|
|
543
|
+
headSha: HEAD,
|
|
544
|
+
deckId: "deck-1",
|
|
545
|
+
probeId: "probe-runner-1",
|
|
546
|
+
timeoutMs: 60_000,
|
|
547
|
+
}, {
|
|
548
|
+
createWorktree: (async () => ({ path: "/tmp/fake-repo-wt", role: "reviewer", ref: HEAD, detached: true })),
|
|
549
|
+
removeWorktree: (async () => ({ removed: true })),
|
|
550
|
+
prepareDeck: async () => ({ ok: true, mcpConfigPath: "/tmp/fake-deck.json" }),
|
|
551
|
+
releaseDeck: async () => { },
|
|
552
|
+
readHead: (async () => HEAD),
|
|
553
|
+
readRuntimeVersion: async () => "claude 9.9.9",
|
|
554
|
+
now: () => new Date("2026-10-08T00:00:00.000Z"),
|
|
555
|
+
spawn: (async (input) => {
|
|
556
|
+
// The runner drives the REAL reviewer spawn SHAPE: reviewer policy, the
|
|
557
|
+
// probe prompt, the detached worktree cwd, and the deck config.
|
|
558
|
+
assert.equal(input.runtime, "claude_code");
|
|
559
|
+
assert.equal(input.policy.worktreeWrite, false);
|
|
560
|
+
assert.equal(input.policy.outboundMutation, false);
|
|
561
|
+
assert.ok(input.prompt.includes(HEAD));
|
|
562
|
+
assert.ok(input.mcpConfigPath);
|
|
563
|
+
return {
|
|
564
|
+
exitCode: 0,
|
|
565
|
+
transcript: `preamble\n\`\`\`json\n${JSON.stringify(report)}\n\`\`\`\n`,
|
|
566
|
+
logPath: "/tmp/fake.ndjson",
|
|
567
|
+
timedOut: false,
|
|
568
|
+
};
|
|
569
|
+
}),
|
|
570
|
+
});
|
|
571
|
+
assert.equal(manifest.status, "pass");
|
|
572
|
+
assert.equal(manifest.headSha, HEAD);
|
|
573
|
+
assert.equal(manifest.verdictBinding.headShaVerified, true);
|
|
574
|
+
assert.ok(manifest.verdictBinding.manifestSha256);
|
|
575
|
+
const ids = manifest.negativeControls.map((c) => c.id);
|
|
576
|
+
for (const id of REVIEWER_NEGATIVE_CONTROL_IDS)
|
|
577
|
+
assert.ok(ids.includes(id), `manifest covers ${id}`);
|
|
578
|
+
assert.equal(manifest.cleanup.tempDirRemoved, true);
|
|
579
|
+
assert.equal(manifest.cleanup.childProcessesRemaining, 0);
|
|
580
|
+
});
|
|
581
|
+
test("runner records not_run when the transcript carries no report", async () => {
|
|
582
|
+
// A real scoped codex home: the runner enforces the production read-only
|
|
583
|
+
// preflight, which reads the config codex itself would read (NOT-134).
|
|
584
|
+
const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "probe-nr-codex-"));
|
|
585
|
+
try {
|
|
586
|
+
fs.writeFileSync(path.join(codexHome, "config.toml"), '[mcp_servers.agent-deck]\nurl = "http://127.0.0.1:1110/mcp"\ndisabled_tools = ["call_service_tool"]\n');
|
|
587
|
+
const manifest = await runReviewerBrowserProbe({ runtime: "codex_local", contract: "direct", repoPath: "/tmp/fake-repo", headSha: HEAD, deckId: "deck-1", probeId: "probe-nr-1" }, {
|
|
588
|
+
createWorktree: (async () => ({ path: "/tmp/wt", role: "reviewer", ref: HEAD, detached: true })),
|
|
589
|
+
removeWorktree: (async () => ({ removed: true })),
|
|
590
|
+
prepareDeck: async () => ({ ok: true, mcpConfigPath: codexHome, mcpEnv: { CODEX_HOME: codexHome } }),
|
|
591
|
+
releaseDeck: async () => { },
|
|
592
|
+
readHead: (async () => HEAD),
|
|
593
|
+
now: () => new Date("2026-10-08T00:00:00.000Z"),
|
|
594
|
+
spawn: (async () => ({ exitCode: 0, transcript: "I looked at it, no JSON, sorry.", logPath: "/tmp/x", timedOut: false })),
|
|
595
|
+
});
|
|
596
|
+
assert.equal(manifest.status, "not_run");
|
|
597
|
+
assert.match(manifest.statusReason, /no parseable reviewer report/);
|
|
598
|
+
}
|
|
599
|
+
finally {
|
|
600
|
+
fs.rmSync(codexHome, { recursive: true, force: true });
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
test("runner records not_run when the deck preflight is unavailable (fail-closed)", async () => {
|
|
604
|
+
const manifest = await runReviewerBrowserProbe({ runtime: "claude_code", contract: "direct", repoPath: "/tmp/fake-repo", headSha: HEAD, deckId: "deck-1", probeId: "probe-deck-1" }, {
|
|
605
|
+
createWorktree: (async () => ({ path: "/tmp/wt", role: "reviewer", ref: HEAD, detached: true })),
|
|
606
|
+
removeWorktree: (async () => ({ removed: true })),
|
|
607
|
+
prepareDeck: async () => ({ ok: false, kind: "deck_unavailable", reason: "Agent Deck is unreachable — refused" }),
|
|
608
|
+
releaseDeck: async () => { },
|
|
609
|
+
readHead: (async () => HEAD),
|
|
610
|
+
now: () => new Date("2026-10-08T00:00:00.000Z"),
|
|
611
|
+
spawn: (async () => {
|
|
612
|
+
throw new Error("must not spawn without a deck");
|
|
613
|
+
}),
|
|
614
|
+
});
|
|
615
|
+
assert.equal(manifest.status, "not_run");
|
|
616
|
+
assert.match(manifest.statusReason, /deck_unavailable/);
|
|
617
|
+
});
|
|
618
|
+
test("runner never spawns muse_code: deterministic blocked control", async () => {
|
|
619
|
+
let spawned = false;
|
|
620
|
+
const manifest = await runReviewerBrowserProbe({ runtime: "muse_code", contract: "direct", repoPath: "/tmp/fake-repo", headSha: HEAD, deckId: "deck-1", probeId: "probe-muse-1" }, {
|
|
621
|
+
readRuntimeVersion: async () => "muse 1.3.0",
|
|
622
|
+
now: () => new Date("2026-10-08T00:00:00.000Z"),
|
|
623
|
+
spawn: (async () => {
|
|
624
|
+
spawned = true;
|
|
625
|
+
throw new Error("muse control must not spawn");
|
|
626
|
+
}),
|
|
627
|
+
});
|
|
628
|
+
assert.equal(spawned, false);
|
|
629
|
+
assert.equal(manifest.status, "blocked");
|
|
630
|
+
assert.equal(manifest.statusReason, MUSE_PROBE_BLOCKED_REASON);
|
|
631
|
+
assert.equal(manifest.runtimeVersion, "muse 1.3.0");
|
|
632
|
+
assert.equal(manifest.cleanup.tempDirRemoved, true);
|
|
633
|
+
});
|
|
634
|
+
test("runner rejects a non-SHA head and a missing deck before doing anything", async () => {
|
|
635
|
+
await assert.rejects(() => runReviewerBrowserProbe({ runtime: "claude_code", contract: "direct", repoPath: "/tmp/r", headSha: "not-a-sha", deckId: "d" }, { now: () => new Date() }), /40-char hex/);
|
|
636
|
+
await assert.rejects(() => runReviewerBrowserProbe({ runtime: "claude_code", contract: "direct", repoPath: "/tmp/r", headSha: HEAD, deckId: " " }, { now: () => new Date() }), /deckId/);
|
|
637
|
+
});
|
|
638
|
+
test("manifest filenames are unique per (runtime, contract)", () => {
|
|
639
|
+
const names = new Set();
|
|
640
|
+
for (const runtime of ["claude_code", "codex_local", "muse_code", "cursor_local"]) {
|
|
641
|
+
for (const contract of ["direct", "playwright-mcp", "coordinator-preview"]) {
|
|
642
|
+
const name = probeManifestFilename(runtime, contract);
|
|
643
|
+
assert.ok(!names.has(name), `duplicate ${name}`);
|
|
644
|
+
names.add(name);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
assert.equal(probeManifestFilename("claude_code", "direct"), "claude_code.direct.probe.json");
|
|
648
|
+
});
|
|
649
|
+
test("canonical JSON sorts keys deterministically", () => {
|
|
650
|
+
assert.equal(canonicalProbeJson({ b: 1, a: { d: 4, c: 3 } }), `{\n "a": {\n "c": 3,\n "d": 4\n },\n "b": 1\n}\n`);
|
|
651
|
+
});
|
|
652
|
+
test("withManifestHash is stable for identical manifests", () => {
|
|
653
|
+
const launch = {
|
|
654
|
+
bin: "b",
|
|
655
|
+
argv: [],
|
|
656
|
+
cwd: "c",
|
|
657
|
+
mcpConfigPath: null,
|
|
658
|
+
mcpEnvKeys: [],
|
|
659
|
+
policy: roleCeiling("reviewer"),
|
|
660
|
+
};
|
|
661
|
+
const base = {
|
|
662
|
+
schemaVersion: REVIEWER_BROWSER_PROBE_SCHEMA_VERSION,
|
|
663
|
+
probeId: "p",
|
|
664
|
+
runtime: "claude_code",
|
|
665
|
+
contract: "direct",
|
|
666
|
+
startedAt: "s",
|
|
667
|
+
endedAt: "e",
|
|
668
|
+
timedOut: false,
|
|
669
|
+
cancelled: false,
|
|
670
|
+
exitCode: 0,
|
|
671
|
+
launch,
|
|
672
|
+
cleanup: cleanCleanup(),
|
|
673
|
+
status: "pass",
|
|
674
|
+
statusReason: "r",
|
|
675
|
+
...validReport(),
|
|
676
|
+
};
|
|
677
|
+
const a = withManifestHash({ ...base, verdictBinding: { headShaVerified: true, manifestSha256: null } });
|
|
678
|
+
const b = withManifestHash({ ...base, verdictBinding: { headShaVerified: true, manifestSha256: null } });
|
|
679
|
+
assert.equal(a.verdictBinding.manifestSha256, b.verdictBinding.manifestSha256);
|
|
680
|
+
});
|
|
@@ -31,7 +31,7 @@ import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
|
|
|
31
31
|
const num = (name, dflt) => Number(process.env[name] ?? dflt);
|
|
32
32
|
export const coordinatorConfig = {
|
|
33
33
|
get maxConcurrency() {
|
|
34
|
-
return num("MAX_COORDINATOR_CONCURRENCY",
|
|
34
|
+
return num("MAX_COORDINATOR_CONCURRENCY", 5);
|
|
35
35
|
},
|
|
36
36
|
get leaseMs() {
|
|
37
37
|
// Default 60s — coordinator Node-worker liveness, not Cursor think time. See
|