@deeeed/metamask-harness 0.70.0 → 0.71.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 +28 -0
- package/README.md +16 -9
- package/adapters/extension/console-tail.mjs +38 -6
- package/adapters/extension/launch-browser.cjs +16 -4
- package/adapters/manifest.json +128 -0
- package/adapters/shared/browser-cdp.cjs +21 -7
- package/adapters/shared/browser-resolver.cjs +22 -11
- package/adapters/terminal/cleanup.mjs +68 -0
- package/adapters/terminal/inject.mjs +74 -0
- package/adapters/terminal/launch.mjs +628 -0
- package/adapters/terminal/lib/cdp-client.mjs +84 -0
- package/adapters/terminal/lib/display.mjs +115 -0
- package/adapters/terminal/lib/hud.mjs +148 -0
- package/adapters/terminal/lib/mainnet-guard.mjs +177 -0
- package/adapters/terminal/lib/origin.mjs +30 -0
- package/adapters/terminal/lib/page-script.mjs +246 -0
- package/adapters/terminal/lib/processes.mjs +187 -0
- package/adapters/terminal/lib/readiness.mjs +274 -0
- package/adapters/terminal/lib/strict-wallet.mjs +138 -0
- package/adapters/terminal/stop.mjs +37 -0
- package/adapters/terminal/verify.mjs +48 -0
- package/adapters/terminal/wallet-host.mjs +703 -0
- package/dist/adapters/console-capture.js +122 -0
- package/dist/adapters/extension/console-capture.js +22 -85
- package/dist/adapters/surface.js +3 -1
- package/dist/adapters/terminal/console-capture.js +46 -0
- package/dist/adapters/terminal/stop.js +19 -0
- package/dist/adapters/terminal/surface.js +104 -0
- package/dist/adapters.js +27 -4
- package/dist/cli.js +2 -2
- package/dist/command-contract.js +12 -4
- package/dist/commands/check.js +1 -1
- package/dist/commands/config.js +1 -1
- package/dist/commands/doctor.js +24 -1
- package/dist/commands/domain.js +1 -1
- package/dist/commands/farmslot-ready.js +2 -0
- package/dist/commands/help.js +2 -2
- package/dist/commands/launch/index.js +97 -2
- package/dist/commands/manifest.js +3 -3
- package/dist/commands/recipe-advice.js +1 -1
- package/dist/commands/review.js +1 -1
- package/dist/commands/run-engine.js +1 -1
- package/dist/commands/shared.js +1 -1
- package/dist/doctor.js +4 -1
- package/dist/harness.js +17 -11
- package/dist/manifest.js +5 -0
- package/dist/mm-harness-cli.js +34 -20
- package/dist/paths.js +2 -2
- package/dist/recipe-security.js +12 -3
- package/dist/review/knowledge.js +1 -1
- package/dist/run-diagnostics.js +150 -33
- package/dist/runner.js +1 -1
- package/dist/runtime-context.js +3 -3
- package/docs/CONTRIBUTING.md +1 -1
- package/library/actions/extension/platform/cdp.mjs +35 -10
- package/library/actions/shared/console-allowlist-add.mjs +137 -0
- package/library/actions/shared/console-collector.mjs +230 -0
- package/library/actions/shared/console-findings.mjs +511 -0
- package/library/actions/terminal/app/assert_no_console_errors.mjs +119 -0
- package/library/actions/terminal/app/launch.mjs +45 -0
- package/library/actions/terminal/perps/_venue.mjs +242 -0
- package/library/actions/terminal/perps/assert_orders.mjs +54 -0
- package/library/actions/terminal/perps/assert_positions.mjs +40 -0
- package/library/actions/terminal/perps/read_orders.mjs +26 -0
- package/library/actions/terminal/perps/read_positions.mjs +25 -0
- package/library/actions/terminal/perps/read_snapshot.mjs +40 -0
- package/library/actions/terminal/perps/revoke_agent.mjs +58 -0
- package/library/actions/terminal/perps/teardown_state.mjs +104 -0
- package/library/actions/terminal/platform/hud.mjs +71 -0
- package/library/actions/terminal/platform/page.mjs +247 -0
- package/library/actions/terminal/platform/runtime.mjs +200 -0
- package/library/actions/terminal/ui/navigate.mjs +31 -0
- package/library/actions/terminal/wallet/_signature-log.mjs +177 -0
- package/library/actions/terminal/wallet/assert_signatures.mjs +43 -0
- package/library/actions/terminal/wallet/list_accounts.mjs +27 -0
- package/library/actions/terminal/wallet/read_signatures.mjs +37 -0
- package/library/manifests/terminal.action-manifest.json +1786 -0
- package/package.json +1 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Source of the script the wallet host evaluates in every app document before
|
|
2
|
+
// page scripts. It reports each wallet request the app makes (method, EIP-712
|
|
3
|
+
// primary type, domain/active chain, outcome; no params or signatures) through
|
|
4
|
+
// a CDP binding, and in injected mode also provides the strict test wallet
|
|
5
|
+
// announced over EIP-6963 as io.metamask. It installs itself only in the top
|
|
6
|
+
// frame of a document on the exact app origin: the CDP preload also runs in
|
|
7
|
+
// iframes and after the tab navigates elsewhere. Errors are logged as a code
|
|
8
|
+
// and a fixed category, never as provider text.
|
|
9
|
+
|
|
10
|
+
import { mainnetTypedDataReason } from './mainnet-guard.mjs';
|
|
11
|
+
|
|
12
|
+
export const LOG_BINDING = '__mmTerminalWalletLog';
|
|
13
|
+
export const REQUEST_BINDING = '__mmTerminalWalletRequest';
|
|
14
|
+
export const RESOLVE_FN = '__mmTerminalWalletResolve';
|
|
15
|
+
|
|
16
|
+
export const LOGGED_METHODS = Object.freeze([
|
|
17
|
+
'eth_requestAccounts',
|
|
18
|
+
'wallet_requestPermissions',
|
|
19
|
+
'wallet_revokePermissions',
|
|
20
|
+
'wallet_switchEthereumChain',
|
|
21
|
+
'wallet_addEthereumChain',
|
|
22
|
+
'eth_signTypedData',
|
|
23
|
+
'eth_signTypedData_v3',
|
|
24
|
+
'eth_signTypedData_v4',
|
|
25
|
+
'personal_sign',
|
|
26
|
+
'eth_sign',
|
|
27
|
+
'eth_sendTransaction',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export const ERROR_CATEGORIES = Object.freeze({
|
|
31
|
+
4001: 'user-rejected',
|
|
32
|
+
4100: 'unauthorized',
|
|
33
|
+
4200: 'unsupported-method',
|
|
34
|
+
4900: 'disconnected',
|
|
35
|
+
4901: 'chain-disconnected',
|
|
36
|
+
4902: 'unrecognized-chain',
|
|
37
|
+
[-32602]: 'invalid-params',
|
|
38
|
+
[-32603]: 'internal',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function pageMain(config, mainnetTypedDataReason) {
|
|
42
|
+
if (window.top !== window || location.origin !== config.appOrigin) return;
|
|
43
|
+
if (window.__mmTerminalHarness) return;
|
|
44
|
+
// The wallet host's ready check reads this marker on the committed document:
|
|
45
|
+
// the preload ran, and the page's provider is wrapped (covers).
|
|
46
|
+
const wrapped = new WeakSet();
|
|
47
|
+
const harness = {
|
|
48
|
+
signer: config.signer,
|
|
49
|
+
network: config.network,
|
|
50
|
+
wrapped: 0,
|
|
51
|
+
wrapFailures: [],
|
|
52
|
+
covers: (provider) => Boolean(provider) && wrapped.has(provider),
|
|
53
|
+
};
|
|
54
|
+
window.__mmTerminalHarness = harness;
|
|
55
|
+
const logged = new Set(config.loggedMethods);
|
|
56
|
+
// Without the host's binding a wallet request would go unrecorded, so it is
|
|
57
|
+
// refused (fail closed) rather than passed through.
|
|
58
|
+
const unrecorded = () => Object.assign(
|
|
59
|
+
new Error('The harness wallet host is not attached to this document; the request was refused so it cannot go unrecorded.'),
|
|
60
|
+
{ code: 4100 },
|
|
61
|
+
);
|
|
62
|
+
const hasBinding = (binding) => typeof window[binding] === 'function';
|
|
63
|
+
const report = (entry) => {
|
|
64
|
+
try {
|
|
65
|
+
window[config.logBinding](JSON.stringify(entry));
|
|
66
|
+
} catch {
|
|
67
|
+
// Binding unavailable (host detached): the request still proceeds.
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const describe = (method, params) => {
|
|
71
|
+
const entry = { kind: 'request', method, t: Date.now(), url: location.pathname };
|
|
72
|
+
if (/^eth_signTypedData/.test(method)) {
|
|
73
|
+
try {
|
|
74
|
+
const raw = params?.[1];
|
|
75
|
+
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
76
|
+
entry.primaryType = data?.primaryType ?? null;
|
|
77
|
+
entry.domainName = data?.domain?.name ?? null;
|
|
78
|
+
entry.domainChainId = data?.domain?.chainId == null ? null : Number(BigInt(data.domain.chainId));
|
|
79
|
+
} catch {
|
|
80
|
+
entry.primaryType = null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (method === 'wallet_switchEthereumChain' || method === 'wallet_addEthereumChain') {
|
|
84
|
+
try {
|
|
85
|
+
entry.toChainId = Number.parseInt(params[0].chainId, 16);
|
|
86
|
+
} catch {
|
|
87
|
+
entry.toChainId = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return entry;
|
|
91
|
+
};
|
|
92
|
+
const wrap = (provider, source) => {
|
|
93
|
+
if (!provider || typeof provider.request !== 'function' || wrapped.has(provider)) return provider;
|
|
94
|
+
const original = provider.request.bind(provider);
|
|
95
|
+
const request = async (args) => {
|
|
96
|
+
const method = args?.method;
|
|
97
|
+
if (!logged.has(method)) return original(args);
|
|
98
|
+
if (!hasBinding(config.logBinding)) throw unrecorded();
|
|
99
|
+
const entry = { ...describe(method, args?.params), source };
|
|
100
|
+
// Testnet runs never let a mainnet Hyperliquid action reach the signer.
|
|
101
|
+
if (config.network === 'testnet' && /^eth_signTypedData/.test(method)) {
|
|
102
|
+
let reason = null;
|
|
103
|
+
try {
|
|
104
|
+
const raw = args?.params?.[1];
|
|
105
|
+
reason = mainnetTypedDataReason(typeof raw === 'string' ? JSON.parse(raw) : raw);
|
|
106
|
+
} catch {
|
|
107
|
+
reason = null;
|
|
108
|
+
}
|
|
109
|
+
if (reason) {
|
|
110
|
+
report({ ...entry, kind: 'refused-mainnet', reason, outcome: 'refused', errorCode: 4100 });
|
|
111
|
+
throw Object.assign(new Error('Refused: this test run is held to Hyperliquid testnet and the request signs a mainnet action.'), { code: 4100 });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
entry.activeChainId = Number.parseInt(await original({ method: 'eth_chainId' }), 16);
|
|
116
|
+
} catch {
|
|
117
|
+
entry.activeChainId = null;
|
|
118
|
+
}
|
|
119
|
+
const started = Date.now();
|
|
120
|
+
try {
|
|
121
|
+
const result = await original(args);
|
|
122
|
+
entry.outcome = /sign/i.test(method) ? 'signed' : 'approved';
|
|
123
|
+
entry.durationMs = Date.now() - started;
|
|
124
|
+
report(entry);
|
|
125
|
+
return result;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const code = Number.isInteger(error?.code) ? error.code : null;
|
|
128
|
+
entry.outcome = code === 4001 ? 'rejected' : 'error';
|
|
129
|
+
entry.errorCode = code;
|
|
130
|
+
entry.errorCategory = config.errorCategories[code] ?? 'other';
|
|
131
|
+
entry.durationMs = Date.now() - started;
|
|
132
|
+
report(entry);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
provider.request = request;
|
|
138
|
+
} catch {
|
|
139
|
+
// Read-only property: define it instead (below).
|
|
140
|
+
}
|
|
141
|
+
if (provider.request !== request) {
|
|
142
|
+
try {
|
|
143
|
+
Object.defineProperty(provider, 'request', { value: request, configurable: true, writable: true });
|
|
144
|
+
} catch {
|
|
145
|
+
// Frozen provider: recorded below.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// A provider whose request could not be replaced would sign unrecorded.
|
|
149
|
+
if (provider.request !== request) {
|
|
150
|
+
harness.wrapFailures.push(source);
|
|
151
|
+
return provider;
|
|
152
|
+
}
|
|
153
|
+
wrapped.add(provider);
|
|
154
|
+
harness.wrapped += 1;
|
|
155
|
+
return provider;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (config.signer === 'injected') {
|
|
159
|
+
const listeners = {};
|
|
160
|
+
const emit = (event, ...args) => (listeners[event] ?? []).forEach((fn) => {
|
|
161
|
+
try { fn(...args); } catch { /* listener errors stay in the app */ }
|
|
162
|
+
});
|
|
163
|
+
const pending = new Map();
|
|
164
|
+
let nextId = 1;
|
|
165
|
+
window[config.resolveFn] = (id, outcome) => {
|
|
166
|
+
const entry = pending.get(id);
|
|
167
|
+
if (!entry) return;
|
|
168
|
+
pending.delete(id);
|
|
169
|
+
if (outcome.error) entry.reject(Object.assign(new Error(outcome.error.message), { code: outcome.error.code }));
|
|
170
|
+
else entry.resolve(outcome.result);
|
|
171
|
+
};
|
|
172
|
+
let lastChain = null;
|
|
173
|
+
const provider = {
|
|
174
|
+
isMetaMask: true,
|
|
175
|
+
_metamask: { isUnlocked: async () => true },
|
|
176
|
+
request: ({ method, params }) => new Promise((resolve, reject) => {
|
|
177
|
+
if (!hasBinding(config.requestBinding)) {
|
|
178
|
+
reject(unrecorded());
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const id = nextId++;
|
|
182
|
+
pending.set(id, { resolve, reject });
|
|
183
|
+
window[config.requestBinding](JSON.stringify({ id, method, params }));
|
|
184
|
+
}).then((result) => {
|
|
185
|
+
if (method === 'wallet_switchEthereumChain' && params?.[0]?.chainId !== lastChain) {
|
|
186
|
+
lastChain = params[0].chainId;
|
|
187
|
+
emit('chainChanged', lastChain);
|
|
188
|
+
}
|
|
189
|
+
if (method === 'eth_chainId') lastChain = result;
|
|
190
|
+
if (method === 'eth_requestAccounts') emit('connect', { chainId: lastChain ?? '0x1' });
|
|
191
|
+
return result;
|
|
192
|
+
}),
|
|
193
|
+
on: (event, fn) => { (listeners[event] ??= []).push(fn); return provider; },
|
|
194
|
+
removeListener: (event, fn) => { listeners[event] = (listeners[event] ?? []).filter((f) => f !== fn); return provider; },
|
|
195
|
+
enable: () => provider.request({ method: 'eth_requestAccounts' }),
|
|
196
|
+
};
|
|
197
|
+
wrap(provider, 'injected-strict-wallet');
|
|
198
|
+
window.ethereum = provider;
|
|
199
|
+
const info = {
|
|
200
|
+
uuid: '5d1e5b4e-e2e0-4c3f-9a1d-000000000001',
|
|
201
|
+
name: 'MetaMask (strict test wallet)',
|
|
202
|
+
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>",
|
|
203
|
+
rdns: 'io.metamask',
|
|
204
|
+
};
|
|
205
|
+
const announce = () => window.dispatchEvent(new CustomEvent('eip6963:announceProvider', { detail: Object.freeze({ info, provider }) }));
|
|
206
|
+
window.addEventListener('eip6963:requestProvider', announce);
|
|
207
|
+
announce();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Extension mode: wrap whatever provider MetaMask injects, however the app
|
|
212
|
+
// reaches it (window.ethereum or an EIP-6963 announcement).
|
|
213
|
+
let current = window.ethereum ? wrap(window.ethereum, 'window.ethereum') : undefined;
|
|
214
|
+
try {
|
|
215
|
+
Object.defineProperty(window, 'ethereum', {
|
|
216
|
+
configurable: true,
|
|
217
|
+
get: () => current,
|
|
218
|
+
set: (value) => { current = wrap(value, 'window.ethereum'); },
|
|
219
|
+
});
|
|
220
|
+
} catch {
|
|
221
|
+
// A non-configurable window.ethereum is still wrapped by the poll below.
|
|
222
|
+
}
|
|
223
|
+
window.addEventListener('eip6963:announceProvider', (event) => {
|
|
224
|
+
wrap(event.detail?.provider, `eip6963:${event.detail?.info?.rdns ?? 'unknown'}`);
|
|
225
|
+
}, true);
|
|
226
|
+
let polls = 0;
|
|
227
|
+
const poll = setInterval(() => {
|
|
228
|
+
if (window.ethereum) wrap(window.ethereum, 'window.ethereum');
|
|
229
|
+
if (++polls > 200) clearInterval(poll);
|
|
230
|
+
}, 50);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function pageScriptSource({ signer, appOrigin, network = 'testnet' }) {
|
|
234
|
+
const origin = new URL(appOrigin).origin;
|
|
235
|
+
const config = {
|
|
236
|
+
signer,
|
|
237
|
+
network,
|
|
238
|
+
appOrigin: origin,
|
|
239
|
+
errorCategories: ERROR_CATEGORIES,
|
|
240
|
+
loggedMethods: LOGGED_METHODS,
|
|
241
|
+
logBinding: LOG_BINDING,
|
|
242
|
+
requestBinding: REQUEST_BINDING,
|
|
243
|
+
resolveFn: RESOLVE_FN,
|
|
244
|
+
};
|
|
245
|
+
return `(${pageMain.toString()})(${JSON.stringify(config)}, ${mainnetTypedDataReason.toString()});`;
|
|
246
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Stop the terminal slot browser and wallet host by their pid files and prove
|
|
2
|
+
// the slot CDP port is released. A pid is signalled only when its command line
|
|
3
|
+
// carries this slot's owner marker for that process kind. A pid file naming a
|
|
4
|
+
// dead process is removed; one naming a live process without the marker is
|
|
5
|
+
// kept and reported, never signalled.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { terminalRuntimeDir } from '../../../library/actions/terminal/platform/runtime.mjs';
|
|
14
|
+
|
|
15
|
+
export function pidAlive(pid) {
|
|
16
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
|
|
17
|
+
try {
|
|
18
|
+
process.kill(Number(pid), 0);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return error.code === 'EPERM';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function groupAlive(pgid) {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(-pgid, 0);
|
|
28
|
+
return true;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return error.code === 'EPERM';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readPid(file) {
|
|
35
|
+
if (!existsSync(file)) return null;
|
|
36
|
+
const pid = Number(readFileSync(file, 'utf8').trim());
|
|
37
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function psField(pid, field) {
|
|
41
|
+
try {
|
|
42
|
+
return execFileSync('ps', ['-ww', '-o', `${field}=`, '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
43
|
+
} catch {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function processCommand(pid) {
|
|
49
|
+
return pidAlive(pid) ? psField(pid, 'command') : '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Each process the slot starts carries `--mm-harness-owner=<kind>-<id>`, where
|
|
53
|
+
// <id> is derived from the canonical runtime dir. Ownership is that exact
|
|
54
|
+
// argument, compared as a whole whitespace-free token, so a runtime dir that
|
|
55
|
+
// shares a prefix with this one (".../terminal backup", ".../terminal2")
|
|
56
|
+
// never matches.
|
|
57
|
+
export const OWNER_FLAG = '--mm-harness-owner';
|
|
58
|
+
export const OWNER_KINDS = Object.freeze(['browser', 'wallet-host', 'console-collector']);
|
|
59
|
+
|
|
60
|
+
export function ownerMarker(kind, runtimeDir) {
|
|
61
|
+
if (!OWNER_KINDS.includes(kind)) throw new Error(`unknown terminal process kind ${kind}`);
|
|
62
|
+
const id = createHash('sha256').update(path.resolve(runtimeDir)).digest('hex').slice(0, 32);
|
|
63
|
+
return `${OWNER_FLAG}=${kind}-${id}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ownsProcess(kind, pid, runtimeDir, command = processCommand(pid)) {
|
|
67
|
+
if (!command) return false;
|
|
68
|
+
return command.split(/\s+/u).includes(ownerMarker(kind, runtimeDir));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function portFree(port) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const socket = net.connect({ host: '127.0.0.1', port }, () => {
|
|
74
|
+
socket.destroy();
|
|
75
|
+
resolve(false);
|
|
76
|
+
});
|
|
77
|
+
socket.on('error', () => resolve(true));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Browsers and hosts are spawned detached, each leading its own process group.
|
|
82
|
+
// When the pid leads its group the whole group is signalled and waited for,
|
|
83
|
+
// so children that outlive the leader (renderers, a child ignoring SIGTERM)
|
|
84
|
+
// are killed too; otherwise only the pid.
|
|
85
|
+
export async function terminate(pid, graceMs = 8000) {
|
|
86
|
+
if (!pidAlive(pid)) return false;
|
|
87
|
+
const group = Number(psField(pid, 'pgid')) === pid;
|
|
88
|
+
const alive = () => (group ? groupAlive(pid) : pidAlive(pid));
|
|
89
|
+
const signal = (name) => {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(group ? -pid : pid, name);
|
|
92
|
+
} catch {
|
|
93
|
+
// Already gone.
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
signal('SIGTERM');
|
|
97
|
+
const deadline = Date.now() + graceMs;
|
|
98
|
+
while (alive() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 200));
|
|
99
|
+
if (alive()) {
|
|
100
|
+
signal('SIGKILL');
|
|
101
|
+
const killDeadline = Date.now() + 5000;
|
|
102
|
+
while (alive() && Date.now() < killDeadline) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The console collector a run started (src/adapters/terminal/console-capture.ts)
|
|
108
|
+
// outlives browser relaunches inside the run; stop and cleanup end it.
|
|
109
|
+
export async function stopConsoleCollector(target, { log = () => {}, runtimeDir } = {}) {
|
|
110
|
+
const runtime = runtimeDir ?? terminalRuntimeDir(target);
|
|
111
|
+
const pidFile = path.join(runtime, 'console-tail.pid');
|
|
112
|
+
const pid = readPid(pidFile);
|
|
113
|
+
if (!pid) return { stopped: null };
|
|
114
|
+
if (ownsProcess('console-collector', pid, runtime)) {
|
|
115
|
+
await terminate(pid, 3000);
|
|
116
|
+
rmSync(pidFile, { force: true });
|
|
117
|
+
log(`stopped console collector pid ${pid}`);
|
|
118
|
+
return { stopped: pid };
|
|
119
|
+
}
|
|
120
|
+
if (pidAlive(pid)) {
|
|
121
|
+
log(`console collector pid ${pid} is alive but is not this slot's collector; left it alone`);
|
|
122
|
+
return { stopped: null, foreign: pid };
|
|
123
|
+
}
|
|
124
|
+
rmSync(pidFile, { force: true });
|
|
125
|
+
return { stopped: null, stale: pid };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Every live process that proves it is a browser on this slot's profiles,
|
|
129
|
+
// found by command line: a browser started through LaunchServices has no
|
|
130
|
+
// child handle, and its pid is only known once it owns the CDP port.
|
|
131
|
+
export function ownedBrowserPids(runtimeDir) {
|
|
132
|
+
const runtime = path.resolve(runtimeDir);
|
|
133
|
+
let lines = '';
|
|
134
|
+
try {
|
|
135
|
+
// `--` ends pgrep's options: the pattern itself starts with dashes.
|
|
136
|
+
lines = execFileSync('pgrep', ['-f', '--', ownerMarker('browser', runtime)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
return lines.split('\n').map(Number).filter((pid) => pid > 0 && ownsProcess('browser', pid, runtime));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function cdpListenerPids(port) {
|
|
144
|
+
try {
|
|
145
|
+
return execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
146
|
+
.split('\n').map(Number).filter((pid) => pid > 0);
|
|
147
|
+
} catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function stopTerminalBrowser(target, { cdpPort, log = () => {}, runtimeDir } = {}) {
|
|
153
|
+
const runtime = runtimeDir ?? terminalRuntimeDir(target);
|
|
154
|
+
const stopped = [];
|
|
155
|
+
const stale = [];
|
|
156
|
+
const foreign = [];
|
|
157
|
+
for (const name of ['wallet-host', 'browser']) {
|
|
158
|
+
const pidFile = path.join(runtime, `${name}.pid`);
|
|
159
|
+
const pid = readPid(pidFile);
|
|
160
|
+
if (pid && ownsProcess(name, pid, runtime)) {
|
|
161
|
+
if (await terminate(pid)) {
|
|
162
|
+
stopped.push({ name, pid });
|
|
163
|
+
log(`stopped ${name} pid ${pid}`);
|
|
164
|
+
}
|
|
165
|
+
rmSync(pidFile, { force: true });
|
|
166
|
+
} else if (pid && pidAlive(pid)) {
|
|
167
|
+
foreign.push({ name, pid });
|
|
168
|
+
log(`${name} pid ${pid} is alive but is not this slot's ${name}; left it and its pid file alone`);
|
|
169
|
+
} else {
|
|
170
|
+
if (pid) stale.push({ name, pid });
|
|
171
|
+
rmSync(pidFile, { force: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
rmSync(path.join(runtime, 'wallet-host.ready'), { force: true });
|
|
175
|
+
rmSync(path.join(runtime, 'browser.json'), { force: true });
|
|
176
|
+
let free = null;
|
|
177
|
+
if (cdpPort) {
|
|
178
|
+
const deadline = Date.now() + 10000;
|
|
179
|
+
free = await portFree(cdpPort);
|
|
180
|
+
while (!free && Date.now() < deadline) {
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
182
|
+
free = await portFree(cdpPort);
|
|
183
|
+
}
|
|
184
|
+
if (!free) throw new Error(`CDP port ${cdpPort} is still in use after stopping the terminal browser; another process owns it.`);
|
|
185
|
+
}
|
|
186
|
+
return { stopped, stale, foreign, cdpPortFree: free };
|
|
187
|
+
}
|