@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,516 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from './paths.ts';
6
+ import type { MetaMaskRecipeAdapter } from './types.ts';
7
+
8
+ // The `harness` command moves the recipe-harness skill's runner-locate + dispatch
9
+ // bash (resolve-runner-source.sh + recipe-harness.sh dispatch_adapter_action)
10
+ // into typed runner code. The skill resolved an *external* runner source; inside
11
+ // the runner the root is known, so "resolve runner root" collapses to runnerDir.
12
+ // Everything else — the per-subcommand/platform orchestration file mapping,
13
+ // candidate search order, installed-overlay/installed-script preference, the
14
+ // core delegate, extension runtime-context env, and verbatim arg passthrough —
15
+ // is reproduced exactly so the skill can become a thin caller.
16
+
17
+ type HarnessAction = 'install' | 'verify' | 'cleanup' | 'live';
18
+
19
+ const HARNESS_ACTIONS: readonly HarnessAction[] = ['install', 'verify', 'cleanup', 'live'];
20
+ const ADAPTERS: readonly MetaMaskRecipeAdapter[] = ['mobile', 'extension', 'core'];
21
+
22
+ // Mirror of scripts/lib/cli-common.sh valid_adapter_action, restricted to the
23
+ // subcommands this command exposes (core has no app/live lifecycle).
24
+ function isValidAdapterAction(adapter: MetaMaskRecipeAdapter, action: HarnessAction): boolean {
25
+ if (adapter === 'core') return action === 'install' || action === 'verify' || action === 'cleanup';
26
+ return true;
27
+ }
28
+
29
+ function harnessUsage(): void {
30
+ console.error(`mm-harness — install and validate the MetaMask recipe runtime in a checkout.
31
+
32
+ Run it from inside a MetaMask checkout; the platform (mobile | extension | core)
33
+ is auto-detected from the repo. Pass --platform only to override.
34
+
35
+ Commands (one copy-pasteable example each):
36
+ install Install the recipe harness runtime overlay into the checkout.
37
+ mm-harness install
38
+ verify Check the harness/runtime is present and healthy (no app launch).
39
+ mm-harness verify
40
+ cleanup Remove the installed harness overlay and restore the checkout.
41
+ mm-harness cleanup
42
+
43
+ Options:
44
+ --platform <mobile|extension|core> Override auto-detection (alias: --adapter).
45
+ --target <repo> Checkout to operate on (default: current dir).
46
+ --json Machine-readable summary for agents/scripts.
47
+ -- <args> Forward the rest verbatim to the underlying
48
+ script, e.g. -- --platform ios --preflight-mode fast
49
+ (mobile) or -- --cdp-port 6665 (extension).
50
+
51
+ Examples:
52
+ mm-harness install # inside a checkout, auto-detected
53
+ mm-harness verify --platform extension
54
+ mm-harness cleanup --target ../metamask-mobile
55
+
56
+ core supports install, verify, and cleanup (headless; no live).`);
57
+ }
58
+
59
+ function failureHint(adapter: MetaMaskRecipeAdapter, action: HarnessAction): string {
60
+ if (adapter === 'core') {
61
+ if (action === 'verify') {
62
+ return `Install the core harness first: mm-harness install --platform core`;
63
+ }
64
+ return 'Read the error above for the specific cause, then re-run this command.';
65
+ }
66
+ const helper = adapter === 'mobile' ? 'mm-harness launch ios # or launch android' : 'mm-harness launch';
67
+ return `Read the error above for the specific cause. To (re)start the runtime, run: ${helper}`;
68
+ }
69
+
70
+ // has_arg: matches an exact flag or its `flag=value` form.
71
+ function hasArg(args: string[], needle: string): boolean {
72
+ return args.some((arg) => arg === needle || arg.startsWith(`${needle}=`));
73
+ }
74
+
75
+ // arg_value: value for `flag value` or `flag=value`, else undefined.
76
+ function argValue(args: string[], needle: string): string | undefined {
77
+ for (let i = 0; i < args.length; i += 1) {
78
+ if (args[i] === needle) return args[i + 1];
79
+ if (args[i].startsWith(`${needle}=`)) return args[i].slice(needle.length + 1);
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ function isAdapter(value: string | undefined): value is MetaMaskRecipeAdapter {
85
+ return value === 'mobile' || value === 'extension' || value === 'core';
86
+ }
87
+
88
+ interface ParsedHarnessArgs {
89
+ // Kept as raw string so invalid values (e.g. --adapter garbage) are not silently
90
+ // cast to MetaMaskRecipeAdapter; narrowed via isAdapter() at the validation site.
91
+ adapter?: string;
92
+ json: boolean;
93
+ forward: string[];
94
+ }
95
+
96
+ // Split the adapter selector and --json out of the remaining argv so they are not
97
+ // forwarded to the orchestration script (the .sh/.mjs scripts reject unknown
98
+ // flags; Mobile's live/verify use --platform ios|android, so only the first
99
+ // --platform whose value is an adapter is consumed here and a later --platform is
100
+ // passed through). `--` forces everything after it to passthrough.
101
+ function parseHarnessArgs(args: string[]): ParsedHarnessArgs {
102
+ const forward: string[] = [];
103
+ let adapter: string | undefined;
104
+ let json = false;
105
+ let separator = false;
106
+ for (let i = 0; i < args.length; i += 1) {
107
+ const arg = args[i];
108
+ // POSIX: everything after `--` is literal; check separator FIRST so that
109
+ // --json appearing after `--` is forwarded verbatim to the delegate.
110
+ if (separator) {
111
+ forward.push(arg);
112
+ continue;
113
+ }
114
+ if (arg === '--') {
115
+ separator = true;
116
+ continue;
117
+ }
118
+ if (arg === '--json') {
119
+ json = true;
120
+ continue;
121
+ }
122
+ if (arg === '--adapter' || arg === '--platform') {
123
+ const value = args[i + 1];
124
+ if (arg === '--adapter') {
125
+ adapter = value;
126
+ i += 1;
127
+ continue;
128
+ }
129
+ if (adapter === undefined && isAdapter(value)) {
130
+ adapter = value;
131
+ i += 1;
132
+ continue;
133
+ }
134
+ forward.push(arg);
135
+ continue;
136
+ }
137
+ if (arg.startsWith('--adapter=')) {
138
+ adapter = arg.slice('--adapter='.length);
139
+ continue;
140
+ }
141
+ if (arg.startsWith('--platform=')) {
142
+ const value = arg.slice('--platform='.length);
143
+ if (adapter === undefined && isAdapter(value)) {
144
+ adapter = value;
145
+ continue;
146
+ }
147
+ forward.push(arg);
148
+ continue;
149
+ }
150
+ forward.push(arg);
151
+ }
152
+ return { adapter, json, forward };
153
+ }
154
+
155
+ // Mirror of scripts/lib/cli-common.sh detect_adapter for the auto-detect path.
156
+ // Exported so `doctor` shares the exact same detect-from-target logic as the
157
+ // overlay commands (verify/install/cleanup) — one auto-detect story, no drift.
158
+ export function detectAdapter(target: string): MetaMaskRecipeAdapter | undefined {
159
+ let remote = '';
160
+ try {
161
+ remote = execFileSync('git', ['-C', target, 'config', '--get', 'remote.origin.url'], {
162
+ encoding: 'utf8',
163
+ stdio: ['ignore', 'pipe', 'ignore'],
164
+ }).trim();
165
+ } catch {
166
+ remote = '';
167
+ }
168
+ if (remote.includes('metamask-extension')) return 'extension';
169
+ if (remote.includes('metamask-mobile')) return 'mobile';
170
+
171
+ const exists = (rel: string): boolean => fs.existsSync(path.join(target, rel));
172
+ const isDir = (rel: string): boolean => {
173
+ try {
174
+ return fs.statSync(path.join(target, rel)).isDirectory();
175
+ } catch {
176
+ return false;
177
+ }
178
+ };
179
+
180
+ if (exists('development/skills-sync.ts') || (isDir('ui') && isDir('app/scripts'))) return 'extension';
181
+ if (exists('scripts/skills-sync.mts') || (isDir('ios') && isDir('android') && isDir('app/core'))) return 'mobile';
182
+ if (isDir('packages/perps-controller') && exists('yarn.lock')) return 'core';
183
+ return undefined;
184
+ }
185
+
186
+ // Resolve the agentic-runtime.json path for a checkout: the RECIPE_RUNTIME_CONTEXT
187
+ // override wins, else the per-checkout runtime dir. Single source shared by the
188
+ // extension pre-dispatch hydration and doctor's runtime-context report.
189
+ export function resolveRuntimeContextPath(target: string): string {
190
+ return process.env.RECIPE_RUNTIME_CONTEXT ?? path.join(target, recipeRuntimeDir(), 'agentic-runtime.json');
191
+ }
192
+
193
+ // read_runtime_context_field: dotted lookup returning the value only when it is a
194
+ // present, non-empty scalar; undefined when the file/field is missing.
195
+ export function readRuntimeContextField(contextPath: string, field: string): string | undefined {
196
+ let data: unknown;
197
+ try {
198
+ data = JSON.parse(fs.readFileSync(contextPath, 'utf8'));
199
+ } catch {
200
+ return undefined;
201
+ }
202
+ let node: unknown = data;
203
+ for (const key of field.split('.')) {
204
+ if (node === undefined || node === null || typeof node !== 'object') return undefined;
205
+ node = (node as Record<string, unknown>)[key];
206
+ }
207
+ if (node === undefined || node === null || node === '') return undefined;
208
+ return String(node);
209
+ }
210
+
211
+ // Port of recipe-harness.sh's extension pre-dispatch block: hydrate runtime env
212
+ // from agentic-runtime.json and append --cdp-port / --prepare-cmd to the args
213
+ // exactly as the skill did (extension launch/live/verify; here live/verify).
214
+ function applyExtensionRuntimeEnv(target: string, action: HarnessAction, args: string[]): string[] {
215
+ const contextPath = resolveRuntimeContextPath(target);
216
+ const contextExists = fs.existsSync(contextPath);
217
+
218
+ if (contextExists) {
219
+ process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
220
+ if (!process.env.RECIPE_SLOT_ID) {
221
+ const slotId = readRuntimeContextField(contextPath, 'slotId');
222
+ if (slotId) process.env.RECIPE_SLOT_ID = slotId;
223
+ }
224
+ if (!process.env.RECIPE_HARNESS_EXTENSION_ID) {
225
+ const extensionId = readRuntimeContextField(contextPath, 'extensionId');
226
+ if (extensionId) process.env.RECIPE_HARNESS_EXTENSION_ID = extensionId;
227
+ }
228
+ if (process.env.RECIPE_RUNTIME_START_APPROVED === undefined) {
229
+ const approved = readRuntimeContextField(contextPath, 'runtimeStart.approved');
230
+ if (approved === 'true' || approved === 'True' || approved === '1') process.env.RECIPE_RUNTIME_START_APPROVED = '1';
231
+ else if (approved === 'false' || approved === 'False' || approved === '0') process.env.RECIPE_RUNTIME_START_APPROVED = '0';
232
+ }
233
+ if (!process.env.RECIPE_RUNTIME_START_CMD) {
234
+ const command = readRuntimeContextField(contextPath, 'runtimeStart.command');
235
+ if (command) process.env.RECIPE_RUNTIME_START_CMD = command;
236
+ }
237
+ if (!process.env.RECIPE_RUNTIME_READY_URL) {
238
+ const readyUrl = readRuntimeContextField(contextPath, 'runtimeStart.readyUrl');
239
+ if (readyUrl) process.env.RECIPE_RUNTIME_READY_URL = readyUrl;
240
+ }
241
+ }
242
+
243
+ let result = [...args];
244
+ if (!hasArg(result, '--cdp-port')) {
245
+ const contextPort = contextExists ? readRuntimeContextField(contextPath, 'cdpPort') : undefined;
246
+ const cdpPort = contextPort ?? process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
247
+ if (cdpPort) {
248
+ process.env.RECIPE_CDP_PORT = cdpPort;
249
+ process.env.CDP_PORT = cdpPort;
250
+ result = [...result, '--cdp-port', cdpPort];
251
+ }
252
+ }
253
+ if (action === 'live' && !hasArg(result, '--prepare-cmd')) {
254
+ if (process.env.RECIPE_RUNTIME_START_APPROVED === '1' && process.env.RECIPE_RUNTIME_START_CMD) {
255
+ result = [...result, '--prepare-cmd', process.env.RECIPE_RUNTIME_START_CMD];
256
+ }
257
+ }
258
+ return result;
259
+ }
260
+
261
+ // runner_executable_entry / runner_file_entry: first candidate that exists (as an
262
+ // executable, or as any file) wins; otherwise the first candidate is returned so
263
+ // the exec error names it.
264
+ function resolveEntry(base: string, candidates: string[], mode: 'exec' | 'file'): string {
265
+ for (const candidate of candidates) {
266
+ const full = path.join(base, candidate);
267
+ try {
268
+ const stat = fs.statSync(full);
269
+ if (!stat.isFile()) continue;
270
+ if (mode === 'file' || (stat.mode & 0o111) !== 0) return full;
271
+ } catch {
272
+ // missing candidate; try the next one
273
+ }
274
+ }
275
+ return path.join(base, candidates[0]);
276
+ }
277
+
278
+ // Content of an installed overlay's runner source pointer, if present. Command
279
+ // substitution in the skill stripped trailing newlines, so trim here too.
280
+ function installedRunnerSource(target: string, adapter: MetaMaskRecipeAdapter): string | undefined {
281
+ const pointer = path.join(recipeHarnessPath(target, adapter), 'runner', '.runner-source');
282
+ if (!fs.existsSync(pointer)) return undefined;
283
+ const value = fs.readFileSync(pointer, 'utf8').trim();
284
+ return value || undefined;
285
+ }
286
+
287
+ function isExecutable(file: string): boolean {
288
+ try {
289
+ const stat = fs.statSync(file);
290
+ return stat.isFile() && (stat.mode & 0o111) !== 0;
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+
296
+ interface HarnessDispatch {
297
+ command: string;
298
+ prefixArgs: string[];
299
+ }
300
+
301
+ const INJECT_CANDIDATES: Record<MetaMaskRecipeAdapter, { entry: string; fallback: string }> = {
302
+ mobile: { entry: 'orchestration/mobile/inject.sh', fallback: 'scripts/inject-mobile-harness.sh' },
303
+ extension: { entry: 'orchestration/extension/inject.mjs', fallback: 'scripts/inject-extension-harness.mjs' },
304
+ core: { entry: 'orchestration/core/inject.sh', fallback: 'scripts/inject-core-harness.sh' },
305
+ };
306
+
307
+ const CLEANUP_CANDIDATES: Record<MetaMaskRecipeAdapter, { entry: string; fallback: string }> = {
308
+ mobile: { entry: 'orchestration/mobile/cleanup.sh', fallback: 'scripts/cleanup-mobile-harness.sh' },
309
+ extension: { entry: 'orchestration/extension/cleanup.mjs', fallback: 'scripts/cleanup-extension-harness.mjs' },
310
+ core: { entry: 'orchestration/core/cleanup.sh', fallback: 'scripts/cleanup-core-harness.sh' },
311
+ };
312
+
313
+ // Resolve the exact command + fixed prefix args to exec, mirroring
314
+ // recipe-harness.sh dispatch_adapter_action. forwardArgs are appended by the
315
+ // caller. Returns a string message for the one fail-fast case (missing core
316
+ // delegate) that the skill handled inline.
317
+ function resolveHarnessDispatch(
318
+ adapter: MetaMaskRecipeAdapter,
319
+ action: HarnessAction,
320
+ target: string,
321
+ ): HarnessDispatch | { error: string } {
322
+ if (action === 'install') {
323
+ const { entry, fallback } = INJECT_CANDIDATES[adapter];
324
+ if (adapter === 'extension') {
325
+ return { command: process.execPath, prefixArgs: [resolveEntry(runnerDir, [entry, fallback], 'file')] };
326
+ }
327
+ return { command: resolveEntry(runnerDir, [entry, fallback], 'exec'), prefixArgs: [] };
328
+ }
329
+
330
+ if (action === 'cleanup') {
331
+ const { entry, fallback } = CLEANUP_CANDIDATES[adapter];
332
+ const base = installedRunnerSource(target, adapter) ?? runnerDir;
333
+ if (adapter === 'extension') {
334
+ return { command: process.execPath, prefixArgs: [resolveEntry(base, [entry, fallback], 'file')] };
335
+ }
336
+ return { command: resolveEntry(base, [entry, fallback], 'exec'), prefixArgs: [] };
337
+ }
338
+
339
+ if (adapter === 'core' && action === 'verify') {
340
+ const delegate = path.join(recipeHarnessPath(target, 'core'), 'runner', 'bin', 'metamask-recipe');
341
+ if (!isExecutable(delegate)) {
342
+ return {
343
+ error:
344
+ `core recipe harness not installed: missing delegate ${delegate}.\n` +
345
+ `Next: mm-harness install --platform core --target ${target}`,
346
+ };
347
+ }
348
+ // --target is carried in forwardArgs (injected when the caller omits it), so
349
+ // the delegate still receives `doctor --adapter core --target <t>`.
350
+ return { command: delegate, prefixArgs: ['doctor', '--adapter', 'core'] };
351
+ }
352
+
353
+ // mobile/extension live|verify: prefer the installed helper, else the runner's
354
+ // own orchestration/runner/scripts helper (first executable candidate wins).
355
+ const installedScript = path.join(recipeHarnessPath(target, adapter), 'scripts', `${action}.sh`);
356
+ if (isExecutable(installedScript)) {
357
+ return { command: installedScript, prefixArgs: [] };
358
+ }
359
+ const command = resolveEntry(
360
+ runnerDir,
361
+ [`orchestration/${adapter}/${action}.sh`, `runner/${adapter}/${action}.sh`, `scripts/${adapter}/${action}.sh`],
362
+ 'exec',
363
+ );
364
+ return { command, prefixArgs: [] };
365
+ }
366
+
367
+ export async function handleHarness(argv: string[]): Promise<number> {
368
+ const action = argv[0];
369
+ if (!action || action === '-h' || action === '--help') {
370
+ harnessUsage();
371
+ return action ? 0 : 2;
372
+ }
373
+ if (!HARNESS_ACTIONS.includes(action as HarnessAction)) {
374
+ harnessUsage();
375
+ console.error(`unsupported harness subcommand: ${action}`);
376
+ return 2;
377
+ }
378
+ const harnessAction = action as HarnessAction;
379
+
380
+ const { adapter: parsedAdapter, json, forward } = parseHarnessArgs(argv.slice(1));
381
+ const rawTarget = argValue(forward, '--target');
382
+ const target = path.resolve(rawTarget ?? process.cwd());
383
+
384
+ const adapter = parsedAdapter ?? detectAdapter(target);
385
+ // isAdapter() narrows adapter to MetaMaskRecipeAdapter for all code below.
386
+ if (!adapter || !isAdapter(adapter)) {
387
+ if (json) {
388
+ // parsedAdapter is string|undefined here; not yet validated as a real
389
+ // adapter, so pass undefined rather than forwarding a garbage string.
390
+ const detectError = adapter
391
+ ? { code: 'UNSUPPORTED_PLATFORM', message: `unsupported platform: ${adapter}` }
392
+ : { code: 'ADAPTER_DETECTION_FAILED', message: `could not detect the MetaMask repo type for ${target}` };
393
+ console.log(harnessSummary(harnessAction, undefined, target, 'fail', 2, false, detectError));
394
+ } else {
395
+ harnessUsage();
396
+ console.error(
397
+ adapter
398
+ ? `\n✗ unsupported platform: ${adapter}`
399
+ : `\n✗ could not detect the MetaMask repo type for ${target}\n Next: pass --platform mobile|extension|core`,
400
+ );
401
+ }
402
+ return 2;
403
+ }
404
+ if (!isValidAdapterAction(adapter, harnessAction)) {
405
+ if (json) {
406
+ console.log(
407
+ harnessSummary(harnessAction, adapter, target, 'fail', 2, parsedAdapter === undefined, {
408
+ code: 'UNSUPPORTED_ACTION',
409
+ message: `${harnessAction} is not supported for ${adapter} (core supports install, verify, cleanup).`,
410
+ }),
411
+ );
412
+ } else {
413
+ console.error(
414
+ `✗ ${harnessAction} is not supported for ${adapter} (core supports install, verify, cleanup).`,
415
+ );
416
+ }
417
+ return 2;
418
+ }
419
+ const autoDetected = parsedAdapter === undefined;
420
+
421
+ // Forward args carry --target so the orchestration script resolves the same
422
+ // checkout; inject the resolved path only when the caller omitted it.
423
+ let forwardArgs = hasArg(forward, '--target') ? [...forward] : ['--target', target, ...forward];
424
+ if (adapter === 'extension' && (harnessAction === 'live' || harnessAction === 'verify')) {
425
+ forwardArgs = applyExtensionRuntimeEnv(target, harnessAction, forwardArgs);
426
+ }
427
+
428
+ const dispatch = resolveHarnessDispatch(adapter, harnessAction, target);
429
+ if ('error' in dispatch) {
430
+ if (json) {
431
+ console.log(
432
+ harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
433
+ code: 'DISPATCH_UNAVAILABLE',
434
+ message: dispatch.error,
435
+ }),
436
+ );
437
+ } else {
438
+ console.error(`✗ ${harnessAction} ${adapter} failed\n ${dispatch.error.replace(/\n/gu, '\n ')}`);
439
+ }
440
+ return 1;
441
+ }
442
+
443
+ if (!json) {
444
+ const detected = autoDetected ? ', auto-detected' : '';
445
+ console.error(`→ ${harnessAction} (${adapter}${detected}) — target: ${target}`);
446
+ }
447
+
448
+ const start = Date.now();
449
+ const result = spawnSync(dispatch.command, [...dispatch.prefixArgs, ...forwardArgs], {
450
+ // Default: stream child output verbatim (byte-identical to the skill path).
451
+ // --json: route child output to our stderr so stdout carries only the summary.
452
+ stdio: json ? ['inherit', 2, 'inherit'] : 'inherit',
453
+ env: process.env,
454
+ });
455
+ const seconds = ((Date.now() - start) / 1000).toFixed(1);
456
+
457
+ if (result.error) {
458
+ if (json) {
459
+ console.log(
460
+ harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
461
+ code: 'HARNESS_SPAWN_FAILED',
462
+ message: `${harnessAction} ${adapter} could not start: ${result.error.message}`,
463
+ }),
464
+ );
465
+ } else {
466
+ console.error(`✗ ${harnessAction} ${adapter} could not start: ${result.error.message}\n ${failureHint(adapter, harnessAction)}`);
467
+ }
468
+ return 1;
469
+ }
470
+
471
+ const exitCode = result.status ?? 1;
472
+ if (json) {
473
+ console.log(
474
+ harnessSummary(
475
+ harnessAction,
476
+ adapter,
477
+ target,
478
+ exitCode === 0 ? 'pass' : 'fail',
479
+ exitCode,
480
+ autoDetected,
481
+ exitCode === 0
482
+ ? undefined
483
+ : { code: 'HARNESS_FAILED', message: `${harnessAction} ${adapter} failed (exit ${exitCode})` },
484
+ ),
485
+ );
486
+ } else if (exitCode === 0) {
487
+ console.error(`✓ ${harnessAction} ${adapter} passed (${seconds}s)`);
488
+ } else {
489
+ console.error(`✗ ${harnessAction} ${adapter} failed (exit ${exitCode}, ${seconds}s)\n ${failureHint(adapter, harnessAction)}`);
490
+ }
491
+ return exitCode;
492
+ }
493
+
494
+ function harnessSummary(
495
+ action: HarnessAction,
496
+ adapter: MetaMaskRecipeAdapter | undefined,
497
+ target: string,
498
+ status: 'pass' | 'fail',
499
+ exitCode: number,
500
+ autoDetected: boolean,
501
+ error?: { code: string; message: string },
502
+ ): string {
503
+ return JSON.stringify({
504
+ schemaVersion: 1,
505
+ command: 'harness',
506
+ action,
507
+ adapter: adapter ?? null,
508
+ target,
509
+ autoDetected,
510
+ status,
511
+ exitCode,
512
+ // Error contract: every --json failure carries a stable machine code + human
513
+ // message (CLI-SPEC.md §5.1).
514
+ ...(status === 'fail' && error ? { error } : {}),
515
+ });
516
+ }
@@ -0,0 +1,179 @@
1
+ // Healing policy + recovery bounds — shared by launch, run, and call (policy, not
2
+ // per-command). Owns: overlay auto-ensure, failure classification, the recovery
3
+ // bounds that stop blind retry loops, and the --heal parser.
4
+ //
5
+ // Overlay auto-ensure seam (overridable for CI/agents and contract tests):
6
+ // MM_HARNESS_INSTALL_BIN — overlay installer (default: handleHarness install)
7
+
8
+ import { spawnSync } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+
11
+ import { handleHarness } from './harness.ts';
12
+ import { recipeHarnessPath, recipeRuntimePath } from './paths.ts';
13
+ import type { MetaMaskRecipeAdapter } from './types.ts';
14
+ import { EXIT } from './commands/shared.ts';
15
+ import type { DeviceAdapter } from './commands/shared.ts';
16
+
17
+ export type HealPolicy = 'off' | 'infra-only' | 'auto';
18
+
19
+ interface Mutation {
20
+ type: string;
21
+ action: string;
22
+ [key: string]: unknown;
23
+ }
24
+
25
+ export interface HealState {
26
+ recovered: string[];
27
+ mutations: Mutation[];
28
+ attemptedRecoveries: string[];
29
+ }
30
+
31
+ export function newHealState(): HealState {
32
+ return { recovered: [], mutations: [], attemptedRecoveries: [] };
33
+ }
34
+
35
+ export function parseHeal(
36
+ options: Record<string, string | boolean>,
37
+ fallback: HealPolicy,
38
+ ): HealPolicy | { error: string } {
39
+ const value = options.heal;
40
+ if (value === undefined) return fallback;
41
+ if (value === 'off' || value === 'infra-only' || value === 'auto') return value;
42
+ return { error: `--heal must be off, infra-only, or auto (got "${String(value)}").` };
43
+ }
44
+
45
+ function overlayDir(target: string, adapter: MetaMaskRecipeAdapter): string {
46
+ return recipeHarnessPath(target, adapter);
47
+ }
48
+
49
+ function overlayPresent(target: string, adapter: MetaMaskRecipeAdapter): boolean {
50
+ try {
51
+ return fs.statSync(overlayDir(target, adapter)).isDirectory();
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ // Auto-ensure the runtime overlay (install phase). Missing + heal != off →
58
+ // install inline, emit a first-install notice to stderr, and record the mutation.
59
+ // heal === off disables auto-install (repro-preserving); we proceed and let the
60
+ // porcelain surface any missing-overlay failure itself.
61
+ export async function ensureOverlay(
62
+ adapter: MetaMaskRecipeAdapter,
63
+ target: string,
64
+ heal: HealPolicy,
65
+ state: HealState,
66
+ json: boolean,
67
+ ): Promise<{ ok: boolean; error?: string }> {
68
+ // core is headless — the engine path used by run/call needs no launch overlay.
69
+ if (adapter === 'core') return { ok: true };
70
+ if (overlayPresent(target, adapter)) return { ok: true };
71
+ if (heal === 'off') return { ok: true };
72
+
73
+ const installBin = process.env.MM_HARNESS_INSTALL_BIN;
74
+ let code: number;
75
+ if (installBin) {
76
+ const result = spawnSync(installBin, ['install', '--platform', adapter, '--target', target], {
77
+ cwd: target,
78
+ stdio: ['ignore', 2, 'inherit'],
79
+ env: process.env,
80
+ });
81
+ code = result.status ?? 1;
82
+ } else {
83
+ code = await handleHarness(['install', '--platform', adapter, '--target', target, ...(json ? ['--json'] : [])]);
84
+ }
85
+ if (code !== 0 || !overlayPresent(target, adapter)) {
86
+ return { ok: false, error: `runtime overlay install failed (exit ${code})` };
87
+ }
88
+ const dir = overlayDir(target, adapter);
89
+ state.mutations.push({ type: 'file', action: 'created', path: dir });
90
+ // First-install notice → stderr (human) / mutations[] only (json; stdout clean).
91
+ if (!json) process.stderr.write(`installed mm-harness overlay → ${dir}\n`);
92
+ return { ok: true };
93
+ }
94
+
95
+ // A recovery that would need a seeded wallet is NEVER auto-performed (healing
96
+ // never touches fixtures). Only POSITIVELY-IDENTIFIED infra transport failures
97
+ // are healable. Unknown/unclassified failures → 'app' (safe default: no healing
98
+ // attempted, failure surfaced verbatim). Defaulting to 'infra' would cause
99
+ // self-healing to mask real app-logic breakage.
100
+ export function classifyFailure(output: string): 'wallet' | 'infra' | 'app' {
101
+ if (/wallet|fixture|keyring|not seeded|\bsrp\b|password|onboard/iu.test(output)) return 'wallet';
102
+ if (/metro|cdp|chrome|bundle|packager|port\b|econnrefused|not reachable|watcher|dev client|websocket/iu.test(output)) {
103
+ return 'infra';
104
+ }
105
+ return 'app';
106
+ }
107
+
108
+ // Refuse ALL recovery while a recipe is executing (mid-run recovery would corrupt
109
+ // state). Signalled by a lock file or MM_HARNESS_RECIPE_RUNNING=1.
110
+ export function recipeRunning(target: string): boolean {
111
+ if (process.env.MM_HARNESS_RECIPE_RUNNING === '1') return true;
112
+ return fs.existsSync(recipeRuntimePath(target, 'recipe.lock'));
113
+ }
114
+
115
+ // Stable recovery codes for the launch infra retry (surfaced in recovered[]).
116
+ export const RECOVERY_CODE: Record<DeviceAdapter, string> = {
117
+ mobile: 'metro.restarted',
118
+ extension: 'chrome.reopened',
119
+ };
120
+
121
+ export interface HealBoundViolation {
122
+ code: string;
123
+ exitCode: number;
124
+ message: string;
125
+ userAction?: string;
126
+ // The ORIGINAL failure output that drove this classification, carried verbatim.
127
+ // The classification `message` accompanies it — it NEVER replaces it: an
128
+ // app-logic assertion like "expected balance 10, got 7" must survive so the
129
+ // human/agent sees the real cause, not just the note.
130
+ originalError?: string;
131
+ }
132
+
133
+ // Returns null (ok to proceed) or a violation descriptor when a bound is hit.
134
+ // Using null instead of { ok: true } keeps the return typeof-narrowable without
135
+ // relying on discriminated-union narrowing (avoids strict-mode tsconfig issues).
136
+ export function checkHealBounds(
137
+ target: string,
138
+ output: string,
139
+ state: HealState,
140
+ ): HealBoundViolation | null {
141
+ // Carry the original failure output verbatim on every violation so the caller
142
+ // can surface it (--json + human) alongside the classification note.
143
+ const originalError = output.trim() || undefined;
144
+ if (recipeRunning(target)) {
145
+ return {
146
+ code: 'RECIPE_RUNNING',
147
+ exitCode: EXIT.bounded,
148
+ message: 'a recipe is currently running — refusing recovery to avoid corrupting mid-run state.',
149
+ originalError,
150
+ };
151
+ }
152
+ const failureClass = classifyFailure(output);
153
+ if (failureClass === 'wallet') {
154
+ return {
155
+ code: 'WALLET_STATE_REQUIRED',
156
+ exitCode: EXIT.bounded,
157
+ message: 'recovery would require a seeded wallet — healing never touches fixtures.',
158
+ userAction: 'run mm-harness fixtures set',
159
+ originalError,
160
+ };
161
+ }
162
+ if (failureClass === 'app') {
163
+ return {
164
+ code: 'APP_LOGIC_FAILURE',
165
+ exitCode: EXIT.runtime,
166
+ message: 'failure looks like app-logic — healing cannot help; surface verbatim.',
167
+ originalError,
168
+ };
169
+ }
170
+ if (state.attemptedRecoveries.length > 0) {
171
+ return {
172
+ code: 'SAME_RECOVERY_TWICE',
173
+ exitCode: EXIT.bounded,
174
+ message: 'same recovery already failed once this invocation — refusing to loop.',
175
+ originalError,
176
+ };
177
+ }
178
+ return null;
179
+ }