@deeeed/metamask-harness 0.83.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +8 -1
  3. package/adapters/extension/console-tail.mjs +11 -7
  4. package/adapters/extension/ensure-browser.sh +17 -18
  5. package/adapters/manifest.json +14 -110
  6. package/adapters/mobile/start-console-forwarder.sh +8 -0
  7. package/adapters/shared/resolve-slot-ports-core.mjs +48 -6
  8. package/adapters/shared/session-lock.mjs +43 -0
  9. package/adapters/web-dapp/metamask-signer.mjs +212 -0
  10. package/dist/adapters/extension/actions.js +5 -0
  11. package/dist/adapters/extension/console-capture.js +18 -0
  12. package/dist/adapters/extension/fixtures.js +2 -0
  13. package/dist/adapters/extension/overlay.js +5 -4
  14. package/dist/adapters/extension/run.js +4 -2
  15. package/dist/adapters/extension/runtime.js +73 -18
  16. package/dist/adapters/mobile/devices.js +5 -1
  17. package/dist/adapters/mobile/launch.js +19 -2
  18. package/dist/adapters/mobile/provision.js +4 -2
  19. package/dist/adapters/mobile/run.js +3 -2
  20. package/dist/adapters/mobile/surface.js +1 -2
  21. package/dist/adapters/mobile/verify.js +15 -7
  22. package/dist/adapters/plugin-surface.js +14 -42
  23. package/dist/adapters/session-lock.js +23 -0
  24. package/dist/adapters/web-dapp/actions.js +7 -5
  25. package/dist/adapters/web-dapp/console-capture.js +2 -20
  26. package/dist/adapters/web-dapp/run.js +25 -24
  27. package/dist/adapters/web-dapp/surface.js +32 -108
  28. package/dist/adapters.js +5 -3
  29. package/dist/command-contract.js +1351 -643
  30. package/dist/commands/check.js +5 -7
  31. package/dist/commands/debug.js +2 -2
  32. package/dist/commands/doctor.js +3 -4
  33. package/dist/commands/domain.js +4 -3
  34. package/dist/commands/fixtures.js +3 -3
  35. package/dist/commands/help.js +6 -5
  36. package/dist/commands/launch/device.js +1 -1
  37. package/dist/commands/launch/extension.js +9 -7
  38. package/dist/commands/logs.js +2 -2
  39. package/dist/commands/prepare.js +4 -4
  40. package/dist/commands/provision.js +2 -2
  41. package/dist/commands/review.js +8 -10
  42. package/dist/commands/runtime-launch.js +2 -0
  43. package/dist/commands/status.js +4 -2
  44. package/dist/commands/task-init.js +2 -3
  45. package/dist/console-classifier.js +3 -2
  46. package/dist/doctor.js +11 -4
  47. package/dist/mm-harness-cli.js +241 -1210
  48. package/dist/paths.js +9 -4
  49. package/dist/recipe-security.js +1 -1
  50. package/dist/review/knowledge.js +10 -2
  51. package/dist/runner.js +18 -3
  52. package/docs/CONTRIBUTING.md +14 -4
  53. package/docs/RECIPES.md +14 -1
  54. package/docs/cli-contract.md +8 -7
  55. package/docs/feature-guard.md +1 -1
  56. package/library/actions/extension/app/assert_no_console_errors.mjs +95 -0
  57. package/library/actions/extension/platform/cdp.mjs +26 -0
  58. package/library/actions/mobile/app/assert_no_console_errors.mjs +129 -0
  59. package/library/actions/shared/console-assert.mjs +174 -0
  60. package/library/actions/shared/console-collector.mjs +11 -4
  61. package/library/actions/shared/console-findings.mjs +108 -7
  62. package/library/actions/web-dapp/app/assert_no_console_errors.mjs +10 -54
  63. package/library/actions/web-dapp/app/launch.mjs +1 -1
  64. package/library/actions/web-dapp/platform/adapter-web-lib.mjs +18 -0
  65. package/library/actions/web-dapp/platform/hud.mjs +3 -10
  66. package/library/actions/web-dapp/platform/page.mjs +63 -18
  67. package/library/actions/web-dapp/platform/runtime.mjs +30 -179
  68. package/library/diagnostics/console-allowlist.json +13 -0
  69. package/library/manifests/extension.action-manifest.json +36 -0
  70. package/library/manifests/mobile.action-manifest.json +36 -0
  71. package/library/manifests/web-dapp.action-manifest.json +1 -1
  72. package/library/recipes/core/health/check.recipe.json +40 -0
  73. package/library/recipes/extension/health/check.recipe.json +71 -0
  74. package/library/recipes/mobile/health/check.recipe.json +71 -0
  75. package/package.json +9 -9
  76. package/scripts/completions.sh +1 -1
  77. package/site/assets/help-recipes.json +2 -1
  78. package/adapters/web-dapp/cleanup.mjs +0 -69
  79. package/adapters/web-dapp/inject.mjs +0 -75
  80. package/adapters/web-dapp/launch.mjs +0 -631
  81. package/adapters/web-dapp/lib/blocked-hosts.mjs +0 -24
  82. package/adapters/web-dapp/lib/cdp-client.mjs +0 -84
  83. package/adapters/web-dapp/lib/display.mjs +0 -115
  84. package/adapters/web-dapp/lib/hud.mjs +0 -148
  85. package/adapters/web-dapp/lib/policy-fence.mjs +0 -119
  86. package/adapters/web-dapp/lib/policy.mjs +0 -84
  87. package/adapters/web-dapp/lib/processes.mjs +0 -187
  88. package/adapters/web-dapp/lib/readiness.mjs +0 -272
  89. package/adapters/web-dapp/stop.mjs +0 -38
  90. package/adapters/web-dapp/verify.mjs +0 -51
  91. package/adapters/web-dapp/wallet-host.mjs +0 -520
  92. package/dist/adapters/web-dapp/doctor-checks.js +0 -33
  93. package/dist/adapters/web-dapp/launch.js +0 -87
  94. package/dist/adapters/web-dapp/overlay.js +0 -21
  95. package/dist/adapters/web-dapp/readiness.js +0 -10
  96. package/dist/adapters/web-dapp/stop.js +0 -20
  97. package/dist/cli.js +0 -202
@@ -0,0 +1,212 @@
1
+ // metamask-signer.mjs — MetaMask as web-dapp's extension signer, and the
2
+ // identity the injected strict wallet presents.
3
+ //
4
+ // Purpose:
5
+ // @farmslot/adapter-web/web-dapp runs the slot browser and wallet host; this
6
+ // module is the signer module it loads (RECIPE_WEB_DAPP_SIGNER_MODULE or
7
+ // --signer-module). signer=extension loads the slot's copy of the MetaMask
8
+ // extension and imports the fixture account through the Extension adapter's
9
+ // fixture flow (wallet-fixture-state.cjs generate → prefill-profile →
10
+ // seed-cdp); the wallet host then logs every MetaMask confirmation it sees
11
+ // open. signer=injected presents the strict test wallet as MetaMask, so the
12
+ // app connects to it the way it connects to the extension.
13
+ //
14
+ // Inputs: env TERMINAL_EXTENSION_DIR (the MetaMask dist, default
15
+ // <checkout>/temp/metamask-extension/chrome), TERMINAL_EXTENSION_CHECKOUT (an
16
+ // installed metamask-extension checkout at the dist commit),
17
+ // TERMINAL_EXTENSION_NODE (that checkout's node; else asdf's install for its .nvmrc).
18
+ // Outputs: the `signers` hooks; per-slot key files in the runtime dir, which the
19
+ // launcher removes when the launch ends.
20
+ // Never prints: mnemonics, private keys, the wallet password.
21
+
22
+ import { execFileSync } from 'node:child_process';
23
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ import { fixtureEntry, readinessCheck } from '../../library/actions/web-dapp/platform/adapter-web-lib.mjs';
29
+
30
+ const HARNESS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
31
+ const FIXTURE_STATE_SCRIPT = path.join(HARNESS_ROOT, 'adapters/extension/wallet-fixture-state.cjs');
32
+ // adapter-web's LAUNCH_SERVICES launch method, named as the launch leaf names it.
33
+ const LAUNCH_SERVICES = 'launch-services';
34
+
35
+ function extensionDirFor(target, env) {
36
+ return path.resolve(env.TERMINAL_EXTENSION_DIR || path.join(target, 'temp/metamask-extension/chrome'));
37
+ }
38
+
39
+ // Node of the extension checkout (its native modules, e.g. classic-level, are
40
+ // built for that ABI): TERMINAL_EXTENSION_NODE, else asdf's install for .nvmrc.
41
+ export function resolveExtensionNode(checkout, env = process.env) {
42
+ if (env.TERMINAL_EXTENSION_NODE) return env.TERMINAL_EXTENSION_NODE;
43
+ const nvmrc = path.join(checkout, '.nvmrc');
44
+ if (existsSync(nvmrc)) {
45
+ const wanted = readFileSync(nvmrc, 'utf8').trim().replace(/^v/u, '');
46
+ const root = path.join(os.homedir(), '.asdf/installs/nodejs');
47
+ if (existsSync(root)) {
48
+ const match = readdirSync(root)
49
+ .filter((version) => version === wanted || version.startsWith(`${wanted}.`))
50
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))[0];
51
+ if (match) return path.join(root, match, 'bin/node');
52
+ }
53
+ }
54
+ return process.execPath;
55
+ }
56
+
57
+ // The Extension vault needs a mnemonic keyring, so a privateKey account is
58
+ // imported beside the fixture's first mnemonic account and selected.
59
+ export function slotWalletFixture(fixture, accountName) {
60
+ const entry = fixtureEntry(fixture, accountName);
61
+ const accounts = [];
62
+ if (entry.type === 'privateKey') {
63
+ const primary = fixture.accounts.find((account) => account?.type === 'mnemonic');
64
+ if (!primary) throw new Error(`fixture has no mnemonic account to host the imported key ${accountName}.`);
65
+ accounts.push({ ...primary, count: 1, names: [primary.name] });
66
+ accounts.push(entry);
67
+ } else {
68
+ accounts.push({ ...entry, count: 1, names: [entry.name] });
69
+ }
70
+ return { password: fixture.password, settings: fixture.settings ?? {}, accounts, selectedAccount: accountName };
71
+ }
72
+
73
+ function runFixtureState(node, phase, flags) {
74
+ return execFileSync(node, [FIXTURE_STATE_SCRIPT, phase, ...flags], {
75
+ encoding: 'utf8',
76
+ stdio: ['ignore', 'pipe', 'pipe'],
77
+ timeout: 240000,
78
+ });
79
+ }
80
+
81
+ async function extensionIdFor(withBrowserClient, idFile) {
82
+ if (existsSync(idFile)) return readFileSync(idFile, 'utf8').trim();
83
+ return withBrowserClient(async (client) => {
84
+ const deadline = Date.now() + 30000;
85
+ while (Date.now() < deadline) {
86
+ const { targetInfos } = await client.send('Target.getTargets');
87
+ const worker = targetInfos.find((info) => info.type === 'service_worker' && info.url.startsWith('chrome-extension://'));
88
+ if (worker) return new URL(worker.url).host;
89
+ await new Promise((resolve) => setTimeout(resolve, 250));
90
+ }
91
+ throw new Error('the MetaMask service worker did not start');
92
+ });
93
+ }
94
+
95
+ // MetaMask shows dapp requests in its side panel (the fixture default) or a
96
+ // notification/popup window; only request routes count as confirmations.
97
+ const WALLET_SURFACES = ['sidepanel.html', 'notification.html', 'popup.html'];
98
+ const CONFIRMATION_ROUTE = /^\/(connect|confirm-transaction|confirmation|confirm|signature-request)(\/|$)/u;
99
+
100
+ export const signers = {
101
+ injected: {
102
+ identity: {
103
+ info: {
104
+ uuid: '5d1e5b4e-e2e0-4c3f-9a1d-000000000001',
105
+ name: 'MetaMask (strict test wallet)',
106
+ icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>",
107
+ rdns: 'io.metamask',
108
+ },
109
+ isMetaMask: true,
110
+ },
111
+ },
112
+
113
+ extension: {
114
+ async prepareProfile({ target, runtime, profile, account, fixture, env, freshProfile, log, writePrivateFile, trackSecret }) {
115
+ const extensionDir = extensionDirFor(target, env);
116
+ const checkout = env.TERMINAL_EXTENSION_CHECKOUT ? path.resolve(env.TERMINAL_EXTENSION_CHECKOUT) : null;
117
+ if (!existsSync(path.join(extensionDir, 'manifest.json'))) {
118
+ throw new Error(`MetaMask extension dist not found at ${extensionDir}.\nNext: copy a built dist/chrome there or set TERMINAL_EXTENSION_DIR.`);
119
+ }
120
+ if (!checkout || !existsSync(path.join(checkout, 'node_modules'))) {
121
+ throw new Error('signer=extension needs TERMINAL_EXTENSION_CHECKOUT: an installed metamask-extension checkout at the dist commit (fixture state generation).');
122
+ }
123
+ const manifest = JSON.parse(readFileSync(path.join(extensionDir, 'manifest.json'), 'utf8'));
124
+ const node = resolveExtensionNode(checkout, env);
125
+ // Key material (the slot fixture) and the encrypted vault (fixture state).
126
+ const slotFixture = trackSecret(path.join(runtime, `wallet-fixture.${account}.json`));
127
+ const fixtureState = trackSecret(path.join(runtime, `fixture-state.${account}.json`));
128
+ writePrivateFile(slotFixture, JSON.stringify(slotWalletFixture(fixture, account)));
129
+ const idFile = path.join(runtime, 'extension.id');
130
+ runFixtureState(node, 'generate', ['--target', checkout, '--fixture', slotFixture, '--out', fixtureState]);
131
+ if (freshProfile) {
132
+ runFixtureState(node, 'prefill-profile', ['--target', checkout, '--state', fixtureState, '--profile', profile, '--extension-dir', extensionDir, '--extension-id-file', idFile]);
133
+ log(`prefilled extension profile for ${account}`);
134
+ }
135
+ return {
136
+ browserArgs: [
137
+ '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
138
+ '--disable-extensions-file-access-check',
139
+ `--disable-extensions-except=${extensionDir}`,
140
+ `--load-extension=${extensionDir}`,
141
+ ],
142
+ secrets: [],
143
+ // browser.json's `extension`; the id is filled in once the extension runs.
144
+ state: { id: undefined, version: manifest.version, dir: extensionDir, checkout },
145
+ async afterBrowserStart({ cdpPort, launchMethod, withBrowserClient, openBackgroundWindow }) {
146
+ if (launchMethod === LAUNCH_SERVICES) {
147
+ // The seed reuses an open MetaMask page; without one it would open a
148
+ // tab in the foreground and activate the browser.
149
+ const extensionId = await extensionIdFor(withBrowserClient, idFile);
150
+ await openBackgroundWindow(`chrome-extension://${extensionId}/home.html`);
151
+ }
152
+ runFixtureState(node, 'seed-cdp', [
153
+ '--target', checkout,
154
+ '--fixture', slotFixture,
155
+ '--state', fixtureState,
156
+ '--cdp-port', String(cdpPort),
157
+ '--extension-dir', extensionDir,
158
+ '--extension-id-file', idFile,
159
+ '--out', path.join(runtime, `seed-report.${account}.json`),
160
+ ]);
161
+ const id = readFileSync(idFile, 'utf8').trim();
162
+ log(`extension ${id} seeded and unlocked for ${account}`);
163
+ return { state: { id }, hostArgs: ['--extension-id', id] };
164
+ },
165
+ };
166
+ },
167
+
168
+ confirm({ args, appendEntry, say, focus }) {
169
+ const extensionOrigin = args['extension-id'] ? `chrome-extension://${args['extension-id']}` : null;
170
+ const seenConfirmationUrls = new Map();
171
+ const isWalletSurfaceUrl = (url) =>
172
+ Boolean(extensionOrigin) && WALLET_SURFACES.some((surface) => url.startsWith(`${extensionOrigin}/${surface}`));
173
+ const confirmationRoute = (url) => {
174
+ const route = (url.split('#')[1] ?? '').split('?')[0];
175
+ return CONFIRMATION_ROUTE.test(route) ? route.replace(/0x[0-9a-fA-F]{40}/gu, '<address>') : null;
176
+ };
177
+ return {
178
+ isWalletSurfaceUrl,
179
+ observe(targetId, url) {
180
+ if (!isWalletSurfaceUrl(url)) return;
181
+ if (focus && !url.includes('/sidepanel.html') && !seenConfirmationUrls.has(targetId)) {
182
+ setTimeout(() => focus.observe(url.includes('/popup.html') ? 'popup' : 'notification', 'MetaMask'), 1000);
183
+ }
184
+ const route = confirmationRoute(url);
185
+ if (!route || seenConfirmationUrls.get(targetId) === route) return;
186
+ seenConfirmationUrls.set(targetId, route);
187
+ const surface = WALLET_SURFACES.find((name) => url.includes(name))?.replace('.html', '');
188
+ appendEntry({ kind: 'confirmation-shown', t: Date.now(), surface, route });
189
+ say(`confirmation shown in ${surface}: ${route}`);
190
+ },
191
+ };
192
+ },
193
+
194
+ async readinessChecks({ target, env, required }) {
195
+ const checks = [];
196
+ const extensionDir = extensionDirFor(target, env);
197
+ const manifestPath = path.join(extensionDir, 'manifest.json');
198
+ if (existsSync(manifestPath)) {
199
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
200
+ checks.push(readinessCheck('metamask-extension', true, `MetaMask ${manifest.version} at ${extensionDir}`, { required }));
201
+ } else {
202
+ checks.push(readinessCheck('metamask-extension', false, `no extension dist at ${extensionDir}`, { required }));
203
+ }
204
+ const checkout = env.TERMINAL_EXTENSION_CHECKOUT;
205
+ const checkoutOk = Boolean(checkout) && existsSync(path.join(checkout, 'node_modules/@metamask/browser-passworder'));
206
+ checks.push(readinessCheck('extension-fixture-tooling', checkoutOk, checkoutOk
207
+ ? `extension checkout ${checkout} has fixture-state dependencies`
208
+ : 'TERMINAL_EXTENSION_CHECKOUT unset or not installed (needed to import the wallet into the extension)', { required }));
209
+ return checks;
210
+ },
211
+ },
212
+ };
@@ -43,6 +43,11 @@ const extensionActions = {
43
43
  }
44
44
  })
45
45
  }),
46
+ // The HUD lives only in the extension pages' DOM.
47
+ async clearHud() {
48
+ const { clearExtensionHud } = await import("../../../library/actions/extension/platform/cdp.mjs");
49
+ await clearExtensionHud();
50
+ },
46
51
  trustedMutation: true,
47
52
  // ensure_positions and ensure_orders only check state here.
48
53
  inputFindings: (nodeId, node) => [
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { recipeRuntimePath } from "@farmslot/recipe-cli/harness";
3
4
  import { verifyCapture } from "../../../library/actions/shared/console-collector.mjs";
4
5
  import { ensureConsoleCollector } from "../console-capture.js";
@@ -10,6 +11,21 @@ function extensionCaptureFiles(target) {
10
11
  pidFile: recipeRuntimePath(target, "console-tail.pid")
11
12
  };
12
13
  }
14
+ const EXTENSION_CONSOLE_LAUNCH_FILE = "extension-console.launch.json";
15
+ function recordConsoleLaunchIn(runtimeDir) {
16
+ const log = path.join(runtimeDir, "extension-console.log");
17
+ const stat = fs.existsSync(log) ? fs.statSync(log) : null;
18
+ fs.mkdirSync(runtimeDir, { recursive: true });
19
+ const launchedAtMs = Date.now();
20
+ fs.writeFileSync(
21
+ path.join(runtimeDir, EXTENSION_CONSOLE_LAUNCH_FILE),
22
+ `${JSON.stringify({ offset: stat?.size ?? 0, inode: stat?.ino ?? null, launchedAtMs, recordedAt: new Date(launchedAtMs).toISOString() })}
23
+ `
24
+ );
25
+ }
26
+ function recordExtensionConsoleLaunch(target) {
27
+ recordConsoleLaunchIn(path.dirname(extensionCaptureFiles(target).extensionLog));
28
+ }
13
29
  function extensionCdpPort() {
14
30
  return process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
15
31
  }
@@ -37,8 +53,10 @@ async function verifyExtensionConsoleControl(capture) {
37
53
  return verifyCapture({ cdpPort: capture.cdpPort, log: capture.extensionLog, target: "extension", extensionId });
38
54
  }
39
55
  export {
56
+ EXTENSION_CONSOLE_LAUNCH_FILE,
40
57
  ensureExtensionConsoleCapture,
41
58
  extensionCaptureFiles,
42
59
  extensionCdpPort,
60
+ recordExtensionConsoleLaunch,
43
61
  verifyExtensionConsoleControl
44
62
  };
@@ -4,6 +4,7 @@ import { colorHumanMessage, EXIT, shellQuote, spawnScriptStreaming } from "@farm
4
4
  import { FIXTURE_STREAM_TIMEOUT_MS } from "../../commands/fixtures.js";
5
5
  import { sameFeatureFlags } from "../../feature-flags.js";
6
6
  import { runnerDir } from "../../paths.js";
7
+ import { recordExtensionConsoleLaunch } from "./console-capture.js";
7
8
  import { readExtensionPinnedFlags } from "./feature-flags.js";
8
9
  import { releaseArtifactState } from "./runtime-decision.js";
9
10
  async function applyExtensionFixture({
@@ -96,6 +97,7 @@ async function applyExtensionFixture({
96
97
  }
97
98
  process.stderr.write(`${colorHumanMessage(`\u2192 fixtures ${action} extension \u2014 resetting and seeding the slot-owned profile from the existing build\u2026`)}
98
99
  `);
100
+ recordExtensionConsoleLaunch(target);
99
101
  const liveScript = path.join(runnerDir, "adapters/extension/live.sh");
100
102
  const args = ["--target", target, "--launch-existing-dist", "--launch-only", "--reset-profile"];
101
103
  const flagKeys = Object.keys(fixtureFlags);
@@ -1,13 +1,14 @@
1
- import { readRuntimeContextField, resolveRuntimeContextPath } from "@farmslot/recipe-cli/harness";
1
+ import { readRuntimeContextField, recipeRuntimeDir, resolveRuntimeContextPath } from "@farmslot/recipe-cli/harness";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { ownsRuntimeContext } from "../../../adapters/shared/resolve-slot-ports-core.mjs";
3
5
  import { resolveExtensionSlotPorts } from "../slot-ports.js";
4
6
  function hasArg(args, needle) {
5
7
  return args.some((arg) => arg === needle || arg.startsWith(`${needle}=`));
6
8
  }
7
9
  function applyExtensionRuntimeEnv(target, args) {
8
- const contextPath = resolveRuntimeContextPath(target);
9
- const contextExists = fs.existsSync(contextPath);
10
- if (contextExists) {
10
+ const contextPath = [resolveRuntimeContextPath(target), path.join(target, recipeRuntimeDir(), "agentic-runtime.json")].find((file) => fs.existsSync(file) && ownsRuntimeContext(file, target));
11
+ if (contextPath) {
11
12
  process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
12
13
  if (!process.env.RECIPE_SLOT_ID) {
13
14
  const slotId = readRuntimeContextField(contextPath, "slotId");
@@ -9,6 +9,7 @@ import {
9
9
  verifyExtensionConsoleControl
10
10
  } from "./console-capture.js";
11
11
  import { emitProofFailure } from "../proof-failure.js";
12
+ import { refuseLockedSession } from "../session-lock.js";
12
13
  import { launchedBrowser } from "./launched-browser.js";
13
14
  const AUTOLAUNCH_ENV = "METAMASK_RECIPE_EXTENSION_AUTOLAUNCH";
14
15
  const extensionRun = {
@@ -39,8 +40,9 @@ const extensionRun = {
39
40
  });
40
41
  },
41
42
  // The proof runtime is checked on the run's own ports (the engine's scope).
42
- runtimeCheck({ target, heal, state, json, onRecovery }) {
43
- return () => ensureExtensionProofRuntime(target, heal, state, json, { onRecovery });
43
+ // The Extension browser is always headful: a locked session refuses first.
44
+ runtimeCheck({ target, options, heal, state, json, onRecovery }) {
45
+ return async () => refuseLockedSession(json, options) ?? ensureExtensionProofRuntime(target, heal, state, json, { onRecovery });
44
46
  },
45
47
  launchedBrowser: (target, artifactsDir, cdpPort) => launchedBrowser(target, artifactsDir, cdpPort),
46
48
  browserProvenance: (browser) => ({
@@ -4,7 +4,9 @@ import fs from "node:fs";
4
4
  import { createRequire } from "node:module";
5
5
  import path from "node:path";
6
6
  import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, resolveFarmslotPoolDir, runnerDir } from "../../paths.js";
7
+ import { recordExtensionConsoleLaunch } from "./console-capture.js";
7
8
  import { poolFilesForHost } from "../resolve-slot-ports.js";
9
+ import { macosSessionLocked, SESSION_LOCKED_MESSAGE, SESSION_LOCKED_USER_ACTION } from "../../../adapters/shared/session-lock.mjs";
8
10
  import { profileProcessPids } from "./validation-process-ownership.js";
9
11
  const require2 = createRequire(import.meta.url);
10
12
  const { RUNTIME_NONCE_PREFIX, detachedLaunchUnprovenPath, hasDetachedLaunchUnproven, shellQuote } = require2(
@@ -62,7 +64,7 @@ async function assertHealthyExtensionRuntime(options) {
62
64
  seededLaunchDir: options.seededLaunchDir
63
65
  });
64
66
  if (lastReport.status === "PASS") return lastReport;
65
- if (lastReport.errorCode === "UI_COMPOSITOR_SUSPENDED" || lastReport.errorCode === "EXTENSION_BACKGROUND_UNRESPONSIVE") {
67
+ if (lastReport.errorCode === "SESSION_LOCKED" || lastReport.errorCode === "UI_COMPOSITOR_SUSPENDED" || lastReport.errorCode === "EXTENSION_BACKGROUND_UNRESPONSIVE") {
66
68
  throw new Error(formatHealthFailure(lastReport, options.projectRoot));
67
69
  }
68
70
  if (lastReport.errorCode === "FIXTURE_SEED_NOT_APPLIED") {
@@ -149,18 +151,17 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
149
151
  Math.min(1e3, cdpCallTimeoutMs)
150
152
  );
151
153
  if (compositor.status === "suspended") {
152
- const userAction = compositorRecoveryAction(projectRoot);
154
+ const suspended = `UI_COMPOSITOR_SUSPENDED: the Extension page is reachable over CDP but its render compositor is not advancing (${compositor.reason ?? "unknown reason"}).`;
155
+ const locked = macosSessionLocked() === true;
153
156
  return {
154
157
  status: "FAIL",
155
- errorCode: "UI_COMPOSITOR_SUSPENDED",
156
- userAction,
158
+ errorCode: locked ? "SESSION_LOCKED" : "UI_COMPOSITOR_SUSPENDED",
159
+ userAction: locked ? SESSION_LOCKED_USER_ACTION : compositorRecoveryAction(projectRoot),
157
160
  cdpPort,
158
161
  targetUrl: target.url,
159
162
  extensionId: safeExtensionId(target),
160
163
  extensionPageTargets: extensionTargets.length,
161
- findings: [
162
- `UI_COMPOSITOR_SUSPENDED: the Extension page is reachable over CDP but its render compositor is not advancing (${compositor.reason ?? "unknown reason"}).`
163
- ],
164
+ findings: locked ? [`SESSION_LOCKED: ${SESSION_LOCKED_MESSAGE}`, suspended] : [suspended],
164
165
  details: {
165
166
  projectRoot,
166
167
  targetUrls: targets.map((entry) => entry.url).filter(Boolean),
@@ -666,10 +667,11 @@ async function launchLockedValidationBrowser(options) {
666
667
  fs.mkdirSync(validationProfileRoot, { recursive: true });
667
668
  fs.mkdirSync(validationArtifactsRoot, { recursive: true });
668
669
  assertNoQuarantinedValidationProfiles(validationProfileRoot);
670
+ recordExtensionConsoleLaunch(options.projectRoot);
669
671
  await stopOwnedCdpListener(options.cdpPort, [
670
672
  validationRuntimePath,
671
673
  path.join(options.projectRoot, recipeRuntimeDir())
672
- ]);
674
+ ], path.join(options.projectRoot, recipeRuntimeDir()));
673
675
  assertNoLiveValidationProfileOwners(validationProfileRoot);
674
676
  pruneValidationRuns(validationProfileRoot, 0);
675
677
  pruneValidationRuns(validationArtifactsRoot, 4);
@@ -933,16 +935,32 @@ function runProcess(command, args, options) {
933
935
  });
934
936
  });
935
937
  }
936
- async function stopOwnedCdpListener(cdpPort, runtimePaths) {
938
+ async function stopOwnedCdpListener(cdpPort, runtimePaths, checkoutRuntimeRoot) {
937
939
  if (process.platform !== "darwin" && process.platform !== "linux") return;
938
940
  let listenerPids = await inspectCdpListenerPids(cdpPort);
939
941
  if (listenerPids.length === 0) return;
940
942
  const identity = runtimePaths.map((runtimePath) => readValidationRuntimeIdentity(runtimePath, cdpPort)).find((candidate) => candidate?.pid === listenerPids[0]);
941
- if (!identity || listenerPids.length !== 1 || listenerPids[0] !== identity.pid || !await browserEchoesRuntimeNonce(cdpPort, identity.nonce)) {
942
- throw new Error(`Extension validation will not stop an unowned CDP listener on port ${cdpPort}.`);
943
+ let pid;
944
+ if (identity && listenerPids.length === 1 && listenerPids[0] === identity.pid && await browserEchoesRuntimeNonce(cdpPort, identity.nonce)) {
945
+ pid = identity.pid;
946
+ }
947
+ if (pid === void 0) {
948
+ const listeners = await listenerCommands(listenerPids);
949
+ const listener = listenerPids.length === 1 ? listeners.get(listenerPids[0]) : void 0;
950
+ if (listener?.chrome && !listener.ambiguous && pathInsideReal(checkoutRuntimeRoot, listener.profile) && (await listenerCommands(listenerPids)).get(listenerPids[0])?.command === listener.command) {
951
+ pid = listenerPids[0];
952
+ } else {
953
+ const seen = listenerPids.map((candidate) => {
954
+ const profile = listeners.get(candidate)?.profile;
955
+ return profile ? `pid ${candidate} --user-data-dir=${profile}` : `pid ${candidate}`;
956
+ }).join("; ");
957
+ throw new Error(
958
+ `Extension validation will not stop an unowned CDP listener on port ${cdpPort} (${seen}). Next: if that is this slot's browser, stop it (kill ${listenerPids.join(" ")}); otherwise free port ${cdpPort}. Then rerun.`
959
+ );
960
+ }
943
961
  }
944
962
  try {
945
- process.kill(identity.pid, "SIGTERM");
963
+ process.kill(pid, "SIGTERM");
946
964
  } catch (error) {
947
965
  if (error.code !== "ESRCH") throw error;
948
966
  }
@@ -950,10 +968,10 @@ async function stopOwnedCdpListener(cdpPort, runtimePaths) {
950
968
  do {
951
969
  await sleep(50);
952
970
  listenerPids = await inspectCdpListenerPids(cdpPort);
953
- } while ((listenerPids.length > 0 || processExists(identity.pid)) && Date.now() < termDeadline);
954
- if (listenerPids.length > 0 || processExists(identity.pid)) {
971
+ } while ((listenerPids.length > 0 || processExists(pid)) && Date.now() < termDeadline);
972
+ if (listenerPids.length > 0 || processExists(pid)) {
955
973
  try {
956
- process.kill(identity.pid, "SIGKILL");
974
+ process.kill(pid, "SIGKILL");
957
975
  } catch (error) {
958
976
  if (error.code !== "ESRCH") throw error;
959
977
  }
@@ -961,12 +979,48 @@ async function stopOwnedCdpListener(cdpPort, runtimePaths) {
961
979
  do {
962
980
  await sleep(50);
963
981
  listenerPids = await inspectCdpListenerPids(cdpPort);
964
- } while ((listenerPids.length > 0 || processExists(identity.pid)) && Date.now() < killDeadline);
982
+ } while ((listenerPids.length > 0 || processExists(pid)) && Date.now() < killDeadline);
965
983
  }
966
- if (listenerPids.length > 0 || processExists(identity.pid)) {
984
+ if (listenerPids.length > 0 || processExists(pid)) {
967
985
  throw new Error(`Extension validation could not stop its CDP listener on port ${cdpPort}.`);
968
986
  }
969
987
  }
988
+ async function listenerCommands(pids) {
989
+ const listeners = /* @__PURE__ */ new Map();
990
+ const result = await runProcess("ps", ["-ww", "-o", "pid=,command=", "-p", pids.join(",")], {
991
+ cwd: runnerDir,
992
+ env: process.env,
993
+ timeoutMs: 5e3
994
+ });
995
+ if (result.timedOut) return listeners;
996
+ for (const line of result.stdout.split("\n")) {
997
+ const match = line.match(/^\s*(\d+)\s+(.*)$/u);
998
+ if (!match) continue;
999
+ const command = match[2];
1000
+ const flags = [...command.matchAll(/(?:^|\s)--user-data-dir=/gu)];
1001
+ const last = flags.at(-1);
1002
+ if (!last) continue;
1003
+ const value = command.slice(last.index + last[0].length);
1004
+ const ends = [...value.matchAll(/\s--/gu)].map((end) => end.index);
1005
+ const profile = value.slice(0, ends[0] ?? value.length);
1006
+ const longerReadings = [...ends.slice(1), value.length].map((end) => value.slice(0, end));
1007
+ listeners.set(Number(match[1]), {
1008
+ command,
1009
+ profile,
1010
+ chrome: /chrom/iu.test(path.basename(command.split(/\s-/u)[0])),
1011
+ ambiguous: flags.length > 1 || !path.isAbsolute(profile) || ends.length > 0 && longerReadings.some((reading) => fs.existsSync(reading))
1012
+ });
1013
+ }
1014
+ return listeners;
1015
+ }
1016
+ function pathInsideReal(root, candidate) {
1017
+ try {
1018
+ const relative = path.relative(fs.realpathSync(root), fs.realpathSync(candidate));
1019
+ return Boolean(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
1020
+ } catch {
1021
+ return false;
1022
+ }
1023
+ }
970
1024
  async function inspectCdpListenerPids(cdpPort) {
971
1025
  const result = await runProcess("lsof", ["-ti", `tcp:${cdpPort}`, "-sTCP:LISTEN"], {
972
1026
  cwd: runnerDir,
@@ -1040,5 +1094,6 @@ export {
1040
1094
  extensionBackgroundProbeTimeoutMs,
1041
1095
  formatHealthFailure,
1042
1096
  prepareExtensionRuntime,
1043
- seededLaunchDir
1097
+ seededLaunchDir,
1098
+ stopOwnedCdpListener
1044
1099
  };
@@ -9,7 +9,11 @@ import {
9
9
  } from "../../commands/mobile-device-view.js";
10
10
  const mobileDevices = {
11
11
  view: mobileDeviceView,
12
- live: mobileDeviceLiveView,
12
+ // adapter-sdk's live view names the app's feature-flag overrides `featureFlags`.
13
+ async live(target, view) {
14
+ const live = await mobileDeviceLiveView(target, view);
15
+ return { ...live, featureFlags: live.featureFlagOverrides };
16
+ },
13
17
  renderList: renderMobileDeviceList,
14
18
  renderAdditional: renderAdditionalReachableDevices,
15
19
  renderLive: renderMobileLiveBlock,
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { recipeRuntimePath, EXIT, str, writeInteractiveProgress } from "@farmslot/recipe-cli/harness";
3
4
  import { applyDeviceTargeting } from "../../commands/device-target.js";
4
5
  import { launchMobile } from "../../commands/launch/mobile.js";
@@ -49,6 +50,14 @@ function metroLaunchEvidence(target) {
49
50
  if (descriptor !== void 0) fs.closeSync(descriptor);
50
51
  }
51
52
  }
53
+ function recordMobileConsoleLaunch(target) {
54
+ const file = recipeRuntimePath(target, "app-console.launch.json");
55
+ const launchedAtMs = Date.now();
56
+ fs.mkdirSync(path.dirname(file), { recursive: true });
57
+ fs.writeFileSync(file, `${JSON.stringify({ launchedAtMs, recordedAt: new Date(launchedAtMs).toISOString() })}
58
+ `);
59
+ }
60
+ const markedLaunches = /* @__PURE__ */ new WeakSet();
52
61
  const mobileLaunchProfile = {
53
62
  refuseFlags({ platformTarget }) {
54
63
  if (!platformTarget) {
@@ -104,7 +113,14 @@ const mobileLaunchProfile = {
104
113
  },
105
114
  nativeInputs: ["ios", "android", "package.json", "yarn.lock"],
106
115
  rebuildCommand: ({ platformTarget }) => `mm-harness launch ${platformTarget} --build`,
107
- compose: ({ target, platformTarget, machine, remoteFlagRequest }, tier, restartApp, clearMetro) => launchMobile(target, platformTarget, tier, machine, restartApp, clearMetro, remoteFlagRequest?.flags),
116
+ compose: (launch, tier, restartApp, clearMetro) => {
117
+ const { target, platformTarget, machine, remoteFlagRequest } = launch;
118
+ if (restartApp || !markedLaunches.has(launch)) {
119
+ markedLaunches.add(launch);
120
+ recordMobileConsoleLaunch(target);
121
+ }
122
+ return launchMobile(target, platformTarget, tier, machine, restartApp, clearMetro, remoteFlagRequest?.flags);
123
+ },
108
124
  classify(_launch, output) {
109
125
  if (output.includes("open-device: Android device is locked.")) {
110
126
  return {
@@ -187,5 +203,6 @@ const mobileLaunchProfile = {
187
203
  evidence: metroLaunchEvidence
188
204
  };
189
205
  export {
190
- mobileLaunchProfile
206
+ mobileLaunchProfile,
207
+ recordMobileConsoleLaunch
191
208
  };
@@ -4,7 +4,7 @@ import crypto from "node:crypto";
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { resolveSlotPortsByRepo } from "../../../adapters/shared/resolve-slot-ports-core.mjs";
7
+ import { ownsRuntimeContext, resolveSlotPortsByRepo } from "../../../adapters/shared/resolve-slot-ports-core.mjs";
8
8
  const RUNWAY_IOS_METADATA = {
9
9
  artifactName: "ios-app-main-dev-expo",
10
10
  workflow: "expo-dev-build.yml",
@@ -177,8 +177,10 @@ function fail(target, platform, code, message, rerunCommand, slot) {
177
177
  };
178
178
  }
179
179
  function readSlotContext(target, runtimeDir) {
180
+ const envContext = process.env.RECIPE_RUNTIME_CONTEXT;
180
181
  const candidates = [
181
- process.env.RECIPE_RUNTIME_CONTEXT,
182
+ // Another checkout's context would name its slot and simulator.
183
+ envContext && ownsRuntimeContext(envContext, target) ? envContext : void 0,
182
184
  runtimeContextPath(target, runtimeDir),
183
185
  path.join(target, recipeRuntimeDir(), "agentic-runtime.json"),
184
186
  path.join(target, "temp/recipe/runtime/agentic-runtime.json"),
@@ -57,7 +57,8 @@ const mobileRun = {
57
57
  // matches its target against it.
58
58
  RECIPE_RN_EXPLICIT_PLATFORM: process.env.RECIPE_RN_EXPLICIT_PLATFORM
59
59
  }),
60
- // An installed release artifact is opaque: no harness HUD inside it.
60
+ // An installed release artifact is opaque: it has no dev bridge and no
61
+ // AgentStepHud, so there is nothing to draw the HUD with.
61
62
  autoHud: () => process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME !== "1",
62
63
  // Always clear a HUD step the engine left on-device (a failed run strands
63
64
  // the FAIL banner). Best-effort — never masks the run's real outcome.
@@ -82,7 +83,7 @@ async function ensureMobileProofRuntime(target, _heal, state, json, deps = {}) {
82
83
  }
83
84
  if (initial.status === "current") return null;
84
85
  const targetArg = JSON.stringify(path.resolve(target));
85
- const userAction = `mm-harness call app.lifecycle --arg command=restart --adapter mobile --target ${targetArg}`;
86
+ const userAction = `mm-harness call app.lifecycle command=restart --adapter mobile --target ${targetArg}`;
86
87
  const message = `Mobile loaded source is not current (${initial.status}).`;
87
88
  emitProofFailure(json, {
88
89
  recoverable: true,
@@ -53,9 +53,8 @@ const mobileSurface = defineAdapter({
53
53
  devServer: {
54
54
  label: "metro",
55
55
  describe: () => "Metro dev server",
56
- // Metro's leaves read the port as METRO_PORT; run and call take it as --metro-port.
56
+ // Metro's leaves read the port as METRO_PORT.
57
57
  portEnv: ["METRO_PORT"],
58
- portFlags: ["metroPort"],
59
58
  stop(target) {
60
59
  const leaf = path.join(runnerDir, "adapters", "mobile", "stop-metro.sh");
61
60
  const args = ["--target", target];