@deeeed/metamask-harness 0.14.7 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +81 -6
  2. package/adapters/core/cleanup.sh +0 -0
  3. package/adapters/core/inject.sh +0 -0
  4. package/adapters/extension/cleanup.mjs +0 -0
  5. package/adapters/extension/ensure-browser.sh +4 -2
  6. package/adapters/extension/inject.mjs +0 -0
  7. package/adapters/extension/launch-browser.cjs +73 -34
  8. package/adapters/extension/launch.sh +0 -0
  9. package/adapters/extension/live.sh +21 -10
  10. package/adapters/extension/readiness.mjs +0 -0
  11. package/adapters/extension/reattach.sh +3 -3
  12. package/adapters/extension/refresh-build.sh +0 -0
  13. package/adapters/extension/seed-fixture.sh +0 -0
  14. package/adapters/extension/sidepanel-toggle.sh +10 -4
  15. package/adapters/extension/snapshot-dist.sh +0 -0
  16. package/adapters/extension/start-watch.sh +2 -2
  17. package/adapters/extension/stop-viewers.sh +0 -0
  18. package/adapters/extension/verify.sh +19 -2
  19. package/adapters/extension/wallet-fixture-state.cjs +14 -1
  20. package/adapters/manifest.json +19 -3
  21. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +0 -0
  22. package/adapters/mobile/bridge-runtime/setup-wallet.sh +6 -4
  23. package/adapters/mobile/cleanup.sh +0 -0
  24. package/adapters/mobile/inject.sh +0 -0
  25. package/adapters/mobile/launch-metro.cjs +74 -0
  26. package/adapters/mobile/lib/metro-listener.sh +0 -0
  27. package/adapters/mobile/lib/tmux-viewer.sh +4 -4
  28. package/adapters/mobile/open-device.sh +3 -1
  29. package/adapters/mobile/prewarm-bundle.sh +0 -0
  30. package/adapters/mobile/start-metro.sh +12 -18
  31. package/adapters/mobile/stop-metro.sh +1 -0
  32. package/adapters/mobile/verify.sh +5 -5
  33. package/adapters/mobile/wait-for-bridge.sh +0 -0
  34. package/adapters/mobile/yarn-setup.sh +0 -0
  35. package/adapters/shared/activate-repo-node.sh +0 -0
  36. package/adapters/shared/activate-repo-ruby.sh +0 -0
  37. package/adapters/shared/cli-ux.sh +0 -0
  38. package/adapters/shared/ensure-runner-deps.sh +0 -0
  39. package/adapters/shared/harness-path.sh +0 -0
  40. package/adapters/shared/hash-helpers.sh +0 -0
  41. package/adapters/shared/install-repo-deps.sh +29 -0
  42. package/adapters/shared/json-field.sh +0 -0
  43. package/adapters/shared/open-debug.mjs +35 -4
  44. package/adapters/shared/open-log-window.sh +1 -1
  45. package/adapters/shared/reap-checkout-metros.sh +0 -0
  46. package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
  47. package/adapters/shared/resolve-farmslot-ports.sh +0 -0
  48. package/adapters/shared/resolve-slot-ports-core.mjs +72 -1
  49. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  50. package/adapters/shared/resolve-slot-ports.sh +21 -17
  51. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  52. package/adapters/shared/tmux-session.sh +0 -5
  53. package/adapters/shared/tmux-viewer.sh +7 -2
  54. package/dist/adapters/core/surface.js +3 -0
  55. package/dist/adapters/extension/ensure-ready.js +0 -7
  56. package/dist/adapters/extension/runtime-decision.js +93 -3
  57. package/dist/adapters/extension/surface.js +17 -6
  58. package/dist/adapters/mobile/metro-env.js +67 -0
  59. package/dist/adapters/mobile/perps-env.js +101 -0
  60. package/dist/adapters/mobile/prepare.js +32 -14
  61. package/dist/adapters/mobile/runtime-decision.js +21 -2
  62. package/dist/adapters/mobile/source-freshness.js +116 -0
  63. package/dist/adapters/mobile/surface.js +3 -0
  64. package/dist/adapters/resolve-slot-ports.js +4 -0
  65. package/dist/adapters/slot-ports.js +131 -49
  66. package/dist/adapters.js +7 -2
  67. package/dist/checkout-lock.js +72 -0
  68. package/dist/cli.js +2 -0
  69. package/dist/commands/call.js +91 -54
  70. package/dist/commands/check.js +266 -46
  71. package/dist/commands/checklist.js +136 -0
  72. package/dist/commands/debug.js +21 -2
  73. package/dist/commands/doctor.js +187 -12
  74. package/dist/commands/fixtures.js +177 -42
  75. package/dist/commands/launch/extension.js +6 -1
  76. package/dist/commands/launch/index.js +54 -22
  77. package/dist/commands/logs.js +24 -4
  78. package/dist/commands/run-engine.js +223 -7
  79. package/dist/commands/run.js +67 -50
  80. package/dist/commands/shared.js +86 -5
  81. package/dist/commands/stop.js +1 -2
  82. package/dist/doctor.js +39 -17
  83. package/dist/harness.js +5 -2
  84. package/dist/heal-bounds.js +1 -1
  85. package/dist/mm-harness-cli.js +37 -9
  86. package/dist/runner.js +35 -5
  87. package/dist/runtime-context.js +228 -0
  88. package/docs/CLI-SPEC.md +15 -11
  89. package/docs/MENTAL-MODEL.md +6 -6
  90. package/library/actions/extension/perps/perps.mjs +29 -8
  91. package/library/actions/extension/platform/cdp.mjs +128 -28
  92. package/library/actions/extension/ui/navigate.mjs +1 -1
  93. package/library/actions/mobile/perps/perps.mjs +13 -1
  94. package/library/actions/mobile/platform/bridge.mjs +302 -29
  95. package/library/actions/mobile/wallet/ensure_unlocked.mjs +7 -2
  96. package/library/manifests/extension.action-manifest.json +32 -12
  97. package/library/manifests/mobile.action-manifest.json +25 -3
  98. package/package.json +5 -5
  99. package/scripts/completions.sh +7 -4
  100. package/scripts/install-completions.sh +0 -0
@@ -0,0 +1,228 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { recipeRuntimeDir } from "./paths.js";
7
+ function ensureRuntimeContext(target, adapter) {
8
+ const repoRoot = fs.realpathSync(target);
9
+ const runtimeDir = recipeRuntimeDir();
10
+ const contextPath = path.join(repoRoot, runtimeDir, "agentic-runtime.json");
11
+ const existingState = readExistingContext(contextPath, repoRoot, runtimeDir, adapter);
12
+ const existing = existingState.context;
13
+ const defaults = localDefaults(repoRoot, adapter, existing);
14
+ const context = {
15
+ schemaVersion: 1,
16
+ ...existing,
17
+ repoRoot,
18
+ slotId: process.env.RECIPE_SLOT_ID || existing.slotId || defaults.slotId,
19
+ runtimeOwner: existing.runtimeOwner && existing.runtimeOwner !== "unknown" ? existing.runtimeOwner : "mm-harness",
20
+ strict: existing.strict ?? true,
21
+ platform: existing.platform || adapter,
22
+ runtimeDir,
23
+ ...runtimeResources(adapter, existing, defaults),
24
+ ...gitIdentity(repoRoot)
25
+ };
26
+ if (!existing.createdAt) context.createdAt = (/* @__PURE__ */ new Date()).toISOString();
27
+ removeEmptyValues(context);
28
+ fs.mkdirSync(path.dirname(contextPath), { recursive: true });
29
+ writeJsonAtomic(contextPath, context);
30
+ hydrateRuntimeEnv(contextPath, context);
31
+ return {
32
+ path: contextPath,
33
+ created: !existingState.existed || Object.keys(existing).length === 0 || existingState.quarantinedPath !== void 0,
34
+ quarantinedPath: existingState.quarantinedPath,
35
+ context
36
+ };
37
+ }
38
+ function readExistingContext(contextPath, repoRoot, runtimeDir, adapter) {
39
+ if (!fs.existsSync(contextPath)) return { context: {}, existed: false };
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(fs.readFileSync(contextPath, "utf8"));
43
+ } catch {
44
+ return quarantineInvalidContext(contextPath);
45
+ }
46
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
47
+ return quarantineInvalidContext(contextPath);
48
+ }
49
+ const context = parsed;
50
+ if (!validExistingContext(context, repoRoot, runtimeDir, adapter)) {
51
+ return quarantineInvalidContext(contextPath);
52
+ }
53
+ return { context, existed: true };
54
+ }
55
+ function validExistingContext(context, repoRoot, runtimeDir, adapter) {
56
+ if (context.schemaVersion !== void 0 && context.schemaVersion !== 1) return false;
57
+ if (context.repoRoot !== void 0 && (typeof context.repoRoot !== "string" || path.resolve(context.repoRoot) !== repoRoot)) return false;
58
+ if (context.platform !== void 0 && context.platform !== adapter) return false;
59
+ if (context.runtimeDir !== void 0 && context.runtimeDir !== runtimeDir) return false;
60
+ if (context.strict !== void 0 && typeof context.strict !== "boolean") return false;
61
+ if (context.gitDirty !== void 0 && typeof context.gitDirty !== "boolean") return false;
62
+ const stringFields = [
63
+ "project",
64
+ "slotId",
65
+ "machine",
66
+ "runtimeOwner",
67
+ "simulator",
68
+ "adbSerial",
69
+ "distDir",
70
+ "extensionId",
71
+ "gitHead",
72
+ "gitBranch",
73
+ "gitCommit",
74
+ "createdAt",
75
+ "session"
76
+ ];
77
+ for (const field of stringFields) {
78
+ if (context[field] !== void 0 && (typeof context[field] !== "string" || context[field].length === 0)) return false;
79
+ }
80
+ if (context.runtimeOwner !== void 0 && !["farmslot", "mm-harness", "unknown"].includes(String(context.runtimeOwner))) return false;
81
+ for (const field of ["cdpPort", "watcherPort", "metroPort", "devServerPort"]) {
82
+ const value = context[field];
83
+ if (value !== void 0 && (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535)) return false;
84
+ }
85
+ if (context.runtimeStart !== void 0) {
86
+ if (!context.runtimeStart || typeof context.runtimeStart !== "object" || Array.isArray(context.runtimeStart)) return false;
87
+ const runtimeStart = context.runtimeStart;
88
+ if (runtimeStart.approved !== void 0 && typeof runtimeStart.approved !== "boolean") return false;
89
+ for (const field of ["command", "readyUrl"]) {
90
+ if (runtimeStart[field] !== void 0 && (typeof runtimeStart[field] !== "string" || runtimeStart[field].length === 0)) return false;
91
+ }
92
+ }
93
+ const forbiddenResources = adapter === "core" ? ["cdpPort", "watcherPort", "metroPort", "devServerPort", "simulator", "adbSerial", "extensionId"] : adapter === "extension" ? ["metroPort", "simulator", "adbSerial"] : ["cdpPort", "extensionId"];
94
+ if (forbiddenResources.some((field) => context[field] !== void 0)) return false;
95
+ return true;
96
+ }
97
+ function quarantineInvalidContext(contextPath) {
98
+ const quarantinedPath = `${contextPath}.invalid-${Date.now()}-${process.pid}`;
99
+ fs.renameSync(contextPath, quarantinedPath);
100
+ return { context: {}, existed: true, quarantinedPath };
101
+ }
102
+ function localDefaults(repoRoot, adapter, existing) {
103
+ const digest = createHash("sha256").update(repoRoot).digest("hex");
104
+ const offset = Number.parseInt(digest.slice(0, 8), 16) % 1e4;
105
+ const defaults = { slotId: `local-${adapter}-${digest.slice(0, 8)}` };
106
+ if (adapter === "extension" && !numericEnv("RECIPE_CDP_PORT", "CDP_PORT") && !existing.cdpPort) {
107
+ defaults.cdpPort = claimLocalPort(repoRoot, "cdp", 2e4 + offset);
108
+ }
109
+ if (adapter !== "core" && !numericEnv("WATCHER_PORT", "METRO_PORT", "RECIPE_WATCHER_PORT") && !existing.watcherPort && !existing.metroPort && !existing.devServerPort) {
110
+ defaults.watcherPort = claimLocalPort(repoRoot, "watcher", (adapter === "extension" ? 3e4 : 4e4) + offset);
111
+ }
112
+ return defaults;
113
+ }
114
+ function claimLocalPort(repoRoot, kind, preferred) {
115
+ const registry = process.env.MM_HARNESS_PORT_REGISTRY ? path.resolve(process.env.MM_HARNESS_PORT_REGISTRY) : path.join(os.homedir(), ".cache", "mm-harness", "ports");
116
+ fs.mkdirSync(registry, { recursive: true });
117
+ const rangeStart = Math.floor(preferred / 1e4) * 1e4;
118
+ for (let step = 0; step < 1e4; step += 1) {
119
+ const port = rangeStart + (preferred - rangeStart + step) % 1e4;
120
+ const claimPath = path.join(registry, `${port}.json`);
121
+ const claim = { schemaVersion: 1, port, kind, repoRoot, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
122
+ try {
123
+ const fd = fs.openSync(claimPath, "wx", 384);
124
+ fs.writeFileSync(fd, `${JSON.stringify(claim, null, 2)}
125
+ `);
126
+ fs.closeSync(fd);
127
+ return port;
128
+ } catch (error) {
129
+ if (error.code !== "EEXIST") throw error;
130
+ const owner = readPortClaim(claimPath);
131
+ if (owner?.repoRoot === repoRoot) return port;
132
+ if (!owner || !portClaimIsLive(owner, port)) {
133
+ fs.rmSync(claimPath, { force: true });
134
+ step -= 1;
135
+ }
136
+ }
137
+ }
138
+ throw new Error(`no local ${kind} port is available in ${rangeStart}-${rangeStart + 9999}`);
139
+ }
140
+ function readPortClaim(claimPath) {
141
+ try {
142
+ const value = JSON.parse(fs.readFileSync(claimPath, "utf8"));
143
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+ function portClaimIsLive(claim, port) {
149
+ if (typeof claim.repoRoot !== "string" || !fs.existsSync(claim.repoRoot)) return false;
150
+ const contextPath = path.join(claim.repoRoot, recipeRuntimeDir(), "agentic-runtime.json");
151
+ try {
152
+ const context = JSON.parse(fs.readFileSync(contextPath, "utf8"));
153
+ return context.cdpPort === port || context.watcherPort === port || context.metroPort === port || context.devServerPort === port;
154
+ } catch {
155
+ const created = typeof claim.createdAt === "string" ? Date.parse(claim.createdAt) : Number.NaN;
156
+ return Number.isFinite(created) && Date.now() - created < 6e4;
157
+ }
158
+ }
159
+ function runtimeResources(adapter, existing, defaults) {
160
+ if (adapter === "core") return {};
161
+ const watcherPort = numericEnv("WATCHER_PORT", "METRO_PORT", "RECIPE_WATCHER_PORT") ?? existing.watcherPort ?? existing.metroPort ?? existing.devServerPort ?? defaults.watcherPort;
162
+ const shared = {
163
+ watcherPort,
164
+ devServerPort: watcherPort
165
+ };
166
+ if (adapter === "extension") {
167
+ shared.cdpPort = numericEnv("RECIPE_CDP_PORT", "CDP_PORT") ?? existing.cdpPort ?? defaults.cdpPort;
168
+ } else {
169
+ shared.metroPort = watcherPort;
170
+ shared.simulator = process.env.IOS_SIMULATOR || existing.simulator;
171
+ shared.adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || existing.adbSerial;
172
+ }
173
+ return shared;
174
+ }
175
+ function numericEnv(...names) {
176
+ for (const name of names) {
177
+ const raw = process.env[name];
178
+ if (!raw || !/^\d+$/u.test(raw)) continue;
179
+ return Number(raw);
180
+ }
181
+ return void 0;
182
+ }
183
+ function gitIdentity(repoRoot) {
184
+ return {
185
+ gitHead: git(repoRoot, ["rev-parse", "HEAD"]),
186
+ gitBranch: git(repoRoot, ["branch", "--show-current"]),
187
+ gitDirty: Boolean(git(repoRoot, ["status", "--short"]))
188
+ };
189
+ }
190
+ function git(repoRoot, args) {
191
+ try {
192
+ return execFileSync("git", ["-C", repoRoot, ...args], {
193
+ encoding: "utf8",
194
+ stdio: ["ignore", "pipe", "ignore"]
195
+ }).trim() || void 0;
196
+ } catch {
197
+ return void 0;
198
+ }
199
+ }
200
+ function removeEmptyValues(context) {
201
+ for (const [key, value] of Object.entries(context)) {
202
+ if (value === void 0 || value === null || value === "") delete context[key];
203
+ }
204
+ }
205
+ function writeJsonAtomic(file, value) {
206
+ const temporary = `${file}.${process.pid}.tmp`;
207
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}
208
+ `, { mode: 384 });
209
+ fs.renameSync(temporary, file);
210
+ }
211
+ function hydrateRuntimeEnv(contextPath, context) {
212
+ process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
213
+ setEnv("RECIPE_SLOT_ID", context.slotId);
214
+ setEnv("CDP_PORT", context.cdpPort);
215
+ setEnv("RECIPE_CDP_PORT", context.cdpPort);
216
+ setEnv("WATCHER_PORT", context.watcherPort);
217
+ setEnv("RECIPE_WATCHER_PORT", context.watcherPort);
218
+ setEnv("METRO_PORT", context.metroPort ?? context.watcherPort);
219
+ setEnv("IOS_SIMULATOR", context.simulator);
220
+ setEnv("ADB_SERIAL", context.adbSerial);
221
+ setEnv("ANDROID_SERIAL", context.adbSerial);
222
+ }
223
+ function setEnv(name, value) {
224
+ if (value !== void 0 && value !== null && value !== "") process.env[name] = String(value);
225
+ }
226
+ export {
227
+ ensureRuntimeContext
228
+ };
package/docs/CLI-SPEC.md CHANGED
@@ -286,19 +286,23 @@ Recovery is silent in human mode. With `--json`: `"recovered": ["metro.restarted
286
286
  > `runOneNode` in `runner/src/cli.ts`). `set` always prints the fixture edit path;
287
287
  > the password is read FROM the fixture, never typed. On-device seed is Tier B.
288
288
 
289
- **One canonical wallet fixture per checkout** — same SSOT model as the farm installer. There is no fixture selection, listing, or bare status verb. **Fixture status is reported by `doctor` automatically** (grounded: `doctor.ts` `fixtureSummary()` already returns `{ status: 'missing'|'ready'|'incomplete'|'invalid', path, accountCount, hasPassword }` and is included in `createDoctorReport()` at line 125). The human edits `wallet-fixture.json` directly; `fixtures set` always teaches where.
289
+ **One canonical wallet fixture per checkout** — same SSOT model as the farm installer. `init --from` copies an existing secure team/test fixture; explicit `init --dev` creates a disposable public test wallet that must never hold real funds. **Fixture status is reported by `doctor` automatically.**
290
290
 
291
- **Synopsis:** `mm-harness fixtures <sync|set|generate|finalize> [flags]` — subcommand required.
291
+ **Synopsis:** `mm-harness fixtures <init|sync|set|generate|finalize> [flags]` — subcommand required.
292
292
 
293
293
  | Subcommand | Behavior |
294
294
  |---|---|
295
+ | `init` | Create the canonical fixture from `--from <path>` or explicit `--dev`; refuse replacement unless `--force` is supplied |
295
296
  | `sync` | Refresh **wallet fixture files** on the target (wallet DATA only — overlay refresh is `--heal` / `doctor --fix` territory, NOT `fixtures`; they are separate concerns) |
296
- | `set` | Apply THE canonical fixture (SRP/password/accounts) to the running slot password read FROM the fixture, never typed; always prints "Want different accounts? Edit: <absolute-path>/wallet-fixture.json" |
297
+ | `set` | Apply THE canonical fixture (SRP/password/accounts) to Mobile/Extension; Core validates and reads the same fixture directly for signing |
297
298
  | `generate` | Render the extension `fixture-state.json` from a wallet fixture for pre-launch profile prefill (fronts `adapters/extension/wallet-fixture-state.cjs generate`). **Extension-only** (mobile/core teach). Requires `--fixture <wallet-fixture.json>` and `--out <fixture-state.json>`. This is the public surface for farm packs that need the pre-launch fixture-state file, replacing direct reach-in to the internal leaf. |
298
299
  | `finalize` | Seed account labels/selection into the **live** extension over CDP and validate import parity (fronts `adapters/extension/wallet-fixture-state.cjs seed-cdp`). **Extension-only, post-launch** (mobile/core teach). Requires `--fixture`, `--state`, `--cdp-port`, `--extension-dir`. This is the public surface for farm packs that finalize account labels after launch, replacing the last direct reach-in to the internal leaf. |
299
300
 
300
301
  | Flag | Type | Default | ENV (agent) | Audience | Description |
301
302
  |---|---|---|---|---|---|
303
+ | `--from <json>` | path | — | — | both | `init`: copy an existing fixture without exposing its values in arguments |
304
+ | `--dev` | bool | false | — | both | `init`: create a disposable public test wallet; never real funds |
305
+ | `--force` | bool | false | — | both | `init`: intentionally replace the canonical fixture |
302
306
  | `--fixture <json>` | path | slot's `wallet-fixture.json` (`set`/`sync`); **required** for `generate`/`finalize` (input wallet fixture) | `RECIPE_WALLET_FIXTURE` | **agent** (`set`/`sync`); both (`generate`/`finalize`) | `set`/`sync`: override fixture path (human form = edit the canonical file directly). `generate`/`finalize`: the input wallet fixture. |
303
307
  | `--out <json>` | path | `generate`: **required**; `finalize`: leaf default report path | — | both | `generate`: output `fixture-state.json`. `finalize`: optional validation report path. |
304
308
  | `--state <json>` | path | — (**required** for `finalize`) | — | both | `finalize` only: the pre-launch `fixture-state.json` to seed into the live wallet |
@@ -393,9 +397,9 @@ Readiness check for a checkout without launching the app. Doctor is the single p
393
397
 
394
398
  | Field | Env var set by harness | Flag override | How to customize |
395
399
  |---|---|---|---|
396
- | `slotId` | `RECIPE_SLOT_ID` | `--slot` | farmslot dispatch writes it |
397
- | `extensionId` | `RECIPE_HARNESS_EXTENSION_ID` | — | auto-resolved; edit file to pin |
398
- | `cdpPort` | `RECIPE_CDP_PORT` / `CDP_PORT` | `--cdp-port` | edit file or pass flag |
400
+ | `slotId` | `RECIPE_SLOT_ID` | `--slot` | `doctor --fix` creates local identity; Farmslot may override |
401
+ | `extensionId` | `RECIPE_HARNESS_EXTENSION_ID` | — | resolved from the built extension |
402
+ | `cdpPort` | `RECIPE_CDP_PORT` / `CDP_PORT` | `--cdp-port` | `doctor --fix` claims one; flag overrides |
399
403
  | `runtimeStart.approved` | `RECIPE_RUNTIME_START_APPROVED` | — | edit file (`true`/`false`) |
400
404
  | `runtimeStart.command` | — | — | edit file |
401
405
  | `runtimeStart.readyUrl` | `RECIPE_RUNTIME_READY_URL` | — | edit file |
@@ -412,9 +416,9 @@ Readiness check for a checkout without launching the app. Doctor is the single p
412
416
  "fileExists": true,
413
417
  "envOverride": null,
414
418
  "fields": {
415
- "slotId": { "value": "slot-1", "source": "file", "envVar": "RECIPE_SLOT_ID", "customize": "farmslot dispatch writes this" },
416
- "extensionId": { "value": "abc123", "source": "file", "envVar": "RECIPE_HARNESS_EXTENSION_ID", "customize": "auto-resolved; edit file to pin" },
417
- "cdpPort": { "value": "6663", "source": "file", "envVar": "CDP_PORT", "customize": "edit file or pass --cdp-port" },
419
+ "slotId": { "value": "slot-1", "source": "file", "envVar": "RECIPE_SLOT_ID", "customize": "doctor --fix creates local identity; Farmslot may override" },
420
+ "extensionId": { "value": "abc123", "source": "file", "envVar": "RECIPE_HARNESS_EXTENSION_ID", "customize": "resolved from the built extension" },
421
+ "cdpPort": { "value": "6663", "source": "file", "envVar": "CDP_PORT", "customize": "doctor --fix claims one; --cdp-port overrides" },
418
422
  "runtimeStart.approved": { "value": "true", "source": "file", "envVar": "RECIPE_RUNTIME_START_APPROVED", "customize": "edit file (true/false)" },
419
423
  "runtimeStart.command": { "value": null, "source": "default", "envVar": null, "customize": "edit file" },
420
424
  "runtimeStart.readyUrl": { "value": null, "source": "default", "envVar": "RECIPE_RUNTIME_READY_URL", "customize": "edit file" }
@@ -428,7 +432,7 @@ Readiness check for a checkout without launching the app. Doctor is the single p
428
432
  | `--adapter <p>` | mobile\|extension\|core | auto-detect | `RECIPE_HARNESS_PLATFORM` | both | Target · [DEFAULT-GAP] required today |
429
433
  | `--target <repo>` | path | `$PWD` | — | both | Checkout to probe |
430
434
  | `--action-manifest <path>` | path | bundled | — | agent | Override manifest (the file; `manifest` verb is retired) |
431
- | `--fix` | bool | false | — | both | Run healing steps WITHOUT launching — repair overlay, runtime context, and validate manifest; no app started. Exit 0 = all healed; 1 = partial/failed. `--json` adds `"fixed": [...]` and `"failed": [...]` arrays. |
435
+ | `--fix` | bool | false | — | both | Repair overlay/runtime context and require a usable canonical fixture without launching. Exit 0 = sandbox prerequisites ready; 1 = partial/failed. JSON adds `fixed`, `failed`, and numbered `nextActions`. |
432
436
  | `--json` | bool | false | — | agent | Machine-readable (includes fixture + runtimeContext sections; with `--fix`: adds fixed/failed arrays) |
433
437
 
434
438
  **`--fix` framing:** `doctor --fix` runs the same healing checks as `launch`/`verify`'s self-healing path but stops short of starting the app. Use it when the agent detects a broken state and wants to repair before the next `run` — or when CI needs to repair without a launch side-effect. `doctor` without `--fix` is always pure read-only.
@@ -618,7 +622,7 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
618
622
  | Flag | Type | Default | ENV | Audience | Description |
619
623
  |---|---|---|---|---|---|
620
624
  | `<action>` positional | string | required | — | both | Full action name or unambiguous short name (fuzzy resolved from adapter vocabulary) |
621
- | `--arg <k=v>` | string (repeatable) | — | — | both | Action field value; repeatable: `--arg symbol=BTC --arg side=long` |
625
+ | `--arg <k=v>` | string (repeatable) | — | — | both | Action field value; parses JSON scalars/arrays/objects. Wrap a numeric-looking string as a JSON string, e.g. `--arg 'test_id="12345"'`. |
622
626
  | `--adapter <p>` | mobile\|extension\|core | auto-detect | `RECIPE_HARNESS_PLATFORM` | both | Target adapter |
623
627
  | `--json` | bool | false | — | **agent PRIMARY** | `{ action, resolvedAction, args, trace, evidence, recovered, mutations, exitCode, schemaVersion }` — same shape as `run --json` (one-node subset) |
624
628
 
@@ -58,7 +58,8 @@ good defaults for easy typing. Enforced rules, visible in every table below:
58
58
  auto-install it inline with a one-line notice on first run on that checkout.
59
59
  `install` is the explicit form for CI, agents, and deterministic setup
60
60
  (`--heal=off` on daily verbs disables the inline check and all healing).
61
- - **Your slot's settings live in `agentic-runtime.json` — `doctor` explains them;
61
+ - **Your checkout's settings live in `agentic-runtime.json` — `doctor --fix` creates
62
+ local identity/resources when absent and `doctor` explains them;
62
63
  you never hunt for the file.** `doctor` prints every field (slotId, extensionId,
63
64
  cdpPort, runtimeStart.*), its current value, its source (file / env var /
64
65
  default), and exactly how to change it. Arthur's rule made concrete: no
@@ -157,15 +158,15 @@ Auto-ensures runtime overlay before launching (installs inline with a one-line n
157
158
  | `mm-recipe debug` | `debug` | ABSORB-LATER |
158
159
  | `mm-recipe dev-menu` · `devmenu` | `debug --dev-menu` (flag) | ABSORB-LATER · [DEFAULT-GAP] separate verb for one console concept |
159
160
 
160
- #### `fixtures` — sync fixture files + set up the wallet · REAL (wave 3)
161
+ #### `fixtures` — initialize/sync fixture files + set up the wallet · REAL (wave 3)
161
162
 
162
- One canonical wallet fixture per checkout (SSOT). Fixture status is reported by `doctor` automatically (grounded: `fixtureSummary()` in `runner/src/doctor.ts`). `fixtures` group = `sync` and `set` only no bare status verb.
163
+ One canonical wallet fixture per checkout (SSOT). Fixture status is reported by `doctor` automatically. Initialize from an existing secure team/test fixture or explicitly choose a disposable public dev wallet; secrets are never typed into chat or command arguments.
163
164
 
164
- Subcommands: `fixtures sync`, `fixtures set`.
165
+ Subcommands: `fixtures init`, `fixtures sync`, `fixtures set`, plus Extension generate/finalize helpers.
165
166
 
166
167
  | | |
167
168
  |---|---|
168
- | **Human form** | `mm-harness fixtures sync` · `mm-harness fixtures set` (applies THE canonical fixture — password read FROM the fixture, never typed; always prints "Want different accounts? Edit: <path>/wallet-fixture.json") |
169
+ | **Human form** | `mm-harness fixtures init --from <path>` or explicit `--dev` · `fixtures sync` · `fixtures set` (password read FROM the fixture, never typed) |
169
170
  | **Agent / advanced** | `mm-harness fixtures set --fixture <json> --platform mobile` (`--fixture` is agent-only; humans edit the canonical file directly) |
170
171
 
171
172
  | Today (real) | → mm-harness | Status |
@@ -292,4 +293,3 @@ The surface below is settled — the design passed a three-round external review
292
293
  ## How farmslot calls mm-harness (preflight & the fleet binding)
293
294
 
294
295
  "Preflight" survives as a capability, not an exposed concept: it is what `launch` does before returning (readiness: Metro/bundle → app → health/bridge poll), with `launch --verify` as the prove-it tier and `doctor` as the read-only view. The fleet is just another agent-grade caller — farmslot prepare profiles keep their git / fixtures / deps phases, and the preflight/health hooks collapse to one call: `mm-harness launch <target> --verify --heal=auto --json`. Same-capability by construction: the machine contract (`--json` schemas, `--heal`, exit-code taxonomy, `recovered[]` / `mutations[]` / `recoverable:false`) is the one agents already use. Three bindings, one verb: the engineer types `mm-harness launch ios`; the slot's prepare hook calls the same verb with slot context; dispatch scales it across the fleet. (Full contract: CLI-SPEC.md Part 6.)
295
-
@@ -38,11 +38,15 @@ function selectedItems(input, items) {
38
38
  });
39
39
  }
40
40
 
41
+ export function positionSelectionState(input, positions) {
42
+ return { hasMatching: selectedItems(input, positions).length > 0 };
43
+ }
44
+
41
45
  async function navigatePerps(input) {
42
46
  const selected = String(input.node?.target ?? input.node?.destination ?? (input.node?.market || input.node?.symbol ? 'market' : 'home')).toLowerCase();
43
47
  return withExtensionPage(input, async (page) => {
44
48
  if (selected === 'home' || selected === 'perps' || selected === 'perps_home') {
45
- const navigation = await page.navigateHash('#/?tab=perps');
49
+ const navigation = await page.navigateHash('#/perps-home');
46
50
  await page.waitForExpression('document.body && document.body.innerText.includes("Perps")', { timeoutMs: 15000 });
47
51
  return { action: input.action, target: selected, navigation, proofPath: 'ui-navigation' };
48
52
  }
@@ -186,6 +190,16 @@ async function requestBackground(page, method, args, timeoutMs = 20000) {
186
190
  return result;
187
191
  }
188
192
 
193
+ async function triggerBackground(page, method, args) {
194
+ return page.evaluate(`(() => {
195
+ const request = globalThis.stateHooks?.submitRequestToBackground;
196
+ if (typeof request !== 'function') throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot call ${method}.');
197
+ const pending = request(${JSON.stringify(method)}, ${JSON.stringify(args)});
198
+ Promise.resolve(pending).catch(() => {});
199
+ return { requested: true };
200
+ })()`);
201
+ }
202
+
189
203
  function isTransientClosePriceError(result) {
190
204
  const message = String(result?.error ?? result?.message ?? '');
191
205
  // Retry local pricing/slippage races only. Provider-level oracle rejections
@@ -422,9 +436,9 @@ export async function ensurePositions(input) {
422
436
  const current = await withExtensionPage(input, async (page) => {
423
437
  const stateResult = await page.readPositions();
424
438
  if (!stateResult.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot ensure live Perps positions.');
425
- return stateResult.positions;
439
+ return positionSelectionState(input, stateResult.positions);
426
440
  });
427
- if (selectedItems(input, current).length === 0) await placeOrder(input);
441
+ if (!current.hasMatching) await placeOrder(input);
428
442
  return assertPositions(input, true);
429
443
  }
430
444
  throw new Error(`metamask.perps.ensure_positions received unsupported state: ${state}`);
@@ -573,14 +587,16 @@ async function readPerpsRuntimeState(page) {
573
587
  const hooks = globalThis.stateHooks || {};
574
588
  const request = hooks.submitRequestToBackground;
575
589
  const reduxState = hooks.store?.getState?.() || {};
576
- const cleanState = hooks.getCleanAppState?.() || hooks.getState?.() || {};
590
+ const cleanState = (await hooks.getCleanAppState?.()) || hooks.getState?.() || {};
591
+ const persisted = await globalThis.chrome?.storage?.local?.get?.('data');
577
592
  // Extension flattens PerpsController state into state.metamask via ComposableObservableStore.getFlatState().
578
593
  const metamask = reduxState.metamask || cleanState.metamask || {};
579
- const nestedPerps = metamask.PerpsController || cleanState.PerpsController || cleanState.perps || {};
594
+ const nestedPerps = persisted?.data?.PerpsController || metamask.PerpsController || cleanState.PerpsController || cleanState.perps || {};
580
595
  return {
581
596
  available: typeof request === 'function',
582
597
  activeProvider: metamask.activeProvider || nestedPerps.activeProvider || 'hyperliquid',
583
- isTestnet: Boolean(metamask.isTestnet ?? nestedPerps.isTestnet),
598
+ networkKnown: typeof nestedPerps.isTestnet === 'boolean',
599
+ isTestnet: Boolean(nestedPerps.isTestnet),
584
600
  initializationState: metamask.initializationState || nestedPerps.initializationState || null,
585
601
  isEligible: Boolean(metamask.isEligible ?? nestedPerps.isEligible),
586
602
  };
@@ -607,8 +623,11 @@ async function ensureNetwork(input, config) {
607
623
  return withExtensionPage(input, async (page) => {
608
624
  const before = await readPerpsRuntimeState(page);
609
625
  if (!before.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot enforce Perps network.');
626
+ if (!before.networkKnown) throw new Error('Unable to read the current Perps network from persisted controller state.');
610
627
  if (Boolean(before.isTestnet) === expectedTestnet) return { requested: config.network, isTestnet: before.isTestnet, changed: false };
611
- const result = await requestBackground(page, 'perpsToggleTestnet', []);
628
+ // The toggle request can remain pending after the controller state changes.
629
+ // Convergence is proved by the bounded state poll below, not request completion.
630
+ const result = await triggerBackground(page, 'perpsToggleTestnet', []);
612
631
  const after = await waitForNetworkState(page, expectedTestnet, Number(input.node?.timeout_ms ?? 30000));
613
632
  if (Boolean(after.isTestnet) !== expectedTestnet) {
614
633
  throw new Error(`Expected Perps network ${config.network}, got isTestnet=${after.isTestnet}.`);
@@ -660,13 +679,15 @@ async function assertBalance(input, config) {
660
679
  export async function startState(input) {
661
680
  const params = paramsForState(input);
662
681
  const config = mergeStateConfig(profileDefaults(params.profile), params);
682
+ // Enter the Perps surface first so its stream bridge/controller API exists
683
+ // before provider, network, order, or position convergence begins.
684
+ const navigation = await applyStateNavigation(input, config);
663
685
  const provider = await ensureProvider(input, config);
664
686
  const network = await ensureNetwork(input, config);
665
687
  const readyToTrade = await assertReadyToTrade(input, config);
666
688
  const balance = await assertBalance(input, config);
667
689
  const orders = await applyOrdersState(input, config.orders);
668
690
  const positions = await applyPositionsState(input, config.positions);
669
- const navigation = await applyStateNavigation(input, config);
670
691
  return {
671
692
  action: input.action,
672
693
  profile: config.profile ?? params.profile ?? 'clean_market_testnet',
@@ -120,18 +120,23 @@ async function captureHelperBrowserPid(context, port) {
120
120
  );
121
121
  }
122
122
 
123
- async function captureHelperSnapshot(page, context, relPath, metadata) {
124
- if (process.platform !== 'darwin') {
125
- throw new Error('Extension ui.screenshot uses capture-helper snapshot and is currently supported only on macOS.');
126
- }
123
+ async function captureCdpViewportSnapshot(page, context, relPath, metadata, captureHelperError = null) {
127
124
  const { relative, absolute } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
128
125
  await mkdir(path.dirname(absolute), { recursive: true });
129
-
130
- await page.session.call('Page.bringToFront');
131
- const pid = await captureHelperBrowserPid(context, page.port);
132
- const timeoutMs = Number(metadata?.timeoutMs ?? 30000);
133
- const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(pid, absolute, timeoutMs);
134
- if (sessionSnapshot) {
126
+ try {
127
+ const timeoutMs = Number(metadata?.cdpTimeoutMs ?? 5000);
128
+ const result = await Promise.race([
129
+ page.session.call('Page.captureScreenshot', {
130
+ format: 'png',
131
+ fromSurface: true,
132
+ captureBeyondViewport: false,
133
+ }),
134
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Chrome Page.captureScreenshot timed out after ${timeoutMs}ms.`)), timeoutMs)),
135
+ ]);
136
+ if (typeof result?.data !== 'string' || result.data.length === 0) {
137
+ throw new Error('Chrome Page.captureScreenshot returned no image data.');
138
+ }
139
+ await writeFile(absolute, Buffer.from(result.data, 'base64'));
135
140
  return {
136
141
  path: relative,
137
142
  type: 'screenshot',
@@ -140,25 +145,62 @@ async function captureHelperSnapshot(page, context, relPath, metadata) {
140
145
  category: metadata?.category ?? 'evidence',
141
146
  mimeType: 'image/png',
142
147
  metadata: {
143
- provider: 'capture-helper',
144
- mode: 'record_session_snapshot',
145
- pid,
146
- captureHelper: sessionSnapshot,
148
+ provider: 'cdp',
149
+ mode: 'Page.captureScreenshot',
150
+ ...(captureHelperError ? { fallbackFrom: 'capture-helper', captureHelperError } : {}),
147
151
  },
148
152
  };
153
+ } catch (error) {
154
+ const cdpError = error instanceof Error ? error.message : String(error);
155
+ return captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError);
149
156
  }
157
+ }
150
158
 
151
- const result = await runProcess(captureHelperPath(), ['snapshot', '--pid', String(pid), '--output', absolute], {
152
- cwd: context.projectRoot,
153
- env: process.env,
154
- timeoutMs,
155
- });
156
- if (result.exitCode !== 0) {
157
- throw new Error(
158
- `capture-helper snapshot failed for Extension ui.screenshot (pid ${pid}): ${result.stderr || result.stdout}`,
159
- );
159
+ async function captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError) {
160
+ const { relative, absolute } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
161
+ const dataUrl = await page.evaluate(`(async () => {
162
+ const width = Math.max(1, window.innerWidth);
163
+ const height = Math.max(1, window.innerHeight);
164
+ const source = document.documentElement;
165
+ const clone = source.cloneNode(true);
166
+ const sourceNodes = [source, ...source.querySelectorAll('*')];
167
+ const cloneNodes = [clone, ...clone.querySelectorAll('*')];
168
+ for (let index = 0; index < sourceNodes.length; index += 1) {
169
+ const sourceNode = sourceNodes[index];
170
+ const cloneNode = cloneNodes[index];
171
+ if (!(sourceNode instanceof Element) || !(cloneNode instanceof Element)) continue;
172
+ const computed = getComputedStyle(sourceNode);
173
+ cloneNode.setAttribute('style', Array.from(computed).map((name) => name + ':' + computed.getPropertyValue(name) + ';').join(''));
174
+ if ('value' in sourceNode && typeof sourceNode.value === 'string') cloneNode.setAttribute('value', sourceNode.value);
175
+ if (sourceNode instanceof HTMLCanvasElement) {
176
+ const image = document.createElement('img');
177
+ try { image.src = sourceNode.toDataURL('image/png'); } catch { image.alt = 'canvas'; }
178
+ image.setAttribute('style', cloneNode.getAttribute('style') || '');
179
+ cloneNode.replaceWith(image);
180
+ }
181
+ }
182
+ clone.querySelectorAll('script').forEach((node) => node.remove());
183
+ clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
184
+ clone.style.width = width + 'px';
185
+ clone.style.height = height + 'px';
186
+ clone.style.overflow = 'hidden';
187
+ const serialized = new XMLSerializer().serializeToString(clone);
188
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '"><foreignObject width="100%" height="100%">' + serialized + '</foreignObject></svg>';
189
+ const image = new Image();
190
+ const loaded = new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error('DOM raster image load failed.')); });
191
+ image.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
192
+ await loaded;
193
+ const canvas = document.createElement('canvas');
194
+ canvas.width = width;
195
+ canvas.height = height;
196
+ const context = canvas.getContext('2d');
197
+ context.drawImage(image, 0, 0, width, height);
198
+ return canvas.toDataURL('image/png');
199
+ })()`);
200
+ if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
201
+ throw new Error(`Extension screenshot fallbacks failed: capture-helper=${captureHelperError ?? 'not attempted'}; cdp=${cdpError}; DOM raster returned no PNG.`);
160
202
  }
161
- const details = parseJsonObject(result.stdout);
203
+ await writeFile(absolute, Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64'));
162
204
  return {
163
205
  path: relative,
164
206
  type: 'screenshot',
@@ -167,14 +209,72 @@ async function captureHelperSnapshot(page, context, relPath, metadata) {
167
209
  category: metadata?.category ?? 'evidence',
168
210
  mimeType: 'image/png',
169
211
  metadata: {
170
- provider: 'capture-helper',
171
- mode: 'snapshot',
172
- pid,
173
- ...(details ? { captureHelper: details } : {}),
212
+ provider: 'dom-raster',
213
+ mode: 'computed-style-viewport',
214
+ fallbackFrom: captureHelperError ? 'capture-helper+cdp' : 'cdp',
215
+ ...(captureHelperError ? { captureHelperError } : {}),
216
+ cdpError,
174
217
  },
175
218
  };
176
219
  }
177
220
 
221
+ export async function captureHelperSnapshot(page, context, relPath, metadata) {
222
+ if (process.platform !== 'darwin') {
223
+ return captureCdpViewportSnapshot(page, context, relPath, metadata);
224
+ }
225
+ const { relative, absolute } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
226
+ await mkdir(path.dirname(absolute), { recursive: true });
227
+
228
+ try {
229
+ const pid = await captureHelperBrowserPid(context, page.port);
230
+ const timeoutMs = Number(metadata?.timeoutMs ?? 30000);
231
+ const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(pid, absolute, timeoutMs);
232
+ if (sessionSnapshot) {
233
+ return {
234
+ path: relative,
235
+ type: 'screenshot',
236
+ nodeId: context.nodeId,
237
+ label: metadata?.label ?? `${context.nodeId} screenshot`,
238
+ category: metadata?.category ?? 'evidence',
239
+ mimeType: 'image/png',
240
+ metadata: {
241
+ provider: 'capture-helper',
242
+ mode: 'record_session_snapshot',
243
+ pid,
244
+ captureHelper: sessionSnapshot,
245
+ },
246
+ };
247
+ }
248
+
249
+ const result = await runProcess(captureHelperPath(), ['snapshot', '--pid', String(pid), '--output', absolute], {
250
+ cwd: context.projectRoot,
251
+ env: process.env,
252
+ timeoutMs,
253
+ });
254
+ if (result.exitCode !== 0) {
255
+ throw new Error(`capture-helper snapshot failed for pid ${pid}: ${result.stderr || result.stdout}`);
256
+ }
257
+ const details = parseJsonObject(result.stdout);
258
+ return {
259
+ path: relative,
260
+ type: 'screenshot',
261
+ nodeId: context.nodeId,
262
+ label: metadata?.label ?? `${context.nodeId} screenshot`,
263
+ category: metadata?.category ?? 'evidence',
264
+ mimeType: 'image/png',
265
+ metadata: {
266
+ provider: 'capture-helper',
267
+ mode: 'snapshot',
268
+ pid,
269
+ ...(details ? { captureHelper: details } : {}),
270
+ },
271
+ };
272
+ } catch (error) {
273
+ const message = error instanceof Error ? error.message : String(error);
274
+ return captureCdpViewportSnapshot(page, context, relPath, metadata, message);
275
+ }
276
+ }
277
+
178
278
  function autolaunchEnabled(input) {
179
279
  return input.node?.launch_existing_dist === true ||
180
280
  input.node?.autolaunch === true ||
@@ -2,7 +2,7 @@ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
2
 
3
3
  const PAGE_HASHES = {
4
4
  home: '#/',
5
- perps: '#/?tab=perps',
5
+ perps: '#/perps-home',
6
6
  };
7
7
 
8
8
  function text(value) {