#!/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("

Title

See this & that

"), "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);