@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,147 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import type { RecordingTarget, RecordingTargetContext, RecordingTargetProvider } from '@farmslot/recipe-harness';
6
+
7
+ import type { MetaMaskRecipeAdapter } from './types.ts';
8
+
9
+ export function createMetaMaskRecordingTargetProvider(
10
+ adapter: MetaMaskRecipeAdapter,
11
+ ): RecordingTargetProvider {
12
+ return {
13
+ async resolveRecordingTarget(context: RecordingTargetContext): Promise<RecordingTarget> {
14
+ if (adapter === 'extension') {
15
+ const pid = resolveExtensionBrowserPid(
16
+ context.projectRoot,
17
+ context.artifactsDir,
18
+ context.env.CDP_PORT ?? context.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT,
19
+ );
20
+ if (!pid) {
21
+ throw new Error(
22
+ 'Could not resolve the extension browser PID for --record-video. Ensure the slot browser is running and CDP_PORT is set.',
23
+ );
24
+ }
25
+ return { kind: 'pid', pid };
26
+ }
27
+ if (adapter === 'mobile') {
28
+ const platform = (context.env.PLATFORM ?? process.env.PLATFORM ?? 'ios').trim().toLowerCase();
29
+ if (platform === 'android' || context.env.ADB_SERIAL || process.env.ADB_SERIAL) {
30
+ throw new Error('--record-video is not implemented for mobile Android replay yet.');
31
+ }
32
+ const simulator = resolveMobileIosSimulatorName(context.env);
33
+ if (!simulator) {
34
+ throw new Error(
35
+ 'Could not resolve an iOS simulator name for --record-video. Set IOS_SIMULATOR or pass --simulator to the recipe hook.',
36
+ );
37
+ }
38
+ const pid = resolveMobileIosSimulatorPid(simulator);
39
+ if (pid) return { kind: 'pid', pid };
40
+ return { kind: 'app-window', appName: 'Simulator', windowName: simulator };
41
+ }
42
+ throw new Error(`--record-video is not implemented for the ${adapter} adapter.`);
43
+ },
44
+ };
45
+ }
46
+
47
+ export function captureHelperSupportsRecordSessionSnapshots(projectRoot: string): boolean {
48
+ const result = spawnSync(captureHelperPath(), ['version', '--json'], {
49
+ cwd: projectRoot,
50
+ encoding: 'utf8',
51
+ });
52
+ if (result.status !== 0) return false;
53
+ try {
54
+ const parsed = JSON.parse(result.stdout) as { capabilities?: unknown };
55
+ return Array.isArray(parsed.capabilities) && parsed.capabilities.includes('record_session_snapshot');
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ export function resolveMobileIosSimulatorName(
62
+ env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
63
+ ): string | undefined {
64
+ const explicit = env.IOS_SIMULATOR?.trim() || env.SIM_UDID?.trim();
65
+ if (explicit) return explicit;
66
+ const slot = env.FARMSLOT_SLOT_ID?.trim() || env.SLOT_ID?.trim();
67
+ if (!slot) return undefined;
68
+ const suffix = slot.includes('-') ? slot.slice(slot.lastIndexOf('-') + 1) : slot;
69
+ return suffix || undefined;
70
+ }
71
+
72
+ export function resolveMobileIosSimulatorPid(simulator: string): number | null {
73
+ const result = spawnSync(
74
+ captureHelperPath(),
75
+ ['resolve', '--app-name', 'Simulator', '--window-name', simulator, '--json'],
76
+ { encoding: 'utf8' },
77
+ );
78
+ if (result.status !== 0) return null;
79
+ try {
80
+ const parsed = JSON.parse(result.stdout) as { selected?: { pid?: unknown } };
81
+ return parsePositivePid(parsed.selected?.pid);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ export function resolveExtensionBrowserPid(
88
+ projectRoot: string,
89
+ artifactsDir: string,
90
+ cdpPort: string | undefined,
91
+ ): number | null {
92
+ const explicit = parsePositivePid(process.env.METAMASK_RECIPE_EXTENSION_BROWSER_PID);
93
+ if (explicit) return explicit;
94
+ if (cdpPort) {
95
+ const lsof = spawnSync('lsof', ['-nP', `-iTCP:${cdpPort}`, '-sTCP:LISTEN', '-t'], {
96
+ cwd: projectRoot,
97
+ encoding: 'utf8',
98
+ });
99
+ if (lsof.status === 0) {
100
+ const pid = parsePositivePid(String(lsof.stdout).split(/\s+/u).find(Boolean));
101
+ if (pid) return pid;
102
+ }
103
+ }
104
+ for (const file of [
105
+ path.join(artifactsDir, 'extension-runtime/runtime.json'),
106
+ path.join(projectRoot, 'temp/recipe/runtime/runtime.json'),
107
+ ]) {
108
+ const pid = parsePositivePid(readJsonFile(file)?.pid);
109
+ if (pid) return pid;
110
+ }
111
+ for (const file of [
112
+ path.join(projectRoot, 'temp/recipe/runtime/chromium.pid'),
113
+ path.join(projectRoot, 'temp/recipe/runtime/browser.pid'),
114
+ ]) {
115
+ const pid = parsePositivePid(readTextFile(file));
116
+ if (pid) return pid;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ function captureHelperPath(): string {
122
+ return process.env.CAPTURE_HELPER_PATH || 'capture-helper';
123
+ }
124
+
125
+ function parsePositivePid(value: unknown): number | null {
126
+ const pid = Number(String(value ?? '').trim());
127
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
128
+ }
129
+
130
+ function readJsonFile(file: string): Record<string, unknown> | null {
131
+ if (!fs.existsSync(file)) return null;
132
+ try {
133
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
134
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ function readTextFile(file: string): string {
141
+ if (!fs.existsSync(file)) return '';
142
+ try {
143
+ return fs.readFileSync(file, 'utf8');
144
+ } catch {
145
+ return '';
146
+ }
147
+ }
@@ -0,0 +1,329 @@
1
+ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import type { RecipeRunResult } from '@farmslot/recipe-harness';
6
+
7
+ import {
8
+ captureHelperSupportsRecordSessionSnapshots,
9
+ resolveExtensionBrowserPid,
10
+ } from './recording-target.ts';
11
+ import type { MetaMaskRecipeAdapter } from './types.ts';
12
+
13
+ export interface RecipeRecordingOptions {
14
+ record?: boolean;
15
+ cdpPort?: string;
16
+ }
17
+
18
+ interface ActiveRecipeRecording {
19
+ child: ChildProcessWithoutNullStreams;
20
+ outputPath: string;
21
+ relativePath: string;
22
+ pid: number;
23
+ stdout: string;
24
+ stderr: string;
25
+ exited: boolean;
26
+ exitCode: number | null;
27
+ error?: Error;
28
+ stderrBuffer: string;
29
+ pendingSnapshots: Map<string, PendingRecordingSnapshot>;
30
+ }
31
+
32
+ interface PendingRecordingSnapshot {
33
+ outputPath: string;
34
+ timer: NodeJS.Timeout;
35
+ resolve: (event: Record<string, unknown>) => void;
36
+ reject: (error: Error) => void;
37
+ }
38
+
39
+ const activeRecordingsByPid = new Map<number, ActiveRecipeRecording>();
40
+
41
+ export async function startRecipeRecording(
42
+ adapter: MetaMaskRecipeAdapter,
43
+ projectRoot: string,
44
+ artifactsDir: string,
45
+ options: RecipeRecordingOptions,
46
+ ): Promise<ActiveRecipeRecording | undefined> {
47
+ if (!options.record) return undefined;
48
+ if (process.platform !== 'darwin') {
49
+ console.error(
50
+ 'WARN: --record uses capture-helper and is currently supported only on macOS; continuing without video.',
51
+ );
52
+ return undefined;
53
+ }
54
+
55
+ if (adapter !== 'extension') return undefined;
56
+
57
+ const cdpPort = options.cdpPort ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
58
+ if (!captureHelperSupportsRecordSessionSnapshots(projectRoot)) {
59
+ console.error(
60
+ 'WARN: framed extension recording requires capture-helper capability record_session_snapshot; falling back to harness --record-video.',
61
+ );
62
+ return undefined;
63
+ }
64
+ const pid = resolveExtensionBrowserPid(projectRoot, artifactsDir, cdpPort);
65
+ if (!pid) {
66
+ console.error(
67
+ `WARN: framed extension recording could not resolve browser PID from CDP port ${cdpPort ?? '<unset>'}; falling back to harness --record-video.`,
68
+ );
69
+ return undefined;
70
+ }
71
+ const recordArgs = ['record', '--framed', '--pid', String(pid)];
72
+
73
+ const relativePath = 'videos/full-run.mp4';
74
+ const outputPath = path.join(artifactsDir, relativePath);
75
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
76
+ fs.rmSync(outputPath, { force: true });
77
+ const child = spawn(captureHelperPath(), [...recordArgs, '--output', outputPath], {
78
+ cwd: projectRoot,
79
+ env: process.env,
80
+ stdio: ['pipe', 'pipe', 'pipe'],
81
+ });
82
+ const recording: ActiveRecipeRecording = {
83
+ child,
84
+ outputPath,
85
+ relativePath,
86
+ pid,
87
+ stdout: '',
88
+ stderr: '',
89
+ exited: false,
90
+ exitCode: null,
91
+ stderrBuffer: '',
92
+ pendingSnapshots: new Map(),
93
+ };
94
+ child.stdout.on('data', (chunk) => {
95
+ recording.stdout += String(chunk);
96
+ });
97
+ child.stderr.on('data', (chunk) => {
98
+ recording.stderr += String(chunk);
99
+ handleRecordingStderr(recording, String(chunk));
100
+ });
101
+ child.on('error', (error) => {
102
+ recording.error = error;
103
+ recording.stderr += error.message;
104
+ recording.exited = true;
105
+ });
106
+ child.on('close', (exitCode) => {
107
+ recording.exited = true;
108
+ recording.exitCode = exitCode;
109
+ activeRecordingsByPid.delete(recording.pid);
110
+ rejectPendingSnapshots(recording, new Error(`capture-helper recording exited before snapshot completed (code=${exitCode ?? 'unknown'})`));
111
+ });
112
+
113
+ await sleep(750);
114
+ if (recording.exited) {
115
+ console.error(
116
+ `WARN: capture-helper record exited before the recipe started (code=${recording.exitCode ?? 'unknown'}): ${recording.stderr || recording.stdout}`,
117
+ );
118
+ return undefined;
119
+ }
120
+ activeRecordingsByPid.set(pid, recording);
121
+ console.error(`INFO: recording recipe video with capture-helper pid=${pid} output=${outputPath}`);
122
+ return recording;
123
+ }
124
+
125
+ export async function captureActiveRecipeRecordingSnapshot(
126
+ pid: number,
127
+ outputPath: string,
128
+ timeoutMs = 30_000,
129
+ ): Promise<Record<string, unknown> | undefined> {
130
+ const recording = activeRecordingsByPid.get(pid);
131
+ if (!recording || recording.exited) return undefined;
132
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
133
+ fs.rmSync(outputPath, { force: true });
134
+ return new Promise<Record<string, unknown>>((resolve, reject) => {
135
+ const timer = setTimeout(() => {
136
+ recording.pendingSnapshots.delete(outputPath);
137
+ reject(new Error(`capture-helper record session snapshot timed out after ${timeoutMs}ms: ${outputPath}`));
138
+ }, timeoutMs);
139
+ recording.pendingSnapshots.set(outputPath, { outputPath, timer, resolve, reject });
140
+ recording.child.stdin.write(`snapshot ${outputPath}\n`, (error) => {
141
+ if (!error) return;
142
+ clearTimeout(timer);
143
+ recording.pendingSnapshots.delete(outputPath);
144
+ reject(error);
145
+ });
146
+ });
147
+ }
148
+
149
+ export async function stopRecipeRecording(
150
+ recording: ActiveRecipeRecording | undefined,
151
+ result?: RecipeRunResult,
152
+ ): Promise<void> {
153
+ if (!recording) return;
154
+ if (!recording.exited) {
155
+ recording.child.stdin.end('stop\n');
156
+ await waitForRecordingExit(recording, 15_000);
157
+ }
158
+ if (!recording.exited) {
159
+ recording.child.kill('SIGINT');
160
+ await waitForRecordingExit(recording, 5_000);
161
+ }
162
+ if (!recording.exited) {
163
+ recording.child.kill('SIGTERM');
164
+ await waitForRecordingExit(recording, 3_000);
165
+ }
166
+ const validation = validateRecordingArtifact(recording);
167
+ if (validation.ok === false) {
168
+ try {
169
+ fs.rmSync(recording.outputPath, { force: true });
170
+ } catch (error) {
171
+ console.error(
172
+ `WARN: could not remove unusable capture-helper video ${recording.outputPath}: ${error instanceof Error ? error.message : String(error)}`,
173
+ );
174
+ }
175
+ console.error(
176
+ `WARN: capture-helper recording did not produce a usable video artifact: ${validation.reason}`,
177
+ );
178
+ if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
179
+ return;
180
+ }
181
+ if (result) addRecordingArtifactToManifest(result, recording);
182
+ }
183
+
184
+ async function waitForRecordingExit(recording: ActiveRecipeRecording, timeoutMs: number): Promise<void> {
185
+ if (recording.exited) return;
186
+ await new Promise<void>((resolve) => {
187
+ const timer = setTimeout(resolve, timeoutMs);
188
+ recording.child.once('close', () => {
189
+ clearTimeout(timer);
190
+ resolve();
191
+ });
192
+ });
193
+ }
194
+
195
+ function handleRecordingStderr(recording: ActiveRecipeRecording, chunk: string): void {
196
+ recording.stderrBuffer += chunk;
197
+ let newlineIndex = recording.stderrBuffer.indexOf('\n');
198
+ while (newlineIndex !== -1) {
199
+ const line = recording.stderrBuffer.slice(0, newlineIndex).trim();
200
+ recording.stderrBuffer = recording.stderrBuffer.slice(newlineIndex + 1);
201
+ if (line) handleRecordingEventLine(recording, line);
202
+ newlineIndex = recording.stderrBuffer.indexOf('\n');
203
+ }
204
+ }
205
+
206
+ function handleRecordingEventLine(recording: ActiveRecipeRecording, line: string): void {
207
+ let event: Record<string, unknown>;
208
+ try {
209
+ const parsed = JSON.parse(line);
210
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
211
+ event = parsed as Record<string, unknown>;
212
+ } catch {
213
+ // capture-helper may emit human-readable stderr lines; only JSON event
214
+ // records are relevant for in-session screenshot correlation.
215
+ return;
216
+ }
217
+ const output = typeof event.output === 'string' ? event.output : undefined;
218
+ if (!output) return;
219
+ const pending = recording.pendingSnapshots.get(output);
220
+ if (!pending) return;
221
+ if (event.type === 'snapshot') {
222
+ clearTimeout(pending.timer);
223
+ recording.pendingSnapshots.delete(output);
224
+ pending.resolve(event);
225
+ return;
226
+ }
227
+ if (event.type === 'error') {
228
+ clearTimeout(pending.timer);
229
+ recording.pendingSnapshots.delete(output);
230
+ pending.reject(new Error(String(event.message ?? `capture-helper snapshot failed: ${output}`)));
231
+ }
232
+ }
233
+
234
+ function rejectPendingSnapshots(recording: ActiveRecipeRecording, error: Error): void {
235
+ for (const pending of recording.pendingSnapshots.values()) {
236
+ clearTimeout(pending.timer);
237
+ pending.reject(error);
238
+ }
239
+ recording.pendingSnapshots.clear();
240
+ }
241
+
242
+ function validateRecordingArtifact(recording: ActiveRecipeRecording): { ok: true } | { ok: false; reason: string } {
243
+ if (!fs.existsSync(recording.outputPath)) {
244
+ return { ok: false, reason: `missing output ${recording.outputPath}` };
245
+ }
246
+ const size = fs.statSync(recording.outputPath).size;
247
+ if (size === 0) {
248
+ return { ok: false, reason: `empty output ${recording.outputPath}` };
249
+ }
250
+ const recorderOutput = `${recording.stdout}\n${recording.stderr}`;
251
+ if (!recorderOutput.includes('record_complete')) {
252
+ return {
253
+ ok: false,
254
+ reason: `capture-helper did not report record_complete for ${recording.outputPath}: ${recorderOutput.trim() || 'no recorder output'}`,
255
+ };
256
+ }
257
+
258
+ const ffprobe = spawnSync(
259
+ 'ffprobe',
260
+ ['-hide_banner', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', recording.outputPath],
261
+ { encoding: 'utf8' },
262
+ );
263
+ if (ffprobe.error && (ffprobe.error as NodeJS.ErrnoException).code === 'ENOENT') {
264
+ return { ok: true };
265
+ }
266
+ if (ffprobe.error) {
267
+ return { ok: false, reason: `ffprobe failed for ${recording.outputPath}: ${ffprobe.error.message}` };
268
+ }
269
+ if (ffprobe.status !== 0) {
270
+ return {
271
+ ok: false,
272
+ reason: `invalid MP4 ${recording.outputPath}: ${ffprobe.stderr.trim() || ffprobe.stdout.trim() || `ffprobe exited ${ffprobe.status}`}`,
273
+ };
274
+ }
275
+ const durationSeconds = Number(ffprobe.stdout.trim());
276
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
277
+ return { ok: false, reason: `MP4 has no positive duration: ${recording.outputPath}` };
278
+ }
279
+ return { ok: true };
280
+ }
281
+
282
+ function addRecordingArtifactToManifest(result: RecipeRunResult, recording: ActiveRecipeRecording): void {
283
+ const manifestPath = result.artifactManifestPath;
284
+ if (!manifestPath || !fs.existsSync(manifestPath)) {
285
+ console.error(`WARN: cannot add video artifact to missing artifact manifest: ${manifestPath ?? '<unset>'}`);
286
+ return;
287
+ }
288
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as {
289
+ artifacts?: Array<Record<string, unknown>>;
290
+ };
291
+ if (!Array.isArray(manifest.artifacts)) manifest.artifacts = [];
292
+ manifest.artifacts = manifest.artifacts.filter((artifact) => artifact.path !== recording.relativePath);
293
+ manifest.artifacts.push({
294
+ path: recording.relativePath,
295
+ type: 'video',
296
+ label: 'Full recipe replay video',
297
+ category: 'evidence',
298
+ mimeType: 'video/mp4',
299
+ record: 'full_run',
300
+ metadata: {
301
+ provider: 'capture-helper',
302
+ mode: 'full_run',
303
+ pid: recording.pid,
304
+ },
305
+ });
306
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
307
+ }
308
+
309
+ function removeRecordingArtifactFromManifest(result: RecipeRunResult, relativePath: string): void {
310
+ const manifestPath = result.artifactManifestPath;
311
+ if (!manifestPath || !fs.existsSync(manifestPath)) return;
312
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as {
313
+ artifacts?: Array<Record<string, unknown>>;
314
+ };
315
+ if (!Array.isArray(manifest.artifacts)) return;
316
+ const nextArtifacts = manifest.artifacts.filter((artifact) => artifact.path !== relativePath);
317
+ if (nextArtifacts.length === manifest.artifacts.length) return;
318
+ manifest.artifacts = nextArtifacts;
319
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
320
+ }
321
+
322
+
323
+ function captureHelperPath(): string {
324
+ return process.env.CAPTURE_HELPER_PATH || 'capture-helper';
325
+ }
326
+
327
+ function sleep(ms: number): Promise<void> {
328
+ return new Promise((resolve) => setTimeout(resolve, ms));
329
+ }
@@ -0,0 +1,108 @@
1
+ import { execSync } from 'node:child_process';
2
+
3
+ import type { RecipeActionManifestDocument } from '@farmslot/protocol';
4
+ import type { ActionAdapter, RecipeRunner } from '@farmslot/recipe-harness';
5
+
6
+ import { createMetaMaskAdapters, createMetaMaskUiTransport } from './adapters.ts';
7
+ import { loadMetaMaskExtensionActionManifest, loadMetaMaskMobileActionManifest } from './manifest.ts';
8
+ import { createMetaMaskRecordingTargetProvider } from './recording-target.ts';
9
+ import { importRecipeHarness, importRecipeHarnessRuntimeCdp, importRecipeHarnessRuntimeReactNativeBridge, runnerDir } from './paths.ts';
10
+ import type { CreateMetaMaskRunnerOptions, MetaMaskRecipeAdapter } from './types.ts';
11
+
12
+ export async function createMetaMaskMobileRunner(
13
+ options: CreateMetaMaskRunnerOptions = {},
14
+ ): Promise<RecipeRunner> {
15
+ return createMetaMaskRunner(
16
+ 'mobile',
17
+ options.actionManifest ?? loadMetaMaskMobileActionManifest(),
18
+ );
19
+ }
20
+
21
+ export async function createMetaMaskExtensionRunner(
22
+ options: CreateMetaMaskRunnerOptions = {},
23
+ ): Promise<RecipeRunner> {
24
+ return createMetaMaskRunner(
25
+ 'extension',
26
+ options.actionManifest ?? loadMetaMaskExtensionActionManifest(),
27
+ );
28
+ }
29
+
30
+ export async function createMetaMaskRunner(
31
+ adapter: MetaMaskRecipeAdapter,
32
+ actionManifest: RecipeActionManifestDocument,
33
+ options: { quietStdout?: boolean } = {},
34
+ ): Promise<RecipeRunner> {
35
+ const {
36
+ createRecipeRunner,
37
+ createStandardCoreAdapters,
38
+ createStandardUiAdapters,
39
+ } = await importRecipeHarness();
40
+ const { createCdpWebUiTransport } = await importRecipeHarnessRuntimeCdp();
41
+ const { createReactNativeBridgeUiTransport } = await importRecipeHarnessRuntimeReactNativeBridge();
42
+ const actions = [
43
+ ...actionManifest.supported_official_actions,
44
+ ...(actionManifest.custom_actions ?? []).map((entry: { name: string }) => entry.name),
45
+ ];
46
+ const declaredActions = new Set(actions);
47
+ const core = createStandardCoreAdapters({ actions });
48
+ const projectOwnedOfficialActions = new Set(['app.status']);
49
+ const ui = createStandardUiAdapters({
50
+ actions: actions.filter((action) => !projectOwnedOfficialActions.has(action)),
51
+ transport: createMetaMaskUiTransport(adapter, {
52
+ createCdpWebUiTransport,
53
+ createReactNativeBridgeUiTransport,
54
+ }),
55
+ });
56
+ const existing = new Set([...core, ...ui].map((entry) => entry.action));
57
+ const custom: ActionAdapter[] = createMetaMaskAdapters(adapter).filter(
58
+ (entry) => declaredActions.has(entry.action) && !existing.has(entry.action),
59
+ );
60
+ const autoHudDisabled = process.env.METAMASK_RECIPE_AUTO_HUD === '0' || process.env.METAMASK_RECIPE_AUTO_HUD === 'false';
61
+ // In --json mode stdout is the machine contract; route every engine log
62
+ // (e.g. the "Recipe libraries: …" resolution line, which uses logger.info →
63
+ // stdout on the plain console) to stderr so stdout stays parseable JSON.
64
+ const logger = options.quietStdout ? new console.Console(process.stderr) : console;
65
+ return createRecipeRunner({
66
+ actionManifest,
67
+ adapters: [...core, ...ui, ...custom],
68
+ logger,
69
+ recording: {
70
+ targetProvider: createMetaMaskRecordingTargetProvider(adapter),
71
+ },
72
+ runner: runnerProvenance(),
73
+ hud: autoHudDisabled
74
+ ? false
75
+ : {
76
+ enabled: true,
77
+ display: {
78
+ layout: 'docked-bottom',
79
+ position: 'bottom',
80
+ showTitle: false,
81
+ showDebug: false,
82
+ maxDetailLines: 2,
83
+ },
84
+ },
85
+ });
86
+ }
87
+
88
+ function runnerProvenance() {
89
+ return {
90
+ source: runnerDir,
91
+ git_ref: runnerGitRef(),
92
+ name: '@metamask/recipe-runner',
93
+ };
94
+ }
95
+
96
+ function runnerGitRef(): string {
97
+ try {
98
+ return execSync('git rev-parse HEAD', {
99
+ cwd: runnerDir,
100
+ encoding: 'utf8',
101
+ stdio: ['ignore', 'pipe', 'ignore'],
102
+ }).trim();
103
+ } catch {
104
+ // Standalone packaged runners may be copied without a .git directory; keep
105
+ // artifact packages valid while source/ref still identifies the runner.
106
+ return 'unknown';
107
+ }
108
+ }
@@ -0,0 +1,57 @@
1
+ import type { RecipeActionManifestDocument, RecipeValidationResult } from '@farmslot/protocol';
2
+
3
+ export type MetaMaskRecipeAdapter = 'mobile' | 'extension' | 'core';
4
+
5
+ export interface CreateMetaMaskRunnerOptions {
6
+ actionManifest?: RecipeActionManifestDocument;
7
+ }
8
+
9
+ export type RecipeHarnessModule = typeof import('@farmslot/recipe-harness');
10
+ export type RecipeHarnessBrowserExtensionModule = typeof import('@farmslot/recipe-harness/runtime/browser-extension');
11
+ export type RecipeHarnessCdpModule = typeof import('@farmslot/recipe-harness/runtime/cdp');
12
+ export type RecipeHarnessReactNativeBridgeModule = typeof import('@farmslot/recipe-harness/runtime/react-native-bridge');
13
+ export type RecipeProtocolModule = typeof import('@farmslot/protocol');
14
+
15
+ export interface MetaMaskDoctorReport {
16
+ schemaVersion: 1;
17
+ protocolVersion: 'v1';
18
+ runner_protocol_version: 1;
19
+ status: 'pass' | 'fail';
20
+ checks: Array<{ id: string; status: 'pass' | 'fail'; message: string }>;
21
+ adapter: MetaMaskRecipeAdapter;
22
+ target: string;
23
+ compatibilityMode:
24
+ | 'bridge present'
25
+ | 'injected bridge present'
26
+ | 'bridge injectable'
27
+ | 'product-local harness present'
28
+ | 'runner bridge with injected app bridge'
29
+ | 'runner bridge with app bridge'
30
+ | 'runner bridge available; app bridge not installed'
31
+ | 'headless controller (no bridge)'
32
+ | 'unsupported/no bridge';
33
+ runner: {
34
+ name: '@metamask/recipe-runner';
35
+ runnerDir: string;
36
+ actionManifestPath: string;
37
+ harnessPackage: '@farmslot/recipe-harness';
38
+ };
39
+ shape: Record<string, unknown>;
40
+ fixture: Record<string, unknown>;
41
+ runtimeContext: MetaMaskRuntimeContextReport;
42
+ manifestValidation: RecipeValidationResult['summary'] | Record<string, unknown>;
43
+ }
44
+
45
+ export interface MetaMaskRuntimeContextField {
46
+ value: string | null;
47
+ source: 'file' | 'env' | 'default';
48
+ envVar: string | null;
49
+ customize: string;
50
+ }
51
+
52
+ export interface MetaMaskRuntimeContextReport {
53
+ file: string;
54
+ fileExists: boolean;
55
+ envOverride: string | null;
56
+ fields: Record<string, MetaMaskRuntimeContextField>;
57
+ }