@deeeed/metamask-harness 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +41 -0
  3. package/adapters/extension/build-lavamoat.sh +2 -1
  4. package/adapters/extension/ensure-browser.sh +82 -9
  5. package/adapters/extension/inject.mjs +1 -0
  6. package/adapters/extension/launch-browser.cjs +83 -1
  7. package/adapters/extension/lib/chrome-args.cjs +325 -1
  8. package/adapters/extension/lib/playwright-cdp.cjs +34 -0
  9. package/adapters/extension/lib/slot-title.cjs +2 -4
  10. package/adapters/extension/lib/validation-launch-supervisor.cjs +292 -0
  11. package/adapters/extension/lib/validation-process-ownership.cjs +69 -0
  12. package/adapters/extension/reattach.sh +2 -1
  13. package/adapters/extension/sidepanel-toggle.sh +14 -96
  14. package/adapters/extension/wallet-fixture-state.cjs +8 -31
  15. package/adapters/manifest.json +16 -0
  16. package/adapters/shared/private-atomic-write.cjs +47 -0
  17. package/adapters/shared/setup-base.sh +864 -0
  18. package/dist/adapters/extension/runtime.js +367 -24
  19. package/dist/adapters/extension/validation-process-ownership.js +10 -0
  20. package/dist/cli-commands.js +1 -0
  21. package/dist/command-contract.js +12 -0
  22. package/dist/commands/launch/extension.js +130 -19
  23. package/dist/commands/setup-base.js +24 -0
  24. package/dist/mm-harness-cli.js +28 -2
  25. package/library/actions/extension/analytics/consent.mjs +203 -0
  26. package/library/actions/extension/analytics/set_consent.mjs +19 -143
  27. package/library/actions/extension/perps/perps.mjs +2 -16
  28. package/library/actions/extension/perps/state.mjs +20 -0
  29. package/library/actions/extension/wallet/list_accounts.mjs +3 -25
  30. package/library/actions/extension/wallet/read_state.mjs +3 -23
  31. package/library/actions/extension/wallet/select_account.mjs +6 -33
  32. package/library/actions/extension/wallet/setup.mjs +2 -20
  33. package/library/actions/extension/wallet/state.mjs +111 -0
  34. package/library/recipes/runner/action-validation.extension.recipe.json +1 -1
  35. package/library/recipes/runner/action-validation.mobile.recipe.json +1 -1
  36. package/package.json +7 -4
  37. package/scripts/site-contrast.mjs +538 -0
  38. package/site/architecture.html +415 -0
  39. package/site/assets/progress.mjs +272 -0
  40. package/site/assets/style.css +808 -0
  41. package/site/cheatsheet.html +305 -0
  42. package/site/index.html +643 -0
  43. package/site/recipes.html +396 -0
  44. package/site/reviewers.html +374 -0
  45. package/site/tutorials/index.html +180 -0
  46. package/site/tutorials/v1.html +211 -0
  47. package/site/tutorials/v2.html +207 -0
  48. package/site/tutorials/v3.html +214 -0
  49. package/site/tutorials/v4.html +195 -0
  50. package/site/tutorials/v5.html +163 -0
  51. package/site/tutorials/v6.html +165 -0
  52. package/site/tutorials/v7.html +184 -0
@@ -1,7 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
+ import { createRequire } from "node:module";
3
4
  import path from "node:path";
4
- import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, recipeRuntimeDir, resolveLocalProtocolRoot, resolveRequiredLocalProtocolRoot, runnerDir } from "../../paths.js";
5
+ import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, recipeHarnessPath, recipeRuntimeDir, resolveLocalProtocolRoot, runnerDir } from "../../paths.js";
6
+ import { profileProcessPids } from "./validation-process-ownership.js";
7
+ const require2 = createRequire(import.meta.url);
8
+ const { detachedLaunchUnprovenPath, hasDetachedLaunchUnproven, shellQuote } = require2(
9
+ "../../../adapters/extension/lib/chrome-args.cjs"
10
+ );
5
11
  const {
6
12
  CdpSession,
7
13
  jsonGet,
@@ -24,6 +30,7 @@ async function prepareExtensionRuntime(options) {
24
30
  projectRoot,
25
31
  cdpPort,
26
32
  slotId: slot.id,
33
+ launchTimeoutMs: options.validationLaunchTimeoutMs ?? 18e4,
27
34
  validationRuntimeDir: options.validationRuntimeDir ?? `${recipeRuntimeDir()}/validation-${cdpPort}`
28
35
  });
29
36
  }
@@ -323,27 +330,115 @@ function resolveExtensionSlot(projectRoot, requestedSlot) {
323
330
  return null;
324
331
  }
325
332
  async function launchHostValidationBrowser(options) {
326
- const protocolRoot = resolveRequiredLocalProtocolRoot("Extension validation browser launch");
327
- const script = path.join(protocolRoot, "projects/metamask-extension-farm/setup/launch-validation-browser.sh");
333
+ const script = recipeHarnessPath(options.projectRoot, "extension", "scripts", "live.sh");
328
334
  if (!fs.existsSync(script)) {
329
335
  throw new Error(`Extension validation launcher not found: ${script}`);
330
336
  }
331
- await killCdpPort(options.cdpPort);
332
- const result = await runProcess("bash", [
333
- script,
334
- options.slotId,
337
+ const validationRuntimeBaseDir = path.relative(
338
+ options.projectRoot,
339
+ path.resolve(options.projectRoot, options.validationRuntimeDir)
340
+ );
341
+ const escapesCheckout = !validationRuntimeBaseDir || validationRuntimeBaseDir === ".." || validationRuntimeBaseDir.startsWith(`..${path.sep}`) || path.isAbsolute(validationRuntimeBaseDir);
342
+ if (escapesCheckout) {
343
+ throw new Error(`Extension validation runtime must stay inside the target checkout: ${options.validationRuntimeDir}`);
344
+ }
345
+ const validationRuntimeBasePath = path.join(options.projectRoot, validationRuntimeBaseDir);
346
+ let currentPath = options.projectRoot;
347
+ for (const segment of validationRuntimeBaseDir.split(path.sep)) {
348
+ currentPath = path.join(currentPath, segment);
349
+ const currentStat = fs.lstatSync(currentPath, { throwIfNoEntry: false });
350
+ if (!currentStat) break;
351
+ if (currentStat.isSymbolicLink()) {
352
+ throw new Error(`Extension validation runtime must stay inside the target checkout: ${options.validationRuntimeDir}`);
353
+ }
354
+ }
355
+ fs.mkdirSync(validationRuntimeBasePath, { recursive: true });
356
+ const realRuntimeBaseDir = path.relative(
357
+ fs.realpathSync(options.projectRoot),
358
+ fs.realpathSync(validationRuntimeBasePath)
359
+ );
360
+ if (!realRuntimeBaseDir || realRuntimeBaseDir === ".." || realRuntimeBaseDir.startsWith(`..${path.sep}`) || path.isAbsolute(realRuntimeBaseDir)) {
361
+ throw new Error(`Extension validation runtime must stay inside the target checkout: ${options.validationRuntimeDir}`);
362
+ }
363
+ const validationRuntimeDir = path.join(validationRuntimeBaseDir, `port-${options.cdpPort}`);
364
+ const validationRuntimePath = path.join(options.projectRoot, validationRuntimeDir);
365
+ if (fs.lstatSync(validationRuntimePath, { throwIfNoEntry: false })?.isSymbolicLink()) {
366
+ throw new Error(`Extension validation runtime must stay inside the target checkout: ${options.validationRuntimeDir}`);
367
+ }
368
+ fs.mkdirSync(validationRuntimePath, { recursive: true });
369
+ const supervisor = await startValidationLaunchSupervisor(options.cdpPort);
370
+ try {
371
+ return await launchLockedValidationBrowser({
372
+ ...options,
373
+ script,
374
+ supervisor,
375
+ validationRuntimeDir,
376
+ validationRuntimePath
377
+ });
378
+ } finally {
379
+ await supervisor.close();
380
+ }
381
+ }
382
+ async function launchLockedValidationBrowser(options) {
383
+ const { validationRuntimeDir, validationRuntimePath } = options;
384
+ const walletFixtureDestination = path.join(validationRuntimePath, "wallet-fixture.json");
385
+ const validationProfileRoot = path.join(validationRuntimePath, "validation-profile-pw");
386
+ const validationArtifactsRoot = path.join(validationRuntimePath, "live");
387
+ for (const destination of [walletFixtureDestination, validationProfileRoot, validationArtifactsRoot]) {
388
+ if (fs.lstatSync(destination, { throwIfNoEntry: false })?.isSymbolicLink()) {
389
+ throw new Error(`Extension validation runtime must stay inside the target checkout: ${options.validationRuntimeDir}`);
390
+ }
391
+ }
392
+ fs.mkdirSync(validationProfileRoot, { recursive: true });
393
+ fs.mkdirSync(validationArtifactsRoot, { recursive: true });
394
+ assertNoQuarantinedValidationProfiles(validationProfileRoot);
395
+ await stopOwnedCdpListener(options.cdpPort, [
396
+ validationRuntimePath,
397
+ path.join(options.projectRoot, recipeRuntimeDir())
398
+ ]);
399
+ assertNoLiveValidationProfileOwners(validationProfileRoot);
400
+ pruneValidationRuns(validationProfileRoot, 0);
401
+ pruneValidationRuns(validationArtifactsRoot, 4);
402
+ const validationProfilePath = fs.mkdtempSync(path.join(validationProfileRoot, "run-"));
403
+ const validationArtifactsPath = fs.mkdtempSync(path.join(validationArtifactsRoot, "run-"));
404
+ if (fs.existsSync(walletFixtureDestination)) fs.unlinkSync(walletFixtureDestination);
405
+ fs.copyFileSync(
406
+ path.join(options.projectRoot, recipeRuntimeDir(), "wallet-fixture.json"),
407
+ walletFixtureDestination,
408
+ fs.constants.COPYFILE_EXCL
409
+ );
410
+ const result = await options.supervisor.run("bash", [
411
+ options.script,
412
+ "--target",
413
+ options.projectRoot,
335
414
  "--cdp-port",
336
415
  String(options.cdpPort),
337
- "--runtime-dir",
338
- options.validationRuntimeDir,
339
- "--no-landing"
416
+ "--launch-existing-dist",
417
+ "--chrome-user-data-dir",
418
+ validationProfilePath,
419
+ "--artifacts-dir",
420
+ validationArtifactsPath
340
421
  ], {
341
- cwd: protocolRoot,
342
- env: process.env,
343
- timeoutMs: 18e4
344
- });
422
+ cwd: options.projectRoot,
423
+ env: {
424
+ ...process.env,
425
+ MM_HARNESS_BIN: path.join(runnerDir, "bin/mm-harness"),
426
+ PLAYWRIGHT_BROWSERS_PATH: path.join(
427
+ options.projectRoot,
428
+ recipeRuntimeDir(),
429
+ "ms-playwright"
430
+ ),
431
+ RECIPE_RUNTIME_DIR: validationRuntimeDir
432
+ },
433
+ profile: validationProfilePath
434
+ }, options.launchTimeoutMs);
435
+ if (result.error) {
436
+ throw new Error(`Extension validation launcher supervision failed: ${result.error}
437
+ ${result.stdout}
438
+ ${result.stderr}`);
439
+ }
345
440
  if (result.timedOut) {
346
- throw new Error(`Extension validation launcher timed out after 180000ms.
441
+ throw new Error(`Extension validation launcher timed out after ${options.launchTimeoutMs}ms.
347
442
  ${result.stdout}
348
443
  ${result.stderr}`);
349
444
  }
@@ -352,27 +447,192 @@ ${result.stderr}`);
352
447
  ${result.stdout}
353
448
  ${result.stderr}`);
354
449
  }
450
+ const identity = readValidationRuntimeIdentity(validationRuntimePath, options.cdpPort);
451
+ const listenerPids = await inspectCdpListenerPids(options.cdpPort);
452
+ if (!identity || listenerPids.length !== 1 || listenerPids[0] !== identity.pid || !profileProcessPids(validationProfilePath).includes(identity.pid) || !await browserEchoesRuntimeNonce(options.cdpPort, identity.nonce)) {
453
+ throw new Error("Extension validation launcher exited without a live nonce-owned browser.");
454
+ }
455
+ await options.supervisor.release(true);
355
456
  return {
356
457
  launched: true,
357
458
  slotId: options.slotId,
358
459
  cdpPort: options.cdpPort,
359
- runtimeDir: options.validationRuntimeDir,
460
+ runtimeDir: validationRuntimeDir,
360
461
  stdout: result.stdout
361
462
  };
362
463
  }
464
+ async function startValidationLaunchSupervisor(cdpPort) {
465
+ const supervisorPath = path.join(runnerDir, "adapters/extension/lib/validation-launch-supervisor.cjs");
466
+ const child = spawn(process.execPath, [supervisorPath, String(cdpPort)], {
467
+ cwd: runnerDir,
468
+ env: process.env,
469
+ stdio: ["ignore", "pipe", "pipe", "ipc"]
470
+ });
471
+ let stdout = "";
472
+ let stderr = "";
473
+ let ready = false;
474
+ let runStarted = false;
475
+ let resultSettled = false;
476
+ let released = false;
477
+ let releaseError;
478
+ let resolveResult;
479
+ let rejectResult;
480
+ let resolveReady;
481
+ let rejectReady;
482
+ const readyPromise = new Promise((resolve, reject) => {
483
+ resolveReady = resolve;
484
+ rejectReady = reject;
485
+ });
486
+ const exitPromise = new Promise((resolve) => {
487
+ child.once("exit", (exitCode, signalCode) => resolve({ exitCode, signalCode }));
488
+ });
489
+ child.stdout.on("data", (chunk) => {
490
+ stdout += chunk;
491
+ });
492
+ child.stderr.on("data", (chunk) => {
493
+ stderr += chunk;
494
+ });
495
+ child.on("message", (message) => {
496
+ if (message?.type === "ready") {
497
+ ready = true;
498
+ resolveReady?.();
499
+ return;
500
+ }
501
+ if (message?.type === "error") {
502
+ const error = new Error(String(message.message || "Extension validation supervisor failed."));
503
+ if (!ready) rejectReady?.(error);
504
+ else rejectResult?.(error);
505
+ return;
506
+ }
507
+ if (message?.type === "result") {
508
+ resultSettled = true;
509
+ resolveResult?.({
510
+ exitCode: typeof message.exitCode === "number" ? message.exitCode : null,
511
+ timedOut: message.timedOut === true,
512
+ ...typeof message.error === "string" ? { error: message.error } : {}
513
+ });
514
+ return;
515
+ }
516
+ if (message?.type === "release-error") {
517
+ releaseError = new Error(String(message.message || "Extension validation supervisor cleanup failed."));
518
+ }
519
+ });
520
+ child.once("error", (error) => {
521
+ if (!ready) rejectReady?.(error);
522
+ else rejectResult?.(error);
523
+ });
524
+ child.once("exit", (exitCode) => {
525
+ const error = new Error(`Extension validation supervisor exited with ${exitCode}.
526
+ ${stdout}
527
+ ${stderr}`);
528
+ if (!ready) rejectReady?.(error);
529
+ else if (runStarted && !resultSettled) rejectResult?.(error);
530
+ });
531
+ await readyPromise;
532
+ return {
533
+ async run(command, args, options, timeoutMs) {
534
+ if (runStarted) throw new Error("Extension validation supervisor already launched.");
535
+ runStarted = true;
536
+ const resultPromise = new Promise((resolve, reject) => {
537
+ resolveResult = resolve;
538
+ rejectResult = reject;
539
+ });
540
+ child.send({
541
+ type: "run",
542
+ command,
543
+ args,
544
+ cwd: options.cwd,
545
+ env: options.env,
546
+ profile: options.profile,
547
+ timeoutMs
548
+ });
549
+ const result = await resultPromise;
550
+ return { ...result, stdout, stderr };
551
+ },
552
+ async release(preserveProfileOwner) {
553
+ if (released) return;
554
+ released = true;
555
+ if (child.connected) child.send({ type: "release", preserveProfileOwner });
556
+ const exit = await exitPromise;
557
+ if (releaseError) throw releaseError;
558
+ if (exit.exitCode !== 0) {
559
+ throw new Error(`Extension validation supervisor exited with ${exit.exitCode ?? exit.signalCode}.
560
+ ${stdout}
561
+ ${stderr}`);
562
+ }
563
+ },
564
+ async close() {
565
+ if (released) return;
566
+ released = true;
567
+ if (child.exitCode === null && child.signalCode === null && child.connected) {
568
+ child.send(runStarted ? { type: "release", preserveProfileOwner: false } : { type: "cancel" });
569
+ }
570
+ const exit = await exitPromise;
571
+ if (releaseError) throw releaseError;
572
+ if (exit.exitCode !== 0) {
573
+ throw new Error(`Extension validation supervisor exited with ${exit.exitCode ?? exit.signalCode}.
574
+ ${stdout}
575
+ ${stderr}`);
576
+ }
577
+ }
578
+ };
579
+ }
580
+ function processExists(pid) {
581
+ try {
582
+ process.kill(pid, 0);
583
+ return true;
584
+ } catch (error) {
585
+ return error.code !== "ESRCH";
586
+ }
587
+ }
588
+ function assertNoQuarantinedValidationProfiles(profileRoot) {
589
+ for (const name of fs.readdirSync(profileRoot).filter((entry) => entry.startsWith("run-"))) {
590
+ const profile = path.join(profileRoot, name);
591
+ if (!fs.lstatSync(profile, { throwIfNoEntry: false })?.isDirectory()) continue;
592
+ if (!hasDetachedLaunchUnproven(profile)) continue;
593
+ const marker = detachedLaunchUnprovenPath(profile);
594
+ throw new Error(
595
+ `Extension validation will not prune the quarantined profile ${profile}. Next: confirm no process uses that profile, then run: rm -- ${shellQuote(marker)}`
596
+ );
597
+ }
598
+ }
599
+ function assertNoLiveValidationProfileOwners(profileRoot) {
600
+ for (const name of fs.readdirSync(profileRoot).filter((entry) => entry.startsWith("run-"))) {
601
+ const profile = path.join(profileRoot, name);
602
+ if (!fs.lstatSync(profile, { throwIfNoEntry: false })?.isDirectory()) continue;
603
+ const pids = profileProcessPids(profile);
604
+ if (pids.length > 0) {
605
+ throw new Error(
606
+ `Extension validation will not prune the live profile ${profile} (pid ${pids.join(", ")}). Next: stop that browser and rerun the validation.`
607
+ );
608
+ }
609
+ }
610
+ }
611
+ function pruneValidationRuns(root, keep) {
612
+ const runs = fs.readdirSync(root).filter((name) => name.startsWith("run-")).map((name) => {
613
+ const entryPath = path.join(root, name);
614
+ return { entryPath, mtimeMs: fs.lstatSync(entryPath).mtimeMs };
615
+ }).sort((left, right) => right.mtimeMs - left.mtimeMs || right.entryPath.localeCompare(left.entryPath));
616
+ for (const { entryPath } of runs.slice(keep)) {
617
+ const entryStat = fs.lstatSync(entryPath, { throwIfNoEntry: false });
618
+ if (!entryStat) continue;
619
+ if (entryStat.isSymbolicLink() || !entryStat.isDirectory()) fs.unlinkSync(entryPath);
620
+ else fs.rmSync(entryPath, { recursive: true, force: true });
621
+ }
622
+ }
363
623
  function runProcess(command, args, options) {
364
624
  return new Promise((resolve, reject) => {
365
625
  const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
366
626
  let stdout = "";
367
627
  let stderr = "";
368
628
  let settled = false;
629
+ let didTimeout = false;
630
+ let killTimer;
369
631
  const timeout = options.timeoutMs ? setTimeout(() => {
370
632
  if (settled) return;
371
- settled = true;
633
+ didTimeout = true;
372
634
  child.kill("SIGTERM");
373
- const killTimer = setTimeout(() => child.kill("SIGKILL"), 1e3);
374
- child.once("close", () => clearTimeout(killTimer));
375
- resolve({ exitCode: null, stdout, stderr, timedOut: true });
635
+ killTimer = setTimeout(() => child.kill("SIGKILL"), 1e3);
376
636
  }, options.timeoutMs) : void 0;
377
637
  child.stdout.on("data", (chunk) => {
378
638
  stdout += chunk;
@@ -384,23 +644,106 @@ function runProcess(command, args, options) {
384
644
  if (settled) return;
385
645
  settled = true;
386
646
  if (timeout) clearTimeout(timeout);
647
+ if (killTimer) clearTimeout(killTimer);
387
648
  reject(error);
388
649
  });
389
650
  child.on("close", (exitCode) => {
390
651
  if (settled) return;
391
652
  settled = true;
392
653
  if (timeout) clearTimeout(timeout);
393
- resolve({ exitCode, stdout, stderr, timedOut: false });
654
+ if (killTimer) clearTimeout(killTimer);
655
+ resolve({ exitCode, stdout, stderr, timedOut: didTimeout });
394
656
  });
395
657
  });
396
658
  }
397
- async function killCdpPort(cdpPort) {
659
+ async function stopOwnedCdpListener(cdpPort, runtimePaths) {
398
660
  if (process.platform !== "darwin" && process.platform !== "linux") return;
399
- await runProcess("bash", ["-lc", `lsof -ti tcp:${cdpPort} -sTCP:LISTEN | xargs -r kill -TERM || true; sleep 1; lsof -ti tcp:${cdpPort} -sTCP:LISTEN | xargs -r kill -KILL || true`], {
661
+ let listenerPids = await inspectCdpListenerPids(cdpPort);
662
+ if (listenerPids.length === 0) return;
663
+ const identity = runtimePaths.map((runtimePath) => readValidationRuntimeIdentity(runtimePath, cdpPort)).find((candidate) => candidate?.pid === listenerPids[0]);
664
+ if (!identity || listenerPids.length !== 1 || listenerPids[0] !== identity.pid || !await browserEchoesRuntimeNonce(cdpPort, identity.nonce)) {
665
+ throw new Error(`Extension validation will not stop an unowned CDP listener on port ${cdpPort}.`);
666
+ }
667
+ try {
668
+ process.kill(identity.pid, "SIGTERM");
669
+ } catch (error) {
670
+ if (error.code !== "ESRCH") throw error;
671
+ }
672
+ const termDeadline = Date.now() + 1e3;
673
+ do {
674
+ await sleep(50);
675
+ listenerPids = await inspectCdpListenerPids(cdpPort);
676
+ } while ((listenerPids.length > 0 || processExists(identity.pid)) && Date.now() < termDeadline);
677
+ if (listenerPids.length > 0 || processExists(identity.pid)) {
678
+ try {
679
+ process.kill(identity.pid, "SIGKILL");
680
+ } catch (error) {
681
+ if (error.code !== "ESRCH") throw error;
682
+ }
683
+ const killDeadline = Date.now() + 1e3;
684
+ do {
685
+ await sleep(50);
686
+ listenerPids = await inspectCdpListenerPids(cdpPort);
687
+ } while ((listenerPids.length > 0 || processExists(identity.pid)) && Date.now() < killDeadline);
688
+ }
689
+ if (listenerPids.length > 0 || processExists(identity.pid)) {
690
+ throw new Error(`Extension validation could not stop its CDP listener on port ${cdpPort}.`);
691
+ }
692
+ }
693
+ async function inspectCdpListenerPids(cdpPort) {
694
+ const result = await runProcess("lsof", ["-ti", `tcp:${cdpPort}`, "-sTCP:LISTEN"], {
400
695
  cwd: runnerDir,
401
696
  env: process.env,
402
- timeoutMs: 1e4
697
+ timeoutMs: 5e3
403
698
  });
699
+ const noListener = result.exitCode === 1 && !result.stdout.trim() && !result.stderr.trim();
700
+ if (result.timedOut || result.exitCode !== 0 && !noListener) {
701
+ throw new Error(`Extension validation could not inspect the CDP listener on port ${cdpPort}.`);
702
+ }
703
+ const pids = result.stdout.trim() ? result.stdout.trim().split(/\s+/u).map(Number) : [];
704
+ if (pids.some((pid) => !Number.isInteger(pid) || pid <= 0)) {
705
+ throw new Error(`Extension validation received invalid CDP listener ownership for port ${cdpPort}.`);
706
+ }
707
+ return [...new Set(pids)];
708
+ }
709
+ function readValidationRuntimeIdentity(validationRuntimePath, cdpPort) {
710
+ const identityPath = path.join(validationRuntimePath, "extension-runtime-identity.json");
711
+ let descriptor;
712
+ try {
713
+ descriptor = fs.openSync(identityPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
714
+ const stat = fs.fstatSync(descriptor);
715
+ if (!stat.isFile() || stat.size <= 0 || stat.size > 4096 || (stat.mode & 63) !== 0) return null;
716
+ const parsed = JSON.parse(fs.readFileSync(descriptor, "utf8"));
717
+ if (parsed.port !== cdpPort || !Number.isInteger(parsed.pid) || Number(parsed.pid) <= 0 || typeof parsed.nonce !== "string" || !/^[a-f0-9]{64}$/u.test(parsed.nonce)) {
718
+ return null;
719
+ }
720
+ return parsed;
721
+ } catch {
722
+ return null;
723
+ } finally {
724
+ if (descriptor !== void 0) fs.closeSync(descriptor);
725
+ }
726
+ }
727
+ async function browserEchoesRuntimeNonce(cdpPort, nonce) {
728
+ let session;
729
+ try {
730
+ const version = await jsonGet(`http://127.0.0.1:${cdpPort}/json/version`, { timeoutMs: 2e3 });
731
+ if (typeof version.webSocketDebuggerUrl !== "string") return false;
732
+ session = await CdpSession.connect(version.webSocketDebuggerUrl, { timeoutMs: 2e3 });
733
+ const result = await boundedCdpCall(
734
+ session,
735
+ "Browser.getBrowserCommandLine",
736
+ {},
737
+ 2e3
738
+ );
739
+ if (!Array.isArray(result.arguments)) return false;
740
+ const expected = `--mm-harness-runtime-nonce=${nonce}`;
741
+ return result.arguments.filter((argument) => argument === expected).length === 1;
742
+ } catch {
743
+ return false;
744
+ } finally {
745
+ session?.close();
746
+ }
404
747
  }
405
748
  function safeExtensionId(target) {
406
749
  try {
@@ -0,0 +1,10 @@
1
+ import { createRequire } from "node:module";
2
+ const require2 = createRequire(import.meta.url);
3
+ const processOwnership = require2(
4
+ "../../../adapters/extension/lib/validation-process-ownership.cjs"
5
+ );
6
+ const { profileProcessPids, stopProfileProcesses } = processOwnership;
7
+ export {
8
+ profileProcessPids,
9
+ stopProfileProcesses
10
+ };
@@ -8,6 +8,7 @@ const SPEC = {
8
8
  { name: "help", aliases: ["-h", "--help"], desc: "Show usage" }
9
9
  ],
10
10
  shared: [
11
+ { name: "setup-base", desc: "Bootstrap numbered product checkouts", flags: ["--dir", "--counts", "--only", "--dry-run", "--force", "--json", "--show-config", "--reset-config", "--skip-harness-update"] },
11
12
  { name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json", "--fast"] },
12
13
  { name: "ports", desc: "Slot ports and runtime paths", flags: ["--json"] },
13
14
  { name: "up", desc: "Decide + run minimum work to reach ready", flags: ["--json", "--dry-run"] },
@@ -50,6 +50,18 @@ const REMOVED_OPTION_REPLACEMENTS = {
50
50
  "--record": "--record-video"
51
51
  };
52
52
  const PUBLIC_COMMAND_CONTRACTS = {
53
+ "setup-base": {
54
+ options: options(HELP, JSON, {
55
+ "--dir": value(),
56
+ "--counts": value(),
57
+ "--only": value(),
58
+ "--dry-run": bool(),
59
+ "--force": bool(),
60
+ "--show-config": bool(),
61
+ "--reset-config": bool(),
62
+ "--skip-harness-update": bool()
63
+ })
64
+ },
53
65
  status: {
54
66
  aliases: ["health", "home"],
55
67
  options: options(HELP, JSON, TARGET, DEVICE, {