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
+365
View File
@@ -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}`);
});
+178
View File
@@ -0,0 +1,178 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const HOME = process.env.HOME || "/home/ai";
const CC_SWITCH_DATABASE = path.join(HOME, ".cc-switch", "cc-switch.db");
const ANSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
const SAFE_ID = /^[a-zA-Z0-9._-]{1,128}$/;
export const CC_SWITCH_APPS = Object.freeze([
{ id: "claude", toolId: "claude", name: "Claude Code" },
{ id: "codex", toolId: "codex", name: "Codex" },
{ id: "open-code", toolId: "opencode", name: "OpenCode" },
]);
export const MULTICA_TOOL_IDS = Object.freeze(["claude", "codex", "codebuddy", "kimi", "opencode"]);
function clean(value) {
return String(value || "").replace(ANSI, "").trim();
}
function publicEndpoint(value) {
const candidate = clean(value);
if (!candidate || candidate === "N/A") return null;
try {
const url = new URL(candidate);
return url.origin;
} catch {
return "configured";
}
}
export function parseCcProviderList(output) {
const providers = [];
let currentProviderId = null;
for (const rawLine of clean(output).split(/\r?\n/)) {
const line = rawLine.trim();
const current = line.match(/^→\s*Current:\s*(\S+)/);
if (current) currentProviderId = current[1];
if (!line.startsWith("│") || !line.includes("┆")) continue;
const columns = line.slice(1, line.endsWith("│") ? -1 : undefined).split("┆").map(clean);
if (columns.length < 4 || columns[1] === "ID") continue;
const [marker, id, name, endpoint] = columns;
if (!SAFE_ID.test(id)) continue;
providers.push({
id,
name: name || id,
endpoint: publicEndpoint(endpoint),
active: marker.includes("✓"),
});
}
if (!currentProviderId) currentProviderId = providers.find((provider) => provider.active)?.id || null;
return { currentProviderId, providers };
}
export function parseMulticaConfig(output) {
const values = {};
for (const line of clean(output).split(/\r?\n/)) {
const match = line.match(/^([a-z_]+):\s*(.*)$/);
if (match) values[match[1]] = match[2] === "(not set)" ? null : match[2];
}
return {
serverUrl: values.server_url || null,
appUrl: values.app_url || null,
workspaceId: values.workspace_id || null,
configured: Boolean(values.server_url && values.workspace_id),
};
}
async function run(command, args, options = {}) {
const { stdout, stderr } = await execFileAsync(command, args, {
timeout: options.timeout || 10000,
maxBuffer: options.maxBuffer || 1024 * 1024,
env: { ...process.env, NO_COLOR: "1", TERM: "dumb" },
});
return clean(stdout || stderr);
}
async function versionOf(command, args = ["--version"]) {
try {
const output = await run(command, args, { timeout: 8000 });
return { installed: true, version: output.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 ccSwitchInitialized() {
try {
await fs.access(CC_SWITCH_DATABASE);
return true;
} catch {
return false;
}
}
async function ccSwitchSnapshot() {
const availability = await versionOf("cc-switch");
const initialized = availability.installed && await ccSwitchInitialized();
const apps = await Promise.all(CC_SWITCH_APPS.map(async (app) => {
if (!initialized) return { ...app, currentProviderId: null, providers: [] };
try {
return { ...app, ...parseCcProviderList(await run("cc-switch", ["--app", app.id, "provider", "list"])) };
} catch (error) {
return { ...app, currentProviderId: null, providers: [], error: error.message };
}
}));
return {
...availability,
initialized,
apps,
unsupportedToolIds: ["codebuddy", "kimi", "qwen", "dsh"],
};
}
async function multicaSnapshot() {
const availability = await versionOf("multica", ["version"]);
if (!availability.installed) {
return { ...availability, daemon: { status: "unavailable" }, config: { configured: false }, supportedToolIds: MULTICA_TOOL_IDS };
}
let daemon = { status: "unknown" };
let config = { configured: false, serverUrl: null, appUrl: null, workspaceId: null };
try {
daemon = JSON.parse(await run("multica", ["daemon", "status", "--output", "json"]));
} catch (error) {
daemon = { status: "unknown", error: error.message };
}
try {
config = parseMulticaConfig(await run("multica", ["config", "show"]));
} catch (error) {
config = { ...config, error: error.message };
}
return { ...availability, daemon, config, supportedToolIds: MULTICA_TOOL_IDS };
}
let cachedSnapshot = null;
let cachedAt = 0;
export async function integrationSnapshot({ force = false } = {}) {
if (!force && cachedSnapshot && Date.now() - cachedAt < 5000) return cachedSnapshot;
const [ccSwitch, multica] = await Promise.all([ccSwitchSnapshot(), multicaSnapshot()]);
cachedSnapshot = { ccSwitch, multica };
cachedAt = Date.now();
return cachedSnapshot;
}
function validateCcSwitchApp(appId) {
if (!CC_SWITCH_APPS.some((app) => app.id === appId)) throw new Error("Unsupported CC Switch application");
}
let mutationQueue = Promise.resolve();
function serializeMutation(operation) {
const result = mutationQueue.then(operation, operation);
mutationQueue = result.catch(() => {});
return result;
}
export async function switchCcProvider(appId, providerId) {
validateCcSwitchApp(appId);
if (!SAFE_ID.test(providerId || "")) throw new Error("Invalid provider ID");
return serializeMutation(async () => {
const output = await run("cc-switch", ["--app", appId, "provider", "switch", providerId], { timeout: 30000 });
cachedSnapshot = null;
return { ok: true, output: output.split(/\r?\n/).slice(0, 4).join("\n") };
});
}
export async function importCcLiveConfig(appId) {
validateCcSwitchApp(appId);
return serializeMutation(async () => {
const output = await run("cc-switch", ["--app", appId, "provider", "import-live"], { timeout: 30000 });
cachedSnapshot = null;
return { ok: true, output: output.split(/\r?\n/).slice(0, 6).join("\n") };
});
}
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseCcProviderList, parseMulticaConfig } from "./integrations.js";
test("parses CC Switch provider table without exposing URL paths", () => {
const result = parseCcProviderList(`
┌───┬────────────────┬─────────────────┬────────────────────────────┐
│ ┆ ID ┆ Name ┆ API URL │
╞═══╪════════════════╪═════════════════╪════════════════════════════╡
│ ✓ ┆ codex-official ┆ OpenAI Official ┆ N/A │
│ ┆ relay-one ┆ Relay One ┆ https://api.example/a/key │
└───┴────────────────┴─────────────────┴────────────────────────────┘
→ Current: codex-official
`);
assert.equal(result.currentProviderId, "codex-official");
assert.deepEqual(result.providers, [
{ id: "codex-official", name: "OpenAI Official", endpoint: null, active: true },
{ id: "relay-one", name: "Relay One", endpoint: "https://api.example", active: false },
]);
});
test("parses Multica config and marks an authenticated workspace configured", () => {
const result = parseMulticaConfig(`
Config file: /home/ai/.multica/config.json
server_url: https://multica-api.example
app_url: https://multica.example
workspace_id: ws-123
`);
assert.deepEqual(result, {
serverUrl: "https://multica-api.example",
appUrl: "https://multica.example",
workspaceId: "ws-123",
configured: true,
});
});
+37
View File
@@ -0,0 +1,37 @@
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
export function parseMulticaCallbackUrl(value) {
if (typeof value !== "string" || !value || value.length > 12_000) {
throw new Error("请粘贴完整的 Multica 回调链接");
}
let url;
try {
url = new URL(value);
} catch {
throw new Error("回调链接格式不正确");
}
const port = Number(url.port);
if (url.protocol !== "http:" || !LOOPBACK_HOSTS.has(url.hostname) || url.pathname !== "/callback") {
throw new Error("只接受 Multica 生成的 localhost 回调链接");
}
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
throw new Error("回调链接缺少有效端口");
}
if (!url.searchParams.get("token") || !url.searchParams.get("state")) {
throw new Error("回调链接缺少 token 或 state");
}
return url;
}
export async function relayMulticaCallback(value, fetchImpl = fetch) {
const url = parseMulticaCallbackUrl(value);
const response = await fetchImpl(url, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) throw new Error(`Multica 回调未被接受 (${response.status})`);
return { ok: true };
}
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseMulticaCallbackUrl, relayMulticaCallback } from "./multica-callback.js";
test("accepts a complete Multica loopback callback", () => {
const url = parseMulticaCallbackUrl("http://localhost:42957/callback?token=secret&state=nonce");
assert.equal(url.port, "42957");
});
test("rejects non-loopback callback targets", () => {
assert.throws(
() => parseMulticaCallbackUrl("https://example.com/callback?token=secret&state=nonce"),
/localhost/,
);
});
test("rejects callbacks without OAuth parameters", () => {
assert.throws(() => parseMulticaCallbackUrl("http://localhost:42957/callback"), /token/);
});
test("relays the callback without exposing its response", async () => {
let receivedUrl;
const result = await relayMulticaCallback(
"http://127.0.0.1:42957/callback?token=secret&state=nonce",
async (url) => {
receivedUrl = url;
return { ok: true, status: 200 };
},
);
assert.equal(receivedUrl.hostname, "127.0.0.1");
assert.deepEqual(result, { ok: true });
});
+924
View File
@@ -0,0 +1,924 @@
{
"name": "agentdock-runner",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agentdock-runner",
"version": "0.1.0",
"dependencies": {
"express": "^5.1.0",
"node-pty": "^1.1.0",
"ws": "^8.18.3"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"license": "MIT",
"dependencies": {
"content-type": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "agentdock-runner",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node index.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.1.0",
"node-pty": "^1.1.0",
"ws": "^8.18.3"
}
}
+26
View File
@@ -0,0 +1,26 @@
import fs from "node:fs/promises";
import path from "node:path";
export async function resolveWorkspacePath(root, project = ".") {
if (typeof project !== "string" || project.includes("\0")) {
throw new Error("Invalid project path");
}
const rootReal = await fs.realpath(root);
const candidate = path.resolve(rootReal, project || ".");
const candidateReal = await fs.realpath(candidate);
if (candidateReal !== rootReal && !candidateReal.startsWith(`${rootReal}${path.sep}`)) {
throw new Error("Project must be below /workspace");
}
const stat = await fs.stat(candidateReal);
if (!stat.isDirectory()) throw new Error("Project is not a directory");
return candidateReal;
}
export async function listProjects(root) {
const rootReal = await fs.realpath(root);
const entries = await fs.readdir(rootReal, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b));
}
+21
View File
@@ -0,0 +1,21 @@
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 { listProjects, resolveWorkspacePath } from "./path-policy.js";
test("workspace policy accepts child directories and rejects traversal", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "agentdock-workspace-"));
await fs.mkdir(path.join(root, "demo"));
assert.equal(await resolveWorkspacePath(root, "demo"), path.join(root, "demo"));
await assert.rejects(() => resolveWorkspacePath(root, ".."));
});
test("project listing hides dot directories", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "agentdock-projects-"));
await fs.mkdir(path.join(root, "zeta"));
await fs.mkdir(path.join(root, "alpha"));
await fs.mkdir(path.join(root, ".cache"));
assert.deepEqual(await listProjects(root), ["alpha", "zeta"]);
});
+168
View File
@@ -0,0 +1,168 @@
export const TOOLS = Object.freeze({
codex: {
id: "codex",
command: "codex",
name: "Codex",
vendor: "OpenAI",
mono: "CX",
modelFlag: "--model",
secretNames: ["OPENAI_API_KEY"],
},
claude: {
id: "claude",
command: "claude",
name: "Claude Code",
vendor: "Anthropic",
mono: "CC",
modelFlag: "--model",
secretNames: ["ANTHROPIC_API_KEY"],
},
codebuddy: {
id: "codebuddy",
command: "codebuddy",
name: "CodeBuddy",
vendor: "Tencent",
mono: "CB",
modelFlag: "--model",
secretNames: ["CODEBUDDY_API_KEY"],
},
kimi: {
id: "kimi",
command: "kimi",
name: "Kimi CLI",
vendor: "Moonshot AI",
mono: "KM",
modelFlag: "--model",
secretNames: ["MOONSHOT_API_KEY"],
},
opencode: {
id: "opencode",
command: "opencode",
name: "OpenCode",
vendor: "SST / Open source",
mono: "OC",
modelFlag: "--model",
secretNames: [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"DEEPSEEK_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY",
],
},
qwen: {
id: "qwen",
command: "qwen",
name: "Qwen Code",
vendor: "Alibaba",
mono: "QW",
modelFlag: "--model",
secretNames: ["DASHSCOPE_API_KEY"],
},
dsh: {
id: "dsh",
command: "dsh",
name: "DeepSeek Harness",
vendor: "DeepSeek",
mono: "DS",
secretNames: ["DEEPSEEK_API_KEY"],
profiles: ["headless"],
},
ccswitch: {
id: "ccswitch",
command: "cc-switch",
name: "CC Switch",
vendor: "Community CLI",
mono: "CS",
secretNames: [],
visible: false,
integration: true,
},
multica: {
id: "multica",
command: "multica-setup",
name: "Multica Runtime",
vendor: "Multica",
mono: "MU",
secretNames: [],
visible: false,
integration: true,
},
});
export const SECRET_ENV_ALLOWLIST = new Set(
Object.values(TOOLS).flatMap((tool) => tool.secretNames),
);
export function toolById(id) {
return typeof id === "string" ? TOOLS[id] : undefined;
}
export function buildCommand(tool, request = {}) {
const args = [];
const input = request.initialInput?.trim() || "";
if (tool.integration) {
if (request.purpose !== "authorization") throw new Error("Integration tools only support setup sessions");
if (tool.id === "multica") {
for (const [flag, value] of [["--server-url", request.serverUrl], ["--app-url", request.appUrl]]) {
if (!value) continue;
let url;
try { url = new URL(value); } catch { throw new Error(`${flag} must be a valid URL`); }
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error(`${flag} must be an HTTP(S) URL without embedded credentials`);
args.push(flag, url.toString().replace(/\/$/, ""));
}
}
return { command: tool.command, args, interactive: true };
}
if (request.purpose === "task" && !input) {
throw new Error("A task message is required");
}
if (request.purpose === "task") {
switch (tool.id) {
case "codex":
args.push("exec", "--color", "never", "--skip-git-repo-check");
break;
case "claude":
case "codebuddy":
args.push("--print", "--output-format", "text");
break;
case "kimi":
args.push("--prompt", input, "--output-format", "text");
break;
case "opencode":
args.push("run", "--format", "default");
break;
case "qwen":
args.push("--prompt", input, "--output-format", "text");
break;
case "dsh":
args.push("--profile", "headless", input);
return { command: tool.command, args, interactive: false };
default:
throw new Error("Unsupported CLI task mode");
}
if (tool.modelFlag && typeof request.model === "string" && request.model.trim()) {
args.push(tool.modelFlag, request.model.trim().slice(0, 128));
}
if (!["kimi", "qwen"].includes(tool.id)) args.push(input);
return { command: tool.command, args, interactive: false };
}
if (tool.id === "dsh") {
const profile = request.profile || "headless";
if (!tool.profiles.includes(profile)) {
throw new Error("DeepSeek Harness profile must be headless or web");
}
if (profile === "headless" && !request.initialInput?.trim()) {
throw new Error("DeepSeek Harness requires a task before starting");
}
args.push("--profile", profile);
if (profile === "headless" && request.initialInput) args.push(request.initialInput);
} else if (tool.modelFlag && typeof request.model === "string" && request.model.trim()) {
args.push(tool.modelFlag, request.model.trim().slice(0, 128));
}
return { command: tool.command, args, interactive: true };
}
+74
View File
@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildCommand, toolById } from "./registry.js";
test("DeepSeek Harness requires a headless task", () => {
const tool = toolById("dsh");
assert.throws(() => buildCommand(tool, { profile: "headless", initialInput: "" }), /requires a task/);
assert.deepEqual(
buildCommand(tool, { profile: "headless", initialInput: "run tests" }),
{ command: "dsh", args: ["--profile", "headless", "run tests"], interactive: true },
);
});
test("DeepSeek Harness rejects unavailable profiles", () => {
assert.throws(() => buildCommand(toolById("dsh"), { profile: "tui", initialInput: "run tests" }), /must be headless/);
});
test("task commands use non-interactive CLI modes", () => {
const task = { purpose: "task", initialInput: "review this", model: "test-model" };
assert.deepEqual(buildCommand(toolById("codex"), task), {
command: "codex",
args: ["exec", "--color", "never", "--skip-git-repo-check", "--model", "test-model", "review this"],
interactive: false,
});
assert.deepEqual(buildCommand(toolById("claude"), task), {
command: "claude",
args: ["--print", "--output-format", "text", "--model", "test-model", "review this"],
interactive: false,
});
assert.deepEqual(buildCommand(toolById("kimi"), task), {
command: "kimi",
args: ["--prompt", "review this", "--output-format", "text", "--model", "test-model"],
interactive: false,
});
assert.deepEqual(buildCommand(toolById("opencode"), task), {
command: "opencode",
args: ["run", "--format", "default", "--model", "test-model", "review this"],
interactive: false,
});
assert.deepEqual(buildCommand(toolById("qwen"), task), {
command: "qwen",
args: ["--prompt", "review this", "--output-format", "text", "--model", "test-model"],
interactive: false,
});
assert.deepEqual(buildCommand(toolById("dsh"), task), {
command: "dsh",
args: ["--profile", "headless", "review this"],
interactive: false,
});
});
test("task commands require a message", () => {
assert.throws(
() => buildCommand(toolById("codex"), { purpose: "task", initialInput: "" }),
/task message is required/i,
);
});
test("Multica setup accepts only HTTP URLs and stays interactive", () => {
const built = buildCommand(toolById("multica"), {
purpose: "authorization",
serverUrl: "https://api.example.test/",
appUrl: "https://app.example.test/",
});
assert.deepEqual(built, {
command: "multica-setup",
args: ["--server-url", "https://api.example.test", "--app-url", "https://app.example.test"],
interactive: true,
});
assert.throws(() => buildCommand(toolById("multica"), {
purpose: "authorization",
serverUrl: "file:///etc/passwd",
}), /HTTP\(S\)/);
});
+103
View File
@@ -0,0 +1,103 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
export class SessionStore {
constructor(directory) {
this.directory = directory;
this.sessions = new Map();
}
async init() {
await fsp.mkdir(this.directory, { recursive: true, mode: 0o700 });
const files = await fsp.readdir(this.directory);
for (const file of files.filter((name) => name.endsWith(".json"))) {
try {
const record = JSON.parse(await fsp.readFile(path.join(this.directory, file), "utf8"));
if (record.status === "running") {
record.status = "interrupted";
record.endedAt = new Date().toISOString();
}
this.sessions.set(record.id, { ...record, archived: true, clients: new Set(), buffer: "" });
} catch {
// A partial metadata file must not prevent the runner from starting.
}
}
}
add(record) {
const session = { ...record, clients: new Set(), buffer: "", errorBuffer: "", archived: false };
this.sessions.set(session.id, session);
this.persist(session);
return session;
}
get(id) {
return this.sessions.get(id);
}
publicRecord(session) {
const {
clients,
pty,
childProcess,
buffer,
errorBuffer,
logStream,
environment,
archived,
...record
} = session;
return { ...record, connectedClients: clients?.size || 0, archived: Boolean(archived) };
}
list() {
return [...this.sessions.values()]
.map((session) => this.publicRecord(session))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
append(session, data) {
session.buffer += data;
if (Buffer.byteLength(session.buffer) > MAX_BUFFER_BYTES) {
session.buffer = session.buffer.slice(-MAX_BUFFER_BYTES);
}
if (!session.logStream) {
session.logStream = fs.createWriteStream(path.join(this.directory, `${session.id}.log`), {
flags: "a",
mode: 0o600,
});
}
session.logStream.write(data);
}
async readOutput(session) {
if (session.buffer) return session.buffer;
try {
return await fsp.readFile(path.join(this.directory, `${session.id}.log`), "utf8");
} catch {
return "";
}
}
finish(session, status, exitCode = null, signal = null) {
session.status = status;
session.exitCode = exitCode;
session.signal = signal;
session.endedAt = new Date().toISOString();
session.logStream?.end();
session.logStream = undefined;
this.persist(session);
}
persist(session) {
const target = path.join(this.directory, `${session.id}.json`);
const temp = `${target}.tmp`;
const body = `${JSON.stringify(this.publicRecord(session), null, 2)}\n`;
fsp.writeFile(temp, body, { mode: 0o600 })
.then(() => fsp.rename(temp, target))
.catch(() => {});
}
}