chore: archive AgentDock v1 implementation
This commit is contained in:
+365
@@ -0,0 +1,365 @@
|
||||
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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user