@sema-agent/server 3.1.0 → 3.2.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 (37) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +9 -9
  4. package/dist/approval-hmac.js +1 -2
  5. package/dist/bench/s1/live-deps.js +1 -1
  6. package/dist/boot/config-center.d.ts +1 -1
  7. package/dist/boot/config-center.js +6 -6
  8. package/dist/boot/resolve-spec.js +5 -5
  9. package/dist/brain.js +42 -9
  10. package/dist/config-center/apply-effective.d.ts +1 -1
  11. package/dist/config-center/apply-effective.js +4 -4
  12. package/dist/config-center/facade.d.ts +47 -0
  13. package/dist/config-center/facade.js +46 -0
  14. package/dist/config-center/http-client.d.ts +1 -1
  15. package/dist/config-center/http-client.js +6 -6
  16. package/dist/config-center/restart-signal.d.ts +1 -1
  17. package/dist/config-center/restart-signal.js +36 -16
  18. package/dist/config-center/skills-mcp.js +1 -1
  19. package/dist/config-center/types.d.ts +2 -2
  20. package/dist/config-provider.d.ts +7 -7
  21. package/dist/config-provider.js +9 -9
  22. package/dist/config.d.ts +1 -1
  23. package/dist/config.js +5 -5
  24. package/dist/http/routes/approvals-assistant.js +3 -3
  25. package/dist/http/routes/runs.js +7 -7
  26. package/dist/http/routes/sessions.js +1 -1
  27. package/dist/http/routes/trace-usage.js +2 -2
  28. package/dist/http/server.d.ts +1 -1
  29. package/dist/http/server.js +3 -3
  30. package/dist/key-resolver.d.ts +14 -0
  31. package/dist/key-resolver.js +27 -11
  32. package/dist/run-local.js +2 -2
  33. package/dist/runtime-caps-resolver.d.ts +1 -1
  34. package/dist/runtime-caps-resolver.js +1 -1
  35. package/dist/runtime-governance.d.ts +1 -1
  36. package/dist/runtime-governance.js +1 -1
  37. package/package.json +1 -1
package/README.md CHANGED
@@ -132,7 +132,7 @@ The server is configured entirely through environment variables. The most import
132
132
  | `PORT` | `8090` | HTTP listen port |
133
133
  | `BIND_HOST` (alias `HOST`) | see note | Listen address. An explicit value **always wins**. Default: `127.0.0.1` when the write face is unauthenticated (`ALLOW_UNAUTHED_WRITES=true` **and** no service token configured), otherwise all interfaces — deployments with a token are unaffected. |
134
134
  | `MODEL_GATEWAY_BASEURL` | `http://127.0.0.1:8000/v1` | OpenAI-compatible gateway base URL (without `/chat/completions`) |
135
- | `MODEL_ID` | **required** | Default model id — **no factory default since 3.0.0**. Unset ⇒ the server refuses to boot with a message naming the knob (the old baked-in default was an internal-only model name, so every external deployment failed later and further from the cause: a gateway `400` plus a cascade of title-hook warnings). Set it to whatever model name your gateway serves, or supply the catalog via the sema-registry control plane |
135
+ | `MODEL_ID` | **required** | Default model id — **no factory default since 3.0.0**. Unset ⇒ the server refuses to boot with a message naming the knob (the old baked-in default was an internal-only model name, so every external deployment failed later and further from the cause: a gateway `400` plus a cascade of title-hook warnings). Set it to whatever model name your gateway serves, or supply the catalog via the config-center control plane |
136
136
  | `MODEL_API_KEY` | — | Gateway API key (optional) |
137
137
  | `SERVICE_AUTH_TOKEN` | — | Callers must send `Authorization: Bearer <token>` |
138
138
  | `DB_BACKEND` | `local`* | `mysql` (any MySQL-protocol DB: MySQL/TiDB/MariaDB; `tidb` alias) / `pg` (PostgreSQL) / `local` (file-backed, no DB) / `memory` (explicit in-memory: nothing survives a restart, durable-runs faces 501). *Bare boot (no DB env at all) defaults to `local` so a single-user machine keeps its runs across restarts; any SQL signal (`SESSION_BACKEND` or `TIDB_/MYSQL_/PG_HOST`) keeps the `mysql` engine default, and `REQUIRE_PRINCIPAL=true` bare boots stay `memory` (the local file store has no tenant isolation — a warning says so). A DEFAULT-derived `local` that cannot create its data root degrades to memory with a warning + the `store_backend_degraded` gauge; an EXPLICIT `DB_BACKEND=local` fails loud instead. Setting `mysql`/`pg` explicitly also switches sessions to durable |
package/README.zh-CN.md CHANGED
@@ -125,7 +125,7 @@ curl -s localhost:8090/v1/tasks -H "Authorization: Bearer <SERVICE_AUTH_TOKEN>"
125
125
  | `PORT` | `8090` | HTTP 监听端口 |
126
126
  | `BIND_HOST`(兼容 `HOST`) | 见说明 | 监听地址。显式值**恒生效**。缺省:写面无鉴权时(`ALLOW_UNAUTHED_WRITES=true` **且**未配任何 service token)= `127.0.0.1`,否则全接口——配了 token 的部署不受影响。 |
127
127
  | `MODEL_GATEWAY_BASEURL` | `http://127.0.0.1:8000/v1` | OpenAI 兼容网关地址(不带 `/chat/completions`) |
128
- | `MODEL_ID` | **必填** | 缺省模型 id ——**3.0.0 起无出厂缺省**。未设 = 启动即失败并指路该旋钮(旧的烤死缺省是内网模型名,外部部署必炸且炸在离根因最远处:网关 `400` + 标题 hook 连环告警)。填你的网关真正提供的模型名,或改由 sema-registry 控制面下发目录 |
128
+ | `MODEL_ID` | **必填** | 缺省模型 id ——**3.0.0 起无出厂缺省**。未设 = 启动即失败并指路该旋钮(旧的烤死缺省是内网模型名,外部部署必炸且炸在离根因最远处:网关 `400` + 标题 hook 连环告警)。填你的网关真正提供的模型名,或改由配置控制面下发目录 |
129
129
  | `MODEL_API_KEY` | — | 网关 key(可选) |
130
130
  | `SERVICE_AUTH_TOKEN` | — | 调用方需带 `Authorization: Bearer <token>` |
131
131
  | `DB_BACKEND` | `mysql` | SQL 引擎:`mysql`(任何 MySQL 协议库:MySQL/TiDB/MariaDB;`tidb` 为兼容别名)/ `pg`(PostgreSQL)/ `local`(免 DB 文件持久化)。显式设置 `mysql`/`pg` 时 session 自动转 durable |
package/USAGE.md CHANGED
@@ -78,11 +78,11 @@ MODEL_DEGRADE_REACTIVE=true # ② 反应式(1.39):主模型 rate_limit
78
78
  MODEL_DEGRADE_ON=rate_limit,breaker_open # 可选,反应式触发器子集;默认两者都开
79
79
  ```
80
80
  - 反应式降级 brain 包在**最外层**(core council #8);fallback brain 用自己的凭据(decorator 清掉主模型的 per-call key 防泄漏给别的 provider)。**坑**:`MODEL_DEGRADE_TO` 最好别和被限流的是同一网关/账号,否则反应式切过去照样撞同一个 rate_limit。
81
- - **定价怎么设**:`model.cost` 来自 `MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE`(**USD per 1M tokens**,默认 0 = `costUsd` 读 0)。云模型(如 review-gw 的 deepseek-v4-pro)必须设,否则 spend 恒为 $0;本地自托管(qwen)留 0 即对(无 per-token 外部花费)。字段名是 `costUsd`,**非美元计价的网关要先折算**(如 DeepSeek 官方 CNY ÷ 汇率)。sema-registry 管的模型走 `CenterModel.cost`(中心存价、不存 secret)。
81
+ - **定价怎么设**:`model.cost` 来自 `MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE`(**USD per 1M tokens**,默认 0 = `costUsd` 读 0)。云模型(如 review-gw 的 deepseek-v4-pro)必须设,否则 spend 恒为 $0;本地自托管(qwen)留 0 即对(无 per-token 外部花费)。字段名是 `costUsd`,**非美元计价的网关要先折算**(如 DeepSeek 官方 CNY ÷ 汇率)。配置控制面管的模型走 `CenterModel.cost`(中心存价、不存 secret)。
82
82
  - 成本计量**自动开**:从 config 的 `model.cost`(per-1M 绝对 USD)注入 `pricing`,core 算出权威的整数 `costMicroUsd`(避免浮点累计误差)。`/metrics` 新增:`model_cost_micro_usd_total{model}`(覆盖所有 brain 调用=主任务+异步+council 子任务的总花费)、`brain_first_token_ms`(网关 hang 早警)、`brain_call_latency_ms`、`tool_calls_total{name,ok}`、`budget_exceeded_total{code}`、`cost_quota_rejected_total`、`degraded_total{reason}`(1.40 降级)。
83
83
  - **近预算降级 vs 硬闸**:降级(`MODEL_DEGRADE_TO`,到 `atCostFraction` 切便宜模型)是**撑长**预算、任务仍完成(出口质量下降、发 `task.degraded` 事件可告警);硬闸(`maxCostUsd` 全额)仍在,切了便宜模型还超全额 → `budget.exceeded` 停。
84
- - **`METRICS_TOKEN`**(可选,只读):设了它,`GET /metrics`+`/metrics/summary` 接受**它或** `SERVICE_AUTH_TOKEN`。**全 fleet 设同一个值** → 控制面(sema-registry)用**一个** token 拉所有 worker 的指标,**无需持有各 worker 的全权 `SERVICE_AUTH_TOKEN`**(不破坏 secret 边界)。即使泄露也只暴露指标(只读)。
85
- - **`GET /metrics/summary`**(token-gated,同 `/metrics`):`/metrics` 的**精炼 JSON**——`{model, runsActive, tasks{status}, tokensTotal, costUsd, costUsdByModel, taskDurationAvgSec, brainFirstTokenAvgMs, brainCallAvgMs, cacheHitRateAvg, rateLimited, costQuotaRejected, budgetExceeded, degraded, cascade, verifications, councilRuns, toolErrors}`。给**轻量 fleet 看板**用(sema-registry 的 fleet 页按 worker 拉、渲染卡片、按轮询算速率;**不用 Prometheus/Grafana**)。counter 是累计值、histogram 报均值。
84
+ - **`METRICS_TOKEN`**(可选,只读):设了它,`GET /metrics`+`/metrics/summary` 接受**它或** `SERVICE_AUTH_TOKEN`。**全 fleet 设同一个值** → 管理端(sema-web,规划名 sema-admin)用**一个** token 拉所有 worker 的指标,**无需持有各 worker 的全权 `SERVICE_AUTH_TOKEN`**(不破坏 secret 边界)。即使泄露也只暴露指标(只读)。
85
+ - **`GET /metrics/summary`**(token-gated,同 `/metrics`):`/metrics` 的**精炼 JSON**——`{model, runsActive, tasks{status}, tokensTotal, costUsd, costUsdByModel, taskDurationAvgSec, brainFirstTokenAvgMs, brainCallAvgMs, cacheHitRateAvg, rateLimited, costQuotaRejected, budgetExceeded, degraded, cascade, verifications, councilRuns, toolErrors}`。给**轻量 fleet 看板**用(管理端 sema-web 的 fleet 页按 worker 拉、渲染卡片、按轮询算速率;**不用 Prometheus/Grafana**)。counter 是累计值、histogram 报均值。
86
86
  - 预算闸是 core 强制的:`maxCostUsd` pre-call 估算(`budget.precall`,没花钱就拒)+ 流中途取消 + turn 边界(`budget.exceeded`);`maxTokens` 超 → `budget.exceeded`。**review 网关**建议设 `MAX_TASK_COST_USD` 防单任务烧掉共享云 key。
87
87
  - **per-principal 累计配额**:用 `AsyncLocalStorage` 把 principal 透传到 cost tracer,所以**council/team 子任务的花费也算到发起人头上**。**配了 `SESSION_BACKEND=tidb` 时自动跨副本共享**(`cost_quota` 表,写后聚合的**原子自增** `micro=micro+delta`,对齐固定窗,最终一致——多副本花费 SUM 到一起、不丢增量);否则 in-memory per-replica 滚动窗(单副本兜底)。启动日志 `costQuota` 字段回显 `shared(tidb)`/`in-process`/`off`。跨副本是最终一致(flush 间隔内峰值可能略超,由**硬 per-task `maxCostUsd` 兜底**)。
88
88
  - **`RATE_LIMIT_RPM` 请求限流同样自动跨副本**(`SESSION_BACKEND=tidb` 时,`rate_limit` 表,与配额共用 `WriteBehindCounter`;启动日志 `rateLimit` 回显)。注意:写后聚合 = **软限流**(边界上短暂略超 OK,适合公平/热调用方防护);要**硬合规上限**得另走 CAS/原子计数,不靠写后聚合——和断路器跨副本同款权衡。
@@ -130,7 +130,7 @@ MODEL_CODE_ROLES=default,subagent # 不设=全中立;仅这些角色在「
130
130
  MODEL_CASCADE_LADDER=deepseek-flash,deepseek-pro # 目录里的模型名,cheapest→strongest;不设=cascade 不可用
131
131
  ```
132
132
  - 结果带 `cascadeOutcome:"passed"|"exhausted"` + `escalated`/`finalRung`/`attempts[]`。`/metrics` 加 `cascade_total{outcome}`。
133
- - **每档用自己的上游 key**:从 sema-registry 模型的 `apiKeyEnv`(env 变量**名**,非密钥值)解析——同一网关下不同模型/账号各用各的 key,没配 `apiKeyEnv` 的模型回落到网关 key(`MODEL_API_KEY`)。`baseUrl` 仍由 brain 层统一持有(非目标)。启动日志 `perModelKeys=N` 报有几个模型带了自己的 key。
133
+ - **每档用自己的上游 key**:从配置控制面模型的 `apiKeyEnv`(env 变量**名**,非密钥值)解析——同一网关下不同模型/账号各用各的 key,没配 `apiKeyEnv` 的模型回落到网关 key(`MODEL_API_KEY`)。`baseUrl` 仍由 brain 层统一持有(非目标)。启动日志 `perModelKeys=N` 报有几个模型带了自己的 key。
134
134
 
135
135
  **嵌入形契约(1.309+;桌面宿主把引擎作为依赖内嵌启动的正门)**
136
136
  - 入口:`import "@sema-agent/server/main"`(exports 正门;此前宿主只能用 node_modules 路径字符串定位
@@ -184,16 +184,16 @@ SEND_USER_FILE_SANDBOX_PUT_ENDPOINT=… # 可选:沙箱直传 PUT 的端
184
184
  - **执行 lane 与租户门**:e2b/k8s 沙箱 lane 任意租户可用(沙箱文件系统=租户边界,沙箱内 `curl -T` 直传、字节不中转、凭据不进沙箱);host/ssh lane 仅单用户部署(`REQUIRE_PRINCIPAL` 未开)时挂载。
185
185
  - **web 侧感知**:`GET /v1/capabilities` 透出 `sendUserFile`(READY 语义:工具真挂载**且**公网端点在场——调用真能成功才 yes)与 `s3PublicEndpoint`(渲染用公网 base;绝不透出密钥/内网端点)。
186
186
 
187
- **可选 — 接 sema-registry(中心化模型/角色/团队配置)**
187
+ **可选 — 接配置控制面(中心化模型/角色/团队配置)**
188
188
  ```bash
189
189
  # 新名(orchestrator 现注入);旧名 CONFIG_CENTER_* 仍兼容(dual-read,新名优先、旧名回退)
190
- SEMA_REGISTRY_URL=http://<sema-registry-host>:3100 # 启动拉 GET /api/config/effective(Bearer+ETag),覆盖 env 兜底
191
- SEMA_REGISTRY_TOKEN=<SERVICE_PULL_TOKEN 的值> # 取自 sema-registry 主机 .env;只读拉取令牌
190
+ SEMA_REGISTRY_URL=http://<config-center-host>:3100 # 启动拉 GET /api/config/effective(Bearer+ETag),覆盖 env 兜底
191
+ SEMA_REGISTRY_TOKEN=<SERVICE_PULL_TOKEN 的值> # 取自配置控制面主机 .env;只读拉取令牌
192
192
  SEMA_REGISTRY_DRY_RUN=true # 安全灰度:只 LOG 中心配置 vs env 推导的差异,不 apply
193
193
  SEMA_REGISTRY_WORKER=<worker名> # 可选:拉取 /effective?worker=<名> 取该 worker 的 roster(reconciler 按 worker 注);不设=全局 roster(向后兼容)
194
194
  ```
195
195
  - 中心**空/未发布** → `applyEffective` 回落 env + 内建 teams 并 warn `config_center_unpublished`,**不影响在跑的服务**(接了也安全)。
196
- - **灰度姿势**(sema-registry AI 建议):先 `SEMA_REGISTRY_DRY_RUN=true` 起一轮,看日志 `sema_registry_dry_run`(中心给的 models/roles/teams + 会否覆盖 default、per-model apiKeyEnv)对得上 env 再去掉该 flag 真正 apply。
196
+ - **灰度姿势**(配置控制面 AI 建议):先 `SEMA_REGISTRY_DRY_RUN=true` 起一轮,看日志 `sema_registry_dry_run`(中心给的 models/roles/teams + 会否覆盖 default、per-model apiKeyEnv)对得上 env 再去掉该 flag 真正 apply。
197
197
  - 拉取**只读、只取逻辑配置**(模型名册/角色/团队);密钥/网关仍在本服务 env(中心只发 env-**名** 引用,不发密钥值)。models/roles 改动需重启生效,teams 60s 热刷。回滚=去掉 `SEMA_REGISTRY_URL` 即纯 env。
198
198
  - **仅 `/v1/tasks`(同步)+ `/v1/runs`(异步)**——`/v1/tasks/stream` 不支持(多次尝试非单流,400)。与 `verify` **互斥**(同时给 → 400)。
199
199
  - 成本上界 = 任务的 `maxCostUsd`(防冷重跑税)。**三条 core 警示**:① 每档**冷重跑**重付输入成本(便宜档常过才划算);② 门收到**未脱敏**输出(自定义门转发外部 verifier 要脱敏);③ **写工具会跑 N 次**——**只用于只读/幂等任务**(每次升级整任务重跑)。开放式任务(找全 bug/文笔)没有可判定 oracle、级联会空转,那种用 `team`(广度对抗)而非级联(深度阶梯)。
@@ -264,7 +264,7 @@ SEMA_REGISTRY_WORKER=<worker名> # 可选:拉取 /effective?worke
264
264
  | `sessionId` | — | 续聊:带上次返回的 `sessionId`,服务端自动 wake 历史 |
265
265
  | `images` | — | 图文输入 `[{data,mimeType}|{url}]`(模型需支持 vision) |
266
266
  | `attachmentIds` | — | D-1 通用文件上传(1.289+):先 `POST /v1/attachments?name=…`(raw body,content-type=mime)拿句柄,提交时引用 ≤16 个;文件物化到执行环境工作目录 `attachments/` 下,objective 尾部自动追加文件清单(内容不进会话流)。单文件缺省 ≤32 MiB(`ATTACHMENT_MAX_BYTES`);可配 mime 白名单(`ATTACHMENT_MIME_ALLOWLIST` CSV,缺省不限);上传后未引用的按 `ATTACHMENT_UNBOUND_TTL_MS`(缺省 24h)回收。**云形态(tidb/pg)字节本体存对象存储——MinIO 必配**(`MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY`,与快照 lane 同一组变量),未配则附件面 501;local 形走本地文件店。 |
267
- | `scenario` | — | `default`(默认)/ `code-review`(见 §5)/ `scan`(同 §5 的 repo 只读工具但**中性无框架提示词**——objective+中心下发 skill 全权主导输出,OA 扫描类用)/ **sema-registry 可声明任意新场景**(`{name, toolset: none\|repo-readonly, prompt?}`,组合即配置、能力钉死在部署;restart-to-apply;center 可覆盖内建名,boot 日志 `config_center_scenarios.shadowsBuiltin` 可审计) |
267
+ | `scenario` | — | `default`(默认)/ `code-review`(见 §5)/ `scan`(同 §5 的 repo 只读工具但**中性无框架提示词**——objective+中心下发 skill 全权主导输出,OA 扫描类用)/ **配置控制面可声明任意新场景**(`{name, toolset: none\|repo-readonly, prompt?}`,组合即配置、能力钉死在部署;restart-to-apply;center 可覆盖内建名,boot 日志 `config_center_scenarios.shadowsBuiltin` 可审计) |
268
268
  | `repo` / `council` / `debate` | — | `repo` 为 `code-review`/`scan` 必填;`council`/`debate` 仅 `code-review`,见 §5 |
269
269
  | ~~model / tools / prompt~~ | 🚫 | **不接受**——服务端注入 |
270
270
 
@@ -4,8 +4,7 @@
4
4
  * ./principal-jwt.ts imports {@link verifyApprovalHmac} from here for the combined proof).
5
5
  *
6
6
  * ✅ WIRED into the resolve path (was "🔴 SCAFFOLD — NOT YET WIRED" — that claim went STALE; a 2026-07-25 doc-rot
7
- * sweep traced the real call chain: {@link verifyApprovalHmac} ← `verifyDirectDoorProof` ← the live decide
8
- * routes at `http/server.ts:4796` and `:4907`). So "not wired" was wrong; "not turned on" is right, and turning it
7
+ * sweep traced the real call chain: {@link verifyApprovalHmac} ← `verifyDirectDoorProof` ← * routes at `http/server.ts`(resumeCheckpoint 链;A9 拆分后行级坐标弃用,文件级指路+grep verifyDirectDoorProof 取证). So "not wired" was wrong; "not turned on" is right, and turning it
9
8
  * on is a DEPLOYMENT + KEY-PROVISIONING task (sema-registry mints/rotates the set), not a coding task.
10
9
  *
11
10
  * FULL activation chain (an independent 2026-07-25 cross-check caught the first draft of this note naming only the
@@ -66,7 +66,7 @@ export function liveRuntimeConfigFromEnv(env = process.env) {
66
66
  if (!(costInput > 0) || !(costOutput > 0))
67
67
  return undefined;
68
68
  return {
69
- modelId: env.MODEL_ID || "deepseek-v4-pro",
69
+ modelId: env.MODEL_ID ?? (() => { throw new Error("MODEL_ID is required for the S1 bench harness (no factory default since server 3.0.0 — same invariant as loadConfig)"); })(),
70
70
  gatewayBaseUrl: env.MODEL_GATEWAY_BASEURL || "https://api.deepseek.com",
71
71
  deepseekApiKey,
72
72
  e2bApiKey,
@@ -6,7 +6,7 @@ import type { LoadedSkill } from "../capabilities/skills.js";
6
6
  import { type ServiceConfig } from "../config.js";
7
7
  import type { Logger } from "../observability/logger.js";
8
8
  import type { Metrics } from "../observability/metrics.js";
9
- import { type RestartSignal } from "../sema-registry.js";
9
+ import { type RestartSignal } from "../config-center/facade.js";
10
10
  export interface ConfigCenterRuntimeCtx {
11
11
  config: ServiceConfig;
12
12
  logger: Logger;
@@ -45,10 +45,10 @@ import { defaultLkgPath, defaultSkillCacheDir, saveLkg, loadLkg } from "../confi
45
45
  import { createConfigProvider, raceBootFetch, BOOT_FETCH_DEFERRED } from "../config-provider.js";
46
46
  import { createKeyResolver } from "../key-resolver.js";
47
47
  import { ensureSealedKeyStore, reportExecutionPublicKey } from "../sealed-key.js";
48
- import { applyEffective, mutateInPlace, logEffectiveDiff, applyCenterSkills, resolveMcpServers, restartReasons, modelPlaneChanged, planeHasActiveTiers, fetchPromptArtifact } from "../sema-registry.js";
48
+ import { applyEffective, mutateInPlace, logEffectiveDiff, applyCenterSkills, resolveMcpServers, restartReasons, modelPlaneChanged, planeHasActiveTiers, fetchPromptArtifact } from "../config-center/facade.js";
49
49
  export async function createConfigCenterRuntime(ctx) {
50
50
  const { config, logger, metrics, localRoot } = ctx;
51
- // Sema registry (sema-registry): pull the effective config on boot and apply it OVER the env
51
+ // Config-center: pull the effective config on boot and apply it OVER the env
52
52
  // defaults — env is the fallback. Unset or unreachable → keep the env-derived config. Applied BEFORE
53
53
  // the Runner is built so models/roles/teams take effect. (Logical config only; secrets stay in env.)
54
54
  let ccEtag;
@@ -81,10 +81,10 @@ export async function createConfigCenterRuntime(ctx) {
81
81
  // same-generation 200 recomputes reasons vs boot as [] and would otherwise silently clear the signal).
82
82
  // Set at the boot writer (R34 arms); folded into every tick's reasons; only a restart resolves it.
83
83
  let bootSkillStale = false;
84
- // Sema-registry restart signal: set by the refresh loop when a pull's restart-to-apply slices
84
+ // Config-center restart signal: set by the refresh loop when a pull's restart-to-apply slices
85
85
  // (skills/mcp/scenarios/runtime-gates) differ from boot; read by GET /health for orchestrator auto-restart.
86
86
  let pendingRestart;
87
- // DUAL-MODE-DESIGN §4: the config SOURCE is a backend-selectable seam — remote (sema-registry HTTP, the fleet
87
+ // DUAL-MODE-DESIGN §4: the config SOURCE is a backend-selectable seam — remote (config-center HTTP, the fleet
88
88
  // default) | local (.env + config.d via @sema-agent/registry-core FileConfigStore, the single-machine/TOC source).
89
89
  // The provider exists when SEMA_REGISTRY_URL (legacy CONFIG_CENTER_URL) is set OR CONFIG_PROVIDER=local is explicit; neither = pure-env
90
90
  // (byte-identical to before). The remote provider DELEGATES verbatim to fetchEffective, so the fleet path is
@@ -473,9 +473,9 @@ export async function createConfigCenterRuntime(ctx) {
473
473
  logger.warn("model_roster_pending", {
474
474
  note: "CONFIG_REQUIRE_ROSTER=true and no center roster has landed yet — the env MODEL_ID is treated as a PLACEHOLDER, so billable submissions 503 (and /health reports ready:false) until the first effective pull lands ≥1 enabled model. Unset the knob if this worker's env model is authoritative.",
475
475
  });
476
- // Per-model API key (sema-registry `apiKeyEnv`): resolve each model's own upstream key per brain call /
476
+ // Per-model API key (config-center `apiKeyEnv`): resolve each model's own upstream key per brain call /
477
477
  // cascade rung. undefined when no per-model keys are configured → spec field stays unset (core default).
478
- // `let` (not const): rebuilt on a sema-registry refresh so per-model key ADDITIONS hot-apply too (the
478
+ // `let` (not const): rebuilt on a config-center refresh so per-model key ADDITIONS hot-apply too (the
479
479
  // resolver is undefined when no per-model keys exist, so in-place mutation alone wouldn't cover empty→non-empty).
480
480
  let keyResolver;
481
481
  // 复审 2026-07-29 P1-11:restart 片里唯一一块 EffectiveConfig **看不见**的输入 —— 反应式降级车道由 env
@@ -40,7 +40,7 @@ import { isIsolatedExecEnv, routeServiceTask, supPostureOverrides } from "../rou
40
40
  import { gateExecutionLane } from "../runtime-caps-resolver.js";
41
41
  import { applyRuntimeGovernance, stripDelegationTools } from "../runtime-governance.js";
42
42
  import { HttpError, memorySpecForRequest } from "../security.js";
43
- import { mcpForScenario } from "../sema-registry.js";
43
+ import { mcpForScenario } from "../config-center/facade.js";
44
44
  import { normalizeAttachments, normalizeResilience, normalizeResumeAtMode, normalizeSuggestNextPrompts, promptProfileFromBody, resolveTaskLimits, retainBackgroundProcessesFromBody, taskAgentsSpecFragment, toolNameListFromBody } from "../spec-fields.js";
45
45
  import { cwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
46
46
  import { resolveRequestMcp } from "../task-mcp.js";
@@ -312,8 +312,8 @@ export function createResolveSpec(ctx) {
312
312
  // them to bounded text placeholders per current model (the pass-through posture above). That modality drop
313
313
  // was invisible fleet-side; count + warn at submit. modelSupportsImages is false-safe (undeclared input →
314
314
  // true → no signal), matching core's "never wrongly strip" posture — no false positives for verbatim
315
- // pass-through models. ⚠️ sema-registry lane caveat (review LOW-2): the roster maps `vision?: boolean` to
316
- // an EXPLICIT input list (sema-registry.ts) — a vision-capable roster model that FORGOT the flag becomes
315
+ // pass-through models. ⚠️ config-center lane caveat (review LOW-2): the roster maps `vision?: boolean` to
316
+ // an EXPLICIT input list (config-center/facade.ts) — a vision-capable roster model that FORGOT the flag becomes
317
317
  // declared text-only, so this signal fires AND core really does degrade its images (signal matches
318
318
  // behavior; the fix for that footgun is the roster entry, not this counter). The per-run durable event rides core's vision.placeholder trace kind once we
319
319
  // consume >=1.245 (the authoritative replacement-time signal), not this submit-time predictor.
@@ -586,7 +586,7 @@ export function createResolveSpec(ctx) {
586
586
  // L2 ultracode (design/111): the preset FLOORS thinking at the ultra tier (= max, via core resolveReasoningProfile);
587
587
  // awareness rides for free (core composes it from thinking∈{xhigh,max}). Else the explicit reasoningEffort.
588
588
  thinking: effectiveThinking(body.reasoningEffort, parsedSettings.settings?.ultracode === true),
589
- // Per-model auth (sema-registry apiKeyEnv): core calls this per brain call / cascade rung so each
589
+ // Per-model auth (config-center apiKeyEnv): core calls this per brain call / cascade rung so each
590
590
  // model uses its own upstream key; a model without one falls back to the gateway key. undefined
591
591
  // when no per-model keys are configured → unchanged single-key behavior.
592
592
  getApiKeyAndHeaders: getKeyResolver(), // A10 搬运改写②:活引用取值(原 `keyResolver`)
@@ -950,7 +950,7 @@ export function createResolveSpec(ctx) {
950
950
  throw new HttpError(400, "sandboxImageProfile is only supported on the k8s sandbox backend");
951
951
  }
952
952
  if (!imageIndex) {
953
- throw new HttpError(400, "sandboxImageProfile requires the image index (sema-registry backend not configured)");
953
+ throw new HttpError(400, "sandboxImageProfile requires the image index (config-center backend not configured)");
954
954
  }
955
955
  if (!auth?.sessionId) {
956
956
  throw new HttpError(400, "sandboxImageProfile requires a resolved session");
package/dist/brain.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createAnthropicBrain, createCircuitBreakerBrain, createDegradingBrain, createFailoverBrain, createOpenAIBrain, createRoutingBrain, } from "@sema-agent/core";
2
+ import { resolveModelApiKey } from "./key-resolver.js";
2
3
  const DEFAULT_RESILIENCE = {
3
4
  // Watchdog defaults ON — mirrors config.ts env defaults so a config object without a
4
5
  // resilience block gets the same posture. connect/firstToken per the [880] fleet audit
@@ -95,20 +96,52 @@ export function createBrain(config, deps = {}) {
95
96
  // credentials (the decorator clears the primary's per-call apiKey so it can't leak to a different
96
97
  // provider). fallbackModel resolved from the catalog.
97
98
  if (config.degrade?.reactive) {
98
- const fallbackModel = config.models[config.degrade.to];
99
+ const degradeTo = config.degrade.to;
100
+ const fallbackModel = config.models[degradeTo];
99
101
  if (fallbackModel) {
100
102
  // Cross-gateway degrade (audit B, brain.ts): the degrading decorator clears the per-call apiKey
101
103
  // (so the primary's key can't leak to another provider), which left the fallback authenticating
102
104
  // with the PRIMARY gateway key even when the catalog points the target at a different gateway.
103
- // Resolve the target's own key at construction via the existing per-model apiKeyEnv mechanism
104
- // (sema-registry catalog); `model.baseUrl` already wins inside the openai brain — pass it
105
- // explicitly so the route is visible here. Unset catalog baseUrl/apiKeyEnv = same-gateway degrade
106
- // (unchanged), which stays meaningful for per-model limits (e.g. deepseek pro→flash).
107
- const fbKeyEnv = config.modelApiKeyEnv?.[fallbackModel.name] ?? config.modelApiKeyEnv?.[config.degrade.to];
108
- const fbApiKey = (fbKeyEnv ? process.env[fbKeyEnv] : undefined) || config.gatewayApiKey;
105
+ // Resolve the target's own key via the SAME chain the main path uses; `model.baseUrl` already wins
106
+ // inside the openai brain — pass it explicitly so the route is visible here. No per-model key =
107
+ // same-gateway degrade (unchanged), which stays meaningful for per-model limits (e.g. deepseek pro→flash).
108
+ //
109
+ // 复审 2026-07-29(同族缺口): this resolution used to read `config.modelApiKeyEnv` ONLY — half of the
110
+ // main path's chain. A degrade target under sealed-box custody lands in `config.modelApiKeys` and, by the
111
+ // registry-core mutual-exclusion contract, NEVER in `modelApiKeyEnv` ⇒ the hop silently authenticated the
112
+ // target's own gateway with the PRIMARY gateway key (wrong key upstream, not fail-loud), and a poison pill
113
+ // (unsealable key) degraded to "no per-model key configured" = the exact silent gateway-account burn the
114
+ // poison pill exists to prevent. Now it calls `resolveModelApiKey` — one chain, one implementation:
115
+ // sealed → env-NAME reference → gateway key, poison throws.
116
+ const fbApiKey = () => {
117
+ // Two lookups for the same reason the pre-fix code had them: `degrade.to` may name the catalog's
118
+ // `default` alias, whose Model carries the REAL name that the key maps are keyed by (and vice versa on
119
+ // env-lane catalogs). Poison in either lookup throws — never falls through to the gateway key.
120
+ const own = resolveModelApiKey(fallbackModel.name, config.modelApiKeyEnv ?? {}, process.env, config.modelApiKeys ?? {}) ??
121
+ resolveModelApiKey(degradeTo, config.modelApiKeyEnv ?? {}, process.env, config.modelApiKeys ?? {});
122
+ return own ?? config.gatewayApiKey;
123
+ };
109
124
  const fallback = fallbackModel.provider === "anthropic" && anthropicBrain
110
- ? anthropicBrain
111
- : createOpenAIBrain({ baseUrl: fallbackModel.baseUrl || config.gatewayBaseUrl, apiKey: fbApiKey, maxRetries: 2, fetchImpl, ...timeouts });
125
+ ? // Cloud Anthropic target: the shared cloud route (its own ANTHROPIC_* credential) — a per-model key
126
+ // on an anthropic degrade target is NOT honored here (the hop shares the primary's brain instance).
127
+ // Named residual, not an oversight: the openai-compatible lane below is where catalog-directed
128
+ // per-model credentials live (every sema-registry `apiKeyEnv`/`sealedApiKey` deployment to date).
129
+ anthropicBrain
130
+ : createOpenAIBrain({
131
+ baseUrl: fallbackModel.baseUrl || config.gatewayBaseUrl,
132
+ // HOT by getter (core reads `config.apiKey` per request inside buildRequest, openai.js:296): the key
133
+ // VALUE tracks refreshes exactly like the main path's rebuilt keyResolver — `applyEffective`
134
+ // REASSIGNS config.modelApiKeys/modelApiKeyEnv, and this closure holds `config`, so it always reads
135
+ // the live tables. Freezing it at boot would make a rotated/newly-sealed key take effect only after a
136
+ // restart, silently. The STRUCTURAL half (target present/absent, its baseUrl/id/apiKeyEnv/sealedApiKey)
137
+ // stays boot-frozen by construction and is registered as the `degrade-route` restart slice.
138
+ get apiKey() {
139
+ return fbApiKey();
140
+ },
141
+ maxRetries: 2,
142
+ fetchImpl,
143
+ ...timeouts,
144
+ });
112
145
  brain = createDegradingBrain({ primary: brain, fallback, fallbackModel, downgradeOn: config.degrade.downgradeOn });
113
146
  }
114
147
  }
@@ -77,7 +77,7 @@ export declare function resolveDefaultModelName(eff: EffectiveConfig, has: (name
77
77
  };
78
78
  /**
79
79
  * Read-only comparison (SEMA_REGISTRY_DRY_RUN): log what the center config WOULD change vs the current
80
- * (env-derived) config, WITHOUT applying it. The safe-rollout step the sema-registry recommends — verify
80
+ * (env-derived) config, WITHOUT applying it. The safe-rollout step the config-center recommends — verify
81
81
  * the center's models/roles/teams match (or intentionally differ from) the env baseline before going live.
82
82
  */
83
83
  export declare function logEffectiveDiff(config: ServiceConfig, eff: EffectiveConfig, logger?: Logger): void;
@@ -2,7 +2,7 @@
2
2
  * EffectiveConfig application — mutate the boot `ServiceConfig` OVER with a pulled `EffectiveConfig`
3
3
  * (models/roles/tiers/projects `applyEffective`, the 6 runtime gates `applyRuntimeGates`, the per-request-hot
4
4
  * governance baton `applyRuntimeHot`) plus their shared helpers (`toModel`, `mutateInPlace`,
5
- * `resolveDefaultModelName`, `logEffectiveDiff`, `runtimeHasActiveGate`). Split out of `sema-registry.ts`
5
+ * `resolveDefaultModelName`, `logEffectiveDiff`, `runtimeHasActiveGate`). Split out of `facade.ts`
6
6
  * (design/158 A13, internal-lossless — the facade re-exports every symbol below unchanged).
7
7
  */
8
8
  import { isThinkingLevel } from "@sema-agent/core";
@@ -79,11 +79,11 @@ export function mutateInPlace(target, source) {
79
79
  Object.assign(target, source);
80
80
  }
81
81
  export function applyEffective(config, eff, logger, opts = {}) {
82
- // version 0 / empty effective = the sema-registry has nothing for us yet — typically CONFIG_PUBLISH_MODE
82
+ // version 0 / empty effective = the config-center has nothing for us yet — typically CONFIG_PUBLISH_MODE
83
83
  // is ON but nothing has been published. We do NOT wipe: models/roles fall back to env (the enabled>0
84
84
  // guard below), teams to BUILTIN_TEAMS (registerTeams resets to built-ins). Warn once at boot so the
85
85
  // operator knows their config isn't live (publish a version, or disable the publish gate). See the
86
- // sema-registry's ORCHESTRATOR-REVIEW §④ publish-gate contract.
86
+ // config-center's ORCHESTRATOR-REVIEW §④ publish-gate contract.
87
87
  if (!opts.teamsOnly && !eff.version) {
88
88
  logger?.warn("sema_registry_unpublished", {
89
89
  version: eff.version,
@@ -437,7 +437,7 @@ export function resolveDefaultModelName(eff, has, fallback, onDangling) {
437
437
  }
438
438
  /**
439
439
  * Read-only comparison (SEMA_REGISTRY_DRY_RUN): log what the center config WOULD change vs the current
440
- * (env-derived) config, WITHOUT applying it. The safe-rollout step the sema-registry recommends — verify
440
+ * (env-derived) config, WITHOUT applying it. The safe-rollout step the config-center recommends — verify
441
441
  * the center's models/roles/teams match (or intentionally differ from) the env baseline before going live.
442
442
  */
443
443
  export function logEffectiveDiff(config, eff, logger) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Config-center adapter — pull the effective config from the config-center control plane and
3
+ * apply it OVER the env-derived defaults (env = fallback, center = override). Follows the
4
+ * "universal internal schema → translate at the boundary" pattern, with our security boundary kept:
5
+ *
6
+ * (name history: this module tracks the control plane's product name, which has changed over time
7
+ * — registry → sema-web → planned sema-admin. The module itself keeps a neutral, product-name-free
8
+ * identifier ("config-center") and does not chase the rename.)
9
+ *
10
+ * - the CENTER owns the LOGICAL config: the model roster (names/capabilities/tier), the role map,
11
+ * and team templates;
12
+ * - the SERVICE env still owns the SECRETS: API keys stay in env (the center never stores a secret);
13
+ * keys are per-model via `apiKeyEnv` → `config.modelApiKeyEnv` → the spec's `getApiKeyAndHeaders`
14
+ * (core 1.45), so each model/cascade-rung authenticates with its own upstream key.
15
+ *
16
+ * Per-model `baseUrl` IS transported (a catalog model may live on a different gateway;
17
+ * core brain honors model.baseUrl, absent = "" = boot-env endpoint).
18
+ *
19
+ * Hot-reload status (复审 2026-07-29 P1-11 — 亲读判定,取代此处旧的 "models/roles are restart-to-apply"
20
+ * TODO, which went stale when `mutateInPlace` landed):
21
+ * - models/roles/roster/teams/projects/autonomy — **HOT**. `applyEffective` mutates `config.models`/
22
+ * `config.roles` IN PLACE, and core's Runner reads `this.deps.models/roles` per task off that very
23
+ * reference. Both brains re-resolve `model.baseUrl || config.baseUrl` inside `buildRequest()` on every
24
+ * call (core 2.1.0 `brain/openai.js` + `brain/anthropic.js`), so a moved gateway takes effect on the
25
+ * next task with no restart.
26
+ * - the same plane under a **tier-frozen** Runner — deferred, not hot: core expands a PRIVATE catalog copy
27
+ * at construction, so `main` defers the whole plane and the `models-tiers` restart slice signals.
28
+ * - **`degrade-route`** — the one genuinely boot-frozen catalog consumer left: reactive degrade
29
+ * (`MODEL_DEGRADE_REACTIVE`) bakes the target Model + its gateway + its key into the brain composition
30
+ * at boot. It cannot be hot-applied without rebuilding the brain, so it is registered on the existing
31
+ * restartRequired /health contract instead (see `restart-signal.ts`). Fail-loud beats serving a stale
32
+ * gateway silently on the rate-limit path.
33
+ * - skills/mcp/scenarios/runtime-gates — restart-to-apply by construction (baked into the boot wiring).
34
+ *
35
+ * (design/158 A13, internal-lossless) This module is now a FACADE: the implementation lives
36
+ * alongside it in `src/config-center/` split by responsibility group (HTTP client / EffectiveConfig
37
+ * application / restart-signal detection / skills+MCP consumption). Every symbol below is
38
+ * re-exported UNCHANGED — existing `from ".../config-center/facade.js"` imports need zero changes
39
+ * across a rename of this file's *contents*; a path rename (this move, 2026-07-29) does require the
40
+ * importers to update, which was done in the same commit.
41
+ */
42
+ export { fetchEffective, fetchPrincipalCaps, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
43
+ export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./apply-effective.js";
44
+ export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSliceCtx, type RestartSignal, } from "./restart-signal.js";
45
+ export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./skills-mcp.js";
46
+ export type { CenterSkillManifest, CenterMcpServer, EffectiveConfig, ExecutionRuling, SessionMirrorRuling, } from "./types.js";
47
+ //# sourceMappingURL=facade.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Config-center adapter — pull the effective config from the config-center control plane and
3
+ * apply it OVER the env-derived defaults (env = fallback, center = override). Follows the
4
+ * "universal internal schema → translate at the boundary" pattern, with our security boundary kept:
5
+ *
6
+ * (name history: this module tracks the control plane's product name, which has changed over time
7
+ * — registry → sema-web → planned sema-admin. The module itself keeps a neutral, product-name-free
8
+ * identifier ("config-center") and does not chase the rename.)
9
+ *
10
+ * - the CENTER owns the LOGICAL config: the model roster (names/capabilities/tier), the role map,
11
+ * and team templates;
12
+ * - the SERVICE env still owns the SECRETS: API keys stay in env (the center never stores a secret);
13
+ * keys are per-model via `apiKeyEnv` → `config.modelApiKeyEnv` → the spec's `getApiKeyAndHeaders`
14
+ * (core 1.45), so each model/cascade-rung authenticates with its own upstream key.
15
+ *
16
+ * Per-model `baseUrl` IS transported (a catalog model may live on a different gateway;
17
+ * core brain honors model.baseUrl, absent = "" = boot-env endpoint).
18
+ *
19
+ * Hot-reload status (复审 2026-07-29 P1-11 — 亲读判定,取代此处旧的 "models/roles are restart-to-apply"
20
+ * TODO, which went stale when `mutateInPlace` landed):
21
+ * - models/roles/roster/teams/projects/autonomy — **HOT**. `applyEffective` mutates `config.models`/
22
+ * `config.roles` IN PLACE, and core's Runner reads `this.deps.models/roles` per task off that very
23
+ * reference. Both brains re-resolve `model.baseUrl || config.baseUrl` inside `buildRequest()` on every
24
+ * call (core 2.1.0 `brain/openai.js` + `brain/anthropic.js`), so a moved gateway takes effect on the
25
+ * next task with no restart.
26
+ * - the same plane under a **tier-frozen** Runner — deferred, not hot: core expands a PRIVATE catalog copy
27
+ * at construction, so `main` defers the whole plane and the `models-tiers` restart slice signals.
28
+ * - **`degrade-route`** — the one genuinely boot-frozen catalog consumer left: reactive degrade
29
+ * (`MODEL_DEGRADE_REACTIVE`) bakes the target Model + its gateway + its key into the brain composition
30
+ * at boot. It cannot be hot-applied without rebuilding the brain, so it is registered on the existing
31
+ * restartRequired /health contract instead (see `restart-signal.ts`). Fail-loud beats serving a stale
32
+ * gateway silently on the rate-limit path.
33
+ * - skills/mcp/scenarios/runtime-gates — restart-to-apply by construction (baked into the boot wiring).
34
+ *
35
+ * (design/158 A13, internal-lossless) This module is now a FACADE: the implementation lives
36
+ * alongside it in `src/config-center/` split by responsibility group (HTTP client / EffectiveConfig
37
+ * application / restart-signal detection / skills+MCP consumption). Every symbol below is
38
+ * re-exported UNCHANGED — existing `from ".../config-center/facade.js"` imports need zero changes
39
+ * across a rename of this file's *contents*; a path rename (this move, 2026-07-29) does require the
40
+ * importers to update, which was done in the same commit.
41
+ */
42
+ export { fetchEffective, fetchPrincipalCaps, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
43
+ export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./apply-effective.js";
44
+ export { restartReasons, planeHasActiveTiers, modelPlaneChanged, } from "./restart-signal.js";
45
+ export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./skills-mcp.js";
46
+ //# sourceMappingURL=facade.js.map
@@ -33,7 +33,7 @@ export declare function fetchPrincipalCaps(baseUrl: string, token: string, princ
33
33
  executionDrift?: string;
34
34
  etag?: string;
35
35
  } | null>;
36
- /** An HTTP error from a sema-registry fetch that carries the response status so callers can branch on it
36
+ /** An HTTP error from a config-center fetch that carries the response status so callers can branch on it
37
37
  * (e.g. translate a 404 to "unknown hash → undefined") WITHOUT parsing the message string. */
38
38
  export declare class ConfigCenterHttpError extends Error {
39
39
  readonly status: number;
@@ -1,7 +1,7 @@
1
1
  /**
2
- * sema-registry HTTP client — the transport calls the service makes against the config-center
2
+ * config-center HTTP client — the transport calls the service makes against the config-center
3
3
  * (`fetchEffective`/`fetchPrincipalCaps`/`fetchSkillContent`/`fetchPromptArtifact`/`fetchPromptBlob`) plus
4
- * the shared HTTP-error type. Split out of `sema-registry.ts` (design/158 A13, internal-lossless — the
4
+ * the shared HTTP-error type. Split out of `facade.ts` (design/158 A13, internal-lossless — the
5
5
  * facade re-exports every symbol below unchanged).
6
6
  */
7
7
  import { createHash } from "node:crypto";
@@ -22,7 +22,7 @@ export async function fetchEffective(baseUrl, token, etag, fetchImpl = fetch, wo
22
22
  if (res.status === 304)
23
23
  return null;
24
24
  if (!res.ok)
25
- throw new Error(`sema-registry HTTP ${res.status}`);
25
+ throw new Error(`config-center HTTP ${res.status}`);
26
26
  return { effective: (await res.json()), etag: res.headers.get("etag") ?? undefined };
27
27
  }
28
28
  /**
@@ -62,11 +62,11 @@ export async function fetchPrincipalCaps(baseUrl, token, principal, etag, fetchI
62
62
  // misbehaving — DON'T treat it as "unchanged" (that surfaces undefined caps = no restriction = fail OPEN).
63
63
  // Throw so the resolver fail-closes (review B). With an etag, 304 = genuine "unchanged" → null.
64
64
  if (!etag)
65
- throw new Error("sema-registry returned 304 to a non-conditional principal-caps request");
65
+ throw new Error("config-center returned 304 to a non-conditional principal-caps request");
66
66
  return null;
67
67
  }
68
68
  if (!res.ok)
69
- throw new Error(`sema-registry principal-caps HTTP ${res.status}`);
69
+ throw new Error(`config-center principal-caps HTTP ${res.status}`);
70
70
  const body = (await res.json());
71
71
  return {
72
72
  runtimeCaps: body.runtimeCaps ?? null,
@@ -118,7 +118,7 @@ export async function fetchPrincipalCaps(baseUrl, token, principal, etag, fetchI
118
118
  etag: res.headers.get("etag") ?? undefined,
119
119
  };
120
120
  }
121
- /** An HTTP error from a sema-registry fetch that carries the response status so callers can branch on it
121
+ /** An HTTP error from a config-center fetch that carries the response status so callers can branch on it
122
122
  * (e.g. translate a 404 to "unknown hash → undefined") WITHOUT parsing the message string. */
123
123
  export class ConfigCenterHttpError extends Error {
124
124
  status;
@@ -15,7 +15,7 @@ export interface RestartSignal {
15
15
  restartRequired: true;
16
16
  /** Which restart-to-apply slices differ from the value baked at boot. */
17
17
  reasons: RestartSlice[];
18
- /** The sema-registry version that currently differs from boot (latest seen). */
18
+ /** The config-center version that currently differs from boot (latest seen). */
19
19
  version: number;
20
20
  /** Epoch ms when this restart first became required (stable across refreshes while `reasons` is unchanged). */
21
21
  since: number;
@@ -3,7 +3,7 @@
3
3
  * `restart`), computed against the process's BOOT config (not presence, else a refresh would re-fire the same
4
4
  * diff and loop-restart). Also owns the model-plane deferral predicates (`planeHasActiveTiers`/
5
5
  * `modelPlaneChanged`) main.ts uses to decide whether a hot-apply is safe under a tier-frozen Runner. Split out
6
- * of `sema-registry.ts` (design/158 A13, internal-lossless — the facade re-exports every symbol below unchanged).
6
+ * of `facade.ts` (design/158 A13, internal-lossless — the facade re-exports every symbol below unchanged).
7
7
  */
8
8
  import { resolveActiveTiers } from "@sema-agent/registry-core";
9
9
  import { RUNTIME_GATE_KEYS, runtimeGatePresent, resolveDefaultModelName } from "./apply-effective.js";
@@ -31,21 +31,37 @@ function enabledOnly(rows) {
31
31
  return null;
32
32
  return rows.filter((r) => r.enabled !== false);
33
33
  }
34
- /** The degrade hop is built from the FULLY-DERIVED Model (`toModel`), so almost every catalog field on the
35
- * target reaches it (id body.model, baseUrl the endpoint, providerwhich brain, maxTokens/compat/
36
- * extraBody/input → the request body, apiKeyEnvthe construction-time credential). We therefore fingerprint
37
- * the WHOLE entry minus the two fields that are provably HOT — `cost` (refreshed via
38
- * `mutateInPlace(pricing, buildPricing(config.models))`) and `quotaWeight` (via
39
- * `mutateInPlace(config.modelQuotaWeights, …)`) so a pure re-pricing never restarts a worker. A DENY-list
40
- * (not an allow-list) on purpose: a catalog field added later rides the fingerprint automatically, so the
41
- * failure mode of drift is a spurious restart, never a silently missed one. */
42
- function degradeRouteFingerprint(entry) {
43
- // `enabled` is dropped too, but for a different reason: `enabledOnly` already filtered on it, so among the
44
- // entries that reach here it is `true`-or-absent two encodings of ONE state. Keeping it would let a center
45
- // serializer that starts/stops emitting the explicit `true` manufacture a restart out of nothing.
34
+ /** One catalog entry, reduced to the fields whose change is genuinely restart-to-apply. Consumers of a
35
+ * boot-frozen catalog entry read almost every field on the FULLY-DERIVED Model (`toModel`) — id body.model,
36
+ * baseUrl → the endpoint, providerwhich brain, maxTokens/compat/extraBody/input the request body,
37
+ * apiKeyEnv/sealedApiKey → the credential — so we fingerprint the WHOLE entry minus the three provably HOT
38
+ * fields:
39
+ * - `cost` refreshed by `mutateInPlace(pricing, buildPricing(config.models))`, and core prefers
40
+ * `deps.pricing[model.id]` over the (possibly frozen) `Model.cost`; `toModel` always emits
41
+ * a cost object, so every center-lane model has a live pricing entry.
42
+ * - `quotaWeight` — refreshed by `mutateInPlace(config.modelQuotaWeights, …)`, read live per burn.
43
+ * - `enabled` `enabledOnly` already filtered on it, so among the entries that reach here it is
44
+ * `true`-or-absent: two encodings of ONE state. Keeping it would let a center serializer
45
+ * that starts/stops emitting the explicit `true` manufacture a restart out of nothing.
46
+ * Every real enable/disable FLIP changes the filtered SET, so it still signals.
47
+ * A DENY-list (not an allow-list) on purpose: a catalog field added later rides the fingerprint automatically,
48
+ * so the failure mode of drift is a spurious restart, never a silently missed one.
49
+ *
50
+ * Shared by BOTH catalog-entry fingerprints (`degrade-route`, `models-tiers` + `modelPlaneChanged`) since the
51
+ * 复审 2026-07-29 留档: models-tiers had kept all three, so on a tiers-active deployment a pure re-pricing /
52
+ * weight tweak / serializer writing `enabled:true` rolling-restarted the fleet. The two fingerprints must
53
+ * subtract the SAME set — under active tiers `modelPlaneChanged` defers the plane and the refresh loop then
54
+ * FORCE-pushes the `models-tiers` reason for a deferred candidate (boot/config-center.ts R15/R22), so
55
+ * subtracting in only one of them is either no-op (restart still fires) or incoherent (applied yet signalled). */
56
+ function catalogEntryFingerprint(entry) {
46
57
  const { cost: _cost, quotaWeight: _quotaWeight, enabled: _enabled, ...route } = entry;
47
58
  return route;
48
59
  }
60
+ /** The enabled entries of a model plane, each reduced by `catalogEntryFingerprint` (null = no plane published). */
61
+ function planeEntriesFingerprint(rows) {
62
+ const enabled = enabledOnly(rows);
63
+ return enabled ? enabled.map((m) => catalogEntryFingerprint(m)) : null;
64
+ }
49
65
  function restartSliceValue(eff, slice, ctx) {
50
66
  if (!eff)
51
67
  return null;
@@ -82,7 +98,8 @@ function restartSliceValue(eff, slice, ctx) {
82
98
  // dangling candidates fall through silently here — applyEffective owns the warn.
83
99
  const names = new Set((enabled ?? []).map((m) => m.name));
84
100
  const def = resolveDefaultModelName(eff, (n) => names.has(n), enabled?.[0]?.name ?? "");
85
- return { models: enabled, tiers: active, default: def.name };
101
+ // Entries reduced by the shared deny-list (cost/quotaWeight/enabled are hot see catalogEntryFingerprint).
102
+ return { models: planeEntriesFingerprint(eff.models?.models), tiers: active, default: def.name };
86
103
  }
87
104
  case "degrade-route": {
88
105
  const to = ctx?.reactiveDegradeTo;
@@ -91,7 +108,7 @@ function restartSliceValue(eff, slice, ctx) {
91
108
  // Present↔absent is itself a change: with no enabled entry named `to`, createBrain composes NO
92
109
  // degrading shell at all, so the flip only takes effect at the next boot.
93
110
  const target = (enabledOnly(eff.models?.models) ?? []).find((m) => m.name === to);
94
- return target ? degradeRouteFingerprint(target) : null;
111
+ return target ? catalogEntryFingerprint(target) : null;
95
112
  }
96
113
  }
97
114
  }
@@ -126,7 +143,10 @@ export function modelPlaneChanged(prev, next) {
126
143
  const active = e.models ? (resolveActiveTiers(e.models) ?? null) : null;
127
144
  const names = new Set((enabled ?? []).map((m) => m.name));
128
145
  const def = resolveDefaultModelName(e, (n) => names.has(n), enabled?.[0]?.name ?? "");
129
- return stableStringify({ models: enabled, tiers: active, default: def.name });
146
+ // Same reduction as the models-tiers slice the two MUST subtract the same hot fields (see
147
+ // catalogEntryFingerprint): a deferred candidate force-pushes that slice's reason, so any divergence
148
+ // either re-manufactures the restart we just subtracted or signals a plane that was applied hot.
149
+ return stableStringify({ models: planeEntriesFingerprint(e.models?.models), tiers: active, default: def.name });
130
150
  };
131
151
  return fp(prev) !== fp(next);
132
152
  }
@@ -2,7 +2,7 @@
2
2
  * Two small consumption lanes that both project a center manifest domain onto a per-request runtime shape:
3
3
  * skills application (`applyCenterSkills` — merge center skills OVER the image baseline, content-addressed
4
4
  * fetch+verify+cache) and MCP server resolution (`resolveMcpServers`/`mcpForScenario` — env-NAME ref
5
- * resolution + per-scenario filtering). Split out of `sema-registry.ts` (design/158 A13, internal-lossless —
5
+ * resolution + per-scenario filtering). Split out of `facade.ts` (design/158 A13, internal-lossless —
6
6
  * the facade re-exports every symbol below unchanged).
7
7
  */
8
8
  import { skillContentHash } from "@sema-agent/registry-core";
@@ -1,8 +1,8 @@
1
1
  /**
2
- * config-center wire types — the sema-registry `/api/config/effective` payload shapes (`CenterModel`,
2
+ * config-center wire types — the config-center `/api/config/effective` payload shapes (`CenterModel`,
3
3
  * `CenterTeam`, `CenterSkillManifest`, `CenterMcpServer`, `EffectiveConfig`) plus the per-principal
4
4
  * execution/session-mirror ruling shapes riding the caps view (`ExecutionRuling`/`SessionMirrorRuling`).
5
- * Pure type/interface declarations — no runtime logic. Split out of `sema-registry.ts` (design/158 A13,
5
+ * Pure type/interface declarations — no runtime logic. Split out of `facade.ts` (design/158 A13,
6
6
  * internal-lossless: the facade re-exports every symbol below unchanged).
7
7
  */
8
8
  import type { CollabTemplateWire } from "../capabilities/collab-wire.js";
@@ -1,7 +1,7 @@
1
1
  import { FileConfigStore } from "@sema-agent/registry-core/node";
2
2
  import { type EffectiveConfig as AgentConfigEffective } from "@sema-agent/registry-core";
3
- import { fetchEffective as remoteFetchEffective, fetchSkillContent as remoteFetchSkillContent, type EffectiveConfig } from "./sema-registry.js";
4
- /** Result of a `fetchEffective` — EXACTLY sema-registry's return: the effective config + its etag, or
3
+ import { fetchEffective as remoteFetchEffective, fetchSkillContent as remoteFetchSkillContent, type EffectiveConfig } from "./config-center/facade.js";
4
+ /** Result of a `fetchEffective` — EXACTLY the config-center's return: the effective config + its etag, or
5
5
  * `null` for "unchanged" (remote 304; local: the version matched the caller's prior etag).
6
6
  * `domainErrors`([898] registry-core 0.10.12 tolerant seam,local lane only):catalog 域坏文件不再
7
7
  * 连坐全包回落 env——坏域按该域 schema default 落+错误单列,好域照常生效;caller(main.ts)对每条打
@@ -29,12 +29,12 @@ export declare const BOOT_FETCH_DEFERRED: unique symbol;
29
29
  export declare function raceBootFetch<T>(fetch: Promise<T>, budgetMs: number): Promise<T | typeof BOOT_FETCH_DEFERRED>;
30
30
  /**
31
31
  * The two transport calls the service makes to obtain config, behind a backend-selectable seam. Both
32
- * signatures and return shapes match `sema-registry.fetchEffective` / `sema-registry.fetchSkillContent`
32
+ * signatures and return shapes match `facade.fetchEffective` / `facade.fetchSkillContent`
33
33
  * EXACTLY so that, ONCE WIRED, the caller (main.ts) could swap remote↔local without touching applyEffective
34
34
  * et al. (main.ts does not swap on this seam yet — see the NOT-YET-WIRED note at the top of this module).
35
35
  */
36
36
  export interface ConfigProvider {
37
- /** "remote" = sema-registry HTTP; "local" = on-disk FileConfigStore. */
37
+ /** "remote" = config-center HTTP; "local" = on-disk FileConfigStore. */
38
38
  readonly kind: "remote" | "local";
39
39
  /**
40
40
  * Pull the effective config. `etag` is the caller's last-seen version token (`if-none-match` on remote;
@@ -72,9 +72,9 @@ export interface ConfigProviderInput {
72
72
  localDir?: string;
73
73
  }
74
74
  /**
75
- * RemoteConfigProvider — DELEGATES to the exported sema-registry HTTP functions. No HTTP is reimplemented;
75
+ * RemoteConfigProvider — DELEGATES to the exported config-center HTTP functions. No HTTP is reimplemented;
76
76
  * baseUrl/token/worker are bound once and passed through. The two delegate fns are injectable (defaulting
77
- * to the real sema-registry exports) so a unit test can assert delegation without module-mocking.
77
+ * to the real config-center exports) so a unit test can assert delegation without module-mocking.
78
78
  */
79
79
  export declare class RemoteConfigProvider implements ConfigProvider {
80
80
  private readonly cc;
@@ -120,7 +120,7 @@ export declare class LocalConfigProvider implements ConfigProvider {
120
120
  export declare function mapToServiceEffective(eff: AgentConfigEffective, version: number): EffectiveConfig;
121
121
  /**
122
122
  * Pick the backend off a CLOSED `provider` enum (`local` | `remote` | undefined): LOCAL when
123
- * `CONFIG_PROVIDER=local`, OR when no sema-registry URL is configured (a lone box with config.d/ on disk
123
+ * `CONFIG_PROVIDER=local`, OR when no config-center URL is configured (a lone box with config.d/ on disk
124
124
  * and no control plane). REMOTE otherwise (a configured center URL, the default fleet posture). A
125
125
  * `CONFIG_PROVIDER=remote` with no URL is a misconfiguration — we fall through to local rather than
126
126
  * constructing a remote provider with no endpoint (the caller would have skipped the remote pull entirely
@@ -2,7 +2,7 @@
2
2
  * ConfigProvider — the dual-mode seam over WHERE the effective config comes from (see
3
3
  * sema-registry docs/DUAL-MODE-DESIGN.md §4).
4
4
  *
5
- * The service consumes sema-registry through EXACTLY two transport calls — `fetchEffective` (pull the
5
+ * The service consumes the config-center through EXACTLY two transport calls — `fetchEffective` (pull the
6
6
  * effective config, ETag-conditional) and `fetchSkillContent` (lazy-pull one skill body by content hash).
7
7
  * Everything downstream (applyEffective / applyRuntimeGates / resolveMcpServers / mcpForScenario / the
8
8
  * skill overlay) is transport-agnostic: it operates on the returned `EffectiveConfig` / skill string.
@@ -17,7 +17,7 @@
17
17
  * This module factors those two calls behind a `ConfigProvider` interface with two backends:
18
18
  *
19
19
  * - {@link RemoteConfigProvider} — the existing behaviour. DELEGATES verbatim to the exported
20
- * `sema-registry.fetchEffective` / `sema-registry.fetchSkillContent` (HTTP + Bearer + ETag + hash
20
+ * `facade.fetchEffective` / `facade.fetchSkillContent` (HTTP + Bearer + ETag + hash
21
21
  * verify). No HTTP is reimplemented here; this is a thin wrapper that binds baseUrl/token/worker.
22
22
  *
23
23
  * - {@link LocalConfigProvider} — reads the SAME config contract from the local filesystem via
@@ -30,7 +30,7 @@
30
30
  *
31
31
  * SECRET BOUNDARY (unchanged in both modes): the config plane carries only env-NAME refs — `apiKeyEnv`,
32
32
  * `envRefs`/`headerRefs`, `tokenEnv`. The real secret VALUE is never stored in config.d/<domain>.json and
33
- * never read by this module; it is resolved from `process.env` downstream (sema-registry.ts
33
+ * never read by this module; it is resolved from `process.env` downstream (config-center/facade.ts
34
34
  * `applyEffective`/`resolveMcpServers`). The local store is symmetric with the remote center on this: it
35
35
  * ships NAMEs only. (Unit test asserts the local path resolves env-NAME→value via process.env and never
36
36
  * persists a value.)
@@ -39,7 +39,7 @@ import { createHmac, randomBytes } from "node:crypto";
39
39
  import { FileConfigStore } from "@sema-agent/registry-core/node";
40
40
  import { findSkillContent, skillContentHash, refIntegrityIssues, siblingResolver, } from "@sema-agent/registry-core";
41
41
  import { redactSecrets } from "./trace/redact.js";
42
- import { fetchEffective as remoteFetchEffective, fetchSkillContent as remoteFetchSkillContent, ConfigCenterHttpError, } from "./sema-registry.js";
42
+ import { fetchEffective as remoteFetchEffective, fetchSkillContent as remoteFetchSkillContent, ConfigCenterHttpError, } from "./config-center/facade.js";
43
43
  import { createLogger } from "./observability/logger.js";
44
44
  const logger = createLogger();
45
45
  /**
@@ -79,9 +79,9 @@ export function raceBootFetch(fetch, budgetMs) {
79
79
  return Promise.race([fetch.finally(() => clearTimeout(timer)), budget]);
80
80
  }
81
81
  /**
82
- * RemoteConfigProvider — DELEGATES to the exported sema-registry HTTP functions. No HTTP is reimplemented;
82
+ * RemoteConfigProvider — DELEGATES to the exported config-center HTTP functions. No HTTP is reimplemented;
83
83
  * baseUrl/token/worker are bound once and passed through. The two delegate fns are injectable (defaulting
84
- * to the real sema-registry exports) so a unit test can assert delegation without module-mocking.
84
+ * to the real config-center exports) so a unit test can assert delegation without module-mocking.
85
85
  */
86
86
  export class RemoteConfigProvider {
87
87
  cc;
@@ -93,7 +93,7 @@ export class RemoteConfigProvider {
93
93
  }
94
94
  fetchEffective(etag) {
95
95
  const fn = this.deps.fetchEffective ?? remoteFetchEffective;
96
- // 5th arg = worker scope; transport (fetchImpl) stays sema-registry's default.
96
+ // 5th arg = worker scope; transport (fetchImpl) stays the config-center's default.
97
97
  return fn(this.cc.baseUrl, this.cc.token, etag, undefined, this.cc.worker);
98
98
  }
99
99
  async fetchSkillContent(contentHash) {
@@ -219,7 +219,7 @@ export function mapToServiceEffective(eff, version) {
219
219
  // [874] registry-core 0.10.11:池结构级 `default?`(catalog name 目录键,与 roles.default 角色词两回事)。
220
220
  // 本 mapper 是 CLOSED 投影——不直通则本地 lane 静默丢键(autoCompactTokens 同类病),applyEffective 的
221
221
  // resolveDefaultModelName 第一优先级在本地 lane 永远点不亮。写面悬空 ref 由 registry-core superRefine
222
- // fail-loud;存量/未经 schema 的路径仍由消费方 warn 降级(sema-registry.ts resolveDefaultModelName)。
222
+ // fail-loud;存量/未经 schema 的路径仍由消费方 warn 降级(config-center/facade.ts resolveDefaultModelName)。
223
223
  ...(eff.models.default !== undefined ? { default: eff.models.default } : {}),
224
224
  ...(eff.models.atModelAllowlist !== undefined ? { atModelAllowlist: eff.models.atModelAllowlist } : {}),
225
225
  // registry-core 0.9.0 档位组: verbatim passthrough — applyEffective's resolveActiveTiers reads
@@ -322,7 +322,7 @@ export function mapToServiceEffective(eff, version) {
322
322
  }
323
323
  /**
324
324
  * Pick the backend off a CLOSED `provider` enum (`local` | `remote` | undefined): LOCAL when
325
- * `CONFIG_PROVIDER=local`, OR when no sema-registry URL is configured (a lone box with config.d/ on disk
325
+ * `CONFIG_PROVIDER=local`, OR when no config-center URL is configured (a lone box with config.d/ on disk
326
326
  * and no control plane). REMOTE otherwise (a configured center URL, the default fleet posture). A
327
327
  * `CONFIG_PROVIDER=remote` with no URL is a misconfiguration — we fall through to local rather than
328
328
  * constructing a remote provider with no endpoint (the caller would have skipped the remote pull entirely
package/dist/config.d.ts CHANGED
@@ -8,7 +8,7 @@ export type { ServiceConfig, ScopedMcpServer, ImageBakeConfig, ServiceConfigFlat
8
8
  /** Parse the AUTONOMY env into a validated autonomy mode. Unset/empty → undefined (unmanaged → no extra
9
9
  * tightening). An UNKNOWN value FAILS at startup rather than silently becoming a no-op (a typo'd `AUTONOMY=readonly`
10
10
  * must not silently leave a deployment ungoverned — fail-loud, same discipline as numEnv). Exported so the HOT
11
- * config overlay (sema-registry.ts `applyRuntimeHot`) can re-derive the ENV BASELINE to revert to when center
11
+ * config overlay (config-center/facade.ts `applyRuntimeHot`) can re-derive the ENV BASELINE to revert to when center
12
12
  * stops managing `autonomy` (a stale center override must not stick — see applyRuntimeHot). */
13
13
  export declare function parseAutonomy(raw: string | undefined): Autonomy | undefined;
14
14
  export declare function drainConfigWarnings(): Array<{
package/dist/config.js CHANGED
@@ -15,7 +15,7 @@ const AUTONOMY_MODES = ["read-only", "ask", "plan", "auto"];
15
15
  /** Parse the AUTONOMY env into a validated autonomy mode. Unset/empty → undefined (unmanaged → no extra
16
16
  * tightening). An UNKNOWN value FAILS at startup rather than silently becoming a no-op (a typo'd `AUTONOMY=readonly`
17
17
  * must not silently leave a deployment ungoverned — fail-loud, same discipline as numEnv). Exported so the HOT
18
- * config overlay (sema-registry.ts `applyRuntimeHot`) can re-derive the ENV BASELINE to revert to when center
18
+ * config overlay (config-center/facade.ts `applyRuntimeHot`) can re-derive the ENV BASELINE to revert to when center
19
19
  * stops managing `autonomy` (a stale center override must not stick — see applyRuntimeHot). */
20
20
  export function parseAutonomy(raw) {
21
21
  const v = raw?.trim();
@@ -534,8 +534,8 @@ function parseModelDomain() {
534
534
  // when body.images hits a text-only model) AND the vision flag on GET /v1/models. The env-lane default model
535
535
  // declares vision via MODEL_VISION — DEFAULT "true" keeps the historical image-capable behavior, so set
536
536
  // MODEL_VISION=false for a text-only model (e.g. deepseek) to make the precheck actually fire instead of letting
537
- // images透传 to an opaque downstream-gateway 400. (The centralized sema-registry lane derives input from its own
538
- // per-model vision flag — see sema-registry.ts.)
537
+ // images透传 to an opaque downstream-gateway 400. (The centralized config-center lane derives input from its own
538
+ // per-model vision flag — see config-center/facade.ts.)
539
539
  input: env("MODEL_VISION", "true") === "true" ? ["text", "image"] : ["text"],
540
540
  // Per-1M-token USD pricing (core `modelCostToPricing`: input→inputPer1M, …). Default 0 keeps the
541
541
  // historical behavior (spend reads $0) — set MODEL_COST_* on a deploy to make `model_cost_micro_usd`
@@ -624,7 +624,7 @@ function parseModelDomain() {
624
624
  // Static catalog audit for this batch: ZERO baked 1M entries exist (env lane defaults 262144; center lane
625
625
  // inherits/declares per roster) — so this is a DERIVATION on the declared window, not a data edit: any
626
626
  // deployment that sets MODEL_CONTEXT_WINDOW>=1e6 (or a center roster with a 1M contextWindow —
627
- // sema-registry.ts applies the same helper) gets the field automatically. <1M ⇒ field ABSENT (core's plain
627
+ // config-center/facade.ts applies the same helper) gets the field automatically. <1M ⇒ field ABSENT (core's plain
628
628
  // W-33000/0.7W geometry already matches CC there).
629
629
  applyAutoCompactWindow(model);
630
630
  if (cheapModel)
@@ -849,7 +849,7 @@ function parseMemoryDomain(ctx) {
849
849
  memoryEngineBackend,
850
850
  memoryScope, // hoisted above (the 142-S2.5-W1 sync-scope default consumes it)
851
851
  ...(memorySync ? { memorySync } : {}),
852
- projectMemoryEnabled, // design/113 C4 opt-out, flipped positive in design/158 B4 (legacy PROJECT_MEMORY_DISABLED still honored)
852
+ projectMemoryEnabled, // design/113 C4 opt-out, flipped positive in design/158 B4 (3.0.0 起 legacy PROJECT_MEMORY_DISABLED = fail-loud 墓碑)
853
853
  syncImportLeaseStaleSec: Math.max(0, numEnv("SYNC_IMPORT_LEASE_STALE_SEC", "600")),
854
854
  };
855
855
  }
@@ -209,7 +209,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
209
209
  // (yields a task, runs no model — see the billable-route classifier) → NO lease gate, else an exhausted
210
210
  // tenant could not stop its own spending.
211
211
  if (!deps.runStore) {
212
- sendError(res, 501, "capability.run_store_required", "preemption requires the TiDB run store");
212
+ sendError(res, 501, "capability.run_store_required", "preemption requires a durable run store (DB_BACKEND=mysql|pg)");
213
213
  return;
214
214
  }
215
215
  // requirePrincipal parity with the sibling mutating endpoints (runOwnerOk / cancel / runs): 401 before the
@@ -282,7 +282,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
282
282
  if (rateLimited(req, res) || quotaExceeded(req, res))
283
283
  return; // 🔴 复审 C2:lease admitted INSIDE driveResumeIntoRunLog on the CHECKPOINT-OWNER principal (the billed tenant), not the request principal — a cross-tenant operator resume must charge the owner's lease, not the operator's. resume hits TiDB + runs the model
284
284
  if (!deps.runStore) {
285
- sendError(res, 501, "capability.run_store_required", "resume requires the TiDB run store");
285
+ sendError(res, 501, "capability.run_store_required", "resume requires a durable run store (DB_BACKEND=mysql|pg)");
286
286
  return;
287
287
  }
288
288
  if (deps.config.requirePrincipal && principal === undefined) {
@@ -319,7 +319,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
319
319
  if (rateLimited(req, res) || quotaExceeded(req, res))
320
320
  return; // 🔴 复审 C2:lease admitted in driveResumeIntoRunLog (owner principal). resume hits TiDB + runs the model (approve/edit)
321
321
  if (!deps.runStore) {
322
- sendError(res, 501, "capability.run_store_required", "plan_review requires the TiDB run store");
322
+ sendError(res, 501, "capability.run_store_required", "plan_review requires a durable run store (DB_BACKEND=mysql|pg)");
323
323
  return;
324
324
  }
325
325
  if (deps.config.requirePrincipal && principal === undefined) {
@@ -93,7 +93,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
93
93
  // Async run: create + return ids immediately, execute in the background.
94
94
  if (req.method === "POST" && url === "/v1/runs") {
95
95
  if (!deps.runStore) {
96
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store (SESSION_BACKEND=tidb)");
96
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg) (SESSION_BACKEND=tidb)");
97
97
  return;
98
98
  }
99
99
  // Idempotency-Key dedup (center blocker): a retried create returns the SAME taskId instead of starting a
@@ -222,7 +222,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
222
222
  const runMatch = req.method === "GET" ? RUN_ID_RE.exec(url) : null;
223
223
  if (runMatch) {
224
224
  if (!deps.runStore) {
225
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
225
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
226
226
  return;
227
227
  }
228
228
  const taskId = runMatch[1];
@@ -310,7 +310,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
310
310
  const cancelMatch = req.method === "POST" ? RUN_CANCEL_RE.exec(url) : null;
311
311
  if (cancelMatch) {
312
312
  if (!deps.runStore) {
313
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
313
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
314
314
  return;
315
315
  }
316
316
  // Principal check BEFORE the lookup (parity with GET /v1/runs/:id): no 404-vs-401 existence oracle.
@@ -496,7 +496,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
496
496
  if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
497
497
  return; // mutating + hits TiDB / runs the model
498
498
  if (!deps.runStore) {
499
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
499
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
500
500
  return;
501
501
  }
502
502
  // Per-tenant identity MUST come from gatedPrincipal (direct-door secure single point), NOT principalFrom:
@@ -713,7 +713,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
713
713
  if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
714
714
  return; // mutating + runs the model (compaction summarizes)
715
715
  if (!deps.runStore) {
716
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
716
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
717
717
  return;
718
718
  }
719
719
  const principal = gatedPrincipal(req, deps.config); // direct-door-secure identity, never the spoofable header
@@ -818,7 +818,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
818
818
  if (rateLimited(req, res))
819
819
  return; // mutating, but runs no model (no quota gate — parity with cancel, not steer)
820
820
  if (!deps.runStore) {
821
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
821
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
822
822
  return;
823
823
  }
824
824
  const principal = gatedPrincipal(req, deps.config); // direct-door-secure identity, never the spoofable header
@@ -1183,7 +1183,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1183
1183
  if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
1184
1184
  return; // mutating + drives a model agent
1185
1185
  if (!deps.runStore) {
1186
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
1186
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
1187
1187
  return;
1188
1188
  }
1189
1189
  // Identity from gatedPrincipal (direct-door-secure single point), NEVER the spoofable header — `trusted`
@@ -1,5 +1,5 @@
1
1
  import { materializeMcpTools, sanitizePathComponent, SessionPolicyError } from "@sema-agent/core";
2
- import { mcpForScenario } from "../../sema-registry.js";
2
+ import { mcpForScenario } from "../../config-center/facade.js";
3
3
  import { isUuidV7, isUuidShape } from "../../security.js";
4
4
  import { windowMessages, truncateMessageBlobs } from "../../audit.js";
5
5
  import { redactSecrets } from "../../trace/redact.js";
@@ -36,7 +36,7 @@ async function handleTraceUsageBody(req, res, url, ctx, miss) {
36
36
  return;
37
37
  }
38
38
  if (!deps.runStore) {
39
- sendError(res, 501, "capability.run_store_required", "trace API requires the TiDB run store (SESSION_BACKEND=tidb)");
39
+ sendError(res, 501, "capability.run_store_required", "trace API requires a durable run store (DB_BACKEND=mysql|pg) (SESSION_BACKEND=tidb)");
40
40
  return;
41
41
  }
42
42
  const q = new URL(req.url ?? "", "http://x").searchParams;
@@ -56,7 +56,7 @@ async function handleTraceUsageBody(req, res, url, ctx, miss) {
56
56
  return;
57
57
  }
58
58
  if (!deps.runStore) {
59
- sendError(res, 501, "capability.run_store_required", "trace API requires the TiDB run store (SESSION_BACKEND=tidb)");
59
+ sendError(res, 501, "capability.run_store_required", "trace API requires a durable run store (DB_BACKEND=mysql|pg) (SESSION_BACKEND=tidb)");
60
60
  return;
61
61
  }
62
62
  const query = new URL(req.url ?? "", "http://x").searchParams;
@@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http";
3
3
  import { type Runner, type TaskSpec, type TaskResult, type WorkflowRunStore, type MemoryEntry } from "@sema-agent/core";
4
4
  import type { TaskRequestBody } from "./wire-types.js";
5
5
  import type { ServiceConfig } from "../config-types.js";
6
- import { type RestartSignal, type SessionMirrorRuling } from "../sema-registry.js";
6
+ import { type RestartSignal, type SessionMirrorRuling } from "../config-center/facade.js";
7
7
  import { type OwnerAwareSessionStore } from "../security.js";
8
8
  import type { RunStore, ApprovalStore, ImageIndex, ImageBake, CheckpointStoreFull, ResumeAnchorStore, ApprovalExemptionStore, ServiceSessionPolicyStore, ServiceFileSnapshotStore, StoreBackend } from "../plugins/store-backend.js";
9
9
  import { type MemorySyncRequest, type MemorySyncResponse } from "../memory-sync.js";
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, HAND_TOOL_EFFECTS, canonicalToolName, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow } from "@sema-agent/core"; // canonicalToolName = core single-source (1.162; replaced the transitional service mirror)
5
5
  import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
6
6
  import { matchCatalogModel } from "../model-select.js";
7
- import {} from "../sema-registry.js";
7
+ import {} from "../config-center/facade.js";
8
8
  import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, verifyDirectDoorProof } from "../security.js";
9
9
  import { exportSession, importSession } from "../session-sync.js";
10
10
  import {} from "../memory-sync.js";
@@ -407,7 +407,7 @@ export function createHttpServer(rawDeps) {
407
407
  return;
408
408
  }
409
409
  // Metrics (read-only): authorized by EITHER the full authToken OR a read-only metricsToken — so a
410
- // control plane (sema-registry) can pull metrics fleet-wide with one token, never holding each
410
+ // control plane (config-center) can pull metrics fleet-wide with one token, never holding each
411
411
  // worker's full authToken. Handled BEFORE the global gate so metricsToken-only callers aren't 401'd.
412
412
  if (req.method === "GET" && (url === "/metrics" || url === "/metrics/summary" || url === "/metrics/plan-cache")) {
413
413
  if (!deps.metrics) {
@@ -495,7 +495,7 @@ export function createHttpServer(rawDeps) {
495
495
  reqState.source = source;
496
496
  // Mandatory service token on submission endpoints (center: a worker MUST validate a service-to-service
497
497
  // token on task creation — not "if auth is configured" — else anything in-cluster can submit & bill
498
- // directly, bypassing sema-registry's auth/audit). Fail-closed: the POST task/run/leader endpoints refuse
498
+ // directly, bypassing config-center's auth/audit). Fail-closed: the POST task/run/leader endpoints refuse
499
499
  // when no authToken is set, unless explicitly opted out for local dev (ALLOW_UNAUTHED_WRITES=true).
500
500
  // The bake door (POST /v1/images/bakes*) is build-host-RCE-capable and authed by the Bearer-token-no-cookie
501
501
  // model (§P2.4b) — it MUST also refuse when no service token is configured (else a forged principal header
@@ -26,4 +26,18 @@ import { type SealedKeyPoison } from "./sealed-key.js";
26
26
  export declare function createKeyResolver(modelApiKeyEnv: Record<string, string>, env?: NodeJS.ProcessEnv, modelApiKeys?: Record<string, string | SealedKeyPoison>): ((model: Model) => Promise<{
27
27
  apiKey: string;
28
28
  } | undefined>) | undefined;
29
+ /**
30
+ * The ONE per-model key chain, synchronous: **sealed custody → env-NAME reference → (caller's) gateway key**,
31
+ * with a poisoned sealed entry THROWING instead of returning undefined. `createKeyResolver` is the async
32
+ * `getApiKeyAndHeaders` face of exactly this function — every other consumer must call it rather than
33
+ * re-implement a subset of the chain.
34
+ *
35
+ * 复审 2026-07-29(同族缺口):`brain.ts` 的反应式降级 hop 曾只查 `modelApiKeyEnv` —— sealed-box 托管密钥的
36
+ * 降级目标(按互斥契约**永不**落 modelApiKeyEnv)于是静默拿网关 key 打自己的网关(错 key 打上游),毒丸更
37
+ * 被当成"没配 key"。链只有一条,重复实现就会长出这种半条链的偏差,故此处抽出同步内核共用。
38
+ *
39
+ * `undefined` = this model has no per-model key ⇒ the CALLER's gateway/default credential applies (the
40
+ * additive contract). An empty env value counts as unset (unchanged from the original inline chain).
41
+ */
42
+ export declare function resolveModelApiKey(modelName: string, modelApiKeyEnv: Record<string, string>, env?: NodeJS.ProcessEnv, modelApiKeys?: Record<string, string | SealedKeyPoison>): string | undefined;
29
43
  //# sourceMappingURL=key-resolver.d.ts.map
@@ -26,18 +26,34 @@ export function createKeyResolver(modelApiKeyEnv, env = process.env, modelApiKey
26
26
  if (Object.keys(modelApiKeyEnv).length === 0 && Object.keys(modelApiKeys).length === 0)
27
27
  return undefined;
28
28
  return async (model) => {
29
- const sealed = modelApiKeys[model.name];
30
- // Poison pill: the operator configured a sealed key for this model but it cannot be unsealed —
31
- // fail the brain call LOUD (never undefined = never the gateway fallback).
32
- if (isSealedKeyPoison(sealed))
33
- throw new SealedKeyPoisonedError(model.name, sealed);
34
- if (sealed)
35
- return { apiKey: sealed }; // sealed-box custody wins (registry-core: sealed 压过 apiKeyEnv)
36
- const envName = modelApiKeyEnv[model.name];
37
- if (!envName)
38
- return undefined; // model has no per-model key → core uses the gateway key
39
- const apiKey = env[envName];
29
+ const apiKey = resolveModelApiKey(model.name, modelApiKeyEnv, env, modelApiKeys);
40
30
  return apiKey ? { apiKey } : undefined;
41
31
  };
42
32
  }
33
+ /**
34
+ * The ONE per-model key chain, synchronous: **sealed custody → env-NAME reference → (caller's) gateway key**,
35
+ * with a poisoned sealed entry THROWING instead of returning undefined. `createKeyResolver` is the async
36
+ * `getApiKeyAndHeaders` face of exactly this function — every other consumer must call it rather than
37
+ * re-implement a subset of the chain.
38
+ *
39
+ * 复审 2026-07-29(同族缺口):`brain.ts` 的反应式降级 hop 曾只查 `modelApiKeyEnv` —— sealed-box 托管密钥的
40
+ * 降级目标(按互斥契约**永不**落 modelApiKeyEnv)于是静默拿网关 key 打自己的网关(错 key 打上游),毒丸更
41
+ * 被当成"没配 key"。链只有一条,重复实现就会长出这种半条链的偏差,故此处抽出同步内核共用。
42
+ *
43
+ * `undefined` = this model has no per-model key ⇒ the CALLER's gateway/default credential applies (the
44
+ * additive contract). An empty env value counts as unset (unchanged from the original inline chain).
45
+ */
46
+ export function resolveModelApiKey(modelName, modelApiKeyEnv, env = process.env, modelApiKeys = {}) {
47
+ const sealed = modelApiKeys[modelName];
48
+ // Poison pill: the operator configured a sealed key for this model but it cannot be unsealed —
49
+ // fail the brain call LOUD (never undefined = never the gateway fallback).
50
+ if (isSealedKeyPoison(sealed))
51
+ throw new SealedKeyPoisonedError(modelName, sealed);
52
+ if (sealed)
53
+ return sealed; // sealed-box custody wins (registry-core: sealed 压过 apiKeyEnv)
54
+ const envName = modelApiKeyEnv[modelName];
55
+ if (!envName)
56
+ return undefined; // model has no per-model key → core uses the gateway key
57
+ return env[envName] || undefined;
58
+ }
43
59
  //# sourceMappingURL=key-resolver.js.map
package/dist/run-local.js CHANGED
@@ -33,7 +33,7 @@ import { taskWallClockSec } from "./task-workflow.js";
33
33
  import { validatePromptsDomain, applyCatalogToSource, CORE_ENGINE_VERSION } from "./capabilities/center-prompts.js";
34
34
  import { loadConfig, logConfigDiagnostics } from "./config.js";
35
35
  import { createConfigProvider } from "./config-provider.js";
36
- import { applyEffective, resolveMcpServers, mcpForScenario } from "./sema-registry.js";
36
+ import { applyEffective, resolveMcpServers, mcpForScenario } from "./config-center/facade.js";
37
37
  import { hostExecutionEnvFactory } from "./plugins/remote-env-host.js";
38
38
  import { makeLoadProjectMemory, makeProbeInstructionSources } from "./project-memory.js";
39
39
  import { loadSkills } from "./capabilities/skills.js";
@@ -272,7 +272,7 @@ export async function runLocal(argv, deps = {}) {
272
272
  return 2;
273
273
  }
274
274
  const sessionStore = fileBackend.sessionStore;
275
- const keyResolver = createKeyResolver(config.modelApiKeyEnv);
275
+ const keyResolver = createKeyResolver(config.modelApiKeyEnv, process.env, config.modelApiKeys); // [2006] 后续:三参全链(sealed→env→undefined),与 boot 正形同源——二参形曾让 sealed 模型静默落网关 key
276
276
  // Host exec lane: run on THIS machine, in a FIXED workspace (CC-like) — the cwd by default (or --workspace /
277
277
  // a config.d workdir). The agent's file edits PERSIST there + are user-visible; the dir is the user's OWN and
278
278
  // is NEVER deleted (workspaceDir mode). One env per task; the Runner owns its lifetime.
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import type { RuntimeCaps } from "@sema-agent/core";
30
30
  import type { EntitlementRuntimeCaps } from "@sema-agent/registry-core";
31
- import { fetchPrincipalCaps, type ExecutionRuling } from "./sema-registry.js";
31
+ import { fetchPrincipalCaps, type ExecutionRuling } from "./config-center/facade.js";
32
32
  import type { ScenarioRuling } from "./capabilities/scenarios.js";
33
33
  export interface EntitlementsResolverOpts {
34
34
  baseUrl: string;
@@ -1,4 +1,4 @@
1
- import { fetchPrincipalCaps } from "./sema-registry.js";
1
+ import { fetchPrincipalCaps } from "./config-center/facade.js";
2
2
  import { HttpError } from "./security.js";
3
3
  import { createLogger } from "./observability/logger.js";
4
4
  const defaultLogger = createLogger();
@@ -51,7 +51,7 @@ export type Autonomy = "read-only" | "ask" | "plan" | "auto";
51
51
  export declare const COARSE_SHELL_TOOLS_MIRROR: readonly string[];
52
52
  /** Validate `commandPolicy` rules — the shape (array of {command, decision}), the `command` (a bare argv[0] name,
53
53
  * see {@link VALID_COMMAND_NAME}), AND the `decision` enum. Returns the list of human-readable errors (empty =
54
- * OK). Called at config-apply (sema-registry.ts `applyRuntimeHot`) so a malformed rule that BYPASSED the
54
+ * OK). Called at config-apply (config-center/facade.ts `applyRuntimeHot`) so a malformed rule that BYPASSED the
55
55
  * registry schema (a hand-edited config.d / a non-conformant publish) is rejected LOUDLY rather than
56
56
  * silently mis-compiling — review MEDIUM: an out-of-enum `decision` (e.g. "DENY") would otherwise fall through
57
57
  * {@link compileCommandPolicy}'s `decision==="deny"?…:"ask"` to ASK, silently WEAKENING an intended deny. */
@@ -108,7 +108,7 @@ const VALID_COMMAND_NAME = /^[A-Za-z0-9._+-]+$/;
108
108
  const VALID_DECISIONS = new Set(["allow", "ask", "deny"]);
109
109
  /** Validate `commandPolicy` rules — the shape (array of {command, decision}), the `command` (a bare argv[0] name,
110
110
  * see {@link VALID_COMMAND_NAME}), AND the `decision` enum. Returns the list of human-readable errors (empty =
111
- * OK). Called at config-apply (sema-registry.ts `applyRuntimeHot`) so a malformed rule that BYPASSED the
111
+ * OK). Called at config-apply (config-center/facade.ts `applyRuntimeHot`) so a malformed rule that BYPASSED the
112
112
  * registry schema (a hand-edited config.d / a non-conformant publish) is rejected LOUDLY rather than
113
113
  * silently mis-compiling — review MEDIUM: an out-of-enum `decision` (e.g. "DENY") would otherwise fall through
114
114
  * {@link compileCommandPolicy}'s `decision==="deny"?…:"ask"` to ASK, silently WEAKENING an intended deny. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",