theseus/tests/claude-connector-unit.test.mjs
Dev c06557e957 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.
2026-10-23 17:45:02 +03:00

334 lines
18 KiB
JavaScript

#!/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);