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
+73
View File
@@ -0,0 +1,73 @@
import crypto from "node:crypto";
const COOKIE_NAME = "agentdock_session";
const MAX_AGE_MS = 12 * 60 * 60 * 1000;
function parseCookies(header = "") {
return Object.fromEntries(header.split(";").map((part) => {
const index = part.indexOf("=");
if (index < 0) return ["", ""];
return [part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())];
}).filter(([key]) => key));
}
export class AuthService {
constructor(password, { secureCookie = true } = {}) {
this.salt = crypto.randomBytes(16);
this.passwordHash = crypto.scryptSync(password, this.salt, 64);
this.sessions = new Map();
this.attempts = new Map();
this.secureCookie = secureCookie;
}
verifyPassword(password) {
const candidate = crypto.scryptSync(String(password || ""), this.salt, 64);
return crypto.timingSafeEqual(candidate, this.passwordHash);
}
login(req, res, password) {
const remote = req.ip || "unknown";
const attempt = this.attempts.get(remote) || { count: 0, resetAt: Date.now() + 60_000 };
if (Date.now() > attempt.resetAt) {
attempt.count = 0;
attempt.resetAt = Date.now() + 60_000;
}
if (attempt.count >= 8) return { ok: false, status: 429, error: "尝试次数过多,请一分钟后重试" };
if (!this.verifyPassword(password)) {
attempt.count += 1;
this.attempts.set(remote, attempt);
return { ok: false, status: 401, error: "管理员密码不正确" };
}
this.attempts.delete(remote);
const token = crypto.randomBytes(32).toString("base64url");
this.sessions.set(token, Date.now() + MAX_AGE_MS);
const secure = this.secureCookie ? "; Secure" : "";
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; Path=/; HttpOnly${secure}; SameSite=Strict; Max-Age=${MAX_AGE_MS / 1000}`);
return { ok: true };
}
logout(req, res) {
const token = parseCookies(req.headers.cookie)[COOKIE_NAME];
if (token) this.sessions.delete(token);
const secure = this.secureCookie ? "; Secure" : "";
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; Path=/; HttpOnly${secure}; SameSite=Strict; Max-Age=0`);
}
isAuthenticated(req) {
const token = parseCookies(req.headers.cookie)[COOKIE_NAME];
if (!token) return false;
const expiresAt = this.sessions.get(token);
if (!expiresAt || expiresAt < Date.now()) {
this.sessions.delete(token);
return false;
}
return true;
}
middleware() {
return (req, res, next) => {
if (!this.isAuthenticated(req)) return res.status(401).json({ error: "Unauthorized" });
next();
};
}
}
+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}`);
});
+36
View File
@@ -0,0 +1,36 @@
const LOGIN_URL_PATTERN = /https?:\/\/[^\s]+\/login\?[^\s]+/g;
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
export function parseMulticaLoginOutput(output) {
const matches = String(output || "").match(LOGIN_URL_PATTERN) || [];
const loginUrl = new URL(matches.at(-1) || "");
const callbackUrl = new URL(loginUrl.searchParams.get("cli_callback") || "");
const state = loginUrl.searchParams.get("cli_state") || "";
const port = Number(callbackUrl.port);
if (callbackUrl.protocol !== "http:" || !LOOPBACK_HOSTS.has(callbackUrl.hostname) || callbackUrl.pathname !== "/callback") {
throw new Error("Multica 尚未生成有效的本机回调地址");
}
if (!Number.isInteger(port) || port < 1024 || port > 65535 || !state) {
throw new Error("Multica 登录链接缺少端口或 state");
}
return { loginUrl, callbackUrl, state };
}
export function buildManagedMulticaLoginUrl(output, publicOrigin, sessionId) {
const { loginUrl } = parseMulticaLoginOutput(output);
const managedCallback = new URL(`/api/integrations/multica/oauth/callback/${encodeURIComponent(sessionId)}`, publicOrigin);
loginUrl.searchParams.set("cli_callback", managedCallback.toString());
return loginUrl.toString();
}
export function buildLocalMulticaCallbackUrl(output, query) {
const { callbackUrl, state } = parseMulticaLoginOutput(output);
const returnedState = typeof query?.state === "string" ? query.state : "";
const token = typeof query?.token === "string" ? query.token : "";
if (!token || !returnedState || returnedState !== state) throw new Error("Multica 回调校验失败,请重新发起验证");
callbackUrl.search = "";
callbackUrl.searchParams.set("token", token);
callbackUrl.searchParams.set("state", returnedState);
return callbackUrl.toString();
}
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildLocalMulticaCallbackUrl, buildManagedMulticaLoginUrl, parseMulticaLoginOutput } from "./multica-oauth.js";
const output = `Waiting for authentication...\nhttps://mul.example/login?cli_callback=http%3A%2F%2Flocalhost%3A43239%2Fcallback&cli_state=nonce`;
test("parses Multica login output", () => {
const parsed = parseMulticaLoginOutput(output);
assert.equal(parsed.callbackUrl.port, "43239");
assert.equal(parsed.state, "nonce");
});
test("rewrites Multica login to the managed callback endpoint", () => {
const loginUrl = new URL(buildManagedMulticaLoginUrl(output, "https://agent.example", "session-id"));
assert.equal(loginUrl.searchParams.get("cli_callback"), "https://agent.example/api/integrations/multica/oauth/callback/session-id");
});
test("builds the local callback only when state matches", () => {
assert.equal(
buildLocalMulticaCallbackUrl(output, { token: "secret", state: "nonce" }),
"http://localhost:43239/callback?token=secret&state=nonce",
);
assert.throws(() => buildLocalMulticaCallbackUrl(output, { token: "secret", state: "wrong" }), /校验失败/);
});
+116
View File
@@ -0,0 +1,116 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
export const SECRET_DEFINITIONS = Object.freeze([
{ name: "OPENAI_API_KEY", label: "OpenAI API Key", tools: ["codex", "opencode"] },
{ name: "ANTHROPIC_API_KEY", label: "Anthropic API Key", tools: ["claude", "opencode"] },
{ name: "CODEBUDDY_API_KEY", label: "CodeBuddy API Key", tools: ["codebuddy"] },
{ name: "MOONSHOT_API_KEY", label: "Moonshot API Key", tools: ["kimi"] },
{ name: "DASHSCOPE_API_KEY", label: "DashScope API Key", tools: ["qwen"] },
{ name: "DEEPSEEK_API_KEY", label: "DeepSeek API Key", tools: ["dsh", "opencode"] },
{ name: "GOOGLE_GENERATIVE_AI_API_KEY", label: "Google AI API Key", tools: ["opencode"] },
]);
const DEFINITION_BY_NAME = new Map(SECRET_DEFINITIONS.map((item) => [item.name, item]));
function mask(value) {
if (!value) return null;
if (value.length < 9) return "********";
return `${value.slice(0, 3)}...${value.slice(-4)}`;
}
export class SettingsStore {
constructor(file, encryptionSecret) {
this.file = file;
this.key = crypto.createHash("sha256").update(encryptionSecret).digest();
this.data = { secrets: {}, preferences: { models: {}, dshProfile: "headless", ccSwitchManaged: false } };
}
async init() {
await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
try {
const payload = JSON.parse(await fs.readFile(this.file, "utf8"));
const iv = Buffer.from(payload.iv, "base64");
const tag = Buffer.from(payload.tag, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", this.key, iv);
decipher.setAuthTag(tag);
const plain = Buffer.concat([
decipher.update(Buffer.from(payload.data, "base64")),
decipher.final(),
]);
const parsed = JSON.parse(plain.toString("utf8"));
this.data = {
secrets: parsed.secrets || {},
preferences: { models: {}, dshProfile: "headless", ccSwitchManaged: false, ...(parsed.preferences || {}) },
};
} catch (error) {
if (error.code !== "ENOENT") throw new Error(`Cannot decrypt console settings: ${error.message}`);
}
}
publicView() {
return {
secrets: SECRET_DEFINITIONS.map((definition) => ({
...definition,
configured: Boolean(this.data.secrets[definition.name]),
masked: mask(this.data.secrets[definition.name]),
})),
preferences: this.data.preferences,
};
}
environmentForTool(toolId) {
const environment = {};
if (this.data.preferences.ccSwitchManaged && ["claude", "codex", "opencode"].includes(toolId)) {
return environment;
}
for (const definition of SECRET_DEFINITIONS) {
if (definition.tools.includes(toolId) && this.data.secrets[definition.name]) {
environment[definition.name] = this.data.secrets[definition.name];
}
}
return environment;
}
async setSecret(name, value) {
if (!DEFINITION_BY_NAME.has(name)) throw new Error("Unsupported secret name");
if (value) this.data.secrets[name] = value;
else delete this.data.secrets[name];
await this.save();
}
async setPreferences(preferences) {
const models = typeof preferences.models === "object" && preferences.models
? preferences.models
: this.data.preferences.models;
const cleanModels = Object.fromEntries(Object.entries(models).map(([key, value]) => [
String(key).slice(0, 32),
String(value).slice(0, 128),
]));
const dshProfile = "headless";
const ccSwitchManaged = typeof preferences.ccSwitchManaged === "boolean"
? preferences.ccSwitchManaged
: this.data.preferences.ccSwitchManaged;
this.data.preferences = { models: cleanModels, dshProfile, ccSwitchManaged };
await this.save();
}
async save() {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", this.key, iv);
const encrypted = Buffer.concat([
cipher.update(JSON.stringify(this.data), "utf8"),
cipher.final(),
]);
const payload = `${JSON.stringify({
version: 1,
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
data: encrypted.toString("base64"),
}, null, 2)}\n`;
const temp = `${this.file}.tmp`;
await fs.writeFile(temp, payload, { mode: 0o600 });
await fs.rename(temp, this.file);
}
}
+38
View File
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { SettingsStore } from "./settings-store.js";
test("settings encrypt secrets and never expose full values", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "agentdock-settings-"));
const file = path.join(directory, "settings.enc.json");
const store = new SettingsStore(file, "a secure test encryption key with 32 chars");
await store.init();
await store.setSecret("OPENAI_API_KEY", "sk-test-1234567890");
const raw = await fs.readFile(file, "utf8");
assert.equal(raw.includes("sk-test-1234567890"), false);
assert.equal(store.publicView().secrets.find((item) => item.name === "OPENAI_API_KEY").masked, "sk-...7890");
});
test("CC Switch ownership suppresses competing provider environment variables", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "agentdock-settings-"));
const store = new SettingsStore(path.join(directory, "settings.enc.json"), "a secure test encryption key with 32 chars");
await store.init();
await store.setSecret("OPENAI_API_KEY", "sk-test-1234567890");
assert.equal(store.environmentForTool("codex").OPENAI_API_KEY, "sk-test-1234567890");
await store.setPreferences({ models: {}, ccSwitchManaged: true });
assert.deepEqual(store.environmentForTool("codex"), {});
assert.equal(store.environmentForTool("dsh").DEEPSEEK_API_KEY, undefined);
});
test("partial preference updates preserve CC Switch ownership", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "agentdock-settings-"));
const store = new SettingsStore(path.join(directory, "settings.enc.json"), "a secure test encryption key with 32 chars");
await store.init();
await store.setPreferences({ models: { codex: "gpt-test" }, ccSwitchManaged: true });
await store.setPreferences({ models: { claude: "sonnet-test" } });
assert.equal(store.publicView().preferences.ccSwitchManaged, true);
assert.deepEqual(store.publicView().preferences.models, { claude: "sonnet-test" });
});