From c06557e9574efa10bbe9b36c142611e162f243f9 Mon Sep 17 00:00:00 2001 From: Dev Date: Fri, 23 Oct 2026 17:04:03 +0300 Subject: [PATCH] Theseus: Claude connector, an opt-in MCP server for the signed-in profile Claude sessions could not work inside the user's own profile: the desktop app's browser lost synthetic pastes and typing in Medium's editor, and a browser-wide --remote-debugging-port would hand every session to any local process. The connector is a built-in extension, off until turned on in Settings > Extensions, that serves MCP on 127.0.0.1 behind a per-install token and drives one tab at a time through that tab's webContents.debugger. - Host: a "tab-control" capability (built-in add-ons only) for listing, opening, navigating, closing and focusing tabs, handing out web tabs' webContents (never Settings, extension or home tabs), and a toolbar chip plus tab outline while a client is connected; the chip's X stops it. Built-in add-ons can ship "defaultEnabled": false. - Tools: list_tabs, open_tab, navigate, activate_tab, close_tab, screenshot, read_page_text, snapshot (accessibility tree with refs), click, type (insertText + real Enter), press_key, paste_html / paste_text (system clipboard + webContents.paste, clipboard restored), upload_file (DOM.setFileInputFiles), wait_for, and evaluate behind its own switch. - Safety: first use of each site asks the user; password and one-time-code fields are refused; uploads only from folders the user added; requests from web pages or foreign Host headers are refused; every action is logged with a timestamp in the panel and actions.log. - Claude Desktop runs stdio-shim.cjs through the Theseus executable in Node mode. - Tests: unit (protocol, auth, policy, shim) and an end-to-end run on a throwaway profile that types, pastes HTML with a heading and a link, and uploads a PNG on a local page. --- DESIGN-claude-connector.md | 10 + README.md | 58 +++ addons-host.js | 40 +++ bundled-addons/claude-connector/README.md | 27 ++ bundled-addons/claude-connector/addon.json | 16 + bundled-addons/claude-connector/index.js | 259 ++++++++++++++ .../claude-connector/lib/action-log.cjs | 61 ++++ .../claude-connector/lib/clipboard.cjs | 59 ++++ .../claude-connector/lib/mcp-server.cjs | 276 +++++++++++++++ .../claude-connector/lib/policy.cjs | 144 ++++++++ .../claude-connector/lib/tab-driver.cjs | 315 +++++++++++++++++ bundled-addons/claude-connector/lib/tools.cjs | 319 +++++++++++++++++ bundled-addons/claude-connector/panel.html | 189 ++++++++++ .../claude-connector/stdio-shim.cjs | 102 ++++++ chrome.html | 33 ++ main.js | 120 ++++++- preload.js | 6 + settings.html | 1 + tests/claude-connector-unit.test.mjs | 334 ++++++++++++++++++ tests/claude-connector.test.mjs | 298 ++++++++++++++++ 20 files changed, 2665 insertions(+), 2 deletions(-) create mode 100644 bundled-addons/claude-connector/README.md create mode 100644 bundled-addons/claude-connector/addon.json create mode 100644 bundled-addons/claude-connector/index.js create mode 100644 bundled-addons/claude-connector/lib/action-log.cjs create mode 100644 bundled-addons/claude-connector/lib/clipboard.cjs create mode 100644 bundled-addons/claude-connector/lib/mcp-server.cjs create mode 100644 bundled-addons/claude-connector/lib/policy.cjs create mode 100644 bundled-addons/claude-connector/lib/tab-driver.cjs create mode 100644 bundled-addons/claude-connector/lib/tools.cjs create mode 100644 bundled-addons/claude-connector/panel.html create mode 100644 bundled-addons/claude-connector/stdio-shim.cjs create mode 100644 tests/claude-connector-unit.test.mjs create mode 100644 tests/claude-connector.test.mjs diff --git a/DESIGN-claude-connector.md b/DESIGN-claude-connector.md index 1f22293c..e9626460 100644 --- a/DESIGN-claude-connector.md +++ b/DESIGN-claude-connector.md @@ -161,6 +161,16 @@ scripts can neither see them nor tamper with the helpers. the newest entries live; the log is kept in `extensions-data/claude-connector/actions.log` (JSON lines, rotated at 1 MB). +### What it does not defend against + +- A program already running as the user. The token sits in the add-on store + (`extensions-data/claude-connector.json`) next to the profile's cookies, + which such a program can read anyway. The connector keeps the door shut to + web pages and to other machines, and keeps the user in charge of which sites + a connected client may touch. +- A site that changes a field's type after the focus check. The check runs + right before each input; what the page does in between is up to the page. + ## Files - `bundled-addons/claude-connector/` — `addon.json`, `index.js` (activate, diff --git a/README.md b/README.md index c315fc7f..a5b1fc5c 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,61 @@ eventually serve the browser's own homepage over the protocol it implements. ## Roadmap [BUILD-ROADMAP.md](../BUILD-ROADMAP.md) Stage 5. + +## Claude connector (MCP) +A built-in extension, **off by default**, that lets Claude Code or Claude +Desktop on the same computer work in your signed-in Theseus tabs: open and +read pages, click, type into rich editors, paste formatted text, upload +files. Design: [DESIGN-claude-connector.md](DESIGN-claude-connector.md). + +**Turn it on.** Settings › Extensions › Claude connector. Its panel (the +connector icon in the toolbar dock) shows the address, the token, the +approved sites, the upload folders and a live log of every action. + +**Add it to Claude Code** (once; `--scope user` makes it available in every +project). Copy the exact command from the panel; it looks like: + +```bash +claude mcp add --scope user --transport http theseus http://127.0.0.1:47800/mcp --header "Authorization: Bearer " +``` + +Check it with `claude mcp list`. **Claude Desktop** speaks stdio only; the +panel's *Claude Desktop* tab has a ready `claude_desktop_config.json` entry +that runs `stdio-shim.cjs` through the Theseus executable itself +(`ELECTRON_RUN_AS_NODE=1`), so nothing else needs installing. + +**What protects you** +- Listens on `127.0.0.1` only, behind a random 256-bit token. Requests from + web pages (any `Origin` header) and foreign `Host` headers are refused. + No `--remote-debugging-port`: each tab is driven through its own + `webContents.debugger`. +- Theseus asks the first time Claude acts on each site (*Allow*, *Until + Theseus restarts*, *Don't allow*). Approved sites are listed in the panel. +- Never types or pastes into password or one-time-code fields. +- Uploads only from folders you add in the panel (none by default). +- `evaluate` (arbitrary page JavaScript) is a separate switch, off by default. +- A **Claude** chip in the toolbar shows while a client is connected, and the + tabs it controls get a dashed outline. The chip's ✕ stops the connector at + once; *Revoke token* in the panel issues a new token, cutting off every + configured client. + +**Tools:** `list_tabs`, `open_tab`, `navigate`, `activate_tab`, `close_tab`, +`screenshot`, `read_page_text`, `snapshot` (accessibility tree with element +refs), `click`, `type`, `press_key`, `paste_html`, `paste_text`, +`upload_file`, `wait_for`, and `evaluate` while allowed. + +**Tests** + +```bash +node tests/claude-connector-unit.test.mjs +``` + +```bash +node tests/claude-connector.test.mjs +``` + +The second starts this checkout on a throwaway profile (`THESEUS_USER_DATA`, +`THESEUS_NO_UPDATE_CHECK`) and drives a local test page over MCP: typing into +a contenteditable editor, `paste_html` with a heading and a link, a PNG upload, +and the refusals (password fields, a file outside the upload folder, no token, +a browser origin). diff --git a/addons-host.js b/addons-host.js index 8814ced9..775cda48 100644 --- a/addons-host.js +++ b/addons-host.js @@ -137,6 +137,14 @@ const KNOWN_CAPABILITIES = new Set([ // always stays the site's. Built-in add-ons only: answering // for a name is impersonating it. "site-route", + // tab-control: api.tabControl — list, open, navigate, close and focus + // tabs, hand the add-on one web tab's webContents (for its + // own per-tab debugger session) and drive the toolbar's + // "an agent is in control" indicator. Built-in add-ons + // only (the Claude connector): it reads and drives pages in + // the user's signed-in profile. Settings, add-on and home + // tabs are never handed out. + "tab-control", ]); // Chrome-style match pattern → predicate. ":///" where @@ -377,6 +385,8 @@ class AddonHost { this._hostRequire = typeof hostRequire === "function" ? hostRequire : null; this._requestFilter = requestFilter && typeof requestFilter.set === "function" ? requestFilter : null; this._tabs = tabs && typeof tabs.active === "function" ? tabs : null; + // tab-control hook from main (see "tab-control" above). + this._tabControl = arguments[0].tabControl && typeof arguments[0].tabControl.list === "function" ? arguments[0].tabControl : null; // ESM-only deps (@noble/*, @scure/*) can't be require()d by Electron's // Node; hostImport resolves them from the app tree and import()s them. this._hostImport = typeof hostImport === "function" ? hostImport : null; @@ -953,6 +963,36 @@ class AddonHost { return off; }, }, + // tab-control: see the capability note above. Every call re-checks + // the manifest and the built-in gate, so a stale reference held by an + // add-on that was turned off still gets nothing. + tabControl: (() => { + // Dropped with the add-on's other listeners when it stops. + let live = true; + active.tabListeners.push(() => { live = false; }); + const gate = () => { + if (!(manifest.capabilities || []).includes("tab-control")) throw new Error(`add-on "${manifest.id}" must declare the "tab-control" capability in addon.json`); + if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) throw new Error("tab-control is reserved for built-in add-ons"); + if (!this._tabControl) throw new Error("tab-control unavailable (host not wired)"); + if (!live) throw new Error(`add-on "${manifest.id}" is no longer running`); + return this._tabControl; + }; + return Object.freeze({ + list: () => gate().list(), + webContents: (tabId) => gate().webContents(tabId, manifest.id), + open: (url, opts) => gate().open(String(url || ""), opts || {}, manifest.id), + navigate: (tabId, url) => gate().navigate(tabId, String(url || ""), manifest.id), + close: (tabId) => gate().close(tabId, manifest.id), + activate: (tabId) => gate().activate(tabId, manifest.id), + setStatus: (status) => gate().setStatus(status || {}, manifest.id), + onStop: (cb) => { + if (typeof cb !== "function") throw new Error("onStop needs a function"); + const off = gate().onStop(cb, manifest.id); + active.tabListeners.push(off); + return off; + }, + }); + })(), setSessionProxy: async (rules) => { if (!this._setSessionProxy) { this.log(`[${manifest.id}] setSessionProxy unavailable (host not wired)`); diff --git a/bundled-addons/claude-connector/README.md b/bundled-addons/claude-connector/README.md new file mode 100644 index 00000000..8311da3e --- /dev/null +++ b/bundled-addons/claude-connector/README.md @@ -0,0 +1,27 @@ +# Claude connector + +Built-in Theseus extension: a local MCP server through which Claude Code or +Claude Desktop works in the user's tabs. Setup and safety model are in the +Theseus README ("Claude connector (MCP)") and DESIGN-claude-connector.md. + +| file | role | +| --- | --- | +| `index.js` | activation, settings (add-on store), server start/stop, panel messages | +| `lib/mcp-server.cjs` | Streamable HTTP + JSON-RPC, token, Host/Origin guards, sessions, SSE presence | +| `lib/tools.cjs` | tool definitions and handlers; resolves tabs through `api.tabControl` | +| `lib/tab-driver.cjs` | one tab over its own `webContents.debugger` (CDP 1.3) | +| `lib/policy.cjs` | site approvals, upload-folder checks, password-field detection | +| `lib/clipboard.cjs` | put / save / restore the system clipboard around a paste | +| `lib/action-log.cjs` | timestamped action log (panel + `actions.log`) | +| `panel.html` | the sidebar panel | +| `stdio-shim.cjs` | stdio ⇄ HTTP relay for Claude Desktop, run by Theseus in Node mode | + +Stored in `/extensions-data/claude-connector.json` (token, port, +switches, approved origins, upload folders) and +`/extensions-data/claude-connector/` (`endpoint.json` while +listening, `actions.log`). + +Needs the host's `tab-control` capability, which only built-in add-ons get. +Turning any extension on or off restarts every add-on, this one included, +which ends the connected clients' sessions. A client that does not start a +new session by itself reconnects from its MCP menu (`/mcp` in Claude Code). diff --git a/bundled-addons/claude-connector/addon.json b/bundled-addons/claude-connector/addon.json new file mode 100644 index 00000000..a4d33ca4 --- /dev/null +++ b/bundled-addons/claude-connector/addon.json @@ -0,0 +1,16 @@ +{ + "id": "claude-connector", + "name": "Claude connector", + "version": "0.1.0", + "description": "Lets Claude Code or Claude Desktop on this computer work in your Theseus tabs through a local MCP connection: read pages, click, type, paste and upload. Off until you turn it on; asks before each new site; never types into password fields; logs every action.", + "author": "Silent Mode", + "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PHJlY3QgeD0iNCIgeT0iNCIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iMTQiIGZpbGw9IiNkOTc3NTciLz48cGF0aCBkPSJNMzIgMTN2MzhNMTMgMzJoMzhNMTguNiAxOC42bDI2LjggMjYuOE00NS40IDE4LjYgMTguNiA0NS40IiBzdHJva2U9IiNmZmY0ZWUiIHN0cm9rZS13aWR0aD0iNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+DQo=", + "main": "index.js", + "activation": "startup", + "defaultEnabled": false, + "capabilities": [ + "sidebar-panel", + "tab-control", + "approval-modal" + ] +} diff --git a/bundled-addons/claude-connector/index.js b/bundled-addons/claude-connector/index.js new file mode 100644 index 00000000..6d8192d1 --- /dev/null +++ b/bundled-addons/claude-connector/index.js @@ -0,0 +1,259 @@ +// Claude connector for Theseus. A local MCP server (Streamable HTTP on +// 127.0.0.1, bearer token) through which Claude Code or Claude Desktop on +// this computer works in the user's tabs: each tab through its own +// webContents.debugger, never a browser-wide debugging port. +// +// The extension ships turned off (Settings › Extensions). Once on, the panel +// shows the connection command, the sites the user approved, the upload +// folders and a live log of every action. The toolbar shows a chip while a +// client is connected; its ✕ stops everything at once. +// +// Design notes: DESIGN-claude-connector.md in the Theseus repo. + +const path = require("node:path"); +const fs = require("node:fs"); +const crypto = require("node:crypto"); + +const { McpServer, pickPort } = require("./lib/mcp-server.cjs"); +const { Controller } = require("./lib/tools.cjs"); +const { SitePolicy } = require("./lib/policy.cjs"); +const { ActionLog } = require("./lib/action-log.cjs"); +const { makeClipboard } = require("./lib/clipboard.cjs"); + +let server = null; +let controller = null; +let statusTimer = null; +let stopping = null; + +const INSTRUCTIONS = [ + "This server controls tabs in Theseus Navigator, the user's own signed-in browser.", + "Start with list_tabs, or open_tab for a new page. Use snapshot to get element refs, then click / type / paste_html / upload_file with those refs.", + "The user approves each site the first time it is controlled; if a call fails because a site was not approved, tell the user rather than retrying.", + "type sends real input that rich editors (Medium, Substack, Google Docs-style editors) register; for formatted content prefer paste_html.", + "Screenshot pixels are CSS pixels, the same coordinates click(x, y) takes. Password fields are always refused. Files can only be uploaded from folders the user allowed.", +].join(" "); + +module.exports = { + activate(api) { + const { clipboard, dialog, BrowserWindow, app } = require("electron"); + const dataDir = path.join(api.dataDir || path.join(api.folder, "..", "..", "extensions-data"), "claude-connector"); + fs.mkdirSync(dataDir, { recursive: true }); + const endpointFile = path.join(dataDir, "endpoint.json"); + + // ---- settings (the add-on's own store) --------------------------------- + const S = { + get token() { return api.storage.get("token", null); }, + get accept() { return api.storage.get("accept", true) !== false; }, + get port() { return Number(api.storage.get("port", 0)) || 0; }, + get allowEvaluate() { return api.storage.get("allowEvaluate", false) === true; }, + get allowedOrigins() { const v = api.storage.get("allowedOrigins", []); return Array.isArray(v) ? v.map(String) : []; }, + get uploadDirs() { const v = api.storage.get("uploadDirs", []); return Array.isArray(v) ? v.map(String) : []; }, + }; + if (!S.token) api.storage.set("token", newToken()); + + const log = new ActionLog(dataDir, { onEntry: (e) => api.emit("log-entry", e) }); + + const policy = new SitePolicy({ + getAllowed: () => S.allowedOrigins, + setAllowed: (list) => { api.storage.set("allowedOrigins", list); pushState(); }, + ask: (origin, ctx) => { + const who = ctx && ctx.client ? ctx.client : "A Claude client"; + const host = origin.replace(/^https?:\/\//, ""); + log.add({ tool: "approval", origin, client: ctx && ctx.client, args: `asked to ${ctx && ctx.action === "open" ? "open" : "control"} ${host}`, ok: true }); + return api.approvalModal({ + title: `Let Claude control ${host}?`, + origin, + body: `${who}, connected to Theseus through the Claude connector, wants to open, read and act on pages from ${host} in this profile, where you are signed in: read text, click, type and paste. It never types into password fields and every action is logged in the connector's panel. You can remove the site there at any time.`, + actions: [ + { id: "allow", label: "Allow", primary: true }, + { id: "session", label: "Until Theseus restarts" }, + { id: "deny", label: "Don't allow" }, + ], + }).then((pick) => { + const p = String(pick || "").split("+")[0]; + log.add({ tool: "approval", origin, client: ctx && ctx.client, args: p === "allow" ? "allowed" : p === "session" ? "allowed until restart" : "refused", ok: p === "allow" || p === "session" }); + return pick; + }); + }, + }); + + controller = new Controller({ + tabControl: api.tabControl, + policy, + log, + getSettings: () => ({ allowEvaluate: S.allowEvaluate, uploadDirs: S.uploadDirs }), + clipboard: makeClipboard(require("electron")), + onTabsChanged: () => pushStatus(), + onBusy: (on) => { busy = on; if (on) { clearTimeout(busyOff); pushStatus(); } else { clearTimeout(busyOff); busyOff = setTimeout(pushStatus, 600); } }, + }); + let busy = false; + let busyOff = null; + + // ---- indicator + panel state --------------------------------------- + let lastConnected = false; + function pushStatus() { + const st = server ? server.status() : { listening: false, connected: false, clients: [] }; + // No client left: let go of every tab (and its markers). + if (lastConnected && !st.connected && controller.drivers.size) controller.releaseAll(); + lastConnected = st.connected; + try { + api.tabControl.setStatus({ connected: st.connected, busy: busy && st.connected, clients: st.clients, tabIds: st.connected ? controller.controlledTabIds() : [] }); + } catch (e) { api.log("indicator update failed:", e.message); } + api.emit("status", publicState()); + } + function pushState() { api.emit("status", publicState()); } + + function publicState() { + const st = server ? server.status() : { listening: false, port: null, connected: false, clients: [], sessions: [] }; + const url = server && server.url; + return { + enabled: true, + accept: S.accept, + listening: st.listening, + url, + port: st.port || S.port || null, + connected: st.connected, + clients: st.clients, + sessions: st.sessions || [], + controlledTabs: controller.controlledTabIds(), + allowEvaluate: S.allowEvaluate, + allowedOrigins: S.allowedOrigins, + uploadDirs: S.uploadDirs, + token: S.token, + commands: url ? commandsFor(url, S.token) : null, + }; + } + + function commandsFor(url, token) { + const exe = process.execPath; + const shim = path.join(api.folder, "stdio-shim.cjs"); + return { + claudeCode: `claude mcp add --scope user --transport http theseus ${url} --header "Authorization: Bearer ${token}"`, + claudeDesktop: JSON.stringify({ mcpServers: { theseus: { command: exe, args: [shim], env: { ELECTRON_RUN_AS_NODE: "1", THESEUS_MCP_URL: url, THESEUS_MCP_TOKEN: token } } } }, null, 2), + }; + } + + // ---- server lifecycle ------------------------------------------------ + async function start() { + if (server) return; + const port = await pickPort(S.port); + if (!port) throw new Error("no free local port"); + const s = new McpServer({ + getToken: () => S.token, + tools: { list: () => controller.definitions(), call: (name, args, session) => controller.call(name, args, session) }, + serverInfo: { name: "theseus", title: "Theseus Navigator", version: readVersion(api.folder) }, + instructions: INSTRUCTIONS, + onStatus: () => pushStatus(), + log: (...a) => api.log(...a), + }); + await s.listen(port); + server = s; + if (port !== S.port) api.storage.set("port", port); + try { fs.writeFileSync(endpointFile, JSON.stringify({ url: s.url, port, pid: process.pid }, null, 2)); } catch {} + clearInterval(statusTimer); + statusTimer = setInterval(pushStatus, 15000); + statusTimer.unref?.(); + api.log(`listening on ${s.url}`); + log.add({ tool: "connector", args: `listening on ${s.url}`, ok: true }); + pushStatus(); + } + + async function stop(reason) { + if (stopping) return stopping; + stopping = (async () => { + clearInterval(statusTimer); + statusTimer = null; + const s = server; + server = null; + if (s) await s.close().catch(() => {}); + await controller.releaseAll(); + policy.reset(); + try { fs.rmSync(endpointFile, { force: true }); } catch {} + if (s) log.add({ tool: "connector", args: reason || "stopped", ok: true }); + pushStatus(); + })().finally(() => { stopping = null; }); + return stopping; + } + + // Toolbar chip ✕: stop now, and stay stopped until the user turns it on. + api.tabControl.onStop(() => { + api.storage.set("accept", false); + stop("stopped from the toolbar").catch(() => {}); + }); + + // ---- panel messages -------------------------------------------------- + api.onMessage("state", () => publicState()); + api.onMessage("log", ({ n } = {}) => log.recent(n || 200)); + api.onMessage("clear-log", () => { log.clear(); return { ok: true }; }); + api.onMessage("set-accept", async ({ on } = {}) => { + api.storage.set("accept", !!on); + if (on) await start(); else await stop("turned off in the panel"); + return publicState(); + }); + api.onMessage("disconnect", async () => { + if (server) server.dropAllSessions(); + await controller.releaseAll(); + log.add({ tool: "connector", args: "disconnected every client", ok: true }); + pushStatus(); + return publicState(); + }); + api.onMessage("revoke-token", async () => { + api.storage.set("token", newToken()); + if (server) server.dropAllSessions(); + await controller.releaseAll(); + log.add({ tool: "connector", args: "token revoked; a new one was issued", ok: true }); + pushStatus(); + return publicState(); + }); + api.onMessage("set-evaluate", ({ on } = {}) => { + api.storage.set("allowEvaluate", !!on); + log.add({ tool: "connector", args: on ? "evaluate allowed" : "evaluate turned off", ok: true }); + if (server) server.notifyToolsChanged(); + pushState(); + return publicState(); + }); + api.onMessage("remove-origin", ({ origin } = {}) => { policy.forget(String(origin || "")); pushState(); return publicState(); }); + api.onMessage("add-upload-dir", async () => { + const r = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow() || undefined, { + title: "Folder Claude may upload files from", + defaultPath: app.getPath("documents"), + properties: ["openDirectory", "createDirectory"], + }); + if (r.canceled || !r.filePaths || !r.filePaths[0]) return publicState(); + const dirs = new Set(S.uploadDirs); + dirs.add(r.filePaths[0]); + api.storage.set("uploadDirs", [...dirs]); + log.add({ tool: "connector", args: `upload folder added: ${r.filePaths[0]}`, ok: true }); + return publicState(); + }); + api.onMessage("remove-upload-dir", ({ dir } = {}) => { + api.storage.set("uploadDirs", S.uploadDirs.filter((d) => d !== dir)); + return publicState(); + }); + api.onMessage("copy", async ({ text } = {}) => { await clipboard.writeText(String(text || "")); return { ok: true }; }); + api.onMessage("open-log-folder", () => { require("electron").shell.openPath(dataDir); return { ok: true }; }); + + api.registerSidebarPanel({ id: "main", title: "Claude connector", page: "panel.html" }); + + if (S.accept) { + Promise.resolve(api.whenUiReady ? api.whenUiReady() : null) + .then(() => start()) + .catch((e) => { api.log("could not start:", e.message); log.add({ tool: "connector", args: "could not start", ok: false, error: e.message }); }); + } + + module.exports._stop = stop; + }, + + async deactivate() { + const stop = module.exports._stop; + module.exports._stop = null; + if (stop) await stop("extension turned off"); + server = null; + controller = null; + }, +}; + +function newToken() { return crypto.randomBytes(32).toString("base64url"); } +function readVersion(folder) { + try { return JSON.parse(fs.readFileSync(path.join(folder, "addon.json"), "utf8")).version || "0.0.0"; } catch { return "0.0.0"; } +} diff --git a/bundled-addons/claude-connector/lib/action-log.cjs b/bundled-addons/claude-connector/lib/action-log.cjs new file mode 100644 index 00000000..30bb6e6a --- /dev/null +++ b/bundled-addons/claude-connector/lib/action-log.cjs @@ -0,0 +1,61 @@ +// Every connector action, timestamped: kept in memory for the panel and +// appended to /actions.log as JSON lines, rotated at 1 MB (one old +// file kept as actions.log.1). + +const fs = require("node:fs"); +const path = require("node:path"); + +const KEEP = 500; +const ROTATE_BYTES = 1024 * 1024; + +class ActionLog { + constructor(dir, { onEntry } = {}) { + this.file = path.join(dir, "actions.log"); + this.onEntry = typeof onEntry === "function" ? onEntry : null; + this.entries = []; + this.seq = 0; + try { + const lines = fs.readFileSync(this.file, "utf8").trim().split("\n").slice(-KEEP); + for (const l of lines) { try { this.entries.push(JSON.parse(l)); } catch {} } + this.seq = this.entries.reduce((m, e) => Math.max(m, Number(e.seq) || 0), 0); + } catch {} + } + + add(fields) { + const entry = { seq: ++this.seq, ts: new Date().toISOString(), ...fields }; + this.entries.push(entry); + if (this.entries.length > KEEP) this.entries.splice(0, this.entries.length - KEEP); + try { + try { if (fs.statSync(this.file).size > ROTATE_BYTES) fs.renameSync(this.file, this.file + ".1"); } catch {} + fs.appendFileSync(this.file, JSON.stringify(entry) + "\n"); + } catch {} + if (this.onEntry) { try { this.onEntry(entry); } catch {} } + return entry; + } + + recent(n = 200) { return this.entries.slice(-Math.max(1, Math.min(KEEP, n))); } + + clear() { + this.entries = []; + try { fs.rmSync(this.file, { force: true }); fs.rmSync(this.file + ".1", { force: true }); } catch {} + } +} + +// A short, safe description of a tool's arguments for the log. Typed and +// pasted content is logged by length and its first characters only. +function summarizeArgs(args) { + const a = args && typeof args === "object" ? args : {}; + const out = []; + const clip = (s, n) => { s = String(s); return s.length > n ? s.slice(0, n) + "…" : s; }; + for (const [k, v] of Object.entries(a)) { + if (v == null || k === "tab_id") continue; + if (k === "text" || k === "html" || k === "expression") { + const s = String(v); + out.push(`${k}: ${s.length} chars "${clip(s.replace(/\s+/g, " "), 60)}"`); + } else if (typeof v === "object") out.push(`${k}: ${clip(JSON.stringify(v), 80)}`); + else out.push(`${k}: ${clip(v, 120)}`); + } + return out.join(", "); +} + +module.exports = { ActionLog, summarizeArgs }; diff --git a/bundled-addons/claude-connector/lib/clipboard.cjs b/bundled-addons/claude-connector/lib/clipboard.cjs new file mode 100644 index 00000000..114a5397 --- /dev/null +++ b/bundled-addons/claude-connector/lib/clipboard.cjs @@ -0,0 +1,59 @@ +// The system clipboard, for paste_html / paste_text: put our content on it, +// and save and restore what the user had there. +// +// Electron 44 replaced the synchronous clipboard (readHTML, readImage, …) +// with the W3C-style async one (read / write of ClipboardItem). Both are +// handled; a build with the old API gets text, HTML, RTF and image back. + +function makeClipboard(electron) { + const cb = electron.clipboard; + const Item = electron.ClipboardItem; + + if (typeof cb.read === "function" && typeof Item === "function") { + return { + async put({ html, text }) { + const data = { "text/plain": String(text ?? "") }; + if (html != null) data["text/html"] = String(html); + await cb.write([new Item(data)]); + }, + // Every format the platform offers, read out now (the items' getType + // would otherwise read the clipboard again later, after our write). + async save() { + const items = await cb.read(); + const out = []; + for (const it of items) { + const entry = {}; + for (const type of it.types) { try { entry[type] = await it.getType(type); } catch {} } + if (Object.keys(entry).length) out.push(entry); + } + return out; + }, + async restore(saved) { + if (!saved || !saved.length) { cb.clear(); return; } + try { await cb.write(saved.map((e) => new Item(e))); return; } catch {} + // A platform format that cannot be written back: keep the text at least. + for (const e of saved) { + const t = e["text/plain"]; + if (t) { try { await cb.writeText(typeof t === "string" ? t : await t.text()); return; } catch {} } + } + cb.clear(); + }, + }; + } + + return { + async put({ html, text }) { cb.write(html != null ? { html: String(html), text: String(text ?? "") } : { text: String(text ?? "") }); }, + async save() { + const s = { text: cb.readText(), html: cb.readHTML(), rtf: cb.readRTF() }; + try { const img = cb.readImage(); if (img && !img.isEmpty()) s.image = img; } catch {} + return s; + }, + async restore(s) { + const back = {}; + for (const k of ["text", "html", "rtf", "image"]) if (s && s[k]) back[k] = s[k]; + if (Object.keys(back).length) cb.write(back); else cb.clear(); + }, + }; +} + +module.exports = { makeClipboard }; diff --git a/bundled-addons/claude-connector/lib/mcp-server.cjs b/bundled-addons/claude-connector/lib/mcp-server.cjs new file mode 100644 index 00000000..85b7a419 --- /dev/null +++ b/bundled-addons/claude-connector/lib/mcp-server.cjs @@ -0,0 +1,276 @@ +// MCP over Streamable HTTP, loopback only. +// +// POST /mcp JSON-RPC request(s) → application/json response +// GET /mcp SSE stream for server notifications (also the "a client is +// still here" signal behind the toolbar indicator) +// DELETE /mcp end the session +// +// Every request needs `Authorization: Bearer `. Requests with an +// Origin header (a web page) or a Host other than this loopback address +// (DNS rebinding) are refused before the token is even looked at. + +const http = require("node:http"); +const crypto = require("node:crypto"); + +const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"]; +const MAX_BODY = 32 * 1024 * 1024; +// A session counts as connected while its SSE stream is open, or for this +// long after its last request. +const IDLE_MS = 2 * 60 * 1000; +const SESSION_TTL_MS = 12 * 60 * 60 * 1000; + +const sha = (s) => crypto.createHash("sha256").update(String(s)).digest(); +function tokenMatches(given, expected) { + if (!given || !expected) return false; + return crypto.timingSafeEqual(sha(given), sha(expected)); +} + +class McpServer { + // tools: { list(): [toolDef], call(name, args, session) => Promise } + constructor({ getToken, tools, serverInfo, instructions, onStatus, log }) { + this.getToken = getToken; + this.tools = tools; + this.serverInfo = serverInfo || { name: "theseus", version: "0.0.0" }; + this.instructions = instructions || ""; + this.onStatus = typeof onStatus === "function" ? onStatus : () => {}; + this.log = typeof log === "function" ? log : () => {}; + this.sessions = new Map(); // id -> { id, client, protocolVersion, created, lastSeen, streams:Set } + this.server = null; + this.port = null; + this._lastStatus = ""; + this._sweep = null; + } + + get url() { return this.port ? `http://127.0.0.1:${this.port}/mcp` : null; } + + listen(port) { + return new Promise((resolve, reject) => { + const srv = http.createServer((req, res) => this._handle(req, res).catch((e) => { + this.log("request failed:", e?.message || e); + if (!res.headersSent) this._json(res, 500, { jsonrpc: "2.0", id: null, error: { code: -32603, message: "internal error" } }); + else try { res.end(); } catch {} + })); + srv.keepAliveTimeout = 5000; + srv.requestTimeout = 0; // tool calls may wait on the user (site approval) + srv.headersTimeout = 20000; + const fail = (e) => { srv.removeListener("listening", ok); reject(e); }; + const ok = () => { + srv.removeListener("error", fail); + this.server = srv; + this.port = srv.address().port; + this._sweep = setInterval(() => this._expire(), 15000); + this._sweep.unref?.(); + resolve(this.port); + }; + srv.once("error", fail); + srv.once("listening", ok); + srv.listen(port, "127.0.0.1"); + }); + } + + async close() { + clearInterval(this._sweep); + this._sweep = null; + this.dropAllSessions(); + const srv = this.server; + this.server = null; + this.port = null; + if (srv) await new Promise((r) => { srv.close(() => r()); srv.closeAllConnections?.(); }); + this._emitStatus(); + } + + dropAllSessions() { + for (const s of this.sessions.values()) for (const r of s.streams) { try { r.end(); } catch {} } + this.sessions.clear(); + this._emitStatus(); + } + + // Tell every connected client that the tool list changed (evaluate on/off). + notifyToolsChanged() { + const msg = `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}\n\n`; + for (const s of this.sessions.values()) for (const r of s.streams) { try { r.write(msg); } catch {} } + } + + status() { + const now = Date.now(); + const live = [...this.sessions.values()].filter((s) => s.streams.size > 0 || now - s.lastSeen < IDLE_MS); + return { + listening: !!this.server, + port: this.port, + connected: live.length > 0, + clients: [...new Set(live.map((s) => s.client))], + sessions: live.map((s) => ({ client: s.client, since: new Date(s.created).toISOString(), lastSeen: new Date(s.lastSeen).toISOString(), streaming: s.streams.size > 0 })), + }; + } + + _emitStatus() { + const st = this.status(); + const key = JSON.stringify([st.listening, st.port, st.connected, st.clients]); + if (key === this._lastStatus) return; + this._lastStatus = key; + this.onStatus(st); + } + + _expire() { + const now = Date.now(); + for (const [id, s] of this.sessions) { + if (s.streams.size === 0 && now - s.lastSeen > SESSION_TTL_MS) this.sessions.delete(id); + } + this._emitStatus(); + } + + _json(res, status, body, headers = {}) { + const data = body === undefined ? "" : JSON.stringify(body); + res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers }); + res.end(data); + } + + _refuse(res, status, message) { + res.writeHead(status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", ...(status === 401 ? { "www-authenticate": 'Bearer realm="theseus"' } : {}) }); + res.end(message); + } + + async _handle(req, res) { + const ra = req.socket.remoteAddress || ""; + if (!/^(?:127\.|::1$|::ffff:127\.)/.test(ra)) return this._refuse(res, 403, "loopback only"); + const host = String(req.headers.host || "").toLowerCase(); + if (host !== `127.0.0.1:${this.port}` && host !== `localhost:${this.port}`) return this._refuse(res, 403, "bad host"); + // Browsers always send Origin on cross-origin requests; MCP clients do + // not. A page in any browser on this computer gets nothing here. + if (req.headers.origin) return this._refuse(res, 403, "browser requests are not accepted"); + const url = new URL(req.url, `http://${host}`); + if (url.pathname !== "/mcp") return this._refuse(res, 404, "not found"); + const auth = String(req.headers.authorization || ""); + const m = /^Bearer\s+(\S+)$/i.exec(auth); + if (!m || !tokenMatches(m[1], this.getToken())) return this._refuse(res, 401, "missing or wrong token"); + + const sid = req.headers["mcp-session-id"] ? String(req.headers["mcp-session-id"]) : null; + if (req.method === "DELETE") { + const s = sid && this.sessions.get(sid); + if (s) { for (const r of s.streams) { try { r.end(); } catch {} } this.sessions.delete(sid); this._emitStatus(); } + return this._json(res, 200, undefined); + } + if (req.method === "GET") { + if (!String(req.headers.accept || "").includes("text/event-stream")) return this._refuse(res, 406, "GET needs Accept: text/event-stream"); + const s = sid && this.sessions.get(sid); + if (!s) return this._refuse(res, sid ? 404 : 400, sid ? "unknown session" : "missing Mcp-Session-Id"); + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" }); + res.write(": theseus\n\n"); + s.streams.add(res); + s.lastSeen = Date.now(); + const ping = setInterval(() => { try { res.write(": ping\n\n"); } catch {} }, 20000); + ping.unref?.(); + const done = () => { clearInterval(ping); s.streams.delete(res); s.lastSeen = Date.now(); this._emitStatus(); }; + req.on("close", done); + res.on("close", done); + this._emitStatus(); + return; + } + if (req.method !== "POST") return this._refuse(res, 405, "method not allowed"); + + const body = await readBody(req); + let msg; + try { msg = JSON.parse(body); } catch { return this._json(res, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }); } + const batch = Array.isArray(msg); + const msgs = batch ? msg : [msg]; + if (!msgs.length) return this._json(res, 400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "empty batch" } }); + + const init = msgs.find((x) => x && x.method === "initialize"); + let session = sid ? this.sessions.get(sid) : null; + const headers = {}; + if (init) { + const id = crypto.randomBytes(16).toString("hex"); + const ci = (init.params && init.params.clientInfo) || {}; + session = { id, client: String(ci.name || "MCP client").slice(0, 60), clientVersion: String(ci.version || ""), protocolVersion: null, created: Date.now(), lastSeen: Date.now(), streams: new Set() }; + this.sessions.set(id, session); + headers["mcp-session-id"] = id; + this.log(`client connected: ${session.client} ${session.clientVersion}`.trim()); + } else if (!session) { + // Unknown id: the client re-initializes (spec: 404 means "start over"). + return this._json(res, sid ? 404 : 400, { jsonrpc: "2.0", id: null, error: { code: -32000, message: sid ? "unknown session; initialize again" : "missing Mcp-Session-Id; initialize first" } }); + } + session.lastSeen = Date.now(); + this._emitStatus(); + + const replies = []; + for (const m1 of msgs) { + if (!m1 || m1.jsonrpc !== "2.0" || typeof m1.method !== "string") { + if (m1 && m1.id !== undefined && (m1.result !== undefined || m1.error !== undefined)) continue; // a client's reply to us + replies.push({ jsonrpc: "2.0", id: m1 && m1.id !== undefined ? m1.id : null, error: { code: -32600, message: "invalid request" } }); + continue; + } + const isNotification = m1.id === undefined || m1.id === null; + const r = await this._dispatch(m1, session); + if (!isNotification) replies.push(r); + } + session.lastSeen = Date.now(); + if (!replies.length) { res.writeHead(202, headers); return res.end(); } + return this._json(res, 200, batch ? replies : replies[0], headers); + } + + async _dispatch(m, session) { + const ok = (result) => ({ jsonrpc: "2.0", id: m.id, result }); + const err = (code, message) => ({ jsonrpc: "2.0", id: m.id, error: { code, message } }); + try { + switch (m.method) { + case "initialize": { + const asked = m.params && m.params.protocolVersion; + const v = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0]; + session.protocolVersion = v; + return ok({ protocolVersion: v, capabilities: { tools: { listChanged: true } }, serverInfo: this.serverInfo, instructions: this.instructions }); + } + case "notifications/initialized": + case "notifications/cancelled": + case "notifications/roots/list_changed": + return null; + case "ping": + return ok({}); + case "tools/list": + return ok({ tools: this.tools.list() }); + case "tools/call": { + const name = m.params && m.params.name; + const args = (m.params && m.params.arguments) || {}; + if (!this.tools.list().some((t) => t.name === name)) return err(-32602, `unknown tool: ${name}`); + try { + return ok(await this.tools.call(name, args, session)); + } catch (e) { + return ok({ content: [{ type: "text", text: `Error: ${e?.message || e}` }], isError: true }); + } + } + default: + if (m.method.startsWith("notifications/")) return null; + return err(-32601, `method not found: ${m.method}`); + } + } catch (e) { + return err(-32603, e?.message || String(e)); + } + } +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let n = 0; + req.on("data", (c) => { + n += c.length; + if (n > MAX_BODY) { reject(new Error("request too large")); req.destroy(); return; } + chunks.push(c); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +// First free port in the connector's range, else any free port. +async function pickPort(preferred) { + const tryPort = (p) => new Promise((resolve) => { + const s = http.createServer(); + s.once("error", () => resolve(null)); + s.listen(p, "127.0.0.1", () => { const got = s.address().port; s.close(() => resolve(got)); }); + }); + if (preferred) { const p = await tryPort(preferred); if (p) return p; } + for (let p = 47800; p < 47900; p++) { const got = await tryPort(p); if (got) return got; } + return tryPort(0); +} + +module.exports = { McpServer, pickPort, tokenMatches, PROTOCOL_VERSIONS }; diff --git a/bundled-addons/claude-connector/lib/policy.cjs b/bundled-addons/claude-connector/lib/policy.cjs new file mode 100644 index 00000000..e43b4136 --- /dev/null +++ b/bundled-addons/claude-connector/lib/policy.cjs @@ -0,0 +1,144 @@ +// What the connector lets a client do: which sites (asked once per origin), +// which files it may upload, and what counts as a password field. + +const fs = require("node:fs"); +const path = require("node:path"); + +// "https://medium.com" for any page on it. BCNR pages load as bns://name.bch +// inside Theseus and show as https:// in the address bar; they are the same +// site, so both forms map to the https origin. Anything else has no origin +// the connector will act on. +function originOf(url) { + let u; + try { u = new URL(String(url || "")); } catch { return null; } + if (u.protocol === "bns:") return `https://${u.host.toLowerCase()}`; + if (u.protocol !== "http:" && u.protocol !== "https:") return null; + return `${u.protocol}//${u.host.toLowerCase()}`; +} + +function hostOf(url) { + try { return new URL(String(url || "")).host.toLowerCase(); } catch { return ""; } +} + +const DENY_MS = 10 * 60 * 1000; +const ASK_TIMEOUT_MS = 3 * 60 * 1000; + +// Approved origins live in the add-on store (remembered) or in memory (this +// session only). A refusal is remembered for ten minutes, so a client cannot +// keep the prompt on screen. Concurrent calls for one origin share a prompt. +class SitePolicy { + constructor({ getAllowed, setAllowed, ask }) { + this.getAllowed = getAllowed; + this.setAllowed = setAllowed; + this.ask = ask; + this.session = new Set(); + this.denied = new Map(); // origin -> until (ms) + this.pending = new Map(); // origin -> Promise + } + + isAllowed(origin) { + return !!origin && (this.session.has(origin) || this.getAllowed().includes(origin)); + } + + async ensure(origin, context = {}) { + if (!origin) throw new Error("this page has no web origin the connector can act on"); + if (this.isAllowed(origin)) return true; + const until = this.denied.get(origin); + if (until && until > Date.now()) throw new Error(`the user did not allow control of ${origin}; ask them to approve it in the Claude connector panel`); + if (!this.pending.has(origin)) { + const p = (async () => { + let answer = "cancel"; + let timer; + try { + answer = await Promise.race([ + this.ask(origin, context), + new Promise((r) => { timer = setTimeout(() => r("timeout"), ASK_TIMEOUT_MS); }), + ]); + } finally { clearTimeout(timer); } + const pick = String(answer || "").split("+")[0]; + if (pick === "allow") { + const list = this.getAllowed(); + if (!list.includes(origin)) this.setAllowed([...list, origin]); + return true; + } + if (pick === "session") { this.session.add(origin); return true; } + if (pick === "timeout") throw new Error(`no answer from the user about ${origin}; try again when they are at the computer`); + this.denied.set(origin, Date.now() + DENY_MS); + throw new Error(`the user did not allow control of ${origin}`); + })().finally(() => this.pending.delete(origin)); + this.pending.set(origin, p); + } + return this.pending.get(origin); + } + + forget(origin) { + this.session.delete(origin); + this.denied.delete(origin); + this.setAllowed(this.getAllowed().filter((o) => o !== origin)); + } + + reset() { this.session.clear(); this.denied.clear(); } +} + +const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; + +// The real file behind `p` if it sits inside one of the allowed folders. +// Both sides are resolved with realpath, so a symlink or junction inside an +// allowed folder that points elsewhere is refused. +function resolveUpload(p, allowedDirs) { + if (!p || typeof p !== "string") throw new Error("upload needs a file path"); + if (!path.isAbsolute(p)) throw new Error("upload needs an absolute file path"); + if (!Array.isArray(allowedDirs) || !allowedDirs.length) throw new Error("no upload folder is set; the user can add one in the Claude connector panel"); + let real; + try { real = fs.realpathSync.native(p); } catch { throw new Error(`file not found: ${p}`); } + const st = fs.statSync(real); + if (!st.isFile()) throw new Error(`not a regular file: ${p}`); + if (st.size > MAX_UPLOAD_BYTES) throw new Error(`file is larger than ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`); + const norm = (x) => (process.platform === "win32" ? x.toLowerCase() : x); + for (const d of allowedDirs) { + let rd; + try { rd = fs.realpathSync.native(d); } catch { continue; } + const base = norm(rd.endsWith(path.sep) ? rd : rd + path.sep); + if (norm(real).startsWith(base)) return real; + } + throw new Error(`${p} is outside the upload folders the user allowed`); +} + +const SECRET_AUTOCOMPLETE = /\b(?:current-password|new-password|one-time-code)\b/i; + +// From DOM.describeNode: is this element a password (or one-time code) field? +function isSecretField(node) { + if (!node) return false; + 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 || node.nodeName || "").toLowerCase() === "input" && String(attrs.type || "").toLowerCase() === "password") return true; + return SECRET_AUTOCOMPLETE.test(attrs.autocomplete || ""); +} + +// Runs in the connector's isolated world (page scripts cannot reach or +// patch it): follows focus through open shadow roots and same-origin frames +// and says whether typing there is allowed. +const FOCUS_GUARD = `(() => { + let el = document.activeElement; + for (let i = 0; el && i < 25; i++) { + if (el.shadowRoot && el.shadowRoot.activeElement) { el = el.shadowRoot.activeElement; continue; } + if (el.tagName === "IFRAME" || el.tagName === "FRAME") { + let doc = null; + try { doc = el.contentDocument; } catch {} + if (!doc) return { ok: false, reason: "the focused element is inside a frame from another site, which the connector cannot check" }; + el = doc.activeElement; + continue; + } + break; + } + if (!el || el.tagName === "BODY" || el.tagName === "HTML") return { ok: true, editable: false, tag: el ? el.tagName.toLowerCase() : null }; + const tag = el.tagName.toLowerCase(); + const ac = String((el.getAttribute && el.getAttribute("autocomplete")) || ""); + if (tag === "input" && String(el.type).toLowerCase() === "password") return { ok: false, reason: "the focused field is a password field" }; + if (${SECRET_AUTOCOMPLETE}.test(ac)) return { ok: false, reason: "the focused field is marked for a password or one-time code" }; + const editable = !!el.isContentEditable || tag === "textarea" || tag === "select" || (tag === "input" && !/^(?:button|submit|reset|checkbox|radio|file|image|range|color|hidden)$/i.test(el.type)); + return { ok: true, editable, tag }; +})()`; + +module.exports = { originOf, hostOf, SitePolicy, resolveUpload, isSecretField, FOCUS_GUARD, MAX_UPLOAD_BYTES }; diff --git a/bundled-addons/claude-connector/lib/tab-driver.cjs b/bundled-addons/claude-connector/lib/tab-driver.cjs new file mode 100644 index 00000000..31008b83 --- /dev/null +++ b/bundled-addons/claude-connector/lib/tab-driver.cjs @@ -0,0 +1,315 @@ +// 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; + 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 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) { + 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 } = {}) { + 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 } = {}) { + 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"); + } + + // 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) await this.send("Input.insertText", { text }); + } + + async press(name, modifiers = []) { + 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) { + 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 } = {}) { + 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) { + 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 }; diff --git a/bundled-addons/claude-connector/lib/tools.cjs b/bundled-addons/claude-connector/lib/tools.cjs new file mode 100644 index 00000000..3c72b51b --- /dev/null +++ b/bundled-addons/claude-connector/lib/tools.cjs @@ -0,0 +1,319 @@ +// The connector's MCP tools. Each tab tool resolves its tab through the +// host (web tabs only), asks the site policy for the tab's origin, then acts +// through that tab's TabDriver. Every call is logged. + +const { TabDriver } = require("./tab-driver.cjs"); +const { originOf, hostOf, resolveUpload } = require("./policy.cjs"); +const { summarizeArgs } = require("./action-log.cjs"); + +const tabIdProp = { tab_id: { type: "integer", description: "Tab id from list_tabs. Omit for the active tab." } }; +const refProp = { ref: { type: "string", description: "Element ref from the latest snapshot, e.g. \"e12\"." } }; + +const DEFS = [ + { name: "list_tabs", description: "List the open Theseus tabs (id, title, URL, which one is active). Tabs on sites the user has not approved show only their host.", inputSchema: { type: "object", properties: {} } }, + { name: "open_tab", description: "Open a URL in a new tab. The user approves each new site the first time.", inputSchema: { type: "object", properties: { url: { type: "string" }, background: { type: "boolean", description: "Open without switching to it (default false)." } }, required: ["url"] } }, + { name: "navigate", description: "Load a URL in a tab, or go back / forward / reload.", inputSchema: { type: "object", properties: { ...tabIdProp, url: { type: "string" }, action: { type: "string", enum: ["back", "forward", "reload"] } } } }, + { name: "activate_tab", description: "Bring a tab to the front.", inputSchema: { type: "object", properties: tabIdProp, required: ["tab_id"] } }, + { name: "close_tab", description: "Close a tab.", inputSchema: { type: "object", properties: tabIdProp, required: ["tab_id"] } }, + { name: "screenshot", description: "Screenshot of a tab, in CSS pixels: a point in the image is the same point for click(x, y). A tab in the background is brought to the front first.", inputSchema: { type: "object", properties: { ...tabIdProp, full_page: { type: "boolean" }, format: { type: "string", enum: ["png", "jpeg"] } } } }, + { name: "read_page_text", description: "The visible text of a tab (innerText), with its URL and title.", inputSchema: { type: "object", properties: { ...tabIdProp, max_chars: { type: "integer", description: "Default 40000." } } } }, + { name: "snapshot", description: "Accessibility tree of a tab, one element per line with a ref (e.g. [ref=e12]) usable in click, type, paste_html, paste_text and upload_file. Refs reset on every snapshot and navigation.", inputSchema: { type: "object", properties: { ...tabIdProp, interactive_only: { type: "boolean", description: "Only controls, editable areas and headings." } } } }, + { name: "click", description: "Click an element by ref, or a point (x, y) in CSS pixels as in the screenshot.", inputSchema: { type: "object", properties: { ...tabIdProp, ...refProp, x: { type: "number" }, y: { type: "number" }, button: { type: "string", enum: ["left", "right", "middle"] }, double: { type: "boolean" } } } }, + { name: "type", description: "Type text as real input that rich editors register (lines are inserted as text, with a real Enter key between them). With ref, focuses that element first. Refuses password fields.", inputSchema: { type: "object", properties: { ...tabIdProp, ...refProp, text: { type: "string" }, mode: { type: "string", enum: ["insert", "keys"], description: "\"keys\" sends every character as its own key press (slower)." } }, required: ["text"] } }, + { name: "press_key", description: "Press one key, optionally with modifiers: Enter, Tab, Backspace, Delete, Escape, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown, or a single character (e.g. key \"a\" with modifiers [\"ctrl\"]).", inputSchema: { type: "object", properties: { ...tabIdProp, key: { type: "string" }, modifiers: { type: "array", items: { type: "string", enum: ["ctrl", "shift", "alt", "meta"] } }, repeat: { type: "integer" } }, required: ["key"] } }, + { name: "paste_html", description: "Paste rich HTML (headings, links, lists, bold…) into the focused editor, or into ref, through the real clipboard and the browser's own paste. The user's clipboard is put back afterwards.", inputSchema: { type: "object", properties: { ...tabIdProp, ...refProp, html: { type: "string" }, text: { type: "string", description: "Plain-text alternative (default: derived from the HTML)." } }, required: ["html"] } }, + { name: "paste_text", description: "Paste plain text through the real clipboard and the browser's own paste. The user's clipboard is put back afterwards.", inputSchema: { type: "object", properties: { ...tabIdProp, ...refProp, text: { type: "string" } }, required: ["text"] } }, + { name: "upload_file", description: "Set a local file on an (by ref). Only files inside the upload folders the user allowed in the connector panel.", inputSchema: { type: "object", properties: { ...tabIdProp, ...refProp, path: { type: "string", description: "Absolute path." }, paths: { type: "array", items: { type: "string" }, description: "Several files, for inputs that accept multiple." } }, required: ["ref"] } }, + { name: "wait_for", description: "Wait until a CSS selector matches, or a text appears, in a tab.", inputSchema: { type: "object", properties: { ...tabIdProp, selector: { type: "string" }, text: { type: "string" }, timeout_ms: { type: "integer", description: "Default 10000, at most 60000." } } } }, +]; +const EVALUATE_DEF = { name: "evaluate", description: "Run JavaScript in the page and return the JSON result. Available only while the user has turned on \"Allow evaluate\" in the connector panel.", inputSchema: { type: "object", properties: { ...tabIdProp, expression: { type: "string" } }, required: ["expression"] } }; + +const text = (s) => ({ content: [{ type: "text", text: String(s) }] }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const htmlToText = (html) => String(html) + .replace(/<(script|style)[\s\S]*?<\/\1>/gi, "") + .replace(//gi, "\n") + .replace(/<\/(p|div|h[1-6]|li|blockquote|pre|tr)>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&") + .replace(/\n{3,}/g, "\n\n").trim(); +const normalizeUrl = (u) => { + const s = String(u || "").trim(); + if (!s) throw new Error("url is required"); + return /^[a-z][a-z0-9+.-]*:/i.test(s) ? s : `https://${s}`; +}; + +class Controller { + constructor({ tabControl, policy, log, getSettings, clipboard, onTabsChanged, onBusy }) { + this.tabControl = tabControl; + this.policy = policy; + this.log = log; + this.getSettings = getSettings; + this.clipboard = clipboard; + this.onTabsChanged = onTabsChanged || (() => {}); + this.onBusy = onBusy || (() => {}); + this.drivers = new Map(); // tabId -> TabDriver + this.inflight = 0; + this._pasteChain = Promise.resolve(); + } + + definitions() { + return this.getSettings().allowEvaluate ? [...DEFS, EVALUATE_DEF] : DEFS; + } + + controlledTabIds() { return [...this.drivers.keys()]; } + + async releaseAll() { + const all = [...this.drivers.values()]; + this.drivers.clear(); + await Promise.all(all.map((d) => d.release().catch(() => {}))); + this.onTabsChanged(); + } + + async release(tabId) { + const d = this.drivers.get(tabId); + if (!d) return; + this.drivers.delete(tabId); + await d.release().catch(() => {}); + this.onTabsChanged(); + } + + _tabRecord(tabId) { + const list = this.tabControl.list(); + if (tabId == null) { + const t = list.find((x) => x.active && x.kind === "web" && x.window === 0) || list.find((x) => x.active && x.kind === "web"); + if (!t) throw new Error("no active web tab; pass tab_id (see list_tabs)"); + return t; + } + const t = list.find((x) => x.id === Number(tabId)); + if (!t) throw new Error(`no tab ${tabId}; see list_tabs`); + return t; + } + + // The tab, approved and attached. Returns { tab, wc, driver, origin }. + async use(tabId, session) { + const tab = this._tabRecord(tabId); + if (tab.kind !== "web") throw new Error(`tab ${tab.id} is not a web page (${tab.kind}); the connector only controls web pages`); + const wc = this.tabControl.webContents(tab.id); + // A dormant tab just started loading: let it reach a URL first. + for (let i = 0; i < 40 && !/^(?:https?|bns):/i.test(wc.getURL()); i++) await sleep(100); + const origin = originOf(wc.getURL() || tab.url); + await this.policy.ensure(origin, { client: session && session.client, action: "control" }); + let driver = this.drivers.get(tab.id); + if (!driver || driver.wc !== wc) { + if (driver) await driver.release().catch(() => {}); + driver = new TabDriver(tab.id, wc, { onGone: (id) => { this.drivers.delete(id); this.onTabsChanged(); } }); + this.drivers.set(tab.id, driver); + this.onTabsChanged(); + } + await driver.ready(); + return { tab, wc, driver, origin }; + } + + async waitLoad(wc, timeoutMs = 20000) { + const until = Date.now() + timeoutMs; + await sleep(150); + while (Date.now() < until) { + if (wc.isDestroyed()) return; + if (!wc.isLoading()) return; + await sleep(150); + } + } + + async call(name, args, session) { + const a = args && typeof args === "object" ? args : {}; + const started = Date.now(); + this.inflight++; + this.onBusy(true); + let origin = null; + let tabId = a.tab_id ?? null; + try { + const out = await this._run(name, a, session, (o, id) => { origin = o; if (id != null) tabId = id; }); + this.log.add({ tool: name, client: session && session.client, tab: tabId, origin, args: summarizeArgs(a), ok: true, ms: Date.now() - started }); + return out; + } catch (e) { + this.log.add({ tool: name, client: session && session.client, tab: tabId, origin, args: summarizeArgs(a), ok: false, error: String(e?.message || e).slice(0, 300), ms: Date.now() - started }); + throw e; + } finally { + this.inflight--; + if (!this.inflight) this.onBusy(false); + } + } + + async _run(name, a, session, note) { + switch (name) { + case "list_tabs": { + const rows = this.tabControl.list().map((t) => { + const o = originOf(t.url); + const approved = o && this.policy.isAllowed(o); + const base = { id: t.id, active: t.active, window: t.window, kind: t.kind, controlled: this.drivers.has(t.id) }; + if (t.kind !== "web") return { ...base, title: t.kind === "settings" ? "Settings" : t.title }; + return approved ? { ...base, title: t.title, url: t.url } : { ...base, host: hostOf(t.url), approved: false }; + }); + return text(JSON.stringify(rows, null, 1)); + } + case "open_tab": { + const url = normalizeUrl(a.url); + const origin = originOf(url); + note(origin); + await this.policy.ensure(origin, { client: session && session.client, action: "open" }); + const id = this.tabControl.open(url, { background: !!a.background }); + note(origin, id); + const wc = this.tabControl.webContents(id); + await this.waitLoad(wc); + return text(JSON.stringify({ tab_id: id, url: wc.getURL(), title: wc.getTitle() })); + } + case "navigate": { + const { tab, wc, driver, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + if (a.action) { + const h = wc.navigationHistory; + if (a.action === "back") { if (!h.canGoBack()) throw new Error("nothing to go back to"); h.goBack(); } + else if (a.action === "forward") { if (!h.canGoForward()) throw new Error("nothing to go forward to"); h.goForward(); } + else if (a.action === "reload") wc.reload(); + else throw new Error(`unknown action ${a.action}`); + } else { + const url = normalizeUrl(a.url); + const target = originOf(url); + if (target !== origin) await this.policy.ensure(target, { client: session && session.client, action: "open" }); + await this.tabControl.navigate(tab.id, url); + } + await this.waitLoad(wc); + driver.refs.clear(); + return text(JSON.stringify({ tab_id: tab.id, url: wc.getURL(), title: wc.getTitle() })); + } + case "activate_tab": { + const { tab, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + this.tabControl.activate(tab.id); + return text(`tab ${tab.id} is in front`); + } + case "close_tab": { + const { tab, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + await this.release(tab.id); + this.tabControl.close(tab.id); + return text(`closed tab ${tab.id}`); + } + case "screenshot": { + const { tab, driver, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + let fronted = false; + // A hidden view renders no frames, so a screenshot would never come. + if (!tab.active) { this.tabControl.activate(tab.id); fronted = true; await sleep(300); } + const s = await driver.screenshot({ fullPage: !!a.full_page, format: a.format }); + return { content: [ + { type: "image", data: s.data, mimeType: s.mimeType }, + { type: "text", text: `${s.width}×${s.height} CSS px${s.truncated ? " (cut at 12000 px)" : ""}${fronted ? `; tab ${tab.id} was brought to the front` : ""}` }, + ] }; + } + case "read_page_text": { + const { tab, driver, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + const r = await driver.text(a.max_chars); + return text(`URL: ${r.url}\nTitle: ${r.title}\n${r.truncated ? `(first ${r.text.length} of ${r.length} characters)\n` : ""}\n${r.text}`); + } + case "snapshot": { + const { tab, driver, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + const info = await driver.pageInfo().catch(() => ({})); + const s = await driver.snapshot({ interactiveOnly: !!a.interactive_only }); + return text(`URL: ${info.url || ""}\nTitle: ${info.title || ""}\n\n${s.lines.join("\n")}${s.truncated ? "\n… (cut; use interactive_only or read_page_text)" : ""}`); + } + case "click": { + const { tab, driver, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + let x = a.x, y = a.y; + if (a.ref) { + const id = driver.backendNode(a.ref); + await driver.assertNotSecret(id); + ({ x, y } = await driver.centerOf(id)); + } else if (!(Number.isFinite(Number(x)) && Number.isFinite(Number(y)))) throw new Error("click needs a ref or x and y"); + await driver.clickAt(Number(x), Number(y), { button: a.button || "left", clickCount: a.double ? 2 : 1 }); + return text(`clicked at ${Math.round(x)},${Math.round(y)}${a.ref ? ` (${a.ref})` : ""}`); + } + case "type": { + const { tab, driver, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + if (typeof a.text !== "string") throw new Error("text is required"); + if (a.ref) { const id = driver.backendNode(a.ref); await driver.assertNotSecret(id); await driver.focus(id); } + const g = await driver.focusGuard(); + await driver.type(a.text, { mode: a.mode === "keys" ? "keys" : "insert" }); + return text(`typed ${a.text.length} characters${g.editable ? "" : " (note: no editable element had focus)"}`); + } + case "press_key": { + const { tab, driver, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + await driver.focusGuard(); + const n = Math.max(1, Math.min(Number(a.repeat) || 1, 100)); + for (let i = 0; i < n; i++) await driver.press(a.key, a.modifiers || []); + return text(`pressed ${(a.modifiers || []).concat(a.key).join("+")}${n > 1 ? ` ×${n}` : ""}`); + } + case "paste_html": + case "paste_text": { + const { tab, wc, driver, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + const isHtml = name === "paste_html"; + const content = isHtml ? a.html : a.text; + if (typeof content !== "string" || !content) throw new Error(`${isHtml ? "html" : "text"} is required`); + if (a.ref) { const id = driver.backendNode(a.ref); await driver.assertNotSecret(id); await driver.focus(id); } + await driver.focusGuard(); + const data = isHtml ? { html: content, text: typeof a.text === "string" ? a.text : htmlToText(content) } : { text: content }; + // One paste at a time: the clipboard is shared. + const run = this._pasteChain.then(() => this._paste(wc, driver, data)); + this._pasteChain = run.catch(() => {}); + await run; + return text(`pasted ${content.length} characters of ${isHtml ? "HTML" : "text"}`); + } + case "upload_file": { + const { tab, driver, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + const list = Array.isArray(a.paths) && a.paths.length ? a.paths : [a.path]; + const dirs = this.getSettings().uploadDirs || []; + const real = list.map((p) => resolveUpload(p, dirs)); + await driver.setFiles(driver.backendNode(a.ref), real); + return text(`set ${real.length} file${real.length === 1 ? "" : "s"} on ${a.ref}`); + } + case "wait_for": { + const { tab, driver, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + if (!a.selector && !a.text) throw new Error("wait_for needs selector or text"); + const r = await driver.waitFor({ selector: a.selector, text: a.text, timeoutMs: a.timeout_ms }); + if (!r.found) throw new Error(`timed out waiting for ${a.selector ? `selector ${a.selector}` : `text "${a.text}"`}`); + return text(`found${r.visible === false ? " (not visible)" : ""}`); + } + case "evaluate": { + if (!this.getSettings().allowEvaluate) throw new Error("evaluate is turned off; the user can allow it in the Claude connector panel"); + const { tab, driver, origin } = await this.use(a.tab_id, session); + note(origin, tab.id); + const r = await driver.evaluate(a.expression); + let out; + try { out = r.value === undefined ? (r.description || r.type) : JSON.stringify(r.value, null, 1); } catch { out = String(r.description || r.type); } + return text(String(out).slice(0, 100000)); + } + default: + throw new Error(`unknown tool ${name}`); + } + } + + // The real clipboard, the browser's own paste, then the user's clipboard + // back. The renderer reads the clipboard when it runs the paste, so the + // old contents return only after a round trip to the page. + async _paste(wc, driver, data) { + const cb = this.clipboard; // lib/clipboard.cjs + let saved = null; + try { saved = await cb.save(); } catch {} + await cb.put(data); + try { + wc.paste(); + await sleep(250); + await driver.isolated("1").catch(() => {}); + await sleep(150); + } finally { + // Nothing was read (the clipboard was busy): leave it rather than wipe it. + if (saved) await cb.restore(saved).catch(() => {}); + } + } +} + +module.exports = { Controller, DEFS, EVALUATE_DEF, htmlToText, normalizeUrl }; diff --git a/bundled-addons/claude-connector/panel.html b/bundled-addons/claude-connector/panel.html new file mode 100644 index 00000000..17b63eee --- /dev/null +++ b/bundled-addons/claude-connector/panel.html @@ -0,0 +1,189 @@ + + + + +Claude connector + + + +
Claude connector…
+ +
+
Accept connections
Off
+
+
+
+ +
+

Connect Claude

+
+
+

Run once in a terminal. --scope user makes it available in every project.

+

+    
+
+ +

The token in this command is the key to this browser. Keep it out of shared files; Revoke token makes a new one.

+
+ +
+

Sites Claude may control

+

Theseus asks the first time Claude acts on a site.

+
    +
    + +
    +

    Upload folders

    +

    Claude can attach files only from these folders.

    +
      +
      +
      + +
      +

      Advanced

      +
      Allow evaluate
      Lets Claude run its own JavaScript in approved sites' pages.
      + +
      + +
      +

      Activity

      +
      Every action, newest first.
      +
      +
      + + + + diff --git a/bundled-addons/claude-connector/stdio-shim.cjs b/bundled-addons/claude-connector/stdio-shim.cjs new file mode 100644 index 00000000..18b84736 --- /dev/null +++ b/bundled-addons/claude-connector/stdio-shim.cjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +// stdio ⇄ Streamable HTTP relay for MCP clients that only start stdio +// servers (Claude Desktop). Run by the Theseus executable itself in Node +// mode, so nothing else needs installing: +// +// "command": "C:\\...\\Theseus Navigator.exe", +// "args": ["\\extensions\\claude-connector\\stdio-shim.cjs"], +// "env": { "ELECTRON_RUN_AS_NODE": "1", "THESEUS_MCP_URL": "http://127.0.0.1:47800/mcp", "THESEUS_MCP_TOKEN": "…" } +// +// THESEUS_MCP_URL may be left out: the shim then reads the address Theseus +// wrote to extensions-data/claude-connector/endpoint.json (THESEUS_USER_DATA, +// else %APPDATA%\Theseus). + +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const readline = require("node:readline"); + +const TOKEN = process.env.THESEUS_MCP_TOKEN || ""; +const err = (...a) => process.stderr.write(`[theseus-shim] ${a.join(" ")}\n`); + +function endpoint() { + if (process.env.THESEUS_MCP_URL) return process.env.THESEUS_MCP_URL; + const ud = process.env.THESEUS_USER_DATA + || (process.platform === "win32" ? path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "Theseus") + : process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support", "Theseus") + : path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "Theseus")); + try { return JSON.parse(fs.readFileSync(path.join(ud, "extensions-data", "claude-connector", "endpoint.json"), "utf8")).url; } + catch { return null; } +} + +let sessionId = null; +let stream = null; +const out = (obj) => process.stdout.write(JSON.stringify(obj) + "\n"); + +function failFor(msg, text) { + if (msg && msg.id !== undefined && msg.id !== null) out({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: text } }); +} + +async function post(msg) { + const url = endpoint(); + if (!url) return failFor(msg, "Theseus is not running, or the Claude connector is off (Settings › Extensions)"); + const headers = { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${TOKEN}` }; + if (sessionId) headers["mcp-session-id"] = sessionId; + let res; + try { res = await fetch(url, { method: "POST", headers, body: JSON.stringify(msg) }); } + catch (e) { return failFor(msg, `Theseus did not answer at ${url} (${e.message})`); } + const sid = res.headers.get("mcp-session-id"); + if (sid && sid !== sessionId) { sessionId = sid; openStream(url); } + if (res.status === 202) return; + const body = await res.text(); + if (!res.ok && !body.startsWith("{")) return failFor(msg, `Theseus refused the request (${res.status}: ${body.trim()})`); + try { + const data = JSON.parse(body); + for (const m of Array.isArray(data) ? data : [data]) out(m); + } catch { failFor(msg, `unreadable answer from Theseus (${res.status})`); } +} + +// Server notifications (tools/list_changed) and the "still connected" signal. +async function openStream(url) { + if (stream) stream.abort(); + const ctl = new AbortController(); + stream = ctl; + try { + const res = await fetch(url, { headers: { accept: "text/event-stream", authorization: `Bearer ${TOKEN}`, "mcp-session-id": sessionId }, signal: ctl.signal }); + if (!res.ok || !res.body) return; + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let i; + while ((i = buf.indexOf("\n\n")) >= 0) { + const block = buf.slice(0, i); + buf = buf.slice(i + 2); + const data = block.split("\n").filter((l) => l.startsWith("data: ")).map((l) => l.slice(6)).join("\n"); + if (data) { try { out(JSON.parse(data)); } catch {} } + } + } + } catch (e) { if (!ctl.signal.aborted) err("event stream closed:", e.message); } +} + +if (!TOKEN) err("THESEUS_MCP_TOKEN is not set; copy the command from the Claude connector panel in Theseus"); + +// Requests are relayed one at a time, in order. +let chain = Promise.resolve(); +const rl = readline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + if (!line.trim()) return; + let msg; + try { msg = JSON.parse(line); } catch { return err("ignored a line that is not JSON"); } + chain = chain.then(() => post(msg)).catch((e) => err(e.message)); +}); +rl.on("close", async () => { + await chain; + if (stream) stream.abort(); + const url = endpoint(); + if (sessionId && url) { try { await fetch(url, { method: "DELETE", headers: { authorization: `Bearer ${TOKEN}`, "mcp-session-id": sessionId } }); } catch {} } + process.exit(0); +}); diff --git a/chrome.html b/chrome.html index 0da20313..ee2613b1 100644 --- a/chrome.html +++ b/chrome.html @@ -346,6 +346,21 @@ .upchip .updismiss { background: transparent; border: none; border-left: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); color: inherit; opacity: .55; padding: 5px 8px; cursor: pointer; font-size: 11px; } .upchip .updismiss:hover { opacity: 1; } + /* Claude connector chip — shown while a Claude client is connected to the + connector extension. Body opens its panel; ✕ stops the connector. The + dot pulses while a tool call runs. Tabs it is driving get .tab.agent. */ + .agentchip { display: inline-flex; align-items: center; background: rgb(217 119 87 / .16); + border: 1px solid rgb(217 119 87 / .55); border-radius: 999px; color: #e08a6c; font-size: 12px; font-weight: 600; overflow: hidden; flex: none; } + .agentchip .agcore { background: transparent; border: none; color: inherit; font: inherit; padding: 5px 9px 5px 10px; + cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; } + .agentchip .agcore:hover { background: rgb(217 119 87 / .12); } + .agentchip .adot { width: 7px; height: 7px; border-radius: 50%; background: #d97757; } + .agentchip.busy .adot { animation: agpulse 0.9s ease-in-out infinite; } + @keyframes agpulse { 50% { opacity: .25; transform: scale(.7); } } + .agentchip .agstop { background: transparent; border: none; border-left: 1px solid rgb(217 119 87 / .35); + color: inherit; padding: 5px 8px; cursor: pointer; font-size: 11px; opacity: .75; } + .agentchip .agstop:hover { opacity: 1; background: rgb(217 119 87 / .2); } + .tab.agent { outline: 2px dashed rgb(217 119 87 / .85); outline-offset: -2px; } /* Password-fill chip — only visible when the vault is unlocked AND the current site has matching credentials. Count sits to the left of the key. */ .pwchip { display: inline-flex; align-items: center; gap: 3px; border: none; background: transparent; color: #3fb950; cursor: pointer; padding: 2px 5px; border-radius: 6px; font-size: 11px; font-weight: 700; } @@ -533,6 +548,7 @@ +