Files
platform/src/servers/data-path.ts
T
pastilhasandClaude Opus 5 e9b1ce5e94 command hq: the web creation path, and a member could not have used it
`context: 'command-hq'` on a chat websocket turn now resolves to
getCommandersDir(email) regardless of any client cwd, and the row gets
written the moment a real transcript id is known — the same
session:claude/result seed chat_session_models already waits on, a sibling
closure (fileAsCommander) hung off the same two triggers.

found while wiring it, not guessed at: a member's commander turn would have
failed to spawn at all. getCommandersDir is a sibling of home, and
confineUserTree confines every sibling of home to 700 owned by the service
user — unreachable "even by name" — which is exactly wrong for a directory a
member's own turn needs to cd into via setpriv. same shape of bug
resolveChatCwd already tells a story about (general_chat_sessions,
abandoned for the same reason). fixed by chowning commanders/ to the member
the same way confineUserTree already chowns home, and excluding it from the
loop that confines every other sibling. no ACL needed, unlike home — command
hq reads a member's transcripts through listTranscriptsAs, the same
as-the-member read /chat already uses, never as the service user directly.

the gap this leaves: an account provisioned before this commit needs one
manual reprovision (provision-linux-route.ts / manage-users.ts, already
idempotent) before its first commander turn works. documented, not silently
left.

pulled COMMS/command-hq/{mobile,watchers-what-broke}.md from the mobile
side's session — apps/commandhq already exists and typechecks against the
router. their finding: mobile cannot create a commander the way the web
composer does (a context flag two independent clients would have to carry
correctly), and decided with the owner on POST /command-hq/commanders/new
instead. not built server-side yet — the real blocker is that claude does
not assign a transcript id until a turn actually runs, so the endpoint has
to run one and wait for session:claude, which means extracting the
turn-starting half of handleClaudeChat rather than a second copy of it.
replied in COMMS/command-hq/platform.md with the two open questions before
building it (prompt omitted, and wanting a second look at that refactor).

docs/command-hq.md updated throughout to say plainly what's built vs planned
per path (web vs mobile) rather than one blended status.

bunx tsgo clean. suite at baseline (same 7 pre-existing failures).
confineUserTree has no unit tests today (sudo/uid-dependent, matching this
file's existing coverage posture) — not inventing a new harness for one
block inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:58:23 +00:00

272 lines
15 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');
/**
* Every commander a user has shares this ONE directory as its cwd — deliberately not one directory per
* commander. The first design was per-commander, and was dropped the same night it was proposed: a fresh
* directory would have to exist before or exactly when a commander's first turn fires, and racing that
* creation against the harness's own spawn is precisely the kind of timing hazard Claude Code's
* directory-per-project convention was never built to be driven by. One shared, pre-existing directory
* removes the race instead of handling it.
*
* `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. See `docs/command-hq.md`.
*
* **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`.
*/
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
/**
* 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;
}
}
// 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'
);
};