@sema-agent/server 2.0.0 → 2.0.1

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