Files
platform/src/servers/data-path.ts
T
pastilhasandClaude Opus 5 7e6cfc85af one resolver for where an email chat lives
the picker above an email composer listed conversations from the owner's home —
a chat started somewhere else entirely, offered as if it belonged to the mailbox.

the address had two resolvers. the chat socket knew `context: 'email'` and ran
the turn in the account's directory; `GET /chat/sessions` did not, and fell
through to `who.home`. so a message went into the mailbox and could not be found
in the list directly above where it was typed. two answers to "where does this
chat live" is one too many, and the one that was missing failed quietly.

`resolveEmailAccountDir` in data-path.ts is now the only answer, and both call
it. it verifies a client-named mailbox against disk rather than sanitising it
into shape, which matters because the socket's caller ends in mkdirSync — an
unrecognised value would not fail, it would CREATE a directory named after
whatever arrived and run turns in it. `all`, the list's own sentinel for the
unified view, is exactly the value most likely to arrive by accident.

pure: no mkdir, no writes. the caller that needs the directory says so itself.

tested, because a missing branch is what this was: the named mailbox wins, `all`
never becomes a directory, a mailbox with no emails.db is not a mailbox, and no
account at all resolves to the accounts root rather than to something invented.

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

420 lines
24 KiB
TypeScript

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/<app-name>/`), 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/<email>/…`).
* 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/<owner>/email_accounts/<accountEmail>/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/<email>/commanders/<dirName>/`.
*
* **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/<email>/plugins/<appName>/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/<email>/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/<email>/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/<transcript id>/`, 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'
);
};