Files
claude-code/packages/remote-control-server/src/__tests__/routes.test.ts
2026-05-01 21:39:30 +08:00

2226 lines
68 KiB
TypeScript

import { describe, test, expect, beforeEach, mock } from 'bun:test'
// Mock config
const mockConfig = {
port: 3000,
host: '0.0.0.0',
apiKeys: ['test-api-key'],
baseUrl: 'http://localhost:3000',
pollTimeout: 1,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
}
mock.module('../config', () => ({
config: mockConfig,
getBaseUrl: () => 'http://localhost:3000',
}))
import { Hono } from 'hono'
import {
storeReset,
storeCreateSession,
storeCreateEnvironment,
storeBindSession,
} from '../store'
import {
removeEventBus,
getAllEventBuses,
getEventBus,
} from '../transport/event-bus'
import { issueToken } from '../auth/token'
import { publishSessionEvent } from '../services/transport'
import { encodeWebSocketAuthProtocol } from '../auth/middleware'
// Import route modules
import v1Sessions from '../routes/v1/sessions'
import v1Environments from '../routes/v1/environments'
import v1EnvironmentsWork from '../routes/v1/environments.work'
import v1SessionIngress, {
decodeSessionIngressWsMessage,
handleSessionIngressWsPayload,
websocket as sessionIngressWebsocket,
} from '../routes/v1/session-ingress'
import {
decodeAcpWsMessageData,
hasAcpRelayAuth,
handleAcpWsPayload,
} from '../routes/acp'
import acpRoutes from '../routes/acp'
import v2CodeSessions from '../routes/v2/code-sessions'
import v2Worker from '../routes/v2/worker'
import v2WorkerEventsStream from '../routes/v2/worker-events-stream'
import v2WorkerEvents from '../routes/v2/worker-events'
import webAuth from '../routes/web/auth'
import webSessions from '../routes/web/sessions'
import webControl from '../routes/web/control'
import webEnvironments from '../routes/web/environments'
function createApp() {
const app = new Hono()
app.route('/v1/sessions', v1Sessions)
app.route('/v1/environments', v1Environments)
app.route('/v1/environments', v1EnvironmentsWork)
app.route('/v2/session_ingress', v1SessionIngress)
app.route('/v1/code/sessions', v2CodeSessions)
app.route('/v1/code/sessions', v2Worker)
app.route('/v1/code/sessions', v2WorkerEventsStream)
app.route('/v1/code/sessions', v2WorkerEvents)
app.route('/web', webAuth)
app.route('/web', webSessions)
app.route('/web', webControl)
app.route('/web', webEnvironments)
app.route('/acp', acpRoutes)
return app
}
const AUTH_HEADERS = {
Authorization: 'Bearer test-api-key',
'X-Username': 'testuser',
}
function toWebSessionId(sessionId: string): string {
if (!sessionId.startsWith('cse_')) return sessionId
return `session_${sessionId.slice('cse_'.length)}`
}
describe('V1 Session Routes', () => {
let app: Hono
beforeEach(() => {
storeReset()
for (const [key] of getAllEventBuses()) {
removeEventBus(key)
}
app = createApp()
})
test('POST /v1/sessions — creates a session', async () => {
const res = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Test Session' }),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.id).toMatch(/^session_/)
expect(body.title).toBe('Test Session')
expect(body.status).toBe('idle')
})
test('POST /v1/sessions — requires auth', async () => {
const res = await app.request('/v1/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(401)
})
test('GET /v1/sessions/:id — returns created session', async () => {
const createRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const getRes = await app.request(`/v1/sessions/${id}`, {
headers: AUTH_HEADERS,
})
expect(getRes.status).toBe(200)
const body = await getRes.json()
expect(body.id).toBe(id)
})
test('GET /v1/sessions/:id — 404 for unknown session', async () => {
const res = await app.request('/v1/sessions/nope', {
headers: AUTH_HEADERS,
})
expect(res.status).toBe(404)
})
test('GET /v1/sessions/:id — resolves compat code session IDs', async () => {
const createRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const {
session: { id },
} = await createRes.json()
const getRes = await app.request(`/v1/sessions/${toWebSessionId(id)}`, {
headers: AUTH_HEADERS,
})
expect(getRes.status).toBe(200)
const body = await getRes.json()
expect(body.id).toBe(id)
})
test('PATCH /v1/sessions/:id — updates title', async () => {
const createRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const patchRes = await app.request(`/v1/sessions/${id}`, {
method: 'PATCH',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Updated Title' }),
})
expect(patchRes.status).toBe(200)
const body = await patchRes.json()
expect(body.title).toBe('Updated Title')
})
test('POST /v1/sessions/:id/archive — archives session', async () => {
const createRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const archiveRes = await app.request(`/v1/sessions/${id}/archive`, {
method: 'POST',
headers: AUTH_HEADERS,
})
expect(archiveRes.status).toBe(200)
})
test('POST /v1/sessions/:id/archive — archives compat code session IDs', async () => {
const createRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const {
session: { id },
} = await createRes.json()
const compatId = toWebSessionId(id)
const archiveRes = await app.request(`/v1/sessions/${compatId}/archive`, {
method: 'POST',
headers: AUTH_HEADERS,
})
expect(archiveRes.status).toBe(200)
const getRes = await app.request(`/v1/sessions/${compatId}`, {
headers: AUTH_HEADERS,
})
expect(getRes.status).toBe(200)
const body = await getRes.json()
expect(body.id).toBe(id)
expect(body.status).toBe('archived')
})
test('POST /v1/sessions/:id/events — publishes events', async () => {
const createRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const eventsRes = await app.request(`/v1/sessions/${id}/events`, {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ events: [{ type: 'user', content: 'hello' }] }),
})
expect(eventsRes.status).toBe(200)
const body = await eventsRes.json()
expect(body.events).toBe(1)
})
test('POST /v1/sessions/:id/events — resolves compat code session IDs', async () => {
const createRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const {
session: { id },
} = await createRes.json()
const compatId = toWebSessionId(id)
const eventsRes = await app.request(`/v1/sessions/${compatId}/events`, {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({
events: [{ type: 'user', content: 'hello from compat' }],
}),
})
expect(eventsRes.status).toBe(200)
const events = getEventBus(id).getEventsSince(0)
expect(events).toHaveLength(1)
expect(events[0]?.type).toBe('user')
expect((events[0]?.payload as { content?: string }).content).toBe(
'hello from compat',
)
})
test('POST /v1/sessions with environment_id creates work item', async () => {
// First register an environment
const envRes = await app.request('/v1/environments/bridge', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ machine_name: 'test' }),
})
const { environment_id } = await envRes.json()
const sessRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ environment_id }),
})
expect(sessRes.status).toBe(200)
const body = await sessRes.json()
expect(body.environment_id).toBe(environment_id)
})
test('POST /v1/sessions with invalid environment_id — session created, work item fails silently', async () => {
const sessRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ environment_id: 'env_nonexistent' }),
})
expect(sessRes.status).toBe(200)
const body = await sessRes.json()
expect(body.id).toMatch(/^session_/)
})
test('POST /v1/sessions with events — publishes initial events', async () => {
const sessRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ events: [{ type: 'init', data: 'starting' }] }),
})
expect(sessRes.status).toBe(200)
})
})
describe('V1 Environment Routes', () => {
let app: Hono
beforeEach(() => {
storeReset()
app = createApp()
})
test('POST /v1/environments/bridge — registers environment', async () => {
const res = await app.request('/v1/environments/bridge', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ machine_name: 'mac1', directory: '/home' }),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.environment_id).toMatch(/^env_/)
expect(body.status).toBe('active')
})
test('DELETE /v1/environments/bridge/:id — deregisters environment', async () => {
const envRes = await app.request('/v1/environments/bridge', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { environment_id } = await envRes.json()
const delRes = await app.request(
`/v1/environments/bridge/${environment_id}`,
{
method: 'DELETE',
headers: AUTH_HEADERS,
},
)
expect(delRes.status).toBe(200)
})
test('POST /v1/environments/:id/bridge/reconnect — reconnects environment', async () => {
const envRes = await app.request('/v1/environments/bridge', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { environment_id } = await envRes.json()
const reconnectRes = await app.request(
`/v1/environments/${environment_id}/bridge/reconnect`,
{
method: 'POST',
headers: AUTH_HEADERS,
},
)
expect(reconnectRes.status).toBe(200)
})
})
describe('V1 Work Routes', () => {
let app: Hono
let envId: string
beforeEach(async () => {
storeReset()
app = createApp()
const envRes = await app.request('/v1/environments/bridge', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
envId = (await envRes.json()).environment_id
})
test('GET /v1/environments/:id/work/poll — returns 204 when no work', async () => {
const res = await app.request(`/v1/environments/${envId}/work/poll`, {
headers: AUTH_HEADERS,
})
expect(res.status).toBe(204)
})
test('work lifecycle: create → poll → ack → stop', async () => {
// Create session with environment (creates work item)
const sessRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ environment_id: envId }),
})
const sessionId = (await sessRes.json()).id
// Poll for work
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
headers: AUTH_HEADERS,
})
expect(pollRes.status).toBe(200)
const work = await pollRes.json()
expect(work.id).toMatch(/^work_/)
expect(work.data.id).toBe(sessionId)
// Ack work
const ackRes = await app.request(
`/v1/environments/${envId}/work/${work.id}/ack`,
{
method: 'POST',
headers: AUTH_HEADERS,
},
)
expect(ackRes.status).toBe(200)
// Stop work
const stopRes = await app.request(
`/v1/environments/${envId}/work/${work.id}/stop`,
{
method: 'POST',
headers: AUTH_HEADERS,
},
)
expect(stopRes.status).toBe(200)
})
test('POST work heartbeat', async () => {
// Create session + work
await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ environment_id: envId }),
})
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
headers: AUTH_HEADERS,
})
const work = await pollRes.json()
const hbRes = await app.request(
`/v1/environments/${envId}/work/${work.id}/heartbeat`,
{
method: 'POST',
headers: AUTH_HEADERS,
},
)
expect(hbRes.status).toBe(200)
const body = await hbRes.json()
expect(body.lease_extended).toBe(true)
})
})
describe('V2 Code Session Routes', () => {
let app: Hono
beforeEach(() => {
storeReset()
process.env.RCS_API_KEYS = 'test-api-key'
app = createApp()
})
test('POST /v1/code/sessions — creates code session', async () => {
const res = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Code Session' }),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.session.id).toMatch(/^cse_/)
expect(body.session.title).toBe('Code Session')
})
test('POST /v1/code/sessions/:id/bridge — returns bridge info with JWT', async () => {
// Create code session
const createRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = (await createRes.json()).session
const bridgeRes = await app.request(`/v1/code/sessions/${id}/bridge`, {
method: 'POST',
headers: AUTH_HEADERS,
})
expect(bridgeRes.status).toBe(200)
const body = await bridgeRes.json()
expect(body.api_base_url).toBe('http://localhost:3000')
expect(body.worker_epoch).toBe(1)
expect(body.worker_jwt).toBeTruthy()
expect(body.expires_in).toBe(3600)
})
test('POST /v1/code/sessions/:id/bridge — 404 for unknown session', async () => {
const res = await app.request('/v1/code/sessions/nope/bridge', {
method: 'POST',
headers: AUTH_HEADERS,
})
expect(res.status).toBe(404)
})
})
describe('V2 Worker Routes', () => {
let app: Hono
beforeEach(() => {
storeReset()
process.env.RCS_API_KEYS = 'test-api-key'
app = createApp()
})
test('POST /v1/code/sessions/:id/worker/register — increments epoch', async () => {
// Create session
const createRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const regRes = await app.request(
`/v1/code/sessions/${id}/worker/register`,
{
method: 'POST',
headers: AUTH_HEADERS,
},
)
expect(regRes.status).toBe(200)
const body = await regRes.json()
expect(body.worker_epoch).toBe(1)
})
test('POST /v1/code/sessions/:id/worker/register — 404 for unknown', async () => {
const res = await app.request('/v1/code/sessions/nope/worker/register', {
method: 'POST',
headers: AUTH_HEADERS,
})
expect(res.status).toBe(404)
})
})
describe('Web Auth Routes', () => {
let app: Hono
beforeEach(() => {
storeReset()
app = createApp()
})
test('POST /web/bind — binds session to UUID', async () => {
// Create session first
const sessRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await sessRes.json()
const bindRes = await app.request('/web/bind?uuid=test-uuid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: id }),
})
expect(bindRes.status).toBe(200)
const body = await bindRes.json()
expect(body.ok).toBe(true)
})
test('POST /web/bind — binds compat code session ID to UUID', async () => {
const sessRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const body = await sessRes.json()
const compatId = toWebSessionId(body.session.id)
const bindRes = await app.request('/web/bind?uuid=test-uuid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: compatId }),
})
expect(bindRes.status).toBe(200)
const bindBody = await bindRes.json()
expect(bindBody.ok).toBe(true)
expect(bindBody.sessionId).toBe(compatId)
})
test('POST /web/bind — 404 for unknown session', async () => {
const res = await app.request('/web/bind?uuid=test-uuid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: 'nope' }),
})
expect(res.status).toBe(404)
})
test('POST /web/bind — 400 when missing params', async () => {
const res = await app.request('/web/bind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
})
})
describe('Web Session Routes', () => {
let app: Hono
beforeEach(() => {
storeReset()
for (const [key] of getAllEventBuses()) {
removeEventBus(key)
}
app = createApp()
})
test('POST /web/sessions — creates and auto-binds session', async () => {
const res = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Web Session' }),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.id).toMatch(/^session_/)
expect(body.source).toBe('web')
})
test('GET /web/sessions — returns sessions owned by UUID', async () => {
// Create and bind
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const listRes = await app.request('/web/sessions?uuid=user-1')
expect(listRes.status).toBe(200)
const sessions = await listRes.json()
expect(sessions).toHaveLength(1)
expect(sessions[0].id).toBe(id)
})
test('GET /web/sessions and /all — serialize owned code sessions as compat IDs', async () => {
const codeSession = storeCreateSession({ idPrefix: 'cse_' })
storeBindSession(codeSession.id, 'user-1')
const compatId = toWebSessionId(codeSession.id)
const listRes = await app.request('/web/sessions?uuid=user-1')
expect(listRes.status).toBe(200)
const sessions = await listRes.json()
expect(sessions).toHaveLength(1)
expect(sessions[0].id).toBe(compatId)
const allRes = await app.request('/web/sessions/all?uuid=user-1')
expect(allRes.status).toBe(200)
const summaries = await allRes.json()
expect(summaries).toHaveLength(1)
expect(summaries[0].id).toBe(compatId)
})
test('GET /web/sessions — requires UUID', async () => {
const res = await app.request('/web/sessions')
expect(res.status).toBe(401)
})
test('GET /web/sessions/all — lists only sessions owned by requesting UUID', async () => {
// Create 2 sessions via different users
await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
await app.request('/web/sessions?uuid=user-2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const allRes = await app.request('/web/sessions/all?uuid=user-1')
expect(allRes.status).toBe(200)
const sessions = await allRes.json()
expect(sessions).toHaveLength(1) // only user-1's session, not user-2's
})
test('GET /web/sessions and /all — hides archived and inactive sessions', async () => {
const archived = storeCreateSession({})
const inactive = storeCreateSession({})
const open = storeCreateSession({})
storeBindSession(archived.id, 'user-1')
storeBindSession(inactive.id, 'user-1')
storeBindSession(open.id, 'user-1')
await app.request(`/v1/sessions/${archived.id}/archive`, {
method: 'POST',
headers: AUTH_HEADERS,
})
const { storeUpdateSession } = await import('../store')
storeUpdateSession(inactive.id, { status: 'inactive' })
const listRes = await app.request('/web/sessions?uuid=user-1')
expect(listRes.status).toBe(200)
const sessions = await listRes.json()
expect(sessions.map((session: { id: string }) => session.id)).toEqual([
open.id,
])
const allRes = await app.request('/web/sessions/all?uuid=user-1')
expect(allRes.status).toBe(200)
const summaries = await allRes.json()
expect(summaries.map((session: { id: string }) => session.id)).toEqual([
open.id,
])
})
test('GET /web/sessions/:id — returns owned session', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`)
expect(getRes.status).toBe(200)
})
test('GET /web/sessions/:id — includes automation_state snapshot when worker metadata has it', async () => {
const createRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const {
session: { id },
} = await createRes.json()
storeBindSession(id, 'user-1')
await app.request(`/v1/code/sessions/${id}/worker`, {
method: 'PUT',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({
worker_epoch: 1,
external_metadata: {
automation_state: {
enabled: true,
phase: 'standby',
next_tick_at: 123456,
sleep_until: null,
},
},
}),
})
const getRes = await app.request(
`/web/sessions/${toWebSessionId(id)}?uuid=user-1`,
)
expect(getRes.status).toBe(200)
const body = await getRes.json()
expect(body.automation_state).toEqual({
enabled: true,
phase: 'standby',
next_tick_at: 123456,
sleep_until: null,
})
})
test('GET /web/sessions/:id — 403 for non-owner', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const getRes = await app.request(`/web/sessions/${id}?uuid=user-2`)
expect(getRes.status).toBe(403)
})
test('GET /web/sessions/:id/history — returns events', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`)
expect(histRes.status).toBe(200)
const body = await histRes.json()
expect(body.events).toEqual([])
})
test('GET /web/sessions/:id/history — returns task_state snapshots', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
publishSessionEvent(
id,
'task_state',
{
task_list_id: 'team-alpha',
tasks: [{ id: '1', subject: 'Investigate', status: 'pending' }],
},
'inbound',
)
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`)
expect(histRes.status).toBe(200)
const body = await histRes.json()
expect(body.events).toHaveLength(1)
expect(body.events[0]?.type).toBe('task_state')
expect(body.events[0]?.payload.task_list_id).toBe('team-alpha')
expect(body.events[0]?.payload.tasks).toEqual([
{ id: '1', subject: 'Investigate', status: 'pending' },
])
})
test('GET /web/sessions/:id and history — supports compat code session IDs', async () => {
const codeSession = storeCreateSession({ idPrefix: 'cse_' })
storeBindSession(codeSession.id, 'user-1')
const compatId = toWebSessionId(codeSession.id)
const getRes = await app.request(`/web/sessions/${compatId}?uuid=user-1`)
expect(getRes.status).toBe(200)
const session = await getRes.json()
expect(session.id).toBe(compatId)
const histRes = await app.request(
`/web/sessions/${compatId}/history?uuid=user-1`,
)
expect(histRes.status).toBe(200)
const history = await histRes.json()
expect(history.events).toEqual([])
})
test('GET /web/sessions/:id/history — 403 for non-owner', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-2`)
expect(histRes.status).toBe(403)
})
test('GET /web/sessions/:id — 404 after session deleted', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
// Archive/delete the session via v1
await app.request(`/v1/sessions/${id}/archive`, {
method: 'POST',
headers: AUTH_HEADERS,
})
// Session still exists (archived), so we can still get it
const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`)
// After archive, session status is "archived" but still exists
expect(getRes.status).toBe(200)
})
test('GET /web/sessions/:id/history — 404 for non-existent session', async () => {
// Bind to a non-existent session won't work, but if ownership was set
// and session deleted, we need to test the 404 path
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
// Delete the session from store directly
const { storeDeleteSession } = await import('../store')
storeDeleteSession(id)
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`)
expect(histRes.status).toBe(404)
})
test('POST /web/sessions with invalid environment_id — handles work item error', async () => {
const res = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment_id: 'env_nonexistent' }),
})
// Session is still created even if work item fails
expect(res.status).toBe(200)
const body = await res.json()
expect(body.id).toMatch(/^session_/)
})
test('GET /web/sessions/:id/events — returns SSE stream', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const eventsRes = await app.request(
`/web/sessions/${id}/events?uuid=user-1`,
)
expect(eventsRes.status).toBe(200)
expect(eventsRes.headers.get('Content-Type')).toBe('text/event-stream')
// Read initial keepalive and cancel
const reader = eventsRes.body?.getReader()
if (reader) {
const { value } = await reader.read()
const text = new TextDecoder().decode(value!)
expect(text).toContain(': keepalive')
reader.cancel()
}
})
test('GET /web/sessions/:id/events — supports compat code session IDs', async () => {
const codeSession = storeCreateSession({ idPrefix: 'cse_' })
storeBindSession(codeSession.id, 'user-1')
const compatId = toWebSessionId(codeSession.id)
const eventsRes = await app.request(
`/web/sessions/${compatId}/events?uuid=user-1`,
)
expect(eventsRes.status).toBe(200)
expect(eventsRes.headers.get('Content-Type')).toBe('text/event-stream')
const reader = eventsRes.body?.getReader()
if (reader) {
const { value } = await reader.read()
const text = new TextDecoder().decode(value!)
expect(text).toContain(': keepalive')
reader.cancel()
}
})
test('GET /web/sessions/:id/events — 403 for non-owner', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const eventsRes = await app.request(
`/web/sessions/${id}/events?uuid=user-2`,
)
expect(eventsRes.status).toBe(403)
})
test('GET /web/sessions/:id/events — 409 for archived session', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
await app.request(`/v1/sessions/${id}/archive`, {
method: 'POST',
headers: AUTH_HEADERS,
})
const res = await app.request(`/web/sessions/${id}/events?uuid=user-1`)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.type).toBe('session_closed')
})
})
describe('Web Control Routes', () => {
let app: Hono
let sessionId: string
beforeEach(async () => {
storeReset()
for (const [key] of getAllEventBuses()) {
removeEventBus(key)
}
app = createApp()
// Create and bind session
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
sessionId = (await createRes.json()).id
})
test('POST /web/sessions/:id/events — sends user message', async () => {
const res = await app.request(
`/web/sessions/${sessionId}/events?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'user', content: 'hello' }),
},
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.status).toBe('ok')
expect(body.event).toBeTruthy()
})
test('POST /web/sessions/:id/events/control/interrupt — supports compat code session IDs', async () => {
const rawSessionId = storeCreateSession({ idPrefix: 'cse_' }).id
storeBindSession(rawSessionId, 'user-1')
const compatId = toWebSessionId(rawSessionId)
const eventsRes = await app.request(
`/web/sessions/${compatId}/events?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'user', content: 'hello' }),
},
)
expect(eventsRes.status).toBe(200)
const controlRes = await app.request(
`/web/sessions/${compatId}/control?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'permission_response',
approved: true,
request_id: 'r1',
}),
},
)
expect(controlRes.status).toBe(200)
const interruptRes = await app.request(
`/web/sessions/${compatId}/interrupt?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
)
expect(interruptRes.status).toBe(200)
})
test('POST /web/sessions/:id/events — 403 for non-owner', async () => {
const res = await app.request(
`/web/sessions/${sessionId}/events?uuid=user-2`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'user', content: 'hello' }),
},
)
expect(res.status).toBe(403)
})
test('POST /web/sessions/:id/control — sends control request', async () => {
const res = await app.request(
`/web/sessions/${sessionId}/control?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'permission_response',
approved: true,
request_id: 'r1',
}),
},
)
expect(res.status).toBe(200)
})
test('POST /web/sessions/:id/interrupt — interrupts session', async () => {
const res = await app.request(
`/web/sessions/${sessionId}/interrupt?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
)
expect(res.status).toBe(200)
})
test('POST /web/sessions/:id/interrupt — 403 for non-owner', async () => {
const res = await app.request(
`/web/sessions/${sessionId}/interrupt?uuid=user-2`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
)
expect(res.status).toBe(403)
})
test('POST /web/sessions/:id/control — 403 for non-owner', async () => {
const res = await app.request(
`/web/sessions/${sessionId}/control?uuid=user-2`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'permission_response', approved: true }),
},
)
expect(res.status).toBe(403)
})
test('POST /web/sessions/:id/events — 403 for non-existent session with no ownership', async () => {
const res = await app.request(
'/web/sessions/nonexistent/events?uuid=user-1',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'user', content: 'hello' }),
},
)
expect(res.status).toBe(403)
})
test('POST /web/sessions/:id/events/control/interrupt — 409 for archived session', async () => {
await app.request(`/v1/sessions/${sessionId}/archive`, {
method: 'POST',
headers: AUTH_HEADERS,
})
const eventsRes = await app.request(
`/web/sessions/${sessionId}/events?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'user', content: 'hello' }),
},
)
expect(eventsRes.status).toBe(409)
const controlRes = await app.request(
`/web/sessions/${sessionId}/control?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'permission_response',
approved: true,
request_id: 'r1',
}),
},
)
expect(controlRes.status).toBe(409)
const interruptRes = await app.request(
`/web/sessions/${sessionId}/interrupt?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
)
expect(interruptRes.status).toBe(409)
})
})
describe('Web Environment Routes', () => {
let app: Hono
beforeEach(() => {
storeReset()
app = createApp()
})
test('GET /web/environments — lists active environments', async () => {
// Register an env via v1
await app.request('/v1/environments/bridge', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ machine_name: 'mac1' }),
})
const res = await app.request('/web/environments?uuid=user-1')
expect(res.status).toBe(200)
const envs = await res.json()
expect(envs).toHaveLength(1)
expect(envs[0].machine_name).toBe('mac1')
})
test('GET /web/environments — requires UUID', async () => {
const res = await app.request('/web/environments')
expect(res.status).toBe(401)
})
})
describe('V1 Session Ingress Routes (HTTP)', () => {
let app: Hono
beforeEach(() => {
storeReset()
for (const [key] of getAllEventBuses()) {
removeEventBus(key)
}
process.env.RCS_API_KEYS = 'test-api-key'
app = createApp()
})
test('POST /v2/session_ingress/session/:sessionId/events — ingests events with API key', async () => {
// Create session first
const sessRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await sessRes.json()
const res = await app.request(`/v2/session_ingress/session/${id}/events`, {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({
events: [{ type: 'assistant', content: 'response' }],
}),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.status).toBe('ok')
})
test('POST /v2/session_ingress/session/:sessionId/events — rejects without auth', async () => {
const res = await app.request('/v2/session_ingress/session/nope/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events: [] }),
})
expect(res.status).toBe(401)
})
test('POST /v2/session_ingress/session/:sessionId/events — 404 for unknown session', async () => {
const res = await app.request('/v2/session_ingress/session/nope/events', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ events: [{ type: 'user', content: 'hi' }] }),
})
expect(res.status).toBe(404)
})
test('POST /v2/session_ingress/session/:sessionId/events — resolves compat code session IDs', async () => {
const sessRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const {
session: { id },
} = await sessRes.json()
const compatId = toWebSessionId(id)
const res = await app.request(
`/v2/session_ingress/session/${compatId}/events`,
{
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({
events: [
{
type: 'assistant',
message: { role: 'assistant', content: 'compat ok' },
},
],
}),
},
)
expect(res.status).toBe(200)
const events = getEventBus(id).getEventsSince(0)
expect(events).toHaveLength(1)
expect(events[0]?.type).toBe('assistant')
})
test('GET /v2/session_ingress/ws/:sessionId — accepts small payload into handler', async () => {
const sessRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await sessRes.json()
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
})
try {
const event = await new Promise((resolve, reject) => {
let ws: WebSocket | undefined
const timeout = setTimeout(() => {
ws?.close()
reject(new Error('Timed out waiting for inbound WebSocket payload'))
}, 2000)
const bus = getEventBus(id)
const unsub = bus.subscribe(sessionEvent => {
if (
sessionEvent.direction === 'inbound' &&
sessionEvent.type === 'user'
) {
clearTimeout(timeout)
unsub()
ws?.close()
resolve(sessionEvent)
}
})
ws = new WebSocket(
`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${id}`,
[encodeWebSocketAuthProtocol('test-api-key')],
)
ws.onopen = () => {
ws.send(
JSON.stringify({
type: 'user',
message: { role: 'user', content: 'hello' },
}) + '\n',
)
}
ws.onerror = () => {
clearTimeout(timeout)
unsub()
reject(new Error('Session ingress WebSocket connection failed'))
}
})
expect((event as { type?: string }).type).toBe('user')
} finally {
await server.stop(true)
}
})
test('GET /v2/session_ingress/ws/:sessionId — closes 11MB payload with 1009', () => {
const close = mock(() => {})
const handled = handleSessionIngressWsPayload(
{ close } as any,
'session_large',
'x'.repeat(11 * 1024 * 1024),
)
expect(handled).toBe(false)
expect(close).toHaveBeenCalledWith(1009, 'message too large')
})
test('session ingress decode rejects unsupported payload types', () => {
const close = mock(() => {})
const handled = handleSessionIngressWsPayload(
{ close } as any,
'session_bad',
{ data: 'bad' },
)
expect(decodeSessionIngressWsMessage({ data: 'bad' }).ok).toBe(false)
expect(handled).toBe(false)
expect(close).toHaveBeenCalledWith(1003, 'unsupported message payload')
})
test('GET /v2/session_ingress/ws/:sessionId — resolves compat code session IDs', async () => {
const sessRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const {
session: { id },
} = await sessRes.json()
const compatId = toWebSessionId(id)
publishSessionEvent(id, 'user', { content: 'compat ws replay' }, 'outbound')
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
})
try {
const message = await new Promise<string>((resolve, reject) => {
const ws = new WebSocket(
`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${compatId}`,
[encodeWebSocketAuthProtocol('test-api-key')],
)
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Timed out waiting for compat WebSocket replay'))
}, 2000)
ws.onmessage = event => {
const data =
typeof event.data === 'string' ? event.data : String(event.data)
if (data.includes('"type":"user"')) {
clearTimeout(timeout)
ws.close()
resolve(data)
}
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error('Compat WebSocket connection failed'))
}
})
expect(message).toContain('"type":"user"')
expect(message).toContain(`"session_id":"${id}"`)
expect(message).toContain('compat ws replay')
} finally {
await server.stop(true)
}
})
})
describe('ACP Routes', () => {
let app: Hono
function createRelayAuthApp() {
const authApp = new Hono()
authApp.get('/relay-auth', c => c.json({ ok: hasAcpRelayAuth(c) }))
return authApp
}
beforeEach(() => {
storeReset()
for (const [key] of getAllEventBuses()) {
removeEventBus(key)
}
app = createApp()
})
test('GET /acp/agents requires auth', async () => {
const res = await app.request('/acp/agents')
expect(res.status).toBe(401)
})
test('GET /acp/agents rejects UUID-only auth', async () => {
const res = await app.request('/acp/agents?uuid=user-1')
expect(res.status).toBe(401)
})
test('GET /acp/agents accepts API key header', async () => {
storeCreateEnvironment({
secret: 'secret',
machineName: 'agent-one',
workerType: 'acp',
bridgeId: 'group-one',
})
const res = await app.request('/acp/agents', {
headers: AUTH_HEADERS,
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toHaveLength(1)
expect(body[0].agent_name).toBe('agent-one')
})
test('GET /acp/channel-groups requires auth', async () => {
const res = await app.request('/acp/channel-groups')
expect(res.status).toBe(401)
})
test('GET /acp/channel-groups rejects UUID-only auth', async () => {
const res = await app.request('/acp/channel-groups?uuid=user-1')
expect(res.status).toBe(401)
})
test('GET /acp/channel-groups accepts API key header', async () => {
storeCreateEnvironment({
secret: 'secret',
machineName: 'agent-one',
workerType: 'acp',
bridgeId: 'group-one',
})
const res = await app.request('/acp/channel-groups', {
headers: AUTH_HEADERS,
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toHaveLength(1)
expect(body[0].channel_group_id).toBe('group-one')
})
test('GET /acp/channel-groups/:id requires auth', async () => {
storeCreateEnvironment({
secret: 'secret',
machineName: 'agent-one',
workerType: 'acp',
bridgeId: 'group-one',
})
const res = await app.request('/acp/channel-groups/group-one')
expect(res.status).toBe(401)
})
test('GET /acp/channel-groups/:id rejects query token auth', async () => {
storeCreateEnvironment({
secret: 'secret',
machineName: 'agent-one',
workerType: 'acp',
bridgeId: 'group-one',
})
const res = await app.request(
'/acp/channel-groups/group-one?token=test-api-key',
)
expect(res.status).toBe(401)
})
test('GET /acp/channel-groups/:id rejects UUID-only auth', async () => {
storeCreateEnvironment({
secret: 'secret',
machineName: 'agent-one',
workerType: 'acp',
bridgeId: 'group-one',
})
const res = await app.request('/acp/channel-groups/group-one?uuid=user-1')
expect(res.status).toBe(401)
})
test('GET /acp/channel-groups/:id returns group with API key auth', async () => {
storeCreateEnvironment({
secret: 'secret',
machineName: 'agent-one',
workerType: 'acp',
bridgeId: 'group-one',
})
const res = await app.request('/acp/channel-groups/group-one', {
headers: AUTH_HEADERS,
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.channel_group_id).toBe('group-one')
expect(body.member_count).toBe(1)
})
test('GET /acp/channel-groups/:id/events requires auth', async () => {
const res = await app.request('/acp/channel-groups/group-one/events')
expect(res.status).toBe(401)
})
test('GET /acp/channel-groups/:id/events rejects UUID-only auth', async () => {
const res = await app.request(
'/acp/channel-groups/group-one/events?uuid=user-1',
)
expect(res.status).toBe(401)
})
test('GET /acp/channel-groups/:id/events accepts API key header', async () => {
const res = await app.request('/acp/channel-groups/group-one/events', {
headers: AUTH_HEADERS,
})
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('text/event-stream')
await res.body?.cancel()
})
test('ACP relay auth rejects UUID-only auth', async () => {
const res = await createRelayAuthApp().request('/relay-auth?uuid=user-1')
expect(await res.json()).toEqual({ ok: false })
})
test('ACP relay auth accepts API key header', async () => {
const res = await createRelayAuthApp().request('/relay-auth', {
headers: AUTH_HEADERS,
})
expect(await res.json()).toEqual({ ok: true })
})
test('ACP relay auth accepts WebSocket protocol auth', async () => {
const res = await createRelayAuthApp().request('/relay-auth', {
headers: {
'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'),
},
})
expect(await res.json()).toEqual({ ok: true })
})
test('ACP WebSocket rejects legacy query-token auth on the real upgrade path', async () => {
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
})
try {
const close = await new Promise<CloseEvent>((resolve, reject) => {
const ws = new WebSocket(
`ws://127.0.0.1:${server.port}/acp/ws?token=test-api-key`,
)
const timeout = setTimeout(() => {
ws.close()
reject(
new Error('Timed out waiting for ACP WebSocket auth rejection'),
)
}, 2000)
ws.onclose = event => {
clearTimeout(timeout)
resolve(event)
}
ws.onerror = () => {
clearTimeout(timeout)
reject(
new Error('ACP WebSocket query-token test failed before close'),
)
}
})
expect(close.code).toBe(4003)
expect(close.reason).toBe('unauthorized')
} finally {
server.stop(true)
}
})
test('ACP WebSocket accepts subprotocol auth on the real upgrade path', async () => {
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
})
try {
const message = await new Promise<string>((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/ws`, [
encodeWebSocketAuthProtocol('test-api-key'),
])
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Timed out waiting for ACP WebSocket registration'))
}, 2000)
ws.onopen = () => {
ws.send(
JSON.stringify({ type: 'register', agent_name: 'agent-one' }) +
'\n',
)
}
ws.onmessage = event => {
const data =
typeof event.data === 'string' ? event.data : String(event.data)
if (data.includes('"type":"registered"')) {
clearTimeout(timeout)
ws.close()
resolve(data)
}
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error('ACP WebSocket subprotocol auth failed'))
}
})
expect(message).toContain('"agent_id"')
} finally {
await server.stop(true)
}
})
test('ACP relay WebSocket rejects legacy query-token auth on the real upgrade path', async () => {
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
})
try {
const close = await new Promise<CloseEvent>((resolve, reject) => {
const ws = new WebSocket(
`ws://127.0.0.1:${server.port}/acp/relay/agent_123?token=test-api-key`,
)
const timeout = setTimeout(() => {
ws.close()
reject(
new Error('Timed out waiting for ACP relay query-token rejection'),
)
}, 2000)
ws.onclose = event => {
clearTimeout(timeout)
resolve(event)
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error('ACP relay query-token test failed before close'))
}
})
expect(close.code).toBe(4003)
expect(close.reason).toBe('unauthorized')
} finally {
server.stop(true)
}
})
test('ACP relay WebSocket accepts subprotocol auth on the real upgrade path', async () => {
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
})
try {
const close = await new Promise<CloseEvent>((resolve, reject) => {
const ws = new WebSocket(
`ws://127.0.0.1:${server.port}/acp/relay/agent_123`,
[encodeWebSocketAuthProtocol('test-api-key')],
)
const timeout = setTimeout(() => {
ws.close()
reject(
new Error('Timed out waiting for ACP relay authenticated close'),
)
}, 2000)
ws.onclose = event => {
clearTimeout(timeout)
resolve(event)
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error('ACP relay subprotocol auth failed before close'))
}
})
expect(close.code).toBe(4004)
expect(close.reason).toBe('agent not found')
} finally {
server.stop(true)
}
})
})
describe('ACP WebSocket payload guards', () => {
test('rejects oversized multibyte text by byte size', () => {
const close = mock(() => {})
const handleMessage = mock(() => {})
const payload = '你'.repeat(4 * 1024 * 1024)
const decoded = decodeAcpWsMessageData(payload)
const handled = handleAcpWsPayload(
{ close } as any,
'[ACP-WS]',
'wsId=multibyte',
payload,
handleMessage,
)
expect(decoded.ok && decoded.size).toBeGreaterThan(10 * 1024 * 1024)
expect(handled).toBe(false)
expect(handleMessage).not.toHaveBeenCalled()
expect(close).toHaveBeenCalledWith(1009, 'message too large')
})
test('rejects oversized binary payload by byte size', () => {
const close = mock(() => {})
const handleMessage = mock(() => {})
const payload = new Uint8Array(11 * 1024 * 1024)
const decoded = decodeAcpWsMessageData(payload)
const handled = handleAcpWsPayload(
{ close } as any,
'[ACP-Relay]',
'relayWsId=binary',
payload,
handleMessage,
)
expect(decoded).toEqual({
ok: false,
reason: 'message too large',
size: 11 * 1024 * 1024,
})
expect(handled).toBe(false)
expect(handleMessage).not.toHaveBeenCalled()
expect(close).toHaveBeenCalledWith(1009, 'message too large')
})
test('accepts small payload into ACP handler', () => {
const close = mock(() => {})
const handleMessage = mock(() => {})
const handled = handleAcpWsPayload(
{ close } as any,
'[ACP-WS]',
'wsId=small',
'{"type":"keep_alive"}',
handleMessage,
)
expect(handled).toBe(true)
expect(handleMessage).toHaveBeenCalledWith('{"type":"keep_alive"}')
expect(close).not.toHaveBeenCalled()
})
})
describe('V2 Worker Events Routes', () => {
let app: Hono
beforeEach(() => {
storeReset()
for (const [key] of getAllEventBuses()) {
removeEventBus(key)
}
process.env.RCS_API_KEYS = 'test-api-key'
app = createApp()
})
test('POST /v1/code/sessions/:id/worker/events — publishes worker events', async () => {
// Create session
const sessRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await sessRes.json()
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'assistant', content: 'response' }]),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.status).toBe('ok')
expect(body.count).toBe(1)
})
test('POST /v1/code/sessions/:id/worker/events — unwraps CCR batch payloads', async () => {
const sessRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const {
session: { id },
} = await sessRes.json()
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({
worker_epoch: 1,
events: [{ payload: { type: 'assistant', content: 'response' } }],
}),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.count).toBe(1)
const events = getEventBus(id).getEventsSince(0)
expect(events).toHaveLength(1)
expect(events[0]?.type).toBe('assistant')
expect((events[0]?.payload as { content?: string }).content).toBe(
'response',
)
})
test('GET/PUT /v1/code/sessions/:id/worker — stores worker state', async () => {
const sessRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const {
session: { id },
} = await sessRes.json()
const putRes = await app.request(`/v1/code/sessions/${id}/worker`, {
method: 'PUT',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({
worker_epoch: 1,
worker_status: 'running',
external_metadata: {
permission_mode: 'default',
automation_state: {
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: 123456,
},
},
}),
})
expect(putRes.status).toBe(200)
const getRes = await app.request(`/v1/code/sessions/${id}/worker`, {
headers: AUTH_HEADERS,
})
expect(getRes.status).toBe(200)
const body = await getRes.json()
expect(body.worker.worker_status).toBe('running')
expect(body.worker.external_metadata.permission_mode).toBe('default')
expect(body.worker.external_metadata.automation_state).toEqual({
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: 123456,
})
const events = getEventBus(id).getEventsSince(0)
expect(events.some(event => event.type === 'automation_state')).toBe(true)
expect(events.at(-1)?.payload).toEqual({
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: 123456,
})
})
test('POST /v1/code/sessions/:id/worker/heartbeat — updates heartbeat', async () => {
const sessRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const {
session: { id },
} = await sessRes.json()
const heartbeatRes = await app.request(
`/v1/code/sessions/${id}/worker/heartbeat`,
{
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ worker_epoch: 1 }),
},
)
expect(heartbeatRes.status).toBe(200)
const getRes = await app.request(`/v1/code/sessions/${id}/worker`, {
headers: AUTH_HEADERS,
})
const body = await getRes.json()
expect(body.worker.last_heartbeat_at).toBeTruthy()
})
test('GET /v1/code/sessions/:id/worker/events/stream — emits CCR client_event frames', async () => {
const sessRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const {
session: { id },
} = await sessRes.json()
const streamRes = await app.request(
`/v1/code/sessions/${id}/worker/events/stream`,
{
headers: AUTH_HEADERS,
},
)
expect(streamRes.status).toBe(200)
const reader = streamRes.body?.getReader()
expect(reader).toBeTruthy()
if (!reader) return
const firstChunk = await reader.read()
const keepalive = new TextDecoder().decode(firstChunk.value!)
expect(keepalive).toContain(': keepalive')
publishSessionEvent(
id,
'user',
{ type: 'user', content: 'hello' },
'outbound',
)
const secondChunk = await reader.read()
const frame = new TextDecoder().decode(secondChunk.value!)
expect(frame).toContain('event: client_event')
expect(frame).toContain(
'"payload":{"type":"user","content":"hello","message":{"content":"hello"}}',
)
reader.cancel()
})
test('GET /v1/code/sessions/:id/worker/events/stream — normalizes web permission approvals to control_response', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const streamRes = await app.request(
`/v1/code/sessions/${id}/worker/events/stream`,
{
headers: AUTH_HEADERS,
},
)
expect(streamRes.status).toBe(200)
const reader = streamRes.body?.getReader()
expect(reader).toBeTruthy()
if (!reader) return
await reader.read() // initial keepalive
const controlRes = await app.request(
`/web/sessions/${id}/control?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'permission_response',
approved: true,
request_id: 'req-1',
}),
},
)
expect(controlRes.status).toBe(200)
const chunk = await reader.read()
const frame = new TextDecoder().decode(chunk.value!)
expect(frame).toContain('event: client_event')
expect(frame).toContain('"event_type":"permission_response"')
expect(frame).toContain('"payload":{"type":"control_response"')
expect(frame).toContain('"request_id":"req-1"')
expect(frame).toContain('"behavior":"allow"')
reader.cancel()
})
test('GET /v1/code/sessions/:id/worker/events/stream — normalizes web plan rejection feedback to deny control_response', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const streamRes = await app.request(
`/v1/code/sessions/${id}/worker/events/stream`,
{
headers: AUTH_HEADERS,
},
)
expect(streamRes.status).toBe(200)
const reader = streamRes.body?.getReader()
expect(reader).toBeTruthy()
if (!reader) return
await reader.read() // initial keepalive
const controlRes = await app.request(
`/web/sessions/${id}/control?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'permission_response',
approved: false,
request_id: 'req-2',
message: 'Need more detail',
}),
},
)
expect(controlRes.status).toBe(200)
const chunk = await reader.read()
const frame = new TextDecoder().decode(chunk.value!)
expect(frame).toContain('event: client_event')
expect(frame).toContain('"event_type":"permission_response"')
expect(frame).toContain('"payload":{"type":"control_response"')
expect(frame).toContain('"request_id":"req-2"')
expect(frame).toContain('"subtype":"error"')
expect(frame).toContain('"behavior":"deny"')
expect(frame).toContain('"message":"Need more detail"')
reader.cancel()
})
test('GET /v1/code/sessions/:id/worker/events/stream — normalizes web interrupts to control_request', async () => {
const createRes = await app.request('/web/sessions?uuid=user-1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await createRes.json()
const streamRes = await app.request(
`/v1/code/sessions/${id}/worker/events/stream`,
{
headers: AUTH_HEADERS,
},
)
expect(streamRes.status).toBe(200)
const reader = streamRes.body?.getReader()
expect(reader).toBeTruthy()
if (!reader) return
await reader.read() // initial keepalive
const interruptRes = await app.request(
`/web/sessions/${id}/interrupt?uuid=user-1`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
)
expect(interruptRes.status).toBe(200)
const chunk = await reader.read()
const frame = new TextDecoder().decode(chunk.value!)
expect(frame).toContain('event: client_event')
expect(frame).toContain('"event_type":"interrupt"')
expect(frame).toContain('"payload":{"type":"control_request"')
expect(frame).toContain('"subtype":"interrupt"')
reader.cancel()
})
test('PUT /v1/code/sessions/:id/worker/state — updates session status', async () => {
const sessRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await sessRes.json()
const res = await app.request(`/v1/code/sessions/${id}/worker/state`, {
method: 'PUT',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'running' }),
})
expect(res.status).toBe(200)
})
test('PUT /v1/code/sessions/:id/worker/external_metadata — no-op', async () => {
const sessRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await sessRes.json()
const res = await app.request(
`/v1/code/sessions/${id}/worker/external_metadata`,
{
method: 'PUT',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ meta: 'data' }),
},
)
expect(res.status).toBe(200)
})
test('POST /v1/code/sessions/:id/worker/events/:eventId/delivery — no-op', async () => {
const sessRes = await app.request('/v1/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { id } = await sessRes.json()
const res = await app.request(
`/v1/code/sessions/${id}/worker/events/evt123/delivery`,
{
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'received' }),
},
)
expect(res.status).toBe(200)
})
test('POST /v1/code/sessions/:id/worker/events/delivery — batch no-op', async () => {
const sessRes = await app.request('/v1/code/sessions', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const {
session: { id },
} = await sessRes.json()
const res = await app.request(
`/v1/code/sessions/${id}/worker/events/delivery`,
{
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({
worker_epoch: 1,
updates: [{ event_id: 'evt123', status: 'received' }],
}),
},
)
expect(res.status).toBe(200)
})
})