mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
2226 lines
68 KiB
TypeScript
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)
|
|
})
|
|
})
|