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