diff --git a/CLAUDE.md b/CLAUDE.md index 6b59e6a2..250e5147 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha - `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds: `core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is - never grantable at any level). **20 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days. + never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 execution, 4 admin. It said 27 for nine days. - `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them, and `confined` stripped for an account with no `osUser`. diff --git a/src/servers/api/sub-agents/router.ts b/src/servers/api/sub-agents/router.ts new file mode 100644 index 00000000..72138d49 --- /dev/null +++ b/src/servers/api/sub-agents/router.ts @@ -0,0 +1,133 @@ +import { createRouter } from '../../create-router'; +import * as errors from '@@/custom-errors'; +import * as sidecar from '@@/sidecar-registry'; +import { chatIdentity } from '../chat/chat'; +import { checkBudget } from './budget'; + +/* + POST /sub-agents/spawn — an agent asks Officer to run another agent. + + ── Why this exists rather than leaning on the harness ── + + A chat agent already has a way to spawn a worker: the harness gives it one. Nothing else does. A watcher + loop, a plugin, a background job, a cron entry — none of them can reach that tool, which is exactly why a + commander's loop shells out to `claude -p` by hand and then has to invent its own record-keeping for what + it started. + + So this is the door Officer defines: the shape, the limits and the vocabulary are the platform's, every + caller gets the same one, and none of it changes when a CLI does. + + ── The condition that comes before the feature ── + + `checkBudget()` runs first and refuses on a floor. The windows belong to the Anthropic account and are + spent by every `claude` on the machine, so an endpoint that can start work without asking would burn the + last of a window and kill the turn the owner is sitting in — a failure that lands on somebody else's + conversation, which is the worst place for it. The reserve is a guess and the honest shape of a guess is a + floor: never spend the last 15%. + + ── What it is NOT ── + + Not a queue, not a supervisor, not a retry. One call starts one agent. Whether that agent's work was any + good is the caller's problem, and a caller that wants three tries writes three calls — because a retry + inside here would be a policy nobody chose, applied to work this endpoint cannot read. +*/ + +export const subAgentsRouter = createRouter(); + +const MAX_PROMPT = 100_000; + +subAgentsRouter.post('/spawn', async (ctx) => { + const user = ctx.get('user'); + const body = ctx.get('body') as { prompt?: string; cwd?: string; model?: string; wait?: boolean }; + + const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''; + if (!prompt) throw errors.BAD_REQUEST('prompt is required'); + if (prompt.length > MAX_PROMPT) throw errors.BAD_REQUEST(`prompt exceeds ${MAX_PROMPT} characters`); + + /* + Refused BEFORE anything is spawned, and the reason travels with the refusal. + + A caller that gets "no room" needs to know which window and how close, or its only move is to try again + immediately — which is the behaviour this is trying to prevent. 429 rather than 403: this is a "not now", + not a "not you". + */ + const budget = await checkBudget(); + if (!budget.ok) { + return ctx.json( + { error: budget.reason ?? 'no budget to start another agent', limits: budget.limits, problem: budget.problem }, + 429, + ); + } + + /* + The identity is resolved from the TOKEN, never from the body. + + `chatIdentity` is what decides the home directory and the OS user the agent runs as, and it is the same + resolution every chat route uses. A caller naming its own email here would be naming whose filesystem to + run in. + */ + const who = await chatIdentity(user); + + // Officer's own handle for the session. The harness's transcript uuid is assigned when the agent first + // reports one, which is after this returns — see the note in the response below. + const sessionKey = crypto.randomUUID(); + const spawn = { + userId: user.id, + email: who.email, + username: user.username ?? who.email.split('@')[0] ?? String(user.id), + prompt, + sessionKey, + ...(body?.cwd ? { cwd: body.cwd } : {}), + ...(body?.model ? { model: body.model } : {}), + /* + Stated explicitly, never omitted. The field is `required-but-may-be-undefined` on purpose — an optional + one let a call site forget it, and forgetting it means a member's agent silently running as the owner in + the owner's home. `undefined` here says "the owner", and that is only reached when `chatIdentity` says + there is no Linux user to run as. + */ + member: who.osUser ? { osUser: who.osUser, home: who.home } : undefined, + }; + + try { + /* + Two modes, and the default is the one a loop wants. + + `wait: false` returns as soon as the agent is running, so a watcher can start work and get on with + watching. `wait: true` blocks for the answer, which is right for a question with a short answer and + wrong for anything that touches a fleet — the request would hold a socket open for the length of the + work. + */ + if (body?.wait === true) { + const result = await sidecar.spawnClaude(spawn); + return ctx.json({ sessionKey, result }); + } + await sidecar.spawnClaudeStreaming(spawn); + /* + `sessionKey` and not a transcript id, because the transcript does not exist yet. + + The agent announces its own uuid on its first turn, which is after this responds. A caller that needs + the transcript — to open `/chat/`, or to record what it started — reads it from the live session + list a moment later. Returning a guess here, or blocking until it arrived, would both be worse than + saying which id this is. + */ + return ctx.json({ sessionKey, running: true }); + } catch (err) { + // The agent sidecar being down is a 503, not a 500: nothing about the request was wrong and retrying it + // later is the correct response, which is a distinction a caller can act on. + // 503 rather than 500: nothing about the request was wrong, and retrying it later is the correct + // response — a distinction the caller can act on. + throw errors.createError(503, err instanceof Error ? err.message : 'could not start an agent'); + } +}); + +/** + * GET /sub-agents/budget — is there room to start one, without starting one. + * + * Separate from the spawn so a caller can decide before it commits. A watcher that would rather skip a cycle + * than be refused mid-flight asks this first; the spawn checks again regardless, because between the two + * calls somebody else may have spent the window. + */ +subAgentsRouter.get('/budget', async (ctx) => { + const budget = await checkBudget(); + return ctx.json(budget); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index fe315a69..4a51ca28 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -28,6 +28,7 @@ import { agentHandoffRouter } from './api/agent-handoff/router'; import { terminalRouter } from './api/terminal/sidecar-server'; import { minesweeperRouter } from './api/minesweeper/router'; import { sendoffRouter } from './api/sendoff/router'; +import { subAgentsRouter } from './api/sub-agents/router'; import { caldavRouter } from './api/dav/sidecar-server'; // import { memosRouter } from './api/memos/router'; import { appStoreRouter } from './api/app-store/router'; @@ -143,6 +144,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType ['/terminal', terminalRouter], ['/minesweeper', minesweeperRouter], ['/sendoff', sendoffRouter], + ['/sub-agents', subAgentsRouter], // ['/memos', memosRouter], // plugin — switched off 2026-08-13 ['/app-store', appStoreRouter], ['/plugins', pluginsRouter], diff --git a/src/servers/permissions/registry.test.ts b/src/servers/permissions/registry.test.ts index 7d5eb58e..356af321 100644 --- a/src/servers/permissions/registry.test.ts +++ b/src/servers/permissions/registry.test.ts @@ -241,8 +241,19 @@ describe('kinds', () => { // both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed // on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential, // transcripts and session ownership. `chat` is now confined in fact and not only in the registry. + // + // `sub-agents` joined on 2026-08-23, and it is confined IN FACT on the day it was added rather than ahead of + // itself. `POST /sub-agents/spawn` resolves the identity through `chatIdentity` from the caller's token and + // passes `member: { osUser, home }` to the sidecar — the same mechanism `chat` runs under, reached by the + // same resolver. It inherits the containment rather than claiming it: there is no path in that endpoint that + // chooses a directory, so there is nothing for a caller to point somewhere else. test('confined is a short, deliberate list', () => { - expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']); + expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual([ + 'terminal', + 'chat', + 'files', + 'sub-agents', + ]); }); test('admin permissions are never grantable', () => { diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 2ec5c9b2..2822c503 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -360,6 +360,24 @@ const CORE_REGISTRY: Permission[] = [ person who should decide whether somebody may put a file in your home is you, not whoever administers roles. This grant only says the feature is reachable at all. */ + /* + `confined`, and for the same reason chat is. + + Spawning an agent runs a process in a home directory — the caller's own, resolved by `chatIdentity` + from their token. So it needs exactly what chat needs: a Linux account for a member, and nothing for + the owner, with the kernel doing the containing. `authorize.ts` drops a confined grant for an account + whose `osUser` is null, which is what stops "granted but unconfined" resolving to the owner's home. + + Not `execution`: that kind exists for surfaces that can ONLY ever run as the owner. This one runs as + whoever called it, which is the whole distinction. + */ + { + key: 'sub-agents', + label: 'Sub-agents', + description: 'Start another agent from a prompt, subject to the account budget', + kind: 'confined', + api: ['/sub-agents'], + }, { key: 'sendoff', label: 'SendOff',