cdp-toolkit 1.10.0 → 2.0.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 +60 -0
- package/README.md +61 -12
- package/dist/cli.js +1233 -312
- package/dist/index.js +11 -11
- package/dist/mcp.js +1314 -320
- package/package.json +4 -2
- package/src/bidi/driver.ts +219 -29
- package/src/bidi/launch.ts +51 -1
- package/src/bidi/marionette.ts +276 -0
- package/src/bidi/session-lease.ts +265 -0
- package/src/cli.ts +10 -0
- package/src/install/browser.ts +211 -0
- package/src/install/harness.ts +224 -0
- package/src/install/wizard.ts +412 -0
- package/src/manifest.ts +297 -297
- package/src/mcp.ts +123 -11
- package/src/toolDocs.ts +468 -0
- package/src/toolGroups.ts +49 -0
- package/src/tools/navigation.ts +4 -2
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Firefox Marionette client — orphan BiDi-session recovery, issue #4.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS FOR, AND NOTHING ELSE. Firefox serves exactly ONE
|
|
5
|
+
* WebDriver-BiDi session per browser instance, and Marionette and BiDi SHARE
|
|
6
|
+
* that one global session slot. When a cdp-toolkit process dies mid-drive it can
|
|
7
|
+
* leave Firefox believing a BiDi session is still active; the next `session.new`
|
|
8
|
+
* then fails with "Maximum number of active sessions" even though no live
|
|
9
|
+
* process holds it. `session-lease.ts` detects that a dead process left the slot
|
|
10
|
+
* occupied (its `staleHolder`); THIS module is the mechanism that then frees the
|
|
11
|
+
* slot inside Firefox itself, WITHOUT killing the browser — so a user's real,
|
|
12
|
+
* logged-in Firefox keeps running and only the orphaned session is cleared.
|
|
13
|
+
*
|
|
14
|
+
* WHY MARIONETTE AND NOT BIDI. The BiDi endpoint is what's wedged: it refuses a
|
|
15
|
+
* new session while the orphan is "active", and it exposes no cross-session
|
|
16
|
+
* teardown. Marionette is Firefox's OTHER, older remote-control channel (a raw
|
|
17
|
+
* TCP port, `--marionette`, default 2828). Because the two dialects share the
|
|
18
|
+
* one session slot, a single Marionette `WebDriver:DeleteSession` tears down
|
|
19
|
+
* whatever occupies that slot — the orphaned BiDi session included — as a blind
|
|
20
|
+
* cross-dialect teardown. No `WebDriver:NewSession` is needed first: DeleteSession
|
|
21
|
+
* against an empty slot is a harmless no-op ("invalid session id").
|
|
22
|
+
*
|
|
23
|
+
* THE WIRE PROTOCOL ("packet" dialect, marionetteProtocol 3), proven against
|
|
24
|
+
* Firefox 153.0.3:
|
|
25
|
+
* - Framing, both directions: `<utf8-byte-length>:<json>` — the ASCII decimal
|
|
26
|
+
* byte count of the JSON body, a literal ':', then exactly that many bytes of
|
|
27
|
+
* UTF-8 JSON. Frames coalesce and split across TCP reads; the decoder handles
|
|
28
|
+
* both (see FrameDecoder).
|
|
29
|
+
* - On connect, Firefox immediately PUSHES a handshake frame (a bare object,
|
|
30
|
+
* e.g. `50:{"applicationType":"gecko","marionetteProtocol":3}`) that a client
|
|
31
|
+
* must consume before anything else.
|
|
32
|
+
* - Command frame: `[0, msgId, "Command:Name", params]`.
|
|
33
|
+
* - Response frame: `[1, msgId, error_or_null, result_or_null]`.
|
|
34
|
+
*
|
|
35
|
+
* ZERO RUNTIME DEPENDENCIES: only `node:net`.
|
|
36
|
+
*/
|
|
37
|
+
import { Socket } from "node:net";
|
|
38
|
+
|
|
39
|
+
/** Firefox's default Marionette control port. */
|
|
40
|
+
const DEFAULT_MARIONETTE_PORT = 2828;
|
|
41
|
+
/** Whole-exchange budget: connect + handshake + command + reply. */
|
|
42
|
+
const DEFAULT_TIMEOUT_MS = 4000;
|
|
43
|
+
/** Marionette listens on loopback only; never dial anything else. */
|
|
44
|
+
const HOST = "127.0.0.1";
|
|
45
|
+
/** ASCII ':' — the length/body delimiter in the packet framing. */
|
|
46
|
+
const COLON = 0x3a;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The one command we ever send. Marionette + BiDi share the single session slot,
|
|
50
|
+
* so this blindly tears down whatever occupies it (the orphaned BiDi session)
|
|
51
|
+
* with no NewSession first. Precomputed once: `34:[0,1,"WebDriver:DeleteSession",{}]`.
|
|
52
|
+
*/
|
|
53
|
+
const DELETE_SESSION_FRAME = encodeFrame([0, 1, "WebDriver:DeleteSession", {}]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The Marionette control port to target. Reads `CDP_FIREFOX_MARIONETTE_PORT`
|
|
57
|
+
* per call (like session-lease.ts's env getters, so a test can redirect it); a
|
|
58
|
+
* value that is not a finite number > 0 falls back to Firefox's 2828 default.
|
|
59
|
+
*/
|
|
60
|
+
export function defaultMarionettePort(): number {
|
|
61
|
+
const raw = Number(process.env.CDP_FIREFOX_MARIONETTE_PORT);
|
|
62
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MARIONETTE_PORT;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Encode one value as a Marionette wire frame: `<utf8-byte-length>:<utf8-json>`.
|
|
67
|
+
* The length prefix is the BYTE count of the JSON body (not its character
|
|
68
|
+
* count), so multi-byte UTF-8 payloads frame correctly. Pure; exported for tests.
|
|
69
|
+
*/
|
|
70
|
+
export function encodeFrame(json: unknown): Buffer {
|
|
71
|
+
const body = Buffer.from(JSON.stringify(json), "utf8");
|
|
72
|
+
const prefix = Buffer.from(`${body.length}:`, "ascii");
|
|
73
|
+
return Buffer.concat([prefix, body]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Raised when the byte stream is not valid Marionette framing — a non-digit in
|
|
78
|
+
* the length prefix, or a frame body that is complete but not parseable JSON.
|
|
79
|
+
* FrameDecoder.next() throws this; forceClearOrphanSession catches it and
|
|
80
|
+
* degrades to `false` (a wedged/unparseable channel cannot confirm a free slot).
|
|
81
|
+
*/
|
|
82
|
+
export class MarionetteProtocolError extends Error {
|
|
83
|
+
constructor(message: string) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = "MarionetteProtocolError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Incremental decoder for the `<byte-length>:<json>` framing. Feed raw TCP
|
|
91
|
+
* chunks with push(); pull one complete, parsed frame per next() call.
|
|
92
|
+
*
|
|
93
|
+
* Handles the two things naive framing gets wrong on a real socket: a single
|
|
94
|
+
* frame SPLIT across several reads (next() returns undefined until the whole
|
|
95
|
+
* body has arrived) and several frames COALESCED into one read (repeated next()
|
|
96
|
+
* calls drain them one at a time). Exported for unit testing against known
|
|
97
|
+
* byte vectors.
|
|
98
|
+
*/
|
|
99
|
+
export class FrameDecoder {
|
|
100
|
+
private buf: Buffer = Buffer.alloc(0);
|
|
101
|
+
|
|
102
|
+
/** Append a chunk of bytes. Cheap: no scan happens here. */
|
|
103
|
+
push(chunk: Buffer): void {
|
|
104
|
+
this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Return the next complete frame as its parsed value, or undefined when more
|
|
109
|
+
* bytes are still needed to complete the frame currently at the front of the
|
|
110
|
+
* buffer. Throws MarionetteProtocolError on malformed framing/JSON.
|
|
111
|
+
*/
|
|
112
|
+
next(): unknown | undefined {
|
|
113
|
+
const colon = this.buf.indexOf(COLON);
|
|
114
|
+
if (colon === -1) {
|
|
115
|
+
// No delimiter yet. Everything buffered so far must be ASCII digits, or
|
|
116
|
+
// this is not Marionette framing and never will be — fail fast instead of
|
|
117
|
+
// buffering garbage until the caller's timeout.
|
|
118
|
+
this.assertDigitsPrefix(this.buf.length);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (colon === 0) throw new MarionetteProtocolError("empty frame length prefix");
|
|
122
|
+
this.assertDigitsPrefix(colon);
|
|
123
|
+
|
|
124
|
+
const len = Number.parseInt(this.buf.toString("ascii", 0, colon), 10);
|
|
125
|
+
const start = colon + 1;
|
|
126
|
+
const end = start + len;
|
|
127
|
+
if (this.buf.length < end) return undefined; // body not fully arrived yet
|
|
128
|
+
|
|
129
|
+
const payload = this.buf.toString("utf8", start, end);
|
|
130
|
+
this.buf = this.buf.subarray(end); // keep any trailing bytes for the next frame
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(payload);
|
|
133
|
+
} catch {
|
|
134
|
+
throw new MarionetteProtocolError(
|
|
135
|
+
`frame body was not valid JSON (${len} bytes): ${payload.slice(0, 80)}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every byte in buf[0, upTo) must be an ASCII digit, or the framing is bad. */
|
|
141
|
+
private assertDigitsPrefix(upTo: number): void {
|
|
142
|
+
for (let i = 0; i < upTo; i++) {
|
|
143
|
+
const b = this.buf[i]!;
|
|
144
|
+
if (b < 0x30 || b > 0x39) {
|
|
145
|
+
throw new MarionetteProtocolError(
|
|
146
|
+
`non-digit byte 0x${b.toString(16)} in frame length prefix`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Interpret a `WebDriver:DeleteSession` reply frame `[1, msgId, error, result]`.
|
|
155
|
+
* The slot is FREE (return true) in exactly two shapes:
|
|
156
|
+
* - error === null (result `{"value":null}`): an active session existed and
|
|
157
|
+
* was cleared.
|
|
158
|
+
* - error is an object with error === "invalid session id": the slot was
|
|
159
|
+
* already empty — DeleteSession was a no-op.
|
|
160
|
+
* Any other shape (a different error, a non-response, a malformed frame) cannot
|
|
161
|
+
* confirm the slot is free, so it returns false.
|
|
162
|
+
*/
|
|
163
|
+
function isSlotFreedReply(frame: unknown): boolean {
|
|
164
|
+
if (!Array.isArray(frame)) return false;
|
|
165
|
+
const err: unknown = frame[2];
|
|
166
|
+
if (err === null) return true; // session existed and was cleared
|
|
167
|
+
if (typeof err === "object" && err !== null && (err as { error?: unknown }).error === "invalid session id") {
|
|
168
|
+
return true; // slot was already empty
|
|
169
|
+
}
|
|
170
|
+
return false; // any other error: cannot confirm
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Force-clear an orphaned WebDriver-BiDi session so a subsequent BiDi
|
|
175
|
+
* `session.new` succeeds, WITHOUT killing Firefox.
|
|
176
|
+
*
|
|
177
|
+
* Connects to Marionette on `127.0.0.1:<marionettePort>`, drains the pushed
|
|
178
|
+
* handshake frame, sends exactly one `WebDriver:DeleteSession`, reads exactly one
|
|
179
|
+
* reply, and returns whether the session slot is now free.
|
|
180
|
+
*
|
|
181
|
+
* NEVER THROWS and NEVER LEAKS THE SOCKET. Every terminal path — success, a
|
|
182
|
+
* refused connection (Marionette not running because Firefox was not launched
|
|
183
|
+
* with --marionette; the NORMAL degrade case, not a loud error), a connect/read
|
|
184
|
+
* timeout, any socket error, a premature close, or an unexpected/unparseable
|
|
185
|
+
* reply — routes through a single settle() that destroys the socket exactly once
|
|
186
|
+
* and resolves the promise. Returns true only when the slot is confirmed free.
|
|
187
|
+
*
|
|
188
|
+
* @param marionettePort TCP port Firefox's Marionette server listens on.
|
|
189
|
+
* @param opts.timeoutMs Whole-exchange budget (connect+handshake+command+reply).
|
|
190
|
+
* Default 4000ms. On timeout the socket is destroyed and
|
|
191
|
+
* the result is false.
|
|
192
|
+
*/
|
|
193
|
+
export function forceClearOrphanSession(
|
|
194
|
+
marionettePort: number,
|
|
195
|
+
opts: { timeoutMs?: number } = {},
|
|
196
|
+
): Promise<boolean> {
|
|
197
|
+
const timeoutMs =
|
|
198
|
+
opts.timeoutMs !== undefined && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
|
|
199
|
+
? opts.timeoutMs
|
|
200
|
+
: DEFAULT_TIMEOUT_MS;
|
|
201
|
+
|
|
202
|
+
return new Promise<boolean>((resolve) => {
|
|
203
|
+
// Guard a nonsense port up front: net.Socket.connect throws synchronously on
|
|
204
|
+
// an out-of-range/NaN port, and that throw must not escape as a rejection.
|
|
205
|
+
if (!Number.isFinite(marionettePort) || marionettePort <= 0 || marionettePort > 65535) {
|
|
206
|
+
resolve(false);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sock = new Socket();
|
|
211
|
+
const decoder = new FrameDecoder();
|
|
212
|
+
let settled = false;
|
|
213
|
+
let handshakeSeen = false;
|
|
214
|
+
let commandSent = false;
|
|
215
|
+
|
|
216
|
+
// One-shot terminal path. Idempotent (settled guard), removes listeners so a
|
|
217
|
+
// close/error triggered by our own destroy() can't re-enter, and ALWAYS
|
|
218
|
+
// destroys the socket — this is the single place the socket is torn down, so
|
|
219
|
+
// no code path can return without freeing it.
|
|
220
|
+
const settle = (value: boolean): void => {
|
|
221
|
+
if (settled) return;
|
|
222
|
+
settled = true;
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
sock.removeAllListeners();
|
|
225
|
+
sock.destroy();
|
|
226
|
+
resolve(value);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// The timeout covers the WHOLE exchange, including connect. unref() so a
|
|
230
|
+
// hosting process is never held open by this timer alone.
|
|
231
|
+
const timer = setTimeout(() => settle(false), timeoutMs);
|
|
232
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
233
|
+
|
|
234
|
+
// ECONNREFUSED (no Marionette on the port) and every other socket error are
|
|
235
|
+
// the normal degrade to false — quiet, not logged.
|
|
236
|
+
sock.on("error", () => settle(false));
|
|
237
|
+
// Peer closed before we got a reply: can't confirm, degrade to false.
|
|
238
|
+
sock.on("close", () => settle(false));
|
|
239
|
+
|
|
240
|
+
sock.on("data", (chunk: Buffer) => {
|
|
241
|
+
if (settled) return;
|
|
242
|
+
try {
|
|
243
|
+
decoder.push(chunk);
|
|
244
|
+
for (;;) {
|
|
245
|
+
const frame = decoder.next();
|
|
246
|
+
if (frame === undefined) break; // need more bytes for the front frame
|
|
247
|
+
if (!handshakeSeen) {
|
|
248
|
+
// First frame is Firefox's handshake (a bare object). Consume and
|
|
249
|
+
// discard it, then keep draining in case a later frame coalesced.
|
|
250
|
+
handshakeSeen = true;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
// First post-handshake frame is our DeleteSession reply. Done.
|
|
254
|
+
settle(isSlotFreedReply(frame));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Handshake in hand and command not yet on the wire → send it once.
|
|
258
|
+
if (handshakeSeen && !commandSent) {
|
|
259
|
+
commandSent = true;
|
|
260
|
+
sock.write(DELETE_SESSION_FRAME);
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
// Malformed framing/JSON: the channel can't confirm a free slot.
|
|
264
|
+
settle(false);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// connect() can still throw synchronously (e.g. a mid-range but invalid
|
|
269
|
+
// arg); route that through settle so nothing escapes as a rejection.
|
|
270
|
+
try {
|
|
271
|
+
sock.connect(marionettePort, HOST);
|
|
272
|
+
} catch {
|
|
273
|
+
settle(false);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firefox BiDi SESSION-slot lease: cross-process coordination for the ONE
|
|
3
|
+
* WebDriver-BiDi session a single Firefox instance will serve.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. Firefox admits exactly one active WebDriver-BiDi session per
|
|
6
|
+
* browser. When two cdp-toolkit MCP-server processes ATTACH to the same
|
|
7
|
+
* user-launched Firefox endpoint (e.g. ws://127.0.0.1:9223/session), they both
|
|
8
|
+
* want that one slot. Today the second process calls session.new and gets a
|
|
9
|
+
* hard "Maximum number of active sessions" with no way to tell a LIVE holder
|
|
10
|
+
* (another agent legitimately driving the browser) from a DEAD one (a crashed
|
|
11
|
+
* process that left the slot occupied). This module makes that distinction on
|
|
12
|
+
* disk, so a live holder is respected and a dead holder's orphan is reclaimed.
|
|
13
|
+
*
|
|
14
|
+
* WHY A FILE, AND WHY KEYED BY ENDPOINT. Same reasoning as the tab leases in
|
|
15
|
+
* ../leases.ts: two MCP servers are two processes, so in-process state would
|
|
16
|
+
* guard the wrong boundary. The tab lease is keyed by (backend, targetId)
|
|
17
|
+
* because it owns a tab; this lease is keyed by the BiDi ws ENDPOINT string
|
|
18
|
+
* because it owns the browser's single session slot, which is a property of the
|
|
19
|
+
* endpoint, not of any tab. One lock file per endpoint.
|
|
20
|
+
*
|
|
21
|
+
* WHY NO NONCE/TOKEN. A tab lease mints a nonce because a caller passes a token
|
|
22
|
+
* back and a reclamation must invalidate the superseded one. Nobody passes a
|
|
23
|
+
* session token back here: the slot is owned by whichever process currently
|
|
24
|
+
* holds the live BiDi connection and is released on dispose. Ownership is
|
|
25
|
+
* therefore identified by (pid, createdAt) of the record we wrote, which is
|
|
26
|
+
* enough to keep release from unlinking a record another process created after
|
|
27
|
+
* stealing the slot from us.
|
|
28
|
+
*
|
|
29
|
+
* WHY STALENESS IS PURE PID-LIVENESS, WITH NO TTL. A long-lived MCP server can
|
|
30
|
+
* legitimately hold the slot for hours, so a time-based expiry would reap a
|
|
31
|
+
* healthy holder. The only thing that makes a holder illegitimate is that its
|
|
32
|
+
* process is gone. So the sole staleness test is isPidAlive (reused from
|
|
33
|
+
* ../leases.ts): pid alive -> legitimate holder; pid dead -> orphan to steal.
|
|
34
|
+
*
|
|
35
|
+
* ATOMICITY. Claims are exclusive creates (writeFile flag "wx"), exactly like
|
|
36
|
+
* ../leases.ts claimLease, so "is the slot free, and if so take it" is atomic
|
|
37
|
+
* against a racing process for free, with no registry lock.
|
|
38
|
+
*/
|
|
39
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { isPidAlive, leaseDir } from "../leases.ts";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The on-disk record for a held session slot. Keyed by endpoint (one file per
|
|
45
|
+
* endpoint). No nonce: see the header. marionettePort is carried so a stealer
|
|
46
|
+
* can surface which Marionette port an orphaned session was bound to when it
|
|
47
|
+
* has to force-clear it.
|
|
48
|
+
*/
|
|
49
|
+
export interface SessionSlotRecord {
|
|
50
|
+
endpoint: string;
|
|
51
|
+
/** Owning MCP server process. The one field staleness turns on. */
|
|
52
|
+
pid: number;
|
|
53
|
+
/** Caller-supplied agent label, surfaced in busy/steal reporting. */
|
|
54
|
+
label: string;
|
|
55
|
+
createdAt: number;
|
|
56
|
+
/** Optional Marionette control port bound to this Firefox, when known. */
|
|
57
|
+
marionettePort?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a holder keeps so it can release ITS OWN record and never another
|
|
62
|
+
* process's. endpoint locates the file; (pid, createdAt) identify our record.
|
|
63
|
+
*/
|
|
64
|
+
export interface SessionSlotHandle {
|
|
65
|
+
endpoint: string;
|
|
66
|
+
pid: number;
|
|
67
|
+
createdAt: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AcquireResult {
|
|
71
|
+
handle: SessionSlotHandle;
|
|
72
|
+
/**
|
|
73
|
+
* Set only when we STOLE a dead holder's slot. Its presence tells the caller
|
|
74
|
+
* that Firefox may still believe an orphaned BiDi session is active, so the
|
|
75
|
+
* caller must force-clear that session (e.g. via Marionette) before it calls
|
|
76
|
+
* session.new, or Firefox will still refuse with "Maximum number of active
|
|
77
|
+
* sessions". Absent on a clean first acquire.
|
|
78
|
+
*/
|
|
79
|
+
staleHolder?: SessionSlotRecord;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AcquireOptions {
|
|
83
|
+
label: string;
|
|
84
|
+
marionettePort?: number;
|
|
85
|
+
/** How long to keep polling a LIVE holder before giving up. Default sessionWaitMs(). */
|
|
86
|
+
waitMs?: number;
|
|
87
|
+
/** Injectable clock for the record's createdAt, so tests are deterministic. */
|
|
88
|
+
now?: number;
|
|
89
|
+
/** Poll interval while waiting on a live holder. Default DEFAULT_POLL_MS. */
|
|
90
|
+
pollMs?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Env-overridable wait ceiling, in ms. */
|
|
94
|
+
const DEFAULT_SESSION_WAIT_MS = 10_000;
|
|
95
|
+
/** Poll cadence while a live holder is blocking us. */
|
|
96
|
+
const DEFAULT_POLL_MS = 250;
|
|
97
|
+
|
|
98
|
+
/** Same substitution rule as ../leases.ts safeId, applied to the endpoint URL. */
|
|
99
|
+
function safeEndpoint(endpoint: string): string {
|
|
100
|
+
return endpoint.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One lock file per endpoint, a sibling of the tab lease files in ARTIFACT_DIR. */
|
|
104
|
+
export function sessionSlotFile(endpoint: string): string {
|
|
105
|
+
return join(leaseDir(), `ff-session-${safeEndpoint(endpoint)}.json`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read per call (like leaseDir/leaseTtlMs), so a test can redirect it. A value
|
|
109
|
+
* that is not a finite number > 0 falls back to the default. */
|
|
110
|
+
export function sessionWaitMs(): number {
|
|
111
|
+
const raw = Number(process.env.CDP_FIREFOX_SESSION_WAIT_MS);
|
|
112
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_WAIT_MS;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Thrown when the slot is held by a LIVE process and did not free up within the
|
|
117
|
+
* wait window (or the attempt cap was hit). Names the live holder so the caller
|
|
118
|
+
* can decide to wait and retry or point this server at a different browser.
|
|
119
|
+
*/
|
|
120
|
+
export class SessionSlotBusyError extends Error {
|
|
121
|
+
readonly endpoint: string;
|
|
122
|
+
readonly holderPid?: number;
|
|
123
|
+
readonly holderLabel?: string;
|
|
124
|
+
constructor(endpoint: string, holder?: SessionSlotRecord) {
|
|
125
|
+
const who = holder ? `'${holder.label}' (pid ${holder.pid})` : "another live process";
|
|
126
|
+
super(
|
|
127
|
+
`Firefox BiDi session slot for endpoint '${endpoint}' is held by ${who}. ` +
|
|
128
|
+
`Firefox serves exactly ONE WebDriver-BiDi session per browser instance, and this slot is held ` +
|
|
129
|
+
`by a LIVE process, so it cannot be taken. Wait for that process to release it and retry, or point ` +
|
|
130
|
+
`this server at a different endpoint/browser.`,
|
|
131
|
+
);
|
|
132
|
+
this.name = "SessionSlotBusyError";
|
|
133
|
+
this.endpoint = endpoint;
|
|
134
|
+
if (holder) {
|
|
135
|
+
this.holderPid = holder.pid;
|
|
136
|
+
this.holderLabel = holder.label;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read the slot record, or undefined if the slot is genuinely free.
|
|
143
|
+
*
|
|
144
|
+
* Mirrors ../leases.ts readLease's fail-closed contract: ENOENT is "free" and
|
|
145
|
+
* returns undefined; any OTHER errno (EACCES, EIO, EISDIR, EMFILE...) is
|
|
146
|
+
* rethrown rather than reported as absent, because reporting "free" for a file
|
|
147
|
+
* that merely could not be read would admit a second process to a slot that is
|
|
148
|
+
* very much held. A record that reads but does not PARSE is treated as absent,
|
|
149
|
+
* deliberately: a corrupt record can never become readable, and failing closed
|
|
150
|
+
* on it would brick the endpoint with no in-product recovery, whereas an errno
|
|
151
|
+
* is transient.
|
|
152
|
+
*/
|
|
153
|
+
export async function readSessionSlot(endpoint: string): Promise<SessionSlotRecord | undefined> {
|
|
154
|
+
let raw: string;
|
|
155
|
+
try {
|
|
156
|
+
raw = await readFile(sessionSlotFile(endpoint), "utf8");
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; // genuinely free
|
|
159
|
+
throw err; // unreadable is not absent: fail closed
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const rec = JSON.parse(raw) as SessionSlotRecord;
|
|
163
|
+
if (typeof rec?.pid === "number" && typeof rec?.endpoint === "string" && typeof rec?.label === "string") {
|
|
164
|
+
return rec;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
/* unparseable: fall through to undefined, see the doc comment above */
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Take the endpoint's session slot, coordinating with any other process.
|
|
174
|
+
*
|
|
175
|
+
* Returns { handle } on a clean acquire. Returns { handle, staleHolder } when
|
|
176
|
+
* the previous holder's process was dead and we stole its orphaned slot (the
|
|
177
|
+
* caller must then force-clear Firefox's orphaned session before session.new).
|
|
178
|
+
* Throws SessionSlotBusyError when a LIVE holder does not free the slot inside
|
|
179
|
+
* the wait window.
|
|
180
|
+
*/
|
|
181
|
+
export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions): Promise<AcquireResult> {
|
|
182
|
+
const waitMs = opts.waitMs ?? sessionWaitMs();
|
|
183
|
+
const pollMs = opts.pollMs !== undefined && opts.pollMs > 0 ? opts.pollMs : DEFAULT_POLL_MS;
|
|
184
|
+
// opts.now stamps OUR record deterministically for tests; the wait deadline
|
|
185
|
+
// uses real time (with real sleeps) so a small waitMs actually times out.
|
|
186
|
+
const createdAt = opts.now ?? Date.now();
|
|
187
|
+
const file = sessionSlotFile(endpoint);
|
|
188
|
+
|
|
189
|
+
const record: SessionSlotRecord = {
|
|
190
|
+
endpoint,
|
|
191
|
+
pid: process.pid,
|
|
192
|
+
label: opts.label,
|
|
193
|
+
createdAt,
|
|
194
|
+
};
|
|
195
|
+
if (opts.marionettePort !== undefined) record.marionettePort = opts.marionettePort;
|
|
196
|
+
const handle: SessionSlotHandle = { endpoint, pid: process.pid, createdAt };
|
|
197
|
+
const payload = JSON.stringify(record);
|
|
198
|
+
|
|
199
|
+
await mkdir(leaseDir(), { recursive: true });
|
|
200
|
+
|
|
201
|
+
const deadline = Date.now() + waitMs;
|
|
202
|
+
// Bounds the immediate-retry paths (released-mid-race, steal-raced) so a
|
|
203
|
+
// pathological create/release partner can never spin us forever. The live
|
|
204
|
+
// holder is bounded by `deadline` instead; this cap is generous relative to
|
|
205
|
+
// it so it only bites on pathology, never on ordinary waiting.
|
|
206
|
+
const maxAttempts = Math.max(100, Math.ceil(waitMs / pollMs) + 100);
|
|
207
|
+
|
|
208
|
+
let attempts = 0;
|
|
209
|
+
let lastLiveHolder: SessionSlotRecord | undefined;
|
|
210
|
+
|
|
211
|
+
while (true) {
|
|
212
|
+
if (attempts >= maxAttempts) throw new SessionSlotBusyError(endpoint, lastLiveHolder);
|
|
213
|
+
attempts++;
|
|
214
|
+
|
|
215
|
+
// 1. Try to take the slot outright.
|
|
216
|
+
try {
|
|
217
|
+
await writeFile(file, payload, { flag: "wx" });
|
|
218
|
+
return { handle };
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 2. Occupied. Read the holder to classify it.
|
|
224
|
+
const holder = await readSessionSlot(endpoint);
|
|
225
|
+
if (!holder) continue; // released between our create and our read: retry immediately
|
|
226
|
+
|
|
227
|
+
if (!isPidAlive(holder.pid)) {
|
|
228
|
+
// 3. DEAD holder: orphan. Steal it — unlink, then re-create OUR record.
|
|
229
|
+
await unlink(file).catch(() => undefined);
|
|
230
|
+
try {
|
|
231
|
+
await writeFile(file, payload, { flag: "wx" });
|
|
232
|
+
return { handle, staleHolder: holder };
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
235
|
+
continue; // steal raced another process: retry immediately
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 4. LIVE holder: legitimate contention. Poll until the deadline, then fail.
|
|
240
|
+
lastLiveHolder = holder;
|
|
241
|
+
const remaining = deadline - Date.now();
|
|
242
|
+
if (remaining <= 0) throw new SessionSlotBusyError(endpoint, holder);
|
|
243
|
+
await sleep(Math.min(pollMs, remaining));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Release the slot, but ONLY if the record on disk is still the one we wrote.
|
|
249
|
+
*
|
|
250
|
+
* A record is ours iff its pid is this process AND its createdAt matches the
|
|
251
|
+
* handle. That guard is what stops us from unlinking a record another process
|
|
252
|
+
* created after stealing the slot from us (dead-pid steal), which would hand
|
|
253
|
+
* two processes the slot at once. Idempotent: a missing record is released:false.
|
|
254
|
+
*/
|
|
255
|
+
export async function releaseSessionSlot(handle: SessionSlotHandle): Promise<{ released: boolean }> {
|
|
256
|
+
const current = await readSessionSlot(handle.endpoint);
|
|
257
|
+
if (!current) return { released: false };
|
|
258
|
+
if (current.pid !== process.pid || current.createdAt !== handle.createdAt) return { released: false };
|
|
259
|
+
await unlink(sessionSlotFile(handle.endpoint)).catch(() => undefined); // swallow release races
|
|
260
|
+
return { released: true };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sleep(ms: number): Promise<void> {
|
|
264
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
265
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { toolAvailability } from "./capabilities.ts";
|
|
|
28
28
|
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
29
29
|
import { leaseFromArgs, withLeaseScope } from "./leases.ts";
|
|
30
30
|
import { MANIFEST } from "./manifest.ts";
|
|
31
|
+
import { runInstaller } from "./install/wizard.ts";
|
|
31
32
|
|
|
32
33
|
const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
|
|
33
34
|
|
|
@@ -210,6 +211,15 @@ function printToolHelp(name: ToolName): void {
|
|
|
210
211
|
|
|
211
212
|
async function main(): Promise<number> {
|
|
212
213
|
const rawArgv = process.argv.slice(2);
|
|
214
|
+
|
|
215
|
+
// `install` is a CLI-ONLY subcommand (the `cdp install` wizard), not an MCP tool: it is not in
|
|
216
|
+
// TOOLS/MANIFEST and never appears in --list or tools/list. Dispatch it FIRST — before --browser
|
|
217
|
+
// resolution, --help, and tool dispatch — so `cdp install --help` reaches the installer's own
|
|
218
|
+
// help rather than the global USAGE, and so nothing below can reinterpret its flags.
|
|
219
|
+
if (rawArgv[0] === "install") {
|
|
220
|
+
return await runInstaller(rawArgv.slice(1));
|
|
221
|
+
}
|
|
222
|
+
|
|
213
223
|
const { kind: browser, endpoint } = resolveBackend(rawArgv);
|
|
214
224
|
const parsed = parseArgv(stripConnectFlag(stripBrowserFlag(rawArgv)));
|
|
215
225
|
|