@schlessera/brain-ui-server 0.32.0 → 0.33.1

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 (48) hide show
  1. package/README.md +59 -1
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +2 -1
  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 +140 -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 +81 -4
  12. package/dist/brain/client.js.map +1 -1
  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 +44 -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 +68 -0
  22. package/dist/cron/emit.d.ts.map +1 -0
  23. package/dist/cron/emit.js +171 -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/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/middleware/origin.d.ts.map +1 -1
  34. package/dist/middleware/origin.js +22 -1
  35. package/dist/middleware/origin.js.map +1 -1
  36. package/dist/routes/brain.js +2 -2
  37. package/dist/routes/brain.js.map +1 -1
  38. package/package.json +6 -3
  39. package/src/app.ts +2 -1
  40. package/src/bin/brain-ui-cron.ts +178 -0
  41. package/src/brain/client.ts +96 -4
  42. package/src/config/env.ts +87 -8
  43. package/src/cron/digest.ts +45 -0
  44. package/src/cron/emit.ts +262 -0
  45. package/src/cron/run-job.ts +254 -0
  46. package/src/index.ts +5 -1
  47. package/src/middleware/origin.ts +21 -1
  48. package/src/routes/brain.ts +2 -2
@@ -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("brainCli", { 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
 
@@ -76,7 +166,7 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
76
166
  stdout: "pipe",
77
167
  stderr: "pipe",
78
168
  // Force JSON output when not a TTY
79
- env: subprocessEnv({ NO_COLOR: "1" }),
169
+ env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
80
170
  });
81
171
 
82
172
  const [stdout, stderr] = await Promise.all([
@@ -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
@@ -21,6 +21,8 @@ import { SEVERITIES, type Severity } from "../observability/types.js";
21
21
  import { envFlag } from "./env-core.js";
22
22
  import {
23
23
  filterSubprocessEnv,
24
+ parseSubprocessEnvExtra,
25
+ type SubprocessEnvAudience,
24
26
  WEB_SEARCH_PROVIDERS,
25
27
  } from "@schlessera/brain-ui-sdk/server";
26
28
 
@@ -69,6 +71,16 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
69
71
  default: "/root",
70
72
  required: false,
71
73
  },
74
+ {
75
+ name: "BRAIN_UI_SUBPROCESS_ENV_EXTRA",
76
+ description:
77
+ "Comma-separated environment variable names to admit to every child " +
78
+ "audience when an operator integration needs a variable outside the " +
79
+ "shipped allowlist. Names are trimmed; malformed entries are ignored; " +
80
+ "the control variable itself is never forwarded.",
81
+ default: "(empty)",
82
+ required: false,
83
+ },
72
84
  {
73
85
  name: "PI_CODING_AGENT_DIR",
74
86
  description:
@@ -105,8 +117,11 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
105
117
  },
106
118
  {
107
119
  name: "DB_PATH",
108
- description: "SQLite file for the UI's own database (sessions, passkeys, settings).",
109
- default: "./brain-ui.db",
120
+ description:
121
+ "SQLite file for the UI's own database (sessions, passkeys, settings). " +
122
+ "The server factory defaults to ./brain-ui.db; brain-ui-cron defaults " +
123
+ "to the container path /data/db/brain-ui.db.",
124
+ default: "./brain-ui.db (server); /data/db/brain-ui.db (brain-ui-cron)",
110
125
  required: false,
111
126
  },
112
127
  {
@@ -494,6 +509,20 @@ export interface ServerConfig {
494
509
 
495
510
  type EnvRecord = Record<string, string | undefined>;
496
511
 
512
+ /** Configuration resolved specifically for the standalone cron bin. */
513
+ export interface CronConfig {
514
+ /** Brain repository inspected by the crontab emitter. */
515
+ brainPath: string;
516
+ /** The deployment database; unlike createApp(), the bin defaults to the container path. */
517
+ dbPath: string;
518
+ /** Allowlisted brain CLI environment used only to discover module cron entries. */
519
+ moduleDiscoveryEnv: EnvRecord;
520
+ /** Allowlisted environment for the scheduled child, before the span sink is added. */
521
+ childEnv: EnvRecord;
522
+ /** Valid operator-added names, also used when emitting /etc/environment. */
523
+ subprocessEnvExtraNames: string[];
524
+ }
525
+
497
526
  function list(raw: string | undefined): string[] {
498
527
  return (raw ?? "")
499
528
  .split(",")
@@ -663,13 +692,63 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
663
692
  }
664
693
 
665
694
  /**
666
- * The filtered parent environment for spawned subprocesses (brain CLI,
667
- * whatsup), plus explicit overrides. The shared descriptor strips server-only
668
- * material while retaining all known subprocess capabilities and unknown
669
- * variables. This reads `process.env`, so it lives behind this chokepoint.
695
+ * Resolve the standalone cron bin's paths and allowlisted scheduled-job
696
+ * environment. The escape hatch is parsed here, at this package's sole env
697
+ * chokepoint, and its control variable is held back from the child.
698
+ */
699
+ export function resolveCronConfig(
700
+ env: EnvRecord = process.env,
701
+ admittedNames: readonly string[] = []
702
+ ): CronConfig {
703
+ const subprocessEnvExtraNames = parseSubprocessEnvExtra(
704
+ [env.BRAIN_UI_SUBPROCESS_ENV_EXTRA, ...admittedNames].join(",")
705
+ );
706
+ return {
707
+ brainPath: env.BRAIN_PATH || "/data/brain",
708
+ dbPath: env.DB_PATH || "/data/db/brain-ui.db",
709
+ moduleDiscoveryEnv: filterPackageSubprocessEnv(
710
+ env,
711
+ "brainCli",
712
+ subprocessEnvExtraNames
713
+ ),
714
+ childEnv: filterPackageSubprocessEnv(
715
+ env,
716
+ "cron",
717
+ subprocessEnvExtraNames
718
+ ),
719
+ subprocessEnvExtraNames,
720
+ };
721
+ }
722
+
723
+ /**
724
+ * Build a package-owned child environment from an environment value.
725
+ */
726
+ function filterPackageSubprocessEnv(
727
+ env: EnvRecord,
728
+ audience: SubprocessEnvAudience,
729
+ extraNames: readonly string[] = [],
730
+ extra: Record<string, string> = {}
731
+ ): EnvRecord {
732
+ const operatorNames = parseSubprocessEnvExtra(
733
+ env.BRAIN_UI_SUBPROCESS_ENV_EXTRA
734
+ );
735
+ return {
736
+ ...filterSubprocessEnv(env, audience, [...operatorNames, ...extraNames]),
737
+ ...extra,
738
+ };
739
+ }
740
+
741
+ /**
742
+ * The allowlisted parent environment for a spawned subprocess, plus explicit
743
+ * overrides. The agent default preserves this exported helper's historical
744
+ * no-argument use; every ui-server spawn names its actual audience.
670
745
  */
671
- export function subprocessEnv(extra: Record<string, string> = {}): EnvRecord {
672
- return { ...filterSubprocessEnv(process.env), ...extra };
746
+ export function subprocessEnv(
747
+ audience: SubprocessEnvAudience = "agent",
748
+ extra: Record<string, string> = {},
749
+ extraNames: readonly string[] = []
750
+ ): EnvRecord {
751
+ return filterPackageSubprocessEnv(process.env, audience, extraNames, extra);
673
752
  }
674
753
 
675
754
  /**
@@ -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,262 @@
1
+ import {
2
+ parseSubprocessEnvExtra,
3
+ SUBPROCESS_ENV,
4
+ type SubprocessEnvAudience,
5
+ } from "@schlessera/brain-ui-sdk/server";
6
+
7
+ /** One cron manifest entry in `brain module list --json`. */
8
+ export interface BrainModuleListCronEntry {
9
+ name: string;
10
+ schedule: string;
11
+ command: string;
12
+ }
13
+
14
+ /** One enabled module in the core CLI's module-list payload. */
15
+ export interface BrainModuleListEnabledModule {
16
+ name: string;
17
+ key: string;
18
+ description: string | null;
19
+ types: string[];
20
+ commands: string[];
21
+ cron: BrainModuleListCronEntry[];
22
+ }
23
+
24
+ /** One disabled-but-available module in the core CLI's module-list payload. */
25
+ export interface BrainModuleListAvailableModule {
26
+ key: string;
27
+ description: string | null;
28
+ enabled: false;
29
+ }
30
+
31
+ /** The payload emitted by `brain module list --json`. */
32
+ export interface BrainModuleListPayload {
33
+ enabled: BrainModuleListEnabledModule[];
34
+ available: BrainModuleListAvailableModule[];
35
+ }
36
+
37
+ export interface EmitCrontabOptions {
38
+ /** Null means the core CLI was unavailable or did not return JSON. */
39
+ modules: BrainModuleListPayload | null;
40
+ /** Command prefix that records and runs one job. */
41
+ wrapperCommand: string;
42
+ /** Command executed inside the wrapper's `digest` job. */
43
+ digestCommand: string;
44
+ /** Complete `PATH=...` crontab line. */
45
+ pathLine: string;
46
+ /** System crontab user field. */
47
+ user: string;
48
+ /** Whether `scripts/jobs/scrape-all.ts` exists in the brain repository. */
49
+ legacyScraperPresent: boolean;
50
+ /** Valid names carried into each wrapper so the run process re-admits them. */
51
+ subprocessEnvExtraNames?: readonly string[];
52
+ }
53
+
54
+ const MODULE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
55
+ const CRON_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
56
+ const CRON_SCHEDULE = /^[-0-9*,/ ]{1,100}$/;
57
+ const CRON_COMMAND = /^[A-Za-z0-9 _.:=@,/-]{1,200}$/;
58
+
59
+ // These use ^...$ without the `m` flag deliberately. In JavaScript `$`
60
+ // matches only at the true end of the string, so these are the correct
61
+ // translation of the bash jq/Oniguruma guards, which needed \A...\z because
62
+ // Oniguruma's `$` can match before a trailing newline.
63
+ function validModuleName(value: unknown): value is string {
64
+ return typeof value === "string" && MODULE_NAME.test(value);
65
+ }
66
+
67
+ function validCronEntry(value: unknown): value is BrainModuleListCronEntry {
68
+ if (!value || typeof value !== "object") return false;
69
+ const entry = value as Partial<BrainModuleListCronEntry>;
70
+ return (
71
+ typeof entry.name === "string" &&
72
+ CRON_NAME.test(entry.name) &&
73
+ typeof entry.schedule === "string" &&
74
+ CRON_SCHEDULE.test(entry.schedule) &&
75
+ typeof entry.command === "string" &&
76
+ CRON_COMMAND.test(entry.command)
77
+ );
78
+ }
79
+
80
+ function assertSingleLine(name: string, value: string): void {
81
+ if (/[\0\r\n]/.test(value)) {
82
+ throw new Error(`${name} must be a single line`);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Emit `/etc/cron.d/brain-ui` with the same byte layout as the deployment
88
+ * shell's historical `generate_crontab` function.
89
+ */
90
+ export function emitCrontab(options: EmitCrontabOptions): string {
91
+ const {
92
+ modules,
93
+ wrapperCommand,
94
+ digestCommand,
95
+ pathLine,
96
+ user,
97
+ legacyScraperPresent,
98
+ subprocessEnvExtraNames = [],
99
+ } = options;
100
+
101
+ assertSingleLine("wrapperCommand", wrapperCommand);
102
+ assertSingleLine("digestCommand", digestCommand);
103
+ assertSingleLine("pathLine", pathLine);
104
+ assertSingleLine("user", user);
105
+
106
+ const validatedExtraNames = parseSubprocessEnvExtra(
107
+ subprocessEnvExtraNames.join(",")
108
+ );
109
+ const wrapper =
110
+ validatedExtraNames.length === 0
111
+ ? wrapperCommand
112
+ : `${wrapperCommand} --subprocess-env-extra ${validatedExtraNames.join(",")}`;
113
+
114
+ const lines = [
115
+ "# Generated by entrypoint.sh — regenerated on each container start.",
116
+ pathLine,
117
+ "",
118
+ "# Base jobs",
119
+ `0 2 * * * ${user} cd /data/brain && ${wrapper} sync -- sh -c 'brain sync && brain index' 2>&1 | logger -t brain-sync`,
120
+ `0 3 * * * ${user} cd /data/brain && ${wrapper} validate -- brain validate 2>&1 | logger -t brain-validate`,
121
+ `0 7 * * * ${user} cd /data/brain && ${wrapper} maintain -- brain maintain 2>&1 | logger -t brain-maintain`,
122
+ `30 7 * * * ${user} cd /data/brain && ${wrapper} digest -- ${digestCommand} 2>&1 | logger -t brain-digest`,
123
+ "",
124
+ "# Module jobs (from enabled modules' cron manifests, when exposed)",
125
+ ];
126
+
127
+ const enabled = modules && Array.isArray(modules.enabled) ? modules.enabled : [];
128
+ if (modules === null) {
129
+ lines.push("# (brain module list unavailable — module cron skipped)");
130
+ } else {
131
+ for (const module of enabled) {
132
+ if (!module || typeof module !== "object" || !validModuleName(module.name)) continue;
133
+ const entries = Array.isArray(module.cron) ? module.cron : [];
134
+ for (const entry of entries) {
135
+ if (!validCronEntry(entry)) continue;
136
+ const jobName = `${module.name}-${entry.name}`;
137
+ lines.push(
138
+ `${entry.schedule} ${user} cd /data/brain && ${wrapper} ${jobName} -- brain ${entry.command} 2>&1 | logger -t brain-${jobName}`
139
+ );
140
+ }
141
+ }
142
+ }
143
+
144
+ const jobsModuleEnabled = enabled.some(
145
+ (module) =>
146
+ module &&
147
+ typeof module === "object" &&
148
+ Array.isArray(module.commands) &&
149
+ module.commands.includes("jobs")
150
+ );
151
+ // Keep the historical standalone scraper fallback for brain repositories
152
+ // that have not moved their jobs workflow to an enabled module.
153
+ if (!jobsModuleEnabled && legacyScraperPresent) {
154
+ lines.push(
155
+ `0 4 * * * ${user} cd /data/brain && ${wrapper} jobs -- bun scripts/jobs/scrape-all.ts --api-only 2>&1 | logger -t brain-jobs`
156
+ );
157
+ }
158
+
159
+ lines.push("");
160
+ return `${lines.join("\n")}\n`;
161
+ }
162
+
163
+ /**
164
+ * NODE_ENV is in the descriptor because Bun uses it to choose `.env.<mode>` in
165
+ * every child spawned by the server. Scheduled jobs take a different path:
166
+ * they historically receive their environment through pam_env without
167
+ * NODE_ENV, and adding it would change which brain-repo env file their Bun
168
+ * commands load. The 0.33.1 allowlist keeps that historical behavior while
169
+ * widening the explicitly approved capability settings below.
170
+ */
171
+ export const CRON_ENV_EXCLUSIONS: ReadonlySet<string> = new Set(["NODE_ENV"]);
172
+
173
+ /**
174
+ * The order the shell emitted `/etc/environment` in. pam_env does not care,
175
+ * but the historical golden is byte-exact and this is what it is compared
176
+ * against.
177
+ *
178
+ * Order lives HERE, not in the SDK descriptor: the SET is owned by
179
+ * `SUBPROCESS_ENV`'s cron audience (see {@link CRON_ENV_NAMES}, which fails
180
+ * loudly if the two ever disagree), and making an unrelated file's declaration
181
+ * order load-bearing for a cosmetic property would be a trap for whoever next
182
+ * tidies it.
183
+ */
184
+ const CRON_ENV_ORDER: readonly string[] = [
185
+ "PATH",
186
+ "BRAIN_PATH",
187
+ "BRAIN_ROOT",
188
+ "TZ",
189
+ "XDG_BIN_HOME",
190
+ "DB_PATH",
191
+ "BRAIN_RERANK_MODE",
192
+ "SCRAPE_CHROME_URL",
193
+ "CHROME_CDP_URL",
194
+ "SCRAPE_CHROME_PATH",
195
+ "SCRAPE_CHROME_NO_SANDBOX",
196
+ "SCRAPE_USER_AGENT",
197
+ "SCRAPE_RESPECT_ROBOTS",
198
+ "GEMINI_API_KEY",
199
+ "GEMINI_BASE_URL",
200
+ "OPENAI_API_KEY",
201
+ "OPENAI_BASE_URL",
202
+ "GITHUB_TOKEN",
203
+ "BRAIN_UI_SYNC_GITHUB_TOKEN",
204
+ "CLAUDE_CODE_OAUTH_TOKEN",
205
+ "BRAIN_UI_PRICING_DISCOVERY",
206
+ "BRAIN_UI_PRICING_TTL_HOURS",
207
+ ];
208
+
209
+ /**
210
+ * Names emitted into `/etc/environment`: the SDK's cron audience minus the
211
+ * explicit exclusions, in {@link CRON_ENV_ORDER}.
212
+ *
213
+ * A variable that joins or leaves the cron audience and is not accounted for
214
+ * here throws at module load rather than silently changing what scheduled jobs
215
+ * can see.
216
+ */
217
+ export const CRON_ENV_NAMES = Object.freeze(
218
+ (() => {
219
+ const audience = Object.entries(SUBPROCESS_ENV)
220
+ .filter(([, audiences]) =>
221
+ (audiences as readonly SubprocessEnvAudience[]).includes("cron")
222
+ )
223
+ .map(([name]) => name)
224
+ .filter((name) => !CRON_ENV_EXCLUSIONS.has(name));
225
+ const ordered = new Set(CRON_ENV_ORDER);
226
+ const missing = audience.filter((name) => !ordered.has(name));
227
+ const extra = CRON_ENV_ORDER.filter((name) => !audience.includes(name));
228
+ if (missing.length > 0 || extra.length > 0) {
229
+ throw new Error(
230
+ "cron environment list drifted from SUBPROCESS_ENV's cron audience: " +
231
+ `unordered=[${missing.join(", ")}] unknown=[${extra.join(", ")}]`
232
+ );
233
+ }
234
+ return CRON_ENV_ORDER.slice();
235
+ })()
236
+ );
237
+
238
+ /** Emit the pam_env input consumed by scheduled jobs. */
239
+ export function emitEnvironment(
240
+ env: Record<string, string | undefined>,
241
+ extraNames: readonly string[] = []
242
+ ): string {
243
+ const lines: string[] = [];
244
+ const names = [
245
+ ...CRON_ENV_NAMES,
246
+ ...parseSubprocessEnvExtra(extraNames.join(",")).filter(
247
+ (name) => !CRON_ENV_NAMES.includes(name)
248
+ ),
249
+ ];
250
+ for (const name of names) {
251
+ const value = env[name];
252
+ // Match the shell's `[ -n ]`: both unset and empty values are omitted.
253
+ if (!value) continue;
254
+ // /etc/environment has no shell-safe interpolation layer. Reject anything
255
+ // that could terminate/escape the quoted value or create another line.
256
+ if (/[\0"\\\r\n]/.test(value)) {
257
+ throw new Error(`cannot emit unsafe /etc/environment value for ${name}`);
258
+ }
259
+ lines.push(`${name}="${value}"`);
260
+ }
261
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
262
+ }