@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. 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
+ }