devtools-fleet-mcp 0.1.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.
@@ -0,0 +1,54 @@
1
+ import { mkdirSync, rmSync, writeFileSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { PATHS } from './config.js';
4
+ import { ensurePrivateDir, isPidAlive, sleep } from './fs-utils.js';
5
+
6
+ const STALE_MS = 30_000;
7
+
8
+ /**
9
+ * Run fn while holding a cross-process lock. mkdir is atomic on every
10
+ * filesystem we care about, so the lock is a directory. A lock whose holder
11
+ * died, or that is older than STALE_MS, is taken over.
12
+ */
13
+ export async function withLock(name, fn, { timeoutMs = 20_000 } = {}) {
14
+ ensurePrivateDir(PATHS.locks);
15
+ const dir = join(PATHS.locks, `${name}.lock`);
16
+ const deadline = Date.now() + timeoutMs;
17
+ let delay = 10;
18
+ for (;;) {
19
+ try {
20
+ mkdirSync(dir);
21
+ writeFileSync(join(dir, 'owner'), String(process.pid));
22
+ break;
23
+ } catch (err) {
24
+ if (err.code !== 'EEXIST') throw err;
25
+ if (isStale(dir)) {
26
+ rmSync(dir, { recursive: true, force: true });
27
+ continue;
28
+ }
29
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for lock "${name}" (${dir})`, { cause: err });
30
+ await sleep(delay + Math.random() * delay);
31
+ delay = Math.min(delay * 2, 200);
32
+ }
33
+ }
34
+ try {
35
+ return await fn();
36
+ } finally {
37
+ rmSync(dir, { recursive: true, force: true });
38
+ }
39
+ }
40
+
41
+ function isStale(dir) {
42
+ try {
43
+ const owner = Number(readFileSync(join(dir, 'owner'), 'utf8'));
44
+ if (owner && !isPidAlive(owner)) return true;
45
+ return Date.now() - statSync(dir).mtimeMs > STALE_MS;
46
+ } catch {
47
+ // Owner file not written yet: the holder is mid-acquire. Only stale if old.
48
+ try {
49
+ return Date.now() - statSync(dir).mtimeMs > STALE_MS;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,38 @@
1
+ // Strict mode's network lock, enforced by Chrome itself rather than over CDP.
2
+ //
3
+ // A strict browser gets a proxy that goes nowhere (port 0 can't be connected
4
+ // to), with the allowed origins on the bypass list. Anything else fails in
5
+ // Chrome's network stack: fetch, XHR, beacons, workers, WebSockets, QUIC. It
6
+ // holds with no CDP client attached, so a detached browser stays locked, and
7
+ // hostnames outside the list are never even resolved (the proxy would do that).
8
+ //
9
+ // Findings that shaped the flags (Chrome 154, see test/integration):
10
+ // - <-loopback> must come first, or it overrides the rules before it.
11
+ // - Rules are host:port without a scheme: "http://host:port" doesn't match
12
+ // ws:// to the same origin. So at this layer http and https on one port are
13
+ // the same; the CDP guard still tells them apart for navigations.
14
+ // - --webrtc-ip-handling-policy stops non-proxied UDP; the "force-" variant
15
+ // of the flag does nothing.
16
+
17
+ const DEFAULT_PORTS = { 'http:': '80', 'https:': '443' };
18
+
19
+ function bypassRule(pattern) {
20
+ const wildcard = /^(https?:)\/\/\*\.(.+)$/.exec(pattern);
21
+ if (wildcard) {
22
+ const url = new URL(`${wildcard[1]}//${wildcard[2]}`);
23
+ return `*.${url.hostname}:${url.port || DEFAULT_PORTS[url.protocol]}`;
24
+ }
25
+ const url = new URL(pattern);
26
+ return `${url.hostname}:${url.port || DEFAULT_PORTS[url.protocol]}`;
27
+ }
28
+
29
+ /** Chrome flags that confine all network traffic to the allowlist. */
30
+ export function lockdownArgs(allowlist) {
31
+ const rules = [...new Set(allowlist.map(bypassRule))];
32
+ return [
33
+ '--proxy-server=http://127.0.0.1:0',
34
+ `--proxy-bypass-list=<-loopback>;${rules.join(';')}`,
35
+ '--webrtc-ip-handling-policy=disable_non_proxied_udp',
36
+ '--dns-prefetch-disable',
37
+ ];
38
+ }
@@ -0,0 +1,177 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { PATHS } from './config.js';
5
+ import { CdpConnection } from './cdp.js';
6
+ import { launchChrome, closeChrome, browserWsUrl, resolveChromePath } from './chrome.js';
7
+ import { newBrowserId, writeEntry, updateEntry, removeEntry } from './registry.js';
8
+ import { ensureReaper } from './reaper.js';
9
+ import { captureStorage, toPlaywrightCookie, openOrigins, PLACEHOLDER_PATH } from './storage.js';
10
+ import { writeState, stateExists, assertStateName } from './states.js';
11
+ import { normalizeOriginPatterns, checkUrl, cookieMatchesAllowlist } from './origins.js';
12
+
13
+ // `devtools-fleet login <name> <url>`: a person logs in by hand in a visible
14
+ // window (2FA, SSO, captchas all fine), fleet records every origin they pass
15
+ // through, and saves cookies + localStorage as a state.
16
+
17
+ const COOKIE_POLL_MS = 3000;
18
+
19
+ export async function login({ name, url, allow = [], strict = false, overwrite = false, yes = false, headless = false, config, io = defaultIo() }) {
20
+ assertStateName(name);
21
+ if (stateExists(name) && !overwrite) throw new Error(`State "${name}" already exists. Use --overwrite to replace it.`);
22
+ const start = new URL(url);
23
+ if (!['http:', 'https:'].includes(start.protocol)) throw new Error('The login URL must be http(s)');
24
+ const extraAllow = normalizeOriginPatterns(allow);
25
+
26
+ const id = newBrowserId();
27
+ const entry = {
28
+ id,
29
+ kind: 'login',
30
+ chromePid: null,
31
+ port: null,
32
+ wsPath: null,
33
+ profileDir: join(PATHS.profiles, id),
34
+ headless,
35
+ viewport: null,
36
+ channel: config.channel,
37
+ // The CLI is both driver and owner: if it dies, the reaper closes the window.
38
+ fleetPid: process.pid,
39
+ anchorPid: process.pid,
40
+ anchorLabel: 'devtools-fleet login',
41
+ cwd: process.cwd(),
42
+ state: name,
43
+ allowedOrigins: null,
44
+ strict,
45
+ createdAt: Date.now(),
46
+ lastSeen: Date.now(),
47
+ };
48
+ writeEntry(entry);
49
+ ensureReaper();
50
+
51
+ let cdp = null;
52
+ let current = entry;
53
+ let browserGone = false;
54
+ let lastCookies = null;
55
+ try {
56
+ const chrome = await launchChrome({
57
+ executablePath: resolveChromePath({ chromePath: config.chromePath, channel: config.channel }),
58
+ profileDir: entry.profileDir,
59
+ onSpawn: (pid) => { current = updateEntry(id, { chromePid: pid }) ?? current; },
60
+ headless,
61
+ viewport: null,
62
+ extraArgs: config.chromeArgs,
63
+ url,
64
+ timeoutMs: config.launchTimeoutSeconds * 1000,
65
+ });
66
+ current = updateEntry(id, { chromePid: chrome.pid, port: chrome.port, wsPath: chrome.wsPath });
67
+ cdp = await CdpConnection.connect(browserWsUrl(chrome));
68
+ cdp.on('__close', () => { browserGone = true; });
69
+
70
+ // Every top-level origin the person passes through (login page, SSO provider, the app).
71
+ const visited = [start.origin];
72
+ const note = (targetInfo) => {
73
+ if (targetInfo.type !== 'page') return;
74
+ try {
75
+ const u = new URL(targetInfo.url);
76
+ if ((u.protocol === 'http:' || u.protocol === 'https:') && u.pathname !== PLACEHOLDER_PATH && !visited.includes(u.origin)) {
77
+ visited.push(u.origin);
78
+ }
79
+ } catch {
80
+ // about:blank etc.
81
+ }
82
+ };
83
+ cdp.on('Target.targetCreated', ({ targetInfo }) => note(targetInfo));
84
+ cdp.on('Target.targetInfoChanged', ({ targetInfo }) => note(targetInfo));
85
+ await cdp.send('Target.setDiscoverTargets', { discover: true });
86
+
87
+ // Closing the last window quits Chrome on Linux and Windows; keep a recent
88
+ // cookie snapshot so that still saves something.
89
+ const poll = setInterval(async () => {
90
+ try {
91
+ lastCookies = (await cdp.send('Storage.getCookies')).cookies;
92
+ } catch {
93
+ // browser going away
94
+ }
95
+ }, COOKIE_POLL_MS);
96
+ poll.unref();
97
+
98
+ io.print(`\nA Chrome window is open at ${url}.`);
99
+ io.print('Log in there (2FA and SSO are fine). When you are done, come back here and press Enter. Ctrl+C cancels.\n');
100
+ await io.waitForEnter(() => browserGone);
101
+ clearInterval(poll);
102
+
103
+ // Default: where the login started and where it ended up (tabs still open),
104
+ // plus --allow. Origins only passed through (an SSO provider, usually) are
105
+ // left out: saving their cookies would hand agents the whole SSO session.
106
+ const landed = browserGone ? [] : await openOrigins(cdp).catch(() => []);
107
+ const proposed = normalizeOriginPatterns([start.origin, ...landed, ...extraAllow]);
108
+ const passedThrough = visited.filter((o) => !checkUrl(proposed, o).allowed);
109
+ let allowedOrigins = proposed;
110
+ io.print(`\nThis state will only be usable on:\n${proposed.map((o) => ` ${o}`).join('\n')}`);
111
+ if (passedThrough.length) {
112
+ io.print(`Also visited during login, not included (add with --allow, or type your own list below):\n${passedThrough.map((o) => ` ${o}`).join('\n')}`);
113
+ }
114
+ if (!yes) {
115
+ const answer = (await io.ask('Save with these allowed origins? [Y/n, or type a comma-separated list instead] ')).trim();
116
+ if (/^n(o)?$/i.test(answer)) throw new Error('Cancelled; nothing saved.');
117
+ if (answer && !/^y(es)?$/i.test(answer)) {
118
+ allowedOrigins = normalizeOriginPatterns(answer.split(',').map((s) => s.trim()).filter(Boolean));
119
+ }
120
+ }
121
+
122
+ let snapshot;
123
+ if (!browserGone) {
124
+ const storageOrigins = [...new Set([
125
+ ...allowedOrigins.filter((p) => !p.includes('*')),
126
+ ...visited.filter((o) => checkUrl(allowedOrigins, o).allowed),
127
+ ])];
128
+ snapshot = await captureStorage(cdp, { cookieAllowlist: allowedOrigins, storageOrigins });
129
+ } else {
130
+ if (!lastCookies) throw new Error('The browser closed before anything could be saved. Run login again and press Enter before closing the window.');
131
+ io.print('The browser was closed, so localStorage could not be read. Saving cookies only.');
132
+ snapshot = storageFromCookies(lastCookies, allowedOrigins);
133
+ }
134
+
135
+ const summary = writeState({ name, cookies: snapshot.cookies, origins: snapshot.origins, allowedOrigins, strict, createdBy: 'cli' });
136
+ io.print(`\nSaved state "${summary.name}": ${summary.cookies} cookie(s), localStorage for ${summary.localStorageOrigins.length} origin(s)${summary.strict ? ', strict' : ''}.`);
137
+ if (summary.cookies === 0) io.print('Warning: no cookies were saved for these origins. Did the login finish?');
138
+ io.print(`Agents can now call browser_start({ state: "${summary.name}" }).`);
139
+ return summary;
140
+ } finally {
141
+ await closeChrome(current, { cdp }).catch(() => {});
142
+ cdp?.close();
143
+ removeEntry(id);
144
+ rmSync(entry.profileDir, { recursive: true, force: true });
145
+ }
146
+ }
147
+
148
+ function storageFromCookies(cookies, allowedOrigins) {
149
+ return {
150
+ cookies: cookies.filter((c) => cookieMatchesAllowlist(c.domain, allowedOrigins)).map(toPlaywrightCookie),
151
+ origins: [],
152
+ };
153
+ }
154
+
155
+ function defaultIo() {
156
+ const rl = () => createInterface({ input: process.stdin, output: process.stdout });
157
+ return {
158
+ print: (line) => console.log(line),
159
+ ask: (question) => new Promise((resolve) => {
160
+ const r = rl();
161
+ r.question(question, (answer) => { r.close(); resolve(answer); });
162
+ }),
163
+ waitForEnter: (isGone) => new Promise((resolve) => {
164
+ const r = rl();
165
+ const timer = setInterval(() => {
166
+ if (isGone()) {
167
+ console.log('\nThe browser window was closed.');
168
+ clearInterval(timer);
169
+ r.close();
170
+ resolve();
171
+ }
172
+ }, 500);
173
+ r.once('line', () => { clearInterval(timer); r.close(); resolve(); });
174
+ }),
175
+ };
176
+ }
177
+
@@ -0,0 +1,104 @@
1
+ // Origin patterns for allowlists.
2
+ //
3
+ // https://app.example.com exact origin (scheme + host + port)
4
+ // http://127.0.0.1:3000 ports matter
5
+ // https://*.example.com any subdomain of example.com (not example.com itself)
6
+ //
7
+ // No paths, no bare hosts: an allowlist entry is always a full origin, so
8
+ // "http vs https" and "which port" are never ambiguous.
9
+
10
+ // Never blocked: internal and inline pages that can't reach the network.
11
+ const ALWAYS_ALLOWED_SCHEMES = new Set(['about:', 'data:', 'blob:', 'chrome-error:', 'devtools:', 'javascript:']);
12
+
13
+ export function normalizeOriginPattern(input) {
14
+ const raw = String(input).trim();
15
+ const wildcard = /^(https?):\/\/\*\.(.+)$/i.exec(raw);
16
+ if (wildcard) {
17
+ const url = parseUrl(`${wildcard[1]}://${wildcard[2]}`, input);
18
+ assertBareOrigin(url, input);
19
+ if (url.hostname.split('.').length < 2) throw new Error(`Wildcard too broad: "${input}"`);
20
+ return `${url.protocol}//*.${url.host}`;
21
+ }
22
+ const url = parseUrl(raw, input);
23
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`Origin must be http(s): "${input}"`);
24
+ assertBareOrigin(url, input);
25
+ return url.origin;
26
+ }
27
+
28
+ export function normalizeOriginPatterns(list) {
29
+ return [...new Set(list.map(normalizeOriginPattern))];
30
+ }
31
+
32
+ function parseUrl(value, original) {
33
+ try {
34
+ return new URL(value);
35
+ } catch {
36
+ throw new Error(`Not an origin: "${original}" (expected e.g. https://app.example.com)`);
37
+ }
38
+ }
39
+
40
+ function assertBareOrigin(url, original) {
41
+ if ((url.pathname && url.pathname !== '/') || url.search || url.hash || url.username || url.password) {
42
+ throw new Error(`Origin must not have a path, query or credentials: "${original}"`);
43
+ }
44
+ }
45
+
46
+ /** Does one pattern cover this origin ("https://a.example.com")? */
47
+ export function patternMatchesOrigin(pattern, origin) {
48
+ if (!pattern.includes('*')) return pattern === origin;
49
+ const { scheme, host } = splitWildcard(pattern);
50
+ let url;
51
+ try {
52
+ url = new URL(origin);
53
+ } catch {
54
+ return false;
55
+ }
56
+ return url.protocol === scheme && url.host.endsWith(`.${host}`);
57
+ }
58
+
59
+ function splitWildcard(pattern) {
60
+ const [scheme, rest] = pattern.split('//*.');
61
+ return { scheme, host: rest };
62
+ }
63
+
64
+ /** @returns {{ allowed: boolean, origin?: string }} */
65
+ export function checkUrl(allowlist, url) {
66
+ if (!allowlist) return { allowed: true };
67
+ let parsed;
68
+ try {
69
+ parsed = new URL(url);
70
+ } catch {
71
+ return { allowed: false, origin: String(url) };
72
+ }
73
+ if (ALWAYS_ALLOWED_SCHEMES.has(parsed.protocol)) return { allowed: true };
74
+ if (!['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) return { allowed: false, origin: parsed.protocol };
75
+ // WebSockets follow the page's http(s) origin rules.
76
+ const origin = parsed.origin.replace(/^ws(s?):/, 'http$1:');
77
+ return { allowed: allowlist.some((p) => patternMatchesOrigin(p, origin)), origin };
78
+ }
79
+
80
+ /** Is every pattern in `narrower` covered by `wider`? (used so agents can't widen an allowlist) */
81
+ export function isSubset(narrower, wider) {
82
+ return narrower.every((p) => {
83
+ if (!p.includes('*')) return wider.some((w) => patternMatchesOrigin(w, p));
84
+ // A wildcard is only covered by an equal or broader wildcard.
85
+ const { scheme, host } = splitWildcard(p);
86
+ return wider.some((w) => {
87
+ if (!w.includes('*')) return false;
88
+ const ww = splitWildcard(w);
89
+ return ww.scheme === scheme && (host === ww.host || host.endsWith(`.${ww.host}`));
90
+ });
91
+ });
92
+ }
93
+
94
+ /** Does a cookie's domain belong to an allowlist? Cookies are only kept (and restored) if so. */
95
+ export function cookieMatchesAllowlist(cookieDomain, allowlist) {
96
+ const domain = cookieDomain.replace(/^\./, '').toLowerCase();
97
+ return allowlist.some((pattern) => {
98
+ const host = pattern.includes('*')
99
+ ? splitWildcard(pattern).host.split(':')[0]
100
+ : new URL(pattern).hostname;
101
+ // Cookie set for a parent domain (example.com) is sent to app.example.com; one set for the host itself too.
102
+ return host === domain || host.endsWith(`.${domain}`) || (pattern.includes('*') && domain.endsWith(`.${host}`));
103
+ });
104
+ }
@@ -0,0 +1,82 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { dirname, resolve, sep, basename, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { PATHS } from './config.js';
5
+ import { checkUrl } from './origins.js';
6
+
7
+ // Checks on arguments an agent passes to chrome-devtools-mcp's tools, before
8
+ // fleet forwards the call. The in-browser guard (guard.js) is the second
9
+ // layer for navigations; these catch what it can't see.
10
+
11
+ const NAVIGATION_TOOLS = new Set(['new_page', 'navigate_page']);
12
+ const OK_SCHEMES = new Set(['about:', 'data:', 'blob:']);
13
+
14
+ /** @returns {string|null} a reason to refuse the call, or null */
15
+ export function checkToolCall(name, args, { allowlist, stateName = null }) {
16
+ if (NAVIGATION_TOOLS.has(name) && typeof args.url === 'string') {
17
+ const reason = checkNavigation(args.url, allowlist, stateName);
18
+ if (reason) return reason;
19
+ }
20
+ // A page in a separate browser context isn't covered by the guard's
21
+ // auto-attach, so with an allowlist there are no extra contexts.
22
+ if (name === 'new_page' && args.isolatedContext !== undefined && allowlist) {
23
+ return 'isolatedContext is not available in a browser with an allowlist. Open the page in the normal context.';
24
+ }
25
+ for (const [key, value] of Object.entries(args)) {
26
+ if (!/path/i.test(key)) continue;
27
+ for (const p of Array.isArray(value) ? value : [value]) {
28
+ if (typeof p === 'string' && isInsideFleetHome(p)) {
29
+ return `${key} points inside ${PATHS.home}, which holds saved logins and browser profiles. Use another location.`;
30
+ }
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function checkNavigation(url, allowlist, stateName) {
37
+ let parsed;
38
+ try {
39
+ parsed = new URL(url);
40
+ } catch {
41
+ return `Not a valid URL: ${url}`;
42
+ }
43
+ if (['http:', 'https:'].includes(parsed.protocol)) {
44
+ const { allowed, origin } = checkUrl(allowlist, url);
45
+ const whose = stateName ? `this browser's allowlist (state "${stateName}")` : "this browser's allowlist";
46
+ return allowed ? null : `${url} is blocked. ${origin} is not in ${whose}: ${allowlist.join(', ')}.`;
47
+ }
48
+ if (OK_SCHEMES.has(parsed.protocol)) return null;
49
+ if (parsed.protocol === 'file:') {
50
+ if (allowlist) return `file: URLs are not available in a browser with an allowlist.`;
51
+ let path;
52
+ try {
53
+ path = fileURLToPath(parsed);
54
+ } catch {
55
+ return `Not a valid file URL: ${url}`;
56
+ }
57
+ return isInsideFleetHome(path) ? `${url} is inside ${PATHS.home}, which holds saved logins and browser profiles.` : null;
58
+ }
59
+ return `${parsed.protocol} URLs can't be opened through devtools-fleet.`;
60
+ }
61
+
62
+ /** Resolve symlinks as far as the path exists, so a link into the fleet home doesn't slip past. */
63
+ function canonical(path) {
64
+ let current = resolve(path);
65
+ const rest = [];
66
+ for (;;) {
67
+ try {
68
+ return join(realpathSync(current), ...rest.reverse());
69
+ } catch {
70
+ const parent = dirname(current);
71
+ if (parent === current) return resolve(path);
72
+ rest.push(basename(current));
73
+ current = parent;
74
+ }
75
+ }
76
+ }
77
+
78
+ export function isInsideFleetHome(path) {
79
+ const home = canonical(PATHS.home);
80
+ const target = canonical(path);
81
+ return target === home || target.startsWith(home + sep);
82
+ }
@@ -0,0 +1,109 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readlinkSync } from 'node:fs';
3
+ import { basename } from 'node:path';
4
+ import { isPidAlive } from './fs-utils.js';
5
+
6
+ // Which AI client session owns this fleet process?
7
+ //
8
+ // MCP clients start servers through wrappers (npx → npm exec → node, sometimes
9
+ // a shell), and restart the whole chain on reconnect. The session itself (the
10
+ // `claude` process, Cursor's extension host, ...) survives the reconnect. So
11
+ // the anchor is the nearest ancestor that isn't a wrapper. A reconnect inside
12
+ // the same session finds the same anchor and re-adopts its browser; a second
13
+ // session in the same directory finds a different one and never shares.
14
+ //
15
+ // The anchor's start time is stored with its pid, so a recycled pid is never
16
+ // mistaken for the session.
17
+ //
18
+ // DEVTOOLS_FLEET_SESSION overrides the lookup (a stable label of your choice).
19
+ // On Windows there is no ps; the anchor falls back to this process, which
20
+ // means no re-adoption but everything else works.
21
+
22
+ const SHELLS_AND_LAUNCHERS = new Set([
23
+ 'npm', 'npx', 'pnpm', 'pnpx', 'yarn', 'bunx', 'sh', 'bash', 'zsh', 'dash', 'fish', 'ksh', 'env', 'uv', 'uvx',
24
+ 'devtools-fleet-mcp',
25
+ ]);
26
+ // A runtime (node, bun, deno) is only a wrapper when it's running a launcher
27
+ // or this package. A client written in Node (`node .../claude/cli.js`, an
28
+ // Agent SDK orchestrator) is a session, not a wrapper.
29
+ const RUNTIMES = new Set(['node', 'nodejs', 'bun', 'deno']);
30
+ const LAUNCHER_ARGS = /\b(npx|npm|pnpm|pnpx|yarn|bunx|npx-cli|npm-cli)\b|devtools-fleet|\/_npx\//;
31
+
32
+ export function isWrapper(comm, args = '') {
33
+ const exe = basename(comm).replace(/^-/, '').toLowerCase();
34
+ const first = basename(args.trim().split(/\s+/)[0] || '').replace(/^-/, '').toLowerCase();
35
+ if (SHELLS_AND_LAUNCHERS.has(exe) || SHELLS_AND_LAUNCHERS.has(first)) return true;
36
+ // npm rewrites its process title ("npm exec foo").
37
+ if (/^npm (exec|run|x)\b/.test(args)) return true;
38
+ if (RUNTIMES.has(exe) || RUNTIMES.has(first)) return LAUNCHER_ARGS.test(args);
39
+ return false;
40
+ }
41
+
42
+ function ps(pid, field) {
43
+ return execFileSync('ps', ['-o', `${field}=`, '-p', String(pid)], { encoding: 'utf8', timeout: 2000 }).trim();
44
+ }
45
+
46
+ export function readProcess(pid) {
47
+ try {
48
+ // One field per call: with several, ps prints a header unless every field has "=".
49
+ const ppid = Number(ps(pid, 'ppid'));
50
+ const comm = linuxExe(pid) || ps(pid, 'comm');
51
+ if (!Number.isInteger(ppid) || !comm) return null;
52
+ return { ppid, comm, args: ps(pid, 'args') };
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ // On Linux, ps's comm is the main thread's name, and Node 24 renames its main
59
+ // thread to "MainThread". The executable is the reliable name there.
60
+ function linuxExe(pid) {
61
+ if (process.platform !== 'linux') return null;
62
+ try {
63
+ return basename(readlinkSync(`/proc/${pid}/exe`).replace(/ \(deleted\)$/, ''));
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** Process start time as ps reports it; stable for the life of the process. */
70
+ export function processStartTime(pid) {
71
+ if (process.platform === 'win32' || !pid) return null;
72
+ try {
73
+ return ps(pid, 'lstart') || null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** @returns {{ pid: number, start: string|null, label: string }} */
80
+ export function findSessionAnchor({ readProc = readProcess, startTime = processStartTime, startPid = process.ppid } = {}) {
81
+ const override = process.env.DEVTOOLS_FLEET_SESSION;
82
+ if (override) return { pid: 0, start: null, label: `session:${override}` };
83
+ if (process.platform === 'win32') return { pid: process.pid, start: null, label: 'self' };
84
+
85
+ let pid = startPid;
86
+ for (let depth = 0; depth < 12 && pid > 1; depth++) {
87
+ const proc = readProc(pid);
88
+ if (!proc) break;
89
+ if (!isWrapper(proc.comm, proc.args)) return { pid, start: startTime(pid), label: basename(proc.comm) };
90
+ pid = proc.ppid;
91
+ }
92
+ // Only wrappers all the way up (or ps failed): treat our parent as the anchor.
93
+ return { pid: startPid, start: startTime(startPid), label: 'parent' };
94
+ }
95
+
96
+ /** Is the session a registry entry belongs to still running? */
97
+ export function anchorAlive(entry) {
98
+ if (entry.anchorPid === 0) return true; // label-based: no process to watch
99
+ if (!isPidAlive(entry.anchorPid)) return false;
100
+ if (!entry.anchorStart) return true;
101
+ const now = processStartTime(entry.anchorPid);
102
+ return now === null || now === entry.anchorStart;
103
+ }
104
+
105
+ /** Does a registry entry belong to this session? */
106
+ export function anchorMatches(entry, anchor) {
107
+ if (anchor.pid === 0) return entry.anchorPid === 0 && entry.anchorLabel === anchor.label;
108
+ return entry.anchorPid === anchor.pid && (entry.anchorStart ?? null) === (anchor.start ?? null) && anchorAlive(entry);
109
+ }