// 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 ()"); 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 };