@deeeed/metamask-harness 0.86.2 → 0.88.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 +49 -0
- package/adapters/extension/ensure-browser.sh +6 -10
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +31 -4
- package/adapters/extension/profile-extension-state.cjs +156 -0
- package/adapters/extension/readiness.mjs +21 -2
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/wait-for-bridge.cjs +9 -3
- package/dist/adapters/extension/actions.js +2 -1
- package/dist/adapters/extension/run.js +6 -0
- package/dist/adapters/mobile/action-inputs.js +20 -0
- package/dist/adapters/mobile/actions.js +18 -1
- package/dist/adapters/mobile/android-screenrecord.js +274 -0
- package/dist/adapters/mobile/readiness.js +2 -1
- package/dist/adapters/mobile/recording-outcome.js +54 -0
- package/dist/adapters/mobile/recording-target.js +11 -0
- package/dist/adapters/mobile/run.js +58 -15
- package/dist/adapters/mobile/ui.js +96 -2
- package/dist/adapters/perps-mainnet-cleanup.js +122 -0
- package/dist/adapters/perps-order-inputs.js +43 -28
- package/dist/adapters.js +4 -1
- package/dist/commands/doctor.js +33 -14
- package/dist/commands/status-probe.js +2 -1
- package/dist/engine.js +11 -2
- package/dist/funding-execution-context.js +13 -8
- package/dist/host.js +6 -2
- package/dist/index.js +3 -0
- package/dist/mm-harness-cli.js +4 -2
- package/dist/provider.js +72 -0
- package/dist/recipe-security.js +1 -0
- package/docs/CONTRIBUTING.md +7 -0
- package/docs/RECIPES.md +96 -9
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
- package/docs/cli-contract.md +1 -1
- package/library/actions/core/perps/read_account.mjs +4 -2
- package/library/actions/core/perps/read_orders.mjs +4 -2
- package/library/actions/core/perps/read_positions.mjs +4 -2
- package/library/actions/extension/analytics/consent.mjs +7 -2
- package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
- package/library/actions/extension/perps/mutation-receipt.mjs +124 -26
- package/library/actions/extension/perps/perps.mjs +298 -39
- package/library/actions/extension/perps/read_snapshot.mjs +6 -2
- package/library/actions/extension/perps/set_network.mjs +6 -0
- package/library/actions/extension/platform/cdp.mjs +6 -2
- package/library/actions/extension/ui/navigate.mjs +8 -0
- package/library/actions/mobile/perps/perps.mjs +264 -102
- package/library/actions/mobile/perps/place_order.mjs +7 -4
- package/library/actions/mobile/platform/bridge.mjs +171 -10
- package/library/actions/mobile/wallet/home.mjs +7 -2
- package/library/actions/mobile/wallet/select_account.mjs +4 -0
- package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
- package/library/actions/shared/perps/mainnet-order-ledger.mjs +378 -0
- package/library/actions/shared/perps/order-policy.mjs +56 -19
- package/library/actions/shared/perps/rate-limit.mjs +93 -0
- package/library/actions/shared/perps/visible-state.mjs +3 -2
- package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
- package/library/manifests/extension.action-manifest.json +36 -5
- package/library/manifests/mobile.action-manifest.json +48 -10
- package/package.json +14 -9
- package/scripts/validate-release-report.mjs +14 -2
- package/site/assets/help-recipes.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.88.0 - 2026-10-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- 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".
|
|
16
|
+
- 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.
|
|
17
|
+
- 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`.
|
|
18
|
+
- `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.
|
|
19
|
+
- 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`.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- 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.
|
|
24
|
+
- 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.
|
|
25
|
+
|
|
26
|
+
## 0.87.0 - 2026-10-10
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- `--record-video=full-run` on a physical Android device records on the device with `adb shell screenrecord`, not a scrcpy window captured through capture-helper. macOS stopped the capture-helper stream whenever another capture-helper process exited (Farmslot's browser liveness check, `mm-harness doctor`, another slot's recording), so a Pixel 6a release run lost its full-run video with ScreenCaptureKit error -3805. screenrecord ends each file at 180 s: the recorder starts the next file as soon as one ends and joins them with ffmpeg, so a physical Android recording now needs ffmpeg at start (`FFMPEG_PATH` overrides). The roughly half-second gap at each restart is kept as a held frame, so video time stays on wall-clock time and the recording timeline stays tight; `videos/recipe-run.mp4.capture.json` lists each file's start, stop and offset. If the file being stopped at the end of the run fails (stopped while screenrecord was still starting), the earlier files are kept. Device files and pulled host files are removed and the device and host recorder processes stopped when the run ends, fails, or the harness gets SIGINT, SIGTERM or SIGHUP. The recipe HUD is drawn in the app, so it is in the video. Screens the app marks secure (FLAG_SECURE) record black, as with any capture of the device screen. Emulators keep the mirror. `METAMASK_RECIPE_MOBILE_ANDROID_RECORDER=screenrecord|mirror` forces either recorder (`mirror` keeps its single-file screenrecord fallback when the mirror is unavailable); any other value fails the run.
|
|
31
|
+
- A Mobile recording that fails, or whose recorder cannot record when the run starts, now fails the run with a code. The `record.video` trace entry reads `RECORDING_FAILED: <cause>. The run has no video.` or, when capture stopped mid-run with nothing kept, `RECORDER_INTERRUPTED: <cause>. The run has no video.`, with `cause_class: "environment"`. Before, it carried the recorder's raw message (for capture-helper 0.3.0 an empty `capture-helper record failed (exit 1):`). A recording that stopped mid-run but kept a finalized partial file is published, and its manifest entry gains `recorder.interrupted` (`code: "RECORDER_INTERRUPTED"`, `cause`, and `coveredMs`, the video time up to the last captured frame, or `coverageUnavailableReason` when that cannot be measured); the run also prints a `WARN: RECORDER_INTERRUPTED` line. This covers capture-helper 0.3.1's `stream_interrupted` event on the emulator mirror (the MP4 and timing sidecar it finalized are kept; `cause` names the ScreenCaptureKit error, such as `SCStreamErrorDomain -3805`) and an on-device screenrecord that fails mid-run (the files finished before it are kept).
|
|
32
|
+
- `mm-harness doctor`, `doctor --expect-live`, `--print-ready` and `doctor --fix` on Mobile check the recorder `run --record-video` would use for the selected device once the runtime is ready, and show it (`recorder` in `--json`, a `recorder:` line otherwise). A recorder that cannot record (no ffmpeg for on-device recording, capture-helper or scrcpy unusable with no fallback, simulator not booted) makes doctor report `ready: false` with that recorder's next step: `DOCTOR_CHECKS_FAILED` from `doctor`, `RECORDER_UNAVAILABLE` from `--expect-live --json`, `not-ready` from `--print-ready`, and `recorder` in `failed` from `--fix`. Before, doctor reported ready while every recording failed.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `mm-harness run --plan` now rejects a `metamask.perps.start_state` node that combines a Mobile `trading_state` with a `positions`, `orders` or `strategies` request other than `false`, or whose `positions` or `orders` block opens a position or order without a market and side (on the node or in the block) and an `amount` or `notional` in the block. Unresolved templates are still checked at run time.
|
|
37
|
+
- Extension `metamask.analytics.set_consent` reads marketing consent from `optedInToMarketing` when the state has no `dataCollectionForMarketing` (Extension 13.51+), so its readback no longer fails with "Extension consent state did not contain boolean consent fields before the deadline".
|
|
38
|
+
- Extension `ui.navigate page=perps` names a Perps home still loading at the deadline, with loading markers listed first in its visible test IDs: "Perps home stayed on its loading view (perps-view-loading visible) within the <n>ms budget" and "Next: check the Perps stream/provider, or raise timeout_ms".
|
|
39
|
+
- Mobile Perps `start_state` and `teardown_state` keep the setup budget apart from bridge command limits. Before, one deadline covered unlock, account, navigation and cleanup, and every nested bridge command could use all of what was left. On a physical Pixel 6a, a market-details to home navigation took about 18 s of a 30 s clean-market setup. Later commands were cut to slivers (`press-test-id` after 1258 ms, `eval-async` after 315 ms) or refused, and every one was reported as `CDP_TIMEOUT`, which reads as a bridge failure. Now each phase runs on its own deadline inside the setup budget. Navigation phases get a slice (`navigation_timeout_ms`), and each bridge command gets its own limit (`cdp_timeout_ms`, default 15 s) capped by the phase deadline. A phase that runs out fails as `DOMAIN_SETUP_DEADLINE`, with the phase, its elapsed time and limit, and the setup's elapsed time and limit. A bridge command its action deadline refused or stopped fails as `ACTION_DEADLINE`, not `CDP_TIMEOUT`. Route waits, `ensure_mode` and account switching treat it as no answer yet: a route or mode wait still ends with its own timeout message, and an account switch the deadline cut short is reported as ambiguous. A command that hits its own limit keeps `CDP_TIMEOUT`, and its message now says `CDP_COMMAND_TIMEOUT` and gives the limit.
|
|
40
|
+
- Mobile setup and navigation budgets depend on the device class. Emulators and simulators keep 30 s for setup and 15 s per navigation. A physical Android device (explicit non-`emulator-*` adb serial) gets 60 s and 30 s. `MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS` and `MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS` override the defaults, and node `timeout_ms` and `navigation_timeout_ms` override both. The setups' live process timeout is raised to the setup budget plus 5 s, so the process timeout no longer races a 60 s setup. `start_state` and `teardown_state` schemas accept `navigation_timeout_ms` and `cdp_timeout_ms`.
|
|
41
|
+
- A Hyperliquid `429 Too Many Requests` in a Perps action is now the typed error `RATE_LIMITED`, naming the call, the attempts and the backoff used. Farm runs share the testnet account and egress IP, so the venue rate-limits them; on Extension 13.52.0 QA (testnet run 46848-1009-223034) the $12 SOL setup order failed with `429 Too Many Requests - null` and Recipe Cook stopped the case, though nothing was placed.
|
|
42
|
+
- Reads retry up to 3 times with exponential backoff and jitter (between half and all of 1 s, 2 s, 4 s), then fail `RATE_LIMITED` with `Next: wait a minute and rerun`. No retry starts unless its backoff and another attempt as long as the last one end before the action deadline, and a retry still unanswered at the deadline fails there. The deadline is the inherited deadline on Mobile, and 5 s before the runner kills the live adapter process (`liveAdapterProcessTimeoutMs`) on Extension and Core. This covers Extension position, order, account, market, size-step and snapshot reads and the `compare_provider_market` venue read, Mobile controller position, order and strategy-order reads, and Core `read_positions`, `read_orders` and `read_account`.
|
|
43
|
+
- Extension background `metamask.perps.place_order` retries a 429 only when the dispatch itself got it (no accepted order) and, after the backoff, Hyperliquid's own info API (read from the harness, not the extension) shows no open order and no fill in that market since the first dispatch (open orders read first, so an order filling between the reads still shows), and for a market order no position on its side. Up to 3 retries, and only while the backoff and those reads (5 s at most) leave the postcondition its `timeout_ms` and 5 s before the process limit. The retry's own pre-dispatch checks are bounded by that deadline too; a retry that would reach the venue after it is not sent, the receipt becomes `not-dispatched`, and the error is still `RATE_LIMITED`. Every retry reruns the pre-dispatch checks (unchanged funding balance, provider and network, CDP target, bound account); the budget cap and the order rule are the first dispatch's.
|
|
44
|
+
- Every 429 on a dispatch goes into the receipt under `rateLimitedDispatches`, with its backoff and venue verification or the reason it was not retried. Without a retry the receipt stays `outcome-unknown` and the error is `RATE_LIMITED` with that reason and still `Next: do not rerun this mutation`: a venue read that fails or shows activity, a HIP-3 market, spent retries, the deadline, a 429 on the postcondition read after dispatch (never redispatched), and every other Extension mutation. Mobile placements keep no receipt and are not retried.
|
|
45
|
+
- Mobile `metamask.perps.read_visible_state` counts the Perps home Recent activity rows (`perps-recent-activity-row-<n>`) and a market's recent trades rows (`perps-market-trades-row-<n>`) as activity. A populated home preview reported `activity: []` and failed `minimum_activity_count`.
|
|
46
|
+
- 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.
|
|
47
|
+
- 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.
|
|
48
|
+
|
|
49
|
+
- 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.
|
|
50
|
+
|
|
5
51
|
## 0.86.2 - 2026-10-09
|
|
6
52
|
|
|
7
53
|
### Fixed
|
|
@@ -15,6 +61,7 @@
|
|
|
15
61
|
|
|
16
62
|
### Fixed
|
|
17
63
|
|
|
64
|
+
- Mobile `metamask.perps.start_state` refuses `trading_state` combined with an explicit `positions`, `orders` or `strategies` request (anything but `false`). The policy sets all three itself, so the request was dropped without a word: `trading_state=preserve` with `positions: { state: "open", notional: 11, leverage: 2 }` passed with `positions: { skipped: true }` and opened nothing. A later isolated-margin prerequisite then read zero positions. The action now fails before unlock, naming the ignored keys. Omit `trading_state` to apply the request, or put the request in its own `start_state` node. Profile defaults are still replaced by the policy, and `false` is still accepted.
|
|
18
65
|
- Mobile route waits (`navigate` and `waitForRoute` in the action library) now end at the calling action's deadline. Before, once a Perps `start_state` or `teardown_state` ran out of budget, the bridge refused each new `get-route` with `CDP_TIMEOUT`. The waits took that refusal for a target transition and kept polling for up to 30 s more on their own 15 s windows. They could not see a route that did arrive, and failed with "did not start before its action deadline: get-route". On a physical Pixel 6a this showed up as `CDP_TIMEOUT` in clean-market setup and teardown, while one `get-route` took 0.1 to 0.5 s. Both now stop at the deadline. A route that never matched fails with the expected route, the last route read and the limit applied; a recovery cut short by the deadline fails with its last bridge error.
|
|
19
66
|
|
|
20
67
|
## 0.86.1 - 2026-10-09
|
|
@@ -24,6 +71,8 @@
|
|
|
24
71
|
- Extension `metamask.perps.close_visible_position cleanup_only=true` reconciles the final balance against the margin the close releases. With no placement receipt, the baseline is the available balance read while the position is still open. That baseline excludes the position's margin, so a clean full close went over the $1 tolerance and left an `outcome-unknown` receipt. A 0.11 SOL testnet close moved $524.42 to $536.45 and failed this way. The action now reads the close modal's "You'll receive" amount before dispatch and records it in the receipt as `closeReleaseBaseUnits`. It then checks the final balance against baseline + receive, with the same tolerance. If the amount is not visible, the action refuses before publishing a receipt or clicking. Closes backed by a placement receipt still reconcile against the pre-order balance. Cleanup output adds `releaseBaseUnits` and `expectedBalanceAfterBaseUnits`.
|
|
25
72
|
- Mobile `metamask.perps.read_visible_state` on iOS sees Perps Text test IDs. iOS accessibility lists React Native Text as a StaticText with no identifier, so a visible Lite market price (`perps-market-header-price`) failed `require_features: ["price"]`. On a non-opaque iOS run, `ui.visible` now reads each mounted `perps-` Text's test ID, string and size over the dev bridge. It gives that test ID to the on-screen StaticText with the same label and size, when exactly one test ID matches. Offscreen StaticText stays unbound, and so does a same-text twin of another size (the collapsed header price). Other Text, such as a revealed Secret Recovery Phrase word, is not read. This reaches every iOS `ui.visible` consumer: `ui.locators`, native-navigation test-ID waits, `readVisiblePerpsTestIds`, `configure_order`, and the assets and swap-bridge `read_visible_state`. Opaque runtimes and Android are unchanged. A non-opaque run with Metro down pays one bridge attempt (at most 2 s) per observation and gets a `ui.visible` warning; a read over 500 Texts is truncated with a warning.
|
|
26
73
|
- An empty `METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT` means the bundled `cdp-bridge.cjs` everywhere the TypeScript side spawns the Mobile bridge, as `run` and the action library already treated it. `launch`'s feature-flag reads and pins (and `doctor`'s) and the `status` live probe read the variable with `??`. An empty value made them check for a script named `""`, so they reported no runtime and never spawned a bridge. All TypeScript callers now share one `mobileBridgeScript()`; the action library keeps the same rule in its own `bridgeScript()` because it cannot import `src/`.
|
|
74
|
+
- Contributors: `tests/contract/run.sh` stopped by SIGTERM signals every running test at once, from one process listing, and its main loop no longer delays the trap by a `sleep`. A runner SIGKILLed 0.3 s after its SIGTERM used to leave most running tests (and their `timeout` parents) going until their own time limit, and `/tmp/contract-run.*` behind; now each test gets its SIGTERM and cleans up, and the directory goes once they have.
|
|
75
|
+
- Contributors: a unit run's teardown also stops what the processes naming its TMPDIR started (a wrapper's child need not name it), so it no longer orphans them. The `feature-guard-run-capture` timeout test times out only once bash has set its EXIT trap, instead of hoping bash starts within 3 s; it no longer fails on a loaded machine and runs in under a second.
|
|
27
76
|
|
|
28
77
|
## 0.86.0 - 2026-10-09
|
|
29
78
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
package/adapters/manifest.json
CHANGED
|
@@ -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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const POLICY_KEYS = ["positions", "orders", "strategies"];
|
|
2
|
+
function isTemplate(value) {
|
|
3
|
+
return typeof value === "string" && value.includes("{{");
|
|
4
|
+
}
|
|
5
|
+
function mobileActionInputFindings(nodeId, node) {
|
|
6
|
+
const policy = node.trading_state;
|
|
7
|
+
if (node.action !== "metamask.perps.start_state" || typeof policy !== "string" || isTemplate(policy)) return [];
|
|
8
|
+
const dropped = POLICY_KEYS.filter((key) => node[key] !== void 0 && node[key] !== false && !isTemplate(node[key]));
|
|
9
|
+
if (dropped.length === 0) return [];
|
|
10
|
+
const keys = dropped.join(", ");
|
|
11
|
+
return [{
|
|
12
|
+
severity: "error",
|
|
13
|
+
code: "recipe.invalid_param",
|
|
14
|
+
path: `workflow.nodes.${nodeId}.trading_state`,
|
|
15
|
+
message: `metamask.perps.start_state trading_state=${policy} sets positions, orders and strategies itself, so the requested ${keys} would be ignored. Omit trading_state to apply ${keys}, or request ${keys} in a separate metamask.perps.start_state node.`
|
|
16
|
+
}];
|
|
17
|
+
}
|
|
18
|
+
export {
|
|
19
|
+
mobileActionInputFindings
|
|
20
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { liveAdapterProcessTimeoutMs } from "@farmslot/recipe-cli/harness";
|
|
1
2
|
import { manifestPath } from "../../paths.js";
|
|
2
3
|
import { perpsOrderInputFindings } from "../perps-order-inputs.js";
|
|
4
|
+
import { mobileActionInputFindings } from "./action-inputs.js";
|
|
3
5
|
const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
|
|
4
6
|
"ui.press",
|
|
5
7
|
"ui.key_press",
|
|
@@ -13,6 +15,8 @@ const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
13
15
|
"ui.screenshot",
|
|
14
16
|
"ui.capture_surface"
|
|
15
17
|
]);
|
|
18
|
+
const MOBILE_SETUP_ACTIONS = /* @__PURE__ */ new Set(["metamask.perps.start_state", "metamask.perps.teardown_state"]);
|
|
19
|
+
const SETUP_EXIT_ALLOWANCE_MS = 5e3;
|
|
16
20
|
const mobileActions = {
|
|
17
21
|
manifestPath: () => manifestPath("mobile"),
|
|
18
22
|
semantic: [
|
|
@@ -49,9 +53,22 @@ const mobileActions = {
|
|
|
49
53
|
}),
|
|
50
54
|
wrap: async (adapters) => (await import("./runtime-identity.js")).withMobileNativeRuntimeIdentity(adapters),
|
|
51
55
|
lifecycle: async (adapters) => (await import("./lifecycle.js")).mobileSourceAwareLifecycleAdapters(adapters),
|
|
56
|
+
// A Perps setup resolves its own budget (device-class default unless the node
|
|
57
|
+
// sets timeout_ms). Its process must outlive that budget so the setup, not the
|
|
58
|
+
// process timeout, reports which phase ran out.
|
|
59
|
+
liveProcessNode: async (action, node, env) => {
|
|
60
|
+
if (!MOBILE_SETUP_ACTIONS.has(action) || node.live_adapter_timeout_ms != null) return node;
|
|
61
|
+
const { mobileSetupTimeoutMs } = await import("../../../library/actions/mobile/platform/bridge.mjs");
|
|
62
|
+
const needed = mobileSetupTimeoutMs({ node, context: { env } }) + SETUP_EXIT_ALLOWANCE_MS;
|
|
63
|
+
return needed > liveAdapterProcessTimeoutMs(node) ? { ...node, live_adapter_timeout_ms: needed } : node;
|
|
64
|
+
},
|
|
52
65
|
// ensure_positions and ensure_orders with an open or present state place an order.
|
|
53
|
-
inputFindings: (nodeId, node) =>
|
|
66
|
+
inputFindings: (nodeId, node) => [
|
|
67
|
+
...mobileActionInputFindings(nodeId, node),
|
|
68
|
+
...perpsOrderInputFindings(nodeId, node, { ensureChecksOnly: false })
|
|
69
|
+
]
|
|
54
70
|
};
|
|
55
71
|
export {
|
|
72
|
+
SETUP_EXIT_ALLOWANCE_MS,
|
|
56
73
|
mobileActions
|
|
57
74
|
};
|