@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.
- package/CHANGELOG.md +18 -0
- package/adapters/extension/console-tail.mjs +38 -6
- package/adapters/manifest.json +128 -0
- package/adapters/terminal/cleanup.mjs +68 -0
- package/adapters/terminal/inject.mjs +74 -0
- package/adapters/terminal/launch.mjs +628 -0
- package/adapters/terminal/lib/cdp-client.mjs +84 -0
- package/adapters/terminal/lib/display.mjs +115 -0
- package/adapters/terminal/lib/hud.mjs +148 -0
- package/adapters/terminal/lib/mainnet-guard.mjs +177 -0
- package/adapters/terminal/lib/origin.mjs +30 -0
- package/adapters/terminal/lib/page-script.mjs +246 -0
- package/adapters/terminal/lib/processes.mjs +187 -0
- package/adapters/terminal/lib/readiness.mjs +274 -0
- package/adapters/terminal/lib/strict-wallet.mjs +138 -0
- package/adapters/terminal/stop.mjs +37 -0
- package/adapters/terminal/verify.mjs +48 -0
- package/adapters/terminal/wallet-host.mjs +703 -0
- package/dist/adapters/console-capture.js +122 -0
- package/dist/adapters/extension/console-capture.js +22 -85
- package/dist/adapters/surface.js +3 -1
- package/dist/adapters/terminal/console-capture.js +46 -0
- package/dist/adapters/terminal/stop.js +19 -0
- package/dist/adapters/terminal/surface.js +104 -0
- package/dist/adapters.js +27 -4
- package/dist/cli.js +2 -2
- package/dist/command-contract.js +12 -4
- package/dist/commands/check.js +1 -1
- package/dist/commands/config.js +1 -1
- package/dist/commands/doctor.js +24 -1
- package/dist/commands/domain.js +1 -1
- package/dist/commands/farmslot-ready.js +2 -0
- package/dist/commands/help.js +2 -2
- package/dist/commands/launch/index.js +97 -2
- package/dist/commands/manifest.js +3 -3
- package/dist/commands/recipe-advice.js +1 -1
- package/dist/commands/review.js +1 -1
- package/dist/commands/run-engine.js +1 -1
- package/dist/commands/shared.js +1 -1
- package/dist/doctor.js +3 -0
- package/dist/harness.js +17 -11
- package/dist/manifest.js +5 -0
- package/dist/mm-harness-cli.js +34 -20
- package/dist/paths.js +2 -2
- package/dist/recipe-security.js +12 -3
- package/dist/review/knowledge.js +1 -1
- package/dist/run-diagnostics.js +150 -33
- package/dist/runner.js +1 -1
- package/dist/runtime-context.js +3 -3
- package/docs/CONTRIBUTING.md +1 -1
- package/library/actions/shared/console-allowlist-add.mjs +137 -0
- package/library/actions/shared/console-collector.mjs +230 -0
- package/library/actions/shared/console-findings.mjs +511 -0
- package/library/actions/terminal/app/assert_no_console_errors.mjs +119 -0
- package/library/actions/terminal/app/launch.mjs +45 -0
- package/library/actions/terminal/perps/_venue.mjs +242 -0
- package/library/actions/terminal/perps/assert_orders.mjs +54 -0
- package/library/actions/terminal/perps/assert_positions.mjs +40 -0
- package/library/actions/terminal/perps/read_orders.mjs +26 -0
- package/library/actions/terminal/perps/read_positions.mjs +25 -0
- package/library/actions/terminal/perps/read_snapshot.mjs +40 -0
- package/library/actions/terminal/perps/revoke_agent.mjs +58 -0
- package/library/actions/terminal/perps/teardown_state.mjs +104 -0
- package/library/actions/terminal/platform/hud.mjs +71 -0
- package/library/actions/terminal/platform/page.mjs +247 -0
- package/library/actions/terminal/platform/runtime.mjs +200 -0
- package/library/actions/terminal/ui/navigate.mjs +31 -0
- package/library/actions/terminal/wallet/_signature-log.mjs +177 -0
- package/library/actions/terminal/wallet/assert_signatures.mjs +43 -0
- package/library/actions/terminal/wallet/list_accounts.mjs +27 -0
- package/library/actions/terminal/wallet/read_signatures.mjs +37 -0
- package/library/manifests/terminal.action-manifest.json +1786 -0
- package/package.json +1 -1
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// launch.mjs — start (or reuse) the terminal slot browser and its wallet host.
|
|
3
|
+
//
|
|
4
|
+
// Purpose:
|
|
5
|
+
// One Chromium-family browser per slot, bound to 127.0.0.1:<cdp-port>, with a
|
|
6
|
+
// profile under <runtime>/terminal/. signer=extension loads the slot's copy of
|
|
7
|
+
// the MetaMask extension and imports the fixture account through the Extension
|
|
8
|
+
// adapter's fixture flow (wallet-fixture-state.cjs generate → prefill-profile →
|
|
9
|
+
// seed-cdp). signer=injected loads no extension; the wallet host provides the
|
|
10
|
+
// strict test wallet. Either way wallet-host.mjs then records wallet requests.
|
|
11
|
+
//
|
|
12
|
+
// Inputs (flags): --target <terminal checkout> --cdp-port <port> --app-port <port>
|
|
13
|
+
// [--signer extension|injected] [--account <fixture name>] [--fresh-profile]
|
|
14
|
+
// [--headless | --headful] [--slow-mo <ms>] [--start-chain <id>] [--json]
|
|
15
|
+
// [--network mainnet --mainnet-confirmation REAL_FUNDS] (required unless the slot
|
|
16
|
+
// dev server is known to force Hyperliquid testnet)
|
|
17
|
+
// env: TERMINAL_CHROME_BIN, TERMINAL_EXTENSION_DIR,
|
|
18
|
+
// TERMINAL_EXTENSION_CHECKOUT, TERMINAL_EXTENSION_NODE, RECIPE_WALLET_FIXTURE,
|
|
19
|
+
// TERMINAL_HEADLESS, TERMINAL_SLOW_MO, TERMINAL_WINDOW, TERMINAL_SCREEN
|
|
20
|
+
// The browser is headful by default, in a window placed per slot (see
|
|
21
|
+
// lib/display.mjs); --headless or TERMINAL_HEADLESS=1 hides it.
|
|
22
|
+
// Outputs: <runtime>/terminal/browser.json (+ pid files, logs); JSON summary on stdout.
|
|
23
|
+
// Exit 0 ready; 1 launch/seed failure; 2 usage.
|
|
24
|
+
// Never prints: mnemonics, private keys, the wallet password.
|
|
25
|
+
|
|
26
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import os from 'node:os';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
fixtureEntry,
|
|
36
|
+
loadWalletFixture,
|
|
37
|
+
resolveSigner,
|
|
38
|
+
terminalRuntimeDir,
|
|
39
|
+
viemAccountFromEntry,
|
|
40
|
+
} from '../../library/actions/terminal/platform/runtime.mjs';
|
|
41
|
+
import { CdpClient, browserWebSocketUrl } from './lib/cdp-client.mjs';
|
|
42
|
+
import { clearHudState } from './lib/hud.mjs';
|
|
43
|
+
import { browserDisplayArgs, resolveHeadless, resolveSlowMo, windowPlacement, writeProfileZoom } from './lib/display.mjs';
|
|
44
|
+
import { cdpListenerPids, ownedBrowserPids, ownerMarker, ownsProcess, pidAlive, stopTerminalBrowser, terminate } from './lib/processes.mjs';
|
|
45
|
+
import { hostResolverRules, venueHosts } from './lib/mainnet-guard.mjs';
|
|
46
|
+
import { assertLaunchNetwork, devServerTestnetDiagnostic } from './lib/readiness.mjs';
|
|
47
|
+
|
|
48
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
const macosFocus = createRequire(import.meta.url)('../extension/lib/macos-focus.cjs');
|
|
50
|
+
const HARNESS_ROOT = path.resolve(SCRIPT_DIR, '../..');
|
|
51
|
+
const FIXTURE_STATE_SCRIPT = path.join(HARNESS_ROOT, 'adapters/extension/wallet-fixture-state.cjs');
|
|
52
|
+
|
|
53
|
+
function usage() {
|
|
54
|
+
return 'Usage: launch.mjs --target <checkout> --cdp-port <port> --app-port <port> [--signer extension|injected] [--account <name>] [--fresh-profile] [--headless | --headful] [--slow-mo <ms>] [--network testnet|mainnet --mainnet-confirmation REAL_FUNDS] [--start-chain <id>] [--json]';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseLaunchArgs(argv) {
|
|
58
|
+
const args = { signer: 'extension', account: 'dev1', 'fresh-profile': false, headless: false, headful: false, json: false };
|
|
59
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
60
|
+
const flag = argv[index];
|
|
61
|
+
if (flag === '--help' || flag === '-h') return { help: true };
|
|
62
|
+
if (flag === '--fresh-profile' || flag === '--headless' || flag === '--headful' || flag === '--json') {
|
|
63
|
+
args[flag.slice(2)] = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!flag.startsWith('--') || index + 1 >= argv.length) throw Object.assign(new Error(usage()), { exitCode: 2 });
|
|
67
|
+
args[flag.slice(2)] = argv[index + 1];
|
|
68
|
+
index += 1;
|
|
69
|
+
}
|
|
70
|
+
for (const key of ['target', 'cdp-port', 'app-port']) {
|
|
71
|
+
if (!args[key]) throw Object.assign(new Error(`Missing --${key}\n${usage()}`), { exitCode: 2 });
|
|
72
|
+
}
|
|
73
|
+
args.signer = resolveSigner(args.signer);
|
|
74
|
+
if (args.headless && args.headful) throw Object.assign(new Error(`--headless and --headful are mutually exclusive\n${usage()}`), { exitCode: 2 });
|
|
75
|
+
return args;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function log(message) {
|
|
79
|
+
process.stderr.write(`[terminal/launch] ${message}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function browserStarts(bin) {
|
|
83
|
+
try {
|
|
84
|
+
const version = execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
85
|
+
return version || null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The slot browser is Chrome for Testing (Playwright's cache, newest build),
|
|
92
|
+
// launched through LaunchServices on macOS, where a direct spawn of it
|
|
93
|
+
// crashes. TERMINAL_CHROME_BIN is the only explicit
|
|
94
|
+
// override. No other browser is picked on its own: an ad-blocking browser
|
|
95
|
+
// (Brave Shields) changes the app's network behaviour and its console.
|
|
96
|
+
export function chromeForTestingCandidates(home = os.homedir(), platform = process.platform, arch = process.arch) {
|
|
97
|
+
if (platform !== 'darwin') return [];
|
|
98
|
+
const cache = path.join(home, 'Library/Caches/ms-playwright');
|
|
99
|
+
if (!existsSync(cache)) return [];
|
|
100
|
+
const folder = arch === 'arm64' ? 'chrome-mac-arm64' : 'chrome-mac';
|
|
101
|
+
return readdirSync(cache)
|
|
102
|
+
.filter((entry) => /^chromium-\d+$/u.test(entry))
|
|
103
|
+
.sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1]))
|
|
104
|
+
.map((entry) => path.join(cache, entry, folder, 'Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Only TERMINAL_CHROME_BIN overrides Chrome for Testing. The shared
|
|
108
|
+
// RECIPE_HARNESS_CHROME_BIN is ignored here: a farm default pointing at a
|
|
109
|
+
// browser that blocks requests itself (Brave) would make diagnostics lie.
|
|
110
|
+
export async function resolveBrowser(env = process.env, candidates = chromeForTestingCandidates()) {
|
|
111
|
+
const explicit = env.TERMINAL_CHROME_BIN;
|
|
112
|
+
if (explicit) {
|
|
113
|
+
if (!existsSync(explicit)) throw new Error(`TERMINAL_CHROME_BIN does not exist: ${explicit}`);
|
|
114
|
+
return { bin: explicit, version: browserStarts(explicit), source: 'TERMINAL_CHROME_BIN' };
|
|
115
|
+
}
|
|
116
|
+
if (env.RECIPE_HARNESS_CHROME_BIN) log(`ignoring RECIPE_HARNESS_CHROME_BIN (${env.RECIPE_HARNESS_CHROME_BIN}) for the terminal; set TERMINAL_CHROME_BIN to override Chrome for Testing`);
|
|
117
|
+
const bin = candidates.find((candidate) => existsSync(candidate));
|
|
118
|
+
if (bin) return { bin, version: browserStarts(bin), source: 'chrome-for-testing' };
|
|
119
|
+
throw new Error(
|
|
120
|
+
'Chrome for Testing is not installed (Playwright cache).\n' +
|
|
121
|
+
'Next: npx playwright install chromium, or set TERMINAL_CHROME_BIN to a Chromium-family browser that honours --load-extension.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Node of the extension checkout (its native modules, e.g. classic-level, are
|
|
126
|
+
// built for that ABI): TERMINAL_EXTENSION_NODE, else asdf's install for .nvmrc.
|
|
127
|
+
export function resolveExtensionNode(checkout, env = process.env) {
|
|
128
|
+
if (env.TERMINAL_EXTENSION_NODE) return env.TERMINAL_EXTENSION_NODE;
|
|
129
|
+
const nvmrc = path.join(checkout, '.nvmrc');
|
|
130
|
+
if (existsSync(nvmrc)) {
|
|
131
|
+
const wanted = readFileSync(nvmrc, 'utf8').trim().replace(/^v/u, '');
|
|
132
|
+
const root = path.join(os.homedir(), '.asdf/installs/nodejs');
|
|
133
|
+
if (existsSync(root)) {
|
|
134
|
+
const match = readdirSync(root)
|
|
135
|
+
.filter((version) => version === wanted || version.startsWith(`${wanted}.`))
|
|
136
|
+
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))[0];
|
|
137
|
+
if (match) return path.join(root, match, 'bin/node');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return process.execPath;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The Extension vault needs a mnemonic keyring, so a privateKey account is
|
|
144
|
+
// imported beside the fixture's first mnemonic account and selected.
|
|
145
|
+
export function slotWalletFixture(fixture, accountName) {
|
|
146
|
+
const entry = fixtureEntry(fixture, accountName);
|
|
147
|
+
const accounts = [];
|
|
148
|
+
if (entry.type === 'privateKey') {
|
|
149
|
+
const primary = fixture.accounts.find((account) => account?.type === 'mnemonic');
|
|
150
|
+
if (!primary) throw new Error(`fixture has no mnemonic account to host the imported key ${accountName}.`);
|
|
151
|
+
accounts.push({ ...primary, count: 1, names: [primary.name] });
|
|
152
|
+
accounts.push(entry);
|
|
153
|
+
} else {
|
|
154
|
+
accounts.push({ ...entry, count: 1, names: [entry.name] });
|
|
155
|
+
}
|
|
156
|
+
return { password: fixture.password, settings: fixture.settings ?? {}, accounts, selectedAccount: accountName };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function runFixtureState(node, phase, flags) {
|
|
160
|
+
const result = execFileSync(node, [FIXTURE_STATE_SCRIPT, phase, ...flags], {
|
|
161
|
+
encoding: 'utf8',
|
|
162
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
163
|
+
timeout: 240000,
|
|
164
|
+
});
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// How the browser process is started, named as the shared browser resolver
|
|
169
|
+
// names them. An .app browser on macOS starts through LaunchServices in the
|
|
170
|
+
// background (`open -g -n -a`), as the Extension launcher does: a visible one
|
|
171
|
+
// never takes the operator's keyboard focus, and Chrome for Testing does not
|
|
172
|
+
// start when spawned directly on this macOS. A binary outside an .app bundle,
|
|
173
|
+
// or any browser elsewhere, is spawned directly.
|
|
174
|
+
export const SPAWN = 'spawn';
|
|
175
|
+
export const LAUNCH_SERVICES = 'launch-services';
|
|
176
|
+
|
|
177
|
+
export function macApplicationForExecutable(executable) {
|
|
178
|
+
let current = path.resolve(executable);
|
|
179
|
+
while (current !== path.dirname(current)) {
|
|
180
|
+
if (current.endsWith('.app')) return current;
|
|
181
|
+
current = path.dirname(current);
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function launchMethodFor(executable, { platform = process.platform } = {}) {
|
|
187
|
+
return platform === 'darwin' && macApplicationForExecutable(executable) ? LAUNCH_SERVICES : SPAWN;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// LaunchServices hands back no child: the browser's pid is the process that
|
|
191
|
+
// listens on the slot CDP port with this slot's profile.
|
|
192
|
+
async function launchInBackground(application, browserArgs, { runtime, cdpPort, logFile, pidFile, timeoutMs }) {
|
|
193
|
+
const out = openSync(logFile, 'a');
|
|
194
|
+
try {
|
|
195
|
+
execFileSync('open', macosFocus.macBackgroundOpenArgs(application, browserArgs), { stdio: ['ignore', out, out], timeout: 30000 });
|
|
196
|
+
} finally {
|
|
197
|
+
closeSync(out);
|
|
198
|
+
}
|
|
199
|
+
const deadline = Date.now() + timeoutMs;
|
|
200
|
+
while (Date.now() < deadline) {
|
|
201
|
+
const pid = cdpListenerPids(cdpPort).find((candidate) => ownsProcess('browser', candidate, runtime));
|
|
202
|
+
if (pid) {
|
|
203
|
+
writeFileSync(pidFile, `${pid}\n`);
|
|
204
|
+
return pid;
|
|
205
|
+
}
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
207
|
+
}
|
|
208
|
+
for (const pid of ownedBrowserPids(runtime)) await terminate(pid, 3000);
|
|
209
|
+
throw new Error(`${path.basename(application)} started but no browser on this slot's profile listened on CDP ${cdpPort} within ${timeoutMs}ms.`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Focus decisions go to <runtime>/focus.log as well as stderr, so a run's
|
|
213
|
+
// focus behaviour can be read back after the fact.
|
|
214
|
+
const FOCUS_OUTCOMES = {
|
|
215
|
+
restored: (previous) => `our browser was in front; restored ${previous.name} (pid ${previous.pid}) once`,
|
|
216
|
+
kept: (previous) => `our browser was not in front, or ${previous?.name ?? 'the previous app'} could not be activated; left alone`,
|
|
217
|
+
off: () => 'focus handling is off, or no app was captured before the launch',
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// The one focus check after a launch (shared macos-focus.cjs): the app in
|
|
221
|
+
// front before the launch gets the front back, once, by its pid, only if one
|
|
222
|
+
// of this slot's browser processes (by owner marker, never by bundle) is in
|
|
223
|
+
// front.
|
|
224
|
+
export function checkFocusAfterLaunch(runtime, previous, { ourPids = ownedBrowserPids(runtime), focus = macosFocus } = {}) {
|
|
225
|
+
const outcome = focus.restoreFrontmostIfOurs(previous, ourPids);
|
|
226
|
+
focusLog(runtime, FOCUS_OUTCOMES[outcome](previous));
|
|
227
|
+
return outcome;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function focusLog(runtime, message) {
|
|
231
|
+
log(`focus: ${message}`);
|
|
232
|
+
try {
|
|
233
|
+
appendFileSync(path.join(runtime, 'focus.log'), `${new Date().toISOString()} ${message}\n`, { mode: 0o600 });
|
|
234
|
+
} catch {
|
|
235
|
+
// The log is advisory.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Browser-level CDP helpers for a background launch.
|
|
240
|
+
async function withBrowserClient(cdpPort, callback) {
|
|
241
|
+
const client = await CdpClient.connect(await browserWebSocketUrl(cdpPort, 15000));
|
|
242
|
+
try {
|
|
243
|
+
return await callback(client);
|
|
244
|
+
} finally {
|
|
245
|
+
client.close?.();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function extensionIdFor(cdpPort, idFile) {
|
|
250
|
+
if (existsSync(idFile)) return readFileSync(idFile, 'utf8').trim();
|
|
251
|
+
return withBrowserClient(cdpPort, async (client) => {
|
|
252
|
+
const deadline = Date.now() + 30000;
|
|
253
|
+
while (Date.now() < deadline) {
|
|
254
|
+
const { targetInfos } = await client.send('Target.getTargets');
|
|
255
|
+
const worker = targetInfos.find((info) => info.type === 'service_worker' && info.url.startsWith('chrome-extension://'));
|
|
256
|
+
if (worker) return new URL(worker.url).host;
|
|
257
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
258
|
+
}
|
|
259
|
+
throw new Error('the MetaMask service worker did not start');
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function backgroundWindowParams(url, placement) {
|
|
264
|
+
return {
|
|
265
|
+
url,
|
|
266
|
+
newWindow: true,
|
|
267
|
+
background: true,
|
|
268
|
+
...(placement ? { left: placement.x, top: placement.y, width: placement.width, height: placement.height } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function openBackgroundWindow(cdpPort, url, placement) {
|
|
273
|
+
return withBrowserClient(cdpPort, (client) => client.send('Target.createTarget', backgroundWindowParams(url, placement)));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function focusSettleMs(env) {
|
|
277
|
+
const value = Number(env.MM_HARNESS_FOCUS_SETTLE_MS);
|
|
278
|
+
return Number.isFinite(value) && value >= 0 ? value : 1000;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Resolves once the process is running, rejects on a spawn failure (EACCES,
|
|
282
|
+
// ENOENT) so the caller's rollback runs; only a running pid is recorded.
|
|
283
|
+
export function spawnDetached(command, args, { logFile, pidFile, env = process.env }) {
|
|
284
|
+
const out = openSync(logFile, 'a');
|
|
285
|
+
return new Promise((resolve, reject) => {
|
|
286
|
+
let child;
|
|
287
|
+
try {
|
|
288
|
+
child = spawn(command, args, { detached: true, stdio: ['ignore', out, out], env });
|
|
289
|
+
} catch (error) {
|
|
290
|
+
closeSync(out);
|
|
291
|
+
reject(error);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
child.once('error', (error) => {
|
|
295
|
+
closeSync(out);
|
|
296
|
+
reject(new Error(`could not start ${path.basename(command)}: ${error.code ?? error.message}`));
|
|
297
|
+
});
|
|
298
|
+
child.once('spawn', () => {
|
|
299
|
+
closeSync(out);
|
|
300
|
+
child.unref();
|
|
301
|
+
try {
|
|
302
|
+
writeFileSync(pidFile, `${child.pid}\n`);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
// Without a pid file nothing could stop this child later: stop it now.
|
|
305
|
+
try {
|
|
306
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
307
|
+
} catch {
|
|
308
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
309
|
+
}
|
|
310
|
+
reject(new Error(`could not record ${path.basename(command)} pid in ${pidFile}: ${error.code ?? error.message}`));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
resolve(child.pid);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function waitFor(check, timeoutMs, label) {
|
|
319
|
+
const deadline = Date.now() + timeoutMs;
|
|
320
|
+
while (Date.now() < deadline) {
|
|
321
|
+
if (await check()) return;
|
|
322
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
323
|
+
}
|
|
324
|
+
throw new Error(`${label} did not happen within ${timeoutMs}ms`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function readStateFile(file) {
|
|
328
|
+
try {
|
|
329
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error?.code === 'ENOENT') return null;
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Write through a fresh temporary file and an atomic rename, owner-only.
|
|
337
|
+
function writePrivateFile(file, text) {
|
|
338
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
339
|
+
writeFileSync(temporary, text, { mode: 0o600, flag: 'wx' });
|
|
340
|
+
renameSync(temporary, file);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// The host is ready once the app's first document has loaded with its hooks,
|
|
344
|
+
// which waits for a cold dev-server compile.
|
|
345
|
+
export const LAUNCH_TIMEOUTS = Object.freeze({ browserStartMs: 90000, hostReadyMs: 180000 });
|
|
346
|
+
|
|
347
|
+
export async function launchTerminalBrowser(args, env = process.env, { timeouts = LAUNCH_TIMEOUTS } = {}) {
|
|
348
|
+
const target = path.resolve(args.target);
|
|
349
|
+
const cdpPort = Number(args['cdp-port']);
|
|
350
|
+
const appPort = Number(args['app-port']);
|
|
351
|
+
const signer = args.signer;
|
|
352
|
+
// Mainnet needs the real-funds confirmation. Testnet is enforced inside the
|
|
353
|
+
// browser: the SDK's mainnet venue hosts are unresolvable for the whole
|
|
354
|
+
// browser, and the wallet host blocks, logs and refuses anything mainnet.
|
|
355
|
+
const network = assertLaunchNetwork(target, { network: args.network, mainnetConfirmation: args['mainnet-confirmation'] });
|
|
356
|
+
const venues = network === 'testnet' ? venueHosts(target) : { mainnet: [], testnet: [] };
|
|
357
|
+
const mainnetHosts = venues.mainnet;
|
|
358
|
+
// What the running browser enforces; a browser enforcing anything else is
|
|
359
|
+
// never reused (its resolver rules are fixed at start).
|
|
360
|
+
const enforcementFingerprint = createHash('sha256').update(JSON.stringify({ network, mainnetHosts, testnetHosts: venues.testnet })).digest('hex').slice(0, 16);
|
|
361
|
+
const devServer = network === 'testnet' ? devServerTestnetDiagnostic(target, { appPort }) : null;
|
|
362
|
+
if (devServer && !(devServer.known && devServer.forced)) {
|
|
363
|
+
log(`WARN dev server testnet check: ${devServer.detail} (advisory; the browser enforces testnet itself)`);
|
|
364
|
+
}
|
|
365
|
+
const runtime = terminalRuntimeDir(target);
|
|
366
|
+
mkdirSync(runtime, { recursive: true, mode: 0o700 });
|
|
367
|
+
chmodSync(runtime, 0o700);
|
|
368
|
+
const stateFile = path.join(runtime, 'browser.json');
|
|
369
|
+
const appOrigin = `http://localhost:${appPort}`;
|
|
370
|
+
const headless = resolveHeadless({ headless: args.headless, headful: args.headful, env });
|
|
371
|
+
const slowMoMs = resolveSlowMo({ flag: args['slow-mo'], env });
|
|
372
|
+
|
|
373
|
+
const { fixture } = await loadWalletFixture(target, env);
|
|
374
|
+
const accountAddress = viemAccountFromEntry(fixtureEntry(fixture, args.account)).address;
|
|
375
|
+
|
|
376
|
+
const previous = readStateFile(stateFile);
|
|
377
|
+
const reusable = previous &&
|
|
378
|
+
!args['fresh-profile'] &&
|
|
379
|
+
previous.signer === signer &&
|
|
380
|
+
previous.cdpPort === cdpPort &&
|
|
381
|
+
previous.appOrigin === appOrigin &&
|
|
382
|
+
previous.account?.name === args.account &&
|
|
383
|
+
previous.browser?.headless === headless &&
|
|
384
|
+
(previous.network ?? 'testnet') === network &&
|
|
385
|
+
previous.networkEnforcement?.fingerprint === enforcementFingerprint &&
|
|
386
|
+
ownsProcess('browser', previous.browserPid, runtime) &&
|
|
387
|
+
ownsProcess('wallet-host', previous.hostPid, runtime);
|
|
388
|
+
if (reusable) {
|
|
389
|
+
await browserWebSocketUrl(cdpPort, 5000);
|
|
390
|
+
log(`reusing browser pid ${previous.browserPid} (${signer}, ${args.account})`);
|
|
391
|
+
const state = { ...previous, slowMoMs };
|
|
392
|
+
if (previous.slowMoMs !== slowMoMs) writePrivateFile(stateFile, `${JSON.stringify(state, null, 2)}\n`);
|
|
393
|
+
return { ...state, reused: true };
|
|
394
|
+
}
|
|
395
|
+
await stopTerminalBrowser(target, { cdpPort, log });
|
|
396
|
+
|
|
397
|
+
const browser = await resolveBrowser(env);
|
|
398
|
+
const profile = path.join(runtime, `profile-${signer}-${args.account.replace(/[^A-Za-z0-9_-]/gu, '_')}`);
|
|
399
|
+
if (args['fresh-profile'] && existsSync(profile)) {
|
|
400
|
+
const retired = `${profile}.retired-${Date.now()}`;
|
|
401
|
+
renameSync(profile, retired);
|
|
402
|
+
rmSync(retired, { recursive: true, force: true });
|
|
403
|
+
}
|
|
404
|
+
const freshProfile = !existsSync(profile);
|
|
405
|
+
mkdirSync(profile, { recursive: true, mode: 0o700 });
|
|
406
|
+
const placement = headless ? null : windowPlacement({ cdpPort, env });
|
|
407
|
+
// Reset on every start so a profile reused across modes never keeps a zoom.
|
|
408
|
+
writeProfileZoom(profile, placement?.zoom ?? 1);
|
|
409
|
+
|
|
410
|
+
const browserArgs = [
|
|
411
|
+
`--user-data-dir=${profile}`,
|
|
412
|
+
'--remote-debugging-address=127.0.0.1',
|
|
413
|
+
`--remote-debugging-port=${cdpPort}`,
|
|
414
|
+
`--remote-allow-origins=http://127.0.0.1:${cdpPort}`,
|
|
415
|
+
'--no-first-run',
|
|
416
|
+
'--no-default-browser-check',
|
|
417
|
+
'--use-mock-keychain',
|
|
418
|
+
'--disable-background-timer-throttling',
|
|
419
|
+
'--disable-backgrounding-occluded-windows',
|
|
420
|
+
'--disable-renderer-backgrounding',
|
|
421
|
+
...browserDisplayArgs({ headless, placement }),
|
|
422
|
+
ownerMarker('browser', runtime),
|
|
423
|
+
...(network === 'testnet' ? [hostResolverRules(mainnetHosts)] : []),
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
// Key material (the slot fixture) and the encrypted vault (fixture state)
|
|
427
|
+
// never outlive this call; anything started here is stopped again when a
|
|
428
|
+
// later step fails.
|
|
429
|
+
const secrets = [];
|
|
430
|
+
let started = false;
|
|
431
|
+
const launchMethod = launchMethodFor(browser.bin);
|
|
432
|
+
// Focus is checked for a visible browser started in the background.
|
|
433
|
+
const guardFocus = launchMethod === LAUNCH_SERVICES && !headless;
|
|
434
|
+
// With no startup window, windows are created over CDP at these bounds; a
|
|
435
|
+
// headless browser keeps the tall viewport the recipes were proven on.
|
|
436
|
+
const windowBounds = placement ?? { x: 0, y: 0, width: 1600, height: 2400 };
|
|
437
|
+
let application = null;
|
|
438
|
+
let previousFrontmost = null;
|
|
439
|
+
try {
|
|
440
|
+
let extension = null;
|
|
441
|
+
if (signer === 'extension') {
|
|
442
|
+
const extensionDir = path.resolve(env.TERMINAL_EXTENSION_DIR || path.join(target, 'temp/metamask-extension/chrome'));
|
|
443
|
+
const checkout = env.TERMINAL_EXTENSION_CHECKOUT ? path.resolve(env.TERMINAL_EXTENSION_CHECKOUT) : null;
|
|
444
|
+
if (!existsSync(path.join(extensionDir, 'manifest.json'))) {
|
|
445
|
+
throw new Error(`MetaMask extension dist not found at ${extensionDir}.\nNext: copy a built dist/chrome there or set TERMINAL_EXTENSION_DIR.`);
|
|
446
|
+
}
|
|
447
|
+
if (!checkout || !existsSync(path.join(checkout, 'node_modules'))) {
|
|
448
|
+
throw new Error('signer=extension needs TERMINAL_EXTENSION_CHECKOUT: an installed metamask-extension checkout at the dist commit (fixture state generation).');
|
|
449
|
+
}
|
|
450
|
+
const manifest = JSON.parse(readFileSync(path.join(extensionDir, 'manifest.json'), 'utf8'));
|
|
451
|
+
const node = resolveExtensionNode(checkout, env);
|
|
452
|
+
const slotFixture = path.join(runtime, `wallet-fixture.${args.account}.json`);
|
|
453
|
+
const fixtureState = path.join(runtime, `fixture-state.${args.account}.json`);
|
|
454
|
+
secrets.push(slotFixture, fixtureState);
|
|
455
|
+
writePrivateFile(slotFixture, JSON.stringify(slotWalletFixture(fixture, args.account)));
|
|
456
|
+
const idFile = path.join(runtime, 'extension.id');
|
|
457
|
+
runFixtureState(node, 'generate', ['--target', checkout, '--fixture', slotFixture, '--out', fixtureState]);
|
|
458
|
+
if (freshProfile) {
|
|
459
|
+
runFixtureState(node, 'prefill-profile', ['--target', checkout, '--state', fixtureState, '--profile', profile, '--extension-dir', extensionDir, '--extension-id-file', idFile]);
|
|
460
|
+
log(`prefilled extension profile for ${args.account}`);
|
|
461
|
+
}
|
|
462
|
+
browserArgs.push(
|
|
463
|
+
'--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
|
|
464
|
+
'--disable-extensions-file-access-check',
|
|
465
|
+
`--disable-extensions-except=${extensionDir}`,
|
|
466
|
+
`--load-extension=${extensionDir}`,
|
|
467
|
+
);
|
|
468
|
+
extension = { dir: extensionDir, version: manifest.version, checkout, node, fixtureState, slotFixture, idFile };
|
|
469
|
+
}
|
|
470
|
+
// A background launch opens no window at startup: Chrome activates itself
|
|
471
|
+
// when its first window appears. Windows are created over CDP instead, in
|
|
472
|
+
// the background.
|
|
473
|
+
browserArgs.push(launchMethod === LAUNCH_SERVICES ? '--no-startup-window' : 'about:blank');
|
|
474
|
+
|
|
475
|
+
started = true;
|
|
476
|
+
// Where this browser's console begins in the slot capture, for
|
|
477
|
+
// assert_no_console_errors.
|
|
478
|
+
// With the file's inode: a replaced file is then read from its start.
|
|
479
|
+
const consoleStats = Object.fromEntries(['page', 'extension'].map((source) => {
|
|
480
|
+
const file = path.join(runtime, source === 'page' ? 'app-console.log' : 'extension-console.log');
|
|
481
|
+
return [source, existsSync(file) ? statSync(file) : null];
|
|
482
|
+
}));
|
|
483
|
+
const consoleOffsets = {
|
|
484
|
+
page: consoleStats.page?.size ?? 0,
|
|
485
|
+
extension: consoleStats.extension?.size ?? 0,
|
|
486
|
+
inodes: { page: consoleStats.page?.ino ?? null, extension: consoleStats.extension?.ino ?? null },
|
|
487
|
+
};
|
|
488
|
+
application = launchMethod === LAUNCH_SERVICES ? macApplicationForExecutable(browser.bin) : null;
|
|
489
|
+
if (guardFocus) {
|
|
490
|
+
// Only recorded: the browser never activates itself, and the one check
|
|
491
|
+
// after the launch (finally) uses this to undo an activation by ours.
|
|
492
|
+
previousFrontmost = macosFocus.captureMacFrontmost();
|
|
493
|
+
focusLog(runtime, `front app before the launch: ${previousFrontmost?.name ?? 'unknown'} (pid ${previousFrontmost?.pid ?? '-'})`);
|
|
494
|
+
}
|
|
495
|
+
const browserPid = launchMethod === LAUNCH_SERVICES
|
|
496
|
+
? await launchInBackground(application, browserArgs, {
|
|
497
|
+
runtime,
|
|
498
|
+
cdpPort,
|
|
499
|
+
logFile: path.join(runtime, 'browser.log'),
|
|
500
|
+
pidFile: path.join(runtime, 'browser.pid'),
|
|
501
|
+
timeoutMs: timeouts.browserStartMs,
|
|
502
|
+
})
|
|
503
|
+
: await spawnDetached(browser.bin, browserArgs, {
|
|
504
|
+
logFile: path.join(runtime, 'browser.log'),
|
|
505
|
+
pidFile: path.join(runtime, 'browser.pid'),
|
|
506
|
+
env,
|
|
507
|
+
});
|
|
508
|
+
await browserWebSocketUrl(cdpPort, timeouts.browserStartMs);
|
|
509
|
+
log(`browser pid ${browserPid} on CDP ${cdpPort} (${browser.version ?? browser.bin}, ${headless ? 'headless' : `window ${placement.width}x${placement.height}+${placement.x}+${placement.y} zoom ${placement.zoom}`})`);
|
|
510
|
+
|
|
511
|
+
if (extension && launchMethod === LAUNCH_SERVICES) {
|
|
512
|
+
// The seed reuses an open MetaMask page; without one it would open a tab
|
|
513
|
+
// in the foreground and activate the browser.
|
|
514
|
+
const extensionId = await extensionIdFor(cdpPort, extension.idFile);
|
|
515
|
+
await openBackgroundWindow(cdpPort, `chrome-extension://${extensionId}/home.html`, windowBounds);
|
|
516
|
+
}
|
|
517
|
+
if (extension) {
|
|
518
|
+
const seedReport = path.join(runtime, `seed-report.${args.account}.json`);
|
|
519
|
+
runFixtureState(extension.node, 'seed-cdp', [
|
|
520
|
+
'--target', extension.checkout,
|
|
521
|
+
'--fixture', extension.slotFixture,
|
|
522
|
+
'--state', extension.fixtureState,
|
|
523
|
+
'--cdp-port', String(cdpPort),
|
|
524
|
+
'--extension-dir', extension.dir,
|
|
525
|
+
'--extension-id-file', extension.idFile,
|
|
526
|
+
'--out', seedReport,
|
|
527
|
+
]);
|
|
528
|
+
extension.id = readFileSync(extension.idFile, 'utf8').trim();
|
|
529
|
+
extension.seedReport = seedReport;
|
|
530
|
+
log(`extension ${extension.id} seeded and unlocked for ${args.account}`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
rmSync(path.join(runtime, 'wallet-host.ready'), { force: true });
|
|
534
|
+
// A new browser starts without a HUD; the runner's next update draws one.
|
|
535
|
+
clearHudState(runtime);
|
|
536
|
+
const hostArgs = [
|
|
537
|
+
path.join(SCRIPT_DIR, 'wallet-host.mjs'),
|
|
538
|
+
'--cdp-port', String(cdpPort),
|
|
539
|
+
'--runtime-dir', runtime,
|
|
540
|
+
'--signer', signer,
|
|
541
|
+
'--app-origin', appOrigin,
|
|
542
|
+
'--target', target,
|
|
543
|
+
'--account', args.account,
|
|
544
|
+
'--start-chain', String(args['start-chain'] ?? 42161),
|
|
545
|
+
'--network', network,
|
|
546
|
+
ownerMarker('wallet-host', runtime),
|
|
547
|
+
];
|
|
548
|
+
if (network === 'testnet') hostArgs.push('--mainnet-hosts', mainnetHosts.join(','), '--testnet-hosts', venues.testnet.join(','));
|
|
549
|
+
if (extension?.id) hostArgs.push('--extension-id', extension.id);
|
|
550
|
+
if (guardFocus) hostArgs.push('--observe-focus', '1');
|
|
551
|
+
if (launchMethod === LAUNCH_SERVICES) hostArgs.push('--window', [windowBounds.x, windowBounds.y, windowBounds.width, windowBounds.height].join(','));
|
|
552
|
+
const hostPid = await spawnDetached(process.execPath, hostArgs, {
|
|
553
|
+
logFile: path.join(runtime, 'wallet-host.log'),
|
|
554
|
+
pidFile: path.join(runtime, 'wallet-host.pid'),
|
|
555
|
+
env,
|
|
556
|
+
});
|
|
557
|
+
await waitFor(() => {
|
|
558
|
+
if (existsSync(path.join(runtime, 'wallet-host.ready'))) return true;
|
|
559
|
+
if (!pidAlive(hostPid)) throw new Error(`wallet host exited before it was ready (see ${path.join(runtime, 'wallet-host.log')})`);
|
|
560
|
+
return false;
|
|
561
|
+
}, timeouts.hostReadyMs, 'wallet host ready');
|
|
562
|
+
// A window can activate the browser when it first paints, after the
|
|
563
|
+
// browser already answers: check once after that (MM_HARNESS_FOCUS_SETTLE_MS).
|
|
564
|
+
if (guardFocus) await new Promise((resolve) => setTimeout(resolve, focusSettleMs(env)));
|
|
565
|
+
|
|
566
|
+
const state = {
|
|
567
|
+
schemaVersion: 1,
|
|
568
|
+
signer,
|
|
569
|
+
network,
|
|
570
|
+
networkEnforcement: network === 'testnet'
|
|
571
|
+
? { mode: 'enforced', mainnetHosts, testnetHosts: venues.testnet, fingerprint: enforcementFingerprint, layers: ['host-resolver-rules', 'cdp-fetch-block', 'wallet-refusal', 'served-network-check'] }
|
|
572
|
+
: { mode: 'disabled', reason: 'network=mainnet with mainnet_confirmation=REAL_FUNDS', fingerprint: enforcementFingerprint },
|
|
573
|
+
devServerDiagnostic: devServer,
|
|
574
|
+
consoleOffsets,
|
|
575
|
+
// The app port's listeners at ready; wallet-surface actions re-check them
|
|
576
|
+
// (lsof only sees this user's sockets).
|
|
577
|
+
appListeners: cdpListenerPids(appPort),
|
|
578
|
+
cdpPort,
|
|
579
|
+
appOrigin,
|
|
580
|
+
account: { name: args.account, address: accountAddress },
|
|
581
|
+
browserPid,
|
|
582
|
+
hostPid,
|
|
583
|
+
browser: { bin: browser.bin, version: browser.version, source: browser.source, headless, launchMethod },
|
|
584
|
+
window: placement,
|
|
585
|
+
slowMoMs,
|
|
586
|
+
profile,
|
|
587
|
+
freshProfile,
|
|
588
|
+
extension: extension
|
|
589
|
+
? { id: extension.id, version: extension.version, dir: extension.dir, checkout: extension.checkout }
|
|
590
|
+
: null,
|
|
591
|
+
startedAt: new Date().toISOString(),
|
|
592
|
+
};
|
|
593
|
+
writePrivateFile(stateFile, `${JSON.stringify(state, null, 2)}\n`);
|
|
594
|
+
return { ...state, reused: false };
|
|
595
|
+
} catch (error) {
|
|
596
|
+
if (started) {
|
|
597
|
+
log(`launch failed, stopping what it started: ${String(error?.message ?? error).split('\n')[0]}`);
|
|
598
|
+
await stopTerminalBrowser(target, { cdpPort, log }).catch((stopError) => log(`rollback incomplete: ${stopError.message}`));
|
|
599
|
+
for (const pid of ownedBrowserPids(runtime)) await terminate(pid, 3000);
|
|
600
|
+
}
|
|
601
|
+
throw error;
|
|
602
|
+
} finally {
|
|
603
|
+
for (const file of secrets) rmSync(file, { force: true });
|
|
604
|
+
if (guardFocus) checkFocusAfterLaunch(runtime, previousFrontmost);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
609
|
+
let args;
|
|
610
|
+
try {
|
|
611
|
+
args = parseLaunchArgs(process.argv.slice(2));
|
|
612
|
+
} catch (error) {
|
|
613
|
+
process.stderr.write(`${error.message}\n`);
|
|
614
|
+
process.exit(error.exitCode ?? 2);
|
|
615
|
+
}
|
|
616
|
+
if (args.help) {
|
|
617
|
+
process.stdout.write(`${usage()}\n`);
|
|
618
|
+
process.exit(0);
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
const state = await launchTerminalBrowser(args);
|
|
622
|
+
process.stdout.write(`${JSON.stringify({ status: 'pass', ...state }, null, args.json ? 0 : 2)}\n`);
|
|
623
|
+
} catch (error) {
|
|
624
|
+
const message = String(error?.stderr ?? '').trim() || error.message;
|
|
625
|
+
process.stdout.write(`${JSON.stringify({ status: 'fail', error: message.replace(/0x[0-9a-fA-F]{64}/gu, '<redacted>') })}\n`);
|
|
626
|
+
process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
}
|