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 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 45-tool surface costs 1,141 characters:
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.2.0 · browser=chrome · 45 tools available, 46 in tools/list (CDP_TOOL_PROFILE=full)
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 (7): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file
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 | 46 | 33,886 | ≈8,471 |
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 |
@@ -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.2.0";
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.2.0",
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
@@ -6,4 +6,4 @@
6
6
  * ready line; keeping it in its own module means a release bump touches one
7
7
  * line here plus package.json, not a const buried in the server.
8
8
  */
9
- export const VERSION = "2.2.0";
9
+ export const VERSION = "2.3.0";