@deeeed/metamask-harness 0.50.5 → 0.50.6

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,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.50.6 - 2026-09-11
6
+
7
+ ### Fixed
8
+
9
+ - Stop every Extension Chrome whose `--user-data-dir` is under this checkout's `temp/recipe/runtime/`, including uniquely named recipe profiles. The previous allow-list of `chrome-profile`, `chrome-profile-recipe`, and `chrome-profile-pw` left leftovers that `mm-harness stop` reported as already stopped. Nested `console-tail.mjs` log paths under that runtime dir are reaped the same way. Personal Chrome and other checkouts stay untouched.
10
+ - Close extra MetaMask home tabs after an Extension browser launch. `open -n` with a start URL against a still-live profile was stacking unlock tabs.
11
+ - Reap every Chrome process that still owns the Extension profile before relaunch, and kill a macOS `open -n` browser that never exposes CDP, so repeated launches cannot stack headed Chrome instances on the same user-data-dir.
12
+ - Keep the Extension Chrome window visible, but restore the previously focused macOS app through Launch Services while Chrome starts so the window does not come to the front or take the keyboard. System Events hangs during that activation, so osascript restore cannot hold focus.
13
+
5
14
  ## 0.50.5 - 2026-09-10
6
15
 
7
16
  ### Fixed
@@ -301,7 +301,8 @@ const {
301
301
  runtimeIdentityArgs,
302
302
  writeRuntimeIdentity,
303
303
  } = require(path.join('${SCRIPT_DIR}', 'lib/chrome-args.cjs'));
304
- const { captureMacFrontmostProcess, preserveMacFrontmostProcess } = require(path.join('${SCRIPT_DIR}', 'lib/macos-focus.cjs'));
304
+ const { captureMacFrontmost, macBackgroundOpenArgs, preserveMacFrontmost, startMacFocusHold, stopMacFocusHold } = require(path.join('${SCRIPT_DIR}', 'lib/macos-focus.cjs'));
305
+ const { stopProfileProcessesSync } = require(path.join('${SCRIPT_DIR}', 'lib/validation-process-ownership.cjs'));
305
306
 
306
307
  const SLOT_ID = '${SLOT_ID}';
307
308
  const AGENT_DIR = '${AGENT_DIR}';
@@ -322,6 +323,13 @@ const resumeWebpack = () => {
322
323
  if (WEBPACK_PID) { try { process.kill(-Number(WEBPACK_PID), 'SIGCONT'); } catch { try { process.kill(Number(WEBPACK_PID), 'SIGCONT'); } catch {} } }
323
324
  };
324
325
 
326
+ const previousFrontmost = captureMacFrontmost();
327
+ let focusHold = null;
328
+ const releaseFocusHold = () => {
329
+ stopMacFocusHold(focusHold);
330
+ preserveMacFrontmost(previousFrontmost);
331
+ };
332
+
325
333
  (async () => {
326
334
  const runtimeNonce = createRuntimeIdentityNonce();
327
335
  const startedAt = Date.now();
@@ -344,10 +352,8 @@ const resumeWebpack = () => {
344
352
  }
345
353
 
346
354
  const chromiumApp = path.dirname(path.dirname(path.dirname(chromium.executablePath())));
347
- const previousFrontmostPid = captureMacFrontmostProcess();
348
- const holdOperatorFocus = () => preserveMacFrontmostProcess(previousFrontmostPid);
349
- execFileSync('open', ['-g', '-n', '-a', chromiumApp, '--args', ...args], { stdio: 'ignore' });
350
- holdOperatorFocus();
355
+ focusHold = startMacFocusHold(previousFrontmost);
356
+ execFileSync('open', macBackgroundOpenArgs(chromiumApp, args), { stdio: 'ignore' });
351
357
  for (let i = 0; i < 60; i++) {
352
358
  try {
353
359
  const version = await new Promise((resolve, reject) => {
@@ -360,7 +366,6 @@ const resumeWebpack = () => {
360
366
  });
361
367
  if (version) break;
362
368
  } catch {}
363
- holdOperatorFocus();
364
369
  await new Promise(r => setTimeout(r, 1000));
365
370
  }
366
371
  const browser = await chromium.connectOverCDP('http://127.0.0.1:' + CDP_PORT);
@@ -399,6 +404,7 @@ const resumeWebpack = () => {
399
404
  } catch {}
400
405
  }
401
406
  if (!browserPid) {
407
+ stopProfileProcessesSync(PROFILE, { waitForAppearanceMs: 400 });
402
408
  throw new Error(
403
409
  'Reopened Chrome did not expose an owned CDP listener on port ' + CDP_PORT +
404
410
  '. Next: inspect the slot browser process, then rerun: mm-harness launch --adapter extension --build'
@@ -431,11 +437,12 @@ const resumeWebpack = () => {
431
437
  try {
432
438
  extId = execFileSync(RUNNER_BIN, ['resolve-extension', '--adapter', 'extension', '--target', REPO], { encoding: 'utf8' }).trim();
433
439
  } catch (err) {
440
+ releaseFocusHold();
434
441
  resumeWebpack();
435
442
  console.error('[FAIL] runner resolve-extension failed: ' + (err && err.message ? err.message : String(err)));
436
443
  process.exit(1);
437
444
  }
438
- if (!/^[a-p]{32}$/.test(extId)) { resumeWebpack(); console.error('[FAIL] runner returned invalid extension id: ' + JSON.stringify(extId)); process.exit(1); }
445
+ if (!/^[a-p]{32}$/.test(extId)) { releaseFocusHold(); resumeWebpack(); console.error('[FAIL] runner returned invalid extension id: ' + JSON.stringify(extId)); process.exit(1); }
439
446
  console.log('[reopen] Extension: ' + extId + ' (via runner resolve-extension)');
440
447
 
441
448
  // Navigate to MetaMask home
@@ -501,8 +508,10 @@ const resumeWebpack = () => {
501
508
  if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') {
502
509
  await page.bringToFront().catch(() => {});
503
510
  } else {
504
- holdOperatorFocus();
511
+ await new Promise(r => setTimeout(r, 1000));
512
+ preserveMacFrontmost(previousFrontmost);
505
513
  }
514
+ releaseFocusHold();
506
515
 
507
516
  fs.writeFileSync(path.join(AGENT_DIR, 'extension.id'), extId);
508
517
  console.log('[reopen] Ready \u2014 ' + SLOT_ID + (CDP_PORT ? ' CDP:' + CDP_PORT : ''));
@@ -512,6 +521,7 @@ const resumeWebpack = () => {
512
521
  if (WEBPACK_PID) console.log('[webpack] Resumed');
513
522
  process.exit(0);
514
523
  })().catch(e => {
524
+ releaseFocusHold();
515
525
  resumeWebpack();
516
526
  try { removeRuntimeIdentity(AGENT_DIR); } catch {}
517
527
  try { fs.unlinkSync(path.join(AGENT_DIR, 'browser.pid')); } catch {}
@@ -24,7 +24,7 @@ const os = require('node:os');
24
24
  const path = require('node:path');
25
25
  const { execFileSync, spawn, spawnSync } = require('node:child_process');
26
26
  const { extensionIdFromExtensionDir } = require('./lib/extension-id.cjs');
27
- const { profileProcessPids } = require('./lib/validation-process-ownership.cjs');
27
+ const { profileProcessPids, stopProfileProcessesSync } = require('./lib/validation-process-ownership.cjs');
28
28
  const { acquireBuildHandoff } = require('./build-handoff.cjs');
29
29
  const {
30
30
  automationRuntimeArgs,
@@ -42,7 +42,13 @@ const {
42
42
  validationLaunchQuarantineError,
43
43
  writeRuntimeIdentity,
44
44
  } = require('./lib/chrome-args.cjs');
45
- const { captureMacFrontmostProcess, preserveMacFrontmostProcess } = require('./lib/macos-focus.cjs');
45
+ const {
46
+ captureMacFrontmost,
47
+ macBackgroundOpenArgs,
48
+ preserveMacFrontmost,
49
+ startMacFocusHold,
50
+ stopMacFocusHold,
51
+ } = require('./lib/macos-focus.cjs');
46
52
 
47
53
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
48
54
  console.log(
@@ -107,7 +113,7 @@ try {
107
113
  'Next: remove the invalid runtime identity and fix owner permissions, then rerun: mm-harness launch --adapter extension --build',
108
114
  );
109
115
  }
110
- terminatePids([...ownedPids]);
116
+ stopProfileProcessesSync(args.profile, { extraPids: [...ownedPids] });
111
117
  if (args['reset-profile'] !== undefined) {
112
118
  fs.rmSync(args.profile, { recursive: true, force: true });
113
119
  fs.mkdirSync(args.profile, { recursive: true });
@@ -164,72 +170,84 @@ const chromeArgs = [
164
170
  initialUrl,
165
171
  ];
166
172
  const logFd = fs.openSync(args['chrome-log'], 'a');
167
- const previousFrontmostPid = captureMacFrontmostProcess();
168
- const holdOperatorFocus = () => preserveMacFrontmostProcess(previousFrontmostPid);
173
+ const previousFrontmost = captureMacFrontmost();
174
+ const focusHold = startMacFocusHold(previousFrontmost);
169
175
  let browserPid;
170
176
  try {
171
- const application = process.platform === 'darwin' ? macApplicationForExecutable(args['chrome-bin']) : null;
172
- if (application) {
173
- beginDetachedLaunch(cdpPort, args.profile, activeValidationLease);
174
- execFileSync('open', ['-g', '-n', '-a', application, '--args', ...chromeArgs], {
175
- env: sanitizedChildEnv(),
176
- stdio: ['ignore', logFd, logFd],
177
+ try {
178
+ const application = process.platform === 'darwin' ? macApplicationForExecutable(args['chrome-bin']) : null;
179
+ if (application) {
180
+ beginDetachedLaunch(cdpPort, args.profile, activeValidationLease);
181
+ execFileSync('open', macBackgroundOpenArgs(application, chromeArgs), {
182
+ env: sanitizedChildEnv(),
183
+ stdio: ['ignore', logFd, logFd],
184
+ });
185
+ browserPid = waitForOwnedCdpPid(cdpPort, args.profile, args['extension-dir']);
186
+ if (browserPid === null) {
187
+ stopProfileProcessesSync(args.profile, { waitForAppearanceMs: 400 });
188
+ throw new Error(
189
+ `Chrome launched but did not expose an owned CDP listener on 127.0.0.1:${cdpPort}. ` +
190
+ `Next: inspect ${args['chrome-log']}, then rerun: mm-harness launch --adapter extension --build`,
191
+ );
192
+ }
193
+ } else {
194
+ beginDetachedLaunch(cdpPort, args.profile, activeValidationLease);
195
+ const child = spawn(args['chrome-bin'], chromeArgs, {
196
+ detached: true,
197
+ env: sanitizedChildEnv(),
198
+ stdio: ['ignore', logFd, logFd],
199
+ });
200
+ child.unref();
201
+ browserPid = waitForOwnedCdpPid(cdpPort, args.profile, args['extension-dir']);
202
+ if (browserPid === null) {
203
+ stopProfileProcessesSync(args.profile, { extraPids: child.pid ? [child.pid] : [] });
204
+ throw new Error(
205
+ `Chrome launched but did not expose an owned CDP listener on 127.0.0.1:${cdpPort}. ` +
206
+ `Next: inspect ${args['chrome-log']}, then rerun: mm-harness launch --adapter extension --build`,
207
+ );
208
+ }
209
+ }
210
+ pruneExtraHomeTabs(cdpPort, args['extension-dir']);
211
+ } finally {
212
+ fs.closeSync(logFd);
213
+ }
214
+ fs.writeFileSync(args['chrome-pid'], `${browserPid}\n`);
215
+ try {
216
+ writeRuntimeIdentity(runtimeDir, {
217
+ port: cdpPort,
218
+ pid: browserPid,
219
+ startedAt,
220
+ nonce: runtimeNonce,
177
221
  });
178
- holdOperatorFocus();
179
- browserPid = waitForOwnedCdpPid(cdpPort, args.profile, args['extension-dir'], holdOperatorFocus);
180
- if (browserPid === null) {
181
- throw new Error(`Chrome launched but did not expose CDP on 127.0.0.1:${cdpPort}`);
222
+ clearDetachedLaunchUnproven(args.profile);
223
+ if (!activeValidationLease) {
224
+ clearValidationPortQuarantine(cdpPort, args.profile);
182
225
  }
183
- } else {
184
- beginDetachedLaunch(cdpPort, args.profile, activeValidationLease);
185
- const child = spawn(args['chrome-bin'], chromeArgs, {
226
+ } catch (error) {
227
+ terminatePids([browserPid]);
228
+ fs.rmSync(args['chrome-pid'], { force: true });
229
+ const detail = error instanceof Error ? error.message : String(error);
230
+ throw new Error(
231
+ `Failed to persist Extension runtime identity under ${runtimeDir}: ${detail}. ` +
232
+ 'Next: fix owner write permissions for the runtime directory, then rerun: mm-harness launch --adapter extension --build',
233
+ );
234
+ }
235
+ if (process.platform === 'darwin') {
236
+ const wake = spawn('caffeinate', ['-dims', '-w', String(browserPid)], {
186
237
  detached: true,
187
- env: sanitizedChildEnv(),
188
- stdio: ['ignore', logFd, logFd],
238
+ stdio: 'ignore',
189
239
  });
190
- child.unref();
191
- holdOperatorFocus();
192
- browserPid = waitForOwnedCdpPid(cdpPort, args.profile, args['extension-dir'], holdOperatorFocus);
193
- if (browserPid === null) {
194
- if (child.pid) terminatePids([child.pid]);
195
- throw new Error(
196
- `Chrome launched but did not expose an owned CDP listener on 127.0.0.1:${cdpPort}. ` +
197
- `Next: inspect ${args['chrome-log']}, then rerun: mm-harness launch --adapter extension --build`,
198
- );
199
- }
240
+ wake.unref();
241
+ // Chrome often activates when the first window paints, which can be after CDP
242
+ // is already listening. Keep the previous app frontmost through that paint.
243
+ const settleMs = Number(process.env.MM_HARNESS_FOCUS_SETTLE_MS);
244
+ const waitSec = Number.isFinite(settleMs) && settleMs >= 0 ? settleMs / 1000 : 1;
245
+ if (waitSec > 0) spawnSync('sleep', [String(waitSec)]);
200
246
  }
201
247
  } finally {
202
- fs.closeSync(logFd);
248
+ stopMacFocusHold(focusHold);
249
+ preserveMacFrontmost(previousFrontmost);
203
250
  }
204
- fs.writeFileSync(args['chrome-pid'], `${browserPid}\n`);
205
- try {
206
- writeRuntimeIdentity(runtimeDir, {
207
- port: cdpPort,
208
- pid: browserPid,
209
- startedAt,
210
- nonce: runtimeNonce,
211
- });
212
- clearDetachedLaunchUnproven(args.profile);
213
- if (!activeValidationLease) {
214
- clearValidationPortQuarantine(cdpPort, args.profile);
215
- }
216
- } catch (error) {
217
- terminatePids([browserPid]);
218
- fs.rmSync(args['chrome-pid'], { force: true });
219
- const detail = error instanceof Error ? error.message : String(error);
220
- throw new Error(
221
- `Failed to persist Extension runtime identity under ${runtimeDir}: ${detail}. ` +
222
- 'Next: fix owner write permissions for the runtime directory, then rerun: mm-harness launch --adapter extension --build',
223
- );
224
- }
225
- if (process.platform === 'darwin') {
226
- const wake = spawn('caffeinate', ['-dims', '-w', String(browserPid)], {
227
- detached: true,
228
- stdio: 'ignore',
229
- });
230
- wake.unref();
231
- }
232
- holdOperatorFocus();
233
251
 
234
252
  function sanitizedChildEnv() {
235
253
  return {
@@ -265,13 +283,12 @@ function macApplicationForExecutable(executable) {
265
283
  return null;
266
284
  }
267
285
 
268
- function waitForOwnedCdpPid(port, profile, extensionDir, onWait) {
286
+ function waitForOwnedCdpPid(port, profile, extensionDir) {
269
287
  for (let i = 0; i < 300; i += 1) {
270
288
  const pid = cdpListenerPids(port).find((candidate) =>
271
289
  processIsOwnedHarnessBrowser(candidate, profile, extensionDir),
272
290
  );
273
291
  if (pid !== undefined) return pid;
274
- if (typeof onWait === 'function' && i % 10 === 0) onWait();
275
292
  spawnSync('sleep', ['0.1']);
276
293
  }
277
294
  return null;
@@ -428,3 +445,64 @@ function extensionHomeUrl(extensionDir) {
428
445
  const id = extensionIdFromExtensionDir(extensionDir);
429
446
  return id ? `chrome-extension://${id}/home.html` : '';
430
447
  }
448
+
449
+ function cdpHttp(port, pathname) {
450
+ const result = spawnSync(process.execPath, ['-e', `
451
+ const http = require('http');
452
+ http.get('http://127.0.0.1:${Number(port)}${pathname}', (res) => {
453
+ let body = '';
454
+ res.on('data', (chunk) => { body += chunk; });
455
+ res.on('end', () => process.stdout.write(body));
456
+ }).on('error', () => process.exit(1));
457
+ `], { encoding: 'utf8', timeout: 4000 });
458
+ if (result.status !== 0) return null;
459
+ return result.stdout;
460
+ }
461
+
462
+ function pruneExtraHomeTabs(port, extensionDir) {
463
+ const id = extensionIdFromExtensionDir(extensionDir);
464
+ const raw = cdpHttp(port, '/json/list');
465
+ if (!id || !raw) return;
466
+ let targets;
467
+ try {
468
+ targets = JSON.parse(raw);
469
+ } catch {
470
+ return;
471
+ }
472
+ if (!Array.isArray(targets)) return;
473
+ const homes = targets.filter((target) => (
474
+ target &&
475
+ target.type === 'page' &&
476
+ typeof target.id === 'string' &&
477
+ typeof target.url === 'string' &&
478
+ target.url.includes(`chrome-extension://${id}/`) &&
479
+ target.url.includes('/home.html')
480
+ ));
481
+ const strays = targets.filter((target) => (
482
+ target &&
483
+ target.type === 'page' &&
484
+ typeof target.id === 'string' &&
485
+ typeof target.url === 'string' &&
486
+ (
487
+ target.url === 'about:blank' ||
488
+ target.url === 'chrome://newtab/' ||
489
+ target.url.startsWith('chrome://new-tab-page')
490
+ )
491
+ ));
492
+ if (homes.length === 0) return;
493
+ const keeper = homes.find((target) => (
494
+ typeof target.title === 'string' &&
495
+ target.title.trim() !== '' &&
496
+ target.title.trim() !== 'MetaMask'
497
+ )) ?? homes[0];
498
+ if (homes.length > 1) {
499
+ for (const extra of homes) {
500
+ if (keeper && extra.id === keeper.id) continue;
501
+ cdpHttp(port, `/json/close/${extra.id}`);
502
+ }
503
+ }
504
+ for (const stray of strays) {
505
+ if (keeper && stray.id === keeper.id) continue;
506
+ cdpHttp(port, `/json/close/${stray.id}`);
507
+ }
508
+ }
@@ -1,41 +1,197 @@
1
1
  'use strict';
2
2
 
3
- const { execFileSync } = require('node:child_process');
3
+ const { execFileSync, spawn } = require('node:child_process');
4
4
 
5
- function captureMacFrontmostProcess() {
5
+ const HOLD_INTERVAL_MS = 20;
6
+ const toolEnv = () => ({ ...process.env });
7
+
8
+ function captureMacFrontmost() {
6
9
  if (process.platform !== 'darwin') return null;
7
10
  try {
8
- const value = execFileSync('osascript', [
9
- '-e',
10
- 'tell application "System Events" to get unix id of first application process whose frontmost is true',
11
- ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 1_000 }).trim();
12
- const pid = Number.parseInt(value, 10);
11
+ const asn = execFileSync('lsappinfo', ['front'], {
12
+ encoding: 'utf8',
13
+ env: toolEnv(),
14
+ stdio: ['ignore', 'pipe', 'ignore'],
15
+ timeout: 200,
16
+ }).trim();
17
+ if (!asn) return null;
18
+ return parseLsappinfo(execFileSync('lsappinfo', ['info', asn], {
19
+ encoding: 'utf8',
20
+ env: toolEnv(),
21
+ stdio: ['ignore', 'pipe', 'ignore'],
22
+ timeout: 200,
23
+ }));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function captureMacFrontmostProcess() {
30
+ return captureMacFrontmost()?.pid ?? null;
31
+ }
32
+
33
+ function parseLsappinfo(info) {
34
+ const pid = Number.parseInt((info.match(/\bpid\s*=\s*(\d+)/u) || info.match(/"pid"=(\d+)/u) || [])[1], 10);
35
+ const bundlePath = (info.match(/bundle path="([^"]+)"/u) || info.match(/"LSBundlePath"="([^"]+)"/u) || [])[1];
36
+ const name = (info.match(/^"([^"]+)"/mu) || info.match(/"LSDisplayName"="([^"]+)"/u) || [])[1];
37
+ if (!Number.isInteger(pid) || pid <= 0 || !bundlePath) return null;
38
+ return { pid, bundlePath, name: name || bundlePath };
39
+ }
40
+
41
+ function currentFrontmostPid() {
42
+ try {
43
+ const asn = execFileSync('lsappinfo', ['front'], {
44
+ encoding: 'utf8',
45
+ env: toolEnv(),
46
+ stdio: ['ignore', 'pipe', 'ignore'],
47
+ timeout: 200,
48
+ }).trim();
49
+ if (!asn) return null;
50
+ const value = execFileSync('lsappinfo', ['info', '-only', 'pid', asn], {
51
+ encoding: 'utf8',
52
+ env: toolEnv(),
53
+ stdio: ['ignore', 'pipe', 'ignore'],
54
+ timeout: 200,
55
+ }).trim();
56
+ const pid = Number.parseInt((value.match(/"pid"=(\d+)/u) || [])[1], 10);
13
57
  return Number.isInteger(pid) && pid > 0 ? pid : null;
14
58
  } catch {
15
59
  return null;
16
60
  }
17
61
  }
18
62
 
63
+ function restoreMacFrontmost(target) {
64
+ if (process.platform !== 'darwin' || !target?.bundlePath) return false;
65
+ try {
66
+ execFileSync('open', ['-a', target.bundlePath], {
67
+ env: toolEnv(),
68
+ stdio: 'ignore',
69
+ timeout: 500,
70
+ });
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
19
77
  function restoreMacFrontmostProcess(pid) {
20
78
  if (process.platform !== 'darwin' || !Number.isInteger(pid) || pid <= 0) return false;
21
79
  try {
22
- execFileSync('osascript', [
23
- '-e',
24
- `tell application "System Events" to set frontmost of first application process whose unix id is ${pid} to true`,
25
- ], { stdio: 'ignore', timeout: 1_000 });
26
- return true;
80
+ const asn = execFileSync('lsappinfo', ['find', `pid=${pid}`], {
81
+ encoding: 'utf8',
82
+ env: toolEnv(),
83
+ stdio: ['ignore', 'pipe', 'ignore'],
84
+ timeout: 200,
85
+ }).trim();
86
+ if (!asn) return false;
87
+ return restoreMacFrontmost(parseLsappinfo(execFileSync('lsappinfo', ['info', asn], {
88
+ encoding: 'utf8',
89
+ env: toolEnv(),
90
+ stdio: ['ignore', 'pipe', 'ignore'],
91
+ timeout: 200,
92
+ })));
27
93
  } catch {
28
94
  return false;
29
95
  }
30
96
  }
31
97
 
98
+ function preserveMacFrontmost(target) {
99
+ if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') return false;
100
+ if (process.platform !== 'darwin' || !target?.bundlePath || !Number.isInteger(target.pid) || target.pid <= 0) {
101
+ return false;
102
+ }
103
+ const current = currentFrontmostPid();
104
+ if (current === target.pid) return true;
105
+ return restoreMacFrontmost(target);
106
+ }
107
+
32
108
  function preserveMacFrontmostProcess(pid) {
33
109
  if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') return false;
110
+ if (!Number.isInteger(pid) || pid <= 0) return false;
111
+ const current = currentFrontmostPid();
112
+ if (current === pid) return true;
34
113
  return restoreMacFrontmostProcess(pid);
35
114
  }
36
115
 
116
+ function macBackgroundOpenArgs(application, chromeArgs) {
117
+ return ['-g', '-n', '-a', application, '--args', ...chromeArgs];
118
+ }
119
+
120
+ function startMacFocusHold(targetOrPid) {
121
+ if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') return null;
122
+ if (process.platform !== 'darwin') return null;
123
+ const target = typeof targetOrPid === 'number'
124
+ ? { pid: targetOrPid, bundlePath: bundlePathForPid(targetOrPid) }
125
+ : targetOrPid;
126
+ if (!target?.bundlePath || !Number.isInteger(target.pid) || target.pid <= 0) return null;
127
+ return spawn(process.execPath, [
128
+ __filename,
129
+ '--hold',
130
+ String(target.pid),
131
+ target.bundlePath,
132
+ String(process.pid),
133
+ ], { stdio: 'ignore', env: toolEnv() });
134
+ }
135
+
136
+ function bundlePathForPid(pid) {
137
+ try {
138
+ const asn = execFileSync('lsappinfo', ['find', `pid=${pid}`], {
139
+ encoding: 'utf8',
140
+ env: toolEnv(),
141
+ stdio: ['ignore', 'pipe', 'ignore'],
142
+ timeout: 200,
143
+ }).trim();
144
+ return parseLsappinfo(execFileSync('lsappinfo', ['info', asn], {
145
+ encoding: 'utf8',
146
+ env: toolEnv(),
147
+ stdio: ['ignore', 'pipe', 'ignore'],
148
+ timeout: 200,
149
+ }))?.bundlePath ?? null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ function stopMacFocusHold(child) {
156
+ if (!child || !Number.isInteger(child.pid) || child.pid <= 0) return;
157
+ try {
158
+ process.kill(child.pid, 'SIGTERM');
159
+ } catch (error) {
160
+ if (error.code !== 'ESRCH') throw error;
161
+ }
162
+ }
163
+
164
+ function parentAlive(pid) {
165
+ if (!Number.isInteger(pid) || pid <= 0) return false;
166
+ try {
167
+ process.kill(pid, 0);
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ if (require.main === module && process.argv[2] === '--hold') {
175
+ const pid = Number.parseInt(process.argv[3], 10);
176
+ const bundlePath = process.argv[4];
177
+ const parentPid = Number.parseInt(process.argv[5], 10);
178
+ const target = { pid, bundlePath };
179
+ const hold = () => {
180
+ if (!parentAlive(parentPid)) process.exit(0);
181
+ preserveMacFrontmost(target);
182
+ };
183
+ hold();
184
+ setInterval(hold, HOLD_INTERVAL_MS);
185
+ }
186
+
37
187
  module.exports = {
188
+ captureMacFrontmost,
38
189
  captureMacFrontmostProcess,
190
+ macBackgroundOpenArgs,
191
+ preserveMacFrontmost,
39
192
  preserveMacFrontmostProcess,
193
+ restoreMacFrontmost,
40
194
  restoreMacFrontmostProcess,
195
+ startMacFocusHold,
196
+ stopMacFocusHold,
41
197
  };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { execFileSync } = require('node:child_process');
3
+ const { execFileSync, spawnSync } = require('node:child_process');
4
4
 
5
5
  function commandHasExactProfile(command, profile) {
6
6
  const expected = `--user-data-dir=${profile}`;
@@ -43,6 +43,53 @@ function delay(milliseconds) {
43
43
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
44
44
  }
45
45
 
46
+ function pidAlive(pid) {
47
+ if (!Number.isInteger(pid) || pid <= 0) return false;
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true;
51
+ } catch (error) {
52
+ if (error.code === 'ESRCH') return false;
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ function uniquePids(pids) {
58
+ return [...new Set(pids)].filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
59
+ }
60
+
61
+ // Reap every live process that still owns this profile, then refuse to continue
62
+ // if any survive. `open -n` otherwise starts a second headed Chrome on the same
63
+ // user-data-dir while the previous instance is still dying.
64
+ function stopProfileProcessesSync(profile, { extraPids = [], timeoutMs = 5_000, waitForAppearanceMs = 0 } = {}) {
65
+ const deadline = Date.now() + timeoutMs;
66
+ const appearUntil = Date.now() + Math.max(0, waitForAppearanceMs);
67
+ let ownersSince = 0;
68
+ while (Date.now() < deadline) {
69
+ const pids = uniquePids([
70
+ ...profileProcessPids(profile),
71
+ ...extraPids.filter(pidAlive),
72
+ ]);
73
+ if (pids.length === 0) {
74
+ if (Date.now() >= appearUntil) return;
75
+ } else {
76
+ if (ownersSince === 0) ownersSince = Date.now();
77
+ signalPids(pids, Date.now() - ownersSince >= 500 ? 'SIGKILL' : 'SIGTERM');
78
+ }
79
+ spawnSync('sleep', ['0.1']);
80
+ }
81
+ const remaining = uniquePids([
82
+ ...profileProcessPids(profile),
83
+ ...extraPids.filter(pidAlive),
84
+ ]);
85
+ if (remaining.length > 0) {
86
+ throw new Error(
87
+ `Owned Chrome processes survived stop (pid ${remaining.join(', ')}). ` +
88
+ 'Next: mm-harness stop --adapter extension --target <checkout>',
89
+ );
90
+ }
91
+ }
92
+
46
93
  async function stopProfileProcesses(profile, { quietMs = 0, timeoutMs = 5_000 } = {}) {
47
94
  const deadline = Date.now() + timeoutMs;
48
95
  let quietSince = Date.now();
@@ -66,4 +113,4 @@ async function stopProfileProcesses(profile, { quietMs = 0, timeoutMs = 5_000 }
66
113
  throw new Error(`Extension validation profile did not remain quiescent for ${quietMs}ms.`);
67
114
  }
68
115
 
69
- module.exports = { profileProcessPids, stopProfileProcesses };
116
+ module.exports = { profileProcessPids, stopProfileProcesses, stopProfileProcessesSync };
@@ -29,7 +29,6 @@ done
29
29
  unset _tv_src
30
30
 
31
31
  runtime_dir="$TARGET/$(recipe_runtime_dir)"
32
- console_log="$runtime_dir/extension-console.log"
33
32
  set +e
34
33
 
35
34
  if command -v tmux_viewer_close_marker >/dev/null 2>&1; then
@@ -48,7 +47,7 @@ ps -axo pid=,command= 2>/dev/null | while read -r pid command; do
48
47
  *) continue ;;
49
48
  esac
50
49
  case "$command" in
51
- *"--log $console_log"*|*"$console_log"*) kill "$pid" 2>/dev/null || true ;;
50
+ *"$runtime_dir/"*) kill "$pid" 2>/dev/null || true ;;
52
51
  esac
53
52
  done
54
53
 
@@ -3,8 +3,9 @@ const require2 = createRequire(import.meta.url);
3
3
  const processOwnership = require2(
4
4
  "../../../adapters/extension/lib/validation-process-ownership.cjs"
5
5
  );
6
- const { profileProcessPids, stopProfileProcesses } = processOwnership;
6
+ const { profileProcessPids, stopProfileProcesses, stopProfileProcessesSync } = processOwnership;
7
7
  export {
8
8
  profileProcessPids,
9
- stopProfileProcesses
9
+ stopProfileProcesses,
10
+ stopProfileProcessesSync
10
11
  };
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { recipeRuntimeDir } from "../paths.js";
5
6
  import {
@@ -131,11 +132,53 @@ function stopExtensionWatcher(target) {
131
132
  }
132
133
  return ownedPids.length;
133
134
  }
135
+ function commandFlagValue(command, flag) {
136
+ const escaped = escapeRegex(flag);
137
+ const patterns = [
138
+ new RegExp(`(?:^|\\s)${escaped}=([^\\s"]+)`, "u"),
139
+ new RegExp(`(?:^|\\s)${escaped}="([^"]+)"`, "u"),
140
+ new RegExp(`(?:^|\\s)${escaped}='([^']+)'`, "u"),
141
+ new RegExp(`(?:^|\\s)${escaped}\\s+([^\\s"]+)`, "u")
142
+ ];
143
+ for (const pattern of patterns) {
144
+ const match = pattern.exec(command);
145
+ if (match?.[1]) return match[1];
146
+ }
147
+ return null;
148
+ }
149
+ function pathIsInsideDir(candidate, root) {
150
+ const resolved = path.resolve(candidate);
151
+ const base = path.resolve(root);
152
+ return resolved === base || resolved.startsWith(`${base}${path.sep}`);
153
+ }
154
+ function chromeUserDataDirOwnedByCheckout(command, runtimeAbs) {
155
+ const value = commandFlagValue(command, "--user-data-dir");
156
+ if (!value) return false;
157
+ return pathIsInsideDir(value, runtimeAbs);
158
+ }
159
+ function commandLoadsCheckoutExtension(command, targetAbs) {
160
+ const loaded = commandFlagValue(command, "--load-extension") ?? commandFlagValue(command, "--disable-extensions-except");
161
+ if (!loaded) return false;
162
+ return pathIsInsideDir(loaded, targetAbs);
163
+ }
164
+ function isSharedOrDefaultBrowserProfile(profile) {
165
+ const resolved = path.resolve(profile);
166
+ const home = os.homedir();
167
+ const osDefaults = [
168
+ path.join(home, "Library/Application Support/Google/Chrome"),
169
+ path.join(home, "Library/Application Support/Chromium"),
170
+ path.join(home, "Library/Application Support/Microsoft Edge"),
171
+ path.join(home, ".config/google-chrome"),
172
+ path.join(home, ".config/chromium"),
173
+ path.join(home, ".config/microsoft-edge")
174
+ ];
175
+ const underDefault = (dir) => resolved === dir || resolved.startsWith(`${dir}${path.sep}`);
176
+ return resolved === home || resolved.includes(`${path.sep}.chrome-farmslot`) || osDefaults.some(underDefault);
177
+ }
134
178
  function stopExtensionRuntime(target) {
135
179
  const resolved = path.resolve(target);
136
180
  const runtimeAbs = path.join(resolved, recipeRuntimeDir());
137
- const configuredProfile = process.env.CHROME_USER_DATA_DIR || path.join(runtimeAbs, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
138
- const profiles = process.env.CHROME_USER_DATA_DIR ? [path.resolve(configuredProfile)] : [configuredProfile, path.join(runtimeAbs, "chrome-profile-recipe"), path.join(runtimeAbs, "chrome-profile-pw")].map((value) => path.resolve(value));
181
+ const extraProfile = process.env.CHROME_USER_DATA_DIR ? path.resolve(process.env.CHROME_USER_DATA_DIR) : null;
139
182
  let signalled = stopExtensionWatcher(resolved);
140
183
  const ownedBrowserPids = /* @__PURE__ */ new Set();
141
184
  try {
@@ -144,7 +187,16 @@ function stopExtensionRuntime(target) {
144
187
  const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
145
188
  if (!match) continue;
146
189
  const pid = Number(match[1]);
147
- if (pid !== process.pid && profiles.some((profile) => commandHasExactArg(match[2], "--user-data-dir", profile))) ownedBrowserPids.add(pid);
190
+ if (pid === process.pid) continue;
191
+ if (chromeUserDataDirOwnedByCheckout(match[2], runtimeAbs)) {
192
+ ownedBrowserPids.add(pid);
193
+ continue;
194
+ }
195
+ if (!extraProfile || pathIsInsideDir(extraProfile, runtimeAbs) || isSharedOrDefaultBrowserProfile(extraProfile)) continue;
196
+ const configured = commandFlagValue(match[2], "--user-data-dir");
197
+ if (configured && path.resolve(configured) === extraProfile && commandLoadsCheckoutExtension(match[2], resolved)) {
198
+ ownedBrowserPids.add(pid);
199
+ }
148
200
  }
149
201
  } catch {
150
202
  }
@@ -203,22 +255,16 @@ function processAlive(pid) {
203
255
  return error.code === "EPERM";
204
256
  }
205
257
  }
206
- function commandHasExactArg(command, flag, value) {
207
- const escapedFlag = escapeRegex(flag);
208
- const escapedValue = escapeRegex(value);
209
- return [
210
- new RegExp(`(?:^|\\s)${escapedFlag}=${escapedValue}(?=\\s|$)`, "u"),
211
- new RegExp(`(?:^|\\s)${escapedFlag}="${escapedValue}"(?=\\s|$)`, "u"),
212
- new RegExp(`(?:^|\\s)${escapedFlag}='${escapedValue}'(?=\\s|$)`, "u"),
213
- new RegExp(`(?:^|\\s)${escapedFlag}\\s+${escapedValue}(?=\\s|$)`, "u")
214
- ].some((pattern) => pattern.test(command));
215
- }
216
258
  function escapeRegex(value) {
217
259
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
218
260
  }
219
261
  export {
220
262
  applyKVLines,
263
+ chromeUserDataDirOwnedByCheckout,
264
+ commandFlagValue,
221
265
  isExtensionWatcherLive,
266
+ isSharedOrDefaultBrowserProfile,
267
+ pathIsInsideDir,
222
268
  resolveExtensionSlotPorts,
223
269
  resolveMobileSlotPorts,
224
270
  stopExtensionRuntime,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.50.5",
3
+ "version": "0.50.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"