@schlessera/brain-ui-server 0.33.0 → 0.34.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 (44) hide show
  1. package/README.md +12 -0
  2. package/dist/agent/backend.d.ts +17 -219
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +212 -471
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +2 -1
  8. package/dist/app.js.map +1 -1
  9. package/dist/bin/brain-ui-cron.d.ts +1 -1
  10. package/dist/bin/brain-ui-cron.d.ts.map +1 -1
  11. package/dist/bin/brain-ui-cron.js +24 -7
  12. package/dist/bin/brain-ui-cron.js.map +1 -1
  13. package/dist/brain/client.js +2 -2
  14. package/dist/brain/client.js.map +1 -1
  15. package/dist/config/env.d.ts +18 -15
  16. package/dist/config/env.d.ts.map +1 -1
  17. package/dist/config/env.js +33 -13
  18. package/dist/config/env.js.map +1 -1
  19. package/dist/cron/emit.d.ts +5 -3
  20. package/dist/cron/emit.d.ts.map +1 -1
  21. package/dist/cron/emit.js +31 -12
  22. package/dist/cron/emit.js.map +1 -1
  23. package/dist/middleware/origin.d.ts.map +1 -1
  24. package/dist/middleware/origin.js +22 -1
  25. package/dist/middleware/origin.js.map +1 -1
  26. package/dist/routes/brain.js +2 -2
  27. package/dist/routes/brain.js.map +1 -1
  28. package/dist/routes/pi-auth.d.ts.map +1 -1
  29. package/dist/routes/pi-auth.js +14 -6
  30. package/dist/routes/pi-auth.js.map +1 -1
  31. package/dist/routes/web-search.d.ts.map +1 -1
  32. package/dist/routes/web-search.js +12 -8
  33. package/dist/routes/web-search.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/agent/backend.ts +281 -793
  36. package/src/app.ts +2 -1
  37. package/src/bin/brain-ui-cron.ts +32 -6
  38. package/src/brain/client.ts +2 -2
  39. package/src/config/env.ts +69 -17
  40. package/src/cron/emit.ts +42 -10
  41. package/src/middleware/origin.ts +21 -1
  42. package/src/routes/brain.ts +2 -2
  43. package/src/routes/pi-auth.ts +13 -8
  44. package/src/routes/web-search.ts +12 -10
package/src/app.ts CHANGED
@@ -170,7 +170,8 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
170
170
  assertPasskeyConfig(config.webauthn);
171
171
  // A missing (or unrecognized) agent backend refuses to boot HERE, not on the
172
172
  // first turn — otherwise /api/health reports healthy while every turn is
173
- // guaranteed to fail. Resolution only; the module still loads lazily.
173
+ // guaranteed to fail. Descriptor loading and profile validation happen here;
174
+ // backend construction and model discovery remain lazy.
174
175
  // Skipped when the embedder injects its own registry.
175
176
  if (!options.registry) assertBackendResolvable(config.agent);
176
177
 
@@ -27,7 +27,7 @@ function isRegularFile(path: string): boolean {
27
27
  }
28
28
  }
29
29
 
30
- export const USAGE = `usage: brain-ui-cron run <job-name> -- <command...>
30
+ export const USAGE = `usage: brain-ui-cron run [--subprocess-env-extra <names>] <job-name> -- <command...>
31
31
  brain-ui-cron digest
32
32
  brain-ui-cron crontab [--wrapper-command <command>] [--digest-command <command>] [--path-line <line>] [--user <user>]
33
33
  brain-ui-cron environment`;
@@ -72,6 +72,24 @@ function crontabOptions(args: string[]): {
72
72
  };
73
73
  }
74
74
 
75
+ function runOptions(args: string[]): {
76
+ jobName: string;
77
+ command: string[];
78
+ subprocessEnvExtraNames: string[];
79
+ } {
80
+ let remaining = args;
81
+ let subprocessEnvExtraNames: string[] = [];
82
+ if (remaining[0] === "--subprocess-env-extra") {
83
+ const value = remaining[1];
84
+ if (value === undefined || value === "") usage();
85
+ subprocessEnvExtraNames = value.split(",");
86
+ remaining = remaining.slice(2);
87
+ }
88
+ const [jobName, separator, ...command] = remaining;
89
+ if (!jobName || separator !== "--" || command.length === 0) usage();
90
+ return { jobName, command, subprocessEnvExtraNames };
91
+ }
92
+
75
93
  async function readModuleList(
76
94
  brainPath: string,
77
95
  env: Record<string, string | undefined>
@@ -104,11 +122,10 @@ async function readModuleList(
104
122
  }
105
123
 
106
124
  const [subcommand, ...args] = process.argv.slice(2);
107
- const config = resolveCronConfig();
108
125
 
109
126
  if (subcommand === "run") {
110
- const [jobName, separator, ...command] = args;
111
- if (!jobName || separator !== "--" || command.length === 0) usage();
127
+ const { jobName, command, subprocessEnvExtraNames } = runOptions(args);
128
+ const config = resolveCronConfig(undefined, subprocessEnvExtraNames);
112
129
  process.exit(
113
130
  await runJob({
114
131
  jobName,
@@ -120,16 +137,22 @@ if (subcommand === "run") {
120
137
  }
121
138
 
122
139
  if (subcommand === "digest" && args.length === 0) {
140
+ const config = resolveCronConfig();
123
141
  process.exit(runDigest({ dbPath: config.dbPath }));
124
142
  }
125
143
 
126
144
  if (subcommand === "crontab") {
145
+ const config = resolveCronConfig();
127
146
  const options = crontabOptions(args);
128
- const modules = await readModuleList(config.brainPath, config.childEnv);
147
+ const modules = await readModuleList(
148
+ config.brainPath,
149
+ config.moduleDiscoveryEnv
150
+ );
129
151
  process.stdout.write(
130
152
  emitCrontab({
131
153
  ...options,
132
154
  modules,
155
+ subprocessEnvExtraNames: config.subprocessEnvExtraNames,
133
156
  legacyScraperPresent: isRegularFile(
134
157
  join(config.brainPath, "scripts", "jobs", "scrape-all.ts")
135
158
  ),
@@ -139,8 +162,11 @@ if (subcommand === "crontab") {
139
162
  }
140
163
 
141
164
  if (subcommand === "environment" && args.length === 0) {
165
+ const config = resolveCronConfig();
142
166
  try {
143
- process.stdout.write(emitEnvironment(config.childEnv));
167
+ process.stdout.write(
168
+ emitEnvironment(config.childEnv, config.subprocessEnvExtraNames)
169
+ );
144
170
  process.exit(0);
145
171
  } catch (error) {
146
172
  const message = error instanceof Error ? error.message : String(error);
@@ -114,7 +114,7 @@ export function probeBrainCliVersion(brainPath: string, log: Logger): void {
114
114
  cwd: brainPath,
115
115
  stdout: "pipe",
116
116
  stderr: "pipe",
117
- env: subprocessEnv({ NO_COLOR: "1" }),
117
+ env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
118
118
  timeout: 5_000,
119
119
  });
120
120
  } catch (error) {
@@ -166,7 +166,7 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
166
166
  stdout: "pipe",
167
167
  stderr: "pipe",
168
168
  // Force JSON output when not a TTY
169
- env: subprocessEnv({ NO_COLOR: "1" }),
169
+ env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
170
170
  });
171
171
 
172
172
  const [stdout, stderr] = await Promise.all([
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:
@@ -435,12 +447,12 @@ export interface AgentConfig {
435
447
  confirmBashPatterns: string[] | null;
436
448
  claudeCodePath: string;
437
449
  defaultModel: string;
438
- /** Raw BRAIN_UI_CLAUDE_PROFILES JSON, parsed lazily by the registry. */
450
+ /** Raw BRAIN_UI_CLAUDE_PROFILES JSON, parsed at boot and again by the registry. */
439
451
  profilesJson: string | null;
440
452
  /**
441
- * Raw BRAIN_UI_PI_PROFILES JSON, parsed by the registry. When set, the pi
442
- * backend runs alongside the Claude backend and these profiles join the
443
- * picker.
453
+ * Raw BRAIN_UI_PI_PROFILES JSON, parsed at boot and again by the registry.
454
+ * When set, the pi backend runs alongside the Claude backend and these
455
+ * profiles join the picker.
444
456
  */
445
457
  piProfilesJson: string | null;
446
458
  modelDiscovery: boolean;
@@ -503,8 +515,12 @@ export interface CronConfig {
503
515
  brainPath: string;
504
516
  /** The deployment database; unlike createApp(), the bin defaults to the container path. */
505
517
  dbPath: string;
506
- /** Exact inherited environment for the scheduled child, before the span sink is added. */
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. */
507
521
  childEnv: EnvRecord;
522
+ /** Valid operator-added names, also used when emitting /etc/environment. */
523
+ subprocessEnvExtraNames: string[];
508
524
  }
509
525
 
510
526
  function list(raw: string | undefined): string[] {
@@ -676,27 +692,63 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
676
692
  }
677
693
 
678
694
  /**
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.
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.
683
698
  */
684
- export function resolveCronConfig(env: EnvRecord = process.env): CronConfig {
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
+ );
685
706
  return {
686
707
  brainPath: env.BRAIN_PATH || "/data/brain",
687
708
  dbPath: env.DB_PATH || "/data/db/brain-ui.db",
688
- childEnv: { ...env },
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,
689
738
  };
690
739
  }
691
740
 
692
741
  /**
693
- * The filtered parent environment for spawned subprocesses (brain CLI,
694
- * whatsup), plus explicit overrides. The shared descriptor strips server-only
695
- * material while retaining all known subprocess capabilities and unknown
696
- * variables. This reads `process.env`, so it lives behind this chokepoint.
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.
697
745
  */
698
- export function subprocessEnv(extra: Record<string, string> = {}): EnvRecord {
699
- 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);
700
752
  }
701
753
 
702
754
  /**
package/src/cron/emit.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ parseSubprocessEnvExtra,
2
3
  SUBPROCESS_ENV,
3
4
  type SubprocessEnvAudience,
4
5
  } from "@schlessera/brain-ui-sdk/server";
@@ -46,6 +47,8 @@ export interface EmitCrontabOptions {
46
47
  user: string;
47
48
  /** Whether `scripts/jobs/scrape-all.ts` exists in the brain repository. */
48
49
  legacyScraperPresent: boolean;
50
+ /** Valid names carried into each wrapper so the run process re-admits them. */
51
+ subprocessEnvExtraNames?: readonly string[];
49
52
  }
50
53
 
51
54
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -92,6 +95,7 @@ export function emitCrontab(options: EmitCrontabOptions): string {
92
95
  pathLine,
93
96
  user,
94
97
  legacyScraperPresent,
98
+ subprocessEnvExtraNames = [],
95
99
  } = options;
96
100
 
97
101
  assertSingleLine("wrapperCommand", wrapperCommand);
@@ -99,15 +103,23 @@ export function emitCrontab(options: EmitCrontabOptions): string {
99
103
  assertSingleLine("pathLine", pathLine);
100
104
  assertSingleLine("user", user);
101
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
+
102
114
  const lines = [
103
115
  "# Generated by entrypoint.sh — regenerated on each container start.",
104
116
  pathLine,
105
117
  "",
106
118
  "# 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`,
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`,
111
123
  "",
112
124
  "# Module jobs (from enabled modules' cron manifests, when exposed)",
113
125
  ];
@@ -123,7 +135,7 @@ export function emitCrontab(options: EmitCrontabOptions): string {
123
135
  if (!validCronEntry(entry)) continue;
124
136
  const jobName = `${module.name}-${entry.name}`;
125
137
  lines.push(
126
- `${entry.schedule} ${user} cd /data/brain && ${wrapperCommand} ${jobName} -- brain ${entry.command} 2>&1 | logger -t brain-${jobName}`
138
+ `${entry.schedule} ${user} cd /data/brain && ${wrapper} ${jobName} -- brain ${entry.command} 2>&1 | logger -t brain-${jobName}`
127
139
  );
128
140
  }
129
141
  }
@@ -140,7 +152,7 @@ export function emitCrontab(options: EmitCrontabOptions): string {
140
152
  // that have not moved their jobs workflow to an enabled module.
141
153
  if (!jobsModuleEnabled && legacyScraperPresent) {
142
154
  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`
155
+ `0 4 * * * ${user} cd /data/brain && ${wrapper} jobs -- bun scripts/jobs/scrape-all.ts --api-only 2>&1 | logger -t brain-jobs`
144
156
  );
145
157
  }
146
158
 
@@ -153,8 +165,8 @@ export function emitCrontab(options: EmitCrontabOptions): string {
153
165
  * every child spawned by the server. Scheduled jobs take a different path:
154
166
  * they historically receive their environment through pam_env without
155
167
  * 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.
168
+ * commands load. The 0.33.1 allowlist keeps that historical behavior while
169
+ * widening the explicitly approved capability settings below.
158
170
  */
159
171
  export const CRON_ENV_EXCLUSIONS: ReadonlySet<string> = new Set(["NODE_ENV"]);
160
172
 
@@ -172,10 +184,21 @@ export const CRON_ENV_EXCLUSIONS: ReadonlySet<string> = new Set(["NODE_ENV"]);
172
184
  const CRON_ENV_ORDER: readonly string[] = [
173
185
  "PATH",
174
186
  "BRAIN_PATH",
187
+ "BRAIN_ROOT",
175
188
  "TZ",
189
+ "XDG_BIN_HOME",
176
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",
177
198
  "GEMINI_API_KEY",
199
+ "GEMINI_BASE_URL",
178
200
  "OPENAI_API_KEY",
201
+ "OPENAI_BASE_URL",
179
202
  "GITHUB_TOKEN",
180
203
  "BRAIN_UI_SYNC_GITHUB_TOKEN",
181
204
  "CLAUDE_CODE_OAUTH_TOKEN",
@@ -213,9 +236,18 @@ export const CRON_ENV_NAMES = Object.freeze(
213
236
  );
214
237
 
215
238
  /** Emit the pam_env input consumed by scheduled jobs. */
216
- export function emitEnvironment(env: Record<string, string | undefined>): string {
239
+ export function emitEnvironment(
240
+ env: Record<string, string | undefined>,
241
+ extraNames: readonly string[] = []
242
+ ): string {
217
243
  const lines: string[] = [];
218
- for (const name of CRON_ENV_NAMES) {
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) {
219
251
  const value = env[name];
220
252
  // Match the shell's `[ -n ]`: both unset and empty values are omitted.
221
253
  if (!value) continue;
@@ -1,5 +1,25 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
2
 
3
+ /**
4
+ * The scheme a browser would put in an `Origin` header for a connection this
5
+ * proxy describes.
6
+ *
7
+ * A browser's Origin is always http/https — never ws/wss, even for a
8
+ * WebSocket. Reverse proxies do not agree on that: several report the
9
+ * *connection* scheme in `X-Forwarded-Proto` and send `ws` / `wss` on an
10
+ * upgrade while sending `https` on ordinary requests. Comparing that verbatim
11
+ * builds an expected origin of `wss://host`, which no browser can ever match,
12
+ * so every WebSocket handshake that falls back to the Origin comparison is
13
+ * refused while HTTP keeps working. Browsers that send `Sec-Fetch-Site` on the
14
+ * handshake never reach this path, which is what made it look like a
15
+ * client-specific failure.
16
+ */
17
+ function webOriginProtocol(forwarded: string): string {
18
+ if (forwarded === "wss") return "https:";
19
+ if (forwarded === "ws") return "http:";
20
+ return `${forwarded}:`;
21
+ }
22
+
3
23
  /**
4
24
  * Compare the browser's Origin with the request's externally visible origin.
5
25
  *
@@ -20,7 +40,7 @@ function originMatchesRequest(
20
40
 
21
41
  const forwarded = c.req.header("x-forwarded-proto")?.split(",", 1)[0]?.trim();
22
42
  const protocol = forwarded
23
- ? `${forwarded.toLowerCase()}:`
43
+ ? webOriginProtocol(forwarded.toLowerCase())
24
44
  : new URL(c.req.url).protocol;
25
45
 
26
46
  return parsedOrigin.origin === new URL(`${protocol}//${host}`).origin;
@@ -117,7 +117,7 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
117
117
  cwd: brainPath,
118
118
  stdout: "pipe",
119
119
  stderr: "pipe",
120
- env: subprocessEnv({ NO_COLOR: "1" }),
120
+ env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
121
121
  });
122
122
 
123
123
  // Start draining stderr NOW, not after the process exits.
@@ -233,7 +233,7 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
233
233
  cwd: brainPath,
234
234
  stdout: "pipe",
235
235
  stderr: "pipe",
236
- env: subprocessEnv({ NO_COLOR: "1" }),
236
+ env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
237
237
  }
238
238
  );
239
239
 
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { Hono } from "hono";
18
18
  import type { AgentConfig } from "../config/env.js";
19
- import { loadBackendModule, parsePiProfiles } from "../agent/backend.js";
19
+ import { loadBackendDescriptor, loadBackendModule } from "../agent/backend.js";
20
20
  import { readJsonBody } from "../middleware/body-limit.js";
21
21
  import { requireJson } from "../middleware/origin.js";
22
22
 
@@ -64,10 +64,15 @@ export function createPiAuthRoutes(deps: PiAuthRoutesDeps): Hono {
64
64
  * Vendors the deployment actually configured — the only providers this
65
65
  * surface may touch. Empty when pi is not in play at all.
66
66
  */
67
- function allowedProviders(): string[] {
68
- const fromProfiles = parsePiProfiles(agent.piProfilesJson, agent.profilesJson).map(
69
- (profile) => profile.vendor
70
- );
67
+ async function allowedProviders(): Promise<string[]> {
68
+ const descriptor = await loadBackendDescriptor("pi", deps.importer);
69
+ const parsed = descriptor.profileSchema.parse(agent.piProfilesJson, {
70
+ occupiedProfiles: [],
71
+ });
72
+ if (!parsed.ok) throw new Error(parsed.errors[0]?.message ?? "Invalid pi profiles.");
73
+ const fromProfiles = parsed.profiles
74
+ .map((profile) => profile.vendor)
75
+ .filter((vendor): vendor is string => typeof vendor === "string");
71
76
  return [...new Set(fromProfiles)];
72
77
  }
73
78
 
@@ -101,7 +106,7 @@ export function createPiAuthRoutes(deps: PiAuthRoutesDeps): Hono {
101
106
  // Not-configured is a normal state, not an error: the client hides the
102
107
  // whole card on an empty list.
103
108
  if (!piConfigured()) return c.json({ providers: [] });
104
- const providers = allowedProviders();
109
+ const providers = await allowedProviders();
105
110
  if (providers.length === 0) return c.json({ providers: [] });
106
111
  const auth = await getAuth();
107
112
  return c.json({ providers: await auth.status(providers) });
@@ -116,7 +121,7 @@ export function createPiAuthRoutes(deps: PiAuthRoutesDeps): Hono {
116
121
  if (!piConfigured()) {
117
122
  return c.json({ error: "The pi backend is not configured." }, 409);
118
123
  }
119
- if (!providerId || !allowedProviders().includes(providerId)) {
124
+ if (!providerId || !(await allowedProviders()).includes(providerId)) {
120
125
  return c.json({ error: "Unknown provider." }, 400);
121
126
  }
122
127
  const auth = await getAuth();
@@ -149,7 +154,7 @@ export function createPiAuthRoutes(deps: PiAuthRoutesDeps): Hono {
149
154
  providerId?: unknown;
150
155
  } | null;
151
156
  const providerId = typeof body?.providerId === "string" ? body.providerId : "";
152
- if (!providerId || !allowedProviders().includes(providerId)) {
157
+ if (!providerId || !(await allowedProviders()).includes(providerId)) {
153
158
  return c.json({ error: "Unknown provider." }, 400);
154
159
  }
155
160
  const auth = await getAuth();
@@ -44,7 +44,7 @@ import {
44
44
  } from "@schlessera/brain-ui-sdk/server";
45
45
  import type { AgentConfig } from "../config/env.js";
46
46
  import { resolveWebSearchEnv } from "../config/env.js";
47
- import { loadBackendModule, parsePiProfiles } from "../agent/backend.js";
47
+ import { loadBackendDescriptor, loadBackendModule } from "../agent/backend.js";
48
48
 
49
49
  /** One toggleable provider, as the Settings UI renders it. */
50
50
  export interface WebSearchProviderView {
@@ -142,17 +142,19 @@ export function createWebSearchRoutes(deps: WebSearchRoutesDeps): Hono {
142
142
  * createApp refuses to boot on it — but a throw would take the whole
143
143
  * settings card down over a label, so it degrades to an empty list.
144
144
  */
145
- const appliesTo = (): string[] => {
145
+ const appliesTo = async (): Promise<string[]> => {
146
146
  try {
147
- return parsePiProfiles(agent.piProfilesJson ?? null, agent.profilesJson ?? null).map(
148
- (p) => p.label
149
- );
147
+ const descriptor = await loadBackendDescriptor("pi", deps.importer);
148
+ const parsed = descriptor.profileSchema.parse(agent.piProfilesJson ?? null, {
149
+ occupiedProfiles: [],
150
+ });
151
+ return parsed.ok ? parsed.profiles.map((profile) => profile.label) : [];
150
152
  } catch {
151
153
  return [];
152
154
  }
153
155
  };
154
156
 
155
- function view(): WebSearchConfigView {
157
+ async function view(): Promise<WebSearchConfigView> {
156
158
  const config = readConfig(configPath());
157
159
  const override = readWebSearchOverride(config);
158
160
  const order = orderByCost(readWebSearchRouting(config));
@@ -161,7 +163,7 @@ export function createWebSearchRoutes(deps: WebSearchRoutesDeps): Hono {
161
163
  configured: true,
162
164
  order,
163
165
  overriddenBy: override,
164
- appliesTo: appliesTo(),
166
+ appliesTo: await appliesTo(),
165
167
  providers: WEB_SEARCH_PROVIDERS.map((p) => ({
166
168
  id: p.id,
167
169
  label: p.label,
@@ -180,13 +182,13 @@ export function createWebSearchRoutes(deps: WebSearchRoutesDeps): Hono {
180
182
  }
181
183
 
182
184
  return new Hono()
183
- .get("/web-search", (c) => {
185
+ .get("/web-search", async (c) => {
184
186
  // Not-configured is a normal state, not an error: the client hides the
185
187
  // whole card (same convention as /pi-auth/providers).
186
188
  if (!piConfigured()) {
187
189
  return c.json({ configured: false, order: [], overriddenBy: null, appliesTo: [], providers: [] });
188
190
  }
189
- return c.json(view());
191
+ return c.json(await view());
190
192
  })
191
193
  .put("/web-search", requireJson(), async (c) => {
192
194
  if (!piConfigured()) {
@@ -317,6 +319,6 @@ export function createWebSearchRoutes(deps: WebSearchRoutesDeps): Hono {
317
319
  /* config written; cache clearing is an optimization */
318
320
  }
319
321
 
320
- return c.json(view());
322
+ return c.json(await view());
321
323
  });
322
324
  }