Files
platform/scripts/backfill-commander-credentials.ts
T
pastilhasandClaude Opus 5 276b12607a every brief the platform writes, where both harnesses will find it
opencode discovers AGENTS.md and nothing else — its only target is
H.up({targets:["AGENTS.md"]}), verified in the binary. claude code discovers
CLAUDE.md. no overlap.

every brief the platform wrote was CLAUDE.md, so under opencode a commander got
none of its standing brief, a bot none of its arming instructions, and a plugin
chat none of its plugin's guidance. nothing failed and nothing was logged: the
agent simply knew nothing, which is indistinguishable from the feature not
existing, and is why it lasted this long. commanders can already run under
opencode, so this was live.

writeAgentBrief puts the content in AGENTS.md and a POINTER in CLAUDE.md. a
pointer rather than two copies because these are rewritten every turn, two
copies of that will eventually disagree, and the stale one is the one somebody
reads.

three writers, and the third needed a flag the others must not have:

  bots          rewritten every turn ON PURPOSE, so a template change reaches them
  plugin chats  same
  commanders    onlyIfAbsent — a commander EXTENDS this file over its life, and
                rewriting it when the template changed would delete the job it
                had agreed with the owner

two things that would have quietly defeated it. the file listing handed to a
commander said "CLAUDE.md — this file", which would have had it editing the
pointer. and backfill-commander-credentials appended to CLAUDE.md, which on a
new commander means burying a credential section inside six lines nobody reads —
it targets AGENTS.md where one exists, CLAUDE.md where it does not, so old
commanders still work.

plugins ship these too: email and proxy-manager's chat/CLAUDE.md are now
chat/AGENTS.md. the platform accepts BOTH source names, because a plugin is its
own repository on its own release cadence and dropping the old name would
un-brief any installed copy that had not updated — silently, again.

tested: both files written, pointer is not a second copy, idempotent, rewrites
on change, and onlyIfAbsent leaves an extended brief alone. verified on disk
after a restart.

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

114 lines
4.7 KiB
TypeScript

/*
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, notifySection } 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;
}
/*
AGENTS.md where it exists, CLAUDE.md where it does not.
Since 2026-08-27 a commander's brief is written as AGENTS.md with a CLAUDE.md pointing at it, because
opencode discovers only the first name. Commanders created BEFORE that have the content in CLAUDE.md
and no AGENTS.md at all. Appending to the wrong one is the quiet failure this avoids: on a new
commander it would bury a credential section inside a six-line pointer the agent never reads.
*/
const brief = existsSync(join(dir, 'AGENTS.md')) ? join(dir, 'AGENTS.md') : join(dir, 'CLAUDE.md');
/**
* Append a section only when it is not already described.
*
* The marker is a string the section itself contains, so a commander whose CLAUDE.md was hand-edited to
* describe the same thing does not end up with it twice.
*/
const appendIfMissing = async (marker: string, lines: string[]): Promise<boolean> => {
const existing = existsSync(brief) ? readFileSync(brief, 'utf8') : '';
if (existing.includes(marker)) return false;
appendFileSync(brief, `\n${lines.join('\n')}`);
await giveToAccount(row.osUser, brief);
return true;
};
const envPath = commanderEnvPath(row.email, row.dirName);
if (!existsSync(envPath)) {
const { path } = await writeCommanderCredential({ userId: row.userId, email: row.email, dirName: row.dirName });
chmodSync(path, 0o600);
await giveToAccount(row.osUser, path);
await appendIfMissing('officer.env', credentialSection(path));
console.log(`MINT ${label} — ${path}`);
} else {
console.log(`ok ${label} — already has a credential`);
}
/*
Separately, because it arrived later and the commanders that need it most already have a credential.
Without it a commander does not know Officer HAS a notification route, so it reaches for whatever its
harness offers — a different channel, with its own rules about when it may interrupt.
*/
if (await appendIfMissing('/api/notify/_officer/notify', notifySection())) {
console.log(`NOTIFY ${label} — told how to reach the owner`);
}
}
process.exit(0);