@sema-agent/server 5.0.0 → 5.1.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/README.md CHANGED
@@ -173,7 +173,7 @@ One row per endpoint family (not exhaustive):
173
173
  | `POST /v1/runs` · `GET /v1/runs/:id` | Asynchronous runs: immediate `202`, background execution, poll for status/result |
174
174
  | `GET /v1/runs/:id/events` | Replayable SSE (`Last-Event-ID` resume); any replica can serve any run |
175
175
  | `POST /v1/runs/:id/cancel` / `steer` / `compact` · `/v1/runs/:id/subagents/:target/steer` / `resume` | Run control verbs: cooperative cancel, mid-run steering, context compaction, sub-agent steering/resume |
176
- | `/v1/approvals` (list · get · decide · stream) | Human-in-the-loop approval center backed by durable checkpoints; decisions from any replica |
176
+ | `/v1/approvals` (list · decide · stream) | Human-in-the-loop approval center backed by durable checkpoints; decisions from any replica |
177
177
  | `/v1/sessions` (list · get · fork · init · settings · wake) | Session listing/search, audit inspection, fork, startup bundle |
178
178
  | `/v1/workflows` (list · get · stream · agents/:label/steer) | Deterministic workflow orchestration runs with live streams and per-agent steering |
179
179
  | `GET /v1/usage` · `GET /v1/policy` | Cumulative spend (when quotas are configured) and effective policy read surface |
package/README.zh-CN.md CHANGED
@@ -164,7 +164,7 @@ curl -s localhost:8090/v1/tasks -H "Authorization: Bearer <SERVICE_AUTH_TOKEN>"
164
164
  | `POST /v1/runs` · `GET /v1/runs/:id` | 异步 run:立即 `202`,后台续跑,轮询状态/结果 |
165
165
  | `GET /v1/runs/:id/events` | 可重放 SSE(`Last-Event-ID` 续订);任意副本可服务任意 run |
166
166
  | `POST /v1/runs/:id/cancel` / `steer` / `compact` · `/v1/runs/:id/subagents/:target/steer` / `resume` | run 控制动词:协作式取消、运行中转向、上下文压缩、子智能体转向/恢复 |
167
- | `/v1/approvals`(list · get · decide · stream) | 人工审批中心,底座是持久化检查点;任意副本都能批复 |
167
+ | `/v1/approvals`(list · decide · stream) | 人工审批中心,底座是持久化检查点;任意副本都能批复 |
168
168
  | `/v1/sessions`(list · get · fork · init · settings · wake) | 会话列表/检索、审计回溯、fork、启动包 |
169
169
  | `/v1/workflows`(list · get · stream · agents/:label/steer) | 确定性 workflow 编排 run,带实时流与逐 agent 转向 |
170
170
  | `GET /v1/usage` · `GET /v1/policy` | 累计花费(配置配额时)与生效策略只读面 |
package/USAGE.md CHANGED
@@ -44,11 +44,11 @@ docker compose up --build --scale service=4 # 4 个无状态 worker,nginx
44
44
  # ① 同协议冗余:主网关挂(连不上/上游开始前就失败)→ 按序切到备网关(同一 model id)
45
45
  MODEL_GATEWAY_BASEURL=http://gw-a:8000/v1 MODEL_GATEWAY_FALLBACK_URLS=http://gw-b:8000/v1,http://gw-c:8000/v1
46
46
  # ② 云 Anthropic 路由:provider="anthropic" 的模型走云 /v1/messages(带 prompt 缓存断点),其余走本地网关
47
- ANTHROPIC_API_KEY=sk-ant-… # 可选:ANTHROPIC_BASEURL / ANTHROPIC_VERSION / ANTHROPIC_CACHE_BREAKPOINTS=true
47
+ ANTHROPIC_API_KEY=sk-ant-… # 可选:ANTHROPIC_BASE_URL / ANTHROPIC_VERSION / ANTHROPIC_CACHE_BREAKPOINTS=true
48
48
  ```
49
49
  - 两个变量都不设 = 和以前**逐字节一致**(单网关)。
50
50
  - **failover ≠ Anthropic↔vLLM**:failover 给所有 brain 发**同一个 model**(同协议同 id 的冗余);云↔本地是**按 `model.provider` 路由**的选择,不是故障转移(两者 model id/参数不同,不能透明互切)。设 `MODEL_PROVIDER=anthropic` + `MODEL_ID=claude-…` 让整个服务走 Anthropic。
51
- - **缺省推断**:`MODEL_PROVIDER` **未设**、但显式配了 Anthropic 协议 base URL(`ANTHROPIC_BASE_URL` 或 `ANTHROPIC_BASEURL`)**和**对应凭证(`ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`)时,缺省自动判 `anthropic` 并打一行启动 warn(`model_provider_inferred`)——纯净机直连 Anthropic 兼容上游不再需要显式第七个键。显式 `MODEL_PROVIDER` 恒赢;只给 base URL 或只给凭证不推断;两者皆无 = `gateway`,与以前逐字节一致。
51
+ - **缺省推断**:`MODEL_PROVIDER` **未设**、但显式配了 Anthropic 协议 base URL(`ANTHROPIC_BASE_URL`)**和**对应凭证(`ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`)时,缺省自动判 `anthropic` 并打一行启动 warn(`model_provider_inferred`)——纯净机直连 Anthropic 兼容上游不再需要显式第七个键。显式 `MODEL_PROVIDER` 恒赢;只给 base URL 或只给凭证不推断;两者皆无 = `gateway`,与以前逐字节一致。
52
52
  - 启动日志的 `brain` 字段会回显当前组合(`failover` / `anthropicRoute` / `anthropicCacheBreakpoints`)。详见 `design/15`。
53
53
 
54
54
  **可选 — 韧性栈:分级超时 + 断路器(core 1.38,叠在 failover 之下)**
@@ -66,7 +66,7 @@ ANTHROPIC_MAX_RETRIES=10 # 云 Anthropic 腿
66
66
  - **全部默认关**(超时=0、断路器=false)→ 不设就和以前**逐字节一致**。
67
67
  - 断路器**只在配了 `MODEL_GATEWAY_FALLBACK_URLS`(≥2 路)时才有意义**——它的价值是"开路即快速失败 → failover 立刻切备";单网关下它是 no-op(启动日志 `circuitBreakerNoop` 会提示)。只 `network/server/rate_limit` 计入连败,`auth`/`invalid_request` 不计(坏 key 熔断整网关无意义)。备用网关(最后一路)不套断路器。
68
68
  - **重试上限**(2026-07-31):两个键**不设就不传给引擎** —— 引擎默认当家。以前这里是 server 侧硬编码 `2`(openai 腿甚至零配置出口),而**显式传参压过引擎默认**,于是引擎抬默认对 server 部署毫无效果;第三方限流 provider 下"重试两次就放弃"正是由此而来。现在:不设=继承引擎默认(抬默认那天自动跟上),设了=按设的走。钉在 `test/brain.test.ts` 的「网关腿重试次数」两条上。
69
- - 断路器状态:配了 `SESSION_BACKEND=tidb` 时自动用**跨副本共享态**(TiDB `circuit_breaker` 表,写穿+刷新最终一致),否则进程内 Map。启动日志 `breakerState` 字段回显 `shared(tidb)`/`in-process`/`off`。详见 `design/27`。
69
+ - 断路器状态:配了 `SESSION_BACKEND=mysql` 时自动用**跨副本共享态**(SQL `circuit_breaker` 表,写穿+刷新最终一致),否则进程内 Map。启动日志 `breakerState` 字段回显 `shared(mysql)`/`in-process`/`off`。详见 `design/27`。
70
70
 
71
71
  **可选 — WebSearch 后端(core 只留注入口,不自带任何 provider)**
72
72
  ```bash
@@ -98,8 +98,9 @@ MODEL_DEGRADE_TO=deepseek-v4-flash
98
98
  MODEL_DEGRADE_AT_COST_FRACTION=0.7 # ① 近预算(1.40):累计成本到 0.7×maxCostUsd 切(需任务有 cost ceiling)
99
99
  MODEL_DEGRADE_REACTIVE=true # ② 反应式(1.39):主模型 rate_limit/breaker-open 时切(brain 级,最外层)
100
100
  MODEL_DEGRADE_ON=rate_limit,breaker_open # 可选,反应式触发器子集;词表=breaker_open|rate_limit|budget|server_error|last_resort
101
- # ⚠️ core 5.1.0 起,不设 MODEL_DEGRADE_ON 时引擎默认**含 server_error**(主模型 5xx 也切便宜模型)。
102
- # 不想要 5xx 触发就显式设回 rate_limit,breaker_open。拼错词 boot 拒启(不静默忽略)。
101
+ # ⚠️ core 5.1.0 起,不设 MODEL_DEGRADE_ON 时引擎默认全集**含 server_error last_resort**
102
+ # (主模型 5xx、以及兜底的一般 http 错误类也切便宜模型)。
103
+ # 不想要这两类触发就显式设回 rate_limit,breaker_open。拼错词 boot 拒启(不静默忽略)。
103
104
  ```
104
105
  - 反应式降级 brain 包在**最外层**(core council #8);fallback brain 用自己的凭据(decorator 清掉主模型的 per-call key 防泄漏给别的 provider)。**坑**:`MODEL_DEGRADE_TO` 最好别和被限流的是同一网关/账号,否则反应式切过去照样撞同一个 rate_limit。
105
106
  - **定价怎么设**:`model.cost` 来自 `MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE`(**USD per 1M tokens**,默认 0 = `costUsd` 读 0)。云模型(如 review-gw 的 deepseek-v4-pro)必须设,否则 spend 恒为 $0;本地自托管(qwen)留 0 即对(无 per-token 外部花费)。字段名是 `costUsd`,**非美元计价的网关要先折算**(如 DeepSeek 官方 CNY ÷ 汇率)。配置控制面管的模型走 `CenterModel.cost`(中心存价、不存 secret)。
@@ -108,8 +109,8 @@ MODEL_DEGRADE_ON=rate_limit,breaker_open # 可选,反应式触发器子集;词
108
109
  - **`METRICS_TOKEN`**(可选,只读):设了它,`GET /metrics`+`/metrics/summary` 接受**它或** `SERVICE_AUTH_TOKEN`。**全 fleet 设同一个值** → 管理端(sema-web,规划名 sema-admin)用**一个** token 拉所有 worker 的指标,**无需持有各 worker 的全权 `SERVICE_AUTH_TOKEN`**(不破坏 secret 边界)。即使泄露也只暴露指标(只读)。
109
110
  - **`GET /metrics/summary`**(token-gated,同 `/metrics`):`/metrics` 的**精炼 JSON**——`{model, runsActive, tasks{status}, tokensTotal, costUsd, costUsdByModel, taskDurationAvgSec, brainFirstTokenAvgMs, brainCallAvgMs, cacheHitRateAvg, rateLimited, costQuotaRejected, budgetExceeded, degraded, cascade, verifications, councilRuns, toolErrors}`。给**轻量 fleet 看板**用(管理端 sema-web 的 fleet 页按 worker 拉、渲染卡片、按轮询算速率;**不用 Prometheus/Grafana**)。counter 是累计值、histogram 报均值。
110
111
  - 预算闸是 core 强制的:`maxCostUsd` pre-call 估算(`budget.precall`,没花钱就拒)+ 流中途取消 + turn 边界(`budget.exceeded`);`maxTokens` 超 → `budget.exceeded`。**review 网关**建议设 `MAX_TASK_COST_USD` 防单任务烧掉共享云 key。
111
- - **per-principal 累计配额**:用 `AsyncLocalStorage` 把 principal 透传到 cost tracer,所以**council/team 子任务的花费也算到发起人头上**。**配了 `SESSION_BACKEND=tidb` 时自动跨副本共享**(`cost_quota` 表,写后聚合的**原子自增** `micro=micro+delta`,对齐固定窗,最终一致——多副本花费 SUM 到一起、不丢增量);否则 in-memory per-replica 滚动窗(单副本兜底)。启动日志 `costQuota` 字段回显 `shared(tidb)`/`in-process`/`off`。跨副本是最终一致(flush 间隔内峰值可能略超,由**硬 per-task `maxCostUsd` 兜底**)。
112
- - **`RATE_LIMIT_RPM` 请求限流同样自动跨副本**(`SESSION_BACKEND=tidb` 时,`rate_limit` 表,与配额共用 `WriteBehindCounter`;启动日志 `rateLimit` 回显)。注意:写后聚合 = **软限流**(边界上短暂略超 OK,适合公平/热调用方防护);要**硬合规上限**得另走 CAS/原子计数,不靠写后聚合——和断路器跨副本同款权衡。
112
+ - **per-principal 累计配额**:用 `AsyncLocalStorage` 把 principal 透传到 cost tracer,所以**council/team 子任务的花费也算到发起人头上**。**配了 `SESSION_BACKEND=mysql` 时自动跨副本共享**(`cost_quota` 表,写后聚合的**原子自增** `micro=micro+delta`,对齐固定窗,最终一致——多副本花费 SUM 到一起、不丢增量);否则 in-memory per-replica 滚动窗(单副本兜底)。启动日志 `costQuota` 字段回显 `shared(mysql)`/`in-process`/`off`。跨副本是最终一致(flush 间隔内峰值可能略超,由**硬 per-task `maxCostUsd` 兜底**)。
113
+ - **`RATE_LIMIT_RPM` 请求限流同样自动跨副本**(`SESSION_BACKEND=mysql` 时,`rate_limit` 表,与配额共用 `WriteBehindCounter`;启动日志 `rateLimit` 回显)。注意:写后聚合 = **软限流**(边界上短暂略超 OK,适合公平/热调用方防护);要**硬合规上限**得另走 CAS/原子计数,不靠写后聚合——和断路器跨副本同款权衡。
113
114
 
114
115
  **可选 — 生成调参透传 + 退化打捞(core 1.59/1.60)**
115
116
  ```bash
@@ -172,14 +173,11 @@ MODEL_CASCADE_LADDER=deepseek-flash,deepseek-pro # 目录里的模型名,cheap
172
173
 
173
174
  **监听绑址(1.306+,[1934])—— 桌面/单机形请注意**
174
175
  - `BIND_HOST` = 监听地址;显式设置**恒生效**(要在无鉴权下对外暴露,显式写 `BIND_HOST=0.0.0.0` 即可)。
175
- - `HOST` 是兼容别名,但**不完全等价**(3.15.0 起):它常被 shell 继承(zsh 默认把 `HOST` 设成机器名),
176
- 所以当下面那条自动收窄的条件成立时,**收窄压过 `HOST`** —— 否则一个用户从没设过的 shell 变量就能
177
- 把无鉴权写面静默扩到 LAN。被压过时 boot 日志打 `bind_host_from_HOST_env_overridden`,点名被忽略的
178
- 值与两条逃生口(显式 `BIND_HOST`,或给写面配 token)。收窄条件不成立时 `HOST` 与从前完全一样。
176
+ - `HOST` 不再被读取(曾经的兼容别名已撤——zsh 默认把 `HOST` 设成机器名的暗通道风险已消除):
177
+ 绑址唯一口 = `BIND_HOST`,设或不设都与 `HOST` 环境变量的值无关。
179
178
  - **缺省自动收窄**:当写面无鉴权时(`ALLOW_UNAUTHED_WRITES=true` 且**完全没有**
180
179
  `SERVICE_AUTH_TOKEN`/TOKENS 名录)⇒ 自动绑 `127.0.0.1`,boot 日志点名原因。理由:该形常配
181
- `REMOTE_EXEC=host`(用户真机、非沙箱),绑全接口=同网段任何人可无鉴权提交任务并执行(1.305 及以前
182
- `HOST` env **零消费**、恒绑所有接口——沉默陷阱,已修)。
180
+ `REMOTE_EXEC=host`(用户真机、非沙箱),绑全接口=同网段任何人可无鉴权提交任务并执行。
183
181
  - 配了凭证的部署(云形/k8s/compose)缺省**不变**(全接口),既有部署零影响。
184
182
 
185
183
  **workspace 浏览面(1.299+,#3)—— 任务产出的浏览/预览/打包下载**
@@ -213,7 +211,7 @@ SEND_USER_FILE_SANDBOX_PUT_ENDPOINT=… # 可选:沙箱直传 PUT 的端
213
211
 
214
212
  **可选 — 接配置控制面(中心化模型/角色/团队配置)**
215
213
  ```bash
216
- # 新名(orchestrator 现注入);旧名 CONFIG_CENTER_* 仍兼容(dual-read,新名优先、旧名回退)
214
+ # 新名(orchestrator 现注入);旧名 CONFIG_CENTER_* 在场即 boot 拒启,只认 SEMA_REGISTRY_*
217
215
  SEMA_REGISTRY_URL=http://<config-center-host>:3100 # 启动拉 GET /api/config/effective(Bearer+ETag),覆盖 env 兜底
218
216
  SEMA_REGISTRY_TOKEN=<SERVICE_PULL_TOKEN 的值> # 取自配置控制面主机 .env;只读拉取令牌
219
217
  SEMA_REGISTRY_DRY_RUN=true # 安全灰度:只 LOG 中心配置 vs env 推导的差异,不 apply
@@ -414,7 +412,7 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
414
412
  | HTTP `403` / `404`(session/run) | 不是该 principal 的资源 | 用正确身份 |
415
413
  | HTTP `409`(`/v1/runs`) | 同 session 已有活跃 run | 等它完成 / 用返回的 `activeTaskId` |
416
414
  | HTTP `429` | 限流 | 看 `Retry-After` 退避 |
417
- | HTTP `501`(`/v1/runs`) | 内存模式不支持异步 | 配 TiDB(`SESSION_BACKEND=tidb`) |
415
+ | HTTP `501`(`/v1/runs`) | 内存模式不支持异步 | 配 MySQL 协议存储(`SESSION_BACKEND=mysql`) |
418
416
 
419
417
  **机器码**:每个 4xx/5xx 响应体都带一个 `errorCode`(与人类文案 `error` 并列),这是**唯一**该拿来做
420
418
  程序分支的字段——**别锚 `error` 文案**。前缀族固定:`auth.` / `request.` / `not_found.` / `conflict.` /
@@ -24,6 +24,18 @@ export declare function hasOperatorGateIntent(config: {
24
24
  approvalNeverAuto: readonly string[];
25
25
  durableApproval: boolean;
26
26
  }): boolean;
27
+ /**
28
+ * 门意图不可服务=boot 拒启(全窗复审 D3-F1,HIGH;与 env 墓碑族同谱系)。
29
+ * v4.3.0 的轮询门形(APPROVAL_REQUIRE + DB backend + DURABLE_APPROVAL 未设)在 5.0.0 只剩 core 每任务
30
+ * warn——**原先拦、现在放**(方向反转)。「留 undefined 让 core warn」对新配置是曝露误配,对升级存量是
31
+ * 门静默消失,后者必须响亮。放这文件与 hasOperatorGateIntent/inv#2 同源(意图判据再漂要同时改两段)。
32
+ */
33
+ export declare function assertGateIntentServiceable(config: {
34
+ approvalRequire: readonly string[];
35
+ approvalDeny: readonly string[];
36
+ approvalNeverAuto: readonly string[];
37
+ durableApproval: boolean;
38
+ }, checkpointStorePresent: boolean): void;
27
39
  /**
28
40
  * Durable approval policy (core 1.67 / design/45): returns three-state **`ask`** for gated tools instead of
29
41
  * polling. With `TaskSpec.durableApproval` set + a `checkpointStore` on the Runner, core turns that `ask` into
package/dist/approval.js CHANGED
@@ -24,6 +24,20 @@ export function hasOperatorGateIntent(config) {
24
24
  config.approvalNeverAuto.length > 0 || // ← 漏了这格,见上面的由来
25
25
  config.durableApproval);
26
26
  }
27
+ /**
28
+ * 门意图不可服务=boot 拒启(全窗复审 D3-F1,HIGH;与 env 墓碑族同谱系)。
29
+ * v4.3.0 的轮询门形(APPROVAL_REQUIRE + DB backend + DURABLE_APPROVAL 未设)在 5.0.0 只剩 core 每任务
30
+ * warn——**原先拦、现在放**(方向反转)。「留 undefined 让 core warn」对新配置是曝露误配,对升级存量是
31
+ * 门静默消失,后者必须响亮。放这文件与 hasOperatorGateIntent/inv#2 同源(意图判据再漂要同时改两段)。
32
+ */
33
+ export function assertGateIntentServiceable(config, checkpointStorePresent) {
34
+ if (!hasOperatorGateIntent(config) || checkpointStorePresent)
35
+ return;
36
+ if (!config.durableApproval) {
37
+ throw new Error("APPROVAL_REQUIRE/APPROVAL_DENY/APPROVAL_NEVER_AUTO are set but the durable gate is off — since 5.0.0 the poll lane is retired and durable is the ONLY gate. Set DURABLE_APPROVAL=true (with a DB/file backend that has a checkpoint face), or remove the APPROVAL_* lists to run ungated on purpose.");
38
+ }
39
+ throw new Error("DURABLE_APPROVAL=true but no checkpoint store is available — the configured DB backend has no checkpoint face (or no backend is configured). Wire a MySQL-protocol/PostgreSQL/file backend, or unset DURABLE_APPROVAL.");
40
+ }
27
41
  export function createDurableAskPolicy(opts) {
28
42
  // 🔒 RAW-name matching(core 5.0.0 RB-476:canonicalToolName 折叠面整体退役)。安全前提没有变松,
29
43
  // 只是换了执行者:live 请求名恒为 CC canonical(旧名在 core roster 层响亮 miss,永远到不了本 policy);
@@ -88,7 +88,7 @@ export async function createConfigCenterRuntime(ctx) {
88
88
  let pendingRestart;
89
89
  // DUAL-MODE-DESIGN §4: the config SOURCE is a backend-selectable seam — remote (config-center HTTP, the fleet
90
90
  // default) | local (.env + config.d via @sema-agent/registry-core FileConfigStore, the single-machine/TOC source).
91
- // The provider exists when SEMA_REGISTRY_URL (legacy CONFIG_CENTER_URL) is set OR CONFIG_PROVIDER=local is explicit; neither = pure-env
91
+ // The provider exists when SEMA_REGISTRY_URL (legacy CONFIG_CENTER_URL now boot-rejects, no dual-read) is set OR CONFIG_PROVIDER=local is explicit; neither = pure-env
92
92
  // (byte-identical to before). The remote provider DELEGATES verbatim to fetchEffective, so the fleet path is
93
93
  // unchanged; applyEffective consumes either source's EffectiveConfig identically (one resolver → no drift).
94
94
  const cc = config.configCenter;
@@ -77,7 +77,7 @@ export function createResolveSpec(ctx) {
77
77
  // body.scenario is bounded to the allowlist — outside ⇒ typed 400 `scenario_not_allowed`). The ruling
78
78
  // {scenario, allowlist} is center-RESOLVED and rides the caps view (TTL-cached, same fetch as runtimeCaps);
79
79
  // no center / dry-run / anonymous / older center / caps blip ⇒ undefined ⇒ legacy body-or-default chain.
80
- const requested = gateScenarioRequest(await principalCaps?.scenarioRuling(auth?.principal), body.scenario, config.defaultScenario);
80
+ const requested = gateScenarioRequest(await principalCaps?.scenarioRuling(auth?.principal), body.scenario, config.defaultScenario, Object.keys(scenarios));
81
81
  // per-principal execution-lane policy — this worker's lane is a
82
82
  // PROCESS identity (REMOTE_EXEC boot wiring, no per-task switch), so admission is the gate: lane ∉
83
83
  // allowedLanes ⇒ typed 403 `execution_lane_not_allowed`. Same caps car as the scenario ruling (zero extra
@@ -45,8 +45,6 @@ export interface ScenarioDeps {
45
45
  webFetchSummarize?: NonNullable<NonNullable<WebFetchConfig["summarize"]>>;
46
46
  metrics?: Metrics;
47
47
  logger?: Logger;
48
- /** OA backend coordinates for the `oa` scenario (read /api/v1, write /api/assets). */
49
- /** 可选:`oa` 场景注入"开 Gitea issue"工具(OA AI 请求#5);repo/token/labels 固定,模型只填 title/body。 */
50
48
  /**
51
49
  * When true, the `default` scenario carries the Sema product identity (see {@link semaIdentityProvider}).
52
50
  * Set by the local/TOC deployment (CONFIG_PROVIDER=local / run-local) so the local engine answers "who
@@ -150,8 +148,8 @@ export interface ScenarioDetail {
150
148
  promptSummary: string;
151
149
  enabled: boolean;
152
150
  }
153
- /** 内建六场景详情。default/autonomous/team 工厂对良性请求无副作用可真调(拿真实工具名单);code-review/scan/oa
154
- * fail-loud 语义(缺 GIT_API/OA 配置/principal 即 throw)→ 探针失败落静态表兜底(表↔工厂一致性由测试锁:
151
+ /** 内建五场景详情。default/autonomous/team 工厂对良性请求无副作用可真调(拿真实工具名单);code-review/scan 是
152
+ * fail-loud 语义(缺 GIT_API 配置/principal 即 throw)→ 探针失败落静态表兜底(表↔工厂一致性由测试锁:
155
153
  * 测试喂 fake deps 真调工厂对账工具名)。enabled 对内建恒 true(约定②)。 */
156
154
  export declare function builtinScenarioDetails(scenarios: Record<string, Scenario>, deps: Pick<ScenarioDeps, "brandIdentity">): Record<string, ScenarioDetail>;
157
155
  /** center 条目详情(有效性判定与 centerScenarios 完全同款:无效条目既不进 overlay 也不进详情——
@@ -198,5 +196,5 @@ export interface ScenarioRuling {
198
196
  * 到现状,与 workflows 面的 fail-closed 是刻意的 posture 差异,见 runtime-caps-resolver)。
199
197
  * - body.scenario 未给:ruling 胜者 ⇒ 指派为默认;无胜者 ⇒ defaultScenario 现状链。
200
198
  */
201
- export declare function gateScenarioRequest(ruling: ScenarioRuling | undefined, bodyScenario: unknown, defaultScenario: string): string;
199
+ export declare function gateScenarioRequest(ruling: ScenarioRuling | undefined, bodyScenario: unknown, defaultScenario: string, known: readonly string[]): string;
202
200
  //# sourceMappingURL=scenarios.d.ts.map
@@ -228,8 +228,8 @@ export function buildScenarios(deps) {
228
228
  export function selectScenario(scenarios, name) {
229
229
  return (name && scenarios[name]) || scenarios.default;
230
230
  }
231
- /** 内建六场景详情。default/autonomous/team 工厂对良性请求无副作用可真调(拿真实工具名单);code-review/scan/oa
232
- * fail-loud 语义(缺 GIT_API/OA 配置/principal 即 throw)→ 探针失败落静态表兜底(表↔工厂一致性由测试锁:
231
+ /** 内建五场景详情。default/autonomous/team 工厂对良性请求无副作用可真调(拿真实工具名单);code-review/scan 是
232
+ * fail-loud 语义(缺 GIT_API 配置/principal 即 throw)→ 探针失败落静态表兜底(表↔工厂一致性由测试锁:
233
233
  * 测试喂 fake deps 真调工厂对账工具名)。enabled 对内建恒 true(约定②)。 */
234
234
  export function builtinScenarioDetails(scenarios, deps) {
235
235
  const probe = (name, req, principal, fallback) => {
@@ -417,8 +417,18 @@ export function mergeUserSkills(scenarioSkills, userSkills, logger) {
417
417
  * 到现状,与 workflows 面的 fail-closed 是刻意的 posture 差异,见 runtime-caps-resolver)。
418
418
  * - body.scenario 未给:ruling 胜者 ⇒ 指派为默认;无胜者 ⇒ defaultScenario 现状链。
419
419
  */
420
- export function gateScenarioRequest(ruling, bodyScenario, defaultScenario) {
420
+ export function gateScenarioRequest(ruling, bodyScenario, defaultScenario, known) {
421
421
  if (typeof bodyScenario === "string") {
422
+ // [2395]D③(5.1.0):body 显式给出的**未知名**响亮 400——此前静默折 default(oa 退役后老调用方
423
+ // 无声换了场景=更宽工具面,与零兼容纲领相反)。词表面先于治理面:名字都不存在时谈不上 allowlist。
424
+ // 指派链(ruling/default)不在此验——悬空指派名仍走 resolveSpec 既有 unknown→default(runtime-caps
425
+ // 面的误配另案,不该让一次中心误配把每个请求 400 死)。
426
+ if (!known.includes(bodyScenario)) {
427
+ throw new HttpError(400, `unknown scenario "${bodyScenario}" — this deployment's scenarios are: ${known.join(", ")}`, {
428
+ code: "scenario_unknown",
429
+ extra: { known: [...known] },
430
+ });
431
+ }
422
432
  const allowed = ruling?.allowlist ?? [];
423
433
  if (allowed.length > 0 && !allowed.includes(bodyScenario)) {
424
434
  throw new HttpError(400, `scenario "${bodyScenario}" is not in this principal's assigned allowlist`, {
@@ -8,7 +8,7 @@
8
8
  import { isThinkingLevel } from "@sema-agent/core";
9
9
  import { resolveActiveTiers, SEALED_BOX_ALG } from "@sema-agent/registry-core";
10
10
  import { sealedKeyPoison } from "../sealed-key.js";
11
- import { applyAutoCompactWindow, parseAutonomy } from "../config.js";
11
+ import { applyAutoCompactWindow, findUnmatchableToolNames, formatUnmatchableToolNames, parseAutonomy } from "../config.js";
12
12
  import { validateCommandRules } from "../runtime-governance.js";
13
13
  import { projectCollabToWorkflows, registerCollabWorkflows } from "../capabilities/collab-workflows.js";
14
14
  import { registerTeams } from "../capabilities/team.js";
@@ -316,7 +316,7 @@ function stageEffective(config, eff, logger, opts) {
316
316
  // governance 切新位:治理三件优先读 governance 域(真值),runtime 旧位(双写镜像)fallback——
317
317
  // 双写期两处逐键相等语义不变;center 撤双写后 governance 即唯一来源。限额残余(rateLimit/cost 五件)仍在 runtime。
318
318
  const gatesView = eff.governance ? { ...eff.runtime, ...eff.governance } : eff.runtime;
319
- const gates = opts.teamsOnly ? undefined : stageRuntimeGates(gatesView);
319
+ const gates = opts.teamsOnly ? undefined : stageRuntimeGates(gatesView, logger);
320
320
  // Runtime governance "second baton" (center §10): autonomy + commandPolicy are per-request HOT (read live in
321
321
  // resolveSpec), NOT baked into boot middleware → apply on BOTH boot and refresh (outside the teamsOnly guard) so
322
322
  // they hot-reload. No restart-to-apply signal (they take effect on the next task without a restart).
@@ -422,8 +422,38 @@ export function runtimeGatePresent(rt, key) {
422
422
  return typeof v === "number" && v > 0;
423
423
  return typeof v === "number";
424
424
  }
425
- /** [2283] stage 半场:六闸的赋值清单(纯计算,零变异)。commit 段照单赋值,通知载荷 post-commit 发。 */
426
- function stageRuntimeGates(rt) {
425
+ /**
426
+ * A1:中心下发的 `approvalRequire` 名单**按 env 腿同款判据**验过再落配。此前只验 `Array.isArray` 就逐字
427
+ * 落配——含退役工具名(`bash`/`Task`/`KillShell`)或 pre-prefix/不完整 MCP 名(`figma__x`/`mcp__figma`)时
428
+ * 该名单项永不匹配任何现役工具,审批门静默失守,而同一拼法从 env 腿(`APPROVAL_REQUIRE`)进来是 boot 拒启
429
+ * ——两腿判据不对称,写配置的人无从知道自己踩的是哪一条。判据单点=config.ts `findUnmatchableToolNames`。
430
+ *
431
+ * 处置形照本文件既有的域级拒绝先例(`commandPolicy` 非法 ⇒ fail-loud + 保留前值):常驻进程不能因为一次
432
+ * 坏发布 crash,所以**只拒这一个域**(其余五闸照常应用)、保留前值、留一条响亮 warn。「半应用一个被静默
433
+ * 削弱的门」才是要堵的那种失败。
434
+ *
435
+ * 顺带 C1-2:数组项 trim + 去空串,与 env csv 归一(`csv()` 的 `.map(trim).filter(Boolean)`)一致——
436
+ * 否则 `" Bash "` 从中心腿进来会是个永不匹配的名字,正是本条要消灭的那类静默失效。
437
+ */
438
+ function stageApprovalRequire(raw, logger) {
439
+ // wire verbatim ⇒ 不信任元素形状(整表是 center 发的):非串项与空串一并落地板,与 env csv 归一同形。
440
+ const names = (Array.isArray(raw) ? raw : [])
441
+ .map((n) => (typeof n === "string" ? n.trim() : n))
442
+ .filter((n) => typeof n === "string" && n.length > 0);
443
+ const bad = findUnmatchableToolNames(names);
444
+ if (bad.length > 0) {
445
+ logger?.warn("sema_registry_approval_require_invalid", {
446
+ detail: formatUnmatchableToolNames("published runtime.approvalRequire", bad),
447
+ names: bad.map((b) => b.name),
448
+ kept: "the prior approvalRequire is unchanged — the published list would have silently matched nothing",
449
+ });
450
+ return {};
451
+ }
452
+ return { value: names };
453
+ }
454
+ /** [2283] stage 半场:六闸的赋值清单(纯计算,零变异)。commit 段照单赋值,通知载荷 post-commit 发。
455
+ * A1 的名单校验是**内容判定**(描述 eff 的真伪,与是否 commit 无关)⇒ 与 commandPolicy 同,stage 期发 warn。 */
456
+ function stageRuntimeGates(rt, logger) {
427
457
  if (!rt)
428
458
  return { assignments: [] };
429
459
  const assignments = [];
@@ -431,6 +461,14 @@ function stageRuntimeGates(rt) {
431
461
  for (const key of RUNTIME_GATE_KEYS) {
432
462
  if (!runtimeGatePresent(rt, key))
433
463
  continue; // undefined / sentinel → keep env
464
+ if (key === "approvalRequire") {
465
+ const staged = stageApprovalRequire(rt[key], logger);
466
+ if (staged.value === undefined)
467
+ continue; // 判据未过 ⇒ 只拒该域,保留前值(其余闸不受牵连)
468
+ assignments.push([key, staged.value]);
469
+ applied[key] = staged.value;
470
+ continue;
471
+ }
434
472
  assignments.push([key, rt[key]]); // present (incl. explicit 0/[]) → override
435
473
  applied[key] = rt[key];
436
474
  }
@@ -439,7 +477,7 @@ function stageRuntimeGates(rt) {
439
477
  export function applyRuntimeGates(config, rt, logger) {
440
478
  // 独立调用面的兼容壳(测试/外部):stage → 就地赋值 → 通知。applyEffective 不走这里——它把
441
479
  // assignments 并进自己的 commit 段以保住整世代原子性([2283]②)。
442
- const s = stageRuntimeGates(rt);
480
+ const s = stageRuntimeGates(rt, logger);
443
481
  for (const [k, v] of s.assignments)
444
482
  config[k] = v;
445
483
  if (s.info)
@@ -53,7 +53,7 @@ export interface ConfigProvider {
53
53
  /** The config-provider knobs read off ServiceConfig (a structural subset — avoids importing the whole
54
54
  * ServiceConfig type and keeps this module testable with a plain object). */
55
55
  export interface ConfigProviderInput {
56
- /** Set when SEMA_REGISTRY_URL (legacy CONFIG_CENTER_URL) is configured (config.ts `configCenter`). `dryRun` is carried through so a
56
+ /** Set when SEMA_REGISTRY_URL (legacy CONFIG_CENTER_URL now boot-rejects, no dual-read) is configured (config.ts `configCenter`). `dryRun` is carried through so a
57
57
  * remote↔local swap via this seam does NOT drop the dry-run posture main.ts branches on (config.ts
58
58
  * `configCenter.dryRun`); the provider itself does not consume it (the caller still branches on it). */
59
59
  configCenter?: {
@@ -849,9 +849,9 @@ export interface ServiceConfigFlat {
849
849
  /** Central sema registry (sema-registry). When set, the service pulls the effective config
850
850
  * on boot (and refreshes) and applies it OVER the env defaults — env is the fallback. Logical config
851
851
  * only (model roster/roles/teams); secrets/gateways stay in this service's env.
852
- * `dryRun` (SEMA_REGISTRY_DRY_RUN=true, legacy CONFIG_CENTER_DRY_RUN): pull + LOG what the registry would change vs the env-derived
852
+ * `dryRun` (SEMA_REGISTRY_DRY_RUN=true; legacy CONFIG_CENTER_DRY_RUN now boot-rejects, no dual-read): pull + LOG what the registry would change vs the env-derived
853
853
  * config, but do NOT apply it — a safe-rollout verification step before flipping to live.
854
- * `worker` (SEMA_REGISTRY_WORKER=<name>, legacy CONFIG_CENTER_WORKER; injected per-worker by the reconciler): scopes the pull to
854
+ * `worker` (SEMA_REGISTRY_WORKER=<name>; legacy CONFIG_CENTER_WORKER now boot-rejects, no dual-read; injected per-worker by the reconciler): scopes the pull to
855
855
  * that worker's roster via `/effective?worker=<name>`. Unset → the global roster (backward compatible). */
856
856
  configCenter?: {
857
857
  baseUrl: string;
@@ -860,7 +860,7 @@ export interface ServiceConfigFlat {
860
860
  worker?: string;
861
861
  };
862
862
  /** Dual-mode config SOURCE (DUAL-MODE-DESIGN §4): "remote" (sema-registry HTTP) | "local" (.env + config.d
863
- * via @sema-agent/registry-core FileConfigStore) | unset → remote if SEMA_REGISTRY_URL (legacy CONFIG_CENTER_URL) is set, else pure-env.
863
+ * via @sema-agent/registry-core FileConfigStore) | unset → remote if SEMA_REGISTRY_URL (legacy CONFIG_CENTER_URL now boot-rejects, no dual-read) is set, else pure-env.
864
864
  * Env: CONFIG_PROVIDER. The same resolver/EffectiveConfig either way (can't drift). */
865
865
  configProvider?: string;
866
866
  /** Root dir holding `config.d/<domain>.json` for the local config source. Env: CONFIG_LOCAL_DIR. */
package/dist/config.d.ts CHANGED
@@ -116,6 +116,34 @@ export declare const hostExecSpoolEnabled: () => boolean;
116
116
  * is REMOVED (a spread-inherited value from a bigger-window base model would mis-trigger a small window).
117
117
  * Shared by the env lane (main + cheap models) and the center lane (sema-registry toModel). */
118
118
  export declare function applyAutoCompactWindow(m: Model, explicit?: number): void;
119
+ /**
120
+ * 一条工具名**永不匹配任何现役工具**的三种拼法,及其修正指引。审批/权限名单是 server 侧 ToolPolicy 的
121
+ * RAW 逐字比对(canonicalToolName 折叠面已随 core 5.0.0 RB-476 退役),所以写错的名字不会报错、只会静默
122
+ * 不生效——对 deny/ask 名单而言这是**失守方向**(运维/客户端以为加了门,实际什么都没加)。
123
+ *
124
+ * 判据在此**单点成文**,三条腿共用(它们此前各自为政,拼法覆盖面不一致才是真缺口):
125
+ * ① env 腿(`APPROVAL_REQUIRE`/`DENY`/`NEVER_AUTO`,下方)—— boot 拒启;
126
+ * ② 中心下发腿(`runtime.approvalRequire`,config-center/apply-effective.ts)—— 域级拒绝+保前值+warn
127
+ * (常驻进程不 crash);
128
+ * ③ per-request 腿(`settings.permissions.{ask,deny}`,task-settings.ts)—— 该请求 422。
129
+ *
130
+ * 三种拼法:
131
+ * - 退役名(`bash`/`Task`/`KillShell`…):core `RETIRED_TOOL_NAMES` 静态表,指引=现役名。
132
+ * - pre-prefix 短名(`figma__x`):v4 自动加前缀的折叠面已删,实挂名恒带 `mcp__`,指引=补全前缀。
133
+ * - 不完整 MCP 名(`mcp__`、`mcp__figma`):前缀对但缺段——MCP 实挂名恒是 `mcp__<server>__<tool>` 三段形,
134
+ * 少一段同样永不匹配。旧判据「含 `__` 且 protocolOf 认不出」放过了这一族(前缀恰好认得出),从另一侧
135
+ * 漏出去,失守方向与 `figma__x` 完全相同。
136
+ *
137
+ * 已接受的权衡(与退役表墓碑同一条):部署自有工具真叫 `figma__x` 的极端形会被误拒——boot 期无 roster 可
138
+ * 豁免,且失败响亮、指引明确,好过静默失守。
139
+ */
140
+ export interface UnmatchableToolName {
141
+ name: string;
142
+ guidance: string;
143
+ }
144
+ export declare function findUnmatchableToolNames(names: readonly string[]): UnmatchableToolName[];
145
+ /** {@link findUnmatchableToolNames} 的成句形——三条腿的文案同源(只有前缀/出口不同)。 */
146
+ export declare function formatUnmatchableToolNames(source: string, bad: readonly UnmatchableToolName[]): string;
119
147
  /** 组名 → 该组取景的平铺键(introspection 面:测试用它钉「每个平铺键恰好被一组取景」)。 */
120
148
  export declare const CONFIG_GROUP_KEYS: {
121
149
  readonly [G in keyof ServiceConfigGroups]: readonly (keyof ServiceConfigFlat)[];
package/dist/config.js CHANGED
@@ -60,32 +60,38 @@ function defaultSkillsDir() {
60
60
  return "skills"; // import.meta 不可用的兜底(不应发生在 ESM 构建里)
61
61
  }
62
62
  }
63
- /** [2354] 兼容面全清:退役 env 名墓碑——老名**在场即拒启**带新名指引(boolEnvWithLegacyNegated 3.0.0
64
- * 负名墓碑同形)。静默忽略=部署带着失效配置无感跑;双读=兼容层吸收下游冒烟。都不要。 */
65
63
  const RETIRED_ENVS = [
66
- ["TIDB_HOST", "MYSQL_HOST"], ["TIDB_PORT", "MYSQL_PORT"], ["TIDB_USER", "MYSQL_USER"],
67
- ["TIDB_PASSWORD", "MYSQL_PASSWORD"], ["TIDB_DATABASE", "MYSQL_DATABASE"], ["TIDB_POOL_SIZE", "MYSQL_POOL_SIZE"],
68
- ["CONFIG_CENTER_URL", "SEMA_REGISTRY_URL"], ["CONFIG_CENTER_TOKEN", "SEMA_REGISTRY_TOKEN"],
69
- ["CONFIG_CENTER_DRY_RUN", "SEMA_REGISTRY_DRY_RUN"], ["CONFIG_CENTER_WORKER", "SEMA_REGISTRY_WORKER"],
70
- ["ANTHROPIC_BASEURL", "ANTHROPIC_BASE_URL"],
71
- ["REGISTRY_ADMIN_TOKEN", "SEMA_REGISTRY_ADMIN_TOKEN"], // [2373]B-1:墓碑族唯一漏网的双读(config-center.ts 发射点)
72
- // [2354] OA 场景整体退役(clay 令 2026-08-01):配置本身作废,无替代名。
73
- ["OA_API_BASEURL", "(retired OA scenario removed in 5.0.0)"],
74
- ["OA_SERVICE_TOKEN", "(retired OA scenario removed in 5.0.0)"],
75
- ["OA_ISSUE_TOKEN", "(retired OA scenario removed in 5.0.0)"],
76
- ["OA_ISSUE_BASEURL", "(retired OA scenario removed in 5.0.0)"],
77
- ["OA_ISSUE_OWNER", "(retired OA scenario removed in 5.0.0)"],
78
- ["OA_ISSUE_REPO", "(retired OA scenario removed in 5.0.0)"],
79
- ["OA_ISSUE_LABELS", "(retired OA scenario removed in 5.0.0)"],
80
- ["APPROVAL_POLL_MS", "(retired OA poll-approval lane removed in 5.0.0; durable checkpoint lane is the only gate)"],
64
+ ["TIDB_HOST", "MYSQL_HOST", "renamed"], ["TIDB_PORT", "MYSQL_PORT", "renamed"], ["TIDB_USER", "MYSQL_USER", "renamed"],
65
+ ["TIDB_PASSWORD", "MYSQL_PASSWORD", "renamed"], ["TIDB_DATABASE", "MYSQL_DATABASE", "renamed"], ["TIDB_POOL_SIZE", "MYSQL_POOL_SIZE", "renamed"],
66
+ ["CONFIG_CENTER_URL", "SEMA_REGISTRY_URL", "renamed"], ["CONFIG_CENTER_TOKEN", "SEMA_REGISTRY_TOKEN", "renamed"],
67
+ ["CONFIG_CENTER_DRY_RUN", "SEMA_REGISTRY_DRY_RUN", "renamed"], ["CONFIG_CENTER_WORKER", "SEMA_REGISTRY_WORKER", "renamed"],
68
+ ["ANTHROPIC_BASEURL", "ANTHROPIC_BASE_URL", "renamed"],
69
+ ["REGISTRY_ADMIN_TOKEN", "SEMA_REGISTRY_ADMIN_TOKEN", "renamed"], // [2373]B-1:墓碑族唯一漏网的双读(config-center.ts 发射点)
70
+ // [2354] OA 场景整体退役(clay 令 2026-08-01):配置本身作废,无替代名 ⇒ removed 族。
71
+ ["OA_API_BASEURL", "the OA scenario was removed in 5.0.0", "removed"],
72
+ ["OA_SERVICE_TOKEN", "the OA scenario was removed in 5.0.0", "removed"],
73
+ ["OA_ISSUE_TOKEN", "the OA scenario was removed in 5.0.0", "removed"],
74
+ ["OA_ISSUE_BASEURL", "the OA scenario was removed in 5.0.0", "removed"],
75
+ ["OA_ISSUE_OWNER", "the OA scenario was removed in 5.0.0", "removed"],
76
+ ["OA_ISSUE_REPO", "the OA scenario was removed in 5.0.0", "removed"],
77
+ ["OA_ISSUE_LABELS", "the OA scenario was removed in 5.0.0", "removed"],
78
+ ["APPROVAL_POLL_MS", "the OA poll-approval lane was removed in 5.0.0; the durable checkpoint lane is the only gate", "removed"],
81
79
  ];
82
80
  function rejectRetiredEnvs() {
83
81
  const hit = RETIRED_ENVS.filter(([o]) => process.env[o] !== undefined && process.env[o] !== "");
84
- if (hit.length > 0) {
85
- throw new Error(`retired env name(s) set ([2354] 兼容面全清,5.0.0 起拒启): ` +
86
- hit.map(([o, n]) => `${o} use ${n}`).join("; ") +
87
- ` rename the env key(s); the value semantics are unchanged.`);
82
+ if (hit.length === 0)
83
+ return;
84
+ const renamed = hit.filter(([, , k]) => k === "renamed");
85
+ const removed = hit.filter(([, , k]) => k === "removed");
86
+ const parts = [];
87
+ if (renamed.length > 0) {
88
+ parts.push(`${renamed.map(([o, n]) => `${o} → use ${n}`).join("; ")} — rename the env key(s); the value semantics are unchanged`);
89
+ }
90
+ if (removed.length > 0) {
91
+ parts.push(`${removed.map(([o, n]) => `${o} (${n})`).join("; ")} — remove the env key(s): the capability itself was retired, ` +
92
+ `there is no replacement name and no rename that restores it`);
88
93
  }
94
+ throw new Error(`retired env name(s) set ([2354] 兼容面全清,5.0.0 起拒启): ${parts.join(" | ")}.`);
89
95
  }
90
96
  /** Numeric env with validation: a non-numeric value FAILS at startup instead of silently becoming `NaN`
91
97
  * (which e.g. slips past the `runStaleSec` liveness guard and silently disables the reaper). */
@@ -717,6 +723,11 @@ function parseModelDomain() {
717
723
  ? { model: cur, systemPrompt: CODE_AGENT_PROMPT }
718
724
  : { ...cur, systemPrompt: CODE_AGENT_PROMPT };
719
725
  }
726
+ // A2:词表验证**无条件**执行(此前它挂在下面 `MODEL_DEGRADE_TO ? {...}` 三元的真臂里,TO 缺席时
727
+ // parseDegradeOn 根本不可达 ⇒ 拼错词静默无效),而 USAGE.md 的「拼错词 boot 拒启,不静默忽略」是对
728
+ // MODEL_DEGRADE_ON 本身的无条件承诺:设了就验。TO 缺席 + ON 合法 ⇒ 照旧不装配 degrade(行为不变),
729
+ // 也不报错——ON 只是把那份已在的意图写下来,不是把 degrade 打开。
730
+ const degradeOn = parseDegradeOn(csv("MODEL_DEGRADE_ON"));
720
731
  return {
721
732
  gatewayBaseUrl,
722
733
  gatewayApiKey: process.env.MODEL_API_KEY,
@@ -772,7 +783,8 @@ function parseModelDomain() {
772
783
  reactive: boolEnv("MODEL_DEGRADE_REACTIVE", false),
773
784
  // core 5.1.0([2390]②):词表=DegradeReason 全集;缺席时 core 默认含 server_error(默认门变宽),
774
785
  // 本 env 是运维收窄/放宽的唯一旋钮。拼错词 boot 响亮拒——盲铸静默折无效成员是 TASK_TIMEOUT_SEC 同族病。
775
- downgradeOn: parseDegradeOn(csv("MODEL_DEGRADE_ON")),
786
+ // (验词在上方无条件做掉了,见 A2 注;这里只是取用已验过的结果。)
787
+ downgradeOn: degradeOn,
776
788
  // vision precheck (adversarial-review finding): the degrade TARGET is an external gateway
777
789
  // model NOT in config.models, so resolveSpec's precheck (which only sees the picked model) can't see its
778
790
  // vision capability — a vision-capable main + text-only degrade target would let images reach the text-only
@@ -798,6 +810,31 @@ function parseDegradeOn(words) {
798
810
  }
799
811
  return words;
800
812
  }
813
+ export function findUnmatchableToolNames(names) {
814
+ const out = [];
815
+ for (const name of names) {
816
+ const retired = RETIRED_TOOL_NAMES.get(name);
817
+ if (retired !== undefined) {
818
+ out.push({ name, guidance: retired });
819
+ continue;
820
+ }
821
+ const ns = protocolOf(name);
822
+ if (ns === undefined) {
823
+ if (name.includes("__"))
824
+ out.push({ name, guidance: `pre-prefix MCP short form — the live name is mcp__${name}` });
825
+ continue;
826
+ }
827
+ if (ns.parse(name) === undefined) {
828
+ out.push({ name, guidance: `incomplete ${ns.id} name — the live form is ${ns.prefix}<server>__<tool>` });
829
+ }
830
+ }
831
+ return out;
832
+ }
833
+ /** {@link findUnmatchableToolNames} 的成句形——三条腿的文案同源(只有前缀/出口不同)。 */
834
+ export function formatUnmatchableToolNames(source, bad) {
835
+ return (`${source} names tool(s) that can never match a live tool (core 5.0.0 RB-476 — the alias/auto-prefix folds are gone): ` +
836
+ bad.map((b) => `${b.name} → ${b.guidance}`).join("; "));
837
+ }
801
838
  function parseApprovalDomain(ctx) {
802
839
  const { postureOn } = ctx; // 跨域入参②:posture 三态(single-user turnkey ⇒ HITL 面默认 ON)
803
840
  // design/80 D-G: the direct-connect approval door anchors (all must be present to ACTIVATE; fail-closed).
@@ -805,34 +842,24 @@ function parseApprovalDomain(ctx) {
805
842
  const dgHmacKeys = parseApprovalHmacKeys(process.env.APPROVAL_HMAC_KEYS);
806
843
  // DURABLE_APPROVAL 既是本域字段,也是直连门的硬前置 —— 那条前置的理由与断言在装配层的跨域不变量段。
807
844
  const durableApproval = boolEnv("DURABLE_APPROVAL", false);
808
- // [2380] core 5.0.0 RB-476:approval 名单里的退役工具名**装配期拒启**(带现役名指引)。core 自己的
845
+ // [2380] core 5.0.0 RB-476:approval 名单里「永不匹配」的名字**装配期拒启**(带修正指引)。core 自己的
809
846
  // prepare 门(config.legacy_tool_name)在提交期才响——boot 期先拒,部署带失效名单不启动而不是首任务炸。
810
- // 名单 RAW 直传(canonicalToolName 折叠面已随 core 退役,见 approval.ts)
811
- const rejectRetiredToolNames = (envName, names) => {
812
- const hit = names.filter((n) => RETIRED_TOOL_NAMES.has(n));
813
- if (hit.length > 0) {
814
- throw new Error(`${envName} names retired tool(s) (core 5.0.0 RB-476 — the alias fold is gone, these never match): ` +
815
- hit.map((n) => `${n} → ${RETIRED_TOOL_NAMES.get(n)}`).join("; "));
816
- }
817
- // [2382]① core 5.1.0 判别式:pre-prefix 短名(figma__x 形)静态表拦不住,而这些名单是 server 侧
818
- // ToolPolicy 原样比对——core 的 prepare 硬臂(G-3)只管 core 侧 rules,管不到这里;放过=deny 静默
819
- // 永不匹配(失守方向)。名含 `__` 且协议前缀不认识 ⇒ boot 拒。部署自有工具真叫 `figma__x` 的极端形
820
- // 会被误拒(boot 无 roster 可豁免)——与上面退役表墓碑同一个已接受权衡,失败响亮且指引明确。
821
- const prePrefix = names.filter((n) => n.includes("__") && protocolOf(n) === undefined);
822
- if (prePrefix.length > 0) {
823
- throw new Error(`${envName} names look like pre-prefix MCP short forms (the v4 auto-prefix fold is gone — these never match a live tool): ` +
824
- prePrefix.map((n) => `${n} → mcp__${n}`).join("; "));
825
- }
847
+ // 名单 RAW 直传(canonicalToolName 折叠面已随 core 退役,见 approval.ts);判据单点见
848
+ // {@link findUnmatchableToolNames}(env / 中心下发 / per-request 三腿共用同一条)。
849
+ const rejectUnmatchableToolNames = (envName, names) => {
850
+ const bad = findUnmatchableToolNames(names);
851
+ if (bad.length > 0)
852
+ throw new Error(formatUnmatchableToolNames(envName, bad));
826
853
  return names;
827
854
  };
828
855
  return {
829
- approvalRequire: rejectRetiredToolNames("APPROVAL_REQUIRE", csv("APPROVAL_REQUIRE")),
830
- approvalDeny: rejectRetiredToolNames("APPROVAL_DENY", csv("APPROVAL_DENY")),
856
+ approvalRequire: rejectUnmatchableToolNames("APPROVAL_REQUIRE", csv("APPROVAL_REQUIRE")),
857
+ approvalDeny: rejectUnmatchableToolNames("APPROVAL_DENY", csv("APPROVAL_DENY")),
831
858
  durableApproval,
832
859
  resourceSuspend: boolEnv("RESOURCE_SUSPEND", false),
833
860
  resourceSuspendTtlSec: Math.max(0, numEnv("RESOURCE_SUSPEND_TTL_SEC", "0")), // 0 ⇒ core default (30d)
834
861
  approvalAutoBudget: Math.min(10000, Math.max(0, numEnv("APPROVAL_AUTO_BUDGET", "0"))), // clamp [0,10000]; 0 = off
835
- approvalNeverAuto: rejectRetiredToolNames("APPROVAL_NEVER_AUTO", csv("APPROVAL_NEVER_AUTO")),
862
+ approvalNeverAuto: rejectUnmatchableToolNames("APPROVAL_NEVER_AUTO", csv("APPROVAL_NEVER_AUTO")),
836
863
  approvalHmacKeys: dgHmacKeys, // D-G: WIRED (see the field's JSDoc); empty ⇒ inactive, not unimplemented
837
864
  directApprovalDoor,
838
865
  // ③ sensitive-path write deny set: unset = core's recommended set; explicit value = full replacement
@@ -54,7 +54,7 @@ export interface FleetTaskRow {
54
54
  status: FleetTaskStatus;
55
55
  /** core 1.367 δ([1514]/[1515]④):launch 时刻 epoch ms(revive 行=本 revive 周期 wake 时刻)——
56
56
  * cli 的 elapsed 锚直出(比 server 端 elapsedMs 快照更稳,消费方自算不漂)。ADDITIVE / tolerate-absent
57
- * (a* 车道 spawn 即带;旧 core 帧无 startedAt 时回落 server 收帧时刻)。 */
57
+ * (a* 车道 spawn 即带;诚实缺席——回落 server 收帧时刻的臂已随 [2354] 删,floor ^4.0.0 下 startedAt 恒在)。 */
58
58
  startedAt?: number;
59
59
  elapsedMs?: number;
60
60
  tokens?: number;
@@ -62,7 +62,9 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
62
62
  // a new-generation shell can drop its frame-level own/foreign checks entirely (incl. the cross-shell
63
63
  // restart edge) while keeping them against older servers whose meta lacks the field. Additive (version
64
64
  // stays 1): older shells ignore unknown fields.
65
- res.write(`event: meta\ndata: ${JSON.stringify({ version: 1, scoped: callerScope !== null, sessionScoped: callerSession !== null, bgNotifyFailClosed: true })}\n\n`);
65
+ // `type: "meta"` `event: meta` 同名双发(tasks/trace-usage/approvals 三流同款):部分代理只转发
66
+ // `data:` 行,按 data.type 分派的消费端否则认不出首帧。additive,老消费端忽略未知键。
67
+ res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", version: 1, scoped: callerScope !== null, sessionScoped: callerSession !== null, bgNotifyFailClosed: true })}\n\n`);
66
68
  const seenTasks = new Set();
67
69
  const seenWf = new Set();
68
70
  // #3 fleet per-session scope (clay 2026-06-30): a row is visible iff it matches the caller's principal scope AND
@@ -965,29 +965,19 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
965
965
  sendError(res, 501, "capability.run_store_required", "subagent streams require the run store");
966
966
  return;
967
967
  }
968
- const principal = gatedPrincipal(req, deps.config);
969
- if (deps.config.requirePrincipal && principal === undefined) {
970
- sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
971
- return;
972
- }
973
968
  const runId = safeDecode(subStreamMatch[1]);
974
969
  const target = safeDecode(subStreamMatch[2]);
975
970
  if (runId === null || target === null) {
976
971
  sendError(res, 400, "request.path_malformed", "malformed subagent path (invalid percent-encoding)");
977
972
  return;
978
973
  }
979
- const trusted = explicitOperatorOk(principal, deps.config.operatorPrincipals);
980
- const run = await deps.runStore.getRun(runId);
981
- if (!run || (!trusted && run.owner !== null && run.owner !== principal)) {
982
- sendError(res, 404, "not_found.run", "run not found");
983
- return;
984
- }
985
- // [1493] session 门(output 面同注,出生即强制——新面零消费者无软拍需要)。
986
- const callerSession = new URL(req.url ?? "", "http://x").searchParams.get("session");
987
- if (!trusted && run.sessionId && callerSession !== run.sessionId) {
988
- sendError(res, 404, "not_found.run", "run not found");
974
+ // [2373]C-5 共享门(principal→owner→session)——本腿此前逐字手写同三层([1493] 语义),与 output/stop
975
+ // 两腿并列;收编到同一函数后一次修复处处生效。判定顺序以共享门为准(与已收编的两腿现行为一致:
976
+ // 路径解码 400 先于 principal 401,门内三级次序不变)
977
+ const gatedTail = await subagentReadGate(req, res, deps, runId);
978
+ if (!gatedTail)
989
979
  return;
990
- }
980
+ const { run } = gatedTail;
991
981
  // 🔴 cli[1524] HIGH-2:**subscribe 先行,probe 在后**——原序(probe await→meta→subscribe)在
992
982
  // probe 与 subscribe 之间留竞态窗:窗内子代 settle → task_settled 发布时无订阅者被丢(总线无
993
983
  // replay/sticky)→ 订阅者只收 heartbeat 永挂。先建订阅,窗内 settle 自然落进队列;probe 判 404
@@ -1010,7 +1000,9 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1010
1000
  // **全域投影**——进程内 live 臂投当前复活周期号 [[1533] ask 兑现,[1548]①],durable 终态 serve
1011
1001
  // 同键同义;老 core 只在 durable 臂在场,缺省容忍)。1=spawn 周期,≥2=复活周期(retained revive
1012
1002
  // 或 tier-3 均 bump——codex 快审 #11 纠,勿窄读成仅 tier-3)。
1013
- res.write(`event: meta\ndata: ${JSON.stringify({ version: 1, taskId: runId, target, status: probeDetails.status ?? "running", ...(typeof probeDetails.seq === "number" ? { seq: probeDetails.seq } : {}), live: "replica-local", replayFace: "GET /v1/runs/:id/subagents/:handle/output" })}\n\n`);
1003
+ // `type: "meta"` `event: meta` 同名双发(tasks/trace-usage/approvals 三流同款):部分代理只转发
1004
+ // `data:` 行,按 data.type 分派的消费端否则认不出首帧。additive,老消费端忽略未知键。
1005
+ res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", version: 1, taskId: runId, target, status: probeDetails.status ?? "running", ...(typeof probeDetails.seq === "number" ? { seq: probeDetails.seq } : {}), live: "replica-local", replayFace: "GET /v1/runs/:id/subagents/:handle/output" })}\n\n`);
1014
1006
  // core 1.382([1561] 提货单③):"parked" 非活非终态(durably suspended pending approval)——跟
1015
1007
  // running/pending 同归"还没完",tail 该继续等而不是收尾指去 replay 面(那是给真终态用的)。子代获批
1016
1008
  // 恢复后会在**同一条**活流上继续产帧,提前收尾会让客户端误判"已经结束",错过恢复后的后续内容。
@@ -451,7 +451,9 @@ function summarizeWorkflowDetail(run) {
451
451
  * stream (same shape as not-found), so the caller passes the SAME scope it owner-gated with. */
452
452
  async function streamWorkflowRun(req, res, runId, scope) {
453
453
  sseHeaders(res);
454
- res.write(`event: meta\ndata: ${JSON.stringify({ version: 1, runId })}\n\n`);
454
+ // `type: "meta"` `event: meta` 同名双发(tasks/trace-usage/approvals 三流同款):部分代理只转发
455
+ // `data:` 行,按 data.type 分派的消费端否则认不出首帧。additive,老消费端忽略未知键。
456
+ res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", version: 1, runId })}\n\n`);
455
457
  let closed = false;
456
458
  // subscribeWorkflow is a PUSH iterator: a parked `next()` on an idle-but-alive workflow does NOT observe a
457
459
  // `closed` flag, so a disconnected client would leave the subscriber queue registered in core's channels and
@@ -1,7 +1,7 @@
1
1
  import http from "node:http";
2
2
  import { once } from "node:events";
3
3
  import { createHash } from "node:crypto";
4
- import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, HAND_TOOL_EFFECTS, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow } from "@sema-agent/core"; // canonicalToolName = core single-source (1.162; replaced the transitional service mirror)
4
+ import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, HAND_TOOL_EFFECTS, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow } from "@sema-agent/core";
5
5
  import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
6
6
  import { matchCatalogModel } from "../model-select.js";
7
7
  import {} from "../config-center/facade.js";
package/dist/main.js CHANGED
@@ -14,7 +14,7 @@ import { ensureChildSessionDurableWithPromotion } from "./plugins/session-store.
14
14
  import { ForkRoutingSessionStore } from "./plugins/fork-routing-session-store.js";
15
15
  import { webSearchConfigFromEnv, createWebSearchBackend, setWebSearchBadPayloadObserver, shouldProbeWebSearchOnBoot } from "./plugins/web-search.js";
16
16
  import { createAuthorizer } from "./security.js";
17
- import { createDurableAskPolicy, hasOperatorGateIntent } from "./approval.js";
17
+ import { assertGateIntentServiceable, createDurableAskPolicy, hasOperatorGateIntent } from "./approval.js";
18
18
  import { loadSkills } from "./capabilities/skills.js";
19
19
  import { GiteaClient } from "./capabilities/repo-tools.js";
20
20
  import { buildScenarios, builtinScenarioDetails } from "./capabilities/scenarios.js";
@@ -398,6 +398,9 @@ async function main() {
398
398
  // still surfaces it. So the baseline needs single-user turnkey ∧ zero expressed gate intent.
399
399
  // 单源在 approval.ts(与 inv#2 同文件)—— 这里原是内联的三格枚举,漏了 approvalNeverAuto,详见该函数顶注。
400
400
  const operatorGateIntent = hasOperatorGateIntent(config);
401
+ // D3-F1(全窗复审,HIGH):门意图已表达但唯一门形(durable)不可服务 ⇒ boot 拒启带双出路指引。
402
+ // 5.0.0 前这形走轮询门真拦;退役后若只留 core 每任务 warn=原先拦、现在放,升级存量静默失守。
403
+ assertGateIntentServiceable(config, checkpointStore !== undefined);
401
404
  const singleUserAutoAcceptBaseline = config.requirePrincipal !== true && !operatorGateIntent;
402
405
  // design/45 durable-checkpoint (core 1.67): opt-in (DURABLE_APPROVAL + a pool). When on, F4 uses durable
403
406
  // suspend/resume instead of the poll gate — a gated `ask` suspends the task (status:"suspended" + checkpoint)
@@ -59,8 +59,7 @@ export declare function memoryEngineBackendFor(config: ServiceConfig, fallbackRo
59
59
  * engine-backend presence guard.
60
60
  */
61
61
  export declare function memorySpecForRequest(scope: string | undefined, memoryWrite: boolean | undefined, defaultScopes?: string[]): {
62
- scope: string;
63
- scopes?: string[];
62
+ scopes: string[];
64
63
  writeScope?: string | null;
65
64
  scopeContract?: "v2";
66
65
  } | undefined;
@@ -113,9 +113,13 @@ export function memorySpecForRequest(scope, memoryWrite, defaultScopes) {
113
113
  // 最后一层,所以带种子时必须 EXPLICIT 钉 writeScope=派生 scope(否则 harvest 会写进登记簿的最后一个默认
114
114
  // scope — 一个共享层,错)。无种子 = 形状逐字节等价旧版(additive 锁)。
115
115
  const extras = (defaultScopes ?? []).filter((s, i, arr) => typeof s === "string" && s.length > 0 && s !== scope && arr.indexOf(s) === i);
116
- const layered = extras.length > 0 ? { scopes: [scope, ...extras], writeScope: scope } : {};
116
+ // core 5.1.0([2390]②)删单数 `scope` 键:normalizeMemorySpec 只迭代 `scopes`,单数形 undefined
117
+ // memory 静默整体关闭(全窗复审 D7-F1,HIGH——返回值位不触发多余属性检查,旧形编译全绿)。生产者恒产
118
+ // `scopes: [scope, ...extras]`;writeScope 只在带种子时显式钉(core 默认取 scopes 最后一层,无种子时
119
+ // 最后一层就是派生 scope=正确,不钉保持最小形)。
120
+ const layered = extras.length > 0 ? { scopes: [scope, ...extras], writeScope: scope } : { scopes: [scope] };
117
121
  const v2 = isV2(scope) || extras.some(isV2) ? { scopeContract: "v2" } : {};
118
122
  // memoryWrite === false(MF-30 pause)wins over the layered writeScope pin — 只读语义在有种子时同样成立。
119
- return memoryWrite === false ? { scope, ...layered, writeScope: null, ...v2 } : { scope, ...layered, ...v2 };
123
+ return memoryWrite === false ? { ...layered, writeScope: null, ...v2 } : { ...layered, ...v2 };
120
124
  }
121
125
  //# sourceMappingURL=memory-scope.js.map
@@ -165,7 +165,7 @@ export declare class FileRunStore {
165
165
  * session forever" incident). Semantics verbatim from the SQL twin: suspended ∧ updatedAt < cutoff ∧ NOT
166
166
  * probe.hasPending(session) → failed 'approval expired before decision' / errorCode 'approval.expired' +
167
167
  * release the task_active claim. The NOT-pending clause is the design/80 D-D adversarial-blocker guard
168
- * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 已是 re-export 壳): a row whose checkpoint is STILL pending belongs to the checkpoint-state sweeps —
168
+ * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 该壳文件已删(5.0.0)): a row whose checkpoint is STILL pending belongs to the checkpoint-state sweeps —
169
169
  * time-reaping it would orphan a model leg on a now-unlocked session. Probe absent ⇒ honest NO-OP.
170
170
  * MemoryRunStore is the parity oracle (byte-identical predicate; only the persist differs).
171
171
  */
@@ -538,7 +538,7 @@ export class FileRunStore {
538
538
  * session forever" incident). Semantics verbatim from the SQL twin: suspended ∧ updatedAt < cutoff ∧ NOT
539
539
  * probe.hasPending(session) → failed 'approval expired before decision' / errorCode 'approval.expired' +
540
540
  * release the task_active claim. The NOT-pending clause is the design/80 D-D adversarial-blocker guard
541
- * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 已是 re-export 壳): a row whose checkpoint is STILL pending belongs to the checkpoint-state sweeps —
541
+ * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 该壳文件已删(5.0.0)): a row whose checkpoint is STILL pending belongs to the checkpoint-state sweeps —
542
542
  * time-reaping it would orphan a model leg on a now-unlocked session. Probe absent ⇒ honest NO-OP.
543
543
  * MemoryRunStore is the parity oracle (byte-identical predicate; only the persist differs).
544
544
  */
@@ -129,7 +129,7 @@ export declare class MemoryRunStore {
129
129
  * locks the session forever" incident). Semantics verbatim from the SQL twin: suspended ∧ updatedAt < cutoff
130
130
  * ∧ NOT probe.hasPending(session) → failed 'approval expired before decision' / error_code 'approval.expired'
131
131
  * + release the task_active claim. The NOT-pending clause is the design/80 D-D adversarial-blocker guard
132
- * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 已是 re-export 壳): a row whose checkpoint is STILL pending belongs to the checkpoint-state
132
+ * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 该壳文件已删(5.0.0)): a row whose checkpoint is STILL pending belongs to the checkpoint-state
133
133
  * sweeps (SLA deny-sweep / failSuspendedWithExpiredCheckpoint) — time-reaping it here would orphan a model
134
134
  * leg on a now-unlocked session. Probe absent ⇒ honest NO-OP (never kill a park without the guard).
135
135
  */
@@ -329,7 +329,7 @@ export class MemoryRunStore {
329
329
  * locks the session forever" incident). Semantics verbatim from the SQL twin: suspended ∧ updatedAt < cutoff
330
330
  * ∧ NOT probe.hasPending(session) → failed 'approval expired before decision' / error_code 'approval.expired'
331
331
  * + release the task_active claim. The NOT-pending clause is the design/80 D-D adversarial-blocker guard
332
- * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 已是 re-export 壳): a row whose checkpoint is STILL pending belongs to the checkpoint-state
332
+ * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 该壳文件已删(5.0.0)): a row whose checkpoint is STILL pending belongs to the checkpoint-state
333
333
  * sweeps (SLA deny-sweep / failSuspendedWithExpiredCheckpoint) — time-reaping it here would orphan a model
334
334
  * leg on a now-unlocked session. Probe absent ⇒ honest NO-OP (never kill a park without the guard).
335
335
  */
@@ -36,8 +36,8 @@ export function tidbPoolOptions(tidb) {
36
36
  }
37
37
  export function createTidbPool(config) {
38
38
  if (!config.tidb) {
39
- throw new Error("TiDB config missing. Set TIDB_HOST / TIDB_USER / TIDB_DATABASE (and TIDB_PASSWORD/PORT) " +
40
- "when SESSION_BACKEND=tidb.");
39
+ throw new Error("TiDB config missing. Set MYSQL_HOST / MYSQL_USER / MYSQL_DATABASE (and MYSQL_PASSWORD/PORT) " +
40
+ "when SESSION_BACKEND=mysql.");
41
41
  }
42
42
  return mysql.createPool(tidbPoolOptions(config.tidb));
43
43
  }
package/dist/run-local.js CHANGED
@@ -174,12 +174,14 @@ export async function runLocal(argv, deps = {}) {
174
174
  process.env.CONFIG_PROVIDER = "local";
175
175
  // FORCE the durable backends OFF: a one-shot, single-machine CLI has nothing durable to persist
176
176
  // and deliberately does NOT assemble the server-only StoreBackend. A shared .env that carries
177
- // SESSION_BACKEND=tidb would otherwise make loadConfig() (needsTidb TIDB_HOST) or createSessionStore()
178
- // throw a cryptic error before the run even starts. Unlike CONFIG_PROVIDER/REMOTE_EXEC (where an explicit
179
- // operator choice can be HONORED), a DB session store cannot work here at all, so we FORCE — never honor —
180
- // this knob to in-memory so the CLI just works regardless of the shared config. Long-term memory needs no
181
- // forcing: the design/138 memory ENGINE is file-based and default-ON here (single-user; MEMORY_ENGINE=off
182
- // and MEMORY_ENGINE_DIR pass through loadConfig as-is).
177
+ // SESSION_BACKEND set to anything but the enum's "memory"/"mysql"/"auto" would otherwise boot-reject
178
+ // outright (SESSION_BACKEND is an enum env, validated at load an old value like the retired public
179
+ // name "tidb" is rejected, not silently coerced), and even a legit "mysql" value would pull in a
180
+ // server-only SQL session store this one-shot CLI has no business assembling. Unlike CONFIG_PROVIDER/
181
+ // REMOTE_EXEC (where an explicit operator choice can be HONORED), a DB session store cannot work here
182
+ // at all, so we FORCE — never honor — this knob to in-memory so the CLI just works regardless of the
183
+ // shared config. Long-term memory needs no forcing: the design/138 memory ENGINE is file-based and
184
+ // default-ON here (single-user; MEMORY_ENGINE=off and MEMORY_ENGINE_DIR pass through loadConfig as-is).
183
185
  process.env.SESSION_BACKEND = "memory";
184
186
  // 🔴 REQUIRE_PRINCIPAL is the same shared-config footgun class: run-local IS the single
185
187
  // user at their own terminal — there is no untrusted caller to gate — but a server .env carrying
@@ -189,33 +189,6 @@ export interface FsWriteGateWiring {
189
189
  * byte-for-byte;这是个 tighten-only opt-in,不是默认开启——运营方需要显式选择开启)。 */
190
190
  shellGate?: "always" | "classify";
191
191
  }
192
- /**
193
- * Build the per-request permission ToolPolicy from the parsed settings (mirrors shell `seam.ts` — see header).
194
- * - base = name-keyed allow/deny (`createAllowDenyPolicy`): an `allow` list narrows to those tools; a `deny` list
195
- * hard-blocks — composed deny-wins with an ASK-list ({@link createAskListPolicy}) so `permissions.ask` actually
196
- * forces an approval gate (NOT silently dropped — review finding). `deny` > `ask` > `allow` on overlap.
197
- * - `defaultMode` derive onto that base ([820]/[822] five-mode table; the fs-write gate needs `gate` wiring —
198
- * without it every mode degrades to the base rules only, the pre-[816] behavior):
199
- * `default` → base + `createFsWriteGatePolicy({rootPath: cwd, exemptDirs:[scratchpad], ask})` — CC
200
- * manual: every fs write asks (the [816] P0 fix), scratchpad writes ride free.
201
- * `acceptEdits` → base + the same factory with `acceptDirs:[cwd]` — writes INSIDE the cwd subtree
202
- * auto-allow, OUTSIDE still ask (CC 207 dirname-variant card). NOT a loosen vs the
203
- * deployment: the factory only ever emits allow/ask and is folded deny-wins, so a
204
- * baseline deny/ask still wins; vs `default` it is simply a narrower CLIENT gate, and
205
- * both are client-side ADDITIONS the client may size itself.
206
- * `plan` → handsReadOnly + present_plan (unchanged; the write tools never reach an ask).
207
- * `bypassPermissions` → base only, NO factory ([820] table: bypass = "不加门" not "开门" — deployment
208
- * approval/governance baselines still apply; this is exactly every mode's pre-[816]
209
- * derive, so bypass grants nothing that wasn't already granted).
210
- * `auto` → base + the `default` factory (ask 兜底) — core's auto-mode classifier (entitlement-
211
- * gated: RunnerDeps.autoMode ∧ runtimeCaps.autoMode, 1.276/1.277) screens asks UPSTREAM
212
- * of resolveAsk, allowing safe calls and leaving suspicious ones on the ask path ([822]②).
213
- * absent (undefined) → base only, NO factory: an expressed-no-mode request keeps its pre-[816] behavior
214
- * (attaching the ask gate on a mere allow/deny-list stamp would regress headless callers
215
- * that never asked for a mode; the shell always sends its mode explicitly).
216
- * Returns the composed ToolPolicy + whether `handsReadOnly` should tighten. Returns `undefined` policy when there
217
- * is no permission rule and no mode-derived gate (nothing to project).
218
- */
219
192
  export declare function deriveSettingsPolicy(settings: ParsedTaskSettings, gate?: FsWriteGateWiring, workflowGate?: WorkflowGateWiring): {
220
193
  toolPolicy?: ToolPolicy;
221
194
  handsReadOnly?: boolean;
@@ -36,6 +36,7 @@
36
36
  * main.ts 单用户闸),仅 malformed 才报 deferred;旧「需要 remote hook-runner」是接线前拍照。)
37
37
  */
38
38
  import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, createSensitivePathPolicy, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, } from "@sema-agent/core";
39
+ import { findUnmatchableToolNames, formatUnmatchableToolNames } from "./config.js";
39
40
  import { parseHooksConfig } from "./hooks/hook-runner.js";
40
41
  /**
41
42
  * R4: coerce a raw top-level `body.permissionMode` (the LIGHT per-turn wire — the shell sends the
@@ -247,10 +248,13 @@ export function acceptAppendSystemPrompt(v, warn, packDropsAppend) {
247
248
  * CC-parity `permissions.ask` list — "prompt the human before THIS tool" — needs this thin policy. Composed
248
249
  * deny-wins with the allow/deny base, so `ask` only ever tightens (a baseline `deny` still wins; `allow`→`ask`). */
249
250
  function createAskListPolicy(askNames) {
250
- // Canonical-space matching, mirroring core's `createAllowDenyPolicy` (1.202): a rule written against a legacy
251
- // alias ("MultiEdit"/"Task") keeps asking after a CC-parity rename, and a durable-resume replay carrying a
252
- // pre-rename `req.toolName` can't slip a rule written in the new names (ask only ever tightens — widening-safe).
253
- const askSet = new Set(askNames); // RAW(5.0.0):折叠面退役
251
+ // RAW matching (core 5.0.0 RB-476 — the canonicalToolName fold is GONE): the rule name is compared to
252
+ // `req.toolName` verbatim, no alias folding in either direction. A rule written against a retired alias
253
+ // ("MultiEdit"/"Task") therefore matches NOTHING which is why such a list is rejected up front
254
+ // ({@link rejectUnmatchableSettingsNames}) instead of being silently mounted as a no-op gate.
255
+ // (旧注写的 "Canonical-space matching, mirroring core's createAllowDenyPolicy (1.202)" 是折叠面还在时的
256
+ // 拍照,与下面这行 RAW 代码整段矛盾——同批改正,连同 :348 的 "canonical toolName key space"。)
257
+ const askSet = new Set(askNames);
254
258
  return {
255
259
  check(req) {
256
260
  return askSet.has(req.toolName)
@@ -283,7 +287,8 @@ function createWorkflowAskPolicy(isExempt) {
283
287
  // approvalExemptionStore) is probed at the POLICY layer here — the durable lane parks every policy `ask`
284
288
  // and its own exempt probe only covers the deployment policy's asks, so without this probe a remembered
285
289
  // Workflow approval would re-park on every later call. Same posture as the fs-write gate's isExempt
286
- // (canonical toolName key space, one grant serves all layers); a THROWN probe ⇒ not exempt (fail-closed).
290
+ // (RAW toolName key space 5.0.0 折叠面退役后各层比对的都是 `req.toolName` 逐字,一个 grant 服务所有层
291
+ // 靠的是「同一个 raw 名」而不是「同一个 canonical 空间」);a THROWN probe ⇒ not exempt (fail-closed).
287
292
  if (isExempt) {
288
293
  try {
289
294
  if (await isExempt(req.toolName))
@@ -341,11 +346,37 @@ function createWorkflowPlanDenyPolicy() {
341
346
  * Returns the composed ToolPolicy + whether `handsReadOnly` should tighten. Returns `undefined` policy when there
342
347
  * is no permission rule and no mode-derived gate (nothing to project).
343
348
  */
349
+ /**
350
+ * A3 — the per-request leg of the ONE "this name can never match a live tool" judgment (single source:
351
+ * config.ts {@link findUnmatchableToolNames}; the other two legs are the `APPROVAL_*` env vars, which reject at
352
+ * boot, and the center-published `runtime.approvalRequire`, which rejects that domain and keeps the prior value).
353
+ *
354
+ * WHY this leg exists: `ask`/`deny` are mounted as RAW name comparisons (`createAskListPolicy` /
355
+ * core's `createAllowDenyPolicy`, the 5.0.0 fold removal), so a retired name ("Task"/"bash"/"KillShell") or a
356
+ * pre-prefix/incomplete MCP name ("figma__x"/"mcp__figma") mounts a gate that matches NOTHING — the caller
357
+ * believes it added an approval gate or a hard block and got neither. That is the FAIL-OPEN direction, and it was
358
+ * silent on this leg. Throwing here surfaces as a 422 at the HTTP boundary (resolve-spec already wraps every
359
+ * throw out of `applyTaskSettings`), i.e. the same fail-loud posture the other two legs have.
360
+ *
361
+ * `allow` is deliberately NOT judged: it is a WHITELIST — a name that matches nothing leaves the allowlist
362
+ * effectively empty, so every tool falls to "not in the allowlist" and is DENIED. That fails closed and is
363
+ * immediately visible to the caller, which is a different (and acceptable) failure mode from a silent hole.
364
+ */
365
+ function rejectUnmatchableSettingsNames(perms) {
366
+ for (const [field, names] of [["permissions.ask", perms.ask], ["permissions.deny", perms.deny]]) {
367
+ if (!names)
368
+ continue;
369
+ const bad = findUnmatchableToolNames(names);
370
+ if (bad.length > 0)
371
+ throw new Error(formatUnmatchableToolNames(field, bad));
372
+ }
373
+ }
344
374
  export function deriveSettingsPolicy(settings, gate, workflowGate) {
345
375
  const perms = settings.permissions;
346
376
  if (!perms)
347
377
  return {};
348
378
  const { allow, deny, ask, defaultMode } = perms;
379
+ rejectUnmatchableSettingsNames(perms);
349
380
  // The base name-keyed gate: allow/deny via core's helper + an ask-list (FINDING-1: `ask` must enforce, not drop).
350
381
  // Each is composed deny-wins (`deny` > `ask` > `allow`), so the fold can only ever tighten.
351
382
  const policies = [];
@@ -304,9 +304,12 @@ export function steeringInjectedEventData(ev) {
304
304
  * outcome/trigger=闭枚举 verbatim;reason=引擎说明文本(可能引用配置值)→ redactSecrets;identity 同款。 */
305
305
  export function compactionOutcomeEventData(ev) {
306
306
  // 形参照 contextUsageEventData 先例:全可选 unknown + 内部窄化,call site 免宽铸型(棘轮零新增)。
307
+ // 🔴 缺键守卫(同款「不合形即缺席」):无条件 String() 会把缺席/非串的键铸成字面 "undefined"/"null"
308
+ // 落进 durable 行——回放端读到的是一个看起来合法的枚举值,比缺席更难发现。
309
+ const s = (v) => (typeof v === "string" ? v : undefined);
307
310
  return {
308
- outcome: String(ev.outcome),
309
- trigger: String(ev.trigger),
311
+ ...(s(ev.outcome) !== undefined ? { outcome: s(ev.outcome) } : {}),
312
+ ...(s(ev.trigger) !== undefined ? { trigger: s(ev.trigger) } : {}),
310
313
  ...(ev.reason !== undefined ? { reason: redactSecrets(String(ev.reason)) } : {}),
311
314
  ...identityFields(ev),
312
315
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "5.0.0",
3
+ "version": "5.1.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",
@@ -68,7 +68,7 @@
68
68
  "sharp": "^0.35.3"
69
69
  },
70
70
  "devDependencies": {
71
- "@sema-agent/sdk": "^3.0.0",
71
+ "@sema-agent/sdk": "^4.1.0",
72
72
  "@types/libsodium-wrappers": "^0.7.14",
73
73
  "@types/node": "22.10.2",
74
74
  "@types/pg": "^8.20.0",