Files
claude-code/packages/acp-link/src/server.ts
2026-05-01 21:39:30 +08:00

1288 lines
36 KiB
TypeScript

import { spawn, type ChildProcess } from 'node:child_process'
import { createServer as createHttpsServer } from 'node:https'
import { Writable, Readable } from 'node:stream'
import * as acp from '@agentclientprotocol/sdk'
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { createNodeWebSocket } from '@hono/node-ws'
import type { WSContext } from 'hono/ws'
import type { WebSocket as RawWebSocket } from 'ws'
import { createLogger } from './logger.js'
import { getOrCreateCertificate, getLanIPs } from './cert.js'
import { RcsUpstreamClient, type RcsUpstreamConfig } from './rcs-upstream.js'
import { decodeJsonWsMessage, WsPayloadTooLargeError } from './ws-message.js'
import { authTokensEqual, extractWebSocketAuthToken } from './ws-auth.js'
export { MAX_CLIENT_WS_PAYLOAD_BYTES } from './ws-message.js'
export interface ServerConfig {
port: number
host: string
command: string
args: string[]
cwd: string
debug?: boolean
token?: string
https?: boolean
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
permissionMode?: string
/** Channel group ID for RCS registration */
group?: string
}
// Pending permission request
interface PendingPermission {
resolve: (
outcome:
| { outcome: 'cancelled' }
| { outcome: 'selected'; optionId: string },
) => void
timeout: ReturnType<typeof setTimeout>
}
// PromptCapabilities from ACP protocol
// Reference: Zed's prompt_capabilities to check image support
interface PromptCapabilities {
audio?: boolean
embeddedContext?: boolean
image?: boolean
}
// SessionModelState from ACP protocol
// Reference: Zed's AgentModelSelector reads from state.available_models
interface SessionModelState {
availableModels: Array<{
modelId: string
name: string
description?: string | null
}>
currentModelId: string
}
// AgentCapabilities from ACP protocol
// Reference: Zed's AcpConnection.agent_capabilities
// Matches SDK's AgentCapabilities exactly
interface AgentCapabilities {
_meta?: Record<string, unknown> | null
loadSession?: boolean
mcpCapabilities?: {
_meta?: Record<string, unknown> | null
clientServers?: boolean
}
promptCapabilities?: PromptCapabilities
sessionCapabilities?: {
_meta?: Record<string, unknown> | null
fork?: Record<string, unknown> | null
list?: Record<string, unknown> | null
resume?: Record<string, unknown> | null
}
}
// Track connected clients and their agent connections
interface ClientState {
process: ChildProcess | null
connection: acp.ClientSideConnection | null
sessionId: string | null
pendingPermissions: Map<string, PendingPermission>
agentCapabilities: AgentCapabilities | null
promptCapabilities: PromptCapabilities | null
modelState: SessionModelState | null
isAlive: boolean
}
// Module-level state (set when server starts)
let AGENT_COMMAND: string
let AGENT_ARGS: string[]
let AGENT_CWD: string
let SERVER_PORT: number
let SERVER_HOST: string
let AUTH_TOKEN: string | undefined
let DEFAULT_PERMISSION_MODE: string | undefined
const clients = new Map<WSContext, ClientState>()
// Module-scoped child loggers
const logWs = createLogger('ws')
const logAgent = createLogger('agent')
const logSession = createLogger('session')
const logPrompt = createLogger('prompt')
const logPerm = createLogger('perm')
const logRelay = createLogger('relay')
const logServer = createLogger('server')
// RCS upstream client (optional — enabled via ACP_RCS_URL env var)
let rcsUpstream: RcsUpstreamClient | null = null
/**
* Create a virtual WSContext for RCS relay messages.
* Responses via send() go to RCS upstream (not a local WS).
*/
function createRelayWs(): WSContext {
return {
get readyState() {
return 1
}, // always OPEN
send: () => {}, // no-op — responses go through rcsUpstream.send()
close: () => {},
raw: null,
isInner: false,
url: '',
origin: '',
protocol: '',
} as unknown as WSContext
}
// Permission request timeout (5 minutes)
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000
// Heartbeat interval for WebSocket ping/pong (30 seconds)
const HEARTBEAT_INTERVAL_MS = 30_000
// Generate unique request ID
function generateRequestId(): string {
return `perm_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
}
// Send a message to the WebSocket client (and optionally forward to RCS upstream)
function send(ws: WSContext, type: string, payload?: unknown): void {
if (ws.readyState === 1) {
// WebSocket.OPEN
ws.send(JSON.stringify({ type, payload }))
}
// Forward to RCS upstream if connected
if (rcsUpstream?.isRegistered()) {
rcsUpstream.send({ type, payload })
}
}
// Create a Client implementation that forwards events to WebSocket
function createClient(ws: WSContext, clientState: ClientState): acp.Client {
return {
async requestPermission(params) {
const requestId = generateRequestId()
logPerm.debug({ requestId, title: params.toolCall.title }, 'requested')
const outcomePromise = new Promise<
{ outcome: 'cancelled' } | { outcome: 'selected'; optionId: string }
>(resolve => {
const timeout = setTimeout(() => {
logPerm.warn({ requestId }, 'timed out')
clientState.pendingPermissions.delete(requestId)
resolve({ outcome: 'cancelled' })
}, PERMISSION_TIMEOUT_MS)
clientState.pendingPermissions.set(requestId, { resolve, timeout })
})
send(ws, 'permission_request', {
requestId,
sessionId: params.sessionId,
options: params.options,
toolCall: params.toolCall,
})
const outcome = await outcomePromise
logPerm.debug({ requestId, outcome: outcome.outcome }, 'resolved')
return { outcome }
},
async sessionUpdate(params) {
send(ws, 'session_update', params)
},
async readTextFile(params) {
logWs.debug({ path: params.path }, 'readTextFile')
return { content: '' }
},
async writeTextFile(params) {
logWs.debug({ path: params.path }, 'writeTextFile')
return {}
},
}
}
// Handle permission response from client
function handlePermissionResponse(
ws: WSContext,
payload: {
requestId: string
outcome:
| { outcome: 'cancelled' }
| { outcome: 'selected'; optionId: string }
},
): void {
const state = clients.get(ws)
if (!state) {
logPerm.warn('response from unknown client')
return
}
const pending = state.pendingPermissions.get(payload.requestId)
if (!pending) {
logPerm.warn(
{ requestId: payload.requestId },
'response for unknown request',
)
return
}
clearTimeout(pending.timeout)
state.pendingPermissions.delete(payload.requestId)
pending.resolve(payload.outcome)
}
// Cancel all pending permissions for a client (called on disconnect)
function cancelPendingPermissions(clientState: ClientState): void {
for (const [requestId, pending] of clientState.pendingPermissions) {
logPerm.debug({ requestId }, 'cancelled on disconnect')
clearTimeout(pending.timeout)
pending.resolve({ outcome: 'cancelled' })
}
clientState.pendingPermissions.clear()
}
async function handleConnect(ws: WSContext): Promise<void> {
const state = clients.get(ws)
if (!state) return
// If already connected to a running agent, just resend status
// This handles frontend reconnections without restarting the agent process
// Check both .killed and .exitCode to detect crashed processes
if (
state.connection &&
state.process &&
!state.process.killed &&
state.process.exitCode === null
) {
logAgent.info('already connected, resending status')
send(ws, 'status', {
connected: true,
agentInfo: { name: AGENT_COMMAND },
capabilities: state.agentCapabilities,
})
return
}
// Kill existing process if any (only if not healthy)
if (state.process) {
cancelPendingPermissions(state)
state.process.kill()
state.process = null
state.connection = null
}
try {
logAgent.info({ command: AGENT_COMMAND, args: AGENT_ARGS }, 'spawning')
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
cwd: AGENT_CWD,
stdio: ['pipe', 'pipe', 'inherit'],
env: buildAgentEnv(),
})
state.process = agentProcess
// Clean up state when agent process exits unexpectedly
agentProcess.on('exit', code => {
logAgent.info({ exitCode: code }, 'agent process exited')
// Only clear if this is still the current process
if (state.process === agentProcess) {
state.process = null
state.connection = null
state.sessionId = null
}
})
const input = Writable.toWeb(
agentProcess.stdin!,
) as unknown as WritableStream<Uint8Array>
const output = Readable.toWeb(
agentProcess.stdout!,
) as unknown as ReadableStream<Uint8Array>
const stream = acp.ndJsonStream(input, output)
const connection = new acp.ClientSideConnection(
_agent => createClient(ws, state),
stream,
)
state.connection = connection
const initResult = await connection.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
clientInfo: { name: 'zed', version: '1.0.0' },
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
})
const agentCaps = initResult.agentCapabilities
state.agentCapabilities = agentCaps
? {
_meta: agentCaps._meta,
loadSession: agentCaps.loadSession,
mcpCapabilities: agentCaps.mcpCapabilities,
promptCapabilities: agentCaps.promptCapabilities,
sessionCapabilities: agentCaps.sessionCapabilities,
}
: null
state.promptCapabilities = agentCaps?.promptCapabilities ?? null
logAgent.info(
{
protocolVersion: initResult.protocolVersion,
loadSession: !!state.agentCapabilities?.loadSession,
sessionList: !!state.agentCapabilities?.sessionCapabilities?.list,
sessionResume: !!state.agentCapabilities?.sessionCapabilities?.resume,
hasMcp: !!state.agentCapabilities?.mcpCapabilities,
},
'initialized',
)
send(ws, 'status', {
connected: true,
agentInfo: initResult.agentInfo,
capabilities: state.agentCapabilities,
})
connection.closed.then(() => {
logAgent.info('connection closed')
state.connection = null
state.sessionId = null
send(ws, 'status', { connected: false })
})
} catch (error) {
logAgent.error({ error: (error as Error).message }, 'connect failed')
send(ws, 'error', {
message: `Failed to connect: ${(error as Error).message}`,
})
}
}
async function handleNewSession(
ws: WSContext,
params: { cwd?: string; permissionMode?: string },
): Promise<void> {
const state = clients.get(ws)
if (!state?.connection) {
logAgent.warn(
{
hasState: !!state,
hasProcess: !!state?.process,
processKilled: state?.process?.killed,
exitCode: state?.process?.exitCode,
},
'handleNewSession: not connected to agent',
)
send(ws, 'error', { message: 'Not connected to agent' })
return
}
try {
const sessionCwd = params.cwd || AGENT_CWD
let permissionMode: string | undefined
try {
permissionMode = resolveNewSessionPermissionMode(
params.permissionMode,
DEFAULT_PERMISSION_MODE,
)
} catch (error) {
send(ws, 'error', { message: (error as Error).message })
return
}
const result = await state.connection.newSession({
cwd: sessionCwd,
mcpServers: [],
...(permissionMode ? { _meta: { permissionMode } } : {}),
})
state.sessionId = result.sessionId
state.modelState = result.models ?? null
logSession.info(
{
sessionId: result.sessionId,
cwd: sessionCwd,
hasModels: !!result.models,
},
'created',
)
send(ws, 'session_created', {
...result,
promptCapabilities: state.promptCapabilities,
models: state.modelState,
})
} catch (error) {
logSession.error({ error: (error as Error).message }, 'create failed')
send(ws, 'error', {
message: `Failed to create session: ${(error as Error).message}`,
})
}
}
// ============================================================================
// Session History Operations
// Reference: Zed's AgentConnection trait - list_sessions, load_session, resume_session
// ============================================================================
async function handleListSessions(
ws: WSContext,
params: { cwd?: string; cursor?: string },
): Promise<void> {
const state = clients.get(ws)
if (!state?.connection) {
logAgent.warn(
{
hasState: !!state,
hasProcess: !!state?.process,
processKilled: state?.process?.killed,
exitCode: state?.process?.exitCode,
},
'handleListSessions: not connected to agent',
)
send(ws, 'error', { message: 'Not connected to agent' })
return
}
if (!state.agentCapabilities?.sessionCapabilities?.list) {
send(ws, 'error', {
message: 'Listing sessions is not supported by this agent',
})
return
}
try {
const result = await state.connection.listSessions({
cwd: params.cwd,
cursor: params.cursor,
})
const MAX_SESSIONS = 20
const sessions = result.sessions.slice(0, MAX_SESSIONS)
logSession.info(
{
total: result.sessions.length,
returned: sessions.length,
hasMore: !!result.nextCursor,
},
'listed',
)
send(ws, 'session_list', {
sessions: sessions.map((s: acp.SessionInfo) => ({
_meta: s._meta,
cwd: s.cwd,
sessionId: s.sessionId,
title: s.title,
updatedAt: s.updatedAt,
})),
nextCursor: result.nextCursor,
_meta: result._meta,
})
} catch (error) {
logSession.error({ error: (error as Error).message }, 'list failed')
send(ws, 'error', {
message: `Failed to list sessions: ${(error as Error).message}`,
})
}
}
async function handleLoadSession(
ws: WSContext,
params: { sessionId: string; cwd?: string },
): Promise<void> {
const state = clients.get(ws)
if (!state?.connection) {
logAgent.warn(
{
hasState: !!state,
hasProcess: !!state?.process,
processKilled: state?.process?.killed,
exitCode: state?.process?.exitCode,
},
'handleLoadSession: not connected to agent',
)
send(ws, 'error', { message: 'Not connected to agent' })
return
}
if (!state.agentCapabilities?.loadSession) {
send(ws, 'error', {
message: 'Loading sessions is not supported by this agent',
})
return
}
try {
const sessionCwd = params.cwd || AGENT_CWD
const sessionId = params.sessionId
const result = await state.connection.loadSession({
sessionId,
cwd: sessionCwd,
mcpServers: [],
})
state.sessionId = sessionId
state.modelState = result.models ?? null
logSession.info({ sessionId, cwd: sessionCwd }, 'loaded')
send(ws, 'session_loaded', {
sessionId,
promptCapabilities: state.promptCapabilities,
models: state.modelState,
})
} catch (error) {
logSession.error({ error: (error as Error).message }, 'load failed')
send(ws, 'error', {
message: `Failed to load session: ${(error as Error).message}`,
})
}
}
async function handleResumeSession(
ws: WSContext,
params: { sessionId: string; cwd?: string },
): Promise<void> {
const state = clients.get(ws)
if (!state?.connection) {
logAgent.warn(
{
hasState: !!state,
hasProcess: !!state?.process,
processKilled: state?.process?.killed,
exitCode: state?.process?.exitCode,
},
'handleResumeSession: not connected to agent',
)
send(ws, 'error', { message: 'Not connected to agent' })
return
}
if (!state.agentCapabilities?.sessionCapabilities?.resume) {
send(ws, 'error', {
message: 'Resuming sessions is not supported by this agent',
})
return
}
try {
const sessionCwd = params.cwd || AGENT_CWD
const sessionId = params.sessionId
const result = await state.connection.unstable_resumeSession({
sessionId,
cwd: sessionCwd,
})
state.sessionId = sessionId
state.modelState = result.models ?? null
logSession.info({ sessionId, cwd: sessionCwd }, 'resumed')
send(ws, 'session_resumed', {
sessionId,
promptCapabilities: state.promptCapabilities,
models: state.modelState,
})
} catch (error) {
logSession.error({ error: (error as Error).message }, 'resume failed')
send(ws, 'error', {
message: `Failed to resume session: ${(error as Error).message}`,
})
}
}
// Reference: Zed's AcpThread.send() forwards Vec<acp::ContentBlock> to agent
async function handlePrompt(
ws: WSContext,
params: { content: ContentBlock[] },
): Promise<void> {
const state = clients.get(ws)
if (!state?.connection || !state.sessionId) {
send(ws, 'error', { message: 'No active session' })
return
}
try {
const firstText = params.content.find(b => b.type === 'text')?.text
const images = params.content.filter(b => b.type === 'image')
logPrompt.debug(
{
text: firstText?.slice(0, 100),
imageCount: images.length,
blockCount: params.content.length,
},
'sending',
)
const result = await state.connection.prompt({
sessionId: state.sessionId,
prompt: params.content as acp.ContentBlock[],
})
logPrompt.info({ stopReason: result.stopReason }, 'completed')
send(ws, 'prompt_complete', result)
} catch (error) {
logPrompt.error({ error: (error as Error).message }, 'failed')
send(ws, 'error', { message: `Prompt failed: ${(error as Error).message}` })
}
}
function handleDisconnect(ws: WSContext): void {
const state = clients.get(ws)
if (!state) return
if (state.process) {
state.process.kill()
state.process = null
}
state.connection = null
state.sessionId = null
send(ws, 'status', { connected: false })
}
// Handle cancel request from client
async function handleCancel(ws: WSContext): Promise<void> {
const state = clients.get(ws)
if (!state?.connection || !state.sessionId) {
logWs.warn('cancel requested but no active session')
return
}
logSession.info({ sessionId: state.sessionId }, 'cancel requested')
cancelPendingPermissions(state)
try {
await state.connection.cancel({ sessionId: state.sessionId })
logSession.info({ sessionId: state.sessionId }, 'cancel sent')
} catch (error) {
logSession.error({ error: (error as Error).message }, 'cancel failed')
}
}
// Reference: Zed's AgentModelSelector.select_model() calls connection.set_session_model()
async function handleSetSessionModel(
ws: WSContext,
params: { modelId: string },
): Promise<void> {
const state = clients.get(ws)
if (!state?.connection || !state.sessionId) {
send(ws, 'error', { message: 'No active session' })
return
}
if (!state.modelState) {
send(ws, 'error', {
message: 'Model selection not supported by this agent',
})
return
}
try {
logSession.info(
{ sessionId: state.sessionId, modelId: params.modelId },
'setting model',
)
await state.connection.unstable_setSessionModel({
sessionId: state.sessionId,
modelId: params.modelId,
})
state.modelState = { ...state.modelState, currentModelId: params.modelId }
send(ws, 'model_changed', { modelId: params.modelId })
logSession.info({ modelId: params.modelId }, 'model changed')
} catch (error) {
logSession.error({ error: (error as Error).message }, 'set model failed')
send(ws, 'error', {
message: `Failed to set model: ${(error as Error).message}`,
})
}
}
// ContentBlock type matching @agentclientprotocol/sdk
interface ContentBlock {
type: string
text?: string
data?: string
mimeType?: string
uri?: string
name?: string
}
type PermissionResponsePayload = {
requestId: string
outcome: { outcome: 'cancelled' } | { outcome: 'selected'; optionId: string }
}
type ProxyMessage =
| { type: 'connect' }
| { type: 'disconnect' }
| { type: 'new_session'; payload: { cwd?: string; permissionMode?: string } }
| { type: 'prompt'; payload: { content: ContentBlock[] } }
| { type: 'permission_response'; payload: PermissionResponsePayload }
| { type: 'cancel' }
| { type: 'set_session_model'; payload: { modelId: string } }
| { type: 'list_sessions'; payload: { cwd?: string; cursor?: string } }
| { type: 'load_session'; payload: { sessionId: string; cwd?: string } }
| { type: 'resume_session'; payload: { sessionId: string; cwd?: string } }
| { type: 'ping' }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
function optionalStringField(
payload: Record<string, unknown>,
key: string,
source: string,
): string | undefined {
if (!Object.hasOwn(payload, key)) return undefined
const value = payload[key]
if (typeof value === 'string') return value
throw new Error(`Invalid ${source}: expected a string`)
}
function payloadRecord(value: unknown, type: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new Error(`Invalid ${type} payload`)
}
return value
}
function optionalPayloadRecord(
value: unknown,
type: string,
): Record<string, unknown> {
if (value === undefined) return {}
return payloadRecord(value, type)
}
function optionalRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {}
}
function decodeContentBlocks(value: unknown): ContentBlock[] {
if (
!Array.isArray(value) ||
!value.every(block => isRecord(block) && typeof block.type === 'string')
) {
throw new Error('Invalid prompt payload')
}
return value as ContentBlock[]
}
function decodePermissionResponsePayload(
value: unknown,
): PermissionResponsePayload {
const payload = payloadRecord(value, 'permission_response')
if (typeof payload.requestId !== 'string' || !isRecord(payload.outcome)) {
throw new Error('Invalid permission_response payload')
}
if (payload.outcome.outcome === 'cancelled') {
return { requestId: payload.requestId, outcome: { outcome: 'cancelled' } }
}
if (
payload.outcome.outcome === 'selected' &&
typeof payload.outcome.optionId === 'string'
) {
return {
requestId: payload.requestId,
outcome: { outcome: 'selected', optionId: payload.outcome.optionId },
}
}
throw new Error('Invalid permission_response payload')
}
function decodeClientMessage(message: Record<string, unknown>): ProxyMessage {
if (typeof message.type !== 'string') {
throw new Error('Invalid WebSocket message payload')
}
switch (message.type) {
case 'connect':
case 'disconnect':
case 'cancel':
case 'ping':
return { type: message.type }
case 'new_session': {
const payload = optionalPayloadRecord(message.payload, 'new_session')
return {
type: 'new_session',
payload: {
cwd: optionalStringField(payload, 'cwd', 'new_session.cwd'),
permissionMode: optionalStringField(
payload,
'permissionMode',
'new_session.permissionMode',
),
},
}
}
case 'prompt': {
const payload = payloadRecord(message.payload, 'prompt')
return {
type: 'prompt',
payload: { content: decodeContentBlocks(payload.content) },
}
}
case 'permission_response':
return {
type: 'permission_response',
payload: decodePermissionResponsePayload(message.payload),
}
case 'set_session_model': {
const payload = payloadRecord(message.payload, 'set_session_model')
if (typeof payload.modelId !== 'string') {
throw new Error('Invalid set_session_model payload')
}
return {
type: 'set_session_model',
payload: { modelId: payload.modelId },
}
}
case 'list_sessions': {
const payload = optionalRecord(message.payload)
return {
type: 'list_sessions',
payload: {
cwd: optionalString(payload.cwd),
cursor: optionalString(payload.cursor),
},
}
}
case 'load_session':
case 'resume_session': {
const payload = payloadRecord(message.payload, message.type)
if (typeof payload.sessionId !== 'string') {
throw new Error(`Invalid ${message.type} payload`)
}
return {
type: message.type,
payload: {
sessionId: payload.sessionId,
cwd: optionalString(payload.cwd),
},
}
}
default:
throw new Error(`Unknown message type: ${message.type}`)
}
}
export function decodeClientWsMessage(data: unknown): ProxyMessage {
return decodeClientMessage(decodeJsonWsMessage(data))
}
async function dispatchClientMessage(
ws: WSContext,
data: ProxyMessage,
): Promise<void> {
switch (data.type) {
case 'connect':
await handleConnect(ws)
break
case 'disconnect':
handleDisconnect(ws)
break
case 'new_session':
await handleNewSession(ws, data.payload)
break
case 'prompt':
await handlePrompt(ws, data.payload)
break
case 'permission_response':
handlePermissionResponse(ws, data.payload)
break
case 'cancel':
await handleCancel(ws)
break
case 'set_session_model':
await handleSetSessionModel(ws, data.payload)
break
case 'list_sessions':
await handleListSessions(ws, data.payload)
break
case 'load_session':
await handleLoadSession(ws, data.payload)
break
case 'resume_session':
await handleResumeSession(ws, data.payload)
break
case 'ping':
send(ws, 'pong')
break
}
}
export const __testing = {
dispatchClientMessage(ws: WSContext, data: unknown): Promise<void> {
assertTestingInternalsEnabled()
return dispatchClientMessage(ws, data as ProxyMessage)
},
registerClient(
ws: WSContext,
state: {
connection?: unknown
process?: ChildProcess | null
sessionId?: string | null
},
): () => void {
assertTestingInternalsEnabled()
clients.set(ws, {
process: state.process ?? null,
connection: (state.connection ?? null) as acp.ClientSideConnection | null,
sessionId: state.sessionId ?? null,
pendingPermissions: new Map(),
agentCapabilities: null,
promptCapabilities: null,
modelState: null,
isAlive: true,
})
return () => {
clients.delete(ws)
}
},
getClientSessionId(ws: WSContext): string | null | undefined {
assertTestingInternalsEnabled()
return clients.get(ws)?.sessionId
},
setDefaultPermissionMode(mode: string | undefined): () => void {
assertTestingInternalsEnabled()
const previous = DEFAULT_PERMISSION_MODE
DEFAULT_PERMISSION_MODE = mode
return () => {
DEFAULT_PERMISSION_MODE = previous
}
},
}
function assertTestingInternalsEnabled(): void {
if (process.env.ACP_LINK_TEST_INTERNALS === '1') {
return
}
throw new Error(
'acp-link test internals are disabled outside test execution.',
)
}
const ACP_LINK_PERMISSION_MODE_ALIASES = {
auto: 'auto',
default: 'default',
acceptedits: 'acceptEdits',
dontask: 'dontAsk',
plan: 'plan',
bypasspermissions: 'bypassPermissions',
bypass: 'bypassPermissions',
} as const
type AcpLinkPermissionMode =
(typeof ACP_LINK_PERMISSION_MODE_ALIASES)[keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES]
export function resolveNewSessionPermissionMode(
requestedMode: string | undefined,
defaultMode: string | undefined,
): string | undefined {
const requested = resolveAcpLinkPermissionMode(requestedMode)
const localDefault = resolveAcpLinkPermissionMode(defaultMode)
if (!requested) {
return localDefault
}
if (requested !== 'bypassPermissions') {
return requested
}
if (localDefault === 'bypassPermissions') {
return 'bypassPermissions'
}
throw new Error(
'bypassPermissions requires local ACP_PERMISSION_MODE=bypassPermissions before a client can request it.',
)
}
function resolveAcpLinkPermissionMode(
mode: string | undefined,
): AcpLinkPermissionMode | undefined {
if (mode === undefined) return undefined
const normalized = mode?.trim().toLowerCase()
if (!normalized) {
throw new Error('Invalid permissionMode: expected a non-empty string.')
}
const resolved =
ACP_LINK_PERMISSION_MODE_ALIASES[
normalized as keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES
]
if (!resolved) {
throw new Error(`Invalid permissionMode: ${mode}.`)
}
return resolved
}
function buildAgentEnv(): NodeJS.ProcessEnv {
if (!DEFAULT_PERMISSION_MODE) {
return process.env
}
return {
...process.env,
ACP_PERMISSION_MODE: DEFAULT_PERMISSION_MODE,
}
}
export async function startServer(config: ServerConfig): Promise<void> {
const { port, host, command, args, cwd, token, https } = config
// Set module-level config
AGENT_COMMAND = command
AGENT_ARGS = args
AGENT_CWD = cwd
SERVER_PORT = port
SERVER_HOST = host
AUTH_TOKEN = token
DEFAULT_PERMISSION_MODE =
config.permissionMode || process.env.ACP_PERMISSION_MODE
// Initialize RCS upstream client if configured
const rcsUrl = process.env.ACP_RCS_URL
const rcsToken = process.env.ACP_RCS_TOKEN
const rcsGroup = config.group || process.env.ACP_RCS_GROUP
if (rcsGroup && !/^[a-zA-Z0-9_-]+$/.test(rcsGroup)) {
throw new Error(
`Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`,
)
}
if (rcsUrl) {
rcsUpstream = new RcsUpstreamClient({
rcsUrl,
apiToken: rcsToken || '',
agentName: command,
channelGroupId: rcsGroup || undefined,
maxSessions: 1,
})
const relayWs = createRelayWs()
const relayState: ClientState = {
process: null,
connection: null,
sessionId: null,
pendingPermissions: new Map(),
agentCapabilities: null,
promptCapabilities: null,
modelState: null,
isAlive: true,
}
clients.set(relayWs, relayState)
rcsUpstream.setMessageHandler(async msg => {
try {
const data = decodeClientMessage(msg)
logRelay.debug({ type: data.type }, 'processing')
await dispatchClientMessage(relayWs, data)
} catch (error) {
logRelay.error({ error: (error as Error).message }, 'handler error')
}
})
rcsUpstream.connect().catch(err => {
logRelay.warn(
{ error: (err as Error).message },
'initial connection failed',
)
})
logRelay.info({ url: rcsUrl }, 'upstream enabled')
}
const app = new Hono()
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
// Health check endpoint
app.get('/health', c => {
return c.json({ status: 'ok' })
})
// WebSocket endpoint with token validation
app.get(
'/ws',
upgradeWebSocket(c => {
if (AUTH_TOKEN) {
const providedToken = extractWebSocketAuthToken({
authorization: c.req.header('Authorization'),
protocol: c.req.header('Sec-WebSocket-Protocol'),
})
if (!authTokensEqual(providedToken, AUTH_TOKEN)) {
logWs.warn('connection rejected: invalid token')
return {
onOpen(_event, ws) {
ws.close(4001, 'Unauthorized: Invalid token')
},
onMessage() {},
onClose() {},
}
}
}
return {
onOpen(_event, ws) {
logWs.info('client connected')
const state: ClientState = {
process: null,
connection: null,
sessionId: null,
pendingPermissions: new Map(),
agentCapabilities: null,
promptCapabilities: null,
modelState: null,
isAlive: true,
}
clients.set(ws, state)
const rawWs = ws.raw as RawWebSocket
rawWs.on('pong', () => {
state.isAlive = true
})
},
async onMessage(event, ws) {
try {
const data = decodeClientWsMessage(event.data)
logWs.debug({ type: data.type }, 'received')
await dispatchClientMessage(ws, data)
} catch (error) {
if (error instanceof WsPayloadTooLargeError) {
logWs.warn({ error: error.message }, 'message too large')
ws.close(1009, 'message too large')
return
}
logWs.error({ error: (error as Error).message }, 'message error')
send(ws, 'error', { message: `Error: ${(error as Error).message}` })
}
},
onClose(_event, ws) {
logWs.info('client disconnected')
const state = clients.get(ws)
if (state) {
cancelPendingPermissions(state)
}
handleDisconnect(ws)
clients.delete(ws)
},
}
}),
)
// Create server with optional HTTPS
let server
if (https) {
const tlsOptions = await getOrCreateCertificate()
server = serve({
fetch: app.fetch,
port,
hostname: host,
createServer: createHttpsServer,
serverOptions: tlsOptions,
})
} else {
server = serve({ fetch: app.fetch, port, hostname: host })
}
injectWebSocket(server)
// Heartbeat: periodically ping all connected clients
setInterval(() => {
for (const [ws, state] of clients) {
// Skip virtual relay connections (no raw socket, always alive)
if (!ws.raw && state.isAlive) continue
if (!ws.raw) {
// Connection already closed, clean up
clients.delete(ws)
continue
}
if (!state.isAlive) {
logWs.info('heartbeat timeout, terminating')
;(ws.raw as RawWebSocket).terminate()
continue
}
state.isAlive = false
;(ws.raw as RawWebSocket).ping()
}
}, HEARTBEAT_INTERVAL_MS)
// Protocol strings based on HTTPS mode
const wsProtocol = https ? 'wss' : 'ws'
// Get actual LAN IP when binding to 0.0.0.0
let displayHost = host
if (host === '0.0.0.0') {
const lanIPs = getLanIPs()
displayHost = lanIPs[0] || 'localhost'
}
// Build URLs
const localWsUrl = `${wsProtocol}://localhost:${port}/ws`
const networkWsUrl = `${wsProtocol}://${displayHost}:${port}/ws`
// Print startup banner
console.log()
console.log(` 🚀 ACP Proxy Server${https ? ' (HTTPS)' : ''}`)
console.log()
console.log(` Connection:`)
if (host === '0.0.0.0') {
console.log(` URL: ${networkWsUrl}`)
} else {
console.log(` URL: ${localWsUrl}`)
}
if (AUTH_TOKEN) {
console.log(` Token: configured`)
}
console.log()
if (!AUTH_TOKEN) {
console.log(` ⚠️ Authentication disabled (--no-auth)`)
console.log()
}
const agentDisplay =
AGENT_ARGS.length > 0
? `${AGENT_COMMAND} ${AGENT_ARGS.join(' ')}`
: AGENT_COMMAND
console.log(` 📦 Agent: ${agentDisplay}`)
console.log(` CWD: ${AGENT_CWD}`)
console.log()
console.log(` Press Ctrl+C to stop`)
console.log()
logServer.info(
{
port,
host,
https,
wsEndpoint: `${wsProtocol}://${displayHost}:${port}/ws`,
agent: AGENT_COMMAND,
agentArgs: AGENT_ARGS,
cwd: AGENT_CWD,
authEnabled: !!AUTH_TOKEN,
},
'started',
)
// Graceful shutdown — close RCS upstream
const shutdown = async () => {
if (rcsUpstream) {
await rcsUpstream.close()
}
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
// Keep the server running
await new Promise(() => {})
}