@deeeed/metamask-harness 0.17.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +1059 -0
  2. package/README.md +91 -140
  3. package/adapters/core/inject.sh +1 -6
  4. package/adapters/extension/console-tail.mjs +55 -24
  5. package/adapters/extension/ensure-browser.sh +6 -1
  6. package/adapters/extension/inject.mjs +7 -13
  7. package/adapters/extension/launch-browser.cjs +7 -1
  8. package/adapters/extension/launch-webpack.cjs +83 -0
  9. package/adapters/extension/launch.sh +8 -11
  10. package/adapters/extension/lib/chrome-args.cjs +8 -1
  11. package/adapters/extension/lib/macos-focus.cjs +32 -0
  12. package/adapters/extension/live.sh +12 -21
  13. package/adapters/extension/reattach.sh +1 -0
  14. package/adapters/extension/seed-fixture.sh +4 -12
  15. package/adapters/extension/sidepanel-toggle.sh +4 -1
  16. package/adapters/extension/stamp-runtime-title.cjs +58 -0
  17. package/adapters/extension/start-watch.sh +17 -4
  18. package/adapters/extension/stop-viewers.sh +1 -1
  19. package/adapters/extension/sync-webpack-dist.cjs +107 -0
  20. package/adapters/extension/verify.sh +2 -2
  21. package/adapters/manifest.json +53 -37
  22. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  23. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +1 -1
  24. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +3 -1
  25. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +2 -3
  26. package/adapters/mobile/cleanup.sh +16 -176
  27. package/adapters/mobile/inject.sh +91 -671
  28. package/adapters/mobile/launch-console-forwarder.cjs +39 -0
  29. package/adapters/mobile/open-device.sh +45 -7
  30. package/adapters/mobile/start-console-forwarder.sh +70 -0
  31. package/adapters/mobile/start-metro.sh +0 -41
  32. package/adapters/mobile/verify.sh +34 -83
  33. package/adapters/mobile/wait-for-bridge.sh +22 -8
  34. package/adapters/shared/harness-source-fingerprint.mjs +14 -12
  35. package/adapters/shared/resolve-slot-ports-core.mjs +17 -6
  36. package/adapters/shared/resolve-slot-ports.sh +17 -5
  37. package/adapters/shared/update-check-worker.mjs +43 -0
  38. package/bin/mm-harness +21 -2
  39. package/dist/adapters/core/surface.js +4 -1
  40. package/dist/adapters/extension/console-capture.js +95 -0
  41. package/dist/adapters/extension/product-config.js +110 -0
  42. package/dist/adapters/extension/runtime-decision.js +20 -2
  43. package/dist/adapters/extension/surface.js +22 -3
  44. package/dist/adapters/harness-freshness.js +49 -0
  45. package/dist/adapters/mobile/perps-env.js +43 -3
  46. package/dist/adapters/mobile/prepare.js +39 -7
  47. package/dist/adapters/mobile/surface.js +5 -2
  48. package/dist/adapters/resolve-slot-ports.js +2 -2
  49. package/dist/adapters/slot-ports.js +13 -16
  50. package/dist/adapters.js +56 -16
  51. package/dist/checkout-lock.js +27 -2
  52. package/dist/cli-color.js +19 -0
  53. package/dist/cli-commands.js +1 -1
  54. package/dist/cli.js +7 -18
  55. package/dist/command-contract.js +462 -0
  56. package/dist/command-journal.js +263 -0
  57. package/dist/commands/call.js +252 -57
  58. package/dist/commands/check.js +10 -4
  59. package/dist/commands/completion-candidates.js +17 -22
  60. package/dist/commands/device-target.js +27 -12
  61. package/dist/commands/doctor.js +106 -25
  62. package/dist/commands/fixtures.js +142 -35
  63. package/dist/commands/last.js +60 -0
  64. package/dist/commands/launch/extension.js +40 -17
  65. package/dist/commands/launch/index.js +207 -63
  66. package/dist/commands/launch/mobile.js +2 -0
  67. package/dist/commands/list-executables.js +187 -29
  68. package/dist/commands/logs.js +8 -6
  69. package/dist/commands/manifest.js +286 -35
  70. package/dist/commands/parse-args.js +68 -4
  71. package/dist/commands/provision.js +10 -4
  72. package/dist/commands/run-engine.js +772 -264
  73. package/dist/commands/run-report.js +12 -3
  74. package/dist/commands/run.js +378 -85
  75. package/dist/commands/shared.js +76 -4
  76. package/dist/commands/status-probe.js +4 -1
  77. package/dist/commands/status.js +2 -1
  78. package/dist/commands/stop.js +7 -2
  79. package/dist/commands/update.js +16 -28
  80. package/dist/completions-cache.js +1 -1
  81. package/dist/doctor.js +57 -10
  82. package/dist/harness.js +47 -137
  83. package/dist/heal-bounds.js +7 -2
  84. package/dist/json-stream.js +57 -0
  85. package/dist/live-adapter-contract.js +138 -32
  86. package/dist/manifest.js +161 -1
  87. package/dist/mm-harness-cli.js +146 -73
  88. package/dist/paths.js +2 -5
  89. package/dist/recipe-security.js +178 -0
  90. package/dist/run-diagnostics.js +261 -0
  91. package/dist/run-recording.js +1 -1
  92. package/dist/runner.js +140 -9
  93. package/docs/CONTRIBUTING.md +136 -0
  94. package/docs/QA.md +184 -0
  95. package/docs/RECIPES.md +122 -0
  96. package/docs/SECURITY.md +88 -0
  97. package/library/README.md +7 -3
  98. package/library/actions/core/perps/_controller.mjs +43 -56
  99. package/library/actions/core/perps/assert_orders.mjs +6 -7
  100. package/library/actions/core/perps/assert_positions.mjs +6 -7
  101. package/library/actions/core/perps/close_orders.mjs +2 -0
  102. package/library/actions/core/perps/close_positions.mjs +2 -0
  103. package/library/actions/core/perps/ensure_orders.mjs +4 -2
  104. package/library/actions/core/perps/ensure_positions.mjs +4 -2
  105. package/library/actions/core/perps/place_order.mjs +7 -3
  106. package/library/actions/core/perps/read_account.mjs +2 -2
  107. package/library/actions/core/perps/read_orders.mjs +2 -1
  108. package/library/actions/core/perps/read_positions.mjs +2 -1
  109. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  110. package/library/actions/extension/perps/assert_orders.mjs +2 -1
  111. package/library/actions/extension/perps/assert_positions.mjs +2 -1
  112. package/library/actions/extension/perps/perps.mjs +43 -14
  113. package/library/actions/extension/platform/cdp.mjs +1 -0
  114. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  115. package/library/actions/mobile/perps/assert_orders.mjs +2 -1
  116. package/library/actions/mobile/perps/assert_positions.mjs +2 -1
  117. package/library/actions/mobile/perps/perps.mjs +40 -12
  118. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  119. package/library/library.json +1 -1
  120. package/library/manifests/core.action-manifest.json +1214 -396
  121. package/library/manifests/extension.action-manifest.json +1635 -728
  122. package/library/manifests/mobile.action-manifest.json +1740 -727
  123. package/library/recipes/app/lifecycle.android-smoke.mobile.recipe.json +63 -81
  124. package/library/recipes/perps/clean-market-testnet.core.recipe.json +44 -0
  125. package/library/recipes/perps/clean-market-testnet.recipe.json +49 -0
  126. package/library/recipes/perps/lifecycle.recipe.json +136 -180
  127. package/library/recipes/perps/order-lifecycle.core.recipe.json +71 -67
  128. package/library/recipes/perps/performance.background-resume.mobile.recipe.json +51 -67
  129. package/library/recipes/perps/performance.cold-start.mobile.recipe.json +51 -67
  130. package/library/recipes/perps/performance.mobile.recipe.json +37 -51
  131. package/library/recipes/perps/performance.warm-start.mobile.recipe.json +44 -59
  132. package/library/recipes/perps/read-markets.core.recipe.json +29 -31
  133. package/library/recipes/perps/smoke.core.recipe.json +29 -32
  134. package/library/recipes/perps/smoke.extension.recipe.json +41 -44
  135. package/library/recipes/perps/smoke.mobile.recipe.json +42 -44
  136. package/library/recipes/perps/trading-lifecycle.core.recipe.json +69 -65
  137. package/library/recipes/runner/action-validation.extension.recipe.json +312 -398
  138. package/library/recipes/runner/action-validation.mobile.recipe.json +316 -402
  139. package/library/recipes/runner/smoke.core.recipe.json +25 -0
  140. package/library/recipes/runner/smoke.extension.recipe.json +23 -24
  141. package/library/recipes/runner/smoke.mobile.recipe.json +23 -24
  142. package/library/recipes/wallet/smoke.extension.recipe.json +40 -0
  143. package/library/recipes/wallet/smoke.mobile.recipe.json +40 -0
  144. package/package.json +12 -5
  145. package/scripts/completions.sh +8 -11
  146. package/scripts/validate-human-outcomes.mjs +169 -0
  147. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -185
  148. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -1662
  149. package/adapters/mobile/overlay/compat/README.md +0 -28
  150. package/adapters/mobile/overlay/compat/rn81-message-event-source.patch +0 -42
  151. package/adapters/shared/resolve-farmslot-ports-core.mjs +0 -3
  152. package/adapters/shared/resolve-farmslot-ports.mjs +0 -5
  153. package/adapters/shared/resolve-farmslot-ports.sh +0 -7
  154. package/dist/adapters/extension/harness-freshness.js +0 -39
  155. package/dist/commands/flows.js +0 -62
  156. package/docs/ADAPTER-SURFACE.md +0 -119
  157. package/docs/CHEATSHEET.md +0 -61
  158. package/docs/CLI-SPEC.md +0 -1098
  159. package/docs/CODE-MAP.md +0 -62
  160. package/docs/DEBUG-HANDOVER.md +0 -36
  161. package/docs/MENTAL-MODEL.md +0 -295
  162. package/docs/UX-PRINCIPLES.md +0 -64
  163. package/docs/architecture.md +0 -398
  164. package/docs/live-adapter-contract.md +0 -188
  165. package/docs/package-boundaries.md +0 -47
  166. package/docs/perps-flow-catalog.md +0 -235
  167. package/docs/recipe-libraries.md +0 -301
  168. package/docs/runtime-file-conventions.md +0 -36
  169. package/library/flows/perps.flows.json +0 -64
@@ -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
+ };
@@ -0,0 +1,261 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { spawnSync } from "node:child_process";
5
+ import { getAdapterSurface } from "./adapters/surface.js";
6
+ import { runnerDir } from "./paths.js";
7
+ import { ensureExtensionConsoleCapture } from "./adapters/extension/console-capture.js";
8
+ const MAX_CAPTURE_BYTES = 512 * 1024;
9
+ const MAX_FINDINGS = 20;
10
+ const MAX_PREVIEW_CHARS = 320;
11
+ function readRunDiagnosticsDocument(diagnosticsPath) {
12
+ if (typeof diagnosticsPath !== "string" || diagnosticsPath.length === 0) return null;
13
+ try {
14
+ const document = JSON.parse(fs.readFileSync(diagnosticsPath, "utf8"));
15
+ return isRunDiagnosticsDocument(document) ? document : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+ function formatRunDiagnosticsForHuman(diagnostics, adapter) {
21
+ if (!diagnostics) {
22
+ return [adapter === "core" ? "N/A \u2014 Core is headless and has no application log source." : "UNAVAILABLE \u2014 application diagnostics could not be collected for this run."];
23
+ }
24
+ const status = diagnostics.status === "clean" ? "CLEAN" : diagnostics.status === "review" ? "REVIEW" : "UNAVAILABLE";
25
+ const lines = [`${status} \u2014 ${diagnostics.note}`];
26
+ for (const finding of diagnostics.findings) {
27
+ const count = finding.count > 1 ? ` \xD7${finding.count}` : "";
28
+ lines.push(`${finding.level.toUpperCase()}${count} \u2014 ${finding.preview}`);
29
+ }
30
+ return lines;
31
+ }
32
+ async function beginRunDiagnostics(adapter, projectRoot) {
33
+ const source = getAdapterSurface(adapter).appLogSource(projectRoot);
34
+ if (!source) return null;
35
+ const mobileIssueBuffer = adapter === "mobile" && armMobileIssueBuffer(projectRoot) ? { projectRoot } : void 0;
36
+ if (adapter === "extension") {
37
+ await ensureExtensionConsoleCapture(projectRoot).catch(() => void 0);
38
+ }
39
+ const stat = safeStat(source.path);
40
+ return {
41
+ source,
42
+ offset: stat?.size ?? 0,
43
+ ...stat ? { inode: stat.ino } : {},
44
+ ...mobileIssueBuffer ? { mobileIssueBuffer } : {}
45
+ };
46
+ }
47
+ function isRunDiagnosticsDocument(value) {
48
+ if (!isRecord(value) || value.schemaVersion !== 1 || value.scope !== "recipe-run-application") return false;
49
+ if (value.status !== "clean" && value.status !== "review" && value.status !== "unavailable") return false;
50
+ return typeof value.note === "string" && Array.isArray(value.findings);
51
+ }
52
+ function finishRunDiagnostics(baseline, result) {
53
+ if (!baseline) return result;
54
+ try {
55
+ const bufferedIssues = baseline.mobileIssueBuffer ? collectMobileIssueBuffer(baseline.mobileIssueBuffer.projectRoot) : void 0;
56
+ const diagnostics = collectRunDiagnostics(baseline, bufferedIssues);
57
+ const artifactsDir = path.dirname(result.summaryPath);
58
+ const diagnosticsPath = path.join(artifactsDir, "diagnostics.json");
59
+ fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}
60
+ `);
61
+ indexDiagnosticArtifact(result.artifactManifestPath);
62
+ indexDiagnosticSummary(result.summaryPath, diagnostics);
63
+ return {
64
+ ...result,
65
+ diagnosticsPath,
66
+ sideFindings: {
67
+ status: diagnostics.status,
68
+ note: diagnostics.note,
69
+ counts: diagnostics.counts
70
+ }
71
+ };
72
+ } catch (error) {
73
+ const detail = error instanceof Error ? error.message : String(error);
74
+ console.warn(`WARN: run diagnostics were unavailable: ${detail}`);
75
+ return {
76
+ ...result,
77
+ sideFindings: {
78
+ status: "unavailable",
79
+ note: "Run diagnostics could not be collected; the recipe result is unchanged.",
80
+ counts: { total: 0, warning: 0, error: 0, exception: 0 }
81
+ }
82
+ };
83
+ }
84
+ }
85
+ function collectRunDiagnostics(baseline, bufferedIssues) {
86
+ const stat = safeStat(baseline.source.path);
87
+ const source = {
88
+ label: baseline.source.label,
89
+ path: baseline.source.path,
90
+ startOffset: baseline.offset,
91
+ endOffset: stat?.size ?? baseline.offset,
92
+ bytesRead: 0,
93
+ truncated: false,
94
+ inAppBuffer: bufferedIssues === void 0 ? "n/a" : bufferedIssues === null ? "unavailable" : "collected"
95
+ };
96
+ let text = "";
97
+ if (stat) {
98
+ const sameFile = baseline.inode === void 0 || baseline.inode === stat.ino;
99
+ const startOffset = sameFile && stat.size >= baseline.offset ? baseline.offset : 0;
100
+ const available = Math.max(0, stat.size - startOffset);
101
+ const bytesToRead = Math.min(available, MAX_CAPTURE_BYTES);
102
+ source.startOffset = startOffset;
103
+ source.endOffset = stat.size;
104
+ source.bytesRead = bytesToRead;
105
+ source.truncated = available > MAX_CAPTURE_BYTES;
106
+ if (bytesToRead > 0) text = readSlice(baseline.source.path, startOffset, bytesToRead);
107
+ }
108
+ const allFindings = dedupeFindings(
109
+ [
110
+ ...text.split(/\r?\n/u).map(classifyLine).filter((finding) => finding !== null),
111
+ ...(bufferedIssues ?? []).map(classifyBufferedIssue).filter((finding) => finding !== null)
112
+ ]
113
+ );
114
+ const findings = allFindings.slice(0, MAX_FINDINGS);
115
+ const counts = countFindings(allFindings);
116
+ const status = counts.total > 0 ? "review" : stat || bufferedIssues !== void 0 && bufferedIssues !== null ? "clean" : "unavailable";
117
+ const note = counts.total > 0 ? `Observed ${counts.total} distinct application warning/error event(s) during the recipe run; relation to the task is not determined.` : status === "clean" ? "No application warnings or errors were emitted during the recipe run." : "Application diagnostics were unavailable for this run.";
118
+ return {
119
+ schemaVersion: 1,
120
+ scope: "recipe-run-application",
121
+ status,
122
+ nonBlocking: true,
123
+ note,
124
+ source,
125
+ counts,
126
+ findings
127
+ };
128
+ }
129
+ function classifyLine(line) {
130
+ const trimmed = line.trim();
131
+ if (!trimmed) return null;
132
+ let level = null;
133
+ if (/\bEXCEPTION\s{2,}|\b(?:Uncaught|UnhandledPromiseRejection|FATAL EXCEPTION)\b/iu.test(trimmed)) {
134
+ level = "exception";
135
+ } else if (/^(?:ERROR\s{2,}|\[error\]\s*)/iu.test(trimmed) || /\]\s+ERROR\s{2,}/u.test(trimmed)) {
136
+ level = "error";
137
+ } else if (/^(?:WARN(?:ING)?\s{2,}|\[warn(?:ing)?\]\s*)/iu.test(trimmed) || /\]\s+WARN(?:ING)?\s{2,}/u.test(trimmed)) {
138
+ level = "warning";
139
+ }
140
+ if (!level) return null;
141
+ return makeFinding(level, trimmed);
142
+ }
143
+ function classifyBufferedIssue(value) {
144
+ if (!isRecord(value) || typeof value.text !== "string") return null;
145
+ const rawLevel = String(value.level ?? "").toLowerCase();
146
+ const level = rawLevel === "warn" || rawLevel === "warning" ? "warning" : rawLevel === "error" ? "error" : rawLevel === "exception" ? "exception" : null;
147
+ return level ? makeFinding(level, value.text) : null;
148
+ }
149
+ function makeFinding(level, text) {
150
+ const preview = redactPreview(text.trim());
151
+ const identity = preview.replace(/\b\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?\b/gu, "[TIME]").replace(/\[(?:sw|page:[^\]]+|console:[^\]]+)\]/gu, "[APP]").replace(/^(?:(?:WARN(?:ING)?|ERROR|EXCEPTION|\[TIME\]|\[APP\])\s*)+/u, "");
152
+ return {
153
+ level,
154
+ fingerprint: createHash("sha256").update(`${level}|${identity}`).digest("hex").slice(0, 12),
155
+ preview
156
+ };
157
+ }
158
+ function armMobileIssueBuffer(projectRoot) {
159
+ const armed = runMobileIssueCommand(projectRoot, "issues-arm");
160
+ if (!armed) return false;
161
+ return runMobileIssueCommand(projectRoot, "issues-collect") !== null;
162
+ }
163
+ function collectMobileIssueBuffer(projectRoot) {
164
+ const result = runMobileIssueCommand(projectRoot, "issues-collect");
165
+ return isRecord(result) && Array.isArray(result.entries) ? result.entries : null;
166
+ }
167
+ function runMobileIssueCommand(projectRoot, command) {
168
+ const bridge = path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
169
+ const result = spawnSync(process.execPath, [bridge, command], {
170
+ cwd: projectRoot,
171
+ env: { ...process.env, APP_ROOT: projectRoot, CDP_TIMEOUT: "5000" },
172
+ encoding: "utf8",
173
+ timeout: 1e4
174
+ });
175
+ if (result.status !== 0) return null;
176
+ try {
177
+ return JSON.parse(result.stdout.trim());
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+ function redactPreview(value) {
183
+ return value.replace(/\b(Bearer)\s+\S+/giu, "$1 [REDACTED]").replace(/\b(api[-_]?key|password|passphrase|mnemonic|seed(?:Phrase)?|privateKey|secret|token|authorization|vault)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/giu, "$1=[REDACTED]").replace(/\b(?:0x)?[a-f0-9]{64,}\b/giu, "[REDACTED_HEX]").replace(/(https?:\/\/[^\s?]+)\?\S+/giu, "$1?[REDACTED_QUERY]").slice(0, MAX_PREVIEW_CHARS);
184
+ }
185
+ function dedupeFindings(findings) {
186
+ const byFingerprint = /* @__PURE__ */ new Map();
187
+ for (const finding of findings) {
188
+ const existing = byFingerprint.get(finding.fingerprint);
189
+ if (existing) existing.count += 1;
190
+ else byFingerprint.set(finding.fingerprint, { ...finding, count: 1 });
191
+ }
192
+ return [...byFingerprint.values()];
193
+ }
194
+ function countFindings(findings) {
195
+ const counts = { total: findings.length, warning: 0, error: 0, exception: 0 };
196
+ for (const finding of findings) counts[finding.level] += 1;
197
+ return counts;
198
+ }
199
+ function readSlice(filePath, offset, length) {
200
+ const fd = fs.openSync(filePath, "r");
201
+ try {
202
+ const buffer = Buffer.alloc(length);
203
+ const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
204
+ return buffer.subarray(0, bytesRead).toString("utf8");
205
+ } finally {
206
+ fs.closeSync(fd);
207
+ }
208
+ }
209
+ function safeStat(filePath) {
210
+ try {
211
+ return fs.statSync(filePath);
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+ function indexDiagnosticArtifact(manifestPath) {
217
+ const manifest = readJsonRecord(manifestPath);
218
+ if (!manifest) return;
219
+ const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
220
+ manifest.artifacts = [
221
+ ...artifacts.filter((artifact) => !isRecord(artifact) || artifact.path !== "diagnostics.json"),
222
+ {
223
+ path: "diagnostics.json",
224
+ type: "json",
225
+ label: "Run-scoped application diagnostics",
226
+ category: "diagnostic"
227
+ }
228
+ ];
229
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
230
+ `);
231
+ }
232
+ function indexDiagnosticSummary(summaryPath, diagnostics) {
233
+ const summary = readJsonRecord(summaryPath);
234
+ if (!summary) return;
235
+ summary.sideFindings = {
236
+ status: diagnostics.status,
237
+ nonBlocking: true,
238
+ counts: diagnostics.counts,
239
+ diagnosticsPath: "diagnostics.json"
240
+ };
241
+ fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}
242
+ `);
243
+ }
244
+ function readJsonRecord(filePath) {
245
+ try {
246
+ const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
247
+ return isRecord(value) ? value : null;
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+ function isRecord(value) {
253
+ return typeof value === "object" && value !== null && !Array.isArray(value);
254
+ }
255
+ export {
256
+ beginRunDiagnostics,
257
+ collectRunDiagnostics,
258
+ finishRunDiagnostics,
259
+ formatRunDiagnosticsForHuman,
260
+ readRunDiagnosticsDocument
261
+ };
@@ -10,7 +10,7 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
10
10
  if (!options.record) return void 0;
11
11
  if (process.platform !== "darwin") {
12
12
  console.error(
13
- "WARN: --record uses capture-helper and is currently supported only on macOS; continuing without video."
13
+ "WARN: --record-video uses capture-helper and is currently supported only on macOS; continuing without video."
14
14
  );
15
15
  return void 0;
16
16
  }
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,37 @@ 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";
58
- const logger = options.quietStdout ? new console.Console(process.stderr) : console;
71
+ const autoHudDisabled = options.autoHud === false || process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false";
72
+ const logger = deduplicateLibraryResolutionLogs(
73
+ options.quietStdout ? new console.Console(process.stderr) : console,
74
+ options.suppressLibraryResolutionLogs
75
+ );
59
76
  return createRecipeRunner({
60
77
  actionManifest,
61
- adapters: [...core, ...ui, ...lifecycle, ...custom],
78
+ adapters: withActionProgress(
79
+ [...core, ...ui, ...lifecycle, ...custom].map((entry) => ({
80
+ ...entry,
81
+ ...adapterSecurity(entry.action, actionSources, options.trustTaskActions)
82
+ })),
83
+ options.onActionEvent
84
+ ),
62
85
  logger,
63
86
  recording: {
64
87
  targetProvider: createMetaMaskRecordingTargetProvider(adapter)
65
88
  },
66
89
  runner: runnerProvenance(),
90
+ defaultSource: {
91
+ kind: "operator",
92
+ trust: "trusted",
93
+ name: "@deeeed/metamask-harness"
94
+ },
67
95
  hud: autoHudDisabled ? false : {
68
96
  enabled: true,
69
97
  display: {
@@ -76,6 +104,108 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
76
104
  }
77
105
  });
78
106
  }
107
+ function deduplicateLibraryResolutionLogs(logger, suppress = false) {
108
+ const seen = /* @__PURE__ */ new Set();
109
+ return {
110
+ info(message) {
111
+ if (message.startsWith("Recipe libraries:")) {
112
+ if (suppress) return;
113
+ if (seen.has(message)) return;
114
+ seen.add(message);
115
+ }
116
+ logger.info(message);
117
+ },
118
+ warn(message) {
119
+ logger.warn(message);
120
+ },
121
+ error(message) {
122
+ logger.error(message);
123
+ }
124
+ };
125
+ }
126
+ async function prepareLiveAdapterImplementations(platform, actions, sources, trustTaskActions = false) {
127
+ const prepared = /* @__PURE__ */ new Map();
128
+ if (!sources) return prepared;
129
+ await Promise.all(actions.map(async (action) => {
130
+ const source = sources.get(action);
131
+ if (!source?.implementationRoot || source.tier === "official") return;
132
+ const executable = await prepareLiveAdapterScript({
133
+ platform,
134
+ action,
135
+ implementationRoot: source.implementationRoot,
136
+ allowRuntimeImports: source.tier === "canonical" || source.trust === "trusted" || source.tier === "task" && trustTaskActions
137
+ });
138
+ if (!executable) return;
139
+ prepared.set(action, executable);
140
+ source.digest = executable.sourceDigest;
141
+ source.resolveDigest = void 0;
142
+ }));
143
+ return prepared;
144
+ }
145
+ function adapterSecurity(action, sources, trustTaskActions = false) {
146
+ const source = sources?.get(action);
147
+ if (source?.tier === "task") {
148
+ return {
149
+ source: {
150
+ kind: "custom-adapter",
151
+ trust: trustTaskActions ? "trusted" : "untrusted",
152
+ name: source.name,
153
+ path: source.manifestPath,
154
+ ...source.digest ? { digest: source.digest } : {}
155
+ },
156
+ ...source.resolveDigest ? { resolveSourceDigest: source.resolveDigest } : {}
157
+ };
158
+ }
159
+ if (source && source.tier !== "official") {
160
+ return {
161
+ source: {
162
+ kind: source.tier === "canonical" ? "bundled" : "custom-adapter",
163
+ trust: source.trust ?? (source.tier === "canonical" ? "trusted" : "unknown"),
164
+ name: source.name,
165
+ path: source.manifestPath,
166
+ ...source.digest ? { digest: source.digest } : {}
167
+ },
168
+ ...source.resolveDigest ? { resolveSourceDigest: source.resolveDigest } : {}
169
+ };
170
+ }
171
+ return {
172
+ source: {
173
+ kind: "bundled",
174
+ trust: "trusted",
175
+ name: "@deeeed/metamask-harness"
176
+ }
177
+ };
178
+ }
179
+ function withActionProgress(adapters, onActionEvent) {
180
+ if (!onActionEvent) return adapters;
181
+ return adapters.map((entry) => ({
182
+ ...entry,
183
+ async execute(node, context) {
184
+ const event = { nodeId: context.nodeId, action: entry.action };
185
+ const reportProgress = !isAutomaticHudProgress(entry.action, node, context.nodeId);
186
+ if (reportProgress) onActionEvent({ ...event, status: "running" });
187
+ try {
188
+ const result = await entry.execute(node, context);
189
+ if (reportProgress) {
190
+ onActionEvent({ ...event, status: "passed" });
191
+ }
192
+ return result;
193
+ } catch (error) {
194
+ if (reportProgress) onActionEvent({ ...event, status: "failed" });
195
+ throw error;
196
+ }
197
+ }
198
+ }));
199
+ }
200
+ function isAutomaticHudProgress(action, node, nodeId) {
201
+ if (action !== "app.hud" || node === null || typeof node !== "object" || Array.isArray(node)) {
202
+ return false;
203
+ }
204
+ const payload = node;
205
+ if (typeof payload.action_name === "string") return true;
206
+ const progress = payload.progress;
207
+ return nodeId === "recipe-complete" && progress !== null && typeof progress === "object" && !Array.isArray(progress) && progress.complete === true;
208
+ }
79
209
  function mobileSourceAwareLifecycleAdapters(adapter, lifecycle) {
80
210
  if (adapter !== "mobile") return lifecycle;
81
211
  return lifecycle.map((entry) => ({
@@ -116,5 +246,6 @@ export {
116
246
  createMetaMaskExtensionRunner,
117
247
  createMetaMaskMobileRunner,
118
248
  createMetaMaskRunner,
249
+ isAutomaticHudProgress,
119
250
  mobileSourceAwareLifecycleAdapters
120
251
  };