@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.
- package/README.md +12 -0
- package/dist/agent/backend.d.ts +17 -219
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +212 -471
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +2 -1
- package/dist/app.js.map +1 -1
- package/dist/bin/brain-ui-cron.d.ts +1 -1
- package/dist/bin/brain-ui-cron.d.ts.map +1 -1
- package/dist/bin/brain-ui-cron.js +24 -7
- package/dist/bin/brain-ui-cron.js.map +1 -1
- package/dist/brain/client.js +2 -2
- package/dist/brain/client.js.map +1 -1
- package/dist/config/env.d.ts +18 -15
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +33 -13
- package/dist/config/env.js.map +1 -1
- package/dist/cron/emit.d.ts +5 -3
- package/dist/cron/emit.d.ts.map +1 -1
- package/dist/cron/emit.js +31 -12
- package/dist/cron/emit.js.map +1 -1
- package/dist/middleware/origin.d.ts.map +1 -1
- package/dist/middleware/origin.js +22 -1
- package/dist/middleware/origin.js.map +1 -1
- package/dist/routes/brain.js +2 -2
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/pi-auth.d.ts.map +1 -1
- package/dist/routes/pi-auth.js +14 -6
- package/dist/routes/pi-auth.js.map +1 -1
- package/dist/routes/web-search.d.ts.map +1 -1
- package/dist/routes/web-search.js +12 -8
- package/dist/routes/web-search.js.map +1 -1
- package/package.json +3 -3
- package/src/agent/backend.ts +281 -793
- package/src/app.ts +2 -1
- package/src/bin/brain-ui-cron.ts +32 -6
- package/src/brain/client.ts +2 -2
- package/src/config/env.ts +69 -17
- package/src/cron/emit.ts +42 -10
- package/src/middleware/origin.ts +21 -1
- package/src/routes/brain.ts +2 -2
- package/src/routes/pi-auth.ts +13 -8
- 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.
|
|
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
|
|
package/src/bin/brain-ui-cron.ts
CHANGED
|
@@ -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
|
|
111
|
-
|
|
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(
|
|
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(
|
|
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);
|
package/src/brain/client.ts
CHANGED
|
@@ -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
|
|
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
|
|
442
|
-
* backend runs alongside the Claude backend and these
|
|
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
|
-
/**
|
|
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
|
|
680
|
-
*
|
|
681
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
|
694
|
-
*
|
|
695
|
-
*
|
|
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(
|
|
699
|
-
|
|
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 && ${
|
|
108
|
-
`0 3 * * * ${user} cd /data/brain && ${
|
|
109
|
-
`0 7 * * * ${user} cd /data/brain && ${
|
|
110
|
-
`30 7 * * * ${user} cd /data/brain && ${
|
|
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 && ${
|
|
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 && ${
|
|
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.
|
|
157
|
-
*
|
|
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(
|
|
239
|
+
export function emitEnvironment(
|
|
240
|
+
env: Record<string, string | undefined>,
|
|
241
|
+
extraNames: readonly string[] = []
|
|
242
|
+
): string {
|
|
217
243
|
const lines: string[] = [];
|
|
218
|
-
|
|
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;
|
package/src/middleware/origin.ts
CHANGED
|
@@ -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
|
-
?
|
|
43
|
+
? webOriginProtocol(forwarded.toLowerCase())
|
|
24
44
|
: new URL(c.req.url).protocol;
|
|
25
45
|
|
|
26
46
|
return parsedOrigin.origin === new URL(`${protocol}//${host}`).origin;
|
package/src/routes/brain.ts
CHANGED
|
@@ -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
|
|
package/src/routes/pi-auth.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { Hono } from "hono";
|
|
18
18
|
import type { AgentConfig } from "../config/env.js";
|
|
19
|
-
import {
|
|
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
|
|
69
|
-
|
|
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();
|
package/src/routes/web-search.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
148
|
-
|
|
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
|
}
|