@sema-agent/server 7.35.1 → 7.36.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 (53) hide show
  1. package/README.zh-CN.md +1 -1
  2. package/USAGE.md +10 -1
  3. package/dist/boot/config-center.js +14 -4
  4. package/dist/boot/org-memory.d.ts +21 -0
  5. package/dist/boot/org-memory.js +1 -1
  6. package/dist/boot/parked-revive-gate.d.ts +16 -1
  7. package/dist/boot/parked-revive-gate.js +33 -68
  8. package/dist/boot/runner-deps.d.ts +1 -1
  9. package/dist/boot/runner-deps.js +18 -3
  10. package/dist/boot/side-query-lane.d.ts +124 -0
  11. package/dist/boot/side-query-lane.js +158 -0
  12. package/dist/boot/webfetch-summarize-lane.d.ts +60 -0
  13. package/dist/boot/webfetch-summarize-lane.js +67 -0
  14. package/dist/brain.js +15 -1
  15. package/dist/config-types.d.ts +27 -2
  16. package/dist/config.js +13 -3
  17. package/dist/degenerate-instrument.d.ts +13 -1
  18. package/dist/degenerate-instrument.js +13 -1
  19. package/dist/http/active-run-conflict.js +16 -0
  20. package/dist/http/routes/a2a-serve.js +12 -2
  21. package/dist/http/routes/admin-drain.d.ts +17 -0
  22. package/dist/http/routes/admin-drain.js +7 -1
  23. package/dist/http/routes/approvals-assistant.js +3 -3
  24. package/dist/http/routes/capabilities.js +11 -2
  25. package/dist/http/routes/rules.js +7 -4
  26. package/dist/http/routes/runs.js +55 -8
  27. package/dist/http/routes/sessions-list.js +1 -1
  28. package/dist/http/routes/sessions.js +4 -4
  29. package/dist/http/routes/side-query.js +6 -1
  30. package/dist/http/server.d.ts +18 -5
  31. package/dist/http/server.js +5 -1
  32. package/dist/index.d.ts +3 -0
  33. package/dist/index.js +14 -0
  34. package/dist/main.js +17 -28
  35. package/dist/memory-posture.d.ts +14 -1
  36. package/dist/memory-posture.js +2 -0
  37. package/dist/observability/fail-open.d.ts +12 -0
  38. package/dist/observability/fail-open.js +12 -0
  39. package/dist/parked-decide.d.ts +3 -1
  40. package/dist/parked-decide.js +34 -11
  41. package/dist/rules-consent.d.ts +20 -0
  42. package/dist/rules-consent.js +21 -0
  43. package/dist/security.d.ts +22 -0
  44. package/dist/security.js +24 -0
  45. package/dist/shared-memory-scope-authorizer.d.ts +36 -3
  46. package/dist/shared-memory-scope-authorizer.js +19 -4
  47. package/dist/store-live-probe.d.ts +26 -0
  48. package/dist/store-live-probe.js +60 -0
  49. package/dist/task-cwd.d.ts +16 -3
  50. package/dist/task-cwd.js +16 -3
  51. package/dist/tool-approval.d.ts +61 -2
  52. package/dist/tool-approval.js +159 -17
  53. package/package.json +2 -2
@@ -0,0 +1,158 @@
1
+ /**
2
+ * #303([4610] test 报「/model 中途切换校验探针经 POST /v1/side-query 对有效 key 返回上游 401」)——
3
+ * side-query 面的 **per-model key plane** 装配。
4
+ *
5
+ * ## 病灶(亲验 core 5.43.0 dist 实现行)
6
+ *
7
+ * core 的 side-query verb **结构性没有 key 座位**:
8
+ * · `core/side-query.d.ts` 的 `SideQuerySpec` 无 `apiKey` / `getApiKeyAndHeaders` 字段;
9
+ * · `SideQueryDeps` = `{ brain, models?, roles? }`;
10
+ * · `core/side-query.js` 铸 brain `options` 时只放 `signal/maxTokens/reasoning`,**不含 apiKey**。
11
+ * 而 openai brain 的 `buildRequest`(`brain/openai.js:281-282`)是这两行:
12
+ * `const apiKey = options?.apiKey ?? config.apiKey;` ← 缺席即回落 **brain 构造时的网关 key**
13
+ * `const root = (model.baseUrl || config.baseUrl || "")` ← 却**按模型**选路
14
+ * ⇒ 对带 per-model `baseUrl` + `apiKeyEnv`/`sealedApiKey` 的模型,side-query 把**主网关 key 发到
15
+ * 外部模型 URL**:用户看到上游 401,同时这是一次凭据外泄(安全轴)。
16
+ *
17
+ * 主推理链没有这个病:core 每次 brain 调用都调 `TaskSpec.getApiKeyAndHeaders`(server 座位=
18
+ * `boot/resolve-spec.ts` 的 `getKeyResolver()` 活取);hook-llm 面自己解析并对 off-route 模型
19
+ * fail-closed。side-query 是 key plane 三面里**唯一**缺席的那一面。
20
+ *
21
+ * ## 修形(窄、可撤)
22
+ *
23
+ * side-query 消费面**脱离 `Runner.sideQuery`**,直调 core 公开导出的 `runSideQuery`,并给它一只
24
+ * **key 注入 brain wrapper**:每次调用按本次 spec 解析出的 `Model` 现解 per-model key,有则注入
25
+ * `options.apiKey`(core 那行 `options?.apiKey ?? config.apiKey` 于是走前一支),没有则**不注入**
26
+ * (回落网关 key = 主推理链逐字同语义)。wrapper 只包 side-query 专用的这一只引用,主推理链的
27
+ * `RunnerDeps.brain` 零扰动。
28
+ *
29
+ * 🔴 **poison(sealed key 不可解)上抛,绝不静默回落网关 key** —— `resolveModelApiKey` 抛
30
+ * `SealedKeyPoisonedError` 是「这个模型配了托管密钥但解不开」的响亮态;吞成 undefined 等于用共享
31
+ * 网关账号去打这只模型的上游,正是毒丸机制存在的理由(CLAUDE.md #157 安全轴 fail-closed)。
32
+ *
33
+ * ## 具名残余:**无 per-model key 的 off-route 模型仍收网关 key**(codex R1-F1,逐条评估后如实留)
34
+ *
35
+ * 判据本身没变:`undefined` ⇒ 不注入 ⇒ core 用网关 key,即使这只模型的 `baseUrl` 指向别家。为什么
36
+ * **不**在本批把它改成 fail-closed(逐条,而不是"先不管"):
37
+ * ① **与主推理链同语义**才是当前的正确性基准 —— 任务面(core 每次调 `getApiKeyAndHeaders`,返回
38
+ * `undefined` 即网关 key)在**同一个** catalog、**同一批**模型上就是这个姿势。只把 side-query 收严,
39
+ * 等于让同一只模型「跑任务能用、问一句就 401」,而任务面才是量大的那一面 —— 半边收严的覆盖比
40
+ * 统一的宽松更难排障。
41
+ * ② **谁被伤到**:`baseUrl` 是**运维**在 config-center 里自己写的(不是用户输入,用户只能在
42
+ * `atModelAllowlist` 里点名单内的模型)。"同一账号多网关主机/多区域"是既有且正当的部署形,
43
+ * 它今天靠网关 key 工作;无版本协商地收严 = 这类部署当场 401,而且没有旋钮可回退。
44
+ * ③ hook 面(`hooks/hook-llm.ts`)确实是 fail-closed 的,但它的辖域是 **hook roster**(运维给钩子挑的
45
+ * 模型),不是用户可点的整本目录;两者的收严代价不同量级。
46
+ * ⇒ 本批**不改行为**,把它作为**跨面语义问题**上报(要收严就三面一起收严、带旋钮、走表态制),
47
+ * 而不是在这一面偷偷收严。真要收严时的正解:按 `normUrl` 比对 `gatewayBaseUrl`(+fallback 列表)
48
+ * 与 anthropic 路由,off-route ∧ 无 per-model key ⇒ 拒发,并同批改任务面。
49
+ *
50
+ * ## 撤除条件(设计成可撤形)
51
+ *
52
+ * core 一旦给 `SideQuerySpec` 补上 key 座位(`apiKey` 或 `getApiKeyAndHeaders`),本模块的 wrapper
53
+ * 即可撤:装配点改成把 `getKeyResolver()` 直接塞进 spec,`createPerModelKeyBrain` 整只删除。
54
+ * 撤除信号有机器钉:`test/side-query-key-plane.test.ts` 的「core 座位缺席钉」在 core 补座位那天先红。
55
+ * 在那之前,本模块**不是** upstream 语义的旁路重写 —— 它只在 server 自己的装配层给自己的 brain
56
+ * 引用补上 server 自己的凭据平面,core 的 side-query 语义(选路/归一/结果形)原样由 `runSideQuery` 决定。
57
+ */
58
+ import { expandTiers, runSideQuery } from "@sema-agent/core";
59
+ import { recordFailOpen } from "../observability/fail-open.js";
60
+ /**
61
+ * 「这只模型的 baseUrl 打的是**别家**主机吗」—— core 的选路规则是 `model.baseUrl || config.baseUrl`,
62
+ * 所以只有 `model.baseUrl` 非空时才可能离开本部署网关。比较按 URL 归一(`new URL` 小写 host、丢默认端口、
63
+ * 去尾斜杠),裸字符串比会在 host 大小写/默认端口写法上假阴。
64
+ *
65
+ * 参照系缺席(`gatewayBaseUrl` 未给)⇒ 回 `false`:判不了就不判,别猜。
66
+ *
67
+ * ⚠️ `hooks/hook-llm.ts:118` 有一只逐字同形的内联归一器(它用同一判据做 **fail-closed 拒发**,本函数只
68
+ * 做**计数**)。两处合一属跨面收严件 #309 的射程(那时三面同批换判据),本批刻意不动 hook 面的 fail-closed
69
+ * 门 —— 在只改可观测性的批里重写另一条安全轴的门,风险与收益不对等。
70
+ */
71
+ export function isOffRouteBaseUrl(modelBaseUrl, gatewayBaseUrl) {
72
+ if (!modelBaseUrl || !gatewayBaseUrl)
73
+ return false;
74
+ // 解析不了(配歪的 baseUrl)⇒ 退到去尾斜杠的裸串比。用 `URL.canParse` 而不是 try/catch:静默降级
75
+ // 棘轮门(`test/silent-degradation-scan.test.ts` SHAPE C)对「catch 只 return 默认值」是零容忍的,
76
+ // 而这条降级恰好有一个不吞异常的写法(Node ≥20 / ES2023 均在射程)。方向:比不出同一家 ⇒ 算 off-route
77
+ // ⇒ 记债(宁可多记一条待查,不可把一次真外泄判成自家网关)。
78
+ const norm = (u) => URL.canParse(u) ? ((x) => `${x.protocol}//${x.host}${x.pathname.replace(/\/+$/, "")}`)(new URL(u)) : u.replace(/\/+$/, "");
79
+ return norm(modelBaseUrl) !== norm(gatewayBaseUrl);
80
+ }
81
+ /**
82
+ * per-model key 注入 wrapper。
83
+ *
84
+ * 语义(逐条对齐主推理链 `getApiKeyAndHeaders`):
85
+ * · `options.apiKey` **已在场** ⇒ 原样透传(上游/装饰器已经定了凭据,本层不抢);
86
+ * · resolver 缺席(部署根本没配 per-model key)⇒ 不注入 ⇒ 网关 key(今日行为逐字不变);
87
+ * · resolver 返回 undefined(这只模型没有自己的 key)⇒ 不注入 ⇒ 网关 key(additive 契约);
88
+ * · resolver 抛(sealed key 中毒)⇒ **上抛**,本次调用整体失败,一个字节都不发。
89
+ *
90
+ * `complete` 在场时同样包一层:core 的 `Brain.complete` 是 compaction/摘要腿的直调口,漏包等于给
91
+ * 同一个病留第二条路(side-query 今天不走它,但 wrapper 的透明性不该依赖调用方今天的用法)。
92
+ *
93
+ * 🔴 A-057.59 [PARTIAL high](2026-08-19)—— 「无 per-model key 的 off-route 模型仍收网关 key」这条
94
+ * 具名残余**行为不变**(逐条理由见模块顶注「具名残余」段:与主推理链
95
+ * `dist/engine/harness/agent-harness.js` 的 `auth?.apiKey !== undefined` 臂逐字同语义,单面收严会造成
96
+ * 「同一模型跑任务能用、问一句 401」;收严件 = 跨面 #309,带旋钮走表态制),但**每次调用零留痕**这一半
97
+ * 被判为要清偿的存量欠账:config-apply 期那条 `sema_registry_model_key_env_missing` warn 只覆盖
98
+ * 「声明了 env 名却没设」,覆盖不到「压根没配 per-model key 的 off-route 模型」,而后者才是真发生外泄
99
+ * 的那一形。按 CLAUDE.md #157「一时改不动的 P 类记 `P-DEBT`」补计数:命中条件 =
100
+ * **解不出 per-model key ∧ 该模型的 baseUrl 不是本部署网关** ⇒ 这一次调用真把网关凭据发给了别家主机。
101
+ * `gatewayBaseUrl` 缺席(调用方没给参照系)⇒ 不计(不猜),与「宁可漏计不可错计」同向。
102
+ */
103
+ export function createPerModelKeyBrain(brain, getKeyResolver, opts = {}) {
104
+ const keyFor = async (model, given) => {
105
+ if (given !== undefined)
106
+ return given; // 已有凭据:不抢
107
+ const resolve = getKeyResolver();
108
+ // 🔴 不 try/catch:poison 必须上抛(静默回落 = 拿共享网关账号打这只模型的上游)。
109
+ // resolver 缺席(部署根本没配 per-model key 平面)与解析回 undefined(这只模型没有自己的 key)
110
+ // 两形都落到同一条缺席语义:不注入 ⇒ core 用网关 key(additive 契约)。
111
+ const apiKey = resolve ? (await resolve({ name: model.name }))?.apiKey : undefined;
112
+ if (apiKey === undefined && isOffRouteBaseUrl(model.baseUrl, opts.gatewayBaseUrl)) {
113
+ recordFailOpen("server.model-key.off-route-model-uses-gateway-key", model.name);
114
+ }
115
+ return apiKey;
116
+ };
117
+ const stream = async (model, context, options) => {
118
+ const apiKey = await keyFor(model, options?.apiKey);
119
+ return brain.stream(model, context, apiKey === undefined ? options : { ...options, apiKey });
120
+ };
121
+ // 🔴 A-057.2:`.bind(brain)` **保留接收者** —— 裸摘引用(`const complete = brain.complete`)在 ESM
122
+ // 严格模式下让 `this` = undefined,任何读 `this` 的 Brain 实现经本 wrapper 后当场 TypeError,
123
+ // 而两行之上的 `stream` 臂是带接收者的 `brain.stream(…)`,同一只 wrapper 两条臂不该有两种调用形。
124
+ // (今日是潜伏:core 5.43.0 的七只 brain 工厂全 `return { stream }`,`complete` 恒缺席;顶注自己
125
+ // 写着「wrapper 的透明性不该依赖调用方今天的用法」。)
126
+ const complete = brain.complete?.bind(brain);
127
+ return {
128
+ stream,
129
+ ...(complete
130
+ ? {
131
+ complete: async (model, context, options) => {
132
+ const apiKey = await keyFor(model, options?.apiKey);
133
+ return complete(model, context, apiKey === undefined ? options : { ...options, apiKey });
134
+ },
135
+ }
136
+ : {}),
137
+ };
138
+ }
139
+ /**
140
+ * 装配 side-query 执行席 = 真 `runSideQuery` + 注入式 brain,models/roles 与 Runner 同源。
141
+ *
142
+ * `models` **按调用**取 `expandTiers(config.models, config.tiers)`:
143
+ * · 档位词 / CC 别名(`flash`/`opus`/…)在 side-query 上照常可选路 —— core 的 `Runner` 构造时做同一件事
144
+ * (`runtask.js:1432`),不做等于把档位词从这条路上悄悄拿掉;
145
+ * · 取「按调用」而不是「boot 一次」是因为 `config.models` 由 config-center 就地热应用,而 `Runner` 那次
146
+ * 展开是**构造时快照**(`expandTiers` 在 tiers 非空时返回新对象)——本路由的模型白名单门
147
+ * (`routes/side-query.ts` 的 `isModelAllowlisted`)本来就按请求现算同一张增广目录,两边取同一份
148
+ * 才不会出现「名单放行、选路说不认识」的分歧。
149
+ */
150
+ export function createSideQueryLane(ctx) {
151
+ const brain = createPerModelKeyBrain(ctx.brain, ctx.getKeyResolver, { gatewayBaseUrl: ctx.config.gatewayBaseUrl });
152
+ return (spec) => runSideQuery(spec, {
153
+ brain,
154
+ models: expandTiers(ctx.config.models, ctx.config.tiers) ?? ctx.config.models,
155
+ roles: ctx.config.roles,
156
+ });
157
+ }
158
+ //# sourceMappingURL=side-query-lane.js.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A-057.5 [CONFIRMED high](2026-08-19 三轴组复审,真机探针复现)—— WebFetch 摘要面的
3
+ * **per-model key plane**,与 `boot/side-query-lane.ts` 同一只 wrapper、同一条纪律。
4
+ *
5
+ * ## 病灶(#303 的第四面,逐字同形)
6
+ *
7
+ * #303 的头注写「side-query 是 key plane 三面里**唯一**缺席的那一面」—— 那句话是假的。
8
+ * `createWebFetchSummarizer(brain, model)` 的 `brain` 是 `RunnerDeps.brain` 那只**裸网关 brain**
9
+ * (`boot/budget-tracing.ts` 的 `createBrain(config, …)`,`get apiKey()` 恒回 `config.gatewayApiKey`),
10
+ * 而 core 的摘要腿(`dist/tools/web.js`)铸 options 只放 `{ signal }`——`WebFetchSummarizerOptions`
11
+ * 结构性没有 key 座位。于是 openai brain 的那两行照旧:
12
+ * `const apiKey = options?.apiKey ?? config.apiKey;` ← 缺席即回落**主网关 key**
13
+ * `const root = (model.baseUrl || config.baseUrl || "")` ← 却**按模型**选路
14
+ * ⇒ summarize 角色解析到一只带自有 `baseUrl` + `apiKeyEnv/sealedApiKey` 的模型时,**主网关 key 被发到
15
+ * 那只模型自己的外部 URL**;而且这不落在 #303 已具名接受的那条残余里(那条说的是「**没有** per-model
16
+ * key 的 off-route 模型仍收网关 key」)—— 这只模型**有**自己的 key,却拿不到。
17
+ *
18
+ * 第二半同样成立:毒丸(sealed key 解不开)在本面**静默回落网关账号** —— 本面根本不经
19
+ * `resolveModelApiKey`,`key-resolver.ts` 顶注那句「a broken sealed key must NEVER silently burn the
20
+ * shared gateway account」在这条路上不成立。
21
+ *
22
+ * ## 修形 = 照 #303 的配方接第四面(不是新造语义)
23
+ *
24
+ * brain 换成 `createPerModelKeyBrain(brain, getKeyResolver)`:
25
+ * · 有 per-model key ⇒ 注入 `options.apiKey`,请求带模型自己的凭据;
26
+ * · 无 ⇒ 不注入 ⇒ 回落网关 key(与主推理链/side-query 逐字同语义,additive 契约;该残余的成文与
27
+ * 跨面收严件见 side-query-lane.ts 顶注「具名残余」段与 A-057.59);
28
+ * · 毒丸 ⇒ **上抛**,一个字节都不发(CLAUDE.md #157 安全轴 fail-closed)。
29
+ * wrapper 只包本席这一只引用,`RunnerDeps.brain` 零扰动(与 side-query 席同形)。
30
+ *
31
+ * ## 为什么住在 `boot/` 而不是继续躺在 `main()` 的闭包里
32
+ *
33
+ * ① 这是一条**可发射的 LLM 面**,必须能被黑盒探针(真 fetchImpl 捕 Authorization)直接驱动 —— 躺在
34
+ * `main()` 里只能靠源码字面锚,而本仓已成文「源码字面锚有致命假绿形」(A-057.4/.11 那一族);
35
+ * ② 发射面枚举门(`test/per-model-key.test.ts` 的 `DIRECT_EMISSION_FACES`)按**文件**登记在册,第四面
36
+ * 有自己的文件才登记得住。
37
+ *
38
+ * 原地保留的两条既有语义(E-MED-2/E-MED-3,[1900]/[1904] 复审批):
39
+ * · **惰性解析**:模型每次调用现取 —— `config.models`/`config.roles` 会被 center 的 effective 热应用
40
+ * `mutateInPlace` 整表替换,boot 期急求值会把摘要器永久 pin 在占位/旧模型上;
41
+ * · **fail-soft**:`resolveTaskModel` 在无可解析角色时 throw,这是全仓唯一 boot 期裸调用点;解析失败
42
+ * ⇒ 本次不摘要(core 的 summarize 抛错是 fail-open:回退原文 + note),绝不崩 boot。
43
+ */
44
+ import { type Brain, type WebFetchConfig } from "@sema-agent/core";
45
+ import type { ServiceConfig } from "../config-types.js";
46
+ import type { Logger } from "../observability/logger.js";
47
+ import { type KeyResolver } from "./side-query-lane.js";
48
+ /** WebFetch 摘要执行席(= `ScenarioDeps.webFetchSummarize`,core 的 `WebFetchConfig.summarize` 逐字同形)。 */
49
+ export type WebFetchSummarizeLane = NonNullable<NonNullable<WebFetchConfig["summarize"]>>;
50
+ export interface WebFetchSummarizeLaneCtx {
51
+ /** 与 `RunnerDeps.brain` **同一只**实例(wrapper 在外面包,不改这只)。 */
52
+ brain: Brain;
53
+ /** 活配置引用:目录/角色表被 config-center 就地热应用(`mutateInPlace`),按调用取值才跟得上。 */
54
+ config: ServiceConfig;
55
+ /** 活引用取值 —— registry 热应用整个换 resolver 引用(`boot/config-center.ts` 每次 apply 重铸)。 */
56
+ getKeyResolver: () => KeyResolver;
57
+ logger: Logger;
58
+ }
59
+ export declare function createWebFetchSummarizeLane(ctx: WebFetchSummarizeLaneCtx): WebFetchSummarizeLane;
60
+ //# sourceMappingURL=webfetch-summarize-lane.d.ts.map
@@ -0,0 +1,67 @@
1
+ /**
2
+ * A-057.5 [CONFIRMED high](2026-08-19 三轴组复审,真机探针复现)—— WebFetch 摘要面的
3
+ * **per-model key plane**,与 `boot/side-query-lane.ts` 同一只 wrapper、同一条纪律。
4
+ *
5
+ * ## 病灶(#303 的第四面,逐字同形)
6
+ *
7
+ * #303 的头注写「side-query 是 key plane 三面里**唯一**缺席的那一面」—— 那句话是假的。
8
+ * `createWebFetchSummarizer(brain, model)` 的 `brain` 是 `RunnerDeps.brain` 那只**裸网关 brain**
9
+ * (`boot/budget-tracing.ts` 的 `createBrain(config, …)`,`get apiKey()` 恒回 `config.gatewayApiKey`),
10
+ * 而 core 的摘要腿(`dist/tools/web.js`)铸 options 只放 `{ signal }`——`WebFetchSummarizerOptions`
11
+ * 结构性没有 key 座位。于是 openai brain 的那两行照旧:
12
+ * `const apiKey = options?.apiKey ?? config.apiKey;` ← 缺席即回落**主网关 key**
13
+ * `const root = (model.baseUrl || config.baseUrl || "")` ← 却**按模型**选路
14
+ * ⇒ summarize 角色解析到一只带自有 `baseUrl` + `apiKeyEnv/sealedApiKey` 的模型时,**主网关 key 被发到
15
+ * 那只模型自己的外部 URL**;而且这不落在 #303 已具名接受的那条残余里(那条说的是「**没有** per-model
16
+ * key 的 off-route 模型仍收网关 key」)—— 这只模型**有**自己的 key,却拿不到。
17
+ *
18
+ * 第二半同样成立:毒丸(sealed key 解不开)在本面**静默回落网关账号** —— 本面根本不经
19
+ * `resolveModelApiKey`,`key-resolver.ts` 顶注那句「a broken sealed key must NEVER silently burn the
20
+ * shared gateway account」在这条路上不成立。
21
+ *
22
+ * ## 修形 = 照 #303 的配方接第四面(不是新造语义)
23
+ *
24
+ * brain 换成 `createPerModelKeyBrain(brain, getKeyResolver)`:
25
+ * · 有 per-model key ⇒ 注入 `options.apiKey`,请求带模型自己的凭据;
26
+ * · 无 ⇒ 不注入 ⇒ 回落网关 key(与主推理链/side-query 逐字同语义,additive 契约;该残余的成文与
27
+ * 跨面收严件见 side-query-lane.ts 顶注「具名残余」段与 A-057.59);
28
+ * · 毒丸 ⇒ **上抛**,一个字节都不发(CLAUDE.md #157 安全轴 fail-closed)。
29
+ * wrapper 只包本席这一只引用,`RunnerDeps.brain` 零扰动(与 side-query 席同形)。
30
+ *
31
+ * ## 为什么住在 `boot/` 而不是继续躺在 `main()` 的闭包里
32
+ *
33
+ * ① 这是一条**可发射的 LLM 面**,必须能被黑盒探针(真 fetchImpl 捕 Authorization)直接驱动 —— 躺在
34
+ * `main()` 里只能靠源码字面锚,而本仓已成文「源码字面锚有致命假绿形」(A-057.4/.11 那一族);
35
+ * ② 发射面枚举门(`test/per-model-key.test.ts` 的 `DIRECT_EMISSION_FACES`)按**文件**登记在册,第四面
36
+ * 有自己的文件才登记得住。
37
+ *
38
+ * 原地保留的两条既有语义(E-MED-2/E-MED-3,[1900]/[1904] 复审批):
39
+ * · **惰性解析**:模型每次调用现取 —— `config.models`/`config.roles` 会被 center 的 effective 热应用
40
+ * `mutateInPlace` 整表替换,boot 期急求值会把摘要器永久 pin 在占位/旧模型上;
41
+ * · **fail-soft**:`resolveTaskModel` 在无可解析角色时 throw,这是全仓唯一 boot 期裸调用点;解析失败
42
+ * ⇒ 本次不摘要(core 的 summarize 抛错是 fail-open:回退原文 + note),绝不崩 boot。
43
+ */
44
+ import { createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel } from "@sema-agent/core";
45
+ import { createPerModelKeyBrain } from "./side-query-lane.js";
46
+ export function createWebFetchSummarizeLane(ctx) {
47
+ // 与 side-query 席同形:wrapper 装配一次,resolver 靠 `getKeyResolver` 活取(热应用后下一次调用即生效)。
48
+ const brain = createPerModelKeyBrain(ctx.brain, ctx.getKeyResolver, { gatewayBaseUrl: ctx.config.gatewayBaseUrl });
49
+ return async (content, prompt, signal) => {
50
+ const model = (() => {
51
+ try {
52
+ return coreResolveTaskModel({ modelRole: "summarize" }, { models: ctx.config.models, roles: ctx.config.roles }).model;
53
+ }
54
+ catch (err) {
55
+ ctx.logger.warn("webfetch_summarizer_model_unresolved", { err: String(err), note: "this fetch falls back to raw page content" });
56
+ return undefined;
57
+ }
58
+ })();
59
+ if (!model)
60
+ throw new Error("summarize model unresolved for this deployment");
61
+ // core 2.13.0:返回形放宽为 additive union(`string | { text, truncated? }`)—— 这里**原样转发**
62
+ // core summarizer 的返回值,不在本仓收窄成 string(收窄会把 core 的截断披露 `truncated` 吃掉,
63
+ // 让「内容被截断」这个事实在 fence 外消失)。
64
+ return createWebFetchSummarizer(brain, model)(content, prompt, signal);
65
+ };
66
+ }
67
+ //# sourceMappingURL=webfetch-summarize-lane.js.map
package/dist/brain.js CHANGED
@@ -89,7 +89,21 @@ export function createBrain(config, deps = {}) {
89
89
  // Local openai-compatible gateway stack: primary + optional same-protocol fallbacks (failover).
90
90
  const routes = [config.gatewayBaseUrl, ...config.gatewayFallbackUrls];
91
91
  const openaiBrains = routes.map((baseUrl, i) => {
92
- const brain = createOpenAIBrain({ baseUrl, apiKey: config.gatewayApiKey, ...retryCap, fetchImpl, ...timeouts });
92
+ // 🔴 #303 顺修:网关 key **活取**(getter),不是 boot 快照。core 每次请求在 `buildRequest` 里读
93
+ // `config.apiKey`(brain/openai.js:281 `options?.apiKey ?? config.apiKey`),所以这只闭包持有的
94
+ // `config` 引用让 key 值随配置对象走 —— 与本文件下面 degrade fallback 腿 2026-07-29 起就写着的
95
+ // `get apiKey()` 同形同理由。修前两条腿对「网关凭据什么时候生效」给出不同答案(主腿要重启,
96
+ // 降级腿即时),那种分歧只在轮换那一刻显形,且是静默的。结构半场(baseUrl/failover 拓扑)仍是
97
+ // boot 冻结,不受此影响。钉:test/brain.test.ts「#303:网关 key 活取」。
98
+ const brain = createOpenAIBrain({
99
+ baseUrl,
100
+ get apiKey() {
101
+ return config.gatewayApiKey;
102
+ },
103
+ ...retryCap,
104
+ fetchImpl,
105
+ ...timeouts,
106
+ });
93
107
  // Breaker on every route EXCEPT the last (the bare last-resort backup), and only with ≥2 routes —
94
108
  // a breaker without a failover alternative would just hard-fail the only gateway during cooldown.
95
109
  const isLast = i === routes.length - 1;
@@ -405,6 +405,30 @@ export interface ServiceConfigFlat {
405
405
  * 照算但纯观察零行为变化(不整拒、不窄化 writeScope,只记 would-deny/would-narrow)——运维诊断位,
406
406
  * 非发布步骤(audit-first 灰度步按裁2 免除)。env `MEMORY_ORG_ADMISSION_MODE`(enumEnv 二值)。 */
407
407
  memoryOrgAdmissionMode: "audit" | "enforce";
408
+ /** #299(core 5.45.0 design/324,#324 裁定① 的部署半场):委派臂的**证据标准** —— 一次委派调用要不要把
409
+ * 本会话的记忆标记成 polluted,按哪一档证据判。env `MEMORY_DELEGATION_EVIDENCE`,两词闭集:
410
+ * · `static-face` —— core 的缺省行为逐字:委派的 attestation 缺失/未知 **且** 静态工具面够得着外部
411
+ * 内容 ⇒ 标记(能力过近似:可能性即暴露);
412
+ * · `attested-only` —— **只**豁免那一条静态面标记,且**真的豁免了一次**才响亮通告
413
+ * (`memory.delegation_static_mark_waived`,经本仓 `engine_notice` 转发面到运维;每个 prepared leg
414
+ * **至多**一次)。⚠️ 通告**不是** leg 计数器:不含委派的 leg、污染面没挂载的 leg、以及送达了
415
+ * `external` attestation 的委派,都**不发** —— 「这一版没看到通告」的常态含义是「没有可豁免的事」,
416
+ * 不是「配置坏了」。其余一律不变:
417
+ * 送达 `"external"` attestation 照标、链上 `incomplete` 照记、非委派的污染类工具照标、委派工具
418
+ * 自身被分类为污染的照标。
419
+ *
420
+ * 🔴 **缺席 = 不铸键**(不复制上游缺省 —— 上游改缺省之日起本仓不会成为第二份真源;`readFace` 同姿态)。
421
+ * 坏值 ⇒ 启动期响亮拒(#210);core 侧 `prepareConfigDoors` 另有一道同判的门
422
+ * (`config.memory_delegation_evidence`,精确拼写、绝不真值判),两道门同向不冲突。
423
+ *
424
+ * ⚠️ **部署席 ONLY**(与 `readDenyBuiltinTiers` 同判,core 侧的设计):TaskSpec 上没有同名键,也不在受治
425
+ * workflow 白名单里 —— 任务作者/受治脚本拿不到任何通道把证据标准放松到部署之下(本仓「部署级旋钮
426
+ * 无条件施加、不挂客户端表态派生腿」同向)。
427
+ * ⚠️ **已接受的代价**(core 成文,运维自己拥有):`attested-only` 下**后台**子代的真实外部接触不标记本
428
+ * 会话 —— 其内容经 TaskOutput / 任务通知注入 / AgentTranscript 步骤摘要回流,三条都不带 attestation;
429
+ * 异常收尾(crash/salvage)的前台子代同样不按静态面标记(链上仍记 `incomplete`)。已标记的会话永不
430
+ * 回滚清洗,本键只管**新**标记。 */
431
+ memoryDelegationEvidence?: "static-face" | "attested-only";
408
432
  /** 件A 单机形目录:env `MEMORY_ORG_DIRECTORY_JSON` 原文(`Record<principal, Record<org:scope, {write?}>>`)。
409
433
  * config 解析期即整段校验(parseOrgDirectoryStatic,fail-loud);此处存**原文**(config=纯数据,
410
434
  * Map 在装配点重建)。与 config-center 目录腿互斥的裁决在装配点(两者都在=center 腿胜,env 表忽略
@@ -1173,7 +1197,8 @@ export interface ServiceConfigFlat {
1173
1197
  *
1174
1198
  * 施加点 = `ToolApprovalCoordinator` 的构造参 `unattendedPolicy` **一处**,五条「无人可答」的臂读同一
1175
1199
  * 个值(设计稿 §1 的表:(a) 有店窗到期赢 CAS /(b) 无店窗到期 /(c) 店报错 catch /(d) windowZero 与
1176
- * headless 腿 /(e) 断连强转 + emit 全灭 + 写侧准入超限)。`"timeout"` 宿主自报只留在 (b)(c) ——
1200
+ * headless 腿 /(e) 断连强转 + emit 全灭 + 写侧准入超限 + **投递集合入口活性过滤清空**(A-054.17
1201
+ * 位:与「查无活流」同一件事实的两个时刻,详见 `askBroadcast` 的该臂注))。`"timeout"` 宿主自报只留在 (b)(c) ——
1177
1202
  * 结束等待的确实是本仓的窗;其余三臂结束等待的是容量/连接/装配,自报恒缺席。
1178
1203
  *
1179
1204
  * 🔴 **射程边界(成文,不是遗漏)**:
@@ -1338,7 +1363,7 @@ export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" |
1338
1363
  * 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
1339
1364
  export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "unattendedApprovalPolicy" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
1340
1365
  /** 组:memory(记忆面 + TOC 同步腿)。 */
1341
- export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memoryPersistenceCapable" | "memorySync" | "memoryEmbedder" | "memoryOrgAdmissionMode" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
1366
+ export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memoryPersistenceCapable" | "memorySync" | "memoryEmbedder" | "memoryOrgAdmissionMode" | "memoryDelegationEvidence" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
1342
1367
  /** 组:auth(鉴权 / 身份 / 治理棒)。`commandPolicy` 只有 sema-registry 腿(无 env 标量形),故 env 解析
1343
1368
  * 函数不产出它,但它与 `autonomy` 是同一根治理棒的两半,归本组。
1344
1369
  * design/170 的三件部署治理声明(`compliancePosture`/`lockedConfigKeys`/`retentionPolicy`)同归本组:
package/dist/config.js CHANGED
@@ -644,8 +644,11 @@ function parseStoreDomain(ctx) {
644
644
  // service serves the same contract a cloud worker does. It has no SQL host/coords (createStoreBackend builds it
645
645
  // without tidb/pg), so it's always "reachable". (Parsed FIRST so the session/memory posture defaults below can
646
646
  // key off the chosen engine.) Wording (clay 2026-07-06): canonical value `mysql` = any MySQL-protocol server
647
- // (MySQL / TiDB / MariaDB); `tidb` accepted as a back-compat alias. TiDB stays fully supported and is the
648
- // recommended MySQL-protocol engine when semantic memory is on (native VECTOR(dim) recall).
647
+ // (MySQL / TiDB / MariaDB). 🔴 A-054.6: this comment used to end "`tidb` accepted as a back-compat alias" that
648
+ // has been FALSE since [2354] retired the public name (see the enum 19 lines below: `tidb` is not in the closed
649
+ // set, so passing it as this variable's value fails boot). The internal "tidb" label survives only in the sessionBackend mapping and
650
+ // the SQL coords, which are NOT the public env vocabulary. TiDB itself stays fully supported and is the recommended
651
+ // MySQL-protocol engine when semantic memory is on (native VECTOR(dim) recall) — you select it with `mysql`.
649
652
  // clay 拍(2026-07-27,[1845] 桌面撞 501 案后):**裸 boot 默认 durable(local file 店)**。
650
653
  // 旧默认「纯内存」让每个宿主(cli fixture/桌面/web BFF)都要手工记得 DB_BACKEND=local,漏设 =
651
654
  // 「跑完的任务重启就丢 + durable runs 面 501」——单机是主流形,直觉预期是留得住。三态:
@@ -1622,6 +1625,13 @@ function parseMemoryDomain(ctx) {
1622
1625
  // #228:向量面接线(三态解析 + 后端/引擎两门,全在 parseMemoryEmbedder 里 fail-loud)
1623
1626
  ...((v) => (v !== undefined ? { memoryEmbedder: v } : {}))(parseMemoryEmbedder({ memoryEngineBackend, memoryEngineEnabled })),
1624
1627
  memoryOrgAdmissionMode,
1628
+ // #299(core 5.45.0 design/324):委派臂的证据标准。**缺席 = 整键缺席**(不是 present-as-undefined,
1629
+ // 也不是折成 core 的缺省词)—— 本仓不复制上游缺省,上游改缺省之日起这里不会成为第二份真源
1630
+ // (`READ_FACE` 同姿态,#246)。设了就必须是闭集两词之一:坏词在 `enumEnv` 里响亮拒(#210),
1631
+ // 而不是等到 core 的 prepare 门上才炸 —— 部署配错应当在**启动期**被点名,不是第一个任务失败时。
1632
+ ...(process.env.MEMORY_DELEGATION_EVIDENCE !== undefined
1633
+ ? { memoryDelegationEvidence: enumEnv("MEMORY_DELEGATION_EVIDENCE", "static-face", ["static-face", "attested-only"]) }
1634
+ : {}),
1625
1635
  ...(memoryOrgDirectoryJson !== undefined ? { memoryOrgDirectoryJson } : {}),
1626
1636
  memoryOrgGrantTtlMs,
1627
1637
  memoryOrgUnavailableBackoffMs,
@@ -2343,7 +2353,7 @@ const APPROVAL_GROUP_KEYS = [
2343
2353
  ];
2344
2354
  const MEMORY_GROUP_KEYS = [
2345
2355
  "memoryEngineEnabled", "memoryEngineDir", "memoryEngineRemoteLaneAllowed", "memoryEngineBackend", "memoryScope",
2346
- "memoryPersistenceCapable", "memorySync", "memoryEmbedder", "memoryOrgAdmissionMode", "memoryOrgDirectoryJson", "memoryOrgGrantTtlMs", "memoryOrgUnavailableBackoffMs",
2356
+ "memoryPersistenceCapable", "memorySync", "memoryEmbedder", "memoryOrgAdmissionMode", "memoryDelegationEvidence", "memoryOrgDirectoryJson", "memoryOrgGrantTtlMs", "memoryOrgUnavailableBackoffMs",
2347
2357
  "projectMemoryEnabled", "syncImportLeaseStaleSec",
2348
2358
  ];
2349
2359
  const AUTH_GROUP_KEYS = [
@@ -2,7 +2,19 @@
2
2
  * Degenerate-repetition instrument (a/b classifier).
3
3
  *
4
4
  * When a task fails with `errorCode === "output.degenerate"` (core 1.59), core hands back
5
- * `salvagedOutput` = the degenerate turn's **whole** text (the good head + the looped garbage tail).
5
+ * `salvagedOutput` = the degenerate turn's text **already tail-TRIMMED at the brain stream layer**.
6
+ * 🔴 A-057.52 correction (this note used to claim "the **whole** text, good head + looped garbage tail";
7
+ * that was true of core 1.59 and false of every engine that ships `trimDegenerateTail` — verified against
8
+ * the installed dist, not against JSDoc): on the main path `assemble-result` fills the seat from the same
9
+ * final message the brain already trimmed, so what arrives here is `head + exactly ONE copy of the
10
+ * repeating unit` (unit ≤ `MAX_PERIOD` = 100 chars). Untrimmed text only reaches us on the narrow
11
+ * fallbacks — the cut landed on the reasoning face, or `trimDegenerateTail` bailed (`reps < 2`, empty
12
+ * unit, unit-loop period mismatch). Consequence for the numbers below: {@link repetitionTail} is a
13
+ * **no-op** on the main-path input (one copy is not ≥ {@link MIN_REPEATS}), so `uniquePrefixLen`
14
+ * collapses onto `salvagedLen` and the a/unknown split degrades to a pure length test. The a-vs-b
15
+ * decision is unaffected (it is decided by `priorChars`, independently); the residual bias on a/unknown
16
+ * is bounded by one unit ≤ 100 chars. Pin: the "已裁形" case in `test/degenerate-instrument.test.ts`
17
+ * drives the REAL `inspectDegenerate` + `trimDegenerateTail` and asserts the tail measures 0.
6
18
  * Core's *salvage ②* — recovering the "last substantive turn" instead of the current one — is being
7
19
  * gated on REAL data: how often is the useful answer actually in an EARLIER turn vs. in the degenerate
8
20
  * turn itself? This instrument answers that, per event, without changing any behaviour.
@@ -2,7 +2,19 @@
2
2
  * Degenerate-repetition instrument (a/b classifier).
3
3
  *
4
4
  * When a task fails with `errorCode === "output.degenerate"` (core 1.59), core hands back
5
- * `salvagedOutput` = the degenerate turn's **whole** text (the good head + the looped garbage tail).
5
+ * `salvagedOutput` = the degenerate turn's text **already tail-TRIMMED at the brain stream layer**.
6
+ * 🔴 A-057.52 correction (this note used to claim "the **whole** text, good head + looped garbage tail";
7
+ * that was true of core 1.59 and false of every engine that ships `trimDegenerateTail` — verified against
8
+ * the installed dist, not against JSDoc): on the main path `assemble-result` fills the seat from the same
9
+ * final message the brain already trimmed, so what arrives here is `head + exactly ONE copy of the
10
+ * repeating unit` (unit ≤ `MAX_PERIOD` = 100 chars). Untrimmed text only reaches us on the narrow
11
+ * fallbacks — the cut landed on the reasoning face, or `trimDegenerateTail` bailed (`reps < 2`, empty
12
+ * unit, unit-loop period mismatch). Consequence for the numbers below: {@link repetitionTail} is a
13
+ * **no-op** on the main-path input (one copy is not ≥ {@link MIN_REPEATS}), so `uniquePrefixLen`
14
+ * collapses onto `salvagedLen` and the a/unknown split degrades to a pure length test. The a-vs-b
15
+ * decision is unaffected (it is decided by `priorChars`, independently); the residual bias on a/unknown
16
+ * is bounded by one unit ≤ 100 chars. Pin: the "已裁形" case in `test/degenerate-instrument.test.ts`
17
+ * drives the REAL `inspectDegenerate` + `trimDegenerateTail` and asserts the tail measures 0.
6
18
  * Core's *salvage ②* — recovering the "last substantive turn" instead of the current one — is being
7
19
  * gated on REAL data: how often is the useful answer actually in an EARLIER turn vs. in the degenerate
8
20
  * turn itself? This instrument answers that, per event, without changing any behaviour.
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { governanceMandatesShellGateAlways } from "../runtime-governance.js";
17
17
  import { isParkedRunStatus } from "../plugins/store-contracts.js";
18
+ import { recordFailOpen } from "../observability/fail-open.js";
18
19
  /** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */
19
20
  export const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)";
20
21
  /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
@@ -201,6 +202,21 @@ export async function buildActiveRunConflict(deps, sessionId, activeTaskId) {
201
202
  };
202
203
  }
203
204
  catch {
205
+ // 🔴 A-057.19 姊妹件 A-057.47 [PARTIAL high](2026-08-19)—— **方向不变,补留痕**。
206
+ // 存活子句 = 运行期遥测缺口:这段 try 包住 getRun / turnActivity / peekPendingScope /
207
+ // findPendingTokenBySession / cs.get 全部,失败时 activeTaskStatus / msSinceLastActivity /
208
+ // pendingGate(kind·decidePath·governanceForced·checkpointId)整套蒸发、退化成裸 409,而
209
+ // ①本文件零 logger 席 ②pg-pool/tidb-pool 无 per-query 错误留痕 ③调用侧因本函数恒不 reject 拿不到
210
+ // 信号 ⇒ store 抖动 / 坏 checkpoint 行 / 滚动升级期 `checkpoint-store-sql.ts` 的版本守卫 throw
211
+ // 导致的「approval 出路材料系统性丢失」在生产遥测里**零信号**。
212
+ // 定性 F 类(台账驳倒了 high 自评):本材料成文为**展示/分诊**,不参与任何门/CAS/resume 判定
213
+ // (顶注 150 行 + FAIL-OPEN-CENSUS 第 21 行同一条判据);降级后 409 仍带 errorCode + activeTaskId +
214
+ // cancel 保底真路,不构成执法面绕过。所以是「F 类兜底必留痕」(CLAUDE.md #157),不是要改方向。
215
+ // 台账同时驳倒了本 finding 的两条推论:`recordFailOpen` 是**零依赖自由函数**,不需要给本模块拉
216
+ // 观测席(顶注 200-204 行那句「需要过三问」高估了成本);本臂也不是完全账外(它在
217
+ // `test/silent-degradation-scan.test.ts` 的 SHAPE C 棘轮里有冻结值)—— 本批把它从「有行无痕」
218
+ // 收成「有行有痕」,棘轮值随之从 1 归 0(catch 体不再是「只 return 默认值」)。
219
+ recordFailOpen("server.approvals.active-run-conflict-material-unavailable");
204
220
  return base; // 材料装配的任何失败都退化为旧形状 —— 增强绝不成为新故障点
205
221
  }
206
222
  }
@@ -150,7 +150,7 @@ const A2A_CONTENT_TYPE_NOT_SUPPORTED = -32005;
150
150
  */
151
151
  export const A2A_TASK_NOT_FOUND_MESSAGE = "task not found";
152
152
  /** 无 durable run store 时两个方法的**同一条**具名回答(不是静默降级成一次同步跑)。 */
153
- const A2A_NO_RUN_STORE_MESSAGE = "this deployment has no durable run store, so A2A tasks cannot be created or polled (set DB_BACKEND=mysql|pg)";
153
+ const A2A_NO_RUN_STORE_MESSAGE = "this deployment has no durable run store, so A2A tasks cannot be created or polled (set DB_BACKEND=mysql|pg|local)";
154
154
  /**
155
155
  * `RunRecord["status"]` → A2A 任务态的**逐词处置表**。
156
156
  *
@@ -347,12 +347,22 @@ function rpcErrorFromTypedFailure(err, skillId) {
347
347
  * 里施加。**服务凭据门那一道不动**:它判的是「这台机没有任何 service token 时能不能收写」,与方法无关,
348
348
  * 而把它挪进方法层会让一次未鉴权的提交先被解析、再被拒 —— 门必须尽早。
349
349
  * 文案与 `handle()` 的两道逐字同源(消费端按 `error:"draining"` 字面判型,那是冻结的 wire 契约)。
350
+ * 🔴 **同源不止文案,还包括体上的 additive 键**(A-054.9,2026-08-19 合并重扫 confirmed):#291 给
351
+ * draining 503 体加 `reason` 时只加在 `handle()` 那道门上,本函数一个字没带 —— 而 wire 契约附录 A 的
352
+ * 序言明写「**码是判别语义**,同一语义的多个站点共用同一个码」(按码编排、跨站点、无路径辖域),
353
+ * `draining` 行又逐字承诺「7.35.0+:体上可带 additive 键 `reason`」,C.5 还让 a2a 消费端就地读 503 体。
354
+ * agent card 只公告 `/v1/a2a` 一个 url ⇒ 外部 peer 在这条腿上读不到因由就是真的读不到。此前那句
355
+ * 「文案逐字同源」不算说谎(漂的是键不是文案),但它正是让人以为两道同源、从而漏补的诱因 —— 所以本注
356
+ * 从此把「同源」的范围写死到体级。新增站点照抄这一条:同码 ⇒ 同键集。
350
357
  */
351
358
  function submitUnavailable(res, ctx) {
352
359
  const { deps } = ctx;
353
360
  if (deps.drainState?.draining) {
354
361
  res.setHeader("retry-after", "15");
355
- sendError(res, 503, "draining", "draining", { message: "this instance is draining for shutdown/upgrade — retry against the replacement instance" });
362
+ sendError(res, 503, "draining", "draining", {
363
+ message: "this instance is draining for shutdown/upgrade — retry against the replacement instance",
364
+ ...(deps.drainState.reason ? { reason: deps.drainState.reason } : {}), // 未声明 ⇒ 键缺席(additive 纪律)
365
+ });
356
366
  return true;
357
367
  }
358
368
  if (deps.modelReady && !deps.modelReady()) {
@@ -12,6 +12,23 @@
12
12
  *
13
13
  * reason 校验:非空 trim 后 string ≤ {@link MAX_DRAIN_REASON_CHARS} —— 它会进 /health 与 503 体
14
14
  * (消费面是监控/壳),超长或怪型响亮拒([3425] 坏值立律),拒绝不留痕(不写半个值)。
15
+ *
16
+ * 🔴 **写侧特权、读侧公开 —— 这条不对称是刻意的,但必须被写的人知道**(A-054.11,2026-08-19 合并重扫
17
+ * partial,存活断言):本口是 operator-only + fail-closed(空 `OPERATOR_PRINCIPALS` 恒拒),而透出面里
18
+ * `/health` 在 `server.ts` 的鉴权门**之前**、无鉴权、缺省全网卡监听,USAGE 三处白纸黑字把它定为「无需
19
+ * 鉴权」—— 任何能连到这个端口的人都读得到这段**运维手打的自由文本**。
20
+ * **裁定:行为不改。** 判据三条:① `/health` 免鉴权是既成契约,而它正是这条信息的设计消费者(k8s
21
+ * probe / LB / 编排器带不了 token,`drainReason` 放这儿的全部理由就是给它们看);② 值的内容完全由
22
+ * operator 自选,且窗口只在 draining 期;③ 同一免鉴权体上早已有 `dataRoot` 绝对路径 / `pid` / `port` /
23
+ * `instanceId` / `configHash` / `storeProbe`,单独收紧本键不改变那面的暴露量级。
24
+ * (那条另立的兄弟件 —— `storeProbe.error` 把**原始异常 message 未截断**塞出去、DSN 片段可随之外泄 ——
25
+ * 已在 #305 修掉:免鉴权面上它改成闭集词,原文只走日志轴。两件的判据不同故不合并:本键的值由 operator
26
+ * 自选且窗口只在 draining 期,`storeProbe.error` 的值则来自不受控的驱动异常。)
27
+ * ⇒ 真正的缺口是**告诫缺席**:此前路由头注、400 文案、CHANGELOG、wire 契约四处都只说「透出」,没有一处
28
+ * 说「其中一面免鉴权」,操作员要拼出「我写的字是公开的」得自己合并两处文档去推理。本批把它写进**写口
29
+ * 现场**(400 文案 + 本注 + USAGE 端点条目),因为那是操作员唯一会读到的地方。
30
+ * 词表化(把自由文本换成受限枚举)= 行为变更,**不在本批**:它会让「rolling upgrade to 7.36.0」这类真正
31
+ * 有用的因由说不出来,取舍要属主拍。
15
32
  */
16
33
  import type { IncomingMessage, ServerResponse } from "node:http";
17
34
  import type { RouteCtx } from "../route-ctx.js";
@@ -29,7 +29,13 @@ async function handleAdminDrainBody(req, res, url, ctx, miss) {
29
29
  const body = (await ctx.helpers.readJson(req));
30
30
  const reason = body?.reason;
31
31
  if (typeof reason !== "string" || reason.trim().length === 0 || reason.length > MAX_DRAIN_REASON_CHARS) {
32
- sendError(res, 400, "request.field_invalid", `body must be { reason: string } — a non-empty reason of at most ${MAX_DRAIN_REASON_CHARS} characters (it is surfaced verbatim on /health and the 503 draining body)`);
32
+ // 🔴 A-054.11:这句是操作员唯一的现场提示,所以它必须说出**免鉴权**那半 —— verbatim」只告诉他
33
+ // 「不会被改写」,不告诉他「谁能读到」。/health 在鉴权门之前、缺省全网卡:写进去的字是公开的。
34
+ // 🔴 语序是**被门的形状决定的**(codex 对抗复审 R1-[medium],验真后修):文案冻结门对模板串只冻
35
+ // **第一个 `${` 之前**的固定前缀 —— 告诫若排在插值之后,整段安全提示可以被删掉/改写而门恒绿
36
+ // (那正是本批自己要修的那类「成文却无看守」)。所以告诫在前、带插值的尺寸句在后:告诫整段落进
37
+ // 冻结前缀,受 `400:request.field_invalid#the-drain-reason-is` 那条钉看守。改这句必须同批改冻结表。
38
+ sendError(res, 400, "request.field_invalid", `the drain reason is surfaced verbatim on the UNAUTHENTICATED /health endpoint (and in 503 draining bodies), so anyone who can reach this port can read it — do not put ticket ids, hostnames, or internal identifiers in it. body must be { reason: string }: a non-empty string of at most ${MAX_DRAIN_REASON_CHARS} characters`);
33
39
  return;
34
40
  }
35
41
  if (!deps.drainState) {
@@ -270,7 +270,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
270
270
  // (yields a task, runs no model — see the billable-route classifier) → NO lease gate, else an exhausted
271
271
  // tenant could not stop its own spending.
272
272
  if (!deps.runStore) {
273
- sendError(res, 501, "capability.run_store_required", "preemption requires a durable run store (DB_BACKEND=mysql|pg)");
273
+ sendError(res, 501, "capability.run_store_required", "preemption requires a durable run store (DB_BACKEND=mysql|pg|local)");
274
274
  return;
275
275
  }
276
276
  // requirePrincipal parity with the sibling mutating endpoints (runOwnerOk / cancel / runs): 401 before the
@@ -369,7 +369,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
369
369
  if (rateLimited(req, res) || quotaExceeded(req, res))
370
370
  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
371
371
  if (!deps.runStore) {
372
- sendError(res, 501, "capability.run_store_required", "resume requires a durable run store (DB_BACKEND=mysql|pg)");
372
+ sendError(res, 501, "capability.run_store_required", "resume requires a durable run store (DB_BACKEND=mysql|pg|local)");
373
373
  return;
374
374
  }
375
375
  if (deps.config.requirePrincipal && principal === undefined) {
@@ -406,7 +406,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
406
406
  if (rateLimited(req, res) || quotaExceeded(req, res))
407
407
  return; // 🔴 复审 C2:lease admitted in driveResumeIntoRunLog (owner principal). resume hits TiDB + runs the model (approve/edit)
408
408
  if (!deps.runStore) {
409
- sendError(res, 501, "capability.run_store_required", "plan_review requires a durable run store (DB_BACKEND=mysql|pg)");
409
+ sendError(res, 501, "capability.run_store_required", "plan_review requires a durable run store (DB_BACKEND=mysql|pg|local)");
410
410
  return;
411
411
  }
412
412
  if (deps.config.requirePrincipal && principal === undefined) {
@@ -394,6 +394,13 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
394
394
  // (no ALS context / undeliverable card) returns "unavailable" → core durable-parks where a checkpoint
395
395
  // exists, and only fail-closed denies without one ([819]⑤). The durable-approval leg (checkpoint park +
396
396
  // /decide) is independent of this.
397
+ // 🔴 A-054.10/.8:上面那句「→ core durable-parks」是**缺省 `park` 政策**下的话。#280 R-13 C 之后
398
+ // `UNATTENDED_APPROVAL_POLICY=deny` 的部署上,那些腿(以及 deny 部署上**同步流腿自己的窗到期**)
399
+ // 是当场 fail-closed deny,checkpoint 在不在场都一样 —— 「有 checkpoint 就 park」在那种部署上整句
400
+ // 为假。⚠️ 本应答里**没有**任何 unattended-policy 位(刻意:那是部署级停机姿态,不是能力面),所以
401
+ // 壳在 wire 上判不出自己连的是 park 还是 deny 部署 ⇒ **别按 park 写分支**,终局一律读 done 帧的
402
+ // status/errorCode(附录 A 的成文原则)。机器门(capabilities-doc-appendix)只对拍键集与谓词类词
403
+ // 表,语义列不在门内 —— 这正是本句能静默漂一整批的原因,所以它只能靠人守,别指望门。
397
404
  toolApproval: Boolean(deps.toolApproval),
398
405
  // #151 车3 刀 3b(设计稿 §2.4):design/172 **流内审批协议**(`approval_request` 呈卡帧 + 开流
399
406
  // preamble 对账基准 + durable 回决端点)。判据走**单一谓词** `resolveStreamApprovalGate` —— 与协调器
@@ -420,8 +427,10 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
420
427
  // 逐次如实回答(店缺席/店抖动时它就是 `false`)。把部署条件混进版本位,会让「says yes ⟺ 面真能用」
421
428
  // 这句话在两个不同的问题上各说一半(与 `permissionRulesRevoke` 的存在性信号同族,见其注)。
422
429
  approvalDecisionNote: true,
423
- // [1469] POST /v1/side-query(core 1.361 Runner.sideQuery 包装):一次性 brain 路由问答,无 session
424
- // 副作用。恒可用(runner 自带)——探测位供壳判「引擎腿在」而非 trial-by-404。
430
+ // [1469] POST /v1/side-query(core 的一次性 brain 路由问答,无 session 副作用)。恒可用 —— 探测位
431
+ // 供壳判「引擎腿在」而非 trial-by-404。
432
+ // #303 起出口是 `deps.sideQuery`(boot/side-query-lane.ts:真 runSideQuery + per-model key 注入 brain),
433
+ // 不再是 Runner.sideQuery;能力位语义不变(必填席,恒在场)。
425
434
  sideQuery: true,
426
435
  // E16/E17/E21 (§0.5 session-ownership): list/fork/delete are homed on the durable SESSION abstraction.
427
436
  // Each flag is true ONLY when its PRODUCING path exists (never a bare const) — list needs an enumerator