agent-dealer 1.3.0 → 1.3.2

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