@sigrun/sigrun 0.0.0-stage → 0.1.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/CHANGELOG.md +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
import { isIanaTimeZone } from "./jobs/spec.js";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { resolveStoreRoots } from "./harness/stores.js";
|
|
4
|
+
import { isLoopbackHost } from "./server/auth/loopback.js";
|
|
5
|
+
/**
|
|
6
|
+
* Sigrun configuration.
|
|
7
|
+
*
|
|
8
|
+
* Sigrun resolves configuration from environment variables over
|
|
9
|
+
* `settings.json` over code defaults (env wins per setting). The three
|
|
10
|
+
* knobs of the spec's design test are `SIGRUN_DEFAULT_MODEL`,
|
|
11
|
+
* `OPENAI_BASE_URL`, and `OPENAI_API_KEY` — but only the model is read
|
|
12
|
+
* here: the OpenAI-compat credentials are consumed by the env-driven
|
|
13
|
+
* degenerate provider (`src/harness/providers`), which may be replaced
|
|
14
|
+
* entirely by an extension-contributed provider that needs no OpenAI
|
|
15
|
+
* credentials at all. `SIGRUN_MODEL` remains as a deprecated alias for
|
|
16
|
+
* `SIGRUN_DEFAULT_MODEL` (one release cycle; a boot warning when used).
|
|
17
|
+
*/
|
|
18
|
+
/** The env var carrying the boot default model spec. */
|
|
19
|
+
export const DEFAULT_MODEL_ENV = "SIGRUN_DEFAULT_MODEL";
|
|
20
|
+
/** Deprecated alias for {@link DEFAULT_MODEL_ENV} (warns once per boot). */
|
|
21
|
+
export const DEPRECATED_MODEL_ENV = "SIGRUN_MODEL";
|
|
22
|
+
/** The provider id used when the model spec carries no `provider/` prefix. */
|
|
23
|
+
export const DEFAULT_PROVIDER = "openai";
|
|
24
|
+
/**
|
|
25
|
+
* The SQLite DB file name under the resolved data root (the file itself
|
|
26
|
+
* is `<dataDir>/<this>`; the old cwd-relative `"data/sigrun.db"` default
|
|
27
|
+
* is RETIRED — the clean break, no migration machinery: only test DBs
|
|
28
|
+
* ever lived at the old default, and chart/compose deploys pin absolute
|
|
29
|
+
* `SIGRUN_STORAGE_PATH` values that stay verbatim).
|
|
30
|
+
*/
|
|
31
|
+
export const STORAGE_FILE_NAME = "sigrun.db";
|
|
32
|
+
export const DEFAULT_PORT = 3000;
|
|
33
|
+
/** Spec §4.1/§6: the built webui workspace package's output. */
|
|
34
|
+
export const DEFAULT_WEBUI_BUNDLE = "webui/dist";
|
|
35
|
+
/**
|
|
36
|
+
* The workspace default (spec §6.3/R5): `<dataDir>/workspace` — the
|
|
37
|
+
* static workspace rides the SAME root chain as every other durable
|
|
38
|
+
* path instead of the old fixed `/home/sigrun` (a container-image
|
|
39
|
+
* assumption that leaked into every non-container boot). Chart and
|
|
40
|
+
* compose deploys set `SIGRUN_WORKSPACE_PATH` explicitly and are
|
|
41
|
+
* unaffected. Used only as the LAST tier of the workspace resolution
|
|
42
|
+
* (`SIGRUN_WORKSPACE_PATH` over `server.workspacePath` over this) — a
|
|
43
|
+
* default that is never persisted (spec §6.6).
|
|
44
|
+
*/
|
|
45
|
+
export function defaultWorkspacePath(dataDir) {
|
|
46
|
+
return join(dataDir, "workspace");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Sigrun's compaction defaults, deliberately equal to pi-durable's
|
|
50
|
+
* `DEFAULT_COMPACTION_POLICY`. Restated here rather than imported so the
|
|
51
|
+
* equality is a checked decision (`test/compaction.test.ts` fails loudly if
|
|
52
|
+
* upstream retunes and sigrun silently follows).
|
|
53
|
+
*
|
|
54
|
+
* These values presume a large-context default model (spec §3.1: "tuned
|
|
55
|
+
* against the default model's window"); a small-window `SIGRUN_MODEL`
|
|
56
|
+
* deployment must retune via `SIGRUN_COMPACTION_*`.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_COMPACTION = {
|
|
59
|
+
enabled: true,
|
|
60
|
+
reserveTokens: 16384,
|
|
61
|
+
keepRecentTokens: 20000,
|
|
62
|
+
backgroundTokens: 32768,
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* The memory-seam defaults (spec §2/§3): 30s hook deadline, 10-minute
|
|
66
|
+
* idle threshold, 10-minute worker deadline, 1000-event queue bound.
|
|
67
|
+
*/
|
|
68
|
+
export const DEFAULT_MEMORY_SEAM = {
|
|
69
|
+
hookDeadlineMs: 30_000,
|
|
70
|
+
idleThresholdMs: 600_000,
|
|
71
|
+
workerDeadlineMs: 600_000,
|
|
72
|
+
queueMax: 1_000,
|
|
73
|
+
};
|
|
74
|
+
/** The env var carrying the interval refresh cadence (Task 5). */
|
|
75
|
+
export const PROVIDER_REFRESH_MINUTES_ENV = "SIGRUN_PROVIDER_REFRESH_MINUTES";
|
|
76
|
+
/** The env var carrying the upload body-limit knob (spec §6.2). */
|
|
77
|
+
export const MAX_UPLOAD_BYTES_ENV = "SIGRUN_MAX_UPLOAD_BYTES";
|
|
78
|
+
/** The env var carrying the deployment's absolute base URL (W5 Task 2). */
|
|
79
|
+
export const PUBLIC_URL_ENV = "SIGRUN_PUBLIC_URL";
|
|
80
|
+
/**
|
|
81
|
+
* Whether `value` parses as an ABSOLUTE http(s) URL with a hostname
|
|
82
|
+
* (the `server.publicUrl` grammar, W5 Task 2) — the shared predicate
|
|
83
|
+
* behind {@link parsePublicUrl} and the settings-file validation
|
|
84
|
+
* (src/harness/settings.ts), so both gates accept the same shapes.
|
|
85
|
+
*/
|
|
86
|
+
export function isAbsoluteHttpUrl(value) {
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = new URL(value);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return ((parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
|
95
|
+
parsed.hostname !== "");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse one `server.publicUrl` / `SIGRUN_PUBLIC_URL` value (W5 Task 2,
|
|
99
|
+
* spec §9.2): an absolute http(s) URL — validated by
|
|
100
|
+
* {@link isAbsoluteHttpUrl} — with any TRAILING SLASHES stripped (the
|
|
101
|
+
* value consumers concatenate paths onto,
|
|
102
|
+
* `${server.publicUrl}/v1/ext/<slug>/…`). Unset/empty → `undefined`;
|
|
103
|
+
* anything else fails loud naming `source`.
|
|
104
|
+
*/
|
|
105
|
+
export function parsePublicUrl(raw, source) {
|
|
106
|
+
const trimmed = raw?.trim() ?? "";
|
|
107
|
+
if (trimmed === "")
|
|
108
|
+
return undefined;
|
|
109
|
+
if (!isAbsoluteHttpUrl(trimmed)) {
|
|
110
|
+
throw new Error(`${source} must be an absolute http(s) URL, got ${JSON.stringify(raw)}`);
|
|
111
|
+
}
|
|
112
|
+
return trimmed.replace(/\/+$/, "");
|
|
113
|
+
}
|
|
114
|
+
/** The upload knob's default (spec §6.2): 64 MiB. */
|
|
115
|
+
export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
|
116
|
+
/** The env var carrying the raw file-read cap (spec §7.8). */
|
|
117
|
+
export const MAX_FILE_READ_BYTES_ENV = "SIGRUN_MAX_FILE_READ_BYTES";
|
|
118
|
+
/** The raw file-read cap's default (spec §7.8): 16 MiB. */
|
|
119
|
+
export const DEFAULT_MAX_FILE_READ_BYTES = 16 * 1024 * 1024;
|
|
120
|
+
/**
|
|
121
|
+
* Parse `SIGRUN_MAX_FILE_READ_BYTES` (spec §7.8) — the strict
|
|
122
|
+
* positive-integer pattern (the `SIGRUN_MAX_UPLOAD_BYTES` posture): `""`
|
|
123
|
+
* counts as unset, anything non-integer or ≤ 0 fails loud naming the
|
|
124
|
+
* variable. The file's `server.maxFileReadBytes` applies where the env
|
|
125
|
+
* var is absent; the 16 MiB default last.
|
|
126
|
+
*/
|
|
127
|
+
function parseMaxFileReadBytes(env, file) {
|
|
128
|
+
return (parsePositiveIntEnv(env, MAX_FILE_READ_BYTES_ENV) ??
|
|
129
|
+
file ??
|
|
130
|
+
DEFAULT_MAX_FILE_READ_BYTES);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The body limit the base64 POST upload and the JSONL import routes use
|
|
134
|
+
* (spec §6.2, verbatim): `Math.ceil(max*4/3)+4096` — the base64 inflation
|
|
135
|
+
* of the knob-capped content plus headroom for the JSON wrapper (the
|
|
136
|
+
* POST body) or the JSONL text itself (import).
|
|
137
|
+
*/
|
|
138
|
+
export function base64BodyLimit(maxUploadBytes) {
|
|
139
|
+
return Math.ceil((maxUploadBytes * 4) / 3) + 4096;
|
|
140
|
+
}
|
|
141
|
+
/** The interval refresh cadence default (minutes); `0` disables. */
|
|
142
|
+
export const DEFAULT_PROVIDER_REFRESH_MINUTES = 15;
|
|
143
|
+
/**
|
|
144
|
+
* The auth defaults (spec §5.2/§5.3 + the plan's Global Constraints):
|
|
145
|
+
* no explicit method list (derive from the listen host), no trusted
|
|
146
|
+
* proxies, Authelia's `Remote-*` header names, auto-provision on, no
|
|
147
|
+
* admin groups. Credentials (`proxy.secret`, `serviceToken`) are never
|
|
148
|
+
* defaulted — they exist only when env provides them.
|
|
149
|
+
*/
|
|
150
|
+
export const DEFAULT_AUTH = {
|
|
151
|
+
methods: undefined,
|
|
152
|
+
proxy: {
|
|
153
|
+
trustedProxies: [],
|
|
154
|
+
userHeader: "Remote-User",
|
|
155
|
+
groupsHeader: "Remote-Groups",
|
|
156
|
+
nameHeader: "Remote-Name",
|
|
157
|
+
emailHeader: "Remote-Email",
|
|
158
|
+
autoProvision: true,
|
|
159
|
+
},
|
|
160
|
+
adminGroups: [],
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* The effective authenticator list (spec §5.2's defaults ruling): an
|
|
164
|
+
* explicit `methods` always wins (including `[]` on a non-loopback
|
|
165
|
+
* bind — the config override); otherwise `[]` on a loopback listen
|
|
166
|
+
* host and `["token"]` on any other. The Task 3 pipeline calls this
|
|
167
|
+
* once at boot with the bound host.
|
|
168
|
+
*/
|
|
169
|
+
export function effectiveMethods(auth, listenHost) {
|
|
170
|
+
if (auth.methods !== undefined)
|
|
171
|
+
return auth.methods;
|
|
172
|
+
return isLoopbackHost(listenHost) ? [] : ["token"];
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The backup honesty window's default (§6.8): how old the
|
|
176
|
+
* `backup/last-success.json` marker may be, in hours, before a CONFIGURED
|
|
177
|
+
* backup target reports `health.backup: "degraded"`. File-only knob
|
|
178
|
+
* (`server.backup.staleAfterHours`) — no env var exists — over this
|
|
179
|
+
* default. 36 h tolerates a nightly Cadence missing one run before the
|
|
180
|
+
* badge flips.
|
|
181
|
+
*/
|
|
182
|
+
export const DEFAULT_BACKUP_STALE_AFTER_HOURS = 36;
|
|
183
|
+
/**
|
|
184
|
+
* Parse one model spec into `{ provider, modelId }`.
|
|
185
|
+
*
|
|
186
|
+
* - `provider/model-id` pins the provider explicitly (first `/` splits;
|
|
187
|
+
* the model id may itself contain further path segments), or
|
|
188
|
+
* - a bare `model-id` uses the env-driven OpenAI-compat provider
|
|
189
|
+
* (`openai`, the degenerate provider of spec §3).
|
|
190
|
+
*
|
|
191
|
+
* Shared by `loadConfig` and the provider collector so both parse
|
|
192
|
+
* identically.
|
|
193
|
+
*
|
|
194
|
+
* @throws Error naming `source` (the env var or the file key) when the
|
|
195
|
+
* spec is empty or has an empty provider or model id.
|
|
196
|
+
*/
|
|
197
|
+
export function parseModelSpec(spec, source = DEFAULT_MODEL_ENV) {
|
|
198
|
+
const trimmed = spec.trim();
|
|
199
|
+
if (!trimmed) {
|
|
200
|
+
throw new Error(`${source} must be a non-empty model spec, e.g. openai/gpt-4o`);
|
|
201
|
+
}
|
|
202
|
+
const slash = trimmed.indexOf("/");
|
|
203
|
+
if (slash === -1) {
|
|
204
|
+
return { provider: DEFAULT_PROVIDER, modelId: trimmed };
|
|
205
|
+
}
|
|
206
|
+
const provider = trimmed.slice(0, slash);
|
|
207
|
+
const modelId = trimmed.slice(slash + 1);
|
|
208
|
+
if (!provider || !modelId) {
|
|
209
|
+
throw new Error(`${source} must have a non-empty provider and model id, got ${JSON.stringify(trimmed)}`);
|
|
210
|
+
}
|
|
211
|
+
return { provider, modelId };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Parse sigrun configuration from an environment (usually `process.env`),
|
|
215
|
+
* over the settings file, over code defaults (env wins per setting).
|
|
216
|
+
*
|
|
217
|
+
* The default model is REQUIRED: `SIGRUN_DEFAULT_MODEL` (deprecated alias
|
|
218
|
+
* `SIGRUN_MODEL`) wins, then the file's top-level `defaultModel` — with
|
|
219
|
+
* neither, boot fails loud (see {@link parseModelSpec} for the format).
|
|
220
|
+
* Optional overrides: `SIGRUN_STORAGE_PATH`, `SIGRUN_EXTENSIONS`,
|
|
221
|
+
* `SIGRUN_PORT`, `SIGRUN_WORKSPACE_PATH`,
|
|
222
|
+
* `SIGRUN_WEBUI_BUNDLE`, `OPENAI_BASE_URL`, the `SIGRUN_COMPACTION_*`
|
|
223
|
+
* knobs of the context-management policy, and the backup target variables
|
|
224
|
+
* (`SIGRUN_BACKUP_PATH` or `SIGRUN_BACKUP_S3_BUCKET`+
|
|
225
|
+
* `SIGRUN_BACKUP_S3_PREFIX`; see {@link parseBackupConfig}).
|
|
226
|
+
*
|
|
227
|
+
* @throws Error naming `SIGRUN_DEFAULT_MODEL` when the model is missing
|
|
228
|
+
* from both env and file, naming `SIGRUN_PORT` when it is set but not a
|
|
229
|
+
* positive integer, naming the offending `SIGRUN_COMPACTION_*` variable
|
|
230
|
+
* when set to an invalid value, and naming both backup variables when
|
|
231
|
+
* they are both set (ambiguous backup target).
|
|
232
|
+
*/
|
|
233
|
+
export function parseExtensionList(raw) {
|
|
234
|
+
if (raw === undefined)
|
|
235
|
+
return undefined;
|
|
236
|
+
return raw
|
|
237
|
+
.split(",")
|
|
238
|
+
.map((entry) => entry.trim())
|
|
239
|
+
.filter((entry) => entry !== "");
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Warn once per process when the deprecated `SIGRUN_MODEL` alias supplied
|
|
243
|
+
* the model. Module-level so repeated `loadConfig` calls in one boot (the
|
|
244
|
+
* deploy entry resolves twice: bootstrap + final) print the warning once.
|
|
245
|
+
*/
|
|
246
|
+
let deprecatedModelWarned = false;
|
|
247
|
+
function warnDeprecatedModelEnv() {
|
|
248
|
+
if (deprecatedModelWarned)
|
|
249
|
+
return;
|
|
250
|
+
deprecatedModelWarned = true;
|
|
251
|
+
console.warn("SIGRUN_MODEL is deprecated, use SIGRUN_DEFAULT_MODEL");
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Warn once per process when the retired `SIGRUN_EXTENSIONS_DIR` env var
|
|
255
|
+
* is set (§6.7: the knob was parsed but never read — the config carries no
|
|
256
|
+
* `extensionsDir` field at all now). Same once-per-process guard as the
|
|
257
|
+
* `SIGRUN_MODEL` alias.
|
|
258
|
+
*/
|
|
259
|
+
let extensionsDirWarned = false;
|
|
260
|
+
function warnDeprecatedExtensionsDirEnv() {
|
|
261
|
+
if (extensionsDirWarned)
|
|
262
|
+
return;
|
|
263
|
+
extensionsDirWarned = true;
|
|
264
|
+
console.warn("SIGRUN_EXTENSIONS_DIR is deprecated and ignored");
|
|
265
|
+
}
|
|
266
|
+
/** Parse `SIGRUN_COMPACTION_ENABLED` ("true"/"false", case-insensitive). */
|
|
267
|
+
function parseCompactionEnabled(env) {
|
|
268
|
+
const raw = env.SIGRUN_COMPACTION_ENABLED;
|
|
269
|
+
if (raw === undefined || raw === "")
|
|
270
|
+
return undefined;
|
|
271
|
+
const lowered = raw.trim().toLowerCase();
|
|
272
|
+
if (lowered === "true")
|
|
273
|
+
return true;
|
|
274
|
+
if (lowered === "false")
|
|
275
|
+
return false;
|
|
276
|
+
throw new Error(`SIGRUN_COMPACTION_ENABLED must be "true" or "false", got ${JSON.stringify(raw)}`);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Parse `SIGRUN_WEBUI` ("true"/"false", case-insensitive) — the webui
|
|
280
|
+
* channel's enable gate, the `parseCompactionEnabled` pattern (strict:
|
|
281
|
+
* empty counts as unset, anything else fails loud naming the variable).
|
|
282
|
+
*/
|
|
283
|
+
function parseWebuiEnabled(env) {
|
|
284
|
+
const raw = env.SIGRUN_WEBUI;
|
|
285
|
+
if (raw === undefined || raw === "")
|
|
286
|
+
return undefined;
|
|
287
|
+
const lowered = raw.trim().toLowerCase();
|
|
288
|
+
if (lowered === "true")
|
|
289
|
+
return true;
|
|
290
|
+
if (lowered === "false")
|
|
291
|
+
return false;
|
|
292
|
+
throw new Error(`SIGRUN_WEBUI must be "true" or "false", got ${JSON.stringify(raw)}`);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Parse `SIGRUN_METRICS_PUBLIC` ("true"/"false", case-insensitive) — the
|
|
296
|
+
* `parseWebuiEnabled` pattern: strict, empty counts as unset, anything
|
|
297
|
+
* else fails loud naming the variable.
|
|
298
|
+
*/
|
|
299
|
+
function parseMetricsPublic(env) {
|
|
300
|
+
const raw = env.SIGRUN_METRICS_PUBLIC;
|
|
301
|
+
if (raw === undefined || raw === "")
|
|
302
|
+
return undefined;
|
|
303
|
+
const lowered = raw.trim().toLowerCase();
|
|
304
|
+
if (lowered === "true")
|
|
305
|
+
return true;
|
|
306
|
+
if (lowered === "false")
|
|
307
|
+
return false;
|
|
308
|
+
throw new Error(`SIGRUN_METRICS_PUBLIC must be "true" or "false", got ${JSON.stringify(raw)}`);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Parse `SIGRUN_AUTH`: a csv of `token`/`proxy` (order preserved,
|
|
312
|
+
* duplicates dropped), `none` ALONE as the empty list (authentication
|
|
313
|
+
* disabled), unset/empty as `undefined` (derive from the listen host).
|
|
314
|
+
* Anything else fails loud naming the variable and the raw value — the
|
|
315
|
+
* `parseCompactionEnabled` pattern.
|
|
316
|
+
*/
|
|
317
|
+
function parseAuthMethods(env) {
|
|
318
|
+
const raw = env.SIGRUN_AUTH;
|
|
319
|
+
if (raw === undefined || raw === "")
|
|
320
|
+
return undefined;
|
|
321
|
+
const methods = [];
|
|
322
|
+
let sawNone = false;
|
|
323
|
+
for (const entry of raw.split(",")) {
|
|
324
|
+
const name = entry.trim();
|
|
325
|
+
if (name === "")
|
|
326
|
+
continue;
|
|
327
|
+
if (name === "none") {
|
|
328
|
+
if (sawNone || methods.length > 0) {
|
|
329
|
+
throw new Error(`SIGRUN_AUTH "none" must appear alone, got ${JSON.stringify(raw)}`);
|
|
330
|
+
}
|
|
331
|
+
sawNone = true;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (name !== "token" && name !== "proxy") {
|
|
335
|
+
throw new Error(`SIGRUN_AUTH must be a comma-separated list of "token" and/or "proxy" (or "none" alone), got ${JSON.stringify(raw)}`);
|
|
336
|
+
}
|
|
337
|
+
if (sawNone) {
|
|
338
|
+
throw new Error(`SIGRUN_AUTH "none" must appear alone, got ${JSON.stringify(raw)}`);
|
|
339
|
+
}
|
|
340
|
+
if (!methods.includes(name))
|
|
341
|
+
methods.push(name);
|
|
342
|
+
}
|
|
343
|
+
if (sawNone)
|
|
344
|
+
return [];
|
|
345
|
+
// Only commas/whitespace: unset, not an empty method list (`none` is
|
|
346
|
+
// the positive spelling for that).
|
|
347
|
+
return methods.length > 0 ? methods : undefined;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Parse `SIGRUN_AUTH_TRUSTED_PROXIES`: a csv of CIDR/IP strings. Unset or
|
|
351
|
+
* empty is `undefined` (the file's `server.auth.proxy.trustedProxies`
|
|
352
|
+
* applies then); comma-only degenerate input parses as the explicit
|
|
353
|
+
* empty list (which fails the `proxy` cross-check below, never
|
|
354
|
+
* "trust everyone").
|
|
355
|
+
*/
|
|
356
|
+
function parseTrustedProxies(env) {
|
|
357
|
+
const raw = env.SIGRUN_AUTH_TRUSTED_PROXIES;
|
|
358
|
+
if (raw === undefined || raw === "")
|
|
359
|
+
return undefined;
|
|
360
|
+
return raw
|
|
361
|
+
.split(",")
|
|
362
|
+
.map((entry) => entry.trim())
|
|
363
|
+
.filter((entry) => entry !== "");
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Resolve {@link AuthConfig}: env over the file's `server.auth` section
|
|
367
|
+
* per field. The env-only credentials (`SIGRUN_PROXY_SECRET` →
|
|
368
|
+
* `proxy.secret`, `SIGRUN_API_TOKEN` → `serviceToken`) have NO file
|
|
369
|
+
* fallback (validation rejects a file `secret` key; tokens never lived
|
|
370
|
+
* in the settings file). Fails loud when `proxy` is a method but no
|
|
371
|
+
* trusted range is configured — an unscoped proxy method would trust
|
|
372
|
+
* everyone (spec §5.3's boot error).
|
|
373
|
+
*/
|
|
374
|
+
function parseAuth(env, server) {
|
|
375
|
+
const methods = parseAuthMethods(env) ?? server?.methods;
|
|
376
|
+
const trustedProxies = parseTrustedProxies(env) ?? server?.proxy?.trustedProxies ?? [];
|
|
377
|
+
if (methods?.includes("proxy") && trustedProxies.length === 0) {
|
|
378
|
+
throw new Error('server.auth methods include "proxy" but trustedProxies is empty — ' +
|
|
379
|
+
"set SIGRUN_AUTH_TRUSTED_PROXIES or server.auth.proxy.trustedProxies " +
|
|
380
|
+
'(a proxy method with no trusted range would trust everyone)');
|
|
381
|
+
}
|
|
382
|
+
const token = env.SIGRUN_API_TOKEN?.trim() || undefined;
|
|
383
|
+
const secret = env.SIGRUN_PROXY_SECRET?.trim() || undefined;
|
|
384
|
+
return {
|
|
385
|
+
methods,
|
|
386
|
+
proxy: {
|
|
387
|
+
trustedProxies,
|
|
388
|
+
...(server?.proxy?.secretHeader === undefined
|
|
389
|
+
? {}
|
|
390
|
+
: { secretHeader: server.proxy.secretHeader }),
|
|
391
|
+
...(secret === undefined ? {} : { secret }),
|
|
392
|
+
userHeader: server?.proxy?.userHeader ?? DEFAULT_AUTH.proxy.userHeader,
|
|
393
|
+
groupsHeader: server?.proxy?.groupsHeader ?? DEFAULT_AUTH.proxy.groupsHeader,
|
|
394
|
+
nameHeader: server?.proxy?.nameHeader ?? DEFAULT_AUTH.proxy.nameHeader,
|
|
395
|
+
emailHeader: server?.proxy?.emailHeader ?? DEFAULT_AUTH.proxy.emailHeader,
|
|
396
|
+
autoProvision: server?.proxy?.autoProvision ?? true,
|
|
397
|
+
},
|
|
398
|
+
adminGroups: server?.adminGroups ?? [],
|
|
399
|
+
...(server?.allowedHosts === undefined
|
|
400
|
+
? {}
|
|
401
|
+
: { allowedHosts: server.allowedHosts }),
|
|
402
|
+
...(server?.allowedOrigins === undefined
|
|
403
|
+
? {}
|
|
404
|
+
: { allowedOrigins: server.allowedOrigins }),
|
|
405
|
+
...(token === undefined
|
|
406
|
+
? {}
|
|
407
|
+
: {
|
|
408
|
+
serviceToken: {
|
|
409
|
+
token,
|
|
410
|
+
user: env.SIGRUN_API_TOKEN_USER?.trim() || "sigrun",
|
|
411
|
+
},
|
|
412
|
+
}),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
* Parse one `SIGRUN_COMPACTION_*` token count. `minimum` is 1 for all knobs
|
|
419
|
+
* except `backgroundTokens`, where 0 is the designed "background off" value
|
|
420
|
+
* (upstream starts a background compaction only when `backgroundTokens > 0`).
|
|
421
|
+
*/
|
|
422
|
+
function parseTokenCount(env, key, minimum) {
|
|
423
|
+
const raw = env[key];
|
|
424
|
+
if (raw === undefined || raw === "")
|
|
425
|
+
return undefined;
|
|
426
|
+
const parsed = Number(raw);
|
|
427
|
+
if (!Number.isInteger(parsed) || parsed < minimum) {
|
|
428
|
+
const constraint = minimum === 0
|
|
429
|
+
? "a non-negative integer"
|
|
430
|
+
: "a positive integer";
|
|
431
|
+
throw new Error(`${key} must be ${constraint}, got ${raw}`);
|
|
432
|
+
}
|
|
433
|
+
return parsed;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Parse the optional backup target (spec §4.3, Task 11).
|
|
437
|
+
*
|
|
438
|
+
* - `SIGRUN_BACKUP_PATH=<dir>` → `{ target: "path", path }`
|
|
439
|
+
* - `SIGRUN_BACKUP_S3_BUCKET=<bucket>` (+ optional
|
|
440
|
+
* `SIGRUN_BACKUP_S3_PREFIX`) → `{ target: "s3", bucket, prefix? }`
|
|
441
|
+
* - both set → throw (ambiguous target — a CronJob would otherwise ship
|
|
442
|
+
* backups to one target while the badge says the other)
|
|
443
|
+
* - neither → `undefined` (backup unconfigured; a badge, not an error)
|
|
444
|
+
*
|
|
445
|
+
* Empty strings count as unset ("" is the shell's unset-adjacent value —
|
|
446
|
+
* a manifest carrying `SIGRUN_BACKUP_PATH: ""` should boot unconfigured
|
|
447
|
+
* the same as omitting the key, not crash).
|
|
448
|
+
*/
|
|
449
|
+
export function parseBackupConfig(env) {
|
|
450
|
+
const path = env.SIGRUN_BACKUP_PATH?.trim() || undefined;
|
|
451
|
+
const bucket = env.SIGRUN_BACKUP_S3_BUCKET?.trim() || undefined;
|
|
452
|
+
const prefix = env.SIGRUN_BACKUP_S3_PREFIX?.trim() || undefined;
|
|
453
|
+
const endpoint = env.SIGRUN_BACKUP_S3_ENDPOINT?.trim() || undefined;
|
|
454
|
+
const sections = parseBackupSections(env.SIGRUN_BACKUP_SECTIONS);
|
|
455
|
+
if (path !== undefined && bucket !== undefined) {
|
|
456
|
+
throw new Error("SIGRUN_BACKUP_PATH and SIGRUN_BACKUP_S3_BUCKET are both set — exactly one backup target is allowed");
|
|
457
|
+
}
|
|
458
|
+
if (path !== undefined) {
|
|
459
|
+
return {
|
|
460
|
+
target: "path",
|
|
461
|
+
path,
|
|
462
|
+
...(sections === undefined ? {} : { sections }),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
if (bucket !== undefined) {
|
|
466
|
+
return {
|
|
467
|
+
target: "s3",
|
|
468
|
+
bucket,
|
|
469
|
+
...(prefix === undefined ? {} : { prefix }),
|
|
470
|
+
...(endpoint === undefined ? {} : { endpoint }),
|
|
471
|
+
...(sections === undefined ? {} : { sections }),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Parse `SIGRUN_BACKUP_SECTIONS` (Task 7): a comma-separated list of
|
|
478
|
+
* `config|secrets|data|state`. Absent/empty → `undefined` (Ruling F:
|
|
479
|
+
* the BackupConfig carries NO `sections` key at all — the DB-only shape
|
|
480
|
+
* stays byte-identical and absent means all-false). An unknown name
|
|
481
|
+
* (`cache` is NEVER valid — it must never be backed up) throws naming
|
|
482
|
+
* the offending token. Duplicates dedup (`config,config` == `config`).
|
|
483
|
+
*/
|
|
484
|
+
export function parseBackupSections(raw) {
|
|
485
|
+
if (raw === undefined || raw.trim() === "")
|
|
486
|
+
return undefined;
|
|
487
|
+
const sections = {
|
|
488
|
+
config: false,
|
|
489
|
+
secrets: false,
|
|
490
|
+
data: false,
|
|
491
|
+
state: false,
|
|
492
|
+
};
|
|
493
|
+
for (const token of raw.split(",")) {
|
|
494
|
+
const name = token.trim();
|
|
495
|
+
if (name === "")
|
|
496
|
+
continue;
|
|
497
|
+
if (!(name in sections)) {
|
|
498
|
+
throw new Error(`SIGRUN_BACKUP_SECTIONS: unknown backup section "${name}" — valid sections are config,secrets,data,state (cache is never backed up)`);
|
|
499
|
+
}
|
|
500
|
+
sections[name] = true;
|
|
501
|
+
}
|
|
502
|
+
return sections;
|
|
503
|
+
}
|
|
504
|
+
function parseCompaction(env, server) {
|
|
505
|
+
// Env parse first (a bad env value fails loud even when the file's
|
|
506
|
+
// value is valid); a file field applies only where its env var is
|
|
507
|
+
// absent; code defaults last.
|
|
508
|
+
return {
|
|
509
|
+
enabled: parseCompactionEnabled(env) ??
|
|
510
|
+
server?.enabled ??
|
|
511
|
+
DEFAULT_COMPACTION.enabled,
|
|
512
|
+
reserveTokens: parseTokenCount(env, "SIGRUN_COMPACTION_RESERVE_TOKENS", 1) ??
|
|
513
|
+
server?.reserveTokens ??
|
|
514
|
+
DEFAULT_COMPACTION.reserveTokens,
|
|
515
|
+
keepRecentTokens: parseTokenCount(env, "SIGRUN_COMPACTION_KEEP_RECENT_TOKENS", 1) ??
|
|
516
|
+
server?.keepRecentTokens ??
|
|
517
|
+
DEFAULT_COMPACTION.keepRecentTokens,
|
|
518
|
+
backgroundTokens: parseTokenCount(env, "SIGRUN_COMPACTION_BACKGROUND_TOKENS", 0) ??
|
|
519
|
+
server?.backgroundTokens ??
|
|
520
|
+
DEFAULT_COMPACTION.backgroundTokens,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Parse one memory-seam positive-integer knob (the
|
|
525
|
+
* `SIGRUN_COMPACTION_*` token-count pattern: fail loud naming the
|
|
526
|
+
* variable on a non-numeric/zero/negative value, treat `""` as unset).
|
|
527
|
+
*/
|
|
528
|
+
function parseMemorySeamCount(env, key) {
|
|
529
|
+
const raw = env[key];
|
|
530
|
+
if (raw === undefined || raw === "")
|
|
531
|
+
return undefined;
|
|
532
|
+
const parsed = Number(raw);
|
|
533
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
534
|
+
throw new Error(`${key} must be a positive integer, got ${raw}`);
|
|
535
|
+
}
|
|
536
|
+
return parsed;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Parse one STRICT positive-integer env knob (the `SIGRUN_PORT`
|
|
540
|
+
* posture): `""` counts as unset, anything non-integer or ≤ 0 fails
|
|
541
|
+
* loud naming the variable. Shared by `loadConfig` and
|
|
542
|
+
* {@link envServerSection} so both parse identically.
|
|
543
|
+
*/
|
|
544
|
+
function parsePositiveIntEnv(env, key) {
|
|
545
|
+
const raw = env[key];
|
|
546
|
+
if (raw === undefined || raw === "")
|
|
547
|
+
return undefined;
|
|
548
|
+
const parsed = Number(raw);
|
|
549
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
550
|
+
throw new Error(`${key} must be a positive integer, got ${raw}`);
|
|
551
|
+
}
|
|
552
|
+
return parsed;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Parse one STRICT non-negative-integer env knob (0 is a designed
|
|
556
|
+
* value): the {@link parsePositiveIntEnv} posture with the floor at 0.
|
|
557
|
+
*/
|
|
558
|
+
function parseNonNegativeIntEnv(env, key) {
|
|
559
|
+
const raw = env[key];
|
|
560
|
+
if (raw === undefined || raw === "")
|
|
561
|
+
return undefined;
|
|
562
|
+
const parsed = Number(raw);
|
|
563
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
564
|
+
throw new Error(`${key} must be a non-negative integer, got ${raw}`);
|
|
565
|
+
}
|
|
566
|
+
return parsed;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Parse the interval refresh cadence (Task 5): `SIGRUN_PROVIDER_REFRESH_MINUTES`
|
|
570
|
+
* — a NON-NEGATIVE integer (0 is the designed disable, unlike the
|
|
571
|
+
* memory-seam positive-only knobs), `""` counts as unset, anything else
|
|
572
|
+
* fails loud naming the variable. The file's `server.providers`
|
|
573
|
+
* section applies where the env var is absent; the default last.
|
|
574
|
+
*/
|
|
575
|
+
function parseRefreshIntervalMinutes(env, file) {
|
|
576
|
+
return (parseNonNegativeIntEnv(env, PROVIDER_REFRESH_MINUTES_ENV) ??
|
|
577
|
+
file ??
|
|
578
|
+
DEFAULT_PROVIDER_REFRESH_MINUTES);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Parse `SIGRUN_MAX_UPLOAD_BYTES` (spec §6.2) — the strict positive-integer
|
|
582
|
+
* pattern (the `SIGRUN_PORT` posture): `""` counts as unset, anything
|
|
583
|
+
* non-integer or ≤ 0 fails loud naming the variable. The file's
|
|
584
|
+
* `server.maxUploadBytes` applies where the env var is absent; the 64 MiB
|
|
585
|
+
* default last.
|
|
586
|
+
*/
|
|
587
|
+
function parseMaxUploadBytes(env, file) {
|
|
588
|
+
return (parsePositiveIntEnv(env, MAX_UPLOAD_BYTES_ENV) ??
|
|
589
|
+
file ??
|
|
590
|
+
DEFAULT_MAX_UPLOAD_BYTES);
|
|
591
|
+
}
|
|
592
|
+
function parseMemorySeam(env, server) {
|
|
593
|
+
// Same chain as parseCompaction: env parsed first (a bad env value
|
|
594
|
+
// fails loud even when a file value would be valid), the settings
|
|
595
|
+
// file's `server.memorySeam` section applies per-field where env is
|
|
596
|
+
// absent, code defaults last.
|
|
597
|
+
return {
|
|
598
|
+
hookDeadlineMs: parseMemorySeamCount(env, "SIGRUN_HOOK_DEADLINE") ??
|
|
599
|
+
server?.hookDeadlineMs ??
|
|
600
|
+
DEFAULT_MEMORY_SEAM.hookDeadlineMs,
|
|
601
|
+
idleThresholdMs: parseMemorySeamCount(env, "SIGRUN_IDLE_THRESHOLD") ??
|
|
602
|
+
server?.idleThresholdMs ??
|
|
603
|
+
DEFAULT_MEMORY_SEAM.idleThresholdMs,
|
|
604
|
+
workerDeadlineMs: parseMemorySeamCount(env, "SIGRUN_WORKER_DEADLINE") ??
|
|
605
|
+
server?.workerDeadlineMs ??
|
|
606
|
+
DEFAULT_MEMORY_SEAM.workerDeadlineMs,
|
|
607
|
+
queueMax: parseMemorySeamCount(env, "SIGRUN_QUEUE_MAX") ??
|
|
608
|
+
server?.queueMax ??
|
|
609
|
+
DEFAULT_MEMORY_SEAM.queueMax,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/** The `BackupConfig` → file-shape projection (the two-shape union). */
|
|
613
|
+
function backupFileShape(config) {
|
|
614
|
+
return config.target === "path"
|
|
615
|
+
? { path: config.path }
|
|
616
|
+
: {
|
|
617
|
+
s3: {
|
|
618
|
+
bucket: config.bucket,
|
|
619
|
+
...(config.prefix === undefined ? {} : { prefix: config.prefix }),
|
|
620
|
+
...(config.endpoint === undefined ? {} : { endpoint: config.endpoint }),
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
/** Copy only the defined entries; `undefined` when none survive. */
|
|
625
|
+
function pickDefined(source) {
|
|
626
|
+
const out = {};
|
|
627
|
+
for (const [key, value] of Object.entries(source)) {
|
|
628
|
+
if (value !== undefined)
|
|
629
|
+
out[key] = value;
|
|
630
|
+
}
|
|
631
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Project a resolved config into the settings file's `server` section —
|
|
635
|
+
* the object `SettingsStore.bootstrap` persists on first boot. Defaults
|
|
636
|
+
* are included deliberately (the bootstrap file records the full
|
|
637
|
+
* effective server configuration, not just the overrides); the backup
|
|
638
|
+
* target maps back to the file's two-shape union (absent target → the
|
|
639
|
+
* key is omitted, so the file carries no empty `backup: {}`).
|
|
640
|
+
*/
|
|
641
|
+
export function resolvedServerSection(config) {
|
|
642
|
+
const backup = config.backup === undefined
|
|
643
|
+
? undefined
|
|
644
|
+
: config.backup.target === "path"
|
|
645
|
+
? { path: config.backup.path }
|
|
646
|
+
: {
|
|
647
|
+
s3: {
|
|
648
|
+
bucket: config.backup.bucket,
|
|
649
|
+
...(config.backup.prefix === undefined
|
|
650
|
+
? {}
|
|
651
|
+
: { prefix: config.backup.prefix }),
|
|
652
|
+
...(config.backup.endpoint === undefined
|
|
653
|
+
? {}
|
|
654
|
+
: { endpoint: config.backup.endpoint }),
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
// The webui keys are the ONE deliberate exception to "the file
|
|
658
|
+
// records the full effective section" (build-and-webui Task 4's
|
|
659
|
+
// review focus): a DEFAULTED webui is OMITTED entirely — persisting a
|
|
660
|
+
// mirrored `webui/dist` (or an `enabled: true` half-section) would
|
|
661
|
+
// read as an explicit selection on the NEXT boot, re-arming the
|
|
662
|
+
// cwd-relative default bug and making absent-default → API-only
|
|
663
|
+
// unreachable. A CONFIGURED webui records the new shape only; the
|
|
664
|
+
// LEGACY `webuiBundle` key is never written (it is a read-side alias).
|
|
665
|
+
const webui = config.webui.enabled === true && config.webui.bundle === undefined
|
|
666
|
+
? undefined
|
|
667
|
+
: {
|
|
668
|
+
enabled: config.webui.enabled,
|
|
669
|
+
...(config.webui.bundle === undefined
|
|
670
|
+
? {}
|
|
671
|
+
: { bundle: config.webui.bundle }),
|
|
672
|
+
};
|
|
673
|
+
// `auth`/`metrics` are the SECOND intent-shaped exception (the webui
|
|
674
|
+
// pattern, core-hardening W1): a DEFAULTED auth is omitted entirely —
|
|
675
|
+
// a mirrored `methods: []` would read as "authentication disabled"
|
|
676
|
+
// intent on the next boot, when the derived default is `[]` ONLY on a
|
|
677
|
+
// loopback bind (a fact the file cannot see). A CONFIGURED auth
|
|
678
|
+
// records only the non-default keys, and the env-only credentials
|
|
679
|
+
// (`proxy.secret`, `serviceToken`) are NEVER written — key material
|
|
680
|
+
// stays in env/auth.json.
|
|
681
|
+
const proxy = config.auth.proxy;
|
|
682
|
+
const proxySection = proxy.trustedProxies.length > 0 ||
|
|
683
|
+
proxy.secretHeader !== undefined ||
|
|
684
|
+
proxy.userHeader !== DEFAULT_AUTH.proxy.userHeader ||
|
|
685
|
+
proxy.groupsHeader !== DEFAULT_AUTH.proxy.groupsHeader ||
|
|
686
|
+
proxy.nameHeader !== DEFAULT_AUTH.proxy.nameHeader ||
|
|
687
|
+
proxy.emailHeader !== DEFAULT_AUTH.proxy.emailHeader ||
|
|
688
|
+
proxy.autoProvision !== true
|
|
689
|
+
? {
|
|
690
|
+
...(proxy.trustedProxies.length > 0
|
|
691
|
+
? { trustedProxies: [...proxy.trustedProxies] }
|
|
692
|
+
: {}),
|
|
693
|
+
...(proxy.secretHeader === undefined
|
|
694
|
+
? {}
|
|
695
|
+
: { secretHeader: proxy.secretHeader }),
|
|
696
|
+
...(proxy.userHeader !== DEFAULT_AUTH.proxy.userHeader
|
|
697
|
+
? { userHeader: proxy.userHeader }
|
|
698
|
+
: {}),
|
|
699
|
+
...(proxy.groupsHeader !== DEFAULT_AUTH.proxy.groupsHeader
|
|
700
|
+
? { groupsHeader: proxy.groupsHeader }
|
|
701
|
+
: {}),
|
|
702
|
+
...(proxy.nameHeader !== DEFAULT_AUTH.proxy.nameHeader
|
|
703
|
+
? { nameHeader: proxy.nameHeader }
|
|
704
|
+
: {}),
|
|
705
|
+
...(proxy.emailHeader !== DEFAULT_AUTH.proxy.emailHeader
|
|
706
|
+
? { emailHeader: proxy.emailHeader }
|
|
707
|
+
: {}),
|
|
708
|
+
...(proxy.autoProvision !== true
|
|
709
|
+
? { autoProvision: proxy.autoProvision }
|
|
710
|
+
: {}),
|
|
711
|
+
}
|
|
712
|
+
: undefined;
|
|
713
|
+
const auth = config.auth.methods !== undefined ||
|
|
714
|
+
proxySection !== undefined ||
|
|
715
|
+
config.auth.adminGroups.length > 0 ||
|
|
716
|
+
config.auth.allowedHosts !== undefined ||
|
|
717
|
+
config.auth.allowedOrigins !== undefined
|
|
718
|
+
? {
|
|
719
|
+
...(config.auth.methods === undefined
|
|
720
|
+
? {}
|
|
721
|
+
: { methods: [...config.auth.methods] }),
|
|
722
|
+
...(proxySection === undefined ? {} : { proxy: proxySection }),
|
|
723
|
+
...(config.auth.adminGroups.length > 0
|
|
724
|
+
? { adminGroups: [...config.auth.adminGroups] }
|
|
725
|
+
: {}),
|
|
726
|
+
...(config.auth.allowedHosts === undefined
|
|
727
|
+
? {}
|
|
728
|
+
: { allowedHosts: [...config.auth.allowedHosts] }),
|
|
729
|
+
...(config.auth.allowedOrigins === undefined
|
|
730
|
+
? {}
|
|
731
|
+
: { allowedOrigins: [...config.auth.allowedOrigins] }),
|
|
732
|
+
}
|
|
733
|
+
: undefined;
|
|
734
|
+
const metrics = config.metricsPublic
|
|
735
|
+
? { public: true }
|
|
736
|
+
: undefined;
|
|
737
|
+
return {
|
|
738
|
+
port: config.port,
|
|
739
|
+
workspacePath: config.workspace.path,
|
|
740
|
+
storagePath: config.storagePath,
|
|
741
|
+
dataDir: config.dataDir,
|
|
742
|
+
cacheDir: config.cacheDir,
|
|
743
|
+
stateDir: config.stateDir,
|
|
744
|
+
...(config.publicUrl === undefined ? {} : { publicUrl: config.publicUrl }),
|
|
745
|
+
...(webui === undefined ? {} : { webui }),
|
|
746
|
+
compaction: { ...config.compaction },
|
|
747
|
+
memorySeam: { ...config.memorySeam },
|
|
748
|
+
providers: { ...config.providers },
|
|
749
|
+
...(auth === undefined ? {} : { auth }),
|
|
750
|
+
...(metrics === undefined ? {} : { metrics }),
|
|
751
|
+
...(backup === undefined ? {} : { backup }),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Project the ENV-SET server values into the settings file's `server`
|
|
756
|
+
* section — the object the first-boot bootstrap persists (ruling 5 /
|
|
757
|
+
* spec §6.6: the file records INTENT, not reality). Only keys whose env
|
|
758
|
+
* var is set (non-empty) appear; defaults are never projected, so later
|
|
759
|
+
* releases' default changes reach existing installs. Each value uses
|
|
760
|
+
* the SAME typed parser `loadConfig` uses (an invalid env value fails
|
|
761
|
+
* loud here exactly as it would at resolution). `SIGRUN_EXTENSIONS_DIR`
|
|
762
|
+
* is RETIRED (§6.7): set → one deprecation warning, then ignored — the
|
|
763
|
+
* config carries no `extensionsDir` field at all.
|
|
764
|
+
*
|
|
765
|
+
* The env-var → key table:
|
|
766
|
+
*
|
|
767
|
+
* | env var | key |
|
|
768
|
+
* |---|---|
|
|
769
|
+
* | `SIGRUN_PORT` | `port` |
|
|
770
|
+
* | `SIGRUN_WORKSPACE_PATH` | `workspacePath` |
|
|
771
|
+
* | `SIGRUN_STORAGE_PATH` | `storagePath` |
|
|
772
|
+
* | `SIGRUN_DATA_DIR` | `dataDir` |
|
|
773
|
+
* | `SIGRUN_CACHE_DIR` | `cacheDir` |
|
|
774
|
+
* | `SIGRUN_STATE_DIR` | `stateDir` |
|
|
775
|
+
* | `SIGRUN_WEBUI` | `webui.enabled` |
|
|
776
|
+
* | `SIGRUN_WEBUI_BUNDLE` | `webui.bundle` |
|
|
777
|
+
* | `SIGRUN_COMPACTION_ENABLED` | `compaction.enabled` |
|
|
778
|
+
* | `SIGRUN_COMPACTION_RESERVE_TOKENS` | `compaction.reserveTokens` |
|
|
779
|
+
* | `SIGRUN_COMPACTION_KEEP_RECENT_TOKENS` | `compaction.keepRecentTokens` |
|
|
780
|
+
* | `SIGRUN_COMPACTION_BACKGROUND_TOKENS` | `compaction.backgroundTokens` |
|
|
781
|
+
* | `SIGRUN_HOOK_DEADLINE` | `memorySeam.hookDeadlineMs` |
|
|
782
|
+
* | `SIGRUN_IDLE_THRESHOLD` | `memorySeam.idleThresholdMs` |
|
|
783
|
+
* | `SIGRUN_WORKER_DEADLINE` | `memorySeam.workerDeadlineMs` |
|
|
784
|
+
* | `SIGRUN_QUEUE_MAX` | `memorySeam.queueMax` |
|
|
785
|
+
* | `SIGRUN_PROVIDER_REFRESH_MINUTES` | `providers.refreshIntervalMinutes` |
|
|
786
|
+
* | `SIGRUN_BACKUP_*` | `backup` |
|
|
787
|
+
* | `SIGRUN_MAX_UPLOAD_BYTES` | `maxUploadBytes` |
|
|
788
|
+
* | `SIGRUN_MAX_FILE_READ_BYTES` | `maxFileReadBytes` |
|
|
789
|
+
* | `SIGRUN_PUBLIC_URL` | `publicUrl` |
|
|
790
|
+
*/
|
|
791
|
+
export function envServerSection(env) {
|
|
792
|
+
const section = {};
|
|
793
|
+
const port = parsePositiveIntEnv(env, "SIGRUN_PORT");
|
|
794
|
+
if (port !== undefined)
|
|
795
|
+
section.port = port;
|
|
796
|
+
// Paths: verbatim when set — the exact reading `loadConfig` applies
|
|
797
|
+
// (`""` counts as unset; no trimming, no suffixing).
|
|
798
|
+
for (const [key, envKey] of [
|
|
799
|
+
["workspacePath", "SIGRUN_WORKSPACE_PATH"],
|
|
800
|
+
["storagePath", "SIGRUN_STORAGE_PATH"],
|
|
801
|
+
["dataDir", "SIGRUN_DATA_DIR"],
|
|
802
|
+
["cacheDir", "SIGRUN_CACHE_DIR"],
|
|
803
|
+
["stateDir", "SIGRUN_STATE_DIR"],
|
|
804
|
+
]) {
|
|
805
|
+
const value = env[envKey] || undefined;
|
|
806
|
+
if (value !== undefined)
|
|
807
|
+
section[key] = value;
|
|
808
|
+
}
|
|
809
|
+
const maxUploadBytes = parsePositiveIntEnv(env, MAX_UPLOAD_BYTES_ENV);
|
|
810
|
+
if (maxUploadBytes !== undefined)
|
|
811
|
+
section.maxUploadBytes = maxUploadBytes;
|
|
812
|
+
const maxFileReadBytes = parsePositiveIntEnv(env, MAX_FILE_READ_BYTES_ENV);
|
|
813
|
+
if (maxFileReadBytes !== undefined) {
|
|
814
|
+
section.maxFileReadBytes = maxFileReadBytes;
|
|
815
|
+
}
|
|
816
|
+
const publicUrl = parsePublicUrl(env[PUBLIC_URL_ENV], PUBLIC_URL_ENV);
|
|
817
|
+
if (publicUrl !== undefined)
|
|
818
|
+
section.publicUrl = publicUrl;
|
|
819
|
+
const webui = pickDefined({
|
|
820
|
+
enabled: parseWebuiEnabled(env),
|
|
821
|
+
bundle: env.SIGRUN_WEBUI_BUNDLE?.trim() || undefined,
|
|
822
|
+
});
|
|
823
|
+
if (webui !== undefined)
|
|
824
|
+
section.webui = webui;
|
|
825
|
+
const compaction = pickDefined({
|
|
826
|
+
enabled: parseCompactionEnabled(env),
|
|
827
|
+
reserveTokens: parseTokenCount(env, "SIGRUN_COMPACTION_RESERVE_TOKENS", 1),
|
|
828
|
+
keepRecentTokens: parseTokenCount(env, "SIGRUN_COMPACTION_KEEP_RECENT_TOKENS", 1),
|
|
829
|
+
backgroundTokens: parseTokenCount(env, "SIGRUN_COMPACTION_BACKGROUND_TOKENS", 0),
|
|
830
|
+
});
|
|
831
|
+
if (compaction !== undefined)
|
|
832
|
+
section.compaction = compaction;
|
|
833
|
+
const memorySeam = pickDefined({
|
|
834
|
+
hookDeadlineMs: parseMemorySeamCount(env, "SIGRUN_HOOK_DEADLINE"),
|
|
835
|
+
idleThresholdMs: parseMemorySeamCount(env, "SIGRUN_IDLE_THRESHOLD"),
|
|
836
|
+
workerDeadlineMs: parseMemorySeamCount(env, "SIGRUN_WORKER_DEADLINE"),
|
|
837
|
+
queueMax: parseMemorySeamCount(env, "SIGRUN_QUEUE_MAX"),
|
|
838
|
+
});
|
|
839
|
+
if (memorySeam !== undefined)
|
|
840
|
+
section.memorySeam = memorySeam;
|
|
841
|
+
const refreshIntervalMinutes = parseNonNegativeIntEnv(env, PROVIDER_REFRESH_MINUTES_ENV);
|
|
842
|
+
if (refreshIntervalMinutes !== undefined) {
|
|
843
|
+
section.providers = { refreshIntervalMinutes };
|
|
844
|
+
}
|
|
845
|
+
const backup = parseBackupConfig(env);
|
|
846
|
+
if (backup !== undefined)
|
|
847
|
+
section.backup = backupFileShape(backup);
|
|
848
|
+
// The jobs default timezone (§8.2): `SIGRUN_TIMEZONE` — verbatim when
|
|
849
|
+
// set (validated as an IANA name by the settings/store consumers).
|
|
850
|
+
const timezone = env.SIGRUN_TIMEZONE?.trim() || undefined;
|
|
851
|
+
if (timezone !== undefined)
|
|
852
|
+
section.timezone = timezone;
|
|
853
|
+
return section;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Build the `/v1/settings` `effective` block (spec §6.6): every server
|
|
857
|
+
* section key — flattened to its dotted wire name (`"server.port"`,
|
|
858
|
+
* `"server.compaction.reserveTokens"`, …) — with the RESOLVED value
|
|
859
|
+
* (from the config boot actually used) and the SOURCE that won:
|
|
860
|
+
* `"env"` when {@link envServerSection} projects the key (the env var
|
|
861
|
+
* was set), else `"file"` when the file's `server` section sets it
|
|
862
|
+
* (the LEGACY `webuiBundle` key counts as the file source for
|
|
863
|
+
* `webui.bundle` — it resolves as the same selector), else `"default"`.
|
|
864
|
+
* This generalizes the build spec's webui-key special case: since the
|
|
865
|
+
* bootstrap no longer persists defaults, the block is where the full
|
|
866
|
+
* resolved section (and each key's provenance) is visible. Unset
|
|
867
|
+
* optional keys (`webui.bundle`, the `backup` leaves) are omitted —
|
|
868
|
+
* there is no resolved value to report.
|
|
869
|
+
*/
|
|
870
|
+
export function effectiveServerSection(env, fileServer, config) {
|
|
871
|
+
const envSection = envServerSection(env);
|
|
872
|
+
const leaf = (source, path) => {
|
|
873
|
+
let node = source;
|
|
874
|
+
for (const key of path) {
|
|
875
|
+
if (typeof node !== "object" || node === null)
|
|
876
|
+
return false;
|
|
877
|
+
node = node[key];
|
|
878
|
+
}
|
|
879
|
+
return node !== undefined;
|
|
880
|
+
};
|
|
881
|
+
const sourceOf = (path, legacyFileKeys = []) => {
|
|
882
|
+
if (leaf(envSection, path))
|
|
883
|
+
return "env";
|
|
884
|
+
if (fileServer !== undefined &&
|
|
885
|
+
(leaf(fileServer, path) ||
|
|
886
|
+
legacyFileKeys.some((key) => leaf(fileServer, [key])))) {
|
|
887
|
+
return "file";
|
|
888
|
+
}
|
|
889
|
+
return "default";
|
|
890
|
+
};
|
|
891
|
+
const out = {};
|
|
892
|
+
const emit = (key, value, path, legacyFileKeys = []) => {
|
|
893
|
+
out[`server.${key}`] = { value, source: sourceOf(path, legacyFileKeys) };
|
|
894
|
+
};
|
|
895
|
+
emit("port", config.port, ["port"]);
|
|
896
|
+
emit("workspacePath", config.workspace.path, ["workspacePath"]);
|
|
897
|
+
emit("storagePath", config.storagePath, ["storagePath"]);
|
|
898
|
+
emit("maxUploadBytes", config.maxUploadBytes, ["maxUploadBytes"]);
|
|
899
|
+
emit("maxFileReadBytes", config.maxFileReadBytes, ["maxFileReadBytes"]);
|
|
900
|
+
emit("dataDir", config.dataDir, ["dataDir"]);
|
|
901
|
+
emit("cacheDir", config.cacheDir, ["cacheDir"]);
|
|
902
|
+
emit("stateDir", config.stateDir, ["stateDir"]);
|
|
903
|
+
if (config.publicUrl !== undefined) {
|
|
904
|
+
emit("publicUrl", config.publicUrl, ["publicUrl"]);
|
|
905
|
+
}
|
|
906
|
+
emit("webui.enabled", config.webui.enabled, ["webui", "enabled"]);
|
|
907
|
+
if (config.webui.bundle !== undefined) {
|
|
908
|
+
emit("webui.bundle", config.webui.bundle, ["webui", "bundle"], [
|
|
909
|
+
"webuiBundle",
|
|
910
|
+
]);
|
|
911
|
+
}
|
|
912
|
+
emit("compaction.enabled", config.compaction.enabled, [
|
|
913
|
+
"compaction",
|
|
914
|
+
"enabled",
|
|
915
|
+
]);
|
|
916
|
+
emit("compaction.reserveTokens", config.compaction.reserveTokens, ["compaction", "reserveTokens"]);
|
|
917
|
+
emit("compaction.keepRecentTokens", config.compaction.keepRecentTokens, ["compaction", "keepRecentTokens"]);
|
|
918
|
+
emit("compaction.backgroundTokens", config.compaction.backgroundTokens, ["compaction", "backgroundTokens"]);
|
|
919
|
+
emit("memorySeam.hookDeadlineMs", config.memorySeam.hookDeadlineMs, ["memorySeam", "hookDeadlineMs"]);
|
|
920
|
+
emit("memorySeam.idleThresholdMs", config.memorySeam.idleThresholdMs, ["memorySeam", "idleThresholdMs"]);
|
|
921
|
+
emit("memorySeam.workerDeadlineMs", config.memorySeam.workerDeadlineMs, ["memorySeam", "workerDeadlineMs"]);
|
|
922
|
+
emit("memorySeam.queueMax", config.memorySeam.queueMax, [
|
|
923
|
+
"memorySeam",
|
|
924
|
+
"queueMax",
|
|
925
|
+
]);
|
|
926
|
+
emit("providers.refreshIntervalMinutes", config.providers.refreshIntervalMinutes, ["providers", "refreshIntervalMinutes"]);
|
|
927
|
+
if (config.backup?.target === "path") {
|
|
928
|
+
emit("backup.path", config.backup.path, ["backup", "path"]);
|
|
929
|
+
emit("backup.staleAfterHours", config.backupStaleAfterHours, ["backup", "staleAfterHours"]);
|
|
930
|
+
}
|
|
931
|
+
else if (config.backup?.target === "s3") {
|
|
932
|
+
emit("backup.s3.bucket", config.backup.bucket, [
|
|
933
|
+
"backup",
|
|
934
|
+
"s3",
|
|
935
|
+
"bucket",
|
|
936
|
+
]);
|
|
937
|
+
if (config.backup.prefix !== undefined) {
|
|
938
|
+
emit("backup.s3.prefix", config.backup.prefix, [
|
|
939
|
+
"backup",
|
|
940
|
+
"s3",
|
|
941
|
+
"prefix",
|
|
942
|
+
]);
|
|
943
|
+
}
|
|
944
|
+
if (config.backup.endpoint !== undefined) {
|
|
945
|
+
emit("backup.s3.endpoint", config.backup.endpoint, [
|
|
946
|
+
"backup",
|
|
947
|
+
"s3",
|
|
948
|
+
"endpoint",
|
|
949
|
+
]);
|
|
950
|
+
}
|
|
951
|
+
emit("backup.staleAfterHours", config.backupStaleAfterHours, ["backup", "staleAfterHours"]);
|
|
952
|
+
}
|
|
953
|
+
return out;
|
|
954
|
+
}
|
|
955
|
+
export function loadConfig(env, settingsFile) {
|
|
956
|
+
// Env is parsed FIRST (fail-loud on an invalid env value even when the
|
|
957
|
+
// file carries a valid one) and always WINS: a file field applies only
|
|
958
|
+
// where the corresponding env variable is absent. Env-only knobs (never
|
|
959
|
+
// file-configurable): `SIGRUN_CONFIG_DIR` (the file's own location) and
|
|
960
|
+
// the OpenAI credentials (`OPENAI_API_KEY`, plus the sibling `auth.json`
|
|
961
|
+
// the provider layer reads — settings.json never carries key material).
|
|
962
|
+
const server = settingsFile?.server;
|
|
963
|
+
// §6.7: `SIGRUN_EXTENSIONS_DIR` is retired — the knob was parsed but
|
|
964
|
+
// never read. Set (non-empty) → one warning, then ignored; the file's
|
|
965
|
+
// `server.extensionsDir` likewise only warns (src/harness/settings.ts).
|
|
966
|
+
if (env.SIGRUN_EXTENSIONS_DIR) {
|
|
967
|
+
warnDeprecatedExtensionsDirEnv();
|
|
968
|
+
}
|
|
969
|
+
// The boot default model (ruling 3): env wins, the file's top-level
|
|
970
|
+
// `defaultModel` is next, and there is NO code default — the model is
|
|
971
|
+
// genuinely required, so neither-source fails loud exactly like the
|
|
972
|
+
// pre-file behavior. `SIGRUN_MODEL` is the deprecated alias: it reads
|
|
973
|
+
// the same value (one release cycle) and warns once per boot when it
|
|
974
|
+
// actually supplied the model.
|
|
975
|
+
const envSpec = env.SIGRUN_DEFAULT_MODEL?.trim() || env.SIGRUN_MODEL?.trim();
|
|
976
|
+
if (env.SIGRUN_DEFAULT_MODEL?.trim() === undefined &&
|
|
977
|
+
env.SIGRUN_MODEL?.trim() !== undefined) {
|
|
978
|
+
warnDeprecatedModelEnv();
|
|
979
|
+
}
|
|
980
|
+
const fileSpec = settingsFile?.defaultModel;
|
|
981
|
+
if (!envSpec && fileSpec === undefined) {
|
|
982
|
+
throw new Error("SIGRUN_DEFAULT_MODEL or settings.json defaultModel is required " +
|
|
983
|
+
"(format: provider/model-id, e.g. openai/gpt-4o)");
|
|
984
|
+
}
|
|
985
|
+
const models = parseModelSpec(envSpec ?? fileSpec, envSpec ? DEFAULT_MODEL_ENV : "settings.json defaultModel");
|
|
986
|
+
let port = server?.port ?? DEFAULT_PORT;
|
|
987
|
+
const envPort = parsePositiveIntEnv(env, "SIGRUN_PORT");
|
|
988
|
+
if (envPort !== undefined) {
|
|
989
|
+
port = envPort;
|
|
990
|
+
}
|
|
991
|
+
// The webui surface (build-and-webui Task 4). The bundle selector's
|
|
992
|
+
// FOUR slots, first-set-wins: `SIGRUN_WEBUI_BUNDLE` (the env form,
|
|
993
|
+
// deprecated in docs but still winning) over the file's
|
|
994
|
+
// `server.webui.bundle` over the LEGACY `server.webuiBundle` (a
|
|
995
|
+
// deprecated read-side alias, one release) over nothing (the built-in
|
|
996
|
+
// default bundle — `webui.bundle` stays ABSENT so serve-time resolution
|
|
997
|
+
// can tell "unset" from "set to the default's value"). The
|
|
998
|
+
// enable gate is its own two-slot chain (strict env parse over the
|
|
999
|
+
// file's `server.webui.enabled`, default true).
|
|
1000
|
+
const envWebui = env.SIGRUN_WEBUI_BUNDLE?.trim() || undefined;
|
|
1001
|
+
const fileWebui = server?.webui?.bundle ?? server?.webuiBundle;
|
|
1002
|
+
// The store roots (Task 6): the ONE resolution chain, applied per root
|
|
1003
|
+
// with the file's `server.*Dir` sitting between the env var and the
|
|
1004
|
+
// tier default — the same precedence shape as every other `server.*`
|
|
1005
|
+
// mirror. An env-or-file value wins VERBATIM (no suffix, no `~`
|
|
1006
|
+
// expansion); the fallback runs Task 1's resolver over the env. Env
|
|
1007
|
+
// `""` counts as unset (the stores module's own reading).
|
|
1008
|
+
const resolvedRoots = resolveStoreRoots(env);
|
|
1009
|
+
const rootDir = (kind) => env[`SIGRUN_${kind.toUpperCase()}_DIR`] ||
|
|
1010
|
+
server?.[`${kind}Dir`] ||
|
|
1011
|
+
resolvedRoots[`${kind}Dir`];
|
|
1012
|
+
const dataDir = rootDir("data");
|
|
1013
|
+
const cacheDir = rootDir("cache");
|
|
1014
|
+
const stateDir = rootDir("state");
|
|
1015
|
+
// The DB default under the data root (clean break): a configured
|
|
1016
|
+
// `SIGRUN_STORAGE_PATH` (or the file's `server.storagePath`) — env over
|
|
1017
|
+
// file, `""`-means-unset — joins the DATA root when relative (the old
|
|
1018
|
+
// cwd-relative reading is retired); absolute values stay verbatim.
|
|
1019
|
+
const configuredStorage = env.SIGRUN_STORAGE_PATH || server?.storagePath || undefined;
|
|
1020
|
+
const storagePath = configuredStorage === undefined
|
|
1021
|
+
? join(dataDir, STORAGE_FILE_NAME)
|
|
1022
|
+
: isAbsolute(configuredStorage)
|
|
1023
|
+
? configuredStorage
|
|
1024
|
+
: join(dataDir, configuredStorage);
|
|
1025
|
+
return {
|
|
1026
|
+
dataDir,
|
|
1027
|
+
cacheDir,
|
|
1028
|
+
stateDir,
|
|
1029
|
+
storagePath,
|
|
1030
|
+
models,
|
|
1031
|
+
maxUploadBytes: parseMaxUploadBytes(env, server?.maxUploadBytes),
|
|
1032
|
+
maxFileReadBytes: parseMaxFileReadBytes(env, server?.maxFileReadBytes),
|
|
1033
|
+
// W5 Task 2 (spec §9.2): the deployment's absolute base URL — env
|
|
1034
|
+
// parsed first (fail loud on an invalid env value even when the
|
|
1035
|
+
// file carries a valid one), the file's `server.publicUrl` next,
|
|
1036
|
+
// unset when neither source sets it.
|
|
1037
|
+
publicUrl: parsePublicUrl(env[PUBLIC_URL_ENV], PUBLIC_URL_ENV) ??
|
|
1038
|
+
parsePublicUrl(server?.publicUrl, "server.publicUrl"),
|
|
1039
|
+
// Ruling 1: env CSV wins; the file's top-level `extensions` array
|
|
1040
|
+
// next; empty otherwise.
|
|
1041
|
+
// Ruling 1: env CSV wins; the file's top-level `extensions` array
|
|
1042
|
+
// next; empty otherwise. The env path yields LOAD strings; the file
|
|
1043
|
+
// path may carry object entries — flatten to LOAD sources here (the
|
|
1044
|
+
// enabled:false filter happens at the loader, which owns load
|
|
1045
|
+
// decisions; the sync step consumed the full specs already).
|
|
1046
|
+
extensions: parseExtensionList(env.SIGRUN_EXTENSIONS) ??
|
|
1047
|
+
(settingsFile?.extensions ?? []).map((entry) => typeof entry === "string" ? entry : entry.source),
|
|
1048
|
+
port,
|
|
1049
|
+
workspace: {
|
|
1050
|
+
type: "local",
|
|
1051
|
+
// Spec §6.3: the default rides the data root (<dataDir>/workspace);
|
|
1052
|
+
// env over file over that — and the default is never persisted
|
|
1053
|
+
// (spec §6.6, the envServerSection projection).
|
|
1054
|
+
path: env.SIGRUN_WORKSPACE_PATH ||
|
|
1055
|
+
server?.workspacePath ||
|
|
1056
|
+
defaultWorkspacePath(dataDir),
|
|
1057
|
+
},
|
|
1058
|
+
// Sections (Task 7): `SIGRUN_BACKUP_SECTIONS` is env-only and
|
|
1059
|
+
// applies to the FILE-sourced target too — the env knob selects
|
|
1060
|
+
// WHAT to back up, the target may come from either tier.
|
|
1061
|
+
backup: parseBackupConfig(env) ??
|
|
1062
|
+
parseServerBackup(server?.backup, parseBackupSections(env.SIGRUN_BACKUP_SECTIONS)),
|
|
1063
|
+
// §6.8: the honesty window — file-only tier over the 36 h default
|
|
1064
|
+
// (no env var exists for this key).
|
|
1065
|
+
backupStaleAfterHours: server?.backup?.staleAfterHours ?? DEFAULT_BACKUP_STALE_AFTER_HOURS,
|
|
1066
|
+
compaction: parseCompaction(env, server?.compaction),
|
|
1067
|
+
memorySeam: parseMemorySeam(env, server?.memorySeam),
|
|
1068
|
+
providers: {
|
|
1069
|
+
refreshIntervalMinutes: parseRefreshIntervalMinutes(env, server?.providers?.refreshIntervalMinutes),
|
|
1070
|
+
},
|
|
1071
|
+
auth: parseAuth(env, server?.auth),
|
|
1072
|
+
metricsPublic: parseMetricsPublic(env) ?? server?.metrics?.public ?? false,
|
|
1073
|
+
// The jobs default timezone (§8.2): env over file over the process
|
|
1074
|
+
// TZ, validated loud (a bad name would otherwise crash the scheduler
|
|
1075
|
+
// at evaluation time instead of failing boot).
|
|
1076
|
+
timezone: resolveTimezone(env.SIGRUN_TIMEZONE?.trim() || server?.timezone),
|
|
1077
|
+
// The webui surface (build-and-webui Task 4): the NEW shape the
|
|
1078
|
+
// serve-time seam resolves (Task 5, src/server/static.ts). `enabled`
|
|
1079
|
+
// is the strict env gate over the file's `server.webui.enabled`, default
|
|
1080
|
+
// true; `bundle` is the four-slot selector — ABSENT when nothing selects
|
|
1081
|
+
// (the built-in default bundle; serve-time resolution reads absence,
|
|
1082
|
+
// never a mirrored default value).
|
|
1083
|
+
webui: {
|
|
1084
|
+
enabled: parseWebuiEnabled(env) ?? server?.webui?.enabled ?? true,
|
|
1085
|
+
...((envWebui ?? fileWebui) === undefined
|
|
1086
|
+
? {}
|
|
1087
|
+
: { bundle: envWebui ?? fileWebui }),
|
|
1088
|
+
},
|
|
1089
|
+
// Ruling 7: the degenerate provider's endpoint. `undefined` when
|
|
1090
|
+
// neither env nor file sets it — the provider layer applies the
|
|
1091
|
+
// OpenAI public default there.
|
|
1092
|
+
defaultProviderBaseUrl: env.OPENAI_BASE_URL?.trim() || settingsFile?.defaultProvider?.baseUrl,
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* The jobs default timezone resolution (§8.2): env over file over the
|
|
1097
|
+
* process TZ. A configured name that is not IANA fails LOUD — the same
|
|
1098
|
+
* gate `validateJobSpec` runs on a spec's own `timezone`.
|
|
1099
|
+
*/
|
|
1100
|
+
function resolveTimezone(configured) {
|
|
1101
|
+
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1102
|
+
if (configured === undefined || configured === "")
|
|
1103
|
+
return fallback;
|
|
1104
|
+
if (!isIanaTimeZone(configured)) {
|
|
1105
|
+
throw new Error(`SIGRUN_TIMEZONE / server.timezone must be an IANA name (got ${JSON.stringify(configured)})`);
|
|
1106
|
+
}
|
|
1107
|
+
return configured;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* The backup target from the file's `server.backup` section, applied only
|
|
1111
|
+
* when env configures NO target (env wins — including the env's own
|
|
1112
|
+
* ambiguity error, which throws before this is consulted). The section is
|
|
1113
|
+
* already shape-validated by the settings store; a missing bucket fails
|
|
1114
|
+
* loud here the way the env parse would.
|
|
1115
|
+
*/
|
|
1116
|
+
function parseServerBackup(backup, sections) {
|
|
1117
|
+
if (backup === undefined)
|
|
1118
|
+
return undefined;
|
|
1119
|
+
// Property-presence narrowing: the union's two shapes are
|
|
1120
|
+
// optional-keyed, so `.path !== undefined` does not discriminate them
|
|
1121
|
+
// for TS — `in` does.
|
|
1122
|
+
if ("path" in backup) {
|
|
1123
|
+
if (backup.path === undefined)
|
|
1124
|
+
return undefined;
|
|
1125
|
+
return {
|
|
1126
|
+
target: "path",
|
|
1127
|
+
path: backup.path,
|
|
1128
|
+
...(sections === undefined ? {} : { sections }),
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
if (!("s3" in backup))
|
|
1132
|
+
return undefined;
|
|
1133
|
+
const s3 = backup.s3;
|
|
1134
|
+
if (s3 === undefined)
|
|
1135
|
+
return undefined;
|
|
1136
|
+
if (s3.bucket === undefined) {
|
|
1137
|
+
throw new Error("settings server.backup.s3.bucket is required when server.backup.s3 is set");
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
target: "s3",
|
|
1141
|
+
bucket: s3.bucket,
|
|
1142
|
+
...(s3.prefix === undefined ? {} : { prefix: s3.prefix }),
|
|
1143
|
+
...(s3.endpoint === undefined ? {} : { endpoint: s3.endpoint }),
|
|
1144
|
+
...(sections === undefined ? {} : { sections }),
|
|
1145
|
+
};
|
|
1146
|
+
}
|