@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.
Files changed (90) hide show
  1. package/README.md +2 -0
  2. package/dist/agent/backend.d.ts +31 -10
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +86 -26
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +51 -17
  8. package/dist/app.js.map +1 -1
  9. package/dist/config/env-core.d.ts +73 -0
  10. package/dist/config/env-core.d.ts.map +1 -0
  11. package/dist/config/env-core.js +62 -0
  12. package/dist/config/env-core.js.map +1 -0
  13. package/dist/config/env.d.ts +24 -5
  14. package/dist/config/env.d.ts.map +1 -1
  15. package/dist/config/env.js +55 -11
  16. package/dist/config/env.js.map +1 -1
  17. package/dist/cron/scheduler.d.ts +17 -0
  18. package/dist/cron/scheduler.d.ts.map +1 -1
  19. package/dist/cron/scheduler.js +33 -6
  20. package/dist/cron/scheduler.js.map +1 -1
  21. package/dist/db/settings.d.ts +1 -1
  22. package/dist/db/settings.d.ts.map +1 -1
  23. package/dist/db/settings.js +2 -2
  24. package/dist/db/settings.js.map +1 -1
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/middleware/auth.d.ts +5 -0
  30. package/dist/middleware/auth.d.ts.map +1 -1
  31. package/dist/middleware/auth.js +46 -1
  32. package/dist/middleware/auth.js.map +1 -1
  33. package/dist/routes/health.d.ts +4 -14
  34. package/dist/routes/health.d.ts.map +1 -1
  35. package/dist/routes/health.js +22 -6
  36. package/dist/routes/health.js.map +1 -1
  37. package/dist/routes/share.d.ts.map +1 -1
  38. package/dist/routes/share.js +2 -2
  39. package/dist/routes/share.js.map +1 -1
  40. package/dist/voice/keyterm-builder.js +2 -2
  41. package/dist/voice/keyterm-builder.js.map +1 -1
  42. package/dist/ws/bridge.d.ts.map +1 -1
  43. package/dist/ws/bridge.js +36 -4
  44. package/dist/ws/bridge.js.map +1 -1
  45. package/dist/ws/clients.d.ts +10 -3
  46. package/dist/ws/clients.d.ts.map +1 -1
  47. package/dist/ws/clients.js +16 -4
  48. package/dist/ws/clients.js.map +1 -1
  49. package/dist/ws/connection.d.ts +1 -1
  50. package/dist/ws/connection.d.ts.map +1 -1
  51. package/dist/ws/connection.js +43 -6
  52. package/dist/ws/connection.js.map +1 -1
  53. package/dist/ws/dispatch.d.ts.map +1 -1
  54. package/dist/ws/dispatch.js +3 -1
  55. package/dist/ws/dispatch.js.map +1 -1
  56. package/dist/ws/host.d.ts +36 -0
  57. package/dist/ws/host.d.ts.map +1 -1
  58. package/dist/ws/host.js +93 -4
  59. package/dist/ws/host.js.map +1 -1
  60. package/dist/ws/run-session.d.ts.map +1 -1
  61. package/dist/ws/run-session.js +31 -5
  62. package/dist/ws/run-session.js.map +1 -1
  63. package/dist/ws/session-catalog.d.ts +2 -1
  64. package/dist/ws/session-catalog.d.ts.map +1 -1
  65. package/dist/ws/session-catalog.js +20 -3
  66. package/dist/ws/session-catalog.js.map +1 -1
  67. package/dist/ws/turns.d.ts +30 -2
  68. package/dist/ws/turns.d.ts.map +1 -1
  69. package/dist/ws/turns.js +13 -0
  70. package/dist/ws/turns.js.map +1 -1
  71. package/package.json +7 -6
  72. package/src/agent/backend.ts +123 -29
  73. package/src/app.ts +59 -17
  74. package/src/config/env-core.ts +93 -0
  75. package/src/config/env.ts +80 -17
  76. package/src/cron/scheduler.ts +48 -14
  77. package/src/db/settings.ts +2 -2
  78. package/src/index.ts +6 -0
  79. package/src/middleware/auth.ts +50 -1
  80. package/src/routes/health.ts +22 -6
  81. package/src/routes/share.ts +11 -7
  82. package/src/voice/keyterm-builder.ts +2 -2
  83. package/src/ws/bridge.ts +36 -7
  84. package/src/ws/clients.ts +24 -6
  85. package/src/ws/connection.ts +59 -6
  86. package/src/ws/dispatch.ts +3 -1
  87. package/src/ws/host.ts +115 -6
  88. package/src/ws/run-session.ts +36 -8
  89. package/src/ws/session-catalog.ts +21 -3
  90. 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
- /** The environment variable, exactly as read. */
37
+ /** Variable name as it appears in the environment. */
24
38
  name: string;
25
39
  /** What it controls. */
26
40
  description: string;
27
41
  /** Human-readable default applied when unset, or null when there is none. */
28
42
  default: string | null;
29
- /**
30
- * `false` when optional; otherwise a human-readable statement of the
31
- * condition under which boot fails without it.
32
- */
33
43
  required: false | string;
34
44
  }
35
45
 
@@ -65,6 +75,19 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
65
75
  default: "(empty)",
66
76
  required: false,
67
77
  },
78
+ {
79
+ name: "BRAIN_UI_CONFIRM_BASH",
80
+ description:
81
+ "JSON array of regex sources; a Bash command matching any of them raises " +
82
+ "a confirmation card before it runs. Unset uses the shipped defaults " +
83
+ "(brain archive, rm -r, git push --force, git reset --hard, git clean -f, " +
84
+ "git checkout -- ). An empty array [] disables the confirmation. Not a " +
85
+ "security boundary — an agent with Bash can reach the same effect another " +
86
+ "way; it stops a destructive command you did not intend, not one that is " +
87
+ "trying to get past you.",
88
+ default: "the shipped pattern set",
89
+ required: false,
90
+ },
68
91
  {
69
92
  name: "BRAIN_UI_WS_RATE",
70
93
  description:
@@ -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 rawDiscovery = env.BRAIN_UI_MODEL_DISCOVERY?.trim().toLowerCase();
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: flag(env.TRUST_PROXY),
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: flag(env.BRAIN_UI_DANGEROUSLY_DISABLE_AUTH),
417
- allowPassword: flag(env.BRAIN_UI_ALLOW_PASSWORD),
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: flag(env.BRAIN_UI_ALLOW_LOOPBACK_ORIGIN),
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,
@@ -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 do not appear here; they log to syslog.
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
- db.prepare(
54
- "INSERT INTO cron_runs (job_name, started_at, status) VALUES (?, ?, 'running')"
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
- const durationMs = Date.now() - startedAt;
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": durationMs },
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
- db.prepare(
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",
@@ -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";
@@ -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
- const ok = await Bun.password.verify(password, hash).catch(() => false);
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
 
@@ -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
- export const healthRoutes = new Hono().get("/health", (c) => {
11
- return c.json({
12
- status: "healthy",
13
- uptime: Date.now() - startTime,
14
- timestamp: new Date().toISOString(),
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. */
@@ -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(brainRoot, {
171
- title: firstString(form, "title"),
172
- text: firstString(form, "text"),
173
- url: firstString(form, "url"),
174
- files,
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, { turn, turnId, resolve, reject });
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
- /** Minimal structural view of a live socket — all we need to write to it. */
5
- export type WSContext = { send: (data: string) => void };
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
- ws.send(payload);
49
- } catch {
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
  }