cdp-toolkit 2.2.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 +11 -0
- package/README.md +4 -4
- package/dist/bidi/mux-daemon.js +2 -0
- package/dist/cli.js +162 -0
- package/dist/index.js +98 -0
- package/dist/mcp.js +186 -2
- package/package.json +3 -2
- package/src/cdp/driver.ts +1 -1
- package/src/driver.ts +3 -0
- package/src/index.ts +6 -0
- package/src/manifest.ts +64 -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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to cdp-toolkit are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.3.0] - 2026-09-10
|
|
9
|
+
|
|
10
|
+
**Drive focus-gated pages on a background tab without stealing OS window focus.** Some sites gate a button behind `document.hasFocus()`/`visibilitychange` — the canonical case is Anthropic's Claude Code OAuth page (`claude.ai/oauth/*`), whose **Authorize** button stays disabled on a background tab, forcing an agent driving `claude -p` login to keep a human clicking. Two new Chrome-only tools port the mechanism of the [`danbuhler/claude-code-auto-authorize`](https://github.com/danbuhler/claude-code-auto-authorize) extension: `Emulation.setFocusEmulationEnabled` makes the page *believe* it is focused (hasFocus→true, visibility stays `visible`, focus events fire) while the real window stays put, and `Input.dispatchMouseEvent` delivers the **trusted** click (`isTrusted:true`) that a content script's synthesized events can never produce.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`focus_emulation { enabled }`** — the raw toggle: a tab reports focused/visible with no OS focus change. Call with `enabled:false` to restore.
|
|
15
|
+
- **`click_focus_gated { selector|text, timeoutMs, pollMs, keepFocus }`** — the one-call compose: enable focus emulation, poll until the button (CSS selector or exact visible-text, e.g. `"Authorize"`) exists **and** is enabled, trusted-click it, restore emulation (always, unless `keepFocus`, even on throw). Timeout error names the failure mode ("never located" vs "found but stayed disabled").
|
|
16
|
+
- New capability **`emulate.focus`** (Chrome declares it; BiDi's emulation module has no focus primitive, so both tools are absent from `tools/list` under `--browser firefox`, per ADR-001).
|
|
17
|
+
- New live harness **`test/focus-emulation-smoke.ts`** (`bun run focus:smoke`): a real headless Chrome serves a focus-gated Authorize fixture (disabled until `hasFocus()`), proves the gate is closed in a background tab (control), then `click_focus_gated` opens it and lands a trusted click — page log shows `gate:unlocked` + `CLICKED trusted=true` with the tab never activated. Plus unit tests `test/focus-emulation.test.ts` (16) pinning validation, capability gating, and manifest/docs/group registration.
|
|
18
|
+
|
|
8
19
|
## [2.2.0] - 2026-09-04
|
|
9
20
|
|
|
10
21
|
**Several agent PROCESSES can now drive tabs of ONE user-launched Firefox at the same time.** Firefox serves exactly one WebDriver BiDi session per browser (measured on 153.0.3: a second `session.new` gets "Maximum number of active sessions", a second WebSocket to `/session/<id>` is refused at the HTTP upgrade). Previously a second cdp-toolkit process attaching to the same Firefox was refused after the session-slot wait; now the process that wins the slot fronts the one real session with a loopback BiDi multiplexer, and every other process joins it instead of waiting. **Measured** (`bun run firefox:multi:smoke`, headless Firefox 153.0.3, 4 agents × 6 rounds): before, 4 server processes attached to one Firefox — 1 of 4 completed, the other 3 refused after the session wait; after, all 4 completed concurrently (40 calls, wall 437ms vs serial 1375ms, 4 distinct processes in flight), a killed server process left 3 survivors finishing with 0 retries, and a killed daemon was replaced within the recovery path with every survivor finishing on its original tab via the stale-id hint (`firefox:coord:smoke` 11/11, `bun test` 929 pass). See [`docs/design/2026-09-04-firefox-multi-agent-session-mux.md`](docs/design/2026-09-04-firefox-multi-agent-session-mux.md).
|
package/README.md
CHANGED
|
@@ -245,12 +245,12 @@ That shape is deliberate. The MCP **2026-07-28** revision puts lazy discovery on
|
|
|
245
245
|
{"tool": "describe_tool", "arguments": {}}
|
|
246
246
|
```
|
|
247
247
|
|
|
248
|
-
With no `name` it returns the grouped catalog of everything this server can run. The whole
|
|
248
|
+
With no `name` it returns the grouped catalog of everything this server can run. The whole 47-tool surface:
|
|
249
249
|
|
|
250
250
|
```
|
|
251
|
-
cdp-toolkit 2.
|
|
251
|
+
cdp-toolkit 2.3.0 · browser=chrome · 47 tools available, 48 in tools/list (CDP_TOOL_PROFILE=full)
|
|
252
252
|
[listed] core (12): list_pages, new_page, close_page, select_page, navigate_page, wait_for, take_snapshot, click, fill, type_text, evaluate_script, take_screenshot
|
|
253
|
-
[listed] input (
|
|
253
|
+
[listed] input (9): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated
|
|
254
254
|
[listed] cookies (3): list_cookies, set_cookie, delete_cookies
|
|
255
255
|
[listed] network (2): list_network_requests, get_network_request
|
|
256
256
|
[listed] console (2): list_console_messages, get_console_message
|
|
@@ -271,7 +271,7 @@ Under a narrower profile the groups it leaves out read `[hidden]` instead of `[l
|
|
|
271
271
|
|
|
272
272
|
| `CDP_TOOL_PROFILE` | entries in `tools/list` | bytes | ≈ tokens |
|
|
273
273
|
|---|---|---|---|
|
|
274
|
-
| unset / `full` — the default |
|
|
274
|
+
| unset / `full` — the default | 48 | 35,556 | ≈8,889 |
|
|
275
275
|
| `core` | 13 | 9,066 | ≈2,266 |
|
|
276
276
|
| `core,network,console` | 17 | 11,893 | ≈2,973 |
|
|
277
277
|
| `full` under `CDP_BROWSER=firefox` | 35 | 26,533 | ≈6,633 |
|
package/dist/bidi/mux-daemon.js
CHANGED
|
@@ -498,6 +498,8 @@ var init_driver = __esm(() => {
|
|
|
498
498
|
list_mocks: ["network.intercept"],
|
|
499
499
|
clear_mocks: ["network.intercept"],
|
|
500
500
|
dispatch_mouse: ["input.raw"],
|
|
501
|
+
focus_emulation: ["emulate.focus"],
|
|
502
|
+
click_focus_gated: ["emulate.focus"],
|
|
501
503
|
wait_for_download: ["browser.downloads"],
|
|
502
504
|
grant_permissions: ["browser.permissions"]
|
|
503
505
|
};
|
package/dist/cli.js
CHANGED
|
@@ -1090,6 +1090,8 @@ var init_driver = __esm(() => {
|
|
|
1090
1090
|
list_mocks: ["network.intercept"],
|
|
1091
1091
|
clear_mocks: ["network.intercept"],
|
|
1092
1092
|
dispatch_mouse: ["input.raw"],
|
|
1093
|
+
focus_emulation: ["emulate.focus"],
|
|
1094
|
+
click_focus_gated: ["emulate.focus"],
|
|
1093
1095
|
wait_for_download: ["browser.downloads"],
|
|
1094
1096
|
grant_permissions: ["browser.permissions"]
|
|
1095
1097
|
};
|
|
@@ -4157,6 +4159,7 @@ var CDP_CAPABILITIES = new Set([
|
|
|
4157
4159
|
"capture.screencast",
|
|
4158
4160
|
"input.raw",
|
|
4159
4161
|
"input.html5Drag",
|
|
4162
|
+
"emulate.focus",
|
|
4160
4163
|
"browser.downloads",
|
|
4161
4164
|
"browser.permissions",
|
|
4162
4165
|
"worker.targets"
|
|
@@ -7577,6 +7580,99 @@ async function dispatchMouse(args) {
|
|
|
7577
7580
|
});
|
|
7578
7581
|
}
|
|
7579
7582
|
|
|
7583
|
+
// src/tools/focus-emulation.ts
|
|
7584
|
+
init_activity();
|
|
7585
|
+
init_client();
|
|
7586
|
+
function validateFocusEmulationArgs(args) {
|
|
7587
|
+
if (typeof args.enabled !== "boolean") {
|
|
7588
|
+
throw new Error("focus_emulation requires { enabled } of true or false");
|
|
7589
|
+
}
|
|
7590
|
+
}
|
|
7591
|
+
function validateClickFocusGatedArgs(args) {
|
|
7592
|
+
const hasSel = typeof args.selector === "string" && args.selector.length > 0;
|
|
7593
|
+
const hasText = typeof args.text === "string" && args.text.length > 0;
|
|
7594
|
+
if (hasSel === hasText) {
|
|
7595
|
+
throw new Error("click_focus_gated requires exactly one of { selector } or { text }");
|
|
7596
|
+
}
|
|
7597
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
7598
|
+
throw new Error("click_focus_gated: timeoutMs must be a positive number");
|
|
7599
|
+
}
|
|
7600
|
+
if (args.pollMs !== undefined && (!Number.isFinite(args.pollMs) || args.pollMs <= 0)) {
|
|
7601
|
+
throw new Error("click_focus_gated: pollMs must be a positive number");
|
|
7602
|
+
}
|
|
7603
|
+
}
|
|
7604
|
+
var FIND_BUTTON = `(() => {
|
|
7605
|
+
const SEL = %SEL%;
|
|
7606
|
+
const TEXT = %TEXT%;
|
|
7607
|
+
let btn = null;
|
|
7608
|
+
let matchedBy = null;
|
|
7609
|
+
if (SEL) {
|
|
7610
|
+
btn = document.querySelector(SEL);
|
|
7611
|
+
if (btn) matchedBy = "selector";
|
|
7612
|
+
}
|
|
7613
|
+
if (!btn && TEXT) {
|
|
7614
|
+
btn = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]"))
|
|
7615
|
+
.find((b) => (b.textContent || b.value || "").trim() === TEXT);
|
|
7616
|
+
if (btn) matchedBy = "text";
|
|
7617
|
+
}
|
|
7618
|
+
if (!btn) return null;
|
|
7619
|
+
const r = btn.getBoundingClientRect();
|
|
7620
|
+
if (r.width === 0 || r.height === 0) return null;
|
|
7621
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2,
|
|
7622
|
+
disabled: !!btn.disabled || btn.getAttribute("aria-disabled") === "true",
|
|
7623
|
+
matchedBy, hasFocus: document.hasFocus(), visibility: document.visibilityState };
|
|
7624
|
+
})()`;
|
|
7625
|
+
function findButtonExpression(selector, text) {
|
|
7626
|
+
return FIND_BUTTON.replace("%SEL%", JSON.stringify(selector ?? null)).replace("%TEXT%", JSON.stringify(text ?? null));
|
|
7627
|
+
}
|
|
7628
|
+
async function focusEmulation(args) {
|
|
7629
|
+
validateFocusEmulationArgs(args);
|
|
7630
|
+
return withPage(args.target, async (conn) => {
|
|
7631
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: args.enabled });
|
|
7632
|
+
return { enabled: args.enabled };
|
|
7633
|
+
}, { lease: args.lease });
|
|
7634
|
+
}
|
|
7635
|
+
async function clickFocusGated(args) {
|
|
7636
|
+
validateClickFocusGatedArgs(args);
|
|
7637
|
+
const timeoutMs = args.timeoutMs ?? 30000;
|
|
7638
|
+
const pollMs = args.pollMs ?? 500;
|
|
7639
|
+
const start = Date.now();
|
|
7640
|
+
return withPage(args.target, async (conn, target) => {
|
|
7641
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: true });
|
|
7642
|
+
try {
|
|
7643
|
+
const expression = findButtonExpression(args.selector, args.text);
|
|
7644
|
+
for (;; ) {
|
|
7645
|
+
const { result } = await conn.send("Runtime.evaluate", {
|
|
7646
|
+
expression,
|
|
7647
|
+
returnByValue: true
|
|
7648
|
+
});
|
|
7649
|
+
const btn = result?.value;
|
|
7650
|
+
if (btn && !btn.disabled) {
|
|
7651
|
+
const base = { x: btn.x, y: btn.y, button: "left", buttons: 1, clickCount: 1 };
|
|
7652
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { type: "mouseMoved", x: btn.x, y: btn.y });
|
|
7653
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mousePressed" });
|
|
7654
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mouseReleased" });
|
|
7655
|
+
return {
|
|
7656
|
+
clicked: true,
|
|
7657
|
+
x: btn.x,
|
|
7658
|
+
y: btn.y,
|
|
7659
|
+
matchedBy: btn.matchedBy,
|
|
7660
|
+
elapsedMs: Date.now() - start
|
|
7661
|
+
};
|
|
7662
|
+
}
|
|
7663
|
+
if (Date.now() - start >= timeoutMs) {
|
|
7664
|
+
throw new Error(`click_focus_gated: button never became clickable within ${timeoutMs}ms` + (btn ? " (found but stayed disabled — focus emulation may not satisfy this gate)" : " (never located)"));
|
|
7665
|
+
}
|
|
7666
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
7667
|
+
}
|
|
7668
|
+
} finally {
|
|
7669
|
+
if (!args.keepFocus) {
|
|
7670
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: false }).catch(() => {});
|
|
7671
|
+
}
|
|
7672
|
+
}
|
|
7673
|
+
}, { lease: args.lease, timeoutMs: timeoutMs + 5000 });
|
|
7674
|
+
}
|
|
7675
|
+
|
|
7580
7676
|
// src/tools/downloads.ts
|
|
7581
7677
|
init_client();
|
|
7582
7678
|
import { readdir as readdir3, rename as rename2, stat as stat4 } from "node:fs/promises";
|
|
@@ -7852,6 +7948,8 @@ var TOOLS = {
|
|
|
7852
7948
|
list_mocks: listMocks,
|
|
7853
7949
|
clear_mocks: clearMocks,
|
|
7854
7950
|
dispatch_mouse: dispatchMouse,
|
|
7951
|
+
focus_emulation: focusEmulation,
|
|
7952
|
+
click_focus_gated: clickFocusGated,
|
|
7855
7953
|
wait_for_download: waitForDownload,
|
|
7856
7954
|
grant_permissions: grantPermissions,
|
|
7857
7955
|
claim_page: onCdp(LEASE_TOOLS.claim_page),
|
|
@@ -8983,6 +9081,70 @@ var MANIFEST = [
|
|
|
8983
9081
|
additionalProperties: false
|
|
8984
9082
|
}
|
|
8985
9083
|
},
|
|
9084
|
+
{
|
|
9085
|
+
name: "focus_emulation",
|
|
9086
|
+
description: "Toggle page focus emulation: the page reports focused/visible without the OS window moving; Chrome-only.",
|
|
9087
|
+
inputSchema: {
|
|
9088
|
+
type: "object",
|
|
9089
|
+
properties: {
|
|
9090
|
+
lease: {
|
|
9091
|
+
type: "string",
|
|
9092
|
+
description: "Lease token; omit for a tab this process already holds. See server instructions."
|
|
9093
|
+
},
|
|
9094
|
+
target: {
|
|
9095
|
+
type: "string",
|
|
9096
|
+
description: "Page selector (index:/url:/title:/label:/targetId, default active); grammar in server instructions."
|
|
9097
|
+
},
|
|
9098
|
+
enabled: {
|
|
9099
|
+
type: "boolean",
|
|
9100
|
+
description: "true = page reports focused; false = restore real focus state."
|
|
9101
|
+
}
|
|
9102
|
+
},
|
|
9103
|
+
required: [
|
|
9104
|
+
"enabled"
|
|
9105
|
+
],
|
|
9106
|
+
additionalProperties: false
|
|
9107
|
+
}
|
|
9108
|
+
},
|
|
9109
|
+
{
|
|
9110
|
+
name: "click_focus_gated",
|
|
9111
|
+
description: "Emulate page focus, wait for a focus-gated button to enable, trusted-click it, restore; Chrome-only.",
|
|
9112
|
+
inputSchema: {
|
|
9113
|
+
type: "object",
|
|
9114
|
+
properties: {
|
|
9115
|
+
lease: {
|
|
9116
|
+
type: "string",
|
|
9117
|
+
description: "Lease token; omit for a tab this process already holds. See server instructions."
|
|
9118
|
+
},
|
|
9119
|
+
target: {
|
|
9120
|
+
type: "string",
|
|
9121
|
+
description: "Page selector (index:/url:/title:/label:/targetId, default active); grammar in server instructions."
|
|
9122
|
+
},
|
|
9123
|
+
selector: {
|
|
9124
|
+
type: "string",
|
|
9125
|
+
description: "CSS selector of the button; exactly one of selector or text."
|
|
9126
|
+
},
|
|
9127
|
+
text: {
|
|
9128
|
+
type: "string",
|
|
9129
|
+
description: 'Visible-text match for the button (e.g. "Authorize"); exactly one of selector or text.'
|
|
9130
|
+
},
|
|
9131
|
+
timeoutMs: {
|
|
9132
|
+
type: "number",
|
|
9133
|
+
description: "Max ms to poll for the button to become enabled. Default 30000."
|
|
9134
|
+
},
|
|
9135
|
+
pollMs: {
|
|
9136
|
+
type: "number",
|
|
9137
|
+
description: "Poll interval ms. Default 500."
|
|
9138
|
+
},
|
|
9139
|
+
keepFocus: {
|
|
9140
|
+
type: "boolean",
|
|
9141
|
+
description: "Leave focus emulation on after the click (default false = restored)."
|
|
9142
|
+
}
|
|
9143
|
+
},
|
|
9144
|
+
required: [],
|
|
9145
|
+
additionalProperties: false
|
|
9146
|
+
}
|
|
9147
|
+
},
|
|
8986
9148
|
{
|
|
8987
9149
|
name: "wait_for_download",
|
|
8988
9150
|
description: "Arm or collect a browser download; arm capture before triggering the download.",
|
package/dist/index.js
CHANGED
|
@@ -1089,6 +1089,8 @@ var init_driver = __esm(() => {
|
|
|
1089
1089
|
list_mocks: ["network.intercept"],
|
|
1090
1090
|
clear_mocks: ["network.intercept"],
|
|
1091
1091
|
dispatch_mouse: ["input.raw"],
|
|
1092
|
+
focus_emulation: ["emulate.focus"],
|
|
1093
|
+
click_focus_gated: ["emulate.focus"],
|
|
1092
1094
|
wait_for_download: ["browser.downloads"],
|
|
1093
1095
|
grant_permissions: ["browser.permissions"]
|
|
1094
1096
|
};
|
|
@@ -1559,6 +1561,7 @@ var CDP_CAPABILITIES = new Set([
|
|
|
1559
1561
|
"capture.screencast",
|
|
1560
1562
|
"input.raw",
|
|
1561
1563
|
"input.html5Drag",
|
|
1564
|
+
"emulate.focus",
|
|
1562
1565
|
"browser.downloads",
|
|
1563
1566
|
"browser.permissions",
|
|
1564
1567
|
"worker.targets"
|
|
@@ -4979,6 +4982,99 @@ async function dispatchMouse(args) {
|
|
|
4979
4982
|
});
|
|
4980
4983
|
}
|
|
4981
4984
|
|
|
4985
|
+
// src/tools/focus-emulation.ts
|
|
4986
|
+
init_activity();
|
|
4987
|
+
init_client();
|
|
4988
|
+
function validateFocusEmulationArgs(args) {
|
|
4989
|
+
if (typeof args.enabled !== "boolean") {
|
|
4990
|
+
throw new Error("focus_emulation requires { enabled } of true or false");
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4993
|
+
function validateClickFocusGatedArgs(args) {
|
|
4994
|
+
const hasSel = typeof args.selector === "string" && args.selector.length > 0;
|
|
4995
|
+
const hasText = typeof args.text === "string" && args.text.length > 0;
|
|
4996
|
+
if (hasSel === hasText) {
|
|
4997
|
+
throw new Error("click_focus_gated requires exactly one of { selector } or { text }");
|
|
4998
|
+
}
|
|
4999
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
5000
|
+
throw new Error("click_focus_gated: timeoutMs must be a positive number");
|
|
5001
|
+
}
|
|
5002
|
+
if (args.pollMs !== undefined && (!Number.isFinite(args.pollMs) || args.pollMs <= 0)) {
|
|
5003
|
+
throw new Error("click_focus_gated: pollMs must be a positive number");
|
|
5004
|
+
}
|
|
5005
|
+
}
|
|
5006
|
+
var FIND_BUTTON = `(() => {
|
|
5007
|
+
const SEL = %SEL%;
|
|
5008
|
+
const TEXT = %TEXT%;
|
|
5009
|
+
let btn = null;
|
|
5010
|
+
let matchedBy = null;
|
|
5011
|
+
if (SEL) {
|
|
5012
|
+
btn = document.querySelector(SEL);
|
|
5013
|
+
if (btn) matchedBy = "selector";
|
|
5014
|
+
}
|
|
5015
|
+
if (!btn && TEXT) {
|
|
5016
|
+
btn = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]"))
|
|
5017
|
+
.find((b) => (b.textContent || b.value || "").trim() === TEXT);
|
|
5018
|
+
if (btn) matchedBy = "text";
|
|
5019
|
+
}
|
|
5020
|
+
if (!btn) return null;
|
|
5021
|
+
const r = btn.getBoundingClientRect();
|
|
5022
|
+
if (r.width === 0 || r.height === 0) return null;
|
|
5023
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2,
|
|
5024
|
+
disabled: !!btn.disabled || btn.getAttribute("aria-disabled") === "true",
|
|
5025
|
+
matchedBy, hasFocus: document.hasFocus(), visibility: document.visibilityState };
|
|
5026
|
+
})()`;
|
|
5027
|
+
function findButtonExpression(selector, text) {
|
|
5028
|
+
return FIND_BUTTON.replace("%SEL%", JSON.stringify(selector ?? null)).replace("%TEXT%", JSON.stringify(text ?? null));
|
|
5029
|
+
}
|
|
5030
|
+
async function focusEmulation(args) {
|
|
5031
|
+
validateFocusEmulationArgs(args);
|
|
5032
|
+
return withPage(args.target, async (conn) => {
|
|
5033
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: args.enabled });
|
|
5034
|
+
return { enabled: args.enabled };
|
|
5035
|
+
}, { lease: args.lease });
|
|
5036
|
+
}
|
|
5037
|
+
async function clickFocusGated(args) {
|
|
5038
|
+
validateClickFocusGatedArgs(args);
|
|
5039
|
+
const timeoutMs = args.timeoutMs ?? 30000;
|
|
5040
|
+
const pollMs = args.pollMs ?? 500;
|
|
5041
|
+
const start = Date.now();
|
|
5042
|
+
return withPage(args.target, async (conn, target) => {
|
|
5043
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: true });
|
|
5044
|
+
try {
|
|
5045
|
+
const expression = findButtonExpression(args.selector, args.text);
|
|
5046
|
+
for (;; ) {
|
|
5047
|
+
const { result } = await conn.send("Runtime.evaluate", {
|
|
5048
|
+
expression,
|
|
5049
|
+
returnByValue: true
|
|
5050
|
+
});
|
|
5051
|
+
const btn = result?.value;
|
|
5052
|
+
if (btn && !btn.disabled) {
|
|
5053
|
+
const base = { x: btn.x, y: btn.y, button: "left", buttons: 1, clickCount: 1 };
|
|
5054
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { type: "mouseMoved", x: btn.x, y: btn.y });
|
|
5055
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mousePressed" });
|
|
5056
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mouseReleased" });
|
|
5057
|
+
return {
|
|
5058
|
+
clicked: true,
|
|
5059
|
+
x: btn.x,
|
|
5060
|
+
y: btn.y,
|
|
5061
|
+
matchedBy: btn.matchedBy,
|
|
5062
|
+
elapsedMs: Date.now() - start
|
|
5063
|
+
};
|
|
5064
|
+
}
|
|
5065
|
+
if (Date.now() - start >= timeoutMs) {
|
|
5066
|
+
throw new Error(`click_focus_gated: button never became clickable within ${timeoutMs}ms` + (btn ? " (found but stayed disabled — focus emulation may not satisfy this gate)" : " (never located)"));
|
|
5067
|
+
}
|
|
5068
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
5069
|
+
}
|
|
5070
|
+
} finally {
|
|
5071
|
+
if (!args.keepFocus) {
|
|
5072
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: false }).catch(() => {});
|
|
5073
|
+
}
|
|
5074
|
+
}
|
|
5075
|
+
}, { lease: args.lease, timeoutMs: timeoutMs + 5000 });
|
|
5076
|
+
}
|
|
5077
|
+
|
|
4982
5078
|
// src/tools/downloads.ts
|
|
4983
5079
|
init_client();
|
|
4984
5080
|
import { readdir as readdir3, rename as rename2, stat as stat4 } from "node:fs/promises";
|
|
@@ -5254,6 +5350,8 @@ var TOOLS = {
|
|
|
5254
5350
|
list_mocks: listMocks,
|
|
5255
5351
|
clear_mocks: clearMocks,
|
|
5256
5352
|
dispatch_mouse: dispatchMouse,
|
|
5353
|
+
focus_emulation: focusEmulation,
|
|
5354
|
+
click_focus_gated: clickFocusGated,
|
|
5257
5355
|
wait_for_download: waitForDownload,
|
|
5258
5356
|
grant_permissions: grantPermissions,
|
|
5259
5357
|
claim_page: onCdp(LEASE_TOOLS.claim_page),
|
package/dist/mcp.js
CHANGED
|
@@ -1090,6 +1090,8 @@ var init_driver = __esm(() => {
|
|
|
1090
1090
|
list_mocks: ["network.intercept"],
|
|
1091
1091
|
clear_mocks: ["network.intercept"],
|
|
1092
1092
|
dispatch_mouse: ["input.raw"],
|
|
1093
|
+
focus_emulation: ["emulate.focus"],
|
|
1094
|
+
click_focus_gated: ["emulate.focus"],
|
|
1093
1095
|
wait_for_download: ["browser.downloads"],
|
|
1094
1096
|
grant_permissions: ["browser.permissions"]
|
|
1095
1097
|
};
|
|
@@ -4161,6 +4163,7 @@ var CDP_CAPABILITIES = new Set([
|
|
|
4161
4163
|
"capture.screencast",
|
|
4162
4164
|
"input.raw",
|
|
4163
4165
|
"input.html5Drag",
|
|
4166
|
+
"emulate.focus",
|
|
4164
4167
|
"browser.downloads",
|
|
4165
4168
|
"browser.permissions",
|
|
4166
4169
|
"worker.targets"
|
|
@@ -7581,6 +7584,99 @@ async function dispatchMouse(args) {
|
|
|
7581
7584
|
});
|
|
7582
7585
|
}
|
|
7583
7586
|
|
|
7587
|
+
// src/tools/focus-emulation.ts
|
|
7588
|
+
init_activity();
|
|
7589
|
+
init_client();
|
|
7590
|
+
function validateFocusEmulationArgs(args) {
|
|
7591
|
+
if (typeof args.enabled !== "boolean") {
|
|
7592
|
+
throw new Error("focus_emulation requires { enabled } of true or false");
|
|
7593
|
+
}
|
|
7594
|
+
}
|
|
7595
|
+
function validateClickFocusGatedArgs(args) {
|
|
7596
|
+
const hasSel = typeof args.selector === "string" && args.selector.length > 0;
|
|
7597
|
+
const hasText = typeof args.text === "string" && args.text.length > 0;
|
|
7598
|
+
if (hasSel === hasText) {
|
|
7599
|
+
throw new Error("click_focus_gated requires exactly one of { selector } or { text }");
|
|
7600
|
+
}
|
|
7601
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
7602
|
+
throw new Error("click_focus_gated: timeoutMs must be a positive number");
|
|
7603
|
+
}
|
|
7604
|
+
if (args.pollMs !== undefined && (!Number.isFinite(args.pollMs) || args.pollMs <= 0)) {
|
|
7605
|
+
throw new Error("click_focus_gated: pollMs must be a positive number");
|
|
7606
|
+
}
|
|
7607
|
+
}
|
|
7608
|
+
var FIND_BUTTON = `(() => {
|
|
7609
|
+
const SEL = %SEL%;
|
|
7610
|
+
const TEXT = %TEXT%;
|
|
7611
|
+
let btn = null;
|
|
7612
|
+
let matchedBy = null;
|
|
7613
|
+
if (SEL) {
|
|
7614
|
+
btn = document.querySelector(SEL);
|
|
7615
|
+
if (btn) matchedBy = "selector";
|
|
7616
|
+
}
|
|
7617
|
+
if (!btn && TEXT) {
|
|
7618
|
+
btn = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]"))
|
|
7619
|
+
.find((b) => (b.textContent || b.value || "").trim() === TEXT);
|
|
7620
|
+
if (btn) matchedBy = "text";
|
|
7621
|
+
}
|
|
7622
|
+
if (!btn) return null;
|
|
7623
|
+
const r = btn.getBoundingClientRect();
|
|
7624
|
+
if (r.width === 0 || r.height === 0) return null;
|
|
7625
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2,
|
|
7626
|
+
disabled: !!btn.disabled || btn.getAttribute("aria-disabled") === "true",
|
|
7627
|
+
matchedBy, hasFocus: document.hasFocus(), visibility: document.visibilityState };
|
|
7628
|
+
})()`;
|
|
7629
|
+
function findButtonExpression(selector, text) {
|
|
7630
|
+
return FIND_BUTTON.replace("%SEL%", JSON.stringify(selector ?? null)).replace("%TEXT%", JSON.stringify(text ?? null));
|
|
7631
|
+
}
|
|
7632
|
+
async function focusEmulation(args) {
|
|
7633
|
+
validateFocusEmulationArgs(args);
|
|
7634
|
+
return withPage(args.target, async (conn) => {
|
|
7635
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: args.enabled });
|
|
7636
|
+
return { enabled: args.enabled };
|
|
7637
|
+
}, { lease: args.lease });
|
|
7638
|
+
}
|
|
7639
|
+
async function clickFocusGated(args) {
|
|
7640
|
+
validateClickFocusGatedArgs(args);
|
|
7641
|
+
const timeoutMs = args.timeoutMs ?? 30000;
|
|
7642
|
+
const pollMs = args.pollMs ?? 500;
|
|
7643
|
+
const start = Date.now();
|
|
7644
|
+
return withPage(args.target, async (conn, target) => {
|
|
7645
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: true });
|
|
7646
|
+
try {
|
|
7647
|
+
const expression = findButtonExpression(args.selector, args.text);
|
|
7648
|
+
for (;; ) {
|
|
7649
|
+
const { result } = await conn.send("Runtime.evaluate", {
|
|
7650
|
+
expression,
|
|
7651
|
+
returnByValue: true
|
|
7652
|
+
});
|
|
7653
|
+
const btn = result?.value;
|
|
7654
|
+
if (btn && !btn.disabled) {
|
|
7655
|
+
const base = { x: btn.x, y: btn.y, button: "left", buttons: 1, clickCount: 1 };
|
|
7656
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { type: "mouseMoved", x: btn.x, y: btn.y });
|
|
7657
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mousePressed" });
|
|
7658
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mouseReleased" });
|
|
7659
|
+
return {
|
|
7660
|
+
clicked: true,
|
|
7661
|
+
x: btn.x,
|
|
7662
|
+
y: btn.y,
|
|
7663
|
+
matchedBy: btn.matchedBy,
|
|
7664
|
+
elapsedMs: Date.now() - start
|
|
7665
|
+
};
|
|
7666
|
+
}
|
|
7667
|
+
if (Date.now() - start >= timeoutMs) {
|
|
7668
|
+
throw new Error(`click_focus_gated: button never became clickable within ${timeoutMs}ms` + (btn ? " (found but stayed disabled — focus emulation may not satisfy this gate)" : " (never located)"));
|
|
7669
|
+
}
|
|
7670
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
7671
|
+
}
|
|
7672
|
+
} finally {
|
|
7673
|
+
if (!args.keepFocus) {
|
|
7674
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: false }).catch(() => {});
|
|
7675
|
+
}
|
|
7676
|
+
}
|
|
7677
|
+
}, { lease: args.lease, timeoutMs: timeoutMs + 5000 });
|
|
7678
|
+
}
|
|
7679
|
+
|
|
7584
7680
|
// src/tools/downloads.ts
|
|
7585
7681
|
init_client();
|
|
7586
7682
|
import { readdir as readdir3, rename as rename2, stat as stat4 } from "node:fs/promises";
|
|
@@ -7856,6 +7952,8 @@ var TOOLS = {
|
|
|
7856
7952
|
list_mocks: listMocks,
|
|
7857
7953
|
clear_mocks: clearMocks,
|
|
7858
7954
|
dispatch_mouse: dispatchMouse,
|
|
7955
|
+
focus_emulation: focusEmulation,
|
|
7956
|
+
click_focus_gated: clickFocusGated,
|
|
7859
7957
|
wait_for_download: waitForDownload,
|
|
7860
7958
|
grant_permissions: grantPermissions,
|
|
7861
7959
|
claim_page: onCdp(LEASE_TOOLS.claim_page),
|
|
@@ -8517,6 +8615,70 @@ var MANIFEST = [
|
|
|
8517
8615
|
additionalProperties: false
|
|
8518
8616
|
}
|
|
8519
8617
|
},
|
|
8618
|
+
{
|
|
8619
|
+
name: "focus_emulation",
|
|
8620
|
+
description: "Toggle page focus emulation: the page reports focused/visible without the OS window moving; Chrome-only.",
|
|
8621
|
+
inputSchema: {
|
|
8622
|
+
type: "object",
|
|
8623
|
+
properties: {
|
|
8624
|
+
lease: {
|
|
8625
|
+
type: "string",
|
|
8626
|
+
description: "Lease token; omit for a tab this process already holds. See server instructions."
|
|
8627
|
+
},
|
|
8628
|
+
target: {
|
|
8629
|
+
type: "string",
|
|
8630
|
+
description: "Page selector (index:/url:/title:/label:/targetId, default active); grammar in server instructions."
|
|
8631
|
+
},
|
|
8632
|
+
enabled: {
|
|
8633
|
+
type: "boolean",
|
|
8634
|
+
description: "true = page reports focused; false = restore real focus state."
|
|
8635
|
+
}
|
|
8636
|
+
},
|
|
8637
|
+
required: [
|
|
8638
|
+
"enabled"
|
|
8639
|
+
],
|
|
8640
|
+
additionalProperties: false
|
|
8641
|
+
}
|
|
8642
|
+
},
|
|
8643
|
+
{
|
|
8644
|
+
name: "click_focus_gated",
|
|
8645
|
+
description: "Emulate page focus, wait for a focus-gated button to enable, trusted-click it, restore; Chrome-only.",
|
|
8646
|
+
inputSchema: {
|
|
8647
|
+
type: "object",
|
|
8648
|
+
properties: {
|
|
8649
|
+
lease: {
|
|
8650
|
+
type: "string",
|
|
8651
|
+
description: "Lease token; omit for a tab this process already holds. See server instructions."
|
|
8652
|
+
},
|
|
8653
|
+
target: {
|
|
8654
|
+
type: "string",
|
|
8655
|
+
description: "Page selector (index:/url:/title:/label:/targetId, default active); grammar in server instructions."
|
|
8656
|
+
},
|
|
8657
|
+
selector: {
|
|
8658
|
+
type: "string",
|
|
8659
|
+
description: "CSS selector of the button; exactly one of selector or text."
|
|
8660
|
+
},
|
|
8661
|
+
text: {
|
|
8662
|
+
type: "string",
|
|
8663
|
+
description: 'Visible-text match for the button (e.g. "Authorize"); exactly one of selector or text.'
|
|
8664
|
+
},
|
|
8665
|
+
timeoutMs: {
|
|
8666
|
+
type: "number",
|
|
8667
|
+
description: "Max ms to poll for the button to become enabled. Default 30000."
|
|
8668
|
+
},
|
|
8669
|
+
pollMs: {
|
|
8670
|
+
type: "number",
|
|
8671
|
+
description: "Poll interval ms. Default 500."
|
|
8672
|
+
},
|
|
8673
|
+
keepFocus: {
|
|
8674
|
+
type: "boolean",
|
|
8675
|
+
description: "Leave focus emulation on after the click (default false = restored)."
|
|
8676
|
+
}
|
|
8677
|
+
},
|
|
8678
|
+
required: [],
|
|
8679
|
+
additionalProperties: false
|
|
8680
|
+
}
|
|
8681
|
+
},
|
|
8520
8682
|
{
|
|
8521
8683
|
name: "wait_for_download",
|
|
8522
8684
|
description: "Arm or collect a browser download; arm capture before triggering the download.",
|
|
@@ -10053,7 +10215,9 @@ var GROUP_TOOLS = {
|
|
|
10053
10215
|
"dispatch_mouse",
|
|
10054
10216
|
"press_key",
|
|
10055
10217
|
"fill_form",
|
|
10056
|
-
"upload_file"
|
|
10218
|
+
"upload_file",
|
|
10219
|
+
"focus_emulation",
|
|
10220
|
+
"click_focus_gated"
|
|
10057
10221
|
],
|
|
10058
10222
|
cookies: ["list_cookies", "set_cookie", "delete_cookies"],
|
|
10059
10223
|
network: ["list_network_requests", "get_network_request"],
|
|
@@ -10278,6 +10442,26 @@ var TOOL_DOCS = {
|
|
|
10278
10442
|
modifiers: "Modifier keys held for this one event, e.g. ['Shift']."
|
|
10279
10443
|
}
|
|
10280
10444
|
},
|
|
10445
|
+
focus_emulation: {
|
|
10446
|
+
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.",
|
|
10447
|
+
params: {
|
|
10448
|
+
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.",
|
|
10449
|
+
target: "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'.",
|
|
10450
|
+
enabled: "true = page reports focused/visible; false = restore the real focus state."
|
|
10451
|
+
}
|
|
10452
|
+
},
|
|
10453
|
+
click_focus_gated: {
|
|
10454
|
+
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.",
|
|
10455
|
+
params: {
|
|
10456
|
+
lease: "Lease token from claim_page. Omit for a tab this process holds. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease).",
|
|
10457
|
+
target: "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'.",
|
|
10458
|
+
selector: "CSS selector of the button. Give exactly one of selector or text.",
|
|
10459
|
+
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.",
|
|
10460
|
+
timeoutMs: "Max ms to poll for the button to appear and become enabled. Default 30000.",
|
|
10461
|
+
pollMs: "Poll interval in ms. Default 500.",
|
|
10462
|
+
keepFocus: "Leave focus emulation ON after the click (default false — restored even on throw, so a failed click never strands the page fake-focused)."
|
|
10463
|
+
}
|
|
10464
|
+
},
|
|
10281
10465
|
wait_for_download: {
|
|
10282
10466
|
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.",
|
|
10283
10467
|
params: {
|
|
@@ -10573,7 +10757,7 @@ var TOOL_DOCS = {
|
|
|
10573
10757
|
};
|
|
10574
10758
|
|
|
10575
10759
|
// src/version.ts
|
|
10576
|
-
var VERSION = "2.
|
|
10760
|
+
var VERSION = "2.3.0";
|
|
10577
10761
|
|
|
10578
10762
|
// src/mcp.ts
|
|
10579
10763
|
var { kind: BROWSER, endpoint: FIREFOX_ENDPOINT } = resolveBackend(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-toolkit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
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",
|
|
@@ -84,7 +84,8 @@
|
|
|
84
84
|
"lease:smoke": "bun run ./test/lease-smoke.ts",
|
|
85
85
|
"staleness:smoke": "bun run ./test/staleness-smoke.ts",
|
|
86
86
|
"input:smoke": "bun run ./test/input-smoke.ts",
|
|
87
|
-
"extension:smoke": "bun run ./test/extension-smoke.ts"
|
|
87
|
+
"extension:smoke": "bun run ./test/extension-smoke.ts",
|
|
88
|
+
"focus:smoke": "bun run ./test/focus-emulation-smoke.ts"
|
|
88
89
|
},
|
|
89
90
|
"devDependencies": {
|
|
90
91
|
"@modelcontextprotocol/client": "^2.0.0",
|
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
|
@@ -674,6 +674,70 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
674
674
|
"additionalProperties": false
|
|
675
675
|
}
|
|
676
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
|
+
},
|
|
677
741
|
{
|
|
678
742
|
"name": "wait_for_download",
|
|
679
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