Files
platform/src/servers/sidecar/agent-credential.ts
T
pastilhasandClaude Opus 5 e6da5d8c77 opencode gets the skills and a credential, which it had neither of
an opencode agent could not have used the calendar skill written this morning,
and would not have failed usefully either — it had no idea the skill existed and
no key to call the platform with. two gaps, one file each.

SKILLS: OPENCODE ALREADY HAD THE MECHANISM. it has a `skill` tool, an index
compiled into its system prompt, and `skills.paths` in config taking directories
of <name>/SKILL.md — the same progressive disclosure claude does over the same
layout the platform already stores. so this points it at the SAME FILES rather
than compiling a second index in a second format. skills/load.ts says a skill is
a path because every harness can read a file; this is the better version of that
bet — the harness had the feature all along. verified: `/skill?directory=…` now
returns all nine platform skills, "Calendar and contacts" among them.

THE CREDENTIAL, AND THE THING TO KNOW ABOUT IT. claude spawns a subprocess per
session, so its key is per session and a member's resolves to their own grants.
opencode cannot: ONE serve runs every session, so the only environment there is
is the serve's own, minted once at sidecar start, belonging to the owner.

that is sound today for exactly one reason — opencode is owner-only, enforced at
every door in chat.ts because one serve runs as one user. when that lifts, this
key becomes a privilege escalation. it is the same blocker as the two already
there, not a new one, but it lives in a sidecar where nobody looking at chat.ts
would find it, so chat.ts now names it. all three move together.

verified end to end: a tool process inside an opencode session ran curl with
$OFFICER_AGENT_KEY and got the owner's calendars back.

OPENCODE_CONFIG_CONTENT rather than editing ~/.config/opencode/opencode.jsonc —
it is inline json, it MERGES into the same chain as the config files (checked in
the binary), and it dies with the process. the alternative is the platform
editing a dotfile it does not own and leaving state behind that outlives the
install.

affordances ride the `instructions` key as one composed file, written beside the
serve's state. loaded — `/config` shows it — but NOT model-verified: this box has
no provider credential in auth.json, so a turn 401s before reaching a model.
whoever adds one should check that first.

the credential module moved out of sidecar/claude/. it was never claude-shaped;
it was just there first, and it had one importer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 11:13:00 +00:00

145 lines
7.4 KiB
TypeScript

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<AgentApiEnv | null> {
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<void> {
await revokeApiKeysByName(userId, sessionKeyName(sessionKey)).catch(() => {});
}