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/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
+ }