@deeeed/metamask-harness 0.83.0 → 0.84.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 (51) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +8 -1
  3. package/adapters/manifest.json +14 -110
  4. package/adapters/shared/session-lock.mjs +43 -0
  5. package/adapters/web-dapp/metamask-signer.mjs +212 -0
  6. package/dist/adapters/extension/run.js +4 -2
  7. package/dist/adapters/extension/runtime.js +71 -18
  8. package/dist/adapters/mobile/devices.js +5 -1
  9. package/dist/adapters/mobile/run.js +1 -1
  10. package/dist/adapters/mobile/surface.js +1 -2
  11. package/dist/adapters/plugin-surface.js +14 -42
  12. package/dist/adapters/session-lock.js +23 -0
  13. package/dist/adapters/web-dapp/console-capture.js +2 -20
  14. package/dist/adapters/web-dapp/run.js +25 -24
  15. package/dist/adapters/web-dapp/surface.js +32 -108
  16. package/dist/command-contract.js +1342 -643
  17. package/dist/commands/doctor.js +1 -1
  18. package/dist/commands/launch/device.js +1 -1
  19. package/dist/doctor.js +5 -2
  20. package/dist/mm-harness-cli.js +228 -1210
  21. package/docs/CONTRIBUTING.md +6 -4
  22. package/docs/cli-contract.md +1 -1
  23. package/docs/feature-guard.md +1 -1
  24. package/library/actions/shared/console-collector.mjs +1 -1
  25. package/library/actions/web-dapp/app/launch.mjs +1 -1
  26. package/library/actions/web-dapp/platform/adapter-web-lib.mjs +18 -0
  27. package/library/actions/web-dapp/platform/hud.mjs +1 -1
  28. package/library/actions/web-dapp/platform/page.mjs +2 -3
  29. package/library/actions/web-dapp/platform/runtime.mjs +29 -178
  30. package/package.json +7 -7
  31. package/scripts/completions.sh +1 -1
  32. package/adapters/web-dapp/cleanup.mjs +0 -69
  33. package/adapters/web-dapp/inject.mjs +0 -75
  34. package/adapters/web-dapp/launch.mjs +0 -631
  35. package/adapters/web-dapp/lib/blocked-hosts.mjs +0 -24
  36. package/adapters/web-dapp/lib/cdp-client.mjs +0 -84
  37. package/adapters/web-dapp/lib/display.mjs +0 -115
  38. package/adapters/web-dapp/lib/hud.mjs +0 -148
  39. package/adapters/web-dapp/lib/policy-fence.mjs +0 -119
  40. package/adapters/web-dapp/lib/policy.mjs +0 -84
  41. package/adapters/web-dapp/lib/processes.mjs +0 -187
  42. package/adapters/web-dapp/lib/readiness.mjs +0 -272
  43. package/adapters/web-dapp/stop.mjs +0 -38
  44. package/adapters/web-dapp/verify.mjs +0 -51
  45. package/adapters/web-dapp/wallet-host.mjs +0 -520
  46. package/dist/adapters/web-dapp/doctor-checks.js +0 -33
  47. package/dist/adapters/web-dapp/launch.js +0 -87
  48. package/dist/adapters/web-dapp/overlay.js +0 -21
  49. package/dist/adapters/web-dapp/readiness.js +0 -10
  50. package/dist/adapters/web-dapp/stop.js +0 -20
  51. package/dist/cli.js +0 -202
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.84.0 - 2026-10-08
6
+
7
+ ### Changed
8
+
9
+ - `mm-harness` is the MetaMask preset over `@farmslot/recipe-cli`'s `createHarnessCli`: the CLI program, help, the strict command grammar, library hydration, adapter loading and dispatch come from recipe-cli; mm-harness supplies its commands, handlers and help text. Apart from the removals under Removed and the `--json-stream` refusal change below, every command, flag, help text and JSON envelope is unchanged.
10
+ - `mm-harness --version` (and `-v`) still prints only the harness version. `mm-harness --version --verbose` adds a second line, `@farmslot/recipe-cli <version>`, the recipe-cli it runs on, and `doctor --json` reports it as `runner.recipeCliVersion`.
11
+ - `src/command-contract.ts` holds the one public command table (`PUBLIC_COMMANDS`: name, aliases, summary, example, help text, grammar) and `HIDDEN_COMMANDS`; `src/cli.ts` is gone.
12
+ - `mm-harness --help -- --help` prints the grouped help (it printed an older usage text), and a failure inside `update`, `pin`, `help`, `tutorial`, `setup-base`, `completions` or `call <action> --help` prints its message and exits with its code instead of a stack trace.
13
+ - Docs: the site's tutorial template snapshots (`site/assets/template-examples.json`) are regenerated from the recipe-cook templates at Consensys/skills b115bd2, which pass `call` inputs as bare `key=value` (`call app.lifecycle command=restart`) now that `call --arg` is refused.
14
+ - Docs: the README's Operate section and the feature-guard machine rules say a headful run, call or web-dapp launch on a locked macOS session refuses with `SESSION_LOCKED` (and how to proceed).
15
+ - `run` and `call` apply `--runtime-dir` before the slot resolves, as `doctor` does: the slot context, the checkout lock and the run's runtime state come from that directory. The flag was accepted and ignored.
16
+ - `call` never loads a funded mutation from its command line (it never accepted the `--funding-*` flags); funded mutations run through `run`.
17
+ - Depend on `@farmslot/recipe-cli` 0.10.0 and `@farmslot/adapter-sdk` 0.7.1 (were 0.8.0 and 0.6.0): `createHarnessCli`'s `afterAdapterLoad` and `explainInvalidChoice` hooks (deeeed/farmslot#846), which the preset uses for the web-dapp policy fence and the missing Terminal library, one `--watcher-port` (#806) and key=value-only `call` inputs (#808). Also `@farmslot/recipe-runner` 0.27.0, `@farmslot/adapter-web` 0.6.2, `@farmslot/adapter-rn` 0.19.1 and `@farmslot/adapter-node` 0.1.4 (were 0.26.0, 0.4.1, 0.18.0 and 0.1.2). `@farmslot/agent-runtime` 0.17.0 and `@farmslot/protocol` 0.34.0 are unchanged, and the install holds one `@farmslot/adapter-sdk` and one `@farmslot/recipe-runner`. With them, `launch` prints its setup stage on stderr (a line at least every 15 s, and a no-progress notice after 60 s); `run`, `run --plan` and `call` refuse `--record-video` on an adapter without recording before anything runs (`RECORDING_UNSUPPORTED`, exit 2); and `run` lists evidence an action produced through a fallback provider (`fallbacks` in `--json` and the `--json-stream` completion, a marked line in human output).
18
+ - Under `--json-stream`, a refused adapter plugin (a loader or venue-policy refusal) prints NDJSON on stdout, an `error` event then `complete` with `status: fail`, where 0.83.0 printed the human line on stderr. Human and `--json` output and the exit code (2) are unchanged.
19
+ - `call` writes its artifacts by `run`'s rule: inside a task (`RECIPE_TASK_DIR`/`FARMSLOT_TASK_DIR`) under `<task>/artifacts/calls/<action>-<uuid>`, else `temp/recipe/calls/<action>-<uuid>`; `--artifacts-dir` still wins.
20
+ - The built-in `web-dapp` is adapter-web's `createWebDappAdapter` (`@farmslot/adapter-web/web-dapp`): the slot browser, wallet host, venue policy and its fence, readiness and the launch, verify, stop, inject and cleanup leaves moved there. MetaMask is its extension signer (`adapters/web-dapp/metamask-signer.mjs`: dist and fixture import, seeding, confirmation surfaces, the two readiness checks, and the MetaMask identity of the injected wallet), loaded by every web-dapp process through `RECIPE_WEB_DAPP_SIGNER_MODULE`. The launch, verify, stop, install and cleanup flags and runtime files are unchanged.
21
+
22
+ ### Removed
23
+
24
+ - **BREAKING:** the web-dapp leaf scripts (`adapters/web-dapp/{launch,verify,stop,inject,cleanup,wallet-host}.mjs`) and `adapters/web-dapp/lib/*` (policy, policy fence, readiness, processes, display, HUD, CDP client, blocked hosts) are no longer in the package. They live in `@farmslot/adapter-web/web-dapp`: use its `webDappPolicy` (same name; `POLICY_VERSION` is no longer exported, and the loader enforces the version) and `createWebDappAdapter`. `mm-harness launch|verify|stop` for web-dapp targets are unchanged. recipe-terminal's own `checks/terminal-policy.test.mjs` imports the old path before its #7 (d3a98d2).
25
+ - **BREAKING:** `--metro-port` is gone from `run`, `call`, `doctor`, `launch` and `verify`; pass `--watcher-port`. The CLI answers `--metro-port` with `Did you mean '--watcher-port'`. `doctor`'s `metroPort` runtime-context row reads `mirrors watcherPort`.
26
+ - **BREAKING:** `call` no longer takes `--arg k=v`; pass action inputs as `key=value` (the documented form, which carries every value `--arg` did, leading `-`/`--` included). The CLI answers `--arg` with `Did you mean 'key=value'`. The Mobile stale-source next step is `mm-harness call app.lifecycle command=restart …`.
27
+
28
+ ### Fixed
29
+
30
+ - Unattended Extension and web-dapp (Terminal) runs on a Mac whose login session is locked fail fast with `SESSION_LOCKED` instead of hanging until a screenshot or unlock timeout. macOS stops painting windows while the session is locked; the probe reads the on-console session's `CGSSessionScreenIsLocked` from `ioreg` (bounded to 3 s). `run` and `call` refuse before they launch or drive a headful browser (exit 3; the Extension always, web-dapp unless its running slot browser, or else `TERMINAL_HEADLESS`, is headless; a saved `browser.json` counts only while its browser process is alive and this slot's), and `run --json-stream` carries `SESSION_LOCKED` as an `error` event ahead of `RUN_PREPARE_FAILED`. The web-dapp launcher refuses to start or reuse a headful browser on a locked session (`SESSION_LOCKED: …` in the launch error), so a recipe launch step can't start one either. The Extension health check reports a stalled compositor as `SESSION_LOCKED` rather than `UI_COMPOSITOR_SUSPENDED` when the session is locked. The next step says to unlock the Mac; caffeinate keeps it awake but does not unlock it. Nothing changes when the session is unlocked, off macOS, or when the lock state can't be read.
31
+ - Extension validation now stops a CDP listener that is this slot's own Chrome when no runtime identity proves it (for example after a reload loses CDP): a single Chrome listener whose `--user-data-dir` resolves, by real path, inside the checkout's recipe runtime dir gets the same TERM-then-KILL as an identity-proven one, after its command line is re-read just before the kill. Any other listener (a profile outside the checkout, a non-Chrome process, several listeners, or a command line whose `--user-data-dir` can't be parsed unambiguously) is still refused, and the refusal now names each pid and the `--user-data-dir` it saw, with the next step: stop that pid if it is this slot's browser, or free the port.
32
+
5
33
  ## 0.83.0 - 2026-10-07
6
34
 
7
35
  ### Added
package/README.md CHANGED
@@ -113,6 +113,13 @@ Extension `launch` keeps its incremental watcher running. Refresh the active
113
113
  page after a successful rebuild; use `launch --build` only for an explicit
114
114
  production-like LavaMoat rebuild.
115
115
 
116
+ macOS stops painting visible windows while the login session is locked. On a locked Mac:
117
+
118
+ - a headful Extension or web-dapp (Terminal) `run`/`call` refuses with `SESSION_LOCKED` at its runtime check, before it launches or drives the browser, instead of stalling on screenshots and UI actions;
119
+ - a headful web-dapp launch, including reuse of a running browser, refuses the same way.
120
+
121
+ The Extension has no headless mode, so unlock the Mac. For the web-dapp, unlock, or relaunch its browser headless (`mm-harness launch --adapter terminal --headless`, or set `TERMINAL_HEADLESS=1` before launching): `TERMINAL_HEADLESS=1` alone doesn't help while a headful slot browser is still running. caffeinate keeps a Mac awake but does not unlock it.
122
+
116
123
  ### Extension browser
117
124
 
118
125
  `RECIPE_HARNESS_BROWSER` picks the browser that hosts the unpacked extension:
@@ -259,7 +266,7 @@ how `prepare --remote-flag` keeps its pin through the fixtures step.
259
266
 
260
267
  ```bash
261
268
  mm-harness call metamask.feature_flags.read
262
- mm-harness call metamask.feature_flags.set --arg 'flags={"perpsEnabled":true}' # mobile
269
+ mm-harness call metamask.feature_flags.set 'flags={"perpsEnabled":true}' # mobile
263
270
  mm-harness call metamask.feature_flags.clear # mobile
264
271
  ```
265
272
 
@@ -498,6 +498,14 @@
498
498
  "inputs": "ESM import; env RECIPE_HARNESS_ROOT optional override",
499
499
  "outputs": "recipeHarnessRoot() relative path string"
500
500
  },
501
+ {
502
+ "id": "lib/session-lock",
503
+ "entry": "adapters/shared/session-lock.mjs",
504
+ "kind": "lib",
505
+ "purpose": "Bounded macOS locked-session probe (ioreg CGSSessionScreenIsLocked) and the SESSION_LOCKED wording, shared by the run/call preflight and the Extension health check.",
506
+ "inputs": "ESM import from src/adapters/session-lock.ts or src/adapters/extension/runtime.ts",
507
+ "outputs": "macosSessionLocked() true/false/null; parseSessionLocked(); SESSION_LOCKED_MESSAGE and SESSION_LOCKED_USER_ACTION"
508
+ },
501
509
  {
502
510
  "id": "lib/resolve-slot-ports-sh",
503
511
  "entry": "adapters/shared/resolve-slot-ports.sh",
@@ -592,122 +600,18 @@
592
600
  "outputs": "numbered checkouts, saved preferences, and run summary"
593
601
  },
594
602
  {
595
- "id": "web-dapp/launch",
596
- "entry": "adapters/web-dapp/launch.mjs",
597
- "kind": "node",
598
- "purpose": "Start or reuse the web-dapp 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 hosts the venue policy blocks are unresolvable for the whole browser.",
599
- "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",
600
- "outputs": "<runtime>/<adapter id>/browser.json + pid/log files; JSON on stdout; exit 0/1/2"
601
- },
602
- {
603
- "id": "web-dapp/wallet-host",
604
- "entry": "adapters/web-dapp/wallet-host.mjs",
605
- "kind": "node",
606
- "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). The page script, the frame judgement and the strict wallet are @farmslot/adapter-web/dapp's; the venue policy (RECIPE_WEB_DAPP_POLICY) gives the testnet refusal, hosts, probe and start page.",
607
- "inputs": "--cdp-port --runtime-dir --signer --app-origin [--extension-id] [--target --account --start-chain] [--browser-app <.app>]",
608
- "outputs": "<runtime>/<adapter id>/wallet-requests.jsonl, wallet-host.ready"
609
- },
610
- {
611
- "id": "web-dapp/stop",
612
- "entry": "adapters/web-dapp/stop.mjs",
613
- "kind": "node",
614
- "purpose": "Stop the web-dapp slot browser, wallet host and console collector and prove the CDP port is free.",
615
- "inputs": "--target [--cdp-port]",
616
- "outputs": "JSON on stdout; exit 0/1"
617
- },
618
- {
619
- "id": "web-dapp/inject",
620
- "entry": "adapters/web-dapp/inject.mjs",
621
- "kind": "node",
622
- "purpose": "web-dapp install: overlay marker, runtime dir and wallet fixture (0600); refuses a checkout the venue policy does not match; no product writes.",
623
- "inputs": "--target [--fixture] [--force]; env RECIPE_WALLET_FIXTURE",
624
- "outputs": "<harness>/<adapter id>/install.json, <runtime>/wallet-fixture.json; exit 0/1"
625
- },
626
- {
627
- "id": "web-dapp/cleanup",
628
- "entry": "adapters/web-dapp/cleanup.mjs",
629
- "kind": "node",
630
- "purpose": "web-dapp cleanup: stop browser/host, remove the slot's runtime state, overlay marker and installed fixture; profiles kept unless --reset-profile.",
631
- "inputs": "--target [--cdp-port] [--reset-profile]",
632
- "outputs": "JSON on stdout; exit 0/1"
633
- },
634
- {
635
- "id": "web-dapp/verify",
636
- "entry": "adapters/web-dapp/verify.mjs",
637
- "kind": "node",
638
- "purpose": "Read-only web-dapp slot readiness: checkout, deps, dev server, forced testnet, fixture, browser start probe, extension copy, CDP port.",
639
- "inputs": "--target [--cdp-port] [--watcher-port] [--signer] [--account] [--json]",
640
- "outputs": "readiness report; exit 0 ready / 1 not ready"
641
- },
642
- {
643
- "id": "web-dapp/lib-cdp-client",
644
- "entry": "adapters/web-dapp/lib/cdp-client.mjs",
645
- "kind": "lib",
646
- "purpose": "Browser-level CDP client with flattened target sessions for the wallet host.",
647
- "inputs": "CdpClient.connect(url)",
648
- "outputs": "send/on"
649
- },
650
- {
651
- "id": "web-dapp/lib-processes",
652
- "entry": "adapters/web-dapp/lib/processes.mjs",
653
- "kind": "lib",
654
- "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.",
655
- "inputs": "stopWebDappBrowser(target, { cdpPort }), stopConsoleCollector(target), ownerMarker(kind, runtimeDir), ownsProcess(kind, pid, runtimeDir)",
656
- "outputs": "stopped processes"
657
- },
658
- {
659
- "id": "web-dapp/lib-readiness",
660
- "entry": "adapters/web-dapp/lib/readiness.mjs",
661
- "kind": "lib",
662
- "purpose": "web-dapp slot readiness checks shared by verify, doctor and the surface.",
663
- "inputs": "webDappReadiness({ policy, target, appPort, cdpPort, signer }), assertLaunchNetwork(target, { network, mainnetConfirmation }), devServerTestnetDiagnostic(target, { appPort, variable }) (advisory)",
664
- "outputs": "checks[]"
665
- },
666
- {
667
- "id": "web-dapp/lib-display",
668
- "entry": "adapters/web-dapp/lib/display.mjs",
669
- "kind": "lib",
670
- "purpose": "Headless/headful and slow-mo resolution, per-slot window placement and the profile zoom that keeps the desktop layout.",
671
- "inputs": "resolveHeadless, resolveSlowMo, windowPlacement, writeProfileZoom",
672
- "outputs": "browser display args"
673
- },
674
- {
675
- "id": "web-dapp/lib-hud",
676
- "entry": "adapters/web-dapp/lib/hud.mjs",
677
- "kind": "lib",
678
- "purpose": "Recipe HUD state (<runtime>/hud.json) and the page expression that draws it on the app origin only.",
679
- "inputs": "nextHudState, hudRenderExpression",
680
- "outputs": "HUD state, expression source"
681
- },
682
- {
683
- "id": "web-dapp/lib-blocked-hosts",
684
- "entry": "adapters/web-dapp/lib/blocked-hosts.mjs",
685
- "kind": "lib",
686
- "purpose": "Testnet enforcement mechanics: the host-resolver rule that makes the policy's blocked hosts unresolvable, and the canonical host check the wallet host's request block uses.",
687
- "inputs": "hostResolverRules(hosts), hostOf(url), isBlockedUrl(url, hosts)",
688
- "outputs": "launch switch, booleans"
689
- },
690
- {
691
- "id": "web-dapp/lib-policy",
692
- "entry": "adapters/web-dapp/lib/policy.mjs",
693
- "kind": "lib",
694
- "purpose": "The venue policy of the adapter that extends web-dapp (e.g. the Terminal plugin's), loaded from the module RECIPE_WEB_DAPP_POLICY names; the host sets it when it binds that adapter.",
695
- "inputs": "env RECIPE_WEB_DAPP_POLICY",
696
- "outputs": "webDappPolicy(), webDappAdapterId()"
697
- },
698
- {
699
- "id": "web-dapp/lib-policy-fence",
700
- "entry": "adapters/web-dapp/lib/policy-fence.mjs",
603
+ "id": "web-dapp/metamask-signer",
604
+ "entry": "adapters/web-dapp/metamask-signer.mjs",
701
605
  "kind": "lib",
702
- "purpose": "Checks the venue policy a plugin binds: the policy and every file it imports must be files the plugin digest covers (its module's directory or the library's actions/), imported by literal relative path or as node built-ins. Records their digest in RECIPE_WEB_DAPP_POLICY_DIGEST; a process loading the policy refuses files that changed since.",
703
- "inputs": "policy path, plugin library root and module; env RECIPE_WEB_DAPP_POLICY_DIGEST",
704
- "outputs": "fencePolicy(), assertPolicyDigest(), PolicyFenceError"
606
+ "purpose": "MetaMask as @farmslot/adapter-web web-dapp's extension signer (load the slot's MetaMask dist, import the fixture account through wallet-fixture-state.cjs generate, prefill-profile and seed-cdp, judge MetaMask's confirmation surfaces, readiness checks for the dist and fixture tooling) and the MetaMask identity the injected strict wallet presents.",
607
+ "inputs": "loaded by the web-dapp leaves through RECIPE_WEB_DAPP_SIGNER_MODULE or --signer-module; env TERMINAL_EXTENSION_DIR, TERMINAL_EXTENSION_CHECKOUT, TERMINAL_EXTENSION_NODE",
608
+ "outputs": "signers.extension (prepareProfile, confirm, readinessChecks) and signers.injected.identity"
705
609
  },
706
610
  {
707
611
  "id": "web-dapp/surface",
708
612
  "entry": "src/adapters/web-dapp/surface.ts",
709
613
  "kind": "module",
710
- "purpose": "The web-dapp adapter surface (runtime status, browser stop, logs, hints) and bindWebDappPolicy, which binds the venue policy of the adapter that extends it.",
614
+ "purpose": "The web-dapp adapter: @farmslot/adapter-web's createWebDappAdapter with the MetaMask signer module, the web-dapp action set, console capture, the locked-session preflight and observation, plus the MetaMask-only surface members; bindWebDappPolicy binds the venue policy and the signer module of the adapter that extends it.",
711
615
  "inputs": "webDappSurface",
712
616
  "outputs": "AdapterSurface"
713
617
  }
@@ -0,0 +1,43 @@
1
+ // Whether the macOS login session is locked. macOS stops painting visible
2
+ // windows while it is, so a headful browser's screenshots, video and UI actions
3
+ // stall until their own timeouts. The run/call preflight (src/adapters/
4
+ // session-lock.ts) and the web-dapp launcher refuse up front instead.
5
+
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ export const SESSION_LOCKED_MESSAGE =
9
+ "The macOS login session is locked, so the headful browser can't paint (screenshots, video and UI actions stall).";
10
+ export const SESSION_LOCKED_USER_ACTION =
11
+ 'Unlock the Mac and keep its login session unlocked while the run drives the browser, then rerun. ' +
12
+ 'caffeinate keeps the Mac awake but does not unlock it.';
13
+
14
+ const LOCKED = /<key>CGSSessionScreenIsLocked<\/key>\s*<true\/>/u;
15
+ const ON_CONSOLE = /<key>kCGSSessionOnConsoleKey<\/key>\s*<true\/>/u;
16
+
17
+ // `ioreg -n Root -d1 -a` output: the lock flag of the on-console session in
18
+ // IOConsoleUsers (any session's when none is on console); false for a plist
19
+ // without it, null for anything else.
20
+ export function parseSessionLocked(output) {
21
+ if (!/<plist[\s>]/u.test(output)) return null;
22
+ const users = /<key>IOConsoleUsers<\/key>\s*<array>([\s\S]*?)<\/array>/u.exec(output)?.[1] ?? '';
23
+ const onConsole = users.match(/<dict>[\s\S]*?<\/dict>/gu)?.find((session) => ON_CONSOLE.test(session));
24
+ return LOCKED.test(onConsole ?? output);
25
+ }
26
+
27
+ // true or false on macOS; null when unknown (another platform, or ioreg failed
28
+ // or took longer than 3 s). Never throws.
29
+ export function macosSessionLocked() {
30
+ if (process.platform !== 'darwin') return null;
31
+ try {
32
+ const result = spawnSync('ioreg', ['-n', 'Root', '-d1', '-a'], {
33
+ encoding: 'utf8',
34
+ timeout: 3_000,
35
+ maxBuffer: 8 * 1024 * 1024,
36
+ stdio: ['ignore', 'pipe', 'ignore'],
37
+ });
38
+ if (result.error || result.status !== 0) return null;
39
+ return parseSessionLocked(result.stdout);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
@@ -0,0 +1,212 @@
1
+ // metamask-signer.mjs — MetaMask as web-dapp's extension signer, and the
2
+ // identity the injected strict wallet presents.
3
+ //
4
+ // Purpose:
5
+ // @farmslot/adapter-web/web-dapp runs the slot browser and wallet host; this
6
+ // module is the signer module it loads (RECIPE_WEB_DAPP_SIGNER_MODULE or
7
+ // --signer-module). signer=extension loads the slot's copy of the MetaMask
8
+ // extension and imports the fixture account through the Extension adapter's
9
+ // fixture flow (wallet-fixture-state.cjs generate → prefill-profile →
10
+ // seed-cdp); the wallet host then logs every MetaMask confirmation it sees
11
+ // open. signer=injected presents the strict test wallet as MetaMask, so the
12
+ // app connects to it the way it connects to the extension.
13
+ //
14
+ // Inputs: env TERMINAL_EXTENSION_DIR (the MetaMask dist, default
15
+ // <checkout>/temp/metamask-extension/chrome), TERMINAL_EXTENSION_CHECKOUT (an
16
+ // installed metamask-extension checkout at the dist commit),
17
+ // TERMINAL_EXTENSION_NODE (that checkout's node; else asdf's install for its .nvmrc).
18
+ // Outputs: the `signers` hooks; per-slot key files in the runtime dir, which the
19
+ // launcher removes when the launch ends.
20
+ // Never prints: mnemonics, private keys, the wallet password.
21
+
22
+ import { execFileSync } from 'node:child_process';
23
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ import { fixtureEntry, readinessCheck } from '../../library/actions/web-dapp/platform/adapter-web-lib.mjs';
29
+
30
+ const HARNESS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
31
+ const FIXTURE_STATE_SCRIPT = path.join(HARNESS_ROOT, 'adapters/extension/wallet-fixture-state.cjs');
32
+ // adapter-web's LAUNCH_SERVICES launch method, named as the launch leaf names it.
33
+ const LAUNCH_SERVICES = 'launch-services';
34
+
35
+ function extensionDirFor(target, env) {
36
+ return path.resolve(env.TERMINAL_EXTENSION_DIR || path.join(target, 'temp/metamask-extension/chrome'));
37
+ }
38
+
39
+ // Node of the extension checkout (its native modules, e.g. classic-level, are
40
+ // built for that ABI): TERMINAL_EXTENSION_NODE, else asdf's install for .nvmrc.
41
+ export function resolveExtensionNode(checkout, env = process.env) {
42
+ if (env.TERMINAL_EXTENSION_NODE) return env.TERMINAL_EXTENSION_NODE;
43
+ const nvmrc = path.join(checkout, '.nvmrc');
44
+ if (existsSync(nvmrc)) {
45
+ const wanted = readFileSync(nvmrc, 'utf8').trim().replace(/^v/u, '');
46
+ const root = path.join(os.homedir(), '.asdf/installs/nodejs');
47
+ if (existsSync(root)) {
48
+ const match = readdirSync(root)
49
+ .filter((version) => version === wanted || version.startsWith(`${wanted}.`))
50
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))[0];
51
+ if (match) return path.join(root, match, 'bin/node');
52
+ }
53
+ }
54
+ return process.execPath;
55
+ }
56
+
57
+ // The Extension vault needs a mnemonic keyring, so a privateKey account is
58
+ // imported beside the fixture's first mnemonic account and selected.
59
+ export function slotWalletFixture(fixture, accountName) {
60
+ const entry = fixtureEntry(fixture, accountName);
61
+ const accounts = [];
62
+ if (entry.type === 'privateKey') {
63
+ const primary = fixture.accounts.find((account) => account?.type === 'mnemonic');
64
+ if (!primary) throw new Error(`fixture has no mnemonic account to host the imported key ${accountName}.`);
65
+ accounts.push({ ...primary, count: 1, names: [primary.name] });
66
+ accounts.push(entry);
67
+ } else {
68
+ accounts.push({ ...entry, count: 1, names: [entry.name] });
69
+ }
70
+ return { password: fixture.password, settings: fixture.settings ?? {}, accounts, selectedAccount: accountName };
71
+ }
72
+
73
+ function runFixtureState(node, phase, flags) {
74
+ return execFileSync(node, [FIXTURE_STATE_SCRIPT, phase, ...flags], {
75
+ encoding: 'utf8',
76
+ stdio: ['ignore', 'pipe', 'pipe'],
77
+ timeout: 240000,
78
+ });
79
+ }
80
+
81
+ async function extensionIdFor(withBrowserClient, idFile) {
82
+ if (existsSync(idFile)) return readFileSync(idFile, 'utf8').trim();
83
+ return withBrowserClient(async (client) => {
84
+ const deadline = Date.now() + 30000;
85
+ while (Date.now() < deadline) {
86
+ const { targetInfos } = await client.send('Target.getTargets');
87
+ const worker = targetInfos.find((info) => info.type === 'service_worker' && info.url.startsWith('chrome-extension://'));
88
+ if (worker) return new URL(worker.url).host;
89
+ await new Promise((resolve) => setTimeout(resolve, 250));
90
+ }
91
+ throw new Error('the MetaMask service worker did not start');
92
+ });
93
+ }
94
+
95
+ // MetaMask shows dapp requests in its side panel (the fixture default) or a
96
+ // notification/popup window; only request routes count as confirmations.
97
+ const WALLET_SURFACES = ['sidepanel.html', 'notification.html', 'popup.html'];
98
+ const CONFIRMATION_ROUTE = /^\/(connect|confirm-transaction|confirmation|confirm|signature-request)(\/|$)/u;
99
+
100
+ export const signers = {
101
+ injected: {
102
+ identity: {
103
+ info: {
104
+ uuid: '5d1e5b4e-e2e0-4c3f-9a1d-000000000001',
105
+ name: 'MetaMask (strict test wallet)',
106
+ icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>",
107
+ rdns: 'io.metamask',
108
+ },
109
+ isMetaMask: true,
110
+ },
111
+ },
112
+
113
+ extension: {
114
+ async prepareProfile({ target, runtime, profile, account, fixture, env, freshProfile, log, writePrivateFile, trackSecret }) {
115
+ const extensionDir = extensionDirFor(target, env);
116
+ const checkout = env.TERMINAL_EXTENSION_CHECKOUT ? path.resolve(env.TERMINAL_EXTENSION_CHECKOUT) : null;
117
+ if (!existsSync(path.join(extensionDir, 'manifest.json'))) {
118
+ throw new Error(`MetaMask extension dist not found at ${extensionDir}.\nNext: copy a built dist/chrome there or set TERMINAL_EXTENSION_DIR.`);
119
+ }
120
+ if (!checkout || !existsSync(path.join(checkout, 'node_modules'))) {
121
+ throw new Error('signer=extension needs TERMINAL_EXTENSION_CHECKOUT: an installed metamask-extension checkout at the dist commit (fixture state generation).');
122
+ }
123
+ const manifest = JSON.parse(readFileSync(path.join(extensionDir, 'manifest.json'), 'utf8'));
124
+ const node = resolveExtensionNode(checkout, env);
125
+ // Key material (the slot fixture) and the encrypted vault (fixture state).
126
+ const slotFixture = trackSecret(path.join(runtime, `wallet-fixture.${account}.json`));
127
+ const fixtureState = trackSecret(path.join(runtime, `fixture-state.${account}.json`));
128
+ writePrivateFile(slotFixture, JSON.stringify(slotWalletFixture(fixture, account)));
129
+ const idFile = path.join(runtime, 'extension.id');
130
+ runFixtureState(node, 'generate', ['--target', checkout, '--fixture', slotFixture, '--out', fixtureState]);
131
+ if (freshProfile) {
132
+ runFixtureState(node, 'prefill-profile', ['--target', checkout, '--state', fixtureState, '--profile', profile, '--extension-dir', extensionDir, '--extension-id-file', idFile]);
133
+ log(`prefilled extension profile for ${account}`);
134
+ }
135
+ return {
136
+ browserArgs: [
137
+ '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
138
+ '--disable-extensions-file-access-check',
139
+ `--disable-extensions-except=${extensionDir}`,
140
+ `--load-extension=${extensionDir}`,
141
+ ],
142
+ secrets: [],
143
+ // browser.json's `extension`; the id is filled in once the extension runs.
144
+ state: { id: undefined, version: manifest.version, dir: extensionDir, checkout },
145
+ async afterBrowserStart({ cdpPort, launchMethod, withBrowserClient, openBackgroundWindow }) {
146
+ if (launchMethod === LAUNCH_SERVICES) {
147
+ // The seed reuses an open MetaMask page; without one it would open a
148
+ // tab in the foreground and activate the browser.
149
+ const extensionId = await extensionIdFor(withBrowserClient, idFile);
150
+ await openBackgroundWindow(`chrome-extension://${extensionId}/home.html`);
151
+ }
152
+ runFixtureState(node, 'seed-cdp', [
153
+ '--target', checkout,
154
+ '--fixture', slotFixture,
155
+ '--state', fixtureState,
156
+ '--cdp-port', String(cdpPort),
157
+ '--extension-dir', extensionDir,
158
+ '--extension-id-file', idFile,
159
+ '--out', path.join(runtime, `seed-report.${account}.json`),
160
+ ]);
161
+ const id = readFileSync(idFile, 'utf8').trim();
162
+ log(`extension ${id} seeded and unlocked for ${account}`);
163
+ return { state: { id }, hostArgs: ['--extension-id', id] };
164
+ },
165
+ };
166
+ },
167
+
168
+ confirm({ args, appendEntry, say, focus }) {
169
+ const extensionOrigin = args['extension-id'] ? `chrome-extension://${args['extension-id']}` : null;
170
+ const seenConfirmationUrls = new Map();
171
+ const isWalletSurfaceUrl = (url) =>
172
+ Boolean(extensionOrigin) && WALLET_SURFACES.some((surface) => url.startsWith(`${extensionOrigin}/${surface}`));
173
+ const confirmationRoute = (url) => {
174
+ const route = (url.split('#')[1] ?? '').split('?')[0];
175
+ return CONFIRMATION_ROUTE.test(route) ? route.replace(/0x[0-9a-fA-F]{40}/gu, '<address>') : null;
176
+ };
177
+ return {
178
+ isWalletSurfaceUrl,
179
+ observe(targetId, url) {
180
+ if (!isWalletSurfaceUrl(url)) return;
181
+ if (focus && !url.includes('/sidepanel.html') && !seenConfirmationUrls.has(targetId)) {
182
+ setTimeout(() => focus.observe(url.includes('/popup.html') ? 'popup' : 'notification', 'MetaMask'), 1000);
183
+ }
184
+ const route = confirmationRoute(url);
185
+ if (!route || seenConfirmationUrls.get(targetId) === route) return;
186
+ seenConfirmationUrls.set(targetId, route);
187
+ const surface = WALLET_SURFACES.find((name) => url.includes(name))?.replace('.html', '');
188
+ appendEntry({ kind: 'confirmation-shown', t: Date.now(), surface, route });
189
+ say(`confirmation shown in ${surface}: ${route}`);
190
+ },
191
+ };
192
+ },
193
+
194
+ async readinessChecks({ target, env, required }) {
195
+ const checks = [];
196
+ const extensionDir = extensionDirFor(target, env);
197
+ const manifestPath = path.join(extensionDir, 'manifest.json');
198
+ if (existsSync(manifestPath)) {
199
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
200
+ checks.push(readinessCheck('metamask-extension', true, `MetaMask ${manifest.version} at ${extensionDir}`, { required }));
201
+ } else {
202
+ checks.push(readinessCheck('metamask-extension', false, `no extension dist at ${extensionDir}`, { required }));
203
+ }
204
+ const checkout = env.TERMINAL_EXTENSION_CHECKOUT;
205
+ const checkoutOk = Boolean(checkout) && existsSync(path.join(checkout, 'node_modules/@metamask/browser-passworder'));
206
+ checks.push(readinessCheck('extension-fixture-tooling', checkoutOk, checkoutOk
207
+ ? `extension checkout ${checkout} has fixture-state dependencies`
208
+ : 'TERMINAL_EXTENSION_CHECKOUT unset or not installed (needed to import the wallet into the extension)', { required }));
209
+ return checks;
210
+ },
211
+ },
212
+ };
@@ -9,6 +9,7 @@ import {
9
9
  verifyExtensionConsoleControl
10
10
  } from "./console-capture.js";
11
11
  import { emitProofFailure } from "../proof-failure.js";
12
+ import { refuseLockedSession } from "../session-lock.js";
12
13
  import { launchedBrowser } from "./launched-browser.js";
13
14
  const AUTOLAUNCH_ENV = "METAMASK_RECIPE_EXTENSION_AUTOLAUNCH";
14
15
  const extensionRun = {
@@ -39,8 +40,9 @@ const extensionRun = {
39
40
  });
40
41
  },
41
42
  // The proof runtime is checked on the run's own ports (the engine's scope).
42
- runtimeCheck({ target, heal, state, json, onRecovery }) {
43
- return () => ensureExtensionProofRuntime(target, heal, state, json, { onRecovery });
43
+ // The Extension browser is always headful: a locked session refuses first.
44
+ runtimeCheck({ target, options, heal, state, json, onRecovery }) {
45
+ return async () => refuseLockedSession(json, options) ?? ensureExtensionProofRuntime(target, heal, state, json, { onRecovery });
44
46
  },
45
47
  launchedBrowser: (target, artifactsDir, cdpPort) => launchedBrowser(target, artifactsDir, cdpPort),
46
48
  browserProvenance: (browser) => ({