Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/deploy/adapters/node-server-dir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) =>
fetch(`http://127.0.0.1:${port}/chat/s1`, { headers }).then((res) => res.status);
expect(await chat({})).toBe(401);
Expand Down
12 changes: 9 additions & 3 deletions src/deploy/adapters/node-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));`,
};
}

Expand All @@ -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.');
}`,
};
}

Expand Down Expand Up @@ -221,7 +227,7 @@ const LOOPBACK = ['127.0.0.1', 'localhost', '::1'];

async function main(): Promise<void> {
${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(
Expand Down
6 changes: 4 additions & 2 deletions src/deploy/nodeServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
56 changes: 53 additions & 3 deletions src/deploy/nodeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand All @@ -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';
Expand Down Expand Up @@ -95,6 +98,47 @@ function definedOnly<T extends Record<string, unknown>>(values: T): Partial<T> {
return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined)) as Partial<T>;
}

/**
* 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/<name>[/...]` for one of `names`. */
function guardedChannelRoute(req: http.IncomingMessage, names: ReadonlySet<string>): 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<boolean> {
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<string, string> = {};
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;

Expand All @@ -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
Expand All @@ -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<Promise<unknown>>();
const track = (work: Promise<unknown>): void => {
Expand Down Expand Up @@ -155,6 +204,7 @@ export function createDeployedServer(agent: SimpleAgent, options: DeployedServer
},
};
const handle = async (req: http.IncomingMessage, res: http.ServerResponse): Promise<void> => {
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));
};
Expand Down Expand Up @@ -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 };
}
83 changes: 80 additions & 3 deletions src/deploy/nodeServerChannels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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/<name> to an agent turn and sends the reply through the channel', async () => {
Expand All @@ -26,10 +45,10 @@ describe('node server channels', () => {
await new Promise<void>((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);

Expand All @@ -52,11 +71,69 @@ describe('node server channels', () => {
await new Promise<void>((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<string, string> = {}) => ({ 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();
}
});
});
});
2 changes: 1 addition & 1 deletion src/oauth/signInRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ describe('sign-in over HTTP', () => {
await new Promise<void>((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://')));
Expand Down
Loading