返回 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 { 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
202 lines Plain Text