@deeeed/metamask-harness 0.82.1 → 0.84.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.
- package/CHANGELOG.md +48 -0
- package/README.md +8 -1
- package/adapters/core/cleanup.sh +5 -29
- package/adapters/core/controller-import-probe.mjs +26 -0
- package/adapters/manifest.json +22 -102
- package/adapters/shared/session-lock.mjs +43 -0
- package/adapters/web-dapp/metamask-signer.mjs +212 -0
- package/dist/adapters/core/actions.js +0 -3
- package/dist/adapters/core/overlay.js +1 -5
- package/dist/adapters/core/readiness.js +133 -2
- package/dist/adapters/core/surface.js +53 -41
- package/dist/adapters/extension/extension-id.js +1 -0
- package/dist/adapters/extension/fixtures.js +60 -1
- package/dist/adapters/extension/network-observer.js +19 -206
- package/dist/adapters/extension/run.js +5 -3
- package/dist/adapters/extension/runtime.js +112 -20
- package/dist/adapters/mobile/android-gfxinfo-performance.js +3 -1
- package/dist/adapters/mobile/devices.js +5 -1
- package/dist/adapters/mobile/performance-observer.js +9 -6
- package/dist/adapters/mobile/run.js +1 -1
- package/dist/adapters/mobile/surface.js +1 -2
- package/dist/adapters/performance/trace-kinds.js +36 -0
- package/dist/adapters/plugin-surface.js +34 -34
- package/dist/adapters/session-lock.js +23 -0
- package/dist/adapters/web-dapp/console-capture.js +2 -20
- package/dist/adapters/web-dapp/run.js +31 -25
- package/dist/adapters/web-dapp/surface.js +32 -108
- package/dist/command-contract.js +1363 -630
- package/dist/commands/core-readiness.js +1 -9
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/launch/device.js +1 -1
- package/dist/doctor.js +5 -2
- package/dist/mm-harness-cli.js +228 -1210
- package/dist/performance-observation.js +28 -5
- package/docs/CONTRIBUTING.md +6 -4
- package/docs/cli-contract.md +1 -1
- package/docs/feature-guard.md +1 -1
- package/library/actions/core/perps/_controller.mjs +10 -0
- package/library/actions/extension/wallet/import.mjs +21 -4
- package/library/actions/extension/wallet/visible-session.mjs +32 -12
- package/library/actions/shared/console-collector.mjs +1 -1
- package/library/actions/web-dapp/app/launch.mjs +1 -1
- package/library/actions/web-dapp/platform/adapter-web-lib.mjs +18 -0
- package/library/actions/web-dapp/platform/hud.mjs +1 -1
- package/library/actions/web-dapp/platform/page.mjs +2 -3
- package/library/actions/web-dapp/platform/runtime.mjs +29 -178
- package/package.json +7 -6
- package/scripts/completions.sh +1 -1
- package/adapters/web-dapp/cleanup.mjs +0 -69
- package/adapters/web-dapp/inject.mjs +0 -75
- package/adapters/web-dapp/launch.mjs +0 -631
- package/adapters/web-dapp/lib/blocked-hosts.mjs +0 -24
- package/adapters/web-dapp/lib/cdp-client.mjs +0 -84
- package/adapters/web-dapp/lib/display.mjs +0 -115
- package/adapters/web-dapp/lib/hud.mjs +0 -148
- package/adapters/web-dapp/lib/policy.mjs +0 -79
- package/adapters/web-dapp/lib/processes.mjs +0 -187
- package/adapters/web-dapp/lib/readiness.mjs +0 -272
- package/adapters/web-dapp/stop.mjs +0 -38
- package/adapters/web-dapp/verify.mjs +0 -51
- package/adapters/web-dapp/wallet-host.mjs +0 -520
- package/dist/adapters/core/live-script-env.js +0 -36
- package/dist/adapters/extension/browser-cdp.js +0 -174
- package/dist/adapters/extension/performance-observer.js +0 -75
- package/dist/adapters/performance/cdp-trace.js +0 -406
- package/dist/adapters/performance/js-task-metrics.js +0 -35
- package/dist/adapters/web-dapp/doctor-checks.js +0 -33
- package/dist/adapters/web-dapp/launch.js +0 -87
- package/dist/adapters/web-dapp/overlay.js +0 -21
- package/dist/adapters/web-dapp/readiness.js +0 -10
- package/dist/adapters/web-dapp/stop.js +0 -20
- package/dist/async.js +0 -19
- package/dist/cli.js +0 -202
|
@@ -1,520 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// wallet-host.mjs — long-running observer (and, in injected mode, signer) for the
|
|
3
|
-
// web-dapp slot browser.
|
|
4
|
-
//
|
|
5
|
-
// Purpose:
|
|
6
|
-
// Attaches to the slot browser over CDP and, for every app document, installs
|
|
7
|
-
// the request-log page script before page scripts run. The app tab is created
|
|
8
|
-
// blank, hooked, and only then navigated to the app, so the first app
|
|
9
|
-
// document already has the log binding; ready is written only after that is
|
|
10
|
-
// verified on the committed app document. Appends one JSON line
|
|
11
|
-
// per wallet request to <runtime>/wallet-requests.jsonl. In extension mode it
|
|
12
|
-
// also logs every MetaMask confirmation window it sees open. In injected mode
|
|
13
|
-
// it answers the page's EIP-1193 requests with the strict test wallet signing
|
|
14
|
-
// as the fixture account. It also redraws the recipe HUD (<runtime>/hud.json)
|
|
15
|
-
// on app pages after navigations and whenever the HUD state changes.
|
|
16
|
-
//
|
|
17
|
-
// Inputs (flags): --cdp-port <port> --runtime-dir <dir> --signer <extension|injected>
|
|
18
|
-
// --app-origin <url> [--extension-id <id>] [--target <checkout> --account <name>
|
|
19
|
-
// --start-chain <id>] (injected mode only) [--observe-focus 1]
|
|
20
|
-
// (a visible browser launched in the background on macOS: the host records
|
|
21
|
-
// in focus.log if a MetaMask notification window brought it to front; it
|
|
22
|
-
// never activates an app)
|
|
23
|
-
// Outputs: <runtime>/wallet-requests.jsonl, <runtime>/wallet-host.ready, stderr log.
|
|
24
|
-
// Exits when the browser connection closes.
|
|
25
|
-
// Never logs: key material, request params, messages, signatures.
|
|
26
|
-
|
|
27
|
-
import { appendFileSync, chmodSync, existsSync, unwatchFile, watchFile, writeFileSync } from 'node:fs';
|
|
28
|
-
import { createRequire } from 'node:module';
|
|
29
|
-
import path from 'node:path';
|
|
30
|
-
|
|
31
|
-
import { createStrictWallet, createWalletRequestBinding, pageReadyExpression, pageScriptSource } from '@farmslot/adapter-web/dapp';
|
|
32
|
-
import { isAppUrl as isExactAppUrl, shortUrl } from '@farmslot/adapter-web/origin';
|
|
33
|
-
|
|
34
|
-
import { CdpClient, browserWebSocketUrl } from './lib/cdp-client.mjs';
|
|
35
|
-
import { hudFile, hudRenderExpression, readHudState } from './lib/hud.mjs';
|
|
36
|
-
import { fixtureAccount, webDappPolicy } from '../../library/actions/web-dapp/platform/runtime.mjs';
|
|
37
|
-
import { hostOf, isBlockedUrl } from './lib/blocked-hosts.mjs';
|
|
38
|
-
import { ownedBrowserPids } from './lib/processes.mjs';
|
|
39
|
-
|
|
40
|
-
const { captureMacFrontmost, macFocusDisabled } = createRequire(import.meta.url)('@farmslot/adapter-web/macos-focus');
|
|
41
|
-
|
|
42
|
-
function usage() {
|
|
43
|
-
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>]';
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function parseArgs(argv) {
|
|
47
|
-
const args = {};
|
|
48
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
49
|
-
const flag = argv[index];
|
|
50
|
-
if (flag === '--help' || flag === '-h') {
|
|
51
|
-
process.stdout.write(`${usage()}\n`);
|
|
52
|
-
process.exit(0);
|
|
53
|
-
}
|
|
54
|
-
if (flag.startsWith('--') && flag.includes('=')) {
|
|
55
|
-
args[flag.slice(2, flag.indexOf('='))] = flag.slice(flag.indexOf('=') + 1);
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
if (!flag.startsWith('--') || index + 1 >= argv.length) throw new Error(usage());
|
|
59
|
-
args[flag.slice(2)] = argv[index + 1];
|
|
60
|
-
index += 1;
|
|
61
|
-
}
|
|
62
|
-
for (const key of ['cdp-port', 'runtime-dir', 'signer', 'app-origin']) {
|
|
63
|
-
if (!args[key]) throw new Error(`Missing --${key}\n${usage()}`);
|
|
64
|
-
}
|
|
65
|
-
return args;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const args = parseArgs(process.argv.slice(2));
|
|
69
|
-
// The venue policy (e.g. the Terminal plugin's policy.mjs), from RECIPE_WEB_DAPP_POLICY:
|
|
70
|
-
// link hosts, the probe, the typed-data refusal, the start page and chain.
|
|
71
|
-
const policy = webDappPolicy();
|
|
72
|
-
const LINK_HOSTS = new Set(policy.linkHosts);
|
|
73
|
-
const runtimeDir = path.resolve(args['runtime-dir']);
|
|
74
|
-
const logFile = path.join(runtimeDir, 'wallet-requests.jsonl');
|
|
75
|
-
const signer = args.signer;
|
|
76
|
-
const appOrigin = args['app-origin'].replace(/\/$/u, '');
|
|
77
|
-
const extensionOrigin = args['extension-id'] ? `chrome-extension://${args['extension-id']}` : null;
|
|
78
|
-
// Testnet runs are held to testnet here too: requests to the mainnet venue
|
|
79
|
-
// hosts are failed and logged, and a mainnet typed-data action is refused.
|
|
80
|
-
const network = args.network === 'mainnet' ? 'mainnet' : 'testnet';
|
|
81
|
-
const mainnetHosts = network === 'testnet' ? String(args['mainnet-hosts'] ?? '').split(',').filter(Boolean) : [];
|
|
82
|
-
if (network === 'testnet' && mainnetHosts.length === 0) throw new Error('testnet mode needs --mainnet-hosts (the venue hosts to block).');
|
|
83
|
-
// The testnet venue endpoints the served-network check looks for: API hosts
|
|
84
|
-
// only, never a link host (app.hyperliquid-testnet.xyz serves pages and icons).
|
|
85
|
-
const testnetHosts = String(args['testnet-hosts'] ?? '').split(',').filter((host) => host && !LINK_HOSTS.has(host));
|
|
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
|
-
};
|
|
100
|
-
|
|
101
|
-
// The request log is evidence; keep it owner-only even when it already exists.
|
|
102
|
-
if (existsSync(logFile)) chmodSync(logFile, 0o600);
|
|
103
|
-
|
|
104
|
-
function appendEntry(entry) {
|
|
105
|
-
appendFileSync(logFile, `${JSON.stringify({ signer, ...entry })}\n`, { mode: 0o600 });
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function say(message) {
|
|
109
|
-
process.stderr.write(`${new Date().toISOString()} [wallet-host] ${message}\n`);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
let wallet = null;
|
|
113
|
-
if (signer === 'injected') {
|
|
114
|
-
if (!args.target || !args.account) throw new Error('injected mode requires --target and --account');
|
|
115
|
-
const account = await fixtureAccount(path.resolve(args.target), args.account);
|
|
116
|
-
wallet = createStrictWallet({ account, chainId: Number(args['start-chain'] ?? policy.startChain) });
|
|
117
|
-
say(`injected strict wallet ready for ${args.account} (${account.address}) on chain ${wallet.chainId}`);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const source = pageScriptSource({ signer, appOrigin, refuseTypedData, injectedWallet: INJECTED_WALLET });
|
|
121
|
-
const client = await CdpClient.connect(await browserWebSocketUrl(Number(args['cdp-port'])));
|
|
122
|
-
// Judges each binding call against the frame and document that made it, logs
|
|
123
|
-
// it and, in injected mode, answers it with the strict wallet.
|
|
124
|
-
const binding = createWalletRequestBinding({ client, appOrigin, signer, wallet, refuseTypedData, record: appendEntry, say });
|
|
125
|
-
// Hooks are installed once per target: a second session for the same tab
|
|
126
|
-
// (auto-attach plus a restored tab) would log every request twice.
|
|
127
|
-
const hookedTargets = new Map();
|
|
128
|
-
const appSessions = new Set();
|
|
129
|
-
// Per app session: its target id (= main frame id) and the URL its top frame
|
|
130
|
-
// has committed.
|
|
131
|
-
const sessionTargets = new Map();
|
|
132
|
-
const sessionCommittedUrls = new Map();
|
|
133
|
-
// App sessions whose target runs with domains enabled; the HUD is drawn only
|
|
134
|
-
// there so no evaluate ever waits on a tab paused for setup.
|
|
135
|
-
const hudSessions = new Set();
|
|
136
|
-
const seenConfirmationUrls = new Map();
|
|
137
|
-
|
|
138
|
-
// New tabs start blank before they navigate to the app.
|
|
139
|
-
function isAppUrl(url) {
|
|
140
|
-
return url === 'about:blank' || url === '' || isExactAppUrl(url, appOrigin);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// MetaMask shows dapp requests in its side panel (the fixture default) or a
|
|
144
|
-
// notification/popup window; only request routes count as confirmations.
|
|
145
|
-
const WALLET_SURFACES = ['sidepanel.html', 'notification.html', 'popup.html'];
|
|
146
|
-
const CONFIRMATION_ROUTE = /^\/(connect|confirm-transaction|confirmation|confirm|signature-request)(\/|$)/u;
|
|
147
|
-
|
|
148
|
-
function isWalletSurfaceUrl(url) {
|
|
149
|
-
return Boolean(extensionOrigin) && WALLET_SURFACES.some((surface) => url.startsWith(`${extensionOrigin}/${surface}`));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function confirmationRoute(url) {
|
|
153
|
-
const route = (url.split('#')[1] ?? '').split('?')[0];
|
|
154
|
-
return CONFIRMATION_ROUTE.test(route) ? route.replace(/0x[0-9a-fA-F]{40}/gu, '<address>') : null;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// A MetaMask notification or popup window opens as a new window and can
|
|
158
|
-
// activate the browser. The host only observes this: it records in
|
|
159
|
-
// <runtime>/focus.log when one of this slot's browser processes (by pid, never
|
|
160
|
-
// by app bundle) is the frontmost app after such a window opens, and never
|
|
161
|
-
// activates any app. Only for a visible background launch (--observe-focus).
|
|
162
|
-
const focus = args['observe-focus'] === '1' && !macFocusDisabled() ? {
|
|
163
|
-
observe(surface) {
|
|
164
|
-
const front = captureMacFrontmost();
|
|
165
|
-
if (front && ownedBrowserPids(runtimeDir).includes(front.pid)) {
|
|
166
|
-
appendFileSync(path.join(runtimeDir, 'focus.log'), `${new Date().toISOString()} slot browser (pid ${front.pid}) is frontmost after a MetaMask ${surface} window opened (observed only)\n`, { mode: 0o600 });
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
} : null;
|
|
170
|
-
|
|
171
|
-
function observeWalletSurface(targetId, url) {
|
|
172
|
-
if (!isWalletSurfaceUrl(url)) return;
|
|
173
|
-
if (focus && !url.includes('/sidepanel.html') && !seenConfirmationUrls.has(targetId)) {
|
|
174
|
-
setTimeout(() => focus.observe(url.includes('/popup.html') ? 'popup' : 'notification'), 1000);
|
|
175
|
-
}
|
|
176
|
-
const route = confirmationRoute(url);
|
|
177
|
-
if (!route || seenConfirmationUrls.get(targetId) === route) return;
|
|
178
|
-
seenConfirmationUrls.set(targetId, route);
|
|
179
|
-
const surface = WALLET_SURFACES.find((name) => url.includes(name))?.replace('.html', '');
|
|
180
|
-
appendEntry({ kind: 'confirmation-shown', t: Date.now(), surface, route });
|
|
181
|
-
say(`confirmation shown in ${surface}: ${route}`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function deferred() {
|
|
185
|
-
let resolve;
|
|
186
|
-
let reject;
|
|
187
|
-
const promise = new Promise((ok, fail) => {
|
|
188
|
-
resolve = ok;
|
|
189
|
-
reject = fail;
|
|
190
|
-
});
|
|
191
|
-
promise.catch(() => {});
|
|
192
|
-
return { promise, resolve, reject };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Each CDP call is bounded so a slow browser can never leave a new tab paused.
|
|
196
|
-
function bounded(promise, label, ms = 10000) {
|
|
197
|
-
let timer;
|
|
198
|
-
return Promise.race([
|
|
199
|
-
promise,
|
|
200
|
-
new Promise((_, reject) => {
|
|
201
|
-
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
202
|
-
}),
|
|
203
|
-
]).finally(() => clearTimeout(timer));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Register the binding and the document script first: neither needs the
|
|
207
|
-
// target running, while Runtime.enable/Page.enable can wait on a target that is
|
|
208
|
-
// paused for the debugger. Enable the domains only after the caller resumed it.
|
|
209
|
-
async function installAppHooks(sessionId, targetId) {
|
|
210
|
-
appSessions.add(sessionId);
|
|
211
|
-
sessionTargets.set(sessionId, targetId);
|
|
212
|
-
await bounded(binding.install(sessionId, targetId), 'Runtime.addBinding');
|
|
213
|
-
await bounded(client.send('Page.addScriptToEvaluateOnNewDocument', { source, runImmediately: true }, sessionId), 'Page.addScriptToEvaluateOnNewDocument');
|
|
214
|
-
if (network === 'testnet') {
|
|
215
|
-
await bounded(client.send('Fetch.enable', {
|
|
216
|
-
patterns: mainnetHosts.map((host) => ({ urlPattern: `*://${host}/*` })),
|
|
217
|
-
}, sessionId), 'Fetch.enable');
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Venue traffic per app session: the served-network check. Only venue
|
|
222
|
-
// endpoints count (the block list and the testnet endpoints); other hosts on a
|
|
223
|
-
// venue domain, such as the coin icons on app.hyperliquid.xyz, are ignored.
|
|
224
|
-
const venueTraffic = new Map();
|
|
225
|
-
function recordVenueTraffic(sessionId, url) {
|
|
226
|
-
const host = hostOf(url);
|
|
227
|
-
if (!host || String(url).includes(PROBE_MARK)) return;
|
|
228
|
-
const side = mainnetHosts.includes(host) ? 'mainnet' : testnetHosts.includes(host) ? 'testnet' : null;
|
|
229
|
-
if (!side) return;
|
|
230
|
-
const traffic = venueTraffic.get(sessionId) ?? { testnet: new Set(), mainnet: new Set() };
|
|
231
|
-
traffic[side].add(host);
|
|
232
|
-
venueTraffic.set(sessionId, traffic);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// How the launch probes failed. The HTTP probe must have been failed by this
|
|
236
|
-
// host (Fetch.failRequest, which Chrome reports as net::ERR_BLOCKED_BY_CLIENT
|
|
237
|
-
// with the blocked reason inspector or other). A probe to the same host on a
|
|
238
|
-
// port the request block does not match must fail with the resolver rule's
|
|
239
|
-
// net::ERR_NAME_NOT_RESOLVED: the rule maps the host for every port and
|
|
240
|
-
// transport, so the WebSocket probe failing too is the resolver's doing. Any
|
|
241
|
-
// other failure (CORS, another blocker) does not prove the block. WebSocket
|
|
242
|
-
// error texts are kept as evidence; Chrome sometimes reports them empty.
|
|
243
|
-
const RESOLVER_PROBE_PORT = 444;
|
|
244
|
-
const probeRequests = new Map();
|
|
245
|
-
const probeFailures = { http: [], resolver: [], websocket: [] };
|
|
246
|
-
let probeFailedHere = false;
|
|
247
|
-
|
|
248
|
-
async function enableAppDomains(sessionId) {
|
|
249
|
-
await bounded(client.send('Runtime.enable', {}, sessionId), 'Runtime.enable');
|
|
250
|
-
await bounded(client.send('Page.enable', {}, sessionId), 'Page.enable');
|
|
251
|
-
if (network === 'testnet') await bounded(client.send('Network.enable', {}, sessionId), 'Network.enable');
|
|
252
|
-
const { frameTree } = await bounded(client.send('Page.getFrameTree', {}, sessionId), 'Page.getFrameTree');
|
|
253
|
-
if (frameTree?.frame?.url !== undefined) {
|
|
254
|
-
sessionCommittedUrls.set(sessionId, frameTree.frame.url);
|
|
255
|
-
binding.commit(sessionId, frameTree.frame.url, frameTree.frame.loaderId);
|
|
256
|
-
}
|
|
257
|
-
if (appSessions.has(sessionId)) hudSessions.add(sessionId);
|
|
258
|
-
binding.drain(sessionId);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Redraw the HUD on app pages. The expression is a no-op when the same state
|
|
262
|
-
// is already drawn or the page is not on the app origin.
|
|
263
|
-
async function drawHud(sessions = hudSessions) {
|
|
264
|
-
const expression = hudRenderExpression(readHudState(runtimeDir), appOrigin);
|
|
265
|
-
await Promise.all([...sessions].map((sessionId) =>
|
|
266
|
-
bounded(client.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId), 'HUD draw', 3000).catch(() => {})));
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
client.on('Target.attachedToTarget', async ({ sessionId, targetInfo, waitingForDebugger }) => {
|
|
270
|
-
const { targetId } = targetInfo;
|
|
271
|
-
const isApp = targetInfo.type === 'page' && isAppUrl(targetInfo.url) && !hookedTargets.has(targetId);
|
|
272
|
-
let hooked;
|
|
273
|
-
if (isApp) {
|
|
274
|
-
hooked = deferred();
|
|
275
|
-
hookedTargets.set(targetId, { sessionId, ready: hooked.promise });
|
|
276
|
-
}
|
|
277
|
-
try {
|
|
278
|
-
if (isApp) await installAppHooks(sessionId, targetId);
|
|
279
|
-
else if (targetInfo.type === 'page') observeWalletSurface(targetId, targetInfo.url);
|
|
280
|
-
} catch (error) {
|
|
281
|
-
// A tab whose hooks did not all install is not an app tab: its requests
|
|
282
|
-
// would go unrecorded (or unblocked). The app tab's own failure fails ready.
|
|
283
|
-
say(`setup failed for ${targetInfo.url}: ${error.message}`);
|
|
284
|
-
appSessions.delete(sessionId);
|
|
285
|
-
hooked?.reject(error);
|
|
286
|
-
} finally {
|
|
287
|
-
if (waitingForDebugger) await bounded(client.send('Runtime.runIfWaitingForDebugger', {}, sessionId), 'runIfWaitingForDebugger').catch((error) => say(error.message));
|
|
288
|
-
if (!appSessions.has(sessionId)) await client.send('Target.detachFromTarget', { sessionId }).catch(() => {});
|
|
289
|
-
}
|
|
290
|
-
if (isApp && appSessions.has(sessionId)) {
|
|
291
|
-
try {
|
|
292
|
-
await enableAppDomains(sessionId);
|
|
293
|
-
say(`attached app page ${targetInfo.url || '(blank)'}`);
|
|
294
|
-
hooked.resolve(sessionId);
|
|
295
|
-
} catch (error) {
|
|
296
|
-
say(`enable failed for ${targetInfo.url}: ${error.message}`);
|
|
297
|
-
hooked.reject(error);
|
|
298
|
-
}
|
|
299
|
-
} else if (hooked) {
|
|
300
|
-
hooked.reject(new Error(`app hooks were not installed on ${targetInfo.url || 'a blank tab'}`));
|
|
301
|
-
}
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
client.on('Target.targetInfoChanged', ({ targetInfo }) => {
|
|
305
|
-
if (targetInfo.type === 'page') observeWalletSurface(targetInfo.targetId, targetInfo.url);
|
|
306
|
-
});
|
|
307
|
-
client.on('Target.targetCreated', ({ targetInfo }) => {
|
|
308
|
-
if (targetInfo.type === 'page') observeWalletSurface(targetInfo.targetId, targetInfo.url);
|
|
309
|
-
});
|
|
310
|
-
client.on('Target.detachedFromTarget', ({ sessionId }) => {
|
|
311
|
-
binding.detach(sessionId);
|
|
312
|
-
const targetId = sessionTargets.get(sessionId);
|
|
313
|
-
if (targetId && hookedTargets.get(targetId)?.sessionId === sessionId) hookedTargets.delete(targetId);
|
|
314
|
-
appSessions.delete(sessionId);
|
|
315
|
-
hudSessions.delete(sessionId);
|
|
316
|
-
sessionTargets.delete(sessionId);
|
|
317
|
-
sessionCommittedUrls.delete(sessionId);
|
|
318
|
-
});
|
|
319
|
-
// What the app tab's first navigation did, for the diagnostic when it never
|
|
320
|
-
// commits: main-frame commits, and the document's response or failure.
|
|
321
|
-
const appNavigation = { commits: [], document: null };
|
|
322
|
-
client.on('Network.responseReceived', ({ type, response }, sessionId) => {
|
|
323
|
-
if (sessionId === appNavigation.session && type === 'Document') appNavigation.document = `HTTP ${response.status} for ${pathOf(response.url)}`;
|
|
324
|
-
});
|
|
325
|
-
client.on('Network.loadingFailed', ({ type, errorText, canceled }, sessionId) => {
|
|
326
|
-
if (sessionId === appNavigation.session && type === 'Document') appNavigation.document = `load failed: ${errorText}${canceled ? ' (canceled)' : ''}`;
|
|
327
|
-
});
|
|
328
|
-
client.on('Page.frameNavigated', ({ frame }, sessionId) => {
|
|
329
|
-
if (sessionId === appNavigation.session && !frame.parentId) appNavigation.commits.push(pathOf(frame.url));
|
|
330
|
-
if (appSessions.has(sessionId) && !frame.parentId) {
|
|
331
|
-
sessionCommittedUrls.set(sessionId, frame.url);
|
|
332
|
-
binding.commit(sessionId, frame.url, frame.loaderId);
|
|
333
|
-
}
|
|
334
|
-
});
|
|
335
|
-
client.on('Fetch.requestPaused', ({ requestId, request, resourceType }, sessionId) => {
|
|
336
|
-
const mainnet = isBlockedUrl(request.url, mainnetHosts);
|
|
337
|
-
if (mainnet) {
|
|
338
|
-
appendEntry({ kind: 'blocked-mainnet', t: Date.now(), transport: 'http', url: shortUrl(request.url), resourceType, probe: request.url.includes(PROBE_MARK) });
|
|
339
|
-
say(`blocked a mainnet request to ${shortUrl(request.url)}`);
|
|
340
|
-
}
|
|
341
|
-
const command = mainnet
|
|
342
|
-
? client.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, sessionId)
|
|
343
|
-
: client.send('Fetch.continueRequest', { requestId }, sessionId);
|
|
344
|
-
command.then(() => {
|
|
345
|
-
if (mainnet && request.url.includes(PROBE_MARK)) probeFailedHere = true;
|
|
346
|
-
}, () => {});
|
|
347
|
-
});
|
|
348
|
-
client.on('Network.requestWillBeSent', ({ requestId, request }, sessionId) => {
|
|
349
|
-
if (request.url.includes(PROBE_MARK)) probeRequests.set(requestId, new URL(request.url).port === String(RESOLVER_PROBE_PORT) ? 'resolver' : 'http');
|
|
350
|
-
recordVenueTraffic(sessionId, request.url);
|
|
351
|
-
});
|
|
352
|
-
client.on('Network.loadingFailed', ({ requestId, errorText, blockedReason }) => {
|
|
353
|
-
const kind = probeRequests.get(requestId);
|
|
354
|
-
if (kind === 'http' || kind === 'resolver') probeFailures[kind].push({ errorText, blockedReason: blockedReason ?? null });
|
|
355
|
-
});
|
|
356
|
-
client.on('Network.webSocketFrameError', ({ requestId, errorMessage }) => {
|
|
357
|
-
if (probeRequests.get(requestId) === 'websocket') probeFailures.websocket.push({ errorText: errorMessage });
|
|
358
|
-
});
|
|
359
|
-
client.on('Network.webSocketCreated', ({ requestId, url }, sessionId) => {
|
|
360
|
-
if (url.includes(PROBE_MARK)) probeRequests.set(requestId, 'websocket');
|
|
361
|
-
recordVenueTraffic(sessionId, url);
|
|
362
|
-
// The host resolver rule fails the connection; the attempt is still evidence.
|
|
363
|
-
if (isBlockedUrl(url, mainnetHosts)) {
|
|
364
|
-
appendEntry({ kind: 'blocked-mainnet', t: Date.now(), transport: 'websocket', url: shortUrl(url), probe: url.includes(PROBE_MARK) });
|
|
365
|
-
say(`blocked a mainnet WebSocket to ${shortUrl(url)}`);
|
|
366
|
-
}
|
|
367
|
-
});
|
|
368
|
-
for (const event of ['Page.domContentEventFired', 'Page.loadEventFired', 'Page.navigatedWithinDocument']) {
|
|
369
|
-
client.on(event, (_params, sessionId) => {
|
|
370
|
-
if (hudSessions.has(sessionId)) drawHud([sessionId]);
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
// The file watch catches state changes; the slow repeat redraws a HUD that a
|
|
374
|
-
// client-side render removed.
|
|
375
|
-
watchFile(hudFile(runtimeDir), { interval: 500 }, () => drawHud());
|
|
376
|
-
const hudRepeat = setInterval(() => drawHud(), 2000);
|
|
377
|
-
|
|
378
|
-
client.on('close', () => {
|
|
379
|
-
clearInterval(hudRepeat);
|
|
380
|
-
unwatchFile(hudFile(runtimeDir));
|
|
381
|
-
say('browser connection closed; exiting');
|
|
382
|
-
process.exit(0);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
function pathOf(url) {
|
|
386
|
-
try {
|
|
387
|
-
const parsed = new URL(url);
|
|
388
|
-
return parsed.protocol === 'about:' ? url : `${parsed.origin}${parsed.pathname}`;
|
|
389
|
-
} catch {
|
|
390
|
-
return '?';
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// Why the app tab never committed its first page (Codex #298 round 7: a
|
|
395
|
-
// 120 s stall seen once on mini): what Page.navigate answered, the tab's
|
|
396
|
-
// commits and document response, where the tab is now, and whether the dev
|
|
397
|
-
// server answers the same URL outside the browser.
|
|
398
|
-
async function commitStallDetail(navigation) {
|
|
399
|
-
const info = await bounded(client.send('Target.getTargetInfo', { targetId: appTargetId }), 'Target.getTargetInfo', 5000).catch(() => null);
|
|
400
|
-
const server = await fetch(startUrl, { signal: AbortSignal.timeout(5000), redirect: 'manual' })
|
|
401
|
-
.then((response) => `HTTP ${response.status}`)
|
|
402
|
-
.catch((error) => (error?.name === 'TimeoutError' ? 'no answer in 5 s' : 'unreachable'));
|
|
403
|
-
return [
|
|
404
|
-
`navigate answered ${navigation?.errorText ? `error ${navigation.errorText}` : `loader ${navigation?.loaderId ? 'set' : 'unset'}`}`,
|
|
405
|
-
`commits [${appNavigation.commits.join(', ') || 'none'}]`,
|
|
406
|
-
`document ${appNavigation.document ?? 'no response seen'}`,
|
|
407
|
-
`tab now at ${pathOf(info?.targetInfo?.url ?? '')}`,
|
|
408
|
-
`dev server ${server}`,
|
|
409
|
-
].join('; ');
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function fail(message) {
|
|
413
|
-
say(`not ready: ${message}`);
|
|
414
|
-
process.exit(1);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
await client.send('Target.setDiscoverTargets', { discover: true });
|
|
418
|
-
await client.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
|
|
419
|
-
const { targetInfos } = await client.send('Target.getTargets');
|
|
420
|
-
for (const info of targetInfos) {
|
|
421
|
-
if (info.type === 'page') observeWalletSurface(info.targetId, info.url);
|
|
422
|
-
}
|
|
423
|
-
// The app gets a tab of its own, created blank: hooks and domains go in while
|
|
424
|
-
// nothing has loaded, then the tab navigates to the app.
|
|
425
|
-
// Created in the background so the browser is never activated (it would take
|
|
426
|
-
// the operator's keyboard focus on macOS). A visible browser started with no
|
|
427
|
-
// window gets the app in a new background window at the slot's bounds.
|
|
428
|
-
const windowBounds = String(args.window ?? '').split(',').map(Number);
|
|
429
|
-
const createParams = windowBounds.length === 4 && windowBounds.every(Number.isFinite)
|
|
430
|
-
? { url: 'about:blank', newWindow: true, background: true, left: windowBounds[0], top: windowBounds[1], width: windowBounds[2], height: windowBounds[3] }
|
|
431
|
-
: { url: 'about:blank', newWindow: true, background: true };
|
|
432
|
-
const { targetId: appTargetId } = await bounded(
|
|
433
|
-
// No foreground fallback: a tab that is not created in the background
|
|
434
|
-
// activates the browser.
|
|
435
|
-
client.send('Target.createTarget', createParams)
|
|
436
|
-
.catch(() => client.send('Target.createTarget', { url: 'about:blank', background: true })),
|
|
437
|
-
'Target.createTarget',
|
|
438
|
-
60000,
|
|
439
|
-
);
|
|
440
|
-
const appStart = Date.now();
|
|
441
|
-
while (!hookedTargets.has(appTargetId) && Date.now() - appStart < 60000) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
442
|
-
if (!hookedTargets.has(appTargetId)) fail(`app tab ${appTargetId} was never attached`);
|
|
443
|
-
const appSession = await bounded(hookedTargets.get(appTargetId).ready, 'app tab hooks', 60000).catch((error) => fail(error.message));
|
|
444
|
-
// Every other tab (the initial blank tab, app tabs restored with a reused
|
|
445
|
-
// profile, MetaMask's home tab) is closed, as the Extension launcher closes its
|
|
446
|
-
// startup tabs: the hooked tab is then the only app tab and the active tab of
|
|
447
|
-
// its window, so it paints without ever being brought to front.
|
|
448
|
-
const { targetInfos: startupTabs } = await client.send('Target.getTargets').catch(() => ({ targetInfos }));
|
|
449
|
-
for (const info of startupTabs) {
|
|
450
|
-
if (info.type === 'page' && info.targetId !== appTargetId && !isWalletSurfaceUrl(info.url)) {
|
|
451
|
-
await client.send('Target.closeTarget', { targetId: info.targetId }).catch(() => {});
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
// The first app document is a market page: it streams from the venue in the
|
|
455
|
-
// browser, which the served-network check below observes (the home page's
|
|
456
|
-
// data comes from the server render).
|
|
457
|
-
const startUrl = new URL(args['start-path'] || policy.startPath, `${appOrigin}/`).href;
|
|
458
|
-
appNavigation.session = appSession;
|
|
459
|
-
const navigation = await bounded(client.send('Page.navigate', { url: startUrl }, appSession), 'Page.navigate', 60000).catch((error) => fail(error.message));
|
|
460
|
-
const navigateStart = Date.now();
|
|
461
|
-
// A cold dev server compiles the first page before it answers.
|
|
462
|
-
const commitTimeoutMs = Number(process.env.MM_HARNESS_APP_COMMIT_TIMEOUT_MS) || 120000;
|
|
463
|
-
while (!isExactAppUrl(sessionCommittedUrls.get(appSession), appOrigin) && Date.now() - navigateStart < commitTimeoutMs) {
|
|
464
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
465
|
-
}
|
|
466
|
-
if (!isExactAppUrl(sessionCommittedUrls.get(appSession), appOrigin)) fail(`the app tab did not commit ${startUrl}: ${await commitStallDetail(navigation)}`);
|
|
467
|
-
|
|
468
|
-
const evaluate = (expression, timeoutMs = 5000) => bounded(
|
|
469
|
-
client.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, appSession),
|
|
470
|
-
'app evaluation',
|
|
471
|
-
timeoutMs,
|
|
472
|
-
).then((response) => response?.result?.value).catch(() => undefined);
|
|
473
|
-
|
|
474
|
-
// The first app document must carry the log binding and the installed
|
|
475
|
-
// preload (its marker), with the page's provider wrapped, or requests on it
|
|
476
|
-
// would go unrecorded.
|
|
477
|
-
const readyExpression = pageReadyExpression({ signer, refusesTypedData: refuseTypedData !== null });
|
|
478
|
-
let hooksReady = false;
|
|
479
|
-
for (let attempt = 0; attempt < 50 && !hooksReady; attempt += 1) {
|
|
480
|
-
hooksReady = (await evaluate(readyExpression)) === true;
|
|
481
|
-
if (!hooksReady) await new Promise((resolve) => setTimeout(resolve, 200));
|
|
482
|
-
}
|
|
483
|
-
if (!hooksReady) fail('the app document has no wallet log binding, no harness preload, or an unwrapped provider');
|
|
484
|
-
|
|
485
|
-
if (network === 'testnet') {
|
|
486
|
-
// The block must hold before anything runs: a probe to the mainnet venue
|
|
487
|
-
// from the app page has to fail (HTTP through the request block and the
|
|
488
|
-
// resolver rule, WebSocket through the resolver rule).
|
|
489
|
-
const { probe } = policy;
|
|
490
|
-
const host = mainnetHosts.includes(probe.host) ? probe.host : mainnetHosts[0];
|
|
491
|
-
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);
|
|
492
|
-
const resolverProbe = await evaluate(`fetch('https://${host}:${RESOLVER_PROBE_PORT}${probe.httpPath}?${PROBE_MARK}', { method: 'POST' }).then(() => 'reached', () => 'blocked')`, 20000);
|
|
493
|
-
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);
|
|
494
|
-
if (httpProbe !== 'blocked' || resolverProbe !== 'blocked' || wsProbe !== 'blocked') {
|
|
495
|
-
fail(`the mainnet venue is reachable from the app (http ${httpProbe}, resolver ${resolverProbe}, websocket ${wsProbe})`);
|
|
496
|
-
}
|
|
497
|
-
const blockedHere = () => probeFailedHere && probeFailures.http.some((failure) => /^net::ERR_BLOCKED_BY_CLIENT\b/u.test(failure.errorText ?? ''));
|
|
498
|
-
const unresolved = () => probeFailures.resolver.some((failure) => /^net::ERR_NAME_NOT_RESOLVED\b/u.test(failure.errorText ?? ''));
|
|
499
|
-
const probeStart = Date.now();
|
|
500
|
-
while (!(blockedHere() && unresolved()) && Date.now() - probeStart < 5000) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
501
|
-
const seen = (failures) => failures.map((failure) => [failure.errorText, failure.blockedReason].filter(Boolean).join(' ')).join('; ') || 'no failure reported';
|
|
502
|
-
if (!blockedHere()) fail(`the HTTP probe failed, but not by the request block (${seen(probeFailures.http)}; failed here: ${probeFailedHere})`);
|
|
503
|
-
if (!unresolved()) fail(`the resolver probe failed, but not by the host resolver rule (${seen(probeFailures.resolver)})`);
|
|
504
|
-
// The app must actually be talking to the testnet venue.
|
|
505
|
-
const servedStart = Date.now();
|
|
506
|
-
let traffic = venueTraffic.get(appSession);
|
|
507
|
-
const servedTimeoutMs = Number(process.env.MM_HARNESS_SERVED_TIMEOUT_MS) || 90000;
|
|
508
|
-
while ((!traffic || (traffic.testnet.size === 0 && traffic.mainnet.size === 0)) && Date.now() - servedStart < servedTimeoutMs) {
|
|
509
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
510
|
-
traffic = venueTraffic.get(appSession);
|
|
511
|
-
}
|
|
512
|
-
if (traffic?.mainnet.size) fail(`the app requested the mainnet venue (${[...traffic.mainnet].join(', ')}): the dev server does not serve testnet`);
|
|
513
|
-
if (!traffic?.testnet.size) fail(`the app made no request to a testnet venue endpoint (${testnetHosts.join(', ') || 'none known'}) within ${Math.round(servedTimeoutMs / 1000)}s, so its network could not be checked`);
|
|
514
|
-
appendEntry({ kind: 'network-enforcement', t: Date.now(), network, enforcement: 'enforced', mainnetHosts, servedHosts: [...traffic.testnet], probes: { http: seen(probeFailures.http), resolver: seen(probeFailures.resolver), websocket: seen(probeFailures.websocket) } });
|
|
515
|
-
} else {
|
|
516
|
-
appendEntry({ kind: 'network-enforcement', t: Date.now(), network, enforcement: 'disabled', reason: 'network=mainnet with mainnet_confirmation=REAL_FUNDS' });
|
|
517
|
-
}
|
|
518
|
-
say(`opened app tab ${appTargetId}`);
|
|
519
|
-
writeFileSync(path.join(runtimeDir, 'wallet-host.ready'), `${JSON.stringify({ pid: process.pid, signer, network, at: new Date().toISOString() })}\n`);
|
|
520
|
-
say(`ready (signer=${signer}, network=${network}, app=${appOrigin})`);
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { writeFile } from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { corePnpNodeOptions } from "../../commands/core-readiness.js";
|
|
5
|
-
const CORE_WORKSPACE_PACKAGES = [
|
|
6
|
-
"@metamask/base-controller",
|
|
7
|
-
"@metamask/messenger",
|
|
8
|
-
"@metamask/controller-utils",
|
|
9
|
-
"@metamask/keyring-controller"
|
|
10
|
-
];
|
|
11
|
-
async function coreLiveScriptEnv(projectRoot, tempDir) {
|
|
12
|
-
const paths = {};
|
|
13
|
-
for (const pkg of CORE_WORKSPACE_PACKAGES) {
|
|
14
|
-
const packageDir = path.join(projectRoot, "packages", pkg.replace("@metamask/", ""));
|
|
15
|
-
const distEntry = path.join(packageDir, "dist/index.cjs");
|
|
16
|
-
if (existsSync(distEntry)) continue;
|
|
17
|
-
const srcDir = path.join(packageDir, "src");
|
|
18
|
-
if (!existsSync(path.join(srcDir, "index.ts"))) continue;
|
|
19
|
-
paths[pkg] = [path.join(srcDir, "index.ts")];
|
|
20
|
-
paths[`${pkg}/*`] = [path.join(srcDir, "*")];
|
|
21
|
-
}
|
|
22
|
-
if (Object.keys(paths).length === 0) return {};
|
|
23
|
-
const tsconfigPath = path.join(tempDir, "core-adapter.tsconfig.json");
|
|
24
|
-
await writeFile(
|
|
25
|
-
tsconfigPath,
|
|
26
|
-
`${JSON.stringify({ compilerOptions: { baseUrl: projectRoot, paths } }, null, 2)}
|
|
27
|
-
`
|
|
28
|
-
);
|
|
29
|
-
return {
|
|
30
|
-
TSX_TSCONFIG_PATH: tsconfigPath,
|
|
31
|
-
NODE_OPTIONS: corePnpNodeOptions(projectRoot, process.env.NODE_OPTIONS)
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
export {
|
|
35
|
-
coreLiveScriptEnv
|
|
36
|
-
};
|