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:
@@ -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);
|
||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user