@deeeed/metamask-harness 0.58.0 → 0.60.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 (53) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +48 -0
  3. package/adapters/extension/build-handoff.cjs +4 -0
  4. package/adapters/extension/inject.mjs +0 -1
  5. package/adapters/extension/live.sh +26 -25
  6. package/adapters/extension/pin-remote-flags.cjs +69 -30
  7. package/adapters/manifest.json +4 -4
  8. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  9. package/adapters/mobile/bridge-runtime/setup-wallet.sh +69 -2
  10. package/adapters/shared/resolve-slot-ports-core.mjs +47 -19
  11. package/dist/adapters/extension/feature-flags.js +41 -0
  12. package/dist/adapters/extension/runtime-decision.js +2 -2
  13. package/dist/adapters/extension/runtime.js +4 -3
  14. package/dist/adapters/extension/verify.js +608 -0
  15. package/dist/adapters/mobile/feature-flags.js +127 -0
  16. package/dist/adapters/resolve-slot-ports.js +2 -0
  17. package/dist/adapters.js +6 -1
  18. package/dist/cli.js +1 -1
  19. package/dist/command-contract.js +6 -3
  20. package/dist/commands/doctor.js +9 -1
  21. package/dist/commands/fixtures.js +45 -3
  22. package/dist/commands/launch/extension.js +28 -6
  23. package/dist/commands/launch/index.js +128 -17
  24. package/dist/commands/launch/mobile.js +26 -2
  25. package/dist/commands/mobile-device-view.js +8 -1
  26. package/dist/commands/prepare.js +20 -3
  27. package/dist/commands/run-engine.js +10 -1
  28. package/dist/commands/runtime-launch.js +10 -1
  29. package/dist/commands/status-probe.js +14 -1
  30. package/dist/commands/status.js +13 -0
  31. package/dist/doctor.js +9 -1
  32. package/dist/feature-flags.js +193 -0
  33. package/dist/harness.js +3 -15
  34. package/dist/mm-harness-cli.js +15 -2
  35. package/library/actions/core/feature_flags/_unsupported.mjs +29 -0
  36. package/library/actions/core/feature_flags/clear.mjs +8 -0
  37. package/library/actions/core/feature_flags/read.mjs +8 -0
  38. package/library/actions/core/feature_flags/set.mjs +8 -0
  39. package/library/actions/extension/feature_flags/clear.mjs +48 -0
  40. package/library/actions/extension/feature_flags/read.mjs +27 -0
  41. package/library/actions/extension/feature_flags/set.mjs +36 -0
  42. package/library/actions/extension/feature_flags/state.mjs +129 -0
  43. package/library/actions/harness-exports.mjs +14 -0
  44. package/library/actions/mobile/feature_flags/clear.mjs +20 -0
  45. package/library/actions/mobile/feature_flags/controller.mjs +120 -0
  46. package/library/actions/mobile/feature_flags/read.mjs +20 -0
  47. package/library/actions/mobile/feature_flags/set.mjs +25 -0
  48. package/library/actions/mobile/wallet/reset.mjs +22 -1
  49. package/library/manifests/core.action-manifest.json +90 -0
  50. package/library/manifests/extension.action-manifest.json +70 -0
  51. package/library/manifests/mobile.action-manifest.json +90 -0
  52. package/package.json +1 -1
  53. package/adapters/extension/verify.sh +0 -572
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.60.0 - 2026-09-19
6
+
7
+ ### Changed
8
+
9
+ - Site: a Features page (`features.html`) catalogues every harness capability with its command or action and the clients it runs on, linked from the lobby and every page's nav. A feature PR adds or updates its row next to its changelog bullet.
10
+
11
+ ### Added
12
+
13
+ - Remote feature flags are a first-class capability with the same signature on both clients. `mm-harness launch --remote-flag KEY=VALUE[,KEY=VALUE]` pins a flag for the launch and `prepare` forwards it. Mobile applies it through the running `RemoteFeatureFlagController` and fails the launch when a key does not read back; the Extension writes it into the per-run dist snapshot manifest and then re-reads that manifest, so the launch reports the pin it proved rather than the one it requested. The wallet fixture takes an optional `remoteFeatureFlags` object: every launch on both clients makes the override set exactly that baseline merged with the `--remote-flag` map, so a plain launch keeps the fixture's variant and drops the previous run's (Mobile clears the controller's persisted overrides first; the Extension re-creates the snapshot unless its harness-owned record, `_harness.remoteFeatureFlags`, already equals the request, which keeps the build's own `.manifest-flags.json` flags from ever reading as a stale pin). `fixtures set` applies the block the same way and takes `--remote-flag` for a one-run override, so `prepare --remote-flag` keeps its pin through the fixtures step. The record is written on every snapshot (an empty map on a plain launch), a snapshot without it is re-created rather than trusted, and a reload in place (`reattach`, the watcher's sync, a recipe's runtime recovery) carries it across the rebuilt snapshot. `--watch` refuses `--remote-flag` because the watcher never re-creates the snapshot; a verified release artifact refuses any pin rather than testing the wrong arm; a Mobile launch that could not prove its override set reports `remoteFeatureFlags: null`. Three actions on every adapter: `metamask.feature_flags.set`, `.read`, and `.clear`. The Extension exposes no runtime override RPC (`setFlagOverride` is never registered in `metamask-controller.js` `getApi()`), so its `set` reports the relaunch that does pin the flag and exits non-zero rather than claiming a change it cannot make; core answers `supported: false`. `doctor` and `status` print a `feature flags:` line and carry the object in `--json`, distinguishing no runtime and an unreadable snapshot from a genuine zero. Mobile reads it on the live probe that already runs, so neither command spawns a second bridge evaluation. Value grammar is parsed in exactly one place (`src/feature-flags.ts`) and every leaf receives the resolved map as JSON: a bare word is a named A/B variant, `true`/`false` is a boolean, and a value opening with `{` or `[` is JSON.
14
+
15
+ ## 0.59.0 - 2026-09-18
16
+
17
+ ### Changed
18
+
19
+ - Extension `verify` is runner-owned TypeScript: `adapters/extension/verify.sh` (572 lines, with node heredocs assembling `summary.json`, the fixture status and the CDP holder probe) is now `src/adapters/extension/verify.ts`, run in-process by `mm-harness verify --adapter extension`. Same flags, env, exit codes, check names and artifacts (`summary.json`, `artifact-manifest.json`, `logs/`, `runner-smoke/`); a live run against the same checkout produced a byte-identical normalised summary. Deliberate differences: `install` no longer copies a `scripts/verify.sh` into the checkout and dispatch no longer prefers one, so an older overlay cannot shadow the current runner's verify; the two shell-lib preflights (`harness-path.sh`, `json-field.sh`) are gone because the typed helpers cannot be missing; usage now names `mm-harness verify --adapter extension`; a `--target` that does not exist is a named exit-1 refusal instead of a bare `cd` failure; and `live.sh` reaches verify through the checkout's runner delegate, refusing early when that delegate is absent. Three more deliberate differences: `--artifacts-dir` is resolved to an absolute path once (the shell resolved a relative value against the cwd for its own files and against the target for the smoke run); `readiness.mjs` runs under the harness's own node instead of `PATH` `node`; and `live.sh`, having just installed the overlay, tells verify to skip the overlay re-ensure (`MM_HARNESS_SKIP_OVERLAY_ENSURE=1`) instead of reinstalling it between launch and the live probe.
20
+
21
+ ### Fixed
22
+
23
+ - Slot identity resolves against this machine's pool first. Every farm node keeps the same checkout paths, so a repo path matched a slot in every `pool/*.json` and the alphabetically first pool won: `doctor --fix` on macwork stamped `metamask-extension-2` as `macpro-mme-2` on CDP 7662 instead of `macwork-mmedev-2` on 6662. Pools whose `machine` or `host` is this host are consulted first; other machines' pools only when none of ours names the repo. A malformed pool file is skipped like the port resolver already did, and `*.bak` files never count.
24
+ - An invalid `RECIPE_RUNTIME_DIR` no longer crashes every command at startup with a stack trace. The extension runtime decision read it while loading; it reads it when a build-log check needs it, so `--help` still works and the command that needs the value reports it.
25
+
5
26
  ## 0.58.0 - 2026-09-18
6
27
 
7
28
  ### Changed
package/README.md CHANGED
@@ -98,6 +98,54 @@ Extension `launch` keeps its incremental watcher running. Refresh the active
98
98
  page after a successful rebuild; use `launch --build` only for an explicit
99
99
  production-like LavaMoat rebuild.
100
100
 
101
+ ### Remote feature flags
102
+
103
+ `--remote-flag KEY=VALUE[,KEY=VALUE]` pins a remote feature flag for the launch,
104
+ which is how a screen gated behind an A/B variant becomes reachable:
105
+
106
+ ```bash
107
+ mm-harness launch --remote-flag perpsTAT3938AbtestScreenVsBottomSheet=treatment
108
+ mm-harness launch ios --remote-flag perpsEnabled=true,perpsConfig='{"tier":2}'
109
+ ```
110
+
111
+ A bare word is a named variant (`treatment` becomes `{ "name": "treatment" }`),
112
+ `true`/`false` is a boolean, and a value opening with `{` or `[` is parsed as
113
+ JSON. The grammar is parsed once and every leaf receives the resolved map, so a
114
+ boolean stays a boolean rather than becoming the string `"true"`.
115
+
116
+ Every launch makes the app's override set exactly the wallet fixture's
117
+ `remoteFeatureFlags` object (the baseline every run shares) merged with the
118
+ `--remote-flag` map (a one-launch override), on both clients. A plain `launch`
119
+ therefore drops a previous run's `--remote-flag` variant and keeps the fixture's.
120
+ Mobile clears and re-applies the set through the running
121
+ `RemoteFeatureFlagController` and fails the launch if it does not read back. The
122
+ Extension writes it into the per-run dist snapshot manifest and re-reads the
123
+ harness-owned record there (`_harness.remoteFeatureFlags`, written on every
124
+ snapshot), so the launch reports the pin it proved; reuse of a running browser
125
+ is allowed only while that record already equals the request, a reload in
126
+ place carries the record across the rebuilt snapshot, and `--watch` (which
127
+ never re-creates the snapshot) refuses `--remote-flag`. A recipe's own recovery
128
+ relaunch carries the live pin rather than dropping it mid-run. A verified
129
+ release artifact is never rewritten, so a launch that would have to pin it
130
+ (`--remote-flag` or a fixture that declares flags) refuses instead of testing
131
+ the wrong arm.
132
+
133
+ Read or change flags on a running app with the actions. `fixtures set` follows
134
+ the same rule as launch: the fixture's block is the baseline and
135
+ `fixtures set --remote-flag KEY=VALUE` layers a one-run override on it, which is
136
+ how `prepare --remote-flag` keeps its pin through the fixtures step.
137
+
138
+ ```bash
139
+ mm-harness call metamask.feature_flags.read
140
+ mm-harness call metamask.feature_flags.set --arg 'flags={"perpsEnabled":true}' # mobile
141
+ mm-harness call metamask.feature_flags.clear # mobile
142
+ ```
143
+
144
+ `doctor` and `status` print a `feature flags:` line naming every active
145
+ override, so a slot never runs a proof under an unnamed variant. The line
146
+ distinguishes `(no runtime)` and an unreadable snapshot from a genuine
147
+ `0 override(s)`.
148
+
101
149
  Log sources stay separate:
102
150
 
103
151
  ```bash
@@ -7,6 +7,7 @@ const { spawnSync } = require('node:child_process');
7
7
  const { connectBrowserViaCdp, evaluatePageViaCdp } = require('./lib/playwright-cdp.cjs');
8
8
  const { RUNTIME_IDENTITY_FILENAME, RUNTIME_NONCE_PREFIX } = require('./lib/chrome-args.cjs');
9
9
  const { stampRuntimeTitles } = require('./stamp-runtime-title.cjs');
10
+ const { carryHarnessPin } = require('./pin-remote-flags.cjs');
10
11
 
11
12
  function acquireBuildHandoff(runtimeDir) {
12
13
  const lock = path.join(runtimeDir, 'extension-build-handoff.lock');
@@ -142,6 +143,9 @@ async function handoffRuntimeBuild({ target, runtimeDist, dist, cdpPort }) {
142
143
  const configured = spawnSync(process.execPath, [path.join(__dirname, 'configure-runtime-manifest.cjs'), '--target', target, '--manifest', path.join(runtimeDist, 'manifest.json')], { encoding: 'utf8' });
143
144
  if (configured.status !== 0) throw new Error(`Extension runtime configuration failed: ${configured.stderr || configured.stdout}`);
144
145
  stampRuntimeTitles({ target, runtimeDist });
146
+ // The rsync replaced the manifest with the build's own; the pin the
147
+ // running slot was launched under must survive a reload in place.
148
+ carryHarnessPin(oldManifest, path.join(runtimeDist, 'manifest.json'));
145
149
  }
146
150
  await setEnabled(true);
147
151
  for (const entry of pages) {
@@ -114,7 +114,6 @@ copyFile(path.join(runnerDir, 'adapters/extension/sidepanel-toggle.sh'), path.jo
114
114
  copyFile(path.join(runnerDir, 'adapters/extension/reattach.sh'), path.join(harnessDir, 'scripts/reattach.sh'));
115
115
  copyFile(path.join(runnerDir, 'adapters/extension/readiness.mjs'), path.join(harnessDir, 'scripts/readiness.mjs'));
116
116
  copyFile(path.join(runnerDir, 'adapters/extension/wallet-fixture-state.cjs'), path.join(harnessDir, 'scripts/wallet-fixture-state.cjs'));
117
- copyFile(path.join(runnerDir, 'adapters/extension/verify.sh'), path.join(harnessDir, 'scripts/verify.sh'));
118
117
  copyFile(path.join(runnerDir, 'adapters/extension/refresh-build.sh'), path.join(harnessDir, 'scripts/refresh-build.sh'));
119
118
  copyFile(path.join(runnerDir, 'adapters/extension/ensure-browser.sh'), path.join(harnessDir, 'scripts/reopen-browser.sh'));
120
119
  copyFile(path.join(runnerDir, 'adapters/shared/harness-path.sh'), path.join(harnessDir, 'scripts/lib/harness-path.sh'));
@@ -6,8 +6,8 @@
6
6
  # Purpose:
7
7
  # Builds the per-run prepare command from the named feature scripts
8
8
  # (watch, dist snapshot, fixture seed, detached chrome, CDP poll), then
9
- # runs launch.sh and, unless --launch-only is set, verify.sh under a
10
- # timestamped artifact dir.
9
+ # runs launch.sh and, unless --launch-only is set, `mm-harness verify` under
10
+ # a timestamped artifact dir.
11
11
  #
12
12
  # Inputs (flags / env):
13
13
  # --target <metamask-extension> (default $PWD)
@@ -17,7 +17,7 @@
17
17
  # --launch-only (skip recipe verification; used by fixture application while
18
18
  # its checkout lock is held)
19
19
  # --dist-dir <rel> (default dist/chrome), --chrome-user-data-dir <dir>,
20
- # --remote-flag <KEY=VARIANT[,KEY=VARIANT...]> (pins manifest _flags into the
20
+ # --remote-flags-json <json-object> (pins manifest _flags into the
21
21
  # ephemeral runtime-dist snapshot before launch; omit for default behavior)
22
22
  # --out <recipes-dir>, --artifacts-dir <dir>
23
23
  # env RECIPE_HARNESS_CHROME_BIN (validated executable; else Playwright
@@ -69,8 +69,8 @@ while [ "$#" -gt 0 ]; do
69
69
  --launch-only) RUN_VERIFY=false; shift ;;
70
70
  --dist-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; DIST_DIR="$2"; shift 2 ;;
71
71
  --chrome-user-data-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; CHROME_USER_DATA_DIR="$2"; shift 2 ;;
72
- --remote-flag) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; REMOTE_FLAGS="$2"; shift 2 ;;
73
- -h|--help) echo "Usage: live.sh [--target <metamask-extension>] [--out <recipes-dir>] [--cdp-port <port>] [--launch-existing-dist|--start-watch|--build-lavamoat|--external-artifact --dist-dir <absolute-dir> --artifact-provenance <absolute-file>|--prepare-cmd <cmd>] [--preserve-profile|--reset-profile] [--launch-only] [--dist-dir dist/chrome] [--remote-flag KEY=VARIANT] [--artifacts-dir <dir>]"; exit 0 ;;
72
+ --remote-flags-json) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; REMOTE_FLAGS="$2"; shift 2 ;;
73
+ -h|--help) echo "Usage: live.sh [--target <metamask-extension>] [--out <recipes-dir>] [--cdp-port <port>] [--launch-existing-dist|--start-watch|--build-lavamoat|--external-artifact --dist-dir <absolute-dir> --artifact-provenance <absolute-file>|--prepare-cmd <cmd>] [--preserve-profile|--reset-profile] [--launch-only] [--dist-dir dist/chrome] [--remote-flags-json '<normalised-json-map>'] [--artifacts-dir <dir>]"; exit 0 ;;
74
74
  *) echo "Unknown arg: $1" >&2; exit 2 ;;
75
75
  esac
76
76
  done
@@ -128,17 +128,6 @@ case "$CDP_PORT" in
128
128
  *[!0-9]*) echo "Invalid --cdp-port (must be numeric): $CDP_PORT" >&2; exit 2 ;;
129
129
  esac
130
130
 
131
- # verify.sh is a recipe-tree feature: co-located in installed copies, under
132
- # adapters/extension in a repo checkout.
133
- VERIFY_SH=""
134
- for _v in "$SCRIPT_DIR/verify.sh" "$SCRIPT_DIR/../../adapters/extension/verify.sh"; do
135
- [ -f "$_v" ] && { VERIFY_SH="$_v"; break; }
136
- done
137
- unset _v
138
- if [ -z "$VERIFY_SH" ]; then
139
- echo "mm-harness: extension verify.sh not found next to $SCRIPT_DIR; reinstall the runner." >&2
140
- exit 1
141
- fi
142
131
  RUNTIME_DIR="$(recipe_runtime_dir)"
143
132
  RUNTIME_DIST_DIR="${RECIPE_RUNTIME_DIST_DIR:-runtime-dist}"
144
133
  case "$RUNTIME_DIST_DIR" in
@@ -152,6 +141,12 @@ CHROME_PROFILE_NAME="${RECIPE_CHROME_PROFILE_NAME:-chrome-profile}"
152
141
  # to defer the cache-clear DECISION to `runtime-decision` (single source) and to
153
142
  # record the deps/cache baseline after a confirmed-good build.
154
143
  RUNNER_BIN="$(harness_dir "$TARGET" extension)/runner/bin/mm-harness"
144
+ # Verify is runner-owned typed code reached through the runner delegate, not a
145
+ # shipped leaf this sequencer could resolve next to itself.
146
+ if $RUN_VERIFY && [ ! -x "$RUNNER_BIN" ]; then
147
+ echo "mm-harness: runner delegate missing at $RUNNER_BIN; run mm-harness install --adapter extension --target $TARGET." >&2
148
+ exit 1
149
+ fi
155
150
  OUT="${OUT:-$(harness_root)/extension/runner/recipes}"
156
151
  ARTIFACTS="${ARTIFACTS:-$(harness_dir "$TARGET" extension)/live/$(date -u +%Y%m%dT%H%M%SZ)}"
157
152
  mkdir -p "$ARTIFACTS/logs"
@@ -332,14 +327,18 @@ NODE
332
327
  if ! $EXTERNAL_ARTIFACT; then
333
328
  prepare_parts+=("node ${quoted_stamp_title} --target ${quoted_target} --runtime-dist ${quoted_runtime_dist} --runtime-dir ${quoted_runtime_dir}")
334
329
  fi
335
- # Optional A/B feature-flag pinning: patch the ephemeral snapshot manifest so
336
- # manifest._flags wins over the fetched ClientConfigApi value. No-op unless
337
- # --remote-flag was passed; the snapshot is re-created each run, so no revert.
338
- if [ -n "$REMOTE_FLAGS" ]; then
330
+ # Remote feature-flag pinning: patch the ephemeral snapshot manifest so
331
+ # manifest._flags wins over the fetched ClientConfigApi value, and record the
332
+ # harness-owned pin on EVERY snapshot (an empty map without --remote-flags-json)
333
+ # so launch can tell a snapshot it wrote from one it never did. The snapshot is
334
+ # re-created each run, so no revert.
335
+ if ! $EXTERNAL_ARTIFACT; then
339
336
  quoted_pin_flags="$(printf '%q' "$SCRIPT_DIR/pin-remote-flags.cjs")"
340
- quoted_remote_flags="$(printf '%q' "$REMOTE_FLAGS")"
337
+ # The empty default is spelled literally: printf %q renders {} differently
338
+ # across bash versions, and the sequencer golden must not depend on that.
339
+ if [ -n "$REMOTE_FLAGS" ]; then quoted_remote_flags="$(printf '%q' "$REMOTE_FLAGS")"; else quoted_remote_flags="'{}'"; fi
341
340
  quoted_runtime_manifest="$(printf '%q' "$RUNTIME_DIST_ABS/manifest.json")"
342
- prepare_parts+=("node ${quoted_pin_flags} ${quoted_runtime_manifest} ${quoted_remote_flags}")
341
+ prepare_parts+=("node ${quoted_pin_flags} ${quoted_runtime_manifest} --flags-json ${quoted_remote_flags}")
343
342
  REMOTE_FLAGS_APPLIED=true
344
343
  fi
345
344
  if [ -n "$WALLET_FIXTURE_ABS" ]; then
@@ -359,7 +358,7 @@ NODE
359
358
  fi
360
359
 
361
360
  if [ -n "$REMOTE_FLAGS" ] && [ "$REMOTE_FLAGS_APPLIED" != "true" ]; then
362
- echo "[recipe-harness] WARN: --remote-flag '$REMOTE_FLAGS' had no effect: flag pinning only applies to the snapshot launch path (--launch-existing-dist/--start-watch/--build-lavamoat without a custom --prepare-cmd)." >&2
361
+ echo "[recipe-harness] WARN: --remote-flags-json '$REMOTE_FLAGS' had no effect: flag pinning only applies to the snapshot launch path (--launch-existing-dist/--start-watch/--build-lavamoat without a custom --prepare-cmd)." >&2
363
362
  fi
364
363
 
365
364
  echo "Extension live validation command:"
@@ -369,7 +368,7 @@ $BUILD_LAVAMOAT && display_args+=(--build-lavamoat)
369
368
  $EXTERNAL_ARTIFACT && display_args+=(--external-artifact --dist-dir "$DIST_DIR" --artifact-provenance "$ARTIFACT_PROVENANCE")
370
369
  $RESET_PROFILE && display_args+=(--reset-profile)
371
370
  $RESET_PROFILE || display_args+=(--preserve-profile)
372
- [ -n "$REMOTE_FLAGS" ] && display_args+=(--remote-flag "$REMOTE_FLAGS")
371
+ [ -n "$REMOTE_FLAGS" ] && display_args+=(--remote-flags-json "$REMOTE_FLAGS")
373
372
  [ -n "$CHROME_USER_DATA_DIR" ] && display_args+=(--chrome-user-data-dir "$CHROME_USER_DATA_DIR")
374
373
  printf ' '
375
374
  printf '%q ' "${display_args[@]}"
@@ -390,7 +389,9 @@ verify_status=0
390
389
  if [ "$launch_status" -eq 0 ] && $RUN_VERIFY; then
391
390
  echo "[recipe-harness] phase 2/2: live verify (readiness + smoke recipe)" >&2
392
391
  set +e
393
- "$VERIFY_SH" --target "$TARGET" --out "$OUT" --cdp-port "$CDP_PORT" --artifacts-dir "$ARTIFACTS/verify"
392
+ # The overlay was installed by this sequencer moments ago; verify must not
393
+ # reinstall it between launch and the live probe.
394
+ MM_HARNESS_SKIP_OVERLAY_ENSURE=1 "$RUNNER_BIN" verify --adapter extension --target "$TARGET" -- --out "$OUT" --cdp-port "$CDP_PORT" --artifacts-dir "$ARTIFACTS/verify"
394
395
  verify_status=$?
395
396
  set -e
396
397
  elif [ "$launch_status" -ne 0 ]; then
@@ -1,45 +1,84 @@
1
1
  #!/usr/bin/env node
2
- // pin-remote-flags.cjs <manifest.json> <KEY=VARIANT[,KEY=VARIANT...]>
2
+ // pin-remote-flags.cjs <manifest.json> --flags-json '<normalised-map>'
3
3
  //
4
- // Pins A/B remote feature-flag variants into an extension manifest's
5
- // `_flags.remoteFeatureFlags`. MetaMask resolves the active flags as
4
+ // Writes an already-normalised remote feature-flag map into an extension
5
+ // manifest's `_flags.remoteFeatureFlags`. MetaMask resolves the active flags as
6
6
  // getRemoteFeatureFlags = merge({}, fetchedState, manifest._flags.remoteFeatureFlags)
7
- // with the manifest winning, so this deterministically forces a variant in the
8
- // live browser regardless of the real ClientConfigApi response — which otherwise
9
- // overwrites any seeded controller state on refetch.
7
+ // with the manifest winning, so this deterministically forces the requested
8
+ // values in the live browser regardless of the real ClientConfigApi response —
9
+ // which otherwise overwrites any seeded controller state on refetch.
10
+ //
11
+ // This leaf does NOT parse the KEY=VALUE grammar. Value normalisation lives in
12
+ // exactly one place, src/feature-flags.ts, and every caller hands the result
13
+ // here as JSON. A second parser here silently disagreed with it: a spec of
14
+ // `flagA=true,flagB={"enabled":true}` used to pin the STRINGS
15
+ // {"flagA":{"name":"true"},"flagB":{"name":"{\"enabled\":true}"}}.
16
+ //
17
+ // The extension build can author `_flags.remoteFeatureFlags` itself (from
18
+ // .manifest-flags.json), so the pin merges over that map and ALSO records
19
+ // exactly what the harness pinned under `_harness.remoteFeatureFlags` — on
20
+ // every snapshot, an empty map included. Launch, fixtures, status and reuse
21
+ // compare against that record, so a build's own flags are reported as live
22
+ // state and never mistaken for a stale pin, a previous run's pin can never
23
+ // hide behind them, and a snapshot without the record is one this harness
24
+ // never wrote (pre-upgrade or foreign) and is re-created rather than trusted.
10
25
  //
11
26
  // Intended for the per-run runtime-dist SNAPSHOT (ephemeral, re-created from
12
27
  // dist/chrome on every launch), so it never mutates the real dist/chrome build
13
- // and needs no revert bookkeeping. Values are written as { name: VARIANT }
14
- // (named-variant A/B flags); boolean/other flag shapes are out of scope.
28
+ // and needs no revert bookkeeping. Every path that rsyncs dist over the
29
+ // snapshot again (build-handoff.cjs: reattach, the watcher's sync monitor)
30
+ // carries the record across with carryHarnessPin().
15
31
  'use strict';
16
32
 
17
33
  const fs = require('node:fs');
18
34
 
19
- const [, , manifestPath, spec] = process.argv;
20
- if (!manifestPath || !spec) {
21
- throw new Error('usage: pin-remote-flags.cjs <manifest.json> <KEY=VARIANT[,KEY=VARIANT...]>');
35
+ function assertFlagMap(flags, where) {
36
+ if (!flags || typeof flags !== 'object' || Array.isArray(flags)) {
37
+ throw new Error(`${where} must be a JSON object of flag key to already-normalised value.`);
38
+ }
22
39
  }
23
40
 
24
- const pairs = spec
25
- .split(',')
26
- .map((entry) => entry.trim())
27
- .filter(Boolean);
28
- if (pairs.length === 0) throw new Error(`--remote-flag had no KEY=VARIANT pairs: ${spec}`);
29
-
30
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
31
- manifest._flags = manifest._flags || {};
32
- const flags = manifest._flags.remoteFeatureFlags || (manifest._flags.remoteFeatureFlags = {});
41
+ /** Merge `flags` into the manifest's effective map and record it as the harness pin. */
42
+ function pinRemoteFlags(manifestPath, flags) {
43
+ assertFlagMap(flags, 'flags');
44
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
45
+ const keys = Object.keys(flags);
46
+ if (keys.length > 0) {
47
+ manifest._flags = manifest._flags || {};
48
+ const effective = manifest._flags.remoteFeatureFlags || (manifest._flags.remoteFeatureFlags = {});
49
+ for (const key of keys) effective[key] = flags[key];
50
+ }
51
+ manifest._harness = { ...(manifest._harness || {}), remoteFeatureFlags: flags };
52
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
53
+ return keys;
54
+ }
33
55
 
34
- const applied = [];
35
- for (const pair of pairs) {
36
- const eq = pair.indexOf('=');
37
- const key = eq > 0 ? pair.slice(0, eq) : '';
38
- const variant = eq > 0 ? pair.slice(eq + 1) : '';
39
- if (!key || !variant) throw new Error(`--remote-flag expects KEY=VARIANT, got: ${pair}`);
40
- flags[key] = { name: variant };
41
- applied.push(`${key}=${variant}`);
56
+ /**
57
+ * Re-apply the pin a previous snapshot manifest recorded to a freshly synced
58
+ * one. A manifest without the record contributes an empty pin: the fresh
59
+ * snapshot then carries `_harness` so the next launch can trust it.
60
+ */
61
+ function carryHarnessPin(previousManifest, manifestPath) {
62
+ const pinned = previousManifest && previousManifest._harness && previousManifest._harness.remoteFeatureFlags;
63
+ return pinRemoteFlags(manifestPath, pinned && typeof pinned === 'object' && !Array.isArray(pinned) ? pinned : {});
42
64
  }
43
65
 
44
- fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
45
- console.log(`[flags] Pinned ${applied.join(', ')} into ${manifestPath}`);
66
+ module.exports = { pinRemoteFlags, carryHarnessPin };
67
+
68
+ if (require.main === module) {
69
+ const [, , manifestPath, flagOption, flagsJson] = process.argv;
70
+ if (!manifestPath || flagOption !== '--flags-json' || !flagsJson) {
71
+ throw new Error("usage: pin-remote-flags.cjs <manifest.json> --flags-json '<json-object>'");
72
+ }
73
+ let flags;
74
+ try {
75
+ flags = JSON.parse(flagsJson);
76
+ } catch (error) {
77
+ throw new Error(`--flags-json is not valid JSON: ${error.message}`);
78
+ }
79
+ assertFlagMap(flags, '--flags-json');
80
+ const keys = pinRemoteFlags(manifestPath, flags);
81
+ console.log(keys.length > 0
82
+ ? `[flags] Pinned ${keys.join(', ')} into ${manifestPath}`
83
+ : `[flags] Recorded an empty remote feature-flag pin in ${manifestPath}`);
84
+ }
@@ -164,10 +164,10 @@
164
164
  },
165
165
  {
166
166
  "id": "extension/verify",
167
- "entry": "adapters/extension/verify.sh",
168
- "kind": "bash",
169
- "purpose": "Verify the extension harness/runtime is present and healthy (no app launch).",
170
- "inputs": "--target --out --cdp-port --static-only",
167
+ "entry": "src/adapters/extension/verify.ts",
168
+ "kind": "module",
169
+ "purpose": "Verify the extension harness/runtime is present and healthy (no app launch). Runner-owned: mm-harness verify runs it in-process.",
170
+ "inputs": "--target --out --cdp-port --static-only --artifacts-dir",
171
171
  "outputs": "verify report + evidence; exit 0/1/2"
172
172
  },
173
173
  {
@@ -431,11 +431,27 @@ const COMMANDS = {
431
431
  var keyringUnlocked = false;
432
432
  try { account = globalThis.__AGENTIC__?.getSelectedAccount() || null; } catch(e) {}
433
433
  try { keyringUnlocked = globalThis.Engine?.context?.KeyringController?.isUnlocked?.() === true; } catch(e) {}
434
+ // Remote feature-flag overrides ride along on this one evaluation: status
435
+ // and doctor must not spawn a second bridge probe to name the variant a
436
+ // slot is pinned to. null means the controller did not answer, which is
437
+ // not the same as "no overrides".
438
+ var featureFlags = null;
439
+ try {
440
+ var flagController = globalThis.Engine?.context?.RemoteFeatureFlagController;
441
+ var flagState = flagController && flagController.state;
442
+ if (flagState) {
443
+ featureFlags = {
444
+ effective: flagState.remoteFeatureFlags || {},
445
+ overrides: flagState.localOverrides || {},
446
+ };
447
+ }
448
+ } catch(e) {}
434
449
  return {
435
450
  route: route,
436
451
  account: account,
437
452
  agenticPresent: agenticPresent,
438
453
  keyringUnlocked: keyringUnlocked,
454
+ featureFlags: featureFlags,
439
455
  };
440
456
  })()`;
441
457
  const snapshot = await cdpEval(client, expr);
@@ -47,6 +47,7 @@ export CDP_DISCOVERY_RETRIES="${CDP_DISCOVERY_RETRIES:-3}"
47
47
  WALLET_SETUP_SETTLE_TIMEOUT="${WALLET_SETUP_SETTLE_TIMEOUT:-180}"
48
48
  CDP="node $SCRIPTS/cdp-bridge.cjs"
49
49
  FIXTURE_PATH=""
50
+ REMOTE_FEATURE_FLAGS=""
50
51
 
51
52
  cdp_json_command() {
52
53
  local mode="$1"
@@ -70,7 +71,10 @@ while [[ $# -gt 0 ]]; do
70
71
  --fixture)
71
72
  [ $# -ge 2 ] || { echo "ERROR: --fixture requires a path argument"; exit 2; }
72
73
  FIXTURE_PATH="$2"; shift 2 ;;
73
- -h|--help) echo "Usage: setup-wallet.sh [--fixture path.json]"; exit 0 ;;
74
+ --remote-flags-json)
75
+ [ $# -ge 2 ] || { echo "ERROR: --remote-flags-json requires a JSON argument"; exit 2; }
76
+ REMOTE_FEATURE_FLAGS="$2"; shift 2 ;;
77
+ -h|--help) echo "Usage: setup-wallet.sh [--fixture path.json] [--remote-flags-json '<normalised-json-map>']"; exit 0 ;;
74
78
  *) echo "Unknown arg: $1"; exit 2 ;;
75
79
  esac
76
80
  done
@@ -167,7 +171,17 @@ echo "Vault backup guard intent: $DISABLE_BACKUP"
167
171
  wait_for_engine
168
172
 
169
173
  # Read fixture JSON and escape it for safe embedding in a JS string literal.
170
- FIXTURE_JSON=$(jq -c '.' "$FIXTURE_PATH")
174
+ # remoteFeatureFlags is a harness field: __AGENTIC__.setupWallet/applyWalletFixture
175
+ # know nothing about it, so strip it here and apply it separately below against
176
+ # RemoteFeatureFlagController once the wallet is up.
177
+ #
178
+ # The VALUES come from --remote-flags-json, already normalised by
179
+ # src/feature-flags.ts. This script must not read them from the fixture itself:
180
+ # a second reader here disagreed with that grammar (a bare "treatment" stayed a
181
+ # string instead of becoming { "name": "treatment" }).
182
+ [ -n "$REMOTE_FEATURE_FLAGS" ] || REMOTE_FEATURE_FLAGS="{}"
183
+ echo "$REMOTE_FEATURE_FLAGS" | jq empty 2>/dev/null || { echo "ERROR: --remote-flags-json is not valid JSON"; exit 2; }
184
+ FIXTURE_JSON=$(jq -c 'del(.remoteFeatureFlags)' "$FIXTURE_PATH")
171
185
  ESCAPED_FIXTURE=$(node -p "JSON.stringify(JSON.stringify(JSON.parse(process.argv[1])))" "$FIXTURE_JSON")
172
186
  EXPECTED_ADDRESSES=$(node - "$FIXTURE_PATH" <<'NODE'
173
187
  const fs = require('fs');
@@ -440,6 +454,59 @@ case "$ROUTE_NAME" in
440
454
  exit 1
441
455
  ;;
442
456
  esac
457
+ # -- Apply the fixture's remote feature-flag overrides --
458
+ # setFlagOverride writes localOverrides and recomputes remoteFeatureFlags, so the
459
+ # override persists and wins over every later ClientConfigApi refetch. Applied on
460
+ # every `fixtures set`, like the rest of the fixture.
461
+ if [ "$REMOTE_FEATURE_FLAGS" = "{}" ]; then
462
+ # The fixture is authoritative: a fixture that declares no flags leaves the
463
+ # slot with no overrides, so one run's variant never leaks into the next.
464
+ CLEAR_RESULT=$(cdp_eval "(function(){ try { var ctx = globalThis.Engine && globalThis.Engine.context; var c = ctx && ctx.RemoteFeatureFlagController; if (!c || typeof c.clearAllFlagOverrides !== 'function') { return JSON.stringify({ok:true, proven:false, skipped:'controller-unavailable'}); } var before = Object.keys((c.state || {}).localOverrides || {}); c.clearAllFlagOverrides(); return JSON.stringify({ok:true, proven:true, cleared: before}); } catch (e) { return JSON.stringify({ok:false, error: e && (e.message || String(e))}); } })()")
465
+ if [ "$(echo "$CLEAR_RESULT" | jq -r '.ok // false')" != "true" ]; then
466
+ echo "ERROR: could not clear remote feature-flag overrides — $(echo "$CLEAR_RESULT" | jq -r '.error // "unknown error"')"
467
+ exit 1
468
+ fi
469
+ if [ "$(echo "$CLEAR_RESULT" | jq -r '.proven // false')" != "true" ]; then
470
+ # Not an error: an app without the controller has no overrides to carry. It
471
+ # is not proof of a clean slot either, so say which it is.
472
+ echo "WARN: remote feature-flag overrides were not cleared — $(echo "$CLEAR_RESULT" | jq -r '.skipped // "unknown reason"'); the app exposes no RemoteFeatureFlagController to prove the slot is clean."
473
+ else
474
+ CLEARED=$(echo "$CLEAR_RESULT" | jq -r '.cleared // [] | join(", ")')
475
+ [ -n "$CLEARED" ] && echo "Cleared remote feature-flag override(s): $CLEARED"
476
+ fi
477
+ else
478
+ ESCAPED_FLAGS=$(node -p "JSON.stringify(JSON.stringify(JSON.parse(process.argv[1])))" "$REMOTE_FEATURE_FLAGS")
479
+ echo "Applying remote feature-flag overrides: $REMOTE_FEATURE_FLAGS"
480
+ # Clear first: the controller persists overrides, so the set must become
481
+ # exactly the fixture's map, never the fixture's plus a previous run's.
482
+ FLAG_RESULT=$(cdp_eval "(function(){ try { var ctx = globalThis.Engine && globalThis.Engine.context; var c = ctx && ctx.RemoteFeatureFlagController; if (!c || typeof c.setFlagOverride !== 'function') { return JSON.stringify({ok:false, error:'RemoteFeatureFlagController.setFlagOverride is unavailable on the running app'}); } if (typeof c.clearAllFlagOverrides === 'function') c.clearAllFlagOverrides(); var requested = JSON.parse($ESCAPED_FLAGS); var applied = []; for (var k in requested) { if (Object.prototype.hasOwnProperty.call(requested, k)) { c.setFlagOverride(k, requested[k]); applied.push(k); } } var st = c.state || {}; return JSON.stringify({ok:true, applied: applied, effective: st.remoteFeatureFlags || {}, overrides: st.localOverrides || {}}); } catch (e) { return JSON.stringify({ok:false, error: e && (e.message || String(e))}); } })()")
483
+ FLAG_OK=$(echo "$FLAG_RESULT" | jq -r '.ok // false')
484
+ if [ "$FLAG_OK" != "true" ]; then
485
+ echo "ERROR: remote feature-flag overrides were not applied — $(echo "$FLAG_RESULT" | jq -r '.error // "unknown error"')"
486
+ exit 1
487
+ fi
488
+ # Read back: a flag nobody can observe is not applied.
489
+ FLAG_MISMATCH=$(node - "$REMOTE_FEATURE_FLAGS" "$FLAG_RESULT" <<'NODE'
490
+ const requested = JSON.parse(process.argv[2]);
491
+ const result = JSON.parse(process.argv[3]);
492
+ const effective = result.effective || {};
493
+ const overrides = result.overrides || {};
494
+ // A requested key that did not read back, or an override that survived the clear.
495
+ const mismatched = [
496
+ ...Object.keys(requested).filter((key) => JSON.stringify(effective[key]) !== JSON.stringify(requested[key])),
497
+ ...Object.keys(overrides).filter((key) => !(key in requested)),
498
+ ];
499
+ process.stdout.write(mismatched.join(', '));
500
+ NODE
501
+ )
502
+ if [ -n "$FLAG_MISMATCH" ]; then
503
+ echo "ERROR: remote feature-flag override(s) did not read back: $FLAG_MISMATCH"
504
+ echo "$FLAG_RESULT" | jq .
505
+ exit 1
506
+ fi
507
+ echo "Remote feature flags applied: $(echo "$FLAG_RESULT" | jq -r '.applied | join(", ")')"
508
+ fi
509
+
443
510
  echo ""
444
511
  echo "=== Wallet Ready ==="
445
512
  echo "Route: ${ROUTE_NAME:-unknown}"
@@ -73,6 +73,35 @@ function scoreSlot(slot, slotSuffix) {
73
73
  return [score, cdp, port, slotId, simulator, adbSerial, androidDevice];
74
74
  }
75
75
 
76
+ // Pool files that describe this machine, then the rest. Every farm node keeps
77
+ // the same checkout paths, so a repo path alone matches a slot in every pool;
78
+ // the pool whose `machine`/`host` is this host is the one that owns the slot.
79
+ export function poolFilesForHost(poolDir, hostname = os.hostname()) {
80
+ // macOS may report a capitalised or FQDN hostname; pools carry lowercase names.
81
+ const canonical = (value) => String(value ?? '').toLowerCase().replace(/\.$/u, '');
82
+ const full = canonical(hostname);
83
+ const short = full.split('.')[0];
84
+ const names = (value) => {
85
+ const name = canonical(value);
86
+ return name ? [name, name.split('.')[0]] : [];
87
+ };
88
+ const local = [];
89
+ const other = [];
90
+ for (const name of fs.readdirSync(poolDir).sort()) {
91
+ // Backups (`*.json.bak.<ts>`, `*.bak`) never define a live slot.
92
+ if (!name.endsWith('.json') || name.includes('.bak')) continue;
93
+ let data;
94
+ try {
95
+ data = JSON.parse(fs.readFileSync(path.join(poolDir, name), 'utf8'));
96
+ } catch {
97
+ continue;
98
+ }
99
+ const isLocal = [...names(data.machine), ...names(data.host)].some((name) => name === full || name === short);
100
+ (isLocal ? local : other).push({ name, data });
101
+ }
102
+ return { local, other };
103
+ }
104
+
76
105
  export function resolveSlotPortsByRepo(repo) {
77
106
  const realRepo = realRepoPath(repo);
78
107
  if (!realRepo) return null;
@@ -91,29 +120,28 @@ export function resolveSlotPortsByRepo(repo) {
91
120
  for (const root of roots) {
92
121
  if (!root || !fs.existsSync(path.join(root, 'pool'))) continue;
93
122
  const poolDir = path.join(root, 'pool');
123
+ const pools = poolFilesForHost(poolDir);
94
124
  let best = null;
95
125
 
96
- for (const name of fs.readdirSync(poolDir).sort()) {
97
- if (!name.endsWith('.json') || name.includes('.bak.')) continue;
98
- let data;
99
- try {
100
- data = JSON.parse(fs.readFileSync(path.join(poolDir, name), 'utf8'));
101
- } catch {
102
- continue;
103
- }
104
- for (const slot of data.slots ?? []) {
105
- const slotRepo = slot.repo ?? '';
106
- if (!slotRepo) continue;
107
- let slotReal;
108
- try {
109
- slotReal = fs.realpathSync(slotRepo);
110
- } catch {
111
- continue;
126
+ // This host's pools decide first; other machines' pools only when none of
127
+ // ours names the repo (single-node and demo setups).
128
+ for (const group of [pools.local, pools.other]) {
129
+ for (const { data } of group) {
130
+ for (const slot of data.slots ?? []) {
131
+ const slotRepo = slot.repo ?? '';
132
+ if (!slotRepo) continue;
133
+ let slotReal;
134
+ try {
135
+ slotReal = fs.realpathSync(slotRepo);
136
+ } catch {
137
+ continue;
138
+ }
139
+ if (slotReal !== realRepo) continue;
140
+ const scored = scoreSlot(slot, slotSuffix);
141
+ if (!best || scored[0] > best[0]) best = scored;
112
142
  }
113
- if (slotReal !== realRepo) continue;
114
- const scored = scoreSlot(slot, slotSuffix);
115
- if (!best || scored[0] > best[0]) best = scored;
116
143
  }
144
+ if (best) break;
117
145
  }
118
146
 
119
147
  if (best) {
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { normalizeFeatureFlagRecord } from "../../feature-flags.js";
4
+ import { recipeRuntimeDir } from "../../paths.js";
5
+ import { releaseArtifactState } from "./runtime-decision.js";
6
+ function extensionRuntimeManifestPath(target) {
7
+ return path.join(
8
+ target,
9
+ recipeRuntimeDir(),
10
+ process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist",
11
+ "manifest.json"
12
+ );
13
+ }
14
+ function readExtensionPinnedFlags(target) {
15
+ const manifestPath = extensionRuntimeManifestPath(target);
16
+ let raw;
17
+ try {
18
+ raw = fs.readFileSync(manifestPath, "utf8");
19
+ } catch {
20
+ return { overrides: null, pinned: null };
21
+ }
22
+ try {
23
+ const manifest = JSON.parse(raw);
24
+ return {
25
+ overrides: normalizeFeatureFlagRecord(manifest._flags?.remoteFeatureFlags, manifestPath),
26
+ pinned: manifest._harness?.remoteFeatureFlags !== void 0 ? normalizeFeatureFlagRecord(manifest._harness.remoteFeatureFlags, manifestPath) : releaseArtifactState(target).status === "valid" ? {} : null,
27
+ sourcePath: manifestPath
28
+ };
29
+ } catch (error) {
30
+ return {
31
+ overrides: null,
32
+ pinned: null,
33
+ error: error instanceof Error ? error.message : String(error),
34
+ sourcePath: manifestPath
35
+ };
36
+ }
37
+ }
38
+ export {
39
+ extensionRuntimeManifestPath,
40
+ readExtensionPinnedFlags
41
+ };