@deeeed/metamask-harness 0.87.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/adapters/extension/ensure-browser.sh +6 -10
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +31 -4
  5. package/adapters/extension/profile-extension-state.cjs +156 -0
  6. package/adapters/extension/readiness.mjs +21 -2
  7. package/adapters/manifest.json +8 -0
  8. package/adapters/mobile/wait-for-bridge.cjs +9 -3
  9. package/dist/adapters/extension/actions.js +2 -1
  10. package/dist/adapters/extension/run.js +6 -0
  11. package/dist/adapters/mobile/android-screenrecord.js +10 -6
  12. package/dist/adapters/mobile/recording-outcome.js +2 -51
  13. package/dist/adapters/mobile/run.js +10 -4
  14. package/dist/adapters/perps-mainnet-cleanup.js +122 -0
  15. package/dist/engine.js +11 -2
  16. package/dist/funding-execution-context.js +13 -8
  17. package/dist/host.js +6 -2
  18. package/dist/index.js +3 -0
  19. package/dist/mm-harness-cli.js +4 -2
  20. package/dist/provider.js +74 -0
  21. package/dist/recipe-security.js +1 -0
  22. package/docs/CONTRIBUTING.md +7 -0
  23. package/docs/RECIPES.md +38 -13
  24. package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
  25. package/docs/cli-contract.md +1 -1
  26. package/library/actions/extension/perps/mutation-receipt.mjs +4 -3
  27. package/library/actions/extension/perps/perps.mjs +200 -9
  28. package/library/actions/extension/perps/set_network.mjs +6 -0
  29. package/library/actions/mobile/perps/perps.mjs +159 -11
  30. package/library/actions/mobile/perps/place_order.mjs +7 -4
  31. package/library/actions/shared/perps/mainnet-order-ledger.mjs +378 -0
  32. package/library/actions/shared/perps/order-policy.mjs +56 -19
  33. package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
  34. package/library/manifests/extension.action-manifest.json +34 -4
  35. package/library/manifests/mobile.action-manifest.json +18 -5
  36. package/package.json +14 -9
  37. package/scripts/validate-release-report.mjs +14 -2
  38. package/site/assets/help-recipes.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.89.0 - 2026-10-11
6
+
7
+ ### Added
8
+
9
+ - Export the canonical command grammar with the provider so shared hosts validate domain options before constructing an executing provider.
10
+
11
+ ### Changed
12
+
13
+ - Pin recipe-runner 0.29.0, recipe-cli 0.13.0, adapter-sdk 0.8.6, adapter-node 0.1.12, adapter-rn 0.19.10 and adapter-web 0.6.10 so the provider shares the generic host's signal-aware execution and finalization runtime. Protocol and agent-runtime stay at their current published versions.
14
+
15
+ ## 0.88.0 - 2026-10-11
16
+
17
+ ### Added
18
+
19
+ - Public `createProvider` and `providerHost` exports for shared recipe commands. The provider reuses MetaMask adapters and funding authorization, loads selected plugins from authorized libraries, and supplies run-owned mainnet cancellation and teardown for explicit runs through the existing abort helper. With the coordinated recipe-cli update, doctor, discovery and recipe previews validate funding policy without creating execution claims, artifact directories or mutation ledgers. Explicit execution still binds the funded plan. Run ownership uses `RECIPE_RUN_OWNER_PID`, preserving approval across invoking processes with the coordinated runner update.
20
+ - Mainnet limit orders on Mobile and Extension `place_order`: Hyperliquid only, from `dev1` only, 1x isolated, at most $15 at the limit price (plus one size step), away from the live market price and sent post-only, with `network: mainnet`. Each one lasts one run: it is tracked before dispatch, and the run cancels whatever is still open when it ends, on success, failure or abort by signal (after a kill, the next run on the checkout), counting it gone only when the Hyperliquid API no longer lists it for the placing address, and writing order ID, cancel result and both reads to `<artifacts>/perps-mainnet-orders.json`. An order that cannot be proven cancelled, or that filled, fails the run with `PERPS_MAINNET_ORDER_LEFT_OPEN`. More than 1x or $15, another account, a `current_price` override, Core, and `ensure_*` opening state stay refused.
21
+ - Extension `metamask.perps.set_network` (default `testnet`): `network=mainnet` switches Perps to mainnet for one run, and the run switches back to testnet at its end.
22
+
23
+ ### Changed
24
+
25
+ - Pin the coordinated provider runtime: protocol 0.37.0, recipe-runner 0.28.5, recipe-cli 0.12.0, adapter-sdk 0.8.5, adapter-node 0.1.11, adapter-rn 0.19.9, adapter-web 0.6.9 and agent-runtime 0.17.5. Planning uses the read-only policy hook, and approval survives a new run owner while substantive inputs stay bound. Plan JSON now includes `executionPlan` and the `preflight.recipe` step; command context includes `runtimeConfigPath`. Human plans list resolved libraries and describe the artifact directory as "resolved at run time".
26
+ - When capture interruption is the run's only failure, `run`/`call` report `CAPTURE_INTERRUPTED` as `retryable: true` with exit 4. The report links the partial video as "evidence incomplete (capture interrupted)", and proven acceptance criteria become `weak` with that note.
27
+ - A Mobile recording that stopped mid-run but kept a partial video now fails as `CAPTURE_INTERRUPTED`, with the video published and its manifest entry's `interruption`. Previously, the run passed with `recorder.interrupted`. This covers capture-helper 0.3.1 on the emulator mirror and on-device screenrecord. With no video kept, capture-helper fails as `RECORDING_FAILED`; interrupted on-device screenrecord fails as `RECORDER_INTERRUPTED`.
28
+ - `scripts/validate-release-report.mjs` requires `BLOCKED_ENVIRONMENT` and "evidence incomplete (capture interrupted)" when every cited run failed only on `CAPTURE_INTERRUPTED`. Retaining an interrupted attempt preserves a passing or product-failing retry's verdict.
29
+ - On mainnet, `close_orders` cancels only orders the run placed, on Mobile and in the Extension's fixture-policy lane; any other order is refused before dispatch with `PERPS_MAINNET_ORDER_REFUSED`.
30
+
31
+ ### Fixed
32
+
33
+ - Mobile launch readiness uses the shared adapter-rn matcher for physical Android bridge names. "Google Pixel 6a" matches the "Pixel 6a" device pin; whole-model boundaries and emulator rejection remain enforced. The published adapter-rn 0.19.7 dependency also applies this rule when checking the ready route.
34
+ - Extension: a kept slot profile in which Chrome had disabled MetaMask (for example `disable_reasons: [16777216]`, `DISABLE_UNSUPPORTED_DEVELOPER_EXTENSION`, in `Default/Secure Preferences` on Chrome for Testing 147) no longer blocks every launch with `ERR_BLOCKED_BY_CLIENT`. `--load-extension` re-registers the build but keeps a persisted disable reason, so the launch now stops the profile's browser, clears the reason for the harness extension only (in `Preferences` and `Secure Preferences`, other extensions untouched) and logs the reasons it cleared. The reopen path (`ensure-browser.sh`) uses the same heal, which before rewrote only `Preferences` and re-enabled every extension. When readiness still finds MetaMask blocked, it names the reason (`MetaMask is disabled in the slot profile: DISABLE_UNSUPPORTED_DEVELOPER_EXTENSION`) instead of reporting a Chrome error page.
35
+
5
36
  ## 0.87.0 - 2026-10-10
6
37
 
7
38
  ### Changed
@@ -25,6 +56,8 @@
25
56
  - Mobile `ui.wait_for expected=absent` no longer times out on a control that only a screen hidden in the navigation stack still mounts. The app's tree query also finds test IDs in screens a navigator keeps behind the focused one (the Activity screen's `activity-screen-aggregated-checkbox` under Perps home). A tree hit now counts as absent only when every match is on a provably hidden route (an inactive tab, or a stack screen that is neither modal-presented nor inheriting a modal presentation, with a screen above it that is not modal either) and a complete native observation, read within the wait's remaining time, does not show the test ID. The result then carries `hiddenRoute: true` and `nativeVisible: false`. A modal-family screen, and any screen under a sheet or transparent modal, is never treated as hidden, even when native accessibility omits its controls; a truncated or warned native observation, or a navigator the walk cannot read, keeps the control present. Present-waits are unchanged.
26
57
  - Extension `metamask.perps.read_snapshot` states that its `history` section is the funds ledger from `perpsGetUserHistory` (deposits, withdrawals and transfers), not trades or orders. Zero history items with trade rows on screen is expected; read those rows with `read_visible_state` activity.
27
58
 
59
+ - Contributors: test leftovers that only run in a sandbox no longer outlive the suites. A server a test started detached in its temp dir (the `mobile-cdp-reliability` fake Metro server, the prepared analytics collector) named nothing of it, so neither the process-group kill nor `ct_cleanup` found it, and some ran for days. `tests/contract/run.sh` now kills a detached leftover that names a test's sandbox when the test ends. After the run, it kills anything still running in a sandbox (working directory), names it on a `LEAK <test> (after the run)` line, and fails the run. Before starting, the contract runner and the unit run's global setup reap an earlier run that was killed outright, and a SIGKILLed contract runner's remover stops what still runs in its sandboxes before removing them. The unit teardown also stops processes running in its TMPDIR. Eight contract tests that replaced `ct_init`'s EXIT trap now end in `ct_cleanup`, and `contract-cleanup` fails a test that drops it.
60
+
28
61
  ## 0.86.2 - 2026-10-09
29
62
 
30
63
  ### Fixed
@@ -230,12 +230,9 @@ cleanup_stale_profile_locks
230
230
  rm -f "${AGENT_DIR}/browser.pid" "${AGENT_DIR}/extension.id" "${AGENT_DIR}/launcher.pid"
231
231
  echo "[cleanup] Reusing profile ${PROFILE}"
232
232
 
233
- # Clear crash recovery state + re-enable any previously-disabled extensions
234
- # (profile Preferences persists extensions.settings.<id>.state=0 after unsafe
235
- # shutdowns; --load-extension re-advertises the path but does not flip it back,
236
- # leaving MV3 service workers inert).
233
+ # Clear crash recovery state left by an unsafe shutdown.
237
234
  if [ -f "${PROFILE}/Default/Preferences" ]; then
238
- python3 - "${PROFILE}/Default/Preferences" 2>/dev/null <<'PY' && echo "[cleanup] Cleared crash recovery state + re-enabled extensions"
235
+ python3 - "${PROFILE}/Default/Preferences" 2>/dev/null <<'PY' && echo "[cleanup] Cleared crash recovery state"
239
236
  import json
240
237
  import sys
241
238
  pf = sys.argv[1]
@@ -243,14 +240,13 @@ prefs = json.load(open(pf))
243
240
  prefs['profile'] = prefs.get('profile', {})
244
241
  prefs['profile']['exit_type'] = 'Normal'
245
242
  prefs['profile']['exited_cleanly'] = True
246
- ext_settings = prefs.get('extensions', {}).get('settings', {})
247
- for ext_id, ext_prefs in ext_settings.items():
248
- if isinstance(ext_prefs, dict) and ext_prefs.get('state') != 1:
249
- ext_prefs['state'] = 1
250
- ext_prefs.pop('disable_reasons', None)
251
243
  json.dump(prefs, open(pf, 'w'))
252
244
  PY
253
245
  fi
246
+ # Re-enable MetaMask when the profile kept it disabled: --load-extension
247
+ # re-advertises the path but does not lift a persisted disable reason, leaving
248
+ # every extension page blocked and the MV3 service worker inert.
249
+ node "${SCRIPT_DIR}/profile-extension-state.cjs" --profile "${PROFILE}" --extension-dir "${EXTENSION}"
254
250
 
255
251
  # Readiness gate: wait until webpack emits a loadable background entry before
256
252
  # freezing dist/chrome. The entry differs by manifest version — MV3 emits
@@ -96,6 +96,7 @@ fs.rmSync(path.join(harnessDir, 'runner/library.json'), { force: true });
96
96
  // package's source layout.
97
97
  copyFile(path.join(runnerDir, 'adapters/extension/launch-browser.cjs'), path.join(harnessDir, 'scripts/launch-browser.cjs'));
98
98
  copyFile(path.join(runnerDir, 'adapters/extension/build-handoff.cjs'), path.join(harnessDir, 'scripts/build-handoff.cjs'));
99
+ copyFile(path.join(runnerDir, 'adapters/extension/profile-extension-state.cjs'), path.join(harnessDir, 'scripts/profile-extension-state.cjs'));
99
100
  copyFile(path.join(runnerDir, 'adapters/extension/console-tail.mjs'), path.join(harnessDir, 'scripts/console-tail.mjs'));
100
101
  copyFile(path.join(runnerDir, 'adapters/extension/launch.sh'), path.join(harnessDir, 'scripts/launch.sh'));
101
102
  copyFile(path.join(runnerDir, 'adapters/extension/live.sh'), path.join(harnessDir, 'scripts/live.sh'));
@@ -4,7 +4,9 @@
4
4
  // Runs @farmslot/adapter-web's launchBrowser with what MetaMask owns: the
5
5
  // build-handoff lock (no build swaps the dist mid-launch), home.html and its
6
6
  // "MetaMask" title, the harness runtime layouts that prove a browser is ours by
7
- // its --load-extension path, and the mm-harness rerun command.
7
+ // its --load-extension path, and the mm-harness rerun command. A real launch
8
+ // into a kept profile first clears a stale disable mark Chrome left on MetaMask
9
+ // (profile-extension-state.cjs), which --load-extension alone does not lift.
8
10
  //
9
11
  // Inputs (flags, all required unless noted):
10
12
  // --cdp-port <port> --chrome-bin <path> --profile <dir>
@@ -21,6 +23,7 @@
21
23
  const path = require('node:path');
22
24
  const { launchBrowser } = require('@farmslot/adapter-web/launch-browser');
23
25
  const { acquireBuildHandoff } = require('./build-handoff.cjs');
26
+ const { clearExtensionDisable, extensionDisableReasons } = require('./profile-extension-state.cjs');
24
27
 
25
28
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
26
29
  console.log(
@@ -33,7 +36,7 @@ const args = parseArgs(process.argv.slice(2));
33
36
  const cdpPort = Number(args['cdp-port']);
34
37
  // Checked here so the error echoes the flag as typed.
35
38
  if (!Number.isInteger(cdpPort) || cdpPort <= 0) throw new Error(`Invalid --cdp-port: ${args['cdp-port'] || ''}`);
36
- launchBrowser({
39
+ const options = {
37
40
  cdpPort,
38
41
  chromeBin: args['chrome-bin'],
39
42
  profile: args.profile,
@@ -48,11 +51,35 @@ launchBrowser({
48
51
  homePage: 'home.html',
49
52
  defaultTitle: 'MetaMask',
50
53
  extensionOwnerRoot: harnessOwnerRootFromExtensionDir,
51
- acquireRuntimeLock: acquireBuildHandoff,
54
+ acquireRuntimeLock: acquireLaunchLock,
52
55
  rerunCommand: 'mm-harness launch --adapter extension --build',
53
56
  // Unset or invalid falls back to the package default (1000 ms).
54
57
  focusSettleMs: Number(process.env.MM_HARNESS_FOCUS_SETTLE_MS),
55
- });
58
+ };
59
+ launchBrowser(options);
60
+
61
+ // The build-handoff lock, taken once launchBrowser has checked its inputs and
62
+ // before it stops or starts a browser. A real launch into a profile that keeps
63
+ // MetaMask disabled first releases the profile through launchBrowser's own
64
+ // ownership checks (Chrome rewrites its prefs from memory on exit), then clears
65
+ // the mark. The lock is already held, so that stop does not take it again.
66
+ function acquireLaunchLock(runtimeDir) {
67
+ const release = acquireBuildHandoff(runtimeDir);
68
+ try {
69
+ if (
70
+ !options.stopOnly &&
71
+ !options.resetProfile &&
72
+ extensionDisableReasons(options.profile, options.extensionDir).length > 0
73
+ ) {
74
+ launchBrowser({ ...options, stopOnly: true, acquireRuntimeLock: undefined });
75
+ clearExtensionDisable(options.profile, options.extensionDir);
76
+ }
77
+ } catch (error) {
78
+ release();
79
+ throw error;
80
+ }
81
+ return release;
82
+ }
56
83
 
57
84
  function parseArgs(argv) {
58
85
  const parsed = {};
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ // profile-extension-state.cjs — the harness extension's disable mark in a slot profile
3
+ //
4
+ // Chrome keeps extensions.settings.<id>.disable_reasons across relaunches, and
5
+ // --load-extension re-registers the unpacked build without clearing them, so a
6
+ // reason recorded once (for example DISABLE_UNSUPPORTED_DEVELOPER_EXTENSION)
7
+ // blocks every MetaMask page (ERR_BLOCKED_BY_CLIENT) on each later launch.
8
+ // Chrome stores extensions.settings in Default/Secure Preferences or
9
+ // Default/Preferences depending on the version, so both are read.
10
+ //
11
+ // clearExtensionDisable drops disable_reasons and a non-enabled legacy state
12
+ // from this extension's entry only (matched by its manifest-key id or its load
13
+ // path); other extensions are never touched. A file is rewritten atomically and
14
+ // only when it changes; a missing or unreadable file is left alone. Chrome must
15
+ // not be running on the profile: it rewrites its prefs from memory on exit. The
16
+ // Secure Preferences MAC is left stale; Chrome accepts the edited entry.
17
+ //
18
+ // CLI: profile-extension-state.cjs --profile <dir> --extension-dir <dist>
19
+ // Clears and prints one line when it did. Exit 0 — cleared or nothing to clear;
20
+ // non-zero — bad args or an unwritable prefs file.
21
+ 'use strict';
22
+
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+ const { extensionIdFromExtensionDir } = require('@farmslot/adapter-web/extension-id');
26
+
27
+ const PREFS_FILES = ['Secure Preferences', 'Preferences'];
28
+
29
+ // Names from Chromium extensions/browser/disable_reason.h (Chrome 147).
30
+ const DISABLE_REASONS = {
31
+ [1 << 0]: 'DISABLE_USER_ACTION',
32
+ [1 << 1]: 'DISABLE_PERMISSIONS_INCREASE',
33
+ [1 << 2]: 'DISABLE_RELOAD',
34
+ [1 << 3]: 'DISABLE_UNSUPPORTED_REQUIREMENT',
35
+ [1 << 4]: 'DISABLE_SIDELOAD_WIPEOUT',
36
+ [1 << 5]: 'DEPRECATED_DISABLE_UNKNOWN_FROM_SYNC',
37
+ [1 << 8]: 'DISABLE_NOT_VERIFIED',
38
+ [1 << 9]: 'DISABLE_GREYLIST',
39
+ [1 << 10]: 'DISABLE_CORRUPTED',
40
+ [1 << 11]: 'DISABLE_REMOTE_INSTALL',
41
+ [1 << 13]: 'DISABLE_EXTERNAL_EXTENSION',
42
+ [1 << 14]: 'DISABLE_UPDATE_REQUIRED_BY_POLICY',
43
+ [1 << 15]: 'DISABLE_CUSTODIAN_APPROVAL_REQUIRED',
44
+ [1 << 16]: 'DISABLE_BLOCKED_BY_POLICY',
45
+ [1 << 19]: 'DISABLE_REINSTALL',
46
+ [1 << 20]: 'DISABLE_NOT_ALLOWLISTED',
47
+ [1 << 21]: 'DEPRECATED_DISABLE_NOT_ASH_KEEPLISTED',
48
+ [1 << 22]: 'DISABLE_PUBLISHED_IN_STORE_REQUIRED_BY_POLICY',
49
+ [1 << 23]: 'DISABLE_UNSUPPORTED_MANIFEST_VERSION',
50
+ [1 << 24]: 'DISABLE_UNSUPPORTED_DEVELOPER_EXTENSION',
51
+ [1 << 25]: 'DISABLE_UNKNOWN',
52
+ [1 << 26]: 'DISABLE_BLOCKED_BY_CLOUD_POLICY_CHECK',
53
+ };
54
+
55
+ // Chrome 147 stores a list of single-bit reasons; older builds one bitmask.
56
+ function disableReasonNames(value) {
57
+ const names = [];
58
+ for (const reason of Array.isArray(value) ? value : [value]) {
59
+ if (!Number.isInteger(reason)) continue;
60
+ for (let bit = 0; bit < 31; bit += 1) {
61
+ const flag = 1 << bit;
62
+ if (reason & flag) names.push(DISABLE_REASONS[flag] || `DISABLE_REASON_${flag}`);
63
+ }
64
+ }
65
+ return names;
66
+ }
67
+
68
+ // Why this entry is disabled; empty when it is not.
69
+ function entryDisableNames(entry) {
70
+ const names = disableReasonNames(entry.disable_reasons);
71
+ if (entry.state !== undefined && entry.state !== 1) names.push(`state=${JSON.stringify(entry.state)}`);
72
+ return names;
73
+ }
74
+
75
+ function readPrefs(file) {
76
+ try {
77
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ // This extension's entries in one prefs document, with why each is disabled.
84
+ function ownDisabledEntries(prefs, extensionDir) {
85
+ const settings = prefs?.extensions?.settings;
86
+ if (!settings || typeof settings !== 'object') return [];
87
+ const extensionId = extensionIdFromExtensionDir(extensionDir);
88
+ const loadPath = path.resolve(extensionDir);
89
+ return Object.entries(settings)
90
+ .filter(([id, entry]) =>
91
+ entry &&
92
+ typeof entry === 'object' &&
93
+ ((extensionId && id === extensionId) ||
94
+ (typeof entry.path === 'string' && path.isAbsolute(entry.path) && path.resolve(entry.path) === loadPath)))
95
+ .map(([id, entry]) => ({ id, entry, names: entryDisableNames(entry) }))
96
+ .filter(({ names }) => names.length > 0);
97
+ }
98
+
99
+ function prefsFiles(profile) {
100
+ return PREFS_FILES.map((name) => path.join(profile, 'Default', name));
101
+ }
102
+
103
+ /** Why the profile keeps this extension disabled; empty when it does not. */
104
+ function extensionDisableReasons(profile, extensionDir) {
105
+ const names = prefsFiles(profile).flatMap((file) =>
106
+ ownDisabledEntries(readPrefs(file), extensionDir).flatMap(({ names: entryNames }) => entryNames));
107
+ return [...new Set(names)];
108
+ }
109
+
110
+ /** Re-enable this extension in a stopped profile; returns the reasons cleared. */
111
+ function clearExtensionDisable(profile, extensionDir, log = (line) => process.stderr.write(`${line}\n`)) {
112
+ const cleared = new Set();
113
+ const ids = new Set();
114
+ for (const file of prefsFiles(profile)) {
115
+ const prefs = readPrefs(file);
116
+ const disabled = ownDisabledEntries(prefs, extensionDir);
117
+ if (disabled.length === 0) continue;
118
+ for (const { id, entry, names } of disabled) {
119
+ delete entry.disable_reasons;
120
+ if (entry.state !== 1) delete entry.state;
121
+ ids.add(id);
122
+ for (const name of names) cleared.add(name);
123
+ }
124
+ const tmp = `${file}.${process.pid}.tmp`;
125
+ fs.writeFileSync(tmp, JSON.stringify(prefs), { mode: fs.statSync(file).mode & 0o777 });
126
+ fs.renameSync(tmp, file);
127
+ }
128
+ if (cleared.size > 0) {
129
+ log(`[profile] Re-enabled MetaMask (${[...ids].join(', ')}) in ${profile}; cleared: ${[...cleared].join(', ')}`);
130
+ }
131
+ return [...cleared];
132
+ }
133
+
134
+ module.exports = { clearExtensionDisable, disableReasonNames, extensionDisableReasons };
135
+
136
+ if (require.main === module) {
137
+ const usage = 'Usage: profile-extension-state.cjs --profile <dir> --extension-dir <dist>';
138
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
139
+ console.log(usage);
140
+ process.exit(0);
141
+ }
142
+ const args = {};
143
+ const argv = process.argv.slice(2);
144
+ for (let i = 0; i < argv.length; i += 2) {
145
+ if (!['--profile', '--extension-dir'].includes(argv[i]) || i + 1 >= argv.length) {
146
+ console.error(usage);
147
+ process.exit(2);
148
+ }
149
+ args[argv[i].slice(2)] = argv[i + 1];
150
+ }
151
+ if (!args.profile || !args['extension-dir']) {
152
+ console.error(usage);
153
+ process.exit(2);
154
+ }
155
+ clearExtensionDisable(args.profile, args['extension-dir'], (line) => console.log(line));
156
+ }
@@ -366,13 +366,32 @@ function findPageTarget(targets, selectedExtensionId, preferredPagePath = '') {
366
366
  return extensionPages.reduce((best, current) => (score(current) > score(best) ? current : best), extensionPages[0]);
367
367
  }
368
368
 
369
+ // A disable reason the slot profile kept on MetaMask blocks every extension
370
+ // page (ERR_BLOCKED_BY_CLIENT); name it instead of the symptom. The profile is
371
+ // the one the launch recorded for this browser.
372
+ function blockedExtensionMessage(target, symptom) {
373
+ let reasons = [];
374
+ try {
375
+ const record = JSON.parse(fs.readFileSync(path.join(target, recipeRuntimeDir(), 'browser-resolution.json'), 'utf8'));
376
+ const profile = record?.launch?.profile;
377
+ if (typeof profile === 'string' && profile) {
378
+ const { extensionDisableReasons } = require('./profile-extension-state.cjs');
379
+ reasons = extensionDisableReasons(profile, extensionBuildRoot(target));
380
+ }
381
+ } catch {
382
+ return symptom;
383
+ }
384
+ if (reasons.length === 0) return symptom;
385
+ return `MetaMask is disabled in the slot profile: ${reasons.join(', ')}. Next: mm-harness launch --adapter extension --build`;
386
+ }
387
+
369
388
  async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceWorker, extensionPagePath) {
370
389
  const version = await httpJson(`http://127.0.0.1:${cdpPort}/json/version`);
371
390
  let targets = await httpJson(`http://127.0.0.1:${cdpPort}/json/list`);
372
391
  if (!Array.isArray(targets)) throw new Error('/json/list did not return an array');
373
392
  let { extensionIds, selectedExtensionId } = chooseExtensionId(targets, expectedExtensionId, expectedServiceWorker);
374
393
  if (!selectedExtensionId) {
375
- throw new Error('CDP is reachable but no chrome-extension:// targets are present');
394
+ throw new Error(blockedExtensionMessage(target, 'CDP is reachable but no chrome-extension:// targets are present'));
376
395
  }
377
396
  let pageTarget = findPageTarget(targets, selectedExtensionId, extensionPagePath);
378
397
  let openedPage = false;
@@ -422,7 +441,7 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
422
441
  ui.hasStartupError ||
423
442
  ui.hasErrorBoundary
424
443
  ) {
425
- throw Object.assign(new Error('MetaMask extension page loaded Chrome error UI'), {
444
+ throw Object.assign(new Error(blockedExtensionMessage(target, 'MetaMask extension page loaded Chrome error UI')), {
426
445
  report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } },
427
446
  });
428
447
  }
@@ -250,6 +250,14 @@
250
250
  "inputs": "--cdp-port --chrome-bin --profile --extension-dir --chrome-log --chrome-pid [--browser-resolution] [--start-url] [--stop-only 1] [--reset-profile 1]",
251
251
  "outputs": "pid file + append-log + <runtime>/browser-resolution.json; exit 0 / non-zero"
252
252
  },
253
+ {
254
+ "id": "extension/profile-extension-state",
255
+ "entry": "adapters/extension/profile-extension-state.cjs",
256
+ "kind": "node",
257
+ "purpose": "Clear a stale Chrome disable mark (disable_reasons / non-enabled state) on the harness extension only, in a stopped slot profile's Preferences and Secure Preferences; decode the reasons for readiness errors.",
258
+ "inputs": "--profile --extension-dir",
259
+ "outputs": "rewrites <profile>/Default/{Preferences,Secure Preferences} only when the extension was disabled; one line naming the cleared reasons; exit 0 / non-zero"
260
+ },
253
261
  {
254
262
  "id": "extension/refresh-build",
255
263
  "entry": "adapters/extension/refresh-build.sh",
@@ -14,6 +14,7 @@ const {
14
14
  describeRequested,
15
15
  describeTargets,
16
16
  hasMatchingRoute,
17
+ manufacturerNameMatches,
17
18
  } = require('@farmslot/adapter-rn/bridge-runtime/lib/match-bridge-target.cjs');
18
19
  const {
19
20
  rankRuntimeCandidates,
@@ -179,9 +180,14 @@ function snapshotMatchesRequest(snapshot, platform, env) {
179
180
  if (snapshot.platform && snapshot.platform !== 'android') return false;
180
181
  const androidName =
181
182
  env.ANDROID_TARGET_DEVICE_NAME || env.ANDROID_DEVICE || '';
182
- return androidName
183
- ? deviceName === androidName || deviceName.startsWith(`${androidName} -`)
184
- : snapshot.platform === 'android';
183
+ const adbSerial = env.ADB_SERIAL || env.ANDROID_SERIAL || '';
184
+ if (!androidName) return snapshot.platform === 'android';
185
+ if (deviceName === androidName || deviceName.startsWith(`${androidName} -`)) {
186
+ return true;
187
+ }
188
+ // Metro names the target "Pixel 6a - 17 - API 37", but the bridge snapshot
189
+ // of a physical device carries the manufacturer: "Google Pixel 6a".
190
+ return manufacturerNameMatches(deviceName, androidName, adbSerial);
185
191
  }
186
192
  return !platform;
187
193
  }
@@ -20,7 +20,8 @@ const extensionActions = {
20
20
  "metamask.perps.update_position_tpsl",
21
21
  "metamask.perps.assert_visible_consistency",
22
22
  "metamask.settings.set_basic_functionality",
23
- "metamask.perps.read_snapshot"
23
+ "metamask.perps.read_snapshot",
24
+ "metamask.perps.set_network"
24
25
  ],
25
26
  liveNeedsCdpPort: true,
26
27
  cdpTarget: { transport: "chrome-extension-cdp", probePath: "json/version" },
@@ -45,6 +45,12 @@ const extensionRun = {
45
45
  return async () => refuseLockedSession(json, options) ?? ensureExtensionProofRuntime(target, heal, state, json, { onRecovery });
46
46
  },
47
47
  launchedBrowser: (target, artifactsDir, cdpPort) => launchedBrowser(target, artifactsDir, cdpPort),
48
+ // Cancel the mainnet limit orders the run opened and switch Perps back to
49
+ // testnet after a start_state network=mainnet; a failure fails the run.
50
+ async teardown(projectRoot, env) {
51
+ const { cancelRunMainnetOrders } = await import("../perps-mainnet-cleanup.js");
52
+ await cancelRunMainnetOrders(projectRoot, env, "teardown");
53
+ },
48
54
  browserProvenance: (browser) => ({
49
55
  browser: browser.browser,
50
56
  browser_version: browser.version,
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createAndroidVideoRecorder } from "@farmslot/adapter-rn";
5
5
  import { optionalVideoTiming } from "@farmslot/recipe-runner";
6
- import { interruptedRecorder, recordingFailure } from "./recording-outcome.js";
6
+ import { recordingFailure } from "./recording-outcome.js";
7
7
  const SEGMENT_SECONDS = 180;
8
8
  const START_TIMEOUT_MS = 5e3;
9
9
  const STOP_TIMEOUT_MS = 1e4;
@@ -196,11 +196,15 @@ async function startRecording(serial, request, adb, options) {
196
196
  return {
197
197
  ...timing.timing ? { timing: { ...timing.timing, clock: { ...timing.timing.clock, latestZeroUnixMs: latestVideoZero(timing.timing, placed) } } } : timing,
198
198
  timingEvidencePath,
199
- recorder: interruption ? interruptedRecorder(
200
- recorder,
201
- interruption,
202
- timing.timing ? { coveredMs: [0, timing.timing.framesMs.at(-1)] } : { coverageUnavailableReason: timing.timingUnavailableReason ?? "video timing unavailable" }
203
- ) : recorder
199
+ recorder,
200
+ // The runner keeps the files finished before the failure and fails the run as CAPTURE_INTERRUPTED.
201
+ ...interruption ? {
202
+ interruption: {
203
+ frames: timing.timing?.framesMs.length ?? 0,
204
+ mediaTimeMs: timing.timing?.framesMs.at(-1) ?? 0,
205
+ cause: interruption
206
+ }
207
+ } : {}
204
208
  };
205
209
  } finally {
206
210
  abandon();
@@ -1,34 +1,8 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { errorMessage, manifestTarget, readCaptureHelperTiming, RecipeExecutionError } from "@farmslot/recipe-runner";
1
+ import { errorMessage, RecipeExecutionError } from "@farmslot/recipe-runner";
4
2
  function recordingFailure(code, cause) {
5
3
  const reason = cause.trim().replace(/[\s.:]+$/u, "");
6
4
  return new RecipeExecutionError("environment", `${code}: ${reason}. The run has no video.`, { code, details: { cause: reason } });
7
5
  }
8
- function interruptedRecorder(recorder, cause, coverage) {
9
- const kept = "coveredMs" in coverage ? `${(coverage.coveredMs[0] / 1e3).toFixed(1)}-${(coverage.coveredMs[1] / 1e3).toFixed(1)} s` : `coverage unknown: ${coverage.coverageUnavailableReason}`;
10
- process.stderr.write(`WARN: RECORDER_INTERRUPTED (${cause}): kept the partial video, ${kept}.
11
- `);
12
- const entry = { ...recorder, interrupted: { code: "RECORDER_INTERRUPTED", cause, ...coverage } };
13
- return entry;
14
- }
15
- function streamInterruption(text) {
16
- for (const line of text.split("\n")) {
17
- if (!line.includes("stream_interrupted")) continue;
18
- try {
19
- const event = JSON.parse(line.slice(line.indexOf("{"), line.lastIndexOf("}") + 1));
20
- if (event.type !== "error" || event.code !== "stream_interrupted") continue;
21
- const mediaTimeMs = typeof event.media_time_ms === "number" && event.media_time_ms >= 0 ? event.media_time_ms : void 0;
22
- return {
23
- cause: `stream_interrupted: ${typeof event.cause === "string" ? event.cause : "capture stream stopped"}`,
24
- ...mediaTimeMs !== void 0 ? { mediaTimeMs } : {}
25
- };
26
- } catch {
27
- continue;
28
- }
29
- }
30
- return void 0;
31
- }
32
6
  function withRecordingOutcomes(recorder) {
33
7
  return {
34
8
  name: recorder.name,
@@ -64,21 +38,7 @@ function withRecordingOutcomes(recorder) {
64
38
  try {
65
39
  return await active.stop();
66
40
  } catch (error) {
67
- const known = coded(error);
68
- if (known) throw known;
69
- const interruption = streamInterruption(errorMessage(error));
70
- if (!interruption) throw recordingFailure("RECORDING_FAILED", errorMessage(error));
71
- if (!hasBytes(request.outputPath)) throw recordingFailure("RECORDER_INTERRUPTED", interruption.cause);
72
- const timing = await readCaptureHelperTiming(request.outputPath);
73
- return {
74
- ...timing,
75
- ...timing.timing ? { timingEvidencePath: `${path.basename(request.outputPath)}.timing.json` } : {},
76
- recorder: interruptedRecorder(
77
- { name: "capture-helper", platform: recorder.platform, target: manifestTarget(request.target) },
78
- interruption.cause,
79
- interruption.mediaTimeMs !== void 0 ? { coveredMs: [0, interruption.mediaTimeMs] } : { coverageUnavailableReason: "capture-helper reported no media time" }
80
- )
81
- };
41
+ throw coded(error) ?? recordingFailure("RECORDING_FAILED", errorMessage(error));
82
42
  }
83
43
  }
84
44
  };
@@ -88,16 +48,7 @@ function withRecordingOutcomes(recorder) {
88
48
  function coded(error) {
89
49
  return error instanceof RecipeExecutionError && typeof error.code === "string" ? error : void 0;
90
50
  }
91
- function hasBytes(file) {
92
- try {
93
- return fs.statSync(file).size > 0;
94
- } catch {
95
- return false;
96
- }
97
- }
98
51
  export {
99
- interruptedRecorder,
100
52
  recordingFailure,
101
- streamInterruption,
102
53
  withRecordingOutcomes
103
54
  };
@@ -61,11 +61,17 @@ const mobileRun = {
61
61
  // An installed release artifact is opaque: it has no dev bridge and no
62
62
  // AgentStepHud, so there is nothing to draw the HUD with.
63
63
  autoHud: () => process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME !== "1",
64
- // Always clear a HUD step the engine left on-device (a failed run strands
65
- // the FAIL banner). Best-effort — never masks the run's real outcome.
64
+ // Cancel the mainnet limit orders the run opened (a failure fails the run),
65
+ // and always clear a HUD step the engine left on-device (a failed run strands
66
+ // the FAIL banner; best-effort, never masks the run's real outcome).
66
67
  async teardown(projectRoot, env) {
67
- const { hideMobileHudOnTeardown } = await import("./ui.js");
68
- await hideMobileHudOnTeardown(projectRoot, env);
68
+ try {
69
+ const { cancelRunMainnetOrders } = await import("../perps-mainnet-cleanup.js");
70
+ await cancelRunMainnetOrders(projectRoot, env, "teardown");
71
+ } finally {
72
+ const { hideMobileHudOnTeardown } = await import("./ui.js");
73
+ await hideMobileHudOnTeardown(projectRoot, env);
74
+ }
69
75
  },
70
76
  // A release artifact is checked before the overlay install; the loaded
71
77
  // source only matters for a development build the caller did not restart.