@deeeed/metamask-harness 0.77.0 → 0.78.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 (31) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +8 -5
  3. package/adapters/extension/build-handoff.cjs +2 -2
  4. package/adapters/extension/ensure-browser.sh +10 -12
  5. package/adapters/extension/inject.mjs +10 -7
  6. package/adapters/extension/launch-browser.cjs +7 -10
  7. package/adapters/extension/live.sh +1 -2
  8. package/adapters/extension/reattach.sh +3 -3
  9. package/adapters/extension/sidepanel-toggle.sh +4 -4
  10. package/adapters/extension/wallet-fixture-state.cjs +2 -2
  11. package/adapters/manifest.json +0 -24
  12. package/adapters/shared/harness-source-fingerprint.mjs +16 -2
  13. package/adapters/terminal/launch.mjs +2 -2
  14. package/adapters/terminal/wallet-host.mjs +1 -1
  15. package/dist/adapters/extension/doctor-checks.js +2 -2
  16. package/dist/adapters/extension/launched-browser.js +1 -1
  17. package/dist/adapters/extension/runtime.js +4 -4
  18. package/dist/adapters/extension/validation-process-ownership.js +1 -1
  19. package/dist/commands/launch/extension.js +1 -1
  20. package/docs/cli-contract.md +6 -17
  21. package/library/actions/extension/platform/cdp.mjs +5 -7
  22. package/library/actions/terminal/platform/page.mjs +2 -2
  23. package/package.json +2 -1
  24. package/adapters/extension/lib/chrome-args.cjs +0 -365
  25. package/adapters/extension/lib/extension-id.cjs +0 -36
  26. package/adapters/extension/lib/macos-focus.cjs +0 -108
  27. package/adapters/extension/lib/playwright-cdp.cjs +0 -50
  28. package/adapters/extension/lib/validation-launch-supervisor.cjs +0 -292
  29. package/adapters/extension/lib/validation-process-ownership.cjs +0 -125
  30. package/adapters/shared/browser-cdp.cjs +0 -542
  31. package/adapters/shared/browser-resolver.cjs +0 -1007
@@ -1,542 +0,0 @@
1
- // browser-cdp.cjs — browser-level CDP client with deadlines, and the extension
2
- // operations the launchers build on it: proof that a CDP port belongs to a
3
- // slot profile, Extensions.loadUnpacked, and isolation of the wallet from
4
- // other user extensions in that profile.
5
- //
6
- // Every command has a deadline and every pending command fails when the
7
- // socket closes, so a browser that dies or stalls never hangs a launcher.
8
- 'use strict';
9
-
10
- const { execFileSync } = require('node:child_process');
11
- const fs = require('node:fs');
12
- const os = require('node:os');
13
- const path = require('node:path');
14
-
15
- // The installed overlay flattens shared helpers into scripts/lib/.
16
- const { extensionIdFromExtensionDir } = require([
17
- path.join(__dirname, 'extension-id.cjs'),
18
- path.join(__dirname, '../extension/lib/extension-id.cjs'),
19
- ].find((candidate) => fs.existsSync(candidate)));
20
-
21
- const DEFAULT_COMMAND_TIMEOUT_MS = 15000;
22
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
23
-
24
- function deadlineError(what, ms) {
25
- return Object.assign(new Error(`${what} did not answer within ${ms}ms`), { code: 'CDP_TIMEOUT' });
26
- }
27
-
28
- async function connectBrowserCdp(port, { timeoutMs = 30000, commandTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = {}) {
29
- const deadline = Date.now() + timeoutMs;
30
- let url = null;
31
- while (!url && Date.now() < deadline) {
32
- try {
33
- const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
34
- signal: AbortSignal.timeout(Math.max(1, Math.min(2000, deadline - Date.now()))),
35
- });
36
- url = (await response.json()).webSocketDebuggerUrl;
37
- } catch {
38
- await sleep(Math.max(1, Math.min(250, deadline - Date.now())));
39
- }
40
- }
41
- if (!url) throw Object.assign(new Error(`No browser CDP endpoint on 127.0.0.1:${port} within ${timeoutMs}ms.`), { code: 'CDP_TIMEOUT' });
42
- const WebSocketImpl = globalThis.WebSocket ?? require('ws');
43
- const socket = new WebSocketImpl(url);
44
- const pending = new Map();
45
- let closedError = null;
46
- const failAll = (error) => {
47
- closedError ??= error;
48
- for (const entry of pending.values()) {
49
- clearTimeout(entry.timer);
50
- entry.reject(closedError);
51
- }
52
- pending.clear();
53
- };
54
- await new Promise((resolve, reject) => {
55
- const timer = setTimeout(() => {
56
- reject(deadlineError(`Opening the browser CDP socket ${url}`, timeoutMs));
57
- try {
58
- socket.close();
59
- } catch {
60
- // Never opened.
61
- }
62
- }, Math.max(1, deadline - Date.now()));
63
- socket.onopen = () => {
64
- clearTimeout(timer);
65
- resolve();
66
- };
67
- socket.onerror = () => {
68
- clearTimeout(timer);
69
- reject(new Error(`Could not open browser CDP socket ${url}.`));
70
- };
71
- });
72
- const lost = () => failAll(new Error('Browser CDP socket closed (the browser exited or disconnected).'));
73
- socket.onerror = lost;
74
- socket.onclose = lost;
75
- let nextId = 0;
76
- socket.onmessage = (event) => {
77
- const message = JSON.parse(typeof event.data === 'string' ? event.data : String(event.data));
78
- const entry = message.id === undefined ? null : pending.get(message.id);
79
- if (!entry) return;
80
- pending.delete(message.id);
81
- clearTimeout(entry.timer);
82
- if (message.error) entry.reject(new Error(`${entry.method}: ${message.error.message}`));
83
- else entry.resolve(message.result);
84
- };
85
- const send = (method, params = {}, sessionId, timeout = commandTimeoutMs) => new Promise((resolve, reject) => {
86
- if (closedError) {
87
- reject(closedError);
88
- return;
89
- }
90
- nextId += 1;
91
- const id = nextId;
92
- const timer = setTimeout(() => {
93
- pending.delete(id);
94
- reject(deadlineError(method, timeout));
95
- }, Math.max(1, timeout));
96
- pending.set(id, { resolve, reject, method, timer });
97
- socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
98
- });
99
- return {
100
- send,
101
- close: () => {
102
- failAll(new Error('Browser CDP client closed.'));
103
- try {
104
- socket.close();
105
- } catch {
106
- // Already closed.
107
- }
108
- },
109
- };
110
- }
111
-
112
- // ---- CDP port ownership ---------------------------------------------------
113
-
114
- // lsof can take many seconds on a loaded Mac; give it room before failing,
115
- // within the caller's budget. LC_ALL=C keeps `ps -o lstart` parseable and
116
- // TZ=UTC makes it the same string for every caller, whatever its time zone.
117
- const SUBPROCESS_TIMEOUT_MS = 30000;
118
- const START_TIME_SLACK_MS = 2000;
119
-
120
- function defaultExec(command, args, { timeoutMs = SUBPROCESS_TIMEOUT_MS } = {}) {
121
- return execFileSync(command, args, {
122
- encoding: 'utf8',
123
- stdio: ['ignore', 'pipe', 'ignore'],
124
- timeout: Math.max(1, timeoutMs),
125
- // SIGKILL: a timed-out lsof must not linger past the caller's budget.
126
- killSignal: 'SIGKILL',
127
- maxBuffer: 16 * 1024 * 1024,
128
- env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
129
- });
130
- }
131
-
132
- // The time left before `deadline` for one ownership subprocess; a spent budget
133
- // is a specific error instead of a generic watchdog kill later.
134
- // Not yet ready rather than refused: a browser that has just opened its port
135
- // may not have written its SingletonLock or opened its profile yet.
136
- function settling(message) {
137
- return Object.assign(new Error(message), { settling: true });
138
- }
139
-
140
- function budget(deadline, what) {
141
- const left = deadline - Date.now();
142
- if (left <= 0) throw Object.assign(new Error(`${what} ran out of its time budget.`), { code: 'CDP_TIMEOUT' });
143
- return Math.min(SUBPROCESS_TIMEOUT_MS, left);
144
- }
145
-
146
- // lsof exits 1 when nothing listens. A missing lsof must not read as "port
147
- // free": callers would then start or load into a browser blind.
148
- function cdpListenerPids(port, exec = defaultExec, { deadline = Date.now() + 2 * SUBPROCESS_TIMEOUT_MS } = {}) {
149
- const what = `Inspecting CDP port ${port} with lsof`;
150
- const run = () => exec('lsof', ['-nP', `-iTCP:${Number(port)}`, '-sTCP:LISTEN', '-t'], { timeoutMs: budget(deadline, what) });
151
- let output = '';
152
- try {
153
- try {
154
- output = run();
155
- } catch (error) {
156
- // One retry when lsof was killed by its timeout (heavily loaded host).
157
- if (!error.signal) throw error;
158
- output = run();
159
- }
160
- } catch (error) {
161
- if (error.code === 'ENOENT') {
162
- throw new Error('lsof is required to prove which process holds a CDP port, and it is not on PATH. Next: install lsof (macOS ships it in /usr/sbin) and rerun.');
163
- }
164
- if (error.code === 'CDP_TIMEOUT') throw error;
165
- if (error.status === 1) return [];
166
- throw new Error(`lsof could not inspect CDP port ${port}: ${error.message}`);
167
- }
168
- return String(output)
169
- .split(/\s+/u)
170
- .map((value) => Number.parseInt(value, 10))
171
- .filter((pid) => Number.isInteger(pid) && pid > 0);
172
- }
173
-
174
- // One spelling per profile: absolute, symlinks resolved, no trailing slash.
175
- function canonicalProfile(profile) {
176
- const resolved = path.resolve(String(profile));
177
- let real = resolved;
178
- try {
179
- real = fs.realpathSync(resolved);
180
- } catch {
181
- // Not created yet: the resolved path is the canonical spelling.
182
- }
183
- let canonical = real;
184
- while (canonical.length > 1 && canonical.endsWith(path.sep)) canonical = canonical.slice(0, -1);
185
- return canonical;
186
- }
187
-
188
- // Chrome's process singleton: <user-data-dir>/SingletonLock is a symlink to
189
- // "<host>-<pid>" naming the browser process that holds the profile. Unlike a
190
- // command line, it cannot be spoofed by another profile whose path merely
191
- // starts with, or contains, this one. Returns the host, pid and the time the
192
- // lock was written.
193
- function profileLock(profile) {
194
- const lockPath = path.join(profile, 'SingletonLock');
195
- try {
196
- const target = fs.readlinkSync(lockPath);
197
- const dash = target.lastIndexOf('-');
198
- const pid = Number(target.slice(dash + 1));
199
- if (dash <= 0 || !Number.isInteger(pid) || pid <= 0) return null;
200
- return { host: target.slice(0, dash), pid, writtenAtMs: fs.lstatSync(lockPath).mtimeMs };
201
- } catch {
202
- return null;
203
- }
204
- }
205
-
206
- function profileLockPid(profile) {
207
- return profileLock(profile)?.pid ?? null;
208
- }
209
-
210
- function processStartTime(pid, exec = defaultExec, { timeoutMs = SUBPROCESS_TIMEOUT_MS } = {}) {
211
- try {
212
- return String(exec('ps', ['-o', 'lstart=', '-p', String(pid)], { timeoutMs })).trim() || null;
213
- } catch (error) {
214
- if (error.signal) {
215
- throw Object.assign(new Error(`Reading the start time of pid ${pid} ran out of its time budget.`), { code: 'CDP_TIMEOUT' });
216
- }
217
- return null;
218
- }
219
- }
220
-
221
- // Whether `pid` has a file open inside `profile` (Chrome keeps its profile
222
- // databases open for its whole life). A process that merely reuses the pid
223
- // of a dead browser holds nothing there.
224
- function holdsProfileOpen(pid, profile, exec, timeoutMs) {
225
- let output = '';
226
- try {
227
- output = String(exec('lsof', ['-nP', '-p', String(pid), '-Fn'], { timeoutMs }));
228
- } catch (error) {
229
- if (error.code === 'ENOENT') throw error;
230
- if (error.signal) {
231
- throw Object.assign(new Error(`Listing the files pid ${pid} holds open ran out of its time budget.`), { code: 'CDP_TIMEOUT' });
232
- }
233
- // lsof exits 1 when some files could not be listed; use what it printed.
234
- output = String(error.stdout ?? '');
235
- }
236
- const prefix = `n${profile}${path.sep}`;
237
- return output.split('\n').some((line) => line.startsWith(prefix));
238
- }
239
-
240
- // The browser on `port` is ours only if every listener is the process that
241
- // holds this profile's SingletonLock, and that process is the one that wrote
242
- // the lock: the lock names this host, the process started no later than the
243
- // lock was written (a pid reused after the browser died started later), and
244
- // it has files open inside the profile. Returns its identity (pid + start
245
- // time) so a connection can be bound to it and re-checked; refuses anything
246
- // else (another slot, the operator's own Chrome, an unknown process).
247
- function cdpOwner(port, profile, { exec = defaultExec, timeoutMs = 2 * SUBPROCESS_TIMEOUT_MS + 10000 } = {}) {
248
- const deadline = Date.now() + timeoutMs;
249
- const canonical = canonicalProfile(profile);
250
- const pids = cdpListenerPids(port, exec, { deadline });
251
- if (pids.length === 0) throw settling(`No process listens on CDP port ${port}; refusing to load the extension.`);
252
- const lock = profileLock(canonical);
253
- if (lock === null) {
254
- throw settling(`No browser holds the profile ${canonical} (no SingletonLock); refusing to load the extension into the browser on CDP port ${port}.`);
255
- }
256
- const lockPid = lock.pid;
257
- const foreign = pids.filter((pid) => pid !== lockPid);
258
- if (foreign.length > 0 || !pids.includes(lockPid)) {
259
- throw new Error(
260
- `CDP port ${port} is held by pid ${pids.join(', ')}, but the browser holding ${canonical} is pid ${lockPid}; ` +
261
- 'refusing to load the extension into a browser this launch did not start.',
262
- );
263
- }
264
- if (lock.host !== os.hostname()) {
265
- throw new Error(`The SingletonLock of ${canonical} names host ${lock.host}, not ${os.hostname()}; refusing to load the extension.`);
266
- }
267
- const startedAt = processStartTime(lockPid, exec, { timeoutMs: budget(deadline, `Reading the start time of pid ${lockPid}`) });
268
- if (!startedAt) throw new Error(`Could not read the start time of pid ${lockPid}; refusing to load the extension.`);
269
- const startedMs = Date.parse(`${startedAt} UTC`);
270
- if (!Number.isFinite(startedMs)) throw new Error(`Could not parse the start time of pid ${lockPid} (${startedAt}); refusing to load the extension.`);
271
- // lstart truncates to the second, and on Linux it is derived from boot time
272
- // and can read a little late after clock slews; allow that slack. A reused
273
- // pid starts long after the dead browser wrote its lock.
274
- if (startedMs > lock.writtenAtMs + START_TIME_SLACK_MS) {
275
- throw new Error(
276
- `pid ${lockPid} started after the SingletonLock of ${canonical} was written, so it is not the browser that holds the profile (a reused pid); ` +
277
- 'refusing to load the extension.',
278
- );
279
- }
280
- if (!holdsProfileOpen(lockPid, canonical, exec, budget(deadline, `Listing the files pid ${lockPid} holds open`))) {
281
- throw settling(`pid ${lockPid} has no file open in ${canonical}, so it is not the browser that holds the profile; refusing to load the extension.`);
282
- }
283
- return { pid: lockPid, startedAt, profile: canonical, cdpPort: Number(port) };
284
- }
285
-
286
- // Re-prove ownership after connecting: same pid, same start time (no pid reuse,
287
- // no browser swapped in between the check and the connection).
288
- function assertSameOwner(owner, { exec = defaultExec, timeoutMs } = {}) {
289
- const current = cdpOwner(owner.cdpPort, owner.profile, { exec, ...(timeoutMs ? { timeoutMs } : {}) });
290
- if (current.pid !== owner.pid || current.startedAt !== owner.startedAt) {
291
- throw new Error(`CDP port ${owner.cdpPort} changed hands (pid ${owner.pid} → ${current.pid}); refusing to load the extension.`);
292
- }
293
- return current;
294
- }
295
-
296
- function assertCdpOwnedByProfile(port, profile, options = {}) {
297
- return cdpOwner(port, profile, options);
298
- }
299
-
300
- // The owning browser's identity right after a launch: retries only while the
301
- // browser is still settling (no listener, lock or open profile yet), and every
302
- // attempt and wait is clipped to the remaining `timeoutMs`. Synchronous for the
303
- // sync launchers.
304
- function waitForCdpOwner(port, profile, { timeoutMs = 30000, exec = defaultExec } = {}) {
305
- const deadline = Date.now() + timeoutMs;
306
- for (;;) {
307
- const left = deadline - Date.now();
308
- if (left <= 0) throw Object.assign(new Error(`The browser on CDP port ${port} did not prove it holds ${profile} within ${timeoutMs}ms.`), { code: 'CDP_TIMEOUT' });
309
- try {
310
- return cdpOwner(port, profile, { exec, timeoutMs: left });
311
- } catch (error) {
312
- if (!error.settling || Date.now() + 200 >= deadline) throw error;
313
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
314
- }
315
- }
316
- }
317
-
318
- // ---- extension loading ----------------------------------------------------
319
-
320
- // Load an unpacked extension through a browser-level CDP `send` and wait until
321
- // one of its targets (service worker / background) is up.
322
- async function loadUnpackedExtension(send, extensionDir, { expectedId = null, timeoutMs = 60000 } = {}) {
323
- const deadline = Date.now() + timeoutMs;
324
- const remaining = () => Math.max(1, deadline - Date.now());
325
- const dir = path.resolve(extensionDir);
326
- let id = null;
327
- let lastError = null;
328
- while (!id && Date.now() < deadline) {
329
- try {
330
- id = (await send('Extensions.loadUnpacked', { path: dir }, undefined, Math.min(remaining(), DEFAULT_COMMAND_TIMEOUT_MS))).id;
331
- } catch (error) {
332
- lastError = error;
333
- if (/socket closed|client closed/u.test(error.message)) break;
334
- await sleep(Math.min(500, remaining()));
335
- }
336
- }
337
- if (!id) {
338
- throw new Error(`Extensions.loadUnpacked failed for ${dir}: ${lastError?.message ?? 'timed out'}. The browser must run with --enable-unsafe-extension-debugging.`);
339
- }
340
- if (expectedId && id !== expectedId) {
341
- throw new Error(`Extensions.loadUnpacked returned extension id ${id}, but the manifest key gives ${expectedId}.`);
342
- }
343
- const prefix = `chrome-extension://${id}/`;
344
- while (Date.now() < deadline) {
345
- const { targetInfos } = await send('Target.getTargets', {}, undefined, Math.min(remaining(), DEFAULT_COMMAND_TIMEOUT_MS));
346
- if (targetInfos.some((target) => target.url.startsWith(prefix) && ['service_worker', 'background_page', 'page', 'other'].includes(target.type))) {
347
- return { id };
348
- }
349
- await sleep(Math.min(250, remaining()));
350
- }
351
- throw new Error(`Extension ${id} loaded but no extension target started within ${timeoutMs}ms.`);
352
- }
353
-
354
- // Branded Chrome runs without --disable-extensions-except (it would disable the
355
- // CDP-loaded wallet), so External Extensions and enterprise force-installs
356
- // reach a slot profile. chrome://extensions lists user extensions (component
357
- // extensions excluded). Every other extension the user may modify is disabled
358
- // in the slot profile, matching what --disable-extensions-except does for
359
- // Chrome for Testing. Extensions an enterprise policy pins (mustRemainInstalled,
360
- // not user-modifiable) cannot be disabled by any client; they are returned so
361
- // the caller records them in the run evidence. Anything left enabled that is
362
- // neither the wallet nor policy-pinned fails the load.
363
- const LIST_EXTENSIONS = 'new Promise((resolve, reject) => chrome.developerPrivate.getExtensionsInfo({ includeDisabled: true, includeTerminated: true }, (items) => chrome.runtime.lastError ? reject(new Error(chrome.runtime.lastError.message)) : resolve(items.map(({ id, name, location, state, mustRemainInstalled, userMayModify }) => ({ id, name, location, state, mustRemainInstalled, userMayModify })))))';
364
-
365
- const SET_DISABLED = 'function (id) { return new Promise((resolve) => chrome.management.setEnabled(id, false, () => resolve(chrome.runtime.lastError ? chrome.runtime.lastError.message : "ok"))); }';
366
-
367
- async function isolateWalletExtension(send, walletId, { timeoutMs = 15000 } = {}) {
368
- const deadline = Date.now() + timeoutMs;
369
- const left = () => Math.max(1, deadline - Date.now());
370
- // A background tab (or, in a browser with no window yet, a background
371
- // window): the extensions page must never take the operator's focus.
372
- const { targetInfos } = await send('Target.getTargets', {}, undefined, left());
373
- const hasWindow = (targetInfos || []).some((target) => target.type === 'page');
374
- const { targetId } = await send('Target.createTarget', { url: 'chrome://extensions/', background: true, ...(hasWindow ? {} : { newWindow: true }) }, undefined, left());
375
- try {
376
- const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }, undefined, left());
377
- const evaluate = async (expression) => {
378
- const { result, exceptionDetails } = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId, Math.max(1, deadline - Date.now()));
379
- if (exceptionDetails) throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text);
380
- return result.value;
381
- };
382
- const list = async () => {
383
- let lastError = null;
384
- while (Date.now() < deadline) {
385
- try {
386
- return (await evaluate(LIST_EXTENSIONS)).filter((entry) => entry.id !== walletId);
387
- } catch (error) {
388
- lastError = error;
389
- await sleep(Math.min(250, left()));
390
- }
391
- }
392
- throw new Error(`Could not list installed extensions: ${lastError?.message ?? 'timed out'}`);
393
- };
394
- const policyPinned = (entry) => entry.mustRemainInstalled === true && entry.userMayModify === false;
395
- const describe = ({ id, name, location }) => ({ id, name, location });
396
- const disabled = [];
397
- // The page's global object, so the id travels as a CDP call argument
398
- // rather than as text spliced into evaluated code.
399
- const { result: pageGlobal } = await send('Runtime.evaluate', { expression: 'globalThis' }, sessionId, Math.max(1, deadline - Date.now()));
400
- for (const entry of await list()) {
401
- if (entry.state !== 'ENABLED' || policyPinned(entry)) continue;
402
- const { result, exceptionDetails } = await send('Runtime.callFunctionOn', {
403
- objectId: pageGlobal.objectId,
404
- functionDeclaration: SET_DISABLED,
405
- arguments: [{ value: entry.id }],
406
- awaitPromise: true,
407
- returnByValue: true,
408
- }, sessionId, Math.max(1, deadline - Date.now()));
409
- if (!exceptionDetails && result.value === 'ok') disabled.push(describe(entry));
410
- }
411
- const remaining = await list();
412
- const stillEnabled = remaining.filter((entry) => entry.state === 'ENABLED' && !policyPinned(entry));
413
- if (stillEnabled.length > 0) {
414
- throw new Error(
415
- `The slot profile keeps user extensions besides the wallet enabled: ${stillEnabled.map((entry) => `${entry.id} (${entry.name}, ${entry.location})`).join(', ')}. ` +
416
- 'Remove them from External Extensions, or use RECIPE_HARNESS_BROWSER=cft.',
417
- );
418
- }
419
- return { disabled, policyPinned: remaining.filter((entry) => entry.state === 'ENABLED' && policyPinned(entry)).map(describe) };
420
- } finally {
421
- await send('Target.closeTarget', { targetId }, undefined, Math.min(5000, left())).catch(() => {});
422
- }
423
- }
424
-
425
- // A new window that does not take the operator's focus: a browser started in
426
- // the background (no startup window) opens its first window this way.
427
- async function openBackgroundWindow(send, url, timeoutMs) {
428
- return send('Target.createTarget', { url, newWindow: true, background: true }, undefined, timeoutMs);
429
- }
430
-
431
- const isBlankPage = (target) => target.type === 'page' && (target.url === 'about:blank' || target.url.startsWith('chrome://newtab') || target.url.startsWith('chrome://new-tab-page'));
432
-
433
- // A browser started without a window gets a blank background window, so the
434
- // first window never takes the operator's focus and nothing is navigated yet.
435
- async function ensureBackgroundWindow(send, remaining) {
436
- const { targetInfos } = await send('Target.getTargets', {}, undefined, remaining());
437
- if ((targetInfos || []).some((target) => target.type === 'page')) return;
438
- await openBackgroundWindow(send, 'about:blank', remaining());
439
- }
440
-
441
- // Point the launch tab at `url` once the extension exists: the tab opened at
442
- // launch would otherwise show an error page for chrome-extension:// URLs.
443
- async function openUrl(send, url, remaining) {
444
- const { targetInfos } = await send('Target.getTargets', {}, undefined, remaining());
445
- const blank = (targetInfos || []).find(isBlankPage);
446
- if (!blank) {
447
- await openBackgroundWindow(send, url, remaining());
448
- return;
449
- }
450
- const { sessionId } = await send('Target.attachToTarget', { targetId: blank.targetId, flatten: true }, undefined, remaining());
451
- await send('Page.navigate', { url }, sessionId, remaining());
452
- await send('Target.detachFromTarget', { sessionId }, undefined, remaining());
453
- }
454
-
455
- // Prove the browser on `port` holds `profile`, bind the connection to that
456
- // process, load the extension, check its id against the manifest key, isolate
457
- // it from other user extensions, then open `url` — all inside one deadline.
458
- // Returns { id, otherExtensions: { disabled, policyPinned }, owner } for the
459
- // caller's browser record. Callers own stopping the browser when this throws.
460
- const OWNER_SETTLE_MS = 5000;
461
-
462
- async function loadUnpackedOverPort(port, extensionDir, {
463
- profile,
464
- expectedId = extensionIdFromExtensionDir(extensionDir) || null,
465
- url = null,
466
- timeoutMs = 60000,
467
- exec = defaultExec,
468
- } = {}) {
469
- if (!profile) throw new Error('loadUnpackedOverPort requires the slot profile that owns the CDP port.');
470
- const deadline = Date.now() + timeoutMs;
471
- let step = 'proving the CDP port owner';
472
- const remaining = () => {
473
- const left = deadline - Date.now();
474
- if (left <= 0) throw Object.assign(deadlineError(`Loading the extension on CDP port ${port} (${step})`, timeoutMs), { budgetSpent: true });
475
- return left;
476
- };
477
- let client = null;
478
- try {
479
- // A browser that has just opened its port may not have written its lock or
480
- // opened its profile yet: while it is settling, a new attempt starts only
481
- // within OWNER_SETTLE_MS. Each attempt is clipped to the load budget, not to
482
- // that window: lsof alone can take tens of seconds on a loaded host.
483
- const settleUntil = Math.min(deadline, Date.now() + OWNER_SETTLE_MS);
484
- let owner = null;
485
- while (!owner) {
486
- try {
487
- owner = cdpOwner(port, profile, { exec, timeoutMs: remaining() });
488
- } catch (error) {
489
- if (!error.settling || Date.now() + 250 >= settleUntil) throw error;
490
- await sleep(250);
491
- // A late wake must not start an attempt past the settle window.
492
- if (Date.now() >= settleUntil) throw error;
493
- }
494
- }
495
- step = 'connecting';
496
- client = await connectBrowserCdp(port, { timeoutMs: remaining() });
497
- step = 're-proving the CDP port owner';
498
- assertSameOwner(owner, { exec, timeoutMs: remaining() });
499
- step = 'Extensions.loadUnpacked';
500
- const loaded = await loadUnpackedExtension(client.send, extensionDir, { expectedId, timeoutMs: remaining() });
501
- // A browser started without a window gets a blank background window
502
- // first; other extensions are disabled before the start page (a dapp, or
503
- // the wallet home) loads in it.
504
- step = 'opening a background window';
505
- await ensureBackgroundWindow(client.send, remaining);
506
- step = 'isolating the wallet from other extensions';
507
- const otherExtensions = await isolateWalletExtension(client.send, loaded.id, { timeoutMs: remaining() });
508
- step = 'opening the start page';
509
- if (url) await openUrl(client.send, url, remaining);
510
- remaining();
511
- return { ...loaded, otherExtensions, owner };
512
- } catch (error) {
513
- // Whatever step was cut short, a spent budget reads as the budget and names the step.
514
- if (!error.budgetSpent && (Date.now() >= deadline || error.code === 'CDP_TIMEOUT')) {
515
- const what = Date.now() >= deadline ? `ran out of its ${timeoutMs}ms budget` : 'timed out';
516
- throw Object.assign(new Error(`Loading the extension on CDP port ${port} ${what} while ${step}: ${error.message}`), { code: 'CDP_TIMEOUT', cause: error });
517
- }
518
- throw error;
519
- } finally {
520
- client?.close();
521
- }
522
- }
523
-
524
- module.exports = {
525
- DEFAULT_COMMAND_TIMEOUT_MS,
526
- START_TIME_SLACK_MS,
527
- assertCdpOwnedByProfile,
528
- assertSameOwner,
529
- canonicalProfile,
530
- cdpListenerPids,
531
- cdpOwner,
532
- processStartTime,
533
- waitForCdpOwner,
534
- connectBrowserCdp,
535
- expectedExtensionId: (extensionDir) => extensionIdFromExtensionDir(extensionDir) || null,
536
- loadUnpackedExtension,
537
- loadUnpackedOverPort,
538
- ensureBackgroundWindow,
539
- isolateWalletExtension,
540
- openBackgroundWindow,
541
- openUrl,
542
- };