import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { createApiKey, revokeApiKeysByName } from 'officerdb'; import { mintApiKey } from '@@/auth-token'; import { PORT, OFFICER_API_URL } from '../../officer-url.mjs'; import { getCommanderDir } from '../../data-path'; /* A commander's own credential. ── Why it has one at all ── A commander is an agent working while nobody watches, and it has to reach the platform to do anything worth doing. Until now it could not: the only credential in the tree sits in the MCP tool server's environment, which the agent's own shell cannot see. So the first commander built here did the reasonable thing from where it was standing and curled the whisper container directly on localhost — correct answer, wrong door, and a door the platform does not control. ── Why it is narrowed ── The owner bypasses every permission check, so a credential carrying the owner's identity is unbounded. That is fine for a browser session somebody is sitting in front of and wrong for a process that runs for months unattended. `api_keys.permissions` is the only thing that can bound a Super Admin — see `permissions/authorize.ts` → `decideApiRequest`, which applies it BEFORE the owner short-circuit. ── Why it excludes a KIND rather than listing what it allows ── A commander's job is open-ended and grows. It should send a notification, send mail, spawn a sub-agent, hand a transcription to an idle one — and reach a plugin installed six months from now without being re-issued. What it should never do is administer the server it runs on. An allow list cannot express that. A list is a snapshot of what existed on the day it was written, so a plugin added afterwards is simply missing from it, and the failure arrives as a bare 403 with nothing saying why. Every future capability would land on that. So the default is a rule: every kind except `admin`. It is resolved against the live registry on each request, which means it keeps meaning the same thing as the platform grows — bounded away from installing plugins, managing users and roles and changing server settings, forever, and open to everything else the platform learns to do. A DEFAULT, not a policy: it is stored on the key row and can be tightened per commander with an explicit allow list alongside it. And for a member it narrows itself, because the check is an intersection with what their role holds. */ /** What a commander gets unless told otherwise: everything the platform can do, except administering it. */ export const COMMANDER_EXCLUDED_KINDS = ['admin']; /** The env file's path, and the name the key is stored under — both derived, so revocation cannot miss one. */ export const commanderKeyName = (dirName: string) => `commander:${dirName}`; export const commanderEnvPath = (email: string, dirName: string) => join(getCommanderDir(email, dirName), 'officer.env'); /** * Mint a commander's key and write it into its directory. * * The secret is written to a FILE rather than injected into the agent's environment because the agent is * not the only thing that needs it: a commander arms a detached loop, and that loop is spawned by the * agent itself and inherits nothing the platform chose. A file both can read is the only shape that * covers the interactive turn and the unattended loop with one credential. * * 0600, and `createCommanderDirectory` chowns the whole directory to the account afterwards — which is * why this must run BEFORE that chown, not after. */ export async function writeCommanderCredential(params: { userId: number; email: string; dirName: string; /** An explicit allow list, to tighten one commander further. Absent leaves it at the default rule. */ permissions?: string[] | null; }): Promise<{ path: string; excludedKinds: string[] }> { const { secret, keyHash, prefix } = mintApiKey(); // Revoke first. A commander recreated under a directory name that has been used before would otherwise // leave a live key nothing points at any more. await revokeApiKeysByName(params.userId, commanderKeyName(params.dirName)).catch(() => {}); await createApiKey({ userId: params.userId, name: commanderKeyName(params.dirName), keyHash, prefix, permissions: params.permissions ?? null, excludedKinds: COMMANDER_EXCLUDED_KINDS, }); const path = commanderEnvPath(params.email, params.dirName); mkdirSync(join(path, '..'), { recursive: true }); writeFileSync( path, [ "# This commander's own credential. Source it before calling Officer:", '# set -a; . ./officer.env; set +a', '#', '# It is narrowed — see `permissions` on its api_keys row — and it is revoked when this commander is', '# deleted. Do not copy it anywhere else; anything that needs one gets its own.', `OFFICER_API_URL=${OFFICER_API_URL}`, `OFFICER_API_HOST=${process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL).host : `127.0.0.1:${PORT}`}`, `OFFICER_API_KEY=${secret}`, '', ].join('\n'), { mode: 0o600 }, ); return { path, excludedKinds: COMMANDER_EXCLUDED_KINDS }; }