diff --git a/.env.example b/.env.example index 238bcef0..df91415d 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,44 @@ +# What officer-setup writes. Everything below this block is optional, or is on its way out. PORT=9000 -JWT_SECRET="" +BROWSER_RELAY_PORT=18792 POSTGRES_URL="postgres://postgres:password@localhost:5432/officer" -PUBLIC_URL=http://localhost:9000 + +# ── Moving to the secret store ───────────────────────────────────────────────────────────────── +# Still REQUIRED — jwt.ts throws at module load without JWT_SECRET, and crypto.ts throws without +# VAULT_STORE_KEY — but officer-setup no longer writes either. They are moving into the SQLite key +# store (docs/secret-store.md), which is designed and not yet built, so an install made by the +# current script will not boot until it is. That is deliberate sequencing, not an oversight. +JWT_SECRET="" + +# NOT Vaultwarden's, despite the name and where it used to sit — it is the platform's at-rest key, +# encrypting every secret column in Postgres: Headscale admin API keys, app-store service +# credentials, Jellyfin tokens, wallet node credentials, and the wallet seed envelope on top of the +# owner passphrase that seals it. +# +# CHANGING IT MAKES ALL OF THAT UNREADABLE AT ONCE, and for the seed that is unrecoverable: the +# passphrase opens the inner envelope and this is the outer one. +VAULT_STORE_KEY="" + +# ── Optional ─────────────────────────────────────────────────────────────────────────────────── +# Where Officer is reached from a browser. Read by origin validation, the task API host check, and +# the CalDAV iOS profile builder — which is the only one that hard-requires it, and demands https. +# PUBLIC_URL=https://officer.example.com # Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to -# "dev" or "development". Leave it unset or set it to "production" for a real deployment; only set -# it to "dev" on a local machine you trust, since that disables all three. -PUBLIC_BUILD_ENV=production +# "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it +# by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script +# only loads this file, so `bun dev` against a production .env runs fully hardened. +# PUBLIC_BUILD_ENV=dev # Origin checking is OFF unless this is explicitly "false" — an inversion of the usual rule, and one # that is only defensible when the tailnet is the perimeter. On a machine with no tailnet, set it to # false. Written explicitly rather than left to the default so the choice is visible. ALLOW_ANY_ORIGIN=true -DATA_PATH=/path/to/data -OFFICER_ITEMS_DIR=/path/to/officer-items -HOME_DIR=/home/user -BROWSER_RELAY_PORT=18792 +# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR were here until 2026-08-12 and are no longer read. +# The install root is derived as the parent of the working directory (src/servers/data-path.ts), so +# data/, capabilities/ and dockers/ follow from it; the owner's home comes from the OS. Three values +# that had to agree with each other and with the disk became one that cannot disagree. # ── Sidecars ──────────────────────────────────────────────────────────────────────────────────── # Each sidecar owns its upstream's credentials; the platform API is only a thin auth+forward proxy @@ -35,19 +57,10 @@ BROWSER_RELAY_PORT=18792 # daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as # X-API-Key on every forwarded request. -# Vaultwarden (officer-vault). +# Vaultwarden (officer-vault). VAULT_STORE_KEY is at the top of this file — it is the platform's +# key, not Vaultwarden's, however much the name and its old position here suggested otherwise. VAULTWARDEN_URL=http://127.0.0.1:8222 -# VAULT_STORE_KEY is NOT Vaultwarden's, despite the name and where it sits — it is the platform's -# at-rest key, and it encrypts every secret column in Postgres: Headscale admin API keys, app-store -# service credentials, Jellyfin tokens, wallet node credentials, and the wallet seed envelope on top -# of the owner passphrase that seals it. Any strong secret of 16+ chars works. -# -# CHANGING IT MAKES ALL OF THAT UNREADABLE AT ONCE, and for the seed that is unrecoverable: the -# passphrase opens the inner envelope and this is the outer one. See docs/secret-store.md, which is -# the design for moving this key out of here and making rotation a supported operation. -VAULT_STORE_KEY="" - # Anthropic proxy (officer-anthropic-proxy). Defaults to 5051; it holds the API credential, which # lives in the host env rather than here. # ANTHROPIC_PROXY_PORT=5051 diff --git a/ecosystem-files/ecosystem.profile.cjs b/ecosystem-files/ecosystem.profile.cjs index ea48a0db..f56b3eb7 100644 --- a/ecosystem-files/ecosystem.profile.cjs +++ b/ecosystem-files/ecosystem.profile.cjs @@ -24,6 +24,27 @@ * @param {string[]} spec.include app names to run, in start order * @param {Record} spec.excluded app name → why it is not in this profile */ +// The directory holding the platform's package.json, found by walking up from this file. Independent of +// where in the tree this config is kept, and of where pm2 was invoked from. +function repoRoot() { + const { existsSync, readFileSync } = require('node:fs'); + const { dirname, join } = require('node:path'); + let dir = __dirname; + for (;;) { + const manifest = join(dir, 'package.json'); + if (existsSync(manifest)) { + try { + if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'officer') return dir; + } catch { + // Unparseable is not ours; keep walking. + } + } + const up = dirname(dir); + if (up === dir) throw new Error("ecosystem.profile.cjs: could not find the platform's package.json above " + __dirname); + dir = up; + } +} + function defineProfile({ file, include, excluded }) { const full = require('./ecosystem.config.cjs'); const byName = new Map(full.apps.map((app) => [app.name, app])); @@ -48,9 +69,17 @@ function defineProfile({ file, include, excluded }) { // `cwd` is pinned because Bun auto-loads .env from the working directory (and the pty sidecar does // `import 'dotenv/config'`). Without it, starting pm2 from anywhere but the repo root silently falls - // back to PORT=5000 with no POSTGRES_URL. __dirname is the repo root — this file sits beside - // ecosystem.config.cjs. - return { apps: include.map((name) => ({ ...byName.get(name), cwd: __dirname })) }; + // back to PORT=5000 with no POSTGRES_URL. + // + // It also decides where the install is. src/servers/data-path.ts derives OFFICER_ROOT as the PARENT of + // the working directory, and data/, capabilities/ and dockers/ hang off that — so a wrong cwd does not + // fail, it relocates the whole install. `assertInstallLayout` is the boot check that catches it. + // + // This was `__dirname`, with a comment asserting "__dirname is the repo root — this file sits beside + // ecosystem.config.cjs". That stopped being true the moment these files were moved into + // ecosystem-files/, and nothing said so. Found by walking up to the package.json instead, which is + // true wherever this file ends up living. + return { apps: include.map((name) => ({ ...byName.get(name), cwd: repoRoot() })) }; } module.exports = { defineProfile }; diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index 9bc6e246..745a29de 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -458,54 +458,22 @@ if ! skip; then echo "" info "Environment — $(env_file)" - # Read back before anything is asked. The two secrets below are kept, never - # reminted, and everything else becomes the default for its question. - ENV_JWT_SECRET="$(env_get JWT_SECRET)" - ENV_VAULT_STORE_KEY="$(env_get VAULT_STORE_KEY)" + # Read back before anything is asked; existing values become the defaults. ENV_PORT="$(env_get PORT)" - ENV_PUBLIC_URL="$(env_get PUBLIC_URL)" ENV_DISCORD_WEBHOOK="$(env_get DISCORD_BUG_REPORT_WEBHOOK)" ENV_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)" if env_exists; then - echo " exists — its values are the defaults below, and the two secrets are kept" + echo " exists — its values are the defaults below" else echo " does not exist yet" fi - # ── the secrets ── - if [[ -n "$ENV_JWT_SECRET" ]]; then - echo " JWT_SECRET: kept (regenerating it logs everybody out)" - else - ENV_JWT_SECRET="$(generate_secret)" - echo " JWT_SECRET: generated" - fi - - if [[ -n "$ENV_VAULT_STORE_KEY" ]]; then - echo " VAULT_STORE_KEY: kept" - else - ENV_VAULT_STORE_KEY="$(generate_secret)" - echo " VAULT_STORE_KEY: generated" - echo "" - warn "back up VAULT_STORE_KEY somewhere safe, now." - echo " It encrypts every upstream credential the platform stores, and the" - echo " wallet's seed on top of your passphrase. Lose it and those are gone" - echo " — the passphrase does not help, because it opens the inner envelope" - echo " and this is the outer one." - fi - # ── what is asked ── echo "" ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}" ENV_BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT:-18792}" - echo "" - echo " PUBLIC_URL is where Officer is reached from a browser. Allowed" - echo " origins are derived from it, and passkeys are bound to its host —" - echo " so it has to be the address you actually use, not localhost, unless" - echo " localhost is genuinely it." - ask_required ENV_PUBLIC_URL "Public URL" "${ENV_PUBLIC_URL:-http://localhost:${ENV_PORT}}" - # ── origin checking, decided by the machine rather than by a default ── # # ALLOW_ANY_ORIGIN defaults to ON inside the platform, which CLAUDE.md says is @@ -524,13 +492,12 @@ if ! skip; then echo "" echo " to write:" echo " PORT=${ENV_PORT} BROWSER_RELAY_PORT=${ENV_BROWSER_RELAY_PORT}" - echo " PUBLIC_URL=${ENV_PUBLIC_URL}" echo " ALLOW_ANY_ORIGIN=${ENV_ALLOW_ANY_ORIGIN}" - echo " DATA_PATH=${OFFICER_ROOT}/data" - echo " OFFICER_ITEMS_DIR=${OFFICER_ROOT}/capabilities" - echo " HOME_DIR=${USER_HOME}" echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…" - echo " JWT_SECRET, VAULT_STORE_KEY — not shown" + echo "" + echo " the install root is not written here — the platform derives it as the" + echo " parent of the repo, so data/, capabilities/ and dockers/ follow from" + echo " ${OFFICER_ROOT} without anything having to agree with anything." echo "" if confirm "Write it?"; then diff --git a/scripts/setup/officer-setup/lib/env.sh b/scripts/setup/officer-setup/lib/env.sh index f10ad210..441b957a 100644 --- a/scripts/setup/officer-setup/lib/env.sh +++ b/scripts/setup/officer-setup/lib/env.sh @@ -5,30 +5,24 @@ # # Definitions only. # -# ── Two secrets that must never be regenerated ── +# ── What is NOT here ── # -# JWT_SECRET signs every session token. Minting a new one logs everybody out of -# every device, silently — the symptom is people being signed out for no stated -# reason. The original regenerated it on every run that answered "yes" to -# regenerating .env. +# JWT_SECRET and VAULT_STORE_KEY are not written. They are moving into the SQLite +# key store (docs/secret-store.md), and writing them here in the meantime would +# mean generating a value that the store then has to be reconciled with — two +# origins for one secret, which is the failure the store exists to end. # -# VAULT_STORE_KEY is worse, and the original never wrote it at all — so a -# scripted install had no key and the vault and wallet refused to store anything. -# It encrypts every upstream credential the platform holds (see docs/secret-store.md -# for the full list) and, on top of the owner passphrase, the BIP39 seed envelope. -# Changing it makes all of them unreadable, and for the seed that is unrecoverable: -# the passphrase opens the inner envelope, and the outer one is gone. Unless the -# mnemonic was written down offline, so are the coins. -# -# Both are read back from an existing .env and kept. Both are slated to move into -# the secret store — docs/secret-store.md — which is what makes changing them an -# operation rather than data loss. +# The consequence is honest and deliberate: jwt.ts throws at module load without +# JWT_SECRET, so an install made by this script does not boot until the store +# lands. That sequencing was chosen rather than stumbled into. # # ── Derived, not asked ── # -# DATA_PATH and OFFICER_ITEMS_DIR come from $OFFICER_ROOT. They were two separate -# questions in the original, which had to agree with each other and with where the -# app store looks. +# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone too, and this time nothing +# replaces them. The platform derives the install root as the parent of its own +# working directory, so data/, capabilities/ and dockers/ follow from the layout +# on disk, and the owner's home comes from the OS. They were three environment +# variables that had to agree with each other and with the directory tree. [[ -n "${OFFICER_SETUP_ENV_LOADED:-}" ]] && return 0 OFFICER_SETUP_ENV_LOADED=1 @@ -50,10 +44,6 @@ env_get() { }' "$(env_file)" } -# Long enough to be worth having, and stripped of characters that would need -# quoting in a file everything reads with a naive parser. -generate_secret() { openssl rand -base64 48 | tr -d '/+=\n' | head -c 48; } - # Origin checking is OFF unless this is explicitly false — CLAUDE.md is explicit # that the inversion is deliberate and is only defensible because the tailnet is # the perimeter. With no tailnet there is no perimeter, so the default stops @@ -80,33 +70,12 @@ write_env() { # holds the token-signing secret and the database credential. PORT="${ENV_PORT}" -PUBLIC_URL="${ENV_PUBLIC_URL}" -PUBLIC_BUILD_ENV="production" # The browser relay listens on its own port, separate from the app. BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT}" -# ── Do not regenerate either of these ── -# -# JWT_SECRET signs every session token. A new one logs everybody out, everywhere. -JWT_SECRET="${ENV_JWT_SECRET}" - -# VAULT_STORE_KEY encrypts every upstream credential in Postgres, and encrypts -# the wallet's seed envelope on top of the owner passphrase. Changing it makes -# all of them unreadable — and for the seed that is unrecoverable, passphrase or -# not. Back it up with the same seriousness as the mnemonics. -VAULT_STORE_KEY="${ENV_VAULT_STORE_KEY}" - POSTGRES_URL="${POSTGRES_URL}" -# Derived from the install root — see scripts/setup/officer-setup/lib/layout.sh. -DATA_PATH="${OFFICER_ROOT}/data" -OFFICER_ITEMS_DIR="${OFFICER_ROOT}/capabilities" - -# The owner's real login home, which is where terminals, chats and task runs -# actually execute — as opposed to the managed home under DATA_PATH. -HOME_DIR="${USER_HOME}" - # Origin checking. Off by default in the platform, which is only safe behind a # tailnet; written explicitly here so the machine's actual situation decides it. ALLOW_ANY_ORIGIN="${ENV_ALLOW_ANY_ORIGIN}" diff --git a/src/server.tsx b/src/server.tsx index c41b9a21..f0a8829b 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -3,6 +3,7 @@ import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono'; import { assertCapabilityTotality } from './servers/capabilities/totality'; +import { assertInstallLayout } from './servers/data-path'; import { assertSecretsClosed } from './servers/os-user'; import { resolveHomeDir } from './servers/user-home'; import { resolveAuthToken } from './servers/auth-token'; @@ -151,9 +152,13 @@ assertCapabilityTotality({ wsProviders: Object.keys(handlers), }); +// That the working directory is the repo, because every path derives from its parent. First of the three, +// since a wrong answer here makes the other two check the wrong files. +assertInstallLayout(); + // And, when members have real Linux accounts, that they cannot read the credentials that would make those // accounts pointless. Also before serve(), also throws: a shell handed out next to a world-readable -// JWT_SECRET is worse than no isolation, because the model looks intact. No-op while the feature is off. +// JWT_SECRET is worse than no isolation, because the model looks intact. await assertSecretsClosed(process.cwd()); async function upgradeWs( diff --git a/src/servers/api/activity/router.ts b/src/servers/api/activity/router.ts index c91f0a9e..e77902e4 100644 --- a/src/servers/api/activity/router.ts +++ b/src/servers/api/activity/router.ts @@ -12,7 +12,8 @@ import { parseTailLine } from './progress'; export const activityRouter = createRouter(); -const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); +import { DATA_PATH } from '../../data-path'; + const HOME_DIR = process.env.HOME_DIR ?? process.env.HOME ?? ''; const ANNOUNCED_PATH = join(DATA_PATH, 'activity', 'announced.json'); const ALLOWED_ROOTS = ['/tmp', DATA_PATH, HOME_DIR].filter(Boolean); diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 7bcc3aab..34b93be3 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -31,9 +31,10 @@ import { readSttConfig } from '../server-settings/stt'; /** * Whose transcripts a request may read. * - * The home comes from `resolveHomeDir`, never from `getOwnerHomeDir` — that one ignores its argument whenever - * HOME_DIR is set, which is how every read in this router used to resolve to the owner's `~/.claude` no matter - * who asked. Throws rather than falling back, for the same reason `resolveTurnIdentity` refuses: there is no + * The home comes from `resolveHomeDir`, never from `getOwnerHomeDir` — that one ignores its argument and + * always answers the owner, which is how every read in this router used to resolve to the owner's + * `~/.claude` no matter who asked. Throws rather than falling back, for the same reason + * `resolveTurnIdentity` refuses: there is no * safe home to substitute, and the owner's is the one wrong answer. * * Unreachable by a member today — the router refuses non-owners above — so this is the path being made correct diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 96df010c..ae3041cb 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -86,7 +86,7 @@ const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour /** * Where a turn runs, relative to the caller's own home. * - * `root` used to be `getOwnerHomeDir(email)`, which ignores its argument whenever HOME_DIR is set — so every + * `root` used to be `getOwnerHomeDir(email)`, which ignores its argument — so every * `~` expanded to the OWNER'S home regardless of who asked, and the comment here said "the server owner is * the only account" as though that were a property rather than an assumption. * diff --git a/src/servers/app-store/paths.ts b/src/servers/app-store/paths.ts index 1291fd51..cd106f80 100644 --- a/src/servers/app-store/paths.ts +++ b/src/servers/app-store/paths.ts @@ -1,5 +1,5 @@ -import { dirname, join } from 'node:path'; -import { DATA_PATH } from '../data-path'; +import { join } from 'node:path'; +import { OFFICER_ROOT } from '../data-path'; // Where the app store puts the containers it provisions. // @@ -32,11 +32,13 @@ import { DATA_PATH } from '../data-path'; // So this directory is exclusively ours to write, and everything in it was put there by an install. /** - * The install root — the parent of `data/`. On a conventional install, `~/officerdev`. + * The install root — on a conventional install, `~/officerdev`. * - * Derived, not configured: see above. + * Derived, not configured: see above. It moved to `../data-path` on 2026-08-12, when the direction + * inverted — it used to be `dirname(DATA_PATH)`, back when DATA_PATH was the environment variable that + * anchored everything. Re-exported here because this file is where callers expect to find it. */ -export const OFFICER_ROOT = dirname(DATA_PATH); +export { OFFICER_ROOT }; /** Where provisioned services live, one directory each. Created on first install, not at boot. */ export const DOCKERS_DIR = join(OFFICER_ROOT, 'dockers'); diff --git a/src/servers/capabilities/authorize.ts b/src/servers/capabilities/authorize.ts index 7a3982fc..b39a7211 100644 --- a/src/servers/capabilities/authorize.ts +++ b/src/servers/capabilities/authorize.ts @@ -89,7 +89,7 @@ export async function getEffectiveCapabilities(userId: number | undefined): Prom // A confined capability touches the filesystem or runs a process, and is safe only because the // account has its own Linux user to be confined to. Without one there is no boundary, so the grant // resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since - // `getOwnerHomeDir` ignores the email it is passed whenever HOME_DIR is set. + // `getOwnerHomeDir` ignores the email it is passed, always. // // Dropped here rather than refused per-router so that one rule covers the HTTP routes, the // websocket doors and the dock all at once. A member with `files` granted but no OS account sees no diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index b74c2768..117c85cf 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -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. // diff --git a/src/servers/sidecar/caldav/index.ts b/src/servers/sidecar/caldav/index.ts index 4845083b..2e680e27 100644 --- a/src/servers/sidecar/caldav/index.ts +++ b/src/servers/sidecar/caldav/index.ts @@ -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 { diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 6a77ee70..5ef331bc 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -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; diff --git a/src/servers/sidecar/claude/state.ts b/src/servers/sidecar/claude/state.ts index b2266c4a..d9952673 100644 --- a/src/servers/sidecar/claude/state.ts +++ b/src/servers/sidecar/claude/state.ts @@ -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. diff --git a/src/servers/sidecar/claude/user-instance.ts b/src/servers/sidecar/claude/user-instance.ts index 6783c539..8a9f032d 100644 --- a/src/servers/sidecar/claude/user-instance.ts +++ b/src/servers/sidecar/claude/user-instance.ts @@ -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'; diff --git a/src/servers/sidecar/headscale/claude-proxy.ts b/src/servers/sidecar/headscale/claude-proxy.ts index 5219dd55..3013a9d6 100644 --- a/src/servers/sidecar/headscale/claude-proxy.ts +++ b/src/servers/sidecar/headscale/claude-proxy.ts @@ -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. */ diff --git a/src/servers/sidecar/music/index.ts b/src/servers/sidecar/music/index.ts index f28aafbc..4ed268f3 100644 --- a/src/servers/sidecar/music/index.ts +++ b/src/servers/sidecar/music/index.ts @@ -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 diff --git a/src/servers/sidecar/music/indexer.ts b/src/servers/sidecar/music/indexer.ts index 93655ec1..3dd92468 100644 --- a/src/servers/sidecar/music/indexer.ts +++ b/src/servers/sidecar/music/indexer.ts @@ -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']);