@deeeed/metamask-harness 0.68.0 → 0.70.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.
@@ -475,8 +475,13 @@ async function openBrokeredMobileDebugger(port, shouldOpen) {
475
475
  function pickExtensionTarget(targets, targetKind) {
476
476
  const extensionTargets = targets.filter((target) => String(target.url || '').startsWith('chrome-extension://'));
477
477
  if (targetKind === 'worker') {
478
+ // Branded Chrome runs component extensions with their own service workers;
479
+ // prefer the worker of the extension whose wallet page is open.
480
+ const walletPage = extensionTargets.find((target) => /\/(home|sidepanel)\.html/u.test(String(target.url || '')));
481
+ const walletId = process.env.RECIPE_HARNESS_EXTENSION_ID || (walletPage ? String(walletPage.url).split('/')[2] : '');
478
482
  return (
479
- extensionTargets.find((target) => target.type === 'service_worker')
483
+ (walletId && extensionTargets.find((target) => target.type === 'service_worker' && String(target.url).startsWith(`chrome-extension://${walletId}/`)))
484
+ || extensionTargets.find((target) => target.type === 'service_worker')
480
485
  || targets.find((target) => target.type === 'service_worker' && String(target.url || '').startsWith('chrome-extension://'))
481
486
  || null
482
487
  );
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { recordDepsBaseline } from "@farmslot/recipe-harness/runtime/deps-readiness";
4
4
  import { resolveAndExportMobileToolPaths } from "../../../library/actions/mobile/platform/tool-paths.mjs";
5
5
  import { EXIT, spawnScriptStreaming } from "../../commands/shared.js";
6
+ import { shellQuote } from "../../commands/parse-args.js";
6
7
  import { runnerDir } from "../../paths.js";
7
8
  import {
8
9
  decideMobileReadiness,
@@ -51,12 +52,14 @@ async function prepareMobile(target, opts = {}) {
51
52
  });
52
53
  if (report.decision === "blocked") {
53
54
  const reasons = report.reasons.join(" ");
54
- const next = report.userAction ?? "fix the bundle error in app code before retrying recipe up.";
55
+ const next = report.userAction ?? `mm-harness logs --target ${shellQuote(target)} --full`;
55
56
  const msg = `mobile prepare blocked: ${reasons}
56
57
  Next: ${next}`;
57
- if (!json) process.stderr.write(`${msg}
58
- `);
59
- return { status: EXIT.runtime, output: msg };
58
+ return {
59
+ status: EXIT.runtime,
60
+ output: msg,
61
+ readinessBlock: { reasonCode: report.reasonCode, message: reasons, userAction: next }
62
+ };
60
63
  }
61
64
  const iosAccessibilityChanged = platform === "ios" && report.decision !== "unknown" && enableIosAccessibility(json);
62
65
  const rebuildNative = preflightMode === "rebuild-native" || preflightMode === "clean";
@@ -12,6 +12,7 @@ import {
12
12
  } from "@farmslot/recipe-harness/runtime/log-analysis";
13
13
  import { probeMetroPackager } from "@farmslot/recipe-harness/runtime/metro-probe";
14
14
  import { color } from "../../cli-color.js";
15
+ import { shellQuote } from "../../commands/parse-args.js";
15
16
  import { recipeRuntimePath } from "../../paths.js";
16
17
  import { resolveMobileToolPath } from "../../../library/actions/mobile/platform/tool-paths.mjs";
17
18
  import { mobileProductMarkers } from "./deps-markers.js";
@@ -149,7 +150,7 @@ const launchActions = (target, clearMetro = false) => {
149
150
  return actions;
150
151
  };
151
152
  function depsInstallCommand(resolved) {
152
- return fs.existsSync(path.join(resolved, "node_modules")) ? "`yarn setup:expo --no-build-ios --no-build-android`" : "`yarn install --immutable && yarn setup:expo --no-build-ios --no-build-android`";
153
+ return fs.existsSync(path.join(resolved, "node_modules")) ? "yarn setup:expo --no-build-ios --no-build-android" : "yarn install --immutable && yarn setup:expo --no-build-ios --no-build-android";
153
154
  }
154
155
  async function decideMobileReadiness(target, options = {}) {
155
156
  const resolved = path.resolve(target);
@@ -157,18 +158,15 @@ async function decideMobileReadiness(target, options = {}) {
157
158
  const fast = options.preflightMode === "fast";
158
159
  const report = await computeMobileReadiness(resolved, options, fast);
159
160
  if (fast && report.decision === "install") {
160
- const standalone = depsInstallCommand(resolved);
161
161
  return {
162
162
  ...report,
163
163
  decision: "blocked",
164
164
  reasonCode: "deps-not-ready",
165
165
  reasons: [
166
- "Fast preflight found dependencies not ready and does not install them (the orchestrator deps phase owns installation).",
167
- ...report.reasons
166
+ ...report.reasons,
167
+ "Quick launch does not install dependencies."
168
168
  ],
169
- userAction: `1. Standalone checkout \u2014 run ${standalone} in the checkout.
170
- 2. Orchestrated slot \u2014 run its deps/prepare phase.
171
- After the selected action succeeds, re-run \`mm-harness launch <platform>\`.`,
169
+ userAction: `cd ${shellQuote(resolved)} && ${depsInstallCommand(resolved)}`,
172
170
  actions: []
173
171
  };
174
172
  }
@@ -189,9 +187,9 @@ async function decideMobileReadiness(target, options = {}) {
189
187
  )
190
188
  };
191
189
  }
192
- function writeDepsNote(resolved) {
190
+ function writeDepsNote() {
193
191
  const stream = process.stderr;
194
- const body = `package.json/yarn.lock are newer than the node_modules install markers and no install baseline is recorded yet, so the installed node_modules are used as they are. Pulling commits refreshes those timestamps without changing dependencies, and a required-dependency presence check still runs, so this on its own is not evidence that anything drifted. Act only if you know dependencies changed: run ${depsInstallCommand(resolved)} once \u2014 that refreshes the markers, and \`mm-harness prepare\` records a baseline when it runs the install, so this note stops.`;
192
+ const body = "No install baseline is recorded. Required package checks passed; timestamps alone do not establish dependency drift. Reusing the installed dependencies.";
195
193
  stream.write(
196
194
  `
197
195
  ${color("warn", "mm-harness note (deps):", { stream })} ${color("dim", body, { stream })}
@@ -206,7 +204,6 @@ async function computeMobileReadiness(resolved, options, fast) {
206
204
  let deps = rawDeps;
207
205
  if (rawDeps.status === "stale" && !rawDeps.hasBaseline) {
208
206
  deps = { installed: rawDeps.installed, status: "current", hasBaseline: false };
209
- writeDepsNote(resolved);
210
207
  }
211
208
  if (deps.status === "current") {
212
209
  const absent = missingRequiredDeps(resolved);
@@ -325,6 +322,7 @@ async function computeMobileReadiness(resolved, options, fast) {
325
322
  actions: installActions(resolved)
326
323
  };
327
324
  }
325
+ if (rawDeps.status === "stale" && !rawDeps.hasBaseline) writeDepsNote();
328
326
  if (metro.status === "down") {
329
327
  return {
330
328
  schemaVersion: 1,
@@ -168,6 +168,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
168
168
  "--title": value(),
169
169
  "--task-text": value(),
170
170
  "--task-file": value(),
171
+ "--acceptance": value(),
171
172
  "--ticket": value(),
172
173
  "--source-ref": value(),
173
174
  "--surface": value(),
@@ -241,6 +242,9 @@ const PUBLIC_COMMAND_CONTRACTS = {
241
242
  call: {
242
243
  options: options(HELP, JSON, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, {
243
244
  "--list": bool(),
245
+ "--domain": value(),
246
+ "--source": value(),
247
+ "--sort": value(["domain", "name", "library"]),
244
248
  "--arg": value()
245
249
  }),
246
250
  positionals: [{ label: "action" }],
@@ -253,6 +257,9 @@ const PUBLIC_COMMAND_CONTRACTS = {
253
257
  run: {
254
258
  options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, RUN_FUNDING, {
255
259
  "--list": bool(),
260
+ "--domain": value(),
261
+ "--source": value(),
262
+ "--sort": value(["domain", "name", "library"]),
256
263
  "--plan": bool(),
257
264
  "--proof": bool(),
258
265
  "--describe": bool(),
@@ -65,6 +65,9 @@ async function handleCall(argv) {
65
65
  }
66
66
  const { action: shortName, args, rest } = parseCallArgs(argv);
67
67
  const { options } = parseArgs(rest, "call");
68
+ if (["domain", "source", "sort"].some((name) => optionString(options, name) !== void 0)) {
69
+ throw usageError("--domain, --source, and --sort require call --list.");
70
+ }
68
71
  const json = optionFlag(options, "json");
69
72
  if (!shortName) {
70
73
  let example = "mm-harness call <action>";
@@ -320,6 +320,10 @@ async function handleLaunchLocked(argv, stream) {
320
320
  if (attempt.status === 0) {
321
321
  return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, attempt.remoteFeatureFlags);
322
322
  }
323
+ const readinessFailure = mobileReadinessFailure(attempt);
324
+ if (readinessFailure) {
325
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, readinessFailure);
326
+ }
323
327
  if (adapter === "extension" && attempt.output.includes("REMOTE_FLAG_NOT_PINNABLE:")) {
324
328
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
325
329
  code: "REMOTE_FLAG_NOT_PINNABLE",
@@ -503,6 +507,10 @@ async function handleLaunchLocked(argv, stream) {
503
507
  state.recovered.push(recoveryCode);
504
508
  return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, attempt.remoteFeatureFlags);
505
509
  }
510
+ const retryReadinessFailure = mobileReadinessFailure(attempt);
511
+ if (retryReadinessFailure) {
512
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, retryReadinessFailure);
513
+ }
506
514
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
507
515
  code: "SAME_RECOVERY_TWICE",
508
516
  message: `launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed again after ${recoveryCode} recovery \u2014 refusing to loop.`,
@@ -511,6 +519,17 @@ async function handleLaunchLocked(argv, stream) {
511
519
  originalError: attempt.output.trim() || void 0
512
520
  });
513
521
  }
522
+ function mobileReadinessFailure(result) {
523
+ const block = result.readinessBlock;
524
+ if (!block) return void 0;
525
+ return {
526
+ code: block.reasonCode === "deps-not-ready" ? "MOBILE_DEPENDENCIES_NOT_READY" : "MOBILE_READINESS_BLOCKED",
527
+ message: block.message,
528
+ userAction: block.userAction,
529
+ recoverable: false,
530
+ exitCode: result.status
531
+ };
532
+ }
514
533
  function extensionQuickReattachFailed(output) {
515
534
  return /\[reattach\]|extension quick reattach|mm-harness launch --build/u.test(output);
516
535
  }
@@ -5,13 +5,14 @@ import { importRecipeProtocol } from "../paths.js";
5
5
  import { color } from "../cli-color.js";
6
6
  import { EXIT } from "./shared.js";
7
7
  import { optionFlag, optionString, optionStrings, resolveAdapter, shellQuoteArg } from "./parse-args.js";
8
- import { describeManifestActions, renderHumanActionCatalog } from "./manifest.js";
8
+ import { actionDomain, describeManifestActions, renderHumanActionCatalog } from "./manifest.js";
9
9
  import {
10
10
  describeRunnableRecipe,
11
11
  listRunnableRecipes,
12
12
  resolveMetaMaskLibrarySources
13
13
  } from "./run-engine.js";
14
14
  const finalSegment = (name) => name.split(".").pop() ?? name;
15
+ const recipeDomain = (name) => name.includes(".") ? name.split(".")[0].replaceAll("_", "-") : "general";
15
16
  function catalogDescription(description) {
16
17
  if (!description) return "";
17
18
  const firstSentence = description.match(/^.*?[.!?](?:\s|$)/u)?.[0]?.trim() ?? description;
@@ -20,23 +21,64 @@ function catalogDescription(description) {
20
21
  async function handleListExecutables(command, options) {
21
22
  const json = optionFlag(options, "json");
22
23
  const { adapter } = resolveAdapter(options);
24
+ const domainFilter = optionString(options, "domain")?.replaceAll("_", "-");
25
+ const sourceFilter = optionString(options, "source");
26
+ const sort = optionString(options, "sort") ?? (json ? "name" : "domain");
27
+ const groupKey = (entry, domain) => sort === "library" ? entry.source : sort === "domain" ? domain : "";
28
+ const select = (available2, domainOf) => available2.filter((entry) => (!domainFilter || domainOf(entry) === domainFilter) && (!sourceFilter || entry.source === sourceFilter)).sort((left, right) => groupKey(left, domainOf(left)).localeCompare(groupKey(right, domainOf(right))) || left.name.localeCompare(right.name));
29
+ const out = (style, text) => color(style, text, { stream: process.stdout });
30
+ const stylesFor = (domains) => {
31
+ const palette = ["label", "accent", "ok", "warn"];
32
+ return new Map([...new Set(domains)].sort().map((domain, index) => [domain, palette[index % palette.length]]));
33
+ };
23
34
  const librarySources = await resolveMetaMaskLibrarySources(optionStrings(options, "library"));
35
+ const libraryLines = () => {
36
+ const lines = ["Libraries loaded:"];
37
+ for (const source of librarySources ?? []) {
38
+ const name = source.name ?? path.basename(source.root);
39
+ const kind = source.provenance?.kind === "bundled" ? "bundled" : "linked";
40
+ lines.push(` ${out("bold", name)} ${kind} \xB7 ${source.root}`);
41
+ }
42
+ if (domainFilter || sourceFilter) {
43
+ lines.push(`Filter: ${[domainFilter && `domain=${domainFilter}`, sourceFilter && `source=${sourceFilter}`].filter(Boolean).join(" ")}`);
44
+ }
45
+ return lines;
46
+ };
24
47
  if (command === "run") {
25
- const recipes = await listRunnableRecipes(adapter, librarySources);
48
+ const available2 = await listRunnableRecipes(adapter, librarySources);
49
+ const recipes = select(available2, (recipe) => recipeDomain(recipe.name));
26
50
  if (json) {
27
51
  console.log(JSON.stringify({ schemaVersion: 1, command, action: "list", adapter, recipes }, null, 2));
28
52
  return EXIT.ok;
29
53
  }
30
- const out = (style, text) => color(style, text, { stream: process.stdout });
54
+ const domainStyles = stylesFor(available2.map((recipe) => recipeDomain(recipe.name)));
31
55
  console.log(out("bold", `runnable recipes (${adapter})`));
56
+ for (const line of libraryLines()) console.log(line);
32
57
  console.log(out("comment", "Inspect: mm-harness run <recipe> --describe"));
58
+ if (recipes.length === 0) {
59
+ console.log("No runnable recipes match.");
60
+ console.log(`Available domains: ${[...domainStyles.keys()].join(", ") || "none"}`);
61
+ console.log(`Available sources: ${[...new Set(available2.map((recipe) => recipe.source))].join(", ") || "none"}`);
62
+ }
63
+ const groups = /* @__PURE__ */ new Map();
33
64
  for (const recipe of recipes) {
34
- const shadowed = recipe.shadows.length > 0 ? ` shadows=${recipe.shadows.join(",")}` : "";
35
- const variant = recipe.variant ? ` variant=${recipe.variant}` : " variant=all";
36
- const parameters = recipe.parameters.length > 0 ? ` params=${recipe.parameters.map((parameter) => parameter.name).join(",")}` : "";
37
- const summary = catalogDescription(recipe.description);
38
- const description = summary ? ` \u2014 ${summary}` : "";
39
- console.log(` ${out("cmd", recipe.name)} ${out("dim", `[${recipe.source}]${variant}${shadowed}${parameters}`)}${description}`);
65
+ const group = groupKey(recipe, recipeDomain(recipe.name));
66
+ groups.set(group, [...groups.get(group) ?? [], recipe]);
67
+ }
68
+ for (const [group, entries] of groups) {
69
+ if (group) {
70
+ const label = sort === "library" ? `Library: ${group}` : group;
71
+ console.log(`
72
+ ${out(sort === "domain" ? domainStyles.get(group) : "bold", label)} ${out("dim", `(${entries.length})`)}`);
73
+ }
74
+ for (const recipe of entries) {
75
+ const style = domainStyles.get(recipeDomain(recipe.name));
76
+ const shadowed = recipe.shadows.length > 0 ? ` shadows=${recipe.shadows.join(",")}` : "";
77
+ const variant = recipe.variant ? ` variant=${recipe.variant}` : " variant=all";
78
+ const summary = catalogDescription(recipe.description);
79
+ console.log(` ${out(style, recipe.name)} ${out("dim", `library=${recipe.source}${variant}${shadowed}`)}`);
80
+ if (summary) console.log(` ${summary}`);
81
+ }
40
82
  }
41
83
  return EXIT.ok;
42
84
  }
@@ -47,9 +89,10 @@ async function handleListExecutables(command, options) {
47
89
  );
48
90
  const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
49
91
  const protocolNames = new Set(getRecipeActionManifestActionNames(manifest));
50
- const described = describeManifestActions(manifest, actionSources).filter((entry) => protocolNames.has(entry.name)).sort((left, right) => left.name.localeCompare(right.name));
92
+ const available = describeManifestActions(manifest, actionSources).filter((entry) => protocolNames.has(entry.name)).sort((left, right) => left.name.localeCompare(right.name));
93
+ const described = select(available, actionDomain);
51
94
  const shortCounts = /* @__PURE__ */ new Map();
52
- for (const entry of described) {
95
+ for (const entry of available) {
53
96
  const short = finalSegment(entry.name);
54
97
  shortCounts.set(short, (shortCounts.get(short) ?? 0) + 1);
55
98
  }
@@ -73,9 +116,17 @@ async function handleListExecutables(command, options) {
73
116
  console.log(JSON.stringify({ schemaVersion: 1, command, action: "list", adapter, actions }, null, 2));
74
117
  return EXIT.ok;
75
118
  }
119
+ const contextLines = libraryLines();
120
+ if (described.length === 0) {
121
+ contextLines.push(`Available domains: ${[...new Set(available.map(actionDomain))].sort().join(", ") || "none"}`);
122
+ contextLines.push(`Available sources: ${[...new Set(available.map((entry) => entry.source))].sort().join(", ") || "none"}`);
123
+ }
76
124
  console.log(renderHumanActionCatalog(described, {
77
125
  title: `invocable actions (${adapter})`,
78
126
  guidance: "Use: mm-harness call <name> \xB7 short names shown first where unambiguous",
127
+ sort,
128
+ domainStyles: stylesFor(available.map(actionDomain)),
129
+ contextLines,
79
130
  displayName: (entry) => {
80
131
  const short = shortByName.get(entry.name);
81
132
  return short ? `${short} (${entry.name})` : `${entry.name} (full name only \u2014 ambiguous short)`;
@@ -345,16 +345,21 @@ function summarizeDescribedAction(entry) {
345
345
  const { schema: _schema, examples: _examples, ...summary } = entry;
346
346
  return summary;
347
347
  }
348
+ function actionDomain(entry) {
349
+ return entry.category.replaceAll("_", "-");
350
+ }
348
351
  function renderHumanActionCatalog(actions, options) {
349
352
  const out = (style, text) => color(style, text, { stream: process.stdout });
350
353
  const groups = /* @__PURE__ */ new Map();
351
354
  for (const entry of actions) {
352
- const group = actionDomainGroup(entry);
355
+ const group = options.sort === "name" ? "" : options.sort === "library" ? `Library: ${entry.source}` : options.sort === "domain" ? actionDomain(entry) : actionDomainGroup(entry);
353
356
  groups.set(group, [...groups.get(group) ?? [], entry]);
354
357
  }
355
- const lines = [out("bold", options.title), out("comment", options.guidance)];
358
+ const lines = [out("bold", options.title), ...options.contextLines ?? [], out("comment", options.guidance)];
359
+ if (actions.length === 0) lines.push("No callable actions match.");
356
360
  for (const [group, entries] of groups) {
357
- lines.push("", `${out(group === "official" ? "label" : "accent", group)} ${out("dim", `(${entries.length})`)}`);
361
+ const groupStyle = options.sort === "domain" ? options.domainStyles?.get(group) ?? "accent" : group === "official" ? "label" : "accent";
362
+ if (group) lines.push("", `${out(groupStyle, group)} ${out("dim", `(${entries.length})`)}`);
358
363
  for (const entry of entries) {
359
364
  const fieldNames = humanFieldNames(entry);
360
365
  const fields = fieldNames.length ? ` ${out("dim", `fields=${fieldNames.join(",")}`)}` : "";
@@ -362,7 +367,8 @@ function renderHumanActionCatalog(actions, options) {
362
367
  const description = entry.description ? ` ${out("comment", `\u2014 ${entry.description}`)}` : "";
363
368
  const source = ` ${out("dim", `[${entry.source}]`)}`;
364
369
  const risk = entry.capabilities.length ? ` ${out("dim", `risk=${entry.capabilities.join(",")}`)}` : "";
365
- lines.push(` ${out("cmd", options.displayName?.(entry) ?? entry.name)}${source}${fields}${cases}${risk}${description}`);
370
+ const nameStyle = options.domainStyles?.get(actionDomain(entry)) ?? "cmd";
371
+ lines.push(` ${out(nameStyle, options.displayName?.(entry) ?? entry.name)}${source}${fields}${cases}${risk}${description}`);
366
372
  }
367
373
  }
368
374
  return lines.join("\n");
@@ -585,6 +591,7 @@ function levenshtein(left, right) {
585
591
  }
586
592
  export {
587
593
  ACTION_MATRIX_ADAPTERS,
594
+ actionDomain,
588
595
  actionExampleCommand,
589
596
  actionLibraryContextArgs,
590
597
  describeManifestActions,
@@ -44,6 +44,7 @@ import {
44
44
  beginRunDiagnostics,
45
45
  finishRunDiagnostics
46
46
  } from "../run-diagnostics.js";
47
+ import { executedBrowser, recipeCdpPorts } from "./run-report.js";
47
48
  import { EXIT } from "./shared.js";
48
49
  import {
49
50
  actionManifestPathOption,
@@ -155,7 +156,13 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
155
156
  finalized.artifactManifestPath,
156
157
  effectsState
157
158
  );
158
- return finalized;
159
+ const browser = executedBrowser(
160
+ adapter,
161
+ projectRoot,
162
+ finalized.artifactManifestPath,
163
+ recipeCdpPorts(runRequest)
164
+ );
165
+ return browser ? { ...finalized, browser } : finalized;
159
166
  } finally {
160
167
  restoreRuntimeEnvironment();
161
168
  }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
+ import { createRequire } from "node:module";
2
3
  import path from "node:path";
3
4
  import { execFileSync } from "node:child_process";
5
+ import { recipeRuntimeDir } from "../paths.js";
4
6
  import { isRecord } from "./parse-args.js";
5
7
  function writeRunReport(result) {
6
8
  const summaryPath = String(result.summaryPath);
@@ -20,7 +22,7 @@ function writeRunReport(result) {
20
22
  preview: lines.filter((line) => line.startsWith("- ")).slice(0, 6).map((line) => line.slice(2))
21
23
  };
22
24
  }
23
- function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
25
+ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter, browser = null) {
24
26
  const artifactsDir = path.dirname(artifactManifestPath);
25
27
  let gitRef = "unknown";
26
28
  let branch = "unknown";
@@ -53,7 +55,8 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
53
55
  repository: path.basename(path.resolve(target)),
54
56
  git_ref: gitRef,
55
57
  branch,
56
- dirty
58
+ dirty,
59
+ ...browser ? { browser } : {}
57
60
  };
58
61
  fs.writeFileSync(
59
62
  path.join(artifactsDir, "product-provenance.json"),
@@ -77,7 +80,13 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
77
80
  type: "json",
78
81
  label: "Product checkout provenance",
79
82
  category: "system",
80
- metadata: { adapter, git_ref: gitRef, branch, dirty }
83
+ metadata: {
84
+ adapter,
85
+ git_ref: gitRef,
86
+ branch,
87
+ dirty,
88
+ ...browser?.boundTo && browser.boundTo !== "none" ? { browser: browser.browser, browser_version: browser.version, browser_source: browser.source, extension_loading: browser.extensionLoading } : {}
89
+ }
81
90
  }
82
91
  ];
83
92
  fs.writeFileSync(
@@ -86,6 +95,87 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
86
95
  `
87
96
  );
88
97
  }
98
+ function readRecord(file) {
99
+ try {
100
+ const recorded = JSON.parse(fs.readFileSync(file, "utf8"));
101
+ return isRecord(recorded) ? recorded : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+ const browserCdp = createRequire(import.meta.url)("../../adapters/shared/browser-cdp.cjs");
107
+ function verifyLaunch(record, cdpPort) {
108
+ const launch = isRecord(record.launch) ? record.launch : {};
109
+ const pid = Number(launch.pid);
110
+ const port = Number(launch.cdpPort);
111
+ if (!Number.isInteger(pid) || !Number.isInteger(port) || typeof launch.profile !== "string" || typeof launch.processStartedAt !== "string") {
112
+ return "the browser record has no process identity (pid, start time, profile, CDP port)";
113
+ }
114
+ if (cdpPort && port !== Number(cdpPort)) return `the browser record is for CDP port ${port}, not ${cdpPort}`;
115
+ try {
116
+ const owner = browserCdp.cdpOwner(port, launch.profile);
117
+ if (owner.pid !== pid || owner.startedAt !== launch.processStartedAt) {
118
+ return `CDP port ${port} is now held by another browser process than the recorded pid ${pid}`;
119
+ }
120
+ } catch (error) {
121
+ return error instanceof Error ? error.message : String(error);
122
+ }
123
+ return null;
124
+ }
125
+ function resolveTemplatedPort(value, params, recipe) {
126
+ let resolved = value;
127
+ const template = /^\{\{\s*params\.([\w-]+)\s*\}\}$/u.exec(String(value).trim());
128
+ if (template) {
129
+ const name = template[1];
130
+ const schema = isRecord(recipe) && isRecord(recipe.paramsSchema) && isRecord(recipe.paramsSchema.properties) ? recipe.paramsSchema.properties[name] : void 0;
131
+ resolved = isRecord(params) && params[name] !== void 0 ? params[name] : isRecord(schema) ? schema.default : void 0;
132
+ }
133
+ const text = String(resolved ?? "").trim();
134
+ return /^\d+$/u.test(text) ? text : null;
135
+ }
136
+ function recipeCdpPorts(request) {
137
+ const ports = /* @__PURE__ */ new Set();
138
+ const envPort = request.env?.CDP_PORT || request.env?.RECIPE_CDP_PORT;
139
+ if (envPort) ports.add(String(envPort));
140
+ let recipe = request.recipeDocument;
141
+ if (recipe === void 0 && request.recipePath) {
142
+ try {
143
+ recipe = JSON.parse(fs.readFileSync(request.recipePath, "utf8"));
144
+ } catch {
145
+ recipe = void 0;
146
+ }
147
+ }
148
+ const workflow = isRecord(recipe) && isRecord(recipe.workflow) ? recipe.workflow : null;
149
+ const nodes = workflow && isRecord(workflow.nodes) ? Object.values(workflow.nodes) : [];
150
+ for (const node of nodes) {
151
+ if (!isRecord(node) || node.cdp_port === void 0 || node.cdp_port === null) continue;
152
+ const port = resolveTemplatedPort(node.cdp_port, request.params, recipe);
153
+ ports.add(port ?? `unresolved ${String(node.cdp_port)}`);
154
+ }
155
+ return [...ports];
156
+ }
157
+ function executedBrowser(adapter, target, artifactManifestPath, ports) {
158
+ if (adapter !== "extension") return null;
159
+ if (ports.length === 0) return { boundTo: "none", reason: "the run had no known CDP port" };
160
+ if (ports.length > 1) return { boundTo: "none", reason: `the run drove several CDP ports (${ports.join(", ")})` };
161
+ return launchedBrowser(target, path.dirname(artifactManifestPath), ports[0]);
162
+ }
163
+ function launchedBrowser(target, artifactsDir, cdpPort) {
164
+ const runtime = readRecord(path.join(artifactsDir, "extension-runtime", "runtime.json"));
165
+ const autolaunched = runtime && isRecord(runtime.browser) ? runtime.browser : null;
166
+ const candidates = [];
167
+ if (autolaunched) candidates.push([autolaunched, "run-autolaunch"]);
168
+ const slot = readRecord(path.join(path.resolve(target), recipeRuntimeDir(), "browser-resolution.json"));
169
+ if (slot) candidates.push([slot, "slot-runtime"]);
170
+ if (candidates.length === 0) return null;
171
+ const reasons = [];
172
+ for (const [record, boundTo] of candidates) {
173
+ const problem = verifyLaunch(record, cdpPort);
174
+ if (problem === null) return { ...record, boundTo };
175
+ reasons.push(`${boundTo}: ${problem}`);
176
+ }
177
+ return { boundTo: "none", reason: reasons.join("; ") };
178
+ }
89
179
  function renderRunReport(summary, entries) {
90
180
  const lines = [
91
181
  "# MetaMask Recipe Run",
@@ -187,6 +277,9 @@ function indexRunReportArtifact(artifactManifestPath) {
187
277
  `);
188
278
  }
189
279
  export {
280
+ executedBrowser,
190
281
  indexProductProvenanceArtifact,
282
+ launchedBrowser,
283
+ recipeCdpPorts,
191
284
  writeRunReport
192
285
  };
@@ -147,6 +147,9 @@ async function handleRun(parsed) {
147
147
  }
148
148
  }
149
149
  async function handleRunInner({ positional, options }, stream) {
150
+ if (!optionFlag(options, "list") && ["domain", "source", "sort"].some((name) => optionString(options, name) !== void 0)) {
151
+ throw usageError("--domain, --source, and --sort require run --list.");
152
+ }
150
153
  if (optionFlag(options, "describe") && (optionFlag(options, "list") || optionFlag(options, "plan"))) {
151
154
  const message = "--describe cannot be combined with --list or --plan.";
152
155
  const userAction = "choose one: mm-harness run --list, mm-harness run <recipe> --describe, or mm-harness run <recipe> --plan";
@@ -373,7 +376,8 @@ async function handleRunInner({ positional, options }, stream) {
373
376
  indexProductProvenanceArtifact(
374
377
  result.artifactManifestPath,
375
378
  target,
376
- adapter
379
+ adapter,
380
+ result.browser ?? null
377
381
  );
378
382
  if (violation !== null) {
379
383
  const userAction = violation.userAction ?? `inspect ${shellQuote(result.summaryPath)} and ${shellQuote(result.tracePath)}; fix the application or recipe failure before retrying`;
package/dist/doctor.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import fs from "node:fs";
2
3
  import { createRequire } from "node:module";
3
4
  import path from "node:path";
@@ -167,7 +168,7 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
167
168
  message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
168
169
  },
169
170
  ...adapter === "mobile" ? mobileToolDoctorChecks(mobilePlatform) : [],
170
- ...adapter === "extension" ? [extensionBrowserDoctorCheck(target)] : []
171
+ ...adapter === "extension" ? [extensionBrowserDoctorCheck(target), extensionBrowserResolutionDoctorCheck(target)] : []
171
172
  ];
172
173
  const requiredChecks = requiredDoctorCheckSummary(checks);
173
174
  return {
@@ -214,6 +215,51 @@ function extensionBrowserDoctorCheck(target) {
214
215
  userAction: "yarn playwright install chromium"
215
216
  };
216
217
  }
218
+ function extensionBrowserResolutionDoctorCheck(target) {
219
+ const resolver = path.join(runnerDir, "adapters/shared/browser-resolver.cjs");
220
+ const runtime = path.join(path.resolve(target), recipeRuntimeDir());
221
+ const record = path.join(runtime, "browser-resolution.json");
222
+ let profile = process.env.CHROME_USER_DATA_DIR || path.join(runtime, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
223
+ while (profile.length > 1 && profile.endsWith("/")) profile = profile.slice(0, -1);
224
+ const profileExists = fs.existsSync(path.join(profile, "Local State"));
225
+ const result = spawnSync(process.execPath, [
226
+ resolver,
227
+ "resolve",
228
+ "--target",
229
+ path.resolve(target),
230
+ "--launch-method",
231
+ "launch-services",
232
+ "--display",
233
+ "headful",
234
+ ...profileExists && fs.existsSync(record) ? ["--recorded", record] : [],
235
+ "--json"
236
+ ], {
237
+ cwd: path.resolve(target),
238
+ encoding: "utf8",
239
+ timeout: 135e3
240
+ });
241
+ if (result.status === 0) {
242
+ const resolution = JSON.parse(result.stdout);
243
+ const managed = resolution.managedChrome;
244
+ const managedNote = managed && !resolution.warning ? ` Google Chrome is managed by policy (${managed.forcedExtensions.length} forced extension${managed.forcedExtensions.length === 1 ? "" : "s"}: ${managed.forcedExtensions.map(({ id, name }) => name ? `${id} (${name})` : id).join(", ")}${managed.signinForced ? "; browser sign-in forced" : ""}); slots use Chrome for Testing.` : "";
245
+ const probe = resolution.probe ? `; Chrome for Testing probe (${resolution.probe.launchMethod}, ${resolution.probe.display}${resolution.probe.cached ? ", cached" : ""}${resolution.probe.transient ? `, retried after ${resolution.probe.expiresAt}` : ""}): ${resolution.probe.reason}` : resolution.recordedFrom ? `; kept from the slot profile record ${resolution.recordedFrom}` : "";
246
+ return {
247
+ id: "extension-browser",
248
+ status: "pass",
249
+ required: false,
250
+ message: `Launch browser: ${resolution.browser} ${resolution.version ?? "(unknown version)"} via ${resolution.source} (${resolution.mode}); extension loading: ${resolution.extensionLoading}${probe}.${managedNote}${resolution.warning ? ` Warning: ${resolution.warning}` : ""}`,
251
+ detail: resolution.bin
252
+ };
253
+ }
254
+ const message = (result.stderr || result.error?.message || "browser resolution failed").trim();
255
+ return {
256
+ id: "extension-browser",
257
+ status: "fail",
258
+ required: message.includes("BROWSER_UNLAUNCHABLE:"),
259
+ message,
260
+ userAction: "install Google Chrome, or set RECIPE_HARNESS_BROWSER / RECIPE_HARNESS_CHROME_BIN to a browser that starts"
261
+ };
262
+ }
217
263
  function mobileToolDoctorChecks(platform) {
218
264
  const specs = platform === "android" ? [{ tool: "adb", platform: "Android" }] : platform === "ios" ? [{ tool: "idb", platform: "iOS" }] : [
219
265
  { tool: "adb", platform: "Android" },
@@ -294,6 +340,7 @@ export {
294
340
  compatibilityMode,
295
341
  createDoctorReport,
296
342
  extensionBrowserDoctorCheck,
343
+ extensionBrowserResolutionDoctorCheck,
297
344
  fixtureFileSummary,
298
345
  fixtureSummary,
299
346
  mobileToolDoctorChecks,
@@ -298,6 +298,7 @@ Example:
298
298
  Task:
299
299
  --title <t> Task title (required)
300
300
  --task-text <s> | --task-file <p> Task description, inline or from a file
301
+ --acceptance <criterion> Authored acceptance criterion (repeatable, preserved in order)
301
302
  --ticket <key> Ticket key
302
303
  --source-ref <url> Where the task came from
303
304
 
@@ -430,6 +431,9 @@ Example:
430
431
  mm-harness actions.
431
432
 
432
433
  --list List actions accepted by call for this adapter; no <action> needed
434
+ --domain <name> With --list: filter by action domain, e.g. perps or swap-bridge
435
+ --source <name> With --list: filter by origin, e.g. metamask, perps, or official
436
+ --sort <domain|name|library> With --list: grouping/order (default: domain; JSON: name)
433
437
  --arg k=v Action field value (repeatable; equivalent to key=value shorthand)
434
438
  --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.
435
439
  --cdp-port <port> Extension only: bind the action to this live browser runtime.
@@ -464,6 +468,9 @@ Remote feature flags (mobile applies at runtime; extension pins at launch):
464
468
 
465
469
  Discovery:
466
470
  --list List complete recipes accepted by run for this adapter; no <recipe> needed
471
+ --domain <name> With --list: filter by recipe domain, e.g. perps or swap-bridge
472
+ --source <name> With --list: filter by library origin, e.g. metamask or perps
473
+ --sort <domain|name|library> With --list: grouping/order (default: domain; JSON: name)
467
474
  --describe Show one recipe's parameters, composed actions/recipes, and runnable command
468
475
  --plan Validate + print execution plan, touching nothing. Exit 5 if invalid.
469
476
  --proof Preflight behavioral proof bindings before planning/execution; runtime evidence is still required.
package/docs/QA.md CHANGED
@@ -94,6 +94,9 @@ mm-harness debug
94
94
  `EXTENSION_PRODUCT_CONFIG_REQUIRED` before build or browser startup.
95
95
  - [ ] Missing Chromium returns `EXTENSION_BROWSER_REQUIRED` without using system
96
96
  Chrome.
97
+ - [ ] `doctor --adapter extension` names the launch browser, its version, why
98
+ it was chosen and how the extension loads; `RECIPE_HARNESS_BROWSER=cft`
99
+ restores Chrome for Testing, `=chrome` loads into Google Chrome over CDP.
97
100
  - [ ] Launch uses the checkout-owned profile, opens one MetaMask surface, avoids
98
101
  keychain prompts, and preserves keyboard focus by default.
99
102
  - [ ] A second launch reuses the watcher/profile without duplicate tabs.