import { join, resolve } from 'node:path'; import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; // ── The install root, and why it is derived rather than configured ── // // An install is one directory with everything under it: // // $OFFICER_ROOT/ // platform/ the repo — the working directory every process runs in // data/ DATA_PATH // capabilities/ OFFICER_ITEMS_DIR // dockers/ what the app store provisions // // These were three environment variables until 2026-08-12, which meant three answers that had to agree // with each other and with the layout on disk. They are one fact now: the root is the parent of the // working directory, and everything else is a fixed name under it. Nothing to set and nothing to // disagree. // // This depends on the working directory being the repo, which is why pm2 pins `cwd` in // ecosystem.profile.cjs — read the comment there before changing either. `assertInstallLayout` below // is the check that says so out loud instead of silently writing to the wrong place. export const OFFICER_ROOT = resolve(process.cwd(), '..'); /** * The repo itself — the working directory, named rather than re-derived at each call site. * * Plugins live under this rather than beside it (`platform/plugins//`), which is the whole * developer story: bun links the workspace packages into the root `node_modules`, so anything inside the * repo can `import { useClient } from 'hooks/useClient'` with no publishing and no version negotiation. * A plugin one directory higher would resolve none of it. */ export const PLATFORM_DIR = process.cwd(); export const DATA_PATH = join(OFFICER_ROOT, 'data'); // Unified, file-based store for all agent items, living outside the repo. Every skill/tool/task/ // process/extension is a directory under one of these type subfolders — no scope tiers, no DB. export const OFFICER_ITEMS_DIR = join(OFFICER_ROOT, 'capabilities'); /** * Refuse to boot when the working directory is not the platform repo. * * Same posture as `assertPermissionTotality` and `assertSecretsClosed`: a prerequisite that silently * not holding is worse than one that fails. Every path in this file hangs off `resolve(process.cwd(), '..')`, * so a process started from the wrong directory does not error — it computes a plausible root somewhere * else and writes managed homes, capabilities and agent runs into it. The install looks empty and the * data looks lost, with nothing naming the cause. * * `ecosystem.profile.cjs` already pins `cwd` for exactly this reason. This is the check that the pin * still works, which is the part that was missing when the ecosystem files moved directory. */ export function assertInstallLayout(): void { const manifest = join(process.cwd(), 'package.json'); let name: string | undefined; try { name = JSON.parse(readFileSync(manifest, 'utf8')).name; } catch { // Absent or unreadable is the same answer as wrong: this is not the repo. } if (name === 'officer') return; throw new Error( [ `Officer must run from the platform repo, but the working directory is ${process.cwd()}`, '', ` expected a directory containing the platform's package.json ("officer")`, ` found ${name ? `package.json for "${name}"` : 'no readable package.json'}`, '', 'Every path is derived from this — the install root is its parent, and data/, capabilities/ and', `dockers/ hang off that. Continuing would write to ${OFFICER_ROOT} instead of the real install.`, '', 'Under pm2 this means the `cwd` pin in ecosystem.profile.cjs no longer points at the repo.', ].join('\n'), ); } export type ItemType = 'skills' | 'tools' | 'tasks' | 'processes' | 'extensions' | 'agents'; export const ITEM_TYPES: ItemType[] = ['skills', 'tools', 'tasks', 'processes', 'extensions', 'agents']; export const itemsDir = (type: ItemType) => join(OFFICER_ITEMS_DIR, type); export const ensureItemDirs = () => { for (const type of ITEM_TYPES) mkdirSync(itemsDir(type), { recursive: true }); }; export const SERVER_CONFIG_DIR = join(DATA_PATH, 'server-settings'); // Every run of a given agent shares one working directory. That is deliberate and load-bearing: the // `claude` CLI groups transcripts by cwd (see api/chat/claude-sessions.ts), so a shared cwd is what // makes an agent's runs show up as their own project group in /chat, listed newest-first, with no // database row anywhere. The accumulated CLAUDE.md / LEARNINGS.md for the agent live here too. export const getAgentRunsDir = (dirName: string) => join(DATA_PATH, 'agentic_runs', dirName); // The `pi`/opencode agent's own config dir (auth.json + models.json). External-tool path. export const AGENT_CONFIG_DIR = join(homedir(), '.pi', 'agent'); export const SEED_PATH = resolve(import.meta.dir, '../../seed'); // The managed home under DATA_PATH. A remnant of the first architecture, where every user ran inside // their own Docker container and this was that container's home — seeded by provisioning, described to // the agent by a generated CLAUDE.md. Both of those are gone, and the OWNER's sessions never come here // any more — terminals, chats and task runs all use getOwnerHomeDir below. // // It is not dead, though: user-home.ts returns it for a NON-owner, where it is deliberately the same // path as osUserHome, and pipeline-executor still calls it. // // It was also getOwnerHomeDir's fallback until 2026-08-12, which is the only reason an unset HOME_DIR // used to run the owner's terminals in a directory nobody meant. export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home'); // Where the owner's sessions actually run: their real login home, so platform terminals/chats/tasks // share config and credentials with the shell they use outside Officer. // // `homedir()` is right here for one reason, and it is worth stating because everything below rests on // it: the server process runs AS the owner. It is not a general "whose home is this" helper — a member // never reaches this function, because their sessions go through os-user.ts and setpriv. It ignores the // email it is passed, which it also did before; the callers that must not are already commented as such. // // ── Why this is captured once, and not read per call ── // // Measured on bun 1.3.10: BOTH `os.homedir()` and `os.userInfo().homedir` return $HOME when it is set, // rather than reading the password file. And `sidecar/claude/user-instance.ts` assigns `process.env.HOME` // on its way to spawning an agent. So a lazy read here would hand back whichever home was most recently // spawned into — the owner's on the first call and something else afterwards. // // This module imports nothing but node builtins, so it is evaluated before any of that can run. The // value is the owner's home, taken while $HOME still means what it says. const OWNER_HOME = homedir(); // This was HOME_DIR in .env until 2026-08-12, whose absence fell back to getHomeDir — the managed home // under DATA_PATH, not a login home at all. So forgetting to set it did not fail; it quietly ran every // terminal somewhere else. export const getOwnerHomeDir = (_email: string): string => OWNER_HOME; // The directory skeleton a new account gets under DATA_PATH. // // Most of these are also created on demand by whichever feature owns them, so pre-creating them buys // legibility more than function — the tree shows what an account has without it having to be used first. // `home` is the exception and the reason this exists: nothing else creates it, and it is where a // non-owner's sessions would run. // // Single-sourced here rather than in the script that used to own the list, because there are now two // callers — `scripts/provision-user-dirs.ts` and the owner's create-account handler — and a skeleton // that differs depending on how the account was made is a bug nobody would think to look for. export const USER_DIRS = ['home', 'attachments', 'cache', 'dashboards', 'email_accounts', 'logs', 'sidecar'] as const; /** * Create an account's root and its skeleton, closed by default. * * Keyed on email because that is what the on-disk layout uses everywhere else (`DATA_PATH//…`). * Renaming an account's email would orphan its directory; that is pre-existing and not this function's * problem, but it is the reason nothing here derives a path from the id. * * ── Why the modes are set here and not only by os-user.ts ── * * `711` on the account directory, `700` on everything inside it. Measured while testing per-user Linux * accounts: at the default umask these came out `755`, and a member with a shell could read ANOTHER * member's home directory just by naming it — the parent being unlistable is not protection when the * child itself is world-readable. "Locked unless something opens it" has to be the resting state, so it * belongs at creation rather than in the confinement pass, which only ever runs for accounts that have an * OS user. * * `chmod` explicitly rather than mkdir's `mode`, which is masked by the umask and does nothing at all for * a directory that already exists. */ export const provisionUserDirs = (email: string): void => { const accountDir = join(DATA_PATH, email); for (const dir of USER_DIRS) mkdirSync(join(accountDir, dir), { recursive: true }); // Traversable, not listable: reaching `home` must not mean enumerating the platform's tree beside it. chmodSync(accountDir, 0o711); for (const dir of USER_DIRS) { try { chmodSync(join(accountDir, dir), 0o700); } catch { // A directory that is no longer OURS to chmod. `home` becomes the member's on the first successful // provision, and `chmod` requires ownership — so re-running this threw EPERM and took every RETRY down // before it began, which is how this was found. os-user.ts sets the home's mode through sudo and is the // authority for it; here the mode is a default for directories we are creating, not an assertion about // ones that already exist. } } }; export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'attachments', 'tmp'); export const getAttachmentsDir = (email: string, sessionId: string) => join(DATA_PATH, email, 'attachments', sessionId); // Per-account email storage: DATA_PATH//email_accounts//emails.db, with a // single attachment_cache/ shared across the owner's accounts. export const getEmailAccountsDir = (ownerEmail: string) => join(DATA_PATH, ownerEmail, 'email_accounts'); export const getEmailDbPath = (ownerEmail: string, accountEmail: string) => join(getEmailAccountsDir(ownerEmail), accountEmail, 'emails.db'); export const getEmailAttachmentCacheDir = (ownerEmail: string) => join(getEmailAccountsDir(ownerEmail), 'attachment_cache'); /** * The root every commander's own directory lives under — `$DATA_PATH//commanders//`. * * **Not a shared cwd any more.** Two designs were tried and retracted before this one. First, one directory * per commander, dropped the night it was proposed: a fresh directory had to exist before or exactly when a * commander's first turn fired, and racing that creation against the harness's own spawn was the kind of * timing hazard Claude Code's directory-per-project convention was never built to be driven by. So it became * one shared directory for every commander a user has — which removed the race but made the directory the * commander's only identity, which is what made the ordering problem `docs/commander-directory.md` describes * inescapable. Then, briefly (2026-08-22), a commander chose an arbitrary directory anywhere in its owner's * tree, once identity moved to the `command_hq_sessions` row instead of the cwd. * * **One directory per commander again, and the race is gone for a different reason than either previous * attempt solved it.** The row (and so `dirName`) is created by `POST /command-hq/commanders` BEFORE any * turn spawns — the ordering fix is exactly what makes a fresh, empty, commander-named directory safe to * create ahead of time: nothing races its own creation, because creation happens first and spawning waits * on it. See `docs/commander-directory.md`. * * `DATA_PATH`-rooted even for the OWNER, which is a deliberate departure from ordinary `/chat` — an owner's * normal chat runs in `getOwnerHomeDir` (their real login home). Commanders use the managed directory * instead, on both sides of the account split, which is what keeps them out of the owner's own project * transcripts as surely as `command_hq_sessions` keeps them out of the `/chat` list. * * **A sibling of `home`, but reachable like `home` — not confined like every other sibling.** Every other * directory alongside a member's `home` (`attachments`, `email_accounts`, …) is 700 and owned by the * SERVICE user, unreachable by the member "even by name" — correct for platform-written data, and exactly * wrong for a directory a member's own `claude` must `cd` into via `setpriv`. `confineUserTree` * (`os-user.ts`) chowns this one to the member the same way it chowns `home`. An account provisioned * BEFORE this existed will not have that chown until it is re-provisioned once — see `docs/command-hq.md`. * * That chown is recursive at provisioning time only — a subdirectory created for a NEW commander afterwards * is written by the platform process (the service user), not by the member, so it will not automatically * carry the member's ownership the way the root does. Not solved here; flagged in * `docs/commander-directory.md`'s implementation notes as a gap for a member-owned commander specifically, * not a regression this file introduces. */ export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders'); /** * Where a plugin's own chats run — `$DATA_PATH//plugins//chats`. * * ── Why a directory at all ── * * A chat's working directory is the platform's proven way to give a conversation context it did not have * to be told: Claude Code reads a `CLAUDE.md` from its cwd at session start, which is the whole mechanism * behind `docs/commander-directory.md`. A plugin that wants its chats to know something specific writes it * there once, rather than paying for it in every preamble on the machine. * * It also keeps those chats out of the account's home, so a plugin's conversation is not casually sitting * on top of everything its owner has. * * ── Why it is a SIBLING of home, and what that costs ── * * `confineUserTree` chmods every sibling of `home` to 0700 owned by the service user, with `commanders` * carved out because a member has to reach it. **`plugins` is carved out the same way, and must stay that * way.** Without it this repeats a failure already on record: `/chat` once ran in * `DATA_PATH//general_chat_sessions`, and the first live member turn died on its own working * directory before doing anything, because a sibling of the home is the platform's by default. * * ── `appName` is not a path ── * * Same guard as `getCommanderDir`, for the same reason: this value reaches here from a plugin manifest and * a client message, and a separator in it would resolve outside the account's tree entirely. */ export const getPluginsDir = (email: string) => join(DATA_PATH, email, 'plugins'); /** * Where every bot runs — `$DATA_PATH//bots`. * * ── One directory, not one per bot ── * * A commander gets its own, because a commander is a job with procedures, a ledger and its own rules. A * bot is one sentence and a watch, and giving each one a directory would mean a tree of near-empty * folders and a creation step before anything can be said. * * Sharing it also gives the list for free: every bot is a chat whose cwd is this, so Claude groups their * transcripts together and `/chat/sessions?cwd=` is already the answer to "what bots do I have". * * A bot that needs to remember something — the headline it saw last time — writes a file here named after * itself. The `CLAUDE.md` in this directory says so; it is the platform's and is rewritten on every bot * turn, the same arrangement as a plugin's chat brief. * * Confined like `commanders` and `plugins`: a member's bot runs as the member, so this must be theirs. */ export const getBotsDir = (email: string) => join(DATA_PATH, email, 'bots'); export const getPluginChatsDir = (email: string, appName: string): string => { if (!appName || appName.includes('/') || appName.includes('\\') || appName === '.' || appName === '..') { throw new Error(`refusing to resolve a plugin directory for ${JSON.stringify(appName)}`); } return join(getPluginsDir(email), appName, 'chats'); }; /** * One commander's own directory — `CLAUDE.md`, `state/`, and whatever it writes for itself. `dirName` is * `command_hq_sessions.dir_name`, derived from the row's own serial id at creation. See * `docs/commander-directory.md`. */ /** * One commander's own directory. * * **Guarded, because two callers `rm -rf` what this returns** — deleting a commander removes its directory, * and `dropSpawnedRecord` removes its `state/`. An empty `dirName` would resolve to the commanders ROOT and * take every commander the account has with it. * * That is not hypothetical arithmetic. `createCommander` inserts the row with `dirName: ''` and fixes it up * in the same statement pair, because the name is derived from a serial id that does not exist until the * insert has happened. The transaction means nothing else can observe that state — and a guard here means * it does not have to, which is the difference between safe and safe-so-far. A traversal is refused for the * same reason, cheaply, rather than reasoned about. */ export const getCommanderDir = (email: string, dirName: string): string => { if (!dirName || dirName.includes('/') || dirName === '.' || dirName === '..') { throw new Error(`refusing to resolve a commander directory for ${JSON.stringify(dirName)}`); } return join(getCommandersDir(email), dirName); }; /** * Where a commander records what it spawned and its loop's own pid — `state/` inside its own directory, * keyed by `dirName` rather than a transcript id. * * Used to be a directory of its own (`commander-state//`, a sibling of the transcripts * rather than the commander), because there was nothing else to key it on before a commander had an * identity independent of its transcript. Collapsed in here 2026-08-24: `state/` is INSTANCE data (what a * commander has handled, what it spawned, its loop's pid) as opposed to `CLAUDE.md` and whatever else the * commander writes beside it, which is DEFINITION — the split `docs/commander-directory.md` calls * structural, kept as a subdirectory rather than a convention precisely so it is enforced by a path rather * than remembered by whoever writes into it. * * Beside the transcripts rather than inside them: a transcript is the harness's file and the platform does * not write into it. `spawned` is a plain newline-delimited list of session ids, appended by the loop the * commander started, because nothing else CAN know them — a spawned agent's id is not recoverable from its * process (no open descriptor, nothing in its environment) and its transcript is indistinguishable from any * other session's. * * That makes this record load-bearing rather than convenient. Without it a commander cannot be deleted * completely, its children cannot be followed, and its cost cannot be totalled. */ export const getCommanderStateDir = (email: string, dirName: string) => join(getCommanderDir(email, dirName), 'state'); /** * Which mail account's store to use, found on DISK rather than by asking the database. * * Email is a plugin (`plugins/email/`) as of 2026-08-18, and the platform may not import a plugin's * queries — that direction is the one the plugin system forbids, and here it is also the direction that has * already broken this server: `getEmailAccounts` was awaited at the top level of the agent sidecar, and * when the table went away on 2026-08-13 the rejection took `officer-agent` into a PM2 restart loop with * every live chat session inside it (`fbb6d6c7`). * * Two callers still legitimately need an answer — the agent's `email_db` MCP tool, and a chat opened * against a mailbox — and neither needs to know anything about mail to get one. The directory layout is * already the platform's (see `USER_DIRS`), so reading it answers the question with no coupling: no import, * no query, and nothing to throw if the plugin is not installed. * * Newest by mtime, because the most recently synced account is the one a fresh question is most likely * about. Returns the DIRECTORY NAME (the account's address), which is what both callers compose with. */ export function findEmailAccountName(ownerEmail: string): string | null { try { const root = getEmailAccountsDir(ownerEmail); const candidates = readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name !== 'attachment_cache') .map((entry) => ({ name: entry.name, db: join(root, entry.name, 'emails.db') })) .filter((candidate) => existsSync(candidate.db)) .map((candidate) => ({ ...candidate, at: statSync(candidate.db).mtimeMs })) .sort((a, b) => b.at - a.at); return candidates[0]?.name ?? null; } catch { // No directory, no permission, no plugin. All of them mean "no account", which is a normal state. return null; } } /** * The directory an email chat belongs to — the mailbox's own, the one holding `emails.db`. * * ── Why this is a function and not two lines at each call site ── * * It IS an address, and it had two resolvers. The chat socket resolved it to run the turn; the sessions * route resolved it to list the conversations. Only the socket knew about `email`, so the picker fell * through to the owner's home and offered chats from somewhere else entirely — you could send a message * into the mailbox and then not find it in the list above the composer. Two answers to "where does this * chat live" is one too many. * * `requested` reaches here from the client (the mailbox picker's `?account=`) and is verified against disk * before it is joined, never sanitised into shape. That matters because the socket's caller ends in * `mkdirSync`: an unrecognised value would not fail, it would CREATE a directory named after whatever * arrived. The list's unified view uses the sentinel `all`, which is the value most likely to show up by * accident. * * Falls back to the most recently synced account, and to the accounts root when there is no account at * all — a real state for a fresh install, and a directory that exists rather than one invented here. * * Pure: no mkdir, no writes. The caller that needs the directory to exist says so itself. */ export function resolveEmailAccountDir(ownerEmail: string, requested?: string): string { const named = requested?.trim(); const known = named && existsSync(join(getEmailAccountsDir(ownerEmail), named, 'emails.db')) ? named : null; const account = known ?? findEmailAccountName(ownerEmail); return account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail); } // Sanitises a display username or email into a bare, lowercase, shell-safe token. The name and the // 32-char Linux limit are the last trace of the per-container architecture, where this really did name // a Linux user inside the user's container. Nothing creates a Linux user now — the value is carried // through the websocket/job payloads and ends up only as a claim inside the signed task token, so this // is a sanitiser rather than an account name. Left in place because unpicking it means changing what // goes into that token and into WSData, which is a wider change than a cleanup. export const toShellUsername = (username: string, email: string): string => { const raw = username || email.split('@')[0]!; // Replace invalid chars, lowercase, truncate to 32 chars return ( raw .replace(/@.*$/, '') .replace(/[^a-zA-Z0-9._-]/g, '_') .toLowerCase() .slice(0, 32) || 'officer' ); };