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

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

390 lines
22 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);
});
console.log("claude apps");
const apps = lib("clients.cjs");
await test("Claude Desktop: adds, updates and removes only the theseus entry, with a backup", async () => {
const appData = fs.mkdtempSync(path.join(os.tmpdir(), "cc-desk-"));
const o = { platform: "win32", home: appData, env: { APPDATA: appData } };
const params = { url: "http://127.0.0.1:47800/mcp", token: "t1", exe: "C:/Theseus.exe", shim: "C:/shim.cjs" };
eq(apps.claudeDesktop.status(params, o).installed, false);
await rejects(apps.claudeDesktop.register(params, o), /not found/);
const dir = path.join(appData, "Claude"); fs.mkdirSync(dir);
const file = path.join(dir, "claude_desktop_config.json");
fs.writeFileSync(file, JSON.stringify({ globalShortcut: "Ctrl+Space", mcpServers: { other: { command: "x" } } }));
const r = await apps.claudeDesktop.register(params, o);
ok(r.restart, "asks for a restart");
const cfg = JSON.parse(fs.readFileSync(file, "utf8"));
eq(cfg.globalShortcut, "Ctrl+Space"); eq(cfg.mcpServers.other, { command: "x" });
eq(cfg.mcpServers.theseus.env.THESEUS_MCP_TOKEN, "t1");
eq(cfg.mcpServers.theseus.env.ELECTRON_RUN_AS_NODE, "1");
ok(fs.existsSync(file + ".theseus-backup"), "backup kept");
eq(apps.claudeDesktop.status(params, o), { installed: true, configured: true, current: true });
eq(apps.claudeDesktop.status({ ...params, token: "t2" }, o).current, false, "a new token makes it stale");
await apps.claudeDesktop.unregister(o);
const after = JSON.parse(fs.readFileSync(file, "utf8"));
ok(!after.mcpServers.theseus && after.mcpServers.other, "only theseus removed");
fs.writeFileSync(file, "{ not json");
await rejects(apps.claudeDesktop.register(params, o), /not valid JSON/);
eq(fs.readFileSync(file, "utf8"), "{ not json", "an unreadable file is left alone");
fs.rmSync(appData, { recursive: true, force: true });
});
await test("Claude Code: the CLI is found in ~/.local/bin", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "cc-home-"));
const bin = path.join(home, ".local", "bin"); fs.mkdirSync(bin, { recursive: true });
const exe = path.join(bin, process.platform === "win32" ? "claude.exe" : "claude"); fs.writeFileSync(exe, "");
eq(apps.findClaudeCli({ home, env: { PATH: "" } }), exe);
eq(apps.findClaudeCli({ home: path.join(home, "nobody"), env: { PATH: "" } }), null);
fs.rmSync(home, { recursive: true, force: true });
});
await test("Claude Code: register, update and remove through the real claude CLI (throwaway config)", async () => {
const cli = apps.findClaudeCli();
if (!cli) { console.log(" (skipped: no claude CLI on this computer)"); return; }
const cfgDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-claude-"));
const o = { env: { ...process.env, CLAUDE_CONFIG_DIR: cfgDir, USERPROFILE: cfgDir, HOME: cfgDir } };
const p1 = { url: "http://127.0.0.1:47800/mcp", token: "tok-one" };
eq(apps.claudeCode.status(p1, o).configured, false);
await apps.claudeCode.register(p1, o);
eq(apps.claudeCode.status(p1, o), { installed: true, configured: true, current: true });
const p2 = { url: "http://127.0.0.1:47801/mcp", token: "tok-two" };
eq(apps.claudeCode.status(p2, o).current, false, "stale after a token/port change");
await apps.claudeCode.register(p2, o); // an existing entry is replaced, not refused
eq(apps.claudeCode.status(p2, o).current, true);
const saved = JSON.parse(fs.readFileSync(path.join(cfgDir, ".claude.json"), "utf8")).mcpServers.theseus;
eq(saved, { type: "http", url: p2.url, headers: { Authorization: "Bearer tok-two" } });
await apps.claudeCode.unregister(o);
eq(apps.claudeCode.status(p2, o).configured, false);
fs.rmSync(cfgDir, { recursive: true, force: true });
});
fs.rmSync(tmp, { recursive: true, force: true });
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);