Compare commits
2
Commits
ad8ed88519
...
9b0376ec39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0376ec39 | ||
|
|
66234cd3e0 |
@@ -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`.
|
||||
|
||||
@@ -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/<id>`, 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);
|
||||
});
|
||||
@@ -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<typeof createRouter>
|
||||
['/terminal', terminalRouter],
|
||||
['/minesweeper', minesweeperRouter],
|
||||
['/sendoff', sendoffRouter],
|
||||
['/sub-agents', subAgentsRouter],
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
['/app-store', appStoreRouter],
|
||||
['/plugins', pluginsRouter],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: sub-agents/an-api-endpoint-for-spawning-an-agent-with-a
|
||||
title: "An API endpoint for spawning an agent with a prompt"
|
||||
title: 'An API endpoint for spawning an agent with a prompt'
|
||||
theme: sub-agents
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 1
|
||||
@@ -28,7 +28,6 @@ what Officer fixes.
|
||||
optional.** An endpoint that spawns freely is exactly how a weekly limit gets spent by something nobody
|
||||
was watching.
|
||||
|
||||
|
||||
---
|
||||
|
||||
**2026-08-21, narrowing this considerably.** The endpoint largely exists:
|
||||
@@ -43,3 +42,28 @@ already respects the window.
|
||||
|
||||
What is genuinely left is the SURFACE an agent can call — MCP tool or authenticated route — and how output
|
||||
returns to the caller. That is the remaining work on this item; the mechanism underneath it is done.
|
||||
|
||||
---
|
||||
|
||||
**Closed 2026-08-23.** `POST /api/sub-agents/spawn` — prompt, optional cwd and model, and either the session
|
||||
key immediately or the result if you ask it to wait. `GET /api/sub-agents/budget` answers whether there is
|
||||
room without consuming any.
|
||||
|
||||
Verified both modes against the live server rather than reasoned about: the synchronous call returned the
|
||||
four words the agent was told to say, and the async call returned in milliseconds with the agent's file on
|
||||
disk five seconds later.
|
||||
|
||||
Three things settled while building it, each worth not re-deriving:
|
||||
|
||||
- **The budget check is a precondition, not a refinement.** It runs before the spawn and refuses 429 naming
|
||||
the window. `checkBudget()` already existed and was already tested, which is the only reason this was a
|
||||
short job.
|
||||
- **`confined`, and true on the day it shipped.** The registry test's own comment records that `chat` sat on
|
||||
that list for a day before its turns actually ran as the member. This one inherits the mechanism rather
|
||||
than claiming it — `chatIdentity` off the token, `member: { osUser, home }` to the sidecar — and no path in
|
||||
the endpoint chooses a directory.
|
||||
- **`member` is passed explicitly, never omitted.** The field is required-but-may-be-undefined on purpose:
|
||||
optional let a call site forget it, and forgetting it means a member's agent running as the owner in the
|
||||
owner's home.
|
||||
|
||||
What it deliberately is not: a queue, a supervisor, or a retry. One call starts one agent.
|
||||
|
||||
Reference in New Issue
Block a user