diff --git a/CHANGELOG.md b/CHANGELOG.md index 42914b296..68c538225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ opt-out. - `lousho doctor` fix lines use PowerShell syntax (`$env:NAME=""`) on Windows. - The `lousho eval` table shows the scores of passing scored gates (such as `t.check('words', n, atLeast(3))`), not only in `--json`. - **Behavior change.** An agent with `output` and tools no longer sends the strict `responseFormat` schema on calls that offer tools: `openai/gpt-4o-mini` via OpenRouter then called tools on every step until `maxSteps` and never answered. `responseFormat` now goes only on calls without tools (an agent without tools, the "answer now" call at `maxSteps`, the repair step); tool-offering calls answer from the schema in the prompt, with the existing parse and repair. `output: { schema, responseFormat: 'always' }` restores the old behavior, and is the default with `promptSchema: false`. `object.delta` events still stream on every step of an `output` run. See [Structured output](docs/structured-output.md#how-it-works). +- An agent directory built with `lousho build --target=node-server` (or `docker`) can now change who may decide an approval and who may use a session: `auth.ts` may export functions named `authorizeApproval` and `authorizeSession` next to its default auth list, `resolveAgentDir()` returns them, and the built server applies them. Before, the built server always used the defaults, so a manager could not approve another user's paused call while that user could approve their own. See [Who may decide an approval](docs/auth.md#who-may-decide-an-approval). ### Documentation diff --git a/api/index.api.md b/api/index.api.md index e23a8d549..ddfd113ed 100644 --- a/api/index.api.md +++ b/api/index.api.md @@ -5189,6 +5189,8 @@ export interface ResolveApprovalOptions { // @public export interface ResolvedAgentDir { auth?: AuthFn | readonly AuthFn[]; + authorizeApproval?: ChatRoutesAccess['authorizeApproval']; + authorizeSession?: ChatRoutesAccess['authorizeSession']; channels: Channel[]; config: CreateAgentConfig; // (undocumented) diff --git a/docs/agent-directories.md b/docs/agent-directories.md index ef025d27a..28854ba18 100644 --- a/docs/agent-directories.md +++ b/docs/agent-directories.md @@ -32,7 +32,8 @@ my-agent/ schedules/*.{ts,mts,cts,js,mjs,cjs} # defineSchedule() cron schedules (see schedules.md) channels/*.{ts,mts,cts,js,mjs,cjs} # a channel each: defineChannel(), webhookChannel(), ... (see channels.md) memory/*.{ts,mts,cts,js,mjs,cjs} # a memory slot each: defineMemory() (see memory.md); part of the agent - auth.{ts,mts,cts,js,mjs,cjs} # route auth for the deployed server: jwt(), oidc(), basic(), ... (see auth.md) + auth.{ts,mts,cts,js,mjs,cjs} # route auth for the deployed server: jwt(), oidc(), basic(), ... (see auth.md), + # plus optional named authorizeSession / authorizeApproval exports ``` Only one config file may exist. Files inside `tools/` and the entries of @@ -373,7 +374,11 @@ npx lousho build ./my-agent --target=node-server # or docker The agent directory is the unit of deployment: the built server loads it with `resolveAgentDir()` at start-up, starts its `schedules/` and mounts its `channels/` under `/channels`, guards its chat routes with `auth.ts` when there -is one ([Route auth and principals](auth.md)), and prints which it found. The code files (the +is one ([Route auth and principals](auth.md)), and prints which it found. A +function `auth.ts` exports as `authorizeSession` or `authorizeApproval` +decides who may use a session and who may decide an approval, e.g. a manager +approving a user's paused claim ([Who may decide an approval](auth.md#who-may-decide-an-approval)); +without them the server keeps the defaults (a session and its approvals belong to the caller that started it). The code files (the config, `auth.ts`, a root `hooks.*` / `approve.*` file, `tools/`, `schedules/`, `channels/`, `memory/`, and the same in each sub-agent) are bundled to `dist/agent/**.js`, and only `instructions.md` (with `instructions/.md`), `skills/` and a JSON/YAML config (the same in each sub-agent) are copied next to them, so the server needs no TypeScript diff --git a/docs/auth.md b/docs/auth.md index 54074fe39..f7b7e903e 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -235,6 +235,14 @@ server uses it (`resolveAgentDir()` returns it as `auth`; the manifest says still is. The `cloudflare-worker` target serves spec files only and keeps the `LOUSHO_API_TOKEN` token; see [Deployment](./deployment.md#auth). +`auth.ts` may also export functions named `authorizeSession` and +`authorizeApproval`, next to its default export. The built server uses them as +the rules of [Who may use a session](#who-may-use-a-session) and +[Who may decide an approval](#who-may-decide-an-approval), and +`resolveAgentDir()` returns them under those names. Any other value under +those names fails the load (`LOUSHO_AGENT_DIR_INVALID`). `lousho dev` has no +route auth, so neither the list nor these rules apply there. + ## Reading the principal in the run The principal is on the run context next to `sessionId` and `metadata`. The @@ -440,7 +448,8 @@ claimed again. So for anything that runs on more than one instance, or keeps sessions across restarts, check ownership yourself with `authorizeSession`. `createRouteHandler()` and the generated node server take `authorizeSession`, -which replaces that default. It is asked before a caller reads a session +which replaces that default (in an agent directory, export it from `auth.ts`: +see the example under [Who may decide an approval](#who-may-decide-an-approval)). It is asked before a caller reads a session (`action: 'read'`), sends it a turn (`'chat'`, also `POST ` and the `useChat` endpoint) or decides one of its approvals (`'approve'`). Return `false` for a `403` (`LOUSHO_SESSION_FORBIDDEN`). Deriving the id from the @@ -551,6 +560,40 @@ export const { GET, POST } = createRouteHandler(agent, { }); ``` +In an agent directory built with `lousho build --target=node-server` (or +`docker`), export the same rules from `auth.ts`. Here users file expense +claims, a manager decides them, and nobody approves their own: + +```ts no-verify +// my-agent/auth.ts +import { apiToken } from '@lousho/build-ai-agent/auth'; +import type { ApprovalAccessRequest, SessionAccessRequest } from '@lousho/build-ai-agent'; + +export default [ + apiToken(process.env.ALICE_TOKEN!, { id: 'alice' }), + apiToken(process.env.BOB_TOKEN!, { id: 'bob' }), + apiToken(process.env.MANAGER_TOKEN!, { id: 'manager' }), +]; + +// Session ids are `-`. +const ownerOf = (sessionId: string) => sessionId.slice(0, Math.max(sessionId.lastIndexOf('-'), 0)); + +// Users use their own sessions; the manager may open any session to review a claim. +export function authorizeSession({ principal, sessionId }: SessionAccessRequest): boolean { + return principal?.id === 'manager' || (principal !== undefined && ownerOf(sessionId) === principal.id); +} + +// Only the manager decides, and never on a claim of their own. +export function authorizeApproval({ principal, approval }: ApprovalAccessRequest): boolean { + return principal?.id === 'manager' && approval.principal?.id !== 'manager'; +} +``` + +Alice's paused claim in session `alice-1` is then decided by +`POST /chat/alice-1/approvals/:id` with the manager's token (`200`); with +alice's own token it is a `403` (`LOUSHO_APPROVAL_FORBIDDEN`). Without these +exports the built server keeps the defaults, so only alice could decide it. + Two requests that decide the same approval get one continuation: while the winning decision's continuation is still running (on the same server process), any other decision of that approval is a `409` (`LOUSHO_APPROVAL_CONFLICT`), diff --git a/docs/deployment.md b/docs/deployment.md index 356c2592a..1554c957c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -124,7 +124,12 @@ interval; `0` turns it off. With an auth list, a session belongs to the first pr it a turn, and a stored session whose owner the server no longer remembers (after a restart) is refused to everyone until `authorizeSession` says whose it is; with only `LOUSHO_API_TOKEN` there is one caller and no owner check -([Who may use a session](./auth.md#who-may-use-a-session)). +([Who may use a session](./auth.md#who-may-use-a-session)). An approval is +decided only by the caller its run acts for unless `authorizeApproval` says +otherwise. In an agent directory, export `authorizeSession` and +`authorizeApproval` from `auth.ts` to change either rule (for example, to let +a manager decide other users' approvals): see +[Who may decide an approval](./auth.md#who-may-decide-an-approval). ```bash curl -N http://127.0.0.1:3000/chat \ diff --git a/llms-full.txt b/llms-full.txt index 1d77f7c9e..34a530b05 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1540,7 +1540,8 @@ my-agent/ schedules/*.{ts,mts,cts,js,mjs,cjs} # defineSchedule() cron schedules (see schedules.md) channels/*.{ts,mts,cts,js,mjs,cjs} # a channel each: defineChannel(), webhookChannel(), ... (see channels.md) memory/*.{ts,mts,cts,js,mjs,cjs} # a memory slot each: defineMemory() (see memory.md); part of the agent - auth.{ts,mts,cts,js,mjs,cjs} # route auth for the deployed server: jwt(), oidc(), basic(), ... (see auth.md) + auth.{ts,mts,cts,js,mjs,cjs} # route auth for the deployed server: jwt(), oidc(), basic(), ... (see auth.md), + # plus optional named authorizeSession / authorizeApproval exports ``` Only one config file may exist. Files inside `tools/` and the entries of @@ -1881,7 +1882,11 @@ npx lousho build ./my-agent --target=node-server # or docker The agent directory is the unit of deployment: the built server loads it with `resolveAgentDir()` at start-up, starts its `schedules/` and mounts its `channels/` under `/channels`, guards its chat routes with `auth.ts` when there -is one ([Route auth and principals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/auth.md)), and prints which it found. The code files (the +is one ([Route auth and principals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/auth.md)), and prints which it found. A +function `auth.ts` exports as `authorizeSession` or `authorizeApproval` +decides who may use a session and who may decide an approval, e.g. a manager +approving a user's paused claim ([Who may decide an approval](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/auth.md#who-may-decide-an-approval)); +without them the server keeps the defaults (a session and its approvals belong to the caller that started it). The code files (the config, `auth.ts`, a root `hooks.*` / `approve.*` file, `tools/`, `schedules/`, `channels/`, `memory/`, and the same in each sub-agent) are bundled to `dist/agent/**.js`, and only `instructions.md` (with `instructions/.md`), `skills/` and a JSON/YAML config (the same in each sub-agent) are copied next to them, so the server needs no TypeScript @@ -3267,6 +3272,14 @@ server uses it (`resolveAgentDir()` returns it as `auth`; the manifest says still is. The `cloudflare-worker` target serves spec files only and keeps the `LOUSHO_API_TOKEN` token; see [Deployment](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/deployment.md#auth). +`auth.ts` may also export functions named `authorizeSession` and +`authorizeApproval`, next to its default export. The built server uses them as +the rules of [Who may use a session](#who-may-use-a-session) and +[Who may decide an approval](#who-may-decide-an-approval), and +`resolveAgentDir()` returns them under those names. Any other value under +those names fails the load (`LOUSHO_AGENT_DIR_INVALID`). `lousho dev` has no +route auth, so neither the list nor these rules apply there. + ## Reading the principal in the run The principal is on the run context next to `sessionId` and `metadata`. The @@ -3472,7 +3485,8 @@ claimed again. So for anything that runs on more than one instance, or keeps sessions across restarts, check ownership yourself with `authorizeSession`. `createRouteHandler()` and the generated node server take `authorizeSession`, -which replaces that default. It is asked before a caller reads a session +which replaces that default (in an agent directory, export it from `auth.ts`: +see the example under [Who may decide an approval](#who-may-decide-an-approval)). It is asked before a caller reads a session (`action: 'read'`), sends it a turn (`'chat'`, also `POST ` and the `useChat` endpoint) or decides one of its approvals (`'approve'`). Return `false` for a `403` (`LOUSHO_SESSION_FORBIDDEN`). Deriving the id from the @@ -3583,6 +3597,40 @@ export const { GET, POST } = createRouteHandler(agent, { }); ``` +In an agent directory built with `lousho build --target=node-server` (or +`docker`), export the same rules from `auth.ts`. Here users file expense +claims, a manager decides them, and nobody approves their own: + +```ts no-verify +// my-agent/auth.ts +import { apiToken } from '@lousho/build-ai-agent/auth'; +import type { ApprovalAccessRequest, SessionAccessRequest } from '@lousho/build-ai-agent'; + +export default [ + apiToken(process.env.ALICE_TOKEN!, { id: 'alice' }), + apiToken(process.env.BOB_TOKEN!, { id: 'bob' }), + apiToken(process.env.MANAGER_TOKEN!, { id: 'manager' }), +]; + +// Session ids are `-`. +const ownerOf = (sessionId: string) => sessionId.slice(0, Math.max(sessionId.lastIndexOf('-'), 0)); + +// Users use their own sessions; the manager may open any session to review a claim. +export function authorizeSession({ principal, sessionId }: SessionAccessRequest): boolean { + return principal?.id === 'manager' || (principal !== undefined && ownerOf(sessionId) === principal.id); +} + +// Only the manager decides, and never on a claim of their own. +export function authorizeApproval({ principal, approval }: ApprovalAccessRequest): boolean { + return principal?.id === 'manager' && approval.principal?.id !== 'manager'; +} +``` + +Alice's paused claim in session `alice-1` is then decided by +`POST /chat/alice-1/approvals/:id` with the manager's token (`200`); with +alice's own token it is a `403` (`LOUSHO_APPROVAL_FORBIDDEN`). Without these +exports the built server keeps the defaults, so only alice could decide it. + Two requests that decide the same approval get one continuation: while the winning decision's continuation is still running (on the same server process), any other decision of that approval is a `409` (`LOUSHO_APPROVAL_CONFLICT`), @@ -7102,7 +7150,12 @@ interval; `0` turns it off. With an auth list, a session belongs to the first pr it a turn, and a stored session whose owner the server no longer remembers (after a restart) is refused to everyone until `authorizeSession` says whose it is; with only `LOUSHO_API_TOKEN` there is one caller and no owner check -([Who may use a session](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/auth.md#who-may-use-a-session)). +([Who may use a session](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/auth.md#who-may-use-a-session)). An approval is +decided only by the caller its run acts for unless `authorizeApproval` says +otherwise. In an agent directory, export `authorizeSession` and +`authorizeApproval` from `auth.ts` to change either rule (for example, to let +a manager decide other users' approvals): see +[Who may decide an approval](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/auth.md#who-may-decide-an-approval). ```bash curl -N http://127.0.0.1:3000/chat \ diff --git a/src/agentDir/loadAgentDir.ts b/src/agentDir/loadAgentDir.ts index 7ab78e610..0023640a3 100644 --- a/src/agentDir/loadAgentDir.ts +++ b/src/agentDir/loadAgentDir.ts @@ -36,6 +36,7 @@ import type { InferSchemaOutput, StandardSchemaV1 } from '../utils/zodCompat'; import type { AgentStore } from '../storage/agentStore'; import { fileStore } from '../storage/fileStore'; import { loadAuth } from './loadAuth'; +import type { ChatRoutesAccess } from '../server/fetchRoutes'; import { importModule } from './importModule'; export type { AgentDirConfig } from './readConfig'; @@ -125,6 +126,17 @@ export interface ResolvedAgentDir { * `loadAgentDir()` does not use it. */ auth?: AuthFn | readonly AuthFn[]; + /** + * `auth.ts`'s named `authorizeSession` export (who may read, continue or + * decide approvals in a session; docs/auth.md): pass it with `auth`. The + * built node-server does. + */ + authorizeSession?: ChatRoutesAccess['authorizeSession']; + /** + * `auth.ts`'s named `authorizeApproval` export (who may decide an approval, + * e.g. a manager; docs/auth.md): pass it with `auth`. The built node-server does. + */ + authorizeApproval?: ChatRoutesAccess['authorizeApproval']; } /** The model source a parent hands down to sub-agents that do not choose their own. */ @@ -622,7 +634,8 @@ async function skillsFor(dir: string, overrides: AgentDirOverrides): Promise { expect(manifest.auth).toBe(false); }); + it('returns the named authorizeSession / authorizeApproval exports, and rejects ones that are not functions', async () => { + const dir = tempDir({ + 'instructions.md': 'x', + 'auth.mjs': [ + 'export default () => null;', + "export const authorizeSession = ({ principal }) => principal?.id === 'manager';", + "export const authorizeApproval = ({ principal, approval }) => principal?.id === 'manager' && approval.principal?.id !== 'manager';", + ].join('\n'), + }); + const resolved = await resolveAgentDir(dir, { provider: mockModel(['x']) }); + const manager = { id: 'manager', type: 'user' as const, authenticator: 'custom' }; + expect(resolved.authorizeSession?.({ principal: manager, sessionId: 's', action: 'read' })).toBe(true); + expect(resolved.authorizeApproval?.({ principal: manager, sessionId: 's', approval: { principal: { ...manager, id: 'alice' } } as never })).toBe(true); + expect(resolved.authorizeApproval?.({ principal: { ...manager, id: 'alice' }, sessionId: 's', approval: { principal: { ...manager, id: 'alice' } } as never })).toBe(false); + + const without = await resolveAgentDir(fixture('auth'), { provider: mockModel(['x']) }); + expect(without.authorizeSession).toBeUndefined(); + expect(without.authorizeApproval).toBeUndefined(); + + const bad = tempDir({ 'instructions.md': 'x', 'auth.mjs': "export default () => null;\nexport const authorizeApproval = 'manager';" }); + await expect(resolveAgentDir(bad, { provider: mockModel(['x']) })).rejects.toThrow(/authorizeApproval export must be a function/); + }); + it('takes a single function, and rejects other exports and two auth files', async () => { const single = tempDir({ 'instructions.md': 'x', 'auth.mjs': 'export default () => null;' }); expect(typeof (await resolveAgentDir(single, { provider: mockModel(['x']) })).auth).toBe('function'); diff --git a/src/agentDir/loadAuth.ts b/src/agentDir/loadAuth.ts index 79680c610..8a228fcf3 100644 --- a/src/agentDir/loadAuth.ts +++ b/src/agentDir/loadAuth.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import type { AuthFn } from '../auth/types'; +import type { ChatRoutesAccess } from '../server/fetchRoutes'; import { SDKError } from '../execution/errors'; import { listSorted } from './fsUtil'; import { importModule } from './importModule'; @@ -7,20 +8,28 @@ import { importModule } from './importModule'; /** `auth.ts`, `auth.js`, ... at the root of an agent directory (not tests or declarations). */ const AUTH_FILE = /^auth\.[cm]?[jt]s$/; +/** The named exports of an auth file that become the route's session and approval rules. */ +const ACCESS_RULES = ['authorizeSession', 'authorizeApproval'] as const; + +/** Who may use a session and who may decide an approval, as an agent directory's auth file exports them. */ +type AgentDirAccessRules = Pick; + /** * The route auth of an agent directory, from its `auth.{ts,js,mjs,cjs,mts}` * file, which default-exports an auth entry or an ordered list of them - * (`jwt()`, `oidc()`, `basic()`, `apiToken()`, ... from `@lousho/build-ai-agent/auth`). - * `undefined` when the directory has no such file. + * (`jwt()`, `oidc()`, `basic()`, `apiToken()`, ... from `@lousho/build-ai-agent/auth`), + * plus its optional named `authorizeSession` / `authorizeApproval` exports + * (docs/auth.md). `undefined` when the directory has no such file. */ -export async function loadAuth(dir: string): Promise<{ file: string; auth: AuthFn | readonly AuthFn[] } | undefined> { +export async function loadAuth(dir: string): Promise<({ file: string; auth: AuthFn | readonly AuthFn[] } & AgentDirAccessRules) | undefined> { const files = await listSorted(dir, (e) => e.isFile && AUTH_FILE.test(e.name)); if (files.length === 0) return undefined; if (files.length > 1) { throw new SDKError(`loadAgentDir: ${dir} has ${files.join(' and ')}. Keep one auth file.`, 'LOUSHO_AGENT_DIR_INVALID'); } const file = path.join(dir, files[0]); - const auth = (await importModule(file)).default; + const exports = await importModule(file); + const auth = exports.default; const valid = typeof auth === 'function' || (Array.isArray(auth) && auth.every((entry) => typeof entry === 'function')); if (!valid) { throw new SDKError( @@ -28,5 +37,14 @@ export async function loadAuth(dir: string): Promise<{ file: string; auth: AuthF 'LOUSHO_AGENT_DIR_INVALID' ); } - return { file, auth: auth as AuthFn | readonly AuthFn[] }; + const rules: AgentDirAccessRules = {}; + for (const name of ACCESS_RULES) { + const rule = exports[name]; + if (rule === undefined) continue; + if (typeof rule !== 'function') { + throw new SDKError(`loadAgentDir: ${file}: the ${name} export must be a function (see docs/auth.md).`, 'LOUSHO_AGENT_DIR_INVALID'); + } + rules[name] = rule as never; + } + return { file, auth: auth as AuthFn | readonly AuthFn[], ...rules }; } diff --git a/src/deploy/adapters/__fixtures__/node-server-approvals/agent.json b/src/deploy/adapters/__fixtures__/node-server-approvals/agent.json new file mode 100644 index 000000000..57439e315 --- /dev/null +++ b/src/deploy/adapters/__fixtures__/node-server-approvals/agent.json @@ -0,0 +1,5 @@ +{ + "model": "mock/test", + "description": "Files expense claims a manager approves", + "maxSteps": 4 +} diff --git a/src/deploy/adapters/__fixtures__/node-server-approvals/auth.ts b/src/deploy/adapters/__fixtures__/node-server-approvals/auth.ts new file mode 100644 index 000000000..2c43762c2 --- /dev/null +++ b/src/deploy/adapters/__fixtures__/node-server-approvals/auth.ts @@ -0,0 +1,17 @@ +import { apiToken } from '@lousho/build-ai-agent/auth'; +import type { ApprovalAccessRequest, SessionAccessRequest } from '@lousho/build-ai-agent'; + +// One token per user; the principal's id is the user. +export default [apiToken('alice-token', { id: 'alice' }), apiToken('bob-token', { id: 'bob' }), apiToken('manager-token', { id: 'manager' })]; + +// Session ids are `-`; the manager may open any of them. +const ownerOf = (sessionId: string) => sessionId.slice(0, Math.max(sessionId.lastIndexOf('-'), 0)); + +export function authorizeSession({ principal, sessionId }: SessionAccessRequest): boolean { + return principal?.id === 'manager' || (principal !== undefined && ownerOf(sessionId) === principal.id); +} + +// Only the manager decides a claim, and never one of their own. +export function authorizeApproval({ principal, approval }: ApprovalAccessRequest): boolean { + return principal?.id === 'manager' && approval.principal?.id !== 'manager'; +} diff --git a/src/deploy/adapters/__fixtures__/node-server-approvals/instructions.md b/src/deploy/adapters/__fixtures__/node-server-approvals/instructions.md new file mode 100644 index 000000000..32f511cc9 --- /dev/null +++ b/src/deploy/adapters/__fixtures__/node-server-approvals/instructions.md @@ -0,0 +1 @@ +You file expense claims. Every claim waits for a manager's approval. diff --git a/src/deploy/adapters/__fixtures__/node-server-approvals/tools/submit_expense.ts b/src/deploy/adapters/__fixtures__/node-server-approvals/tools/submit_expense.ts new file mode 100644 index 000000000..4d3ddd547 --- /dev/null +++ b/src/deploy/adapters/__fixtures__/node-server-approvals/tools/submit_expense.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { defineTool } from '@lousho/build-ai-agent'; + +// The registry's mock provider calls a tool the user message names, with { input: 'mock input' }. +export default defineTool({ + name: 'submit_expense', + description: 'Files an expense claim', + input: z.object({ input: z.string() }), + needsApproval: true, + execute: () => 'claim filed', +}); diff --git a/src/deploy/adapters/node-server-approvals.test.ts b/src/deploy/adapters/node-server-approvals.test.ts new file mode 100644 index 000000000..ba422706c --- /dev/null +++ b/src/deploy/adapters/node-server-approvals.test.ts @@ -0,0 +1,94 @@ +/** + * `lousho build --target=node-server` with an auth.ts that exports + * `authorizeSession` / `authorizeApproval`: the built server applies them, so + * a manager can decide a user's paused claim and the user cannot approve + * their own. Scaffold + tsup build + dist/server.js as a real subprocess; + * only the LLM is the registry's mock provider. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { NodeServerAdapter } from './node-server'; +import { withBuildLock } from '../buildLock.testkit'; + +const AGENT_DIR = path.join(__dirname, '__fixtures__', 'node-server-approvals'); + +/** Starts dist/server.js on a free port; `stop()` resolves once the process has exited. */ +function startServer(outDir: string): Promise<{ stop: () => Promise; port: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['dist/server.js', '--port=0'], { cwd: outDir, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => reject(new Error(`server did not start: ${stdout} ${stderr}`)), 15_000); + child.stdout.on('data', (chunk) => { + stdout += chunk; + const match = /listening on http:\/\/[^:]+:(\d+)/.exec(stdout); + if (!match) return; + clearTimeout(timer); + const stop = () => + new Promise((done) => { + if (child.exitCode !== null || child.signalCode !== null) return done(); + child.once('exit', () => done()); + child.kill(); + }); + resolve({ stop, port: Number(match[1]) }); + }); + child.stderr.on('data', (chunk) => (stderr += chunk)); + child.on('exit', (code) => reject(new Error(`server exited early (code ${code}): ${stdout} ${stderr}`))); + }); +} + +async function events(response: Response): Promise & { type: string }>> { + return (await response.text()) + .split('\n\n') + .filter((frame) => frame.startsWith('data: ')) + .map((frame) => JSON.parse(frame.slice(6))); +} + +describe('a built agent directory with auth.ts access rules', () => { + let outDir: string; + let server: { stop: () => Promise; port: number }; + + beforeAll(async () => { + outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lousho-approvals-out-')); + await NodeServerAdapter.scaffold(AGENT_DIR, outDir); + await withBuildLock(() => NodeServerAdapter.build(outDir)); + server = await startServer(outDir); + }, 120_000); + afterAll(async () => { + await server?.stop(); + if (outDir) fs.rmSync(outDir, { recursive: true, force: true }); + }); + + const call = (method: string, route: string, token: string, body?: unknown) => + fetch(`http://127.0.0.1:${server.port}${route}`, { + method, + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + ...(body !== undefined && { body: JSON.stringify(body) }), + }); + + it("lets the manager decide alice's claim, and not alice herself", async () => { + const paused = await events(await call('POST', '/chat', 'alice-token', { sessionId: 'alice-1', input: 'submit_expense: taxi, $340' })); + const requested = paused.find((event) => event.type === 'approval.requested') as { approvalId: string } | undefined; + expect(requested, JSON.stringify(paused)).toBeDefined(); + const decide = (token: string) => call('POST', `/chat/alice-1/approvals/${requested!.approvalId}`, token, { approved: true }); + + const self = await decide('alice-token'); + expect(self.status).toBe(403); + expect(await self.json()).toMatchObject({ code: 'LOUSHO_APPROVAL_FORBIDDEN' }); + + const manager = await decide('manager-token'); + expect(manager.status).toBe(200); + expect((await events(manager)).at(-1)).toMatchObject({ type: 'run.done' }); + }, 30_000); + + it("applies authorizeSession: bob cannot read alice's session, the manager can", async () => { + const bob = await call('GET', '/chat/alice-1', 'bob-token'); + expect(bob.status).toBe(403); + expect(await bob.json()).toMatchObject({ code: 'LOUSHO_SESSION_FORBIDDEN' }); + expect((await call('GET', '/chat/alice-1', 'manager-token')).status).toBe(200); + expect((await call('GET', '/chat/bob-1', 'bob-token')).status).toBe(200); + }); +}); diff --git a/src/deploy/adapters/node-server-dir.test.ts b/src/deploy/adapters/node-server-dir.test.ts index 88e11db9a..48d389125 100644 --- a/src/deploy/adapters/node-server-dir.test.ts +++ b/src/deploy/adapters/node-server-dir.test.ts @@ -113,7 +113,9 @@ describe('NodeServerAdapter with an agent directory', () => { it('generates an entry that resolves the directory and passes its schedules and channels to the server', () => { const server = fs.readFileSync(path.join(outDir, 'server.ts'), 'utf8'); expect(server).toContain('resolveAgentDir('); - expect(server).toContain('createDeployedServer(agent, { ...{}, ...(resolved.auth ? { auth: resolved.auth } : {}), schedules: resolved.schedules, channels: resolved.channels })'); + expect(server).toContain( + 'createDeployedServer(agent, { ...{}, ...(resolved.auth ? { auth: resolved.auth } : {}), authorizeSession: resolved.authorizeSession, authorizeApproval: resolved.authorizeApproval, schedules: resolved.schedules, channels: resolved.channels })' + ); expect(fs.existsSync(path.join(outDir, 'agent.config.js'))).toBe(false); }); diff --git a/src/deploy/adapters/node-server.ts b/src/deploy/adapters/node-server.ts index 4a3ac08bf..3bacb3967 100644 --- a/src/deploy/adapters/node-server.ts +++ b/src/deploy/adapters/node-server.ts @@ -123,7 +123,8 @@ import { createAgent, createDeployedServer, resolveAgentDir, storeFromEnv } from const agent = createAgent({ ...resolved.config, store }); await agent.ready();`, // An agent directory's auth.ts replaces the baked token; LOUSHO_API_TOKEN is appended to it. - options: `{ ...${JSON.stringify(options)}, ...(resolved.auth ? { auth: resolved.auth } : {}), schedules: resolved.schedules, channels: resolved.channels }`, + // 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'));`, }; }