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".
346 lines
16 KiB
JavaScript
346 lines
16 KiB
JavaScript
// 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"; }
|
||
}
|