@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
package/dist/manifest.js CHANGED
@@ -1,5 +1,10 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { manifestPath, readJson, importRecipeProtocol } from "./paths.js";
4
+ import {
5
+ actionSourceIntegrity,
6
+ metaMaskActionExecutionCapabilities
7
+ } from "./recipe-security.js";
3
8
  function loadMetaMaskMobileActionManifest() {
4
9
  return asActionManifest(readJson(manifestPath("mobile")));
5
10
  }
@@ -15,6 +20,130 @@ function loadActionManifest(adapter, overridePath) {
15
20
  if (adapter === "core") return loadMetaMaskCoreActionManifest();
16
21
  return loadMetaMaskExtensionActionManifest();
17
22
  }
23
+ async function resolveActionManifest(adapter, overridePath, librarySources, taskActionRoot) {
24
+ if (overridePath) {
25
+ const manifestPath2 = path.resolve(overridePath);
26
+ const implementationRoot = path.resolve(
27
+ taskActionRoot ?? defaultImplementationRoot(manifestPath2)
28
+ );
29
+ const manifest = withMetaMaskExecutionCapabilities(loadActionManifest(adapter, manifestPath2));
30
+ await validateManifest(manifest);
31
+ const integrity = await actionSourceIntegrity(manifestPath2, "task", implementationRoot);
32
+ return {
33
+ manifest,
34
+ actionSources: describeManifestSources(manifest, {
35
+ name: "task",
36
+ tier: "task",
37
+ manifestPath: manifestPath2,
38
+ implementationRoot,
39
+ trust: "unknown",
40
+ ...integrity
41
+ })
42
+ };
43
+ }
44
+ const canonicalPath = manifestPath(adapter);
45
+ const sources = librarySources?.length ? librarySources : [{ name: "metamask", root: path.dirname(path.dirname(canonicalPath)) }];
46
+ const manifests = [];
47
+ for (const source of sources) {
48
+ const file = path.join(source.root, "manifests", `${adapter}.action-manifest.json`);
49
+ if (!fs.existsSync(file)) continue;
50
+ const manifest = asActionManifest(readJson(file));
51
+ await validateManifest(manifest);
52
+ manifests.push({ source, file, manifest });
53
+ }
54
+ if (!manifests.some((entry) => path.resolve(entry.file) === path.resolve(canonicalPath))) {
55
+ const manifest = loadActionManifest(adapter);
56
+ await validateManifest(manifest);
57
+ manifests.push({ source: { name: "metamask", root: path.dirname(path.dirname(canonicalPath)) }, file: canonicalPath, manifest });
58
+ }
59
+ const canonical = manifests.find((entry) => entry.source.name === "metamask") ?? manifests.at(-1);
60
+ if (!canonical) throw new Error(`No ${adapter} action manifest could be resolved.`);
61
+ const base = cloneManifest(canonical.manifest);
62
+ const official = new Set(stringArray(recordOf(base).supported_official_actions));
63
+ const custom = /* @__PURE__ */ new Map();
64
+ const customMetadata = /* @__PURE__ */ new Map();
65
+ const bindings = /* @__PURE__ */ new Map();
66
+ const canonicalImplementationRoot = path.join(canonical.source.root, "actions");
67
+ const canonicalIntegrity = await actionSourceIntegrity(
68
+ canonical.file,
69
+ "canonical",
70
+ canonicalImplementationRoot
71
+ );
72
+ const actionSources = describeManifestSources(base, {
73
+ name: "metamask",
74
+ tier: "canonical",
75
+ manifestPath: canonical.file,
76
+ implementationRoot: canonicalImplementationRoot,
77
+ trust: "trusted",
78
+ ...canonicalIntegrity
79
+ });
80
+ for (const entry of manifests) {
81
+ const sourceName = entry.source.name ?? path.basename(entry.source.root);
82
+ const tier = sourceName === "metamask" ? "canonical" : sourceName === "personal" ? "personal" : "team";
83
+ const implementationRoot = path.join(entry.source.root, "actions");
84
+ const integrity = await actionSourceIntegrity(entry.file, tier, implementationRoot);
85
+ const sourceInfo = {
86
+ name: sourceName,
87
+ tier,
88
+ manifestPath: entry.file,
89
+ implementationRoot,
90
+ trust: entry.source.provenance?.trust ?? (tier === "canonical" ? "trusted" : "unknown"),
91
+ ...integrity
92
+ };
93
+ const manifestRecord = recordOf(entry.manifest);
94
+ const metadata = recordOf(manifestRecord.action_metadata);
95
+ for (const action of customActions(manifestRecord.custom_actions)) {
96
+ if (official.has(action.name)) {
97
+ throw new Error(`Library action ${action.name} from ${entry.file} conflicts with an official action.`);
98
+ }
99
+ if (custom.has(action.name)) {
100
+ const existing = actionSources.get(action.name);
101
+ if (existing && existing.name !== sourceName) {
102
+ existing.shadows = [...existing.shadows ?? [], sourceName];
103
+ }
104
+ continue;
105
+ }
106
+ custom.set(action.name, action.value);
107
+ customMetadata.set(action.name, metadata[action.name]);
108
+ actionSources.set(action.name, { ...sourceInfo });
109
+ }
110
+ for (const binding of objectArray(manifestRecord.native_bindings)) {
111
+ const action = typeof binding.action === "string" ? binding.action : void 0;
112
+ if (action && !bindings.has(action)) bindings.set(action, binding);
113
+ }
114
+ }
115
+ const baseRecord = recordOf(base);
116
+ baseRecord.custom_actions = [...custom.values()];
117
+ const baseMetadata = recordOf(baseRecord.action_metadata);
118
+ for (const [name, metadata] of customMetadata) {
119
+ if (metadata !== void 0) baseMetadata[name] = metadata;
120
+ }
121
+ baseRecord.action_metadata = baseMetadata;
122
+ baseRecord.native_bindings = [...bindings.values()];
123
+ return { manifest: withMetaMaskExecutionCapabilities(base), actionSources };
124
+ }
125
+ function defaultImplementationRoot(selectedManifestPath) {
126
+ const manifestDirectory = path.dirname(selectedManifestPath);
127
+ return path.basename(manifestDirectory) === "manifests" ? path.join(path.dirname(manifestDirectory), "actions") : path.join(manifestDirectory, "actions");
128
+ }
129
+ function withMetaMaskExecutionCapabilities(manifest) {
130
+ const result = cloneManifest(manifest);
131
+ const record = recordOf(result);
132
+ if (!Array.isArray(record.custom_actions)) return result;
133
+ record.custom_actions = record.custom_actions.map((entry) => {
134
+ const name = typeof entry === "string" ? entry : recordOf(entry).name;
135
+ if (typeof name !== "string") return entry;
136
+ const capabilities = metaMaskActionExecutionCapabilities(name);
137
+ if (capabilities.length === 0) return entry;
138
+ const declaredValue = typeof entry === "string" ? void 0 : recordOf(entry).execution_capabilities;
139
+ const declared = Array.isArray(declaredValue) ? declaredValue.filter(
140
+ (capability) => typeof capability === "string"
141
+ ) : [];
142
+ const executionCapabilities = [.../* @__PURE__ */ new Set([...declared, ...capabilities])];
143
+ return typeof entry === "string" ? { name, execution_capabilities: executionCapabilities } : { ...recordOf(entry), execution_capabilities: executionCapabilities };
144
+ });
145
+ return result;
146
+ }
18
147
  async function validateManifest(manifest) {
19
148
  const { validateRecipeActionManifestDocument } = await importRecipeProtocol();
20
149
  const result = validateRecipeActionManifestDocument(manifest);
@@ -28,10 +157,41 @@ async function validateManifest(manifest) {
28
157
  function asActionManifest(value) {
29
158
  return value;
30
159
  }
160
+ function cloneManifest(value) {
161
+ return JSON.parse(JSON.stringify(value));
162
+ }
163
+ function recordOf(value) {
164
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
165
+ }
166
+ function stringArray(value) {
167
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
168
+ }
169
+ function objectArray(value) {
170
+ return Array.isArray(value) ? value.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
171
+ }
172
+ function customActions(value) {
173
+ if (!Array.isArray(value)) return [];
174
+ return value.flatMap((entry) => {
175
+ if (typeof entry === "string") return [{ name: entry, value: entry }];
176
+ const record = recordOf(entry);
177
+ return typeof record.name === "string" ? [{ name: record.name, value: entry }] : [];
178
+ });
179
+ }
180
+ function describeManifestSources(manifest, customSource) {
181
+ const record = recordOf(manifest);
182
+ const sources = /* @__PURE__ */ new Map();
183
+ for (const name of stringArray(record.supported_official_actions)) {
184
+ sources.set(name, { name: "official", tier: "official", manifestPath: customSource.manifestPath });
185
+ }
186
+ for (const action of customActions(record.custom_actions)) sources.set(action.name, { ...customSource });
187
+ return sources;
188
+ }
31
189
  export {
32
190
  loadActionManifest,
33
191
  loadMetaMaskCoreActionManifest,
34
192
  loadMetaMaskExtensionActionManifest,
35
193
  loadMetaMaskMobileActionManifest,
36
- validateManifest
194
+ resolveActionManifest,
195
+ validateManifest,
196
+ withMetaMaskExecutionCapabilities
37
197
  };
@@ -134,7 +134,7 @@ Example:
134
134
  Actions differ per adapter \u2014 list this checkout's with:
135
135
  mm-harness actions.
136
136
 
137
- --list List everything invocable for the adapter (actions + flows); no <action> needed
137
+ --list List actions accepted by call for this adapter; no <action> needed
138
138
  --arg k=v Action field value (repeatable; equivalent to key=value shorthand)
139
139
  --device <udid|serial|name> Mobile only: target this device (env: IOS_SIMULATOR / ADB_SERIAL). Without it, >1 connected mobile device fails fast and lists them.
140
140
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
@@ -154,7 +154,7 @@ Example (real actions; run mm-harness actions for this checkout's full set):
154
154
  name: "flows",
155
155
  summary: "Browse reusable parameterized action sequences.",
156
156
  example: "mm-harness flows",
157
- helpText: `mm-harness flows [flags]
157
+ helpText: `mm-harness flows [list|describe <ref>|promote] [flags]
158
158
 
159
159
  Browse reusable parameterized action sequences. Flows resolve by precedence
160
160
  (personal > team > canonical); the highest-tier copy wins and shadows lower ones.
@@ -165,7 +165,9 @@ Example (real actions; run mm-harness actions for this checkout's full set):
165
165
  --json Machine-readable output
166
166
 
167
167
  Example:
168
- mm-harness flows`
168
+ mm-harness flows
169
+ mm-harness flows describe perps.clean_market_testnet
170
+ mm-harness flows describe perps.clean_market_testnet --json`
169
171
  },
170
172
  {
171
173
  name: "run",
@@ -176,12 +178,15 @@ Example:
176
178
  Validate and execute a complete proof, then write summary, trace, diagnostics,
177
179
  and artifacts. A recipe may use actions directly or call reusable flows.
178
180
 
179
- --list List everything invocable for the adapter (actions + flows); no <recipe> needed
181
+ --list List complete recipes accepted by run for this adapter; no <recipe> needed
182
+ --describe Show one recipe's metadata, declared inputs, composed actions/flows, and next command
180
183
  --plan Validate + print execution plan, touching nothing. Exit 5 if invalid.
181
184
  --device <udid|serial|name> Mobile only: target this device (env: IOS_SIMULATOR / ADB_SERIAL). Without it, >1 connected mobile device fails fast and lists them.
185
+ --cdp-port <port> Extension CDP port (env: CDP_PORT / RECIPE_CDP_PORT)
186
+ --launch-existing-dist Extension: reuse/launch the existing dist without rebuilding
182
187
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
183
188
  --target <path> Checkout path (default: cwd)
184
- --artifacts-dir <dir> Where to write evidence (required unless --plan)
189
+ --artifacts-dir <dir> Override evidence output (default: active task/artifacts, otherwise a checkout-local temp/recipe/runs/<run>)
185
190
  --action-manifest <path> Override the action manifest
186
191
  --library <name=path> Add/override a recipe-library source (repeatable)
187
192
  --heal <off|infra-only|auto> Healing policy (default: infra-only); auto-ensures the overlay
@@ -190,8 +195,9 @@ Example:
190
195
  --record-video=full-run Record a video of the run
191
196
 
192
197
  Example:
198
+ mm-harness run wallet.smoke --describe
193
199
  mm-harness run recipe.json --plan --adapter mobile
194
- mm-harness run recipe.json --adapter extension --artifacts-dir ./out`
200
+ mm-harness run recipe.json --adapter extension`
195
201
  },
196
202
  {
197
203
  name: "last",
@@ -644,7 +650,7 @@ function groupedHelp() {
644
650
  lines.push("DEV/PROD \u2014 the global npm install is prod; set MM_HARNESS_BIN to a dev checkout to override:");
645
651
  lines.push(" MM_HARNESS_BIN=/path/to/checkout/bin/mm-harness # unset = the installed/global bin");
646
652
  lines.push("");
647
- lines.push("See README.md for the workflow and docs/CHEATSHEET.md for examples.");
653
+ lines.push("See README.md for the workflow and docs/RECIPES.md for proof authoring.");
648
654
  return `${lines.join("\n")}
649
655
  `;
650
656
  }
@@ -0,0 +1,178 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, readdir, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
5
+ "metamask.wallet.fixture_status",
6
+ "metamask.wallet.list_accounts",
7
+ "metamask.wallet.read_state",
8
+ "metamask.perps.read_positions",
9
+ "metamask.perps.read_orders",
10
+ "metamask.perps.read_account",
11
+ "metamask.perps.assert_positions",
12
+ "metamask.perps.assert_orders"
13
+ ]);
14
+ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
15
+ "metamask.wallet.setup",
16
+ "metamask.wallet.ensure_unlocked",
17
+ "metamask.wallet.select_account",
18
+ "metamask.perps.close_positions",
19
+ "metamask.perps.close_orders",
20
+ "metamask.perps.place_order",
21
+ "metamask.perps.ensure_positions",
22
+ "metamask.perps.ensure_orders",
23
+ "metamask.perps.start_state",
24
+ "metamask.perps.teardown_state"
25
+ ]);
26
+ const EXTERNAL_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
27
+ "metamask.perps.close_positions",
28
+ "metamask.perps.close_orders",
29
+ "metamask.perps.place_order",
30
+ "metamask.perps.ensure_positions",
31
+ "metamask.perps.ensure_orders",
32
+ "metamask.perps.start_state",
33
+ "metamask.perps.teardown_state"
34
+ ]);
35
+ function metaMaskActionExecutionCapabilities(action) {
36
+ const capabilities = [];
37
+ if (READ_ONLY_CUSTOM_ACTIONS.has(action)) capabilities.push("host-read-export");
38
+ if (APP_MUTATION_CUSTOM_ACTIONS.has(action)) capabilities.push("app-mutation");
39
+ if (EXTERNAL_MUTATION_CUSTOM_ACTIONS.has(action)) capabilities.push("external-mutation");
40
+ return capabilities.length > 0 ? capabilities : ["arbitrary-code"];
41
+ }
42
+ async function actionSourceIntegrity(manifestPath, tier, implementationRoot) {
43
+ const manifestRoot = path.dirname(manifestPath);
44
+ const actionRoot = implementationRoot ?? (tier === "task" ? path.join(manifestRoot, "actions") : path.join(path.dirname(manifestRoot), "actions"));
45
+ const resolveDigest = () => digestSourceInputs([
46
+ { label: "manifest", root: manifestRoot, inputs: [path.basename(manifestPath)] },
47
+ { label: "implementation", root: actionRoot, inputs: ["."], optional: true }
48
+ ]);
49
+ return { digest: await resolveDigest(), resolveDigest };
50
+ }
51
+ async function digestSourceInputs(sources) {
52
+ const entries = [];
53
+ for (const source of sources) {
54
+ let rootReal;
55
+ try {
56
+ rootReal = await realpath(source.root);
57
+ } catch (error) {
58
+ if (source.optional && isMissing(error)) continue;
59
+ throw error;
60
+ }
61
+ const visitedDirectories = /* @__PURE__ */ new Set();
62
+ for (const relativeInput of source.inputs) {
63
+ await collectDigestEntries(
64
+ source.root,
65
+ rootReal,
66
+ relativeInput,
67
+ source.label,
68
+ entries,
69
+ visitedDirectories
70
+ );
71
+ }
72
+ }
73
+ entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
74
+ const hash = createHash("sha256");
75
+ for (const entry of entries) {
76
+ hash.update(`${entry.name}\0${entry.bytes.length}\0`);
77
+ hash.update(entry.bytes);
78
+ }
79
+ return `sha256:${hash.digest("hex")}`;
80
+ }
81
+ async function collectDigestEntries(root, rootReal, relativePath, label, entries, visitedDirectories) {
82
+ const lexicalPath = path.join(root, relativePath);
83
+ const info = await lstat(lexicalPath);
84
+ const resolvedPath = await realpath(lexicalPath);
85
+ if (!isPathWithin(rootReal, resolvedPath)) {
86
+ throw new Error(`Action source ${relativePath} resolves outside its selected source root.`);
87
+ }
88
+ const resolvedInfo = info.isSymbolicLink() ? await lstat(resolvedPath) : info;
89
+ if (resolvedInfo.isDirectory()) {
90
+ if (visitedDirectories.has(resolvedPath)) return;
91
+ visitedDirectories.add(resolvedPath);
92
+ for (const entry of await readdir(resolvedPath, { withFileTypes: true })) {
93
+ await collectDigestEntries(
94
+ root,
95
+ rootReal,
96
+ path.join(relativePath, entry.name),
97
+ label,
98
+ entries,
99
+ visitedDirectories
100
+ );
101
+ }
102
+ return;
103
+ }
104
+ if (!resolvedInfo.isFile()) return;
105
+ entries.push({
106
+ name: `${label}/${relativePath.split(path.sep).join("/")}`,
107
+ bytes: await readFile(resolvedPath)
108
+ });
109
+ }
110
+ function isPathWithin(root, candidate) {
111
+ const relative = path.relative(root, candidate);
112
+ return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
113
+ }
114
+ function isMissing(error) {
115
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
116
+ }
117
+ function explicitRecipeTrustOptions(values) {
118
+ const { sourceTrust, sourceKind, sourceName, sourceDigest, approvalDigest } = values;
119
+ const source = sourceTrust || sourceKind || sourceName || sourceDigest ? {
120
+ trust: sourceTrust ?? "unknown",
121
+ kind: sourceKind ?? "recipe-file",
122
+ ...sourceName ? { name: sourceName } : {},
123
+ ...sourceDigest ? { digest: sourceDigest } : {}
124
+ } : void 0;
125
+ return {
126
+ ...source ? { source } : {},
127
+ ...approvalDigest ? { approval: { planDigest: approvalDigest } } : {}
128
+ };
129
+ }
130
+ function recipeTrustFailure(error) {
131
+ if (!error || typeof error !== "object") return void 0;
132
+ const value = error;
133
+ if (typeof value.code !== "string" || !value.code.startsWith("RECIPE_") || typeof value.message !== "string" || typeof value.userAction !== "string") return void 0;
134
+ const failure = value.failure && typeof value.failure === "object" ? value.failure : void 0;
135
+ const blocked = Array.isArray(failure?.blocked) ? failure.blocked.flatMap((entry) => {
136
+ if (!entry || typeof entry !== "object") return [];
137
+ const node = entry;
138
+ if (typeof node.nodeId !== "string" || typeof node.action !== "string" || !Array.isArray(node.capabilities)) return [];
139
+ const origin = node.origin && typeof node.origin === "object" ? node.origin : void 0;
140
+ const adapterOrigin = node.adapterOrigin && typeof node.adapterOrigin === "object" ? node.adapterOrigin : void 0;
141
+ const implementation = adapterOrigin ? {
142
+ ...typeof adapterOrigin.kind === "string" ? { kind: adapterOrigin.kind } : {},
143
+ ...typeof adapterOrigin.trust === "string" ? { trust: adapterOrigin.trust } : {},
144
+ ...typeof adapterOrigin.digest === "string" ? { digest: adapterOrigin.digest } : {}
145
+ } : void 0;
146
+ return [{
147
+ nodeId: node.nodeId,
148
+ action: node.action,
149
+ capabilities: node.capabilities.filter(
150
+ (item) => typeof item === "string"
151
+ ),
152
+ source: typeof origin?.kind === "string" ? origin.kind : "unknown",
153
+ ...implementation && Object.keys(implementation).length > 0 ? { implementation } : {}
154
+ }];
155
+ }) : void 0;
156
+ const recipeDigest = typeof failure?.recipeDigest === "string" ? failure.recipeDigest : void 0;
157
+ const userAction = recipeDigest && (value.code === "RECIPE_TRUST_REQUIRED" || value.code === "RECIPE_APPROVAL_MISMATCH") && !value.userAction.includes("execution environment") ? `${value.userAction}; rerun with the same project root, artifact directory, and execution environment` : value.userAction;
158
+ return {
159
+ code: value.code,
160
+ message: value.message,
161
+ userAction,
162
+ ...failure ? {
163
+ details: {
164
+ // Farmslot reports the approved execution plan digest as
165
+ // recipeDigest in failures; approval input names it planDigest.
166
+ ...recipeDigest ? { recipeDigest } : {},
167
+ ...typeof failure.trust === "string" ? { trust: failure.trust } : {},
168
+ ...blocked?.length ? { blocked } : {}
169
+ }
170
+ } : {}
171
+ };
172
+ }
173
+ export {
174
+ actionSourceIntegrity,
175
+ explicitRecipeTrustOptions,
176
+ metaMaskActionExecutionCapabilities,
177
+ recipeTrustFailure
178
+ };
package/dist/runner.js CHANGED
@@ -6,6 +6,9 @@ import {
6
6
  } from "./adapters/mobile/source-freshness.js";
7
7
  import { resolveMetaMaskMobileLifecycleTarget } from "./app-lifecycle.js";
8
8
  import { loadMetaMaskExtensionActionManifest, loadMetaMaskMobileActionManifest } from "./manifest.js";
9
+ import {
10
+ prepareLiveAdapterScript
11
+ } from "./live-adapter-contract.js";
9
12
  import { createMetaMaskRecordingTargetProvider } from "./recording-target.js";
10
13
  import { importRecipeHarness, importRecipeHarnessAppLifecycle, importRecipeHarnessRuntimeCdp, importRecipeHarnessRuntimeReactNativeBridge, runnerDir } from "./paths.js";
11
14
  async function createMetaMaskMobileRunner(options = {}) {
@@ -29,10 +32,17 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
29
32
  const { createCdpWebUiTransport } = await importRecipeHarnessRuntimeCdp();
30
33
  const { createReactNativeBridgeUiTransport } = await importRecipeHarnessRuntimeReactNativeBridge();
31
34
  const { createAppLifecycleAdapters } = await importRecipeHarnessAppLifecycle();
32
- const actions = [
33
- ...actionManifest.supported_official_actions,
34
- ...(actionManifest.custom_actions ?? []).map((entry) => entry.name)
35
- ];
35
+ const customActions = (actionManifest.custom_actions ?? []).map(
36
+ (entry) => typeof entry === "string" ? entry : entry.name
37
+ );
38
+ const actionSources = options.actionSources ? new Map([...options.actionSources].map(([action, source]) => [action, { ...source }])) : void 0;
39
+ const preparedLiveAdapters = await prepareLiveAdapterImplementations(
40
+ adapter,
41
+ customActions,
42
+ actionSources,
43
+ options.trustTaskActions
44
+ );
45
+ const actions = [...actionManifest.supported_official_actions, ...customActions];
36
46
  const declaredActions = new Set(actions);
37
47
  const core = createStandardCoreAdapters({ actions });
38
48
  const projectOwnedOfficialActions = /* @__PURE__ */ new Set(["app.status", "app.lifecycle"]);
@@ -41,7 +51,7 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
41
51
  transport: createMetaMaskUiTransport(adapter, {
42
52
  createCdpWebUiTransport,
43
53
  createReactNativeBridgeUiTransport
44
- })
54
+ }, preparedLiveAdapters)
45
55
  });
46
56
  const existing = new Set([...core, ...ui].map((entry) => entry.action));
47
57
  const lifecycle = mobileSourceAwareLifecycleAdapters(
@@ -51,19 +61,34 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
51
61
  targetProvider: { resolveTarget: resolveMetaMaskMobileLifecycleTarget }
52
62
  })
53
63
  );
54
- const custom = createMetaMaskAdapters(adapter).filter(
64
+ const custom = createMetaMaskAdapters(
65
+ adapter,
66
+ customActions,
67
+ preparedLiveAdapters
68
+ ).filter(
55
69
  (entry) => declaredActions.has(entry.action) && !existing.has(entry.action) && !lifecycle.some((lifecycleEntry) => lifecycleEntry.action === entry.action)
56
70
  );
57
- const autoHudDisabled = process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false";
71
+ const autoHudDisabled = options.autoHud === false || process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false";
58
72
  const logger = options.quietStdout ? new console.Console(process.stderr) : console;
59
73
  return createRecipeRunner({
60
74
  actionManifest,
61
- adapters: withActionProgress([...core, ...ui, ...lifecycle, ...custom], options.onActionEvent),
75
+ adapters: withActionProgress(
76
+ [...core, ...ui, ...lifecycle, ...custom].map((entry) => ({
77
+ ...entry,
78
+ ...adapterSecurity(entry.action, actionSources, options.trustTaskActions)
79
+ })),
80
+ options.onActionEvent
81
+ ),
62
82
  logger,
63
83
  recording: {
64
84
  targetProvider: createMetaMaskRecordingTargetProvider(adapter)
65
85
  },
66
86
  runner: runnerProvenance(),
87
+ defaultSource: {
88
+ kind: "operator",
89
+ trust: "trusted",
90
+ name: "@deeeed/metamask-harness"
91
+ },
67
92
  hud: autoHudDisabled ? false : {
68
93
  enabled: true,
69
94
  display: {
@@ -76,6 +101,59 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
76
101
  }
77
102
  });
78
103
  }
104
+ async function prepareLiveAdapterImplementations(platform, actions, sources, trustTaskActions = false) {
105
+ const prepared = /* @__PURE__ */ new Map();
106
+ if (!sources) return prepared;
107
+ await Promise.all(actions.map(async (action) => {
108
+ const source = sources.get(action);
109
+ if (!source?.implementationRoot || source.tier === "official") return;
110
+ const executable = await prepareLiveAdapterScript({
111
+ platform,
112
+ action,
113
+ implementationRoot: source.implementationRoot,
114
+ allowRuntimeImports: source.tier === "canonical" || source.trust === "trusted" || source.tier === "task" && trustTaskActions
115
+ });
116
+ if (!executable) return;
117
+ prepared.set(action, executable);
118
+ source.digest = executable.sourceDigest;
119
+ source.resolveDigest = void 0;
120
+ }));
121
+ return prepared;
122
+ }
123
+ function adapterSecurity(action, sources, trustTaskActions = false) {
124
+ const source = sources?.get(action);
125
+ if (source?.tier === "task") {
126
+ return {
127
+ source: {
128
+ kind: "custom-adapter",
129
+ trust: trustTaskActions ? "trusted" : "untrusted",
130
+ name: source.name,
131
+ path: source.manifestPath,
132
+ ...source.digest ? { digest: source.digest } : {}
133
+ },
134
+ ...source.resolveDigest ? { resolveSourceDigest: source.resolveDigest } : {}
135
+ };
136
+ }
137
+ if (source && source.tier !== "official") {
138
+ return {
139
+ source: {
140
+ kind: source.tier === "canonical" ? "bundled" : "custom-adapter",
141
+ trust: source.trust ?? (source.tier === "canonical" ? "trusted" : "unknown"),
142
+ name: source.name,
143
+ path: source.manifestPath,
144
+ ...source.digest ? { digest: source.digest } : {}
145
+ },
146
+ ...source.resolveDigest ? { resolveSourceDigest: source.resolveDigest } : {}
147
+ };
148
+ }
149
+ return {
150
+ source: {
151
+ kind: "bundled",
152
+ trust: "trusted",
153
+ name: "@deeeed/metamask-harness"
154
+ }
155
+ };
156
+ }
79
157
  function withActionProgress(adapters, onActionEvent) {
80
158
  if (!onActionEvent) return adapters;
81
159
  return adapters.map((entry) => ({
@@ -113,11 +191,7 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle) {
113
191
  async execute(node, context) {
114
192
  const command = node.command ?? node.event ?? node.state;
115
193
  const reloadsSource = command === "launch" || command === "foreground" || command === "restart";
116
- const source = reloadsSource ? mobileSourceFingerprint(context.projectRoot) : void 0;
117
- if (source?.status === "unavailable") {
118
- throw new Error(`${source.error.message} Next: ${source.error.userAction}`);
119
- }
120
- const fingerprint = source?.fingerprint;
194
+ const fingerprint = reloadsSource ? mobileSourceFingerprint(context.projectRoot) : void 0;
121
195
  const result = await entry.execute(node, context);
122
196
  if (fingerprint !== void 0 && !recordMobileSourceBaseline(context.projectRoot, fingerprint)) {
123
197
  throw new Error(
@@ -53,32 +53,6 @@ function readExistingContext(contextPath, repoRoot, runtimeDir, adapter) {
53
53
  return { context, existed: true };
54
54
  }
55
55
  function validExistingContext(context, repoRoot, runtimeDir, adapter) {
56
- const allowedFields = /* @__PURE__ */ new Set([
57
- "schemaVersion",
58
- "repoRoot",
59
- "project",
60
- "slotId",
61
- "machine",
62
- "runtimeOwner",
63
- "strict",
64
- "platform",
65
- "runtimeDir",
66
- "gitDirty",
67
- "simulator",
68
- "adbSerial",
69
- "distDir",
70
- "extensionId",
71
- "gitHead",
72
- "gitBranch",
73
- "gitCommit",
74
- "createdAt",
75
- "session",
76
- "cdpPort",
77
- "watcherPort",
78
- "metroPort",
79
- "devServerPort"
80
- ]);
81
- if (Object.keys(context).some((field) => !allowedFields.has(field))) return false;
82
56
  if (context.schemaVersion !== void 0 && context.schemaVersion !== 1) return false;
83
57
  if (context.repoRoot !== void 0 && (typeof context.repoRoot !== "string" || path.resolve(context.repoRoot) !== repoRoot)) return false;
84
58
  if (context.platform !== void 0 && context.platform !== adapter) return false;
@@ -108,6 +82,14 @@ function validExistingContext(context, repoRoot, runtimeDir, adapter) {
108
82
  const value = context[field];
109
83
  if (value !== void 0 && (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535)) return false;
110
84
  }
85
+ if (context.runtimeStart !== void 0) {
86
+ if (!context.runtimeStart || typeof context.runtimeStart !== "object" || Array.isArray(context.runtimeStart)) return false;
87
+ const runtimeStart = context.runtimeStart;
88
+ if (runtimeStart.approved !== void 0 && typeof runtimeStart.approved !== "boolean") return false;
89
+ for (const field of ["command", "readyUrl"]) {
90
+ if (runtimeStart[field] !== void 0 && (typeof runtimeStart[field] !== "string" || runtimeStart[field].length === 0)) return false;
91
+ }
92
+ }
111
93
  const forbiddenResources = adapter === "core" ? ["cdpPort", "watcherPort", "metroPort", "devServerPort", "simulator", "adbSerial", "extensionId"] : adapter === "extension" ? ["metroPort", "simulator", "adbSerial"] : ["cdpPort", "extensionId"];
112
94
  if (forbiddenResources.some((field) => context[field] !== void 0)) return false;
113
95
  return true;