@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.
- package/README.md +1 -0
- package/dist/agent/backend.d.ts +31 -10
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +86 -26
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +47 -16
- package/dist/app.js.map +1 -1
- package/dist/config/env-core.d.ts +73 -0
- package/dist/config/env-core.d.ts.map +1 -0
- package/dist/config/env-core.js +62 -0
- package/dist/config/env-core.js.map +1 -0
- package/dist/config/env.d.ts +19 -5
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +43 -11
- package/dist/config/env.js.map +1 -1
- package/dist/cron/scheduler.d.ts +17 -0
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +33 -6
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/settings.d.ts +1 -1
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +2 -2
- package/dist/db/settings.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +5 -0
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +46 -1
- package/dist/middleware/auth.js.map +1 -1
- package/dist/routes/health.d.ts +4 -14
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +22 -6
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +2 -2
- package/dist/routes/share.js.map +1 -1
- package/dist/voice/keyterm-builder.js +2 -2
- package/dist/voice/keyterm-builder.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +17 -2
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +10 -3
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +16 -4
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +11 -2
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +3 -1
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +36 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +93 -4
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +27 -5
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/session-catalog.d.ts +2 -1
- package/dist/ws/session-catalog.d.ts.map +1 -1
- package/dist/ws/session-catalog.js +20 -3
- package/dist/ws/session-catalog.js.map +1 -1
- package/dist/ws/turns.d.ts +8 -0
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js.map +1 -1
- package/package.json +7 -6
- package/src/agent/backend.ts +123 -29
- package/src/app.ts +55 -16
- package/src/config/env-core.ts +93 -0
- package/src/config/env.ts +61 -17
- package/src/cron/scheduler.ts +48 -14
- package/src/db/settings.ts +2 -2
- package/src/index.ts +6 -0
- package/src/middleware/auth.ts +50 -1
- package/src/routes/health.ts +22 -6
- package/src/routes/share.ts +11 -7
- package/src/voice/keyterm-builder.ts +2 -2
- package/src/ws/bridge.ts +17 -5
- package/src/ws/clients.ts +24 -6
- package/src/ws/connection.ts +11 -2
- package/src/ws/dispatch.ts +3 -1
- package/src/ws/host.ts +115 -6
- package/src/ws/run-session.ts +32 -8
- package/src/ws/session-catalog.ts +21 -3
- package/src/ws/turns.ts +8 -0
package/src/agent/backend.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
396
|
-
* one;
|
|
397
|
-
*
|
|
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
|
|
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
|
|
435
|
-
|
|
436
|
-
|
|
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({
|
|
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(
|
|
475
|
-
|
|
476
|
-
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
208
|
-
|
|
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",
|
|
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
|
-
|
|
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(
|
|
276
|
-
|
|
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
|
-
/**
|
|
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
|
|
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:
|
|
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:
|
|
417
|
-
allowPassword:
|
|
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:
|
|
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,
|