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:
+33
-20
@@ -1,22 +1,44 @@
|
||||
# What officer-setup writes. Everything below this block is optional, or is on its way out.
|
||||
PORT=9000
|
||||
JWT_SECRET="<generate with: openssl rand -base64 32>"
|
||||
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="<generate with: openssl rand -base64 32>"
|
||||
|
||||
# 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="<generate with: openssl rand -base64 32>"
|
||||
|
||||
# ── 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="<generate with: openssl rand -base64 32>"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -24,6 +24,27 @@
|
||||
* @param {string[]} spec.include app names to run, in start order
|
||||
* @param {Record<string,string>} 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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+6
-1
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
+93
-10
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user