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.
298 lines
18 KiB
JavaScript
298 lines
18 KiB
JavaScript
#!/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);
|