@deeeed/metamask-harness 0.13.0 → 0.14.1

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 (66) hide show
  1. package/CHANGELOG.md +43 -4
  2. package/adapters/extension/inject.mjs +1 -0
  3. package/adapters/extension/launch-browser.cjs +15 -3
  4. package/adapters/extension/lib/extension-id.cjs +36 -0
  5. package/adapters/extension/live.sh +5 -3
  6. package/adapters/extension/readiness.mjs +48 -0
  7. package/adapters/extension/reattach.sh +163 -55
  8. package/adapters/extension/sidepanel-toggle.sh +96 -24
  9. package/adapters/extension/verify.sh +15 -4
  10. package/adapters/extension/wallet-fixture-state.cjs +6 -11
  11. package/adapters/mobile/open-device.sh +32 -5
  12. package/adapters/mobile/start-metro.sh +15 -1
  13. package/adapters/mobile/verify.sh +50 -13
  14. package/adapters/shared/log-tui.mjs +7 -3
  15. package/adapters/shared/resolve-slot-ports-core.mjs +14 -4
  16. package/dist/adapters/core/surface.js +1 -0
  17. package/dist/adapters/extension/runtime.js +14 -5
  18. package/dist/adapters/extension/surface.js +1 -0
  19. package/dist/adapters/mobile/provision.js +49 -4
  20. package/dist/adapters/mobile/surface.js +1 -0
  21. package/dist/adapters/slot-ports.js +11 -4
  22. package/dist/cli-commands.js +5 -4
  23. package/dist/cli.js +4 -0
  24. package/dist/commands/call.js +79 -6
  25. package/dist/commands/check.js +326 -0
  26. package/dist/commands/core-readiness.js +75 -0
  27. package/dist/commands/device-target.js +123 -13
  28. package/dist/commands/doctor.js +30 -18
  29. package/dist/commands/fixtures.js +2 -1
  30. package/dist/commands/launch/extension.js +105 -9
  31. package/dist/commands/launch/index.js +116 -15
  32. package/dist/commands/mobile-device-view.js +140 -0
  33. package/dist/commands/parse-args.js +4 -1
  34. package/dist/commands/recipe-quality.js +6 -2
  35. package/dist/commands/run-engine.js +90 -5
  36. package/dist/commands/run-report.js +115 -0
  37. package/dist/commands/run.js +115 -8
  38. package/dist/commands/self-test.js +1 -1
  39. package/dist/commands/shared.js +2 -1
  40. package/dist/commands/status-probe.js +9 -3
  41. package/dist/commands/status.js +45 -50
  42. package/dist/live-adapter-contract.js +5 -1
  43. package/dist/mm-harness-cli.js +68 -27
  44. package/dist/recipe-files.js +14 -0
  45. package/docs/CLI-SPEC.md +25 -0
  46. package/docs/architecture.md +2 -2
  47. package/docs/live-adapter-contract.md +1 -1
  48. package/docs/recipe-libraries.md +21 -18
  49. package/library/actions/extension/platform/cdp.mjs +7 -4
  50. package/library/recipes/{app-lifecycle-android-smoke.mobile.recipe.json → app/lifecycle.android-smoke.mobile.recipe.json} +1 -1
  51. package/library/recipes/{perps-performance.mobile.recipe.json → perps/performance.mobile.recipe.json} +1 -1
  52. package/library/recipes/perps/smoke.core.recipe.json +39 -0
  53. package/library/recipes/perps/smoke.extension.recipe.json +51 -0
  54. package/library/recipes/perps/smoke.mobile.recipe.json +51 -0
  55. package/library/recipes/{action-validation.extension.recipe.json → runner/action-validation.extension.recipe.json} +7 -7
  56. package/library/recipes/{action-validation.mobile.recipe.json → runner/action-validation.mobile.recipe.json} +7 -7
  57. package/package.json +1 -1
  58. /package/library/recipes/{perps-lifecycle.recipe.json → perps/lifecycle.recipe.json} +0 -0
  59. /package/library/recipes/{order-lifecycle.core.recipe.json → perps/order-lifecycle.core.recipe.json} +0 -0
  60. /package/library/recipes/{perps-performance-background-resume.mobile.recipe.json → perps/performance.background-resume.mobile.recipe.json} +0 -0
  61. /package/library/recipes/{perps-performance-cold-start.mobile.recipe.json → perps/performance.cold-start.mobile.recipe.json} +0 -0
  62. /package/library/recipes/{perps-performance-warm-start.mobile.recipe.json → perps/performance.warm-start.mobile.recipe.json} +0 -0
  63. /package/library/recipes/{read-markets.core.recipe.json → perps/read-markets.core.recipe.json} +0 -0
  64. /package/library/recipes/{trading-lifecycle.core.recipe.json → perps/trading-lifecycle.core.recipe.json} +0 -0
  65. /package/library/recipes/{smoke.extension.recipe.json → runner/smoke.extension.recipe.json} +0 -0
  66. /package/library/recipes/{smoke.mobile.recipe.json → runner/smoke.mobile.recipe.json} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.14.1 - 2026-07-09
6
+
7
+ ### Added
8
+ - `mm-harness check diff` runs bounded repo-local validation for the active git diff, writing `validation-summary.json`/`.md` plus per-check logs. The fast profile runs changed-file ESLint, Prettier, and changed test files; `--profile full` also runs the repo typecheck when available. The default base follows the active PR base or repo remote HEAD before falling back to common branch names.
9
+ - `mm-harness run` writes a concise human report (`report.md`) and prints a colored step summary for every adapter, so successful Core/Mobile/Extension recipe runs are scannable without opening raw trace files.
10
+
11
+ ### Fixed
12
+ - Mobile slot targeting now refuses a platform launch/run/call when `--platform ios|android` conflicts with the configured default target, preventing an iOS-pinned slot from silently stealing a shared Android device; `status` and `doctor` scope device lists to the configured target by default and `status` reports other reachable bridges as additional targets that can be driven with `--device`.
13
+ - Mobile `status` bases `Next:` on the configured default target only, avoids assigning an additional Android bridge to a same-platform selected device, and reports stale Android reverse ports when the selected device is not connected to the slot Metro.
14
+ - Mobile `doctor` now reports the same additional reachable targets as `status`, so diagnostics and the home view agree on which devices can be driven with `--device`.
15
+ - Mobile `status` and `doctor` reuse one bridge probe for scoped and additional reachable devices, preserving the bounded probe window when the bridge is down.
16
+ - Mobile slot env resolution now preserves a configured Android Metro device name when an ADB serial is also present, instead of overwriting it with the serial.
17
+ - `mm-harness run <action-name>` now teaches `mm-harness call <action-name>` when the argument matches an action instead of a recipe, before requiring `--artifacts-dir`.
18
+ - Core Perps recipes now fail fast with a targeted `yarn install --immutable` teaching step when the target checkout is missing controller dependencies, including Perps actions reached through called library flows.
19
+ - Extension quick reattach refreshes the loaded runtime-dist in place and preserves an explicitly requested dapp/start URL instead of closing it during fullscreen cleanup.
20
+ - `mm-harness check diff` ignores its own artifact directory when that directory lives inside the target checkout, so repeated runs do not validate generated check artifacts.
21
+ - Mobile launch treats missing slot simulators or missing dev clients as provisioning failures, not Metro-healable failures, and prints the matching `mm-harness provision runway ios` recovery command without retrying the wrong layer.
22
+ - Mobile Runway provisioning recreates a deleted slot simulator by resolving the latest available iOS runtime and iPhone simulator type from `simctl`, so accidental simulator deletion is one-command repairable.
23
+ - Extension readiness validates local-only CDP websocket targets and sanitizes slot title data before stamping browser tab titles.
24
+
25
+ ## 0.14.0 - 2026-07-08
26
+
27
+ ### Added
28
+ - `mm-harness call` accepts action fields as direct `key=value` shorthand in addition to repeated `--arg k=v`, so commands like `mm-harness call ui.navigate page=perps` now match the examples agents naturally write.
29
+ - Packaged recipes are grouped by domain under `library/recipes/<domain>/` and still resolve by dotted names (`perps.smoke`, `runner.smoke`) with legacy aliases for the former flat names.
30
+ - Packaged `perps.smoke` recipes for mobile, extension, and core provide an explicit non-mutating Perps domain smoke. The existing bare `smoke` alias remains a runner/transport smoke for compatibility.
31
+
32
+ ### Fixed
33
+ - The top-level help SLOT banner now labels the slot dev-server port by adapter, so extension checkouts show `webpack :<port>` instead of the mobile-only `metro :<port>`.
34
+ - Hidden internal runtime commands remain executable through the installed `mm-harness` bin, fixing Extension verify warnings where `runtime-decision` returned `{}` because the wrapper blocked its own shipped helper.
35
+ - Extension launch/readiness now stamps the live home tab title with the Farmslot slot id after MetaMask has settled, so headed browser windows identify their owning slot.
36
+ - Extension quick launch now reattaches to a live slot browser when CDP belongs to this checkout's Chrome profile and dist is fresh, even if the webpack watcher is not currently listening; foreign CDP profiles still force the clean path.
37
+ - `status` now hydrates slot runtime context before device enumeration, so a Farmslot checkout pinned to `IOS_SIMULATOR=mm-1` reports only the selected slot simulator instead of every booted simulator/device on the machine. Live status also updates `Next:` to `mm-harness logs` when the selected bridge is reachable.
38
+ - The tmux Metro window opened by `launch ios|android` now streams the Metro output it writes to `metro.log` instead of opening a blank server window.
39
+ - `log-tui watch` now matches success/failure patterns only against log content emitted after the watcher starts, so stale markers already present in long-lived Metro/webpack logs cannot false-pass readiness checks.
40
+ - `start-metro` now reaps any existing mobile console-forwarder attached to the same Metro port before spawning this slot's forwarder, even when the old process was launched from another checkout or package install path.
41
+
3
42
  ## 0.13.0 - 2026-07-08
4
43
 
5
44
  ### Added
@@ -29,7 +68,7 @@
29
68
  ### Added
30
69
  - Mobile recipes can now use the standard outer `app.lifecycle` action for deterministic performance start states without rebuilding (Android background/foreground/terminate/restart, iOS simulator launch/terminate/restart through the shared Farmslot lifecycle adapter).
31
70
  - `fixtures set --device <serial|udid|name>` now pins mobile fixture sync to the requested device using the same targeting rules as `run`, `call`, and `doctor`.
32
- - New lifecycle-controlled measured flows in the packaged library: `app-lifecycle-android-smoke`, `perps-performance-warm-start`, `perps-performance-background-resume`, `perps-performance-cold-start`; performance recipes now keep lifecycle/unlock in `setup[]`/`startState` so measured nodes carry only CUF timings.
71
+ - New lifecycle-controlled measured flows in the packaged library: `app.lifecycle.android-smoke`, `perps.performance.warm-start`, `perps.performance.background-resume`, `perps.performance.cold-start`; performance recipes now keep lifecycle/unlock in `setup[]`/`startState` so measured nodes carry only CUF timings.
33
72
 
34
73
  ### Changed
35
74
  - `@farmslot/recipe-harness` dependency raised to `^0.4.0` (ships the `app.lifecycle` adapter — the `FARMSLOT_ROOT` local-source fallback is no longer needed at runtime).
@@ -46,9 +85,9 @@
46
85
  ## 0.10.0 - 2026-07-07
47
86
 
48
87
  ### Added
49
- - **Canonical `perps-performance` measured flow** (`library/recipes/perps-performance.mobile.recipe.json`): unlock → open the Perps market list → read live state → open a market detail — one node per user-visible step with stable node names, so the per-node `duration`s in `trace.json` are the timings you monitor. Run pinned: `mm-harness run perps-performance --device <serial> --heal off`. Device-proven end-to-end on a physical Pixel.
88
+ - **Canonical `perps.performance` measured flow** (`library/recipes/perps/performance.mobile.recipe.json`): unlock → open the Perps market list → read live state → open a market detail — one node per user-visible step with stable node names, so the per-node `duration`s in `trace.json` are the timings you monitor. Run pinned: `mm-harness run perps.performance --device <serial> --heal off`. Device-proven end-to-end on a physical Pixel.
50
89
  - **`run <name>` resolves personal/team recipe libraries.** Previously only the packaged library was probed by name; custom recipes ran by path. Sources resolve in library-precedence order (personal > team shadow the packaged canonical — a same-named personal recipe wins), path-shaped args never probe libraries, and a miss teaches which sources were searched. Zero-flag default: `$FARMSLOT_HOME/recipe-library`.
51
- - **Custom-library walkthrough** ("Your own measured flow" in `docs/recipe-libraries.md`): a peer engineer scaffolds a personal library, copies the canonical flow, retargets the nodes to their journey, and runs it by name. Every step is executed by the `perps-performance-recipe` contract test, so the doc cannot drift from reality.
90
+ - **Custom-library walkthrough** ("Your own measured flow" in `docs/recipe-libraries.md`): a peer engineer scaffolds a personal library, copies the canonical flow, retargets the nodes to their journey, and runs it by name. Every step is executed by the `perps.performance-recipe` contract test, so the doc cannot drift from reality.
52
91
  - **`call` declared on mobile and extension manifests** (was core-only) with self-discovery metadata — personal `flows/` segments are now usable from mobile/extension recipes. Honest limitation documented: flows carry no adapter dimension in the protocol yet, so a cross-adapter `call` fails at live-run rather than plan time.
53
92
 
54
93
  ### Fixed
@@ -71,7 +110,7 @@
71
110
  ## 0.8.0 - 2026-07-07
72
111
 
73
112
  ### Added
74
- - **`run <name>` — the recipe positional resolves packaged-library recipe names.** An existing file path (absolute or cwd-relative) wins; otherwise the arg is a library recipe name, adapter-scoped first (`run smoke` on mobile → `smoke.mobile.recipe.json`), then adapterless (`run perps-lifecycle`), then the exact library filename. A miss teaches `RECIPE_NOT_FOUND` naming the library recipes available for the adapter — no more `$LIB`-style path prefixes in docs or demo scripts. A directory shadowing a library name never wins, and path-shaped args (containing a separator) never fall through to the library.
113
+ - **`run <name>` — the recipe positional resolves packaged-library recipe names.** An existing file path (absolute or cwd-relative) wins; otherwise the arg is a library recipe name, adapter-scoped first (`run smoke` on mobile → `library/recipes/runner/smoke.mobile.recipe.json`), then adapterless (`run perps.lifecycle` → `library/recipes/perps/lifecycle.recipe.json`), then the exact library filename. A miss teaches `RECIPE_NOT_FOUND` naming the library recipes available for the adapter — no more `$LIB`-style path prefixes in docs or demo scripts. A directory shadowing a library name never wins, and path-shaped args (containing a separator) never fall through to the library.
75
114
  - **`--device <udid|serial|name>` — first-class mobile device targeting on `run`, `call`, and `doctor`.** Resolves against connected devices (adb + booted simulators; exact id first, then exact name with an ambiguity teaching error) and sets the same env `launch --device` does. Mobile `run`/`call` **without** `--device` while more than one targetable device is connected (across android + ios; android state `device`, iOS `Booted`) fail fast listing the devices and the `--device` hint, so a recipe never lands on the wrong device silently. `run --plan`/`--list` are exempt; extension/core teach. Errors honor `--json` with structured envelopes.
76
115
  - **`status` gains `devices[]`** (additive): `{platform, id, name, state, selected}` for connected android devices and booted iOS simulators on mobile checkouts — the `yarn a:status` successor; `selected` reflects the current env pinning.
77
116
 
@@ -127,6 +127,7 @@ copyFile(path.join(runnerDir, 'adapters/shared/path-defaults.json'), path.join(h
127
127
  copyFile(path.join(runnerDir, 'adapters/shared/json-field.sh'), path.join(harnessDir, 'scripts/lib/json-field.sh'));
128
128
  copyFile(path.join(runnerDir, 'adapters/shared/tmux-session.sh'), path.join(harnessDir, 'scripts/lib/tmux-session.sh'));
129
129
  copyFile(path.join(runnerDir, 'adapters/shared/log-tui.mjs'), path.join(harnessDir, 'scripts/lib/log-tui.mjs'));
130
+ copyFile(path.join(runnerDir, 'adapters/extension/lib/extension-id.cjs'), path.join(harnessDir, 'scripts/lib/extension-id.cjs'));
130
131
  makeExecutableTree(path.join(harnessDir, 'scripts'));
131
132
  fs.writeFileSync(path.join(harnessDir, 'installed-scripts.sha256'), `${dirContentHash(path.join(harnessDir, 'scripts'))}\n`);
132
133
 
@@ -23,6 +23,7 @@ const fs = require('node:fs');
23
23
  const os = require('node:os');
24
24
  const path = require('node:path');
25
25
  const { execFileSync, spawn, spawnSync } = require('node:child_process');
26
+ const { extensionIdFromExtensionDir } = require('./lib/extension-id.cjs');
26
27
 
27
28
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
28
29
  console.log(
@@ -74,6 +75,10 @@ const ownedPids = new Set(listenerPids);
74
75
  const previousPid = readPidFile(args['chrome-pid']);
75
76
  if (previousPid !== null && processLoadsProfile(previousPid, args.profile)) ownedPids.add(previousPid);
76
77
  terminatePids([...ownedPids]);
78
+ if (args['reset-profile'] !== undefined) {
79
+ fs.rmSync(args.profile, { recursive: true, force: true });
80
+ fs.mkdirSync(args.profile, { recursive: true });
81
+ }
77
82
  removeProfileSingletonLocks(args.profile);
78
83
 
79
84
  // --stop-only: release the profile (owned Chrome terminated, singleton locks
@@ -87,6 +92,7 @@ if (args['stop-only'] !== undefined) {
87
92
  const logFd = fs.openSync(args['chrome-log'], 'a');
88
93
  let child;
89
94
  try {
95
+ const initialUrl = args['start-url'] || extensionHomeUrl(args['extension-dir']) || 'chrome://extensions/';
90
96
  child = spawn(args['chrome-bin'], [
91
97
  `--user-data-dir=${args.profile}`,
92
98
  '--remote-debugging-address=127.0.0.1',
@@ -100,9 +106,10 @@ try {
100
106
  '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
101
107
  `--disable-extensions-except=${args['extension-dir']}`,
102
108
  `--load-extension=${args['extension-dir']}`,
103
- // Initial tab: the caller's dapp URL when provided (sidepanel demos pair the
104
- // extension sidepanel with a dapp in the main tab), else the extensions page.
105
- args['start-url'] || 'chrome://extensions/',
109
+ // Initial tab: caller's dapp URL when provided, else the extension home. The
110
+ // extension home avoids leaving disposable chrome://newtab/ or extensions
111
+ // manager tabs in the operator's slot browser.
112
+ initialUrl,
106
113
  ], {
107
114
  detached: true,
108
115
  env: {
@@ -228,3 +235,8 @@ function removeProfileSingletonLocks(profile) {
228
235
  if (fs.existsSync(file)) fs.rmSync(file, { force: true });
229
236
  }
230
237
  }
238
+
239
+ function extensionHomeUrl(extensionDir) {
240
+ const id = extensionIdFromExtensionDir(extensionDir);
241
+ return id ? `chrome-extension://${id}/home.html` : '';
242
+ }
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ // Chromium extension IDs encode each SHA-256 nibble as a-p.
8
+ const CHROMIUM_EXTENSION_ID_ALPHABET = 'abcdefghijklmnop';
9
+
10
+ function extensionIdFromManifestKey(keyBase64) {
11
+ if (typeof keyBase64 !== 'string' || !keyBase64) return '';
12
+ const digest = crypto.createHash('sha256').update(Buffer.from(keyBase64, 'base64')).digest();
13
+ return [...digest.subarray(0, 16)]
14
+ .map((byte) => `${CHROMIUM_EXTENSION_ID_ALPHABET[byte >> 4]}${CHROMIUM_EXTENSION_ID_ALPHABET[byte & 0x0f]}`)
15
+ .join('');
16
+ }
17
+
18
+ function extensionIdFromManifestFile(manifestPath) {
19
+ try {
20
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
21
+ return extensionIdFromManifestKey(manifest.key);
22
+ } catch {
23
+ return '';
24
+ }
25
+ }
26
+
27
+ function extensionIdFromExtensionDir(extensionDir) {
28
+ return extensionIdFromManifestFile(path.join(extensionDir, 'manifest.json'));
29
+ }
30
+
31
+ module.exports = {
32
+ CHROMIUM_EXTENSION_ID_ALPHABET,
33
+ extensionIdFromManifestKey,
34
+ extensionIdFromManifestFile,
35
+ extensionIdFromExtensionDir,
36
+ };
@@ -90,6 +90,8 @@ if [ -z "$VERIFY_SH" ]; then
90
90
  fi
91
91
  TARGET="$(cd "$TARGET" && pwd)"
92
92
  RUNTIME_DIR="$(recipe_runtime_dir)"
93
+ RUNTIME_DIST_DIR="${RECIPE_RUNTIME_DIST_DIR:-runtime-dist}"
94
+ CHROME_PROFILE_NAME="${RECIPE_CHROME_PROFILE_NAME:-chrome-profile}"
93
95
  # Runner bin (installed wrapper → source runner). Used by the watch prepare path
94
96
  # to defer the cache-clear DECISION to `runtime-decision` (single source) and to
95
97
  # record the deps/cache baseline after a confirmed-good build.
@@ -118,12 +120,12 @@ if $LAUNCH_EXISTING_DIST && [ -z "$PREPARE_CMD" ]; then
118
120
  # Stable beside the profile: the reused profile registers the unpacked
119
121
  # extension by PATH; a per-run snapshot path gets pruned and the next launch
120
122
  # loads nothing ("No EIP-6963 Provider Detected").
121
- RUNTIME_DIST_ABS="$TARGET/temp/recipe/runtime/runtime-dist"
123
+ RUNTIME_DIST_ABS="$TARGET/$RUNTIME_DIR/$RUNTIME_DIST_DIR"
122
124
  # Stable per-checkout profile: successive runs recognize their own Chrome
123
125
  # (ownership guard matches on --user-data-dir), take it over cleanly, and the
124
126
  # seeded wallet persists across relaunches. A per-run profile made every
125
127
  # relaunch "foreign" and let live/ pruning delete profiles still in use.
126
- PROFILE_ABS="${CHROME_USER_DATA_DIR:-$TARGET/temp/recipe/runtime/chrome-profile}"
128
+ PROFILE_ABS="${CHROME_USER_DATA_DIR:-$TARGET/$RUNTIME_DIR/$CHROME_PROFILE_NAME}"
127
129
  FIXTURE_STATE_ABS="$ARTIFACTS/fixture-state.json"
128
130
  FIXTURE_VALIDATION_ABS="$ARTIFACTS/logs/fixture-account-parity.json"
129
131
  # Wallet fixture resolution chain + provenance live in seed-fixture.sh.
@@ -241,7 +243,7 @@ NODE
241
243
  if $START_WATCH; then
242
244
  prepare_parts+=("bash ${quoted_start_watch} --runtime-dir ${quoted_runtime_dir} --runner-bin ${quoted_runner}")
243
245
  fi
244
- prepare_parts+=("node ${quoted_chrome_launcher} --stop-only 1 --chrome-bin ${quoted_chrome} --profile ${quoted_profile} --cdp-port ${CDP_PORT} --extension-dir ${quoted_runtime_dist} --chrome-log ${quoted_chrome_log} --chrome-pid ${quoted_chrome_pid}")
246
+ prepare_parts+=("node ${quoted_chrome_launcher} --stop-only 1 --reset-profile 1 --chrome-bin ${quoted_chrome} --profile ${quoted_profile} --cdp-port ${CDP_PORT} --extension-dir ${quoted_runtime_dist} --chrome-log ${quoted_chrome_log} --chrome-pid ${quoted_chrome_pid}")
245
247
  prepare_parts+=("bash ${quoted_snapshot_dist} --dist ${quoted_dist} --runtime-dist ${quoted_runtime_dist}")
246
248
  # Optional A/B feature-flag pinning: patch the ephemeral snapshot manifest so
247
249
  # manifest._flags wins over the fetched ClientConfigApi value. No-op unless
@@ -100,6 +100,28 @@ function readExpectedExtensionId(target) {
100
100
  return /^[a-p]{32}$/.test(id) ? id : '';
101
101
  }
102
102
 
103
+ function readSlotId(target) {
104
+ for (const key of ['RECIPE_SLOT_ID', 'SLOT_ID', 'FARMSLOT_SLOT_ID']) {
105
+ const value = sanitizeSlotId(process.env[key]);
106
+ if (value) return value;
107
+ }
108
+ try {
109
+ const runtimeContext = JSON.parse(fs.readFileSync(path.join(target, recipeRuntimeDir(), 'agentic-runtime.json'), 'utf8'));
110
+ const value = sanitizeSlotId(runtimeContext.slotId);
111
+ if (value) {
112
+ return value;
113
+ }
114
+ } catch {
115
+ // No slot context: standalone harness users do not need a custom browser title.
116
+ }
117
+ return '';
118
+ }
119
+
120
+ function sanitizeSlotId(value) {
121
+ const slotId = typeof value === 'string' ? value.trim() : '';
122
+ return /^[A-Za-z0-9._:-]{1,64}$/u.test(slotId) ? slotId : '';
123
+ }
124
+
103
125
  function writeExtensionId(target, extensionId) {
104
126
  if (!/^[a-p]{32}$/.test(extensionId)) return false;
105
127
  const idPath = extensionIdPath(target);
@@ -118,6 +140,12 @@ function assertLocalUrl(url) {
118
140
  }
119
141
  }
120
142
 
143
+ function assertLocalWebSocketUrl(url) {
144
+ if (!/^ws:\/\/(?:127\.0\.0\.1|localhost):\d+\//u.test(String(url))) {
145
+ throw new Error(`readiness: refusing non-local websocket URL: ${url}`);
146
+ }
147
+ }
148
+
121
149
  function httpJson(url, timeoutMs = 3000) {
122
150
  assertLocalUrl(url);
123
151
  return new Promise((resolve, reject) => {
@@ -176,6 +204,7 @@ function resolveWebSocket(target) {
176
204
  }
177
205
 
178
206
  async function cdpEvaluate(target, webSocketDebuggerUrl, expression, timeoutMs = 5000) {
207
+ assertLocalWebSocketUrl(webSocketDebuggerUrl);
179
208
  const WebSocketImpl = resolveWebSocket(target);
180
209
  if (!WebSocketImpl) return { skipped: true, reason: 'WebSocket unavailable in this Node runtime' };
181
210
  return new Promise((resolve, reject) => {
@@ -333,6 +362,7 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
333
362
  // Inspect only when the chosen page exposes a websocket. A present-but-attached
334
363
  // tab has none; skip inspection and report it rather than opening a duplicate.
335
364
  if (pageTarget && typeof pageTarget.webSocketDebuggerUrl === 'string') {
365
+ const slotId = readSlotId(target);
336
366
  ui = await cdpEvaluate(
337
367
  target,
338
368
  pageTarget.webSocketDebuggerUrl,
@@ -373,6 +403,24 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
373
403
  report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } },
374
404
  });
375
405
  }
406
+ if (pageInspected && slotId) {
407
+ const stampedTitle = await cdpEvaluate(
408
+ target,
409
+ pageTarget.webSocketDebuggerUrl,
410
+ `(() => {
411
+ const slot = ${JSON.stringify(slotId)};
412
+ const current = document.title || 'MetaMask';
413
+ const base = current.replace(/^.+?\\s+—\\s+/, '') || 'MetaMask';
414
+ const next = slot + ' — ' + base;
415
+ if (document.title !== next) document.title = next;
416
+ return document.title;
417
+ })()`,
418
+ );
419
+ if (stampedTitle && !stampedTitle.skipped) {
420
+ ui.title = stampedTitle;
421
+ ui.slotTitleApplied = true;
422
+ }
423
+ }
376
424
  }
377
425
  return {
378
426
  browser: version.Browser || 'unknown',
@@ -33,6 +33,7 @@ START_URL="${EXTENSION_START_URL:-}"
33
33
  EXT_ID="${EXT_ID:-}"
34
34
  DIST_DIR="dist/chrome"
35
35
  SETTLE_MS="8000"
36
+ DISPLAY_MODE="${EXTENSION_DISPLAY_MODE:-fullscreen}"
36
37
 
37
38
  require_value() { [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; }
38
39
  while [ "$#" -gt 0 ]; do
@@ -44,8 +45,9 @@ while [ "$#" -gt 0 ]; do
44
45
  --ext-id) require_value "$@"; EXT_ID="$2"; shift 2 ;;
45
46
  --dist-dir) require_value "$@"; DIST_DIR="$2"; shift 2 ;;
46
47
  --settle-ms) require_value "$@"; SETTLE_MS="$2"; shift 2 ;;
48
+ --display-mode) require_value "$@"; DISPLAY_MODE="$2"; shift 2 ;;
47
49
  -h|--help)
48
- echo "Usage: reattach.sh --target <metamask-extension> --cdp-port <port> [--watcher-port <port>] [--start-url <url>] [--ext-id <id>] [--dist-dir <rel>] [--settle-ms <ms>]"
50
+ echo "Usage: reattach.sh --target <metamask-extension> --cdp-port <port> [--watcher-port <port>] [--start-url <url>] [--ext-id <id>] [--dist-dir <rel>] [--settle-ms <ms>] [--display-mode fullscreen|sidepanel]"
49
51
  exit 0
50
52
  ;;
51
53
  *) echo "reattach: unknown arg: $1" >&2; exit 2 ;;
@@ -60,6 +62,10 @@ fi
60
62
  case "$CDP_PORT" in
61
63
  *[!0-9]*) echo "reattach: --cdp-port must be numeric (got: $CDP_PORT)" >&2; exit 2 ;;
62
64
  esac
65
+ case "$DISPLAY_MODE" in
66
+ fullscreen|sidepanel) ;;
67
+ *) echo "reattach: --display-mode must be fullscreen or sidepanel (got: $DISPLAY_MODE)" >&2; exit 2 ;;
68
+ esac
63
69
 
64
70
  if [ ! -f "$TARGET/package.json" ]; then
65
71
  echo "reattach: --target is not a checkout (no package.json): $TARGET" >&2
@@ -75,23 +81,33 @@ if ! curl -s -m 3 "http://127.0.0.1:${CDP_PORT}/json/version" >/dev/null 2>&1; t
75
81
  fi
76
82
 
77
83
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
84
+ # shellcheck disable=SC1091
85
+ for _hp in "$SCRIPT_DIR/lib/harness-path.sh" "$SCRIPT_DIR/../../adapters/shared/harness-path.sh" "$SCRIPT_DIR/../shared/harness-path.sh"; do
86
+ [ -f "$_hp" ] && { . "$_hp"; break; }
87
+ done
88
+ unset _hp
78
89
  DIST_ABS="$TARGET/$DIST_DIR"
79
- # The running Chrome loads the runtime-dist snapshot (frozen at launch), not the
80
- # live dist. Re-snapshot so the in-place reload serves the latest watched build.
81
- RUNTIME_DIST_ABS="$TARGET/temp/recipe/runtime/runtime-dist"
82
-
83
- if [ -f "$SCRIPT_DIR/snapshot-dist.sh" ] && [ -d "$RUNTIME_DIST_ABS" ]; then
84
- echo "[reattach] re-snapshotting dist -> runtime-dist for in-place reload" >&2
85
- bash "$SCRIPT_DIR/snapshot-dist.sh" --dist "$DIST_ABS" --runtime-dist "$RUNTIME_DIST_ABS" >&2 || {
86
- echo "reattach: dist re-snapshot failed; the loaded extension may be mid-rebuild." >&2
87
- echo " Next: mm-harness launch --build (clean build + relaunch)" >&2
88
- exit 1
89
- }
90
+ if command -v recipe_runtime_dir >/dev/null 2>&1; then
91
+ RUNTIME_DIR="$(recipe_runtime_dir)"
92
+ else
93
+ RUNTIME_DIR="${RECIPE_RUNTIME_DIR:-temp/recipe/runtime}"
94
+ fi
95
+ RUNTIME_DIST_DIR="${RECIPE_RUNTIME_DIST_DIR:-runtime-dist}"
96
+ # The running Chrome loads this unpacked extension directory. Do not recreate or
97
+ # rsync-delete it while Chrome is live: Chrome can keep the intended
98
+ # chrome-extension:// target URL but serve chrome-error://chromewebdata after its
99
+ # loaded directory is replaced underneath it. Fresh snapshots belong to the clean
100
+ # relaunch path; quick reattach only reloads the already-loaded runtime.
101
+ RUNTIME_DIST_ABS="$TARGET/$RUNTIME_DIR/$RUNTIME_DIST_DIR"
102
+ echo "[reattach] reusing loaded runtime-dist: $RUNTIME_DIST_ABS" >&2
103
+ if [ -d "$DIST_ABS" ] && [ -d "$RUNTIME_DIST_ABS" ]; then
104
+ echo "[reattach] refreshing loaded runtime-dist in place" >&2
105
+ rsync -a --exclude _metadata "$DIST_ABS/" "$RUNTIME_DIST_ABS/" >&2
90
106
  fi
91
107
 
92
108
  # Resolve the extension id: explicit flag, then the recorded id file, else CDP.
93
109
  if [ -z "$EXT_ID" ]; then
94
- for idf in "$TARGET/temp/recipe/runtime/extension.id" "$SCRIPT_DIR/extension.id"; do
110
+ for idf in "$TARGET/$RUNTIME_DIR/extension.id" "$SCRIPT_DIR/extension.id"; do
95
111
  if [ -f "$idf" ]; then
96
112
  EXT_ID="$(tr -d '[:space:]' < "$idf")"
97
113
  [ -n "$EXT_ID" ] && break
@@ -102,23 +118,82 @@ fi
102
118
  cd "$TARGET"
103
119
  echo "[reattach] reloading extension in place over CDP :${CDP_PORT}" >&2
104
120
 
105
- CDP_PORT="$CDP_PORT" EXT_ID="$EXT_ID" START_URL="$START_URL" SETTLE_MS="$SETTLE_MS" node <<'NODE'
121
+ CDP_PORT="$CDP_PORT" EXT_ID="$EXT_ID" TARGET="$TARGET" SCRIPT_DIR="$SCRIPT_DIR" RUNTIME_DIR="$RUNTIME_DIR" RUNTIME_DIST_DIR="$RUNTIME_DIST_DIR" START_URL="$START_URL" SETTLE_MS="$SETTLE_MS" DISPLAY_MODE="$DISPLAY_MODE" node <<'NODE'
106
122
  let chromium; try { chromium = require('@playwright/test').chromium; } catch { chromium = require('playwright').chromium; }
123
+ const fs = require('node:fs');
124
+ const path = require('node:path');
125
+ const { extensionIdFromManifestFile } = require(path.join(process.env.SCRIPT_DIR, 'lib/extension-id.cjs'));
107
126
 
108
127
  const port = process.env.CDP_PORT;
128
+ const target = process.env.TARGET;
129
+ const runtimeDir = process.env.RUNTIME_DIR;
130
+ const runtimeDistDir = process.env.RUNTIME_DIST_DIR;
109
131
  const startUrl = process.env.START_URL || '';
110
132
  const settleMs = Number(process.env.SETTLE_MS || '8000') || 8000;
133
+ const displayMode = process.env.DISPLAY_MODE || 'fullscreen';
134
+ const slotId = process.env.RECIPE_SLOT_ID || process.env.SLOT_ID || process.env.FARMSLOT_SLOT_ID || readRuntimeSlotId();
135
+
136
+ if (!runtimeDir || !runtimeDistDir) {
137
+ console.error('reattach: resolved runtime paths were not provided.');
138
+ process.exit(2);
139
+ }
140
+
141
+ function readRuntimeSlotId() {
142
+ try {
143
+ const runtimeContext = JSON.parse(fs.readFileSync(path.join(target, runtimeDir, 'agentic-runtime.json'), 'utf8'));
144
+ return typeof runtimeContext.slotId === 'string' ? runtimeContext.slotId.trim() : '';
145
+ } catch {
146
+ return '';
147
+ }
148
+ }
149
+
150
+ function extensionIdFromRuntimeDist() {
151
+ return extensionIdFromManifestFile(path.join(target, runtimeDir, runtimeDistDir, 'manifest.json'));
152
+ }
153
+
154
+ function validExtensionId(value) {
155
+ return /^[a-p]{32}$/u.test(String(value || '')) ? String(value) : '';
156
+ }
157
+
158
+ function writeExtensionId(id) {
159
+ if (!validExtensionId(id)) return;
160
+ try {
161
+ const idPath = path.join(target, runtimeDir, 'extension.id');
162
+ fs.mkdirSync(path.dirname(idPath), { recursive: true });
163
+ fs.writeFileSync(idPath, `${id}\n`);
164
+ } catch {
165
+ // Best-effort marker repair; the live CDP reload is the source of truth.
166
+ }
167
+ }
111
168
 
112
169
  function extensionIdFrom(context, provided) {
113
- if (provided) return provided;
114
170
  const urls = [];
115
- for (const page of context.pages()) urls.push(page.url());
116
- for (const worker of context.serviceWorkers()) urls.push(worker.url());
171
+ const ids = [];
172
+ for (const page of allPages(context)) urls.push(page.url());
173
+ for (const worker of allServiceWorkers(context)) urls.push(worker.url());
117
174
  for (const url of urls) {
118
175
  const match = /^chrome-extension:\/\/([^/]+)\//u.exec(url);
119
- if (match) return match[1];
176
+ if (match && !ids.includes(match[1])) ids.push(match[1]);
177
+ }
178
+ if (provided) {
179
+ if (ids.length === 0 || ids.includes(provided)) return provided;
180
+ return '';
120
181
  }
121
- return '';
182
+ return ids[0] || '';
183
+ }
184
+
185
+ function allContexts(context) {
186
+ const browser = context.browser?.();
187
+ const contexts = browser?.contexts?.();
188
+ return contexts && contexts.length ? contexts : [context];
189
+ }
190
+
191
+ function allPages(context) {
192
+ return allContexts(context).flatMap((candidate) => candidate.pages());
193
+ }
194
+
195
+ function allServiceWorkers(context) {
196
+ return allContexts(context).flatMap((candidate) => candidate.serviceWorkers());
122
197
  }
123
198
 
124
199
  async function connect() {
@@ -127,20 +202,56 @@ async function connect() {
127
202
  return { browser, context };
128
203
  }
129
204
 
205
+ async function stampSlotTitle(page) {
206
+ if (!slotId || !page) return;
207
+ await page.evaluate((slot) => {
208
+ const base = (document.title || 'MetaMask').replace(/^.+?\s+—\s+/, '') || 'MetaMask';
209
+ const next = `${slot} — ${base}`;
210
+ if (document.title !== next) document.title = next;
211
+ }, slotId).catch(() => {
212
+ // Best-effort operator affordance; reload success is verified by CDP state.
213
+ });
214
+ }
215
+
216
+ async function closeDisposableChromeTabs(context) {
217
+ for (const page of allPages(context)) {
218
+ const url = page.url();
219
+ if (url === 'chrome://newtab/' || url === 'chrome://new-tab-page/' || url === 'chrome://extensions/') {
220
+ await page.close().catch(() => {
221
+ // Best-effort tab hygiene; runtime health is verified separately.
222
+ });
223
+ }
224
+ }
225
+ }
226
+
227
+ async function closePages(context, predicate) {
228
+ for (const page of allPages(context)) {
229
+ if (!predicate(page)) continue;
230
+ await page.close().catch(() => {
231
+ // Best-effort display-mode cleanup; final health checks own correctness.
232
+ });
233
+ }
234
+ }
235
+
130
236
  (async () => {
131
- // Phase 1: trigger a full extension reload (service worker + all pages) from an
132
- // extension page so Chrome re-reads the freshly snapshotted runtime-dist.
237
+ // Phase 1: attach to the already-loaded extension page. Do not call
238
+ // chrome.runtime.reload() here: on the slot Chrome version it can leave the
239
+ // target URL as chrome-extension://.../home.html while the actual document is
240
+ // chrome-error://chromewebdata. Clean relaunch owns loading a new unpacked
241
+ // runtime-dist; quick reattach only refreshes/foregrounds the live UI.
133
242
  let { browser, context } = await connect();
134
- const extId = extensionIdFrom(context, process.env.EXT_ID);
243
+ const providedExtId = extensionIdFromRuntimeDist() || validExtensionId(process.env.EXT_ID);
244
+ const extId = extensionIdFrom(context, providedExtId);
135
245
  if (!extId) {
136
- await browser.close();
137
- console.error('reattach: could not resolve the extension id from the live CDP targets.');
246
+ if (typeof browser.disconnect === 'function') await browser.disconnect();
247
+ else await browser.close();
248
+ console.error('reattach: could not resolve the extension id from runtime-dist or live CDP targets.');
138
249
  console.error(' Next: mm-harness launch --build (clean build + relaunch)');
139
250
  process.exit(1);
140
251
  }
252
+ writeExtensionId(extId);
141
253
 
142
- let page = context
143
- .pages()
254
+ let page = allPages(context)
144
255
  .find((candidate) => candidate.url().startsWith(`chrome-extension://${extId}/`));
145
256
  if (!page) {
146
257
  page = await context.newPage();
@@ -149,35 +260,19 @@ async function connect() {
149
260
  timeout: 15000,
150
261
  });
151
262
  }
152
- await page.evaluate(() => chrome.runtime.reload()).catch(() => {
153
- // The reload tears the page context down; a rejected evaluate is expected.
263
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }).catch(async () => {
264
+ await page.goto(`chrome-extension://${extId}/home.html`, {
265
+ waitUntil: 'domcontentloaded',
266
+ timeout: 15000,
267
+ });
154
268
  });
155
- await browser.close();
156
-
157
- // Phase 2: wait for the extension to come back, then re-open the wallet and,
158
- // when requested, a dapp tab. Chrome itself never restarted, so CDP stays up.
159
- const deadline = Date.now() + settleMs;
160
- let back = false;
161
- while (Date.now() < deadline) {
162
- try {
163
- const probe = await connect();
164
- const id = extensionIdFrom(probe.context, extId);
165
- await probe.browser.close();
166
- if (id) { back = true; break; }
167
- } catch {
168
- // CDP momentarily unavailable during reload; retry until the deadline.
169
- }
170
- await new Promise((resolve) => setTimeout(resolve, 250));
171
- }
172
- if (!back) {
173
- console.error(`reattach: extension did not come back within ${settleMs}ms after reload.`);
174
- console.error(' Next: mm-harness launch --build (clean build + relaunch)');
175
- process.exit(1);
176
- }
269
+ if (typeof browser.disconnect === 'function') await browser.disconnect();
270
+ else await browser.close();
177
271
 
272
+ // Phase 2: re-open/retitle the wallet and, when requested, a dapp tab. Chrome
273
+ // itself never restarted, so CDP stays up.
178
274
  ({ browser, context } = await connect());
179
- let home = context
180
- .pages()
275
+ let home = allPages(context)
181
276
  .find(
182
277
  (candidate) =>
183
278
  candidate.url().startsWith(`chrome-extension://${extId}/`) &&
@@ -189,16 +284,29 @@ async function connect() {
189
284
  .goto(`chrome-extension://${extId}/home.html`, { waitUntil: 'domcontentloaded', timeout: 15000 })
190
285
  .catch(() => { /* wallet home is best-effort; the reload already applied */ });
191
286
  }
287
+ await stampSlotTitle(home);
288
+ await closeDisposableChromeTabs(context);
192
289
  if (startUrl) {
193
- const dapp = context
194
- .pages()
290
+ const dapp = allPages(context)
195
291
  .find((candidate) => candidate.url().startsWith('http://') || candidate.url().startsWith('https://'));
196
292
  const tab = dapp || (await context.newPage());
197
293
  await tab
198
294
  .goto(startUrl, { waitUntil: 'domcontentloaded', timeout: 20000 })
199
295
  .catch(() => { /* dapp navigation is best-effort */ });
200
296
  }
201
- await browser.close();
297
+ if (displayMode === 'fullscreen') {
298
+ await closePages(context, (candidate) => {
299
+ const url = candidate.url();
300
+ if (url.startsWith(`chrome-extension://${extId}/`) && url.includes('/sidepanel.html')) return true;
301
+ if (!startUrl && (url.startsWith('http://') || url.startsWith('https://'))) return true;
302
+ return false;
303
+ });
304
+ await home.bringToFront().catch(() => {
305
+ // Foreground is an operator affordance; health is verified separately.
306
+ });
307
+ }
308
+ if (typeof browser.disconnect === 'function') await browser.disconnect();
309
+ else await browser.close();
202
310
  })().catch((error) => {
203
311
  console.error(`reattach: ${error && error.message ? error.message : error}`);
204
312
  console.error(' Next: mm-harness launch --build (clean build + relaunch)');