@deeeed/metamask-harness 0.85.0 → 0.86.1

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 (33) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +5 -3
  3. package/adapters/extension/refresh-build.sh +1 -1
  4. package/adapters/extension/start-watch.sh +17 -8
  5. package/adapters/manifest.json +1 -1
  6. package/adapters/mobile/reload-app.mjs +2 -1
  7. package/dist/adapters/console-capture.js +3 -1
  8. package/dist/adapters/mobile/bridge-script.js +12 -0
  9. package/dist/adapters/mobile/feature-flags.js +2 -6
  10. package/dist/adapters/mobile/run.js +2 -2
  11. package/dist/adapters/mobile/ui.js +2 -1
  12. package/dist/command-contract.js +4 -1
  13. package/dist/commands/status-probe.js +10 -7
  14. package/dist/review/knowledge.js +6 -2
  15. package/docs/CONTRIBUTING.md +4 -1
  16. package/docs/QA.md +1 -0
  17. package/docs/RECIPES.md +4 -1
  18. package/docs/cli-contract.md +1 -0
  19. package/library/actions/core/perps/_controller.mjs +2 -2
  20. package/library/actions/extension/perps/close_visible_position.mjs +17 -2
  21. package/library/actions/extension/platform/cdp.mjs +46 -2
  22. package/library/actions/extension/ui/navigate.mjs +3 -1
  23. package/library/actions/extension/wallet/select_account.mjs +17 -17
  24. package/library/actions/extension/wallet/state.mjs +75 -21
  25. package/library/actions/mobile/platform/bridge.mjs +2 -0
  26. package/library/actions/mobile/platform/observe-ui.mjs +79 -8
  27. package/library/actions/mobile/wallet/select_account.mjs +34 -18
  28. package/library/manifests/core.action-manifest.json +6 -6
  29. package/library/manifests/extension.action-manifest.json +1 -1
  30. package/library/manifests/mobile.action-manifest.json +4 -4
  31. package/package.json +7 -7
  32. package/scripts/completions.sh +2 -2
  33. package/site/assets/help-recipes.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.86.1 - 2026-10-09
6
+
7
+ ### Fixed
8
+
9
+ - Extension `metamask.perps.close_visible_position cleanup_only=true` reconciles the final balance against the margin the close releases. With no placement receipt, the baseline is the available balance read while the position is still open. That baseline excludes the position's margin, so a clean full close went over the $1 tolerance and left an `outcome-unknown` receipt. A 0.11 SOL testnet close moved $524.42 to $536.45 and failed this way. The action now reads the close modal's "You'll receive" amount before dispatch and records it in the receipt as `closeReleaseBaseUnits`. It then checks the final balance against baseline + receive, with the same tolerance. If the amount is not visible, the action refuses before publishing a receipt or clicking. Closes backed by a placement receipt still reconcile against the pre-order balance. Cleanup output adds `releaseBaseUnits` and `expectedBalanceAfterBaseUnits`.
10
+ - Mobile `metamask.perps.read_visible_state` on iOS sees Perps Text test IDs. iOS accessibility lists React Native Text as a StaticText with no identifier, so a visible Lite market price (`perps-market-header-price`) failed `require_features: ["price"]`. On a non-opaque iOS run, `ui.visible` now reads each mounted `perps-` Text's test ID, string and size over the dev bridge. It gives that test ID to the on-screen StaticText with the same label and size, when exactly one test ID matches. Offscreen StaticText stays unbound, and so does a same-text twin of another size (the collapsed header price). Other Text, such as a revealed Secret Recovery Phrase word, is not read. This reaches every iOS `ui.visible` consumer: `ui.locators`, native-navigation test-ID waits, `readVisiblePerpsTestIds`, `configure_order`, and the assets and swap-bridge `read_visible_state`. Opaque runtimes and Android are unchanged. A non-opaque run with Metro down pays one bridge attempt (at most 2 s) per observation and gets a `ui.visible` warning; a read over 500 Texts is truncated with a warning.
11
+ - An empty `METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT` means the bundled `cdp-bridge.cjs` everywhere the TypeScript side spawns the Mobile bridge, as `run` and the action library already treated it. `launch`'s feature-flag reads and pins (and `doctor`'s) and the `status` live probe read the variable with `??`. An empty value made them check for a script named `""`, so they reported no runtime and never spawned a bridge. All TypeScript callers now share one `mobileBridgeScript()`; the action library keeps the same rule in its own `bridgeScript()` because it cannot import `src/`.
12
+
13
+ ## 0.86.0 - 2026-10-09
14
+
15
+ ### Added
16
+
17
+ - `MM_HARNESS_SIBLING_LIBRARIES=0` turns off sibling discovery: libraries and reference checkouts beside the checkout or under `~/shared-library` are no longer found.
18
+ - `call` takes `--hud show|hide` like `run`. With recipe-cli 0.11.1 a call draws the HUD by default, and its HUD text is its `intent=` input (`mm-harness call ui.screenshot intent="Show the order filled"`).
19
+ - `MOBILE_RELOAD_VERIFY_TIMEOUT_MS` sets how long `adapters/mobile/reload-app.mjs` waits to verify a reload (default 30 000), and `MM_HARNESS_CONSOLE_ATTACH_TIMEOUT_MS` how long `launch` waits for the Extension console collector to attach (default about 10 s). A value that is not a positive number uses the default. Contract tests use them where no app or extension target can answer.
20
+
21
+ ### Changed
22
+
23
+ - Depend on `@farmslot/recipe-runner` 0.28.1, `@farmslot/recipe-cli` 0.11.1, `@farmslot/adapter-sdk` 0.8.1, `@farmslot/adapter-web` 0.6.5, `@farmslot/adapter-node` 0.1.7 and `@farmslot/adapter-rn` 0.19.4 (were 0.28.0, 0.11.0, 0.8.0, 0.6.4, 0.1.6 and 0.19.3), pinned together; the install holds one `@farmslot/recipe-runner` and one `@farmslot/adapter-sdk`. Farmslot #869 and #870:
24
+ - `run` and `call` draw the HUD for every recipe source, an untrusted or inherited one (a task's `recipe-source.json`) included, unless `--hud hide`. The HUD is part of the plan an approval covers, so an untrusted-source approval made before this release without `--hud hide` (on an adapter that draws the HUD), or with another `--hud` setting, must be approved again; the refusal prints the new digest.
25
+ - The HUD on Extension pages is drawn in a closed shadow root with its text capped at 180 characters, so a node's own intent can no longer satisfy its `ui.wait_for text=` check or catch a text-target press.
26
+ - `call app.hud` drives the HUD itself, with no automatic updates.
27
+ - A Mobile `run` arms and collects the app's issue buffer through `METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT` when it is set, as launch, status and the action library already do (it always used the bundled `cdp-bridge.cjs`). An empty value means the bundled bridge, as in the action library.
28
+ - Extension `start-watch.sh` checks for the webpack compile marker every 0.5 s (was 2 s) and still refreshes the watcher pid every 2 s; its 480 s limit is now wall-clock time. It waits for stale watchers to stop instead of a fixed 2 s.
29
+
30
+ ### Fixed
31
+
32
+ - Mobile `status` and `doctor` pair a physical Android device whose bridge entry carries a manufacturer prefix. A Pixel 6a over USB lists as `model:Pixel_6a` in adb while its bridge entry says `Google Pixel 6a`; it read `liveState: no-bridge`, so `doctor --print-ready` printed `not-ready` and Farmslot slot.check health failed on an unlocked wallet. The prefixed name pairs only the sole physical device (not `emulator-*`) of that model, so a same-model emulator cannot take the phone's entry; bare-model matching is unchanged.
33
+ - Extension `ui.navigate hash=` and `url=` accept a route the Extension router rewrites in the same document. Extension 13.52 canonicalizes Perps symbols (`#/perps/trade/sol` becomes `#/perps/trade/SOL`, `XYZ%3Atsla` becomes `xyz%3ATSLA`, query kept), and readiness waited for the requested URL until it timed out. Readiness for a requested URL now takes the rewritten URL once it holds for two polls, if only the hash changed on the same extension page (same origin, path and query). Another origin, extension page or query, a `chrome-error://` page or a document that never becomes ready still fails; the error names the last URL (`Extension page did not reach <url> within <n>ms (last URL <url>)`). A lock screen that redirects to `#/unlock` is reported, not failed, so assert the route on `href`. `hash=` returns `href` (where the page landed), `requestedHref` and `redirected`; `url=` returns `href`.
34
+ - `metamask.wallet.select_account name=` on Extension and Mobile also matches account group names. Multichain wallets (Extension 13.52) keep fixture names such as `dev1` on the account group while the internal account stays `Account 1`, so `name=dev1` failed with `Requested account was not found in internalAccounts`. A name with no internal account match now selects the EVM account of the account group with that name; a name two account groups share, or a group with no EVM account, fails without selecting anything. Extension, like Mobile, returns `alreadySelected: true` without a write when the requested account is already selected.
35
+
36
+ - Docs, `help` and the Mobile, Extension and Core Perps action descriptions no longer say mainnet is read-only; they state the real mainnet rule (the shared Perps order rule, Core's `CORE_PERPS_ALLOW_MAINNET_WRITES=1`, Mobile cleanup acting on the app's current network, Extension cancel refused on mainnet outside the funded release lane). Core close, ensure and teardown descriptions no longer claim testnet only.
37
+ - Contributors: the feature-guard scripts (`differential.mjs`, `release-smoke.mjs`, `builds.mjs`) reach a remote host with `-o ConnectTimeout=10` on every `ssh` and `scp`, and `BatchMode=yes` on `scp` as on `ssh`. A host name whose first address does not answer (`ssh macpro.local` from macwork: `connect to host macpro.local port 22: Undefined error: 0`) failed or hung without it; with it, ssh moves on to the next address.
38
+ - Extension `refresh-build.sh` writes its build log under `$TMPDIR` as `refresh-build.XXXXXX`, without the `.log` suffix (macOS `mktemp` takes no suffix after the X's); macOS `mktemp -t` ignored `TMPDIR`, so each contract run left two logs in the machine's temp dir.
39
+ - Contributors: a unit run gets one TMPDIR that every worker inherits. When the run ends, interrupted (Ctrl-C, SIGTERM) or not, the processes still naming it are stopped and it is removed; an interrupted run used to leave its per-file temp dirs, vitest's transform dir and detached test browsers behind. Every unit run also left a CDP broker socket in `/tmp` (the console-forwarder test killed the forwarder before it could release it), and the mutation-receipt test now writes under TMPDIR, not the checkout. Two contract tests whose bare `mktemp -d` ignored the runner's TMPDIR on macOS now create under it.
40
+ - Contributors: contract and unit tests no longer see the operator's recipe libraries. `ct_init` and the unit setup point `MM_HARNESS_CONFIG` at a missing file, set `MM_HARNESS_SIBLING_LIBRARIES=0`, point `FARMSLOT_HOME` into the sandbox (the personal library) and clear `RECIPE_LIBRARY_PATH` and any exported `MM_HARNESS_REF_<ADAPTER>`. Port claims go to a sandbox `MM_HARNESS_PORT_REGISTRY`, not `~/.cache/mm-harness/ports`. From a checkout with perps and terminal configured, or checked out beside it, `run-name-resolution` and `extension-perps-g005` failed (`mm-harness-wave2` failed with the config). A test that covers one of these sources sets it up itself.
41
+
5
42
  ## 0.85.0 - 2026-10-09
6
43
 
7
44
  ### Added
@@ -34,6 +71,8 @@
34
71
 
35
72
  ### Fixed
36
73
 
74
+ - A command no longer runs `git rev-parse` on every library it finds before dispatch. Library path hydration ran on every command, `--version` included, and pinned each discovered library (a sibling checkout, a config entry, the fetch cache) to its revision, which it never reads. The revision is now looked up only by what reports it (`status`, `domain`, `review`), as before. In a workspace with recipe-library checkouts beside the product checkout, that was one git process per library on every start.
75
+ - `tests/contract/run.sh`, and `yarn test:goldens` through it, refuse to start (exit 2, `Next: npm run build`) when `dist/` is missing or older than any `src/**/*.ts`. `bin/mm-harness` then runs the TypeScript source on every start, type-stripped each time (about 260 ms more per start, over a thousand starts per run). CI and the full local suite build first, so only a bare `run.sh` or `yarn test:goldens` was affected.
37
76
  - Extension reopen (`adapters/extension/ensure-browser.sh`) no longer builds its `node -e` script or its `python3` profile cleanup from the slot id, checkout, runtime, profile, extension and runner paths, CDP port, webpack pid or wallet fixture path. The scripts are fixed text and get those values as argv. Before, a checkout path with an apostrophe (`/tmp/Arthur's checkout`) broke the reopen script's syntax and silently skipped the profile cleanup, and a crafted value could change the code the reopen ran.
38
77
 
39
78
  ## 0.84.0 - 2026-10-08
package/README.md CHANGED
@@ -419,9 +419,11 @@ A team library supplies `review/antipatterns.md` (one `##` section per family),
419
419
  optional `review/antipatterns.<adapter>.md` families appended for that adapter only,
420
420
  `review/parity.md`, and `owned-paths.json`; the harness composes them and never
421
421
  copies them. Locations resolve env (`RECIPE_LIBRARY_PATH`, `MM_HARNESS_REF_<ADAPTER>`)
422
- → `mm-harness config` → sibling directory beside the checkout → shallow fetch into
423
- `<checkout>/.skills-cache/<name>`. A missing reference checkout degrades the parity
424
- step to "not checked" with the reason.
422
+ → `mm-harness config` → sibling directory beside the checkout or under
423
+ `~/shared-library` → shallow fetch into `<checkout>/.skills-cache/<name>`.
424
+ `MM_HARNESS_SIBLING_LIBRARIES=0` turns the sibling step off, for libraries and
425
+ reference checkouts alike (the test sandboxes set it). A missing reference
426
+ checkout degrades the parity step to "not checked" with the reason.
425
427
 
426
428
  ## Recover
427
429
 
@@ -103,7 +103,7 @@ fi
103
103
 
104
104
  echo "[refresh] Using node $(node -v 2>/dev/null || echo 'not found')"
105
105
  echo "[refresh] Starting yarn start (watcher-port=${WATCHER_PORT:-default})..."
106
- LOG="$(mktemp -t refresh-build-XXXXXX.log)"
106
+ LOG="$(mktemp "${TMPDIR:-/tmp}/refresh-build.XXXXXX")"
107
107
  echo "[refresh] Log: $LOG"
108
108
  env ${PORT_ARG} yarn start > "$LOG" 2>&1 &
109
109
  BUILD_PID=$!
@@ -19,7 +19,7 @@
19
19
  # <runtime-dir>/recipe-harness-webpack.{pid,log}; optional --summary file
20
20
  # {feature,status,inputs,outputs,generatedAt}.
21
21
  # Exit 0 — compiled; 1 — slot watcher owns the checkout, build failed, or
22
- # compile-marker timeout (240x2s); 2 — bad args.
22
+ # compile-marker timeout (480 s); 2 — bad args.
23
23
  #
24
24
  # Never touches: the slot-owned webpack.pid watcher process (refusal only,
25
25
  # removes the pid file only when stale); product source files.
@@ -229,8 +229,11 @@ existing_watch_pids="$(watcher_pids || true)"
229
229
  if [ -n "$existing_watch_pids" ]; then
230
230
  echo "[recipe-harness] Stopping stale target-scoped watcher(s): $(printf '%s' "$existing_watch_pids" | tr '\n' ' ')"
231
231
  printf '%s\n' "$existing_watch_pids" | xargs kill 2>/dev/null || true
232
- sleep 2
233
- existing_watch_pids="$(watcher_pids || true)"
232
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
233
+ existing_watch_pids="$(watcher_pids || true)"
234
+ [ -z "$existing_watch_pids" ] && break
235
+ sleep 0.2
236
+ done
234
237
  if [ -n "$existing_watch_pids" ]; then
235
238
  printf '%s\n' "$existing_watch_pids" | xargs kill -9 2>/dev/null || true
236
239
  fi
@@ -290,7 +293,13 @@ elif [ -n "$LIB_DIR" ] && [ "${RECIPE_LOG_TUI_PARENT:-}" != "1" ]; then
290
293
  watch_tail_pid=$!
291
294
  fi
292
295
  compiled=false
293
- for i in {1..240}; do
296
+ # The log is checked every 0.5 s, so a marker is seen at once. The watcher pid
297
+ # needs a full process listing, so it is refreshed every 2 s. Bounded by time:
298
+ # 480 s for a compile marker, 20 s for yarn start's watcher to appear.
299
+ poll_started=$SECONDS
300
+ i=0
301
+ while [ $((SECONDS - poll_started)) -lt 480 ]; do
302
+ i=$((i + 1))
294
303
  if [ -n "$watch_tail_pid" ] && ! kill -0 "$watch_tail_pid" >/dev/null 2>&1; then
295
304
  wait "$watch_tail_pid" 2>/dev/null || true
296
305
  if grep -Eq 'compiled successfully|compiled with [0-9]+ warning|MetaMask .* compiled|Bundle end: service worker|Bundle end:.*app-init' "$watch_log" 2>/dev/null; then
@@ -300,9 +309,9 @@ for i in {1..240}; do
300
309
  echo '[recipe-harness] compact build watcher exited before compile marker; continuing to poll webpack log' >&2
301
310
  watch_tail_pid=""
302
311
  fi
303
- if ! write_canonical_watch_pid; then
304
- if [ "$i" -le 10 ]; then
305
- sleep 2
312
+ if [ $((i % 4)) -eq 1 ] && ! write_canonical_watch_pid; then
313
+ if [ $((SECONDS - poll_started)) -lt 20 ]; then
314
+ sleep 0.5
306
315
  continue
307
316
  fi
308
317
  [ -n "$watch_tail_pid" ] && kill "$watch_tail_pid" >/dev/null 2>&1 || true
@@ -331,7 +340,7 @@ for i in {1..240}; do
331
340
  [ -n "$watch_tail_pid" ] && wait "$watch_tail_pid" 2>/dev/null || true
332
341
  break
333
342
  fi
334
- sleep 2
343
+ sleep 0.5
335
344
  done
336
345
  if [ "$compiled" != true ]; then
337
346
  [ -n "$watch_tail_pid" ] && kill "$watch_tail_pid" >/dev/null 2>&1 || true
@@ -47,7 +47,7 @@
47
47
  "entry": "adapters/mobile/reload-app.mjs",
48
48
  "kind": "node",
49
49
  "purpose": "Request an in-place React Native app reload through Metro's protocol-v2 message endpoint.",
50
- "inputs": "--port --json; env WATCHER_PORT, METRO_PORT",
50
+ "inputs": "--port --json; env WATCHER_PORT, METRO_PORT, MOBILE_RELOAD_VERIFY_TIMEOUT_MS (default 30000)",
51
51
  "outputs": "reload result on stdout; exit 0 requested / 1 unavailable or invalid input"
52
52
  },
53
53
  {
@@ -146,7 +146,8 @@ async function reload(port) {
146
146
  }
147
147
 
148
148
  async function waitForReload(port, targetBefore, processBefore, observer) {
149
- const deadline = Date.now() + 30_000;
149
+ const timeoutMs = Number(process.env.MOBILE_RELOAD_VERIFY_TIMEOUT_MS);
150
+ const deadline = Date.now() + (timeoutMs > 0 ? timeoutMs : 30_000);
150
151
  let lastError;
151
152
  while (Date.now() < deadline) {
152
153
  if (processBefore && readAndroidPid() !== processBefore) {
@@ -58,7 +58,9 @@ async function ensureConsoleCollector(options) {
58
58
  fs.writeFileSync(pidFile, `${child.pid}
59
59
  `);
60
60
  const attachmentFrom = { [extensionLog]: fileSize(extensionLog), [pageLog]: fileSize(pageLog) };
61
- for (let attempt = 0; attempt < 100; attempt += 1) {
61
+ const timeoutMs = Number(process.env.MM_HARNESS_CONSOLE_ATTACH_TIMEOUT_MS);
62
+ const attempts = Math.ceil((timeoutMs > 0 ? timeoutMs : 1e4) / 100);
63
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
62
64
  const proc = processArgs(child.pid);
63
65
  if (!proc) break;
64
66
  if (collectorMatches(proc, identity)) {
@@ -0,0 +1,12 @@
1
+ import path from "node:path";
2
+ import { runnerDir } from "../../paths.js";
3
+ function mobileBridgeScriptOverride() {
4
+ return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || void 0;
5
+ }
6
+ function mobileBridgeScript() {
7
+ return mobileBridgeScriptOverride() ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
8
+ }
9
+ export {
10
+ mobileBridgeScript,
11
+ mobileBridgeScriptOverride
12
+ };
@@ -1,11 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
- import path from "node:path";
4
- import { runnerDir } from "../../paths.js";
3
+ import { mobileBridgeScript } from "./bridge-script.js";
5
4
  const DEFAULT_TIMEOUT_MS = 1e4;
6
- function bridgeScriptPath() {
7
- return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
8
- }
9
5
  const CONTROLLER_PREAMBLE = `
10
6
  var ctx = globalThis.Engine && globalThis.Engine.context;
11
7
  var c = ctx && ctx.RemoteFeatureFlagController;
@@ -19,7 +15,7 @@ function evalExpression(body) {
19
15
  return `(function(){ try {${CONTROLLER_PREAMBLE}${body} } catch (e) { return JSON.stringify({ ok: false, error: e && (e.message || String(e)) }); } })()`;
20
16
  }
21
17
  function runBridgeEval(target, expression, timeoutMs) {
22
- const script = bridgeScriptPath();
18
+ const script = mobileBridgeScript();
23
19
  if (!fs.existsSync(script)) return Promise.resolve(null);
24
20
  return new Promise((resolve) => {
25
21
  let settled = false;
@@ -2,7 +2,6 @@ import { EXIT, isRecord } from "@farmslot/recipe-cli/harness";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import { IOS_SIMULATOR_RECORDING_TARGET_PREFIX } from "@farmslot/adapter-rn";
5
- import { runnerDir } from "../../paths.js";
6
5
  import { devicePlatformFromEnv } from "../performance/device-platform.js";
7
6
  import {
8
7
  resolveAndroidDeviceSerial,
@@ -10,6 +9,7 @@ import {
10
9
  } from "./recording-target.js";
11
10
  import { emitProofFailure } from "../proof-failure.js";
12
11
  import { mobileAppIdentityEnv } from "./app-identity.js";
12
+ import { mobileBridgeScript } from "./bridge-script.js";
13
13
  import { requireCurrentMobileReleaseArtifactState } from "./release-artifact-state.js";
14
14
  const EXPLICIT_DEVICE_KEYS = [
15
15
  "PLATFORM",
@@ -158,7 +158,7 @@ const mobileObservation = {
158
158
  }
159
159
  };
160
160
  function runMobileIssueCommand(projectRoot, command) {
161
- const bridge = path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
161
+ const bridge = mobileBridgeScript();
162
162
  const result = spawnSync(process.execPath, [bridge, command], {
163
163
  cwd: projectRoot,
164
164
  env: { ...process.env, APP_ROOT: projectRoot, CDP_TIMEOUT: "5000" },
@@ -10,6 +10,7 @@ import { trustedMutationContextExtras } from "../../funding-execution-context.js
10
10
  import { LIBRARY_ACTION_NAMESPACE } from "../../paths.js";
11
11
  import { liveTsxCandidates } from "../../live-tsx.js";
12
12
  import { mobileAppIdentityEnv } from "./app-identity.js";
13
+ import { mobileBridgeScriptOverride } from "./bridge-script.js";
13
14
  const execFileAsync = promisify(execFile);
14
15
  function sleep(ms) {
15
16
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -58,7 +59,7 @@ function mobileProbeOutput(status, input, projectRoot) {
58
59
  };
59
60
  }
60
61
  function mobileBridgePath(_projectRoot) {
61
- return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || "runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs";
62
+ return mobileBridgeScriptOverride() ?? "runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs";
62
63
  }
63
64
  function resolveMobileWaitTarget(payload) {
64
65
  const testId = optionalScalarText(
@@ -721,12 +721,14 @@ Example:
721
721
  --action-manifest <path> Override the action manifest
722
722
  --library <name=path> Add/override a recipe-library source (repeatable)
723
723
  --heal <off|infra-only|auto> Healing policy (default: infra-only); auto-ensures the overlay
724
+ --hud <show|hide> Recipe HUD: the call's intent= drawn over the app, headless included (default: show)
724
725
  --json Machine-readable output
725
726
 
726
727
  Example (real actions; run mm-harness actions for this checkout's full set):
727
728
  mm-harness call ensure_unlocked --adapter extension # a wallet action (extension/mobile)
728
729
  mm-harness call navigate page=perps --adapter mobile
729
730
  mm-harness call command cmd="echo hi" --adapter core # the universal action (all adapters)
731
+ mm-harness call ui.screenshot intent="Show the order filled" # intent= is the HUD text
730
732
 
731
733
  Remote feature flags (mobile applies at runtime; extension pins at launch):
732
734
  mm-harness call metamask.feature_flags.read --adapter mobile
@@ -738,7 +740,8 @@ Remote feature flags (mobile applies at runtime; extension pins at launch):
738
740
  "--list": bool(),
739
741
  "--domain": value(),
740
742
  "--source": value(),
741
- "--sort": value(["domain", "name", "library"])
743
+ "--sort": value(["domain", "name", "library"]),
744
+ "--hud": value(["show", "hide"])
742
745
  }),
743
746
  positionals: [{ label: "action" }],
744
747
  minimumPositionals: 0,
@@ -1,13 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
- import path from "node:path";
4
3
  import { fixtureSummary } from "../doctor.js";
5
4
  import { normalizeFeatureFlagRecord } from "../feature-flags.js";
6
- import { runnerDir } from "../paths.js";
5
+ import { mobileBridgeScript } from "../adapters/mobile/bridge-script.js";
7
6
  const PROBE_TIMEOUT_MS = 2e3;
8
- function bridgeScriptPath() {
9
- return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
10
- }
11
7
  function truncateAddress(address) {
12
8
  if (!address || address.length <= 10) return address;
13
9
  return `${address.slice(0, 6)}\u2026${address.slice(-4)}`;
@@ -52,6 +48,13 @@ function parseBridgeEntries(raw) {
52
48
  ...e.featureFlags !== void 0 ? { featureFlags: e.featureFlags } : {}
53
49
  }));
54
50
  }
51
+ function androidModelMatches(deviceName, model, device, devices) {
52
+ if (deviceName === model || deviceName.startsWith(`${model} -`)) return true;
53
+ if (!deviceName.endsWith(` ${model}`) && !deviceName.includes(` ${model} -`)) return false;
54
+ const physical = (d) => d.platform === "android" && !d.id.startsWith("emulator-");
55
+ if (!physical(device)) return false;
56
+ return devices.filter((d) => physical(d) && d.name === device.name).length === 1;
57
+ }
55
58
  function matchBridgeEntry(device, entries, devices) {
56
59
  const byId = entries.find((e) => e.deviceName === device.id);
57
60
  if (byId) return byId;
@@ -60,7 +63,7 @@ function matchBridgeEntry(device, entries, devices) {
60
63
  if (byName) return byName;
61
64
  if (device.platform === "android") {
62
65
  const model = device.name.replace(/_/gu, " ");
63
- const byModel = entries.find((e) => e.deviceName === model || e.deviceName.startsWith(`${model} -`));
66
+ const byModel = entries.find((e) => androidModelMatches(e.deviceName, model, device, devices));
64
67
  if (byModel) return byModel;
65
68
  }
66
69
  }
@@ -70,7 +73,7 @@ function matchBridgeEntry(device, entries, devices) {
70
73
  return null;
71
74
  }
72
75
  function probeMobileBridge(target) {
73
- const script = bridgeScriptPath();
76
+ const script = mobileBridgeScript();
74
77
  if (!fs.existsSync(script)) {
75
78
  return Promise.resolve({ ok: false, liveState: "no-bridge" });
76
79
  }
@@ -95,6 +95,7 @@ function withRevision(location) {
95
95
  return revision ? { ...location, revision } : location;
96
96
  }
97
97
  function siblingRoots(context) {
98
+ if ((context.env ?? process.env).MM_HARNESS_SIBLING_LIBRARIES === "0") return [];
98
99
  const home = context.homeDir ?? os.homedir();
99
100
  const parents = [path.dirname(path.resolve(context.target)), path.join(home, "shared-library")];
100
101
  const roots = [];
@@ -173,6 +174,9 @@ function resolveLibrary(name, options) {
173
174
  return void 0;
174
175
  }
175
176
  function discoverLibraries(context) {
177
+ return discoverLibraryLocations(context).map(withRevision);
178
+ }
179
+ function discoverLibraryLocations(context) {
176
180
  const env = context.env ?? process.env;
177
181
  const seenRoots = /* @__PURE__ */ new Set();
178
182
  const seenNames = /* @__PURE__ */ new Set();
@@ -184,7 +188,7 @@ function discoverLibraries(context) {
184
188
  if (seenNames.has(libraryName)) return;
185
189
  seenRoots.add(resolved);
186
190
  seenNames.add(libraryName);
187
- found.push(withRevision({ name: libraryName, root: resolved, source }));
191
+ found.push({ name: libraryName, root: resolved, source });
188
192
  };
189
193
  for (const entry of parseLibraryPathEnv(declaredLibraryPathFor(env))) add(entry.name, entry.root, "env");
190
194
  for (const [name, root] of Object.entries(context.config?.libraries ?? {})) add(name, root, "config");
@@ -215,7 +219,7 @@ async function hydrateLibraryPathEnv(context) {
215
219
  (entry) => entry.name ?? loadOwnedPaths(entry.root)?.domain ?? path.basename(entry.root)
216
220
  )
217
221
  );
218
- const discovered = discoverLibraries(context).filter(
222
+ const discovered = discoverLibraryLocations(context).filter(
219
223
  (library) => library.source !== "env" && !declared.has(library.name)
220
224
  );
221
225
  if (discovered.length === 0) return;
@@ -176,10 +176,13 @@ or wrapper, verify real callers and retain compatibility only when one exists.
176
176
  yarn test:unit
177
177
  yarn test:coverage
178
178
  yarn check
179
+ npm run build
179
180
  bash tests/contract/run.sh
180
181
  ```
181
182
 
182
- `run.sh` runs half the cores' worth of tests at once (`-j N` to change it), each
183
+ `run.sh` refuses to start when `dist/` is missing or older than any `src/**/*.ts`: the
184
+ CLI would then run the TypeScript source on every start. It runs half the
185
+ cores' worth of tests at once (`-j N` to change it), each
183
186
  in its own process group and `TMPDIR`, and prints each test's duration and the
184
187
  15 slowest. A test gets 60 s (`CONTRACT_TEST_TIMEOUT`); one whose product waits
185
188
  are longer declares `# contract: timeout=<seconds>`, and one that cannot share
package/docs/QA.md CHANGED
@@ -183,6 +183,7 @@ mm-harness debug
183
183
 
184
184
  ```bash
185
185
  yarn check
186
+ npm run build
186
187
  bash tests/contract/run.sh
187
188
  ```
188
189
 
package/docs/RECIPES.md CHANGED
@@ -329,7 +329,10 @@ block, skip, or refuse; a fixture that demands an empty account must converge
329
329
  rather than refuse. Extension `ensure_*` and `teardown_state` only assert, so
330
330
  converge there through an explicitly funded leaf action. Never broaden cleanup
331
331
  onto an account or market the task does not own; obtain an owned one instead
332
- of blocking. Mainnet is read-only.
332
+ of blocking. On mainnet, orders follow the Perps order rule above, Core needs
333
+ `CORE_PERPS_ALLOW_MAINNET_WRITES=1` for any write, Mobile cleanup acts on
334
+ whatever network the app is on, and Extension refuses mainnet writes other than
335
+ orders and `close_visible_position` in the fixture-policy lane.
333
336
 
334
337
  ### Mobile advanced-order setup
335
338
 
@@ -101,6 +101,7 @@ Outside this boundary: a successful `launch` and the `temp/recipe/runtime/termin
101
101
  ## Running the goldens
102
102
 
103
103
  ```bash
104
+ npm run build # first: the families run the CLI from dist/, and refuse a stale one
104
105
  yarn test:goldens # compare all families in parallel, within 2 minutes (CI runs them in the contract shards)
105
106
  yarn test:goldens mobile # families whose name contains "mobile"
106
107
  yarn test:goldens --update # rewrite goldens, print a per-field diff summary, drop orphans
@@ -229,8 +229,8 @@ let cached = null;
229
229
  /**
230
230
  * Resolve the requested network. Default testnet; mainnet only when a node
231
231
  * explicitly sets network: "mainnet". Mainnet reads are safe; mainnet mutations
232
- * use REAL funds and must be explicitly requested — mirrors the extension/mobile
233
- * "mainnet is read-only unless explicitly requested" contract.
232
+ * use REAL funds and must be explicitly requested (see the Perps order rule in
233
+ * docs/RECIPES.md).
234
234
  */
235
235
  export function resolveNetwork(input) {
236
236
  const raw = String(input.node?.network ?? 'testnet').toLowerCase();
@@ -208,10 +208,11 @@ async function prepareCloseModal(page, partialAmount) {
208
208
  return confirmation;
209
209
  }
210
210
 
211
- async function proveGlobalCleanup(page, balanceBeforeBaseUnits, toleranceBaseUnits, timeoutMs = 20_000) {
211
+ async function proveGlobalCleanup(page, balanceBeforeBaseUnits, releaseBaseUnits, toleranceBaseUnits, timeoutMs = 20_000) {
212
212
  await page.navigateHash('#/perps-home');
213
213
  const state = await waitForStableGlobalPerpsHome(page, { timeoutMs, requireZero: true });
214
- const balanceDeltaBaseUnits = absoluteDelta(state.balanceBaseUnits, balanceBeforeBaseUnits);
214
+ const expectedBalanceAfterBaseUnits = `${BigInt(balanceBeforeBaseUnits) + BigInt(releaseBaseUnits ?? '0')}`;
215
+ const balanceDeltaBaseUnits = absoluteDelta(state.balanceBaseUnits, expectedBalanceAfterBaseUnits);
215
216
  if (BigInt(balanceDeltaBaseUnits) > BigInt(toleranceBaseUnits)) {
216
217
  throw new Error('Final visible Perps balance exceeds the authorized reconciliation tolerance.');
217
218
  }
@@ -219,6 +220,8 @@ async function proveGlobalCleanup(page, balanceBeforeBaseUnits, toleranceBaseUni
219
220
  positionCount: 0,
220
221
  orderCount: 0,
221
222
  balanceBeforeBaseUnits,
223
+ releaseBaseUnits: releaseBaseUnits ?? null,
224
+ expectedBalanceAfterBaseUnits,
222
225
  balanceAfterBaseUnits: state.balanceBaseUnits,
223
226
  balanceDeltaBaseUnits,
224
227
  toleranceBaseUnits,
@@ -327,9 +330,14 @@ export async function closeVisiblePosition(input) {
327
330
  if (!/^(?:0|[1-9][0-9]{0,29})$/u.test(balanceBeforeBaseUnits)) {
328
331
  throw new Error('Resumed close receipt has no valid pre-order balance.');
329
332
  }
333
+ const releaseBaseUnits = existingReceipt.record?.facts?.closeReleaseBaseUnits ?? null;
334
+ if (releaseBaseUnits != null && !/^-?(?:0|[1-9][0-9]{0,29})$/u.test(String(releaseBaseUnits))) {
335
+ throw new Error('Resumed close receipt has an invalid close release amount.');
336
+ }
330
337
  const cleanup = await proveGlobalCleanup(
331
338
  page,
332
339
  balanceBeforeBaseUnits,
340
+ releaseBaseUnits,
333
341
  balanceToleranceBaseUnits,
334
342
  );
335
343
  return {
@@ -413,6 +421,11 @@ export async function closeVisiblePosition(input) {
413
421
  if (partialAmount == null && (!confirmation.fullSizeSelected || !confirmation.marketOrder)) {
414
422
  throw new Error('Receipt-bound full close requires the visible 100% market-close form.');
415
423
  }
424
+ // Without a placement receipt the baseline is the in-position balance, which excludes the released margin.
425
+ const closeReleaseBaseUnits = placement ? null : parseUsdBaseUnits(confirmation.receive);
426
+ if (!placement && closeReleaseBaseUnits == null) {
427
+ throw new Error('Close without a placement receipt requires the visible close receive amount.');
428
+ }
416
429
  const closeSizing = partialAmount == null ? { size: sizeMatch[1], side: fundingSide === 'long' ? 'short' : 'long' } : null;
417
430
  let submissionObserver = null;
418
431
  let mutation;
@@ -427,6 +440,7 @@ export async function closeVisiblePosition(input) {
427
440
  cleanupOnly,
428
441
  fundingBalance,
429
442
  tradingContext,
443
+ ...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits } : {}),
430
444
  ...(acceptedFill ? { acceptedFill } : {}),
431
445
  ...(closeSizing ? { closeSizing } : {}),
432
446
  },
@@ -507,6 +521,7 @@ export async function closeVisiblePosition(input) {
507
521
  const cleanup = await proveGlobalCleanup(
508
522
  page,
509
523
  balanceBeforeBaseUnits,
524
+ closeReleaseBaseUnits,
510
525
  balanceToleranceBaseUnits,
511
526
  );
512
527
  return {
@@ -981,8 +981,52 @@ export class ExtensionPage extends CdpWebPage {
981
981
  location.hash = ${JSON.stringify(normalizedHash)};
982
982
  return { before, href: location.href };
983
983
  })()`);
984
- await this.waitForDocumentReady({ expectedUrl: navigation.href, timeoutMs });
985
- return { ...navigation, sameDocument: true };
984
+ const ready = await this.waitForDocumentReady({ expectedUrl: navigation.href, timeoutMs });
985
+ return {
986
+ before: navigation.before,
987
+ requestedHref: navigation.href,
988
+ href: ready.url,
989
+ redirected: ready.redirected,
990
+ sameDocument: true,
991
+ };
992
+ }
993
+
994
+ // The Extension router may replace a requested hash with its canonical route
995
+ // in the same document (e.g. Perps symbol casing). The runtime still decides
996
+ // readiness; this accepts the rewritten URL once it holds for two polls. Any
997
+ // other origin, page or query still fails.
998
+ async waitForDocumentReady({ expectedUrl, timeoutMs = 10000 } = {}) {
999
+ const deadline = Date.now() + timeoutMs;
1000
+ let url;
1001
+ let redirectCandidate;
1002
+ while (true) {
1003
+ await super.waitForDocumentReady({ timeoutMs: Math.max(1, deadline - Date.now()) });
1004
+ // A context lost between the two probes counts as an unmatched poll.
1005
+ url = await this.evaluate('location.href').catch(() => undefined);
1006
+ if (!expectedUrl || url === expectedUrl) return { url, redirected: false };
1007
+ const redirect = this.isSameDocumentRedirect(expectedUrl, url) ? url : undefined;
1008
+ if (redirect && redirect === redirectCandidate) return { url, redirected: true };
1009
+ redirectCandidate = redirect;
1010
+ const remainingMs = deadline - Date.now();
1011
+ if (remainingMs <= 0) break;
1012
+ await sleep(Math.min(50, remainingMs));
1013
+ }
1014
+ throw new Error(`Extension page did not reach ${expectedUrl} within ${timeoutMs}ms (last URL ${url ?? 'unavailable'})`);
1015
+ }
1016
+
1017
+ isSameDocumentRedirect(expectedUrl, actualUrl) {
1018
+ try {
1019
+ const expected = new URL(expectedUrl);
1020
+ const actual = new URL(actualUrl);
1021
+ return `${expected.protocol}//${expected.host}` === this.origin
1022
+ && actual.protocol === expected.protocol
1023
+ && actual.host === expected.host
1024
+ && actual.pathname === expected.pathname
1025
+ && actual.search === expected.search
1026
+ && actual.hash !== expected.hash;
1027
+ } catch {
1028
+ return false;
1029
+ }
986
1030
  }
987
1031
 
988
1032
  async readPositions() {
@@ -591,7 +591,9 @@ export async function navigateExtensionUi(input) {
591
591
  const url = input.node?.url;
592
592
  if (typeof url === 'string' && url.length > 0) {
593
593
  const navigation = await page.navigate(url);
594
- return { action: input.action, url, navigation, proofPath: 'ui-navigation' };
594
+ // The router may canonicalize the requested route; report where it landed.
595
+ const href = await currentHref(page);
596
+ return { action: input.action, url, href, navigation, proofPath: 'ui-navigation' };
595
597
  }
596
598
 
597
599
  const hash = input.node?.hash ?? input.node?.path;
@@ -1,9 +1,10 @@
1
- // Selects an account by address, ID, or name through the background request hook.
2
- // Falls back to visible account search when that hook is unavailable and checks the selected address.
1
+ // Selects an account by address, ID, or account group/internal name through the background request hook.
2
+ // Returns without a write when the account is already selected. Falls back to visible account search
3
+ // when that hook is unavailable and checks the selected address.
3
4
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
4
5
  import {
5
- listWalletAccountsExpression,
6
6
  readWalletStateExpression,
7
+ resolveWalletAccountExpression,
7
8
  selectWalletAccountExpression,
8
9
  } from './state.mjs';
9
10
 
@@ -18,25 +19,24 @@ runAdapter((input) => withExtensionPage(input, async (page) => {
18
19
  if (!requestedAddress && !requestedId && !requestedName) {
19
20
  throw new Error('metamask.wallet.select_account extension live adapter requires node.address, node.id, or node.name.');
20
21
  }
22
+ const criteria = { address: requestedAddress, id: requestedId, name: requestedName };
21
23
  try {
22
- const selected = await page.evaluate(selectWalletAccountExpression({
23
- address: requestedAddress,
24
- id: requestedId,
25
- name: requestedName,
26
- }), { awaitPromise: true });
27
- return { action: input.action, selected, proofPath: 'extension-background-account-selection' };
24
+ const { alreadySelected, ...selected } = await page.evaluate(
25
+ selectWalletAccountExpression(criteria),
26
+ { awaitPromise: true },
27
+ );
28
+ return {
29
+ action: input.action,
30
+ selected,
31
+ ...(alreadySelected ? { alreadySelected: true } : {}),
32
+ proofPath: 'extension-background-account-selection',
33
+ };
28
34
  } catch (error) {
29
35
  if (!String(error?.message ?? error).includes('submitRequestToBackground is unavailable')) throw error;
30
36
  }
31
37
 
32
- let searchAddress = requestedAddress;
33
- if (!searchAddress) {
34
- const listed = await page.evaluate(listWalletAccountsExpression('all'), { awaitPromise: true });
35
- const account = listed.accounts.find((entry) =>
36
- (requestedId && entry.id === requestedId) ||
37
- (requestedName && entry.name === requestedName));
38
- searchAddress = normalize(account?.address);
39
- }
38
+ const resolved = await page.evaluate(resolveWalletAccountExpression(criteria), { awaitPromise: true });
39
+ const searchAddress = normalize(resolved?.address);
40
40
  if (!searchAddress) throw new Error('Requested account does not have a searchable EVM address.');
41
41
 
42
42
  await page.navigateHash('#/account-list');
@@ -94,40 +94,94 @@ export function listWalletAccountsExpression(scope) {
94
94
  })()`;
95
95
  }
96
96
 
97
+ // Resolves id, address, or internal metadata name against internalAccounts first. A name
98
+ // with no internal match falls back to AccountTreeController group names (multichain
99
+ // wallets keep fixture names there, e.g. group "dev1" over internal "Account 1") and
100
+ // selects the group's EVM account; a group without one is rejected.
101
+ const resolveAccountPreamble = `
102
+ const resolveRequestedAccount = (state, criteria) => {
103
+ const accounts = state.internal.accounts || {};
104
+ const entries = Object.entries(accounts);
105
+ const isEvm = (account) => /^0x[0-9a-f]{40}$/iu.test(String(account?.address || ''));
106
+ const direct = entries.find(([id, account]) =>
107
+ (criteria.id && id === criteria.id) ||
108
+ (criteria.address && String(account?.address || '').toLowerCase() === criteria.address) ||
109
+ (criteria.name && account?.metadata?.name === criteria.name));
110
+ if (direct) return { id: direct[0], accountGroup: null };
111
+ if (!criteria.name) return null;
112
+ const groups = [];
113
+ for (const wallet of Object.values(state.metamask.accountTree?.wallets || {})) {
114
+ for (const [groupId, group] of Object.entries(wallet?.groups || {})) {
115
+ if (group?.metadata?.name === criteria.name) groups.push({ id: groupId, accounts: group.accounts || [] });
116
+ }
117
+ }
118
+ if (groups.length > 1) {
119
+ throw new Error('Requested account name ' + JSON.stringify(criteria.name) + ' matches ' + groups.length +
120
+ ' account groups; select by address instead.');
121
+ }
122
+ if (!groups.length) return null;
123
+ const id = groups[0].accounts.find((candidate) => isEvm(accounts[candidate]));
124
+ if (!id) {
125
+ throw new Error('Account group ' + JSON.stringify(criteria.name) +
126
+ ' has no EVM account; select by id instead.');
127
+ }
128
+ return { id, accountGroup: { id: groups[0].id, name: criteria.name } };
129
+ };
130
+ const describeAccount = (state, resolved) => {
131
+ const account = state.internal.accounts?.[resolved.id];
132
+ return {
133
+ id: resolved.id,
134
+ address: account?.address || null,
135
+ name: account?.metadata?.name || null,
136
+ type: account?.type || null,
137
+ ...(resolved.accountGroup ? { accountGroup: resolved.accountGroup } : {}),
138
+ };
139
+ };
140
+ `;
141
+
142
+ function criteriaLiteral(criteria) {
143
+ return JSON.stringify({
144
+ address: criteria.address || null,
145
+ id: criteria.id || null,
146
+ name: criteria.name || null,
147
+ });
148
+ }
149
+
150
+ export function resolveWalletAccountExpression(criteria) {
151
+ return `(async () => {
152
+ ${walletStatePreamble}
153
+ ${resolveAccountPreamble}
154
+ const state = await readWalletState();
155
+ const resolved = resolveRequestedAccount(state, ${criteriaLiteral(criteria)});
156
+ if (!resolved) throw new Error('Requested account was not found in account groups or internalAccounts.');
157
+ return {
158
+ ...describeAccount(state, resolved),
159
+ alreadySelected: resolved.id === (state.internal.selectedAccount || null),
160
+ };
161
+ })()`;
162
+ }
163
+
97
164
  export function selectWalletAccountExpression(criteria, timeoutMs = 5000) {
98
165
  return `(async () => {
99
166
  ${walletStatePreamble}
167
+ ${resolveAccountPreamble}
100
168
  const before = await readWalletState();
101
- const accounts = before.internal.accounts || {};
102
- const requestedAddress = ${JSON.stringify(criteria.address)};
103
- const requestedId = ${JSON.stringify(criteria.id)};
104
- const requestedName = ${JSON.stringify(criteria.name)};
105
- const match = Object.entries(accounts).find(([id, account]) => {
106
- const metadata = account?.metadata || {};
107
- return (requestedId && id === requestedId) ||
108
- (requestedAddress && String(account?.address || '').toLowerCase() === requestedAddress) ||
109
- (requestedName && metadata.name === requestedName);
110
- });
111
- if (!match) throw new Error('Requested account was not found in internalAccounts.');
169
+ const resolved = resolveRequestedAccount(before, ${criteriaLiteral(criteria)});
170
+ if (!resolved) throw new Error('Requested account was not found in account groups or internalAccounts.');
171
+ const id = resolved.id;
172
+ if ((before.internal.selectedAccount || null) === id) {
173
+ return { ...describeAccount(before, resolved), alreadySelected: true };
174
+ }
112
175
  if (typeof before.hooks.submitRequestToBackground !== 'function') {
113
176
  throw new Error('stateHooks.submitRequestToBackground is unavailable.');
114
177
  }
115
- const [id] = match;
116
178
  await before.hooks.submitRequestToBackground('setSelectedInternalAccount', [id]);
117
179
  const deadline = Date.now() + ${Number(timeoutMs)};
118
180
  let observedId = before.internal.selectedAccount || null;
119
181
  while (Date.now() <= deadline) {
120
182
  const after = await readWalletState();
121
183
  observedId = after.internal.selectedAccount || null;
122
- if (observedId === id) {
123
- const selected = after.internal.accounts?.[observedId];
124
- return {
125
- id: observedId,
126
- address: selected?.address || null,
127
- name: selected?.metadata?.name || null,
128
- type: selected?.type || null,
129
- };
130
- }
184
+ if (observedId === id) return describeAccount(after, resolved);
131
185
  await new Promise((resolve) => setTimeout(resolve, 100));
132
186
  }
133
187
  throw new Error(
@@ -107,6 +107,8 @@ export async function runAdapter(callback) {
107
107
  }
108
108
  }
109
109
 
110
+ // Same rule as src/adapters/mobile/bridge-script.ts (unset or empty means the
111
+ // bundled bridge); the library cannot import src/.
110
112
  function bridgeScript(input) {
111
113
  if (process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT) {
112
114
  return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT;
@@ -16,6 +16,41 @@ import { mobileToolRecovery, resolveMobileToolPath } from '@farmslot/adapter-rn'
16
16
  const execFileAsync = promisify(execFile);
17
17
  const VISIBLE_LIMIT = 200;
18
18
  const HIDDEN_LIMIT = 100;
19
+ // iOS accessibility exposes React Native Text as StaticText without its testID.
20
+ // Read each mounted Perps Text's testID, string and size (synchronous on Fabric)
21
+ // so the visible native element with the same label and size can carry that
22
+ // testID. Only perps- Texts leave the app: other Text, such as a revealed
23
+ // Secret Recovery Phrase word, never reaches the harness or a trace.
24
+ const TEXT_TEST_ID_LIMIT = 500;
25
+ const IOS_TEXT_TEST_IDS = `(() => {
26
+ const ui = globalThis.nativeFabricUIManager;
27
+ const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
28
+ if (!ui?.measureInWindow || !hook?.getFiberRoots) return { texts: [], total: 0 };
29
+ const texts = [];
30
+ const content = (fiber) => {
31
+ let value = '';
32
+ for (let child = fiber.child; child; child = child.sibling) {
33
+ value += typeof child.memoizedProps === 'string' || typeof child.memoizedProps === 'number'
34
+ ? String(child.memoizedProps) : content(child);
35
+ }
36
+ return value;
37
+ };
38
+ const visit = (fiber) => {
39
+ for (; fiber; fiber = fiber.sibling) {
40
+ const testId = fiber.memoizedProps?.testID;
41
+ if (fiber.type === 'RCTText' && typeof testId === 'string' && testId.startsWith('perps-') && fiber.stateNode?.node) {
42
+ let bounds;
43
+ ui.measureInWindow(fiber.stateNode.node, (x, y, width, height) => { bounds = { x, y, width, height }; });
44
+ if (bounds) texts.push({ testId, label: content(fiber), bounds });
45
+ }
46
+ visit(fiber.child);
47
+ }
48
+ };
49
+ const roots = new Set();
50
+ for (const id of hook.renderers.keys()) hook.getFiberRoots(id)?.forEach((root) => roots.add(root));
51
+ roots.forEach((root) => visit(root.current));
52
+ return { texts: texts.slice(0, ${TEXT_TEST_ID_LIMIT}), total: texts.length };
53
+ })()`;
19
54
 
20
55
  export async function observeNativeUi(payload, context) {
21
56
  await hideAccessibilityMaskingHud(payload, context);
@@ -39,7 +74,26 @@ export async function observeNativeUi(payload, context) {
39
74
  warnings.push({ ref: 'ui.screen', message: `Navigation route unavailable: ${error.message}` });
40
75
  }
41
76
  }
42
- const hierarchy = await readNativeHierarchy(payload, context);
77
+ let textTestIds = [];
78
+ if (
79
+ supported.includes('ui.visible') &&
80
+ resolvePlatform(record(payload?.node), env) === 'ios' &&
81
+ !isOpaqueRuntime(env) && (env.METRO_PORT || env.WATCHER_PORT)
82
+ ) {
83
+ try {
84
+ const read = await bridgeCommand(routeInput, ['eval', IOS_TEXT_TEST_IDS]);
85
+ textTestIds = Array.isArray(read?.texts) ? read.texts : [];
86
+ if (read?.total > textTestIds.length) {
87
+ warnings.push({
88
+ ref: 'ui.visible',
89
+ message: `Text test IDs truncated: read ${textTestIds.length} of ${read.total}.`,
90
+ });
91
+ }
92
+ } catch (error) {
93
+ warnings.push({ ref: 'ui.visible', message: `Text test IDs unavailable: ${error.message}` });
94
+ }
95
+ }
96
+ const hierarchy = await readNativeHierarchy(payload, context, textTestIds);
43
97
  if (routeBefore?.name) {
44
98
  const routeAfter = await bridgeCommand(routeInput, ['get-route']);
45
99
  if (routeAfter?.name !== routeBefore.name || routeAfter?.key !== routeBefore.key) {
@@ -85,7 +139,7 @@ export async function hideAccessibilityMaskingHud(payload, context) {
85
139
  }
86
140
  }
87
141
 
88
- async function readNativeHierarchy(payload, context) {
142
+ async function readNativeHierarchy(payload, context, textTestIds) {
89
143
  const node = record(payload?.node);
90
144
  const env = {
91
145
  ...process.env,
@@ -98,13 +152,13 @@ async function readNativeHierarchy(payload, context) {
98
152
  const appLabel = nativeAppLabel(env);
99
153
  const hierarchy = platform === 'android'
100
154
  ? await readAndroidHierarchy(node, env)
101
- : await readIosHierarchy(node, env);
155
+ : await readIosHierarchy(node, env, textTestIds);
102
156
  // No screen name and no labelled item: name the screen after the app.
103
157
  hierarchy.screen.name ??= appLabel;
104
158
  return hierarchy;
105
159
  }
106
160
 
107
- async function readIosHierarchy(node, env) {
161
+ async function readIosHierarchy(node, env, textTestIds) {
108
162
  const idbPath = resolveMobileToolPath('idb');
109
163
  if (!idbPath) {
110
164
  throw new Error(
@@ -123,7 +177,7 @@ async function readIosHierarchy(node, env) {
123
177
  },
124
178
  );
125
179
  const parsed = parseJsonOutput(stdout);
126
- const hierarchy = flattenIosHierarchy(parsed);
180
+ const hierarchy = flattenIosHierarchy(parsed, textTestIds);
127
181
  if (hierarchy.nodeCount === 0) {
128
182
  throw new Error('idb returned an empty accessibility hierarchy.');
129
183
  }
@@ -364,7 +418,12 @@ function parseJsonOutput(output) {
364
418
  }
365
419
  }
366
420
 
367
- function flattenIosHierarchy(value) {
421
+ function flattenIosHierarchy(value, textTestIds = []) {
422
+ const texts = (Array.isArray(textTestIds) ? textTestIds : []).map((entry) => ({
423
+ testId: text(entry?.testId),
424
+ label: text(entry?.label),
425
+ bounds: normalizeBounds(entry?.bounds),
426
+ })).filter((entry) => entry.testId && entry.label && entry.bounds);
368
427
  const visible = [];
369
428
  const hidden = [];
370
429
  let nodeCount = 0;
@@ -382,9 +441,10 @@ function flattenIosHierarchy(value) {
382
441
  text(item.role_description) ?? text(item.className);
383
442
  const label = text(item.label) ?? text(item.AXLabel) ?? text(item.name) ??
384
443
  text(item.title);
385
- const testId = text(item.identifier) ?? text(item.accessibilityIdentifier) ??
386
- text(item.AXUniqueId) ?? text(item.id);
387
444
  const bounds = normalizeBounds(item.frame ?? item.AXFrame ?? item.bounds);
445
+ const testId = text(item.identifier) ?? text(item.accessibilityIdentifier) ??
446
+ text(item.AXUniqueId) ?? text(item.id) ??
447
+ (label && hasVisibleBounds(bounds, screenBounds) ? textTestId(texts, label, bounds) : undefined);
388
448
  if (isApplicationRole(role)) {
389
449
  screenName ??= label;
390
450
  screenBounds ??= bounds;
@@ -466,6 +526,17 @@ function parseAndroidHierarchy(output) {
466
526
  return { visible, hidden, nodeCount, screenName };
467
527
  }
468
528
 
529
+ // Bind an exact label and size to one testID. Origins are not compared: Fabric
530
+ // frames omit native screen offsets such as a modal card. A same-text twin with
531
+ // another size (the collapsed header price) or another testID stays unbound.
532
+ function textTestId(texts, label, bounds) {
533
+ const testIds = new Set(texts
534
+ .filter((entry) => entry.label === label &&
535
+ ['width', 'height'].every((key) => Math.abs(entry.bounds[key] - bounds[key]) <= 1))
536
+ .map((entry) => entry.testId));
537
+ return testIds.size === 1 ? [...testIds][0] : undefined;
538
+ }
539
+
469
540
  function isApplicationRole(role) {
470
541
  return role?.toLowerCase().includes('application') ?? false;
471
542
  }
@@ -1,4 +1,5 @@
1
1
  // Select a wallet account by address, ID, or name and verify the resulting selection.
2
+ // A name with no internal account match falls back to AccountTreeController group names.
2
3
  // Observe ambiguous bridge write failures until the deadline without repeating the switch.
3
4
  import { pathToFileURL } from 'node:url';
4
5
  import {
@@ -34,13 +35,13 @@ function withRemainingDeadline(input, deadline, attemptTimeoutMs) {
34
35
  };
35
36
  }
36
37
 
37
- async function readSelectedAccount(input, deadline) {
38
+ async function readBridge(input, deadline, args, timeoutMessage) {
38
39
  let lastError = null;
39
40
  while (Date.now() < deadline) {
40
41
  try {
41
42
  return await bridgeCommand(
42
43
  withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
43
- ['get-selected-account'],
44
+ args,
44
45
  );
45
46
  } catch (error) {
46
47
  if (!TRANSIENT_READ_CODES.has(error?.code)) throw error;
@@ -48,24 +49,34 @@ async function readSelectedAccount(input, deadline) {
48
49
  await sleep(Math.min(100, Math.max(0, deadline - Date.now())));
49
50
  }
50
51
  }
51
- throw lastError ?? new Error('Timed out reading the selected Mobile account.');
52
+ throw lastError ?? new Error(timeoutMessage);
52
53
  }
53
54
 
54
- async function readAccounts(input, deadline) {
55
- let lastError = null;
56
- while (Date.now() < deadline) {
57
- try {
58
- return await bridgeCommand(
59
- withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
60
- ['list-accounts'],
61
- );
62
- } catch (error) {
63
- if (!TRANSIENT_READ_CODES.has(error?.code)) throw error;
64
- lastError = error;
65
- await sleep(Math.min(100, Math.max(0, deadline - Date.now())));
55
+ const readSelectedAccount = (input, deadline) =>
56
+ readBridge(input, deadline, ['get-selected-account'], 'Timed out reading the selected Mobile account.');
57
+
58
+ const readAccounts = (input, deadline) =>
59
+ readBridge(input, deadline, ['list-accounts'], 'Timed out reading Mobile accounts.');
60
+
61
+ // Multichain wallets name the account group, not always the internal account.
62
+ async function findAccountByGroupName(input, deadline, accounts, name) {
63
+ const wallets = await readBridge(
64
+ input,
65
+ deadline,
66
+ ['get-state', 'engine.backgroundState.AccountTreeController.accountTree.wallets'],
67
+ 'Timed out reading Mobile account groups.',
68
+ );
69
+ const groups = [];
70
+ for (const wallet of Object.values(wallets && typeof wallets === 'object' ? wallets : {})) {
71
+ for (const [groupId, group] of Object.entries(wallet?.groups ?? {})) {
72
+ if (group?.metadata?.name === name) groups.push({ id: groupId, accounts: group.accounts ?? [] });
66
73
  }
67
74
  }
68
- throw lastError ?? new Error('Timed out reading Mobile accounts.');
75
+ if (groups.length > 1) {
76
+ throw new Error(`Requested mobile account name ${JSON.stringify(name)} matches ${groups.length} account groups; select by address instead.`);
77
+ }
78
+ const match = accounts.find((account) => groups[0]?.accounts.includes(account?.id));
79
+ return match ? { ...match, accountGroup: { id: groups[0].id, name } } : null;
69
80
  }
70
81
 
71
82
  function ambiguousWriteFailure(writeError, match, finalSelected, readError) {
@@ -98,7 +109,7 @@ export async function selectAccount(input) {
98
109
  if (!Array.isArray(accounts)) {
99
110
  throw new Error(`metamask.wallet.select_account expected list-accounts to return an array, got ${typeof accounts}.`);
100
111
  }
101
- const match = accounts.find((account) => {
112
+ let match = accounts.find((account) => {
102
113
  const address = String(account?.address ?? '').toLowerCase();
103
114
  const id = String(account?.id ?? '');
104
115
  const name = String(account?.name ?? '');
@@ -106,8 +117,11 @@ export async function selectAccount(input) {
106
117
  (requestedId && id === requestedId) ||
107
118
  (requestedName && name === requestedName);
108
119
  });
120
+ if (!match && requestedName) {
121
+ match = await findAccountByGroupName(input, deadline, accounts, requestedName);
122
+ }
109
123
  if (!match?.address) {
110
- throw new Error(`Requested mobile account was not found in list-accounts: ${JSON.stringify({ address: requestedAddress, id: requestedId, name: requestedName })}`);
124
+ throw new Error(`Requested mobile account was not found in list-accounts or account groups: ${JSON.stringify({ address: requestedAddress, id: requestedId, name: requestedName })}`);
111
125
  }
112
126
  const current = await readSelectedAccount(input, deadline);
113
127
  const matchAddress = String(match.address).toLowerCase();
@@ -118,6 +132,7 @@ export async function selectAccount(input) {
118
132
  address: current?.address ?? match.address,
119
133
  id: current?.id ?? match.id,
120
134
  name: current?.name ?? match.name,
135
+ ...(match.accountGroup ? { accountGroup: match.accountGroup } : {}),
121
136
  },
122
137
  alreadySelected: true,
123
138
  proofPath: 'agentic-account-selection',
@@ -172,6 +187,7 @@ export async function selectAccount(input) {
172
187
  address: selected?.address ?? result?.address ?? match.address,
173
188
  id: selected?.id ?? result?.id ?? match.id,
174
189
  name: selected?.name ?? result?.name ?? match.name,
190
+ ...(match.accountGroup ? { accountGroup: match.accountGroup } : {}),
175
191
  },
176
192
  ...(ambiguousWriteError
177
193
  ? { recoveredAfterAmbiguousWrite: true }
@@ -1321,7 +1321,7 @@
1321
1321
  ]
1322
1322
  },
1323
1323
  "metamask.perps.close_positions": {
1324
- "description": "core Close selected live Perps positions on HyperLiquid testnet by driving the headless perps controller closePosition() through the full signing/provider path. Testnet only. Pre-existing testnet state on an account the task owns is converged, not refused; mainnet is never mutated.",
1324
+ "description": "core Close selected live Perps positions by driving the headless perps controller closePosition() through the full signing/provider path. Testnet by default; mainnet needs network: mainnet and CORE_PERPS_ALLOW_MAINNET_WRITES=1. Pre-existing testnet state on an account the task owns is converged, not refused.",
1325
1325
  "schema": {
1326
1326
  "type": "object",
1327
1327
  "properties": {
@@ -1591,7 +1591,7 @@
1591
1591
  "execution_capabilities": []
1592
1592
  },
1593
1593
  "metamask.perps.ensure_positions": {
1594
- "description": "core Higher-level wrapper that reads selected positions, places or closes via the headless controller as needed, then asserts the final position state. Testnet only. Pre-existing testnet state on an account the task owns is converged, not refused; mainnet is never mutated.",
1594
+ "description": "core Higher-level wrapper that reads selected positions, places or closes via the headless controller as needed, then asserts the final position state. Testnet by default; mainnet needs network: mainnet and CORE_PERPS_ALLOW_MAINNET_WRITES=1, and placing follows the shared Perps order rule. Pre-existing testnet state on an account the task owns is converged, not refused.",
1595
1595
  "schema": {
1596
1596
  "type": "object",
1597
1597
  "properties": {
@@ -1744,7 +1744,7 @@
1744
1744
  ]
1745
1745
  },
1746
1746
  "metamask.perps.close_orders": {
1747
- "description": "Core Perps cancellation selects current orders by market/side or mode=all, then dispatches and verifies those exact order IDs. Missing or duplicate selected IDs fail before dispatch. Requires authorized signing and task-owned selection; defaults to testnet. Pre-existing testnet state on an account the task owns is converged, not refused; mainnet is never mutated.",
1747
+ "description": "Core Perps cancellation selects current orders by market/side or mode=all, then dispatches and verifies those exact order IDs. Missing or duplicate selected IDs fail before dispatch. Requires authorized signing and task-owned selection. Testnet by default; mainnet needs network: mainnet and CORE_PERPS_ALLOW_MAINNET_WRITES=1. Pre-existing testnet state on an account the task owns is converged, not refused.",
1748
1748
  "schema": {
1749
1749
  "type": "object",
1750
1750
  "properties": {
@@ -2078,7 +2078,7 @@
2078
2078
  "execution_capabilities": []
2079
2079
  },
2080
2080
  "metamask.perps.ensure_orders": {
2081
- "description": "core Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested.Higher-level wrapper that reads selected orders, cancels when needed, then asserts final order state. Pre-existing testnet state on an account the task owns is converged, not refused; mainnet is never mutated.",
2081
+ "description": "core Higher-level wrapper that reads selected orders, cancels when needed, then asserts final order state. Testnet by default; mainnet needs network: mainnet and CORE_PERPS_ALLOW_MAINNET_WRITES=1. An open state is a resting limit order, which the shared Perps order rule refuses on mainnet. Pre-existing testnet state on an account the task owns is converged, not refused.",
2082
2082
  "schema": {
2083
2083
  "type": "object",
2084
2084
  "properties": {
@@ -2233,7 +2233,7 @@
2233
2233
  ]
2234
2234
  },
2235
2235
  "metamask.perps.start_state": {
2236
- "description": "core Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested.Recommended configurable Perps start state that composes reusable domain operations before the proof window. Headless: converges POSITION and ORDER baselines on testnet via the controller; no navigation/page/HUD.",
2236
+ "description": "core Recommended configurable Perps start state that composes reusable domain operations before the proof window. Headless: converges POSITION and ORDER baselines via the controller; no navigation/page/HUD. Testnet by default; mainnet needs network: mainnet and CORE_PERPS_ALLOW_MAINNET_WRITES=1, and orders follow the shared Perps order rule.",
2237
2237
  "schema": {
2238
2238
  "type": "object",
2239
2239
  "properties": {
@@ -2554,7 +2554,7 @@
2554
2554
  ]
2555
2555
  },
2556
2556
  "metamask.perps.teardown_state": {
2557
- "description": "core Recommended configurable Perps teardown state that restores the selected environment after proof. Headless: converges POSITION and ORDER baselines on testnet via the controller; no navigation/page/HUD.",
2557
+ "description": "core Recommended configurable Perps teardown state that restores the selected environment after proof. Headless: converges POSITION and ORDER baselines via the controller; no navigation/page/HUD. Testnet by default; mainnet needs network: mainnet and CORE_PERPS_ALLOW_MAINNET_WRITES=1.",
2558
2558
  "schema": {
2559
2559
  "type": "object",
2560
2560
  "properties": {
@@ -4131,7 +4131,7 @@
4131
4131
  "execution_capabilities": []
4132
4132
  },
4133
4133
  "metamask.perps.close_orders": {
4134
- "description": "extension Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested. Cancel selected live Perps open orders. Use selector/mode params to cancel matching symbols or all orders. Extension ensure_* only assert existing state; converge an owned testnet account through an explicitly funded leaf action. Mainnet is never mutated.",
4134
+ "description": "extension Cancel selected live Perps open orders. Use selector/mode params to cancel matching symbols or all orders. On mainnet, cancel is refused in the fixture-policy lane and needs the funded release lane. Extension ensure_* only assert existing state; converge an owned testnet account through an explicitly funded leaf action.",
4135
4135
  "schema": {
4136
4136
  "type": "object",
4137
4137
  "properties": {
@@ -2522,7 +2522,7 @@
2522
2522
  "execution_capabilities": []
2523
2523
  },
2524
2524
  "metamask.perps.close_positions": {
2525
- "description": "mobile Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested. Close selected live Perps positions. Use selector/mode params to close matching symbols or all positions. Pre-existing testnet state on an account the task owns is converged, not refused; mainnet is never mutated.",
2525
+ "description": "mobile Close selected live Perps positions on the app's current Perps network, with no mainnet opt-in. Use selector/mode params to close matching symbols or all positions. Pre-existing testnet state on an account the task owns is converged, not refused.",
2526
2526
  "schema": {
2527
2527
  "type": "object",
2528
2528
  "properties": {
@@ -2613,7 +2613,7 @@
2613
2613
  "execution_capabilities": ["app-mutation", "external-mutation"]
2614
2614
  },
2615
2615
  "metamask.perps.close_orders": {
2616
- "description": "mobile Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested. Cancel selected live Perps open orders. Use selector/mode params to cancel matching symbols or all orders. Pre-existing testnet state on an account the task owns is converged, not refused; mainnet is never mutated.",
2616
+ "description": "mobile Cancel selected live Perps open orders on the app's current Perps network, with no mainnet opt-in. Use selector/mode params to cancel matching symbols or all orders. Pre-existing testnet state on an account the task owns is converged, not refused.",
2617
2617
  "schema": {
2618
2618
  "type": "object",
2619
2619
  "properties": {
@@ -3025,7 +3025,7 @@
3025
3025
  "execution_capabilities": []
3026
3026
  },
3027
3027
  "metamask.perps.ensure_positions": {
3028
- "description": "mobile Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested. Higher-level wrapper that reads selected positions, closes or places when needed, then asserts final position state. Pre-existing testnet state on an account the task owns is converged, not refused; mainnet is never mutated.",
3028
+ "description": "mobile Higher-level wrapper that reads selected positions, closes or places when needed, then asserts final position state. Acts on the app's current Perps network: closing has no mainnet opt-in, and placing follows the shared Perps order rule, so it cannot leave a position open on mainnet. Pre-existing testnet state on an account the task owns is converged, not refused.",
3029
3029
  "schema": {
3030
3030
  "type": "object",
3031
3031
  "properties": {
@@ -3141,7 +3141,7 @@
3141
3141
  "execution_capabilities": ["app-mutation", "external-mutation"]
3142
3142
  },
3143
3143
  "metamask.perps.ensure_orders": {
3144
- "description": "mobile Converge selected testnet orders to open or absent, then independently assert the final state. Creating an open state uses a resting limit order. Pre-existing testnet state on an account the task owns is converged, not refused; mainnet is never mutated.",
3144
+ "description": "mobile Converge selected testnet orders to open or absent, then independently assert the final state. Creating an open state uses a resting limit order, which the shared Perps order rule refuses on mainnet; cancelling acts on the app's current Perps network with no mainnet opt-in. Pre-existing testnet state on an account the task owns is converged, not refused.",
3145
3145
  "schema": {
3146
3146
  "type": "object",
3147
3147
  "properties": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.85.0",
3
+ "version": "0.86.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -21,15 +21,15 @@
21
21
  "check:syntax": "find . -name '*.mjs' -print0 | xargs -0 -n1 node --check"
22
22
  },
23
23
  "dependencies": {
24
- "@farmslot/adapter-node": "0.1.6",
25
- "@farmslot/adapter-rn": "0.19.3",
26
- "@farmslot/adapter-sdk": "0.8.0",
27
- "@farmslot/adapter-web": "0.6.4",
24
+ "@farmslot/adapter-node": "0.1.7",
25
+ "@farmslot/adapter-rn": "0.19.4",
26
+ "@farmslot/adapter-sdk": "0.8.1",
27
+ "@farmslot/adapter-web": "0.6.5",
28
28
  "@farmslot/agent-runtime": "0.17.1",
29
29
  "@farmslot/handoff": "^0.3.1",
30
30
  "@farmslot/protocol": "0.35.0",
31
- "@farmslot/recipe-cli": "0.11.0",
32
- "@farmslot/recipe-runner": "0.28.0",
31
+ "@farmslot/recipe-cli": "0.11.1",
32
+ "@farmslot/recipe-runner": "0.28.1",
33
33
  "agent-device": "0.19.3",
34
34
  "commander": "^12.0.0",
35
35
  "esbuild": "0.28.1",
@@ -45,8 +45,8 @@ _mmh_flags_for() {
45
45
  execution-template) printf '%s' "--dir --domain-dir --project-worker --project-name --package-templates --package-id --flow --run-mode --platform --domain --id --provenance --include-shadowed --no-include-shadowed --title --force --json" ;;
46
46
  recipe-quality) printf '%s' "--input --output --recipe --task --evidence --params --adapter --library --advisor --advisor-model --advisor-stages --json" ;;
47
47
  actions) printf '%s' "--adapter --action --kind --raw --action-manifest --target --json" ;;
48
- call) printf '%s' "--list --domain --source --sort --adapter --target --artifacts-dir --action-manifest --heal --json" ;;
49
- run) printf '%s' "--list --domain --source --sort --describe --plan --adapter --artifacts-dir --target --library --cdp-port --heal --record-video --json" ;;
48
+ call) printf '%s' "--list --domain --source --sort --adapter --target --artifacts-dir --action-manifest --heal --hud --json" ;;
49
+ run) printf '%s' "--list --domain --source --sort --describe --plan --adapter --artifacts-dir --target --library --cdp-port --heal --record-video --hud --json" ;;
50
50
  doctor) printf '%s' "--adapter --target --fix --json" ;;
51
51
  install|verify|cleanup) printf '%s' "--adapter --target --json" ;;
52
52
  *) printf '%s' "" ;;
@@ -201,7 +201,7 @@
201
201
  "Analytics: verify the collection destination and existing consent before capture. Changing participation or marketing consent requires operator approval and restoration of the prior settings; a recipe's hardcoded opt-in is not permission. Missing collection prerequisites are not missing product events.",
202
202
  "Flake risk: wait on observable state instead of sleeping, keep device and runtime identity explicit, and never overwrite a prior run's artifacts.",
203
203
  "Mutation: require an independent state assertion and identity-bound receipt. Bind activity to that operation's accepted ID; an old row or nonzero count is insufficient. Opening and closing orders have different IDs. Schedule authorized cleanup in teardown, verify its result, and report any residue. Testnet cleanup closes what the task opened and never silently undoes another task's live proof on a reserved account or market.",
204
- "Testnet account state: on a testnet account or market the task owns for its run (the slot's fixture account, or one reserved with exclusive_testnet_market=true), converging is an explicit, ownership-scoped setup choice, not a violation. Use the team library's perps.ensure-market-state or perps.clean-market-testnet, or on Mobile and Core metamask.perps.ensure_positions / metamask.perps.ensure_orders (mode=all for strays), record what was closed in the trace, then prove. A pre-existing position or order on an account the task owns is never a reason to block, skip, or refuse, and a fixture that demands an empty account must converge rather than refuse. Extension ensure_* and teardown_state only assert; converge there through an explicitly funded leaf action. Never broaden cleanup onto an account or market the task does not own; obtain an owned one instead of blocking. Mainnet is read-only.",
204
+ "Testnet account state: on a testnet account or market the task owns for its run (the slot's fixture account, or one reserved with exclusive_testnet_market=true), converging is an explicit, ownership-scoped setup choice, not a violation. Use the team library's perps.ensure-market-state or perps.clean-market-testnet, or on Mobile and Core metamask.perps.ensure_positions / metamask.perps.ensure_orders (mode=all for strays), record what was closed in the trace, then prove. A pre-existing position or order on an account the task owns is never a reason to block, skip, or refuse, and a fixture that demands an empty account must converge rather than refuse. Extension ensure_* and teardown_state only assert; converge there through an explicitly funded leaf action. Never broaden cleanup onto an account or market the task does not own; obtain an owned one instead of blocking. On mainnet, orders follow the shared Perps order rule (PERPS_MAINNET_ORDER_REFUSED), Core needs CORE_PERPS_ALLOW_MAINNET_WRITES=1 for any write, Mobile cleanup acts on whatever network the app is on, and Extension refuses mainnet writes other than orders and `close_visible_position` in the fixture-policy lane.",
205
205
  {
206
206
  "text": "An accepted order can fill before the UI updates. Preserve its receipt even when the expected resting-order assertion fails; one empty read does not prove cleanup. Check the read API's cache and freshness options before adding polling. Reconcile returned margin against the pre-placement balance. A successful close does not turn the original failed proof into a pass.",
207
207
  "adapters": [