backfill: a credential for a commander that predates them

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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-24 13:57:01 +00:00
co-authored by Claude Opus 5
parent 12a9605b01
commit a3a8516e66
3 changed files with 146 additions and 37 deletions
+89
View File
@@ -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:<dirName>`, 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<void> {
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);
+6 -2
View File
@@ -91,8 +91,12 @@ export async function writeCommanderCredential(params: {
"# This commander's own credential. Source it before calling Officer:", "# This commander's own credential. Source it before calling Officer:",
'# set -a; . ./officer.env; set +a', '# set -a; . ./officer.env; set +a',
'#', '#',
'# It is narrowed — see `permissions` on its api_keys row — and it is revoked when this commander is', '# It reaches everything the platform does EXCEPT administering it — no installing plugins, no',
'# deleted. Do not copy it anywhere else; anything that needs one gets its own.', '# 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_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_HOST=${process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL).host : `127.0.0.1:${PORT}`}`,
`OFFICER_API_KEY=${secret}`, `OFFICER_API_KEY=${secret}`,
+51 -35
View File
@@ -27,6 +27,56 @@ export type CommanderClaudeMdParams = {
credentialPath: string | null; 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/<dirName>/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 * 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. * 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', '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.', 'record is the only one. Without it, deleting this commander leaves its loop running.',
'', '',
'## Reaching the platform', ...credentialSection(params.credentialPath),
'',
...(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/<dirName>/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.',
'',
]),
'## Before arming anything', '## Before arming anything',
'', '',
'Never arm a watcher on the first turn. The first turn is for settling what to watch and what to do', 'Never arm a watcher on the first turn. The first turn is for settling what to watch and what to do',