@deeeed/metamask-harness 0.2.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 (158) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +140 -0
  3. package/bin/mm-harness +99 -0
  4. package/docs/CHEATSHEET.md +61 -0
  5. package/docs/CLI-SPEC.md +915 -0
  6. package/docs/MENTAL-MODEL.md +295 -0
  7. package/docs/architecture.md +367 -0
  8. package/docs/extension-runtime-commands.md +60 -0
  9. package/docs/harness-cli.md +43 -0
  10. package/docs/live-adapter-contract.md +188 -0
  11. package/docs/package-boundaries.md +47 -0
  12. package/docs/perps-flow-catalog.md +235 -0
  13. package/docs/recipe-libraries.md +95 -0
  14. package/docs/runtime-file-conventions.md +36 -0
  15. package/library/actions/core/perps/_controller.mjs +727 -0
  16. package/library/actions/core/perps/assert_orders.mjs +53 -0
  17. package/library/actions/core/perps/assert_positions.mjs +52 -0
  18. package/library/actions/core/perps/close_orders.mjs +97 -0
  19. package/library/actions/core/perps/close_positions.mjs +118 -0
  20. package/library/actions/core/perps/ensure_orders.mjs +40 -0
  21. package/library/actions/core/perps/ensure_positions.mjs +37 -0
  22. package/library/actions/core/perps/place_order.mjs +201 -0
  23. package/library/actions/core/perps/read_account.mjs +30 -0
  24. package/library/actions/core/perps/read_orders.mjs +27 -0
  25. package/library/actions/core/perps/read_positions.mjs +27 -0
  26. package/library/actions/core/perps/start_state.mjs +92 -0
  27. package/library/actions/core/perps/teardown_state.mjs +86 -0
  28. package/library/actions/extension/perps/assert_orders.mjs +11 -0
  29. package/library/actions/extension/perps/assert_positions.mjs +11 -0
  30. package/library/actions/extension/perps/close_orders.mjs +8 -0
  31. package/library/actions/extension/perps/close_positions.mjs +8 -0
  32. package/library/actions/extension/perps/ensure_orders.mjs +4 -0
  33. package/library/actions/extension/perps/ensure_positions.mjs +4 -0
  34. package/library/actions/extension/perps/perps.mjs +730 -0
  35. package/library/actions/extension/perps/place_order.mjs +7 -0
  36. package/library/actions/extension/perps/read_orders.mjs +4 -0
  37. package/library/actions/extension/perps/read_positions.mjs +3 -0
  38. package/library/actions/extension/platform/cdp.mjs +541 -0
  39. package/library/actions/extension/ui/navigate.mjs +44 -0
  40. package/library/actions/extension/wallet/ensure_unlocked.mjs +36 -0
  41. package/library/actions/extension/wallet/read_state.mjs +27 -0
  42. package/library/actions/extension/wallet/select_account.mjs +48 -0
  43. package/library/actions/extension/wallet/setup.mjs +35 -0
  44. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +185 -0
  45. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +1662 -0
  46. package/library/actions/mobile/bridge-runtime/cdp-bridge.cjs +686 -0
  47. package/library/actions/mobile/bridge-runtime/lib/cdp-eval.cjs +110 -0
  48. package/library/actions/mobile/bridge-runtime/lib/config.cjs +39 -0
  49. package/library/actions/mobile/bridge-runtime/lib/issue-capture.cjs +446 -0
  50. package/library/actions/mobile/bridge-runtime/lib/target-discovery.cjs +204 -0
  51. package/library/actions/mobile/bridge-runtime/lib/ws-client.cjs +108 -0
  52. package/library/actions/mobile/bridge-runtime/setup-wallet.sh +442 -0
  53. package/library/actions/mobile/perps/assert_orders.mjs +11 -0
  54. package/library/actions/mobile/perps/assert_positions.mjs +11 -0
  55. package/library/actions/mobile/perps/close_orders.mjs +8 -0
  56. package/library/actions/mobile/perps/close_positions.mjs +8 -0
  57. package/library/actions/mobile/perps/ensure_orders.mjs +4 -0
  58. package/library/actions/mobile/perps/ensure_positions.mjs +4 -0
  59. package/library/actions/mobile/perps/perps.mjs +709 -0
  60. package/library/actions/mobile/perps/place_order.mjs +7 -0
  61. package/library/actions/mobile/perps/read_orders.mjs +4 -0
  62. package/library/actions/mobile/perps/read_positions.mjs +3 -0
  63. package/library/actions/mobile/platform/bridge.mjs +283 -0
  64. package/library/actions/mobile/ui/navigate.mjs +38 -0
  65. package/library/actions/mobile/wallet/ensure_unlocked.mjs +107 -0
  66. package/library/actions/mobile/wallet/home.mjs +35 -0
  67. package/library/actions/mobile/wallet/read_state.mjs +40 -0
  68. package/library/actions/mobile/wallet/select_account.mjs +48 -0
  69. package/library/actions/mobile/wallet/setup.mjs +220 -0
  70. package/library/flows/perps.flows.json +64 -0
  71. package/library/library.json +7 -0
  72. package/library/manifests/core.action-manifest.json +1282 -0
  73. package/library/manifests/extension.action-manifest.json +1749 -0
  74. package/library/manifests/mobile.action-manifest.json +1753 -0
  75. package/library/recipes/action-validation.extension.recipe.json +417 -0
  76. package/library/recipes/action-validation.mobile.recipe.json +422 -0
  77. package/library/recipes/order-lifecycle.core.recipe.json +78 -0
  78. package/library/recipes/perps-lifecycle.recipe.json +194 -0
  79. package/library/recipes/read-markets.core.recipe.json +38 -0
  80. package/library/recipes/smoke.extension.recipe.json +31 -0
  81. package/library/recipes/smoke.mobile.recipe.json +31 -0
  82. package/library/recipes/trading-lifecycle.core.recipe.json +76 -0
  83. package/orchestration/compat-overlays/README.md +19 -0
  84. package/orchestration/compat-overlays/mobile/README.md +13 -0
  85. package/orchestration/compat-overlays/mobile/rn81-message-event-source.patch +42 -0
  86. package/orchestration/core/cleanup.sh +37 -0
  87. package/orchestration/core/inject.sh +154 -0
  88. package/orchestration/doctor.mjs +72 -0
  89. package/orchestration/extension/cleanup.mjs +60 -0
  90. package/orchestration/extension/console-tail.mjs +228 -0
  91. package/orchestration/extension/ensure-browser.sh +416 -0
  92. package/orchestration/extension/ensure-ready.ts +185 -0
  93. package/orchestration/extension/extension-id.ts +107 -0
  94. package/orchestration/extension/inject.mjs +266 -0
  95. package/orchestration/extension/launch-browser.cjs +216 -0
  96. package/orchestration/extension/launch.sh +175 -0
  97. package/orchestration/extension/live.sh +320 -0
  98. package/orchestration/extension/pin-remote-flags.cjs +45 -0
  99. package/orchestration/extension/readiness.mjs +414 -0
  100. package/orchestration/extension/refresh-build.sh +190 -0
  101. package/orchestration/extension/runtime-decision.ts +445 -0
  102. package/orchestration/extension/runtime.ts +407 -0
  103. package/orchestration/extension/seed-fixture.sh +177 -0
  104. package/orchestration/extension/sidepanel-toggle.sh +291 -0
  105. package/orchestration/extension/snapshot-dist.sh +84 -0
  106. package/orchestration/extension/start-watch.sh +339 -0
  107. package/orchestration/extension/wallet-fixture-state.cjs +1086 -0
  108. package/orchestration/lib/activate-repo-node.sh +144 -0
  109. package/orchestration/lib/cli-color.mjs +84 -0
  110. package/orchestration/lib/cli-commands.mjs +243 -0
  111. package/orchestration/lib/cli-home.mjs +354 -0
  112. package/orchestration/lib/cli-ux.sh +252 -0
  113. package/orchestration/lib/cli-version.mjs +123 -0
  114. package/orchestration/lib/ensure-runner-deps.sh +56 -0
  115. package/orchestration/lib/harness-path.sh +55 -0
  116. package/orchestration/lib/hash-helpers.sh +44 -0
  117. package/orchestration/lib/json-field.sh +23 -0
  118. package/orchestration/lib/log-tui.mjs +304 -0
  119. package/orchestration/lib/open-debug.mjs +317 -0
  120. package/orchestration/lib/path-defaults.json +4 -0
  121. package/orchestration/lib/progress.mjs +107 -0
  122. package/orchestration/lib/recipe-paths.mjs +26 -0
  123. package/orchestration/lib/resolve-farmslot-ports.sh +144 -0
  124. package/orchestration/manifest.json +358 -0
  125. package/orchestration/mobile/cleanup.sh +192 -0
  126. package/orchestration/mobile/deps-markers.ts +21 -0
  127. package/orchestration/mobile/inject.sh +681 -0
  128. package/orchestration/mobile/launch.sh +137 -0
  129. package/orchestration/mobile/live.sh +125 -0
  130. package/orchestration/mobile/runtime-decision.ts +292 -0
  131. package/orchestration/porcelain/metamask-recipe +99 -0
  132. package/orchestration/porcelain/mm-recipe +1591 -0
  133. package/orchestration/porcelain/mme-recipe +1181 -0
  134. package/package.json +59 -0
  135. package/runner/extension/verify.sh +511 -0
  136. package/runner/mobile/verify.sh +501 -0
  137. package/runner/src/adapters.ts +601 -0
  138. package/runner/src/cli.ts +1820 -0
  139. package/runner/src/commands/debug.ts +44 -0
  140. package/runner/src/commands/fixtures.ts +99 -0
  141. package/runner/src/commands/launch.ts +397 -0
  142. package/runner/src/commands/logs.ts +60 -0
  143. package/runner/src/commands/shared.ts +138 -0
  144. package/runner/src/completions-cache.ts +86 -0
  145. package/runner/src/doctor.ts +203 -0
  146. package/runner/src/harness.ts +516 -0
  147. package/runner/src/heal-bounds.ts +179 -0
  148. package/runner/src/index.ts +6 -0
  149. package/runner/src/live-adapter-contract.ts +274 -0
  150. package/runner/src/manifest.ts +47 -0
  151. package/runner/src/mm-harness-cli.ts +488 -0
  152. package/runner/src/paths.ts +198 -0
  153. package/runner/src/recording-target.ts +147 -0
  154. package/runner/src/run-recording.ts +329 -0
  155. package/runner/src/runner.ts +108 -0
  156. package/runner/src/types.ts +57 -0
  157. package/scripts/completions.sh +125 -0
  158. package/scripts/install-completions.sh +62 -0
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env bash
2
+ # ensure-browser.sh — ensure the slot's Playwright Chromium is running
3
+ # (auto-detects the slot, kills any existing browser by recorded PID,
4
+ # relaunches against the existing dist/profile/port; requires an
5
+ # already-prepared profile). Project-specific (MetaMask Extension).
6
+ #
7
+ # Purpose:
8
+ # Restarts the slot's Chromium against the existing profile + dist build,
9
+ # re-resolves the extension id via the installed runner, and unlocks.
10
+ #
11
+ # Inputs (flags / env):
12
+ # --slot-id <id> (default <host>-<repo-basename>), --repo <checkout>,
13
+ # --cdp-port <port> (auto-detected from browser.pid when omitted),
14
+ # --runtime-dir <dir>, --watcher-port <port>; env CHROME_USER_DATA_DIR,
15
+ # RECIPE_HARNESS_ROOT. Repo/runtime-dir auto-detected by walking up from
16
+ # browser.pid/webpack.pid when omitted.
17
+ #
18
+ # Outputs:
19
+ # <runtime-dir>/{browser.pid,chromium.pid,extension.id}; progress on stdout.
20
+ # Exit 0 — browser reopened and ready; 1 — preflight/launch failure.
21
+ #
22
+ # Never touches: other slots' browsers (kills by recorded PID/profile only);
23
+ # the dist build (read-only).
24
+ #
25
+ # Usage (from worker template — all vars are template-expanded):
26
+ # bash <project>/setup/reopen-browser.sh \
27
+ # --slot-id macwork-metamask-extension-5 \
28
+ # --repo /Users/deeeed/dev/metamask/metamask-extension-5 \
29
+ # --cdp-port 6665 \
30
+ # --runtime-dir <runtime-dir>
31
+ #
32
+ # Requires: webpack watch running, valid build in dist/chrome.
33
+ set -euo pipefail
34
+
35
+ # --- Parse args ---
36
+ SLOT_ID=""
37
+ REPO=""
38
+ CDP_PORT=""
39
+ RUNTIME_DIR=""
40
+ WATCHER_PORT=""
41
+
42
+ while [[ $# -gt 0 ]]; do
43
+ case "$1" in
44
+ --slot-id) SLOT_ID="$2"; shift 2 ;;
45
+ --repo) REPO="$2"; shift 2 ;;
46
+ --cdp-port) CDP_PORT="$2"; shift 2 ;;
47
+ --runtime-dir) RUNTIME_DIR="$2"; shift 2 ;;
48
+ --watcher-port) WATCHER_PORT="$2"; shift 2 ;;
49
+ -h|--help)
50
+ echo "Usage: ensure-browser.sh [--slot-id <id>] [--repo <metamask-extension>] [--cdp-port <port>] [--runtime-dir <dir>] [--watcher-port <port>]"
51
+ exit 0
52
+ ;;
53
+ *) echo "Unknown flag: $1" >&2; exit 1 ;;
54
+ esac
55
+ done
56
+
57
+ # --- Auto-detect from script location or CWD ---
58
+ # Script lives at <repo>/<runtime_dir>/reopen-browser.sh
59
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
60
+ # shellcheck disable=SC1091
61
+ for _hp in "$SCRIPT_DIR/lib/harness-path.sh" "$SCRIPT_DIR/../lib/harness-path.sh"; do
62
+ [ -f "$_hp" ] && { . "$_hp"; break; }
63
+ done
64
+ unset _hp
65
+ if ! command -v harness_root >/dev/null 2>&1; then
66
+ echo "reopen-browser: shared lib orchestration/lib/harness-path.sh missing; reinstall the harness." >&2
67
+ exit 1
68
+ fi
69
+ if [[ -z "$RUNTIME_DIR" ]]; then
70
+ # Infer runtime_dir as relative path from repo root
71
+ # Script is at <repo>/<runtime-dir>/reopen-browser.sh, so runtime_dir is inferred from that relative path
72
+ if [[ -f "$SCRIPT_DIR/browser.pid" || -f "$SCRIPT_DIR/webpack.pid" || -f "$SCRIPT_DIR/wallet-fixture.json" ]]; then
73
+ AGENT_DIR_ABS="$SCRIPT_DIR"
74
+ elif [[ -f "$PWD/browser.pid" || -f "$PWD/webpack.pid" ]]; then
75
+ AGENT_DIR_ABS="$PWD"
76
+ fi
77
+ if [[ -n "${AGENT_DIR_ABS:-}" ]]; then
78
+ # Walk up to find repo root (has package.json + dist/chrome)
79
+ d="$AGENT_DIR_ABS"
80
+ while [[ "$d" != "/" ]]; do
81
+ if [[ -f "$d/package.json" && -d "$d/dist/chrome" ]]; then
82
+ REPO="${REPO:-$d}"
83
+ # runtime_dir is the relative path from repo to agent dir
84
+ RUNTIME_DIR="${AGENT_DIR_ABS#$d/}"
85
+ break
86
+ fi
87
+ d="$(dirname "$d")"
88
+ done
89
+ fi
90
+ fi
91
+
92
+ [[ -z "$REPO" ]] && { echo "Error: cannot detect repo — pass --repo" >&2; exit 1; }
93
+ RUNTIME_DIR="${RUNTIME_DIR:-$(recipe_runtime_dir)}"
94
+
95
+ # Auto-detect slot-id from directory name (e.g. metamask-extension-6 → macwork-metamask-extension-6)
96
+ if [[ -z "$SLOT_ID" ]]; then
97
+ REPO_BASENAME="$(basename "$REPO")"
98
+ HOSTNAME_SHORT="$(hostname -s)"
99
+ SLOT_ID="${HOSTNAME_SHORT}-${REPO_BASENAME}"
100
+ fi
101
+
102
+ # Auto-detect CDP port from existing browser launch args or agent files
103
+ if [[ -z "$CDP_PORT" && -f "${REPO}/${RUNTIME_DIR}/browser.pid" ]]; then
104
+ BPID=$(cat "${REPO}/${RUNTIME_DIR}/browser.pid" 2>/dev/null)
105
+ if [[ -n "$BPID" ]]; then
106
+ CDP_PORT=$(ps -p "$BPID" -o args= 2>/dev/null | grep -oE 'remote-debugging-port=[0-9]+' | cut -d= -f2 || true)
107
+ fi
108
+ fi
109
+
110
+ EXTENSION="${REPO}/dist/chrome"
111
+ AGENT_DIR="${REPO}/${RUNTIME_DIR}"
112
+ PROFILE="${CHROME_USER_DATA_DIR:-${AGENT_DIR}/chrome-profile-recipe}"
113
+ if [ ! -f "${PROFILE}/Default/Preferences" ] && [ -f "${AGENT_DIR}/chrome-profile-pw/Default/Preferences" ]; then
114
+ PROFILE="${AGENT_DIR}/chrome-profile-pw"
115
+ fi
116
+ WALLET_FIXTURE="${AGENT_DIR}/wallet-fixture.json"
117
+ # Extension id resolution is delegated to the installed recipe-runner — the
118
+ # single source of truth (deterministic id from the loaded dist's manifest key).
119
+ # Honor the configured recipe harness root.
120
+ HARNESS_ROOT="$(harness_root)"
121
+ RUNNER_BIN="${REPO}/${HARNESS_ROOT}/extension/runner/bin/metamask-recipe"
122
+
123
+ # --- Preflight checks ---
124
+ if [ ! -f "${EXTENSION}/manifest.json" ]; then
125
+ echo "FAIL: No build at ${EXTENSION}/manifest.json"
126
+ echo " Run preflight or: cd ${REPO} && PORT=${WATCHER_PORT:-9011} yarn start"
127
+ exit 1
128
+ fi
129
+
130
+ if [ ! -f "${WALLET_FIXTURE}" ]; then
131
+ echo "FAIL: wallet fixture missing at ${WALLET_FIXTURE}"
132
+ exit 1
133
+ fi
134
+
135
+ if [ ! -f "${PROFILE}/Default/Preferences" ]; then
136
+ echo "FAIL: existing browser profile not found at ${PROFILE}"
137
+ echo " Reopen only reuses an already-prepared profile. Run: metamask-recipe runtime-launch --adapter extension --target ${REPO} --cdp-port ${CDP_PORT} --chrome-user-data-dir ${PROFILE} --json"
138
+ exit 1
139
+ fi
140
+
141
+ WEBPACK_PID=$(cat "${AGENT_DIR}/webpack.pid" 2>/dev/null || true)
142
+ if [ -z "$WEBPACK_PID" ] || ! kill -0 "$WEBPACK_PID" 2>/dev/null; then
143
+ echo "WARN: webpack not running (no live PID in ${AGENT_DIR}/webpack.pid)"
144
+ echo " Hot reload won't work. Start it: cd ${REPO} && PORT=${WATCHER_PORT:-9011} yarn start"
145
+ WEBPACK_PID=""
146
+ fi
147
+
148
+ wait_for_browser_exit() {
149
+ local pid="$1"
150
+ if [ -z "$pid" ]; then
151
+ return 0
152
+ fi
153
+ for _ in $(seq 1 30); do
154
+ if ! kill -0 "$pid" 2>/dev/null; then
155
+ break
156
+ fi
157
+ sleep 1
158
+ done
159
+ if kill -0 "$pid" 2>/dev/null; then
160
+ echo "[cleanup] Browser PID ${pid} did not exit after SIGTERM; forcing kill"
161
+ kill -KILL "$pid" 2>/dev/null || true
162
+ fi
163
+ for _ in $(seq 1 10); do
164
+ if [ ! -e "${PROFILE}/SingletonLock" ] && [ ! -e "${PROFILE}/SingletonSocket" ] && [ ! -e "${PROFILE}/SingletonCookie" ]; then
165
+ return 0
166
+ fi
167
+ sleep 1
168
+ done
169
+ rm -f "${PROFILE}/SingletonLock" "${PROFILE}/SingletonSocket" "${PROFILE}/SingletonCookie" 2>/dev/null || true
170
+ }
171
+
172
+ cleanup_stale_profile_locks() {
173
+ local lock_path="${PROFILE}/SingletonLock"
174
+ if [ ! -L "$lock_path" ]; then
175
+ return 0
176
+ fi
177
+ local target pid
178
+ target=$(readlink "$lock_path" 2>/dev/null || true)
179
+ pid=$(printf '%s' "$target" | sed -E 's/.*-([0-9]+)$/\1/' )
180
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
181
+ echo "[cleanup] Profile lock owned by live Chromium PID ${pid}"
182
+ return 0
183
+ fi
184
+ echo "[cleanup] Removing stale Chromium profile lock (${target:-unknown})"
185
+ rm -f "${PROFILE}/SingletonLock" "${PROFILE}/SingletonSocket" "${PROFILE}/SingletonCookie" 2>/dev/null || true
186
+ }
187
+
188
+ kill_profile_processes() {
189
+ local pids
190
+ pids=$(pgrep -f "${PROFILE}" 2>/dev/null || true)
191
+ if [ -z "$pids" ]; then
192
+ return 0
193
+ fi
194
+ echo "[cleanup] Killing lingering profile processes: $(echo "$pids" | tr '\n' ' ')"
195
+ echo "$pids" | xargs kill -TERM 2>/dev/null || true
196
+ sleep 2
197
+ pids=$(pgrep -f "${PROFILE}" 2>/dev/null || true)
198
+ if [ -n "$pids" ]; then
199
+ echo "$pids" | xargs kill -KILL 2>/dev/null || true
200
+ fi
201
+ }
202
+
203
+ # --- Kill existing browser (by PID only — don't touch other slots) ---
204
+ PREV_PID=$(cat "${AGENT_DIR}/browser.pid" 2>/dev/null || true)
205
+ if [ -n "$PREV_PID" ] && kill -0 "$PREV_PID" 2>/dev/null; then
206
+ echo "[cleanup] Killing browser PID ${PREV_PID}"
207
+ kill "$PREV_PID" 2>/dev/null || true
208
+ wait_for_browser_exit "$PREV_PID"
209
+ fi
210
+ kill_profile_processes
211
+ cleanup_stale_profile_locks
212
+ rm -f "${AGENT_DIR}/browser.pid" "${AGENT_DIR}/extension.id" "${AGENT_DIR}/launcher.pid"
213
+ echo "[cleanup] Reusing profile ${PROFILE}"
214
+
215
+ # Clear crash recovery state + re-enable any previously-disabled extensions
216
+ # (profile Preferences persists extensions.settings.<id>.state=0 after unsafe
217
+ # shutdowns; --load-extension re-advertises the path but does not flip it back,
218
+ # leaving MV3 service workers inert).
219
+ if [ -f "${PROFILE}/Default/Preferences" ]; then
220
+ python3 -c "
221
+ import json
222
+ pf = '${PROFILE}/Default/Preferences'
223
+ prefs = json.load(open(pf))
224
+ prefs['profile'] = prefs.get('profile', {})
225
+ prefs['profile']['exit_type'] = 'Normal'
226
+ prefs['profile']['exited_cleanly'] = True
227
+ ext_settings = prefs.get('extensions', {}).get('settings', {})
228
+ for ext_id, ext_prefs in ext_settings.items():
229
+ if isinstance(ext_prefs, dict) and ext_prefs.get('state') != 1:
230
+ ext_prefs['state'] = 1
231
+ ext_prefs.pop('disable_reasons', None)
232
+ json.dump(prefs, open(pf, 'w'))
233
+ " 2>/dev/null && echo "[cleanup] Cleared crash recovery state + re-enabled extensions"
234
+ fi
235
+
236
+ # Readiness gate: wait until webpack emits a loadable background entry before
237
+ # freezing dist/chrome. The entry differs by manifest version — MV3 emits
238
+ # service-worker.js, MV2 emits scripts/app-init.js — so accept EITHER (gated on
239
+ # manifest.json). Hardcoding the MV2 app-init.js path made this hang the full
240
+ # timeout and FAIL on MV3 builds, which is the #1 reason agents can't launch the
241
+ # browser and then burn tokens debugging. Deterministic and MV-agnostic.
242
+ build_entry_ready() {
243
+ [ -s "${EXTENSION}/manifest.json" ] || return 1
244
+ [ -s "${EXTENSION}/service-worker.js" ] || [ -s "${EXTENSION}/scripts/app-init.js" ]
245
+ }
246
+ for _ in $(seq 1 60); do
247
+ build_entry_ready && break
248
+ sleep 1
249
+ done
250
+
251
+ if ! build_entry_ready; then
252
+ echo "FAIL: no loadable background entry (service-worker.js [MV3] or scripts/app-init.js [MV2]) under ${EXTENSION} after waiting for webpack output"
253
+ exit 1
254
+ fi
255
+
256
+ resume_webpack() {
257
+ if [ -n "${WEBPACK_PID:-}" ]; then
258
+ kill -CONT -"$WEBPACK_PID" 2>/dev/null || kill -CONT "$WEBPACK_PID" 2>/dev/null || true
259
+ fi
260
+ }
261
+
262
+ # Pause webpack process group so dist/chrome is stable during launch
263
+ if [ -n "$WEBPACK_PID" ]; then
264
+ kill -STOP -"$WEBPACK_PID" 2>/dev/null || kill -STOP "$WEBPACK_PID" 2>/dev/null || true
265
+ echo "[webpack] Paused (PID ${WEBPACK_PID})"
266
+ trap resume_webpack EXIT
267
+ fi
268
+
269
+ echo "[reopen] ${SLOT_ID} — CDP:${CDP_PORT:-off}"
270
+
271
+ cd "${REPO}"
272
+
273
+ trap - EXIT
274
+ exec node -e "
275
+ const path = require('path');
276
+ const fs = require('fs');
277
+ const http = require('http');
278
+ const { execSync, execFileSync } = require('child_process');
279
+ let chromium; try { chromium = require('@playwright/test').chromium; } catch { chromium = require('playwright').chromium; }
280
+
281
+ const SLOT_ID = '${SLOT_ID}';
282
+ const AGENT_DIR = '${AGENT_DIR}';
283
+ const EXTENSION = '${EXTENSION}';
284
+ const REPO = '${REPO}';
285
+ const RUNNER_BIN = '${RUNNER_BIN}';
286
+ const PROFILE = '${PROFILE}';
287
+ const CDP_PORT = '${CDP_PORT}' || null;
288
+ const WEBPACK_PID = '${WEBPACK_PID}' || null;
289
+ const walletFixture = JSON.parse(fs.readFileSync('${WALLET_FIXTURE}', 'utf8'));
290
+ const PASSWORD = walletFixture.password;
291
+
292
+ if (!PASSWORD) {
293
+ throw new Error('wallet-fixture.json is missing password');
294
+ }
295
+
296
+ const resumeWebpack = () => {
297
+ if (WEBPACK_PID) { try { process.kill(-Number(WEBPACK_PID), 'SIGCONT'); } catch { try { process.kill(Number(WEBPACK_PID), 'SIGCONT'); } catch {} } }
298
+ };
299
+
300
+ (async () => {
301
+ const args = [
302
+ '--user-data-dir=' + PROFILE,
303
+ '--disable-extensions-except=' + EXTENSION,
304
+ '--load-extension=' + EXTENSION,
305
+ '--disable-background-timer-throttling',
306
+ '--disable-backgrounding-occluded-windows',
307
+ '--disable-renderer-backgrounding',
308
+ '--no-first-run',
309
+ '--no-default-browser-check',
310
+ '--window-size=420,800',
311
+ ];
312
+ if (CDP_PORT) args.push('--remote-debugging-port=' + CDP_PORT);
313
+
314
+ const chromiumApp = path.dirname(path.dirname(path.dirname(chromium.executablePath())));
315
+ execFileSync('open', ['-n', '-a', chromiumApp, '--args', ...args], { stdio: 'ignore' });
316
+ for (let i = 0; i < 60; i++) {
317
+ try {
318
+ const version = await new Promise((resolve, reject) => {
319
+ const req = http.get('http://127.0.0.1:' + CDP_PORT + '/json/version', (res) => {
320
+ let body = '';
321
+ res.on('data', (chunk) => { body += chunk; });
322
+ res.on('end', () => resolve(body));
323
+ });
324
+ req.on('error', reject);
325
+ });
326
+ if (version) break;
327
+ } catch {}
328
+ await new Promise(r => setTimeout(r, 1000));
329
+ }
330
+ const browser = await chromium.connectOverCDP('http://127.0.0.1:' + CDP_PORT);
331
+ const ctx = browser.contexts()[0];
332
+ try {
333
+ const browserPid = execSync('lsof -ti tcp:' + CDP_PORT + ' -sTCP:LISTEN | head -1', { timeout: 2000 }).toString().trim();
334
+ if (browserPid) {
335
+ fs.writeFileSync(path.join(AGENT_DIR, 'browser.pid'), browserPid);
336
+ fs.writeFileSync(path.join(AGENT_DIR, 'chromium.pid'), browserPid);
337
+ console.log('[reopen] Chromium PID ' + browserPid);
338
+ }
339
+ } catch {}
340
+
341
+ // Detect extension ID via the recipe-runner — the single source of truth.
342
+ // Deterministic id from the loaded dist's manifest key; replaces a fragile
343
+ // serviceWorkers()[0] scan that grabbed Chrome *component* extensions and
344
+ // pointed home.html at the wrong (non-MetaMask) extension.
345
+ let extId = '';
346
+ try {
347
+ extId = execFileSync(RUNNER_BIN, ['resolve-extension', '--adapter', 'extension', '--target', REPO], { encoding: 'utf8' }).trim();
348
+ } catch (err) {
349
+ resumeWebpack();
350
+ console.error('[FAIL] runner resolve-extension failed: ' + (err && err.message ? err.message : String(err)));
351
+ process.exit(1);
352
+ }
353
+ if (!/^[a-p]{32}$/.test(extId)) { resumeWebpack(); console.error('[FAIL] runner returned invalid extension id: ' + JSON.stringify(extId)); process.exit(1); }
354
+ console.log('[reopen] Extension: ' + extId + ' (via runner resolve-extension)');
355
+
356
+ // Navigate to MetaMask home
357
+ const homeUrl = 'chrome-extension://' + extId + '/home.html';
358
+ const page = ctx.pages().find(p => p.url().includes('chrome-extension://')) || ctx.pages()[0];
359
+ await page.goto(homeUrl, { waitUntil: 'load', timeout: 15000 });
360
+
361
+ // Set window title to slot ID
362
+ await page.evaluate((id) => { document.title = id + ' \u2014 ' + document.title; }, SLOT_ID);
363
+
364
+ // Wait for runtime state to settle, then unlock if needed.
365
+ const unlockSelector = '[data-testid=\"unlock-password\"]';
366
+ const homeSelector = '[data-testid=\"account-menu-icon\"]';
367
+ let detectedState = 'unknown';
368
+ for (let i = 0; i < 15; i++) {
369
+ if (await page.locator(homeSelector).count()) {
370
+ detectedState = 'unlocked';
371
+ break;
372
+ }
373
+ if (await page.locator(unlockSelector).count()) {
374
+ detectedState = 'locked';
375
+ break;
376
+ }
377
+ if (page.url().includes('/onboarding')) {
378
+ detectedState = 'onboarding';
379
+ break;
380
+ }
381
+ await new Promise(r => setTimeout(r, 1000));
382
+ }
383
+
384
+ if (detectedState === 'locked') {
385
+ console.log('[reopen] Unlocking wallet...');
386
+ await page.fill(unlockSelector, PASSWORD);
387
+ await page.click('[data-testid=\"unlock-submit\"]');
388
+ await page.waitForSelector(homeSelector, { timeout: 30000 });
389
+ console.log('[reopen] Unlocked');
390
+ } else if (detectedState === 'onboarding') {
391
+ console.log('[reopen] On onboarding — state injection may have failed');
392
+ } else {
393
+ console.log('[reopen] Screen: ' + page.url());
394
+ }
395
+
396
+ // Close extra extension tabs
397
+ for (const p of ctx.pages()) {
398
+ if (p !== page && p.url().includes('chrome-extension://')) await p.close().catch(() => {});
399
+ }
400
+
401
+ fs.writeFileSync(path.join(AGENT_DIR, 'extension.id'), extId);
402
+ console.log('[reopen] Ready \u2014 ' + SLOT_ID + (CDP_PORT ? ' CDP:' + CDP_PORT : ''));
403
+
404
+ // Resume webpack now that browser loaded the extension
405
+ resumeWebpack();
406
+ if (WEBPACK_PID) console.log('[webpack] Resumed');
407
+ process.exit(0);
408
+ })().catch(e => {
409
+ resumeWebpack();
410
+ try { fs.unlinkSync(path.join(AGENT_DIR, 'browser.pid')); } catch {}
411
+ try { fs.unlinkSync(path.join(AGENT_DIR, 'chromium.pid')); } catch {}
412
+ try { fs.unlinkSync(path.join(AGENT_DIR, 'extension.id')); } catch {}
413
+ console.error(e);
414
+ process.exit(1);
415
+ });
416
+ "
@@ -0,0 +1,185 @@
1
+ // ensure-ready.ts — ensure the extension runtime is attachable.
2
+ // runner/extension/extension-ensure-ready.ts) App/instance control.
3
+ import path from 'node:path';
4
+
5
+ import { resolveExtensionId } from './extension-id.ts';
6
+
7
+ /**
8
+ * "Drive the extension to a ready state" — the single source of truth for the
9
+ * open-home + collapse-to-one-tab + verify-healthy logic that host launchers
10
+ * and reopen helpers previously hand-rolled. They left duplicate home tabs, which trips
11
+ * runtime-health's "exactly one home page" contract and makes agents see a
12
+ * broken-looking runtime.
13
+ *
14
+ * Guarantees, given a live CDP port:
15
+ * - the extension id is resolved deterministically (resolveExtensionId),
16
+ * - exactly ONE chrome-extension home.html page target is open (opened if none,
17
+ * extras closed if several),
18
+ * - the runtime is verified healthy (checkExtensionRuntimeHealth).
19
+ *
20
+ * Pure-ish: it only opens/closes tabs to converge on the single-tab invariant;
21
+ * it does not rebuild or relaunch. Unlocking the wallet remains with the host
22
+ * launcher for now (a future increment can fold ensure_unlocked in here too).
23
+ */
24
+
25
+ export interface EnsureReadyResult {
26
+ schemaVersion: 1;
27
+ adapter: 'extension';
28
+ target: string;
29
+ cdpPort: number;
30
+ extensionId: string | null;
31
+ opened: boolean;
32
+ homeTabs: { before: number; closed: number; after: number };
33
+ ready: boolean;
34
+ reasonCode: string;
35
+ health: { status: 'PASS' | 'FAIL' | 'unknown'; findings: string[] };
36
+ }
37
+
38
+ interface CdpTarget {
39
+ id?: string;
40
+ type?: string;
41
+ url?: string;
42
+ }
43
+
44
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
45
+
46
+ async function jsonList(port: number): Promise<CdpTarget[]> {
47
+ try {
48
+ const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) });
49
+ if (!res.ok) return [];
50
+ const value = await res.json();
51
+ return Array.isArray(value) ? (value as CdpTarget[]) : [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ async function closeTab(port: number, id: string): Promise<void> {
58
+ try {
59
+ await fetch(`http://127.0.0.1:${port}/json/close/${id}`, { signal: AbortSignal.timeout(4000) });
60
+ } catch {
61
+ // best-effort; a tab that won't close is surfaced by the final health check
62
+ }
63
+ }
64
+
65
+ async function activateTab(port: number, id: string): Promise<void> {
66
+ try {
67
+ await fetch(`http://127.0.0.1:${port}/json/activate/${id}`, { signal: AbortSignal.timeout(4000) });
68
+ } catch {
69
+ // best-effort; foregrounding only steers the headed-window screen capture onto
70
+ // the wallet — the authoritative readiness signal is the health check below
71
+ }
72
+ }
73
+
74
+ // Blank / New-Tab pages Chrome opens by default. The headed-window screen capture
75
+ // (device-grid stream) follows whatever tab is foreground, so a lingering New Tab
76
+ // makes the stream show chrome://newtab instead of the wallet. Safe to close once
77
+ // a home tab exists.
78
+ function isStrayTab(url?: string): boolean {
79
+ if (typeof url !== 'string') return false;
80
+ return /^chrome:\/\/newtab\/?$/.test(url) || /^chrome:\/\/new-tab-page\b/.test(url) || url === 'about:blank';
81
+ }
82
+
83
+ async function openHome(port: number, extensionId: string): Promise<boolean> {
84
+ const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(`chrome-extension://${extensionId}/home.html`)}`;
85
+ try {
86
+ let res = await fetch(endpoint, { method: 'PUT', signal: AbortSignal.timeout(8000) });
87
+ if (res.status === 404 || res.status === 405) res = await fetch(endpoint, { signal: AbortSignal.timeout(8000) });
88
+ return res.ok;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ function homePages(targets: CdpTarget[], extensionId: string): CdpTarget[] {
95
+ const prefix = `chrome-extension://${extensionId}`;
96
+ return targets.filter(
97
+ (t) => t.type === 'page' && typeof t.url === 'string' && t.url.startsWith(prefix) && t.url.includes('/home.html') && t.id,
98
+ );
99
+ }
100
+
101
+ export async function ensureExtensionReady(
102
+ target: string,
103
+ options: { cdpPort: number },
104
+ ): Promise<EnsureReadyResult> {
105
+ const resolved = path.resolve(target);
106
+ const { cdpPort } = options;
107
+ const base = (extra: Partial<EnsureReadyResult>): EnsureReadyResult => ({
108
+ schemaVersion: 1,
109
+ adapter: 'extension',
110
+ target: resolved,
111
+ cdpPort,
112
+ extensionId: null,
113
+ opened: false,
114
+ homeTabs: { before: 0, closed: 0, after: 0 },
115
+ ready: false,
116
+ reasonCode: 'unknown',
117
+ health: { status: 'unknown', findings: [] },
118
+ ...extra,
119
+ });
120
+
121
+ const { extensionId } = await resolveExtensionId(resolved, { cdpPort });
122
+ if (!extensionId) return base({ reasonCode: 'no-extension-id' });
123
+
124
+ const homes = homePages(await jsonList(cdpPort), extensionId);
125
+ const before = homes.length;
126
+
127
+ // Converge on exactly one home tab.
128
+ // - 0 tabs → open one.
129
+ // - 1 tab → keep it (cheap; the health check below validates it).
130
+ // - >1 tabs → close ALL and open one fresh. /json/list alone can't tell a
131
+ // healthy tab from a chrome-error one (a crashed page still lists its
132
+ // intended url), so keeping the first could discard the good tab and keep a
133
+ // broken one. A fresh tab is deterministically clean.
134
+ let opened = false;
135
+ let closed = 0;
136
+ if (homes.length > 1) {
137
+ for (const h of homes) {
138
+ await closeTab(cdpPort, String(h.id));
139
+ closed += 1;
140
+ }
141
+ await sleep(500);
142
+ opened = await openHome(cdpPort, extensionId);
143
+ await sleep(1500);
144
+ } else if (homes.length === 0) {
145
+ opened = await openHome(cdpPort, extensionId);
146
+ await sleep(1500);
147
+ }
148
+
149
+ // Drop stray New Tab / blank pages and foreground the wallet so the headed
150
+ // window captured by the device-grid stream shows MetaMask, not chrome://newtab.
151
+ // Only prune once a home tab exists, so the window is never left with zero tabs
152
+ // (which would close it).
153
+ const converged = await jsonList(cdpPort);
154
+ const homesNow = homePages(converged, extensionId);
155
+ if (homesNow.length >= 1) {
156
+ for (const t of converged) {
157
+ if (t.type === 'page' && t.id && isStrayTab(t.url)) await closeTab(cdpPort, String(t.id));
158
+ }
159
+ if (homesNow[0]?.id) await activateTab(cdpPort, String(homesNow[0].id));
160
+ }
161
+
162
+ const after = homePages(await jsonList(cdpPort), extensionId).length;
163
+
164
+ // Verify with the single health source. Lazy import: extension-runtime.ts
165
+ // loads the recipe harness at module scope, so it must not be pulled into
166
+ // any no-CDP path.
167
+ let health: EnsureReadyResult['health'] = { status: 'unknown', findings: [] };
168
+ try {
169
+ const { checkExtensionRuntimeHealth } = await import('./runtime.ts');
170
+ const report = await checkExtensionRuntimeHealth(resolved, cdpPort);
171
+ health = { status: report.status, findings: report.findings };
172
+ } catch (error) {
173
+ health = { status: 'FAIL', findings: [error instanceof Error ? error.message : String(error)] };
174
+ }
175
+
176
+ const ready = health.status === 'PASS' && after === 1;
177
+ return base({
178
+ extensionId,
179
+ opened,
180
+ homeTabs: { before, closed, after },
181
+ ready,
182
+ reasonCode: ready ? 'ready' : after !== 1 ? 'tab-count' : 'unhealthy',
183
+ health,
184
+ });
185
+ }