@deeeed/metamask-harness 0.35.0 → 0.37.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 (73) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +9 -1
  3. package/adapters/extension/artifact-runtime-state.cjs +128 -0
  4. package/adapters/extension/check-infura-readiness.cjs +102 -0
  5. package/adapters/extension/inject.mjs +2 -0
  6. package/adapters/extension/launch-browser.cjs +22 -0
  7. package/adapters/extension/live.sh +103 -18
  8. package/adapters/extension/readiness.mjs +77 -36
  9. package/adapters/extension/snapshot-dist.sh +88 -3
  10. package/adapters/extension/start-watch.sh +15 -0
  11. package/adapters/extension/verify.sh +6 -2
  12. package/adapters/extension/wallet-fixture-state.cjs +3 -1
  13. package/adapters/manifest.json +25 -1
  14. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +73 -42
  15. package/adapters/mobile/reset-app-data.sh +154 -0
  16. package/adapters/shared/log-tui.mjs +1 -1
  17. package/dist/adapters/extension/artifact-integrity.js +38 -0
  18. package/dist/adapters/extension/extension-id.js +23 -4
  19. package/dist/adapters/extension/product-config.js +29 -1
  20. package/dist/adapters/extension/release-artifact.js +386 -0
  21. package/dist/adapters/extension/runtime-decision.js +161 -20
  22. package/dist/adapters/extension/runtime.js +127 -0
  23. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  24. package/dist/adapters/mobile/release-artifact.js +295 -0
  25. package/dist/adapters.js +29 -4
  26. package/dist/cli-commands.js +1 -1
  27. package/dist/cli.js +2 -2
  28. package/dist/command-contract.js +17 -1
  29. package/dist/commands/call.js +2 -1
  30. package/dist/commands/device-target.js +5 -0
  31. package/dist/commands/fixtures.js +106 -31
  32. package/dist/commands/launch/extension.js +92 -7
  33. package/dist/commands/launch/index.js +13 -0
  34. package/dist/commands/launch/mobile.js +2 -0
  35. package/dist/commands/provision.js +2 -0
  36. package/dist/commands/run-engine.js +89 -5
  37. package/dist/commands/run.js +3 -1
  38. package/dist/commands/runtime-launch.js +178 -10
  39. package/dist/heal-bounds.js +1 -1
  40. package/dist/live-adapter-contract.js +3 -1
  41. package/dist/metamask-action-validation.js +47 -1
  42. package/dist/mm-harness-cli.js +37 -4
  43. package/dist/recipe-security.js +3 -0
  44. package/dist/run-diagnostics.js +1 -1
  45. package/docs/RECIPES.md +29 -0
  46. package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
  47. package/library/actions/extension/perps/perps.mjs +2 -0
  48. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  49. package/library/actions/extension/platform/cdp.mjs +6 -3
  50. package/library/actions/extension/wallet/import.mjs +201 -0
  51. package/library/actions/extension/wallet/reset.mjs +98 -0
  52. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  53. package/library/actions/extension/wallet/state.mjs +1 -0
  54. package/library/actions/mobile/analytics/consent-settings.mjs +112 -0
  55. package/library/actions/mobile/analytics/set_consent.mjs +4 -112
  56. package/library/actions/mobile/platform/bridge.mjs +8 -0
  57. package/library/actions/mobile/platform/observe-ui.mjs +84 -2
  58. package/library/actions/mobile/ui/native-navigation.mjs +225 -0
  59. package/library/actions/mobile/ui/navigate.mjs +7 -0
  60. package/library/actions/mobile/wallet/import.mjs +328 -0
  61. package/library/actions/mobile/wallet/native-ui.mjs +493 -0
  62. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  63. package/library/actions/mobile/wallet/reset-helper.mjs +99 -0
  64. package/library/actions/mobile/wallet/reset.mjs +20 -0
  65. package/library/actions/shared/ui/locators.mjs +7 -0
  66. package/library/actions/shared/wallet/import-source.mjs +101 -0
  67. package/library/manifests/extension.action-manifest.json +228 -0
  68. package/library/manifests/mobile.action-manifest.json +124 -0
  69. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  70. package/library/recipes/wallet/import.recipe.json +102 -0
  71. package/library/recipes/wallet/reset-import.recipe.json +107 -0
  72. package/package.json +1 -1
  73. package/scripts/completions.sh +2 -2
@@ -13,6 +13,9 @@ import {
13
13
  import { resolveActionManifest, validateManifest } from "../manifest.js";
14
14
  import { getAdapterSurface } from "../adapters/surface.js";
15
15
  import { extensionProductConfigBlock } from "../adapters/extension/product-config.js";
16
+ import {
17
+ requireCurrentMobileReleaseArtifactState
18
+ } from "../adapters/mobile/release-artifact-state.js";
16
19
  import {
17
20
  importRecipeHarness,
18
21
  importRecipeProtocol,
@@ -137,10 +140,11 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
137
140
  const { createMetaMaskRunner } = await import("../runner.js");
138
141
  const recipeDocument = runtimeRecipeDocument(recipe, absoluteRecipePath);
139
142
  const suppressHudForLifecycleTiming = adapter === "mobile" && mobileRecipeRequiresUninterruptedLifecycleTiming(recipeDocument);
143
+ const suppressHudForOpaqueMobile = adapter === "mobile" && process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1";
140
144
  const runner = await createMetaMaskRunner(adapter, manifest, {
141
145
  quietStdout: runtimeOptions.stdoutIsMachineContract === true,
142
146
  suppressLibraryResolutionLogs: runtimeOptions.suppressLibraryResolutionLogs,
143
- autoHud: suppressHudForLifecycleTiming ? false : trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
147
+ autoHud: suppressHudForLifecycleTiming || suppressHudForOpaqueMobile ? false : trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
144
148
  onActionEvent: runtimeOptions.onActionEvent,
145
149
  actionSources,
146
150
  // A direct engineer invocation is the explicit trust boundary for a
@@ -216,6 +220,10 @@ function activateRecipeRuntimeEnvironment(adapter, projectRoot, runtimeOptions)
216
220
  const previousWatcherPort = process.env.WATCHER_PORT;
217
221
  const previousMetroPort = process.env.METRO_PORT;
218
222
  const previousExtensionAutolaunch = process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH;
223
+ const previousMobileOpaqueRuntime = process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME;
224
+ const previousAndroidPackageId = process.env.ANDROID_PACKAGE_ID;
225
+ const previousAdbSerial = process.env.ADB_SERIAL;
226
+ const previousAndroidSerial = process.env.ANDROID_SERIAL;
219
227
  getAdapterSurface(adapter).resolveSlotPorts(projectRoot);
220
228
  if (runtimeOptions.cdpPort) {
221
229
  process.env.CDP_PORT = runtimeOptions.cdpPort;
@@ -228,6 +236,21 @@ function activateRecipeRuntimeEnvironment(adapter, projectRoot, runtimeOptions)
228
236
  if (runtimeOptions.launchExistingDist) {
229
237
  process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH = "1";
230
238
  }
239
+ if (adapter === "mobile") {
240
+ const state = requireCurrentMobileReleaseArtifactState(projectRoot);
241
+ if (state) {
242
+ const selected = process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL;
243
+ if (selected && selected !== state.deviceSerial) {
244
+ throw new Error(
245
+ `The Mobile release artifact is installed on ${state.deviceSerial}, not ${selected}. Next: rerun runtime-launch for ${selected}.`
246
+ );
247
+ }
248
+ process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME = "1";
249
+ process.env.ANDROID_PACKAGE_ID = state.packageId;
250
+ process.env.ADB_SERIAL = state.deviceSerial;
251
+ process.env.ANDROID_SERIAL = state.deviceSerial;
252
+ }
253
+ }
231
254
  return () => {
232
255
  restoreEnv("CDP_PORT", previousCdpPort);
233
256
  restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
@@ -237,6 +260,13 @@ function activateRecipeRuntimeEnvironment(adapter, projectRoot, runtimeOptions)
237
260
  "METAMASK_RECIPE_EXTENSION_AUTOLAUNCH",
238
261
  previousExtensionAutolaunch
239
262
  );
263
+ restoreEnv(
264
+ "METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME",
265
+ previousMobileOpaqueRuntime
266
+ );
267
+ restoreEnv("ANDROID_PACKAGE_ID", previousAndroidPackageId);
268
+ restoreEnv("ADB_SERIAL", previousAdbSerial);
269
+ restoreEnv("ANDROID_SERIAL", previousAndroidSerial);
240
270
  };
241
271
  }
242
272
  function actionSourceMap(sources) {
@@ -267,12 +297,23 @@ function recipeRunEnv(adapter, runtimeOptions = {}) {
267
297
  ANDROID_DEVICE: process.env.ANDROID_DEVICE,
268
298
  ANDROID_TARGET_DEVICE_NAME: process.env.ANDROID_TARGET_DEVICE_NAME,
269
299
  ADB_SERIAL: process.env.ADB_SERIAL,
270
- ANDROID_SERIAL: process.env.ANDROID_SERIAL
300
+ ANDROID_SERIAL: process.env.ANDROID_SERIAL,
301
+ ANDROID_PACKAGE_ID: process.env.ANDROID_PACKAGE_ID,
302
+ METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME: process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME
271
303
  };
272
304
  }
273
305
  async function prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions) {
274
306
  if (adapter !== "extension" || runtimeOptions.skipExtensionRuntimePrepare === true)
275
307
  return;
308
+ const { releaseArtifactState, runtimeDistCheck } = await import("../adapters/extension/runtime-decision.js");
309
+ const artifact = releaseArtifactState(projectRoot);
310
+ const runtimeSnapshot = runtimeDistCheck(projectRoot, artifact);
311
+ if (artifact.status !== "none") {
312
+ if (artifact.status === "valid" && runtimeSnapshot.status === "fresh") return;
313
+ throw new Error(
314
+ "The loaded release artifact identity is invalid or stale. Next: rerun runtime-launch with the same artifact source."
315
+ );
316
+ }
276
317
  const { prepareExtensionRuntime } = await import("../adapters/extension/runtime.js");
277
318
  await prepareExtensionRuntime({
278
319
  projectRoot,
@@ -306,7 +347,7 @@ async function validateRecipeAdapterAware(adapter, recipe, manifest, librarySour
306
347
  );
307
348
  const findings = [
308
349
  ...withManifest.findings,
309
- ...validateMetaMaskActionInputs(recipe)
350
+ ...validateMetaMaskActionInputs(recipe, adapter)
310
351
  ];
311
352
  if (withManifest.status === "valid" && isRecord(recipe) && libraryResolution) {
312
353
  try {
@@ -336,7 +377,7 @@ async function validateRecipeAdapterAware(adapter, recipe, manifest, librarySour
336
377
  manifest,
337
378
  validationOptions
338
379
  ).findings,
339
- ...validateMetaMaskActionInputs(dependency.document)
380
+ ...validateMetaMaskActionInputs(dependency.document, adapter)
340
381
  );
341
382
  }
342
383
  } catch (error) {
@@ -782,6 +823,7 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
782
823
  return EXIT.usage;
783
824
  }
784
825
  const state = newHealState();
826
+ const mobileArtifact = adapter === "mobile" ? requireCurrentMobileReleaseArtifactState(target) : null;
785
827
  prepareOptions.onPhase?.("install");
786
828
  const ensured = await ensureOverlay(
787
829
  adapter,
@@ -847,7 +889,7 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
847
889
  restoreEnv("WATCHER_PORT", previousWatcherPort);
848
890
  }
849
891
  }
850
- if (adapter === "mobile" && prepareOptions.skipMobileSourceFreshness !== true) {
892
+ if (adapter === "mobile" && mobileArtifact === null && prepareOptions.skipMobileSourceFreshness !== true) {
851
893
  const current = await ensureMobileProofRuntime(
852
894
  target,
853
895
  heal,
@@ -994,6 +1036,22 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
994
1036
  });
995
1037
  const initial = await decide();
996
1038
  if (initial.decision === "ready") return null;
1039
+ if (initial.reasonCode === "release-artifact-identity-invalid" || initial.reasonCode === "release-artifact-runtime-missing" || initial.reasonCode === "release-artifact-runtime-stale") {
1040
+ const message = initial.reasons[0] ?? "The loaded release artifact identity is invalid.";
1041
+ const userAction2 = "rerun runtime-launch with the same artifact source";
1042
+ if (json) {
1043
+ console.log(JSON.stringify({
1044
+ schemaVersion: 1,
1045
+ status: "fail",
1046
+ recoverable: false,
1047
+ error: { code: "RELEASE_ARTIFACT_NOT_CURRENT", message, userAction: userAction2 }
1048
+ }, null, 2));
1049
+ } else {
1050
+ console.error(`\u2717 ${message}`);
1051
+ console.error(` Next: ${userAction2}`);
1052
+ }
1053
+ return EXIT.infra;
1054
+ }
997
1055
  if (initial.reasonCode === "runtime-dist-check-failed") {
998
1056
  const message = `Extension proof runtime freshness could not be checked: ${initial.reasons[0] ?? initial.decision}`;
999
1057
  const userAction2 = `mm-harness doctor --adapter extension --target ${JSON.stringify(path.resolve(target))}`;
@@ -1142,10 +1200,35 @@ const RUN_RECOVERY_CODE = {
1142
1200
  async function recoverRunInfra(adapter, target, json) {
1143
1201
  getAdapterSurface(adapter).resolveSlotPorts(target);
1144
1202
  if (adapter === "extension") {
1203
+ const { releaseArtifactState } = await import("../adapters/extension/runtime-decision.js");
1204
+ const artifact = releaseArtifactState(target);
1205
+ if (artifact.status !== "none") {
1206
+ return {
1207
+ status: 1,
1208
+ output: "Release artifact recovery cannot switch to a checkout build.\nNext: rerun runtime-launch with the same artifact source."
1209
+ };
1210
+ }
1145
1211
  const { launchExtension } = await import("./launch/extension.js");
1146
1212
  return launchExtension(target, "quick", false);
1147
1213
  }
1148
1214
  if (adapter === "mobile") {
1215
+ try {
1216
+ const artifact = requireCurrentMobileReleaseArtifactState(target);
1217
+ if (artifact) {
1218
+ const { relaunchAndroidReleaseArtifactRuntime } = await import("../adapters/mobile/release-artifact.js");
1219
+ await relaunchAndroidReleaseArtifactRuntime(artifact);
1220
+ return {
1221
+ status: 0,
1222
+ output: `Relaunched ${artifact.packageId} ${artifact.versionName} (${artifact.versionCode}) on ${artifact.deviceSerial}.`
1223
+ };
1224
+ }
1225
+ } catch (error) {
1226
+ return {
1227
+ status: 1,
1228
+ output: `Release artifact recovery could not revalidate and relaunch the pinned Android package: ${error instanceof Error ? error.message : String(error)}
1229
+ Next: rerun runtime-launch with the same artifact source.`
1230
+ };
1231
+ }
1149
1232
  const { launchMobile } = await import("./launch/mobile.js");
1150
1233
  const platform = resolveMobileRecoveryPlatform();
1151
1234
  return launchMobile(target, platform, "quick", json);
@@ -1251,6 +1334,7 @@ export {
1251
1334
  mobileRecipeRequiresUninterruptedLifecycleTiming,
1252
1335
  preflightRecipe,
1253
1336
  prepareHeal,
1337
+ prepareRuntimeIfNeeded,
1254
1338
  recoverRunInfra,
1255
1339
  resolveMetaMaskLibrarySources,
1256
1340
  resolveMobileRecoveryPlatform,
@@ -29,6 +29,7 @@ import { handleDescribeRecipe, handleListExecutables } from "./list-executables.
29
29
  import { applyDeviceTargeting } from "./device-target.js";
30
30
  import { getAdapterSurface } from "../adapters/surface.js";
31
31
  import { coreDependencyBlock } from "./core-readiness.js";
32
+ import { readMobileReleaseArtifactState } from "../adapters/mobile/release-artifact-state.js";
32
33
  import { writeRunReport } from "./run-report.js";
33
34
  import { acquireCheckoutLock } from "../checkout-lock.js";
34
35
  import { JsonStreamWriter } from "../json-stream.js";
@@ -250,7 +251,8 @@ async function handleRunInner({ positional, options }, stream) {
250
251
  }
251
252
  try {
252
253
  const prepared = await prepareHeal(adapter, target, options, machine, {
253
- onPhase: (phase, fields) => stream.phase(phase, fields)
254
+ onPhase: (phase, fields) => stream.phase(phase, fields),
255
+ skipMobileSourceFreshness: adapter === "mobile" && readMobileReleaseArtifactState(target) !== null
254
256
  });
255
257
  if (typeof prepared === "number") {
256
258
  stream.error({
@@ -1,6 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import {
5
+ acquireExtensionReleaseArtifact
6
+ } from "../adapters/extension/release-artifact.js";
7
+ import { installAndroidReleaseArtifact } from "../adapters/mobile/release-artifact.js";
8
+ import {
9
+ clearMobileReleaseArtifactState,
10
+ writeMobileReleaseArtifactState
11
+ } from "../adapters/mobile/release-artifact-state.js";
4
12
  import { recipeHarnessPath } from "../paths.js";
5
13
  import {
6
14
  adapterOption,
@@ -13,9 +21,56 @@ import {
13
21
  usageError
14
22
  } from "./parse-args.js";
15
23
  async function handleRuntimeLaunch({ options }) {
24
+ if (optionFlag(options, "help")) {
25
+ console.log(`mm-harness runtime-launch --adapter <extension|mobile> --target <checkout> [flags]
26
+
27
+ --artifact-file <path> Artifact downloaded by the validation skill
28
+ --artifact-version <X.Y.Z> Expected manifest or Android version name
29
+ --artifact-sha256 <digest> Trusted SHA-256 from the acquisition step
30
+ --artifact-build-id <id> Android version code
31
+ --artifact-package-id <id> Android application ID
32
+ --artifact-cache <dir> Artifact cache root
33
+ --platform <android> Mobile artifact platform (iOS is unsupported)
34
+ --device <serial> Explicit Android device serial
35
+ --cdp-port <port> Extension CDP port
36
+ --chrome-user-data-dir Isolated Chrome profile path
37
+ --artifacts-dir <dir> Validation evidence directory
38
+ --json Machine-readable terminal report
39
+
40
+ The harness verifies and runs a local artifact but never downloads one. Extension
41
+ launch resets an isolated profile. Android install requires an explicit serial.`);
42
+ return 0;
43
+ }
16
44
  const adapter = adapterOption(options);
17
45
  const target = targetPath(options);
46
+ const artifactFile = optionString(options, "artifactFile");
47
+ const artifactVersion = optionString(options, "artifactVersion");
48
+ const artifactSha256 = optionString(options, "artifactSha256");
49
+ const artifactBuildId = optionString(options, "artifactBuildId");
50
+ const artifactPackageId = optionString(options, "artifactPackageId");
51
+ const artifactCache = optionString(options, "artifactCache");
52
+ const artifactRequested = Boolean(
53
+ artifactFile || artifactVersion || artifactSha256 || artifactBuildId || artifactPackageId || artifactCache
54
+ );
55
+ if (artifactRequested && (!artifactFile || !artifactVersion || !artifactSha256)) {
56
+ throw usageError("artifact launch requires --artifact-file, --artifact-version, and --artifact-sha256 together.");
57
+ }
58
+ if (adapter === "mobile") {
59
+ return handleAndroidArtifactLaunch({
60
+ options,
61
+ target,
62
+ artifactFile,
63
+ artifactVersion,
64
+ artifactSha256,
65
+ artifactBuildId,
66
+ artifactPackageId,
67
+ artifactCache
68
+ });
69
+ }
18
70
  if (adapter !== "extension") throw new Error("runtime-launch currently applies to the extension adapter.");
71
+ if (artifactBuildId || artifactPackageId || optionString(options, "platform") || optionString(options, "device")) {
72
+ throw usageError("Extension runtime-launch does not accept Android artifact options.");
73
+ }
19
74
  const cdpPort = parsePort(
20
75
  optionString(options, "cdpPort") ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT,
21
76
  "runtime-launch requires --cdp-port <port>."
@@ -29,7 +84,19 @@ async function handleRuntimeLaunch({ options }) {
29
84
  if (startWatch && buildLavaMoat) {
30
85
  throw usageError("runtime-launch accepts only one of --start-watch or --build-lavamoat.");
31
86
  }
32
- const mode = buildLavaMoat ? "build-lavamoat" : startWatch ? "start-watch" : "existing-dist";
87
+ if (artifactRequested && (startWatch || buildLavaMoat || optionString(options, "remoteFlag"))) {
88
+ throw usageError("artifact launch cannot be combined with build, watch, or remote-flag options.");
89
+ }
90
+ let artifact;
91
+ if (artifactRequested) {
92
+ artifact = await acquireExtensionReleaseArtifact({
93
+ file: artifactFile,
94
+ expectedVersion: artifactVersion,
95
+ expectedSha256: artifactSha256,
96
+ cacheRoot: artifactCache
97
+ });
98
+ }
99
+ const mode = artifact ? "release-artifact" : buildLavaMoat ? "build-lavamoat" : startWatch ? "start-watch" : "existing-dist";
33
100
  const liveScript = recipeHarnessPath(target, "extension", "scripts", "live.sh");
34
101
  const command = [
35
102
  "bash",
@@ -42,6 +109,15 @@ async function handleRuntimeLaunch({ options }) {
42
109
  "--artifacts-dir",
43
110
  artifactsDir
44
111
  ];
112
+ if (artifact) {
113
+ command.push(
114
+ "--external-artifact",
115
+ "--dist-dir",
116
+ artifact.extensionDir,
117
+ "--artifact-provenance",
118
+ artifact.provenancePath
119
+ );
120
+ }
45
121
  if (chromeUserDataDir) command.push("--chrome-user-data-dir", chromeUserDataDir);
46
122
  const remoteFlag = optionString(options, "remoteFlag");
47
123
  if (remoteFlag) command.push("--remote-flag", remoteFlag);
@@ -52,7 +128,7 @@ async function handleRuntimeLaunch({ options }) {
52
128
  cdpPort,
53
129
  artifactsDir,
54
130
  reason: "harness_live_script_missing",
55
- fix: `Run mm-harness install --adapter extension --target ${shellQuote(target)}, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, mode)}`,
131
+ fix: `Run mm-harness install --adapter extension --target ${shellQuote(target)}, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, mode, artifactInvocation(options))}`,
56
132
  command
57
133
  });
58
134
  printRuntimeLaunchReport(report2, optionFlag(options, "json"));
@@ -61,7 +137,7 @@ async function handleRuntimeLaunch({ options }) {
61
137
  fs.mkdirSync(artifactsDir, { recursive: true });
62
138
  const jsonMode = optionFlag(options, "json");
63
139
  if (!jsonMode) {
64
- const detail = mode === "build-lavamoat" ? "production-like LavaMoat build" : mode === "start-watch" ? "test webpack watcher" : "existing dist";
140
+ const detail = mode === "release-artifact" ? `release artifact ${artifact?.manifestVersion}` : mode === "build-lavamoat" ? "production-like LavaMoat build" : mode === "start-watch" ? "test webpack watcher" : "existing dist";
65
141
  console.error(`recipe: runtime-launch starting (cdp=${cdpPort}, ${detail})`);
66
142
  } else {
67
143
  console.error(JSON.stringify({
@@ -69,7 +145,7 @@ async function handleRuntimeLaunch({ options }) {
69
145
  type: "progress",
70
146
  command: "rebuild",
71
147
  phase: "runtime-launch",
72
- message: mode === "build-lavamoat" ? "production-like LavaMoat build starting" : mode === "start-watch" ? "test webpack watcher starting" : "launching existing dist",
148
+ message: mode === "release-artifact" ? `release artifact ${artifact?.manifestVersion} launch starting` : mode === "build-lavamoat" ? "production-like LavaMoat build starting" : mode === "start-watch" ? "test webpack watcher starting" : "launching existing dist",
73
149
  cdpPort
74
150
  }));
75
151
  }
@@ -90,7 +166,8 @@ async function handleRuntimeLaunch({ options }) {
90
166
  summaryPath,
91
167
  reason: "runtime_ready",
92
168
  fix: "",
93
- command
169
+ command,
170
+ artifact
94
171
  });
95
172
  printRuntimeLaunchReport(report2, optionFlag(options, "json"));
96
173
  return 0;
@@ -109,20 +186,111 @@ async function handleRuntimeLaunch({ options }) {
109
186
  launchLogPath: fs.existsSync(launchLogPath) ? launchLogPath : void 0,
110
187
  verifySummaryPath: fs.existsSync(verifySummaryPath) ? verifySummaryPath : void 0,
111
188
  reason,
112
- fix: `Read ${failurePath}, fix the first error, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, mode)}`,
189
+ fix: `Read ${failurePath}, fix the first error, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, mode, artifactInvocation(options))}`,
113
190
  command,
191
+ artifact,
114
192
  exitCode: result.status ?? 1
115
193
  });
116
194
  printRuntimeLaunchReport(report, optionFlag(options, "json"));
117
195
  return 1;
118
196
  }
197
+ async function handleAndroidArtifactLaunch(input) {
198
+ const {
199
+ options,
200
+ target,
201
+ artifactFile,
202
+ artifactVersion,
203
+ artifactSha256,
204
+ artifactBuildId,
205
+ artifactPackageId,
206
+ artifactCache
207
+ } = input;
208
+ const platform = optionString(options, "platform") ?? "android";
209
+ if (platform === "ios") {
210
+ throw usageError("Downloaded iOS artifact launch is unsupported. Next: use --platform android with a local APK.");
211
+ }
212
+ if (platform !== "android") throw usageError("--platform must be android for Mobile artifact launch.");
213
+ if (!artifactFile || !artifactVersion || !artifactSha256 || !artifactBuildId || !artifactPackageId) {
214
+ throw usageError(
215
+ "Android artifact launch requires --artifact-file, --artifact-version, --artifact-build-id, --artifact-package-id, and --artifact-sha256 together."
216
+ );
217
+ }
218
+ const deviceSerial = optionString(options, "device");
219
+ if (!deviceSerial) {
220
+ throw usageError("Android artifact launch requires --device <serial>. Next: run adb devices -l and rerun with one serial.");
221
+ }
222
+ if (artifactCache || optionString(options, "cdpPort") || optionString(options, "chromeUserDataDir") || optionString(options, "remoteFlag") || optionFlag(options, "startWatch") || optionFlag(options, "buildLavamoat")) {
223
+ throw usageError("Android artifact launch cannot be combined with Extension build, profile, cache, or CDP options.");
224
+ }
225
+ const artifactsDir = path.resolve(
226
+ optionString(options, "artifactsDir") ?? recipeHarnessPath(target, "mobile", "runtime-launch", (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/gu, ""))
227
+ );
228
+ fs.mkdirSync(artifactsDir, { recursive: true });
229
+ const summaryPath = path.join(artifactsDir, "summary.json");
230
+ try {
231
+ clearMobileReleaseArtifactState(target);
232
+ const artifact = await installAndroidReleaseArtifact({
233
+ file: artifactFile,
234
+ expectedVersion: artifactVersion,
235
+ expectedBuildId: artifactBuildId,
236
+ expectedSha256: artifactSha256,
237
+ packageId: artifactPackageId,
238
+ deviceSerial
239
+ });
240
+ const runtimeState = writeMobileReleaseArtifactState(target, artifact);
241
+ const report = runtimeLaunchReport("pass", {
242
+ adapter: "mobile",
243
+ platform,
244
+ target,
245
+ artifactsDir,
246
+ summaryPath,
247
+ reason: "runtime_ready",
248
+ fix: "",
249
+ artifact,
250
+ runtimeState
251
+ });
252
+ fs.writeFileSync(summaryPath, `${JSON.stringify(report, null, 2)}
253
+ `, { encoding: "utf8", mode: 384 });
254
+ printRuntimeLaunchReport(report, optionFlag(options, "json"));
255
+ return 0;
256
+ } catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error);
258
+ const report = runtimeLaunchReport("fail", {
259
+ adapter: "mobile",
260
+ platform,
261
+ target,
262
+ artifactsDir,
263
+ summaryPath,
264
+ reason: "android_artifact_launch_failed",
265
+ error: message,
266
+ fix: `Fix the first error, then rerun with --device ${shellQuote(deviceSerial)} and the same verified artifact identity.`
267
+ });
268
+ fs.writeFileSync(summaryPath, `${JSON.stringify(report, null, 2)}
269
+ `, { encoding: "utf8", mode: 384 });
270
+ printRuntimeLaunchReport(report, optionFlag(options, "json"));
271
+ return 1;
272
+ }
273
+ }
119
274
  function runtimeLaunchLiveFlag(mode) {
120
- return mode === "existing-dist" ? "--launch-existing-dist" : `--${mode}`;
275
+ return mode === "existing-dist" || mode === "release-artifact" ? "--launch-existing-dist" : `--${mode}`;
121
276
  }
122
- function runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, mode = "existing-dist") {
277
+ function runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, mode = "existing-dist", artifact) {
123
278
  const base = `mm-harness runtime-launch --adapter extension --target ${shellQuote(target)} --cdp-port ${cdpPort}`;
124
- const withMode = mode === "existing-dist" ? base : `${base} --${mode}`;
125
- return chromeUserDataDir ? `${withMode} --chrome-user-data-dir ${shellQuote(chromeUserDataDir)}` : withMode;
279
+ let command = mode === "existing-dist" || mode === "release-artifact" ? base : `${base} --${mode}`;
280
+ if (artifact?.file) command += ` --artifact-file ${shellQuote(artifact.file)}`;
281
+ if (artifact?.version) command += ` --artifact-version ${shellQuote(artifact.version)}`;
282
+ if (artifact?.sha256) command += ` --artifact-sha256 ${shellQuote(artifact.sha256)}`;
283
+ if (artifact?.cache) command += ` --artifact-cache ${shellQuote(artifact.cache)}`;
284
+ return chromeUserDataDir ? `${command} --chrome-user-data-dir ${shellQuote(chromeUserDataDir)}` : command;
285
+ }
286
+ function artifactInvocation(options) {
287
+ const artifact = {
288
+ file: optionString(options, "artifactFile"),
289
+ version: optionString(options, "artifactVersion"),
290
+ sha256: optionString(options, "artifactSha256"),
291
+ cache: optionString(options, "artifactCache")
292
+ };
293
+ return Object.values(artifact).some(Boolean) ? artifact : void 0;
126
294
  }
127
295
  function runtimeLaunchReport(status, fields) {
128
296
  return {
@@ -86,7 +86,7 @@ function checkHealBounds(target, output, state) {
86
86
  originalError
87
87
  };
88
88
  }
89
- const failureClass = classifyFailure(output);
89
+ const failureClass = classifyFailure(conciseFailureForHuman(output));
90
90
  if (failureClass === "screenshot-protected") {
91
91
  return {
92
92
  code: "SCREENSHOT_PROTECTED",
@@ -158,7 +158,9 @@ async function prepareLiveAdapterScript({
158
158
  target: `node${process.versions.node.split(".")[0]}`,
159
159
  legalComments: "none",
160
160
  sourcemap: false,
161
- banner: { js: `process.argv[1] = ${JSON.stringify(entryReal)};` },
161
+ banner: {
162
+ js: `import { createRequire as __mmCreateRequire } from 'node:module';const require = __mmCreateRequire(import.meta.url);process.argv[1] = ${JSON.stringify(entryReal)};`
163
+ },
162
164
  plugins: [bindImportMetaUrls()],
163
165
  logLevel: "silent"
164
166
  });
@@ -1,7 +1,7 @@
1
1
  function isRecord(value) {
2
2
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3
3
  }
4
- function validateMetaMaskActionInputs(recipe) {
4
+ function validateMetaMaskActionInputs(recipe, adapter) {
5
5
  if (!isRecord(recipe) || !isRecord(recipe.workflow)) return [];
6
6
  const nodes = isRecord(recipe.workflow.nodes) ? recipe.workflow.nodes : void 0;
7
7
  if (!nodes) return [];
@@ -9,6 +9,52 @@ function validateMetaMaskActionInputs(recipe) {
9
9
  for (const [nodeId, node] of Object.entries(nodes)) {
10
10
  if (!isRecord(node)) continue;
11
11
  const action = node.action;
12
+ if (action === "metamask.perps.read_snapshot" && adapter === "extension") {
13
+ if (node.market !== void 0 && node.markets !== void 0) {
14
+ findings.push({
15
+ severity: "error",
16
+ code: "recipe.invalid_param",
17
+ path: `workflow.nodes.${nodeId}.markets`,
18
+ message: `${action} accepts market or markets, not both`
19
+ });
20
+ }
21
+ if (isRecord(node.history)) {
22
+ const start = node.history.start_time_ms;
23
+ const end = node.history.end_time_ms;
24
+ if (typeof start === "number" && typeof end === "number" && start > end) {
25
+ findings.push({
26
+ severity: "error",
27
+ code: "recipe.invalid_param",
28
+ path: `workflow.nodes.${nodeId}.history.start_time_ms`,
29
+ message: `${action} history.start_time_ms must not exceed history.end_time_ms`
30
+ });
31
+ }
32
+ if (typeof node.history.limit === "number" && node.history.limit > 500) {
33
+ findings.push({
34
+ severity: "error",
35
+ code: "recipe.invalid_param",
36
+ path: `workflow.nodes.${nodeId}.history.limit`,
37
+ message: `${action} history.limit must not exceed 500`
38
+ });
39
+ }
40
+ }
41
+ if (typeof node.max_items_per_section === "number" && node.max_items_per_section > 500) {
42
+ findings.push({
43
+ severity: "error",
44
+ code: "recipe.invalid_param",
45
+ path: `workflow.nodes.${nodeId}.max_items_per_section`,
46
+ message: `${action} max_items_per_section must not exceed 500`
47
+ });
48
+ }
49
+ if (typeof node.timeout_ms === "number" && node.timeout_ms > 6e4) {
50
+ findings.push({
51
+ severity: "error",
52
+ code: "recipe.invalid_param",
53
+ path: `workflow.nodes.${nodeId}.timeout_ms`,
54
+ message: `${action} timeout_ms must not exceed 60000`
55
+ });
56
+ }
57
+ }
12
58
  const state = typeof node.state === "string" ? node.state.toLowerCase() : void 0;
13
59
  const createsOrder = action === "metamask.perps.place_order" || (action === "metamask.perps.ensure_positions" || action === "metamask.perps.ensure_orders") && (state === "open" || state === "present");
14
60
  if (!createsOrder) continue;
@@ -199,6 +199,7 @@ Example:
199
199
  --list List actions accepted by call for this adapter; no <action> needed
200
200
  --arg k=v Action field value (repeatable; equivalent to key=value shorthand)
201
201
  --device <udid|serial|name> Mobile only: target this device (sets IOS_SIMULATOR / ADB_SERIAL and updates agentic-runtime.json). Without it, >1 connected mobile device fails fast and lists them. Do not shut down other devices to force selection.
202
+ --cdp-port <port> Extension only: bind the action to this live browser runtime.
202
203
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
203
204
  --target <path> Checkout path (default: cwd)
204
205
  --artifacts-dir <dir> Where to write evidence (default: temp dir)
@@ -452,7 +453,7 @@ Example:
452
453
  Extension quick launch REUSES a healthy live runtime (reload in place, no rebuild);
453
454
  --build is the escape hatch that always clean-builds + relaunches a fresh runtime.
454
455
 
455
- --build Full clean build + relaunch (escape hatch; skips extension reuse)
456
+ --build Full production-like build + relaunch (escape hatch; skips extension reuse)
456
457
  --clear-metro Mobile only: clear Metro's transform cache before launch
457
458
  --verify Launch then poll CDP/bridge until ready (absorbs the old \`live\`)
458
459
  --runway Post-launch runway check (mobile only; teaching error elsewhere)
@@ -537,11 +538,41 @@ Example:
537
538
  mm-harness update
538
539
  mm-harness update --check --json`
539
540
  },
541
+ {
542
+ name: "runtime-launch",
543
+ summary: "Launch and verify an Extension runtime or local Android release artifact.",
544
+ example: "mm-harness runtime-launch --adapter extension --target <checkout> --cdp-port 9222 --artifact-file <zip> --artifact-version <X.Y.Z> --artifact-sha256 <digest>",
545
+ helpText: `mm-harness runtime-launch --adapter <extension|mobile> --target <checkout> [flags]
546
+
547
+ Verify and run an artifact already downloaded by the validation skill. The
548
+ harness performs no network acquisition. Extension ZIPs run through the
549
+ packaged browser/CDP path; Android APKs install to one explicitly named device.
550
+
551
+ --artifact-file <path> Artifact downloaded by the validation skill
552
+ --artifact-version <X.Y.Z> Expected manifest or Android version name
553
+ --artifact-build-id <id> Android version code
554
+ --artifact-package-id <id> Android application ID
555
+ --artifact-sha256 <digest> Trusted SHA-256 from the acquisition step
556
+ --artifact-cache <dir> Extension artifact cache root
557
+ --platform <android> Mobile platform (downloaded iOS is unsupported)
558
+ --device <serial> Explicit Android device serial
559
+ --cdp-port <port> Extension CDP port
560
+ --chrome-user-data-dir <dir> Isolated Chrome profile path
561
+ --artifacts-dir <dir> Validation evidence directory
562
+ --start-watch Build through the checkout watcher
563
+ --build-lavamoat Build through the production-like path
564
+ --remote-flag <KEY=VARIANT> Pin checkout snapshot flags (not allowed for artifacts)
565
+ --json Machine-readable terminal report
566
+
567
+ Example:
568
+ mm-harness runtime-launch --adapter extension --target /path/to/checkout --cdp-port 9222 --artifact-file /path/to/metamask-chrome-X.Y.Z.zip --artifact-version X.Y.Z --artifact-sha256 <digest>
569
+ mm-harness runtime-launch --adapter mobile --platform android --target /path/to/checkout --device <serial> --artifact-file /path/to/app.apk --artifact-version X.Y.Z --artifact-build-id <code> --artifact-package-id io.metamask --artifact-sha256 <digest>`
570
+ },
540
571
  {
541
572
  name: "fixtures",
542
- summary: "Manage the canonical wallet fixture (wallet DATA only) \u2014 init / sync / set / generate / finalize.",
573
+ summary: "Manage the canonical wallet fixture (wallet DATA only) \u2014 init / sync / set / reset / generate / finalize.",
543
574
  example: "mm-harness fixtures set",
544
- helpText: `mm-harness fixtures [<init|sync|set|generate|finalize>] [flags]
575
+ helpText: `mm-harness fixtures [<init|sync|set|reset|generate|finalize>] [flags]
545
576
 
546
577
  Manage the ONE canonical wallet fixture per checkout \u2014 wallet DATA only.
547
578
  With no action, show the current fixture status and safe next command.
@@ -550,6 +581,8 @@ Example:
550
581
  sync Refresh the wallet fixture files on the target.
551
582
  set Apply the canonical fixture (SRP/password/accounts); the password is
552
583
  read FROM the fixture, never typed.
584
+ reset Delete the current Mobile/Extension wallet, then apply and validate
585
+ the canonical fixture from a clean state.
553
586
  generate Render the extension fixture-state.json from a wallet fixture, for
554
587
  pre-launch profile prefill (extension-only). Requires --fixture and --out.
555
588
  finalize Seed account labels/selection into the LIVE extension over CDP and
@@ -578,6 +611,7 @@ Example:
578
611
  mm-harness fixtures init --dev
579
612
  mm-harness fixtures sync
580
613
  mm-harness fixtures set
614
+ mm-harness fixtures reset
581
615
  mm-harness fixtures generate --fixture wallet-fixture.json --out fixture-state.json
582
616
  mm-harness fixtures finalize --fixture wallet-fixture.json --state fixture-state.json --cdp-port 6661 --extension-dir dist/chrome`
583
617
  }
@@ -742,7 +776,6 @@ const HIDDEN = [
742
776
  "completion-candidates",
743
777
  "runtime-health",
744
778
  "runtime-decision",
745
- "runtime-launch",
746
779
  "resolve-extension",
747
780
  "ensure-ready",
748
781
  "self-test"
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
5
5
  "ui.locators",
6
6
  "metamask.wallet.fixture_status",
7
+ "metamask.wallet.validate_import",
7
8
  "metamask.wallet.list_accounts",
8
9
  "metamask.wallet.read_state",
9
10
  "metamask.perps.read_positions",
@@ -19,6 +20,8 @@ const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
19
20
  ]);
20
21
  const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
21
22
  "metamask.wallet.setup",
23
+ "metamask.wallet.import",
24
+ "metamask.wallet.reset",
22
25
  "metamask.wallet.ensure_unlocked",
23
26
  "metamask.wallet.select_account",
24
27
  "metamask.deeplink.open",