@deeeed/metamask-harness 0.59.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 (44) hide show
  1. package/CHANGELOG.md +10 -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.js +13 -0
  24. package/dist/doctor.js +9 -1
  25. package/dist/feature-flags.js +193 -0
  26. package/dist/mm-harness-cli.js +15 -2
  27. package/library/actions/core/feature_flags/_unsupported.mjs +29 -0
  28. package/library/actions/core/feature_flags/clear.mjs +8 -0
  29. package/library/actions/core/feature_flags/read.mjs +8 -0
  30. package/library/actions/core/feature_flags/set.mjs +8 -0
  31. package/library/actions/extension/feature_flags/clear.mjs +48 -0
  32. package/library/actions/extension/feature_flags/read.mjs +27 -0
  33. package/library/actions/extension/feature_flags/set.mjs +36 -0
  34. package/library/actions/extension/feature_flags/state.mjs +129 -0
  35. package/library/actions/harness-exports.mjs +14 -0
  36. package/library/actions/mobile/feature_flags/clear.mjs +20 -0
  37. package/library/actions/mobile/feature_flags/controller.mjs +120 -0
  38. package/library/actions/mobile/feature_flags/read.mjs +20 -0
  39. package/library/actions/mobile/feature_flags/set.mjs +25 -0
  40. package/library/actions/mobile/wallet/reset.mjs +22 -1
  41. package/library/manifests/core.action-manifest.json +90 -0
  42. package/library/manifests/extension.action-manifest.json +70 -0
  43. package/library/manifests/mobile.action-manifest.json +90 -0
  44. package/package.json +1 -1
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { readExtensionPinnedFlags } from "../adapters/extension/feature-flags.js";
6
+ import { featureFlagReport } from "../feature-flags.js";
5
7
  import { color } from "../cli-color.js";
6
8
  import { collectCheckoutView, renderCheckoutView } from "../checkout-view.js";
7
9
  import { createDoctorReport, fixtureSummary, requiredDoctorCheckSummary } from "../doctor.js";
@@ -209,12 +211,14 @@ async function handleDoctor({ options }) {
209
211
  }
210
212
  const skills = skillsView(target);
211
213
  const update = updateStatus();
214
+ const extensionFlags = adapter === "extension" ? readExtensionPinnedFlags(target) : null;
215
+ const featureFlags = extensionFlags ? featureFlagReport(extensionFlags.overrides, extensionFlags) : featureFlagReport(liveView?.featureFlagOverrides ?? null);
212
216
  const doctorUserAction = doctorResult.checks.find(
213
217
  (check) => check.required && check.status === "fail" && check.userAction
214
218
  )?.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
215
219
  const doctorError = doctorResult.status === "fail" ? { code: "DOCTOR_CHECKS_FAILED", message: "one or more required doctor checks failed", userAction: doctorUserAction } : void 0;
216
220
  const next = doctorError?.userAction ?? discovery.deviceDiscoveryErrors?.[0]?.userAction ?? (runtime?.decision === "ready" ? void 0 : runtime?.nextAction);
217
- if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, view, orphanMetros, capture, skills, update, devices, additionalReachableDevices, ...discovery, ...next ? { next } : {}, ...doctorError ? { error: doctorError } : {} }, null, 2));
221
+ if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, view, orphanMetros, capture, skills, update, featureFlags, devices, additionalReachableDevices, ...discovery, ...next ? { next } : {}, ...doctorError ? { error: doctorError } : {} }, null, 2));
218
222
  else {
219
223
  const out = (style, text) => color(style, text, { stream: process.stdout });
220
224
  const stateStyle = (value, good) => value === good ? "ok" : "warn";
@@ -236,6 +240,10 @@ async function handleDoctor({ options }) {
236
240
  console.log(`${out("err", "runtime probe failed:")} ${out("dim", runtimeProbeError)}`);
237
241
  }
238
242
  console.log(renderCheckoutView(view));
243
+ console.log(`${out("label", "feature flags:")} ${out(
244
+ featureFlags.error || (featureFlags.overrideCount ?? 0) > 0 ? "warn" : "dim",
245
+ featureFlags.summary.replace(/^feature flags: /u, "")
246
+ )}`);
239
247
  if (adapter === "mobile") {
240
248
  const perps = mobilePerpsEnvironment(target);
241
249
  console.log(
@@ -8,6 +8,8 @@ import { acquireCheckoutLock } from "../checkout-lock.js";
8
8
  import { colorHumanMessage } from "../cli-color.js";
9
9
  import { applyDeviceTargeting } from "./device-target.js";
10
10
  import { ADAPTER_DETECT_NEXT, checkoutBusyOut, EXIT, flag, parseFlags, resolveAdapter, scriptOverride, spawnScript, spawnScriptStreaming, str, targetOf, usageOut } from "./shared.js";
11
+ import { FeatureFlagSpecError, fixtureFeatureFlags, parseRemoteFlagSpec, sameFeatureFlags } from "../feature-flags.js";
12
+ import { readExtensionPinnedFlags } from "../adapters/extension/feature-flags.js";
11
13
  const FIXTURES_BOOLEANS = /* @__PURE__ */ new Set(["dev", "force", "json"]);
12
14
  const FIXTURE_STREAM_TIMEOUT_MS = 12e4;
13
15
  const MOBILE_SETUP_WALLET_TIMEOUT_MS = 36e4;
@@ -177,8 +179,20 @@ async function handleFixturesLocked(argv) {
177
179
  );
178
180
  }
179
181
  }
182
+ const remoteFlagSpec = str(options, "remoteFlag");
183
+ let fixtureFlags;
184
+ try {
185
+ fixtureFlags = {
186
+ ...fixtureFeatureFlags(fixturePath),
187
+ ...remoteFlagSpec === void 0 ? {} : parseRemoteFlagSpec(remoteFlagSpec)
188
+ };
189
+ } catch (error) {
190
+ if (!(error instanceof FeatureFlagSpecError)) throw error;
191
+ return usageOut(json, "fixtures", error.message, error.userAction);
192
+ }
193
+ const fixtureFlagsJson = JSON.stringify(fixtureFlags);
180
194
  if (adapter === "extension") {
181
- return applyExtensionFixture(target, fixturePath, canonicalFixture, json, sub);
195
+ return applyExtensionFixture(target, fixturePath, canonicalFixture, json, sub, fixtureFlags);
182
196
  }
183
197
  const reset = sub === "reset";
184
198
  process.stderr.write(`\u2192 fixtures ${sub} ${adapter} \u2014 ${reset ? "resetting selected app data, relaunching, then applying" : "connecting bridge + applying"} wallet fixture (can take ~30s)\u2026
@@ -252,7 +266,7 @@ async function handleFixturesLocked(argv) {
252
266
  if (!resetError) {
253
267
  mobileSetupResult = await spawnScriptStreaming(
254
268
  setupWalletSh,
255
- ["--fixture", fixturePath],
269
+ ["--fixture", fixturePath, "--remote-flags-json", fixtureFlagsJson],
256
270
  target,
257
271
  {
258
272
  env: {
@@ -303,7 +317,7 @@ async function handleFixturesLocked(argv) {
303
317
  }
304
318
  return status === "pass" ? EXIT.ok : EXIT.runtime;
305
319
  }
306
- async function applyExtensionFixture(target, fixturePath, canonicalFixture, json, action) {
320
+ async function applyExtensionFixture(target, fixturePath, canonicalFixture, json, action, fixtureFlags) {
307
321
  const distManifest = path.join(target, "dist/chrome/manifest.json");
308
322
  const userAction = `mm-harness launch --build --verify --target ${JSON.stringify(target)}`;
309
323
  if (!fs.existsSync(distManifest)) {
@@ -330,11 +344,39 @@ async function applyExtensionFixture(target, fixturePath, canonicalFixture, json
330
344
  `);
331
345
  const liveScript = path.join(runnerDir, "adapters/extension/live.sh");
332
346
  const args = ["--target", target, "--launch-existing-dist", "--launch-only", "--reset-profile"];
347
+ const flagKeys = Object.keys(fixtureFlags);
348
+ if (flagKeys.length > 0) args.push("--remote-flags-json", JSON.stringify(fixtureFlags));
333
349
  if (process.env.CDP_PORT) args.push("--cdp-port", process.env.CDP_PORT);
334
350
  const result = await spawnScriptStreaming(liveScript, args, target, {
335
351
  env: { RECIPE_WALLET_FIXTURE: fixturePath },
336
352
  timeoutMs: FIXTURE_STREAM_TIMEOUT_MS
337
353
  });
354
+ const pinned = readExtensionPinnedFlags(target);
355
+ const pinnedFlags = pinned.pinned ?? {};
356
+ const flagMismatch = result.status === 0 && !pinned.error && (pinned.pinned === null || !sameFeatureFlags(pinnedFlags, fixtureFlags)) ? [.../* @__PURE__ */ new Set([...flagKeys, ...Object.keys(pinnedFlags)])].filter(
357
+ (key) => JSON.stringify(pinnedFlags[key]) !== JSON.stringify(fixtureFlags[key])
358
+ ) : [];
359
+ if (result.status === 0 && (pinned.error || flagMismatch.length > 0)) {
360
+ const detail = pinned.error ? `the launched snapshot manifest could not be read (${pinned.sourcePath ?? "unknown path"}): ${pinned.error}` : `snapshot pin disagrees with the fixture for: ${flagMismatch.join(", ")}`;
361
+ const flagNext = `mm-harness launch --adapter extension --build --target ${JSON.stringify(target)}`;
362
+ if (json) {
363
+ console.log(JSON.stringify({
364
+ schemaVersion: 1,
365
+ command: "fixtures",
366
+ action,
367
+ adapter: "extension",
368
+ fixture: fixturePath,
369
+ canonicalFixture,
370
+ status: "fail",
371
+ exitCode: EXIT.runtime,
372
+ error: { code: "EXTENSION_FIXTURE_FLAGS_NOT_APPLIED", message: detail, userAction: flagNext }
373
+ }, null, 2));
374
+ } else {
375
+ console.error(`\u2717 mm-harness fixtures ${action}: ${detail}
376
+ Next: ${flagNext}`);
377
+ }
378
+ return EXIT.runtime;
379
+ }
338
380
  const status = result.status === 0 ? "pass" : "fail";
339
381
  const exitCode = status === "pass" ? EXIT.ok : EXIT.runtime;
340
382
  const message = status === "pass" ? `Wallet fixture applied and validated. Want different accounts? Edit: ${canonicalFixture}` : "Extension wallet fixture could not be applied and validated.";
@@ -30,6 +30,8 @@ import { checkExtensionRuntimeHealth } from "../../adapters/extension/runtime.js
30
30
  import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
31
31
  import { isExtensionWatcherLive, stopExtensionWatcher } from "../../adapters/slot-ports.js";
32
32
  import { EXIT, spawnScriptStreaming } from "../shared.js";
33
+ import { readExtensionPinnedFlags } from "../../adapters/extension/feature-flags.js";
34
+ import { sameFeatureFlags } from "../../feature-flags.js";
33
35
  const { CdpSession } = await importRecipeHarnessRuntimeCdp();
34
36
  const { RUNTIME_IDENTITY_FILENAME, RUNTIME_NONCE_PREFIX } = createRequire(import.meta.url)(
35
37
  path.join(runnerDir, "adapters/extension/lib/chrome-args.cjs")
@@ -55,7 +57,7 @@ async function installExtensionDeps(target) {
55
57
  if (result.status === 0) recordDepsBaseline(target);
56
58
  return result;
57
59
  }
58
- async function launchExtension(target, tier, wantWatch, displayMode = "fullscreen") {
60
+ async function launchExtension(target, tier, wantWatch, displayMode = "fullscreen", remoteFlags) {
59
61
  const requestedCdpPort = process.env.CDP_PORT;
60
62
  const requestedRecipeCdpPort = process.env.RECIPE_CDP_PORT;
61
63
  const restoreRequestedPorts = () => {
@@ -68,8 +70,19 @@ async function launchExtension(target, tier, wantWatch, displayMode = "fullscree
68
70
  const artifact = releaseArtifactState(target);
69
71
  const artifactSnapshot = runtimeDistCheck(target, artifact);
70
72
  const artifactLaunchable = !wantWatch && tier !== "build" && artifact.status === "valid" && artifactSnapshot.status === "fresh";
71
- const reusable = !wantWatch && tier !== "build" && await extensionRuntimeReusable(target, artifact);
73
+ const livePin = readExtensionPinnedFlags(target);
74
+ const requestedPin = remoteFlags?.flags ?? {};
75
+ const pinCurrent = !livePin.error && (livePin.overrides === null ? Object.keys(requestedPin).length === 0 : livePin.pinned !== null) && sameFeatureFlags(livePin.pinned ?? {}, requestedPin);
76
+ const reusable = !wantWatch && tier !== "build" && pinCurrent && await extensionRuntimeReusable(target, artifact);
72
77
  restoreRequestedPorts();
78
+ if (!pinCurrent && !wantWatch) {
79
+ const have = Object.keys(livePin.pinned ?? {});
80
+ const want = Object.keys(requestedPin);
81
+ const dropping = have.filter((key) => !want.includes(key));
82
+ console.error(colorHumanMessage(
83
+ `\u2192 extension launch \u2014 re-creating the dist snapshot to pin remote feature flags` + (want.length ? ` (${want.join(", ")})` : "") + (dropping.length ? `, dropping the previous run's ${dropping.join(", ")}` : "")
84
+ ));
85
+ }
73
86
  if (artifact.status !== "none" && !reusable && !artifactLaunchable) {
74
87
  const detail = artifact.status === "invalid" ? `identity is invalid: ${artifact.reason ?? "unknown reason"}` : "runtime is not healthy and reusable";
75
88
  return {
@@ -98,7 +111,7 @@ Next: ${block.userAction}`
98
111
  return spawnScriptStreaming(startWatchSh, watchArgs, target);
99
112
  }
100
113
  if (tier === "build") {
101
- return extensionRebuild(target);
114
+ return extensionRebuild(target, remoteFlags);
102
115
  }
103
116
  if (reusable) {
104
117
  if (artifact.status === "valid") {
@@ -110,9 +123,16 @@ Next: ${block.userAction}`
110
123
  return extensionReattach(target, displayMode);
111
124
  }
112
125
  if (artifactLaunchable) {
126
+ if (remoteFlags) {
127
+ return {
128
+ status: EXIT.infra,
129
+ output: `REMOTE_FLAG_NOT_PINNABLE: the remote feature-flag pin '${remoteFlags.spec}' (from --remote-flag and the wallet fixture's remoteFeatureFlags) cannot be applied to a verified release artifact; the artifact's manifest is signed evidence and is never rewritten.
130
+ Next: drop --remote-flag and remove remoteFeatureFlags from the wallet fixture to validate the artifact as shipped, or validate the variant on a source checkout launched without the artifact.`
131
+ };
132
+ }
113
133
  return extensionLaunchReleaseArtifact(target, artifact);
114
134
  }
115
- return extensionLaunchDevelopment(target);
135
+ return extensionLaunchDevelopment(target, remoteFlags);
116
136
  }
117
137
  async function extensionRuntimeReusable(target, artifact = releaseArtifactState(target)) {
118
138
  const cdpPort = process.env.CDP_PORT;
@@ -370,11 +390,12 @@ async function extensionReattach(target, displayMode = "fullscreen") {
370
390
  console.error(colorHumanMessage(`\u2192 extension quick reattach \u2014 reload in place \xB7 CDP :${process.env.CDP_PORT ?? "default"} (no rebuild, no relaunch)`));
371
391
  return spawnScriptStreaming(reattachScript, reattachArgs, target);
372
392
  }
373
- async function extensionLaunchDevelopment(target) {
393
+ async function extensionLaunchDevelopment(target, remoteFlags) {
374
394
  const liveScript = recipeHarnessPath(target, "extension", "scripts", "live.sh");
375
395
  const liveArgs = ["--target", target];
376
396
  const existingBuild = isExtensionWatcherLive(target) && harnessWatcherMatchesProductConfig(target) && extensionCompiledScriptsMatchProductConfig(target, path.join(target, "dist/chrome")) && fs.existsSync(path.join(target, "dist/chrome/manifest.json")) && !isExtensionDistStale(target);
377
397
  liveArgs.push(existingBuild ? "--launch-existing-dist" : "--start-watch");
398
+ if (remoteFlags) liveArgs.push("--remote-flags-json", remoteFlags.json);
378
399
  if (process.env.CDP_PORT) liveArgs.push("--cdp-port", process.env.CDP_PORT);
379
400
  if (process.env.EXTENSION_START_URL) liveArgs.push("--start-url", process.env.EXTENSION_START_URL);
380
401
  console.error(colorHumanMessage(
@@ -425,7 +446,7 @@ function harnessWatcherMatchesProductConfig(target) {
425
446
  return false;
426
447
  }
427
448
  }
428
- async function extensionRebuild(target) {
449
+ async function extensionRebuild(target, remoteFlags) {
429
450
  const runtimeDirRel = recipeRuntimeDir();
430
451
  const runtimeAbs = path.join(target, runtimeDirRel);
431
452
  const rebuildLog = path.join(runtimeAbs, "rebuild.log");
@@ -434,6 +455,7 @@ async function extensionRebuild(target) {
434
455
  fs.writeFileSync(rebuildLog, "");
435
456
  const liveScript = recipeHarnessPath(target, "extension", "scripts", "live.sh");
436
457
  const liveArgs = ["--target", target, "--build-lavamoat"];
458
+ if (remoteFlags) liveArgs.push("--remote-flags-json", remoteFlags.json);
437
459
  if (process.env.CDP_PORT) liveArgs.push("--cdp-port", process.env.CDP_PORT);
438
460
  if (process.env.EXTENSION_START_URL) liveArgs.push("--start-url", process.env.EXTENSION_START_URL);
439
461
  console.error(colorHumanMessage(`\u2192 extension quick relaunch \u2014 production-like LavaMoat build \xB7 CDP :${process.env.CDP_PORT ?? "default"} (output streams below)`));
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { color, colorHumanMessage } from "../../cli-color.js";
5
5
  import { handleHarness } from "../../harness.js";
6
- import { recipeRuntimePath, runnerDir } from "../../paths.js";
6
+ import { recipeRuntimePath, runnerDir, walletFixturePath } from "../../paths.js";
7
7
  import { getAdapterSurface } from "../../adapters/surface.js";
8
8
  import {
9
9
  ADAPTER_DETECT_NEXT,
@@ -35,6 +35,15 @@ import { launchMobile } from "./mobile.js";
35
35
  import { applyDeviceTargeting } from "../device-target.js";
36
36
  import { acquireCheckoutLock } from "../../checkout-lock.js";
37
37
  import { JsonStreamWriter } from "../../json-stream.js";
38
+ import {
39
+ FeatureFlagSpecError,
40
+ fixtureFeatureFlags,
41
+ parseRemoteFlagSpec,
42
+ remoteFlagRequestFor,
43
+ sameFeatureFlags,
44
+ toRemoteFlagSpec
45
+ } from "../../feature-flags.js";
46
+ import { readExtensionPinnedFlags } from "../../adapters/extension/feature-flags.js";
38
47
  import { ensureExtensionConsoleCapture } from "../../adapters/extension/console-capture.js";
39
48
  import { extensionProductConfigBlock } from "../../adapters/extension/product-config.js";
40
49
  import {
@@ -178,6 +187,25 @@ async function handleLaunchLocked(argv, stream) {
178
187
  );
179
188
  }
180
189
  const displayMode = requestedSurface ?? legacySurface ?? "fullscreen";
190
+ const remoteFlagSpec = str(options, "remoteFlag");
191
+ if (remoteFlagSpec !== void 0 && adapter === "extension" && wantWatch) {
192
+ return launchUsage(
193
+ jsonOutput,
194
+ stream,
195
+ "--remote-flag cannot be applied with --watch: the watcher never re-creates the dist snapshot that carries the pin.",
196
+ `mm-harness launch --adapter extension --remote-flag ${remoteFlagSpec} --target ${shellQuote(target)}`
197
+ );
198
+ }
199
+ let remoteFlagRequest;
200
+ try {
201
+ remoteFlagRequest = remoteFlagRequestFor({
202
+ ...fixtureFeatureFlags(walletFixturePath(target)),
203
+ ...remoteFlagSpec === void 0 ? {} : parseRemoteFlagSpec(remoteFlagSpec)
204
+ });
205
+ } catch (error) {
206
+ if (!(error instanceof FeatureFlagSpecError)) throw error;
207
+ return launchUsage(jsonOutput, stream, error.message, error.userAction);
208
+ }
181
209
  for (const portFlag of ["cdpPort", "watcherPort"]) {
182
210
  const value = str(options, portFlag);
183
211
  if (value !== void 0 && !/^\d+$/u.test(value)) {
@@ -286,10 +314,23 @@ async function handleLaunchLocked(argv, stream) {
286
314
  machine,
287
315
  displayMode,
288
316
  false,
289
- wantClearMetro
317
+ wantClearMetro,
318
+ remoteFlagRequest
290
319
  );
291
320
  if (attempt.status === 0) {
292
- return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
321
+ return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, attempt.remoteFeatureFlags);
322
+ }
323
+ if (adapter === "extension" && attempt.output.includes("REMOTE_FLAG_NOT_PINNABLE:")) {
324
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
325
+ code: "REMOTE_FLAG_NOT_PINNABLE",
326
+ message: `a verified release artifact cannot carry a remote feature-flag pin (${remoteFlagRequest?.spec ?? ""}); its manifest is signed evidence and is never rewritten. Validate the artifact as shipped (drop --remote-flag and the fixture's remoteFeatureFlags) or validate the variant on a source build.`,
327
+ recoverable: false,
328
+ // The next step must leave the artifact alone: a --build here would replace
329
+ // the artifact under validation with a source build.
330
+ userAction: Object.keys(fixtureFeatureFlags(walletFixturePath(target))).length > 0 ? `remove remoteFeatureFlags from ${shellQuote(walletFixturePath(target))}, then: mm-harness launch --adapter extension --target ${shellQuote(target)}` : `mm-harness launch --adapter extension --target ${shellQuote(target)}`,
331
+ exitCode: EXIT.infra,
332
+ originalError: attempt.output.trim() || void 0
333
+ });
293
334
  }
294
335
  if (adapter === "extension" && extensionProductConfigMissing(attempt.output)) {
295
336
  const block = extensionProductConfigBlock(target);
@@ -352,10 +393,10 @@ async function handleLaunchLocked(argv, stream) {
352
393
  const recoveryCode2 = "chrome.relaunched";
353
394
  state.attemptedRecoveries.push(recoveryCode2);
354
395
  stream.phase("recover");
355
- const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
396
+ const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode, false, false, remoteFlagRequest);
356
397
  if (rebuildAttempt.status === 0) {
357
398
  state.recovered.push(recoveryCode2);
358
- return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
399
+ return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, rebuildAttempt.remoteFeatureFlags);
359
400
  }
360
401
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
361
402
  code: "EXTENSION_RELAUNCH_FAILED",
@@ -434,11 +475,13 @@ async function handleLaunchLocked(argv, stream) {
434
475
  target,
435
476
  machine,
436
477
  displayMode,
437
- restartMobileApp
478
+ restartMobileApp,
479
+ false,
480
+ remoteFlagRequest
438
481
  );
439
482
  if (attempt.status === 0) {
440
483
  state.recovered.push(recoveryCode);
441
- return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
484
+ return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, attempt.remoteFeatureFlags);
442
485
  }
443
486
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
444
487
  code: "SAME_RECOVERY_TWICE",
@@ -560,7 +603,39 @@ function nativeInputsChanged(target, adapter) {
560
603
  return false;
561
604
  }
562
605
  }
563
- async function executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode = "fullscreen", restartMobileApp = false, clearMobileMetro = false) {
606
+ function verifyExtensionPin(target, requested) {
607
+ const pinned = readExtensionPinnedFlags(target);
608
+ if (pinned.error) {
609
+ return {
610
+ ok: false,
611
+ flags: {},
612
+ code: "RUNTIME_MANIFEST_UNREADABLE",
613
+ message: `the launched dist snapshot manifest could not be read (${pinned.sourcePath ?? "unknown path"}): ${pinned.error}`
614
+ };
615
+ }
616
+ if (pinned.overrides !== null && pinned.pinned === null) {
617
+ return {
618
+ ok: false,
619
+ flags: {},
620
+ code: "REMOTE_FLAG_NOT_APPLIED",
621
+ message: `the launched dist snapshot manifest (${pinned.sourcePath ?? "unknown path"}) carries no harness pin record; it was not written by this launch.`
622
+ };
623
+ }
624
+ const actual = pinned.pinned ?? {};
625
+ if (!sameFeatureFlags(actual, requested)) {
626
+ const mismatched = [.../* @__PURE__ */ new Set([...Object.keys(requested), ...Object.keys(actual)])].filter(
627
+ (key) => JSON.stringify(actual[key]) !== JSON.stringify(requested[key])
628
+ );
629
+ return {
630
+ ok: false,
631
+ flags: {},
632
+ code: "REMOTE_FLAG_NOT_APPLIED",
633
+ message: `the remote feature-flag pin ${toRemoteFlagSpec(requested) || "(none)"} does not match the launched snapshot manifest: ` + mismatched.map((key) => `${key} expected ${JSON.stringify(requested[key])}, got ${JSON.stringify(actual[key])}`).join("; ")
634
+ };
635
+ }
636
+ return { ok: true, flags: actual, message: "" };
637
+ }
638
+ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode = "fullscreen", restartMobileApp = false, clearMobileMetro = false, remoteFlagRequest) {
564
639
  if (adapter === "mobile") {
565
640
  return launchMobile(
566
641
  target,
@@ -568,12 +643,44 @@ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target
568
643
  tier,
569
644
  json,
570
645
  restartMobileApp,
571
- clearMobileMetro
646
+ clearMobileMetro,
647
+ remoteFlagRequest?.flags
572
648
  );
573
649
  }
574
- return launchExtension(target, tier, wantWatch, displayMode);
650
+ return launchExtension(target, tier, wantWatch, displayMode, remoteFlagRequest);
575
651
  }
576
- async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true) {
652
+ async function settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, sidepanelRecoveryAllowed = true, mobileProvenFlags) {
653
+ const requested = remoteFlagRequest?.flags ?? {};
654
+ const pinned = adapter !== "extension" ? { ok: true, flags: mobileProvenFlags === void 0 ? requested : mobileProvenFlags, message: "" } : wantWatch ? { ok: true, flags: readExtensionPinnedFlags(target).pinned, message: "" } : verifyExtensionPin(target, requested);
655
+ if (!pinned.ok) {
656
+ const spec = remoteFlagRequest?.spec;
657
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
658
+ code: pinned.code ?? "REMOTE_FLAG_NOT_APPLIED",
659
+ message: pinned.message,
660
+ recoverable: false,
661
+ // A rebuild re-creates the snapshot; the flag is repeated only when one was asked for.
662
+ userAction: `mm-harness launch --adapter extension --build${spec ? ` --remote-flag ${spec}` : ""} --target ${shellQuote(target)}`,
663
+ exitCode: EXIT.runtime
664
+ });
665
+ }
666
+ return finishLaunch(
667
+ jsonOutput,
668
+ machine,
669
+ stream,
670
+ adapter,
671
+ mobileTarget,
672
+ tier,
673
+ displayMode,
674
+ target,
675
+ state,
676
+ wantWatch,
677
+ wantVerify,
678
+ sidepanelRecoveryAllowed,
679
+ remoteFlagRequest,
680
+ pinned.flags
681
+ );
682
+ }
683
+ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true, remoteFlagRequest, remoteFlags = {}) {
577
684
  if (adapter === "extension" && displayMode === "fullscreen") {
578
685
  const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
579
686
  const sidepanelArgs = ["close"];
@@ -605,10 +712,10 @@ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget,
605
712
  if (sidepanelRecoveryAllowed && tier === "quick" && extensionRuntimeBlocked(sidepanel.output) && !state.attemptedRecoveries.includes(recoveryCode)) {
606
713
  state.attemptedRecoveries.push(recoveryCode);
607
714
  stream.phase("recover");
608
- const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
715
+ const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode, false, false, remoteFlagRequest);
609
716
  if (rebuildAttempt.status === 0) {
610
717
  state.recovered.push(recoveryCode);
611
- return await finishLaunch(
718
+ return await settleLaunch(
612
719
  jsonOutput,
613
720
  machine,
614
721
  stream,
@@ -620,6 +727,7 @@ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget,
620
727
  state,
621
728
  wantWatch,
622
729
  wantVerify,
730
+ remoteFlagRequest,
623
731
  false
624
732
  );
625
733
  }
@@ -668,14 +776,15 @@ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget,
668
776
  tier,
669
777
  recovered: state.recovered,
670
778
  mutations: state.mutations,
671
- metro: metroLaunchEvidence(adapter, target)
779
+ metro: metroLaunchEvidence(adapter, target),
780
+ remoteFeatureFlags: remoteFlags
672
781
  });
673
782
  }
674
783
  return exitCode;
675
784
  }
676
- return launchPass(jsonOutput, stream, adapter, mobileTarget, tier, displayMode, target, state);
785
+ return launchPass(jsonOutput, stream, adapter, mobileTarget, tier, displayMode, target, state, remoteFlags);
677
786
  }
678
- function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, target, state) {
787
+ function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, target, state, remoteFlags = {}) {
679
788
  if (stream.enabled) {
680
789
  for (const mutation of state.mutations) stream.mutation(mutation);
681
790
  for (const recovery of state.recovered) stream.recovery(recovery);
@@ -685,7 +794,8 @@ function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, targ
685
794
  tier,
686
795
  recovered: state.recovered,
687
796
  mutations: state.mutations,
688
- metro: metroLaunchEvidence(adapter, target)
797
+ metro: metroLaunchEvidence(adapter, target),
798
+ remoteFeatureFlags: remoteFlags
689
799
  });
690
800
  } else if (json) {
691
801
  console.log(
@@ -701,6 +811,7 @@ function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, targ
701
811
  recovered: state.recovered,
702
812
  mutations: state.mutations,
703
813
  metro: metroLaunchEvidence(adapter, target),
814
+ remoteFeatureFlags: remoteFlags,
704
815
  exitCode: EXIT.ok
705
816
  },
706
817
  null,
@@ -4,9 +4,11 @@ import {
4
4
  recordMobileSourceBaseline
5
5
  } from "../../adapters/mobile/source-freshness.js";
6
6
  import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
7
+ import { pinMobileFeatureFlags } from "../../adapters/mobile/feature-flags.js";
8
+ import { toRemoteFlagSpec } from "../../feature-flags.js";
7
9
  import { clearMobileReleaseArtifactState } from "../../adapters/mobile/release-artifact-state.js";
8
10
  import { EXIT } from "../shared.js";
9
- async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false) {
11
+ async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false, remoteFlags) {
10
12
  clearMobileReleaseArtifactState(target);
11
13
  await ensureHarnessFresh(target, "mobile");
12
14
  const platform = mobileTarget ?? "ios";
@@ -22,13 +24,35 @@ async function launchMobile(target, mobileTarget, tier, json, restartApp = false
22
24
  clearMetro
23
25
  });
24
26
  if (result.status !== 0) return result;
27
+ const flags = remoteFlags ?? {};
28
+ const pin = await pinMobileFeatureFlags(target, flags);
29
+ let provenFlags = flags;
30
+ if (!pin.ok && !remoteFlags && pin.error) {
31
+ provenFlags = null;
32
+ if (!json) process.stderr.write(`WARN: remote feature-flag overrides were not cleared: ${pin.error}
33
+ `);
34
+ } else if (!pin.ok) {
35
+ const detail = pin.error ?? `did not read back: ${pin.mismatched.join(", ")} (got ${JSON.stringify(
36
+ Object.fromEntries(pin.mismatched.map((key) => [key, pin.effective[key] ?? pin.overrides[key]]))
37
+ )})`;
38
+ const message = `REMOTE_FLAG_NOT_APPLIED: remote feature flags ${toRemoteFlagSpec(flags) || "(none: clearing overrides)"} ${detail}
39
+ Next: confirm the app is running with the harness bridge, then re-run mm-harness launch`;
40
+ if (!json) process.stderr.write(`${message}
41
+ `);
42
+ return { status: EXIT.runtime, output: message };
43
+ } else if (!json) {
44
+ process.stderr.write(
45
+ pin.applied.length ? `Remote feature flags pinned: ${pin.applied.join(", ")}
46
+ ` : "Remote feature flag overrides cleared.\n"
47
+ );
48
+ }
25
49
  if (!recordMobileSourceBaseline(target, source.fingerprint)) {
26
50
  const message = "Mobile source changed while the app was launching; the loaded-source baseline was not recorded.\n Next: re-run mm-harness launch.";
27
51
  if (!json) process.stderr.write(`${message}
28
52
  `);
29
53
  return { status: EXIT.runtime, output: message };
30
54
  }
31
- return result;
55
+ return { ...result, remoteFeatureFlags: provenFlags };
32
56
  }
33
57
  export {
34
58
  launchMobile
@@ -21,9 +21,16 @@ async function mobileDeviceLiveView(target, view) {
21
21
  liveMap,
22
22
  devicesWithLive,
23
23
  additionalReachableDevices: reachableDevices,
24
- androidPortHints: selectedAndroidPortHints(view.devices, liveMap)
24
+ androidPortHints: selectedAndroidPortHints(view.devices, liveMap),
25
+ featureFlagOverrides: slotFeatureFlagOverrides(liveMap)
25
26
  };
26
27
  }
28
+ function slotFeatureFlagOverrides(liveMap) {
29
+ for (const live of liveMap.values()) {
30
+ if (live.featureFlagOverrides) return live.featureFlagOverrides;
31
+ }
32
+ return null;
33
+ }
27
34
  function scopedLiveMap(devices, allLiveMap) {
28
35
  const liveMap = /* @__PURE__ */ new Map();
29
36
  for (const device of devices) {
@@ -11,6 +11,7 @@ import { harnessExecutable, manifestPath, recipeRuntimeDir } from "../paths.js";
11
11
  import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
12
12
  import { EXIT } from "./shared.js";
13
13
  import { formatDuration } from "./status-watch.js";
14
+ import { FeatureFlagSpecError, parseRemoteFlagSpec } from "../feature-flags.js";
14
15
  const USAGE = "mm-harness prepare [--target <checkout>] [--platform mobile|extension|core] [--mobile-platform ios|android] [--clear-metro] [--artifacts-dir <dir>] [--json]";
15
16
  const REPORT_DIR = "prepare";
16
17
  const SKIPPED_AFTER_FAILURE = "previous step failed";
@@ -318,6 +319,21 @@ async function handlePrepare(argv) {
318
319
  throw usageError(`--clear-metro is mobile only; this checkout is ${platform}
319
320
  Next: ${USAGE}`);
320
321
  }
322
+ const remoteFlag = optionString(options, "remoteFlag");
323
+ if (remoteFlag !== void 0) {
324
+ if (platform === "core") {
325
+ throw usageError(`--remote-flag needs an app runtime; this checkout is core
326
+ Next: ${USAGE}`);
327
+ }
328
+ try {
329
+ parseRemoteFlagSpec(remoteFlag);
330
+ } catch (error) {
331
+ if (!(error instanceof FeatureFlagSpecError)) throw error;
332
+ throw usageError(`${error.message}
333
+ Next: ${error.userAction}`);
334
+ }
335
+ }
336
+ const remoteFlagArgs = remoteFlag === void 0 ? [] : ["--remote-flag", remoteFlag];
321
337
  let mobilePlatform = mobilePlatformOption(optionString(options, "mobilePlatform"));
322
338
  const recordPath = path.join(artifactsDir, READINESS_RECORD);
323
339
  const chain = stepChain(platform);
@@ -353,17 +369,18 @@ async function handlePrepare(argv) {
353
369
  addStep(skippedStep("fixtures", `${executable} fixtures set`, reason));
354
370
  } else if (platform === "mobile") {
355
371
  const device = mobilePlatform ? [mobilePlatform] : [];
356
- await run("launch", ["launch", ...device, ...clearMetro ? ["--clear-metro"] : [], "--verify", "--json", ...targetArgs]);
372
+ await run("launch", ["launch", ...device, ...clearMetro ? ["--clear-metro"] : [], ...remoteFlagArgs, "--verify", "--json", ...targetArgs]);
357
373
  await run("fixtures", [
358
374
  "fixtures",
359
375
  "set",
360
376
  ...mobilePlatform ? ["--platform", mobilePlatform] : [],
377
+ ...remoteFlagArgs,
361
378
  "--json",
362
379
  ...targetArgs
363
380
  ]);
364
381
  } else {
365
- await run("launch", ["launch", "--verify", "--json", ...targetArgs]);
366
- await run("fixtures", ["fixtures", "set", "--json", ...targetArgs]);
382
+ await run("launch", ["launch", ...remoteFlagArgs, "--verify", "--json", ...targetArgs]);
383
+ await run("fixtures", ["fixtures", "set", ...remoteFlagArgs, "--json", ...targetArgs]);
367
384
  }
368
385
  await run("verify", ["verify", "--json", "--adapter", platform, ...targetArgs]);
369
386
  } finally {
@@ -1344,7 +1344,16 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
1344
1344
  );
1345
1345
  const launch = deps.launch ?? (async (requestedTier) => {
1346
1346
  const { launchExtension } = await import("./launch/extension.js");
1347
- return launchExtension(target, requestedTier, false);
1347
+ const { readExtensionPinnedFlags } = await import("../adapters/extension/feature-flags.js");
1348
+ const { fixtureFeatureFlags, remoteFlagRequestFor } = await import("../feature-flags.js");
1349
+ const { walletFixturePath } = await import("../paths.js");
1350
+ return launchExtension(
1351
+ target,
1352
+ requestedTier,
1353
+ false,
1354
+ "fullscreen",
1355
+ remoteFlagRequestFor(readExtensionPinnedFlags(target).pinned ?? fixtureFeatureFlags(walletFixturePath(target)))
1356
+ );
1348
1357
  });
1349
1358
  const result = await launch(tier);
1350
1359
  const verified = result.status === 0 ? await decide() : initial;
@@ -20,6 +20,7 @@ import {
20
20
  targetPath,
21
21
  usageError
22
22
  } from "./parse-args.js";
23
+ import { FeatureFlagSpecError, parseRemoteFlagSpec } from "../feature-flags.js";
23
24
  async function handleRuntimeLaunch({ options }) {
24
25
  if (optionFlag(options, "help")) {
25
26
  console.log(`mm-harness runtime-launch --adapter <extension|mobile> --target <checkout> [flags]
@@ -129,7 +130,15 @@ async function handleRuntimeLaunch({ options }) {
129
130
  if (chromeUserDataDir) command.push("--chrome-user-data-dir", chromeUserDataDir);
130
131
  command.push(optionFlag(options, "resetProfile") ? "--reset-profile" : "--preserve-profile");
131
132
  const remoteFlag = optionString(options, "remoteFlag");
132
- if (remoteFlag) command.push("--remote-flag", remoteFlag);
133
+ if (remoteFlag) {
134
+ try {
135
+ command.push("--remote-flags-json", JSON.stringify(parseRemoteFlagSpec(remoteFlag)));
136
+ } catch (error) {
137
+ if (!(error instanceof FeatureFlagSpecError)) throw error;
138
+ throw usageError(`${error.message}
139
+ Next: ${error.userAction}`);
140
+ }
141
+ }
133
142
  if (!fs.existsSync(liveScript)) {
134
143
  const report2 = runtimeLaunchReport("fail", {
135
144
  adapter,