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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ opt-out.
- `lousho doctor` fix lines use PowerShell syntax (`$env:NAME="<your key>"`) 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

Expand Down
2 changes: 2 additions & 0 deletions api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions docs/agent-directories.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<family>.md`), `skills/`
and a JSON/YAML config (the same in each sub-agent) are copied next to them, so the server needs no TypeScript
Expand Down
45 changes: 44 additions & 1 deletion docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <basePath>` and the
`useChat` endpoint) or decides one of its approvals (`'approve'`). Return
`false` for a `403` (`LOUSHO_SESSION_FORBIDDEN`). Deriving the id from the
Expand Down Expand Up @@ -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 `<user id>-<suffix without '-'>`.
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`),
Expand Down
7 changes: 6 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
61 changes: 57 additions & 4 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<family>.md`), `skills/`
and a JSON/YAML config (the same in each sub-agent) are copied next to them, so the server needs no TypeScript
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <basePath>` and the
`useChat` endpoint) or decides one of its approvals (`'approve'`). Return
`false` for a `403` (`LOUSHO_SESSION_FORBIDDEN`). Deriving the id from the
Expand Down Expand Up @@ -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 `<user id>-<suffix without '-'>`.
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`),
Expand Down Expand Up @@ -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 \
Expand Down
18 changes: 16 additions & 2 deletions src/agentDir/loadAgentDir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -622,7 +634,8 @@ async function skillsFor(dir: string, overrides: AgentDirOverrides): Promise<Ski
* schedules/*.ts|js each default-exports defineSchedule(); run with startSchedules()
* channels/*.ts|js each default-exports a channel (defineChannel(), webhookChannel(), ...)
* memory/*.ts|js each default-exports a memory slot (defineMemory()); part of the agent
* auth.ts | auth.js default-exports route auth (jwt(), oidc(), basic(), ...); the deployed server uses it
* auth.ts | auth.js default-exports route auth (jwt(), oidc(), basic(), ...); the deployed server uses it,
* and its named authorizeSession / authorizeApproval exports
* ```
*
* Loading executes the directory's code. Only load directories you trust.
Expand All @@ -641,7 +654,8 @@ export async function resolveAgentDir(
const resolved = await resolveWith(dir, overrides, {});
const auth = await loadAuth(resolved.manifest.dir);
if (!auth) return resolved;
return { ...resolved, auth: auth.auth, manifest: { ...resolved.manifest, auth: true, files: [...resolved.manifest.files, auth.file] } };
const { file, ...access } = auth;
return { ...resolved, ...access, manifest: { ...resolved.manifest, auth: true, files: [...resolved.manifest.files, file] } };
}

/**
Expand Down
23 changes: 23 additions & 0 deletions src/agentDir/loadAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,29 @@ describe('agent directory auth.ts', () => {
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');
Expand Down
Loading
Loading