735 lines
26 KiB
JavaScript
735 lines
26 KiB
JavaScript
import crypto from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import express from "express";
|
|
import { WebSocket, WebSocketServer } from "ws";
|
|
|
|
const DEFAULT_WS_PATH = "/api/ai-workspace/hub";
|
|
const MAX_EVENTS_PER_AGENT = 5000;
|
|
const MAX_REQUEST_META_PER_AGENT = 1000;
|
|
const HEARTBEAT_INTERVAL_MS = 30000;
|
|
const AGENT_STALE_MS = 75000;
|
|
const ASSISTANT_RELAY_POLL_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_TIMEOUT_MS || 25000);
|
|
const ASSISTANT_RELAY_ACTION_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_ACTION_TIMEOUT_MS || 45000);
|
|
const ASSISTANT_RELAY_MAX_PENDING = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_MAX_PENDING || 200);
|
|
const ASSISTANT_RELAY_MAX_BATCH = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_MAX_BATCH || 10);
|
|
|
|
const config = readConfig();
|
|
const app = express();
|
|
const httpServer = createServer(app);
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
const agentsByCode = new Map();
|
|
const assistantRelaysById = new Map();
|
|
let eventSeq = 0;
|
|
|
|
app.disable("x-powered-by");
|
|
app.use(express.json({ limit: "1mb" }));
|
|
|
|
app.get("/healthz", (_req, res) => {
|
|
res.json({
|
|
ok: true,
|
|
service: "nodedc-ai-workspace-hub",
|
|
wsPath: config.wsPath,
|
|
agentsOnline: Array.from(agentsByCode.values()).filter(isAgentOnline).length,
|
|
assistantRelays: assistantRelaysById.size,
|
|
internalApiConfigured: config.internalAccessTokens.length > 0,
|
|
});
|
|
});
|
|
|
|
app.get("/api/ai-workspace/hub/v1/agents/:pairingCode", requireInternalApi, (req, res) => {
|
|
const agent = agentsByCode.get(cleanPairingCode(req.params.pairingCode));
|
|
res.json({ ok: true, agent: publicAgent(agent) });
|
|
});
|
|
|
|
app.get("/api/ai-workspace/hub/v1/agents/:pairingCode/events", requireInternalApi, (req, res) => {
|
|
const payload = readAgentEvents(req.params.pairingCode, {
|
|
since: req.query.since,
|
|
limit: req.query.limit,
|
|
});
|
|
res.json({ ok: true, ...payload });
|
|
});
|
|
|
|
app.post("/api/ai-workspace/hub/v1/agents/:pairingCode/request", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const command = cleanString(req.body?.command, 100);
|
|
const timeoutMs = sanitizeTimeoutMs(req.body?.timeoutMs, 30000);
|
|
const payload = await requestAgent(req.params.pairingCode, command, req.body?.payload || {}, timeoutMs, {
|
|
quiet: Boolean(req.body?.quiet),
|
|
});
|
|
res.json({ ok: true, payload });
|
|
}));
|
|
|
|
app.post("/api/ai-workspace/hub/v1/agents/:pairingCode/dispatch", requireInternalApi, (req, res) => {
|
|
const command = cleanString(req.body?.command, 100);
|
|
const timeoutMs = sanitizeTimeoutMs(req.body?.timeoutMs, 30000);
|
|
const dispatch = dispatchAgent(req.params.pairingCode, command, req.body?.payload || {}, timeoutMs, {
|
|
quiet: Boolean(req.body?.quiet),
|
|
});
|
|
res.json({ ok: true, requestId: dispatch.requestId });
|
|
});
|
|
|
|
app.post("/api/ai-workspace/assistant/v1/actions", requireInternalApi, asyncRoute(proxyAssistantActions));
|
|
app.post("/api/ai-workspace/setup-codes/redeem", asyncRoute(proxySetupCodeRedeem));
|
|
|
|
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/actions", requireInternalApi, asyncRoute(callAssistantRelayAction));
|
|
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/poll", requireInternalApi, asyncRoute(pollAssistantRelay));
|
|
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/results/:callId", requireInternalApi, asyncRoute(completeAssistantRelayCall));
|
|
|
|
app.use("/api/ai-workspace/hub/v1/ndc-agent-mcp/:pairingCode", asyncRoute(proxyNdcAgentMcp));
|
|
|
|
app.use((error, _req, res, _next) => {
|
|
const status = Number(error?.status || 500);
|
|
res.status(status >= 400 && status < 600 ? status : 500).json({
|
|
ok: false,
|
|
error: String(error?.message || error || "internal_error"),
|
|
});
|
|
});
|
|
|
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
let pathname = "";
|
|
try {
|
|
pathname = new URL(req.url || "/", "http://localhost").pathname;
|
|
} catch {}
|
|
if (pathname !== config.wsPath) return;
|
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
wss.emit("connection", ws, req);
|
|
});
|
|
});
|
|
|
|
wss.on("connection", attachAgent);
|
|
|
|
const heartbeat = setInterval(() => {
|
|
for (const agent of Array.from(agentsByCode.values())) {
|
|
if (!isSocketOpen(agent) || isAgentStale(agent)) {
|
|
removeAgent(agent, "bridge_agent_stale");
|
|
continue;
|
|
}
|
|
try {
|
|
agent.awaitingPong = true;
|
|
agent.lastPingAt = new Date().toISOString();
|
|
agent.socket.ping();
|
|
} catch {
|
|
removeAgent(agent, "bridge_agent_ping_failed");
|
|
}
|
|
}
|
|
}, HEARTBEAT_INTERVAL_MS);
|
|
|
|
httpServer.listen(config.port, "0.0.0.0", () => {
|
|
console.log(`NODE.DC AI Workspace Hub listening on http://0.0.0.0:${config.port}`);
|
|
console.log(`NODE.DC AI Workspace Hub ws path: ${config.wsPath}`);
|
|
});
|
|
|
|
process.on("SIGTERM", shutdown);
|
|
process.on("SIGINT", shutdown);
|
|
|
|
function attachAgent(socket, req) {
|
|
const url = new URL(req.url || config.wsPath, "http://localhost");
|
|
const pairingCode = cleanPairingCode(url.searchParams.get("pairingCode"));
|
|
if (!pairingCode) {
|
|
socket.close(1008, "pairing_code_required");
|
|
return;
|
|
}
|
|
|
|
const previous = agentsByCode.get(pairingCode);
|
|
if (previous) {
|
|
removeAgent(previous, "agent_replaced");
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const agent = {
|
|
socket,
|
|
pairingCode,
|
|
machineName: cleanString(url.searchParams.get("machineName"), 120),
|
|
host: "",
|
|
agentVersion: "",
|
|
protocolVersion: "",
|
|
capabilities: [],
|
|
runtimeStatus: "",
|
|
runtimeError: "",
|
|
runtimeCheckedAt: "",
|
|
connectedAt: now,
|
|
lastSeenAt: now,
|
|
lastPingAt: "",
|
|
awaitingPong: false,
|
|
closed: false,
|
|
pending: new Map(),
|
|
requestMeta: previous?.requestMeta || new Map(),
|
|
events: previous?.events || [],
|
|
};
|
|
agentsByCode.set(pairingCode, agent);
|
|
pushAgentEvent(agent, "", { kind: "connected", message: "Bridge agent connected." });
|
|
|
|
socket.on("pong", () => {
|
|
agent.lastSeenAt = new Date().toISOString();
|
|
agent.awaitingPong = false;
|
|
});
|
|
socket.on("message", (raw) => handleAgentMessage(agent, raw));
|
|
socket.on("close", () => removeAgent(agent, "bridge_agent_offline"));
|
|
socket.on("error", () => {});
|
|
|
|
sendJson(socket, {
|
|
type: "ready",
|
|
pairingCode,
|
|
connectedAt: now,
|
|
});
|
|
}
|
|
|
|
function handleAgentMessage(agent, raw) {
|
|
agent.lastSeenAt = new Date().toISOString();
|
|
agent.awaitingPong = false;
|
|
let message = null;
|
|
try {
|
|
message = JSON.parse(String(raw));
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (message?.type === "hello") {
|
|
agent.machineName = cleanString(message.machineName || agent.machineName, 120);
|
|
agent.host = cleanString(message.host, 120);
|
|
agent.agentVersion = cleanString(message.agentVersion, 80);
|
|
agent.protocolVersion = cleanString(message.protocolVersion, 120);
|
|
agent.capabilities = cleanStringList(message.capabilities, 80, 80);
|
|
agent.runtimeStatus = cleanString(message.runtimeStatus, 40);
|
|
agent.runtimeError = cleanString(message.runtimeError, 1000);
|
|
agent.runtimeCheckedAt = cleanString(message.runtimeCheckedAt, 80);
|
|
pushAgentEvent(agent, "", {
|
|
kind: "hello",
|
|
message: "Bridge agent hello received.",
|
|
cwd: cleanString(message.cwd, 500),
|
|
agentVersion: agent.agentVersion,
|
|
protocolVersion: agent.protocolVersion,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (message?.type === "event") {
|
|
const pending = message?.requestId ? agent.pending.get(String(message.requestId || "")) : null;
|
|
if (pending?.quiet) return;
|
|
pushAgentEvent(agent, message.requestId, message.event || message.payload || {});
|
|
return;
|
|
}
|
|
|
|
if (message?.type !== "response") return;
|
|
const requestId = String(message.requestId || "");
|
|
const pending = agent.pending.get(requestId);
|
|
if (!pending) return;
|
|
agent.pending.delete(requestId);
|
|
clearTimeout(pending.timeout);
|
|
if (!pending.quiet) {
|
|
pushAgentEvent(agent, requestId, {
|
|
kind: "response_received",
|
|
message: `Bridge response received after ${elapsedLabel(pending.startedAt)}.`,
|
|
});
|
|
}
|
|
if (message.ok === false) {
|
|
const error = new Error(String(message.error || "bridge_agent_failed"));
|
|
error.status = Number(message.status || 502);
|
|
if (!pending.quiet) pushAgentEvent(agent, requestId, { kind: "error", message: error.message });
|
|
pending.reject(error);
|
|
return;
|
|
}
|
|
if (!pending.quiet) pushAgentEvent(agent, requestId, { kind: "done", message: "Bridge request completed." });
|
|
pending.resolve(message.payload || { ok: true });
|
|
}
|
|
|
|
function requestAgent(pairingCodeRaw, command, payload = {}, timeoutMs = 30000, options = {}) {
|
|
return startAgentRequest(pairingCodeRaw, command, payload, timeoutMs, options).promise;
|
|
}
|
|
|
|
function dispatchAgent(pairingCodeRaw, command, payload = {}, timeoutMs = 30000, options = {}) {
|
|
const { requestId, promise } = startAgentRequest(pairingCodeRaw, command, payload, timeoutMs, options);
|
|
promise.catch(() => {});
|
|
return { requestId };
|
|
}
|
|
|
|
async function proxyNdcAgentMcp(req, res) {
|
|
if (!config.engineInternalUrl || !config.engineInternalAccessToken) {
|
|
res.status(503).json({ ok: false, error: "engine_proxy_not_configured" });
|
|
return;
|
|
}
|
|
const pairingCode = cleanPairingCode(req.params.pairingCode);
|
|
const agent = agentsByCode.get(pairingCode);
|
|
if (!agent || !isAgentOnline(agent)) {
|
|
res.status(503).json({ ok: false, error: "bridge_agent_offline" });
|
|
return;
|
|
}
|
|
|
|
const marker = `/api/ai-workspace/hub/v1/ndc-agent-mcp/${encodeURIComponent(req.params.pairingCode)}`;
|
|
const suffix = String(req.originalUrl || "").startsWith(marker)
|
|
? String(req.originalUrl || "").slice(marker.length)
|
|
: String(req.url || "");
|
|
const targetUrl = `${config.engineInternalUrl.replace(/\/+$/, "")}/api/ndc-agent-mcp${suffix || ""}`;
|
|
const method = String(req.method || "GET").toUpperCase();
|
|
const hasBody = !["GET", "HEAD"].includes(method);
|
|
const upstream = await fetch(targetUrl, {
|
|
method,
|
|
redirect: "manual",
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${config.engineInternalAccessToken}`,
|
|
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
|
},
|
|
...(hasBody ? { body: JSON.stringify(req.body || {}) } : {}),
|
|
});
|
|
const contentType = upstream.headers.get("content-type") || "application/json; charset=utf-8";
|
|
const text = await upstream.text();
|
|
res.status(upstream.status);
|
|
res.setHeader("content-type", contentType);
|
|
res.send(text);
|
|
}
|
|
|
|
async function proxyAssistantActions(req, res) {
|
|
if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) {
|
|
res.status(503).json({ ok: false, error: "assistant_action_proxy_not_configured" });
|
|
return;
|
|
}
|
|
const targetUrl = `${config.assistantInternalUrl.replace(/\/+$/, "")}/api/ai-workspace/assistant/v1/actions`;
|
|
const upstream = await fetch(targetUrl, {
|
|
method: "POST",
|
|
redirect: "manual",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${config.assistantInternalAccessToken}`,
|
|
...forwardOwnerHeaders(req),
|
|
},
|
|
body: JSON.stringify(req.body || {}),
|
|
});
|
|
const contentType = upstream.headers.get("content-type") || "application/json; charset=utf-8";
|
|
const text = await upstream.text();
|
|
res.status(upstream.status);
|
|
res.setHeader("content-type", contentType);
|
|
res.send(text);
|
|
}
|
|
|
|
async function proxySetupCodeRedeem(req, res) {
|
|
if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) {
|
|
res.status(503).json({ ok: false, error: "assistant_setup_proxy_not_configured" });
|
|
return;
|
|
}
|
|
const targetUrl = `${config.assistantInternalUrl.replace(/\/+$/, "")}/api/ai-workspace/assistant/v1/setup-codes/redeem`;
|
|
const upstream = await fetch(targetUrl, {
|
|
method: "POST",
|
|
redirect: "manual",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${config.assistantInternalAccessToken}`,
|
|
},
|
|
body: JSON.stringify(req.body || {}),
|
|
});
|
|
const contentType = upstream.headers.get("content-type") || "application/json; charset=utf-8";
|
|
const text = await upstream.text();
|
|
res.status(upstream.status);
|
|
res.setHeader("content-type", contentType);
|
|
res.send(text);
|
|
}
|
|
|
|
async function callAssistantRelayAction(req, res) {
|
|
const relayId = cleanRelayId(req.params.relayId);
|
|
if (!relayId) {
|
|
res.status(400).json({ ok: false, error: "assistant_relay_id_required" });
|
|
return;
|
|
}
|
|
|
|
const relay = getAssistantRelay(relayId);
|
|
pruneAssistantRelay(relay);
|
|
if (relay.calls.size >= ASSISTANT_RELAY_MAX_PENDING) {
|
|
res.status(429).json({ ok: false, error: "assistant_relay_backpressure" });
|
|
return;
|
|
}
|
|
|
|
const callId = crypto.randomUUID();
|
|
const timeoutMs = sanitizeTimeoutMs(req.body?.timeoutMs || req.query?.timeoutMs || ASSISTANT_RELAY_ACTION_TIMEOUT_MS, ASSISTANT_RELAY_ACTION_TIMEOUT_MS);
|
|
const createdAt = new Date().toISOString();
|
|
const call = {
|
|
callId,
|
|
relayId,
|
|
createdAt,
|
|
payload: req.body || {},
|
|
headers: forwardOwnerHeaders(req),
|
|
};
|
|
|
|
const result = await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
relay.calls.delete(callId);
|
|
relay.pending = relay.pending.filter((item) => item.callId !== callId);
|
|
const error = new Error("assistant_relay_timeout");
|
|
error.status = 504;
|
|
reject(error);
|
|
}, timeoutMs);
|
|
timeout.unref?.();
|
|
relay.calls.set(callId, { resolve, reject, timeout, createdAt: Date.now() });
|
|
relay.pending.push(call);
|
|
notifyAssistantRelayWaiters(relay);
|
|
});
|
|
|
|
res.status(result.status).json(result.body);
|
|
}
|
|
|
|
async function pollAssistantRelay(req, res) {
|
|
const relayId = cleanRelayId(req.params.relayId);
|
|
if (!relayId) {
|
|
res.status(400).json({ ok: false, error: "assistant_relay_id_required" });
|
|
return;
|
|
}
|
|
const relay = getAssistantRelay(relayId);
|
|
relay.lastSeenAt = new Date().toISOString();
|
|
const limit = sanitizeInteger(req.body?.limit, ASSISTANT_RELAY_MAX_BATCH, 1, ASSISTANT_RELAY_MAX_BATCH);
|
|
const timeoutMs = sanitizeTimeoutMs(req.body?.timeoutMs || req.query?.timeoutMs || ASSISTANT_RELAY_POLL_TIMEOUT_MS, ASSISTANT_RELAY_POLL_TIMEOUT_MS);
|
|
const calls = await waitForAssistantRelayCalls(relay, limit, timeoutMs);
|
|
res.json({ ok: true, relayId, calls });
|
|
}
|
|
|
|
async function completeAssistantRelayCall(req, res) {
|
|
const relayId = cleanRelayId(req.params.relayId);
|
|
const callId = cleanString(req.params.callId, 120);
|
|
const relay = assistantRelaysById.get(relayId);
|
|
const pending = relay?.calls.get(callId);
|
|
if (!relay || !pending) {
|
|
res.status(404).json({ ok: false, error: "assistant_relay_call_not_found" });
|
|
return;
|
|
}
|
|
|
|
relay.calls.delete(callId);
|
|
clearTimeout(pending.timeout);
|
|
pending.resolve({
|
|
status: sanitizeHttpStatus(req.body?.status),
|
|
body: req.body?.body === undefined ? { ok: true } : req.body.body,
|
|
});
|
|
res.json({ ok: true, relayId, callId });
|
|
}
|
|
|
|
function getAssistantRelay(relayId) {
|
|
const cleanId = cleanRelayId(relayId);
|
|
let relay = assistantRelaysById.get(cleanId);
|
|
if (!relay) {
|
|
relay = {
|
|
relayId: cleanId,
|
|
pending: [],
|
|
waiters: [],
|
|
calls: new Map(),
|
|
createdAt: new Date().toISOString(),
|
|
lastSeenAt: "",
|
|
};
|
|
assistantRelaysById.set(cleanId, relay);
|
|
}
|
|
return relay;
|
|
}
|
|
|
|
function waitForAssistantRelayCalls(relay, limit, timeoutMs) {
|
|
const ready = takeAssistantRelayCalls(relay, limit);
|
|
if (ready.length) return Promise.resolve(ready);
|
|
return new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
relay.waiters = relay.waiters.filter((waiter) => waiter.resolve !== resolve);
|
|
resolve([]);
|
|
}, timeoutMs);
|
|
timeout.unref?.();
|
|
relay.waiters.push({ resolve, timeout, limit });
|
|
});
|
|
}
|
|
|
|
function notifyAssistantRelayWaiters(relay) {
|
|
while (relay.waiters.length && relay.pending.length) {
|
|
const waiter = relay.waiters.shift();
|
|
clearTimeout(waiter.timeout);
|
|
waiter.resolve(takeAssistantRelayCalls(relay, waiter.limit));
|
|
}
|
|
}
|
|
|
|
function takeAssistantRelayCalls(relay, limit) {
|
|
const calls = [];
|
|
while (relay.pending.length && calls.length < limit) {
|
|
const call = relay.pending.shift();
|
|
if (relay.calls.has(call.callId)) calls.push(call);
|
|
}
|
|
return calls;
|
|
}
|
|
|
|
function pruneAssistantRelay(relay) {
|
|
relay.pending = relay.pending.filter((call) => relay.calls.has(call.callId));
|
|
}
|
|
|
|
function cleanRelayId(value) {
|
|
return String(value || "").trim().replace(/[^A-Za-z0-9_.:-]/g, "").slice(0, 120);
|
|
}
|
|
|
|
function sanitizeHttpStatus(value) {
|
|
const status = Number(value || 200);
|
|
return Number.isInteger(status) && status >= 100 && status <= 599 ? status : 200;
|
|
}
|
|
|
|
function forwardOwnerHeaders(req) {
|
|
const headers = {};
|
|
for (const name of [
|
|
"x-nodedc-user-id",
|
|
"x-nodedc-user-email",
|
|
"x-nodedc-user-role",
|
|
"x-nodedc-user-groups",
|
|
]) {
|
|
const value = req.headers[name];
|
|
if (typeof value === "string" && value.trim()) headers[name] = value.trim();
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function startAgentRequest(pairingCodeRaw, command, payload = {}, timeoutMs = 30000, options = {}) {
|
|
const pairingCode = cleanPairingCode(pairingCodeRaw);
|
|
const agent = agentsByCode.get(pairingCode);
|
|
if (!agent || !isAgentOnline(agent)) {
|
|
const error = new Error("bridge_agent_offline");
|
|
error.status = 503;
|
|
throw error;
|
|
}
|
|
|
|
const requestId = crypto.randomUUID();
|
|
const quiet = Boolean(options?.quiet);
|
|
agent.requestMeta.set(requestId, requestMetaFromPayload(command, payload));
|
|
trimRequestMeta(agent);
|
|
const promise = new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
agent.pending.delete(requestId);
|
|
const error = new Error("bridge_timeout");
|
|
error.status = 504;
|
|
if (!quiet) pushAgentEvent(agent, requestId, { kind: "timeout", command, message: "Bridge request timed out." });
|
|
reject(error);
|
|
}, timeoutMs);
|
|
const startedAt = Date.now();
|
|
agent.pending.set(requestId, { resolve, reject, timeout, startedAt, quiet });
|
|
if (!quiet) pushAgentEvent(agent, requestId, { kind: "engine_send", command, message: `Engine sent request: ${command}` });
|
|
sendJson(agent.socket, {
|
|
type: "request",
|
|
requestId,
|
|
command: String(command || ""),
|
|
payload,
|
|
quiet,
|
|
});
|
|
});
|
|
return { requestId, promise };
|
|
}
|
|
|
|
function readAgentEvents(pairingCodeRaw, options = {}) {
|
|
const pairingCode = cleanPairingCode(pairingCodeRaw);
|
|
const agent = agentsByCode.get(pairingCode);
|
|
const since = Number(options.since || 0);
|
|
const limit = Math.max(0, Math.min(2000, Number(options.limit || 100)));
|
|
const events = agent?.events || [];
|
|
const latestEventId = events.length ? events[events.length - 1].id : eventSeq;
|
|
return {
|
|
events: events.filter((event) => event.id > since).slice(-limit),
|
|
latestEventId,
|
|
online: Boolean(agent && isAgentOnline(agent)),
|
|
};
|
|
}
|
|
|
|
function requestMetaFromPayload(command, payload = {}) {
|
|
const context = payload?.context && typeof payload.context === "object" ? payload.context : {};
|
|
const publicUserMessage = payload?.displayMessage || payload?.publicUserMessage || payload?.message;
|
|
return {
|
|
command: cleanString(command, 80),
|
|
threadId: cleanString(payload?.threadId, 160),
|
|
threadTitle: cleanString(payload?.threadTitle, 240),
|
|
userMessage: cleanString(publicUserMessage, 12000),
|
|
remoteControlMode: cleanString(context.remoteControlMode, 40),
|
|
};
|
|
}
|
|
|
|
function pushAgentEvent(agent, requestId, rawEvent = {}) {
|
|
if (!agent) return null;
|
|
const now = new Date().toISOString();
|
|
const cleanRequestId = String(requestId || rawEvent.requestId || "");
|
|
const meta = cleanRequestId ? agent.requestMeta?.get(cleanRequestId) : null;
|
|
const event = {
|
|
id: ++eventSeq,
|
|
pairingCode: agent.pairingCode,
|
|
requestId: cleanRequestId,
|
|
threadId: rawEvent.threadId == null ? cleanString(meta?.threadId, 160) : cleanString(rawEvent.threadId, 160),
|
|
sessionId: rawEvent.sessionId == null ? "" : cleanString(rawEvent.sessionId, 160),
|
|
threadTitle: rawEvent.threadTitle == null ? cleanString(meta?.threadTitle, 240) : cleanString(rawEvent.threadTitle, 240),
|
|
userMessage: rawEvent.userMessage == null ? cleanString(meta?.userMessage, 12000) : cleanString(rawEvent.userMessage, 12000),
|
|
remoteControlMode: rawEvent.remoteControlMode == null ? cleanString(meta?.remoteControlMode, 40) : cleanString(rawEvent.remoteControlMode, 40),
|
|
kind: cleanString(rawEvent.kind || rawEvent.type || "event", 40),
|
|
stream: rawEvent.stream ? cleanString(rawEvent.stream, 40) : "",
|
|
text: rawEvent.text == null ? "" : cleanString(rawEvent.text, 12000),
|
|
message: rawEvent.message == null ? "" : cleanString(rawEvent.message, 12000),
|
|
command: rawEvent.command == null ? "" : cleanString(rawEvent.command, 1000),
|
|
cwd: rawEvent.cwd == null ? "" : cleanString(rawEvent.cwd, 500),
|
|
currentFile: rawEvent.currentFile == null ? "" : cleanString(rawEvent.currentFile, 1000),
|
|
eventsFile: rawEvent.eventsFile == null ? "" : cleanString(rawEvent.eventsFile, 1000),
|
|
at: rawEvent.at ? cleanString(rawEvent.at, 80) : now,
|
|
};
|
|
agent.events.push(event);
|
|
if (agent.events.length > MAX_EVENTS_PER_AGENT) {
|
|
agent.events.splice(0, agent.events.length - MAX_EVENTS_PER_AGENT);
|
|
}
|
|
return event;
|
|
}
|
|
|
|
function publicAgent(agent) {
|
|
if (!agent) return null;
|
|
return {
|
|
online: isAgentOnline(agent),
|
|
pairingCode: agent.pairingCode,
|
|
machineName: agent.machineName,
|
|
host: agent.host,
|
|
agentVersion: agent.agentVersion,
|
|
protocolVersion: agent.protocolVersion,
|
|
capabilities: agent.capabilities,
|
|
runtimeStatus: agent.runtimeStatus,
|
|
runtimeError: agent.runtimeError,
|
|
runtimeCheckedAt: agent.runtimeCheckedAt,
|
|
connectedAt: agent.connectedAt,
|
|
lastSeenAt: agent.lastSeenAt,
|
|
};
|
|
}
|
|
|
|
function isSocketOpen(agent) {
|
|
return Boolean(agent?.socket && agent.socket.readyState === WebSocket.OPEN);
|
|
}
|
|
|
|
function isAgentStale(agent) {
|
|
const lastSeenMs = Date.parse(agent?.lastSeenAt || agent?.connectedAt || "");
|
|
if (!Number.isFinite(lastSeenMs)) return true;
|
|
return Date.now() - lastSeenMs > AGENT_STALE_MS;
|
|
}
|
|
|
|
function isAgentOnline(agent) {
|
|
return isSocketOpen(agent) && !isAgentStale(agent);
|
|
}
|
|
|
|
function removeAgent(agent, reason = "bridge_agent_offline") {
|
|
if (!agent || agent.closed) return;
|
|
agent.closed = true;
|
|
const error = Object.assign(new Error(reason), { status: reason === "agent_replaced" ? 409 : 503 });
|
|
pushAgentEvent(agent, "", {
|
|
kind: "disconnected",
|
|
message: reason === "agent_replaced"
|
|
? "Bridge agent connection replaced."
|
|
: reason === "bridge_agent_stale"
|
|
? "Bridge agent heartbeat timed out."
|
|
: "Bridge agent disconnected.",
|
|
});
|
|
if (agentsByCode.get(agent.pairingCode) === agent) agentsByCode.delete(agent.pairingCode);
|
|
settleAll(agent, error);
|
|
try {
|
|
if (isSocketOpen(agent)) agent.socket.terminate();
|
|
} catch {}
|
|
}
|
|
|
|
function settleAll(agent, error) {
|
|
for (const pending of agent.pending.values()) {
|
|
clearTimeout(pending.timeout);
|
|
pending.reject(error);
|
|
}
|
|
agent.pending.clear();
|
|
}
|
|
|
|
function trimRequestMeta(agent) {
|
|
if (!agent?.requestMeta || agent.requestMeta.size <= MAX_REQUEST_META_PER_AGENT) return;
|
|
const excess = agent.requestMeta.size - MAX_REQUEST_META_PER_AGENT;
|
|
Array.from(agent.requestMeta.keys()).slice(0, excess).forEach((key) => agent.requestMeta.delete(key));
|
|
}
|
|
|
|
function sendJson(socket, payload) {
|
|
socket.send(JSON.stringify(payload));
|
|
}
|
|
|
|
function cleanPairingCode(value) {
|
|
return String(value || "")
|
|
.trim()
|
|
.toUpperCase()
|
|
.replace(/[^A-Z0-9]/g, "")
|
|
.slice(0, 32);
|
|
}
|
|
|
|
function cleanString(value, maxLength) {
|
|
return String(value || "").trim().slice(0, maxLength);
|
|
}
|
|
|
|
function cleanStringList(value, maxItems = 80, maxLength = 120) {
|
|
const source = Array.isArray(value)
|
|
? value
|
|
: String(value || "").split(/[\n,;]+/);
|
|
const out = [];
|
|
for (const item of source) {
|
|
const text = cleanString(item, maxLength);
|
|
if (!text || out.includes(text)) continue;
|
|
out.push(text);
|
|
if (out.length >= maxItems) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function sanitizeTimeoutMs(value, fallback) {
|
|
const numeric = Number(value || fallback);
|
|
if (!Number.isFinite(numeric)) return fallback;
|
|
return Math.max(1000, Math.min(12 * 60 * 60 * 1000, numeric));
|
|
}
|
|
|
|
function sanitizeInteger(value, fallback, min, max) {
|
|
const numeric = Number(value || fallback);
|
|
if (!Number.isFinite(numeric)) return fallback;
|
|
return Math.min(Math.max(Math.trunc(numeric), min), max);
|
|
}
|
|
|
|
function elapsedLabel(startedAt) {
|
|
const elapsed = Math.max(0, Date.now() - Number(startedAt || Date.now()));
|
|
if (elapsed < 1000) return `${elapsed}ms`;
|
|
return `${(elapsed / 1000).toFixed(1)}s`;
|
|
}
|
|
|
|
function asyncRoute(handler) {
|
|
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
|
|
}
|
|
|
|
function requireInternalApi(req, res, next) {
|
|
if (!config.internalAccessTokens.length) {
|
|
res.status(503).json({ ok: false, error: "ai_workspace_hub_token_not_configured" });
|
|
return;
|
|
}
|
|
const header = String(req.headers.authorization || "");
|
|
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
if (!token || !config.internalAccessTokens.some((candidate) => safeEqual(token, candidate))) {
|
|
res.status(401).json({ ok: false, error: "ai_workspace_hub_unauthorized" });
|
|
return;
|
|
}
|
|
next();
|
|
}
|
|
|
|
function safeEqual(left, right) {
|
|
const leftBuffer = Buffer.from(String(left || ""));
|
|
const rightBuffer = Buffer.from(String(right || ""));
|
|
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
|
}
|
|
|
|
function readConfig() {
|
|
const internalAccessTokens = Array.from(new Set([
|
|
process.env.AI_WORKSPACE_HUB_TOKEN,
|
|
process.env.NODEDC_INTERNAL_ACCESS_TOKEN,
|
|
].map((value) => cleanString(value, 1000)).filter(Boolean)));
|
|
return {
|
|
port: Number(process.env.PORT || 18081),
|
|
wsPath: process.env.AI_WORKSPACE_HUB_WS_PATH || DEFAULT_WS_PATH,
|
|
internalAccessTokens,
|
|
engineInternalUrl: cleanString(process.env.NODEDC_ENGINE_INTERNAL_URL, 1000).replace(/\/+$/, ""),
|
|
engineInternalAccessToken: cleanString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN, 1000),
|
|
assistantInternalUrl: cleanString(
|
|
process.env.NODEDC_AI_WORKSPACE_ASSISTANT_URL ||
|
|
process.env.NDC_AI_WORKSPACE_ASSISTANT_INTERNAL_URL ||
|
|
process.env.AI_WORKSPACE_ASSISTANT_INTERNAL_URL ||
|
|
"http://ai-workspace-assistant:18082",
|
|
1000,
|
|
).replace(/\/+$/, ""),
|
|
assistantInternalAccessToken: cleanString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN, 1000),
|
|
};
|
|
}
|
|
|
|
function shutdown() {
|
|
clearInterval(heartbeat);
|
|
wss.close();
|
|
httpServer.close(() => {
|
|
process.exit(0);
|
|
});
|
|
setTimeout(() => process.exit(1), 5000).unref();
|
|
}
|