@deeeed/metamask-harness 0.70.1 → 0.72.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 (75) hide show
  1. package/CHANGELOG.md +25 -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/mobile/verify.js +8 -3
  22. package/dist/adapters/surface.js +3 -1
  23. package/dist/adapters/terminal/console-capture.js +46 -0
  24. package/dist/adapters/terminal/stop.js +19 -0
  25. package/dist/adapters/terminal/surface.js +104 -0
  26. package/dist/adapters.js +27 -4
  27. package/dist/cli.js +2 -2
  28. package/dist/command-contract.js +12 -4
  29. package/dist/commands/check.js +1 -1
  30. package/dist/commands/config.js +1 -1
  31. package/dist/commands/doctor.js +24 -1
  32. package/dist/commands/domain.js +1 -1
  33. package/dist/commands/farmslot-ready.js +2 -0
  34. package/dist/commands/help.js +2 -2
  35. package/dist/commands/launch/index.js +97 -2
  36. package/dist/commands/manifest.js +3 -3
  37. package/dist/commands/recipe-advice.js +1 -1
  38. package/dist/commands/review.js +1 -1
  39. package/dist/commands/run-engine.js +1 -1
  40. package/dist/commands/shared.js +1 -1
  41. package/dist/doctor.js +3 -0
  42. package/dist/harness.js +17 -11
  43. package/dist/manifest.js +5 -0
  44. package/dist/mm-harness-cli.js +34 -20
  45. package/dist/paths.js +2 -2
  46. package/dist/recipe-security.js +12 -3
  47. package/dist/review/knowledge.js +1 -1
  48. package/dist/run-diagnostics.js +150 -33
  49. package/dist/runner.js +1 -1
  50. package/dist/runtime-context.js +3 -3
  51. package/docs/CONTRIBUTING.md +1 -1
  52. package/docs/cli-contract.md +163 -0
  53. package/library/actions/shared/console-allowlist-add.mjs +137 -0
  54. package/library/actions/shared/console-collector.mjs +230 -0
  55. package/library/actions/shared/console-findings.mjs +511 -0
  56. package/library/actions/terminal/app/assert_no_console_errors.mjs +119 -0
  57. package/library/actions/terminal/app/launch.mjs +45 -0
  58. package/library/actions/terminal/perps/_venue.mjs +242 -0
  59. package/library/actions/terminal/perps/assert_orders.mjs +54 -0
  60. package/library/actions/terminal/perps/assert_positions.mjs +40 -0
  61. package/library/actions/terminal/perps/read_orders.mjs +26 -0
  62. package/library/actions/terminal/perps/read_positions.mjs +25 -0
  63. package/library/actions/terminal/perps/read_snapshot.mjs +40 -0
  64. package/library/actions/terminal/perps/revoke_agent.mjs +58 -0
  65. package/library/actions/terminal/perps/teardown_state.mjs +104 -0
  66. package/library/actions/terminal/platform/hud.mjs +71 -0
  67. package/library/actions/terminal/platform/page.mjs +247 -0
  68. package/library/actions/terminal/platform/runtime.mjs +200 -0
  69. package/library/actions/terminal/ui/navigate.mjs +31 -0
  70. package/library/actions/terminal/wallet/_signature-log.mjs +177 -0
  71. package/library/actions/terminal/wallet/assert_signatures.mjs +43 -0
  72. package/library/actions/terminal/wallet/list_accounts.mjs +27 -0
  73. package/library/actions/terminal/wallet/read_signatures.mjs +37 -0
  74. package/library/manifests/terminal.action-manifest.json +1786 -0
  75. package/package.json +2 -1
@@ -0,0 +1,274 @@
1
+ // Read-only readiness of a Web Terminal slot for recipe runs: checkout shape,
2
+ // dependencies, the slot dev server, forced testnet, wallet fixture, browser,
3
+ // MetaMask extension copy and the per-slot CDP port. Never launches anything
4
+ // except the optional short-lived browser start probe.
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import { canonicalPath, fixtureEntry, loadWalletFixture, readBrowserState, terminalRuntimeDir, viemAccountFromEntry } from '../../../library/actions/terminal/platform/runtime.mjs';
11
+ import { ownsProcess, pidAlive, portFree } from './processes.mjs';
12
+
13
+ function check(id, ok, detail, { required = true, status } = {}) {
14
+ return { id, status: status ?? (ok ? 'pass' : 'fail'), required, detail };
15
+ }
16
+
17
+ export function isTerminalCheckout(target) {
18
+ try {
19
+ const pkg = JSON.parse(readFileSync(path.join(target, 'package.json'), 'utf8'));
20
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
21
+ return Boolean(deps.next) && existsSync(path.join(target, 'src/features/perpetuals'));
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ async function httpStatus(url, timeoutMs = 20000) {
28
+ try {
29
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'manual' });
30
+ return response.status;
31
+ } catch (error) {
32
+ return error?.name === 'TimeoutError' ? 'timeout' : 'unreachable';
33
+ }
34
+ }
35
+
36
+ // Diagnostic only. Whether the slot's dev server looks configured for testnet:
37
+ // the farm starts `next dev` (temp/farmslot/next-dev.pid) with
38
+ // NEXT_PUBLIC_HYPERLIQUID_FORCE_TESTNET=true. Process inspection cannot prove
39
+ // what the browser talks to (macOS reports a process's initial environment,
40
+ // and lsof cannot see other users' sockets), so this only warns; the browser
41
+ // is held to testnet by the network and wallet layers (mainnet-guard.mjs, the
42
+ // wallet host). Each listener on the app port must descend from the pid-file
43
+ // process and run from the checkout (its cwd); every process on the way from
44
+ // a listener up to the pid-file process is read exactly (argv and environment
45
+ // entries from sysctl KERN_PROCARGS2) and must carry the testnet variable. An
46
+ // unreadable process makes the result unknown.
47
+ export const DEV_SERVER_PID_FILE = 'temp/farmslot/next-dev.pid';
48
+ const FORCE_VARIABLE = 'NEXT_PUBLIC_HYPERLIQUID_FORCE_TESTNET';
49
+
50
+ function run(command, args) {
51
+ try {
52
+ return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
53
+ } catch (error) {
54
+ return typeof error?.stdout === 'string' ? error.stdout : '';
55
+ }
56
+ }
57
+
58
+ const PROCARGS_HELPER = `
59
+ import ctypes, ctypes.util, json, struct, sys
60
+ libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
61
+ pid = int(sys.argv[1])
62
+ argmax = ctypes.c_int(0)
63
+ size = ctypes.c_size_t(ctypes.sizeof(argmax))
64
+ if libc.sysctl((ctypes.c_int * 2)(1, 8), 2, ctypes.byref(argmax), ctypes.byref(size), None, 0) != 0:
65
+ print("null"); sys.exit(0)
66
+ buf = ctypes.create_string_buffer(argmax.value)
67
+ size = ctypes.c_size_t(argmax.value)
68
+ if libc.sysctl((ctypes.c_int * 3)(1, 49, pid), 3, buf, ctypes.byref(size), None, 0) != 0:
69
+ print("null"); sys.exit(0)
70
+ raw = buf.raw[:size.value]
71
+ argc = struct.unpack("i", raw[:4])[0]
72
+ rest = raw[4:]
73
+ rest = rest[rest.index(b"\\0"):].lstrip(b"\\0")
74
+ parts = rest.split(b"\\0")
75
+ env = []
76
+ for part in parts[argc:]:
77
+ if not part: break
78
+ env.append(part.decode("utf-8", "replace"))
79
+ print(json.dumps({"argv": [p.decode("utf-8", "replace") for p in parts[:argc]], "env": env}))
80
+ `;
81
+
82
+ export const systemProbe = Object.freeze({
83
+ listeners: (port) => run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']).split('\n').map(Number).filter((pid) => pid > 0),
84
+ parents: () => new Map(run('ps', ['-A', '-o', 'pid=,ppid=']).split('\n')
85
+ .map((line) => line.trim().split(/\s+/u).map(Number))
86
+ .filter(([pid, ppid]) => pid > 0 && ppid >= 0)),
87
+ cwd: (pid) => (run('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']).split('\n').find((line) => line.startsWith('n')) ?? '').slice(1) || null,
88
+ // { argv: string[], env: string[] } with exact element boundaries, or null.
89
+ procArgs: (pid) => {
90
+ const python = usablePython();
91
+ if (!python) return null;
92
+ try {
93
+ return JSON.parse(execFileSync(python, ['-c', PROCARGS_HELPER, String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 }).trim());
94
+ } catch {
95
+ return null;
96
+ }
97
+ },
98
+ });
99
+
100
+ // macOS's /usr/bin/python3 is a stub until the Command Line Tools are
101
+ // installed, and running it opens an install dialog that takes keyboard
102
+ // focus. Use it only when xcode-select reports a developer directory; any
103
+ // other python3 on PATH is a real install.
104
+ export function pythonFor({ found, platform = process.platform, developerDir }) {
105
+ if (!found) return null;
106
+ if (platform === 'darwin' && found === '/usr/bin/python3' && !developerDir()) return null;
107
+ return found;
108
+ }
109
+
110
+ let pythonChoice;
111
+ function usablePython() {
112
+ if (pythonChoice === undefined) {
113
+ pythonChoice = pythonFor({ found: run('/usr/bin/which', ['python3']).trim(), developerDir: () => run('/usr/bin/xcode-select', ['-p']).trim() });
114
+ }
115
+ return pythonChoice;
116
+ }
117
+
118
+ export function environmentValue(env, name) {
119
+ const entry = env.find((item) => item.startsWith(`${name}=`));
120
+ return entry === undefined ? undefined : entry.slice(name.length + 1);
121
+ }
122
+
123
+ // `next dev` / `next start`: the argument after the next binary.
124
+ export function nextServerMode(argv) {
125
+ for (let index = 0; index < argv.length - 1; index += 1) {
126
+ const element = argv[index];
127
+ if (element === 'next' || element.endsWith('/next') || element.endsWith('/next/dist/bin/next')) {
128
+ if (argv[index + 1] === 'dev' || argv[index + 1] === 'start') return argv[index + 1];
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function chainTo(listener, root, parents) {
135
+ const chain = [];
136
+ let current = listener;
137
+ for (let hops = 0; current && hops < 64; hops += 1) {
138
+ chain.push(current);
139
+ if (current === root) return chain;
140
+ current = parents.get(current);
141
+ }
142
+ return null;
143
+ }
144
+
145
+ export function devServerTestnetDiagnostic(target, { appPort, probe = systemProbe } = {}) {
146
+ const checkout = canonicalPath(target);
147
+ const unknown = (detail) => ({ known: false, forced: false, detail });
148
+ const pidFile = path.join(checkout, DEV_SERVER_PID_FILE);
149
+ if (!existsSync(pidFile)) return unknown(`no ${DEV_SERVER_PID_FILE}`);
150
+ const root = Number(readFileSync(pidFile, 'utf8').trim());
151
+ if (!pidAlive(root)) return unknown(`next dev pid ${root} is not running`);
152
+ if (!appPort) return unknown('no app port to check');
153
+ const listeners = probe.listeners(appPort);
154
+ if (listeners.length === 0) return unknown(`nothing (visible to this user) listens on app port ${appPort}`);
155
+ const parents = probe.parents();
156
+ let mode = null;
157
+ for (const listener of listeners) {
158
+ const chain = chainTo(listener, root, parents);
159
+ if (!chain) return { known: true, forced: false, detail: `app port ${appPort} is served by pid ${listener}, outside next dev pid ${root}` };
160
+ const cwd = probe.cwd(listener);
161
+ if (!cwd || canonicalPath(cwd) !== checkout) {
162
+ return { known: true, forced: false, detail: `pid ${listener} on app port ${appPort} runs from ${cwd ?? 'an unknown directory'}, not ${checkout}` };
163
+ }
164
+ for (const pid of chain) {
165
+ const args = probe.procArgs(pid);
166
+ if (!args) return unknown(`cannot read the arguments and environment of pid ${pid}`);
167
+ // A process that rewrote its title (next-server) overwrote the memory
168
+ // its arguments and environment are read from.
169
+ if (args.env.length === 0) return unknown(`pid ${pid} (${args.argv[0] ?? '?'}) rewrote its process title; its environment is not readable`);
170
+ const processMode = nextServerMode(args.argv);
171
+ if (processMode === 'start') return { known: true, forced: false, detail: `pid ${pid} runs next start; its environment cannot prove what the build inlined` };
172
+ if (processMode === 'dev') mode = 'dev';
173
+ const value = environmentValue(args.env, FORCE_VARIABLE);
174
+ if (value !== 'true') {
175
+ return { known: true, forced: false, detail: `pid ${pid} (between app port ${appPort} and next dev pid ${root}) has ${FORCE_VARIABLE}=${value ?? '<unset>'}` };
176
+ }
177
+ }
178
+ }
179
+ if (mode !== 'dev') return { known: true, forced: false, detail: `no process serving app port ${appPort} runs next dev` };
180
+ return { known: true, forced: true, detail: `every process from app port ${appPort}'s listeners (cwd ${checkout}) up to next dev pid ${root} has ${FORCE_VARIABLE}=true` };
181
+ }
182
+
183
+ export const MAINNET_CONFIRMATION = 'REAL_FUNDS';
184
+
185
+ // The launch only checks the requested network: mainnet needs the real-funds
186
+ // confirmation. Testnet is enforced inside the browser (mainnet-guard.mjs).
187
+ export function assertLaunchNetwork(_target, { network, mainnetConfirmation } = {}) {
188
+ const requested = network == null || network === '' ? 'testnet' : String(network);
189
+ if (requested !== 'testnet' && requested !== 'mainnet') {
190
+ throw new Error(`network must be testnet or mainnet, got ${JSON.stringify(network)}.`);
191
+ }
192
+ if (requested === 'mainnet' && mainnetConfirmation !== MAINNET_CONFIRMATION) {
193
+ throw new Error(`network=mainnet needs mainnet_confirmation=${MAINNET_CONFIRMATION}; refusing to launch the terminal browser.`);
194
+ }
195
+ return requested;
196
+ }
197
+
198
+ export async function terminalReadiness({
199
+ target,
200
+ appPort,
201
+ cdpPort,
202
+ signer = 'extension',
203
+ account = 'dev1',
204
+ env = process.env,
205
+ probeBrowser = undefined,
206
+ }) {
207
+ const checks = [];
208
+ const shape = isTerminalCheckout(target);
209
+ checks.push(check('checkout', shape, shape ? 'Next.js checkout with src/features/perpetuals' : `${target} is not a Web Terminal checkout`));
210
+ const deps = ['next', '@nktkas/hyperliquid', '@metamask/perps-controller'].filter((name) => !existsSync(path.join(target, 'node_modules', name, 'package.json')));
211
+ checks.push(check('dependencies', deps.length === 0, deps.length === 0 ? 'next, @nktkas/hyperliquid and @metamask/perps-controller installed' : `missing: ${deps.join(', ')} (run npm ci on Node 22)`));
212
+
213
+ if (appPort) {
214
+ const status = await httpStatus(`http://127.0.0.1:${appPort}/order/BTC`);
215
+ checks.push(check('app-dev-server', status === 200, `GET http://localhost:${appPort}/order/BTC -> ${status}`));
216
+ } else {
217
+ checks.push(check('app-dev-server', false, 'no dev-server port; pass --watcher-port <port> or set TERMINAL_APP_PORT'));
218
+ }
219
+ // Advisory: the browser enforces testnet itself (mainnet-guard.mjs).
220
+ const testnet = devServerTestnetDiagnostic(target, { appPort });
221
+ checks.push(check('testnet-forced', testnet.known && testnet.forced, testnet.detail, {
222
+ required: false,
223
+ status: testnet.known && testnet.forced ? 'pass' : 'warn',
224
+ }));
225
+
226
+ try {
227
+ const { file, fixture } = await loadWalletFixture(target, env);
228
+ const address = viemAccountFromEntry(fixtureEntry(fixture, account)).address;
229
+ checks.push(check('wallet-fixture', true, `${path.basename(file)}: account ${account} -> ${address}`));
230
+ } catch (error) {
231
+ checks.push(check('wallet-fixture', false, error.message.split('\n')[0]));
232
+ }
233
+
234
+ if (probeBrowser) {
235
+ try {
236
+ const browser = await probeBrowser(env);
237
+ checks.push(check('browser', true, `${browser.version ?? browser.bin} (${browser.source})`));
238
+ } catch (error) {
239
+ checks.push(check('browser', false, error.message.split('\n')[0]));
240
+ }
241
+ }
242
+
243
+ const extensionDir = path.resolve(env.TERMINAL_EXTENSION_DIR || path.join(target, 'temp/metamask-extension/chrome'));
244
+ const manifestPath = path.join(extensionDir, 'manifest.json');
245
+ const extensionRequired = signer === 'extension';
246
+ if (existsSync(manifestPath)) {
247
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
248
+ checks.push(check('metamask-extension', true, `MetaMask ${manifest.version} at ${extensionDir}`, { required: extensionRequired }));
249
+ } else {
250
+ checks.push(check('metamask-extension', false, `no extension dist at ${extensionDir}`, { required: extensionRequired }));
251
+ }
252
+ const checkout = env.TERMINAL_EXTENSION_CHECKOUT;
253
+ const checkoutOk = Boolean(checkout) && existsSync(path.join(checkout, 'node_modules/@metamask/browser-passworder'));
254
+ checks.push(check('extension-fixture-tooling', checkoutOk, checkoutOk
255
+ ? `extension checkout ${checkout} has fixture-state dependencies`
256
+ : 'TERMINAL_EXTENSION_CHECKOUT unset or not installed (needed to import the wallet into the extension)', { required: extensionRequired }));
257
+
258
+ const state = await readBrowserState(target);
259
+ const runtime = terminalRuntimeDir(target);
260
+ const live = Boolean(state && ownsProcess('browser', state.browserPid, runtime) && ownsProcess('wallet-host', state.hostPid, runtime));
261
+ if (cdpPort) {
262
+ const free = await portFree(cdpPort);
263
+ const owned = live && state.cdpPort === cdpPort;
264
+ checks.push(check('cdp-port', free || owned, free ? `CDP port ${cdpPort} is free` : owned ? `CDP port ${cdpPort} held by this slot's browser` : `CDP port ${cdpPort} is held by another process`));
265
+ } else {
266
+ checks.push(check('cdp-port', false, 'no CDP port; pass --cdp-port <port>'));
267
+ }
268
+ checks.push(check('browser-live', live, live
269
+ ? `browser pid ${state.browserPid} (${state.signer}, ${state.account?.name}) and wallet host pid ${state.hostPid}`
270
+ : 'no slot browser running (metamask.app.launch starts it)', { required: false }));
271
+
272
+ const failed = checks.filter((item) => item.required && item.status === 'fail').map((item) => item.id);
273
+ return { status: failed.length === 0 ? 'pass' : 'fail', checks, failed, browser: live ? state : null };
274
+ }
@@ -0,0 +1,138 @@
1
+ // EIP-1193 test wallet that enforces MetaMask's typed-data rule: reject
2
+ // eth_signTypedData_v4 whose domain chainId differs from the active chain.
3
+ // Signs with a viem account held by the wallet host; never exposes key
4
+ // material. Ported from the TAT-4032 migration e2e strict wallet.
5
+
6
+ export const DEFAULT_KNOWN_CHAINS = Object.freeze([1, 10, 137, 8453, 42161, 421614, 59144, 59141, 11155111]);
7
+
8
+ const READ_RPC = {
9
+ 1: 'https://ethereum-rpc.publicnode.com',
10
+ 42161: 'https://arbitrum-one-rpc.publicnode.com',
11
+ 421614: 'https://arbitrum-sepolia-rpc.publicnode.com',
12
+ 59144: 'https://linea-rpc.publicnode.com',
13
+ };
14
+
15
+ // Only these reads go to the public RPC. Anything else the wallet does not
16
+ // answer itself (eth_sendRawTransaction, account or filter methods, unknown
17
+ // wallet_* calls) is refused before any network request.
18
+ export const READ_ONLY_RPC_METHODS = Object.freeze([
19
+ 'eth_blockNumber',
20
+ 'eth_call',
21
+ 'eth_estimateGas',
22
+ 'eth_feeHistory',
23
+ 'eth_gasPrice',
24
+ 'eth_getBalance',
25
+ 'eth_getBlockByHash',
26
+ 'eth_getBlockByNumber',
27
+ 'eth_getCode',
28
+ 'eth_getLogs',
29
+ 'eth_getStorageAt',
30
+ 'eth_getTransactionByHash',
31
+ 'eth_getTransactionCount',
32
+ 'eth_getTransactionReceipt',
33
+ 'eth_maxPriorityFeePerGas',
34
+ 'web3_clientVersion',
35
+ ]);
36
+ const READ_ONLY = new Set(READ_ONLY_RPC_METHODS);
37
+
38
+ function rpcError(message, code) {
39
+ return Object.assign(new Error(message), { code });
40
+ }
41
+
42
+ function toBigIntDeep(types, primaryType, message) {
43
+ const out = {};
44
+ for (const field of types[primaryType] ?? []) {
45
+ const value = message[field.name];
46
+ if (value === undefined) continue;
47
+ if (/^u?int\d*$/u.test(field.type)) out[field.name] = BigInt(value);
48
+ else if (types[field.type]) out[field.name] = toBigIntDeep(types, field.type, value);
49
+ else out[field.name] = value;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ export function createStrictWallet({
55
+ account,
56
+ chainId = 42161,
57
+ knownChains = DEFAULT_KNOWN_CHAINS,
58
+ refuseSwitch = false,
59
+ fetchImpl = globalThis.fetch,
60
+ }) {
61
+ let activeChain = chainId;
62
+ const known = new Set(knownChains);
63
+ let rpcId = 1;
64
+
65
+ async function request({ method, params = [] }) {
66
+ switch (method) {
67
+ case 'eth_chainId':
68
+ return `0x${activeChain.toString(16)}`;
69
+ case 'net_version':
70
+ return String(activeChain);
71
+ case 'eth_accounts':
72
+ case 'eth_requestAccounts':
73
+ return [account.address];
74
+ case 'wallet_requestPermissions':
75
+ case 'wallet_getPermissions':
76
+ return [{ parentCapability: 'eth_accounts', caveats: [{ type: 'restrictReturnedAccounts', value: [account.address.toLowerCase()] }] }];
77
+ case 'wallet_revokePermissions':
78
+ return null;
79
+ case 'metamask_getProviderState':
80
+ return { accounts: [account.address.toLowerCase()], chainId: `0x${activeChain.toString(16)}`, isUnlocked: true, networkVersion: String(activeChain) };
81
+ case 'wallet_switchEthereumChain': {
82
+ const id = Number.parseInt(params[0].chainId, 16);
83
+ if (refuseSwitch) throw rpcError('User rejected the request.', 4001);
84
+ if (!known.has(id)) throw rpcError(`Unrecognized chain ID ${params[0].chainId}`, 4902);
85
+ activeChain = id;
86
+ return null;
87
+ }
88
+ case 'wallet_addEthereumChain':
89
+ known.add(Number.parseInt(params[0].chainId, 16));
90
+ return null;
91
+ case 'eth_signTypedData_v4': {
92
+ const [from, raw] = params;
93
+ const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
94
+ const domainChain = Number(BigInt(data.domain.chainId));
95
+ if (String(from).toLowerCase() !== account.address.toLowerCase()) throw rpcError('Unknown account', 4100);
96
+ if (domainChain !== activeChain) {
97
+ throw rpcError(`Provided chainId "${domainChain}" must match the active chainId "${activeChain}"`, -32602);
98
+ }
99
+ const types = { ...data.types };
100
+ delete types.EIP712Domain;
101
+ return account.signTypedData({
102
+ domain: { ...data.domain, chainId: domainChain },
103
+ types,
104
+ primaryType: data.primaryType,
105
+ message: toBigIntDeep(types, data.primaryType, data.message),
106
+ });
107
+ }
108
+ case 'personal_sign': {
109
+ const [message, from] = params;
110
+ if (String(from).toLowerCase() !== account.address.toLowerCase()) throw rpcError('Unknown account', 4100);
111
+ return account.signMessage({ message: { raw: message } });
112
+ }
113
+ case 'eth_sendTransaction':
114
+ case 'eth_sign':
115
+ throw rpcError(`${method} is not supported by the strict test wallet`, 4200);
116
+ default: {
117
+ if (!READ_ONLY.has(method)) throw rpcError(`${method} is not supported by the strict test wallet`, 4200);
118
+ const url = READ_RPC[activeChain];
119
+ if (!url) throw rpcError(`No read RPC for chain ${activeChain}`, 4200);
120
+ const response = await fetchImpl(url, {
121
+ method: 'POST',
122
+ headers: { 'content-type': 'application/json' },
123
+ body: JSON.stringify({ jsonrpc: '2.0', id: rpcId++, method, params }),
124
+ });
125
+ const body = await response.json();
126
+ if (body.error) throw rpcError(body.error.message, body.error.code);
127
+ return body.result;
128
+ }
129
+ }
130
+ }
131
+
132
+ return {
133
+ request,
134
+ get chainId() {
135
+ return activeChain;
136
+ },
137
+ };
138
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // stop.mjs — stop the terminal slot browser, wallet host and console collector, verify the CDP port
3
+ // is free. Idempotent: nothing running is success.
4
+ //
5
+ // Inputs: --target <checkout> [--cdp-port <port>] [--json]
6
+ // Outputs: JSON summary on stdout. Exit 0 stopped/nothing to stop; 1 port still owned.
7
+ // Never touches: the Next.js dev server (Farmslot owns it), browser profiles.
8
+
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ import { stopConsoleCollector, stopTerminalBrowser } from './lib/processes.mjs';
13
+
14
+ const usage = 'Usage: stop.mjs --target <checkout> [--cdp-port <port>] [--json]';
15
+
16
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
17
+ const argv = process.argv.slice(2);
18
+ if (argv.includes('--help') || argv.includes('-h')) {
19
+ process.stdout.write(`${usage}\n`);
20
+ process.exit(0);
21
+ }
22
+ const value = (flag) => {
23
+ const index = argv.indexOf(flag);
24
+ return index >= 0 ? argv[index + 1] : undefined;
25
+ };
26
+ const target = value('--target') ?? process.cwd();
27
+ const rawPort = value('--cdp-port') ?? process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
28
+ try {
29
+ const log = (message) => process.stderr.write(`[terminal/stop] ${message}\n`);
30
+ const result = await stopTerminalBrowser(target, { cdpPort: rawPort ? Number(rawPort) : undefined, log });
31
+ const collector = await stopConsoleCollector(target, { log });
32
+ process.stdout.write(`${JSON.stringify({ status: 'pass', ...result, consoleCollector: collector })}\n`);
33
+ } catch (error) {
34
+ process.stdout.write(`${JSON.stringify({ status: 'fail', error: error.message })}\n`);
35
+ process.exit(1);
36
+ }
37
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // verify.mjs — `mm-harness verify --adapter terminal`: read-only readiness of a
3
+ // Web Terminal slot (checkout, deps, dev server, forced testnet, fixture,
4
+ // browser start probe, MetaMask extension copy, CDP port).
5
+ //
6
+ // Inputs: --target <checkout> [--cdp-port <port>] [--watcher-port <app port>]
7
+ // [--signer extension|injected] [--account <name>] [--json]
8
+ // Outputs: readiness JSON (or a human summary) on stdout. Exit 0 ready; 1 not ready.
9
+ // Never launches the slot browser or the dev server.
10
+
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ import { resolveBrowser } from './launch.mjs';
15
+ import { terminalReadiness } from './lib/readiness.mjs';
16
+
17
+ const usage = 'Usage: verify.mjs --target <checkout> [--cdp-port <port>] [--watcher-port <app port>] [--signer extension|injected] [--account <name>] [--json]';
18
+
19
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
20
+ const argv = process.argv.slice(2);
21
+ if (argv.includes('--help') || argv.includes('-h')) {
22
+ process.stdout.write(`${usage}\n`);
23
+ process.exit(0);
24
+ }
25
+ const value = (flag) => {
26
+ const index = argv.indexOf(flag);
27
+ return index >= 0 ? argv[index + 1] : undefined;
28
+ };
29
+ const num = (raw) => (raw && /^\d+$/u.test(raw) ? Number(raw) : undefined);
30
+ const report = await terminalReadiness({
31
+ target: path.resolve(value('--target') ?? process.cwd()),
32
+ appPort: num(value('--watcher-port') ?? process.env.TERMINAL_APP_PORT ?? process.env.WATCHER_PORT),
33
+ cdpPort: num(value('--cdp-port') ?? process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT),
34
+ signer: value('--signer') ?? process.env.TERMINAL_SIGNER ?? 'extension',
35
+ account: value('--account') ?? process.env.TERMINAL_ACCOUNT ?? 'dev1',
36
+ probeBrowser: resolveBrowser,
37
+ });
38
+ if (argv.includes('--json')) {
39
+ process.stdout.write(`${JSON.stringify({ schemaVersion: 1, command: 'verify', adapter: 'terminal', ...report }, null, 2)}\n`);
40
+ } else {
41
+ for (const check of report.checks) {
42
+ const mark = check.status === 'pass' ? '✓' : check.status === 'warn' || !check.required ? '·' : '✗';
43
+ process.stdout.write(`${mark} ${check.id}: ${check.detail}\n`);
44
+ }
45
+ process.stdout.write(report.status === 'pass' ? 'terminal slot ready\n' : `not ready: ${report.failed.join(', ')}\n`);
46
+ }
47
+ process.exit(report.status === 'pass' ? 0 : 1);
48
+ }