@deeeed/metamask-harness 0.72.0 → 0.74.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.
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import { prepareMobile } from "../../adapters/mobile/prepare.js";
2
3
  import {
3
4
  mobileSourceCheck,
@@ -7,7 +8,11 @@ import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
7
8
  import { pinMobileFeatureFlags } from "../../adapters/mobile/feature-flags.js";
8
9
  import { toRemoteFlagSpec } from "../../feature-flags.js";
9
10
  import { clearMobileReleaseArtifactState } from "../../adapters/mobile/release-artifact-state.js";
10
- import { EXIT } from "../shared.js";
11
+ import { EXIT, str, writeInteractiveProgress } from "../shared.js";
12
+ import { applyDeviceTargeting } from "../device-target.js";
13
+ import { RECOVERY_CODE } from "../../heal-bounds.js";
14
+ import { recipeRuntimePath } from "../../paths.js";
15
+ import { shellQuote } from "./device.js";
11
16
  async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false, remoteFlags) {
12
17
  clearMobileReleaseArtifactState(target);
13
18
  await ensureHarnessFresh(target, "mobile");
@@ -54,6 +59,189 @@ async function launchMobile(target, mobileTarget, tier, json, restartApp = false
54
59
  }
55
60
  return { ...result, remoteFeatureFlags: provenFlags };
56
61
  }
62
+ function mobileProvisioningBlocked(output) {
63
+ return /open-device: configured iOS simulator '.+' does not exist|open-device: no MetaMask bundle found|fast mode requires an installed (?:iOS dev client|Android dev client)/u.test(output);
64
+ }
65
+ function missingConfiguredIosSimulator(output) {
66
+ return /open-device: configured iOS simulator '([^']+)' does not exist/u.exec(output)?.[1];
67
+ }
68
+ function ambiguousConfiguredIosSimulator(output) {
69
+ return /open-device: iOS simulator target ("[^"]+") is ambiguous/u.exec(output)?.[1];
70
+ }
71
+ function mobileBridgeTargetMissing(output) {
72
+ return /no bridge target matched|wait-for-bridge: readiness deadline expired/iu.test(output);
73
+ }
74
+ function mobileProvisionCommand(target, mobileTarget) {
75
+ const platform = mobileTarget === "android" ? "android" : "ios";
76
+ const device = platform === "ios" ? process.env.SIM_UDID || process.env.IOS_SIMULATOR : process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE;
77
+ const parts = ["mm-harness", "provision", "runway", platform, "--adapter", "mobile", "--target", shellQuote(target)];
78
+ if (device) parts.push("--device", shellQuote(device));
79
+ return parts.join(" ");
80
+ }
81
+ function isIosSimulatorUdid(value) {
82
+ return /^[0-9A-Fa-f-]{36}$/u.test(value);
83
+ }
84
+ function launchDeviceLabel(mobileTarget) {
85
+ if (mobileTarget === "android") {
86
+ return process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE || "android device";
87
+ }
88
+ if (mobileTarget === "ios") {
89
+ return process.env.IOS_SIMULATOR || process.env.SIM_UDID || "iOS simulator";
90
+ }
91
+ return process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || "booted device";
92
+ }
93
+ function metroLaunchEvidence(target) {
94
+ const evidencePath = recipeRuntimePath(target, "metro-generation.json");
95
+ let descriptor;
96
+ try {
97
+ descriptor = fs.openSync(evidencePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
98
+ const stat = fs.fstatSync(descriptor);
99
+ if (!stat.isFile() || stat.size > 256 * 1024) return void 0;
100
+ return JSON.parse(fs.readFileSync(descriptor, "utf8"));
101
+ } catch {
102
+ return void 0;
103
+ } finally {
104
+ if (descriptor !== void 0) fs.closeSync(descriptor);
105
+ }
106
+ }
107
+ const mobileLaunchProfile = {
108
+ refuseFlags({ mobileTarget }) {
109
+ if (!mobileTarget) {
110
+ return { message: "target is required for mobile.", userAction: "mm-harness launch ios or mm-harness launch android" };
111
+ }
112
+ return void 0;
113
+ },
114
+ applyEnv({ adapter, options, mobileTarget, target }) {
115
+ const device = str(options, "device");
116
+ if (!device && mobileTarget !== "ios" && mobileTarget !== "android") return void 0;
117
+ const targetingOptions = {
118
+ ...options,
119
+ ...mobileTarget ? { platform: mobileTarget } : {}
120
+ };
121
+ const result = applyDeviceTargeting("launch", adapter, targetingOptions, { gate: false, rerun: "" });
122
+ if ("code" in result) {
123
+ if (mobileTarget === "ios" && result.code === "DEVICE_NOT_FOUND") {
124
+ process.env.IOS_SIMULATOR = device;
125
+ process.env.SIM_UDID = isIosSimulatorUdid(device) ? device : "";
126
+ process.env.ADB_SERIAL = "";
127
+ process.env.ANDROID_SERIAL = "";
128
+ process.env.ANDROID_DEVICE = "";
129
+ process.env.ANDROID_TARGET_DEVICE_NAME = "";
130
+ } else {
131
+ return result;
132
+ }
133
+ }
134
+ if (mobileTarget === "android" && device && !process.env.ADB_SERIAL && !process.env.ANDROID_SERIAL) {
135
+ return {
136
+ ok: false,
137
+ code: "DEVICE_WRONG_PLATFORM",
138
+ message: `--device ${device} did not resolve to an Android device for launch android.`,
139
+ userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
140
+ };
141
+ }
142
+ if (mobileTarget === "ios" && device && !process.env.IOS_SIMULATOR) {
143
+ return {
144
+ ok: false,
145
+ code: "DEVICE_WRONG_PLATFORM",
146
+ message: `--device ${device} did not resolve to an iOS simulator for launch ios.`,
147
+ userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
148
+ };
149
+ }
150
+ return void 0;
151
+ },
152
+ start({ options, mobileTarget, machine, tier, wantClearMetro }) {
153
+ const device = str(options, "device") ?? (mobileTarget === "android" ? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL : process.env.IOS_SIMULATOR ?? process.env.SIM_UDID) ?? "configured device";
154
+ const watcherPort = str(options, "watcherPort") ?? process.env.WATCHER_PORT ?? "auto";
155
+ writeInteractiveProgress(
156
+ machine,
157
+ `\u2192 mobile launch \u2014 ${mobileTarget} \xB7 ${tier === "build" ? "native build" : "quick readiness"} \xB7 ${device} \xB7 Metro ${watcherPort === "auto" ? "auto" : `:${watcherPort}`}${wantClearMetro ? " \xB7 clear cache" : ""}`
158
+ );
159
+ },
160
+ nativeInputs: ["ios", "android", "package.json", "yarn.lock"],
161
+ rebuildCommand: ({ mobileTarget }) => `mm-harness launch ${mobileTarget} --build`,
162
+ compose: ({ target, mobileTarget, machine, remoteFlagRequest }, tier, restartApp, clearMetro) => launchMobile(target, mobileTarget, tier, machine, restartApp, clearMetro, remoteFlagRequest?.flags),
163
+ classify(_launch, output) {
164
+ if (output.includes("open-device: Android device is locked.")) {
165
+ return {
166
+ code: "MOBILE_DEVICE_LOCKED",
167
+ message: "the selected Android device is locked.",
168
+ recoverable: false,
169
+ userAction: "Unlock the selected Android device, then retry the launch.",
170
+ exitCode: EXIT.runtime,
171
+ originalError: output.trim()
172
+ };
173
+ }
174
+ if (/open-device: Android (?:lock state unavailable|display preparation failed)\./u.test(output)) {
175
+ return {
176
+ code: "MOBILE_DEVICE_UI_UNAVAILABLE",
177
+ message: "the selected Android display could not be prepared.",
178
+ recoverable: false,
179
+ userAction: "Check adb access and the selected device screen, then retry the launch.",
180
+ exitCode: EXIT.runtime,
181
+ originalError: output.trim()
182
+ };
183
+ }
184
+ return void 0;
185
+ },
186
+ async beforeHeal(launch, attempt, state) {
187
+ const { target, mobileTarget, tier } = launch;
188
+ const output = attempt.output;
189
+ const missingIosSimulator = missingConfiguredIosSimulator(output);
190
+ const ambiguousIosSimulator = ambiguousConfiguredIosSimulator(output);
191
+ if (ambiguousIosSimulator) {
192
+ return launch.fail(state, {
193
+ code: "MOBILE_DEVICE_AMBIGUOUS",
194
+ message: `more than one iOS simulator matches ${ambiguousIosSimulator}.`,
195
+ recoverable: false,
196
+ userAction: `mm-harness status --adapter mobile --target ${shellQuote(target)} --all-devices --json`,
197
+ exitCode: EXIT.runtime,
198
+ originalError: output.trim() || void 0
199
+ });
200
+ }
201
+ if (missingIosSimulator && isIosSimulatorUdid(missingIosSimulator)) {
202
+ return launch.fail(state, {
203
+ code: "MOBILE_DEVICE_NOT_FOUND",
204
+ message: `the configured iOS simulator ${missingIosSimulator} no longer exists.`,
205
+ recoverable: false,
206
+ userAction: `mm-harness status --adapter mobile --target ${shellQuote(target)} --all-devices --json`,
207
+ exitCode: EXIT.runtime,
208
+ originalError: output.trim() || void 0
209
+ });
210
+ }
211
+ if (mobileProvisioningBlocked(output)) {
212
+ return launch.fail(state, {
213
+ code: "MOBILE_PROVISION_REQUIRED",
214
+ message: `mobile ${mobileTarget ?? "runtime"} is not provisioned for this slot.`,
215
+ recoverable: false,
216
+ userAction: mobileProvisionCommand(target, mobileTarget),
217
+ exitCode: EXIT.runtime,
218
+ originalError: output.trim() || void 0
219
+ });
220
+ }
221
+ if (tier === "build" && /open-device: (?:Android|iOS) native build failed/u.test(output)) {
222
+ return launch.fail(state, {
223
+ code: "MOBILE_NATIVE_BUILD_FAILED",
224
+ message: "the selected Mobile native build failed; refusing to repeat the expensive build as a Metro recovery.",
225
+ recoverable: false,
226
+ exitCode: EXIT.runtime,
227
+ originalError: output.trim() || void 0
228
+ });
229
+ }
230
+ return void 0;
231
+ },
232
+ recovery(_launch, output) {
233
+ const restartApp = mobileBridgeTargetMissing(output);
234
+ return { code: restartApp ? "mobile.app-restarted" : RECOVERY_CODE.mobile, restartApp };
235
+ },
236
+ // Mobile proved its set inside launchMobile (null: nothing requested and no
237
+ // runtime answered).
238
+ provenFlags: (_launch, requested, proven) => ({ flags: proven === void 0 ? requested : proven }),
239
+ verifyArgs: ({ mobileTarget }) => mobileTarget ? ["--platform", mobileTarget] : [],
240
+ platformLabel: ({ mobileTarget }) => mobileTarget ?? null,
241
+ deviceLabel: ({ mobileTarget }) => launchDeviceLabel(mobileTarget),
242
+ evidence: metroLaunchEvidence
243
+ };
57
244
  export {
58
- launchMobile
245
+ launchMobile,
246
+ mobileLaunchProfile
59
247
  };
@@ -8,6 +8,7 @@ import {
8
8
  parityAdapter,
9
9
  readConfig,
10
10
  referenceHint,
11
+ REVIEW_ADAPTERS,
11
12
  renderReviewChecklist,
12
13
  renderReviewHelp,
13
14
  resolveLibrary,
@@ -30,7 +31,8 @@ function adapterFromOptions(options, target) {
30
31
  if (raw !== "mobile" && raw !== "extension" && raw !== "core" && raw !== "terminal") throw usageError(`--adapter must be mobile, extension, core, or terminal (got ${raw}).`);
31
32
  return raw;
32
33
  }
33
- return detectAdapter(target) ?? "unscoped";
34
+ const detected = detectAdapter(target);
35
+ return REVIEW_ADAPTERS.find((adapter) => adapter === detected) ?? "unscoped";
34
36
  }
35
37
  function resolveReviewContext(options, { fetch = true } = {}) {
36
38
  const target = targetPath(options);
@@ -30,6 +30,8 @@ import {
30
30
  validateMetaMaskActionInputs
31
31
  } from "../metamask-action-validation.js";
32
32
  import { gitLibraryProvenance } from "../library-provenance.js";
33
+ import { runnableLibraryRecipes } from "../discovery.js";
34
+ import { recipeComposition } from "@farmslot/recipe-cli";
33
35
  import {
34
36
  captureExecutionProvenance,
35
37
  executionProvenanceDrift,
@@ -710,35 +712,7 @@ function effectiveLibrarySources(librarySources) {
710
712
  return librarySources && librarySources.length > 0 ? librarySources : [{ name: "metamask", root: path.resolve(runnerDir, "library") }];
711
713
  }
712
714
  async function listRunnableRecipes(adapter, librarySources) {
713
- const harness = await importRecipeHarness();
714
- const sources = effectiveLibrarySources(librarySources);
715
- const resolution = await harness.loadRecipeLibraries(
716
- sources,
717
- {
718
- adapter
719
- }
720
- );
721
- const protocol = await importRecipeProtocol();
722
- const { manifest } = await resolveActionManifest(adapter, void 0, sources);
723
- const externalRecipeIds = new Set(resolution.recipes.keys());
724
- const validRefs = new Set([...resolution.recipes.values()].filter((recipe) => protocol.validateRecipeWithManifest(recipe.document, manifest, {
725
- externalRecipeIds
726
- }).status === "valid").map((recipe) => recipe.ref));
727
- return [...resolution.recipes.values()].filter((recipe) => {
728
- if (!validRefs.has(recipe.ref)) return false;
729
- try {
730
- const dependencies = harness.resolveRecipeDependencies({
731
- rootRef: recipe.ref,
732
- root: recipe.document,
733
- rootSource: recipe.provenance,
734
- recipes: resolution.recipes
735
- });
736
- return [...dependencies.recipes.keys()].every((ref) => validRefs.has(ref));
737
- } catch (error) {
738
- if (error instanceof harness.RecipeResolutionError) return false;
739
- throw error;
740
- }
741
- }).map(
715
+ return (await runnableLibraryRecipes(adapter, effectiveLibrarySources(librarySources))).map(
742
716
  (recipe) => recipeSummary(recipe.ref, adapter, recipe.document, recipe)
743
717
  ).sort((left, right) => left.name.localeCompare(right.name));
744
718
  }
@@ -822,42 +796,6 @@ function recipeParameters(document) {
822
796
  };
823
797
  });
824
798
  }
825
- function recipeComposition(recipe, recipes) {
826
- const actions = /* @__PURE__ */ new Set();
827
- const nestedRecipes = /* @__PURE__ */ new Set();
828
- const unresolvedRecipes = /* @__PURE__ */ new Set();
829
- const visitedRecipes = /* @__PURE__ */ new Set();
830
- const visitNode = (value) => {
831
- if (!isRecord(value) || typeof value.action !== "string") return;
832
- if (value.action !== "call") {
833
- actions.add(value.action);
834
- return;
835
- }
836
- if (typeof value.ref !== "string") return;
837
- nestedRecipes.add(value.ref);
838
- const dependency = recipes.get(value.ref);
839
- if (!dependency) {
840
- unresolvedRecipes.add(value.ref);
841
- return;
842
- }
843
- if (visitedRecipes.has(value.ref)) return;
844
- visitedRecipes.add(value.ref);
845
- visitRecipe(dependency.document);
846
- };
847
- const visitWorkflow = (value) => {
848
- if (!isRecord(value)) return;
849
- if (isRecord(value.nodes)) Object.values(value.nodes).forEach(visitNode);
850
- };
851
- const visitRecipe = (document) => {
852
- visitWorkflow(document.workflow);
853
- };
854
- visitRecipe(recipe);
855
- return {
856
- actions: [...actions].sort(),
857
- nestedRecipes: [...nestedRecipes].sort(),
858
- unresolvedRecipes: [...unresolvedRecipes].sort()
859
- };
860
- }
861
799
  async function validateRunRecipeStatic(recipeArg, adapter, options, params = {}) {
862
800
  let librarySources = await resolveMetaMaskLibrarySources(
863
801
  optionStrings(options, "library")
@@ -1492,7 +1430,6 @@ export {
1492
1430
  preflightRecipe,
1493
1431
  prepareHeal,
1494
1432
  prepareRuntimeIfNeeded,
1495
- recipeComposition,
1496
1433
  resolveMetaMaskLibrarySources,
1497
1434
  resolveRunRecipeArg,
1498
1435
  runOneNode,
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { detectAdapter } from "../harness.js";
8
8
  import { colorHumanMessage } from "../cli-color.js";
9
9
  import { resolveLeafInvoke, shellLeafMissing } from "../leaf-invoke.js";
10
+ import { adapterRegistry } from "../adapters/registry.js";
10
11
  const EXIT = { ok: 0, runtime: 1, usage: 2, infra: 3, bounded: 4, validation: 5 };
11
12
  function writeInteractiveProgress(json, message, {
12
13
  stdoutIsTTY = Boolean(process.stdout.isTTY),
@@ -59,11 +60,10 @@ function flag(options, key) {
59
60
  function targetOf(options) {
60
61
  return path.resolve(str(options, "target") ?? process.cwd());
61
62
  }
62
- const ADAPTER_TOKENS = ["mobile", "extension", "core", "terminal"];
63
63
  function resolveAdapter(options, target, hint) {
64
64
  const platform = str(options, "platform");
65
65
  const explicit = str(options, "adapter") ?? (platform === "ios" || platform === "android" ? "mobile" : platform);
66
- if (explicit && ADAPTER_TOKENS.includes(explicit)) return explicit;
66
+ if (explicit && adapterRegistry.has(explicit)) return explicit;
67
67
  if (hint) return hint;
68
68
  return detectAdapter(target);
69
69
  }
@@ -0,0 +1,15 @@
1
+ import { assessRecipe } from "@farmslot/recipe-cli";
2
+ import {
3
+ loadRecipeLibraries
4
+ } from "@farmslot/recipe-harness";
5
+ import { resolveActionManifest } from "./manifest.js";
6
+ async function runnableLibraryRecipes(adapter, sources) {
7
+ const resolution = await loadRecipeLibraries(sources, { adapter });
8
+ const { manifest } = await resolveActionManifest(adapter, void 0, sources);
9
+ return [...resolution.recipes.values()].filter(
10
+ (recipe) => assessRecipe({ resolution, manifest }, recipe).length === 0
11
+ );
12
+ }
13
+ export {
14
+ runnableLibraryRecipes
15
+ };
@@ -4,7 +4,8 @@ import { loadRecipeLibraries, resolveRecipeDependencies, rootResolutionRef } fro
4
4
  import { isSensitiveKey } from "./command-journal.js";
5
5
  import { resolveActionManifest } from "./manifest.js";
6
6
  import { describeManifestActions } from "./commands/manifest.js";
7
- import { recipeComposition, resolveMetaMaskLibrarySources, validateRunRecipeStatic } from "./commands/run-engine.js";
7
+ import { recipeComposition } from "@farmslot/recipe-cli";
8
+ import { resolveMetaMaskLibrarySources, validateRunRecipeStatic } from "./commands/run-engine.js";
8
9
  import { CliError, isRecord, optionStrings } from "./commands/parse-args.js";
9
10
  import { EXIT } from "./commands/shared.js";
10
11
  import { MAX_ADVICE_STATE_BYTES } from "./recipe-assistance-rubric.js";
@@ -12,6 +12,7 @@ bin/mm-harness
12
12
  -> src/ typed CLI and product decisions
13
13
  -> @farmslot/agent-runtime checklist state
14
14
  -> @farmslot/handoff scrubbed learning capture/share
15
+ -> @farmslot/recipe-cli recipe discovery: libraries, runnable checks, composition
15
16
  -> @farmslot/recipe-harness generic execution, UI transports, evidence
16
17
  -> @farmslot/protocol schemas
17
18
  -> adapters/ focused host/browser/device leaves
@@ -24,6 +25,7 @@ bin/mm-harness
24
25
  | `@farmslot/protocol` | recipe and evidence schemas |
25
26
  | `@farmslot/agent-runtime` | task-local checklist state and terminal contracts |
26
27
  | `@farmslot/handoff` | learning assembly, scrubbing, validation, approval, and publication |
28
+ | `@farmslot/recipe-cli` | recipe discovery: library resolution, runnable checks, composition |
27
29
  | `@farmslot/recipe-harness` | generic execution, recovery, traces, artifacts, `ui.*` |
28
30
  | `mm-harness` | MetaMask runtime control, diagnostics, durable domain actions |
29
31
  | skills/checklists | task workflow and proof expectations |
@@ -59,9 +61,14 @@ exits.
59
61
  ## Adapter surface
60
62
 
61
63
  Commands resolve shared platform behavior through
62
- `src/adapters/surface.ts`. Extend `AdapterSurface` only when all platforms need
63
- the concept, implement all three adapters, and let Core report headless/N/A
64
- explicitly. Do not add command-local platform ladders for surface-owned behavior.
64
+ `src/adapters/surface.ts`. Each platform's surface registers itself in
65
+ `src/adapters/registry.ts` when its module loads; a new platform registers the
66
+ same way and needs no edit to shared code. Extend `AdapterSurface` only when all
67
+ platforms need the concept, implement it in every adapter, and let Core report
68
+ headless/N/A explicitly. Do not add command-local platform ladders for
69
+ surface-owned behavior: `launch` already dispatches through
70
+ `AdapterSurface.launch`, and the device platforms supply a `DeviceLaunchProfile`
71
+ (`src/commands/launch/device.ts`).
65
72
 
66
73
  Each checkout owns:
67
74
 
@@ -122,10 +122,10 @@ Each case runs under a safety cap, `GOLDENS_CASE_TIMEOUT` (default 600 s). A cas
122
122
  Each family is a normal contract test (`tests/contract/goldens-<family>.test.sh`). `gd_init` (`tests/contract/goldens/lib.sh`) builds a sandbox on `ct_init`, and every case runs under `env -i`:
123
123
  - `gd_init` first unsets every inherited `MM_HARNESS_*`, `RECIPE_*`, `METAMASK_*` and `FARMSLOT_*` variable, plus device and port pins (`IOS_SIMULATOR`, `ADB_SERIAL`, `WATCHER_PORT`, `METRO_PORT`, `CDP_PORT`, `IDB_PATH`, `ANDROID_HOME`, …). A developer's `MM_HARNESS_BIN` or `MM_HARNESS_IDB_PATH` export therefore cannot redirect a case;
124
124
  - the environment is an allowlist: sandbox `HOME`, caches, config, `FARMSLOT_HOME`, `TZ=UTC`, no update probe, and only the variables the family sets itself;
125
- - every interpreter and tool the goldens launch is resolved to its real binary before `HOME` moves into the sandbox: `node` (`process.execPath`), `python3` (`sys.executable`, for the test shell's port holder only), `git` (`git --exec-path`), `bash` (`$BASH`) and `timeout`. A tool that resolves to a script, the shape of an asdf/mise/volta/pyenv shim, fails the family up front with a message naming it. Each is checked to run with a sandboxed `HOME`;
125
+ - every interpreter and tool the goldens launch is resolved to its real binary before `HOME` moves into the sandbox: `node` (`process.execPath`), `git` (`git --exec-path`), `bash` (`$BASH`) and `timeout`. The goldens need no Python. A tool that resolves to a script, the shape of an asdf/mise/volta/pyenv shim, fails the family up front with a message naming it. Each is checked to run with a sandboxed `HOME`;
126
126
  - `PATH` is pinned to the case stubs, the sandbox stubs, those real binaries, and `/usr/bin:/bin:/usr/sbin:/sbin`. Host tools (`idb`, `capture-helper`, a developer's Chrome) cannot change a golden;
127
127
  - device-tool discovery is pinned: `MM_HARNESS_IDB_PATH` and `MM_HARNESS_ADB_PATH` point at sandbox stubs, which discovery checks before any absolute Homebrew or pipx path;
128
- - localhost probes cannot reach a real server. `gd_reserve_ports` holds ephemeral ports that are bound but never listened on (`127.0.0.1` and `::1`), so Metro `/status` and CDP probes cannot connect (on macOS they time out rather than being refused) and nothing real can take the port mid-run. Each family exports the farm's slot port variables (`RECIPE_CDP_PORT`, `WATCHER_PORT`, `METRO_PORT`) set to those ports, so commands without an explicit port use them too. Goldens show them as `<PORT:NAME>`, rewritten only in port positions (`*port` keys, `:<port>`, `port <n>`/`PORT=<n>`, or an argument that is exactly the port), so an unrelated equal number such as a 60000 ms timeout is never rewritten;
128
+ - localhost probes cannot reach a real server. `gd_reserve_ports` runs a Node port holder (`tests/contract/goldens/port-holder.mjs`) that holds ephemeral ports on `127.0.0.1` and `::1` and resets every connection it accepts, so Metro `/status` and CDP probes never get a response and nothing real can take the port mid-run. Each family exports the farm's slot port variables (`RECIPE_CDP_PORT`, `WATCHER_PORT`, `METRO_PORT`) set to those ports, so commands without an explicit port use them too. Goldens show them as `<PORT:NAME>`, rewritten only in port positions (`*port` keys, `:<port>`, `port <n>`/`PORT=<n>`, or a string that is exactly the port), so an unrelated equal number such as a 60000 ms timeout is never rewritten;
129
129
  - `tmux`, `xcrun` and `adb` are stubbed (`ct_init`, plus one booted simulator and one Android device for Mobile);
130
130
  - forbidden stubs record any call to `open`, `osascript`, `lsappinfo`, `screencapture`, `yarn`, `npx`, `npm`, `corepack`, `gh` and Chrome/Chromium, and the family fails if one is called. A browser stub answers only `--version`, and `npm` only `root -g` (install-source detection), with a sandbox path;
131
131
  - inert stubs on the case PATH only: `lsof`, `pgrep` and `pkill` find nothing, `ps` passes per-pid queries (`-p`, the CLI identifying its own processes) and lists an empty host for table listings, and `watchman` and `caffeinate` are no-ops. `stop` cannot see or signal a real process or touch a host daemon;
@@ -149,7 +149,7 @@ The goldens need the harness to be a git checkout: `execution-provenance.json` `
149
149
 
150
150
  `tests/contract/goldens/normalize.mjs` is the only normaliser. It rewrites only volatile values:
151
151
 
152
- 1. Absolute paths: the sandbox becomes `<SANDBOX>`, the repo `<REPO>`, the real `$HOME` `<HOME>`, the node binary and its prefix `<NODE>`/`<NODE_PREFIX>`, and `os.tmpdir()` `<TMP>`. Both `/var` and `/private/var` spellings are covered. Ports from `gd_reserve_ports` become `<PORT:NAME>` in port positions only: numbers under `*port` keys, and in text after `:`, `port `, `port=` or `PORT=`, or a whole string equal to the port.
152
+ 1. Absolute paths: the sandbox becomes `<SANDBOX>`, the repo `<REPO>`, the real `$HOME` `<HOME>`, the node binary and its prefix `<NODE>`/`<NODE_PREFIX>`, and `os.tmpdir()` `<TMP>`. Both `/var` and `/private/var` spellings are covered. Ports from `gd_reserve_ports` become `<PORT:NAME>` in port positions only: numbers under `*port` keys, and in text after `:`, `port `, `port=` or `PORT=`, or a string that is exactly the port (`^<port>$`; `60000 ms` is left alone).
153
153
  2. UUIDs become `<UUID>`. Git object ids (40 hex, or a short hex value under a `*head`/`*gitRef`/`*commit` key) become `<GIT_REF>`. ISO-8601 timestamps become `<TIMESTAMP>`, and compact stamps in names `<STAMP>`. Path-derived slot ids `local-<adapter>-<8 hex>` become `local-<adapter>-<HASH>`.
154
154
  3. The package version becomes `<HARNESS_VERSION>`. Any other `x.y.z` under a key ending in `version` becomes `<VERSION>`.
155
155
  4. Numbers under volatile keys: `*pid` becomes `<PID>`, `*port` `<PORT>`, `*At`/`*Time`/`*timestamp`/`mtime*` `<TIMESTAMP>`, and `*Ms`/`duration*`/`elapsed*` `<DURATION>`. Values inside `schema` and `examples` subtrees (static manifest content) are never rewritten.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -21,11 +21,12 @@
21
21
  "check:syntax": "find . -name '*.mjs' -print0 | xargs -0 -n1 node --check"
22
22
  },
23
23
  "dependencies": {
24
- "@farmslot/agent-runtime": "0.15.1",
25
- "@farmslot/expo-recipe": "0.12.2",
24
+ "@farmslot/agent-runtime": "0.16.0",
25
+ "@farmslot/expo-recipe": "0.13.1",
26
26
  "@farmslot/handoff": "^0.3.1",
27
- "@farmslot/protocol": "0.33.0",
28
- "@farmslot/recipe-harness": "0.21.1",
27
+ "@farmslot/protocol": "0.34.0",
28
+ "@farmslot/recipe-cli": "0.1.1",
29
+ "@farmslot/recipe-harness": "0.22.1",
29
30
  "agent-device": "0.19.3",
30
31
  "commander": "^12.0.0",
31
32
  "es-module-lexer": "2.3.1",