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".
390 lines
22 KiB
JavaScript
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> & 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);
|