cdp-toolkit 1.1.0 → 1.4.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 +203 -9
- package/CONTRACT.md +7 -5
- package/README.md +113 -33
- package/dist/cli.js +764 -64
- package/dist/index.js +650 -42
- package/dist/mcp.js +1091 -77
- package/package.json +4 -3
- package/src/bidi/driver.ts +167 -4
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +145 -1
- package/src/cli.ts +47 -22
- package/src/client.ts +25 -7
- package/src/driver.ts +107 -1
- package/src/firefox-tools.ts +6 -3
- package/src/index.ts +14 -2
- package/src/leases-tools.ts +107 -0
- package/src/leases.ts +541 -0
- package/src/manifest.ts +339 -15
- package/src/mcp.ts +27 -19
- package/src/origins.ts +266 -0
- package/src/shared-tools.ts +365 -14
- package/src/tools/console.ts +3 -3
- package/src/tools/evaluate.ts +2 -2
- package/src/tools/heap.ts +3 -3
- package/src/tools/lighthouse.ts +4 -4
- package/src/tools/navigation.ts +1 -1
- package/src/tools/network.ts +7 -7
- package/src/tools/network_mock.ts +8 -8
- package/src/tools/performance.ts +11 -11
- package/src/tools/recorder.ts +5 -5
- package/src/tools/snapshot.ts +2 -2
package/src/leases.ts
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab leases: one file per leased tab, exclusive-create claims, opaque tokens.
|
|
3
|
+
*
|
|
4
|
+
* WHY A FILE AND NOT MODULE STATE. Two Claude Code sessions each run their own
|
|
5
|
+
* MCP server process against the same Chrome on port 9222 (see backend.ts's
|
|
6
|
+
* header), so in-process state would protect two agents inside one process and
|
|
7
|
+
* miss the case that actually collides. Intermittent protection is worse than
|
|
8
|
+
* none, because a caller learns to distrust a refusal it only sometimes gets.
|
|
9
|
+
*
|
|
10
|
+
* WHY ONE FILE PER TAB AND NOT ONE REGISTRY. A shared registry file would need
|
|
11
|
+
* its own lock to make "check if free, then claim" atomic, which relocates the
|
|
12
|
+
* race one level up. Filesystem exclusive-create (the "wx" flag below) gives
|
|
13
|
+
* that atomicity per tab for free. This is the first use of "wx" in the repo.
|
|
14
|
+
*
|
|
15
|
+
* WHY THE TOKEN EMBEDS A NONCE. Reclaiming a stale lease mints a fresh nonce,
|
|
16
|
+
* so the superseded token stops matching by construction rather than by a check
|
|
17
|
+
* someone could forget to write. See assertLeaseOk.
|
|
18
|
+
*
|
|
19
|
+
* THE ONE WRITE THAT COULD BREAK THAT GUARANTEE is touchLease, because it is
|
|
20
|
+
* the only write here that is not an exclusive create. Everything else either
|
|
21
|
+
* creates with "wx" or unlinks, so a record can never be modified in place and
|
|
22
|
+
* a nonce can never survive a reclamation. touchLease has to rewrite an
|
|
23
|
+
* existing file, so it re-reads and re-checks the nonce immediately before
|
|
24
|
+
* writing and writes only what it just read. Anyone adding a second
|
|
25
|
+
* non-exclusive write to this module owes the same guard, or the whole feature
|
|
26
|
+
* loses the property it rests on.
|
|
27
|
+
*/
|
|
28
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
29
|
+
import { randomBytes } from "node:crypto";
|
|
30
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** Opaque ownership token. Hold it, pass it back, never parse or build one. */
|
|
34
|
+
export type LeaseToken = string;
|
|
35
|
+
|
|
36
|
+
/** Ownership is keyed by backend plus id: a CDP targetId and a BiDi context id
|
|
37
|
+
* are not guaranteed disjoint, so a bare id could collide across browsers. */
|
|
38
|
+
export type LeaseBackend = "chrome" | "firefox";
|
|
39
|
+
|
|
40
|
+
export interface LeaseRecord {
|
|
41
|
+
backend: LeaseBackend;
|
|
42
|
+
targetId: string;
|
|
43
|
+
/** The value embedded in the token, compared on every call. */
|
|
44
|
+
nonce: string;
|
|
45
|
+
/** Owning MCP server process. */
|
|
46
|
+
pid: number;
|
|
47
|
+
/** Caller-supplied agent label, surfaced in conflicts and list_leases. */
|
|
48
|
+
label: string;
|
|
49
|
+
createdAt: number;
|
|
50
|
+
lastUsedAt: number;
|
|
51
|
+
ttlMs: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 15 minutes. Refresh-on-use means an active agent never expires, so this only
|
|
55
|
+
* bites on abandonment and can afford to be generous. */
|
|
56
|
+
export const DEFAULT_LEASE_TTL_MS = 900_000;
|
|
57
|
+
|
|
58
|
+
/** Read per call, not at module load, so a test can redirect the directory. */
|
|
59
|
+
export function leaseDir(): string {
|
|
60
|
+
return process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function leaseTtlMs(): number {
|
|
64
|
+
const raw = Number(process.env.CDP_LEASE_TTL_MS);
|
|
65
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The label recorded when a caller supplies none. ONE definition, because it
|
|
69
|
+
* is now written by three call sites (claim_page, new_page, and the origin
|
|
70
|
+
* ledger) and a second rule would make the same tab describe itself two
|
|
71
|
+
* different ways depending on which of them wrote first. */
|
|
72
|
+
export function defaultLabel(): string {
|
|
73
|
+
return `pid-${process.pid}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function safeId(id: string): string {
|
|
77
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Sibling of recorder.ts's rec-<targetId>.jsonl, in the same ARTIFACT_DIR. */
|
|
81
|
+
export function leaseFile(backend: LeaseBackend, targetId: string): string {
|
|
82
|
+
return join(leaseDir(), `lease-${backend}-${safeId(targetId)}.json`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function mintToken(backend: LeaseBackend, targetId: string, nonce: string): LeaseToken {
|
|
86
|
+
return `${backend}:${targetId}:${nonce}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Split on the FIRST and LAST colon so an id containing a colon still parses. */
|
|
90
|
+
export function tokenParts(token: LeaseToken): { backend: LeaseBackend; targetId: string; nonce: string } | undefined {
|
|
91
|
+
if (typeof token !== "string") return undefined;
|
|
92
|
+
const first = token.indexOf(":");
|
|
93
|
+
const last = token.lastIndexOf(":");
|
|
94
|
+
if (first <= 0 || last <= first) return undefined;
|
|
95
|
+
const backend = token.slice(0, first);
|
|
96
|
+
if (backend !== "chrome" && backend !== "firefox") return undefined;
|
|
97
|
+
const targetId = token.slice(first + 1, last);
|
|
98
|
+
const nonce = token.slice(last + 1);
|
|
99
|
+
if (!targetId.length || !/^[0-9a-f]{24}$/.test(nonce)) return undefined;
|
|
100
|
+
return { backend, targetId, nonce };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class LeaseConflictError extends Error {
|
|
104
|
+
constructor(
|
|
105
|
+
message: string,
|
|
106
|
+
readonly targetId: string,
|
|
107
|
+
readonly holder?: string,
|
|
108
|
+
) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = "LeaseConflictError";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read a lease record, or undefined if the tab is genuinely unleased.
|
|
116
|
+
*
|
|
117
|
+
* "UNREADABLE" IS NOT "ABSENT", AND CONFLATING THEM IS WRONG IN BOTH DIRECTIONS
|
|
118
|
+
* AT ONCE. Enforcement reads `undefined` as "free to take". If an EACCES, EIO or
|
|
119
|
+
* EMFILE on a live, healthy lease file also produced `undefined`, a stranger
|
|
120
|
+
* would be ADMITTED to a tab someone owns, while the true owner would be
|
|
121
|
+
* REFUSED with "not leased any more" (its token names a tab that now reads as
|
|
122
|
+
* unleased). Fd exhaustion on a long-lived MCP server is the most plausible
|
|
123
|
+
* trigger. So every errno other than ENOENT is rethrown and the call fails.
|
|
124
|
+
*
|
|
125
|
+
* FAILING CLOSED HERE CANNOT REGRESS THE UPGRADE PATH, and that is what makes
|
|
126
|
+
* it safe: a user who never calls claim_page and never passes claim:true has NO
|
|
127
|
+
* lease files at all, so every read here is a plain ENOENT and this throw is
|
|
128
|
+
* unreachable for them. Only a tab that already has a lease file can reach it.
|
|
129
|
+
*
|
|
130
|
+
* A record that reads fine but does not PARSE is treated as absent instead,
|
|
131
|
+
* deliberately: a corrupt record can never become readable, so throwing would
|
|
132
|
+
* brick that tab permanently with no in-product recovery, whereas an errno is
|
|
133
|
+
* transient. A parse failure need not mean tampering; touchLease rewrites the
|
|
134
|
+
* file by truncating it first, so a crash or ENOSPC mid-heartbeat can leave a
|
|
135
|
+
* genuinely partial file with no tampering involved. listLeases reports such
|
|
136
|
+
* a file so an operator can see it and delete it.
|
|
137
|
+
*/
|
|
138
|
+
export async function readLease(backend: LeaseBackend, targetId: string): Promise<LeaseRecord | undefined> {
|
|
139
|
+
let raw: string;
|
|
140
|
+
try {
|
|
141
|
+
raw = await readFile(leaseFile(backend, targetId), "utf8");
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; // genuinely unleased
|
|
144
|
+
throw err; // unreadable is not absent: refuse rather than hand out a held tab
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const rec = JSON.parse(raw) as LeaseRecord;
|
|
148
|
+
if (typeof rec?.nonce === "string" && typeof rec?.pid === "number") return rec;
|
|
149
|
+
} catch {
|
|
150
|
+
/* unparseable: fall through to undefined, see the doc comment above */
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ClaimOptions {
|
|
156
|
+
label: string;
|
|
157
|
+
ttlMs?: number;
|
|
158
|
+
now?: number;
|
|
159
|
+
/** Live target ids from the browser, enabling the "target-gone" reclamation. */
|
|
160
|
+
liveIds?: readonly string[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Why an existing lease may be taken over. All three are sufficient alone. */
|
|
164
|
+
export type LeaseStaleReason = "dead-pid" | "expired" | "target-gone";
|
|
165
|
+
|
|
166
|
+
/** Signal-0 liveness probe. EPERM means the pid exists but belongs to another
|
|
167
|
+
* user, which still counts as alive. */
|
|
168
|
+
export function isPidAlive(pid: number): boolean {
|
|
169
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
170
|
+
try {
|
|
171
|
+
process.kill(pid, 0);
|
|
172
|
+
return true;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Classify a lease. `liveIds` is passed in by the caller rather than looked up
|
|
180
|
+
* here on purpose: it is the only browser-derived input this module needs, and
|
|
181
|
+
* taking it as data keeps leases.ts free of any CDP or BiDi import, which is
|
|
182
|
+
* what makes the negative tests cheap to run.
|
|
183
|
+
*/
|
|
184
|
+
export function staleReason(
|
|
185
|
+
rec: LeaseRecord,
|
|
186
|
+
opts: { now: number; liveIds?: readonly string[] },
|
|
187
|
+
): LeaseStaleReason | false {
|
|
188
|
+
if (!isPidAlive(rec.pid)) return "dead-pid";
|
|
189
|
+
if (opts.now - rec.lastUsedAt > rec.ttlMs) return "expired";
|
|
190
|
+
if (opts.liveIds && !opts.liveIds.includes(rec.targetId)) return "target-gone";
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Take exclusive ownership. Throws LeaseConflictError if the tab is held and
|
|
195
|
+
* not stale. A stale lease is reclaimed: the old file is unlinked and a fresh
|
|
196
|
+
* record with a fresh nonce is created, never overwritten in place, so the
|
|
197
|
+
* superseded token stops matching by construction. */
|
|
198
|
+
export async function claimLease(
|
|
199
|
+
backend: LeaseBackend,
|
|
200
|
+
targetId: string,
|
|
201
|
+
opts: ClaimOptions,
|
|
202
|
+
): Promise<{ token: LeaseToken; record: LeaseRecord }> {
|
|
203
|
+
const now = opts.now ?? Date.now();
|
|
204
|
+
await mkdir(leaseDir(), { recursive: true });
|
|
205
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
206
|
+
const record: LeaseRecord = {
|
|
207
|
+
backend,
|
|
208
|
+
targetId,
|
|
209
|
+
// A fresh nonce on EVERY attempt, so a reclamation cannot reuse the
|
|
210
|
+
// nonce it displaced. This is what invalidates the old owner's token.
|
|
211
|
+
nonce: randomBytes(12).toString("hex"),
|
|
212
|
+
pid: process.pid,
|
|
213
|
+
label: opts.label,
|
|
214
|
+
createdAt: now,
|
|
215
|
+
lastUsedAt: now,
|
|
216
|
+
ttlMs: opts.ttlMs ?? leaseTtlMs(),
|
|
217
|
+
};
|
|
218
|
+
try {
|
|
219
|
+
await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
|
|
220
|
+
return { token: mintToken(backend, targetId, record.nonce), record };
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
223
|
+
}
|
|
224
|
+
const existing = await readLease(backend, targetId);
|
|
225
|
+
if (!existing) continue; // released between our create and our read: retry
|
|
226
|
+
const stale = staleReason(existing, { now, liveIds: opts.liveIds });
|
|
227
|
+
if (!stale) {
|
|
228
|
+
throw new LeaseConflictError(
|
|
229
|
+
`target '${targetId}' is already leased by '${existing.label}' (pid ${existing.pid}); call release_page with that lease's token, or list_leases to inspect it`,
|
|
230
|
+
targetId,
|
|
231
|
+
existing.label,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
await unlink(leaseFile(backend, targetId)).catch(() => undefined);
|
|
235
|
+
}
|
|
236
|
+
throw new LeaseConflictError(
|
|
237
|
+
`target '${targetId}' could not be claimed after 3 attempts: another process is claiming and releasing it concurrently`,
|
|
238
|
+
targetId,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Give up a lease. Idempotent: releasing an already-gone lease is not an error. */
|
|
243
|
+
export async function releaseLease(token: LeaseToken): Promise<{ released: boolean }> {
|
|
244
|
+
const parts = tokenParts(token);
|
|
245
|
+
if (!parts) return { released: false };
|
|
246
|
+
const rec = await readLease(parts.backend, parts.targetId);
|
|
247
|
+
if (!rec || rec.nonce !== parts.nonce) return { released: false };
|
|
248
|
+
await unlink(leaseFile(parts.backend, parts.targetId)).catch(() => undefined);
|
|
249
|
+
return { released: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Drop whatever lease a tab has, token or not. Only for close_page, which is
|
|
253
|
+
* already authorized against the lease by the time it calls this. */
|
|
254
|
+
export async function releaseLeaseFor(backend: LeaseBackend, targetId: string): Promise<{ released: boolean }> {
|
|
255
|
+
if (!(await readLease(backend, targetId))) return { released: false };
|
|
256
|
+
await unlink(leaseFile(backend, targetId)).catch(() => undefined);
|
|
257
|
+
return { released: true };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* -------------------------------- the gate -------------------------------- */
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The lease token for the tool call currently being dispatched.
|
|
264
|
+
*
|
|
265
|
+
* This exists so enforcement can stay at ONE point per resolution path while
|
|
266
|
+
* the token still reaches it. The token arrives as a key on a tool's args
|
|
267
|
+
* object, many frames above resolveTarget, and threading a parameter through
|
|
268
|
+
* all 36 tools would put the burden on every future contributor to remember
|
|
269
|
+
* the check exists: one missed tool would silently defeat the feature for that
|
|
270
|
+
* tool. mcp.ts and cli.ts open this scope once per dispatch instead, so a tool
|
|
271
|
+
* added tomorrow is covered with no action from whoever writes it.
|
|
272
|
+
*/
|
|
273
|
+
const leaseScope = new AsyncLocalStorage<LeaseToken | undefined>();
|
|
274
|
+
|
|
275
|
+
export function withLeaseScope<T>(lease: LeaseToken | undefined, fn: () => Promise<T>): Promise<T> {
|
|
276
|
+
return leaseScope.run(lease, fn);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function currentLease(): LeaseToken | undefined {
|
|
280
|
+
return leaseScope.getStore();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Pull the token off a tool's loosely-typed args object at the dispatch site.
|
|
284
|
+
* Kept here rather than duplicated in mcp.ts and cli.ts so the two entry points
|
|
285
|
+
* can never disagree about what counts as a token. An empty string is treated
|
|
286
|
+
* as absent: a caller that sends lease:"" means "no lease", and letting it
|
|
287
|
+
* through would trip the malformed-token branch with a confusing message. */
|
|
288
|
+
export function leaseFromArgs(args: unknown): LeaseToken | undefined {
|
|
289
|
+
if (!args || typeof args !== "object") return undefined;
|
|
290
|
+
const lease = (args as { lease?: unknown }).lease;
|
|
291
|
+
return typeof lease === "string" && lease.length > 0 ? lease : undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Refresh lastUsedAt. Every checked call already touches this file, so keeping
|
|
296
|
+
* an active lease alive needs no separate heartbeat.
|
|
297
|
+
*
|
|
298
|
+
* THIS IS THE ONE WRITE IN THIS MODULE THAT IS NOT AN EXCLUSIVE CREATE, so it
|
|
299
|
+
* is the one write that could violate the nonce guarantee. `rec` was read some
|
|
300
|
+
* time ago by the caller; between that read and this write another process can
|
|
301
|
+
* reclaim the tab, which unlinks the file and creates a new one with a fresh
|
|
302
|
+
* nonce. A blind `writeFile` of `rec` would then clobber the new owner's record
|
|
303
|
+
* with the old nonce: the new owner's token would start failing and the
|
|
304
|
+
* superseded one would start passing. That is the two-owner failure inverted.
|
|
305
|
+
*
|
|
306
|
+
* So this re-reads and compares the nonce immediately before writing, and
|
|
307
|
+
* writes the record it just read rather than the caller's older copy. A lease
|
|
308
|
+
* that was reclaimed or released underneath us is left alone.
|
|
309
|
+
*
|
|
310
|
+
* Errors are swallowed DELIBERATELY: this is a heartbeat, and a tool call that
|
|
311
|
+
* did real work must not fail because a lastUsedAt refresh could not be
|
|
312
|
+
* persisted. The cost of that choice is real and worth stating plainly: an
|
|
313
|
+
* EACCES or ENOSPC here means the holder's lease quietly stops being refreshed
|
|
314
|
+
* and expires under it after ttlMs, with no signal anywhere. If lease
|
|
315
|
+
* expiry-under-load ever gets reported, this silence is the first place to look.
|
|
316
|
+
*/
|
|
317
|
+
export async function touchLease(rec: LeaseRecord, now: number = Date.now()): Promise<void> {
|
|
318
|
+
// readLease now THROWS on an unreadable file rather than reporting absence.
|
|
319
|
+
// That throw is load-bearing at the gate and must not leak out of a
|
|
320
|
+
// heartbeat, so it is caught here and treated as "leave the record alone",
|
|
321
|
+
// which is the same thing this function does for any record it does not own.
|
|
322
|
+
const current = await readLease(rec.backend, rec.targetId).catch(() => undefined);
|
|
323
|
+
if (!current || current.nonce !== rec.nonce) return; // reclaimed or released: not ours to write
|
|
324
|
+
await writeFile(leaseFile(current.backend, current.targetId), JSON.stringify({ ...current, lastUsedAt: now }), "utf8").catch(
|
|
325
|
+
() => undefined,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface LeaseCheckContext {
|
|
330
|
+
/** Explicit token. Falls back to the ambient dispatch scope when absent. */
|
|
331
|
+
lease?: LeaseToken;
|
|
332
|
+
url?: string;
|
|
333
|
+
title?: string;
|
|
334
|
+
/** Live ids from the resolution the caller just did, for "target-gone". */
|
|
335
|
+
liveIds?: readonly string[];
|
|
336
|
+
now?: number;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function describeTarget(targetId: string, ctx: LeaseCheckContext): string {
|
|
340
|
+
const url = ctx.url ? ` (url ${ctx.url})` : ctx.title ? ` (title ${JSON.stringify(ctx.title)})` : "";
|
|
341
|
+
return `target '${targetId}'${url}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The single enforcement point, called from every target-resolution path.
|
|
346
|
+
*
|
|
347
|
+
* NOT an ADR-001 capability gap. ADR-001 says a capability a backend can never
|
|
348
|
+
* do is ABSENT from tools/list rather than present and throwing, because that
|
|
349
|
+
* gap is static and known before the first call. A lease conflict is the
|
|
350
|
+
* opposite: the same tool against the same tab succeeds or fails depending on
|
|
351
|
+
* who holds it at that instant. There is nothing to remove from tools/list,
|
|
352
|
+
* because the tool is not unsupported, it is supported and currently spoken
|
|
353
|
+
* for. So this throws, loudly, naming the holder. Do not "fix" the
|
|
354
|
+
* inconsistency with ADR-001 by hiding leased tools from tools/list: the two
|
|
355
|
+
* situations differ in kind, not degree.
|
|
356
|
+
*/
|
|
357
|
+
export async function assertLeaseOk(
|
|
358
|
+
backend: LeaseBackend,
|
|
359
|
+
targetId: string,
|
|
360
|
+
ctx: LeaseCheckContext = {},
|
|
361
|
+
): Promise<void> {
|
|
362
|
+
const token = ctx.lease ?? currentLease();
|
|
363
|
+
const now = ctx.now ?? Date.now();
|
|
364
|
+
// NOT wrapped in a catch. If the lease file exists but cannot be read,
|
|
365
|
+
// readLease throws and that error propagates out of the gate, failing the
|
|
366
|
+
// call. Swallowing it here would put us back where "unreadable" means
|
|
367
|
+
// "unleased", which admits a stranger to a held tab. See readLease.
|
|
368
|
+
const rec = await readLease(backend, targetId);
|
|
369
|
+
const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
|
|
370
|
+
const where = describeTarget(targetId, ctx);
|
|
371
|
+
|
|
372
|
+
if (token === undefined) {
|
|
373
|
+
if (!held) return; // unleased, or leased-but-reclaimable: today's behavior
|
|
374
|
+
throw new LeaseConflictError(
|
|
375
|
+
`${where} is leased by '${held.label}' (pid ${held.pid}). Pass that lease's token as the 'lease' argument, or call release_page to free it. list_leases shows every active lease.`,
|
|
376
|
+
targetId,
|
|
377
|
+
held.label,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const parts = tokenParts(token);
|
|
382
|
+
if (!parts) {
|
|
383
|
+
throw new LeaseConflictError(
|
|
384
|
+
`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`,
|
|
385
|
+
targetId,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
// The "is this token even about this tab" question comes FIRST, before the
|
|
389
|
+
// "is this tab leased" question. Order matters: holding a token for TAB-A
|
|
390
|
+
// must not change what happens when you touch an unrelated TAB-C. If the
|
|
391
|
+
// mismatch check ran second, a token for another tab would fall into the
|
|
392
|
+
// !held branch below and refuse every unleased tab with a message about a
|
|
393
|
+
// lease that was "released or reclaimed", which is both wrong and confusing.
|
|
394
|
+
// Under the ambient dispatch scope that is the COMMON case, not an edge one:
|
|
395
|
+
// any agent holding one tab that touches a second unleased tab in the same
|
|
396
|
+
// call arrives here, select_page via activatePage included.
|
|
397
|
+
if (parts.backend !== backend || parts.targetId !== targetId) {
|
|
398
|
+
// The token is about a different tab, so it says nothing about this one.
|
|
399
|
+
// An unleased tab is open to everyone, token in hand or not.
|
|
400
|
+
if (!held) return;
|
|
401
|
+
throw new LeaseConflictError(
|
|
402
|
+
`the lease token you passed is for ${parts.backend} target '${parts.targetId}', not ${where}, which is leased by '${held.label}'. Resolve the tab you actually hold, or claim this one.`,
|
|
403
|
+
targetId,
|
|
404
|
+
held.label,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
// From here the token names THIS tab, so the caller believes it holds this
|
|
408
|
+
// tab. Being wrong about that is an error, never a pass-through.
|
|
409
|
+
if (!held) {
|
|
410
|
+
throw new LeaseConflictError(
|
|
411
|
+
`${where} is not leased any more: the token you passed belongs to a lease that was released or reclaimed. Call claim_page again to take the tab.`,
|
|
412
|
+
targetId,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
// The nonce match is what makes reclamation safe. Without it a stalled agent
|
|
416
|
+
// that comes back after its lease was reclaimed would sail through with a
|
|
417
|
+
// token whose backend and targetId still line up, and two owners would be
|
|
418
|
+
// driving one tab. claimLease mints a fresh nonce on every create, so the
|
|
419
|
+
// superseded token can never match here.
|
|
420
|
+
if (parts.nonce !== held.nonce) {
|
|
421
|
+
throw new LeaseConflictError(
|
|
422
|
+
`${where} was reclaimed and is now leased by '${held.label}' (pid ${held.pid}). Your token was invalidated by that reclamation. Call claim_page again if you still need this tab.`,
|
|
423
|
+
targetId,
|
|
424
|
+
held.label,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
await touchLease(held, now);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* ------------------------------- enumeration ------------------------------- */
|
|
431
|
+
|
|
432
|
+
export interface LeaseSummary {
|
|
433
|
+
backend: LeaseBackend;
|
|
434
|
+
targetId: string;
|
|
435
|
+
label: string;
|
|
436
|
+
pid: number;
|
|
437
|
+
createdAt: number;
|
|
438
|
+
lastUsedAt: number;
|
|
439
|
+
ttlMs: number;
|
|
440
|
+
pidAlive: boolean;
|
|
441
|
+
stale: LeaseStaleReason | false;
|
|
442
|
+
/**
|
|
443
|
+
* Present ONLY on a row this function could not read: the errno of the failed
|
|
444
|
+
* read, or "unparseable" for a file whose contents are not a lease record.
|
|
445
|
+
* The row still appears, because the whole point of this tool is to show an
|
|
446
|
+
* operator what is on disk, and a silently skipped file is the one thing that
|
|
447
|
+
* makes an unreadable lease impossible to diagnose. Every other field on such
|
|
448
|
+
* a row is a placeholder derived from the FILENAME and nothing else: backend
|
|
449
|
+
* and targetId are real (the filename carries both), the rest are zeroed. It
|
|
450
|
+
* reports stale:false so the row never reads as "free to take".
|
|
451
|
+
*/
|
|
452
|
+
unreadable?: string;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const LEASE_FILE_RE = /^lease-(chrome|firefox)-(.+)\.json$/;
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Enumerate every lease on disk. Diagnosis only, so it needs no token.
|
|
459
|
+
*
|
|
460
|
+
* `liveIds` comes from ONE browser, but this enumeration spans both backends,
|
|
461
|
+
* so `liveBackend` names which backend those ids belong to and the target-gone
|
|
462
|
+
* test is applied only to records of that backend. Without that scoping, a
|
|
463
|
+
* caller listing from Chrome would see every Firefox lease reported as
|
|
464
|
+
* target-gone (its context ids are simply not in Chrome's target list), which
|
|
465
|
+
* reads as "free to take" for a lease that is very much held.
|
|
466
|
+
*
|
|
467
|
+
* Residual: for records of the OTHER backend, the target-gone test is never
|
|
468
|
+
* evaluated, so a cross-backend lease whose tab really did close reports
|
|
469
|
+
* stale:false until its owning pid dies or its ttlMs elapses. That errs
|
|
470
|
+
* toward "held", which is the safe direction, and a Firefox lease fails the
|
|
471
|
+
* dead-pid test as soon as its per-invocation process exits.
|
|
472
|
+
*/
|
|
473
|
+
export async function listLeases(
|
|
474
|
+
opts: { now?: number; liveIds?: readonly string[]; liveBackend?: LeaseBackend } = {},
|
|
475
|
+
): Promise<LeaseSummary[]> {
|
|
476
|
+
const now = opts.now ?? Date.now();
|
|
477
|
+
let names: string[] = [];
|
|
478
|
+
try {
|
|
479
|
+
names = await readdir(leaseDir());
|
|
480
|
+
} catch (err) {
|
|
481
|
+
// An absent directory means nobody has ever claimed anything, which is the
|
|
482
|
+
// normal state for a user who never opts in and is genuinely "no leases".
|
|
483
|
+
// Any OTHER errno means leases may exist and we cannot see them, and
|
|
484
|
+
// answering "none" to that question is exactly the hidden failure this
|
|
485
|
+
// function is supposed to expose. Fail loudly instead.
|
|
486
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
487
|
+
throw err;
|
|
488
|
+
}
|
|
489
|
+
const out: LeaseSummary[] = [];
|
|
490
|
+
for (const name of names) {
|
|
491
|
+
const parts = LEASE_FILE_RE.exec(name);
|
|
492
|
+
if (!parts) continue;
|
|
493
|
+
// Everything the filename alone can tell us, used only to describe a row
|
|
494
|
+
// whose contents we could not read. safeId() may have substituted
|
|
495
|
+
// characters in the id, so this is an identifying label, not a key.
|
|
496
|
+
const fromName = {
|
|
497
|
+
backend: parts[1] as LeaseBackend,
|
|
498
|
+
targetId: parts[2] as string,
|
|
499
|
+
label: "",
|
|
500
|
+
pid: 0,
|
|
501
|
+
createdAt: 0,
|
|
502
|
+
lastUsedAt: 0,
|
|
503
|
+
ttlMs: 0,
|
|
504
|
+
pidAlive: false,
|
|
505
|
+
// Never "reclaimable": we do not know, and this errs toward held.
|
|
506
|
+
stale: false as const,
|
|
507
|
+
};
|
|
508
|
+
let rec: LeaseRecord;
|
|
509
|
+
try {
|
|
510
|
+
rec = JSON.parse(await readFile(join(leaseDir(), name), "utf8")) as LeaseRecord;
|
|
511
|
+
} catch (err) {
|
|
512
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
513
|
+
// A file that vanished between readdir and readFile is not a problem; it
|
|
514
|
+
// was released. Anything else is reported rather than skipped.
|
|
515
|
+
if (code === "ENOENT") continue;
|
|
516
|
+
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (typeof rec?.nonce !== "string" || typeof rec?.pid !== "number") {
|
|
520
|
+
out.push({ ...fromName, unreadable: "unparseable" });
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
out.push({
|
|
524
|
+
backend: rec.backend,
|
|
525
|
+
targetId: rec.targetId,
|
|
526
|
+
label: rec.label,
|
|
527
|
+
pid: rec.pid,
|
|
528
|
+
createdAt: rec.createdAt,
|
|
529
|
+
lastUsedAt: rec.lastUsedAt,
|
|
530
|
+
ttlMs: rec.ttlMs,
|
|
531
|
+
pidAlive: isPidAlive(rec.pid),
|
|
532
|
+
// The nonce never leaves this function: it is not in LeaseSummary, so a
|
|
533
|
+
// diagnosis read can never be turned into a forged token.
|
|
534
|
+
stale: staleReason(rec, {
|
|
535
|
+
now,
|
|
536
|
+
liveIds: opts.liveBackend === undefined || opts.liveBackend === rec.backend ? opts.liveIds : undefined,
|
|
537
|
+
}),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return out;
|
|
541
|
+
}
|