chore: archive AgentDock v1 implementation
This commit is contained in:
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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" }), /校验失败/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
Reference in New Issue
Block a user