@deeeed/metamask-harness 0.73.0 → 0.75.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 (62) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/adapters/shared/ensure-runner-deps.sh +15 -3
  4. package/dist/action-values.js +15 -0
  5. package/dist/adapters/core/actions.js +20 -0
  6. package/dist/adapters/core/live-script-env.js +36 -0
  7. package/dist/adapters/core/run.js +54 -0
  8. package/dist/adapters/core/surface.js +8 -1
  9. package/dist/adapters/extension/action-inputs.js +206 -0
  10. package/dist/adapters/extension/actions.js +51 -0
  11. package/dist/adapters/extension/console-capture.js +13 -1
  12. package/dist/adapters/extension/launch.js +315 -0
  13. package/dist/adapters/extension/launched-browser.js +51 -0
  14. package/dist/adapters/extension/run.js +105 -0
  15. package/dist/adapters/extension/surface.js +17 -1
  16. package/dist/adapters/mobile/actions.js +54 -0
  17. package/dist/adapters/mobile/launch.js +192 -0
  18. package/dist/adapters/mobile/lifecycle.js +300 -0
  19. package/dist/adapters/mobile/run.js +156 -0
  20. package/dist/adapters/mobile/runtime-identity.js +34 -0
  21. package/dist/adapters/mobile/surface.js +19 -1
  22. package/dist/adapters/mobile/ui.js +701 -0
  23. package/dist/adapters/performance/cdp-trace.js +24 -6
  24. package/dist/adapters/performance/device-platform.js +10 -0
  25. package/dist/adapters/registry.js +17 -0
  26. package/dist/adapters/surface.js +6 -11
  27. package/dist/adapters/terminal/actions.js +43 -0
  28. package/dist/adapters/terminal/console-capture.js +14 -1
  29. package/dist/adapters/terminal/launch.js +85 -0
  30. package/dist/adapters/terminal/run.js +30 -0
  31. package/dist/adapters/terminal/surface.js +9 -1
  32. package/dist/adapters.js +32 -824
  33. package/dist/cli-commands.js +1 -0
  34. package/dist/command-contract.js +3 -0
  35. package/dist/commands/call.js +2 -5
  36. package/dist/commands/launch/device.js +342 -0
  37. package/dist/commands/launch/index.js +9 -942
  38. package/dist/commands/launch/quote.js +7 -0
  39. package/dist/commands/pin.js +226 -0
  40. package/dist/commands/review.js +3 -1
  41. package/dist/commands/run-engine.js +38 -138
  42. package/dist/commands/run-report.js +4 -48
  43. package/dist/commands/run.js +6 -48
  44. package/dist/commands/shared.js +2 -2
  45. package/dist/doctor.js +3 -1
  46. package/dist/execution-provenance.js +3 -2
  47. package/dist/harness-pin.js +10 -0
  48. package/dist/heal-bounds.js +3 -2
  49. package/dist/live-adapter-contract.js +8 -34
  50. package/dist/manifest.js +3 -5
  51. package/dist/metamask-action-validation.js +5 -196
  52. package/dist/mm-harness-cli.js +27 -2
  53. package/dist/network-observation.js +10 -3
  54. package/dist/performance-observation.js +15 -22
  55. package/dist/pr-body/render.js +3 -1
  56. package/dist/recording-target.js +4 -39
  57. package/dist/run-diagnostics.js +21 -60
  58. package/dist/run-recording.js +5 -4
  59. package/dist/runner.js +20 -368
  60. package/docs/CONTRIBUTING.md +20 -3
  61. package/package.json +2 -1
  62. package/scripts/completions.sh +2 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.75.0 - 2026-10-04
6
+
7
+ ### Added
8
+
9
+ - `mm-harness pin` gives a task a harness that a global upgrade cannot change. It copies the running installed harness into an immutable, versioned store (`~/.local/share/mm-harness/pins/<version>-<hash>/`, APFS clone where available; `MM_HARNESS_PIN_ROOT` overrides) and prints the copy's executable for the task to record. Pins are keyed by the install's full content, so the same install returns the same pin and a same-version rebuild gets its own; absolute links into the install are re-pointed into the copy, and a pin whose content changed after it was created is refused rather than reused. A source checkout or an existing pin is printed as is; an install whose dependencies or links lead outside it is refused. `doctor` reports a pinned runner as `pinned-install`.
10
+
11
+ ### Changed
12
+
13
+ - `run` and `call` reach platform behavior through the adapter surface instead of branching on the adapter name: new optional `AdapterSurface` members `run` (environment, runtime preparation and checks, dependency block, heal wording, executed browser), `recording`, `diagnostics` (console capture, wallet log, in-app issue buffer), `observation` (network and performance) and `sourceFingerprint`. The engine modules (`run-engine`, `run`, `call`, `run-report`, `run-recording`, `recording-target`, `heal-bounds`, `execution-provenance`, `run-diagnostics`, performance and network observation, `cdp-trace`) go from 48 adapter-name branches to 0. Each platform's implementation lives under `src/adapters/<adapter>/` (`run.ts`, and now `launch.ts` for the Extension and Mobile launch profiles). The test-only `example` adapter is reached by the run/call engine with no shared-file edit. Flags, output, and the CLI goldens are unchanged, with one ordering exception: for `run` and `call` on Mobile, an invalid release-artifact state file is now reported after the running-recipe check instead of before it.
14
+ - The runner's action set comes from a required `AdapterSurface.actions` member instead of adapter-name branches: the bundled manifest, the semantic actions each platform bundles, when live adapter scripts run and under tsx, the `cdp.target` probe, the `ui.*` transport, wrappers around the action and `app.lifecycle` adapters, funded-mutation binding and recipe input checks. The device video recorder joins `AdapterSurface.recording`. `adapters.ts`, `runner.ts`, `metamask-action-validation.ts`, `live-adapter-contract.ts` and `manifest.ts` go from 45 adapter-name branches to 0. The Mobile bridge, HUD, native-provider, lifecycle and runtime-identity code moves to `src/adapters/mobile/` and loads on first use, as does the Extension page helper. The test-only `example` adapter runs a recipe through the runner (manifest, `cdp.target`, `ui.*`, a bundled action, a live script) with no shared-file edit. Flags, output, and the CLI goldens are unchanged.
15
+
16
+ ### Fixed
17
+
18
+ - `pr-body render` counts the nodes of a Recipe v1 graph (`workflow.nodes`). The recipe summary read `recipe.json (0 steps …)` for every v1 recipe; it now reads `recipe.json (<n> nodes …)` with the authored nodes (a `call` node once), next to the run log's executed node count.
19
+ - Several `mm-harness` commands starting at once no longer race on dependency links. The local Farmslot relink (`FARMSLOT_ROOT`, `dev:link-farmslot`) holds one `proper-lockfile` lock, reclaimed when its holder dies, and swaps each link with `rename()`, so runs no longer fail with `EEXIST`/`ENOENT` or see a package missing. The self-package link is renamed into place too, so a fresh install no longer gets a stray link inside the checkout.
20
+
21
+ ## 0.74.0 - 2026-10-03
22
+
23
+ ### Changed
24
+
25
+ - Adapters register in an `AdapterRegistry` (`src/adapters/registry.ts`) instead of a compiled-in union: the adapter id is a string, the four built-in surfaces register themselves, and `launch` resolves `--adapter` through the registry (the CLI front door still accepts only the four built-ins). `launch` moves behind a new `AdapterSurface.launch` member, so the command keeps only the shared grammar and no longer branches on the adapter name (39 such branches in `commands/launch/index.ts`, now 0). A test-only `example` adapter registers and launches with no shared-file edit. Flags, output, and the CLI goldens are unchanged.
26
+
5
27
  ## 0.73.0 - 2026-10-03
6
28
 
7
29
  ### Changed
package/README.md CHANGED
@@ -84,7 +84,7 @@ mm-harness fixtures init --from /secure/path/wallet-fixture.json
84
84
  mm-harness fixtures init --dev
85
85
  ```
86
86
 
87
- Use `mm-harness update` to update a published installation.
87
+ Use `mm-harness update` to update a published installation. `npm i -g` and `update` rewrite the global install in place, so a task that must survive an upgrade records `mm-harness pin` instead: it copies the running install into an immutable, versioned store and prints the copy's executable.
88
88
 
89
89
  ## Operate
90
90
 
@@ -30,10 +30,22 @@ deps_ready() {
30
30
  # is not npm-installed into its own node_modules, so symlink self for resolution.
31
31
  ensure_self_package_link() {
32
32
  local link="$RUNNER_DIR/node_modules/@deeeed/metamask-harness"
33
- /bin/mkdir -p "$RUNNER_DIR/node_modules/@deeeed"
34
- if [ ! -e "$link" ]; then
35
- ln -sf "$RUNNER_DIR" "$link"
33
+ if [ -e "$link" ]; then
34
+ return 0
36
35
  fi
36
+ # Several bins can start at once on a fresh install. `ln -sf` would follow a link another
37
+ # run just made and create a second one inside the runner checkout, so stage the link and
38
+ # rename it into place: rename() replaces a link without following it.
39
+ node -e '
40
+ const fs = require("node:fs");
41
+ const path = require("node:path");
42
+ const [runnerDir, link] = process.argv.slice(1);
43
+ fs.mkdirSync(path.dirname(link), { recursive: true });
44
+ const staged = `${link}.${process.pid}`;
45
+ fs.rmSync(staged, { force: true });
46
+ fs.symlinkSync(runnerDir, staged, "dir");
47
+ fs.renameSync(staged, link);
48
+ ' "$RUNNER_DIR" "$link"
37
49
  }
38
50
 
39
51
  ensure_dev_package_condition() {
@@ -0,0 +1,15 @@
1
+ function isRecord(value) {
2
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
3
+ }
4
+ function traceText(value) {
5
+ if (value === void 0 || value === null) return "";
6
+ if (value instanceof Error) return value.message;
7
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
8
+ return String(value);
9
+ }
10
+ return JSON.stringify(value);
11
+ }
12
+ export {
13
+ isRecord,
14
+ traceText
15
+ };
@@ -0,0 +1,20 @@
1
+ import { manifestPath } from "../../paths.js";
2
+ const coreActions = {
3
+ manifestPath: () => manifestPath("core"),
4
+ semantic: [
5
+ "metamask.perps.close_positions",
6
+ "metamask.perps.start_state",
7
+ "metamask.perps.read_snapshot",
8
+ "metamask.perps.read_account"
9
+ ],
10
+ // read_account is Core's alone, and only its live adapter implements it.
11
+ liveOnly: ["metamask.perps.read_account"],
12
+ tsxLiveScripts: {
13
+ env: async (projectRoot, tempDir) => (await import("./live-script-env.js")).coreLiveScriptEnv(projectRoot, tempDir)
14
+ },
15
+ // Core declares no cdp.target; the probe matches the Extension's.
16
+ cdpTarget: { transport: "chrome-extension-cdp", probePath: "json/version" }
17
+ };
18
+ export {
19
+ coreActions
20
+ };
@@ -0,0 +1,36 @@
1
+ import { existsSync } from "node:fs";
2
+ import { writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { corePnpNodeOptions } from "../../commands/core-readiness.js";
5
+ const CORE_WORKSPACE_PACKAGES = [
6
+ "@metamask/base-controller",
7
+ "@metamask/messenger",
8
+ "@metamask/controller-utils",
9
+ "@metamask/keyring-controller"
10
+ ];
11
+ async function coreLiveScriptEnv(projectRoot, tempDir) {
12
+ const paths = {};
13
+ for (const pkg of CORE_WORKSPACE_PACKAGES) {
14
+ const packageDir = path.join(projectRoot, "packages", pkg.replace("@metamask/", ""));
15
+ const distEntry = path.join(packageDir, "dist/index.cjs");
16
+ if (existsSync(distEntry)) continue;
17
+ const srcDir = path.join(packageDir, "src");
18
+ if (!existsSync(path.join(srcDir, "index.ts"))) continue;
19
+ paths[pkg] = [path.join(srcDir, "index.ts")];
20
+ paths[`${pkg}/*`] = [path.join(srcDir, "*")];
21
+ }
22
+ if (Object.keys(paths).length === 0) return {};
23
+ const tsconfigPath = path.join(tempDir, "core-adapter.tsconfig.json");
24
+ await writeFile(
25
+ tsconfigPath,
26
+ `${JSON.stringify({ compilerOptions: { baseUrl: projectRoot, paths } }, null, 2)}
27
+ `
28
+ );
29
+ return {
30
+ TSX_TSCONFIG_PATH: tsconfigPath,
31
+ NODE_OPTIONS: corePnpNodeOptions(projectRoot, process.env.NODE_OPTIONS)
32
+ };
33
+ }
34
+ export {
35
+ coreLiveScriptEnv
36
+ };
@@ -0,0 +1,54 @@
1
+ import { coreDependencyBlock } from "../../commands/core-readiness.js";
2
+ import { isRecord } from "../../commands/parse-args.js";
3
+ const coreRun = {
4
+ async dependencyBlock(target, { recipe, librarySources, action }) {
5
+ const usesController = action !== void 0 ? actionUsesCoreController(action) : await recipeUsesCoreController(recipe, librarySources);
6
+ return usesController ? coreDependencyBlock(target) : null;
7
+ },
8
+ violationUserAction: (violation) => violation.code === "WALLET_STATE_REQUIRED" ? "for signing, configure a reserved wallet-fixture.json account and pass its non-empty name; an explicit 0x address is supported only by read-only actions" : void 0
9
+ };
10
+ async function recipeUsesCoreController(recipe, librarySources) {
11
+ if (objectUsesCoreController(recipe)) return true;
12
+ const refs = collectCallRefs(recipe);
13
+ if (refs.length === 0) return false;
14
+ const sources = librarySources ?? [];
15
+ if (sources.length === 0) return false;
16
+ const { loadRecipeLibraries } = await import("@farmslot/recipe-harness");
17
+ const recipes = (await loadRecipeLibraries(sources, { adapter: "core" })).recipes;
18
+ const visited = /* @__PURE__ */ new Set();
19
+ const visit = (ref) => {
20
+ if (visited.has(ref)) return false;
21
+ visited.add(ref);
22
+ const dependency = recipes.get(ref);
23
+ if (!dependency) return false;
24
+ if (objectUsesCoreController(dependency.document)) return true;
25
+ return collectCallRefs(dependency.document).some(visit);
26
+ };
27
+ return refs.some(visit);
28
+ }
29
+ function objectUsesCoreController(value) {
30
+ if (Array.isArray(value)) return value.some(objectUsesCoreController);
31
+ if (!isRecord(value)) return false;
32
+ if (actionUsesCoreController(value.action)) return true;
33
+ return Object.values(value).some(objectUsesCoreController);
34
+ }
35
+ function collectCallRefs(value) {
36
+ const refs = [];
37
+ const visit = (node) => {
38
+ if (Array.isArray(node)) {
39
+ for (const item of node) visit(item);
40
+ return;
41
+ }
42
+ if (!isRecord(node)) return;
43
+ if (node.action === "call" && typeof node.ref === "string" && node.ref) refs.push(node.ref);
44
+ for (const child of Object.values(node)) visit(child);
45
+ };
46
+ visit(value);
47
+ return [...new Set(refs)];
48
+ }
49
+ function actionUsesCoreController(action) {
50
+ return typeof action === "string" && action.startsWith("metamask.perps.");
51
+ }
52
+ export {
53
+ coreRun
54
+ };
@@ -1,6 +1,9 @@
1
1
  import path from "node:path";
2
2
  import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
3
3
  import { shellQuote } from "../../commands/parse-args.js";
4
+ import { adapterRegistry } from "../registry.js";
5
+ import { coreActions } from "./actions.js";
6
+ import { coreRun } from "./run.js";
4
7
  const coreSurface = {
5
8
  adapter: "core",
6
9
  headless: true,
@@ -53,8 +56,12 @@ const coreSurface = {
53
56
  launch: "mm-harness run <recipe> # run recipes against the headless core",
54
57
  relaunch: "mm-harness verify",
55
58
  runtimeProbeRecovery: (target) => `mm-harness verify --adapter core --target ${shellQuote(path.resolve(target))}`
56
- }
59
+ },
60
+ launch: async ({ usage }) => usage("core is headless; there is nothing to launch.", "mm-harness verify"),
61
+ actions: coreActions,
62
+ run: coreRun
57
63
  };
64
+ adapterRegistry.register(coreSurface);
58
65
  export {
59
66
  coreSurface
60
67
  };
@@ -0,0 +1,206 @@
1
+ function isRecord(value) {
2
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+ function extensionActionInputFindings(nodeId, node) {
5
+ const findings = [];
6
+ const action = node.action;
7
+ const maximum = (name, limit) => {
8
+ if (typeof node[name] !== "number" || node[name] <= limit) return;
9
+ findings.push({
10
+ severity: "error",
11
+ code: "recipe.invalid_param",
12
+ path: `workflow.nodes.${nodeId}.${name}`,
13
+ message: `${action} ${name} must not exceed ${limit}`
14
+ });
15
+ };
16
+ if (action === "metamask.perps.read_snapshot") {
17
+ if (node.market !== void 0 && node.markets !== void 0) {
18
+ findings.push({
19
+ severity: "error",
20
+ code: "recipe.invalid_param",
21
+ path: `workflow.nodes.${nodeId}.markets`,
22
+ message: `${action} accepts market or markets, not both`
23
+ });
24
+ }
25
+ if (isRecord(node.history)) {
26
+ const start = node.history.start_time_ms;
27
+ const end = node.history.end_time_ms;
28
+ if (typeof start === "number" && typeof end === "number" && start > end) {
29
+ findings.push({
30
+ severity: "error",
31
+ code: "recipe.invalid_param",
32
+ path: `workflow.nodes.${nodeId}.history.start_time_ms`,
33
+ message: `${action} history.start_time_ms must not exceed history.end_time_ms`
34
+ });
35
+ }
36
+ if (typeof node.history.limit === "number" && node.history.limit > 500) {
37
+ findings.push({
38
+ severity: "error",
39
+ code: "recipe.invalid_param",
40
+ path: `workflow.nodes.${nodeId}.history.limit`,
41
+ message: `${action} history.limit must not exceed 500`
42
+ });
43
+ }
44
+ }
45
+ if (typeof node.max_items_per_section === "number" && node.max_items_per_section > 500) {
46
+ findings.push({
47
+ severity: "error",
48
+ code: "recipe.invalid_param",
49
+ path: `workflow.nodes.${nodeId}.max_items_per_section`,
50
+ message: `${action} max_items_per_section must not exceed 500`
51
+ });
52
+ }
53
+ if (typeof node.timeout_ms === "number" && node.timeout_ms > 6e4) {
54
+ findings.push({
55
+ severity: "error",
56
+ code: "recipe.invalid_param",
57
+ path: `workflow.nodes.${nodeId}.timeout_ms`,
58
+ message: `${action} timeout_ms must not exceed 60000`
59
+ });
60
+ }
61
+ if (isRecord(node.sampling)) {
62
+ if (typeof node.sampling.count === "number" && node.sampling.count > 10) {
63
+ findings.push({
64
+ severity: "error",
65
+ code: "recipe.invalid_param",
66
+ path: `workflow.nodes.${nodeId}.sampling.count`,
67
+ message: `${action} sampling.count must not exceed 10`
68
+ });
69
+ }
70
+ if (typeof node.sampling.interval_ms === "number" && node.sampling.interval_ms > 3e4) {
71
+ findings.push({
72
+ severity: "error",
73
+ code: "recipe.invalid_param",
74
+ path: `workflow.nodes.${nodeId}.sampling.interval_ms`,
75
+ message: `${action} sampling.interval_ms must not exceed 30000`
76
+ });
77
+ }
78
+ }
79
+ if (isRecord(node.expect)) {
80
+ if (typeof node.expect.minimum_changed_market_count === "number" && node.expect.minimum_changed_market_count > 100) {
81
+ findings.push({
82
+ severity: "error",
83
+ code: "recipe.invalid_param",
84
+ path: `workflow.nodes.${nodeId}.expect.minimum_changed_market_count`,
85
+ message: `${action} expect.minimum_changed_market_count must not exceed 100`
86
+ });
87
+ }
88
+ if (typeof node.expect.minimum_history_count === "number" && node.expect.minimum_history_count > 500) {
89
+ findings.push({
90
+ severity: "error",
91
+ code: "recipe.invalid_param",
92
+ path: `workflow.nodes.${nodeId}.expect.minimum_history_count`,
93
+ message: `${action} expect.minimum_history_count must not exceed 500`
94
+ });
95
+ }
96
+ }
97
+ }
98
+ if (action === "metamask.perps.assert_visible_consistency") {
99
+ maximum("sample_count", 10);
100
+ maximum("sample_interval_ms", 3e4);
101
+ maximum("minimum_changed_count", 200);
102
+ maximum("minimum_recent_activity_count", 50);
103
+ maximum("timeout_ms", 12e4);
104
+ maximum("max_items", 200);
105
+ }
106
+ const fundedVisibleMutation = action === "metamask.perps.close_visible_position" || action === "metamask.perps.close_orders" || action === "metamask.perps.edit_margin" || action === "metamask.perps.update_position_tpsl" || action === "metamask.perps.place_order" && node.confirm_live === true;
107
+ if (fundedVisibleMutation) {
108
+ const required = (name, pattern, message) => {
109
+ if (typeof node[name] === "string" && pattern.test(node[name])) return;
110
+ findings.push({
111
+ severity: "error",
112
+ code: "recipe.invalid_param",
113
+ path: `workflow.nodes.${nodeId}.${name}`,
114
+ message
115
+ });
116
+ };
117
+ const decimal = action === "metamask.perps.close_visible_position" || action === "metamask.perps.update_position_tpsl" ? node.funding_amount : node.amount ?? node.notional;
118
+ const decimalText = String(decimal ?? "");
119
+ const match = /^([1-9][0-9]*)(?:\.([0-9]{1,6}))?$/u.exec(decimalText);
120
+ if (!match) {
121
+ findings.push({
122
+ severity: "error",
123
+ code: "recipe.invalid_param",
124
+ path: `workflow.nodes.${nodeId}.${action === "metamask.perps.close_visible_position" || action === "metamask.perps.update_position_tpsl" ? "funding_amount" : "amount"}`,
125
+ message: `${action} funded decimal amount must be positive plain decimal text with at most six places`
126
+ });
127
+ }
128
+ if (action === "metamask.perps.place_order" && node.submit_attempts !== void 0 && node.submit_attempts !== 1) {
129
+ findings.push({
130
+ severity: "error",
131
+ code: "recipe.invalid_param",
132
+ path: `workflow.nodes.${nodeId}.submit_attempts`,
133
+ message: `${action} funded visible submission requires submit_attempts=1`
134
+ });
135
+ }
136
+ if (action === "metamask.perps.close_visible_position" || action === "metamask.perps.edit_margin" || action === "metamask.perps.update_position_tpsl") {
137
+ required("funding_side", /^(?:long|short)$/u, `${action} requires the authorized position side`);
138
+ }
139
+ if (action === "metamask.perps.edit_margin" || action === "metamask.perps.update_position_tpsl") {
140
+ required(
141
+ "funding_position_size",
142
+ /^(?=[0-9.]*[1-9])[0-9]+(?:\.[0-9]+)? [A-Z0-9:_-]{2,20}$/u,
143
+ `${action} requires the exact visible position size`
144
+ );
145
+ }
146
+ if (action === "metamask.perps.close_visible_position" && node.cleanup_only === true) {
147
+ required(
148
+ "funding_position_size",
149
+ /^(?=[0-9.]*[1-9])[0-9]+(?:\.[0-9]+)? [A-Z0-9:_-]{2,20}$/u,
150
+ `${action} cleanup requires the exact visible position size`
151
+ );
152
+ if (node.amount != null) {
153
+ findings.push({
154
+ severity: "error",
155
+ code: "recipe.invalid_param",
156
+ path: `workflow.nodes.${nodeId}.amount`,
157
+ message: `${action} cleanup must close the full exact position`
158
+ });
159
+ }
160
+ }
161
+ if (action === "metamask.perps.edit_margin" && !/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(String(node.funding_amount ?? ""))) {
162
+ findings.push({
163
+ severity: "error",
164
+ code: "recipe.invalid_param",
165
+ path: `workflow.nodes.${nodeId}.funding_amount`,
166
+ message: `${action} requires the authorized position amount`
167
+ });
168
+ }
169
+ if (action === "metamask.perps.close_orders") {
170
+ required("side", /^(?:long|short)$/u, `${action} requires the exact order side`);
171
+ const outputPrice = /^\{\{outputs\.[A-Za-z0-9_.-]+\}\}$/u.test(String(node.limit_price ?? ""));
172
+ if (!outputPrice && (!/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,6})?$/u.test(String(node.limit_price ?? "")) || !(Number(node.limit_price) > 0))) {
173
+ findings.push({
174
+ severity: "error",
175
+ code: "recipe.invalid_param",
176
+ path: `workflow.nodes.${nodeId}.limit_price`,
177
+ message: `${action} requires an explicit positive limit_price for resumable cancellation`
178
+ });
179
+ }
180
+ }
181
+ if (action === "metamask.perps.place_order" && String(node.order_type ?? "market") === "limit" && (!/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,6})?$/u.test(String(node.limit_price ?? "")) || !(Number(node.limit_price) > 0))) {
182
+ findings.push({
183
+ severity: "error",
184
+ code: "recipe.invalid_param",
185
+ path: `workflow.nodes.${nodeId}.limit_price`,
186
+ message: `${action} funded limit submission requires explicit limit_price; offset-only pricing is not resumable`
187
+ });
188
+ }
189
+ for (const name of ["identity_rounding_tolerance_base_units", "balance_reconciliation_tolerance_base_units"]) {
190
+ if (node[name] === void 0) continue;
191
+ const value = String(node[name]);
192
+ if (!/^(?:0|[1-9][0-9]{0,6})$/u.test(value) || BigInt(value) > 1000000n) {
193
+ findings.push({
194
+ severity: "error",
195
+ code: "recipe.invalid_param",
196
+ path: `workflow.nodes.${nodeId}.${name}`,
197
+ message: `${action} ${name} must be an integer string from 0 through 1000000`
198
+ });
199
+ }
200
+ }
201
+ }
202
+ return findings;
203
+ }
204
+ export {
205
+ extensionActionInputFindings
206
+ };
@@ -0,0 +1,51 @@
1
+ import { manifestPath } from "../../paths.js";
2
+ import { extensionActionInputFindings } from "./action-inputs.js";
3
+ const extensionActions = {
4
+ manifestPath: () => manifestPath("extension"),
5
+ semantic: [
6
+ "app.network_capture",
7
+ "app.network_assert",
8
+ "app.performance_capture",
9
+ "app.performance_assert",
10
+ "metamask.assets.prepare_send",
11
+ "metamask.assets.review_send",
12
+ "metamask.assets.set_custom_gas",
13
+ "metamask.assets.select_network_scope",
14
+ "metamask.networks.add_chainlist",
15
+ "metamask.perps.compare_provider_market",
16
+ "metamask.perps.read_eligibility",
17
+ "metamask.perps.open_position_action",
18
+ "metamask.perps.edit_margin",
19
+ "metamask.perps.update_position_tpsl",
20
+ "metamask.perps.assert_visible_consistency",
21
+ "metamask.settings.set_basic_functionality",
22
+ "metamask.perps.read_snapshot"
23
+ ],
24
+ liveNeedsCdpPort: true,
25
+ cdpTarget: { transport: "chrome-extension-cdp", probePath: "json/version" },
26
+ ui: (harness) => ({
27
+ base: harness.createCdpWebUiTransport({
28
+ async withPage(input, callback) {
29
+ const { withExtensionPage } = await import("../../../library/actions/extension/platform/cdp.mjs");
30
+ return withExtensionPage(
31
+ {
32
+ action: input.action,
33
+ node: input.node,
34
+ context: {
35
+ nodeId: input.context.nodeId,
36
+ projectRoot: input.context.projectRoot,
37
+ artifactsDir: input.context.artifactsDir
38
+ }
39
+ },
40
+ callback
41
+ );
42
+ }
43
+ })
44
+ }),
45
+ trustedMutation: true,
46
+ inputFindings: extensionActionInputFindings,
47
+ ensureChecksOnly: true
48
+ };
49
+ export {
50
+ extensionActions
51
+ };
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import { verifyCapture } from "../../../library/actions/shared/console-collector.mjs";
1
3
  import { recipeRuntimePath } from "../../paths.js";
2
4
  import { ensureConsoleCollector } from "../console-capture.js";
3
5
  function extensionCaptureFiles(target) {
@@ -25,8 +27,18 @@ async function ensureExtensionConsoleCapture(target) {
25
27
  cwd: target
26
28
  });
27
29
  }
30
+ async function verifyExtensionConsoleControl(capture) {
31
+ let extensionId = null;
32
+ try {
33
+ extensionId = fs.readFileSync(recipeRuntimePath(capture.projectRoot, "extension.id"), "utf8").trim();
34
+ } catch {
35
+ extensionId = null;
36
+ }
37
+ return verifyCapture({ cdpPort: capture.cdpPort, log: capture.extensionLog, target: "extension", extensionId });
38
+ }
28
39
  export {
29
40
  ensureExtensionConsoleCapture,
30
41
  extensionCaptureFiles,
31
- extensionCdpPort
42
+ extensionCdpPort,
43
+ verifyExtensionConsoleControl
32
44
  };