cdp-toolkit 2.1.0 → 2.3.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 +33 -0
- package/README.md +21 -7
- package/dist/bidi/mux-daemon.js +3775 -0
- package/dist/cli.js +6573 -5644
- package/dist/index.js +373 -210
- package/dist/mcp.js +8334 -7383
- package/package.json +4 -2
- package/src/bidi/driver.ts +329 -30
- package/src/bidi/mux-daemon.ts +122 -0
- package/src/bidi/mux.ts +610 -0
- package/src/bidi/session-lease.ts +95 -3
- package/src/cdp/driver.ts +1 -1
- package/src/driver.ts +3 -0
- package/src/index.ts +6 -0
- package/src/manifest.ts +65 -0
- package/src/toolDocs.ts +20 -0
- package/src/toolGroups.ts +1 -1
- package/src/tools/focus-emulation.ts +185 -0
- package/src/version.ts +1 -1
|
@@ -35,8 +35,34 @@
|
|
|
35
35
|
* ATOMICITY. Claims are exclusive creates (writeFile flag "wx"), exactly like
|
|
36
36
|
* ../leases.ts claimLease, so "is the slot free, and if so take it" is atomic
|
|
37
37
|
* against a racing process for free, with no registry lock.
|
|
38
|
+
*
|
|
39
|
+
* THE MUX: WHY A LIVE HOLDER IS NO LONGER A DEAD END. Everything above still
|
|
40
|
+
* holds — one process owns Firefox's single BiDi session — but the holder now
|
|
41
|
+
* also hosts a loopback BiDi multiplexer (./mux.ts) over that one real session
|
|
42
|
+
* and ADVERTISES its ws endpoint in this record (`muxEndpoint`). Two consequences
|
|
43
|
+
* for this module, and nothing else changes:
|
|
44
|
+
*
|
|
45
|
+
* - THE HOLDER advertises via advertiseMux() once its mux is listening. That is
|
|
46
|
+
* a rewrite of its OWN record, guarded by the same (pid, createdAt) ownership
|
|
47
|
+
* test release uses, and written atomically (tmp + rename) so a concurrent
|
|
48
|
+
* reader never sees a half-written record. Advertising is best-effort: a
|
|
49
|
+
* refusal (`advertised:false`) means the record is no longer ours and the
|
|
50
|
+
* holder simply runs alone.
|
|
51
|
+
* - A JOINER DOES NOT WAIT. When acquire finds a LIVE holder that carries a
|
|
52
|
+
* muxEndpoint, there is nothing to wait FOR: the slot will not free up (the
|
|
53
|
+
* holder is healthy) and it does not need to, because the mux serves that one
|
|
54
|
+
* session to as many processes as ask. So acquire returns { joinMux }
|
|
55
|
+
* immediately, with NO handle and nothing written to disk — the joiner holds
|
|
56
|
+
* no slot and must never release one. The record is re-read on every poll
|
|
57
|
+
* iteration, so a holder that took the slot a moment ago and advertises a beat
|
|
58
|
+
* later is joined as soon as it does, inside the existing wait window.
|
|
59
|
+
*
|
|
60
|
+
* A live holder with NO muxEndpoint (a foreign client, an older cdp-toolkit, or
|
|
61
|
+
* one running with CDP_FIREFOX_MUX=off) is still plain BUSY and still throws
|
|
62
|
+
* SessionSlotBusyError at the deadline — exactly today's behavior. CDP_FIREFOX_MUX=off
|
|
63
|
+
* makes acquire ignore muxEndpoint entirely, which is the opt-out.
|
|
38
64
|
*/
|
|
39
|
-
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
65
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
40
66
|
import { join } from "node:path";
|
|
41
67
|
import { isPidAlive, leaseDir } from "../leases.ts";
|
|
42
68
|
|
|
@@ -55,6 +81,12 @@ export interface SessionSlotRecord {
|
|
|
55
81
|
createdAt: number;
|
|
56
82
|
/** Optional Marionette control port bound to this Firefox, when known. */
|
|
57
83
|
marionettePort?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Loopback ws endpoint of the BiDi multiplexer this holder hosts over the one
|
|
86
|
+
* real session (see ./mux.ts and the header). Absent means "this holder serves
|
|
87
|
+
* nobody but itself", which is what every pre-mux client's record looks like.
|
|
88
|
+
*/
|
|
89
|
+
muxEndpoint?: string;
|
|
58
90
|
}
|
|
59
91
|
|
|
60
92
|
/**
|
|
@@ -68,7 +100,11 @@ export interface SessionSlotHandle {
|
|
|
68
100
|
}
|
|
69
101
|
|
|
70
102
|
export interface AcquireResult {
|
|
71
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Present iff WE took the slot. Absent on the joinMux path: a joiner owns no
|
|
105
|
+
* slot, wrote no record, and must never release one.
|
|
106
|
+
*/
|
|
107
|
+
handle?: SessionSlotHandle;
|
|
72
108
|
/**
|
|
73
109
|
* Set only when we STOLE a dead holder's slot. Its presence tells the caller
|
|
74
110
|
* that Firefox may still believe an orphaned BiDi session is active, so the
|
|
@@ -77,6 +113,12 @@ export interface AcquireResult {
|
|
|
77
113
|
* sessions". Absent on a clean first acquire.
|
|
78
114
|
*/
|
|
79
115
|
staleHolder?: SessionSlotRecord;
|
|
116
|
+
/**
|
|
117
|
+
* Set instead of `handle` when a LIVE holder advertises a BiDi multiplexer.
|
|
118
|
+
* The caller dials `endpoint` exactly as it would dial Firefox; `holder` is the
|
|
119
|
+
* record it came from, for error reporting. Nothing was written to disk.
|
|
120
|
+
*/
|
|
121
|
+
joinMux?: { endpoint: string; holder: SessionSlotRecord };
|
|
80
122
|
}
|
|
81
123
|
|
|
82
124
|
export interface AcquireOptions {
|
|
@@ -105,6 +147,16 @@ export function sessionSlotFile(endpoint: string): string {
|
|
|
105
147
|
return join(leaseDir(), `ff-session-${safeEndpoint(endpoint)}.json`);
|
|
106
148
|
}
|
|
107
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Where the detached mux DAEMON (./mux-daemon.ts) appends its one-line diagnostics for this
|
|
152
|
+
* endpoint. It is spawned with stdio:"ignore" (no console at all), so this file is the ONLY
|
|
153
|
+
* record of why a daemon refused to start; the driver names it in the error it raises when a
|
|
154
|
+
* daemon it spawned never advertised. Sibling of the slot file, same safeEndpoint key.
|
|
155
|
+
*/
|
|
156
|
+
export function muxDaemonLogFile(endpoint: string): string {
|
|
157
|
+
return join(leaseDir(), `ff-mux-${safeEndpoint(endpoint)}.log`);
|
|
158
|
+
}
|
|
159
|
+
|
|
108
160
|
/** Read per call (like leaseDir/leaseTtlMs), so a test can redirect it. A value
|
|
109
161
|
* that is not a finite number > 0 falls back to the default. */
|
|
110
162
|
export function sessionWaitMs(): number {
|
|
@@ -236,7 +288,16 @@ export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions)
|
|
|
236
288
|
}
|
|
237
289
|
}
|
|
238
290
|
|
|
239
|
-
// 4. LIVE holder
|
|
291
|
+
// 4. LIVE holder. If it advertises a mux, JOIN it instead of waiting: the slot
|
|
292
|
+
// is never going to free up, and it does not have to — the holder's mux serves
|
|
293
|
+
// the one real session to every joiner. Return immediately, writing nothing.
|
|
294
|
+
// (The re-read at step 2 happens every iteration, so a holder that advertises a
|
|
295
|
+
// beat after taking the slot is picked up on the next poll.) CDP_FIREFOX_MUX=off
|
|
296
|
+
// opts out and restores the pre-mux busy-wait exactly.
|
|
297
|
+
if (typeof holder.muxEndpoint === "string" && holder.muxEndpoint.length > 0 && process.env.CDP_FIREFOX_MUX !== "off") {
|
|
298
|
+
return { joinMux: { endpoint: holder.muxEndpoint, holder } };
|
|
299
|
+
}
|
|
300
|
+
// No mux: legitimate contention. Poll until the deadline, then fail.
|
|
240
301
|
lastLiveHolder = holder;
|
|
241
302
|
const remaining = deadline - Date.now();
|
|
242
303
|
if (remaining <= 0) throw new SessionSlotBusyError(endpoint, holder);
|
|
@@ -244,6 +305,37 @@ export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions)
|
|
|
244
305
|
}
|
|
245
306
|
}
|
|
246
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Publish this holder's BiDi multiplexer endpoint into its OWN slot record, so a
|
|
310
|
+
* contending process joins the mux instead of waiting out the slot.
|
|
311
|
+
*
|
|
312
|
+
* Ownership-guarded by the same (pid, createdAt) test releaseSessionSlot uses: if
|
|
313
|
+
* the record on disk is missing or belongs to someone else (we were stolen from
|
|
314
|
+
* as a dead-looking holder, or we already released), we refuse with
|
|
315
|
+
* { advertised: false } and leave the file byte-for-byte untouched rather than
|
|
316
|
+
* stamping our mux onto a stranger's claim.
|
|
317
|
+
*
|
|
318
|
+
* Atomic: the merged record is written to `<file>.tmp-<pid>` and renamed over the
|
|
319
|
+
* original, so a concurrent acquire's read sees either the old record or the new
|
|
320
|
+
* one, never a truncated one. Every other field is preserved.
|
|
321
|
+
*/
|
|
322
|
+
export async function advertiseMux(handle: SessionSlotHandle, muxEndpoint: string): Promise<{ advertised: boolean }> {
|
|
323
|
+
const current = await readSessionSlot(handle.endpoint);
|
|
324
|
+
if (!current) return { advertised: false };
|
|
325
|
+
if (current.pid !== process.pid || current.createdAt !== handle.createdAt) return { advertised: false };
|
|
326
|
+
const file = sessionSlotFile(handle.endpoint);
|
|
327
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
328
|
+
const next: SessionSlotRecord = { ...current, muxEndpoint };
|
|
329
|
+
try {
|
|
330
|
+
await writeFile(tmp, JSON.stringify(next), "utf8");
|
|
331
|
+
await rename(tmp, file);
|
|
332
|
+
} catch {
|
|
333
|
+
await unlink(tmp).catch(() => undefined);
|
|
334
|
+
return { advertised: false };
|
|
335
|
+
}
|
|
336
|
+
return { advertised: true };
|
|
337
|
+
}
|
|
338
|
+
|
|
247
339
|
/**
|
|
248
340
|
* Release the slot, but ONLY if the record on disk is still the one we wrote.
|
|
249
341
|
*
|
package/src/cdp/driver.ts
CHANGED
|
@@ -651,7 +651,7 @@ const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
|
|
|
651
651
|
"emulate.mediaFeatures", "emulate.deviceMetrics", "emulate.networkConditions",
|
|
652
652
|
"screenshot.fullPage", "screenshot.element", "screenshot.scale", "screenshot.renderSize", "screenshot.tile", "network.intercept",
|
|
653
653
|
"snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
|
|
654
|
-
"capture.screencast", "input.raw", "input.html5Drag",
|
|
654
|
+
"capture.screencast", "input.raw", "input.html5Drag", "emulate.focus",
|
|
655
655
|
"browser.downloads", "browser.permissions", "worker.targets",
|
|
656
656
|
]);
|
|
657
657
|
/* ---------------------------------- PageDriver ---------------------------------- */
|
package/src/driver.ts
CHANGED
|
@@ -150,6 +150,7 @@ export type Capability =
|
|
|
150
150
|
| "locate.xpath" // find a node by xpath
|
|
151
151
|
| "input.raw" // raw single mouse-event dispatch (dispatch_mouse's move/down/up primitive); Chrome only
|
|
152
152
|
| "input.html5Drag" // real HTML5 drag events (drag's mode:"html5"); Chrome only, see PageDriver.drag
|
|
153
|
+
| "emulate.focus" // fake page focus without moving the OS window (focus_emulation / click_focus_gated); Chrome only — BiDi's emulation module has no focus primitive
|
|
153
154
|
| "browser.downloads" // capture a file download to a known path (wait_for_download); Chrome only
|
|
154
155
|
| "browser.permissions" // grant/reset browser permissions for an origin (grant_permissions); Chrome only
|
|
155
156
|
| "worker.targets"; // address a service/shared worker as a target ("worker:<substr>"), incl. waking an evicted MV3 worker; Chrome only
|
|
@@ -172,6 +173,8 @@ export const REQUIRED_CAPABILITIES: Partial<Record<ToolName, readonly Capability
|
|
|
172
173
|
list_mocks: ["network.intercept"],
|
|
173
174
|
clear_mocks: ["network.intercept"],
|
|
174
175
|
dispatch_mouse: ["input.raw"],
|
|
176
|
+
focus_emulation: ["emulate.focus"],
|
|
177
|
+
click_focus_gated: ["emulate.focus"],
|
|
175
178
|
wait_for_download: ["browser.downloads"],
|
|
176
179
|
grant_permissions: ["browser.permissions"],
|
|
177
180
|
} as const;
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,8 @@ import { lighthouseAudit } from "./tools/lighthouse.ts";
|
|
|
43
43
|
import { mockRequest, listMocks, clearMocks } from "./tools/network_mock.ts";
|
|
44
44
|
// --- raw mouse dispatch (Input.dispatchMouseEvent, one event per call) : chrome-only, capability "input.raw" ---
|
|
45
45
|
import { dispatchMouse } from "./tools/dispatch-mouse.ts";
|
|
46
|
+
// --- focus emulation + focus-gated click (Emulation.setFocusEmulationEnabled) : chrome-only, capability "emulate.focus" ---
|
|
47
|
+
import { focusEmulation, clickFocusGated } from "./tools/focus-emulation.ts";
|
|
46
48
|
// --- downloads + permissions (browser-endpoint state on a standing connection) : chrome-only, 1.8.0 Track P3 ---
|
|
47
49
|
import { waitForDownload } from "./tools/downloads.ts";
|
|
48
50
|
import { grantPermissions } from "./tools/permissions.ts";
|
|
@@ -145,6 +147,10 @@ export const TOOLS = {
|
|
|
145
147
|
clear_mocks: clearMocks,
|
|
146
148
|
// raw mouse dispatch (1) : 1.8.0 Track P1 toolkit addition, chrome-only (capability "input.raw")
|
|
147
149
|
dispatch_mouse: dispatchMouse,
|
|
150
|
+
// focus emulation (2) : fake page focus without stealing OS focus; trusted click on a focus-gated
|
|
151
|
+
// button (e.g. Claude Code OAuth Authorize). chrome-only (capability "emulate.focus")
|
|
152
|
+
focus_emulation: focusEmulation,
|
|
153
|
+
click_focus_gated: clickFocusGated,
|
|
148
154
|
// downloads + permissions (2) : 1.8.0 Track P3 toolkit additions, chrome-only (capabilities
|
|
149
155
|
// "browser.downloads" / "browser.permissions"). Both drive browser-endpoint state that Chrome
|
|
150
156
|
// reverts when the issuing client disconnects, so both run on tools/browser-session.ts's standing
|
package/src/manifest.ts
CHANGED
|
@@ -230,6 +230,7 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
230
230
|
},
|
|
231
231
|
"args": {
|
|
232
232
|
"type": "array",
|
|
233
|
+
"items": {},
|
|
233
234
|
"description": "Positional JSON arguments."
|
|
234
235
|
},
|
|
235
236
|
"savePath": {
|
|
@@ -673,6 +674,70 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
673
674
|
"additionalProperties": false
|
|
674
675
|
}
|
|
675
676
|
},
|
|
677
|
+
{
|
|
678
|
+
"name": "focus_emulation",
|
|
679
|
+
"description": "Toggle page focus emulation: the page reports focused/visible without the OS window moving; Chrome-only.",
|
|
680
|
+
"inputSchema": {
|
|
681
|
+
"type": "object",
|
|
682
|
+
"properties": {
|
|
683
|
+
"lease": {
|
|
684
|
+
"type": "string",
|
|
685
|
+
"description": "Lease token; omit for a tab this process already holds. See server instructions."
|
|
686
|
+
},
|
|
687
|
+
"target": {
|
|
688
|
+
"type": "string",
|
|
689
|
+
"description": "Page selector (index:/url:/title:/label:/targetId, default active); grammar in server instructions."
|
|
690
|
+
},
|
|
691
|
+
"enabled": {
|
|
692
|
+
"type": "boolean",
|
|
693
|
+
"description": "true = page reports focused; false = restore real focus state."
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
"required": [
|
|
697
|
+
"enabled"
|
|
698
|
+
],
|
|
699
|
+
"additionalProperties": false
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
"name": "click_focus_gated",
|
|
704
|
+
"description": "Emulate page focus, wait for a focus-gated button to enable, trusted-click it, restore; Chrome-only.",
|
|
705
|
+
"inputSchema": {
|
|
706
|
+
"type": "object",
|
|
707
|
+
"properties": {
|
|
708
|
+
"lease": {
|
|
709
|
+
"type": "string",
|
|
710
|
+
"description": "Lease token; omit for a tab this process already holds. See server instructions."
|
|
711
|
+
},
|
|
712
|
+
"target": {
|
|
713
|
+
"type": "string",
|
|
714
|
+
"description": "Page selector (index:/url:/title:/label:/targetId, default active); grammar in server instructions."
|
|
715
|
+
},
|
|
716
|
+
"selector": {
|
|
717
|
+
"type": "string",
|
|
718
|
+
"description": "CSS selector of the button; exactly one of selector or text."
|
|
719
|
+
},
|
|
720
|
+
"text": {
|
|
721
|
+
"type": "string",
|
|
722
|
+
"description": "Visible-text match for the button (e.g. \"Authorize\"); exactly one of selector or text."
|
|
723
|
+
},
|
|
724
|
+
"timeoutMs": {
|
|
725
|
+
"type": "number",
|
|
726
|
+
"description": "Max ms to poll for the button to become enabled. Default 30000."
|
|
727
|
+
},
|
|
728
|
+
"pollMs": {
|
|
729
|
+
"type": "number",
|
|
730
|
+
"description": "Poll interval ms. Default 500."
|
|
731
|
+
},
|
|
732
|
+
"keepFocus": {
|
|
733
|
+
"type": "boolean",
|
|
734
|
+
"description": "Leave focus emulation on after the click (default false = restored)."
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
"required": [],
|
|
738
|
+
"additionalProperties": false
|
|
739
|
+
}
|
|
740
|
+
},
|
|
676
741
|
{
|
|
677
742
|
"name": "wait_for_download",
|
|
678
743
|
"description": "Arm or collect a browser download; arm capture before triggering the download.",
|
package/src/toolDocs.ts
CHANGED
|
@@ -173,6 +173,26 @@ export const TOOL_DOCS: Record<string, ToolDoc> = {
|
|
|
173
173
|
"modifiers": "Modifier keys held for this one event, e.g. ['Shift']."
|
|
174
174
|
}
|
|
175
175
|
},
|
|
176
|
+
"focus_emulation": {
|
|
177
|
+
"description": "Toggles CDP Emulation.setFocusEmulationEnabled on a tab: the page then BELIEVES it is focused (document.hasFocus()→true, visibilitychange stays 'visible', focus events fire) while the real OS window and active tab stay put — no focus theft. This is the primitive that unlocks focus-GATED UI (a button the site disables unless its tab is focused, e.g. Anthropic's Claude Code OAuth Authorize button on claude.ai/oauth/*) on a background tab. Chrome-only: WebDriver BiDi's emulation module has no focus primitive, so this tool is absent from tools/list under --browser firefox. Ports the core mechanism of the danbuhler/claude-code-auto-authorize extension. Remember to call with enabled:false to restore, or use click_focus_gated which restores automatically.",
|
|
178
|
+
"params": {
|
|
179
|
+
"lease": "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
|
|
180
|
+
"target": "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'.",
|
|
181
|
+
"enabled": "true = page reports focused/visible; false = restore the real focus state."
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
"click_focus_gated": {
|
|
185
|
+
"description": "One-call compose of focus emulation + trusted click for a focus-gated button: enables Emulation.setFocusEmulationEnabled, polls until the button (located by CSS selector OR exact visible-text match, e.g. 'Authorize') exists AND is enabled, dispatches a trusted move/press/release click via Input.dispatchMouseEvent (isTrusted:true — a content script's synthesized events can never satisfy a focus gate), then restores focus emulation off unless keepFocus. Built for the Claude Code OAuth Authorize button but works on any focus-gated control on a background tab. Throws with a diagnostic ('never located' vs 'found but stayed disabled') on timeout. Chrome-only; absent under --browser firefox.",
|
|
186
|
+
"params": {
|
|
187
|
+
"lease": "Lease token from claim_page. Omit for a tab this process holds. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease).",
|
|
188
|
+
"target": "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'.",
|
|
189
|
+
"selector": "CSS selector of the button. Give exactly one of selector or text.",
|
|
190
|
+
"text": "Exact visible-text match of the button (trims whitespace, matches button/[role=button]/submit inputs), e.g. 'Authorize'. Give exactly one of selector or text.",
|
|
191
|
+
"timeoutMs": "Max ms to poll for the button to appear and become enabled. Default 30000.",
|
|
192
|
+
"pollMs": "Poll interval in ms. Default 500.",
|
|
193
|
+
"keepFocus": "Leave focus emulation ON after the click (default false — restored even on throw, so a failed click never strands the page fake-focused)."
|
|
194
|
+
}
|
|
195
|
+
},
|
|
176
196
|
"wait_for_download": {
|
|
177
197
|
"description": "Wait for a file download to finish and return it as a real file on disk: {path,suggestedFilename,bytes,url,target}, written under the artifact dir's downloads/. ORDERING RULE, not optional: capture must be ARMED BEFORE the click that starts the download — call wait_for_download{arm:true} first (returns {armed:true,downloadPath,pending}), then click, then call again to collect the file. The download-behavior override is per-connection state Chrome REVERTS the moment the arming client disconnects, and an unarmed headless Chrome denies the download outright, so one triggered before arming is lost. SIDE EFFECT, browser-global: arming redirects EVERY download in this browser (all tabs, all origins) into the toolkit's downloads dir until this server exits — they no longer land in the user's normal Downloads folder. The arm lives on a connection this server holds open (an MCP-server capability): under the one-shot CLI the connection dies with the process and nothing is captured. Chrome-only (capability 'browser.downloads'): absent from tools/list under Firefox.",
|
|
178
198
|
"params": {
|
package/src/toolGroups.ts
CHANGED
|
@@ -14,7 +14,7 @@ export const GROUP_TOOLS: Record<ToolGroup, readonly string[]> = {
|
|
|
14
14
|
],
|
|
15
15
|
input: [
|
|
16
16
|
"hover", "drag", "scroll", "dispatch_mouse", "press_key", "fill_form",
|
|
17
|
-
"upload_file",
|
|
17
|
+
"upload_file", "focus_emulation", "click_focus_gated",
|
|
18
18
|
],
|
|
19
19
|
cookies: ["list_cookies", "set_cookie", "delete_cookies"],
|
|
20
20
|
network: ["list_network_requests", "get_network_request"],
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* focus-emulation.ts: `focus_emulation` + `click_focus_gated` — the Chrome CDP primitive behind
|
|
3
|
+
* driving a focus-gated page (a button the site disables unless the tab is focused) WITHOUT
|
|
4
|
+
* stealing OS window focus.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS (the mechanism it ports)
|
|
7
|
+
* ========================================
|
|
8
|
+
* Anthropic's Claude Code OAuth page (claude.ai/oauth/*) gates its Authorize button behind
|
|
9
|
+
* document.hasFocus()/visibilitychange: a background tab reports hasFocus()===false and the
|
|
10
|
+
* button stays disabled, so an agent driving claude -p's login has to keep a human clicking.
|
|
11
|
+
* The danbuhler/claude-code-auto-authorize Chrome extension defeats it with two CDP primitives:
|
|
12
|
+
* 1. Emulation.setFocusEmulationEnabled {enabled:true} — the page BELIEVES it is focused
|
|
13
|
+
* (hasFocus()→true, visibilitychange stays "visible", focus events fire) while the real
|
|
14
|
+
* OS window stays put; and
|
|
15
|
+
* 2. Input.dispatchMouseEvent — a TRUSTED click (isTrusted:true) on that background tab,
|
|
16
|
+
* which a content script's synthesized events (isTrusted:false) can never produce.
|
|
17
|
+
* This module is that pair as toolkit tools: focus_emulation is the raw toggle;
|
|
18
|
+
* click_focus_gated composes toggle → locate-button → trusted-click → restore.
|
|
19
|
+
*
|
|
20
|
+
* WHY CHROME-ONLY (capability "emulate.focus", declared in ../driver.ts /
|
|
21
|
+
* ../cdp/driver.ts): WebDriver BiDi has no focus-emulation module (the emulation module covers
|
|
22
|
+
* geolocation/timezone/locale/scripting only), so under --browser firefox BOTH tools are absent
|
|
23
|
+
* from tools/list (ADR-001: absent, never present-and-throwing) — like dispatch_mouse and
|
|
24
|
+
* start_screen_recording.
|
|
25
|
+
*
|
|
26
|
+
* Lease gate: withPage (client.ts) → resolveTarget → assertLeaseOk("chrome", ...), the same
|
|
27
|
+
* choke point as every input tool.
|
|
28
|
+
*/
|
|
29
|
+
import { sendInput } from "../activity.ts";
|
|
30
|
+
import { withPage } from "../client.ts";
|
|
31
|
+
import type { TargetSelector } from "../types.ts";
|
|
32
|
+
|
|
33
|
+
export interface FocusEmulationArgs {
|
|
34
|
+
target?: TargetSelector;
|
|
35
|
+
lease?: string;
|
|
36
|
+
/** true → the page reports focused; false → restore real focus state. */
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FocusEmulationResult {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ClickFocusGatedArgs {
|
|
45
|
+
target?: TargetSelector;
|
|
46
|
+
lease?: string;
|
|
47
|
+
/** CSS selector of the button to click once focus is emulated. Exactly one of selector/text. */
|
|
48
|
+
selector?: string;
|
|
49
|
+
/** Visible-text match for the button (e.g. "Authorize"); exact-trim match, case-sensitive. */
|
|
50
|
+
text?: string;
|
|
51
|
+
/** Max ms to poll for the button to exist AND become enabled. Default 30000. */
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
/** Poll interval ms. Default 500. */
|
|
54
|
+
pollMs?: number;
|
|
55
|
+
/** Leave focus emulation ON after the click (default false — always restored). */
|
|
56
|
+
keepFocus?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ClickFocusGatedResult {
|
|
60
|
+
clicked: boolean;
|
|
61
|
+
x: number;
|
|
62
|
+
y: number;
|
|
63
|
+
/** How the button was located. */
|
|
64
|
+
matchedBy: "selector" | "text";
|
|
65
|
+
/** ms from call to click. */
|
|
66
|
+
elapsedMs: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validateFocusEmulationArgs(args: FocusEmulationArgs): void {
|
|
70
|
+
if (typeof args.enabled !== "boolean") {
|
|
71
|
+
throw new Error("focus_emulation requires { enabled } of true or false");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function validateClickFocusGatedArgs(args: ClickFocusGatedArgs): void {
|
|
76
|
+
const hasSel = typeof args.selector === "string" && args.selector.length > 0;
|
|
77
|
+
const hasText = typeof args.text === "string" && args.text.length > 0;
|
|
78
|
+
if (hasSel === hasText) {
|
|
79
|
+
throw new Error("click_focus_gated requires exactly one of { selector } or { text }");
|
|
80
|
+
}
|
|
81
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
82
|
+
throw new Error("click_focus_gated: timeoutMs must be a positive number");
|
|
83
|
+
}
|
|
84
|
+
if (args.pollMs !== undefined && (!Number.isFinite(args.pollMs) || args.pollMs <= 0)) {
|
|
85
|
+
throw new Error("click_focus_gated: pollMs must be a positive number");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const FIND_BUTTON = `(() => {
|
|
90
|
+
const SEL = %SEL%;
|
|
91
|
+
const TEXT = %TEXT%;
|
|
92
|
+
let btn = null;
|
|
93
|
+
let matchedBy = null;
|
|
94
|
+
if (SEL) {
|
|
95
|
+
btn = document.querySelector(SEL);
|
|
96
|
+
if (btn) matchedBy = "selector";
|
|
97
|
+
}
|
|
98
|
+
if (!btn && TEXT) {
|
|
99
|
+
btn = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]"))
|
|
100
|
+
.find((b) => (b.textContent || b.value || "").trim() === TEXT);
|
|
101
|
+
if (btn) matchedBy = "text";
|
|
102
|
+
}
|
|
103
|
+
if (!btn) return null;
|
|
104
|
+
const r = btn.getBoundingClientRect();
|
|
105
|
+
if (r.width === 0 || r.height === 0) return null;
|
|
106
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2,
|
|
107
|
+
disabled: !!btn.disabled || btn.getAttribute("aria-disabled") === "true",
|
|
108
|
+
matchedBy, hasFocus: document.hasFocus(), visibility: document.visibilityState };
|
|
109
|
+
})()`;
|
|
110
|
+
|
|
111
|
+
function findButtonExpression(selector: string | undefined, text: string | undefined): string {
|
|
112
|
+
return FIND_BUTTON.replace("%SEL%", JSON.stringify(selector ?? null)).replace(
|
|
113
|
+
"%TEXT%",
|
|
114
|
+
JSON.stringify(text ?? null),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Toggle page focus emulation. The page reports focused/visible without the OS window moving. */
|
|
119
|
+
export async function focusEmulation(args: FocusEmulationArgs): Promise<FocusEmulationResult> {
|
|
120
|
+
validateFocusEmulationArgs(args);
|
|
121
|
+
return withPage(
|
|
122
|
+
args.target,
|
|
123
|
+
async (conn) => {
|
|
124
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: args.enabled });
|
|
125
|
+
return { enabled: args.enabled };
|
|
126
|
+
},
|
|
127
|
+
{ lease: args.lease },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Emulate focus, poll for a focus-gated button to become enabled, trusted-click it, restore.
|
|
133
|
+
* The restore runs in `finally` unless keepFocus — a thrown click never strands the page in a
|
|
134
|
+
* fake-focused state.
|
|
135
|
+
*/
|
|
136
|
+
export async function clickFocusGated(args: ClickFocusGatedArgs): Promise<ClickFocusGatedResult> {
|
|
137
|
+
validateClickFocusGatedArgs(args);
|
|
138
|
+
const timeoutMs = args.timeoutMs ?? 30_000;
|
|
139
|
+
const pollMs = args.pollMs ?? 500;
|
|
140
|
+
const start = Date.now();
|
|
141
|
+
return withPage(
|
|
142
|
+
args.target,
|
|
143
|
+
async (conn, target) => {
|
|
144
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: true });
|
|
145
|
+
try {
|
|
146
|
+
const expression = findButtonExpression(args.selector, args.text);
|
|
147
|
+
for (;;) {
|
|
148
|
+
const { result } = await conn.send<{ result: { value?: unknown } }>("Runtime.evaluate", {
|
|
149
|
+
expression,
|
|
150
|
+
returnByValue: true,
|
|
151
|
+
});
|
|
152
|
+
const btn = (result as { value?: unknown } | undefined)?.value as
|
|
153
|
+
| { x: number; y: number; disabled: boolean; matchedBy: "selector" | "text" }
|
|
154
|
+
| null
|
|
155
|
+
| undefined;
|
|
156
|
+
if (btn && !btn.disabled) {
|
|
157
|
+
const base = { x: btn.x, y: btn.y, button: "left", buttons: 1, clickCount: 1 };
|
|
158
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { type: "mouseMoved", x: btn.x, y: btn.y });
|
|
159
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mousePressed" });
|
|
160
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mouseReleased" });
|
|
161
|
+
return {
|
|
162
|
+
clicked: true,
|
|
163
|
+
x: btn.x,
|
|
164
|
+
y: btn.y,
|
|
165
|
+
matchedBy: btn.matchedBy,
|
|
166
|
+
elapsedMs: Date.now() - start,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (Date.now() - start >= timeoutMs) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`click_focus_gated: button never became clickable within ${timeoutMs}ms` +
|
|
172
|
+
(btn ? " (found but stayed disabled — focus emulation may not satisfy this gate)" : " (never located)"),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
if (!args.keepFocus) {
|
|
179
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: false }).catch(() => {});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
{ lease: args.lease, timeoutMs: timeoutMs + 5_000 },
|
|
184
|
+
);
|
|
185
|
+
}
|
package/src/version.ts
CHANGED