chore: archive AgentDock v1 implementation

This commit is contained in:
leefer
2026-08-24 17:14:08 +08:00
commit f512bd58ec
66 changed files with 10663 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
import { WebSocketServer, WebSocket } from "ws";
import { AuthService } from "./auth.js";
import { buildLocalMulticaCallbackUrl, buildManagedMulticaLoginUrl } from "./multica-oauth.js";
import { SettingsStore } from "./settings-store.js";
const PORT = Number(process.env.CONSOLE_PORT || 4173);
const RUNNER_URL = process.env.RUNNER_URL || "http://ai-tools:4174";
const RUNNER_TOKEN = process.env.RUNNER_TOKEN || "";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "";
const CONFIG_ENCRYPTION_KEY = process.env.CONFIG_ENCRYPTION_KEY || "";
const DATA_DIRECTORY = process.env.CONSOLE_DATA_DIRECTORY || "/data";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
if (RUNNER_TOKEN.length < 32) throw new Error("RUNNER_TOKEN must contain at least 32 characters");
if (ADMIN_PASSWORD.length < 12) throw new Error("ADMIN_PASSWORD must contain at least 12 characters");
if (CONFIG_ENCRYPTION_KEY.length < 32) throw new Error("CONFIG_ENCRYPTION_KEY must contain at least 32 characters");
const auth = new AuthService(ADMIN_PASSWORD, { secureCookie: process.env.COOKIE_SECURE !== "false" });
const settings = new SettingsStore(path.join(DATA_DIRECTORY, "settings.enc.json"), CONFIG_ENCRYPTION_KEY);
await settings.init();
async function runnerFetch(route, options = {}) {
const response = await fetch(`${RUNNER_URL}${route}`, {
...options,
headers: {
Authorization: `Bearer ${RUNNER_TOKEN}`,
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
const contentType = response.headers.get("content-type") || "";
const body = contentType.includes("application/json") ? await response.json() : await response.text();
if (!response.ok) {
const error = new Error(body?.error || `Runner request failed (${response.status})`);
error.status = response.status;
throw error;
}
return body;
}
function requireSameSite(req, res, next) {
if (!["GET", "HEAD", "OPTIONS"].includes(req.method) && req.headers["sec-fetch-site"] === "cross-site") {
return res.status(403).json({ error: "Cross-site request blocked" });
}
next();
}
const app = express();
app.set("trust proxy", 1);
app.disable("x-powered-by");
app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
res.setHeader("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' wss:; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'");
next();
});
app.use(express.json({ limit: "128kb" }));
app.use(requireSameSite);
app.get("/health", (_req, res) => res.json({ ok: true, service: "agentdock-console" }));
app.get("/api/auth/status", (req, res) => res.json({ authenticated: auth.isAuthenticated(req) }));
app.post("/api/auth/login", (req, res) => {
const result = auth.login(req, res, req.body?.password);
res.status(result.status || 200).json(result);
});
app.post("/api/auth/logout", (req, res) => {
auth.logout(req, res);
res.status(204).end();
});
app.use("/api", auth.middleware());
app.get("/api/bootstrap", async (_req, res) => {
try {
const [tools, projects, sessions, integrations] = await Promise.all([
runnerFetch("/tools"),
runnerFetch("/projects"),
runnerFetch("/sessions"),
runnerFetch("/integrations"),
]);
res.json({ ...tools, ...projects, ...sessions, ...integrations, settings: settings.publicView() });
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.get("/api/integrations", async (req, res) => {
try {
res.json(await runnerFetch(`/integrations${req.query.force === "1" ? "?force=1" : ""}`));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.get("/api/sessions/:id/output", async (req, res) => {
try {
res.type("text/plain").send(await runnerFetch(`/sessions/${encodeURIComponent(req.params.id)}/output`));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.get("/api/sessions/:id/diff", async (req, res) => {
try {
res.type("text/plain").send(await runnerFetch(`/sessions/${encodeURIComponent(req.params.id)}/diff`));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.post("/api/sessions", async (req, res) => {
try {
const body = {
tool: req.body?.tool,
purpose: req.body?.purpose,
project: req.body?.project,
profile: req.body?.profile,
model: req.body?.model,
initialInput: req.body?.initialInput,
serverUrl: req.body?.serverUrl,
appUrl: req.body?.appUrl,
cols: req.body?.cols,
rows: req.body?.rows,
environment: settings.environmentForTool(req.body?.tool),
};
res.status(201).json(await runnerFetch("/sessions", { method: "POST", body: JSON.stringify(body) }));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
for (const action of ["input", "resize", "stop"]) {
app.post(`/api/sessions/:id/${action}`, async (req, res) => {
try {
await runnerFetch(`/sessions/${encodeURIComponent(req.params.id)}/${action}`, {
method: "POST",
body: JSON.stringify(req.body || {}),
});
res.status(204).end();
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
}
app.put("/api/settings/secrets/:name", async (req, res) => {
try {
const value = typeof req.body?.value === "string" ? req.body.value.trim().slice(0, 4096) : "";
await settings.setSecret(req.params.name, value);
res.json({ settings: settings.publicView() });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.put("/api/settings/preferences", async (req, res) => {
try {
await settings.setPreferences(req.body || {});
res.json({ settings: settings.publicView() });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.post("/api/integrations/cc-switch/:app/switch", async (req, res) => {
try {
res.json(await runnerFetch(`/integrations/cc-switch/${encodeURIComponent(req.params.app)}/switch`, {
method: "POST",
body: JSON.stringify({ providerId: req.body?.providerId }),
}));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.post("/api/integrations/cc-switch/:app/import-live", async (req, res) => {
try {
res.json(await runnerFetch(`/integrations/cc-switch/${encodeURIComponent(req.params.app)}/import-live`, {
method: "POST",
body: "{}",
}));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.post("/api/integrations/multica/callback", async (req, res) => {
try {
res.json(await runnerFetch("/integrations/multica/callback", {
method: "POST",
body: JSON.stringify({ callbackUrl: req.body?.callbackUrl }),
}));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
async function multicaSession(id) {
const [{ sessions }, output] = await Promise.all([
runnerFetch("/sessions"),
runnerFetch(`/sessions/${encodeURIComponent(id)}/output`),
]);
const session = sessions.find((item) => item.id === id);
if (!session || session.tool !== "multica" || session.purpose !== "authorization") {
const error = new Error("Multica 初始化会话不存在");
error.status = 404;
throw error;
}
if (session.status !== "running") {
const error = new Error("Multica 初始化会话已过期,请重新开始");
error.status = 409;
throw error;
}
return { session, output };
}
app.get("/api/integrations/multica/auth-url/:sessionId", async (req, res) => {
try {
const { output } = await multicaSession(req.params.sessionId);
const publicOrigin = `${req.protocol}://${req.get("host")}`;
res.json({ authUrl: buildManagedMulticaLoginUrl(output, publicOrigin, req.params.sessionId) });
} catch (error) {
res.status(error.status || 409).json({ error: error.message });
}
});
app.get("/api/integrations/multica/oauth/callback/:sessionId", async (req, res) => {
try {
const { output } = await multicaSession(req.params.sessionId);
const callbackUrl = buildLocalMulticaCallbackUrl(output, req.query);
await runnerFetch("/integrations/multica/callback", {
method: "POST",
body: JSON.stringify({ callbackUrl }),
});
res.type("html").send("<!doctype html><html lang=zh-CN><meta charset=utf-8><meta http-equiv=refresh content='2;url=/'><title>Multica 验证成功</title><body><main><h1>Multica 验证成功</h1><p>验证结果已送回容器,页面即将返回 AgentDock。</p><p><a href='/'>立即返回</a></p></main></body></html>");
} catch (error) {
const message = String(error.message).replace(/[&<>"']/g, "");
res.status(error.status || 400).type("html").send(`<!doctype html><html lang=zh-CN><meta charset=utf-8><title>Multica 验证失败</title><body><main><h1>Multica 验证失败</h1><p>${message}</p><p><a href='/'>返回 AgentDock 重新验证</a></p></main></body></html>`);
}
});
const dist = path.resolve(__dirname, "../dist");
app.use(express.static(dist, { index: false, maxAge: "1h" }));
app.use((_req, res) => res.sendFile(path.join(dist, "index.html")));
const server = http.createServer(app);
const browserSockets = new WebSocketServer({ noServer: true });
server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url, "http://console.internal");
const match = url.pathname.match(/^\/api\/sessions\/([a-f0-9-]+)\/ws$/);
if (!match || !auth.isAuthenticated(request)) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
return socket.destroy();
}
browserSockets.handleUpgrade(request, socket, head, (browser) => {
const runnerWsUrl = new URL(RUNNER_URL.replace(/^http/, "ws"));
runnerWsUrl.pathname = `/ws/sessions/${match[1]}`;
runnerWsUrl.searchParams.set("token", RUNNER_TOKEN);
const upstream = new WebSocket(runnerWsUrl);
const heartbeat = setInterval(() => {
if (browser.readyState === WebSocket.OPEN) browser.ping();
if (upstream.readyState === WebSocket.OPEN) upstream.ping();
}, 25_000);
heartbeat.unref();
upstream.on("open", () => {
browser.on("message", (message, isBinary) => {
if (upstream.readyState === WebSocket.OPEN) upstream.send(message, { binary: isBinary });
});
upstream.on("message", (message, isBinary) => {
if (browser.readyState === WebSocket.OPEN) browser.send(message, { binary: isBinary });
});
});
upstream.on("close", () => browser.close());
upstream.on("error", () => browser.close(1011, "Runner connection failed"));
browser.on("close", () => upstream.close());
const clearHeartbeat = () => clearInterval(heartbeat);
upstream.once("close", clearHeartbeat);
browser.once("close", clearHeartbeat);
});
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`agentdock console listening on ${PORT}`);
});