the install root is derived, not configured

Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone
from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and
VAULT_STORE_KEY are no longer written by the setup script.

data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/,
capabilities/ and dockers/ as fixed names under it. The direction used to run the
other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in
app-store/paths.ts — which meant three environment variables that had to agree
with each other and with the tree on disk.

Eight files re-read process.env.DATA_PATH independently, each with its own
`?? cwd()/data` fallback. They import the one value now, which is what made
removing it safe: otherwise each would have derived its own and drifted.

Three things this turned up.

The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a
comment asserting "__dirname is the repo root — this file sits beside
ecosystem.config.cjs", which stopped being true when these files moved into
ecosystem-files/. It walks up to the platform's package.json now, which holds
wherever the file lives. That was a live bug before this change and a load-bearing
one after it, since cwd now decides where the install is.

assertInstallLayout joins the other two boot assertions. A wrong cwd does not
error — it computes a plausible root somewhere else and writes managed homes and
agent runs into it, so the install looks empty and the data looks lost with
nothing naming the cause. It throws before serve(), first of the three, because a
wrong answer there makes the other two check the wrong files.

getOwnerHomeDir captures homedir() once at module load rather than per call.
Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME
when set rather than reading passwd, and user-instance.ts assigns process.env.HOME
on its way to spawning an agent. A lazy read would have returned the owner's home
on the first call and a member's afterwards. data-path.ts imports only node
builtins, so it is evaluated before any of that runs.

JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script
does not boot — jwt.ts throws at module load without one. That is the agreed
sequencing: they move to the SQLite store (docs/secret-store.md), and writing them
here meanwhile would create a second origin for a secret the store then has to be
reconciled with. Said plainly in .env.example and in lib/env.sh rather than left
to be discovered.

Not typechecked: node_modules is empty here and installs are frozen. Every edited
file parses under `bun build --no-bundle`; the profile loads and pins the right
cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup
section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx
is not the pinned resolution and reformatted unrelated unions and line wraps in
six files, so those were reverted and the edits re-applied by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 23:18:48 +00:00
co-authored by Claude Opus 5
parent 86079adb9a
commit 3f071c0b24
18 changed files with 207 additions and 136 deletions
+93 -10
View File
@@ -1,12 +1,70 @@
import { join, resolve } from 'node:path';
import { chmodSync, mkdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { chmodSync, mkdirSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
// ── 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 = dirname(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 = process.env.OFFICER_ITEMS_DIR ?? join(process.cwd(), 'officer-items');
export const OFFICER_ITEMS_DIR = join(OFFICER_ROOT, 'capabilities');
/**
* Refuse to boot when the working directory is not the platform repo.
*
* Same posture as `assertCapabilityTotality` and `assertSecretsClosed`: a prerequisite that silently
* not holding is worse than one that fails. Every path in this file hangs off `dirname(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';
@@ -33,14 +91,39 @@ 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.
// 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 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);
// 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.
//