@deeeed/metamask-harness 0.17.4 → 0.18.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 (93) hide show
  1. package/CHANGELOG.md +775 -0
  2. package/README.md +65 -71
  3. package/adapters/extension/ensure-browser.sh +12 -1
  4. package/adapters/extension/inject.mjs +7 -0
  5. package/adapters/extension/launch-browser.cjs +10 -3
  6. package/adapters/extension/lib/chrome-args.cjs +31 -0
  7. package/adapters/extension/lib/macos-focus.cjs +32 -0
  8. package/adapters/extension/live.sh +10 -20
  9. package/adapters/manifest.json +8 -0
  10. package/adapters/mobile/open-device.sh +45 -7
  11. package/adapters/mobile/verify.sh +15 -3
  12. package/adapters/shared/harness-source-fingerprint.mjs +49 -0
  13. package/adapters/shared/install-repo-deps.sh +1 -5
  14. package/adapters/shared/open-debug.mjs +184 -103
  15. package/adapters/shared/resolve-slot-ports-core.mjs +23 -6
  16. package/adapters/shared/resolve-slot-ports.sh +22 -5
  17. package/bin/mm-harness +15 -3
  18. package/dist/adapters/core/surface.js +4 -1
  19. package/dist/adapters/extension/console-capture.js +3 -1
  20. package/dist/adapters/extension/harness-freshness.js +39 -0
  21. package/dist/adapters/extension/product-config.js +110 -0
  22. package/dist/adapters/extension/runtime-decision.js +20 -71
  23. package/dist/adapters/extension/surface.js +19 -1
  24. package/dist/adapters/mobile/prepare.js +17 -0
  25. package/dist/adapters/mobile/source-freshness.js +26 -41
  26. package/dist/adapters/mobile/surface.js +4 -1
  27. package/dist/adapters/resolve-slot-ports.js +2 -0
  28. package/dist/adapters/slot-ports.js +13 -32
  29. package/dist/adapters.js +50 -17
  30. package/dist/checkout-lock.js +27 -2
  31. package/dist/cli-color.js +19 -0
  32. package/dist/cli-commands.js +1 -1
  33. package/dist/cli.js +2 -3
  34. package/dist/command-contract.js +13 -3
  35. package/dist/commands/call.js +115 -29
  36. package/dist/commands/checklist.js +4 -1
  37. package/dist/commands/completion-candidates.js +20 -13
  38. package/dist/commands/debug.js +31 -38
  39. package/dist/commands/doctor.js +33 -6
  40. package/dist/commands/fixtures.js +65 -17
  41. package/dist/commands/flows.js +39 -10
  42. package/dist/commands/launch/extension.js +40 -15
  43. package/dist/commands/launch/index.js +41 -5
  44. package/dist/commands/list-executables.js +151 -29
  45. package/dist/commands/manifest.js +127 -18
  46. package/dist/commands/parse-args.js +11 -1
  47. package/dist/commands/run-engine.js +384 -56
  48. package/dist/commands/run.js +112 -17
  49. package/dist/commands/shared.js +22 -2
  50. package/dist/commands/status-probe.js +3 -0
  51. package/dist/commands/status.js +1 -0
  52. package/dist/completions-cache.js +1 -1
  53. package/dist/doctor.js +56 -6
  54. package/dist/harness.js +6 -5
  55. package/dist/heal-bounds.js +1 -1
  56. package/dist/live-adapter-contract.js +132 -12
  57. package/dist/manifest.js +161 -1
  58. package/dist/mm-harness-cli.js +13 -7
  59. package/dist/recipe-security.js +178 -0
  60. package/dist/runner.js +87 -13
  61. package/dist/runtime-context.js +8 -26
  62. package/docs/CONTRIBUTING.md +137 -0
  63. package/docs/QA.md +185 -0
  64. package/docs/RECIPES.md +161 -0
  65. package/docs/SECURITY.md +88 -0
  66. package/library/actions/core/perps/read_account.mjs +2 -2
  67. package/library/actions/core/perps/read_orders.mjs +2 -1
  68. package/library/actions/core/perps/read_positions.mjs +2 -1
  69. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  70. package/library/actions/extension/platform/cdp.mjs +1 -0
  71. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  72. package/library/actions/mobile/platform/bridge.mjs +1 -5
  73. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  74. package/library/manifests/core.action-manifest.json +61 -0
  75. package/library/manifests/extension.action-manifest.json +53 -0
  76. package/library/manifests/mobile.action-manifest.json +53 -0
  77. package/library/recipes/runner/action-validation.extension.recipe.json +8 -1
  78. package/library/recipes/runner/action-validation.mobile.recipe.json +8 -1
  79. package/package.json +10 -6
  80. package/scripts/completions.sh +7 -7
  81. package/docs/ADAPTER-SURFACE.md +0 -26
  82. package/docs/CHEATSHEET.md +0 -48
  83. package/docs/CLI-ERGONOMICS-AUDIT.md +0 -32
  84. package/docs/CLI-ERGONOMICS-HUMAN-QA.md +0 -64
  85. package/docs/CODE-MAP.md +0 -62
  86. package/docs/UX-PRINCIPLES.md +0 -66
  87. package/docs/VIDEO-DEMO-VALIDATION.md +0 -74
  88. package/docs/architecture.md +0 -88
  89. package/docs/live-adapter-contract.md +0 -190
  90. package/docs/package-boundaries.md +0 -47
  91. package/docs/perps-flow-catalog.md +0 -235
  92. package/docs/recipe-libraries.md +0 -71
  93. package/docs/runtime-file-conventions.md +0 -36
@@ -7,7 +7,6 @@ import { checkoutBusyOut, EXIT, usageOut, writeInteractiveProgress } from "./sha
7
7
  import {
8
8
  optionFlag,
9
9
  optionString,
10
- requiredOption,
11
10
  resolveAdapter,
12
11
  runtimeOptionsFromCli,
13
12
  isRecord,
@@ -19,12 +18,13 @@ import {
19
18
  countRecipeNodes,
20
19
  emitHealViolation,
21
20
  executeWithHealBounds,
21
+ preflightRecipe,
22
22
  prepareHeal,
23
23
  recoverRunInfra,
24
24
  runRecipe,
25
25
  validateRunRecipeStatic
26
26
  } from "./run-engine.js";
27
- import { handleListExecutables } from "./list-executables.js";
27
+ import { handleDescribeRecipe, handleListExecutables } from "./list-executables.js";
28
28
  import { applyDeviceTargeting } from "./device-target.js";
29
29
  import { getAdapterSurface } from "../adapters/surface.js";
30
30
  import { coreDependencyBlock } from "./core-readiness.js";
@@ -32,6 +32,7 @@ import { writeRunReport } from "./run-report.js";
32
32
  import { acquireCheckoutLock } from "../checkout-lock.js";
33
33
  import { JsonStreamWriter } from "../json-stream.js";
34
34
  import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run-diagnostics.js";
35
+ import { recipeTrustFailure } from "../recipe-security.js";
35
36
  async function handleRun(parsed) {
36
37
  const stream = new JsonStreamWriter("run", optionFlag(parsed.options, "jsonStream"));
37
38
  const restoreStdout = stream.isolateStdout();
@@ -41,6 +42,36 @@ async function handleRun(parsed) {
41
42
  stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode);
42
43
  return exitCode;
43
44
  } catch (error) {
45
+ const trustFailure = recipeTrustFailure(error);
46
+ if (trustFailure) {
47
+ if (stream.enabled) {
48
+ stream.error(trustFailure);
49
+ stream.complete("fail", EXIT.validation);
50
+ } else if (optionFlag(parsed.options, "json")) {
51
+ console.log(JSON.stringify({
52
+ schemaVersion: 1,
53
+ command: "run",
54
+ status: "fail",
55
+ error: trustFailure,
56
+ exitCode: EXIT.validation
57
+ }, null, 2));
58
+ } else {
59
+ console.error(`\u2717 mm-harness run: ${trustFailure.message}`);
60
+ const blocked = trustFailure.details?.blocked;
61
+ if (blocked?.length) {
62
+ console.error(" Restricted plan nodes:");
63
+ for (const node of blocked.slice(0, 10)) {
64
+ const implementation = node.implementation?.digest ? ` implementation=${node.implementation.kind ?? "custom"}@${node.implementation.digest}` : "";
65
+ console.error(
66
+ ` - ${node.nodeId}: ${node.action} [${node.capabilities.join(", ")}] source=${node.source}${implementation}`
67
+ );
68
+ }
69
+ if (blocked.length > 10) console.error(` - \u2026 ${blocked.length - 10} more (use --json)`);
70
+ }
71
+ console.error(` Next: ${trustFailure.userAction}`);
72
+ }
73
+ return EXIT.validation;
74
+ }
44
75
  const exitCode = error !== null && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number" ? error.exitCode : EXIT.runtime;
45
76
  stream.error({
46
77
  code: exitCode === EXIT.usage ? "CLI_USAGE_ERROR" : "RUN_FAILED",
@@ -54,6 +85,15 @@ async function handleRun(parsed) {
54
85
  }
55
86
  }
56
87
  async function handleRunInner({ positional, options }, stream) {
88
+ if (optionFlag(options, "describe") && (optionFlag(options, "list") || optionFlag(options, "plan"))) {
89
+ const message = "--describe cannot be combined with --list or --plan.";
90
+ const userAction = "choose one: mm-harness run --list, mm-harness run <recipe> --describe, or mm-harness run <recipe> --plan";
91
+ if (stream.enabled) {
92
+ stream.error({ code: "CLI_USAGE_ERROR", message, userAction });
93
+ return EXIT.usage;
94
+ }
95
+ return usageOut(optionFlag(options, "json"), "run", message, userAction);
96
+ }
57
97
  if (optionFlag(options, "list")) {
58
98
  if (stream.enabled) {
59
99
  const message = "--json-stream is for recipe execution; use run --list --json for discovery.";
@@ -64,6 +104,18 @@ async function handleRunInner({ positional, options }, stream) {
64
104
  }
65
105
  const targetRecipe = positional[0];
66
106
  if (!targetRecipe) throw usageError("run requires <recipe.json>.");
107
+ if (optionFlag(options, "describe")) {
108
+ if (stream.enabled) {
109
+ const message = "--json-stream is for recipe execution; use --describe --json for discovery.";
110
+ stream.error({
111
+ code: "JSON_STREAM_UNSUPPORTED_MODE",
112
+ message,
113
+ userAction: `mm-harness run ${shellQuote(targetRecipe)} --describe --json`
114
+ });
115
+ return EXIT.usage;
116
+ }
117
+ return handleDescribeRecipe(targetRecipe, options);
118
+ }
67
119
  if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options, stream);
68
120
  const { adapter, target } = resolveAdapter(options);
69
121
  const json = optionFlag(options, "json");
@@ -86,9 +138,6 @@ async function handleRunInner({ positional, options }, stream) {
86
138
  return emitRunUsageError(jsonOutput, stream, adapter, targetRecipe, dtResult.code, dtResult.message, dtResult.userAction);
87
139
  }
88
140
  if (recipeRunning(target)) return emitRunRecipeRunning(jsonOutput, stream, target);
89
- if (optionString(options, "artifactsDir") === void 0 && runArgLooksLikeRecipeFile(targetRecipe)) {
90
- requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
91
- }
92
141
  stream.phase("validate");
93
142
  const validated = await validateRunRecipeStatic(targetRecipe, adapter, options);
94
143
  if (validated.usageError) {
@@ -115,7 +164,37 @@ async function handleRunInner({ positional, options }, stream) {
115
164
  if (depsBlock) {
116
165
  return emitRunUsageError(jsonOutput, stream, adapter, validated.recipeFile, depsBlock.code, depsBlock.message, depsBlock.userAction);
117
166
  }
118
- const artifactsDir = requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
167
+ let artifactsDir;
168
+ try {
169
+ artifactsDir = resolveRunArtifactsDir(target, optionString(options, "artifactsDir"));
170
+ } catch (error) {
171
+ const message = error instanceof Error ? error.message : String(error);
172
+ return emitRunUsageError(
173
+ jsonOutput,
174
+ stream,
175
+ adapter,
176
+ validated.recipeFile,
177
+ "ARTIFACT_DIR_INVALID",
178
+ message,
179
+ "set RECIPE_TASK_DIR/FARMSLOT_TASK_DIR inside the checkout or pass --artifacts-dir <path>"
180
+ );
181
+ }
182
+ const librarySources = validated.librarySources;
183
+ const runtimeOptions = {
184
+ ...runtimeOptionsFromCli(options),
185
+ ...librarySources ? { librarySources } : {},
186
+ stdoutIsMachineContract: machine,
187
+ onActionEvent: ({ nodeId, action, status }) => stream.node(nodeId, action, status)
188
+ };
189
+ stream.phase("authorize");
190
+ let preflightedExecution = await preflightRecipe(
191
+ adapter,
192
+ validated.recipeFile,
193
+ artifactsDir,
194
+ target,
195
+ optionString(options, "actionManifest"),
196
+ runtimeOptions
197
+ );
119
198
  const lock = acquireCheckoutLock(target, "run");
120
199
  if ("message" in lock) {
121
200
  const userAction = `wait for the current owner, or inspect ${lock.path} if its process has exited`;
@@ -135,18 +214,23 @@ async function handleRunInner({ positional, options }, stream) {
135
214
  return prepared;
136
215
  }
137
216
  const { state, heal } = prepared;
138
- const librarySources = validated.librarySources;
139
- const runtimeOptions = {
140
- ...runtimeOptionsFromCli(options),
141
- ...librarySources ? { librarySources } : {},
142
- stdoutIsMachineContract: machine,
143
- onActionEvent: ({ nodeId, action, status }) => stream.node(nodeId, action, status)
144
- };
145
217
  stream.phase("execute");
146
218
  const { result, violation } = await executeWithHealBounds(
147
219
  // validated.recipeFile, not the raw arg: the arg may be a library recipe NAME
148
220
  // that only the resolver knows how to turn into a file.
149
- () => runRecipe(adapter, validated.recipeFile, artifactsDir, target, optionString(options, "actionManifest"), runtimeOptions),
221
+ () => {
222
+ const execution = preflightedExecution;
223
+ preflightedExecution = void 0;
224
+ return runRecipe(
225
+ adapter,
226
+ validated.recipeFile,
227
+ artifactsDir,
228
+ target,
229
+ optionString(options, "actionManifest"),
230
+ runtimeOptions,
231
+ execution
232
+ );
233
+ },
150
234
  adapter,
151
235
  target,
152
236
  heal,
@@ -238,6 +322,20 @@ function formatDiagnosticLine(line, out) {
238
322
  const style = status === "CLEAN" ? "ok" : status === "ERROR" || status === "EXCEPTION" ? "err" : "warn";
239
323
  return `${out(style, status)}${match[2]}`;
240
324
  }
325
+ function resolveRunArtifactsDir(target, explicit) {
326
+ if (explicit !== void 0) return path.resolve(explicit);
327
+ const taskDir = process.env.RECIPE_TASK_DIR || process.env.FARMSLOT_TASK_DIR;
328
+ if (taskDir) {
329
+ const resolvedTask = path.resolve(target, taskDir);
330
+ const relative = path.relative(path.resolve(target), resolvedTask);
331
+ if (!relative.startsWith(`..${path.sep}`) && relative !== "..") {
332
+ return path.join(resolvedTask, "artifacts");
333
+ }
334
+ throw new Error(`task directory must be inside the target checkout: ${taskDir}`);
335
+ }
336
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/gu, "");
337
+ return path.join(target, "temp", "recipe", "runs", `${stamp}-${process.pid}`);
338
+ }
241
339
  function runArtifactInventory(manifestPathValue) {
242
340
  const manifestPath = path.resolve(String(manifestPathValue));
243
341
  const root = path.dirname(manifestPath);
@@ -468,9 +566,6 @@ function emitPlanUsageError(json, stream, adapter, recipeFile, code, message, us
468
566
  }
469
567
  return EXIT.usage;
470
568
  }
471
- function runArgLooksLikeRecipeFile(value) {
472
- return path.isAbsolute(value) || value.includes("/") || value.includes(path.sep) || value.endsWith(".json") || fs.existsSync(path.resolve(value));
473
- }
474
569
  function emitRunRecipeRunning(json, stream, target, detail) {
475
570
  const message = detail ?? "a recipe is currently running \u2014 refusing to start while another recipe executes.";
476
571
  const userAction = `inspect the checkout state with: mm-harness status --target ${shellQuote(target)} --json; retry after the active recipe finishes`;
@@ -1,6 +1,7 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import path from "node:path";
3
3
  import { detectAdapter } from "../harness.js";
4
+ import { colorHumanMessage } from "../cli-color.js";
4
5
  import { resolveLeafInvoke, shellLeafMissing } from "../leaf-invoke.js";
5
6
  const EXIT = { ok: 0, runtime: 1, usage: 2, infra: 3, bounded: 4, validation: 5 };
6
7
  function writeInteractiveProgress(json, message, {
@@ -8,7 +9,7 @@ function writeInteractiveProgress(json, message, {
8
9
  stream = process.stderr
9
10
  } = {}) {
10
11
  if (json || !stdoutIsTTY) return false;
11
- stream.write(`${message}
12
+ stream.write(`${colorHumanMessage(message, { stream })}
12
13
  `);
13
14
  return true;
14
15
  }
@@ -147,6 +148,8 @@ function spawnScriptStreaming(script, args, cwd, options) {
147
148
  let settled = false;
148
149
  let didTimeout = false;
149
150
  let escalation;
151
+ let forwardedParentSignal;
152
+ let parentSignalFallback;
150
153
  const parentSignals = ["SIGINT", "SIGTERM"];
151
154
  const removeParentSignalHandlers = () => {
152
155
  for (const signal of parentSignals) process.removeListener(signal, parentSignalHandlers[signal]);
@@ -156,6 +159,7 @@ function spawnScriptStreaming(script, args, cwd, options) {
156
159
  settled = true;
157
160
  if (timer) clearTimeout(timer);
158
161
  if (escalation) clearTimeout(escalation);
162
+ if (parentSignalFallback) clearTimeout(parentSignalFallback);
159
163
  removeParentSignalHandlers();
160
164
  resolve(result);
161
165
  };
@@ -166,10 +170,22 @@ function spawnScriptStreaming(script, args, cwd, options) {
166
170
  } catch {
167
171
  }
168
172
  };
173
+ const exitFromForwardedSignal = () => {
174
+ if (!forwardedParentSignal) return;
175
+ const signal = forwardedParentSignal;
176
+ forwardedParentSignal = void 0;
177
+ if (parentSignalFallback) clearTimeout(parentSignalFallback);
178
+ process.kill(process.pid, signal);
179
+ };
169
180
  const parentSignalHandlers = Object.fromEntries(parentSignals.map((signal) => [signal, () => {
181
+ if (forwardedParentSignal) return;
182
+ forwardedParentSignal = signal;
170
183
  signalChildTree(signal);
171
184
  removeParentSignalHandlers();
172
- process.kill(process.pid, signal);
185
+ parentSignalFallback = setTimeout(() => {
186
+ signalChildTree("SIGKILL");
187
+ exitFromForwardedSignal();
188
+ }, 1e3);
173
189
  }]));
174
190
  if (ownsProcessGroup) {
175
191
  for (const signal of parentSignals) process.once(signal, parentSignalHandlers[signal]);
@@ -205,6 +221,10 @@ function spawnScriptStreaming(script, args, cwd, options) {
205
221
  finish({ status: 1, output: message });
206
222
  });
207
223
  child.on("exit", () => {
224
+ if (forwardedParentSignal) {
225
+ exitFromForwardedSignal();
226
+ return;
227
+ }
208
228
  if (!didTimeout) return;
209
229
  signalChildTree("SIGKILL");
210
230
  finish({ status: 1, output, timedOut: true, timeoutMs: Number(timeoutMs) });
@@ -22,6 +22,9 @@ function routeToString(route) {
22
22
  function deriveWalletState(account, route) {
23
23
  const routeStr = (routeToString(route) ?? "").toLowerCase();
24
24
  if (routeStr.includes("onboard")) return "onboarding";
25
+ if (routeStr === "login" || routeStr === "lockscreen") {
26
+ return "locked";
27
+ }
25
28
  if (account !== null && account !== void 0 && typeof account === "object") return "unlocked";
26
29
  return "locked";
27
30
  }
@@ -104,6 +104,7 @@ function renderRuntimeStatus(runtime, out) {
104
104
  );
105
105
  }
106
106
  function nextForRuntime(launchHint, relaunchHint, runtime) {
107
+ if (runtime.nextAction) return runtime.nextAction;
107
108
  if (runtime.decision === "ready") return "mm-harness logs";
108
109
  if (runtime.decision === "relaunch") return launchHint;
109
110
  return relaunchHint;
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { recipeRuntimePath } from "./paths.js";
4
- const COMPLETION_CACHE_VERSION = 1;
4
+ const COMPLETION_CACHE_VERSION = 2;
5
5
  const COMPLETION_CACHE_TTL_MS = 6e4;
6
6
  function completionCachePath(projectRoot) {
7
7
  return recipeRuntimePath(projectRoot, ".completion-cache.json");
package/dist/doctor.js CHANGED
@@ -155,12 +155,7 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
155
155
  requiredChecks,
156
156
  adapter,
157
157
  target,
158
- runner: {
159
- name: "@metamask/recipe-runner",
160
- runnerDir,
161
- actionManifestPath,
162
- harnessPackage: "@farmslot/recipe-harness"
163
- },
158
+ runner: runnerProvenance(actionManifestPath),
164
159
  compatibilityMode: mode,
165
160
  shape: repoShape(target),
166
161
  fixture: fixtureSummary(target, adapter),
@@ -169,6 +164,60 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
169
164
  manifestValidation: manifestValidation.summary
170
165
  };
171
166
  }
167
+ function runnerInstallKind(runnerRoot, invokedPath, executablePath) {
168
+ const normalizedRoot = path.normalize(runnerRoot);
169
+ const nodeModulesSegment = `${path.sep}node_modules${path.sep}`;
170
+ const globalNodeModulesSegment = `${path.sep}lib${path.sep}node_modules${path.sep}`;
171
+ if (normalizedRoot.includes(globalNodeModulesSegment)) return "global-install";
172
+ const nodeModulesIndex = normalizedRoot.lastIndexOf(nodeModulesSegment);
173
+ if (nodeModulesIndex >= 0) {
174
+ const nodeModulesRoot = normalizedRoot.slice(
175
+ 0,
176
+ nodeModulesIndex + nodeModulesSegment.length - 1
177
+ );
178
+ const invoked = path.resolve(invokedPath);
179
+ const projectBin = `${path.join(nodeModulesRoot, ".bin")}${path.sep}`;
180
+ if (invoked === path.resolve(executablePath) || invoked.startsWith(projectBin)) {
181
+ return "project-install";
182
+ }
183
+ return "global-install";
184
+ }
185
+ if (path.resolve(invokedPath) !== path.resolve(executablePath)) return "local-link";
186
+ return "source-checkout";
187
+ }
188
+ function runnerProvenance(actionManifestPath) {
189
+ const packagePath = path.join(runnerDir, "package.json");
190
+ const packageDocument = readJsonObject(packagePath);
191
+ const packageName = packageDocument.name;
192
+ const version = packageDocument.version;
193
+ if (packageName !== "@deeeed/metamask-harness") {
194
+ throw new Error(`Expected @deeeed/metamask-harness package name in ${packagePath}`);
195
+ }
196
+ if (typeof version !== "string" || !version) {
197
+ throw new Error(`Expected package version in ${packagePath}`);
198
+ }
199
+ const executablePath = path.resolve(
200
+ process.env.MM_HARNESS_EXECUTABLE ?? path.join(runnerDir, "bin/mm-harness")
201
+ );
202
+ const invokedPath = path.resolve(process.env.MM_HARNESS_INVOKED_AS ?? executablePath);
203
+ const installKind = runnerInstallKind(runnerDir, invokedPath, executablePath);
204
+ const packageSource = runnerDir.includes(`${path.sep}node_modules${path.sep}`) ? "node_modules" : "source-checkout";
205
+ return {
206
+ name: "@metamask/recipe-runner",
207
+ packageName,
208
+ version,
209
+ runnerDir,
210
+ packagePath,
211
+ packageSource,
212
+ installKind,
213
+ linked: installKind === "local-link",
214
+ global: installKind === "global-install",
215
+ invokedPath,
216
+ executablePath,
217
+ actionManifestPath,
218
+ harnessPackage: "@farmslot/recipe-harness"
219
+ };
220
+ }
172
221
  function readJsonObject(file) {
173
222
  const value = readJson(file);
174
223
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -184,5 +233,6 @@ export {
184
233
  renderRuntimeContext,
185
234
  repoShape,
186
235
  requiredDoctorCheckSummary,
236
+ runnerInstallKind,
187
237
  runtimeContextSummary
188
238
  };
package/dist/harness.js CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { resolveLeafInvoke, shellLeafMissing, missingShellLeafMessage } from "./leaf-invoke.js";
5
5
  import { resolveExtensionSlotPorts } from "./adapters/slot-ports.js";
6
6
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "./paths.js";
7
+ import { colorHumanMessage } from "./cli-color.js";
7
8
  const HARNESS_ACTIONS = ["install", "verify", "cleanup"];
8
9
  const ADAPTERS = ["mobile", "extension", "core"];
9
10
  function harnessUsage() {
@@ -332,7 +333,7 @@ async function handleHarness(argv) {
332
333
  }
333
334
  if (!json && !quiet) {
334
335
  const detected = autoDetected ? ", auto-detected" : "";
335
- console.error(`\u2192 ${harnessAction} (${adapter}${detected}) \u2014 target: ${target}`);
336
+ console.error(colorHumanMessage(`\u2192 ${harnessAction} (${adapter}${detected}) \u2014 target: ${target}`));
336
337
  }
337
338
  const start = Date.now();
338
339
  if (shellLeafMissing(dispatch.command)) {
@@ -396,11 +397,11 @@ async function handleHarness(argv) {
396
397
  )
397
398
  );
398
399
  } else if (exitCode === 0) {
399
- console.error(`\u2713 ${harnessAction} ${adapter} passed (${seconds}s)
400
- Next: ${successNext(adapter, harnessAction, target)}`);
400
+ console.error(colorHumanMessage(`\u2713 ${harnessAction} ${adapter} passed (${seconds}s)
401
+ Next: ${successNext(adapter, harnessAction, target)}`));
401
402
  } else {
402
- console.error(`\u2717 ${harnessAction} ${adapter} failed (exit ${exitCode}, ${seconds}s)
403
- ${failureHint(adapter, harnessAction)}`);
403
+ console.error(colorHumanMessage(`\u2717 ${harnessAction} ${adapter} failed (exit ${exitCode}, ${seconds}s)
404
+ ${failureHint(adapter, harnessAction)}`));
404
405
  }
405
406
  return exitCode;
406
407
  }
@@ -45,7 +45,7 @@ async function ensureOverlay(adapter, target, heal, state, json) {
45
45
  });
46
46
  code = result.status ?? 1;
47
47
  } else {
48
- code = await handleHarness(["install", "--platform", adapter, "--target", target, ...json ? ["--json"] : []]);
48
+ code = await handleHarness(["install", "--platform", adapter, "--target", target, "--quiet"]);
49
49
  }
50
50
  if (code !== 0 || !overlayPresent(target, adapter)) {
51
51
  return { ok: false, error: `runtime overlay install failed (exit ${code})` };
@@ -1,8 +1,12 @@
1
- import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { createHash } from "node:crypto";
2
+ import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { spawn } from "node:child_process";
7
+ import { pathToFileURL } from "node:url";
8
+ import { build } from "esbuild";
9
+ import { init, parse } from "es-module-lexer";
6
10
  import { resolveRequiredLocalProtocolRoot, runnerDir } from "./paths.js";
7
11
  import { corePnpNodeOptions } from "./commands/core-readiness.js";
8
12
  function actionFileStem(action) {
@@ -31,13 +35,17 @@ function candidateFamilies(action) {
31
35
  const families = [family];
32
36
  return [...new Set(families)];
33
37
  }
34
- function candidatePaths(platform, action) {
38
+ function candidatePaths(platform, action, runtimeEnv = process.env) {
35
39
  const stems = candidateStems(action);
36
40
  const families = candidateFamilies(action);
37
- const roots = [
38
- process.env.METAMASK_RECIPE_LIVE_ADAPTER_DIR,
41
+ const declaredRoot = declaredActionRoot(
42
+ runtimeEnv.METAMASK_RECIPE_ACTION_SOURCE_MAP ?? process.env.METAMASK_RECIPE_ACTION_SOURCE_MAP,
43
+ action
44
+ );
45
+ const roots = [...new Set((declaredRoot ? [declaredRoot] : [
46
+ runtimeEnv.METAMASK_RECIPE_LIVE_ADAPTER_DIR ?? process.env.METAMASK_RECIPE_LIVE_ADAPTER_DIR,
39
47
  path.join(runnerDir, "library/actions")
40
- ].filter(Boolean);
48
+ ]).filter((entry) => Boolean(entry)))];
41
49
  const files = [];
42
50
  for (const root of roots) {
43
51
  for (const family of families) {
@@ -49,6 +57,16 @@ function candidatePaths(platform, action) {
49
57
  }
50
58
  return files;
51
59
  }
60
+ function declaredActionRoot(raw, action) {
61
+ if (!raw) return void 0;
62
+ try {
63
+ const parsed = JSON.parse(raw);
64
+ const root = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed[action] : void 0;
65
+ return typeof root === "string" && root.length > 0 ? root : void 0;
66
+ } catch {
67
+ return void 0;
68
+ }
69
+ }
52
70
  function pushCandidateFiles(files, root, platform, family, stem) {
53
71
  files.push(path.join(root, platform, family, `${stem}.mjs`));
54
72
  files.push(path.join(root, "shared", family, `${stem}.mjs`));
@@ -74,8 +92,16 @@ function runProcess(command, args, options) {
74
92
  const child = spawn(command, args, {
75
93
  cwd: options.cwd,
76
94
  env: options.env,
77
- stdio: ["ignore", "pipe", "pipe"]
95
+ stdio: [options.stdin == null ? "ignore" : "pipe", "pipe", "pipe"]
78
96
  });
97
+ if (options.stdin != null) {
98
+ child.stdin.on("error", (error) => {
99
+ if (error.code === "EPIPE" || settled) return;
100
+ settled = true;
101
+ reject(error);
102
+ });
103
+ child.stdin.end(options.stdin);
104
+ }
79
105
  let stdout = "";
80
106
  let stderr = "";
81
107
  child.stdout.on("data", (chunk) => {
@@ -106,6 +132,79 @@ function runProcess(command, args, options) {
106
132
  });
107
133
  });
108
134
  }
135
+ async function prepareLiveAdapterScript({
136
+ platform,
137
+ action,
138
+ implementationRoot,
139
+ allowRuntimeImports = false
140
+ }) {
141
+ const entryPath = await firstExecutablePath(
142
+ candidatePaths(platform, action, {
143
+ METAMASK_RECIPE_ACTION_SOURCE_MAP: JSON.stringify({ [action]: implementationRoot })
144
+ })
145
+ );
146
+ if (!entryPath) return null;
147
+ const rootReal = await realpath(implementationRoot);
148
+ const entryReal = await realpath(entryPath);
149
+ if (!isPathWithin(rootReal, entryReal)) {
150
+ throw new Error(`Live adapter ${entryPath} resolves outside ${implementationRoot}.`);
151
+ }
152
+ const result = await build({
153
+ entryPoints: [entryReal],
154
+ bundle: true,
155
+ write: false,
156
+ platform: "node",
157
+ format: "esm",
158
+ target: `node${process.versions.node.split(".")[0]}`,
159
+ legalComments: "none",
160
+ sourcemap: false,
161
+ banner: { js: `process.argv[1] = ${JSON.stringify(entryReal)};` },
162
+ plugins: [bindImportMetaUrls()],
163
+ logLevel: "silent"
164
+ });
165
+ const output = result.outputFiles[0];
166
+ if (!output) throw new Error(`Bundling live adapter ${entryReal} produced no executable output.`);
167
+ const sourceText = output.text;
168
+ await init;
169
+ const [imports] = parse(sourceText);
170
+ if (!allowRuntimeImports && imports.some((entry) => entry.d >= 0)) {
171
+ const error = new Error(
172
+ `Live adapter ${entryReal} contains a runtime dynamic import that cannot be bound to the approved bundle. Replace it with a static import so its implementation bytes are included in the plan digest.`
173
+ );
174
+ Object.assign(error, {
175
+ code: "RECIPE_TRUST_UNBOUND_IMPORT",
176
+ userAction: "replace the computed import with a static import, or promote the adapter into an explicitly trusted engineer-owned library"
177
+ });
178
+ throw error;
179
+ }
180
+ return {
181
+ action,
182
+ platform,
183
+ entryPath: entryReal,
184
+ sourceText,
185
+ sourceDigest: `sha256:${createHash("sha256").update(sourceText).digest("hex")}`
186
+ };
187
+ }
188
+ function bindImportMetaUrls() {
189
+ return {
190
+ name: "bind-import-meta-urls",
191
+ setup(buildContext) {
192
+ buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
193
+ const contents = await readFile(args.path, "utf8");
194
+ if (!contents.includes("import.meta.url")) return void 0;
195
+ const extension = path.extname(args.path);
196
+ const loader = extension === ".ts" || extension === ".mts" || extension === ".cts" ? "ts" : extension === ".tsx" ? "tsx" : extension === ".jsx" ? "jsx" : "js";
197
+ return {
198
+ contents: contents.replaceAll(
199
+ "import.meta.url",
200
+ JSON.stringify(pathToFileURL(args.path).href)
201
+ ),
202
+ loader
203
+ };
204
+ });
205
+ }
206
+ };
207
+ }
109
208
  function commandFor(file, projectRoot, platform) {
110
209
  const needsTsx = platform === "core" || importsSourceTypescript(file);
111
210
  if (!needsTsx) {
@@ -158,8 +257,8 @@ function importsSourceTypescriptFrom(file, visited) {
158
257
  }
159
258
  return false;
160
259
  }
161
- async function resolveLiveAdapter(platform, action) {
162
- return firstExecutablePath(candidatePaths(platform, action));
260
+ async function resolveLiveAdapter(platform, action, runtimeEnv) {
261
+ return firstExecutablePath(candidatePaths(platform, action, runtimeEnv));
163
262
  }
164
263
  const CORE_WORKSPACE_PACKAGES = [
165
264
  "@metamask/base-controller",
@@ -191,8 +290,8 @@ async function platformAdapterEnv(platform, projectRoot, tempDir) {
191
290
  NODE_OPTIONS: corePnpNodeOptions(projectRoot, process.env.NODE_OPTIONS)
192
291
  };
193
292
  }
194
- async function runLiveAdapterScript({ platform, action, node, context }) {
195
- const script = await resolveLiveAdapter(platform, action);
293
+ async function runLiveAdapterScript({ platform, action, node, context, prepared }) {
294
+ const script = prepared?.entryPath ?? await resolveLiveAdapter(platform, action, context?.env);
196
295
  if (!script) return null;
197
296
  const tempDir = await mkdtemp(path.join(os.tmpdir(), "mm-harness-live-adapter-"));
198
297
  const inputPath = path.join(tempDir, "input.json");
@@ -211,7 +310,7 @@ async function runLiveAdapterScript({ platform, action, node, context }) {
211
310
  };
212
311
  await writeFile(inputPath, `${JSON.stringify(input, null, 2)}
213
312
  `);
214
- const command = commandFor(script, context.projectRoot, platform);
313
+ const command = prepared ? commandForPrepared(context.projectRoot, platform) : commandFor(script, context.projectRoot, platform);
215
314
  const platformEnv = await platformAdapterEnv(platform, context.projectRoot, tempDir);
216
315
  const result = await runProcess(command.command, [...command.args, inputPath], {
217
316
  cwd: context.projectRoot,
@@ -222,7 +321,8 @@ async function runLiveAdapterScript({ platform, action, node, context }) {
222
321
  METAMASK_RECIPE_ADAPTER_INPUT: inputPath,
223
322
  METAMASK_RECIPE_ADAPTER_OUTPUT: outputPath
224
323
  },
225
- timeoutMs: Number(node.live_adapter_timeout_ms ?? node.timeout_ms ?? 6e4)
324
+ timeoutMs: Number(node.live_adapter_timeout_ms ?? node.timeout_ms ?? 6e4),
325
+ ...prepared ? { stdin: prepared.sourceText } : {}
226
326
  });
227
327
  try {
228
328
  if (result.timedOut) {
@@ -244,7 +344,27 @@ async function runLiveAdapterScript({ platform, action, node, context }) {
244
344
  await rm(tempDir, { recursive: true, force: true });
245
345
  }
246
346
  }
347
+ function commandForPrepared(projectRoot, platform) {
348
+ if (platform !== "core") {
349
+ return { command: process.execPath, args: ["--input-type=module", "-"] };
350
+ }
351
+ const tsxBin = resolveTsxBin(projectRoot);
352
+ if (!tsxBin) {
353
+ const error = new Error(
354
+ `this action imports TypeScript from the checkout but no tsx runtime was found.
355
+ Next: run 'yarn install' in ${projectRoot}`
356
+ );
357
+ error.exitCode = 2;
358
+ throw error;
359
+ }
360
+ return { command: tsxBin, args: ["--input-type=module", "-"] };
361
+ }
362
+ function isPathWithin(root, candidate) {
363
+ const relative = path.relative(root, candidate);
364
+ return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
365
+ }
247
366
  export {
367
+ prepareLiveAdapterScript,
248
368
  resolveLiveAdapter,
249
369
  runLiveAdapterScript
250
370
  };