diff --git a/src/deploy/adapters/node-server-dir.test.ts b/src/deploy/adapters/node-server-dir.test.ts index 48d38912..6e25443b 100644 --- a/src/deploy/adapters/node-server-dir.test.ts +++ b/src/deploy/adapters/node-server-dir.test.ts @@ -153,12 +153,14 @@ describe('NodeServerAdapter with an agent directory', () => { it('the built server mounts the channel, answers it and reports the schedule', async () => { const { stop, port, stdout } = await startServer(outDir); try { - expect(stdout).toContain('schedules: daily; channels: echo'); - const hook = await fetch(`http://127.0.0.1:${port}/channels/echo`, { method: 'POST', body: 'ping' }); + expect(stdout).toContain('schedules: daily; channels: echo (behind the server auth)'); + // The channel has no verify, so auth.ts guards it like the chat routes. + expect((await fetch(`http://127.0.0.1:${port}/channels/echo`, { method: 'POST', body: 'ping' })).status).toBe(401); + const hook = await fetch(`http://127.0.0.1:${port}/channels/echo`, { method: 'POST', headers: { Authorization: 'Bearer dir-token' }, body: 'ping' }); expect(hook.status).toBe(200); expect(((await hook.json()) as { reply: string }).reply.trim()).toBe('pong'); expect((await fetch(`http://127.0.0.1:${port}/health`)).status).toBe(200); - // Auth.ts guards the chat routes (the channel above was answered without it). + // Auth.ts guards the chat routes too. const chat = (headers: Record) => fetch(`http://127.0.0.1:${port}/chat/s1`, { headers }).then((res) => res.status); expect(await chat({})).toBe(401); diff --git a/src/deploy/adapters/node-server.ts b/src/deploy/adapters/node-server.ts index 3bacb396..211f6835 100644 --- a/src/deploy/adapters/node-server.ts +++ b/src/deploy/adapters/node-server.ts @@ -107,7 +107,8 @@ import agentConfig from './agent.config.js';`, const agent = await createDeployedAgent(spec); const schedules = specSchedules(spec.triggers);`, options: `{ ...${JSON.stringify(options)}, schedules }`, - report: `console.log('lousho server: schedules: ' + (schedules.map((s) => s.name).join(', ') || 'none'));`, + report: `const guards = { 'self-verified': 'verifies its own requests', 'server-auth': 'behind the server auth', open: 'open' }; + console.log('lousho server: schedules: ' + (schedules.map((s) => s.name).join(', ') || 'none'));`, }; } @@ -125,7 +126,12 @@ import { createAgent, createDeployedServer, resolveAgentDir, storeFromEnv } from // An agent directory's auth.ts replaces the baked token; LOUSHO_API_TOKEN is appended to it. // Its named authorizeSession / authorizeApproval exports decide who may use a session and decide an approval. options: `{ ...${JSON.stringify(options)}, ...(resolved.auth ? { auth: resolved.auth } : {}), authorizeSession: resolved.authorizeSession, authorizeApproval: resolved.authorizeApproval, schedules: resolved.schedules, channels: resolved.channels }`, - report: `console.log('lousho server: schedules: ' + (resolved.manifest.schedules.join(', ') || 'none') + '; channels: ' + (resolved.manifest.channels.join(', ') || 'none'));`, + // Each channel route with its guard: its own verify, the server's auth (LOUSHO_API_TOKEN / auth.ts), or open. + report: `const guards = { 'self-verified': 'verifies its own requests', 'server-auth': 'behind the server auth', open: 'open' }; + console.log('lousho server: schedules: ' + (resolved.manifest.schedules.join(', ') || 'none') + '; channels: ' + (channelRoutes.map((route) => route.name + ' (' + guards[route.auth] + ')').join(', ') || 'none')); + if (channelRoutes.some((route) => route.auth === 'open')) { + console.warn('[lousho server] warning: channel routes marked "open" have no verify and no LOUSHO_API_TOKEN guards them, so anyone who can reach this server can call them.'); + }`, }; } @@ -221,7 +227,7 @@ const LOOPBACK = ['127.0.0.1', 'localhost', '::1']; async function main(): Promise { ${variant.boot} - const { server, authenticated, shutdown } = createDeployedServer(agent, ${variant.options}); + const { server, authenticated, channelRoutes, shutdown } = createDeployedServer(agent, ${variant.options}); server.on('error', (err: NodeJS.ErrnoException) => { console.error( diff --git a/src/deploy/nodeServer.test.ts b/src/deploy/nodeServer.test.ts index c06a3ff1..334f4c12 100644 --- a/src/deploy/nodeServer.test.ts +++ b/src/deploy/nodeServer.test.ts @@ -215,9 +215,11 @@ describe('deployed node server auth list', () => { expect((await closed.chat({ Authorization: 'Bearer t' })).status).toBe(401); }); - it('channels are answered without the route auth (they verify themselves)', async () => { + it('a channel without its own verify is behind the route auth list', async () => { const { base, replies } = await serveWithPrincipal({ auth: [basic({ users: { ops: 'pw' } })], env: {} }); - const hook = await fetch(`${base}/channels/sms`, { method: 'POST', body: 'ping' }); + expect((await fetch(`${base}/channels/sms`, { method: 'POST', body: 'ping' })).status).toBe(401); + expect(replies).toEqual([]); + const hook = await fetch(`${base}/channels/sms`, { method: 'POST', headers: basicHeader('ops', 'pw'), body: 'ping' }); expect(hook.status).toBe(200); expect(replies).toEqual(['ok']); expect((await fetch(`${base}/health`)).status).toBe(200); diff --git a/src/deploy/nodeServer.ts b/src/deploy/nodeServer.ts index 2ed962a8..0d4df8d5 100644 --- a/src/deploy/nodeServer.ts +++ b/src/deploy/nodeServer.ts @@ -6,7 +6,9 @@ * and shared with the Worker target): `GET /health`, sessions, SSE streaming, * approvals and the legacy `POST /chat { message }`. When auth is configured (a bearer * token, or an auth list from `@lousho/build-ai-agent/auth`), every route except - * `/health` and the channels requires it. + * `/health` requires it, including a channel route, unless that channel + * authenticates its own requests (it has a `verify`: a signed platform channel, + * a webhook with a secret, an `httpChannel({ verify })`). * * Node-only (the Worker target has its own runtime, runtime.worker.ts). */ @@ -26,6 +28,7 @@ import { SDKError } from '../execution/errors'; import type { AuthFn } from '../auth/types'; import { apiToken } from '../auth/basic'; import { requestGuardFromEnv } from '../server/requestGuard'; +import { routeAuth } from '../auth/routeAuth'; /** Environment variable holding the bearer token; wins over a token baked in at build time. */ const API_TOKEN_ENV = 'LOUSHO_API_TOKEN'; @@ -95,6 +98,47 @@ function definedOnly>(values: T): Partial { return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined)) as Partial; } +/** + * How a channel route of the deployed server is guarded: `self-verified` (the + * channel has its own `verify`: a platform signature, a webhook secret, ...), + * `server-auth` (no `verify`, so the server's auth - `LOUSHO_API_TOKEN`, the + * build's token or the agent directory's `auth.ts` - guards it, as it guards + * `/chat`), or `open` (no `verify` and no server auth: anyone can call it). + */ +export type ChannelRouteAuth = 'self-verified' | 'server-auth' | 'open'; + +/** The channel routes' guards, in channel order. */ +function channelRoutesOf(channels: readonly Channel[], authenticated: boolean): { name: string; auth: ChannelRouteAuth }[] { + return channels.map((channel) => ({ name: channel.name, auth: channel.verify ? 'self-verified' : authenticated ? 'server-auth' : 'open' })); +} + +/** Whether `req` is a `POST /channels/[/...]` for one of `names`. */ +function guardedChannelRoute(req: http.IncomingMessage, names: ReadonlySet): boolean { + if (req.method !== 'POST') return false; + const { pathname } = new URL(req.url ?? '/', 'http://localhost'); + return pathname.startsWith('/channels/') && names.has(pathname.slice('/channels/'.length).split('/')[0]); +} + +/** + * Runs the server's auth on the headers of `req` (its body is left unread for + * the channel) and writes the 401 / 403 / 500 when it refuses; `true` when the + * request may go on. + */ +async function channelRequestAllowed(req: http.IncomingMessage, res: http.ServerResponse, auth: ServeAuth): Promise { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + for (const item of Array.isArray(value) ? value : value === undefined ? [] : [value]) headers.append(name, item); + } + const request = new Request(new URL(req.url ?? '/', 'http://localhost'), { method: req.method, headers }); + const outcome = await routeAuth(request, typeof auth === 'string' ? apiToken(auth) : auth); + if (outcome.ok) return true; + const responseHeaders: Record = {}; + outcome.response.headers.forEach((value, name) => (responseHeaders[name] = value)); + res.writeHead(outcome.response.status, responseHeaders); + res.end(await outcome.response.text()); + return false; +} + /** Default bound of {@link DeployedServer.shutdown}'s wait for in-flight turns; `LOUSHO_SHUTDOWN_TIMEOUT_MS` overrides it in the generated server. */ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000; @@ -103,6 +147,8 @@ export interface DeployedServer { server: http.Server; /** Whether requests are checked (a token or an auth list), so the caller can warn when it listens publicly without. */ authenticated: boolean; + /** Each channel route and how it is guarded (see {@link ChannelRouteAuth}), for the start-up report. */ + channelRoutes: { name: string; auth: ChannelRouteAuth }[]; /** * A graceful stop, for SIGTERM. Stops accepting connections * (`server.close()`), stops the schedules (no new fires), then waits for @@ -121,8 +167,11 @@ export interface DeployedServer { */ export function createDeployedServer(agent: SimpleAgent, options: DeployedServerOptions = {}): DeployedServer { const auth = serverAuth(options); - // Channels authenticate themselves (their own verify), so they sit beside the bearer-protected chat routes. const channels = options.channels?.length ? mountChannels(agent, options.channels) : undefined; + // A channel with its own verify authenticates its callers (a platform's signature: they cannot send our token); + // one without it sits behind the server's auth, like the chat routes. + const channelRoutes = channelRoutesOf(options.channels ?? [], auth !== undefined); + const guarded = new Set(channelRoutes.filter((route) => route.auth === 'server-auth').map((route) => route.name)); // Requests being handled, and turns that outlived their client, for shutdown() to wait for. const inFlight = new Set>(); const track = (work: Promise): void => { @@ -155,6 +204,7 @@ export function createDeployedServer(agent: SimpleAgent, options: DeployedServer }, }; const handle = async (req: http.IncomingMessage, res: http.ServerResponse): Promise => { + if (auth !== undefined && guardedChannelRoute(req, guarded) && !(await channelRequestAllowed(req, res, auth))) return; if (await channels?.(req, res)) return; await relayFetch(req, res, (request) => serveFetch(request, chat, auth)); }; @@ -183,5 +233,5 @@ export function createDeployedServer(agent: SimpleAgent, options: DeployedServer } server.closeAllConnections(); }; - return { server, authenticated: auth !== undefined, shutdown }; + return { server, authenticated: auth !== undefined, channelRoutes, shutdown }; } diff --git a/src/deploy/nodeServerChannels.test.ts b/src/deploy/nodeServerChannels.test.ts index ce6dfc26..2291e649 100644 --- a/src/deploy/nodeServerChannels.test.ts +++ b/src/deploy/nodeServerChannels.test.ts @@ -7,6 +7,25 @@ import { defineChannel } from '../channels/defineChannel'; import { createDeployedServer } from './nodeServer'; import { memoryStore } from '../storage/agentStore'; import { channelSessionId } from '../channels/defineChannel'; +import { httpChannel } from '../channels/httpChannel'; +import { webhookChannel } from '../channels/webhookChannel'; +import { createHmac } from 'node:crypto'; +import type { Channel } from '../channels/defineChannel'; +import type { SimpleAgent } from '../createAgent'; + +const BEARER = { authorization: 'Bearer secret' }; + +/** Listens on a free port; resolves the base URL and a closer. */ +async function listen(agent: SimpleAgent, channels: readonly Channel[], env: NodeJS.ProcessEnv = { LOUSHO_API_TOKEN: 'secret' }) { + const deployed = createDeployedServer(agent, { env, channels }); + await new Promise((resolve) => deployed.server.listen(0, '127.0.0.1', resolve)); + const base = `http://127.0.0.1:${(deployed.server.address() as AddressInfo).port}`; + const close = async () => { + await new Promise((resolve) => deployed.server.close(resolve)); + await agent.close(); + }; + return { base, close, channelRoutes: deployed.channelRoutes }; +} describe('node server channels', () => { it('routes POST /channels/ to an agent turn and sends the reply through the channel', async () => { @@ -26,10 +45,10 @@ describe('node server channels', () => { await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; - const hook = await fetch(`${base}/channels/sms`, { method: 'POST', body: JSON.stringify({ from: '+1', body: 'ping' }) }); + const hook = await fetch(`${base}/channels/sms`, { method: 'POST', headers: BEARER, body: JSON.stringify({ from: '+1', body: 'ping' }) }); expect(hook.status).toBe(200); expect(sent).toEqual(['+1:pong']); - // The chat routes stay behind the bearer token, next to the channels. + // The chat routes stay behind the bearer token, like the channel (it has no verify of its own). expect((await fetch(`${base}/health`)).status).toBe(200); expect((await fetch(`${base}/sessions`)).status).toBe(401); @@ -52,11 +71,69 @@ describe('node server channels', () => { await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; - expect((await fetch(`${base}/channels/sms`, { method: 'POST', body: JSON.stringify({ from: '+1', body: 'ping' }) })).status).toBe(200); + expect((await fetch(`${base}/channels/sms`, { method: 'POST', headers: BEARER, body: JSON.stringify({ from: '+1', body: 'ping' }) })).status).toBe(200); const id = channelSessionId(sms, { sessionKey: '+1', input: '', replyTo: undefined }); expect(await store.sessions.load(id)).toHaveLength(2); await new Promise((resolve) => server.close(resolve)); await agent.close(); }); + + describe('route auth', () => { + const json = (body: unknown, headers: Record = {}) => ({ method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) }); + + it('puts a channel without verify behind LOUSHO_API_TOKEN', async () => { + const { base, close, channelRoutes } = await listen(createAgent({ provider: mockModel(['pong', 'pong']), instructions: 'x' }), [httpChannel()]); + try { + expect((await fetch(`${base}/channels/http`, json({ sessionKey: 'alice', input: 'hi' }))).status).toBe(401); + expect((await fetch(`${base}/channels/http`, json({ sessionKey: 'alice', input: 'hi' }, { authorization: 'Bearer wrong' }))).status).toBe(401); + expect((await fetch(`${base}/channels/http/approvals/a1`, json({ approved: true }))).status).toBe(401); + const ok = await fetch(`${base}/channels/http`, json({ sessionKey: 'alice', input: 'hi' }, BEARER)); + expect(ok.status).toBe(200); + expect(((await ok.json()) as { text: string }).text).toBe('pong'); + expect(channelRoutes).toEqual([{ name: 'http', auth: 'server-auth' }]); + } finally { + await close(); + } + }); + + it('leaves a channel with its own verify reachable without the bearer token', async () => { + const api = httpChannel({ verify: async (req) => req.headers['x-api-key'] === 'k' }); + const { base, close, channelRoutes } = await listen(createAgent({ provider: mockModel(['pong']), instructions: 'x' }), [api]); + try { + expect((await fetch(`${base}/channels/http`, json({ sessionKey: 'a', input: 'hi' }, { 'x-api-key': 'nope' }))).status).toBe(401); + expect((await fetch(`${base}/channels/http`, json({ sessionKey: 'a', input: 'hi' }, { 'x-api-key': 'k' }))).status).toBe(200); + expect(channelRoutes).toEqual([{ name: 'http', auth: 'self-verified' }]); + } finally { + await close(); + } + }); + + it('leaves a signed platform channel (webhook with a secret) reachable without the bearer token', async () => { + const body = JSON.stringify({ input: 'deploy failed' }); + const signature = `sha256=${createHmac('sha256', 'hook-secret').update(body).digest('hex')}`; + const hook = webhookChannel({ secret: 'hook-secret' }); + const { base, close, channelRoutes } = await listen(createAgent({ provider: mockModel(['ack']), instructions: 'x' }), [hook, httpChannel()]); + try { + expect((await fetch(`${base}/channels/webhook`, { method: 'POST', headers: { 'content-type': 'application/json' }, body })).status).toBe(401); + expect((await fetch(`${base}/channels/webhook`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-signature-256': signature }, body })).status).toBe(200); + expect(channelRoutes).toEqual([ + { name: 'webhook', auth: 'self-verified' }, + { name: 'http', auth: 'server-auth' }, + ]); + } finally { + await close(); + } + }); + + it('reports a channel without verify as open when no token is configured', async () => { + const { base, close, channelRoutes } = await listen(createAgent({ provider: mockModel(['pong']), instructions: 'x' }), [httpChannel()], {}); + try { + expect((await fetch(`${base}/channels/http`, json({ sessionKey: 'a', input: 'hi' }))).status).toBe(200); + expect(channelRoutes).toEqual([{ name: 'http', auth: 'open' }]); + } finally { + await close(); + } + }); + }); }); diff --git a/src/oauth/signInRoutes.test.ts b/src/oauth/signInRoutes.test.ts index f5d88a7b..75f40cf7 100644 --- a/src/oauth/signInRoutes.test.ts +++ b/src/oauth/signInRoutes.test.ts @@ -153,7 +153,7 @@ describe('sign-in over HTTP', () => { await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; try { - expect((await fetch(`${base}/channels/chat`, { method: 'POST', body: JSON.stringify({ user: 'alice', text: 'List my repositories.' }) })).status).toBe(200); + expect((await fetch(`${base}/channels/chat`, { method: 'POST', headers: { authorization: 'Bearer secret' }, body: JSON.stringify({ user: 'alice', text: 'List my repositories.' }) })).status).toBe(200); expect(prompts).toHaveLength(1); expect(prompts[0]).toMatch(/^Sign in to GitHub to continue: https:\/\/github\.example\.com\/login\/oauth\/authorize\?/); const state = stateOf(prompts[0].slice(prompts[0].indexOf('https://')));