@deeeed/metamask-harness 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +48 -0
  3. package/adapters/extension/build-handoff.cjs +4 -0
  4. package/adapters/extension/inject.mjs +0 -1
  5. package/adapters/extension/live.sh +26 -25
  6. package/adapters/extension/pin-remote-flags.cjs +69 -30
  7. package/adapters/manifest.json +4 -4
  8. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  9. package/adapters/mobile/bridge-runtime/setup-wallet.sh +69 -2
  10. package/adapters/shared/resolve-slot-ports-core.mjs +47 -19
  11. package/dist/adapters/extension/feature-flags.js +41 -0
  12. package/dist/adapters/extension/runtime-decision.js +2 -2
  13. package/dist/adapters/extension/runtime.js +4 -3
  14. package/dist/adapters/extension/verify.js +608 -0
  15. package/dist/adapters/mobile/feature-flags.js +127 -0
  16. package/dist/adapters/resolve-slot-ports.js +2 -0
  17. package/dist/adapters.js +6 -1
  18. package/dist/cli.js +1 -1
  19. package/dist/command-contract.js +6 -3
  20. package/dist/commands/doctor.js +9 -1
  21. package/dist/commands/fixtures.js +45 -3
  22. package/dist/commands/launch/extension.js +28 -6
  23. package/dist/commands/launch/index.js +128 -17
  24. package/dist/commands/launch/mobile.js +26 -2
  25. package/dist/commands/mobile-device-view.js +8 -1
  26. package/dist/commands/prepare.js +20 -3
  27. package/dist/commands/run-engine.js +10 -1
  28. package/dist/commands/runtime-launch.js +10 -1
  29. package/dist/commands/status-probe.js +14 -1
  30. package/dist/commands/status.js +13 -0
  31. package/dist/doctor.js +9 -1
  32. package/dist/feature-flags.js +193 -0
  33. package/dist/harness.js +3 -15
  34. package/dist/mm-harness-cli.js +15 -2
  35. package/library/actions/core/feature_flags/_unsupported.mjs +29 -0
  36. package/library/actions/core/feature_flags/clear.mjs +8 -0
  37. package/library/actions/core/feature_flags/read.mjs +8 -0
  38. package/library/actions/core/feature_flags/set.mjs +8 -0
  39. package/library/actions/extension/feature_flags/clear.mjs +48 -0
  40. package/library/actions/extension/feature_flags/read.mjs +27 -0
  41. package/library/actions/extension/feature_flags/set.mjs +36 -0
  42. package/library/actions/extension/feature_flags/state.mjs +129 -0
  43. package/library/actions/harness-exports.mjs +14 -0
  44. package/library/actions/mobile/feature_flags/clear.mjs +20 -0
  45. package/library/actions/mobile/feature_flags/controller.mjs +120 -0
  46. package/library/actions/mobile/feature_flags/read.mjs +20 -0
  47. package/library/actions/mobile/feature_flags/set.mjs +25 -0
  48. package/library/actions/mobile/wallet/reset.mjs +22 -1
  49. package/library/manifests/core.action-manifest.json +90 -0
  50. package/library/manifests/extension.action-manifest.json +70 -0
  51. package/library/manifests/mobile.action-manifest.json +90 -0
  52. package/package.json +1 -1
  53. package/adapters/extension/verify.sh +0 -572
@@ -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
+ };
@@ -3,6 +3,7 @@ import {
3
3
  formatExtensionIdentityPorts,
4
4
  formatExtensionSlotPorts,
5
5
  inferSlotSuffix,
6
+ poolFilesForHost,
6
7
  realRepoPath,
7
8
  resolveDefaultExtensionPorts,
8
9
  resolveExtensionRuntimeContext,
@@ -17,6 +18,7 @@ export {
17
18
  formatExtensionSlotPorts,
18
19
  formatKvLines,
19
20
  inferSlotSuffix,
21
+ poolFilesForHost,
20
22
  realRepoPath,
21
23
  resolveDefaultExtensionPorts,
22
24
  resolveExtensionRuntimeContext,
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;
package/dist/cli.js CHANGED
@@ -103,7 +103,7 @@ async function main(argv) {
103
103
  if (command === "completion-candidates") return handleCompletionCandidates(argv.slice(1));
104
104
  if (command === "provision") return handleProvision(parseArgs(argv.slice(1), command));
105
105
  if (OVERLAY_COMMANDS.includes(command)) {
106
- if (command === "verify") {
106
+ if (command === "verify" && process.env.MM_HARNESS_SKIP_OVERLAY_ENSURE !== "1") {
107
107
  try {
108
108
  const { options } = parseArgs(argv.slice(1), command);
109
109
  const target = targetPath(options);
@@ -154,7 +154,8 @@ const PUBLIC_COMMAND_CONTRACTS = {
154
154
  "--platform": value(["mobile", "extension", "core"]),
155
155
  "--mobile-platform": value(["ios", "android"]),
156
156
  "--clear-metro": bool(),
157
- "--artifacts-dir": value()
157
+ "--artifacts-dir": value(),
158
+ "--remote-flag": value()
158
159
  })
159
160
  },
160
161
  task: {
@@ -329,7 +330,8 @@ const PUBLIC_COMMAND_CONTRACTS = {
329
330
  "--sidepanel": bool(),
330
331
  "--fullscreen": bool(),
331
332
  "--url": value(),
332
- "--heal": value(["off", "infra-only", "auto"])
333
+ "--heal": value(["off", "infra-only", "auto"]),
334
+ "--remote-flag": value()
333
335
  }),
334
336
  positionals: [{ label: "platform", choices: ["ios", "android"] }]
335
337
  },
@@ -383,7 +385,8 @@ const PUBLIC_COMMAND_CONTRACTS = {
383
385
  "--cdp-port": value(),
384
386
  "--extension-dir": value(),
385
387
  "--extension-id-file": value(),
386
- "--action-manifest": value()
388
+ "--action-manifest": value(),
389
+ "--remote-flag": value()
387
390
  }),
388
391
  positionals: [{ label: "action", choices: ["init", "sync", "set", "reset", "generate", "finalize"] }],
389
392
  minimumPositionals: 0
@@ -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)`));