@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
@@ -0,0 +1,36 @@
1
+ // Refuse a runtime override honestly and name the relaunch that pins the flag.
2
+ //
3
+ // The extension's only override channel is the per-run dist snapshot manifest
4
+ // (`_flags.remoteFeatureFlags`), written at launch. Nothing in the background
5
+ // exposes setFlagOverride, so there is no code path that could make this action
6
+ // take effect — see EXTENSION_SET_UNSUPPORTED_REASON in ./state.mjs. The
7
+ // structured refusal is written to the output file before throwing so callers
8
+ // keep the record and still see a non-zero exit.
9
+ import { normalizeFeatureFlagRecord, toRemoteFlagSpec } from '../../harness-exports.mjs';
10
+ import { runAdapter, writeOutput } from '../platform/cdp.mjs';
11
+ import { EXTENSION_SET_UNSUPPORTED_REASON, relaunchHint } from './state.mjs';
12
+
13
+ runAdapter(async (input) => {
14
+ const requested = normalizeFeatureFlagRecord(input.node?.flags, 'metamask.feature_flags.set');
15
+ if (Object.keys(requested).length === 0) {
16
+ throw new Error('metamask.feature_flags.set requires a non-empty flags object, e.g. flags={"perpsTAT3938AbtestScreenVsBottomSheet":"treatment"}.');
17
+ }
18
+ const next = relaunchHint(
19
+ String(input.context?.projectRoot ?? '<extension-checkout>'),
20
+ toRemoteFlagSpec(requested),
21
+ );
22
+ await writeOutput(input, {
23
+ action: input.action,
24
+ source: 'extension-manifest-pin',
25
+ supported: false,
26
+ reason: EXTENSION_SET_UNSUPPORTED_REASON,
27
+ applied: [],
28
+ requested,
29
+ needsRelaunch: true,
30
+ next,
31
+ proofPath: 'extension-remote-feature-flags',
32
+ });
33
+ throw new Error(
34
+ `metamask.feature_flags.set is not supported on the extension at runtime. ${EXTENSION_SET_UNSUPPORTED_REASON}\n Next: ${next}`,
35
+ );
36
+ });
@@ -0,0 +1,129 @@
1
+ // Extension remote feature-flag transport: the manifest `_flags` pin plus controller state.
2
+ //
3
+ // The extension resolves flags as
4
+ // getRemoteFeatureFlags = merge({}, state.metamask.remoteFeatureFlags, manifest._flags.remoteFeatureFlags)
5
+ // (shared/lib/selectors/remote-feature-flags.ts), with the manifest winning. The
6
+ // manifest pin is therefore the extension's override channel, and it is written
7
+ // into the per-run dist snapshot at launch by adapters/extension/pin-remote-flags.cjs.
8
+ //
9
+ // There is no runtime override RPC: metamask-controller.js never wires
10
+ // RemoteFeatureFlagController.setFlagOverride into getApi(), and
11
+ // `grep -rn 'setFlagOverride' app/ shared/ ui/` finds nothing in the checkout,
12
+ // so stateHooks.submitRequestToBackground cannot reach it. `set` and `clear`
13
+ // therefore refuse honestly and name the relaunch that does work.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ import { recipeRuntimeDir } from '../../harness-exports.mjs';
19
+
20
+ export const EXTENSION_SET_UNSUPPORTED_REASON =
21
+ 'The extension exposes no runtime remote feature-flag override RPC: RemoteFeatureFlagController.setFlagOverride is never registered in metamask-controller.js getApi(), so stateHooks.submitRequestToBackground cannot reach it. Overrides are pinned into the per-run dist snapshot manifest at launch.';
22
+
23
+ /**
24
+ * Page expression reading the controller's fetched flags, plus the running
25
+ * manifest's pin when the page realm can still reach `chrome`.
26
+ *
27
+ * A LavaMoat build scuttles `chrome` in the page realm, so the manifest read is
28
+ * best-effort here and the caller falls back to the snapshot file on disk. The
29
+ * failure is reported, never swallowed: `manifestReadable` says which source the
30
+ * override record can come from.
31
+ */
32
+ export function readFeatureFlagsExpression() {
33
+ return `(async () => {
34
+ const hooks = globalThis.stateHooks || {};
35
+ let root = null;
36
+ if (typeof hooks.getCleanAppState === 'function') {
37
+ root = await hooks.getCleanAppState();
38
+ }
39
+ if ((!root || typeof root !== 'object') && typeof hooks.store?.getState === 'function') {
40
+ root = hooks.store.getState();
41
+ }
42
+ if (!root || typeof root !== 'object') {
43
+ throw new Error('stateHooks.getCleanAppState and stateHooks.store.getState are unavailable.');
44
+ }
45
+ const metamask = root.metamask || root;
46
+ let manifestFlags = null;
47
+ let manifestUnreadableReason = null;
48
+ try {
49
+ // Gate on the accessor itself: optional chaining alone would yield
50
+ // undefined on a page without chrome and report an empty pin as if the
51
+ // running manifest had answered.
52
+ if (typeof globalThis.chrome?.runtime?.getManifest !== 'function') {
53
+ throw new Error('chrome.runtime.getManifest is not a function on this page.');
54
+ }
55
+ const manifest = globalThis.chrome.runtime.getManifest();
56
+ manifestFlags = manifest?._flags?.remoteFeatureFlags || {};
57
+ } catch (error) {
58
+ // Not swallowed: a LavaMoat build scuttles chrome in the page realm, and
59
+ // the caller answers from the snapshot manifest on disk instead. The
60
+ // reason travels with the result so the source is never ambiguous.
61
+ manifestUnreadableReason = String(error?.message || error);
62
+ }
63
+ return {
64
+ manifestReadable: manifestFlags !== null,
65
+ manifestUnreadableReason,
66
+ stateFlags: metamask.remoteFeatureFlags || {},
67
+ manifestFlags: manifestFlags || {},
68
+ thresholdGroups: metamask.featureFlagThresholdGroups || {},
69
+ };
70
+ })()`;
71
+ }
72
+
73
+ /**
74
+ * The per-run dist snapshot manifest, which is what pin-remote-flags.cjs writes
75
+ * and what the browser was launched from. `null` means no snapshot exists.
76
+ */
77
+ export function readSnapshotManifestFlags(projectRoot) {
78
+ const manifestPath = path.join(
79
+ String(projectRoot ?? ''),
80
+ recipeRuntimeDir(),
81
+ process.env.RECIPE_RUNTIME_DIST_DIR || 'runtime-dist',
82
+ 'manifest.json',
83
+ );
84
+ if (!fs.existsSync(manifestPath)) return null;
85
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
86
+ return { flags: manifest?._flags?.remoteFeatureFlags || {}, path: manifestPath };
87
+ }
88
+
89
+ /**
90
+ * Resolve the override record from the running manifest when the page can read
91
+ * it, and from the launched snapshot on disk when LavaMoat scuttles `chrome`.
92
+ * Always reports which source answered.
93
+ */
94
+ export function resolveOverrides(pageState, projectRoot) {
95
+ if (pageState.manifestReadable) {
96
+ return {
97
+ overrides: pageState.manifestFlags,
98
+ overrideSource: 'running-extension-manifest',
99
+ };
100
+ }
101
+ const snapshot = readSnapshotManifestFlags(projectRoot);
102
+ if (!snapshot) {
103
+ throw new Error(
104
+ `The page realm cannot read chrome.runtime.getManifest() (${pageState.manifestUnreadableReason}) and no dist snapshot manifest exists under ${String(projectRoot)}, so the pinned remote feature flags are unknown.`,
105
+ );
106
+ }
107
+ return {
108
+ overrides: snapshot.flags,
109
+ overrideSource: 'launched-dist-snapshot-manifest',
110
+ overrideSourcePath: snapshot.path,
111
+ manifestUnreadableReason: pageState.manifestUnreadableReason,
112
+ };
113
+ }
114
+
115
+ /** Restrict a flag map to the requested keys, when `keys` is given. */
116
+ export function selectKeys(map, keys) {
117
+ if (!Array.isArray(keys) || keys.length === 0) return map;
118
+ const selected = {};
119
+ // An absent key reads as null rather than disappearing: a caller that asked
120
+ // about a key deserves an answer about it, and JSON drops undefined.
121
+ for (const key of keys) selected[key] = map[key] === undefined ? null : map[key];
122
+ return selected;
123
+ }
124
+
125
+ /** The relaunch that does pin a flag, quoted so it can be run as printed. */
126
+ export function relaunchHint(target, spec) {
127
+ const quoted = /[^A-Za-z0-9_@%+=:,./-]/u.test(target) ? `'${target.replaceAll("'", "'\\''")}'` : target;
128
+ return `mm-harness launch --adapter extension --remote-flag ${spec} --target ${quoted}`;
129
+ }
@@ -15,13 +15,27 @@ async function load(distRelative, srcRelative) {
15
15
 
16
16
  const paths = await load('dist/paths.js', 'src/paths.ts');
17
17
  const recording = await load('dist/run-recording.js', 'src/run-recording.ts');
18
+ const featureFlags = await load('dist/feature-flags.js', 'src/feature-flags.ts');
18
19
 
19
20
  export const {
20
21
  importRecipeHarnessRuntimeBrowserExtension,
21
22
  importRecipeHarnessRuntimeCdp,
22
23
  extensionIdPath,
23
24
  recipeHarnessPath,
25
+ recipeRuntimeDir,
24
26
  walletFixturePath,
25
27
  } = paths;
26
28
 
27
29
  export const { captureActiveRecipeRecordingSnapshot } = recording;
30
+
31
+ // One normalisation for --remote-flag specs, fixture objects, and the
32
+ // doctor/status summary line. See src/feature-flags.ts.
33
+ export const {
34
+ FeatureFlagSpecError,
35
+ describeFeatureFlagValue,
36
+ formatFeatureFlagLine,
37
+ normalizeFeatureFlagRecord,
38
+ normalizeFeatureFlagValue,
39
+ parseRemoteFlagSpec,
40
+ toRemoteFlagSpec,
41
+ } = featureFlags;
@@ -0,0 +1,20 @@
1
+ // Drop every local remote feature-flag override so the slot leaks no variant.
2
+ import { runAdapter } from '../platform/bridge.mjs';
3
+ import { clearMobileFlags } from './controller.mjs';
4
+
5
+ runAdapter(async (input) => {
6
+ const { cleared, effective, overrides } = await clearMobileFlags(input);
7
+ const remaining = Object.keys(overrides);
8
+ if (remaining.length > 0) {
9
+ throw new Error(`metamask.feature_flags.clear left ${remaining.length} override(s): ${remaining.join(', ')}.`);
10
+ }
11
+ return {
12
+ action: input.action,
13
+ source: 'mobile-remote-feature-flag-controller',
14
+ cleared,
15
+ overrides,
16
+ effective,
17
+ supported: true,
18
+ proofPath: 'mobile-remote-feature-flags',
19
+ };
20
+ });
@@ -0,0 +1,120 @@
1
+ // Mobile remote feature-flag transport: RemoteFeatureFlagController over the CDP bridge.
2
+ //
3
+ // `setFlagOverride` writes `state.localOverrides` and recomputes
4
+ // `state.remoteFeatureFlags` as merge(processed remote flags, localOverrides) —
5
+ // overrides persist and win over every later refetch, so a pinned variant
6
+ // survives a ClientConfigApi response. Verified against
7
+ // @metamask/remote-feature-flag-controller
8
+ // dist/remote-feature-flag-controller.cjs (setFlagOverride / removeFlagOverride /
9
+ // clearAllFlagOverrides) and the global Engine in app/core/Engine/Engine.ts.
10
+ import { evalAsync } from '../platform/bridge.mjs';
11
+
12
+ const CONTROLLER_PREAMBLE = `
13
+ var engine = globalThis.Engine;
14
+ var context = engine && engine.context;
15
+ var controller = context && context.RemoteFeatureFlagController;
16
+ if (!controller) {
17
+ return JSON.stringify({
18
+ ok: false,
19
+ error: 'Engine.context.RemoteFeatureFlagController is not available on the running app.',
20
+ });
21
+ }
22
+ var snapshot = function () {
23
+ var state = controller.state || {};
24
+ return {
25
+ effective: state.remoteFeatureFlags || {},
26
+ overrides: state.localOverrides || {},
27
+ };
28
+ };
29
+ `;
30
+
31
+ function evaluate(input, body) {
32
+ return evalAsync(input, `Promise.resolve().then(function () {${CONTROLLER_PREAMBLE}${body}})`);
33
+ }
34
+
35
+ function unwrap(result, action) {
36
+ if (!result || typeof result !== 'object') {
37
+ throw new Error(`${action} did not return a controller result from the mobile bridge.`);
38
+ }
39
+ if (result.ok !== true) {
40
+ throw new Error(String(result.error || `${action} failed on the mobile runtime.`));
41
+ }
42
+ return result;
43
+ }
44
+
45
+ /** Read `{ effective, overrides }` straight from controller state. */
46
+ export async function readMobileFlags(input) {
47
+ const result = await evaluate(input, `
48
+ var current = snapshot();
49
+ return JSON.stringify({ ok: true, effective: current.effective, overrides: current.overrides });
50
+ `);
51
+ return unwrap(result, 'metamask.feature_flags.read');
52
+ }
53
+
54
+ /** Apply every normalised flag, then read the controller back in the same eval. */
55
+ export async function setMobileFlags(input, flags) {
56
+ const payload = JSON.stringify(JSON.stringify(flags));
57
+ const result = await evaluate(input, `
58
+ if (typeof controller.setFlagOverride !== 'function') {
59
+ return JSON.stringify({
60
+ ok: false,
61
+ error: 'RemoteFeatureFlagController.setFlagOverride is unavailable in this build; update @metamask/remote-feature-flag-controller in the checkout.',
62
+ });
63
+ }
64
+ var requested = JSON.parse(${payload});
65
+ var applied = [];
66
+ for (var key in requested) {
67
+ if (Object.prototype.hasOwnProperty.call(requested, key)) {
68
+ controller.setFlagOverride(key, requested[key]);
69
+ applied.push(key);
70
+ }
71
+ }
72
+ var current = snapshot();
73
+ return JSON.stringify({ ok: true, applied: applied, effective: current.effective, overrides: current.overrides });
74
+ `);
75
+ return unwrap(result, 'metamask.feature_flags.set');
76
+ }
77
+
78
+ /** Drop every override so the slot never leaks a variant into the next run. */
79
+ export async function clearMobileFlags(input) {
80
+ const result = await evaluate(input, `
81
+ if (typeof controller.clearAllFlagOverrides !== 'function') {
82
+ return JSON.stringify({
83
+ ok: false,
84
+ error: 'RemoteFeatureFlagController.clearAllFlagOverrides is unavailable in this build; update @metamask/remote-feature-flag-controller in the checkout.',
85
+ });
86
+ }
87
+ var before = Object.keys((controller.state || {}).localOverrides || {});
88
+ controller.clearAllFlagOverrides();
89
+ var current = snapshot();
90
+ return JSON.stringify({ ok: true, cleared: before, effective: current.effective, overrides: current.overrides });
91
+ `);
92
+ return unwrap(result, 'metamask.feature_flags.clear');
93
+ }
94
+
95
+ /** Restrict an effective/override map to the requested keys, when `keys` is given. */
96
+ export function selectKeys(map, keys) {
97
+ if (!Array.isArray(keys) || keys.length === 0) return map;
98
+ const selected = {};
99
+ // An absent key reads as null rather than disappearing: a caller that asked
100
+ // about a key deserves an answer about it, and JSON drops undefined.
101
+ for (const key of keys) selected[key] = map[key] === undefined ? null : map[key];
102
+ return selected;
103
+ }
104
+
105
+ /**
106
+ * Read the requested keys back and fail loudly when the runtime disagrees with
107
+ * what was asked for. Applying a flag nobody can observe is not proof.
108
+ */
109
+ export function assertFlagsReadBack(requested, effective, action) {
110
+ const mismatched = Object.keys(requested).filter(
111
+ (key) => JSON.stringify(effective[key]) !== JSON.stringify(requested[key]),
112
+ );
113
+ if (mismatched.length > 0) {
114
+ throw new Error(
115
+ `${action} did not read back: ${mismatched
116
+ .map((key) => `${key} expected ${JSON.stringify(requested[key])}, got ${JSON.stringify(effective[key])}`)
117
+ .join('; ')}.`,
118
+ );
119
+ }
120
+ }
@@ -0,0 +1,20 @@
1
+ // Read the effective remote feature flags and the local overrides from the running app.
2
+ import { runAdapter } from '../platform/bridge.mjs';
3
+ import { readMobileFlags, selectKeys } from './controller.mjs';
4
+
5
+ runAdapter(async (input) => {
6
+ const keys = Array.isArray(input.node?.keys) ? input.node.keys.map(String) : [];
7
+ // Read only. The keys here are the app's, not the harness's, so they are
8
+ // reported as the controller holds them: validating app-owned key names on a
9
+ // read would fail the read over a flag the harness never authored.
10
+ const { effective, overrides } = await readMobileFlags(input);
11
+ return {
12
+ action: input.action,
13
+ source: 'mobile-remote-feature-flag-controller',
14
+ effective: selectKeys(effective, keys),
15
+ overrides: selectKeys(overrides, keys),
16
+ overrideCount: Object.keys(overrides).length,
17
+ supported: true,
18
+ proofPath: 'mobile-remote-feature-flags',
19
+ };
20
+ });
@@ -0,0 +1,25 @@
1
+ // Pin remote feature-flag overrides on the running app, then read them back.
2
+ import { normalizeFeatureFlagRecord } from '../../harness-exports.mjs';
3
+ import { runAdapter } from '../platform/bridge.mjs';
4
+ import { assertFlagsReadBack, setMobileFlags } from './controller.mjs';
5
+
6
+ runAdapter(async (input) => {
7
+ const requested = normalizeFeatureFlagRecord(
8
+ input.node?.flags,
9
+ 'metamask.feature_flags.set',
10
+ );
11
+ if (Object.keys(requested).length === 0) {
12
+ throw new Error('metamask.feature_flags.set requires a non-empty flags object, e.g. flags={"perpsTAT3938AbtestScreenVsBottomSheet":"treatment"}.');
13
+ }
14
+ const { applied, effective, overrides } = await setMobileFlags(input, requested);
15
+ assertFlagsReadBack(requested, effective, 'metamask.feature_flags.set');
16
+ return {
17
+ action: input.action,
18
+ source: 'mobile-remote-feature-flag-controller',
19
+ applied,
20
+ overrides,
21
+ effective,
22
+ supported: true,
23
+ proofPath: 'mobile-remote-feature-flags',
24
+ };
25
+ });
@@ -927,7 +927,7 @@ export async function simulatorScreenshot(input, relPath) {
927
927
  path: relative,
928
928
  type: 'screenshot',
929
929
  nodeId: input.context.nodeId,
930
- label: input.node?.description || `${input.action} screenshot`,
930
+ label: input.node?.label || input.node?.description || `${input.action} screenshot`,
931
931
  category: 'evidence',
932
932
  mimeType: 'image/png',
933
933
  metadata: {
@@ -976,7 +976,7 @@ async function androidScreenshot(input, relPath) {
976
976
  path: relative,
977
977
  type: 'screenshot',
978
978
  nodeId: input.context.nodeId,
979
- label: input.node?.description || `${input.action} screenshot`,
979
+ label: input.node?.label || input.node?.description || `${input.action} screenshot`,
980
980
  category: 'evidence',
981
981
  mimeType: 'image/png',
982
982
  metadata: {
@@ -1,21 +1,42 @@
1
1
  // Reset the wallet through visible UI and verify fresh onboarding using the bridge or native accessibility.
2
2
  import { runAdapter } from '../platform/bridge.mjs';
3
+ import { clearMobileFlags } from '../feature_flags/controller.mjs';
3
4
  import { resetMobileWalletThroughUi } from './reset-helper.mjs';
4
5
  import { isOpaqueMobileRuntime, resetWalletThroughNativeUi } from './native-ui.mjs';
5
6
 
7
+ // A reset that leaves a pinned A/B variant behind would silently carry one run's
8
+ // variant into the next. Overrides live in the controller, not the vault, so the
9
+ // visible reset flow does not drop them on its own.
10
+ //
11
+ // The opaque-runtime path drives native accessibility precisely because the JS
12
+ // bridge is unavailable there, so the controller cannot be reached and the
13
+ // override state is reported as unattempted rather than failing the reset or
14
+ // claiming a clear that never happened.
15
+ async function clearFeatureFlagOverrides(input) {
16
+ const { cleared } = await clearMobileFlags(input);
17
+ return { attempted: true, cleared };
18
+ }
19
+
6
20
  runAdapter(async (input) => {
7
21
  if (isOpaqueMobileRuntime(input)) {
8
22
  const result = await resetWalletThroughNativeUi(input);
9
23
  return {
10
24
  action: input.action,
11
25
  ...result,
26
+ featureFlagOverrides: {
27
+ attempted: false,
28
+ reason: 'The opaque mobile runtime has no JS bridge, so RemoteFeatureFlagController is unreachable from this reset path.',
29
+ cleared: [],
30
+ },
12
31
  route: 'Onboarding',
13
32
  redacted: true,
14
33
  proofPath: 'mobile-visible-wallet-reset-native',
15
34
  };
16
35
  }
36
+ const reset = await resetMobileWalletThroughUi(input);
17
37
  return {
18
38
  action: input.action,
19
- ...(await resetMobileWalletThroughUi(input)),
39
+ ...reset,
40
+ featureFlagOverrides: await clearFeatureFlagOverrides(input),
20
41
  };
21
42
  });
@@ -1,6 +1,96 @@
1
1
  {
2
2
  "$schema": "https://farmslot.io/schemas/action-manifest-v1.schema.json",
3
3
  "actions": {
4
+ "metamask.feature_flags.set": {
5
+ "description": "Report that core, the headless adapter, holds no remote feature flags to override.",
6
+ "schema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "flags": {
10
+ "type": "object",
11
+ "description": "Flag key to value. A bare word is a named A/B variant (\"treatment\" becomes { \"name\": \"treatment\" }); true/false is a boolean; an object or array is used as authored."
12
+ },
13
+ "controller_ready_timeout_ms": {
14
+ "type": "number",
15
+ "minimum": 1,
16
+ "description": "Maximum time in milliseconds to wait for the app runtime to accept the override."
17
+ }
18
+ },
19
+ "required": [
20
+ "flags"
21
+ ],
22
+ "additionalProperties": false
23
+ },
24
+ "examples": [
25
+ {
26
+ "action": "metamask.feature_flags.set",
27
+ "flags": {
28
+ "perpsTAT3938AbtestScreenVsBottomSheet": "treatment"
29
+ },
30
+ "intent": "Pin the A/B variant the proof runs under",
31
+ "next": "done"
32
+ }
33
+ ],
34
+ "execution_capabilities": [
35
+ "app-mutation"
36
+ ]
37
+ },
38
+ "metamask.feature_flags.read": {
39
+ "description": "Report that core, the headless adapter, holds no remote feature flags to read.",
40
+ "schema": {
41
+ "type": "object",
42
+ "properties": {
43
+ "keys": {
44
+ "type": "array",
45
+ "items": {
46
+ "type": "string"
47
+ },
48
+ "description": "Restrict the reported flags to these keys. Omit to report every flag."
49
+ },
50
+ "controller_ready_timeout_ms": {
51
+ "type": "number",
52
+ "minimum": 1,
53
+ "description": "Maximum time in milliseconds to wait for the app runtime to answer."
54
+ }
55
+ },
56
+ "additionalProperties": false
57
+ },
58
+ "examples": [
59
+ {
60
+ "action": "metamask.feature_flags.read",
61
+ "keys": [
62
+ "perpsTAT3938AbtestScreenVsBottomSheet"
63
+ ],
64
+ "intent": "Record the A/B variant the proof ran under",
65
+ "next": "done"
66
+ }
67
+ ],
68
+ "execution_capabilities": []
69
+ },
70
+ "metamask.feature_flags.clear": {
71
+ "description": "Report that core, the headless adapter, holds no remote feature-flag overrides to clear.",
72
+ "schema": {
73
+ "type": "object",
74
+ "properties": {
75
+ "controller_ready_timeout_ms": {
76
+ "type": "number",
77
+ "minimum": 1,
78
+ "description": "Maximum time in milliseconds to wait for the app runtime to answer."
79
+ }
80
+ },
81
+ "additionalProperties": false
82
+ },
83
+ "examples": [
84
+ {
85
+ "action": "metamask.feature_flags.clear",
86
+ "intent": "Drop the pinned variant before the next run",
87
+ "next": "done"
88
+ }
89
+ ],
90
+ "execution_capabilities": [
91
+ "app-mutation"
92
+ ]
93
+ },
4
94
  "command": {
5
95
  "description": "Run a shell command from the project root.",
6
96
  "examples": [
@@ -1,6 +1,76 @@
1
1
  {
2
2
  "$schema": "https://farmslot.io/schemas/action-manifest-v1.schema.json",
3
3
  "actions": {
4
+ "metamask.feature_flags.set": {
5
+ "description": "Report that the extension cannot pin remote feature flags at runtime and name the relaunch that can. The extension pins flags into the per-run dist snapshot manifest at launch.",
6
+ "schema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "flags": {
10
+ "type": "object",
11
+ "description": "Flag key to value. A bare word is a named A/B variant (\"treatment\" becomes { \"name\": \"treatment\" }); true/false is a boolean; an object or array is used as authored."
12
+ }
13
+ },
14
+ "required": [
15
+ "flags"
16
+ ],
17
+ "additionalProperties": false
18
+ },
19
+ "examples": [
20
+ {
21
+ "action": "metamask.feature_flags.set",
22
+ "flags": {
23
+ "perpsTAT3938AbtestScreenVsBottomSheet": "treatment"
24
+ },
25
+ "intent": "Pin the A/B variant the proof runs under",
26
+ "next": "done"
27
+ }
28
+ ],
29
+ "execution_capabilities": []
30
+ },
31
+ "metamask.feature_flags.read": {
32
+ "description": "Read the effective remote feature flags, the manifest pin, and the fetched flags from the running extension.",
33
+ "schema": {
34
+ "type": "object",
35
+ "properties": {
36
+ "keys": {
37
+ "type": "array",
38
+ "items": {
39
+ "type": "string"
40
+ },
41
+ "description": "Restrict the reported flags to these keys. Omit to report every flag."
42
+ }
43
+ },
44
+ "additionalProperties": false
45
+ },
46
+ "examples": [
47
+ {
48
+ "action": "metamask.feature_flags.read",
49
+ "keys": [
50
+ "perpsTAT3938AbtestScreenVsBottomSheet"
51
+ ],
52
+ "intent": "Record the A/B variant the proof ran under",
53
+ "next": "done"
54
+ }
55
+ ],
56
+ "execution_capabilities": []
57
+ },
58
+ "metamask.feature_flags.clear": {
59
+ "description": "Confirm no manifest flag pin is active, or name the relaunch that drops it.",
60
+ "schema": {
61
+ "type": "object",
62
+ "properties": {},
63
+ "additionalProperties": false
64
+ },
65
+ "examples": [
66
+ {
67
+ "action": "metamask.feature_flags.clear",
68
+ "intent": "Drop the pinned variant before the next run",
69
+ "next": "done"
70
+ }
71
+ ],
72
+ "execution_capabilities": []
73
+ },
4
74
  "command": {
5
75
  "description": "Run a shell command from the project root.",
6
76
  "examples": [