返回 CodeWhale
spawn.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / spawn.test.mjs
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
250 lines Plain Text