@schlessera/brain-ui-server 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +1 -0
  2. package/dist/agent/backend.d.ts +31 -10
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +86 -26
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +47 -16
  8. package/dist/app.js.map +1 -1
  9. package/dist/config/env-core.d.ts +73 -0
  10. package/dist/config/env-core.d.ts.map +1 -0
  11. package/dist/config/env-core.js +62 -0
  12. package/dist/config/env-core.js.map +1 -0
  13. package/dist/config/env.d.ts +19 -5
  14. package/dist/config/env.d.ts.map +1 -1
  15. package/dist/config/env.js +43 -11
  16. package/dist/config/env.js.map +1 -1
  17. package/dist/cron/scheduler.d.ts +17 -0
  18. package/dist/cron/scheduler.d.ts.map +1 -1
  19. package/dist/cron/scheduler.js +33 -6
  20. package/dist/cron/scheduler.js.map +1 -1
  21. package/dist/db/settings.d.ts +1 -1
  22. package/dist/db/settings.d.ts.map +1 -1
  23. package/dist/db/settings.js +2 -2
  24. package/dist/db/settings.js.map +1 -1
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/middleware/auth.d.ts +5 -0
  30. package/dist/middleware/auth.d.ts.map +1 -1
  31. package/dist/middleware/auth.js +46 -1
  32. package/dist/middleware/auth.js.map +1 -1
  33. package/dist/routes/health.d.ts +4 -14
  34. package/dist/routes/health.d.ts.map +1 -1
  35. package/dist/routes/health.js +22 -6
  36. package/dist/routes/health.js.map +1 -1
  37. package/dist/routes/share.d.ts.map +1 -1
  38. package/dist/routes/share.js +2 -2
  39. package/dist/routes/share.js.map +1 -1
  40. package/dist/voice/keyterm-builder.js +2 -2
  41. package/dist/voice/keyterm-builder.js.map +1 -1
  42. package/dist/ws/bridge.d.ts.map +1 -1
  43. package/dist/ws/bridge.js +17 -2
  44. package/dist/ws/bridge.js.map +1 -1
  45. package/dist/ws/clients.d.ts +10 -3
  46. package/dist/ws/clients.d.ts.map +1 -1
  47. package/dist/ws/clients.js +16 -4
  48. package/dist/ws/clients.js.map +1 -1
  49. package/dist/ws/connection.d.ts +1 -1
  50. package/dist/ws/connection.d.ts.map +1 -1
  51. package/dist/ws/connection.js +11 -2
  52. package/dist/ws/connection.js.map +1 -1
  53. package/dist/ws/dispatch.d.ts.map +1 -1
  54. package/dist/ws/dispatch.js +3 -1
  55. package/dist/ws/dispatch.js.map +1 -1
  56. package/dist/ws/host.d.ts +36 -0
  57. package/dist/ws/host.d.ts.map +1 -1
  58. package/dist/ws/host.js +93 -4
  59. package/dist/ws/host.js.map +1 -1
  60. package/dist/ws/run-session.d.ts.map +1 -1
  61. package/dist/ws/run-session.js +27 -5
  62. package/dist/ws/run-session.js.map +1 -1
  63. package/dist/ws/session-catalog.d.ts +2 -1
  64. package/dist/ws/session-catalog.d.ts.map +1 -1
  65. package/dist/ws/session-catalog.js +20 -3
  66. package/dist/ws/session-catalog.js.map +1 -1
  67. package/dist/ws/turns.d.ts +8 -0
  68. package/dist/ws/turns.d.ts.map +1 -1
  69. package/dist/ws/turns.js.map +1 -1
  70. package/package.json +7 -6
  71. package/src/agent/backend.ts +123 -29
  72. package/src/app.ts +55 -16
  73. package/src/config/env-core.ts +93 -0
  74. package/src/config/env.ts +61 -17
  75. package/src/cron/scheduler.ts +48 -14
  76. package/src/db/settings.ts +2 -2
  77. package/src/index.ts +6 -0
  78. package/src/middleware/auth.ts +50 -1
  79. package/src/routes/health.ts +22 -6
  80. package/src/routes/share.ts +11 -7
  81. package/src/voice/keyterm-builder.ts +2 -2
  82. package/src/ws/bridge.ts +17 -5
  83. package/src/ws/clients.ts +24 -6
  84. package/src/ws/connection.ts +11 -2
  85. package/src/ws/dispatch.ts +3 -1
  86. package/src/ws/host.ts +115 -6
  87. package/src/ws/run-session.ts +32 -8
  88. package/src/ws/session-catalog.ts +21 -3
  89. package/src/ws/turns.ts +8 -0
@@ -14,7 +14,7 @@ import type {
14
14
  * tests install fakes by constructing a registry, not by mutating a module.
15
15
  *
16
16
  * BOTH backend packages are optional peers loaded lazily (the same
17
- * `createRequire` path): a deployment installs the one its AGENT_BACKEND
17
+ * dynamic-import path): a deployment installs the one its AGENT_BACKEND
18
18
  * names, and the other never has to be present — at runtime AND at
19
19
  * type-check time (see the structural mirrors below).
20
20
  *
@@ -48,6 +48,33 @@ import type {
48
48
  * incompatible drift slide.
49
49
  */
50
50
 
51
+ /**
52
+ * Mirror of the backends' `BackendLogFn` — the minimal log seam a backend
53
+ * accepts. A callback rather than a Logger so the backend packages carry no
54
+ * telemetry dependency; {@link toBackendLog} adapts the registry's Logger.
55
+ */
56
+ export type BackendLogFn = (
57
+ level: "debug" | "info" | "warn" | "error",
58
+ message: string,
59
+ attrs?: Record<string, string | number | boolean>
60
+ ) => void;
61
+
62
+ const LEVEL_SEVERITY = {
63
+ debug: "DEBUG",
64
+ info: "INFO",
65
+ warn: "WARN",
66
+ error: "ERROR",
67
+ } as const;
68
+
69
+ function toBackendLog(log: Logger): BackendLogFn {
70
+ return (level, message, attrs) =>
71
+ log.emit({
72
+ severityText: LEVEL_SEVERITY[level],
73
+ body: message,
74
+ ...(attrs ? { attributes: attrs } : {}),
75
+ });
76
+ }
77
+
51
78
  /** Mirror of the Claude package's `InferenceProfileInput` (declarative shape). */
52
79
  export interface ClaudeProfileInput {
53
80
  id: string;
@@ -95,6 +122,8 @@ export interface ClaudeBackendModule {
95
122
  brainPath: string;
96
123
  claudeCodePath?: string;
97
124
  profiles?: ClaudeProfile[] | (() => ClaudeProfile[]);
125
+ confirmBashPatterns?: readonly string[];
126
+ log?: BackendLogFn;
98
127
  }) => AgentBackend;
99
128
  createModelSource: (options: {
100
129
  brainPath: string;
@@ -146,6 +175,12 @@ export interface BackendRegistryOptions {
146
175
  * sessions pinned to it. Defaults to "nothing hidden".
147
176
  */
148
177
  getHiddenModelIds?: () => string[];
178
+ /**
179
+ * Where the registry and its backends report. Adapted to the backends'
180
+ * minimal callback ({@link BackendLogFn}) before crossing the package
181
+ * boundary. Absent means silence.
182
+ */
183
+ log?: Logger;
149
184
  }
150
185
 
151
186
  export interface BackendRegistry {
@@ -258,6 +293,59 @@ function missingBackendError(primary: "claude" | "pi"): Error {
258
293
  * The `resolve` parameter exists for tests (simulating an absent package);
259
294
  * production callers pass nothing.
260
295
  */
296
+ /** The specifier an ERR_MODULE_NOT_FOUND failed on, or null for any other error. */
297
+ function moduleNotFoundSpecifier(error: unknown): string | null {
298
+ if (typeof error !== "object" || error === null) return null;
299
+ const { code, specifier, message } = error as {
300
+ code?: unknown;
301
+ specifier?: unknown;
302
+ message?: unknown;
303
+ };
304
+ if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") return null;
305
+ // Bun's ResolveMessage (not an Error instance) carries the failing
306
+ // specifier as a property; Node quotes it in the message instead.
307
+ if (typeof specifier === "string") return specifier;
308
+ if (typeof message === "string") {
309
+ const quoted = /Cannot find (?:package|module) '([^']+)'/.exec(message);
310
+ if (quoted) return quoted[1];
311
+ }
312
+ return null;
313
+ }
314
+
315
+ /**
316
+ * Load an optional backend package, mapping ONLY "the backend package itself
317
+ * is not installed" to the actionable install hint. Everything else — the
318
+ * backend missing one of its OWN transitive deps, a syntax error, a throwing
319
+ * top-level — is a different failure whose original error IS the diagnostic,
320
+ * so it is rethrown untouched. The distinction rides on ERR_MODULE_NOT_FOUND
321
+ * naming the specifier it failed on: a transitive miss names the transitive
322
+ * dep, not the backend, and must not read as "backend not installed".
323
+ *
324
+ * `await import()` rather than `createRequire()(...)`: the backend packages
325
+ * are ESM, and a CJS require of them under plain Node dies with
326
+ * ERR_REQUIRE_ESM — which the old blanket catch then reported as "not
327
+ * installed" on a machine where the package was sitting right there.
328
+ *
329
+ * The `importer` parameter exists for tests (simulating absent or broken
330
+ * packages); production callers pass nothing.
331
+ */
332
+ export async function loadBackendModule(
333
+ // "claude" | "pi" spelled out, NOT keyof typeof BACKEND_SPECIFIERS: the
334
+ // keyof form drags the table's literal string types — the backend
335
+ // specifiers — into the emitted .d.ts, which the declaration-surface gate
336
+ // rightly refuses.
337
+ key: "claude" | "pi",
338
+ importer: (specifier: string) => Promise<unknown> = (specifier) => import(specifier)
339
+ ): Promise<unknown> {
340
+ const specifier = BACKEND_SPECIFIERS[key];
341
+ try {
342
+ return await importer(specifier);
343
+ } catch (error) {
344
+ if (moduleNotFoundSpecifier(error) === specifier) throw missingBackendError(key);
345
+ throw error;
346
+ }
347
+ }
348
+
261
349
  export function assertBackendResolvable(
262
350
  agent: AgentConfig,
263
351
  resolve: (specifier: string) => void = (specifier) => {
@@ -279,6 +367,7 @@ export function createBackendRegistry(
279
367
  ): BackendRegistry {
280
368
  const { brainPath, agent } = options;
281
369
  const getHidden = options.getHiddenModelIds ?? (() => []);
370
+ const backendLog = options.log ? toBackendLog(options.log) : undefined;
282
371
 
283
372
  let cachedRegistry: Promise<RegistrySnapshot> | null = null;
284
373
  let modelSource: ClaudeModelSource | null = null;
@@ -392,18 +481,12 @@ export function createBackendRegistry(
392
481
  /**
393
482
  * NEITHER backend package is a hard dependency — a deployment installs the
394
483
  * one its AGENT_BACKEND names (both, if it switches). Loaded lazily through
395
- * the same `createRequire` path so the server still boots without the unused
396
- * one; when the configured backend's package is absent, fail with an
397
- * actionable message.
484
+ * {@link loadBackendModule} so the server still boots without the unused
485
+ * one; only "the package is absent" maps to the actionable install hint,
486
+ * any other load failure surfaces as itself.
398
487
  */
399
- function buildClaudeBackend(): AgentBackend {
400
- const require = createRequire(import.meta.url);
401
- let claude: ClaudeBackendModule;
402
- try {
403
- claude = require("@schlessera/brain-backend-claude");
404
- } catch {
405
- throw missingBackendError("claude");
406
- }
488
+ async function buildClaudeBackend(): Promise<AgentBackend> {
489
+ const claude = (await loadBackendModule("claude")) as ClaudeBackendModule;
407
490
  if (typeof claude.createClaudeBackend !== "function") {
408
491
  throw new Error(
409
492
  '"@schlessera/brain-backend-claude" does not export createClaudeBackend.'
@@ -423,6 +506,12 @@ export function createBackendRegistry(
423
506
  return claude.createClaudeBackend({
424
507
  brainPath,
425
508
  claudeCodePath: agent.claudeCodePath,
509
+ ...(backendLog ? { log: backendLog } : {}),
510
+ // Omitted entirely when unconfigured, so the backend's own defaults
511
+ // apply; an explicit [] passes through and disables confirmation.
512
+ ...(agent.confirmBashPatterns !== null
513
+ ? { confirmBashPatterns: agent.confirmBashPatterns }
514
+ : {}),
426
515
  // A function, not an array: discovery refreshes in the background and the
427
516
  // new roster has to be visible without restarting the process.
428
517
  profiles: () =>
@@ -430,31 +519,30 @@ export function createBackendRegistry(
430
519
  });
431
520
  }
432
521
 
433
- function buildPiBackend(): AgentBackend {
434
- const require = createRequire(import.meta.url);
435
- let mod: { createPiBackend?: (opts: { brainPath: string }) => AgentBackend };
436
- try {
437
- mod = require("@schlessera/brain-backend-pi");
438
- } catch {
439
- throw missingBackendError("pi");
440
- }
522
+ async function buildPiBackend(): Promise<AgentBackend> {
523
+ const mod = (await loadBackendModule("pi")) as {
524
+ createPiBackend?: (opts: { brainPath: string; log?: BackendLogFn }) => AgentBackend;
525
+ };
441
526
  if (typeof mod.createPiBackend !== "function") {
442
527
  throw new Error(
443
528
  '"@schlessera/brain-backend-pi" does not export createPiBackend.'
444
529
  );
445
530
  }
446
- return mod.createPiBackend({ brainPath });
531
+ return mod.createPiBackend({
532
+ brainPath,
533
+ ...(backendLog ? { log: backendLog } : {}),
534
+ });
447
535
  }
448
536
 
449
537
  async function buildRegistry(): Promise<RegistrySnapshot> {
450
538
  const primary = agent.backend || "claude";
451
539
 
452
540
  if (primary === "pi") {
453
- const pi = buildPiBackend();
541
+ const pi = await buildPiBackend();
454
542
  return buildSnapshot([pi], pi.id);
455
543
  }
456
544
 
457
- const backends = [buildClaudeBackend()];
545
+ const backends = [await buildClaudeBackend()];
458
546
 
459
547
  // AGENT_BACKEND must name a configured backend. The in-process options are
460
548
  // "claude" (default) and "pi" (handled above). An unrecognized value is a
@@ -471,10 +559,15 @@ export function createBackendRegistry(
471
559
  return cachedRegistry;
472
560
  }
473
561
 
474
- return makeRegistry(getRegistry, getHidden, async () => {
475
- await getRegistry();
476
- return modelSource;
477
- });
562
+ return makeRegistry(
563
+ getRegistry,
564
+ getHidden,
565
+ async () => {
566
+ await getRegistry();
567
+ return modelSource;
568
+ },
569
+ options.log
570
+ );
478
571
  }
479
572
 
480
573
  /**
@@ -486,13 +579,14 @@ export function createBackendRegistry(
486
579
  export function createStaticBackendRegistry(
487
580
  backends: AgentBackend[],
488
581
  defaultBackendId = backends[0]?.id ?? "",
489
- options: { getHiddenModelIds?: () => string[] } = {}
582
+ options: { getHiddenModelIds?: () => string[]; log?: Logger } = {}
490
583
  ): BackendRegistry {
491
584
  const snapshot = buildSnapshot(backends, defaultBackendId);
492
585
  return makeRegistry(
493
586
  async () => snapshot,
494
587
  options.getHiddenModelIds ?? (() => []),
495
- async () => null
588
+ async () => null,
589
+ options.log
496
590
  );
497
591
  }
498
592
 
package/src/app.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Context } from "hono";
3
3
  import type { Database } from "bun:sqlite";
4
- import { logger } from "hono/logger";
5
4
  import { cors } from "hono/cors";
6
5
  import { serveStatic } from "hono/bun";
7
6
  import { join } from "path";
8
7
  import { resolveServerConfig, type ServerConfig } from "./config/env.js";
9
- import { healthRoutes, createStatusRoutes } from "./routes/health.js";
8
+ import { createHealthRoutes, createStatusRoutes } from "./routes/health.js";
10
9
  import { createBrainRoutes } from "./routes/brain.js";
11
10
  import { createSessionRoutes } from "./routes/sessions.js";
12
11
  import { createVoiceRoutes } from "./routes/voice.js";
@@ -168,7 +167,8 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
168
167
  // Per-instance state: the app's own database, the brain CLI wrapper, the
169
168
  // backend registry, and the WebSocket host. No module-level singletons —
170
169
  // two apps with different configuration coexist in one process.
171
- const db = createUiDb(config.dbPath, { log: observability.logger("db") });
170
+ const dbLog = observability.logger("db");
171
+ const db = createUiDb(config.dbPath, { log: dbLog });
172
172
  const brain = createBrainClient({ brainPath: config.brainPath });
173
173
  const cron = createCronScheduler({ db, brain, log: observability.logger("cron") });
174
174
  const registry =
@@ -176,36 +176,60 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
176
176
  createBackendRegistry({
177
177
  brainPath: config.brainPath,
178
178
  agent: config.agent,
179
- getHiddenModelIds: () => getHiddenModelIds(db),
179
+ getHiddenModelIds: () => getHiddenModelIds(db, dbLog),
180
+ log: observability.logger("agent"),
180
181
  });
181
182
  const host = new WsHost({
182
183
  registry,
183
184
  observability,
184
- catalog: createSessionCatalog(() => db),
185
+ catalog: createSessionCatalog(() => db, dbLog),
185
186
  ...(options.appName ? { appName: options.appName } : {}),
186
187
  ...(options.turnTimeoutMs ? { turnTimeoutMs: options.turnTimeoutMs } : {}),
187
188
  maxConcurrentSessions: () => config.maxConcurrentSessions,
188
189
  wsRate: config.wsRate,
189
190
  });
190
191
  const wsUpgrade = createWsUpgrade(host);
192
+ // One instrument for every way a login can fail — passkey ceremonies and
193
+ // password logins land in the same series, split by attributes.
194
+ const authFailures = observability.meter("auth").createCounter("auth.failures", {
195
+ description: "Failed authentication ceremonies, by reason",
196
+ });
191
197
  const passkeyCtx: PasskeyContext = {
192
198
  db,
193
199
  webauthn: config.webauthn,
194
200
  auth,
195
201
  allowedOrigins: config.allowedOrigins,
196
202
  log: observability.logger("passkeys"),
197
- failures: observability.meter("auth").createCounter("auth.failures", {
198
- description: "Failed authentication ceremonies, by reason",
199
- }),
203
+ failures: authFailures,
200
204
  };
201
205
  const keyterms: KeytermSettings = {
202
206
  brainPath: config.brainPath,
203
207
  cacheDir: config.voice.cacheDir,
204
208
  limit: config.voice.keytermLimit,
209
+ log: observability.logger("voice"),
205
210
  };
206
211
 
207
- // Middleware
208
- app.use("*", logger());
212
+ // Request logging through the observability layer, so BRAIN_UI_LOG_LEVEL
213
+ // governs it like every other emission (hono's logger() wrote raw console
214
+ // lines no threshold or consumer swap could touch). Path only — never the
215
+ // query string or body. /api/health is skipped: the Docker healthcheck
216
+ // polls it and would drown everything else out.
217
+ const httpLog = observability.logger("http");
218
+ app.use("*", async (c, next) => {
219
+ if (c.req.path === "/api/health") return next();
220
+ const startedAt = Date.now();
221
+ await next();
222
+ httpLog.emit({
223
+ severityText: "INFO",
224
+ body: "request",
225
+ attributes: {
226
+ method: c.req.method,
227
+ path: c.req.path,
228
+ status: c.res.status,
229
+ "duration.ms": Date.now() - startedAt,
230
+ },
231
+ });
232
+ });
209
233
 
210
234
  // CORS is only needed for a SPLIT topology where the client is served from a
211
235
  // different origin than the API. ALLOWED_ORIGINS is a comma-separated
@@ -228,7 +252,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
228
252
  // probe and the login/logout routes are reachable without a session.
229
253
  // /api/status is intentionally NOT here — it leaks the git SHA, cron errors,
230
254
  // and a session oracle, so it lives behind the guard below.
231
- app.route("/api", healthRoutes);
255
+ app.route("/api", createHealthRoutes({ db }));
232
256
  // Not under /api, and not behind the guard: this is where a system share
233
257
  // lands when no service worker was around to intercept it. See the route.
234
258
  app.route("/", shareTargetFallbackRoutes);
@@ -236,6 +260,8 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
236
260
  "/api",
237
261
  authRoutes(authMode, auth, {
238
262
  passwordDisabled: (c) => passwordLoginDisabled(c, passkeyCtx),
263
+ log: authLog,
264
+ failures: authFailures,
239
265
  })
240
266
  );
241
267
  app.route("/api", passkeyPublicRoutes(authMode, passkeyCtx));
@@ -265,15 +291,28 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
265
291
  );
266
292
  app.route("/api", createSessionRoutes({ registry, db }));
267
293
  app.route("/api", createVoiceRoutes({ voice: config.voice, keyterms }));
268
- app.route("/api", createFilesRoutes({ brainRoot: config.brainPath }));
269
294
  app.route(
270
295
  "/api",
271
- createShareRoutes({ brainRoot: config.brainPath, allowedOrigins })
296
+ createFilesRoutes({ brainRoot: config.brainPath, log: observability.logger("files") })
297
+ );
298
+ app.route(
299
+ "/api",
300
+ createShareRoutes({
301
+ brainRoot: config.brainPath,
302
+ allowedOrigins,
303
+ log: observability.logger("share"),
304
+ })
272
305
  );
273
- app.route("/api", createRenderRoutes(options.renderer));
306
+ app.route("/api", createRenderRoutes(options.renderer, observability.logger("render")));
274
307
  app.route("/api", createProviderRoutes({ registry }));
275
- app.route("/api", createModelRoutes({ registry, db }));
276
- app.route("/api", createGraphRoutes({ brainRoot: config.brainPath }));
308
+ app.route(
309
+ "/api",
310
+ createModelRoutes({ registry, db, log: observability.logger("models") })
311
+ );
312
+ app.route(
313
+ "/api",
314
+ createGraphRoutes({ brainRoot: config.brainPath, log: observability.logger("graph") })
315
+ );
277
316
 
278
317
  // WebSocket endpoint. Browsers can't set headers on the WS handshake, so the
279
318
  // upgrade authenticates via the session cookie (or IP/proxy header) INSIDE
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Shared core of every package's environment chokepoint — a SYNC-ENFORCED
3
+ * COPY, not a package.
4
+ *
5
+ * This file exists byte-identically as `src/config/env-core.ts` in every
6
+ * package that has an env chokepoint (`src/config/env.ts`), and the root test
7
+ * `tests/env-core-sync.test.ts` fails the build when any copy differs. To
8
+ * change it: edit ONE copy, copy the file verbatim into every package that
9
+ * has one (each `src/config/env-core.ts`), and rerun the sync test.
10
+ *
11
+ * Why a copy and not a `@schlessera/brain-env` package: the repo adds a seam
12
+ * only where a second implementation is plausible (docs/extending/README.md),
13
+ * and a new package for four fields and three small functions would churn the
14
+ * release manifest and every dependent's dependency list. Byte-equality
15
+ * enforced by a test gives the same single-source-of-truth guarantee without
16
+ * the edge. (`scripts/env-docs.ts` imports the canonical copy in
17
+ * `packages/core` directly — it is a root script, not published code.)
18
+ *
19
+ * What belongs here: the descriptor contract the env-docs generator and the
20
+ * env-parity gate consume, the dynamic-read escape hatch, and the boolean
21
+ * parsing helpers. What does NOT belong here: `resolveEnv` — each package's
22
+ * resolver reads its own variables into its own shape and stays in that
23
+ * package's `env.ts`.
24
+ */
25
+
26
+ /** One environment variable a package reads. */
27
+ export interface EnvVarSpec {
28
+ /** Variable name as it appears in the environment. */
29
+ name: string;
30
+ /** What it controls. */
31
+ description: string;
32
+ /**
33
+ * Behaviour when the variable is unset. Omitted or null when there is no
34
+ * meaningful default to state.
35
+ */
36
+ default?: string | null;
37
+ /**
38
+ * `true` when the package cannot do its job at all without it, `false`
39
+ * when optional; a string states the CONDITION under which it becomes
40
+ * required (e.g. "AUTH_MODE=password") — flattening that to a boolean
41
+ * would lose the only part a reader needs.
42
+ */
43
+ required: boolean | string;
44
+ }
45
+
46
+ /** A family of reads whose variable NAME is data, not code. */
47
+ export interface DynamicEnvReadSpec {
48
+ /** Where the variable name comes from. */
49
+ source: string;
50
+ /** What the value is used for. */
51
+ description: string;
52
+ }
53
+
54
+ /**
55
+ * Call-time read of a single variable whose name is data (see the package's
56
+ * `DYNAMIC_ENV_READS`) or whose presence gates a feature. Never cache the
57
+ * result at module scope.
58
+ */
59
+ export function readEnvVar(
60
+ name: string,
61
+ env: NodeJS.ProcessEnv = process.env
62
+ ): string | undefined {
63
+ return env[name];
64
+ }
65
+
66
+ const TRUTHY_TOKENS: readonly string[] = ["1", "true", "on", "yes"];
67
+ const FALSY_TOKENS: readonly string[] = ["0", "false", "off", "no"];
68
+
69
+ /**
70
+ * The one way a token-valued boolean environment variable is parsed.
71
+ *
72
+ * Truthy: 1 / true / on / yes. Falsy: 0 / false / off / no. Matching is
73
+ * case-insensitive after trimming. Unset, empty, or any unrecognised token
74
+ * returns `defaultValue` — a typo in a flag must never silently flip a
75
+ * behaviour; it falls back to the documented default instead.
76
+ */
77
+ export function envFlag(value: string | undefined, defaultValue: boolean): boolean {
78
+ if (value === undefined) return defaultValue;
79
+ const token = value.trim().toLowerCase();
80
+ if (TRUTHY_TOKENS.includes(token)) return true;
81
+ if (FALSY_TOKENS.includes(token)) return false;
82
+ return defaultValue;
83
+ }
84
+
85
+ /**
86
+ * Presence-based boolean: set to ANY non-empty value means on. This is the
87
+ * `NO_COLOR` convention (https://no-color.org) — `NO_COLOR=0` still disables
88
+ * color — so it must NOT go through `envFlag`. Use only for variables whose
89
+ * documented contract is presence, not a token.
90
+ */
91
+ export function envPresent(value: string | undefined): boolean {
92
+ return value !== undefined && value !== "";
93
+ }
package/src/config/env.ts CHANGED
@@ -16,20 +16,30 @@
16
16
  import { join } from "path";
17
17
 
18
18
  import { SEVERITIES, type Severity } from "../observability/types.js";
19
+ import { envFlag } from "./env-core.js";
19
20
 
20
21
  // --- descriptor -------------------------------------------------------------
21
22
 
23
+ /**
24
+ * The shared descriptor contract (sync-enforced copy in ./env-core.ts),
25
+ * under the name this package has always exported. This package's entries
26
+ * use the `string` arm of `required` for conditionally-required variables
27
+ * (e.g. "AUTH_MODE=password") and `null` for "no default".
28
+ */
29
+ /**
30
+ * One environment variable the server reads.
31
+ *
32
+ * Deliberately LOCAL and narrower than env-core's EnvVarSpec: this is the
33
+ * package's published descriptor shape, and widening it to the shared
34
+ * union would be a breaking change for typed consumers of ENV_VARS.
35
+ */
22
36
  export interface EnvVarDescriptor {
23
- /** The environment variable, exactly as read. */
37
+ /** Variable name as it appears in the environment. */
24
38
  name: string;
25
39
  /** What it controls. */
26
40
  description: string;
27
41
  /** Human-readable default applied when unset, or null when there is none. */
28
42
  default: string | null;
29
- /**
30
- * `false` when optional; otherwise a human-readable statement of the
31
- * condition under which boot fails without it.
32
- */
33
43
  required: false | string;
34
44
  }
35
45
 
@@ -65,6 +75,19 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
65
75
  default: "(empty)",
66
76
  required: false,
67
77
  },
78
+ {
79
+ name: "BRAIN_UI_CONFIRM_BASH",
80
+ description:
81
+ "JSON array of regex sources; a Bash command matching any of them raises " +
82
+ "a confirmation card before it runs. Unset uses the shipped defaults " +
83
+ "(brain archive, rm -r, git push --force, git reset --hard, git clean -f, " +
84
+ "git checkout -- ). An empty array [] disables the confirmation. Not a " +
85
+ "security boundary — an agent with Bash can reach the same effect another " +
86
+ "way; it stops a destructive command you did not intend, not one that is " +
87
+ "trying to get past you.",
88
+ default: "the shipped pattern set",
89
+ required: false,
90
+ },
68
91
  {
69
92
  name: "BRAIN_UI_WS_RATE",
70
93
  description:
@@ -306,6 +329,11 @@ export interface WebAuthnConfig {
306
329
  export interface AgentConfig {
307
330
  /** Trimmed, lowercased AGENT_BACKEND; null when unset (defaults to claude). */
308
331
  backend: string | null;
332
+ /**
333
+ * Bash-confirmation regex sources; null means "use the backend's defaults".
334
+ * An empty array is a deliberate opt-out and is passed through as such.
335
+ */
336
+ confirmBashPatterns: string[] | null;
309
337
  claudeCodePath: string;
310
338
  defaultModel: string;
311
339
  /** Raw BRAIN_UI_CLAUDE_PROFILES JSON, parsed lazily by the registry. */
@@ -353,10 +381,6 @@ function list(raw: string | undefined): string[] {
353
381
  .filter(Boolean);
354
382
  }
355
383
 
356
- function flag(raw: string | undefined): boolean {
357
- return raw === "1";
358
- }
359
-
360
384
  /**
361
385
  * A log threshold, defaulting to INFO.
362
386
  *
@@ -371,6 +395,28 @@ function positiveNumber(raw: string | undefined, fallback: number): number {
371
395
  return Number.isFinite(n) && n >= 0 ? n : fallback;
372
396
  }
373
397
 
398
+ /**
399
+ * Parse BRAIN_UI_CONFIRM_BASH into pattern sources.
400
+ *
401
+ * Unset or unparseable → null, meaning the backend's shipped defaults. An
402
+ * explicit `[]` is honoured as "no confirmation": disabling the seatbelt is a
403
+ * choice a deployment is allowed to make, and silently re-enabling it would be
404
+ * worse than obeying. Malformed JSON falls back to the defaults rather than
405
+ * throwing — a typo here must not stop the server booting, and the safe
406
+ * direction to fail is "more confirmation", not less.
407
+ */
408
+ function parseConfirmBash(raw: string | undefined): string[] | null {
409
+ const text = raw?.trim();
410
+ if (!text) return null;
411
+ try {
412
+ const parsed = JSON.parse(text);
413
+ if (!Array.isArray(parsed)) return null;
414
+ return parsed.filter((p): p is string => typeof p === "string");
415
+ } catch {
416
+ return null;
417
+ }
418
+ }
419
+
374
420
  function parseSeverity(raw: string | undefined): Severity {
375
421
  const upper = raw?.trim().toUpperCase();
376
422
  return (SEVERITIES as readonly string[]).includes(upper ?? "")
@@ -390,10 +436,7 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
390
436
  const rawAuthMode = env.AUTH_MODE?.trim().toLowerCase() || null;
391
437
  const validMode = AUTH_MODES.find((mode) => mode === rawAuthMode) ?? null;
392
438
 
393
- const rawDiscovery = env.BRAIN_UI_MODEL_DISCOVERY?.trim().toLowerCase();
394
- const modelDiscovery = rawDiscovery
395
- ? !(rawDiscovery === "0" || rawDiscovery === "off" || rawDiscovery === "false")
396
- : env.NODE_ENV !== "test";
439
+ const modelDiscovery = envFlag(env.BRAIN_UI_MODEL_DISCOVERY, env.NODE_ENV !== "test");
397
440
 
398
441
  const rawTtl = Number(env.BRAIN_UI_MODEL_TTL_HOURS);
399
442
  const ttlHours = Number.isFinite(rawTtl) && rawTtl > 0 ? rawTtl : 24;
@@ -410,11 +453,11 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
410
453
  invalidMode: validMode ? null : rawAuthMode,
411
454
  passwordHash: env.BRAIN_UI_PASSWORD_HASH || null,
412
455
  cookieSecret: env.COOKIE_SECRET || null,
413
- trustProxy: flag(env.TRUST_PROXY),
456
+ trustProxy: envFlag(env.TRUST_PROXY, false),
414
457
  trustProxyHops: Math.max(1, Number(env.TRUST_PROXY_HOPS) || 1),
415
458
  proxyAuthHeader: (env.PROXY_AUTH_HEADER || "x-forwarded-user").toLowerCase(),
416
- dangerouslyDisableAuth: flag(env.BRAIN_UI_DANGEROUSLY_DISABLE_AUTH),
417
- allowPassword: flag(env.BRAIN_UI_ALLOW_PASSWORD),
459
+ dangerouslyDisableAuth: envFlag(env.BRAIN_UI_DANGEROUSLY_DISABLE_AUTH, false),
460
+ allowPassword: envFlag(env.BRAIN_UI_ALLOW_PASSWORD, false),
418
461
  },
419
462
  webauthn: {
420
463
  rpName: env.WEBAUTHN_RP_NAME || "Brain UI",
@@ -422,10 +465,11 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
422
465
  userId: env.WEBAUTHN_USER_ID || "brain-ui-owner",
423
466
  rpId: env.WEBAUTHN_RP_ID || null,
424
467
  origins: list(env.WEBAUTHN_ORIGINS),
425
- allowLoopbackOrigin: flag(env.BRAIN_UI_ALLOW_LOOPBACK_ORIGIN),
468
+ allowLoopbackOrigin: envFlag(env.BRAIN_UI_ALLOW_LOOPBACK_ORIGIN, false),
426
469
  },
427
470
  agent: {
428
471
  backend: env.AGENT_BACKEND?.trim().toLowerCase() || null,
472
+ confirmBashPatterns: parseConfirmBash(env.BRAIN_UI_CONFIRM_BASH),
429
473
  claudeCodePath: env.CLAUDE_CODE_PATH || "/usr/local/bin/claude",
430
474
  defaultModel: env.BRAIN_UI_CLAUDE_DEFAULT_MODEL?.trim() || "claude-sonnet-4-6",
431
475
  profilesJson: env.BRAIN_UI_CLAUDE_PROFILES?.trim() || null,