@deeeed/metamask-harness 0.84.0 → 0.85.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 (60) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/adapters/extension/console-tail.mjs +11 -7
  3. package/adapters/extension/ensure-browser.sh +17 -18
  4. package/adapters/mobile/start-console-forwarder.sh +8 -0
  5. package/adapters/shared/resolve-slot-ports-core.mjs +48 -6
  6. package/dist/adapters/extension/actions.js +5 -0
  7. package/dist/adapters/extension/console-capture.js +18 -0
  8. package/dist/adapters/extension/fixtures.js +2 -0
  9. package/dist/adapters/extension/overlay.js +5 -4
  10. package/dist/adapters/extension/runtime.js +2 -0
  11. package/dist/adapters/mobile/launch.js +19 -2
  12. package/dist/adapters/mobile/provision.js +4 -2
  13. package/dist/adapters/mobile/run.js +2 -1
  14. package/dist/adapters/mobile/verify.js +15 -7
  15. package/dist/adapters/web-dapp/actions.js +7 -5
  16. package/dist/adapters.js +5 -3
  17. package/dist/command-contract.js +28 -19
  18. package/dist/commands/check.js +5 -7
  19. package/dist/commands/debug.js +2 -2
  20. package/dist/commands/doctor.js +2 -3
  21. package/dist/commands/domain.js +4 -3
  22. package/dist/commands/fixtures.js +3 -3
  23. package/dist/commands/help.js +6 -5
  24. package/dist/commands/launch/extension.js +9 -7
  25. package/dist/commands/logs.js +2 -2
  26. package/dist/commands/prepare.js +4 -4
  27. package/dist/commands/provision.js +2 -2
  28. package/dist/commands/review.js +8 -10
  29. package/dist/commands/runtime-launch.js +2 -0
  30. package/dist/commands/status.js +4 -2
  31. package/dist/commands/task-init.js +2 -3
  32. package/dist/console-classifier.js +3 -2
  33. package/dist/doctor.js +6 -2
  34. package/dist/mm-harness-cli.js +19 -6
  35. package/dist/paths.js +9 -4
  36. package/dist/recipe-security.js +1 -1
  37. package/dist/review/knowledge.js +10 -2
  38. package/dist/runner.js +18 -3
  39. package/docs/CONTRIBUTING.md +8 -0
  40. package/docs/RECIPES.md +14 -1
  41. package/docs/cli-contract.md +7 -6
  42. package/library/actions/extension/app/assert_no_console_errors.mjs +95 -0
  43. package/library/actions/extension/platform/cdp.mjs +26 -0
  44. package/library/actions/mobile/app/assert_no_console_errors.mjs +129 -0
  45. package/library/actions/shared/console-assert.mjs +174 -0
  46. package/library/actions/shared/console-collector.mjs +10 -3
  47. package/library/actions/shared/console-findings.mjs +108 -7
  48. package/library/actions/web-dapp/app/assert_no_console_errors.mjs +10 -54
  49. package/library/actions/web-dapp/platform/hud.mjs +2 -9
  50. package/library/actions/web-dapp/platform/page.mjs +62 -16
  51. package/library/actions/web-dapp/platform/runtime.mjs +1 -1
  52. package/library/diagnostics/console-allowlist.json +13 -0
  53. package/library/manifests/extension.action-manifest.json +36 -0
  54. package/library/manifests/mobile.action-manifest.json +36 -0
  55. package/library/manifests/web-dapp.action-manifest.json +1 -1
  56. package/library/recipes/core/health/check.recipe.json +40 -0
  57. package/library/recipes/extension/health/check.recipe.json +71 -0
  58. package/library/recipes/mobile/health/check.recipe.json +71 -0
  59. package/package.json +9 -9
  60. package/site/assets/help-recipes.json +2 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.85.0 - 2026-10-09
6
+
7
+ ### Added
8
+
9
+ - `health.check` recipes for Mobile, Extension and Core: a read-only readiness check to run before ticket work. A failed precondition stops at a node named after its error (for example `WALLET_LOCKED`, `NOT_ON_HOME` or, on Mobile and Extension, `CONSOLE_ERRORS`), whose intent gives the recovery command. `mm-harness help` lists it under the target step.
10
+ - `metamask.app.assert_no_console_errors` on Mobile and Extension, as on `web-dapp`: it fails on a console error or exception that no console allowlist covers, judging the events since the launch began by their own timestamps, and fails as unavailable unless the capture is proven (the slot's console forwarder or collector is running and a control line logged in the app or MetaMask reaches the log). `launch` records when it starts, and console lines replayed from before then (by an app runtime or browser the launch kept) are left out. Mobile reads `app-console.log`, which every launch now restarts empty along with the forwarder's replay cursor, and judges only the device the bridge selects; Extension reads `extension-console.log` from the offset `launch` records, and its collector now stamps each line with the event's own time (UTC, with milliseconds). Known app noise is accepted only when it also appears on the app's unchanged base build: the bundled library ships `diagnostics/console-allowlist.json` (the Extension unlock page's `UNSAFE_componentWillMount` warning), read ahead of other libraries' by these actions and by run diagnostics, and the classifier carries pattern rules for base-reproduced errors one exact entry cannot cover: Mobile's SES lockdown intrinsic reports and its notifications startup failure (`createOnChainTriggers`), whose message carries the bundle line; Extension's `getBearerToken` while locked (MetaMask/metamask-extension#46688) and the Money query adapter's `mutations` TypeError (TAT-4063), whose stacks name build-specific chunks. Allowlist entries can name the `app` source Mobile lines carry, and Mobile console lines match without the forwarder's time and device prefix.
11
+
12
+ ### Changed
13
+
14
+ - **BREAKING:** `run --hud` takes `show` or `hide`, default `show`. `auto` is gone: the CLI refuses it as an invalid choice (`--hud must be show or hide; received 'auto'.`); drop the flag to get the default. The recipe HUD, each node's intent, now draws on every adapter that implements `app.hud`, headless browsers included. A headless web-dapp (Terminal) browser gets it where `auto` skipped it; Mobile and Extension already drew it by default. A Mobile release artifact still runs without it, since it has no dev bridge to draw with. `METAMASK_RECIPE_AUTO_HUD=0` stays the internal off switch for self-test, verify and the test sandboxes, and `--hud show` wins over it.
15
+ - **BREAKING:** web-dapp `ui.screenshot` keeps the HUD in the capture (it hid it while capturing), so Terminal screenshot evidence shows the node's intent as Mobile and Extension evidence does. `--hud hide` is the way to a HUD-free screenshot.
16
+ - A `surface=wallet` web-dapp `ui.screenshot` (the MetaMask confirmation window or side panel) shows the running node's intent too: the HUD is a bar docked to that MetaMask page's bottom edge for the capture only, which reserves its own height so the request and the footer buttons move up rather than being covered (pointer-events none), and is removed after it.
17
+ - A run with the HUD off (`--hud hide`, a `call`, an untrusted source) first clears the HUD an earlier run left on the slot, so its screenshots never show a previous recipe's intent: web-dapp deletes `hud.json` (the wallet host redraws from it) and the drawn HUD on the app tabs; Extension removes the runner HUD from its pages. Nothing is launched to do it, `METAMASK_RECIPE_AUTO_HUD=0` skips it, and a failed clear prints one `WARN:` line on stderr.
18
+ - Inside a checkout, every command resolves its adapter, target and slot once, through recipe-cli's context resolver, so `status`, `doctor`, `run` and the rest need no `--adapter`. The order is: flag, then the checkout's runtime binding, then its slot, then a unique detect match among the built-ins and the operator's library plugins (matched by their declared `detect`, before any plugin code loads).
19
+ - A checkout that more than one adapter matches stops with `ADAPTER_AMBIGUOUS` (exit 2) and lists the candidates. With no match, a command keeps its "could not detect the MetaMask repo type" error.
20
+ - `status`, `doctor`, `run`, `call` and discovery (`actions`, `run --list`/`--describe`, `call --list`) carry `context` in their `--json` envelopes (each value and its source), as does a command's own `USAGE` error once its context has resolved. Grammar refusals (raised before resolution), the task view (`status --task`/`--watch`) and the other commands' envelopes, `launch` on every adapter included, are unchanged. Human output adds one `context:` line on stderr when the adapter was not given by a flag.
21
+ - Terminal detection needs a recipe-terminal library that declares `detect` (recipe-terminal #8, 8a44aee); with an older library, pass `--adapter terminal`.
22
+ - `status` accepts `--adapter` and `--platform` (it refused them with `CLI_UNKNOWN_OPTION`).
23
+ - The slot comes from mm-harness's own pool resolution (the configured Farmslot root, else the nearest Farmslot checkout's `pool/`) unless `FARMSLOT_POOL_DIR` or `FARMSLOT_ROOT` names one, so a slot checkout beside a Farmslot checkout resolves its slot, and its ports fill `--cdp-port`/`--watcher-port`, with no env.
24
+ - Commands no longer detect the adapter themselves: status, doctor, check, provision, prepare, help, task init, `verify`'s overlay, debug, logs, fixtures, review and domain all read the resolved one. `check` still refuses `--platform ios|android`.
25
+ - `review` and `domain` scope to the resolved adapter when review covers it (mobile, extension, core, terminal), else run unscoped. Only an explicit `--adapter` that names another adapter is refused.
26
+ - Depend on `@farmslot/recipe-cli` 0.11.0 and `@farmslot/adapter-sdk` 0.8.0 (were 0.10.0 and 0.7.1) for the context resolver (deeeed/farmslot#864), on `@farmslot/recipe-runner` 0.28.0, `@farmslot/protocol` 0.35.0 and `@farmslot/agent-runtime` 0.17.1 (were 0.27.0, 0.34.0 and 0.17.0), and on `@farmslot/adapter-web` 0.6.4, `@farmslot/adapter-rn` 0.19.3 and `@farmslot/adapter-node` 0.1.6 (were 0.6.2, 0.19.1 and 0.1.4). With them, a web-dapp launch stops waiting for CDP when its browser dies (deeeed/farmslot#865). The install holds one `@farmslot/adapter-sdk` and one `@farmslot/recipe-runner`.
27
+ - Help lists the selectable adapters (the registered ones, then those the operator's libraries and the invocation's `--library` entries declare) wherever it names `--adapter` or `--platform`, instead of a fixed `mobile|extension|core|terminal`.
28
+ - **BREAKING:** Mobile `verify` takes `--watcher-port` (the public `verify` grammar accepted it; the Mobile leaf refused it) and reads `RECIPE_WATCHER_PORT`: the Metro port is `--watcher-port`, then `RECIPE_WATCHER_PORT`, then `WATCHER_PORT`, then the slot's `.js.env`. With no port env set, a checkout the slot pool maps gets the pool slot's port through `WATCHER_PORT`, so that port now wins over the checkout's `.js.env` `WATCHER_PORT`.
29
+ - **BREAKING:** A `RECIPE_RUNTIME_CONTEXT` that belongs to another checkout no longer sets the Extension slot's ports (`CDP_PORT`, `WATCHER_PORT`, `SLOT_ID`), Extension `verify`'s `RECIPE_SLOT_ID` and `RECIPE_HARNESS_EXTENSION_ID` (it reads the checkout's own context instead), or the Mobile provision slot (simulator, slot id). It counts when its `repoRoot` is the checkout's Git top level (so a clone nested inside another checkout never takes that checkout's context), or, without a `repoRoot`, when the file sits inside the checkout: the rule recipe-cli applies to the runtime binding. A context outside every checkout that names no `repoRoot` used to be authoritative; an orchestrator that keeps the file elsewhere sets `repoRoot`.
30
+ - Contributors: `bash tests/contract/run.sh` runs half the cores' worth of tests at once (`-j N`), each in its own process group, `TMPDIR` and output file, and prints each test's duration and the 15 slowest. A test gets 60 s (`CONTRACT_TEST_TIMEOUT`, or `# contract: timeout=<s>` in its header) and fails as `timed out`; what it leaves running is killed and named on a `LEAK` line; a test that rebuilds `dist/` declares `# contract: serial`. An unfiltered run over `CONTRACT_BUDGET` (180 s) fails, and a stopped run first lets each running test clean up (its EXIT trap reaps the leaves its CLI detached), then kills every process group left in the test's tree. A process a test leaves behind can no longer stall the run.
31
+ - Contributors: `yarn test:goldens` runs its families through `run.sh` and fails over `GOLDENS_BUDGET` (120 s); a golden case gets 60 s (`GOLDENS_CASE_TIMEOUT`, was 600 s). Docs: CONTRIBUTING and cli-contract describe both.
32
+ - Contributors: `AGENTS.md` has a `## Test speed` section: the default and CI suite must take 5 minutes or less (the contract and goldens runs check their own budgets; build and unit are not checked), and a new or changed test uses no fixed waits of 1 s or more, moves to `tests/live/` when it takes over 5 s (a golden case over 30 s), tears down every background process, and never rebuilds `dist/` under concurrent readers.
33
+ - Site: the platform map (`site/platform.html`) is a static page instead of the zoomable canvas. It shows the five layers (Libraries, MetaMask preset, Adapters, CLI, Engine) with their packages, one `mm-harness run` traced through them, and who changes what. It renders from `file://` (no `fetch`), so `site/assets/platform.json` is removed.
34
+
35
+ ### Fixed
36
+
37
+ - Extension reopen (`adapters/extension/ensure-browser.sh`) no longer builds its `node -e` script or its `python3` profile cleanup from the slot id, checkout, runtime, profile, extension and runner paths, CDP port, webpack pid or wallet fixture path. The scripts are fixed text and get those values as argv. Before, a checkout path with an apostrophe (`/tmp/Arthur's checkout`) broke the reopen script's syntax and silently skipped the profile cleanup, and a crafted value could change the code the reopen ran.
38
+
5
39
  ## 0.84.0 - 2026-10-08
6
40
 
7
41
  ### Changed
@@ -90,10 +90,14 @@ function out(event, destination = null) {
90
90
  }
91
91
  }
92
92
 
93
- function stamp() {
94
- const d = new Date();
95
- const p = (n) => String(n).padStart(2, '0');
96
- return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
93
+ // A record's time: the UTC time of day of the event's own timestamp (CDP
94
+ // Runtime.Timestamp, ms since the epoch), as the Mobile console forwarder
95
+ // writes it, or of the write when the event carries none. Events the browser
96
+ // replays when a collector attaches keep their own time, so
97
+ // metamask.app.assert_no_console_errors can leave out those from before a
98
+ // launch.
99
+ function stamp(timestamp) {
100
+ return new Date(Number.isFinite(timestamp) ? timestamp : Date.now()).toISOString().slice(11, 23);
97
101
  }
98
102
 
99
103
  function shortLabel(target) {
@@ -160,7 +164,7 @@ function emitConsole(label, params, destination) {
160
164
  const level = String(params.type || 'log').toUpperCase();
161
165
  const text = (params.args || []).map(previewArg).join(' ');
162
166
  const [first, ...rest] = text.split('\n');
163
- out(`${stamp()} [${label}] ${level} ${first}${atSuffix(frameText(params.stackTrace?.callFrames?.[0]))}${rest.length ? `\n${rest.join('\n')}` : ''}`, destination);
167
+ out(`${stamp(params.timestamp)} [${label}] ${level} ${first}${atSuffix(frameText(params.stackTrace?.callFrames?.[0]))}${rest.length ? `\n${rest.join('\n')}` : ''}`, destination);
164
168
  }
165
169
 
166
170
  function emitException(label, params, destination) {
@@ -172,7 +176,7 @@ function emitException(label, params, destination) {
172
176
  'uncaught exception';
173
177
  const frame = frameText(d.stackTrace?.callFrames?.[0]) || frameText({ url: d.url, lineNumber: d.lineNumber, columnNumber: d.columnNumber });
174
178
  const [first, ...rest] = String(text).split('\n');
175
- out(`${stamp()} [${label}] EXCEPTION ${first}${atSuffix(frame)}${rest.length ? `\n${rest.join('\n')}` : ''}`, destination);
179
+ out(`${stamp(params.timestamp)} [${label}] EXCEPTION ${first}${atSuffix(frame)}${rest.length ? `\n${rest.join('\n')}` : ''}`, destination);
176
180
  }
177
181
 
178
182
  // Browser log entries (network failures, interventions, deprecations) are
@@ -186,7 +190,7 @@ function emitLogEntry(label, params, destination) {
186
190
  // A Log entry's location is its URL as is: a line number appended to a URL
187
191
  // could not be told apart from the URL's own text.
188
192
  const where = e.url ? e.url : frameText(e.stackTrace?.callFrames?.[0]);
189
- out(`${stamp()} [${label}] [browser:${source}] ${level} ${e.text || ''}${atSuffix(where)}`, destination);
193
+ out(`${stamp(e.timestamp)} [${label}] [browser:${source}] ${level} ${e.text || ''}${atSuffix(where)}`, destination);
190
194
  }
191
195
 
192
196
  function handleMessage(label, destination, data) {
@@ -235,9 +235,10 @@ echo "[cleanup] Reusing profile ${PROFILE}"
235
235
  # shutdowns; --load-extension re-advertises the path but does not flip it back,
236
236
  # leaving MV3 service workers inert).
237
237
  if [ -f "${PROFILE}/Default/Preferences" ]; then
238
- python3 -c "
238
+ python3 - "${PROFILE}/Default/Preferences" 2>/dev/null <<'PY' && echo "[cleanup] Cleared crash recovery state + re-enabled extensions"
239
239
  import json
240
- pf = '${PROFILE}/Default/Preferences'
240
+ import sys
241
+ pf = sys.argv[1]
241
242
  prefs = json.load(open(pf))
242
243
  prefs['profile'] = prefs.get('profile', {})
243
244
  prefs['profile']['exit_type'] = 'Normal'
@@ -248,7 +249,7 @@ for ext_id, ext_prefs in ext_settings.items():
248
249
  ext_prefs['state'] = 1
249
250
  ext_prefs.pop('disable_reasons', None)
250
251
  json.dump(prefs, open(pf, 'w'))
251
- " 2>/dev/null && echo "[cleanup] Cleared crash recovery state + re-enabled extensions"
252
+ PY
252
253
  fi
253
254
 
254
255
  # Readiness gate: wait until webpack emits a loadable background entry before
@@ -289,10 +290,13 @@ echo "[reopen] ${SLOT_ID} — CDP:${CDP_PORT:-off}"
289
290
  cd "${REPO}"
290
291
 
291
292
  trap - EXIT
292
- exec node -e "
293
+ # The reopen script is fixed text; paths, ids and ports reach it as argv, never as source.
294
+ IFS= read -r -d '' REOPEN_SCRIPT <<'NODE' || true
295
+ const [SCRIPT_DIR, SLOT_ID, AGENT_DIR, EXTENSION, REPO, RUNNER_BIN, PROFILE, cdpPortArg, webpackPidArg, WALLET_FIXTURE] =
296
+ process.argv.slice(1);
293
297
  const path = require('path');
294
298
  const fs = require('fs');
295
- const adapterWeb = (name) => require('module').createRequire(path.join('${SCRIPT_DIR}', 'package.json'))('@farmslot/adapter-web/' + name);
299
+ const adapterWeb = (name) => require('module').createRequire(path.join(SCRIPT_DIR, 'package.json'))('@farmslot/adapter-web/' + name);
296
300
  const http = require('http');
297
301
  const { execFileSync } = require('child_process');
298
302
  let chromium; try { chromium = require('@playwright/test').chromium; } catch { chromium = require('playwright').chromium; }
@@ -318,15 +322,9 @@ const {
318
322
  resolveBrowser,
319
323
  } = adapterWeb('browser-resolver');
320
324
 
321
- const SLOT_ID = '${SLOT_ID}';
322
- const AGENT_DIR = '${AGENT_DIR}';
323
- const EXTENSION = '${EXTENSION}';
324
- const REPO = '${REPO}';
325
- const RUNNER_BIN = '${RUNNER_BIN}';
326
- const PROFILE = '${PROFILE}';
327
- const CDP_PORT = '${CDP_PORT}' || null;
328
- const WEBPACK_PID = '${WEBPACK_PID}' || null;
329
- const walletFixture = JSON.parse(fs.readFileSync('${WALLET_FIXTURE}', 'utf8'));
325
+ const CDP_PORT = cdpPortArg || null;
326
+ const WEBPACK_PID = webpackPidArg || null;
327
+ const walletFixture = JSON.parse(fs.readFileSync(WALLET_FIXTURE, 'utf8'));
330
328
  const PASSWORD = walletFixture.password;
331
329
 
332
330
  if (!PASSWORD) {
@@ -522,8 +520,8 @@ const releaseFocusHold = () => {
522
520
  await evaluatePageViaCdp(page, applyPersistentSlotTitle, { slotId: SLOT_ID, defaultTitle: 'MetaMask' });
523
521
 
524
522
  // Wait for runtime state to settle, then unlock if needed.
525
- const unlockSelector = '[data-testid=\"unlock-password\"]';
526
- const homeSelector = '[data-testid=\"account-menu-icon\"]';
523
+ const unlockSelector = '[data-testid="unlock-password"]';
524
+ const homeSelector = '[data-testid="account-menu-icon"]';
527
525
  let detectedState = 'unknown';
528
526
  for (let i = 0; i < 15; i++) {
529
527
  if (await page.locator(homeSelector).count()) {
@@ -544,7 +542,7 @@ const releaseFocusHold = () => {
544
542
  if (detectedState === 'locked') {
545
543
  console.log('[reopen] Unlocking wallet...');
546
544
  await page.fill(unlockSelector, PASSWORD);
547
- await page.click('[data-testid=\"unlock-submit\"]');
545
+ await page.click('[data-testid="unlock-submit"]');
548
546
  await page.waitForSelector(homeSelector, { timeout: 30000 });
549
547
  console.log('[reopen] Unlocked');
550
548
  } else if (detectedState === 'onboarding') {
@@ -611,4 +609,5 @@ const releaseFocusHold = () => {
611
609
  console.error(e);
612
610
  process.exit(1);
613
611
  });
614
- "
612
+ NODE
613
+ exec node -e "$REOPEN_SCRIPT" "$SCRIPT_DIR" "$SLOT_ID" "$AGENT_DIR" "$EXTENSION" "$REPO" "$RUNNER_BIN" "$PROFILE" "$CDP_PORT" "$WEBPACK_PID" "$WALLET_FIXTURE"
@@ -72,7 +72,15 @@ if [ -f "$PID_FILE" ]; then
72
72
  rm -f "$PID_FILE"
73
73
  fi
74
74
 
75
+ # Each launch starts a fresh log. The replay cursor goes with it: it marks
76
+ # lines as written that are no longer in the file, such as the relaunched
77
+ # app's first lines the previous forwarder wrote, so the new forwarder replays
78
+ # the app runtime's console buffer into the new log instead of skipping them.
79
+ # A runtime the launch kept replays lines from before the launch too;
80
+ # metamask.app.assert_no_console_errors leaves those out by their own time
81
+ # against the mark each launch attempt records (app-console.launch.json).
75
82
  : > "$APP_LOG_FILE"
83
+ rm -f "$APP_LOG_FILE.forwarder-state.json"
76
84
  pid="$(node "$SCRIPT_DIR/launch-console-forwarder.cjs" \
77
85
  --port "$PORT" \
78
86
  --out "$APP_LOG_FILE" \
@@ -198,15 +198,57 @@ function mergeKvLayers(...layers) {
198
198
  return Object.keys(result).length ? formatKvLines(result) : '';
199
199
  }
200
200
 
201
+ // Whether a runtime context belongs to the checkout at `repo` (recipe-cli's
202
+ // binding rule): its repoRoot is the checkout's Git top level, so a clone
203
+ // nested inside another checkout never takes that checkout's context; without
204
+ // a repoRoot, the file sits inside that top level. An orchestrator may keep the
205
+ // file elsewhere as long as it names the checkout. Twin of recipe-cli's
206
+ // runtimeContextOwned: this module stays dependency-free for the shell paths
207
+ // that load it, so the rule is repeated here rather than imported.
208
+ export function ownsRuntimeContext(file, repo) {
209
+ let repoRoot;
210
+ try {
211
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
212
+ repoRoot = typeof value?.repoRoot === 'string' ? value.repoRoot : undefined;
213
+ } catch {
214
+ return false;
215
+ }
216
+ if (repoRoot === undefined) return within(gitTopLevel(repo), file);
217
+ try {
218
+ return fs.realpathSync(repoRoot) === fs.realpathSync(gitTopLevel(repo));
219
+ } catch {
220
+ // A path that does not exist owns nothing.
221
+ return false;
222
+ }
223
+ }
224
+
225
+ function gitTopLevel(dir) {
226
+ try {
227
+ return execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
228
+ encoding: 'utf8',
229
+ stdio: ['ignore', 'pipe', 'ignore'],
230
+ }).trim();
231
+ } catch {
232
+ // Not a Git checkout: the directory is its own root.
233
+ return dir;
234
+ }
235
+ }
236
+
237
+ function within(outer, inner) {
238
+ try {
239
+ const relative = path.relative(fs.realpathSync(outer), fs.realpathSync(inner));
240
+ return !(relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative));
241
+ } catch {
242
+ // A path that does not exist owns nothing.
243
+ return false;
244
+ }
245
+ }
246
+
201
247
  function extensionRuntimeContextPath(repo) {
202
248
  const repoRoot = path.resolve(repo);
203
249
  const envCtx = process.env.RECIPE_RUNTIME_CONTEXT;
204
- if (envCtx) {
205
- // Farmslot may manage runtime context outside the checkout. Treat the
206
- // explicit environment override as authoritative; containment is not a
207
- // security boundary and silently dropping it selects the wrong port.
208
- return path.resolve(envCtx);
209
- }
250
+ // Another checkout's context would set this checkout's ports.
251
+ if (envCtx && ownsRuntimeContext(path.resolve(envCtx), repoRoot)) return path.resolve(envCtx);
210
252
  return path.join(repoRoot, recipeRuntimeDir(), 'agentic-runtime.json');
211
253
  }
212
254
 
@@ -43,6 +43,11 @@ const extensionActions = {
43
43
  }
44
44
  })
45
45
  }),
46
+ // The HUD lives only in the extension pages' DOM.
47
+ async clearHud() {
48
+ const { clearExtensionHud } = await import("../../../library/actions/extension/platform/cdp.mjs");
49
+ await clearExtensionHud();
50
+ },
46
51
  trustedMutation: true,
47
52
  // ensure_positions and ensure_orders only check state here.
48
53
  inputFindings: (nodeId, node) => [
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { recipeRuntimePath } from "@farmslot/recipe-cli/harness";
3
4
  import { verifyCapture } from "../../../library/actions/shared/console-collector.mjs";
4
5
  import { ensureConsoleCollector } from "../console-capture.js";
@@ -10,6 +11,21 @@ function extensionCaptureFiles(target) {
10
11
  pidFile: recipeRuntimePath(target, "console-tail.pid")
11
12
  };
12
13
  }
14
+ const EXTENSION_CONSOLE_LAUNCH_FILE = "extension-console.launch.json";
15
+ function recordConsoleLaunchIn(runtimeDir) {
16
+ const log = path.join(runtimeDir, "extension-console.log");
17
+ const stat = fs.existsSync(log) ? fs.statSync(log) : null;
18
+ fs.mkdirSync(runtimeDir, { recursive: true });
19
+ const launchedAtMs = Date.now();
20
+ fs.writeFileSync(
21
+ path.join(runtimeDir, EXTENSION_CONSOLE_LAUNCH_FILE),
22
+ `${JSON.stringify({ offset: stat?.size ?? 0, inode: stat?.ino ?? null, launchedAtMs, recordedAt: new Date(launchedAtMs).toISOString() })}
23
+ `
24
+ );
25
+ }
26
+ function recordExtensionConsoleLaunch(target) {
27
+ recordConsoleLaunchIn(path.dirname(extensionCaptureFiles(target).extensionLog));
28
+ }
13
29
  function extensionCdpPort() {
14
30
  return process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
15
31
  }
@@ -37,8 +53,10 @@ async function verifyExtensionConsoleControl(capture) {
37
53
  return verifyCapture({ cdpPort: capture.cdpPort, log: capture.extensionLog, target: "extension", extensionId });
38
54
  }
39
55
  export {
56
+ EXTENSION_CONSOLE_LAUNCH_FILE,
40
57
  ensureExtensionConsoleCapture,
41
58
  extensionCaptureFiles,
42
59
  extensionCdpPort,
60
+ recordExtensionConsoleLaunch,
43
61
  verifyExtensionConsoleControl
44
62
  };
@@ -4,6 +4,7 @@ import { colorHumanMessage, EXIT, shellQuote, spawnScriptStreaming } from "@farm
4
4
  import { FIXTURE_STREAM_TIMEOUT_MS } from "../../commands/fixtures.js";
5
5
  import { sameFeatureFlags } from "../../feature-flags.js";
6
6
  import { runnerDir } from "../../paths.js";
7
+ import { recordExtensionConsoleLaunch } from "./console-capture.js";
7
8
  import { readExtensionPinnedFlags } from "./feature-flags.js";
8
9
  import { releaseArtifactState } from "./runtime-decision.js";
9
10
  async function applyExtensionFixture({
@@ -96,6 +97,7 @@ async function applyExtensionFixture({
96
97
  }
97
98
  process.stderr.write(`${colorHumanMessage(`\u2192 fixtures ${action} extension \u2014 resetting and seeding the slot-owned profile from the existing build\u2026`)}
98
99
  `);
100
+ recordExtensionConsoleLaunch(target);
99
101
  const liveScript = path.join(runnerDir, "adapters/extension/live.sh");
100
102
  const args = ["--target", target, "--launch-existing-dist", "--launch-only", "--reset-profile"];
101
103
  const flagKeys = Object.keys(fixtureFlags);
@@ -1,13 +1,14 @@
1
- import { readRuntimeContextField, resolveRuntimeContextPath } from "@farmslot/recipe-cli/harness";
1
+ import { readRuntimeContextField, recipeRuntimeDir, resolveRuntimeContextPath } from "@farmslot/recipe-cli/harness";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { ownsRuntimeContext } from "../../../adapters/shared/resolve-slot-ports-core.mjs";
3
5
  import { resolveExtensionSlotPorts } from "../slot-ports.js";
4
6
  function hasArg(args, needle) {
5
7
  return args.some((arg) => arg === needle || arg.startsWith(`${needle}=`));
6
8
  }
7
9
  function applyExtensionRuntimeEnv(target, args) {
8
- const contextPath = resolveRuntimeContextPath(target);
9
- const contextExists = fs.existsSync(contextPath);
10
- if (contextExists) {
10
+ const contextPath = [resolveRuntimeContextPath(target), path.join(target, recipeRuntimeDir(), "agentic-runtime.json")].find((file) => fs.existsSync(file) && ownsRuntimeContext(file, target));
11
+ if (contextPath) {
11
12
  process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
12
13
  if (!process.env.RECIPE_SLOT_ID) {
13
14
  const slotId = readRuntimeContextField(contextPath, "slotId");
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import { createRequire } from "node:module";
5
5
  import path from "node:path";
6
6
  import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, resolveFarmslotPoolDir, runnerDir } from "../../paths.js";
7
+ import { recordExtensionConsoleLaunch } from "./console-capture.js";
7
8
  import { poolFilesForHost } from "../resolve-slot-ports.js";
8
9
  import { macosSessionLocked, SESSION_LOCKED_MESSAGE, SESSION_LOCKED_USER_ACTION } from "../../../adapters/shared/session-lock.mjs";
9
10
  import { profileProcessPids } from "./validation-process-ownership.js";
@@ -666,6 +667,7 @@ async function launchLockedValidationBrowser(options) {
666
667
  fs.mkdirSync(validationProfileRoot, { recursive: true });
667
668
  fs.mkdirSync(validationArtifactsRoot, { recursive: true });
668
669
  assertNoQuarantinedValidationProfiles(validationProfileRoot);
670
+ recordExtensionConsoleLaunch(options.projectRoot);
669
671
  await stopOwnedCdpListener(options.cdpPort, [
670
672
  validationRuntimePath,
671
673
  path.join(options.projectRoot, recipeRuntimeDir())
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { recipeRuntimePath, EXIT, str, writeInteractiveProgress } from "@farmslot/recipe-cli/harness";
3
4
  import { applyDeviceTargeting } from "../../commands/device-target.js";
4
5
  import { launchMobile } from "../../commands/launch/mobile.js";
@@ -49,6 +50,14 @@ function metroLaunchEvidence(target) {
49
50
  if (descriptor !== void 0) fs.closeSync(descriptor);
50
51
  }
51
52
  }
53
+ function recordMobileConsoleLaunch(target) {
54
+ const file = recipeRuntimePath(target, "app-console.launch.json");
55
+ const launchedAtMs = Date.now();
56
+ fs.mkdirSync(path.dirname(file), { recursive: true });
57
+ fs.writeFileSync(file, `${JSON.stringify({ launchedAtMs, recordedAt: new Date(launchedAtMs).toISOString() })}
58
+ `);
59
+ }
60
+ const markedLaunches = /* @__PURE__ */ new WeakSet();
52
61
  const mobileLaunchProfile = {
53
62
  refuseFlags({ platformTarget }) {
54
63
  if (!platformTarget) {
@@ -104,7 +113,14 @@ const mobileLaunchProfile = {
104
113
  },
105
114
  nativeInputs: ["ios", "android", "package.json", "yarn.lock"],
106
115
  rebuildCommand: ({ platformTarget }) => `mm-harness launch ${platformTarget} --build`,
107
- compose: ({ target, platformTarget, machine, remoteFlagRequest }, tier, restartApp, clearMetro) => launchMobile(target, platformTarget, tier, machine, restartApp, clearMetro, remoteFlagRequest?.flags),
116
+ compose: (launch, tier, restartApp, clearMetro) => {
117
+ const { target, platformTarget, machine, remoteFlagRequest } = launch;
118
+ if (restartApp || !markedLaunches.has(launch)) {
119
+ markedLaunches.add(launch);
120
+ recordMobileConsoleLaunch(target);
121
+ }
122
+ return launchMobile(target, platformTarget, tier, machine, restartApp, clearMetro, remoteFlagRequest?.flags);
123
+ },
108
124
  classify(_launch, output) {
109
125
  if (output.includes("open-device: Android device is locked.")) {
110
126
  return {
@@ -187,5 +203,6 @@ const mobileLaunchProfile = {
187
203
  evidence: metroLaunchEvidence
188
204
  };
189
205
  export {
190
- mobileLaunchProfile
206
+ mobileLaunchProfile,
207
+ recordMobileConsoleLaunch
191
208
  };
@@ -4,7 +4,7 @@ import crypto from "node:crypto";
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { resolveSlotPortsByRepo } from "../../../adapters/shared/resolve-slot-ports-core.mjs";
7
+ import { ownsRuntimeContext, resolveSlotPortsByRepo } from "../../../adapters/shared/resolve-slot-ports-core.mjs";
8
8
  const RUNWAY_IOS_METADATA = {
9
9
  artifactName: "ios-app-main-dev-expo",
10
10
  workflow: "expo-dev-build.yml",
@@ -177,8 +177,10 @@ function fail(target, platform, code, message, rerunCommand, slot) {
177
177
  };
178
178
  }
179
179
  function readSlotContext(target, runtimeDir) {
180
+ const envContext = process.env.RECIPE_RUNTIME_CONTEXT;
180
181
  const candidates = [
181
- process.env.RECIPE_RUNTIME_CONTEXT,
182
+ // Another checkout's context would name its slot and simulator.
183
+ envContext && ownsRuntimeContext(envContext, target) ? envContext : void 0,
182
184
  runtimeContextPath(target, runtimeDir),
183
185
  path.join(target, recipeRuntimeDir(), "agentic-runtime.json"),
184
186
  path.join(target, "temp/recipe/runtime/agentic-runtime.json"),
@@ -57,7 +57,8 @@ const mobileRun = {
57
57
  // matches its target against it.
58
58
  RECIPE_RN_EXPLICIT_PLATFORM: process.env.RECIPE_RN_EXPLICIT_PLATFORM
59
59
  }),
60
- // An installed release artifact is opaque: no harness HUD inside it.
60
+ // An installed release artifact is opaque: it has no dev bridge and no
61
+ // AgentStepHud, so there is nothing to draw the HUD with.
61
62
  autoHud: () => process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME !== "1",
62
63
  // Always clear a HUD step the engine left on-device (a failed run strands
63
64
  // the FAIL banner). Best-effort — never masks the run's real outcome.
@@ -4,7 +4,7 @@ import crypto from "node:crypto";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { runnerDir } from "../../paths.js";
7
- const USAGE = "Usage: mm-harness verify --adapter mobile -- [--target <metamask-mobile>] [--artifacts-dir <dir>] [--static-only] [--platform ios|android] [--preflight-mode fast|auto|default|rebuild-native|clean] [--no-auto-start]";
7
+ const USAGE = "Usage: mm-harness verify --adapter mobile -- [--target <metamask-mobile>] [--artifacts-dir <dir>] [--static-only] [--platform ios|android] [--watcher-port <port>] [--preflight-mode fast|auto|default|rebuild-native|clean] [--no-auto-start]";
8
8
  const ENV_FILES = [".js.env", ".env", ".env.local"];
9
9
  const PREFLIGHT_MODES = ["fast", "auto", "default", "rebuild-native", "clean"];
10
10
  const AUTO_START_TRUE = ["1", "true", "TRUE", "True", "yes", "YES", "Yes", "on", "ON", "On"];
@@ -36,6 +36,7 @@ function parseMobileVerifyArgs(argv, env = process.env) {
36
36
  let platform = env.RECIPE_HARNESS_PLATFORM || env.PLATFORM || "ios";
37
37
  let preflightMode = env.RECIPE_HARNESS_MOBILE_PREFLIGHT_MODE || "fast";
38
38
  let autoStart = env.RECIPE_HARNESS_MOBILE_AUTO_START ?? "false";
39
+ let watcherPort;
39
40
  for (let i = 0; i < argv.length; i += 1) {
40
41
  const arg = argv[i];
41
42
  const needsValue = () => {
@@ -65,6 +66,12 @@ function parseMobileVerifyArgs(argv, env = process.env) {
65
66
  platform = value;
66
67
  break;
67
68
  }
69
+ case "--watcher-port": {
70
+ const value = needsValue();
71
+ if (value === void 0) return { kind: "error", message: "Missing value for --watcher-port", exitCode: 2 };
72
+ watcherPort = value;
73
+ break;
74
+ }
68
75
  case "--preflight-mode": {
69
76
  const value = needsValue();
70
77
  if (value === void 0) return { kind: "error", message: "Missing value for --preflight-mode", exitCode: 2 };
@@ -101,7 +108,8 @@ function parseMobileVerifyArgs(argv, env = process.env) {
101
108
  staticOnly,
102
109
  platform,
103
110
  preflightMode,
104
- autoStart: AUTO_START_TRUE.includes(autoStart)
111
+ autoStart: AUTO_START_TRUE.includes(autoStart),
112
+ ...watcherPort === void 0 ? {} : { watcherPort }
105
113
  }
106
114
  };
107
115
  }
@@ -141,8 +149,8 @@ function buildMobileVerifySummary(input) {
141
149
  generatedAt: input.generatedAt
142
150
  };
143
151
  }
144
- function resolveWatcherPort(target, env = process.env) {
145
- const explicit = env.WATCHER_PORT || "";
152
+ function resolveWatcherPort(target, env = process.env, flag) {
153
+ const explicit = flag || env.RECIPE_WATCHER_PORT || env.WATCHER_PORT || "";
146
154
  if (explicit) return explicit;
147
155
  for (const file of ENV_FILES) {
148
156
  const full = path.join(target, file);
@@ -299,7 +307,7 @@ async function runMobileVerify(argv, io = defaultIo) {
299
307
  io.err(parsed.message);
300
308
  return parsed.exitCode;
301
309
  }
302
- const { staticOnly, platform, preflightMode, autoStart } = parsed.options;
310
+ const { staticOnly, platform, preflightMode, autoStart, watcherPort } = parsed.options;
303
311
  const target = path.resolve(parsed.options.target);
304
312
  if (!fs.existsSync(target)) {
305
313
  io.err(`Refusing mobile verify: target checkout does not exist: ${target}`);
@@ -344,9 +352,9 @@ async function runMobileVerify(argv, io = defaultIo) {
344
352
  let fixtureStatus = null;
345
353
  let portHolder = null;
346
354
  if (!staticOnly) {
347
- const port = resolveWatcherPort(target);
355
+ const port = resolveWatcherPort(target, process.env, watcherPort);
348
356
  if (!isNumericWatcherPort(port)) {
349
- io.err(`Refusing mobile verify: resolved watcher port is not numeric: '${port}' (check WATCHER_PORT)`);
357
+ io.err(`Refusing mobile verify: resolved watcher port is not numeric: '${port}' (check --watcher-port, RECIPE_WATCHER_PORT or WATCHER_PORT)`);
350
358
  return 2;
351
359
  }
352
360
  let deviceTarget = mobileDeviceTarget(target, platform, process.env);
@@ -9,7 +9,7 @@ const webDappActions = {
9
9
  "app.performance_assert"
10
10
  ],
11
11
  cdpTarget: { transport: "chrome-cdp", probePath: "json/version" },
12
- ui: (harness, { hudPolicy }) => ({
12
+ ui: (harness) => ({
13
13
  base: harness.createCdpWebUiTransport({
14
14
  async withPage(input, callback) {
15
15
  const { withAppPage } = await import("../../../library/actions/web-dapp/platform/page.mjs");
@@ -30,14 +30,16 @@ const webDappActions = {
30
30
  async execute(action, node, context, standard) {
31
31
  if (action === "app.hud") {
32
32
  const { showAppHud } = await import("../../../library/actions/web-dapp/platform/hud.mjs");
33
- return showAppHud(
34
- { action, node, context: { nodeId: context.nodeId, projectRoot: context.projectRoot } },
35
- { hudPolicy }
36
- );
33
+ return showAppHud({ action, node, context: { nodeId: context.nodeId, projectRoot: context.projectRoot } });
37
34
  }
38
35
  return standard();
39
36
  }
40
37
  }),
38
+ // Deletes hud.json (the wallet host redraws from it) and the HUD on the app tabs.
39
+ async clearHud(projectRoot) {
40
+ const { showAppHud } = await import("../../../library/actions/web-dapp/platform/hud.mjs");
41
+ await showAppHud({ node: { clear: true }, context: { nodeId: "hud-clear", projectRoot } });
42
+ },
41
43
  // ensure_positions and ensure_orders with an open or present state place an order.
42
44
  inputFindings: (nodeId, node) => perpsOrderInputFindings(nodeId, node, { ensureChecksOnly: false })
43
45
  };
package/dist/adapters.js CHANGED
@@ -90,7 +90,9 @@ const LIVE_ONLY_APP_ACTIONS = /* @__PURE__ */ new Set([
90
90
  "app.network_capture",
91
91
  "app.network_assert",
92
92
  "app.performance_capture",
93
- "app.performance_assert"
93
+ "app.performance_assert",
94
+ // Proves a capture of the running app; a static result would pass on no evidence.
95
+ "metamask.app.assert_no_console_errors"
94
96
  ]);
95
97
  function requiresLiveAdapter(platform, action) {
96
98
  return LIVE_ONLY_ACTIONS.has(action) || Boolean(getAdapterSurface(platform).actions.liveOnly?.includes(action)) || LIVE_ONLY_WALLET_ACTIONS.has(action) || LIVE_ONLY_FEATURE_FLAG_ACTIONS.has(action) || LIVE_ONLY_APP_ACTIONS.has(action);
@@ -290,7 +292,7 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
290
292
  )
291
293
  );
292
294
  }
293
- function createMetaMaskUiTransport(platform, harness, preparedLiveAdapters, { hudPolicy = "auto" } = {}) {
295
+ function createMetaMaskUiTransport(platform, harness, preparedLiveAdapters) {
294
296
  const ui = getAdapterSurface(platform).actions.ui;
295
297
  if (!ui) {
296
298
  return {
@@ -301,7 +303,7 @@ function createMetaMaskUiTransport(platform, harness, preparedLiveAdapters, { hu
301
303
  }
302
304
  };
303
305
  }
304
- const { base, execute } = ui(harness, { hudPolicy });
306
+ const { base, execute } = ui(harness, { hudPolicy: "show" });
305
307
  async function standard(action, node, context) {
306
308
  if (action === "ui.navigate") {
307
309
  const live = await runLiveFirst(