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
+1 -1
View File
@@ -2,6 +2,7 @@ import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { startRadicale, davPaths } from './radicale';
import { listCollections, listEvents, listContacts } from './collections';
import { DATA_PATH } from '../../data-path';
// The officer-caldav sidecar. Owns the whole CalDAV/CardDAV contract: it supervises Radicale, owns the
// collection storage under DATA_PATH/dav, and exposes two very different doors.
@@ -23,7 +24,6 @@ import { listCollections, listEvents, listContacts } from './collections';
// ─────────────────────────────────────────────────────────────────────────────────────────────────
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
const DATA_PATH = process.env.DATA_PATH ?? `${process.cwd()}/data`;
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
+1 -1
View File
@@ -35,7 +35,7 @@ console.log(`[claude] CLI resolved to ${CLAUDE_BIN}`);
// Capture original HOME before user-instance overrides it
const HOST_HOME = process.env.HOME!;
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
import { DATA_PATH } from '../../data-path';
// Tear a persistent session down after this long with no new turn (see PersistentSession below).
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
+1 -2
View File
@@ -1,7 +1,6 @@
import { join } from 'node:path';
import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
import { DATA_PATH } from '../../data-path';
/**
* A resumable session, and whose it is.
+1 -1
View File
@@ -17,6 +17,7 @@ import * as claudeManager from './claude-manager';
import { createSidecarConnector } from '../connect';
import { sign } from '../../jwt';
import { getUserByEmail, getOwnerUser, getEmailAccounts } from 'officerdb';
import { DATA_PATH } from '../../data-path';
// PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than
// being told who to run as by the main server — one less thing that has to come from `officer` before
@@ -60,7 +61,6 @@ async function resolveOwner() {
const dbUser = await resolveOwner();
const email = dbUser.email;
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
// Same officer instance for both, so the fallback port has to agree. It used to default to 5000 for the
// WebSocket and 9010 for the REST base, which would have split them apart if PORT were ever unset.
const OFFICER_PORT = process.env.PORT ?? '9010';
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '../../data-path';
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
//
@@ -16,7 +17,6 @@ import { join } from 'node:path';
// surface, which already exists and already persists.
const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
const DATA_PATH = process.env.DATA_PATH ?? '';
const DEFAULT_TIMEOUT_MS = 120_000;
/** The proxy is not running, has no token, or refused us. Distinct from the model declining to answer. */
+1 -1
View File
@@ -38,8 +38,8 @@ import {
setPlaylistItems,
type FavoriteKind,
} from 'officerdb';
import { DATA_PATH } from '../../data-path';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
// ── Per-user state validation ──
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
+3 -1
View File
@@ -20,9 +20,11 @@ import { homedir } from 'node:os';
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
// phone's resync diff (fetch only changed `v`s).
import { DATA_PATH } from '../../data-path';
const HOME = process.env.HOME_DIR ?? homedir();
export const MUSIC_ROOT = join(HOME, 'Music');
const CACHE_ROOT = join(process.env.DATA_PATH ?? join(process.cwd(), 'data'), 'music', 'cache');
const CACHE_ROOT = join(DATA_PATH, 'music', 'cache');
const MANIFEST_PATH = join(CACHE_ROOT, 'manifest.json');
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma']);