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("Multica 验证成功

Multica 验证成功

验证结果已送回容器,页面即将返回 AgentDock。

立即返回

"); } catch (error) { const message = String(error.message).replace(/[&<>"']/g, ""); res.status(error.status || 400).type("html").send(`Multica 验证失败

Multica 验证失败

${message}

返回 AgentDock 重新验证

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