@deeeed/metamask-harness 0.85.0 → 0.86.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.86.0 - 2026-10-09
6
+
7
+ ### Added
8
+
9
+ - `MM_HARNESS_SIBLING_LIBRARIES=0` turns off sibling discovery: libraries and reference checkouts beside the checkout or under `~/shared-library` are no longer found.
10
+ - `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"`).
11
+ - `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.
12
+
13
+ ### Changed
14
+
15
+ - 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:
16
+ - `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.
17
+ - 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.
18
+ - `call app.hud` drives the HUD itself, with no automatic updates.
19
+ - 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.
20
+ - 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.
21
+
22
+ ### Fixed
23
+
24
+ - 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.
25
+ - 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`.
26
+ - `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.
27
+
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+ - 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.
32
+ - 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.
33
+
5
34
  ## 0.85.0 - 2026-10-09
6
35
 
7
36
  ### Added
@@ -34,6 +63,8 @@
34
63
 
35
64
  ### Fixed
36
65
 
66
+ - 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.
67
+ - `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
68
  - 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
69
 
39
70
  ## 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)) {
@@ -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 = process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
162
162
  const result = spawnSync(process.execPath, [bridge, command], {
163
163
  cwd: projectRoot,
164
164
  env: { ...process.env, APP_ROOT: projectRoot, CDP_TIMEOUT: "5000" },
@@ -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,
@@ -52,6 +52,13 @@ function parseBridgeEntries(raw) {
52
52
  ...e.featureFlags !== void 0 ? { featureFlags: e.featureFlags } : {}
53
53
  }));
54
54
  }
55
+ function androidModelMatches(deviceName, model, device, devices) {
56
+ if (deviceName === model || deviceName.startsWith(`${model} -`)) return true;
57
+ if (!deviceName.endsWith(` ${model}`) && !deviceName.includes(` ${model} -`)) return false;
58
+ const physical = (d) => d.platform === "android" && !d.id.startsWith("emulator-");
59
+ if (!physical(device)) return false;
60
+ return devices.filter((d) => physical(d) && d.name === device.name).length === 1;
61
+ }
55
62
  function matchBridgeEntry(device, entries, devices) {
56
63
  const byId = entries.find((e) => e.deviceName === device.id);
57
64
  if (byId) return byId;
@@ -60,7 +67,7 @@ function matchBridgeEntry(device, entries, devices) {
60
67
  if (byName) return byName;
61
68
  if (device.platform === "android") {
62
69
  const model = device.name.replace(/_/gu, " ");
63
- const byModel = entries.find((e) => e.deviceName === model || e.deviceName.startsWith(`${model} -`));
70
+ const byModel = entries.find((e) => androidModelMatches(e.deviceName, model, device, devices));
64
71
  if (byModel) return byModel;
65
72
  }
66
73
  }
@@ -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();
@@ -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(
@@ -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.0",
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": [