@deeeed/metamask-harness 0.39.0 → 0.40.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 (67) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/adapters/extension/lib/playwright-cdp.cjs +17 -1
  3. package/adapters/extension/sidepanel-toggle.sh +135 -13
  4. package/dist/adapters/mobile/release-artifact.js +65 -0
  5. package/dist/adapters.js +8 -7
  6. package/dist/command-contract.js +10 -2
  7. package/dist/commands/call.js +7 -4
  8. package/dist/commands/launch/index.js +55 -3
  9. package/dist/commands/parse-args.js +6 -0
  10. package/dist/commands/run-engine.js +75 -39
  11. package/dist/funding-execution-context.js +1386 -0
  12. package/dist/live-adapter-contract.js +3 -1
  13. package/dist/metamask-action-validation.js +170 -3
  14. package/dist/mm-harness-cli.js +8 -2
  15. package/dist/recipe-security.js +5 -12
  16. package/dist/runner.js +102 -6
  17. package/docs/RECIPES.md +74 -1
  18. package/docs/RELEASE-QA-CAPABILITY-MAP.md +6 -5
  19. package/library/actions/extension/networks/add_chainlist.mjs +4 -0
  20. package/library/actions/extension/networks/add_custom.mjs +4 -0
  21. package/library/actions/extension/networks/custom_network.mjs +48 -1
  22. package/library/actions/extension/perps/assert_visible_consistency.mjs +366 -0
  23. package/library/actions/extension/perps/close_visible_position.mjs +299 -37
  24. package/library/actions/extension/perps/compare_provider_market.mjs +77 -0
  25. package/library/actions/extension/perps/edit_margin.mjs +285 -44
  26. package/library/actions/extension/perps/mutation-receipt.mjs +781 -0
  27. package/library/actions/extension/perps/perps.mjs +1029 -363
  28. package/library/actions/extension/perps/read_funds_confirmation.mjs +126 -13
  29. package/library/actions/extension/perps/read_snapshot.mjs +124 -3
  30. package/library/actions/extension/perps/read_visible_state.mjs +63 -5
  31. package/library/actions/extension/perps/select_activity_filter.mjs +81 -18
  32. package/library/actions/extension/perps/select_market_filter.mjs +48 -4
  33. package/library/actions/extension/perps/update_position_tpsl.mjs +251 -69
  34. package/library/actions/extension/perps/visible-mutation-identity.mjs +164 -0
  35. package/library/actions/extension/platform/cdp.mjs +88 -9
  36. package/library/actions/extension/settings/set_basic_functionality.mjs +35 -7
  37. package/library/actions/extension/ui/navigate.mjs +45 -5
  38. package/library/actions/extension/wallet/import.mjs +70 -29
  39. package/library/actions/extension/wallet/lock.mjs +62 -5
  40. package/library/actions/extension/wallet/visible-session.mjs +218 -0
  41. package/library/actions/mobile/ui/native-navigation.mjs +45 -0
  42. package/library/actions/mobile/wallet/import.mjs +17 -10
  43. package/library/actions/mobile/wallet/native-ui.mjs +21 -1
  44. package/library/actions/shared/perps/visible-state.mjs +25 -5
  45. package/library/manifests/extension.action-manifest.json +1696 -1271
  46. package/library/recipes/extension/assets/release-custom-network-token-persistence.recipe.json +1 -0
  47. package/library/recipes/{perps → extension/perps}/release-activity-filters.recipe.json +5 -5
  48. package/library/recipes/{perps → extension/perps}/release-funds-flows.recipe.json +3 -3
  49. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +24 -19
  50. package/library/recipes/extension/perps/release-market-filters.recipe.json +97 -0
  51. package/library/recipes/extension/perps/release-order-entry.recipe.json +4 -1
  52. package/library/recipes/extension/perps/release-order-validation.recipe.json +53 -14
  53. package/library/recipes/extension/perps/release-visible-consistency.recipe.json +47 -0
  54. package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +76 -0
  55. package/library/recipes/perps/release-live-market-order.recipe.json +30 -13
  56. package/library/recipes/perps/release-market-details.recipe.json +1 -1
  57. package/library/recipes/swap-bridge/release-quote.recipe.json +1 -0
  58. package/library/recipes/wallet/import.recipe.json +4 -14
  59. package/package.json +1 -1
  60. package/library/actions/extension/assets/finish_send.mjs +0 -128
  61. package/library/actions/extension/swap_bridge/submit_transaction.mjs +0 -363
  62. package/library/actions/extension/ui/activate_and_observe.mjs +0 -96
  63. package/library/recipes/assets/release-custom-gas-send.recipe.json +0 -88
  64. package/library/recipes/assets/release-native-max-cancel.recipe.json +0 -70
  65. package/library/recipes/assets/release-native-max-send.recipe.json +0 -78
  66. package/library/recipes/assets/release-own-account-send.recipe.json +0 -80
  67. package/library/recipes/extension/runner/action-validation.recipe.json +0 -343
package/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.40.0 - 2026-08-16
6
+
7
+ - Remove Extension's public `metamask.perps.close_positions` and `metamask.perps.start_state` surfaces and its mutating `runner.action-validation` recipe; retain only state assertions it can prove without implicit readiness or unreceipted convergence.
8
+ - Make guarded Extension custom-network setup wait for an unobscured account-menu hit point before opening network management.
9
+
10
+ ### Added
11
+
12
+ - Add explicit Extension fullscreen/sidepanel launch selection and auto/show/hide recipe HUD policy.
13
+ - Bind external-mutation runs to independently validated release, onboarding, balance-evidence, request, and preflight bytes plus the same-run visible wallet/runtime identity.
14
+ - Add a packaged Perps market-filter release recipe with a deterministic market-list start state and every nonconditional category control.
15
+ - Add official-compatible visible Extension Perps market-list/activity consistency assertions, plus source-dev-only background snapshot sampling and activity-to-history identity expectations.
16
+ - Bind every live Extension action result to the selected CDP target and current browser runtime session.
17
+
18
+ ### Changed
19
+
20
+ - Canonicalize durable Extension Perps release journeys under one discovered recipe ID each, with activity and funds flows scoped to Extension and explicit official/source-development and live-mutation boundaries.
21
+ - Parameterize live values in the Extension Perps order-validation recipe and narrow existing market-details, activity-filter, and funds-preview claims to their asserted evidence.
22
+ - Require an explicit flat market for non-submitting Extension order-form recipes and make live-price movement an opt-in source-development snapshot assertion.
23
+
24
+ ### Fixed
25
+
26
+ - Trust only the current Perps-only funding registry plus the named frozen G005 digest; reject duplicate or aggregate-over-cap mutation operations and budgets, mutation-capable balance graphs, root-ref dependency shadowing, and non-visible or non-Home balance observations.
27
+ - Bind trusted funding and onboarding evidence to exact emitted call graphs, successful node/action order, and reachable root PASS terminals while preserving Mobile's adapter-owned external-mutation consent boundary.
28
+ - Stop advertising shared Perps clean-market convergence on Extension; its state helpers now expose only navigation and assertion inputs and reject ignored creation or cleanup parameters during planning.
29
+ - Remove unsupported Extension send completion, Swap/Bridge submission, and generic activate-and-observe capabilities plus four non-executable Assets submit recipes; retain read-only, prepare, review, quote, and Mobile-owned live Swap planning.
30
+ - Bind funded Extension Perps margin and TP/SL updates to the exact visible position size, enforce order caps against requested leveraged exposure before receipt publication, and default funded placements to one-times leverage.
31
+ - Fail closed on visible and background Perps financial mutations without an identity-bound durable receipt, current visible funding minimum, runner-owned artifact/runtime, and exact dispatch-boundary provider/network; terminally record known non-dispatch instead of retrying the same claim.
32
+ - Require approved visible wallet method and credential provenance plus a hash-bound emitted recipe, pass summary, artifact manifest, and exact reachable root-terminal PASS in funding evidence.
33
+ - Require every funded visible position edit or close to recheck current funding, provider/network, and exact target state after receipt publication and immediately before its first pointer dispatch.
34
+ - Invalidate funded-mutation wallet identity proof after account selection, reset, lock, unlock, setup, or any state-changing generic UI action until a complete fresh visible wallet import proof succeeds.
35
+ - Keep Extension Perps state wrappers observation-only so they cannot place, close, or cancel implicitly outside a leaf action's funding authorization.
36
+ - Preserve a final Perps navigation observation poll and require Side Panel convergence to one MetaMask product surface beside the explicitly requested dapp.
37
+ - Require the canonical non-submitting Swap quote recipe to prove a positive reviewable quote instead of accepting a no-route state.
38
+ - Refuse cleanup-bound custom-network setup when the target network already exists, preserving pre-existing wallet configuration.
39
+ - Bind Android release-artifact action results to the verified native app process session and redact Mobile wallet-import identity proofs to a matched digest.
40
+ - Distinguish obscured Perps controls from harmless edge intersections in visible layout proofs and include confirmation controls on funds surfaces.
41
+ - Reject duplicate rendered market, watchlist, or activity identities during optional Extension consistency proofs.
42
+ - Read Perps funds confirmation titles from their visible wallet-initiated header and support exact amount, token, network, percentage-control, and disabled-state assertions.
43
+ - Wait for Basic Functionality consent to enable its confirmation before submitting the visible settings change.
44
+ - Reach the Extension global wallet menu through the visible Home control before locking from headerless product routes.
45
+ - Keep Extension surface transitions singular and route the sidepanel launcher's obscuration hit test through hardened raw CDP when its visible notification badge overlaps the button.
46
+ - Namespace funded Perps claims by exact request and preflight bytes, persist identity-bound receipts before live pointer dispatch, and restrict over-cap order recovery to exact cleanup-only cancellation without upgrading placement success.
47
+ - Relaunch intact Extension release artifacts without source dependency repair, and support exact receipt-bound close-only Perps cleanup with primary-card and balance reconciliation proof.
48
+ - Validate Extension Perps activity filters against their singular route query while requiring the requested filter label to remain visible.
49
+ - Make Extension `wallet.import method=ui` idempotent on an onboarded profile by visibly unlocking and selecting the exact derived account without fixture or background-state probes.
50
+
5
51
  ## 0.39.0 - 2026-08-14
6
52
 
7
53
  ### Added
@@ -1,5 +1,21 @@
1
1
  'use strict';
2
2
 
3
+ const path = require('node:path');
4
+ const { createRequire } = require('node:module');
5
+
6
+ function loadChromium() {
7
+ const runtimeRequire = createRequire(path.join(process.cwd(), 'package.json'));
8
+ try {
9
+ return runtimeRequire('@playwright/test').chromium;
10
+ } catch {
11
+ return runtimeRequire('playwright').chromium;
12
+ }
13
+ }
14
+
15
+ async function connectBrowserViaCdp(endpoint) {
16
+ return loadChromium().connectOverCDP(endpoint);
17
+ }
18
+
3
19
  function buildEvaluationExpression(callbackOrExpression, argument) {
4
20
  if (typeof callbackOrExpression === 'string') return callbackOrExpression;
5
21
  if (typeof callbackOrExpression !== 'function') {
@@ -31,4 +47,4 @@ async function evaluatePageViaCdp(page, callbackOrExpression, argument) {
31
47
  }
32
48
  }
33
49
 
34
- module.exports = { buildEvaluationExpression, evaluatePageViaCdp };
50
+ module.exports = { buildEvaluationExpression, connectBrowserViaCdp, evaluatePageViaCdp };
@@ -7,7 +7,8 @@
7
7
  #
8
8
  # Inputs: subcommand (status|open|close|toggle|cycle, default status);
9
9
  # --cdp-port <port> (required), --ext-id <id>, --agent-dir <dir>,
10
- # --settle-ms <ms>; env REPO, CDP_PORT, EXT_ID, SETTLE_MS, AGENT_DIR.
10
+ # --settle-ms <ms>, --dapp-url <url>; env REPO, CDP_PORT, EXT_ID,
11
+ # SETTLE_MS, AGENT_DIR, EXTENSION_START_URL.
11
12
  # Outputs: status/progress lines on stdout.
12
13
  # Exit 0 — action done; 1 — repo/args missing or action failed;
13
14
  # 2 — non-numeric port / CDP unreachable.
@@ -30,7 +31,7 @@ set -euo pipefail
30
31
 
31
32
  ACTION="${1:-status}"
32
33
  if [ "$ACTION" = "-h" ] || [ "$ACTION" = "--help" ]; then
33
- echo "Usage: sidepanel-toggle.sh <status|open|close|toggle|cycle> --cdp-port <port> [--ext-id <id>] [--agent-dir <dir>] [--settle-ms <ms>]"
34
+ echo "Usage: sidepanel-toggle.sh <status|open|close|toggle|cycle> --cdp-port <port> [--ext-id <id>] [--agent-dir <dir>] [--settle-ms <ms>] [--dapp-url <url>]"
34
35
  exit 0
35
36
  fi
36
37
  shift || true
@@ -68,6 +69,7 @@ CDP_PORT="${CDP_PORT:-}"
68
69
  EXT_ID="${EXT_ID:-}"
69
70
  SETTLE_MS="${SETTLE_MS:-10000}"
70
71
  AGENT_DIR="${AGENT_DIR:-$SCRIPT_DIR}"
72
+ DAPP_URL="${EXTENSION_START_URL:-https://metamask.github.io/test-dapp/}"
71
73
 
72
74
  while [[ $# -gt 0 ]]; do
73
75
  case "$1" in
@@ -75,8 +77,9 @@ while [[ $# -gt 0 ]]; do
75
77
  --ext-id) EXT_ID="$2"; shift 2 ;;
76
78
  --agent-dir) AGENT_DIR="$2"; shift 2 ;;
77
79
  --settle-ms) SETTLE_MS="$2"; shift 2 ;;
80
+ --dapp-url) DAPP_URL="$2"; shift 2 ;;
78
81
  -h|--help)
79
- echo "Usage: sidepanel-toggle.sh <status|open|close|toggle|cycle> --cdp-port <port> [--ext-id <id>] [--agent-dir <dir>] [--settle-ms <ms>]"
82
+ echo "Usage: sidepanel-toggle.sh <status|open|close|toggle|cycle> --cdp-port <port> [--ext-id <id>] [--agent-dir <dir>] [--settle-ms <ms>] [--dapp-url <url>]"
80
83
  exit 0
81
84
  ;;
82
85
  *) echo "unknown arg: $1" >&2; exit 1 ;;
@@ -91,6 +94,10 @@ if ! [[ "$CDP_PORT" =~ ^[0-9]+$ ]]; then
91
94
  echo "FAIL: CDP port \"$CDP_PORT\" must be numeric" >&2
92
95
  exit 2
93
96
  fi
97
+ case "$DAPP_URL" in
98
+ http://*|https://*) ;;
99
+ *) echo "FAIL: --dapp-url must use http or https" >&2; exit 1 ;;
100
+ esac
94
101
  if ! curl -s -m 3 "http://127.0.0.1:${CDP_PORT}/json/version" >/dev/null 2>&1; then
95
102
  echo "FAIL: CDP not reachable on port ${CDP_PORT}" >&2
96
103
  exit 2
@@ -105,7 +112,44 @@ focus_browser() {
105
112
  }
106
113
 
107
114
  find_sidepanel_id() {
108
- json_list | python3 -c "import json,sys; d=json.load(sys.stdin); ids=[t.get('id','') for t in d if t.get('type')=='page' and 'sidepanel.html' in t.get('url','')]; print(ids[0] if ids else '')"
115
+ local ext_id
116
+ ext_id="$(resolve_ext_id)"
117
+ json_list | EXT_ID="$ext_id" python3 -c "import json,os,sys; d=json.load(sys.stdin); ext=os.environ.get('EXT_ID',''); ids=[t.get('id','') for t in d if t.get('type')=='page' and t.get('url','').startswith('chrome-extension://'+ext+'/sidepanel.html')]; print(ids[0] if ids else '')"
118
+ }
119
+
120
+ find_dapp_id() {
121
+ json_list | DAPP_URL="$DAPP_URL" python3 -c "import json,os,sys; d=json.load(sys.stdin); wanted=os.environ['DAPP_URL'].rstrip('/'); ids=[t.get('id','') for t in d if t.get('type')=='page' and t.get('url','').rstrip('/')==wanted]; print(ids[0] if ids else '')"
122
+ }
123
+
124
+ surface_counts() {
125
+ local ext_id
126
+ ext_id="$(resolve_ext_id)"
127
+ json_list | EXT_ID="$ext_id" DAPP_URL="$DAPP_URL" python3 -c "import json,os,sys; d=json.load(sys.stdin); ext=os.environ.get('EXT_ID',''); wanted=os.environ['DAPP_URL'].rstrip('/'); pages=[t for t in d if t.get('type')=='page']; homes=sum(t.get('url','').startswith('chrome-extension://'+ext+'/home.html') for t in pages); panels=sum(t.get('url','').startswith('chrome-extension://'+ext+'/sidepanel.html') for t in pages); dapps=sum(t.get('url','').rstrip('/')==wanted for t in pages); products=sum(t.get('url','').startswith('chrome-extension://'+ext+'/') for t in pages); print(homes,panels,dapps,products)"
128
+ }
129
+
130
+ ensure_dapp_tab() {
131
+ [ -n "$(find_dapp_id)" ] && return 0
132
+ local encoded
133
+ encoded="$(DAPP_URL="$DAPP_URL" python3 -c "import os,urllib.parse; print(urllib.parse.quote(os.environ['DAPP_URL'], safe=''))")"
134
+ curl -fsS -X PUT "http://127.0.0.1:${CDP_PORT}/json/new?${encoded}" >/dev/null
135
+ }
136
+
137
+ ensure_home_page() {
138
+ local ext_id encoded home_ids first
139
+ ext_id="$(resolve_ext_id)"
140
+ [ -n "$ext_id" ] || { echo "FAIL: could not resolve extension id for CDP ${CDP_PORT}" >&2; return 1; }
141
+ home_ids="$(json_list | EXT_ID="$ext_id" python3 -c "import json,os,sys; d=json.load(sys.stdin); ext=os.environ.get('EXT_ID',''); print('\\n'.join(t.get('id','') for t in d if t.get('type')=='page' and t.get('url','').startswith('chrome-extension://'+ext+'/home.html')))" )"
142
+ if [ -n "$home_ids" ]; then
143
+ first=1
144
+ while IFS= read -r target_id; do
145
+ [ -n "$target_id" ] || continue
146
+ if [ "$first" -eq 1 ]; then first=0; continue; fi
147
+ curl -fsS "http://127.0.0.1:${CDP_PORT}/json/close/${target_id}" >/dev/null
148
+ done <<< "$home_ids"
149
+ return 0
150
+ fi
151
+ encoded="$(EXT_ID="$ext_id" python3 -c "import os,urllib.parse; print(urllib.parse.quote('chrome-extension://'+os.environ['EXT_ID']+'/home.html#/', safe=''))")"
152
+ curl -fsS -X PUT "http://127.0.0.1:${CDP_PORT}/json/new?${encoded}" >/dev/null
109
153
  }
110
154
 
111
155
  resolve_ext_id() {
@@ -172,6 +216,20 @@ wait_for_sidepanel() {
172
216
  done
173
217
  }
174
218
 
219
+ wait_for_surface_invariant() {
220
+ local expected="$1" start deadline now home_count panel_count dapp_count product_count
221
+ start="$(now_ms)"
222
+ deadline=$((start + SETTLE_MS))
223
+ while :; do
224
+ read -r home_count panel_count dapp_count product_count <<< "$(surface_counts)"
225
+ if [ "$expected" = "fullscreen" ] && [ "$home_count" -eq 1 ] && [ "$panel_count" -eq 0 ] && [ "$product_count" -eq 1 ]; then return 0; fi
226
+ if [ "$expected" = "sidepanel" ] && [ "$home_count" -eq 0 ] && [ "$panel_count" -eq 1 ] && [ "$dapp_count" -eq 1 ] && [ "$product_count" -eq 1 ]; then return 0; fi
227
+ now="$(now_ms)"
228
+ [ "$now" -lt "$deadline" ] || return 1
229
+ sleep 0.2
230
+ done
231
+ }
232
+
175
233
  print_targets() {
176
234
  json_list | python3 -c "import json,sys; d=json.load(sys.stdin); [print(f' {t.get(\"type\",\"?\")[:18]:18s} {t.get(\"url\",\"\")[:100]}') for t in d]"
177
235
  }
@@ -179,7 +237,7 @@ print_targets() {
179
237
  activate_dapp_tab() {
180
238
  local target_id
181
239
  target_id="$(
182
- json_list | python3 -c "import json,sys; d=json.load(sys.stdin); pages=[t for t in d if t.get('type')=='page']; target=next((t for t in pages if t.get('url','').startswith('http://') or t.get('url','').startswith('https://')), None); print(target.get('id','') if target else '')"
240
+ json_list | DAPP_URL="$DAPP_URL" python3 -c "import json,os,sys; d=json.load(sys.stdin); wanted=os.environ['DAPP_URL'].rstrip('/'); pages=[t for t in d if t.get('type')=='page']; target=next((t for t in pages if t.get('url','').rstrip('/')==wanted), None); print(target.get('id','') if target else '')"
183
241
  )"
184
242
  if [ -n "$target_id" ] && focus_browser; then
185
243
  curl -s "http://127.0.0.1:${CDP_PORT}/json/activate/${target_id}" >/dev/null || true
@@ -191,7 +249,7 @@ cleanup_sidepanel_tabs() {
191
249
  ext_id="$(resolve_ext_id)"
192
250
  [ -n "$ext_id" ] || return 0
193
251
  close_ids="$(
194
- json_list | EXT_ID="$ext_id" python3 -c "import json,os,sys; d=json.load(sys.stdin); ext=os.environ.get('EXT_ID',''); has_panel=any(t.get('type')=='page' and t.get('url','').startswith('chrome-extension://'+ext+'/') and 'sidepanel.html' in t.get('url','') for t in d); has_dapp=any(t.get('type')=='page' and (t.get('url','').startswith('http://') or t.get('url','').startswith('https://')) for t in d); print('\\n'.join(t.get('id','') for t in d if has_panel and has_dapp and t.get('type')=='page' and t.get('url','').startswith('chrome-extension://'+ext+'/') and 'home.html' in t.get('url','')))"
252
+ json_list | EXT_ID="$ext_id" DAPP_URL="$DAPP_URL" python3 -c "import json,os,sys; d=json.load(sys.stdin); ext=os.environ.get('EXT_ID',''); wanted=os.environ['DAPP_URL'].rstrip('/'); has_panel=any(t.get('type')=='page' and t.get('url','').startswith('chrome-extension://'+ext+'/sidepanel.html') for t in d); has_dapp=any(t.get('type')=='page' and t.get('url','').rstrip('/')==wanted for t in d); print('\\n'.join(t.get('id','') for t in d if has_panel and has_dapp and t.get('type')=='page' and t.get('url','').startswith('chrome-extension://'+ext+'/home.html')))"
195
253
  )"
196
254
  if [ -n "$close_ids" ]; then
197
255
  while IFS= read -r target_id; do
@@ -202,6 +260,18 @@ cleanup_sidepanel_tabs() {
202
260
  activate_dapp_tab
203
261
  }
204
262
 
263
+ dedupe_sidepanel_tabs() {
264
+ local ext_id sidepanel_ids first
265
+ ext_id="$(resolve_ext_id)"
266
+ sidepanel_ids="$(json_list | EXT_ID="$ext_id" python3 -c "import json,os,sys; d=json.load(sys.stdin); ext=os.environ.get('EXT_ID',''); print('\\n'.join(t.get('id','') for t in d if t.get('type')=='page' and t.get('url','').startswith('chrome-extension://'+ext+'/sidepanel.html')))" )"
267
+ first=1
268
+ while IFS= read -r target_id; do
269
+ [ -n "$target_id" ] || continue
270
+ if [ "$first" -eq 1 ]; then first=0; continue; fi
271
+ curl -fsS "http://127.0.0.1:${CDP_PORT}/json/close/${target_id}" >/dev/null
272
+ done <<< "$sidepanel_ids"
273
+ }
274
+
205
275
  status_sidepanel() {
206
276
  local sp
207
277
  sp="$(find_sidepanel_id)"
@@ -217,15 +287,22 @@ close_sidepanel() {
217
287
  sp="$(find_sidepanel_id)"
218
288
  if [ -z "$sp" ]; then
219
289
  echo "[sidepanel] already closed"
290
+ ensure_home_page
291
+ wait_for_surface_invariant fullscreen || { echo "FAIL: fullscreen surface did not converge to one Home target" >&2; return 1; }
220
292
  return 0
221
293
  fi
222
294
  curl -s "http://127.0.0.1:${CDP_PORT}/json/close/${sp}" >/dev/null
295
+ ensure_home_page
296
+ wait_for_surface_invariant fullscreen || { echo "FAIL: fullscreen surface did not converge to one Home target" >&2; return 1; }
223
297
  echo "[sidepanel] closed target ${sp}"
224
298
  }
225
299
 
226
300
  open_sidepanel() {
301
+ ensure_dapp_tab
227
302
  if [ -n "$(find_sidepanel_id)" ]; then
303
+ dedupe_sidepanel_tabs
228
304
  cleanup_sidepanel_tabs
305
+ wait_for_surface_invariant sidepanel || { echo "FAIL: sidepanel surface did not converge to one panel and a dapp host" >&2; return 1; }
229
306
  echo "[sidepanel] already open"
230
307
  return 0
231
308
  fi
@@ -239,13 +316,54 @@ open_sidepanel() {
239
316
  exit 4
240
317
  fi
241
318
 
242
- CDP_PORT="$CDP_PORT" EXT_ID="$ext_id" SCRIPT_DIR="$SCRIPT_DIR" node <<'NODE'
243
- const { chromium } = require('playwright');
319
+ CDP_PORT="$CDP_PORT" EXT_ID="$ext_id" SCRIPT_DIR="$SCRIPT_DIR" node <<'NODE'
320
+ const path = require('node:path');
321
+ const {
322
+ connectBrowserViaCdp,
323
+ evaluatePageViaCdp,
324
+ } = require(path.join(process.env.SCRIPT_DIR, 'lib/playwright-cdp.cjs'));
325
+
326
+ async function clickVisibleSafeHit(page, selector, successSelector, timeoutMs) {
327
+ const deadline = Date.now() + timeoutMs;
328
+ while (Date.now() < deadline) {
329
+ if (await page.locator(successSelector).isVisible().catch(() => false)) return;
330
+ const candidate = page.locator(selector);
331
+ const count = await candidate.count();
332
+ if (count > 1) throw new Error(`expected one visible ${selector} control, found ${count}`);
333
+ if (count === 1 && await candidate.isVisible().catch(() => false)) {
334
+ try {
335
+ const point = await evaluatePageViaCdp(page, (candidateSelector) => {
336
+ const matches = [...document.querySelectorAll(candidateSelector)];
337
+ if (matches.length !== 1) return null;
338
+ const [element] = matches;
339
+ const rect = element.getBoundingClientRect();
340
+ const points = [
341
+ [rect.left + rect.width / 2, rect.top + rect.height / 2],
342
+ [rect.left + rect.width / 4, rect.top + rect.height / 2],
343
+ [rect.left + rect.width * 3 / 4, rect.top + rect.height / 2],
344
+ [rect.left + rect.width / 2, rect.top + rect.height / 4],
345
+ [rect.left + rect.width / 2, rect.top + rect.height * 3 / 4],
346
+ ];
347
+ const safe = points.find(([x, y]) => {
348
+ const hit = document.elementFromPoint(x, y);
349
+ return hit === element || Boolean(hit && element.contains(hit));
350
+ });
351
+ return safe ? { x: safe[0], y: safe[1] } : null;
352
+ }, selector);
353
+ if (point) await page.mouse.click(point.x, point.y);
354
+ } catch (error) {
355
+ if (!/detached|not attached|execution context was destroyed/iu.test(String(error?.message || error))) throw error;
356
+ }
357
+ }
358
+ await page.waitForTimeout(50);
359
+ }
360
+ throw new Error(`visible ${selector} control did not activate within ${timeoutMs}ms`);
361
+ }
244
362
 
245
363
  (async () => {
246
364
  const port = process.env.CDP_PORT;
247
365
  const extId = process.env.EXT_ID;
248
- const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
366
+ const browser = await connectBrowserViaCdp(`http://127.0.0.1:${port}`);
249
367
  const context = browser.contexts()[0];
250
368
  const page = await context.newPage();
251
369
  try {
@@ -256,11 +374,13 @@ const { chromium } = require('playwright');
256
374
  if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') {
257
375
  await page.bringToFront();
258
376
  }
259
- const menu = page.locator('[data-testid="account-options-menu-button"]');
260
- await menu.waitFor({ state: 'visible', timeout: 15000 });
261
- await menu.click({ timeout: 5000 });
377
+ await clickVisibleSafeHit(
378
+ page,
379
+ '[data-testid="account-options-menu-button"]',
380
+ '[data-testid="global-menu-toggle-view"]',
381
+ 5000,
382
+ );
262
383
  const toggle = page.locator('[data-testid="global-menu-toggle-view"]');
263
- await toggle.waitFor({ state: 'visible', timeout: 10000 });
264
384
  await toggle.click({ timeout: 5000 });
265
385
  } finally {
266
386
  if (!page.isClosed()) await page.close();
@@ -277,7 +397,9 @@ const { chromium } = require('playwright');
277
397
  NODE
278
398
 
279
399
  if wait_for_sidepanel; then
400
+ dedupe_sidepanel_tabs
280
401
  cleanup_sidepanel_tabs
402
+ wait_for_surface_invariant sidepanel || { echo "FAIL: sidepanel surface did not converge to one panel and a dapp host" >&2; return 1; }
281
403
  echo "[sidepanel] opened"
282
404
  return 0
283
405
  fi
@@ -89,6 +89,70 @@ function assertAndroidReleaseArtifactRuntime(identity, deps = defaultDeps) {
89
89
  const adb = resolveAdb(deps);
90
90
  assertInstalledAndroidReleaseArtifact(identity, deps, adb);
91
91
  }
92
+ function readAndroidNativeRuntimeIdentity(identity, deps = defaultDeps) {
93
+ assertAndroidReleaseArtifactRuntime(identity, deps);
94
+ const adb = resolveAdb(deps);
95
+ const buildFingerprint = runChecked(deps, adb, [
96
+ "-s",
97
+ identity.deviceSerial,
98
+ "shell",
99
+ "getprop",
100
+ "ro.build.fingerprint"
101
+ ]).trim();
102
+ if (!/^[A-Za-z0-9._+:/=-]{1,512}$/u.test(buildFingerprint)) {
103
+ throw new Error(`Android device ${identity.deviceSerial} returned an invalid build fingerprint.`);
104
+ }
105
+ const bootId = runChecked(deps, adb, [
106
+ "-s",
107
+ identity.deviceSerial,
108
+ "shell",
109
+ "cat",
110
+ "/proc/sys/kernel/random/boot_id"
111
+ ]).trim().toLowerCase();
112
+ if (!/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/u.test(bootId)) {
113
+ throw new Error(`Android device ${identity.deviceSerial} returned an invalid boot ID.`);
114
+ }
115
+ const pids = runChecked(deps, adb, [
116
+ "-s",
117
+ identity.deviceSerial,
118
+ "shell",
119
+ "pidof",
120
+ identity.packageId
121
+ ]).trim().split(/\s+/u).filter(Boolean);
122
+ if (pids.length !== 1 || !/^\d+$/u.test(pids[0])) {
123
+ throw new Error(`Android package ${identity.packageId} must have exactly one running app process on ${identity.deviceSerial}.`);
124
+ }
125
+ const pid = pids[0];
126
+ const processStat = runChecked(deps, adb, [
127
+ "-s",
128
+ identity.deviceSerial,
129
+ "shell",
130
+ "cat",
131
+ `/proc/${pid}/stat`
132
+ ]).trim();
133
+ const close = processStat.lastIndexOf(") ");
134
+ const processFields = close === -1 ? [] : processStat.slice(close + 2).trim().split(/\s+/u);
135
+ const startTimeTicks = processFields[19] ?? "";
136
+ if (!/^\d+$/u.test(startTimeTicks)) {
137
+ throw new Error(`Android package ${identity.packageId} returned an invalid process start time on ${identity.deviceSerial}.`);
138
+ }
139
+ const payload = {
140
+ schemaVersion: 1,
141
+ platform: "android",
142
+ deviceSerial: identity.deviceSerial,
143
+ buildFingerprint,
144
+ bootId,
145
+ packageId: identity.packageId,
146
+ versionName: identity.versionName,
147
+ versionCode: identity.versionCode,
148
+ sha256: identity.sha256,
149
+ appProcess: { pid, startTimeTicks }
150
+ };
151
+ return {
152
+ ...payload,
153
+ runtimeSessionId: createHash("sha256").update(JSON.stringify(payload)).digest("hex")
154
+ };
155
+ }
92
156
  async function relaunchAndroidReleaseArtifactRuntime(identity, deps = defaultDeps) {
93
157
  assertAndroidReleaseArtifactRuntime(identity, deps);
94
158
  const adb = resolveAdb(deps);
@@ -291,5 +355,6 @@ const defaultDeps = {
291
355
  export {
292
356
  assertAndroidReleaseArtifactRuntime,
293
357
  installAndroidReleaseArtifact,
358
+ readAndroidNativeRuntimeIdentity,
294
359
  relaunchAndroidReleaseArtifactRuntime
295
360
  };
package/dist/adapters.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import http from "node:http";
2
+ import { createHash } from "node:crypto";
2
3
  import { execFile } from "node:child_process";
3
4
  import { promisify } from "node:util";
4
5
  import { compatibilityMode, fixtureSummary, repoShape } from "./doctor.js";
@@ -45,7 +46,6 @@ const LIVE_ONLY_ACTIONS = /* @__PURE__ */ new Set([
45
46
  "metamask.assets.import_custom_token",
46
47
  "metamask.assets.prepare_send",
47
48
  "metamask.assets.review_send",
48
- "metamask.assets.finish_send",
49
49
  "metamask.assets.set_custom_gas",
50
50
  "metamask.assets.select_network_scope",
51
51
  "metamask.wallet.lock",
@@ -66,6 +66,7 @@ const LIVE_ONLY_ACTIONS = /* @__PURE__ */ new Set([
66
66
  "metamask.perps.edit_margin",
67
67
  "metamask.perps.update_position_tpsl",
68
68
  "metamask.perps.read_funds_confirmation",
69
+ "metamask.perps.assert_visible_consistency",
69
70
  "metamask.swap_bridge.read_visible_state",
70
71
  "metamask.swap_bridge.select_assets",
71
72
  "metamask.swap_bridge.set_amount",
@@ -164,7 +165,7 @@ async function semanticResult(platform, action, node, context, forceLive = false
164
165
  ...output,
165
166
  credentialSource: credentials.source,
166
167
  credentialSourceName: credentials.sourceName,
167
- expectedAddress: credentials.expectedAddress
168
+ expectedIdentitySha256: createHash("sha256").update(credentials.expectedAddress).digest("hex")
168
169
  }
169
170
  };
170
171
  }
@@ -261,7 +262,6 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
261
262
  ...platform === "extension" ? [
262
263
  "metamask.assets.prepare_send",
263
264
  "metamask.assets.review_send",
264
- "metamask.assets.finish_send",
265
265
  "metamask.assets.set_custom_gas",
266
266
  "metamask.assets.select_network_scope"
267
267
  ] : [],
@@ -278,25 +278,26 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
278
278
  ...platform === "extension" ? [
279
279
  "metamask.perps.open_position_action",
280
280
  "metamask.perps.edit_margin",
281
- "metamask.perps.update_position_tpsl"
281
+ "metamask.perps.update_position_tpsl",
282
+ "metamask.perps.assert_visible_consistency"
282
283
  ] : [],
283
284
  "metamask.swap_bridge.read_visible_state",
284
285
  "metamask.swap_bridge.select_assets",
285
286
  "metamask.swap_bridge.set_amount",
286
287
  "metamask.swap_bridge.set_max_amount",
287
288
  "metamask.swap_bridge.set_slippage",
288
- "metamask.swap_bridge.submit_transaction",
289
+ ...platform === "mobile" ? ["metamask.swap_bridge.submit_transaction"] : [],
289
290
  ...platform === "extension" ? ["metamask.settings.set_basic_functionality"] : [],
290
291
  "metamask.perps.read_positions",
291
292
  "metamask.perps.ensure_positions",
292
293
  "metamask.perps.assert_positions",
293
294
  "metamask.perps.place_order",
294
- "metamask.perps.close_positions",
295
+ ...platform === "mobile" || platform === "core" ? ["metamask.perps.close_positions"] : [],
295
296
  "metamask.perps.read_orders",
296
297
  "metamask.perps.close_orders",
297
298
  "metamask.perps.ensure_orders",
298
299
  "metamask.perps.assert_orders",
299
- "metamask.perps.start_state",
300
+ ...platform === "mobile" || platform === "core" ? ["metamask.perps.start_state"] : [],
300
301
  "metamask.perps.teardown_state",
301
302
  "metamask.perps.capture_performance",
302
303
  ...platform === "extension" || platform === "core" ? ["metamask.perps.read_snapshot"] : [],
@@ -31,6 +31,12 @@ const RECIPE_RUNTIME = {
31
31
  "--source-digest": value(),
32
32
  "--approve-plan": value()
33
33
  };
34
+ const RUN_FUNDING = {
35
+ "--funding-request": value(),
36
+ "--funding-preflight": value(),
37
+ "--funding-release-proof": value(),
38
+ "--funding-balance-proof": value()
39
+ };
34
40
  const RUNWAY = {
35
41
  "--branch": value(),
36
42
  "--default-branch": value(),
@@ -158,10 +164,11 @@ const PUBLIC_COMMAND_CONTRACTS = {
158
164
  variadic: { label: "key=value", pattern: /^[^=\s]+=.*/u }
159
165
  },
160
166
  run: {
161
- options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, {
167
+ options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, RUN_FUNDING, {
162
168
  "--list": bool(),
163
169
  "--plan": bool(),
164
- "--describe": bool()
170
+ "--describe": bool(),
171
+ "--hud": value(["auto", "show", "hide"])
165
172
  }),
166
173
  positionals: [{ label: "recipe" }],
167
174
  minimumPositionals: 1,
@@ -232,6 +239,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
232
239
  "--verify": bool(),
233
240
  "--runway": bool(),
234
241
  "--watch": bool(),
242
+ "--surface": value(["fullscreen", "sidepanel"]),
235
243
  "--sidepanel": bool(),
236
244
  "--fullscreen": bool(),
237
245
  "--url": value(),
@@ -208,16 +208,19 @@ async function handleCall(argv) {
208
208
  const artifactsDir = optionString(options, "artifactsDir") ?? defaultCallArtifactsDir(target, resolvedAction, args);
209
209
  recordCommandEvidence(artifactsDir);
210
210
  const requestedRuntimeOptions = runtimeOptionsFromCli(options);
211
+ const inheritedSource = process.env.FARMSLOT_RECIPE_SOURCE_TRUST || process.env.FARMSLOT_RECIPE_SOURCE_KIND || process.env.FARMSLOT_RECIPE_SOURCE_NAME || process.env.FARMSLOT_RECIPE_SOURCE_DIGEST;
211
212
  const callRuntimeOptions = {
212
213
  ...requestedRuntimeOptions,
213
214
  ...librarySources ? { librarySources } : {},
214
215
  autoHud: false,
215
216
  suppressLibraryResolutionLogs: true,
216
217
  stdoutIsMachineContract: json,
217
- source: requestedRuntimeOptions.source ?? {
218
- kind: "operator",
219
- trust: "trusted",
220
- name: "mm-harness call"
218
+ ...requestedRuntimeOptions.source ? { source: requestedRuntimeOptions.source } : inheritedSource ? {} : {
219
+ source: {
220
+ kind: "operator",
221
+ trust: "trusted",
222
+ name: "mm-harness call"
223
+ }
221
224
  }
222
225
  };
223
226
  let preflightedExecution;
@@ -37,6 +37,10 @@ import { acquireCheckoutLock } from "../../checkout-lock.js";
37
37
  import { JsonStreamWriter } from "../../json-stream.js";
38
38
  import { ensureExtensionConsoleCapture } from "../../adapters/extension/console-capture.js";
39
39
  import { extensionProductConfigBlock } from "../../adapters/extension/product-config.js";
40
+ import {
41
+ releaseArtifactState,
42
+ runtimeDistCheck
43
+ } from "../../adapters/extension/runtime-decision.js";
40
44
  const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
41
45
  "build",
42
46
  "watch",
@@ -124,13 +128,41 @@ async function handleLaunchLocked(argv, stream) {
124
128
  const wantWatch = flag(options, "watch");
125
129
  const wantRunway = flag(options, "runway");
126
130
  const wantClearMetro = flag(options, "clearMetro");
131
+ const requestedSurface = str(options, "surface");
132
+ const legacySidepanel = flag(options, "sidepanel");
133
+ const legacyFullscreen = flag(options, "fullscreen");
127
134
  if (wantRunway && adapter !== "mobile") {
128
135
  return launchUsage(jsonOutput, stream, "runway is mobile-only.", "drop --runway for the extension");
129
136
  }
130
137
  if (wantClearMetro && adapter !== "mobile") {
131
138
  return launchUsage(jsonOutput, stream, "--clear-metro is mobile-only.", "drop --clear-metro for the extension");
132
139
  }
133
- const displayMode = flag(options, "sidepanel") && !flag(options, "fullscreen") ? "sidepanel" : "fullscreen";
140
+ if (requestedSurface !== void 0 && requestedSurface !== "fullscreen" && requestedSurface !== "sidepanel") {
141
+ return launchUsage(
142
+ jsonOutput,
143
+ stream,
144
+ `--surface must be fullscreen or sidepanel (got: ${requestedSurface}).`,
145
+ "pass --surface fullscreen or --surface sidepanel"
146
+ );
147
+ }
148
+ if (legacySidepanel && legacyFullscreen) {
149
+ return launchUsage(
150
+ jsonOutput,
151
+ stream,
152
+ "--sidepanel and --fullscreen conflict.",
153
+ "pass exactly one Extension surface"
154
+ );
155
+ }
156
+ const legacySurface = legacySidepanel ? "sidepanel" : legacyFullscreen ? "fullscreen" : void 0;
157
+ if (requestedSurface && legacySurface && requestedSurface !== legacySurface) {
158
+ return launchUsage(
159
+ jsonOutput,
160
+ stream,
161
+ `--surface ${requestedSurface} conflicts with --${legacySurface}.`,
162
+ "pass one consistent Extension surface"
163
+ );
164
+ }
165
+ const displayMode = requestedSurface ?? legacySurface ?? "fullscreen";
134
166
  for (const portFlag of ["cdpPort", "watcherPort"]) {
135
167
  const value = str(options, portFlag);
136
168
  if (value !== void 0 && !/^\d+$/u.test(value)) {
@@ -189,7 +221,11 @@ async function handleLaunchLocked(argv, stream) {
189
221
  }
190
222
  stream.phase("healthcheck");
191
223
  if (adapter === "extension") {
192
- const willBuild = wantWatch || tier === "build" || !await extensionRuntimeReusable(target);
224
+ const artifact = releaseArtifactState(target);
225
+ const reusable = !wantWatch && tier !== "build" && await extensionRuntimeReusable(target, artifact);
226
+ const verifiedReleaseReuse = artifact.status === "valid" && reusable;
227
+ const verifiedReleaseLaunch = !wantWatch && tier !== "build" && artifact.status === "valid" && runtimeDistCheck(target, artifact).status === "fresh";
228
+ const willBuild = !reusable;
193
229
  if (willBuild) {
194
230
  const productConfigBlock = extensionProductConfigBlock(target);
195
231
  if (productConfigBlock) {
@@ -202,7 +238,7 @@ async function handleLaunchLocked(argv, stream) {
202
238
  });
203
239
  }
204
240
  }
205
- const depsBlock = extensionDepsBlock(target);
241
+ const depsBlock = verifiedReleaseReuse || verifiedReleaseLaunch ? null : extensionDepsBlock(target);
206
242
  if (depsBlock) {
207
243
  if (heal === "off") {
208
244
  return launchUsage(jsonOutput, stream, depsBlock.message, depsBlock.userAction);
@@ -498,6 +534,22 @@ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target
498
534
  return launchExtension(target, tier, wantWatch, displayMode);
499
535
  }
500
536
  async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true) {
537
+ if (adapter === "extension" && displayMode === "fullscreen") {
538
+ const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
539
+ const sidepanelArgs = ["close"];
540
+ if (process.env.CDP_PORT) sidepanelArgs.push("--cdp-port", process.env.CDP_PORT);
541
+ const fullscreen = spawnScript(sidepanelSh, sidepanelArgs, target, machine, { REPO: target });
542
+ if (fullscreen.status !== 0) {
543
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
544
+ code: "FULLSCREEN_OPEN_FAILED",
545
+ message: "app launched but restoring the fullscreen Extension surface failed.",
546
+ recoverable: false,
547
+ userAction: `bash adapters/extension/sidepanel-toggle.sh close --cdp-port ${process.env.CDP_PORT ?? "<CDP_PORT>"}`,
548
+ exitCode: EXIT.runtime,
549
+ originalError: fullscreen.output.trim() || void 0
550
+ });
551
+ }
552
+ }
501
553
  if (adapter === "extension" && displayMode === "sidepanel") {
502
554
  const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
503
555
  const sidepanelArgs = ["open"];