@deeeed/metamask-harness 0.37.0 → 0.38.1

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 (113) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +13 -3
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +2 -0
  5. package/adapters/extension/live.sh +1 -1
  6. package/adapters/extension/readiness.mjs +1 -1
  7. package/adapters/manifest.json +8 -0
  8. package/dist/adapters/extension/runtime-decision.js +4 -1
  9. package/dist/adapters/extension/runtime.js +42 -3
  10. package/dist/adapters/mobile/provision.js +38 -3
  11. package/dist/adapters.js +68 -3
  12. package/dist/app-lifecycle.js +4 -3
  13. package/dist/commands/call.js +4 -1
  14. package/dist/commands/launch/extension.js +21 -5
  15. package/dist/commands/launch/index.js +3 -0
  16. package/dist/commands/run-engine.js +40 -29
  17. package/dist/commands/run.js +1 -1
  18. package/dist/recipe-security.js +40 -0
  19. package/dist/runner.js +3 -0
  20. package/docs/RELEASE-QA-CAPABILITY-MAP.md +116 -141
  21. package/library/actions/extension/assets/finish_send.mjs +128 -0
  22. package/library/actions/extension/assets/import_custom_token.mjs +227 -0
  23. package/library/actions/extension/assets/prepare_send.mjs +53 -0
  24. package/library/actions/extension/assets/read_details.mjs +57 -0
  25. package/library/actions/extension/assets/read_visible_state.mjs +50 -0
  26. package/library/actions/extension/assets/review_send.mjs +23 -0
  27. package/library/actions/extension/assets/select_network_scope.mjs +30 -0
  28. package/library/actions/extension/assets/send.mjs +104 -0
  29. package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
  30. package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
  31. package/library/actions/extension/assets/verify_sorting.mjs +89 -0
  32. package/library/actions/extension/networks/add_chainlist.mjs +94 -0
  33. package/library/actions/extension/networks/add_custom.mjs +123 -0
  34. package/library/actions/extension/networks/custom_network.mjs +103 -0
  35. package/library/actions/extension/networks/read_visible_state.mjs +50 -0
  36. package/library/actions/extension/networks/remove_custom.mjs +68 -0
  37. package/library/actions/extension/perps/close_orders.mjs +1 -0
  38. package/library/actions/extension/perps/close_visible_position.mjs +133 -0
  39. package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
  40. package/library/actions/extension/perps/edit_margin.mjs +82 -0
  41. package/library/actions/extension/perps/open_balance_action.mjs +53 -0
  42. package/library/actions/extension/perps/open_position_action.mjs +80 -0
  43. package/library/actions/extension/perps/perps.mjs +194 -2
  44. package/library/actions/extension/perps/place_order.mjs +5 -2
  45. package/library/actions/extension/perps/read_eligibility.mjs +34 -0
  46. package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
  47. package/library/actions/extension/perps/read_visible_state.mjs +98 -0
  48. package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
  49. package/library/actions/extension/perps/select_market_filter.mjs +72 -0
  50. package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
  51. package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
  52. package/library/actions/extension/platform/cdp.mjs +104 -1
  53. package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
  54. package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
  55. package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
  56. package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
  57. package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
  58. package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
  59. package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
  60. package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
  61. package/library/actions/extension/ui/navigate.mjs +264 -8
  62. package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
  63. package/library/actions/extension/wallet/lock.mjs +35 -0
  64. package/library/actions/extension/wallet/select_account.mjs +71 -7
  65. package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
  66. package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
  67. package/library/actions/mobile/platform/native-session-name.mjs +5 -0
  68. package/library/actions/mobile/platform/observe-ui.mjs +5 -3
  69. package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
  70. package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
  71. package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
  72. package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
  73. package/library/actions/mobile/ui/native-navigation.mjs +45 -5
  74. package/library/actions/mobile/ui/navigate.mjs +3 -1
  75. package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
  76. package/library/actions/mobile/wallet/native-ui.mjs +21 -2
  77. package/library/actions/shared/assets/visible-state.mjs +176 -0
  78. package/library/actions/shared/networks/visible-state.mjs +70 -0
  79. package/library/actions/shared/perps/visible-state.mjs +354 -0
  80. package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
  81. package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
  82. package/library/manifests/extension.action-manifest.json +1255 -189
  83. package/library/manifests/mobile.action-manifest.json +219 -1
  84. package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
  85. package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
  86. package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
  87. package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
  88. package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
  89. package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
  90. package/library/recipes/assets/release-token-details.recipe.json +48 -0
  91. package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
  92. package/library/recipes/assets/release-token-list.recipe.json +75 -0
  93. package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
  94. package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
  95. package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
  96. package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
  97. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
  98. package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
  99. package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
  100. package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
  101. package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
  102. package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
  103. package/library/recipes/perps/release-market-details.recipe.json +83 -0
  104. package/library/recipes/perps/release-market-search.recipe.json +79 -0
  105. package/library/recipes/perps/release-watchlist.recipe.json +99 -0
  106. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
  107. package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
  108. package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
  109. package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
  110. package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
  111. package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
  112. package/package.json +6 -5
  113. package/scripts/validate-release-report.mjs +333 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.38.1 - 2026-08-14
6
+
7
+ ### Fixed
8
+
9
+ - Read release evidence through verified regular-file descriptors, escape generated Markdown safely, and keep UI waits out of dynamically constructed page expressions.
10
+
11
+ ## 0.38.0 - 2026-08-14
12
+
13
+ ### Added
14
+
15
+ - Add visible Perps release actions and recipes for market details, search, filters, watchlists, order entry, position management, funds confirmations, and activity on untouched Extension builds, plus native Android market-state evidence without a development bridge.
16
+ - Add shared Assets release actions and recipes for token lists, details, sorting, import, visibility, persistence, send review, custom gas, and network scope on Extension, plus native Android holdings evidence.
17
+ - Add shared Swap and Bridge actions and recipes for exact asset selection, quote amounts, Max, slippage, and guarded submission on Extension, with native Android selection and quote evidence.
18
+ - Add Extension custom-network lifecycle and visible-state actions plus bounded balance-stability validation, and native Android multi-network holdings evidence.
19
+ - Add a canonical release-report gate that rejects missing case IDs, incomplete evidence, non-terminal verdicts, and unfinished case inventories before writing report metadata and quality output.
20
+
21
+ ### Fixed
22
+
23
+ - Recover Runway provisioning from an unresponsive iOS simulator by restarting the selected device once, waiting for a confirmed boot, and retrying the bounded installed-app probe.
24
+ - Consume Protocol 0.21.0, Recipe Harness 0.15.0, Agent Runtime 0.8.1, and Expo Recipe 0.8.1 as one compatible QA runtime set.
25
+ - Stop every Chrome process using an Extension validation profile before replacing its runtime snapshot, preventing stale pages from loading chunks from a different build.
26
+ - Prefer an unlocked Extension Home page over stale onboarding and Side Panel pages when multiple CDP targets remain open.
27
+ - Fixed opaque Android navigation completing the same native session twice after reaching its destination.
28
+ - Keep Android release actions on one device-and-app-scoped accessibility session so sequential recipe nodes do not conflict over stale leases.
29
+ - Navigate Android Swap through semantic accessibility identifiers, dismiss the keyboard before selecting exact chain-qualified assets, and scroll the token list until the requested row is visible.
30
+ - Unlock an opaque Android release with the wallet password instead of entering biometric authentication, and omit Metro launch data from native release lifecycle actions.
31
+ - Bind send and limit-order evidence to the newly submitted operation, reject selectors that native UI cannot disambiguate, and fail when Perps navigation or partial TP/SL reconciliation is not proven.
32
+ - Classify live Swap submission as an external mutation, reject ID-less release-report cases, and probe competing Extension tabs before selecting the runtime health target.
33
+ - Require explicit approval for live Perps submissions and reject release evidence that escapes its declared root or lacks regular trace leaves.
34
+ - Bind custom-token reuse to its chain and contract, reject vacuous asset sorting proof, and report capped Perps observations as truncated.
35
+ - Bind Swap confirmation to a newly created transaction, reconcile exact TP/SL values, assert disabled zero-withdraw submission, and require structured run evidence.
36
+ - Dismiss the first-run Perps tutorial through its visible Skip control before release recipes act on the Perps home screen.
37
+
5
38
  ## 0.37.0 - 2026-08-13
6
39
 
7
40
  ### Added
@@ -5,7 +5,7 @@ const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
 
7
7
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
8
- console.log('Usage: artifact-runtime-state.cjs <set|clear> --target <absolute-dir> --runtime-dir <relative-dir> [--source-dir <absolute-dir> --runtime-dist <absolute-dir> --provenance <absolute-file>]');
8
+ console.log('Usage: artifact-runtime-state.cjs <set|clear> --target <absolute-dir> --runtime-dir <relative-dir> [--source-dir <absolute-dir> --runtime-dist <absolute-dir> --provenance <absolute-file> --chrome-user-data-dir <absolute-dir>]');
9
9
  process.exit(0);
10
10
  }
11
11
 
@@ -29,6 +29,16 @@ if (action !== 'set') throw new Error('Usage: artifact-runtime-state.cjs <set|cl
29
29
  const sourceDir = requiredPath(args, 'source-dir');
30
30
  const runtimeDist = requiredPath(args, 'runtime-dist');
31
31
  const provenancePath = requiredPath(args, 'provenance');
32
+ const chromeUserDataDir = requiredPath(args, 'chrome-user-data-dir');
33
+ const directory = safeRuntimeDirectory(target, runtimeDir, true);
34
+ const runtimeRoot = path.resolve(target, runtimeDir);
35
+ if (!chromeUserDataDir.startsWith(`${runtimeRoot}${path.sep}`)) {
36
+ throw new Error('Release artifact Chrome profile must be a dedicated child of the runtime directory.');
37
+ }
38
+ const profileStat = fs.lstatSync(chromeUserDataDir, { throwIfNoEntry: false });
39
+ if (!profileStat?.isDirectory() || profileStat.isSymbolicLink()) {
40
+ throw new Error('Release artifact Chrome profile must be an existing non-symlink directory.');
41
+ }
32
42
  const provenance = readRegularJson(provenancePath);
33
43
  const relativeExtensionDir = provenance.extensionDir;
34
44
  if (
@@ -48,17 +58,17 @@ if (!fs.existsSync(path.join(sourceDir, 'manifest.json'))) throw new Error('Rele
48
58
  if (!fs.existsSync(path.join(runtimeDist, 'manifest.json'))) throw new Error('Release artifact runtime manifest is missing.');
49
59
 
50
60
  const state = {
51
- schemaVersion: 1,
61
+ schemaVersion: 2,
52
62
  sourceDir,
53
63
  runtimeDist,
54
64
  provenancePath,
65
+ chromeUserDataDir,
55
66
  sha256: provenance.sha256,
56
67
  treeSha256: provenance.treeSha256,
57
68
  expectedVersion: provenance.expectedVersion,
58
69
  manifestVersion: provenance.manifestVersion,
59
70
  recordedAt: new Date().toISOString(),
60
71
  };
61
- const directory = safeRuntimeDirectory(target, runtimeDir, true);
62
72
  const statePath = path.join(directory, 'extension-release-artifact.json');
63
73
  const existing = fs.lstatSync(statePath, { throwIfNoEntry: false });
64
74
  if (existing && (!existing.isFile() || existing.isSymbolicLink())) throw invalidRuntimeState(statePath);
@@ -132,6 +132,7 @@ copyFile(path.join(runnerDir, 'adapters/extension/lib/slot-title.cjs'), path.joi
132
132
  copyFile(path.join(runnerDir, 'adapters/extension/lib/chrome-args.cjs'), path.join(harnessDir, 'scripts/lib/chrome-args.cjs'));
133
133
  copyFile(path.join(runnerDir, 'adapters/extension/lib/macos-focus.cjs'), path.join(harnessDir, 'scripts/lib/macos-focus.cjs'));
134
134
  copyFile(path.join(runnerDir, 'adapters/extension/lib/product-config.cjs'), path.join(harnessDir, 'scripts/lib/product-config.cjs'));
135
+ copyFile(path.join(runnerDir, 'adapters/extension/lib/validation-process-ownership.cjs'), path.join(harnessDir, 'scripts/lib/validation-process-ownership.cjs'));
135
136
  makeExecutableTree(path.join(harnessDir, 'scripts'));
136
137
  fs.writeFileSync(path.join(harnessDir, 'installed-scripts.sha256'), `${dirContentHash(path.join(harnessDir, 'scripts'))}\n`);
137
138
 
@@ -24,6 +24,7 @@ const os = require('node:os');
24
24
  const path = require('node:path');
25
25
  const { execFileSync, spawn, spawnSync } = require('node:child_process');
26
26
  const { extensionIdFromExtensionDir } = require('./lib/extension-id.cjs');
27
+ const { profileProcessPids } = require('./lib/validation-process-ownership.cjs');
27
28
  const {
28
29
  automationRuntimeArgs,
29
30
  clearDetachedLaunchUnproven,
@@ -91,6 +92,7 @@ if (foreignPids.length > 0) {
91
92
  );
92
93
  }
93
94
  const ownedPids = new Set(listenerPids);
95
+ for (const pid of profileProcessPids(args.profile)) ownedPids.add(pid);
94
96
  const previousPid = readPidFile(args['chrome-pid']);
95
97
  if (previousPid !== null && processIsOwnedHarnessBrowser(previousPid, args.profile, args['extension-dir'])) ownedPids.add(previousPid);
96
98
  try {
@@ -324,7 +324,7 @@ NODE
324
324
  prepare_parts+=("bash ${quoted_snapshot_dist} --dist ${quoted_dist} --target ${quoted_target} --runtime-root ${quoted_runtime_root} --runtime-dist ${quoted_runtime_dist}")
325
325
  fi
326
326
  if $EXTERNAL_ARTIFACT; then
327
- prepare_parts+=("node ${quoted_artifact_state} set --target ${quoted_target} --runtime-dir ${quoted_runtime_dir} --source-dir ${quoted_dist} --runtime-dist ${quoted_runtime_dist} --provenance ${quoted_artifact_provenance}")
327
+ prepare_parts+=("node ${quoted_artifact_state} set --target ${quoted_target} --runtime-dir ${quoted_runtime_dir} --source-dir ${quoted_dist} --runtime-dist ${quoted_runtime_dist} --provenance ${quoted_artifact_provenance} --chrome-user-data-dir ${quoted_profile}")
328
328
  fi
329
329
  if ! $EXTERNAL_ARTIFACT; then
330
330
  prepare_parts+=("node ${quoted_configure_manifest} --target ${quoted_target} --manifest ${quoted_runtime_dist}/manifest.json")
@@ -75,7 +75,7 @@ function extensionBuildRoot(target) {
75
75
  }
76
76
  const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
77
77
  const expected = path.join(runtimeRoot, process.env.RECIPE_RUNTIME_DIST_DIR || 'runtime-dist');
78
- if (state.schemaVersion === 1 && path.resolve(state.runtimeDist || '') === expected) return expected;
78
+ if (state.schemaVersion === 2 && path.resolve(state.runtimeDist || '') === expected) return expected;
79
79
  } catch (error) {
80
80
  throw new Error(`Loaded release artifact identity is invalid: ${error instanceof Error ? error.message : String(error)}`);
81
81
  }
@@ -234,6 +234,14 @@
234
234
  "inputs": "--cdp-port --chrome-bin --profile --extension-dir --chrome-log --chrome-pid",
235
235
  "outputs": "pid file + append-log; exit 0 / non-zero"
236
236
  },
237
+ {
238
+ "id": "extension/validation-process-ownership",
239
+ "entry": "adapters/extension/lib/validation-process-ownership.cjs",
240
+ "kind": "lib",
241
+ "purpose": "Resolve browser processes owned by one isolated Extension validation profile.",
242
+ "inputs": "profile path",
243
+ "outputs": "owned process identifiers"
244
+ },
237
245
  {
238
246
  "id": "extension/refresh-build",
239
247
  "entry": "adapters/extension/refresh-build.sh",
@@ -165,11 +165,13 @@ function releaseArtifactState(target) {
165
165
  const sourceDir = typeof state.sourceDir === "string" ? path.resolve(state.sourceDir) : "";
166
166
  const runtimeDist = typeof state.runtimeDist === "string" ? path.resolve(state.runtimeDist) : "";
167
167
  const provenancePath = typeof state.provenancePath === "string" ? path.resolve(state.provenancePath) : "";
168
+ const chromeUserDataDir = typeof state.chromeUserDataDir === "string" ? path.resolve(state.chromeUserDataDir) : "";
168
169
  const expectedRuntimeDist = path.join(
169
170
  runtimeRoot,
170
171
  process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist"
171
172
  );
172
- if (state.schemaVersion !== 1 || !sourceDir || runtimeDist !== expectedRuntimeDist || !provenancePath) {
173
+ const profileStat = chromeUserDataDir ? fs.lstatSync(chromeUserDataDir, { throwIfNoEntry: false }) : void 0;
174
+ if (state.schemaVersion !== 2 || !sourceDir || runtimeDist !== expectedRuntimeDist || !provenancePath || !chromeUserDataDir.startsWith(`${runtimeRoot}${path.sep}`) || !profileStat?.isDirectory() || profileStat.isSymbolicLink()) {
173
175
  return { status: "invalid", reason: "artifact runtime identity has invalid paths or schema" };
174
176
  }
175
177
  const provenanceStat = fs.lstatSync(provenancePath);
@@ -199,6 +201,7 @@ function releaseArtifactState(target) {
199
201
  sourceDir,
200
202
  runtimeDist,
201
203
  provenancePath,
204
+ chromeUserDataDir,
202
205
  sha256,
203
206
  treeSha256,
204
207
  expectedVersion,
@@ -83,11 +83,15 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
83
83
  (target2) => target2.type === "page" && String(target2.url ?? "").startsWith("chrome-extension://") && String(target2.url ?? "").includes("/sidepanel.html") && Boolean(target2.webSocketDebuggerUrl)
84
84
  );
85
85
  const extensionTargets = pageMode === "home-or-sidepanel" && homeTargets.length === 0 ? sidepanelTargets : homeTargets;
86
- if (extensionTargets.length !== 1) {
87
- const expected = pageMode === "home-or-sidepanel" ? "exactly one MetaMask extension home page target, or one sidepanel target when home is intentionally closed" : "exactly one MetaMask extension home page target";
86
+ if (extensionTargets.length === 0) {
87
+ const expected = pageMode === "home-or-sidepanel" ? "a MetaMask extension home page target, or one sidepanel target when home is intentionally closed" : "a MetaMask extension home page target";
88
88
  findings.push(`Expected ${expected}, found ${extensionTargets.length}.`);
89
89
  }
90
- const target = extensionTargets[0];
90
+ const targetSelection = await selectResponsiveExtensionTarget(extensionTargets, cdpCallTimeoutMs);
91
+ const target = targetSelection.target;
92
+ if (extensionTargets.length > 1) {
93
+ warnings.push(`Found ${extensionTargets.length} MetaMask Extension page targets; health selected ${target?.url ?? "none"} after probing each candidate.`);
94
+ }
91
95
  if (!target?.webSocketDebuggerUrl) {
92
96
  return failReport(cdpPort, findings, {
93
97
  targetUrls: targets.map((entry) => entry.url).filter(Boolean)
@@ -205,6 +209,41 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
205
209
  session?.close();
206
210
  }
207
211
  }
212
+ async function selectResponsiveExtensionTarget(targets, timeoutMs) {
213
+ if (targets.length <= 1) return { target: targets[0] };
214
+ const probeTimeoutMs = Math.min(1e3, timeoutMs);
215
+ const ranked = [];
216
+ for (const [index, target] of targets.entries()) {
217
+ let session;
218
+ try {
219
+ session = await CdpSession.connect(target.webSocketDebuggerUrl, { timeoutMs: probeTimeoutMs });
220
+ await boundedCdpCall(session, "Runtime.enable", {}, probeTimeoutMs);
221
+ await boundedCdpCall(session, "Page.enable", {}, probeTimeoutMs);
222
+ const compositor = await probeCdpCompositorInteractivity(session, probeTimeoutMs);
223
+ const result = await boundedCdpCall(session, "Runtime.evaluate", {
224
+ expression: `(() => ({
225
+ readyState: String(document.readyState || ''),
226
+ href: String(location.href || ''),
227
+ text: String(document.body?.innerText || '').slice(0, 5000),
228
+ }))()`,
229
+ returnByValue: true
230
+ }, probeTimeoutMs);
231
+ const value = result.result?.value ?? {};
232
+ const text = String(value.text ?? "");
233
+ const href = String(value.href ?? target.url ?? "");
234
+ const blocked = text.includes("Background connection unresponsive") || text.includes("MetaMask had trouble starting") || text.includes("Unable to connect to Ethereum");
235
+ const onboarding = href.includes("/onboarding/");
236
+ const score = (compositor.status === "ready" ? 100 : compositor.status === "not-interactive" ? 10 : 0) + (value.readyState === "complete" ? 10 : 0) + (blocked ? 0 : 5) + (onboarding ? 0 : 2);
237
+ ranked.push({ target, score, index });
238
+ } catch {
239
+ ranked.push({ target, score: -1, index });
240
+ } finally {
241
+ session?.close();
242
+ }
243
+ }
244
+ ranked.sort((left, right) => right.score - left.score || left.index - right.index);
245
+ return { target: ranked[0]?.target };
246
+ }
208
247
  function failReport(cdpPort, findings, details, extensionPageTargets = 0) {
209
248
  return {
210
249
  status: "FAIL",
@@ -14,6 +14,8 @@ const RUNWAY_IOS_METADATA = {
14
14
  };
15
15
  class SimulatorTargetError extends Error {
16
16
  }
17
+ class SimulatorUnresponsiveError extends Error {
18
+ }
17
19
  function resolvePoolIdentity(target) {
18
20
  const out = resolveSlotPortsByRepo(target);
19
21
  const id = {};
@@ -75,7 +77,7 @@ async function provisionRunwayMobile(target, options) {
75
77
  const sim = ensureSimulator(simulator, runtime, deviceType);
76
78
  let resolved;
77
79
  let reason = options.force ? "forced" : "fresh-install";
78
- if (!options.force && appInstalled(sim.udid ?? sim.name)) {
80
+ if (!options.force && appInstalledWithRecovery(sim.udid ?? sim.name, options)) {
79
81
  const installed = readInstalledBaselineArtifact(resolvedTarget, sim, options.runtimeDir);
80
82
  let requestedRunId = options.run;
81
83
  if (installed && !requestedRunId) {
@@ -445,6 +447,24 @@ function bootSimulator(device, options) {
445
447
  log(options, `runway: bootstatus wait for ${device} did not confirm; continuing to install`);
446
448
  }
447
449
  }
450
+ function restartSimulator(device, options) {
451
+ log(options, `runway: simulator ${device} did not answer the app probe; restarting it once`);
452
+ execFileSync("xcrun", ["simctl", "shutdown", device], {
453
+ stdio: ["ignore", "ignore", "ignore"],
454
+ timeout: 3e4
455
+ });
456
+ try {
457
+ execFileSync("xcrun", ["simctl", "boot", device], {
458
+ stdio: ["ignore", "ignore", "ignore"],
459
+ timeout: 3e4
460
+ });
461
+ } catch {
462
+ }
463
+ execFileSync("xcrun", ["simctl", "bootstatus", device, "-b"], {
464
+ stdio: ["ignore", "ignore", "ignore"],
465
+ timeout: 12e4
466
+ });
467
+ }
448
468
  function preapproveDeepLinkScheme(device, bundleId, options) {
449
469
  const scheme = process.env.IOS_DEV_CLIENT_SCHEME ?? "expo-metamask";
450
470
  try {
@@ -572,12 +592,27 @@ function readInstalledBaselineArtifact(target, sim, runtimeDir) {
572
592
  }
573
593
  function appInstalled(device) {
574
594
  try {
575
- execFileSync("xcrun", ["simctl", "get_app_container", device, RUNWAY_IOS_METADATA.bundleId, "app"], { stdio: ["ignore", "ignore", "ignore"] });
595
+ execFileSync("xcrun", ["simctl", "get_app_container", device, RUNWAY_IOS_METADATA.bundleId, "app"], {
596
+ stdio: ["ignore", "ignore", "ignore"],
597
+ timeout: 15e3
598
+ });
576
599
  return true;
577
- } catch {
600
+ } catch (error) {
601
+ if (error instanceof Error && "code" in error && error.code === "ETIMEDOUT") {
602
+ throw new SimulatorUnresponsiveError(`simulator ${device} timed out while checking the installed app`);
603
+ }
578
604
  return false;
579
605
  }
580
606
  }
607
+ function appInstalledWithRecovery(device, options) {
608
+ try {
609
+ return appInstalled(device);
610
+ } catch (error) {
611
+ if (!(error instanceof SimulatorUnresponsiveError)) throw error;
612
+ restartSimulator(device, options);
613
+ return appInstalled(device);
614
+ }
615
+ }
581
616
  function writeRunwayBaseline(target, slot, platform, resolved, artifact, simulator, alreadyInstalled, runtimeDir, preservedArtifact) {
582
617
  const file = runwayBaselinePath(target, runtimeDir);
583
618
  fs.mkdirSync(path.dirname(file), { recursive: true });
package/dist/adapters.js CHANGED
@@ -13,6 +13,7 @@ import { resolveWalletImportCredentials, validateWalletImportOptions } from "../
13
13
  const execFileAsync = promisify(execFile);
14
14
  const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
15
15
  "ui.press",
16
+ "ui.key_press",
16
17
  "ui.set_input",
17
18
  "ui.scroll",
18
19
  "ui.swipe",
@@ -34,7 +35,41 @@ function simpleAdapter(action, executor) {
34
35
  }
35
36
  };
36
37
  }
37
- const LIVE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
38
+ const LIVE_ONLY_ACTIONS = /* @__PURE__ */ new Set([
39
+ "metamask.assets.read_visible_state",
40
+ "metamask.assets.read_details",
41
+ "metamask.assets.verify_sorting",
42
+ "metamask.assets.set_token_visibility",
43
+ "metamask.assets.import_custom_token",
44
+ "metamask.assets.prepare_send",
45
+ "metamask.assets.review_send",
46
+ "metamask.assets.finish_send",
47
+ "metamask.assets.set_custom_gas",
48
+ "metamask.assets.select_network_scope",
49
+ "metamask.wallet.lock",
50
+ "metamask.networks.read_visible_state",
51
+ "metamask.networks.add_custom",
52
+ "metamask.networks.add_chainlist",
53
+ "metamask.networks.remove_custom",
54
+ "metamask.perps.read_visible_state",
55
+ "metamask.perps.compare_provider_market",
56
+ "metamask.perps.read_eligibility",
57
+ "metamask.perps.select_market_filter",
58
+ "metamask.perps.close_visible_position",
59
+ "metamask.perps.set_market_favorite",
60
+ "metamask.perps.select_activity_filter",
61
+ "metamask.perps.open_balance_action",
62
+ "metamask.perps.open_position_action",
63
+ "metamask.perps.edit_margin",
64
+ "metamask.perps.update_position_tpsl",
65
+ "metamask.perps.read_funds_confirmation",
66
+ "metamask.swap_bridge.read_visible_state",
67
+ "metamask.swap_bridge.select_assets",
68
+ "metamask.swap_bridge.set_amount",
69
+ "metamask.swap_bridge.set_max_amount",
70
+ "metamask.swap_bridge.set_slippage",
71
+ "metamask.swap_bridge.submit_transaction",
72
+ "metamask.settings.set_basic_functionality",
38
73
  "metamask.perps.read_snapshot",
39
74
  "metamask.perps.read_positions",
40
75
  "metamask.perps.ensure_positions",
@@ -66,7 +101,7 @@ const LIVE_ONLY_WALLET_ACTIONS = /* @__PURE__ */ new Set([
66
101
  ]);
67
102
  const LIVE_ONLY_APP_ACTIONS = /* @__PURE__ */ new Set(["ui.navigate"]);
68
103
  function requiresLiveAdapter(platform, action) {
69
- return LIVE_ONLY_PERPS_ACTIONS.has(action) || platform === "core" && CORE_ONLY_PERPS_ACTIONS.has(action) || LIVE_ONLY_WALLET_ACTIONS.has(action) || LIVE_ONLY_APP_ACTIONS.has(action);
104
+ return LIVE_ONLY_ACTIONS.has(action) || platform === "core" && CORE_ONLY_PERPS_ACTIONS.has(action) || LIVE_ONLY_WALLET_ACTIONS.has(action) || LIVE_ONLY_APP_ACTIONS.has(action);
70
105
  }
71
106
  function liveRuntimeConfigured(platform, node) {
72
107
  if (platform === "mobile" || platform === "core") return true;
@@ -214,6 +249,36 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
214
249
  ];
215
250
  const bundledActions = [
216
251
  ...walletActions,
252
+ "metamask.assets.read_visible_state",
253
+ ...platform === "extension" ? ["metamask.assets.read_details"] : [],
254
+ ...platform === "extension" ? ["metamask.assets.verify_sorting"] : [],
255
+ ...platform === "extension" ? ["metamask.assets.set_token_visibility"] : [],
256
+ ...platform === "extension" ? ["metamask.assets.import_custom_token"] : [],
257
+ ...platform === "extension" ? [
258
+ "metamask.assets.prepare_send",
259
+ "metamask.assets.review_send",
260
+ "metamask.assets.finish_send",
261
+ "metamask.assets.set_custom_gas",
262
+ "metamask.assets.select_network_scope"
263
+ ] : [],
264
+ ...platform === "extension" ? ["metamask.wallet.lock"] : [],
265
+ ...platform === "extension" ? ["metamask.networks.read_visible_state"] : [],
266
+ ...platform === "extension" ? ["metamask.networks.add_custom", "metamask.networks.add_chainlist", "metamask.networks.remove_custom"] : [],
267
+ "metamask.perps.read_visible_state",
268
+ ...platform === "extension" ? ["metamask.perps.compare_provider_market"] : [],
269
+ ...platform === "extension" ? ["metamask.perps.read_eligibility"] : [],
270
+ ...platform === "extension" ? [
271
+ "metamask.perps.open_position_action",
272
+ "metamask.perps.edit_margin",
273
+ "metamask.perps.update_position_tpsl"
274
+ ] : [],
275
+ "metamask.swap_bridge.read_visible_state",
276
+ "metamask.swap_bridge.select_assets",
277
+ "metamask.swap_bridge.set_amount",
278
+ ...platform === "extension" ? ["metamask.swap_bridge.set_max_amount"] : [],
279
+ ...platform === "extension" ? ["metamask.swap_bridge.set_slippage"] : [],
280
+ ...platform === "extension" ? ["metamask.swap_bridge.submit_transaction"] : [],
281
+ ...platform === "extension" ? ["metamask.settings.set_basic_functionality"] : [],
217
282
  "metamask.perps.read_positions",
218
283
  "metamask.perps.ensure_positions",
219
284
  "metamask.perps.assert_positions",
@@ -810,7 +875,7 @@ function createMetaMaskUiTransport(platform, harness, preparedLiveAdapters) {
810
875
  if (live) return live.output;
811
876
  throw new Error(`ui.navigate requires library/actions/${platform}/ui/navigate.mjs.`);
812
877
  }
813
- if (platform === "mobile" && NATIVE_PROVIDER_UI_ACTIONS.has(action) && (context.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || ["ui.swipe", "ui.pan", "ui.drag", "ui.long_press", "ui.capture_surface"].includes(action))) {
878
+ if (platform === "mobile" && NATIVE_PROVIDER_UI_ACTIONS.has(action) && (context.env?.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || ["ui.swipe", "ui.pan", "ui.drag", "ui.long_press", "ui.capture_surface"].includes(action))) {
814
879
  return executeNativeProviderAction(action, node, context, harness.createNativeUiTransport);
815
880
  }
816
881
  return base.execute(action, action === "ui.wait_for" ? normalizeUiWaitNode(node) : node, context);
@@ -1,6 +1,7 @@
1
1
  function resolveMetaMaskMobileLifecycleTarget(node, rawContext) {
2
2
  const context = rawContext;
3
3
  const platform = resolveMobilePlatform(node, context);
4
+ const opaqueRuntime = context.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1";
4
5
  const port = scalar(node.watcher_port ?? node.metro_port, "app.lifecycle.metro_port") ?? context.env.WATCHER_PORT ?? context.env.METRO_PORT ?? process.env.WATCHER_PORT ?? process.env.METRO_PORT ?? "8081";
5
6
  const launchUrl = scalar(node.launch_url ?? node.launchUrl ?? node.url, "app.lifecycle.launch_url") ?? expoDevClientUrl(port, platform);
6
7
  if (platform === "android") {
@@ -8,9 +9,9 @@ function resolveMetaMaskMobileLifecycleTarget(node, rawContext) {
8
9
  platform,
9
10
  deviceId: scalar(node.adb_serial ?? node.android_device ?? node.device, "app.lifecycle.device") ?? context.env.ADB_SERIAL ?? context.env.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL,
10
11
  appId: scalar(node.package_id ?? node.packageName ?? node.app_id, "app.lifecycle.package_id") ?? context.env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask",
11
- launchUrl,
12
- metroPort: port,
13
- prelaunchCalls: expoBundlePrewarmCalls(port, "android")
12
+ launchUrl: opaqueRuntime ? void 0 : launchUrl,
13
+ metroPort: opaqueRuntime ? void 0 : port,
14
+ prelaunchCalls: opaqueRuntime ? [] : expoBundlePrewarmCalls(port, "android")
14
15
  };
15
16
  }
16
17
  return {
@@ -290,7 +290,10 @@ async function handleCall(argv) {
290
290
  target,
291
291
  heal,
292
292
  state,
293
- () => recoverRunInfra(adapter, target, json)
293
+ () => recoverRunInfra(adapter, target, json, {
294
+ cdpPort: optionString(options, "cdpPort"),
295
+ watcherPort: optionString(options, "watcherPort") ?? optionString(options, "metroPort")
296
+ })
294
297
  );
295
298
  if (violation !== null) {
296
299
  const conciseFailure = violation.originalError ? conciseFailureForHuman(violation.originalError) : "";
@@ -56,6 +56,12 @@ async function installExtensionDeps(target) {
56
56
  return result;
57
57
  }
58
58
  async function launchExtension(target, tier, wantWatch, displayMode = "fullscreen") {
59
+ const requestedCdpPort = process.env.CDP_PORT;
60
+ const requestedRecipeCdpPort = process.env.RECIPE_CDP_PORT;
61
+ const restoreRequestedPorts = () => {
62
+ if (requestedCdpPort) process.env.CDP_PORT = requestedCdpPort;
63
+ if (requestedRecipeCdpPort) process.env.RECIPE_CDP_PORT = requestedRecipeCdpPort;
64
+ };
59
65
  if (process.env.CHROME_USER_DATA_DIR) {
60
66
  process.env.CHROME_USER_DATA_DIR = path.resolve(process.env.CHROME_USER_DATA_DIR);
61
67
  }
@@ -63,6 +69,7 @@ async function launchExtension(target, tier, wantWatch, displayMode = "fullscree
63
69
  const artifactSnapshot = runtimeDistCheck(target, artifact);
64
70
  const artifactLaunchable = !wantWatch && tier !== "build" && artifact.status === "valid" && artifactSnapshot.status === "fresh";
65
71
  const reusable = !wantWatch && tier !== "build" && await extensionRuntimeReusable(target, artifact);
72
+ restoreRequestedPorts();
66
73
  if (artifact.status !== "none" && !reusable && !artifactLaunchable) {
67
74
  const detail = artifact.status === "invalid" ? `identity is invalid: ${artifact.reason ?? "unknown reason"}` : "runtime is not healthy and reusable";
68
75
  return {
@@ -82,6 +89,7 @@ Next: ${block.userAction}`
82
89
  }
83
90
  }
84
91
  await ensureHarnessFresh(target, "extension");
92
+ restoreRequestedPorts();
85
93
  if (wantWatch) {
86
94
  const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
87
95
  const watchArgs = ["--target", target];
@@ -93,6 +101,12 @@ Next: ${block.userAction}`
93
101
  return extensionRebuild(target);
94
102
  }
95
103
  if (reusable) {
104
+ if (artifact.status === "valid") {
105
+ console.error(colorHumanMessage(
106
+ `\u2192 extension release reuse \u2014 verified artifact ${artifact.manifestVersion ?? artifact.expectedVersion ?? ""} \xB7 CDP :${process.env.CDP_PORT ?? "default"} (no snapshot mutation)`
107
+ ));
108
+ return { status: EXIT.ok, output: "Verified release artifact runtime is already healthy." };
109
+ }
96
110
  return extensionReattach(target, displayMode);
97
111
  }
98
112
  if (artifactLaunchable) {
@@ -115,7 +129,7 @@ async function extensionRuntimeReusable(target, artifact = releaseArtifactState(
115
129
  for (let attempt = 0; attempt < 3; attempt += 1) {
116
130
  const reachable = await cdpVersionReachable(cdpPort);
117
131
  const nonceOwned = reachable && await cdpOwnedByRuntimeNonce(cdpPort, target);
118
- const owned = nonceOwned || reachable && cdpOwnedByExpectedRuntime(cdpPort, target);
132
+ const owned = nonceOwned || reachable && cdpOwnedByExpectedRuntime(cdpPort, target, releaseArtifact ? artifact.chromeUserDataDir : void 0);
119
133
  const targetPresent = owned && await cdpHasExpectedExtensionTarget(cdpPort, target);
120
134
  const healthy = targetPresent && await cdpRuntimeHealthy(cdpPort, target);
121
135
  if (healthy) return true;
@@ -142,9 +156,9 @@ function commandHasExactFlagValue(command, flag, value) {
142
156
  }
143
157
  return false;
144
158
  }
145
- function cdpOwnedByExpectedRuntime(port, target) {
159
+ function cdpOwnedByExpectedRuntime(port, target, profileOverride) {
146
160
  const pids = cdpListenerPids(port);
147
- const profile = expectedChromeProfile(target);
161
+ const profile = profileOverride ?? expectedChromeProfile(target);
148
162
  const runtimeDist = expectedRuntimeDist(target);
149
163
  for (const pid of pids) {
150
164
  try {
@@ -369,7 +383,7 @@ async function extensionLaunchDevelopment(target) {
369
383
  return spawnScriptStreaming(liveScript, liveArgs, target);
370
384
  }
371
385
  async function extensionLaunchReleaseArtifact(target, artifact) {
372
- if (!artifact.sourceDir || !artifact.provenancePath) {
386
+ if (!artifact.sourceDir || !artifact.provenancePath || !artifact.chromeUserDataDir) {
373
387
  return {
374
388
  status: EXIT.infra,
375
389
  output: "RELEASE_ARTIFACT_NOT_REUSABLE: The loaded release artifact identity is incomplete.\nNext: rerun runtime-launch with the same artifact source."
@@ -384,7 +398,9 @@ async function extensionLaunchReleaseArtifact(target, artifact) {
384
398
  artifact.sourceDir,
385
399
  "--artifact-provenance",
386
400
  artifact.provenancePath,
387
- "--preserve-profile"
401
+ "--preserve-profile",
402
+ "--chrome-user-data-dir",
403
+ artifact.chromeUserDataDir
388
404
  ];
389
405
  if (process.env.CDP_PORT) liveArgs.push("--cdp-port", process.env.CDP_PORT);
390
406
  if (process.env.EXTENSION_START_URL) liveArgs.push("--start-url", process.env.EXTENSION_START_URL);
@@ -562,6 +562,9 @@ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget,
562
562
  stream.phase("verify");
563
563
  const verifyArgs = ["verify", "--adapter", adapter, "--target", target];
564
564
  if (adapter === "mobile" && mobileTarget) verifyArgs.push("--platform", mobileTarget);
565
+ if (adapter === "extension" && process.env.CDP_PORT) {
566
+ verifyArgs.push("--cdp-port", process.env.CDP_PORT);
567
+ }
565
568
  if (machine) verifyArgs.push("--json");
566
569
  const exitCode = await handleHarness(verifyArgs);
567
570
  if (stream.enabled) {
@@ -1197,43 +1197,54 @@ const RUN_RECOVERY_CODE = {
1197
1197
  extension: "extension.runtime_recovered",
1198
1198
  core: "run.retried"
1199
1199
  };
1200
- async function recoverRunInfra(adapter, target, json) {
1200
+ async function recoverRunInfra(adapter, target, json, runtimeOptions = {}) {
1201
1201
  getAdapterSurface(adapter).resolveSlotPorts(target);
1202
- if (adapter === "extension") {
1203
- const { releaseArtifactState } = await import("../adapters/extension/runtime-decision.js");
1204
- const artifact = releaseArtifactState(target);
1205
- if (artifact.status !== "none") {
1206
- return {
1207
- status: 1,
1208
- output: "Release artifact recovery cannot switch to a checkout build.\nNext: rerun runtime-launch with the same artifact source."
1209
- };
1210
- }
1211
- const { launchExtension } = await import("./launch/extension.js");
1212
- return launchExtension(target, "quick", false);
1202
+ const previousCdpPort = process.env.CDP_PORT;
1203
+ const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
1204
+ if (runtimeOptions.cdpPort) {
1205
+ process.env.CDP_PORT = runtimeOptions.cdpPort;
1206
+ process.env.RECIPE_CDP_PORT = runtimeOptions.cdpPort;
1213
1207
  }
1214
- if (adapter === "mobile") {
1215
- try {
1216
- const artifact = requireCurrentMobileReleaseArtifactState(target);
1217
- if (artifact) {
1218
- const { relaunchAndroidReleaseArtifactRuntime } = await import("../adapters/mobile/release-artifact.js");
1219
- await relaunchAndroidReleaseArtifactRuntime(artifact);
1208
+ try {
1209
+ if (adapter === "extension") {
1210
+ const { releaseArtifactState } = await import("../adapters/extension/runtime-decision.js");
1211
+ const artifact = releaseArtifactState(target);
1212
+ if (artifact.status !== "none" && artifact.status !== "valid") {
1220
1213
  return {
1221
- status: 0,
1222
- output: `Relaunched ${artifact.packageId} ${artifact.versionName} (${artifact.versionCode}) on ${artifact.deviceSerial}.`
1214
+ status: 1,
1215
+ output: "Release artifact recovery cannot trust the recorded runtime identity.\nNext: rerun runtime-launch with the same artifact source."
1223
1216
  };
1224
1217
  }
1225
- } catch (error) {
1226
- return {
1227
- status: 1,
1228
- output: `Release artifact recovery could not revalidate and relaunch the pinned Android package: ${error instanceof Error ? error.message : String(error)}
1218
+ const { launchExtension } = await import("./launch/extension.js");
1219
+ return launchExtension(target, "quick", false);
1220
+ }
1221
+ if (adapter === "mobile") {
1222
+ try {
1223
+ const artifact = requireCurrentMobileReleaseArtifactState(target);
1224
+ if (artifact) {
1225
+ const { relaunchAndroidReleaseArtifactRuntime } = await import("../adapters/mobile/release-artifact.js");
1226
+ await relaunchAndroidReleaseArtifactRuntime(artifact);
1227
+ return {
1228
+ status: 0,
1229
+ output: `Relaunched ${artifact.packageId} ${artifact.versionName} (${artifact.versionCode}) on ${artifact.deviceSerial}.`
1230
+ };
1231
+ }
1232
+ } catch (error) {
1233
+ return {
1234
+ status: 1,
1235
+ output: `Release artifact recovery could not revalidate and relaunch the pinned Android package: ${error instanceof Error ? error.message : String(error)}
1229
1236
  Next: rerun runtime-launch with the same artifact source.`
1230
- };
1237
+ };
1238
+ }
1239
+ const { launchMobile } = await import("./launch/mobile.js");
1240
+ const platform = resolveMobileRecoveryPlatform();
1241
+ return launchMobile(target, platform, "quick", json);
1231
1242
  }
1232
- const { launchMobile } = await import("./launch/mobile.js");
1233
- const platform = resolveMobileRecoveryPlatform();
1234
- return launchMobile(target, platform, "quick", json);
1243
+ return { status: 0, output: "headless run retry" };
1244
+ } finally {
1245
+ restoreEnv("CDP_PORT", previousCdpPort);
1246
+ restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
1235
1247
  }
1236
- return { status: 0, output: "headless run retry" };
1237
1248
  }
1238
1249
  function resolveMobileRecoveryPlatform(env = process.env) {
1239
1250
  if (env.PLATFORM === "android" || env.PLATFORM === "ios") return env.PLATFORM;
@@ -284,7 +284,7 @@ async function handleRunInner({ positional, options }, stream) {
284
284
  target,
285
285
  heal,
286
286
  state,
287
- () => recoverRunInfra(adapter, target, machine),
287
+ () => recoverRunInfra(adapter, target, machine, runtimeOptions),
288
288
  (code) => stream.phase("recover", { code })
289
289
  );
290
290
  for (const mutation of state.mutations) stream.mutation(mutation);