| 1 | // Server protocol tests: real MCP server process over stdio, isolated state. |
| 2 | // The ssh/scp shims stand in for a remote machine, proving the full remote |
| 3 | // agent loop (install -> platform probe -> tool dispatch) without real ssh. |
| 4 | import { test, before, after } from "node:test"; |
| 5 | import assert from "node:assert/strict"; |
| 6 | import { spawn } from "node:child_process"; |
| 7 | import fs from "node:fs"; |
| 8 | import os from "node:os"; |
| 9 | import path from "node:path"; |
| 10 | import url from "node:url"; |
| 11 | |
| 12 | const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); |
| 13 | const ROOT = path.resolve(__dirname, ".."); |
| 14 | |
| 15 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-proto-state-")); |
| 16 | const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-proto-rec-")); |
| 17 | const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "cu-proto-home-")); |
| 18 | const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-proto-bin-")); |
| 19 | |
| 20 | // Portable command fixtures still run the real transferred remote agent. |
| 21 | fs.writeFileSync(path.join(binDir, "ssh.cjs"), ` |
| 22 | const fs = require('node:fs'), path = require('node:path'); |
| 23 | const args = process.argv.slice(2); |
| 24 | const remote = args.slice(args.findIndex(a => a.includes('@')) + 1); |
| 25 | if (remote[0] === 'mkdir') fs.mkdirSync(path.join(process.env.FAKE_HOME, remote.at(-1)), {recursive:true}); |
| 26 | else if (remote[0] === 'node') { |
| 27 | const agent = path.join(process.env.FAKE_HOME, remote[1]); |
| 28 | process.argv = [process.execPath, agent, ...remote.slice(2)]; |
| 29 | import(require('node:url').pathToFileURL(agent).href); |
| 30 | } else process.exit(1); |
| 31 | `); |
| 32 | fs.writeFileSync(path.join(binDir, "scp.cjs"), ` |
| 33 | const fs = require('node:fs'), path = require('node:path'); |
| 34 | const [source, remote] = process.argv.slice(-2); |
| 35 | const dest = path.join(process.env.FAKE_HOME, remote.slice(remote.indexOf(':') + 1)); |
| 36 | fs.mkdirSync(path.dirname(dest), {recursive:true}); fs.copyFileSync(source, dest); |
| 37 | `); |
| 38 | |
| 39 | let server; |
| 40 | let buf = ""; |
| 41 | const pending = new Map(); |
| 42 | let nextId = 1; |
| 43 | |
| 44 | function rpc(method, params, timeoutMs = 90_000) { |
| 45 | const id = nextId++; |
| 46 | return new Promise((resolve, reject) => { |
| 47 | const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, timeoutMs); |
| 48 | pending.set(id, (msg) => { clearTimeout(t); resolve(msg); }); |
| 49 | server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); |
| 50 | }); |
| 51 | } |
| 52 | |
| 53 | async function tool(name, args = {}) { |
| 54 | const res = await rpc("tools/call", { name, arguments: args }); |
| 55 | assert.ok(res.result, `${name}: protocol error ${JSON.stringify(res.error ?? {})}`); |
| 56 | return JSON.parse(res.result.content[0].text); |
| 57 | } |
| 58 | |
| 59 | before(async () => { |
| 60 | server = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], { |
| 61 | env: { |
| 62 | ...process.env, |
| 63 | PATH: `${binDir}${path.delimiter}${process.env.PATH}`, |
| 64 | CU_COMMAND_FIXTURES: binDir, |
| 65 | NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${new URL("./fixtures/command-shims.mjs", import.meta.url).href}`, |
| 66 | FAKE_HOME: fakeHome, |
| 67 | CODEWHALE_CU_STATE_DIR: stateDir, |
| 68 | CODEWHALE_CU_RECORDINGS_DIR: recDir, |
| 69 | }, |
| 70 | stdio: ["pipe", "pipe", "pipe"], |
| 71 | }); |
| 72 | server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`)); |
| 73 | server.stdout.setEncoding("utf8"); |
| 74 | server.stdout.on("data", (d) => { |
| 75 | buf += d; |
| 76 | let i; |
| 77 | while ((i = buf.indexOf("\n")) !== -1) { |
| 78 | const line = buf.slice(0, i).trim(); |
| 79 | buf = buf.slice(i + 1); |
| 80 | if (!line) continue; |
| 81 | try { |
| 82 | const msg = JSON.parse(line); |
| 83 | if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); } |
| 84 | } catch {} |
| 85 | } |
| 86 | }); |
| 87 | const init = await rpc("initialize", { protocolVersion: "2025-06-18" }); |
| 88 | assert.equal(init.result.serverInfo.name, "codewhale-cu"); |
| 89 | assert.equal(init.result.serverInfo.version, JSON.parse(fs.readFileSync(new URL("../plugin.json", import.meta.url), "utf8")).version); |
| 90 | }); |
| 91 | |
| 92 | after(() => { |
| 93 | server?.kill("SIGTERM"); |
| 94 | for (const d of [stateDir, recDir, fakeHome]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } |
| 95 | }); |
| 96 | |
| 97 | test("tools/list advertises the merged surface with valid schemas; wire names stay aliases", async () => { |
| 98 | const res = await rpc("tools/list", {}); |
| 99 | const tools = res.result.tools; |
| 100 | assert.ok(tools.length >= 30, `${tools.length} tools`); |
| 101 | assert.ok(tools.every((t) => t.hidden !== true), "hidden aliases must not be listed"); |
| 102 | for (const t of tools) { |
| 103 | assert.ok(t.name && t.description && t.inputSchema, `schema incomplete for ${t.name}`); |
| 104 | } |
| 105 | const names = new Set(tools.map((t) => t.name)); |
| 106 | for (const required of ["screenshot", "zoom", "click", "pointer", "left_click_drag", "scroll", "type", "key", |
| 107 | "set_value", "focus", "get_value", "find_elements", "run_actions", "select_text", "perform_action", "get_app_state", "list_apps", "list_windows", "list_displays", |
| 108 | "switch_display", "open_application", "clipboard", "cursor_position", "wait", |
| 109 | "recording", "computer", "request_access", "stop_computer_control", "invoke_menu", "preview", "wait_for"]) { |
| 110 | assert.ok(names.has(required), `missing tool ${required}`); |
| 111 | } |
| 112 | for (const hidden of ["left_click", "double_click", "right_click", "hold_key", "mouse_move", "read_clipboard", |
| 113 | "recording_start", "computer_list", "computer_switch"]) { |
| 114 | assert.ok(!names.has(hidden), `${hidden} is an alias, not advertised`); |
| 115 | } |
| 116 | }); |
| 117 | |
| 118 | test("computer registry round-trip over the protocol", async () => { |
| 119 | let r = await tool("computer_list"); |
| 120 | assert.equal(r.ok, true); |
| 121 | assert.equal(r.active, "local"); |
| 122 | r = await tool("computer_register", { computer: "pad", transport: "hdc" }); |
| 123 | assert.equal(r.registered.platform, "harmonyos"); |
| 124 | r = await tool("computer_switch", { computer: "pad" }); |
| 125 | assert.equal(r.active, "pad"); |
| 126 | r = await tool("computer_remove", { computer: "pad" }); |
| 127 | assert.equal(r.active, "local"); |
| 128 | }); |
| 129 | |
| 130 | test("registering an ssh computer installs the agent and probes the platform", async () => { |
| 131 | const r = await tool("computer_register", { computer: "box", transport: "ssh", host: "box.test", user: "me" }); |
| 132 | assert.equal(r.ok, true, JSON.stringify(r.error ?? {})); |
| 133 | assert.equal(r.agentInstall.remotePlatform, process.platform, "platform probed via agent"); |
| 134 | assert.ok(fs.existsSync(path.join(fakeHome, ".codewhale-cu", "agent", "agent.mjs")), "agent pushed"); |
| 135 | assert.ok(fs.existsSync(path.join(fakeHome, ".codewhale-cu", "agent", "src", "backends", "darwin.mjs")), "src tree pushed"); |
| 136 | // dispatch a real tool to the "remote" computer. A headless Linux host |
| 137 | // (CI) has no window manager tooling, so the remote backend fails closed |
| 138 | // with its named reason; that error still proves the round trip. |
| 139 | const apps = await tool("list_apps", { computer: "box" }); |
| 140 | if (apps.ok) { |
| 141 | assert.equal(apps.computer.id, "box"); |
| 142 | assert.ok(Array.isArray(apps.apps), "an app list came back over the wire"); |
| 143 | // An empty list is a real answer, not a broken one: the Linux box in |
| 144 | // docker/ runs a live X session with nothing on it. Only a login session |
| 145 | // is guaranteed to have an application in it. |
| 146 | if (process.platform === "darwin") { |
| 147 | assert.ok(apps.apps.length > 0, "apps returned over the wire"); |
| 148 | } |
| 149 | } else { |
| 150 | assert.equal(process.platform, "linux", JSON.stringify(apps.error ?? {})); |
| 151 | // A headless CI host fails closed with either shape: the modern |
| 152 | // no_session (no $DISPLAY/$WAYLAND_DISPLAY visible to the process) |
| 153 | // or the older tool_error naming the missing window-manager tool. |
| 154 | // Either answer proves the ssh round trip reached the remote |
| 155 | // backend and came back. |
| 156 | assert.ok( |
| 157 | apps.error.code === "no_session" || |
| 158 | (apps.error.code === "tool_error" && |
| 159 | /wmctrl|swaymsg|hyprctl/u.test(apps.error.message)), |
| 160 | JSON.stringify(apps.error ?? {}), |
| 161 | ); |
| 162 | } |
| 163 | }); |
| 164 | |
| 165 | test("unknown computer fails closed with a named error", async () => { |
| 166 | const r = await tool("screenshot", { computer: "ghost" }); |
| 167 | assert.equal(r.ok, false); |
| 168 | assert.equal(r.error.code, "unknown_computer"); |
| 169 | }); |
| 170 | |
| 171 | test("kill switch refuses mutating tools but keeps read-only probes", async () => { |
| 172 | let r = await tool("stop_computer_control", { reason: "protocol-test" }); |
| 173 | assert.equal(r.stopped, true); |
| 174 | r = await tool("screenshot"); |
| 175 | assert.equal(r.error.code, "control_stopped"); |
| 176 | r = await tool("computer_list"); |
| 177 | assert.equal(r.ok, true); |
| 178 | }); |
| 179 |