@schlessera/brain-ui-server 0.15.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 +4 -0
- package/dist/agent/backend.d.ts +31 -10
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +92 -28
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts +9 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +80 -18
- 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 +27 -5
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +90 -10
- package/dist/config/env.js.map +1 -1
- package/dist/cron/scheduler.d.ts +20 -0
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +43 -8
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/client.d.ts +10 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +5 -14
- package/dist/db/client.js.map +1 -1
- package/dist/db/settings.d.ts +3 -2
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +8 -4
- package/dist/db/settings.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +8 -2
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +74 -10
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +10 -0
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +19 -3
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/observability/index.d.ts +73 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +74 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/loggers.d.ts +62 -0
- package/dist/observability/loggers.d.ts.map +1 -0
- package/dist/observability/loggers.js +109 -0
- package/dist/observability/loggers.js.map +1 -0
- package/dist/observability/meter.d.ts +39 -0
- package/dist/observability/meter.d.ts.map +1 -0
- package/dist/observability/meter.js +95 -0
- package/dist/observability/meter.js.map +1 -0
- package/dist/observability/types.d.ts +67 -0
- package/dist/observability/types.d.ts.map +1 -0
- package/dist/observability/types.js +44 -0
- package/dist/observability/types.js.map +1 -0
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +9 -1
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.d.ts +2 -0
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +7 -7
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/graph.d.ts +2 -0
- package/dist/routes/graph.d.ts.map +1 -1
- package/dist/routes/graph.js +7 -7
- package/dist/routes/graph.js.map +1 -1
- package/dist/routes/health.d.ts +7 -14
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +25 -6
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/models.d.ts +3 -0
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +5 -1
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/render.d.ts +2 -1
- package/dist/routes/render.d.ts.map +1 -1
- package/dist/routes/render.js +2 -2
- package/dist/routes/render.js.map +1 -1
- package/dist/routes/share.d.ts +3 -0
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +5 -5
- package/dist/routes/share.js.map +1 -1
- package/dist/share/staging.d.ts +3 -2
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +12 -4
- package/dist/share/staging.js.map +1 -1
- package/dist/voice/keyterm-builder.d.ts +4 -1
- package/dist/voice/keyterm-builder.d.ts.map +1 -1
- package/dist/voice/keyterm-builder.js +15 -8
- package/dist/voice/keyterm-builder.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +23 -3
- 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 +15 -0
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +66 -10
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts +22 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +36 -16
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +73 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +127 -1
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/rate-limit.d.ts +58 -0
- package/dist/ws/rate-limit.d.ts.map +1 -0
- package/dist/ws/rate-limit.js +62 -0
- package/dist/ws/rate-limit.js.map +1 -0
- package/dist/ws/run-session.d.ts +3 -0
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +49 -9
- 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 +21 -0
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +11 -1
- package/dist/ws/turns.js.map +1 -1
- package/package.json +9 -6
- package/src/agent/backend.ts +131 -35
- package/src/app.ts +97 -18
- package/src/config/env-core.ts +93 -0
- package/src/config/env.ts +116 -15
- package/src/cron/scheduler.ts +61 -16
- package/src/db/client.ts +16 -5
- package/src/db/settings.ts +9 -4
- package/src/index.ts +43 -2
- package/src/middleware/auth.ts +80 -15
- package/src/middleware/passkeys.ts +29 -5
- package/src/observability/index.ts +150 -0
- package/src/observability/loggers.ts +174 -0
- package/src/observability/meter.ts +190 -0
- package/src/observability/types.ts +101 -0
- package/src/routes/brain.ts +10 -1
- package/src/routes/files.ts +9 -8
- package/src/routes/graph.ts +9 -8
- package/src/routes/health.ts +29 -6
- package/src/routes/models.ts +8 -5
- package/src/routes/render.ts +3 -2
- package/src/routes/share.ts +17 -10
- package/src/share/staging.ts +18 -4
- package/src/voice/keyterm-builder.ts +19 -10
- package/src/ws/bridge.ts +22 -6
- package/src/ws/clients.ts +24 -6
- package/src/ws/connection.ts +73 -13
- package/src/ws/dispatch.ts +47 -16
- package/src/ws/host.ts +164 -1
- package/src/ws/rate-limit.ts +86 -0
- package/src/ws/run-session.ts +57 -14
- package/src/ws/session-catalog.ts +21 -3
- package/src/ws/turns.ts +27 -3
package/src/config/env.ts
CHANGED
|
@@ -15,19 +15,31 @@
|
|
|
15
15
|
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
|
|
18
|
+
import { SEVERITIES, type Severity } from "../observability/types.js";
|
|
19
|
+
import { envFlag } from "./env-core.js";
|
|
20
|
+
|
|
18
21
|
// --- descriptor -------------------------------------------------------------
|
|
19
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
|
+
*/
|
|
20
36
|
export interface EnvVarDescriptor {
|
|
21
|
-
/**
|
|
37
|
+
/** Variable name as it appears in the environment. */
|
|
22
38
|
name: string;
|
|
23
39
|
/** What it controls. */
|
|
24
40
|
description: string;
|
|
25
41
|
/** Human-readable default applied when unset, or null when there is none. */
|
|
26
42
|
default: string | null;
|
|
27
|
-
/**
|
|
28
|
-
* `false` when optional; otherwise a human-readable statement of the
|
|
29
|
-
* condition under which boot fails without it.
|
|
30
|
-
*/
|
|
31
43
|
required: false | string;
|
|
32
44
|
}
|
|
33
45
|
|
|
@@ -63,6 +75,44 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
|
|
|
63
75
|
default: "(empty)",
|
|
64
76
|
required: false,
|
|
65
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
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "BRAIN_UI_WS_RATE",
|
|
93
|
+
description:
|
|
94
|
+
"Sustained inbound WebSocket frames per second per connection. 0 " +
|
|
95
|
+
"disables metering entirely.",
|
|
96
|
+
default: "20",
|
|
97
|
+
required: false,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "BRAIN_UI_WS_BURST",
|
|
101
|
+
description:
|
|
102
|
+
"Inbound WebSocket frames absorbable in one burst before the sustained " +
|
|
103
|
+
"rate applies. Opening the app legitimately fires several at once.",
|
|
104
|
+
default: "60",
|
|
105
|
+
required: false,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "BRAIN_UI_LOG_LEVEL",
|
|
109
|
+
description:
|
|
110
|
+
"Minimum severity the console log consumer emits: TRACE, DEBUG, INFO, " +
|
|
111
|
+
"WARN, ERROR or FATAL. Case-insensitive; an unrecognised value falls " +
|
|
112
|
+
"back to the default rather than silencing the server.",
|
|
113
|
+
default: "INFO",
|
|
114
|
+
required: false,
|
|
115
|
+
},
|
|
66
116
|
{
|
|
67
117
|
name: "SOURCE_COMMIT",
|
|
68
118
|
description: "Git SHA reported by /api/status (baked at image build time).",
|
|
@@ -279,6 +329,11 @@ export interface WebAuthnConfig {
|
|
|
279
329
|
export interface AgentConfig {
|
|
280
330
|
/** Trimmed, lowercased AGENT_BACKEND; null when unset (defaults to claude). */
|
|
281
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;
|
|
282
337
|
claudeCodePath: string;
|
|
283
338
|
defaultModel: string;
|
|
284
339
|
/** Raw BRAIN_UI_CLAUDE_PROFILES JSON, parsed lazily by the registry. */
|
|
@@ -305,6 +360,10 @@ export interface ServerConfig {
|
|
|
305
360
|
sourceCommit: string;
|
|
306
361
|
allowedOrigins: string[];
|
|
307
362
|
maxConcurrentSessions: number;
|
|
363
|
+
/** Threshold for the console log consumer (BRAIN_UI_LOG_LEVEL). */
|
|
364
|
+
logLevel: Severity;
|
|
365
|
+
/** Inbound WebSocket frame metering, per connection. */
|
|
366
|
+
wsRate: { ratePerSecond: number; burst: number };
|
|
308
367
|
auth: AuthConfig;
|
|
309
368
|
webauthn: WebAuthnConfig;
|
|
310
369
|
agent: AgentConfig;
|
|
@@ -322,8 +381,47 @@ function list(raw: string | undefined): string[] {
|
|
|
322
381
|
.filter(Boolean);
|
|
323
382
|
}
|
|
324
383
|
|
|
325
|
-
|
|
326
|
-
|
|
384
|
+
/**
|
|
385
|
+
* A log threshold, defaulting to INFO.
|
|
386
|
+
*
|
|
387
|
+
* An unrecognised value falls back rather than throwing: a typo in a log level
|
|
388
|
+
* must never be the reason a server refuses to boot, and silently emitting
|
|
389
|
+
* nothing would be worse than emitting too much.
|
|
390
|
+
*/
|
|
391
|
+
/** A non-negative number, falling back rather than throwing on nonsense. */
|
|
392
|
+
function positiveNumber(raw: string | undefined, fallback: number): number {
|
|
393
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
394
|
+
const n = Number(raw);
|
|
395
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
396
|
+
}
|
|
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
|
+
|
|
420
|
+
function parseSeverity(raw: string | undefined): Severity {
|
|
421
|
+
const upper = raw?.trim().toUpperCase();
|
|
422
|
+
return (SEVERITIES as readonly string[]).includes(upper ?? "")
|
|
423
|
+
? (upper as Severity)
|
|
424
|
+
: "INFO";
|
|
327
425
|
}
|
|
328
426
|
|
|
329
427
|
const AUTH_MODES: readonly AuthModeName[] = ["password", "tailscale", "proxy", "none"];
|
|
@@ -338,10 +436,7 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
|
|
|
338
436
|
const rawAuthMode = env.AUTH_MODE?.trim().toLowerCase() || null;
|
|
339
437
|
const validMode = AUTH_MODES.find((mode) => mode === rawAuthMode) ?? null;
|
|
340
438
|
|
|
341
|
-
const
|
|
342
|
-
const modelDiscovery = rawDiscovery
|
|
343
|
-
? !(rawDiscovery === "0" || rawDiscovery === "off" || rawDiscovery === "false")
|
|
344
|
-
: env.NODE_ENV !== "test";
|
|
439
|
+
const modelDiscovery = envFlag(env.BRAIN_UI_MODEL_DISCOVERY, env.NODE_ENV !== "test");
|
|
345
440
|
|
|
346
441
|
const rawTtl = Number(env.BRAIN_UI_MODEL_TTL_HOURS);
|
|
347
442
|
const ttlHours = Number.isFinite(rawTtl) && rawTtl > 0 ? rawTtl : 24;
|
|
@@ -358,11 +453,11 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
|
|
|
358
453
|
invalidMode: validMode ? null : rawAuthMode,
|
|
359
454
|
passwordHash: env.BRAIN_UI_PASSWORD_HASH || null,
|
|
360
455
|
cookieSecret: env.COOKIE_SECRET || null,
|
|
361
|
-
trustProxy:
|
|
456
|
+
trustProxy: envFlag(env.TRUST_PROXY, false),
|
|
362
457
|
trustProxyHops: Math.max(1, Number(env.TRUST_PROXY_HOPS) || 1),
|
|
363
458
|
proxyAuthHeader: (env.PROXY_AUTH_HEADER || "x-forwarded-user").toLowerCase(),
|
|
364
|
-
dangerouslyDisableAuth:
|
|
365
|
-
allowPassword:
|
|
459
|
+
dangerouslyDisableAuth: envFlag(env.BRAIN_UI_DANGEROUSLY_DISABLE_AUTH, false),
|
|
460
|
+
allowPassword: envFlag(env.BRAIN_UI_ALLOW_PASSWORD, false),
|
|
366
461
|
},
|
|
367
462
|
webauthn: {
|
|
368
463
|
rpName: env.WEBAUTHN_RP_NAME || "Brain UI",
|
|
@@ -370,16 +465,22 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
|
|
|
370
465
|
userId: env.WEBAUTHN_USER_ID || "brain-ui-owner",
|
|
371
466
|
rpId: env.WEBAUTHN_RP_ID || null,
|
|
372
467
|
origins: list(env.WEBAUTHN_ORIGINS),
|
|
373
|
-
allowLoopbackOrigin:
|
|
468
|
+
allowLoopbackOrigin: envFlag(env.BRAIN_UI_ALLOW_LOOPBACK_ORIGIN, false),
|
|
374
469
|
},
|
|
375
470
|
agent: {
|
|
376
471
|
backend: env.AGENT_BACKEND?.trim().toLowerCase() || null,
|
|
472
|
+
confirmBashPatterns: parseConfirmBash(env.BRAIN_UI_CONFIRM_BASH),
|
|
377
473
|
claudeCodePath: env.CLAUDE_CODE_PATH || "/usr/local/bin/claude",
|
|
378
474
|
defaultModel: env.BRAIN_UI_CLAUDE_DEFAULT_MODEL?.trim() || "claude-sonnet-4-6",
|
|
379
475
|
profilesJson: env.BRAIN_UI_CLAUDE_PROFILES?.trim() || null,
|
|
380
476
|
modelDiscovery,
|
|
381
477
|
modelTtlMs: ttlHours * 60 * 60 * 1000,
|
|
382
478
|
},
|
|
479
|
+
logLevel: parseSeverity(env.BRAIN_UI_LOG_LEVEL),
|
|
480
|
+
wsRate: {
|
|
481
|
+
ratePerSecond: positiveNumber(env.BRAIN_UI_WS_RATE, 20),
|
|
482
|
+
burst: positiveNumber(env.BRAIN_UI_WS_BURST, 60),
|
|
483
|
+
},
|
|
383
484
|
voice: {
|
|
384
485
|
provider: env.VOICE_PROVIDER?.trim().toLowerCase() || null,
|
|
385
486
|
deepgramApiKey: env.DEEPGRAM_API_KEY || null,
|
package/src/cron/scheduler.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
2
3
|
import type { BrainClient } from "../brain/client.js";
|
|
3
4
|
|
|
4
5
|
// Scheduling is owned by the container crontab (config/crontab) — sync at
|
|
5
6
|
// 02:00, validate at 03:00, job scrape at 04:00. This module only provides
|
|
6
7
|
// manual triggers (triggerJob) and run history for the /api/status endpoint.
|
|
7
|
-
// Runs started by the crontab
|
|
8
|
+
// Runs started by the crontab report themselves via recordCronRun below;
|
|
9
|
+
// their stdout/stderr still goes to syslog.
|
|
8
10
|
|
|
9
11
|
export interface CronScheduler {
|
|
10
12
|
getCronStatus(): Array<{
|
|
@@ -22,11 +24,53 @@ interface CronJob {
|
|
|
22
24
|
handler: () => Promise<void>;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** One in-flight job run being recorded into `cron_runs`. */
|
|
28
|
+
export interface CronRunRecord {
|
|
29
|
+
/** Mark the run finished: success without an argument, error with one. */
|
|
30
|
+
finish(error?: string): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Record a job run in the `cron_runs` history that `/api/status` serves
|
|
35
|
+
* (`cronJobs`, via {@link CronScheduler.getCronStatus}).
|
|
36
|
+
*
|
|
37
|
+
* The scheduler in this module only writes the table for its own manual
|
|
38
|
+
* triggers; in the shipped deployment the jobs are executed by the container
|
|
39
|
+
* crontab, whose runs were invisible to `/api/status`. An external scheduler's
|
|
40
|
+
* wrapper calls this before the job and `finish()` after, writing exactly the
|
|
41
|
+
* rows the in-process runner writes. A run whose process died before
|
|
42
|
+
* `finish()` stays in status "running" — itself a signal.
|
|
43
|
+
*/
|
|
44
|
+
export function recordCronRun(db: Database, jobName: string): CronRunRecord {
|
|
45
|
+
const startedAt = Date.now();
|
|
46
|
+
db.prepare(
|
|
47
|
+
"INSERT INTO cron_runs (job_name, started_at, status) VALUES (?, ?, 'running')"
|
|
48
|
+
).run(jobName, startedAt);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
finish(error?: string) {
|
|
52
|
+
const finishedAt = Date.now();
|
|
53
|
+
const durationMs = finishedAt - startedAt;
|
|
54
|
+
if (error === undefined) {
|
|
55
|
+
db.prepare(
|
|
56
|
+
"UPDATE cron_runs SET status = 'success', finished_at = ?, duration_ms = ? WHERE job_name = ? AND started_at = ?"
|
|
57
|
+
).run(finishedAt, durationMs, jobName, startedAt);
|
|
58
|
+
} else {
|
|
59
|
+
db.prepare(
|
|
60
|
+
"UPDATE cron_runs SET status = 'error', finished_at = ?, duration_ms = ?, error_message = ? WHERE job_name = ? AND started_at = ?"
|
|
61
|
+
).run(finishedAt, durationMs, error, jobName, startedAt);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
25
67
|
export function createCronScheduler(deps: {
|
|
26
68
|
db: Database;
|
|
27
69
|
brain: BrainClient;
|
|
70
|
+
/** Where run outcomes are reported; absent means silence. */
|
|
71
|
+
log?: Logger;
|
|
28
72
|
}): CronScheduler {
|
|
29
|
-
const { db, brain } = deps;
|
|
73
|
+
const { db, brain, log } = deps;
|
|
30
74
|
|
|
31
75
|
const jobs: CronJob[] = [
|
|
32
76
|
{
|
|
@@ -46,25 +90,26 @@ export function createCronScheduler(deps: {
|
|
|
46
90
|
|
|
47
91
|
async function runJob(job: CronJob) {
|
|
48
92
|
const startedAt = Date.now();
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
).run(job.name, startedAt);
|
|
93
|
+
// Same recorder the external-scheduler path uses, so the two can never
|
|
94
|
+
// drift in what a cron_runs row looks like.
|
|
95
|
+
const record = recordCronRun(db, job.name);
|
|
53
96
|
|
|
54
97
|
try {
|
|
55
98
|
await job.handler();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
99
|
+
record.finish();
|
|
100
|
+
log?.emit({
|
|
101
|
+
severityText: "INFO",
|
|
102
|
+
body: "job completed",
|
|
103
|
+
attributes: { job: job.name, "duration.ms": Date.now() - startedAt },
|
|
104
|
+
});
|
|
61
105
|
} catch (err) {
|
|
62
|
-
const durationMs = Date.now() - startedAt;
|
|
63
106
|
const message = err instanceof Error ? err.message : String(err);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
107
|
+
record.finish(message);
|
|
108
|
+
log?.emit({
|
|
109
|
+
severityText: "ERROR",
|
|
110
|
+
body: "job failed",
|
|
111
|
+
attributes: { job: job.name, error: message },
|
|
112
|
+
});
|
|
68
113
|
}
|
|
69
114
|
}
|
|
70
115
|
|
package/src/db/client.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { readdirSync, readFileSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
|
|
5
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* The UI's OWN SQLite database (sessions, passkeys, settings, cron runs) —
|
|
7
9
|
* distinct from the brain database, which is opened read-only via
|
|
@@ -11,15 +13,24 @@ import { join } from "path";
|
|
|
11
13
|
* it to every consumer, so two apps with different configuration can coexist
|
|
12
14
|
* in one process and a test gets an isolated database by construction.
|
|
13
15
|
*/
|
|
14
|
-
export
|
|
16
|
+
export interface CreateUiDbOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Where migration progress is reported. Optional so a test can open a
|
|
19
|
+
* database without wiring observability; absent means silence, never
|
|
20
|
+
* console output.
|
|
21
|
+
*/
|
|
22
|
+
log?: Logger;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createUiDb(dbPath: string, options: CreateUiDbOptions = {}): Database {
|
|
15
26
|
const db = new Database(dbPath, { create: true });
|
|
16
27
|
db.exec("PRAGMA journal_mode = WAL");
|
|
17
28
|
db.exec("PRAGMA foreign_keys = ON");
|
|
18
|
-
runMigrations(db);
|
|
29
|
+
runMigrations(db, options.log);
|
|
19
30
|
return db;
|
|
20
31
|
}
|
|
21
32
|
|
|
22
|
-
function runMigrations(database: Database) {
|
|
33
|
+
function runMigrations(database: Database, log?: Logger) {
|
|
23
34
|
// Ensure _migrations table exists
|
|
24
35
|
database.exec(`
|
|
25
36
|
CREATE TABLE IF NOT EXISTS _migrations (
|
|
@@ -39,7 +50,7 @@ function runMigrations(database: Database) {
|
|
|
39
50
|
.filter((f) => f.endsWith(".sql"))
|
|
40
51
|
.sort();
|
|
41
52
|
} catch {
|
|
42
|
-
|
|
53
|
+
log?.emit({ severityText: "WARN", body: "no migrations directory; skipping migrations" });
|
|
43
54
|
return;
|
|
44
55
|
}
|
|
45
56
|
|
|
@@ -53,7 +64,7 @@ function runMigrations(database: Database) {
|
|
|
53
64
|
for (const file of files) {
|
|
54
65
|
if (applied.has(file)) continue;
|
|
55
66
|
|
|
56
|
-
|
|
67
|
+
log?.emit({ severityText: "INFO", body: "applying migration", attributes: { file } });
|
|
57
68
|
const sql = readFileSync(join(migrationsDir, file), "utf-8");
|
|
58
69
|
|
|
59
70
|
database.transaction(() => {
|
package/src/db/settings.ts
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
// corrupt row degrades to the caller's fallback rather than throwing, because
|
|
7
7
|
// a bad preference must never take a route down.
|
|
8
8
|
|
|
9
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
9
10
|
import type { Database } from "bun:sqlite";
|
|
10
11
|
|
|
11
12
|
const HIDDEN_MODELS_KEY = "models.hidden";
|
|
12
13
|
|
|
13
|
-
export function getSetting<T>(db: Database, key: string, fallback: T): T {
|
|
14
|
+
export function getSetting<T>(db: Database, key: string, fallback: T, log?: Logger): T {
|
|
14
15
|
const row = db
|
|
15
16
|
.query("SELECT value FROM settings WHERE key = ?")
|
|
16
17
|
.get(key) as { value: string } | null;
|
|
@@ -18,7 +19,11 @@ export function getSetting<T>(db: Database, key: string, fallback: T): T {
|
|
|
18
19
|
try {
|
|
19
20
|
return JSON.parse(row.value) as T;
|
|
20
21
|
} catch {
|
|
21
|
-
|
|
22
|
+
log?.emit({
|
|
23
|
+
severityText: "WARN",
|
|
24
|
+
body: "corrupt JSON in settings; using fallback",
|
|
25
|
+
attributes: { key },
|
|
26
|
+
});
|
|
22
27
|
return fallback;
|
|
23
28
|
}
|
|
24
29
|
}
|
|
@@ -31,8 +36,8 @@ export function setSetting(db: Database, key: string, value: unknown): void {
|
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/** Profile ids the user keeps out of the model picker. */
|
|
34
|
-
export function getHiddenModelIds(db: Database): string[] {
|
|
35
|
-
const value = getSetting<unknown>(db, HIDDEN_MODELS_KEY, []);
|
|
39
|
+
export function getHiddenModelIds(db: Database, log?: Logger): string[] {
|
|
40
|
+
const value = getSetting<unknown>(db, HIDDEN_MODELS_KEY, [], log);
|
|
36
41
|
if (!Array.isArray(value)) return [];
|
|
37
42
|
return value.filter((id): id is string => typeof id === "string");
|
|
38
43
|
}
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export {
|
|
|
34
34
|
assertBackendResolvable,
|
|
35
35
|
createBackendRegistry,
|
|
36
36
|
createStaticBackendRegistry,
|
|
37
|
+
type BackendLogFn,
|
|
37
38
|
type BackendRegistry,
|
|
38
39
|
type ModelDiscoverySource,
|
|
39
40
|
type ModelDiscoveryState,
|
|
@@ -58,8 +59,12 @@ export {
|
|
|
58
59
|
|
|
59
60
|
// WebSocket internals for embedders and tests.
|
|
60
61
|
export { WsHost, type WsHostOptions } from "./ws/host.js";
|
|
61
|
-
export { createWsUpgrade, websocket } from "./ws/connection.js";
|
|
62
|
-
export {
|
|
62
|
+
export { createWsUpgrade, createWsHandlers, websocket } from "./ws/connection.js";
|
|
63
|
+
export {
|
|
64
|
+
handleClientMessage,
|
|
65
|
+
turnIdMatches,
|
|
66
|
+
type ConnectionState,
|
|
67
|
+
} from "./ws/dispatch.js";
|
|
63
68
|
export { resolveTurnTarget } from "./ws/routing.js";
|
|
64
69
|
export {
|
|
65
70
|
createSessionCatalog,
|
|
@@ -70,10 +75,46 @@ export {
|
|
|
70
75
|
// their own routes on top.
|
|
71
76
|
export { createBrainClient, type BrainClient } from "./brain/client.js";
|
|
72
77
|
|
|
78
|
+
// Cron run history. Scheduling belongs to the deployment (container crontab);
|
|
79
|
+
// an external scheduler's wrapper records each run here so /api/status's
|
|
80
|
+
// `cronJobs` reflects what actually ran.
|
|
81
|
+
export { recordCronRun, type CronRunRecord } from "./cron/scheduler.js";
|
|
82
|
+
|
|
73
83
|
// Share staging: a deployment can sweep expired staging dirs at boot; the
|
|
74
84
|
// intake route also sweeps opportunistically on every share.
|
|
75
85
|
export { pruneShareStaging, shareStagingRoot } from "./share/staging.js";
|
|
76
86
|
|
|
87
|
+
// Observability: the producing side is the OpenTelemetry API, the consuming
|
|
88
|
+
// side is ours. Swap the consumer to change where a deployment reports; a test
|
|
89
|
+
// swaps in the recording one and asserts on what the server actually said.
|
|
90
|
+
export {
|
|
91
|
+
createObservability,
|
|
92
|
+
createRecordingObservability,
|
|
93
|
+
createSilentObservability,
|
|
94
|
+
createConsoleLoggerProvider,
|
|
95
|
+
createRecordingLoggerProvider,
|
|
96
|
+
createSilentLoggerProvider,
|
|
97
|
+
createInMemoryMeterProvider,
|
|
98
|
+
SEVERITIES,
|
|
99
|
+
severityRank,
|
|
100
|
+
seriesKey,
|
|
101
|
+
} from "./observability/index.js";
|
|
102
|
+
export type {
|
|
103
|
+
Observability,
|
|
104
|
+
ObservabilityOptions,
|
|
105
|
+
RecordingObservability,
|
|
106
|
+
RecordingLoggerProvider,
|
|
107
|
+
InMemoryMeterProvider,
|
|
108
|
+
LogReader,
|
|
109
|
+
LogWriter,
|
|
110
|
+
MetricsReader,
|
|
111
|
+
Severity,
|
|
112
|
+
CapturedLog,
|
|
113
|
+
LogQuery,
|
|
114
|
+
MetricPoint,
|
|
115
|
+
MetricSnapshot,
|
|
116
|
+
} from "./observability/index.js";
|
|
117
|
+
|
|
77
118
|
// Voice keyterm cache rebuild (used by deployments after `brain sync`).
|
|
78
119
|
export {
|
|
79
120
|
buildKeyterms,
|
package/src/middleware/auth.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Counter } from "@opentelemetry/api";
|
|
2
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
1
3
|
import { Hono } from "hono";
|
|
2
4
|
import type { Context, MiddlewareHandler } from "hono";
|
|
3
5
|
import { getSignedCookie, setSignedCookie, deleteCookie } from "hono/cookie";
|
|
@@ -48,12 +50,14 @@ const LOGIN_RATE_WINDOW_MS = 60_000; // per minute
|
|
|
48
50
|
// attacker could weaponize to deny the owner access.
|
|
49
51
|
export const GLOBAL_LOGIN_RATE_LIMIT = 20; // attempts per window, all IPs combined
|
|
50
52
|
|
|
51
|
-
export function resolveAuthMode(auth: AuthRuntime): AuthMode {
|
|
53
|
+
export function resolveAuthMode(auth: AuthRuntime, log?: Logger): AuthMode {
|
|
52
54
|
if (auth.mode) return auth.mode;
|
|
53
55
|
if (auth.invalidMode) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
log?.emit({
|
|
57
|
+
severityText: "WARN",
|
|
58
|
+
body: "unknown AUTH_MODE; auto-detecting instead",
|
|
59
|
+
attributes: { requested: auth.invalidMode },
|
|
60
|
+
});
|
|
57
61
|
}
|
|
58
62
|
if (auth.passwordHash) return "password";
|
|
59
63
|
return "tailscale";
|
|
@@ -63,7 +67,7 @@ export function resolveAuthMode(auth: AuthRuntime): AuthMode {
|
|
|
63
67
|
* Validate the auth configuration at startup. Throws (refusing to boot) on an
|
|
64
68
|
* unsafe or unusable configuration.
|
|
65
69
|
*/
|
|
66
|
-
export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime): void {
|
|
70
|
+
export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime, log?: Logger): void {
|
|
67
71
|
const host = auth.host;
|
|
68
72
|
const loopback = host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
69
73
|
|
|
@@ -87,11 +91,17 @@ export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime): void {
|
|
|
87
91
|
// exposed to the network".
|
|
88
92
|
if (mode === "none" && !loopback) {
|
|
89
93
|
if (auth.dangerouslyDisableAuth) {
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
// ERROR, not WARN: this is a safety that has been deliberately switched
|
|
95
|
+
// off, and the severity must survive any sane BRAIN_UI_LOG_LEVEL. A log
|
|
96
|
+
// threshold must never be the reason nobody saw this.
|
|
97
|
+
log?.emit({
|
|
98
|
+
severityText: "ERROR",
|
|
99
|
+
body:
|
|
100
|
+
"AUTH_MODE=none on a non-loopback host, allowed by " +
|
|
92
101
|
"BRAIN_UI_DANGEROUSLY_DISABLE_AUTH=1 — every network peer has full " +
|
|
93
|
-
"agent access. Do not run this on anything but a trusted network."
|
|
94
|
-
|
|
102
|
+
"agent access. Do not run this on anything but a trusted network.",
|
|
103
|
+
attributes: { "auth.mode": "none", host },
|
|
104
|
+
});
|
|
95
105
|
} else {
|
|
96
106
|
throw new Error(
|
|
97
107
|
"AUTH_MODE=none refuses to start unless HOST is loopback " +
|
|
@@ -112,13 +122,20 @@ export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime): void {
|
|
|
112
122
|
"any client-supplied copy. Set TRUST_PROXY=1 once that holds."
|
|
113
123
|
);
|
|
114
124
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
log?.emit({
|
|
126
|
+
severityText: "INFO",
|
|
127
|
+
body:
|
|
128
|
+
"auth mode resolved: proxy — ensure the upstream proxy sets this header " +
|
|
129
|
+
"and strips any client-supplied copy",
|
|
130
|
+
attributes: { "auth.mode": "proxy", header: auth.proxyAuthHeader },
|
|
131
|
+
});
|
|
119
132
|
return;
|
|
120
133
|
}
|
|
121
|
-
|
|
134
|
+
log?.emit({
|
|
135
|
+
severityText: "INFO",
|
|
136
|
+
body: "auth mode resolved",
|
|
137
|
+
attributes: { "auth.mode": mode },
|
|
138
|
+
});
|
|
122
139
|
}
|
|
123
140
|
|
|
124
141
|
/** Middleware guarding /api/* according to the resolved mode. */
|
|
@@ -273,9 +290,14 @@ export function authRoutes(
|
|
|
273
290
|
* of WebAuthn concerns.
|
|
274
291
|
*/
|
|
275
292
|
passwordDisabled?: (c: Context) => boolean;
|
|
293
|
+
/** Where login outcomes are reported; absent means silence. */
|
|
294
|
+
log?: Logger;
|
|
295
|
+
/** Failed-login counter — the same `auth.failures` instrument passkeys use. */
|
|
296
|
+
failures?: Counter;
|
|
276
297
|
} = {}
|
|
277
298
|
): Hono {
|
|
278
299
|
const app = new Hono();
|
|
300
|
+
const { log, failures } = deps;
|
|
279
301
|
|
|
280
302
|
app.post("/auth/login", async (c) => {
|
|
281
303
|
if (mode !== "password") {
|
|
@@ -286,6 +308,12 @@ export function authRoutes(
|
|
|
286
308
|
const perIpOk = consumeLoginToken(`ip:${key}`, LOGIN_RATE_LIMIT);
|
|
287
309
|
const globalOk = consumeLoginToken("global", GLOBAL_LOGIN_RATE_LIMIT);
|
|
288
310
|
if (!perIpOk || !globalOk) {
|
|
311
|
+
failures?.add(1, { reason: "rate_limited", method: "password" });
|
|
312
|
+
log?.emit({
|
|
313
|
+
severityText: "WARN",
|
|
314
|
+
body: "login rate limited",
|
|
315
|
+
attributes: { ip: key, limit: perIpOk ? "global" : "ip" },
|
|
316
|
+
});
|
|
289
317
|
return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
|
|
290
318
|
}
|
|
291
319
|
|
|
@@ -306,15 +334,52 @@ export function authRoutes(
|
|
|
306
334
|
}
|
|
307
335
|
const password = typeof body.password === "string" ? body.password : "";
|
|
308
336
|
if (!password || !hash || !secret) {
|
|
337
|
+
failures?.add(1, { reason: "invalid_password", method: "password" });
|
|
338
|
+
log?.emit({
|
|
339
|
+
severityText: "WARN",
|
|
340
|
+
body: "login failed",
|
|
341
|
+
attributes: { ip: key },
|
|
342
|
+
});
|
|
309
343
|
return c.json({ error: "Invalid credentials" }, 401);
|
|
310
344
|
}
|
|
311
345
|
|
|
312
|
-
|
|
346
|
+
// A verify that THROWS is not a wrong password — it is a hash Bun cannot
|
|
347
|
+
// parse (a corrupt BRAIN_UI_PASSWORD_HASH locks the owner out of every
|
|
348
|
+
// login), and collapsing it into "invalid credentials" hid exactly that.
|
|
349
|
+
let ok = false;
|
|
350
|
+
let verifyError: unknown = null;
|
|
351
|
+
try {
|
|
352
|
+
ok = await Bun.password.verify(password, hash);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
verifyError = err;
|
|
355
|
+
}
|
|
356
|
+
if (verifyError) {
|
|
357
|
+
failures?.add(1, { reason: "verify_error", method: "password" });
|
|
358
|
+
log?.emit({
|
|
359
|
+
severityText: "ERROR",
|
|
360
|
+
body: "password verification errored — check BRAIN_UI_PASSWORD_HASH",
|
|
361
|
+
attributes: {
|
|
362
|
+
error: verifyError instanceof Error ? verifyError.message : String(verifyError),
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
366
|
+
}
|
|
313
367
|
if (!ok) {
|
|
368
|
+
failures?.add(1, { reason: "invalid_password", method: "password" });
|
|
369
|
+
log?.emit({
|
|
370
|
+
severityText: "WARN",
|
|
371
|
+
body: "login failed",
|
|
372
|
+
attributes: { ip: key },
|
|
373
|
+
});
|
|
314
374
|
return c.json({ error: "Invalid credentials" }, 401);
|
|
315
375
|
}
|
|
316
376
|
|
|
317
377
|
await issueSessionCookie(c, auth);
|
|
378
|
+
log?.emit({
|
|
379
|
+
severityText: "INFO",
|
|
380
|
+
body: "login succeeded",
|
|
381
|
+
attributes: { ip: key },
|
|
382
|
+
});
|
|
318
383
|
return c.json({ ok: true });
|
|
319
384
|
});
|
|
320
385
|
|