@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.
- package/CHANGELOG.md +775 -0
- package/README.md +65 -71
- package/adapters/extension/ensure-browser.sh +12 -1
- package/adapters/extension/inject.mjs +7 -0
- package/adapters/extension/launch-browser.cjs +10 -3
- package/adapters/extension/lib/chrome-args.cjs +31 -0
- package/adapters/extension/lib/macos-focus.cjs +32 -0
- package/adapters/extension/live.sh +10 -20
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/open-device.sh +45 -7
- package/adapters/mobile/verify.sh +15 -3
- package/adapters/shared/harness-source-fingerprint.mjs +49 -0
- package/adapters/shared/install-repo-deps.sh +1 -5
- package/adapters/shared/open-debug.mjs +184 -103
- package/adapters/shared/resolve-slot-ports-core.mjs +23 -6
- package/adapters/shared/resolve-slot-ports.sh +22 -5
- package/bin/mm-harness +15 -3
- package/dist/adapters/core/surface.js +4 -1
- package/dist/adapters/extension/console-capture.js +3 -1
- package/dist/adapters/extension/harness-freshness.js +39 -0
- package/dist/adapters/extension/product-config.js +110 -0
- package/dist/adapters/extension/runtime-decision.js +20 -71
- package/dist/adapters/extension/surface.js +19 -1
- package/dist/adapters/mobile/prepare.js +17 -0
- package/dist/adapters/mobile/source-freshness.js +26 -41
- package/dist/adapters/mobile/surface.js +4 -1
- package/dist/adapters/resolve-slot-ports.js +2 -0
- package/dist/adapters/slot-ports.js +13 -32
- package/dist/adapters.js +50 -17
- package/dist/checkout-lock.js +27 -2
- package/dist/cli-color.js +19 -0
- package/dist/cli-commands.js +1 -1
- package/dist/cli.js +2 -3
- package/dist/command-contract.js +13 -3
- package/dist/commands/call.js +115 -29
- package/dist/commands/checklist.js +4 -1
- package/dist/commands/completion-candidates.js +20 -13
- package/dist/commands/debug.js +31 -38
- package/dist/commands/doctor.js +33 -6
- package/dist/commands/fixtures.js +65 -17
- package/dist/commands/flows.js +39 -10
- package/dist/commands/launch/extension.js +40 -15
- package/dist/commands/launch/index.js +41 -5
- package/dist/commands/list-executables.js +151 -29
- package/dist/commands/manifest.js +127 -18
- package/dist/commands/parse-args.js +11 -1
- package/dist/commands/run-engine.js +384 -56
- package/dist/commands/run.js +112 -17
- package/dist/commands/shared.js +22 -2
- package/dist/commands/status-probe.js +3 -0
- package/dist/commands/status.js +1 -0
- package/dist/completions-cache.js +1 -1
- package/dist/doctor.js +56 -6
- package/dist/harness.js +6 -5
- package/dist/heal-bounds.js +1 -1
- package/dist/live-adapter-contract.js +132 -12
- package/dist/manifest.js +161 -1
- package/dist/mm-harness-cli.js +13 -7
- package/dist/recipe-security.js +178 -0
- package/dist/runner.js +87 -13
- package/dist/runtime-context.js +8 -26
- package/docs/CONTRIBUTING.md +137 -0
- package/docs/QA.md +185 -0
- package/docs/RECIPES.md +161 -0
- package/docs/SECURITY.md +88 -0
- package/library/actions/core/perps/read_account.mjs +2 -2
- package/library/actions/core/perps/read_orders.mjs +2 -1
- package/library/actions/core/perps/read_positions.mjs +2 -1
- package/library/actions/core/wallet/list_accounts.mjs +95 -0
- package/library/actions/extension/platform/cdp.mjs +1 -0
- package/library/actions/extension/wallet/list_accounts.mjs +41 -0
- package/library/actions/mobile/platform/bridge.mjs +1 -5
- package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
- package/library/manifests/core.action-manifest.json +61 -0
- package/library/manifests/extension.action-manifest.json +53 -0
- package/library/manifests/mobile.action-manifest.json +53 -0
- package/library/recipes/runner/action-validation.extension.recipe.json +8 -1
- package/library/recipes/runner/action-validation.mobile.recipe.json +8 -1
- package/package.json +10 -6
- package/scripts/completions.sh +7 -7
- package/docs/ADAPTER-SURFACE.md +0 -26
- package/docs/CHEATSHEET.md +0 -48
- package/docs/CLI-ERGONOMICS-AUDIT.md +0 -32
- package/docs/CLI-ERGONOMICS-HUMAN-QA.md +0 -64
- package/docs/CODE-MAP.md +0 -62
- package/docs/UX-PRINCIPLES.md +0 -66
- package/docs/VIDEO-DEMO-VALIDATION.md +0 -74
- package/docs/architecture.md +0 -88
- package/docs/live-adapter-contract.md +0 -190
- package/docs/package-boundaries.md +0 -47
- package/docs/perps-flow-catalog.md +0 -235
- package/docs/recipe-libraries.md +0 -71
- 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
|
-
|
|
194
|
+
resolveActionManifest,
|
|
195
|
+
validateManifest,
|
|
196
|
+
withMetaMaskExecutionCapabilities
|
|
37
197
|
};
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -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
|
|
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
|
|
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>
|
|
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
|
|
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/
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
package/dist/runtime-context.js
CHANGED
|
@@ -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;
|