import { createApiKey, revokeApiKeysByName } from 'officerdb'; import { mintApiKey } from '@@/auth-token'; import { PORT, OFFICER_API_URL } from '../officer-url.mjs'; /** * A credential for an agent, carrying its holder's authority minus administration. * * Shared by both harnesses, and it lived under `sidecar/claude/` until 2026-08-27 only because Claude was * the first to need one. Nothing in it is Claude-shaped. * * ── The gap it closes ── * * A commander has `officer.env` and can reach the platform. An ordinary chat agent has nothing: the only * token in the tree lives in the MCP server's environment, which the agent's own Bash cannot see. So an * agent asked to tell its owner something had no door at all — it either could not, or it asked to be * handed a personal API key, which is the owner's full authority pasted into a transcript. * * ── It was `['notify']` until 2026-08-25, and the reason it stopped being enough ── * * The original argument was that "an agent may tell me things" is far smaller than "an agent may act as * me", and that everything else an agent needs it already has, because it runs as somebody, in their home, * with their files. Both halves were true. The second half is also what undid the first. * * An owner's agent has a shell AS THE OWNER, unsandboxed. It can read `.env`, query Postgres, decrypt a * plugin's stored credential, `docker exec` into a container. So a key scoped to `notify` never made that * agent safer — it made it unable to use the platform POLITELY while every impolite route stayed open. The * moment that became visible: a chat asked to take a proxy host out of service correctly reported it could * not, having read this file, while sitting one `docker exec` away from the same container. * * A route that goes through the platform is authorised, logged, and bounded by a permission. One that goes * around it is none of those. Withholding the first does not prevent the second; it only chooses which one * gets used. * * ── What it carries now ── * * Its holder's own authority, minus `admin`. Not a list of permissions — a rule, resolved against the live * registry at request time, so a plugin installed next year works from a chat without this file changing. * The alternative on offer was adding each plugin's key to an array here, which puts plugin names in a core * file and hands every plugin to every chat, one line at a time, forever. * * `execution` needs no exclusion: it is never grantable at any level and `authorize.ts` strips it * regardless. `admin` is the one worth naming — an agent may use the server, and may not administer it. * Creating accounts, granting permissions and installing plugins stay with a person. * * ── This is narrower for a MEMBER, and that is where it matters ── * * A member's agent runs as their own Linux user, confined by the kernel, without the owner's environment. * For them this key is a real boundary rather than a courtesy: it resolves to exactly what their role was * granted, so a chat can reach what its owner allowed and nothing else. That is the shape the owner asked * for — "the features provided by the platform, including the ones that I allow for each user". * * ── One per SESSION for Claude, one per PROCESS for OpenCode ── * * Claude spawns a subprocess per session, so its key is named for the session, dies with it, and a member's * key resolves to exactly what their role was granted. OpenCode cannot do that: ONE `opencode serve` runs * every session, so the only environment there is is the serve's own, minted once at sidecar start. * * That is sound today for exactly one reason — OpenCode is owner-only, enforced at every door in * `api/chat/chat.ts`. The key is the owner's because every session is. **When that restriction lifts, this * key becomes a privilege escalation**: a member's turn would hold the owner's authority. It is not the * first thing that has to change — a member's OpenCode turn already runs as the service user in the owner's * home — but it belongs on the same list, and the note in `chat.ts` names it. * * ── Why an env var rather than a file ── * * It dies with the process. There is no path to leak, nothing to clean up after a crash, and no file for a * later session to find. The commander's own credential is a file precisely because it must OUTLIVE the * turn that made it — a detached loop reads it hours later. A chat agent has no such need. * * ── Lifetime ── * * Named for the session, so re-arming the same conversation replaces its key rather than accumulating one * per turn, and a key outliving its session is findable by name rather than being one of a hundred * identical rows. Also expires on its own: a session that ends without cleanup — a crash, a restart — must * not leave a live credential behind for a month. */ /** * Kinds this key may never reach, resolved against the registry on every request rather than expanded * here. See `api_keys.excludedKinds` — it is checked BEFORE the owner bypass, which is what makes it the * only thing capable of bounding a Super Admin's agent at all. */ const SESSION_KEY_EXCLUDED_KINDS = ['admin']; /** A week. Long enough for a conversation somebody returns to, short enough that a leaked one dies. */ const SESSION_KEY_DAYS = 7; export const sessionKeyName = (sessionKey: string) => `session:${sessionKey}`; export type AgentApiEnv = { OFFICER_API_URL: string; OFFICER_API_HOST: string; OFFICER_AGENT_KEY: string; }; /** * Mint one, replacing any previous key for this session. * * Best effort by contract: a turn must still start if the database refuses. The agent then simply has no * key, which is the state everything was in before this existed — degradation rather than failure. */ export async function mintSessionCredential(userId: number, sessionKey: string): Promise { try { const name = sessionKeyName(sessionKey); await revokeApiKeysByName(userId, name); const { secret, keyHash, prefix } = mintApiKey(); const expiresAt = new Date(Date.now() + SESSION_KEY_DAYS * 24 * 60 * 60 * 1000); // `permissions: null` — no allow list. The narrowing is the kind rule below, so this key follows its // holder's grants as they change rather than freezing a snapshot of them at mint time. await createApiKey({ userId, name, keyHash, prefix, permissions: null, excludedKinds: SESSION_KEY_EXCLUDED_KINDS, expiresAt, }); const publicHost = (() => { try { return process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL.replace(/^"|"$/g, '')).host : undefined; } catch { return undefined; } })(); return { OFFICER_API_URL, // Requests go to 127.0.0.1, but origin validation matches Host against PUBLIC_URL — so a bare // loopback request is refused without this. Same reason `task-api-env.ts` carries it. OFFICER_API_HOST: publicHost ?? `127.0.0.1:${PORT}`, OFFICER_AGENT_KEY: secret, }; } catch (err) { console.error('[claude] could not mint a session credential — the agent will have no API key', err); return null; } } /** Drop it when the session ends. Never fatal: the expiry above is the backstop. */ export async function revokeSessionCredential(userId: number, sessionKey: string): Promise { await revokeApiKeysByName(userId, sessionKeyName(sessionKey)).catch(() => {}); }