| 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 |