import { spawn } from "node:child_process"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { Server } from "node:http"; import type { WebSocketServer } from "ws"; import { LinkRegistry, startLinkServer } from "../src/link-server.js"; import { startMcpServer } from "../src/mcp-server.js"; const HERE = dirname(fileURLToPath(import.meta.url)); const LUA_DIR = join(HERE, "lua"); const REPO_ROOT = join(HERE, "../../.."); export type Bridge = { registry: LinkRegistry; mcpUrl: string; linkUrl: string; close: () => Promise; }; export async function startBridge(probeTimeoutMs = 500): Promise { const registry = new LinkRegistry(); const mcpServer = startMcpServer({ host: "127.0.0.1", port: 0, probeTimeoutMs, registry }); const linkServer = startLinkServer({ host: "127.0.0.1", port: 0, registry }); await Promise.all([waitForListening(mcpServer), waitForListening(linkServer)]); return { registry, mcpUrl: `http://127.0.0.1:${serverPort(mcpServer)}`, linkUrl: `ws://127.0.0.1:${serverPort(linkServer)}`, close: () => closeBridge(mcpServer, linkServer), }; } export async function waitForComputers(registry: LinkRegistry, count: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (registry.count() >= count) { return; } await sleep(50); } throw new Error(`waitForComputers timed out (expected ${count}, got ${registry.count()})`); } export async function callProbeComputers(mcpUrl: string): Promise { const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "probe-computers", arguments: {} }, }; const response = await fetch(mcpUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); const payload = (await response.json()) as { result?: { content?: { type: string; text: string }[] }; }; const text = payload.result?.content?.[0]?.text; if (typeof text !== "string") { throw new Error(`Unexpected MCP response: ${JSON.stringify(payload)}`); } return text; } export async function callExecLua(mcpUrl: string, computerId: number, code: string, timeoutMs?: number): Promise { const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "exec-lua", arguments: { computerId, code, timeoutMs } }, }; const response = await fetch(mcpUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); const payload = (await response.json()) as { result?: { content?: { type: string; text: string }[] }; }; const text = payload.result?.content?.[0]?.text; if (typeof text !== "string") { throw new Error(`Unexpected MCP response: ${JSON.stringify(payload)}`); } return text; } export async function callWriteFile( mcpUrl: string, computerId: number, path: string, content: string, timeoutMs?: number, ): Promise { const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "write-file", arguments: { computerId, path, content, timeoutMs } }, }; const response = await fetch(mcpUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); const payload = (await response.json()) as { result?: { content?: { type: string; text: string }[] }; }; const text = payload.result?.content?.[0]?.text; if (typeof text !== "string") { throw new Error(`Unexpected MCP response: ${JSON.stringify(payload)}`); } return text; } export type CraftosResult = { status: number | null; signal: NodeJS.Signals | null; output: string; }; export type CraftosHandle = { done: Promise; abort: () => void; }; export function startCraftos( luaName: string, opts: { computerId?: number; computerLabel?: string; mountRepo?: boolean; shellArgs?: string[]; timeoutMs?: number } = {}, ): CraftosHandle { const timeoutMs = opts.timeoutMs ?? 15_000; const controller = new AbortController(); const done = (async (): Promise => { const dataDir = await mkdtemp(join(tmpdir(), "mcp-bridge-it-")); const watchdog = setTimeout(() => controller.abort(), timeoutMs); try { const args: string[] = ["--directory", dataDir, "--headless", "--mount-ro", `/staging=${LUA_DIR}`]; if (opts.computerId !== undefined) { args.push("--id", String(opts.computerId)); } if (opts.mountRepo) { args.push( "--mount-ro", `/trapos=${REPO_ROOT}`, "--mount-ro", `/apis=${join(REPO_ROOT, "apis")}`, "--mount-ro", `/programs=${join(REPO_ROOT, "programs")}`, ); } if (process.platform === "darwin") { args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources"); } args.push("--exec", buildExecCode(luaName, opts.shellArgs ?? [], opts.computerLabel)); const chunks: Buffer[] = []; const child = spawn("craftos", args, { signal: controller.signal }); child.stdout.on("data", (d: Buffer) => chunks.push(d)); child.stderr.on("data", (d: Buffer) => chunks.push(d)); const result = await new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolve) => { child.once("close", (code, signal) => resolve({ status: code, signal })); child.once("error", () => resolve({ status: null, signal: null })); }); return { status: result.status, signal: result.signal, output: Buffer.concat(chunks).toString("utf8") }; } finally { clearTimeout(watchdog); await rm(dataDir, { recursive: true, force: true }); } })(); return { done, abort: () => controller.abort() }; } export function formatFailure(message: string, craftosOutput: string): string { const lines = ["\x1b[31mFAIL\x1b[0m " + message, "--- craftos output ---", craftosOutput.trimEnd(), "----------------------"]; return lines.join("\n"); } function buildExecCode(luaName: string, shellArgs: string[], computerLabel?: string): string { const programPath = luaName.startsWith("/") ? luaName : `/staging/${luaName}`; const parts = [luaQuote(programPath), ...shellArgs.map(luaQuote)]; const setup = computerLabel === undefined ? "" : `os.setComputerLabel(${luaQuote(computerLabel)}); `; return `${setup}shell.run(${parts.join(", ")})`; } function luaQuote(value: string): string { return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; } function waitForListening(server: Server | WebSocketServer): Promise { return new Promise((resolve, reject) => { const addr = "address" in server ? server.address() : null; if (addr) { resolve(); return; } server.once("listening", () => resolve()); server.once("error", reject); }); } function serverPort(server: Server | WebSocketServer): number { const address = server.address(); if (!address || typeof address === "string") { throw new Error("server does not have a TCP address"); } return address.port; } async function closeBridge(mcpServer: Server, linkServer: WebSocketServer): Promise { for (const client of linkServer.clients) { client.terminate(); } await new Promise((resolve) => linkServer.close(() => resolve())); await new Promise((resolve) => mcpServer.close(() => resolve())); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }