@deeeed/metamask-harness 0.70.1 → 0.72.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/adapters/extension/console-tail.mjs +38 -6
  3. package/adapters/manifest.json +128 -0
  4. package/adapters/terminal/cleanup.mjs +68 -0
  5. package/adapters/terminal/inject.mjs +74 -0
  6. package/adapters/terminal/launch.mjs +628 -0
  7. package/adapters/terminal/lib/cdp-client.mjs +84 -0
  8. package/adapters/terminal/lib/display.mjs +115 -0
  9. package/adapters/terminal/lib/hud.mjs +148 -0
  10. package/adapters/terminal/lib/mainnet-guard.mjs +177 -0
  11. package/adapters/terminal/lib/origin.mjs +30 -0
  12. package/adapters/terminal/lib/page-script.mjs +246 -0
  13. package/adapters/terminal/lib/processes.mjs +187 -0
  14. package/adapters/terminal/lib/readiness.mjs +274 -0
  15. package/adapters/terminal/lib/strict-wallet.mjs +138 -0
  16. package/adapters/terminal/stop.mjs +37 -0
  17. package/adapters/terminal/verify.mjs +48 -0
  18. package/adapters/terminal/wallet-host.mjs +703 -0
  19. package/dist/adapters/console-capture.js +122 -0
  20. package/dist/adapters/extension/console-capture.js +22 -85
  21. package/dist/adapters/mobile/verify.js +8 -3
  22. package/dist/adapters/surface.js +3 -1
  23. package/dist/adapters/terminal/console-capture.js +46 -0
  24. package/dist/adapters/terminal/stop.js +19 -0
  25. package/dist/adapters/terminal/surface.js +104 -0
  26. package/dist/adapters.js +27 -4
  27. package/dist/cli.js +2 -2
  28. package/dist/command-contract.js +12 -4
  29. package/dist/commands/check.js +1 -1
  30. package/dist/commands/config.js +1 -1
  31. package/dist/commands/doctor.js +24 -1
  32. package/dist/commands/domain.js +1 -1
  33. package/dist/commands/farmslot-ready.js +2 -0
  34. package/dist/commands/help.js +2 -2
  35. package/dist/commands/launch/index.js +97 -2
  36. package/dist/commands/manifest.js +3 -3
  37. package/dist/commands/recipe-advice.js +1 -1
  38. package/dist/commands/review.js +1 -1
  39. package/dist/commands/run-engine.js +1 -1
  40. package/dist/commands/shared.js +1 -1
  41. package/dist/doctor.js +3 -0
  42. package/dist/harness.js +17 -11
  43. package/dist/manifest.js +5 -0
  44. package/dist/mm-harness-cli.js +34 -20
  45. package/dist/paths.js +2 -2
  46. package/dist/recipe-security.js +12 -3
  47. package/dist/review/knowledge.js +1 -1
  48. package/dist/run-diagnostics.js +150 -33
  49. package/dist/runner.js +1 -1
  50. package/dist/runtime-context.js +3 -3
  51. package/docs/CONTRIBUTING.md +1 -1
  52. package/docs/cli-contract.md +163 -0
  53. package/library/actions/shared/console-allowlist-add.mjs +137 -0
  54. package/library/actions/shared/console-collector.mjs +230 -0
  55. package/library/actions/shared/console-findings.mjs +511 -0
  56. package/library/actions/terminal/app/assert_no_console_errors.mjs +119 -0
  57. package/library/actions/terminal/app/launch.mjs +45 -0
  58. package/library/actions/terminal/perps/_venue.mjs +242 -0
  59. package/library/actions/terminal/perps/assert_orders.mjs +54 -0
  60. package/library/actions/terminal/perps/assert_positions.mjs +40 -0
  61. package/library/actions/terminal/perps/read_orders.mjs +26 -0
  62. package/library/actions/terminal/perps/read_positions.mjs +25 -0
  63. package/library/actions/terminal/perps/read_snapshot.mjs +40 -0
  64. package/library/actions/terminal/perps/revoke_agent.mjs +58 -0
  65. package/library/actions/terminal/perps/teardown_state.mjs +104 -0
  66. package/library/actions/terminal/platform/hud.mjs +71 -0
  67. package/library/actions/terminal/platform/page.mjs +247 -0
  68. package/library/actions/terminal/platform/runtime.mjs +200 -0
  69. package/library/actions/terminal/ui/navigate.mjs +31 -0
  70. package/library/actions/terminal/wallet/_signature-log.mjs +177 -0
  71. package/library/actions/terminal/wallet/assert_signatures.mjs +43 -0
  72. package/library/actions/terminal/wallet/list_accounts.mjs +27 -0
  73. package/library/actions/terminal/wallet/read_signatures.mjs +37 -0
  74. package/library/manifests/terminal.action-manifest.json +1786 -0
  75. package/package.json +2 -1
@@ -3,8 +3,27 @@ import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { getAdapterSurface } from "./adapters/surface.js";
6
- import { runnerDir } from "./paths.js";
7
- import { ensureExtensionConsoleCapture } from "./adapters/extension/console-capture.js";
6
+ import { recipeRuntimePath, runnerDir } from "./paths.js";
7
+ import { ensureExtensionConsoleCapture, extensionCaptureFiles, extensionCdpPort } from "./adapters/extension/console-capture.js";
8
+ import {
9
+ ensureTerminalConsoleCapture,
10
+ terminalCaptureFiles,
11
+ terminalCdpPort,
12
+ terminalRuntimeFile
13
+ } from "./adapters/terminal/console-capture.js";
14
+ import {
15
+ collectorOwnerMarker,
16
+ collectorStatus,
17
+ verifyCapture
18
+ } from "../library/actions/shared/console-collector.mjs";
19
+ import {
20
+ allowlistMatch,
21
+ classifyConsoleRecord,
22
+ consoleRecords,
23
+ libraryRootsFromEnv,
24
+ loadConsoleAllowlist,
25
+ messageSignature
26
+ } from "../library/actions/shared/console-findings.mjs";
8
27
  const MAX_CAPTURE_BYTES = 512 * 1024;
9
28
  const MAX_FINDINGS = 20;
10
29
  const MAX_PREVIEW_CHARS = 320;
@@ -26,7 +45,9 @@ function formatRunDiagnosticsForHuman(diagnostics, adapter) {
26
45
  const lines = [`${status} \u2014 ${diagnostics.note}`];
27
46
  for (const finding of diagnostics.findings) {
28
47
  const count = finding.count > 1 ? ` \xD7${finding.count}` : "";
29
- lines.push(`${finding.level.toUpperCase()}${count} \u2014 ${finding.preview}`);
48
+ const source = finding.source ? ` [${finding.source}]` : "";
49
+ const allowed = finding.allowlisted ? ` (allowlisted: ${finding.allowlisted.reason})` : "";
50
+ lines.push(`${finding.level.toUpperCase()}${count}${source} \u2014 ${finding.preview}${allowed}`);
30
51
  }
31
52
  return lines;
32
53
  }
@@ -34,27 +55,64 @@ async function beginRunDiagnostics(adapter, projectRoot) {
34
55
  const source = getAdapterSurface(adapter).appLogSource(projectRoot);
35
56
  if (!source) return null;
36
57
  const mobileIssueBuffer = adapter === "mobile" && process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME !== "1" && armMobileIssueBuffer(projectRoot) ? { projectRoot } : void 0;
37
- if (adapter === "extension") {
38
- await ensureExtensionConsoleCapture(projectRoot).catch(() => void 0);
58
+ let capture;
59
+ if (adapter === "extension" || adapter === "terminal") {
60
+ const start = adapter === "extension" ? ensureExtensionConsoleCapture : ensureTerminalConsoleCapture;
61
+ const startError = await start(projectRoot).then(() => void 0, (error) => error instanceof Error ? error.message : String(error));
62
+ const { extensionLog, pageLog, pidFile } = adapter === "extension" ? extensionCaptureFiles(projectRoot) : terminalCaptureFiles(projectRoot);
63
+ const cdpPort = adapter === "extension" ? extensionCdpPort() : terminalCdpPort();
64
+ capture = { adapter, projectRoot, extensionLog, pageLog, pidFile, ...cdpPort ? { cdpPort } : {}, ...startError ? { startError } : {} };
39
65
  }
66
+ const walletLogPath = adapter === "terminal" ? terminalRuntimeFile(projectRoot, "wallet-requests.jsonl") : null;
67
+ const walletStat = walletLogPath ? safeStat(walletLogPath) : null;
40
68
  const stat = safeStat(source.path);
41
69
  return {
42
70
  source,
43
71
  offset: stat?.size ?? 0,
44
72
  ...stat ? { inode: stat.ino } : {},
45
- ...mobileIssueBuffer ? { mobileIssueBuffer } : {}
73
+ ...mobileIssueBuffer ? { mobileIssueBuffer } : {},
74
+ ...capture ? { capture } : {},
75
+ ...walletLogPath ? { walletLog: { path: walletLogPath, offset: walletStat?.size ?? 0, ...walletStat ? { inode: walletStat.ino } : {} } } : {}
46
76
  };
47
77
  }
78
+ async function verifyConsoleCapture(capture) {
79
+ if (capture.startError) return { verified: false, detail: `the console collector did not start: ${capture.startError}` };
80
+ if (!capture.cdpPort) return { verified: false, detail: "no CDP port for the console collector" };
81
+ const status = collectorStatus({
82
+ pidFile: capture.pidFile,
83
+ extensionLog: capture.extensionLog,
84
+ marker: collectorOwnerMarker(path.dirname(capture.pidFile)),
85
+ cdpPort: capture.cdpPort
86
+ });
87
+ if (!status.ok) return { verified: false, detail: status.detail };
88
+ let control;
89
+ if (capture.adapter === "extension") {
90
+ let extensionId = null;
91
+ try {
92
+ extensionId = fs.readFileSync(recipeRuntimePath(capture.projectRoot, "extension.id"), "utf8").trim();
93
+ } catch {
94
+ extensionId = null;
95
+ }
96
+ control = await verifyCapture({ cdpPort: capture.cdpPort, log: capture.extensionLog, target: "extension", extensionId });
97
+ } else {
98
+ const browser = readJsonRecord(terminalRuntimeFile(capture.projectRoot, "browser.json"));
99
+ if (typeof browser?.appOrigin !== "string") return { verified: false, detail: "no slot browser state (browser.json) to verify the page console capture against" };
100
+ control = await verifyCapture({ cdpPort: capture.cdpPort, log: capture.pageLog, target: "page", appOrigin: browser.appOrigin });
101
+ }
102
+ return { verified: control.ok, detail: control.ok ? `${status.detail}; ${control.detail}` : control.detail };
103
+ }
48
104
  function isRunDiagnosticsDocument(value) {
49
105
  if (!isRecord(value) || value.schemaVersion !== 1 || value.scope !== "recipe-run-application") return false;
50
106
  if (value.status !== "clean" && value.status !== "review" && value.status !== "unavailable") return false;
51
107
  return typeof value.note === "string" && Array.isArray(value.findings);
52
108
  }
53
- function finishRunDiagnostics(baseline, result) {
109
+ async function finishRunDiagnostics(baseline, result) {
54
110
  if (!baseline) return result;
55
111
  try {
56
112
  const bufferedIssues = baseline.mobileIssueBuffer ? collectMobileIssueBuffer(baseline.mobileIssueBuffer.projectRoot) : void 0;
57
- const { diagnostics, rawAppLog } = collectRunDiagnosticsCapture(baseline, bufferedIssues);
113
+ const allowlist = loadConsoleAllowlist(recipeLibraryRoots(result.summaryPath));
114
+ const capture = baseline.capture ? await verifyConsoleCapture(baseline.capture) : void 0;
115
+ const { diagnostics, rawAppLog } = collectRunDiagnosticsCapture(baseline, bufferedIssues, allowlist, capture);
58
116
  const artifactsDir = path.dirname(result.summaryPath);
59
117
  const diagnosticsPath = path.join(artifactsDir, "diagnostics.json");
60
118
  fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}
@@ -87,16 +145,17 @@ function finishRunDiagnostics(baseline, result) {
87
145
  };
88
146
  }
89
147
  }
90
- function collectRunDiagnostics(baseline, bufferedIssues) {
91
- return collectRunDiagnosticsCapture(baseline, bufferedIssues).diagnostics;
148
+ function collectRunDiagnostics(baseline, bufferedIssues, allowlist = null, capture) {
149
+ return collectRunDiagnosticsCapture(baseline, bufferedIssues, allowlist, capture).diagnostics;
92
150
  }
93
- function collectRunDiagnosticsCapture(baseline, bufferedIssues) {
151
+ function collectRunDiagnosticsCapture(baseline, bufferedIssues, allowlist = null, capture) {
94
152
  const stat = safeStat(baseline.source.path);
95
153
  const source = {
96
154
  label: baseline.source.label,
97
155
  path: baseline.source.path,
98
156
  startOffset: baseline.offset,
99
157
  endOffset: stat?.size ?? baseline.offset,
158
+ inode: void 0,
100
159
  bytesRead: 0,
101
160
  truncated: false,
102
161
  inAppBuffer: bufferedIssues === void 0 ? "n/a" : bufferedIssues === null ? "unavailable" : "collected",
@@ -111,22 +170,31 @@ function collectRunDiagnosticsCapture(baseline, bufferedIssues) {
111
170
  const bytesToRead = Math.min(available, MAX_CAPTURE_BYTES);
112
171
  source.startOffset = startOffset;
113
172
  source.endOffset = stat.size;
173
+ source.inode = stat.ino;
114
174
  source.bytesRead = bytesToRead;
115
175
  source.truncated = available > MAX_CAPTURE_BYTES;
116
176
  if (bytesToRead > 0) rawAppLog = readSlice(baseline.source.path, startOffset, bytesToRead);
177
+ if (source.truncated) rawAppLog = rawAppLog.subarray(0, rawAppLog.lastIndexOf(10) + 1);
117
178
  }
118
179
  const text = rawAppLog.toString("utf8");
180
+ const lineFindings = consoleRecords(text).map(classifyRecord).filter((finding) => finding !== null);
181
+ applyConsoleAllowlist(lineFindings, allowlist);
119
182
  const allFindings = dedupeFindings(
120
183
  [
121
- ...text.split(/\r?\n/u).map(classifyLine).filter((finding) => finding !== null),
122
- ...(bufferedIssues ?? []).map(classifyBufferedIssue).filter((finding) => finding !== null)
184
+ ...lineFindings,
185
+ ...(bufferedIssues ?? []).map(classifyBufferedIssue).filter((finding) => finding !== null),
186
+ ...baseline.walletLog ? walletLogFindings(baseline.walletLog) : []
123
187
  ]
124
188
  );
125
- const findings = allFindings.slice(0, MAX_FINDINGS);
189
+ const applied = allFindings.filter((finding) => finding.level === "info").length;
190
+ allFindings.sort((a, b) => Number(a.level === "info") - Number(b.level === "info"));
191
+ const findings = allFindings.slice(0, MAX_FINDINGS).map(stripInternal);
126
192
  const counts = countFindings(allFindings);
127
193
  const omittedFindingCount = Math.max(0, allFindings.length - findings.length);
128
- const status = counts.total > 0 ? "review" : stat || bufferedIssues !== void 0 && bufferedIssues !== null ? "clean" : "unavailable";
129
- const note = counts.total > 0 ? `Observed ${counts.total} distinct application warning/error event(s) during the recipe run; showing ${findings.length} preview(s) and omitting ${omittedFindingCount}. Relation to the task is not determined.` : status === "clean" ? "No application warnings or errors were emitted during the recipe run." : "Application diagnostics were unavailable for this run.";
194
+ const captureFailed = capture !== void 0 && !capture.verified;
195
+ const status = counts.total > 0 ? "review" : !captureFailed && !source.truncated && (stat || bufferedIssues !== void 0 && bufferedIssues !== null) ? "clean" : "unavailable";
196
+ const captureNote = (captureFailed ? ` Console capture was not verified: ${capture.detail}.` : "") + (source.truncated ? ` The run's log exceeded ${MAX_CAPTURE_BYTES} bytes; only its start was checked.` : "");
197
+ const note = (counts.total > 0 ? `Observed ${counts.total} distinct application warning/error event(s) during the recipe run; showing ${findings.length} preview(s) and omitting ${omittedFindingCount}. Relation to the task is not determined.` : status === "clean" ? "No application warnings or errors were emitted during the recipe run." : "Application diagnostics were unavailable for this run.") + captureNote;
130
198
  return { diagnostics: {
131
199
  schemaVersion: 1,
132
200
  scope: "recipe-run-application",
@@ -135,24 +203,47 @@ function collectRunDiagnosticsCapture(baseline, bufferedIssues) {
135
203
  note,
136
204
  source,
137
205
  counts,
206
+ ...allowlist ? { allowlist: { entries: allowlist.entries.length, applied, problems: allowlist.problems } } : {},
207
+ ...capture ? { capture } : {},
138
208
  displayedFindingCount: findings.length,
139
209
  omittedFindingCount,
140
210
  findings
141
211
  }, rawAppLog };
142
212
  }
143
- function classifyLine(line) {
144
- const trimmed = line.trim();
145
- if (!trimmed) return null;
146
- let level = null;
147
- if (/\bEXCEPTION\s{2,}|\b(?:Uncaught|UnhandledPromiseRejection|FATAL EXCEPTION)\b/iu.test(trimmed)) {
148
- level = "exception";
149
- } else if (/^(?:ERROR\s{2,}|\[error\]\s*)/iu.test(trimmed) || /\]\s+ERROR\s{2,}/u.test(trimmed)) {
150
- level = "error";
151
- } else if (/^(?:WARN(?:ING)?\s{2,}|\[warn(?:ing)?\]\s*)/iu.test(trimmed) || /\]\s+WARN(?:ING)?\s{2,}/u.test(trimmed)) {
152
- level = "warning";
213
+ function classifyRecord(record) {
214
+ const classified = classifyConsoleRecord(record);
215
+ if (!classified) return null;
216
+ const line = record.line;
217
+ const signature = messageSignature(redactDiagnosticText(classified.message));
218
+ return {
219
+ ...makeFinding(classified.level, line.trim(), `${classified.source}|${signature}`),
220
+ source: classified.source,
221
+ signature: redactPreview(signature),
222
+ firstFrame: classified.firstFrame ? redactPreview(classified.firstFrame) : null,
223
+ event: classified.key
224
+ };
225
+ }
226
+ function walletLogFindings(walletLog) {
227
+ const stat = safeStat(walletLog.path);
228
+ if (!stat) return [];
229
+ const sameFile = walletLog.inode === void 0 || walletLog.inode === stat.ino;
230
+ const start = sameFile && stat.size >= walletLog.offset ? walletLog.offset : 0;
231
+ const findings = [];
232
+ for (const line of readSlice(walletLog.path, start, stat.size - start).toString("utf8").split("\n")) {
233
+ let entry;
234
+ try {
235
+ entry = JSON.parse(line);
236
+ } catch {
237
+ continue;
238
+ }
239
+ if (!isRecord(entry)) continue;
240
+ let text = null;
241
+ if (entry.kind === "blocked-mainnet" && entry.probe !== true) text = `blocked-mainnet: ${String(entry.transport ?? "request")} to ${String(entry.url ?? "?")}`;
242
+ else if (entry.kind === "refused-mainnet") text = `refused-mainnet: ${String(entry.method ?? "?")} (${String(entry.reason ?? "?")})`;
243
+ else if (entry.kind === "unattributed") text = `unattributed wallet ${entry.binding === "request" ? "request" : "log entry"}${typeof entry.method === "string" ? ` (${entry.method})` : ""}`;
244
+ if (text) findings.push({ ...makeFinding("error", text, `wallet|${text}`), source: "wallet", signature: text, firstFrame: null });
153
245
  }
154
- if (!level) return null;
155
- return makeFinding(level, trimmed);
246
+ return findings;
156
247
  }
157
248
  function classifyBufferedIssue(value) {
158
249
  if (!isRecord(value) || typeof value.text !== "string") return null;
@@ -160,15 +251,26 @@ function classifyBufferedIssue(value) {
160
251
  const level = rawLevel === "warn" || rawLevel === "warning" ? "warning" : rawLevel === "error" ? "error" : rawLevel === "exception" ? "exception" : null;
161
252
  return level ? makeFinding(level, value.text) : null;
162
253
  }
163
- function makeFinding(level, text) {
254
+ function makeFinding(level, text, groupKey) {
164
255
  const preview = redactPreview(text.trim());
165
- const identity = preview.replace(/\b\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?\b/gu, "[TIME]").replace(/\[(?:sw|page:[^\]]+|console:[^\]]+)\]/gu, "[APP]").replace(/^(?:(?:WARN(?:ING)?|ERROR|EXCEPTION|\[TIME\]|\[APP\])\s*)+/u, "");
256
+ const identity = groupKey ?? preview.replace(/\b\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?\b/gu, "[TIME]").replace(/\[(?:sw|page:[^\]]+|console:[^\]]+)\]/gu, "[APP]").replace(/^(?:(?:WARN(?:ING)?|ERROR|EXCEPTION|\[TIME\]|\[APP\])\s*)+/u, "");
166
257
  return {
167
258
  level,
168
259
  fingerprint: createHash("sha256").update(`${level}|${identity}`).digest("hex").slice(0, 12),
169
260
  preview
170
261
  };
171
262
  }
263
+ function applyConsoleAllowlist(findings, allowlist) {
264
+ if (!allowlist) return;
265
+ for (const finding of findings) {
266
+ if (!finding.event) continue;
267
+ const match = allowlistMatch({ source: finding.source, key: finding.event }, allowlist);
268
+ if (!match) continue;
269
+ finding.allowlisted = { reason: match.reason, entry: match.index, file: match.file, ...match.temporary ? { temporary: true } : {} };
270
+ finding.level = "info";
271
+ finding.fingerprint = createHash("sha256").update(`${finding.fingerprint}|info|${match.file}#${match.index}`).digest("hex").slice(0, 12);
272
+ }
273
+ }
172
274
  function armMobileIssueBuffer(projectRoot) {
173
275
  const armed = runMobileIssueCommand(projectRoot, "issues-arm");
174
276
  if (!armed) return false;
@@ -209,10 +311,17 @@ function dedupeFindings(findings) {
209
311
  return [...byFingerprint.values()];
210
312
  }
211
313
  function countFindings(findings) {
212
- const counts = { total: findings.length, warning: 0, error: 0, exception: 0 };
213
- for (const finding of findings) counts[finding.level] += 1;
314
+ const counts = { total: 0, warning: 0, error: 0, exception: 0, info: 0 };
315
+ for (const finding of findings) {
316
+ counts[finding.level] += 1;
317
+ if (finding.level !== "info") counts.total += 1;
318
+ }
214
319
  return counts;
215
320
  }
321
+ function stripInternal(finding) {
322
+ const { event: _event, ...rest } = finding;
323
+ return rest;
324
+ }
216
325
  function readSlice(filePath, offset, length) {
217
326
  const fd = fs.openSync(filePath, "r");
218
327
  try {
@@ -266,6 +375,13 @@ function indexDiagnosticSummary(summaryPath, diagnostics) {
266
375
  fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}
267
376
  `);
268
377
  }
378
+ function recipeLibraryRoots(summaryPath) {
379
+ const summary = readJsonRecord(summaryPath);
380
+ const libraries = isRecord(summary?.recipeLibraries) ? summary.recipeLibraries : null;
381
+ const sources = Array.isArray(libraries?.sources) ? libraries.sources : [];
382
+ const roots = sources.map((source) => isRecord(source) && typeof source.root === "string" ? source.root : null).filter((root) => Boolean(root));
383
+ return roots.length ? roots : libraryRootsFromEnv();
384
+ }
269
385
  function readJsonRecord(filePath) {
270
386
  try {
271
387
  const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
@@ -282,5 +398,6 @@ export {
282
398
  collectRunDiagnostics,
283
399
  finishRunDiagnostics,
284
400
  formatRunDiagnosticsForHuman,
285
- readRunDiagnosticsDocument
401
+ readRunDiagnosticsDocument,
402
+ verifyConsoleCapture
286
403
  };
package/dist/runner.js CHANGED
@@ -89,7 +89,7 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
89
89
  const { createAgentDeviceUiTransport } = await importMetaMaskNativeUiProvider();
90
90
  return createAgentDeviceUiTransport(options2);
91
91
  }
92
- }, preparedLiveAdapters)
92
+ }, preparedLiveAdapters, { hudPolicy: options.autoHud === true ? "show" : "auto" })
93
93
  });
94
94
  const existing = new Set([...core, ...ui].map((entry) => entry.action));
95
95
  const lifecycle = mobileSourceAwareLifecycleAdapters(
@@ -91,7 +91,7 @@ function validExistingContext(context, repoRoot, runtimeDir, adapter) {
91
91
  if (runtimeStart[field] !== void 0 && (typeof runtimeStart[field] !== "string" || runtimeStart[field].length === 0)) return false;
92
92
  }
93
93
  }
94
- const forbiddenResources = adapter === "core" ? ["cdpPort", "watcherPort", "metroPort", "devServerPort", "simulator", "simulatorUdid", "adbSerial", "extensionId"] : adapter === "extension" ? ["metroPort", "simulator", "simulatorUdid", "adbSerial"] : ["cdpPort", "extensionId"];
94
+ const forbiddenResources = adapter === "core" ? ["cdpPort", "watcherPort", "metroPort", "devServerPort", "simulator", "simulatorUdid", "adbSerial", "extensionId"] : adapter === "extension" ? ["metroPort", "simulator", "simulatorUdid", "adbSerial"] : adapter === "terminal" ? ["metroPort", "simulator", "simulatorUdid", "adbSerial", "extensionId"] : ["cdpPort", "extensionId"];
95
95
  if (forbiddenResources.some((field) => context[field] !== void 0)) return false;
96
96
  return true;
97
97
  }
@@ -107,7 +107,7 @@ function localDefaults(repoRoot, adapter, existing) {
107
107
  if (adapter === "extension" && !numericEnv("RECIPE_CDP_PORT", "CDP_PORT") && !existing.cdpPort) {
108
108
  defaults.cdpPort = claimLocalPort(repoRoot, "cdp", 2e4 + offset);
109
109
  }
110
- if (adapter !== "core" && !numericEnv("WATCHER_PORT", "METRO_PORT", "RECIPE_WATCHER_PORT") && !existing.watcherPort && !existing.metroPort && !existing.devServerPort) {
110
+ if (adapter !== "core" && adapter !== "terminal" && !numericEnv("WATCHER_PORT", "METRO_PORT", "RECIPE_WATCHER_PORT") && !existing.watcherPort && !existing.metroPort && !existing.devServerPort) {
111
111
  defaults.watcherPort = claimLocalPort(repoRoot, "watcher", (adapter === "extension" ? 3e4 : 4e4) + offset);
112
112
  }
113
113
  return defaults;
@@ -164,7 +164,7 @@ function runtimeResources(adapter, existing, defaults) {
164
164
  watcherPort,
165
165
  devServerPort: watcherPort
166
166
  };
167
- if (adapter === "extension") {
167
+ if (adapter === "extension" || adapter === "terminal") {
168
168
  shared.cdpPort = numericEnv("RECIPE_CDP_PORT", "CDP_PORT") ?? existing.cdpPort ?? defaults.cdpPort;
169
169
  } else {
170
170
  shared.metroPort = watcherPort;
@@ -93,7 +93,7 @@ The input path is argv 1 and `METAMASK_RECIPE_ADAPTER_INPUT`. Its document is:
93
93
  ```json
94
94
  {
95
95
  "schemaVersion": 1,
96
- "platform": "mobile|extension|core",
96
+ "platform": "mobile|extension|core|terminal",
97
97
  "action": "metamask.example.action",
98
98
  "node": {},
99
99
  "context": {
@@ -0,0 +1,163 @@
1
+ # mm-harness CLI contract
2
+
3
+ This page lists every `mm-harness` invocation that something outside this repo depends on, and what that caller reads back. The golden tests in `tests/contract/goldens-*.test.sh` freeze this surface. A refactor that changes any of it fails CI until someone accepts the change with `yarn test:goldens --update` and updates the callers in the same release.
4
+
5
+ Callers scanned (2026-10-02):
6
+ - Farmslot projects `metamask-{extension,mobile,core}-farm` and `va-mmcx-terminal-farm`: `project.json` hooks, `setup/*.sh`, `scripts/*.sh`, worker templates.
7
+ - Farmslot gateway: `services/gateway/src/methods/recipe.ts` and the modules it calls.
8
+ - Recipe libraries: `experimental-metamask-recipe-perps` and `experimental-metamask-recipe-terminal` (`checks/*.mjs`).
9
+ - This repo's README and `docs/`.
10
+
11
+ ## Resolution and environment
12
+
13
+ - Farms resolve the binary with `HARNESS_BIN="${METAMASK_HARNESS_BIN:-${MM_HARNESS_BIN:-mm-harness}}"; command -v "$HARNESS_BIN"`. `MM_HARNESS_BIN` hands the whole invocation to another checkout's `bin/mm-harness`.
14
+ - Static review runs `{{support}}/bin/mm-harness` with entry `dist/mm-harness-cli.js`, so the package layout (`bin/`, `dist/`) is part of the contract.
15
+ - Hook environment: `RECIPE_SLOT_ID`, `RECIPE_RUNTIME_DIR` (default `temp/recipe/runtime`), `RECIPE_WALLET_FIXTURE`, `RECIPE_LIBRARY_PATH`, `MM_HARNESS_DOMAIN`, `IOS_SIMULATOR`/`ADB_SERIAL` (mobile slots), `RECIPE_HARNESS_ROOT=temp/recipe/harness` (Extension).
16
+
17
+ ## Commands, callers, and what they consume
18
+
19
+ `G:` names the golden that freezes the row (`tests/contract/goldens/<family>/<case>.json`).
20
+
21
+ ### Recipe execution (gateway `recipe.ts`, worker templates, library checks)
22
+
23
+ | Command | Caller | Consumes | G: |
24
+ |---|---|---|---|
25
+ | `run <recipe> --adapter <a> --artifacts-dir <d> --target <repo> [--slot <s>] --json [--cdp-port <p>] [--watcher-port <p>] [--launch-existing-dist] [--record-video=full-run]` | `recipe_run` hook, all four packs | exit code, then the artifact package (below) | `core/run-hook`, `extension/run-hook`, `extension/run-hook-plan` (all flags incl. `--record-video=full-run`), `mobile/run-hook`, `mobile/run-hook-plan`, `core/run-invalid`, `*/run-unknown-flag` |
26
+ | `run <recipe> --artifacts-dir <d> --json` from the checkout | Core templates | exit code; `trace.json` on failure | `core/run-autodetect` |
27
+ | `run <recipe> [k=v…] --plan [--adapter <a>] [--library ns=dir] [--json]`, with `RECIPE_LIBRARY_PATH=ns=dir` or cleared | templates; perps `checks/*.mjs` | exit code; `JSON.parse(stdout).status === 'pass'`; on rejection stdout+stderr matching `RECIPE_PARAMS_INVALID\|parameter\|required\|enum`. No harness code emits `RECIPE_PARAMS_INVALID`: plan rejections exit 5 with findings `recipe.missing_param` / `recipe.invalid_param_value_enum` / unknown-param, and a real run exits 5 with `RECIPE_VALIDATION_FAILED`. The check passes on the `parameter\|required\|enum` alternatives | `core/run-plan`, `mobile/run-plan`, `library/*` |
28
+ | `run --list [--adapter <a>] [--json]`, `run <r> --describe --json` | templates, docs | prose | `discovery/run-list-*`, `discovery/run-describe` |
29
+ | `call <action> [k=v…] --adapter <a> [--target] [--watcher-port] [--json]` | mobile `unlock` hook, templates | exit code (unlock output is ignored) | `core/call-command`, `mobile/call-unlock` |
30
+ | `actions --raw --adapter <a> --json` | `recipe_action_manifest` hook, all packs | stdout is a Recipe v1 action manifest (`validateRecipeActionManifestDocument`) | `discovery/actions-raw-*` |
31
+ | `actions --adapter <a> --json` | templates (Core runs `jq -r '.actions[].name'`) | `.actions[].name` | `discovery/actions-*` |
32
+ | `actions --action <name> --json`, `actions <term> --json` | templates, docs | prose | `discovery/actions-action`, `discovery/actions-search` |
33
+
34
+ ### Readiness (preflight, health_check, recipe_doctor)
35
+
36
+ | Command | Caller | Consumes | G: |
37
+ |---|---|---|---|
38
+ | `doctor --adapter <a> --target <repo> [--cdp-port\|--watcher-port <p>] --json` | `recipe_doctor` hook | `runner_protocol_version === 1`, `status === 'pass'`, every `checks[].status === 'pass'` | `*/doctor-json` |
39
+ | `doctor --adapter <a> --target <repo> [--cdp-port\|--watcher-port <p>] [--runtime-dir <rd>] [--device <d>] --print-ready` | `health_check` (gateway `slot/check.ts`, `2>/dev/null`) | exit 0 and trimmed stdout equal to `OK` (Extension, Mobile) or `ready` (Core) | `*/doctor-print-ready`, `mobile/doctor-print-ready-device` |
40
+ | `doctor … --expect-live [--json]` | Extension `ensure-runtime-ready.sh`, `preflight.sh`; templates | exit code; JSON shown to the agent | `extension/doctor-expect-live-json`, `mobile/doctor-expect-live-json` |
41
+ | `prepare --target <repo> --platform <extension\|mobile\|core> --artifacts-dir <repo>/<rd> --json` | preflight (all packs, stdout to `/dev/null`) | exit code; gateway reads `<rd>/sandbox.json`: `schemaVersion === 1`, `steps[]` (`id`, `status`, `reason`), `ready`, `harness.name`, `harness.version` | `*/prepare` |
42
+ | `install --adapter <a> --target <repo>` | `recipe_harness_install`, Core preflight | exit code | `*/install` |
43
+ | `verify --adapter <a> --target <repo> [--json]` | `recipe_harness_verify`, Core preflight | exit code (JSON not parsed) | `core/verify`, `mobile/verify` |
44
+ | `cleanup --adapter <a> --target <repo>` | `recipe_harness_cleanup`, Extension recycle | exit code | `*/cleanup` |
45
+
46
+ ### Runtime lifecycle
47
+
48
+ | Command | Caller | Consumes | G: |
49
+ |---|---|---|---|
50
+ | `launch --verify --adapter extension --target <repo> --watcher-port <p> --cdp-port <p> --surface <fullscreen\|sidepanel> [--build] [--url <u>]` | Extension `preflight.sh` | exit code | `extension/launch-verify` |
51
+ | `launch --adapter extension --target --cdp-port (--fullscreen \| --sidepanel --url <u>) [--verify] [--build] [--remote-flag K=V] [--json]` | Extension browser resource boot hook and slot actions | exit code | `extension/launch-boot-fullscreen`, `extension/launch-sidepanel-url`, `extension/launch-verify-remote-flag`, `extension/launch-build-verify` (cold, through the stubbed browser/build leaf; `live-calls.log` frozen) |
52
+ | `launch <ios\|android> --adapter mobile --target <repo> --verify --json --watcher-port <p> [--build] [--device <d>]` | Mobile `preflight.sh` | exit code; stdout saved to `<rd>/mobile-launch/summary.json` (not parsed) | `mobile-launch/launch-ios-verify`, `mobile-launch/launch-ios-device-verify`, `mobile-launch/launch-android-device-verify` (`leaf-calls.log` frozen) |
53
+ | `stop --adapter <extension\|mobile> --port <p> --target <repo>` | teardown, recycle, shutdown hooks | exit code | `extension/stop`, `mobile/stop` |
54
+ | `fixtures set --adapter extension --target <repo> --fixture <rd>/wallet-fixture.json` | Extension preflight | exit code | `extension/fixtures-set-no-browser` |
55
+ | `fixtures set --adapter mobile --target <repo> --json` | Mobile preflight | exit code; stdout saved to `<rd>/mobile-launch/wallet-setup.json` | `mobile/fixtures-set` |
56
+ | `fixtures generate --target <repo> --fixture <rd>/wallet-fixture.json --out <rd>/fixture-state.json` | Extension `launch-browser.sh` | reads `fixture-state.json` | `extension/fixtures-generate` |
57
+ | `<repo>/temp/recipe/harness/extension/runner/bin/mm-harness resolve-extension --adapter extension --target <repo>` (overlay path, not the public bin) | Extension `setup/launch-browser.sh:53,449` | stdout must match `/^[a-p]{32}$/`; failure falls back to scanning | `extension/resolve-extension-overlay` |
58
+ | `provision runway <ios\|android> --adapter mobile --target --slot --watcher-port --runtime-dir --run <id> [--device] [--force]` | Mobile `runway-preflight.sh` | exit code (non-zero falls back) | `mobile/provision-runway-android` (ios needs `gh` + network: not covered) |
59
+
60
+ ### Task tooling (gateway and worker templates)
61
+
62
+ | Command | Caller | Consumes | G: |
63
+ |---|---|---|---|
64
+ | `checklist mark <taskDir> {start\|<n>\|complete [--mark-last]\|blocked --reason <s>\|no-change --reason <s>\|--help}` | `{{TASK_DIR}}/mark` shim (`mark_cmd`) | exit code; `SIGNAL.json`, `CHECKLIST.md` | `task/mark-*` |
65
+ | `pr-body render <taskDir> --json` (cwd = task dir) | gateway `pr-body-render.ts` (`pr_body_cmd`) | on failure `JSON.parse(stdout).error`, else stderr; then `artifacts/pr-body.md` | `task/pr-body-render-*` |
66
+ | `review checklist --out <file> [--domain <d>] [--since <sha>]` | domain fixtures, review templates | the written file | `task/review-checklist` |
67
+ | `check diff …`, `recipe-quality build …` | templates | exit code | not covered: run repo lint/test tooling |
68
+
69
+ ### Discovery (moves to Farmslot in phase 2a)
70
+
71
+ `--help`, `--version`, `help [review] --json`, `actions` (above), `run --list`, `call --list`, `completion-candidates actions`, `execution-template {new,list,materialize} --json`, `completions {zsh,bash}`, and the unknown-command error. G: `discovery/*`.
72
+
73
+ ## Files callers read
74
+
75
+ - **Recipe artifacts dir** (`--artifacts-dir`): `summary.json` (`.status`), `trace.json`, `artifact-manifest.json` (`.runStatus` equals `summary.status`; Recipe v1), optional `recipe.json`, `recipe-resolution.json` with `resolved-recipes/<sha256>.recipe.json`, and videos when `--record-video=full-run`. Frozen with full normalised values (`valueTrees`) for passing runs in `core/run-hook`, `core/run-autodetect`, `core/call-command` and `library/run-library-recipe`. `extension/run-hook` and `mobile/run-hook` run cold with no runtime, so they freeze the failure envelope and exit code, and their artifact tree is `null` (nothing is written). A passing Extension or Mobile artifact package needs a live runtime and is not covered.
76
+ - **`<rd>/sandbox.json`** from `prepare`: frozen in full by `*/prepare`.
77
+ - **Pid files and logs** under `<rd>`: `browser.pid`, `recipe-harness-webpack.pid`, `recipe-harness-webpack.log` (Extension); `metro.log` (Mobile); the gateway kills `launcher.pid`, `browser.pid`, `chromium.pid`, `webpack.pid` and deletes `extension.id` and `preflight.pgid`. Live-runtime only, so not covered by the cold-path goldens.
78
+ - **`<rd>/fixture-state.json`** from `fixtures generate`: `extension/fixtures-generate`.
79
+ - **Task artifacts**: `SIGNAL.json`, `artifacts/pr-body.md`, review checklist file.
80
+
81
+ ## Callers that are out of contract today
82
+
83
+ These are real farm bugs. The goldens freeze the current rejections, so fixing either side shows up as a reviewed golden change. The farm fixes go in a separate Farmslot PR.
84
+
85
+ | Caller | Today | Correct call | G: |
86
+ |---|---|---|---|
87
+ | Mobile `scripts/cleanup-recipe-harness.sh:30` (`recipe_harness_cleanup`) | `cleanup … --allow-managed-changes` exits 2 (`CLI_UNKNOWN_OPTION`); its `No mobile harness backup found` fallback never matches, so the hook always fails | `cleanup --adapter mobile --target <repo>` | `mobile/cleanup-allow-managed-changes`, `mobile/cleanup` |
88
+ | Mobile `project.json` `unlock` hook | `call app.unlock` exits 2 (unknown action); the gateway ignores the result, so unlock is a silent no-op | `call metamask.wallet.ensure_unlocked --adapter mobile --target <repo> --watcher-port <p>` | `mobile/call-unlock` |
89
+ | Mobile `scripts/runway-preflight.sh:66` | `provision runway android` exits 2 (only `ios`), so the Android runway profile always falls back | add Android runway to the harness, or have the farm reject Android before calling | `mobile/provision-runway-android` |
90
+ | Mobile templates `dev.md`, `dev-interactive.md`, `fix-bug.md`, `review-pr.md` | `mark complete --status blocked --outcome partial --reason …` exits 2 | `mark blocked --reason "<why>"` | `task/mark-invalid-status-outcome`, `task/mark-blocked` |
91
+ | Docs | `launch --build-lavamoat` (only `runtime-launch` has it); `run --target <recipe-file>` | `runtime-launch --build-lavamoat`; `run <recipe-file> --target <repo>` | — |
92
+
93
+ ## Terminal adapter: TODO
94
+
95
+ #298 merged the `terminal` adapter (`fd1c77e`). Discovery already reflects it (`discovery/actions-matrix`, the adapter hint in `help`). **Follow-up, not in this PR:** freeze its farm hooks once a stub browser replaces the LaunchServices launch of a visible Chrome for Testing (the goldens must never start a real browser). Add `tests/contract/goldens-terminal.test.sh` covering the `va-mmcx-terminal-farm` hooks:
96
+
97
+ - [ ] `run <recipe> --adapter terminal --artifacts-dir <d> --target <repo> --slot <s> --cdp-port <p> --watcher-port <p> --json`
98
+ - [ ] `actions --raw --adapter terminal --json`
99
+ - [ ] `doctor --adapter terminal --target <repo> --cdp-port <p> --watcher-port <p> --json`
100
+ - [ ] `install --adapter terminal --target <repo>`
101
+ - [ ] `verify --adapter terminal --target <repo> -- --cdp-port <p> --watcher-port <p> --account <a>`
102
+ - [ ] `cleanup --adapter terminal --target <repo> -- --cdp-port <p>`
103
+ - [ ] `launch --adapter terminal --target <repo> --cdp-port <p> --watcher-port <p> --signer extension --account <a>` (use the stub browser from #298's `tests/fixtures/terminal-stub-browser.mjs`)
104
+ - [ ] `stop --adapter terminal --target <repo>` with `RECIPE_CDP_PORT`
105
+ - [ ] `run terminal.perps.<name> k=v… --adapter terminal --target <t> --cdp-port <p> --watcher-port <p> --plan` (recipe-terminal `checks/plan-recipes.mjs`)
106
+ - [ ] runtime layout of `temp/recipe/runtime/terminal/` (`browser.pid` is watched by the farm)
107
+ - [ ] discovery: add `terminal` to the per-adapter loop in `goldens-discovery.test.sh` (`actions --raw`, `actions`, `run --list`, `call --list`)
108
+ - [ ] recipe-terminal `console-guard.mjs` imports the harness's `library/actions/shared/console-findings.mjs` and `tests/fixtures/console-allowlist-vectors.json`: freeze both paths and the exported surface
109
+
110
+ ## Running the goldens
111
+
112
+ ```bash
113
+ yarn test:goldens # compare all families in parallel (CI runs them in the contract shards)
114
+ yarn test:goldens mobile # families whose name contains "mobile"
115
+ yarn test:goldens --update # rewrite goldens, print a per-field diff summary, drop orphans
116
+ ```
117
+
118
+ Every family checks its own golden directory: a golden no case writes fails the family, in CI too (`--update` deletes it). Every family also fails on a golden directory that has no `goldens-<dir>.test.sh`, so deleting a whole family script is caught. Families run in parallel locally and sequentially inside the contract CI shards.
119
+
120
+ Each case runs under a safety cap, `GOLDENS_CASE_TIMEOUT` (default 600 s). A case that hits it reports `TIMEOUT <case>` and fails without being compared or written, so `--update` can never accept a truncated capture.
121
+
122
+ Each family is a normal contract test (`tests/contract/goldens-<family>.test.sh`). `gd_init` (`tests/contract/goldens/lib.sh`) builds a sandbox on `ct_init`, and every case runs under `env -i`:
123
+ - `gd_init` first unsets every inherited `MM_HARNESS_*`, `RECIPE_*`, `METAMASK_*` and `FARMSLOT_*` variable, plus device and port pins (`IOS_SIMULATOR`, `ADB_SERIAL`, `WATCHER_PORT`, `METRO_PORT`, `CDP_PORT`, `IDB_PATH`, `ANDROID_HOME`, …). A developer's `MM_HARNESS_BIN` or `MM_HARNESS_IDB_PATH` export therefore cannot redirect a case;
124
+ - the environment is an allowlist: sandbox `HOME`, caches, config, `FARMSLOT_HOME`, `TZ=UTC`, no update probe, and only the variables the family sets itself;
125
+ - every interpreter and tool the goldens launch is resolved to its real binary before `HOME` moves into the sandbox: `node` (`process.execPath`), `python3` (`sys.executable`, for the test shell's port holder only), `git` (`git --exec-path`), `bash` (`$BASH`) and `timeout`. A tool that resolves to a script, the shape of an asdf/mise/volta/pyenv shim, fails the family up front with a message naming it. Each is checked to run with a sandboxed `HOME`;
126
+ - `PATH` is pinned to the case stubs, the sandbox stubs, those real binaries, and `/usr/bin:/bin:/usr/sbin:/sbin`. Host tools (`idb`, `capture-helper`, a developer's Chrome) cannot change a golden;
127
+ - device-tool discovery is pinned: `MM_HARNESS_IDB_PATH` and `MM_HARNESS_ADB_PATH` point at sandbox stubs, which discovery checks before any absolute Homebrew or pipx path;
128
+ - localhost probes cannot reach a real server. `gd_reserve_ports` holds ephemeral ports that are bound but never listened on (`127.0.0.1` and `::1`), so Metro `/status` and CDP probes cannot connect (on macOS they time out rather than being refused) and nothing real can take the port mid-run. Each family exports the farm's slot port variables (`RECIPE_CDP_PORT`, `WATCHER_PORT`, `METRO_PORT`) set to those ports, so commands without an explicit port use them too. Goldens show them as `<PORT:NAME>`, rewritten only in port positions (`*port` keys, `:<port>`, `port <n>`/`PORT=<n>`, or an argument that is exactly the port), so an unrelated equal number such as a 60000 ms timeout is never rewritten;
129
+ - `tmux`, `xcrun` and `adb` are stubbed (`ct_init`, plus one booted simulator and one Android device for Mobile);
130
+ - forbidden stubs record any call to `open`, `osascript`, `lsappinfo`, `screencapture`, `yarn`, `npx`, `npm`, `corepack`, `gh` and Chrome/Chromium, and the family fails if one is called. A browser stub answers only `--version`, and `npm` only `root -g` (install-source detection), with a sandbox path;
131
+ - inert stubs on the case PATH only: `lsof`, `pgrep` and `pkill` find nothing, `ps` passes per-pid queries (`-p`, the CLI identifying its own processes) and lists an empty host for table listings, and `watchman` and `caffeinate` are no-ops. `stop` cannot see or signal a real process or touch a host daemon;
132
+ - `RECIPE_HARNESS_BROWSER` points at the sandbox Chrome stub, so browser resolution never probe-launches Chrome for Testing or reads host Chrome policy;
133
+ - `MM_HARNESS_MOBILE_VERIFY_PROBE_TIMEOUT_MS=2000` shortens Mobile verify's live-bridge wait (default 60 s), because no fixture provides a debug target.
134
+
135
+ Mobile Metro, device, bridge and wallet leaves, the Extension build/browser leaf, and the fixture-state leaf are stubbed through `MM_HARNESS_SCRIPT_BIN_*`. `leaf-calls.log` and `live-calls.log` are frozen per launch case.
136
+
137
+ A golden records:
138
+ - `bin` (only when a case runs another executable) and argv;
139
+ - the exit code;
140
+ - stdout (parsed JSON, or lines), and stderr when the exit code is non-zero;
141
+ - `--file`/`--log` files in full;
142
+ - `--tree` runtime trees: relative paths plus each JSON file's key shape, where arrays record the union of every element;
143
+ - `--value-tree` artifact packages: relative paths plus each JSON file's normalised values;
144
+ - `--shallow-tree` for the internal overlay: paths two levels deep, no shapes.
145
+
146
+ The goldens need the harness to be a git checkout: `execution-provenance.json` `runner.head` and `libraries[0].head` are `null` outside one, which changes the artifact goldens. CI and the farm checkouts satisfy this.
147
+
148
+ ### Normaliser
149
+
150
+ `tests/contract/goldens/normalize.mjs` is the only normaliser. It rewrites only volatile values:
151
+
152
+ 1. Absolute paths: the sandbox becomes `<SANDBOX>`, the repo `<REPO>`, the real `$HOME` `<HOME>`, the node binary and its prefix `<NODE>`/`<NODE_PREFIX>`, and `os.tmpdir()` `<TMP>`. Both `/var` and `/private/var` spellings are covered. Ports from `gd_reserve_ports` become `<PORT:NAME>` in port positions only: numbers under `*port` keys, and in text after `:`, `port `, `port=` or `PORT=`, or a whole string equal to the port.
153
+ 2. UUIDs become `<UUID>`. Git object ids (40 hex, or a short hex value under a `*head`/`*gitRef`/`*commit` key) become `<GIT_REF>`. ISO-8601 timestamps become `<TIMESTAMP>`, and compact stamps in names `<STAMP>`. Path-derived slot ids `local-<adapter>-<8 hex>` become `local-<adapter>-<HASH>`.
154
+ 3. The package version becomes `<HARNESS_VERSION>`. Any other `x.y.z` under a key ending in `version` becomes `<VERSION>`.
155
+ 4. Numbers under volatile keys: `*pid` becomes `<PID>`, `*port` `<PORT>`, `*At`/`*Time`/`*timestamp`/`mtime*` `<TIMESTAMP>`, and `*Ms`/`duration*`/`elapsed*` `<DURATION>`. Values inside `schema` and `examples` subtrees (static manifest content) are never rewritten.
156
+ 5. `invocationDigest` becomes `<DIGEST>`: it hashes `recipe-invocation.json`, which carries sandbox paths and timestamps and is frozen itself in normalised form. `runner.sourceFingerprint` becomes `<FINGERPRINT>`: it hashes the harness source tree, so any harness edit changes it. A `status` holding `git status --porcelain` output (provenance `runner.status` and `libraries[].status`, empty when clean) becomes `<GIT_STATUS>`. Content-only digests (recipe and dependency digests) stay frozen.
157
+ 6. Durations in text (`0.3s`, `123ms`) become `<DURATION>`.
158
+
159
+ In tree listings, files whose names differ only by a volatile token collapse into one row: a count and the union of their key shapes, or the sorted list of their values in a value tree.
160
+
161
+ The `--update` summary diffs objects by key. Arrays whose entries all carry a unique `id`, `name` or `path` (manifest artifacts) diff by that key, so an inserted entry reads as one addition. Lists of plain values report `+[added] -[removed]`; other arrays diff element by element.
162
+
163
+ The goldens run from the source checkout, so `harness.source` reads `source-checkout/source-checkout` and `harness.executable` is `<REPO>/bin/mm-harness`. On the farm both name the installed package instead.