delete the bug-report discord webhook, and the last dead HOME_DIR reads

DISCORD_BUG_REPORT_WEBHOOK is gone, with sendToDiscord and its helpers. Reports
still land in DATA_PATH/bug-reports — the disk write always happened first and
the webhook was only a ping about it, so nothing about the report is lost.

It was a personal notification channel living in deployment config, on a platform
whose owner is the only person who files reports. It was also never in
.env.example: the setup script wrote a variable nothing documented, which is the
same drift as PORT, in the other direction.

Note DISCORD_WEBHOOK_URL is a DIFFERENT variable — the notify sidecar's own
channel — and is untouched.

Then a parity sweep of setup / .env.example / what the code reads, which turned
up two leftovers from earlier today:

HOME_DIR was still read in six files, each with its own `?? homedir()` fallback.
Dead since nothing sets it, but a dead read is worse than none — it reads as a
supported override. They take homedir() directly now. user-instance.ts gets a
comment on why its line stays where it is: it sits above `process.env.HOME =
homeDir`, and homedir() reads $HOME, so a read moved below that assignment would
return whichever member was last spawned into. Two of the six had fallback chains
ending in process.cwd() and '' — the second would have silently disabled whatever
consumed it rather than failing.

VAULTWARDEN_URL was uncommented in .env.example among the variables setup writes,
though it is a plugin variable setup has never written. Commented out with the
other plugin entries.

The three files now agree: setup writes PORT, BROWSER_RELAY_PORT and
POSTGRES_URL; .env.example lists those plus JWT_SECRET and VAULT_STORE_KEY, which
are required by code and deliberately unwritten until the secret store lands.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes three variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 00:23:32 +00:00
co-authored by Claude Opus 5
parent f063fc0c08
commit 571d0a62ff
11 changed files with 23 additions and 77 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
# Vaultwarden (officer-vault). VAULT_STORE_KEY is at the top of this file — it is the platform's # 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. # key, not Vaultwarden's, however much the name and its old position here suggested otherwise.
VAULTWARDEN_URL=http://127.0.0.1:8222 # VAULTWARDEN_URL=http://127.0.0.1:8222
# The Anthropic proxy (officer-anthropic-proxy) binds PORT + 1, derived rather than configured — see # The Anthropic proxy (officer-anthropic-proxy) binds PORT + 1, derived rather than configured — see
# src/servers/officer-url.mjs. There is nothing to set. It holds no credential from this file either: # src/servers/officer-url.mjs. There is nothing to set. It holds no credential from this file either:
-1
View File
@@ -460,7 +460,6 @@ if ! skip; then
# Read back before anything is asked; existing values become the defaults. # Read back before anything is asked; existing values become the defaults.
ENV_PORT="$(env_get PORT)" ENV_PORT="$(env_get PORT)"
ENV_DISCORD_WEBHOOK="$(env_get DISCORD_BUG_REPORT_WEBHOOK)"
ENV_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)" ENV_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)"
if env_exists; then if env_exists; then
-1
View File
@@ -70,7 +70,6 @@ BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT}"
POSTGRES_URL="${POSTGRES_URL}" POSTGRES_URL="${POSTGRES_URL}"
DISCORD_BUG_REPORT_WEBHOOK="${ENV_DISCORD_WEBHOOK}"
ENVF ENVF
umask "$prior_umask" umask "$prior_umask"
+2 -1
View File
@@ -13,8 +13,9 @@ import { parseTailLine } from './progress';
export const activityRouter = createRouter(); export const activityRouter = createRouter();
import { DATA_PATH } from '../../data-path'; import { DATA_PATH } from '../../data-path';
import { homedir } from 'node:os';
const HOME_DIR = process.env.HOME_DIR ?? process.env.HOME ?? ''; const HOME_DIR = homedir();
const ANNOUNCED_PATH = join(DATA_PATH, 'activity', 'announced.json'); const ANNOUNCED_PATH = join(DATA_PATH, 'activity', 'announced.json');
const ALLOWED_ROOTS = ['/tmp', DATA_PATH, HOME_DIR].filter(Boolean); const ALLOWED_ROOTS = ['/tmp', DATA_PATH, HOME_DIR].filter(Boolean);
const ACTIVE_WINDOW_MS = 120_000; // a task file touched within this is considered "active" const ACTIVE_WINDOW_MS = 120_000; // a task file touched within this is considered "active"
+10 -66
View File
@@ -3,7 +3,15 @@ import { mkdir } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { DATA_PATH } from '@@/data-path'; import { DATA_PATH } from '@@/data-path';
const DISCORD_WEBHOOK_URL = process.env.DISCORD_BUG_REPORT_WEBHOOK; // Bug reports land on disk and nowhere else.
//
// There was a Discord webhook here until 2026-08-13, behind DISCORD_BUG_REPORT_WEBHOOK. It was a
// personal notification channel living in deployment config, on a self-hosted platform whose owner is
// the only person filing reports — and it was never in .env.example, so the setup script wrote a
// variable nothing documented.
//
// Nothing is lost from the report itself: the disk write below always happened first, and the webhook
// was only a ping about it.
export const bugReportRouter = createRouter(); export const bugReportRouter = createRouter();
@@ -36,73 +44,9 @@ bugReportRouter.post('/', async (ctx) => {
await Bun.write(join(reportDir, 'report.json'), JSON.stringify(report, null, 2)); await Bun.write(join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
let screenshotBuffer: Buffer | null = null;
if (screenshot instanceof File) { if (screenshot instanceof File) {
screenshotBuffer = Buffer.from(await screenshot.arrayBuffer()); await Bun.write(join(reportDir, 'screenshot.png'), Buffer.from(await screenshot.arrayBuffer()));
await Bun.write(join(reportDir, 'screenshot.png'), screenshotBuffer);
}
if (DISCORD_WEBHOOK_URL) {
await sendToDiscord(report, screenshotBuffer);
} }
return ctx.json({ ok: true, id: dirName }); return ctx.json({ ok: true, id: dirName });
}); });
type BugReport = {
description: string;
context: {
url?: string;
userAgent?: string;
viewport?: { width: number; height: number };
apiError?: { status: number; message: string } | null;
} | null;
reporter: { id: number; email: string; name: string | null };
createdAt: string;
};
async function sendToDiscord(report: BugReport, screenshot: Buffer | null) {
const embed = {
title: 'Bug Report',
description: report.description,
color: 0xed4245,
fields: [
{ name: 'Reporter', value: `${report.reporter.name} (${report.reporter.email})`, inline: true },
{ name: 'URL', value: report.context?.url ?? 'N/A', inline: false },
{
name: 'Viewport',
value: report.context?.viewport ? `${report.context.viewport.width}x${report.context.viewport.height}` : 'N/A',
inline: true,
},
{ name: 'Browser', value: shortenUA(report.context?.userAgent), inline: true },
],
timestamp: report.createdAt,
};
if (report.context?.apiError) {
embed.fields.push({
name: 'Last API Error',
value: `${report.context.apiError.status}: ${report.context.apiError.message}`,
inline: false,
});
}
const form = new FormData();
form.append('payload_json', JSON.stringify({ embeds: [embed] }));
if (screenshot) {
form.append('files[0]', new Blob([new Uint8Array(screenshot)], { type: 'image/png' }), 'screenshot.png');
}
const res = await fetch(DISCORD_WEBHOOK_URL!, { method: 'POST', body: form });
if (!res.ok) {
console.error('[bug-report] Discord webhook failed:', res.status, await res.text());
}
}
function shortenUA(ua?: string): string {
if (!ua) return 'N/A';
const browser = ua.match(/(Chrome|Firefox|Safari|Edge|Brave|OPR)\/[\d.]+/)?.[0] ?? '';
const os = ua.match(/\(([^)]+)\)/)?.[1]?.split(';')[0] ?? '';
return [browser, os].filter(Boolean).join(' — ') || ua.slice(0, 80);
}
+2 -2
View File
@@ -23,8 +23,8 @@ import { DATA_PATH } from '../../data-path';
// //
// ── Why this takes a home instead of an email ── // ── Why this takes a home instead of an email ──
// //
// It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discards its argument whenever // It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discarded its argument
// HOME_DIR is set — which is always, on a real install. Every read therefore resolved to the OWNER'S // whenever HOME_DIR was set — which was always, on a real install. Every read therefore resolved to the OWNER'S
// transcripts regardless of who was asking, and the comment above it said "single-user platform" as though // transcripts regardless of who was asking, and the comment above it said "single-user platform" as though
// that were a property rather than an assumption. A member reaching these functions would have been handed the // that were a property rather than an assumption. A member reaching these functions would have been handed the
// owner's conversation list. // owner's conversation list.
+3 -1
View File
@@ -80,7 +80,9 @@ const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.u
// perfect parity with terminal sessions (same config, credentials and transcript store, // perfect parity with terminal sessions (same config, credentials and transcript store,
// interchangeable via `claude --resume`). That absence of isolation is precisely why `chat` is an // interchangeable via `claude --resume`). That absence of isolation is precisely why `chat` is an
// `execution` capability and can never be granted: this is a shell, not a feature flag. // `execution` capability and can never be granted: this is a shell, not a feature flag.
const homeDir = process.env.HOME_DIR ?? homedir(); // Evaluated here, ABOVE the `process.env.HOME = homeDir` below: homedir() reads $HOME, so a
// read placed after that assignment would return whatever was last spawned into.
const homeDir = homedir();
const globalToolsDir = join(DATA_PATH, 'tools'); const globalToolsDir = join(DATA_PATH, 'tools');
// The email_db MCP tool reads one account's SQLite store; match the API's choice (first enabled). // The email_db MCP tool reads one account's SQLite store; match the API's choice (first enabled).
+1 -1
View File
@@ -20,7 +20,7 @@ const ASOUNDRC_PATH = join(import.meta.dir, 'asoundrc');
// Single super user, so the owner's home is the root every path is resolved against — same convention as // Single super user, so the owner's home is the root every path is resolved against — same convention as
// stream-audio.ts and the indexer. // stream-audio.ts and the indexer.
const ROOT_DIR = process.env.HOME_DIR ?? homedir(); const ROOT_DIR = homedir();
// parec's output format IS the contract with the browser's AudioWorklet: signed 16-bit LE, 44.1kHz, stereo. // parec's output format IS the contract with the browser's AudioWorklet: signed 16-bit LE, 44.1kHz, stereo.
const CAPTURE_ARGS = ['--format=s16le', '--rate=44100', '--channels=2', '-d', `${VIRTUAL_SINK}.monitor`]; const CAPTURE_ARGS = ['--format=s16le', '--rate=44100', '--channels=2', '-d', `${VIRTUAL_SINK}.monitor`];
+1 -1
View File
@@ -22,7 +22,7 @@ import { homedir } from 'node:os';
import { DATA_PATH } from '../../data-path'; import { DATA_PATH } from '../../data-path';
const HOME = process.env.HOME_DIR ?? homedir(); const HOME = homedir();
export const MUSIC_ROOT = join(HOME, 'Music'); export const MUSIC_ROOT = join(HOME, 'Music');
const CACHE_ROOT = join(DATA_PATH, 'music', 'cache'); const CACHE_ROOT = join(DATA_PATH, 'music', 'cache');
const MANIFEST_PATH = join(CACHE_ROOT, 'manifest.json'); const MANIFEST_PATH = join(CACHE_ROOT, 'manifest.json');
+1 -1
View File
@@ -5,7 +5,7 @@ import { homedir } from 'node:os';
// All processing lives here (the platform is just a proxy). Files live under the owner's home — single // All processing lives here (the platform is just a proxy). Files live under the owner's home — single
// super user, so HOME_DIR. Paths from the app are home-relative (e.g. "Music/Artist/Album/track.mp3"), // super user, so HOME_DIR. Paths from the app are home-relative (e.g. "Music/Artist/Album/track.mp3"),
// exactly like file-browser /raw. // exactly like file-browser /raw.
const ROOT_DIR = process.env.HOME_DIR ?? homedir(); const ROOT_DIR = homedir();
const CONTENT_TYPES: Record<string, string> = { const CONTENT_TYPES: Record<string, string> = {
mp3: 'audio/mpeg', mp3: 'audio/mpeg',
+2 -1
View File
@@ -1,5 +1,6 @@
import { join } from 'node:path'; import { join } from 'node:path';
import * as pty from 'node-pty'; import * as pty from 'node-pty';
import { homedir } from 'node:os';
// The shell store. Everything about running a terminal lives here: what shell, where it opens, how much // The shell store. Everything about running a terminal lives here: what shell, where it opens, how much
// scrollback is kept, who is watching. The platform holds none of it — it authenticates a browser and // scrollback is kept, who is watching. The platform holds none of it — it authenticates a browser and
@@ -11,7 +12,7 @@ const BUFFER_MAX = 512 * 1024;
// HOME_DIR mirrors `data-path.ts:getOwnerHomeDir` — on a host where the owner's real login home differs // HOME_DIR mirrors `data-path.ts:getOwnerHomeDir` — on a host where the owner's real login home differs
// from this process's HOME, the shell should open in the former, like every other host-executing surface. // from this process's HOME, the shell should open in the former, like every other host-executing surface.
const HOME_DIR = process.env.HOME_DIR ?? process.env.HOME ?? process.cwd(); const HOME_DIR = homedir();
const SHELL = { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }; const SHELL = { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] };
// A terminal is always a plain host shell: the owner is the only account and it is their own machine // A terminal is always a plain host shell: the owner is the only account and it is their own machine