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.
This commit is contained in:
Dev 2026-10-23 17:04:03 +03:00
parent 08cc113371
commit c06557e957
20 changed files with 2665 additions and 2 deletions

View file

@ -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,

View file

@ -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 <token from the panel>"
```
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).

View file

@ -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. "<scheme>://<host>/<path>" 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)`);

View file

@ -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 `<userData>/extensions-data/claude-connector.json` (token, port,
switches, approved origins, upload folders) and
`<userData>/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).

View file

@ -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"
]
}

View file

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

View file

@ -0,0 +1,61 @@
// Every connector action, timestamped: kept in memory for the panel and
// appended to <dataDir>/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 };

View file

@ -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 };

View file

@ -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 <token>`. 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<result> }
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<res> }
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 };

View file

@ -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<boolean>
}
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 };

View file

@ -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 (<input type=file>)");
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 };

View file

@ -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 <input type=file> (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(/<br\s*\/?>/gi, "\n")
.replace(/<\/(p|div|h[1-6]|li|blockquote|pre|tr)>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&amp;/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 };

View file

@ -0,0 +1,189 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Claude connector</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --clay:#d97757; --bad:#ff9081;
--btn:#1c2432; --btn-h:#242e40; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#f0f3fa; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --btn:#f0f3fa; --btn-h:#e3e8f2; --bad:#c43c2c; }
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--ink); font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
header { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 2; }
header .t { font-weight: 600; flex: 1; }
.pill { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--mut); white-space: nowrap; }
.pill.on { color: var(--clay); border-color: rgb(217 119 87 / .6); background: rgb(217 119 87 / .12); }
.pill.idle { color: var(--acid); border-color: rgb(from var(--acid) r g b / .35); }
section { padding: 12px 14px; border-bottom: 1px solid var(--line); }
h2 { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); margin: 0 0 8px; font-weight: 600; }
p { margin: 0 0 8px; color: var(--mut); }
.row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
.row .grow { flex: 1; min-width: 0; }
.sw { position: relative; width: 34px; height: 20px; flex: none; }
.sw input { opacity: 0; width: 0; height: 0; position: absolute; }
.sw span { position: absolute; inset: 0; background: var(--btn); border: 1px solid var(--line); border-radius: 999px; cursor: pointer; transition: .15s; }
.sw span::after { content: ""; position: absolute; width: 14px; height: 14px; left: 2px; top: 2px; background: var(--mut); border-radius: 50%; transition: .15s; }
.sw input:checked + span { background: rgb(217 119 87 / .35); border-color: var(--clay); }
.sw input:checked + span::after { transform: translateX(14px); background: var(--clay); }
button.b { font: inherit; font-size: 12px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: var(--btn); color: var(--ink); cursor: pointer; white-space: nowrap; }
button.b:hover { background: var(--btn-h); }
button.b.warn { color: var(--bad); }
button.x { border: none; background: none; color: var(--dim); cursor: pointer; font-size: 13px; padding: 2px 6px; }
button.x:hover { color: var(--bad); }
pre { margin: 6px 0; padding: 8px; background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; font: 11.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 180px; overflow: auto; }
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
.list { margin: 4px 0; padding: 0; list-style: none; }
.list li { display: flex; align-items: center; gap: 6px; padding: 3px 0; border-bottom: 1px dashed var(--line); }
.list li:last-child { border-bottom: none; }
.list li .grow { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.empty { color: var(--dim); font-style: italic; }
.note { font-size: 12px; color: var(--mut); }
.warnbox { font-size: 12px; color: var(--bad); margin-top: 4px; }
.log { font: 11.5px/1.4 ui-monospace, Consolas, monospace; }
.log .e { padding: 4px 0; border-bottom: 1px solid var(--line); }
.log .e .h { display: flex; gap: 6px; flex-wrap: wrap; }
.log .ts { color: var(--dim); }
.log .tool { color: var(--ink); font-weight: 600; }
.log .org { color: var(--mut); }
.log .ok { color: var(--acid); }
.log .bad { color: var(--bad); }
.log .a { color: var(--mut); overflow-wrap: anywhere; }
.tabs { display: flex; gap: 4px; margin-bottom: 6px; }
.tabs button { font: inherit; font-size: 12px; padding: 3px 9px; border: 1px solid var(--line); background: var(--btn); color: var(--mut); border-radius: 6px; cursor: pointer; }
.tabs button.sel { color: var(--ink); border-color: var(--clay); }
</style>
</head>
<body>
<header><span class="t">Claude connector</span><span class="pill" id="pill">…</span></header>
<section>
<div class="row"><div class="grow"><b>Accept connections</b><div class="note" id="where">Off</div></div><label class="sw"><input type="checkbox" id="accept"><span></span></label></div>
<div id="clients" class="note"></div>
<div class="row" style="margin-top:8px"><button class="b" id="disconnect">Disconnect clients</button><button class="b warn" id="revoke" title="Issue a new token. Every client must be set up again with the new command.">Revoke token</button></div>
</section>
<section id="setup">
<h2>Connect Claude</h2>
<div class="tabs"><button id="tCode" class="sel">Claude Code</button><button id="tDesk">Claude Desktop</button></div>
<div id="pCode">
<p>Run once in a terminal. <span class="mono">--scope user</span> makes it available in every project.</p>
<pre id="cmdCode"></pre>
<div class="row"><button class="b" id="copyCode">Copy command</button></div>
</div>
<div id="pDesk" hidden>
<p>Add to <span class="mono">claude_desktop_config.json</span> (Settings › Developer › Edit config), then restart Claude Desktop. It runs through Theseus itself, nothing else to install.</p>
<pre id="cmdDesk"></pre>
<div class="row"><button class="b" id="copyDesk">Copy config</button></div>
</div>
<p class="note">The token in this command is the key to this browser. Keep it out of shared files; Revoke token makes a new one.</p>
</section>
<section>
<h2>Sites Claude may control</h2>
<p>Theseus asks the first time Claude acts on a site.</p>
<ul class="list" id="origins"></ul>
</section>
<section>
<h2>Upload folders</h2>
<p>Claude can attach files only from these folders.</p>
<ul class="list" id="dirs"></ul>
<div class="row"><button class="b" id="addDir">Add folder…</button></div>
</section>
<section>
<h2>Advanced</h2>
<div class="row"><div class="grow"><b>Allow evaluate</b><div class="note">Lets Claude run its own JavaScript in approved sites' pages.</div></div><label class="sw"><input type="checkbox" id="evaluate"><span></span></label></div>
<div class="warnbox" id="evalWarn" hidden>Evaluate runs any script as the page itself, including reading what you see there. Turn it off when you do not need it.</div>
</section>
<section>
<h2>Activity</h2>
<div class="row"><span class="note grow">Every action, newest first.</span><button class="b" id="openLog">Log file</button><button class="b" id="clearLog">Clear</button></div>
<div class="log" id="log"></div>
</section>
<script>
const $ = (id) => document.getElementById(id);
const S = window.silentmode;
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
let state = null;
function render(st) {
state = st;
$("accept").checked = !!st.accept;
const pill = $("pill");
if (st.connected) { pill.textContent = "Connected"; pill.className = "pill on"; }
else if (st.listening) { pill.textContent = "Waiting for Claude"; pill.className = "pill idle"; }
else { pill.textContent = "Off"; pill.className = "pill"; }
$("where").textContent = st.listening ? `Listening on ${st.url} (this computer only)` : st.accept ? "Starting…" : "Not listening";
const sess = st.sessions || [];
$("clients").innerHTML = sess.length
? sess.map((s) => `${esc(s.client)} — connected since ${esc(new Date(s.since).toLocaleTimeString())}${s.streaming ? "" : ", idle"}`).join("<br>") + (st.controlledTabs.length ? `<br>Controlling tab${st.controlledTabs.length === 1 ? "" : "s"} ${st.controlledTabs.join(", ")}` : "")
: (st.listening ? "No client connected." : "");
$("setup").hidden = !st.commands;
if (st.commands) { $("cmdCode").textContent = st.commands.claudeCode; $("cmdDesk").textContent = st.commands.claudeDesktop; }
$("origins").innerHTML = st.allowedOrigins.length
? st.allowedOrigins.map((o) => `<li><span class="grow mono">${esc(o)}</span><button class="x" data-origin="${esc(o)}" title="Remove">✕</button></li>`).join("")
: '<li class="empty">None yet.</li>';
$("dirs").innerHTML = st.uploadDirs.length
? st.uploadDirs.map((d) => `<li><span class="grow mono">${esc(d)}</span><button class="x" data-dir="${esc(d)}" title="Remove">✕</button></li>`).join("")
: '<li class="empty">None. Uploads are refused.</li>';
$("evaluate").checked = !!st.allowEvaluate;
$("evalWarn").hidden = !st.allowEvaluate;
}
function entryHtml(e) {
const t = new Date(e.ts);
const ts = `${t.toLocaleDateString()} ${t.toLocaleTimeString()}`;
return `<div class="e"><div class="h"><span class="ts">${esc(ts)}</span><span class="tool">${esc(e.tool)}</span>${e.tab != null ? `<span class="org">tab ${esc(e.tab)}</span>` : ""}${e.origin ? `<span class="org">${esc(e.origin)}</span>` : ""}<span class="${e.ok ? "ok" : "bad"}">${e.ok ? "ok" : "failed"}</span>${e.ms != null ? `<span class="ts">${esc(e.ms)} ms</span>` : ""}</div>${e.args ? `<div class="a">${esc(e.args)}</div>` : ""}${e.error ? `<div class="bad">${esc(e.error)}</div>` : ""}</div>`;
}
async function loadLog() {
const list = await S.invoke("log", { n: 200 });
$("log").innerHTML = list.slice().reverse().map(entryHtml).join("") || '<div class="empty">Nothing yet.</div>';
}
$("accept").onchange = async (e) => render(await S.invoke("set-accept", { on: e.target.checked }));
$("evaluate").onchange = async (e) => render(await S.invoke("set-evaluate", { on: e.target.checked }));
$("disconnect").onclick = async () => render(await S.invoke("disconnect"));
// Two clicks: every client has to be set up again afterwards.
let revokeArmed = null;
$("revoke").onclick = async () => {
const b = $("revoke");
if (!revokeArmed) { b.textContent = "Click again to revoke"; revokeArmed = setTimeout(() => { revokeArmed = null; b.textContent = "Revoke token"; }, 4000); return; }
clearTimeout(revokeArmed); revokeArmed = null; b.textContent = "Revoke token";
render(await S.invoke("revoke-token"));
};
$("addDir").onclick = async () => render(await S.invoke("add-upload-dir"));
$("copyCode").onclick = () => state && S.invoke("copy", { text: state.commands.claudeCode }).then(() => flash("copyCode"));
$("copyDesk").onclick = () => state && S.invoke("copy", { text: state.commands.claudeDesktop }).then(() => flash("copyDesk"));
$("clearLog").onclick = async () => { await S.invoke("clear-log"); loadLog(); };
$("openLog").onclick = () => S.invoke("open-log-folder");
$("tCode").onclick = () => { $("pCode").hidden = false; $("pDesk").hidden = true; $("tCode").classList.add("sel"); $("tDesk").classList.remove("sel"); };
$("tDesk").onclick = () => { $("pCode").hidden = true; $("pDesk").hidden = false; $("tDesk").classList.add("sel"); $("tCode").classList.remove("sel"); };
document.body.addEventListener("click", async (e) => {
const o = e.target.closest("[data-origin]");
if (o) render(await S.invoke("remove-origin", { origin: o.dataset.origin }));
const d = e.target.closest("[data-dir]");
if (d) render(await S.invoke("remove-upload-dir", { dir: d.dataset.dir }));
});
function flash(id) { const b = $(id), t = b.textContent; b.textContent = "Copied"; setTimeout(() => { b.textContent = t; }, 1200); }
S.on("status", render);
S.on("log-entry", (e) => {
const box = $("log");
if (box.querySelector(".empty")) box.innerHTML = "";
box.insertAdjacentHTML("afterbegin", entryHtml(e));
while (box.children.length > 300) box.lastChild.remove();
});
S.invoke("state").then(render);
loadLog();
</script>
</body>
</html>

View file

@ -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": ["<userData>\\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);
});

View file

@ -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 @@
<input id="search" placeholder="Search" spellcheck="false">
</div>
</div>
<span class="agentchip" id="agentchip" hidden><button class="agcore" id="agentOpen" title="A Claude client is connected to this browser. Open the connector's panel and log."><span class="adot"></span><span id="agentLabel">Claude</span></button><button class="agstop" id="agentStop" title="Stop the Claude connector now: disconnect every client and release every tab">✕</button></span>
<button class="ic dlbtn" id="downloads" title="Downloads">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2 V10 M4.5 6.5 L8 10 L11.5 6.5"/><path d="M3 12.5 L3 13.5 L13 13.5 L13 12.5"/></svg>
<span class="dlbadge" id="dlbadge" hidden>0</span>
@ -739,6 +755,22 @@
T.getDownloads && T.getDownloads().then(renderDownloads);
T.onDownloads && T.onDownloads(renderDownloads);
// ---- Claude connector chip: visible while a client is connected ----
const agentChip = $("agentchip");
function renderAgent(st) {
const on = !!(st && st.connected);
agentChip.hidden = !on;
agentChip.classList.toggle("busy", on && !!st.busy);
const n = (st && st.tabIds && st.tabIds.length) || 0;
$("agentLabel").textContent = n ? `Claude · ${n} tab${n === 1 ? "" : "s"}` : "Claude";
const who = st && st.clients && st.clients.length ? st.clients.join(", ") : "A Claude client";
$("agentOpen").title = `${who} is connected to this browser${n ? ` and controlling ${n} tab${n === 1 ? "" : "s"}` : ""}. Open the connector's panel and log.`;
}
$("agentOpen").onclick = () => T.agentControlOpen && T.agentControlOpen();
$("agentStop").onclick = () => T.agentControlStop && T.agentControlStop();
T.agentControlState && T.agentControlState().then(renderAgent);
T.onAgentControl && T.onAgentControl(renderAgent);
// ---- website-language pill: shows the language sites see us in ----
// "AUTO" (2-letter tone) when following the OS locale; a two-letter
// uppercase code (e.g. "DE", "PT") when a specific language is pinned.
@ -1680,6 +1712,7 @@
el.querySelector(".mute").hidden = !t.muted;
el.querySelector(".ask").hidden = !t.asking;
el.querySelector(".bg").hidden = !t.keepRunning;
el.classList.toggle("agent", !!t.agent);
// Icon slot: spinner while loading, otherwise the favicon. The <img> is
// only touched when the URL actually changes, so it never reloads.
const ico = el.querySelector(".ico");

120
main.js
View file

@ -589,6 +589,9 @@ const SETTINGS_DEFAULTS = {
// Add-on framework: ids the user has explicitly turned off. Installed but
// disabled add-ons are still discovered — they just never activate.
disabledAddons: [],
// Built-in add-ons that ship turned off ("defaultEnabled": false) and have
// already been turned off once, so the user's later choice stands.
addonsDefaultOffSeen: [],
// Toolbar extension dock: user-chosen button order (keys "p:<panelId>" /
// "m:<addonId>", unknown keys keep registration order after these) and
// buttons hidden from the toolbar via the dock's right-click menu.
@ -2999,7 +3002,7 @@ function firstPartyAddonIds() {
// leave the bundle: their extensions-data/<id>.json and vault.derive
// namespace (e.g. pithos/sia-recovery/v1) outlive them, and a community
// add-on installed under a dropped id would inherit both.
for (const id of ["bchwallet", "siawallet", "aegis", "blocker", "consent", "docx-editor", "notepad", "pdf-editor", "pithos", "recorder", "screenshot", "translate", "vpn"]) {
for (const id of ["bchwallet", "siawallet", "aegis", "blocker", "consent", "docx-editor", "notepad", "pdf-editor", "pithos", "recorder", "screenshot", "translate", "vpn", "claude-connector"]) {
if (!bundled.has(id)) absorbed.add(id);
}
firstPartyIdsCache = { bundled, absorbed };
@ -3131,6 +3134,117 @@ function installRequestFilter() {
callback(block && details.resourceType !== "mainFrame" ? { cancel: true } : {});
});
}
// ---- tab-control (add-on capability; the Claude connector) ----
// A built-in add-on that lets a local agent work in this profile gets the
// tabs through here: a list, open / navigate / close / focus, and one web
// tab's webContents at a time for its own debugger session. Only ordinary
// web pages are handed out. A Settings tab would let the agent change the
// connector's own safety switches; add-on and home pages carry privileged
// preloads.
const agentStopCallbacks = new Set();
let agentStatus = { connected: false, busy: false, clients: [], tabIds: [] };
function tabControlKind(t) {
if (t.settings) return "settings";
if (t.addonId) return "addon";
const u = (t.pending && t.pending.url) || (() => { try { return t.view.webContents.getURL(); } catch { return ""; } })() || t.url || "";
return /^(?:https?|bns):\/\//i.test(u) ? "web" : "home";
}
function tabControlTab(id) {
const t = tabById(Number(id));
if (!t) throw new Error(`no tab ${id}`);
return t;
}
const tabControlHook = {
list: () => tabs.map((t) => ({
id: t.id,
title: t.title || "",
url: (t.pending && t.pending.url) || t.url || "",
kind: tabControlKind(t),
active: isShownTabId(t.id),
pending: !!t.pending,
window: Math.max(0, windows.indexOf(t.w)),
})),
webContents: (id) => {
const t = tabControlTab(id);
if (tabControlKind(t) !== "web") throw new Error("only web pages can be controlled (not Settings, extension or home tabs)");
if (t.pending) withWin(t.w, () => materializePending(t));
thawTab(t);
const wc = t.view.webContents;
if (!wc || wc.isDestroyed()) throw new Error(`tab ${id} is closed`);
return wc;
},
open: (url, opts, addonId) => {
if (!/^(?:https?|bns):\/\//i.test(url)) throw new Error("open needs an http(s) URL");
const id = withWin(primaryW, () => createTab(url, { background: !!opts.background }));
console.log(`[addons] [${addonId}] opened tab ${id}`);
return id;
},
navigate: (id, url, addonId) => {
const t = tabControlTab(id);
if (tabControlKind(t) !== "web") throw new Error("only web pages can be navigated");
if (!/^(?:https?|bns):\/\//i.test(url)) throw new Error("navigate needs an http(s) URL");
console.log(`[addons] [${addonId}] navigate tab ${t.id}`);
return withWin(t.w, () => navigateTab(t.id, url));
},
close: (id, addonId) => {
const t = tabControlTab(id);
if (tabControlKind(t) !== "web") throw new Error("only web tabs can be closed");
console.log(`[addons] [${addonId}] close tab ${t.id}`);
closeTab(t.id);
},
activate: (id) => { const t = tabControlTab(id); setActive(t.id); },
setStatus: (st) => {
const next = {
connected: !!st.connected,
busy: !!st.busy,
clients: (Array.isArray(st.clients) ? st.clients : []).map((c) => String(c).slice(0, 60)).slice(0, 8),
tabIds: (Array.isArray(st.tabIds) ? st.tabIds : []).map(Number).filter(Number.isFinite),
};
const tabsChanged = next.tabIds.join(",") !== agentStatus.tabIds.join(",");
agentStatus = next;
sendAllChrome("agent-control", agentStatus);
if (tabsChanged) emitTabs();
},
onStop: (cb) => { agentStopCallbacks.add(cb); return () => agentStopCallbacks.delete(cb); },
};
const isChromeSender = (sender) => windows.some((w) => w.chrome && w.chrome.webContents === sender);
ipcMain.handle("agent-control-state", () => agentStatus);
// The toolbar chip's ✕: stop the connector now (it closes its port and
// detaches from every tab).
ipcMain.handle("agent-control-stop", (e) => {
if (!isChromeSender(e.sender)) return false;
for (const cb of agentStopCallbacks) { try { cb(); } catch (err) { console.warn("[addons] agent stop failed:", err?.message); } }
agentStatus = { connected: false, busy: false, clients: [], tabIds: [] };
sendAllChrome("agent-control", agentStatus);
emitTabs();
return true;
});
ipcMain.handle("agent-control-open", (e) => {
if (!isChromeSender(e.sender)) return false;
try { withWin(primaryW, () => setSidebar(true, "claude-connector:main")); } catch {}
return true;
});
// Built-in add-ons whose manifest says "defaultEnabled": false start out
// turned off. Done once per add-on and remembered, so it never overrides
// the user's own choice later.
function applyDefaultOffAddons() {
const seen = new Set(Array.isArray(settings.addonsDefaultOffSeen) ? settings.addonsDefaultOffSeen : []);
let changed = false;
try {
for (const de of fs.readdirSync(bundledAddonsDir(), { withFileTypes: true })) {
if (!de.isDirectory()) continue;
let m = null;
try { m = JSON.parse(fs.readFileSync(path.join(bundledAddonsDir(), de.name, "addon.json"), "utf8")); } catch {}
if (!m || !m.id || m.defaultEnabled !== false || seen.has(m.id)) continue;
seen.add(m.id);
const off = new Set(Array.isArray(settings.disabledAddons) ? settings.disabledAddons : []);
off.add(m.id);
settings.disabledAddons = [...off];
changed = true;
}
} catch {}
if (changed) { settings.addonsDefaultOffSeen = [...seen]; saveSettings(); }
}
function initAddons() {
// Promote any signed add-on update staged by a previous run BEFORE we
// reseed from the bundle — a fresh install of a newer version from
@ -3146,8 +3260,10 @@ function initAddons() {
// aegis/ + siawallet-less state is what AddonHost enumerates.
migrateAegisRename();
seedBundledAddons();
applyDefaultOffAddons();
addonHost = new AddonHost({
addonsDir: addonsUserDir(),
tabControl: tabControlHook,
// request-filter capability: add-ons register a decision function;
// main owns the session's one onBeforeRequest listener (see
// installRequestFilter) and asks every registered filter.
@ -4674,7 +4790,7 @@ function emitTabsHere() {
const t = activeTab();
const wc = t?.view.webContents;
chrome?.webContents.send("tabs", {
tabs: winTabs().map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "", asking: tabHasPendingDialog(x.id), keepRunning: !!x.keepRunning })),
tabs: winTabs().map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "", asking: tabHasPendingDialog(x.id), keepRunning: !!x.keepRunning, agent: agentStatus.tabIds.includes(x.id) })),
collapsedGroups: [...tabGroupCollapsed],
url: t?.url || "",
loading: !!t?.loading,

View file

@ -128,4 +128,10 @@ contextBridge.exposeInMainWorld("theseus", {
// Chrome listens so it can drop the "active" tint when the native menu
// dismisses (Esc, outside click, item picked — main fires all three).
onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()),
// Claude connector indicator: {connected, busy, clients, tabIds}. The chip
// opens the connector's panel; its ✕ stops the connector.
agentControlState: () => ipcRenderer.invoke("agent-control-state"),
onAgentControl: (cb) => ipcRenderer.on("agent-control", (_e, d) => cb(d)),
agentControlOpen: () => ipcRenderer.invoke("agent-control-open"),
agentControlStop: () => ipcRenderer.invoke("agent-control-stop"),
});

View file

@ -2988,6 +2988,7 @@
"vault-derive": "Derives its own keys from your password vault. It never sees the vault itself.",
"approval-modal": "Can ask you to approve an action in a dialog over the page.",
"session-proxy": "Can route the browser's traffic through a proxy it controls.",
"tab-control": "Can open, read and operate your web tabs for a program on this computer that you connect to it, on sites you approve one by one.",
};
function renderAddons(snap) {
lastAddonSnap = snap;

View file

@ -0,0 +1,334 @@
#!/usr/bin/env node
// Claude connector, the parts that need no browser: MCP over HTTP (auth,
// browser and DNS-rebinding refusals, sessions, the SSE presence stream),
// the site policy, upload-path checks, password-field detection and the log.
//
// Usage: node tests/claude-connector-unit.test.mjs
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const here = path.dirname(fileURLToPath(import.meta.url));
const lib = (f) => require(path.join(here, "..", "bundled-addons", "claude-connector", "lib", f));
const { McpServer, tokenMatches } = lib("mcp-server.cjs");
const { SitePolicy, resolveUpload, isSecretField, originOf } = lib("policy.cjs");
const { ActionLog, summarizeArgs } = lib("action-log.cjs");
const { htmlToText, normalizeUrl } = lib("tools.cjs");
const { keyInfo } = lib("tab-driver.cjs");
let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); passed++; console.log(` ok ${name}`); }
catch (e) { failed++; console.log(` FAIL ${name}\n ${e?.stack || e}`); }
}
function eq(a, b, msg) { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${msg || "not equal"}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`); }
function ok(v, msg) { if (!v) throw new Error(msg || "expected truthy"); }
async function rejects(p, re) {
try { await p; } catch (e) { if (re && !re.test(e.message)) throw new Error(`wrong error: ${e.message}`); return; }
throw new Error("expected a rejection");
}
function request(port, { method = "POST", headers = {}, body, host } = {}) {
return new Promise((resolve, reject) => {
const req = http.request({ host: "127.0.0.1", port, path: "/mcp", method, headers: { host: host || `127.0.0.1:${port}`, ...headers } }, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
let json = null; try { json = JSON.parse(text); } catch {}
resolve({ status: res.statusCode, headers: res.headers, text, json });
});
});
req.on("error", reject);
if (body !== undefined) req.write(typeof body === "string" ? body : JSON.stringify(body));
req.end();
});
}
console.log("mcp server");
const TOKEN = "test-token-abc";
let statuses = [];
const srv = new McpServer({
getToken: () => TOKEN,
tools: {
list: () => [{ name: "echo", description: "echo", inputSchema: { type: "object", properties: { s: { type: "string" } } } }, { name: "boom", description: "fails", inputSchema: { type: "object" } }],
call: async (name, args) => { if (name === "boom") throw new Error("kaboom"); return { content: [{ type: "text", text: `echo:${args.s}` }] }; },
},
serverInfo: { name: "theseus", version: "test" },
onStatus: (s) => statuses.push(s),
});
const port = await srv.listen(0);
const auth = { authorization: `Bearer ${TOKEN}`, "content-type": "application/json", accept: "application/json, text/event-stream" };
let sid = null;
await test("binds 127.0.0.1", () => { eq(srv.server.address().address, "127.0.0.1"); });
await test("no token → 401", async () => {
const r = await request(port, { body: { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, headers: { "content-type": "application/json" } });
eq(r.status, 401);
ok(String(r.headers["www-authenticate"]).startsWith("Bearer"));
});
await test("wrong token → 401", async () => {
const r = await request(port, { body: { jsonrpc: "2.0", id: 1, method: "initialize" }, headers: { ...auth, authorization: "Bearer nope" } });
eq(r.status, 401);
});
await test("request with Origin (a web page) → 403 even with the token", async () => {
const r = await request(port, { body: { jsonrpc: "2.0", id: 1, method: "initialize" }, headers: { ...auth, origin: "https://evil.example" } });
eq(r.status, 403);
});
await test("foreign Host header (DNS rebinding) → 403", async () => {
const r = await request(port, { body: { jsonrpc: "2.0", id: 1, method: "initialize" }, headers: auth, host: `evil.example:${port}` });
eq(r.status, 403);
});
await test("request before initialize → 400", async () => {
const r = await request(port, { body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, headers: auth });
eq(r.status, 400);
});
await test("initialize negotiates a version and returns a session id", async () => {
const r = await request(port, { body: { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", clientInfo: { name: "claude-code", version: "9" }, capabilities: {} } }, headers: auth });
eq(r.status, 200);
eq(r.json.result.protocolVersion, "2025-03-26");
eq(r.json.result.serverInfo.name, "theseus");
ok(r.json.result.capabilities.tools);
sid = r.headers["mcp-session-id"];
ok(/^[0-9a-f]{32}$/.test(sid), "session id");
});
await test("unknown protocol version → server's latest", async () => {
const r = await request(port, { body: { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "1999-01-01" } }, headers: auth });
eq(r.json.result.protocolVersion, "2025-06-18");
});
await test("notification → 202, no body", async () => {
const r = await request(port, { body: { jsonrpc: "2.0", method: "notifications/initialized" }, headers: { ...auth, "mcp-session-id": sid } });
eq(r.status, 202);
eq(r.text, "");
});
await test("unknown session → 404 (client re-initializes)", async () => {
const r = await request(port, { body: { jsonrpc: "2.0", id: 2, method: "tools/list" }, headers: { ...auth, "mcp-session-id": "f".repeat(32) } });
eq(r.status, 404);
});
await test("tools/list and tools/call", async () => {
const h = { ...auth, "mcp-session-id": sid };
const l = await request(port, { body: { jsonrpc: "2.0", id: 3, method: "tools/list" }, headers: h });
eq(l.json.result.tools.map((t) => t.name), ["echo", "boom"]);
const c = await request(port, { body: { jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "echo", arguments: { s: "hi" } } }, headers: h });
eq(c.json.result.content[0].text, "echo:hi");
});
await test("a tool error is a result with isError, not a protocol error", async () => {
const c = await request(port, { body: { jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "boom" } }, headers: { ...auth, "mcp-session-id": sid } });
eq(c.json.result.isError, true);
ok(/kaboom/.test(c.json.result.content[0].text));
});
await test("unknown tool / method", async () => {
const h = { ...auth, "mcp-session-id": sid };
const t = await request(port, { body: { jsonrpc: "2.0", id: 6, method: "tools/call", params: { name: "nope" } }, headers: h });
eq(t.json.error.code, -32602);
const m = await request(port, { body: { jsonrpc: "2.0", id: 7, method: "resources/list" }, headers: h });
eq(m.json.error.code, -32601);
});
await test("batch", async () => {
const r = await request(port, { body: [{ jsonrpc: "2.0", id: 8, method: "ping" }, { jsonrpc: "2.0", method: "notifications/initialized" }, { jsonrpc: "2.0", id: 9, method: "ping" }], headers: { ...auth, "mcp-session-id": sid } });
eq(r.json.map((x) => x.id), [8, 9]);
});
await test("bad JSON → -32700", async () => {
const r = await request(port, { body: "{nope", headers: { ...auth, "mcp-session-id": sid } });
eq(r.json.error.code, -32700);
});
await test("connected status and client name", () => {
const st = srv.status();
ok(st.connected);
ok(st.clients.includes("claude-code"));
ok(statuses.some((s) => s.connected));
});
await test("GET opens an SSE stream that carries tools/list_changed", async () => {
const got = await new Promise((resolve, reject) => {
const req = http.request({ host: "127.0.0.1", port, path: "/mcp", method: "GET", headers: { host: `127.0.0.1:${port}`, authorization: `Bearer ${TOKEN}`, accept: "text/event-stream", "mcp-session-id": sid } }, (res) => {
let buf = "";
res.on("data", (c) => {
buf += c;
if (buf.includes(": theseus") && !buf.includes("list_changed")) {
ok(srv.status().sessions.some((s) => s.streaming), "streaming");
srv.notifyToolsChanged();
}
if (buf.includes("notifications/tools/list_changed")) { req.destroy(); resolve(buf); }
});
});
req.on("error", (e) => { if (e.code !== "ECONNRESET") reject(e); });
req.end();
setTimeout(() => reject(new Error("no SSE")), 3000);
});
ok(got.includes("event: message"));
});
await test("DELETE ends the session", async () => {
const d = await request(port, { method: "DELETE", headers: { authorization: `Bearer ${TOKEN}`, "mcp-session-id": sid } });
eq(d.status, 200);
const r = await request(port, { body: { jsonrpc: "2.0", id: 10, method: "ping" }, headers: { ...auth, "mcp-session-id": sid } });
eq(r.status, 404);
});
await test("close stops listening", async () => {
await srv.close();
await rejects(request(port, { body: {} }), /ECONNREFUSED|ECONNRESET|connect/);
});
await test("tokenMatches is exact", () => { ok(tokenMatches("a", "a")); ok(!tokenMatches("a", "b")); ok(!tokenMatches("", "")); ok(!tokenMatches("a", null)); });
console.log("stdio shim");
await test("the shim relays stdio JSON-RPC to the HTTP endpoint and back", async () => {
const { spawn } = await import("node:child_process");
const s2 = new McpServer({ getToken: () => TOKEN, tools: { list: () => [{ name: "echo", description: "e", inputSchema: { type: "object" } }], call: async (n, a) => ({ content: [{ type: "text", text: `echo:${a.s}` }] }) } });
const p2 = await s2.listen(0);
// SHIM_EXEC lets the same check run under Theseus/Electron in Node mode.
const exec = process.env.SHIM_EXEC || process.execPath;
const child = spawn(exec, [path.join(here, "..", "bundled-addons", "claude-connector", "stdio-shim.cjs")], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", THESEUS_MCP_URL: `http://127.0.0.1:${p2}/mcp`, THESEUS_MCP_TOKEN: TOKEN },
stdio: ["pipe", "pipe", "pipe"],
});
const lines = [];
let buf = "";
child.stdout.on("data", (d) => { buf += d; let i; while ((i = buf.indexOf("\n")) >= 0) { lines.push(JSON.parse(buf.slice(0, i))); buf = buf.slice(i + 1); } });
const send = (m) => child.stdin.write(JSON.stringify(m) + "\n");
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", clientInfo: { name: "claude-desktop" } } });
send({ jsonrpc: "2.0", method: "notifications/initialized" });
send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "echo", arguments: { s: "via-shim" } } });
for (let i = 0; i < 100 && lines.length < 2; i++) await new Promise((r) => setTimeout(r, 50));
// The shim also holds an SSE stream open: the client shows as connected.
for (let i = 0; i < 40 && !s2.status().sessions.some((x) => x.streaming); i++) await new Promise((r) => setTimeout(r, 50));
const streaming = s2.status().sessions.some((x) => x.streaming && x.client === "claude-desktop");
child.stdin.end();
await new Promise((r) => child.on("exit", r));
await s2.close();
eq(lines.map((l) => l.id), [1, 2]);
eq(lines[1].result.content[0].text, "echo:via-shim");
ok(streaming, "SSE presence stream");
});
await test("the shim reports a wrong token as a JSON-RPC error", async () => {
const { spawn } = await import("node:child_process");
const s3 = new McpServer({ getToken: () => TOKEN, tools: { list: () => [], call: async () => ({}) } });
const p3 = await s3.listen(0);
const child = spawn(process.env.SHIM_EXEC || process.execPath, [path.join(here, "..", "bundled-addons", "claude-connector", "stdio-shim.cjs")], { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", THESEUS_MCP_URL: `http://127.0.0.1:${p3}/mcp`, THESEUS_MCP_TOKEN: "wrong" }, stdio: ["pipe", "pipe", "pipe"] });
let out = "";
child.stdout.on("data", (d) => { out += d; });
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }) + "\n");
for (let i = 0; i < 100 && !out.includes("\n"); i++) await new Promise((r) => setTimeout(r, 50));
child.stdin.end();
await new Promise((r) => child.on("exit", r));
await s3.close();
const msg = JSON.parse(out.trim());
eq(msg.id, 1);
ok(/401|token/.test(msg.error.message), msg.error.message);
});
console.log("site policy");
await test("origins: http(s) and BCNR only", () => {
eq(originOf("https://Medium.com/new-story?x=1"), "https://medium.com");
eq(originOf("http://127.0.0.1:8080/a"), "http://127.0.0.1:8080");
eq(originOf("bns://silentmode.bch/"), "https://silentmode.bch");
eq(originOf("file:///C:/x"), null);
eq(originOf("theseus://settings"), null);
});
await test("allow is remembered, session is not stored, deny cools down, prompts are shared", async () => {
let stored = [];
const asked = [];
const answers = { "https://a.example": "allow", "https://s.example": "session", "https://d.example": "deny" };
const p = new SitePolicy({ getAllowed: () => stored, setAllowed: (l) => { stored = l; }, ask: async (o) => { asked.push(o); await new Promise((r) => setTimeout(r, 30)); return answers[o]; } });
await Promise.all([p.ensure("https://a.example"), p.ensure("https://a.example"), p.ensure("https://a.example")]);
eq(asked, ["https://a.example"], "one prompt for concurrent calls");
eq(stored, ["https://a.example"]);
await p.ensure("https://a.example");
eq(asked.length, 1, "no prompt once allowed");
await p.ensure("https://s.example");
ok(p.isAllowed("https://s.example"));
eq(stored, ["https://a.example"], "session approval is not stored");
await rejects(p.ensure("https://d.example"), /did not allow/);
await rejects(p.ensure("https://d.example"), /did not allow/);
eq(asked.filter((o) => o === "https://d.example").length, 1, "refusal is not re-asked right away");
p.forget("https://a.example");
eq(stored, []);
await rejects(p.ensure(null), /no web origin/);
});
await test("cancel (Escape) counts as a refusal", async () => {
const p = new SitePolicy({ getAllowed: () => [], setAllowed: () => {}, ask: async () => "cancel" });
await rejects(p.ensure("https://c.example"), /did not allow/);
});
console.log("uploads");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-unit-"));
const allowed = path.join(tmp, "allowed");
const outside = path.join(tmp, "outside");
fs.mkdirSync(allowed); fs.mkdirSync(outside);
fs.writeFileSync(path.join(allowed, "a.png"), "x");
fs.writeFileSync(path.join(outside, "b.png"), "y");
await test("inside an allowed folder", () => { ok(resolveUpload(path.join(allowed, "a.png"), [allowed]).toLowerCase().endsWith("a.png")); });
await test("outside, relative, missing, a folder, no folders", async () => {
await rejects(Promise.resolve().then(() => resolveUpload(path.join(outside, "b.png"), [allowed])), /outside/);
await rejects(Promise.resolve().then(() => resolveUpload(path.join(allowed, "..", "outside", "b.png"), [allowed])), /outside/);
await rejects(Promise.resolve().then(() => resolveUpload("a.png", [allowed])), /absolute/);
await rejects(Promise.resolve().then(() => resolveUpload(path.join(allowed, "nope.png"), [allowed])), /not found/);
await rejects(Promise.resolve().then(() => resolveUpload(allowed, [allowed])), /regular file/);
await rejects(Promise.resolve().then(() => resolveUpload(path.join(allowed, "a.png"), [])), /no upload folder/);
});
await test("a link inside the folder that points outside is refused", async () => {
const link = path.join(allowed, "sneaky");
try { fs.symlinkSync(outside, link, "junction"); } catch (e) { console.log(` (skipped: ${e.code})`); return; }
await rejects(Promise.resolve().then(() => resolveUpload(path.join(link, "b.png"), [allowed])), /outside/);
});
await test("a sibling folder sharing the prefix is refused", async () => {
const sib = allowed + "-evil";
fs.mkdirSync(sib);
fs.writeFileSync(path.join(sib, "c.png"), "z");
await rejects(Promise.resolve().then(() => resolveUpload(path.join(sib, "c.png"), [allowed])), /outside/);
});
console.log("password fields");
await test("isSecretField", () => {
ok(isSecretField({ localName: "input", attributes: ["type", "password", "name", "pw"] }));
ok(isSecretField({ localName: "input", attributes: ["type", "PASSWORD"] }));
ok(isSecretField({ localName: "input", attributes: ["type", "text", "autocomplete", "current-password"] }));
ok(isSecretField({ localName: "input", attributes: ["autocomplete", "one-time-code"] }));
ok(!isSecretField({ localName: "input", attributes: ["type", "text", "autocomplete", "username"] }));
ok(!isSecretField({ localName: "div", attributes: ["contenteditable", "true"] }));
});
console.log("helpers");
await test("summarizeArgs clips typed text and skips tab_id", () => {
const s = summarizeArgs({ tab_id: 3, text: "x".repeat(200), ref: "e4" });
ok(s.startsWith("text: 200 chars"), s);
ok(s.includes("ref: e4"));
ok(!s.includes("tab_id"));
ok(s.length < 120);
});
await test("action log appends, rotates in memory, survives reload", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-log-"));
const seen = [];
const l = new ActionLog(dir, { onEntry: (e) => seen.push(e) });
l.add({ tool: "click", ok: true });
l.add({ tool: "type", ok: false, error: "refused" });
eq(seen.length, 2);
ok(/^\d{4}-\d\d-\d\dT/.test(seen[0].ts), "ISO timestamp");
const l2 = new ActionLog(dir);
eq(l2.recent().map((e) => e.tool), ["click", "type"]);
l2.add({ tool: "x" });
eq(l2.recent().at(-1).seq, 3);
l2.clear();
eq(new ActionLog(dir).recent().length, 0);
});
await test("htmlToText and normalizeUrl", () => {
eq(htmlToText("<h2>Title</h2><p>See <a href='x'>this</a> &amp; that</p>"), "Title\nSee this & that");
eq(normalizeUrl("medium.com/new-story"), "https://medium.com/new-story");
eq(normalizeUrl("http://127.0.0.1:1/"), "http://127.0.0.1:1/");
});
await test("keyInfo", () => {
eq(keyInfo("Enter").vk, 13);
eq(keyInfo("enter").key, "Enter");
eq(keyInfo("a").code, "KeyA");
eq(keyInfo("7").code, "Digit7");
eq(keyInfo("é").text, "é");
let threw = false; try { keyInfo("Hyper"); } catch { threw = true; }
ok(threw);
});
fs.rmSync(tmp, { recursive: true, force: true });
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);

View file

@ -0,0 +1,298 @@
#!/usr/bin/env node
// Claude connector, end to end: starts this Theseus checkout on a throwaway
// profile (THESEUS_USER_DATA, THESEUS_NO_UPDATE_CHECK) with the connector
// turned on in that profile, serves a local test page, and drives it over
// MCP the way Claude Code does:
//
// - type into a contenteditable editor (real insertText + Enter)
// - paste_html with a heading and a link (trusted paste through the real
// clipboard; the user's clipboard comes back afterwards)
// - upload a PNG from the allowed folder; refuse one outside it
// - refuse typing into a password field, by ref and by focus
// - no token / a browser Origin is refused; evaluate is not offered
//
// Usage: node tests/claude-connector.test.mjs [--keep] [--shot out.png]
// --keep leave Theseus running and the profile on disk afterwards
import { spawn, execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
import zlib from "node:zlib";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const here = path.dirname(fileURLToPath(import.meta.url));
const appDir = path.resolve(here, "..");
const KEEP = process.argv.includes("--keep");
const SHOT = process.argv.includes("--shot") ? process.argv[process.argv.indexOf("--shot") + 1] : null;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let passed = 0, failed = 0;
function check(name, cond, detail) {
if (cond) { passed++; console.log(` ok ${name}`); }
else { failed++; console.log(` FAIL ${name}${detail ? `\n ${detail}` : ""}`); }
}
// ---- fixtures --------------------------------------------------------------
function crc32(buf) {
let c, crc = 0xffffffff;
for (let n = 0; n < buf.length; n++) {
c = (crc ^ buf[n]) & 0xff;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
crc = (crc >>> 8) ^ c;
}
return (crc ^ 0xffffffff) >>> 0;
}
function png(w, h) {
const chunk = (type, data) => {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type), data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, crc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
const raw = Buffer.alloc((w * 3 + 1) * h);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) raw.set([217, 119, 87], y * (w * 3 + 1) + 1 + x * 3);
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
}
const PAGE = `<!doctype html>
<html><head><meta charset="utf-8"><title>Connector test</title>
<style>body{font:15px system-ui;margin:24px;max-width:720px} #editor{min-height:120px;border:1px solid #888;padding:8px;margin:12px 0} label{display:block;margin:8px 0}</style></head>
<body>
<h1>Connector test page</h1>
<label>Title <input id="title" aria-label="Title"></label>
<label>Password <input id="pw" type="password" aria-label="Password" autocomplete="current-password"></label>
<div id="editor" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Story editor"></div>
<label>Image <input id="file" type="file" accept="image/png" aria-label="Image upload"></label>
<pre id="report"></pre>
<script>
const st = { beforeinput: {}, enterKeys: 0, paste: null, file: null };
editor.addEventListener("beforeinput", (e) => { st.beforeinput[e.inputType] = (st.beforeinput[e.inputType] || 0) + 1; });
editor.addEventListener("keydown", (e) => { if (e.key === "Enter" && e.isTrusted) st.enterKeys++; });
editor.addEventListener("paste", (e) => { const dt = e.clipboardData; st.paste = { types: [...dt.types], htmlLength: dt.getData("text/html").length, isTrusted: e.isTrusted }; });
file.addEventListener("change", async () => {
const f = file.files[0];
const b = new Uint8Array(await f.arrayBuffer());
st.file = { name: f.name, size: f.size, type: f.type, isPng: b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 };
});
setInterval(() => {
report.textContent = "REPORT" + JSON.stringify({ ...st, editorHtml: editor.innerHTML, editorText: editor.innerText, title: title.value, pwLength: pw.value.length }) + "END";
}, 50);
</script>
</body></html>`;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "theseus-cc-e2e-"));
const profile = path.join(tmp, "profile");
const uploads = path.join(tmp, "uploads");
const outside = path.join(tmp, "outside");
for (const d of [profile, uploads, outside, path.join(profile, "extensions-data")]) fs.mkdirSync(d, { recursive: true });
const PNG = png(8, 6);
fs.writeFileSync(path.join(uploads, "test.png"), PNG);
fs.writeFileSync(path.join(outside, "secret.png"), PNG);
const site = http.createServer((req, res) => { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(PAGE); });
await new Promise((r) => site.listen(0, "127.0.0.1", r));
const siteOrigin = `http://127.0.0.1:${site.address().port}`;
// The profile: every other built-in extension off (a quicker, quieter
// start), the connector on, its site and upload folder already approved.
const bundled = fs.readdirSync(path.join(appDir, "bundled-addons"), { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => { try { return JSON.parse(fs.readFileSync(path.join(appDir, "bundled-addons", d.name, "addon.json"), "utf8")).id; } catch { return null; } })
.filter(Boolean);
fs.writeFileSync(path.join(profile, "settings.json"), JSON.stringify({ disabledAddons: bundled.filter((id) => id !== "claude-connector"), addonsDefaultOffSeen: ["claude-connector"] }));
const TOKEN = crypto.randomBytes(32).toString("base64url");
fs.writeFileSync(path.join(profile, "extensions-data", "claude-connector.json"), JSON.stringify({ token: TOKEN, accept: true, allowedOrigins: [siteOrigin], uploadDirs: [uploads] }));
// ---- launch ------------------------------------------------------------------
const electron = require("electron");
const out = [];
const env = { ...process.env, THESEUS_USER_DATA: profile, THESEUS_NO_UPDATE_CHECK: "1" };
delete env.ELECTRON_RUN_AS_NODE;
const child = spawn(electron, [appDir], { env, stdio: ["ignore", "pipe", "pipe"] });
child.stdout.on("data", (d) => out.push(String(d)));
child.stderr.on("data", (d) => out.push(String(d)));
let exited = false;
child.on("exit", () => { exited = true; });
function shutdown() {
if (!KEEP) {
try { if (process.platform === "win32") execFileSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); else child.kill(); } catch {}
}
site.close();
}
const endpointFile = path.join(profile, "extensions-data", "claude-connector", "endpoint.json");
let url = null;
for (let i = 0; i < 240 && !url && !exited; i++) {
try { url = JSON.parse(fs.readFileSync(endpointFile, "utf8")).url; } catch {}
if (!url) await sleep(250);
}
if (!url) {
console.log("Theseus did not start the connector. Last output:\n" + out.join("").split("\n").slice(-40).join("\n"));
shutdown();
process.exit(1);
}
console.log(`connector at ${url}`);
// ---- a minimal MCP client ------------------------------------------------------
let sid = null, rid = 0;
async function rpc(method, params, { token = TOKEN, headers = {} } = {}) {
const h = { "content-type": "application/json", accept: "application/json, text/event-stream", ...headers };
if (token) h.authorization = `Bearer ${token}`;
if (sid) h["mcp-session-id"] = sid;
const isNote = method.startsWith("notifications/");
const res = await fetch(url, { method: "POST", headers: h, body: JSON.stringify({ jsonrpc: "2.0", method, ...(params ? { params } : {}), ...(isNote ? {} : { id: ++rid }) }) });
const s = res.headers.get("mcp-session-id");
if (s) sid = s;
const text = await res.text();
return { status: res.status, json: text ? (() => { try { return JSON.parse(text); } catch { return null; } })() : null };
}
async function tool(name, args = {}) {
const r = await rpc("tools/call", { name, arguments: args });
if (r.json?.error) return { error: r.json.error.message };
const res = r.json.result;
const txt = (res.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
return res.isError ? { error: txt } : { text: txt, content: res.content };
}
async function report(tabId) {
const r = await tool("read_page_text", { tab_id: tabId });
const m = /REPORT(\{.*\})END/s.exec(r.text || "");
return m ? JSON.parse(m[1]) : null;
}
function clipboardText() {
if (process.platform !== "win32") return null;
try { return execFileSync("powershell", ["-NoProfile", "-Command", "Get-Clipboard -Raw"], { encoding: "utf8" }); } catch { return null; }
}
try {
console.log("auth");
const noTok = await rpc("initialize", { protocolVersion: "2025-06-18", clientInfo: { name: "test" } }, { token: null });
check("no token is refused (401)", noTok.status === 401, `status ${noTok.status}`);
const browser = await rpc("initialize", { protocolVersion: "2025-06-18" }, { headers: { origin: "https://evil.example" } });
check("a browser Origin is refused (403)", browser.status === 403, `status ${browser.status}`);
console.log("session");
const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "connector-e2e", version: "1" } });
check("initialize", init.status === 200 && init.json?.result?.serverInfo?.name === "theseus" && !!sid, JSON.stringify(init.json));
await rpc("notifications/initialized");
const list = await rpc("tools/list");
const names = (list.json?.result?.tools || []).map((t) => t.name);
const required = ["list_tabs", "open_tab", "navigate", "close_tab", "screenshot", "read_page_text", "snapshot", "click", "type", "paste_html", "paste_text", "upload_file", "wait_for"];
check("every required tool is listed", required.every((n) => names.includes(n)), `missing: ${required.filter((n) => !names.includes(n))}`);
check("evaluate is not offered while it is off", !names.includes("evaluate"));
console.log("open + snapshot");
const opened = await tool("open_tab", { url: siteOrigin + "/" });
const tabId = opened.text ? JSON.parse(opened.text).tab_id : null;
check("open_tab", Number.isInteger(tabId), opened.error || opened.text);
const tabs = await tool("list_tabs");
check("list_tabs shows the tab with its URL", (tabs.text || "").includes(siteOrigin), tabs.text);
const waited = await tool("wait_for", { tab_id: tabId, selector: "#editor" });
check("wait_for selector", !waited.error, waited.error);
const snap = await tool("snapshot", { tab_id: tabId, interactive_only: true });
const refOf = (label) => { const m = new RegExp(`"${label}"[^\\n]*\\[ref=(e\\d+)\\]`).exec(snap.text || ""); return m && m[1]; };
const refs = { editor: refOf("Story editor"), title: refOf("Title"), pw: refOf("Password"), file: refOf("Image upload") };
check("snapshot has refs for editor, inputs and file input", Object.values(refs).every(Boolean), `${JSON.stringify(refs)}\n${snap.text || snap.error}`);
console.log("type");
const c1 = await tool("click", { tab_id: tabId, ref: refs.editor });
check("click the editor by ref", !c1.error, c1.error);
const t1 = await tool("type", { tab_id: tabId, text: "Hello world\nSecond line" });
check("type", !t1.error, t1.error);
await sleep(200);
let r = await report(tabId);
check("typed text landed in the editor, Enter made a new line", /Hello world\n+Second line/.test(r?.editorText || ""), JSON.stringify(r?.editorText));
check("the editor saw real input events (beforeinput insertText)", (r?.beforeinput?.insertText || 0) > 0, JSON.stringify(r?.beforeinput));
check("Enter arrived as a trusted key press", r?.enterKeys === 1, `enterKeys ${r?.enterKeys}`);
console.log("paste_html");
const clipBefore = clipboardText();
await tool("press_key", { tab_id: tabId, key: "End" });
await tool("press_key", { tab_id: tabId, key: "Enter" });
const html = '<h2>A heading from Claude</h2><p>Read <a href="https://example.com/story">the full story</a> here.</p>';
const p1 = await tool("paste_html", { tab_id: tabId, html });
check("paste_html", !p1.error, p1.error);
await sleep(300);
r = await report(tabId);
check("the page got a trusted paste event carrying text/html", r?.paste?.isTrusted === true && r.paste.types.includes("text/html") && r.paste.htmlLength > 0, JSON.stringify(r?.paste));
check("the heading arrived as <h2>", /<h2[^>]*>A heading from Claude<\/h2>/.test(r?.editorHtml || ""), r?.editorHtml);
check("the link arrived with its href", /<a [^>]*href="https:\/\/example\.com\/story"[^>]*>the full story<\/a>/.test(r?.editorHtml || ""), r?.editorHtml);
check("earlier text is still there", (r?.editorText || "").startsWith("Hello world"), JSON.stringify(r?.editorText));
const clipAfter = clipboardText();
if (clipBefore !== null) check("the user's clipboard text is back after the paste", clipAfter === clipBefore, `before ${JSON.stringify(clipBefore?.slice(0, 40))} after ${JSON.stringify(clipAfter?.slice(0, 40))}`);
const p2 = await tool("paste_text", { tab_id: tabId, text: " Plain tail." });
await sleep(250);
r = await report(tabId);
check("paste_text", !p2.error && (r?.editorText || "").includes("Plain tail."), p2.error || JSON.stringify(r?.editorText));
console.log("inputs and password fields");
const t2 = await tool("type", { tab_id: tabId, ref: refs.title, text: "My title" });
check("type into a text input by ref", !t2.error, t2.error);
const pw1 = await tool("type", { tab_id: tabId, ref: refs.pw, text: "hunter2" });
check("type into a password field by ref is refused", /password/i.test(pw1.error || ""), pw1.error || pw1.text);
const pw2 = await tool("click", { tab_id: tabId, ref: refs.pw });
check("click on a password field by ref is refused", /password/i.test(pw2.error || ""), pw2.error || pw2.text);
await tool("click", { tab_id: tabId, ref: refs.title });
await tool("press_key", { tab_id: tabId, key: "Tab" }); // focus moves to the password field
const pw3 = await tool("type", { tab_id: tabId, text: "hunter2" });
check("type while focus sits in a password field is refused", /password/i.test(pw3.error || ""), pw3.error || pw3.text);
const pw4 = await tool("paste_text", { tab_id: tabId, text: "hunter2" });
check("paste into a password field is refused", /password/i.test(pw4.error || ""), pw4.error || pw4.text);
await sleep(150);
r = await report(tabId);
check("the title input has the text, the password field stayed empty", r?.title === "My title" && r?.pwLength === 0, `title ${JSON.stringify(r?.title)} pw ${r?.pwLength}`);
console.log("upload_file");
const u1 = await tool("upload_file", { tab_id: tabId, ref: refs.file, path: path.join(uploads, "test.png") });
check("upload a PNG from the allowed folder", !u1.error, u1.error);
await sleep(300);
r = await report(tabId);
check("the page received test.png as a real PNG", r?.file?.name === "test.png" && r.file.size === PNG.length && r.file.isPng, JSON.stringify(r?.file));
const u2 = await tool("upload_file", { tab_id: tabId, ref: refs.file, path: path.join(outside, "secret.png") });
check("a file outside the allowed folder is refused", /outside/i.test(u2.error || ""), u2.error || u2.text);
const u3 = await tool("upload_file", { tab_id: tabId, ref: refs.title, path: path.join(uploads, "test.png") });
check("upload onto a non-file input is refused", /not a file input/i.test(u3.error || ""), u3.error || u3.text);
console.log("reading");
const txt = await tool("read_page_text", { tab_id: tabId });
check("read_page_text", (txt.text || "").includes("Connector test page"), txt.error);
const shot = await tool("screenshot", { tab_id: tabId });
const img = (shot.content || []).find((c) => c.type === "image");
const imgBuf = img ? Buffer.from(img.data, "base64") : Buffer.alloc(0);
check("screenshot is a PNG", imgBuf.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])), shot.error);
if (SHOT && img) { fs.writeFileSync(SHOT, imgBuf); console.log(` saved ${SHOT}`); }
const w2 = await tool("wait_for", { tab_id: tabId, text: "this text never appears", timeout_ms: 400 });
check("wait_for times out cleanly", /timed out/.test(w2.error || ""), w2.error || w2.text);
const ev = await tool("evaluate", { tab_id: tabId, expression: "1+1" });
check("evaluate is refused while it is off", !!ev.error, ev.text);
console.log("log");
const logFile = path.join(profile, "extensions-data", "claude-connector", "actions.log");
const entries = fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8").trim().split("\n").map((l) => JSON.parse(l)) : [];
const tools = new Set(entries.map((e) => e.tool));
check("every action is in the log with a timestamp", ["open_tab", "type", "paste_html", "upload_file", "click"].every((t) => tools.has(t)) && entries.every((e) => /^\d{4}-\d\d-\d\dT/.test(e.ts)), [...tools].join(","));
check("refusals are logged as failures", entries.some((e) => e.tool === "type" && !e.ok && /password/i.test(e.error || "")));
check("typed secrets are not logged in full", !entries.some((e) => String(e.args || "").includes("hunter2hunter2")));
if (!KEEP) {
const cl = await tool("close_tab", { tab_id: tabId });
check("close_tab", !cl.error, cl.error);
}
} catch (e) {
failed++;
console.log(` FAIL ${e?.stack || e}`);
} finally {
shutdown();
}
if (failed) console.log("\nTheseus output (tail):\n" + out.join("").split("\n").filter((l) => /claude-connector|error|Error/i.test(l)).slice(-30).join("\n"));
console.log(`\n${passed} passed, ${failed} failed${KEEP ? `\nprofile kept at ${profile}` : ""}`);
if (!KEEP) { await sleep(500); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
process.exit(failed ? 1 : 0);