@sema-agent/server 7.104.0 → 7.105.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 (52) hide show
  1. package/MIGRATION.md +32 -0
  2. package/USAGE.md +59 -10
  3. package/dist/boot/runner-deps.js +2 -1
  4. package/dist/config.d.ts +15 -2
  5. package/dist/config.js +42 -7
  6. package/dist/file-history-disclosure.d.ts +7 -31
  7. package/dist/file-history-disclosure.js +12 -0
  8. package/dist/fleet/fleet-bus.d.ts +5 -1
  9. package/dist/http/admission.d.ts +1 -1
  10. package/dist/http/dispatch.d.ts +18 -0
  11. package/dist/http/dispatch.js +17 -4
  12. package/dist/http/resume-legs.d.ts +1 -1
  13. package/dist/http/route-table.d.ts +15 -4
  14. package/dist/http/route-table.js +6 -5
  15. package/dist/http/routes/fleet.d.ts +3 -2
  16. package/dist/http/routes/images.js +0 -1
  17. package/dist/http/routes/runs.js +2 -8
  18. package/dist/http/routes/session-sync.js +6 -1
  19. package/dist/http/routes/sessions.js +5 -2
  20. package/dist/http/routes/side-query.js +8 -1
  21. package/dist/http/routes/trace-usage.d.ts +2 -0
  22. package/dist/http/routes/trace-usage.js +4 -9
  23. package/dist/http/server.d.ts +9 -4
  24. package/dist/http/server.js +4 -1
  25. package/dist/http/sse-lifecycle.d.ts +1 -1
  26. package/dist/http/sse-log.d.ts +12 -9
  27. package/dist/http/sse-log.js +1 -8
  28. package/dist/observability/fail-open.d.ts +5 -1
  29. package/dist/observability/fail-open.js +5 -1
  30. package/dist/observability/metrics.js +1 -1
  31. package/dist/observability/run-terminal.d.ts +45 -9
  32. package/dist/observability/run-terminal.js +115 -9
  33. package/dist/plugins/image-bake-store-sql.d.ts +0 -2
  34. package/dist/plugins/image-bake-store-sql.js +0 -5
  35. package/dist/plugins/retention-store-sql.js +1 -0
  36. package/dist/plugins/run-store-sql.d.ts +3 -2
  37. package/dist/plugins/schema-indexes.js +1 -0
  38. package/dist/plugins/session-policy-store-sql.js +18 -5
  39. package/dist/plugins/store-backend.d.ts +6 -4
  40. package/dist/plugins/tidb-pool.js +4 -3
  41. package/dist/read-face-posture.d.ts +21 -0
  42. package/dist/read-face-posture.js +9 -0
  43. package/dist/rules-consent.js +0 -11
  44. package/dist/session-policy-wire.d.ts +98 -12
  45. package/dist/session-policy-wire.js +71 -8
  46. package/dist/trace/project.d.ts +10 -5
  47. package/dist/trace/project.js +3 -3
  48. package/dist/trace/redact.d.ts +7 -0
  49. package/dist/trace/redact.js +153 -61
  50. package/package.json +1 -1
  51. package/dist/plugins/memory-session-policy-store.d.ts +0 -46
  52. package/dist/plugins/memory-session-policy-store.js +0 -52
package/MIGRATION.md CHANGED
@@ -7,6 +7,38 @@
7
7
  > ⚠️ 完整清单在仓库根 `CHANGELOG.md`——它**不随 npm tarball 出包**(本文件随包)。看完整迁移窗的
8
8
  > 权威姿势是源码 tag diff:`git diff v<旧>..v<新>`(每版都推 `v<版本>` tag);npm 包页也镜像 CHANGELOG。
9
9
 
10
+ ## 7.105.0(车QQ 部分)—— 门前三族只读路要凭据(S-672)· 会话规则写口拒退役 / 无前缀工具名(S-800)· `ServiceDeps.runnerFor` 必填(S-175)
11
+
12
+ ### wire:HTTP / 错误码
13
+
14
+ - **🔴 行为面:门前三族只读路在配了 service 凭据的部署上要凭据(S-672)。** `GET /v1/tasks`(及 `…/:id/{turns,stream,artifacts,tool-results/:ref}`、
15
+ `/v1/tasks/source-summary`)、`GET /v1/usage/{summary,series,breakdown}`、`GET /v1/workflows[/:id[/journal|/stream]]`、`GET /v1/fleet/stream`:部署配了
16
+ `SERVICE_AUTH_TOKEN` / `SERVICE_AUTH_TOKENS` 时,请求必须带 service token、只读 `TRACE_TOKEN` 或验过的 principal(registry 登录令牌 / 直连门签名 principal 令牌)
17
+ 之一,否则 **401 `auth.unauthorized`**(与全局凭据门同一句)。只带调用方自写的 `x-agent-principal` 头不算。**迁移**:这几条读口带上与提交任务同一把
18
+ service token(或登录令牌);没配 service 凭据的部署不受影响。`/.well-known/agent-card.json` 不变(匿名)。契约 §0.56。
19
+ - **会话规则写口拒「写进去就跑不了」的工具名(S-800)。** `PUT /v1/sessions/:id/policy` 与 `POST /v1/sessions/:id/sync/import` 的 `policy` 面:`toolAllow` /
20
+ `toolDeny` 里有引擎退役名(`Task` / `KillShell` / `MultiEdit` / `bash` / `Recall` …)或含 `__` 却无协议前缀的名字 ⇒ **400 `rules.legacy_tool_name`**
21
+ (新码,体带 `field` / `name`)。7.104.x 及以前照收、之后这个会话每一跑拒启。**迁移**:换成现役名(句子里带指引)/ 给协议工具名加 `mcp__` 等前缀;
22
+ 对未知前缀族严格的消费方收下 `rules.` 族(400,改请求再写,原样重试不改判)。部署自己挂了与退役名同名工具的,写口仍拒 —— 走部署级工具策略。
23
+
24
+ ### 类型面与嵌入方
25
+
26
+ - **🔴 `ServiceDeps.runnerFor` 必填(S-175)。** 修前可选,缺席时每只任务静默跑在 `deps.runner`(最宽的那只)上;现在缺席 = 编译错,绕过类型的 deps 在
27
+ `createHttpServer` 入口抛(报文点名 `runnerFor`)。**迁移**:单 Runner 的嵌入方显式传 `runnerFor: () => runner`(一句表态,不是回落);按场景分有手 / 无手两只 Runner 的嵌入方传自己的选路函数(本仓自己的装配在 `boot/stage-10-http-server.ts`,该工厂不在包根导出面)。
28
+
29
+ ## 7.105.0(车QP 部分)—— 续读不再答 416 `limit.retention_evicted`(S-211 ①)· `TOOL_RESULT_TTL_SEC ≤ 0` 拒启(S-211 ⑤)· 记号表加索引(S-546 A9)
30
+
31
+ - **续读(`Last-Event-ID` / `?from=`)不再答 416 `limit.retention_evicted`**(`GET /v1/runs/:id/events`、`GET /v1/tasks/:id/stream`、
32
+ `GET /v1/images/bakes/:id/events`)。这个码从来判不出一次真淘汰:账本没有局部头部裁剪,唯一的删除是整棵会话树连删 —— run 行
33
+ 随之不在,开流前就是 **404 `not_found.run`**(不变)。唯一走得到那一臂的是「写失败耗号」留下的头部缺行,现在那一形照常 200、
34
+ 投 `seq > from` 的每一行。**消费端动作**:为 416 写的重同步分支可以删(它本来就走不到);按错误码闭集分派的客户端,
35
+ `limit.*` 族今天只剩 429。
36
+ - **`TOOL_RESULT_TTL_SEC` 设 `0` 或负数 ⇒ 拒启**(此前照收,效果是每拍收光全部卸载的工具结果;config center 发布同一个值一直整批拒)。
37
+ 没有「关」:想留久一点就设大。
38
+ - **`session_policy_rev_mark` 多一条索引 `idx_session_policy_rev_mark_session`(`session_id`)**:新建库随建表到货;存量库由启动期的
39
+ 幂等加索引补一次(MySQL 协议在线 `ADD INDEX`、PG `CREATE INDEX CONCURRENTLY`),零运维动作。托管留存的整树清扫从此把被删会话的
40
+ 记号行一并删掉。
41
+
10
42
  ## 7.104.0 —— 最重三件:起服拒启收紧(多租户姿态 ∧ 不分租户的执行车道 / `CONFIG_PROVIDER` 闭集 / 退役负名与正名同设)· 同步提交口的 prepare 期拒在零工作时改答错误体(`corrupt_session` 500 / 名册超界三码 503 / `rewind.store_unconfigured` 409;做过工作的多级 run 末级被拒仍 200 + failed 体)· SQL `session_meta.incarnation` 新列 + 一次性迁移 M5(先升库再升副本)
11
43
 
12
44
  > 本版迁移面按面分五小节:wire(HTTP / SSE / 错误码 / 帧)· env 与启动 · 存储与一次性迁移 · 部署:sema-up · 类型面与嵌入方。一项变更跨面时正文落它的主面,别处一句「见 …」指过去。
package/USAGE.md CHANGED
@@ -2100,7 +2100,7 @@ server 进程(HTTP 服务与 `run-local`)**所有走 undici 的出网跳**都认
2100
2100
  照常带着代理变量(不被剥名),由 git / curl 这类工具自己认它们;**MCP stdio 服务器不继承**(MCP SDK 只给子进程 `HOME` / `LOGNAME` / `PATH` /
2101
2101
  `SHELL` / `TERM` / `USER` 加上该服务器声明的 env)—— 要它走代理,在它的 env 声明里写上代理变量。
2102
2102
 
2103
- ### 9.9 发车链(维护者):本地两阶段发包 · CI 健康读数 · 远端门上船清单 · 提货门区间 · core RC 探针 · 读表坐标 · 兄弟仓改名 · 门 × 凭据 · tag 跑复用 main 结论(7.104.0 起)
2103
+ ### 9.9 发车链(维护者):本地两阶段发包 · CI 健康读数 · 远端门上船清单 · 提货门区间 · core RC 探针 · 读表坐标 · 兄弟仓改名 · 门 × 凭据 · tag 跑复用 main 结论(7.104.0 起)· 远端门按车分库 · registry 读单一属主 · 门 2.9 · 起链启动器 · wire 变更清单(7.105.0 起)
2104
2104
 
2105
2105
  这几件是**仓库维护者**的工具面,不影响服务运行时。
2106
2106
 
@@ -2132,7 +2132,21 @@ server 进程(HTTP 服务与 `run-local`)**所有走 undici 的出网跳**都认
2132
2132
  - **门 4 不再等 CI**(同一裁定):`scripts/post-publish-gates.sh` 的门 4 = 一个 45 min 的 npm 窗(公开 packument 上 `versions[<v>]` 在且
2133
2133
  `dist-tags.latest` 追平)。判决行 `GATES456 v=… rc4=… rc5=… rc6=… shasum=… elapsed=… ciTag=… ciMain=… tagRun=… mainRun=… why4=…` 十一键形状不变;
2134
2134
  CI 两腿(`ciTag` / `ciMain` / `tagRun` / `mainRun`)是**信息格**,不进 rc4、不进 why4;`why4` 只剩 `propagation_timeout`(rc4 非零)与 `-`(绿)。
2135
- 自此门 4 常态首拍即绿,CI 两腿多半还在跑 ⇒ 常落 `unchecked:still-running`,如实。
2135
+ 自此门 4 常态首拍即绿,CI 两腿多半还在跑 ⇒ 常落 `unchecked:still-running`,如实。7.105.0 起(S-882 ③)CI 信息格见包后**读一次、不轮询**
2136
+ (此前的有界轮询几乎恒白等 10 min 再落同一个 still-running):落了终态记 conclusion;还在跑 ⇒ `unchecked:still-running` + 一句 `gh run view <id>`
2137
+ 指路;列表里认不出 ⇒ `absent`(没触发 / 腿名不对,与「在跑」分开说)。认腿的身份判据与常量(`ci.yml` / `push` / `main`)只在
2138
+ `scripts/lib/ci-health.mjs`(`node scripts/lib/ci-health.mjs identity` / `legs <version> <sha>`),S-159 的 tag 跑复用读的是同一只。
2139
+ - **registry 读的单一属主**(S-882,7.105.0 起):发车链读 npmjs 的四处 —— 门 1.5(死 tag)/ 门 2.5(近 5 个 tag 都已发)/ 门 3.5
2140
+ (precheck 与回读)/ 门 4(公开 packument + latest)—— 一律经 `scripts/lib/npm-registry.sh` 的 `registry_packument <包> [<版本>]`:
2141
+ curl 直打 registry(不经 npm,`.npmrc` 的 scope 映射接管不了)、每次读 ≤ `NPM_REGISTRY_TIMEOUT_SEC`(60 s,重试 1 次)、一行
2142
+ `<态> <versions> <latest> <shasum> <tarball> <gitHead>`,态 ∈ `present` / `absent` / `unreadable:<因>` —— 读不出与「不在」永远是两个词,
2143
+ 由各门自己判(门 1.5 读不出就说跳过;门 2.5 / 3.5 读不出即红)。此前门 1.5 / 2.5 / 4 的 curl 不限时(registry 挂住就挂死同步链),门 2.5 还手抄包名。
2144
+ 起链的 `npm whoami` 错误体两种流都认(npm 11 写 stdout、npm 8 写 stderr —— 只读 stdout 会把 npm 8 的 E401 误判成「网络不可达」)。
2145
+ - **门 2.9 = build + pack-hygiene**(S-882 ④ / S-237,7.105.0 起):门 2 的 tsc 之后、全量之前跑 `npm run prepublishOnly`(与门 3.5 在 tag 树里
2146
+ 跑的是同一只脚本;远端臂在 k3s8 的容器里 build)。此前 pack-hygiene 的 dist 字节段在远端臂恒 skip,第一次真执行是门 3.5 —— tag 已推;
2147
+ 本机臂又读盘上上一次留下的旧 dist,合并批改了 fail-open 词表注文就误红。
2148
+ - **门 3 推 main 前核 main = 起链提交**(S-882 ⑤,7.105.0 起):发车树 detached 在起链提交上、本地 `main` 被别处挪走 ⇒ 停链点名两只 sha
2149
+ (此前只核 HEAD,推出去的 main 是门 1–2 没测过的提交)。
2136
2150
  - **CI 健康读数**(S-846):`GH_TOKEN=… node scripts/ci-health-brief.mjs` 读 GitHub 上 `ci.yml` 在 `main` 的最近一次 run,一行
2137
2151
  `CI-HEALTH main=… test=… steps=… run=… verdict=ok|red why=…`(rc 0 = ok,1 = red,2 = 用法错)—— 每小时巡查用。`test` job success,或在跑且已起步骤
2138
2152
  ⇒ ok;completed 非 success(零步骤 = CI 没起,如 7.103.1 的账单真案 / 有步骤 = CI 红)、排队零步骤、读不出 ⇒ red(读不到 ≠ 健康)。
@@ -2153,13 +2167,16 @@ server 进程(HTTP 服务与 `run-local`)**所有走 undici 的出网跳**都认
2153
2167
  # --dry-run:只做前四步(不上 k3s8);--core-repo <core 仓的 git 根>(缺省 ../sema-core)
2154
2168
  ```
2155
2169
  脚本从本仓 **HEAD** 建临时 worktree(主树一字不动),六步各落一行 `CORE-RC-STEP v=<ver> step=<名> …` 进 `scratchpad/core-rc-probe-<ver>.log`
2156
- (原始输出在同目录的 `core-rc-probe-<ver>.XXXX/`):`tarball`(sha1 = npm 的 dist.shasum,**只打读数,与 core 直讯的 shasum 由人对**;tarball 自报版本
2157
- 必须 = 清单文件名 `core@<ver>.json` 的版本,不一致 ⇒ 红句点名两者、不建临时树)→ `install`(npm ci → `npm i <tarball> --save-exact`,声明改成精确版本
2170
+ (原始输出在同目录的 `core-rc-probe-<ver>.XXXX/`,版本读出前叫 `core-rc-probe-staging.XXXX/`,读出后按版本改名、后缀不变):`tarball`(**起链先拷进
2171
+ 读数目录 `tarball.tgz`,sha1 / 版本 / 装树全用副本** —— 原路径在 npm ci 期间被同版重新打包也不会「装 B、报 A」;sha1 = npm 的 dist.shasum,**只打读数,
2172
+ 与 core 直讯的 shasum 由人对**;tarball 自报版本必须 = 清单文件名 `core@<ver>.json` 的版本,不一致 ⇒ 红句点名两者、不建临时树)→ `install`(npm ci →
2173
+ `npm i <副本> --save-exact`,声明改成精确版本
2158
2174
  = 真提货形)→ `tsc` → `read-table`(重生成读表 → 提货门 `--wire-changes <文件>` `--from <HEAD 的 core 钉>`:同一只门,目标清单读自文件 —— RC 还没有
2159
- tag;清单文件的文件名与它自报的 `repo` / `version` 都要是 core@<ver>,不然门判输入不可信)→ `probes`(k3s8 车道 `/root/ci/sema-server-<lane>`,
2160
- 缺省 lane = `RC-<ver>-<本次运行的随机后缀>`,起用前原子占位 —— 已存在 ⇒ 红、不接手;secret-env 族 + 四只门钉)→ `default`(k3s8 全量,`--dsn ""`)。
2161
- 任一步红 ⇒ `CORE-RC-FAIL v=<ver> step=<名>`,后续照跑,最后非零退出;全绿才删临时树与本次占到的 k3s8 车道目录(先核没有容器挂着 —— 读不出也不删),
2162
- 红则留盘、路径在红句里。
2175
+ tag;清单文件的文件名与它自报的 `repo` / `version` 都要是 core@<ver>,不然门判输入不可信)→ `probes`(k3s8 车道 = 占位根 `/root/ci/sema-server-<lane>`,
2176
+ 缺省 lane = `RC-<ver>-<本次运行的随机后缀>`,起用前原子占位 —— 已存在 ⇒ 红、不接手;**ship / run 进占位根的子目录 `tree/`**,remote-gate ship 开场
2177
+ rm -rf 的是子目录、占位根整个运行期间都在,同名 `--lane` 的并发第二跑恒占不到;secret-env 族 + 四只门钉)→ `default`(k3s8 全量,`--dsn ""`)。
2178
+ 任一步红 ⇒ `CORE-RC-FAIL v=<ver> step=<名>`,后续照跑,最后非零退出;全绿才删临时树、读数目录里的 tarball 副本(`cleanup` 步骤行 `tarballCopy=`)与
2179
+ 本次占到的 k3s8 占位根连子目录(先核两只目录都没有容器挂着 —— 读不出也不删),红则留盘、路径在红句里。
2163
2180
  **读哪一行**:日志最后一行(尾行)
2164
2181
  `CORE-RC v=<ver> shasum=<sha1> tsc=<rc> gate=<0|N|untrusted> probes=<pass>/<fail> default=<pass>/<fail> regressions=<N> failed=<文件,…|->`
2165
2182
  —— 缺读数一律 `-`;`regressions` 只在 probes 与 default 两步都有读数时才是数(`-` ≠ 0);`gate=N` = 门命中的不同键数(`read-table` 步骤行的
@@ -2188,8 +2205,40 @@ server 进程(HTTP 服务与 `run-local`)**所有走 undici 的出网跳**都认
2188
2205
  `GATE2_WITHHELD_TEST_CREDENTIALS` 写明为何不给(钉会逼这一步)。
2189
2206
  - 起链环境里有名字不是 shell 标识符的凭据形变量,或带凭据条目的 git env 配置记录(`GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_<i>` …)⇒ 封口拒绝起链
2190
2207
  (点名不打值);起链前 unset 掉。
2191
- - `scripts/remote-gate.sh run --dsn "<名…>" <cmd>` 只把点名的 DSN(`TIDB_TEST_URL` / `PG_TEST_URL` / `INNODB_TEST_URL`,值取 `GATE_*_TEST_URL`
2192
- 或缺省)交给容器,`--dsn ""` = 一条都不给;**不给 `--dsn`(手跑)照旧三条**。`ship` 的容器不带 DSN。
2208
+ - `scripts/remote-gate.sh run --dsn "<名…>" <cmd>` 只把点名的 DSN 交给容器(名的闭集 = bug 复现套的 `ENGINE_DSN_ENV`,今天是
2209
+ `TIDB_TEST_URL` / `PG_TEST_URL` / `INNODB_TEST_URL`;值取 `GATE_<名>`),`--dsn ""` = 一条都不给;**不给 `--dsn`(手跑)= 闭集全部点名**。
2210
+ `ship` 的容器不带 DSN。
2211
+ - **按车分库是硬规则**(S-875,7.105.0 起):点了名的每一条都必须显式给出非空的 `GATE_<名>` —— **在场但为空**
2212
+ (`GATE_INNODB_TEST_URL=""` 想关掉那条腿)⇒ `REMOTE_GATE_DSN_EMPTY` 红停;**缺席** ⇒ `REMOTE_GATE_DSN_ABSENT` 红停。脚本不再有共享库缺省
2213
+ (旧形把空串 / 缺席回落到共享的 `sema_server_test` 等库,车QC 的锁形套在共享库上整表 DELETE 了别车的行)。要关一条腿:别在 `--dsn` 里点它;
2214
+ 要用:给按车库(`eval "$(node scripts/db-lane.mjs <车>)"` 建 `aiagent_test_<车>` / `sema_server_test_<车>`,再显式 export 三条
2215
+ `GATE_*_TEST_URL` 指 k3s8 上那几只库);发车链由 `scripts/chain-launch.sh` 显式传共享库那三条(唯一合法的「用共享库」)。
2216
+ 值**不进任何 argv**:经 ssh 的 stdin 送到远端 shell,再以 `docker run -e <名>`(只有名字)交给容器 —— 本机与 k3s8 的 `ps` 都看不见口令,
2217
+ 值里带 `&` / 空格也不会切断远端命令;值里有换行 ⇒ 红停。远端先 unset 点了名的每一条、只收点了名的名,核每一条都收到且解得开才起容器
2218
+ (没收到 ⇒ `REMOTE_GATE_DSN_LOST`,解不开 ⇒ `REMOTE_GATE_DSN_BAD`),核完打一行机读 `REMOTE_GATE_DSN handed=<名,…|-> skipped=<n>`
2219
+ (skipped = 闭集里这一跑没交给容器的引擎数);发车链把它抄上 `GATE2 …` 读数行的 `skipped=`(本机臂按换门交出的名数;远端红 ⇒ `unknown`)。
2220
+ - **起链 = `scripts/chain-launch.sh <version>`**(S-883,7.105.0 起;取代 sema-internal 的 chain-helper):
2221
+ ```bash
2222
+ nohup bash scripts/chain-launch.sh 7.105.0 > scratchpad/chain-7.105.0.log 2>&1 & disown # 在 node_modules 是真目录的树(主树)里
2223
+ ```
2224
+ 它先核前置(版本号 = `package.json`、node_modules 不是软链、`~/.zshrc` 里有 `GITHUB_SEMA_ORG_API_TOKEN` / `NPM_TOKEN` / `DEEPSEEK_API_KEY`、
2225
+ 本机三条隧道口连得上、读得出 `post-publish-gates.sh` 的 `GATE4_WINDOW_SEC`),再用**白名单**环境起 `remote-gate.sh probe` 与 `release.sh`:
2226
+ 白名单 = `node scripts/lib/gate-credentials.mjs allowlist`(「门 × 凭据」表里每一个名字 ∪ `HOME` / `PATH` / `LANG` / `TERM` / `SHELL` / `USER` /
2227
+ `TMPDIR` / `SSH_AUTH_SOCK` / `DEVELOPER_DIR` / `GATE_HOST` / `COMMS_DIR` ∪ 发车旋钮 `SKIP_SUITE` / `SHIP_CAPABILITY_NONE` ∪ 三条本机 DSN 名),
2228
+ **从表派生** —— 一门要一个新名字就登记进表,
2229
+ 启动器自动透传(7.104.0 手抄名单漏了 `DEEPSEEK_API_KEY` ⇒ 门 2.4 停链)。起链 shell 里表外的变量(含 core 名形黑名单不认的
2230
+ `*_PASS` / `*_PWD` / `*_COOKIE`)一条都进不去;值经管道进 `env -i` 起的子 shell,不进任何 argv。远端三条 `GATE_*_TEST_URL` 由它显式传
2231
+ 共享库(k3s8 NodePort)。链日志按版本追加,启动器只认起链之后新落的行(同版本重起链不会把上一次的判决当成这一次的)。之后转述门 3.5 的 `PUBLISH` 行;release.sh 红而 tag 已在 ⇒ 立即收尾并指路 `publish-local.sh` + `post-publish-gates.sh`;
2232
+ 绿 ⇒ 等 `GATES456`(窗 = `GATE4_WINDOW_SEC` + 20 min)后转述 `GATE2` / `PUBLISH` / 判决行。退出码见脚本头注(0 = 全绿)。
2233
+ - **wire 变更机器清单**(S-129 ③,7.105.0 起):`node scripts/wire-changes-server.mjs <version>` 生成 `wire-changes/server@<version>.json`
2234
+ (形照 core 的 `wire-changes/core@<ver>.json`:`added` / `removed` / `changed` / `narrowed` + `closedSetMembers` + `breaking`)。源 = 契约附录
2235
+ A.1 ∪ A.1b 错误码(属性 = 状态码)、B.1 能力位 / B.2 `taskSettings` 子键(属性 = 谓词类)、D.3 `engine_notice` 白名单(属性 = `detail`)、
2236
+ `src/trace/wire-projection-faces.ts` 的 `WIRE_PROJECTION_FACES`;对比上一 tag 与本版(tag 在读 tag,不在读工作树)。源读不懂 ⇒ exit 2
2237
+ (不产出「零变更」清单)。随 release commit 提交;查账门(门 1.6)第三腿 `--check` 核它在仓且与生成输出逐字节同,缺席 / 陈旧 ⇒ 红。
2238
+ `wire-changes/server@7.104.0.json` 是回填的第一份。
2239
+ - **台账 `refs:` 列建议**(S-129 ②):`node scripts/ledger-refs.mjs [--comms-dir <dir>] [--json]` 只读 sema-comms 的 `debts/DEBTS-server.md` /
2240
+ `PLANS.md`(server 段)/ `bugs/B-*.md`(文件头 `owner:` 格),按「一方点了另一方的号」双向互链,输出每行的 `refs:` 建议值(号按数值认,
2241
+ 零填充与不填充的拼法算同一只号;悬空号 stderr 点名)。不改 sema-comms,落账由主会话做。
2193
2242
  - **tag 跑复用 main 上同一提交的 test 结论**(S-159,`.github/workflows/ci.yml`):tag run 的 `test` job 先跑 `node scripts/ci-tag-test-reuse.mjs`,
2194
2243
  按 headSha 读 main 上同一提交那次 push run 的 `test` job —— success ⇒ 跳过全套;在跑 ⇒ 等(上限 45 min,复用步 55 min 时限之内);
2195
2244
  失败 / 查不到 / 读不出 ⇒ 照跑全套。结论一行 `TAG-TEST-REUSE decision=reuse|run why=… mainRun=… waited=…s sha=…` 在该 job 的日志与 step summary 里。
@@ -18,6 +18,7 @@ import { buildCrossSessionInboundLayers, crossSessionDialogExpiryOf } from "../c
18
18
  import { createPeerDirectory } from "../peer-directory.js";
19
19
  import { targetOutsideStoreInIncident } from "../memory-store-refusal.js";
20
20
  import { coreFileHistoryCaptureSeat } from "../file-history-capture.js";
21
+ import { rewindFailureCode } from "../file-history-disclosure.js";
21
22
  export function createRunnerDepsOnAsk(toolApproval) {
22
23
  if (!toolApproval)
23
24
  return undefined;
@@ -235,7 +236,7 @@ export function createRunnerDeps(ctx) {
235
236
  return;
236
237
  }
237
238
  if (ctx.phase === "rewind") {
238
- const code = /\((\w+)\)/.exec(String(err))?.[1] ?? "unknown";
239
+ const code = rewindFailureCode(err);
239
240
  logger.warn("rewind_snapshot_failed", { sessionId: ctx.sessionId, code, info: String(err) });
240
241
  metrics.inc("rewind_snapshot_failed_total", { code });
241
242
  return;
package/dist/config.d.ts CHANGED
@@ -91,10 +91,23 @@ interface ConfigWarning {
91
91
  detail?: string;
92
92
  }
93
93
  export declare function drainConfigWarnings(): ConfigWarning[];
94
- export declare function drainConfigNotices(): Array<{
94
+ /** Boot NOTICES ([ref]④): non-error decisions loadConfig makes that the operator should see once (e.g. the
95
+ * MODEL_PROVIDER inference below). Same pre-logger collection pattern as CONFIG_WARNINGS, but each entry
96
+ * carries its own event name + fields (the warnings drain has one fixed "invalid, using default" message
97
+ * which would misdescribe an inference). main.ts drains + logs right after createLogger.
98
+ *
99
+ * S-176 —— 每条带一个**级别**(闭集两词,排空处穷举 switch)。此前这一格固定按 warn 排空,于是「每次 boot 都成立的
100
+ * 常态事实」(posture 派生的档位)在这里没有位置:进 warn 会让一台一个字节都没配错的单机每次启动报警,另开一个
101
+ * 收集器又是第五条日志族。级别写在**铸点**(不是按事件名在排空处猜):
102
+ * · `warn` —— 运维要看一眼的决定(推断出的 provider、被丢掉的缺省行、no-op 旋钮……):既有的每一条;
103
+ * · `info` —— 常态事实,每次这样配就每次成立,不是异常(与执行车道落点那一行同级)。 */
104
+ type ConfigNoticeLevel = "warn" | "info";
105
+ export interface ConfigNotice {
106
+ level: ConfigNoticeLevel;
95
107
  event: string;
96
108
  fields: Record<string, string>;
97
- }>;
109
+ }
110
+ export declare function drainConfigNotices(): ConfigNotice[];
98
111
  /** F10 (codex audit): drain + log BOTH pre-logger diagnostic collectors (S20 soft-knob warnings + [ref]④ boot
99
112
  * notices) in one place. EVERY entrypoint that calls loadConfig() then builds a logger must call this right
100
113
  * after (main.ts, run-local.ts) — a new entry that forgets it would silently drop operator-facing diagnostics
package/dist/config.js CHANGED
@@ -13,7 +13,7 @@ import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js";
13
13
  import { CONFIG_PROVIDER_WORDS, RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC, RESILIENCE_DEFAULTS, TASK_WRITE_FACE_ESCALATIONS, MAX_TIMER_DELAY_MS, AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS, DRAIN_GRACE_FLOOR_MS, DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS, DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS, FIRST_BYTE_TIMEOUT_BOUNDS } from "./config-types.js";
14
14
  import { buildWriteProtectionPosture } from "./write-protection.js";
15
15
  import { normalizeSshFingerprint } from "./ssh-host-key.js";
16
- import { readFaceForPosture } from "./read-face-posture.js";
16
+ import { buildReadFacePostureNotice, readFaceForPosture } from "./read-face-posture.js";
17
17
  import { THINKING_LEVELS, THINKING_FORMATS, buildModelCompat, isThinkingFormat, isThinkingTier } from "./model-compat.js";
18
18
  import {} from "./posture-source.js";
19
19
  import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
@@ -251,8 +251,20 @@ export function logConfigDiagnostics(logger) {
251
251
  }
252
252
  }
253
253
  }
254
- for (const n of drainConfigNotices())
255
- logger.warn(n.event, n.fields);
254
+ for (const n of drainConfigNotices()) {
255
+ switch (n.level) {
256
+ case "warn":
257
+ logger.warn(n.event, n.fields);
258
+ break;
259
+ case "info":
260
+ logger.info?.(n.event, n.fields);
261
+ break;
262
+ default: {
263
+ const unreachable = n.level;
264
+ throw new Error(`unhandled config notice level: ${String(unreachable)}`);
265
+ }
266
+ }
267
+ }
256
268
  const laneNotice = EXEC_LANE_NOTICE;
257
269
  EXEC_LANE_NOTICE = undefined;
258
270
  if (laneNotice && logger.info)
@@ -531,6 +543,7 @@ export function parseEnvNameAllowlist(knob, singleUserTurnkey) {
531
543
  return [];
532
544
  case "honored":
533
545
  CONFIG_NOTICES.push({
546
+ level: "warn",
534
547
  event: "config_env_forwarded_multi_tenant",
535
548
  fields: {
536
549
  env: knob,
@@ -634,6 +647,7 @@ function parseWriteProtectedTableEnv(extraRaw, replaceRaw) {
634
647
  const dropped = posture.droppedDefaultRows ?? [];
635
648
  CONFIG_NOTICES.push(posture.source === "off"
636
649
  ? {
650
+ level: "warn",
637
651
  event: "write_protection_table_disabled",
638
652
  fields: {
639
653
  env: "WRITE_PROTECTED_TABLE_REPLACE",
@@ -644,6 +658,7 @@ function parseWriteProtectedTableEnv(extraRaw, replaceRaw) {
644
658
  },
645
659
  }
646
660
  : {
661
+ level: "warn",
647
662
  event: "write_protection_table_replaced",
648
663
  fields: {
649
664
  env: "WRITE_PROTECTED_TABLE_REPLACE",
@@ -795,6 +810,7 @@ function noticeAutoCompactAboveWindow(slot, envName, m) {
795
810
  if (act === undefined || window <= 0 || act <= window)
796
811
  return;
797
812
  CONFIG_NOTICES.push({
813
+ level: "warn",
798
814
  event: "auto_compact_tokens_above_context_window",
799
815
  fields: {
800
816
  slot,
@@ -816,6 +832,7 @@ function parseStoreDomain(ctx) {
816
832
  const dbBackend = enumEnv("DB_BACKEND", bareBoot ? (multiTenantBareBoot ? "memory" : "local") : "mysql", ["mysql", "pg", "local", "memory"]);
817
833
  if (multiTenantBareBoot) {
818
834
  CONFIG_NOTICES.push({
835
+ level: "warn",
819
836
  event: "db_backend_default_memory_multi_tenant",
820
837
  fields: {
821
838
  note: "REQUIRE_PRINCIPAL=true with no DB_BACKEND — the single-user default (local file store) is incompatible with multi-tenant (no tenant isolation in the file owner map), so this deployment runs IN-MEMORY (runs/sessions lost on restart, durable runs 501). Configure DB_BACKEND=mysql|pg for a durable multi-tenant deployment.",
@@ -922,6 +939,7 @@ function parseModelDomain() {
922
939
  return explicitProvider;
923
940
  if (inferredAnthropicBaseUrl && inferredAnthropicCred) {
924
941
  CONFIG_NOTICES.push({
942
+ level: "warn",
925
943
  event: "model_provider_inferred",
926
944
  fields: {
927
945
  provider: "anthropic",
@@ -967,6 +985,7 @@ function parseModelDomain() {
967
985
  const cheapId = process.env.MODEL_CHEAP_ID;
968
986
  if (cheapId && modelId === "") {
969
987
  CONFIG_NOTICES.push({
988
+ level: "warn",
970
989
  event: "model_cheap_slot_without_main",
971
990
  fields: { hint: "MODEL_CHEAP_ID is set but MODEL_ID is not — the env cheap slot derives from the env main model, so it is not minted; the catalog (config.d/models.json or the config center) serves the roles. Set MODEL_ID to use the env slots, or unset MODEL_CHEAP_ID to silence this line." },
972
991
  });
@@ -974,6 +993,7 @@ function parseModelDomain() {
974
993
  const cheapIsMain = cheapId !== undefined && cheapId !== "" && modelId !== "" && cheapId === modelId;
975
994
  if (cheapIsMain) {
976
995
  CONFIG_NOTICES.push({
996
+ level: "warn",
977
997
  event: "config_env_entry_cheap_same_as_main",
978
998
  fields: {
979
999
  env: "MODEL_CHEAP_ID",
@@ -1007,6 +1027,7 @@ function parseModelDomain() {
1007
1027
  const mainAct = optFinitePositiveEnv("MODEL_AUTO_COMPACT_TOKENS");
1008
1028
  if (applyAutoCompactWindow(model, mainAct) === "removed-1m" && modelId !== "") {
1009
1029
  CONFIG_NOTICES.push({
1030
+ level: "warn",
1010
1031
  event: "auto_compact_window_not_derived_1m",
1011
1032
  fields: {
1012
1033
  slot: "main",
@@ -1022,6 +1043,7 @@ function parseModelDomain() {
1022
1043
  if (applyAutoCompactWindow(cheapModel, cheapAct) === "removed-1m") {
1023
1044
  const inherited = process.env.MODEL_CHEAP_CONTEXT_WINDOW === undefined || process.env.MODEL_CHEAP_CONTEXT_WINDOW === "";
1024
1045
  CONFIG_NOTICES.push({
1046
+ level: "warn",
1025
1047
  event: "auto_compact_window_not_derived_1m",
1026
1048
  fields: {
1027
1049
  slot: "cheap",
@@ -1038,6 +1060,7 @@ function parseModelDomain() {
1038
1060
  const gatewayApiKey = process.env.MODEL_API_KEY;
1039
1061
  if (gatewayBaseUrl === undefined && gatewayApiKey !== undefined && gatewayApiKey !== "") {
1040
1062
  CONFIG_NOTICES.push({
1063
+ level: "warn",
1041
1064
  event: "model_gateway_key_unpaired",
1042
1065
  fields: {
1043
1066
  hint: "MODEL_API_KEY is set but MODEL_GATEWAY_BASEURL is not — MODEL_API_KEY is the gateway's credential and pairs only with the gateway's own root, so with no gateway it is NOT loaded and is sent to no route (a model with its own baseUrl authenticates with its own per-model key). Set MODEL_GATEWAY_BASEURL to use it, or unset MODEL_API_KEY to silence this line.",
@@ -1317,6 +1340,7 @@ function parseApprovalDomain(ctx) {
1317
1340
  if (trimmed === "" || trimmed === "off") {
1318
1341
  if (trimmed === "off") {
1319
1342
  CONFIG_NOTICES.push({
1343
+ level: "warn",
1320
1344
  event: "manual_mode_shell_gate_off_is_noop",
1321
1345
  fields: {
1322
1346
  env: "MANUAL_MODE_SHELL_GATE",
@@ -1614,6 +1638,7 @@ function parseMemoryDomain(ctx) {
1614
1638
  const replication = memoryReplicationCoverage(memorySync !== undefined ? { memorySync } : {});
1615
1639
  if (replication.kind === "scopes" && memoryEngineEnabled && !requirePrincipal && memoryScope !== undefined) {
1616
1640
  CONFIG_NOTICES.push({
1641
+ level: "warn",
1617
1642
  event: "memory_project_scope_yields_to_sync",
1618
1643
  fields: {
1619
1644
  replicatedScopes: replication.scopes.join(","),
@@ -1796,11 +1821,15 @@ function parseOrchestrationDomain(ctx) {
1796
1821
  }
1797
1822
  }
1798
1823
  }
1824
+ const readFaceFacts = process.env.READ_FACE !== undefined
1825
+ ? { readFace: enumEnv("READ_FACE", "roots", ["open", "roots"]), readFaceSource: "env" }
1826
+ : readFaceForPosture(singleUserTurnkey);
1827
+ const readFaceNotice = buildReadFacePostureNotice(readFaceFacts);
1828
+ if (readFaceNotice !== undefined)
1829
+ CONFIG_NOTICES.push({ level: "info", ...readFaceNotice });
1799
1830
  return {
1800
1831
  requireExecutionLane: boolEnv("REQUIRE_EXECUTION_LANE", false),
1801
- ...(process.env.READ_FACE !== undefined
1802
- ? { readFace: enumEnv("READ_FACE", "roots", ["open", "roots"]), readFaceSource: "env" }
1803
- : readFaceForPosture(singleUserTurnkey)),
1832
+ ...readFaceFacts,
1804
1833
  ...optionalEnumKey("taskWriteFaceEscalation", "TASK_WRITE_FACE_ESCALATION", process.env.TASK_WRITE_FACE_ESCALATION, TASK_WRITE_FACE_ESCALATIONS),
1805
1834
  ...(process.env.READ_DENY_PATTERNS !== undefined ? { readDenyPatterns: parseReadDenyPatternsEnv(process.env.READ_DENY_PATTERNS) } : {}),
1806
1835
  ...(() => {
@@ -2037,7 +2066,13 @@ function parseLimitsHttpDomain() {
2037
2066
  costQuotaWindowSec: numEnv("COST_QUOTA_WINDOW_SEC", "86400"),
2038
2067
  reapIntervalSec: numEnv("REAP_INTERVAL_SEC", "60"),
2039
2068
  runStaleSec: numEnv("REAP_RUN_STALE_SEC", "120"),
2040
- toolResultTtlSec: numEnv("TOOL_RESULT_TTL_SEC", "86400"),
2069
+ toolResultTtlSec: (() => {
2070
+ const ttl = numEnv("TOOL_RESULT_TTL_SEC", "86400");
2071
+ if (ttl <= 0) {
2072
+ throw new Error(`env TOOL_RESULT_TTL_SEC=${ttl} must be > 0 — it is the age (seconds) after which an offloaded tool result is reaped, so 0 or less puts the cutoff at or after now and every reaper tick would delete ALL of them; there is no "off": set a larger value to keep results longer (the config center refuses the same value)`);
2073
+ }
2074
+ return ttl;
2075
+ })(),
2041
2076
  };
2042
2077
  }
2043
2078
  function parseObservabilityDomain() {
@@ -1,34 +1,3 @@
1
- /**
2
- * S-157④ —— SQL file-history 店的 **typed 永久失败**披露座:散文一个铸点 + 机器面一只 sink。
3
- *
4
- * ## 病(基线 v7.79.0 逐行亲核 + 亲跑)
5
- * `plugins/blob-backend.ts:55` 的 `BlobTooLargeError` 带 typed 判别码 `blob_too_large_for_sql`,头注
6
- * 原话「绝不能与 transient 写失败混在一类(那类是可重试的 502;这类重试永远不会成功)」。可它一旦被
7
- * `plugins/file-history-store-sql.ts` 的任何一条 `catch` 折进 `FileHistoryResult.error.message`,判别码
8
- * 就只剩散文,**机器一位都读不到**。两条腿各自的后果:
9
- *
10
- * · **snapshot(边界捕获)腿**:引擎把失败包成一句散文交给 `RunnerDeps.onError({phase:"rewind"})`
11
- * (core 7.20.0 `dist/core/runner/prepare-file-history.js` 的 `wrapFileHistoryEntry`),服务那条臂从文本里抠**第一个**
12
- * 括号词当 metric label —— 而第一个括号词恒是引擎自己的闭集码。实测:「这个文件永远存不进这口店」
13
- * 与「连接被重置」在 `rewind_snapshot_failed_total` 上**同形** `code="snapshot_failed"`。
14
- * · **trackEdit(首触备份)腿**:本服务用引擎默认的 `onTrackFailure:"refuse"`(`boot/runner-deps.ts` 亲读),
15
- * 那条路 **完全不走 onError** —— 引擎把 `(track_failed: <本店 message>)` 念给模型,末句是
16
- * 「Retry when the file-history store is healthy.」(`prepare-file-history.js`)。对这枚码那是永远
17
- * 不会到来的一天,而**运维面一个计数都没有**:这条腿今天在遥测上完全不可见。
18
- *
19
- * ## 修法(源头留痕,不走文本通道)
20
- * · **散文面**({@link discloseCaughtFailure} 的返回值):把 caught error 的 typed `code` 写成一个括号词
21
- * 前置到它自己的 message 前 —— **不 `instanceof` 单挑任何一个类**(身上有字符串 `code` 就折),该店
22
- * 11 处折点全部改走这一只函数。人与模型因此看得到「这是哪一类失败」。
23
- * · **机器面**(同一只函数的副作用):码取自 typed 错**对象**、对闭集词表判定,**绝不从散文里抠**。
24
- * 🔴 这一条是 codex R1-[high] 验真后的定形:文本通道**天然可伪造** —— 一个名叫
25
- * `(blob_too_large_for_sql).txt` 的文件读不出来时,它的路径就在 `snapshot_failed` 的散文里,任何
26
- * 「从散文里取在册括号词」的读法都会把它当成一枚永久码(继而抑制掉真故障的告警)。闭集只约束基数,
27
- * 不能证明来源。所以机读判定搬回**唯一知道真相的那一层**=铸错的地方,`rewind` 臂的读法一字不动。
28
- * · 留痕三件套与 `observability/fail-open.ts` / `observability/secret-env-scrub.ts` 同律:**计数逐次**
29
- * (`file_history_permanent_failure_total{code}`)、**每进程每码一条 warn**(第二条起零新增内容,而
30
- * 20G 树的用户每 turn 撞一次)、装配前累计的次数在 install 时补进 metrics。
31
- */
32
1
  import type { Logger } from "./observability/logger.js";
33
2
  import type { Metrics } from "./observability/metrics.js";
34
3
  interface Sink {
@@ -49,5 +18,12 @@ export declare function installFileHistoryFailureRecorder(deps: Sink): void;
49
18
  export declare function fileHistoryPermanentFailureCounts(): ReadonlyMap<string, number>;
50
19
  /** 测试用重置(进程级状态在同一个 vitest worker 里跨用例存活)。 */
51
20
  export declare function resetFileHistoryFailureRecorderForTest(deps?: Sink): void;
21
+ /** core `FileHistoryError.code` 闭集的值投影(两道差集钉在下面;`satisfies` 让它进 core 读表的 `literals` 段 ——
22
+ * core 退役其中一词时,提货前的求交门就能点名这张表)。 */
23
+ declare const FILE_HISTORY_ERROR_CODE_WORDS: readonly ["track_failed", "snapshot_failed", "restore_failed", "adopt_failed", "not_found", "aborted", "unsupported_format", "conflict"];
24
+ /** rewind 粗码臂的码词 = core 的闭集。 */
25
+ export type FileHistoryErrorCode = (typeof FILE_HISTORY_ERROR_CODE_WORDS)[number];
26
+ /** rewind 臂一次失败的粗码:文本里**第一个**在册的 `(码)` / `(码:`;没有 ⇒ `unknown`。纯函数、不抛。 */
27
+ export declare function rewindFailureCode(err: unknown): FileHistoryErrorCode | "unknown";
52
28
  export {};
53
29
  //# sourceMappingURL=file-history-disclosure.d.ts.map
@@ -86,4 +86,16 @@ export function resetFileHistoryFailureRecorderForTest(deps = {}) {
86
86
  sink = { ...deps };
87
87
  deps.metrics?.counter(PERMANENT_FAILURE_COUNTER, PERMANENT_FAILURE_HELP);
88
88
  }
89
+ const FILE_HISTORY_ERROR_CODE_WORDS = ["track_failed", "snapshot_failed", "restore_failed", "adopt_failed", "not_found", "aborted", "unsupported_format", "conflict"];
90
+ const FILE_HISTORY_ERROR_CODE_SET = new Set(FILE_HISTORY_ERROR_CODE_WORDS);
91
+ const isFileHistoryErrorCode = (w) => FILE_HISTORY_ERROR_CODE_SET.has(w);
92
+ export function rewindFailureCode(err) {
93
+ const text = err instanceof Error ? err.message : String(err);
94
+ for (const m of text.matchAll(/\(([a-z_]+)[):]/g)) {
95
+ const word = m[1];
96
+ if (isFileHistoryErrorCode(word))
97
+ return word;
98
+ }
99
+ return "unknown";
100
+ }
89
101
  //# sourceMappingURL=file-history-disclosure.js.map
@@ -54,7 +54,11 @@ export interface FleetTaskRow {
54
54
  * 🔴 与 `name` 分域:`name` 是**显示名**(源 `description ?? name`,模型自由文本);本键是**身份键**,
55
55
  * 消费端按串相等把「@某个名字」绑到这一行(同类型两个子代在 `agentType` 上不可分,在本键上可分)。
56
56
  * ADDITIVE / tolerate-absent:**缺席 = 一次匿名委派**(不是「不知道它叫什么」)—— 7.25.0 之前本键在本仓
57
- * 没有任何源,恒缺席。SECRET-SAFE:与 `name` 同一道边界(emit 前 redact)。 */
57
+ * 没有任何源,恒缺席。**VERBATIM,不过 `redactSecrets`**(S-546 B6①:本注此前写「与 `name` 同一道边界,emit 前
58
+ * redact」,而三处铸点从本键到货起就是原样投 —— 见本文件 spawn / tick / terminal 三臂的行注与
59
+ * `trace/core-keyset-guard.ts` 的 `spawnName` 记账):它是**身份键**,消费端按串相等绑行,脱敏会把两个合法队友名
60
+ * 改写成同一串;出生点校验过的字符集里也没有可脱敏的形。残余(已披露)= 有人把真凭据当队友名写进
61
+ * `Agent({name})`,那一串原样上 fleet 面。展示名 `name` 照旧脱敏。 */
58
62
  agentName?: string;
59
63
  /** Owning principal (= the run's scope). The `/v1/fleet/stream` route filters to the caller's scope (owner-gated)
60
64
  * unless the caller is fleet-wide (operator / trace-token). NOT rendered — a tenant-isolation field. */
@@ -54,7 +54,7 @@ export declare const MAX_OUTPUT_SCHEMA_CHARS = 32768;
54
54
  export declare function validateUserSkills(skills: unknown): string | null;
55
55
  export declare function createAdmission(ctx: {
56
56
  deps: FlatServiceDeps;
57
- runnerFor: NonNullable<FlatServiceDeps["runnerFor"]>;
57
+ runnerFor: FlatServiceDeps["runnerFor"];
58
58
  } & Pick<RouteHelpers, "readJson">): {
59
59
  prepareSpec: (req: IncomingMessage, res: ServerResponse, bodyIn?: TaskRequestBody, onTypedFailure?: (err: HttpError) => void, legFacts?: {
60
60
  liveStream?: boolean;
@@ -22,6 +22,24 @@ import type { FlatServiceDeps } from "./server.js";
22
22
  * 凭证门、[ref] 的 parked 赎回席、以及 [ref] 的 consolidation 阀门启动期告警读同一句(手抄迟早
23
23
  * 只改一处,而这条判据决定的是「无凭证部署上写口开不开」)。导出仅为最后一个消费点(main.ts)。 */
24
24
  export declare function hasServiceAuth(config: ServiceConfig): boolean;
25
+ /**
26
+ * 🔴 S-672(test T-72 [ref];主会话裁 ①,分诊 [ref])—— 全局服务凭据门**之前**那几族只读路
27
+ * (`route-table.ts` 里 `position: "read-credential"` 的行)的**唯一**鉴权谓词:「服务凭据 ∨ 已验 principal」。
28
+ *
29
+ * 病:这几族(trace-usage / workflows / fleet)排在全局门之前,理由只有一条 —— 只读 `TRACE_TOKEN` 单凭据的调用方
30
+ * (registry 用量 / 轨迹中继)在全局门会被 401。可排在门前之后,每一族只剩**块内**自己那道门:fleet / workflows
31
+ * 只在 `REQUIRE_PRINCIPAL` 时要身份,trace 认 `gatedPrincipal`(BFF 形上就是调用方自己写的 principal 头)。于是
32
+ * 配了 service token、没开 REQUIRE_PRINCIPAL 的部署上,`POST /v1/tasks` 吃凭据,这三族读路对端口全开。
33
+ *
34
+ * 规则(一条,三族同读;fail-closed):
35
+ * · 部署**没配**任何 service 凭据(`hasServiceAuth` 假)⇒ 放行 —— 与全局门对 `/v1/tasks` 同判(run-local 形);
36
+ * · 否则要么出示了**服务凭据**(service token 单 / 多,或只读 `TRACE_TOKEN` —— 后者正是这几族在门前的理由),
37
+ * 要么带着**验过的** principal(registry 登录令牌 / direct-door 签名 principal 令牌;判据 = `verifiedIdentitySource`,
38
+ * 与 off-box operator 读面同一只)。调用方自己写的 principal 头不是凭据。
39
+ * 过了本门,各族块内的门(属主 scope / fleet-wide 上限 / `REQUIRE_PRINCIPAL` 的身份门)照旧 —— 本谓词只回答
40
+ * 「这个请求带没带这台部署认的凭据」,不回答「它能看谁的行」。
41
+ */
42
+ export declare function readCredentialOk(req: IncomingMessage, config: ServiceConfig): boolean;
25
43
  export declare function createDispatch(ctx: {
26
44
  deps: FlatServiceDeps;
27
45
  noteActivity: (url: string) => void;
@@ -3,14 +3,21 @@ import { setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isDestructiveSessio
3
3
  import { publicStoreProbeError } from "../store-live-probe.js";
4
4
  import { looksLikeJwt } from "@sema-agent/settings-schema/api/auth-bridge";
5
5
  import { ROUTE_METHOD_ORDER } from "./route-ctx.js";
6
- import { PRE_CREDENTIAL_ROUTES, POST_CREDENTIAL_ROUTES, routeLabel, isBillableSubmitPath, isCredentialGatedRewrite, isMethodDispatchedSubmitPath, lookupRoute, isProbePath } from "./route-table.js";
6
+ import { READ_CREDENTIAL_ROUTES, PUBLIC_ROUTES, POST_CREDENTIAL_ROUTES, routeLabel, isBillableSubmitPath, isCredentialGatedRewrite, isMethodDispatchedSubmitPath, lookupRoute, isProbePath } from "./route-table.js";
7
7
  import { sendJson, sendError, isSseResponse } from "./send.js";
8
8
  import { readinessOf, healthReadinessKeys, sendReadyz, billableRefusalOf, sendBillableRefusal } from "./readiness.js";
9
- import { authorized, systemFor, gatedPrincipal, sendPrincipalRefusal, sendUnauthorized } from "./principal-gate.js";
9
+ import { authorized, systemFor, gatedPrincipal, sendPrincipalRefusal, sendUnauthorized, verifiedIdentitySource } from "./principal-gate.js";
10
10
  const PROCESS_STARTED_AT_MS = Date.now() - Math.round(process.uptime() * 1000);
11
11
  export function hasServiceAuth(config) {
12
12
  return Boolean(config.authToken) || Object.keys(config.authTokens ?? {}).length > 0;
13
13
  }
14
+ export function readCredentialOk(req, config) {
15
+ if (!hasServiceAuth(config))
16
+ return true;
17
+ if (verifiedIdentitySource(req, config) !== undefined)
18
+ return true;
19
+ return config.traceToken !== undefined && config.traceToken !== "" && authorized(req, config.traceToken);
20
+ }
14
21
  function cors(res, req, origins, principalHeader) {
15
22
  if (!origins || origins.length === 0)
16
23
  return;
@@ -44,7 +51,7 @@ export function createDispatch(ctx) {
44
51
  };
45
52
  deps.drainState.lastAttachEndedAt = () => lastAttachEndedAt;
46
53
  }
47
- async function dispatchSegment(rows, req, res, url, search, method, allow, ctx) {
54
+ async function dispatchSegment(rows, req, res, url, search, method, allow, ctx, admit) {
48
55
  for (const row of rows) {
49
56
  if (row.enabled !== undefined && !row.enabled(deps))
50
57
  continue;
@@ -54,6 +61,10 @@ export function createDispatch(ctx) {
54
61
  allow.push(m);
55
62
  if (found.decl === null)
56
63
  continue;
64
+ if (admit !== undefined && !admit(req)) {
65
+ sendUnauthorized(res, "credential");
66
+ return true;
67
+ }
57
68
  await row.handler(req, res, { decl: found.decl, params: found.params, search }, ctx);
58
69
  return true;
59
70
  }
@@ -204,7 +215,9 @@ export function createDispatch(ctx) {
204
215
  }
205
216
  }
206
217
  }
207
- if (await dispatchSegment(PRE_CREDENTIAL_ROUTES, req, res, url, search, method, allow.pre, ctx))
218
+ if (await dispatchSegment(READ_CREDENTIAL_ROUTES, req, res, url, search, method, allow.pre, ctx, (r) => readCredentialOk(r, deps.config)))
219
+ return;
220
+ if (await dispatchSegment(PUBLIC_ROUTES, req, res, url, search, method, allow.pre, ctx))
208
221
  return;
209
222
  let source = null;
210
223
  const anyServiceAuth = hasServiceAuth(deps.config);
@@ -24,7 +24,7 @@ import type { createRequestGuards } from "./request-guards.js";
24
24
  import type { FlatServiceDeps } from "./server.js";
25
25
  export declare function createResumeLegs(ctx: {
26
26
  deps: FlatServiceDeps;
27
- runnerFor: NonNullable<FlatServiceDeps["runnerFor"]>;
27
+ runnerFor: FlatServiceDeps["runnerFor"];
28
28
  } & RunRegistry & RouteHelpers & Pick<RouteLegs, "prepareSpec" | "finalizeTaskResult"> & Pick<ReturnType<typeof createRequestGuards>, "ownerCostQuotaDenied" | "ownerFleetLeaseDenied" | "usageWindowDenialFor">): {
29
29
  routeCtxBase: RouteCtxBase;
30
30
  resumeCheckpoint: (sessionId: string, decision: "approve" | "deny", reason: string | undefined, hostDecision: HostDecision, req?: IncomingMessage, answer?: QuestionAnswer, binding?: DecideBinding, onResumeCommitted?: (grant: {
@@ -55,7 +55,17 @@ import type { FlatServiceDeps } from "./server.js";
55
55
  import type { IncomingMessage, ServerResponse } from "node:http";
56
56
  import type { RouteDecl, RouteDispatchDecl, RouteMatch, RouteCtx, RouteMethod } from "./route-ctx.js";
57
57
  /** 一只域 handler 相对**全局服务凭据门**的位置。门前 = 未鉴权可达(每一只都要单独论证),门后 = 先过凭据。 */
58
- export type RoutePosition = "pre-credential" | "post-credential";
58
+ /**
59
+ * 一行路由在全局服务凭据门的哪一侧、吃哪道门(闭集,三员;S-672 起由两员拆成三员):
60
+ * · `"read-credential"` —— 门**之前**,但过**一只**读路谓词(`dispatch.ts` 的 `readCredentialOk`:部署配了 service
61
+ * 凭据时要「服务凭据(含只读 TRACE_TOKEN)∨ 已验 principal」,没配时与全局门同样放行)。只读路才能坐这一段:
62
+ * 它们在门前只为收只读 TRACE_TOKEN 单凭据的调用方,不是为了对端口全开;
63
+ * · `"public"` —— 门之前、**不吃**任何凭据门:按裁定匿名可达的面(今天只有 A2A agent card,R-11「公网匿名可发现」,
64
+ * 零存量披露);
65
+ * · `"post-credential"` —— 全局服务凭据门(+ 无凭据写门 / 排空 503 / roster-pending 503 / 破坏性会话写门)之后。
66
+ * 修前是 `"pre-credential" | "post-credential"` 两员:门前四族一律不吃任何门,三族只读路因此对端口全开(T-72)。
67
+ */
68
+ export type RoutePosition = "read-credential" | "public" | "post-credential";
59
69
  export interface RouteRow {
60
70
  /** 域入口(`routes/*.ts` 的 `handleX`)。签名一致性是编译期钉:入口漂了形 = 编译红,不是运行时静默 404。
61
71
  *
@@ -79,9 +89,10 @@ export interface RouteRow {
79
89
  readonly enabled?: (deps: FlatServiceDeps) => boolean;
80
90
  }
81
91
  export declare const ROUTES: readonly RouteRow[];
82
- /** 两段(`dispatch.ts` 的分派循环各跑一段)。段内序 = 本表行序;
83
- * 「门前行不许出现在门后行之后」由 `test/route-order-freeze.test.ts` 钉住。 */
84
- export declare const PRE_CREDENTIAL_ROUTES: readonly RouteRow[];
92
+ /** 三段(`dispatch.ts` 的分派循环按 read-credential → public → 全局门 → post-credential 各跑一段)。段内序 = 本表行序;
93
+ * 「三段各自连续、按这个次序排」由 `test/route-order-freeze.test.ts` 钉住。 */
94
+ export declare const READ_CREDENTIAL_ROUTES: readonly RouteRow[];
95
+ export declare const PUBLIC_ROUTES: readonly RouteRow[];
85
96
  export declare const POST_CREDENTIAL_ROUTES: readonly RouteRow[];
86
97
  /**
87
98
  * **探针路径**(k8s / 编排器 / 抓取器的常态高频采样):不计 admission inflight、不推活性时钟(`http/server.ts`
@@ -38,10 +38,10 @@ function row(spec) {
38
38
  return spec;
39
39
  }
40
40
  export const ROUTES = [
41
- row({ handler: handleTraceUsage, decls: TRACE_USAGE_ROUTES, position: "pre-credential", label: "trace-usage" }),
42
- row({ handler: handleWorkflows, decls: WORKFLOWS_ROUTES, position: "pre-credential", label: "workflows" }),
43
- row({ handler: handleFleet, decls: FLEET_ROUTES, position: "pre-credential", label: "fleet" }),
44
- row({ handler: handleA2aServe, decls: A2A_SERVE_ROUTES, position: "pre-credential", label: "a2a-serve" }),
41
+ row({ handler: handleTraceUsage, decls: TRACE_USAGE_ROUTES, position: "read-credential", label: "trace-usage" }),
42
+ row({ handler: handleWorkflows, decls: WORKFLOWS_ROUTES, position: "read-credential", label: "workflows" }),
43
+ row({ handler: handleFleet, decls: FLEET_ROUTES, position: "read-credential", label: "fleet" }),
44
+ row({ handler: handleA2aServe, decls: A2A_SERVE_ROUTES, position: "public", label: "a2a-serve" }),
45
45
  row({ handler: handleCapabilities, decls: CAPABILITIES_ROUTES, position: "post-credential", label: "capabilities" }),
46
46
  row({ handler: handleSideQuery, decls: SIDE_QUERY_ROUTES, position: "post-credential", label: "side-query" }),
47
47
  row({ handler: handleTasks, decls: TASKS_ROUTES, position: "post-credential", label: "tasks" }),
@@ -78,7 +78,8 @@ export const ROUTES = [
78
78
  row({ handler: handleAttachments, decls: ATTACHMENTS_ROUTES, position: "post-credential", label: "attachments" }),
79
79
  row({ handler: handleNotifyWake, decls: NOTIFY_WAKE_ROUTES, position: "post-credential", label: "notify-wake" }),
80
80
  ];
81
- export const PRE_CREDENTIAL_ROUTES = ROUTES.filter((r) => r.position === "pre-credential");
81
+ export const READ_CREDENTIAL_ROUTES = ROUTES.filter((r) => r.position === "read-credential");
82
+ export const PUBLIC_ROUTES = ROUTES.filter((r) => r.position === "public");
82
83
  export const POST_CREDENTIAL_ROUTES = ROUTES.filter((r) => r.position === "post-credential");
83
84
  const PIPELINE_ROUTES = [
84
85
  { id: "health", path: "/health", methods: ["GET"] },
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * [ref] A9 域模块 —— MF-Fleet 实时舰队流。
3
3
  *
4
- * 路由:`GET /v1/fleet/stream`(SSE PUSH:连上先快照,之后每条 per-row delta)。跑在服务凭据门**之前**
5
- * (按 principal 自门,与 workflow 只读路由同列)。本域零可变状态。
4
+ * 路由:`GET /v1/fleet/stream`(SSE PUSH:连上先快照,之后每条 per-row delta)。跑在服务凭据门**之前**的
5
+ * 读路谓词段(S-672:先过 `dispatch.ts` 的 `readCredentialOk`「服务凭据 ∨ 已验 principal」,再按 principal 自门,
6
+ * 与 workflow 只读路由同段)。本域零可变状态。
6
7
  */
7
8
  import type { IncomingMessage, ServerResponse } from "node:http";
8
9
  import type { RouteCtx, RouteMatch, RouteIdsOf } from "../route-ctx.js";