@schlessera/brain-ui-server 0.32.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schlessera/brain-ui-server",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "brain-kit chat-UI server: Hono app factory, WebSocket turn coordinator, auth (password/passkeys/tailscale/proxy), session catalog, and brain/files/voice routes",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -27,6 +27,9 @@
27
27
  },
28
28
  "sideEffects": false,
29
29
  "type": "module",
30
+ "bin": {
31
+ "brain-ui-cron": "./dist/bin/brain-ui-cron.js"
32
+ },
30
33
  "exports": {
31
34
  ".": {
32
35
  "bun": "./src/index.ts",
@@ -51,8 +54,8 @@
51
54
  "dependencies": {
52
55
  "@opentelemetry/api": "^1.9.1",
53
56
  "@opentelemetry/api-logs": "0.221.0",
54
- "@schlessera/brain-render-template": "0.32.0",
55
- "@schlessera/brain-ui-sdk": "0.32.0",
57
+ "@schlessera/brain-render-template": "0.33.0",
58
+ "@schlessera/brain-ui-sdk": "0.33.0",
56
59
  "@simplewebauthn/server": "^13.3.2",
57
60
  "fflate": "^0.8.2",
58
61
  "gray-matter": "^4.0.3",
package/src/app.ts CHANGED
@@ -53,7 +53,7 @@ import {
53
53
  createBackendRegistry,
54
54
  type BackendRegistry,
55
55
  } from "./agent/backend.js";
56
- import { createBrainClient } from "./brain/client.js";
56
+ import { createBrainClient, probeBrainCliVersion } from "./brain/client.js";
57
57
  import { createCronScheduler } from "./cron/scheduler.js";
58
58
  import { WsHost } from "./ws/host.js";
59
59
  import { createWsUpgrade, websocket } from "./ws/connection.js";
@@ -177,6 +177,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
177
177
  // Per-instance state: the app's own database, the brain CLI wrapper, the
178
178
  // backend registry, and the WebSocket host. No module-level singletons —
179
179
  // two apps with different configuration coexist in one process.
180
+ probeBrainCliVersion(config.brainPath, observability.logger("brain"));
180
181
  const dbLog = observability.logger("db");
181
182
  const db = createUiDb(config.dbPath, { log: dbLog });
182
183
  const brain = createBrainClient({ brainPath: config.brainPath });
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env bun
2
+ /** Thin argv dispatcher for the ui-server's container cron jobs. */
3
+
4
+ import { statSync } from "fs";
5
+ import { join } from "path";
6
+
7
+ import { resolveCronConfig } from "../config/env.js";
8
+ import { runDigest } from "../cron/digest.js";
9
+ import {
10
+ emitCrontab,
11
+ emitEnvironment,
12
+ type BrainModuleListPayload,
13
+ } from "../cron/emit.js";
14
+ import { runJob } from "../cron/run-job.js";
15
+
16
+ /**
17
+ * The bash this replaced tested `[ -f … ]`, which is true only for a regular
18
+ * file (following symlinks). `existsSync` would also accept a directory, and a
19
+ * directory at that path would put a root cron line in the crontab that the
20
+ * shell has always skipped.
21
+ */
22
+ function isRegularFile(path: string): boolean {
23
+ try {
24
+ return statSync(path).isFile();
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ export const USAGE = `usage: brain-ui-cron run <job-name> -- <command...>
31
+ brain-ui-cron digest
32
+ brain-ui-cron crontab [--wrapper-command <command>] [--digest-command <command>] [--path-line <line>] [--user <user>]
33
+ brain-ui-cron environment`;
34
+
35
+ const DEFAULT_WRAPPER_COMMAND = "bun /opt/brain-ui/server/scripts/cron-run.ts";
36
+ const DEFAULT_DIGEST_COMMAND = "bun /opt/brain-ui/server/scripts/brain-digest.ts";
37
+ const DEFAULT_PATH_LINE =
38
+ "PATH=/root/.local/bin:/root/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
39
+ const DEFAULT_USER = "root";
40
+
41
+ function usage(): never {
42
+ process.stderr.write(`${USAGE}\n`);
43
+ process.exit(2);
44
+ }
45
+
46
+ function crontabOptions(args: string[]): {
47
+ wrapperCommand: string;
48
+ digestCommand: string;
49
+ pathLine: string;
50
+ user: string;
51
+ } {
52
+ const values = new Map<string, string>();
53
+ const accepted = new Set([
54
+ "--wrapper-command",
55
+ "--digest-command",
56
+ "--path-line",
57
+ "--user",
58
+ ]);
59
+ for (let i = 0; i < args.length; i += 2) {
60
+ const flag = args[i];
61
+ const value = args[i + 1];
62
+ if (!accepted.has(flag) || value === undefined || value === "" || value.startsWith("--")) {
63
+ usage();
64
+ }
65
+ values.set(flag, value);
66
+ }
67
+ return {
68
+ wrapperCommand: values.get("--wrapper-command") ?? DEFAULT_WRAPPER_COMMAND,
69
+ digestCommand: values.get("--digest-command") ?? DEFAULT_DIGEST_COMMAND,
70
+ pathLine: values.get("--path-line") ?? DEFAULT_PATH_LINE,
71
+ user: values.get("--user") ?? DEFAULT_USER,
72
+ };
73
+ }
74
+
75
+ async function readModuleList(
76
+ brainPath: string,
77
+ env: Record<string, string | undefined>
78
+ ): Promise<BrainModuleListPayload | null> {
79
+ try {
80
+ const proc = Bun.spawn(["brain", "module", "list", "--json"], {
81
+ cwd: brainPath,
82
+ env,
83
+ stdout: "pipe",
84
+ stderr: "ignore",
85
+ });
86
+ const [exitCode, stdout] = await Promise.all([
87
+ proc.exited,
88
+ new Response(proc.stdout).text(),
89
+ ]);
90
+ if (exitCode !== 0 || stdout.trim() === "") return null;
91
+ const parsed: unknown = JSON.parse(stdout);
92
+ if (
93
+ !parsed ||
94
+ typeof parsed !== "object" ||
95
+ !Array.isArray((parsed as Partial<BrainModuleListPayload>).enabled) ||
96
+ !Array.isArray((parsed as Partial<BrainModuleListPayload>).available)
97
+ ) {
98
+ return null;
99
+ }
100
+ return parsed as BrainModuleListPayload;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ const [subcommand, ...args] = process.argv.slice(2);
107
+ const config = resolveCronConfig();
108
+
109
+ if (subcommand === "run") {
110
+ const [jobName, separator, ...command] = args;
111
+ if (!jobName || separator !== "--" || command.length === 0) usage();
112
+ process.exit(
113
+ await runJob({
114
+ jobName,
115
+ command,
116
+ dbPath: config.dbPath,
117
+ childEnv: config.childEnv,
118
+ })
119
+ );
120
+ }
121
+
122
+ if (subcommand === "digest" && args.length === 0) {
123
+ process.exit(runDigest({ dbPath: config.dbPath }));
124
+ }
125
+
126
+ if (subcommand === "crontab") {
127
+ const options = crontabOptions(args);
128
+ const modules = await readModuleList(config.brainPath, config.childEnv);
129
+ process.stdout.write(
130
+ emitCrontab({
131
+ ...options,
132
+ modules,
133
+ legacyScraperPresent: isRegularFile(
134
+ join(config.brainPath, "scripts", "jobs", "scrape-all.ts")
135
+ ),
136
+ })
137
+ );
138
+ process.exit(0);
139
+ }
140
+
141
+ if (subcommand === "environment" && args.length === 0) {
142
+ try {
143
+ process.stdout.write(emitEnvironment(config.childEnv));
144
+ process.exit(0);
145
+ } catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ process.stderr.write(`[brain-ui-cron] ${message}\n`);
148
+ process.exit(1);
149
+ }
150
+ }
151
+
152
+ usage();
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "fs";
2
2
  import { join } from "path";
3
+ import type { Logger } from "@opentelemetry/api-logs";
3
4
  import { subprocessEnv } from "../config/env.js";
4
5
  import type {
5
6
  BrainSearchResult,
@@ -48,6 +49,13 @@ interface ExecResult {
48
49
  exitCode: number;
49
50
  }
50
51
 
52
+ /**
53
+ * First core release whose parser understands `--`. The ui-server inserts the
54
+ * separator before user input, so an older brain repo pin would lose every
55
+ * positional silently.
56
+ */
57
+ export const MIN_BRAIN_CLI_VERSION = "0.33.0";
58
+
51
59
  /**
52
60
  * argv prefix for invoking the brain CLI inside `brainPath`.
53
61
  *
@@ -67,6 +75,88 @@ export function brainCliCommand(brainPath: string): string[] {
67
75
  return ["bun", "scripts/brain-cli.ts"];
68
76
  }
69
77
 
78
+ interface ParsedVersion {
79
+ major: number;
80
+ minor: number;
81
+ patch: number;
82
+ prerelease: boolean;
83
+ }
84
+
85
+ function parseVersion(value: string): ParsedVersion | null {
86
+ const match = value.match(
87
+ // Full SemVer 2.0.0 grammar: prerelease and build metadata are
88
+ // dot-separated NON-EMPTY identifiers. A loose `[0-9A-Za-z.-]+` would
89
+ // accept "0.32.9-.." and treat garbage as a real prerelease, which the
90
+ // comparison below would then refuse to boot on instead of warning.
91
+ /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
92
+ );
93
+ if (!match) return null;
94
+ return {
95
+ major: Number(match[1]),
96
+ minor: Number(match[2]),
97
+ patch: Number(match[3]),
98
+ prerelease: match[4] !== undefined,
99
+ };
100
+ }
101
+
102
+ function isBelowMinimum(found: ParsedVersion, minimum: ParsedVersion): boolean {
103
+ for (const key of ["major", "minor", "patch"] as const) {
104
+ if (found[key] !== minimum[key]) return found[key] < minimum[key];
105
+ }
106
+ return found.prerelease && !minimum.prerelease;
107
+ }
108
+
109
+ /** Probe the brain repo's own CLI pin, refusing only known-incompatible versions. */
110
+ export function probeBrainCliVersion(brainPath: string, log: Logger): void {
111
+ let result: ReturnType<typeof Bun.spawnSync>;
112
+ try {
113
+ result = Bun.spawnSync([...brainCliCommand(brainPath), "--version"], {
114
+ cwd: brainPath,
115
+ stdout: "pipe",
116
+ stderr: "pipe",
117
+ env: subprocessEnv({ NO_COLOR: "1" }),
118
+ timeout: 5_000,
119
+ });
120
+ } catch (error) {
121
+ log.emit({
122
+ severityText: "WARN",
123
+ body: "brain CLI version probe failed; continuing",
124
+ attributes: {
125
+ reason: error instanceof Error ? error.message : String(error),
126
+ },
127
+ });
128
+ return;
129
+ }
130
+
131
+ if (result.exitCode !== 0) {
132
+ log.emit({
133
+ severityText: "WARN",
134
+ body: "brain CLI version probe failed; continuing",
135
+ attributes: { "exit.code": result.exitCode },
136
+ });
137
+ return;
138
+ }
139
+
140
+ const foundText = new TextDecoder().decode(result.stdout).trim();
141
+ const found = parseVersion(foundText);
142
+ const minimum = parseVersion(MIN_BRAIN_CLI_VERSION)!;
143
+ if (!found) {
144
+ log.emit({
145
+ severityText: "WARN",
146
+ body: "brain CLI returned an unparseable version; continuing",
147
+ attributes: { version: foundText },
148
+ });
149
+ return;
150
+ }
151
+
152
+ if (isBelowMinimum(found, minimum)) {
153
+ throw new Error(
154
+ `brain CLI version ${foundText} is incompatible: version ${MIN_BRAIN_CLI_VERSION} ` +
155
+ `or newer is required; bump the brain repo's @schlessera/brain pin before deploying.`
156
+ );
157
+ }
158
+ }
159
+
70
160
  export function createBrainClient(opts: { brainPath: string }): BrainClient {
71
161
  const { brainPath } = opts;
72
162
 
@@ -108,11 +198,12 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
108
198
  },
109
199
 
110
200
  async search(query, opts) {
111
- const args = ["search", query];
201
+ const args = ["search"];
112
202
  if (opts?.type) args.push("--type", opts.type);
113
203
  if (opts?.tag) args.push("--tag", opts.tag);
114
204
  if (opts?.limit) args.push("--limit", String(opts.limit));
115
205
  if (opts?.mode) args.push("--mode", opts.mode);
206
+ args.push("--", query);
116
207
 
117
208
  const result = await execBrain(args);
118
209
  const parsed = parseJsonOutput<BrainSearchResponse | BrainSearchResult[]>(result);
@@ -146,7 +237,7 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
146
237
  },
147
238
 
148
239
  async read(path) {
149
- const result = await execBrain(["read", path]);
240
+ const result = await execBrain(["read", "--", path]);
150
241
  if (result.exitCode !== 0) {
151
242
  throw new Error(`brain read failed: ${result.stderr}`);
152
243
  }
@@ -178,10 +269,11 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
178
269
  },
179
270
 
180
271
  async add(content, opts) {
181
- const args = ["add", content];
272
+ const args = ["add"];
182
273
  if (opts?.type) args.push("--type", opts.type);
183
274
  if (opts?.title) args.push("--title", opts.title);
184
275
  if (opts?.tags) args.push("--tags", opts.tags.join(","));
276
+ args.push("--", content);
185
277
 
186
278
  const result = await execBrain(args);
187
279
  if (result.exitCode !== 0) {
package/src/config/env.ts CHANGED
@@ -105,8 +105,11 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
105
105
  },
106
106
  {
107
107
  name: "DB_PATH",
108
- description: "SQLite file for the UI's own database (sessions, passkeys, settings).",
109
- default: "./brain-ui.db",
108
+ description:
109
+ "SQLite file for the UI's own database (sessions, passkeys, settings). " +
110
+ "The server factory defaults to ./brain-ui.db; brain-ui-cron defaults " +
111
+ "to the container path /data/db/brain-ui.db.",
112
+ default: "./brain-ui.db (server); /data/db/brain-ui.db (brain-ui-cron)",
110
113
  required: false,
111
114
  },
112
115
  {
@@ -494,6 +497,16 @@ export interface ServerConfig {
494
497
 
495
498
  type EnvRecord = Record<string, string | undefined>;
496
499
 
500
+ /** Configuration resolved specifically for the standalone cron bin. */
501
+ export interface CronConfig {
502
+ /** Brain repository inspected by the crontab emitter. */
503
+ brainPath: string;
504
+ /** The deployment database; unlike createApp(), the bin defaults to the container path. */
505
+ dbPath: string;
506
+ /** Exact inherited environment for the scheduled child, before the span sink is added. */
507
+ childEnv: EnvRecord;
508
+ }
509
+
497
510
  function list(raw: string | undefined): string[] {
498
511
  return (raw ?? "")
499
512
  .split(",")
@@ -662,6 +675,20 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
662
675
  };
663
676
  }
664
677
 
678
+ /**
679
+ * Resolve the standalone cron bin's environment without applying the server's
680
+ * subprocess secret filter. Scheduled jobs inherit the same environment they
681
+ * did before this behavior moved out of the deployment shell; narrowing that
682
+ * environment belongs to the later least-privilege release.
683
+ */
684
+ export function resolveCronConfig(env: EnvRecord = process.env): CronConfig {
685
+ return {
686
+ brainPath: env.BRAIN_PATH || "/data/brain",
687
+ dbPath: env.DB_PATH || "/data/db/brain-ui.db",
688
+ childEnv: { ...env },
689
+ };
690
+ }
691
+
665
692
  /**
666
693
  * The filtered parent environment for spawned subprocesses (brain CLI,
667
694
  * whatsup), plus explicit overrides. The shared descriptor strips server-only
@@ -0,0 +1,45 @@
1
+ /** The standalone daily activity-digest job. Failures are deliberately loud. */
2
+ import { generateActivityDigest } from "../activity/digest.js";
3
+ import { createUiDb } from "../db/client.js";
4
+
5
+ interface TextSink {
6
+ write(text: string): unknown;
7
+ }
8
+
9
+ export interface DigestOptions {
10
+ dbPath: string;
11
+ stdout?: TextSink;
12
+ stderr?: TextSink;
13
+ }
14
+
15
+ export interface DigestDependencies {
16
+ createDb?: typeof createUiDb;
17
+ generateDigest?: typeof generateActivityDigest;
18
+ }
19
+
20
+ /** Generate and persist one digest, returning a process-compatible status. */
21
+ export function runDigest(
22
+ options: DigestOptions,
23
+ dependencies: DigestDependencies = {}
24
+ ): number {
25
+ const stdout = options.stdout ?? process.stdout;
26
+ const stderr = options.stderr ?? process.stderr;
27
+ const openDb = dependencies.createDb ?? createUiDb;
28
+ const generate = dependencies.generateDigest ?? generateActivityDigest;
29
+ let db: ReturnType<typeof createUiDb> | undefined;
30
+
31
+ try {
32
+ db = openDb(options.dbPath);
33
+ const digest = generate(db);
34
+ stdout.write(
35
+ `[brain-digest] generated: ${digest.runs} runs, ${digest.failures} failures, $${digest.costUsd.toFixed(2)}\n`
36
+ );
37
+ return 0;
38
+ } catch (error) {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ stderr.write(`[brain-digest] failed: ${message}\n`);
41
+ return 1;
42
+ } finally {
43
+ db?.close();
44
+ }
45
+ }
@@ -0,0 +1,230 @@
1
+ import {
2
+ SUBPROCESS_ENV,
3
+ type SubprocessEnvAudience,
4
+ } from "@schlessera/brain-ui-sdk/server";
5
+
6
+ /** One cron manifest entry in `brain module list --json`. */
7
+ export interface BrainModuleListCronEntry {
8
+ name: string;
9
+ schedule: string;
10
+ command: string;
11
+ }
12
+
13
+ /** One enabled module in the core CLI's module-list payload. */
14
+ export interface BrainModuleListEnabledModule {
15
+ name: string;
16
+ key: string;
17
+ description: string | null;
18
+ types: string[];
19
+ commands: string[];
20
+ cron: BrainModuleListCronEntry[];
21
+ }
22
+
23
+ /** One disabled-but-available module in the core CLI's module-list payload. */
24
+ export interface BrainModuleListAvailableModule {
25
+ key: string;
26
+ description: string | null;
27
+ enabled: false;
28
+ }
29
+
30
+ /** The payload emitted by `brain module list --json`. */
31
+ export interface BrainModuleListPayload {
32
+ enabled: BrainModuleListEnabledModule[];
33
+ available: BrainModuleListAvailableModule[];
34
+ }
35
+
36
+ export interface EmitCrontabOptions {
37
+ /** Null means the core CLI was unavailable or did not return JSON. */
38
+ modules: BrainModuleListPayload | null;
39
+ /** Command prefix that records and runs one job. */
40
+ wrapperCommand: string;
41
+ /** Command executed inside the wrapper's `digest` job. */
42
+ digestCommand: string;
43
+ /** Complete `PATH=...` crontab line. */
44
+ pathLine: string;
45
+ /** System crontab user field. */
46
+ user: string;
47
+ /** Whether `scripts/jobs/scrape-all.ts` exists in the brain repository. */
48
+ legacyScraperPresent: boolean;
49
+ }
50
+
51
+ const MODULE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
52
+ const CRON_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
53
+ const CRON_SCHEDULE = /^[-0-9*,/ ]{1,100}$/;
54
+ const CRON_COMMAND = /^[A-Za-z0-9 _.:=@,/-]{1,200}$/;
55
+
56
+ // These use ^...$ without the `m` flag deliberately. In JavaScript `$`
57
+ // matches only at the true end of the string, so these are the correct
58
+ // translation of the bash jq/Oniguruma guards, which needed \A...\z because
59
+ // Oniguruma's `$` can match before a trailing newline.
60
+ function validModuleName(value: unknown): value is string {
61
+ return typeof value === "string" && MODULE_NAME.test(value);
62
+ }
63
+
64
+ function validCronEntry(value: unknown): value is BrainModuleListCronEntry {
65
+ if (!value || typeof value !== "object") return false;
66
+ const entry = value as Partial<BrainModuleListCronEntry>;
67
+ return (
68
+ typeof entry.name === "string" &&
69
+ CRON_NAME.test(entry.name) &&
70
+ typeof entry.schedule === "string" &&
71
+ CRON_SCHEDULE.test(entry.schedule) &&
72
+ typeof entry.command === "string" &&
73
+ CRON_COMMAND.test(entry.command)
74
+ );
75
+ }
76
+
77
+ function assertSingleLine(name: string, value: string): void {
78
+ if (/[\0\r\n]/.test(value)) {
79
+ throw new Error(`${name} must be a single line`);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Emit `/etc/cron.d/brain-ui` with the same byte layout as the deployment
85
+ * shell's historical `generate_crontab` function.
86
+ */
87
+ export function emitCrontab(options: EmitCrontabOptions): string {
88
+ const {
89
+ modules,
90
+ wrapperCommand,
91
+ digestCommand,
92
+ pathLine,
93
+ user,
94
+ legacyScraperPresent,
95
+ } = options;
96
+
97
+ assertSingleLine("wrapperCommand", wrapperCommand);
98
+ assertSingleLine("digestCommand", digestCommand);
99
+ assertSingleLine("pathLine", pathLine);
100
+ assertSingleLine("user", user);
101
+
102
+ const lines = [
103
+ "# Generated by entrypoint.sh — regenerated on each container start.",
104
+ pathLine,
105
+ "",
106
+ "# Base jobs",
107
+ `0 2 * * * ${user} cd /data/brain && ${wrapperCommand} sync -- sh -c 'brain sync && brain index' 2>&1 | logger -t brain-sync`,
108
+ `0 3 * * * ${user} cd /data/brain && ${wrapperCommand} validate -- brain validate 2>&1 | logger -t brain-validate`,
109
+ `0 7 * * * ${user} cd /data/brain && ${wrapperCommand} maintain -- brain maintain 2>&1 | logger -t brain-maintain`,
110
+ `30 7 * * * ${user} cd /data/brain && ${wrapperCommand} digest -- ${digestCommand} 2>&1 | logger -t brain-digest`,
111
+ "",
112
+ "# Module jobs (from enabled modules' cron manifests, when exposed)",
113
+ ];
114
+
115
+ const enabled = modules && Array.isArray(modules.enabled) ? modules.enabled : [];
116
+ if (modules === null) {
117
+ lines.push("# (brain module list unavailable — module cron skipped)");
118
+ } else {
119
+ for (const module of enabled) {
120
+ if (!module || typeof module !== "object" || !validModuleName(module.name)) continue;
121
+ const entries = Array.isArray(module.cron) ? module.cron : [];
122
+ for (const entry of entries) {
123
+ if (!validCronEntry(entry)) continue;
124
+ const jobName = `${module.name}-${entry.name}`;
125
+ lines.push(
126
+ `${entry.schedule} ${user} cd /data/brain && ${wrapperCommand} ${jobName} -- brain ${entry.command} 2>&1 | logger -t brain-${jobName}`
127
+ );
128
+ }
129
+ }
130
+ }
131
+
132
+ const jobsModuleEnabled = enabled.some(
133
+ (module) =>
134
+ module &&
135
+ typeof module === "object" &&
136
+ Array.isArray(module.commands) &&
137
+ module.commands.includes("jobs")
138
+ );
139
+ // Keep the historical standalone scraper fallback for brain repositories
140
+ // that have not moved their jobs workflow to an enabled module.
141
+ if (!jobsModuleEnabled && legacyScraperPresent) {
142
+ lines.push(
143
+ `0 4 * * * ${user} cd /data/brain && ${wrapperCommand} jobs -- bun scripts/jobs/scrape-all.ts --api-only 2>&1 | logger -t brain-jobs`
144
+ );
145
+ }
146
+
147
+ lines.push("");
148
+ return `${lines.join("\n")}\n`;
149
+ }
150
+
151
+ /**
152
+ * NODE_ENV is in the descriptor because Bun uses it to choose `.env.<mode>` in
153
+ * every child spawned by the server. Scheduled jobs take a different path:
154
+ * they historically receive their environment through pam_env without
155
+ * NODE_ENV, and adding it would change which brain-repo env file their Bun
156
+ * commands load. Revisit this explicit subtraction in the 0.33.1
157
+ * subprocess-environment allowlist unit.
158
+ */
159
+ export const CRON_ENV_EXCLUSIONS: ReadonlySet<string> = new Set(["NODE_ENV"]);
160
+
161
+ /**
162
+ * The order the shell emitted `/etc/environment` in. pam_env does not care,
163
+ * but the historical golden is byte-exact and this is what it is compared
164
+ * against.
165
+ *
166
+ * Order lives HERE, not in the SDK descriptor: the SET is owned by
167
+ * `SUBPROCESS_ENV`'s cron audience (see {@link CRON_ENV_NAMES}, which fails
168
+ * loudly if the two ever disagree), and making an unrelated file's declaration
169
+ * order load-bearing for a cosmetic property would be a trap for whoever next
170
+ * tidies it.
171
+ */
172
+ const CRON_ENV_ORDER: readonly string[] = [
173
+ "PATH",
174
+ "BRAIN_PATH",
175
+ "TZ",
176
+ "DB_PATH",
177
+ "GEMINI_API_KEY",
178
+ "OPENAI_API_KEY",
179
+ "GITHUB_TOKEN",
180
+ "BRAIN_UI_SYNC_GITHUB_TOKEN",
181
+ "CLAUDE_CODE_OAUTH_TOKEN",
182
+ "BRAIN_UI_PRICING_DISCOVERY",
183
+ "BRAIN_UI_PRICING_TTL_HOURS",
184
+ ];
185
+
186
+ /**
187
+ * Names emitted into `/etc/environment`: the SDK's cron audience minus the
188
+ * explicit exclusions, in {@link CRON_ENV_ORDER}.
189
+ *
190
+ * A variable that joins or leaves the cron audience and is not accounted for
191
+ * here throws at module load rather than silently changing what scheduled jobs
192
+ * can see.
193
+ */
194
+ export const CRON_ENV_NAMES = Object.freeze(
195
+ (() => {
196
+ const audience = Object.entries(SUBPROCESS_ENV)
197
+ .filter(([, audiences]) =>
198
+ (audiences as readonly SubprocessEnvAudience[]).includes("cron")
199
+ )
200
+ .map(([name]) => name)
201
+ .filter((name) => !CRON_ENV_EXCLUSIONS.has(name));
202
+ const ordered = new Set(CRON_ENV_ORDER);
203
+ const missing = audience.filter((name) => !ordered.has(name));
204
+ const extra = CRON_ENV_ORDER.filter((name) => !audience.includes(name));
205
+ if (missing.length > 0 || extra.length > 0) {
206
+ throw new Error(
207
+ "cron environment list drifted from SUBPROCESS_ENV's cron audience: " +
208
+ `unordered=[${missing.join(", ")}] unknown=[${extra.join(", ")}]`
209
+ );
210
+ }
211
+ return CRON_ENV_ORDER.slice();
212
+ })()
213
+ );
214
+
215
+ /** Emit the pam_env input consumed by scheduled jobs. */
216
+ export function emitEnvironment(env: Record<string, string | undefined>): string {
217
+ const lines: string[] = [];
218
+ for (const name of CRON_ENV_NAMES) {
219
+ const value = env[name];
220
+ // Match the shell's `[ -n ]`: both unset and empty values are omitted.
221
+ if (!value) continue;
222
+ // /etc/environment has no shell-safe interpolation layer. Reject anything
223
+ // that could terminate/escape the quoted value or create another line.
224
+ if (/[\0"\\\r\n]/.test(value)) {
225
+ throw new Error(`cannot emit unsafe /etc/environment value for ${name}`);
226
+ }
227
+ lines.push(`${name}="${value}"`);
228
+ }
229
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
230
+ }