@deeeed/metamask-harness 0.63.0 → 0.64.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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.64.0 - 2026-09-25
6
+
7
+ ### Added
8
+
9
+ - Preflight behavioral proof bindings with `run --proof`, including `--plan`, before launching QA recipes. Executed outcomes and evidence still require runtime validation.
10
+ - Read source-development Extension pending approval IDs and types with `metamask.wallet.read_state scope=pending_approvals`, without mutating requests or exposing payloads.
11
+
12
+ ### Fixed
13
+
14
+ - Gate all Extension source launch paths on current dependencies and reject bundles compiled against stale or changed installed resolutions. Successful watch and LavaMoat builds record their dependency baseline; verified release artifacts retain their independent launch path.
15
+
16
+ ### Documentation
17
+
18
+ - Clarify how reusable recipes support daily domain and release QA through the scope-owning skill.
19
+
5
20
  ## 0.63.0 - 2026-09-23
6
21
 
7
22
  ### Added
package/README.md CHANGED
@@ -10,6 +10,21 @@ and runtime paths are detected automatically.
10
10
  The generic graph engine and evidence schemas live in [Farmslot](https://farmslot.io) packages.
11
11
  `mm-harness` owns MetaMask runtime control and domain capabilities.
12
12
 
13
+ ## Daily and release QA
14
+
15
+ The same recipes can validate yesterday's domain changes together or a release
16
+ that is behind `main`. The `mms-recipe-qa` skill freezes the change scope and
17
+ criteria; the team library supplies smoke and reusable journeys; this CLI runs
18
+ them on the selected app and retains assertions, traces and captures.
19
+
20
+ Use the installed skill's `references/domain-qa.md` for window/range inputs and
21
+ report policy. Scope selection and scheduling are outside the harness. Discover
22
+ recipes through `run --list` and `run <recipe> --describe`, then follow the
23
+ locked version's `help --json` for composition and drift diagnosis. Adapt a
24
+ recipe to the tested release without changing the release or weakening its
25
+ criteria. An internal action that needs development instrumentation cannot
26
+ prove behavior on an unmodified release artifact.
27
+
13
28
  ## Getting started from zero
14
29
 
15
30
  No checkouts yet? `mm-harness setup-base` clones the MetaMask product repos into one
@@ -4,11 +4,13 @@ set -euo pipefail
4
4
 
5
5
  TARGET="$PWD"
6
6
  RUNTIME_DIR="temp/recipe/runtime"
7
+ RUNNER_BIN=""
7
8
  while [ "$#" -gt 0 ]; do
8
9
  case "$1" in
9
10
  --target) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; TARGET="$2"; shift 2 ;;
10
11
  --runtime-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; RUNTIME_DIR="$2"; shift 2 ;;
11
- -h|--help) echo "Usage: build-lavamoat.sh [--target <metamask-extension>] [--runtime-dir <relative path>]"; exit 0 ;;
12
+ --runner-bin) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; RUNNER_BIN="$2"; shift 2 ;;
13
+ -h|--help) echo "Usage: build-lavamoat.sh [--target <metamask-extension>] [--runtime-dir <relative path>] [--runner-bin <path>]"; exit 0 ;;
12
14
  *) echo "Unknown arg: $1" >&2; exit 2 ;;
13
15
  esac
14
16
  done
@@ -55,4 +57,7 @@ test -f dist/chrome/manifest.json || {
55
57
  echo "build-lavamoat: build completed without dist/chrome/manifest.json" >&2
56
58
  exit 1
57
59
  }
60
+ if [ -n "$RUNNER_BIN" ]; then
61
+ "$RUNNER_BIN" runtime-decision --adapter extension --target "$TARGET" --record-baseline --json >/dev/null
62
+ fi
58
63
  echo "[recipe-harness] non-test LavaMoat build completed"
@@ -308,7 +308,7 @@ NODE
308
308
  prepare_parts+=("bash ${quoted_start_watch} --runtime-dir ${quoted_runtime_dir} --runner-bin ${quoted_runner}")
309
309
  fi
310
310
  if $BUILD_LAVAMOAT; then
311
- prepare_parts+=("bash ${quoted_build_lavamoat} --target ${quoted_target} --runtime-dir ${quoted_runtime_dir}")
311
+ prepare_parts+=("bash ${quoted_build_lavamoat} --target ${quoted_target} --runtime-dir ${quoted_runtime_dir} --runner-bin ${quoted_runner}")
312
312
  fi
313
313
  if ! $EXTERNAL_ARTIFACT; then
314
314
  prepare_parts+=("node ${quoted_check_infura} --target ${quoted_target} --runtime-dist ${quoted_dist}")
@@ -12,7 +12,7 @@
12
12
  # --runtime-dir <rel> (default from adapters/shared path-defaults)
13
13
  # --watcher-port <port> (default env WATCHER_PORT/RECIPE_WATCHER_PORT,
14
14
  # runtime json, or slot suffix e.g. metamask-extension-1 => 9011)
15
- # --runner-bin <path> (optional; records deps/cache baseline best-effort)
15
+ # --runner-bin <path> (optional; records deps/cache baseline)
16
16
  # --summary <file> (optional; writes the standard summary.json shape)
17
17
  #
18
18
  # Outputs:
@@ -352,7 +352,7 @@ if ! write_canonical_watch_pid; then
352
352
  fi
353
353
  echo '[recipe-harness] yarn start compiled successfully'
354
354
  if [ -n "$RUNNER_BIN" ]; then
355
- "$RUNNER_BIN" runtime-decision --adapter extension --target . --record-baseline --json >/dev/null 2>&1 || true
355
+ "$RUNNER_BIN" runtime-decision --adapter extension --target . --record-baseline --json >/dev/null
356
356
  echo '[recipe-harness] recorded webpack cache baseline (runtime-decision --record-baseline)'
357
357
  fi
358
358
  status=pass
@@ -56,6 +56,13 @@ function webpackFingerprint(target) {
56
56
  for (const dir of WEBPACK_RECURSIVE_INPUTS) walk(hash, target, dir);
57
57
  return { gitHead: git(target, ["rev-parse", "HEAD"]) ?? "unknown", fingerprint: hash.digest("hex") };
58
58
  }
59
+ function installedDepsFingerprint(target) {
60
+ const hash = crypto.createHash("sha256");
61
+ for (const rel of INSTALL_MARKERS) {
62
+ if (fs.existsSync(path.join(target, rel))) addFile(hash, target, rel);
63
+ }
64
+ return hash.digest("hex");
65
+ }
59
66
  function stateDir(target) {
60
67
  return recipeHarnessPath(target, "extension", "decision-state");
61
68
  }
@@ -328,6 +335,10 @@ function distCheck(target) {
328
335
  } catch {
329
336
  return { status: "no-build" };
330
337
  }
338
+ const baseline = readBaseline(target, "webpack-cache-state.json");
339
+ if (!baseline || !("installedDepsFingerprint" in baseline) || baseline?.depsCurrent === false || typeof baseline?.installedDepsFingerprint === "string" && baseline.installedDepsFingerprint !== installedDepsFingerprint(target)) {
340
+ return { status: "stale", reason: "dependencies-changed" };
341
+ }
331
342
  const description = typeof manifest.description === "string" ? manifest.description : "";
332
343
  const match = /from git id:\s*([0-9a-f]{7,40})/iu.exec(description);
333
344
  const head = (git(target, ["rev-parse", "HEAD"]) ?? "").toLowerCase();
@@ -363,7 +374,11 @@ async function cdpCheck(target, cdpPort, pageMode) {
363
374
  }
364
375
  function recordWebpackBaseline(target) {
365
376
  const cache = webpackFingerprint(target);
366
- writeBaseline(target, "webpack-cache-state.json", cache);
377
+ writeBaseline(target, "webpack-cache-state.json", {
378
+ ...cache,
379
+ depsCurrent: depsCheck(target).status === "current",
380
+ installedDepsFingerprint: installedDepsFingerprint(target)
381
+ });
367
382
  }
368
383
  async function decideExtensionReadiness(target, options = {}) {
369
384
  const resolved = path.resolve(target);
@@ -424,7 +439,7 @@ async function decideExtensionReadiness(target, options = {}) {
424
439
  when: dist.status === "stale",
425
440
  decision: "build",
426
441
  reasonCode: "dist-stale",
427
- reasons: [dist.reason === "uncommitted-source" ? `Uncommitted source since the build (${dist.modified?.length ?? 0} file(s)); rebuild.` : `dist git id ${dist.distGitId} != HEAD ${dist.head}; rebuild.`],
442
+ reasons: [dist.reason === "uncommitted-source" ? `Uncommitted source since the build (${dist.modified?.length ?? 0} file(s)); rebuild.` : dist.reason === "dependencies-changed" ? "Dependency installation changed or was not current at compilation; rebuild." : `dist git id ${dist.distGitId} != HEAD ${dist.head}; rebuild.`],
428
443
  actions: buildAction(resolved, false)
429
444
  },
430
445
  {
@@ -25,7 +25,7 @@ const SPEC = {
25
25
  { name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/reset/generate)", args: ["sync", "set", "reset", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
26
26
  { name: "actions", desc: "List runnable recipe actions", flags: ["--json", "--matrix", "--categories", "--category", "--action", "--library"] },
27
27
  { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
28
- { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
28
+ { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device", "--plan", "--proof"] },
29
29
  { 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"] },
30
30
  { name: "recipe-quality", desc: "Build canonical quality or request optional recipe planning/advice", args: ["build", "plan", "advise", "advisor-status"], flags: ["--input", "--output", "--recipe", "--task", "--evidence", "--params", "--adapter", "--library", "--advisor", "--advisor-model", "--advisor-stages", "--json"] },
31
31
  { name: "interactive", aliases: ["menu"], desc: "Interactive command menu" },
@@ -254,6 +254,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
254
254
  options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, RUN_FUNDING, {
255
255
  "--list": bool(),
256
256
  "--plan": bool(),
257
+ "--proof": bool(),
257
258
  "--describe": bool(),
258
259
  "--hud": value(["auto", "show", "hide"])
259
260
  }),
@@ -52,6 +52,7 @@ function extensionDepsBlock(target) {
52
52
  return null;
53
53
  }
54
54
  async function installExtensionDeps(target) {
55
+ stopExtensionWatcher(target);
55
56
  const installScript = path.join(runnerDir, "adapters/shared/install-repo-deps.sh");
56
57
  const result = await spawnScriptStreaming(installScript, ["--target", target], target);
57
58
  if (result.status === 0) recordDepsBaseline(target);
@@ -91,6 +92,16 @@ async function launchExtension(target, tier, wantWatch, displayMode = "fullscree
91
92
  Next: rerun runtime-launch with the same artifact source.`
92
93
  };
93
94
  }
95
+ if (artifact.status === "none") {
96
+ const depsBlock = extensionDepsBlock(target);
97
+ if (depsBlock) {
98
+ return {
99
+ status: EXIT.infra,
100
+ output: `EXTENSION_DEPENDENCIES_REQUIRED: ${depsBlock.message}
101
+ Next: ${depsBlock.userAction}`
102
+ };
103
+ }
104
+ }
94
105
  if (!reusable) {
95
106
  const block = extensionProductConfigBlock(target);
96
107
  if (block) {
@@ -105,7 +116,7 @@ Next: ${block.userAction}`
105
116
  restoreRequestedPorts();
106
117
  if (wantWatch) {
107
118
  const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
108
- const watchArgs = ["--target", target];
119
+ const watchArgs = ["--target", target, "--runner-bin", path.join(runnerDir, "bin/mm-harness")];
109
120
  if (process.env.WATCHER_PORT) watchArgs.push("--watcher-port", process.env.WATCHER_PORT);
110
121
  console.error(colorHumanMessage(`\u2192 extension watch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} (output streams below)`));
111
122
  return spawnScriptStreaming(startWatchSh, watchArgs, target);
@@ -28,6 +28,7 @@ function parseArgs(argv, command) {
28
28
  "preserveProfile",
29
29
  "record",
30
30
  "plan",
31
+ "proof",
31
32
  "list",
32
33
  "describe",
33
34
  "raw",
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import { mkdtemp } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { validateRuntimeProofPlan } from "../runtime-proof.js";
5
6
  import {
6
7
  ensureOverlay,
7
8
  newHealState,
@@ -46,6 +47,7 @@ import { EXIT } from "./shared.js";
46
47
  import {
47
48
  actionManifestPathOption,
48
49
  optionString,
50
+ optionFlag,
49
51
  optionStrings,
50
52
  isRecord,
51
53
  shellQuoteArg,
@@ -563,7 +565,7 @@ function restoreEnv(key, value) {
563
565
  if (value === void 0) delete process.env[key];
564
566
  else process.env[key] = value;
565
567
  }
566
- async function validateRecipeAdapterAware(adapter, recipe, manifest, librarySources, rootRef, params) {
568
+ async function validateRecipeAdapterAware(adapter, recipe, manifest, librarySources, rootRef, params, proof = false) {
567
569
  const protocol = await importRecipeProtocol();
568
570
  let externalRecipeIds;
569
571
  let libraryResolution;
@@ -641,6 +643,12 @@ async function validateRecipeAdapterAware(adapter, recipe, manifest, librarySour
641
643
  }
642
644
  };
643
645
  validateDependencyInstances(recipe, params ?? {});
646
+ if (proof && !findings.some((finding) => finding.severity === "error")) {
647
+ findings.push(...validateRuntimeProofPlan(
648
+ recipe,
649
+ new Map([...dependencies.recipes].map(([ref, dependency]) => [ref, dependency.document]))
650
+ ));
651
+ }
644
652
  } catch (error) {
645
653
  const resolutionError = error instanceof harness.RecipeResolutionError ? error : void 0;
646
654
  if (!resolutionError) throw error;
@@ -919,7 +927,8 @@ async function validateRunRecipeStatic(recipeArg, adapter, options, params = {})
919
927
  manifest,
920
928
  librarySources,
921
929
  resolved.ref,
922
- effectiveParams
930
+ effectiveParams,
931
+ optionFlag(options, "proof")
923
932
  ) : {
924
933
  status: "invalid",
925
934
  findings: [],
@@ -613,6 +613,12 @@ async function handleRunPlan(recipeArg, params, options, stream) {
613
613
  status: manifestOk && schemaValid ? "ok" : "error",
614
614
  detail: "recipe document schema + action existence/platform vs the adapter manifest"
615
615
  },
616
+ ...optionFlag(options, "proof") ? [{
617
+ step: "validate.proofBindings",
618
+ confidence: "static",
619
+ status: findings.some((finding) => finding.code.startsWith("proof.")) ? "error" : errorCount ? "planned" : "ok",
620
+ detail: "checks possible behavioral assertion bindings only; runtime order, branches, results and proof artifact indexing remain unverified"
621
+ }] : [],
616
622
  {
617
623
  step: "fixture.file",
618
624
  confidence: "static",
@@ -10,6 +10,8 @@ import {
10
10
  writeMobileReleaseArtifactState
11
11
  } from "../adapters/mobile/release-artifact-state.js";
12
12
  import { recipeHarnessPath } from "../paths.js";
13
+ import { extensionDepsBlock } from "./launch/extension.js";
14
+ import { isExtensionDistStale } from "../adapters/extension/runtime-decision.js";
13
15
  import {
14
16
  adapterOption,
15
17
  isRecord,
@@ -152,6 +154,23 @@ async function handleRuntimeLaunch({ options }) {
152
154
  printRuntimeLaunchReport(report2, optionFlag(options, "json"));
153
155
  return 1;
154
156
  }
157
+ if (!artifact) {
158
+ const depsBlock = extensionDepsBlock(target);
159
+ const staleDist = mode === "existing-dist" && isExtensionDistStale(target);
160
+ if (depsBlock || staleDist) {
161
+ const report2 = runtimeLaunchReport("fail", {
162
+ adapter,
163
+ target,
164
+ cdpPort,
165
+ artifactsDir,
166
+ reason: depsBlock ? "extension_dependencies_required" : "extension_dist_stale",
167
+ fix: depsBlock?.userAction ?? `mm-harness launch --adapter extension --target ${shellQuote(target)}`,
168
+ command
169
+ });
170
+ printRuntimeLaunchReport(report2, optionFlag(options, "json"));
171
+ return 1;
172
+ }
173
+ }
155
174
  fs.mkdirSync(artifactsDir, { recursive: true });
156
175
  const jsonMode = optionFlag(options, "json");
157
176
  if (!jsonMode) {
@@ -464,6 +464,7 @@ Discovery:
464
464
  --list List complete recipes accepted by run for this adapter; no <recipe> needed
465
465
  --describe Show one recipe's parameters, composed actions/recipes, and runnable command
466
466
  --plan Validate + print execution plan, touching nothing. Exit 5 if invalid.
467
+ --proof Preflight behavioral proof bindings before planning/execution; runtime evidence is still required.
467
468
 
468
469
  Common overrides:
469
470
  --device <udid|serial|name> Mobile only: target this device (sets IOS_SIMULATOR / ADB_SERIAL and updates agentic-runtime.json). Without it, >1 connected mobile device fails fast and lists them. Do not shut down other devices to force selection.
@@ -9,23 +9,8 @@ function validateRuntimeProof(bundle, smokeTarget) {
9
9
  assert(entries.length && entries.every((entry) => entry.ok === true), "Runtime trace contains failed or unknown actions");
10
10
  assert(entries.some((entry) => entry.action === "end" && entry.status === "pass" && !String(entry.nodeId ?? entry.id).includes("/")), "Runtime recipe did not reach its passing end");
11
11
  const documents = new Map(recipeResolution.dependencies.map((dependency) => [normalizeRecipeRef(dependency.ref), resolvedRecipes[dependency.digest]]));
12
- const nodes = /* @__PURE__ */ new Map();
13
- function collect(document, prefix = "", stack = /* @__PURE__ */ new Set()) {
14
- for (const [id, node] of Object.entries(document.workflow.nodes)) {
15
- nodes.set(prefix + id, node);
16
- if (node.action === "call" && typeof node.ref === "string") {
17
- const ref = normalizeRecipeRef(node.ref);
18
- assert(!stack.has(ref), "Recursive recipe proof");
19
- const nested = documents.get(ref);
20
- assert(nested, `Missing resolved recipe ${ref}`);
21
- collect(nested, `${prefix}${id}/`, new Set(stack).add(ref));
22
- }
23
- }
24
- }
25
- collect(recipe);
12
+ const nodes = collectProofNodes(recipe, documents);
26
13
  const executed = new Map(entries.map((entry, index) => [entry.nodeId ?? entry.id, { entry, index }]));
27
- const assertions = /* @__PURE__ */ new Set(["assert_output", "assert_json", "ui.wait_for"]);
28
- const controls = /* @__PURE__ */ new Set(["end", "wait", "manual", "switch", "call", "index_artifacts", "assert_exit_code", "assert_file", "branch", "repeat", "set", "log"]);
29
14
  function proves(id) {
30
15
  const node = nodes.get(id);
31
16
  const current = executed.get(id);
@@ -33,13 +18,12 @@ function validateRuntimeProof(bundle, smokeTarget) {
33
18
  if (node.action === "call") return [...nodes.keys()].some((child) => child.startsWith(`${id}/`) && proves(child));
34
19
  if (!assertions.has(node.action) || !current.entry.output || typeof current.entry.output !== "object") return false;
35
20
  if (node.action === "assert_output") {
36
- const prefix = id.includes("/") ? id.slice(0, id.lastIndexOf("/") + 1) : "";
37
- const source = prefix + String(node.source ?? node.node ?? "");
21
+ const source = assertionSource(id, node);
38
22
  const prior = executed.get(source);
39
23
  const action = nodes.get(source)?.action;
40
- return Boolean(prior && prior.index < current.index && action && action === prior.entry.action && !controls.has(action) && !assertions.has(action));
24
+ return Boolean(prior && prior.index < current.index && action && action === prior.entry.action && isObservation(action));
41
25
  }
42
- if (node.action === "ui.wait_for") return entries.slice(0, current.index).some((entry) => ["ui.press", "ui.key_press", "ui.set_input", "ui.swipe", "ui.drag", "ui.long_press"].includes(entry.action));
26
+ if (node.action === "ui.wait_for") return entries.slice(0, current.index).some((entry) => inputs.has(entry.action));
43
27
  return typeof node.path === "string" && manifest.artifacts.some((artifact) => artifact.path === node.path && artifact.category === "proof");
44
28
  }
45
29
  assert(recipe.proofTargets?.length, "Runtime recipe has no behavioral proof targets");
@@ -49,6 +33,58 @@ function validateRuntimeProof(bundle, smokeTarget) {
49
33
  }
50
34
  if (smokeTarget) assert(recipe.proofTargets.some((target) => target.id === smokeTarget), "Smoke target is absent from recipe");
51
35
  }
36
+ const assertions = /* @__PURE__ */ new Set(["assert_output", "assert_json", "ui.wait_for"]);
37
+ const controls = /* @__PURE__ */ new Set(["end", "wait", "manual", "switch", "call", "index_artifacts", "assert_exit_code", "assert_file", "branch", "repeat", "set", "log"]);
38
+ const inputs = /* @__PURE__ */ new Set(["ui.press", "ui.key_press", "ui.set_input", "ui.swipe", "ui.drag", "ui.long_press"]);
39
+ function isObservation(action) {
40
+ return !controls.has(action) && !assertions.has(action);
41
+ }
42
+ function assertionSource(id, node) {
43
+ const prefix = id.includes("/") ? id.slice(0, id.lastIndexOf("/") + 1) : "";
44
+ return prefix + String(node.source ?? node.node ?? "");
45
+ }
46
+ function collectProofNodes(recipe, documents) {
47
+ const nodes = /* @__PURE__ */ new Map();
48
+ function collect(document, prefix = "", stack = /* @__PURE__ */ new Set()) {
49
+ for (const [id, node] of Object.entries(document.workflow.nodes)) {
50
+ nodes.set(prefix + id, node);
51
+ if (node.action === "call" && typeof node.ref === "string") {
52
+ const ref = normalizeRecipeRef(node.ref);
53
+ assert(!stack.has(ref), "Recursive recipe proof");
54
+ const nested = documents.get(ref);
55
+ assert(nested, `Missing resolved recipe ${ref}`);
56
+ collect(nested, `${prefix}${id}/`, new Set(stack).add(ref));
57
+ }
58
+ }
59
+ }
60
+ collect(recipe);
61
+ return nodes;
62
+ }
63
+ function validateRuntimeProofPlan(recipe, documents = /* @__PURE__ */ new Map()) {
64
+ const finding = (code, path, message) => ({ severity: "error", code, path, message });
65
+ if (!recipe.proofTargets?.length) return [finding("proof.targets_missing", "proofTargets", "Behavioral proof requires at least one proof target.")];
66
+ const nodes = collectProofNodes(recipe, documents);
67
+ const hasInput = [...nodes.values()].some((node) => inputs.has(node.action));
68
+ function eligible(id) {
69
+ const node = nodes.get(id);
70
+ if (node.action === "call") return [...nodes.keys()].some((child) => child.startsWith(`${id}/`) && eligible(child));
71
+ if (node.action === "assert_output") {
72
+ const source = nodes.get(assertionSource(id, node));
73
+ return Boolean(source && isObservation(source.action));
74
+ }
75
+ if (node.action === "ui.wait_for") return hasInput;
76
+ return node.action === "assert_json" && typeof node.path === "string";
77
+ }
78
+ return recipe.proofTargets.flatMap((target, index) => {
79
+ const linked = [...nodes].filter(([, node]) => node.proves?.includes(target.id));
80
+ return linked.some(([id]) => eligible(id)) ? [] : [finding(
81
+ "proof.no_runtime_assertion",
82
+ `proofTargets[${index}]`,
83
+ `Proof target ${target.id} has no potentially eligible runtime assertion. Bind proves to assert_output over an observation, ui.wait_for with an input action, assert_json over a proof artifact, or a call containing one. Runtime order, outcomes and artifact indexing remain unverified.`
84
+ )];
85
+ });
86
+ }
52
87
  export {
53
- validateRuntimeProof
88
+ validateRuntimeProof,
89
+ validateRuntimeProofPlan
54
90
  };
@@ -118,6 +118,24 @@ current manifests do not expose the required durable capability.
118
118
  | Bridge tracking and details | Completed supported bridge route | Read-only Swap/Bridge evidence exists, but Extension cannot create the terminal bridge transaction | **MISSING** for a complete Extension bridge journey |
119
119
  | Android multi-network visibility | Imported wallet with holdings on multiple chains | Native Assets read reports aggregate visible holdings without Metro or a development bridge | **AVAILABLE** |
120
120
 
121
+ ## Pending approval observation
122
+
123
+ On source-dev Extension runtimes, `metamask.wallet.read_state` with
124
+ `scope: "pending_approvals"` reads `stateHooks.store.getState()` once. It returns
125
+ only `state.pendingApprovals`, an ID-sorted array of `{ id, type }`, and
126
+ `state.pendingApprovalCount`. It neither approves nor rejects requests and does
127
+ not navigate, reload, or call the background controller. Missing or malformed
128
+ approval state fails the read instead of reporting an empty queue.
129
+
130
+ Capture the IDs before entering a confirmation flow and again after its explicit
131
+ UI teardown. Check that newly created IDs are gone and pre-existing IDs remain.
132
+ A count alone or Back navigation does not prove cleanup. This observes pending
133
+ approvals only, not transaction settlement or other controller queues.
134
+
135
+ This scope requires the development store hook. It is unsupported on official
136
+ Extension artifacts without that hook and on Mobile. Do not patch a release or
137
+ mutate controller state to make the read available.
138
+
121
139
  ## Platform scope
122
140
 
123
141
  The 135-case source inventory targets Extension. It must not be relabeled as an
@@ -1,8 +1,16 @@
1
- // Reads wallet identity, onboarding, and network state from state hooks alongside the password-input marker.
1
+ // Reads wallet state or pending approval identities without changing requests.
2
2
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
3
3
  import { readWalletStateExpression } from './state.mjs';
4
4
 
5
5
  runAdapter((input) => withExtensionPage(input, async (page) => {
6
- const state = await page.evaluate(readWalletStateExpression(), { awaitPromise: true });
7
- return { action: input.action, source: 'extension-stateHooks-clean-state', state, redacted: true, proofPath: 'extension-wallet-state' };
6
+ const scope = input.node?.scope ?? 'wallet';
7
+ const state = await page.evaluate(readWalletStateExpression(scope), { awaitPromise: true });
8
+ return {
9
+ action: input.action,
10
+ source: scope === 'pending_approvals' ? 'extension-stateHooks-store' : 'extension-stateHooks-clean-state',
11
+ scope,
12
+ state,
13
+ redacted: true,
14
+ proofPath: 'extension-wallet-state',
15
+ };
8
16
  }));
@@ -22,7 +22,31 @@ const walletStatePreamble = `
22
22
  };
23
23
  `;
24
24
 
25
- export function readWalletStateExpression() {
25
+ export function readWalletStateExpression(scope = 'wallet') {
26
+ if (scope === 'pending_approvals') {
27
+ return `(() => {
28
+ const hooks = globalThis.stateHooks;
29
+ if (typeof hooks?.store?.getState !== 'function') {
30
+ throw new Error('Pending approval reads require source-dev stateHooks.store.getState; official artifacts are unsupported.');
31
+ }
32
+ const root = hooks.store.getState();
33
+ const pending = (root?.metamask || root)?.pendingApprovals;
34
+ if (!pending || typeof pending !== 'object' || Array.isArray(pending)) {
35
+ throw new Error('Pending approval state is unavailable; an empty queue cannot be inferred.');
36
+ }
37
+ const pendingApprovals = Object.entries(pending).map(([id, approval]) => {
38
+ if (!id || !approval || approval.id !== id ||
39
+ typeof approval.type !== 'string' || !approval.type) {
40
+ throw new Error('Pending approval state contains an invalid request identity or type.');
41
+ }
42
+ return { id, type: approval.type };
43
+ }).sort((left, right) => left.id.localeCompare(right.id));
44
+ return { pendingApprovals, pendingApprovalCount: pendingApprovals.length };
45
+ })()`;
46
+ }
47
+ if (scope !== 'wallet') {
48
+ throw new Error('metamask.wallet.read_state scope must be wallet or pending_approvals.');
49
+ }
26
50
  return `(async () => {
27
51
  ${walletStatePreamble}
28
52
  const { metamask, internal } = await readWalletState();
@@ -1753,10 +1753,16 @@
1753
1753
  "execution_capabilities": []
1754
1754
  },
1755
1755
  "metamask.wallet.read_state": {
1756
- "description": "Read the redacted wallet state (selected account, route, and device) from the running app.",
1756
+ "description": "Read redacted wallet state, or pending approval IDs/types/count from the source-dev store without modifying requests. Pending approvals are unsupported on official artifacts without the dev store.",
1757
1757
  "schema": {
1758
1758
  "type": "object",
1759
- "properties": {},
1759
+ "properties": {
1760
+ "scope": {
1761
+ "type": "string",
1762
+ "enum": ["wallet", "pending_approvals"],
1763
+ "default": "wallet"
1764
+ }
1765
+ },
1760
1766
  "additionalProperties": false
1761
1767
  },
1762
1768
  "examples": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"