@deeeed/metamask-harness 0.80.0 → 0.81.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 (76) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/adapters/manifest.json +14 -30
  3. package/adapters/terminal/launch.mjs +17 -13
  4. package/adapters/terminal/lib/mainnet-guard.mjs +9 -0
  5. package/adapters/terminal/lib/readiness.mjs +20 -12
  6. package/adapters/terminal/policy.mjs +35 -0
  7. package/adapters/terminal/verify.mjs +10 -3
  8. package/adapters/terminal/wallet-host.mjs +43 -225
  9. package/dist/adapters/core/actions.js +4 -1
  10. package/dist/adapters/extension/actions.js +6 -2
  11. package/dist/adapters/extension/network-observer.js +13 -0
  12. package/dist/adapters/extension/run.js +112 -5
  13. package/dist/adapters/mobile/actions.js +4 -1
  14. package/dist/adapters/mobile/network-observer.js +68 -0
  15. package/dist/adapters/mobile/performance-observer.js +1 -3
  16. package/dist/adapters/mobile/run.js +32 -9
  17. package/dist/adapters/perps-order-inputs.js +36 -0
  18. package/dist/adapters/proof-failure.js +11 -0
  19. package/dist/adapters/terminal/actions.js +4 -1
  20. package/dist/adapters/terminal/doctor-checks.js +2 -0
  21. package/dist/adapters/terminal/launch.js +3 -0
  22. package/dist/adapters/terminal/overlay.js +8 -1
  23. package/dist/adapters/terminal/run.js +8 -3
  24. package/dist/adapters/terminal/surface.js +2 -0
  25. package/dist/adapters.js +20 -4
  26. package/dist/cli.js +31 -6
  27. package/dist/commands/check.js +30 -6
  28. package/dist/commands/completion-candidates.js +5 -5
  29. package/dist/commands/self-test.js +4 -3
  30. package/dist/engine.js +57 -0
  31. package/dist/funding-execution-context.js +81 -0
  32. package/dist/mm-harness-cli.js +3 -3
  33. package/dist/performance-observation.js +1 -3
  34. package/dist/recipe-assistance-context.js +6 -7
  35. package/dist/runner.js +2 -1
  36. package/docs/CONTRIBUTING.md +4 -2
  37. package/docs/RECIPES.md +31 -3
  38. package/library/actions/core/perps/_controller.mjs +24 -28
  39. package/library/actions/core/perps/_rewards.mjs +186 -0
  40. package/library/actions/core/perps/close_positions.mjs +1 -1
  41. package/library/actions/core/perps/place_order.mjs +167 -45
  42. package/library/actions/core/perps/quote_fees.mjs +7 -4
  43. package/library/actions/core/wallet/list_accounts.mjs +1 -1
  44. package/library/actions/extension/perps/close_visible_position.mjs +5 -0
  45. package/library/actions/extension/perps/edit_margin.mjs +4 -0
  46. package/library/actions/extension/perps/mutation-receipt.mjs +95 -3
  47. package/library/actions/extension/perps/perps.mjs +320 -3
  48. package/library/actions/extension/perps/place_order.mjs +3 -1
  49. package/library/actions/extension/perps/update_position_tpsl.mjs +4 -0
  50. package/library/actions/extension/platform/cdp.mjs +71 -3
  51. package/library/actions/mobile/perps/perps.mjs +216 -11
  52. package/library/actions/mobile/perps/place_order.mjs +3 -1
  53. package/library/actions/shared/perps/order-policy.mjs +177 -0
  54. package/library/actions/{core/wallet/_fixture-accounts.mjs → shared/wallet/fixture-accounts.mjs} +18 -3
  55. package/library/actions/terminal/app/launch.mjs +3 -2
  56. package/library/actions/terminal/platform/hud.mjs +2 -1
  57. package/library/actions/terminal/platform/page.mjs +2 -1
  58. package/library/actions/terminal/wallet/_signature-log.mjs +40 -161
  59. package/library/actions/terminal/wallet/assert_signatures.mjs +6 -12
  60. package/library/actions/terminal/wallet/read_signatures.mjs +10 -9
  61. package/library/manifests/core.action-manifest.json +6 -2
  62. package/library/manifests/extension.action-manifest.json +4 -0
  63. package/library/manifests/mobile.action-manifest.json +12 -0
  64. package/package.json +4 -4
  65. package/site/assets/help-recipes.json +1 -0
  66. package/adapters/terminal/lib/origin.mjs +0 -30
  67. package/adapters/terminal/lib/page-script.mjs +0 -246
  68. package/adapters/terminal/lib/strict-wallet.mjs +0 -138
  69. package/dist/commands/call.js +0 -671
  70. package/dist/commands/list-executables.js +0 -245
  71. package/dist/commands/manifest.js +0 -477
  72. package/dist/commands/run-engine.js +0 -1301
  73. package/dist/commands/run.js +0 -730
  74. package/dist/discovery.js +0 -15
  75. package/dist/metamask-action-validation.js +0 -68
  76. package/dist/network-observation.js +0 -290
@@ -27,19 +27,20 @@
27
27
  import { appendFileSync, chmodSync, existsSync, unwatchFile, watchFile, writeFileSync } from 'node:fs';
28
28
  import { createRequire } from 'node:module';
29
29
  import path from 'node:path';
30
+ import { pathToFileURL } from 'node:url';
31
+
32
+ import { createStrictWallet, createWalletRequestBinding, pageReadyExpression, pageScriptSource } from '@farmslot/adapter-web/dapp';
33
+ import { isAppUrl as isExactAppUrl, shortUrl } from '@farmslot/adapter-web/origin';
30
34
 
31
35
  import { CdpClient, browserWebSocketUrl } from './lib/cdp-client.mjs';
32
36
  import { hudFile, hudRenderExpression, readHudState } from './lib/hud.mjs';
33
- import { LINK_HOSTS, hostOf, isMainnetVenueUrl, mainnetTypedDataReason } from './lib/mainnet-guard.mjs';
37
+ import { hostOf, isMainnetVenueUrl } from './lib/mainnet-guard.mjs';
34
38
  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
 
39
40
  const { captureMacFrontmost, macFocusDisabled } = createRequire(import.meta.url)('@farmslot/adapter-web/macos-focus');
40
41
 
41
42
  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
+ return 'Usage: wallet-host.mjs --cdp-port <port> --runtime-dir <dir> --signer <extension|injected> --app-origin <url> --policy-module <venue policy module> [--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
  }
44
45
 
45
46
  function parseArgs(argv) {
@@ -58,13 +59,17 @@ function parseArgs(argv) {
58
59
  args[flag.slice(2)] = argv[index + 1];
59
60
  index += 1;
60
61
  }
61
- for (const key of ['cdp-port', 'runtime-dir', 'signer', 'app-origin']) {
62
+ for (const key of ['cdp-port', 'runtime-dir', 'signer', 'app-origin', 'policy-module']) {
62
63
  if (!args[key]) throw new Error(`Missing --${key}\n${usage()}`);
63
64
  }
64
65
  return args;
65
66
  }
66
67
 
67
68
  const args = parseArgs(process.argv.slice(2));
69
+ // The venue policy (the Terminal's adapters/terminal/policy.mjs): link hosts,
70
+ // the probe host, the typed-data refusal and the start page.
71
+ const { policy } = await import(pathToFileURL(path.resolve(args['policy-module'])).href);
72
+ const LINK_HOSTS = new Set(policy.linkHosts);
68
73
  const runtimeDir = path.resolve(args['runtime-dir']);
69
74
  const logFile = path.join(runtimeDir, 'wallet-requests.jsonl');
70
75
  const signer = args.signer;
@@ -79,6 +84,19 @@ if (network === 'testnet' && mainnetHosts.length === 0) throw new Error('testnet
79
84
  // only, never a link host (app.hyperliquid-testnet.xyz serves pages and icons).
80
85
  const testnetHosts = String(args['testnet-hosts'] ?? '').split(',').filter((host) => host && !LINK_HOSTS.has(host));
81
86
  const PROBE_MARK = 'mm-harness-probe=1';
87
+ // A testnet run never lets the typed data the policy refuses reach the signer.
88
+ const refuseTypedData = network === 'testnet' ? policy.refuseTypedData : null;
89
+ // The injected strict wallet presents as MetaMask, so the app connects to it
90
+ // the way it connects to the extension.
91
+ const INJECTED_WALLET = {
92
+ info: {
93
+ uuid: '5d1e5b4e-e2e0-4c3f-9a1d-000000000001',
94
+ name: 'MetaMask (strict test wallet)',
95
+ icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>",
96
+ rdns: 'io.metamask',
97
+ },
98
+ isMetaMask: true,
99
+ };
82
100
 
83
101
  // The request log is evidence; keep it owner-only even when it already exists.
84
102
  if (existsSync(logFile)) chmodSync(logFile, 0o600);
@@ -96,29 +114,23 @@ if (signer === 'injected') {
96
114
  const { fixtureAccount } = await import('../../library/actions/terminal/platform/runtime.mjs');
97
115
  if (!args.target || !args.account) throw new Error('injected mode requires --target and --account');
98
116
  const account = await fixtureAccount(path.resolve(args.target), args.account);
99
- wallet = createStrictWallet({ account, chainId: Number(args['start-chain'] ?? 42161) });
117
+ wallet = createStrictWallet({ account, chainId: Number(args['start-chain'] ?? policy.startChain) });
100
118
  say(`injected strict wallet ready for ${args.account} (${account.address}) on chain ${wallet.chainId}`);
101
119
  }
102
120
 
103
- const source = pageScriptSource({ signer, appOrigin, network });
121
+ const source = pageScriptSource({ signer, appOrigin, refuseTypedData, injectedWallet: INJECTED_WALLET });
104
122
  const client = await CdpClient.connect(await browserWebSocketUrl(Number(args['cdp-port'])));
123
+ // Judges each binding call against the frame and document that made it, logs
124
+ // it and, in injected mode, answers it with the strict wallet.
125
+ const binding = createWalletRequestBinding({ client, appOrigin, signer, wallet, refuseTypedData, record: appendEntry, say });
105
126
  // Hooks are installed once per target: a second session for the same tab
106
127
  // (auto-attach plus a restored tab) would log every request twice.
107
128
  const hookedTargets = new Map();
108
129
  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.
130
+ // Per app session: its target id (= main frame id) and the URL its top frame
131
+ // has committed.
112
132
  const sessionTargets = new Map();
113
133
  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
134
  // App sessions whose target runs with domains enabled; the HUD is drawn only
123
135
  // there so no evaluate ever waits on a tab paused for setup.
124
136
  const hudSessions = new Set();
@@ -129,41 +141,6 @@ function isAppUrl(url) {
129
141
  return url === 'about:blank' || url === '' || isExactAppUrl(url, appOrigin);
130
142
  }
131
143
 
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
144
  // MetaMask shows dapp requests in its side panel (the fixture default) or a
168
145
  // notification/popup window; only request routes count as confirmations.
169
146
  const WALLET_SURFACES = ['sidepanel.html', 'notification.html', 'popup.html'];
@@ -233,9 +210,7 @@ function bounded(promise, label, ms = 10000) {
233
210
  async function installAppHooks(sessionId, targetId) {
234
211
  appSessions.add(sessionId);
235
212
  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');
213
+ await bounded(binding.install(sessionId, targetId), 'Runtime.addBinding');
239
214
  await bounded(client.send('Page.addScriptToEvaluateOnNewDocument', { source, runImmediately: true }, sessionId), 'Page.addScriptToEvaluateOnNewDocument');
240
215
  if (network === 'testnet') {
241
216
  await bounded(client.send('Fetch.enable', {
@@ -244,16 +219,6 @@ async function installAppHooks(sessionId, targetId) {
244
219
  }
245
220
  }
246
221
 
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
222
  // Venue traffic per app session: the served-network check. Only venue
258
223
  // endpoints count (the block list and the testnet endpoints); other hosts on a
259
224
  // venue domain, such as the coin icons on app.hyperliquid.xyz, are ignored.
@@ -288,10 +253,10 @@ async function enableAppDomains(sessionId) {
288
253
  const { frameTree } = await bounded(client.send('Page.getFrameTree', {}, sessionId), 'Page.getFrameTree');
289
254
  if (frameTree?.frame?.url !== undefined) {
290
255
  sessionCommittedUrls.set(sessionId, frameTree.frame.url);
291
- assignDocumentUrl(sessionId, frameTree.frame.url, frameTree.frame.loaderId);
256
+ binding.commit(sessionId, frameTree.frame.url, frameTree.frame.loaderId);
292
257
  }
293
258
  if (appSessions.has(sessionId)) hudSessions.add(sessionId);
294
- drainPendingCalls(sessionId);
259
+ binding.drain(sessionId);
295
260
  }
296
261
 
297
262
  // Redraw the HUD on app pages. The expression is a no-op when the same state
@@ -344,18 +309,13 @@ client.on('Target.targetCreated', ({ targetInfo }) => {
344
309
  if (targetInfo.type === 'page') observeWalletSurface(targetInfo.targetId, targetInfo.url);
345
310
  });
346
311
  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);
312
+ binding.detach(sessionId);
351
313
  const targetId = sessionTargets.get(sessionId);
352
314
  if (targetId && hookedTargets.get(targetId)?.sessionId === sessionId) hookedTargets.delete(targetId);
353
315
  appSessions.delete(sessionId);
354
316
  hudSessions.delete(sessionId);
355
317
  sessionTargets.delete(sessionId);
356
318
  sessionCommittedUrls.delete(sessionId);
357
- sessionContexts.delete(sessionId);
358
- pendingCalls.delete(sessionId);
359
319
  });
360
320
  // What the app tab's first navigation did, for the diagnostic when it never
361
321
  // commits: main-frame commits, and the document's response or failure.
@@ -370,8 +330,7 @@ client.on('Page.frameNavigated', ({ frame }, sessionId) => {
370
330
  if (sessionId === appNavigation.session && !frame.parentId) appNavigation.commits.push(pathOf(frame.url));
371
331
  if (appSessions.has(sessionId) && !frame.parentId) {
372
332
  sessionCommittedUrls.set(sessionId, frame.url);
373
- assignDocumentUrl(sessionId, frame.url, frame.loaderId);
374
- drainPendingCalls(sessionId);
333
+ binding.commit(sessionId, frame.url, frame.loaderId);
375
334
  }
376
335
  });
377
336
  client.on('Fetch.requestPaused', ({ requestId, request, resourceType }, sessionId) => {
@@ -407,25 +366,6 @@ client.on('Network.webSocketCreated', ({ requestId, url }, sessionId) => {
407
366
  say(`blocked a mainnet WebSocket to ${shortUrl(url)}`);
408
367
  }
409
368
  });
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
369
  for (const event of ['Page.domContentEventFired', 'Page.loadEventFired', 'Page.navigatedWithinDocument']) {
430
370
  client.on(event, (_params, sessionId) => {
431
371
  if (hudSessions.has(sessionId)) drawHud([sessionId]);
@@ -436,123 +376,6 @@ for (const event of ['Page.domContentEventFired', 'Page.loadEventFired', 'Page.n
436
376
  watchFile(hudFile(runtimeDir), { interval: 500 }, () => drawHud());
437
377
  const hudRepeat = setInterval(() => drawHud(), 2000);
438
378
 
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
379
  client.on('close', () => {
557
380
  clearInterval(hudRepeat);
558
381
  unwatchFile(hudFile(runtimeDir));
@@ -632,7 +455,7 @@ for (const info of startupTabs) {
632
455
  // The first app document is a market page: it streams from the venue in the
633
456
  // browser, which the served-network check below observes (the home page's
634
457
  // data comes from the server render).
635
- const startUrl = new URL(args['start-path'] || '/order/BTC', `${appOrigin}/`).href;
458
+ const startUrl = new URL(args['start-path'] || policy.startPath, `${appOrigin}/`).href;
636
459
  appNavigation.session = appSession;
637
460
  const navigation = await bounded(client.send('Page.navigate', { url: startUrl }, appSession), 'Page.navigate', 60000).catch((error) => fail(error.message));
638
461
  const navigateStart = Date.now();
@@ -652,13 +475,7 @@ const evaluate = (expression, timeoutMs = 5000) => bounded(
652
475
  // The first app document must carry the log binding and the installed
653
476
  // preload (its marker), with the page's provider wrapped, or requests on it
654
477
  // 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(' && ');
478
+ const readyExpression = pageReadyExpression({ signer, refusesTypedData: refuseTypedData !== null });
662
479
  let hooksReady = false;
663
480
  for (let attempt = 0; attempt < 50 && !hooksReady; attempt += 1) {
664
481
  hooksReady = (await evaluate(readyExpression)) === true;
@@ -670,10 +487,11 @@ if (network === 'testnet') {
670
487
  // The block must hold before anything runs: a probe to the mainnet venue
671
488
  // from the app page has to fail (HTTP through the request block and the
672
489
  // 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);
490
+ const { probe } = policy;
491
+ const host = mainnetHosts.includes(probe.host) ? probe.host : mainnetHosts[0];
492
+ const httpProbe = await evaluate(`fetch('https://${host}${probe.httpPath}?${PROBE_MARK}', { method: 'POST', headers: { 'content-type': 'application/json' }, body: ${JSON.stringify(probe.httpBody)} }).then(() => 'reached', () => 'blocked')`, 20000);
493
+ const resolverProbe = await evaluate(`fetch('https://${host}:${RESOLVER_PROBE_PORT}${probe.httpPath}?${PROBE_MARK}', { method: 'POST' }).then(() => 'reached', () => 'blocked')`, 20000);
494
+ const wsProbe = await evaluate(`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); })`, 20000);
677
495
  if (httpProbe !== 'blocked' || resolverProbe !== 'blocked' || wsProbe !== 'blocked') {
678
496
  fail(`the mainnet venue is reachable from the app (http ${httpProbe}, resolver ${resolverProbe}, websocket ${wsProbe})`);
679
497
  }
@@ -1,4 +1,5 @@
1
1
  import { manifestPath } from "../../paths.js";
2
+ import { perpsOrderInputFindings } from "../perps-order-inputs.js";
2
3
  const coreActions = {
3
4
  manifestPath: () => manifestPath("core"),
4
5
  semantic: [
@@ -14,7 +15,9 @@ const coreActions = {
14
15
  env: async (projectRoot, tempDir) => (await import("./live-script-env.js")).coreLiveScriptEnv(projectRoot, tempDir)
15
16
  },
16
17
  // Core declares no cdp.target; the probe matches the Extension's.
17
- cdpTarget: { transport: "chrome-extension-cdp", probePath: "json/version" }
18
+ cdpTarget: { transport: "chrome-extension-cdp", probePath: "json/version" },
19
+ // ensure_positions and ensure_orders with an open or present state place an order.
20
+ inputFindings: (nodeId, node) => perpsOrderInputFindings(nodeId, node, { ensureChecksOnly: false })
18
21
  };
19
22
  export {
20
23
  coreActions
@@ -1,4 +1,5 @@
1
1
  import { manifestPath } from "../../paths.js";
2
+ import { perpsOrderInputFindings } from "../perps-order-inputs.js";
2
3
  import { extensionActionInputFindings } from "./action-inputs.js";
3
4
  const extensionActions = {
4
5
  manifestPath: () => manifestPath("extension"),
@@ -43,8 +44,11 @@ const extensionActions = {
43
44
  })
44
45
  }),
45
46
  trustedMutation: true,
46
- inputFindings: extensionActionInputFindings,
47
- ensureChecksOnly: true
47
+ // ensure_positions and ensure_orders only check state here.
48
+ inputFindings: (nodeId, node) => [
49
+ ...extensionActionInputFindings(nodeId, node),
50
+ ...perpsOrderInputFindings(nodeId, node, { ensureChecksOnly: true })
51
+ ]
48
52
  };
49
53
  export {
50
54
  extensionActions
@@ -204,6 +204,19 @@ async function waitForSocket(socketPath) {
204
204
  }
205
205
  throw new Error("Extension network observer broker did not start.");
206
206
  }
207
+ async function createExtensionNetworkBackend(env, artifactsDir) {
208
+ return createExtensionNetworkObserver(extensionCdpPort(env), artifactsDir);
209
+ }
210
+ function extensionCdpPort(env) {
211
+ const port = Number(
212
+ env.CDP_PORT ?? env.RECIPE_CDP_PORT ?? process.env.CDP_PORT
213
+ );
214
+ if (!Number.isInteger(port) || port <= 0) {
215
+ throw new Error("Extension network observation requires CDP_PORT.");
216
+ }
217
+ return port;
218
+ }
207
219
  export {
220
+ createExtensionNetworkBackend,
208
221
  createExtensionNetworkObserver
209
222
  };
@@ -1,4 +1,5 @@
1
- import { optionFlag, optionString } from "@farmslot/recipe-cli/harness";
1
+ import path from "node:path";
2
+ import { EXIT, optionFlag, optionString } from "@farmslot/recipe-cli/harness";
2
3
  import { verifyOwnedCollectorControl } from "../console-capture.js";
3
4
  import { resolveExtensionBrowserPid } from "./browser-pid.js";
4
5
  import {
@@ -7,6 +8,7 @@ import {
7
8
  extensionCdpPort,
8
9
  verifyExtensionConsoleControl
9
10
  } from "./console-capture.js";
11
+ import { emitProofFailure } from "../proof-failure.js";
10
12
  import { launchedBrowser } from "./launched-browser.js";
11
13
  const AUTOLAUNCH_ENV = "METAMASK_RECIPE_EXTENSION_AUTOLAUNCH";
12
14
  const extensionRun = {
@@ -51,7 +53,6 @@ const extensionRun = {
51
53
  }
52
54
  if (explicitWatcherPort) process.env.WATCHER_PORT = explicitWatcherPort;
53
55
  try {
54
- const { ensureExtensionProofRuntime } = await import("../../commands/run-engine.js");
55
56
  return await ensureExtensionProofRuntime(target, heal, state, json, { onRecovery });
56
57
  } finally {
57
58
  restoreEnv("CDP_PORT", previousCdpPort);
@@ -68,6 +69,106 @@ const extensionRun = {
68
69
  extension_loading: browser.extensionLoading
69
70
  })
70
71
  };
72
+ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {}) {
73
+ const decide = deps.decide ?? (async () => {
74
+ const { decideExtensionReadiness } = await import("./runtime-decision.js");
75
+ const port = Number(process.env.CDP_PORT);
76
+ return decideExtensionReadiness(target, {
77
+ ...Number.isInteger(port) && port > 0 ? { cdpPort: port } : {},
78
+ pageMode: "home-or-sidepanel"
79
+ });
80
+ });
81
+ const initial = await decide();
82
+ if (initial.decision === "ready") return null;
83
+ if (initial.reasonCode === "release-artifact-identity-invalid" || initial.reasonCode === "release-artifact-runtime-missing" || initial.reasonCode === "release-artifact-runtime-stale") {
84
+ const message = initial.reasons[0] ?? "The loaded release artifact identity is invalid.";
85
+ const userAction2 = "rerun runtime-launch with the same artifact source";
86
+ emitProofFailure(json, {
87
+ recoverable: false,
88
+ error: { code: "RELEASE_ARTIFACT_NOT_CURRENT", message, userAction: userAction2 }
89
+ });
90
+ return EXIT.infra;
91
+ }
92
+ if (initial.reasonCode === "runtime-dist-check-failed") {
93
+ const message = `Extension proof runtime freshness could not be checked: ${initial.reasons[0] ?? initial.decision}`;
94
+ const userAction2 = `mm-harness doctor --adapter extension --target ${JSON.stringify(path.resolve(target))}`;
95
+ emitProofFailure(json, {
96
+ recoverable: false,
97
+ error: { code: "EXTENSION_RUNTIME_CHECK_FAILED", message, userAction: userAction2 }
98
+ });
99
+ return EXIT.infra;
100
+ }
101
+ if (![
102
+ "dist-stale",
103
+ "dist-missing",
104
+ "runtime-dist-stale",
105
+ "runtime-dist-missing"
106
+ ].includes(initial.reasonCode)) {
107
+ return null;
108
+ }
109
+ const tier = initial.decision === "build" ? "build" : "quick";
110
+ const canHeal = heal === "auto" || heal === "infra-only" && tier === "quick";
111
+ const targetArg = JSON.stringify(path.resolve(target));
112
+ const userAction = tier === "build" ? `mm-harness launch --build --verify --adapter extension --target ${targetArg}` : `mm-harness launch --verify --adapter extension --target ${targetArg}`;
113
+ if (!canHeal) {
114
+ const message = `Extension proof runtime is not current (${initial.reasonCode}): ${initial.reasons[0] ?? initial.decision}`;
115
+ emitProofFailure(json, {
116
+ recoverable: true,
117
+ mutations: state.mutations,
118
+ error: { code: "EXTENSION_RUNTIME_NOT_CURRENT", message, userAction }
119
+ });
120
+ return EXIT.infra;
121
+ }
122
+ const recoveryCode = tier === "build" ? "extension.runtime_rebuilt" : "extension.runtime_reloaded";
123
+ const productConfigBlock = tier === "build" ? (await import("./product-config.js")).extensionProductConfigBlock(target) : null;
124
+ if (productConfigBlock) {
125
+ const message = productConfigBlock.message;
126
+ const userAction2 = productConfigBlock.userAction;
127
+ emitProofFailure(json, {
128
+ recoverable: false,
129
+ mutations: state.mutations,
130
+ error: { code: "EXTENSION_PRODUCT_CONFIG_REQUIRED", message, userAction: userAction2 }
131
+ });
132
+ return EXIT.infra;
133
+ }
134
+ state.attemptedRecoveries.push(recoveryCode);
135
+ deps.onRecovery?.(recoveryCode);
136
+ if (!json)
137
+ console.error(
138
+ `\u2192 Extension proof preflight: ${initial.reasonCode}; ${tier === "build" ? "rebuilding" : "reloading"} before execution`
139
+ );
140
+ const launch = deps.launch ?? (async (requestedTier) => {
141
+ const { launchExtension } = await import("../../commands/launch/extension.js");
142
+ const { readExtensionPinnedFlags } = await import("./feature-flags.js");
143
+ const { fixtureFeatureFlags, remoteFlagRequestFor } = await import("../../feature-flags.js");
144
+ const { walletFixturePath } = await import("../../paths.js");
145
+ return launchExtension(
146
+ target,
147
+ requestedTier,
148
+ false,
149
+ "fullscreen",
150
+ remoteFlagRequestFor(readExtensionPinnedFlags(target).pinned ?? fixtureFeatureFlags(walletFixturePath(target)))
151
+ );
152
+ });
153
+ const result = await launch(tier);
154
+ const verified = result.status === 0 ? await decide() : initial;
155
+ if (result.status !== 0 || verified.decision !== "ready") {
156
+ const message = `Extension runtime recovery did not produce a current healthy runtime (${verified.reasonCode}).`;
157
+ emitProofFailure(json, {
158
+ recoverable: false,
159
+ attemptedRecoveries: state.attemptedRecoveries,
160
+ error: { code: "EXTENSION_RUNTIME_RECOVERY_FAILED", message, userAction, originalError: result.output }
161
+ });
162
+ return EXIT.infra;
163
+ }
164
+ state.recovered.push(recoveryCode);
165
+ state.mutations.push({
166
+ type: "runtime",
167
+ action: tier === "build" ? "rebuilt" : "reloaded",
168
+ reasonCode: initial.reasonCode
169
+ });
170
+ return null;
171
+ }
71
172
  const extensionRecording = {
72
173
  target: (context) => resolveExtensionRecordingTarget(context),
73
174
  framed: {
@@ -99,12 +200,17 @@ const extensionDiagnostics = {
99
200
  };
100
201
  const extensionObservation = {
101
202
  network: {
102
- backend: async (_target, env, artifactsDir) => (await import("../../network-observation.js")).createExtensionNetworkBackend(env, artifactsDir),
203
+ backend: async (_target, env, artifactsDir) => (await import("./network-observer.js")).createExtensionNetworkBackend(env, artifactsDir),
103
204
  actions: true
104
205
  },
105
206
  performance: {
106
- backend: async (target, env, ports) => (await import("../../performance-observation.js")).createExtensionPerformanceObserver(target, env, ports),
107
- platform: () => "extension"
207
+ start: async (context) => {
208
+ const observation = await import("../../performance-observation.js");
209
+ return observation.startRunPerformanceObservation({
210
+ backend: (target, env, ports) => observation.createExtensionPerformanceObserver(target, env, ports),
211
+ platform: () => "extension"
212
+ }, context);
213
+ }
108
214
  }
109
215
  };
110
216
  function restoreEnv(key, value) {
@@ -112,6 +218,7 @@ function restoreEnv(key, value) {
112
218
  else process.env[key] = value;
113
219
  }
114
220
  export {
221
+ ensureExtensionProofRuntime,
115
222
  extensionDiagnostics,
116
223
  extensionObservation,
117
224
  extensionRecording,
@@ -1,4 +1,5 @@
1
1
  import { manifestPath } from "../../paths.js";
2
+ import { perpsOrderInputFindings } from "../perps-order-inputs.js";
2
3
  const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
3
4
  "ui.press",
4
5
  "ui.key_press",
@@ -47,7 +48,9 @@ const mobileActions = {
47
48
  }
48
49
  }),
49
50
  wrap: async (adapters) => (await import("./runtime-identity.js")).withMobileNativeRuntimeIdentity(adapters),
50
- lifecycle: async (adapters) => (await import("./lifecycle.js")).mobileSourceAwareLifecycleAdapters(adapters)
51
+ lifecycle: async (adapters) => (await import("./lifecycle.js")).mobileSourceAwareLifecycleAdapters(adapters),
52
+ // ensure_positions and ensure_orders with an open or present state place an order.
53
+ inputFindings: (nodeId, node) => perpsOrderInputFindings(nodeId, node, { ensureChecksOnly: false })
51
54
  };
52
55
  export {
53
56
  mobileActions