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.
- package/.claude-plugin/marketplace.json +9 -0
- package/.claude-plugin/plugin.json +21 -0
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/bin/cli.js +246 -0
- package/package.json +61 -0
- package/skills/devtools-fleet/SKILL.md +41 -0
- package/src/index.js +7 -0
- package/src/lib/cdp.js +105 -0
- package/src/lib/chrome.js +222 -0
- package/src/lib/config.js +103 -0
- package/src/lib/fs-utils.js +31 -0
- package/src/lib/guard.js +93 -0
- package/src/lib/lock.js +54 -0
- package/src/lib/lockdown.js +38 -0
- package/src/lib/login.js +177 -0
- package/src/lib/origins.js +104 -0
- package/src/lib/policy.js +82 -0
- package/src/lib/process-tree.js +109 -0
- package/src/lib/reaper.js +184 -0
- package/src/lib/registry.js +89 -0
- package/src/lib/session.js +466 -0
- package/src/lib/states.js +135 -0
- package/src/lib/storage.js +137 -0
- package/src/lib/upstream.js +98 -0
- package/src/reaper-main.js +10 -0
- package/src/server.js +85 -0
- package/src/tools/fleet-tools.js +116 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, writeFileSync, rmSync, readdirSync, statSync, openSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { PATHS } from './config.js';
|
|
6
|
+
import { listEntries, removeEntry, classify, readEntry } from './registry.js';
|
|
7
|
+
import { closeChrome } from './chrome.js';
|
|
8
|
+
import { withLock } from './lock.js';
|
|
9
|
+
import { ensurePrivateDir, isPidAlive, sleep } from './fs-utils.js';
|
|
10
|
+
|
|
11
|
+
// Browsers outlive their fleet process on purpose (re-adoption). Something has
|
|
12
|
+
// to close the ones nobody comes back for. That's the reaper: one small
|
|
13
|
+
// detached process per machine, started by whichever fleet process needs it,
|
|
14
|
+
// exiting on its own once the registry has been empty for a while.
|
|
15
|
+
|
|
16
|
+
const REAPER_MAIN = join(dirname(fileURLToPath(import.meta.url)), '..', 'reaper-main.js');
|
|
17
|
+
const STRAY_PROFILE_MS = 60 * 60_000;
|
|
18
|
+
|
|
19
|
+
/** Close a deregistered browser and delete its profile. */
|
|
20
|
+
export async function closeEntryBrowser(entry) {
|
|
21
|
+
if (entry.chromePid && isPidAlive(entry.chromePid)) await closeChrome(entry).catch(() => {});
|
|
22
|
+
if (entry.profileDir) rmSync(entry.profileDir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Cheap, synchronous cleanup of entries whose Chrome is already gone. Safe under the registry lock. */
|
|
26
|
+
export function pruneRegistry() {
|
|
27
|
+
for (const entry of listEntries()) {
|
|
28
|
+
const status = classify(entry);
|
|
29
|
+
if (status === 'starting') continue;
|
|
30
|
+
if (!entry.chromePid || !isPidAlive(entry.chromePid)) {
|
|
31
|
+
removeEntry(entry.id);
|
|
32
|
+
if (entry.profileDir) rmSync(entry.profileDir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One pass: close orphans, close detached browsers idle past the timeout,
|
|
39
|
+
* forget dead ones, delete stray profile dirs.
|
|
40
|
+
* @returns {Array<{ id: string, action: string }>}
|
|
41
|
+
*/
|
|
42
|
+
export async function reapOnce({ orphanTimeoutMinutes, now = Date.now(), closeDetached = false } = {}) {
|
|
43
|
+
const actions = [];
|
|
44
|
+
const reasonFor = (entry) => {
|
|
45
|
+
const status = classify(entry);
|
|
46
|
+
if (status === 'starting' || status === 'active') return null;
|
|
47
|
+
if (!entry.chromePid || !isPidAlive(entry.chromePid)) return 'chrome already exited';
|
|
48
|
+
if (!entry.port) return 'launch never finished';
|
|
49
|
+
if (status === 'orphan') return 'owning session ended';
|
|
50
|
+
if (closeDetached) return 'detached (forced)';
|
|
51
|
+
if (now - entry.lastSeen > orphanTimeoutMinutes * 60_000) return `detached for over ${orphanTimeoutMinutes} min`;
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
for (const candidate of listEntries()) {
|
|
55
|
+
if (!reasonFor(candidate)) continue;
|
|
56
|
+
// Decide and deregister under the lock, against the entry as it is now: a
|
|
57
|
+
// session may have re-adopted it since the list was read. Once the entry is
|
|
58
|
+
// gone nobody can adopt it, so closing Chrome can happen outside the lock.
|
|
59
|
+
const claimed = await withLock('registry', async () => {
|
|
60
|
+
const entry = readEntry(candidate.id);
|
|
61
|
+
const reason = entry && reasonFor(entry);
|
|
62
|
+
if (!reason) return null;
|
|
63
|
+
removeEntry(entry.id);
|
|
64
|
+
return { entry, reason };
|
|
65
|
+
});
|
|
66
|
+
if (!claimed) continue;
|
|
67
|
+
await closeEntryBrowser(claimed.entry);
|
|
68
|
+
actions.push({ id: claimed.entry.id, action: claimed.reason });
|
|
69
|
+
}
|
|
70
|
+
// Chromes and profile dirs with no registry entry: leftovers from a fleet
|
|
71
|
+
// process that died between spawning Chrome and recording it.
|
|
72
|
+
const known = new Set(listEntries().map((e) => e.id));
|
|
73
|
+
for (const { pid, id } of strayChromes(known)) {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(-pid, 'SIGTERM');
|
|
76
|
+
} catch {
|
|
77
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
|
|
78
|
+
}
|
|
79
|
+
actions.push({ id, action: `killed unregistered chrome ${pid}` });
|
|
80
|
+
}
|
|
81
|
+
let dirs = [];
|
|
82
|
+
try {
|
|
83
|
+
dirs = readdirSync(PATHS.profiles);
|
|
84
|
+
} catch {
|
|
85
|
+
// none yet
|
|
86
|
+
}
|
|
87
|
+
for (const id of dirs) {
|
|
88
|
+
if (known.has(id)) continue;
|
|
89
|
+
const dir = join(PATHS.profiles, id);
|
|
90
|
+
try {
|
|
91
|
+
if (now - statSync(dir).mtimeMs > STRAY_PROFILE_MS) {
|
|
92
|
+
rmSync(dir, { recursive: true, force: true });
|
|
93
|
+
actions.push({ id, action: 'removed stray profile dir' });
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// raced with someone else
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return actions;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Chrome main processes on a fleet profile dir that the registry doesn't know. */
|
|
103
|
+
function strayChromes(known) {
|
|
104
|
+
if (process.platform === 'win32') return [];
|
|
105
|
+
let out;
|
|
106
|
+
try {
|
|
107
|
+
out = execFileSync('ps', ['-ax', '-o', 'pid=,args='], { encoding: 'utf8', timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
|
|
108
|
+
} catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
const marker = `--user-data-dir=${PATHS.profiles}/`;
|
|
112
|
+
const found = [];
|
|
113
|
+
for (const line of out.split('\n')) {
|
|
114
|
+
const at = line.indexOf(marker);
|
|
115
|
+
// Helpers (--type=renderer etc.) die with the main process; only target the main one.
|
|
116
|
+
if (at === -1 || line.includes('--type=')) continue;
|
|
117
|
+
const id = line.slice(at + marker.length).split(/[\s/]/)[0];
|
|
118
|
+
const pid = Number(line.trim().split(/\s+/)[0]);
|
|
119
|
+
// Re-check the registry: the entry is always written before Chrome is spawned.
|
|
120
|
+
if (id && pid && !known.has(id) && !readEntry(id)) found.push({ pid, id });
|
|
121
|
+
}
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function reaperRunning() {
|
|
126
|
+
try {
|
|
127
|
+
const pid = Number(readFileSync(PATHS.reaperPid, 'utf8'));
|
|
128
|
+
if (!isPidAlive(pid)) return false;
|
|
129
|
+
if (process.platform === 'win32') return true;
|
|
130
|
+
const args = execFileSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 });
|
|
131
|
+
return args.includes('reaper-main.js');
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Start the reaper if it isn't running. Never throws: a missing reaper only delays cleanup. */
|
|
138
|
+
export function ensureReaper() {
|
|
139
|
+
try {
|
|
140
|
+
if (reaperRunning()) return;
|
|
141
|
+
ensurePrivateDir(PATHS.home);
|
|
142
|
+
const log = openSync(PATHS.reaperLog, 'a', 0o600);
|
|
143
|
+
const child = spawn(process.execPath, [REAPER_MAIN], { detached: true, stdio: ['ignore', log, log], env: process.env });
|
|
144
|
+
child.unref();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error(`[devtools-fleet] could not start the reaper: ${err.message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ownsPidFile() {
|
|
151
|
+
try {
|
|
152
|
+
return Number(readFileSync(PATHS.reaperPid, 'utf8')) === process.pid;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Body of the reaper process. */
|
|
159
|
+
export async function runReaper({ loadConfig, intervalMs = 10_000, idleExitMs = 2 * 60_000 }) {
|
|
160
|
+
if (reaperRunning()) return;
|
|
161
|
+
writeFileSync(PATHS.reaperPid, String(process.pid), { mode: 0o600 });
|
|
162
|
+
// Two fleet processes can start a reaper at the same moment; last writer wins, the other leaves.
|
|
163
|
+
await sleep(300);
|
|
164
|
+
if (!ownsPidFile()) return;
|
|
165
|
+
let emptySince = null;
|
|
166
|
+
try {
|
|
167
|
+
for (;;) {
|
|
168
|
+
// Another reaper took over, or the fleet home was deleted: step aside.
|
|
169
|
+
if (!ownsPidFile()) return;
|
|
170
|
+
const config = loadConfig();
|
|
171
|
+
const actions = await reapOnce({ orphanTimeoutMinutes: config.orphanTimeoutMinutes });
|
|
172
|
+
for (const { id, action } of actions) console.log(`${new Date().toISOString()} ${id} ${action}`);
|
|
173
|
+
if (listEntries().length === 0) {
|
|
174
|
+
emptySince ??= Date.now();
|
|
175
|
+
if (Date.now() - emptySince > idleExitMs) break;
|
|
176
|
+
} else {
|
|
177
|
+
emptySince = null;
|
|
178
|
+
}
|
|
179
|
+
await sleep(intervalMs);
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
if (ownsPidFile()) rmSync(PATHS.reaperPid, { force: true });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { PATHS } from './config.js';
|
|
5
|
+
import { writeJsonAtomic, isPidAlive } from './fs-utils.js';
|
|
6
|
+
import { anchorAlive } from './process-tree.js';
|
|
7
|
+
|
|
8
|
+
// One JSON file per browser in ~/.devtools-fleet/browsers/. Writes are atomic
|
|
9
|
+
// renames; anything that must be consistent across processes (the cap check,
|
|
10
|
+
// adoption) happens under withLock('registry').
|
|
11
|
+
//
|
|
12
|
+
// Entry shape:
|
|
13
|
+
// {
|
|
14
|
+
// id, kind: 'agent' | 'login',
|
|
15
|
+
// chromePid, port, wsPath, profileDir, headless, viewport, channel,
|
|
16
|
+
// fleetPid, // the devtools-fleet process driving it (0 when detached)
|
|
17
|
+
// anchorPid, // the AI client session that owns it (see process-tree.js)
|
|
18
|
+
// anchorStart, // its start time, so a recycled pid doesn't count
|
|
19
|
+
// anchorLabel,
|
|
20
|
+
// cwd, state, allowedOrigins, strict,
|
|
21
|
+
// createdAt, lastSeen, // ms epoch; lastSeen is the fleet heartbeat
|
|
22
|
+
// }
|
|
23
|
+
|
|
24
|
+
export function newBrowserId() {
|
|
25
|
+
return randomBytes(4).toString('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function entryPath(id) {
|
|
29
|
+
return join(PATHS.browsers, `${id}.json`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeEntry(entry) {
|
|
33
|
+
writeJsonAtomic(entryPath(entry.id), entry);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readEntry(id) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(entryPath(id), 'utf8'));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function updateEntry(id, patch) {
|
|
45
|
+
const entry = readEntry(id);
|
|
46
|
+
if (!entry) return null;
|
|
47
|
+
const next = { ...entry, ...patch };
|
|
48
|
+
writeEntry(next);
|
|
49
|
+
return next;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function removeEntry(id) {
|
|
53
|
+
rmSync(entryPath(id), { force: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function listEntries() {
|
|
57
|
+
let files;
|
|
58
|
+
try {
|
|
59
|
+
files = readdirSync(PATHS.browsers);
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const entries = [];
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
if (!file.endsWith('.json')) continue;
|
|
66
|
+
try {
|
|
67
|
+
entries.push(JSON.parse(readFileSync(join(PATHS.browsers, file), 'utf8')));
|
|
68
|
+
} catch {
|
|
69
|
+
// Half-written or corrupt; ignore. Temp files don't end in .json.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return entries.sort((a, b) => a.createdAt - b.createdAt);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where a browser stands, from the registry's point of view:
|
|
77
|
+
* active – its fleet process is alive
|
|
78
|
+
* detached – fleet process gone, owning session alive (can be re-adopted)
|
|
79
|
+
* orphan – fleet process and owning session both gone
|
|
80
|
+
* starting – still launching
|
|
81
|
+
* Whether Chrome itself is alive is checked separately (chrome.js isChromeAlive).
|
|
82
|
+
*/
|
|
83
|
+
export function classify(entry) {
|
|
84
|
+
if (entry.fleetPid && isPidAlive(entry.fleetPid)) return entry.port ? 'active' : 'starting';
|
|
85
|
+
// anchorPid 0 = DEVTOOLS_FLEET_SESSION label: no process to watch, so it
|
|
86
|
+
// stays detached until the orphan timeout.
|
|
87
|
+
if (anchorAlive(entry)) return 'detached';
|
|
88
|
+
return 'orphan';
|
|
89
|
+
}
|