Files
agentdock/runner/index.js

366 lines
14 KiB
JavaScript

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}`);
});