@deeeed/metamask-harness 0.43.0 → 0.44.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.44.0 - 2026-08-25
6
+
7
+ ### Fixed
8
+
9
+ - Remove `cold_disk_cache` from the `perps.performance` lifecycle enum because disk hydration is a cache source within a lifecycle, not a standalone lifecycle.
10
+ - Await promise-returning Mobile `press-test-id` and `set-input` bridge handlers so recipe nodes finish only after the in-app operation settles.
11
+ - Keep Mobile broker commands available while Hermes installs the in-app bridge after a target handoff, instead of reconnecting the inspector until actions time out.
12
+ - Honor `launch --build` when a healthy Mobile runtime is already connected; the explicit native build can no longer return a false pass without invoking the build leaf.
13
+ - Let resident Homepage and short-resume performance recipes finish from the ordered visible-content sequence without requiring a new live takeover.
14
+ - Make Mobile reload stay on the pinned device, fail before an active Android Hermes sampler can abort the app, and fail if the process exits or a replacement target never appears; reload never relaunches the app.
15
+ - Bind run evidence to the executable Harness source, build, bin, package, library, and adapter inputs plus task-local command/config helpers, and reject input drift or symlink swaps.
16
+ - Persist recovery and mutation lists in recipe packages, include a redacted run-scoped application log, reject failed recovery before retrying, and prove lifecycle continuity with native process IDs, including opaque runtimes when requested.
17
+ - Refuse stale Mobile source under every heal policy unless an authored `app.lifecycle restart` reloads it, and invalidate run evidence if recipe, library, or product provenance drifts after preparation.
18
+ - Require Homepage demand, committed UI, resolved content, and live-current frame records before the canonical Mobile Perps performance recipe can pass.
19
+ - Fail a Mobile recipe or action without implicitly restarting Metro or the app; process restarts remain explicit lifecycle actions.
20
+ - Keep one Mobile inspector connection open while React Native replays its console buffer, avoiding repeated `Runtime.enable` requests that can stall Metro after unlock.
21
+ - Allow `metamask.wallet.lock` to use the same visible Android controls on development and opaque clients.
22
+ - Keep account-selection proof attached across the temporary Hermes/CDP stall caused by the account switch itself.
23
+ - Add `metamask.perps.ensure_mode` so recipes can reach Lite or Pro through the visible mode control without inheriting prior device state.
24
+ - Preserve Homepage boundary source and content-variant metadata in performance summaries, and bind recipe manifests to the exact product checkout commit.
25
+ - Accept only structured, generation-coherent Perps live-stream records as performance proof, and keep Mobile wallet and mode probes inside one absolute action deadline.
26
+ - Keep the latest automatic Mobile HUD update in the CDP broker and retry it until the in-app bridge applies it when the pinned Hermes target returns, without restarting the app.
27
+ - Keep the Perps performance recipe's empty-account setup read-only and allow its setup-only prime capture to contain no performance records.
28
+ - Redact quoted JSON credential fields before persisting run-scoped application logs.
29
+
5
30
  ## 0.43.0 - 2026-08-21
6
31
 
7
32
  ### Added
@@ -18,6 +18,7 @@
18
18
 
19
19
  const fs = require('node:fs');
20
20
  const path = require('node:path');
21
+ const { execFileSync } = require('node:child_process');
21
22
  const { loadPort } = require('./lib/config.cjs');
22
23
  const {
23
24
  discoverTarget,
@@ -36,6 +37,126 @@ const {
36
37
  } = require('./lib/bridge-errors.cjs');
37
38
  const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
38
39
  const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
40
+ const APPLY_HUD_UPDATE_FUNCTION = `function(step) {
41
+ const bridge = this.__AGENTIC__;
42
+ if (step === null) {
43
+ if (typeof bridge?.hideStep !== 'function') return false;
44
+ setTimeout(() => bridge.hideStep(), 0);
45
+ return true;
46
+ }
47
+ if (typeof bridge?.showStep !== 'function') return false;
48
+ setTimeout(() => bridge.showStep(step), 0);
49
+ return true;
50
+ }`;
51
+
52
+ function mobileToolPath(tool) {
53
+ return process.env[`MM_HARNESS_${tool.toUpperCase()}_PATH`] || tool;
54
+ }
55
+
56
+ function bootedIosDevice(deviceName) {
57
+ if (!deviceName) return null;
58
+ try {
59
+ const devices = JSON.parse(
60
+ execFileSync('xcrun', ['simctl', 'list', 'devices', 'available', '-j'], {
61
+ encoding: 'utf8',
62
+ }),
63
+ );
64
+ const device = Object.values(devices.devices || {})
65
+ .flat()
66
+ .find(
67
+ (candidate) =>
68
+ (candidate?.name === deviceName || candidate?.udid === deviceName) &&
69
+ candidate?.state === 'Booted',
70
+ );
71
+ return device?.udid ? device : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function tapVisibleIosAccessibilityTarget(testId, deviceName, platform) {
78
+ if (platform !== 'ios') return null;
79
+ const device = bootedIosDevice(deviceName);
80
+ if (!device) return null;
81
+ try {
82
+ const idb = mobileToolPath('idb');
83
+ const elements = JSON.parse(
84
+ execFileSync(
85
+ idb,
86
+ ['ui', 'describe-all', '--udid', device.udid, '--json'],
87
+ { encoding: 'utf8' },
88
+ ),
89
+ );
90
+ const windowFrame = elements
91
+ .map((element) => element?.frame)
92
+ .filter(
93
+ (frame) =>
94
+ frame &&
95
+ frame.x === 0 &&
96
+ frame.y === 0 &&
97
+ frame.width > 0 &&
98
+ frame.height > 0,
99
+ )
100
+ .sort(
101
+ (first, second) =>
102
+ second.width * second.height - first.width * first.height,
103
+ )[0];
104
+ if (!windowFrame) return null;
105
+ const target = elements.find((element) => {
106
+ const frame = element?.frame;
107
+ return (
108
+ element?.AXUniqueId === testId &&
109
+ element?.enabled !== false &&
110
+ element?.hittable !== false &&
111
+ frame &&
112
+ frame.width > 0 &&
113
+ frame.height > 0 &&
114
+ frame.x >= 0 &&
115
+ frame.y >= 0 &&
116
+ frame.x + frame.width <= windowFrame.width &&
117
+ frame.y + frame.height <= windowFrame.height
118
+ );
119
+ });
120
+ if (!target) return null;
121
+ const x = Math.round(target.frame.x + target.frame.width / 2);
122
+ const y = Math.round(target.frame.y + target.frame.height / 2);
123
+ execFileSync(
124
+ idb,
125
+ ['ui', 'tap', String(x), String(y), '--udid', device.udid],
126
+ { encoding: 'utf8' },
127
+ );
128
+ return { ok: true, testId, deviceName, provider: 'idb-accessibility' };
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ async function applyHudUpdate(client, step) {
135
+ const globalObject = await client.send('Runtime.evaluate', {
136
+ expression: 'globalThis',
137
+ returnByValue: false,
138
+ awaitPromise: false,
139
+ });
140
+ const objectId = globalObject?.result?.objectId;
141
+ if (!objectId) {
142
+ throw new Error('Mobile HUD runtime did not expose globalThis');
143
+ }
144
+ const result = await client.send('Runtime.callFunctionOn', {
145
+ objectId,
146
+ functionDeclaration: APPLY_HUD_UPDATE_FUNCTION,
147
+ arguments: [{ value: step }],
148
+ returnByValue: true,
149
+ awaitPromise: false,
150
+ });
151
+ if (result?.exceptionDetails) {
152
+ throw new Error(
153
+ result.exceptionDetails.exception?.description ||
154
+ result.exceptionDetails.text ||
155
+ 'Mobile HUD update failed',
156
+ );
157
+ }
158
+ return result?.result?.value === true;
159
+ }
39
160
 
40
161
  function parsePerformanceConsoleEvent(params) {
41
162
  const markers = ['[PerpsPerf] ', '[HomepagePerf] '];
@@ -97,7 +218,7 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
97
218
  throw new Error('Usage: set-input <testId> <value>');
98
219
  }
99
220
  const expr = `(function() {
100
- if (globalThis.__AGENTIC__?.setInput) return globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)});
221
+ if (globalThis.__AGENTIC__?.setInput) return Promise.resolve(globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)}));
101
222
  var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
102
223
  if (!hook) return { ok: false, error: 'No React DevTools hook' };
103
224
  var renderers = hook.renderers;
@@ -133,7 +254,7 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
133
254
  })()`;
134
255
  let result;
135
256
  try {
136
- result = await cdpEval(client, expr);
257
+ result = await cdpEvalAsync(client, expr);
137
258
  } catch (error) {
138
259
  if (redact) {
139
260
  throw new Error(`Secret input could not be applied for testID ${testId}.`);
@@ -151,6 +272,26 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
151
272
  };
152
273
  }
153
274
 
275
+ function parseHudStep(args) {
276
+ const raw = args.join(' ');
277
+ let step;
278
+ try {
279
+ step = JSON.parse(raw);
280
+ } catch (error) {
281
+ throw new Error(`show-step-json requires a JSON step payload: ${error.message}`);
282
+ }
283
+ if (!step || typeof step !== 'object' || Array.isArray(step)) {
284
+ throw new Error('show-step-json requires a JSON object step payload');
285
+ }
286
+ if (typeof step.intent !== 'string' || !step.intent.trim()) {
287
+ throw new Error('show-step-json requires step.intent');
288
+ }
289
+ if (step.id !== undefined && typeof step.id !== 'string') {
290
+ throw new Error('show-step-json step.id must be a string when provided');
291
+ }
292
+ return step;
293
+ }
294
+
154
295
  const COMMANDS = {
155
296
  async navigate(client, args, { deviceName, platform } = {}) {
156
297
  const routeName = ROUTE_ALIASES[args[0]] || args[0];
@@ -300,14 +441,15 @@ const COMMANDS = {
300
441
  return await cdpEval(client, `globalThis.__AGENTIC__?.switchAccount(${JSON.stringify(address)})`);
301
442
  },
302
443
 
303
- async 'press-test-id'(client, args, { deviceName } = {}) {
444
+ async 'press-test-id'(client, args, { deviceName, platform } = {}) {
304
445
  const testId = args[0];
305
446
  if (!testId) {
306
447
  throw new Error('Usage: press-test-id <testId>');
307
448
  }
308
- // Try __AGENTIC__ bridge first, fall back to inline fiber walking
449
+ // Try the app bridge first. Native accessibility remains an iOS fallback
450
+ // for controls whose React handler cannot be invoked directly.
309
451
  const expr = `(function() {
310
- if (globalThis.__AGENTIC__?.pressTestId) return globalThis.__AGENTIC__.pressTestId(${JSON.stringify(testId)});
452
+ if (globalThis.__AGENTIC__?.pressTestId) return Promise.resolve(globalThis.__AGENTIC__.pressTestId(${JSON.stringify(testId)}));
311
453
  var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
312
454
  if (!hook) return { ok: false, error: 'No React DevTools hook' };
313
455
  var renderers = hook.renderers;
@@ -330,10 +472,72 @@ const COMMANDS = {
330
472
  }
331
473
  return { ok: false, error: 'No component with testID=' + ${JSON.stringify(testId)} + ' found or no onPress' };
332
474
  })()`;
333
- const result = await cdpEval(client, expr);
475
+ let result;
476
+ let bridgeError;
477
+ try {
478
+ result = await cdpEvalAsync(client, expr);
479
+ if (result?.ok !== false) {
480
+ return { ...result, testId, deviceName };
481
+ }
482
+ } catch (error) {
483
+ bridgeError = error;
484
+ }
485
+ const nativeResult = tapVisibleIosAccessibilityTarget(
486
+ testId,
487
+ deviceName,
488
+ platform,
489
+ );
490
+ if (nativeResult) return nativeResult;
491
+ if (bridgeError) throw bridgeError;
334
492
  return { ...result, testId, deviceName };
335
493
  },
336
494
 
495
+ async 'key-press'(_client, args, { deviceName, platform } = {}) {
496
+ const requestedKey = String(args[0] || 'Enter').toLowerCase();
497
+ const isEnter = requestedKey === 'enter' || requestedKey === 'return';
498
+ const isBack = requestedKey === 'escape' || requestedKey === 'back';
499
+ if (!isEnter && !isBack) {
500
+ throw new Error(`Unsupported Mobile key ${JSON.stringify(args[0])}`);
501
+ }
502
+ if (platform === 'android') {
503
+ const serial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL;
504
+ if (!serial) throw new Error('key-press requires a selected Android device');
505
+ const keyCode = isEnter ? '66' : '4';
506
+ execFileSync(
507
+ mobileToolPath('adb'),
508
+ ['-s', serial, 'shell', 'input', 'keyevent', keyCode],
509
+ { encoding: 'utf8' },
510
+ );
511
+ return {
512
+ ok: true,
513
+ key: args[0] || 'Enter',
514
+ keyCode,
515
+ deviceName,
516
+ provider: 'adb-key',
517
+ };
518
+ }
519
+ if (platform !== 'ios') {
520
+ throw new Error(`key-press requires a selected Mobile platform`);
521
+ }
522
+ const device = bootedIosDevice(deviceName);
523
+ if (!device) {
524
+ throw new Error(`No booted simulator named ${JSON.stringify(deviceName)}`);
525
+ }
526
+ const keyCode = isEnter ? '40' : '41';
527
+ execFileSync(
528
+ mobileToolPath('idb'),
529
+ ['ui', 'key', keyCode, '--udid', device.udid],
530
+ { encoding: 'utf8' },
531
+ );
532
+ return {
533
+ ok: true,
534
+ key: args[0] || 'Enter',
535
+ keyCode,
536
+ deviceName,
537
+ provider: 'idb-key',
538
+ };
539
+ },
540
+
337
541
  async 'long-press-test-id'(client, args, { deviceName } = {}) {
338
542
  const testId = args[0];
339
543
  if (!testId) {
@@ -406,6 +610,9 @@ const COMMANDS = {
406
610
  // selector fiber. For into-view requests the scroll container is commonly
407
611
  // an ancestor (for example, a Wallet Home section row), so use the
408
612
  // ancestor-aware bridge fallback below instead.
613
+ if (${intoView} && globalThis.__AGENTIC__?.scrollIntoView) {
614
+ return globalThis.__AGENTIC__.scrollIntoView(${JSON.stringify(testId)}, ${animated});
615
+ }
409
616
  if (!${intoView} && globalThis.__AGENTIC__?.scrollView) return globalThis.__AGENTIC__.scrollView(${optsJson});
410
617
  var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
411
618
  if (!hook) return { ok: false, error: 'No React DevTools hook' };
@@ -427,14 +634,56 @@ const COMMANDS = {
427
634
  }
428
635
  return false;
429
636
  }
637
+ function findMeasurable(fiber) {
638
+ if (!fiber) return null;
639
+ var sn = fiber.stateNode;
640
+ if (sn && typeof sn.measureLayout === 'function') return sn;
641
+ return findMeasurable(fiber.child);
642
+ }
430
643
  function tryScrollNear(anchor) {
431
644
  if (!opts.intoView && tryScroll(anchor, false)) return true;
645
+ var target = opts.intoView ? findMeasurable(anchor) : null;
432
646
  var current = anchor ? anchor.return : null;
433
647
  while (current) {
434
648
  var sn = current.stateNode;
649
+ var currentProps = current.memoizedProps;
435
650
  if (sn) {
436
- if (typeof sn.scrollTo === 'function') { sn.scrollTo({ y: opts.offset, animated: opts.animated }); return true; }
437
- if (typeof sn.scrollToOffset === 'function') { sn.scrollToOffset({ offset: opts.offset, animated: opts.animated }); return true; }
651
+ if (typeof sn.scrollTo === 'function' && !(currentProps && currentProps.horizontal === true)) {
652
+ if (target) {
653
+ return new Promise(function(resolve) {
654
+ target.measureLayout(
655
+ sn,
656
+ function(x, y) {
657
+ sn.scrollTo({ y: y, animated: opts.animated });
658
+ resolve({ ok: true, testId: opts.testId, measuredOffset: y, animated: opts.animated });
659
+ },
660
+ function() {
661
+ resolve({ ok: false, error: 'Unable to measure testID=' + opts.testId + ' relative to its scroll container' });
662
+ }
663
+ );
664
+ });
665
+ }
666
+ sn.scrollTo({ y: opts.offset, animated: opts.animated });
667
+ return true;
668
+ }
669
+ if (typeof sn.scrollToOffset === 'function' && !(currentProps && currentProps.horizontal === true)) {
670
+ if (target) {
671
+ return new Promise(function(resolve) {
672
+ target.measureLayout(
673
+ sn,
674
+ function(x, y) {
675
+ sn.scrollToOffset({ offset: y, animated: opts.animated });
676
+ resolve({ ok: true, testId: opts.testId, measuredOffset: y, animated: opts.animated });
677
+ },
678
+ function() {
679
+ resolve({ ok: false, error: 'Unable to measure testID=' + opts.testId + ' relative to its scroll container' });
680
+ }
681
+ );
682
+ });
683
+ }
684
+ sn.scrollToOffset({ offset: opts.offset, animated: opts.animated });
685
+ return true;
686
+ }
438
687
  }
439
688
  current = current.return;
440
689
  }
@@ -443,7 +692,23 @@ const COMMANDS = {
443
692
  function findTestId(fiber) {
444
693
  if (!fiber) return null;
445
694
  var props = fiber.memoizedProps;
446
- if (props && props.testID === opts.testId) return fiber;
695
+ if (props && props.testID === opts.testId) {
696
+ var ancestor = fiber;
697
+ var inactive = false;
698
+ while (ancestor) {
699
+ var ancestorProps = ancestor.memoizedProps;
700
+ var ancestorStyle = ancestorProps && ancestorProps.style;
701
+ if (
702
+ (ancestorProps && ancestorProps.activityState === 0) ||
703
+ (ancestorStyle && ancestorStyle.display === 'none')
704
+ ) {
705
+ inactive = true;
706
+ break;
707
+ }
708
+ ancestor = ancestor.return;
709
+ }
710
+ if (!inactive) return fiber;
711
+ }
447
712
  return findTestId(fiber.child) || findTestId(fiber.sibling);
448
713
  }
449
714
  for (var [id] of renderers) {
@@ -459,11 +724,16 @@ const COMMANDS = {
459
724
  scrolled = tryScroll(r.current);
460
725
  }
461
726
  });
462
- if (scrolled) return { ok: true, testId: opts.testId, offset: opts.offset, animated: opts.animated };
727
+ if (scrolled) {
728
+ if (typeof scrolled.then === 'function') return scrolled;
729
+ return { ok: true, testId: opts.testId, offset: opts.offset, animated: opts.animated };
730
+ }
463
731
  }
464
732
  return { ok: false, error: opts.testId ? 'No scrollable near testID=' + opts.testId : 'No scrollable found' };
465
733
  })()`;
466
- const result = await cdpEval(client, expr);
734
+ const result = intoView
735
+ ? await cdpEvalAsync(client, expr)
736
+ : await cdpEval(client, expr);
467
737
  return { ...result, deviceName };
468
738
  },
469
739
 
@@ -841,34 +1111,33 @@ const COMMANDS = {
841
1111
  },
842
1112
 
843
1113
  async 'show-step-json'(client, args) {
844
- const raw = args.join(' ');
845
- let step;
846
- try {
847
- step = JSON.parse(raw);
848
- } catch (error) {
849
- throw new Error(`show-step-json requires a JSON step payload: ${error.message}`);
850
- }
851
- if (!step || typeof step !== 'object' || Array.isArray(step)) {
852
- throw new Error('show-step-json requires a JSON object step payload');
853
- }
854
- if (typeof step.intent !== 'string' || !step.intent.trim()) {
855
- throw new Error('show-step-json requires step.intent');
856
- }
857
- // HUD derives status/progress from step.id when status is omitted; a
858
- // non-string id makes statusForStep throw during render. Reject at the boundary.
859
- if (step.id !== undefined && typeof step.id !== 'string') {
860
- throw new Error('show-step-json step.id must be a string when provided');
861
- }
862
- const payload = JSON.stringify(step);
863
- await cdpEval(client, `globalThis.__AGENTIC__?.showStep && globalThis.__AGENTIC__.showStep(${payload})`);
1114
+ const step = parseHudStep(args);
1115
+ await applyHudUpdate(client, step);
864
1116
  return { ok: true };
865
1117
  },
866
1118
 
1119
+ async 'show-step-json-deferred'(client, args) {
1120
+ const step = parseHudStep(args);
1121
+ if (typeof client.control === 'function') {
1122
+ return client.control('hud-update', { step });
1123
+ }
1124
+ await applyHudUpdate(client, step);
1125
+ return { ok: true, status: 'applied' };
1126
+ },
1127
+
867
1128
  async 'hide-step'(client) {
868
- await cdpEval(client, `globalThis.__AGENTIC__?.hideStep && globalThis.__AGENTIC__.hideStep()`);
1129
+ await applyHudUpdate(client, null);
869
1130
  return { ok: true };
870
1131
  },
871
1132
 
1133
+ async 'hide-step-deferred'(client) {
1134
+ if (typeof client.control === 'function') {
1135
+ return client.control('hud-update', { step: null });
1136
+ }
1137
+ await applyHudUpdate(client, null);
1138
+ return { ok: true, status: 'applied' };
1139
+ },
1140
+
872
1141
  async 'profiler-start'(client) {
873
1142
  // Hermes CDP exposes the sampling profiler via the Profiler domain.
874
1143
  // Output of Profiler.stop is a Chrome-compatible .cpuprofile object.
@@ -1170,8 +1439,9 @@ Environment:
1170
1439
  const client = await clientFor(wsUrl);
1171
1440
 
1172
1441
  try {
1173
- // Detect platform from the running app (exposed by __AGENTIC__ bridge as Platform.OS)
1174
- const platform = await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
1442
+ const platform = command.endsWith('-deferred')
1443
+ ? ''
1444
+ : await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
1175
1445
  const result = await handler(client, args.slice(1), { deviceName, platform });
1176
1446
  console.log(JSON.stringify(result, null, 2));
1177
1447
  } finally {
@@ -40,6 +40,7 @@ const { resolvePort } = require('./lib/config.cjs');
40
40
  // Hermes uses Node's built-in WebSocket client. The local DevTools proxy uses
41
41
  // the package's pinned `ws` server implementation.
42
42
  const HANDSHAKE_TIMEOUT_MS = 3000;
43
+ const RUNTIME_ENABLE_TIMEOUT_MS = 60_000;
43
44
 
44
45
  const DISCOVER_ACTIVE_MS = 1000; // a device is unattached — look for it quickly
45
46
  const DISCOVER_STEADY_MS = 10000; // all known targets attached — cheap liveness tick
@@ -311,25 +312,36 @@ function connect(target) {
311
312
  ws.addEventListener('open', async () => {
312
313
  clearTimeout(handshakeTimer);
313
314
  session.opened = true;
315
+ // A live inspector socket is enough for broker commands. Do not keep the
316
+ // broker unavailable while Runtime.enable replays a large console buffer or
317
+ // while __AGENTIC__ is being installed after a Hermes context handoff.
318
+ // Commands such as status already report agenticPresent=false and their
319
+ // callers retry against the same bounded action deadline.
320
+ session.brokerReady = true;
321
+ broker.onSessionOpen(deviceId);
322
+ devtoolsProxy.onSessionOpen(deviceId, session);
323
+ process.stderr.write(`console-forwarder: attached ${name}\n`);
314
324
  try {
315
- await sendCommand(session, 'Runtime.enable');
325
+ await sendCommand(
326
+ session,
327
+ 'Runtime.enable',
328
+ {},
329
+ RUNTIME_ENABLE_TIMEOUT_MS,
330
+ );
316
331
  const evaluation = await sendCommand(session, 'Runtime.evaluate', {
317
332
  expression: "typeof globalThis.__AGENTIC__ === 'object'",
318
333
  returnByValue: true,
319
334
  awaitPromise: false,
320
335
  });
321
336
  if (evaluation?.result?.value !== true) {
322
- throw new Error('CDP target does not expose __AGENTIC__');
337
+ process.stderr.write(
338
+ `console-forwarder: ${name} is waiting for __AGENTIC__\n`,
339
+ );
323
340
  }
324
- session.brokerReady = true;
325
- broker.onSessionOpen(deviceId);
326
- devtoolsProxy.onSessionOpen(deviceId, session);
327
- process.stderr.write(`console-forwarder: attached ${name}\n`);
328
341
  } catch (error) {
329
342
  process.stderr.write(
330
- `console-forwarder: rejected ${name}: ${String(error?.message || error).slice(0, 256)}\n`,
343
+ `console-forwarder: console bootstrap pending for ${name}: ${String(error?.message || error).slice(0, 256)}\n`,
331
344
  );
332
- ws.close();
333
345
  }
334
346
  });
335
347
  ws.addEventListener('message', (event) => {
@@ -14,12 +14,25 @@ const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
14
14
  const MAX_POST_DATA_BYTES = 64 * 1024;
15
15
  const MAX_REQUEST_FRAME_BYTES = 8 * 1024 * 1024;
16
16
  const MAX_RESPONSE_FRAME_BYTES = 16 * 1024 * 1024;
17
+ const MAX_HUD_STEP_BYTES = 16 * 1024;
18
+ const MAX_HUD_UPDATES = 64;
17
19
  const MAX_RETAINED_STRING_LENGTH = 256;
18
20
  const MAX_BROKER_TIMEOUT_MS = 60_000;
19
21
  const SENSITIVE_FIELD =
20
22
  /(?:address|authorization|cookie|key|password|secret|token|user|account)/iu;
21
23
  const SENSITIVE_VALUE =
22
24
  /(?:0x[a-f0-9]{40,}|[a-f0-9]{64,}|eyJ[a-z0-9_-]{20,}\.[a-z0-9_-]{20,})/iu;
25
+ const APPLY_HUD_UPDATE_FUNCTION = `function(step) {
26
+ const bridge = this.__AGENTIC__;
27
+ if (step === null) {
28
+ if (typeof bridge?.hideStep !== 'function') return false;
29
+ setTimeout(() => bridge.hideStep(), 0);
30
+ return true;
31
+ }
32
+ if (typeof bridge?.showStep !== 'function') return false;
33
+ setTimeout(() => bridge.showStep(step), 0);
34
+ return true;
35
+ }`;
23
36
 
24
37
  function brokerSocketPath(runtimeDir, endpointIdentity) {
25
38
  const runtimePath = path.resolve(
@@ -353,6 +366,9 @@ function createCdpBroker({
353
366
  }) {
354
367
  const captures = new Map();
355
368
  const completedCaptures = new Map();
369
+ const hudUpdates = new Map();
370
+ const appliedHudVersions = new Map();
371
+ const hudApplyChains = new Map();
356
372
  const clients = new Set();
357
373
  const subscriptions = new Map();
358
374
  const knownTargets = new Map(
@@ -482,6 +498,67 @@ function createCdpBroker({
482
498
  capturesFor(deviceId).delete(capture.id);
483
499
  }
484
500
 
501
+ async function applyHudUpdate(deviceId, timeoutMs, socket) {
502
+ if (!hudUpdates.has(deviceId)) return { status: 'none' };
503
+ const budgetMs = boundedTimeout(timeoutMs);
504
+ const deadline = Date.now() + budgetMs;
505
+ const session = await waitForSession(deviceId, budgetMs, socket);
506
+ const update = hudUpdates.get(deviceId);
507
+ if (!update) return { status: 'none' };
508
+ if (appliedHudVersions.get(deviceId) === update.version) {
509
+ return { status: 'applied', version: update.version };
510
+ }
511
+ let applied = false;
512
+ while (Date.now() < deadline) {
513
+ const globalObject = await sendCommand(
514
+ session,
515
+ 'Runtime.evaluate',
516
+ { expression: 'globalThis', returnByValue: false, awaitPromise: false },
517
+ Math.max(1, deadline - Date.now()),
518
+ );
519
+ const objectId = globalObject?.result?.objectId;
520
+ if (!objectId) {
521
+ throw new Error('Mobile HUD runtime did not expose globalThis');
522
+ }
523
+ const evaluation = await sendCommand(
524
+ session,
525
+ 'Runtime.callFunctionOn',
526
+ {
527
+ objectId,
528
+ functionDeclaration: APPLY_HUD_UPDATE_FUNCTION,
529
+ arguments: [{ value: update.step }],
530
+ returnByValue: true,
531
+ awaitPromise: false,
532
+ },
533
+ Math.max(1, deadline - Date.now()),
534
+ );
535
+ if (evaluation?.result?.value === true) {
536
+ applied = true;
537
+ break;
538
+ }
539
+ await new Promise((resolve) => setTimeout(resolve, 100));
540
+ }
541
+ if (!applied) {
542
+ throw new Error('Mobile HUD bridge was not installed before the update deadline');
543
+ }
544
+ if (hudUpdates.get(deviceId)?.version === update.version) {
545
+ appliedHudVersions.set(deviceId, update.version);
546
+ }
547
+ return { status: 'applied', version: update.version };
548
+ }
549
+
550
+ function scheduleHudUpdate(deviceId, timeoutMs, socket) {
551
+ const previous = hudApplyChains.get(deviceId) || Promise.resolve();
552
+ const next = previous
553
+ .catch(() => undefined)
554
+ .then(() => applyHudUpdate(deviceId, timeoutMs, socket));
555
+ hudApplyChains.set(deviceId, next);
556
+ void next.finally(() => {
557
+ if (hudApplyChains.get(deviceId) === next) hudApplyChains.delete(deviceId);
558
+ }).catch(() => undefined);
559
+ return next;
560
+ }
561
+
485
562
  function expireCapture(deviceId, capture) {
486
563
  if (capturesFor(deviceId).get(capture.id) !== capture) return;
487
564
  finishCapture(deviceId, capture, true);
@@ -539,6 +616,29 @@ function createCdpBroker({
539
616
  if (action === 'resolve-targets') {
540
617
  return resolveTargets(params, timeoutMs, socket);
541
618
  }
619
+ if (action === 'hud-update') {
620
+ const step = params.step ?? null;
621
+ if (step !== null && (typeof step !== 'object' || Array.isArray(step))) {
622
+ throw new Error('HUD update requires an object step or null');
623
+ }
624
+ if (Buffer.byteLength(JSON.stringify(step)) > MAX_HUD_STEP_BYTES) {
625
+ throw new Error('HUD update exceeds its bound');
626
+ }
627
+ const previousVersion = hudUpdates.get(deviceId)?.version || 0;
628
+ if (hudUpdates.has(deviceId)) {
629
+ hudUpdates.delete(deviceId);
630
+ }
631
+ while (hudUpdates.size >= MAX_HUD_UPDATES) {
632
+ const oldestDeviceId = hudUpdates.keys().next().value;
633
+ hudUpdates.delete(oldestDeviceId);
634
+ appliedHudVersions.delete(oldestDeviceId);
635
+ }
636
+ const version = previousVersion + 1;
637
+ hudUpdates.set(deviceId, { step, version });
638
+ requestDiscovery?.(deviceId);
639
+ void scheduleHudUpdate(deviceId, 10_000).catch(() => undefined);
640
+ return { status: 'queued', version };
641
+ }
542
642
  if (action === 'capture-start') {
543
643
  const capture = normalizeCapture(params);
544
644
  const deviceCaptures = capturesFor(deviceId);
@@ -726,11 +826,13 @@ function createCdpBroker({
726
826
  if (capture.reconnects > 0) capture.partial = true;
727
827
  void enableNetwork(deviceId, capture);
728
828
  }
829
+ void scheduleHudUpdate(deviceId, 10_000).catch(() => undefined);
729
830
  },
730
831
  onSessionClose(deviceId) {
731
832
  const previous = knownTargets.get(deviceId);
732
833
  if (previous) knownTargets.set(deviceId, { ...previous, ready: false });
733
834
  observedSessions.delete(deviceId);
835
+ appliedHudVersions.delete(deviceId);
734
836
  for (const capture of capturesFor(deviceId).values()) {
735
837
  capture.reconnects += 1;
736
838
  capture.partial = true;