@sema-agent/server 7.44.0 → 7.45.0-rc.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.
Files changed (48) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/USAGE.md +10 -1
  4. package/dist/approval-card.d.ts +5 -0
  5. package/dist/approval-card.js +2 -0
  6. package/dist/boot/config-center.js +22 -11
  7. package/dist/boot/execution-env.js +1 -0
  8. package/dist/boot/shutdown.d.ts +18 -2
  9. package/dist/boot/shutdown.js +54 -12
  10. package/dist/boot/side-query-lane.d.ts +31 -46
  11. package/dist/boot/side-query-lane.js +3 -15
  12. package/dist/boot/webfetch-summarize-lane.d.ts +3 -2
  13. package/dist/boot/webfetch-summarize-lane.js +1 -1
  14. package/dist/brain.js +32 -1
  15. package/dist/config-center/apply-effective.js +3 -2
  16. package/dist/config-types.d.ts +16 -1
  17. package/dist/config.js +68 -3
  18. package/dist/hooks/hook-llm.js +29 -20
  19. package/dist/hooks/hook-runner.js +3 -0
  20. package/dist/http/routes/capabilities.js +10 -1
  21. package/dist/http/routes/runs.js +4 -0
  22. package/dist/http/server.d.ts +12 -1
  23. package/dist/http/server.js +6 -3
  24. package/dist/leader/merge.js +4 -2
  25. package/dist/main.js +6 -2
  26. package/dist/memory-sync.js +15 -2
  27. package/dist/observability/fail-open.d.ts +10 -2
  28. package/dist/observability/fail-open.js +10 -2
  29. package/dist/orchestration/hardened-vm-runner.d.ts +29 -0
  30. package/dist/orchestration/hardened-vm-runner.js +52 -4
  31. package/dist/plugins/remote-env-ssh.d.ts +3 -0
  32. package/dist/plugins/remote-env-ssh.js +20 -1
  33. package/dist/plugins/send-user-file.js +2 -1
  34. package/dist/plugins/tidb-session-storage.d.ts +8 -6
  35. package/dist/plugins/tidb-session-storage.js +2 -5
  36. package/dist/rules-consent.d.ts +19 -16
  37. package/dist/rules-consent.js +18 -1
  38. package/dist/run-local.js +6 -1
  39. package/dist/session-titler.js +2 -1
  40. package/dist/sighup-idle.d.ts +16 -2
  41. package/dist/sighup-idle.js +10 -1
  42. package/dist/ssh-host-key.d.ts +65 -0
  43. package/dist/ssh-host-key.js +117 -0
  44. package/dist/text-bidi.d.ts +50 -0
  45. package/dist/text-bidi.js +8 -0
  46. package/dist/tool-approval.d.ts +56 -2
  47. package/dist/tool-approval.js +95 -14
  48. package/package.json +2 -2
package/README.md CHANGED
@@ -153,6 +153,8 @@ The server is configured entirely through environment variables. The most import
153
153
  | `DB_BACKEND` | `local`* | `mysql` (any MySQL-protocol DB: MySQL/TiDB/MariaDB) / `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 |
154
154
  | `SESSION_BACKEND` | `memory`* | `memory` / `mysql` (durable session center) / `auto`. *Defaults to durable when `DB_BACKEND` is explicitly `mysql`/`pg` |
155
155
  | `REMOTE_EXEC` | unset | Sandbox execution lane: `host` / `local-docker` / `e2b` / `k8s` / `ssh` / `adb`; unset = in-process stub (with `CONFIG_PROVIDER=local` the default becomes `host`). Naming a lane without its required env (e.g. `e2b` without `E2B_API_KEY`) or an unrecognized value **refuses to start** — no silent downgrade to the host/in-process lane (#157 fail-closed) |
156
+ | `SSH_HOST_FINGERPRINT` / `SSH_KNOWN_HOSTS` | unset | **SSH lane host-key verification** (`REMOTE_EXEC=ssh` only). `SSH_HOST_FINGERPRINT` = the host key's SHA256 base64 fingerprint (`SHA256:` prefix and `=` padding both optional); `SSH_KNOWN_HOSTS` = a path to a `known_hosts` file, matched literally on `host` / `[host]:port`. **Either one present ⇒ strict verification, a mismatch REFUSES the connection** (the error carries both fingerprints plus an `ssh-keyscan` line); both set ⇒ both must pass. **Both unset ⇒ a loud per-connection warning (`ssh_host_key_unverified`, man-in-the-middle risk) and the connection proceeds** — the deliberate posture for the batch-deploy lane, registered as a P-DEBT fail-open (`docs/FAIL-OPEN-CENSUS.md`). A malformed fingerprint or an unreadable `known_hosts` path **refuses to start**. Not supported in `known_hosts`, on purpose: hashed (`\|1\|…`) lines, wildcard patterns, `@cert-authority` — all skipped, so a host whose only entries are those is REFUSED (`@revoked` is honored and wins over any plain line, whatever the order) |
157
+ | `HOOKS_TIMEOUT_MS` | unset (engine default 600000) | Per-invocation time bound for every hook seat (core `Hooks.timeoutMs`). Unset = the engine's own default (this server does not copy upstream defaults). `0` is honored as written (every seat expires immediately). A non-integer, negative, or above-`setTimeout`-ceiling (2147483647) value **refuses to start** |
156
158
  | `CONFIG_PROVIDER` | unset | Config source: `local` (file-backed `config.d/`, single machine) / `remote` (registry control plane) |
157
159
  | `DEFAULT_SCENARIO` | `code` | Default scenario when the request body names none |
158
160
  | `SANDBOX_PKG_SOURCE` | `global` | Package sources inside sandboxes: `global` (official upstreams) / `cn` (China mirrors) / `custom` / `none` |
package/README.zh-CN.md CHANGED
@@ -142,6 +142,8 @@ curl -s localhost:8090/v1/tasks -H "Authorization: Bearer <SERVICE_AUTH_TOKEN>"
142
142
  | `DB_BACKEND` | `local`* | SQL 引擎:`mysql`(任何 MySQL 协议库:MySQL/TiDB/MariaDB —— TiDB 走这个值,**没有** `tidb` 别名,写它启动即拒)/ `pg`(PostgreSQL)/ `local`(免 DB 文件持久化)/ `memory`(显式纯内存)。显式设置 `mysql`/`pg` 时 session 自动转 durable。*单租户裸 boot 缺省 `local`;`REQUIRE_PRINCIPAL=true` 的多租户裸 boot 缺省 `memory`(local 与多租户互斥) |
143
143
  | `SESSION_BACKEND` | `memory`* | `memory` / `mysql`(durable 会话中心)/ `auto`。*显式 `DB_BACKEND=mysql/pg` 时默认转 durable |
144
144
  | `REMOTE_EXEC` | 未设 | 沙箱执行通道:`host` / `local-docker` / `e2b` / `k8s` / `ssh` / `adb`;未设 = 进程内 stub(`CONFIG_PROVIDER=local` 时缺省转 `host`)。点名了通道但必需 env 不全(如 `e2b` 缺 `E2B_API_KEY`)或值不在闭集内 ⇒ **启动即拒**——不再静默降级到 host/进程内通道(#157 fail-closed) |
145
+ | `SSH_HOST_FINGERPRINT` / `SSH_KNOWN_HOSTS` | 未设 | **SSH 通道主机密钥校验**(仅 `REMOTE_EXEC=ssh`)。前者 = 主机公钥的 SHA256 base64 指纹(`SHA256:` 前缀与 `=` 填充都可选);后者 = `known_hosts` 文件路径,按 `host` / `[host]:port` **逐字**匹配行。**任一在场 ⇒ 严格校验,不符即拒连**(拒因带两半指纹 + `ssh-keyscan` 取指纹指路一行);两只同时在场 ⇒ 合取(都要过)。**两只全缺席 ⇒ 每连接一条响亮 warn(`ssh_host_key_unverified`,中间人风险)后照常连** —— 批量部署通道的既定姿态,已登记为 P-DEBT fail-open(`docs/FAIL-OPEN-CENSUS.md`)。指纹拼错 / `known_hosts` 路径读不到 ⇒ **启动即拒**。`known_hosts` 里**刻意不支持**:hashed(`\|1\|…`)行、通配符 pattern、`@cert-authority` —— 一律跳过,于是只有这些条目的主机会被**拒连**(`@revoked` 照常生效,且无论写在哪一行都优先于普通匹配行) |
146
+ | `HOOKS_TIMEOUT_MS` | 未设(引擎缺省 600000) | 每一只 hook 席位**单次调用**的时间上限(core `Hooks.timeoutMs`)。未设 = 用引擎自己的缺省(本服务不复制上游默认值)。`0` **按字面生效**(每个席位立即到期)。非整数 / 负数 / 超 `setTimeout` 上限(2147483647)⇒ **启动即拒** |
145
147
  | `CONFIG_PROVIDER` | 未设 | 配置来源:`local`(单机文件 `config.d/`)/ `remote`(registry 控制面) |
146
148
  | `DEFAULT_SCENARIO` | `code` | 请求体未指定场景时的缺省场景 |
147
149
  | `SANDBOX_PKG_SOURCE` | `global` | 沙箱内装包源:`global`(官方源)/ `cn`(国内镜像)/ `custom` / `none` |
package/USAGE.md CHANGED
@@ -143,6 +143,7 @@ MODEL_ID=<your-model-name> MODEL_CHEAP_ID=<your-cheap-model-name>
143
143
  - 调用方可在 `objective` 里 `@<模型名>` 选模型,**只认已配置的名单**(`MODEL_ID` / `MODEL_CHEAP_ID`)——注入不了 `baseUrl`/`apiKey`;无 `@` → 默认模型。
144
144
  - **`GET /v1/models`**(带 Bearer)→ `{models:[{name,id,provider,reasoning,vision}],default}`,只含名字/能力、**不含密钥** —— 消费方(如 OA)拿它做 `@` 自动补全下拉的数据源。
145
145
  - 不设 `MODEL_CHEAP_ID` → 所有角色 = 主模型(行为不变)。详见 `design/19`。
146
+ - **1M 双窗**(`autoCompactTokens`):`967000` 只发给 **claude-sonnet-5 系 id ∧ 窗 ≥1M**(CC 的 per-model 值,不是窗宽几何)。其他 ≥1M 模型要双窗触发点请显式配 —— 主槽 `MODEL_AUTO_COMPACT_TOKENS=<tokens>`、cheap 槽 `MODEL_CHEAP_AUTO_COMPACT_TOKENS=<tokens>`(**两槽各配各的**:主槽的值按主模型 id/窗声明,不会外溢到 cheap)。不配 → core 按整窗的 `W-33000` 平几何触发,boot 期发 `auto_compact_window_not_derived_1m` warn 点名槽位(`slot=main|cheap`)。⚠️ cheap 槽不设 `MODEL_CHEAP_CONTEXT_WINDOW` 时**继承主模型的窗**,主模型是 1M 则 cheap 也按 1M 计。取值范围:**必须低于该槽的物理窗**(CC 的形是 `窗-33000`)—— 高于物理窗 = 触发点永远到不了 = autocompact 事实上关掉、改由破坏性 guard 砍历史;配高了照配的落但 boot 期发 `auto_compact_tokens_above_context_window` warn(不拒启、不夹紧)。
146
147
 
147
148
  **可选 — 开发者模式 building blocks(core 1.43,默认中立)**
148
149
  ```bash
@@ -443,7 +444,15 @@ READ_DENY_PATTERNS='[{"pattern":"**/secrets/**"},{"pattern":".ssh","caseSensitiv
443
444
  restart 才生效),但**从 enabled 集删除一个服务器即时生效**——refresh 拍收货后,被删服务器的
444
445
  MCP 调用当场按 coded 拒绝 `mcp.server_revoked` 结算(known-not-executed,引擎每次派发前探询
445
446
  台账,不缓存)。因此 `/health` 的 `restart.reasons` 对**纯删除向**不再报 `mcp`(签了就是为一个
446
- 已生效的变更滚动重启 fleet);删除+新增/改址混合拍照签。重新把服务器加回 enabled 集=解除撤销。
447
+ 已生效的变更滚动重启 fleet);删除+新增/改址混合拍照签。
448
+ **恢复=指纹判,不是名字判**:把服务器加回 enabled 集,只有当**整条目与 boot 那一份逐字同形**
449
+ (全字段指纹,`enabled` 键也算一位)时才解除撤销。三形**保持 revoked**、只能候 restart 收敛:
450
+ ①改址/改配置后同名加回(复活的会是 boot 冻结的**旧** mount,新形要 restart 才装上 ⇒ fail-closed);
451
+ ②boot 条目本没写 `enabled` 键、却用 `enabled:false → true` 开关切回(带上这个键就已经不同形了;
452
+ 想走开关撤销/恢复,boot 那份条目里就要显式写 `enabled: true`);③boot 期压根没铸出 baseline
453
+ (中心迟到到货 / boot 超时 ⇒ mount 集=env 集),那台机器整条中心撤销语义不适用,恒不解除。
454
+ ①②两形 `/health` 的 `restart.reasons` 会签 `mcp`,按信号滚动重启即收敛;判「解除了没有」看下面那个
455
+ 被撤名单,别按「我加回去了」推断。
447
456
  当前被撤名单可从 `GET /v1/diagnostics/wiring` 的 `mcpRevocations.revoked` 读(`null`=本进程无
448
457
  配置管道);`GET /v1/sessions/:id/mcp` 面板与执行面同一台账(被撤服务器的工具不再展示)。
449
458
  纯 env 配置的 `MCP_SERVERS` 部署无中心撤销语义,行为逐字不变。
@@ -176,6 +176,7 @@ export declare const ApprovalCardSchema: z.ZodObject<{
176
176
  requiresRealApproval: z.ZodBoolean;
177
177
  }, z.core.$strict>;
178
178
  governanceForced: z.ZodOptional<z.ZodLiteral<true>>;
179
+ inputHasBidi: z.ZodOptional<z.ZodLiteral<true>>;
179
180
  persistedRuleShadowed: z.ZodOptional<z.ZodString>;
180
181
  ruleSuggestions: z.ZodOptional<z.ZodArray<z.ZodObject<{
181
182
  rule: z.ZodString;
@@ -238,6 +239,7 @@ export declare const ApprovalCardEnvelopeSchema: z.ZodObject<{
238
239
  requiresRealApproval: z.ZodBoolean;
239
240
  }, z.core.$strict>;
240
241
  governanceForced: z.ZodOptional<z.ZodLiteral<true>>;
242
+ inputHasBidi: z.ZodOptional<z.ZodLiteral<true>>;
241
243
  persistedRuleShadowed: z.ZodOptional<z.ZodString>;
242
244
  ruleSuggestions: z.ZodOptional<z.ZodArray<z.ZodObject<{
243
245
  rule: z.ZodString;
@@ -304,6 +306,9 @@ export interface ApprovalCardSource {
304
306
  /** [2942]/[2943]:治理来源标 —— 与 wire 帧**同一份素材**(`ToolApprovalFrame` 结构上满足本接口),
305
307
  * 于是 live 帧 / `card_json` / 重放帧三面同源,不是三处各判一遍。 */
306
308
  governanceForced?: true;
309
+ /** E-14:bidi 在场位 —— 与 wire 帧**同一份素材**(`ToolApprovalFrame` 结构上满足本接口)⇒ live 帧 /
310
+ * `approval_request` / `card_json` / 重放帧四面同源,不是四处各扫一遍。 */
311
+ inputHasBidi?: true;
307
312
  /** #144(core 5.25.0):被越级的持久规则原文 —— **已 redactSecrets**(发帧点做,本模块只 clip)。
308
313
  * 与 wire 帧**同一份素材**(`ToolApprovalFrame` 结构上满足本接口)⇒ live 帧 / `card_json` / 重放帧三面同源。 */
309
314
  persistedRuleShadowed?: string;
@@ -127,6 +127,7 @@ export const ApprovalCardSchema = z
127
127
  })
128
128
  .strict(),
129
129
  governanceForced: z.literal(true).optional(),
130
+ inputHasBidi: z.literal(true).optional(),
130
131
  persistedRuleShadowed: z.string().max(MAX_RULE_TEXT_CHARS).optional(),
131
132
  ruleSuggestions: z.array(RuleSuggestionSchema).max(MAX_RULE_SUGGESTIONS).optional(),
132
133
  probeCause: ProbeCauseSchema.optional(),
@@ -184,6 +185,7 @@ export function buildApprovalCard(source, req, requiresRealApproval) {
184
185
  requiresRealApproval,
185
186
  },
186
187
  ...(source.governanceForced === true ? { governanceForced: true } : {}),
188
+ ...(source.inputHasBidi === true ? { inputHasBidi: true } : {}),
187
189
  ...(shadowedRule !== undefined && shadowedRule !== "" ? { persistedRuleShadowed: shadowedRule } : {}),
188
190
  ...(probeCause !== undefined ? { probeCause } : {}),
189
191
  ...(ruleEvidence !== undefined ? { ruleEvidence } : {}),
@@ -35,6 +35,16 @@ export async function createConfigCenterRuntime(ctx) {
35
35
  }
36
36
  return generationOrdinal;
37
37
  };
38
+ const reserveArrivalGeneration = () => {
39
+ generationOrdinal += 1;
40
+ lastGenerationVersion = undefined;
41
+ return generationOrdinal;
42
+ };
43
+ const useArrivalGeneration = (version, ticket) => {
44
+ if (ticket === generationOrdinal)
45
+ lastGenerationVersion = version;
46
+ return ticket;
47
+ };
38
48
  let manualRefresh;
39
49
  let appliedPlaneEff;
40
50
  let planeDeferredNoHandoff;
@@ -459,7 +469,7 @@ export async function createConfigCenterRuntime(ctx) {
459
469
  if (configProvider) {
460
470
  const ccRef = config.configCenter;
461
471
  let refreshInFlight = false;
462
- const refreshTick = async (prefetched) => {
472
+ const refreshTick = async (arrival) => {
463
473
  if (refreshInFlight)
464
474
  return "ok";
465
475
  refreshInFlight = true;
@@ -469,7 +479,7 @@ export async function createConfigCenterRuntime(ctx) {
469
479
  metrics.inc("models_tiers_plane_deferred_stuck_total");
470
480
  logger.warn("models_tiers_deferred_no_handoff", { version: planeDeferredNoHandoff.version, since: planeDeferredNoHandoff.since, note: "candidate still retained without a next-boot handoff — mount restart-surviving LKG storage + CONFIG_LKG_DURABLE=true, or restart manually after ensuring the candidate is re-pulled at boot" });
471
481
  }
472
- const r = prefetched !== undefined ? prefetched : await configProvider.fetchEffective(ccEtag);
482
+ const r = arrival !== undefined ? arrival.result : await configProvider.fetchEffective(ccEtag);
473
483
  if (r === null && planeDeferredNoHandoff) {
474
484
  const skillsDebt = (planeDeferredNoHandoff.blocked ?? []).includes("skills");
475
485
  const promoted = lkgSurvivesRestart || skillsDebt ? await persistLkgDurable(planeDeferredNoHandoff.candidate, planeDeferredNoHandoff.candidateEtag) : false;
@@ -489,7 +499,7 @@ export async function createConfigCenterRuntime(ctx) {
489
499
  }
490
500
  }
491
501
  if (r) {
492
- const tickGen = generationOf(r.effective.version);
502
+ const tickGen = arrival !== undefined ? useArrivalGeneration(r.effective.version, arrival.ordinal) : generationOf(r.effective.version);
493
503
  applyLedger.seenTarget(tickGen, r.effective.version);
494
504
  const promptsRaw = r.effective.prompts;
495
505
  const promptsGate = promptsRaw !== undefined ? validatePromptsDomain(promptsRaw) : { ok: true };
@@ -624,10 +634,10 @@ export async function createConfigCenterRuntime(ctx) {
624
634
  return "ok";
625
635
  };
626
636
  let inFlight;
627
- const runTick = (prefetched) => {
637
+ const runTick = (arrival) => {
628
638
  if (inFlight)
629
639
  return inFlight;
630
- const p = refreshTick(prefetched).finally(() => {
640
+ const p = refreshTick(arrival).finally(() => {
631
641
  inFlight = undefined;
632
642
  });
633
643
  inFlight = p;
@@ -636,21 +646,20 @@ export async function createConfigCenterRuntime(ctx) {
636
646
  manualRefresh = runTick;
637
647
  ccTimer = setInterval(() => void runTick(), 60_000);
638
648
  ccTimer.unref?.();
639
- const deferredBootApply = async (r) => {
649
+ const deferredBootApply = async (r, arrivalGen) => {
640
650
  if (!r)
641
651
  return;
642
652
  if (refreshInFlight)
643
653
  return;
644
654
  refreshInFlight = true;
645
655
  try {
646
- await deferredBootApplyInner(r);
656
+ await deferredBootApplyInner(r, useArrivalGeneration(r.effective.version, arrivalGen));
647
657
  }
648
658
  finally {
649
659
  refreshInFlight = false;
650
660
  }
651
661
  };
652
- const deferredBootApplyInner = async (r) => {
653
- const lateGen = generationOf(r.effective.version);
662
+ const deferredBootApplyInner = async (r, lateGen) => {
654
663
  applyLedger.seenTarget(lateGen, r.effective.version);
655
664
  const promptsRaw = r.effective.prompts;
656
665
  const promptsGate = promptsRaw !== undefined ? validatePromptsDomain(promptsRaw) : { ok: true };
@@ -736,8 +745,10 @@ export async function createConfigCenterRuntime(ctx) {
736
745
  pendingRestart = { restartRequired: true, reasons, version: r.effective.version, since: Date.now() };
737
746
  logger.info("config_loaded_deferred", { source: configProvider.kind, version: r.effective.version, models: (r.effective.models?.models ?? []).filter((m) => m.enabled !== false).length, ...(reasons.length > 0 ? { restartRequired: true, restartReasons: reasons } : {}) });
738
747
  };
739
- if (bootConfigPending)
740
- void bootConfigPending.then((r) => void (lkgBooted ? runTick(r) : deferredBootApply(r)), () => { });
748
+ if (bootConfigPending) {
749
+ const arrivalGen = reserveArrivalGeneration();
750
+ void bootConfigPending.then((r) => void (lkgBooted ? runTick({ result: r, ordinal: arrivalGen }) : deferredBootApply(r, arrivalGen)), () => { });
751
+ }
741
752
  }
742
753
  },
743
754
  stopRefreshLoop() {
@@ -82,6 +82,7 @@ export function createExecutionEnv(ctx) {
82
82
  privateKey: config.remoteExec.privateKey,
83
83
  ...(config.remoteExec.port != null ? { port: config.remoteExec.port } : {}),
84
84
  ...(config.remoteExec.mountPath ? { mountPath: config.remoteExec.mountPath } : {}),
85
+ ...(config.remoteExec.hostKey !== undefined ? { hostKey: config.remoteExec.hostKey } : {}),
85
86
  });
86
87
  }
87
88
  else if (config.remoteExec?.provider === "adb") {
@@ -12,6 +12,9 @@
12
12
  * "第二个信号"的语义读数。
13
13
  * 3. `clearInterval(reaper)` 必须是 hardShutdown 的第一件事(在 `server.close()` 之前),否则
14
14
  * 收尾期还会有 reaper tick 打向正在关闭的池。
15
+ * 4. **进程级监听一律成对**(A-070 二轮件4):本函数装的五只(三信号 + #320① 双钩)只能经返回的
16
+ * {@link ShutdownHandle} 摘 —— 调用方手写「摘三信号」的收尾会把双钩漏在进程上,而陈旧的那只
17
+ * rejection 钩仍握着 `process.exit(1)`,会对后续无关的 rejection 把进程打掉。
15
18
  */
16
19
  import { Runner, type RunnerDeps } from "@sema-agent/core";
17
20
  import type { ServiceConfig } from "../config.js";
@@ -73,6 +76,19 @@ export interface ShutdownCtx {
73
76
  stopRefreshLoop(): void;
74
77
  } | undefined;
75
78
  }
76
- /** 注册 SIGTERM/SIGINT/SIGHUP 收尾链。**必须在 listen 之后调用**(见文件头「位置即契约」)。 */
77
- export declare function installShutdownHandlers(ctx: ShutdownCtx): void;
79
+ /**
80
+ * `installShutdownHandlers` 的**摘除把手**(A-070 二轮重扫件4)
81
+ *
82
+ * 存在理由:本函数装的是**进程级**监听(三信号 + #320① 双钩),而进程级注册表是全局单例 —— 一个进程里
83
+ * 装两次就是两套 handler 同时在场。生产进程只装一次、装到死,所以这把手服务的是**同进程内反复装配**的
84
+ * 那一族调用方(测试):此前它们各自手写「只摘三信号」的收尾,双钩每次净泄漏两只,而陈旧 handler 会对
85
+ * **后续无关**的 rejection 执行 `process.exit(1)`。注册/摘除配对因此必须由装配方给,不许调用方各猜一份。
86
+ */
87
+ export interface ShutdownHandle {
88
+ /** 摘除**本次调用**注册的全部进程级监听(三信号 + 双钩)并停掉本次装的 parent 监视腿。幂等。 */
89
+ dispose(): void;
90
+ }
91
+ /** 注册 SIGTERM/SIGINT/SIGHUP 收尾链。**必须在 listen 之后调用**(见文件头「位置即契约」)。
92
+ * 返回 {@link ShutdownHandle} —— 同进程内反复装配的调用方(测试)必须用它收尾。 */
93
+ export declare function installShutdownHandlers(ctx: ShutdownCtx): ShutdownHandle;
78
94
  //# sourceMappingURL=shutdown.d.ts.map
@@ -2,6 +2,7 @@ import { Runner, defaultTaskRegistry } from "@sema-agent/core";
2
2
  import { writeCrashLast, clearCrashLast, readCrashLast } from "./crash-last.js";
3
3
  import { createSighupIdleHandler } from "../sighup-idle.js";
4
4
  import { createParentWatch } from "../parent-watch.js";
5
+ import { isScriptRealmRejection, describeRejectionReason } from "../orchestration/hardened-vm-runner.js";
5
6
  export function installShutdownHandlers(ctx) {
6
7
  const { config, logger, server, reaper, fleetReconcile, retentionLane, releaseRetentionLease, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, version, } = ctx;
7
8
  const dataRoot = config.localDataRoot;
@@ -14,16 +15,34 @@ export function installShutdownHandlers(ctx) {
14
15
  if (dataRoot)
15
16
  writeCrashLast(dataRoot, { version, inflight: drainState.inflight?.(), err });
16
17
  };
17
- process.on("uncaughtException", (err) => {
18
- writeLastWords(err);
19
- console.error(err);
20
- process.exit(1);
21
- });
22
- process.on("unhandledRejection", (err) => {
23
- writeLastWords(err);
24
- console.error(err);
25
- process.exit(1);
26
- });
18
+ const onUncaughtException = (err) => {
19
+ try {
20
+ writeLastWords(err);
21
+ console.error(err);
22
+ }
23
+ finally {
24
+ process.exit(1);
25
+ }
26
+ };
27
+ process.on("uncaughtException", onUncaughtException);
28
+ const onUnhandledRejection = (err, promise) => {
29
+ if (isScriptRealmRejection(promise)) {
30
+ try {
31
+ logger.warn("unhandled_rejection_script_realm_contained", { reason: describeRejectionReason(err) });
32
+ }
33
+ catch {
34
+ }
35
+ return;
36
+ }
37
+ try {
38
+ writeLastWords(err);
39
+ console.error(err);
40
+ }
41
+ finally {
42
+ process.exit(1);
43
+ }
44
+ };
45
+ process.on("unhandledRejection", onUnhandledRejection);
27
46
  let closing = false;
28
47
  let parentWatch;
29
48
  const hardShutdown = () => {
@@ -74,6 +93,7 @@ export function installShutdownHandlers(ctx) {
74
93
  forceClose.unref?.();
75
94
  };
76
95
  let draining = false;
96
+ let drainTick;
77
97
  const drainThenShutdown = () => {
78
98
  if (closing)
79
99
  return;
@@ -98,14 +118,16 @@ export function installShutdownHandlers(ctx) {
98
118
  const elapsed = Math.round(performance.now() - t0);
99
119
  if (n === 0 || elapsed >= config.drainGraceMs) {
100
120
  clearInterval(tick);
121
+ drainTick = undefined;
101
122
  logger.info("drain_complete", { inflight: n, elapsedMs: elapsed, timedOut: n > 0 });
102
123
  hardShutdown();
103
124
  }
104
125
  }, 1_000);
126
+ drainTick = tick;
105
127
  };
106
128
  process.on("SIGTERM", drainThenShutdown);
107
129
  process.on("SIGINT", hardShutdown);
108
- process.on("SIGHUP", createSighupIdleHandler({
130
+ const onSighup = createSighupIdleHandler({
109
131
  inflight: () => drainState.inflight?.() ?? 0,
110
132
  lastActivityAt: () => drainState.lastActivityAt?.() ?? 0,
111
133
  isStopped: () => closing || draining,
@@ -113,7 +135,8 @@ export function installShutdownHandlers(ctx) {
113
135
  shutdown: hardShutdown,
114
136
  idleGraceMs: config.sighupIdleGraceMs,
115
137
  log: (event, fields) => logger.info(event, fields),
116
- }));
138
+ });
139
+ process.on("SIGHUP", onSighup);
117
140
  if (config.parentPid !== undefined) {
118
141
  parentWatch = createParentWatch({
119
142
  pid: config.parentPid,
@@ -122,5 +145,24 @@ export function installShutdownHandlers(ctx) {
122
145
  log: (event, fields) => logger.info(event, fields),
123
146
  });
124
147
  }
148
+ let disposed = false;
149
+ return {
150
+ dispose: () => {
151
+ if (disposed)
152
+ return;
153
+ disposed = true;
154
+ process.removeListener("uncaughtException", onUncaughtException);
155
+ process.removeListener("unhandledRejection", onUnhandledRejection);
156
+ process.removeListener("SIGTERM", drainThenShutdown);
157
+ process.removeListener("SIGINT", hardShutdown);
158
+ process.removeListener("SIGHUP", onSighup);
159
+ onSighup.stop();
160
+ if (drainTick !== undefined) {
161
+ clearInterval(drainTick);
162
+ drainTick = undefined;
163
+ }
164
+ parentWatch?.stop();
165
+ },
166
+ };
125
167
  }
126
168
  //# sourceMappingURL=shutdown.js.map
@@ -35,22 +35,25 @@
35
35
  * 网关账号去打这只模型的上游,正是毒丸机制存在的理由(CLAUDE.md #157 安全轴 fail-closed)。换装后
36
36
  * 这条语义由 core 承载得更彻底:座位抛 ⇒ `runSideQuery` 的 `await` 直接上抛,brain 一个字节都没发。
37
37
  *
38
- * ## 具名残余:**无 per-model key off-route 模型仍收网关 key**(codex R1-F1,逐条评估后如实留)
38
+ * ## 具名残余已在**源头**清偿(core 5.57.0,#345 提货批)—— 本席的 P-DEBT 腿随之撤线
39
39
  *
40
- * 判据本身没变(座位到货也没改它):`undefined` 座位不给凭据 core 用网关 key,即使这只模型的
41
- * `baseUrl` 指向别家。为什么**不**在本批把它改成 fail-closed(逐条,而不是"先不管"):
42
- * **与主推理链同语义**才是当前的正确性基准 —— 任务面(core 每次调 `getApiKeyAndHeaders`,返回
43
- * `undefined` 即网关 key)在**同一个** catalog、**同一批**模型上就是这个姿势。只把 side-query 收严,
44
- * 等于让同一只模型「跑任务能用、问一句就 401」,而任务面才是量大的那一面 —— 半边收严的覆盖比
45
- * 统一的宽松更难排障。
46
- * **谁被伤到**:`baseUrl` 是**运维**在 config-center 里自己写的(不是用户输入,用户只能在
47
- * `atModelAllowlist` 里点名单内的模型)。"同一账号多网关主机/多区域"是既有且正当的部署形,
48
- * 它今天靠网关 key 工作;无版本协商地收严 = 这类部署当场 401,而且没有旋钮可回退。
49
- * hook 面(`hooks/hook-llm.ts`)确实是 fail-closed 的,但它的辖域是 **hook roster**(运维给钩子挑的
50
- * 模型),不是用户可点的整本目录;两者的收严代价不同量级。
51
- * ⇒ 本批**不改行为**,把它作为**跨面语义问题**上报(要收严就三面一起收严、带旋钮、走表态制),
52
- * 而不是在这一面偷偷收严。真要收严时的正解:按 `normUrl` 比对 `gatewayBaseUrl`(+fallback 列表)
53
- * anthropic 路由,off-route ∧ 无 per-model key ⇒ 拒发,并同批改任务面。
40
+ * 曾经的具名残余是「无 per-model key **off-route** 模型仍收网关 key」(codex R1-F1):座位回
41
+ * `undefined` ⇒ core 用网关 key,而选路仍按 `model.baseUrl` 这一次调用真把共享网关凭据发给了别家
42
+ * 主机。当年**刻意不在本面收严**,理由是「与主推理链逐字同语义,单面收严 = 同一只模型跑任务能用、
43
+ * 问一句 401」,收严件挂在跨面件 #309;补偿是一条 `P-DEBT` 计数(census 32 行)。
44
+ *
45
+ * core 5.57.0 把这件事**在唯一正确的层**做了:`adjudicateModelRoute`(`brain/route-adjudicator.ts`)
46
+ * 是配对法的单一实现,三只一方 brain(openai / anthropic / open-responses)在 `buildRequest` 里逐次
47
+ * 复判 —— 部署声明了 `config.baseUrl` 而 entry URL 不在那个根上、凭据又只有部署级那一份 ⇒
48
+ * `route.credential_mismatch` 响亮拒;连部署级凭据都没有 `route.credential_missing`。**三面同批**
49
+ * (主推理链 / side-query / WebFetch 摘要走的是同一只 brain 栈),旋钮 = 给模型自己的凭据、或不声明
50
+ * `config.baseUrl`(core 的 `"unpinned"` 快速起步姿态)。⇒ #309 要的「三面同批 + 旋钮」由上游一次交齐。
51
+ *
52
+ * 于是本席的 `isOffRouteBaseUrl` + `recordFailOpen("server.model-key.off-route-model-uses-gateway-key")`
53
+ * 两件**一并撤线**:那条计数的语义是「这一次调用真把网关凭据发给了别家」,而在 5.57 上那次调用根本
54
+ * 发不出去(core 在 brain 的 request gate 上先拒)—— 留着它只会在遥测里记一条与事实相反的债。
55
+ * 撤线同批:`docs/FAIL-OPEN-CENSUS.md` 第 32 行改终态、`observability/fail-open.ts` 销 tag。
56
+ * 新语义的钉在 `test/side-query-key-plane.test.ts`(off-root 拒格 + 同根回落格)。
54
57
  */
55
58
  import { type Brain, type SideQueryResult, type SideQuerySpec } from "@sema-agent/core";
56
59
  import type { ServiceConfig } from "../config-types.js";
@@ -63,18 +66,6 @@ export type KeyResolver = ((model: ModelKeyRef) => Promise<{
63
66
  export type PerModelAuthSeat = NonNullable<SideQuerySpec["getApiKeyAndHeaders"]>;
64
67
  /** side-query 执行席:HTTP 路由(`routes/side-query.ts`)唯一的 brain 出口。 */
65
68
  export type SideQueryLane = (spec: SideQuerySpec) => Promise<SideQueryResult>;
66
- /**
67
- * 「这只模型的 baseUrl 打的是**别家**主机吗」—— core 的选路规则是 `model.baseUrl || config.baseUrl`,
68
- * 所以只有 `model.baseUrl` 非空时才可能离开本部署网关。比较按 URL 归一(`new URL` 小写 host、丢默认端口、
69
- * 去尾斜杠),裸字符串比会在 host 大小写/默认端口写法上假阴。
70
- *
71
- * 参照系缺席(`gatewayBaseUrl` 未给)⇒ 回 `false`:判不了就不判,别猜。
72
- *
73
- * ⚠️ `hooks/hook-llm.ts` 有一只逐字同形的内联归一器(它用同一判据做 **fail-closed 拒发**,本函数只
74
- * 做**计数**)。两处合一属跨面收严件 #309 的射程(那时三面同批换判据),本批刻意不动 hook 面的 fail-closed
75
- * 门 —— 在只改可观测性的批里重写另一条安全轴的门,风险与收益不对等。
76
- */
77
- export declare function isOffRouteBaseUrl(modelBaseUrl: string | undefined, gatewayBaseUrl: string | undefined): boolean;
78
69
  export interface SideQueryLaneCtx {
79
70
  /** 与 `RunnerDeps.brain` **同一只**实例(#341 换装后本席不再包它 —— key 走 spec 座位)。 */
80
71
  brain: Brain;
@@ -95,19 +86,15 @@ export interface SideQueryLaneCtx {
95
86
  * · resolver 抛(sealed key 中毒)⇒ **上抛**,`runSideQuery` 的 `await` 直接把它扔给调用方,
96
87
  * 本次调用整体失败,一个字节都不发。
97
88
  *
98
- * 🔴 A-057.59 [PARTIAL high](2026-08-19;#341 换装后**处置不变**)—— 「无 per-model key 的 off-route
99
- * 模型仍收网关 key」这条具名残余**行为不变**(逐条理由见模块顶注「具名残余」段;core 的座位是
100
- * 「absent construction-time credentials apply」,与主推理链
101
- * `dist/engine/harness/agent-harness.js` `auth?.apiKey !== undefined` 臂逐字同语义,座位到货并没有
102
- * 改变这条残余,所以 `P-DEBT` 计数**不可销**),但**每次调用零留痕**这一半是要清偿的存量欠账:
103
- * config-apply 期那条 `sema_registry_model_key_env_missing` warn 只覆盖「声明了 env 名却没设」,覆盖不到
104
- * 「压根没配 per-model key 的 off-route 模型」,而后者才是真发生外泄的那一形。按 CLAUDE.md #157
105
- * 「一时改不动的 P 类记 `P-DEBT`」补计数:命中条件 = **解不出 per-model key ∧ 该模型的 baseUrl 不是本
106
- * 部署网关** ⇒ 这一次调用真把网关凭据发给了别家主机。`gatewayBaseUrl` 缺席(调用方没给参照系)⇒ 不计
107
- * (不猜),与「宁可漏计不可错计」同向。
89
+ * 🔴 A-057.59 [PARTIAL high](2026-08-19 立;**2026-08-24 / core 5.57.0 源头清偿,本席腿撤线**)——
90
+ * 「无 per-model key 的 off-route 模型仍收网关 key」这条具名残余的收严件曾挂在跨面件 #309,补偿是一条
91
+ * `P-DEBT` 计数。core 5.57.0 `adjudicateModelRoute` 在**三只一方 brain 的 request gate** 上把它改成
92
+ * 响亮拒(`route.credential_mismatch` / `route.credential_missing`),三面同批、旋钮成文 那条计数的
93
+ * 前提(「这一次调用真把网关凭据发给了别家」)不再成立,`isOffRouteBaseUrl` `recordFailOpen` 一并
94
+ * 撤线(逐条理由见模块顶注)。本座位从此**只做一件事**:按模型解 per-model 凭据,解不出就交回
95
+ * `undefined` core 按它自己的配对法判。
108
96
  */
109
97
  export declare function createPerModelAuthSeat(getKeyResolver: () => KeyResolver, opts?: {
110
- readonly gatewayBaseUrl?: string;
111
98
  readonly upstream?: SideQuerySpec["getApiKeyAndHeaders"];
112
99
  }): PerModelAuthSeat;
113
100
  /**
@@ -124,21 +111,19 @@ export declare function createPerModelAuthSeat(getKeyResolver: () => KeyResolver
124
111
  * `complete` 在场时同样包一层:core 的 `Brain.complete` 是 compaction/摘要腿的直调口(WebFetch 摘要器
125
112
  * 正是**优先**走它),漏包等于给同一个病留第二条路。
126
113
  */
127
- export declare function createPerModelKeyBrain(brain: Brain, getKeyResolver: () => KeyResolver, opts?: {
128
- readonly gatewayBaseUrl?: string;
129
- }): Brain;
114
+ export declare function createPerModelKeyBrain(brain: Brain, getKeyResolver: () => KeyResolver): Brain;
130
115
  /**
131
116
  * 装配 side-query 执行席 = 真 `runSideQuery` + core 的 per-model auth 座,models/roles 与 Runner 同源。
132
117
  *
133
- * **按调用**取三件活值(`models` / `getKeyResolver` / `gatewayBaseUrl`),因为它们是**同一次** center
134
- * 下发里成对换代的(#303 codex R1-F2:目录与密钥表零 await 窗成对换代):
118
+ * **按调用**取两件活值(`models` / `getKeyResolver`),因为它们是**同一次** center 下发里成对换代的
119
+ * (#303 codex R1-F2:目录与密钥表零 await 窗成对换代):
135
120
  * · `models` = `expandTiers(config.models, config.tiers)` —— 档位词 / CC 别名(`flash`/`opus`/…)在
136
121
  * side-query 上照常可选路(core 的 `Runner` 构造时做同一件事),且 `Runner` 那次展开是**构造时快照**,
137
122
  * 而本路由的模型白名单门(`routes/side-query.ts` 的 `isModelAllowlisted`)按请求现算同一张增广目录 ——
138
123
  * 两边取同一份才不会出现「名单放行、选路说不认识」的分歧;
139
- * · `getKeyResolver()` —— 热应用整个换 resolver 引用,活取才跟得上(装配时快照 = 旧 key 用到重启);
140
- * · `config.gatewayBaseUrl` —— off-route 判据的参照系。装配时快照会让「换网关那一刻起,这次调用把网关
141
- * 凭据发给了别家」在遥测里永久失明(钉:`test/side-query-key-plane.test.ts` 的活取钉)
124
+ * · `getKeyResolver()` —— 热应用整个换 resolver 引用,活取才跟得上(装配时快照 = 旧 key 用到重启)
125
+ * (第三件 `config.gatewayBaseUrl` 曾是 off-route 记债判据的参照系,core 5.57.0 起判据整只上收 core 的
126
+ * 配对裁决器 —— 而 core 拿的是 brain 构造时那份 `config.baseUrl`,与本席无关,见顶注撤线段。)
142
127
  *
143
128
  * `spec` 自带的 `getApiKeyAndHeaders`(嵌入方/装饰器)**不被覆盖** —— 它作为 `upstream` 折进本席,
144
129
  * 每次调用先问它,它没意见时才由本席按模型解(见 `createPerModelAuthSeat` 顶注第一条臂)。
@@ -1,26 +1,15 @@
1
1
  import { expandTiers, runSideQuery } from "@sema-agent/core";
2
- import { recordFailOpen } from "../observability/fail-open.js";
3
- export function isOffRouteBaseUrl(modelBaseUrl, gatewayBaseUrl) {
4
- if (!modelBaseUrl || !gatewayBaseUrl)
5
- return false;
6
- const norm = (u) => URL.canParse(u) ? ((x) => `${x.protocol}//${x.host}${x.pathname.replace(/\/+$/, "")}`)(new URL(u)) : u.replace(/\/+$/, "");
7
- return norm(modelBaseUrl) !== norm(gatewayBaseUrl);
8
- }
9
2
  export function createPerModelAuthSeat(getKeyResolver, opts = {}) {
10
3
  return async (model) => {
11
4
  const resolve = getKeyResolver();
12
5
  const given = await opts.upstream?.(model);
13
6
  if (given?.apiKey !== undefined)
14
7
  return given;
15
- const resolved = resolve ? await resolve({ name: model.name }) : undefined;
16
- if (resolved === undefined && isOffRouteBaseUrl(model.baseUrl, opts.gatewayBaseUrl)) {
17
- recordFailOpen("server.model-key.off-route-model-uses-gateway-key", model.name);
18
- }
19
- return resolved;
8
+ return resolve ? await resolve({ name: model.name }) : undefined;
20
9
  };
21
10
  }
22
- export function createPerModelKeyBrain(brain, getKeyResolver, opts = {}) {
23
- const seat = createPerModelAuthSeat(getKeyResolver, opts);
11
+ export function createPerModelKeyBrain(brain, getKeyResolver) {
12
+ const seat = createPerModelAuthSeat(getKeyResolver);
24
13
  const keyFor = async (model, given) => {
25
14
  if (given !== undefined)
26
15
  return given;
@@ -47,7 +36,6 @@ export function createSideQueryLane(ctx) {
47
36
  return (spec) => runSideQuery({
48
37
  ...spec,
49
38
  getApiKeyAndHeaders: createPerModelAuthSeat(ctx.getKeyResolver, {
50
- ...(ctx.config.gatewayBaseUrl !== undefined ? { gatewayBaseUrl: ctx.config.gatewayBaseUrl } : {}),
51
39
  ...(spec.getApiKeyAndHeaders !== undefined ? { upstream: spec.getApiKeyAndHeaders } : {}),
52
40
  }),
53
41
  }, {
@@ -23,8 +23,9 @@
23
23
  *
24
24
  * brain 换成 `createPerModelKeyBrain(brain, getKeyResolver)`:
25
25
  * · 有 per-model key ⇒ 注入 `options.apiKey`,请求带模型自己的凭据;
26
- * · 无 ⇒ 不注入 ⇒ 回落网关 key(与主推理链/side-query 逐字同语义,additive 契约;该残余的成文与
27
- * 跨面收严件见 side-query-lane.ts 顶注「具名残余」段与 A-057.59);
26
+ * · 无 ⇒ 不注入 ⇒ 交回 core 按它自己的 key↔URL 配对法判(core 5.57.0 起:部署根声明了而 entry URL
27
+ * 不在那个根上 ⇒ `route.credential_mismatch` 响亮拒;未声明部署根 = `"unpinned"` 快速起步姿态 ⇒
28
+ * 照旧回落网关 key。A-057.59 的具名残余与其 P-DEBT 腿已在源头清偿,见 side-query-lane.ts 顶注);
28
29
  * · 毒丸 ⇒ **上抛**,一个字节都不发(CLAUDE.md #157 安全轴 fail-closed)。
29
30
  * wrapper 只包本席这一只引用,`RunnerDeps.brain` 零扰动(与 side-query 席同形)。
30
31
  *
@@ -1,7 +1,7 @@
1
1
  import { createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel } from "@sema-agent/core";
2
2
  import { createPerModelKeyBrain } from "./side-query-lane.js";
3
3
  export function createWebFetchSummarizeLane(ctx) {
4
- const brain = createPerModelKeyBrain(ctx.brain, ctx.getKeyResolver, { gatewayBaseUrl: ctx.config.gatewayBaseUrl });
4
+ const brain = createPerModelKeyBrain(ctx.brain, ctx.getKeyResolver);
5
5
  return async (content, prompt, signal) => {
6
6
  const model = (() => {
7
7
  try {
package/dist/brain.js CHANGED
@@ -1,5 +1,16 @@
1
1
  import { createAnthropicBrain, createCircuitBreakerBrain, createDegradingBrain, createFailoverBrain, createOpenAIBrain, createRoutingBrain, } from "@sema-agent/core";
2
+ import { normalizeBaseUrl } from "@sema-agent/core";
2
3
  import { resolveModelApiKey } from "./key-resolver.js";
4
+ import { isSealedKeyPoison } from "./sealed-key.js";
5
+ import { recordFailOpen } from "./observability/fail-open.js";
6
+ function isOffDeploymentRoot(baseUrl, config) {
7
+ if (!baseUrl)
8
+ return false;
9
+ const entry = normalizeBaseUrl(baseUrl);
10
+ if (entry === "")
11
+ return false;
12
+ return ![config.gatewayBaseUrl, ...config.gatewayFallbackUrls].some((u) => normalizeBaseUrl(u) === entry);
13
+ }
3
14
  export const FAST_FAIL_MAX_RETRIES = 2;
4
15
  const DEFAULT_RESILIENCE = {
5
16
  connectTimeoutMs: 30_000,
@@ -70,6 +81,15 @@ export function createBrain(config, deps = {}) {
70
81
  resolveModelApiKey(degradeTo, config.modelApiKeyEnv ?? {}, process.env, config.modelApiKeys ?? {});
71
82
  return own ?? config.gatewayApiKey;
72
83
  };
84
+ const hopBorrowsGatewayKey = () => {
85
+ const keys = config.modelApiKeys ?? {};
86
+ if (isSealedKeyPoison(keys[fallbackModel.name]) || isSealedKeyPoison(keys[degradeTo]))
87
+ return false;
88
+ const own = resolveModelApiKey(fallbackModel.name, config.modelApiKeyEnv ?? {}, process.env, keys) ??
89
+ resolveModelApiKey(degradeTo, config.modelApiKeyEnv ?? {}, process.env, keys);
90
+ return own === undefined && config.gatewayApiKey !== undefined && isOffDeploymentRoot(fallbackModel.baseUrl, config);
91
+ };
92
+ const anthropicLaneHop = fallbackModel.provider === "anthropic" && anthropicBrain !== undefined;
73
93
  const fallback = fallbackModel.provider === "anthropic" && anthropicBrain
74
94
  ?
75
95
  anthropicBrain
@@ -82,7 +102,18 @@ export function createBrain(config, deps = {}) {
82
102
  fetchImpl,
83
103
  ...timeouts,
84
104
  });
85
- brain = createDegradingBrain({ primary: brain, fallbacks: [{ brain: fallback, model: fallbackModel }], downgradeOn: config.degrade.downgradeOn });
105
+ const fallbackWithDebtProbe = anthropicLaneHop
106
+ ? fallback
107
+ : {
108
+ stream: (m, c, o) => {
109
+ if (hopBorrowsGatewayKey())
110
+ recordFailOpen("server.model-key.degrade-hop-borrows-gateway-key", fallbackModel.name);
111
+ return fallback.stream(m, c, o);
112
+ },
113
+ ...(fallback.adjudicateRoute !== undefined ? { adjudicateRoute: (m, a) => fallback.adjudicateRoute?.(m, a) } : {}),
114
+ ...(fallback.complete !== undefined ? { complete: (m, c, o) => fallback.complete(m, c, o) } : {}),
115
+ };
116
+ brain = createDegradingBrain({ primary: brain, fallbacks: [{ brain: fallbackWithDebtProbe, model: fallbackModel }], downgradeOn: config.degrade.downgradeOn });
86
117
  }
87
118
  }
88
119
  return brain;
@@ -85,7 +85,8 @@ export function applyEffective(config, eff, logger, opts = {}) {
85
85
  const staged = stageEffective(config, eff, logger, opts);
86
86
  const gen = opts.generation ?? staged.version;
87
87
  const live = appliedGeneration.get(config);
88
- if (staged.version > 0 && live !== undefined && gen < live) {
88
+ const ordered = opts.generation !== undefined || staged.version > 0;
89
+ if (ordered && live !== undefined && gen < live) {
89
90
  logger?.warn("sema_registry_stale_generation_refused", { staged: staged.version, stagedOrdinal: gen, live, note: "an older staged generation must not overwrite a newer committed one — refused whole; next poll replays" });
90
91
  return false;
91
92
  }
@@ -103,7 +104,7 @@ export function applyEffective(config, eff, logger, opts = {}) {
103
104
  }
104
105
  }
105
106
  commitStaged(config, staged);
106
- if (staged.version > 0)
107
+ if (ordered)
107
108
  appliedGeneration.set(config, gen);
108
109
  opts.report?.({
109
110
  version: staged.version,