@deeeed/metamask-harness 0.5.0 → 0.6.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 (102) hide show
  1. package/CHANGELOG.md +51 -20
  2. package/adapters/core/inject.sh +1 -0
  3. package/adapters/extension/cleanup.mjs +1 -1
  4. package/adapters/extension/inject.mjs +4 -1
  5. package/adapters/extension/launch-browser.cjs +18 -4
  6. package/adapters/extension/live.sh +14 -2
  7. package/adapters/extension/refresh-build.sh +2 -2
  8. package/adapters/extension/sidepanel-toggle.sh +33 -12
  9. package/adapters/extension/start-watch.sh +2 -2
  10. package/adapters/extension/wallet-fixture-state.cjs +38 -24
  11. package/adapters/manifest.json +32 -3
  12. package/adapters/mobile/inject.sh +1 -0
  13. package/adapters/mobile/start-metro.sh +1 -1
  14. package/adapters/mobile/stop-metro.sh +20 -0
  15. package/adapters/mobile/verify.sh +1 -1
  16. package/adapters/shared/activate-repo-node.sh +1 -1
  17. package/adapters/shared/cli-ux.sh +44 -25
  18. package/adapters/shared/ensure-runner-deps.sh +30 -0
  19. package/adapters/shared/log-tui.mjs +4 -4
  20. package/adapters/shared/open-debug.mjs +1 -1
  21. package/adapters/shared/reap-checkout-metros.sh +53 -0
  22. package/adapters/shared/recipe-harness-root.mjs +23 -0
  23. package/adapters/shared/resolve-farmslot-ports-core.mjs +205 -0
  24. package/adapters/shared/resolve-farmslot-ports.mjs +20 -0
  25. package/adapters/shared/resolve-farmslot-ports.sh +19 -126
  26. package/bin/mm-harness +45 -4
  27. package/dist/adapters/extension/runtime.js +3 -1
  28. package/dist/adapters/mobile/provision.js +34 -1
  29. package/dist/adapters/mobile/runtime-decision.js +3 -1
  30. package/dist/adapters/resolve-farmslot-ports.js +22 -0
  31. package/dist/adapters/slot-ports.js +18 -29
  32. package/dist/cli.js +32 -1463
  33. package/dist/commands/call.js +183 -0
  34. package/dist/commands/completion-candidates.js +58 -0
  35. package/dist/commands/doctor.js +101 -0
  36. package/dist/commands/ensure-ready.js +24 -0
  37. package/dist/commands/flows.js +62 -0
  38. package/dist/commands/launch/extension.js +40 -0
  39. package/dist/commands/{launch.js → launch/index.js} +15 -47
  40. package/dist/commands/launch/mobile.js +10 -0
  41. package/dist/commands/manifest.js +72 -0
  42. package/dist/commands/parse-args.js +189 -0
  43. package/dist/commands/provision.js +136 -0
  44. package/dist/commands/resolve-extension.js +23 -0
  45. package/dist/commands/run-engine.js +341 -0
  46. package/dist/commands/run.js +217 -0
  47. package/dist/commands/runtime-decision.js +58 -0
  48. package/dist/commands/runtime-health.js +25 -0
  49. package/dist/commands/runtime-launch.js +139 -0
  50. package/dist/commands/self-test.js +52 -0
  51. package/dist/commands/stop.js +52 -0
  52. package/dist/harness.js +8 -48
  53. package/dist/mm-harness-cli.js +13 -8
  54. package/docs/CLI-SPEC.md +1 -1
  55. package/docs/CODE-MAP.md +62 -0
  56. package/library/README.md +14 -0
  57. package/library/actions/core/perps/_controller.mjs +1 -1
  58. package/library/actions/extension/platform/cdp.mjs +2 -2
  59. package/library/actions/extension/wallet/ensure_unlocked.mjs +7 -1
  60. package/library/actions/harness-exports.mjs +27 -0
  61. package/library/actions/mobile/wallet/ensure_unlocked.mjs +14 -2
  62. package/library/actions/mobile/wallet/setup.mjs +1 -1
  63. package/package.json +6 -7
  64. package/src/adapters/core/surface.ts +0 -71
  65. package/src/adapters/extension/ensure-ready.ts +0 -185
  66. package/src/adapters/extension/extension-id.ts +0 -107
  67. package/src/adapters/extension/runtime-decision.ts +0 -445
  68. package/src/adapters/extension/runtime.ts +0 -407
  69. package/src/adapters/extension/surface.ts +0 -88
  70. package/src/adapters/mobile/deps-markers.ts +0 -21
  71. package/src/adapters/mobile/prepare.ts +0 -246
  72. package/src/adapters/mobile/provision.ts +0 -594
  73. package/src/adapters/mobile/runtime-decision.ts +0 -459
  74. package/src/adapters/mobile/surface.ts +0 -71
  75. package/src/adapters/slot-ports.ts +0 -165
  76. package/src/adapters/surface.ts +0 -117
  77. package/src/adapters.ts +0 -601
  78. package/src/cli-color.ts +0 -92
  79. package/src/cli-commands.ts +0 -250
  80. package/src/cli-version.ts +0 -141
  81. package/src/cli.ts +0 -2091
  82. package/src/commands/debug.ts +0 -65
  83. package/src/commands/fixtures.ts +0 -198
  84. package/src/commands/launch.ts +0 -470
  85. package/src/commands/logs.ts +0 -99
  86. package/src/commands/shared.ts +0 -235
  87. package/src/commands/update.ts +0 -316
  88. package/src/completions-cache.ts +0 -86
  89. package/src/doctor.ts +0 -215
  90. package/src/harness.ts +0 -797
  91. package/src/heal-bounds.ts +0 -198
  92. package/src/index.ts +0 -15
  93. package/src/leaf-invoke.ts +0 -28
  94. package/src/live-adapter-contract.ts +0 -274
  95. package/src/manifest.ts +0 -47
  96. package/src/mm-harness-cli.ts +0 -655
  97. package/src/paths.ts +0 -198
  98. package/src/progress.ts +0 -117
  99. package/src/recording-target.ts +0 -147
  100. package/src/run-recording.ts +0 -329
  101. package/src/runner.ts +0 -108
  102. package/src/types.ts +0 -57
@@ -423,6 +423,9 @@ function groupedHelp() {
423
423
  lines.push("");
424
424
  lines.push(`${out("warn", "DEV OVERRIDE ACTIVE")} \u2014 this run is served by MM_HARNESS_BIN=${out("path", process.env.MM_HARNESS_BIN)} (unset it to return to the installed/global bin).`);
425
425
  }
426
+ if (process.env.MM_HARNESS_BIN && process.env.MM_HARNESS_RUN_MODE) {
427
+ lines.push(`${out("label", "running from:")} ${out("path", process.env.MM_HARNESS_RUN_MODE)} (source checkout; dist shadows src when both exist)`);
428
+ }
426
429
  const slotLine = detectedSlotLine(out);
427
430
  if (slotLine) {
428
431
  lines.push("");
@@ -469,14 +472,8 @@ function translateActionsRaw(argv) {
469
472
  const program = new Command();
470
473
  program.name("mm-harness").description("the MetaMask recipe harness: launch the app, prove behavior, manage the runtime overlay").helpOption("-h, --help", "Show grouped help").showHelpAfterError("(run `mm-harness --help` for the full surface)").configureHelp({ formatHelp: () => groupedHelp() });
471
474
  for (const command of REAL) {
472
- program.command(command.name).description(command.summary).allowUnknownOption().helpOption(false).argument("[args...]").action(async () => {
473
- const ddIdx = rawArgv.indexOf("--");
474
- const flagsBeforeSep = ddIdx === -1 ? rawArgv : rawArgv.slice(0, ddIdx);
475
- if (flagsBeforeSep.includes("--help") || flagsBeforeSep.includes("-h")) {
476
- process.stdout.write(`${command.helpText}
477
- `);
478
- process.exit(0);
479
- }
475
+ program.command(command.name).description(command.summary).allowUnknownOption().helpOption("-h, --help", "Show command help").configureHelp({ formatHelp: () => `${command.helpText}
476
+ ` }).argument("[args...]").action(async () => {
480
477
  if (command.name === "update") {
481
478
  process.exit(await handleUpdate(rawArgv.slice(1)));
482
479
  }
@@ -514,8 +511,16 @@ const NUDGE_SKIP = ["update", "completions", "completion-candidates"];
514
511
  if (rawArgv.length > 0 && !NUDGE_SKIP.includes(rawArgv[0])) {
515
512
  setImmediate(() => void maybeNudge());
516
513
  }
514
+ function hasPassthroughHelp(argv) {
515
+ const divider = argv.indexOf("--");
516
+ if (divider === -1) return false;
517
+ return argv.slice(divider + 1).some((arg) => arg === "-h" || arg === "--help");
518
+ }
517
519
  if (rawArgv.length === 0) {
518
520
  process.stdout.write(groupedHelp());
519
521
  process.exit(0);
520
522
  }
523
+ if (hasPassthroughHelp(rawArgv)) {
524
+ process.exit(await delegate(rawArgv));
525
+ }
521
526
  await program.parseAsync(process.argv);
package/docs/CLI-SPEC.md CHANGED
@@ -180,7 +180,7 @@ Audience: **human** (daily, ≤1 flag), **agent** (depth), **both**.
180
180
 
181
181
  ## `launch` (REAL as of wave 3 → main DAILY LOOP verb)
182
182
 
183
- > **Implemented (wave 3):** `handleLaunch` in `runner/src/commands/launch.ts`. The typed
183
+ > **Implemented (wave 3):** `handleLaunch` in `src/commands/launch/`. The typed
184
184
  > layer owns grammar (mobile-target mandatory, core/extension refusals), tier
185
185
  > selection, quick-launch change detection, overlay auto-ensure, healing policy +
186
186
  > bounds, teaching errors, and the `--json` contract. It does NOT rewrite
@@ -0,0 +1,62 @@
1
+ # Code map — where things live and how a command flows
2
+
3
+ One page for humans. The contracts live in docs/UX-PRINCIPLES.md and
4
+ docs/ADAPTER-SURFACE.md; this is the geography.
5
+
6
+ ## How a command flows
7
+
8
+ ```
9
+ bin/mm-harness bash front door: MM_HARNESS_BIN dev/prod switch,
10
+ │ dist-vs-src selection (dist wins when present;
11
+ │ stale-dist warning), deps ensure
12
+
13
+ src/mm-harness-cli.ts commander surface: grouped colored help, per-command
14
+ │ --help, retired-name teaching; delegates in-process
15
+
16
+ src/cli.ts parse + dispatch ONLY — no verb logic lives here
17
+
18
+ ├── src/commands/<verb>.ts one module per verb (doctor, stop, call, run,
19
+ │ │ provision, fixtures, flows, logs, debug, …)
20
+ │ ▼
21
+ │ src/adapters/surface.ts AdapterSurface registry — per-platform behavior
22
+ │ │ (ports, runtime status, dev server, log sources)
23
+ │ ▼
24
+ │ src/adapters/{mobile,extension,core}/ TS logic per platform
25
+
26
+ └── src/harness.ts overlay lifecycle (install/verify/cleanup) — writes
27
+ │ the per-checkout overlay + runner delegate
28
+
29
+ adapters/ SHELL LEAVES (repo root — not src/adapters!):
30
+ mobile/ extension/ single-OS-op scripts the TS layer spawns
31
+ core/ shared/ (simctl/adb/tmux/metro/webpack lifecycle)
32
+ ```
33
+
34
+ ## The two `adapters/` directories
35
+
36
+ - `adapters/` (repo root) — executable shell leaves. Thin, OS-facing, spawned by TS.
37
+ - `src/adapters/` — TypeScript platform logic behind the AdapterSurface.
38
+ Rule: logic/decisions in TS; a leaf only executes. A leaf converts to TS only when
39
+ it starts making decisions (see backlog ruling), never as a campaign.
40
+
41
+ ## Other roots
42
+
43
+ - `library/` — recipe library: actions (`library/actions/<adapter>/…`, executable
44
+ action modules — importing one RUNS it, see library/README.md), flows, recipes.
45
+ - `tests/contract/` — hermetic bash contract tests (PATH-stubbed OS tools). The
46
+ launch-leaf-flags test enforces composer↔leaf flag agreement.
47
+ - `dist/` — esbuild output; the ONLY thing published runs. gitignored; in a source
48
+ checkout it SHADOWS src when present (bin warns when it goes stale).
49
+ - Per-checkout runtime state lives in the TARGET repo: `temp/recipe/runtime/`
50
+ (context, logs, wallet fixture) and `temp/recipe/harness/<adapter>/` (overlay +
51
+ runner delegate pinned to the installing bin via `.runner-source`).
52
+
53
+ ## Adding things
54
+
55
+ - **A verb**: module in `src/commands/`, register in `src/cli.ts` dispatch + the
56
+ commander metadata in `src/mm-harness-cli.ts` (help group), contract test.
57
+ - **A flag**: parse in the verb module; if it reaches a leaf, the leaf's parser
58
+ must accept it (launch-leaf-flags test will fail otherwise) — or pass via env.
59
+ - **A leaf**: shell under `adapters/<platform>/`, spawned via the shared spawn
60
+ helpers (never raw exec), contract test with PATH stubs, errors teach escape.
61
+ - **A platform behavior**: extend the AdapterSurface, implement per platform —
62
+ verbs must not branch on adapter for surface-owned behavior.
@@ -0,0 +1,14 @@
1
+ # Recipe library
2
+
3
+ - `actions/<adapter>/<group>/<name>.mjs` — executable action modules.
4
+ **Importing an action module RUNS it**: each ends with `runAdapter(main)` at top
5
+ level, which reads the live-adapter input path from the environment and executes.
6
+ That is the engine contract (the recipe engine spawns/imports them with the
7
+ input env set) — a bare `import` outside the engine throws
8
+ "Missing live adapter input path", which is expected, not a bug.
9
+ - Actions load harness helpers ONLY through `actions/harness-exports.mjs` (the
10
+ dist-preferring bridge) — never deep-import `src/` (published installs have no
11
+ src/, and the entrypoint would close an import cycle through adapters.ts).
12
+ - `recipes/` — runnable smoke/validation recipes (`mm-harness run <file>`).
13
+ - Flow resolution order is personal > team > canonical; shadowing is recorded in
14
+ summary.json (never silent).
@@ -8,7 +8,7 @@ import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts';
8
8
  // this adapter under the bundled tsx (see commandFor/importsSourceTypescript in
9
9
  // src/live-adapter-contract.ts). The core adapter dynamic-imports the perps
10
10
  // controller TypeScript at runtime, so it MUST run under tsx, not plain node.
11
- import { walletFixturePath } from '../../../../src/index.ts';
11
+ import { walletFixturePath } from '../../harness-exports.mjs';
12
12
 
13
13
  // Shared headless instantiation for the MetaMask `core` adapter.
14
14
  //
@@ -11,10 +11,10 @@ import {
11
11
  recipeHarnessPath,
12
12
  walletFixturePath,
13
13
  captureActiveRecipeRecordingSnapshot,
14
- } from '../../../../src/index.ts';
14
+ } from '../../harness-exports.mjs';
15
15
 
16
16
  // Resolve the Farmslot harness through normal package dependencies by default.
17
- // Local Farmslot source is only a dev override handled by src/paths.ts.
17
+ // Local Farmslot source is only a dev override handled by the harness paths module.
18
18
  const {
19
19
  CdpSession,
20
20
  CdpWebPage,
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
3
- import { walletFixturePath } from '../../../../src/index.ts';
3
+ import { walletFixturePath } from '../../harness-exports.mjs';
4
4
 
5
5
  async function fixturePassword(projectRoot) {
6
6
  const candidates = [
@@ -19,6 +19,12 @@ async function fixturePassword(projectRoot) {
19
19
  }
20
20
 
21
21
  runAdapter((input) => withExtensionPage(input, async (page) => {
22
+ // A fresh profile shows onboarding, which has no password input — that is not
23
+ // an unlocked wallet, and passing here would be vacuous.
24
+ const onboarding = await page.evaluate(`(() => (window.location.hash || '').includes('onboarding'))()`);
25
+ if (onboarding) {
26
+ throw new Error('No wallet onboarded in this extension profile — unlock has nothing to unlock.\n Next: mm-harness fixtures set # applies the fixture wallet, then re-run: mm-harness call ensure_unlocked');
27
+ }
22
28
  const locked = await page.evaluate(`(() => Boolean(document.querySelector('input[type="password"]')))()`);
23
29
  if (!locked) {
24
30
  return { action: input.action, unlocked: true, alreadyUnlocked: true, proofPath: 'extension-unlocked-state' };
@@ -0,0 +1,27 @@
1
+ // Bridge from library actions to the harness path/recording helpers. Published
2
+ // installs load the compiled dist (node cannot type-strip .ts under
3
+ // node_modules); source checkouts without dist fall back to src. Deliberately
4
+ // narrow — importing the package entry here would close an import cycle
5
+ // through src/adapters.ts (which imports these actions) and deadlock.
6
+ const packageRoot = new URL('../../', import.meta.url);
7
+
8
+ async function load(distRelative, srcRelative) {
9
+ try {
10
+ return await import(new URL(distRelative, packageRoot).href);
11
+ } catch {
12
+ return await import(new URL(srcRelative, packageRoot).href);
13
+ }
14
+ }
15
+
16
+ const paths = await load('dist/paths.js', 'src/paths.ts');
17
+ const recording = await load('dist/run-recording.js', 'src/run-recording.ts');
18
+
19
+ export const {
20
+ importRecipeHarnessRuntimeBrowserExtension,
21
+ importRecipeHarnessRuntimeCdp,
22
+ extensionIdPath,
23
+ recipeHarnessPath,
24
+ walletFixturePath,
25
+ } = paths;
26
+
27
+ export const { captureActiveRecipeRecordingSnapshot } = recording;
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { bridgeCommand, runAdapter } from '../platform/bridge.mjs';
3
- import { walletFixturePath } from '../../../../src/index.ts';
3
+ import { walletFixturePath } from '../../harness-exports.mjs';
4
4
 
5
5
  async function fixturePassword(projectRoot) {
6
6
  const candidates = [
@@ -19,7 +19,14 @@ async function fixturePassword(projectRoot) {
19
19
  }
20
20
 
21
21
  function selectedAccount(status, input) {
22
- return selectedStatus(status, input)?.account ?? null;
22
+ // The bridge reports an account OBJECT even pre-onboarding, with every field
23
+ // empty — an empty shell is not a wallet. Only a populated address/id counts,
24
+ // otherwise a fresh app "passes" unlock while sitting on the onboarding screen.
25
+ const account = selectedStatus(status, input)?.account ?? null;
26
+ if (!account || typeof account !== 'object') return null;
27
+ const address = typeof account.address === 'string' ? account.address : '';
28
+ const id = typeof account.id === 'string' ? account.id : '';
29
+ return address.length > 0 || id.length > 0 ? account : null;
23
30
  }
24
31
 
25
32
  function routeName(status, input) {
@@ -68,6 +75,11 @@ async function waitForUnlocked(input, timeoutMs = 15000) {
68
75
 
69
76
  runAdapter(async (input) => {
70
77
  const before = await status(input);
78
+ if (!selectedAccount(before, input) && routeName(before, input) !== 'Login') {
79
+ throw new Error(
80
+ `No wallet onboarded on this device (route ${routeName(before, input) || 'unknown'}, empty wallet status) — unlock has nothing to unlock.\n Next: mm-harness fixtures set # applies the fixture wallet, then re-run: mm-harness call ensure_unlocked`,
81
+ );
82
+ }
71
83
  if (selectedAccount(before, input) && routeName(before, input) !== 'Login') {
72
84
  return {
73
85
  action: input.action,
@@ -3,7 +3,7 @@ import { spawn } from 'node:child_process';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { bridgeCommand, bridgeEnv, runAdapter } from '../platform/bridge.mjs';
6
- import { walletFixturePath } from '../../../../src/index.ts';
6
+ import { walletFixturePath } from '../../harness-exports.mjs';
7
7
 
8
8
  async function fixtureProfile(projectRoot) {
9
9
  const candidates = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -19,7 +19,6 @@
19
19
  "@farmslot/protocol": "^0.7.2",
20
20
  "@farmslot/recipe-harness": "^0.3.3",
21
21
  "commander": "^12.0.0",
22
- "tsx": "^4.23.0",
23
22
  "viem": "^2.54.3"
24
23
  },
25
24
  "resolutions": {
@@ -33,15 +32,16 @@
33
32
  "esbuild": "0.28.1",
34
33
  "eslint": "^9",
35
34
  "globals": "^17.7.0",
35
+ "tsx": "^4.23.0",
36
36
  "typescript": "^5.6.0",
37
37
  "typescript-eslint": "^8"
38
38
  },
39
- "main": "./src/index.ts",
40
- "types": "./src/index.ts",
39
+ "main": "./dist/index.js",
41
40
  "exports": {
42
41
  ".": {
43
- "types": "./src/index.ts",
44
- "import": "./src/index.ts"
42
+ "development": "./src/index.ts",
43
+ "import": "./dist/index.js",
44
+ "default": "./dist/index.js"
45
45
  }
46
46
  },
47
47
  "publishConfig": {
@@ -50,7 +50,6 @@
50
50
  "files": [
51
51
  "bin",
52
52
  "dist",
53
- "src",
54
53
  "adapters",
55
54
  "library",
56
55
  "scripts/completions.sh",
@@ -1,71 +0,0 @@
1
- // Core surface: headless. No app, no dev server — dependency presence is the
2
- // only runtime signal, and lifecycle commands teach the reachable headless path.
3
- import path from 'node:path';
4
-
5
- import { depsCheck } from '@farmslot/recipe-harness/runtime/deps-readiness';
6
-
7
- import type {
8
- AdapterDevServerStop,
9
- AdapterLogSource,
10
- AdapterRuntimeStatus,
11
- AdapterRunwayProvisionResult,
12
- AdapterSurface,
13
- } from '../surface.ts';
14
-
15
- export const coreSurface: AdapterSurface = {
16
- adapter: 'core',
17
- headless: true,
18
-
19
- resolveSlotPorts(): void {
20
- // Headless: no ports or device to resolve.
21
- },
22
-
23
- async runtimeStatus(target: string): Promise<AdapterRuntimeStatus> {
24
- const deps = depsCheck(path.resolve(target));
25
- const ready = deps.status === 'current';
26
- return {
27
- decision: ready ? 'ready' : 'install',
28
- reasonCode: ready ? 'deps-present' : `deps-${deps.status}`,
29
- reasons: [
30
- ready
31
- ? 'Core is headless; dependencies are installed. Run recipes with mm-harness run.'
32
- : 'Core is headless; dependencies are not fully installed.',
33
- ],
34
- deps: deps.status,
35
- };
36
- },
37
-
38
- runwayProvision: {
39
- async run(target, options): Promise<AdapterRunwayProvisionResult> {
40
- return {
41
- schemaVersion: 1,
42
- command: 'provision',
43
- adapter: 'core',
44
- target: path.resolve(target),
45
- status: 'fail',
46
- exitCode: 2,
47
- error: { code: 'UNSUPPORTED_ADAPTER', message: 'core is headless and has no mobile simulator to provision.', userAction: options.rerunCommand || 'mm-harness run <recipe> --adapter core' },
48
- };
49
- },
50
- },
51
-
52
- devServer: {
53
- describe: () => 'no dev server (headless)',
54
- stop(): AdapterDevServerStop {
55
- return {
56
- kind: 'headless',
57
- message: 'core is headless — no dev server runs for a core checkout',
58
- userAction: 'use mm-harness verify (readiness) or mm-harness run (execute a recipe)',
59
- };
60
- },
61
- },
62
-
63
- logSources(): AdapterLogSource[] {
64
- return [];
65
- },
66
-
67
- hints: {
68
- launch: 'mm-harness run <recipe> # run recipes against the headless core',
69
- relaunch: 'mm-harness verify',
70
- },
71
- };
@@ -1,185 +0,0 @@
1
- // ensure-ready.ts — ensure the extension runtime is attachable.
2
- // runner/extension/extension-ensure-ready.ts) App/instance control.
3
- import path from 'node:path';
4
-
5
- import { resolveExtensionId } from './extension-id.ts';
6
-
7
- /**
8
- * "Drive the extension to a ready state" — the single source of truth for the
9
- * open-home + collapse-to-one-tab + verify-healthy logic that host launchers
10
- * and reopen helpers previously hand-rolled. They left duplicate home tabs, which trips
11
- * runtime-health's "exactly one home page" contract and makes agents see a
12
- * broken-looking runtime.
13
- *
14
- * Guarantees, given a live CDP port:
15
- * - the extension id is resolved deterministically (resolveExtensionId),
16
- * - exactly ONE chrome-extension home.html page target is open (opened if none,
17
- * extras closed if several),
18
- * - the runtime is verified healthy (checkExtensionRuntimeHealth).
19
- *
20
- * Pure-ish: it only opens/closes tabs to converge on the single-tab invariant;
21
- * it does not rebuild or relaunch. Unlocking the wallet remains with the host
22
- * launcher for now (a future increment can fold ensure_unlocked in here too).
23
- */
24
-
25
- export interface EnsureReadyResult {
26
- schemaVersion: 1;
27
- adapter: 'extension';
28
- target: string;
29
- cdpPort: number;
30
- extensionId: string | null;
31
- opened: boolean;
32
- homeTabs: { before: number; closed: number; after: number };
33
- ready: boolean;
34
- reasonCode: string;
35
- health: { status: 'PASS' | 'FAIL' | 'unknown'; findings: string[] };
36
- }
37
-
38
- interface CdpTarget {
39
- id?: string;
40
- type?: string;
41
- url?: string;
42
- }
43
-
44
- const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
45
-
46
- async function jsonList(port: number): Promise<CdpTarget[]> {
47
- try {
48
- const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) });
49
- if (!res.ok) return [];
50
- const value = await res.json();
51
- return Array.isArray(value) ? (value as CdpTarget[]) : [];
52
- } catch {
53
- return [];
54
- }
55
- }
56
-
57
- async function closeTab(port: number, id: string): Promise<void> {
58
- try {
59
- await fetch(`http://127.0.0.1:${port}/json/close/${id}`, { signal: AbortSignal.timeout(4000) });
60
- } catch {
61
- // best-effort; a tab that won't close is surfaced by the final health check
62
- }
63
- }
64
-
65
- async function activateTab(port: number, id: string): Promise<void> {
66
- try {
67
- await fetch(`http://127.0.0.1:${port}/json/activate/${id}`, { signal: AbortSignal.timeout(4000) });
68
- } catch {
69
- // best-effort; foregrounding only steers the headed-window screen capture onto
70
- // the wallet — the authoritative readiness signal is the health check below
71
- }
72
- }
73
-
74
- // Blank / New-Tab pages Chrome opens by default. The headed-window screen capture
75
- // (device-grid stream) follows whatever tab is foreground, so a lingering New Tab
76
- // makes the stream show chrome://newtab instead of the wallet. Safe to close once
77
- // a home tab exists.
78
- function isStrayTab(url?: string): boolean {
79
- if (typeof url !== 'string') return false;
80
- return /^chrome:\/\/newtab\/?$/.test(url) || /^chrome:\/\/new-tab-page\b/.test(url) || url === 'about:blank';
81
- }
82
-
83
- async function openHome(port: number, extensionId: string): Promise<boolean> {
84
- const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(`chrome-extension://${extensionId}/home.html`)}`;
85
- try {
86
- let res = await fetch(endpoint, { method: 'PUT', signal: AbortSignal.timeout(8000) });
87
- if (res.status === 404 || res.status === 405) res = await fetch(endpoint, { signal: AbortSignal.timeout(8000) });
88
- return res.ok;
89
- } catch {
90
- return false;
91
- }
92
- }
93
-
94
- function homePages(targets: CdpTarget[], extensionId: string): CdpTarget[] {
95
- const prefix = `chrome-extension://${extensionId}`;
96
- return targets.filter(
97
- (t) => t.type === 'page' && typeof t.url === 'string' && t.url.startsWith(prefix) && t.url.includes('/home.html') && t.id,
98
- );
99
- }
100
-
101
- export async function ensureExtensionReady(
102
- target: string,
103
- options: { cdpPort: number },
104
- ): Promise<EnsureReadyResult> {
105
- const resolved = path.resolve(target);
106
- const { cdpPort } = options;
107
- const base = (extra: Partial<EnsureReadyResult>): EnsureReadyResult => ({
108
- schemaVersion: 1,
109
- adapter: 'extension',
110
- target: resolved,
111
- cdpPort,
112
- extensionId: null,
113
- opened: false,
114
- homeTabs: { before: 0, closed: 0, after: 0 },
115
- ready: false,
116
- reasonCode: 'unknown',
117
- health: { status: 'unknown', findings: [] },
118
- ...extra,
119
- });
120
-
121
- const { extensionId } = await resolveExtensionId(resolved, { cdpPort });
122
- if (!extensionId) return base({ reasonCode: 'no-extension-id' });
123
-
124
- const homes = homePages(await jsonList(cdpPort), extensionId);
125
- const before = homes.length;
126
-
127
- // Converge on exactly one home tab.
128
- // - 0 tabs → open one.
129
- // - 1 tab → keep it (cheap; the health check below validates it).
130
- // - >1 tabs → close ALL and open one fresh. /json/list alone can't tell a
131
- // healthy tab from a chrome-error one (a crashed page still lists its
132
- // intended url), so keeping the first could discard the good tab and keep a
133
- // broken one. A fresh tab is deterministically clean.
134
- let opened = false;
135
- let closed = 0;
136
- if (homes.length > 1) {
137
- for (const h of homes) {
138
- await closeTab(cdpPort, String(h.id));
139
- closed += 1;
140
- }
141
- await sleep(500);
142
- opened = await openHome(cdpPort, extensionId);
143
- await sleep(1500);
144
- } else if (homes.length === 0) {
145
- opened = await openHome(cdpPort, extensionId);
146
- await sleep(1500);
147
- }
148
-
149
- // Drop stray New Tab / blank pages and foreground the wallet so the headed
150
- // window captured by the device-grid stream shows MetaMask, not chrome://newtab.
151
- // Only prune once a home tab exists, so the window is never left with zero tabs
152
- // (which would close it).
153
- const converged = await jsonList(cdpPort);
154
- const homesNow = homePages(converged, extensionId);
155
- if (homesNow.length >= 1) {
156
- for (const t of converged) {
157
- if (t.type === 'page' && t.id && isStrayTab(t.url)) await closeTab(cdpPort, String(t.id));
158
- }
159
- if (homesNow[0]?.id) await activateTab(cdpPort, String(homesNow[0].id));
160
- }
161
-
162
- const after = homePages(await jsonList(cdpPort), extensionId).length;
163
-
164
- // Verify with the single health source. Lazy import: extension-runtime.ts
165
- // loads the recipe harness at module scope, so it must not be pulled into
166
- // any no-CDP path.
167
- let health: EnsureReadyResult['health'] = { status: 'unknown', findings: [] };
168
- try {
169
- const { checkExtensionRuntimeHealth } = await import('./runtime.ts');
170
- const report = await checkExtensionRuntimeHealth(resolved, cdpPort);
171
- health = { status: report.status, findings: report.findings };
172
- } catch (error) {
173
- health = { status: 'FAIL', findings: [error instanceof Error ? error.message : String(error)] };
174
- }
175
-
176
- const ready = health.status === 'PASS' && after === 1;
177
- return base({
178
- extensionId,
179
- opened,
180
- homeTabs: { before, closed, after },
181
- ready,
182
- reasonCode: ready ? 'ready' : after !== 1 ? 'tab-count' : 'unhealthy',
183
- health,
184
- });
185
- }
@@ -1,107 +0,0 @@
1
- // extension-id.ts — deterministic extension (CRX) id resolution.
2
- // App control: derives the id from the loaded dist manifest key. Name keeps
3
- // the extension- prefix: this is the CRX id, not an instance/slot id.
4
- import crypto from 'node:crypto';
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
-
8
- /**
9
- * Single source of truth for "which extension id is this MetaMask build".
10
- *
11
- * WHY THIS EXISTS. The id was resolved in three different, drifting places —
12
- * host `launch-browser.sh` (Playwright candidate loop), `reopen-browser.sh`
13
- * (`serviceWorkers()[0]`, which grabs Chrome's *component* extensions and broke
14
- * recovery), and the harness CDP target selector. They disagreed. This module
15
- * makes the runner the one resolver host launchers defer to.
16
- *
17
- * The id is **deterministic**: Chrome derives an unpacked extension's id from
18
- * the `key` in its manifest (base64-decode → sha256 → first 16 bytes → each
19
- * nibble mapped to a-p). Since the browser is launched with `--load-extension`
20
- * pointing at exactly this dist, the computed id IS the loaded extension's id —
21
- * no service-worker/page guessing, robust even when the MV3 worker is idle.
22
- *
23
- * `--cdp-port` (optional) only *verifies* the computed id is actually present in
24
- * the running browser; it never changes the answer.
25
- */
26
-
27
- export interface ResolveExtensionResult {
28
- adapter: 'extension';
29
- target: string;
30
- extensionId: string | null;
31
- source: 'manifest-key' | 'cdp-target' | 'none';
32
- verified: boolean | null;
33
- }
34
-
35
- const DIST_MANIFEST = 'dist/chrome/manifest.json';
36
-
37
- /** Chrome's unpacked-extension id derivation from a manifest `key` (base64 DER). */
38
- export function extensionIdFromKey(keyBase64: string): string {
39
- const der = Buffer.from(keyBase64, 'base64');
40
- const hash = crypto.createHash('sha256').update(der).digest();
41
- let id = '';
42
- for (let i = 0; i < 16; i += 1) {
43
- id += String.fromCharCode(97 + (hash[i] >> 4));
44
- id += String.fromCharCode(97 + (hash[i] & 0xf));
45
- }
46
- return id;
47
- }
48
-
49
- function idFromDistManifest(target: string): string | null {
50
- const manifestPath = path.join(target, DIST_MANIFEST);
51
- if (!fs.existsSync(manifestPath)) return null;
52
- try {
53
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { key?: unknown };
54
- return typeof manifest.key === 'string' && manifest.key.length > 0
55
- ? extensionIdFromKey(manifest.key)
56
- : null;
57
- } catch {
58
- return null;
59
- }
60
- }
61
-
62
- /** chrome-extension ids visible to CDP (page targets + service workers). */
63
- async function cdpExtensionIds(cdpPort: number): Promise<string[]> {
64
- try {
65
- const res = await fetch(`http://127.0.0.1:${cdpPort}/json/list`, { signal: AbortSignal.timeout(4000) });
66
- if (!res.ok) return [];
67
- const targets = (await res.json()) as Array<{ url?: string }>;
68
- const ids = new Set<string>();
69
- for (const t of targets) {
70
- const m = /^chrome-extension:\/\/([^/]+)/u.exec(String(t.url ?? ''));
71
- if (m) ids.add(m[1]);
72
- }
73
- return [...ids];
74
- } catch {
75
- return [];
76
- }
77
- }
78
-
79
- /**
80
- * Resolve the MetaMask extension id for a checkout. Pure file/crypto by default;
81
- * `--cdp-port` adds a presence check (and a fallback when the dist has no `key`).
82
- */
83
- export async function resolveExtensionId(
84
- target: string,
85
- options: { cdpPort?: number } = {},
86
- ): Promise<ResolveExtensionResult> {
87
- const resolved = path.resolve(target);
88
- const fromKey = idFromDistManifest(resolved);
89
- let cdpIds: string[] | null = null;
90
- if (options.cdpPort) cdpIds = await cdpExtensionIds(options.cdpPort);
91
-
92
- if (fromKey) {
93
- return {
94
- adapter: 'extension',
95
- target: resolved,
96
- extensionId: fromKey,
97
- source: 'manifest-key',
98
- verified: cdpIds ? cdpIds.includes(fromKey) : null,
99
- };
100
- }
101
- // No `key` in the dist manifest (rare). Fall back to a live CDP id if exactly
102
- // one extension is loaded; otherwise we cannot disambiguate deterministically.
103
- if (cdpIds && cdpIds.length === 1) {
104
- return { adapter: 'extension', target: resolved, extensionId: cdpIds[0], source: 'cdp-target', verified: true };
105
- }
106
- return { adapter: 'extension', target: resolved, extensionId: null, source: 'none', verified: cdpIds ? false : null };
107
- }