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,466 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { PATHS } from './config.js';
4
+ import { CdpConnection } from './cdp.js';
5
+ import { launchChrome, closeChrome, browserWsUrl, resolveChromePath, isChromeAlive } from './chrome.js';
6
+ import { withLock } from './lock.js';
7
+ import { newBrowserId, writeEntry, updateEntry, removeEntry, listEntries, classify } from './registry.js';
8
+ import { pruneRegistry, ensureReaper } from './reaper.js';
9
+ import { installGuard } from './guard.js';
10
+ import { checkUrl, normalizeOriginPatterns, isSubset, patternMatchesOrigin } from './origins.js';
11
+ import { checkToolCall } from './policy.js';
12
+ import { lockdownArgs } from './lockdown.js';
13
+ import { applyStorage, captureStorage, openOrigins, openTabUrls } from './storage.js';
14
+ import { readState, writeState, stateExists, summarizeState } from './states.js';
15
+ import { Upstream } from './upstream.js';
16
+ import { anchorMatches } from './process-tree.js';
17
+
18
+ const HEARTBEAT_MS = 30_000;
19
+
20
+ // One FleetSession per devtools-fleet-mcp process, i.e. per agent session.
21
+ // It owns at most one browser at a time.
22
+ //
23
+ // MCP clients send tool calls in parallel, so everything that changes which
24
+ // browser, CDP connection or upstream process this session has runs through
25
+ // #serial. Upstream tool calls themselves run concurrently, outside it.
26
+ export class FleetSession {
27
+ #config;
28
+ #anchor;
29
+ #entry = null;
30
+ #cdp = null;
31
+ #upstream = null;
32
+ #guardOff = null;
33
+ #heartbeat = null;
34
+ #blocks = [];
35
+ #queue = Promise.resolve();
36
+ #rootsProvider = null;
37
+
38
+ constructor(config, anchor) {
39
+ this.#config = config;
40
+ this.#anchor = anchor;
41
+ }
42
+
43
+ get allowlist() {
44
+ return this.#entry?.allowedOrigins ?? null;
45
+ }
46
+
47
+ /** Where upstream may read and write files: the MCP client's workspace roots, if it has any. */
48
+ setRootsProvider(provider) {
49
+ this.#rootsProvider = provider;
50
+ }
51
+
52
+ rootsChanged() {
53
+ this.#upstream?.sendRootsListChanged();
54
+ }
55
+
56
+ #serial(fn) {
57
+ const run = this.#queue.then(fn, fn);
58
+ this.#queue = run.catch(() => {});
59
+ return run;
60
+ }
61
+
62
+ // ---------------------------------------------------------------- start
63
+
64
+ /** Start (or re-adopt) this session's browser. */
65
+ start(opts = {}) {
66
+ return this.#serial(() => this.#start(opts));
67
+ }
68
+
69
+ async #start(opts) {
70
+ const explicit = Object.values(opts).some((v) => v !== undefined);
71
+ if (this.#entry && !this.#cdp?.closed) {
72
+ if (explicit) {
73
+ throw new Error(`This session already has a browser (${this.#entry.id}${this.#entry.state ? `, state "${this.#entry.state}"` : ''}). Call browser_stop first to start one with different options.`);
74
+ }
75
+ return { adopted: false, alreadyRunning: true };
76
+ }
77
+ if (this.#entry) await this.#teardown({ keepBrowser: false });
78
+
79
+ const plan = this.#plan(opts);
80
+ // Re-adopt only for a plain start or the same state: explicit options
81
+ // (allowlist, url, headless...) must never be silently dropped.
82
+ const onlyState = Object.entries(opts).every(([k, v]) => k === 'state' || v === undefined);
83
+ const { entry, adopted } = await withLock('registry', () => this.#reserve(plan, { mayAdopt: onlyState }));
84
+ this.#entry = entry;
85
+ try {
86
+ if (adopted) await this.#attach();
87
+ else await this.#launch(plan);
88
+ } catch (err) {
89
+ await this.#teardown({ keepBrowser: adopted });
90
+ throw err;
91
+ }
92
+ ensureReaper();
93
+ this.#startHeartbeat();
94
+ return { adopted, alreadyRunning: false };
95
+ }
96
+
97
+ #plan({ state, headless, viewport, channel, url, allowedOrigins }) {
98
+ const plan = {
99
+ headless: headless ?? this.#config.headless,
100
+ viewport: viewport ?? this.#config.viewport,
101
+ channel: channel ?? this.#config.channel,
102
+ url: url ?? null,
103
+ state: state ?? null,
104
+ stateData: null,
105
+ allowedOrigins: null,
106
+ strict: false,
107
+ };
108
+ if (state) {
109
+ if (allowedOrigins) throw new Error('allowedOrigins comes from the state; pass one or the other, not both');
110
+ plan.stateData = readState(state);
111
+ plan.allowedOrigins = plan.stateData.devtoolsFleet.allowedOrigins;
112
+ plan.strict = plan.stateData.devtoolsFleet.strict;
113
+ } else if (allowedOrigins?.length) {
114
+ plan.allowedOrigins = normalizeOriginPatterns(allowedOrigins);
115
+ }
116
+ if (plan.url) {
117
+ const reason = checkToolCall('new_page', { url: plan.url }, { allowlist: plan.allowedOrigins });
118
+ if (reason) throw new Error(reason);
119
+ }
120
+ return plan;
121
+ }
122
+
123
+ // Runs under the registry lock: adopt a detached browser of this session, or reserve a slot.
124
+ async #reserve(plan, { mayAdopt }) {
125
+ pruneRegistry();
126
+ if (mayAdopt) {
127
+ const mine = listEntries().filter((e) =>
128
+ e.kind === 'agent' &&
129
+ classify(e) === 'detached' &&
130
+ anchorMatches(e, this.#anchor) &&
131
+ (plan.state ?? null) === (e.state ?? null) &&
132
+ e.port);
133
+ // Newest first: after several reconnects, the most recent browser is the one the agent was using.
134
+ for (const candidate of mine.reverse()) {
135
+ if (!(await isChromeAlive(candidate))) continue;
136
+ const entry = updateEntry(candidate.id, { fleetPid: process.pid, lastSeen: Date.now() });
137
+ if (entry) return { entry, adopted: true };
138
+ }
139
+ }
140
+
141
+ const running = listEntries().filter((e) => e.kind === 'agent');
142
+ if (running.length >= this.#config.maxBrowsers) {
143
+ const lines = running.map((e) => ` ${e.id} ${classify(e).padEnd(8)} ${e.anchorLabel ?? ''} ${e.cwd ?? ''}`).join('\n');
144
+ throw new Error(
145
+ `devtools-fleet is at its limit of ${this.#config.maxBrowsers} browsers.\n${lines}\n` +
146
+ 'Run `devtools-fleet gc` to close orphans, `devtools-fleet kill <id>` to close one, or raise maxBrowsers.',
147
+ );
148
+ }
149
+
150
+ const id = newBrowserId();
151
+ const now = Date.now();
152
+ const entry = {
153
+ id,
154
+ kind: 'agent',
155
+ chromePid: null,
156
+ port: null,
157
+ wsPath: null,
158
+ profileDir: join(PATHS.profiles, id),
159
+ headless: plan.headless,
160
+ viewport: plan.viewport,
161
+ channel: plan.channel,
162
+ fleetPid: process.pid,
163
+ anchorPid: this.#anchor.pid,
164
+ anchorStart: this.#anchor.start ?? null,
165
+ anchorLabel: this.#anchor.label,
166
+ cwd: process.cwd(),
167
+ state: plan.state,
168
+ allowedOrigins: plan.allowedOrigins,
169
+ strict: plan.strict,
170
+ createdAt: now,
171
+ lastSeen: now,
172
+ };
173
+ writeEntry(entry);
174
+ return { entry, adopted: false };
175
+ }
176
+
177
+ /** Update this session's registry entry. Writes the whole entry, so it survives a concurrent prune. */
178
+ #patch(patch) {
179
+ this.#entry = { ...this.#entry, ...patch };
180
+ writeEntry(this.#entry);
181
+ }
182
+
183
+ async #launchChrome(headless) {
184
+ return launchChrome({
185
+ executablePath: resolveChromePath({ chromePath: this.#config.chromePath, channel: this.#entry.channel }),
186
+ onSpawn: (pid) => this.#patch({ chromePid: pid }),
187
+ profileDir: this.#entry.profileDir,
188
+ headless,
189
+ viewport: this.#entry.viewport,
190
+ // Lockdown flags go last so they win over anything in chromeArgs.
191
+ extraArgs: [
192
+ ...this.#config.chromeArgs,
193
+ ...(this.#entry.strict && this.#entry.allowedOrigins ? lockdownArgs(this.#entry.allowedOrigins) : []),
194
+ ],
195
+ timeoutMs: this.#config.launchTimeoutSeconds * 1000,
196
+ });
197
+ }
198
+
199
+ async #launch(plan) {
200
+ const chrome = await this.#launchChrome(plan.headless);
201
+ this.#patch({ chromePid: chrome.pid, port: chrome.port, wsPath: chrome.wsPath });
202
+ this.#cdp = await CdpConnection.connect(browserWsUrl(chrome));
203
+ // Logins go in before the guard (the restore tab is fleet's own) and before any real page loads.
204
+ if (plan.stateData) await applyStorage(this.#cdp, plan.stateData);
205
+ await this.#installGuard();
206
+ await this.#startUpstream();
207
+ if (plan.url) await this.#navigateFirstTab(plan.url);
208
+ }
209
+
210
+ async #attach() {
211
+ this.#cdp = await CdpConnection.connect(browserWsUrl(this.#entry));
212
+ await this.#installGuard();
213
+ await this.#startUpstream();
214
+ }
215
+
216
+ async #installGuard() {
217
+ this.#guardOff = await installGuard(this.#cdp, {
218
+ allowlist: this.#entry.allowedOrigins,
219
+ strict: this.#entry.strict,
220
+ onBlock: (block) => {
221
+ this.#blocks.push(block);
222
+ if (this.#blocks.length > 50) this.#blocks.shift();
223
+ },
224
+ });
225
+ }
226
+
227
+ async #startUpstream() {
228
+ this.#upstream = await Upstream.start({
229
+ browserUrl: `http://127.0.0.1:${this.#entry.port}`,
230
+ extraArgs: this.#config.upstreamArgs,
231
+ rootsProvider: this.#rootsProvider,
232
+ });
233
+ }
234
+
235
+ async #navigateFirstTab(url) {
236
+ const { targetInfos } = await this.#cdp.send('Target.getTargets');
237
+ const page = targetInfos.find((t) => t.type === 'page');
238
+ if (!page) {
239
+ await this.#cdp.send('Target.createTarget', { url });
240
+ return;
241
+ }
242
+ const { sessionId } = await this.#cdp.send('Target.attachToTarget', { targetId: page.targetId, flatten: true });
243
+ await this.#cdp.send('Page.navigate', { url }, sessionId);
244
+ await this.#cdp.send('Target.detachFromTarget', { sessionId }).catch(() => {});
245
+ }
246
+
247
+ #startHeartbeat() {
248
+ clearInterval(this.#heartbeat);
249
+ this.#heartbeat = setInterval(() => {
250
+ try {
251
+ if (this.#entry) updateEntry(this.#entry.id, { lastSeen: Date.now(), fleetPid: process.pid });
252
+ } catch (err) {
253
+ // A full disk or permissions problem shouldn't take the MCP server down.
254
+ console.error(`[devtools-fleet] heartbeat failed: ${err.message}`);
255
+ }
256
+ }, HEARTBEAT_MS);
257
+ this.#heartbeat.unref();
258
+ }
259
+
260
+ // ------------------------------------------------------------ upstream calls
261
+
262
+ /** Make sure there is a healthy browser + upstream. Returns a note for the agent if something was recovered. */
263
+ ensureReady() {
264
+ return this.#serial(() => this.#ensureReady());
265
+ }
266
+
267
+ async #ensureReady() {
268
+ if (!this.#entry) {
269
+ const { adopted } = await this.#start({});
270
+ return adopted ? `devtools-fleet: reconnected to this session's existing browser ${this.#entry.id}.` : null;
271
+ }
272
+ if (this.#cdp.closed || !(await isChromeAlive(this.#entry))) {
273
+ const { state, allowedOrigins, headless, viewport, channel } = this.#entry;
274
+ await this.#teardown({ keepBrowser: false });
275
+ await this.#start({ state: state ?? undefined, allowedOrigins: state ? undefined : allowedOrigins ?? undefined, headless, viewport, channel });
276
+ return `devtools-fleet: the browser had exited, so a fresh one was started (${this.#entry.id}${state ? `, state "${state}" reloaded` : ''}). Open tabs were lost.`;
277
+ }
278
+ if (!this.#upstream || this.#upstream.closed) {
279
+ await this.#startUpstream();
280
+ return 'devtools-fleet: chrome-devtools-mcp had exited and was restarted. The browser and its tabs are unchanged.';
281
+ }
282
+ return null;
283
+ }
284
+
285
+ async callUpstream(name, args = {}) {
286
+ const { note, upstream, entry } = await this.#serial(async () => {
287
+ const recovered = await this.#ensureReady();
288
+ return { note: recovered, upstream: this.#upstream, entry: this.#entry };
289
+ });
290
+ const refusal = checkToolCall(name, args, { allowlist: entry.allowedOrigins, stateName: entry.state });
291
+ if (refusal) return errorResult(`devtools-fleet: ${refusal}`);
292
+ const blocksBefore = this.#blocks.length;
293
+ let result;
294
+ try {
295
+ result = await upstream.callTool(name, args);
296
+ } catch (err) {
297
+ if (upstream.closed || this.#cdp?.closed || upstream !== this.#upstream) {
298
+ const recovered = await this.ensureReady().catch((e) => `Recovery failed: ${e.message}`);
299
+ return errorResult(`devtools-fleet: the call failed because the browser tooling exited or was restarted (${err.message}). ${recovered ?? ''} Retry the call.`);
300
+ }
301
+ throw err;
302
+ }
303
+ const newBlocks = this.#blocks.slice(blocksBefore);
304
+ const extra = [];
305
+ if (note) extra.push(note);
306
+ if (newBlocks.length) {
307
+ extra.push(
308
+ `devtools-fleet blocked ${newBlocks.length} request(s) outside the allowlist (${(entry.allowedOrigins ?? []).join(', ')}):\n` +
309
+ newBlocks.slice(0, 5).map((b) => ` ${b.resourceType ?? 'request'} ${b.url}`).join('\n'),
310
+ );
311
+ }
312
+ if (extra.length) result = { ...result, content: [...(result.content ?? []), { type: 'text', text: extra.join('\n\n') }] };
313
+ return result;
314
+ }
315
+
316
+ // ------------------------------------------------------------- fleet tools
317
+
318
+ status() {
319
+ return this.#serial(async () => {
320
+ if (!this.#entry) return { running: false };
321
+ const alive = !this.#cdp?.closed && (await isChromeAlive(this.#entry));
322
+ const tabs = alive ? await openTabUrls(this.#cdp).catch(() => []) : [];
323
+ return {
324
+ running: alive,
325
+ id: this.#entry.id,
326
+ state: this.#entry.state,
327
+ allowedOrigins: this.#entry.allowedOrigins,
328
+ strict: this.#entry.strict,
329
+ headless: this.#entry.headless,
330
+ viewport: this.#entry.viewport,
331
+ channel: this.#entry.channel,
332
+ tabs,
333
+ uptimeSeconds: Math.round((Date.now() - this.#entry.createdAt) / 1000),
334
+ blockedRequests: this.#blocks.length,
335
+ };
336
+ });
337
+ }
338
+
339
+ /** Relaunch with the same profile, cookies, storage and tabs, optionally switching headless. */
340
+ restart({ headless } = {}) {
341
+ return this.#serial(() => this.#restart({ headless }));
342
+ }
343
+
344
+ async #restart({ headless }) {
345
+ if (!this.#entry) throw new Error('No browser is running in this session. Call browser_start first.');
346
+ await this.#ensureReady();
347
+ const allowlist = this.#entry.allowedOrigins;
348
+ const tabs = await openTabUrls(this.#cdp);
349
+ const storageOrigins = [...new Set(tabs.map((u) => new URL(u).origin))];
350
+ const snapshot = await captureStorage(this.#cdp, { cookieAllowlist: null, storageOrigins });
351
+
352
+ // No port = "starting" to everyone else: the reaper and prune leave the entry
353
+ // (and the profile dir the new Chrome is about to use) alone.
354
+ const previous = { ...this.#entry };
355
+ this.#patch({ port: null, wsPath: null });
356
+ await this.#stopUpstreamAndGuard();
357
+ await closeChrome(previous, { cdp: this.#cdp });
358
+ this.#cdp.close();
359
+
360
+ const nextHeadless = headless ?? this.#entry.headless;
361
+ const chrome = await this.#launchChrome(nextHeadless);
362
+ this.#cdp = await CdpConnection.connect(browserWsUrl(chrome));
363
+ await applyStorage(this.#cdp, snapshot);
364
+ await this.#installGuard();
365
+ this.#patch({ chromePid: chrome.pid, port: chrome.port, wsPath: chrome.wsPath, headless: nextHeadless, lastSeen: Date.now() });
366
+ const reopen = tabs.filter((u) => checkUrl(allowlist, u).allowed);
367
+ if (reopen.length) {
368
+ const { targetInfos } = await this.#cdp.send('Target.getTargets');
369
+ for (const url of reopen) await this.#cdp.send('Target.createTarget', { url });
370
+ for (const t of targetInfos.filter((t) => t.type === 'page' && t.url === 'about:blank')) {
371
+ await this.#cdp.send('Target.closeTarget', { targetId: t.targetId }).catch(() => {});
372
+ }
373
+ }
374
+ await this.#startUpstream();
375
+ return { headless: nextHeadless, reopenedTabs: reopen.length };
376
+ }
377
+
378
+ stop() {
379
+ return this.#serial(async () => {
380
+ if (!this.#entry) return { stopped: false };
381
+ const id = this.#entry.id;
382
+ await this.#teardown({ keepBrowser: false });
383
+ return { stopped: true, id };
384
+ });
385
+ }
386
+
387
+ saveState(opts) {
388
+ return this.#serial(() => this.#saveState(opts));
389
+ }
390
+
391
+ async #saveState({ name, allowedOrigins, overwrite = false, strict }) {
392
+ if (!this.#entry) throw new Error('No browser is running in this session. Start one and log in first.');
393
+ await this.#ensureReady();
394
+ if (stateExists(name)) {
395
+ const existing = summarizeState(readState(name));
396
+ if (existing.createdBy === 'cli') {
397
+ throw new Error(`State "${name}" was created by a person with \`devtools-fleet login\`; agents can't overwrite it. Pick another name.`);
398
+ }
399
+ if (!overwrite) throw new Error(`State "${name}" already exists. Pass overwrite: true to replace it.`);
400
+ }
401
+ const current = this.#entry.allowedOrigins;
402
+ const open = await openOrigins(this.#cdp);
403
+ const allow = allowedOrigins?.length ? normalizeOriginPatterns(allowedOrigins) : open.filter((o) => checkUrl(current, o).allowed);
404
+ if (!allow.length) throw new Error('Nothing to save: no allowed http(s) page is open. Open the logged-in site first, or pass allowedOrigins.');
405
+ if (current && !isSubset(allow, current)) {
406
+ throw new Error(`allowedOrigins may only narrow this browser's allowlist (${current.join(', ')}), not widen it.`);
407
+ }
408
+ if (current && this.#entry.strict && strict === false) throw new Error('This browser runs in strict mode; a state saved from it stays strict.');
409
+ const storageOrigins = [...new Set([
410
+ ...allow.filter((p) => !p.includes('*')),
411
+ ...open.filter((o) => allow.some((p) => patternMatchesOrigin(p, o))),
412
+ ])];
413
+ const snapshot = await captureStorage(this.#cdp, { cookieAllowlist: allow, storageOrigins });
414
+ return writeState({
415
+ name,
416
+ cookies: snapshot.cookies,
417
+ origins: snapshot.origins,
418
+ allowedOrigins: allow,
419
+ strict: strict ?? this.#entry.strict ?? false,
420
+ createdBy: 'agent',
421
+ });
422
+ }
423
+
424
+ // ------------------------------------------------------------- lifecycle
425
+
426
+ async #stopUpstreamAndGuard() {
427
+ this.#guardOff?.();
428
+ this.#guardOff = null;
429
+ await this.#upstream?.close();
430
+ this.#upstream = null;
431
+ }
432
+
433
+ async #teardown({ keepBrowser }) {
434
+ clearInterval(this.#heartbeat);
435
+ await this.#stopUpstreamAndGuard().catch(() => {});
436
+ const entry = this.#entry;
437
+ if (entry && !keepBrowser) {
438
+ await closeChrome(entry, { cdp: this.#cdp }).catch(() => {});
439
+ removeEntry(entry.id);
440
+ rmSync(entry.profileDir, { recursive: true, force: true });
441
+ } else if (entry) {
442
+ try {
443
+ updateEntry(entry.id, { fleetPid: 0, lastSeen: Date.now() });
444
+ } catch {
445
+ // best effort on the way out
446
+ }
447
+ }
448
+ this.#cdp?.close();
449
+ this.#cdp = null;
450
+ this.#entry = null;
451
+ }
452
+
453
+ /**
454
+ * The MCP connection is going away (client reconnect, session end). Leave the
455
+ * browser running for re-adoption; the reaper closes it if nobody comes back.
456
+ */
457
+ detach() {
458
+ return this.#serial(async () => {
459
+ if (this.#entry) await this.#teardown({ keepBrowser: true });
460
+ });
461
+ }
462
+ }
463
+
464
+ function errorResult(text) {
465
+ return { content: [{ type: 'text', text }], isError: true };
466
+ }
@@ -0,0 +1,135 @@
1
+ import { readFileSync, readdirSync, rmSync, existsSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+ import { PATHS } from './config.js';
5
+ import { ensurePrivateDir, writeJsonAtomic } from './fs-utils.js';
6
+ import { normalizeOriginPatterns, cookieMatchesAllowlist, checkUrl } from './origins.js';
7
+
8
+ // A state is a saved login: cookies + localStorage in Playwright's
9
+ // storageState shape, plus fleet metadata. Playwright can load a fleet state
10
+ // file as-is (it ignores the extra keys).
11
+ //
12
+ // {
13
+ // cookies: [...], origins: [{ origin, localStorage: [{ name, value }] }],
14
+ // devtoolsFleet: { version: 1, name, allowedOrigins, strict, createdBy: 'cli' | 'agent', savedAt }
15
+ // }
16
+
17
+ const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
18
+
19
+ export function assertStateName(name) {
20
+ if (typeof name !== 'string' || !NAME_RE.test(name)) {
21
+ throw new Error(`Invalid state name "${name}": letters, digits, dot, dash, underscore; max 64; must start with a letter or digit`);
22
+ }
23
+ }
24
+
25
+ export function statePath(name) {
26
+ assertStateName(name);
27
+ return join(PATHS.states, `${name}.json`);
28
+ }
29
+
30
+ /** States hold live session tokens. Never write them where git could pick them up. */
31
+ function assertNotInGitRepo(dir) {
32
+ try {
33
+ const inside = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).trim();
34
+ if (inside === 'true') {
35
+ throw new Error(`Refusing to store login states inside a git work tree (${dir}). Point DEVTOOLS_FLEET_HOME somewhere outside any repository.`);
36
+ }
37
+ } catch (err) {
38
+ if (err.message.startsWith('Refusing')) throw err;
39
+ // git missing or not a repo: fine
40
+ }
41
+ }
42
+
43
+ export function stateExists(name) {
44
+ return existsSync(statePath(name));
45
+ }
46
+
47
+ export function readState(name) {
48
+ const file = statePath(name);
49
+ let data;
50
+ try {
51
+ data = JSON.parse(readFileSync(file, 'utf8'));
52
+ } catch (err) {
53
+ if (err.code === 'ENOENT') throw new Error(`No state named "${name}". List them with state_list or \`devtools-fleet states\`.`, { cause: err });
54
+ throw new Error(`State "${name}" is unreadable: ${err.message}`, { cause: err });
55
+ }
56
+ if (!data.devtoolsFleet?.allowedOrigins?.length) {
57
+ throw new Error(`State "${name}" has no allowedOrigins. Re-save it with \`devtools-fleet login\` or \`devtools-fleet state import --allow ...\`.`);
58
+ }
59
+ return data;
60
+ }
61
+
62
+ export function writeState({ name, cookies, origins, allowedOrigins, strict = false, createdBy }) {
63
+ assertStateName(name);
64
+ const allow = normalizeOriginPatterns(allowedOrigins);
65
+ if (!allow.length) throw new Error('A state needs at least one allowed origin');
66
+ ensurePrivateDir(PATHS.states);
67
+ assertNotInGitRepo(PATHS.states);
68
+ const data = {
69
+ cookies,
70
+ origins,
71
+ devtoolsFleet: { version: 1, name, allowedOrigins: allow, strict: Boolean(strict), createdBy, savedAt: new Date().toISOString() },
72
+ };
73
+ writeJsonAtomic(statePath(name), data);
74
+ return summarizeState(data);
75
+ }
76
+
77
+ export function deleteState(name) {
78
+ const file = statePath(name);
79
+ if (!existsSync(file)) throw new Error(`No state named "${name}"`);
80
+ rmSync(file);
81
+ }
82
+
83
+ /** Metadata only. Never cookie or storage values. */
84
+ export function summarizeState(data) {
85
+ const meta = data.devtoolsFleet;
86
+ const now = Date.now() / 1000;
87
+ const expiring = data.cookies.filter((c) => c.expires > 0);
88
+ const soonest = expiring.length ? Math.min(...expiring.map((c) => c.expires)) : null;
89
+ return {
90
+ name: meta.name,
91
+ allowedOrigins: meta.allowedOrigins,
92
+ strict: meta.strict,
93
+ createdBy: meta.createdBy,
94
+ savedAt: meta.savedAt,
95
+ cookies: data.cookies.length,
96
+ sessionCookies: data.cookies.filter((c) => c.expires === -1).length,
97
+ expiredCookies: expiring.filter((c) => c.expires < now).length,
98
+ firstCookieExpiry: soonest ? new Date(soonest * 1000).toISOString() : null,
99
+ localStorageOrigins: data.origins.map((o) => o.origin),
100
+ };
101
+ }
102
+
103
+ export function listStates() {
104
+ let files;
105
+ try {
106
+ files = readdirSync(PATHS.states);
107
+ } catch {
108
+ return [];
109
+ }
110
+ const out = [];
111
+ for (const file of files.sort()) {
112
+ if (!file.endsWith('.json')) continue;
113
+ const name = file.slice(0, -5);
114
+ try {
115
+ out.push(summarizeState(readState(name)));
116
+ } catch (err) {
117
+ out.push({ name, error: err.message });
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** Import a Playwright / agent-browser storageState file. */
124
+ export function importState({ name, file, allowedOrigins, strict = false }) {
125
+ const data = JSON.parse(readFileSync(file, 'utf8'));
126
+ if (!Array.isArray(data.cookies)) throw new Error(`${file} is not a storageState file (no cookies array)`);
127
+ const allow = normalizeOriginPatterns(allowedOrigins ?? data.devtoolsFleet?.allowedOrigins ?? []);
128
+ if (!allow.length) throw new Error('Pass --allow <origin> at least once: imported states need an allowlist');
129
+ const cookies = data.cookies.filter((c) => cookieMatchesAllowlist(c.domain, allow));
130
+ const origins = (data.origins ?? []).filter((o) => checkUrl(allow, o.origin).allowed);
131
+ return {
132
+ summary: writeState({ name, cookies, origins, allowedOrigins: allow, strict, createdBy: 'cli' }),
133
+ droppedCookies: data.cookies.length - cookies.length,
134
+ };
135
+ }