@sema-agent/server 2.0.0 → 3.0.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 (63) hide show
  1. package/MIGRATION.md +74 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +391 -0
  5. package/dist/boot/budget-tracing.d.ts +48 -0
  6. package/dist/boot/budget-tracing.js +86 -0
  7. package/dist/boot/config-center.d.ts +62 -0
  8. package/dist/boot/config-center.js +1002 -0
  9. package/dist/boot/coordinators.d.ts +33 -0
  10. package/dist/boot/coordinators.js +97 -0
  11. package/dist/boot/execution-env.d.ts +26 -0
  12. package/dist/boot/execution-env.js +370 -0
  13. package/dist/boot/leader.d.ts +27 -0
  14. package/dist/boot/leader.js +81 -0
  15. package/dist/boot/reapers.d.ts +53 -0
  16. package/dist/boot/reapers.js +252 -0
  17. package/dist/boot/resolve-spec.d.ts +70 -0
  18. package/dist/boot/resolve-spec.js +1072 -0
  19. package/dist/boot/runner-deps.d.ts +101 -0
  20. package/dist/boot/runner-deps.js +343 -0
  21. package/dist/boot/runtime-caps.d.ts +21 -0
  22. package/dist/boot/runtime-caps.js +62 -0
  23. package/dist/boot/session-faces.d.ts +57 -0
  24. package/dist/boot/session-faces.js +157 -0
  25. package/dist/boot/shutdown.d.ts +50 -0
  26. package/dist/boot/shutdown.js +129 -0
  27. package/dist/boot/stores.d.ts +32 -0
  28. package/dist/boot/stores.js +361 -0
  29. package/dist/boot/workflow-orchestration.d.ts +46 -0
  30. package/dist/boot/workflow-orchestration.js +150 -0
  31. package/dist/capabilities/scenarios.d.ts +5 -3
  32. package/dist/capabilities/scenarios.js +5 -3
  33. package/dist/config-center/apply-effective.js +4 -3
  34. package/dist/config-lkg.d.ts +2 -1
  35. package/dist/config-lkg.js +2 -1
  36. package/dist/config-types.d.ts +47 -7
  37. package/dist/config.d.ts +30 -13
  38. package/dist/config.js +562 -387
  39. package/dist/hooks/hook-llm.js +9 -0
  40. package/dist/http/routes/approvals-assistant.js +1 -1
  41. package/dist/http/routes/attachments.js +2 -2
  42. package/dist/http/routes/memory-policy.js +3 -3
  43. package/dist/http/routes/runs.js +1 -1
  44. package/dist/http/routes/session-sync.js +2 -2
  45. package/dist/http/routes/sessions.js +2 -2
  46. package/dist/http/routes/tasks.js +2 -2
  47. package/dist/http/routes/trace-usage.js +2 -2
  48. package/dist/http/routes/workflows.js +3 -1
  49. package/dist/http/server.d.ts +1 -1
  50. package/dist/http/server.js +25 -5
  51. package/dist/http/sse-log.js +1 -1
  52. package/dist/main.js +164 -3799
  53. package/dist/model-select.d.ts +1 -1
  54. package/dist/model-select.js +1 -1
  55. package/dist/plugins/checkpoint-store-sql.d.ts +13 -5
  56. package/dist/plugins/checkpoint-store-sql.js +10 -3
  57. package/dist/plugins/local-checkpoint-store.js +8 -2
  58. package/dist/plugins/remote-env-host.d.ts +2 -1
  59. package/dist/run-local.js +2 -1
  60. package/dist/session-titler.d.ts +3 -1
  61. package/dist/session-titler.js +2 -2
  62. package/dist/trace/project.js +4 -1
  63. package/package.json +5 -3
package/dist/config.js CHANGED
@@ -132,7 +132,6 @@ export function logConfigDiagnostics(logger) {
132
132
  polarity: k.polarity,
133
133
  value: k.value,
134
134
  source: k.source,
135
- ...(k.legacyName ? { legacyName: k.legacyName } : {}),
136
135
  });
137
136
  }
138
137
  }
@@ -164,9 +163,6 @@ function optFinitePositiveEnv(name) {
164
163
  /** Keyed by canonical name so a knob read twice in one load (e.g. DURABLE_APPROVAL, once for the D-G boot
165
164
  * invariant and once for the returned field) registers one row, not two. Reset per `loadConfig()`. */
166
165
  const CONFIG_KNOBS = new Map();
167
- /** Deprecated aliases already announced for this load — the lazily-read twins (HOST_BG/HOST_EXEC_SPOOL) resolve
168
- * again on every use, and an operator does not need the same deprecation line once per command. */
169
- const KNOB_DEPRECATIONS_SEEN = new Set();
170
166
  /** The registered boolean knobs, sorted by name — the data behind `logConfigDiagnostics`'s polarity table. */
171
167
  export function configKnobTable() {
172
168
  return [...CONFIG_KNOBS.values()].sort((a, b) => a.name.localeCompare(b.name));
@@ -189,29 +185,33 @@ function boolEnv(name, def) {
189
185
  source = "env";
190
186
  }
191
187
  else if (!CONFIG_KNOBS.has(name)) {
192
- // Previously a silent default at every hand-written site. Guarded on first registration because a few
193
- // knobs are legitimately read twice in one load (IMAGE_BAKES_ENABLED by its two boot invariants and then
194
- // by the returned field; DURABLE_APPROVAL by the D-G gate and then the field) — one typo, one warning.
188
+ // Previously a silent default at every hand-written site. Guarded on first registration: the LAZILY
189
+ // re-read knobs (boolEnvWithLegacyNegated's host twins, read per exec) must not grow this array for the
190
+ // process lifetime on one typo — one typo, one warning. (复审 2026-07-30 F6:早期理由「IMAGE_BAKES_
191
+ // ENABLED/DURABLE_APPROVAL 一次 load 读两次」已随九域拆分的去重失效,懒读族成为本守卫的现行理由。)
195
192
  CONFIG_WARNINGS.push({ env: name, raw });
196
193
  }
197
194
  }
198
195
  registerKnob({ name, polarity: def ? "opt-out" : "opt-in", value, source });
199
196
  return value;
200
197
  }
201
- /** design/158 N8 (D-family) a knob whose CANONICAL name is positive (`X_ENABLED`, default ON) but which keeps
202
- * its retired negative name (`X_DISABLED`) working for one deprecation window.
198
+ /** design/158 N8 (D-family) + server 3.0.0 墓碑 —— a knob whose CANONICAL name is positive (`X_ENABLED`,
199
+ * default ON) and whose retired negative name (`X_DISABLED`) is now a **fail-loud tombstone**.
203
200
  *
204
201
  * Why the flip at all: `X_DISABLED !== "true"` is a double negative at the read site, so the four knobs that
205
202
  * used it (`PROJECT_MEMORY`, `CONFIG_LKG`, `HOST_BG`, `HOST_EXEC_SPOOL`) could not be read for their default
206
203
  * without inverting twice in your head, and `projectMemoryDisabled` was the ONE negative boolean on a
207
204
  * `ServiceConfig` that otherwise carries 16 positive `*Enabled`/`*Allowed` fields.
208
205
  *
209
- * Resolution order (new name wins, so there is never an ambiguous both-set state):
206
+ * 🔴 3.0.0([1989],clay 令「不做兼容」):旧名不再有兼容窗——**设了就拒启**。这个函数保留 legacy 形参
207
+ * 不是为了继续读它,而正是为了**认出它在场并拒绝启动**(把墓碑放在唯一知道这对名字关系的地方)。
208
+ *
209
+ * Resolution order:
210
210
  * 1. `X_ENABLED` set to a literal ⇒ that.
211
- * 2. `X_ENABLED` set to a non-literal ⇒ default + warning (the legacy name is NOT consulted — a typo in the
212
- * new name must not silently hand control back to the deprecated one).
213
- * 3. `X_ENABLED` unset ∧ `X_DISABLED` set ⇒ the legacy site's exact contract, `X_DISABLED !== "true"`, plus a
214
- * one-shot deprecation notice naming the replacement.
211
+ * 2. `X_ENABLED` set to a non-literal ⇒ default + warning (the tombstone is NOT consulted — a typo in the
212
+ * new name must not turn into a boot refusal about an env the operator never set).
213
+ * 3. `X_ENABLED` unset ∧ `X_DISABLED` set(**任何值**,含 `"false"`)⇒ throw,文案指路新名。选拒启不选
214
+ * 静默忽略:静默会让升级部署的旧开关名义在、实际归默认——比拒启危险,且下游冒烟测试测不出来。
215
215
  * 4. neither ⇒ default. */
216
216
  export function boolEnvWithLegacyNegated(name, legacyNegatedName, def) {
217
217
  const raw = process.env[name];
@@ -233,21 +233,17 @@ export function boolEnvWithLegacyNegated(name, legacyNegatedName, def) {
233
233
  source = "default";
234
234
  }
235
235
  else if (legacy !== undefined && legacy !== "") {
236
- value = legacy !== "true"; // verbatim legacy contract: only the literal "true" disabled the feature
237
- source = "legacy-env";
238
- if (!KNOB_DEPRECATIONS_SEEN.has(legacyNegatedName)) {
239
- KNOB_DEPRECATIONS_SEEN.add(legacyNegatedName);
240
- CONFIG_NOTICES.push({
241
- event: "config_env_deprecated_negated_name",
242
- fields: { deprecated: legacyNegatedName, replacement: name, effective: String(value) },
243
- });
244
- }
236
+ // server 3.0.0([1989],clay 令「不做兼容」):负名旧 env = fail-loud 墓碑。设了(任何值,含
237
+ // "false"——设它的人同样以为它还生效)即拒启并指路新名。选拒启不选静默忽略:静默会让升级部署的
238
+ // 旧开关名义在、实际归默认——比拒启危险,而且下游/运维的冒烟测试测不出来。
239
+ throw new Error(`${legacyNegatedName} was removed in server 3.0.0 — set ${name}=${legacy !== "true"} instead ` +
240
+ `(positive-name knob, default ${def ? "on" : "off"}; migration table in USAGE.md)`);
245
241
  }
246
242
  else {
247
243
  value = def;
248
244
  source = "default";
249
245
  }
250
- registerKnob({ name, polarity: def ? "opt-out" : "opt-in", value, source, legacyName: legacyNegatedName });
246
+ registerKnob({ name, polarity: def ? "opt-out" : "opt-in", value, source });
251
247
  return value;
252
248
  }
253
249
  /** LKG (last-known-good) persistence of the remote effective-config fetch. Read LAZILY at both main.ts sites
@@ -338,35 +334,9 @@ export function applyAutoCompactWindow(m, explicit) {
338
334
  else
339
335
  delete target.autoCompactTokens;
340
336
  }
341
- export function loadConfig() {
342
- CONFIG_WARNINGS.length = 0; // repeated loadConfig() calls (test setup) must not accumulate stale warnings
343
- CONFIG_NOTICES.length = 0;
344
- CONFIG_KNOBS.clear(); // the polarity table describes THIS load, never a previous one's env
345
- KNOB_DEPRECATIONS_SEEN.clear();
346
- // npm 卫生纪律:默认值不烤内网坐标 — localhost 占位(公开分发正确缺省);真网关一律显式 env。
347
- const gatewayBaseUrl = env("MODEL_GATEWAY_BASEURL", "http://127.0.0.1:8000/v1");
348
- const modelId = env("MODEL_ID", "Qwen3.5-35B");
349
- // Posture gate: single-user turnkey (`REQUIRE_PRINCIPAL !== "true"` = one trusted super-admin on their own
350
- // box/tenant, CC's trust model) ⇒ the CC capability set defaults ON, each composed with its infra prereq; multi-tenant
351
- // (requirePrincipal) stays opt-in (gated + secured). This is the SAME discriminator LSP's host-default-ON uses,
352
- // generalized into one posture switch (the PM's "not per-flag" ask). In `postureOn`, an explicit `X_ENABLED=true/false`
353
- // ALWAYS wins; else the default = single-user ∧ infra-ready. 🔒 Multi-tenant (prod) is UNAFFECTED (postureOn → false).
354
- // Parsed ONCE here (design/158 B4) instead of re-comparing `process.env.REQUIRE_PRINCIPAL` at the four sites
355
- // that needed it — two of which spelled it `!== "true"` and two `=== "true"`, i.e. the same knob read in both
356
- // polarities within one function. `boolEnv(x, false)` is value-identical to the old `=== "true"`.
357
- const requirePrincipal = boolEnv("REQUIRE_PRINCIPAL", false);
358
- const singleUserTurnkey = !requirePrincipal;
359
- // `postureOn` takes the NAME (not the pre-read value) so the knob can register itself in the polarity table —
360
- // the posture family is precisely the one whose default is NOT readable from its name, so leaving it out of the
361
- // self-report would omit the worst offenders. Its tri-state semantics are unchanged: an explicit literal wins,
362
- // anything else (including a typo — deliberately NOT warned here, unlike boolEnv) falls to the posture default.
363
- const postureOn = (name, infraReady = true) => {
364
- const envVal = process.env[name];
365
- const explicit = envVal === "true" ? true : envVal === "false" ? false : undefined;
366
- const value = explicit ?? (singleUserTurnkey && infraReady);
367
- registerKnob({ name, polarity: "posture", value, source: explicit === undefined ? "posture" : "env" });
368
- return value;
369
- };
337
+ /** 域:store(持久化)—— DB 引擎三态、session 后端、SQL coords、快照 BLOB / SendUserFile 对象存储。 */
338
+ function parseStoreDomain(ctx) {
339
+ const { requirePrincipal } = ctx; // 跨域入参①:多租户裸 boot 的 memory 缺省由它决定(见下方注释)
370
340
  // `local` (clay 2026-06-25 seamless local↔cloud) = the DB-less in-memory/file StoreBackend, so a locally-run HTTP
371
341
  // service serves the same contract a cloud worker does. It has no SQL host/coords (createStoreBackend builds it
372
342
  // without tidb/pg), so it's always "reachable". (Parsed FIRST so the session/memory posture defaults below can
@@ -408,62 +378,6 @@ export function loadConfig() {
408
378
  const sqlEngineExplicit = !!process.env.DB_BACKEND && dbBackend !== "local" && dbBackend !== "memory";
409
379
  const sessionBackendRaw = enumEnv("SESSION_BACKEND", sqlEngineExplicit ? "mysql" : "memory", ["memory", "mysql", "tidb", "auto"]);
410
380
  const sessionBackend = sessionBackendRaw === "mysql" ? "tidb" : sessionBackendRaw;
411
- // design/138 S1 (clay 2026-07-08): long-term memory = the injection-first file-based memory ENGINE
412
- // (core RunnerDeps.memoryBackend), default ON with MEMORY_ENGINE=off as the explicit kill-switch.
413
- // The legacy MEMORY_BACKEND/EMBEDDING_* store plane was dropped without migration (those env vars are
414
- // no longer read). Single-user gating lives in main.ts (the engine is never wired multi-tenant).
415
- const memoryEngineEnabled = process.env.MEMORY_ENGINE !== "off";
416
- // S3-TOB(边界重切后的 backend 选择,设计 §1.3):记忆持久面方言。缺省 file=现状零变化;
417
- // pg/tidb=DB 真身(零卷主档)——多租户点亮的唯一门(显式 opt-in,复审 F5:绝不静默翻转)。
418
- const memoryEngineBackendRaw = process.env.MEMORY_ENGINE_BACKEND ?? "file";
419
- if (!["file", "pg", "tidb"].includes(memoryEngineBackendRaw)) {
420
- throw new Error(`MEMORY_ENGINE_BACKEND must be file|pg|tidb, got "${memoryEngineBackendRaw}"`);
421
- }
422
- const memoryEngineBackend = memoryEngineBackendRaw;
423
- // workflowSizeGuideline: CC normalizes unknown→unrestricted (lenient), but an env knob typo
424
- // silently degrading to "no guidance" is the half-config trap — fail-loud here (memoryEngineBackend
425
- // posture; the ADVISORY nature is core's concern, the env spelling is ours).
426
- const workflowSizeGuidelineRaw = process.env.WORKFLOW_SIZE_GUIDELINE;
427
- if (workflowSizeGuidelineRaw !== undefined && !["small", "medium", "large", "unrestricted"].includes(workflowSizeGuidelineRaw)) {
428
- throw new Error(`WORKFLOW_SIZE_GUIDELINE must be small|medium|large|unrestricted, got "${workflowSizeGuidelineRaw}"`);
429
- }
430
- const workflowSizeGuideline = workflowSizeGuidelineRaw;
431
- // Single-user memory scope (hoisted from the config literal so the sync-scope default below can consume it):
432
- // explicit MEMORY_SCOPE always wins; default "local" only when the engine is on AND single-user (see the
433
- // literal-site comment on why multi-tenant/engine-off must stay undefined = memory dark).
434
- const memoryScope = process.env.MEMORY_SCOPE ?? (memoryEngineEnabled && !requirePrincipal ? "local" : undefined);
435
- // 142-S2.5-W1: TOC 同步 client 腿的三键(file memory 形态专用)。MEMORY_SYNC_URL 设了=开;
436
- // 缺省 undefined=纯本地现状零变化。半配置 fail-loud(memoryEngineBackend 同款姿势,绝不静默降级):
437
- // - URL 有 TOKEN 无 ⇒ throw(中心同步面要 Bearer,缺 token 的“开了但永远 401”是半配置陷阱);
438
- // - TOKEN/SCOPE 有 URL 无 ⇒ throw(operator 显然想开同步,静默不跑=同款陷阱,对称拒启);
439
- // - DB backend(pg/tidb)配了任意同步键 ⇒ throw(中心侧自己就是 POST /v1/memory/sync/:scope 的权威,
440
- // 不该再跑 client 腿——两个半场叠在一个进程是拓扑错误)。
441
- // scope 缺省 = formatUserScope(memoryScope)(中心侧 owner 门的 user 盘键铸法);memoryScope 本身已是
442
- // v2 typed key(user:/org:/proj:/userproj:,只可能来自 operator 显式 MEMORY_SCOPE)则原样直通——
443
- // 再包一层会铸出 `user:user%3A...` 双包键(诚实偏离,报告记档)。
444
- const memorySyncUrl = process.env.MEMORY_SYNC_URL || undefined;
445
- const memorySyncToken = process.env.MEMORY_SYNC_TOKEN || undefined;
446
- const memorySyncScopeRaw = process.env.MEMORY_SYNC_SCOPE || undefined;
447
- let memorySync;
448
- if (memorySyncUrl === undefined) {
449
- if (memorySyncToken !== undefined || memorySyncScopeRaw !== undefined) {
450
- throw new Error("MEMORY_SYNC_TOKEN/MEMORY_SYNC_SCOPE are set but MEMORY_SYNC_URL is not — refusing to start half-configured (set MEMORY_SYNC_URL to enable memory sync, or unset the other MEMORY_SYNC_* keys)");
451
- }
452
- }
453
- else {
454
- if (memoryEngineBackend !== "file") {
455
- throw new Error(`MEMORY_SYNC_URL is a FILE-memory (TOC client) knob, but MEMORY_ENGINE_BACKEND=${memoryEngineBackend} — the DB memory plane IS the central sync authority (it serves POST /v1/memory/sync/:scope) and must not also run the client leg; unset MEMORY_SYNC_* here`);
456
- }
457
- if (memorySyncToken === undefined) {
458
- throw new Error("MEMORY_SYNC_URL is set but MEMORY_SYNC_TOKEN is not — refusing to start half-configured (the central sync face requires a bearer token)");
459
- }
460
- const isV2ScopeKey = (k) => k.startsWith("user:") || k.startsWith("org:") || k.startsWith("proj:") || k.startsWith("userproj:");
461
- const scope = memorySyncScopeRaw ?? (memoryScope !== undefined ? (isV2ScopeKey(memoryScope) ? memoryScope : formatUserScope(memoryScope)) : undefined);
462
- if (scope === undefined) {
463
- throw new Error("MEMORY_SYNC_URL is set but no sync scope is derivable (memory engine off or multi-tenant without an explicit scope) — set MEMORY_SYNC_SCOPE, or run the single-user file memory engine so MEMORY_SCOPE (default \"local\") provides one");
464
- }
465
- memorySync = { url: memorySyncUrl, token: memorySyncToken, scope, ...((v) => (v !== undefined && Number.isInteger(v) && v >= 1 ? { maxPushEntries: v } : {}))(process.env.MEMORY_SYNC_MAX_PUSH_ENTRIES !== undefined ? Number(process.env.MEMORY_SYNC_MAX_PUSH_ENTRIES) : undefined), ...((v) => (v !== undefined && Number.isInteger(v) && v >= 1 ? { maxPullEntries: v } : {}))(process.env.MEMORY_SYNC_MAX_PULL_ENTRIES !== undefined ? Number(process.env.MEMORY_SYNC_MAX_PULL_ENTRIES) : undefined) };
466
- }
467
381
  // P0.5 variant-2: the file-backed `local` backend's data root. SAME resolution main.ts:102 (localRoot =
468
382
  // CONFIG_LOCAL_DIR ?? AGENT_DATA_DIR ?? ~/.ai-agent) + run-local.ts:198, so the HTTP service + a run-local on one
469
383
  // box open ONE boot-locked data dir. `LOCAL_DATA_ROOT` is an additional highest-priority override (the explicit
@@ -476,6 +390,94 @@ export function loadConfig() {
476
390
  const dbHostSet = dbBackend === "pg" ? !!process.env.PG_HOST : !!(process.env.MYSQL_HOST || process.env.TIDB_HOST);
477
391
  const needsTidb = sessionBackend === "tidb" || (sessionBackend === "auto" && dbHostSet);
478
392
  const needsDb = needsTidb;
393
+ return {
394
+ sessionBackend,
395
+ sessionCacheTtlSec: Number(env("SESSION_CACHE_TTL_SEC", "300")),
396
+ rewindSnapshotMaxMb: optFinitePositiveEnv("REWIND_SNAPSHOT_MAX_MB"), // soft knob (S20: bad value warns + default)
397
+ dbBackend,
398
+ dbBackendExplicit: dbBackendSet,
399
+ localDataRoot,
400
+ tidb: needsDb && dbBackend === "mysql"
401
+ ? {
402
+ host: env2("MYSQL_HOST", "TIDB_HOST"),
403
+ // default port follows the env family in use: MYSQL_HOST → 3306 (stock MySQL), TIDB_HOST → 4000 (TiDB)
404
+ port: Number(env2("MYSQL_PORT", "TIDB_PORT", process.env.MYSQL_HOST ? "3306" : "4000")),
405
+ user: env2("MYSQL_USER", "TIDB_USER"),
406
+ password: env2("MYSQL_PASSWORD", "TIDB_PASSWORD", ""),
407
+ database: env2("MYSQL_DATABASE", "TIDB_DATABASE"),
408
+ connectionLimit: process.env.MYSQL_POOL_SIZE || process.env.TIDB_POOL_SIZE
409
+ ? Number(process.env.MYSQL_POOL_SIZE || process.env.TIDB_POOL_SIZE)
410
+ : undefined,
411
+ }
412
+ : undefined,
413
+ pg: needsDb && dbBackend === "pg"
414
+ ? {
415
+ host: env("PG_HOST"),
416
+ port: Number(env("PG_PORT", "5432")),
417
+ user: env("PG_USER"),
418
+ password: env("PG_PASSWORD", ""),
419
+ database: env("PG_DATABASE"),
420
+ connectionLimit: process.env.PG_POOL_SIZE ? Number(process.env.PG_POOL_SIZE) : undefined,
421
+ }
422
+ : undefined,
423
+ // S9 deeper fix: per-query DB timeout (see the interface doc). Soft knob — a typo degrades to the default
424
+ // posture (pool-wide off / counter-family 30s) with an S20 boot warning, never NaN into a driver timer.
425
+ dbQueryTimeoutMs: optFinitePositiveEnv("DB_QUERY_TIMEOUT_MS"),
426
+ // E19/2c snapshot-BLOB object-store offload (clay 2026-06-26). Reuses the SAME adapter-held MinIO creds as the k8s
427
+ // workspace-snapshot lane (secret minio-agent-worker); all three keys required to enable, else the file-snapshot
428
+ // stores keep the SQL `snapshot_blob` default (byte-identical to today). Bucket = SESSION_SNAPSHOT_BUCKET (default
429
+ // "session-snapshots"); keyPrefix optional (default "blobs/" in MinioBlobBackend).
430
+ snapshotBlobStore: process.env.MINIO_ENDPOINT && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY
431
+ ? {
432
+ endpoint: process.env.MINIO_ENDPOINT,
433
+ bucket: process.env.SESSION_SNAPSHOT_BUCKET ?? "session-snapshots",
434
+ accessKey: process.env.MINIO_ACCESS_KEY,
435
+ secretKey: process.env.MINIO_SECRET_KEY,
436
+ ...(process.env.MINIO_REGION ? { region: process.env.MINIO_REGION } : {}),
437
+ ...(process.env.SESSION_SNAPSHOT_PREFIX ? { keyPrefix: process.env.SESSION_SNAPSHOT_PREFIX } : {}),
438
+ // Fail-SAFE parse (finding 20): a non-numeric/non-positive value → omit (the MinioBlobBackend default 3600s
439
+ // applies), never NaN (which would sign every URL with X-Amz-Expires=NaN → total snapshot outage).
440
+ ...((ttl) => (ttl !== undefined ? { presignTtlSec: ttl } : {}))(optFinitePositiveEnv("SESSION_SNAPSHOT_TTL_SEC")),
441
+ }
442
+ : undefined,
443
+ // (a+c)(clay 2026-07-27)snapshot_blob 包墙修复两旋钮——语义见 ServiceConfig 字段注释。
444
+ snapshotBlobSqlMaxBytes: optFinitePositiveEnv("SNAPSHOT_BLOB_SQL_MAX_BYTES"),
445
+ snapshotBlobAllowSql: boolEnv("SNAPSHOT_BLOB_ALLOW_SQL_BYTES", false),
446
+ // SendUserFile 双轨公网直链(同三键开闸;S3_ENDPOINT 兼容位=外接 S3 场景内网/公网同址)。
447
+ // `||` 非 `??`(配置面自查 2026-07-14):env 惯例(env() helper 同款)空串=未设——`?? ` 会让
448
+ // 一个显式置空的 MINIO_ENDPOINT/S3_PUBLIC_ENDPOINT 把后备名吞掉(compose `${X:-}` 透传空串是常态)。
449
+ sendUserFile: (process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT) && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY
450
+ ? {
451
+ endpoint: (process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT),
452
+ ...(process.env.S3_PUBLIC_ENDPOINT || process.env.S3_ENDPOINT
453
+ ? { publicEndpoint: (process.env.S3_PUBLIC_ENDPOINT || process.env.S3_ENDPOINT) }
454
+ : {}),
455
+ publicBucket: process.env.S3_PUBLIC_BUCKET ?? "sema-public",
456
+ privateBucket: process.env.SESSION_SNAPSHOT_BUCKET ?? "session-snapshots",
457
+ privateKeyPrefix: "sendfile/",
458
+ ...(process.env.SEND_USER_FILE_SANDBOX_PUT_ENDPOINT ? { sandboxPutEndpoint: process.env.SEND_USER_FILE_SANDBOX_PUT_ENDPOINT } : {}),
459
+ accessKey: process.env.MINIO_ACCESS_KEY,
460
+ secretKey: process.env.MINIO_SECRET_KEY,
461
+ ...(process.env.MINIO_REGION ? { region: process.env.MINIO_REGION } : {}),
462
+ // fail-SAFE:非法值回落 0(永久轨缺省),不 NaN 进签名。
463
+ defaultTtlSec: ((v) => (v !== undefined && Number.isInteger(v) && v >= 0 ? v : 0))(process.env.SEND_USER_FILE_URL_TTL !== undefined ? Number(process.env.SEND_USER_FILE_URL_TTL) : undefined),
464
+ }
465
+ : undefined,
466
+ };
467
+ }
468
+ /** 域:model(模型面)—— 网关坐标、Anthropic 路线、韧性旋钮、主/廉价 model entry、role 表、降级梯。 */
469
+ function parseModelDomain() {
470
+ // npm 卫生纪律:默认值不烤内网坐标 — localhost 占位(公开分发正确缺省);真网关一律显式 env。
471
+ const gatewayBaseUrl = env("MODEL_GATEWAY_BASEURL", "http://127.0.0.1:8000/v1");
472
+ // server 3.0.0([1992] 真机 400 案根治):MODEL_ID **无出厂缺省**。旧缺省是一个只在开发环境存在的
473
+ // 模型名,对任何外部部署都必炸——且炸在离根因最远的地方(gateway 400 + title hook 连环告警),
474
+ // 用户看不出是没配模型。fail-loud at boot 指路配置,与 env 墓碑同哲学:让问题在最早、最清楚处现形。
475
+ const modelId = env("MODEL_ID", "");
476
+ if (modelId === "") {
477
+ throw new Error("MODEL_ID is required (no factory default since server 3.0.0) — set MODEL_ID=<model name your gateway serves>, " +
478
+ "or configure models via the sema-registry catalog; the old silent default was an internal-only model name " +
479
+ "that made every external deployment fail far from the cause (gateway 400)");
480
+ }
479
481
  // Model.api must reflect the brain that actually serves it (routing is by provider): an "anthropic"
480
482
  // provider runs the Anthropic brain, whose usage reports `input` EXCLUDING cached tokens. core ≥1.22
481
483
  // normalizes the cache-hit-rate denominator per api family — a wrong api makes promptTokens too small
@@ -664,97 +666,7 @@ export function loadConfig() {
664
666
  ? { model: cur, systemPrompt: CODE_AGENT_PROMPT }
665
667
  : { ...cur, systemPrompt: CODE_AGENT_PROMPT };
666
668
  }
667
- // design/80 D-G: the direct-connect approval door anchors (all must be present to ACTIVATE; fail-closed).
668
- const directApprovalDoor = boolEnv("DIRECT_APPROVAL_DOOR", false);
669
- const principalJwtPubkeys = parsePrincipalJwks(process.env.PRINCIPAL_JWT_PUBKEYS);
670
- const principalJwtIss = process.env.PRINCIPAL_JWT_ISS || undefined;
671
- const principalJwtAud = process.env.PRINCIPAL_JWT_AUD || undefined;
672
- const principalJwtMaxTtlSec = Math.max(0, numEnv("PRINCIPAL_JWT_MAX_TTL_SEC", "120")); // 0 = no cap; default ≤120s
673
- const dgHmacKeys = parseApprovalHmacKeys(process.env.APPROVAL_HMAC_KEYS);
674
- const dgOperators = csv("OPERATOR_PRINCIPALS");
675
- // 🔴 the direct-door crypto gate lives ONLY in the durable (checkpointStore) /decide branch — so DURABLE_APPROVAL
676
- // is a HARD precondition. Without it, checkpointStore is undefined, that branch is skipped, and the LEGACY
677
- // approvalStore /decide (trusted-header, NO crypto) becomes the live path = a full bypass (adversarial CRITICAL).
678
- const dgDurable = boolEnv("DURABLE_APPROVAL", false);
679
- const directDoorActive = directApprovalDoor && dgDurable && dgHmacKeys.length > 0 && principalJwtPubkeys.length > 0 && !!principalJwtIss && !!principalJwtAud;
680
- // 🔴 boot invariant (D-G §3): a half-configured direct door is the WORST state (looks on, verifies nothing) — and
681
- // an empty OPERATOR_PRINCIPALS makes isOperator() true-for-all (= cross-tenant escalation). Refuse to start.
682
- if (directApprovalDoor) {
683
- const missing = [];
684
- if (!dgDurable)
685
- missing.push("DURABLE_APPROVAL=true (the direct door's crypto gate only exists on the durable /decide path)");
686
- if (dgHmacKeys.length === 0)
687
- missing.push("APPROVAL_HMAC_KEYS");
688
- if (principalJwtPubkeys.length === 0)
689
- missing.push("PRINCIPAL_JWT_PUBKEYS");
690
- if (!principalJwtIss)
691
- missing.push("PRINCIPAL_JWT_ISS");
692
- if (!principalJwtAud)
693
- missing.push("PRINCIPAL_JWT_AUD");
694
- if (dgOperators.length === 0)
695
- missing.push("OPERATOR_PRINCIPALS (else isOperator is true-for-all)");
696
- // 🔴 F-fix (dim-6): a direct door is inherently MULTI-TENANT (per-task verified JWT principal). Without
697
- // REQUIRE_PRINCIPAL=true an absent/invalid principal falls through as ANONYMOUS and the owner/quota checks
698
- // degrade to the spoofable header for null-owner rows — refuse a direct door that doesn't require a principal.
699
- if (!requirePrincipal)
700
- missing.push("REQUIRE_PRINCIPAL=true (a direct door is multi-tenant; without it an absent principal is anonymous and the spoofable header governs owner/quota checks)");
701
- if (missing.length) {
702
- throw new Error(`DIRECT_APPROVAL_DOOR=true but D-G anchors are missing: ${missing.join(", ")} — refusing to start a half-open direct door (design/80 D-G boot invariant)`);
703
- }
704
- }
705
- // Per-system service credentials (token → system name). Parsed HERE (not just in the return object) so the
706
- // bake boot invariant below can assert the runner-credential→runnerPrincipal mapping exists.
707
- const authTokens = Object.fromEntries((process.env.SERVICE_AUTH_TOKENS ?? "")
708
- .split(",")
709
- .map((s) => s.trim())
710
- .filter((s) => s.includes("="))
711
- .map((s) => [s.slice(0, s.indexOf("=")).trim(), s.slice(s.indexOf("=") + 1).trim()])
712
- .filter(([tok, sys]) => tok.length > 0 && sys.length > 0));
713
- const bakeRunnerPrincipal = env("BAKE_RUNNER_PRINCIPAL", "system:bake-runner");
714
- // 🔴 boot invariant (IMAGE-API-DESIGN.md §P2.4a): the bake door is build-host-RCE-capable, and isOperator([],p)
715
- // is true-for-all — so enabling bakes with an empty OPERATOR_PRINCIPALS = a WORLD-WRITABLE RCE door. Refuse to
716
- // start (never a half-open bake door). The explicit-operator gate in server.ts is the runtime twin of this.
717
- if (boolEnv("IMAGE_BAKES_ENABLED", false) && dgOperators.length === 0) {
718
- throw new Error("IMAGE_BAKES_ENABLED=true but OPERATOR_PRINCIPALS is empty — the bake door runs build.sh on the privileged build host; an empty operator set makes isOperator() true-for-all (world-writable RCE). Set OPERATOR_PRINCIPALS or disable bakes (IMAGE-API-DESIGN.md §P2.4a boot invariant)");
719
- }
720
- // 🔴 boot invariant (IMAGE-API-DESIGN.md §P2.12 / H1): the bake-runner identity is DERIVED from its bearer
721
- // credential (token-derived `source`), never a self-asserted header — so a SERVICE_AUTH_TOKENS entry whose
722
- // system == BAKE_RUNNER_PRINCIPAL MUST exist, else the runner can never satisfy isRunner and the entire
723
- // claim/ingest/heartbeat pipeline is silently dead. Refuse to start a bake door with no runner credential.
724
- if (boolEnv("IMAGE_BAKES_ENABLED", false) && !Object.values(authTokens).includes(bakeRunnerPrincipal)) {
725
- throw new Error(`IMAGE_BAKES_ENABLED=true but no SERVICE_AUTH_TOKENS entry maps to the runner principal '${bakeRunnerPrincipal}' — the bake-runner authenticates by its credential (token-derived source), so a '<BAKE_RUNNER_TOKEN>=${bakeRunnerPrincipal}' SERVICE_AUTH_TOKENS entry is required or claim/ingest/heartbeat all 403 (IMAGE-API-DESIGN.md §P2.12 boot invariant)`);
726
- }
727
- // ── design/158 B4 ② — the four knobs whose canonical name went from negative to positive. Only the first
728
- // lands on `ServiceConfig`; the other three are read lazily at their use sites (main.ts's LKG gate,
729
- // remote-env-host's capability probe and exec path) but are resolved HERE too so that their polarity row and
730
- // any legacy-alias deprecation notice are emitted once, at boot, with every other config diagnostic — an
731
- // operator must not have to trigger a background shell before being told the name they set is retired.
732
- const projectMemoryEnabled = boolEnvWithLegacyNegated("PROJECT_MEMORY_ENABLED", "PROJECT_MEMORY_DISABLED", true);
733
- configLkgEnabled();
734
- hostBackgroundShellEnabled();
735
- hostExecSpoolEnabled();
736
- // ── design/158 B4 ③ — LSP: one knob per lane. `LSP_ENABLED` used to drive BOTH the sandbox lane (opt-in,
737
- // default OFF — it needs a baked `sema-code-lsp` template) and the host lane (opt-out, default ON — it needs
738
- // nothing and degrades to grep/read), i.e. one env name with two OPPOSITE defaults and no way to silence the
739
- // host lane without also naming the sandbox knob. `LSP_HOST_ENABLED` now owns the host lane; an explicit
740
- // `LSP_ENABLED=false` keeps working as a host opt-out (it was the only one operators ever had) with a notice
741
- // naming the replacement, and `LSP_HOST_ENABLED` always wins when set.
742
- const lspHostEnabled = (() => {
743
- const explicitHost = process.env.LSP_HOST_ENABLED;
744
- if (explicitHost === undefined || explicitHost === "") {
745
- if (process.env.LSP_ENABLED === "false") {
746
- CONFIG_NOTICES.push({
747
- event: "config_env_deprecated_lane_alias",
748
- fields: { deprecated: "LSP_ENABLED=false", replacement: "LSP_HOST_ENABLED=false", lane: "host", effective: "false" },
749
- });
750
- registerKnob({ name: "LSP_HOST_ENABLED", polarity: "opt-out", value: false, source: "legacy-env", legacyName: "LSP_ENABLED" });
751
- return false;
752
- }
753
- }
754
- return boolEnv("LSP_HOST_ENABLED", true);
755
- })();
756
669
  return {
757
- port: Number(env("PORT", "8090")),
758
670
  gatewayBaseUrl,
759
671
  gatewayApiKey: process.env.MODEL_API_KEY,
760
672
  gatewayFallbackUrls: csv("MODEL_GATEWAY_FALLBACK_URLS"),
@@ -798,22 +710,223 @@ export function loadConfig() {
798
710
  tiers: {}, // 档位表:env lane 恒空(INERT);applyEffective 从 models.tierGroups 解析填充(保引用)
799
711
  projects: {}, // 142-S4 项目登记簿:env lane 恒空(INERT);applyEffective 从 projects 域填充(保引用)
800
712
  roles,
801
- sessionBackend,
802
- sessionCacheTtlSec: Number(env("SESSION_CACHE_TTL_SEC", "300")),
803
- rewindSnapshotMaxMb: optFinitePositiveEnv("REWIND_SNAPSHOT_MAX_MB"), // soft knob (S20: bad value warns + default)
804
- memoryEngineEnabled,
805
- memoryEngineDir: process.env.MEMORY_ENGINE_DIR || undefined,
806
- memoryEngineRemoteLaneAllowed: process.env.MEMORY_ENGINE_REMOTE_LANE === "allow", // N0: fail-closed default
807
- // A single-user deployment has no principal, so `memoryScopeFor` falls back to `config.memoryScope` — and if
808
- // THAT were unset the resolved scope would be undefined ⇒ resolveSpec builds NO `spec.memory` ⇒ the memory
809
- // engine never materializes (the "wired-not-triggered" gap a §4 real test hit on the legacy plane).
810
- // Default it to "local" for single-user (REQUIRE_PRINCIPAL !== "true") so TOC local memory works out-of-the-box
811
- // (CC-parity). Multi-tenant memory is DARK by design (design/138 S1: the file basement has no tenant isolation —
812
- // memoryScopeFor returns undefined there). An explicit MEMORY_SCOPE always wins.
813
- memoryEngineBackend,
814
- ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}),
815
- memoryScope, // hoisted above (the 142-S2.5-W1 sync-scope default consumes it)
713
+ cascadeLadder: csv("MODEL_CASCADE_LADDER"),
714
+ degrade: process.env.MODEL_DEGRADE_TO
715
+ ? {
716
+ to: env("MODEL_DEGRADE_TO"),
717
+ atCostFraction: Number(env("MODEL_DEGRADE_AT_COST_FRACTION", "0.7")),
718
+ reactive: boolEnv("MODEL_DEGRADE_REACTIVE", false),
719
+ downgradeOn: csv("MODEL_DEGRADE_ON").length
720
+ ? csv("MODEL_DEGRADE_ON")
721
+ : undefined,
722
+ // vision precheck (adversarial-review finding): the degrade TARGET is an external gateway
723
+ // model NOT in config.models, so resolveSpec's precheck (which only sees the picked model) can't see its
724
+ // vision capability a vision-capable main + text-only degrade target would let images reach the text-only
725
+ // gateway as the opaque 400 the precheck prevents, AFTER admission, at runtime degrade. Declare it via
726
+ // MODEL_DEGRADE_TO_VISION (strict "true"). DEFAULT false = assume the cheaper degrade target is text-only
727
+ // (the common case, e.g. deepseek), so resolveSpec drops degrade for image-carrying tasks (main.ts).
728
+ toSupportsImages: boolEnv("MODEL_DEGRADE_TO_VISION", false),
729
+ }
730
+ : undefined,
731
+ };
732
+ }
733
+ /** 域:approval(审批/HITL 门)—— 审批名单与预算、durable 面、直连门开关与钥、敏感写门、elicitation。
734
+ * 注:直连门的**激活**(directDoorActive)与 D-G §3 半开门不变量是跨域合取,留在装配层。 */
735
+ function parseApprovalDomain(ctx) {
736
+ const { postureOn } = ctx; // 跨域入参②:posture 三态(single-user turnkey ⇒ HITL 面默认 ON)
737
+ // design/80 D-G: the direct-connect approval door anchors (all must be present to ACTIVATE; fail-closed).
738
+ const directApprovalDoor = boolEnv("DIRECT_APPROVAL_DOOR", false);
739
+ const dgHmacKeys = parseApprovalHmacKeys(process.env.APPROVAL_HMAC_KEYS);
740
+ // DURABLE_APPROVAL 既是本域字段,也是直连门的硬前置 —— 那条前置的理由与断言在装配层的跨域不变量段。
741
+ const durableApproval = boolEnv("DURABLE_APPROVAL", false);
742
+ return {
743
+ approvalRequire: csv("APPROVAL_REQUIRE"),
744
+ approvalDeny: csv("APPROVAL_DENY"),
745
+ approvalPollMs: Number(env("APPROVAL_POLL_MS", "1500")),
746
+ durableApproval,
747
+ resourceSuspend: boolEnv("RESOURCE_SUSPEND", false),
748
+ resourceSuspendTtlSec: Math.max(0, numEnv("RESOURCE_SUSPEND_TTL_SEC", "0")), // 0 ⇒ core default (30d)
749
+ approvalAutoBudget: Math.min(10000, Math.max(0, numEnv("APPROVAL_AUTO_BUDGET", "0"))), // clamp [0,10000]; 0 = off
750
+ approvalNeverAuto: csv("APPROVAL_NEVER_AUTO"),
751
+ approvalHmacKeys: dgHmacKeys, // D-G: WIRED (see the field's JSDoc); empty ⇒ inactive, not unimplemented
752
+ directApprovalDoor,
753
+ // ③ sensitive-path write deny set: unset = core's recommended set; explicit value = full replacement
754
+ // (comma-separated); "off"/empty = disabled. Set curation is core's (成文 core-side); server passes through.
755
+ sensitiveWritePatterns: ((raw) => {
756
+ if (raw === undefined)
757
+ return [...RECOMMENDED_SENSITIVE_PATTERNS];
758
+ const trimmed = raw.trim();
759
+ if (trimmed === "" || trimmed.toLowerCase() === "off")
760
+ return [];
761
+ return trimmed.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
762
+ })(process.env.SENSITIVE_WRITE_PATTERNS),
763
+ // [1557]§四: opt-in only — any value other than the two real gate shapes (incl. "off"/unset) stays undefined
764
+ // (no field on the config object at all), so the fs-write gate wiring never even considers it.
765
+ ...(process.env.MANUAL_MODE_SHELL_GATE === "always" || process.env.MANUAL_MODE_SHELL_GATE === "classify"
766
+ ? { manualModeShellGate: process.env.MANUAL_MODE_SHELL_GATE }
767
+ : {}),
768
+ mcpElicitation: {
769
+ enabled: boolEnv("MCP_ELICITATION_ENABLED", false), // E23 inbound elicitation; default OFF (fail-closed)
770
+ throttle: {
771
+ maxConcurrentPerRun: numEnvBounded("MCP_ELICITATION_MAX_CONCURRENT", String(DEFAULT_ELICITATION_THROTTLE.maxConcurrentPerRun), 1, 64),
772
+ maxTotalPerRun: numEnvBounded("MCP_ELICITATION_MAX_TOTAL", String(DEFAULT_ELICITATION_THROTTLE.maxTotalPerRun), 1, 10_000),
773
+ minIntervalMsPerServer: numEnvBounded("MCP_ELICITATION_MIN_INTERVAL_MS", String(DEFAULT_ELICITATION_THROTTLE.minIntervalMsPerServer), 0, 600_000),
774
+ ttlMs: numEnvBounded("MCP_ELICITATION_TTL_MS", String(DEFAULT_ELICITATION_THROTTLE.ttlMs), 1_000, 3_600_000),
775
+ },
776
+ },
777
+ askQuestionEnabled: postureOn("ASK_QUESTION_ENABLED"), // §4④ AskUserQuestion live HITL; posture-gated (single-user → ON)
778
+ toolApprovalEnabled: postureOn("TOOL_APPROVAL_ENABLED"), // [816]/[820]② live tool-approval HITL; posture-gated (single-user → ON), mirrors askQuestion
779
+ // [875]a 成文:0(缺省)= durable HITL 无限期等人,时间型 reapSuspended 不跑;file/memory lane 的回收
780
+ // 探针只在 DURABLE_APPROVAL=true 时注入(无 durable 的部署两只 suspended 回收器恒 NO-OP,parked 行的
781
+ // 恢复把手 = POST /v1/runs/:id/cancel,[868]①)。checkpoint 过期驱动的那只不受本旋钮门控。
782
+ approvalTimeoutSec: numEnv("APPROVAL_TIMEOUT_SEC", "0"),
783
+ };
784
+ }
785
+ /** 域:memory(记忆面 + TOC 同步腿)—— 记忆引擎开关/方言/根目录、scope 缺省、同步 client 三键、项目记忆。 */
786
+ function parseMemoryDomain(ctx) {
787
+ const { requirePrincipal } = ctx; // 跨域入参①:多租户下记忆面 DARK(scope 缺省不铸)
788
+ // design/138 S1 (clay 2026-07-08): long-term memory = the injection-first file-based memory ENGINE
789
+ // (core RunnerDeps.memoryBackend), default ON with MEMORY_ENGINE=off as the explicit kill-switch.
790
+ // The legacy MEMORY_BACKEND/EMBEDDING_* store plane was dropped without migration (those env vars are
791
+ // no longer read). Single-user gating lives in main.ts (the engine is never wired multi-tenant).
792
+ const memoryEngineEnabled = process.env.MEMORY_ENGINE !== "off";
793
+ // S3-TOB(边界重切后的 backend 选择,设计 §1.3):记忆持久面方言。缺省 file=现状零变化;
794
+ // pg/tidb=DB 真身(零卷主档)——多租户点亮的唯一门(显式 opt-in,复审 F5:绝不静默翻转)。
795
+ const memoryEngineBackendRaw = process.env.MEMORY_ENGINE_BACKEND ?? "file";
796
+ if (!["file", "pg", "tidb"].includes(memoryEngineBackendRaw)) {
797
+ throw new Error(`MEMORY_ENGINE_BACKEND must be file|pg|tidb, got "${memoryEngineBackendRaw}"`);
798
+ }
799
+ const memoryEngineBackend = memoryEngineBackendRaw;
800
+ // Single-user memory scope (hoisted from the config literal so the sync-scope default below can consume it):
801
+ // explicit MEMORY_SCOPE always wins; default "local" only when the engine is on AND single-user (see the
802
+ // literal-site comment on why multi-tenant/engine-off must stay undefined = memory dark).
803
+ const memoryScope = process.env.MEMORY_SCOPE ?? (memoryEngineEnabled && !requirePrincipal ? "local" : undefined);
804
+ // 142-S2.5-W1: TOC 同步 client 腿的三键(file memory 形态专用)。MEMORY_SYNC_URL 设了=开;
805
+ // 缺省 undefined=纯本地现状零变化。半配置 fail-loud(memoryEngineBackend 同款姿势,绝不静默降级):
806
+ // - URL 有 TOKEN 无 ⇒ throw(中心同步面要 Bearer,缺 token 的“开了但永远 401”是半配置陷阱);
807
+ // - TOKEN/SCOPE 有 URL 无 ⇒ throw(operator 显然想开同步,静默不跑=同款陷阱,对称拒启);
808
+ // - DB backend(pg/tidb)配了任意同步键 ⇒ throw(中心侧自己就是 POST /v1/memory/sync/:scope 的权威,
809
+ // 不该再跑 client 腿——两个半场叠在一个进程是拓扑错误)。
810
+ // scope 缺省 = formatUserScope(memoryScope)(中心侧 owner 门的 user 盘键铸法);memoryScope 本身已是
811
+ // v2 typed key(user:/org:/proj:/userproj:,只可能来自 operator 显式 MEMORY_SCOPE)则原样直通——
812
+ // 再包一层会铸出 `user:user%3A...` 双包键(诚实偏离,报告记档)。
813
+ const memorySyncUrl = process.env.MEMORY_SYNC_URL || undefined;
814
+ const memorySyncToken = process.env.MEMORY_SYNC_TOKEN || undefined;
815
+ const memorySyncScopeRaw = process.env.MEMORY_SYNC_SCOPE || undefined;
816
+ let memorySync;
817
+ if (memorySyncUrl === undefined) {
818
+ if (memorySyncToken !== undefined || memorySyncScopeRaw !== undefined) {
819
+ throw new Error("MEMORY_SYNC_TOKEN/MEMORY_SYNC_SCOPE are set but MEMORY_SYNC_URL is not — refusing to start half-configured (set MEMORY_SYNC_URL to enable memory sync, or unset the other MEMORY_SYNC_* keys)");
820
+ }
821
+ }
822
+ else {
823
+ if (memoryEngineBackend !== "file") {
824
+ throw new Error(`MEMORY_SYNC_URL is a FILE-memory (TOC client) knob, but MEMORY_ENGINE_BACKEND=${memoryEngineBackend} — the DB memory plane IS the central sync authority (it serves POST /v1/memory/sync/:scope) and must not also run the client leg; unset MEMORY_SYNC_* here`);
825
+ }
826
+ if (memorySyncToken === undefined) {
827
+ throw new Error("MEMORY_SYNC_URL is set but MEMORY_SYNC_TOKEN is not — refusing to start half-configured (the central sync face requires a bearer token)");
828
+ }
829
+ const isV2ScopeKey = (k) => k.startsWith("user:") || k.startsWith("org:") || k.startsWith("proj:") || k.startsWith("userproj:");
830
+ const scope = memorySyncScopeRaw ?? (memoryScope !== undefined ? (isV2ScopeKey(memoryScope) ? memoryScope : formatUserScope(memoryScope)) : undefined);
831
+ if (scope === undefined) {
832
+ throw new Error("MEMORY_SYNC_URL is set but no sync scope is derivable (memory engine off or multi-tenant without an explicit scope) — set MEMORY_SYNC_SCOPE, or run the single-user file memory engine so MEMORY_SCOPE (default \"local\") provides one");
833
+ }
834
+ memorySync = { url: memorySyncUrl, token: memorySyncToken, scope, ...((v) => (v !== undefined && Number.isInteger(v) && v >= 1 ? { maxPushEntries: v } : {}))(process.env.MEMORY_SYNC_MAX_PUSH_ENTRIES !== undefined ? Number(process.env.MEMORY_SYNC_MAX_PUSH_ENTRIES) : undefined), ...((v) => (v !== undefined && Number.isInteger(v) && v >= 1 ? { maxPullEntries: v } : {}))(process.env.MEMORY_SYNC_MAX_PULL_ENTRIES !== undefined ? Number(process.env.MEMORY_SYNC_MAX_PULL_ENTRIES) : undefined) };
835
+ }
836
+ // design/158 B4 ② 的 D 家族四枚里,唯一落在 ServiceConfig 上的就是这枚(其余三枚只在使用点懒读,
837
+ // 预登记留在 orchestration 域 —— 顺序与改前一致:本枚的弃用通知仍先于那三枚)。
838
+ const projectMemoryEnabled = boolEnvWithLegacyNegated("PROJECT_MEMORY_ENABLED", "PROJECT_MEMORY_DISABLED", true);
839
+ return {
840
+ memoryEngineEnabled,
841
+ memoryEngineDir: process.env.MEMORY_ENGINE_DIR || undefined,
842
+ memoryEngineRemoteLaneAllowed: process.env.MEMORY_ENGINE_REMOTE_LANE === "allow", // N0: fail-closed default
843
+ // A single-user deployment has no principal, so `memoryScopeFor` falls back to `config.memoryScope` — and if
844
+ // THAT were unset the resolved scope would be undefined ⇒ resolveSpec builds NO `spec.memory` ⇒ the memory
845
+ // engine never materializes (the "wired-not-triggered" gap a §4 real test hit on the legacy plane).
846
+ // Default it to "local" for single-user (REQUIRE_PRINCIPAL !== "true") so TOC local memory works out-of-the-box
847
+ // (CC-parity). Multi-tenant memory is DARK by design (design/138 S1: the file basement has no tenant isolation —
848
+ // memoryScopeFor returns undefined there). An explicit MEMORY_SCOPE always wins.
849
+ memoryEngineBackend,
850
+ memoryScope, // hoisted above (the 142-S2.5-W1 sync-scope default consumes it)
816
851
  ...(memorySync ? { memorySync } : {}),
852
+ projectMemoryEnabled, // design/113 C4 opt-out, flipped positive in design/158 B4 (legacy PROJECT_MEMORY_DISABLED still honored)
853
+ syncImportLeaseStaleSec: Math.max(0, numEnv("SYNC_IMPORT_LEASE_STALE_SEC", "600")),
854
+ };
855
+ }
856
+ /** 域:auth(身份/凭证/绑址)—— service token 面、CORS、principal 头与开关、JWT 锚、autonomy、绑址。 */
857
+ function parseAuthDomain(ctx) {
858
+ const { requirePrincipal } = ctx; // 装配层已解析(它是四个域的共同入参),本域只负责把它落到字段上
859
+ // design/80 D-G 直连门的 principal-JWT 锚:验签材料属 auth 域,门的激活与半开门不变量在装配层。
860
+ const principalJwtPubkeys = parsePrincipalJwks(process.env.PRINCIPAL_JWT_PUBKEYS);
861
+ const principalJwtIss = process.env.PRINCIPAL_JWT_ISS || undefined;
862
+ const principalJwtAud = process.env.PRINCIPAL_JWT_AUD || undefined;
863
+ const principalJwtMaxTtlSec = Math.max(0, numEnv("PRINCIPAL_JWT_MAX_TTL_SEC", "120")); // 0 = no cap; default ≤120s
864
+ // Per-system service credentials (token → system name). Parsed as a DOMAIN field (not inline in the return
865
+ // literal) so the bake boot invariant at the assembly layer can assert the runner-credential→runnerPrincipal
866
+ // mapping exists.
867
+ const authTokens = Object.fromEntries((process.env.SERVICE_AUTH_TOKENS ?? "")
868
+ .split(",")
869
+ .map((s) => s.trim())
870
+ .filter((s) => s.includes("="))
871
+ .map((s) => [s.slice(0, s.indexOf("=")).trim(), s.slice(s.indexOf("=") + 1).trim()])
872
+ .filter(([tok, sys]) => tok.length > 0 && sys.length > 0));
873
+ return {
874
+ // 绑址([1934]):BIND_HOST 优先,HOST 兼容(cli/桌面传的就是它);空串=未设。
875
+ bindHost: process.env.BIND_HOST || process.env.HOST || undefined,
876
+ authToken: process.env.SERVICE_AUTH_TOKEN,
877
+ authTokens,
878
+ allowUnauthedWrites: boolEnv("ALLOW_UNAUTHED_WRITES", false),
879
+ corsOrigins: (process.env.CORS_ORIGIN ?? "").split(",").map((s) => s.trim()).filter(Boolean),
880
+ principalHeader: headerNameEnv("PRINCIPAL_HEADER", "x-agent-principal"),
881
+ requirePrincipal,
882
+ // Runtime governance second baton (center §10): AUTONOMY env for the env/local path; sema-registry overlays
883
+ // it (applyRuntimeHot). commandPolicy is structured config — sema-registry-only (no flat env scalar form).
884
+ autonomy: parseAutonomy(process.env.AUTONOMY),
885
+ operatorPrincipals: csv("OPERATOR_PRINCIPALS"),
886
+ principalJwtPubkeys,
887
+ principalJwtIss,
888
+ principalJwtAud,
889
+ principalJwtMaxTtlSec,
890
+ };
891
+ }
892
+ /** 域:orchestration(编排 + 执行车道)—— remoteExec 五车道/worktree 隔离、leader/router/scheduler/fork、
893
+ * workflow 与后台 agent 的存留旋钮、沙箱面(pkg 源/env facts/LSP)、烤镜像门。 */
894
+ function parseOrchestrationDomain(ctx) {
895
+ const { postureOn } = ctx; // 跨域入参②:posture 三态(single-user turnkey ⇒ 自编排/fork/scheduler 默认 ON)
896
+ // workflowSizeGuideline: CC normalizes unknown→unrestricted (lenient), but an env knob typo
897
+ // silently degrading to "no guidance" is the half-config trap — fail-loud here (memoryEngineBackend
898
+ // posture; the ADVISORY nature is core's concern, the env spelling is ours).
899
+ const workflowSizeGuidelineRaw = process.env.WORKFLOW_SIZE_GUIDELINE;
900
+ if (workflowSizeGuidelineRaw !== undefined && !["small", "medium", "large", "unrestricted"].includes(workflowSizeGuidelineRaw)) {
901
+ throw new Error(`WORKFLOW_SIZE_GUIDELINE must be small|medium|large|unrestricted, got "${workflowSizeGuidelineRaw}"`);
902
+ }
903
+ const workflowSizeGuideline = workflowSizeGuidelineRaw;
904
+ const bakeRunnerPrincipal = env("BAKE_RUNNER_PRINCIPAL", "system:bake-runner");
905
+ // ── design/158 B4 ② — the four knobs whose canonical name went from negative to positive. Only the first
906
+ // lands on `ServiceConfig`; the other three are read lazily at their use sites (main.ts's LKG gate,
907
+ // remote-env-host's capability probe and exec path) but are resolved HERE too so that their polarity row and
908
+ // any legacy-alias deprecation notice are emitted once, at boot, with every other config diagnostic — an
909
+ // operator must not have to trigger a background shell before being told the name they set is retired.
910
+ // (家族的第一枚 PROJECT_MEMORY_ENABLED 是 memory 域的字段,已在那里解析 —— 通知顺序不变。)
911
+ configLkgEnabled();
912
+ hostBackgroundShellEnabled();
913
+ hostExecSpoolEnabled();
914
+ // ── design/158 B4 ③ — LSP: one knob per lane. `LSP_ENABLED` used to drive BOTH the sandbox lane (opt-in,
915
+ // default OFF — it needs a baked `sema-code-lsp` template) and the host lane (opt-out, default ON — it needs
916
+ // nothing and degrades to grep/read), i.e. one env name with two OPPOSITE defaults and no way to silence the
917
+ // host lane without also naming the sandbox knob. `LSP_HOST_ENABLED` now owns the host lane; an explicit
918
+ // `LSP_ENABLED=false` keeps working as a host opt-out (it was the only one operators ever had) with a notice
919
+ // naming the replacement, and `LSP_HOST_ENABLED` always wins when set.
920
+ const lspHostEnabled = (() => {
921
+ // server 3.0.0([1989],不做兼容):`LSP_ENABLED=false` 的 host 腿别名形 = fail-loud 墓碑。
922
+ // `LSP_ENABLED=true`(沙箱腿 opt-in 正名)不受影响——墓碑只咬「用沙箱名关 host 腿」的旧姿势。
923
+ if (process.env.LSP_ENABLED === "false") {
924
+ throw new Error("LSP_ENABLED=false no longer drives the HOST lane (removed in server 3.0.0) — set LSP_HOST_ENABLED=false " +
925
+ "for the host lane; LSP_ENABLED remains the SANDBOX-lane opt-in only (migration table in USAGE.md)");
926
+ }
927
+ return boolEnv("LSP_HOST_ENABLED", true);
928
+ })();
929
+ return {
817
930
  remoteExec: process.env.REMOTE_EXEC === "e2b" && process.env.E2B_API_KEY
818
931
  ? {
819
932
  provider: "e2b",
@@ -940,121 +1053,6 @@ export function loadConfig() {
940
1053
  ...(process.env.WORKTREE_BASE_COMMIT ? { commit: process.env.WORKTREE_BASE_COMMIT } : {}),
941
1054
  }
942
1055
  : undefined,
943
- dbBackend,
944
- dbBackendExplicit: dbBackendSet,
945
- localDataRoot,
946
- tidb: needsDb && dbBackend === "mysql"
947
- ? {
948
- host: env2("MYSQL_HOST", "TIDB_HOST"),
949
- // default port follows the env family in use: MYSQL_HOST → 3306 (stock MySQL), TIDB_HOST → 4000 (TiDB)
950
- port: Number(env2("MYSQL_PORT", "TIDB_PORT", process.env.MYSQL_HOST ? "3306" : "4000")),
951
- user: env2("MYSQL_USER", "TIDB_USER"),
952
- password: env2("MYSQL_PASSWORD", "TIDB_PASSWORD", ""),
953
- database: env2("MYSQL_DATABASE", "TIDB_DATABASE"),
954
- connectionLimit: process.env.MYSQL_POOL_SIZE || process.env.TIDB_POOL_SIZE
955
- ? Number(process.env.MYSQL_POOL_SIZE || process.env.TIDB_POOL_SIZE)
956
- : undefined,
957
- }
958
- : undefined,
959
- pg: needsDb && dbBackend === "pg"
960
- ? {
961
- host: env("PG_HOST"),
962
- port: Number(env("PG_PORT", "5432")),
963
- user: env("PG_USER"),
964
- password: env("PG_PASSWORD", ""),
965
- database: env("PG_DATABASE"),
966
- connectionLimit: process.env.PG_POOL_SIZE ? Number(process.env.PG_POOL_SIZE) : undefined,
967
- }
968
- : undefined,
969
- // S9 deeper fix: per-query DB timeout (see the interface doc). Soft knob — a typo degrades to the default
970
- // posture (pool-wide off / counter-family 30s) with an S20 boot warning, never NaN into a driver timer.
971
- dbQueryTimeoutMs: optFinitePositiveEnv("DB_QUERY_TIMEOUT_MS"),
972
- // E19/2c snapshot-BLOB object-store offload (clay 2026-06-26). Reuses the SAME adapter-held MinIO creds as the k8s
973
- // workspace-snapshot lane (secret minio-agent-worker); all three keys required to enable, else the file-snapshot
974
- // stores keep the SQL `snapshot_blob` default (byte-identical to today). Bucket = SESSION_SNAPSHOT_BUCKET (default
975
- // "session-snapshots"); keyPrefix optional (default "blobs/" in MinioBlobBackend).
976
- snapshotBlobStore: process.env.MINIO_ENDPOINT && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY
977
- ? {
978
- endpoint: process.env.MINIO_ENDPOINT,
979
- bucket: process.env.SESSION_SNAPSHOT_BUCKET ?? "session-snapshots",
980
- accessKey: process.env.MINIO_ACCESS_KEY,
981
- secretKey: process.env.MINIO_SECRET_KEY,
982
- ...(process.env.MINIO_REGION ? { region: process.env.MINIO_REGION } : {}),
983
- ...(process.env.SESSION_SNAPSHOT_PREFIX ? { keyPrefix: process.env.SESSION_SNAPSHOT_PREFIX } : {}),
984
- // Fail-SAFE parse (finding 20): a non-numeric/non-positive value → omit (the MinioBlobBackend default 3600s
985
- // applies), never NaN (which would sign every URL with X-Amz-Expires=NaN → total snapshot outage).
986
- ...((ttl) => (ttl !== undefined ? { presignTtlSec: ttl } : {}))(optFinitePositiveEnv("SESSION_SNAPSHOT_TTL_SEC")),
987
- }
988
- : undefined,
989
- // (a+c)(clay 2026-07-27)snapshot_blob 包墙修复两旋钮——语义见 ServiceConfig 字段注释。
990
- snapshotBlobSqlMaxBytes: optFinitePositiveEnv("SNAPSHOT_BLOB_SQL_MAX_BYTES"),
991
- snapshotBlobAllowSql: boolEnv("SNAPSHOT_BLOB_ALLOW_SQL_BYTES", false),
992
- // plugins 域 clone host 白名单(契约 [1361]①;缺省 github.com,逗号扩 https 镜像域)。
993
- pluginsAllowHosts: (process.env.PLUGINS_ALLOW_HOSTS ?? "github.com").split(",").map((h) => h.trim()).filter(Boolean),
994
- // 绑址([1934]):BIND_HOST 优先,HOST 兼容(cli/桌面传的就是它);空串=未设。
995
- bindHost: process.env.BIND_HOST || process.env.HOST || undefined,
996
- // D-1 孤儿对象 GC grace(复审 F10;0=关掉本腿,负/坏值走默认)。
997
- attachmentOrphanGraceMs: ((v) => (v !== undefined && Number.isFinite(v) && v >= 0 ? v : 3_600_000))(process.env.ATTACHMENT_ORPHAN_GRACE_MS !== undefined ? Number(process.env.ATTACHMENT_ORPHAN_GRACE_MS) : undefined),
998
- // workspace 浏览面(#3,[1894]①)单文件读上限。
999
- workspaceFileMaxBytes: optFinitePositiveEnv("WORKSPACE_FILE_MAX_BYTES") ?? 8 * 1024 * 1024,
1000
- // SendUserFile 双轨公网直链(同三键开闸;S3_ENDPOINT 兼容位=外接 S3 场景内网/公网同址)。
1001
- // `||` 非 `??`(配置面自查 2026-07-14):env 惯例(env() helper 同款)空串=未设——`?? ` 会让
1002
- // 一个显式置空的 MINIO_ENDPOINT/S3_PUBLIC_ENDPOINT 把后备名吞掉(compose `${X:-}` 透传空串是常态)。
1003
- sendUserFile: (process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT) && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY
1004
- ? {
1005
- endpoint: (process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT),
1006
- ...(process.env.S3_PUBLIC_ENDPOINT || process.env.S3_ENDPOINT
1007
- ? { publicEndpoint: (process.env.S3_PUBLIC_ENDPOINT || process.env.S3_ENDPOINT) }
1008
- : {}),
1009
- publicBucket: process.env.S3_PUBLIC_BUCKET ?? "sema-public",
1010
- privateBucket: process.env.SESSION_SNAPSHOT_BUCKET ?? "session-snapshots",
1011
- privateKeyPrefix: "sendfile/",
1012
- ...(process.env.SEND_USER_FILE_SANDBOX_PUT_ENDPOINT ? { sandboxPutEndpoint: process.env.SEND_USER_FILE_SANDBOX_PUT_ENDPOINT } : {}),
1013
- accessKey: process.env.MINIO_ACCESS_KEY,
1014
- secretKey: process.env.MINIO_SECRET_KEY,
1015
- ...(process.env.MINIO_REGION ? { region: process.env.MINIO_REGION } : {}),
1016
- // fail-SAFE:非法值回落 0(永久轨缺省),不 NaN 进签名。
1017
- defaultTtlSec: ((v) => (v !== undefined && Number.isInteger(v) && v >= 0 ? v : 0))(process.env.SEND_USER_FILE_URL_TTL !== undefined ? Number(process.env.SEND_USER_FILE_URL_TTL) : undefined),
1018
- }
1019
- : undefined,
1020
- authToken: process.env.SERVICE_AUTH_TOKEN,
1021
- authTokens,
1022
- allowUnauthedWrites: boolEnv("ALLOW_UNAUTHED_WRITES", false),
1023
- metricsToken: process.env.METRICS_TOKEN || undefined,
1024
- traceToken: process.env.TRACE_TOKEN || undefined,
1025
- corsOrigins: (process.env.CORS_ORIGIN ?? "").split(",").map((s) => s.trim()).filter(Boolean),
1026
- attachmentMaxBytes: Math.max(1024, numEnv("ATTACHMENT_MAX_BYTES", String(32 * 1024 * 1024))),
1027
- ...(process.env.ATTACHMENT_MIME_ALLOWLIST
1028
- ? { attachmentMimeAllowlist: process.env.ATTACHMENT_MIME_ALLOWLIST.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean) }
1029
- : {}),
1030
- attachmentUnboundTtlMs: Math.max(60_000, numEnv("ATTACHMENT_UNBOUND_TTL_MS", String(24 * 3600 * 1000))),
1031
- principalHeader: headerNameEnv("PRINCIPAL_HEADER", "x-agent-principal"),
1032
- requirePrincipal,
1033
- // Runtime governance second baton (center §10): AUTONOMY env for the env/local path; sema-registry overlays
1034
- // it (applyRuntimeHot). commandPolicy is structured config — sema-registry-only (no flat env scalar form).
1035
- autonomy: parseAutonomy(process.env.AUTONOMY),
1036
- approvalRequire: csv("APPROVAL_REQUIRE"),
1037
- operatorPrincipals: csv("OPERATOR_PRINCIPALS"),
1038
- cascadeLadder: csv("MODEL_CASCADE_LADDER"),
1039
- approvalDeny: csv("APPROVAL_DENY"),
1040
- approvalPollMs: Number(env("APPROVAL_POLL_MS", "1500")),
1041
- durableApproval: boolEnv("DURABLE_APPROVAL", false),
1042
- resourceSuspend: boolEnv("RESOURCE_SUSPEND", false),
1043
- resourceSuspendTtlSec: Math.max(0, numEnv("RESOURCE_SUSPEND_TTL_SEC", "0")), // 0 ⇒ core default (30d)
1044
- approvalAutoBudget: Math.min(10000, Math.max(0, numEnv("APPROVAL_AUTO_BUDGET", "0"))), // clamp [0,10000]; 0 = off
1045
- approvalNeverAuto: csv("APPROVAL_NEVER_AUTO"),
1046
- approvalHmacKeys: dgHmacKeys, // D-G: WIRED (see the field's JSDoc); empty ⇒ inactive, not unimplemented
1047
- directApprovalDoor,
1048
- principalJwtPubkeys,
1049
- principalJwtIss,
1050
- principalJwtAud,
1051
- principalJwtMaxTtlSec,
1052
- directDoorActive,
1053
- infraCostRates: {
1054
- toolCallMicroUsd: Math.max(0, numEnv("INFRA_COST_TOOL_CALL", "0")),
1055
- sandboxSecMicroUsd: Math.max(0, numEnv("INFRA_COST_SANDBOX_SEC", "0")),
1056
- egressGbMicroUsd: Math.max(0, numEnv("INFRA_COST_EGRESS_GB", "0")),
1057
- },
1058
1056
  leaderEnabled: boolEnv("LEADER_ENABLED", false),
1059
1057
  leaderFanoutEnabled: boolEnv("LEADER_FANOUT_ENABLED", true),
1060
1058
  routerEnabled: boolEnv("ROUTER_ENABLED", false), // A value router; default OFF (ship dark, enable after live validation)
@@ -1062,13 +1060,12 @@ export function loadConfig() {
1062
1060
  forkEnabled: postureOn("FORK_ENABLED"), // §4 CC /fork posture: single-user turnkey → default-ON for the LLM; multi-tenant honored only with a per-principal entitlement resolver (core enforces allowFork, registry-core 0.1.47; enableForkFromBody)
1063
1061
  experimentalObserverAgents: boolEnv("EXPERIMENTAL_OBSERVER_AGENTS", false), // observer 开闸线:显式 "true" 才开(实验面,core ships dark——非 postureOn);多租户只认 center caps,env 被忽略(applyObserverEnvOptIn boot warn)
1064
1062
  schedulerEnabled: postureOn("SCHEDULER_ENABLED"), // R7 自唤醒 posture: single-user turnkey → ON (CC self-wake); multi-tenant routes to center (opt-in)
1065
- projectMemoryEnabled, // design/113 C4 opt-out, flipped positive in design/158 B4 (legacy PROJECT_MEMORY_DISABLED still honored)
1066
1063
  planModeEnabled: boolEnv("PLAN_MODE_ENABLED", true), // EnterPlanMode (core 1.167) model-driven plan; 🆕 default ON (resident — clay), PLAN_MODE_ENABLED=false opts out
1067
1064
  ...(process.env.SCHEDULER_STORE_PATH ? { schedulerStorePath: process.env.SCHEDULER_STORE_PATH } : {}),
1068
- configBootFetchBudgetMs: Math.max(0, numEnv("CONFIG_BOOT_FETCH_BUDGET_MS", "1500")), // boot 首拉抢答窗;过窗转后台补齐(clay 2026-07-17:本地 5s 启动=黑洞中心同步等待)
1069
1065
  schedulerSessionWakeup: boolEnv("SCHEDULER_SESSION_WAKEUP", true), // [1009]② host-signal: default ON, the spawning shell opts a daemon-less engine out (PLAN_MODE_ENABLED 同形解析)
1070
1066
  selfOrchestrationModels: csv("SELF_ORCHESTRATION_MODELS"),
1071
1067
  selfOrchestrationWorkerIsolation: boolEnv("SELF_ORCHESTRATION_WORKER_ISOLATION", false),
1068
+ ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}),
1072
1069
  workflowRunStoreBackend: enumEnv("WORKFLOW_RUN_STORE", "auto", ["auto", "file", "memory"]), // SVC-1 + P1: auto = SQL backend when present (cross-replica), else File
1073
1070
  workflowOrphanGraceMs: Math.max(60_000, numEnv("WORKFLOW_ORPHAN_GRACE_MS", String(24 * 60 * 60 * 1000))), // SVC-1: 24h default, floor 1m
1074
1071
  workflowJournalRetentionMs: Math.max(60_000, numEnv("WORKFLOW_JOURNAL_RETENTION_MS", String(7 * 24 * 60 * 60 * 1000))), // SVC-2: 7d default, floor 1m
@@ -1088,37 +1085,9 @@ export function loadConfig() {
1088
1085
  sessionAutoTitle: boolEnv("SESSION_AUTO_TITLE", true), // session auto-title: default ON (one cheap call per session)
1089
1086
  selectEnvironmentTool: boolEnv("SELECT_ENVIRONMENT_TOOL", true), // RFC A2: default ON (mount additionally gated on k8s+catalog)
1090
1087
  envFactsEnabled: boolEnv("SANDBOX_ENV_FACTS", true), // RFC A1: default ON (core 1.240.0 TaskSpec.envFacts; no-facts = no field)
1091
- // ③ sensitive-path write deny set: unset = core's recommended set; explicit value = full replacement
1092
- // (comma-separated); "off"/empty = disabled. Set curation is core's (成文 core-side); server passes through.
1093
- sensitiveWritePatterns: ((raw) => {
1094
- if (raw === undefined)
1095
- return [...RECOMMENDED_SENSITIVE_PATTERNS];
1096
- const trimmed = raw.trim();
1097
- if (trimmed === "" || trimmed.toLowerCase() === "off")
1098
- return [];
1099
- return trimmed.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
1100
- })(process.env.SENSITIVE_WRITE_PATTERNS),
1101
- // [1557]§四: opt-in only — any value other than the two real gate shapes (incl. "off"/unset) stays undefined
1102
- // (no field on the config object at all), so the fs-write gate wiring never even considers it.
1103
- ...(process.env.MANUAL_MODE_SHELL_GATE === "always" || process.env.MANUAL_MODE_SHELL_GATE === "classify"
1104
- ? { manualModeShellGate: process.env.MANUAL_MODE_SHELL_GATE }
1105
- : {}),
1106
1088
  toolDeferLongtail: boolEnv("TOOL_DEFER_LONGTAIL", false), // [803]④ defer face: EXPERIMENTAL, default OFF
1107
1089
  lspEnabled: boolEnv("LSP_ENABLED", false), // sandbox lane: opt-in (needs a baked sema-code-lsp template)
1108
1090
  lspHostEnabled, // host lane: LSP_HOST_ENABLED, DEFAULT ON (CC-parity, degrades gracefully) — see the derivation above
1109
- drainGraceMs: Math.max(10_000, numEnv("DRAIN_GRACE_MS", String(10 * 60 * 1000))), // SIGTERM drain window, default 10min, floor 10s
1110
- sighupIdleGraceMs: Math.max(5_000, numEnv("SIGHUP_IDLE_GRACE_MS", String(120_000))), // B3: SIGHUP idle-shutdown window, default 2min, floor 5s
1111
- mcpElicitation: {
1112
- enabled: boolEnv("MCP_ELICITATION_ENABLED", false), // E23 inbound elicitation; default OFF (fail-closed)
1113
- throttle: {
1114
- maxConcurrentPerRun: numEnvBounded("MCP_ELICITATION_MAX_CONCURRENT", String(DEFAULT_ELICITATION_THROTTLE.maxConcurrentPerRun), 1, 64),
1115
- maxTotalPerRun: numEnvBounded("MCP_ELICITATION_MAX_TOTAL", String(DEFAULT_ELICITATION_THROTTLE.maxTotalPerRun), 1, 10_000),
1116
- minIntervalMsPerServer: numEnvBounded("MCP_ELICITATION_MIN_INTERVAL_MS", String(DEFAULT_ELICITATION_THROTTLE.minIntervalMsPerServer), 0, 600_000),
1117
- ttlMs: numEnvBounded("MCP_ELICITATION_TTL_MS", String(DEFAULT_ELICITATION_THROTTLE.ttlMs), 1_000, 3_600_000),
1118
- },
1119
- },
1120
- askQuestionEnabled: postureOn("ASK_QUESTION_ENABLED"), // §4④ AskUserQuestion live HITL; posture-gated (single-user → ON)
1121
- toolApprovalEnabled: postureOn("TOOL_APPROVAL_ENABLED"), // [816]/[820]② live tool-approval HITL; posture-gated (single-user → ON), mirrors askQuestion
1122
1091
  workflowAgentsReadOnly: boolEnv("WORKFLOW_AGENTS_READONLY", false), // [824]① TOB 保守旋钮:默认 off = workflow 子 agent 同权(CC parity)
1123
1092
  imageBakes: {
1124
1093
  enabled: boolEnv("IMAGE_BAKES_ENABLED", false),
@@ -1131,9 +1100,28 @@ export function loadConfig() {
1131
1100
  submitRateMax: Math.max(0, numEnv("BAKE_SUBMIT_RATE_MAX", "10")),
1132
1101
  submitRateWindowSec: Math.max(1, numEnv("BAKE_SUBMIT_RATE_WINDOW_SEC", "60")),
1133
1102
  },
1134
- toolTrace: boolEnv("TOOL_TRACE", false),
1135
- traceThinking: boolEnv("TRACE_THINKING", true), // default ON (clay)
1136
- logLevel: env("LOG_LEVEL", "info"),
1103
+ };
1104
+ }
1105
+ /** 域:limits+http(限额与 HTTP 面)—— 端口、附件/工作区体积与 TTL、成本与速率天花板、排空窗、回收周期。 */
1106
+ function parseLimitsHttpDomain() {
1107
+ return {
1108
+ port: Number(env("PORT", "8090")),
1109
+ // D-1 孤儿对象 GC grace(复审 F10;0=关掉本腿,负/坏值走默认)。
1110
+ attachmentOrphanGraceMs: ((v) => (v !== undefined && Number.isFinite(v) && v >= 0 ? v : 3_600_000))(process.env.ATTACHMENT_ORPHAN_GRACE_MS !== undefined ? Number(process.env.ATTACHMENT_ORPHAN_GRACE_MS) : undefined),
1111
+ // workspace 浏览面(#3,[1894]①)单文件读上限。
1112
+ workspaceFileMaxBytes: optFinitePositiveEnv("WORKSPACE_FILE_MAX_BYTES") ?? 8 * 1024 * 1024,
1113
+ attachmentMaxBytes: Math.max(1024, numEnv("ATTACHMENT_MAX_BYTES", String(32 * 1024 * 1024))),
1114
+ ...(process.env.ATTACHMENT_MIME_ALLOWLIST
1115
+ ? { attachmentMimeAllowlist: process.env.ATTACHMENT_MIME_ALLOWLIST.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean) }
1116
+ : {}),
1117
+ attachmentUnboundTtlMs: Math.max(60_000, numEnv("ATTACHMENT_UNBOUND_TTL_MS", String(24 * 3600 * 1000))),
1118
+ infraCostRates: {
1119
+ toolCallMicroUsd: Math.max(0, numEnv("INFRA_COST_TOOL_CALL", "0")),
1120
+ sandboxSecMicroUsd: Math.max(0, numEnv("INFRA_COST_SANDBOX_SEC", "0")),
1121
+ egressGbMicroUsd: Math.max(0, numEnv("INFRA_COST_EGRESS_GB", "0")),
1122
+ },
1123
+ drainGraceMs: Math.max(10_000, numEnv("DRAIN_GRACE_MS", String(10 * 60 * 1000))), // SIGTERM drain window, default 10min, floor 10s
1124
+ sighupIdleGraceMs: Math.max(5_000, numEnv("SIGHUP_IDLE_GRACE_MS", String(120_000))), // B3: SIGHUP idle-shutdown window, default 2min, floor 5s
1137
1125
  // BL-9: numEnv (not bare Number) — a non-numeric typo FAILS at startup instead of becoming NaN, which would
1138
1126
  // silently DISABLE the operator cost/rate ceiling (every `> ceiling` comparison against NaN is false).
1139
1127
  rateLimitPerMin: numEnv("RATE_LIMIT_RPM", "0"),
@@ -1141,23 +1129,19 @@ export function loadConfig() {
1141
1129
  maxTaskTokens: numEnv("MAX_TASK_TOKENS", "0"),
1142
1130
  maxPrincipalCostUsd: numEnv("MAX_PRINCIPAL_COST_USD", "0"),
1143
1131
  costQuotaWindowSec: numEnv("COST_QUOTA_WINDOW_SEC", "86400"),
1144
- degrade: process.env.MODEL_DEGRADE_TO
1145
- ? {
1146
- to: env("MODEL_DEGRADE_TO"),
1147
- atCostFraction: Number(env("MODEL_DEGRADE_AT_COST_FRACTION", "0.7")),
1148
- reactive: boolEnv("MODEL_DEGRADE_REACTIVE", false),
1149
- downgradeOn: csv("MODEL_DEGRADE_ON").length
1150
- ? csv("MODEL_DEGRADE_ON")
1151
- : undefined,
1152
- // vision precheck (adversarial-review finding): the degrade TARGET is an external gateway
1153
- // model NOT in config.models, so resolveSpec's precheck (which only sees the picked model) can't see its
1154
- // vision capability — a vision-capable main + text-only degrade target would let images reach the text-only
1155
- // gateway as the opaque 400 the precheck prevents, AFTER admission, at runtime degrade. Declare it via
1156
- // MODEL_DEGRADE_TO_VISION (strict "true"). DEFAULT false = assume the cheaper degrade target is text-only
1157
- // (the common case, e.g. deepseek), so resolveSpec drops degrade for image-carrying tasks (main.ts).
1158
- toSupportsImages: boolEnv("MODEL_DEGRADE_TO_VISION", false),
1159
- }
1160
- : undefined,
1132
+ reapIntervalSec: numEnv("REAP_INTERVAL_SEC", "60"),
1133
+ runStaleSec: numEnv("REAP_RUN_STALE_SEC", "120"),
1134
+ toolResultTtlSec: Number(env("TOOL_RESULT_TTL_SEC", "86400")),
1135
+ };
1136
+ }
1137
+ /** 域:observability(可观测)—— 指标/追踪令牌、工具与思考轨迹开关、日志档位、OTLP 出口。 */
1138
+ function parseObservabilityDomain() {
1139
+ return {
1140
+ metricsToken: process.env.METRICS_TOKEN || undefined,
1141
+ traceToken: process.env.TRACE_TOKEN || undefined,
1142
+ toolTrace: boolEnv("TOOL_TRACE", false),
1143
+ traceThinking: boolEnv("TRACE_THINKING", true), // default ON (clay)
1144
+ logLevel: env("LOG_LEVEL", "info"),
1161
1145
  otel: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
1162
1146
  ? {
1163
1147
  endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT.replace(/\/$/, ""),
@@ -1170,14 +1154,14 @@ export function loadConfig() {
1170
1154
  })),
1171
1155
  }
1172
1156
  : undefined,
1173
- // [875]a 成文:0(缺省)= durable HITL 无限期等人,时间型 reapSuspended 不跑;file/memory lane 的回收
1174
- // 探针只在 DURABLE_APPROVAL=true 时注入(无 durable 的部署两只 suspended 回收器恒 NO-OP,parked 行的
1175
- // 恢复把手 = POST /v1/runs/:id/cancel,[868]①)。checkpoint 过期驱动的那只不受本旋钮门控。
1176
- approvalTimeoutSec: numEnv("APPROVAL_TIMEOUT_SEC", "0"),
1177
- reapIntervalSec: numEnv("REAP_INTERVAL_SEC", "60"),
1178
- runStaleSec: numEnv("REAP_RUN_STALE_SEC", "120"),
1179
- syncImportLeaseStaleSec: Math.max(0, numEnv("SYNC_IMPORT_LEASE_STALE_SEC", "600")),
1180
- toolResultTtlSec: Number(env("TOOL_RESULT_TTL_SEC", "86400")),
1157
+ };
1158
+ }
1159
+ /** 域:misc-integration(外部集成)—— plugins clone 白名单、git/OA 面、场景与 skill 目录、sema-registry 接入。 */
1160
+ function parseIntegrationsDomain() {
1161
+ return {
1162
+ // plugins 域 clone host 白名单(契约 [1361]①;缺省 github.com,逗号扩 https 镜像域)
1163
+ pluginsAllowHosts: (process.env.PLUGINS_ALLOW_HOSTS ?? "github.com").split(",").map((h) => h.trim()).filter(Boolean),
1164
+ configBootFetchBudgetMs: Math.max(0, numEnv("CONFIG_BOOT_FETCH_BUDGET_MS", "1500")), // boot 首拉抢答窗;过窗转后台补齐(clay 2026-07-17:本地 5s 启动=黑洞中心同步等待)
1181
1165
  gitApiBaseUrl: process.env.GIT_API_BASEURL,
1182
1166
  gitApiToken: process.env.GIT_API_TOKEN,
1183
1167
  // BEHAVIOR CHANGE([891] clay 硬裁定):出厂缺省场景 default→code(CC 编码 persona 蒸馏版)——
@@ -1219,6 +1203,197 @@ export function loadConfig() {
1219
1203
  configLocalDir: process.env.CONFIG_LOCAL_DIR || undefined,
1220
1204
  };
1221
1205
  }
1206
+ const STORE_GROUP_KEYS = [
1207
+ "sessionBackend", "sessionCacheTtlSec", "rewindSnapshotMaxMb", "dbBackend", "dbBackendExplicit", "localDataRoot",
1208
+ "tidb", "pg", "dbQueryTimeoutMs", "snapshotBlobStore", "snapshotBlobSqlMaxBytes", "snapshotBlobAllowSql", "sendUserFile",
1209
+ ];
1210
+ const MODEL_PLANE_GROUP_KEYS = [
1211
+ "gatewayBaseUrl", "gatewayApiKey", "gatewayFallbackUrls", "anthropic", "resilience", "model", "models",
1212
+ "modelApiKeyEnv", "modelApiKeys", "modelQuotaWeights", "tiers", "projects", "roles", "cascadeLadder", "degrade",
1213
+ ];
1214
+ const APPROVAL_GROUP_KEYS = [
1215
+ "approvalRequire", "approvalDeny", "approvalPollMs", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto",
1216
+ "approvalHmacKeys", "durableApproval", "directApprovalDoor", "directDoorActive", "resourceSuspend",
1217
+ "resourceSuspendTtlSec", "askQuestionEnabled", "toolApprovalEnabled", "mcpElicitation", "sensitiveWritePatterns",
1218
+ "manualModeShellGate",
1219
+ ];
1220
+ const MEMORY_GROUP_KEYS = [
1221
+ "memoryEngineEnabled", "memoryEngineDir", "memoryEngineRemoteLaneAllowed", "memoryEngineBackend", "memoryScope",
1222
+ "memorySync", "projectMemoryEnabled", "syncImportLeaseStaleSec",
1223
+ ];
1224
+ const AUTH_GROUP_KEYS = [
1225
+ "authToken", "authTokens", "allowUnauthedWrites", "corsOrigins", "principalHeader", "requirePrincipal", "autonomy",
1226
+ "commandPolicy", "operatorPrincipals", "principalJwtPubkeys", "principalJwtIss", "principalJwtAud",
1227
+ "principalJwtMaxTtlSec", "bindHost",
1228
+ ];
1229
+ const ORCHESTRATION_GROUP_KEYS = [
1230
+ "remoteExec", "worktreeIsolation", "leaderEnabled", "leaderFanoutEnabled", "routerEnabled", "selfOrchestrationEnabled",
1231
+ "selfOrchestrationModels", "selfOrchestrationWorkerIsolation", "forkEnabled", "experimentalObserverAgents",
1232
+ "schedulerEnabled", "schedulerSessionWakeup", "schedulerStorePath", "planModeEnabled", "workflowRunStoreBackend",
1233
+ "workflowOrphanGraceMs", "workflowJournalRetentionMs", "workflowRunRetentionMs", "workflowAgentsReadOnly",
1234
+ "workflowSizeGuideline", "backgroundAgentRetentionMs", "backgroundAgentStaleRunningMs",
1235
+ "backgroundAgentParkClaimStaleMs", "rosterRetentionMs", "scratchpadSweepTtlMs", "sandboxPkgSource",
1236
+ "sessionAutoTitle", "selectEnvironmentTool", "envFactsEnabled", "toolDeferLongtail", "lspEnabled", "lspHostEnabled",
1237
+ "imageBakes",
1238
+ ];
1239
+ const LIMITS_HTTP_GROUP_KEYS = [
1240
+ "port", "attachmentOrphanGraceMs", "workspaceFileMaxBytes", "attachmentMaxBytes", "attachmentMimeAllowlist",
1241
+ "attachmentUnboundTtlMs", "infraCostRates", "drainGraceMs", "sighupIdleGraceMs", "rateLimitPerMin", "maxTaskCostUsd",
1242
+ "maxTaskTokens", "maxPrincipalCostUsd", "costQuotaWindowSec", "reapIntervalSec", "runStaleSec", "toolResultTtlSec",
1243
+ ];
1244
+ const OBSERVABILITY_GROUP_KEYS = ["metricsToken", "traceToken", "toolTrace", "traceThinking", "logLevel", "otel"];
1245
+ const INTEGRATIONS_GROUP_KEYS = [
1246
+ "pluginsAllowHosts", "mcpServers", "configBootFetchBudgetMs", "gitApiBaseUrl", "gitApiToken", "defaultScenario",
1247
+ "skillsDir", "oaApiBaseUrl", "oaServiceToken", "oaIssue", "configCenter", "configProvider", "configLocalDir",
1248
+ ];
1249
+ /** 组名 → 该组取景的平铺键(introspection 面:测试用它钉「每个平铺键恰好被一组取景」)。 */
1250
+ export const CONFIG_GROUP_KEYS = {
1251
+ store: STORE_GROUP_KEYS,
1252
+ modelPlane: MODEL_PLANE_GROUP_KEYS,
1253
+ approval: APPROVAL_GROUP_KEYS,
1254
+ memory: MEMORY_GROUP_KEYS,
1255
+ auth: AUTH_GROUP_KEYS,
1256
+ orchestration: ORCHESTRATION_GROUP_KEYS,
1257
+ limitsHttp: LIMITS_HTTP_GROUP_KEYS,
1258
+ observability: OBSERVABILITY_GROUP_KEYS,
1259
+ integrations: INTEGRATIONS_GROUP_KEYS,
1260
+ };
1261
+ /** 一组的**取景器**:每个键一个 accessor,读的时候才去平铺面取值。
1262
+ * ⚠️ 刻意不是 `{...pick(flat, keys)}` 的快照——快照会在 sema-registry 热应用(`config.model = …`、
1263
+ * `mutateInPlace(config.models, …)`)之后变成一份**静默过期**的旧值;取景器没有第二份存储,故不存在
1264
+ * 过期这个状态。`create*` 而非 `build*`:返回的是带行为(accessor)的活对象,不是纯数据。 */
1265
+ /** ⚠️ 语义边界(复审 2026-07-30 F4):组视图给**每个注册键**都装访问器,不看平铺面是否真有值——
1266
+ * `"tidb" in config.store` 恒 true、`Object.keys(config.store).length` 恒为注册表长度。本仓的
1267
+ * presence-as-semantics 判定(`"k" in x` / Object.keys 计数)**只能打平铺面**,组视图仅供点号读值。 */
1268
+ function createGroupView(flat, keys) {
1269
+ const view = {};
1270
+ for (const key of keys) {
1271
+ Object.defineProperty(view, key, { get: () => flat[key], enumerable: true, configurable: true });
1272
+ }
1273
+ return view;
1274
+ }
1275
+ /** 把九个组视图挂到平铺配置上(**就地**,返回的就是同一个对象——视图必须看着这一个存储处)。
1276
+ *
1277
+ * 组键是**不可枚举**的:`Object.keys(config)` / `JSON.stringify(config)` / `{...config}` 的平铺面因此
1278
+ * 逐字节不变(既有消费点、既有 dump/审计面零影响),代价是 spread 克隆不带组视图——克隆后要用组形,
1279
+ * 再过一次本函数(读一个缺席的组是当场 TypeError,不会静默给旧值)。
1280
+ *
1281
+ * `loadConfig` 是主调用点;测试夹具(test/helpers/make-config.ts)构造 `ServiceConfigFlat` 后同样过它。 */
1282
+ export function attachConfigGroups(flat) {
1283
+ const groups = {
1284
+ store: createGroupView(flat, STORE_GROUP_KEYS),
1285
+ modelPlane: createGroupView(flat, MODEL_PLANE_GROUP_KEYS),
1286
+ approval: createGroupView(flat, APPROVAL_GROUP_KEYS),
1287
+ memory: createGroupView(flat, MEMORY_GROUP_KEYS),
1288
+ auth: createGroupView(flat, AUTH_GROUP_KEYS),
1289
+ orchestration: createGroupView(flat, ORCHESTRATION_GROUP_KEYS),
1290
+ limitsHttp: createGroupView(flat, LIMITS_HTTP_GROUP_KEYS),
1291
+ observability: createGroupView(flat, OBSERVABILITY_GROUP_KEYS),
1292
+ integrations: createGroupView(flat, INTEGRATIONS_GROUP_KEYS),
1293
+ };
1294
+ for (const [name, view] of Object.entries(groups)) {
1295
+ Object.defineProperty(flat, name, { value: view, enumerable: false, writable: false, configurable: true });
1296
+ }
1297
+ return flat;
1298
+ }
1299
+ export function loadConfig() {
1300
+ CONFIG_WARNINGS.length = 0; // repeated loadConfig() calls (test setup) must not accumulate stale warnings
1301
+ CONFIG_NOTICES.length = 0;
1302
+ CONFIG_KNOBS.clear(); // the polarity table describes THIS load, never a previous one's env
1303
+ // Posture gate: single-user turnkey (`REQUIRE_PRINCIPAL !== "true"` = one trusted super-admin on their own
1304
+ // box/tenant, CC's trust model) ⇒ the CC capability set defaults ON, each composed with its infra prereq; multi-tenant
1305
+ // (requirePrincipal) stays opt-in (gated + secured). This is the SAME discriminator LSP's host-default-ON uses,
1306
+ // generalized into one posture switch (the PM's "not per-flag" ask). In `postureOn`, an explicit `X_ENABLED=true/false`
1307
+ // ALWAYS wins; else the default = single-user ∧ infra-ready. 🔒 Multi-tenant (prod) is UNAFFECTED (postureOn → false).
1308
+ // Parsed ONCE here (design/158 B4) instead of re-comparing `process.env.REQUIRE_PRINCIPAL` at the four sites
1309
+ // that needed it — two of which spelled it `!== "true"` and two `=== "true"`, i.e. the same knob read in both
1310
+ // polarities within one function. `boolEnv(x, false)` is value-identical to the old `=== "true"`.
1311
+ const requirePrincipal = boolEnv("REQUIRE_PRINCIPAL", false);
1312
+ const singleUserTurnkey = !requirePrincipal;
1313
+ // `postureOn` takes the NAME (not the pre-read value) so the knob can register itself in the polarity table —
1314
+ // the posture family is precisely the one whose default is NOT readable from its name, so leaving it out of the
1315
+ // self-report would omit the worst offenders. Its tri-state semantics are unchanged: an explicit literal wins,
1316
+ // anything else (including a typo — deliberately NOT warned here, unlike boolEnv) falls to the posture default.
1317
+ const postureOn = (name, infraReady = true) => {
1318
+ const envVal = process.env[name];
1319
+ const explicit = envVal === "true" ? true : envVal === "false" ? false : undefined;
1320
+ const value = explicit ?? (singleUserTurnkey && infraReady);
1321
+ registerKnob({ name, polarity: "posture", value, source: explicit === undefined ? "posture" : "env" });
1322
+ return value;
1323
+ };
1324
+ // ── 九域解析(纯解析)。调用次序贴合改前的 env 读序,故 CONFIG_NOTICES 的产出顺序逐条不变。
1325
+ const store = parseStoreDomain({ requirePrincipal });
1326
+ const modelPlane = parseModelDomain();
1327
+ const approval = parseApprovalDomain({ postureOn });
1328
+ const memory = parseMemoryDomain({ requirePrincipal });
1329
+ const auth = parseAuthDomain({ requirePrincipal });
1330
+ const orchestration = parseOrchestrationDomain({ postureOn });
1331
+ const limitsHttp = parseLimitsHttpDomain();
1332
+ const observability = parseObservabilityDomain();
1333
+ const integrations = parseIntegrationsDomain();
1334
+ // ── 跨域耦合与跨域不变量:两条门(D-G 直连门、bake 门)的判据各自横跨 approval/auth/orchestration 三域,
1335
+ // 任何单域都不足以裁定,故留在装配层(旧名保留,断言与消息逐字不变)。
1336
+ const { directApprovalDoor, durableApproval: dgDurable, approvalHmacKeys: dgHmacKeys } = approval;
1337
+ const { principalJwtPubkeys, principalJwtIss, principalJwtAud, operatorPrincipals: dgOperators, authTokens } = auth;
1338
+ const { enabled: imageBakesEnabled, runnerPrincipal: bakeRunnerPrincipal } = orchestration.imageBakes;
1339
+ // 🔴 the direct-door crypto gate lives ONLY in the durable (checkpointStore) /decide branch — so DURABLE_APPROVAL
1340
+ // is a HARD precondition. Without it, checkpointStore is undefined, that branch is skipped, and the LEGACY
1341
+ // approvalStore /decide (trusted-header, NO crypto) becomes the live path = a full bypass (adversarial CRITICAL).
1342
+ const directDoorActive = directApprovalDoor && dgDurable && dgHmacKeys.length > 0 && principalJwtPubkeys.length > 0 && !!principalJwtIss && !!principalJwtAud;
1343
+ // 🔴 boot invariant (D-G §3): a half-configured direct door is the WORST state (looks on, verifies nothing) — and
1344
+ // an empty OPERATOR_PRINCIPALS makes isOperator() true-for-all (= cross-tenant escalation). Refuse to start.
1345
+ if (directApprovalDoor) {
1346
+ const missing = [];
1347
+ if (!dgDurable)
1348
+ missing.push("DURABLE_APPROVAL=true (the direct door's crypto gate only exists on the durable /decide path)");
1349
+ if (dgHmacKeys.length === 0)
1350
+ missing.push("APPROVAL_HMAC_KEYS");
1351
+ if (principalJwtPubkeys.length === 0)
1352
+ missing.push("PRINCIPAL_JWT_PUBKEYS");
1353
+ if (!principalJwtIss)
1354
+ missing.push("PRINCIPAL_JWT_ISS");
1355
+ if (!principalJwtAud)
1356
+ missing.push("PRINCIPAL_JWT_AUD");
1357
+ if (dgOperators.length === 0)
1358
+ missing.push("OPERATOR_PRINCIPALS (else isOperator is true-for-all)");
1359
+ // 🔴 F-fix (dim-6): a direct door is inherently MULTI-TENANT (per-task verified JWT principal). Without
1360
+ // REQUIRE_PRINCIPAL=true an absent/invalid principal falls through as ANONYMOUS and the owner/quota checks
1361
+ // degrade to the spoofable header for null-owner rows — refuse a direct door that doesn't require a principal.
1362
+ if (!requirePrincipal)
1363
+ missing.push("REQUIRE_PRINCIPAL=true (a direct door is multi-tenant; without it an absent principal is anonymous and the spoofable header governs owner/quota checks)");
1364
+ if (missing.length) {
1365
+ throw new Error(`DIRECT_APPROVAL_DOOR=true but D-G anchors are missing: ${missing.join(", ")} — refusing to start a half-open direct door (design/80 D-G boot invariant)`);
1366
+ }
1367
+ }
1368
+ // 🔴 boot invariant (IMAGE-API-DESIGN.md §P2.4a): the bake door is build-host-RCE-capable, and isOperator([],p)
1369
+ // is true-for-all — so enabling bakes with an empty OPERATOR_PRINCIPALS = a WORLD-WRITABLE RCE door. Refuse to
1370
+ // start (never a half-open bake door). The explicit-operator gate in server.ts is the runtime twin of this.
1371
+ if (imageBakesEnabled && dgOperators.length === 0) {
1372
+ throw new Error("IMAGE_BAKES_ENABLED=true but OPERATOR_PRINCIPALS is empty — the bake door runs build.sh on the privileged build host; an empty operator set makes isOperator() true-for-all (world-writable RCE). Set OPERATOR_PRINCIPALS or disable bakes (IMAGE-API-DESIGN.md §P2.4a boot invariant)");
1373
+ }
1374
+ // 🔴 boot invariant (IMAGE-API-DESIGN.md §P2.12 / H1): the bake-runner identity is DERIVED from its bearer
1375
+ // credential (token-derived `source`), never a self-asserted header — so a SERVICE_AUTH_TOKENS entry whose
1376
+ // system == BAKE_RUNNER_PRINCIPAL MUST exist, else the runner can never satisfy isRunner and the entire
1377
+ // claim/ingest/heartbeat pipeline is silently dead. Refuse to start a bake door with no runner credential.
1378
+ if (imageBakesEnabled && !Object.values(authTokens).includes(bakeRunnerPrincipal)) {
1379
+ throw new Error(`IMAGE_BAKES_ENABLED=true but no SERVICE_AUTH_TOKENS entry maps to the runner principal '${bakeRunnerPrincipal}' — the bake-runner authenticates by its credential (token-derived source), so a '<BAKE_RUNNER_TOKEN>=${bakeRunnerPrincipal}' SERVICE_AUTH_TOKENS entry is required or claim/ingest/heartbeat all 403 (IMAGE-API-DESIGN.md §P2.12 boot invariant)`);
1380
+ }
1381
+ // 平铺面**单点展开**九域产物(唯一存储处),再挂上九个组视图。组视图是对这同一个对象的取景器,
1382
+ // 不是第二份拷贝 —— 故 `config.limitsHttp.port === config.port` 恒成立,含热应用之后(见
1383
+ // attachConfigGroups / config-types.ts 分组段)。
1384
+ return attachConfigGroups({
1385
+ ...store,
1386
+ ...modelPlane,
1387
+ ...approval,
1388
+ ...memory,
1389
+ ...auth,
1390
+ ...orchestration,
1391
+ ...limitsHttp,
1392
+ ...observability,
1393
+ ...integrations,
1394
+ directDoorActive, // 跨域合取(approval × auth × requirePrincipal),见上
1395
+ });
1396
+ }
1222
1397
  /** 有效监听绑址([1934])。**显式 `BIND_HOST`/`HOST` 恒生效**(operator 保留在任何形下明示暴露的
1223
1398
  * 权利);未显式时:**无鉴权写面 ⇒ 127.0.0.1**,其余 ⇒ undefined(Node 默认全接口,既有部署零影响)。
1224
1399
  *