@deeeed/metamask-harness 0.70.1 → 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 (73) hide show
  1. package/CHANGELOG.md +18 -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/surface.js +3 -1
  22. package/dist/adapters/terminal/console-capture.js +46 -0
  23. package/dist/adapters/terminal/stop.js +19 -0
  24. package/dist/adapters/terminal/surface.js +104 -0
  25. package/dist/adapters.js +27 -4
  26. package/dist/cli.js +2 -2
  27. package/dist/command-contract.js +12 -4
  28. package/dist/commands/check.js +1 -1
  29. package/dist/commands/config.js +1 -1
  30. package/dist/commands/doctor.js +24 -1
  31. package/dist/commands/domain.js +1 -1
  32. package/dist/commands/farmslot-ready.js +2 -0
  33. package/dist/commands/help.js +2 -2
  34. package/dist/commands/launch/index.js +97 -2
  35. package/dist/commands/manifest.js +3 -3
  36. package/dist/commands/recipe-advice.js +1 -1
  37. package/dist/commands/review.js +1 -1
  38. package/dist/commands/run-engine.js +1 -1
  39. package/dist/commands/shared.js +1 -1
  40. package/dist/doctor.js +3 -0
  41. package/dist/harness.js +17 -11
  42. package/dist/manifest.js +5 -0
  43. package/dist/mm-harness-cli.js +34 -20
  44. package/dist/paths.js +2 -2
  45. package/dist/recipe-security.js +12 -3
  46. package/dist/review/knowledge.js +1 -1
  47. package/dist/run-diagnostics.js +150 -33
  48. package/dist/runner.js +1 -1
  49. package/dist/runtime-context.js +3 -3
  50. package/docs/CONTRIBUTING.md +1 -1
  51. package/library/actions/shared/console-allowlist-add.mjs +137 -0
  52. package/library/actions/shared/console-collector.mjs +230 -0
  53. package/library/actions/shared/console-findings.mjs +511 -0
  54. package/library/actions/terminal/app/assert_no_console_errors.mjs +119 -0
  55. package/library/actions/terminal/app/launch.mjs +45 -0
  56. package/library/actions/terminal/perps/_venue.mjs +242 -0
  57. package/library/actions/terminal/perps/assert_orders.mjs +54 -0
  58. package/library/actions/terminal/perps/assert_positions.mjs +40 -0
  59. package/library/actions/terminal/perps/read_orders.mjs +26 -0
  60. package/library/actions/terminal/perps/read_positions.mjs +25 -0
  61. package/library/actions/terminal/perps/read_snapshot.mjs +40 -0
  62. package/library/actions/terminal/perps/revoke_agent.mjs +58 -0
  63. package/library/actions/terminal/perps/teardown_state.mjs +104 -0
  64. package/library/actions/terminal/platform/hud.mjs +71 -0
  65. package/library/actions/terminal/platform/page.mjs +247 -0
  66. package/library/actions/terminal/platform/runtime.mjs +200 -0
  67. package/library/actions/terminal/ui/navigate.mjs +31 -0
  68. package/library/actions/terminal/wallet/_signature-log.mjs +177 -0
  69. package/library/actions/terminal/wallet/assert_signatures.mjs +43 -0
  70. package/library/actions/terminal/wallet/list_accounts.mjs +27 -0
  71. package/library/actions/terminal/wallet/read_signatures.mjs +37 -0
  72. package/library/manifests/terminal.action-manifest.json +1786 -0
  73. package/package.json +1 -1
@@ -0,0 +1,703 @@
1
+ #!/usr/bin/env node
2
+ // wallet-host.mjs — long-running observer (and, in injected mode, signer) for the
3
+ // terminal slot browser.
4
+ //
5
+ // Purpose:
6
+ // Attaches to the slot browser over CDP and, for every app document, installs
7
+ // the request-log page script before page scripts run. The app tab is created
8
+ // blank, hooked, and only then navigated to the app, so the first app
9
+ // document already has the log binding; ready is written only after that is
10
+ // verified on the committed app document. Appends one JSON line
11
+ // per wallet request to <runtime>/wallet-requests.jsonl. In extension mode it
12
+ // also logs every MetaMask confirmation window it sees open. In injected mode
13
+ // it answers the page's EIP-1193 requests with the strict test wallet signing
14
+ // as the fixture account. It also redraws the recipe HUD (<runtime>/hud.json)
15
+ // on app pages after navigations and whenever the HUD state changes.
16
+ //
17
+ // Inputs (flags): --cdp-port <port> --runtime-dir <dir> --signer <extension|injected>
18
+ // --app-origin <url> [--extension-id <id>] [--target <checkout> --account <name>
19
+ // --start-chain <id>] (injected mode only) [--observe-focus 1]
20
+ // (a visible browser launched in the background on macOS: the host records
21
+ // in focus.log if a MetaMask notification window brought it to front; it
22
+ // never activates an app)
23
+ // Outputs: <runtime>/wallet-requests.jsonl, <runtime>/wallet-host.ready, stderr log.
24
+ // Exits when the browser connection closes.
25
+ // Never logs: key material, request params, messages, signatures.
26
+
27
+ import { appendFileSync, chmodSync, existsSync, unwatchFile, watchFile, writeFileSync } from 'node:fs';
28
+ import { createRequire } from 'node:module';
29
+ import path from 'node:path';
30
+
31
+ import { CdpClient, browserWebSocketUrl } from './lib/cdp-client.mjs';
32
+ import { hudFile, hudRenderExpression, readHudState } from './lib/hud.mjs';
33
+ import { LINK_HOSTS, hostOf, isMainnetVenueUrl, mainnetTypedDataReason } from './lib/mainnet-guard.mjs';
34
+ import { ownedBrowserPids } from './lib/processes.mjs';
35
+ import { isAppTopFrameContext, isAppUrl as isExactAppUrl } from './lib/origin.mjs';
36
+ import { LOG_BINDING, REQUEST_BINDING, RESOLVE_FN, pageScriptSource } from './lib/page-script.mjs';
37
+ import { createStrictWallet } from './lib/strict-wallet.mjs';
38
+
39
+ const { captureMacFrontmost, macFocusDisabled } = createRequire(import.meta.url)('../extension/lib/macos-focus.cjs');
40
+
41
+ function usage() {
42
+ return 'Usage: wallet-host.mjs --cdp-port <port> --runtime-dir <dir> --signer <extension|injected> --app-origin <url> [--network testnet|mainnet] [--mainnet-hosts <h1,h2>] [--testnet-hosts <h1,h2>] [--extension-id <id>] [--target <checkout> --account <name> --start-chain <id>] [--observe-focus 1] [--window x,y,w,h] [--start-path <path>] [--mm-harness-owner=<marker>]';
43
+ }
44
+
45
+ function parseArgs(argv) {
46
+ const args = {};
47
+ for (let index = 0; index < argv.length; index += 1) {
48
+ const flag = argv[index];
49
+ if (flag === '--help' || flag === '-h') {
50
+ process.stdout.write(`${usage()}\n`);
51
+ process.exit(0);
52
+ }
53
+ if (flag.startsWith('--') && flag.includes('=')) {
54
+ args[flag.slice(2, flag.indexOf('='))] = flag.slice(flag.indexOf('=') + 1);
55
+ continue;
56
+ }
57
+ if (!flag.startsWith('--') || index + 1 >= argv.length) throw new Error(usage());
58
+ args[flag.slice(2)] = argv[index + 1];
59
+ index += 1;
60
+ }
61
+ for (const key of ['cdp-port', 'runtime-dir', 'signer', 'app-origin']) {
62
+ if (!args[key]) throw new Error(`Missing --${key}\n${usage()}`);
63
+ }
64
+ return args;
65
+ }
66
+
67
+ const args = parseArgs(process.argv.slice(2));
68
+ const runtimeDir = path.resolve(args['runtime-dir']);
69
+ const logFile = path.join(runtimeDir, 'wallet-requests.jsonl');
70
+ const signer = args.signer;
71
+ const appOrigin = args['app-origin'].replace(/\/$/u, '');
72
+ const extensionOrigin = args['extension-id'] ? `chrome-extension://${args['extension-id']}` : null;
73
+ // Testnet runs are held to testnet here too: requests to the mainnet venue
74
+ // hosts are failed and logged, and a mainnet typed-data action is refused.
75
+ const network = args.network === 'mainnet' ? 'mainnet' : 'testnet';
76
+ const mainnetHosts = network === 'testnet' ? String(args['mainnet-hosts'] ?? '').split(',').filter(Boolean) : [];
77
+ if (network === 'testnet' && mainnetHosts.length === 0) throw new Error('testnet mode needs --mainnet-hosts (the venue hosts to block).');
78
+ // The testnet venue endpoints the served-network check looks for: API hosts
79
+ // only, never a link host (app.hyperliquid-testnet.xyz serves pages and icons).
80
+ const testnetHosts = String(args['testnet-hosts'] ?? '').split(',').filter((host) => host && !LINK_HOSTS.has(host));
81
+ const PROBE_MARK = 'mm-harness-probe=1';
82
+
83
+ // The request log is evidence; keep it owner-only even when it already exists.
84
+ if (existsSync(logFile)) chmodSync(logFile, 0o600);
85
+
86
+ function appendEntry(entry) {
87
+ appendFileSync(logFile, `${JSON.stringify({ signer, ...entry })}\n`, { mode: 0o600 });
88
+ }
89
+
90
+ function say(message) {
91
+ process.stderr.write(`${new Date().toISOString()} [wallet-host] ${message}\n`);
92
+ }
93
+
94
+ let wallet = null;
95
+ if (signer === 'injected') {
96
+ const { fixtureAccount } = await import('../../library/actions/terminal/platform/runtime.mjs');
97
+ if (!args.target || !args.account) throw new Error('injected mode requires --target and --account');
98
+ const account = await fixtureAccount(path.resolve(args.target), args.account);
99
+ wallet = createStrictWallet({ account, chainId: Number(args['start-chain'] ?? 42161) });
100
+ say(`injected strict wallet ready for ${args.account} (${account.address}) on chain ${wallet.chainId}`);
101
+ }
102
+
103
+ const source = pageScriptSource({ signer, appOrigin, network });
104
+ const client = await CdpClient.connect(await browserWebSocketUrl(Number(args['cdp-port'])));
105
+ // Hooks are installed once per target: a second session for the same tab
106
+ // (auto-attach plus a restored tab) would log every request twice.
107
+ const hookedTargets = new Map();
108
+ const appSessions = new Set();
109
+ // Per app session: its target id (= main frame id), the URL its top frame has
110
+ // committed, and live execution contexts, so a binding call can be traced to
111
+ // the frame, document and origin that made it.
112
+ const sessionTargets = new Map();
113
+ const sessionCommittedUrls = new Map();
114
+ const sessionContexts = new Map();
115
+ // Binding calls whose execution context, or that context's document, is not
116
+ // known yet.
117
+ const pendingCalls = new Map();
118
+ // Per app session: the URL of a top-frame commit no execution context has
119
+ // been tied to yet.
120
+ const awaitingDocuments = new Map();
121
+ const PENDING_CALL_TTL_MS = 5000;
122
+ // App sessions whose target runs with domains enabled; the HUD is drawn only
123
+ // there so no evaluate ever waits on a tab paused for setup.
124
+ const hudSessions = new Set();
125
+ const seenConfirmationUrls = new Map();
126
+
127
+ // New tabs start blank before they navigate to the app.
128
+ function isAppUrl(url) {
129
+ return url === 'about:blank' || url === '' || isExactAppUrl(url, appOrigin);
130
+ }
131
+
132
+ function isTopDefaultContext(sessionId, context) {
133
+ return context?.auxData?.isDefault === true && context.auxData.frameId === sessionTargets.get(sessionId);
134
+ }
135
+
136
+ // A top-frame commit is tied to the top frame's default context of its
137
+ // document: one created before the commit and still untied, or else the next
138
+ // one created.
139
+ // A commit is applied once: Page.getFrameTree and Page.frameNavigated can
140
+ // both report the same document (its loaderId); a second report would leave a
141
+ // stale commit waiting for the next document's context.
142
+ const appliedCommits = new Map();
143
+ function assignDocumentUrl(sessionId, url, loaderId) {
144
+ if (loaderId) {
145
+ if (appliedCommits.get(sessionId) === loaderId) return;
146
+ appliedCommits.set(sessionId, loaderId);
147
+ }
148
+ const untied = [...(sessionContexts.get(sessionId)?.values() ?? [])]
149
+ .filter((context) => isTopDefaultContext(sessionId, context) && context.documentUrl === null);
150
+ if (untied.length === 0) {
151
+ awaitingDocuments.set(sessionId, url);
152
+ return;
153
+ }
154
+ for (const context of untied) context.documentUrl = url;
155
+ }
156
+
157
+ // Judged against the caller's own document, not the tab's latest commit.
158
+ function callerIsAppTopFrame(sessionId, executionContextId) {
159
+ const context = sessionContexts.get(sessionId)?.get(executionContextId);
160
+ return isAppTopFrameContext(context, {
161
+ targetId: sessionTargets.get(sessionId),
162
+ appOrigin,
163
+ committedUrl: context?.documentUrl ?? null,
164
+ });
165
+ }
166
+
167
+ // MetaMask shows dapp requests in its side panel (the fixture default) or a
168
+ // notification/popup window; only request routes count as confirmations.
169
+ const WALLET_SURFACES = ['sidepanel.html', 'notification.html', 'popup.html'];
170
+ const CONFIRMATION_ROUTE = /^\/(connect|confirm-transaction|confirmation|confirm|signature-request)(\/|$)/u;
171
+
172
+ function isWalletSurfaceUrl(url) {
173
+ return Boolean(extensionOrigin) && WALLET_SURFACES.some((surface) => url.startsWith(`${extensionOrigin}/${surface}`));
174
+ }
175
+
176
+ function confirmationRoute(url) {
177
+ const route = (url.split('#')[1] ?? '').split('?')[0];
178
+ return CONFIRMATION_ROUTE.test(route) ? route.replace(/0x[0-9a-fA-F]{40}/gu, '<address>') : null;
179
+ }
180
+
181
+ // A MetaMask notification or popup window opens as a new window and can
182
+ // activate the browser. The host only observes this: it records in
183
+ // <runtime>/focus.log when one of this slot's browser processes (by pid, never
184
+ // by app bundle) is the frontmost app after such a window opens, and never
185
+ // activates any app. Only for a visible background launch (--observe-focus).
186
+ const focus = args['observe-focus'] === '1' && !macFocusDisabled() ? {
187
+ observe(surface) {
188
+ const front = captureMacFrontmost();
189
+ if (front && ownedBrowserPids(runtimeDir).includes(front.pid)) {
190
+ appendFileSync(path.join(runtimeDir, 'focus.log'), `${new Date().toISOString()} slot browser (pid ${front.pid}) is frontmost after a MetaMask ${surface} window opened (observed only)\n`, { mode: 0o600 });
191
+ }
192
+ },
193
+ } : null;
194
+
195
+ function observeWalletSurface(targetId, url) {
196
+ if (!isWalletSurfaceUrl(url)) return;
197
+ if (focus && !url.includes('/sidepanel.html') && !seenConfirmationUrls.has(targetId)) {
198
+ setTimeout(() => focus.observe(url.includes('/popup.html') ? 'popup' : 'notification'), 1000);
199
+ }
200
+ const route = confirmationRoute(url);
201
+ if (!route || seenConfirmationUrls.get(targetId) === route) return;
202
+ seenConfirmationUrls.set(targetId, route);
203
+ const surface = WALLET_SURFACES.find((name) => url.includes(name))?.replace('.html', '');
204
+ appendEntry({ kind: 'confirmation-shown', t: Date.now(), surface, route });
205
+ say(`confirmation shown in ${surface}: ${route}`);
206
+ }
207
+
208
+ function deferred() {
209
+ let resolve;
210
+ let reject;
211
+ const promise = new Promise((ok, fail) => {
212
+ resolve = ok;
213
+ reject = fail;
214
+ });
215
+ promise.catch(() => {});
216
+ return { promise, resolve, reject };
217
+ }
218
+
219
+ // Each CDP call is bounded so a slow browser can never leave a new tab paused.
220
+ function bounded(promise, label, ms = 10000) {
221
+ let timer;
222
+ return Promise.race([
223
+ promise,
224
+ new Promise((_, reject) => {
225
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
226
+ }),
227
+ ]).finally(() => clearTimeout(timer));
228
+ }
229
+
230
+ // Register the binding and the document script first: neither needs the
231
+ // target running, while Runtime.enable/Page.enable can wait on a target that is
232
+ // paused for the debugger. Enable the domains only after the caller resumed it.
233
+ async function installAppHooks(sessionId, targetId) {
234
+ appSessions.add(sessionId);
235
+ sessionTargets.set(sessionId, targetId);
236
+ sessionContexts.set(sessionId, new Map());
237
+ await bounded(client.send('Runtime.addBinding', { name: LOG_BINDING }, sessionId), 'Runtime.addBinding');
238
+ if (signer === 'injected') await bounded(client.send('Runtime.addBinding', { name: REQUEST_BINDING }, sessionId), 'Runtime.addBinding');
239
+ await bounded(client.send('Page.addScriptToEvaluateOnNewDocument', { source, runImmediately: true }, sessionId), 'Page.addScriptToEvaluateOnNewDocument');
240
+ if (network === 'testnet') {
241
+ await bounded(client.send('Fetch.enable', {
242
+ patterns: mainnetHosts.map((host) => ({ urlPattern: `*://${host}/*` })),
243
+ }, sessionId), 'Fetch.enable');
244
+ }
245
+ }
246
+
247
+ // Where a request went, without its query string or body.
248
+ function shortUrl(url) {
249
+ try {
250
+ const parsed = new URL(url);
251
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
252
+ } catch {
253
+ return String(url).slice(0, 120);
254
+ }
255
+ }
256
+
257
+ // Venue traffic per app session: the served-network check. Only venue
258
+ // endpoints count (the block list and the testnet endpoints); other hosts on a
259
+ // venue domain, such as the coin icons on app.hyperliquid.xyz, are ignored.
260
+ const venueTraffic = new Map();
261
+ function recordVenueTraffic(sessionId, url) {
262
+ const host = hostOf(url);
263
+ if (!host || String(url).includes(PROBE_MARK)) return;
264
+ const side = mainnetHosts.includes(host) ? 'mainnet' : testnetHosts.includes(host) ? 'testnet' : null;
265
+ if (!side) return;
266
+ const traffic = venueTraffic.get(sessionId) ?? { testnet: new Set(), mainnet: new Set() };
267
+ traffic[side].add(host);
268
+ venueTraffic.set(sessionId, traffic);
269
+ }
270
+
271
+ // How the launch probes failed. The HTTP probe must have been failed by this
272
+ // host (Fetch.failRequest, which Chrome reports as net::ERR_BLOCKED_BY_CLIENT
273
+ // with the blocked reason inspector or other). A probe to the same host on a
274
+ // port the request block does not match must fail with the resolver rule's
275
+ // net::ERR_NAME_NOT_RESOLVED: the rule maps the host for every port and
276
+ // transport, so the WebSocket probe failing too is the resolver's doing. Any
277
+ // other failure (CORS, another blocker) does not prove the block. WebSocket
278
+ // error texts are kept as evidence; Chrome sometimes reports them empty.
279
+ const RESOLVER_PROBE_PORT = 444;
280
+ const probeRequests = new Map();
281
+ const probeFailures = { http: [], resolver: [], websocket: [] };
282
+ let probeFailedHere = false;
283
+
284
+ async function enableAppDomains(sessionId) {
285
+ await bounded(client.send('Runtime.enable', {}, sessionId), 'Runtime.enable');
286
+ await bounded(client.send('Page.enable', {}, sessionId), 'Page.enable');
287
+ if (network === 'testnet') await bounded(client.send('Network.enable', {}, sessionId), 'Network.enable');
288
+ const { frameTree } = await bounded(client.send('Page.getFrameTree', {}, sessionId), 'Page.getFrameTree');
289
+ if (frameTree?.frame?.url !== undefined) {
290
+ sessionCommittedUrls.set(sessionId, frameTree.frame.url);
291
+ assignDocumentUrl(sessionId, frameTree.frame.url, frameTree.frame.loaderId);
292
+ }
293
+ if (appSessions.has(sessionId)) hudSessions.add(sessionId);
294
+ drainPendingCalls(sessionId);
295
+ }
296
+
297
+ // Redraw the HUD on app pages. The expression is a no-op when the same state
298
+ // is already drawn or the page is not on the app origin.
299
+ async function drawHud(sessions = hudSessions) {
300
+ const expression = hudRenderExpression(readHudState(runtimeDir), appOrigin);
301
+ await Promise.all([...sessions].map((sessionId) =>
302
+ bounded(client.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId), 'HUD draw', 3000).catch(() => {})));
303
+ }
304
+
305
+ client.on('Target.attachedToTarget', async ({ sessionId, targetInfo, waitingForDebugger }) => {
306
+ const { targetId } = targetInfo;
307
+ const isApp = targetInfo.type === 'page' && isAppUrl(targetInfo.url) && !hookedTargets.has(targetId);
308
+ let hooked;
309
+ if (isApp) {
310
+ hooked = deferred();
311
+ hookedTargets.set(targetId, { sessionId, ready: hooked.promise });
312
+ }
313
+ try {
314
+ if (isApp) await installAppHooks(sessionId, targetId);
315
+ else if (targetInfo.type === 'page') observeWalletSurface(targetId, targetInfo.url);
316
+ } catch (error) {
317
+ // A tab whose hooks did not all install is not an app tab: its requests
318
+ // would go unrecorded (or unblocked). The app tab's own failure fails ready.
319
+ say(`setup failed for ${targetInfo.url}: ${error.message}`);
320
+ appSessions.delete(sessionId);
321
+ hooked?.reject(error);
322
+ } finally {
323
+ if (waitingForDebugger) await bounded(client.send('Runtime.runIfWaitingForDebugger', {}, sessionId), 'runIfWaitingForDebugger').catch((error) => say(error.message));
324
+ if (!appSessions.has(sessionId)) await client.send('Target.detachFromTarget', { sessionId }).catch(() => {});
325
+ }
326
+ if (isApp && appSessions.has(sessionId)) {
327
+ try {
328
+ await enableAppDomains(sessionId);
329
+ say(`attached app page ${targetInfo.url || '(blank)'}`);
330
+ hooked.resolve(sessionId);
331
+ } catch (error) {
332
+ say(`enable failed for ${targetInfo.url}: ${error.message}`);
333
+ hooked.reject(error);
334
+ }
335
+ } else if (hooked) {
336
+ hooked.reject(new Error(`app hooks were not installed on ${targetInfo.url || 'a blank tab'}`));
337
+ }
338
+ });
339
+
340
+ client.on('Target.targetInfoChanged', ({ targetInfo }) => {
341
+ if (targetInfo.type === 'page') observeWalletSurface(targetInfo.targetId, targetInfo.url);
342
+ });
343
+ client.on('Target.targetCreated', ({ targetInfo }) => {
344
+ if (targetInfo.type === 'page') observeWalletSurface(targetInfo.targetId, targetInfo.url);
345
+ });
346
+ client.on('Target.detachedFromTarget', ({ sessionId }) => {
347
+ // Calls still waiting for their document can no longer be judged.
348
+ for (const call of pendingCalls.get(sessionId) ?? []) recordUnattributed(call, 'its tab detached first');
349
+ awaitingDocuments.delete(sessionId);
350
+ appliedCommits.delete(sessionId);
351
+ const targetId = sessionTargets.get(sessionId);
352
+ if (targetId && hookedTargets.get(targetId)?.sessionId === sessionId) hookedTargets.delete(targetId);
353
+ appSessions.delete(sessionId);
354
+ hudSessions.delete(sessionId);
355
+ sessionTargets.delete(sessionId);
356
+ sessionCommittedUrls.delete(sessionId);
357
+ sessionContexts.delete(sessionId);
358
+ pendingCalls.delete(sessionId);
359
+ });
360
+ // What the app tab's first navigation did, for the diagnostic when it never
361
+ // commits: main-frame commits, and the document's response or failure.
362
+ const appNavigation = { commits: [], document: null };
363
+ client.on('Network.responseReceived', ({ type, response }, sessionId) => {
364
+ if (sessionId === appNavigation.session && type === 'Document') appNavigation.document = `HTTP ${response.status} for ${pathOf(response.url)}`;
365
+ });
366
+ client.on('Network.loadingFailed', ({ type, errorText, canceled }, sessionId) => {
367
+ if (sessionId === appNavigation.session && type === 'Document') appNavigation.document = `load failed: ${errorText}${canceled ? ' (canceled)' : ''}`;
368
+ });
369
+ client.on('Page.frameNavigated', ({ frame }, sessionId) => {
370
+ if (sessionId === appNavigation.session && !frame.parentId) appNavigation.commits.push(pathOf(frame.url));
371
+ if (appSessions.has(sessionId) && !frame.parentId) {
372
+ sessionCommittedUrls.set(sessionId, frame.url);
373
+ assignDocumentUrl(sessionId, frame.url, frame.loaderId);
374
+ drainPendingCalls(sessionId);
375
+ }
376
+ });
377
+ client.on('Fetch.requestPaused', ({ requestId, request, resourceType }, sessionId) => {
378
+ const mainnet = isMainnetVenueUrl(request.url, mainnetHosts);
379
+ if (mainnet) {
380
+ appendEntry({ kind: 'blocked-mainnet', t: Date.now(), transport: 'http', url: shortUrl(request.url), resourceType, probe: request.url.includes(PROBE_MARK) });
381
+ say(`blocked a mainnet request to ${shortUrl(request.url)}`);
382
+ }
383
+ const command = mainnet
384
+ ? client.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, sessionId)
385
+ : client.send('Fetch.continueRequest', { requestId }, sessionId);
386
+ command.then(() => {
387
+ if (mainnet && request.url.includes(PROBE_MARK)) probeFailedHere = true;
388
+ }, () => {});
389
+ });
390
+ client.on('Network.requestWillBeSent', ({ requestId, request }, sessionId) => {
391
+ if (request.url.includes(PROBE_MARK)) probeRequests.set(requestId, new URL(request.url).port === String(RESOLVER_PROBE_PORT) ? 'resolver' : 'http');
392
+ recordVenueTraffic(sessionId, request.url);
393
+ });
394
+ client.on('Network.loadingFailed', ({ requestId, errorText, blockedReason }) => {
395
+ const kind = probeRequests.get(requestId);
396
+ if (kind === 'http' || kind === 'resolver') probeFailures[kind].push({ errorText, blockedReason: blockedReason ?? null });
397
+ });
398
+ client.on('Network.webSocketFrameError', ({ requestId, errorMessage }) => {
399
+ if (probeRequests.get(requestId) === 'websocket') probeFailures.websocket.push({ errorText: errorMessage });
400
+ });
401
+ client.on('Network.webSocketCreated', ({ requestId, url }, sessionId) => {
402
+ if (url.includes(PROBE_MARK)) probeRequests.set(requestId, 'websocket');
403
+ recordVenueTraffic(sessionId, url);
404
+ // The host resolver rule fails the connection; the attempt is still evidence.
405
+ if (isMainnetVenueUrl(url, mainnetHosts)) {
406
+ appendEntry({ kind: 'blocked-mainnet', t: Date.now(), transport: 'websocket', url: shortUrl(url), probe: url.includes(PROBE_MARK) });
407
+ say(`blocked a mainnet WebSocket to ${shortUrl(url)}`);
408
+ }
409
+ });
410
+ client.on('Runtime.executionContextCreated', ({ context }, sessionId) => {
411
+ const contexts = sessionContexts.get(sessionId);
412
+ if (!contexts) return;
413
+ contexts.set(context.id, context);
414
+ if (isTopDefaultContext(sessionId, context)) {
415
+ // The navigation this document belongs to may already have committed
416
+ // (frameNavigated first) or not yet (the context first).
417
+ const awaiting = awaitingDocuments.get(sessionId);
418
+ context.documentUrl = awaiting ?? null;
419
+ awaitingDocuments.delete(sessionId);
420
+ }
421
+ drainPendingCalls(sessionId);
422
+ });
423
+ client.on('Runtime.executionContextDestroyed', ({ executionContextId }, sessionId) => {
424
+ sessionContexts.get(sessionId)?.delete(executionContextId);
425
+ });
426
+ client.on('Runtime.executionContextsCleared', (_params, sessionId) => {
427
+ sessionContexts.get(sessionId)?.clear();
428
+ });
429
+ for (const event of ['Page.domContentEventFired', 'Page.loadEventFired', 'Page.navigatedWithinDocument']) {
430
+ client.on(event, (_params, sessionId) => {
431
+ if (hudSessions.has(sessionId)) drawHud([sessionId]);
432
+ });
433
+ }
434
+ // The file watch catches state changes; the slow repeat redraws a HUD that a
435
+ // client-side render removed.
436
+ watchFile(hudFile(runtimeDir), { interval: 500 }, () => drawHud());
437
+ const hudRepeat = setInterval(() => drawHud(), 2000);
438
+
439
+ // Answer a request the host refuses, so the page's promise settles instead of
440
+ // hanging; harmless where the preload (and its resolver) is absent.
441
+ function refuse(sessionId, executionContextId, id, message) {
442
+ const outcome = { error: { message, code: 4100 } };
443
+ const expression = `typeof window.${RESOLVE_FN} === 'function' && window.${RESOLVE_FN}(${JSON.stringify(id)}, ${JSON.stringify(outcome)})`;
444
+ return client.send('Runtime.evaluate', { expression, contextId: executionContextId }, sessionId).catch(() => {});
445
+ }
446
+
447
+ function requestId(name, payload) {
448
+ if (name !== REQUEST_BINDING) return null;
449
+ try {
450
+ return JSON.parse(payload).id ?? null;
451
+ } catch {
452
+ return null;
453
+ }
454
+ }
455
+
456
+ async function handleBindingCall({ name, payload, executionContextId }, sessionId) {
457
+ // Only the app's own top frame may log or ask the injected wallet to sign;
458
+ // anything else (an iframe, a blank popup, a page the tab navigated to) is
459
+ // dropped, and a refused request is answered with 4100.
460
+ if (!callerIsAppTopFrame(sessionId, executionContextId)) {
461
+ const context = sessionContexts.get(sessionId)?.get(executionContextId);
462
+ appendEntry({ kind: 'outside-app-frame', t: Date.now(), binding: name === REQUEST_BINDING ? 'request' : 'log', origin: context?.origin ?? null, document: context?.documentUrl ? shortUrl(context.documentUrl) : null });
463
+ say(`ignored ${name === REQUEST_BINDING ? 'wallet request' : 'log entry'} from outside the app top frame`);
464
+ const id = requestId(name, payload);
465
+ if (id !== null) await refuse(sessionId, executionContextId, id, 'Wallet requests are accepted only from the app top frame.');
466
+ return;
467
+ }
468
+ if (name === LOG_BINDING) {
469
+ try {
470
+ appendEntry(JSON.parse(payload));
471
+ } catch (error) {
472
+ say(`unparseable log payload: ${error.message}`);
473
+ }
474
+ return;
475
+ }
476
+ if (name !== REQUEST_BINDING || !wallet) return;
477
+ const { id, method, params } = JSON.parse(payload);
478
+ if (network === 'testnet' && /^eth_signTypedData/u.test(String(method))) {
479
+ let reason = null;
480
+ try {
481
+ reason = mainnetTypedDataReason(typeof params?.[1] === 'string' ? JSON.parse(params[1]) : params?.[1]);
482
+ } catch {
483
+ reason = null;
484
+ }
485
+ if (reason) {
486
+ appendEntry({ kind: 'refused-mainnet', t: Date.now(), method, reason, layer: 'wallet-host' });
487
+ await refuse(sessionId, executionContextId, id, 'Refused: this test run is held to Hyperliquid testnet and the request signs a mainnet action.');
488
+ return;
489
+ }
490
+ }
491
+ let outcome;
492
+ try {
493
+ outcome = { result: await wallet.request({ method, params }) };
494
+ } catch (error) {
495
+ outcome = { error: { message: String(error.message), code: error.code ?? -32603 } };
496
+ }
497
+ await client.send(
498
+ 'Runtime.evaluate',
499
+ { expression: `window.${RESOLVE_FN}(${JSON.stringify(id)}, ${JSON.stringify(outcome)})`, contextId: executionContextId },
500
+ sessionId,
501
+ ).catch((error) => say(`resolve ${method} failed: ${error.message}`));
502
+ }
503
+
504
+ // A binding call can arrive before its execution context is reported (the
505
+ // domains are enabled after the tab resumes), or before its document's commit.
506
+ // Hold it until both are known; after a short wait it cannot be judged: it is
507
+ // logged as unattributed (and a request is refused).
508
+ function drainPendingCalls(sessionId) {
509
+ const queue = pendingCalls.get(sessionId);
510
+ if (!queue) return;
511
+ for (const call of [...queue]) {
512
+ const known = callAttributable(sessionId, call.executionContextId);
513
+ if (!known && Date.now() - call.at < PENDING_CALL_TTL_MS) continue;
514
+ queue.splice(queue.indexOf(call), 1);
515
+ if (known) {
516
+ handleBindingCall(call, sessionId);
517
+ continue;
518
+ }
519
+ recordUnattributed(call, 'its document was never attributed');
520
+ const id = requestId(call.name, call.payload);
521
+ if (id !== null) refuse(sessionId, call.executionContextId, id, 'Wallet request from a document the harness could not attribute.');
522
+ }
523
+ }
524
+
525
+ function recordUnattributed(call, why) {
526
+ let entry = {};
527
+ try {
528
+ entry = JSON.parse(call.payload);
529
+ } catch {
530
+ entry = {};
531
+ }
532
+ const binding = call.name === REQUEST_BINDING ? 'request' : 'log';
533
+ appendEntry({ kind: 'unattributed', t: Date.now(), binding, method: typeof entry.method === 'string' ? entry.method : null, loggedKind: typeof entry.kind === 'string' ? entry.kind : null, reason: why });
534
+ say(`a wallet ${binding === 'request' ? 'request' : 'log entry'} could not be judged (${why}); recorded as unattributed`);
535
+ }
536
+
537
+ // A binding call can be judged once its execution context is known and, for
538
+ // the top frame's default context, the URL of that context's own document.
539
+ function callAttributable(sessionId, executionContextId) {
540
+ const context = sessionContexts.get(sessionId)?.get(executionContextId);
541
+ if (!context) return false;
542
+ return !isTopDefaultContext(sessionId, context) || context.documentUrl !== null;
543
+ }
544
+
545
+ client.on('Runtime.bindingCalled', (call, sessionId) => {
546
+ if (callAttributable(sessionId, call.executionContextId)) {
547
+ handleBindingCall(call, sessionId);
548
+ return;
549
+ }
550
+ const queue = pendingCalls.get(sessionId) ?? [];
551
+ queue.push({ ...call, at: Date.now() });
552
+ pendingCalls.set(sessionId, queue);
553
+ setTimeout(() => drainPendingCalls(sessionId), PENDING_CALL_TTL_MS + 50);
554
+ });
555
+
556
+ client.on('close', () => {
557
+ clearInterval(hudRepeat);
558
+ unwatchFile(hudFile(runtimeDir));
559
+ say('browser connection closed; exiting');
560
+ process.exit(0);
561
+ });
562
+
563
+ function pathOf(url) {
564
+ try {
565
+ const parsed = new URL(url);
566
+ return parsed.protocol === 'about:' ? url : `${parsed.origin}${parsed.pathname}`;
567
+ } catch {
568
+ return '?';
569
+ }
570
+ }
571
+
572
+ // Why the app tab never committed its first page (Codex #298 round 7: a
573
+ // 120 s stall seen once on mini): what Page.navigate answered, the tab's
574
+ // commits and document response, where the tab is now, and whether the dev
575
+ // server answers the same URL outside the browser.
576
+ async function commitStallDetail(navigation) {
577
+ const info = await bounded(client.send('Target.getTargetInfo', { targetId: appTargetId }), 'Target.getTargetInfo', 5000).catch(() => null);
578
+ const server = await fetch(startUrl, { signal: AbortSignal.timeout(5000), redirect: 'manual' })
579
+ .then((response) => `HTTP ${response.status}`)
580
+ .catch((error) => (error?.name === 'TimeoutError' ? 'no answer in 5 s' : 'unreachable'));
581
+ return [
582
+ `navigate answered ${navigation?.errorText ? `error ${navigation.errorText}` : `loader ${navigation?.loaderId ? 'set' : 'unset'}`}`,
583
+ `commits [${appNavigation.commits.join(', ') || 'none'}]`,
584
+ `document ${appNavigation.document ?? 'no response seen'}`,
585
+ `tab now at ${pathOf(info?.targetInfo?.url ?? '')}`,
586
+ `dev server ${server}`,
587
+ ].join('; ');
588
+ }
589
+
590
+ function fail(message) {
591
+ say(`not ready: ${message}`);
592
+ process.exit(1);
593
+ }
594
+
595
+ await client.send('Target.setDiscoverTargets', { discover: true });
596
+ await client.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
597
+ const { targetInfos } = await client.send('Target.getTargets');
598
+ for (const info of targetInfos) {
599
+ if (info.type === 'page') observeWalletSurface(info.targetId, info.url);
600
+ }
601
+ // The app gets a tab of its own, created blank: hooks and domains go in while
602
+ // nothing has loaded, then the tab navigates to the app.
603
+ // Created in the background so the browser is never activated (it would take
604
+ // the operator's keyboard focus on macOS). A visible browser started with no
605
+ // window gets the app in a new background window at the slot's bounds.
606
+ const windowBounds = String(args.window ?? '').split(',').map(Number);
607
+ const createParams = windowBounds.length === 4 && windowBounds.every(Number.isFinite)
608
+ ? { url: 'about:blank', newWindow: true, background: true, left: windowBounds[0], top: windowBounds[1], width: windowBounds[2], height: windowBounds[3] }
609
+ : { url: 'about:blank', newWindow: true, background: true };
610
+ const { targetId: appTargetId } = await bounded(
611
+ // No foreground fallback: a tab that is not created in the background
612
+ // activates the browser.
613
+ client.send('Target.createTarget', createParams)
614
+ .catch(() => client.send('Target.createTarget', { url: 'about:blank', background: true })),
615
+ 'Target.createTarget',
616
+ 60000,
617
+ );
618
+ const appStart = Date.now();
619
+ while (!hookedTargets.has(appTargetId) && Date.now() - appStart < 60000) await new Promise((resolve) => setTimeout(resolve, 50));
620
+ if (!hookedTargets.has(appTargetId)) fail(`app tab ${appTargetId} was never attached`);
621
+ const appSession = await bounded(hookedTargets.get(appTargetId).ready, 'app tab hooks', 60000).catch((error) => fail(error.message));
622
+ // Every other tab (the initial blank tab, app tabs restored with a reused
623
+ // profile, MetaMask's home tab) is closed, as the Extension launcher closes its
624
+ // startup tabs: the hooked tab is then the only app tab and the active tab of
625
+ // its window, so it paints without ever being brought to front.
626
+ const { targetInfos: startupTabs } = await client.send('Target.getTargets').catch(() => ({ targetInfos }));
627
+ for (const info of startupTabs) {
628
+ if (info.type === 'page' && info.targetId !== appTargetId && !isWalletSurfaceUrl(info.url)) {
629
+ await client.send('Target.closeTarget', { targetId: info.targetId }).catch(() => {});
630
+ }
631
+ }
632
+ // The first app document is a market page: it streams from the venue in the
633
+ // browser, which the served-network check below observes (the home page's
634
+ // data comes from the server render).
635
+ const startUrl = new URL(args['start-path'] || '/order/BTC', `${appOrigin}/`).href;
636
+ appNavigation.session = appSession;
637
+ const navigation = await bounded(client.send('Page.navigate', { url: startUrl }, appSession), 'Page.navigate', 60000).catch((error) => fail(error.message));
638
+ const navigateStart = Date.now();
639
+ // A cold dev server compiles the first page before it answers.
640
+ const commitTimeoutMs = Number(process.env.MM_HARNESS_APP_COMMIT_TIMEOUT_MS) || 120000;
641
+ while (!isExactAppUrl(sessionCommittedUrls.get(appSession), appOrigin) && Date.now() - navigateStart < commitTimeoutMs) {
642
+ await new Promise((resolve) => setTimeout(resolve, 100));
643
+ }
644
+ if (!isExactAppUrl(sessionCommittedUrls.get(appSession), appOrigin)) fail(`the app tab did not commit ${startUrl}: ${await commitStallDetail(navigation)}`);
645
+
646
+ const evaluate = (expression, timeoutMs = 5000) => bounded(
647
+ client.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, appSession),
648
+ 'app evaluation',
649
+ timeoutMs,
650
+ ).then((response) => response?.result?.value).catch(() => undefined);
651
+
652
+ // The first app document must carry the log binding and the installed
653
+ // preload (its marker), with the page's provider wrapped, or requests on it
654
+ // would go unrecorded.
655
+ const readyExpression = [
656
+ `typeof window.${LOG_BINDING} === 'function'`,
657
+ ...(signer === 'injected' ? [`typeof window.${REQUEST_BINDING} === 'function'`] : []),
658
+ `Boolean(window.__mmTerminalHarness && window.__mmTerminalHarness.signer === ${JSON.stringify(signer)} && window.__mmTerminalHarness.network === ${JSON.stringify(network)})`,
659
+ // The provider the app sees is the wrapped one, and no wrap failed.
660
+ 'window.__mmTerminalHarness.wrapFailures.length === 0 && window.__mmTerminalHarness.covers(window.ethereum)',
661
+ ].join(' && ');
662
+ let hooksReady = false;
663
+ for (let attempt = 0; attempt < 50 && !hooksReady; attempt += 1) {
664
+ hooksReady = (await evaluate(readyExpression)) === true;
665
+ if (!hooksReady) await new Promise((resolve) => setTimeout(resolve, 200));
666
+ }
667
+ if (!hooksReady) fail('the app document has no wallet log binding, no harness preload, or an unwrapped provider');
668
+
669
+ if (network === 'testnet') {
670
+ // The block must hold before anything runs: a probe to the mainnet venue
671
+ // from the app page has to fail (HTTP through the request block and the
672
+ // resolver rule, WebSocket through the resolver rule).
673
+ const host = mainnetHosts.includes('api.hyperliquid.xyz') ? 'api.hyperliquid.xyz' : mainnetHosts[0];
674
+ const httpProbe = await evaluate(`fetch('https://${host}/info?${PROBE_MARK}', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"type":"meta"}' }).then(() => 'reached', () => 'blocked')`, 20000);
675
+ const resolverProbe = await evaluate(`fetch('https://${host}:${RESOLVER_PROBE_PORT}/info?${PROBE_MARK}', { method: 'POST' }).then(() => 'reached', () => 'blocked')`, 20000);
676
+ const wsProbe = await evaluate(`new Promise((resolve) => { const socket = new WebSocket('wss://${host}/ws?${PROBE_MARK}'); const done = (value) => { try { socket.close(); } catch {} resolve(value); }; socket.onopen = () => done('reached'); socket.onerror = () => done('blocked'); setTimeout(() => done('timeout'), 10000); })`, 20000);
677
+ if (httpProbe !== 'blocked' || resolverProbe !== 'blocked' || wsProbe !== 'blocked') {
678
+ fail(`the mainnet venue is reachable from the app (http ${httpProbe}, resolver ${resolverProbe}, websocket ${wsProbe})`);
679
+ }
680
+ const blockedHere = () => probeFailedHere && probeFailures.http.some((failure) => /^net::ERR_BLOCKED_BY_CLIENT\b/u.test(failure.errorText ?? ''));
681
+ const unresolved = () => probeFailures.resolver.some((failure) => /^net::ERR_NAME_NOT_RESOLVED\b/u.test(failure.errorText ?? ''));
682
+ const probeStart = Date.now();
683
+ while (!(blockedHere() && unresolved()) && Date.now() - probeStart < 5000) await new Promise((resolve) => setTimeout(resolve, 100));
684
+ const seen = (failures) => failures.map((failure) => [failure.errorText, failure.blockedReason].filter(Boolean).join(' ')).join('; ') || 'no failure reported';
685
+ if (!blockedHere()) fail(`the HTTP probe failed, but not by the request block (${seen(probeFailures.http)}; failed here: ${probeFailedHere})`);
686
+ if (!unresolved()) fail(`the resolver probe failed, but not by the host resolver rule (${seen(probeFailures.resolver)})`);
687
+ // The app must actually be talking to the testnet venue.
688
+ const servedStart = Date.now();
689
+ let traffic = venueTraffic.get(appSession);
690
+ const servedTimeoutMs = Number(process.env.MM_HARNESS_SERVED_TIMEOUT_MS) || 90000;
691
+ while ((!traffic || (traffic.testnet.size === 0 && traffic.mainnet.size === 0)) && Date.now() - servedStart < servedTimeoutMs) {
692
+ await new Promise((resolve) => setTimeout(resolve, 250));
693
+ traffic = venueTraffic.get(appSession);
694
+ }
695
+ if (traffic?.mainnet.size) fail(`the app requested the mainnet venue (${[...traffic.mainnet].join(', ')}): the dev server does not serve testnet`);
696
+ if (!traffic?.testnet.size) fail(`the app made no request to a testnet venue endpoint (${testnetHosts.join(', ') || 'none known'}) within ${Math.round(servedTimeoutMs / 1000)}s, so its network could not be checked`);
697
+ appendEntry({ kind: 'network-enforcement', t: Date.now(), network, enforcement: 'enforced', mainnetHosts, servedHosts: [...traffic.testnet], probes: { http: seen(probeFailures.http), resolver: seen(probeFailures.resolver), websocket: seen(probeFailures.websocket) } });
698
+ } else {
699
+ appendEntry({ kind: 'network-enforcement', t: Date.now(), network, enforcement: 'disabled', reason: 'network=mainnet with mainnet_confirmation=REAL_FUNDS' });
700
+ }
701
+ say(`opened app tab ${appTargetId}`);
702
+ writeFileSync(path.join(runtimeDir, 'wallet-host.ready'), `${JSON.stringify({ pid: process.pid, signer, network, at: new Date().toISOString() })}\n`);
703
+ say(`ready (signer=${signer}, network=${network}, app=${appOrigin})`);