@deeeed/metamask-harness 0.69.0 → 0.70.1

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 CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.70.1 - 2026-10-02
6
+
7
+ ### Fixed
8
+
9
+ - Detect forced Google Chrome sign-in (`BrowserSignin: 2`, `ForceBrowserSignin`) as managed: `plutil` returns these scalar policies only as raw values, so a Mac with only forced sign-in looked unmanaged and fell back to the managed Chrome.
10
+ - Disable other user extensions in the slot browser before its start page (a dapp or the wallet home) loads; a browser started without a window gets a blank background window first.
11
+ - Fail the launch, and stop the browser, when a slot browser started without a window cannot open its start window, instead of recording a browser with no window.
12
+ - Start the library autolaunch browser on macOS without a startup window, load the wallet home into its window's blank tab (so it is the page a screenshot of the window shows) or a background window, and restore the operator's front app once, by pid, only if its own browser took it. Headful browser probes also start without a startup window.
13
+ - Show doctor's managed-Chrome diagnostics next to any other browser warning.
14
+
15
+ ## 0.70.0 - 2026-10-02
16
+
17
+ ### Added
18
+
19
+ - 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).
20
+
21
+ ### Changed
22
+
23
+ - Shorten tutorials and correct template selection, report outputs, and static-review ownership; illustrate task progress with the fix-bug screenshot and watcher command.
24
+
5
25
  ## 0.69.0 - 2026-09-30
6
26
 
7
27
  ### Changed
package/README.md CHANGED
@@ -113,6 +113,110 @@ 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, `auto` uses Chrome for
153
+ Testing, which does not read those policies, so no forced extension can open
154
+ windows in the slot. Only MDM-managed preferences are read
155
+ (`/Library/Managed Preferences/com.google.Chrome.plist` and its per-user
156
+ copy under `/Library/Managed Preferences/<user>/`), not `defaults` a user set
157
+ themselves; force-installed extensions (`ExtensionInstallForcelist`,
158
+ `ExtensionSettings`) or forced sign-in (`BrowserSignin: 2`,
159
+ `ForceBrowserSignin`) count as managed; a policy stored as a Data or Date
160
+ value is treated as absent. If Chrome for Testing does not start
161
+ and paint there, the launch stops with `BROWSER_UNLAUNCHABLE` rather than
162
+ falling back to the managed Chrome; the way out is
163
+ `RECIPE_HARNESS_BROWSER=chrome` (accepting that forced extensions may open
164
+ windows that take the front), or a less loaded machine. Doctor lists the forced
165
+ extension ids, with names where it can read them (best effort, read-only: the
166
+ slot record, or the operator's own Chrome profile folders). A slot profile
167
+ created on the managed Chrome keeps it, with a warning to reset the profile.
168
+
169
+ A slot then keeps the kind of browser its profile was created with, until the
170
+ profile is reset (`mm-harness fixtures set` or `fixtures reset`):
171
+
172
+ - created on Chrome for Testing: the checkout's current Chrome for Testing
173
+ (a Playwright upgrade is followed and re-probed); it moves to Google Chrome
174
+ only once that build is proven to crash, never back;
175
+ - created on Google Chrome or an explicit path: that binary; if it is removed,
176
+ the launch fails and says how to recover rather than switching browsers;
177
+ - created with `RECIPE_HARNESS_CHROME_BIN`, a path in `RECIPE_HARNESS_BROWSER`,
178
+ or a launcher given `--chrome-bin` directly:
179
+ that exact binary on every later `auto` launch, even when it is a Chrome for
180
+ Testing build.
181
+
182
+ A preserved slot stuck on Chrome for Testing that "renders no frames" (a
183
+ transient verdict, which keeps it on Chrome for Testing) moves to Google Chrome
184
+ without a profile reset: launch it once with `RECIPE_HARNESS_BROWSER=chrome`.
185
+ The profile is then a Google Chrome profile and later `auto` launches keep
186
+ Google Chrome. Going the other way opens a profile in an older browser, so
187
+ reset the profile instead.
188
+
189
+ Profiles created before this browser record existed have no record, so their
190
+ first `auto` launch probes like a new profile and may move to Google Chrome.
191
+
192
+ `mm-harness doctor --adapter extension` shows the current choice; each launch
193
+ writes it to `temp/recipe/runtime/browser-resolution.json` with the browser
194
+ process identity, and run evidence (`product-provenance.json`) names the
195
+ browser the run drove only after checking that exact process still serves the
196
+ CDP port the run executed on. The owner of a CDP port is the process named by
197
+ the profile's `SingletonLock`, and only if the lock names this host, the
198
+ process started before the lock was written (a reused pid did not), and it
199
+ holds files open in the profile. That proof uses `lsof`, which macOS ships;
200
+ without it the launch stops with an explanation. Start times are compared exactly as
201
+ `ps -o lstart` prints them in UTC, so a record written by an earlier harness
202
+ build (local time) binds nothing (`boundTo: none`) until the slot relaunches.
203
+
204
+ On macOS the slot browser starts in the background with no startup window,
205
+ and its first window opens over CDP as a background window. After the launch
206
+ settles there is one focus check: only if the slot browser's own process is
207
+ frontmost does the previously focused app get the front back, once
208
+ (`MM_HARNESS_FOCUS_HOLD=0` turns this off). A click on the slot window right
209
+ after launch can therefore be undone once.
210
+
211
+ ```bash
212
+ # Roll back to Chrome for Testing with a fresh slot profile (a profile Google
213
+ # Chrome has opened is not reopened in the older Chrome for Testing). Keep
214
+ # RECIPE_HARNESS_BROWSER=cft set for later launches too: on a plain auto launch
215
+ # the profile follows Chrome for Testing only until a probe proves it dies.
216
+ RECIPE_HARNESS_BROWSER=cft mm-harness fixtures set
217
+ rm ~/.cache/mm-harness/browser-probe.json # forget probe verdicts
218
+ ```
219
+
116
220
  ### Remote feature flags
117
221
 
118
222
  `--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 Playwright Chromium is running
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}; progress on stdout.
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, preserveMacFrontmost, startMacFocusHold, stopMacFocusHold } = require(path.join('${SCRIPT_DIR}', 'lib/macos-focus.cjs'));
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
- const chromiumApp = path.dirname(path.dirname(path.dirname(chromium.executablePath())));
328
- let focusHold = null;
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
- stopMacFocusHold(focusHold);
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
- '--disable-extensions-except=' + EXTENSION,
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
- let browserPid = '';
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
- const page = ctx.pages().find(p => p.url().includes('chrome-extension://')) || ctx.pages()[0];
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
- preserveMacFrontmost(previousFrontmost, chromiumApp);
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
- preserveMacFrontmost,
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
- `--disable-extensions-except=${args['extension-dir']}`,
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(browserPid, 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,121 @@ try {
240
268
  stdio: 'ignore',
241
269
  });
242
270
  wake.unref();
243
- // Chrome often activates when the first window paints, which can be after CDP
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
- stopMacFocusHold(focusHold);
251
- preserveMacFrontmost(previousFrontmost, application);
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
+ // Without it the slot has no window at all, so a failure fails the launch:
287
+ // the owned browser is stopped (its markers kept if it cannot be).
288
+ function openBackgroundWindow(pid, url) {
289
+ const result = spawnSync(process.execPath, [browserResolverPath, 'open-window', '--port', String(cdpPort), '--url', url], {
290
+ encoding: 'utf8',
291
+ timeout: 30_000,
292
+ });
293
+ if (result.status === 0) return;
294
+ const openError = (result.stderr || result.error?.message || `exit ${result.status}`).trim();
295
+ try {
296
+ stopProfileProcessesSync(args.profile, { extraPids: [pid] });
297
+ } catch (stopError) {
298
+ throw new Error(
299
+ `Chrome started but its start window could not be opened (${openError}), and stopping it failed: ${stopError.message}. ` +
300
+ `The launch markers for port ${cdpPort} and ${args.profile} are kept. Next: stop pid ${pid}, then rerun.`,
301
+ );
302
+ }
303
+ clearDetachedLaunchUnproven(args.profile);
304
+ if (!activeValidationLease) clearValidationPortQuarantine(cdpPort, args.profile);
305
+ throw new Error(`Chrome started but its start window could not be opened: ${openError}. Next: inspect ${args['chrome-log']}, then rerun.`);
306
+ }
307
+
308
+ // live.sh passes the record its resolver just wrote (--browser-resolution);
309
+ // reuse it when it names the same binary. A direct --chrome-bin without it is
310
+ // the caller's own explicit choice and is recorded as such, whatever an older
311
+ // record of this profile says.
312
+ function launchBrowserResolution(file, chromeBin) {
313
+ if (!file) return explicitResolution(chromeBin);
314
+ try {
315
+ const recorded = JSON.parse(fs.readFileSync(file, 'utf8'));
316
+ if (recorded && recorded.bin === chromeBin && typeof recorded.extensionLoading === 'string') {
317
+ const { launch: _previousLaunch, ...resolution } = recorded;
318
+ return resolution;
319
+ }
320
+ } catch {
321
+ // No resolver record for this launch; fall through to a direct description.
322
+ }
323
+ return explicitResolution(chromeBin);
324
+ }
325
+
326
+ function explicitResolution(chromeBin) {
327
+ return { schemaVersion: 2, ...describeBrowser(chromeBin), source: '--chrome-bin', mode: 'explicit', selection: 'explicit', resolvedAt: new Date().toISOString() };
328
+ }
329
+
330
+ function writeBrowserResolution(runtime, resolution) {
331
+ fs.writeFileSync(path.join(runtime, 'browser-resolution.json'), `${JSON.stringify(resolution, null, 2)}\n`);
332
+ }
333
+
334
+ function loadExtensionOverCdp(pid) {
335
+ const result = spawnSync(process.execPath, [
336
+ browserResolverPath,
337
+ 'load-unpacked',
338
+ '--port', String(cdpPort),
339
+ '--path', args['extension-dir'],
340
+ '--profile', args.profile,
341
+ '--open-url', initialUrl,
342
+ ], { encoding: 'utf8', timeout: 130_000 });
343
+ if (result.status === 0) {
344
+ const loaded = JSON.parse(result.stdout);
345
+ process.stderr.write(`[launch] extension ${loaded.id} loaded over CDP\n`);
346
+ for (const entry of loaded.otherExtensions?.disabled ?? []) {
347
+ process.stderr.write(`[launch] disabled ${entry.id} (${entry.name}) in the slot profile\n`);
348
+ }
349
+ for (const entry of loaded.otherExtensions?.policyPinned ?? []) {
350
+ process.stderr.write(`[launch] WARN: enterprise policy keeps ${entry.id} (${entry.name}) enabled in the slot profile\n`);
351
+ }
352
+ return loaded;
353
+ }
354
+ // Ownership was proven; once the browser is stopped the launch markers would
355
+ // only quarantine a port and profile that nothing holds. If it cannot be
356
+ // stopped, the markers stay and both failures are reported.
357
+ const loadError = (result.stderr || result.error?.message || '').trim();
358
+ try {
359
+ stopProfileProcessesSync(args.profile, { extraPids: [pid] });
360
+ } catch (stopError) {
361
+ throw new Error(
362
+ `Chrome started but the extension did not load over CDP (${loadError}), and stopping it failed: ${stopError.message}. ` +
363
+ `The launch markers for port ${cdpPort} and ${args.profile} are kept. Next: stop pid ${pid}, then rerun.`,
364
+ );
365
+ }
366
+ clearDetachedLaunchUnproven(args.profile);
367
+ if (!activeValidationLease) clearValidationPortQuarantine(cdpPort, args.profile);
368
+ throw new Error(
369
+ `Chrome started but the extension did not load over CDP: ${loadError}. ` +
370
+ `Next: inspect ${args['chrome-log']}, or rerun with RECIPE_HARNESS_BROWSER=cft to use Chrome for Testing.`,
371
+ );
372
+ }
373
+
374
+ // Identity of the browser that holds the profile (pid + start time), recorded
375
+ // so run evidence can later prove it names this exact process.
376
+ // Best effort, so a short budget: a browser that never proves ownership (a
377
+ // stub without a SingletonLock, say) costs seconds, not the full default.
378
+ function ownerIdentity(pid) {
379
+ try {
380
+ const identity = waitForCdpOwner(cdpPort, args.profile, { timeoutMs: 15000 });
381
+ return identity.pid === pid ? identity : null;
382
+ } catch (error) {
383
+ process.stderr.write(`[launch] could not record the browser identity: ${error.message}\n`);
384
+ return null;
385
+ }
252
386
  }
253
387
 
254
388
  function sanitizedChildEnv() {