@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 +31 -0
- package/README.md +5 -3
- package/adapters/extension/refresh-build.sh +1 -1
- package/adapters/extension/start-watch.sh +17 -8
- package/adapters/manifest.json +1 -1
- package/adapters/mobile/reload-app.mjs +2 -1
- package/dist/adapters/console-capture.js +3 -1
- package/dist/adapters/mobile/run.js +1 -1
- package/dist/command-contract.js +4 -1
- package/dist/commands/status-probe.js +8 -1
- package/dist/review/knowledge.js +6 -2
- package/docs/CONTRIBUTING.md +4 -1
- package/docs/QA.md +1 -0
- package/docs/RECIPES.md +4 -1
- package/docs/cli-contract.md +1 -0
- package/library/actions/core/perps/_controller.mjs +2 -2
- package/library/actions/extension/platform/cdp.mjs +46 -2
- package/library/actions/extension/ui/navigate.mjs +3 -1
- package/library/actions/extension/wallet/select_account.mjs +17 -17
- package/library/actions/extension/wallet/state.mjs +75 -21
- package/library/actions/mobile/wallet/select_account.mjs +34 -18
- package/library/manifests/core.action-manifest.json +6 -6
- package/library/manifests/extension.action-manifest.json +1 -1
- package/library/manifests/mobile.action-manifest.json +4 -4
- package/package.json +7 -7
- package/scripts/completions.sh +2 -2
- package/site/assets/help-recipes.json +1 -1
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
|
|
423
|
-
`<checkout>/.skills-cache/<name>`.
|
|
424
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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 [
|
|
305
|
-
sleep
|
|
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
|
|
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
|
package/adapters/manifest.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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" },
|
package/dist/command-contract.js
CHANGED
|
@@ -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
|
|
70
|
+
const byModel = entries.find((e) => androidModelMatches(e.deviceName, model, device, devices));
|
|
64
71
|
if (byModel) return byModel;
|
|
65
72
|
}
|
|
66
73
|
}
|
package/dist/review/knowledge.js
CHANGED
|
@@ -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(
|
|
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 =
|
|
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;
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -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`
|
|
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
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.
|
|
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
|
|
package/docs/cli-contract.md
CHANGED
|
@@ -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
|
|
233
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
52
|
+
throw lastError ?? new Error(timeoutMessage);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
25
|
-
"@farmslot/adapter-rn": "0.19.
|
|
26
|
-
"@farmslot/adapter-sdk": "0.8.
|
|
27
|
-
"@farmslot/adapter-web": "0.6.
|
|
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.
|
|
32
|
-
"@farmslot/recipe-runner": "0.28.
|
|
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",
|
package/scripts/completions.sh
CHANGED
|
@@ -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.
|
|
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": [
|