| 1 | // Spawned computers: registry shape, executor wiring, docker lifecycle, and |
| 2 | // the MCP spawn/remove path. Docker tests are integration tests — they run |
| 3 | // real containers when a daemon is present and skip otherwise. |
| 4 | import { test, after } from "node:test"; |
| 5 | import assert from "node:assert/strict"; |
| 6 | import fs from "node:fs"; |
| 7 | import os from "node:os"; |
| 8 | import path from "node:path"; |
| 9 | import { spawn } from "node:child_process"; |
| 10 | import url from "node:url"; |
| 11 | |
| 12 | const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), ".."); |
| 13 | const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cu-spawn-test-")); |
| 14 | process.env.CODEWHALE_CU_STATE_DIR = tmp; |
| 15 | |
| 16 | const registry = await import("../src/registry.mjs"); |
| 17 | const spawnMod = await import("../src/spawn.mjs"); |
| 18 | const { dockerExec, routeFingerprint, SESSION_ID } = await import("../src/transport.mjs"); |
| 19 | const { run } = await import("../src/exec.mjs"); |
| 20 | |
| 21 | const DOCKER = await spawnMod.dockerAvailable(); |
| 22 | const NEED_DOCKER = { skip: !DOCKER && "docker daemon not available" }; |
| 23 | const containers = new Set(); // anything a test leaves behind gets reaped |
| 24 | |
| 25 | async function rmContainer(name) { |
| 26 | containers.delete(name); |
| 27 | await run("docker", ["rm", "-f", name], { timeoutMs: 15_000, signal: null }); |
| 28 | } |
| 29 | |
| 30 | after(async () => { |
| 31 | for (const name of [...containers]) await rmContainer(name); |
| 32 | }); |
| 33 | |
| 34 | // ---------- registry ---------- |
| 35 | |
| 36 | test("registry accepts docker computers and defaults them to linux", () => { |
| 37 | const entry = registry.register({ id: "d1", transport: "docker", container: "cu-spawn-d1-ab12cd", owned: true }); |
| 38 | assert.equal(entry.platform, "linux"); |
| 39 | assert.equal(entry.container, "cu-spawn-d1-ab12cd"); |
| 40 | assert.equal(entry.owned, true); |
| 41 | const again = registry.load(); |
| 42 | assert.equal(again.computers.d1.container, "cu-spawn-d1-ab12cd"); |
| 43 | }); |
| 44 | |
| 45 | test("registry rejects docker computers without a safe container name", () => { |
| 46 | assert.throws(() => registry.register({ id: "d2", transport: "docker" }), (e) => e.code === "invalid_container"); |
| 47 | assert.throws(() => registry.register({ id: "d3", transport: "docker", container: "bad;rm -rf" }), (e) => e.code === "invalid_container"); |
| 48 | assert.throws(() => registry.register({ id: "d4", transport: "docker", container: "..-escape" }), (e) => e.code === "invalid_container"); |
| 49 | }); |
| 50 | |
| 51 | // ---------- executor ---------- |
| 52 | |
| 53 | test("dockerExec speaks the agent contract through agent-exec.sh", () => { |
| 54 | const ex = dockerExec({ id: "d", transport: "docker", container: "cu-spawn-d-ab12cd", platform: "linux" }, null); |
| 55 | assert.equal(ex.kind, "docker"); |
| 56 | assert.equal(ex.container, "cu-spawn-d-ab12cd"); |
| 57 | assert.equal(ex.remoteAgent, "/app/docker/agent-exec.sh"); |
| 58 | assert.equal(typeof ex.remote, "function"); |
| 59 | assert.equal(ex.persistent, undefined, "no binding means no persistent channel"); |
| 60 | const bound = dockerExec({ id: "d", transport: "docker", container: "cu-spawn-d-ab12cd" }, {}); |
| 61 | assert.equal(typeof bound.persistent, "function"); |
| 62 | assert.equal(typeof bound.closeChannel, "function"); |
| 63 | }); |
| 64 | |
| 65 | test("routeFingerprint distinguishes containers on the same image", () => { |
| 66 | const a = routeFingerprint({ id: "d", transport: "docker", container: "cu-spawn-a-1", platform: "linux" }); |
| 67 | const b = routeFingerprint({ id: "d", transport: "docker", container: "cu-spawn-a-2", platform: "linux" }); |
| 68 | assert.notEqual(a, b, "a new container is a new route — stale bindings must re-observe"); |
| 69 | }); |
| 70 | |
| 71 | // ---------- docker lifecycle (integration) ---------- |
| 72 | |
| 73 | test("spawnDockerComputer provisions a usable desktop and destroy removes it", NEED_DOCKER, async () => { |
| 74 | const spawned = await spawnMod.spawnDockerComputer({ id: "it1" }); |
| 75 | containers.add(spawned.container); |
| 76 | assert.match(spawned.container, /^cu-spawn-it1-[0-9a-f]{6}$/); |
| 77 | |
| 78 | // Labels mark it ours, this session's, and name the computer. |
| 79 | const labels = await run("docker", ["inspect", "--format", |
| 80 | '{{index .Config.Labels "codewhale.cu.spawned"}}|{{index .Config.Labels "codewhale.cu.session"}}|{{index .Config.Labels "codewhale.cu.computer"}}', |
| 81 | spawned.container], { timeoutMs: 10_000 }); |
| 82 | assert.equal(labels.stdout.trim(), `1|${SESSION_ID}|it1`); |
| 83 | |
| 84 | // The desktop stack is genuinely up — the readiness probe waits for the WM. |
| 85 | const ex = dockerExec({ id: "it1", transport: "docker", container: spawned.container, platform: "linux" }, {}); |
| 86 | const wins = await ex.persistent({ tool: "list_windows", args: {} }); |
| 87 | assert.equal(wins.ok, true); |
| 88 | const cur = await ex.persistent({ tool: "cursor_position", args: {} }); |
| 89 | assert.equal(cur.ok, true); |
| 90 | assert.ok(Number.isFinite(cur.data.x)); |
| 91 | |
| 92 | const res = await spawnMod.destroyDockerComputer({ container: spawned.container }); |
| 93 | assert.equal(res.destroyed, true); |
| 94 | containers.delete(spawned.container); |
| 95 | const gone = await run("docker", ["inspect", spawned.container], { timeoutMs: 10_000 }); |
| 96 | assert.notEqual(gone.code, 0, "container is gone after destroy"); |
| 97 | }); |
| 98 | |
| 99 | test("destroyDockerComputer refuses containers it did not spawn", NEED_DOCKER, async () => { |
| 100 | const r = await run("docker", ["run", "-d", "--name", "cu-not-ours", "codewhale-cu-linux", "sleep", "infinity"], { timeoutMs: 30_000 }); |
| 101 | assert.equal(r.code, 0, r.stderr); |
| 102 | containers.add("cu-not-ours"); |
| 103 | const res = await spawnMod.destroyDockerComputer({ container: "cu-not-ours" }); |
| 104 | assert.deepEqual(res, { destroyed: false, reason: "not_spawned" }); |
| 105 | const alive = await run("docker", ["inspect", "--format", "{{.State.Running}}", "cu-not-ours"], { timeoutMs: 10_000 }); |
| 106 | assert.equal(alive.stdout.trim(), "true", "unlabeled containers are never destroyed"); |
| 107 | await rmContainer("cu-not-ours"); |
| 108 | }); |
| 109 | |
| 110 | test("destroyDockerComputer reports a missing container without destroying anything", NEED_DOCKER, async () => { |
| 111 | const res = await spawnMod.destroyDockerComputer({ container: "cu-spawn-ghost-000000" }); |
| 112 | assert.deepEqual(res, { destroyed: false, reason: "container_gone" }); |
| 113 | }); |
| 114 | |
| 115 | test("spawn refuses an image that is not present instead of guessing a build", NEED_DOCKER, async () => { |
| 116 | await assert.rejects( |
| 117 | () => spawnMod.spawnDockerComputer({ id: "it2", image: "cu-image-that-does-not-exist" }), |
| 118 | (e) => e.code === "spawn_image_missing"); |
| 119 | }); |
| 120 | |
| 121 | // ---------- MCP end to end ---------- |
| 122 | |
| 123 | const pending = new Map(); |
| 124 | let server, buf = "", nextId = 1; |
| 125 | function rpc(method, params) { |
| 126 | const id = nextId++; |
| 127 | return new Promise((resolve, reject) => { |
| 128 | const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 90_000); |
| 129 | pending.set(id, (msg) => { clearTimeout(t); resolve(msg); }); |
| 130 | server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); |
| 131 | }); |
| 132 | } |
| 133 | const call = async (name, args = {}) => JSON.parse((await rpc("tools/call", { name, arguments: args })).result.content[0].text); |
| 134 | |
| 135 | test("computer spawn registers an owned docker computer, acts on it, and remove destroys it", NEED_DOCKER, async () => { |
| 136 | server = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], { |
| 137 | env: { ...process.env, CODEWHALE_CU_STATE_DIR: tmp }, |
| 138 | stdio: ["pipe", "pipe", "pipe"], |
| 139 | }); |
| 140 | server.stdout.on("data", (c) => { |
| 141 | buf += c.toString(); |
| 142 | let i; |
| 143 | while ((i = buf.indexOf("\n")) !== -1) { |
| 144 | const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); |
| 145 | if (!line) continue; |
| 146 | const msg = JSON.parse(line); |
| 147 | if (msg.id != null && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); } |
| 148 | } |
| 149 | }); |
| 150 | await rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "0" } }); |
| 151 | server.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n"); |
| 152 | |
| 153 | const s = await call("computer", { action: "spawn", id: "mcp-e2e", transport: "docker" }); |
| 154 | assert.equal(s.ok, true, JSON.stringify(s)); |
| 155 | assert.equal(s.active, "mcp-e2e", "spawn selects the disposable computer"); |
| 156 | assert.equal(s.spawned.owned, true); |
| 157 | containers.add(s.spawned.container); |
| 158 | |
| 159 | const listed = await call("computer", { action: "list" }); |
| 160 | const entry = listed.computers.find((c) => c.id === "mcp-e2e"); |
| 161 | assert.equal(entry.transport, "docker"); |
| 162 | assert.equal(entry.owned, true); |
| 163 | assert.equal(entry.platform, "linux"); |
| 164 | |
| 165 | // A real tool call against the spawned desktop — same path as ssh. |
| 166 | const wins = await call("list_windows", {}); |
| 167 | assert.equal(wins.ok, true, JSON.stringify(wins)); |
| 168 | assert.equal(wins.computer.id, "mcp-e2e"); |
| 169 | |
| 170 | // app_script must be refused — a spawned channel is not a shell either. |
| 171 | const script = await call("app_script", { language: "applescript", script: "return 1" }); |
| 172 | assert.equal(script.ok, false); |
| 173 | assert.equal(script.error.code, "unsupported_on_transport"); |
| 174 | |
| 175 | const removed = await call("computer", { action: "remove", id: "mcp-e2e" }); |
| 176 | assert.equal(removed.ok, true); |
| 177 | assert.equal(removed.destroyed, true); |
| 178 | assert.equal(removed.active, "local"); |
| 179 | containers.delete(s.spawned.container); |
| 180 | const gone = await run("docker", ["inspect", s.spawned.container], { timeoutMs: 10_000 }); |
| 181 | assert.notEqual(gone.code, 0); |
| 182 | }); |
| 183 | |
| 184 | test("server shutdown destroys session-owned spawned computers", NEED_DOCKER, async () => { |
| 185 | // Fresh server: spawn, then end stdin — the session teardown must reap. |
| 186 | const s2 = await call("computer", { action: "spawn", id: "mcp-reap", transport: "docker" }); |
| 187 | assert.equal(s2.ok, true, JSON.stringify(s2)); |
| 188 | containers.add(s2.spawned.container); |
| 189 | server.stdin.end(); |
| 190 | await new Promise((resolve) => server.on("close", resolve)); |
| 191 | await new Promise((r) => setTimeout(r, 500)); |
| 192 | const gone = await run("docker", ["inspect", s2.spawned.container], { timeoutMs: 10_000 }); |
| 193 | assert.notEqual(gone.code, 0, "session end reaps its spawned containers"); |
| 194 | containers.delete(s2.spawned.container); |
| 195 | }); |
| 196 | |
| 197 | test('disposable desktops require a live Linux Docker engine, including on Windows hosts', async () => { |
| 198 | for (const [response, expected] of [[{code:0,stdout:'linux\n'},true],[{code:0,stdout:'windows\n'},false],[{code:1,stdout:'linux'},false],[{code:0,stdout:'linux',timedOut:true},false],[{code:0,stdout:'linux',aborted:true},false]]) { |
| 199 | assert.equal(await spawnMod.dockerAvailable(async args => { assert.deepEqual(args,['info','--format','{{.OSType}}']); return response; }),expected); |
| 200 | } |
| 201 | }); |
| 202 |