theseus/bundled-addons/claude-connector/index.js
Dev a920737c3c Claude connector 0.2.0: connect Claude Code and Claude Desktop from the panel
Setting the connector up meant copying a command with a token into a
terminal, and since 0.3.107 the token rotates whenever the connector
stops, so a configured client silently lost access after any extension
toggle and had to be set up again.

The panel now has a Connect button per app. Claude Code is registered
through its own CLI (claude mcp add-json, user scope), so its config is
written by Claude Code itself; Claude Desktop gets only the theseus entry
in claude_desktop_config.json, with a backup and no write to a file that
does not parse. Apps connected this way are re-registered after every
start and every Revoke when the address or token no longer matches. The
panel opens by itself the first time the extension is turned on. The
copy-a-command route stays under "Set up by hand".
2026-10-24 00:32:06 +03:00

346 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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