@deeeed/metamask-harness 0.27.0 → 0.28.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 +7 -0
- package/dist/adapters.js +0 -11
- package/dist/cli-commands.js +1 -1
- package/dist/command-contract.js +1 -0
- package/dist/commands/call.js +38 -1
- package/dist/commands/manifest.js +201 -6
- package/dist/commands/parse-args.js +1 -0
- package/dist/commands/run.js +74 -8
- package/dist/mm-harness-cli.js +2 -0
- package/dist/recipe-security.js +1 -0
- package/library/actions/extension/ui/locators.mjs +13 -0
- package/library/actions/mobile/platform/observe-ui.mjs +21 -0
- package/library/actions/mobile/ui/locators.mjs +17 -0
- package/library/actions/shared/ui/locators.mjs +160 -0
- package/library/manifests/extension.action-manifest.json +18 -0
- package/library/manifests/mobile.action-manifest.json +18 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.28.0 - 2026-07-31
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add `actions --matrix` and adapter-aware action refusals that name missing capabilities and list the adapters that satisfy them.
|
|
10
|
+
- Add `ui.locators` for ranked, copy-pasteable recipe targets derived from the current Extension or Mobile `ui.visible` observation.
|
|
11
|
+
|
|
5
12
|
## 0.27.0 - 2026-07-31
|
|
6
13
|
|
|
7
14
|
### Changed
|
package/dist/adapters.js
CHANGED
|
@@ -305,17 +305,6 @@ async function handleMobileStatus(payload, context) {
|
|
|
305
305
|
return bridgeCommand(mobileUiInput(context, "status", payload), ["status"]);
|
|
306
306
|
}
|
|
307
307
|
async function handleMobileObserveUi(payload, context) {
|
|
308
|
-
try {
|
|
309
|
-
await bridgeCommand(
|
|
310
|
-
mobileUiInput(context, "observe", {
|
|
311
|
-
...payload,
|
|
312
|
-
bridge_timeout_ms: 2e3,
|
|
313
|
-
cdp_timeout_ms: 2e3
|
|
314
|
-
}),
|
|
315
|
-
["hide-step"]
|
|
316
|
-
);
|
|
317
|
-
} catch {
|
|
318
|
-
}
|
|
319
308
|
return observeNativeUi(payload, context);
|
|
320
309
|
}
|
|
321
310
|
async function handleMobileNavigate(payload, context) {
|
package/dist/cli-commands.js
CHANGED
|
@@ -15,7 +15,7 @@ const SPEC = {
|
|
|
15
15
|
{ name: "logs", aliases: ["tail"], desc: "Compact build events or full log", flags: ["--full", "-f", "--window", "--events", "--source", "--json"] },
|
|
16
16
|
{ name: "debug", aliases: ["devtools", "inspect"], desc: "Open DevTools UI", flags: ["--json", "--no-open"] },
|
|
17
17
|
{ name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
|
|
18
|
-
{ name: "actions", desc: "List runnable recipe actions", flags: ["--json", "--categories", "--category", "--action", "--library"] },
|
|
18
|
+
{ name: "actions", desc: "List runnable recipe actions", flags: ["--json", "--matrix", "--categories", "--category", "--action", "--library"] },
|
|
19
19
|
{ name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
|
|
20
20
|
{ name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
|
|
21
21
|
{ name: "execution-template", desc: "Discover and materialize shared agent checklists", args: ["list", "materialize", "lint", "new"], flags: ["--dir", "--domain-dir", "--project-worker", "--project-name", "--package-templates", "--package-id", "--flow", "--run-mode", "--platform", "--domain", "--id", "--provenance", "--include-shadowed", "--no-include-shadowed", "--title", "--force", "--json"] },
|
package/dist/command-contract.js
CHANGED
|
@@ -117,6 +117,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
|
|
|
117
117
|
actions: {
|
|
118
118
|
options: options(HELP, JSON, TARGET, ADAPTER, ADAPTER_OR_MOBILE_PLATFORM, {
|
|
119
119
|
"--action": value(),
|
|
120
|
+
"--matrix": bool(),
|
|
120
121
|
"--categories": bool(),
|
|
121
122
|
"--category": value(),
|
|
122
123
|
"--action-manifest": value(),
|
package/dist/commands/call.js
CHANGED
|
@@ -6,7 +6,14 @@ import { importRecipeProtocol } from "../paths.js";
|
|
|
6
6
|
import { conciseFailureForHuman, recipeRunning } from "../heal-bounds.js";
|
|
7
7
|
import { color } from "../cli-color.js";
|
|
8
8
|
import { checkoutBusyOut, EXIT, usageOut } from "./shared.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
actionLibraryContextArgs,
|
|
11
|
+
actionExampleCommand,
|
|
12
|
+
describeManifestActions,
|
|
13
|
+
fuzzyResolveActions,
|
|
14
|
+
resolveActionCapabilityMatrix,
|
|
15
|
+
resolveActionCapabilityRefusal
|
|
16
|
+
} from "./manifest.js";
|
|
10
17
|
import {
|
|
11
18
|
parseArgs,
|
|
12
19
|
isRecord,
|
|
@@ -112,6 +119,36 @@ async function handleCall(argv) {
|
|
|
112
119
|
const names = getRecipeActionManifestActionNames(manifest);
|
|
113
120
|
const resolution = resolveActionName(shortName, names);
|
|
114
121
|
if (resolution.status === "unknown") {
|
|
122
|
+
const refusal = actionManifestOverride ? void 0 : resolveActionCapabilityRefusal(
|
|
123
|
+
shortName,
|
|
124
|
+
adapter,
|
|
125
|
+
await resolveActionCapabilityMatrix(librarySources)
|
|
126
|
+
);
|
|
127
|
+
if (refusal) {
|
|
128
|
+
const satisfying = refusal.satisfyingAdapters.join(", ");
|
|
129
|
+
const message2 = `missing action capability "${refusal.capability}" for the ${adapter} adapter.`;
|
|
130
|
+
const userAction2 = `Satisfying adapters for "${refusal.capability}": ${satisfying}. Inspect: mm-harness actions --matrix --action ${shellQuote(refusal.capability)}${actionLibraryContextArgs(librarySources)} --json`;
|
|
131
|
+
const error = {
|
|
132
|
+
code: "ACTION_CAPABILITY_UNAVAILABLE",
|
|
133
|
+
message: message2,
|
|
134
|
+
capability: refusal.capability,
|
|
135
|
+
satisfyingAdapters: refusal.satisfyingAdapters,
|
|
136
|
+
userAction: userAction2
|
|
137
|
+
};
|
|
138
|
+
if (json) {
|
|
139
|
+
console.log(JSON.stringify({
|
|
140
|
+
schemaVersion: 1,
|
|
141
|
+
command: "call",
|
|
142
|
+
adapter,
|
|
143
|
+
action: shortName,
|
|
144
|
+
error
|
|
145
|
+
}, null, 2));
|
|
146
|
+
} else {
|
|
147
|
+
console.error(`\u2717 call: ${message2}
|
|
148
|
+
Next: ${userAction2}`);
|
|
149
|
+
}
|
|
150
|
+
return EXIT.usage;
|
|
151
|
+
}
|
|
115
152
|
const message = `unknown action "${shortName}" for the ${adapter} adapter.`;
|
|
116
153
|
const userAction = `mm-harness actions --adapter ${adapter} --json`;
|
|
117
154
|
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
|
|
@@ -18,22 +18,33 @@ import {
|
|
|
18
18
|
import { metaMaskActionExecutionCapabilities } from "../recipe-security.js";
|
|
19
19
|
import { closest } from "../command-contract.js";
|
|
20
20
|
const OFFICIAL_ACTIONS = new Set(OFFICIAL_RECIPE_ACTIONS);
|
|
21
|
+
const ACTION_MATRIX_ADAPTERS = ["mobile", "extension", "core"];
|
|
22
|
+
function actionLibraryContextArgs(librarySources) {
|
|
23
|
+
return (librarySources ?? []).filter((source) => source.name !== "metamask").map((source) => {
|
|
24
|
+
const entry = source.name ? `${source.name}=${source.root}` : source.root;
|
|
25
|
+
return ` --library ${shellQuoteArg(entry)}`;
|
|
26
|
+
}).join("");
|
|
27
|
+
}
|
|
21
28
|
async function handleActions({ options, positional }) {
|
|
22
|
-
const { adapter, target } = resolveAdapter(options);
|
|
23
29
|
const librarySources = await resolveMetaMaskLibrarySources(optionStrings(options, "library"));
|
|
30
|
+
const json = optionFlag(options, "json");
|
|
31
|
+
const action = optionString(options, "action");
|
|
32
|
+
const query = positional[0]?.trim();
|
|
33
|
+
const category = optionString(options, "category")?.toLowerCase();
|
|
34
|
+
if (optionFlag(options, "matrix")) {
|
|
35
|
+
return handleActionMatrix(options, { json, action, query, category, librarySources });
|
|
36
|
+
}
|
|
37
|
+
const { adapter, target } = resolveAdapter(options);
|
|
38
|
+
const actionManifestOverride = optionString(options, "actionManifest");
|
|
24
39
|
const { manifest, actionSources } = await resolveActionManifest(
|
|
25
40
|
adapter,
|
|
26
|
-
|
|
41
|
+
actionManifestOverride,
|
|
27
42
|
librarySources
|
|
28
43
|
);
|
|
29
44
|
if (optionFlag(options, "raw")) {
|
|
30
45
|
console.log(JSON.stringify(manifest, null, 2));
|
|
31
46
|
return EXIT.ok;
|
|
32
47
|
}
|
|
33
|
-
const json = optionFlag(options, "json");
|
|
34
|
-
const action = optionString(options, "action");
|
|
35
|
-
const query = positional[0]?.trim();
|
|
36
|
-
const category = optionString(options, "category")?.toLowerCase();
|
|
37
48
|
const categoriesOnly = optionFlag(options, "categories");
|
|
38
49
|
const all = describeManifestActions(manifest, actionSources);
|
|
39
50
|
const categories = summarizeActionCategories(all);
|
|
@@ -67,6 +78,36 @@ async function handleActions({ options, positional }) {
|
|
|
67
78
|
}
|
|
68
79
|
const actions = action ? fuzzyResolveActions(categoryActions, action) : query ? searchActions(categoryActions, query) : categoryActions;
|
|
69
80
|
if (action && actions.length === 0) {
|
|
81
|
+
const refusal = actionManifestOverride ? void 0 : resolveActionCapabilityRefusal(
|
|
82
|
+
action,
|
|
83
|
+
adapter,
|
|
84
|
+
await resolveActionCapabilityMatrix(librarySources)
|
|
85
|
+
);
|
|
86
|
+
if (refusal) {
|
|
87
|
+
const satisfying = refusal.satisfyingAdapters.join(", ");
|
|
88
|
+
const message2 = `missing action capability "${refusal.capability}" for the ${adapter} adapter.`;
|
|
89
|
+
const userAction2 = `Satisfying adapters for "${refusal.capability}": ${satisfying}. Inspect: mm-harness actions --matrix --action ${shellQuoteArg(refusal.capability)}${actionLibraryContextArgs(librarySources)} --json`;
|
|
90
|
+
if (json) {
|
|
91
|
+
console.log(JSON.stringify({
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
command: "actions",
|
|
94
|
+
adapter,
|
|
95
|
+
action,
|
|
96
|
+
category,
|
|
97
|
+
error: {
|
|
98
|
+
code: "ACTION_CAPABILITY_UNAVAILABLE",
|
|
99
|
+
message: message2,
|
|
100
|
+
capability: refusal.capability,
|
|
101
|
+
satisfyingAdapters: refusal.satisfyingAdapters,
|
|
102
|
+
userAction: userAction2
|
|
103
|
+
}
|
|
104
|
+
}, null, 2));
|
|
105
|
+
} else {
|
|
106
|
+
console.error(`\u2717 mm-harness actions: ${message2}
|
|
107
|
+
Next: ${userAction2}`);
|
|
108
|
+
}
|
|
109
|
+
return EXIT.usage;
|
|
110
|
+
}
|
|
70
111
|
const message = `no action matches "${action}" for the ${adapter} adapter.`;
|
|
71
112
|
const userAction = `mm-harness actions --adapter ${adapter}`;
|
|
72
113
|
if (json) {
|
|
@@ -126,6 +167,154 @@ ${color("label", "Related:", { stream: process.stdout })} ${relatedActions.join(
|
|
|
126
167
|
}
|
|
127
168
|
return 0;
|
|
128
169
|
}
|
|
170
|
+
async function resolveActionCapabilityMatrix(librarySources) {
|
|
171
|
+
const catalogs = await Promise.all(
|
|
172
|
+
ACTION_MATRIX_ADAPTERS.map(async (adapter) => {
|
|
173
|
+
const { manifest, actionSources } = await resolveActionManifest(
|
|
174
|
+
adapter,
|
|
175
|
+
void 0,
|
|
176
|
+
librarySources
|
|
177
|
+
);
|
|
178
|
+
return { adapter, actions: describeManifestActions(manifest, actionSources) };
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
const actions = /* @__PURE__ */ new Map();
|
|
182
|
+
for (const catalog of catalogs) {
|
|
183
|
+
for (const action of catalog.actions) {
|
|
184
|
+
const entry = actions.get(action.name) ?? { entries: [], adapters: /* @__PURE__ */ new Set() };
|
|
185
|
+
entry.entries.push(action);
|
|
186
|
+
entry.adapters.add(catalog.adapter);
|
|
187
|
+
actions.set(action.name, entry);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return [...actions.entries()].map(([name, entry]) => {
|
|
191
|
+
const representative = entry.entries[0];
|
|
192
|
+
const satisfyingAdapters = ACTION_MATRIX_ADAPTERS.filter(
|
|
193
|
+
(adapter) => entry.adapters.has(adapter)
|
|
194
|
+
);
|
|
195
|
+
return {
|
|
196
|
+
name,
|
|
197
|
+
category: representative.category,
|
|
198
|
+
description: entry.entries.map((action) => action.description).find(Boolean) ?? "",
|
|
199
|
+
fields: [...new Set(entry.entries.flatMap((action) => action.fields))].sort(),
|
|
200
|
+
support: Object.fromEntries(
|
|
201
|
+
ACTION_MATRIX_ADAPTERS.map((adapter) => [
|
|
202
|
+
adapter,
|
|
203
|
+
entry.adapters.has(adapter) ? "available" : "unavailable"
|
|
204
|
+
])
|
|
205
|
+
),
|
|
206
|
+
satisfyingAdapters
|
|
207
|
+
};
|
|
208
|
+
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
209
|
+
}
|
|
210
|
+
function resolveActionCapabilityRefusal(action, adapter, matrix) {
|
|
211
|
+
const matches = fuzzyResolveActions(matrix, action);
|
|
212
|
+
if (matches.length !== 1 || matches[0].satisfyingAdapters.includes(adapter)) {
|
|
213
|
+
return void 0;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
capability: matches[0].name,
|
|
217
|
+
satisfyingAdapters: matches[0].satisfyingAdapters
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function missingActionCapabilities(adapter, actionNames, matrix) {
|
|
221
|
+
return [...new Set(actionNames)].flatMap((action) => {
|
|
222
|
+
const row = matrix.find((entry) => entry.name === action);
|
|
223
|
+
if (!row || row.satisfyingAdapters.includes(adapter)) return [];
|
|
224
|
+
return [{ capability: row.name, satisfyingAdapters: row.satisfyingAdapters }];
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
function handleActionMatrixError(json, code, message, userAction, details = {}) {
|
|
228
|
+
if (json) {
|
|
229
|
+
console.log(JSON.stringify({
|
|
230
|
+
schemaVersion: 1,
|
|
231
|
+
command: "actions",
|
|
232
|
+
view: "matrix",
|
|
233
|
+
...details,
|
|
234
|
+
error: { code, message, userAction }
|
|
235
|
+
}, null, 2));
|
|
236
|
+
} else {
|
|
237
|
+
console.error(`\u2717 mm-harness actions: ${message}
|
|
238
|
+
Next: ${userAction}`);
|
|
239
|
+
}
|
|
240
|
+
return EXIT.usage;
|
|
241
|
+
}
|
|
242
|
+
async function handleActionMatrix(options, input) {
|
|
243
|
+
const inspectCommand = `mm-harness actions --matrix${actionLibraryContextArgs(input.librarySources)} --json`;
|
|
244
|
+
const conflicts = [
|
|
245
|
+
optionFlag(options, "raw") ? "--raw" : void 0,
|
|
246
|
+
optionFlag(options, "categories") ? "--categories" : void 0,
|
|
247
|
+
optionString(options, "adapter") ? "--adapter" : void 0,
|
|
248
|
+
optionString(options, "platform") ? "--platform" : void 0,
|
|
249
|
+
optionString(options, "actionManifest") ? "--action-manifest" : void 0
|
|
250
|
+
].filter((value) => Boolean(value));
|
|
251
|
+
if (conflicts.length > 0) {
|
|
252
|
+
return handleActionMatrixError(
|
|
253
|
+
input.json,
|
|
254
|
+
"ACTION_MATRIX_CONFLICT",
|
|
255
|
+
`--matrix cannot be combined with ${conflicts.join(", ")}.`,
|
|
256
|
+
inspectCommand
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const matrix = await resolveActionCapabilityMatrix(input.librarySources);
|
|
260
|
+
const categories = summarizeActionCategories(matrix);
|
|
261
|
+
const categoryActions = input.category ? matrix.filter((entry) => entry.category === input.category) : matrix;
|
|
262
|
+
if (input.category && categoryActions.length === 0) {
|
|
263
|
+
return handleActionMatrixError(
|
|
264
|
+
input.json,
|
|
265
|
+
"ACTION_CATEGORY_UNKNOWN",
|
|
266
|
+
`no action category matches "${input.category}" across the adapter matrix.`,
|
|
267
|
+
inspectCommand,
|
|
268
|
+
{ category: input.category, availableCategories: categories }
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const actions = input.action ? fuzzyResolveActions(categoryActions, input.action) : input.query ? searchActions(categoryActions, input.query) : categoryActions;
|
|
272
|
+
if (input.action && actions.length === 0) {
|
|
273
|
+
return handleActionMatrixError(
|
|
274
|
+
input.json,
|
|
275
|
+
"ACTION_UNKNOWN",
|
|
276
|
+
`no action capability matches "${input.action}" across Mobile, Extension, or Core.`,
|
|
277
|
+
inspectCommand,
|
|
278
|
+
{ action: input.action, category: input.category }
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (input.query && actions.length === 0) {
|
|
282
|
+
return handleActionMatrixError(
|
|
283
|
+
input.json,
|
|
284
|
+
"ACTION_SEARCH_EMPTY",
|
|
285
|
+
`no action capability matches search "${input.query}" across Mobile, Extension, or Core.`,
|
|
286
|
+
inspectCommand,
|
|
287
|
+
{ query: input.query, category: input.category }
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (input.json) {
|
|
291
|
+
console.log(JSON.stringify({
|
|
292
|
+
schemaVersion: 1,
|
|
293
|
+
command: "actions",
|
|
294
|
+
view: "matrix",
|
|
295
|
+
adapters: ACTION_MATRIX_ADAPTERS,
|
|
296
|
+
...input.query ? { query: input.query } : {},
|
|
297
|
+
...input.category ? { category: input.category } : {},
|
|
298
|
+
actions
|
|
299
|
+
}, null, 2));
|
|
300
|
+
} else {
|
|
301
|
+
console.log(renderHumanActionMatrix(actions));
|
|
302
|
+
}
|
|
303
|
+
return EXIT.ok;
|
|
304
|
+
}
|
|
305
|
+
function renderHumanActionMatrix(actions) {
|
|
306
|
+
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
307
|
+
const width = Math.max("action capability".length, ...actions.map((action) => action.name.length));
|
|
308
|
+
const cell = (status, width2) => status === "available" ? out("accent", "yes".padEnd(width2)) : out("dim", "\u2014".padEnd(width2));
|
|
309
|
+
return [
|
|
310
|
+
out("bold", "action capability matrix"),
|
|
311
|
+
out("comment", "Inspect one: mm-harness actions --matrix --action <name> --json"),
|
|
312
|
+
`${"action capability".padEnd(width)} mobile extension core`,
|
|
313
|
+
...actions.map(
|
|
314
|
+
(action) => `${action.name.padEnd(width)} ${cell(action.support.mobile, 6)} ${cell(action.support.extension, 9)} ${cell(action.support.core, 4)}`
|
|
315
|
+
)
|
|
316
|
+
].join("\n");
|
|
317
|
+
}
|
|
129
318
|
function fuzzyResolveActions(entries, query) {
|
|
130
319
|
const exactFull = entries.filter((e) => e.name === query);
|
|
131
320
|
if (exactFull.length > 0) return exactFull;
|
|
@@ -393,13 +582,19 @@ function levenshtein(left, right) {
|
|
|
393
582
|
return prior[right.length] ?? right.length;
|
|
394
583
|
}
|
|
395
584
|
export {
|
|
585
|
+
ACTION_MATRIX_ADAPTERS,
|
|
396
586
|
actionExampleCommand,
|
|
587
|
+
actionLibraryContextArgs,
|
|
397
588
|
describeManifestActions,
|
|
398
589
|
findRelatedActions,
|
|
399
590
|
fuzzyResolveActions,
|
|
400
591
|
handleActions,
|
|
592
|
+
missingActionCapabilities,
|
|
401
593
|
renderHumanActionCatalog,
|
|
402
594
|
renderHumanActionExample,
|
|
595
|
+
renderHumanActionMatrix,
|
|
596
|
+
resolveActionCapabilityMatrix,
|
|
597
|
+
resolveActionCapabilityRefusal,
|
|
403
598
|
searchActions,
|
|
404
599
|
summarizeActionCategories,
|
|
405
600
|
summarizeDescribedAction
|
package/dist/commands/run.js
CHANGED
|
@@ -35,6 +35,30 @@ import { JsonStreamWriter } from "../json-stream.js";
|
|
|
35
35
|
import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run-diagnostics.js";
|
|
36
36
|
import { recipeTrustFailure } from "../recipe-security.js";
|
|
37
37
|
import { recordCommandEvidence, redactStructuredValue } from "../command-journal.js";
|
|
38
|
+
import {
|
|
39
|
+
actionLibraryContextArgs,
|
|
40
|
+
missingActionCapabilities,
|
|
41
|
+
resolveActionCapabilityMatrix
|
|
42
|
+
} from "./manifest.js";
|
|
43
|
+
async function validationCapabilityRefusals(adapter, findings, librarySources) {
|
|
44
|
+
const actionNames = findings.flatMap((finding) => {
|
|
45
|
+
if (finding.code !== "recipe.action_not_declared_by_manifest") return [];
|
|
46
|
+
const match = /^Recipe action ([^ ]+) is not declared by the runner action manifest[.]?$/u.exec(finding.message);
|
|
47
|
+
return match?.[1] ? [match[1]] : [];
|
|
48
|
+
});
|
|
49
|
+
if (actionNames.length === 0) return [];
|
|
50
|
+
return missingActionCapabilities(
|
|
51
|
+
adapter,
|
|
52
|
+
actionNames,
|
|
53
|
+
await resolveActionCapabilityMatrix(librarySources)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
function capabilityValidationUserAction(adapter, refusals, libraryContextArgs) {
|
|
57
|
+
const capabilities = refusals.map(
|
|
58
|
+
(refusal) => `"${refusal.capability}" [${refusal.satisfyingAdapters.join(", ")}]`
|
|
59
|
+
).join("; ");
|
|
60
|
+
return `Missing action capabilities for ${adapter}, with satisfying adapters: ${capabilities}. Inspect: mm-harness actions --matrix${libraryContextArgs} --json; then rerun from a checkout matching a satisfying adapter, splitting the recipe if no single adapter satisfies every capability`;
|
|
61
|
+
}
|
|
38
62
|
async function handleRun(parsed) {
|
|
39
63
|
const stream = new JsonStreamWriter("run", optionFlag(parsed.options, "jsonStream"));
|
|
40
64
|
const restoreStdout = stream.isolateStdout();
|
|
@@ -165,7 +189,21 @@ async function handleRunInner({ positional, options }, stream) {
|
|
|
165
189
|
);
|
|
166
190
|
}
|
|
167
191
|
if (validated.errorCount > 0) {
|
|
168
|
-
|
|
192
|
+
const capabilityRefusals = optionString(options, "actionManifest") ? [] : await validationCapabilityRefusals(
|
|
193
|
+
adapter,
|
|
194
|
+
validated.findings,
|
|
195
|
+
validated.librarySources
|
|
196
|
+
);
|
|
197
|
+
return emitRunValidationError(
|
|
198
|
+
jsonOutput,
|
|
199
|
+
stream,
|
|
200
|
+
adapter,
|
|
201
|
+
validated.recipeFile,
|
|
202
|
+
validated.findings,
|
|
203
|
+
validated.errorCount,
|
|
204
|
+
capabilityRefusals,
|
|
205
|
+
actionLibraryContextArgs(validated.librarySources)
|
|
206
|
+
);
|
|
169
207
|
}
|
|
170
208
|
const depsBlock = adapter === "core" && await recipeUsesCoreController(validated.recipe, validated.librarySources) ? coreDependencyBlock(target) : null;
|
|
171
209
|
if (depsBlock) {
|
|
@@ -447,8 +485,23 @@ async function handleRunPlan(recipeArg, params, options, stream) {
|
|
|
447
485
|
userAction
|
|
448
486
|
);
|
|
449
487
|
}
|
|
450
|
-
const {
|
|
488
|
+
const {
|
|
489
|
+
recipe,
|
|
490
|
+
recipeFile,
|
|
491
|
+
findings,
|
|
492
|
+
errorCount,
|
|
493
|
+
manifestOk,
|
|
494
|
+
schemaValid,
|
|
495
|
+
effectiveParams,
|
|
496
|
+
librarySources
|
|
497
|
+
} = validated;
|
|
451
498
|
const status = errorCount === 0 ? "pass" : "fail";
|
|
499
|
+
const capabilityRefusals = status === "fail" && !optionString(options, "actionManifest") ? await validationCapabilityRefusals(adapter, findings, librarySources) : [];
|
|
500
|
+
const failureUserAction = capabilityRefusals.length > 0 ? capabilityValidationUserAction(
|
|
501
|
+
adapter,
|
|
502
|
+
capabilityRefusals,
|
|
503
|
+
actionLibraryContextArgs(librarySources)
|
|
504
|
+
) : runPlanProbe(adapter, recipeFile);
|
|
452
505
|
const nodeCount = countRecipeNodes(recipe);
|
|
453
506
|
const artifactsDir = optionString(options, "artifactsDir");
|
|
454
507
|
const plan = [
|
|
@@ -514,7 +567,8 @@ async function handleRunPlan(recipeArg, params, options, stream) {
|
|
|
514
567
|
payload.error = {
|
|
515
568
|
code: "RECIPE_VALIDATION_FAILED",
|
|
516
569
|
message: `recipe validation found ${errorCount} error(s)`,
|
|
517
|
-
|
|
570
|
+
...capabilityRefusals.length > 0 ? { missingCapabilities: capabilityRefusals } : {},
|
|
571
|
+
userAction: failureUserAction
|
|
518
572
|
};
|
|
519
573
|
}
|
|
520
574
|
if (stream.enabled) {
|
|
@@ -534,7 +588,7 @@ async function handleRunPlan(recipeArg, params, options, stream) {
|
|
|
534
588
|
console.log(` ${finding.severity === "error" ? "\u2717" : "\u26A0"} ${finding.code} ${finding.path} \u2014 ${finding.message}`);
|
|
535
589
|
}
|
|
536
590
|
}
|
|
537
|
-
if (status === "fail") console.error(` Next: ${
|
|
591
|
+
if (status === "fail") console.error(` Next: ${failureUserAction}`);
|
|
538
592
|
}
|
|
539
593
|
return status === "pass" ? EXIT.ok : EXIT.validation;
|
|
540
594
|
}
|
|
@@ -582,10 +636,17 @@ function emitRunUsageError(json, stream, adapter, recipeFile, code, message, use
|
|
|
582
636
|
}
|
|
583
637
|
return EXIT.usage;
|
|
584
638
|
}
|
|
585
|
-
function emitRunValidationError(json, stream, adapter, recipeFile, findings, errorCount) {
|
|
639
|
+
function emitRunValidationError(json, stream, adapter, recipeFile, findings, errorCount, capabilityRefusals, libraryContextArgs) {
|
|
586
640
|
const message = `recipe validation found ${errorCount} error(s)`;
|
|
587
|
-
const userAction = runPlanProbe(adapter, recipeFile);
|
|
588
|
-
|
|
641
|
+
const userAction = capabilityRefusals.length > 0 ? capabilityValidationUserAction(adapter, capabilityRefusals, libraryContextArgs) : runPlanProbe(adapter, recipeFile);
|
|
642
|
+
const capabilityDetails = capabilityRefusals.length > 0 ? { missingCapabilities: capabilityRefusals } : {};
|
|
643
|
+
stream.error({
|
|
644
|
+
code: "RECIPE_VALIDATION_FAILED",
|
|
645
|
+
message,
|
|
646
|
+
userAction,
|
|
647
|
+
findings,
|
|
648
|
+
...capabilityDetails
|
|
649
|
+
});
|
|
589
650
|
if (json) {
|
|
590
651
|
console.log(
|
|
591
652
|
JSON.stringify(
|
|
@@ -599,7 +660,12 @@ function emitRunValidationError(json, stream, adapter, recipeFile, findings, err
|
|
|
599
660
|
mutations: [],
|
|
600
661
|
recipe: recipeFile,
|
|
601
662
|
findings,
|
|
602
|
-
error: {
|
|
663
|
+
error: {
|
|
664
|
+
code: "RECIPE_VALIDATION_FAILED",
|
|
665
|
+
message,
|
|
666
|
+
userAction,
|
|
667
|
+
...capabilityDetails
|
|
668
|
+
}
|
|
603
669
|
},
|
|
604
670
|
null,
|
|
605
671
|
2
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -121,6 +121,7 @@ Materialize:
|
|
|
121
121
|
|
|
122
122
|
query Search names, categories, fields, and descriptions (typo-tolerant)
|
|
123
123
|
--action <name> Describe one action; fuzzy-resolves like call (short or full name)
|
|
124
|
+
--matrix Compare action availability across Mobile, Extension, and Core
|
|
124
125
|
--categories List compact action categories and counts
|
|
125
126
|
--category <name> List only one category (for example ui, wallet, or perps)
|
|
126
127
|
--adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
|
|
@@ -130,6 +131,7 @@ Materialize:
|
|
|
130
131
|
|
|
131
132
|
Example:
|
|
132
133
|
mm-harness actions --adapter mobile
|
|
134
|
+
mm-harness actions --matrix --json
|
|
133
135
|
mm-harness actions positions --adapter mobile
|
|
134
136
|
mm-harness actions --adapter mobile --categories --json
|
|
135
137
|
mm-harness actions --adapter mobile --category ui --json
|
package/dist/recipe-security.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { lstat, readFile, readdir, realpath } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
|
|
5
|
+
"ui.locators",
|
|
5
6
|
"metamask.wallet.fixture_status",
|
|
6
7
|
"metamask.wallet.list_accounts",
|
|
7
8
|
"metamask.wallet.read_state",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
import {
|
|
3
|
+
locatorsFromObservation,
|
|
4
|
+
requireVisibleObservation,
|
|
5
|
+
} from '../../shared/ui/locators.mjs';
|
|
6
|
+
|
|
7
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
8
|
+
const observed = await page.observe(['ui.visible']);
|
|
9
|
+
return {
|
|
10
|
+
action: input.action,
|
|
11
|
+
...locatorsFromObservation(requireVisibleObservation(observed)),
|
|
12
|
+
};
|
|
13
|
+
}));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
|
|
4
|
+
import { bridgeCommand } from './bridge.mjs';
|
|
4
5
|
import { mobileToolRecovery, resolveMobileToolPath } from './tool-paths.mjs';
|
|
5
6
|
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
@@ -8,6 +9,7 @@ const VISIBLE_LIMIT = 50;
|
|
|
8
9
|
const HIDDEN_LIMIT = 50;
|
|
9
10
|
|
|
10
11
|
export async function observeNativeUi(payload, context) {
|
|
12
|
+
await hideAccessibilityMaskingHud(payload, context);
|
|
11
13
|
const refs = Array.isArray(payload?.refs)
|
|
12
14
|
? payload.refs.filter((ref) => typeof ref === 'string')
|
|
13
15
|
: [];
|
|
@@ -38,6 +40,25 @@ export async function observeNativeUi(payload, context) {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
async function hideAccessibilityMaskingHud(payload, context) {
|
|
44
|
+
try {
|
|
45
|
+
await bridgeCommand(
|
|
46
|
+
{
|
|
47
|
+
action: 'ui.observe',
|
|
48
|
+
node: {
|
|
49
|
+
...record(payload),
|
|
50
|
+
bridge_timeout_ms: 2_000,
|
|
51
|
+
cdp_timeout_ms: 2_000,
|
|
52
|
+
},
|
|
53
|
+
context,
|
|
54
|
+
},
|
|
55
|
+
['hide-step'],
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
// Native observation remains warning-only when the separate iOS HUD window is unavailable.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
async function readNativeHierarchy(payload, context) {
|
|
42
63
|
const node = record(payload?.node);
|
|
43
64
|
const env = {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { observeNativeUi } from '../platform/observe-ui.mjs';
|
|
2
|
+
import { runAdapter } from '../platform/bridge.mjs';
|
|
3
|
+
import {
|
|
4
|
+
locatorsFromObservation,
|
|
5
|
+
requireVisibleObservation,
|
|
6
|
+
} from '../../shared/ui/locators.mjs';
|
|
7
|
+
|
|
8
|
+
runAdapter(async (input) => {
|
|
9
|
+
const observed = await observeNativeUi(
|
|
10
|
+
{ refs: ['ui.visible'], node: input.node },
|
|
11
|
+
input.context,
|
|
12
|
+
);
|
|
13
|
+
return {
|
|
14
|
+
action: input.action,
|
|
15
|
+
...locatorsFromObservation(requireVisibleObservation(observed)),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const LOCATOR_LIMIT = 20;
|
|
2
|
+
const INTERACTIVE_ROLE_PARTS = [
|
|
3
|
+
'button',
|
|
4
|
+
'textfield',
|
|
5
|
+
'edittext',
|
|
6
|
+
'searchfield',
|
|
7
|
+
'textarea',
|
|
8
|
+
'checkbox',
|
|
9
|
+
'radiobutton',
|
|
10
|
+
'switch',
|
|
11
|
+
'togglebutton',
|
|
12
|
+
'slider',
|
|
13
|
+
'seekbar',
|
|
14
|
+
'picker',
|
|
15
|
+
'spinner',
|
|
16
|
+
'link',
|
|
17
|
+
'tab',
|
|
18
|
+
'menuitem',
|
|
19
|
+
'cell',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function locatorsFromObservation(observation) {
|
|
23
|
+
const value = record(observation);
|
|
24
|
+
const items = Array.isArray(value.items) ? value.items.map(record) : [];
|
|
25
|
+
const ranked = items
|
|
26
|
+
.filter(isInteractive)
|
|
27
|
+
.map(locatorFor)
|
|
28
|
+
.filter((locator) => locator.suggestions.length > 0)
|
|
29
|
+
.sort((left, right) => {
|
|
30
|
+
const leftBest = left.suggestions[0];
|
|
31
|
+
const rightBest = right.suggestions[0];
|
|
32
|
+
return (
|
|
33
|
+
confidenceRank(leftBest?.confidence) - confidenceRank(rightBest?.confidence) ||
|
|
34
|
+
strategyRank(leftBest?.strategy) - strategyRank(rightBest?.strategy)
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
const locators = ranked.slice(0, LOCATOR_LIMIT);
|
|
38
|
+
return {
|
|
39
|
+
provider: text(value.provider) ?? 'unknown',
|
|
40
|
+
observed: items.length,
|
|
41
|
+
count: locators.length,
|
|
42
|
+
truncated: value.truncated === true || ranked.length > locators.length,
|
|
43
|
+
locators,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function requireVisibleObservation(result) {
|
|
48
|
+
const value = record(result);
|
|
49
|
+
const observations = record(value.observations);
|
|
50
|
+
const visible = observations['ui.visible'];
|
|
51
|
+
if (visible && typeof visible === 'object' && !Array.isArray(visible)) {
|
|
52
|
+
return visible;
|
|
53
|
+
}
|
|
54
|
+
const warning = Array.isArray(value.warnings)
|
|
55
|
+
? value.warnings.map((entry) => text(record(entry).message)).find(Boolean)
|
|
56
|
+
: undefined;
|
|
57
|
+
throw new Error(
|
|
58
|
+
`ui.locators could not read the current ui.visible observation${warning ? `: ${warning}` : '.'}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isInteractive(item) {
|
|
63
|
+
const role = text(item.role)?.toLowerCase() ?? '';
|
|
64
|
+
return (
|
|
65
|
+
Boolean(text(item.selector)) ||
|
|
66
|
+
INTERACTIVE_ROLE_PARTS.some((part) => role.includes(part)) ||
|
|
67
|
+
(item.enabled !== false && Boolean(text(item.test_id)))
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function locatorFor(item) {
|
|
72
|
+
const testId = text(item.test_id);
|
|
73
|
+
const label = text(item.label);
|
|
74
|
+
const selector = text(item.selector);
|
|
75
|
+
const role = text(item.role);
|
|
76
|
+
const suggestions = [];
|
|
77
|
+
if (testId) {
|
|
78
|
+
suggestions.push({
|
|
79
|
+
strategy: 'test_id',
|
|
80
|
+
value: testId,
|
|
81
|
+
confidence: 'high',
|
|
82
|
+
params: { test_id: testId },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (label && supportsTextTarget(role)) {
|
|
86
|
+
suggestions.push({
|
|
87
|
+
strategy: 'label',
|
|
88
|
+
value: label,
|
|
89
|
+
confidence: testId ? 'medium' : 'high',
|
|
90
|
+
params: { text: label },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (selector && !selectorRepresentsTestId(selector, testId)) {
|
|
94
|
+
suggestions.push({
|
|
95
|
+
strategy: 'selector',
|
|
96
|
+
value: selector,
|
|
97
|
+
confidence: stableSelector(selector) ? 'high' : 'low',
|
|
98
|
+
params: { selector },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
suggestions.sort((left, right) => (
|
|
102
|
+
confidenceRank(left.confidence) - confidenceRank(right.confidence) ||
|
|
103
|
+
strategyRank(left.strategy) - strategyRank(right.strategy)
|
|
104
|
+
));
|
|
105
|
+
return {
|
|
106
|
+
description: describe(item),
|
|
107
|
+
role,
|
|
108
|
+
enabled: item.enabled !== false,
|
|
109
|
+
bounds: bounds(item.bounds),
|
|
110
|
+
suggestions,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function supportsTextTarget(role) {
|
|
115
|
+
const normalized = role?.toLowerCase() ?? '';
|
|
116
|
+
return ['button', 'link', 'tab', 'menuitem', 'cell'].some((part) =>
|
|
117
|
+
normalized.includes(part));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function describe(item) {
|
|
121
|
+
const parts = [text(item.role) ?? 'element'];
|
|
122
|
+
if (text(item.label)) parts.push(`label=${JSON.stringify(text(item.label))}`);
|
|
123
|
+
if (text(item.test_id)) parts.push(`test_id=${JSON.stringify(text(item.test_id))}`);
|
|
124
|
+
return parts.join(' ');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function bounds(value) {
|
|
128
|
+
const candidate = record(value);
|
|
129
|
+
const keys = ['x', 'y', 'width', 'height'];
|
|
130
|
+
if (!keys.every((key) => Number.isFinite(candidate[key]))) return undefined;
|
|
131
|
+
return Object.fromEntries(keys.map((key) => [key, candidate[key]]));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function selectorRepresentsTestId(selector, testId) {
|
|
135
|
+
return Boolean(testId && selector.includes('data-test') && selector.includes(testId));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stableSelector(selector) {
|
|
139
|
+
return /^\[(?:id|data-test(?:id|-id))=/u.test(selector);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function confidenceRank(value) {
|
|
143
|
+
if (value === 'high') return 0;
|
|
144
|
+
if (value === 'medium') return 1;
|
|
145
|
+
return 2;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function strategyRank(value) {
|
|
149
|
+
if (value === 'test_id') return 0;
|
|
150
|
+
if (value === 'selector') return 1;
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function text(value) {
|
|
155
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function record(value) {
|
|
159
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
160
|
+
}
|
|
@@ -716,6 +716,24 @@
|
|
|
716
716
|
}
|
|
717
717
|
]
|
|
718
718
|
},
|
|
719
|
+
"ui.locators": {
|
|
720
|
+
"description": "List ranked, copy-pasteable ui.press targets from the current ui.visible observation. Prefer high-confidence test IDs when updating durable recipe source after exploratory interaction.",
|
|
721
|
+
"execution_capabilities": [
|
|
722
|
+
"host-read-export"
|
|
723
|
+
],
|
|
724
|
+
"schema": {
|
|
725
|
+
"type": "object",
|
|
726
|
+
"properties": {},
|
|
727
|
+
"additionalProperties": false
|
|
728
|
+
},
|
|
729
|
+
"examples": [
|
|
730
|
+
{
|
|
731
|
+
"action": "ui.locators",
|
|
732
|
+
"intent": "Find stable targets for durable recipe authoring.",
|
|
733
|
+
"next": "done"
|
|
734
|
+
}
|
|
735
|
+
]
|
|
736
|
+
},
|
|
719
737
|
"ui.screenshot": {
|
|
720
738
|
"description": "Capture registered visual evidence through capture-helper, bounded Chrome-native capture, or an explicitly labeled computed-style DOM raster fallback. Always read the resulting PNG; artifact metadata records the provider.",
|
|
721
739
|
"examples": [
|
|
@@ -734,6 +734,24 @@
|
|
|
734
734
|
}
|
|
735
735
|
]
|
|
736
736
|
},
|
|
737
|
+
"ui.locators": {
|
|
738
|
+
"description": "List ranked, copy-pasteable ui.press targets from the current ui.visible observation. Prefer high-confidence test IDs when updating durable recipe source after exploratory interaction.",
|
|
739
|
+
"execution_capabilities": [
|
|
740
|
+
"host-read-export"
|
|
741
|
+
],
|
|
742
|
+
"schema": {
|
|
743
|
+
"type": "object",
|
|
744
|
+
"properties": {},
|
|
745
|
+
"additionalProperties": false
|
|
746
|
+
},
|
|
747
|
+
"examples": [
|
|
748
|
+
{
|
|
749
|
+
"action": "ui.locators",
|
|
750
|
+
"intent": "Find stable targets for durable recipe authoring.",
|
|
751
|
+
"next": "done"
|
|
752
|
+
}
|
|
753
|
+
]
|
|
754
|
+
},
|
|
737
755
|
"ui.screenshot": {
|
|
738
756
|
"description": "Capture registered PNG evidence with xcrun simctl on iOS or adb screencap on Android. Artifact metadata records the native provider, command mode, and selected device; invalid or empty PNG output fails before registration.",
|
|
739
757
|
"examples": [
|