@deeeed/metamask-harness 0.17.4 → 0.18.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 (93) hide show
  1. package/CHANGELOG.md +775 -0
  2. package/README.md +65 -71
  3. package/adapters/extension/ensure-browser.sh +12 -1
  4. package/adapters/extension/inject.mjs +7 -0
  5. package/adapters/extension/launch-browser.cjs +10 -3
  6. package/adapters/extension/lib/chrome-args.cjs +31 -0
  7. package/adapters/extension/lib/macos-focus.cjs +32 -0
  8. package/adapters/extension/live.sh +10 -20
  9. package/adapters/manifest.json +8 -0
  10. package/adapters/mobile/open-device.sh +45 -7
  11. package/adapters/mobile/verify.sh +15 -3
  12. package/adapters/shared/harness-source-fingerprint.mjs +49 -0
  13. package/adapters/shared/install-repo-deps.sh +1 -5
  14. package/adapters/shared/open-debug.mjs +184 -103
  15. package/adapters/shared/resolve-slot-ports-core.mjs +23 -6
  16. package/adapters/shared/resolve-slot-ports.sh +22 -5
  17. package/bin/mm-harness +15 -3
  18. package/dist/adapters/core/surface.js +4 -1
  19. package/dist/adapters/extension/console-capture.js +3 -1
  20. package/dist/adapters/extension/harness-freshness.js +39 -0
  21. package/dist/adapters/extension/product-config.js +110 -0
  22. package/dist/adapters/extension/runtime-decision.js +20 -71
  23. package/dist/adapters/extension/surface.js +19 -1
  24. package/dist/adapters/mobile/prepare.js +17 -0
  25. package/dist/adapters/mobile/source-freshness.js +26 -41
  26. package/dist/adapters/mobile/surface.js +4 -1
  27. package/dist/adapters/resolve-slot-ports.js +2 -0
  28. package/dist/adapters/slot-ports.js +13 -32
  29. package/dist/adapters.js +50 -17
  30. package/dist/checkout-lock.js +27 -2
  31. package/dist/cli-color.js +19 -0
  32. package/dist/cli-commands.js +1 -1
  33. package/dist/cli.js +2 -3
  34. package/dist/command-contract.js +13 -3
  35. package/dist/commands/call.js +115 -29
  36. package/dist/commands/checklist.js +4 -1
  37. package/dist/commands/completion-candidates.js +20 -13
  38. package/dist/commands/debug.js +31 -38
  39. package/dist/commands/doctor.js +33 -6
  40. package/dist/commands/fixtures.js +65 -17
  41. package/dist/commands/flows.js +39 -10
  42. package/dist/commands/launch/extension.js +40 -15
  43. package/dist/commands/launch/index.js +41 -5
  44. package/dist/commands/list-executables.js +151 -29
  45. package/dist/commands/manifest.js +127 -18
  46. package/dist/commands/parse-args.js +11 -1
  47. package/dist/commands/run-engine.js +384 -56
  48. package/dist/commands/run.js +112 -17
  49. package/dist/commands/shared.js +22 -2
  50. package/dist/commands/status-probe.js +3 -0
  51. package/dist/commands/status.js +1 -0
  52. package/dist/completions-cache.js +1 -1
  53. package/dist/doctor.js +56 -6
  54. package/dist/harness.js +6 -5
  55. package/dist/heal-bounds.js +1 -1
  56. package/dist/live-adapter-contract.js +132 -12
  57. package/dist/manifest.js +161 -1
  58. package/dist/mm-harness-cli.js +13 -7
  59. package/dist/recipe-security.js +178 -0
  60. package/dist/runner.js +87 -13
  61. package/dist/runtime-context.js +8 -26
  62. package/docs/CONTRIBUTING.md +137 -0
  63. package/docs/QA.md +185 -0
  64. package/docs/RECIPES.md +161 -0
  65. package/docs/SECURITY.md +88 -0
  66. package/library/actions/core/perps/read_account.mjs +2 -2
  67. package/library/actions/core/perps/read_orders.mjs +2 -1
  68. package/library/actions/core/perps/read_positions.mjs +2 -1
  69. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  70. package/library/actions/extension/platform/cdp.mjs +1 -0
  71. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  72. package/library/actions/mobile/platform/bridge.mjs +1 -5
  73. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  74. package/library/manifests/core.action-manifest.json +61 -0
  75. package/library/manifests/extension.action-manifest.json +53 -0
  76. package/library/manifests/mobile.action-manifest.json +53 -0
  77. package/library/recipes/runner/action-validation.extension.recipe.json +8 -1
  78. package/library/recipes/runner/action-validation.mobile.recipe.json +8 -1
  79. package/package.json +10 -6
  80. package/scripts/completions.sh +7 -7
  81. package/docs/ADAPTER-SURFACE.md +0 -26
  82. package/docs/CHEATSHEET.md +0 -48
  83. package/docs/CLI-ERGONOMICS-AUDIT.md +0 -32
  84. package/docs/CLI-ERGONOMICS-HUMAN-QA.md +0 -64
  85. package/docs/CODE-MAP.md +0 -62
  86. package/docs/UX-PRINCIPLES.md +0 -66
  87. package/docs/VIDEO-DEMO-VALIDATION.md +0 -74
  88. package/docs/architecture.md +0 -88
  89. package/docs/live-adapter-contract.md +0 -190
  90. package/docs/package-boundaries.md +0 -47
  91. package/docs/perps-flow-catalog.md +0 -235
  92. package/docs/recipe-libraries.md +0 -71
  93. package/docs/runtime-file-conventions.md +0 -36
@@ -3,10 +3,11 @@
3
3
  * open-debug.mjs — open human-facing debugger UIs for recipe runtimes.
4
4
  *
5
5
  * Mobile: Metro /open-debugger or /open-dev-menu HTTP endpoints.
6
- * Extension: DevTools in the slot-owned Chrome process, attached to the exact target.
6
+ * Extension: Chrome DevTools frontend for an extension page or service worker.
7
7
  */
8
8
  import { spawnSync } from 'node:child_process';
9
- import WebSocket from 'ws';
9
+ import http from 'node:http';
10
+ import { createRequire } from 'node:module';
10
11
 
11
12
  function usage() {
12
13
  console.error(`Usage:
@@ -97,8 +98,8 @@ async function fetchOk(url, init) {
97
98
  return response.ok;
98
99
  }
99
100
 
100
- async function fetchJson(url, init) {
101
- const response = await fetch(url, init);
101
+ async function fetchJson(url) {
102
+ const response = await fetch(url);
102
103
  if (!response.ok) {
103
104
  throw new Error(`HTTP ${response.status} for ${url}`);
104
105
  }
@@ -106,52 +107,109 @@ async function fetchJson(url, init) {
106
107
  return body;
107
108
  }
108
109
 
109
- function sendBrowserCommand(webSocketDebuggerUrl, method, params) {
110
+ // Probe whether the debug server will accept a browser DevTools frontend
111
+ // WebSocket for `targetId` under `origin`. Chrome 403s every browser Origin
112
+ // unless the slot launched with --remote-allow-origins for it; a slot launched
113
+ // before that flag existed fails here, so we can tell the user to relaunch
114
+ // instead of opening a window that shows "Debugging connection was closed".
115
+ // Transient: the socket is destroyed the instant the handshake resolves.
116
+ function probeAttach(port, targetId, origin) {
110
117
  return new Promise((resolve) => {
111
- const socket = new WebSocket(webSocketDebuggerUrl);
112
- const timer = setTimeout(() => finish({ error: { message: `${method} timed out` } }), 5000);
113
- const finish = (result) => {
114
- clearTimeout(timer);
115
- socket.close();
116
- resolve(result);
117
- };
118
- socket.once('open', () => {
119
- socket.send(JSON.stringify({
120
- id: 1,
121
- method,
122
- params,
123
- }));
118
+ const req = http.request({
119
+ host: '127.0.0.1',
120
+ port,
121
+ path: `/devtools/page/${targetId}`,
122
+ headers: {
123
+ Connection: 'Upgrade',
124
+ Upgrade: 'websocket',
125
+ 'Sec-WebSocket-Version': '13',
126
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
127
+ Origin: origin,
128
+ },
124
129
  });
125
- socket.on('message', (raw) => {
126
- let message;
130
+ let settled = false;
131
+ const finish = (value) => {
132
+ if (settled) return;
133
+ settled = true;
127
134
  try {
128
- message = JSON.parse(String(raw));
135
+ req.destroy();
129
136
  } catch {
130
- finish({ error: { message: `${method} returned a non-JSON WebSocket frame` } });
131
- return;
137
+ // Best-effort teardown of the probe socket.
132
138
  }
133
- if (message.id !== 1) return;
134
- finish(message);
135
- });
136
- socket.once('error', () => finish({ error: { message: `${method} WebSocket failed` } }));
139
+ resolve(value);
140
+ };
141
+ req.on('upgrade', () => finish({ ok: true }));
142
+ req.on('response', (res) => finish({ ok: false, status: res.statusCode }));
143
+ req.on('error', (err) => finish({ ok: false, error: err.message }));
144
+ req.setTimeout(3000, () => finish({ ok: false, error: 'timeout' }));
145
+ req.end();
137
146
  });
138
147
  }
139
148
 
140
- async function openNativeDevtools(cdpPort, browserWebSocketUrl, target) {
141
- const response = await sendBrowserCommand(browserWebSocketUrl, 'Target.openDevTools', {
142
- targetId: target.id,
143
- panelId: 'console',
144
- });
145
- if (response.error) throw new Error(response.error.message);
146
- const devtoolsId = response.result?.targetId;
147
- if (!devtoolsId) throw new Error('Chrome did not return a native DevTools target');
148
- for (let attempt = 0; attempt < 20; attempt += 1) {
149
- const targets = await fetchJson(`http://127.0.0.1:${cdpPort}/json/list`);
150
- const devtools = targets.find((candidate) => candidate.id === devtoolsId);
151
- if (devtools && String(devtools.url || '').startsWith('devtools://devtools/')) return devtools;
152
- await new Promise((resolve) => setTimeout(resolve, 100));
149
+ function resolveWebSocketImpl() {
150
+ if (typeof WebSocket === 'function') return WebSocket;
151
+ try {
152
+ const require = createRequire(import.meta.url);
153
+ return require(require.resolve('ws', { paths: [process.cwd()] }));
154
+ } catch {
155
+ return null;
153
156
  }
154
- throw new Error('Chrome opened DevTools but did not expose its native target');
157
+ }
158
+
159
+ // Open the locally-served bundled DevTools frontend as its own window inside the
160
+ // slot's own Chromium, via a browser-level CDP call (the Node client sends no
161
+ // Origin header, so it is never origin-rejected). Returns the new target id.
162
+ async function openDevtoolsWindow(browserWsUrl, url) {
163
+ const WebSocketImpl = resolveWebSocketImpl();
164
+ if (!WebSocketImpl) throw new Error('WebSocket unavailable in this Node runtime to open the DevTools window');
165
+ return new Promise((resolve, reject) => {
166
+ const ws = new WebSocketImpl(browserWsUrl);
167
+ const timer = setTimeout(() => {
168
+ try {
169
+ ws.close();
170
+ } catch {
171
+ // Best-effort cleanup on timeout.
172
+ }
173
+ reject(new Error('timeout creating the DevTools window over CDP'));
174
+ }, 8000);
175
+ const onOpen = () => {
176
+ ws.send(JSON.stringify({
177
+ id: 1,
178
+ method: 'Target.createTarget',
179
+ params: { url, newWindow: true, width: 1200, height: 800 },
180
+ }));
181
+ };
182
+ const onMessage = (event) => {
183
+ const raw = event?.data ?? event;
184
+ let msg;
185
+ try {
186
+ msg = JSON.parse(Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw));
187
+ } catch {
188
+ return;
189
+ }
190
+ if (msg.id !== 1) return;
191
+ clearTimeout(timer);
192
+ ws.close();
193
+ if (msg.error) {
194
+ reject(new Error(msg.error.message || 'Target.createTarget failed'));
195
+ return;
196
+ }
197
+ resolve(msg.result?.targetId || null);
198
+ };
199
+ const onError = (err) => {
200
+ clearTimeout(timer);
201
+ reject(new Error(`browser CDP websocket error while opening DevTools: ${err?.message || err || 'unknown'}`));
202
+ };
203
+ if (typeof ws.on === 'function') {
204
+ ws.on('open', onOpen);
205
+ ws.on('message', onMessage);
206
+ ws.on('error', onError);
207
+ } else {
208
+ ws.addEventListener('open', onOpen);
209
+ ws.addEventListener('message', onMessage);
210
+ ws.addEventListener('error', onError);
211
+ }
212
+ });
155
213
  }
156
214
 
157
215
  // CDP-eval fallback: invoke DevSettings native module directly via the bridge
@@ -256,8 +314,7 @@ function pickExtensionTarget(targets, targetKind) {
256
314
 
257
315
  const pages = extensionTargets.filter((target) => target.type === 'page');
258
316
  return (
259
- pages.find((target) => String(target.url || '').includes('/sidepanel.html'))
260
- || pages.find((target) => String(target.url || '').includes('/home.html'))
317
+ pages.find((target) => String(target.url || '').includes('/home.html'))
261
318
  || pages.find((target) => !String(target.url || '').includes('/popup-init.html'))
262
319
  || pages[0]
263
320
  || null
@@ -270,7 +327,6 @@ async function openExtensionDevtools(cdpPort, targetKind, shouldOpen) {
270
327
  return { ok: false, adapter: 'extension', error: 'invalid-cdp-port', cdpPort };
271
328
  }
272
329
 
273
- const inspectUrl = 'chrome://inspect/#devices';
274
330
  const configureHint = `127.0.0.1:${numericPort}`;
275
331
  const versionEndpoint = `http://127.0.0.1:${numericPort}/json/version`;
276
332
  const listEndpoint = `http://127.0.0.1:${numericPort}/json/list`;
@@ -286,9 +342,8 @@ async function openExtensionDevtools(cdpPort, targetKind, shouldOpen) {
286
342
  cdpPort: numericPort,
287
343
  error: 'cdp-unreachable',
288
344
  endpoint: versionEndpoint,
289
- inspectUrl,
290
345
  configureHint,
291
- hint: `Start the extension runtime first (mm-harness launch). Then open chrome://inspect and add ${configureHint}.`,
346
+ hint: 'Start the extension runtime first (mm-harness launch).',
292
347
  detail: error instanceof Error ? error.message : String(error),
293
348
  };
294
349
  }
@@ -304,9 +359,8 @@ async function openExtensionDevtools(cdpPort, targetKind, shouldOpen) {
304
359
  cdpPort: numericPort,
305
360
  error: 'cdp-list-failed',
306
361
  endpoint: listEndpoint,
307
- inspectUrl,
308
362
  configureHint,
309
- hint: `CDP is up but /json/list failed. Open chrome://inspect and add ${configureHint}.`,
363
+ hint: 'CDP is up but /json/list failed. Relaunch the runtime (mm-harness launch).',
310
364
  detail: error instanceof Error ? error.message : String(error),
311
365
  };
312
366
  }
@@ -319,80 +373,107 @@ async function openExtensionDevtools(cdpPort, targetKind, shouldOpen) {
319
373
  cdpPort: numericPort,
320
374
  error: 'cdp-list-invalid',
321
375
  endpoint: listEndpoint,
322
- inspectUrl,
323
376
  configureHint,
324
- hint: `Open chrome://inspect and add ${configureHint}.`,
377
+ hint: 'Relaunch the runtime (mm-harness launch).',
325
378
  };
326
379
  }
327
380
 
328
381
  const picked = pickExtensionTarget(targets, targetKind);
329
- if (picked?.id && shouldOpen) {
330
- let devtoolsTarget;
331
- try {
332
- if (!version.webSocketDebuggerUrl) throw new Error('Chrome did not expose its browser debugger endpoint');
333
- devtoolsTarget = await openNativeDevtools(numericPort, version.webSocketDebuggerUrl, picked);
334
- } catch (error) {
335
- return {
336
- ok: false,
337
- adapter: 'extension',
338
- targetKind,
339
- cdpPort: numericPort,
340
- error: 'debugger-open-failed',
341
- endpoint: listEndpoint,
342
- target: { type: picked.type, url: picked.url, title: picked.title || null },
343
- hint: 'The extension target is healthy, but its debugger window could not be opened. Run mm-harness debug --no-open --json to inspect the resolved target.',
344
- detail: error instanceof Error ? error.message : String(error),
345
- };
346
- }
382
+ if (!picked) {
383
+ return {
384
+ ok: false,
385
+ adapter: 'extension',
386
+ targetKind,
387
+ cdpPort: numericPort,
388
+ error: 'no-target',
389
+ endpoint: listEndpoint,
390
+ configureHint,
391
+ hint: 'No extension targets are registered yet. Start the runtime (mm-harness launch).',
392
+ };
393
+ }
394
+
395
+ // Locally-served bundled DevTools frontend (offline, same host:port as the ws
396
+ // target so its Origin is allowed). Built from the target id Chrome omits
397
+ // webSocketDebuggerUrl for already-attached targets, but the id is always present.
398
+ const wsRef = `127.0.0.1:${numericPort}/devtools/page/${picked.id}`;
399
+ const devtoolsUrl = `http://127.0.0.1:${numericPort}/devtools/inspector.html?ws=${wsRef}`;
400
+ const targetInfo = { type: picked.type, url: picked.url, title: picked.title || null };
401
+
402
+ if (!shouldOpen) {
347
403
  return {
348
404
  ok: true,
349
405
  adapter: 'extension',
350
406
  targetKind,
351
- method: 'native-devtools',
352
- endpoint: devtoolsTarget.url,
407
+ method: 'devtools-window',
408
+ endpoint: devtoolsUrl,
353
409
  cdpPort: numericPort,
354
- target: {
355
- type: picked.type,
356
- url: picked.url,
357
- title: picked.title || null,
358
- },
359
- debuggerTargetId: devtoolsTarget.id,
360
- attached: true,
361
- opened: true,
410
+ target: targetInfo,
411
+ devtoolsUrl,
412
+ opened: false,
413
+ detail: 'Open action not invoked (--no-open).',
362
414
  };
363
415
  }
364
416
 
365
- if (shouldOpen) return {
366
- ok: false,
367
- adapter: 'extension',
368
- targetKind,
369
- error: picked ? 'devtools-url-unavailable' : 'extension-target-unavailable',
370
- endpoint: listEndpoint,
371
- cdpPort: numericPort,
372
- target: picked
373
- ? { type: picked.type, url: picked.url, title: picked.title || null }
374
- : null,
375
- inspectUrl,
376
- configureHint,
377
- opened: false,
378
- hint: picked
379
- ? 'The selected extension target did not expose a DevTools URL. Relaunch it with mm-harness launch.'
380
- : 'No extension target is ready. Launch it with mm-harness launch, then retry mm-harness debug.',
381
- };
417
+ // Honest gate: confirm the debug server will accept a browser DevTools Origin
418
+ // before opening a window. A slot launched before --remote-allow-origins support
419
+ // 403s here; tell the user to relaunch rather than open a dead console.
420
+ const probe = await probeAttach(numericPort, picked.id, `http://127.0.0.1:${numericPort}`);
421
+ if (!probe.ok) {
422
+ return {
423
+ ok: false,
424
+ adapter: 'extension',
425
+ targetKind,
426
+ cdpPort: numericPort,
427
+ error: 'origin-rejected',
428
+ endpoint: devtoolsUrl,
429
+ target: targetInfo,
430
+ hint: 'This slot was launched before debugger support (Chrome rejects the DevTools WebSocket origin). Relaunch it: mm-harness launch',
431
+ detail: probe.status ? `HTTP ${probe.status}` : probe.error || 'handshake rejected',
432
+ };
433
+ }
434
+
435
+ const browserWsUrl = version?.webSocketDebuggerUrl;
436
+ if (!browserWsUrl) {
437
+ return {
438
+ ok: false,
439
+ adapter: 'extension',
440
+ targetKind,
441
+ cdpPort: numericPort,
442
+ error: 'no-browser-ws',
443
+ endpoint: devtoolsUrl,
444
+ target: targetInfo,
445
+ hint: 'CDP /json/version did not expose a browser websocket. Relaunch the runtime (mm-harness launch).',
446
+ };
447
+ }
448
+
449
+ let createdTargetId;
450
+ try {
451
+ createdTargetId = await openDevtoolsWindow(browserWsUrl, devtoolsUrl);
452
+ } catch (error) {
453
+ return {
454
+ ok: false,
455
+ adapter: 'extension',
456
+ targetKind,
457
+ cdpPort: numericPort,
458
+ error: 'devtools-window-failed',
459
+ endpoint: devtoolsUrl,
460
+ target: targetInfo,
461
+ hint: 'Could not open the DevTools window over CDP. Relaunch the runtime (mm-harness launch).',
462
+ detail: error instanceof Error ? error.message : String(error),
463
+ };
464
+ }
382
465
 
383
466
  return {
384
467
  ok: true,
385
468
  adapter: 'extension',
386
469
  targetKind,
387
- method: picked?.devtoolsFrontendUrl ? 'devtools-frontend' : 'target-list',
388
- endpoint: picked?.devtoolsFrontendUrl || listEndpoint,
470
+ method: 'devtools-window',
471
+ endpoint: devtoolsUrl,
389
472
  cdpPort: numericPort,
390
- target: picked
391
- ? { type: picked.type, url: picked.url, title: picked.title || null }
392
- : null,
393
- devtoolsUrl: picked?.devtoolsFrontendUrl || null,
394
- opened: false,
395
- detail: 'Open action not invoked (--no-open).',
473
+ target: targetInfo,
474
+ devtoolsUrl,
475
+ createdTargetId,
476
+ opened: true,
396
477
  };
397
478
  }
398
479
 
@@ -172,10 +172,10 @@ function extensionRuntimeContextPath(repo) {
172
172
  const repoRoot = path.resolve(repo);
173
173
  const envCtx = process.env.RECIPE_RUNTIME_CONTEXT;
174
174
  if (envCtx) {
175
- const resolved = path.resolve(envCtx);
176
- if (resolved === repoRoot || resolved.startsWith(`${repoRoot}${path.sep}`)) {
177
- return resolved;
178
- }
175
+ // Farmslot may manage runtime context outside the checkout. Treat the
176
+ // explicit environment override as authoritative; containment is not a
177
+ // security boundary and silently dropping it selects the wrong port.
178
+ return path.resolve(envCtx);
179
179
  }
180
180
  return path.join(repoRoot, recipeRuntimeDir(), 'agentic-runtime.json');
181
181
  }
@@ -198,12 +198,22 @@ export function resolveExtensionRuntimeContext(repo) {
198
198
  }
199
199
  }
200
200
 
201
- // Checkout-scoped extension ports: runtime context (authoritative) pool suffix formula.
202
- export function formatExtensionSlotPorts(repo) {
201
+ // Context and pool describe checkout identity and therefore replace stale
202
+ // inherited env values. The suffix formula is only a fallback and is applied
203
+ // separately by callers so an explicitly supplied env port can still win.
204
+ export function formatExtensionIdentityPorts(repo) {
203
205
  const realRepo = path.resolve(repo);
204
206
  return mergeKvLayers(
205
207
  { source: resolveExtensionRuntimeContext(realRepo), overwrite: true },
206
208
  { source: resolveSlotPortsByRepo(realRepo), overwrite: false },
209
+ );
210
+ }
211
+
212
+ // Checkout-scoped extension ports: runtime context (authoritative) → pool → suffix formula.
213
+ export function formatExtensionSlotPorts(repo) {
214
+ const realRepo = path.resolve(repo);
215
+ return mergeKvLayers(
216
+ { source: formatExtensionIdentityPorts(realRepo), overwrite: true },
207
217
  { source: resolveDefaultExtensionPorts(realRepo), overwrite: false },
208
218
  );
209
219
  }
@@ -276,9 +286,16 @@ export function resolveMobileRuntimePorts(repo) {
276
286
  );
277
287
  }
278
288
 
289
+ // Back-compat: the previous name for resolveSlotPortsByRepo, kept as an alias for
290
+ // one release so existing importers keep resolving.
291
+ export const resolveFarmslotPortsByRepo = resolveSlotPortsByRepo;
292
+
279
293
  export const cliFns = {
280
294
  resolve_slot_ports_by_repo: resolveSlotPortsByRepo,
295
+ // Back-compat cli key for one release.
296
+ resolve_farmslot_ports_by_repo: resolveSlotPortsByRepo,
281
297
  resolve_default_extension_ports: resolveDefaultExtensionPorts,
298
+ resolve_extension_identity_ports: (repo) => formatExtensionIdentityPorts(repo) || null,
282
299
  resolve_extension_runtime_context: resolveExtensionRuntimeContext,
283
300
  resolve_extension_slot_ports: (repo) => formatExtensionSlotPorts(repo) || null,
284
301
  resolve_extension_runtime_ports: (repo) => resolveExtensionRuntimePorts(repo) || null,
@@ -20,6 +20,11 @@ resolve_slot_ports_by_repo() {
20
20
  _resolve_ports_cli resolve_slot_ports_by_repo "$1"
21
21
  }
22
22
 
23
+ # Back-compat alias for the previous function name (one release).
24
+ resolve_farmslot_ports_by_repo() {
25
+ resolve_slot_ports_by_repo "$1"
26
+ }
27
+
23
28
  infer_extension_slot_suffix() {
24
29
  local repo="$1" base
25
30
  base="$(basename "$repo")"
@@ -47,12 +52,13 @@ apply_extension_slot_ports() {
47
52
  local repo="${1:-.}" line key val resolved="" ctx_file="" runtime_rel="${RECIPE_RUNTIME_DIR:-temp/recipe/runtime}"
48
53
  repo="$(cd "$repo" && pwd)"
49
54
  ctx_file="$repo/$runtime_rel/agentic-runtime.json"
50
- if [ -f "$ctx_file" ]; then
51
- export RECIPE_RUNTIME_CONTEXT="$ctx_file"
52
- else
53
- unset RECIPE_RUNTIME_CONTEXT
55
+ if [ -z "${RECIPE_RUNTIME_CONTEXT:-}" ] && [ -f "$ctx_file" ]; then
56
+ RECIPE_RUNTIME_CONTEXT="$ctx_file"
54
57
  fi
55
- resolved="$(_resolve_ports_cli resolve_extension_slot_ports "$repo" 2>/dev/null)" || return 0
58
+ export RECIPE_RUNTIME_CONTEXT
59
+ # Context/pool are authoritative checkout identity. Formula ports are only
60
+ # fallback values and must not replace explicitly supplied env ports.
61
+ resolved="$(_resolve_ports_cli resolve_extension_identity_ports "$repo" 2>/dev/null)" || resolved=""
56
62
  while IFS= read -r line; do
57
63
  [ -n "$line" ] || continue
58
64
  key="${line%%=*}"
@@ -63,6 +69,17 @@ apply_extension_slot_ports() {
63
69
  SLOT_ID) RECIPE_SLOT_ID="$val" ;;
64
70
  esac
65
71
  done <<< "$resolved"
72
+ resolved="$(_resolve_ports_cli resolve_default_extension_ports "$repo" 2>/dev/null)" || return 0
73
+ while IFS= read -r line; do
74
+ [ -n "$line" ] || continue
75
+ key="${line%%=*}"
76
+ val="${line#*=}"
77
+ case "$key" in
78
+ CDP_PORT) [ -n "${CDP_PORT:-}" ] || CDP_PORT="$val" ;;
79
+ WATCHER_PORT) [ -n "${WATCHER_PORT:-}" ] || WATCHER_PORT="$val" ;;
80
+ SLOT_ID) [ -n "${RECIPE_SLOT_ID:-}" ] || RECIPE_SLOT_ID="$val" ;;
81
+ esac
82
+ done <<< "$resolved"
66
83
  }
67
84
 
68
85
  apply_resolved_extension_ports() {
package/bin/mm-harness CHANGED
@@ -5,6 +5,17 @@
5
5
  # end-state surface; it resolves node/tsx/dist the same way whether the package is
6
6
  # installed as a dependency or run from a source checkout.
7
7
  set -euo pipefail
8
+ INVOKED_AS="$0"
9
+ if [[ "$INVOKED_AS" == */* ]]; then
10
+ INVOKED_AS="$(cd "$(dirname "$INVOKED_AS")" && pwd -L)/${INVOKED_AS##*/}"
11
+ else
12
+ RESOLVED_INVOKED_AS="$(command -v "$INVOKED_AS" 2>/dev/null || true)"
13
+ if [[ -z "$RESOLVED_INVOKED_AS" ]]; then
14
+ echo "ERROR: Cannot resolve the invoked mm-harness executable: $INVOKED_AS" >&2
15
+ exit 1
16
+ fi
17
+ INVOKED_AS="$RESOLVED_INVOKED_AS"
18
+ fi
8
19
  SOURCE="${BASH_SOURCE[0]}"
9
20
  while [ -L "$SOURCE" ]; do
10
21
  SOURCE_DIR="$(cd "$(dirname "$SOURCE")" && pwd -P)"
@@ -26,7 +37,7 @@ RUNNER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
26
37
  # already points at this script — including via a symlink (both sides are resolved
27
38
  # to their real paths before comparing).
28
39
  if [ -n "${MM_HARNESS_BIN:-}" ]; then
29
- self_real="$SCRIPT_DIR/$(basename "$SOURCE")"
40
+ self_real="$SCRIPT_DIR/${SOURCE##*/}"
30
41
  # Resolve MM_HARNESS_BIN through any symlink chain so a symlink-to-self is caught.
31
42
  mm_bin_src="$MM_HARNESS_BIN"
32
43
  while [ -L "$mm_bin_src" ]; do
@@ -39,7 +50,7 @@ if [ -n "${MM_HARNESS_BIN:-}" ]; then
39
50
  fi
40
51
  done
41
52
  bin_real_dir="$(cd "$(dirname "$mm_bin_src")" 2>/dev/null && pwd -P || true)"
42
- bin_real="${bin_real_dir:+$bin_real_dir/$(basename "$mm_bin_src")}"
53
+ bin_real="${bin_real_dir:+$bin_real_dir/${mm_bin_src##*/}}"
43
54
  if [ "$bin_real" != "$self_real" ]; then
44
55
  if [ ! -x "$MM_HARNESS_BIN" ]; then
45
56
  echo "mm-harness: MM_HARNESS_BIN is not an executable: $MM_HARNESS_BIN" >&2
@@ -55,7 +66,8 @@ fi
55
66
  # mm-harness is not on PATH (task-local installs). Set AFTER the MM_HARNESS_BIN
56
67
  # delegation above so the final executable entered always overwrites any
57
68
  # inherited/stale value with its own resolved self path.
58
- export MM_HARNESS_EXECUTABLE="$SCRIPT_DIR/$(basename "$SOURCE")"
69
+ export MM_HARNESS_EXECUTABLE="$SCRIPT_DIR/${SOURCE##*/}"
70
+ export MM_HARNESS_INVOKED_AS="$INVOKED_AS"
59
71
 
60
72
  ENTRY_TS="$RUNNER_DIR/src/mm-harness-cli.ts"
61
73
  ENTRY_DIST="$RUNNER_DIR/dist/mm-harness-cli.js"
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
3
+ import { shellQuote } from "../../commands/parse-args.js";
3
4
  const coreSurface = {
4
5
  adapter: "core",
5
6
  headless: true,
@@ -14,6 +15,7 @@ const coreSurface = {
14
15
  reasons: [
15
16
  ready ? "Core is headless; dependencies are installed. Run recipes with mm-harness run." : "Core is headless; dependencies are not fully installed."
16
17
  ],
18
+ nextAction: ready ? void 0 : `cd ${shellQuote(path.resolve(target))} && yarn install --immutable`,
17
19
  deps: deps.status
18
20
  };
19
21
  },
@@ -49,7 +51,8 @@ const coreSurface = {
49
51
  },
50
52
  hints: {
51
53
  launch: "mm-harness run <recipe> # run recipes against the headless core",
52
- relaunch: "mm-harness verify"
54
+ relaunch: "mm-harness verify",
55
+ runtimeProbeRecovery: (target) => `mm-harness verify --adapter core --target ${shellQuote(path.resolve(target))}`
53
56
  }
54
57
  };
55
58
  export {
@@ -5,7 +5,9 @@ import { recipeRuntimePath, runnerDir } from "../../paths.js";
5
5
  async function ensureExtensionConsoleCapture(target) {
6
6
  const cdpPort = process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
7
7
  if (!cdpPort) throw new Error("Extension console capture requires a resolved CDP port.");
8
- const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
8
+ const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
9
+ signal: AbortSignal.timeout(3e3)
10
+ });
9
11
  if (!response.ok) throw new Error(`Extension CDP ${cdpPort} is not ready for console capture.`);
10
12
  const extensionLog = recipeRuntimePath(target, "extension-console.log");
11
13
  const dappLog = recipeRuntimePath(target, "dapp-console.log");
@@ -0,0 +1,39 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { recipeHarnessPath, runnerDir } from "../../paths.js";
6
+ async function ensureExtensionHarnessFresh(target) {
7
+ if (!fs.existsSync(path.join(target, "package.json"))) return;
8
+ let current;
9
+ try {
10
+ const mod = await import(pathToFileURL(path.join(runnerDir, "adapters/shared/harness-source-fingerprint.mjs")).href);
11
+ current = mod.harnessSourceFingerprint(runnerDir);
12
+ } catch {
13
+ return;
14
+ }
15
+ const manifestPath = recipeHarnessPath(target, "extension", "manifest.json");
16
+ const installed = readInstalledFingerprint(manifestPath);
17
+ if (installed === current) return;
18
+ const reason = installed === null ? "harness not installed" : "runner changed";
19
+ try {
20
+ execFileSync(process.execPath, [path.join(runnerDir, "adapters/extension/inject.mjs"), "--target", target], {
21
+ stdio: ["ignore", "ignore", "inherit"]
22
+ });
23
+ console.error(`\u2192 refreshed extension harness overlay (${reason})`);
24
+ } catch (error) {
25
+ const detail = error instanceof Error ? `: ${error.message}` : "";
26
+ throw new Error(`could not refresh the extension harness overlay (${reason})${detail}`);
27
+ }
28
+ }
29
+ function readInstalledFingerprint(manifestPath) {
30
+ try {
31
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
32
+ return typeof manifest.sourceFingerprint === "string" ? manifest.sourceFingerprint : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ export {
38
+ ensureExtensionHarnessFresh
39
+ };