import crypto from "node:crypto"; import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; import fs from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import express from "express"; import pty from "node-pty"; import { WebSocketServer, WebSocket } from "ws"; import { buildCommand, SECRET_ENV_ALLOWLIST, TOOLS, toolById } from "./registry.js"; import { importCcLiveConfig, integrationSnapshot, switchCcProvider } from "./integrations.js"; import { relayMulticaCallback } from "./multica-callback.js"; import { listProjects, resolveWorkspacePath } from "./path-policy.js"; import { SessionStore } from "./session-store.js"; const execFileAsync = promisify(execFile); const PORT = Number(process.env.RUNNER_PORT || 4174); const TOKEN = process.env.RUNNER_TOKEN || ""; const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT || "/workspace"; const SESSION_DIRECTORY = process.env.SESSION_DIRECTORY || "/home/ai/.ai-console/sessions"; if (TOKEN.length < 32) throw new Error("RUNNER_TOKEN must contain at least 32 characters"); const store = new SessionStore(SESSION_DIRECTORY); await store.init(); function tokenMatches(candidate = "") { const left = Buffer.from(candidate); const right = Buffer.from(TOKEN); return left.length === right.length && crypto.timingSafeEqual(left, right); } function requireRunnerToken(req, res, next) { const candidate = req.headers.authorization?.replace(/^Bearer\s+/i, "") || ""; if (!tokenMatches(candidate)) return res.status(401).json({ error: "Unauthorized" }); next(); } function jsonError(res, error, status = 400) { const message = error instanceof Error ? error.message : "Request failed"; return res.status(status).json({ error: message }); } async function commandVersion(command) { try { const { stdout, stderr } = await execFileAsync(command, ["--version"], { timeout: 8000, maxBuffer: 256 * 1024, env: process.env, }); return { installed: true, version: (stdout || stderr).trim().split(/\r?\n/)[0] || "installed" }; } catch (error) { if (error.code === "ENOENT") return { installed: false, version: null }; return { installed: true, version: "unknown", error: error.message }; } } async function processResources(pid) { if (!pid) return { cpu: null, memoryBytes: null }; try { const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "%cpu=,rss="], { timeout: 3000, maxBuffer: 64 * 1024, }); const [cpu, rssKb] = stdout.trim().split(/\s+/); return { cpu: Number.isFinite(Number(cpu)) ? Number(cpu) : null, memoryBytes: Number.isFinite(Number(rssKb)) ? Number(rssKb) * 1024 : null, }; } catch { return { cpu: null, memoryBytes: null }; } } function activeSessionForTool(toolId) { return store.list().find((session) => session.tool === toolId && session.status === "running"); } function broadcast(session, message) { const payload = JSON.stringify(message); for (const client of session.clients) { if (client.readyState === WebSocket.OPEN) client.send(payload); } } function appendOutput(session, data) { if (!data) return; store.append(session, data); broadcast(session, { type: "output", data }); } const app = express(); app.disable("x-powered-by"); app.use(express.json({ limit: "128kb" })); app.get("/health", (_req, res) => res.json({ ok: true, service: "agentdock-runner" })); app.use(requireRunnerToken); app.get("/tools", async (_req, res) => { const records = await Promise.all(Object.values(TOOLS).filter((tool) => tool.visible !== false).map(async (tool) => { const version = await commandVersion(tool.command); const active = activeSessionForTool(tool.id); const resources = active ? await processResources(active.pid) : { cpu: null, memoryBytes: null }; return { id: tool.id, name: tool.name, vendor: tool.vendor, mono: tool.mono, command: tool.command, profiles: tool.profiles || [], ...version, status: active ? "running" : version.installed ? "stopped" : "error", sessionId: active?.id || null, project: active?.project || null, resources, }; })); res.json({ tools: records }); }); app.get("/projects", async (_req, res) => { try { res.json({ projects: await listProjects(WORKSPACE_ROOT) }); } catch (error) { jsonError(res, error, 500); } }); app.get("/sessions", (_req, res) => res.json({ sessions: store.list() })); app.get("/integrations", async (req, res) => { try { res.json({ integrations: await integrationSnapshot({ force: req.query.force === "1" }) }); } catch (error) { jsonError(res, error, 500); } }); app.post("/integrations/cc-switch/:app/switch", async (req, res) => { try { await switchCcProvider(req.params.app, req.body?.providerId); res.json({ integrations: await integrationSnapshot({ force: true }) }); } catch (error) { jsonError(res, error); } }); app.post("/integrations/cc-switch/:app/import-live", async (req, res) => { try { await importCcLiveConfig(req.params.app); res.json({ integrations: await integrationSnapshot({ force: true }) }); } catch (error) { jsonError(res, error); } }); app.post("/integrations/multica/callback", async (req, res) => { try { res.json(await relayMulticaCallback(req.body?.callbackUrl)); } catch (error) { jsonError(res, error); } }); app.get("/sessions/:id/output", async (req, res) => { const session = store.get(req.params.id); if (!session) return res.status(404).json({ error: "Session not found" }); res.type("text/plain").send(await store.readOutput(session)); }); app.get("/sessions/:id/diff", async (req, res) => { const session = store.get(req.params.id); if (!session) return res.status(404).json({ error: "Session not found" }); try { const cwd = await resolveWorkspacePath(WORKSPACE_ROOT, session.project); const { stdout } = await execFileAsync("git", ["diff", "--no-ext-diff", "--no-color"], { cwd, timeout: 10000, maxBuffer: 2 * 1024 * 1024, }); res.type("text/plain").send(stdout || ""); } catch (error) { if (error.code === 1 && typeof error.stdout === "string") return res.type("text/plain").send(error.stdout); jsonError(res, error, 500); } }); app.post("/sessions", async (req, res) => { const tool = toolById(req.body?.tool); if (!tool) return res.status(400).json({ error: "Unsupported CLI tool" }); try { const cwd = await resolveWorkspacePath(WORKSPACE_ROOT, req.body.project || "."); const request = { purpose: req.body.purpose === "authorization" ? "authorization" : "task", profile: req.body.profile, model: req.body.model, initialInput: typeof req.body.initialInput === "string" ? req.body.initialInput.slice(0, 32000) : "", serverUrl: typeof req.body.serverUrl === "string" ? req.body.serverUrl.trim().slice(0, 2048) : "", appUrl: typeof req.body.appUrl === "string" ? req.body.appUrl.trim().slice(0, 2048) : "", }; const built = buildCommand(tool, request); const environment = {}; for (const [name, value] of Object.entries(req.body.environment || {})) { if (SECRET_ENV_ALLOWLIST.has(name) && tool.secretNames.includes(name) && typeof value === "string") { environment[name] = value; } } const id = crypto.randomUUID(); const processEnvironment = { ...process.env, ...environment, TERM: built.interactive ? "xterm-256color" : "dumb", COLORTERM: built.interactive ? "truecolor" : "", NO_COLOR: built.interactive ? (process.env.NO_COLOR || "") : "1", CI: built.interactive ? (process.env.CI || "") : "1", }; const baseRecord = { id, tool: tool.id, toolName: tool.name, project: path.relative(await fs.realpath(WORKSPACE_ROOT), cwd) || ".", purpose: request.purpose, initialInput: request.initialInput, profile: request.profile || null, model: typeof req.body.model === "string" ? req.body.model.slice(0, 128) : null, status: "running", createdAt: new Date().toISOString(), endedAt: null, exitCode: null, signal: null, interactive: built.interactive, environment, }; let session; if (built.interactive) { const processHandle = pty.spawn(built.command, built.args, { name: "xterm-256color", cols: Math.max(40, Math.min(300, Number(req.body.cols) || 120)), rows: Math.max(12, Math.min(120, Number(req.body.rows) || 32)), cwd, env: processEnvironment, }); session = store.add({ ...baseRecord, pid: processHandle.pid, pty: processHandle }); processHandle.onData((data) => appendOutput(session, data)); processHandle.onExit(({ exitCode, signal }) => { session.processExited = true; const status = session.status === "stopped" ? "stopped" : exitCode === 0 ? "exited" : "failed"; store.finish(session, status, exitCode, signal); broadcast(session, { type: "exit", exitCode, signal, status }); }); } else { const childProcess = spawn(built.command, built.args, { cwd, env: processEnvironment, stdio: ["ignore", "pipe", "pipe"], }); session = store.add({ ...baseRecord, pid: childProcess.pid, childProcess }); childProcess.stdout.setEncoding("utf8"); childProcess.stderr.setEncoding("utf8"); childProcess.stdout.on("data", (data) => appendOutput(session, data)); childProcess.stderr.on("data", (data) => { session.errorBuffer = `${session.errorBuffer}${data}`.slice(-256 * 1024); broadcast(session, { type: "activity", label: "CLI 正在处理任务" }); }); childProcess.on("error", (error) => { session.errorBuffer = error.message; }); childProcess.on("close", (exitCode, signal) => { session.processExited = true; const status = session.status === "stopped" ? "stopped" : exitCode === 0 ? "exited" : "failed"; if (!session.buffer.trim()) { const fallback = status === "failed" ? session.errorBuffer.trim() || `CLI 退出,代码 ${exitCode ?? "unknown"}` : "任务已完成,但 CLI 没有返回文本。"; appendOutput(session, fallback); } store.finish(session, status, exitCode, signal); broadcast(session, { type: "exit", exitCode, signal, status }); }); } res.status(201).json({ session: store.publicRecord(session) }); } catch (error) { jsonError(res, error); } }); app.post("/sessions/:id/input", (req, res) => { const session = store.get(req.params.id); if (!session) return res.status(404).json({ error: "Session not found" }); if (session.status !== "running" || !session.pty || !session.interactive) return res.status(409).json({ error: "Only interactive sessions accept terminal input" }); const data = typeof req.body?.data === "string" ? req.body.data.slice(0, 65536) : ""; session.pty.write(data); res.status(204).end(); }); app.post("/sessions/:id/resize", (req, res) => { const session = store.get(req.params.id); if (!session) return res.status(404).json({ error: "Session not found" }); if (session.status !== "running" || !session.pty || !session.interactive) return res.status(409).json({ error: "Only interactive sessions can be resized" }); const cols = Math.max(40, Math.min(300, Number(req.body?.cols) || 120)); const rows = Math.max(12, Math.min(120, Number(req.body?.rows) || 32)); session.pty.resize(cols, rows); res.status(204).end(); }); app.post("/sessions/:id/stop", (req, res) => { const session = store.get(req.params.id); if (!session) return res.status(404).json({ error: "Session not found" }); if (session.status === "running" && (session.pty || session.childProcess)) { store.finish(session, "stopped"); const handle = session.pty || session.childProcess; handle.kill("SIGTERM"); setTimeout(() => { if (!session.processExited) handle.kill("SIGKILL"); }, 5000).unref(); } res.status(204).end(); }); const server = http.createServer(app); const sockets = new WebSocketServer({ noServer: true }); const socketHeartbeat = setInterval(() => { for (const client of sockets.clients) { if (client.readyState === WebSocket.OPEN) client.ping(); } }, 25_000); socketHeartbeat.unref(); server.on("upgrade", (request, socket, head) => { const url = new URL(request.url, "http://runner.internal"); const match = url.pathname.match(/^\/ws\/sessions\/([a-f0-9-]+)$/); if (!match || !tokenMatches(url.searchParams.get("token") || "")) { socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); return socket.destroy(); } const session = store.get(match[1]); if (!session) { socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"); return socket.destroy(); } sockets.handleUpgrade(request, socket, head, async (client) => { session.clients.add(client); client.send(JSON.stringify({ type: "snapshot", data: await store.readOutput(session), session: store.publicRecord(session) })); client.on("message", (raw) => { if (session.status !== "running" || !session.pty || !session.interactive) return; try { const message = JSON.parse(raw.toString()); if (message.type === "input" && typeof message.data === "string") session.pty.write(message.data.slice(0, 65536)); if (message.type === "resize") { const cols = Math.max(40, Math.min(300, Number(message.cols) || 120)); const rows = Math.max(12, Math.min(120, Number(message.rows) || 32)); session.pty.resize(cols, rows); } } catch { client.send(JSON.stringify({ type: "error", error: "Invalid WebSocket message" })); } }); client.on("close", () => session.clients.delete(client)); }); }); server.listen(PORT, "0.0.0.0", () => { console.log(`agentdock runner listening on ${PORT}`); });