import { Hono } from "hono"; import { randomUUID } from "node:crypto"; import type { Context } from "hono"; import type { WSContext, WSMessageReceive } from "hono/ws"; import { upgradeWebSocket } from "../../transport/ws-shared"; import { decodeWsPayload, handleSizedWsPayload, } from "../../transport/ws-payload"; import { extractBearerToken, extractWebSocketAuthToken, } from "../../auth/middleware"; import { validateApiKey } from "../../auth/api-key"; import { handleAcpWsOpen, handleAcpWsMessage, handleAcpWsClose, } from "../../transport/acp-ws-handler"; import { handleRelayOpen, handleRelayMessage, handleRelayClose, } from "../../transport/acp-relay-handler"; import { storeListAcpAgents, storeListAcpAgentsByChannelGroup, storeGetEnvironment, } from "../../store"; import { createAcpSSEStream } from "../../transport/acp-sse-writer"; import { log, error as logError } from "../../logger"; const app = new Hono(); type WsMessageEvent = { data: WSMessageReceive; }; type WsCloseEvent = { code?: number; reason?: string; }; /** Response shape for an ACP agent */ function toAcpAgentResponse(env: ReturnType & {}) { if (!env) return null; return { id: env.id, agent_name: env.machineName, channel_group_id: env.bridgeId, status: env.status === "active" ? "online" : "offline", max_sessions: env.maxSessions, last_seen_at: env.lastPollAt ? env.lastPollAt.getTime() / 1000 : null, created_at: env.createdAt.getTime() / 1000, }; } function hasAcpReadAuth(c: Context): boolean { const token = extractBearerToken(c); return !!token && validateApiKey(token); } export function hasAcpRelayAuth(c: Context): boolean { const token = extractWebSocketAuthToken(c); return !!token && validateApiKey(token); } function acpReadUnauthorized(c: Context) { return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401); } /** GET /acp/agents — List all registered ACP agents (API key auth) */ app.get("/agents", async (c) => { if (!hasAcpReadAuth(c)) { return acpReadUnauthorized(c); } const agents = storeListAcpAgents(); return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean)); }); /** GET /acp/channel-groups — List all channel groups with member agents (API key auth) */ app.get("/channel-groups", async (c) => { if (!hasAcpReadAuth(c)) { return acpReadUnauthorized(c); } const agents = storeListAcpAgents(); const groupMap = new Map(); for (const agent of agents) { const groupId = agent.bridgeId || "default"; if (!groupMap.has(groupId)) { groupMap.set(groupId, []); } groupMap.get(groupId)!.push(agent); } const groups = [...groupMap.entries()].map(([id, members]) => ({ channel_group_id: id, member_count: members.length, members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean), })); return c.json(groups); }); /** GET /acp/channel-groups/:id — Specific channel group detail (API key auth) */ app.get("/channel-groups/:id", async (c) => { if (!hasAcpReadAuth(c)) { return acpReadUnauthorized(c); } const groupId = c.req.param("id")!; const members = storeListAcpAgentsByChannelGroup(groupId); if (members.length === 0) { return c.json({ error: { type: "not_found", message: "Channel group not found" } }, 404); } return c.json({ channel_group_id: groupId, member_count: members.length, members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean), }); }); /** SSE /acp/channel-groups/:id/events — Event stream for external consumers (API key auth) */ app.get("/channel-groups/:id/events", async (c) => { if (!hasAcpReadAuth(c)) { return acpReadUnauthorized(c); } const groupId = c.req.param("id")!; // Support Last-Event-ID / from_sequence_num for reconnection const lastEventId = c.req.header("Last-Event-ID"); const fromSeq = c.req.query("from_sequence_num"); const fromSeqNum = fromSeq ? parseInt(fromSeq, 10) : lastEventId ? parseInt(lastEventId, 10) : 0; return createAcpSSEStream(c, groupId, fromSeqNum); }); /** WS /acp/ws — WebSocket endpoint for acp-link connections */ app.get( "/ws", upgradeWebSocket(async (c) => { const token = extractWebSocketAuthToken(c); if (!token || !validateApiKey(token)) { log("[ACP-WS] Upgrade rejected: unauthorized"); return { onOpen(_evt: Event, ws: WSContext) { ws.close(4003, "unauthorized"); }, }; } // Generate unique wsId for this connection const wsId = `acp_ws_${randomUUID().replace(/-/g, "")}`; log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`); return { onOpen(_evt: Event, ws: WSContext) { handleAcpWsOpen(ws, wsId); }, onMessage(evt: WsMessageEvent, ws: WSContext) { handleAcpWsPayload( ws, "[ACP-WS]", `wsId=${wsId}`, evt.data, data => handleAcpWsMessage(ws, wsId, data), ); }, onClose(evt: WsCloseEvent, ws: WSContext) { handleAcpWsClose(ws, wsId, evt.code, evt.reason); }, onError(evt: Event, ws: WSContext) { logError(`[ACP-WS] Error on wsId=${wsId}:`, evt); handleAcpWsClose(ws, wsId, 1006, "websocket error"); }, }; }), ); /** WS /acp/relay/:agentId — WebSocket relay for frontend to interact with an agent */ app.get( "/relay/:agentId", upgradeWebSocket(async (c) => { if (!hasAcpRelayAuth(c)) { log("[ACP-Relay] Upgrade rejected: unauthorized"); return { onOpen(_evt: Event, ws: WSContext) { ws.close(4003, "unauthorized"); }, }; } const agentId = c.req.param("agentId")!; const relayWsId = `relay_${randomUUID().replace(/-/g, "")}`; log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`); return { onOpen(_evt: Event, ws: WSContext) { handleRelayOpen(ws, relayWsId, agentId); }, onMessage(evt: WsMessageEvent, ws: WSContext) { handleAcpWsPayload( ws, "[ACP-Relay]", `relayWsId=${relayWsId}`, evt.data, data => handleRelayMessage(ws, relayWsId, data), ); }, onClose(evt: WsCloseEvent, ws: WSContext) { handleRelayClose(ws, relayWsId, evt.code, evt.reason); }, onError(evt: Event, ws: WSContext) { logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt); handleRelayClose(ws, relayWsId, 1006, "websocket error"); }, }; }), ); export const decodeAcpWsMessageData = decodeWsPayload; export function handleAcpWsPayload( ws: WSContext, logPrefix: string, label: string, payload: unknown, handleMessage: (data: string) => void, ): boolean { return handleSizedWsPayload(ws, logPrefix, label, payload, handleMessage); } export default app;