From a3a8516e66ccd8b45b3b0415c1c0db493b2771ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 24 Aug 2026 13:57:01 +0000 Subject: [PATCH] backfill: a credential for a commander that predates them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a commander made before credentials existed has no officer.env and no key row, so it cannot reach the platform at all — the state that made the first one curl whisper directly. deleting and recreating it would fix that and throw away the conversation, which is the one thing a commander IS: a long-running agreement about a job. so this is additive and re-runnable. a commander that already has one is left alone. CLAUDE.md is APPENDED to, never rewritten — it is a living document its commander has been extending, and surviving is the whole point of not recreating it — and only when it does not already mention officer.env, so a hand-edited one does not end up with the section twice. the credential section moved out of commanderClaudeMd for this: it is now written in two places, and the backfill needs a self-contained section rather than a slice of one file's layout. verified locally: minted for the one commander here, second run reported "already has a credential" and changed nothing, file is 0600. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- scripts/backfill-commander-credentials.ts | 89 +++++++++++++++++++++++ src/servers/api/command-hq/credential.ts | 8 +- src/servers/api/command-hq/directory.ts | 86 +++++++++++++--------- 3 files changed, 146 insertions(+), 37 deletions(-) create mode 100644 scripts/backfill-commander-credentials.ts diff --git a/scripts/backfill-commander-credentials.ts b/scripts/backfill-commander-credentials.ts new file mode 100644 index 00000000..de51c464 --- /dev/null +++ b/scripts/backfill-commander-credentials.ts @@ -0,0 +1,89 @@ +/* + Give a credential to every commander that predates them. + + A commander created before `credential.ts` existed has no `officer.env` and no key row, so it cannot + reach the platform at all — which is the state that made the first one curl the whisper container + directly. Deleting and recreating it would fix that and throw away the conversation, which is the one + thing a commander is: a long-running agreement about a job. + + So this is additive and re-runnable: + + - a commander that already has `officer.env` is left alone, whole; + - `CLAUDE.md` is APPENDED to, never rewritten — it is a living document its commander has been + extending, and the whole point of not recreating it is that it survives; + - the key is named `commander:`, so it revokes with the commander like any other. + + Run it after a deploy that introduces credentials, on each host: + + bun run --env-file=.env scripts/backfill-commander-credentials.ts +*/ +import { readFileSync, existsSync, appendFileSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; +import { getUsers, listCommandHqSessions } from 'officerdb'; +import { writeCommanderCredential, commanderEnvPath } from '../src/servers/api/command-hq/credential'; +import { credentialSection } from '../src/servers/api/command-hq/directory'; +import { getCommanderDir } from '../src/servers/data-path'; +import { lookupOsUser } from '../src/servers/os-user'; + +/** Best effort, and only for a member: the owner already owns everything under DATA_PATH. */ +async function giveToAccount(osUser: string | null, path: string): Promise { + if (!osUser) return; + const ids = await lookupOsUser(osUser); + if (!ids) return; + Bun.spawnSync(['sudo', '-n', 'chown', `${ids.uid}:${ids.gid}`, path]); +} + +// Per user, because `listCommandHqSessions` is scoped by one — which is the right shape everywhere else +// and only awkward here. There are few accounts and few commanders; this runs once per deploy. +const rows: Array<{ + dirName: string | null; + name: string | null; + userId: number; + email: string; + osUser: string | null; +}> = []; +for (const user of await getUsers()) { + for (const row of await listCommandHqSessions(user.id)) { + rows.push({ dirName: row.dirName, name: row.name, userId: user.id, email: user.email, osUser: user.osUser }); + } +} + +if (rows.length === 0) console.log('no commanders on this host'); + +for (const row of rows) { + const label = `${row.dirName} (${row.name})`; + + if (!row.dirName) { + console.log(`skip ${label} — no directory name`); + continue; + } + + const dir = getCommanderDir(row.email, row.dirName); + if (!existsSync(dir)) { + console.log(`skip ${label} — no directory on disk`); + continue; + } + + const envPath = commanderEnvPath(row.email, row.dirName); + if (existsSync(envPath)) { + console.log(`ok ${label} — already has a credential`); + continue; + } + + const { path } = await writeCommanderCredential({ userId: row.userId, email: row.email, dirName: row.dirName }); + chmodSync(path, 0o600); + await giveToAccount(row.osUser, path); + + // Appended only when it is not already described. A commander whose CLAUDE.md was hand-edited to + // mention its credential must not end up with the section twice. + const claudeMd = join(dir, 'CLAUDE.md'); + const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : ''; + if (!existing.includes('officer.env')) { + appendFileSync(claudeMd, `\n${credentialSection(path).join('\n')}`); + await giveToAccount(row.osUser, claudeMd); + } + + console.log(`MINT ${label} — ${path}`); +} + +process.exit(0); diff --git a/src/servers/api/command-hq/credential.ts b/src/servers/api/command-hq/credential.ts index b58eeec3..7708364b 100644 --- a/src/servers/api/command-hq/credential.ts +++ b/src/servers/api/command-hq/credential.ts @@ -91,8 +91,12 @@ export async function writeCommanderCredential(params: { "# 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.', + '# It reaches everything the platform does EXCEPT administering it — no installing plugins, no', + '# managing users or roles, no server settings. That holds for anything added later too: the rule is', + '# resolved per request, not frozen into a list.', + '#', + '# 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}`, diff --git a/src/servers/api/command-hq/directory.ts b/src/servers/api/command-hq/directory.ts index 0d88de75..03f75e5c 100644 --- a/src/servers/api/command-hq/directory.ts +++ b/src/servers/api/command-hq/directory.ts @@ -27,6 +27,56 @@ export type CommanderClaudeMdParams = { credentialPath: string | null; }; +/** + * The section telling a commander it has a credential, where it is, and what it must not do with it. + * + * Exported because it is written in two places: at creation, and by + * `scripts/backfill-commander-credentials.ts` for a commander that predates credentials existing. That + * backfill APPENDS it to a `CLAUDE.md` its commander has been extending for days, so this has to be a + * self-contained section rather than a slice of one file's layout. + */ +export function credentialSection(credentialPath: string | null): string[] { + if (!credentialPath) { + return [ + '## Reaching the platform', + '', + 'This commander has NO credential — minting one failed. It cannot reach the platform API, and it', + 'must not work around that by calling a sidecar directly. Tell the owner.', + '', + ]; + } + + return [ + '## Reaching the platform', + '', + 'This commander has its own credential, and it is the ONLY way it should reach anything:', + '', + '```', + `set -a; . ${credentialPath}; set +a`, + 'curl -H "Authorization: Bearer $OFFICER_API_KEY" -H "Host: $OFFICER_API_HOST" \\', + ' "$OFFICER_API_URL/api/..."', + '```', + '', + 'Use the platform, never the thing behind it. A sidecar — whisper, a media server, anything a plugin', + 'wraps — usually also listens on localhost with no authentication, so calling it directly works and', + 'looks correct. It is still wrong: it skips every permission, every setting the owner chose and every', + 'log, and it breaks silently the day that service moves.', + '', + 'Prefer a TASK over a raw call where one exists. `GET /api/tasks` lists them and', + '`POST /api/tasks//run` runs one and returns what it did — one reviewed definition on disk', + 'instead of a re-implementation living in a loop nobody reads.', + '', + 'It reaches everything the platform does EXCEPT administering it — installing plugins, managing users', + 'or roles, changing server settings. That holds for features added later too: the rule is resolved per', + 'request rather than frozen into a list, so a plugin installed next year is already covered without', + 'this commander being re-issued.', + '', + 'A 403 is an answer, not a problem to route around. It means this commander was not given that, and', + 'the owner is the one who decides otherwise. Say so rather than finding another way in.', + '', + ]; +} + /** * The file itself. Prose, not a config dump — this is a document the commander reads once and then keeps * extending, and the owner may open it directly. @@ -67,41 +117,7 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string { 'The platform cannot find this process and cannot recover a spawned agent id after it exits, so this', 'record is the only one. Without it, deleting this commander leaves its loop running.', '', - '## Reaching the platform', - '', - ...(params.credentialPath - ? [ - 'This commander has its own credential, and it is the ONLY way it should reach anything:', - '', - '```', - `set -a; . ${params.credentialPath}; set +a`, - 'curl -H "Authorization: Bearer $OFFICER_API_KEY" -H "Host: $OFFICER_API_HOST" \\', - ' "$OFFICER_API_URL/api/..."', - '```', - '', - 'Use the platform, never the thing behind it. A sidecar — whisper, a media server, anything a', - 'plugin wraps — usually also listens on localhost with no authentication, so calling it directly', - 'works and looks correct. It is still wrong: it skips every permission, every setting the owner', - 'chose and every log, and it breaks silently the day that service moves.', - '', - 'Prefer a TASK over a raw call where one exists. `GET /api/tasks` lists them and', - '`POST /api/tasks//run` runs one and returns what it did — one reviewed definition on', - 'disk instead of a re-implementation living in a loop nobody reads.', - '', - 'It reaches everything the platform does EXCEPT administering it — installing plugins, managing', - 'users or roles, changing server settings. That holds for features added later too: the rule is', - 'resolved per request rather than frozen into a list, so a plugin installed next year is already', - 'covered without this commander being re-issued.', - '', - 'A 403 is an answer, not a problem to route around. It means this commander was not given that,', - 'and the owner is the one who decides otherwise. Say so rather than finding another way in.', - '', - ] - : [ - 'This commander has NO credential — minting one failed at creation. It cannot reach the platform', - 'API, and it must not work around that by calling a sidecar directly. Tell the owner.', - '', - ]), + ...credentialSection(params.credentialPath), '## Before arming anything', '', 'Never arm a watcher on the first turn. The first turn is for settling what to watch and what to do',