// 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"); const apps = require("./lib/clients.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, apps: appStatus, }; } // ---- Claude apps on this computer ------------------------------------- // "Connect" registers the connector with Claude Code / Claude Desktop in // their own configs (lib/clients.cjs). The apps the user connected this // way are remembered and kept current: the token changes on every stop // and on Revoke, and the port can change, so after each start the // entries are rewritten when they no longer match. const APPS = { claudeCode: apps.claudeCode, claudeDesktop: apps.claudeDesktop }; const linked = () => ({ claudeCode: false, claudeDesktop: false, ...(api.storage.get("linkedApps", {}) || {}) }); let appStatus = null; function appParams() { return { url: server && server.url, token: S.token, exe: process.execPath, shim: path.join(api.folder, "stdio-shim.cjs") }; } function refreshApps() { const params = appParams(); const l = linked(); const out = {}; for (const [id, impl] of Object.entries(APPS)) { try { out[id] = { ...impl.status(params), linked: !!l[id] }; } catch (e) { out[id] = { installed: false, configured: false, current: false, linked: !!l[id], error: e.message }; } } appStatus = out; return out; } async function connectApp(id) { const impl = APPS[id]; if (!impl) throw new Error(`unknown app ${id}`); if (!server) throw new Error("turn on Accept connections first"); const r = await impl.register(appParams()); api.storage.set("linkedApps", { ...linked(), [id]: true }); log.add({ tool: "connector", args: `registered with ${id === "claudeCode" ? "Claude Code" : "Claude Desktop"}`, ok: true }); return r; } async function syncApps() { if (!server) return; const params = appParams(); for (const [id, on] of Object.entries(linked())) { if (!on || !APPS[id]) continue; let st; try { st = APPS[id].status(params); } catch { continue; } if (!st.installed || st.current) continue; try { await APPS[id].register(params); log.add({ tool: "connector", args: `${id === "claudeCode" ? "Claude Code" : "Claude Desktop"} updated to the current address and token`, ok: true }); } catch (e) { log.add({ tool: "connector", args: `could not update ${id}`, ok: false, error: e.message }); } } refreshApps(); pushState(); } 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(); syncApps().catch((e) => api.log("app sync failed:", e.message)); } 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 {} // Rotate the bearer token on every stop so that re-enabling the // connector doesn't resurrect a credential a client (or anyone who // read its config while it was off) still has — matches the mental // model where "off" means "the keys change". The panel's installed- // command strings reflect the new token on the next start. if (s) { api.storage.set("token", newToken()); log.add({ tool: "connector", args: `${reason || "stopped"}; token rotated`, 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(); await syncApps(); return publicState(); }); api.onMessage("apps", () => { refreshApps(); return publicState(); }); api.onMessage("app-connect", async ({ app } = {}) => { const r = await connectApp(String(app || "")); refreshApps(); return { ...publicState(), restart: !!(r && r.restart) }; }); api.onMessage("app-remove", async ({ app } = {}) => { const id = String(app || ""); if (!APPS[id]) throw new Error(`unknown app ${id}`); const r = await APPS[id].unregister(); api.storage.set("linkedApps", { ...linked(), [id]: false }); log.add({ tool: "connector", args: `removed from ${id === "claudeCode" ? "Claude Code" : "Claude Desktop"}`, ok: true }); refreshApps(); return { ...publicState(), restart: !!(r && r.restart) }; }); 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" }); // The first time the extension is turned on, open its panel: the next // step (Connect) is there, and nothing else points to it. if (!api.storage.get("introShown", false)) { api.storage.set("introShown", true); Promise.resolve(api.whenUiReady ? api.whenUiReady() : null) .then(() => api.revealSidebar && api.revealSidebar("main")) .catch(() => {}); } 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"; } }