@deeeed/metamask-harness 0.70.0 → 0.71.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 (78) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +16 -9
  3. package/adapters/extension/console-tail.mjs +38 -6
  4. package/adapters/extension/launch-browser.cjs +16 -4
  5. package/adapters/manifest.json +128 -0
  6. package/adapters/shared/browser-cdp.cjs +21 -7
  7. package/adapters/shared/browser-resolver.cjs +22 -11
  8. package/adapters/terminal/cleanup.mjs +68 -0
  9. package/adapters/terminal/inject.mjs +74 -0
  10. package/adapters/terminal/launch.mjs +628 -0
  11. package/adapters/terminal/lib/cdp-client.mjs +84 -0
  12. package/adapters/terminal/lib/display.mjs +115 -0
  13. package/adapters/terminal/lib/hud.mjs +148 -0
  14. package/adapters/terminal/lib/mainnet-guard.mjs +177 -0
  15. package/adapters/terminal/lib/origin.mjs +30 -0
  16. package/adapters/terminal/lib/page-script.mjs +246 -0
  17. package/adapters/terminal/lib/processes.mjs +187 -0
  18. package/adapters/terminal/lib/readiness.mjs +274 -0
  19. package/adapters/terminal/lib/strict-wallet.mjs +138 -0
  20. package/adapters/terminal/stop.mjs +37 -0
  21. package/adapters/terminal/verify.mjs +48 -0
  22. package/adapters/terminal/wallet-host.mjs +703 -0
  23. package/dist/adapters/console-capture.js +122 -0
  24. package/dist/adapters/extension/console-capture.js +22 -85
  25. package/dist/adapters/surface.js +3 -1
  26. package/dist/adapters/terminal/console-capture.js +46 -0
  27. package/dist/adapters/terminal/stop.js +19 -0
  28. package/dist/adapters/terminal/surface.js +104 -0
  29. package/dist/adapters.js +27 -4
  30. package/dist/cli.js +2 -2
  31. package/dist/command-contract.js +12 -4
  32. package/dist/commands/check.js +1 -1
  33. package/dist/commands/config.js +1 -1
  34. package/dist/commands/doctor.js +24 -1
  35. package/dist/commands/domain.js +1 -1
  36. package/dist/commands/farmslot-ready.js +2 -0
  37. package/dist/commands/help.js +2 -2
  38. package/dist/commands/launch/index.js +97 -2
  39. package/dist/commands/manifest.js +3 -3
  40. package/dist/commands/recipe-advice.js +1 -1
  41. package/dist/commands/review.js +1 -1
  42. package/dist/commands/run-engine.js +1 -1
  43. package/dist/commands/shared.js +1 -1
  44. package/dist/doctor.js +4 -1
  45. package/dist/harness.js +17 -11
  46. package/dist/manifest.js +5 -0
  47. package/dist/mm-harness-cli.js +34 -20
  48. package/dist/paths.js +2 -2
  49. package/dist/recipe-security.js +12 -3
  50. package/dist/review/knowledge.js +1 -1
  51. package/dist/run-diagnostics.js +150 -33
  52. package/dist/runner.js +1 -1
  53. package/dist/runtime-context.js +3 -3
  54. package/docs/CONTRIBUTING.md +1 -1
  55. package/library/actions/extension/platform/cdp.mjs +35 -10
  56. package/library/actions/shared/console-allowlist-add.mjs +137 -0
  57. package/library/actions/shared/console-collector.mjs +230 -0
  58. package/library/actions/shared/console-findings.mjs +511 -0
  59. package/library/actions/terminal/app/assert_no_console_errors.mjs +119 -0
  60. package/library/actions/terminal/app/launch.mjs +45 -0
  61. package/library/actions/terminal/perps/_venue.mjs +242 -0
  62. package/library/actions/terminal/perps/assert_orders.mjs +54 -0
  63. package/library/actions/terminal/perps/assert_positions.mjs +40 -0
  64. package/library/actions/terminal/perps/read_orders.mjs +26 -0
  65. package/library/actions/terminal/perps/read_positions.mjs +25 -0
  66. package/library/actions/terminal/perps/read_snapshot.mjs +40 -0
  67. package/library/actions/terminal/perps/revoke_agent.mjs +58 -0
  68. package/library/actions/terminal/perps/teardown_state.mjs +104 -0
  69. package/library/actions/terminal/platform/hud.mjs +71 -0
  70. package/library/actions/terminal/platform/page.mjs +247 -0
  71. package/library/actions/terminal/platform/runtime.mjs +200 -0
  72. package/library/actions/terminal/ui/navigate.mjs +31 -0
  73. package/library/actions/terminal/wallet/_signature-log.mjs +177 -0
  74. package/library/actions/terminal/wallet/assert_signatures.mjs +43 -0
  75. package/library/actions/terminal/wallet/list_accounts.mjs +27 -0
  76. package/library/actions/terminal/wallet/read_signatures.mjs +37 -0
  77. package/library/manifests/terminal.action-manifest.json +1786 -0
  78. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.71.0 - 2026-10-02
6
+
7
+ ### Added
8
+
9
+ - `terminal` adapter for the MetaMask Web Terminal (Next.js site): `mm-harness launch|doctor|verify|install|cleanup|stop|run --adapter terminal` drive a per-slot Chrome for Testing on `--cdp-port` against the slot dev server on `--watcher-port`. `signer=extension` loads a MetaMask build with the fixture account imported; `signer=injected` is a strict EIP-1193 test wallet that rejects typed data for another chain. A wallet host logs every wallet request (method, EIP-712 primary type, chain, outcome; never params or signatures) and every MetaMask confirmation shown.
10
+ - Terminal actions: `metamask.app.launch`, venue-backed `metamask.perps.read_positions|read_orders|read_snapshot|assert_positions|assert_orders`, testnet `metamask.perps.teardown_state` and `metamask.perps.revoke_agent` (signed in Node by the fixture account; mainnet only with `mainnet_confirmation=REAL_FUNDS`), `metamask.wallet.read_signatures|assert_signatures|list_accounts`, and `ui.*` with `surface: app|wallet` to drive the MetaMask side panel / notification.
11
+ - Terminal browser is visible by default, one window per slot side by side on the main screen (odd CDP port left, even right; `TERMINAL_WINDOW` / `TERMINAL_SCREEN` override) with a profile zoom that keeps the desktop layout. `--headless`, launch node `headless: true` or `TERMINAL_HEADLESS=1` hide it; `--headful` stays accepted. `--slow-mo <ms>` / `slow_mo_ms` / `TERMINAL_SLOW_MO` pause before each UI action. Terminal `app.hud` draws the recipe, step N/M, current intent and last result on the app tab (closed shadow root outside `<body>`, pointer-events none, redrawn after navigation, never on MetaMask pages); `--hud auto` shows it only in a visible browser.
12
+ - Terminal testnet enforcement: every mainnet host the venue SDK names, and the API and base URLs of `@metamask/perps-controller` and the app source, are unresolvable for the whole browser and blocked by the wallet host (`blocked-mainnet`); mainnet Hyperliquid typed data is refused before any signer sees it (`refused-mainnet`). The venues' web hosts (`app.hyperliquid.xyz`, `app.hyperliquid-testnet.xyz`) are neither blocked nor proof of the served network, and a launch fails if any scanned source uses one with an API path. Source URLs are compared in canonical form (parsed: lowercase host without a trailing dot, default port dropped, dot segments resolved), and the host resolver rules cover each host's trailing-dot form. The launch proves both blocks with probes and that the app talks to a testnet API host, and fails if the SDK names a mainnet host missing from the block list. Mainnet needs `network: mainnet` with `mainnet_confirmation: REAL_FUNDS`. A browser is reused only while it enforces the same host list.
13
+ - Terminal slot safety: slot processes carry `--mm-harness-owner` markers and only proven-owned ones are signalled; wallet calls are judged against their own document's commit, and anything that can't be (including calls pending when a tab detaches) is logged as `unattributed`; a failed launch stops what it started and removes temporary key material; a first page that never commits is reported with what the tab and the dev server did. Only `TERMINAL_CHROME_BIN` overrides the browser.
14
+ - Terminal focus: a visible browser starts in the background through LaunchServices with no startup window; one check after the launch (shared `macos-focus.cjs`, `restoreFrontmostIfOurs`) gives the front back once, by pid, only if this slot's browser took it, and the wallet host only observes. Pages come to front only with `MM_HARNESS_FOCUS_BROWSER=1`.
15
+ - Terminal run diagnostics: recipe runs start the shared console collector for the slot (`terminal/app-console.log` for the app page, `terminal/extension-console.log` for MetaMask; `mm-harness logs` lists both and the wallet request log), and the run's non-probe `blocked-mainnet`, `refused-mainnet` and `unattributed` wallet entries are findings. `metamask.app.assert_no_console_errors` fails a recipe on unexplained page, browser or extension errors.
16
+ - Console allowlists (`diagnostics/console-allowlist.json`, `schemaVersion: 3`, earlier versions refused): exact signatures of an event's first line, continuation and optional location, compared by string equality through one matcher, `library/actions/shared/console-findings.mjs`, which recipe-library guards also call. Normalisation only touches parsed fields: URLs (Next.js scope only on a plain path: every segment made of letters, digits, `.`, `_`, `@`, `-` and `%40`, never `.` or `..`) in a loopback port, a `/_next/static/` asset name (its directory and extension kept), `dpl`/`v` on `/_next/` paths and an unpacked extension id; frames and locations lose their position; other text only in times, UUIDs and hex of 9+ digits. `library/actions/shared/console-allowlist-add.mjs <run artifacts dir>` writes entries from a real run.
17
+
18
+ ### Changed
19
+
20
+ - Console collector capture files are framed: every line after an event's first starts with `│ `, and a browser Log entry's `[browser:<source>]` tag sits in the record header before the level word, where message text can't put it; its location is its URL alone, without a line number. **Logs from older collectors:** each unprefixed line is a finding of its own (at least an error) and their browser entries read as page events, so they report REVIEW.
21
+ - Run diagnostics and `assert_no_console_errors` require capture evidence: this runtime's own collector (a process running this checkout's `console-tail.mjs` as its script, with its exact argument list, so install and runtime paths may contain spaces; owner marker; a marker-less collector for the same log is replaced once, and this runtime's collector started by another harness install is retired before its replacement starts; capture files rotate past 32 MB) and a control line logged in the captured target that reaches the capture file (for the Extension, from MetaMask's own extension id). **Extension consumers:** a run whose capture can't be proven at the end reports UNAVAILABLE instead of CLEAN, so the browser must still be open then; a capture over the 512 KB read limit with no findings is UNAVAILABLE too. Capture control lines are never findings.
22
+
23
+ ## 0.70.1 - 2026-10-02
24
+
25
+ ### Fixed
26
+
27
+ - Detect forced Google Chrome sign-in (`BrowserSignin: 2`, `ForceBrowserSignin`) as managed: `plutil` returns these scalar policies only as raw values, so a Mac with only forced sign-in looked unmanaged and fell back to the managed Chrome.
28
+ - Disable other user extensions in the slot browser before its start page (a dapp or the wallet home) loads; a browser started without a window gets a blank background window first.
29
+ - Fail the launch, and stop the browser, when a slot browser started without a window cannot open its start window, instead of recording a browser with no window.
30
+ - Start the library autolaunch browser on macOS without a startup window, load the wallet home into its window's blank tab (so it is the page a screenshot of the window shows) or a background window, and restore the operator's front app once, by pid, only if its own browser took it. Headful browser probes also start without a startup window.
31
+ - Show doctor's managed-Chrome diagnostics next to any other browser warning.
32
+
5
33
  ## 0.70.0 - 2026-10-02
6
34
 
7
35
  ### Added
package/README.md CHANGED
@@ -149,15 +149,22 @@ On a cold cache the probe can take up to about 80 seconds and
149
149
  shows a small (about 480×360) browser window; `doctor --adapter extension`
150
150
  runs the same probe.
151
151
 
152
- On a Mac whose Google Chrome is managed by policy (force-installed extensions
153
- in `/Library/Managed Preferences/…/com.google.Chrome.plist`, or forced
154
- sign-in), `auto` uses Chrome for Testing: it does not read those policies, so
155
- no forced extension can open windows in the slot. If Chrome for Testing does
156
- not start and paint there, the launch stops and names the two ways forward
157
- (`RECIPE_HARNESS_BROWSER=chrome`, accepting the forced extensions' windows, or
158
- a less loaded machine) instead of falling back to the managed Chrome. Doctor
159
- lists the forced extensions. A slot profile created on the managed Chrome keeps
160
- it, with a warning to reset the profile.
152
+ On a Mac whose Google Chrome is managed by policy, `auto` uses Chrome for
153
+ Testing, which does not read those policies, so no forced extension can open
154
+ windows in the slot. Only MDM-managed preferences are read
155
+ (`/Library/Managed Preferences/com.google.Chrome.plist` and its per-user
156
+ copy under `/Library/Managed Preferences/<user>/`), not `defaults` a user set
157
+ themselves; force-installed extensions (`ExtensionInstallForcelist`,
158
+ `ExtensionSettings`) or forced sign-in (`BrowserSignin: 2`,
159
+ `ForceBrowserSignin`) count as managed; a policy stored as a Data or Date
160
+ value is treated as absent. If Chrome for Testing does not start
161
+ and paint there, the launch stops with `BROWSER_UNLAUNCHABLE` rather than
162
+ falling back to the managed Chrome; the way out is
163
+ `RECIPE_HARNESS_BROWSER=chrome` (accepting that forced extensions may open
164
+ windows that take the front), or a less loaded machine. Doctor lists the forced
165
+ extension ids, with names where it can read them (best effort, read-only: the
166
+ slot record, or the operator's own Chrome profile folders). A slot profile
167
+ created on the managed Chrome keeps it, with a warning to reset the profile.
161
168
 
162
169
  A slot then keeps the kind of browser its profile was created with, until the
163
170
  profile is reset (`mm-harness fixtures set` or `fixtures reset`):
@@ -39,11 +39,13 @@ function parseArgs(argv) {
39
39
  case '-h':
40
40
  case '--help':
41
41
  process.stdout.write(
42
- 'Usage: console-tail.mjs --cdp-port <port> [--extension-log <file>] [--dapp-log <file>]\n',
42
+ 'Usage: console-tail.mjs --cdp-port <port> [--extension-log <file>] [--dapp-log <file>] [--mm-harness-owner=<marker>]\n',
43
43
  );
44
44
  process.exit(0);
45
45
  break;
46
46
  default:
47
+ // An ownership marker the caller identifies its collector by.
48
+ if (arg.startsWith('--mm-harness-owner=')) break;
47
49
  throw new Error(`Unknown argument: ${arg}`);
48
50
  }
49
51
  }
@@ -73,7 +75,13 @@ const cooldown = new Map();
73
75
  // Writes formatted console-event strings (never raw HTTP response bodies) to
74
76
  // stdout and optionally to a caller-supplied log file. The source is the local
75
77
  // Chrome CDP WebSocket (127.0.0.1:<cdpPort>) — a trusted-local endpoint.
76
- function out(line, destination = null) {
78
+ // Every line after an event's first is written with this prefix, so text
79
+ // inside a message can never read as the start of another event.
80
+ const CONTINUATION_PREFIX = '│ ';
81
+
82
+ function out(event, destination = null) {
83
+ const [first, ...rest] = String(event).split('\n');
84
+ const line = [first, ...rest.map((continued) => `${CONTINUATION_PREFIX}${continued}`)].join('\n');
77
85
  process.stdout.write(`${line}\n`);
78
86
  if (destination) {
79
87
  // This file is explicitly an untrusted application-console transcript; it is never executed or parsed as configuration.
@@ -136,10 +144,23 @@ function previewArg(arg) {
136
144
  return arg.subtype || arg.type || '?';
137
145
  }
138
146
 
147
+ // Where an event came from, appended as " (at <frame>)": the first stack
148
+ // frame of a console call or exception, the URL of a browser log entry. Run
149
+ // diagnostics group findings by message and report this frame.
150
+ function frameText(callFrame) {
151
+ if (!callFrame?.url) return '';
152
+ return `${callFrame.functionName || '<anonymous>'} ${callFrame.url}:${(callFrame.lineNumber ?? 0) + 1}:${(callFrame.columnNumber ?? 0) + 1}`;
153
+ }
154
+
155
+ function atSuffix(frame) {
156
+ return frame ? ` (at ${frame})` : '';
157
+ }
158
+
139
159
  function emitConsole(label, params, destination) {
140
160
  const level = String(params.type || 'log').toUpperCase();
141
161
  const text = (params.args || []).map(previewArg).join(' ');
142
- out(`${stamp()} [${label}] ${level} ${text}`, destination);
162
+ const [first, ...rest] = text.split('\n');
163
+ out(`${stamp()} [${label}] ${level} ${first}${atSuffix(frameText(params.stackTrace?.callFrames?.[0]))}${rest.length ? `\n${rest.join('\n')}` : ''}`, destination);
143
164
  }
144
165
 
145
166
  function emitException(label, params, destination) {
@@ -149,13 +170,23 @@ function emitException(label, params, destination) {
149
170
  d.exception?.value ||
150
171
  d.text ||
151
172
  'uncaught exception';
152
- out(`${stamp()} [${label}] EXCEPTION ${text}`, destination);
173
+ const frame = frameText(d.stackTrace?.callFrames?.[0]) || frameText({ url: d.url, lineNumber: d.lineNumber, columnNumber: d.columnNumber });
174
+ const [first, ...rest] = String(text).split('\n');
175
+ out(`${stamp()} [${label}] EXCEPTION ${first}${atSuffix(frame)}${rest.length ? `\n${rest.join('\n')}` : ''}`, destination);
153
176
  }
154
177
 
178
+ // Browser log entries (network failures, interventions, deprecations) are
179
+ // tagged with their Log source in the record header, before the level word,
180
+ // e.g. "[dapp:page:BTC] [browser:network] ERROR …": message text (written
181
+ // after the level word) can never produce the tag.
155
182
  function emitLogEntry(label, params, destination) {
156
183
  const e = params.entry || {};
157
184
  const level = String(e.level || 'log').toUpperCase();
158
- out(`${stamp()} [${label}] ${level} ${e.text || ''}`, destination);
185
+ const source = String(e.source || 'other').replace(/[^a-z-]/gu, '') || 'other';
186
+ // A Log entry's location is its URL as is: a line number appended to a URL
187
+ // could not be told apart from the URL's own text.
188
+ const where = e.url ? e.url : frameText(e.stackTrace?.callFrames?.[0]);
189
+ out(`${stamp()} [${label}] [browser:${source}] ${level} ${e.text || ''}${atSuffix(where)}`, destination);
159
190
  }
160
191
 
161
192
  function handleMessage(label, destination, data) {
@@ -185,7 +216,8 @@ function handleMessage(label, destination, data) {
185
216
  function attach(target) {
186
217
  const stream = targetStream(target);
187
218
  if (!stream) return;
188
- const label = `${stream.kind}:${shortLabel(target)}`;
219
+ // No brackets or spaces: the label is a header field.
220
+ const label = `${stream.kind}:${shortLabel(target)}`.replace(/[[\]\s]/gu, '_');
189
221
  const ws = new WebSocket(target.webSocketDebuggerUrl);
190
222
  const entry = { ws, label, destination: stream.log, opened: false };
191
223
  attached.set(target.id, entry);
@@ -223,7 +223,7 @@ try {
223
223
  }
224
224
  }
225
225
  if (loadsOverCdp) cdpLoad = loadExtensionOverCdp(browserPid);
226
- else if (application) openBackgroundWindow(initialUrl);
226
+ else if (application) openBackgroundWindow(browserPid, initialUrl);
227
227
  owner = cdpLoad?.owner ?? ownerIdentity(browserPid);
228
228
  for (const problem of pruneExtraHomeTabs(cdpPort, args['extension-dir'])) {
229
229
  process.stderr.write(`[launch] home-tab pruning: ${problem}\n`);
@@ -283,14 +283,26 @@ try {
283
283
  }
284
284
 
285
285
  // The first window of a browser started without one, opened in the background.
286
- function openBackgroundWindow(url) {
286
+ // Without it the slot has no window at all, so a failure fails the launch:
287
+ // the owned browser is stopped (its markers kept if it cannot be).
288
+ function openBackgroundWindow(pid, url) {
287
289
  const result = spawnSync(process.execPath, [browserResolverPath, 'open-window', '--port', String(cdpPort), '--url', url], {
288
290
  encoding: 'utf8',
289
291
  timeout: 30_000,
290
292
  });
291
- if (result.status !== 0) {
292
- process.stderr.write(`[launch] could not open the start window: ${(result.stderr || result.error?.message || '').trim()}\n`);
293
+ if (result.status === 0) return;
294
+ const openError = (result.stderr || result.error?.message || `exit ${result.status}`).trim();
295
+ try {
296
+ stopProfileProcessesSync(args.profile, { extraPids: [pid] });
297
+ } catch (stopError) {
298
+ throw new Error(
299
+ `Chrome started but its start window could not be opened (${openError}), and stopping it failed: ${stopError.message}. ` +
300
+ `The launch markers for port ${cdpPort} and ${args.profile} are kept. Next: stop pid ${pid}, then rerun.`,
301
+ );
293
302
  }
303
+ clearDetachedLaunchUnproven(args.profile);
304
+ if (!activeValidationLease) clearValidationPortQuarantine(cdpPort, args.profile);
305
+ throw new Error(`Chrome started but its start window could not be opened: ${openError}. Next: inspect ${args['chrome-log']}, then rerun.`);
294
306
  }
295
307
 
296
308
  // live.sh passes the record its resolver just wrote (--browser-resolution);
@@ -638,6 +638,134 @@
638
638
  "purpose": "Bootstrap numbered MetaMask checkouts through the setup-base command.",
639
639
  "inputs": "setup-base command arguments",
640
640
  "outputs": "numbered checkouts, saved preferences, and run summary"
641
+ },
642
+ {
643
+ "id": "terminal/launch",
644
+ "entry": "adapters/terminal/launch.mjs",
645
+ "kind": "node",
646
+ "purpose": "Start or reuse the Web Terminal slot browser (Chrome for Testing unless TERMINAL_CHROME_BIN; MetaMask extension with the fixture imported, or the injected strict wallet) and its wallet host. On macOS the browser starts through LaunchServices with no startup window; in testnet mode the venue SDK's mainnet hosts are unresolvable for the whole browser.",
647
+ "inputs": "--target --cdp-port --app-port [--signer extension|injected] [--account] [--fresh-profile] [--headless|--headful] [--slow-mo] [--network mainnet --mainnet-confirmation REAL_FUNDS] [--json]; env TERMINAL_CHROME_BIN, TERMINAL_EXTENSION_DIR, TERMINAL_EXTENSION_CHECKOUT, RECIPE_WALLET_FIXTURE, TERMINAL_HEADLESS, TERMINAL_SLOW_MO, TERMINAL_WINDOW, TERMINAL_SCREEN",
648
+ "outputs": "<runtime>/terminal/browser.json + pid/log files; JSON on stdout; exit 0/1/2"
649
+ },
650
+ {
651
+ "id": "terminal/wallet-host",
652
+ "entry": "adapters/terminal/wallet-host.mjs",
653
+ "kind": "node",
654
+ "purpose": "Long-running CDP observer: opens the app tab blank, hooks it once per target, navigates it to the app and verifies the log binding before ready; logs every wallet request from the app top frame and every MetaMask confirmation shown; answers the injected strict wallet in injected mode (other frames and blank popups get 4100).",
655
+ "inputs": "--cdp-port --runtime-dir --signer --app-origin [--extension-id] [--target --account --start-chain] [--browser-app <.app>]",
656
+ "outputs": "<runtime>/terminal/wallet-requests.jsonl, wallet-host.ready"
657
+ },
658
+ {
659
+ "id": "terminal/stop",
660
+ "entry": "adapters/terminal/stop.mjs",
661
+ "kind": "node",
662
+ "purpose": "Stop the terminal slot browser, wallet host and console collector and prove the CDP port is free.",
663
+ "inputs": "--target [--cdp-port]",
664
+ "outputs": "JSON on stdout; exit 0/1"
665
+ },
666
+ {
667
+ "id": "terminal/inject",
668
+ "entry": "adapters/terminal/inject.mjs",
669
+ "kind": "node",
670
+ "purpose": "Terminal install: overlay marker, runtime dir and wallet fixture (0600); no product writes.",
671
+ "inputs": "--target [--fixture] [--force]; env RECIPE_WALLET_FIXTURE",
672
+ "outputs": "<harness>/terminal/install.json, <runtime>/wallet-fixture.json; exit 0/1"
673
+ },
674
+ {
675
+ "id": "terminal/cleanup",
676
+ "entry": "adapters/terminal/cleanup.mjs",
677
+ "kind": "node",
678
+ "purpose": "Terminal cleanup: stop browser/host, remove terminal runtime state, overlay marker and installed fixture; profiles kept unless --reset-profile.",
679
+ "inputs": "--target [--cdp-port] [--reset-profile]",
680
+ "outputs": "JSON on stdout; exit 0/1"
681
+ },
682
+ {
683
+ "id": "terminal/verify",
684
+ "entry": "adapters/terminal/verify.mjs",
685
+ "kind": "node",
686
+ "purpose": "Read-only terminal slot readiness: checkout, deps, dev server, forced testnet, fixture, browser start probe, extension copy, CDP port.",
687
+ "inputs": "--target [--cdp-port] [--watcher-port] [--signer] [--account] [--json]",
688
+ "outputs": "readiness report; exit 0 ready / 1 not ready"
689
+ },
690
+ {
691
+ "id": "terminal/lib-cdp-client",
692
+ "entry": "adapters/terminal/lib/cdp-client.mjs",
693
+ "kind": "lib",
694
+ "purpose": "Browser-level CDP client with flattened target sessions for the wallet host.",
695
+ "inputs": "CdpClient.connect(url)",
696
+ "outputs": "send/on"
697
+ },
698
+ {
699
+ "id": "terminal/lib-page-script",
700
+ "entry": "adapters/terminal/lib/page-script.mjs",
701
+ "kind": "lib",
702
+ "purpose": "Page script source: wallet request logging and the injected strict provider (EIP-6963 io.metamask).",
703
+ "inputs": "pageScriptSource({ signer, appOrigin })",
704
+ "outputs": "script source string"
705
+ },
706
+ {
707
+ "id": "terminal/lib-strict-wallet",
708
+ "entry": "adapters/terminal/lib/strict-wallet.mjs",
709
+ "kind": "lib",
710
+ "purpose": "EIP-1193 strict test wallet enforcing typed-data chainId == active chain.",
711
+ "inputs": "createStrictWallet({ account, chainId })",
712
+ "outputs": "request()"
713
+ },
714
+ {
715
+ "id": "terminal/lib-processes",
716
+ "entry": "adapters/terminal/lib/processes.mjs",
717
+ "kind": "lib",
718
+ "purpose": "Pid-file process stop with owner-marker checks (--mm-harness-owner=<kind>-<id>, exact token; live foreign pids kept and reported, dead ones dropped), process-group termination, CDP port release check.",
719
+ "inputs": "stopTerminalBrowser(target, { cdpPort }), stopConsoleCollector(target), ownerMarker(kind, runtimeDir), ownsProcess(kind, pid, runtimeDir)",
720
+ "outputs": "stopped processes"
721
+ },
722
+ {
723
+ "id": "terminal/lib-readiness",
724
+ "entry": "adapters/terminal/lib/readiness.mjs",
725
+ "kind": "lib",
726
+ "purpose": "Terminal slot readiness checks shared by verify, doctor and the surface.",
727
+ "inputs": "terminalReadiness({ target, appPort, cdpPort, signer }), assertLaunchNetwork(target, { network, mainnetConfirmation }), devServerTestnetDiagnostic(target, { appPort }) (advisory)",
728
+ "outputs": "checks[]"
729
+ },
730
+ {
731
+ "id": "terminal/lib-display",
732
+ "entry": "adapters/terminal/lib/display.mjs",
733
+ "kind": "lib",
734
+ "purpose": "Headless/headful and slow-mo resolution, per-slot window placement and the profile zoom that keeps the desktop layout.",
735
+ "inputs": "resolveHeadless, resolveSlowMo, windowPlacement, writeProfileZoom",
736
+ "outputs": "browser display args"
737
+ },
738
+ {
739
+ "id": "terminal/lib-hud",
740
+ "entry": "adapters/terminal/lib/hud.mjs",
741
+ "kind": "lib",
742
+ "purpose": "Recipe HUD state (<runtime>/hud.json) and the page expression that draws it on the app origin only.",
743
+ "inputs": "nextHudState, hudRenderExpression",
744
+ "outputs": "HUD state, expression source"
745
+ },
746
+ {
747
+ "id": "terminal/lib-origin",
748
+ "entry": "adapters/terminal/lib/origin.mjs",
749
+ "kind": "lib",
750
+ "purpose": "Exact app-origin and app top-frame checks for targets, wallet requests and log entries.",
751
+ "inputs": "isAppUrl(url, appOrigin), isAppTopFrameContext(context, { targetId, appOrigin })",
752
+ "outputs": "boolean"
753
+ },
754
+ {
755
+ "id": "terminal/lib-mainnet-guard",
756
+ "entry": "adapters/terminal/lib/mainnet-guard.mjs",
757
+ "kind": "lib",
758
+ "purpose": "Testnet enforcement: the venue endpoint hosts (from the venue SDK, @metamask/perps-controller and the app source in the checkout), the browser's host-resolver block, and the mainnet typed-data classifier used by the page script and the wallet host.",
759
+ "inputs": "venueHosts(checkout), hostResolverRules(hosts), isMainnetVenueUrl(url, hosts), mainnetTypedDataReason(data)",
760
+ "outputs": "host list, launch switch, refusal reason"
761
+ },
762
+ {
763
+ "id": "terminal/surface",
764
+ "entry": "src/adapters/terminal/surface.ts",
765
+ "kind": "module",
766
+ "purpose": "Terminal adapter surface (runtime status, browser stop, logs, hints).",
767
+ "inputs": "terminalSurface",
768
+ "outputs": "AdapterSurface"
641
769
  }
642
770
  ]
643
771
  }
@@ -428,12 +428,21 @@ async function openBackgroundWindow(send, url, timeoutMs) {
428
428
  return send('Target.createTarget', { url, newWindow: true, background: true }, undefined, timeoutMs);
429
429
  }
430
430
 
431
+ const isBlankPage = (target) => target.type === 'page' && (target.url === 'about:blank' || target.url.startsWith('chrome://newtab') || target.url.startsWith('chrome://new-tab-page'));
432
+
433
+ // A browser started without a window gets a blank background window, so the
434
+ // first window never takes the operator's focus and nothing is navigated yet.
435
+ async function ensureBackgroundWindow(send, remaining) {
436
+ const { targetInfos } = await send('Target.getTargets', {}, undefined, remaining());
437
+ if ((targetInfos || []).some((target) => target.type === 'page')) return;
438
+ await openBackgroundWindow(send, 'about:blank', remaining());
439
+ }
440
+
431
441
  // Point the launch tab at `url` once the extension exists: the tab opened at
432
- // launch would otherwise show an error page for chrome-extension:// URLs. A
433
- // browser started without a window gets a background window instead.
442
+ // launch would otherwise show an error page for chrome-extension:// URLs.
434
443
  async function openUrl(send, url, remaining) {
435
444
  const { targetInfos } = await send('Target.getTargets', {}, undefined, remaining());
436
- const blank = targetInfos.find((target) => target.type === 'page' && (target.url === 'about:blank' || target.url.startsWith('chrome://newtab') || target.url.startsWith('chrome://new-tab-page')));
445
+ const blank = (targetInfos || []).find(isBlankPage);
437
446
  if (!blank) {
438
447
  await openBackgroundWindow(send, url, remaining());
439
448
  return;
@@ -489,12 +498,15 @@ async function loadUnpackedOverPort(port, extensionDir, {
489
498
  assertSameOwner(owner, { exec, timeoutMs: remaining() });
490
499
  step = 'Extensions.loadUnpacked';
491
500
  const loaded = await loadUnpackedExtension(client.send, extensionDir, { expectedId, timeoutMs: remaining() });
492
- // The start page first: a browser started without a window gets its first
493
- // window here, in the background, before isolation opens its tab.
494
- step = 'opening the start page';
495
- if (url) await openUrl(client.send, url, remaining);
501
+ // A browser started without a window gets a blank background window
502
+ // first; other extensions are disabled before the start page (a dapp, or
503
+ // the wallet home) loads in it.
504
+ step = 'opening a background window';
505
+ await ensureBackgroundWindow(client.send, remaining);
496
506
  step = 'isolating the wallet from other extensions';
497
507
  const otherExtensions = await isolateWalletExtension(client.send, loaded.id, { timeoutMs: remaining() });
508
+ step = 'opening the start page';
509
+ if (url) await openUrl(client.send, url, remaining);
498
510
  remaining();
499
511
  return { ...loaded, otherExtensions, owner };
500
512
  } catch (error) {
@@ -523,6 +535,8 @@ module.exports = {
523
535
  expectedExtensionId: (extensionDir) => extensionIdFromExtensionDir(extensionDir) || null,
524
536
  loadUnpackedExtension,
525
537
  loadUnpackedOverPort,
538
+ ensureBackgroundWindow,
526
539
  isolateWalletExtension,
527
540
  openBackgroundWindow,
541
+ openUrl,
528
542
  };
@@ -106,14 +106,24 @@ function managedChromePlists(env = process.env) {
106
106
  ];
107
107
  }
108
108
 
109
- // One policy key as JSON, or undefined when absent or unreadable.
109
+ // One policy key, or undefined when absent or unreadable. `plutil -extract
110
+ // … json` only extracts arrays and dictionaries; a scalar (BrowserSignin: 2,
111
+ // ForceBrowserSignin: true) comes out with `raw`.
110
112
  function readPolicyKey(file, key) {
113
+ const extract = (format) => execFileSync('plutil', ['-extract', key, format, '-o', '-', file], {
114
+ encoding: 'utf8',
115
+ stdio: ['ignore', 'pipe', 'ignore'],
116
+ timeout: 5000,
117
+ });
111
118
  try {
112
- return JSON.parse(execFileSync('plutil', ['-extract', key, 'json', '-o', '-', file], {
113
- encoding: 'utf8',
114
- stdio: ['ignore', 'pipe', 'ignore'],
115
- timeout: 5000,
116
- }));
119
+ return JSON.parse(extract('json'));
120
+ } catch {
121
+ // A scalar, or the key is absent: try it as a raw value.
122
+ }
123
+ try {
124
+ const raw = extract('raw').trim();
125
+ if (raw === 'true' || raw === 'false') return raw === 'true';
126
+ return /^-?\d+$/u.test(raw) ? Number(raw) : raw;
117
127
  } catch {
118
128
  return undefined;
119
129
  }
@@ -512,11 +522,12 @@ async function probeLaunch(executable, {
512
522
  let exited = null;
513
523
  try {
514
524
  const port = await freePort();
515
- // A headful Launch Services probe starts like the slot browser: in the
516
- // background with no startup window (Chrome activates itself when it opens
517
- // one); renderCheck opens a background window. Like the launcher, it keeps
518
- // a window that sits behind the operator's apps rendering.
519
- const backgroundWindow = method === LAUNCH_SERVICES && display === HEADFUL;
525
+ // A headful probe starts like the slot browsers (the launchers and the
526
+ // library autolaunch): in the background with no startup window (Chrome
527
+ // activates itself when it opens one); renderCheck opens a background
528
+ // window. Like the launchers, it keeps a window behind the operator's apps
529
+ // rendering.
530
+ const backgroundWindow = display === HEADFUL;
520
531
  const args = [
521
532
  ...(display === HEADLESS ? ['--headless=new'] : ['--window-size=480,360']),
522
533
  `--user-data-dir=${profile}`,
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // cleanup.mjs — `mm-harness cleanup --adapter terminal`: stop the slot browser
3
+ // and wallet host, then remove the terminal runtime state and the installed
4
+ // wallet fixture. Browser profiles are kept unless --reset-profile.
5
+ //
6
+ // Inputs: --target <checkout> [--cdp-port <port>] [--reset-profile]
7
+ // Outputs: JSON summary on stdout. Exit 0 cleaned; 1 the CDP port stayed busy.
8
+ // Never touches: product source files, the Next.js dev server.
9
+
10
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ import { recipeHarnessPath, walletFixturePath } from '../../library/actions/harness-exports.mjs';
15
+ import { terminalRuntimeDir } from '../../library/actions/terminal/platform/runtime.mjs';
16
+ import { stopConsoleCollector, stopTerminalBrowser } from './lib/processes.mjs';
17
+
18
+ const usage = 'Usage: cleanup.mjs --target <checkout> [--cdp-port <port>] [--reset-profile]';
19
+
20
+ export async function cleanupTerminalRuntime({ target, cdpPort, resetProfile = false }) {
21
+ const root = path.resolve(target);
22
+ const stop = await stopTerminalBrowser(root, { cdpPort });
23
+ await stopConsoleCollector(root);
24
+ const runtime = terminalRuntimeDir(root);
25
+ const removed = [];
26
+ if (existsSync(runtime)) {
27
+ for (const entry of readdirSync(runtime)) {
28
+ if (entry.startsWith('profile-') && !resetProfile) continue;
29
+ rmSync(path.join(runtime, entry), { recursive: true, force: true });
30
+ removed.push(entry);
31
+ }
32
+ }
33
+ const overlay = recipeHarnessPath(root, 'terminal');
34
+ if (existsSync(overlay)) {
35
+ rmSync(overlay, { recursive: true, force: true });
36
+ removed.push(path.relative(root, overlay));
37
+ }
38
+ const fixture = walletFixturePath(root);
39
+ if (existsSync(fixture)) {
40
+ rmSync(fixture, { force: true });
41
+ removed.push(path.relative(root, fixture));
42
+ }
43
+ return { status: 'pass', target: root, stopped: stop.stopped, cdpPortFree: stop.cdpPortFree, removed };
44
+ }
45
+
46
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
47
+ const argv = process.argv.slice(2);
48
+ if (argv.includes('--help') || argv.includes('-h')) {
49
+ process.stdout.write(`${usage}\n`);
50
+ process.exit(0);
51
+ }
52
+ const value = (flag) => {
53
+ const index = argv.indexOf(flag);
54
+ return index >= 0 ? argv[index + 1] : undefined;
55
+ };
56
+ const rawPort = value('--cdp-port') ?? process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
57
+ try {
58
+ const result = await cleanupTerminalRuntime({
59
+ target: value('--target') ?? process.cwd(),
60
+ cdpPort: rawPort ? Number(rawPort) : undefined,
61
+ resetProfile: argv.includes('--reset-profile'),
62
+ });
63
+ process.stdout.write(`${JSON.stringify(result)}\n`);
64
+ } catch (error) {
65
+ process.stdout.write(`${JSON.stringify({ status: 'fail', error: error.message })}\n`);
66
+ process.exit(1);
67
+ }
68
+ }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ // inject.mjs — `mm-harness install --adapter terminal`: prepare the slot runtime
3
+ // directory and install the wallet fixture the terminal actions read.
4
+ //
5
+ // Inputs: --target <terminal checkout> [--fixture <wallet-fixture.json>] [--force]
6
+ // env RECIPE_WALLET_FIXTURE (fixture source when --fixture is absent)
7
+ // Outputs: <target>/<harness root>/terminal/install.json (overlay marker),
8
+ // <target>/<runtime>/terminal/ (0700), <runtime>/wallet-fixture.json (0600);
9
+ // JSON summary on stdout (account names only). Exit 0 installed; 1 failure; 2 usage.
10
+ // Never touches: product source files; never prints key material.
11
+
12
+ import { copyFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ import { recipeHarnessPath, recipeRuntimeDir, walletFixturePath } from '../../library/actions/harness-exports.mjs';
17
+ import { terminalRuntimeDir } from '../../library/actions/terminal/platform/runtime.mjs';
18
+ import { isTerminalCheckout } from './lib/readiness.mjs';
19
+
20
+ const usage = 'Usage: inject.mjs --target <terminal checkout> [--fixture <wallet-fixture.json>] [--force]';
21
+
22
+ export function installTerminalRuntime({ target, fixture, force = false }) {
23
+ const root = path.resolve(target);
24
+ if (!isTerminalCheckout(root)) throw new Error(`${root} is not a Web Terminal checkout (needs next + src/features/perpetuals).`);
25
+ mkdirSync(path.join(root, recipeRuntimeDir()), { recursive: true, mode: 0o700 });
26
+ mkdirSync(terminalRuntimeDir(root), { recursive: true, mode: 0o700 });
27
+ const destination = walletFixturePath(root);
28
+ let fixtureAction = 'kept';
29
+ if (fixture && (force || !existsSync(destination))) {
30
+ const parsed = JSON.parse(readFileSync(fixture, 'utf8'));
31
+ if (!Array.isArray(parsed.accounts) || parsed.accounts.length === 0) throw new Error(`${fixture} has no accounts.`);
32
+ copyFileSync(fixture, destination);
33
+ chmodSync(destination, 0o600);
34
+ fixtureAction = 'installed';
35
+ } else if (!existsSync(destination)) {
36
+ fixtureAction = 'missing';
37
+ }
38
+ const accounts = existsSync(destination)
39
+ ? JSON.parse(readFileSync(destination, 'utf8')).accounts.map((account) => account?.name).filter(Boolean)
40
+ : [];
41
+ const marker = { schemaVersion: 1, adapter: 'terminal', installedAt: new Date().toISOString(), fixture: fixtureAction };
42
+ // The overlay directory is the install marker run/call auto-ensure checks.
43
+ const overlay = recipeHarnessPath(root, 'terminal');
44
+ mkdirSync(overlay, { recursive: true, mode: 0o700 });
45
+ writeFileSync(path.join(overlay, 'install.json'), `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
46
+ return { status: fixtureAction === 'missing' ? 'fail' : 'pass', target: root, fixture: fixtureAction, fixturePath: destination, accounts };
47
+ }
48
+
49
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
50
+ const argv = process.argv.slice(2);
51
+ if (argv.includes('--help') || argv.includes('-h')) {
52
+ process.stdout.write(`${usage}\n`);
53
+ process.exit(0);
54
+ }
55
+ const value = (flag) => {
56
+ const index = argv.indexOf(flag);
57
+ return index >= 0 ? argv[index + 1] : undefined;
58
+ };
59
+ try {
60
+ const result = installTerminalRuntime({
61
+ target: value('--target') ?? process.cwd(),
62
+ fixture: value('--fixture') ?? process.env.RECIPE_WALLET_FIXTURE,
63
+ force: argv.includes('--force'),
64
+ });
65
+ process.stdout.write(`${JSON.stringify(result)}\n`);
66
+ if (result.status !== 'pass') {
67
+ process.stderr.write('No wallet fixture installed. Next: set RECIPE_WALLET_FIXTURE or pass --fixture <wallet-fixture.json>.\n');
68
+ process.exit(1);
69
+ }
70
+ } catch (error) {
71
+ process.stderr.write(`${error.message}\n`);
72
+ process.exit(1);
73
+ }
74
+ }