@deeeed/metamask-harness 0.17.5 → 0.19.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 (169) hide show
  1. package/CHANGELOG.md +1059 -0
  2. package/README.md +91 -140
  3. package/adapters/core/inject.sh +1 -6
  4. package/adapters/extension/console-tail.mjs +55 -24
  5. package/adapters/extension/ensure-browser.sh +6 -1
  6. package/adapters/extension/inject.mjs +7 -13
  7. package/adapters/extension/launch-browser.cjs +7 -1
  8. package/adapters/extension/launch-webpack.cjs +83 -0
  9. package/adapters/extension/launch.sh +8 -11
  10. package/adapters/extension/lib/chrome-args.cjs +8 -1
  11. package/adapters/extension/lib/macos-focus.cjs +32 -0
  12. package/adapters/extension/live.sh +12 -21
  13. package/adapters/extension/reattach.sh +1 -0
  14. package/adapters/extension/seed-fixture.sh +4 -12
  15. package/adapters/extension/sidepanel-toggle.sh +4 -1
  16. package/adapters/extension/stamp-runtime-title.cjs +58 -0
  17. package/adapters/extension/start-watch.sh +17 -4
  18. package/adapters/extension/stop-viewers.sh +1 -1
  19. package/adapters/extension/sync-webpack-dist.cjs +107 -0
  20. package/adapters/extension/verify.sh +2 -2
  21. package/adapters/manifest.json +53 -37
  22. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  23. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +1 -1
  24. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +3 -1
  25. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +2 -3
  26. package/adapters/mobile/cleanup.sh +16 -176
  27. package/adapters/mobile/inject.sh +91 -671
  28. package/adapters/mobile/launch-console-forwarder.cjs +39 -0
  29. package/adapters/mobile/open-device.sh +45 -7
  30. package/adapters/mobile/start-console-forwarder.sh +70 -0
  31. package/adapters/mobile/start-metro.sh +0 -41
  32. package/adapters/mobile/verify.sh +34 -83
  33. package/adapters/mobile/wait-for-bridge.sh +22 -8
  34. package/adapters/shared/harness-source-fingerprint.mjs +14 -12
  35. package/adapters/shared/resolve-slot-ports-core.mjs +17 -6
  36. package/adapters/shared/resolve-slot-ports.sh +17 -5
  37. package/adapters/shared/update-check-worker.mjs +43 -0
  38. package/bin/mm-harness +21 -2
  39. package/dist/adapters/core/surface.js +4 -1
  40. package/dist/adapters/extension/console-capture.js +95 -0
  41. package/dist/adapters/extension/product-config.js +110 -0
  42. package/dist/adapters/extension/runtime-decision.js +20 -2
  43. package/dist/adapters/extension/surface.js +22 -3
  44. package/dist/adapters/harness-freshness.js +49 -0
  45. package/dist/adapters/mobile/perps-env.js +43 -3
  46. package/dist/adapters/mobile/prepare.js +39 -7
  47. package/dist/adapters/mobile/surface.js +5 -2
  48. package/dist/adapters/resolve-slot-ports.js +2 -2
  49. package/dist/adapters/slot-ports.js +13 -16
  50. package/dist/adapters.js +56 -16
  51. package/dist/checkout-lock.js +27 -2
  52. package/dist/cli-color.js +19 -0
  53. package/dist/cli-commands.js +1 -1
  54. package/dist/cli.js +7 -18
  55. package/dist/command-contract.js +462 -0
  56. package/dist/command-journal.js +263 -0
  57. package/dist/commands/call.js +252 -57
  58. package/dist/commands/check.js +10 -4
  59. package/dist/commands/completion-candidates.js +17 -22
  60. package/dist/commands/device-target.js +27 -12
  61. package/dist/commands/doctor.js +106 -25
  62. package/dist/commands/fixtures.js +142 -35
  63. package/dist/commands/last.js +60 -0
  64. package/dist/commands/launch/extension.js +40 -17
  65. package/dist/commands/launch/index.js +207 -63
  66. package/dist/commands/launch/mobile.js +2 -0
  67. package/dist/commands/list-executables.js +187 -29
  68. package/dist/commands/logs.js +8 -6
  69. package/dist/commands/manifest.js +286 -35
  70. package/dist/commands/parse-args.js +68 -4
  71. package/dist/commands/provision.js +10 -4
  72. package/dist/commands/run-engine.js +772 -264
  73. package/dist/commands/run-report.js +12 -3
  74. package/dist/commands/run.js +378 -85
  75. package/dist/commands/shared.js +76 -4
  76. package/dist/commands/status-probe.js +4 -1
  77. package/dist/commands/status.js +2 -1
  78. package/dist/commands/stop.js +7 -2
  79. package/dist/commands/update.js +16 -28
  80. package/dist/completions-cache.js +1 -1
  81. package/dist/doctor.js +57 -10
  82. package/dist/harness.js +47 -137
  83. package/dist/heal-bounds.js +7 -2
  84. package/dist/json-stream.js +57 -0
  85. package/dist/live-adapter-contract.js +138 -32
  86. package/dist/manifest.js +161 -1
  87. package/dist/mm-harness-cli.js +146 -73
  88. package/dist/paths.js +2 -5
  89. package/dist/recipe-security.js +178 -0
  90. package/dist/run-diagnostics.js +261 -0
  91. package/dist/run-recording.js +1 -1
  92. package/dist/runner.js +140 -9
  93. package/docs/CONTRIBUTING.md +136 -0
  94. package/docs/QA.md +184 -0
  95. package/docs/RECIPES.md +122 -0
  96. package/docs/SECURITY.md +88 -0
  97. package/library/README.md +7 -3
  98. package/library/actions/core/perps/_controller.mjs +43 -56
  99. package/library/actions/core/perps/assert_orders.mjs +6 -7
  100. package/library/actions/core/perps/assert_positions.mjs +6 -7
  101. package/library/actions/core/perps/close_orders.mjs +2 -0
  102. package/library/actions/core/perps/close_positions.mjs +2 -0
  103. package/library/actions/core/perps/ensure_orders.mjs +4 -2
  104. package/library/actions/core/perps/ensure_positions.mjs +4 -2
  105. package/library/actions/core/perps/place_order.mjs +7 -3
  106. package/library/actions/core/perps/read_account.mjs +2 -2
  107. package/library/actions/core/perps/read_orders.mjs +2 -1
  108. package/library/actions/core/perps/read_positions.mjs +2 -1
  109. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  110. package/library/actions/extension/perps/assert_orders.mjs +2 -1
  111. package/library/actions/extension/perps/assert_positions.mjs +2 -1
  112. package/library/actions/extension/perps/perps.mjs +43 -14
  113. package/library/actions/extension/platform/cdp.mjs +1 -0
  114. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  115. package/library/actions/mobile/perps/assert_orders.mjs +2 -1
  116. package/library/actions/mobile/perps/assert_positions.mjs +2 -1
  117. package/library/actions/mobile/perps/perps.mjs +40 -12
  118. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  119. package/library/library.json +1 -1
  120. package/library/manifests/core.action-manifest.json +1214 -396
  121. package/library/manifests/extension.action-manifest.json +1635 -728
  122. package/library/manifests/mobile.action-manifest.json +1740 -727
  123. package/library/recipes/app/lifecycle.android-smoke.mobile.recipe.json +63 -81
  124. package/library/recipes/perps/clean-market-testnet.core.recipe.json +44 -0
  125. package/library/recipes/perps/clean-market-testnet.recipe.json +49 -0
  126. package/library/recipes/perps/lifecycle.recipe.json +136 -180
  127. package/library/recipes/perps/order-lifecycle.core.recipe.json +71 -67
  128. package/library/recipes/perps/performance.background-resume.mobile.recipe.json +51 -67
  129. package/library/recipes/perps/performance.cold-start.mobile.recipe.json +51 -67
  130. package/library/recipes/perps/performance.mobile.recipe.json +37 -51
  131. package/library/recipes/perps/performance.warm-start.mobile.recipe.json +44 -59
  132. package/library/recipes/perps/read-markets.core.recipe.json +29 -31
  133. package/library/recipes/perps/smoke.core.recipe.json +29 -32
  134. package/library/recipes/perps/smoke.extension.recipe.json +41 -44
  135. package/library/recipes/perps/smoke.mobile.recipe.json +42 -44
  136. package/library/recipes/perps/trading-lifecycle.core.recipe.json +69 -65
  137. package/library/recipes/runner/action-validation.extension.recipe.json +312 -398
  138. package/library/recipes/runner/action-validation.mobile.recipe.json +316 -402
  139. package/library/recipes/runner/smoke.core.recipe.json +25 -0
  140. package/library/recipes/runner/smoke.extension.recipe.json +23 -24
  141. package/library/recipes/runner/smoke.mobile.recipe.json +23 -24
  142. package/library/recipes/wallet/smoke.extension.recipe.json +40 -0
  143. package/library/recipes/wallet/smoke.mobile.recipe.json +40 -0
  144. package/package.json +12 -5
  145. package/scripts/completions.sh +8 -11
  146. package/scripts/validate-human-outcomes.mjs +169 -0
  147. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -185
  148. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -1662
  149. package/adapters/mobile/overlay/compat/README.md +0 -28
  150. package/adapters/mobile/overlay/compat/rn81-message-event-source.patch +0 -42
  151. package/adapters/shared/resolve-farmslot-ports-core.mjs +0 -3
  152. package/adapters/shared/resolve-farmslot-ports.mjs +0 -5
  153. package/adapters/shared/resolve-farmslot-ports.sh +0 -7
  154. package/dist/adapters/extension/harness-freshness.js +0 -39
  155. package/dist/commands/flows.js +0 -62
  156. package/docs/ADAPTER-SURFACE.md +0 -119
  157. package/docs/CHEATSHEET.md +0 -61
  158. package/docs/CLI-SPEC.md +0 -1098
  159. package/docs/CODE-MAP.md +0 -62
  160. package/docs/DEBUG-HANDOVER.md +0 -36
  161. package/docs/MENTAL-MODEL.md +0 -295
  162. package/docs/UX-PRINCIPLES.md +0 -64
  163. package/docs/architecture.md +0 -398
  164. package/docs/live-adapter-contract.md +0 -188
  165. package/docs/package-boundaries.md +0 -47
  166. package/docs/perps-flow-catalog.md +0 -235
  167. package/docs/recipe-libraries.md +0 -301
  168. package/docs/runtime-file-conventions.md +0 -36
  169. package/library/flows/perps.flows.json +0 -64
@@ -7,90 +7,220 @@ import {
7
7
  newHealState,
8
8
  parseHeal,
9
9
  recipeRunning,
10
- checkHealBounds
10
+ checkHealBounds,
11
+ conciseFailureForHuman
11
12
  } from "../heal-bounds.js";
12
- import { loadActionManifest, validateManifest } from "../manifest.js";
13
+ import { resolveActionManifest, validateManifest } from "../manifest.js";
13
14
  import { getAdapterSurface } from "../adapters/surface.js";
15
+ import { extensionProductConfigBlock } from "../adapters/extension/product-config.js";
14
16
  import {
15
17
  importRecipeHarness,
16
18
  importRecipeProtocol,
17
- recipePath,
18
19
  runnerDir
19
20
  } from "../paths.js";
20
21
  import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
21
- import { listRecipeFiles } from "../recipe-files.js";
22
22
  import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
23
+ import {
24
+ beginRunDiagnostics,
25
+ finishRunDiagnostics
26
+ } from "../run-diagnostics.js";
23
27
  import { EXIT, spawnScriptStreaming } from "./shared.js";
24
28
  import {
25
29
  actionManifestPathOption,
26
30
  optionString,
31
+ optionStrings,
27
32
  isRecord,
28
33
  shellQuoteArg,
34
+ targetPath,
29
35
  usageError
30
36
  } from "./parse-args.js";
31
- async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}) {
32
- const previousCdpPort = process.env.CDP_PORT;
33
- const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
34
- const previousWatcherPort = process.env.WATCHER_PORT;
35
- const previousMetroPort = process.env.METRO_PORT;
36
- const previousExtensionAutolaunch = process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH;
37
- getAdapterSurface(adapter).resolveSlotPorts(projectRoot);
38
- if (runtimeOptions.cdpPort) {
39
- process.env.CDP_PORT = runtimeOptions.cdpPort;
40
- process.env.RECIPE_CDP_PORT = runtimeOptions.cdpPort;
41
- }
42
- if (runtimeOptions.watcherPort) {
43
- process.env.WATCHER_PORT = runtimeOptions.watcherPort;
44
- process.env.METRO_PORT = runtimeOptions.watcherPort;
45
- }
46
- if (runtimeOptions.launchExistingDist) {
47
- process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH = "1";
48
- }
37
+ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}, preflightedExecution) {
38
+ const restoreRuntimeEnvironment = activateRecipeRuntimeEnvironment(
39
+ adapter,
40
+ projectRoot,
41
+ runtimeOptions
42
+ );
49
43
  try {
44
+ const execution = preflightedExecution ?? await resolveRecipeExecution(
45
+ adapter,
46
+ recipe,
47
+ artifactsDir,
48
+ projectRoot,
49
+ actionManifestPath,
50
+ runtimeOptions
51
+ );
52
+ const {
53
+ runner,
54
+ runRequest,
55
+ absoluteArtifactsDir,
56
+ useFramedExtensionRecording
57
+ } = execution;
58
+ await runner.preflight(runRequest);
50
59
  await prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions);
51
- const absoluteArtifactsDir = path.resolve(artifactsDir);
52
- const recordVideo = runtimeOptions.recordVideo ?? false;
53
- const useFramedExtensionRecording = adapter === "extension" && recordVideo === "full-run" && captureHelperSupportsRecordSessionSnapshots(projectRoot);
60
+ const diagnosticBaseline = await beginRunDiagnostics(adapter, projectRoot);
54
61
  const recording = useFramedExtensionRecording ? await startRecipeRecording(adapter, projectRoot, absoluteArtifactsDir, {
55
62
  record: true,
56
63
  cdpPort: runtimeOptions.cdpPort
57
64
  }) : void 0;
58
65
  try {
59
- const manifest = loadActionManifest(adapter, actionManifestPath);
60
- await validateManifest(manifest);
61
- const { createMetaMaskRunner } = await import("../runner.js");
62
- const runner = await createMetaMaskRunner(adapter, manifest, {
63
- quietStdout: runtimeOptions.stdoutIsMachineContract === true
64
- });
65
- const runRequest = {
66
- recipePath: path.resolve(recipe),
67
- artifactsDir: absoluteArtifactsDir,
68
- projectRoot,
69
- env: recipeRunEnv(adapter, runtimeOptions),
70
- recordVideo: useFramedExtensionRecording ? false : recordVideo,
71
- ...runtimeOptions.librarySources ? { librarySources: runtimeOptions.librarySources } : {}
72
- };
73
66
  let result;
74
67
  try {
75
68
  result = await runner.run(runRequest);
76
69
  } finally {
77
70
  if (adapter === "mobile") {
78
71
  const { hideMobileHudOnTeardown } = await import("../adapters.js");
79
- await hideMobileHudOnTeardown(projectRoot, recipeRunEnv(adapter, runtimeOptions));
72
+ await hideMobileHudOnTeardown(
73
+ projectRoot,
74
+ recipeRunEnv(adapter, runtimeOptions)
75
+ );
80
76
  }
81
77
  }
82
78
  await stopRecipeRecording(recording, result);
83
- return result;
79
+ return finishRunDiagnostics(diagnosticBaseline, result);
84
80
  } finally {
85
81
  await stopRecipeRecording(recording);
86
82
  }
87
83
  } finally {
84
+ restoreRuntimeEnvironment();
85
+ }
86
+ }
87
+ async function preflightRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}) {
88
+ const restoreRuntimeEnvironment = activateRecipeRuntimeEnvironment(
89
+ adapter,
90
+ projectRoot,
91
+ runtimeOptions
92
+ );
93
+ try {
94
+ const execution = await resolveRecipeExecution(
95
+ adapter,
96
+ recipe,
97
+ artifactsDir,
98
+ projectRoot,
99
+ actionManifestPath,
100
+ runtimeOptions
101
+ );
102
+ await execution.runner.preflight(execution.runRequest);
103
+ return execution;
104
+ } finally {
105
+ restoreRuntimeEnvironment();
106
+ }
107
+ }
108
+ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions) {
109
+ const absoluteArtifactsDir = path.resolve(artifactsDir);
110
+ const absoluteRecipePath = typeof recipe === "string" ? path.resolve(recipe) : void 0;
111
+ const recordVideo = runtimeOptions.recordVideo ?? false;
112
+ const { resolveRecipeTrustInput } = await importRecipeHarness();
113
+ const trust = resolveRecipeTrustInput({
114
+ sourceTrust: runtimeOptions.source?.trust,
115
+ sourceKind: runtimeOptions.source?.kind,
116
+ sourceName: runtimeOptions.source?.name,
117
+ sourceDigest: runtimeOptions.source?.digest,
118
+ approvalDigest: runtimeOptions.approval?.planDigest
119
+ });
120
+ const librarySources = executionLibrarySources(
121
+ runtimeOptions.librarySources,
122
+ trust.source?.trust ?? "trusted"
123
+ );
124
+ const { manifest, actionSources } = await resolveActionManifest(
125
+ adapter,
126
+ actionManifestPath,
127
+ librarySources,
128
+ process.env.METAMASK_RECIPE_LIVE_ADAPTER_DIR
129
+ );
130
+ const { createMetaMaskRunner } = await import("../runner.js");
131
+ const runner = await createMetaMaskRunner(adapter, manifest, {
132
+ quietStdout: runtimeOptions.stdoutIsMachineContract === true,
133
+ suppressLibraryResolutionLogs: runtimeOptions.suppressLibraryResolutionLogs,
134
+ autoHud: trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
135
+ onActionEvent: runtimeOptions.onActionEvent,
136
+ actionSources,
137
+ // A direct engineer invocation is the explicit trust boundary for a
138
+ // task-local adapter. Inherited gateway provenance remains authoritative,
139
+ // so an untrusted worker cannot promote itself with CLI flags.
140
+ trustTaskActions: (trust.source?.trust ?? "trusted") === "trusted"
141
+ });
142
+ const useFramedExtensionRecording = adapter === "extension" && recordVideo === "full-run" && trust.source?.trust !== "untrusted" && trust.source?.trust !== "unknown" && captureHelperSupportsRecordSessionSnapshots(projectRoot);
143
+ return {
144
+ runner,
145
+ absoluteArtifactsDir,
146
+ useFramedExtensionRecording,
147
+ runRequest: {
148
+ ...absoluteRecipePath ? { recipePath: absoluteRecipePath } : { recipeDocument: recipe },
149
+ artifactsDir: absoluteArtifactsDir,
150
+ projectRoot,
151
+ inheritProcessEnv: false,
152
+ env: {
153
+ ...recipeProcessEnvironment(),
154
+ ...recipeRunEnv(adapter, runtimeOptions),
155
+ METAMASK_RECIPE_ACTION_SOURCE_MAP: actionSourceMap(actionSources)
156
+ },
157
+ recordVideo: useFramedExtensionRecording ? false : recordVideo,
158
+ ...trust,
159
+ ...librarySources ? { librarySources } : {},
160
+ adapter,
161
+ ...runtimeOptions.params ? { params: runtimeOptions.params } : {}
162
+ }
163
+ };
164
+ }
165
+ function recipeProcessEnvironment() {
166
+ const env = { ...process.env };
167
+ delete env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
168
+ return env;
169
+ }
170
+ function executionLibrarySources(sources, invocationTrust) {
171
+ return sources?.map((source) => {
172
+ if (source.name === "metamask" || source.provenance?.trust === "trusted")
173
+ return source;
174
+ return {
175
+ ...source,
176
+ provenance: {
177
+ ...source.provenance,
178
+ kind: source.provenance?.kind ?? "library",
179
+ trust: invocationTrust === "trusted" ? "trusted" : "unknown",
180
+ name: source.provenance?.name ?? source.name ?? path.basename(source.root)
181
+ }
182
+ };
183
+ });
184
+ }
185
+ function activateRecipeRuntimeEnvironment(adapter, projectRoot, runtimeOptions) {
186
+ const previousCdpPort = process.env.CDP_PORT;
187
+ const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
188
+ const previousWatcherPort = process.env.WATCHER_PORT;
189
+ const previousMetroPort = process.env.METRO_PORT;
190
+ const previousExtensionAutolaunch = process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH;
191
+ getAdapterSurface(adapter).resolveSlotPorts(projectRoot);
192
+ if (runtimeOptions.cdpPort) {
193
+ process.env.CDP_PORT = runtimeOptions.cdpPort;
194
+ process.env.RECIPE_CDP_PORT = runtimeOptions.cdpPort;
195
+ }
196
+ if (runtimeOptions.watcherPort) {
197
+ process.env.WATCHER_PORT = runtimeOptions.watcherPort;
198
+ process.env.METRO_PORT = runtimeOptions.watcherPort;
199
+ }
200
+ if (runtimeOptions.launchExistingDist) {
201
+ process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH = "1";
202
+ }
203
+ return () => {
88
204
  restoreEnv("CDP_PORT", previousCdpPort);
89
205
  restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
90
206
  restoreEnv("WATCHER_PORT", previousWatcherPort);
91
207
  restoreEnv("METRO_PORT", previousMetroPort);
92
- restoreEnv("METAMASK_RECIPE_EXTENSION_AUTOLAUNCH", previousExtensionAutolaunch);
208
+ restoreEnv(
209
+ "METAMASK_RECIPE_EXTENSION_AUTOLAUNCH",
210
+ previousExtensionAutolaunch
211
+ );
212
+ };
213
+ }
214
+ function actionSourceMap(sources) {
215
+ const roots = {};
216
+ for (const [action, source] of sources) {
217
+ if (source.tier === "official") continue;
218
+ roots[action] = source.implementationRoot ?? (source.tier === "task" ? path.join(path.dirname(source.manifestPath), "actions") : path.join(
219
+ path.dirname(path.dirname(source.manifestPath)),
220
+ "actions"
221
+ ));
93
222
  }
223
+ return JSON.stringify(roots);
94
224
  }
95
225
  function recipeRunEnv(adapter, runtimeOptions = {}) {
96
226
  const base = {
@@ -113,7 +243,8 @@ function recipeRunEnv(adapter, runtimeOptions = {}) {
113
243
  };
114
244
  }
115
245
  async function prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions) {
116
- if (adapter !== "extension" || runtimeOptions.skipExtensionRuntimePrepare === true) return;
246
+ if (adapter !== "extension" || runtimeOptions.skipExtensionRuntimePrepare === true)
247
+ return;
117
248
  const { prepareExtensionRuntime } = await import("../adapters/extension/runtime.js");
118
249
  await prepareExtensionRuntime({
119
250
  projectRoot,
@@ -128,21 +259,74 @@ function restoreEnv(key, value) {
128
259
  if (value === void 0) delete process.env[key];
129
260
  else process.env[key] = value;
130
261
  }
131
- async function validateRecipeAdapterAware(recipe, manifest, librarySources) {
132
- const { validateRecipeDocument, validateRecipeWithManifest } = await importRecipeProtocol();
133
- let externalFlowIds;
262
+ async function validateRecipeAdapterAware(adapter, recipe, manifest, librarySources, rootRef, params) {
263
+ const protocol = await importRecipeProtocol();
264
+ let externalRecipeIds;
265
+ let libraryResolution;
266
+ const harness = await importRecipeHarness();
134
267
  if (librarySources && librarySources.length > 0) {
135
- const harness = await importRecipeHarness();
136
- const resolution = await harness.loadRecipeLibraries(librarySources);
137
- externalFlowIds = new Set(resolution.flows.keys());
138
- }
139
- const validationOptions = externalFlowIds !== void 0 ? { externalFlowIds } : void 0;
140
- const schema = validateRecipeDocument(recipe, validationOptions);
141
- const withManifest = validateRecipeWithManifest(recipe, manifest, validationOptions);
142
- const findings = [...schema.findings, ...withManifest.findings];
143
- const errors = findings.filter((finding) => finding.severity === "error").length;
268
+ libraryResolution = await harness.loadRecipeLibraries(librarySources, {
269
+ adapter
270
+ });
271
+ externalRecipeIds = new Set(libraryResolution.recipes.keys());
272
+ }
273
+ const validationOptions = externalRecipeIds !== void 0 ? { externalRecipeIds } : void 0;
274
+ const withManifest = protocol.validateRecipeWithManifest(
275
+ recipe,
276
+ manifest,
277
+ validationOptions
278
+ );
279
+ const findings = [...withManifest.findings];
280
+ if (withManifest.status === "valid" && isRecord(recipe) && libraryResolution) {
281
+ try {
282
+ const digest = protocol.digestRecipeDocument(recipe);
283
+ const dependencies = harness.resolveRecipeDependencies({
284
+ rootRef: rootRef ?? `$root:${digest}`,
285
+ root: recipe,
286
+ rootSource: {
287
+ kind: "recipe-file",
288
+ trust: "unknown",
289
+ name: rootRef ?? "recipe file",
290
+ digest
291
+ },
292
+ recipes: libraryResolution.recipes
293
+ });
294
+ if (params) {
295
+ harness.validateRecipeDependencyParams({
296
+ root: recipe,
297
+ params,
298
+ recipes: dependencies.recipes
299
+ });
300
+ }
301
+ for (const dependency of dependencies.recipes.values()) {
302
+ findings.push(
303
+ ...protocol.validateRecipeWithManifest(
304
+ dependency.document,
305
+ manifest,
306
+ validationOptions
307
+ ).findings
308
+ );
309
+ }
310
+ } catch (error) {
311
+ const resolutionError = error instanceof harness.RecipeResolutionError ? error : void 0;
312
+ if (!resolutionError) throw error;
313
+ findings.push({
314
+ severity: "error",
315
+ code: resolutionError.code,
316
+ path: "workflow",
317
+ message: `${resolutionError.message} Next: ${resolutionError.userAction}`
318
+ });
319
+ }
320
+ }
321
+ const errors = findings.filter(
322
+ (finding) => finding.severity === "error"
323
+ ).length;
144
324
  const warnings = findings.length - errors;
145
- return { status: errors > 0 ? "invalid" : "valid", findings, summary: { errors, warnings } };
325
+ return {
326
+ status: errors > 0 ? "invalid" : "valid",
327
+ findings,
328
+ summary: { errors, warnings }
329
+ };
146
330
  }
147
331
  function isRecipeFile(p) {
148
332
  try {
@@ -151,107 +335,169 @@ function isRecipeFile(p) {
151
335
  return false;
152
336
  }
153
337
  }
154
- function resolveRunRecipeArg(recipeArg, adapter, librarySources) {
338
+ async function resolveRunRecipeArg(recipeArg, adapter, librarySources) {
155
339
  const direct = path.resolve(recipeArg);
156
340
  if (isRecipeFile(direct)) return { recipeFile: direct };
341
+ const sources = effectiveLibrarySources(librarySources);
342
+ const harness = await importRecipeHarness();
343
+ const resolution = await harness.loadRecipeLibraries(sources, { adapter });
157
344
  if (!recipeArg.includes("/") && !recipeArg.includes(path.sep)) {
158
- const candidates = recipeNameCandidates(recipeArg, adapter);
159
- if (librarySources && librarySources.length > 0) {
160
- for (const source of librarySources) {
161
- for (const candidate of candidates) {
162
- const file = path.join(source.root, "recipes", candidate);
163
- if (isRecipeFile(file)) return { recipeFile: file };
164
- }
165
- }
166
- } else {
167
- for (const candidate of candidates) {
168
- const file = recipePath(candidate);
169
- if (isRecipeFile(file)) return { recipeFile: file };
170
- }
171
- }
345
+ const selected = resolution.recipes.get(recipeArg);
346
+ if (selected) return { recipeFile: selected.path, ref: selected.ref };
172
347
  }
173
- const packagedNames = libraryRecipeNames(adapter);
174
- const nonCanonical = librarySources?.filter((s) => s.name !== "metamask") ?? [];
175
- const notFoundCore = nonCanonical.length > 0 ? `recipe not found: ${recipeArg} \u2014 not a file, and no recipe matched in library sources [${librarySources.map((s) => s.name ?? path.basename(s.root)).join(", ")}].` : `recipe not found: ${recipeArg} \u2014 not a file, and no packaged library recipe matched.`;
176
- const suffix = packagedNames.length > 0 ? ` Library recipes for ${adapter}: ${packagedNames.join(", ")} (mm-harness run <name>).` : ` The packaged library has no recipes for ${adapter}.`;
348
+ const available = [...resolution.recipes.keys()].sort();
349
+ const nonCanonical = sources.filter((source) => source.name !== "metamask");
350
+ const notFoundCore = nonCanonical.length > 0 ? `recipe not found: ${recipeArg} \u2014 not a file, and no recipe matched in library sources [${sources.map((source) => source.name ?? path.basename(source.root)).join(", ")}].` : `recipe not found: ${recipeArg} \u2014 not a file, and no packaged library recipe matched.`;
351
+ const suffix = available.length > 0 ? ` Library recipes for ${adapter}: ${available.join(", ")} (mm-harness run <name>).` : ` The packaged library has no recipes for ${adapter}.`;
177
352
  return { notFound: notFoundCore + suffix };
178
353
  }
179
- function libraryRecipeNames(adapter) {
180
- let entries;
354
+ function effectiveLibrarySources(librarySources) {
355
+ return librarySources && librarySources.length > 0 ? librarySources : [{ name: "metamask", root: path.resolve(runnerDir, "library") }];
356
+ }
357
+ async function listRunnableRecipes(adapter, librarySources) {
358
+ const harness = await importRecipeHarness();
359
+ const resolution = await harness.loadRecipeLibraries(
360
+ effectiveLibrarySources(librarySources),
361
+ {
362
+ adapter
363
+ }
364
+ );
365
+ return [...resolution.recipes.values()].map(
366
+ (recipe) => recipeSummary(recipe.ref, adapter, recipe.document, recipe)
367
+ ).sort((left, right) => left.name.localeCompare(right.name));
368
+ }
369
+ async function describeRunnableRecipe(recipeArg, adapter, librarySources) {
370
+ const resolved = await resolveRunRecipeArg(
371
+ recipeArg,
372
+ adapter,
373
+ librarySources
374
+ );
375
+ if ("notFound" in resolved) return resolved;
376
+ let document;
181
377
  try {
182
- entries = listRecipeFiles(recipePath(""));
183
- } catch {
184
- return [];
185
- }
186
- const scopes = ["mobile", "extension", "core"];
187
- const names = entries.filter((f) => f.endsWith(".recipe.json")).map((f) => f.slice(0, -".recipe.json".length).replace(/[\\/]/gu, ".")).filter((n) => {
188
- const scope = n.split(".").pop() ?? "";
189
- return !scopes.includes(scope) || scope === adapter;
190
- }).map((n) => n.endsWith(`.${adapter}`) ? n.slice(0, -(adapter.length + 1)) : n);
191
- return [...new Set(names)].sort();
192
- }
193
- function recipeNameCandidates(recipeArg, adapter) {
194
- const names = [recipeArg, ...LEGACY_RECIPE_ALIASES[recipeArg] ?? []];
195
- const candidates = [];
196
- for (const name of names) {
197
- candidates.push(...singleRecipeNameCandidates(name, adapter));
198
- }
199
- return [...new Set(candidates)];
200
- }
201
- function singleRecipeNameCandidates(name, adapter) {
202
- if (name.endsWith(".recipe.json")) {
203
- return uniqueStrings([domainPathForRecipeFile(name), name]);
204
- }
205
- const bases = uniqueStrings([domainPathForRecipeName(name), name]);
206
- const candidates = [];
207
- for (const base of bases) {
208
- candidates.push(`${base}.${adapter}.recipe.json`, `${base}.recipe.json`, base);
209
- }
210
- return candidates;
211
- }
212
- function domainPathForRecipeFile(name) {
213
- const suffix = ".recipe.json";
214
- const base = name.slice(0, -suffix.length);
215
- return `${domainPathForRecipeName(base)}${suffix}`;
216
- }
217
- function domainPathForRecipeName(name) {
218
- if (name.includes("/") || name.includes(path.sep)) return name;
219
- const dot = name.indexOf(".");
220
- return dot > 0 ? `${name.slice(0, dot)}/${name.slice(dot + 1)}` : name;
221
- }
222
- function uniqueStrings(values) {
223
- return [...new Set(values)];
224
- }
225
- const LEGACY_RECIPE_ALIASES = {
226
- "action-validation": ["runner.action-validation"],
227
- "action-validation.extension.recipe.json": ["runner.action-validation.extension.recipe.json"],
228
- "action-validation.mobile.recipe.json": ["runner.action-validation.mobile.recipe.json"],
229
- "app-lifecycle-android-smoke": ["app.lifecycle.android-smoke"],
230
- "app-lifecycle-android-smoke.mobile.recipe.json": ["app.lifecycle.android-smoke.mobile.recipe.json"],
231
- "order-lifecycle": ["perps.order-lifecycle"],
232
- "order-lifecycle.core.recipe.json": ["perps.order-lifecycle.core.recipe.json"],
233
- "perps-lifecycle": ["perps.lifecycle"],
234
- "perps-lifecycle.recipe.json": ["perps.lifecycle.recipe.json"],
235
- "perps-performance": ["perps.performance"],
236
- "perps-performance-background-resume": ["perps.performance.background-resume"],
237
- "perps-performance-background-resume.mobile.recipe.json": ["perps.performance.background-resume.mobile.recipe.json"],
238
- "perps-performance-cold-start": ["perps.performance.cold-start"],
239
- "perps-performance-cold-start.mobile.recipe.json": ["perps.performance.cold-start.mobile.recipe.json"],
240
- "perps-performance-warm-start": ["perps.performance.warm-start"],
241
- "perps-performance-warm-start.mobile.recipe.json": ["perps.performance.warm-start.mobile.recipe.json"],
242
- "perps-performance.mobile.recipe.json": ["perps.performance.mobile.recipe.json"],
243
- "read-markets": ["perps.read-markets"],
244
- "read-markets.core.recipe.json": ["perps.read-markets.core.recipe.json"],
245
- "smoke": ["runner.smoke"],
246
- "smoke.extension.recipe.json": ["runner.smoke.extension.recipe.json"],
247
- "smoke.mobile.recipe.json": ["runner.smoke.mobile.recipe.json"],
248
- "trading-lifecycle": ["perps.trading-lifecycle"],
249
- "trading-lifecycle.core.recipe.json": ["perps.trading-lifecycle.core.recipe.json"]
250
- };
251
- async function validateRunRecipeStatic(recipeArg, adapter, options) {
252
- const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
253
- const resolved = resolveRunRecipeArg(recipeArg, adapter, librarySources);
378
+ document = JSON.parse(fs.readFileSync(resolved.recipeFile, "utf8"));
379
+ } catch (error) {
380
+ return {
381
+ unreadable: `could not read recipe ${resolved.recipeFile}: ${error instanceof Error ? error.message : String(error)}`
382
+ };
383
+ }
384
+ if (!isRecord(document)) {
385
+ return {
386
+ unreadable: `recipe ${resolved.recipeFile} must contain a JSON object.`
387
+ };
388
+ }
389
+ const harness = await importRecipeHarness();
390
+ const libraryResolution = await harness.loadRecipeLibraries(
391
+ effectiveLibrarySources(librarySources),
392
+ { adapter }
393
+ );
394
+ const selected = resolved.ref ? libraryResolution.recipes.get(resolved.ref) : void 0;
395
+ const composition = recipeComposition(document, libraryResolution.recipes);
396
+ const summary = recipeSummary(
397
+ resolved.ref ?? recipeArg,
398
+ adapter,
399
+ document,
400
+ selected
401
+ );
402
+ return {
403
+ recipe: {
404
+ ...summary,
405
+ source: selected?.source ?? "file",
406
+ file: selected?.file ?? resolved.recipeFile,
407
+ path: resolved.recipeFile,
408
+ ...typeof document.$schema === "string" ? { $schema: document.$schema } : {},
409
+ actions: composition.actions,
410
+ nestedRecipes: composition.nestedRecipes,
411
+ unresolvedRecipes: composition.unresolvedRecipes
412
+ }
413
+ };
414
+ }
415
+ function recipeSummary(ref, adapter, document, selected) {
416
+ return {
417
+ name: ref,
418
+ source: selected?.source ?? "file",
419
+ file: selected?.file ?? "",
420
+ shadows: selected?.shadows ?? [],
421
+ adapter,
422
+ variant: selected?.adapter ?? null,
423
+ parameters: recipeParameters(document),
424
+ ...typeof document.title === "string" && document.title.trim() ? { title: document.title.trim() } : {},
425
+ ...typeof document.description === "string" && document.description.trim() ? { description: document.description.trim() } : {}
426
+ };
427
+ }
428
+ function recipeParameters(document) {
429
+ if (!isRecord(document.paramsSchema)) return [];
430
+ const schema = document.paramsSchema;
431
+ const required = new Set(
432
+ Array.isArray(schema.required) ? schema.required.filter(
433
+ (entry) => typeof entry === "string"
434
+ ) : []
435
+ );
436
+ if (!isRecord(schema.properties)) return [];
437
+ return Object.entries(schema.properties).map(([name, raw]) => {
438
+ const property = isRecord(raw) ? raw : {};
439
+ return {
440
+ name,
441
+ ...typeof property.type === "string" ? { type: property.type } : {},
442
+ required: required.has(name),
443
+ ...Object.hasOwn(property, "default") ? { default: property.default } : {},
444
+ ...typeof property.description === "string" ? { description: property.description } : {},
445
+ ...Array.isArray(property.enum) ? { enum: property.enum } : {}
446
+ };
447
+ });
448
+ }
449
+ function recipeComposition(recipe, recipes) {
450
+ const actions = /* @__PURE__ */ new Set();
451
+ const nestedRecipes = /* @__PURE__ */ new Set();
452
+ const unresolvedRecipes = /* @__PURE__ */ new Set();
453
+ const visitedRecipes = /* @__PURE__ */ new Set();
454
+ const visitNode = (value) => {
455
+ if (!isRecord(value) || typeof value.action !== "string") return;
456
+ if (value.action !== "call") {
457
+ actions.add(value.action);
458
+ return;
459
+ }
460
+ if (typeof value.ref !== "string") return;
461
+ nestedRecipes.add(value.ref);
462
+ const dependency = recipes.get(value.ref);
463
+ if (!dependency) {
464
+ unresolvedRecipes.add(value.ref);
465
+ return;
466
+ }
467
+ if (visitedRecipes.has(value.ref)) return;
468
+ visitedRecipes.add(value.ref);
469
+ visitRecipe(dependency.document);
470
+ };
471
+ const visitWorkflow = (value) => {
472
+ if (!isRecord(value)) return;
473
+ if (isRecord(value.nodes)) Object.values(value.nodes).forEach(visitNode);
474
+ };
475
+ const visitRecipe = (document) => {
476
+ visitWorkflow(document.workflow);
477
+ };
478
+ visitRecipe(recipe);
479
+ return {
480
+ actions: [...actions].sort(),
481
+ nestedRecipes: [...nestedRecipes].sort(),
482
+ unresolvedRecipes: [...unresolvedRecipes].sort()
483
+ };
484
+ }
485
+ async function validateRunRecipeStatic(recipeArg, adapter, options, params = {}) {
486
+ let librarySources = await resolveMetaMaskLibrarySources(
487
+ optionStrings(options, "library")
488
+ );
489
+ const resolved = await resolveRunRecipeArg(
490
+ recipeArg,
491
+ adapter,
492
+ librarySources
493
+ );
254
494
  const recipeFile = "recipeFile" in resolved ? resolved.recipeFile : path.resolve(recipeArg);
495
+ if ("recipeFile" in resolved && !resolved.ref) {
496
+ librarySources = await resolveMetaMaskLibrarySources(
497
+ optionStrings(options, "library"),
498
+ recipeFile
499
+ );
500
+ }
255
501
  const empty = {
256
502
  recipe: void 0,
257
503
  recipeFile,
@@ -259,6 +505,7 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
259
505
  errorCount: 0,
260
506
  manifestOk: false,
261
507
  schemaValid: false,
508
+ effectiveParams: params,
262
509
  ...librarySources ? { librarySources } : {}
263
510
  };
264
511
  if ("notFound" in resolved) {
@@ -283,7 +530,11 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
283
530
  };
284
531
  }
285
532
  const findings = [];
286
- const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
533
+ const { manifest } = await resolveActionManifest(
534
+ adapter,
535
+ optionString(options, "actionManifest"),
536
+ librarySources
537
+ );
287
538
  let manifestOk = true;
288
539
  try {
289
540
  await validateManifest(manifest);
@@ -296,30 +547,47 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
296
547
  message: error instanceof Error ? error.message : String(error)
297
548
  });
298
549
  }
299
- const validation = manifestOk ? await validateRecipeAdapterAware(recipe, manifest, librarySources) : { status: "invalid", findings: [], summary: { errors: 1, warnings: 0 } };
300
- findings.push(...validation.findings);
550
+ let effectiveParams = params;
301
551
  if (isRecord(recipe)) {
302
- const validate = isRecord(recipe.validate) ? recipe.validate : void 0;
303
- const workflow = validate && isRecord(validate.workflow) ? validate.workflow : void 0;
304
- if (workflow?.startState != null) {
305
- findings.push({
306
- severity: "error",
307
- code: "recipe.start-state-placement",
308
- path: "validate.workflow.startState",
309
- message: "startState must be declared at the recipe root, not validate.workflow.startState"
310
- });
311
- }
312
- const startState = isRecord(recipe.startState) ? recipe.startState : void 0;
313
- if (adapter === "extension" && startState?.action === "metamask.perps.start_state" && startState.page === false) {
314
- findings.push({
315
- severity: "error",
316
- code: "recipe.extension-perps-start-page",
317
- path: "startState.page",
318
- message: "Extension metamask.perps.start_state must enter Perps; omit page or provide a string target instead of false"
319
- });
552
+ const protocol = await importRecipeProtocol();
553
+ effectiveParams = protocol.applyRecipeParamDefaults(
554
+ params,
555
+ recipe.paramsSchema
556
+ );
557
+ findings.push(
558
+ ...protocol.validateRecipeParams(effectiveParams, recipe.paramsSchema).findings
559
+ );
560
+ const workflow = isRecord(recipe.workflow) ? recipe.workflow : void 0;
561
+ const nodes = workflow && isRecord(workflow.nodes) ? workflow.nodes : void 0;
562
+ if (adapter === "extension" && nodes) {
563
+ for (const [nodeId, node] of Object.entries(nodes)) {
564
+ if (isRecord(node) && node.action === "metamask.perps.start_state" && node.page === false) {
565
+ findings.push({
566
+ severity: "error",
567
+ code: "recipe.extension-perps-start-page",
568
+ path: `workflow.nodes.${nodeId}.page`,
569
+ message: "Extension metamask.perps.start_state must enter Perps; omit page or provide a string target instead of false"
570
+ });
571
+ }
572
+ }
320
573
  }
321
574
  }
322
- const errorCount = findings.filter((finding) => finding.severity === "error").length;
575
+ const validation = manifestOk ? await validateRecipeAdapterAware(
576
+ adapter,
577
+ recipe,
578
+ manifest,
579
+ librarySources,
580
+ resolved.ref,
581
+ effectiveParams
582
+ ) : {
583
+ status: "invalid",
584
+ findings: [],
585
+ summary: { errors: 1, warnings: 0 }
586
+ };
587
+ findings.push(...validation.findings);
588
+ const errorCount = findings.filter(
589
+ (finding) => finding.severity === "error"
590
+ ).length;
323
591
  return {
324
592
  recipe,
325
593
  recipeFile,
@@ -327,13 +595,21 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
327
595
  errorCount,
328
596
  manifestOk,
329
597
  schemaValid: validation.status === "valid" && errorCount === 0,
598
+ effectiveParams,
330
599
  ...librarySources ? { librarySources } : {}
331
600
  };
332
601
  }
333
602
  async function actionInsteadOfRecipeHint(recipeArg, adapter, options) {
334
603
  if (recipeArg.includes("/") || recipeArg.includes(path.sep)) return "";
335
604
  try {
336
- const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
605
+ const librarySources = await resolveMetaMaskLibrarySources(
606
+ optionStrings(options, "library")
607
+ );
608
+ const { manifest } = await resolveActionManifest(
609
+ adapter,
610
+ optionString(options, "actionManifest"),
611
+ librarySources
612
+ );
337
613
  const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
338
614
  const actions = getRecipeActionManifestActionNames(manifest);
339
615
  const matches = actions.includes(recipeArg) ? [recipeArg] : actions.filter((name) => name.split(".").pop() === recipeArg);
@@ -341,67 +617,119 @@ async function actionInsteadOfRecipeHint(recipeArg, adapter, options) {
341
617
  const device = optionString(options, "device");
342
618
  const actionManifest = optionString(options, "actionManifest");
343
619
  const parts = ["mm-harness", "call", matches[0]];
620
+ parts.push(
621
+ "--adapter",
622
+ adapter,
623
+ "--target",
624
+ shellQuoteArg(targetPath(options))
625
+ );
344
626
  if (device) parts.push("--device", shellQuoteArg(device));
345
- if (actionManifest) parts.push("--action-manifest", shellQuoteArg(actionManifest));
627
+ if (actionManifest)
628
+ parts.push("--action-manifest", shellQuoteArg(actionManifest));
346
629
  return ` This is an action, not a recipe. Use: ${parts.join(" ")}.`;
347
630
  } catch {
348
631
  return "";
349
632
  }
350
633
  }
351
- async function resolveMetaMaskLibrarySources(libraryEntry) {
634
+ async function resolveMetaMaskLibrarySources(libraryEntry, recipePath) {
352
635
  const harness = await importRecipeHarness();
353
- if (typeof harness.resolveRecipeLibrarySources !== "function") {
354
- if (libraryEntry) {
355
- throw usageError(
356
- "--library requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support / resolveRecipeLibrarySources). The pinned 0.3.0 lacks it, and npm 0.3.2 still does not export it \u2014 recipe-library support is PENDING PUBLISH from farmslot."
357
- );
358
- }
359
- return void 0;
360
- }
361
- const sources = await harness.resolveRecipeLibrarySources(
362
- libraryEntry ? { cliEntries: [libraryEntry] } : void 0
636
+ const cliEntries = typeof libraryEntry === "string" ? [libraryEntry] : libraryEntry ? [...libraryEntry] : [];
637
+ const sources = await harness.resolveRecipeLibrarySources({
638
+ ...cliEntries.length > 0 ? { cliEntries } : {},
639
+ ...recipePath ? { recipePath } : {}
640
+ });
641
+ const canonicalRoot = path.resolve(runnerDir, "library");
642
+ const configuredCanonical = sources.find(
643
+ (source) => source.name === "metamask"
363
644
  );
364
- sources.push({ name: "metamask", root: path.join(runnerDir, "library") });
365
- return sources;
645
+ if (configuredCanonical && path.resolve(configuredCanonical.root) !== canonicalRoot) {
646
+ throw usageError(
647
+ `Recipe library source metamask must resolve to ${canonicalRoot}; got ${path.resolve(configuredCanonical.root)}.`
648
+ );
649
+ }
650
+ if (!sources.some((source) => path.resolve(source.root) === canonicalRoot)) {
651
+ sources.push({ name: "metamask", root: canonicalRoot });
652
+ }
653
+ return sources.map((source) => ({
654
+ ...source,
655
+ provenance: source.provenance ?? {
656
+ kind: source.name === "metamask" ? "bundled" : "library",
657
+ trust: source.name === "metamask" ? "trusted" : "unknown",
658
+ name: source.name ?? path.basename(source.root)
659
+ }
660
+ }));
366
661
  }
367
662
  function synthesizeOneNodeRecipe(action, args) {
368
663
  return {
369
- schema_version: 1,
664
+ $schema: "https://farmslot.io/schemas/recipe-v1.schema.json",
370
665
  title: `mm-harness call ${action}`,
371
666
  description: `Ad-hoc single-action execution of ${action} via the real engine path (mm-harness call).`,
372
- validate: {
373
- workflow: {
374
- entry: "call",
375
- nodes: {
376
- call: { action, ...args, next: "done", intent: `Call ${action} in isolation` },
377
- done: { action: "end", status: "pass" }
378
- }
667
+ workflow: {
668
+ entry: "call",
669
+ nodes: {
670
+ call: {
671
+ ...args,
672
+ action,
673
+ next: "done",
674
+ intent: typeof args.intent === "string" ? args.intent : `Complete the requested ${action} operation`
675
+ },
676
+ done: { action: "end", status: "pass" }
379
677
  }
380
678
  }
381
679
  };
382
680
  }
383
681
  async function runOneNode(adapter, action, args, target, actionManifest) {
384
- const manifest = loadActionManifest(adapter, actionManifest);
682
+ const librarySources = await resolveMetaMaskLibrarySources(void 0);
683
+ const { manifest } = await resolveActionManifest(
684
+ adapter,
685
+ actionManifest,
686
+ librarySources
687
+ );
385
688
  const recipe = synthesizeOneNodeRecipe(action, args);
386
- const validation = await validateRecipeAdapterAware(recipe, manifest);
689
+ const validation = await validateRecipeAdapterAware(
690
+ adapter,
691
+ recipe,
692
+ manifest,
693
+ librarySources
694
+ );
387
695
  if (validation.status === "invalid") return { status: "fail" };
388
696
  const scratch = await mkdtemp(path.join(os.tmpdir(), "mm-harness-fixtures-"));
389
697
  const recipeFile = path.join(scratch, "set.recipe.json");
390
698
  fs.writeFileSync(recipeFile, `${JSON.stringify(recipe, null, 2)}
391
699
  `);
392
- const result = await runRecipe(adapter, recipeFile, path.join(scratch, "artifacts"), target, actionManifest, {
393
- librarySources: await resolveMetaMaskLibrarySources(void 0),
394
- stdoutIsMachineContract: true
395
- });
700
+ const result = await runRecipe(
701
+ adapter,
702
+ recipeFile,
703
+ path.join(scratch, "artifacts"),
704
+ target,
705
+ actionManifest,
706
+ {
707
+ librarySources,
708
+ stdoutIsMachineContract: true
709
+ }
710
+ );
396
711
  return { status: result.status === "pass" ? "pass" : "fail" };
397
712
  }
398
713
  async function prepareHeal(adapter, target, options, json, prepareOptions = {}) {
399
714
  if (recipeRunning(target)) {
400
715
  const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
716
+ const userAction = `inspect the checkout state with: mm-harness status --target ${shellQuoteArg(target)} --json; retry after the active recipe finishes`;
401
717
  if (json) {
402
- console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg } }, null, 2));
718
+ console.log(
719
+ JSON.stringify(
720
+ {
721
+ schemaVersion: 1,
722
+ status: "fail",
723
+ recoverable: false,
724
+ error: { code: "RECIPE_RUNNING", message: msg, userAction }
725
+ },
726
+ null,
727
+ 2
728
+ )
729
+ );
403
730
  } else {
404
- console.error(`\u2717 mm-harness: ${msg}`);
731
+ console.error(`\u2717 mm-harness: ${msg}
732
+ Next: ${userAction}`);
405
733
  }
406
734
  return EXIT.bounded;
407
735
  }
@@ -414,21 +742,43 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
414
742
  return EXIT.usage;
415
743
  }
416
744
  const state = newHealState();
417
- const ensured = await ensureOverlay(adapter, target, heal, state, json);
745
+ prepareOptions.onPhase?.("install");
746
+ const ensured = await ensureOverlay(
747
+ adapter,
748
+ target,
749
+ heal,
750
+ state,
751
+ json
752
+ );
418
753
  if (!ensured.ok) {
754
+ const userAction = `mm-harness install --adapter ${adapter} --target ${shellQuoteArg(target)}`;
419
755
  if (json) {
420
756
  console.log(
421
757
  JSON.stringify(
422
- { schemaVersion: 1, status: "fail", recoverable: false, mutations: state.mutations, error: { code: "OVERLAY_INSTALL_FAILED", message: ensured.error } },
758
+ {
759
+ schemaVersion: 1,
760
+ status: "fail",
761
+ recoverable: false,
762
+ mutations: state.mutations,
763
+ error: {
764
+ code: "OVERLAY_INSTALL_FAILED",
765
+ message: ensured.error,
766
+ userAction
767
+ }
768
+ },
423
769
  null,
424
770
  2
425
771
  )
426
772
  );
427
773
  } else {
428
- console.error(`\u2717 overlay auto-ensure failed: ${ensured.error}`);
774
+ console.error(
775
+ `\u2717 overlay auto-ensure failed: ${ensured.error}
776
+ Next: ${userAction}`
777
+ );
429
778
  }
430
779
  return EXIT.infra;
431
780
  }
781
+ prepareOptions.onPhase?.("healthcheck");
432
782
  if (adapter === "extension") {
433
783
  const previousCdpPort = process.env.CDP_PORT;
434
784
  const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
@@ -441,7 +791,15 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
441
791
  }
442
792
  if (explicitWatcherPort) process.env.WATCHER_PORT = explicitWatcherPort;
443
793
  try {
444
- const current = await ensureExtensionProofRuntime(target, heal, state, json);
794
+ const current = await ensureExtensionProofRuntime(
795
+ target,
796
+ heal,
797
+ state,
798
+ json,
799
+ {
800
+ onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
801
+ }
802
+ );
445
803
  if (current !== null) return current;
446
804
  } finally {
447
805
  restoreEnv("CDP_PORT", previousCdpPort);
@@ -450,7 +808,15 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
450
808
  }
451
809
  }
452
810
  if (adapter === "mobile" && prepareOptions.skipMobileSourceFreshness !== true) {
453
- const current = await ensureMobileProofRuntime(target, heal, state, json);
811
+ const current = await ensureMobileProofRuntime(
812
+ target,
813
+ heal,
814
+ state,
815
+ json,
816
+ {
817
+ onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
818
+ }
819
+ );
454
820
  if (current !== null) return current;
455
821
  }
456
822
  return { state, heal };
@@ -460,19 +826,45 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
460
826
  const check = deps.check ?? (() => source.mobileSourceCheck(target));
461
827
  const record = deps.record ?? ((fingerprint) => source.recordMobileSourceBaseline(target, fingerprint));
462
828
  const initial = check();
829
+ if (initial.status === "unavailable") {
830
+ if (json) {
831
+ console.log(
832
+ JSON.stringify(
833
+ {
834
+ schemaVersion: 1,
835
+ status: "fail",
836
+ recoverable: false,
837
+ error: initial.error
838
+ },
839
+ null,
840
+ 2
841
+ )
842
+ );
843
+ } else {
844
+ console.error(`\u2717 ${initial.error.message}`);
845
+ console.error(` Next: ${initial.error.userAction}`);
846
+ }
847
+ return EXIT.usage;
848
+ }
463
849
  if (initial.status === "current") return null;
464
850
  const targetArg = JSON.stringify(path.resolve(target));
465
851
  const userAction = `mm-harness call app.lifecycle --arg command=restart --adapter mobile --target ${targetArg}`;
466
852
  if (heal === "off") {
467
853
  const message = `Mobile loaded source is not current (${initial.status}).`;
468
854
  if (json) {
469
- console.log(JSON.stringify({
470
- schemaVersion: 1,
471
- status: "fail",
472
- recoverable: true,
473
- mutations: state.mutations,
474
- error: { code: "MOBILE_SOURCE_NOT_LOADED", message, userAction }
475
- }, null, 2));
855
+ console.log(
856
+ JSON.stringify(
857
+ {
858
+ schemaVersion: 1,
859
+ status: "fail",
860
+ recoverable: true,
861
+ mutations: state.mutations,
862
+ error: { code: "MOBILE_SOURCE_NOT_LOADED", message, userAction }
863
+ },
864
+ null,
865
+ 2
866
+ )
867
+ );
476
868
  } else {
477
869
  console.error(`\u2717 ${message}`);
478
870
  console.error(` Next: ${userAction}`);
@@ -481,7 +873,11 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
481
873
  }
482
874
  const recoveryCode = "mobile.source_reloaded";
483
875
  state.attemptedRecoveries.push(recoveryCode);
484
- if (!json) console.error(`\u2192 Mobile proof preflight: source ${initial.status}; restarting the app before execution`);
876
+ deps.onRecovery?.(recoveryCode);
877
+ if (!json)
878
+ console.error(
879
+ `\u2192 Mobile proof preflight: source ${initial.status}; restarting the app before execution`
880
+ );
485
881
  const restart = deps.restart ?? (async () => {
486
882
  const result2 = await runOneNode(
487
883
  "mobile",
@@ -490,7 +886,10 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
490
886
  target,
491
887
  void 0
492
888
  );
493
- return { status: result2.status === "pass" ? 0 : EXIT.runtime, output: result2.status };
889
+ return {
890
+ status: result2.status === "pass" ? 0 : EXIT.runtime,
891
+ output: result2.status
892
+ };
494
893
  });
495
894
  const result = await restart();
496
895
  const platform = resolveRunMobilePlatform();
@@ -504,23 +903,46 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
504
903
  if (result.status !== 0 || bridge.status !== 0 || !recorded || verified.status !== "current") {
505
904
  const message = bridge.status !== 0 ? "Mobile source restart completed but the platform-matched bridge did not become ready." : "Mobile source restart did not produce a current loaded-source baseline.";
506
905
  if (json) {
507
- console.log(JSON.stringify({
508
- schemaVersion: 1,
509
- status: "fail",
510
- recoverable: false,
511
- attemptedRecoveries: state.attemptedRecoveries,
512
- error: { code: bridge.status !== 0 ? "MOBILE_BRIDGE_NOT_READY" : "MOBILE_SOURCE_RELOAD_FAILED", message, userAction, originalError: bridge.output || result.output }
513
- }, null, 2));
906
+ console.log(
907
+ JSON.stringify(
908
+ {
909
+ schemaVersion: 1,
910
+ status: "fail",
911
+ recoverable: false,
912
+ attemptedRecoveries: state.attemptedRecoveries,
913
+ error: {
914
+ code: bridge.status !== 0 ? "MOBILE_BRIDGE_NOT_READY" : "MOBILE_SOURCE_RELOAD_FAILED",
915
+ message,
916
+ userAction,
917
+ originalError: bridge.output || result.output
918
+ }
919
+ },
920
+ null,
921
+ 2
922
+ )
923
+ );
514
924
  } else {
515
925
  console.error(`\u2717 ${message}`);
516
- console.error(` Next: ${bridge.status !== 0 ? "inspect the bridge-status log and rerun mm-harness launch <platform>" : userAction}`);
926
+ console.error(
927
+ ` Next: ${bridge.status !== 0 ? "inspect the bridge-status log and rerun mm-harness launch <platform>" : userAction}`
928
+ );
517
929
  }
518
930
  return EXIT.infra;
519
931
  }
520
932
  state.recovered.push(recoveryCode);
521
- state.mutations.push({ type: "runtime", action: "restarted", reasonCode: `source-${initial.status}` });
933
+ state.mutations.push({
934
+ type: "runtime",
935
+ action: "restarted",
936
+ reasonCode: `source-${initial.status}`
937
+ });
522
938
  return null;
523
939
  }
940
+ function resolveRunMobilePlatform() {
941
+ if (process.env.PLATFORM === "android" || process.env.PLATFORM === "ios") {
942
+ return process.env.PLATFORM;
943
+ }
944
+ return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL ? "android" : "ios";
945
+ }
524
946
  async function ensureExtensionProofRuntime(target, heal, state, json, deps = {}) {
525
947
  const decide = deps.decide ?? (async () => {
526
948
  const { decideExtensionReadiness } = await import("../adapters/extension/runtime-decision.js");
@@ -532,7 +954,38 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
532
954
  });
533
955
  const initial = await decide();
534
956
  if (initial.decision === "ready") return null;
535
- if (!["dist-stale", "dist-missing", "runtime-dist-stale", "runtime-dist-missing"].includes(initial.reasonCode)) {
957
+ if (initial.reasonCode === "runtime-dist-check-failed") {
958
+ const message = `Extension proof runtime freshness could not be checked: ${initial.reasons[0] ?? initial.decision}`;
959
+ const userAction2 = `mm-harness doctor --adapter extension --target ${JSON.stringify(path.resolve(target))}`;
960
+ if (json) {
961
+ console.log(
962
+ JSON.stringify(
963
+ {
964
+ schemaVersion: 1,
965
+ status: "fail",
966
+ recoverable: false,
967
+ error: {
968
+ code: "EXTENSION_RUNTIME_CHECK_FAILED",
969
+ message,
970
+ userAction: userAction2
971
+ }
972
+ },
973
+ null,
974
+ 2
975
+ )
976
+ );
977
+ } else {
978
+ console.error(`\u2717 ${message}`);
979
+ console.error(` Next: ${userAction2}`);
980
+ }
981
+ return EXIT.infra;
982
+ }
983
+ if (![
984
+ "dist-stale",
985
+ "dist-missing",
986
+ "runtime-dist-stale",
987
+ "runtime-dist-missing"
988
+ ].includes(initial.reasonCode)) {
536
989
  return null;
537
990
  }
538
991
  const tier = initial.decision === "build" ? "build" : "quick";
@@ -542,13 +995,23 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
542
995
  if (!canHeal) {
543
996
  const message = `Extension proof runtime is not current (${initial.reasonCode}): ${initial.reasons[0] ?? initial.decision}`;
544
997
  if (json) {
545
- console.log(JSON.stringify({
546
- schemaVersion: 1,
547
- status: "fail",
548
- recoverable: true,
549
- mutations: state.mutations,
550
- error: { code: "EXTENSION_RUNTIME_NOT_CURRENT", message, userAction }
551
- }, null, 2));
998
+ console.log(
999
+ JSON.stringify(
1000
+ {
1001
+ schemaVersion: 1,
1002
+ status: "fail",
1003
+ recoverable: true,
1004
+ mutations: state.mutations,
1005
+ error: {
1006
+ code: "EXTENSION_RUNTIME_NOT_CURRENT",
1007
+ message,
1008
+ userAction
1009
+ }
1010
+ },
1011
+ null,
1012
+ 2
1013
+ )
1014
+ );
552
1015
  } else {
553
1016
  console.error(`\u2717 ${message}`);
554
1017
  console.error(` Next: ${userAction}`);
@@ -556,30 +1019,67 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
556
1019
  return EXIT.infra;
557
1020
  }
558
1021
  const recoveryCode = tier === "build" ? "extension.runtime_rebuilt" : "extension.runtime_reloaded";
1022
+ const productConfigBlock = tier === "build" ? extensionProductConfigBlock(target) : null;
1023
+ if (productConfigBlock) {
1024
+ const message = productConfigBlock.message;
1025
+ const userAction2 = productConfigBlock.userAction;
1026
+ if (json) {
1027
+ console.log(
1028
+ JSON.stringify(
1029
+ {
1030
+ schemaVersion: 1,
1031
+ status: "fail",
1032
+ recoverable: false,
1033
+ mutations: state.mutations,
1034
+ error: {
1035
+ code: "EXTENSION_PRODUCT_CONFIG_REQUIRED",
1036
+ message,
1037
+ userAction: userAction2
1038
+ }
1039
+ },
1040
+ null,
1041
+ 2
1042
+ )
1043
+ );
1044
+ } else {
1045
+ console.error(`\u2717 ${message}`);
1046
+ console.error(` Next: ${userAction2}`);
1047
+ }
1048
+ return EXIT.infra;
1049
+ }
559
1050
  state.attemptedRecoveries.push(recoveryCode);
560
- if (!json) console.error(`\u2192 Extension proof preflight: ${initial.reasonCode}; ${tier === "build" ? "rebuilding" : "reloading"} before execution`);
1051
+ deps.onRecovery?.(recoveryCode);
1052
+ if (!json)
1053
+ console.error(
1054
+ `\u2192 Extension proof preflight: ${initial.reasonCode}; ${tier === "build" ? "rebuilding" : "reloading"} before execution`
1055
+ );
561
1056
  const launch = deps.launch ?? (async (requestedTier) => {
562
1057
  const { launchExtension } = await import("./launch/extension.js");
563
1058
  return launchExtension(target, requestedTier, false);
564
1059
  });
565
1060
  const result = await launch(tier);
566
- let verified = result.status === 0 ? await decide() : initial;
567
- if (result.status === 0 && verified.decision !== "ready") {
568
- for (let attempt = 0; attempt < 8 && verified.decision !== "ready"; attempt += 1) {
569
- await new Promise((resolve) => setTimeout(resolve, 250));
570
- verified = await decide();
571
- }
572
- }
1061
+ const verified = result.status === 0 ? await decide() : initial;
573
1062
  if (result.status !== 0 || verified.decision !== "ready") {
574
1063
  const message = `Extension runtime recovery did not produce a current healthy runtime (${verified.reasonCode}).`;
575
1064
  if (json) {
576
- console.log(JSON.stringify({
577
- schemaVersion: 1,
578
- status: "fail",
579
- recoverable: false,
580
- attemptedRecoveries: state.attemptedRecoveries,
581
- error: { code: "EXTENSION_RUNTIME_RECOVERY_FAILED", message, userAction, originalError: result.output }
582
- }, null, 2));
1065
+ console.log(
1066
+ JSON.stringify(
1067
+ {
1068
+ schemaVersion: 1,
1069
+ status: "fail",
1070
+ recoverable: false,
1071
+ attemptedRecoveries: state.attemptedRecoveries,
1072
+ error: {
1073
+ code: "EXTENSION_RUNTIME_RECOVERY_FAILED",
1074
+ message,
1075
+ userAction,
1076
+ originalError: result.output
1077
+ }
1078
+ },
1079
+ null,
1080
+ 2
1081
+ )
1082
+ );
583
1083
  } else {
584
1084
  console.error(`\u2717 ${message}`);
585
1085
  console.error(` Next: ${userAction}`);
@@ -587,7 +1087,11 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
587
1087
  return EXIT.infra;
588
1088
  }
589
1089
  state.recovered.push(recoveryCode);
590
- state.mutations.push({ type: "runtime", action: tier === "build" ? "rebuilt" : "reloaded", reasonCode: initial.reasonCode });
1090
+ state.mutations.push({
1091
+ type: "runtime",
1092
+ action: tier === "build" ? "rebuilt" : "reloaded",
1093
+ reasonCode: initial.reasonCode
1094
+ });
591
1095
  return null;
592
1096
  }
593
1097
  const RUN_RECOVERY_CODE = {
@@ -603,35 +1107,36 @@ async function recoverRunInfra(adapter, target, json) {
603
1107
  }
604
1108
  if (adapter === "mobile") {
605
1109
  const { launchMobile } = await import("./launch/mobile.js");
606
- const platform = resolveRunMobilePlatform();
1110
+ const platform = process.env.PLATFORM === "android" ? "android" : "ios";
607
1111
  return launchMobile(target, platform, "quick", json);
608
1112
  }
609
1113
  return { status: 0, output: "headless run retry" };
610
1114
  }
611
- function resolveRunMobilePlatform() {
612
- if (process.env.PLATFORM === "android" || process.env.PLATFORM === "ios") {
613
- return process.env.PLATFORM;
614
- }
615
- return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL ? "android" : "ios";
616
- }
617
1115
  function readRunFailureText(result) {
618
1116
  try {
619
1117
  const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
620
1118
  const entries = Array.isArray(trace.entries) ? trace.entries : [];
621
- return entries.filter((entry) => entry && entry.ok === false && typeof entry.error === "string").map((entry) => entry.error).join("\n").trim();
1119
+ return entries.filter(
1120
+ (entry) => entry && entry.ok === false && typeof entry.error === "string"
1121
+ ).map((entry) => entry.error).join("\n").trim();
622
1122
  } catch {
623
1123
  return "";
624
1124
  }
625
1125
  }
626
- async function executeWithHealBounds(exec, adapter, target, heal, state, recover = () => recoverRunInfra(adapter, target, false)) {
1126
+ async function executeWithHealBounds(exec, adapter, target, heal, state, recover = () => recoverRunInfra(adapter, target, false), onRecovery = () => void 0) {
627
1127
  let result = await exec();
628
1128
  for (; ; ) {
629
1129
  if (result.status === "pass") return { result, violation: null };
630
- const violation = checkHealBounds(target, readRunFailureText(result), state);
1130
+ const violation = checkHealBounds(
1131
+ target,
1132
+ readRunFailureText(result),
1133
+ state
1134
+ );
631
1135
  if (violation !== null) return { result, violation };
632
1136
  if (heal === "off") return { result, violation: null };
633
1137
  const recoveryCode = RUN_RECOVERY_CODE[adapter];
634
1138
  state.attemptedRecoveries.push(recoveryCode);
1139
+ onRecovery(recoveryCode);
635
1140
  await recover();
636
1141
  result = await exec();
637
1142
  if (result.status === "pass") {
@@ -641,7 +1146,7 @@ async function executeWithHealBounds(exec, adapter, target, heal, state, recover
641
1146
  }
642
1147
  }
643
1148
  function emitHealViolation(json, command, result, violation, state, adapter) {
644
- const userAction = adapter === "core" && violation.code === "WALLET_STATE_REQUIRED" ? 'set MM_TEST_ACCOUNT_ADDRESS=<0x\u2026> in env, or add "account": "<0x\u2026>" to the node block in the recipe' : violation.userAction;
1149
+ const userAction = adapter === "core" && violation.code === "WALLET_STATE_REQUIRED" ? 'create temp/recipe/runtime/wallet-fixture.json, or add "account": "<0x\u2026>" to the node block in the recipe' : violation.userAction ?? `inspect ${shellQuoteArg(result.summaryPath)} and ${shellQuoteArg(result.tracePath)}; fix the application or recipe failure before retrying`;
645
1150
  if (json) {
646
1151
  console.log(
647
1152
  JSON.stringify(
@@ -661,7 +1166,7 @@ function emitHealViolation(json, command, result, violation, state, adapter) {
661
1166
  code: violation.code,
662
1167
  message: violation.message,
663
1168
  retryable: false,
664
- userAction: userAction ?? null,
1169
+ userAction,
665
1170
  originalError: violation.originalError ?? null
666
1171
  }
667
1172
  },
@@ -670,8 +1175,9 @@ function emitHealViolation(json, command, result, violation, state, adapter) {
670
1175
  )
671
1176
  );
672
1177
  } else {
1178
+ const message = violation.code === "APP_LOGIC_FAILURE" && violation.originalError ? conciseFailureForHuman(violation.originalError) : violation.message;
673
1179
  console.error(
674
- `\u2717 mm-harness ${command}: ${violation.message}` + (violation.originalError ? `
1180
+ `\u2717 mm-harness ${command}: ${message}` + (violation.originalError && violation.code !== "APP_LOGIC_FAILURE" ? `
675
1181
  --- original failure ---
676
1182
  ${violation.originalError}` : "") + (userAction ? `
677
1183
  Next: ${userAction}` : "")
@@ -681,17 +1187,19 @@ ${violation.originalError}` : "") + (userAction ? `
681
1187
  }
682
1188
  function countRecipeNodes(recipe) {
683
1189
  if (!isRecord(recipe)) return void 0;
684
- const validate = isRecord(recipe.validate) ? recipe.validate : void 0;
685
- const workflow = validate && isRecord(validate.workflow) ? validate.workflow : void 0;
1190
+ const workflow = isRecord(recipe.workflow) ? recipe.workflow : void 0;
686
1191
  const nodes = workflow && isRecord(workflow.nodes) ? workflow.nodes : void 0;
687
1192
  return nodes ? Object.keys(nodes).length : void 0;
688
1193
  }
689
1194
  export {
690
1195
  countRecipeNodes,
1196
+ describeRunnableRecipe,
691
1197
  emitHealViolation,
692
1198
  ensureExtensionProofRuntime,
693
1199
  ensureMobileProofRuntime,
694
1200
  executeWithHealBounds,
1201
+ listRunnableRecipes,
1202
+ preflightRecipe,
695
1203
  prepareHeal,
696
1204
  recoverRunInfra,
697
1205
  resolveMetaMaskLibrarySources,