@deeeed/metamask-harness 0.41.0 → 0.42.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 (44) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +7 -0
  3. package/adapters/manifest.json +25 -1
  4. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +70 -10
  5. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +115 -15
  6. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +752 -0
  7. package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +177 -0
  8. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +13 -3
  9. package/adapters/mobile/coalesce-metro-log.cjs +24 -0
  10. package/adapters/mobile/launch-metro.cjs +9 -8
  11. package/adapters/mobile/metro-log-generation.cjs +106 -0
  12. package/adapters/mobile/reload-app.mjs +67 -0
  13. package/adapters/mobile/start-console-forwarder.sh +17 -2
  14. package/adapters/mobile/start-metro.sh +23 -18
  15. package/adapters/mobile/stop-metro.sh +15 -7
  16. package/adapters/shared/open-debug.mjs +172 -2
  17. package/adapters/shared/reap-checkout-metros.sh +17 -0
  18. package/dist/adapters/extension/network-observer.js +300 -0
  19. package/dist/adapters/mobile/metro-env.js +0 -5
  20. package/dist/adapters/mobile/prepare.js +1 -3
  21. package/dist/adapters/mobile/runtime-decision.js +6 -30
  22. package/dist/adapters.js +14 -1
  23. package/dist/cli-commands.js +6 -3
  24. package/dist/cli.js +4 -0
  25. package/dist/command-contract.js +3 -0
  26. package/dist/commands/call.js +45 -20
  27. package/dist/commands/launch/index.js +25 -5
  28. package/dist/commands/reload.js +80 -0
  29. package/dist/commands/run.js +49 -22
  30. package/dist/mm-harness-cli.js +17 -1
  31. package/dist/network-observation.js +271 -0
  32. package/docs/NETWORK-CAPTURE.md +98 -0
  33. package/docs/QA.md +2 -0
  34. package/docs/RECIPES.md +10 -0
  35. package/library/actions/mobile/app/network_assert.mjs +14 -0
  36. package/library/actions/mobile/app/network_capture.mjs +72 -0
  37. package/library/actions/mobile/platform/bridge.mjs +7 -2
  38. package/library/actions/shared/app/network-artifact.mjs +10 -0
  39. package/library/actions/shared/app/network-assert.mjs +154 -0
  40. package/library/manifests/extension.action-manifest.json +88 -0
  41. package/library/manifests/mobile.action-manifest.json +107 -0
  42. package/library/recipes/mobile/perps/performance.recipe.json +11 -11
  43. package/package.json +1 -1
  44. package/scripts/completions.sh +2 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.42.0 - 2026-08-20
6
+
7
+ ### Added
8
+
9
+ - Add `app.network_capture` and `app.network_assert` for redacted Mobile HTTP request windows, indexed evidence, and assertions that preserve failed-run diagnostics.
10
+ - Support the same bounded network observation contract on Extension and automatically index safe request metadata with recipe-node boundaries for live Mobile and Extension runs.
11
+ - Add `mm-harness reload` for Metro-equivalent Mobile reloads and in-place Extension CDP refreshes.
12
+
13
+ ### Changed
14
+
15
+ - Route Mobile console forwarding and recipe bridge commands through the same debugger connection so Network capture can coexist with HUD and wallet/domain actions.
16
+ - Make the canonical Perps performance recipe close positions and orders outside timing for its market-only cohort instead of failing on inherited account state.
17
+
18
+ ### Fixed
19
+
20
+ - Pass Expo `--clear` only for an explicit `mm-harness launch --clear-metro`, bound default Metro concurrency to four workers, and remove a stopped checkout from Watchman's root set.
21
+ - Route React Native DevTools through the harness CDP broker so debugger use, recipe actions, and app reloads no longer evict one another.
22
+
23
+ ## 0.41.1 - 2026-08-19
24
+
25
+ ### Fixed
26
+
27
+ - Rotate each harness-owned Metro start into a run-scoped evidence generation, bound retained archives, coalesce repetitive bundle progress, and keep bundle-error recovery state inside one launch invocation.
28
+
5
29
  ## 0.41.0 - 2026-08-19
6
30
 
7
31
  ### Added
package/README.md CHANGED
@@ -87,6 +87,7 @@ mm-harness status
87
87
  mm-harness stop
88
88
  mm-harness logs
89
89
  mm-harness debug
90
+ mm-harness reload # Mobile Metro reload / Extension CDP refresh
90
91
  mm-harness fixtures set
91
92
 
92
93
  # Delete the current wallet, then reapply the canonical fixture from clean state.
@@ -109,6 +110,12 @@ mm-harness logs --source app # Mobile app
109
110
  mm-harness logs --source metro # Mobile bundler
110
111
  ```
111
112
 
113
+ Live Mobile and Extension recipes automatically index a bounded, redacted
114
+ `network/run-summary.json` with request timing and recipe-node boundaries. Use
115
+ explicit `app.network_capture` and `app.network_assert` nodes for filtered
116
+ windows and machine-checked request expectations. See
117
+ [Recipe-scoped network capture](docs/NETWORK-CAPTURE.md).
118
+
112
119
  ## Discover and prove
113
120
 
114
121
  ```bash
@@ -39,9 +39,25 @@
39
39
  "entry": "adapters/mobile/launch-metro.cjs",
40
40
  "kind": "node",
41
41
  "purpose": "Launch Metro detached from the invoking process group so the runtime survives command cleanup.",
42
- "inputs": "--target --port --log --pid-file [--workers] [--clear]",
42
+ "inputs": "--target --port --log --pid-file --build-env --runner [--workers] [--clear]",
43
43
  "outputs": "Metro PID/launch metadata and redirected log; exit 0/1/2"
44
44
  },
45
+ {
46
+ "id": "mobile/metro-log-generation",
47
+ "entry": "adapters/mobile/metro-log-generation.cjs",
48
+ "kind": "module",
49
+ "purpose": "Rotate one Metro generation, retain bounded archives, and publish launch evidence.",
50
+ "inputs": "runtime directory, Metro port, rotation reason; env MM_HARNESS_METRO_LOG_ARCHIVE_COUNT, MM_HARNESS_METRO_LOG_ARCHIVE_BYTES",
51
+ "outputs": "metro.log, archived logs, metro-generation.json"
52
+ },
53
+ {
54
+ "id": "mobile/coalesce-metro-log",
55
+ "entry": "adapters/mobile/coalesce-metro-log.cjs",
56
+ "kind": "module",
57
+ "purpose": "Coalesce repetitive Metro percentage progress while preserving diagnostics.",
58
+ "inputs": "Metro output on stdin",
59
+ "outputs": "errors, completion, and timestamped meaningful progress on stdout"
60
+ },
45
61
  {
46
62
  "id": "mobile/metro-config",
47
63
  "entry": "adapters/mobile/metro-config.cjs",
@@ -58,6 +74,14 @@
58
74
  "inputs": "--target --port; env WATCHER_PORT",
59
75
  "outputs": "progress on stderr; exit 0 stopped-or-nothing / 1 stop failed / 2 bad args"
60
76
  },
77
+ {
78
+ "id": "mobile/reload-app",
79
+ "entry": "adapters/mobile/reload-app.mjs",
80
+ "kind": "node",
81
+ "purpose": "Request an in-place React Native app reload through Metro's protocol-v2 message endpoint.",
82
+ "inputs": "--port --json; env WATCHER_PORT, METRO_PORT",
83
+ "outputs": "reload result on stdout; exit 0 requested / 1 unavailable or invalid input"
84
+ },
61
85
  {
62
86
  "id": "mobile/prewarm-bundle",
63
87
  "entry": "adapters/mobile/prewarm-bundle.sh",
@@ -19,8 +19,16 @@
19
19
  const fs = require('node:fs');
20
20
  const path = require('node:path');
21
21
  const { loadPort } = require('./lib/config.cjs');
22
- const { discoverTarget } = require('./lib/target-discovery.cjs');
22
+ const {
23
+ discoverTarget,
24
+ rankRuntimeCandidates,
25
+ } = require('./lib/target-discovery.cjs');
23
26
  const { createWSClient } = require('./lib/ws-client.cjs');
27
+ const {
28
+ brokerSocketPath,
29
+ createBrokerClient,
30
+ deviceIdFromUrl,
31
+ } = require('./lib/cdp-broker.cjs');
24
32
  const {
25
33
  EXIT_CODE_BY_ERROR_CODE,
26
34
  TEACHING_BY_ERROR_CODE,
@@ -924,6 +932,22 @@ const COMMANDS = {
924
932
  return result || { count: 0, entries: [] };
925
933
  },
926
934
 
935
+ async 'network-capture-start'(client, args) {
936
+ if (typeof client.control !== 'function') {
937
+ throw new Error('Network capture requires the CDP broker');
938
+ }
939
+ const options = JSON.parse(args[0] || '{}');
940
+ return client.control('capture-start', options);
941
+ },
942
+
943
+ async 'network-capture-end'(client, args) {
944
+ if (typeof client.control !== 'function') {
945
+ throw new Error('Network capture requires the CDP broker');
946
+ }
947
+ const options = JSON.parse(args[0] || '{}');
948
+ return client.control('capture-end', options);
949
+ },
950
+
927
951
  };
928
952
 
929
953
  // ---------------------------------------------------------------------------
@@ -937,6 +961,7 @@ const BRIDGE_LOCK_FILE = path.join(
937
961
  process.env.RECIPE_RUNTIME_DIR || path.join('temp', 'recipe', 'runtime'),
938
962
  'cdp-bridge.lock',
939
963
  );
964
+ const BROKER_SOCKET_FILE = brokerSocketPath(path.dirname(BRIDGE_LOCK_FILE));
940
965
  const BRIDGE_LOCK_OWNER_PID = /^\d+$/.test(
941
966
  process.env.CDP_BRIDGE_LOCK_OWNER_PID || '',
942
967
  )
@@ -1019,6 +1044,8 @@ Commands:
1019
1044
  issues-arm Install console/exception hooks that
1020
1045
  populate globalThis.__AGENTIC_ISSUES__
1021
1046
  issues-collect Snapshot + clear the in-app issue buffer
1047
+ network-capture-start <json> Start a broker-owned Network capture
1048
+ network-capture-end <json> End capture and return redacted summary
1022
1049
 
1023
1050
  Environment:
1024
1051
  WATCHER_PORT Metro port (default: 8081)
@@ -1035,24 +1062,54 @@ Environment:
1035
1062
  process.exit(1);
1036
1063
  }
1037
1064
 
1038
- // Only a real command takes the debugger slot; help/unknown paths above never
1039
- // suppress the console forwarder.
1040
- acquireBridgeLock();
1041
-
1042
1065
  const port = loadPort();
1043
1066
  const timeout = Number.parseInt(process.env.CDP_TIMEOUT || '5000', 10);
1067
+ const brokerAvailable = fs.existsSync(BROKER_SOCKET_FILE);
1068
+ if (!brokerAvailable) acquireBridgeLock();
1069
+
1070
+ async function brokerTargets() {
1071
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`);
1072
+ if (!response.ok) throw new Error(`Metro target listing returned ${response.status}`);
1073
+ const ranked = rankRuntimeCandidates(await response.json());
1074
+ const picked = new Set();
1075
+ return ranked.flatMap((target) => {
1076
+ const deviceId = deviceIdFromUrl(target.webSocketDebuggerUrl);
1077
+ if (picked.has(deviceId)) return [];
1078
+ picked.add(deviceId);
1079
+ return [{
1080
+ wsUrl: target.webSocketDebuggerUrl,
1081
+ deviceName: target.deviceName || '',
1082
+ }];
1083
+ });
1084
+ }
1085
+
1086
+ async function clientFor(wsUrl) {
1087
+ if (brokerAvailable) {
1088
+ return createBrokerClient(
1089
+ BROKER_SOCKET_FILE,
1090
+ deviceIdFromUrl(wsUrl),
1091
+ timeout,
1092
+ );
1093
+ }
1094
+ if (command.startsWith('network-capture-')) {
1095
+ throw new Error('Network capture requires a running CDP broker');
1096
+ }
1097
+ acquireBridgeLock();
1098
+ return createWSClient(wsUrl, timeout);
1099
+ }
1044
1100
 
1045
1101
  // `status` probes ALL connected targets so both platforms are visible.
1046
1102
  // `status-selected` falls through to the normal pinned discovery path for
1047
1103
  // action code that must survive one device's Hermes runtime rotation.
1048
1104
  if (command === 'status') {
1049
- const { discoverAllTargets } = require('./lib/target-discovery.cjs');
1050
- const allTargets = await discoverAllTargets(port);
1105
+ const allTargets = brokerAvailable
1106
+ ? await brokerTargets()
1107
+ : await require('./lib/target-discovery.cjs').discoverAllTargets(port);
1051
1108
  const results = [];
1052
1109
  for (const target of allTargets) {
1053
1110
  let client;
1054
1111
  try {
1055
- client = await createWSClient(target.wsUrl, timeout);
1112
+ client = await clientFor(target.wsUrl);
1056
1113
  const platform = await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
1057
1114
  const result = await handler(client, args.slice(1), { deviceName: target.deviceName, platform });
1058
1115
  results.push(result);
@@ -1068,8 +1125,11 @@ Environment:
1068
1125
  return;
1069
1126
  }
1070
1127
 
1071
- const { wsUrl, deviceName } = await discoverTarget(port);
1072
- const client = await createWSClient(wsUrl, timeout);
1128
+ const { wsUrl, deviceName } = await discoverTarget(port, {
1129
+ probe: !brokerAvailable,
1130
+ });
1131
+ if (!wsUrl) throw new Error('No broker-owned Hermes target is available');
1132
+ const client = await clientFor(wsUrl);
1073
1133
 
1074
1134
  try {
1075
1135
  // Detect platform from the running app (exposed by __AGENTIC__ bridge as Platform.OS)
@@ -29,9 +29,15 @@ const http = require('node:http');
29
29
  const path = require('node:path');
30
30
  const { rankRuntimeCandidates } = require('./lib/target-discovery.cjs');
31
31
  const { formatArgs: formatConsoleArgs } = require('./lib/console-format.cjs');
32
+ const {
33
+ brokerSocketPath,
34
+ createCdpBroker,
35
+ deviceIdFromUrl,
36
+ } = require('./lib/cdp-broker.cjs');
37
+ const { createDevtoolsProxy } = require('./lib/devtools-proxy.cjs');
32
38
 
33
- // Built-in WebSocket (Node 22+), same zero-dependency transport choice as
34
- // lib/ws-client.cjs — the published package must not depend on `ws`.
39
+ // Hermes uses Node's built-in WebSocket client. The local DevTools proxy uses
40
+ // the package's pinned `ws` server implementation.
35
41
  const HANDSHAKE_TIMEOUT_MS = 3000;
36
42
 
37
43
  const DISCOVER_ACTIVE_MS = 1000; // a device is unattached — look for it quickly
@@ -101,9 +107,10 @@ function bridgeLockActive() {
101
107
  }
102
108
 
103
109
  function yieldSessions() {
104
- for (const ws of sessions.values()) {
110
+ for (const session of sessions.values()) {
111
+ broker?.onSessionClose(session.deviceId);
105
112
  try {
106
- ws.close();
113
+ session.ws.close();
107
114
  } catch {}
108
115
  }
109
116
  sessions.clear();
@@ -170,8 +177,56 @@ function serializeState() {
170
177
  return JSON.stringify(state);
171
178
  }
172
179
 
173
- /** deviceId -> live WebSocket session. */
180
+ /** deviceId -> live WebSocket session state. */
174
181
  const sessions = new Map();
182
+ let broker = null;
183
+ let devtoolsProxy = null;
184
+
185
+ function sendCommand(session, method, params = {}, timeoutMs = 10_000) {
186
+ return new Promise((resolve, reject) => {
187
+ if (!session.opened) {
188
+ reject(new Error('CDP session is not open'));
189
+ return;
190
+ }
191
+ const id = ++session.nextId;
192
+ const timer = setTimeout(() => {
193
+ session.pending.delete(id);
194
+ reject(new Error(`CDP command timed out: ${method}`));
195
+ }, timeoutMs);
196
+ session.pending.set(id, {
197
+ resolve: (result) => {
198
+ clearTimeout(timer);
199
+ resolve(result);
200
+ },
201
+ reject: (error) => {
202
+ clearTimeout(timer);
203
+ reject(error);
204
+ },
205
+ });
206
+ session.ws.send(JSON.stringify({ id, method, params }));
207
+ });
208
+ }
209
+
210
+ broker = createCdpBroker({
211
+ socketPath: brokerSocketPath(path.dirname(out)),
212
+ sessions,
213
+ sendCommand,
214
+ requestDiscovery(deviceId) {
215
+ foreignDebuggerUntil.delete(deviceId);
216
+ discover();
217
+ },
218
+ onClientActivity: noteBridgeCoordination,
219
+ });
220
+ devtoolsProxy = createDevtoolsProxy({
221
+ descriptorPath: path.join(path.dirname(out), 'devtools-proxy.json'),
222
+ sessions,
223
+ sendCommand,
224
+ requestDiscovery: discover,
225
+ allowedOrigins: [
226
+ `http://127.0.0.1:${port}`,
227
+ `http://localhost:${port}`,
228
+ ],
229
+ });
175
230
 
176
231
  /** deviceId -> epoch ms until which a foreign debugger owns the slot. */
177
232
  const foreignDebuggerUntil = new Map();
@@ -226,11 +281,6 @@ function deviceNameFromTitle(title) {
226
281
  return m ? m[1] : (title || 'device');
227
282
  }
228
283
 
229
- function deviceIdFromUrl(wsUrl) {
230
- const m = /[?&]device=([^&]+)/.exec(wsUrl || '');
231
- return m ? m[1] : wsUrl;
232
- }
233
-
234
284
  function connect(target) {
235
285
  const deviceId = deviceIdFromUrl(target.webSocketDebuggerUrl);
236
286
  const name = deviceNameFromTitle(target.title);
@@ -240,7 +290,16 @@ function connect(target) {
240
290
  } catch {
241
291
  return;
242
292
  }
243
- sessions.set(deviceId, ws);
293
+ const session = {
294
+ deviceId,
295
+ name,
296
+ ws,
297
+ opened: false,
298
+ brokerReady: false,
299
+ nextId: 1,
300
+ pending: new Map(),
301
+ };
302
+ sessions.set(deviceId, session);
244
303
  // The built-in WebSocket has no handshake timeout; a hung connect would hold
245
304
  // the session slot forever and block every future re-attach for this device.
246
305
  const handshakeTimer = setTimeout(() => {
@@ -248,10 +307,29 @@ function connect(target) {
248
307
  ws.close();
249
308
  } catch {}
250
309
  }, HANDSHAKE_TIMEOUT_MS);
251
- ws.addEventListener('open', () => {
310
+ ws.addEventListener('open', async () => {
252
311
  clearTimeout(handshakeTimer);
253
- ws.send(JSON.stringify({ id: 1, method: 'Runtime.enable' }));
254
- process.stderr.write(`console-forwarder: attached ${name}\n`);
312
+ session.opened = true;
313
+ try {
314
+ await sendCommand(session, 'Runtime.enable');
315
+ const evaluation = await sendCommand(session, 'Runtime.evaluate', {
316
+ expression: "typeof globalThis.__AGENTIC__ === 'object'",
317
+ returnByValue: true,
318
+ awaitPromise: false,
319
+ });
320
+ if (evaluation?.result?.value !== true) {
321
+ throw new Error('CDP target does not expose __AGENTIC__');
322
+ }
323
+ session.brokerReady = true;
324
+ broker.onSessionOpen(deviceId);
325
+ devtoolsProxy.onSessionOpen(deviceId, session);
326
+ process.stderr.write(`console-forwarder: attached ${name}\n`);
327
+ } catch (error) {
328
+ process.stderr.write(
329
+ `console-forwarder: rejected ${name}: ${String(error?.message || error).slice(0, 256)}\n`,
330
+ );
331
+ ws.close();
332
+ }
255
333
  });
256
334
  ws.addEventListener('message', (event) => {
257
335
  let msg;
@@ -260,6 +338,17 @@ function connect(target) {
260
338
  } catch {
261
339
  return;
262
340
  }
341
+ if (msg.id && session.pending.has(msg.id)) {
342
+ const entry = session.pending.get(msg.id);
343
+ session.pending.delete(msg.id);
344
+ if (msg.error) entry.reject(new Error(JSON.stringify(msg.error)));
345
+ else entry.resolve(msg.result);
346
+ return;
347
+ }
348
+ if (msg.method) {
349
+ broker.onCdpEvent(deviceId, msg.method, msg.params || {});
350
+ devtoolsProxy.onCdpEvent(deviceId, msg.method, msg.params || {});
351
+ }
263
352
  if (msg.method !== 'Runtime.consoleAPICalled') return;
264
353
  const text = formatArgs(msg.params.args);
265
354
  // dev-middleware emits this NOTE on every debugger attach (i.e. ours). Drop it.
@@ -278,8 +367,15 @@ function connect(target) {
278
367
  });
279
368
  const drop = () => {
280
369
  clearTimeout(handshakeTimer);
281
- if (sessions.get(deviceId) === ws) {
370
+ session.opened = false;
371
+ session.brokerReady = false;
372
+ for (const entry of session.pending.values()) {
373
+ entry.reject(new Error('CDP session closed'));
374
+ }
375
+ session.pending.clear();
376
+ if (sessions.get(deviceId) === session) {
282
377
  sessions.delete(deviceId);
378
+ broker.onSessionClose(deviceId);
283
379
  process.stderr.write(`console-forwarder: detached ${name}; will re-attach\n`);
284
380
  }
285
381
  };
@@ -351,10 +447,14 @@ function schedule(ms) {
351
447
 
352
448
  process.on('SIGTERM', () => {
353
449
  flushSync();
450
+ devtoolsProxy.close();
451
+ broker.close();
354
452
  process.exit(0);
355
453
  });
356
454
  process.on('SIGINT', () => {
357
455
  flushSync();
456
+ devtoolsProxy.close();
457
+ broker.close();
358
458
  process.exit(0);
359
459
  });
360
460