Compare commits
2
Commits
| 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:
|
- `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
|
`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
|
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
|
- `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,
|
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`.
|
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 { terminalRouter } from './api/terminal/sidecar-server';
|
||||||
import { minesweeperRouter } from './api/minesweeper/router';
|
import { minesweeperRouter } from './api/minesweeper/router';
|
||||||
import { sendoffRouter } from './api/sendoff/router';
|
import { sendoffRouter } from './api/sendoff/router';
|
||||||
|
import { subAgentsRouter } from './api/sub-agents/router';
|
||||||
import { caldavRouter } from './api/dav/sidecar-server';
|
import { caldavRouter } from './api/dav/sidecar-server';
|
||||||
// import { memosRouter } from './api/memos/router';
|
// import { memosRouter } from './api/memos/router';
|
||||||
import { appStoreRouter } from './api/app-store/router';
|
import { appStoreRouter } from './api/app-store/router';
|
||||||
@@ -143,6 +144,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
|||||||
['/terminal', terminalRouter],
|
['/terminal', terminalRouter],
|
||||||
['/minesweeper', minesweeperRouter],
|
['/minesweeper', minesweeperRouter],
|
||||||
['/sendoff', sendoffRouter],
|
['/sendoff', sendoffRouter],
|
||||||
|
['/sub-agents', subAgentsRouter],
|
||||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||||
['/app-store', appStoreRouter],
|
['/app-store', appStoreRouter],
|
||||||
['/plugins', pluginsRouter],
|
['/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
|
// 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,
|
// 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.
|
// 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', () => {
|
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', () => {
|
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
|
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.
|
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',
|
key: 'sendoff',
|
||||||
label: 'SendOff',
|
label: 'SendOff',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
id: sub-agents/an-api-endpoint-for-spawning-an-agent-with-a
|
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
|
theme: sub-agents
|
||||||
status: open
|
status: done
|
||||||
created: 2026-08-21
|
created: 2026-08-21
|
||||||
date_source: stated
|
date_source: stated
|
||||||
order: 1
|
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
|
optional.** An endpoint that spawns freely is exactly how a weekly limit gets spent by something nobody
|
||||||
was watching.
|
was watching.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**2026-08-21, narrowing this considerably.** The endpoint largely exists:
|
**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
|
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.
|
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