import { Hono } from 'hono' import { getCookie } from 'hono/cookie' import { randomUUID } from 'node:crypto' import type { Context } from 'hono' import type { UpgradeWebSocket } from 'hono/ws' import type { WSContext, WSMessageReceive } from 'hono/ws' import { upgradeWebSocket as bunUpgradeWebSocket } from 'hono/bun' import { decodeWsPayload, handleSizedWsPayload, } from '../../transport/ws-payload' import { extractBearerToken, extractWebSocketAuthToken, } from '../../auth/middleware' import { validateApiKey } from '../../auth/api-key' import { resolveToken } from '../../auth/token' import { resolveSessionToken } from '../../auth/session' import { getDb } from '../../db/sqlite' 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' 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, } } /** * Resolve a Web UI user's token (SQLite session token from cookie or * Bearer) to a userId. Returns null if the token is invalid or absent. * * Web UI users authenticate via rct_* SQLite session tokens stored in the * rcs_access cookie (or Bearer for API calls). ACP read/relay endpoints * must accept these in addition to admin API keys, otherwise non-admin * users can list ACP sessions (via /web/sessions with sessionAuth) but * can't open them (ACP endpoints reject with 401 "unauthorized"). */ function resolveWebUserToken(c: Context): string | null { // Cookie (rcs_access) — primary Web UI auth path for WebSocket upgrades. // Wrapped because getCookie accesses c.req.raw.headers, which may not // exist on mock contexts in unit tests. let cookieToken: string | undefined try { cookieToken = getCookie(c, 'rcs_access') } catch { cookieToken = undefined } // Bearer — for REST calls from Web UI (Authorization header) const bearerToken = extractBearerToken(c) const token = cookieToken || bearerToken if (!token) return null // Try in-memory token (legacy issueToken flow) const username = resolveToken(token) if (username) return username // Try SQLite session token try { return resolveSessionToken(token, getDb()) } catch { return null } } function hasAcpReadAuth(c: Context): boolean { // Admin API key (RCS_API_KEYS) const bearer = extractBearerToken(c) if (bearer && validateApiKey(bearer)) return true // Web UI user (SQLite session token or in-memory token) return !!resolveWebUserToken(c) } /** * Relay auth: accept admin API key (RCS_API_KEYS) or valid session token. * * After Phase 4 cleanup, arbitrary non-empty tokens (UUIDs) are no longer * accepted. Only validated credentials pass. */ export function hasAcpRelayAuth(c: Context): boolean { const token = extractWebSocketAuthToken(c) if (token) { if (validateApiKey(token)) return true // Try in-memory token resolution (existing issueToken flow) if (resolveToken(token)) return true // Try SQLite session token (rct_* from Bearer or Sec-WebSocket-Protocol) try { if (resolveSessionToken(token, getDb())) return true } catch { // DB unavailable — fall through to cookie check } } // Cookie-based session token (rcs_access) — browser WebSocket upgrades // automatically send cookies, so this works without client-side token // injection. Wrapped because getCookie accesses c.req.raw.headers, // which may not exist on mock contexts in unit tests. let cookieToken: string | undefined try { cookieToken = getCookie(c, 'rcs_access') } catch { cookieToken = undefined } if (cookieToken) { if (resolveToken(cookieToken)) return true try { if (resolveSessionToken(cookieToken, getDb())) return true } catch { return false } } return false } function acpReadUnauthorized(c: Context) { return c.json( { error: { type: 'unauthorized', message: 'Missing auth' } }, 401, ) } /** * Create the ACP Hono sub-app. * Accepts `upgradeWebSocket` as a parameter so it works with both * Bun (`hono/bun`) and Node.js (`@hono/node-ws`) adapters. */ export function createAcpApp( upgradeWebSocket: UpgradeWebSocket, ): Hono { const app = new Hono() /** 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((c: Context) => { 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((c: Context) => { 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') }, } }), ) return app } 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) } /** Default export: Bun pre-built app (backward compatibility for tests) */ export default createAcpApp(bunUpgradeWebSocket as any)