366 lines
14 KiB
JavaScript
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}`);
|
|
});
|