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