@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.
- package/CHANGELOG.md +62 -0
- package/README.md +8 -1
- package/adapters/extension/console-tail.mjs +11 -7
- package/adapters/extension/ensure-browser.sh +17 -18
- package/adapters/manifest.json +14 -110
- package/adapters/mobile/start-console-forwarder.sh +8 -0
- package/adapters/shared/resolve-slot-ports-core.mjs +48 -6
- package/adapters/shared/session-lock.mjs +43 -0
- package/adapters/web-dapp/metamask-signer.mjs +212 -0
- package/dist/adapters/extension/actions.js +5 -0
- package/dist/adapters/extension/console-capture.js +18 -0
- package/dist/adapters/extension/fixtures.js +2 -0
- package/dist/adapters/extension/overlay.js +5 -4
- package/dist/adapters/extension/run.js +4 -2
- package/dist/adapters/extension/runtime.js +73 -18
- package/dist/adapters/mobile/devices.js +5 -1
- package/dist/adapters/mobile/launch.js +19 -2
- package/dist/adapters/mobile/provision.js +4 -2
- package/dist/adapters/mobile/run.js +3 -2
- package/dist/adapters/mobile/surface.js +1 -2
- package/dist/adapters/mobile/verify.js +15 -7
- package/dist/adapters/plugin-surface.js +14 -42
- package/dist/adapters/session-lock.js +23 -0
- package/dist/adapters/web-dapp/actions.js +7 -5
- package/dist/adapters/web-dapp/console-capture.js +2 -20
- package/dist/adapters/web-dapp/run.js +25 -24
- package/dist/adapters/web-dapp/surface.js +32 -108
- package/dist/adapters.js +5 -3
- package/dist/command-contract.js +1351 -643
- package/dist/commands/check.js +5 -7
- package/dist/commands/debug.js +2 -2
- package/dist/commands/doctor.js +3 -4
- package/dist/commands/domain.js +4 -3
- package/dist/commands/fixtures.js +3 -3
- package/dist/commands/help.js +6 -5
- package/dist/commands/launch/device.js +1 -1
- package/dist/commands/launch/extension.js +9 -7
- package/dist/commands/logs.js +2 -2
- package/dist/commands/prepare.js +4 -4
- package/dist/commands/provision.js +2 -2
- package/dist/commands/review.js +8 -10
- package/dist/commands/runtime-launch.js +2 -0
- package/dist/commands/status.js +4 -2
- package/dist/commands/task-init.js +2 -3
- package/dist/console-classifier.js +3 -2
- package/dist/doctor.js +11 -4
- package/dist/mm-harness-cli.js +241 -1210
- package/dist/paths.js +9 -4
- package/dist/recipe-security.js +1 -1
- package/dist/review/knowledge.js +10 -2
- package/dist/runner.js +18 -3
- package/docs/CONTRIBUTING.md +14 -4
- package/docs/RECIPES.md +14 -1
- package/docs/cli-contract.md +8 -7
- package/docs/feature-guard.md +1 -1
- package/library/actions/extension/app/assert_no_console_errors.mjs +95 -0
- package/library/actions/extension/platform/cdp.mjs +26 -0
- package/library/actions/mobile/app/assert_no_console_errors.mjs +129 -0
- package/library/actions/shared/console-assert.mjs +174 -0
- package/library/actions/shared/console-collector.mjs +11 -4
- package/library/actions/shared/console-findings.mjs +108 -7
- package/library/actions/web-dapp/app/assert_no_console_errors.mjs +10 -54
- package/library/actions/web-dapp/app/launch.mjs +1 -1
- package/library/actions/web-dapp/platform/adapter-web-lib.mjs +18 -0
- package/library/actions/web-dapp/platform/hud.mjs +3 -10
- package/library/actions/web-dapp/platform/page.mjs +63 -18
- package/library/actions/web-dapp/platform/runtime.mjs +30 -179
- package/library/diagnostics/console-allowlist.json +13 -0
- package/library/manifests/extension.action-manifest.json +36 -0
- package/library/manifests/mobile.action-manifest.json +36 -0
- package/library/manifests/web-dapp.action-manifest.json +1 -1
- package/library/recipes/core/health/check.recipe.json +40 -0
- package/library/recipes/extension/health/check.recipe.json +71 -0
- package/library/recipes/mobile/health/check.recipe.json +71 -0
- package/package.json +9 -9
- package/scripts/completions.sh +1 -1
- package/site/assets/help-recipes.json +2 -1
- package/adapters/web-dapp/cleanup.mjs +0 -69
- package/adapters/web-dapp/inject.mjs +0 -75
- package/adapters/web-dapp/launch.mjs +0 -631
- package/adapters/web-dapp/lib/blocked-hosts.mjs +0 -24
- package/adapters/web-dapp/lib/cdp-client.mjs +0 -84
- package/adapters/web-dapp/lib/display.mjs +0 -115
- package/adapters/web-dapp/lib/hud.mjs +0 -148
- package/adapters/web-dapp/lib/policy-fence.mjs +0 -119
- package/adapters/web-dapp/lib/policy.mjs +0 -84
- package/adapters/web-dapp/lib/processes.mjs +0 -187
- package/adapters/web-dapp/lib/readiness.mjs +0 -272
- package/adapters/web-dapp/stop.mjs +0 -38
- package/adapters/web-dapp/verify.mjs +0 -51
- package/adapters/web-dapp/wallet-host.mjs +0 -520
- package/dist/adapters/web-dapp/doctor-checks.js +0 -33
- package/dist/adapters/web-dapp/launch.js +0 -87
- package/dist/adapters/web-dapp/overlay.js +0 -21
- package/dist/adapters/web-dapp/readiness.js +0 -10
- package/dist/adapters/web-dapp/stop.js +0 -20
- 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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
942
|
-
|
|
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(
|
|
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(
|
|
954
|
-
if (listenerPids.length > 0 || processExists(
|
|
971
|
+
} while ((listenerPids.length > 0 || processExists(pid)) && Date.now() < termDeadline);
|
|
972
|
+
if (listenerPids.length > 0 || processExists(pid)) {
|
|
955
973
|
try {
|
|
956
|
-
process.kill(
|
|
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(
|
|
982
|
+
} while ((listenerPids.length > 0 || processExists(pid)) && Date.now() < killDeadline);
|
|
965
983
|
}
|
|
966
|
-
if (listenerPids.length > 0 || processExists(
|
|
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
|
|
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: (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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];
|