@deeeed/metamask-harness 0.82.0 → 0.83.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 (34) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/adapters/core/cleanup.sh +5 -29
  3. package/adapters/core/controller-import-probe.mjs +26 -0
  4. package/adapters/manifest.json +16 -0
  5. package/adapters/web-dapp/lib/policy-fence.mjs +119 -0
  6. package/adapters/web-dapp/lib/policy.mjs +7 -2
  7. package/dist/adapters/core/actions.js +0 -3
  8. package/dist/adapters/core/overlay.js +1 -5
  9. package/dist/adapters/core/readiness.js +133 -2
  10. package/dist/adapters/core/surface.js +53 -41
  11. package/dist/adapters/extension/extension-id.js +1 -0
  12. package/dist/adapters/extension/fixtures.js +60 -1
  13. package/dist/adapters/extension/network-observer.js +19 -206
  14. package/dist/adapters/extension/run.js +1 -1
  15. package/dist/adapters/extension/runtime.js +42 -3
  16. package/dist/adapters/mobile/android-gfxinfo-performance.js +3 -1
  17. package/dist/adapters/mobile/performance-observer.js +9 -6
  18. package/dist/adapters/performance/trace-kinds.js +36 -0
  19. package/dist/adapters/plugin-surface.js +29 -1
  20. package/dist/adapters/web-dapp/run.js +6 -1
  21. package/dist/command-contract.js +34 -0
  22. package/dist/commands/check.js +3 -2
  23. package/dist/commands/core-readiness.js +1 -9
  24. package/dist/performance-observation.js +28 -5
  25. package/library/actions/core/perps/_controller.mjs +10 -0
  26. package/library/actions/extension/wallet/import.mjs +21 -4
  27. package/library/actions/extension/wallet/visible-session.mjs +32 -12
  28. package/package.json +7 -6
  29. package/dist/adapters/core/live-script-env.js +0 -36
  30. package/dist/adapters/extension/browser-cdp.js +0 -174
  31. package/dist/adapters/extension/performance-observer.js +0 -75
  32. package/dist/adapters/performance/cdp-trace.js +0 -406
  33. package/dist/adapters/performance/js-task-metrics.js +0 -35
  34. package/dist/async.js +0 -19
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.83.0 - 2026-10-07
6
+
7
+ ### Added
8
+
9
+ - Site: a zoomable platform map (`site/platform.html`) of the recipe platform layers, from the Farmslot runner and adapter packages to mm-harness, recipe libraries and plugins and the Farmslot pieces that call it, with packages, versions and four request flows; linked from the front page, Architecture and Repositories.
10
+
11
+ ### Changed
12
+
13
+ - The Core adapter is a MetaMask preset over `@farmslot/adapter-node` 0.1.2. The headless lifecycle, the runtime context and the overlay cleanup leaf come from the package (`adapters/core/cleanup.sh` runs its `scripts/cleanup.sh --adapter core`). Live scripts import every workspace package the checkout declares from source, through the package's tsconfig paths; that replaces four listed packages, so a stale `dist/` or a package that moved into the monorepo no longer breaks `metamask.perps.*`. mm-harness keeps Core's detection, wording, actions, install leaf, fixtures, and its dependency gate (`run`, `call`, `doctor` and `verify` still agree on `core-controller-actions`). Messages and codes are unchanged.
14
+ - Depend on `@farmslot/recipe-cli` 0.8.0 and `@farmslot/adapter-sdk` 0.6.0 (were 0.6.0 and 0.5.0), which classify an adapter's declared environment failures and forward a parent signal that arrives while a script's leaf starts, on `@farmslot/recipe-runner` 0.26.0, `@farmslot/adapter-rn` 0.18.0 and `@farmslot/adapter-web` 0.4.1 (were 0.25.0, 0.17.1 and 0.3.1), and on `@farmslot/adapter-node` 0.1.2 (new). The frame-metric helpers now come from `@farmslot/recipe-runner/runtime/cdp-trace`, where adapter-rn 0.18.0 moved them. `@farmslot/agent-runtime` 0.17.0 and `@farmslot/protocol` 0.34.0 are unchanged, and the install still holds one `@farmslot/recipe-runner`.
15
+ - Performance and network observation run on Farmslot (Farmslot #830, #834): the CDP trace collector and parser and the JavaScript-task and frame summaries come from `@farmslot/recipe-runner/runtime/cdp-trace`, and the Extension performance and network observers from `@farmslot/adapter-web/performance-observer` and `/network-observer`. `src/adapters/extension/{browser-cdp,performance-observer}.ts`, `src/adapters/performance/{cdp-trace,js-task-metrics}.ts` and `src/async.ts` are gone. mm-harness keeps the MetaMask parts: `TRACE_KINDS` per platform (`src/adapters/performance/trace-kinds.ts`), the `mmh-clock-` marker prefix, the traced UI pages (`/home.html`, then `/sidepanel.html`), `CDP_PORT` and the extension id. The network observer now gets that id too (on `web-dapp`, from the manifest key of the dist the browser loaded), so a component extension that Chrome lists first is never captured instead of MetaMask. Artifacts are unchanged. Two Extension performance errors now say "extension UI target" instead of "MetaMask UI target", and a CDP port that does not answer fails with adapter-web's connection error after retrying for 10 seconds.
16
+
17
+ ### Fixed
18
+
19
+ - A plugin that extends `web-dapp` can no longer bind a venue policy outside its own code. The policy module and every file it imports must be files the plugin digest covers (the loader's file list for the plugin, by real path: the module's directory and the library's `actions/`), imported by literal relative path or as node built-ins. Anything else refuses the plugin with `ADAPTER_PLUGIN_INVALID` before any policy code runs. The digest of those files goes in `RECIPE_WEB_DAPP_POLICY_DIGEST`, and every process that loads the policy refuses files that changed since the command bound it. A refusal from a plugin's own checks is no longer mistaken for one from a built-in.
20
+ - `--adapter terminal` (or `--platform terminal`) without the Terminal recipe library is no longer reported as a typo. It fails with `CLI_ADAPTER_LIBRARY_MISSING`, names `MetaMask/experimental-metamask-recipe-terminal` (at a revision whose `recipe-library.json` declares `adapters.terminal`) and gives the command again with the library on `RECIPE_LIBRARY_PATH`. When `RECIPE_LIBRARY_PATH` already holds an older checkout of that library (named `terminal`, or holding `recipes/terminal`), it says to update that checkout. Other unknown ids are still `CLI_INVALID_OPTION_VALUE`.
21
+ - Core `doctor` and `verify` import the Perps controller the way `metamask.perps.*` actions do (a new required `core-controller-import` check, bounded to 60 s, run once the dependency gate passes). A partial workspace build (a package whose `dist/` is missing) used to pass readiness and then fail the first action; it now fails readiness, naming the missing module, with `cd <core> && yarn build`, and Core's runtime status reports `blocked` (`CORE_CONTROLLER_IMPORT_FAILED`) so `--print-ready`, `--expect-live` and `--fix` agree. The probe runs once per command, in its own process group, and a timeout, or a signal that ends the command, kills the whole tree. When an action still hits a missing module, Core's live adapter prints `[core/perps] environment: cannot resolve …` with the same build step, and `run`/`call` classify it `ENVIRONMENT_NOT_READY` (exit 4) instead of `APP_LOGIC_FAILURE`.
22
+ - The Extension validation browser that `run --launch-existing-dist` opens now seeds the slot's wallet fixture (it launches its fresh profile with `--reset-profile`); it used to open on onboarding with no wallet. A seeded launch still on onboarding after a 5-second re-check fails with `FIXTURE_SEED_NOT_APPLIED` instead of running on an empty wallet. Runs that attach to an existing runtime, and `fixtures set`, are not gated; under `--launch-existing-dist` an onboarding recipe now starts from the seeded wallet.
23
+ - Extension `fixtures set|reset` no longer swaps a selected release artifact for the checkout build. It used to report pass after relaunching `dist/chrome`, and the next action failed with `EXTENSION_RUNTIME_NOT_CURRENT` and a checkout-rebuild hint. While an artifact is recorded, valid or not, it now refuses before touching the browser with `FIXTURES_ARTIFACT_ACTIVE`, exit 1. For a valid artifact, `Next:` re-selects it (archive, version, SHA-256, cache, profile) with `--reset-profile`, then runs `wallet.import method=ui credential_source=fixture`, so exported `MM_HARNESS_WALLET_*` values can't import another wallet. A `--fixture`/`RECIPE_WALLET_FIXTURE` that isn't the canonical fixture is published first with `fixtures init --from <path> --force`, and the message names the slot's canonical fixture that it replaces. When the recorded archive no longer exists, `Next:` asks for it (`--artifact-file <path-to-the-artifact-zip>`) instead of printing a command that would fail. `userAction` and `Next:` are each one runnable command line. For a valid artifact, the checkout route (`runtime-launch` without an artifact, which drops the artifact record, then `fixtures set`) is offered separately, as `error.alternatives` in JSON and an `Or:` line in human output; for an invalid record it is the `Next:` itself. Commands are shell-quoted. Checkout launches are unchanged. Extension `wallet.import method=ui` also recovers the wallet locking right after onboarding (seen on a fresh 13.51.0 artifact profile while the sidepanel attaches). It used to fail the account search with `could not enter the derived release identity (route "#/unlock")`. After the usual unlock of a profile that starts locked, a relock during the account proof is unlocked once more with the fixture password and reported as `relockRecovered: true`; a further lock still fails with that route error. The visible account search also re-enters a search value that the account list drops right after onboarding (it failed with `could not enter the alternate-address control (route "#/account-list")`), in place and within the same deadline. It stops at once if the page leaves the list, and the error now names the last input failure, with addresses redacted. The no-match control and the exact match are still required.
24
+
25
+ ## 0.82.1 - 2026-10-07
26
+
27
+ ### Fixed
28
+
29
+ - Extension `check diff [--fix]` no longer fails when every changed file is one the repo's oxfmt config ignores (for example `package.json`, `yarn.lock` and regenerated LavaMoat policy JSON): oxfmt runs with `--no-error-on-unmatched-pattern`, so it checks nothing instead of exiting 2 with "Expected at least one target file". A real formatting problem in a file oxfmt does check still fails the gate.
30
+
5
31
  ## 0.82.0 - 2026-10-07
6
32
 
7
33
  ### Added
@@ -3,35 +3,11 @@
3
3
  #
4
4
  # Inputs: --target <metamask-core> (default $PWD); env RECIPE_HARNESS_ROOT.
5
5
  # Outputs: removes <harness>/core. Exit 0 — cleaned (idempotent); 2 — bad args.
6
- # Never touches: product files (core install patches nothing).
7
- #
8
- # Core injects nothing into the product checkout, so cleanup only removes the
9
- # ignored overlay directory temp/recipe/harness/core. There are no backups to
10
- # restore and no patched product files. Idempotent: a missing overlay is a
11
- # success, not an error.
6
+ # Binding: runs @farmslot/adapter-node's scripts/cleanup.sh --adapter core.
12
7
  set -euo pipefail
13
-
14
- TARGET="$PWD"
15
- while [ "$#" -gt 0 ]; do
16
- case "$1" in
17
- --target) TARGET="$2"; shift 2 ;;
18
- -h|--help) echo "Usage: cleanup.sh (core) [--target <metamask-core>]"; exit 0 ;;
19
- *) echo "Unknown arg: $1" >&2; exit 2 ;;
20
- esac
21
- done
22
-
23
8
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
- TARGET="$(cd "$TARGET" && pwd)"
25
- # shellcheck disable=SC1091
26
- for _hp in "$SCRIPT_DIR/../shared/harness-path.sh"; do
27
- [ -f "$_hp" ] && { . "$_hp"; break; }
28
- done
29
- unset _hp
30
- if ! command -v harness_root >/dev/null 2>&1; then
31
- echo "mm-harness: shared lib adapters/shared/harness-path.sh not found; reinstall the runner." >&2
9
+ NODE_CLEANUP="$(cd "$SCRIPT_DIR" && node -p "require.resolve('@farmslot/adapter-node/scripts/cleanup.sh')")" || {
10
+ echo "cleanup.sh: @farmslot/adapter-node is not installed next to the runner; reinstall the runner." >&2
32
11
  exit 1
33
- fi
34
- HARNESS_DIR="$(harness_dir "$TARGET" core)"
35
-
36
- rm -rf "$HARNESS_DIR"
37
- echo "Cleaned core recipe harness from $TARGET"
12
+ }
13
+ exec bash "$NODE_CLEANUP" --adapter core "$@"
@@ -0,0 +1,26 @@
1
+ // controller-import-probe — import what Core's metamask.perps.* live scripts
2
+ // import (the checkout's perps-controller and messenger sources), under the same
3
+ // tsx environment, and report the outcome as one JSON line on stdout.
4
+ //
5
+ // Inputs: argv[2] = the core checkout. Run by `doctor` (readiness), never by a recipe.
6
+ // Outputs: {"ok":true} or {"ok":false,"code":…,"url":…,"message":…}; exit 0 either way (2: no checkout).
7
+ // Never touches: the checkout (import only, no controller is constructed).
8
+ import path from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+
11
+ const root = process.argv[2];
12
+ if (!root || root === '--help' || root === '-h') {
13
+ console.log('Usage: controller-import-probe.mjs <core-checkout> (run under the actions\' tsx env by doctor)');
14
+ process.exit(root ? 0 : 2);
15
+ }
16
+ try {
17
+ await Promise.all([
18
+ import(pathToFileURL(path.join(root, 'packages/perps-controller/src/index.ts')).href),
19
+ import(pathToFileURL(path.join(root, 'packages/messenger/src/index.ts')).href),
20
+ ]);
21
+ process.stdout.write(`${JSON.stringify({ ok: true })}\n`);
22
+ } catch (error) {
23
+ process.stdout.write(
24
+ `${JSON.stringify({ ok: false, code: error?.code ?? null, url: error?.url ?? null, message: String(error?.message ?? error) })}\n`,
25
+ );
26
+ }
@@ -386,6 +386,14 @@
386
386
  "inputs": "--target; env RECIPE_HARNESS_ROOT",
387
387
  "outputs": "removes <harness>/core; exit 0/2"
388
388
  },
389
+ {
390
+ "id": "core/controller-import-probe",
391
+ "entry": "adapters/core/controller-import-probe.mjs",
392
+ "kind": "node",
393
+ "purpose": "Import the checkout's Perps controller and messenger sources the way metamask.perps.* actions do; run by doctor's core-controller-import check under the actions' tsx env.",
394
+ "inputs": "<core-checkout>",
395
+ "outputs": "one JSON line {ok,code,url,message} on stdout; exit 0 (2: no checkout)"
396
+ },
389
397
  {
390
398
  "id": "lib/harness-path",
391
399
  "entry": "adapters/shared/harness-path.sh",
@@ -687,6 +695,14 @@
687
695
  "inputs": "env RECIPE_WEB_DAPP_POLICY",
688
696
  "outputs": "webDappPolicy(), webDappAdapterId()"
689
697
  },
698
+ {
699
+ "id": "web-dapp/lib-policy-fence",
700
+ "entry": "adapters/web-dapp/lib/policy-fence.mjs",
701
+ "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"
705
+ },
690
706
  {
691
707
  "id": "web-dapp/surface",
692
708
  "entry": "src/adapters/web-dapp/surface.ts",
@@ -0,0 +1,119 @@
1
+ // The venue policy module runs outside the plugin loader's import fence: the
2
+ // host and every web-dapp leaf process require it by path. So mm-harness checks
3
+ // it itself when it binds the adapter: the policy file and everything it
4
+ // imports must be files the plugin digest covers (the loader's
5
+ // libraryAdapterFiles, by real path), and imports must be literal relative
6
+ // paths or node built-ins. The digest of those files goes in
7
+ // RECIPE_WEB_DAPP_POLICY_DIGEST, and a process that loads the policy with it
8
+ // set refuses files that changed since.
9
+
10
+ import { createHash } from 'node:crypto';
11
+ import fs from 'node:fs';
12
+ import { isBuiltin } from 'node:module';
13
+ import path from 'node:path';
14
+
15
+ export const POLICY_DIGEST_ENV = 'RECIPE_WEB_DAPP_POLICY_DIGEST';
16
+
17
+ export class PolicyFenceError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.code = 'ADAPTER_PLUGIN_INVALID';
21
+ this.userAction =
22
+ 'keep the venue policy and every file it imports in the plugin module\'s directory (or the library\'s actions/), importing node built-ins and literal relative paths only';
23
+ }
24
+ }
25
+
26
+ const STATIC_IMPORT = /(?:^|[\s;])(?:import|export)\b[^'"`;]*?\bfrom\s*(['"])([^'"]+)\1/gu;
27
+ const BARE_IMPORT = /(?:^|[\s;])import\s*(['"])([^'"]+)\1/gu;
28
+ const LITERAL_CALL = /(?<![.\w$])(?:import|require)\s*\(\s*(['"])([^'"]+)\1\s*\)/gu;
29
+ const ANY_CALL = /(?<![.\w$])(?:import|require)\s*\(/gu;
30
+
31
+ // Whole-line comments may mention imports and are dropped. Nothing else is: a
32
+ // block or trailing comment that mentions an import is read as one and refused,
33
+ // so the scan errs on the side of refusing, never of missing an import.
34
+ function stripComments(source) {
35
+ return source.replace(/^\s*\/\/.*$/gmu, '');
36
+ }
37
+
38
+ function specifiers(source, file) {
39
+ const code = stripComments(source);
40
+ const found = [];
41
+ for (const pattern of [STATIC_IMPORT, BARE_IMPORT, LITERAL_CALL]) {
42
+ for (const match of code.matchAll(pattern)) found.push(match[2]);
43
+ }
44
+ const literalCalls = [...code.matchAll(LITERAL_CALL)].length;
45
+ if ([...code.matchAll(ANY_CALL)].length > literalCalls) {
46
+ throw new PolicyFenceError(`web-dapp venue policy file ${file} imports or requires a computed specifier, which mm-harness can't check.`);
47
+ }
48
+ return found;
49
+ }
50
+
51
+ function realFile(file) {
52
+ try {
53
+ const real = fs.realpathSync(file);
54
+ if (fs.statSync(real).isFile()) return real;
55
+ } catch {
56
+ // reported below
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ /**
62
+ * The real paths of the policy module and every file it imports, in import
63
+ * order. `allowed(real)` decides each file; relative specifiers resolve
64
+ * against the importer's real path, as Node does.
65
+ */
66
+ function policyClosure(file, allowed) {
67
+ const start = realFile(path.resolve(file));
68
+ if (!start) throw new PolicyFenceError(`web-dapp venue policy file ${file} does not exist.`);
69
+ const seen = new Set();
70
+ const visit = (real) => {
71
+ if (seen.has(real)) return;
72
+ allowed(real);
73
+ seen.add(real);
74
+ for (const specifier of specifiers(fs.readFileSync(real, 'utf8'), real)) {
75
+ if (specifier.startsWith('node:') || isBuiltin(specifier)) continue;
76
+ if (!specifier.startsWith('./') && !specifier.startsWith('../')) {
77
+ throw new PolicyFenceError(`web-dapp venue policy file ${real} imports '${specifier}', which is neither a node built-in nor a relative path the plugin digest covers.`);
78
+ }
79
+ const target = realFile(path.resolve(path.dirname(real), specifier));
80
+ if (!target) throw new PolicyFenceError(`web-dapp venue policy file ${real} imports '${specifier}', which does not resolve to a file.`);
81
+ visit(target);
82
+ }
83
+ };
84
+ visit(start);
85
+ return [...seen];
86
+ }
87
+
88
+ function digestFiles(files) {
89
+ const hash = createHash('sha256');
90
+ for (const file of [...files].sort()) {
91
+ hash.update(file).update('\0').update(fs.readFileSync(file)).update('\0');
92
+ }
93
+ return hash.digest('hex');
94
+ }
95
+
96
+ /**
97
+ * Check a policy module against `covered`, the real paths of the files the
98
+ * plugin digest covers, and return the digest of the policy's files. Throws
99
+ * PolicyFenceError when one of them is not covered.
100
+ */
101
+ export function fencePolicy({ file, covered, scope }) {
102
+ const allowed = (real) => {
103
+ if (!covered.has(real)) {
104
+ throw new PolicyFenceError(`web-dapp venue policy file ${real} is outside the files the plugin digest covers (${scope}).`);
105
+ }
106
+ };
107
+ return digestFiles(policyClosure(file, allowed));
108
+ }
109
+
110
+ /** Refuse a policy whose files changed since mm-harness bound it (`digest`). */
111
+ export function assertPolicyDigest(file, digest) {
112
+ const now = digestFiles(policyClosure(file, () => {}));
113
+ if (now !== digest) {
114
+ throw new Error(
115
+ `web-dapp venue policy ${file} or a file it imports changed since mm-harness bound it.\n` +
116
+ 'Next: rerun the command, so it binds and checks the policy again.',
117
+ );
118
+ }
119
+ }
@@ -7,6 +7,8 @@
7
7
  import { createRequire } from 'node:module';
8
8
  import path from 'node:path';
9
9
 
10
+ import { POLICY_DIGEST_ENV, assertPolicyDigest } from './policy-fence.mjs';
11
+
10
12
  export const POLICY_ENV = 'RECIPE_WEB_DAPP_POLICY';
11
13
  // The policy contract this web-dapp reads; a policy declares the version it was written for.
12
14
  export const POLICY_VERSION = 1;
@@ -66,9 +68,12 @@ export function webDappPolicy(env = process.env) {
66
68
  'Next: put the library that declares it on RECIPE_LIBRARY_PATH and pass its id, e.g. --adapter terminal.',
67
69
  );
68
70
  }
69
- if (loaded?.file !== file) {
71
+ const digest = env[POLICY_DIGEST_ENV];
72
+ if (loaded?.file !== file || loaded.digest !== digest) {
73
+ // The digest mm-harness took when it bound the policy: refuse files that changed since.
74
+ if (digest) assertPolicyDigest(file, digest);
70
75
  // The policy module uses node built-ins only and has no top-level await, so it loads synchronously.
71
- loaded = { file, policy: checkPolicy(createRequire(import.meta.url)(path.resolve(file)).policy, file) };
76
+ loaded = { file, digest, policy: checkPolicy(createRequire(import.meta.url)(path.resolve(file)).policy, file) };
72
77
  }
73
78
  return loaded.policy;
74
79
  }
@@ -11,9 +11,6 @@ const coreActions = {
11
11
  ],
12
12
  // read_account and quote_fees are Core's alone, and only its live adapter implements them.
13
13
  liveOnly: ["metamask.perps.read_account", "metamask.perps.quote_fees"],
14
- tsxLiveScripts: {
15
- env: async (projectRoot, tempDir) => (await import("./live-script-env.js")).coreLiveScriptEnv(projectRoot, tempDir)
16
- },
17
14
  // Core declares no cdp.target; the probe matches the Extension's.
18
15
  cdpTarget: { transport: "chrome-extension-cdp", probePath: "json/version" },
19
16
  // ensure_positions and ensure_orders with an open or present state place an order.
@@ -23,12 +23,8 @@ Next: mm-harness install --platform core --target ${target}`
23
23
  return { command: delegate, prefixArgs: ["doctor", "--adapter", "core"] };
24
24
  }
25
25
  };
26
- const coreRuntimeContext = {
27
- forbiddenFields: ["cdpPort", "watcherPort", "metroPort", "devServerPort", "simulator", "simulatorUdid", "adbSerial", "extensionId"]
28
- };
29
26
  const corePrepare = {};
30
27
  export {
31
28
  coreHarness,
32
- corePrepare,
33
- coreRuntimeContext
29
+ corePrepare
34
30
  };
@@ -1,11 +1,140 @@
1
+ import { spawn } from "node:child_process";
1
2
  import fs from "node:fs";
3
+ import os from "node:os";
2
4
  import path from "node:path";
3
5
  import { coreDependencyBlock } from "../../commands/core-readiness.js";
6
+ import { shellQuote } from "@farmslot/recipe-cli/harness";
4
7
  import { resolveLiveTsx } from "../../live-tsx.js";
8
+ import { runnerDir } from "../../paths.js";
5
9
  const coreReadiness = {
6
10
  compatibilityMode: (target) => fs.existsSync(path.join(target, "packages/perps-controller/src/index.ts")) ? "headless controller (no bridge)" : "unsupported/no bridge",
7
- checks: (target) => [coreControllerActionsCheck(target)]
11
+ checks: (target) => [coreControllerActionsCheck(target)],
12
+ // Installed dependencies and a tsx are not enough: a partial workspace build
13
+ // can still leave the controller unimportable. Import it the way the actions
14
+ // do, once the cheaper gate passes. runtimeStatus asks the same question, so
15
+ // --print-ready, --expect-live and verify agree.
16
+ liveChecks: async (target) => coreDependencyBlock(target) ? [] : [coreControllerImportCheck(await coreControllerImport(target))],
17
+ // doctor --fix cannot build the workspace: an unimportable controller stays failed.
18
+ runtimeBlock: (_target, runtime) => runtime?.reasonCode === "CORE_CONTROLLER_IMPORT_FAILED" ? { id: "core-controller-import", userAction: runtime.nextAction } : void 0
8
19
  };
20
+ const CONTROLLER_IMPORT_TIMEOUT_MS = 6e4;
21
+ const PARENT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
22
+ function isProbeReport(value) {
23
+ if (typeof value !== "object" || value === null) return false;
24
+ const report = value;
25
+ const optionalString = (field) => field === void 0 || field === null || typeof field === "string";
26
+ return typeof report.ok === "boolean" && optionalString(report.code) && optionalString(report.url) && (report.message === void 0 || typeof report.message === "string");
27
+ }
28
+ function coreRebuildAction(target) {
29
+ return `cd ${shellQuote(path.resolve(target))} && yarn build`;
30
+ }
31
+ const controllerImports = /* @__PURE__ */ new Map();
32
+ function coreControllerImport(target) {
33
+ const key = path.resolve(target);
34
+ let result = controllerImports.get(key);
35
+ if (!result) {
36
+ result = probeControllerImport(key);
37
+ controllerImports.set(key, result);
38
+ }
39
+ return result;
40
+ }
41
+ async function probeControllerImport(target) {
42
+ const runner = resolveLiveTsx(target);
43
+ if (!runner) return { ok: false, reason: "no tsx runtime found" };
44
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-harness-core-probe-"));
45
+ let cancelledBy;
46
+ try {
47
+ const { coreSurface } = await import("./surface.js");
48
+ const env = { ...process.env, ...await coreSurface.actions.tsxLiveScripts?.env(target, tempDir) };
49
+ const probe = path.join(runnerDir, "adapters/core/controller-import-probe.mjs");
50
+ const run = await runBounded(runner.bin, [probe, target], target, env);
51
+ cancelledBy = run.cancelledBy;
52
+ if (run.spawnError) {
53
+ return { ok: false, reason: `tsx (${runner.source}) ${runner.bin} could not start: ${run.spawnError.code ?? run.spawnError.message}`, code: run.spawnError.code };
54
+ }
55
+ if (run.timedOut) {
56
+ return {
57
+ ok: false,
58
+ reason: `importing the Perps controller took over ${CONTROLLER_IMPORT_TIMEOUT_MS / 1e3}s`,
59
+ userAction: coreRebuildAction(target)
60
+ };
61
+ }
62
+ const line = run.stdout.trim().split("\n").at(-1) ?? "";
63
+ let parsed;
64
+ if (line.startsWith("{")) {
65
+ try {
66
+ parsed = JSON.parse(line);
67
+ } catch (error) {
68
+ if (!(error instanceof SyntaxError)) throw error;
69
+ }
70
+ }
71
+ const report = isProbeReport(parsed) ? parsed : void 0;
72
+ if (!report || run.exitCode !== 0) {
73
+ return { ok: false, reason: `tsx (${runner.source}) ${runner.bin} did not run the controller import probe (exit ${run.exitCode ?? "signal"})` };
74
+ }
75
+ if (report.ok) return { ok: true };
76
+ const missingModule = report.code === "ERR_MODULE_NOT_FOUND" || report.code === "MODULE_NOT_FOUND";
77
+ const detail = report.url ?? report.message?.split("\n")[0];
78
+ return {
79
+ ok: false,
80
+ reason: missingModule ? `a module is missing (${detail})` : String(detail),
81
+ code: report.code ?? void 0,
82
+ userAction: coreRebuildAction(target)
83
+ };
84
+ } finally {
85
+ fs.rmSync(tempDir, { recursive: true, force: true });
86
+ if (cancelledBy) process.kill(process.pid, cancelledBy);
87
+ }
88
+ }
89
+ function coreControllerImportCheck(result) {
90
+ const id = "core-controller-import";
91
+ if (result.ok) {
92
+ return { id, status: "pass", required: true, message: "the Perps controller imports under the action runtime." };
93
+ }
94
+ return {
95
+ id,
96
+ status: "fail",
97
+ required: true,
98
+ message: `metamask.perps.* actions cannot import the Perps controller: ${result.reason}.`,
99
+ ...result.code ? { detail: result.code } : {},
100
+ ...result.userAction ? { userAction: result.userAction } : {}
101
+ };
102
+ }
103
+ function runBounded(bin, args, cwd, env) {
104
+ return new Promise((resolve) => {
105
+ const child = spawn(bin, args, { cwd, env, stdio: ["ignore", "pipe", "ignore"], detached: true });
106
+ let stdout = "";
107
+ let timedOut = false;
108
+ let cancelledBy;
109
+ const killGroup = () => {
110
+ if (child.pid === void 0) return;
111
+ try {
112
+ process.kill(-child.pid, "SIGKILL");
113
+ } catch (error) {
114
+ if (error.code !== "ESRCH") throw error;
115
+ }
116
+ };
117
+ const onSignal = (signal) => {
118
+ cancelledBy ??= signal;
119
+ killGroup();
120
+ };
121
+ for (const signal of PARENT_SIGNALS) process.on(signal, onSignal);
122
+ const timer = setTimeout(() => {
123
+ timedOut = true;
124
+ killGroup();
125
+ }, CONTROLLER_IMPORT_TIMEOUT_MS);
126
+ const settle = (run) => {
127
+ clearTimeout(timer);
128
+ for (const signal of PARENT_SIGNALS) process.off(signal, onSignal);
129
+ resolve({ ...run, ...cancelledBy ? { cancelledBy } : {} });
130
+ };
131
+ child.stdout.on("data", (chunk) => {
132
+ stdout += chunk.toString();
133
+ });
134
+ child.on("error", (error) => settle({ stdout, exitCode: null, timedOut: false, spawnError: error }));
135
+ child.on("close", (code) => settle({ stdout, exitCode: code, timedOut }));
136
+ });
137
+ }
9
138
  function coreControllerActionsCheck(target) {
10
139
  const block = coreDependencyBlock(target);
11
140
  if (block) {
@@ -31,5 +160,7 @@ function coreControllerActionsCheck(target) {
31
160
  };
32
161
  }
33
162
  export {
34
- coreReadiness
163
+ coreControllerImport,
164
+ coreReadiness,
165
+ coreRebuildAction
35
166
  };
@@ -1,20 +1,50 @@
1
- import { shellQuote } from "@farmslot/recipe-cli/harness";
2
1
  import path from "node:path";
3
- import { depsCheck } from "@farmslot/recipe-runner/runtime/deps-readiness";
2
+ import { checkoutWorkspacePackages, createNodeAdapter } from "@farmslot/adapter-node";
4
3
  import { defineAdapter } from "@farmslot/adapter-sdk";
4
+ import { shellQuote } from "@farmslot/recipe-cli/harness";
5
+ import { depsCheck } from "@farmslot/recipe-runner/runtime/deps-readiness";
6
+ import { coreDependencyBlock } from "../../commands/core-readiness.js";
7
+ import { checkoutHas, checkoutHasDir } from "../checkout-files.js";
5
8
  import { adapterRegistry } from "../registry.js";
6
9
  import { coreActions } from "./actions.js";
7
- import { coreHarness, corePrepare, coreRuntimeContext } from "./overlay.js";
8
- import { coreReadiness } from "./readiness.js";
10
+ import { coreHarness, corePrepare } from "./overlay.js";
11
+ import { coreControllerImport, coreReadiness, coreRebuildAction } from "./readiness.js";
9
12
  import { coreRun } from "./run.js";
10
- import { coreDependencyBlock } from "../../commands/core-readiness.js";
11
- import { checkoutHas, checkoutHasDir } from "../checkout-files.js";
12
13
  const coreSurface = defineAdapter({
13
- id: "core",
14
- sdkVersion: 1,
15
- headless: true,
16
- resolveSlotPorts() {
14
+ ...createNodeAdapter({
15
+ id: "core",
16
+ detect: {
17
+ files: (target) => checkoutHasDir(target, "packages/perps-controller") && checkoutHas(target, "yarn.lock")
18
+ },
19
+ hints: {
20
+ launch: "mm-harness run <recipe> # run recipes against the headless core",
21
+ relaunch: "mm-harness verify",
22
+ runtimeProbeRecovery: (target) => `mm-harness verify --adapter core --target ${shellQuote(path.resolve(target))}`
23
+ },
24
+ wording: {
25
+ devServerStop: {
26
+ message: "core is headless \u2014 no dev server runs for a core checkout",
27
+ userAction: "use mm-harness verify (readiness) or mm-harness run (execute a recipe)"
28
+ }
29
+ },
30
+ // Live scripts import the checkout's workspace packages from source: an
31
+ // unbuilt or stale checkout has no usable dist/ for them.
32
+ workspacePackages: checkoutWorkspacePackages,
33
+ actions: coreActions,
34
+ harness: coreHarness,
35
+ // coreRun's dependencyBlock (the Core gate) replaces adapter-node's check.
36
+ run: coreRun
37
+ }),
38
+ // Core's live adapter marks a module the checkout cannot resolve (a partial
39
+ // workspace build) on its own line: the environment, not app logic.
40
+ failurePatterns: {
41
+ environment: {
42
+ pattern: /\[core\/perps\] environment: cannot resolve /u,
43
+ message: "the Core checkout cannot resolve a module the action imports (a partial workspace build).",
44
+ userAction: "run yarn build in the Core checkout, then retry"
45
+ }
17
46
  },
47
+ // The Core gate decides readiness too, so doctor, verify, run and call agree.
18
48
  async runtimeStatus(target) {
19
49
  const deps = depsCheck(path.resolve(target));
20
50
  const block = deps.status === "current" ? coreDependencyBlock(target) : null;
@@ -27,6 +57,18 @@ const coreSurface = defineAdapter({
27
57
  deps: deps.status
28
58
  };
29
59
  }
60
+ if (deps.status === "current") {
61
+ const imported = await coreControllerImport(target);
62
+ if (!imported.ok) {
63
+ return {
64
+ decision: "blocked",
65
+ reasonCode: "CORE_CONTROLLER_IMPORT_FAILED",
66
+ reasons: [`Core is headless; controller actions cannot import the Perps controller: ${imported.reason}`],
67
+ nextAction: imported.userAction ?? coreRebuildAction(target),
68
+ deps: deps.status
69
+ };
70
+ }
71
+ }
30
72
  const ready = deps.status === "current";
31
73
  return {
32
74
  decision: ready ? "ready" : "install",
@@ -51,39 +93,9 @@ const coreSurface = defineAdapter({
51
93
  };
52
94
  }
53
95
  },
54
- devServer: {
55
- label: "dev-server",
56
- describe: () => "no dev server (headless)",
57
- stop() {
58
- return {
59
- kind: "headless",
60
- message: "core is headless \u2014 no dev server runs for a core checkout",
61
- userAction: "use mm-harness verify (readiness) or mm-harness run (execute a recipe)"
62
- };
63
- }
64
- },
65
- logSources() {
66
- return [];
67
- },
68
- appLogSource() {
69
- return null;
70
- },
71
- detect: {
72
- files: (target) => checkoutHasDir(target, "packages/perps-controller") && checkoutHas(target, "yarn.lock")
73
- },
74
- hints: {
75
- launch: "mm-harness run <recipe> # run recipes against the headless core",
76
- relaunch: "mm-harness verify",
77
- runtimeProbeRecovery: (target) => `mm-harness verify --adapter core --target ${shellQuote(path.resolve(target))}`
78
- },
79
- launch: async ({ usage }) => usage("core is headless; there is nothing to launch.", "mm-harness verify"),
80
- actions: coreActions,
81
- harness: coreHarness,
82
- runtimeContext: coreRuntimeContext,
83
96
  readiness: coreReadiness,
84
97
  fixtures: { operations: ["init", "sync", "set"] },
85
- prepare: corePrepare,
86
- run: coreRun
98
+ prepare: corePrepare
87
99
  });
88
100
  adapterRegistry.register(coreSurface);
89
101
  export {
@@ -77,5 +77,6 @@ async function resolveExtensionId(target, options = {}) {
77
77
  }
78
78
  export {
79
79
  extensionIdFromKey,
80
+ idFromManifest,
80
81
  resolveExtensionId
81
82
  };