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