@sema-agent/server 3.9.0 → 3.11.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.
package/USAGE.md CHANGED
@@ -59,9 +59,13 @@ MODEL_FIRST_TOKEN_TIMEOUT_MS=30000 # SSE 已开但迟迟不吐第一个 delta(
59
59
  MODEL_IDLE_TIMEOUT_MS=20000 # (1.40.1)出过 token 后中途卡死:每个 delta 重置,静默超时→中止(补 first-token 只管首字)
60
60
  # ④ 断路器:主网关连败 N 次即"开路"→ 快速失败,让 failover 立刻切备(不再逐个等超时)
61
61
  MODEL_CIRCUIT_BREAKER=true MODEL_CB_FAILURE_THRESHOLD=5 MODEL_CB_COOLDOWN_MS=30000
62
+ # ⑤ 每次调用的重试上限(0–20)。**不设 = 用引擎默认**(server 不再钉死它)
63
+ GATEWAY_MAX_RETRIES=10 # openai 兼容腿(第三方限流 provider 走的就是这条)
64
+ ANTHROPIC_MAX_RETRIES=10 # 云 Anthropic 腿
62
65
  ```
63
66
  - **全部默认关**(超时=0、断路器=false)→ 不设就和以前**逐字节一致**。
64
67
  - 断路器**只在配了 `MODEL_GATEWAY_FALLBACK_URLS`(≥2 路)时才有意义**——它的价值是"开路即快速失败 → failover 立刻切备";单网关下它是 no-op(启动日志 `circuitBreakerNoop` 会提示)。只 `network/server/rate_limit` 计入连败,`auth`/`invalid_request` 不计(坏 key 熔断整网关无意义)。备用网关(最后一路)不套断路器。
68
+ - **重试上限**(2026-07-31):两个键**不设就不传给引擎** —— 引擎默认当家。以前这里是 server 侧硬编码 `2`(openai 腿甚至零配置出口),而**显式传参压过引擎默认**,于是引擎抬默认对 server 部署毫无效果;第三方限流 provider 下"重试两次就放弃"正是由此而来。现在:不设=继承引擎默认(抬默认那天自动跟上),设了=按设的走。钉在 `test/brain.test.ts` 的「网关腿重试次数」两条上。
65
69
  - 断路器状态:配了 `SESSION_BACKEND=tidb` 时自动用**跨副本共享态**(TiDB `circuit_breaker` 表,写穿+刷新最终一致),否则进程内 Map。启动日志 `breakerState` 字段回显 `shared(tidb)`/`in-process`/`off`。详见 `design/27`。
66
70
  - **大工具结果落盘**(core 1.47/1.49):单条工具结果 > ~20000 字符时 core 把全文移出上下文、只留预览+ref,模型用 `read_tool_result` 按需分页回取。配了 TiDB 时自动用**durable `tool_result` 表**(跨副本 wake 仍能取回全文;否则 core 进程内默认 = 跨副本 wake 取不到→降级到预览,不崩)。`TOOL_RESULT_TTL_SEC`(默认 86400)按 TTL 回收(要 ≥ run 可恢复期)。启动日志 `toolResultStore` 回显 `shared(tidb)`/`in-process`。
67
71
 
package/dist/brain.js CHANGED
@@ -48,10 +48,15 @@ export function createBrain(config, deps = {}) {
48
48
  firstTokenTimeoutMs: r.firstTokenTimeoutMs,
49
49
  idleTimeoutMs: r.idleTimeoutMs,
50
50
  };
51
+ // 🔴 重试上限:**配了才传**。此前这里(和下面 degrade fallback 那处)硬编码 `maxRetries: 2`,而显式传参
52
+ // 压过引擎默认 ⇒ core 把默认抬到 10 对 server 部署零效果。缺席不传之后引擎默认当家、抬默认自动继承。
53
+ // (五报清查 R1① 的 server 半场:限流 provider 走的正是这条 openai 兼容腿,而它此前**零配置出口**——
54
+ // 同文件的 anthropic 腿反倒早有 `ANTHROPIC_MAX_RETRIES`。钉在 test/brain.test.ts 的两条上。)
55
+ const retryCap = config.gatewayMaxRetries !== undefined ? { maxRetries: config.gatewayMaxRetries } : {};
51
56
  // Local openai-compatible gateway stack: primary + optional same-protocol fallbacks (failover).
52
57
  const routes = [config.gatewayBaseUrl, ...config.gatewayFallbackUrls];
53
58
  const openaiBrains = routes.map((baseUrl, i) => {
54
- const brain = createOpenAIBrain({ baseUrl, apiKey: config.gatewayApiKey, maxRetries: 2, fetchImpl, ...timeouts });
59
+ const brain = createOpenAIBrain({ baseUrl, apiKey: config.gatewayApiKey, ...retryCap, fetchImpl, ...timeouts });
55
60
  // Breaker on every route EXCEPT the last (the bare last-resort backup), and only with ≥2 routes —
56
61
  // a breaker without a failover alternative would just hard-fail the only gateway during cooldown.
57
62
  const isLast = i === routes.length - 1;
@@ -82,7 +87,8 @@ export function createBrain(config, deps = {}) {
82
87
  baseUrl: config.anthropic.baseUrl,
83
88
  version: config.anthropic.version,
84
89
  cacheBreakpoints: config.anthropic.cacheBreakpoints,
85
- maxRetries: config.anthropic.maxRetries,
90
+ // 同 retryCap:配了才传(`ANTHROPIC_MAX_RETRIES` 缺席时 config 里就没这个键)
91
+ ...(config.anthropic.maxRetries !== undefined ? { maxRetries: config.anthropic.maxRetries } : {}),
86
92
  fetchImpl,
87
93
  ...timeouts,
88
94
  })
@@ -138,7 +144,7 @@ export function createBrain(config, deps = {}) {
138
144
  get apiKey() {
139
145
  return fbApiKey();
140
146
  },
141
- maxRetries: 2,
147
+ ...retryCap, // 与主路同源:配了才传,没配让引擎默认当家(见上面 retryCap 的注)
142
148
  fetchImpl,
143
149
  ...timeouts,
144
150
  });
package/dist/budget.js CHANGED
@@ -178,6 +178,15 @@ export function createTracer(metrics, costQuota, modelUsage, fleetUsage, fleetLe
178
178
  // 「下界 + costUnknown」——两层语义与本处的关系见 fleet-client.ts 的旁注(3.7.0 前这里曾写
179
179
  // 「wire 表达不了缺席、center 只见低估」,已被该车推翻;doc-rot 修 2026-07-30)。
180
180
  // └──────────────────────────────────────────────────────────────────────────────────────────────┘
181
+ // [2153]③ 侦察钉(core 2.6.0):`brain.call` 帧新增可选 `turn?: number`(trace.d.ts 确认;此前只有
182
+ // `turn.end`/`repetition.detected`/`task.degraded` 三帧带 turn)。**刻意不接**——`modelUsage.record`
183
+ // 喂的是 E8 durable `model_usage` echo,累加键=`taskId → model`(见 `ModelUsageTracker.byTask`,
184
+ // 上方 class 定义),drain 边界=**per-turn**(run lifecycle 在每个 turn 边界 drain 一次、append 一条
185
+ // durable 事件行)。也就是说 turn 粒度已经由「何时 drain」这个外层动作表达,一次 drain 窗口内本就
186
+ // 只可能有一个 turn 的 delta——帧上逐行再带一份 `turn` 是重复信息,对已经 per-turn 聚合的行没有
187
+ // 增量语义(不是「丢了信息」,是「信息已经在结构里」)。将来若变盘:若聚合粒度改粗(比如攒够 N
188
+ // turn 才 drain 一次,单行 delta 跨多个 turn),或 usage-analytics 想要「同一 drain 窗口内哪个 turn
189
+ // 花了多少」的细分,才值得把 `e.turn` 塞进 `ModelUsageDelta`(那时 byTask 需要再加一层 turn 键)。
181
190
  // E8 (shell-host): per-task × per-model usage for the `TaskStats.modelUsage` echo. `record` is a NO-OP unless
182
191
  // e.taskId is a REGISTERED top-level run (the leak fence — sub-task/throwaway taskIds are never registered, so
183
192
  // they neither leak nor enter the echo; their spend is in TaskStats.nested). Drained at turn boundaries into the
@@ -201,6 +210,20 @@ export function createTracer(metrics, costQuota, modelUsage, fleetUsage, fleetLe
201
210
  metrics.observe("brain_call_latency_ms", e.latencyMs);
202
211
  }
203
212
  else if (e.kind === "tool.call") {
213
+ // [2153]③ 侦察钉(core 2.6.0):`tool.call` 帧新增可选 `toolCallId?: string` / `turn?: number`(trace.d.ts
214
+ // 确认)。**刻意不接进 `tool_calls_total` 的标签**:`toolCallId` 逐调用唯一 → 直接当标签会让这个
215
+ // 计数器的基数=调用总数(等于没聚合),是本文件 `cardinalityGuard` 存在的理由要防的那类爆炸,
216
+ // 连 guard 都救不了(不是「用户输入无界」那类可折叠 __other__ 的情形,是键本身设计上就唯一)。
217
+ // `turn` 本身是低基数(有界于单任务的 turn 数),但作为**跨任务**的 Prometheus 标签会随长任务/
218
+ // 多任务叠加成事实上无界的序列(同 model/tool_name 已用 guard 的理由,turn 目前没有等价上限)——
219
+ // 且当前没有「按 turn 分桶看 tool 调用量」的仪表盘/告警消费者在等这个切面,先不加标签。这两个键
220
+ // 已经在别的通道有名分:`toolCallId` 走 wire 的 `tool_start`/`tool_end` TaskEvent(trace/project.ts
221
+ // toolStartEventData/toolEndEventData,逐调用可关联、不进 metrics 基数);`turn` 走 `turn.end`/
222
+ // `task.end.turns`。本臂(createTracer→/metrics)只做计数,不是这两键的合适去处。将来若要接:
223
+ // (a) 出现「per-turn tool 调用密度」的真实消费者(如异常 turn 侦测),`turn` 分桶价值才成立——
224
+ // 用有界桶(如 `turn <= 20 ? String(turn) : "20+"`)而非原始整数,避免上面同款无界问题;
225
+ // (b) `toolCallId` 除非改造成非标签的关联字段(如结构化日志行,不是 Prometheus label),否则永远
226
+ // 不该进这个计数器。
204
227
  metrics.inc("tool_calls_total", { name: toolName(e.name), ok: String(e.ok) });
205
228
  }
206
229
  else if (e.kind === "task.degraded") {
@@ -64,6 +64,13 @@ export interface ServiceConfigFlat {
64
64
  /** Secondary openai-compatible gateways for cross-gateway FAILOVER (same protocol + same model id).
65
65
  * Tried in order after the primary when a gateway fails before streaming any content. */
66
66
  gatewayFallbackUrls: string[];
67
+ /** `GATEWAY_MAX_RETRIES` —— openai 兼容腿的**每次调用**重试上限。
68
+ *
69
+ * 🔴 **缺席 = 不传给引擎**(不是"传个默认值"):此前这里是硬编码 `maxRetries: 2`,而显式传参**压过**
70
+ * 引擎默认 ⇒ core 抬默认对 server 部署一点效果都没有。缺席不传之后,引擎默认当家、core 抬默认自动继承。
71
+ * (五报清查 R1① 的 server 半场,黑板 [2161]/[2162];anthropic 腿早有 `ANTHROPIC_MAX_RETRIES` 出口,
72
+ * 这条只是把同样的出口补给限流 provider 真正走的那条腿。) */
73
+ gatewayMaxRetries?: number;
67
74
  /** Optional Anthropic Messages API ROUTE (cloud). Selected for models whose `provider` is
68
75
  * "anthropic" (routing is by model.provider); the local gateway serves every other provider. */
69
76
  anthropic?: {
@@ -79,7 +86,9 @@ export interface ServiceConfigFlat {
79
86
  version?: string;
80
87
  /** Prompt-cache breakpoints on system + last tool (stable prefix). Default true. */
81
88
  cacheBreakpoints: boolean;
82
- maxRetries: number;
89
+ /** `ANTHROPIC_MAX_RETRIES` —— **缺席不铸键**,同 `gatewayMaxRetries` 的理由(见其顶注):
90
+ * env 没配时以前会铸成 `2` 再显式传给引擎,把引擎默认钉死在 server 这一层。 */
91
+ maxRetries?: number;
83
92
  };
84
93
  /** Resilience brain stack (1.38, design/27): tiered timeouts + an optional circuit breaker layered
85
94
  * UNDER the existing failover (gatewayFallbackUrls). Every field defaults to off/disabled, so the
@@ -670,8 +679,13 @@ export interface ServiceConfigFlat {
670
679
  * (default/auto/acceptEdits) gate every fs WRITE hand tool (Write/Edit/NotebookEdit) but never touched Bash —
671
680
  * a model can `echo -n '…' > file` straight past the write-approval card. `MANUAL_MODE_SHELL_GATE` opts a
672
681
  * deployment into ALSO tightening `TaskSpec.shellGate` on those same modes (core `bashReversibilityProbe`
673
- * under `"classify"` only asks on constructively-irreversible commands — shell redirects etc.; plain reads
674
- * auto-allow). Absent/unset = `undefined` = **zero behavior change** (this is a tighten-ONLY opt-in, never a
682
+ * under `"classify"` only asks on constructively-irreversible commands — shell redirects etc.).
683
+ * 🔴 **语义随 core 版本演进(2026-07-31 核)**:`"classify"` 下「纯读自动放行」这句自 core 2.7.0
684
+ * **不再无条件成立** —— RB-412 给 classifier 加了 root 边界扫描,core 内部自铸 boundary
685
+ * (`prepare-task.ts` 的 `shellReadBoundary`:roots=任务 root+additionalRoots,cwd=活 handsCwd),
686
+ * **读到 roots 之外的路径同样会 ask**(server 侧零接线即得:我们只传 shellGate,边界是 core 自算的)。
687
+ * 旧 core(<2.7.0)下仍是名字级判定=越界读放行,两者都能工作、后者更严 ⇒ floor 不因此抬。
688
+ * Absent/unset = `undefined` = **zero behavior change** (this is a tighten-ONLY opt-in, never a
675
689
  * default — a deployment must explicitly choose it); `"always"` = CC-strict parity (every Bash asks). Any
676
690
  * other value (incl. `"off"`) is treated as unset — this knob has no "off" value to opt BACK OUT with once a
677
691
  * session's baseline already tightened elsewhere (tightenTaskSpec would reject that as a loosen anyway). */
@@ -860,7 +874,7 @@ export interface ServiceConfigFlat {
860
874
  /** 组:store(持久化)—— DB 引擎三态、session 后端、SQL coords、快照 BLOB / SendUserFile 对象存储。 */
861
875
  export type ServiceStoreConfig = Pick<ServiceConfigFlat, "sessionBackend" | "sessionCacheTtlSec" | "rewindSnapshotMaxMb" | "dbBackend" | "dbBackendExplicit" | "localDataRoot" | "tidb" | "pg" | "dbQueryTimeoutMs" | "snapshotBlobStore" | "snapshotBlobSqlMaxBytes" | "snapshotBlobAllowSql" | "sendUserFile">;
862
876
  /** 组:modelPlane(模型面)—— 网关坐标、Anthropic 路线、韧性旋钮、主/廉价 model entry、role 表、降级梯。 */
863
- export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "anthropic" | "resilience" | "model" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "cascadeLadder" | "degrade">;
877
+ export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "gatewayMaxRetries" | "anthropic" | "resilience" | "model" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "cascadeLadder" | "degrade">;
864
878
  /** 组:approval(审批 / HITL 门)。`directDoorActive` 无 env 解析腿(装配层三域合取的产物),但语义上
865
879
  * 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
866
880
  export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalPollMs" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "askQuestionEnabled" | "toolApprovalEnabled" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
package/dist/config.js CHANGED
@@ -670,6 +670,9 @@ function parseModelDomain() {
670
670
  gatewayBaseUrl,
671
671
  gatewayApiKey: process.env.MODEL_API_KEY,
672
672
  gatewayFallbackUrls: csv("MODEL_GATEWAY_FALLBACK_URLS"),
673
+ // 缺席**不铸键**(不是铸个 2):见 config-types 的 `gatewayMaxRetries` 顶注 —— 缺席时 brain.ts 不把
674
+ // 这个字段传给引擎,引擎默认当家。铸个默认值等于把「server 钉死引擎默认」这个病换个地方犯。
675
+ ...(process.env.GATEWAY_MAX_RETRIES ? { gatewayMaxRetries: numEnvBounded("GATEWAY_MAX_RETRIES", "2", 0, 20) } : {}),
673
676
  // Anthropic-compatible-ecosystem env compat (2026-07-03): the route now assembles when EITHER credential
674
677
  // is present — `ANTHROPIC_API_KEY` (x-api-key) or `ANTHROPIC_AUTH_TOKEN` (Authorization: Bearer; many such
675
678
  // providers accept only this). AUTH_TOKEN wins when both are set. Base URL reads the widely-adopted
@@ -683,7 +686,7 @@ function parseModelDomain() {
683
686
  baseUrl: firstSetEnv("ANTHROPIC_BASEURL", "ANTHROPIC_BASE_URL"),
684
687
  version: process.env.ANTHROPIC_VERSION,
685
688
  cacheBreakpoints: (process.env.ANTHROPIC_CACHE_BREAKPOINTS ?? "true") === "true",
686
- maxRetries: Number(env("ANTHROPIC_MAX_RETRIES", "2")),
689
+ ...(process.env.ANTHROPIC_MAX_RETRIES ? { maxRetries: Number(env("ANTHROPIC_MAX_RETRIES", "2")) } : {}),
687
690
  }
688
691
  : undefined,
689
692
  resilience: {
@@ -1211,7 +1214,7 @@ const STORE_GROUP_KEYS = [
1211
1214
  "tidb", "pg", "dbQueryTimeoutMs", "snapshotBlobStore", "snapshotBlobSqlMaxBytes", "snapshotBlobAllowSql", "sendUserFile",
1212
1215
  ];
1213
1216
  const MODEL_PLANE_GROUP_KEYS = [
1214
- "gatewayBaseUrl", "gatewayApiKey", "gatewayFallbackUrls", "anthropic", "resilience", "model", "models",
1217
+ "gatewayBaseUrl", "gatewayApiKey", "gatewayFallbackUrls", "gatewayMaxRetries", "anthropic", "resilience", "model", "models",
1215
1218
  "modelApiKeyEnv", "modelApiKeys", "modelQuotaWeights", "tiers", "projects", "roles", "cascadeLadder", "degrade",
1216
1219
  ];
1217
1220
  const APPROVAL_GROUP_KEYS = [
@@ -10,5 +10,16 @@
10
10
  */
11
11
  import type { IncomingMessage, ServerResponse } from "node:http";
12
12
  import type { RouteCtx } from "../route-ctx.js";
13
+ /**
14
+ * `POST /v1/sessions/:id/wake` 的路由形 —— **单源导出**,因为它有两个消费者:本模块的分发,
15
+ * 和 `server.ts` 的 `isBillableSubmitPath`(wake 会烧模型,必须吃 drain / model-roster-pending /
16
+ * 无 service-token 三道提交门)。
17
+ *
18
+ * 🔴 为什么提成常量:2026-07-31 的缝合审查出这两处**曾经不同步** —— 匹配形写死在本文件里,
19
+ * 名单在 server.ts 里,wake 从来没进过名单,于是同一条 `driveResumeIntoRunLog` 模型腿
20
+ * 从 `/v1/assistant/tasks/:id/resume` 进来受三道门管、从 wake 进来一道都不受。
21
+ * 共用一个常量之后,再想漏就得两处一起改。
22
+ */
23
+ export declare const SESSION_WAKE_RE: RegExp;
13
24
  export declare function handleNotifyWake(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
14
25
  //# sourceMappingURL=notify-wake.d.ts.map
@@ -1,6 +1,17 @@
1
1
  import { taskNotificationInboxEntry } from "../../orchestration/workflow-completion-inbox.js";
2
2
  import { sendJson, sendError } from "../send.js";
3
3
  import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
4
+ /**
5
+ * `POST /v1/sessions/:id/wake` 的路由形 —— **单源导出**,因为它有两个消费者:本模块的分发,
6
+ * 和 `server.ts` 的 `isBillableSubmitPath`(wake 会烧模型,必须吃 drain / model-roster-pending /
7
+ * 无 service-token 三道提交门)。
8
+ *
9
+ * 🔴 为什么提成常量:2026-07-31 的缝合审查出这两处**曾经不同步** —— 匹配形写死在本文件里,
10
+ * 名单在 server.ts 里,wake 从来没进过名单,于是同一条 `driveResumeIntoRunLog` 模型腿
11
+ * 从 `/v1/assistant/tasks/:id/resume` 进来受三道门管、从 wake 进来一道都不受。
12
+ * 共用一个常量之后,再想漏就得两处一起改。
13
+ */
14
+ export const SESSION_WAKE_RE = /^\/v1\/sessions\/[^/]+\/wake$/;
4
15
  export async function handleNotifyWake(req, res, url, ctx) {
5
16
  const miss = { fell: false };
6
17
  await handleNotifyWakeBody(req, res, url, ctx, miss);
@@ -112,7 +123,7 @@ async function handleNotifyWakeBody(req, res, url, ctx, miss) {
112
123
  sendJson(res, 202, { sessionId: notifySession, delivery: "parked", note: "no live stream — parked in the session inbox; drained as a task_notification on the next stream open" });
113
124
  return;
114
125
  }
115
- if (req.method === "POST" && /^\/v1\/sessions\/[^/]+\/wake$/.test(url)) {
126
+ if (req.method === "POST" && SESSION_WAKE_RE.test(url)) {
116
127
  // design/144 §3(core 1.283 wake arm):对 task_done 纯 park 的「消息唤醒」。非门决策——不 allow/deny
117
128
  // 任何 pending 动作,只解除 park 并把消息(或已 park 的 pendingSteer)作为续跑首轮输入。有未决门 =
118
129
  // core `wake.gate_pending` 拒(永不绕审批);空 park + 无 message = `wake.nothing_to_deliver` 拒。
@@ -34,7 +34,7 @@ import { handleAttachments } from "./routes/attachments.js";
34
34
  import { handleFleet } from "./routes/fleet.js";
35
35
  import { handleTraceUsage } from "./routes/trace-usage.js";
36
36
  import { handleWorkflows, handleWorkflowAgentSteer } from "./routes/workflows.js";
37
- import { handleNotifyWake } from "./routes/notify-wake.js";
37
+ import { handleNotifyWake, SESSION_WAKE_RE } from "./routes/notify-wake.js";
38
38
  import { handleApprovalsAssistant, streamApprovals, isQuestionAnswer, ASSISTANT_RESUME_RE, ASSISTANT_PLAN_REVIEW_RE } from "./routes/approvals-assistant.js";
39
39
  export { streamApprovals, isQuestionAnswer };
40
40
  import { handleRuns, handleRunVerbs, RUN_SUBAGENT_RESUME_RE } from "./routes/runs.js";
@@ -415,7 +415,12 @@ export function createHttpServer(rawDeps) {
415
415
  }
416
416
  const { authToken: t, metricsToken: mt } = deps.config;
417
417
  const anyT = Boolean(t) || Object.keys(deps.config.authTokens ?? {}).length > 0;
418
- const ok = (!anyT && !mt) || (anyT && systemFor(req, deps.config) !== undefined) || (!!mt && authorized(req, mt));
418
+ // 🔴 第一臂的 `!requirePrincipal` 2026-07-31 缝合审补的:同族单源 `isFleetWide` 早就带着它
419
+ // (连同那段「多租户下 token 的缺席不得开放全网」的审计注释),这一处停在了旧形。要紧在于
420
+ // `/metrics/plan-cache` 的 dump **按 principal 分键** ⇒ 它是租户名册而不是聚合数;而「多租 + 无
421
+ // service token + 无 metrics token」并不是 boot 会拒的形(main.ts 只 warn)。dev-open 只在单用户形成立。
422
+ const devOpen = !anyT && !mt && !deps.config.requirePrincipal;
423
+ const ok = devOpen || (anyT && systemFor(req, deps.config) !== undefined) || (!!mt && authorized(req, mt));
419
424
  if (!ok) {
420
425
  sendError(res, 401, "auth.unauthorized", "unauthorized");
421
426
  return;
@@ -2447,6 +2452,11 @@ function isBillableSubmitPath(url) {
2447
2452
  ASSISTANT_RESUME_RE.test(url) || // resource_limit/preempt resume runs the model → BILLABLE (preempt is not)
2448
2453
  ASSISTANT_PLAN_REVIEW_RE.test(url) || // plan_review approve/edit resumes + runs the model → BILLABLE
2449
2454
  RUN_SUBAGENT_RESUME_RE.test(url) || // 对抗复查 B-2(HIGH):subagent revive (handle.resume) STARTS model work → BILLABLE
2455
+ // 缝合审 2026-07-31:wake 走的是 assistant resume 那条同一个 `driveResumeIntoRunLog` 驱动腿
2456
+ // (`routes/notify-wake.ts` 头注:「wake 会**烧模型**(续跑)」),resume 在名单里而它一直不在 ⇒
2457
+ // 同一条模型腿从 wake 进来时 drain / model-roster-pending / 无 service-token 三道门全不生效。
2458
+ // 正则从 notify-wake 单源导入,免得又漂开。钉在 test/drain.test.ts。
2459
+ SESSION_WAKE_RE.test(url) ||
2450
2460
  url.startsWith("/v1/approvals"));
2451
2461
  }
2452
2462
  // ⚠️ SSE 帧纪律(2026-07-12):center BFF 中继凭「data 无 type 字段」识别心跳帧并吞掉
@@ -2503,6 +2513,10 @@ const ROUTE_LABEL_LITERALS = new Set([
2503
2513
  "/v1/assistant/inbox", "/v1/assistant/tasks", "/v1/usage", "/v1/policy", "/v1/capabilities",
2504
2514
  "/v1/images", "/v1/images/bakes", "/v1/images/bakes/claim", "/v1/images/select", "/v1/images/register",
2505
2515
  "/v1/workflows", "/v1/attachments", "/v1/leader", "/v1/outcomes", "/v1/side-query",
2516
+ // 2026-07-31 缝合审:它一直不在表里,而它是**每个壳一条的长连接** —— 那种 duration 落进 `other`
2517
+ // 桶会把该桶的 P99 拉爆,运营看不出是谁。批1 建表时漏掉它不是遗忘:fleet 域用 `pathname === …` 判路,
2518
+ // 名册门的枚举器当时只认 `url === …`,整条路由对门隐形(枚举器已一并补上)。
2519
+ "/v1/fleet/stream",
2506
2520
  "/v1/memory/export", "/v1/sendfile-links",
2507
2521
  ]);
2508
2522
  /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`). */
@@ -205,6 +205,12 @@ export class SqlFileSnapshotStore {
205
205
  // the manifest tx runs → fail-closed: no manifest row ⇒ has() false ⇒ never a half-snapshot a restore would
206
206
  // silently truncate. Upsert REFRESHES created_at (incl dedup hits) → blobs touched=now, GC-grace-protected.
207
207
  for (const hash of new Set(manifest.values())) {
208
+ // D2(试剂盒揪出,RB-361 同源):路径逃逸型 hash 在任何 fetch **之前**拒,文案逐字同 core
209
+ // File/InMemory 后端(消费方按文案分支不劈叉);SQL 侧 hash 虽只是字符串键,但 srcGetBlob
210
+ // 是跨后端回调(2c 传输面可能远端),可疑值不外递。
211
+ if (hash === "" || hash === "." || hash === ".." || !/^[A-Za-z0-9_.-]+$/.test(hash)) {
212
+ return { ok: false, error: { code: "read_failed", message: `unsafe blob hash ${JSON.stringify(hash)}` } };
213
+ }
208
214
  let bytes;
209
215
  try {
210
216
  bytes = await srcGetBlob(hash);
@@ -10,6 +10,13 @@ function assertScope(scope) {
10
10
  /** 键长守卫(复审 F5):TiDB `INSERT IGNORE` 对超 190 字节键**静默截断**(warning 降级)——截断键
11
11
  * 与全长参数的字节比对恒 miss(seq 分配读空行 TypeError / 隔离键错位);PG 侧是干净 22001,同点
12
12
  * 提前拒 = 双方言同文案 fail-loud。 */
13
+ /** D4(试剂盒揪出,RB-251 语义):身份键含 NUL/控制符——TiDB VARBINARY 能存但与文件头「两后端一致
14
+ * 拒绝」的声明不符(该声明此前是谎);写入面双方言同精神拒绝(PG 臂经 PgUnstorableError 已拒)。 */
15
+ function assertIdentityChars(what, v) {
16
+ if ([...v].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f)) {
17
+ throw new Error(`MailboxStore: ${what} contains control characters (refusing — identity keys must be byte-exact addressable)`);
18
+ }
19
+ }
13
20
  function assertKeyBytes(what, v) {
14
21
  if (Buffer.byteLength(v, "utf8") > 190) {
15
22
  throw new Error(`MailboxStore: ${what} exceeds the 190-byte key column (refusing — a truncated key would desync from its byte-exact lookups)`);
@@ -95,6 +102,8 @@ export class TiDBMailboxStore {
95
102
  }
96
103
  async append(scope, handle, msg) {
97
104
  assertScope(scope);
105
+ assertIdentityChars("scope", scope);
106
+ assertIdentityChars("handle", handle);
98
107
  assertKeyBytes("scope", scope);
99
108
  assertKeyBytes("handle", handle);
100
109
  // ensure-first 形:先幂等 INSERT IGNORE 建盒(next_seq=1,首条/drop 后重建同臂)再 FOR UPDATE——
@@ -290,6 +299,10 @@ export class PgMailboxStore {
290
299
  WHERE scope_key = $1 AND handle = $2 AND lease_owner = $3`, [scope, handle, owner]);
291
300
  }
292
301
  async peekCount(scope, handle) {
302
+ // D4(试剂盒揪出):unstorable 身份键在**读面降级**返回 0(该键在写入面就被拒 ⇒ 盒必空),
303
+ // 而不是把驱动层 22021 裸抛给调用方——与 tool-result 读面「degrades instead」同精神。
304
+ if (pgHasUnstorable(scope) || pgHasUnstorable(handle))
305
+ return 0;
293
306
  const { rows } = await this.pool.query(`SELECT COUNT(*) AS n FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2`, [scope, handle]);
294
307
  return Number(rows[0].n);
295
308
  }
@@ -51,6 +51,11 @@ export declare class SqlToolResultStore implements ToolResultStore {
51
51
  constructor(db: SqlDriver);
52
52
  /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
53
53
  private q;
54
+ /** D1(试剂盒揪出,RB-266 语义):ref 是主键且 deleteBySession 靠 `tr_<sid>_%` 前缀清理——不合规
55
+ * ref 的行既躲过 session 删除也只能等 TTL,永远清不掉。判定逐字镜像 core assertSafeToolResultRef
56
+ * (dist/core/tool-result-store.js:5;core 根不导出,本地镜像,试剂盒契约条目为同源锚)。 */
57
+ private isUnsafeRef;
58
+ private assertSafeRef;
54
59
  put(ref: string, content: string): Promise<void>;
55
60
  get(ref: string, opts?: {
56
61
  offset?: number;
@@ -28,7 +28,19 @@ export class SqlToolResultStore {
28
28
  q(tidb, pg) {
29
29
  return this.db.dialect === "tidb" ? tidb : pg;
30
30
  }
31
+ /** D1(试剂盒揪出,RB-266 语义):ref 是主键且 deleteBySession 靠 `tr_<sid>_%` 前缀清理——不合规
32
+ * ref 的行既躲过 session 删除也只能等 TTL,永远清不掉。判定逐字镜像 core assertSafeToolResultRef
33
+ * (dist/core/tool-result-store.js:5;core 根不导出,本地镜像,试剂盒契约条目为同源锚)。 */
34
+ isUnsafeRef(ref) {
35
+ return ref === "" || ref === "." || ref === ".." || ref.includes("/") || ref.includes("\\") ||
36
+ [...ref].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f);
37
+ }
38
+ assertSafeRef(ref) {
39
+ if (this.isUnsafeRef(ref))
40
+ throw new Error(`tool-result store: unsafe ref ${JSON.stringify(ref)}`);
41
+ }
31
42
  async put(ref, content) {
43
+ this.assertSafeRef(ref);
32
44
  // Sanitize invalid UTF-16 (lone/half surrogates — a JS string CAN hold them, e.g. a binary-ish tool
33
45
  // output) to U+FFFD so the column accepts the FULL value via a Buffer utf8 round-trip (valid content is
34
46
  // unchanged). Shared by both dialects.
@@ -45,6 +57,10 @@ export class SqlToolResultStore {
45
57
  await this.db.query(this.q("INSERT IGNORE INTO tool_result (ref, content, created_at) VALUES (?,?,?)", "INSERT INTO tool_result (ref, content, created_at) VALUES ($1,$2,$3) ON CONFLICT (ref) DO NOTHING"), [ref, safe, new Date()]);
46
58
  }
47
59
  async get(ref, opts = {}) {
60
+ // D1 读面半条(RB-266「the read face degrades instead」):unsafe ref 在读面**降级**返回 undefined
61
+ // (该 ref 写入面就被拒 ⇒ 必不在库),不把驱动层错误(PG 22021 对 NUL)漏给调用方。
62
+ if (this.isUnsafeRef(ref))
63
+ return undefined;
48
64
  const offset = Math.max(0, Math.floor(opts.offset ?? 0));
49
65
  const hasLimit = opts.limit !== undefined && Number.isFinite(opts.limit);
50
66
  const limit = hasLimit ? Math.max(0, Math.floor(opts.limit)) : 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.9.0",
3
+ "version": "3.11.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",