Files
platform/src/servers/data-path.ts
T
pastilhasandClaude Opus 5 9c353f5f0d move host setup into scripts/setup/
scripts/ was holding two unrelated kinds of thing: install-this-machine, and run-this-occasionally. The
eight installers now live in scripts/setup/; what stays at the top level is the build steps (gen-index,
prebuild, build/) and the two maintenance scripts (reindex-music, rebuild-soulseek-tree).

The move is not just a rename. Three of these derive the repo root from their own location:

  setup.sh:51            PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
  setup_mac_light.sh:51  same
  cleanup-desktop.sh:134 ENV_FILE="$(dirname "$0")/../.env"

Left alone, all three would now resolve to scripts/ — and nothing downstream complains. PROJECT_DIR is
where .env is written, where `bun install`, `gen:index` and `db:push` run, and what pm2 is pointed at, so
a fresh install would have quietly provisioned scripts/ and reported success. cleanup-desktop.sh fails
the other way: it would find no .env, print "No .env — skipping", and leave the real VNC_PASSWORD in the
real file. All three are now `../..` with a comment saying why the level matters.

provision-user-dirs.ts imports data-path.ts relatively; that one tsgo caught.

Also disambiguated `setup.sh` where it had become two files. app-store/templates/<name>/setup.sh is a
per-sidecar installer with its own contract, and preflight.ts + docs/sidecar-app-store.md discussed both
in the same paragraph. The host one is now spelled with its full path at those sites.

Verified: bash -n on all six shell scripts, tsgo clean, os-user tests pass, both derivations resolve to
the repo root, starship.toml still resolves from os-user-shell.ts, and provision-user-dirs.ts runs under
DRY_RUN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:02:19 +00:00

124 lines
6.9 KiB
TypeScript

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/setup/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');
// 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'
);
};