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/CHANGELOG.md +439 -0
- package/CONTRACT.md +11 -2
- package/README.md +89 -23
- package/dist/cli.js +1150 -111
- package/dist/index.js +1011 -99
- package/dist/mcp.js +1447 -167
- package/package.json +5 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +231 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +472 -25
- package/src/driver.ts +161 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +269 -27
- package/src/leases.ts +84 -4
- package/src/manifest.ts +295 -56
- package/src/mcp.ts +10 -2
- package/src/reap.ts +165 -0
- package/src/shared-tools.ts +303 -30
- package/src/tools/browser-session.ts +246 -0
- package/src/tools/dispatch-mouse.ts +108 -0
- package/src/tools/downloads.ts +143 -0
- package/src/tools/permissions.ts +130 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-toolkit",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task.
|
|
3
|
+
"version": "1.8.0",
|
|
4
|
+
"description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 45 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": {
|
|
@@ -78,7 +78,9 @@
|
|
|
78
78
|
"screencast:smoke": "bun run ./test/screencast-smoke.ts",
|
|
79
79
|
"mcp:smoke": "bun run ./test/mcp-smoke.ts",
|
|
80
80
|
"firefox:smoke": "bun run ./test/firefox-smoke.ts",
|
|
81
|
-
"lease:smoke": "bun run ./test/lease-smoke.ts"
|
|
81
|
+
"lease:smoke": "bun run ./test/lease-smoke.ts",
|
|
82
|
+
"staleness:smoke": "bun run ./test/staleness-smoke.ts",
|
|
83
|
+
"input:smoke": "bun run ./test/input-smoke.ts"
|
|
82
84
|
},
|
|
83
85
|
"devDependencies": {
|
|
84
86
|
"@types/node": "22.10.10",
|
package/src/activity.ts
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The activity beacon: what tells an agent that a HUMAN is still using the tab
|
|
3
|
+
* it is about to take over.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. Since 1.5.0 claim_page{target} can take over a tab that is
|
|
6
|
+
* already open, and the tab that feature exists for is a human's. Nothing until
|
|
7
|
+
* now could detect the human still using it: a lease's lastUsedAt measures
|
|
8
|
+
* TOOLKIT calls, so a tab a person has been typing in for ten minutes and no
|
|
9
|
+
* agent has touched looks perfectly idle. This module measures the other side.
|
|
10
|
+
*
|
|
11
|
+
* THE HONEST SIGNAL IS A CORRELATION, NOT A FLAG. The obvious approach —
|
|
12
|
+
* listen for input events and trust `isTrusted` — does not work, and it fails
|
|
13
|
+
* in the direction that matters: CDP-dispatched input (Input.dispatchMouseEvent,
|
|
14
|
+
* Input.dispatchKeyEvent) arrives at the page as `isTrusted: true`, byte for
|
|
15
|
+
* byte indistinguishable from a real finger. So an in-page listener alone can
|
|
16
|
+
* only say "input happened", never "a person did it". What makes the answer
|
|
17
|
+
* honest is correlating the in-page timestamp with THIS PROCESS'S OWN log of
|
|
18
|
+
* every input it dispatched: input this process cannot account for is input it
|
|
19
|
+
* did not cause. See isHumanAttributed.
|
|
20
|
+
*
|
|
21
|
+
* THE CAVEAT THAT CANNOT BE ENGINEERED AWAY, stated plainly because a reader
|
|
22
|
+
* will otherwise assume it was overlooked: the dispatch log is IN-PROCESS. Two
|
|
23
|
+
* MCP server processes drive one Chrome (see backend.ts and leases.ts), so a
|
|
24
|
+
* SECOND server's clicks are not in this one's log and read as human here. That
|
|
25
|
+
* is the wrong answer in the safe direction — it reports contention that is
|
|
26
|
+
* really another agent, rather than reporting a human as idle — and the tool
|
|
27
|
+
* surface never refuses a claim on it, so the cost is a spurious warning rather
|
|
28
|
+
* than a blocked call. Do not "fix" it by writing the dispatch log to disk
|
|
29
|
+
* beside the lease files without also solving clock skew and write amplification
|
|
30
|
+
* on every single click; that trade was considered and declined.
|
|
31
|
+
*
|
|
32
|
+
* A SECOND, NARROWER GAP: the beacon listens on the TOP-LEVEL window only.
|
|
33
|
+
* Events do not cross document boundaries, so a person clicking inside a
|
|
34
|
+
* cross-origin iframe moves no timestamp this module can see. Same direction of
|
|
35
|
+
* error (reads as idle), same non-fix (a per-frame beacon would need a
|
|
36
|
+
* frame-tree walk on every install and still miss an OOPIF that navigates).
|
|
37
|
+
*
|
|
38
|
+
* AND A THIRD: the beacon only answers for a tab something INSTALLED it into.
|
|
39
|
+
* A human's tab that no agent has ever claimed carries no beacon at all, so the
|
|
40
|
+
* first claim_page{target} against it reports humanActiveMs null — "no data",
|
|
41
|
+
* never "no human". Every consumer must read null as unknown. The value shows
|
|
42
|
+
* up on the second claim, on any tab an agent claimed and released, and on any
|
|
43
|
+
* tab listed by list_leases, which is exactly the population where two parties
|
|
44
|
+
* are most likely to be fighting over one tab.
|
|
45
|
+
*/
|
|
46
|
+
import type { BrowserDriver } from "./driver.ts";
|
|
47
|
+
import type { LeaseBackend } from "./leases.ts";
|
|
48
|
+
|
|
49
|
+
/* ------------------------------- the in-page beacon ------------------------------- */
|
|
50
|
+
|
|
51
|
+
/** Where the beacon parks its timestamp. Read by this module, by list_leases,
|
|
52
|
+
* and (from 1.8.0's renderer ping) by list_pages. Never change this string:
|
|
53
|
+
* a page carrying a beacon installed by an older server must stay readable. */
|
|
54
|
+
export const BEACON_DATA_GLOBAL = "__cdpToolkitLastInput";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The idempotence guard, deliberately a DIFFERENT global from the data one.
|
|
58
|
+
*
|
|
59
|
+
* Guarding on BEACON_DATA_GLOBAL itself would be the obvious one-global design
|
|
60
|
+
* and it is broken: that global does not exist until the first input arrives,
|
|
61
|
+
* so on a tab nobody has touched yet every re-install would see `undefined`,
|
|
62
|
+
* conclude "not installed", and add another full set of listeners. The beacon is
|
|
63
|
+
* re-installed on every claim, so that leak is not hypothetical.
|
|
64
|
+
*/
|
|
65
|
+
export const BEACON_INSTALLED_GLOBAL = "__cdpToolkitBeacon";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The events that count as input.
|
|
69
|
+
*
|
|
70
|
+
* `pointerdown` and `mousedown` are both listed on purpose rather than as a
|
|
71
|
+
* belt-and-braces reflex: a page that calls preventDefault() on pointerdown
|
|
72
|
+
* suppresses the compatibility mousedown, and a page using a pointer-events
|
|
73
|
+
* polyfill can fire mousedown with no pointerdown at all. `wheel` is what makes
|
|
74
|
+
* plain reading register — scrolling an article is the most common thing a
|
|
75
|
+
* person does in a tab without ever clicking. Deliberately NOT here: mousemove
|
|
76
|
+
* (fires continuously and would report "active" for a cursor resting over the
|
|
77
|
+
* window), focus/blur (fire on tab switching, i.e. on someone leaving), and
|
|
78
|
+
* keyup (redundant with keydown and doubles the event volume).
|
|
79
|
+
*/
|
|
80
|
+
export const BEACON_EVENTS = ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"] as const;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Capture phase and passive, both load-bearing.
|
|
84
|
+
*
|
|
85
|
+
* CAPTURE: a page that stops propagation on a bubbling handler (drag surfaces,
|
|
86
|
+
* canvas editors and modal overlays all do) would hide the input from a bubble-
|
|
87
|
+
* phase listener entirely. Capture runs before the page's own handlers, so no
|
|
88
|
+
* page can suppress the beacon.
|
|
89
|
+
*
|
|
90
|
+
* PASSIVE: promises the browser this listener will never call preventDefault(),
|
|
91
|
+
* which is what keeps a `wheel` listener off the scrolling critical path. A
|
|
92
|
+
* non-passive wheel listener on window measurably janks scrolling, and a
|
|
93
|
+
* diagnostic that makes the page it is diagnosing feel slower is not acceptable.
|
|
94
|
+
*/
|
|
95
|
+
const BEACON_BODY = `
|
|
96
|
+
if (!window.${BEACON_INSTALLED_GLOBAL}) {
|
|
97
|
+
window.${BEACON_INSTALLED_GLOBAL} = true;
|
|
98
|
+
var mark = function () { window.${BEACON_DATA_GLOBAL} = Date.now(); };
|
|
99
|
+
var events = ${JSON.stringify([...BEACON_EVENTS])};
|
|
100
|
+
for (var i = 0; i < events.length; i++) {
|
|
101
|
+
window.addEventListener(events[i], mark, { capture: true, passive: true });
|
|
102
|
+
}
|
|
103
|
+
}`;
|
|
104
|
+
|
|
105
|
+
/** The beacon as a PROGRAM, for CDP (Page.addScriptToEvaluateOnNewDocument and
|
|
106
|
+
* Runtime.evaluate both take a source string). */
|
|
107
|
+
export const BEACON_SOURCE = `(function () {${BEACON_BODY}\n})();`;
|
|
108
|
+
|
|
109
|
+
/** The same beacon as a FUNCTION DECLARATION, for BiDi, whose
|
|
110
|
+
* script.addPreloadScript takes a functionDeclaration rather than a program. */
|
|
111
|
+
export const BEACON_FUNCTION_DECLARATION = `function () {${BEACON_BODY}\n}`;
|
|
112
|
+
|
|
113
|
+
/** Reads the beacon timestamp, or null on a document that has no beacon. Never
|
|
114
|
+
* throws in-page: an unknown global is `undefined`, not a ReferenceError, when
|
|
115
|
+
* reached through `window`. */
|
|
116
|
+
export const BEACON_READ_EXPRESSION = `(typeof window.${BEACON_DATA_GLOBAL} === "number" ? window.${BEACON_DATA_GLOBAL} : null)`;
|
|
117
|
+
|
|
118
|
+
/* -------------------------------- the dispatch log -------------------------------- */
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* How long after one of OUR dispatches a beacon timestamp is still attributed
|
|
122
|
+
* to us rather than to a human.
|
|
123
|
+
*
|
|
124
|
+
* A single toolkit click produces a burst: pointerdown, mousedown, and on a
|
|
125
|
+
* page with its own synthesized follow-ups possibly more, all after the CDP
|
|
126
|
+
* command was sent and all landing on the beacon. The window has to cover that
|
|
127
|
+
* burst plus the round trip. 1500ms is generous for the burst and short enough
|
|
128
|
+
* that a person who reaches for the mouse right after an agent acted is still
|
|
129
|
+
* seen: the failure it trades away (a human input inside 1.5s of our own click
|
|
130
|
+
* read as ours) is a false NEGATIVE on contention, which is the direction that
|
|
131
|
+
* merely under-warns rather than the direction that cries wolf.
|
|
132
|
+
*/
|
|
133
|
+
export const DISPATCH_ATTRIBUTION_WINDOW_MS = 1500;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Keyed by backend AND target id, exactly as leases.ts keys its records, and
|
|
137
|
+
* for the same stated reason: a CDP targetId and a BiDi context id are not
|
|
138
|
+
* guaranteed disjoint, so a bare id could let a Chrome tab's dispatch suppress
|
|
139
|
+
* a Firefox context's beacon.
|
|
140
|
+
*/
|
|
141
|
+
function dispatchKey(backend: LeaseBackend, targetId: string): string {
|
|
142
|
+
return `${backend}:${targetId}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* targetId -> when this process last dispatched input into it.
|
|
147
|
+
*
|
|
148
|
+
* MODULE STATE, WHICH leases.ts's HEADER OTHERWISE FORBIDS, and the exception is
|
|
149
|
+
* the same one leases.ts carves out for `longLived`: that prohibition is about
|
|
150
|
+
* state describing OTHER processes, which must live on disk because two MCP
|
|
151
|
+
* servers drive one browser. This describes what THIS process did, which no
|
|
152
|
+
* other process can observe and none should — the whole discrimination rule is
|
|
153
|
+
* "input this process cannot account for". Putting it on disk would not make it
|
|
154
|
+
* more correct, it would make it a different and worse signal (see the header's
|
|
155
|
+
* caveat).
|
|
156
|
+
*/
|
|
157
|
+
const dispatchLog = new Map<string, number>();
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Bound on the log, because an MCP server can outlive thousands of tabs.
|
|
161
|
+
* Eviction is oldest-write-first, and evicting is SAFE in the only direction
|
|
162
|
+
* that matters: a forgotten dispatch makes a later beacon read as human, which
|
|
163
|
+
* over-warns rather than under-warns.
|
|
164
|
+
*/
|
|
165
|
+
const DISPATCH_LOG_LIMIT = 512;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* THE CHOKE POINT. Every driver method that dispatches synthesized input calls
|
|
169
|
+
* this, and it is called from exactly one place per driver (cdp/driver.ts's
|
|
170
|
+
* dispatchInput, bidi/driver.ts's performActions) so a NEW input tool inherits
|
|
171
|
+
* the bookkeeping by construction instead of by its author remembering.
|
|
172
|
+
*
|
|
173
|
+
* If you are adding an input tool: do not call this directly. Route the protocol
|
|
174
|
+
* command through the driver's own input choke point and this comes for free.
|
|
175
|
+
*/
|
|
176
|
+
export function recordDispatch(backend: LeaseBackend, targetId: string, now: number = Date.now()): void {
|
|
177
|
+
const key = dispatchKey(backend, targetId);
|
|
178
|
+
// Delete-then-set so the Map's insertion order is genuinely last-write order,
|
|
179
|
+
// which is what makes the eviction below oldest-first rather than arbitrary.
|
|
180
|
+
dispatchLog.delete(key);
|
|
181
|
+
dispatchLog.set(key, now);
|
|
182
|
+
while (dispatchLog.size > DISPATCH_LOG_LIMIT) {
|
|
183
|
+
const oldest = dispatchLog.keys().next();
|
|
184
|
+
if (oldest.done) break;
|
|
185
|
+
dispatchLog.delete(oldest.value);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The one thing `sendInput` needs from a CDP connection. Structural on purpose: this module is
|
|
190
|
+
* backend-neutral and must not import chrome's CdpConnection, and tools/dispatch-mouse.ts must be
|
|
191
|
+
* able to use it without depending on the Driver-abstraction layer (see that file's header). */
|
|
192
|
+
export interface InputDispatchConnection {
|
|
193
|
+
send<T = Record<string, unknown>>(method: string, params?: Record<string, unknown>): Promise<T>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `Input.*` commands that change a MODE and synthesize no event in the page. Recording one would
|
|
198
|
+
* write a dispatch timestamp with no input behind it, and a dispatch we did not actually make
|
|
199
|
+
* suppresses a real human input landing inside the attribution window — the under-warning
|
|
200
|
+
* direction, which is the one this module's comments consistently refuse to trade toward.
|
|
201
|
+
*
|
|
202
|
+
* A DENYLIST rather than an allowlist of the dispatching methods, deliberately: an `Input.*` command
|
|
203
|
+
* nobody thought to classify then defaults to being LOGGED, which over-warns. A missing allowlist
|
|
204
|
+
* entry would default the other way and silently reopen the exact hole the choke point closes.
|
|
205
|
+
*/
|
|
206
|
+
const NON_SYNTHESIZING_INPUT_METHODS = new Set(["Input.setInterceptDrags", "Input.setIgnoreInputEvents"]);
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* THE ONLY PLACE AN `Input.*` COMMAND IS WRITTEN TO A CHROME CONNECTION.
|
|
210
|
+
*
|
|
211
|
+
* cdp/driver.ts's `dispatchInput` is the driver-side choke point and delegates here; the chrome-only
|
|
212
|
+
* raw tools that live OUTSIDE the Driver abstraction (tools/dispatch-mouse.ts) call this directly,
|
|
213
|
+
* because they have a connection and a target id but no PageDriver to route through. Both paths
|
|
214
|
+
* therefore log, and the post-merge invariant is mechanically checkable: a literal
|
|
215
|
+
* `conn.send("Input.` appears NOWHERE in src/ (this comment aside) — the only send of an `Input.*`
|
|
216
|
+
* command is the parameterised one below. Firefox's twin is bidi/driver.ts's performActions.
|
|
217
|
+
*
|
|
218
|
+
* Keeping the log complete is the whole point — a synthesized input this process forgets to record
|
|
219
|
+
* reads back as a HUMAN's, which is precisely the false contention warning the beacon exists to
|
|
220
|
+
* avoid. See recordDispatch's contract above.
|
|
221
|
+
*/
|
|
222
|
+
export async function sendInput<T = Record<string, unknown>>(
|
|
223
|
+
conn: InputDispatchConnection,
|
|
224
|
+
backend: LeaseBackend,
|
|
225
|
+
targetId: string,
|
|
226
|
+
method: string,
|
|
227
|
+
params: Record<string, unknown> = {},
|
|
228
|
+
): Promise<T> {
|
|
229
|
+
if (!NON_SYNTHESIZING_INPUT_METHODS.has(method)) recordDispatch(backend, targetId);
|
|
230
|
+
return conn.send<T>(method, params);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** When this process last dispatched input into a target, or undefined if never. */
|
|
234
|
+
export function lastDispatchAt(backend: LeaseBackend, targetId: string): number | undefined {
|
|
235
|
+
return dispatchLog.get(dispatchKey(backend, targetId));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Drop one target's dispatch record. For a tab that has been closed, and for
|
|
239
|
+
* tests that need a clean slate for one target. */
|
|
240
|
+
export function forgetDispatch(backend: LeaseBackend, targetId: string): void {
|
|
241
|
+
dispatchLog.delete(dispatchKey(backend, targetId));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Empty the log. Tests only: no product path wants to forget what it did. */
|
|
245
|
+
export function clearDispatchLog(): void {
|
|
246
|
+
dispatchLog.clear();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ------------------------------- discrimination ------------------------------- */
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* THE RULE: a beacon timestamp is a HUMAN's iff it is newer than this process's
|
|
253
|
+
* last dispatch into that tab by more than the attribution window.
|
|
254
|
+
*
|
|
255
|
+
* `dispatchAt` undefined means this process has never driven the tab, so every
|
|
256
|
+
* real timestamp is unaccounted-for and therefore human. That falls out of the
|
|
257
|
+
* `?? 0` rather than needing its own branch, and it is the correct reading: a
|
|
258
|
+
* tab we have never touched cannot have been moved by us.
|
|
259
|
+
*
|
|
260
|
+
* A non-finite or absent beacon value is NOT human — it is NO DATA. The two are
|
|
261
|
+
* different answers and every caller has to keep them apart, which is why the
|
|
262
|
+
* surfaced field is `number | null` and never a boolean.
|
|
263
|
+
*/
|
|
264
|
+
export function isHumanAttributed(
|
|
265
|
+
beaconTs: number | null | undefined,
|
|
266
|
+
dispatchAt: number | undefined,
|
|
267
|
+
windowMs: number = DISPATCH_ATTRIBUTION_WINDOW_MS,
|
|
268
|
+
): boolean {
|
|
269
|
+
if (typeof beaconTs !== "number" || !Number.isFinite(beaconTs)) return false;
|
|
270
|
+
return beaconTs > (dispatchAt ?? 0) + windowMs;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Milliseconds since the last HUMAN-attributed input, or null when there is no
|
|
275
|
+
* human-attributed input to measure (no beacon, no data, or every timestamp
|
|
276
|
+
* accounted for by our own dispatches).
|
|
277
|
+
*
|
|
278
|
+
* Clamped at zero. The beacon's clock is the page's `Date.now()` and this
|
|
279
|
+
* function's is the server's; they are the same machine's wall clock, but a
|
|
280
|
+
* beacon read that races a millisecond forward would otherwise produce a
|
|
281
|
+
* negative "ms ago", which reads as a corrupt field rather than as the rounding
|
|
282
|
+
* artifact it is.
|
|
283
|
+
*/
|
|
284
|
+
export function humanActiveMs(
|
|
285
|
+
beaconTs: number | null | undefined,
|
|
286
|
+
dispatchAt: number | undefined,
|
|
287
|
+
now: number = Date.now(),
|
|
288
|
+
windowMs: number = DISPATCH_ATTRIBUTION_WINDOW_MS,
|
|
289
|
+
): number | null {
|
|
290
|
+
if (!isHumanAttributed(beaconTs, dispatchAt, windowMs)) return null;
|
|
291
|
+
return Math.max(0, now - (beaconTs as number));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* --------------------------------- contention --------------------------------- */
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* How recent a human input has to be for a takeover to be worth warning about.
|
|
298
|
+
* 30s is "they are still in this tab", not "they used it this session".
|
|
299
|
+
*/
|
|
300
|
+
export const CONTENTION_WINDOW_MS = 30_000;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The warning claim_page attaches to a takeover of a tab a human is still using.
|
|
304
|
+
*
|
|
305
|
+
* IT IS A WARNING AND NEVER A REFUSAL, and that is a product decision, not an
|
|
306
|
+
* oversight: taking over a human's tab IS the feature (see leases-tools.ts's
|
|
307
|
+
* header), a caller asked for this specific tab, and a tool that refused on
|
|
308
|
+
* detected activity would break the exact workflow the takeover mode exists to
|
|
309
|
+
* serve. The claim has already succeeded by the time this string is built. The
|
|
310
|
+
* warning informs; the caller decides.
|
|
311
|
+
*
|
|
312
|
+
* Returns undefined — the field is then omitted entirely — for a fresh tab this
|
|
313
|
+
* call opened (nobody can be contending for a tab that did not exist a moment
|
|
314
|
+
* ago), for no data, and for a human who has been idle past the window.
|
|
315
|
+
*/
|
|
316
|
+
export function contentionWarning(ms: number | null, opts: { takeover: boolean }): string | undefined {
|
|
317
|
+
if (!opts.takeover) return undefined;
|
|
318
|
+
if (ms === null || ms >= CONTENTION_WINDOW_MS) return undefined;
|
|
319
|
+
return (
|
|
320
|
+
`a person appears to be using this tab right now: input not dispatched by this server landed ${ms}ms ago. ` +
|
|
321
|
+
`The claim SUCCEEDED and you hold the lease — this is a warning, not a refusal. ` +
|
|
322
|
+
`Driving the tab now will fight them for the keyboard and mouse. ` +
|
|
323
|
+
`Consider claim_page with no target to work in your own tab instead, or ask before continuing.`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* ---------------------------- driver-facing helpers ---------------------------- */
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Whether this backend can answer beacon questions at all.
|
|
331
|
+
*
|
|
332
|
+
* The two members are optional on BrowserDriver rather than universal because
|
|
333
|
+
* ADR-001's rule for a capability one backend lacks is "absent, never present
|
|
334
|
+
* and throwing". They are NOT in the `Capability` union, deliberately: that
|
|
335
|
+
* union gates whole TOOLS out of tools/list, and no tool here is gated — the
|
|
336
|
+
* beacon is an additive FIELD on two tools that both work fine without it. So
|
|
337
|
+
* the honest expression of the gap is an absent optional method and an absent
|
|
338
|
+
* field, which is what a caller sees on a backend with no beacon.
|
|
339
|
+
*/
|
|
340
|
+
export function beaconSupported(driver: BrowserDriver): boolean {
|
|
341
|
+
return typeof driver.installActivityBeacon === "function" && typeof driver.readActivityBeacon === "function";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Install the beacon into a target, best effort.
|
|
346
|
+
*
|
|
347
|
+
* NEVER THROWS, and never fails the caller's real work. This is an annotation,
|
|
348
|
+
* exactly like origins.ts's ledger write: claim_page's job is to hand back a
|
|
349
|
+
* lease, and a claim that failed because a diagnostic script could not be
|
|
350
|
+
* injected would be a worse tool than one with a missing field. The cost, stated
|
|
351
|
+
* plainly: a tab whose injection silently failed reports humanActiveMs null
|
|
352
|
+
* forever, indistinguishable from a tab nobody has touched.
|
|
353
|
+
*/
|
|
354
|
+
export async function installBeacon(driver: BrowserDriver, targetId: string): Promise<boolean> {
|
|
355
|
+
if (typeof driver.installActivityBeacon !== "function") return false;
|
|
356
|
+
return driver.installActivityBeacon(targetId).catch(() => false);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Read a target's human-activity age, gate-free, best effort.
|
|
361
|
+
*
|
|
362
|
+
* GATE-FREE IS REQUIRED, NOT CONVENIENT, on the claim_page{target} path, and
|
|
363
|
+
* for exactly the reason pickPage is gate-free (see leases-tools.ts's header):
|
|
364
|
+
* under CDP_REQUIRE_LEASE the lease gate AUTO-ACQUIRES a lease on an unleased
|
|
365
|
+
* tab, so reading through a gated path before claiming would claim the tab
|
|
366
|
+
* once implicitly and then collide with itself on the explicit claim. The
|
|
367
|
+
* driver members this calls therefore bypass the gate — and the bypass is safe
|
|
368
|
+
* to hand out because it is not general: `readActivityBeacon` can return one
|
|
369
|
+
* number and cannot drive, mutate, or even read anything else from the page.
|
|
370
|
+
*
|
|
371
|
+
* Returns null both for "no beacon" and for "every timestamp was ours". A
|
|
372
|
+
* caller that must distinguish "this backend has no beacon at all" checks
|
|
373
|
+
* beaconSupported first, and omits the field entirely in that case.
|
|
374
|
+
*/
|
|
375
|
+
export async function readHumanActiveMs(
|
|
376
|
+
driver: BrowserDriver,
|
|
377
|
+
backend: LeaseBackend,
|
|
378
|
+
targetId: string,
|
|
379
|
+
now: number = Date.now(),
|
|
380
|
+
): Promise<number | null> {
|
|
381
|
+
if (typeof driver.readActivityBeacon !== "function") return null;
|
|
382
|
+
const beaconTs = await driver.readActivityBeacon(targetId).catch(() => null);
|
|
383
|
+
return humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* ------------------------------- the renderer ping ------------------------------- */
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* How long list_pages{probe:true} waits for one target's renderer to answer
|
|
390
|
+
* before calling it unresponsive. Bounded so ONE wedged tab cannot stall the
|
|
391
|
+
* whole listing beyond its own budget; see BrowserDriver.probeRenderer.
|
|
392
|
+
*/
|
|
393
|
+
export const RENDERER_PROBE_TIMEOUT_MS = 500;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Evaluated in-page by the bounded probe. The leading `1` is a liveness
|
|
397
|
+
* marker, not a beacon reading: it is what lets a caller distinguish "the
|
|
398
|
+
* renderer executed this and came back with genuinely no beacon data"
|
|
399
|
+
* (`[1, null]`) from a probe that never got a value back at all, which the
|
|
400
|
+
* driver reports as a rejection — see probeRenderer — rather than as this
|
|
401
|
+
* expression evaluating to something falsy.
|
|
402
|
+
*
|
|
403
|
+
* The second element is BEACON_READ_EXPRESSION verbatim, not a re-derived
|
|
404
|
+
* `window.X ?? null` — composed, not duplicated, so list_pages{probe:true}
|
|
405
|
+
* and claim_page/list_leases's own beacon reads can never drift apart on what
|
|
406
|
+
* counts as a valid timestamp. That guard matters: `?? null` alone would let
|
|
407
|
+
* a non-number value some other script wrote into the beacon global (a
|
|
408
|
+
* string, an object — anything but null/undefined) pass straight through,
|
|
409
|
+
* where BEACON_READ_EXPRESSION's `typeof ... === "number"` check does not.
|
|
410
|
+
*/
|
|
411
|
+
export const RENDERER_PROBE_EXPRESSION = `[1, ${BEACON_READ_EXPRESSION}]`;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Whether this backend can answer the renderer ping at all.
|
|
415
|
+
*
|
|
416
|
+
* Same rule as beaconSupported and the same reason: not a `Capability`,
|
|
417
|
+
* because no whole TOOL is gated here, only an additive field on list_pages
|
|
418
|
+
* that works fine (simply absent) without it.
|
|
419
|
+
*/
|
|
420
|
+
export function rendererProbeSupported(driver: BrowserDriver): boolean {
|
|
421
|
+
return typeof driver.probeRenderer === "function";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export interface RendererProbeResult {
|
|
425
|
+
responsive: boolean;
|
|
426
|
+
/** Same rule as everywhere else in this module: present only where the
|
|
427
|
+
* same round trip found beacon data AND that data is human-attributed. */
|
|
428
|
+
humanActiveMs?: number;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Probe one target and fold the answer through the SAME discrimination rule as
|
|
433
|
+
* every other beacon read (isHumanAttributed via humanActiveMs, keyed by this
|
|
434
|
+
* process's own dispatch log), so probe:true can never disagree with
|
|
435
|
+
* claim_page or list_leases about what counts as human.
|
|
436
|
+
*
|
|
437
|
+
* NEVER THROWS. driver.probeRenderer is documented to never reject, but this
|
|
438
|
+
* defends anyway (matching readHumanActiveMs's own defensive `.catch`) rather
|
|
439
|
+
* than trust every implementer to honor that. A driver with no probeRenderer
|
|
440
|
+
* at all degrades to `{responsive:false}` with no humanActiveMs — the same
|
|
441
|
+
* shape as a backend that answered and found nothing, which is deliberate:
|
|
442
|
+
* the caller that needs to tell "cannot answer" from "answered, no data" is
|
|
443
|
+
* list_pages itself, via rendererProbeSupported, not this function.
|
|
444
|
+
*/
|
|
445
|
+
export async function probeRendererActivity(
|
|
446
|
+
driver: BrowserDriver,
|
|
447
|
+
backend: LeaseBackend,
|
|
448
|
+
targetId: string,
|
|
449
|
+
now: number = Date.now(),
|
|
450
|
+
): Promise<RendererProbeResult> {
|
|
451
|
+
if (typeof driver.probeRenderer !== "function") return { responsive: false };
|
|
452
|
+
const { responsive, beaconTs } = await driver
|
|
453
|
+
.probeRenderer(targetId, RENDERER_PROBE_TIMEOUT_MS)
|
|
454
|
+
.catch(() => ({ responsive: false, beaconTs: null as number | null }));
|
|
455
|
+
const ms = humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
|
|
456
|
+
return ms === null ? { responsive } : { responsive, humanActiveMs: ms };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* ------------------------- keep-alive session bookkeeping ------------------------- */
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* A bounded registry of the live connections that keep a beacon re-arming
|
|
463
|
+
* across navigation. Both drivers own one; the eviction policy lives here once
|
|
464
|
+
* rather than twice.
|
|
465
|
+
*
|
|
466
|
+
* WHY A HELD CONNECTION IS NECESSARY AT ALL, since it is the one expensive thing
|
|
467
|
+
* in this module and a reader will reasonably ask. Chrome's
|
|
468
|
+
* Page.addScriptToEvaluateOnNewDocument is SESSION-SCOPED: the registration is
|
|
469
|
+
* cleared when the client that made it disconnects. Verified against Chrome
|
|
470
|
+
* 151.0.7922.109 — install, disconnect, reconnect, navigate, and the script does
|
|
471
|
+
* not run; install and navigate on ONE connection and it runs on every
|
|
472
|
+
* navigation. This toolkit's CDP lifetime is "per-call" (driver.ts), so the
|
|
473
|
+
* naive install would cover the current document only and would be silently
|
|
474
|
+
* lost the moment the person navigated, which is the single most common thing
|
|
475
|
+
* a person does in a tab. Holding one socket per beaconed tab is what makes
|
|
476
|
+
* "survives navigation" a true statement instead of an aspiration.
|
|
477
|
+
*
|
|
478
|
+
* EVICTION DEGRADES, IT DOES NOT BREAK. Past the limit the oldest socket is
|
|
479
|
+
* closed; the listeners already installed in that tab's CURRENT document keep
|
|
480
|
+
* working and keep answering reads, so the tab loses only the re-arm on its
|
|
481
|
+
* NEXT navigation. That is the same state as a tab beaconed by a CLI process,
|
|
482
|
+
* which cannot hold anything at all.
|
|
483
|
+
*/
|
|
484
|
+
export class BeaconSessions<T> {
|
|
485
|
+
private readonly live = new Map<string, T>();
|
|
486
|
+
constructor(
|
|
487
|
+
private readonly close: (value: T) => void,
|
|
488
|
+
private readonly limit = 32,
|
|
489
|
+
) {}
|
|
490
|
+
|
|
491
|
+
get(key: string): T | undefined {
|
|
492
|
+
return this.live.get(key);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Adopt a handle for `key`, closing any handle it displaces and evicting the
|
|
496
|
+
* oldest once over the limit. */
|
|
497
|
+
set(key: string, value: T): void {
|
|
498
|
+
const previous = this.live.get(key);
|
|
499
|
+
if (previous !== undefined) {
|
|
500
|
+
this.live.delete(key);
|
|
501
|
+
this.safeClose(previous);
|
|
502
|
+
}
|
|
503
|
+
this.live.set(key, value);
|
|
504
|
+
while (this.live.size > this.limit) {
|
|
505
|
+
const oldest = this.live.keys().next();
|
|
506
|
+
if (oldest.done) break;
|
|
507
|
+
this.drop(oldest.value);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
drop(key: string): void {
|
|
512
|
+
const value = this.live.get(key);
|
|
513
|
+
if (value === undefined) return;
|
|
514
|
+
this.live.delete(key);
|
|
515
|
+
this.safeClose(value);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
clear(): void {
|
|
519
|
+
for (const key of [...this.live.keys()]) this.drop(key);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
get size(): number {
|
|
523
|
+
return this.live.size;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private safeClose(value: T): void {
|
|
527
|
+
try {
|
|
528
|
+
this.close(value);
|
|
529
|
+
} catch {
|
|
530
|
+
/* a socket that is already gone is exactly the state we wanted */
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|