@deeeed/metamask-harness 0.70.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,16 @@
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
+
5
15
  ## 0.70.0 - 2026-10-02
6
16
 
7
17
  ### Added
package/README.md CHANGED
@@ -149,15 +149,22 @@ On a cold cache the probe can take up to about 80 seconds and
149
149
  shows a small (about 480×360) browser window; `doctor --adapter extension`
150
150
  runs the same probe.
151
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.
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.
161
168
 
162
169
  A slot then keeps the kind of browser its profile was created with, until the
163
170
  profile is reset (`mm-harness fixtures set` or `fixtures reset`):
@@ -223,7 +223,7 @@ try {
223
223
  }
224
224
  }
225
225
  if (loadsOverCdp) cdpLoad = loadExtensionOverCdp(browserPid);
226
- else if (application) openBackgroundWindow(initialUrl);
226
+ else if (application) openBackgroundWindow(browserPid, initialUrl);
227
227
  owner = cdpLoad?.owner ?? ownerIdentity(browserPid);
228
228
  for (const problem of pruneExtraHomeTabs(cdpPort, args['extension-dir'])) {
229
229
  process.stderr.write(`[launch] home-tab pruning: ${problem}\n`);
@@ -283,14 +283,26 @@ try {
283
283
  }
284
284
 
285
285
  // The first window of a browser started without one, opened in the background.
286
- function openBackgroundWindow(url) {
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) {
287
289
  const result = spawnSync(process.execPath, [browserResolverPath, 'open-window', '--port', String(cdpPort), '--url', url], {
288
290
  encoding: 'utf8',
289
291
  timeout: 30_000,
290
292
  });
291
- if (result.status !== 0) {
292
- process.stderr.write(`[launch] could not open the start window: ${(result.stderr || result.error?.message || '').trim()}\n`);
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
+ );
293
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.`);
294
306
  }
295
307
 
296
308
  // live.sh passes the record its resolver just wrote (--browser-resolution);
@@ -428,12 +428,21 @@ async function openBackgroundWindow(send, url, timeoutMs) {
428
428
  return send('Target.createTarget', { url, newWindow: true, background: true }, undefined, timeoutMs);
429
429
  }
430
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
+
431
441
  // Point the launch tab at `url` once the extension exists: the tab opened at
432
- // launch would otherwise show an error page for chrome-extension:// URLs. A
433
- // browser started without a window gets a background window instead.
442
+ // launch would otherwise show an error page for chrome-extension:// URLs.
434
443
  async function openUrl(send, url, remaining) {
435
444
  const { targetInfos } = await send('Target.getTargets', {}, undefined, remaining());
436
- const blank = targetInfos.find((target) => target.type === 'page' && (target.url === 'about:blank' || target.url.startsWith('chrome://newtab') || target.url.startsWith('chrome://new-tab-page')));
445
+ const blank = (targetInfos || []).find(isBlankPage);
437
446
  if (!blank) {
438
447
  await openBackgroundWindow(send, url, remaining());
439
448
  return;
@@ -489,12 +498,15 @@ async function loadUnpackedOverPort(port, extensionDir, {
489
498
  assertSameOwner(owner, { exec, timeoutMs: remaining() });
490
499
  step = 'Extensions.loadUnpacked';
491
500
  const loaded = await loadUnpackedExtension(client.send, extensionDir, { expectedId, timeoutMs: remaining() });
492
- // The start page first: a browser started without a window gets its first
493
- // window here, in the background, before isolation opens its tab.
494
- step = 'opening the start page';
495
- if (url) await openUrl(client.send, url, 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);
496
506
  step = 'isolating the wallet from other extensions';
497
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);
498
510
  remaining();
499
511
  return { ...loaded, otherExtensions, owner };
500
512
  } catch (error) {
@@ -523,6 +535,8 @@ module.exports = {
523
535
  expectedExtensionId: (extensionDir) => extensionIdFromExtensionDir(extensionDir) || null,
524
536
  loadUnpackedExtension,
525
537
  loadUnpackedOverPort,
538
+ ensureBackgroundWindow,
526
539
  isolateWalletExtension,
527
540
  openBackgroundWindow,
541
+ openUrl,
528
542
  };
@@ -106,14 +106,24 @@ function managedChromePlists(env = process.env) {
106
106
  ];
107
107
  }
108
108
 
109
- // One policy key as JSON, or undefined when absent or unreadable.
109
+ // One policy key, or undefined when absent or unreadable. `plutil -extract
110
+ // … json` only extracts arrays and dictionaries; a scalar (BrowserSignin: 2,
111
+ // ForceBrowserSignin: true) comes out with `raw`.
110
112
  function readPolicyKey(file, key) {
113
+ const extract = (format) => execFileSync('plutil', ['-extract', key, format, '-o', '-', file], {
114
+ encoding: 'utf8',
115
+ stdio: ['ignore', 'pipe', 'ignore'],
116
+ timeout: 5000,
117
+ });
111
118
  try {
112
- return JSON.parse(execFileSync('plutil', ['-extract', key, 'json', '-o', '-', file], {
113
- encoding: 'utf8',
114
- stdio: ['ignore', 'pipe', 'ignore'],
115
- timeout: 5000,
116
- }));
119
+ return JSON.parse(extract('json'));
120
+ } catch {
121
+ // A scalar, or the key is absent: try it as a raw value.
122
+ }
123
+ try {
124
+ const raw = extract('raw').trim();
125
+ if (raw === 'true' || raw === 'false') return raw === 'true';
126
+ return /^-?\d+$/u.test(raw) ? Number(raw) : raw;
117
127
  } catch {
118
128
  return undefined;
119
129
  }
@@ -512,11 +522,12 @@ async function probeLaunch(executable, {
512
522
  let exited = null;
513
523
  try {
514
524
  const port = await freePort();
515
- // A headful Launch Services probe starts like the slot browser: in the
516
- // background with no startup window (Chrome activates itself when it opens
517
- // one); renderCheck opens a background window. Like the launcher, it keeps
518
- // a window that sits behind the operator's apps rendering.
519
- const backgroundWindow = method === LAUNCH_SERVICES && display === HEADFUL;
525
+ // A headful probe starts like the slot browsers (the launchers and the
526
+ // library autolaunch): in the background with no startup window (Chrome
527
+ // activates itself when it opens one); renderCheck opens a background
528
+ // window. Like the launchers, it keeps a window behind the operator's apps
529
+ // rendering.
530
+ const backgroundWindow = display === HEADFUL;
520
531
  const args = [
521
532
  ...(display === HEADLESS ? ['--headless=new'] : ['--window-size=480,360']),
522
533
  `--user-data-dir=${profile}`,
package/dist/doctor.js CHANGED
@@ -241,7 +241,7 @@ function extensionBrowserResolutionDoctorCheck(target) {
241
241
  if (result.status === 0) {
242
242
  const resolution = JSON.parse(result.stdout);
243
243
  const managed = resolution.managedChrome;
244
- const managedNote = managed && !resolution.warning ? ` Google Chrome is managed by policy (${managed.forcedExtensions.length} forced extension${managed.forcedExtensions.length === 1 ? "" : "s"}: ${managed.forcedExtensions.map(({ id, name }) => name ? `${id} (${name})` : id).join(", ")}${managed.signinForced ? "; browser sign-in forced" : ""}); slots use Chrome for Testing.` : "";
244
+ const managedNote = managed && !resolution.warning?.includes("managed by policy") ? ` Google Chrome is managed by policy (${managed.forcedExtensions.length} forced extension${managed.forcedExtensions.length === 1 ? "" : "s"}: ${managed.forcedExtensions.map(({ id, name }) => name ? `${id} (${name})` : id).join(", ")}${managed.signinForced ? "; browser sign-in forced" : ""}); slots use Chrome for Testing.` : "";
245
245
  const probe = resolution.probe ? `; Chrome for Testing probe (${resolution.probe.launchMethod}, ${resolution.probe.display}${resolution.probe.cached ? ", cached" : ""}${resolution.probe.transient ? `, retried after ${resolution.probe.expiresAt}` : ""}): ${resolution.probe.reason}` : resolution.recordedFrom ? `; kept from the slot profile record ${resolution.recordedFrom}` : "";
246
246
  return {
247
247
  id: "extension-browser",
@@ -373,6 +373,26 @@ const runnerRequire = createRequire(import.meta.url);
373
373
  const runnerRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
374
374
  const browserResolver = runnerRequire(path.join(runnerRoot, 'adapters/shared/browser-resolver.cjs'));
375
375
  const { stopProfileProcessesSync } = runnerRequire(path.join(runnerRoot, 'adapters/extension/lib/validation-process-ownership.cjs'));
376
+ const browserCdp = runnerRequire(path.join(runnerRoot, 'adapters/shared/browser-cdp.cjs'));
377
+ const macosFocus = runnerRequire(path.join(runnerRoot, 'adapters/extension/lib/macos-focus.cjs'));
378
+
379
+ // On macOS a browser that opens a window at startup activates itself, so the
380
+ // autolaunched browser starts without one and gets its pages over CDP in the
381
+ // background (as the extension launcher does).
382
+ const startsWithoutWindow = () => process.platform === 'darwin';
383
+
384
+ // One bounded browser-level CDP operation (the shared helpers in
385
+ // browser-cdp.cjs never take the operator's focus).
386
+ async function withBrowserCdp(port, fn) {
387
+ const deadline = Date.now() + 15000;
388
+ const remaining = () => Math.max(1, deadline - Date.now());
389
+ const client = await browserCdp.connectBrowserCdp(port, { timeoutMs: remaining() });
390
+ try {
391
+ return await fn(client.send, remaining);
392
+ } finally {
393
+ client.close();
394
+ }
395
+ }
376
396
 
377
397
  // Same browser choice as the live launcher (RECIPE_HARNESS_BROWSER /
378
398
  // RECIPE_HARNESS_CHROME_BIN); branded Chrome loads the dist over CDP. This
@@ -491,6 +511,7 @@ export async function launchExistingDistRuntime(input, port) {
491
511
  const profileExisted = await canAccess(path.join(profileDir, 'Local State'));
492
512
  const browser = await resolveChromeBinary(projectRoot, profileExisted ? browserRecord : null);
493
513
  const loadsOverCdp = browser.extensionLoading === browserResolver.CDP_LOAD_UNPACKED;
514
+ const previousFrontmost = macosFocus.captureMacFrontmost();
494
515
  const stdoutFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
495
516
  const stderrFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
496
517
  let child;
@@ -508,7 +529,7 @@ export async function launchExistingDistRuntime(input, port) {
508
529
  '--disable-extensions-content-verification',
509
530
  '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
510
531
  ...browserResolver.extensionLaunchArgs(runtimeDist, browser.extensionLoading),
511
- 'chrome://extensions/',
532
+ startsWithoutWindow() ? '--no-startup-window' : 'chrome://extensions/',
512
533
  ], {
513
534
  cwd: projectRoot,
514
535
  detached: true,
@@ -534,7 +555,10 @@ export async function launchExistingDistRuntime(input, port) {
534
555
  try {
535
556
  await retryJsonGet(`http://127.0.0.1:${port}/json/version`, 45000);
536
557
  const owner = browserResolver.waitForCdpOwner(port, profileDir);
558
+ // The CDP load opens a background window itself; otherwise open one here.
559
+ // Without it the browser has no window, so a failure fails the launch.
537
560
  const cdpLoad = loadsOverCdp ? await browserResolver.loadUnpackedOverPort(port, runtimeDist, { profile: profileDir }) : null;
561
+ if (!loadsOverCdp && startsWithoutWindow()) await withBrowserCdp(port, browserCdp.ensureBackgroundWindow);
538
562
  launchedBrowser = {
539
563
  ...browser,
540
564
  launch: {
@@ -582,6 +606,8 @@ export async function launchExistingDistRuntime(input, port) {
582
606
  }
583
607
  throw error;
584
608
  }
609
+ // One check, at most one restore, only if this launch's browser holds the front.
610
+ macosFocus.restoreFrontmostIfOurs(previousFrontmost, [launchedBrowser.launch.pid]);
585
611
 
586
612
  await writeFile(path.join(runtimeRoot, 'runtime.json'), `${JSON.stringify({
587
613
  port,
@@ -618,17 +644,16 @@ function extensionIdFromAnyTarget(targets) {
618
644
  return null;
619
645
  }
620
646
 
621
- async function openExtensionHomePage(port, extensionId) {
647
+ // The wallet home goes into the launch's blank tab (so it is the window's
648
+ // selected page, and what a native screenshot of the window captures), or a
649
+ // background window when there is no blank tab; never a foreground one.
650
+ export async function openExtensionHomePage(port, extensionId) {
622
651
  const url = `chrome-extension://${extensionId}/home.html`;
623
- const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(url)}`;
624
- let response = await fetch(endpoint, { method: 'PUT' });
625
- if (response.status === 405 || response.status === 404) {
626
- response = await fetch(endpoint);
627
- }
628
- if (!response.ok) {
629
- throw new Error(`Failed to open extension home page ${url}: HTTP ${response.status}`);
652
+ try {
653
+ return await withBrowserCdp(port, (send, remaining) => browserCdp.openUrl(send, url, remaining));
654
+ } catch (error) {
655
+ throw new Error(`Failed to open extension home page ${url}: ${error.message}`);
630
656
  }
631
- return response.json();
632
657
  }
633
658
 
634
659
  async function ensureExtensionTarget(input, port) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.70.0",
3
+ "version": "0.70.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"