@deeeed/metamask-harness 0.44.1 → 0.44.2

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,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.44.2 - 2026-08-27
6
+
7
+ ### Fixed
8
+
9
+ - Wait for a new React Native runtime after an authored Mobile restart, and avoid a second Android foreground launch when that runtime is already ready.
10
+ - Hold Mobile restart recovery inside one readiness deadline: the foreground relaunch and its subprocesses inherit the remaining budget, and an expired deadline fails the lifecycle action instead of starting another launch or readiness probe.
11
+ - Read the selected Mobile target identity and its broker generation for `status-selected` without attaching to CDP.
12
+
5
13
  ## 0.44.1 - 2026-08-27
6
14
 
7
15
  ### Fixed
@@ -30,9 +30,11 @@ const {
30
30
  deviceIdFromUrl,
31
31
  } = require('./lib/cdp-broker.cjs');
32
32
  const {
33
+ BRIDGE_ERROR_CODES,
33
34
  EXIT_CODE_BY_ERROR_CODE,
34
35
  TEACHING_BY_ERROR_CODE,
35
36
  classifyBridgeErrorMessage,
37
+ coded,
36
38
  formatErrorMarker,
37
39
  } = require('./lib/bridge-errors.cjs');
38
40
  const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
@@ -293,6 +295,9 @@ function parseHudStep(args) {
293
295
  }
294
296
 
295
297
  const COMMANDS = {
298
+ async 'target-identity-selected'() {
299
+ throw new Error('target-identity-selected is resolved before CDP attachment');
300
+ },
296
301
  async navigate(client, args, { deviceName, platform } = {}) {
297
302
  const routeName = ROUTE_ALIASES[args[0]] || args[0];
298
303
  if (!routeName) {
@@ -413,7 +418,11 @@ const COMMANDS = {
413
418
  return { currentRoute: route ?? null, deviceName, platform };
414
419
  },
415
420
 
416
- async status(client, _args, { deviceName, platform } = {}) {
421
+ async status(
422
+ client,
423
+ _args,
424
+ { deviceName, platform, runtimeIdentity } = {},
425
+ ) {
417
426
  const expr = `(function() {
418
427
  var agenticPresent = typeof globalThis.__AGENTIC__ !== 'undefined';
419
428
  var route = globalThis.__AGENTIC__?.getRoute() || null;
@@ -429,7 +438,12 @@ const COMMANDS = {
429
438
  };
430
439
  })()`;
431
440
  const snapshot = await cdpEval(client, expr);
432
- return { ...snapshot, deviceName: deviceName || '', platform: platform || '' };
441
+ return {
442
+ ...snapshot,
443
+ ...(runtimeIdentity ? { runtimeIdentity } : {}),
444
+ deviceName: deviceName || '',
445
+ platform: platform || '',
446
+ };
433
447
  },
434
448
 
435
449
  async 'list-accounts'(client) {
@@ -1359,7 +1373,10 @@ Environment:
1359
1373
  ).trim();
1360
1374
  }
1361
1375
 
1362
- async function brokerTargets({ retainIdentity = false } = {}) {
1376
+ async function brokerTargets({
1377
+ retainIdentity = false,
1378
+ includeGeneration = false,
1379
+ } = {}) {
1363
1380
  const discoveryClient = await createBrokerClient(
1364
1381
  brokerSocketFile,
1365
1382
  '',
@@ -1367,14 +1384,22 @@ Environment:
1367
1384
  );
1368
1385
  try {
1369
1386
  const targets = await discoveryClient.control(
1370
- retainIdentity ? 'resolve-targets' : 'list-targets',
1387
+ retainIdentity
1388
+ ? 'resolve-targets'
1389
+ : includeGeneration
1390
+ ? 'list-target-identities'
1391
+ : 'list-targets',
1371
1392
  retainIdentity ? { nameIncludes: selectedTargetPin() } : {},
1372
1393
  timeout,
1373
1394
  );
1374
1395
  return (Array.isArray(targets) ? targets : []).flatMap((target) => {
1375
1396
  const deviceId = String(target?.deviceId || '');
1376
1397
  return deviceId
1377
- ? [{ wsUrl: deviceId, deviceName: String(target?.name || '') }]
1398
+ ? [{
1399
+ wsUrl: deviceId,
1400
+ deviceName: String(target?.name || ''),
1401
+ generation: Number(target?.generation) || 0,
1402
+ }]
1378
1403
  : [];
1379
1404
  });
1380
1405
  } finally {
@@ -1390,13 +1415,31 @@ Environment:
1390
1415
  )
1391
1416
  : targets;
1392
1417
  if (candidates.length !== 1) {
1393
- throw new Error(
1418
+ const error = new Error(
1394
1419
  `Mobile CDP broker target selection requires one target; found ${candidates.length}${pin ? ` for ${JSON.stringify(pin)}` : ''}.`,
1395
1420
  );
1421
+ throw candidates.length === 0
1422
+ ? coded(error, BRIDGE_ERROR_CODES.NO_TARGET)
1423
+ : error;
1396
1424
  }
1397
1425
  return candidates[0];
1398
1426
  }
1399
1427
 
1428
+ if (command === 'target-identity-selected') {
1429
+ const target = brokerAvailable
1430
+ ? selectBrokerTarget(await brokerTargets({ includeGeneration: true }))
1431
+ : await discoverTarget(port, { probe: true });
1432
+ const generation = Number(target.generation) || 0;
1433
+ console.log(
1434
+ JSON.stringify({
1435
+ runtimeIdentity: generation
1436
+ ? `${target.wsUrl}:${generation}`
1437
+ : target.wsUrl,
1438
+ }),
1439
+ );
1440
+ return;
1441
+ }
1442
+
1400
1443
  async function clientFor(wsUrl) {
1401
1444
  if (brokerAvailable) {
1402
1445
  return createBrokerClient(
@@ -1439,9 +1482,10 @@ Environment:
1439
1482
  return;
1440
1483
  }
1441
1484
 
1442
- const { wsUrl, deviceName } = brokerAvailable
1485
+ const selectedTarget = brokerAvailable
1443
1486
  ? selectBrokerTarget(await brokerTargets({ retainIdentity: true }))
1444
1487
  : await discoverTarget(port, { probe: true });
1488
+ const { wsUrl, deviceName, generation } = selectedTarget;
1445
1489
  if (!wsUrl) throw new Error('No broker-owned Hermes target is available');
1446
1490
  const client = await clientFor(wsUrl);
1447
1491
 
@@ -1449,7 +1493,28 @@ Environment:
1449
1493
  const platform = command.endsWith('-deferred')
1450
1494
  ? ''
1451
1495
  : await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
1452
- const result = await handler(client, args.slice(1), { deviceName, platform });
1496
+ const runtimeIdentity =
1497
+ command === 'status-selected'
1498
+ ? generation
1499
+ ? `${wsUrl}:${generation}`
1500
+ : wsUrl
1501
+ : undefined;
1502
+ const result = await handler(client, args.slice(1), {
1503
+ deviceName,
1504
+ platform,
1505
+ runtimeIdentity,
1506
+ });
1507
+ if (brokerAvailable && command === 'status-selected') {
1508
+ const currentTarget = selectBrokerTarget(
1509
+ await brokerTargets({ includeGeneration: true }),
1510
+ );
1511
+ if (
1512
+ currentTarget.wsUrl !== wsUrl ||
1513
+ currentTarget.generation !== generation
1514
+ ) {
1515
+ throw new Error('Hermes runtime rotated during status-selected');
1516
+ }
1517
+ }
1453
1518
  console.log(JSON.stringify(result, null, 2));
1454
1519
  } finally {
1455
1520
  client.close();
@@ -59,6 +59,7 @@ const NEEDLE_CODES = [
59
59
  ['did not match any metro target', BRIDGE_ERROR_CODES.NO_TARGET],
60
60
  ['pinned android device', BRIDGE_ERROR_CODES.NO_TARGET],
61
61
  ['no react native bridge target', BRIDGE_ERROR_CODES.NO_TARGET],
62
+ ['cdp broker target unavailable', BRIDGE_ERROR_CODES.NO_TARGET],
62
63
  ['websocket closed', BRIDGE_ERROR_CODES.WS_CLOSED],
63
64
  ['websocket error', BRIDGE_ERROR_CODES.WS_CLOSED],
64
65
  ['cdp connection timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
@@ -613,6 +613,11 @@ function createCdpBroker({
613
613
  name,
614
614
  }));
615
615
  }
616
+ if (action === 'list-target-identities') {
617
+ return targetList({ readyOnly: true }).map(
618
+ ({ deviceId, generation, name }) => ({ deviceId, generation, name }),
619
+ );
620
+ }
616
621
  if (action === 'resolve-targets') {
617
622
  return resolveTargets(params, timeoutMs, socket);
618
623
  }
@@ -1,3 +1,56 @@
1
+ import { execFile } from "node:child_process";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { promisify } from "node:util";
4
+ const MOBILE_LIFECYCLE_DEADLINE_FIELD = "_mobile_lifecycle_deadline_epoch_ms";
5
+ const lifecycleDeadline = new AsyncLocalStorage();
6
+ const execFileAsync = promisify(execFile);
7
+ function bindMetaMaskMobileLifecycleDeadline(adapters) {
8
+ return adapters.map((adapter) => ({
9
+ ...adapter,
10
+ execute(node, context) {
11
+ const deadline = Number(node[MOBILE_LIFECYCLE_DEADLINE_FIELD]);
12
+ if (!Number.isFinite(deadline)) return adapter.execute(node, context);
13
+ return lifecycleDeadline.run(deadline, () => adapter.execute(node, context));
14
+ }
15
+ }));
16
+ }
17
+ function createMetaMaskMobileLifecycleCommandRunner(base = defaultLifecycleCommandRunner()) {
18
+ return {
19
+ execFile(file, args, options) {
20
+ const deadline = lifecycleDeadline.getStore();
21
+ if (deadline === void 0) return base.execFile(file, args, options);
22
+ const remainingMs = deadline - Date.now();
23
+ if (remainingMs <= 0) {
24
+ throw new Error(
25
+ `Mobile lifecycle deadline expired before ${file} could start.`
26
+ );
27
+ }
28
+ const configuredTimeoutMs = options?.timeoutMs;
29
+ return base.execFile(file, args, {
30
+ timeoutMs: configuredTimeoutMs === void 0 ? remainingMs : Math.min(configuredTimeoutMs, remainingMs)
31
+ });
32
+ }
33
+ };
34
+ }
35
+ function defaultLifecycleCommandRunner() {
36
+ return {
37
+ async execFile(file, args, options) {
38
+ try {
39
+ return await execFileAsync(file, args, {
40
+ timeout: options?.timeoutMs,
41
+ encoding: "utf8"
42
+ });
43
+ } catch (error) {
44
+ if (error instanceof Error) {
45
+ const output = error;
46
+ const details = [output.message, output.stderr, output.stdout].filter(Boolean).join("\n");
47
+ throw new Error(`${file} ${args.join(" ")} failed: ${details}`);
48
+ }
49
+ throw error;
50
+ }
51
+ }
52
+ };
53
+ }
1
54
  function resolveMetaMaskMobileLifecycleTarget(node, rawContext) {
2
55
  const context = rawContext;
3
56
  const platform = resolveMobilePlatform(node, context);
@@ -69,5 +122,8 @@ function scalar(value, label) {
69
122
  throw new Error(`${label} must be a string, number, or boolean.`);
70
123
  }
71
124
  export {
125
+ MOBILE_LIFECYCLE_DEADLINE_FIELD,
126
+ bindMetaMaskMobileLifecycleDeadline,
127
+ createMetaMaskMobileLifecycleCommandRunner,
72
128
  resolveMetaMaskMobileLifecycleTarget
73
129
  };
package/dist/runner.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { execFileSync, execSync } from "node:child_process";
2
2
  import { OFFICIAL_RECIPE_ACTIONS } from "@farmslot/protocol";
3
3
  import { createMetaMaskAdapters, createMetaMaskUiTransport } from "./adapters.js";
4
- import { bridgeCommand } from "../library/actions/mobile/platform/bridge.mjs";
4
+ import {
5
+ bridgeCommand,
6
+ MOBILE_BRIDGE_ERROR_CODES
7
+ } from "../library/actions/mobile/platform/bridge.mjs";
5
8
  import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
6
9
  import {
7
10
  mobileSourceFingerprint,
@@ -13,7 +16,12 @@ import {
13
16
  createAndroidVideoRecorder,
14
17
  createIosSimulatorVideoRecorder
15
18
  } from "./adapters/mobile/video-recorder.js";
16
- import { resolveMetaMaskMobileLifecycleTarget } from "./app-lifecycle.js";
19
+ import {
20
+ bindMetaMaskMobileLifecycleDeadline,
21
+ createMetaMaskMobileLifecycleCommandRunner,
22
+ MOBILE_LIFECYCLE_DEADLINE_FIELD,
23
+ resolveMetaMaskMobileLifecycleTarget
24
+ } from "./app-lifecycle.js";
17
25
  import { loadMetaMaskExtensionActionManifest, loadMetaMaskMobileActionManifest } from "./manifest.js";
18
26
  import { metaMaskActionExecutionCapabilities } from "./recipe-security.js";
19
27
  import {
@@ -84,10 +92,13 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
84
92
  const existing = new Set([...core, ...ui].map((entry) => entry.action));
85
93
  const lifecycle = mobileSourceAwareLifecycleAdapters(
86
94
  adapter,
87
- createAppLifecycleAdapters({
88
- actions,
89
- targetProvider: { resolveTarget: resolveMetaMaskMobileLifecycleTarget }
90
- })
95
+ bindMetaMaskMobileLifecycleDeadline(
96
+ createAppLifecycleAdapters({
97
+ actions,
98
+ commandRunner: createMetaMaskMobileLifecycleCommandRunner(),
99
+ targetProvider: { resolveTarget: resolveMetaMaskMobileLifecycleTarget }
100
+ })
101
+ )
91
102
  );
92
103
  const custom = createMetaMaskAdapters(
93
104
  adapter,
@@ -337,7 +348,7 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
337
348
  }, processIdentity = {
338
349
  readIosPid: readIosAppPid,
339
350
  readAndroidPid: readAndroidAppPid
340
- }) {
351
+ }, runtimeMarker = readMobileLifecycleRuntimeIdentity) {
341
352
  if (adapter !== "mobile") return lifecycle;
342
353
  return lifecycle.map((entry) => ({
343
354
  ...entry,
@@ -357,19 +368,62 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
357
368
  const readProcessId = androidLifecycle ? processIdentity.readAndroidPid ?? readAndroidAppPid : processIdentity.readIosPid;
358
369
  const processBefore = recordsProcessIdentity ? readProcessId(node, context) : void 0;
359
370
  const fingerprint = recordsSource ? sourceFreshness.fingerprint(context.projectRoot) : void 0;
371
+ const previousRuntimeIdentity = recordsSource ? await runtimeMarker(node, context) : void 0;
360
372
  const androidRestart = command === "restart" && androidLifecycle;
361
- const initialNode = androidRestart ? { ...node, settle_ms: 0 } : node;
362
- const initialResult = await entry.execute(initialNode, context);
373
+ const initialResult = await entry.execute(node, context);
374
+ const readinessDeadline = reloadsSource ? mobileLifecycleReadinessDeadline(command, node) : void 0;
363
375
  let result = initialResult;
376
+ let runtimeReady = false;
364
377
  if (androidRestart) {
365
- const foregroundResult = await entry.execute(
366
- { ...node, command: "foreground" },
367
- context
368
- );
369
- result = mergeAndroidRestartResults(initialResult, foregroundResult);
378
+ try {
379
+ const remainingMs = Math.max(
380
+ 1,
381
+ (readinessDeadline ?? Date.now()) - Date.now()
382
+ );
383
+ const handoffDeadline = Math.min(
384
+ readinessDeadline ?? Number.POSITIVE_INFINITY,
385
+ Date.now() + Math.min(1e4, Math.max(1, remainingMs / 2))
386
+ );
387
+ await readinessProbe(
388
+ node,
389
+ context,
390
+ previousRuntimeIdentity,
391
+ handoffDeadline
392
+ );
393
+ runtimeReady = true;
394
+ } catch (error) {
395
+ if (readinessDeadline !== void 0 && Date.now() >= readinessDeadline) {
396
+ throw error;
397
+ }
398
+ const foregroundTimeoutMs = Math.max(
399
+ 1,
400
+ (readinessDeadline ?? Date.now() + 1) - Date.now()
401
+ );
402
+ const foregroundResult = await entry.execute(
403
+ {
404
+ ...node,
405
+ command: "foreground",
406
+ settle_ms: 0,
407
+ timeout_ms: foregroundTimeoutMs,
408
+ [MOBILE_LIFECYCLE_DEADLINE_FIELD]: readinessDeadline
409
+ },
410
+ context
411
+ );
412
+ result = mergeAndroidRestartResults(initialResult, foregroundResult);
413
+ }
370
414
  }
371
- if (reloadsSource) {
372
- await readinessProbe(node, context);
415
+ if (reloadsSource && !runtimeReady) {
416
+ if (readinessDeadline !== void 0 && Date.now() >= readinessDeadline) {
417
+ throw new Error(
418
+ `Mobile lifecycle ${String(command)} exhausted its runtime readiness deadline before recovery completed.`
419
+ );
420
+ }
421
+ await readinessProbe(
422
+ node,
423
+ context,
424
+ previousRuntimeIdentity,
425
+ readinessDeadline
426
+ );
373
427
  }
374
428
  if (recordsProcessIdentity) {
375
429
  const processAfter = readProcessId(node, context);
@@ -468,35 +522,92 @@ function mergeAndroidRestartResults(initialResult, foregroundResult) {
468
522
  }
469
523
  };
470
524
  }
471
- async function probeMobileLifecycleRuntime(node, context) {
525
+ async function probeMobileLifecycleRuntime(node, context, previousRuntimeIdentity, deadlineMs) {
472
526
  const command = node.command ?? node.event ?? node.state;
473
527
  const timeoutMs = resolveMobileLifecycleReadinessTimeout(
474
528
  command,
475
529
  node.runtime_ready_timeout_ms
476
530
  );
477
- const deadline = Date.now() + timeoutMs;
531
+ const startedAt = Date.now();
532
+ const deadline = deadlineMs ?? startedAt + timeoutMs;
478
533
  let lastError;
479
534
  while (Date.now() < deadline) {
480
535
  try {
481
- return await bridgeCommand(
536
+ const status = await bridgeCommand(
482
537
  {
483
538
  action: "app.lifecycle",
484
539
  node: {
485
540
  ...node,
486
- cdp_timeout_ms: Math.min(8e3, Math.max(1e3, deadline - Date.now()))
541
+ cdp_timeout_ms: Math.min(8e3, Math.max(1, deadline - Date.now()))
487
542
  },
488
543
  context
489
544
  },
490
- ["get-route"]
545
+ ["status-selected"]
546
+ );
547
+ if (status?.agenticPresent === true && status.route && (!previousRuntimeIdentity || status.runtimeIdentity !== previousRuntimeIdentity)) {
548
+ return status;
549
+ }
550
+ lastError = new Error(
551
+ previousRuntimeIdentity && status?.runtimeIdentity === previousRuntimeIdentity ? "retiring Mobile runtime is still selected" : "selected Mobile runtime is not ready"
491
552
  );
492
553
  } catch (error) {
493
554
  lastError = error;
494
- await new Promise((resolve) => setTimeout(resolve, 1e3));
555
+ }
556
+ const remainingMs = deadline - Date.now();
557
+ if (remainingMs > 0) {
558
+ await new Promise(
559
+ (resolve) => setTimeout(resolve, Math.min(1e3, remainingMs))
560
+ );
495
561
  }
496
562
  }
497
563
  throw new Error(
498
- `Mobile lifecycle ${String(command)} did not expose a usable pinned __AGENTIC__ runtime within ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`
564
+ `Mobile lifecycle ${String(command)} did not expose a usable pinned __AGENTIC__ runtime within ${Math.max(0, deadline - startedAt)}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`
565
+ );
566
+ }
567
+ async function readMobileLifecycleRuntimeIdentity(node, context) {
568
+ const deadline = Date.now() + 5e3;
569
+ let lastError;
570
+ do {
571
+ try {
572
+ const status = await bridgeCommand(
573
+ {
574
+ action: "app.lifecycle",
575
+ node: {
576
+ ...node,
577
+ cdp_timeout_ms: Math.min(1e3, Math.max(1, deadline - Date.now()))
578
+ },
579
+ context
580
+ },
581
+ ["target-identity-selected"]
582
+ );
583
+ if (typeof status?.runtimeIdentity === "string") {
584
+ return status.runtimeIdentity;
585
+ }
586
+ lastError = new Error("selected Mobile runtime has no stable identity");
587
+ } catch (error) {
588
+ if (error instanceof Error && "code" in error && error.code === MOBILE_BRIDGE_ERROR_CODES.NO_TARGET) {
589
+ return void 0;
590
+ }
591
+ lastError = error;
592
+ }
593
+ const remainingMs = deadline - Date.now();
594
+ if (remainingMs <= 0) {
595
+ break;
596
+ }
597
+ await new Promise(
598
+ (resolve) => setTimeout(resolve, Math.min(250, remainingMs))
599
+ );
600
+ } while (Date.now() < deadline);
601
+ throw new Error(
602
+ `Mobile lifecycle restart could not identify the retiring runtime: ${lastError instanceof Error ? lastError.message : String(lastError)}`
603
+ );
604
+ }
605
+ function mobileLifecycleReadinessDeadline(command, node) {
606
+ const timeoutMs = resolveMobileLifecycleReadinessTimeout(
607
+ command,
608
+ node.runtime_ready_timeout_ms
499
609
  );
610
+ return Date.now() + timeoutMs;
500
611
  }
501
612
  function resolveMobileLifecycleReadinessTimeout(command, value) {
502
613
  if (value === void 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.44.1",
3
+ "version": "0.44.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"