@schlessera/brain-ui-server 0.16.0 → 0.18.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 +2 -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 +51 -17
- 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 +24 -5
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +55 -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 +36 -4
- 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 +43 -6
- 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 +31 -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 +30 -2
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +13 -0
- package/dist/ws/turns.js.map +1 -1
- package/package.json +7 -6
- package/src/agent/backend.ts +123 -29
- package/src/app.ts +59 -17
- package/src/config/env-core.ts +93 -0
- package/src/config/env.ts +80 -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 +36 -7
- package/src/ws/clients.ts +24 -6
- package/src/ws/connection.ts +59 -6
- package/src/ws/dispatch.ts +3 -1
- package/src/ws/host.ts +115 -6
- package/src/ws/run-session.ts +36 -8
- package/src/ws/session-catalog.ts +21 -3
- package/src/ws/turns.ts +37 -1
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:
|
|
@@ -109,6 +132,13 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
|
|
|
109
132
|
default: "3",
|
|
110
133
|
required: false,
|
|
111
134
|
},
|
|
135
|
+
{
|
|
136
|
+
name: "BRAIN_UI_TURN_TIMEOUT_MS",
|
|
137
|
+
description:
|
|
138
|
+
"Hard per-turn timeout in ms; the host aborts a turn that runs past it. Raise for agent-heavy research work (e.g. 1800000 for 30 minutes).",
|
|
139
|
+
default: "600000 (10 minutes)",
|
|
140
|
+
required: false,
|
|
141
|
+
},
|
|
112
142
|
// auth
|
|
113
143
|
{
|
|
114
144
|
name: "AUTH_MODE",
|
|
@@ -306,6 +336,11 @@ export interface WebAuthnConfig {
|
|
|
306
336
|
export interface AgentConfig {
|
|
307
337
|
/** Trimmed, lowercased AGENT_BACKEND; null when unset (defaults to claude). */
|
|
308
338
|
backend: string | null;
|
|
339
|
+
/**
|
|
340
|
+
* Bash-confirmation regex sources; null means "use the backend's defaults".
|
|
341
|
+
* An empty array is a deliberate opt-out and is passed through as such.
|
|
342
|
+
*/
|
|
343
|
+
confirmBashPatterns: string[] | null;
|
|
309
344
|
claudeCodePath: string;
|
|
310
345
|
defaultModel: string;
|
|
311
346
|
/** Raw BRAIN_UI_CLAUDE_PROFILES JSON, parsed lazily by the registry. */
|
|
@@ -332,6 +367,11 @@ export interface ServerConfig {
|
|
|
332
367
|
sourceCommit: string;
|
|
333
368
|
allowedOrigins: string[];
|
|
334
369
|
maxConcurrentSessions: number;
|
|
370
|
+
/**
|
|
371
|
+
* Per-turn timeout in ms (BRAIN_UI_TURN_TIMEOUT_MS), or null to use the
|
|
372
|
+
* WsHost default (10 minutes). An explicit createApp option still wins.
|
|
373
|
+
*/
|
|
374
|
+
turnTimeoutMs: number | null;
|
|
335
375
|
/** Threshold for the console log consumer (BRAIN_UI_LOG_LEVEL). */
|
|
336
376
|
logLevel: Severity;
|
|
337
377
|
/** Inbound WebSocket frame metering, per connection. */
|
|
@@ -353,10 +393,6 @@ function list(raw: string | undefined): string[] {
|
|
|
353
393
|
.filter(Boolean);
|
|
354
394
|
}
|
|
355
395
|
|
|
356
|
-
function flag(raw: string | undefined): boolean {
|
|
357
|
-
return raw === "1";
|
|
358
|
-
}
|
|
359
|
-
|
|
360
396
|
/**
|
|
361
397
|
* A log threshold, defaulting to INFO.
|
|
362
398
|
*
|
|
@@ -371,6 +407,28 @@ function positiveNumber(raw: string | undefined, fallback: number): number {
|
|
|
371
407
|
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
372
408
|
}
|
|
373
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Parse BRAIN_UI_CONFIRM_BASH into pattern sources.
|
|
412
|
+
*
|
|
413
|
+
* Unset or unparseable → null, meaning the backend's shipped defaults. An
|
|
414
|
+
* explicit `[]` is honoured as "no confirmation": disabling the seatbelt is a
|
|
415
|
+
* choice a deployment is allowed to make, and silently re-enabling it would be
|
|
416
|
+
* worse than obeying. Malformed JSON falls back to the defaults rather than
|
|
417
|
+
* throwing — a typo here must not stop the server booting, and the safe
|
|
418
|
+
* direction to fail is "more confirmation", not less.
|
|
419
|
+
*/
|
|
420
|
+
function parseConfirmBash(raw: string | undefined): string[] | null {
|
|
421
|
+
const text = raw?.trim();
|
|
422
|
+
if (!text) return null;
|
|
423
|
+
try {
|
|
424
|
+
const parsed = JSON.parse(text);
|
|
425
|
+
if (!Array.isArray(parsed)) return null;
|
|
426
|
+
return parsed.filter((p): p is string => typeof p === "string");
|
|
427
|
+
} catch {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
374
432
|
function parseSeverity(raw: string | undefined): Severity {
|
|
375
433
|
const upper = raw?.trim().toUpperCase();
|
|
376
434
|
return (SEVERITIES as readonly string[]).includes(upper ?? "")
|
|
@@ -390,10 +448,7 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
|
|
|
390
448
|
const rawAuthMode = env.AUTH_MODE?.trim().toLowerCase() || null;
|
|
391
449
|
const validMode = AUTH_MODES.find((mode) => mode === rawAuthMode) ?? null;
|
|
392
450
|
|
|
393
|
-
const
|
|
394
|
-
const modelDiscovery = rawDiscovery
|
|
395
|
-
? !(rawDiscovery === "0" || rawDiscovery === "off" || rawDiscovery === "false")
|
|
396
|
-
: env.NODE_ENV !== "test";
|
|
451
|
+
const modelDiscovery = envFlag(env.BRAIN_UI_MODEL_DISCOVERY, env.NODE_ENV !== "test");
|
|
397
452
|
|
|
398
453
|
const rawTtl = Number(env.BRAIN_UI_MODEL_TTL_HOURS);
|
|
399
454
|
const ttlHours = Number.isFinite(rawTtl) && rawTtl > 0 ? rawTtl : 24;
|
|
@@ -405,16 +460,23 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
|
|
|
405
460
|
sourceCommit: env.SOURCE_COMMIT ?? "dev",
|
|
406
461
|
allowedOrigins: list(env.ALLOWED_ORIGINS),
|
|
407
462
|
maxConcurrentSessions: Math.max(1, Number(env.MAX_CONCURRENT_SESSIONS) || 3),
|
|
463
|
+
// Positive integer or null — 0, negatives, and garbage all mean "unset",
|
|
464
|
+
// so a typo degrades to the safe default instead of an instant timeout.
|
|
465
|
+
turnTimeoutMs:
|
|
466
|
+
Number.isFinite(Number(env.BRAIN_UI_TURN_TIMEOUT_MS)) &&
|
|
467
|
+
Number(env.BRAIN_UI_TURN_TIMEOUT_MS) > 0
|
|
468
|
+
? Math.floor(Number(env.BRAIN_UI_TURN_TIMEOUT_MS))
|
|
469
|
+
: null,
|
|
408
470
|
auth: {
|
|
409
471
|
mode: validMode,
|
|
410
472
|
invalidMode: validMode ? null : rawAuthMode,
|
|
411
473
|
passwordHash: env.BRAIN_UI_PASSWORD_HASH || null,
|
|
412
474
|
cookieSecret: env.COOKIE_SECRET || null,
|
|
413
|
-
trustProxy:
|
|
475
|
+
trustProxy: envFlag(env.TRUST_PROXY, false),
|
|
414
476
|
trustProxyHops: Math.max(1, Number(env.TRUST_PROXY_HOPS) || 1),
|
|
415
477
|
proxyAuthHeader: (env.PROXY_AUTH_HEADER || "x-forwarded-user").toLowerCase(),
|
|
416
|
-
dangerouslyDisableAuth:
|
|
417
|
-
allowPassword:
|
|
478
|
+
dangerouslyDisableAuth: envFlag(env.BRAIN_UI_DANGEROUSLY_DISABLE_AUTH, false),
|
|
479
|
+
allowPassword: envFlag(env.BRAIN_UI_ALLOW_PASSWORD, false),
|
|
418
480
|
},
|
|
419
481
|
webauthn: {
|
|
420
482
|
rpName: env.WEBAUTHN_RP_NAME || "Brain UI",
|
|
@@ -422,10 +484,11 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
|
|
|
422
484
|
userId: env.WEBAUTHN_USER_ID || "brain-ui-owner",
|
|
423
485
|
rpId: env.WEBAUTHN_RP_ID || null,
|
|
424
486
|
origins: list(env.WEBAUTHN_ORIGINS),
|
|
425
|
-
allowLoopbackOrigin:
|
|
487
|
+
allowLoopbackOrigin: envFlag(env.BRAIN_UI_ALLOW_LOOPBACK_ORIGIN, false),
|
|
426
488
|
},
|
|
427
489
|
agent: {
|
|
428
490
|
backend: env.AGENT_BACKEND?.trim().toLowerCase() || null,
|
|
491
|
+
confirmBashPatterns: parseConfirmBash(env.BRAIN_UI_CONFIRM_BASH),
|
|
429
492
|
claudeCodePath: env.CLAUDE_CODE_PATH || "/usr/local/bin/claude",
|
|
430
493
|
defaultModel: env.BRAIN_UI_CLAUDE_DEFAULT_MODEL?.trim() || "claude-sonnet-4-6",
|
|
431
494
|
profilesJson: env.BRAIN_UI_CLAUDE_PROFILES?.trim() || null,
|
package/src/cron/scheduler.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { BrainClient } from "../brain/client.js";
|
|
|
5
5
|
// Scheduling is owned by the container crontab (config/crontab) — sync at
|
|
6
6
|
// 02:00, validate at 03:00, job scrape at 04:00. This module only provides
|
|
7
7
|
// manual triggers (triggerJob) and run history for the /api/status endpoint.
|
|
8
|
-
// Runs started by the crontab
|
|
8
|
+
// Runs started by the crontab report themselves via recordCronRun below;
|
|
9
|
+
// their stdout/stderr still goes to syslog.
|
|
9
10
|
|
|
10
11
|
export interface CronScheduler {
|
|
11
12
|
getCronStatus(): Array<{
|
|
@@ -23,6 +24,46 @@ interface CronJob {
|
|
|
23
24
|
handler: () => Promise<void>;
|
|
24
25
|
}
|
|
25
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
|
+
|
|
26
67
|
export function createCronScheduler(deps: {
|
|
27
68
|
db: Database;
|
|
28
69
|
brain: BrainClient;
|
|
@@ -49,28 +90,21 @@ export function createCronScheduler(deps: {
|
|
|
49
90
|
|
|
50
91
|
async function runJob(job: CronJob) {
|
|
51
92
|
const startedAt = Date.now();
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
).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);
|
|
56
96
|
|
|
57
97
|
try {
|
|
58
98
|
await job.handler();
|
|
59
|
-
|
|
60
|
-
db.prepare(
|
|
61
|
-
"UPDATE cron_runs SET status = 'success', finished_at = ?, duration_ms = ? WHERE job_name = ? AND started_at = ?"
|
|
62
|
-
).run(Date.now(), durationMs, job.name, startedAt);
|
|
99
|
+
record.finish();
|
|
63
100
|
log?.emit({
|
|
64
101
|
severityText: "INFO",
|
|
65
102
|
body: "job completed",
|
|
66
|
-
attributes: { job: job.name, "duration.ms":
|
|
103
|
+
attributes: { job: job.name, "duration.ms": Date.now() - startedAt },
|
|
67
104
|
});
|
|
68
105
|
} catch (err) {
|
|
69
|
-
const durationMs = Date.now() - startedAt;
|
|
70
106
|
const message = err instanceof Error ? err.message : String(err);
|
|
71
|
-
|
|
72
|
-
"UPDATE cron_runs SET status = 'error', finished_at = ?, duration_ms = ?, error_message = ? WHERE job_name = ? AND started_at = ?"
|
|
73
|
-
).run(Date.now(), durationMs, message, job.name, startedAt);
|
|
107
|
+
record.finish(message);
|
|
74
108
|
log?.emit({
|
|
75
109
|
severityText: "ERROR",
|
|
76
110
|
body: "job failed",
|
package/src/db/settings.ts
CHANGED
|
@@ -36,8 +36,8 @@ export function setSetting(db: Database, key: string, value: unknown): void {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/** Profile ids the user keeps out of the model picker. */
|
|
39
|
-
export function getHiddenModelIds(db: Database): string[] {
|
|
40
|
-
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);
|
|
41
41
|
if (!Array.isArray(value)) return [];
|
|
42
42
|
return value.filter((id): id is string => typeof id === "string");
|
|
43
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,
|
|
@@ -74,6 +75,11 @@ export {
|
|
|
74
75
|
// their own routes on top.
|
|
75
76
|
export { createBrainClient, type BrainClient } from "./brain/client.js";
|
|
76
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
|
+
|
|
77
83
|
// Share staging: a deployment can sweep expired staging dirs at boot; the
|
|
78
84
|
// intake route also sweeps opportunistically on every share.
|
|
79
85
|
export { pruneShareStaging, shareStagingRoot } from "./share/staging.js";
|
package/src/middleware/auth.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Counter } from "@opentelemetry/api";
|
|
1
2
|
import type { Logger } from "@opentelemetry/api-logs";
|
|
2
3
|
import { Hono } from "hono";
|
|
3
4
|
import type { Context, MiddlewareHandler } from "hono";
|
|
@@ -289,9 +290,14 @@ export function authRoutes(
|
|
|
289
290
|
* of WebAuthn concerns.
|
|
290
291
|
*/
|
|
291
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;
|
|
292
297
|
} = {}
|
|
293
298
|
): Hono {
|
|
294
299
|
const app = new Hono();
|
|
300
|
+
const { log, failures } = deps;
|
|
295
301
|
|
|
296
302
|
app.post("/auth/login", async (c) => {
|
|
297
303
|
if (mode !== "password") {
|
|
@@ -302,6 +308,12 @@ export function authRoutes(
|
|
|
302
308
|
const perIpOk = consumeLoginToken(`ip:${key}`, LOGIN_RATE_LIMIT);
|
|
303
309
|
const globalOk = consumeLoginToken("global", GLOBAL_LOGIN_RATE_LIMIT);
|
|
304
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
|
+
});
|
|
305
317
|
return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
|
|
306
318
|
}
|
|
307
319
|
|
|
@@ -322,15 +334,52 @@ export function authRoutes(
|
|
|
322
334
|
}
|
|
323
335
|
const password = typeof body.password === "string" ? body.password : "";
|
|
324
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
|
+
});
|
|
325
343
|
return c.json({ error: "Invalid credentials" }, 401);
|
|
326
344
|
}
|
|
327
345
|
|
|
328
|
-
|
|
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
|
+
}
|
|
329
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
|
+
});
|
|
330
374
|
return c.json({ error: "Invalid credentials" }, 401);
|
|
331
375
|
}
|
|
332
376
|
|
|
333
377
|
await issueSessionCookie(c, auth);
|
|
378
|
+
log?.emit({
|
|
379
|
+
severityText: "INFO",
|
|
380
|
+
body: "login succeeded",
|
|
381
|
+
attributes: { ip: key },
|
|
382
|
+
});
|
|
334
383
|
return c.json({ ok: true });
|
|
335
384
|
});
|
|
336
385
|
|
package/src/routes/health.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
+
import type { Database } from "bun:sqlite";
|
|
2
3
|
|
|
3
4
|
import type { MetricSnapshot } from "../observability/index.js";
|
|
4
5
|
|
|
@@ -7,13 +8,28 @@ const startTime = Date.now();
|
|
|
7
8
|
// Public liveness probe. Deliberately minimal: no version/commit, no cron
|
|
8
9
|
// detail, no session oracle — this route sits in front of the auth guard and
|
|
9
10
|
// is reachable by anyone on the origin.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
//
|
|
12
|
+
// "Healthy" means the app's own SQLite handle answers a real read, not merely
|
|
13
|
+
// that the process accepts connections: the Docker healthcheck gates on this
|
|
14
|
+
// route, and a wedged database previously kept reporting healthy while every
|
|
15
|
+
// stateful route failed. The probe reads sqlite_master rather than a bare
|
|
16
|
+
// SELECT 1 — a constant expression touches no page of the database file, so it
|
|
17
|
+
// cannot notice a locked or corrupted one. The unhealthy body carries no
|
|
18
|
+
// detail — the route is public.
|
|
19
|
+
export function createHealthRoutes(deps: { db: Database }): Hono {
|
|
20
|
+
return new Hono().get("/health", (c) => {
|
|
21
|
+
try {
|
|
22
|
+
deps.db.query("SELECT name FROM sqlite_master LIMIT 1").get();
|
|
23
|
+
} catch {
|
|
24
|
+
return c.json({ status: "unhealthy" }, 503);
|
|
25
|
+
}
|
|
26
|
+
return c.json({
|
|
27
|
+
status: "healthy",
|
|
28
|
+
uptime: Date.now() - startTime,
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
});
|
|
15
31
|
});
|
|
16
|
-
}
|
|
32
|
+
}
|
|
17
33
|
|
|
18
34
|
export interface StatusDeps {
|
|
19
35
|
/** Git SHA baked at build time (SOURCE_COMMIT), "dev" when unset. */
|
package/src/routes/share.ts
CHANGED
|
@@ -118,7 +118,7 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
|
|
|
118
118
|
lastPrune = now;
|
|
119
119
|
// Deliberately not awaited: pruning is housekeeping, and the client is
|
|
120
120
|
// waiting on the staging result, not on it.
|
|
121
|
-
void pruneShareStaging(brainRoot).catch((err) => {
|
|
121
|
+
void pruneShareStaging(brainRoot, Date.now(), log).catch((err) => {
|
|
122
122
|
log?.emit({ severityText: "ERROR", body: "share staging prune failed", attributes: { error: err instanceof Error ? err.message : String(err) } });
|
|
123
123
|
});
|
|
124
124
|
}
|
|
@@ -167,12 +167,16 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
|
|
|
167
167
|
|
|
168
168
|
inFlight += 1;
|
|
169
169
|
try {
|
|
170
|
-
const result = await stageShare(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
170
|
+
const result = await stageShare(
|
|
171
|
+
brainRoot,
|
|
172
|
+
{
|
|
173
|
+
title: firstString(form, "title"),
|
|
174
|
+
text: firstString(form, "text"),
|
|
175
|
+
url: firstString(form, "url"),
|
|
176
|
+
files,
|
|
177
|
+
},
|
|
178
|
+
log
|
|
179
|
+
);
|
|
176
180
|
|
|
177
181
|
maybePrune();
|
|
178
182
|
return c.json(result, 201);
|
|
@@ -405,7 +405,7 @@ export function buildKeyterms(settings: KeytermSettings): KeytermsCache {
|
|
|
405
405
|
keyterms,
|
|
406
406
|
generatedAt: Date.now(),
|
|
407
407
|
count: keyterms.length,
|
|
408
|
-
overrides: loadOverrides(brainPath),
|
|
408
|
+
overrides: loadOverrides(brainPath, log),
|
|
409
409
|
};
|
|
410
410
|
});
|
|
411
411
|
} catch (err) {
|
|
@@ -429,7 +429,7 @@ export function buildKeyterms(settings: KeytermSettings): KeytermsCache {
|
|
|
429
429
|
keyterms: [],
|
|
430
430
|
generatedAt: Date.now(),
|
|
431
431
|
count: 0,
|
|
432
|
-
overrides: loadOverrides(brainPath),
|
|
432
|
+
overrides: loadOverrides(brainPath, log),
|
|
433
433
|
degraded: true,
|
|
434
434
|
};
|
|
435
435
|
}
|
package/src/ws/bridge.ts
CHANGED
|
@@ -38,10 +38,30 @@ export function makeBridge(
|
|
|
38
38
|
}
|
|
39
39
|
host.sendToClients(withTurnScope(msg, turn, turnId));
|
|
40
40
|
if (msg.type === "result") {
|
|
41
|
+
// Only the live turn's own result may set its disposition — a late
|
|
42
|
+
// frame through a previous turn's bridge must not relabel this one.
|
|
43
|
+
if (turn.turnId === turnId) {
|
|
44
|
+
turn.lastResult =
|
|
45
|
+
msg.isError || msg.outcome === "error"
|
|
46
|
+
? "error"
|
|
47
|
+
: msg.outcome === "cancelled"
|
|
48
|
+
? "cancelled"
|
|
49
|
+
: "success";
|
|
50
|
+
}
|
|
41
51
|
catalog.persistSession(msg, promptText, turn.providerId, backendId);
|
|
42
52
|
}
|
|
43
53
|
},
|
|
44
54
|
requestPermission: (req) => {
|
|
55
|
+
if (!host.clients.hasClients()) {
|
|
56
|
+
// Not a failure — the card is parked and re-delivered on reconnect
|
|
57
|
+
// (see resendPendingInteractive) — but the wait was invisible before
|
|
58
|
+
// this line existed, and it is bounded only by the turn timeout.
|
|
59
|
+
host.log.emit({
|
|
60
|
+
severityText: "WARN",
|
|
61
|
+
body: "approval requested with no client connected; holding for reconnect",
|
|
62
|
+
attributes: { "tool.name": req.toolName, "toolUse.id": req.toolUseId },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
45
65
|
host.sendToClients(
|
|
46
66
|
withTurnScope(
|
|
47
67
|
{
|
|
@@ -67,7 +87,7 @@ export function makeBridge(
|
|
|
67
87
|
resolve({ behavior: "deny", message: "Duplicate tool-approval id" });
|
|
68
88
|
return;
|
|
69
89
|
}
|
|
70
|
-
coordinator.pendingApprovals.set(req.toolUseId, { turn, turnId, resolve });
|
|
90
|
+
coordinator.pendingApprovals.set(req.toolUseId, { turn, turnId, request: req, resolve });
|
|
71
91
|
});
|
|
72
92
|
},
|
|
73
93
|
askUser: (requestId, questions) => {
|
|
@@ -86,7 +106,14 @@ export function makeBridge(
|
|
|
86
106
|
reject(new Error("Duplicate ask-user request id"));
|
|
87
107
|
return;
|
|
88
108
|
}
|
|
89
|
-
coordinator.pendingAskUser.set(requestId, {
|
|
109
|
+
coordinator.pendingAskUser.set(requestId, {
|
|
110
|
+
turn,
|
|
111
|
+
turnId,
|
|
112
|
+
requestId,
|
|
113
|
+
questions,
|
|
114
|
+
resolve,
|
|
115
|
+
reject,
|
|
116
|
+
});
|
|
90
117
|
});
|
|
91
118
|
},
|
|
92
119
|
getLocation: (options) => {
|
|
@@ -144,8 +171,10 @@ export function makeBridge(
|
|
|
144
171
|
|
|
145
172
|
export function emitTurnError(host: WsHost, turn: RunningTurn, err: unknown): void {
|
|
146
173
|
// startTurn resolves for runtime failures (it emits its own error frame); it
|
|
147
|
-
// only rejects for caller errors.
|
|
174
|
+
// only rejects for caller errors. Each rejection is reported server-side
|
|
175
|
+
// too — the frame alone leaves no trace once the browser tab is gone.
|
|
148
176
|
if (err instanceof BackendBusyError) {
|
|
177
|
+
host.reportTurnFailed("SESSION_BUSY", turn);
|
|
149
178
|
host.sendToClients(
|
|
150
179
|
withTurnScope(
|
|
151
180
|
{ type: "error", code: "SESSION_BUSY", message: "That session already has a running turn." },
|
|
@@ -153,15 +182,15 @@ export function emitTurnError(host: WsHost, turn: RunningTurn, err: unknown): vo
|
|
|
153
182
|
)
|
|
154
183
|
);
|
|
155
184
|
} else if (err instanceof BackendRequestError) {
|
|
185
|
+
host.reportTurnFailed("BACKEND_REQUEST_ERROR", turn, err.message);
|
|
156
186
|
host.sendToClients(
|
|
157
187
|
withTurnScope({ type: "error", code: "BACKEND_REQUEST_ERROR", message: err.message }, turn)
|
|
158
188
|
);
|
|
159
189
|
} else {
|
|
190
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
191
|
+
host.reportTurnFailed("BACKEND_ERROR", turn, message);
|
|
160
192
|
host.sendToClients(
|
|
161
|
-
withTurnScope(
|
|
162
|
-
{ type: "error", code: "BACKEND_ERROR", message: err instanceof Error ? err.message : String(err) },
|
|
163
|
-
turn
|
|
164
|
-
)
|
|
193
|
+
withTurnScope({ type: "error", code: "BACKEND_ERROR", message }, turn)
|
|
165
194
|
);
|
|
166
195
|
}
|
|
167
196
|
}
|
package/src/ws/clients.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { ServerMessage } from "@schlessera/brain-ui-sdk/protocol";
|
|
2
2
|
import { shrinkForReplication } from "./shrink.js";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Minimal structural view of a live socket — all we need to write to it.
|
|
6
|
+
* `raw` is the underlying Bun ServerWebSocket when hono's Bun adapter built
|
|
7
|
+
* the context; its `send` RETURNS a status instead of throwing.
|
|
8
|
+
*/
|
|
9
|
+
export type WSContext = {
|
|
10
|
+
send: (data: string) => void;
|
|
11
|
+
raw?: { send?: (data: string) => number } | undefined;
|
|
12
|
+
};
|
|
6
13
|
|
|
7
14
|
/** Serialize + size-bound a frame, then send it to one specific socket. */
|
|
8
15
|
export function sendTo(ws: WSContext, msg: ServerMessage): void {
|
|
@@ -38,16 +45,27 @@ export class ClientSet {
|
|
|
38
45
|
/**
|
|
39
46
|
* Broadcast a frame to every attached client. Serializes once. A failing
|
|
40
47
|
* socket is skipped (its `onClose` will prune it) so one dead peer can't
|
|
41
|
-
* block delivery to the others.
|
|
48
|
+
* block delivery to the others; `onSendError` lets the owner count the skip.
|
|
42
49
|
*/
|
|
43
|
-
broadcast(msg: ServerMessage): void {
|
|
50
|
+
broadcast(msg: ServerMessage, onSendError?: (err: unknown) => void): void {
|
|
44
51
|
if (this.clients.size === 0) return;
|
|
45
52
|
const payload = JSON.stringify(shrinkForReplication(msg));
|
|
46
53
|
for (const ws of this.clients) {
|
|
47
54
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
// Bun's ServerWebSocket reports a dropped write by RETURNING 0 (closed
|
|
56
|
+
// connection) rather than throwing, and hono's WSContext.send discards
|
|
57
|
+
// that status — so write through the raw socket where one exists. -1
|
|
58
|
+
// is backpressure: the frame is queued, not lost.
|
|
59
|
+
if (typeof ws.raw?.send === "function") {
|
|
60
|
+
if (ws.raw.send(payload) === 0) {
|
|
61
|
+
onSendError?.(new Error("send dropped: connection closed"));
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
ws.send(payload);
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
50
67
|
// Drop; the socket's onClose handler removes it from the set.
|
|
68
|
+
onSendError?.(err);
|
|
51
69
|
}
|
|
52
70
|
}
|
|
53
71
|
}
|