@schlessera/brain-ui-server 0.31.0 → 0.33.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 (120) hide show
  1. package/README.md +104 -6
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +51 -26
  4. package/dist/app.js.map +1 -1
  5. package/dist/bin/brain-ui-cron.d.ts +4 -0
  6. package/dist/bin/brain-ui-cron.d.ts.map +1 -0
  7. package/dist/bin/brain-ui-cron.js +123 -0
  8. package/dist/bin/brain-ui-cron.js.map +1 -0
  9. package/dist/brain/client.d.ts +9 -0
  10. package/dist/brain/client.d.ts.map +1 -1
  11. package/dist/brain/client.js +80 -3
  12. package/dist/brain/client.js.map +1 -1
  13. package/dist/config/env.d.ts +22 -5
  14. package/dist/config/env.d.ts.map +1 -1
  15. package/dist/config/env.js +30 -9
  16. package/dist/config/env.js.map +1 -1
  17. package/dist/cron/digest.d.ts +19 -0
  18. package/dist/cron/digest.d.ts.map +1 -0
  19. package/dist/cron/digest.js +26 -0
  20. package/dist/cron/digest.js.map +1 -0
  21. package/dist/cron/emit.d.ts +66 -0
  22. package/dist/cron/emit.d.ts.map +1 -0
  23. package/dist/cron/emit.js +152 -0
  24. package/dist/cron/emit.js.map +1 -0
  25. package/dist/cron/run-job.d.ts +48 -0
  26. package/dist/cron/run-job.d.ts.map +1 -0
  27. package/dist/cron/run-job.js +172 -0
  28. package/dist/cron/run-job.js.map +1 -0
  29. package/dist/files/walker.d.ts.map +1 -1
  30. package/dist/files/walker.js +70 -10
  31. package/dist/files/walker.js.map +1 -1
  32. package/dist/index.d.ts +3 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +6 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/middleware/auth.d.ts +25 -5
  37. package/dist/middleware/auth.d.ts.map +1 -1
  38. package/dist/middleware/auth.js +255 -47
  39. package/dist/middleware/auth.js.map +1 -1
  40. package/dist/middleware/body-limit.d.ts +15 -0
  41. package/dist/middleware/body-limit.d.ts.map +1 -0
  42. package/dist/middleware/body-limit.js +41 -0
  43. package/dist/middleware/body-limit.js.map +1 -0
  44. package/dist/middleware/origin.d.ts +20 -12
  45. package/dist/middleware/origin.d.ts.map +1 -1
  46. package/dist/middleware/origin.js +71 -25
  47. package/dist/middleware/origin.js.map +1 -1
  48. package/dist/middleware/passkeys.d.ts +5 -1
  49. package/dist/middleware/passkeys.d.ts.map +1 -1
  50. package/dist/middleware/passkeys.js +67 -26
  51. package/dist/middleware/passkeys.js.map +1 -1
  52. package/dist/routes/brain.d.ts.map +1 -1
  53. package/dist/routes/brain.js +7 -2
  54. package/dist/routes/brain.js.map +1 -1
  55. package/dist/routes/files.js +2 -2
  56. package/dist/routes/files.js.map +1 -1
  57. package/dist/routes/models.d.ts.map +1 -1
  58. package/dist/routes/models.js +22 -10
  59. package/dist/routes/models.js.map +1 -1
  60. package/dist/routes/pi-auth.d.ts.map +1 -1
  61. package/dist/routes/pi-auth.js +12 -4
  62. package/dist/routes/pi-auth.js.map +1 -1
  63. package/dist/routes/push.d.ts.map +1 -1
  64. package/dist/routes/push.js +12 -4
  65. package/dist/routes/push.js.map +1 -1
  66. package/dist/routes/render.d.ts +6 -0
  67. package/dist/routes/render.d.ts.map +1 -1
  68. package/dist/routes/render.js +8 -11
  69. package/dist/routes/render.js.map +1 -1
  70. package/dist/routes/share.d.ts +2 -0
  71. package/dist/routes/share.d.ts.map +1 -1
  72. package/dist/routes/share.js +30 -31
  73. package/dist/routes/share.js.map +1 -1
  74. package/dist/routes/skills.d.ts.map +1 -1
  75. package/dist/routes/skills.js +22 -8
  76. package/dist/routes/skills.js.map +1 -1
  77. package/dist/routes/web-search.d.ts.map +1 -1
  78. package/dist/routes/web-search.js +7 -2
  79. package/dist/routes/web-search.js.map +1 -1
  80. package/dist/share/staging.d.ts.map +1 -1
  81. package/dist/share/staging.js +2 -1
  82. package/dist/share/staging.js.map +1 -1
  83. package/dist/ws/clients.d.ts +13 -4
  84. package/dist/ws/clients.d.ts.map +1 -1
  85. package/dist/ws/clients.js +41 -5
  86. package/dist/ws/clients.js.map +1 -1
  87. package/dist/ws/connection.d.ts.map +1 -1
  88. package/dist/ws/connection.js +10 -2
  89. package/dist/ws/connection.js.map +1 -1
  90. package/dist/ws/host.d.ts +5 -0
  91. package/dist/ws/host.d.ts.map +1 -1
  92. package/dist/ws/host.js +18 -1
  93. package/dist/ws/host.js.map +1 -1
  94. package/package.json +6 -3
  95. package/src/app.ts +57 -24
  96. package/src/bin/brain-ui-cron.ts +152 -0
  97. package/src/brain/client.ts +95 -3
  98. package/src/config/env.ts +47 -9
  99. package/src/cron/digest.ts +45 -0
  100. package/src/cron/emit.ts +230 -0
  101. package/src/cron/run-job.ts +254 -0
  102. package/src/files/walker.ts +66 -10
  103. package/src/index.ts +13 -1
  104. package/src/middleware/auth.ts +311 -48
  105. package/src/middleware/body-limit.ts +47 -0
  106. package/src/middleware/origin.ts +92 -24
  107. package/src/middleware/passkeys.ts +80 -29
  108. package/src/routes/brain.ts +7 -3
  109. package/src/routes/files.ts +2 -2
  110. package/src/routes/models.ts +17 -10
  111. package/src/routes/pi-auth.ts +10 -4
  112. package/src/routes/push.ts +10 -4
  113. package/src/routes/render.ts +14 -10
  114. package/src/routes/share.ts +35 -34
  115. package/src/routes/skills.ts +18 -8
  116. package/src/routes/web-search.ts +6 -2
  117. package/src/share/staging.ts +2 -1
  118. package/src/ws/clients.ts +45 -7
  119. package/src/ws/connection.ts +11 -2
  120. package/src/ws/host.ts +23 -1
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Execute one container-cron job while recording its status and activity.
3
+ * Tracking is fail-open: database or activity failures never prevent the job
4
+ * from running, and the wrapper always returns the child's exit code.
5
+ */
6
+ import { unlinkSync } from "fs";
7
+
8
+ import { ingestSpanSink } from "../activity/span-sink.js";
9
+ import { createActivityStore } from "../activity/store.js";
10
+ import { createUiDb } from "../db/client.js";
11
+ import { recordCronRun } from "./scheduler.js";
12
+
13
+ /** Tail of stderr retained for the cron_runs error row. */
14
+ export const STDERR_TAIL_CHARS = 2_000;
15
+ /** Tail of combined stdout/stderr retained as the job_output activity event. */
16
+ export const OUTPUT_TAIL_CHARS = 8_000;
17
+ /** Heartbeat cadence, comfortably inside the activity store's stale threshold. */
18
+ export const HEARTBEAT_MS = 30_000;
19
+ /** Child-visible path where span-sink JSONL may be appended. */
20
+ export const SPAN_SINK_ENV = "BRAIN_ACTIVITY_SPAN_SINK";
21
+
22
+ interface TextSink {
23
+ write(text: string): unknown;
24
+ }
25
+
26
+ interface SpawnedJob {
27
+ stdout: ReadableStream<Uint8Array>;
28
+ stderr: ReadableStream<Uint8Array>;
29
+ exited: Promise<number>;
30
+ signalCode: string | null;
31
+ }
32
+
33
+ interface RunRecorder {
34
+ finish(error: string | undefined, output: string, exitCode: number): void;
35
+ close(): void;
36
+ }
37
+
38
+ export interface RunJobOptions {
39
+ jobName: string;
40
+ command: string[];
41
+ dbPath: string;
42
+ childEnv: Record<string, string | undefined>;
43
+ stdout?: TextSink;
44
+ stderr?: TextSink;
45
+ }
46
+
47
+ export interface RunJobDependencies {
48
+ spawn?: (
49
+ command: string[],
50
+ options: {
51
+ stdin: "inherit";
52
+ stdout: "pipe";
53
+ stderr: "pipe";
54
+ env: Record<string, string | undefined>;
55
+ }
56
+ ) => SpawnedJob;
57
+ sinkPath?: string;
58
+ startRecord?: (
59
+ name: string,
60
+ sinkPath: string,
61
+ command: string[],
62
+ dbPath: string,
63
+ stderr: TextSink
64
+ ) => Promise<RunRecorder | null>;
65
+ removeSink?: (path: string) => void;
66
+ }
67
+
68
+ function messageOf(error: unknown): string {
69
+ return error instanceof Error ? error.message : String(error);
70
+ }
71
+
72
+ function writeLine(sink: TextSink, text: string): void {
73
+ sink.write(`${text}\n`);
74
+ }
75
+
76
+ /** Forward a stream verbatim while exposing decoded chunks for tail capture. */
77
+ export async function teeStream(
78
+ stream: ReadableStream<Uint8Array>,
79
+ sink: TextSink,
80
+ onText: (text: string) => void
81
+ ): Promise<void> {
82
+ const decoder = new TextDecoder();
83
+ for await (const chunk of stream) {
84
+ const text = decoder.decode(chunk, { stream: true });
85
+ sink.write(text);
86
+ onText(text);
87
+ }
88
+ }
89
+
90
+ /** Keep the last `cap` characters, never the head. */
91
+ export function appendTail(current: string, text: string, cap: number): string {
92
+ return (current + text).slice(-cap);
93
+ }
94
+
95
+ async function startRecord(
96
+ name: string,
97
+ sinkPath: string,
98
+ command: string[],
99
+ dbPath: string,
100
+ stderr: TextSink
101
+ ): Promise<RunRecorder | null> {
102
+ try {
103
+ const db = createUiDb(dbPath);
104
+ const record = recordCronRun(db, name);
105
+ const store = createActivityStore(db, { writer: `cron:${process.pid}` });
106
+ const runId = `cron-${name}-${Date.now()}`;
107
+ const rootSpanId = `${runId}:root`;
108
+ store.startSpan({
109
+ spanId: rootSpanId,
110
+ runId,
111
+ name: `cron ${name}`,
112
+ kind: "cron",
113
+ origin: "cron",
114
+ jobName: name,
115
+ });
116
+
117
+ let sinkOffset = 0;
118
+ const ingest = () => {
119
+ try {
120
+ const result = ingestSpanSink(
121
+ store,
122
+ sinkPath,
123
+ { runId, rootSpanId, jobName: name },
124
+ sinkOffset
125
+ );
126
+ sinkOffset = result.offset;
127
+ } catch {
128
+ // Sink lines are best-effort enrichment.
129
+ }
130
+ };
131
+
132
+ const heartbeat = setInterval(() => {
133
+ try {
134
+ store.heartbeat(rootSpanId);
135
+ } catch {
136
+ // Tracking remains fail-open after startup too.
137
+ }
138
+ ingest();
139
+ }, HEARTBEAT_MS);
140
+
141
+ return {
142
+ finish(error, output, exitCode) {
143
+ record.finish(error);
144
+ try {
145
+ ingest();
146
+ if (output) {
147
+ try {
148
+ store.appendEvent(rootSpanId, "job_output", output, undefined, OUTPUT_TAIL_CHARS);
149
+ } catch {
150
+ // Output capture is enrichment; never fail a run over it.
151
+ }
152
+ }
153
+ store.endSpan(rootSpanId, {
154
+ outcome: error === undefined ? "success" : "error",
155
+ reason: error,
156
+ attrs: {
157
+ "cron.command": command.join(" "),
158
+ "cron.exit_code": exitCode,
159
+ },
160
+ });
161
+ store.cascadeClose(runId, "cancelled", "job finished");
162
+ store.rollupRun(runId);
163
+ } catch (error) {
164
+ writeLine(stderr, `[cron-run] failed to close activity span: ${messageOf(error)}`);
165
+ } finally {
166
+ clearInterval(heartbeat);
167
+ }
168
+ },
169
+ close() {
170
+ clearInterval(heartbeat);
171
+ db.close();
172
+ },
173
+ };
174
+ } catch (error) {
175
+ writeLine(
176
+ stderr,
177
+ `[cron-run] tracking unavailable (job runs anyway): ${messageOf(error)}`
178
+ );
179
+ return null;
180
+ }
181
+ }
182
+
183
+ /** Run a job as argv (never through a shell) and return its exact exit code. */
184
+ export async function runJob(
185
+ options: RunJobOptions,
186
+ dependencies: RunJobDependencies = {}
187
+ ): Promise<number> {
188
+ const stdout = options.stdout ?? process.stdout;
189
+ const stderr = options.stderr ?? process.stderr;
190
+ const sinkPath =
191
+ dependencies.sinkPath ??
192
+ `/tmp/brain-activity-sink-${process.pid}-${Date.now()}.jsonl`;
193
+ const beginRecord = dependencies.startRecord ?? startRecord;
194
+ const recorder = await beginRecord(
195
+ options.jobName,
196
+ sinkPath,
197
+ options.command,
198
+ options.dbPath,
199
+ stderr
200
+ );
201
+
202
+ let exitCode: number;
203
+ let errorMessage: string | undefined;
204
+ let outputTail = "";
205
+
206
+ try {
207
+ const spawn = dependencies.spawn ?? ((command, spawnOptions) => Bun.spawn(command, spawnOptions));
208
+ const proc = spawn(options.command, {
209
+ stdin: "inherit",
210
+ stdout: "pipe",
211
+ stderr: "pipe",
212
+ env: { ...options.childEnv, [SPAN_SINK_ENV]: sinkPath },
213
+ });
214
+
215
+ let stderrTail = "";
216
+ await Promise.all([
217
+ teeStream(proc.stdout, stdout, (text) => {
218
+ outputTail = appendTail(outputTail, text, OUTPUT_TAIL_CHARS);
219
+ }),
220
+ teeStream(proc.stderr, stderr, (text) => {
221
+ stderrTail = appendTail(stderrTail, text, STDERR_TAIL_CHARS);
222
+ outputTail = appendTail(outputTail, text, OUTPUT_TAIL_CHARS);
223
+ }),
224
+ ]);
225
+
226
+ exitCode = await proc.exited;
227
+ if (exitCode !== 0) {
228
+ const cause = proc.signalCode ? `killed by ${proc.signalCode}` : `exit code ${exitCode}`;
229
+ const trimmedTail = stderrTail.trim();
230
+ errorMessage = trimmedTail ? `${cause}; stderr tail: ${trimmedTail}` : cause;
231
+ }
232
+ } catch (error) {
233
+ exitCode = 127;
234
+ errorMessage = messageOf(error);
235
+ writeLine(stderr, `[cron-run] failed to start job command: ${errorMessage}`);
236
+ }
237
+
238
+ if (recorder) {
239
+ try {
240
+ recorder.finish(errorMessage, outputTail.trim(), exitCode);
241
+ recorder.close();
242
+ } catch (error) {
243
+ writeLine(stderr, `[cron-run] failed to record run outcome: ${messageOf(error)}`);
244
+ }
245
+ }
246
+
247
+ try {
248
+ (dependencies.removeSink ?? unlinkSync)(sinkPath);
249
+ } catch {
250
+ // Never emitted, or already gone.
251
+ }
252
+
253
+ return exitCode;
254
+ }
@@ -1,6 +1,5 @@
1
- import { readdir, readFile, stat, realpath } from "node:fs/promises";
2
- import { join, resolve, sep, posix, dirname } from "node:path";
3
- import { existsSync } from "node:fs";
1
+ import { lstat, readdir, readFile, stat, realpath } from "node:fs/promises";
2
+ import { join, relative, resolve, sep, posix, dirname } from "node:path";
4
3
  import ignore, { type Ignore } from "ignore";
5
4
  import type { FileEntry, FileContentKind } from "@schlessera/brain-ui-sdk/protocol";
6
5
  import { FILE_SIZE_CAP_BYTES } from "@schlessera/brain-ui-sdk/protocol";
@@ -25,6 +24,15 @@ const HARD_EXCLUDE_FILE_NAMES = new Set(
25
24
  ].map((n) => n.toLowerCase())
26
25
  );
27
26
 
27
+ const UNRESOLVABLE_PATH_CODES = new Set([
28
+ "ENOENT",
29
+ "ENOTDIR",
30
+ "ENAMETOOLONG",
31
+ "ELOOP",
32
+ "EACCES",
33
+ "EPERM",
34
+ ]);
35
+
28
36
  export class PathEscapeError extends Error {
29
37
  constructor(rel: string) {
30
38
  super(`path_escape: ${rel}`);
@@ -52,8 +60,14 @@ export class TooLargeError extends Error {
52
60
  */
53
61
  export async function safeResolve(rel: string, root: string): Promise<string> {
54
62
  if (typeof rel !== "string") throw new PathEscapeError(String(rel));
55
- // Reject absolute paths and null bytes
56
- if (rel.startsWith("/") || rel.startsWith("\\") || rel.includes("\0")) {
63
+ const requestSegments = rel.split("/");
64
+ // Reject absolute paths, null bytes, and Windows path syntax before resolving.
65
+ if (
66
+ rel.startsWith("/") ||
67
+ rel.startsWith("\\") ||
68
+ rel.includes("\0") ||
69
+ requestSegments.some((segment) => segment.includes(":") || segment.includes("\\"))
70
+ ) {
57
71
  throw new PathEscapeError(rel);
58
72
  }
59
73
  // Normalize separators; reject ".." segments anywhere
@@ -61,14 +75,49 @@ export async function safeResolve(rel: string, root: string): Promise<string> {
61
75
  for (const seg of normalized.split("/")) {
62
76
  if (seg === "..") throw new PathEscapeError(rel);
63
77
  }
64
- const absRoot = resolve(root);
78
+ const lexicalRoot = resolve(root);
79
+ let absRoot: string;
80
+ try {
81
+ absRoot = await realpath(lexicalRoot);
82
+ } catch (err) {
83
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
84
+ absRoot = lexicalRoot;
85
+ }
65
86
  const abs = resolve(absRoot, normalized);
66
87
  if (abs !== absRoot && !abs.startsWith(absRoot + sep)) {
67
88
  throw new PathEscapeError(rel);
68
89
  }
69
- // Check for symlink escape via realpath when the path exists
70
- if (existsSync(abs)) {
71
- const real = await realpath(abs);
90
+ // Canonicalize every existing component. Stopping at the first genuinely
91
+ // missing component permits callers to create a new path, while lstat
92
+ // distinguishes that case from a dangling symlink, which must fail closed.
93
+ let candidate = absRoot;
94
+ const candidates = [candidate];
95
+ for (const segment of relative(absRoot, abs).split(sep)) {
96
+ if (!segment || segment === ".") continue;
97
+ candidate = join(candidate, segment);
98
+ candidates.push(candidate);
99
+ }
100
+
101
+ for (const existing of candidates) {
102
+ try {
103
+ await lstat(existing);
104
+ } catch (err) {
105
+ const code = (err as NodeJS.ErrnoException).code;
106
+ if (code && UNRESOLVABLE_PATH_CODES.has(code)) break;
107
+ throw err;
108
+ }
109
+
110
+ let real: string;
111
+ try {
112
+ real = await realpath(existing);
113
+ } catch (err) {
114
+ const code = (err as NodeJS.ErrnoException).code;
115
+ if (code === "ENOENT" || code === "ENOTDIR") {
116
+ throw new PathEscapeError(rel);
117
+ }
118
+ if (code && UNRESOLVABLE_PATH_CODES.has(code)) break;
119
+ throw err;
120
+ }
72
121
  if (real !== absRoot && !real.startsWith(absRoot + sep)) {
73
122
  throw new PathEscapeError(rel);
74
123
  }
@@ -373,7 +422,14 @@ export async function buildWikilinkMap(root: string): Promise<Record<string, str
373
422
  }
374
423
 
375
424
  if (childStat.isDirectory()) {
376
- await walk(relChild);
425
+ try {
426
+ await walk(relChild);
427
+ } catch (err) {
428
+ // Directory names come from readdir rather than an external request.
429
+ // Keep request-facing validation strict, but skip a discovered path
430
+ // that safeResolve rejects instead of discarding the whole map.
431
+ if (!(err instanceof PathEscapeError)) throw err;
432
+ }
377
433
  continue;
378
434
  }
379
435
  const lower = d.name.toLowerCase();
package/src/index.ts CHANGED
@@ -40,6 +40,9 @@ export {
40
40
  type ModelDiscoveryState,
41
41
  } from "./agent/backend.js";
42
42
  export {
43
+ authGuard,
44
+ bumpSessionsEpoch,
45
+ isWsAuthorized,
43
46
  resolveAuthMode,
44
47
  type AuthMode,
45
48
  type AuthRuntime,
@@ -73,7 +76,11 @@ export {
73
76
 
74
77
  // Brain repo access (spawned CLI wrapper) — useful for embedders that add
75
78
  // their own routes on top.
76
- export { createBrainClient, type BrainClient } from "./brain/client.js";
79
+ export {
80
+ createBrainClient,
81
+ MIN_BRAIN_CLI_VERSION,
82
+ type BrainClient,
83
+ } from "./brain/client.js";
77
84
 
78
85
  // Cron run history. Scheduling belongs to the deployment (container crontab);
79
86
  // an external scheduler's wrapper records each run here so /api/status's
@@ -107,6 +114,11 @@ export {
107
114
  // intake route also sweeps opportunistically on every share.
108
115
  export { pruneShareStaging, shareStagingRoot } from "./share/staging.js";
109
116
 
117
+ // Skill archive transport sizing for deployment shells. The route keeps the
118
+ // application-level check; consumers use this value only to avoid setting a
119
+ // smaller process-level request ceiling.
120
+ export { MAX_ARCHIVE_BYTES } from "./skills/install.js";
121
+
110
122
  // Observability: the producing side is the OpenTelemetry API, the consuming
111
123
  // side is ours. Swap the consumer to change where a deployment reports; a test
112
124
  // swaps in the recording one and asserts on what the server actually said.