@deeeed/metamask-harness 0.2.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 (158) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +140 -0
  3. package/bin/mm-harness +99 -0
  4. package/docs/CHEATSHEET.md +61 -0
  5. package/docs/CLI-SPEC.md +915 -0
  6. package/docs/MENTAL-MODEL.md +295 -0
  7. package/docs/architecture.md +367 -0
  8. package/docs/extension-runtime-commands.md +60 -0
  9. package/docs/harness-cli.md +43 -0
  10. package/docs/live-adapter-contract.md +188 -0
  11. package/docs/package-boundaries.md +47 -0
  12. package/docs/perps-flow-catalog.md +235 -0
  13. package/docs/recipe-libraries.md +95 -0
  14. package/docs/runtime-file-conventions.md +36 -0
  15. package/library/actions/core/perps/_controller.mjs +727 -0
  16. package/library/actions/core/perps/assert_orders.mjs +53 -0
  17. package/library/actions/core/perps/assert_positions.mjs +52 -0
  18. package/library/actions/core/perps/close_orders.mjs +97 -0
  19. package/library/actions/core/perps/close_positions.mjs +118 -0
  20. package/library/actions/core/perps/ensure_orders.mjs +40 -0
  21. package/library/actions/core/perps/ensure_positions.mjs +37 -0
  22. package/library/actions/core/perps/place_order.mjs +201 -0
  23. package/library/actions/core/perps/read_account.mjs +30 -0
  24. package/library/actions/core/perps/read_orders.mjs +27 -0
  25. package/library/actions/core/perps/read_positions.mjs +27 -0
  26. package/library/actions/core/perps/start_state.mjs +92 -0
  27. package/library/actions/core/perps/teardown_state.mjs +86 -0
  28. package/library/actions/extension/perps/assert_orders.mjs +11 -0
  29. package/library/actions/extension/perps/assert_positions.mjs +11 -0
  30. package/library/actions/extension/perps/close_orders.mjs +8 -0
  31. package/library/actions/extension/perps/close_positions.mjs +8 -0
  32. package/library/actions/extension/perps/ensure_orders.mjs +4 -0
  33. package/library/actions/extension/perps/ensure_positions.mjs +4 -0
  34. package/library/actions/extension/perps/perps.mjs +730 -0
  35. package/library/actions/extension/perps/place_order.mjs +7 -0
  36. package/library/actions/extension/perps/read_orders.mjs +4 -0
  37. package/library/actions/extension/perps/read_positions.mjs +3 -0
  38. package/library/actions/extension/platform/cdp.mjs +541 -0
  39. package/library/actions/extension/ui/navigate.mjs +44 -0
  40. package/library/actions/extension/wallet/ensure_unlocked.mjs +36 -0
  41. package/library/actions/extension/wallet/read_state.mjs +27 -0
  42. package/library/actions/extension/wallet/select_account.mjs +48 -0
  43. package/library/actions/extension/wallet/setup.mjs +35 -0
  44. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +185 -0
  45. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +1662 -0
  46. package/library/actions/mobile/bridge-runtime/cdp-bridge.cjs +686 -0
  47. package/library/actions/mobile/bridge-runtime/lib/cdp-eval.cjs +110 -0
  48. package/library/actions/mobile/bridge-runtime/lib/config.cjs +39 -0
  49. package/library/actions/mobile/bridge-runtime/lib/issue-capture.cjs +446 -0
  50. package/library/actions/mobile/bridge-runtime/lib/target-discovery.cjs +204 -0
  51. package/library/actions/mobile/bridge-runtime/lib/ws-client.cjs +108 -0
  52. package/library/actions/mobile/bridge-runtime/setup-wallet.sh +442 -0
  53. package/library/actions/mobile/perps/assert_orders.mjs +11 -0
  54. package/library/actions/mobile/perps/assert_positions.mjs +11 -0
  55. package/library/actions/mobile/perps/close_orders.mjs +8 -0
  56. package/library/actions/mobile/perps/close_positions.mjs +8 -0
  57. package/library/actions/mobile/perps/ensure_orders.mjs +4 -0
  58. package/library/actions/mobile/perps/ensure_positions.mjs +4 -0
  59. package/library/actions/mobile/perps/perps.mjs +709 -0
  60. package/library/actions/mobile/perps/place_order.mjs +7 -0
  61. package/library/actions/mobile/perps/read_orders.mjs +4 -0
  62. package/library/actions/mobile/perps/read_positions.mjs +3 -0
  63. package/library/actions/mobile/platform/bridge.mjs +283 -0
  64. package/library/actions/mobile/ui/navigate.mjs +38 -0
  65. package/library/actions/mobile/wallet/ensure_unlocked.mjs +107 -0
  66. package/library/actions/mobile/wallet/home.mjs +35 -0
  67. package/library/actions/mobile/wallet/read_state.mjs +40 -0
  68. package/library/actions/mobile/wallet/select_account.mjs +48 -0
  69. package/library/actions/mobile/wallet/setup.mjs +220 -0
  70. package/library/flows/perps.flows.json +64 -0
  71. package/library/library.json +7 -0
  72. package/library/manifests/core.action-manifest.json +1282 -0
  73. package/library/manifests/extension.action-manifest.json +1749 -0
  74. package/library/manifests/mobile.action-manifest.json +1753 -0
  75. package/library/recipes/action-validation.extension.recipe.json +417 -0
  76. package/library/recipes/action-validation.mobile.recipe.json +422 -0
  77. package/library/recipes/order-lifecycle.core.recipe.json +78 -0
  78. package/library/recipes/perps-lifecycle.recipe.json +194 -0
  79. package/library/recipes/read-markets.core.recipe.json +38 -0
  80. package/library/recipes/smoke.extension.recipe.json +31 -0
  81. package/library/recipes/smoke.mobile.recipe.json +31 -0
  82. package/library/recipes/trading-lifecycle.core.recipe.json +76 -0
  83. package/orchestration/compat-overlays/README.md +19 -0
  84. package/orchestration/compat-overlays/mobile/README.md +13 -0
  85. package/orchestration/compat-overlays/mobile/rn81-message-event-source.patch +42 -0
  86. package/orchestration/core/cleanup.sh +37 -0
  87. package/orchestration/core/inject.sh +154 -0
  88. package/orchestration/doctor.mjs +72 -0
  89. package/orchestration/extension/cleanup.mjs +60 -0
  90. package/orchestration/extension/console-tail.mjs +228 -0
  91. package/orchestration/extension/ensure-browser.sh +416 -0
  92. package/orchestration/extension/ensure-ready.ts +185 -0
  93. package/orchestration/extension/extension-id.ts +107 -0
  94. package/orchestration/extension/inject.mjs +266 -0
  95. package/orchestration/extension/launch-browser.cjs +216 -0
  96. package/orchestration/extension/launch.sh +175 -0
  97. package/orchestration/extension/live.sh +320 -0
  98. package/orchestration/extension/pin-remote-flags.cjs +45 -0
  99. package/orchestration/extension/readiness.mjs +414 -0
  100. package/orchestration/extension/refresh-build.sh +190 -0
  101. package/orchestration/extension/runtime-decision.ts +445 -0
  102. package/orchestration/extension/runtime.ts +407 -0
  103. package/orchestration/extension/seed-fixture.sh +177 -0
  104. package/orchestration/extension/sidepanel-toggle.sh +291 -0
  105. package/orchestration/extension/snapshot-dist.sh +84 -0
  106. package/orchestration/extension/start-watch.sh +339 -0
  107. package/orchestration/extension/wallet-fixture-state.cjs +1086 -0
  108. package/orchestration/lib/activate-repo-node.sh +144 -0
  109. package/orchestration/lib/cli-color.mjs +84 -0
  110. package/orchestration/lib/cli-commands.mjs +243 -0
  111. package/orchestration/lib/cli-home.mjs +354 -0
  112. package/orchestration/lib/cli-ux.sh +252 -0
  113. package/orchestration/lib/cli-version.mjs +123 -0
  114. package/orchestration/lib/ensure-runner-deps.sh +56 -0
  115. package/orchestration/lib/harness-path.sh +55 -0
  116. package/orchestration/lib/hash-helpers.sh +44 -0
  117. package/orchestration/lib/json-field.sh +23 -0
  118. package/orchestration/lib/log-tui.mjs +304 -0
  119. package/orchestration/lib/open-debug.mjs +317 -0
  120. package/orchestration/lib/path-defaults.json +4 -0
  121. package/orchestration/lib/progress.mjs +107 -0
  122. package/orchestration/lib/recipe-paths.mjs +26 -0
  123. package/orchestration/lib/resolve-farmslot-ports.sh +144 -0
  124. package/orchestration/manifest.json +358 -0
  125. package/orchestration/mobile/cleanup.sh +192 -0
  126. package/orchestration/mobile/deps-markers.ts +21 -0
  127. package/orchestration/mobile/inject.sh +681 -0
  128. package/orchestration/mobile/launch.sh +137 -0
  129. package/orchestration/mobile/live.sh +125 -0
  130. package/orchestration/mobile/runtime-decision.ts +292 -0
  131. package/orchestration/porcelain/metamask-recipe +99 -0
  132. package/orchestration/porcelain/mm-recipe +1591 -0
  133. package/orchestration/porcelain/mme-recipe +1181 -0
  134. package/package.json +59 -0
  135. package/runner/extension/verify.sh +511 -0
  136. package/runner/mobile/verify.sh +501 -0
  137. package/runner/src/adapters.ts +601 -0
  138. package/runner/src/cli.ts +1820 -0
  139. package/runner/src/commands/debug.ts +44 -0
  140. package/runner/src/commands/fixtures.ts +99 -0
  141. package/runner/src/commands/launch.ts +397 -0
  142. package/runner/src/commands/logs.ts +60 -0
  143. package/runner/src/commands/shared.ts +138 -0
  144. package/runner/src/completions-cache.ts +86 -0
  145. package/runner/src/doctor.ts +203 -0
  146. package/runner/src/harness.ts +516 -0
  147. package/runner/src/heal-bounds.ts +179 -0
  148. package/runner/src/index.ts +6 -0
  149. package/runner/src/live-adapter-contract.ts +274 -0
  150. package/runner/src/manifest.ts +47 -0
  151. package/runner/src/mm-harness-cli.ts +488 -0
  152. package/runner/src/paths.ts +198 -0
  153. package/runner/src/recording-target.ts +147 -0
  154. package/runner/src/run-recording.ts +329 -0
  155. package/runner/src/runner.ts +108 -0
  156. package/runner/src/types.ts +57 -0
  157. package/scripts/completions.sh +125 -0
  158. package/scripts/install-completions.sh +62 -0
@@ -0,0 +1,138 @@
1
+ // Shared helpers for the mm-harness command modules: flag parsing, adapter
2
+ // resolution, porcelain composition, and the teaching-error emitter. Each command
3
+ // module (launch/logs/debug/fixtures) composes mobile/extension porcelain scripts
4
+ // that live under orchestration/porcelain/; these helpers are the common seam.
5
+ //
6
+ // Composition seams (overridable for CI/agents and contract tests):
7
+ // MM_HARNESS_MM_RECIPE_BIN — mobile porcelain (default: <runner>/orchestration/porcelain/mm-recipe)
8
+ // MM_HARNESS_MME_RECIPE_BIN — extension porcelain (default: <runner>/orchestration/porcelain/mme-recipe)
9
+ // A stub that records argv against one of these paths is the contract-test lever.
10
+
11
+ import { spawnSync } from 'node:child_process';
12
+ import path from 'node:path';
13
+
14
+ import { detectAdapter } from '../harness.ts';
15
+ import { runnerDir } from '../paths.ts';
16
+ import type { MetaMaskRecipeAdapter } from '../types.ts';
17
+
18
+ // Exit-code taxonomy (docs/CLI-SPEC.md §5.6).
19
+ export const EXIT = { ok: 0, runtime: 1, usage: 2, infra: 3, bounded: 4, validation: 5 } as const;
20
+
21
+ // Adapters that launch a live app surface (core is headless).
22
+ export type DeviceAdapter = 'mobile' | 'extension';
23
+
24
+ export interface ParsedFlags {
25
+ positional: string[];
26
+ options: Record<string, string | boolean>;
27
+ }
28
+
29
+ // Positionals + boolean/valued flags; camelCases --foo-bar to fooBar.
30
+ export function parseFlags(argv: string[], booleans: Set<string>): ParsedFlags {
31
+ const positional: string[] = [];
32
+ const options: Record<string, string | boolean> = {};
33
+ for (let i = 0; i < argv.length; i += 1) {
34
+ const arg = argv[i];
35
+ if (!arg.startsWith('--')) {
36
+ positional.push(arg);
37
+ continue;
38
+ }
39
+ const body = arg.slice(2);
40
+ const eq = body.indexOf('=');
41
+ const rawKey = eq === -1 ? body : body.slice(0, eq);
42
+ const inline = eq === -1 ? undefined : body.slice(eq + 1);
43
+ const key = rawKey.replace(/-([a-z])/gu, (_, c: string) => c.toUpperCase());
44
+ if (booleans.has(key)) {
45
+ options[key] = inline === undefined ? true : inline !== 'false';
46
+ continue;
47
+ }
48
+ if (inline !== undefined) {
49
+ options[key] = inline;
50
+ continue;
51
+ }
52
+ // Valued flag with a following token, unless the next token is itself a flag.
53
+ const next = argv[i + 1];
54
+ if (next === undefined || next.startsWith('--')) {
55
+ options[key] = true;
56
+ continue;
57
+ }
58
+ options[key] = next;
59
+ i += 1;
60
+ }
61
+ return { positional, options };
62
+ }
63
+
64
+ export function str(options: Record<string, string | boolean>, key: string): string | undefined {
65
+ const value = options[key];
66
+ return typeof value === 'string' ? value : undefined;
67
+ }
68
+
69
+ export function flag(options: Record<string, string | boolean>, key: string): boolean {
70
+ return options[key] === true;
71
+ }
72
+
73
+ export function targetOf(options: Record<string, string | boolean>): string {
74
+ return path.resolve(str(options, 'target') ?? str(options, 'projectRoot') ?? process.cwd());
75
+ }
76
+
77
+ const ADAPTER_TOKENS: readonly string[] = ['mobile', 'extension', 'core'];
78
+
79
+ // Explicit --adapter/--platform, else an optional hint, else auto-detect from the
80
+ // target. Reimplemented here (rather than shared with cli.ts) to avoid a cycle.
81
+ export function resolveAdapter(
82
+ options: Record<string, string | boolean>,
83
+ target: string,
84
+ hint?: MetaMaskRecipeAdapter,
85
+ ): MetaMaskRecipeAdapter | undefined {
86
+ const explicit = str(options, 'adapter') ?? str(options, 'platform');
87
+ if (explicit && ADAPTER_TOKENS.includes(explicit)) return explicit as MetaMaskRecipeAdapter;
88
+ if (hint) return hint;
89
+ return detectAdapter(target);
90
+ }
91
+
92
+ function porcelainBin(adapter: DeviceAdapter): string {
93
+ if (adapter === 'mobile') {
94
+ return process.env.MM_HARNESS_MM_RECIPE_BIN ?? path.join(runnerDir, 'orchestration', 'porcelain', 'mm-recipe');
95
+ }
96
+ return process.env.MM_HARNESS_MME_RECIPE_BIN ?? path.join(runnerDir, 'orchestration', 'porcelain', 'mme-recipe');
97
+ }
98
+
99
+ export interface PorcelainResult {
100
+ status: number;
101
+ output: string;
102
+ }
103
+
104
+ // Compose the porcelain. Output is always captured (needed for heal
105
+ // classification) and, in human mode, forwarded to our stderr so the human still
106
+ // sees it. --json keeps stdout clean for the machine summary.
107
+ export function spawnPorcelain(
108
+ adapter: DeviceAdapter,
109
+ args: string[],
110
+ target: string,
111
+ json: boolean,
112
+ ): PorcelainResult {
113
+ const bin = porcelainBin(adapter);
114
+ const result = spawnSync(bin, args, {
115
+ cwd: target,
116
+ encoding: 'utf8',
117
+ env: process.env,
118
+ maxBuffer: 64 * 1024 * 1024,
119
+ });
120
+ if (result.error) {
121
+ return { status: 1, output: `porcelain could not start: ${result.error.message}` };
122
+ }
123
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
124
+ if (!json && output) process.stderr.write(output);
125
+ return { status: result.status ?? 1, output };
126
+ }
127
+
128
+ // Teaching-error emitter (exit 2, machine-readable in --json).
129
+ export function usageOut(json: boolean, command: string, message: string): number {
130
+ if (json) {
131
+ console.log(
132
+ JSON.stringify({ schemaVersion: 1, command, status: 'fail', exitCode: EXIT.usage, error: { code: 'USAGE', message } }, null, 2),
133
+ );
134
+ } else {
135
+ console.error(`✗ mm-harness ${command}: ${message}`);
136
+ }
137
+ return EXIT.usage;
138
+ }
@@ -0,0 +1,86 @@
1
+ // Dynamic completions cache (module + file format).
2
+ //
3
+ // The DISCOVER layer (actions/flows) is the single source that feeds the agent
4
+ // (compose), the shell (complete), and docs (cheatsheet). Shell completion
5
+ // scripts call the hidden `mm-harness completion-candidates <actions|flows>`
6
+ // command; that command reads/writes this per-checkout cache so tab-completion
7
+ // never blocks the shell on a manifest/library read.
8
+ //
9
+ // Format (per-checkout, git-ignored — lives under the runtime dir):
10
+ // { version, updatedAt (epoch ms), candidates: { actions?: string[], flows?: string[] } }
11
+ // TTL: short; a stale/missing cache falls back to a live read (never an error).
12
+ // Invalidated on overlay install (`mm-harness install`) — the overlay is what
13
+ // changes the available action/flow vocabulary.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ import { recipeRuntimePath } from './paths.ts';
19
+
20
+ export const COMPLETION_CACHE_VERSION = 1;
21
+ // Short TTL: completions must reflect a freshly-installed overlay quickly; a miss
22
+ // is cheap (one manifest read) and never blocks the shell.
23
+ export const COMPLETION_CACHE_TTL_MS = 60_000;
24
+
25
+ export type CompletionKind = 'actions' | 'flows';
26
+
27
+ export interface CompletionCache {
28
+ version: number;
29
+ updatedAt: number;
30
+ candidates: Partial<Record<CompletionKind, string[]>>;
31
+ }
32
+
33
+ export function completionCachePath(projectRoot: string): string {
34
+ return recipeRuntimePath(projectRoot, '.completion-cache.json');
35
+ }
36
+
37
+ export function readCompletionCache(projectRoot: string): CompletionCache | undefined {
38
+ try {
39
+ const raw = JSON.parse(fs.readFileSync(completionCachePath(projectRoot), 'utf8')) as CompletionCache;
40
+ if (!raw || raw.version !== COMPLETION_CACHE_VERSION || typeof raw.updatedAt !== 'number') return undefined;
41
+ return raw;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ export function isCacheFresh(cache: CompletionCache, now: number = Date.now()): boolean {
48
+ return now - cache.updatedAt < COMPLETION_CACHE_TTL_MS;
49
+ }
50
+
51
+ // Return cached candidates for a kind when the cache is present + fresh; else
52
+ // undefined (caller does a live read and then writeCompletionCandidates).
53
+ export function readFreshCandidates(
54
+ projectRoot: string,
55
+ kind: CompletionKind,
56
+ now: number = Date.now(),
57
+ ): string[] | undefined {
58
+ const cache = readCompletionCache(projectRoot);
59
+ if (!cache || !isCacheFresh(cache, now)) return undefined;
60
+ return cache.candidates[kind];
61
+ }
62
+
63
+ export function writeCompletionCandidates(
64
+ projectRoot: string,
65
+ kind: CompletionKind,
66
+ candidates: string[],
67
+ ): void {
68
+ const existing = readCompletionCache(projectRoot);
69
+ const next: CompletionCache = {
70
+ version: COMPLETION_CACHE_VERSION,
71
+ updatedAt: Date.now(),
72
+ candidates: { ...(existing?.candidates ?? {}), [kind]: candidates },
73
+ };
74
+ const file = completionCachePath(projectRoot);
75
+ fs.mkdirSync(path.dirname(file), { recursive: true });
76
+ fs.writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
77
+ }
78
+
79
+ // Invalidated on overlay install (the overlay defines the action/flow vocabulary).
80
+ export function invalidateCompletionCache(projectRoot: string): void {
81
+ try {
82
+ fs.rmSync(completionCachePath(projectRoot));
83
+ } catch {
84
+ // Absent cache is the desired post-state; nothing to do.
85
+ }
86
+ }
@@ -0,0 +1,203 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { readRuntimeContextField, resolveRuntimeContextPath } from './harness.ts';
5
+ import { manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from './paths.ts';
6
+ import type {
7
+ MetaMaskDoctorReport,
8
+ MetaMaskRecipeAdapter,
9
+ MetaMaskRuntimeContextField,
10
+ MetaMaskRuntimeContextReport,
11
+ } from './types.ts';
12
+
13
+ export function repoShape(target: string): Record<string, unknown> {
14
+ const exists = (rel) => fs.existsSync(path.join(target, rel));
15
+ const packageInfo = readPackageInfo(target);
16
+ const extensionProject =
17
+ packageInfo.name === 'metamask-crx' ||
18
+ (exists('app/manifest') && exists('app/scripts') && exists('ui'));
19
+ const mobileProject =
20
+ packageInfo.name === 'metamask' && exists('app/core') && (exists('ios') || exists('android'));
21
+ return {
22
+ packageName: packageInfo.name,
23
+ packageJsonStatus: packageInfo.status,
24
+ packageJsonError: packageInfo.error,
25
+ extensionProject,
26
+ mobileProject,
27
+ agenticService: exists('app/dev-tools/AgenticService/AgenticService.ts'),
28
+ mobileProductHarness: false,
29
+ mobileBridgeScript: true,
30
+ extensionRuntime: exists(`${recipeHarnessRoot()}/extension`),
31
+ injectedHarness: exists(`${recipeHarnessRoot()}/mobile`) || exists(`${recipeHarnessRoot()}/extension`),
32
+ walletFixture:
33
+ exists(`${recipeRuntimeDir()}/wallet-fixture.json`),
34
+ };
35
+ }
36
+
37
+ export function compatibilityMode(adapter: MetaMaskRecipeAdapter, target: string) {
38
+ const shape = repoShape(target);
39
+ if (adapter === 'core') {
40
+ return fs.existsSync(path.join(target, 'packages/perps-controller/src/index.ts'))
41
+ ? 'headless controller (no bridge)'
42
+ : 'unsupported/no bridge';
43
+ }
44
+ if (adapter === 'mobile') {
45
+ if (shape.injectedHarness && shape.agenticService) return 'runner bridge with injected app bridge';
46
+ if (shape.agenticService) return 'runner bridge with app bridge';
47
+ return 'runner bridge available; app bridge not installed';
48
+ }
49
+ if (!shape.extensionProject) return 'unsupported/no bridge';
50
+ if (shape.extensionRuntime || shape.injectedHarness) return 'bridge present';
51
+ return 'bridge injectable';
52
+ }
53
+
54
+ function readPackageInfo(target: string) {
55
+ const packageJsonPath = path.join(target, 'package.json');
56
+ if (!fs.existsSync(packageJsonPath)) {
57
+ return { status: 'missing', name: null, error: null };
58
+ }
59
+ try {
60
+ const data = readJsonObject(packageJsonPath);
61
+ return {
62
+ status: 'valid',
63
+ name: typeof data.name === 'string' ? data.name : null,
64
+ error: null,
65
+ };
66
+ } catch (error) {
67
+ return {
68
+ status: 'invalid',
69
+ name: null,
70
+ error: error instanceof Error ? error.message : String(error),
71
+ };
72
+ }
73
+ }
74
+
75
+ export function fixtureSummary(target: string): Record<string, unknown> {
76
+ const candidates = [
77
+ `${recipeRuntimeDir()}/wallet-fixture.json`,
78
+ ];
79
+ const rel = candidates.find((candidate) => fs.existsSync(path.join(target, candidate)));
80
+ if (!rel) return { status: 'missing', path: null };
81
+ try {
82
+ const data = readJsonObject(path.join(target, rel));
83
+ return {
84
+ status: Array.isArray(data.accounts) && data.accounts.length > 0 ? 'ready' : 'incomplete',
85
+ path: rel,
86
+ accountCount: Array.isArray(data.accounts) ? data.accounts.length : 0,
87
+ hasPassword: typeof data.password === 'string' && data.password.length > 0,
88
+ };
89
+ } catch (error) {
90
+ return { status: 'invalid', path: rel, error: error instanceof Error ? error.message : String(error) };
91
+ }
92
+ }
93
+
94
+ // The runtime-context fields the harness hydrates from agentic-runtime.json,
95
+ // each paired with the env var(s) that override the file value and the one-line
96
+ // customize hint. `envVars` lists the harness precedence (first set wins); `envVar`
97
+ // is the canonical name reported. Order matches the contract table (CLI-SPEC.md).
98
+ const RUNTIME_CONTEXT_FIELDS: ReadonlyArray<{
99
+ key: string;
100
+ envVars: readonly string[];
101
+ envVar: string | null;
102
+ customize: string;
103
+ }> = [
104
+ { key: 'slotId', envVars: ['RECIPE_SLOT_ID'], envVar: 'RECIPE_SLOT_ID', customize: 'farmslot dispatch writes this' },
105
+ { key: 'extensionId', envVars: ['RECIPE_HARNESS_EXTENSION_ID'], envVar: 'RECIPE_HARNESS_EXTENSION_ID', customize: 'auto-resolved; edit file to pin' },
106
+ { key: 'cdpPort', envVars: ['RECIPE_CDP_PORT', 'CDP_PORT'], envVar: 'CDP_PORT', customize: 'edit file or pass --cdp-port' },
107
+ { key: 'runtimeStart.approved', envVars: ['RECIPE_RUNTIME_START_APPROVED'], envVar: 'RECIPE_RUNTIME_START_APPROVED', customize: 'edit file (true/false)' },
108
+ { key: 'runtimeStart.command', envVars: [], envVar: null, customize: 'edit file' },
109
+ { key: 'runtimeStart.readyUrl', envVars: ['RECIPE_RUNTIME_READY_URL'], envVar: 'RECIPE_RUNTIME_READY_URL', customize: 'edit file' },
110
+ ];
111
+
112
+ // Report every runtime-context field with its current value and where it came from
113
+ // (live env override > file > unset default), reusing the harness's own file reader
114
+ // so doctor and dispatch agree on resolution. Absent file → fileExists:false and
115
+ // every field falls back to env or default; the path shows where it WOULD live.
116
+ export function runtimeContextSummary(target: string): MetaMaskRuntimeContextReport {
117
+ const contextPath = resolveRuntimeContextPath(target);
118
+ const envOverride = process.env.RECIPE_RUNTIME_CONTEXT ?? null;
119
+ const fileExists = fs.existsSync(contextPath);
120
+ const file = envOverride ?? path.relative(target, contextPath);
121
+ const fields: Record<string, MetaMaskRuntimeContextField> = {};
122
+ for (const spec of RUNTIME_CONTEXT_FIELDS) {
123
+ const envValue = spec.envVars
124
+ .map((name) => process.env[name])
125
+ .find((value) => value !== undefined && value !== '');
126
+ if (envValue !== undefined) {
127
+ fields[spec.key] = { value: envValue, source: 'env', envVar: spec.envVar, customize: spec.customize };
128
+ continue;
129
+ }
130
+ const fileValue = fileExists ? readRuntimeContextField(contextPath, spec.key) : undefined;
131
+ fields[spec.key] = fileValue !== undefined
132
+ ? { value: fileValue, source: 'file', envVar: spec.envVar, customize: spec.customize }
133
+ : { value: null, source: 'default', envVar: spec.envVar, customize: spec.customize };
134
+ }
135
+ return { file, fileExists, envOverride, fields };
136
+ }
137
+
138
+ // Human-readable runtime-context section for `doctor` without --json.
139
+ export function renderRuntimeContext(runtimeContext: MetaMaskRuntimeContextReport): string {
140
+ const lines: string[] = [];
141
+ lines.push(
142
+ runtimeContext.fileExists
143
+ ? `runtime-context: ${runtimeContext.file} (present)`
144
+ : `runtime-context: ${runtimeContext.file} (absent — written by farmslot prepare/dispatch)`,
145
+ );
146
+ for (const [key, field] of Object.entries(runtimeContext.fields)) {
147
+ const value = field.value ?? '(unset)';
148
+ const origin = field.source === 'env' && field.envVar ? `env ${field.envVar}` : field.source;
149
+ lines.push(` ${key.padEnd(22)} ${value} [${origin}] — ${field.customize}`);
150
+ }
151
+ return lines.join('\n');
152
+ }
153
+
154
+ export function createDoctorReport(
155
+ adapter: MetaMaskRecipeAdapter,
156
+ target: string,
157
+ manifestValidation: { summary?: { errors?: number } & Record<string, unknown> },
158
+ actionManifestPath = manifestPath(adapter),
159
+ ): MetaMaskDoctorReport {
160
+ const mode = compatibilityMode(adapter, target);
161
+ const manifestErrors = Number(manifestValidation.summary?.errors ?? 0);
162
+ const status = manifestErrors > 0 ? 'fail' : 'pass';
163
+ const checks = [
164
+ {
165
+ id: 'manifest',
166
+ status: manifestErrors === 0 ? 'pass' : 'fail',
167
+ message: manifestErrors === 0 ? 'Action manifest is valid Recipe v1.' : `Action manifest has ${manifestErrors} validation error(s).`,
168
+ },
169
+ {
170
+ id: 'bridge',
171
+ status: mode === 'unsupported/no bridge' ? 'fail' : 'pass',
172
+ message: mode === 'unsupported/no bridge' ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`,
173
+ },
174
+ ] as const;
175
+ return {
176
+ schemaVersion: 1,
177
+ protocolVersion: 'v1',
178
+ runner_protocol_version: 1,
179
+ status,
180
+ checks: [...checks],
181
+ adapter,
182
+ target,
183
+ runner: {
184
+ name: '@metamask/recipe-runner',
185
+ runnerDir,
186
+ actionManifestPath,
187
+ harnessPackage: '@farmslot/recipe-harness',
188
+ },
189
+ compatibilityMode: mode,
190
+ shape: repoShape(target),
191
+ fixture: fixtureSummary(target),
192
+ runtimeContext: runtimeContextSummary(target),
193
+ manifestValidation: manifestValidation.summary,
194
+ };
195
+ }
196
+
197
+ function readJsonObject(file: string): Record<string, unknown> {
198
+ const value = readJson(file);
199
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
200
+ throw new Error(`Expected JSON object in ${file}`);
201
+ }
202
+ return value as Record<string, unknown>;
203
+ }