@deeeed/metamask-harness 0.69.0 → 0.70.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 +10 -0
- package/README.md +97 -0
- package/adapters/extension/ensure-browser.sh +85 -15
- package/adapters/extension/inject.mjs +2 -0
- package/adapters/extension/launch-browser.cjs +140 -18
- package/adapters/extension/lib/macos-focus.cjs +67 -195
- package/adapters/extension/live.sh +29 -48
- package/adapters/manifest.json +20 -4
- package/adapters/shared/browser-cdp.cjs +528 -0
- package/adapters/shared/browser-resolver.cjs +996 -0
- package/adapters/shared/open-debug.mjs +6 -1
- package/dist/commands/run-engine.js +8 -1
- package/dist/commands/run-report.js +96 -3
- package/dist/commands/run.js +2 -1
- package/dist/doctor.js +48 -1
- package/docs/QA.md +3 -0
- package/library/actions/extension/platform/cdp.mjs +87 -46
- package/package.json +1 -1
- package/scripts/site-contrast.mjs +16 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.70.0 - 2026-10-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Choose the Extension browser with `RECIPE_HARNESS_BROWSER=auto|cft|chrome|<path>` (default `auto`). Auto probe-launches the Playwright Chrome for Testing build the way the launcher starts it (launch method and headful/headless), requires a painted frame and a screenshot, and otherwise uses branded Google Chrome, loading the unpacked extension over CDP (`Extensions.loadUnpacked`) because branded Chrome ignores `--load-extension`. Crash verdicts are cached per OS build and browser binary; timeouts are retried after 30 minutes; concurrent probes are serialized through a directory of numbered lock tickets next to the cache, which grows by two small files per probe (the README gives a cleanup command for an idle machine). On a Mac whose Google Chrome is managed by policy (forced extensions or forced sign-in), auto uses Chrome for Testing and never falls back to the managed Chrome; doctor names the forced extensions. A slot profile keeps the kind of browser it was created with until the profile is reset: a Chrome for Testing profile follows the checkout's current build and moves to Google Chrome only on a proven crash; a Google Chrome or explicit-path profile keeps that binary, and an explicitly chosen binary (including a launcher given `--chrome-bin` directly) stays chosen across later auto launches. CDP loading runs only in the browser process that holds the slot profile's SingletonLock, wrote it on this host, and has the profile open (re-checked after connecting, within one deadline), checks the id against the manifest key, disables other user extensions in the slot profile (enterprise-pinned ones are recorded), and stops the browser on failure. `cft` forces the previous path; `RECIPE_HARNESS_CHROME_BIN` keeps its meaning. The chosen browser, version, source and loading method appear in `doctor --adapter extension`, the launch logs and each run's `product-provenance.json`, bound to the CDP port the run executed on (including a node's `cdp_port`, with `{{params.x}}` resolved from the run's params).
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Shorten tutorials and correct template selection, report outputs, and static-review ownership; illustrate task progress with the fix-bug screenshot and watcher command.
|
|
14
|
+
|
|
5
15
|
## 0.69.0 - 2026-09-30
|
|
6
16
|
|
|
7
17
|
### Changed
|
package/README.md
CHANGED
|
@@ -113,6 +113,103 @@ Extension `launch` keeps its incremental watcher running. Refresh the active
|
|
|
113
113
|
page after a successful rebuild; use `launch --build` only for an explicit
|
|
114
114
|
production-like LavaMoat rebuild.
|
|
115
115
|
|
|
116
|
+
### Extension browser
|
|
117
|
+
|
|
118
|
+
`RECIPE_HARNESS_BROWSER` picks the browser that hosts the unpacked extension:
|
|
119
|
+
|
|
120
|
+
| Value | Browser |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `auto` (default) | The checkout's Playwright Chrome for Testing when it starts and paints on this machine, else Google Chrome |
|
|
123
|
+
| `cft` | Chrome for Testing, never probed: the behaviour before auto existed |
|
|
124
|
+
| `chrome` | Google Chrome |
|
|
125
|
+
| `/abs/path` | That executable |
|
|
126
|
+
|
|
127
|
+
`RECIPE_HARNESS_CHROME_BIN=/path` still overrides everything and is used as
|
|
128
|
+
given. Google Chrome ignores `--load-extension`, so the harness loads the build
|
|
129
|
+
over CDP (`Extensions.loadUnpacked`, with `--enable-unsafe-extension-debugging`)
|
|
130
|
+
into the slot's own profile and checks the id against the manifest key. Other
|
|
131
|
+
user extensions in that profile are disabled, as `--disable-extensions-except`
|
|
132
|
+
does for Chrome for Testing; extensions an enterprise policy pins cannot be
|
|
133
|
+
disabled, so the launch warns and records them with the run evidence.
|
|
134
|
+
|
|
135
|
+
In `auto`, a launch with a new profile probe-launches Chrome for Testing the
|
|
136
|
+
way the launcher starts it (headful, through Launch Services on macOS). The
|
|
137
|
+
verdict lives in `~/.cache/mm-harness/browser-probe.json`: a crash is kept
|
|
138
|
+
until the OS build or browser changes; a probe that only timed out is retried
|
|
139
|
+
after 30 minutes. Concurrent launches wait for one probe per browser build
|
|
140
|
+
(a `<cache>.<key>.locks/` directory of numbered tickets next to the cache; a
|
|
141
|
+
holder older than 3 minutes counts as gone, so a probe that outlives it can
|
|
142
|
+
overlap the next one, and during an upgrade from a build that used a `.lock`
|
|
143
|
+
file one duplicate probe is possible). Tickets are never removed by the
|
|
144
|
+
harness: each probe adds two small files, and a crashed holder's unreleased
|
|
145
|
+
ticket stays (it no longer blocks once its process is gone). To reclaim the
|
|
146
|
+
space, remove the lock directories while no launch or doctor is running:
|
|
147
|
+
`rm -r ~/.cache/mm-harness/browser-probe.json.*.locks`.
|
|
148
|
+
On a cold cache the probe can take up to about 80 seconds and
|
|
149
|
+
shows a small (about 480×360) browser window; `doctor --adapter extension`
|
|
150
|
+
runs the same probe.
|
|
151
|
+
|
|
152
|
+
On a Mac whose Google Chrome is managed by policy (force-installed extensions
|
|
153
|
+
in `/Library/Managed Preferences/…/com.google.Chrome.plist`, or forced
|
|
154
|
+
sign-in), `auto` uses Chrome for Testing: it does not read those policies, so
|
|
155
|
+
no forced extension can open windows in the slot. If Chrome for Testing does
|
|
156
|
+
not start and paint there, the launch stops and names the two ways forward
|
|
157
|
+
(`RECIPE_HARNESS_BROWSER=chrome`, accepting the forced extensions' windows, or
|
|
158
|
+
a less loaded machine) instead of falling back to the managed Chrome. Doctor
|
|
159
|
+
lists the forced extensions. A slot profile created on the managed Chrome keeps
|
|
160
|
+
it, with a warning to reset the profile.
|
|
161
|
+
|
|
162
|
+
A slot then keeps the kind of browser its profile was created with, until the
|
|
163
|
+
profile is reset (`mm-harness fixtures set` or `fixtures reset`):
|
|
164
|
+
|
|
165
|
+
- created on Chrome for Testing: the checkout's current Chrome for Testing
|
|
166
|
+
(a Playwright upgrade is followed and re-probed); it moves to Google Chrome
|
|
167
|
+
only once that build is proven to crash, never back;
|
|
168
|
+
- created on Google Chrome or an explicit path: that binary; if it is removed,
|
|
169
|
+
the launch fails and says how to recover rather than switching browsers;
|
|
170
|
+
- created with `RECIPE_HARNESS_CHROME_BIN`, a path in `RECIPE_HARNESS_BROWSER`,
|
|
171
|
+
or a launcher given `--chrome-bin` directly:
|
|
172
|
+
that exact binary on every later `auto` launch, even when it is a Chrome for
|
|
173
|
+
Testing build.
|
|
174
|
+
|
|
175
|
+
A preserved slot stuck on Chrome for Testing that "renders no frames" (a
|
|
176
|
+
transient verdict, which keeps it on Chrome for Testing) moves to Google Chrome
|
|
177
|
+
without a profile reset: launch it once with `RECIPE_HARNESS_BROWSER=chrome`.
|
|
178
|
+
The profile is then a Google Chrome profile and later `auto` launches keep
|
|
179
|
+
Google Chrome. Going the other way opens a profile in an older browser, so
|
|
180
|
+
reset the profile instead.
|
|
181
|
+
|
|
182
|
+
Profiles created before this browser record existed have no record, so their
|
|
183
|
+
first `auto` launch probes like a new profile and may move to Google Chrome.
|
|
184
|
+
|
|
185
|
+
`mm-harness doctor --adapter extension` shows the current choice; each launch
|
|
186
|
+
writes it to `temp/recipe/runtime/browser-resolution.json` with the browser
|
|
187
|
+
process identity, and run evidence (`product-provenance.json`) names the
|
|
188
|
+
browser the run drove only after checking that exact process still serves the
|
|
189
|
+
CDP port the run executed on. The owner of a CDP port is the process named by
|
|
190
|
+
the profile's `SingletonLock`, and only if the lock names this host, the
|
|
191
|
+
process started before the lock was written (a reused pid did not), and it
|
|
192
|
+
holds files open in the profile. That proof uses `lsof`, which macOS ships;
|
|
193
|
+
without it the launch stops with an explanation. Start times are compared exactly as
|
|
194
|
+
`ps -o lstart` prints them in UTC, so a record written by an earlier harness
|
|
195
|
+
build (local time) binds nothing (`boundTo: none`) until the slot relaunches.
|
|
196
|
+
|
|
197
|
+
On macOS the slot browser starts in the background with no startup window,
|
|
198
|
+
and its first window opens over CDP as a background window. After the launch
|
|
199
|
+
settles there is one focus check: only if the slot browser's own process is
|
|
200
|
+
frontmost does the previously focused app get the front back, once
|
|
201
|
+
(`MM_HARNESS_FOCUS_HOLD=0` turns this off). A click on the slot window right
|
|
202
|
+
after launch can therefore be undone once.
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
# Roll back to Chrome for Testing with a fresh slot profile (a profile Google
|
|
206
|
+
# Chrome has opened is not reopened in the older Chrome for Testing). Keep
|
|
207
|
+
# RECIPE_HARNESS_BROWSER=cft set for later launches too: on a plain auto launch
|
|
208
|
+
# the profile follows Chrome for Testing only until a probe proves it dies.
|
|
209
|
+
RECIPE_HARNESS_BROWSER=cft mm-harness fixtures set
|
|
210
|
+
rm ~/.cache/mm-harness/browser-probe.json # forget probe verdicts
|
|
211
|
+
```
|
|
212
|
+
|
|
116
213
|
### Remote feature flags
|
|
117
214
|
|
|
118
215
|
`--remote-flag KEY=VALUE[,KEY=VALUE]` pins a remote feature flag for the launch,
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# ensure-browser.sh — ensure the slot's
|
|
2
|
+
# ensure-browser.sh — ensure the slot's browser is running
|
|
3
3
|
# (auto-detects the slot, kills any existing browser by recorded PID,
|
|
4
4
|
# relaunches against the existing dist/profile/port; requires an
|
|
5
5
|
# already-prepared profile). Project-specific (MetaMask Extension).
|
|
6
6
|
#
|
|
7
7
|
# Purpose:
|
|
8
8
|
# Restarts the slot's Chromium against the existing profile + dist build,
|
|
9
|
-
# re-resolves the extension id via the installed runner, and unlocks.
|
|
9
|
+
# re-resolves the extension id via the installed runner, and unlocks. The
|
|
10
|
+
# browser comes from the shared resolver (RECIPE_HARNESS_BROWSER); branded
|
|
11
|
+
# Chrome gets the extension over CDP instead of --load-extension.
|
|
10
12
|
#
|
|
11
13
|
# Inputs (flags / env):
|
|
12
14
|
# --slot-id <id> (default <host>-<repo-basename>), --repo <checkout>,
|
|
@@ -16,7 +18,8 @@
|
|
|
16
18
|
# browser.pid/webpack.pid when omitted.
|
|
17
19
|
#
|
|
18
20
|
# Outputs:
|
|
19
|
-
# <runtime-dir>/{browser.pid,chromium.pid,extension.id};
|
|
21
|
+
# <runtime-dir>/{browser.pid,chromium.pid,extension.id,browser-resolution.json};
|
|
22
|
+
# progress on stdout.
|
|
20
23
|
# Exit 0 — browser reopened and ready; 1 — preflight/launch failure.
|
|
21
24
|
#
|
|
22
25
|
# Never touches: other slots' browsers (kills by recorded PID/profile only);
|
|
@@ -301,8 +304,21 @@ const {
|
|
|
301
304
|
runtimeIdentityArgs,
|
|
302
305
|
writeRuntimeIdentity,
|
|
303
306
|
} = require(path.join('${SCRIPT_DIR}', 'lib/chrome-args.cjs'));
|
|
304
|
-
const { captureMacFrontmost, macBackgroundOpenArgs,
|
|
307
|
+
const { captureMacFrontmost, macBackgroundOpenArgs, restoreFrontmostIfOurs } = require(path.join('${SCRIPT_DIR}', 'lib/macos-focus.cjs'));
|
|
305
308
|
const { stopProfileProcessesSync } = require(path.join('${SCRIPT_DIR}', 'lib/validation-process-ownership.cjs'));
|
|
309
|
+
const {
|
|
310
|
+
CDP_LOAD_UNPACKED,
|
|
311
|
+
HEADFUL,
|
|
312
|
+
LAUNCH_SERVICES,
|
|
313
|
+
extensionLaunchArgs,
|
|
314
|
+
loadUnpackedOverPort,
|
|
315
|
+
waitForCdpOwner,
|
|
316
|
+
playwrightChromiumExecutable,
|
|
317
|
+
resolveBrowser,
|
|
318
|
+
} = require([
|
|
319
|
+
path.join('${SCRIPT_DIR}', 'lib/browser-resolver.cjs'),
|
|
320
|
+
path.join('${SCRIPT_DIR}', '../shared/browser-resolver.cjs'),
|
|
321
|
+
].find((candidate) => fs.existsSync(candidate)));
|
|
306
322
|
|
|
307
323
|
const SLOT_ID = '${SLOT_ID}';
|
|
308
324
|
const AGENT_DIR = '${AGENT_DIR}';
|
|
@@ -324,26 +340,40 @@ const resumeWebpack = () => {
|
|
|
324
340
|
};
|
|
325
341
|
|
|
326
342
|
const previousFrontmost = captureMacFrontmost();
|
|
327
|
-
|
|
328
|
-
|
|
343
|
+
let chromiumApp = null;
|
|
344
|
+
// Set once the CDP listener is proven to run with this slot's profile.
|
|
345
|
+
let browserPid = '';
|
|
346
|
+
// One check, at most one restore, and only if OUR browser (the proven CDP
|
|
347
|
+
// owner pid) holds the front; any other app in front is left alone.
|
|
329
348
|
const releaseFocusHold = () => {
|
|
330
|
-
|
|
331
|
-
preserveMacFrontmost(previousFrontmost, chromiumApp);
|
|
349
|
+
if (browserPid) restoreFrontmostIfOurs(previousFrontmost, [Number(browserPid)]);
|
|
332
350
|
};
|
|
333
351
|
|
|
334
352
|
(async () => {
|
|
353
|
+
// Reopen always reuses the slot profile, so auto keeps the browser recorded
|
|
354
|
+
// for it.
|
|
355
|
+
const resolution = await resolveBrowser({
|
|
356
|
+
cft: () => playwrightChromiumExecutable(REPO),
|
|
357
|
+
launchMethod: LAUNCH_SERVICES,
|
|
358
|
+
display: HEADFUL,
|
|
359
|
+
recorded: path.join(AGENT_DIR, 'browser-resolution.json'),
|
|
360
|
+
});
|
|
361
|
+
chromiumApp = path.dirname(path.dirname(path.dirname(resolution.bin)));
|
|
362
|
+
console.log('[reopen] Browser: ' + resolution.browser + ' ' + (resolution.version || '?') + ' (' + resolution.extensionLoading + ')');
|
|
335
363
|
const runtimeNonce = createRuntimeIdentityNonce();
|
|
336
364
|
const startedAt = Date.now();
|
|
337
365
|
const args = [
|
|
338
366
|
'--user-data-dir=' + PROFILE,
|
|
339
|
-
|
|
340
|
-
'--load-extension=' + EXTENSION,
|
|
367
|
+
...extensionLaunchArgs(EXTENSION, resolution.extensionLoading),
|
|
341
368
|
...automationRuntimeArgs(),
|
|
342
369
|
...runtimeIdentityArgs(runtimeNonce),
|
|
343
370
|
'--no-first-run',
|
|
344
371
|
'--no-default-browser-check',
|
|
345
372
|
...isolatedProfileArgs(),
|
|
346
373
|
'--window-size=420,800',
|
|
374
|
+
// Started in the background with no window (Chrome activates itself when
|
|
375
|
+
// it opens one); the first window is opened over CDP as a background window.
|
|
376
|
+
'--no-startup-window',
|
|
347
377
|
];
|
|
348
378
|
// Debug-port trio (address + port + scoped allow-origins) from the shared module
|
|
349
379
|
// so the DevTools origin allow-list can never drift away from the port; without
|
|
@@ -352,7 +382,6 @@ const releaseFocusHold = () => {
|
|
|
352
382
|
args.push(...remoteDebuggingArgs(CDP_PORT));
|
|
353
383
|
}
|
|
354
384
|
|
|
355
|
-
focusHold = startMacFocusHold(previousFrontmost, chromiumApp);
|
|
356
385
|
execFileSync('open', macBackgroundOpenArgs(chromiumApp, args), { stdio: 'ignore' });
|
|
357
386
|
for (let i = 0; i < 60; i++) {
|
|
358
387
|
try {
|
|
@@ -394,7 +423,7 @@ const releaseFocusHold = () => {
|
|
|
394
423
|
'Next: install or restore lsof, then rerun: mm-harness launch --adapter extension --build'
|
|
395
424
|
);
|
|
396
425
|
}
|
|
397
|
-
|
|
426
|
+
browserPid = '';
|
|
398
427
|
for (const pid of listenerPids) {
|
|
399
428
|
try {
|
|
400
429
|
const command = execFileSync(
|
|
@@ -439,6 +468,28 @@ const releaseFocusHold = () => {
|
|
|
439
468
|
);
|
|
440
469
|
}
|
|
441
470
|
console.log('[reopen] Chromium PID ' + browserPid);
|
|
471
|
+
let cdpLoad = null;
|
|
472
|
+
if (resolution.extensionLoading === CDP_LOAD_UNPACKED) {
|
|
473
|
+
cdpLoad = await loadUnpackedOverPort(Number(CDP_PORT), EXTENSION, { profile: PROFILE });
|
|
474
|
+
console.log('[reopen] Extension loaded over CDP: ' + cdpLoad.id);
|
|
475
|
+
for (const entry of cdpLoad.otherExtensions.policyPinned) {
|
|
476
|
+
console.log('[reopen] WARN: enterprise policy keeps ' + entry.id + ' (' + entry.name + ') enabled in the slot profile');
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
let owner = cdpLoad ? cdpLoad.owner : null;
|
|
480
|
+
if (!owner) {
|
|
481
|
+
try { owner = waitForCdpOwner(Number(CDP_PORT), PROFILE, { timeoutMs: 15000 }); } catch (error) { console.log('[reopen] WARN: could not record the browser identity: ' + error.message); }
|
|
482
|
+
}
|
|
483
|
+
fs.writeFileSync(path.join(AGENT_DIR, 'browser-resolution.json'), JSON.stringify({
|
|
484
|
+
...resolution,
|
|
485
|
+
launch: {
|
|
486
|
+
pid: Number(browserPid),
|
|
487
|
+
cdpPort: Number(CDP_PORT),
|
|
488
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
489
|
+
...(owner ? { profile: owner.profile, processStartedAt: owner.startedAt } : {}),
|
|
490
|
+
...(cdpLoad ? { extensionId: cdpLoad.id, otherExtensions: cdpLoad.otherExtensions } : {}),
|
|
491
|
+
},
|
|
492
|
+
}, null, 2) + '\n');
|
|
442
493
|
|
|
443
494
|
// Detect extension ID via the recipe-runner — the single source of truth.
|
|
444
495
|
// Deterministic id from the loaded dist's manifest key; replaces a fragile
|
|
@@ -458,7 +509,13 @@ const releaseFocusHold = () => {
|
|
|
458
509
|
|
|
459
510
|
// Navigate to MetaMask home
|
|
460
511
|
const homeUrl = 'chrome-extension://' + extId + '/home.html';
|
|
461
|
-
|
|
512
|
+
let page = ctx.pages().find(p => p.url().includes('chrome-extension://')) || ctx.pages()[0];
|
|
513
|
+
if (!page) {
|
|
514
|
+
const session = await browser.newBrowserCDPSession();
|
|
515
|
+
const opened = ctx.waitForEvent('page', { timeout: 15000 });
|
|
516
|
+
await session.send('Target.createTarget', { url: 'about:blank', newWindow: true, background: true });
|
|
517
|
+
page = await opened;
|
|
518
|
+
}
|
|
462
519
|
await page.goto(homeUrl, { waitUntil: 'load', timeout: 15000 });
|
|
463
520
|
|
|
464
521
|
// Set window title to slot ID (persists across SPA navigations)
|
|
@@ -522,9 +579,8 @@ const releaseFocusHold = () => {
|
|
|
522
579
|
const settleMs = Number(process.env.MM_HARNESS_FOCUS_SETTLE_MS);
|
|
523
580
|
const waitMs = Number.isFinite(settleMs) && settleMs >= 0 ? settleMs : 1000;
|
|
524
581
|
if (waitMs > 0) await new Promise(r => setTimeout(r, waitMs));
|
|
525
|
-
|
|
582
|
+
releaseFocusHold();
|
|
526
583
|
}
|
|
527
|
-
releaseFocusHold();
|
|
528
584
|
|
|
529
585
|
fs.writeFileSync(path.join(AGENT_DIR, 'extension.id'), extId);
|
|
530
586
|
console.log('[reopen] Ready \u2014 ' + SLOT_ID + (CDP_PORT ? ' CDP:' + CDP_PORT : ''));
|
|
@@ -534,6 +590,20 @@ const releaseFocusHold = () => {
|
|
|
534
590
|
if (WEBPACK_PID) console.log('[webpack] Resumed');
|
|
535
591
|
process.exit(0);
|
|
536
592
|
})().catch(e => {
|
|
593
|
+
// A proven owner is stopped before its records go, so no browser outlives
|
|
594
|
+
// the identity and pid files that name it. If it cannot be stopped, the
|
|
595
|
+
// records stay so the next launch can still find and stop it.
|
|
596
|
+
if (browserPid) {
|
|
597
|
+
try {
|
|
598
|
+
stopProfileProcessesSync(PROFILE, { extraPids: [Number(browserPid)] });
|
|
599
|
+
} catch (stopError) {
|
|
600
|
+
releaseFocusHold();
|
|
601
|
+
resumeWebpack();
|
|
602
|
+
console.error(e);
|
|
603
|
+
console.error('[FAIL] could not stop browser pid ' + browserPid + ' after the failure above: ' + (stopError && stopError.message) + '. Its records are kept; stop it, then rerun.');
|
|
604
|
+
process.exit(1);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
537
607
|
releaseFocusHold();
|
|
538
608
|
resumeWebpack();
|
|
539
609
|
try { removeRuntimeIdentity(AGENT_DIR); } catch {}
|
|
@@ -126,6 +126,8 @@ copyFile(path.join(runnerDir, 'adapters/shared/json-field.sh'), path.join(harnes
|
|
|
126
126
|
copyFile(path.join(runnerDir, 'adapters/shared/tmux-session.sh'), path.join(harnessDir, 'scripts/lib/tmux-session.sh'));
|
|
127
127
|
copyFile(path.join(runnerDir, 'adapters/shared/tmux-viewer.sh'), path.join(harnessDir, 'scripts/lib/tmux-viewer.sh'));
|
|
128
128
|
copyFile(path.join(runnerDir, 'adapters/shared/log-tui.mjs'), path.join(harnessDir, 'scripts/lib/log-tui.mjs'));
|
|
129
|
+
copyFile(path.join(runnerDir, 'adapters/shared/browser-resolver.cjs'), path.join(harnessDir, 'scripts/lib/browser-resolver.cjs'));
|
|
130
|
+
copyFile(path.join(runnerDir, 'adapters/shared/browser-cdp.cjs'), path.join(harnessDir, 'scripts/lib/browser-cdp.cjs'));
|
|
129
131
|
copyFile(path.join(runnerDir, 'adapters/extension/lib/extension-id.cjs'), path.join(harnessDir, 'scripts/lib/extension-id.cjs'));
|
|
130
132
|
copyFile(path.join(runnerDir, 'adapters/extension/lib/playwright-cdp.cjs'), path.join(harnessDir, 'scripts/lib/playwright-cdp.cjs'));
|
|
131
133
|
copyFile(path.join(runnerDir, 'adapters/extension/lib/slot-title.cjs'), path.join(harnessDir, 'scripts/lib/slot-title.cjs'));
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Purpose:
|
|
5
5
|
// Spawns a detached Chrome bound to 127.0.0.1:<cdp-port> with its own
|
|
6
|
-
// profile and the runtime dist loaded as an unpacked extension
|
|
6
|
+
// profile and the runtime dist loaded as an unpacked extension: by
|
|
7
|
+
// --load-extension, or over CDP (Extensions.loadUnpacked) for branded Chrome,
|
|
8
|
+
// which ignores that flag. The browser choice comes from
|
|
9
|
+
// <runtime>/browser-resolution.json when it names --chrome-bin.
|
|
7
10
|
//
|
|
8
11
|
// Inputs (flags, all required):
|
|
9
12
|
// --cdp-port <port> --chrome-bin <path> --profile <dir>
|
|
@@ -11,7 +14,8 @@
|
|
|
11
14
|
// Env is sanitized for the child (DYLD_*, PYTHON*, BUNDLED_DEBUGPY_PATH).
|
|
12
15
|
//
|
|
13
16
|
// Outputs:
|
|
14
|
-
// <chrome-pid> pid file; <chrome-log> append-log
|
|
17
|
+
// <chrome-pid> pid file; <chrome-log> append-log;
|
|
18
|
+
// <runtime>/browser-resolution.json (browser, version, loading method, pid).
|
|
15
19
|
// Exit 0 — spawned; non-zero — invalid/missing args, missing chrome binary,
|
|
16
20
|
// or missing dist manifest (thrown error message on stderr).
|
|
17
21
|
//
|
|
@@ -24,6 +28,12 @@ const os = require('node:os');
|
|
|
24
28
|
const path = require('node:path');
|
|
25
29
|
const { execFileSync, spawn, spawnSync } = require('node:child_process');
|
|
26
30
|
const { extensionIdFromExtensionDir } = require('./lib/extension-id.cjs');
|
|
31
|
+
// The installed overlay flattens shared helpers into scripts/lib/.
|
|
32
|
+
const browserResolverPath = [
|
|
33
|
+
path.join(__dirname, 'lib/browser-resolver.cjs'),
|
|
34
|
+
path.join(__dirname, '../shared/browser-resolver.cjs'),
|
|
35
|
+
].find((candidate) => fs.existsSync(candidate));
|
|
36
|
+
const { CDP_LOAD_UNPACKED, describeBrowser, extensionLaunchArgs, waitForCdpOwner } = require(browserResolverPath);
|
|
27
37
|
const { profileProcessPids, stopProfileProcessesSync } = require('./lib/validation-process-ownership.cjs');
|
|
28
38
|
const { acquireBuildHandoff } = require('./build-handoff.cjs');
|
|
29
39
|
const {
|
|
@@ -45,14 +55,12 @@ const {
|
|
|
45
55
|
const {
|
|
46
56
|
captureMacFrontmost,
|
|
47
57
|
macBackgroundOpenArgs,
|
|
48
|
-
|
|
49
|
-
startMacFocusHold,
|
|
50
|
-
stopMacFocusHold,
|
|
58
|
+
restoreFrontmostIfOurs,
|
|
51
59
|
} = require('./lib/macos-focus.cjs');
|
|
52
60
|
|
|
53
61
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
54
62
|
console.log(
|
|
55
|
-
'Usage: launch-browser.cjs --cdp-port <port> --chrome-bin <path> --profile <dir> --extension-dir <dist> --chrome-log <file> --chrome-pid <file>',
|
|
63
|
+
'Usage: launch-browser.cjs --cdp-port <port> --chrome-bin <path> --profile <dir> --extension-dir <dist> --chrome-log <file> --chrome-pid <file> [--browser-resolution <resolver record>]',
|
|
56
64
|
);
|
|
57
65
|
process.exit(0);
|
|
58
66
|
}
|
|
@@ -145,6 +153,10 @@ if (requestedProfileQuarantined || (portQuarantine && !activeValidationLease)) {
|
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
const initialUrl = args['start-url'] || extensionHomeUrl(args['extension-dir']) || 'chrome://extensions/';
|
|
156
|
+
const browser = launchBrowserResolution(args['browser-resolution'], args['chrome-bin']);
|
|
157
|
+
const loadsOverCdp = browser.extensionLoading === CDP_LOAD_UNPACKED;
|
|
158
|
+
let cdpLoad = null;
|
|
159
|
+
let owner = null;
|
|
148
160
|
const runtimeNonce = createRuntimeIdentityNonce();
|
|
149
161
|
const startedAt = Date.now();
|
|
150
162
|
const chromeArgs = [
|
|
@@ -162,17 +174,20 @@ const chromeArgs = [
|
|
|
162
174
|
'--disable-extensions-file-access-check',
|
|
163
175
|
'--disable-extensions-content-verification',
|
|
164
176
|
'--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
|
|
165
|
-
|
|
166
|
-
`--load-extension=${args['extension-dir']}`,
|
|
167
|
-
// Initial tab: caller's dapp URL when provided, else the extension home. The
|
|
168
|
-
// extension home avoids leaving disposable chrome://newtab/ or extensions
|
|
169
|
-
// manager tabs in the operator's slot browser.
|
|
170
|
-
initialUrl,
|
|
177
|
+
...extensionLaunchArgs(args['extension-dir'], browser.extensionLoading),
|
|
171
178
|
];
|
|
179
|
+
// Initial tab: caller's dapp URL when provided, else the extension home. The
|
|
180
|
+
// extension home avoids leaving disposable chrome://newtab/ or extensions
|
|
181
|
+
// manager tabs in the operator's slot browser. On macOS the browser starts
|
|
182
|
+
// through Launch Services in the background with no startup window (Chrome
|
|
183
|
+
// activates itself when it opens one), and the first window is opened over
|
|
184
|
+
// CDP as a background window. A CDP-loaded extension does not exist yet at
|
|
185
|
+
// startup, so its window is opened after the load.
|
|
186
|
+
const application = process.platform === 'darwin' ? macApplicationForExecutable(args['chrome-bin']) : null;
|
|
187
|
+
if (application) chromeArgs.push('--no-startup-window');
|
|
188
|
+
else chromeArgs.push(loadsOverCdp ? 'about:blank' : initialUrl);
|
|
172
189
|
const logFd = fs.openSync(args['chrome-log'], 'a');
|
|
173
190
|
const previousFrontmost = captureMacFrontmost();
|
|
174
|
-
const application = process.platform === 'darwin' ? macApplicationForExecutable(args['chrome-bin']) : null;
|
|
175
|
-
const focusHold = startMacFocusHold(previousFrontmost, application);
|
|
176
191
|
let browserPid;
|
|
177
192
|
try {
|
|
178
193
|
try {
|
|
@@ -207,6 +222,9 @@ try {
|
|
|
207
222
|
);
|
|
208
223
|
}
|
|
209
224
|
}
|
|
225
|
+
if (loadsOverCdp) cdpLoad = loadExtensionOverCdp(browserPid);
|
|
226
|
+
else if (application) openBackgroundWindow(initialUrl);
|
|
227
|
+
owner = cdpLoad?.owner ?? ownerIdentity(browserPid);
|
|
210
228
|
for (const problem of pruneExtraHomeTabs(cdpPort, args['extension-dir'])) {
|
|
211
229
|
process.stderr.write(`[launch] home-tab pruning: ${problem}\n`);
|
|
212
230
|
}
|
|
@@ -214,6 +232,16 @@ try {
|
|
|
214
232
|
fs.closeSync(logFd);
|
|
215
233
|
}
|
|
216
234
|
fs.writeFileSync(args['chrome-pid'], `${browserPid}\n`);
|
|
235
|
+
writeBrowserResolution(runtimeDir, {
|
|
236
|
+
...browser,
|
|
237
|
+
launch: {
|
|
238
|
+
pid: browserPid,
|
|
239
|
+
cdpPort,
|
|
240
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
241
|
+
...(owner ? { profile: owner.profile, processStartedAt: owner.startedAt } : {}),
|
|
242
|
+
...(cdpLoad ? { extensionId: cdpLoad.id, otherExtensions: cdpLoad.otherExtensions } : {}),
|
|
243
|
+
},
|
|
244
|
+
});
|
|
217
245
|
try {
|
|
218
246
|
writeRuntimeIdentity(runtimeDir, {
|
|
219
247
|
port: cdpPort,
|
|
@@ -240,15 +268,109 @@ try {
|
|
|
240
268
|
stdio: 'ignore',
|
|
241
269
|
});
|
|
242
270
|
wake.unref();
|
|
243
|
-
//
|
|
244
|
-
// is already listening. Keep the previous app frontmost through that paint.
|
|
271
|
+
// Let the first window settle before the one focus check below.
|
|
245
272
|
const settleMs = Number(process.env.MM_HARNESS_FOCUS_SETTLE_MS);
|
|
246
273
|
const waitSec = Number.isFinite(settleMs) && settleMs >= 0 ? settleMs / 1000 : 1;
|
|
247
274
|
if (waitSec > 0) spawnSync('sleep', [String(waitSec)]);
|
|
248
275
|
}
|
|
249
276
|
} finally {
|
|
250
|
-
|
|
251
|
-
|
|
277
|
+
// One check, at most one restore, and only if OUR browser (this launch's
|
|
278
|
+
// CDP owner pid) holds the front; any other app in front is left alone.
|
|
279
|
+
if (browserPid) {
|
|
280
|
+
const outcome = restoreFrontmostIfOurs(previousFrontmost, [Number(browserPid)]);
|
|
281
|
+
if (outcome === 'restored') process.stderr.write(`[launch] focus: the slot browser took the front; restored ${previousFrontmost.name}\n`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The first window of a browser started without one, opened in the background.
|
|
286
|
+
function openBackgroundWindow(url) {
|
|
287
|
+
const result = spawnSync(process.execPath, [browserResolverPath, 'open-window', '--port', String(cdpPort), '--url', url], {
|
|
288
|
+
encoding: 'utf8',
|
|
289
|
+
timeout: 30_000,
|
|
290
|
+
});
|
|
291
|
+
if (result.status !== 0) {
|
|
292
|
+
process.stderr.write(`[launch] could not open the start window: ${(result.stderr || result.error?.message || '').trim()}\n`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// live.sh passes the record its resolver just wrote (--browser-resolution);
|
|
297
|
+
// reuse it when it names the same binary. A direct --chrome-bin without it is
|
|
298
|
+
// the caller's own explicit choice and is recorded as such, whatever an older
|
|
299
|
+
// record of this profile says.
|
|
300
|
+
function launchBrowserResolution(file, chromeBin) {
|
|
301
|
+
if (!file) return explicitResolution(chromeBin);
|
|
302
|
+
try {
|
|
303
|
+
const recorded = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
304
|
+
if (recorded && recorded.bin === chromeBin && typeof recorded.extensionLoading === 'string') {
|
|
305
|
+
const { launch: _previousLaunch, ...resolution } = recorded;
|
|
306
|
+
return resolution;
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
// No resolver record for this launch; fall through to a direct description.
|
|
310
|
+
}
|
|
311
|
+
return explicitResolution(chromeBin);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function explicitResolution(chromeBin) {
|
|
315
|
+
return { schemaVersion: 2, ...describeBrowser(chromeBin), source: '--chrome-bin', mode: 'explicit', selection: 'explicit', resolvedAt: new Date().toISOString() };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function writeBrowserResolution(runtime, resolution) {
|
|
319
|
+
fs.writeFileSync(path.join(runtime, 'browser-resolution.json'), `${JSON.stringify(resolution, null, 2)}\n`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function loadExtensionOverCdp(pid) {
|
|
323
|
+
const result = spawnSync(process.execPath, [
|
|
324
|
+
browserResolverPath,
|
|
325
|
+
'load-unpacked',
|
|
326
|
+
'--port', String(cdpPort),
|
|
327
|
+
'--path', args['extension-dir'],
|
|
328
|
+
'--profile', args.profile,
|
|
329
|
+
'--open-url', initialUrl,
|
|
330
|
+
], { encoding: 'utf8', timeout: 130_000 });
|
|
331
|
+
if (result.status === 0) {
|
|
332
|
+
const loaded = JSON.parse(result.stdout);
|
|
333
|
+
process.stderr.write(`[launch] extension ${loaded.id} loaded over CDP\n`);
|
|
334
|
+
for (const entry of loaded.otherExtensions?.disabled ?? []) {
|
|
335
|
+
process.stderr.write(`[launch] disabled ${entry.id} (${entry.name}) in the slot profile\n`);
|
|
336
|
+
}
|
|
337
|
+
for (const entry of loaded.otherExtensions?.policyPinned ?? []) {
|
|
338
|
+
process.stderr.write(`[launch] WARN: enterprise policy keeps ${entry.id} (${entry.name}) enabled in the slot profile\n`);
|
|
339
|
+
}
|
|
340
|
+
return loaded;
|
|
341
|
+
}
|
|
342
|
+
// Ownership was proven; once the browser is stopped the launch markers would
|
|
343
|
+
// only quarantine a port and profile that nothing holds. If it cannot be
|
|
344
|
+
// stopped, the markers stay and both failures are reported.
|
|
345
|
+
const loadError = (result.stderr || result.error?.message || '').trim();
|
|
346
|
+
try {
|
|
347
|
+
stopProfileProcessesSync(args.profile, { extraPids: [pid] });
|
|
348
|
+
} catch (stopError) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`Chrome started but the extension did not load over CDP (${loadError}), and stopping it failed: ${stopError.message}. ` +
|
|
351
|
+
`The launch markers for port ${cdpPort} and ${args.profile} are kept. Next: stop pid ${pid}, then rerun.`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
clearDetachedLaunchUnproven(args.profile);
|
|
355
|
+
if (!activeValidationLease) clearValidationPortQuarantine(cdpPort, args.profile);
|
|
356
|
+
throw new Error(
|
|
357
|
+
`Chrome started but the extension did not load over CDP: ${loadError}. ` +
|
|
358
|
+
`Next: inspect ${args['chrome-log']}, or rerun with RECIPE_HARNESS_BROWSER=cft to use Chrome for Testing.`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Identity of the browser that holds the profile (pid + start time), recorded
|
|
363
|
+
// so run evidence can later prove it names this exact process.
|
|
364
|
+
// Best effort, so a short budget: a browser that never proves ownership (a
|
|
365
|
+
// stub without a SingletonLock, say) costs seconds, not the full default.
|
|
366
|
+
function ownerIdentity(pid) {
|
|
367
|
+
try {
|
|
368
|
+
const identity = waitForCdpOwner(cdpPort, args.profile, { timeoutMs: 15000 });
|
|
369
|
+
return identity.pid === pid ? identity : null;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
process.stderr.write(`[launch] could not record the browser identity: ${error.message}\n`);
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
252
374
|
}
|
|
253
375
|
|
|
254
376
|
function sanitizedChildEnv() {
|