@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,247 @@
1
+ // Select the CDP page a terminal UI action drives: the app tab (default) or the
2
+ // MetaMask wallet surface (side panel / notification / popup) that shows the
3
+ // pending dapp request. Pages are never brought to front unless
4
+ // MM_HARNESS_FOCUS_BROWSER=1, as in the Extension adapter: Page.bringToFront
5
+ // activates the browser on macOS and takes the operator's keyboard focus. The
6
+ // app tab is the only tab of its window (the wallet host closes the others),
7
+ // so it stays the window's active tab and keeps painting without it.
8
+
9
+ import { resolveSlowMo } from '../../../../adapters/terminal/lib/display.mjs';
10
+ import { HUD_ELEMENT } from '../../../../adapters/terminal/lib/hud.mjs';
11
+ import { isAppUrl } from '../../../../adapters/terminal/lib/origin.mjs';
12
+ import { cdpListenerPids } from '../../../../adapters/terminal/lib/processes.mjs';
13
+ import { appOrigin, cdpPort, readBrowserState } from './runtime.mjs';
14
+ import { importRecipeHarnessRuntimeCdp } from '../../harness-exports.mjs';
15
+
16
+ const { CdpSession, CdpWebPage, listCdpTargets, sleep } = await importRecipeHarnessRuntimeCdp();
17
+
18
+ export const WALLET_SURFACES = Object.freeze(['sidepanel.html', 'notification.html', 'popup.html']);
19
+ const CONFIRMATION_ROUTE = /^\/(connect|confirm-transaction|confirmation|confirm|signature-request)(\/|$)/u;
20
+
21
+ export function resolveSurface(node = {}) {
22
+ const surface = node.surface == null ? 'app' : String(node.surface);
23
+ if (surface !== 'app' && surface !== 'wallet') {
24
+ throw new Error(`surface must be app or wallet, got ${JSON.stringify(node.surface)}.`);
25
+ }
26
+ return surface;
27
+ }
28
+
29
+ export function isWalletRequestTarget(target, extensionId) {
30
+ if (target.type !== 'page' || !target.url) return false;
31
+ const origin = extensionId ? `chrome-extension://${extensionId}/` : 'chrome-extension://';
32
+ if (!target.url.startsWith(origin)) return false;
33
+ if (!WALLET_SURFACES.some((surface) => target.url.includes(`/${surface}`))) return false;
34
+ const route = (target.url.split('#')[1] ?? '').split('?')[0];
35
+ return CONFIRMATION_ROUTE.test(route);
36
+ }
37
+
38
+ export function pickAppTarget(targets, origin) {
39
+ const pages = targets.filter((target) => target.type === 'page');
40
+ return pages.find((target) => isAppUrl(target.url, origin)) ?? pages.find((target) => target.url === 'about:blank') ?? null;
41
+ }
42
+
43
+ export async function selectTerminalTarget(input) {
44
+ const node = input.node ?? {};
45
+ const port = cdpPort(input);
46
+ const surface = resolveSurface(node);
47
+ const state = input.context?.projectRoot ? await readBrowserState(input.context.projectRoot) : null;
48
+ if (surface === 'wallet') {
49
+ if (state?.signer === 'injected') {
50
+ throw new Error('surface=wallet needs signer=extension; the injected strict wallet has no confirmation UI.');
51
+ }
52
+ const timeoutMs = Number(node.wallet_timeout_ms ?? node.timeout_ms ?? 20000);
53
+ const deadline = Date.now() + timeoutMs;
54
+ while (true) {
55
+ const targets = await listCdpTargets('127.0.0.1', port);
56
+ const match = targets.find((target) => isWalletRequestTarget(target, state?.extension?.id));
57
+ if (match) return { target: match, surface, port };
58
+ if (Date.now() >= deadline) {
59
+ throw new Error(`No MetaMask confirmation surface (side panel / notification) showed a pending request within ${timeoutMs}ms.`);
60
+ }
61
+ await sleep(250);
62
+ }
63
+ }
64
+ const origin = appOrigin(input);
65
+ const target = pickAppTarget(await listCdpTargets('127.0.0.1', port), origin);
66
+ if (!target) {
67
+ throw new Error(`No app tab on CDP ${port}. Next: run metamask.app.launch before UI nodes.`);
68
+ }
69
+ return { target, surface, port };
70
+ }
71
+
72
+ // MetaMask pages run under LavaMoat scuttling, so main-world helpers (timers in
73
+ // wait loops) are unavailable there; evaluate in an isolated world that shares
74
+ // the DOM instead.
75
+ class WalletSurfacePage extends CdpWebPage {
76
+ evaluate(expression) {
77
+ return this.evaluateInIsolatedWorld(expression, 'mm-terminal-wallet-surface');
78
+ }
79
+ }
80
+
81
+ // Number inputs ignore select(), so the generic setInput would append to a
82
+ // prefilled value (the order form prefills the mid price). Clear the field with
83
+ // the editor's selectAll command, as a user's Cmd+A would, type over the
84
+ // selection and require the field to read back exactly that value.
85
+ // Screenshots are evidence of the app, so the recipe HUD is hidden while one
86
+ // is captured and shown again afterwards.
87
+ export function hudVisibilityExpression(visible) {
88
+ return `(() => { const host = document.documentElement.querySelector(':scope > ${HUD_ELEMENT}'); if (!host) return false; host.style.visibility = ${JSON.stringify(visible ? '' : 'hidden')}; return true; })()`;
89
+ }
90
+
91
+ class TerminalAppPage extends CdpWebPage {
92
+ async screenshot(context, relPath, metadata = {}) {
93
+ const hidden = await this.evaluate(hudVisibilityExpression(false)).catch(() => false);
94
+ try {
95
+ return await super.screenshot(context, relPath, metadata);
96
+ } finally {
97
+ if (hidden) await this.evaluate(hudVisibilityExpression(true)).catch(() => {});
98
+ }
99
+ }
100
+
101
+ // A tab whose first load is still pending (a slow dev-server compile) has no
102
+ // committed history entry yet; navigating away from it is still fine.
103
+ async currentNavigationUrl() {
104
+ const history = await this.session.call('Page.getNavigationHistory');
105
+ const committed = history.entries?.[history.currentIndex]?.url;
106
+ if (committed) return committed;
107
+ return (await this.evaluate('location.href')) || 'about:blank';
108
+ }
109
+
110
+ // A dev-server overlay (the Next.js issues badge) can cover part of a
111
+ // control. Click the first point of the control that is actually on top, as a
112
+ // user would aim at its visible part; fail when no part is reachable.
113
+ async click(selector, timeoutMs = 10000) {
114
+ // Rows and dialogs render after venue streams update; wait for the control
115
+ // to exist and expose a reachable point before giving up.
116
+ const deadline = Date.now() + timeoutMs;
117
+ let target = await this.#clickTarget(selector);
118
+ while (target.error && Date.now() < deadline) {
119
+ await new Promise((resolve) => setTimeout(resolve, 250));
120
+ target = await this.#clickTarget(selector);
121
+ }
122
+ if (target.error) throw new Error(target.error);
123
+ await this.clickPoint(target.x, target.y);
124
+ return { clicked: true, selector, tagName: target.tagName, partiallyCovered: target.covered };
125
+ }
126
+
127
+ #clickTarget(selector) {
128
+ return this.evaluateInIsolatedWorld(`(() => {
129
+ const el = document.querySelector(${JSON.stringify(selector)});
130
+ if (!el) return { error: 'Selector not found: ' + ${JSON.stringify(selector)} };
131
+ el.scrollIntoView({ block: 'center', inline: 'center' });
132
+ const rect = el.getBoundingClientRect();
133
+ if (rect.width === 0 || rect.height === 0) return { error: 'Target has no box: ' + ${JSON.stringify(selector)} };
134
+ const xs = [0.5, 0.25, 0.75, 0.1, 0.9];
135
+ const ys = [0.5, 0.25, 0.75, 0.1, 0.9];
136
+ for (const fy of ys) {
137
+ for (const fx of xs) {
138
+ const x = rect.left + rect.width * fx;
139
+ const y = rect.top + rect.height * fy;
140
+ const hit = document.elementFromPoint(x, y);
141
+ if (hit && (hit === el || el.contains(hit))) return { x, y, tagName: el.tagName, covered: !(fx === 0.5 && fy === 0.5) };
142
+ }
143
+ }
144
+ const top = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
145
+ return { error: 'Target is fully covered by ' + (top ? top.tagName.toLowerCase() : 'nothing') + ': ' + ${JSON.stringify(selector)} };
146
+ })()`, 'mm-terminal-click');
147
+ }
148
+
149
+ async setInput(selector, value) {
150
+ const text = String(value ?? '');
151
+ const focus = await this.evaluate(`(() => {
152
+ const root = document.querySelector(${JSON.stringify(selector)});
153
+ const el = root && (root.matches('input, textarea') ? root : root.querySelector('input, textarea'));
154
+ if (!el) throw new Error('Input target not found: ' + ${JSON.stringify(selector)});
155
+ if (el.disabled) throw new Error('Input target is disabled: ' + ${JSON.stringify(selector)});
156
+ el.scrollIntoView({ block: 'center', inline: 'nearest' });
157
+ el.focus();
158
+ return { tagName: el.tagName, previousValue: el.value ?? '' };
159
+ })()`);
160
+ await this.session.call('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65, commands: ['selectAll'] });
161
+ await this.session.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65 });
162
+ // Replace the selection in one edit: deleting first lets the order form
163
+ // refill the empty limit price with the live mid before the text lands.
164
+ if (text) {
165
+ await this.session.call('Input.insertText', { text });
166
+ } else {
167
+ await this.session.call('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 });
168
+ await this.session.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 });
169
+ }
170
+ const after = await this.evaluate(`(() => {
171
+ const root = document.querySelector(${JSON.stringify(selector)});
172
+ const el = root && (root.matches('input, textarea') ? root : root.querySelector('input, textarea'));
173
+ if (!el) throw new Error('Input target disappeared after typing');
174
+ el.dispatchEvent(new Event('change', { bubbles: true }));
175
+ return { value: el.value ?? '' };
176
+ })()`);
177
+ if (!inputReadsBack(text, after.value)) {
178
+ throw new Error(`Input target value mismatch after typing ${selector}: expected ${JSON.stringify(text)}, got ${JSON.stringify(after.value)}.`);
179
+ }
180
+ return { set: true, selector, tagName: focus.tagName, previousValue: focus.previousValue, value: after.value };
181
+ }
182
+ }
183
+
184
+ // A typed number may come back decorated by a formatting field ("1" -> "1x",
185
+ // "15" -> "$15"); the digits must still match exactly. Anything else must read
186
+ // back verbatim.
187
+ export function inputReadsBack(typed, actual) {
188
+ if (actual === typed) return true;
189
+ if (!/^-?[\d.,]+$/u.test(typed)) return false;
190
+ const digits = (value) => String(value).replace(/[^\d.-]/gu, '');
191
+ return digits(actual) === digits(typed) && digits(actual) !== '';
192
+ }
193
+
194
+ // Slow-mo resolved at launch (--slow-mo, slow_mo_ms, TERMINAL_SLOW_MO), else
195
+ // the current TERMINAL_SLOW_MO.
196
+ export function slowMoFor(browserState, env = process.env) {
197
+ if (Number.isInteger(browserState?.slowMoMs)) return browserState.slowMoMs;
198
+ return resolveSlowMo({ env });
199
+ }
200
+
201
+ export function focusBrowserRequested(env = process.env) {
202
+ return env.MM_HARNESS_FOCUS_BROWSER === '1';
203
+ }
204
+
205
+ // Defence in depth before a wallet confirmation: the app port is still served
206
+ // by the processes that served it when the browser launched. A later, more
207
+ // specific bind on the port would otherwise put another server behind the tab.
208
+ export function assertAppListenersUnchanged(state, current) {
209
+ const recorded = Array.isArray(state?.appListeners) ? state.appListeners : [];
210
+ if (recorded.length === 0) return;
211
+ const added = current.filter((pid) => !recorded.includes(pid));
212
+ if (added.length > 0) {
213
+ throw new Error(`the app port is now also served by pid ${added.join(', ')}, which did not serve it at launch; refusing to confirm a wallet request.`);
214
+ }
215
+ }
216
+
217
+ const PAGE_DEPS = Object.freeze({
218
+ listeners: (port) => cdpListenerPids(port),
219
+ select: (input) => selectTerminalTarget(input),
220
+ connect: (url) => CdpSession.connect(url),
221
+ browserState: (projectRoot) => readBrowserState(projectRoot),
222
+ sleep: (ms) => sleep(ms),
223
+ env: process.env,
224
+ });
225
+
226
+ export async function withTerminalPage(input, callback, deps = PAGE_DEPS) {
227
+ const state = input.context?.projectRoot ? await deps.browserState(input.context.projectRoot) : null;
228
+ const delay = slowMoFor(state, deps.env);
229
+ if (delay > 0) await deps.sleep(delay);
230
+ const { target, surface, port } = await deps.select(input);
231
+ if (surface === 'wallet' && state?.appOrigin) {
232
+ assertAppListenersUnchanged(state, deps.listeners(Number(new URL(state.appOrigin).port)));
233
+ }
234
+ const session = await deps.connect(target.webSocketDebuggerUrl);
235
+ try {
236
+ await session.call('Page.enable');
237
+ await session.call('Runtime.enable');
238
+ if (focusBrowserRequested(deps.env)) await session.call('Page.bringToFront');
239
+ const page = surface === 'wallet' ? new WalletSurfacePage(session) : new TerminalAppPage(session);
240
+ const result = await callback(page);
241
+ return result && typeof result === 'object' && !Array.isArray(result)
242
+ ? { ...result, surface, cdpPort: port, targetUrl: target.url.replace(/0x[0-9a-fA-F]{40}/gu, '<address>') }
243
+ : result;
244
+ } finally {
245
+ session.close();
246
+ }
247
+ }
@@ -0,0 +1,200 @@
1
+ // Resolve the Web Terminal slot runtime: ports, runtime paths, browser state and
2
+ // wallet-fixture accounts. Key material is only ever returned as a viem account
3
+ // object; callers must never log or persist it.
4
+
5
+ import { realpathSync } from 'node:fs';
6
+ import { readFile, writeFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+
10
+ import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts';
11
+
12
+ import { recipeRuntimeDir, walletFixturePath } from '../../harness-exports.mjs';
13
+
14
+ export const SIGNER_MODES = Object.freeze(['extension', 'injected']);
15
+ export const DEFAULT_ACCOUNT = 'dev1';
16
+
17
+ // The checkout's canonical path: a symlinked or /tmp-vs-/private/tmp spelling
18
+ // of the same slot must name the same runtime dir, profile and pid owners.
19
+ export function canonicalPath(target) {
20
+ const resolved = path.resolve(target);
21
+ try {
22
+ return realpathSync(resolved);
23
+ } catch {
24
+ return resolved;
25
+ }
26
+ }
27
+
28
+ export function terminalRuntimeDir(projectRoot) {
29
+ return path.join(canonicalPath(projectRoot), recipeRuntimeDir(), 'terminal');
30
+ }
31
+
32
+ export function terminalRuntimePath(projectRoot, ...segments) {
33
+ return path.join(terminalRuntimeDir(projectRoot), ...segments);
34
+ }
35
+
36
+ function portFrom(raw, label) {
37
+ if (raw === undefined || raw === null || raw === '') return undefined;
38
+ const port = Number(raw);
39
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
40
+ throw new Error(`${label} must be a TCP port, got ${JSON.stringify(raw)}.`);
41
+ }
42
+ return port;
43
+ }
44
+
45
+ // The slot's Next.js dev server. Farmslot passes it as --watcher-port {{port}};
46
+ // TERMINAL_APP_PORT is the explicit override.
47
+ export function appPort(input = {}, env = process.env) {
48
+ const port =
49
+ portFrom(input.node?.app_port, 'app_port') ??
50
+ portFrom(env.TERMINAL_APP_PORT, 'TERMINAL_APP_PORT') ??
51
+ portFrom(env.RECIPE_WATCHER_PORT, 'RECIPE_WATCHER_PORT') ??
52
+ portFrom(env.WATCHER_PORT, 'WATCHER_PORT');
53
+ if (!port) {
54
+ throw new Error(
55
+ 'terminal adapter requires the slot dev-server port.\n' +
56
+ 'Next: pass --watcher-port <port> (Farmslot: {{port}}) or set TERMINAL_APP_PORT.',
57
+ );
58
+ }
59
+ return port;
60
+ }
61
+
62
+ export function appOrigin(input = {}, env = process.env) {
63
+ return `http://localhost:${appPort(input, env)}`;
64
+ }
65
+
66
+ export function cdpPort(input = {}, env = process.env) {
67
+ const port =
68
+ portFrom(input.node?.cdp_port, 'cdp_port') ??
69
+ portFrom(env.RECIPE_CDP_PORT, 'RECIPE_CDP_PORT') ??
70
+ portFrom(env.CDP_PORT, 'CDP_PORT');
71
+ if (!port) {
72
+ throw new Error(
73
+ 'terminal adapter requires a per-slot browser CDP port.\n' +
74
+ 'Next: pass --cdp-port <port> (Farmslot: {{cdp_port}}).',
75
+ );
76
+ }
77
+ return port;
78
+ }
79
+
80
+ export async function readJsonFile(file) {
81
+ try {
82
+ return JSON.parse(await readFile(file, 'utf8'));
83
+ } catch (error) {
84
+ if (error?.code === 'ENOENT') return null;
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ export async function writePrivateJson(file, value) {
90
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
91
+ }
92
+
93
+ export async function readBrowserState(projectRoot) {
94
+ return readJsonFile(terminalRuntimePath(projectRoot, 'browser.json'));
95
+ }
96
+
97
+ export function resolveSigner(raw) {
98
+ const signer = raw == null || raw === '' ? 'extension' : String(raw);
99
+ if (!SIGNER_MODES.includes(signer)) {
100
+ throw new Error(`signer must be one of ${SIGNER_MODES.join(' | ')}, got ${JSON.stringify(raw)}.`);
101
+ }
102
+ return signer;
103
+ }
104
+
105
+ // Account name precedence: node.account_name → node.account (when not an
106
+ // address) → the running browser's account → dev1.
107
+ export function accountName(input = {}, browserState = null) {
108
+ const explicit = input.node?.account_name ?? input.node?.account;
109
+ if (typeof explicit === 'string' && explicit.trim() && !/^0x[0-9a-fA-F]{40}$/u.test(explicit.trim())) {
110
+ return explicit.trim();
111
+ }
112
+ return browserState?.account?.name ?? DEFAULT_ACCOUNT;
113
+ }
114
+
115
+ export function fixturePath(projectRoot, env = process.env) {
116
+ return env.RECIPE_WALLET_FIXTURE ? path.resolve(env.RECIPE_WALLET_FIXTURE) : walletFixturePath(projectRoot);
117
+ }
118
+
119
+ export async function loadWalletFixture(projectRoot, env = process.env) {
120
+ const file = fixturePath(projectRoot, env);
121
+ const fixture = await readJsonFile(file);
122
+ if (!fixture) {
123
+ throw new Error(
124
+ `wallet fixture not found at ${file}.\n` +
125
+ 'Next: mm-harness install --adapter terminal --target <checkout> with RECIPE_WALLET_FIXTURE set, or copy the fixture there (0600).',
126
+ );
127
+ }
128
+ if (!Array.isArray(fixture.accounts) || fixture.accounts.length === 0) {
129
+ throw new Error(`wallet fixture at ${file} has no accounts.`);
130
+ }
131
+ return { file, fixture };
132
+ }
133
+
134
+ export function fixtureEntry(fixture, name) {
135
+ const entry = fixture.accounts.find((account) => account?.name === name);
136
+ if (!entry) {
137
+ const names = fixture.accounts.map((account) => account?.name).filter(Boolean).join(', ');
138
+ throw new Error(`wallet fixture has no account named "${name}". Available: ${names}.`);
139
+ }
140
+ if (entry.type !== 'mnemonic' && entry.type !== 'privateKey') {
141
+ throw new Error(`wallet fixture account "${name}" must be mnemonic or privateKey, got "${entry.type}".`);
142
+ }
143
+ if (typeof entry.value !== 'string' || !entry.value.trim()) {
144
+ throw new Error(`wallet fixture account "${name}" has no value.`);
145
+ }
146
+ return entry;
147
+ }
148
+
149
+ // Mnemonics use BIP-44 index 0, the MetaMask default and the derivation the
150
+ // Extension fixture flow imports.
151
+ export function viemAccountFromEntry(entry) {
152
+ if (entry.type === 'mnemonic') return mnemonicToAccount(entry.value.trim(), { addressIndex: 0 });
153
+ const raw = entry.value.trim();
154
+ const key = raw.startsWith('0x') ? raw : `0x${raw}`;
155
+ if (!/^0x[0-9a-fA-F]{64}$/u.test(key)) {
156
+ throw new Error(`wallet fixture privateKey account "${entry.name}" is not a 32-byte hex key.`);
157
+ }
158
+ return privateKeyToAccount(key);
159
+ }
160
+
161
+ export async function fixtureAccount(projectRoot, name, env = process.env) {
162
+ const { fixture } = await loadWalletFixture(projectRoot, env);
163
+ return viemAccountFromEntry(fixtureEntry(fixture, name));
164
+ }
165
+
166
+ // Address-only view of the account a node targets: an explicit address wins
167
+ // (read-only use), otherwise the fixture account is derived.
168
+ export async function resolveAccountAddress(input, env = process.env) {
169
+ const raw = input.node?.account;
170
+ if (typeof raw === 'string' && /^0x[0-9a-fA-F]{40}$/u.test(raw.trim())) {
171
+ return { name: null, address: raw.trim() };
172
+ }
173
+ const projectRoot = input.context?.projectRoot;
174
+ const browserState = projectRoot ? await readBrowserState(projectRoot) : null;
175
+ const name = accountName(input, browserState);
176
+ const account = await fixtureAccount(projectRoot, name, env);
177
+ return { name, address: account.address };
178
+ }
179
+
180
+ export async function loadInput() {
181
+ const inputPath = process.argv[2] || process.env.METAMASK_RECIPE_ADAPTER_INPUT;
182
+ if (!inputPath) throw new Error('Missing live adapter input path.');
183
+ return JSON.parse(await readFile(inputPath, 'utf8'));
184
+ }
185
+
186
+ export function isDirectRun(importMetaUrl) {
187
+ const entry = process.argv[1];
188
+ return Boolean(entry) && importMetaUrl === pathToFileURL(entry).href;
189
+ }
190
+
191
+ export async function runAdapter(callback) {
192
+ const input = await loadInput();
193
+ try {
194
+ const output = await callback(input);
195
+ await writeFile(input.outputPath, `${JSON.stringify(output, null, 2)}\n`);
196
+ } catch (error) {
197
+ process.stderr.write(`[terminal] ${input.action} failed: ${error?.stack ?? error}\n`);
198
+ process.exitCode = 1;
199
+ }
200
+ }
@@ -0,0 +1,31 @@
1
+ // Navigate the terminal app tab to a product page (order/<MARKET>, portfolio)
2
+ // or an explicit app path, and wait for the document to be ready.
3
+
4
+ import { appOrigin, isDirectRun, runAdapter } from '../platform/runtime.mjs';
5
+ import { withTerminalPage } from '../platform/page.mjs';
6
+
7
+ export function pathFor(node = {}) {
8
+ if (typeof node.path === 'string' && node.path.startsWith('/')) return node.path;
9
+ const page = node.page == null ? 'order' : String(node.page);
10
+ if (page === 'order' || page === 'market') {
11
+ const market = String(node.market ?? node.symbol ?? 'BTC').toUpperCase();
12
+ if (!/^[A-Z0-9:_-]+$/u.test(market)) throw new Error(`unsupported market symbol ${JSON.stringify(market)}`);
13
+ return `/order/${market}`;
14
+ }
15
+ if (page === 'portfolio') return '/portfolio';
16
+ if (page === 'home') return '/';
17
+ throw new Error(`ui.navigate page must be order, portfolio or home (or pass path), got ${JSON.stringify(page)}.`);
18
+ }
19
+
20
+ export async function navigate(input) {
21
+ const node = input.node ?? {};
22
+ const url = `${appOrigin(input)}${pathFor(node)}`;
23
+ return withTerminalPage({ ...input, node: { ...node, surface: 'app' } }, async (page) => {
24
+ await page.navigate(url, Number(node.timeout_ms ?? 120000));
25
+ await page.waitForDocumentReady({ expectedUrl: url, timeoutMs: Number(node.timeout_ms ?? 120000) });
26
+ if (node.settle_ms) await new Promise((resolve) => setTimeout(resolve, Number(node.settle_ms)));
27
+ return { action: input.action, url, page: node.page ?? 'order', market: node.market ?? null };
28
+ });
29
+ }
30
+
31
+ if (isDirectRun(import.meta.url)) runAdapter((input) => navigate(input));
@@ -0,0 +1,177 @@
1
+ // Read, window and assert the terminal wallet request log the wallet host
2
+ // writes: one JSON line per request the app sent to the main wallet (method,
3
+ // EIP-712 primary type, domain/active chain, outcome) and, in extension mode,
4
+ // one line per confirmation window MetaMask opened. Never holds params,
5
+ // messages or signatures.
6
+
7
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+
10
+ import { terminalRuntimePath } from '../platform/runtime.mjs';
11
+
12
+ export const L1_PRIMARY_TYPE = 'Agent';
13
+ export const L1_CHAIN_ID = 1337;
14
+
15
+ export function signatureLogPath(projectRoot) {
16
+ return terminalRuntimePath(projectRoot, 'wallet-requests.jsonl');
17
+ }
18
+
19
+ export function signatureCursorPath(projectRoot) {
20
+ return terminalRuntimePath(projectRoot, 'wallet-requests.cursor.json');
21
+ }
22
+
23
+ export function parseLog(text) {
24
+ return text
25
+ .split('\n')
26
+ .filter((line) => line.trim())
27
+ .map((line, index) => {
28
+ try {
29
+ return { seq: index, ...JSON.parse(line) };
30
+ } catch {
31
+ return { seq: index, kind: 'unparseable' };
32
+ }
33
+ });
34
+ }
35
+
36
+ export async function readLog(projectRoot) {
37
+ try {
38
+ return parseLog(await readFile(signatureLogPath(projectRoot), 'utf8'));
39
+ } catch (error) {
40
+ if (error?.code === 'ENOENT') return [];
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ export async function readCursor(projectRoot) {
46
+ try {
47
+ const cursor = JSON.parse(await readFile(signatureCursorPath(projectRoot), 'utf8'));
48
+ return Number.isInteger(cursor.seq) ? cursor.seq : 0;
49
+ } catch (error) {
50
+ if (error?.code === 'ENOENT') return 0;
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ export async function writeCursor(projectRoot, seq) {
56
+ await writeFile(signatureCursorPath(projectRoot), `${JSON.stringify({ seq, at: new Date().toISOString() })}\n`, { mode: 0o600 });
57
+ }
58
+
59
+ // "HyperliquidTransaction:ApproveAgent" → "ApproveAgent".
60
+ export function shortPrimaryType(primaryType) {
61
+ if (primaryType == null) return null;
62
+ const raw = String(primaryType);
63
+ return raw.includes(':') ? raw.slice(raw.lastIndexOf(':') + 1) : raw;
64
+ }
65
+
66
+ export function isTypedDataRequest(entry) {
67
+ return entry.kind === 'request' && /^eth_signTypedData/u.test(String(entry.method));
68
+ }
69
+
70
+ export function isL1Request(entry) {
71
+ return isTypedDataRequest(entry) &&
72
+ (shortPrimaryType(entry.primaryType) === L1_PRIMARY_TYPE || Number(entry.domainChainId) === L1_CHAIN_ID);
73
+ }
74
+
75
+ export function summarize(entries) {
76
+ const requests = entries.filter((entry) => entry.kind === 'request');
77
+ const typed = requests.filter(isTypedDataRequest);
78
+ const byPrimaryType = {};
79
+ for (const entry of typed) {
80
+ const key = shortPrimaryType(entry.primaryType) ?? '<none>';
81
+ byPrimaryType[key] ??= { requested: 0, signed: 0, rejected: 0, chainIds: [] };
82
+ byPrimaryType[key].requested += 1;
83
+ if (entry.outcome === 'signed') byPrimaryType[key].signed += 1;
84
+ if (entry.outcome === 'rejected' || entry.outcome === 'error') byPrimaryType[key].rejected += 1;
85
+ if (!byPrimaryType[key].chainIds.includes(entry.domainChainId)) byPrimaryType[key].chainIds.push(entry.domainChainId);
86
+ }
87
+ const byMethod = {};
88
+ for (const entry of requests) byMethod[entry.method] = (byMethod[entry.method] ?? 0) + 1;
89
+ return {
90
+ requests: requests.length,
91
+ typedDataRequests: typed.length,
92
+ l1Requests: typed.filter(isL1Request).length,
93
+ confirmationsShown: entries.filter((entry) => entry.kind === 'confirmation-shown').length,
94
+ // Testnet enforcement and evidence gaps: each one fails the assertion.
95
+ blockedMainnet: entries.filter((entry) => entry.kind === 'blocked-mainnet' && !entry.probe).length,
96
+ refusedMainnet: entries.filter((entry) => entry.kind === 'refused-mainnet').length,
97
+ unattributed: entries.filter((entry) => entry.kind === 'unattributed').length,
98
+ // Calls from an iframe, a foreign page or a blank document: refused, kept as evidence.
99
+ outsideAppFrame: entries.filter((entry) => entry.kind === 'outside-app-frame').length,
100
+ byPrimaryType,
101
+ byMethod,
102
+ };
103
+ }
104
+
105
+ function exact(label, actual, expected, failures) {
106
+ if (actual !== expected) failures.push(`${label}: expected ${expected}, got ${actual}`);
107
+ }
108
+
109
+ // Every expectation is optional; max_l1_requests defaults to 0 because an L1
110
+ // (agent-signed) payload must never reach the main wallet.
111
+ export function evaluateSignatureLog(entries, node = {}) {
112
+ const summary = summarize(entries);
113
+ const typed = entries.filter(isTypedDataRequest);
114
+ const failures = [];
115
+ if (summary.blockedMainnet > 0) failures.push(`the browser tried to reach a mainnet venue ${summary.blockedMainnet} time(s) (blocked-mainnet)`);
116
+ if (summary.refusedMainnet > 0) failures.push(`the app asked the wallet to sign ${summary.refusedMainnet} mainnet action(s) (refused-mainnet)`);
117
+ if (summary.unattributed > 0) failures.push(`${summary.unattributed} wallet log entr(y/ies) could not be attributed to the app's top frame (unattributed)`);
118
+ const maxL1 = node.max_l1_requests == null ? 0 : Number(node.max_l1_requests);
119
+ if (summary.l1Requests > maxL1) {
120
+ failures.push(`L1 requests reached the main wallet: ${summary.l1Requests} > ${maxL1}`);
121
+ }
122
+ if (Array.isArray(node.allowed_primary_types)) {
123
+ const allowed = new Set(node.allowed_primary_types.map(shortPrimaryType));
124
+ for (const entry of typed) {
125
+ const type = shortPrimaryType(entry.primaryType);
126
+ if (!allowed.has(type)) failures.push(`primary type ${type} (seq ${entry.seq}) is not allowed`);
127
+ }
128
+ }
129
+ if (node.counts && typeof node.counts === 'object') {
130
+ for (const [type, expected] of Object.entries(node.counts)) {
131
+ exact(`signed ${shortPrimaryType(type)}`, summary.byPrimaryType[shortPrimaryType(type)]?.signed ?? 0, Number(expected), failures);
132
+ }
133
+ }
134
+ if (node.request_counts && typeof node.request_counts === 'object') {
135
+ for (const [type, expected] of Object.entries(node.request_counts)) {
136
+ exact(`requested ${shortPrimaryType(type)}`, summary.byPrimaryType[shortPrimaryType(type)]?.requested ?? 0, Number(expected), failures);
137
+ }
138
+ }
139
+ if (node.methods && typeof node.methods === 'object') {
140
+ for (const [method, expected] of Object.entries(node.methods)) {
141
+ exact(`method ${method}`, summary.byMethod[method] ?? 0, Number(expected), failures);
142
+ }
143
+ }
144
+ if (node.max_typed_data_requests != null && summary.typedDataRequests > Number(node.max_typed_data_requests)) {
145
+ failures.push(`typed-data requests ${summary.typedDataRequests} > ${node.max_typed_data_requests}`);
146
+ }
147
+ if (node.expect_confirmations != null) {
148
+ exact('confirmations shown', summary.confirmationsShown, Number(node.expect_confirmations), failures);
149
+ }
150
+ if (node.require_active_chain === true) {
151
+ for (const entry of typed.filter((item) => item.outcome === 'signed')) {
152
+ if (Number(entry.domainChainId) !== Number(entry.activeChainId)) {
153
+ failures.push(`seq ${entry.seq} ${shortPrimaryType(entry.primaryType)} signed with domain chainId ${entry.domainChainId} while the wallet was on ${entry.activeChainId}`);
154
+ }
155
+ }
156
+ }
157
+ if (node.max_rejected != null) {
158
+ const rejected = typed.filter((entry) => entry.outcome === 'rejected' || entry.outcome === 'error').length;
159
+ if (rejected > Number(node.max_rejected)) failures.push(`rejected typed-data requests ${rejected} > ${node.max_rejected}`);
160
+ }
161
+ return { ok: failures.length === 0, failures, summary };
162
+ }
163
+
164
+ export async function windowSinceCursor(projectRoot) {
165
+ const [entries, cursor] = await Promise.all([readLog(projectRoot), readCursor(projectRoot)]);
166
+ return { cursor, entries: entries.filter((entry) => entry.seq >= cursor), total: entries.length };
167
+ }
168
+
169
+ export async function writeLogArtifact(input, payload) {
170
+ const artifactsDir = input.context?.artifactsDir;
171
+ if (!artifactsDir) return [];
172
+ const nodeId = String(input.context?.nodeId ?? input.action).replaceAll('/', '__');
173
+ const relative = `terminal-wallet/${nodeId}-signature-log.json`;
174
+ await mkdir(path.join(artifactsDir, 'terminal-wallet'), { recursive: true });
175
+ await writeFile(path.join(artifactsDir, relative), `${JSON.stringify(payload, null, 2)}\n`);
176
+ return [{ path: relative, type: 'report', nodeId: String(input.context?.nodeId ?? input.action) }];
177
+ }