@deeeed/metamask-harness 0.70.0 → 0.71.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 (78) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +16 -9
  3. package/adapters/extension/console-tail.mjs +38 -6
  4. package/adapters/extension/launch-browser.cjs +16 -4
  5. package/adapters/manifest.json +128 -0
  6. package/adapters/shared/browser-cdp.cjs +21 -7
  7. package/adapters/shared/browser-resolver.cjs +22 -11
  8. package/adapters/terminal/cleanup.mjs +68 -0
  9. package/adapters/terminal/inject.mjs +74 -0
  10. package/adapters/terminal/launch.mjs +628 -0
  11. package/adapters/terminal/lib/cdp-client.mjs +84 -0
  12. package/adapters/terminal/lib/display.mjs +115 -0
  13. package/adapters/terminal/lib/hud.mjs +148 -0
  14. package/adapters/terminal/lib/mainnet-guard.mjs +177 -0
  15. package/adapters/terminal/lib/origin.mjs +30 -0
  16. package/adapters/terminal/lib/page-script.mjs +246 -0
  17. package/adapters/terminal/lib/processes.mjs +187 -0
  18. package/adapters/terminal/lib/readiness.mjs +274 -0
  19. package/adapters/terminal/lib/strict-wallet.mjs +138 -0
  20. package/adapters/terminal/stop.mjs +37 -0
  21. package/adapters/terminal/verify.mjs +48 -0
  22. package/adapters/terminal/wallet-host.mjs +703 -0
  23. package/dist/adapters/console-capture.js +122 -0
  24. package/dist/adapters/extension/console-capture.js +22 -85
  25. package/dist/adapters/surface.js +3 -1
  26. package/dist/adapters/terminal/console-capture.js +46 -0
  27. package/dist/adapters/terminal/stop.js +19 -0
  28. package/dist/adapters/terminal/surface.js +104 -0
  29. package/dist/adapters.js +27 -4
  30. package/dist/cli.js +2 -2
  31. package/dist/command-contract.js +12 -4
  32. package/dist/commands/check.js +1 -1
  33. package/dist/commands/config.js +1 -1
  34. package/dist/commands/doctor.js +24 -1
  35. package/dist/commands/domain.js +1 -1
  36. package/dist/commands/farmslot-ready.js +2 -0
  37. package/dist/commands/help.js +2 -2
  38. package/dist/commands/launch/index.js +97 -2
  39. package/dist/commands/manifest.js +3 -3
  40. package/dist/commands/recipe-advice.js +1 -1
  41. package/dist/commands/review.js +1 -1
  42. package/dist/commands/run-engine.js +1 -1
  43. package/dist/commands/shared.js +1 -1
  44. package/dist/doctor.js +4 -1
  45. package/dist/harness.js +17 -11
  46. package/dist/manifest.js +5 -0
  47. package/dist/mm-harness-cli.js +34 -20
  48. package/dist/paths.js +2 -2
  49. package/dist/recipe-security.js +12 -3
  50. package/dist/review/knowledge.js +1 -1
  51. package/dist/run-diagnostics.js +150 -33
  52. package/dist/runner.js +1 -1
  53. package/dist/runtime-context.js +3 -3
  54. package/docs/CONTRIBUTING.md +1 -1
  55. package/library/actions/extension/platform/cdp.mjs +35 -10
  56. package/library/actions/shared/console-allowlist-add.mjs +137 -0
  57. package/library/actions/shared/console-collector.mjs +230 -0
  58. package/library/actions/shared/console-findings.mjs +511 -0
  59. package/library/actions/terminal/app/assert_no_console_errors.mjs +119 -0
  60. package/library/actions/terminal/app/launch.mjs +45 -0
  61. package/library/actions/terminal/perps/_venue.mjs +242 -0
  62. package/library/actions/terminal/perps/assert_orders.mjs +54 -0
  63. package/library/actions/terminal/perps/assert_positions.mjs +40 -0
  64. package/library/actions/terminal/perps/read_orders.mjs +26 -0
  65. package/library/actions/terminal/perps/read_positions.mjs +25 -0
  66. package/library/actions/terminal/perps/read_snapshot.mjs +40 -0
  67. package/library/actions/terminal/perps/revoke_agent.mjs +58 -0
  68. package/library/actions/terminal/perps/teardown_state.mjs +104 -0
  69. package/library/actions/terminal/platform/hud.mjs +71 -0
  70. package/library/actions/terminal/platform/page.mjs +247 -0
  71. package/library/actions/terminal/platform/runtime.mjs +200 -0
  72. package/library/actions/terminal/ui/navigate.mjs +31 -0
  73. package/library/actions/terminal/wallet/_signature-log.mjs +177 -0
  74. package/library/actions/terminal/wallet/assert_signatures.mjs +43 -0
  75. package/library/actions/terminal/wallet/list_accounts.mjs +27 -0
  76. package/library/actions/terminal/wallet/read_signatures.mjs +37 -0
  77. package/library/manifests/terminal.action-manifest.json +1786 -0
  78. package/package.json +1 -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": {
@@ -373,6 +373,26 @@ const runnerRequire = createRequire(import.meta.url);
373
373
  const runnerRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
374
374
  const browserResolver = runnerRequire(path.join(runnerRoot, 'adapters/shared/browser-resolver.cjs'));
375
375
  const { stopProfileProcessesSync } = runnerRequire(path.join(runnerRoot, 'adapters/extension/lib/validation-process-ownership.cjs'));
376
+ const browserCdp = runnerRequire(path.join(runnerRoot, 'adapters/shared/browser-cdp.cjs'));
377
+ const macosFocus = runnerRequire(path.join(runnerRoot, 'adapters/extension/lib/macos-focus.cjs'));
378
+
379
+ // On macOS a browser that opens a window at startup activates itself, so the
380
+ // autolaunched browser starts without one and gets its pages over CDP in the
381
+ // background (as the extension launcher does).
382
+ const startsWithoutWindow = () => process.platform === 'darwin';
383
+
384
+ // One bounded browser-level CDP operation (the shared helpers in
385
+ // browser-cdp.cjs never take the operator's focus).
386
+ async function withBrowserCdp(port, fn) {
387
+ const deadline = Date.now() + 15000;
388
+ const remaining = () => Math.max(1, deadline - Date.now());
389
+ const client = await browserCdp.connectBrowserCdp(port, { timeoutMs: remaining() });
390
+ try {
391
+ return await fn(client.send, remaining);
392
+ } finally {
393
+ client.close();
394
+ }
395
+ }
376
396
 
377
397
  // Same browser choice as the live launcher (RECIPE_HARNESS_BROWSER /
378
398
  // RECIPE_HARNESS_CHROME_BIN); branded Chrome loads the dist over CDP. This
@@ -491,6 +511,7 @@ export async function launchExistingDistRuntime(input, port) {
491
511
  const profileExisted = await canAccess(path.join(profileDir, 'Local State'));
492
512
  const browser = await resolveChromeBinary(projectRoot, profileExisted ? browserRecord : null);
493
513
  const loadsOverCdp = browser.extensionLoading === browserResolver.CDP_LOAD_UNPACKED;
514
+ const previousFrontmost = macosFocus.captureMacFrontmost();
494
515
  const stdoutFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
495
516
  const stderrFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
496
517
  let child;
@@ -508,7 +529,7 @@ export async function launchExistingDistRuntime(input, port) {
508
529
  '--disable-extensions-content-verification',
509
530
  '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
510
531
  ...browserResolver.extensionLaunchArgs(runtimeDist, browser.extensionLoading),
511
- 'chrome://extensions/',
532
+ startsWithoutWindow() ? '--no-startup-window' : 'chrome://extensions/',
512
533
  ], {
513
534
  cwd: projectRoot,
514
535
  detached: true,
@@ -534,7 +555,10 @@ export async function launchExistingDistRuntime(input, port) {
534
555
  try {
535
556
  await retryJsonGet(`http://127.0.0.1:${port}/json/version`, 45000);
536
557
  const owner = browserResolver.waitForCdpOwner(port, profileDir);
558
+ // The CDP load opens a background window itself; otherwise open one here.
559
+ // Without it the browser has no window, so a failure fails the launch.
537
560
  const cdpLoad = loadsOverCdp ? await browserResolver.loadUnpackedOverPort(port, runtimeDist, { profile: profileDir }) : null;
561
+ if (!loadsOverCdp && startsWithoutWindow()) await withBrowserCdp(port, browserCdp.ensureBackgroundWindow);
538
562
  launchedBrowser = {
539
563
  ...browser,
540
564
  launch: {
@@ -582,6 +606,8 @@ export async function launchExistingDistRuntime(input, port) {
582
606
  }
583
607
  throw error;
584
608
  }
609
+ // One check, at most one restore, only if this launch's browser holds the front.
610
+ macosFocus.restoreFrontmostIfOurs(previousFrontmost, [launchedBrowser.launch.pid]);
585
611
 
586
612
  await writeFile(path.join(runtimeRoot, 'runtime.json'), `${JSON.stringify({
587
613
  port,
@@ -618,17 +644,16 @@ function extensionIdFromAnyTarget(targets) {
618
644
  return null;
619
645
  }
620
646
 
621
- async function openExtensionHomePage(port, extensionId) {
647
+ // The wallet home goes into the launch's blank tab (so it is the window's
648
+ // selected page, and what a native screenshot of the window captures), or a
649
+ // background window when there is no blank tab; never a foreground one.
650
+ export async function openExtensionHomePage(port, extensionId) {
622
651
  const url = `chrome-extension://${extensionId}/home.html`;
623
- const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(url)}`;
624
- let response = await fetch(endpoint, { method: 'PUT' });
625
- if (response.status === 405 || response.status === 404) {
626
- response = await fetch(endpoint);
627
- }
628
- if (!response.ok) {
629
- throw new Error(`Failed to open extension home page ${url}: HTTP ${response.status}`);
652
+ try {
653
+ return await withBrowserCdp(port, (send, remaining) => browserCdp.openUrl(send, url, remaining));
654
+ } catch (error) {
655
+ throw new Error(`Failed to open extension home page ${url}: ${error.message}`);
630
656
  }
631
- return response.json();
632
657
  }
633
658
 
634
659
  async function ensureExtensionTarget(input, port) {
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ // Write console allowlist entries (schemaVersion 3) from a real capture, so
3
+ // entries are copied from observed events instead of written by hand.
4
+ //
5
+ // Usage:
6
+ // node console-allowlist-add.mjs <run artifacts dir | capture log> [options]
7
+ // --allowlist <file> the library's diagnostics/console-allowlist.json
8
+ // (default: from RECIPE_LIBRARY_PATH, first library)
9
+ // --reason <text> why these events are noise (required with --write)
10
+ // --link <url> code or issue the reason points at
11
+ // --temporary the entry goes once a known fix lands
12
+ // --pick <n,n,...> the candidates to write (numbers from the listing)
13
+ // --write append the picked candidates to the allowlist
14
+ // (needs --pick: every entry is a deliberate choice)
15
+ // A run artifacts dir is read through its diagnostics.json: the run's slice of
16
+ // the raw slot capture (diagnostics-app-log.txt is redacted, so its query
17
+ // strings would not match). Without --write it lists the candidates: every
18
+ // warning, error or exception no entry matches yet, one per distinct event.
19
+
20
+ import { closeSync, fstatSync, openSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ import {
25
+ ALLOWLIST_RELATIVE_PATH,
26
+ allowlistEntryFor,
27
+ allowlistMatch,
28
+ classifyConsoleRecord,
29
+ consoleRecords,
30
+ libraryRootsFromEnv,
31
+ loadConsoleAllowlist,
32
+ } from './console-findings.mjs';
33
+
34
+ function parseArgs(argv) {
35
+ const options = { input: null, allowlist: null, reason: null, link: null, temporary: false, pick: null, write: false };
36
+ for (let index = 0; index < argv.length; index += 1) {
37
+ const arg = argv[index];
38
+ if (arg === '--allowlist') options.allowlist = argv[++index];
39
+ else if (arg === '--reason') options.reason = argv[++index];
40
+ else if (arg === '--link') options.link = argv[++index];
41
+ else if (arg === '--temporary') options.temporary = true;
42
+ else if (arg === '--pick') options.pick = String(argv[++index]).split(',').map(Number);
43
+ else if (arg === '--write') options.write = true;
44
+ else if (!options.input && !arg.startsWith('--')) options.input = arg;
45
+ else throw new Error(`unknown argument ${arg}`);
46
+ }
47
+ if (!options.input) throw new Error('usage: console-allowlist-add.mjs <run artifacts dir | capture log> [--allowlist <file>] [--reason <text>] [--link <url>] [--temporary] [--pick <n,...>] [--write]');
48
+ return options;
49
+ }
50
+
51
+ // The capture text a run diagnosed, or a whole log file.
52
+ export function captureText(input) {
53
+ const run = readJsonOr(path.join(input, 'diagnostics.json'), null);
54
+ if (run) {
55
+ const { source } = run;
56
+ const fd = openSync(source.path, 'r');
57
+ try {
58
+ // The same file the run diagnosed: its inode, and long enough.
59
+ const stat = fstatSync(fd);
60
+ if ((source.inode !== undefined && source.inode !== stat.ino) || stat.size < source.endOffset) {
61
+ throw new Error(`${source.path} is not the capture the run diagnosed (it was rotated or replaced since)`);
62
+ }
63
+ return readFileSync(fd).subarray(source.startOffset, source.endOffset).toString('utf8');
64
+ } finally {
65
+ closeSync(fd);
66
+ }
67
+ }
68
+ return readFileSync(input, 'utf8');
69
+ }
70
+
71
+ // Distinct events no entry matches yet, as entries without a reason.
72
+ export function allowlistCandidates(text, allowlist = { entries: [] }) {
73
+ const candidates = new Map();
74
+ for (const record of consoleRecords(text)) {
75
+ const finding = classifyConsoleRecord(record);
76
+ if (!finding || allowlistMatch(finding, allowlist)) continue;
77
+ const entry = allowlistEntryFor(finding);
78
+ const id = JSON.stringify(entry);
79
+ const known = candidates.get(id);
80
+ if (known) known.count += 1;
81
+ else candidates.set(id, { entry, level: finding.level, count: 1 });
82
+ }
83
+ return [...candidates.values()];
84
+ }
85
+
86
+ function readJsonOr(file, fallback) {
87
+ try {
88
+ return JSON.parse(readFileSync(file, 'utf8'));
89
+ } catch (error) {
90
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return fallback;
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ export function appendEntries(file, entries) {
96
+ const document = readJsonOr(file, { schemaVersion: 3, entries: [] });
97
+ if (document.schemaVersion !== 3) throw new Error(`${file} is schemaVersion ${document.schemaVersion}; convert it to 3 first`);
98
+ document.entries.push(...entries);
99
+ writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
100
+ }
101
+
102
+ function main(argv) {
103
+ const options = parseArgs(argv);
104
+ const roots = options.allowlist ? [] : libraryRootsFromEnv();
105
+ const file = options.allowlist ?? (roots[0] ? path.join(roots[0], ALLOWLIST_RELATIVE_PATH) : null);
106
+ const allowlist = file ? loadConsoleAllowlist([path.dirname(path.dirname(file))]) : { entries: [], problems: [] };
107
+ const candidates = allowlistCandidates(captureText(options.input), allowlist);
108
+ const picked = options.pick ? candidates.filter((_, index) => options.pick.includes(index + 1)) : candidates;
109
+ if (!options.write) {
110
+ candidates.forEach((candidate, index) => {
111
+ process.stdout.write(`${index + 1}. ${candidate.level} ×${candidate.count}\n${JSON.stringify(candidate.entry, null, 2)}\n`);
112
+ });
113
+ if (candidates.length === 0) process.stdout.write('every warning and error in this capture is already allowlisted\n');
114
+ return 0;
115
+ }
116
+ if (!file) throw new Error('--write needs --allowlist <file> (or RECIPE_LIBRARY_PATH)');
117
+ if (!options.reason?.trim()) throw new Error('--write needs --reason');
118
+ if (!options.pick?.length) throw new Error('--write needs --pick <n,...>: choose the candidates to allowlist from the listing');
119
+ const entries = picked.map(({ entry }) => ({
120
+ ...entry,
121
+ reason: options.reason.trim(),
122
+ ...(options.link ? { link: options.link } : {}),
123
+ ...(options.temporary ? { temporary: true } : {}),
124
+ }));
125
+ appendEntries(file, entries);
126
+ process.stdout.write(`appended ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} to ${file}\n`);
127
+ return 0;
128
+ }
129
+
130
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
131
+ try {
132
+ process.exitCode = main(process.argv.slice(2));
133
+ } catch (error) {
134
+ process.stderr.write(`console-allowlist-add: ${error.message}\n`);
135
+ process.exitCode = 2;
136
+ }
137
+ }