@deeeed/metamask-harness 0.59.0 → 0.61.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 (47) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +48 -0
  3. package/adapters/extension/build-handoff.cjs +4 -0
  4. package/adapters/extension/live.sh +15 -11
  5. package/adapters/extension/pin-remote-flags.cjs +69 -30
  6. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  7. package/adapters/mobile/bridge-runtime/setup-wallet.sh +69 -2
  8. package/dist/adapters/extension/feature-flags.js +41 -0
  9. package/dist/adapters/extension/runtime-decision.js +1 -0
  10. package/dist/adapters/mobile/feature-flags.js +127 -0
  11. package/dist/adapters.js +6 -1
  12. package/dist/command-contract.js +6 -3
  13. package/dist/commands/doctor.js +9 -1
  14. package/dist/commands/fixtures.js +45 -3
  15. package/dist/commands/launch/extension.js +28 -6
  16. package/dist/commands/launch/index.js +128 -17
  17. package/dist/commands/launch/mobile.js +26 -2
  18. package/dist/commands/mobile-device-view.js +8 -1
  19. package/dist/commands/prepare.js +20 -3
  20. package/dist/commands/run-engine.js +10 -1
  21. package/dist/commands/runtime-launch.js +10 -1
  22. package/dist/commands/status-probe.js +14 -1
  23. package/dist/commands/status-watch.js +32 -0
  24. package/dist/commands/status.js +13 -0
  25. package/dist/doctor.js +9 -1
  26. package/dist/feature-flags.js +193 -0
  27. package/dist/mm-harness-cli.js +15 -2
  28. package/dist/task-view.js +34 -1
  29. package/library/actions/core/feature_flags/_unsupported.mjs +29 -0
  30. package/library/actions/core/feature_flags/clear.mjs +8 -0
  31. package/library/actions/core/feature_flags/read.mjs +8 -0
  32. package/library/actions/core/feature_flags/set.mjs +8 -0
  33. package/library/actions/extension/feature_flags/clear.mjs +48 -0
  34. package/library/actions/extension/feature_flags/read.mjs +27 -0
  35. package/library/actions/extension/feature_flags/set.mjs +36 -0
  36. package/library/actions/extension/feature_flags/state.mjs +129 -0
  37. package/library/actions/harness-exports.mjs +14 -0
  38. package/library/actions/mobile/feature_flags/clear.mjs +20 -0
  39. package/library/actions/mobile/feature_flags/controller.mjs +120 -0
  40. package/library/actions/mobile/feature_flags/read.mjs +20 -0
  41. package/library/actions/mobile/feature_flags/set.mjs +25 -0
  42. package/library/actions/mobile/platform/bridge.mjs +2 -2
  43. package/library/actions/mobile/wallet/reset.mjs +22 -1
  44. package/library/manifests/core.action-manifest.json +90 -0
  45. package/library/manifests/extension.action-manifest.json +70 -0
  46. package/library/manifests/mobile.action-manifest.json +90 -0
  47. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.61.0 - 2026-09-20
6
+
7
+ ### Added
8
+
9
+ - `status --watch` and `status --task` show child checklist units (Farmslot ADR-060): a unit registered on a row is read from `subtasks/index.json` beside the checklist and printed under the row that owns it, with its id, status, `done/total` and source, and its rows indented one level deeper. Piped output logs a registration, each child row tick and a child status move. Everything comes from the protocol helpers (`subtaskPaths`, `isSettledSubtaskStatus`, `enumerateChecklistCheckboxes`), so the harness parses nothing itself.
10
+ - Pins `@farmslot/protocol` 0.30.0, `@farmslot/agent-runtime` 0.13.0, `@farmslot/recipe-harness` 0.18.0 and `@farmslot/expo-recipe` 0.11.0 together, so one protocol copy serves the harness; a worker produces `subtasks/` once its node runs agent-runtime 0.13.0 (`mark sub`).
11
+
12
+ ### Fixed
13
+
14
+ - Mobile `ui.screenshot` records the node's `label` on its artifact-manifest entry (simctl and adb paths); the bridge leaf used to stamp the generic `ui.screenshot screenshot` and drop the label the action schema declares, so packaged evidence carried no caption. The Extension path already carried it.
15
+
16
+ ## 0.60.0 - 2026-09-19
17
+
18
+ ### Changed
19
+
20
+ - 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.
21
+
22
+ ### Added
23
+
24
+ - 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.
25
+
5
26
  ## 0.59.0 - 2026-09-18
6
27
 
7
28
  ### Changed
@@ -1484,6 +1505,11 @@ Fresh-install hotfixes found in live validation of 0.5.0.
1484
1505
 
1485
1506
  ## Unreleased
1486
1507
 
1508
+ ### Added
1509
+
1510
+ - `status --watch` and `status --task` show child checklist units (Farmslot ADR-060): a unit registered on a row is read from `subtasks/index.json` beside the checklist and printed under the row that owns it, with its id, status, `done/total` and source, and its rows indented one level deeper. Piped output logs a registration, each child row tick and a child status move. Everything comes from the protocol helpers (`subtaskPaths`, `isSettledSubtaskStatus`, `enumerateChecklistCheckboxes`), so the harness parses nothing itself.
1511
+ - Pins `@farmslot/protocol` 0.30.0, `@farmslot/agent-runtime` 0.13.0, `@farmslot/recipe-harness` 0.18.0 and `@farmslot/expo-recipe` 0.11.0 together, so one protocol copy serves the harness; a worker produces `subtasks/` once its node runs agent-runtime 0.13.0 (`mark sub`).
1512
+
1487
1513
  ### Fixed
1488
1514
 
1489
1515
  - **`provision --json` adapter-detection failures emit the standard error envelope** — usage errors now match `doctor`/`launch` (envelope on stdout with `error.userAction`; stderr stays human-only). Previously `--json` callers got empty stdout.
@@ -2372,6 +2398,11 @@ Fresh-install hotfixes found in live validation of 0.5.0.
2372
2398
 
2373
2399
  ## Unreleased
2374
2400
 
2401
+ ### Added
2402
+
2403
+ - `status --watch` and `status --task` show child checklist units (Farmslot ADR-060): a unit registered on a row is read from `subtasks/index.json` beside the checklist and printed under the row that owns it, with its id, status, `done/total` and source, and its rows indented one level deeper. Piped output logs a registration, each child row tick and a child status move. Everything comes from the protocol helpers (`subtaskPaths`, `isSettledSubtaskStatus`, `enumerateChecklistCheckboxes`), so the harness parses nothing itself.
2404
+ - Pins `@farmslot/protocol` 0.30.0, `@farmslot/agent-runtime` 0.13.0, `@farmslot/recipe-harness` 0.18.0 and `@farmslot/expo-recipe` 0.11.0 together, so one protocol copy serves the harness; a worker produces `subtasks/` once its node runs agent-runtime 0.13.0 (`mark sub`).
2405
+
2375
2406
  ### Fixed
2376
2407
 
2377
2408
  - **`provision --json` adapter-detection failures emit the standard error envelope** — usage errors now match `doctor`/`launch` (envelope on stdout with `error.userAction`; stderr stays human-only). Previously `--json` callers got empty stdout.
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) {
@@ -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
@@ -327,14 +327,18 @@ NODE
327
327
  if ! $EXTERNAL_ARTIFACT; then
328
328
  prepare_parts+=("node ${quoted_stamp_title} --target ${quoted_target} --runtime-dist ${quoted_runtime_dist} --runtime-dir ${quoted_runtime_dir}")
329
329
  fi
330
- # Optional A/B feature-flag pinning: patch the ephemeral snapshot manifest so
331
- # manifest._flags wins over the fetched ClientConfigApi value. No-op unless
332
- # --remote-flag was passed; the snapshot is re-created each run, so no revert.
333
- 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
334
336
  quoted_pin_flags="$(printf '%q' "$SCRIPT_DIR/pin-remote-flags.cjs")"
335
- 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
336
340
  quoted_runtime_manifest="$(printf '%q' "$RUNTIME_DIST_ABS/manifest.json")"
337
- 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}")
338
342
  REMOTE_FLAGS_APPLIED=true
339
343
  fi
340
344
  if [ -n "$WALLET_FIXTURE_ABS" ]; then
@@ -354,7 +358,7 @@ NODE
354
358
  fi
355
359
 
356
360
  if [ -n "$REMOTE_FLAGS" ] && [ "$REMOTE_FLAGS_APPLIED" != "true" ]; then
357
- 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
358
362
  fi
359
363
 
360
364
  echo "Extension live validation command:"
@@ -364,7 +368,7 @@ $BUILD_LAVAMOAT && display_args+=(--build-lavamoat)
364
368
  $EXTERNAL_ARTIFACT && display_args+=(--external-artifact --dist-dir "$DIST_DIR" --artifact-provenance "$ARTIFACT_PROVENANCE")
365
369
  $RESET_PROFILE && display_args+=(--reset-profile)
366
370
  $RESET_PROFILE || display_args+=(--preserve-profile)
367
- [ -n "$REMOTE_FLAGS" ] && display_args+=(--remote-flag "$REMOTE_FLAGS")
371
+ [ -n "$REMOTE_FLAGS" ] && display_args+=(--remote-flags-json "$REMOTE_FLAGS")
368
372
  [ -n "$CHROME_USER_DATA_DIR" ] && display_args+=(--chrome-user-data-dir "$CHROME_USER_DATA_DIR")
369
373
  printf ' '
370
374
  printf '%q ' "${display_args[@]}"
@@ -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
+ }
@@ -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}"
@@ -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
+ };
@@ -313,6 +313,7 @@ function normalizedRuntimeManifest(file) {
313
313
  delete manifest._flags.remoteFeatureFlags;
314
314
  if (Object.keys(manifest._flags).length === 0) delete manifest._flags;
315
315
  }
316
+ delete manifest._harness;
316
317
  return JSON.stringify(manifest);
317
318
  } catch {
318
319
  return null;
@@ -0,0 +1,127 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { runnerDir } from "../../paths.js";
5
+ const DEFAULT_TIMEOUT_MS = 1e4;
6
+ function bridgeScriptPath() {
7
+ return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
8
+ }
9
+ const CONTROLLER_PREAMBLE = `
10
+ var ctx = globalThis.Engine && globalThis.Engine.context;
11
+ var c = ctx && ctx.RemoteFeatureFlagController;
12
+ if (!c) return JSON.stringify({ ok: false, error: 'Engine.context.RemoteFeatureFlagController is not available on the running app.' });
13
+ var snap = function () {
14
+ var st = c.state || {};
15
+ return { effective: st.remoteFeatureFlags || {}, overrides: st.localOverrides || {} };
16
+ };
17
+ `;
18
+ function evalExpression(body) {
19
+ return `(function(){ try {${CONTROLLER_PREAMBLE}${body} } catch (e) { return JSON.stringify({ ok: false, error: e && (e.message || String(e)) }); } })()`;
20
+ }
21
+ function runBridgeEval(target, expression, timeoutMs) {
22
+ const script = bridgeScriptPath();
23
+ if (!fs.existsSync(script)) return Promise.resolve(null);
24
+ return new Promise((resolve) => {
25
+ let settled = false;
26
+ let stdout = "";
27
+ const child = spawn(process.execPath, [script, "eval", expression], {
28
+ cwd: target,
29
+ env: { ...process.env, APP_ROOT: target, CDP_TIMEOUT: String(timeoutMs) },
30
+ stdio: ["ignore", "pipe", "ignore"]
31
+ });
32
+ const finish = (value) => {
33
+ if (settled) return;
34
+ settled = true;
35
+ clearTimeout(timer);
36
+ resolve(value);
37
+ };
38
+ const timer = setTimeout(() => {
39
+ child.kill("SIGTERM");
40
+ const killTimer = setTimeout(() => child.kill("SIGKILL"), 500);
41
+ child.once("close", () => clearTimeout(killTimer));
42
+ finish(null);
43
+ }, timeoutMs);
44
+ child.stdout.on("data", (chunk) => {
45
+ stdout += chunk;
46
+ });
47
+ child.on("close", (code) => {
48
+ if (code !== 0) return finish(null);
49
+ const trimmed = stdout.trim();
50
+ if (!trimmed) return finish(null);
51
+ try {
52
+ const first = JSON.parse(trimmed);
53
+ const parsed = typeof first === "string" ? JSON.parse(first) : first;
54
+ return finish(parsed);
55
+ } catch {
56
+ return finish(null);
57
+ }
58
+ });
59
+ child.on("error", () => finish(null));
60
+ });
61
+ }
62
+ async function readMobileFeatureFlags(target, timeoutMs = DEFAULT_TIMEOUT_MS) {
63
+ const result = await runBridgeEval(
64
+ target,
65
+ evalExpression("var s = snap(); return JSON.stringify({ ok: true, effective: s.effective, overrides: s.overrides });"),
66
+ timeoutMs
67
+ );
68
+ if (!result || result.ok !== true) return null;
69
+ return { effective: result.effective ?? {}, overrides: result.overrides ?? {} };
70
+ }
71
+ async function pinMobileFeatureFlags(target, flags, timeoutMs = DEFAULT_TIMEOUT_MS) {
72
+ const payload = JSON.stringify(JSON.stringify(flags));
73
+ const result = await runBridgeEval(
74
+ target,
75
+ evalExpression(`
76
+ if (typeof c.setFlagOverride !== 'function') {
77
+ return JSON.stringify({ ok: false, error: 'RemoteFeatureFlagController.setFlagOverride is unavailable in this build.' });
78
+ }
79
+ var requested = JSON.parse(${payload});
80
+ if (typeof c.clearAllFlagOverrides === 'function') c.clearAllFlagOverrides();
81
+ var applied = [];
82
+ for (var k in requested) {
83
+ if (Object.prototype.hasOwnProperty.call(requested, k)) { c.setFlagOverride(k, requested[k]); applied.push(k); }
84
+ }
85
+ var s = snap();
86
+ return JSON.stringify({ ok: true, applied: applied, effective: s.effective, overrides: s.overrides });
87
+ `),
88
+ timeoutMs
89
+ );
90
+ if (!result) {
91
+ return {
92
+ ok: false,
93
+ error: "the mobile bridge did not answer, so no remote feature flag was applied.",
94
+ applied: [],
95
+ effective: {},
96
+ overrides: {},
97
+ mismatched: Object.keys(flags)
98
+ };
99
+ }
100
+ if (result.ok !== true) {
101
+ return {
102
+ ok: false,
103
+ error: result.error ?? "the mobile runtime refused the remote feature-flag override.",
104
+ applied: [],
105
+ effective: {},
106
+ overrides: {},
107
+ mismatched: Object.keys(flags)
108
+ };
109
+ }
110
+ const effective = result.effective ?? {};
111
+ const overrides = result.overrides ?? {};
112
+ const mismatched = [
113
+ ...Object.keys(flags).filter((key) => JSON.stringify(effective[key]) !== JSON.stringify(flags[key])),
114
+ ...Object.keys(overrides).filter((key) => !(key in flags))
115
+ ];
116
+ return {
117
+ ok: mismatched.length === 0,
118
+ applied: result.applied ?? [],
119
+ effective,
120
+ overrides,
121
+ mismatched
122
+ };
123
+ }
124
+ export {
125
+ pinMobileFeatureFlags,
126
+ readMobileFeatureFlags
127
+ };
package/dist/adapters.js CHANGED
@@ -105,6 +105,11 @@ const LIVE_ONLY_WALLET_ACTIONS = /* @__PURE__ */ new Set([
105
105
  "metamask.wallet.list_accounts",
106
106
  "metamask.wallet.read_state"
107
107
  ]);
108
+ const LIVE_ONLY_FEATURE_FLAG_ACTIONS = /* @__PURE__ */ new Set([
109
+ "metamask.feature_flags.set",
110
+ "metamask.feature_flags.read",
111
+ "metamask.feature_flags.clear"
112
+ ]);
108
113
  const LIVE_ONLY_APP_ACTIONS = /* @__PURE__ */ new Set([
109
114
  "ui.navigate",
110
115
  "app.network_capture",
@@ -113,7 +118,7 @@ const LIVE_ONLY_APP_ACTIONS = /* @__PURE__ */ new Set([
113
118
  "app.performance_assert"
114
119
  ]);
115
120
  function requiresLiveAdapter(platform, action) {
116
- return LIVE_ONLY_ACTIONS.has(action) || platform === "core" && CORE_ONLY_PERPS_ACTIONS.has(action) || LIVE_ONLY_WALLET_ACTIONS.has(action) || LIVE_ONLY_APP_ACTIONS.has(action);
121
+ return LIVE_ONLY_ACTIONS.has(action) || platform === "core" && CORE_ONLY_PERPS_ACTIONS.has(action) || LIVE_ONLY_WALLET_ACTIONS.has(action) || LIVE_ONLY_FEATURE_FLAG_ACTIONS.has(action) || LIVE_ONLY_APP_ACTIONS.has(action);
117
122
  }
118
123
  function liveRuntimeConfigured(platform, node) {
119
124
  if (platform === "mobile" || platform === "core") return true;