theseus/bundled-addons/claude-connector/lib/tab-driver.cjs
Local Dev 7f70779f1c Theseus 0.3.107: two-section home + Claude connector hardening
Home page splits into two self-contained panels so the left-column
links no longer scale with the Silent Mode cards. The slot grid is
narrower, the icons default closer to Windows desktop-shortcut size,
and each section carries its own subtle panel background and heading.

Claude connector hardening (the opt-in MCP add-on from 0.3.106):

- Pin the approved origin to the TabDriver on every Controller.use()
  and re-check the tab's current URL before each CDP read / input
  (isolated, snapshot, screenshot, clickAt, insertText, press,
  setFiles, evaluate). A meta-refresh or soft-nav to an unapproved
  origin between approval and the call now throws instead of leaking
  content or steering input to the new site.
- Rotate the bearer token on every stop() so toggling the connector
  off (toolbar chip, panel switch, deactivate) invalidates existing
  credentials. Re-enabling issues a fresh token; the panel's install
  strings update on the next start. Matches the mental model where
  "off" means "the keys change" instead of "the server stops".
- click without a ref now resolves the element at (x, y) through
  DOM.getNodeForLocation and refuses if it's a password / one-time-
  code field. Previously only ref-based click ran isSecretField; raw
  coordinates let focus land on a password field before FOCUS_GUARD
  kicked in to block typing.
2026-10-23 19:12:52 +03:00

360 lines
18 KiB
JavaScript

// One tab, driven through its own webContents.debugger (CDP 1.3). No
// browser-wide DevTools port is involved.
//
// Theseus itself attaches the same debugger to freeze background tabs, so a
// driver attaches only when nothing is attached and detaches only what it
// attached. Page reads run in an isolated world that page scripts cannot see
// or patch.
const { FOCUS_GUARD, isSecretField } = require("./policy.cjs");
const CMD_TIMEOUT_MS = 30000;
const WORLD = "theseus-claude-connector";
// Keys by DOM `key` name: code, Windows virtual key code, and the text a
// keyDown produces (none for non-printing keys).
const KEYS = {
Enter: { code: "Enter", vk: 13, text: "\r" },
Tab: { code: "Tab", vk: 9 },
Backspace: { code: "Backspace", vk: 8 },
Delete: { code: "Delete", vk: 46 },
Escape: { code: "Escape", vk: 27 },
ArrowLeft: { code: "ArrowLeft", vk: 37 },
ArrowUp: { code: "ArrowUp", vk: 38 },
ArrowRight: { code: "ArrowRight", vk: 39 },
ArrowDown: { code: "ArrowDown", vk: 40 },
Home: { code: "Home", vk: 36 },
End: { code: "End", vk: 35 },
PageUp: { code: "PageUp", vk: 33 },
PageDown: { code: "PageDown", vk: 34 },
" ": { code: "Space", vk: 32, text: " " },
};
const KEY_ALIASES = { enter: "Enter", return: "Enter", tab: "Tab", backspace: "Backspace", delete: "Delete", del: "Delete", escape: "Escape", esc: "Escape", left: "ArrowLeft", right: "ArrowRight", up: "ArrowUp", down: "ArrowDown", arrowleft: "ArrowLeft", arrowright: "ArrowRight", arrowup: "ArrowUp", arrowdown: "ArrowDown", home: "Home", end: "End", pageup: "PageUp", pagedown: "PageDown", space: " " };
const MOD_BITS = { alt: 1, ctrl: 2, control: 2, meta: 4, cmd: 4, shift: 8 };
// Editing shortcuts Chromium only honours from synthetic events when the
// command is named.
const KEY_COMMANDS = { "2:a": ["selectAll"], "2:z": ["undo"], "10:z": ["redo"], "2:y": ["redo"] };
function keyInfo(name) {
const k = KEYS[name] || KEYS[KEY_ALIASES[String(name).toLowerCase()]];
if (k) return { key: KEYS[name] ? name : KEY_ALIASES[String(name).toLowerCase()], ...k };
if ([...String(name)].length === 1) {
const ch = String(name);
const up = ch.toUpperCase();
const code = /[a-z]/i.test(ch) ? `Key${up}` : /[0-9]/.test(ch) ? `Digit${ch}` : "";
return { key: ch, code, vk: /[a-z0-9]/i.test(ch) ? up.charCodeAt(0) : 0, text: ch };
}
throw new Error(`unknown key: ${name}`);
}
// Interactive roles shown by snapshot({interactive_only: true}), plus
// headings so the client still sees where things are.
const INTERACTIVE = new Set(["button", "link", "textbox", "searchbox", "combobox", "checkbox", "radio", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "switch", "slider", "spinbutton", "option", "listbox", "treeitem", "heading"]);
const SKIP_ROLES = new Set(["none", "generic", "InlineTextBox", "LineBreak", "presentation", "LayoutTable", "LayoutTableRow", "LayoutTableCell"]);
class TabDriver {
constructor(tabId, wc, { onGone } = {}) {
this.tabId = tabId;
this.wc = wc;
this.attachedByUs = false;
this.ctx = null; // isolated world execution context id
this.refs = new Map(); // "e12" -> backendDOMNodeId
this.refSeq = 0;
this.onGone = onGone;
// Approved-origin pin: set once per Controller.use() so each read/input
// method can bail if the page redirected to a different origin between
// approval and the CDP call. See _checkOrigin.
this.expectedOrigin = null;
this._originOf = null;
this._onDetach = () => { this.attachedByUs = false; this.ctx = null; };
this._onDestroyed = () => { if (this.onGone) this.onGone(this.tabId); };
wc.debugger.on("detach", this._onDetach);
wc.once("destroyed", this._onDestroyed);
this._onNav = () => { this.ctx = null; this.refs.clear(); };
wc.on("did-navigate", this._onNav);
}
// Called by Controller.use() right after site approval. Each read/input
// method re-checks the current URL's origin against this before firing
// any CDP command, so a meta-refresh or soft-nav to an unapproved origin
// between approval and the call can't leak content or steer input.
setExpectedOrigin(origin, originOf) {
this.expectedOrigin = origin || null;
this._originOf = typeof originOf === "function" ? originOf : null;
}
_checkOrigin() {
if (!this.expectedOrigin || !this._originOf) return;
if (this.wc.isDestroyed()) throw new Error("the tab was closed");
const cur = this._originOf(this.wc.getURL());
if (cur !== this.expectedOrigin) {
throw new Error(`refused: the page navigated from ${this.expectedOrigin} to ${cur || "about:blank"}; call list_tabs/snapshot again — the new site needs its own approval`);
}
}
// Called before every use: the tab's webContents is the same object for
// the tab's life, but the debugger may have been detached (DevTools opened).
async ready() {
const dbg = this.wc.debugger;
if (!dbg.isAttached()) {
dbg.attach("1.3");
this.attachedByUs = true;
this.ctx = null;
}
if (!this._prepared || this._preparedFor !== this.attachedByUs) {
// Editors that check document.hasFocus() accept input while Theseus
// is in the background.
try { await this.send("Emulation.setFocusEmulationEnabled", { enabled: true }); } catch {}
this._prepared = true;
this._preparedFor = this.attachedByUs;
}
}
send(method, params = {}) {
const p = this.wc.debugger.sendCommand(method, params);
let t;
return Promise.race([p, new Promise((_, rej) => { t = setTimeout(() => rej(new Error(`${method} timed out`)), CMD_TIMEOUT_MS); })]).finally(() => clearTimeout(t));
}
async release() {
try { this.wc.debugger.removeListener("detach", this._onDetach); } catch {}
try { this.wc.removeListener("destroyed", this._onDestroyed); } catch {}
try { this.wc.removeListener("did-navigate", this._onNav); } catch {}
if (this.wc.isDestroyed()) return;
try { if (this.wc.debugger.isAttached()) await this.send("Emulation.setFocusEmulationEnabled", { enabled: false }); } catch {}
if (this.attachedByUs) { try { this.wc.debugger.detach(); } catch {} }
this.attachedByUs = false;
}
// ---- isolated world ----------------------------------------------------
async _context() {
if (this.ctx) return this.ctx;
const { frameTree } = await this.send("Page.getFrameTree");
const { executionContextId } = await this.send("Page.createIsolatedWorld", { frameId: frameTree.frame.id, worldName: WORLD, grantUniveralAccess: false });
this.ctx = executionContextId;
return this.ctx;
}
// Evaluate an expression in the isolated world; the value comes back as JSON.
async isolated(expression) {
this._checkOrigin();
for (let attempt = 0; attempt < 2; attempt++) {
const contextId = await this._context();
let r;
try {
r = await this.send("Runtime.evaluate", { expression, contextId, returnByValue: true, awaitPromise: true });
} catch (e) {
// The document changed under us: a new world on the new document.
if (/context|Cannot find|not found/i.test(e?.message || "") && attempt === 0) { this.ctx = null; continue; }
throw e;
}
if (r.exceptionDetails) {
const d = r.exceptionDetails;
throw new Error((d.exception && d.exception.description) || d.text || "script failed");
}
return r.result ? r.result.value : undefined;
}
throw new Error("page context unavailable");
}
// ---- reading -----------------------------------------------------------
async pageInfo() {
return this.isolated(`({ url: location.href, title: document.title, readyState: document.readyState })`);
}
async text(maxChars) {
const r = await this.isolated(`(() => { const b = document.body || document.documentElement; return { url: location.href, title: document.title, text: b ? b.innerText : "" }; })()`);
const n = Math.max(500, Math.min(Number(maxChars) || 40000, 400000));
const truncated = r.text.length > n;
return { ...r, text: truncated ? r.text.slice(0, n) : r.text, truncated, length: r.text.length };
}
async snapshot({ interactiveOnly = false, maxLines = 1500 } = {}) {
this._checkOrigin();
await this.send("Accessibility.enable").catch(() => {});
await this.send("DOM.enable").catch(() => {});
await this.send("DOM.getDocument", { depth: 0 }).catch(() => {});
const { nodes } = await this.send("Accessibility.getFullAXTree");
this.refs.clear();
this.refSeq = 0;
const byId = new Map(nodes.map((n) => [n.nodeId, n]));
const root = nodes.find((n) => !n.parentId) || nodes[0];
const lines = [];
let truncated = false;
const prop = (n, name) => { const p = (n.properties || []).find((x) => x.name === name); return p ? p.value && p.value.value : undefined; };
const walk = (n, depth, parentName) => {
if (!n || truncated) return;
const role = n.role && n.role.value;
const name = (n.name && n.name.value) || "";
const editable = prop(n, "editable");
const show = !n.ignored && role && !SKIP_ROLES.has(role) && !(role === "StaticText" && (interactiveOnly || name === parentName))
&& (!interactiveOnly || INTERACTIVE.has(role) || editable || prop(n, "focusable") && name);
let nextDepth = depth;
if (show) {
if (lines.length >= maxLines) { truncated = true; return; }
let line = `${" ".repeat(Math.min(depth, 30))}${role === "StaticText" ? "text" : role}`;
if (name) line += ` "${name.length > 160 ? name.slice(0, 160) + "…" : name}"`;
if (n.backendDOMNodeId && role !== "StaticText") {
const ref = `e${++this.refSeq}`;
this.refs.set(ref, n.backendDOMNodeId);
line += ` [ref=${ref}]`;
}
const flags = [];
if (editable) flags.push(`editable=${editable}`);
for (const f of ["focused", "checked", "disabled", "expanded", "selected", "required"]) { const v = prop(n, f); if (v !== undefined && v !== false && v !== "false") flags.push(v === true ? f : `${f}=${v}`); }
const level = prop(n, "level"); if (level) flags.push(`level=${level}`);
if (n.value && n.value.value !== undefined && n.value.value !== "") flags.push(`value="${String(n.value.value).slice(0, 80)}"`);
if (flags.length) line += ` (${flags.join(", ")})`;
lines.push(line);
nextDepth = depth + 1;
}
for (const c of n.childIds || []) walk(byId.get(c), nextDepth, show ? name : parentName);
};
walk(root, 0, "");
return { lines, truncated, refs: this.refSeq };
}
backendNode(ref) {
const id = this.refs.get(String(ref || ""));
if (!id) throw new Error(`unknown ref "${ref}"; take a new snapshot (refs reset on every snapshot and navigation)`);
return id;
}
async describe(backendNodeId) {
const { node } = await this.send("DOM.describeNode", { backendNodeId });
return node;
}
// ---- input -------------------------------------------------------------
async centerOf(backendNodeId) {
await this.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(() => {});
const { quads } = await this.send("DOM.getContentQuads", { backendNodeId });
const q = (quads || []).find((x) => Math.abs((x[2] - x[0]) * (x[5] - x[1])) > 1);
if (!q) throw new Error("the element has no visible box to click");
return { x: (q[0] + q[2] + q[4] + q[6]) / 4, y: (q[1] + q[3] + q[5] + q[7]) / 4 };
}
async clickAt(x, y, { button = "left", clickCount = 1 } = {}) {
this._checkOrigin();
const buttons = { left: 1, right: 2, middle: 4 }[button] || 1;
await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
for (let i = 1; i <= clickCount; i++) {
await this.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, buttons, clickCount: i });
await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, buttons: 0, clickCount: i });
}
}
async focus(backendNodeId) {
await this.send("DOM.focus", { backendNodeId });
}
async assertNotSecret(backendNodeId) {
if (isSecretField(await this.describe(backendNodeId))) throw new Error("refused: that element is a password field");
}
// Point-level secret check: for click(x, y) without a ref, resolve the
// element at the pointer and refuse if it's a password / one-time-code
// field. Falls back silently if the point lies on nothing we can resolve.
async assertNotSecretAt(x, y) {
this._checkOrigin();
await this.send("DOM.enable").catch(() => {});
let r;
try { r = await this.send("DOM.getNodeForLocation", { x: Math.round(x), y: Math.round(y), includeUserAgentShadowDOM: true }); } catch { return; }
if (!r || !r.backendNodeId) return;
let node;
try { node = await this.describe(r.backendNodeId); } catch { return; }
if (isSecretField(node)) throw new Error("refused: that point is on a password field");
}
// Throws if typing now would land in a password field.
async focusGuard() {
const g = await this.isolated(FOCUS_GUARD);
if (!g || !g.ok) throw new Error(`refused: ${(g && g.reason) || "could not check the focused element"}`);
return g;
}
async insertText(text) {
if (!text) return;
this._checkOrigin();
await this.send("Input.insertText", { text });
}
async press(name, modifiers = []) {
this._checkOrigin();
const k = keyInfo(name);
const mods = (Array.isArray(modifiers) ? modifiers : String(modifiers || "").split("+")).reduce((m, x) => m | (MOD_BITS[String(x).trim().toLowerCase()] || 0), 0);
const chord = (mods & (1 | 2 | 4)) !== 0;
let text = chord ? undefined : k.text;
if (text && mods & 8 && text.length === 1) text = text.toUpperCase();
const base = { key: text && text.length === 1 && !KEYS[k.key] ? text : k.key, code: k.code, windowsVirtualKeyCode: k.vk, nativeVirtualKeyCode: k.vk, modifiers: mods };
const commands = KEY_COMMANDS[`${mods}:${String(k.key).toLowerCase()}`];
await this.send("Input.dispatchKeyEvent", { type: text ? "keyDown" : "rawKeyDown", ...base, ...(text ? { text, unmodifiedText: text } : {}), ...(commands ? { commands } : {}) });
await this.send("Input.dispatchKeyEvent", { type: "keyUp", ...base });
}
// Text the way rich editors register it: each line as an IME-style insert,
// a real Enter key between lines. mode "keys" sends every character as its
// own key press instead (slower; for fields that only listen to keydown).
async type(text, { mode = "insert" } = {}) {
const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
for (let i = 0; i < lines.length; i++) {
if (mode === "keys") { for (const ch of lines[i]) await this.press(ch); }
else await this.insertText(lines[i]);
if (i < lines.length - 1) await this.press("Enter");
}
}
async setFiles(backendNodeId, files) {
this._checkOrigin();
const node = await this.describe(backendNodeId);
const attrs = {};
const a = node.attributes || [];
for (let i = 0; i + 1 < a.length; i += 2) attrs[String(a[i]).toLowerCase()] = String(a[i + 1]);
if (String(node.localName).toLowerCase() !== "input" || String(attrs.type || "").toLowerCase() !== "file") throw new Error("that ref is not a file input (<input type=file>)");
await this.send("DOM.setFileInputFiles", { files, backendNodeId });
}
// ---- screenshots -------------------------------------------------------
// Scaled to CSS pixels, so a point in the image is a point for click(x, y).
async screenshot({ fullPage = false, format } = {}) {
this._checkOrigin();
const dpr = Number(await this.isolated("window.devicePixelRatio")) || 1;
const m = await this.send("Page.getLayoutMetrics");
const vv = m.cssVisualViewport || m.visualViewport;
const fmt = format === "jpeg" || (fullPage && format !== "png") ? "jpeg" : "png";
let clip;
if (fullPage) {
const cs = m.cssContentSize || m.contentSize;
clip = { x: 0, y: 0, width: Math.ceil(cs.width), height: Math.min(Math.ceil(cs.height), 12000), scale: 1 / dpr };
} else {
clip = { x: vv.pageX, y: vv.pageY, width: Math.floor(vv.clientWidth), height: Math.floor(vv.clientHeight), scale: 1 / dpr };
}
const r = await this.send("Page.captureScreenshot", { format: fmt, ...(fmt === "jpeg" ? { quality: 75 } : {}), clip, captureBeyondViewport: fullPage, fromSurface: true });
return { data: r.data, mimeType: `image/${fmt}`, width: clip.width, height: clip.height, fullPage, truncated: fullPage && clip.height >= 12000 };
}
async waitFor({ selector, text, timeoutMs = 10000 }) {
const until = Date.now() + Math.max(100, Math.min(Number(timeoutMs) || 10000, 60000));
const probe = selector
? `(() => { const el = document.querySelector(${JSON.stringify(String(selector))}); if (!el) return false; const r = el.getBoundingClientRect(); return { visible: r.width > 0 && r.height > 0 }; })()`
: `(() => { const b = document.body; return !!b && b.innerText.includes(${JSON.stringify(String(text))}); })()`;
for (;;) {
let hit = false;
try { hit = await this.isolated(probe); } catch (e) { if (!/context/i.test(e?.message || "")) throw e; }
if (hit) return { found: true, ...(typeof hit === "object" ? hit : {}) };
if (Date.now() > until) return { found: false };
await new Promise((r) => setTimeout(r, 250));
}
}
async evaluate(expression) {
this._checkOrigin();
const r = await this.send("Runtime.evaluate", { expression: String(expression), returnByValue: true, awaitPromise: true, userGesture: true });
if (r.exceptionDetails) {
const d = r.exceptionDetails;
throw new Error((d.exception && d.exception.description) || d.text || "script failed");
}
return r.result;
}
}
module.exports = { TabDriver, keyInfo };