返回 CodeWhale
spawn.mjs
根目录 / crates / tui / plugins / computer-use / src / spawn.mjs
1 // Spawned computers: task-owned disposable desktops.
2 //
3 // A spawned computer is a Docker container running the plugin's Linux desktop
4 // image (Xvfb + openbox + AT-SPI + the bundled remote agent). It is not the
5 // user's machine: every existing tool reaches it through the same agent
6 // protocol as ssh, and the container boundary is the isolation. `owned:true`
7 // in the registry marks it as ours — remove or session end destroys it.
8 //
9 // Safety posture: docker is invoked with argv arrays only (never a shell), the
10 // container is addressed by a name we generated, and `docker rm` is only ever
11 // issued against containers carrying our spawn label — a hand-registered
12 // docker entry pointing at a user's container cannot be destroyed here.
13 import crypto from "node:crypto";
14 import path from "node:path";
15 import { run, ExecError, trim } from "./exec.mjs";
16 import { PLUGIN_ROOT, SESSION_ID, b64 } from "./transport.mjs";
17
18 export const SPAWN_LABEL = "codewhale.cu.spawned";
19 export const SESSION_LABEL = "codewhale.cu.session";
20 export const COMPUTER_LABEL = "codewhale.cu.computer";
21 export const DEFAULT_IMAGE = process.env.CODEWHALE_CU_SPAWN_IMAGE || "codewhale-cu-linux";
22
23 const CONTAINER_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
24 const AGENT_EXEC = "/app/docker/agent-exec.sh";
25
26 class SpawnError extends ExecError {
27 constructor(code, message, result) {
28 super(message, result);
29 this.code = code;
30 }
31 }
32
33 function docker(args, opts = {}) {
34 // Docker CLI lives off PATH; on macOS a Colima/Docker-Desktop install puts
35 // it in /usr/local/bin or /opt/homebrew/bin which the MCP host's PATH may
36 // lack, so try the well-known paths too.
37 return run("docker", args, opts);
38 }
39
40 async function dockerOk(args, opts = {}) {
41 const r = await docker(args, opts);
42 if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
43 if (r.timedOut) throw new SpawnError("spawn_failed", `docker ${args[0]} timed out`, r);
44 if (r.code !== 0) throw new SpawnError("spawn_failed", `docker ${args[0]} failed: ${trim(r.stderr || r.stdout)}`, r);
45 return r;
46 }
47
48 export async function dockerAvailable(command = docker) {
49 const r = await command(["info", "--format", "{{.OSType}}"], { timeoutMs: 10_000 });
50 return r.code === 0 && !r.timedOut && !r.aborted && r.stdout.trim() === "linux";
51 }
52
53 /**
54 * The image must exist locally. The plugin's own image is built from
55 * docker/Dockerfile on first use; any other image name is the caller's
56 * responsibility — we never guess a build context for it.
57 */
58 async function ensureImage(image) {
59 const inspect = await docker(["image", "inspect", image], { timeoutMs: 15_000 });
60 if (inspect.code === 0) return { built: false };
61 if (image !== DEFAULT_IMAGE) {
62 throw new SpawnError("spawn_image_missing", `docker image "${image}" is not present locally`);
63 }
64 const r = await docker(["build", "-t", image, "-f", path.join(PLUGIN_ROOT, "docker", "Dockerfile"), PLUGIN_ROOT], { timeoutMs: 15 * 60_000 });
65 if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
66 if (r.code !== 0) throw new SpawnError("spawn_failed", `docker build ${image} failed: ${trim(r.stderr || r.stdout, 800)}`, r);
67 return { built: true };
68 }
69
70 /**
71 * Start a disposable desktop container and verify the agent answers inside
72 * its session. On any failure the container is removed — spawn is
73 * transactional: either a live computer comes back or nothing was left.
74 */
75 export async function spawnDockerComputer({ id, image = DEFAULT_IMAGE } = {}) {
76 if (!await dockerAvailable()) {
77 throw new SpawnError("docker_unavailable", "A Linux Docker engine is required — start Docker Desktop in Linux-container mode (or Colima) and spawn again");
78 }
79 const { built } = await ensureImage(image);
80 const container = `cu-spawn-${id}-${crypto.randomBytes(3).toString("hex")}`;
81 const cleanup = async () => {
82 await docker(["rm", "-f", container], { timeoutMs: 15_000, signal: null }).catch(() => {});
83 };
84 try {
85 // --init reaps the desktop's children; --ipc=host keeps Chromium off the
86 // 64MB default /dev/shm, same as docker/run.sh. "sleep infinity" is the
87 // payload — the image entrypoint stands up Xvfb/openbox/the session bus
88 // first, and the agent is exec'd in per request.
89 await dockerOk([
90 "run", "-d", "--name", container,
91 "--init", "--ipc=host",
92 "--label", `${SPAWN_LABEL}=1`,
93 "--label", `${SESSION_LABEL}=${SESSION_ID}`,
94 "--label", `${COMPUTER_LABEL}=${id}`,
95 image, "sleep", "infinity",
96 ], { timeoutMs: 30_000 });
97 const deadline = Date.now() + 30_000;
98 let lastErr = "no reply";
99 for (;;) {
100 // list_windows is the honest readiness probe: it needs the session env,
101 // the X display, and a window manager managing the root window — the
102 // whole stack a caller is about to drive, not just a live agent.
103 const probe = await docker(["exec", container, "/bin/sh", AGENT_EXEC, b64({ tool: "list_windows", args: {}, nonce: "spawn" })], { timeoutMs: 10_000, signal: null });
104 if (probe.code === 0 && /"ok"\s*:\s*true/.test(probe.stdout)) {
105 return { container, image, built };
106 }
107 lastErr = trim(probe.stderr || probe.stdout) || `exited ${probe.code}`;
108 if (Date.now() >= deadline) break;
109 await new Promise((resolve) => setTimeout(resolve, 500));
110 }
111 throw new SpawnError("spawn_failed", `spawned desktop did not come up: ${lastErr}`);
112 } catch (err) {
113 await cleanup();
114 throw err;
115 }
116 }
117
118 /**
119 * Destroy a spawned container — but only one this plugin created. A docker
120 * computer whose container lacks our spawn label is left running and reported
121 * not_spawned; removing its registry entry is still the caller's choice.
122 */
123 export async function destroyDockerComputer(computer) {
124 const container = computer?.container;
125 if (!container || !CONTAINER_RE.test(container)) {
126 throw new SpawnError("invalid_container", "docker computer has no valid container name");
127 }
128 const insp = await docker(["container", "inspect", "--format", `{{index .Config.Labels "${SPAWN_LABEL}"}}`, container], { timeoutMs: 10_000, signal: null });
129 if (insp.code !== 0) return { destroyed: false, reason: "container_gone" };
130 if (insp.stdout.trim() !== "1") return { destroyed: false, reason: "not_spawned" };
131 const r = await docker(["rm", "-f", container], { timeoutMs: 20_000, signal: null });
132 if (r.code !== 0) throw new SpawnError("cleanup_failed", `docker rm -f ${container} failed: ${trim(r.stderr)}`, r);
133 return { destroyed: true };
134 }
135
136 /**
137 * Session teardown: destroy every spawned container this MCP process owns.
138 * Other sessions' spawns are left alone — the registry is shared but a
139 * container belongs to the process that created it.
140 */
141 export async function destroySessionSpawns() {
142 const listed = await docker(["ps", "-aq", "--filter", `label=${SPAWN_LABEL}=1`, "--filter", `label=${SESSION_LABEL}=${SESSION_ID}`], { timeoutMs: 10_000, signal: null });
143 if (listed.code !== 0) return { destroyed: [], error: trim(listed.stderr) };
144 const ids = listed.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
145 const destroyed = [];
146 for (const container of ids) {
147 const r = await docker(["rm", "-f", container], { timeoutMs: 15_000, signal: null });
148 destroyed.push({ container, ok: r.code === 0 });
149 }
150 return { destroyed };
151 }
152
152 lines Plain Text