cdp-toolkit 1.6.0 → 1.8.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 CHANGED
@@ -49,6 +49,15 @@ export interface LeaseRecord {
49
49
  createdAt: number;
50
50
  lastUsedAt: number;
51
51
  ttlMs: number;
52
+ /**
53
+ * How this lease was taken, and therefore who may use it without a token.
54
+ * true - the gate acquired it implicitly; passes for any call from `pid`.
55
+ * false - claim_page or new_page{claim:true}; the token is required, even
56
+ * from the owning process.
57
+ * Absent on records written by <=1.4.0 and read as false, so an upgrade never
58
+ * downgrades a held lease from token-required to pid-only.
59
+ */
60
+ auto?: boolean;
52
61
  }
53
62
 
54
63
  /** 15 minutes. Refresh-on-use means an active agent never expires, so this only
@@ -65,6 +74,43 @@ export function leaseTtlMs(): number {
65
74
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
66
75
  }
67
76
 
77
+ /**
78
+ * Whether THIS process is the long-lived MCP server rather than a one-shot CLI
79
+ * invocation. Module state, which this file's header otherwise forbids: that
80
+ * prohibition is about state describing OTHER processes' ownership, which must
81
+ * live on disk because two MCP servers drive one browser. This describes this
82
+ * process's own role, which no other process can observe or contend for, so a
83
+ * module flag is the correct scope rather than a violation of the rule.
84
+ */
85
+ let longLived = false;
86
+
87
+ /** Called once by mcp.ts at startup. cli.ts never calls it. The parameter
88
+ * exists so a test can put the module back into CLI state. */
89
+ export function markLongLivedProcess(value = true): void {
90
+ longLived = value;
91
+ }
92
+
93
+ /**
94
+ * Strict mode: every tool call acquires a lease for the tab it resolves, and
95
+ * stale agent tabs are reaped on read. Off by default, so 1.4.0 consumers are
96
+ * unaffected.
97
+ *
98
+ * ALWAYS FALSE UNDER THE CLI, regardless of the env var, and that is not a
99
+ * convenience. cli.ts already refuses claim_page because a CLI invocation is
100
+ * one process per call, so its lease is reclaimable by the dead-pid rule the
101
+ * moment the process exits, and "a lease that is reclaimable on arrival is
102
+ * worse than no lease". Auto-acquire would mint exactly such a lease on every
103
+ * call. The sharper danger is reap: those dead-pid records would read as
104
+ * orphaned agent tabs, so a CLI user running list_pages twice could watch the
105
+ * second run close the tabs the first run leased. Read per call, like
106
+ * leaseDir(), so a test can flip it.
107
+ */
108
+ export function requireLease(): boolean {
109
+ if (!longLived) return false;
110
+ const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
111
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
112
+ }
113
+
68
114
  /** The label recorded when a caller supplies none. ONE definition, because it
69
115
  * is now written by three call sites (claim_page, new_page, and the origin
70
116
  * ledger) and a second rule would make the same tab describe itself two
@@ -158,6 +204,9 @@ export interface ClaimOptions {
158
204
  now?: number;
159
205
  /** Live target ids from the browser, enabling the "target-gone" reclamation. */
160
206
  liveIds?: readonly string[];
207
+ /** Marks the lease gate-acquired. See LeaseRecord.auto. Defaults to false:
208
+ * an unmarked claim is an explicit one, which is the stricter reading. */
209
+ auto?: boolean;
161
210
  }
162
211
 
163
212
  /** Why an existing lease may be taken over. All three are sufficient alone. */
@@ -214,6 +263,7 @@ export async function claimLease(
214
263
  createdAt: now,
215
264
  lastUsedAt: now,
216
265
  ttlMs: opts.ttlMs ?? leaseTtlMs(),
266
+ auto: opts.auto === true,
217
267
  };
218
268
  try {
219
269
  await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
@@ -265,7 +315,7 @@ export async function releaseLeaseFor(backend: LeaseBackend, targetId: string):
265
315
  * This exists so enforcement can stay at ONE point per resolution path while
266
316
  * the token still reaches it. The token arrives as a key on a tool's args
267
317
  * object, many frames above resolveTarget, and threading a parameter through
268
- * all 41 tools would put the burden on every future contributor to remember
318
+ * all 45 tools would put the burden on every future contributor to remember
269
319
  * the check exists: one missed tool would silently defeat the feature for that
270
320
  * tool. mcp.ts and cli.ts open this scope once per dispatch instead, so a tool
271
321
  * added tomorrow is covered with no action from whoever writes it.
@@ -370,7 +420,29 @@ export async function assertLeaseOk(
370
420
  const where = describeTarget(targetId, ctx);
371
421
 
372
422
  if (token === undefined) {
373
- if (!held) return; // unleased, or leased-but-reclaimable: today's behavior
423
+ if (!held) {
424
+ // STRICT MODE: acquire rather than wave through. This is the change that
425
+ // makes "you cannot drive a tab you do not hold" true, and it is done
426
+ // here rather than by refusing because refusing would force every caller
427
+ // to learn a claim/release protocol to do what used to just work.
428
+ // Marked auto:true, which is what lets a later call from this same
429
+ // process pass with no token. A LeaseConflictError from claimLease (two
430
+ // processes racing to acquire the same tab, resolved by the "wx"
431
+ // exclusive create) propagates: the loser is genuinely refused.
432
+ if (requireLease()) {
433
+ await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
434
+ }
435
+ return; // unleased, or leased-but-reclaimable: today's behavior
436
+ }
437
+ // An auto lease is owned by a PROCESS, not by a token, so any call from the
438
+ // owning pid passes. NOT gated on requireLease(): an auto:true record can
439
+ // only exist because strict mode created it, and gating this too would mean
440
+ // turning the flag off locks this process out of leases it still holds. It
441
+ // cannot regress 1.4.0, where no auto:true record can exist at all.
442
+ if (held.auto === true && held.pid === process.pid) {
443
+ await touchLease(held, now);
444
+ return;
445
+ }
374
446
  throw new LeaseConflictError(
375
447
  `${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
448
  targetId,
@@ -396,8 +468,16 @@ export async function assertLeaseOk(
396
468
  // call arrives here, select_page via activatePage included.
397
469
  if (parts.backend !== backend || parts.targetId !== targetId) {
398
470
  // 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;
471
+ // Under strict mode this tab still has to be acquired: an agent holding
472
+ // tab A that reaches into unleased tab C is exactly the case the feature
473
+ // exists to cover, and it is the COMMON case under the ambient dispatch
474
+ // scope, not an edge one.
475
+ if (!held) {
476
+ if (requireLease()) {
477
+ await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
478
+ }
479
+ return;
480
+ }
401
481
  throw new LeaseConflictError(
402
482
  `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
483
  targetId,