import { join, resolve } from 'node:path'; import { chmodSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), '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 = process.env.OFFICER_ITEMS_DIR ?? join(process.cwd(), 'officer-items'); 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 nothing executes here any more: // terminals, chats and task runs all use getOwnerHomeDir below. It survives only as that function's // fallback for when HOME_DIR is unset, and in pipeline-executor. export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home'); // Where the owner's sessions actually run: their real login home when HOME_DIR is set, so platform // terminals/chats/tasks share config and credentials with the shell they use outside Officer. export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ?? getHomeDir(email); // 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', 'general_chat_sessions', 'logs', 'sidecar', ] as const; /** * The folders a home is seeded with, so a new account's file browser is not an empty rectangle. * * Here rather than in the file browser because there are now two seeders: that router (for the owner, whose * home it can write to) and the Linux-account provisioner, which has to create them AS the member because * their home is 700 and theirs. Two lists would mean a member's home quietly differing from the owner's. */ export const HOME_SEED_DIRS = ['Downloads', 'Documents', 'Music', 'Videos', 'Pictures'] 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'); // 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' ); };