Three things, from a member sitting on /music with no music capability on a server with no music sidecar: an empty library, and 403s in the console. PERMISSIONS AT THE ROUTE. `canVisit` filtered the dock and nothing else, so the tile was hidden and the route was wide open — typing the path, following an old link or restoring a tab rendered the screen anyway. RouteGate now wraps every screen in one place, inside the error boundary. It does not redirect. Sending someone to `/` erases what they asked for and reads as a bug: they clicked Music and landed on Home. It says why instead, and the URL stays put so a reload after installing the thing just works. And it says which of the two reasons applies, because they need different screens and send the reader to different places. `not-installed` is a fact about the SERVER — the owner gets a link to the app store. `not-granted` is a fact about the ACCOUNT, and only the owner can change it. Presenting either as the other sends you looking in the wrong place. ROUTES FOLLOW THE SIDECAR. Free, once the above exists: `deniedRoutes` already covers "held but its sidecar is not installed", so an uninstalled feature has no tile AND no screen. The dock, the Permissions list and the routes now agree because they read one answer. NO MORE SEEDING. Downloads/Documents/Music/Videos/Pictures are gone from both places that made them — the member's provisioning and, older and worse, `/ls`, which created folders in somebody's home as a side effect of LOOKING at it. A listing that invents its own contents is a listing you cannot trust, and the platform has no standing to choose a person's folder layout. A new home is empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
7.0 KiB
TypeScript
133 lines
7.0 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/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;
|
|
|
|
/**
|
|
* 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'
|
|
);
|
|
};
|