@sema-agent/server 7.122.0 → 7.123.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 (69) hide show
  1. package/MIGRATION.md +77 -0
  2. package/README.md +5 -0
  3. package/README.zh-CN.md +3 -0
  4. package/USAGE.md +23 -5
  5. package/dist/audit.d.ts +1 -1
  6. package/dist/audit.js +52 -22
  7. package/dist/auto-mode-face.d.ts +12 -8
  8. package/dist/auto-mode-face.js +8 -7
  9. package/dist/boot/config-center.js +3 -0
  10. package/dist/config-catalog.js +1 -1
  11. package/dist/config-center/apply-effective.d.ts +1 -1
  12. package/dist/config-center/apply-effective.js +5 -3
  13. package/dist/config-center/http-client.js +3 -3
  14. package/dist/config-center/read-warnings.d.ts +56 -35
  15. package/dist/config-center/read-warnings.js +67 -19
  16. package/dist/config-center/types.d.ts +7 -8
  17. package/dist/config-lkg.d.ts +12 -9
  18. package/dist/config-lkg.js +38 -39
  19. package/dist/config-provider.d.ts +3 -2
  20. package/dist/config-provider.js +2 -2
  21. package/dist/config-types.d.ts +9 -1
  22. package/dist/config.d.ts +8 -8
  23. package/dist/config.js +69 -92
  24. package/dist/fleet/engine-signal.d.ts +54 -23
  25. package/dist/fleet/engine-signal.js +29 -5
  26. package/dist/fleet-client.d.ts +8 -6
  27. package/dist/fleet-client.js +4 -3
  28. package/dist/http/dispatch.js +5 -14
  29. package/dist/http/health-self-report.d.ts +3 -0
  30. package/dist/http/routes/capabilities.js +2 -1
  31. package/dist/http/routes/sessions.js +1 -4
  32. package/dist/http/stored-body-replay.d.ts +4 -3
  33. package/dist/http/stored-body-replay.js +2 -3
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.js +1 -0
  36. package/dist/leader/leader.d.ts +7 -6
  37. package/dist/leader/leader.js +3 -1
  38. package/dist/leader/run-entry.d.ts +2 -2
  39. package/dist/leader/run-entry.js +3 -3
  40. package/dist/leader/wire.d.ts +2 -2
  41. package/dist/model-entry-refusal.d.ts +18 -9
  42. package/dist/model-entry-refusal.js +19 -14
  43. package/dist/model-route-endpoint.d.ts +21 -1
  44. package/dist/model-route-endpoint.js +9 -3
  45. package/dist/model-route-gap.d.ts +103 -0
  46. package/dist/model-route-gap.js +80 -0
  47. package/dist/namespaced-rule-name.d.ts +16 -30
  48. package/dist/namespaced-rule-name.js +2 -20
  49. package/dist/observability/fail-open.d.ts +0 -4
  50. package/dist/observability/fail-open.js +0 -4
  51. package/dist/plugins/one-time-migrations.js +27 -0
  52. package/dist/plugins/session-policy-store-sql.js +1 -2
  53. package/dist/plugins/workflow-run-store-sql.d.ts +4 -2
  54. package/dist/rule-entry-list.d.ts +3 -13
  55. package/dist/rule-entry-list.js +1 -12
  56. package/dist/run-local.js +4 -4
  57. package/dist/runtime-governance.d.ts +31 -8
  58. package/dist/runtime-governance.js +7 -2
  59. package/dist/session-policy-wire.d.ts +47 -49
  60. package/dist/session-policy-wire.js +41 -36
  61. package/dist/session-sync.d.ts +5 -1
  62. package/dist/session-sync.js +2 -1
  63. package/dist/spec-fields.d.ts +4 -4
  64. package/dist/spec-fields.js +1 -4
  65. package/dist/task-settings.d.ts +4 -5
  66. package/dist/task-settings.js +5 -8
  67. package/dist/unattended-permission-mode.d.ts +3 -3
  68. package/dist/unattended-permission-mode.js +2 -2
  69. package/package.json +3 -3
package/MIGRATION.md CHANGED
@@ -7,6 +7,60 @@
7
7
  > ⚠️ 完整清单在仓库根 `CHANGELOG.md`——它**不随 npm tarball 出包**(本文件随包)。看完整迁移窗的
8
8
  > 权威姿势是源码 tag diff:`git diff v<旧>..v<新>`(每版都推 `v<版本>` tag);npm 包页也镜像 CHANGELOG。
9
9
 
10
+ ## 7.123.0 —— `GET /v1/models` 自报被读腿丢掉的目录条目(additive)· LKG 信封 formatVersion 2 → 3(升级零动作,回滚有条件)· settings-schema 10.1.1(存量配置里夹私钥 PEM 头的条目升级后被丢) · core 7.48.0(会话规则写口拒空串项 / `mcp__<server>__` 拒码变 / `/` 开头的读拒行根锚定 —— 回滚有条件 / shell 读法收窄与放宽) · `permissionModeAuto.reason` 新词 `operator_floor`
11
+
12
+ **升级前必读**:`SEMA_CONFIG_DIR` 设成工作目录本身或它的祖先(容器里 `SEMA_CONFIG_DIR=/workspace` 而项目在 `/workspace/proj`、或直接设成 `HOME`)的部署,项目里的写都要过 `write_protection` 一问、allow 规则放不掉 —— 把配置根指到项目之外的目录(第 4 条);依赖「`AUTONOMY=plan` 下人批了一次写就写成」的壳核一遍第 5 条的射程(写面根围栏之内);把订阅流的 `error` 终帧整类当「run 已动过」的消费端核第 6 条。往会话规则里写空串 / 纯空白项、或按 `request.field_invalid` 处理 `mcp__<server>__` 的调用方核第 7、8 条;设了 `CONFIG_LKG_PATH` 或运维读拒行以 `/` 开头的部署核第 9 条 —— **回滚到 7.122.0 及以前前先清空停泊任务**;无人值守腿(leader、无终端的 `run-local`)上跑带流喂 shell 的脚本核第 10 条。
13
+
14
+ 1. **`GET /v1/models` 顶层新键 `droppedEntries`**(additive,恒在场;零条 = `[]`,键缺席 = 更早的 server):升级零动作。把未知顶层键当错误的严格解析器要放行该键。
15
+ 2. **LKG 信封 `formatVersion` 2 → 3**(被丢目录条目的记录随 effective 一起落盘,墓碑与 `droppedEntries` 读同一份)。**升级**:启动时读回 7.122.0 及以前写的文件(2)一次性迁移,运维日志 `config_lkg_migrated` 一行;迁移来的文件 **ETag 不复用** ⇒ 中心可达后第一拍是整拉(不是 304),记录随即补全;中心断着的那段时间 `droppedEntries` 只列旧文件记下的那几行。**回滚到 7.122.0 及以前**:那些进程读不懂 3,把这份 LKG 当不可用(运维日志 `config_lkg_unusable` 一行)—— 中心可达则照常拉取、断着则 env 兜底,下一份干净候选按旧格式重写;回滚前确认中心可达,或删掉 LKG 文件让下一次拉取重写。迁移臂在本版发布 30 天后的版本删除。
16
+ 3. **`@sema-agent/settings-schema` 9.0.0 → 10.1.1**:写入校验在串中**任意位置**认私钥 PEM 头(此前只认串首),读侧同一套 schema ⇒ **存量**配置里自由文本位(目录条目的 `extraBody` / `compat` / `headers` / `baseUrl`、mcp 的 `url` / `command` / `args` 等)夹着私钥 PEM 头的:可降级域(`models` / `mcp` / `plugins` …)那一条在读腿被逐条丢掉(目录条目出现在 `GET /v1/models.droppedEntries`,原因 `entry-schema-invalid`;其余域的被丢条目只进 `config_entry_dropped` 日志行);gate 域(`workers` / `entitlement` …)整域读拒 —— 读拒发生在拉取段(读候选即拒),与「源不可达」走同一臂、同一事件名(与 7.122.0 及以前任何 gate 域坏形同一行):**起服**时运维行 `config_source_unreachable_using_env { source:"remote", err:<schema 诊断,带字段路径> }`,随后按 LKG → env 兜底(盘上有干净 LKG 就从它起服,运维行 `config_loaded_lkg`,`/v1/models` 与 `droppedEntries` 是 LKG 那一代;没有才走 env,此时 `droppedEntries:[]`);**运行中刷新**时运维行 `sema_registry_refresh_failed`,ETag 不前进、正在服务的那一代保持不变,下一拍重拉同一份候选。两行都不点名出错的域,`err` 里的字段路径是唯一线索。升级前把私钥移出配置串(引用 env 名或密封托管)并轮换那把密钥。
17
+ 4. **`SEMA_CONFIG_DIR` 不要设成工作目录本身或它的祖先**(引擎 7.46.0 起一直如此,7.120.0 节第 3 条的另一面;本版零代码改动,补记一条此前没写的事实):引擎内建写保护表里的 `.sema` 行不只守家目录下的 `~/.sema`,
18
+ 也被放在声明的配置根下 —— 根**之内任何路径**(不止配置文件)的写都要过 `write_protection` 一问。所以配置根是项目的祖先时(容器里 `SEMA_CONFIG_DIR=/workspace` 而项目在 `/workspace/proj`、直接设成 `HOME`、或设成项目目录本身),
19
+ 项目里的每一次写(`Write` / `Edit` / `NotebookEdit`)都出这一张卡:allow 规则清不掉它(请求 `settings.permissions.allow`、`APPROVAL_GRANT`、持久规则同),`acceptEdits` 不放,`dontAsk` 拒;无人值守腿(leader、无终端的 `run-local`)的作答集只有 `ordinary` 类、这一类不在其中 ⇒ 拒(leader 配了耐久审批的部署上改为停驻,等人经 `/decide` 答);
20
+ 只有 `bypassPermissions` 放行(托管部署上请求这一档另受 `TASK_WRITE_FACE_ESCALATION` 约束)。**起服不报、首条任务也不报**:本服务不拿配置根与工作目录比,引擎只验它是已存在的绝对目录 —— 第一次知道是第一次写。
21
+ **出路**:把配置根指到项目之外的专用目录(不设即缺省 `~/.sema`;`/workspace/.cfg` 一类不含项目的目录也行)。LKG(`config-lkg-*.json`)、skill 缓存(`skill-cache/`)、scheduler 共享店(`scheduled_tasks.json`)的缺省位置随配置根走,
22
+ 改根不会替你搬旧文件 —— 要保留 scheduler 共享店就手动把 `scheduled_tasks.json` 搬过去。确实要保留祖先形配置根的:`WRITE_PROTECTED_TABLE_REPLACE` 给一张不含 `.sema` 行的表可去掉这一问,代价是 `~/.sema` 下的这一问一并没了。
23
+ 5. **勘误(7.122.0 节第 7 条;已发节不改字节):`AUTONOMY=plan` 下「一次写在有人可答时出一张卡(人批了就写)」的射程是写面根围栏之内,不是任意路径**。批准只清模式门(plan 门那一问);
24
+ 引擎文件工具(`Write` / `Edit` / `NotebookEdit`)自己的根围栏不受批准影响,围栏 = 任务 `cwd` + 引擎宣告的可写目录(记忆目录、会话 scratchpad)+ 请求的 `additionalDirectories`。
25
+ 围栏外的路径,卡批了也不写:工具答错误,`tool_end.errorCode` = `path_not_in_root`,拒句 `… resolves outside the allowed root(s) (<根清单>); access denied.` 点名这些根。
26
+ 把 7.122.0 那句读成「批了就写,不论路径」的壳 —— 例如计划文件的路径在工作目录与上述目录之外的(默认配置根 `~/.sema` 之下就是一例)—— 要么让路径落进围栏(会话 scratchpad 或请求的 `additionalDirectories`),要么别指望那次写成功。
27
+ 写面声明为 open 的任务不受这道围栏约束(`bypassPermissions` 派生的开放写面;`GET /v1/capabilities` 的 `taskWriteFaceOpen` 披露本部署认不认)。本版零行为变化,这一条只改文字。
28
+ 6. **补记(7.122.0 节第 6 条):订阅流的排空终帧 `error{ errorCode:"draining" }` 是连接面的信号,不是 run 状态信号**(零行为变化,只补文字)。排空进入那一拍,服务端对**每一条**打开着的只读订阅流发这一帧
29
+ (`/v1/approvals/stream`、`/v1/fleet/stream`、`/v1/sessions/:id/events`、`/v1/runs/:id/events`、`/v1/tasks/:id/stream`、`/v1/runs/:id/subagents/:sub/stream`、`/v1/workflows/:id/stream`、`/v1/images/bakes/:id/events`),
30
+ 与流所观察的 run / 审批此刻在什么状态无关 —— 在跑的、停在一张审批卡上的、已终局的收到同一帧,帧里只有 `type` / `errorCode` / `message`,不带任何 run 或审批的状态。消费端:收到它 ⇒ 重连到替换实例;
31
+ **不要**据此判「run 已续跑 / 已动过 / 卡已决」,不要因此收掉在等的审批卡、也不要把重连前持住的停驻标当已决丢弃;按 `errorCode` 分支,别把 `error` 帧型整类当作 run 状态的信号。
32
+ 要知道 run 或审批此刻的状态,重连之后读 `GET /v1/runs/:id` / `GET /v1/approvals`。
33
+ 7. **会话规则写口对「不是一列非空字符串」的桶答 400**(`PUT /v1/sessions/:id/policy`、`POST /v1/sessions/:id/sync/import` 的 `policy` 面;引擎 7.48.0 的规则表判官):
34
+ 空串 / 纯空白项(`toolDeny: [""]`、`commandDeny: [" "]`、`toolAllow: ["Read", ""]`、`allowDirs: ["/work", "\t"]`)此前存得进(一条什么都不匹配的规则),现在
35
+ `400 request.field_invalid`,店里零字节;句子是引擎的,点名桶与值类型(`session rules <桶> must be an array of strings, got … — a rule list is written as a list of names (["rm"], not "rm")`)。
36
+ 修前就是 400 的裸字符串 / `null` / 混入非字符串三形码不变、句子换成同一句(按码匹配的不受影响)。**存量**:旧版写下的空串项行照常读出、照常执行;
37
+ 非 operator 想删掉空串 **deny** 项会被收紧门判成放宽(403 `loosen_forbidden`)—— 由运营方(operator)`PUT` 一份干净的规则体,或删会话。
38
+ 2c 推送遇到云端那一把键上是这种存量行 ⇒ 照常写:operator 推送照写(干净规则替换存量行,清它的另一条路);非 operator 推送若去掉了空串 **deny** 项 ⇒ 403 `loosen_forbidden`
39
+ (与 7.122.0 同答;装上引擎 7.48.0 而不改本服务时这里是 500)。推送体自己的记录里带空串项 ⇒ Phase A 400(先在源端清掉那一项再推)。
40
+ 8. **工具段为空的协议名(`mcp__<server>__` / `a2a__<peer>__`)由引擎判官判**(判词 `unmintable_segment`,与对端段铸不出的名字同一判):
41
+ 会话规则两写口的拒码 **`400 request.field_invalid` → `400 rules.legacy_tool_name`**(体带 `field` / `name` / `verdict: "unmintable_segment"` / `guidance`);
42
+ 请求 `settings.permissions.allow` 与技能 `allowedTools` 里它**由拒(422 / 400)变收**,引擎在开跑时读成 `tool_name` 并通告(`permission.allowed_tool_not_granted`);
43
+ 技能 `disallowedTools` 里它仍 400 `request.field_invalid`(句子换成引擎条目读法的那一句);运维 `APPROVAL_*` 四表、中心下发名表、请求 deny / ask 桶照拒,指引换成引擎的句子。
44
+ **存量**:7.115.x 及以前写进会话规则的这种名字,本版起每一跑在准备阶段拒(`config.legacy_tool_name`,一个 turn 不跑;同步提交腿 200 + failed 体)——
45
+ 7.122.0 及以前那条规则什么都不守、run 照跑。属主自己删得掉(删一条非现行 deny 不算放宽)。**出路**:写 `mcp__<server>`(整台服务器)或 `mcp__<server>__<tool>`(一只工具)。
46
+ 9. **`/` 开头的读拒行根锚定**(引擎 7.48.0):只守那一个绝对位置,不再按尾段在任意深度匹配;路径经符号链接的目录时,引擎在每次准备时把这一行沿链放置到字面头真正通向的地方
47
+ (链接目标同样被拒)。`CONFIG_LKG_PATH` 派生的读拒行收成一条 = 覆写的绝对路径(此前两条:写下的路径 + 本服务解出的落点);用户仓里同尾的路径读得到、Grep 也找得到;
48
+ `permission.read_deny_unapplied` 的 `detail.rows` 里部署 LKG 路径由两行变一行。运维 `READ_DENY_PATTERNS` 里 `/` 开头的行同此 —— **要任意深度就去掉前导 `/`**。
49
+ **回滚到 7.122.0 及以前**:本版(引擎 7.48.0)写出的检查点带 `/` 开头的读拒行时,7.47.x 引擎的工作者恢复它即拒(`checkpoint.invalid_outcome`,原因 `read_face_damaged`),
50
+ 检查点停泊不动 —— 回滚前先在本版上续跑完、或确认没有这类停泊任务(部署设了 `CONFIG_LKG_PATH`、或运维读拒行以 `/` 开头,就有这类行)。
51
+ 10. **shell 读法:收窄五条、放宽四条**(引擎 7.48.0 披露为可否决;本服务零改码,三种放行形 —— 无 allow 的 `default`、请求 `settings.permissions.allow: ["Bash"]`、
52
+ `bypassPermissions` —— 原样过境)。**多问**:`P=rm; $P -rf <根>` 这类经变量拼出的危险删除在 allow 与 bypass 下也是一张 `dangerous_delete` 卡;
53
+ **流喂 / 进程替换的 shell 一律 `dangerous_delete`,无害文本也是**(`ls | xargs -I{} sh -c 'echo {}'`、`bash <(printf 'echo hi')`,三种放行形各一张卡)。
54
+ 无人值守腿(leader 各条腿、无终端的 `run-local`)的作答集只有 `ordinary` ⇒ 这些卡没人答 ⇒ 拒(leader 配了耐久审批则停驻等 `/decide`)—— 跑在这些腿上的脚本里用
55
+ `xargs … sh -c` / `bash <(…)` 驱动 shell 循环的,先改成不经流喂 shell 的写法。**少问**(只在请求 allow `Bash` 与 `bypassPermissions` 下;`default` 照问):
56
+ 未加引号的多词值(`X='a b'; cat $X`)、名字取多个值后经 `su -c "$X"`、嵌套循环、带通配符的未引号值不再各出一张卡。
57
+
58
+ 11. **`GET /v1/capabilities` 的 `permissionModeAuto.reason` 闭集加一词 `operator_floor`**(additive;改正 7.122.0 节第 7 条「能力面答 `reason:"mode_not_auto"`」那半句,已发节不改字节)。
59
+ `AUTONOMY=plan` 的部署上,意图(查询串 `?permissionMode=`,缺席按 auto)被入口地板改成了别的词 ⇒ `reason:"operator_floor"`(此前 `mode_not_auto`):意图缺席、`auto`,以及任何比 `plan` 松的词
60
+ (`default` / `acceptEdits` / `bypassPermissions` / `dontAsk`)都是这一词;意图本身是 `plan` ⇒ 仍是 `mode_not_auto`。没有地板的部署读数不变。`armed` 不变。
61
+ 消费端:按 `reason` 指路的壳(`sema doctor permissions` 一类)给 `operator_floor` 一条「运维的 `AUTONOMY=plan` 让每个任务从 plan 起步,改请求的词到不了 auto;找运维换档」的出路;
62
+ 对 `reason` 写穷尽 switch 的类型面加这一臂,未知词照旧按「未武装」兜底。
63
+
10
64
  ## 7.122.0 —— 配置型 URL 拒句零回显(两种模型路由形改拒启)· 排空口的期限有上界(`FLEET_LEASE_OUTAGE_MAX_MS`)· 服务租约能力位撤掉再宣告不重置失联钟 · 改路停驻的审批在停驻当拍收口(迟到作答的 202 写真)· 排空不被只读订阅流挂住 · plan 模式一只判官(运维 `AUTONOMY=plan` = 提交入口的模式词地板)
11
65
 
12
66
  **升级前必读**:按拒句文字匹配配置拒启的脚本改按拒启码匹配(第 1 条);模型路由三键里没有 `ftp://` 一类非 http(s) 根(被消费的根修前已拒启,不被消费的那一根升级后也拒启)、没有以空 `#` 结尾的根(第 2 条,升级后拒启);
@@ -1442,6 +1496,29 @@ wire 面(同步提交撞 park 的 200 体、`GET /v1/runs/:id` 行形、同一
1442
1496
  另外六名新出现在 `exact-name` 桶。
1443
1497
  - 零 wire 键、零 DDL。
1444
1498
 
1499
+ ## 7.103.1 —— 补记:ssh / k8s / adb 三条远端车道的工作目录进不去时,命令不再在远端起始目录里照跑(改为不执行 + 一句诊断)
1500
+
1501
+ > 这一变化随 7.103.1 发出,当时本文件没有写。7.103.1 的另一块(git 配置组的判定改归引擎)见 7.103.0 的 core 7.33.1 提货一节里标 `⤷ 7.103.1` 的条目。
1502
+
1503
+ 1. **远端车道(`REMOTE_EXEC=ssh` / `k8s` / `adb`)的命令前面拼着「先进工作目录」;进不去时,命令现在一段也不跑**。
1504
+ · **修前(≤7.103.0)**:交给远端 shell 的是 `cd '<目录>' && export K='v'; <命令>` —— `&&` 只绑住第一只 `export`,目录不存在 / 进不去 / 不是目录时,`;` 之后的命令**照样在远端 shell 的起始目录里跑**
1505
+ (ssh 为目标账户的登录目录),只少了第一只 `export`;引擎的 `Bash` 工具带 `env`(`PWD`)调用,所以模型命令走的就是这一形。没有 `env` 的调用方形是 `cd … && <命令>`,只短路命令的第一段:`a; b`、`a || b`、多行命令的后半同样照跑。
1506
+ 结果是命令在**错的目录**里「成功」了(退出码是命令自己的,通常 0),没有任何一行告诉你工作目录没进去。
1507
+ · **现在(7.103.1 起)**:`cd '<目录>' || { echo '<守卫行>' >&2; exit 125; }; …` —— 进不去 ⇒ 命令**不执行**。缓冲执行面(模型的 `Bash`、带工作目录的 git 探针等)把它报成执行层的 `spawn_error`(与 host 车道的 spawn 失败同码同句族),
1508
+ 句子 `<ssh|k8s|adb> spawn failed: sema: working directory unavailable: <目录> — the command did not run (<远端 cd 自己的报错>)`。引擎的 `Bash` 工具据此判目录是不是真没了:
1509
+ 模型自己 `cd` 进去又删掉的目录 ⇒ 工作目录复位到任务根,答「… The working directory has been reset to <根>; re-run your command (cd first if you need a different directory).」,模型重发即可;
1510
+ 任务根本身没了 ⇒ 答「… no longer exist … this task cannot recover its working tree」,无从复位。目录在但**进不去**(权限)时引擎不判死目录、不复位,模型只看到 `command did not run (spawn_error)` 那一句,
1511
+ 本轮后续前台 `Bash` 都是它 —— 修目录权限(与 host 车道同形)。
1512
+ · **流式执行与后台任务不走 `spawn_error`**:cd 失败时交出退出码 125 + stderr 一行 `sema: working directory unavailable [<nonce>]: <目录> (cd failed; command not run)`(命令自己退 125 的不会被误认:缓冲面在 cd 成功之后、命令之前会往 stderr 写一行
1513
+ `sema: command started [<同一 nonce>]`,服务在交出之前剥掉它,没见到这一行才算「命令没跑」;文件面探针只用绝对路径、不进工作目录,工作目录进不去不影响它们答真话)。
1514
+ · **谁会看到**:远端工作目录被删 / 挂载没起 / 权限不对的部署 —— 以前这些命令在别的目录里「跑成功」,现在是显式失败。按退出码判成败、或把命令输出当数据的脚本要知道这一点。
1515
+ · **部署前提**:命令串用了 `{ …; }` 组命令,**远端 shell 须兼容 POSIX sh**。ssh 车道走目标账户的**登录 shell**(`-c`):登录 shell 是 csh / tcsh(fish 看版本)的账户整条语法错、命令一条也不跑
1516
+ (修前无 `env` 的 `cd 'X' && cmd` 在 csh 里能跑)—— 给 agent 用的账户把登录 shell 设成 `/bin/sh` / bash / zsh / dash。k8s 走容器里的 `sh -c`,adb 走设备的 `sh`。
1517
+ · **出路**:在远端建好并能进工作目录 —— `K8S_MOUNT_PATH`(缺省 `/workspace`)在容器里存在;`ADB_MOUNT_PATH`(缺省 `/data/local/tmp`)在设备上存在且可进;`SSH_MOUNT_PATH` 在目标机上存在且可进
1518
+ (不设 ⇒ 调用方没给工作目录的那几次不进目录、没有守卫)。
1519
+ · **不在本条范围**:`local-docker` 与 `e2b` 两条车道不经这一组装,它们的死工作目录报法本节未核。
1520
+ · 零 wire 键、零 DDL、零新旋钮。
1521
+
1445
1522
  ## 7.103.0(车OY 部分)—— server 进程出网认 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`(S-776)
1446
1523
 
1447
1524
  - **出网跟着代理变量走**:server 进程(HTTP 服务与 `run-local`)的出网 —— 引擎 → 网关、中心拉取 / 公告 / 凭证续签、WebSearch、对象存储、OTLP … ——
package/README.md CHANGED
@@ -131,6 +131,11 @@ curl -s localhost:8090/v1/tasks -H "Authorization: Bearer <SERVICE_AUTH_TOKEN>"
131
131
  Build-time tools (e.g. a catalog editor) read two allowlists from the package root instead of hand-copying them: `RETIRED_ENVS` (the retired-env
132
132
  tombstones) and, since 7.110.0, `CATALOG_ENTRY_HEADER_LANES` / `CATALOG_ENTRY_HEADER_NAMES` (+ type `CatalogEntryHeaderName`) — the closed allowlist of
133
133
  model-catalog entry `headers` and the API line that reads each name. Both are frozen, the very objects the server judges with.
134
+ A shell or package that must know **before the engine starts** whether the model route would stop it imports `judgeModelRouteGap(env)`
135
+ (pure; takes an env snapshot) from the package root: `{ gap: "endpoint_missing" | "lane_credential_missing" | "none" }`, plus `code` and the
136
+ refusal sentence `reason` when there is a gap. It is the very function `loadConfig()` judges the model route with — not a copy of it —
137
+ so its reading and the start-up refusal are the same by construction. `none` says the route gap is absent, not that the engine will start
138
+ (other invariants refuse with their own codes).
134
139
  - **Bundled binaries**: the package ships two `bin` entries — `run-local` (single-machine local
135
140
  runner) and `sema-up` (deployment bootstrap script).
136
141
  - `run-local` boots the same engine in-process, runs one task from the CLI objective, prints the
package/README.zh-CN.md CHANGED
@@ -114,6 +114,9 @@ curl -s localhost:8090/v1/tasks -H "Authorization: Bearer <SERVICE_AUTH_TOKEN>"
114
114
  `assertExecutionLaneInvariants(config)` 是同一份判据,不做 `config.d/remote-exec.json` 覆写。
115
115
  构建期工具(如目录编辑页)从包根取两张表、不手抄:`RETIRED_ENVS`(退役 env 墓碑)与 7.110.0 起的 `CATALOG_ENTRY_HEADER_LANES` /
116
116
  `CATALOG_ENTRY_HEADER_NAMES`(+ 型 `CatalogEntryHeaderName`)—— 模型目录条目 `headers` 的闭集白名单及读每个名字的线路。两者都冻结,就是服务端自己判据用的那一只。
117
+ 壳 / 包要在**引擎起来之前**知道模型路由会不会让它起不来,从包根调 `judgeModelRouteGap(env)`(纯函数,吃一份 env 快照):
118
+ `{ gap: "endpoint_missing" | "lane_credential_missing" | "none" }`,有缺口时另带 `code` 与拒启句 `reason`。它就是 `loadConfig()` 判模型路由用的那一只函数
119
+ (不是它的副本),所以读数与起服拒启同源。`none` 只说路由缺口不在,不担保起得来(别的不变量各自带码拒启)。
117
120
  - **随包二进制**:包内带两个 `bin` —— `run-local`(单机本地 runner)与
118
121
  `sema-up`(部署引导脚本)。
119
122
  - `run-local` 在进程内启同一套引擎,把命令行给的目标跑完一次、打印结果、退出——没有 HTTP 服务,
package/USAGE.md CHANGED
@@ -369,7 +369,7 @@ OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer xxx # 逗号分隔的 k=v
369
369
  MODEL_ID=<your-model-name> MODEL_CHEAP_ID=<your-cheap-model-name>
370
370
  ```
371
371
  - 调用方可在 `objective` 里 `@<模型名>` 选模型,**只认已配置的名单**(`MODEL_ID` / `MODEL_CHEAP_ID`,或目录条目)——注入不了 `baseUrl`/`apiKey`;无 `@` → 默认模型;`@` 点中一条已发布但本部署不可用的目录条目 ⇒ 400 `request.model_unavailable`(S-704)。
372
- - **`GET /v1/models`**(带 Bearer)→ `{models:[{name,id,provider,reasoning,vision}],default}`,只含名字/能力、**不含密钥** —— 消费方(如 OA)拿它做 `@` 自动补全下拉的数据源。
372
+ - **`GET /v1/models`**(带 Bearer)→ `{models:[{name,id,provider,reasoning,vision,…}],default,defaultModel,droppedEntries}`,只含名字/能力、**不含密钥** —— 消费方(如 OA)拿它做 `@` 自动补全下拉的数据源;`droppedEntries`(7.123.0 起恒在场,零条 = `[]`)是本代目录里被读腿逐条丢掉的条目(下标 + 闭集原因词 + 可披露身份),契约 §A.1d。
373
373
  - 不设 `MODEL_CHEAP_ID` → 所有角色 = 主模型(行为不变)。
374
374
  - `MODEL_CHEAP_ID` 写成与 `MODEL_ID` **同一个 id**(7.104.0 起)→ 那就是主模型本身:不铸 cheap 槽,所有角色 = 主模型,起服一行
375
375
  `config_env_entry_cheap_same_as_main`(同时设的其余 `MODEL_CHEAP_*` 键一并不生效)。要给同一只上游 id 第二种形(另一个价 / 思考 /
@@ -488,6 +488,13 @@ MODEL_CASCADE_LADDER=deepseek-flash,deepseek-pro # 目录里的模型名,cheap
488
488
  **7.106.0+**(S-810):候选**部分应用**(某组被整组拒、其余组已提交)的拍也照同一律算这一信号 —— 修前那一拍不签;被拒的是审批组 / 模型面组时
489
489
  (新进程 boot 重判可能拒启)信号扣下、只出 warn `restart_signal_withheld_boot_may_refuse`(点名组与差异)。
490
490
  钉:`test/health-readyz.test.ts` / `test/health-readiness-single-source.test.ts`。
491
+ - **引擎信号 `engineSignal`(7.123.0+,`/health` 与 fleet 心跳都带,恒在场)**:引擎态整只,型 = settings-schema `EngineSignal`
492
+ (`@sema-agent/settings-schema/fleet`;字段以契约包为准)。不带 `instanceId`(身份只在体顶层)。与扁平键**同一次读数**:
493
+ `engineSignal.draining`(排空事实 `{actionId, since, deadlineAt}`)在场 ⇔ 扁平 `draining: true`,`drainingSince` = `engineSignal.draining.since`;
494
+ `ready` / `restart` / 成员三键 / `/health` 世代账两版本与嵌套里的同名键同值。缺席 = 更早的引擎。⚠️ `/health` 无鉴权而排空中
495
+ `engineSignal.draining.actionId` 是本次排空的动作号:引擎自起的排空是引擎的闭集词,经 `POST /v1/admin/drain` 进的是调用方自取的幂等键原样
496
+ —— 用不透明 id(推荐 `manual:<uuid>`),别放秘密或人名。扁平布尔对 `draining` / `drainingSince`
497
+ 在 settings-schema 12.0.0 删、本引擎 7.124.0+ 删发 —— 读它们的编排脚本 / 壳改读 `engineSignal.draining`。
491
498
  - **配置接力位 `configHandoff`(7.121.0+,只在 `/health`,不上心跳)**:`{ state: "missing", version, since }`,**在场 ⇔ 部署声明了耐久接力(`CONFIG_LKG_DURABLE=true`
492
499
  ∧ LKG 启用)∧ 应用着的那一代没在下一次起服将用的凭证身份的 LKG 桶里确认耐久落盘** —— 中心断着时重启按**此刻的凭证身份**选 LKG 桶,那只桶里没有这一代就回到
493
500
  别的配置。三种形:LKG 发布失败(保留候选、启动期技能债)、部分应用(某组被整拒、其余组已热应用,LKG 停在上一个完整世代)、凭据换了人(凭证文件换成别的身份,
@@ -723,7 +730,7 @@ FLEET_ADVERTISE_ADDRESS=http://<本机可达IP>:8090 # 可选:设了才启公
723
730
  ```
724
731
  - 中心**空/未发布** → `applyEffective` 回落 env + 内建 teams 并 warn `config_center_unpublished`,**不影响在跑的服务**(接了也安全)。
725
732
  - **逐条容错**(S-704 ②,7.101.0 起):目录里一条条目不合形(如 `contextWindow:"abc"`)⇒ **只丢那一条**(warn `config_entry_dropped`,
726
- 点名坐标 `models.<下标>` 与字段诊断),其余条目照用;refresh 拍 / 迟到的 boot 拉取**不再因此整拒候选**(此前一条坏条目让整份目录停在
733
+ 点名坐标 `models.<下标>`、闭集原因词 `reasonCode` 与字段诊断;7.123.0 起同一条也在 `GET /v1/models` 的 `droppedEntries` 里自报),其余条目照用;refresh 拍 / 迟到的 boot 拉取**不再因此整拒候选**(此前一条坏条目让整份目录停在
727
734
  上一代 —— 上一代若还是 env,env 模型就一直服务)。颗粒更粗的失效(整个域读不懂、拼错键、坏文件)仍整拒候选、保 LKG。
728
735
  - **没有 `MODEL_ID` 也能起**(S-705,7.101.0 起):目录(`config.d/models.json` 或中心)有可用条目时 env 可以不写 `MODEL_ID` —— 缺省模型取
729
736
  目录缺省。判据在目录应用**之后**:env `MODEL_ID` 或目录缺省任一在场即过;两者皆无 ⇒ 拒启(码 `config.default_model_missing`,指路二选一)。
@@ -953,7 +960,7 @@ APPROVAL_GRANT='mcp__figma__*,a2a__planner__*,Bash(git:*)' # 逗号分隔的 a
953
960
  `Read(//<dir>/**)` 读目录。`auto` 模式下交出任意代码的条目(整只 shell、解释器 / 启动器前缀)交分类器判(引擎的剔除表)。
954
961
  命令规则 `Bash(<命令>)` 按命令判每一只跑 shell 命令的工具(`Bash` 与 `Monitor`;7.122.0 起);`Monitor(<命令>)` 不是规则拼法(引擎读成空 ⇒ 启动拒,见下)。
955
962
  坏值 ⇒ **启动拒**(句子点名那一条、引擎给的原因词、怎么写):引擎读成空的条目(`*`、`mcp__*`、写路径形 `Edit(src/**)`、域名形、带空白的名字;
956
- 7.118.0 起条目名也过引擎的判官 —— 退役名 / 无前缀短名 / 协议名服务器段铸不出读成 `tool_name`,句子带现名)、工具段为空的协议名(`mcp__srv__`)。
963
+ 7.118.0 起条目名也过引擎的判官 —— 退役名 / 无前缀短名 / 协议名服务器段铸不出、工具段为空(`mcp__srv__`,7.123.0 起由引擎判;此前本服务另判)读成 `tool_name`,句子带现名或改法)。
957
964
  内容形里带逗号的规则写不进(这张表的分隔符就是逗号)。
958
965
  - **永远清不掉的**:任何一层的 deny(`APPROVAL_DENY`、请求 / 会话规则的 deny、命令策略拒档、敏感写路径)、ask 规则(`APPROVAL_REQUIRE`、请求的 ask)、
959
966
  `APPROVAL_NEVER_AUTO`、`MANUAL_MODE_SHELL_GATE=always`、写保护路径、治理、PreToolUse hook 的问、plan 模式。命中而清不掉时卡上带
@@ -992,6 +999,11 @@ WRITE_PROTECTED_TABLE_REPLACE='[{"name":".envrc","kind":"basename"}]' # 整表
992
999
  - 租户面 `GET /v1/capabilities` → `writeProtection: {armed, rows, replaced}`(只给计数,不给行名);
993
1000
  - operator 面 `GET /v1/diagnostics/wiring` → `writeProtection: {rows[], source, droppedDefaultRows?}`
994
1001
  (`source`:`default` / `extra` / `replace` / `off`;逐行内容只在这一面)。
1002
+ - **配置根(`SEMA_CONFIG_DIR`)之内的整棵树都在这张表的射程里**(引擎 7.46.0 起):缺省表里的 `.sema` 行不只守家目录下的 `~/.sema`,也被放在你声明的配置根下 ——
1003
+ 根之内**任何**路径(不止配置文件)的写(`Write` / `Edit` / `NotebookEdit`)都要过 `write_protection` 一问。所以**配置根不要设成工作目录本身或它的祖先**
1004
+ (容器里 `SEMA_CONFIG_DIR=/workspace` 而项目在 `/workspace/proj`、或直接设成 `HOME`):项目里的每一次写都出一张卡,allow 规则清不掉它,`acceptEdits` 不放,
1005
+ `dontAsk` 拒,无人值守腿(leader、无终端的 `run-local`)拒(leader 配了耐久审批时停驻等人答),只有 `bypassPermissions` 放行。**起服不报、首条任务也不报** —— 第一次知道是第一次写。出路:把配置根指到项目之外的专用目录
1006
+ (不设即缺省 `~/.sema`);确实要保留祖先形配置根的,`WRITE_PROTECTED_TABLE_REPLACE` 给一张不含 `.sema` 行的表可去掉这一问,代价是 `~/.sema` 下的这一问一并没了。
995
1007
  - **与 `SENSITIVE_WRITE_PATTERNS` 并列、不合流**:那一根是**模式**(正则/glob)型 DENY 集,这一根是
996
1008
  字面名表的 `allow → ask` 降级。两套互不覆盖,读面也分开报 —— 一条路径被哪一套拦住,是两个不同的问题
997
1009
  (解法一个是改模式、一个是改名表)。
@@ -2053,11 +2065,15 @@ GET /v1/capabilities?permissionMode=auto
2053
2065
  → permissionModeAuto: { accepted:true, classifierSeat:true, entitlementSource:false, armed:true, model:"default" }
2054
2066
  GET /v1/capabilities?permissionMode=default
2055
2067
  → permissionModeAuto: { …, armed:false, reason:"mode_not_auto" }
2068
+ GET /v1/capabilities?permissionMode=auto (AUTONOMY=plan 的部署)
2069
+ → permissionModeAuto: { …, armed:false, reason:"operator_floor" }
2056
2070
  ```
2057
2071
 
2058
2072
  - `armed`:按上式此刻会不会武装(`?permissionMode=` 折进本次意图;查询串缺席 = 按 auto 意图答「若请求会不会武装」;
2059
2073
  core 闭集之外 400 `request.field_invalid`)。
2060
- - `reason`(未武装时,闭集**六词**,判序即武装式合取序):`mode_not_auto`(意图不是 auto)/ `deployment_incapable`
2074
+ - `reason`(未武装时,闭集**七词**,判序即武装式合取序):`mode_not_auto`(意图不是 auto,且原样到得了引擎 —— 改请求)/ `operator_floor`
2075
+ (7.123.0 起:本部署 `AUTONOMY=plan` 的入口地板把意图改成了别的词、不是 auto —— 意图缺席、`auto`、任何比 `plan` 松的词都是这一词;
2076
+ 出路是运维换档,改请求的词到不了 auto;7.122.0 在这里答 `mode_not_auto`)/ `deployment_incapable`
2061
2077
  (装配面没挂分类器席位——生产装配恒挂,桩与一次性 CLI 不挂)/ `org_denied`(`caps.autoMode === false`:center 显式 deny、
2062
2078
  center 硬失败 fail-closed 拒项)/ `local_denied`(本地旋钮 `PERMISSIONS_DISABLE_AUTO_MODE=true`,改本机 env)/ `settings_denied`
2063
2079
  (settings 层 `permissions.disableAutoMode:"disable"`——本面不产,归 run 级面)/ `resolver_fault`(per-principal resolver 抛错,
@@ -2082,7 +2098,9 @@ GET /v1/capabilities?permissionMode=default
2082
2098
  `permissionMode`;写它的只有提交入口与下面的 `plan_review` 写回。运维 `AUTONOMY=plan` 是**提交入口的模式词地板**:受理时盖进提交体
2083
2099
  (请求不带模式词 ⇒ `plan`;带任何词 ⇒ 钳成 `plan`;bundle 的 `defaultMode` 不动,顶层赢),之后不再按段续跑重钳;热改 `AUTONOMY` 只影响之后提交的任务,
2084
2100
  在飞的 run 保持它的入口词。工具表与 `default` 同面(写手、委派工具 `Agent` 都在),
2085
- 写与非只读调用在**调用时**由引擎的 plan 门问(有人可答)/ 拒(无人可答);委派的子代以 plan 为天花板继承同一道门。所以请求体带
2101
+ 写与非只读调用在**调用时**由引擎的 plan 门问(有人可答)/ 拒(无人可答)—— 问是一张审批卡,人批了就写,
2102
+ **射程是写面根围栏之内**(任务 `cwd`、引擎宣告的可写目录〔记忆目录、会话 scratchpad〕、请求的 `additionalDirectories`):批准只清模式门,引擎文件工具自己的根围栏不受批准影响,
2103
+ 围栏外的路径批了也不写(`tool_end.errorCode` = `path_not_in_root`,拒句点名 roots;写面声明为 open 的任务除外);委派的子代以 plan 为天花板继承同一道门。所以请求体带
2086
2104
  `agents`(自定义子代定义)的任务进 plan 照常起跑。此前本服务把用户选的 plan 另翻成「只读手 + 剥委派工具」,带 `agents` 的任务因此整轮被引擎拒
2087
2105
  (`config.agents.no_delegation_tool`);只读手现在只是 `AUTONOMY=read-only` 一个词的事(子代同受只读,委派照挂)。
2088
2106
 
package/dist/audit.d.ts CHANGED
@@ -48,7 +48,7 @@ export declare function buildSessionAuditLocal(root: string, sessionId: string):
48
48
  * 🔴 **会话视图的读面口 —— `GET /v1/sessions/:id` 的全部取法(整读 / `?tail` / `?before` / `?blobCap` / `?message=N`)共用这一只**。
49
49
  * 次序 = **选 → 脱 → 截**(路由 `http/routes/sessions.ts`):整条消息的选窗不切字符串,先选窗不改任何一条的脱敏读数、只把脱敏的量降到选中的那几条;
50
50
  * `blobCap` 的截断会切字符串(预览切在密钥中间,半截密钥认不出来)⇒ 截断恒在脱敏之后。
51
- * `messages` 缺席 ⇒ 不铸键;非数组 ⇒ 深扫;其余键(`owner` / `promptEpoch` / `model` …)逐字。字节没变 ⇒ **同一引用**。
51
+ * `messages` 缺席 ⇒ 不铸键;非数组 ⇒ 深扫;其余键(`owner` / `promptEpoch` / `model` …)逐字。每条消息都回同一引用 ⇒ **同一引用**(带结构叶的消息恒重建,见 {@link readByTable})。
52
52
  */
53
53
  export declare function redactSessionViewForRead<V extends object>(view: V): V;
54
54
  /** Window `messages` — `tail=N` (last N) or `before=<offset>&limit=N` ([max(0,before-N), before)).
package/dist/audit.js CHANGED
@@ -45,48 +45,78 @@ async function auditFromStorage(storage, sessionId) {
45
45
  }
46
46
  const deepScrub = (v) => redactDeepWith(v, redactModelText);
47
47
  const textSlot = (v) => (typeof v === "string" ? redactModelText(v) : deepScrub(v));
48
- function scrubSlot(p, key, scan) {
49
- const v = p[key];
50
- if (v === undefined)
51
- return p;
52
- const next = scan(v);
53
- return next === v ? p : { ...p, [key]: next };
48
+ function readLeaf(treatment, v) {
49
+ switch (treatment) {
50
+ case "text": return textSlot(v);
51
+ case "structure": return deepScrub(v);
52
+ case "parts": return readParts(v);
53
+ case "verbatim": return v;
54
+ default: {
55
+ const unhandled = treatment;
56
+ throw new Error(`unhandled leaf treatment: ${String(unhandled)}`);
57
+ }
58
+ }
59
+ }
60
+ const isPlainRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
61
+ function readByTable(node, table) {
62
+ let changed = false;
63
+ const entries = Object.entries(node).map(([key, v]) => {
64
+ const next = readLeaf(Object.hasOwn(table, key) ? table[key] : "structure", v);
65
+ if (next !== v)
66
+ changed = true;
67
+ return [key, next];
68
+ });
69
+ return changed ? Object.fromEntries(entries) : node;
54
70
  }
55
71
  const PART_READ_TREATMENT = {
56
- text: (p) => scrubSlot(p, "text", textSlot),
57
- thinking: (p) => scrubSlot(p, "thinking", textSlot),
58
- toolCall: (p) => scrubSlot(p, "arguments", deepScrub),
59
- image: (p) => p,
60
- document: (p) => p,
72
+ text: { type: "verbatim", text: "text" },
73
+ thinking: { type: "verbatim", thinking: "text", thinkingSignature: "verbatim", redacted: "verbatim" },
74
+ toolCall: { type: "verbatim", id: "verbatim", wireId: "verbatim", name: "verbatim", arguments: "structure", executionMode: "verbatim" },
75
+ image: { type: "verbatim", data: "verbatim", mimeType: "verbatim" },
76
+ document: { type: "verbatim", data: "verbatim", mimeType: "verbatim" },
61
77
  };
62
78
  function readParts(content) {
63
79
  if (!Array.isArray(content))
64
80
  return textSlot(content);
65
81
  const next = content.map((part) => {
66
- if (part !== null && typeof part === "object" && !Array.isArray(part)) {
82
+ if (isPlainRecord(part)) {
67
83
  const type = part.type;
68
84
  if (typeof type === "string" && Object.hasOwn(PART_READ_TREATMENT, type)) {
69
- return PART_READ_TREATMENT[type](part);
85
+ return readByTable(part, PART_READ_TREATMENT[type]);
70
86
  }
71
87
  }
72
88
  return deepScrub(part);
73
89
  });
74
90
  return next.every((x, i) => x === content[i]) ? content : next;
75
91
  }
76
- const readContent = (m) => scrubSlot(m, "content", readParts);
77
92
  const MESSAGE_READ_TREATMENT = {
78
- user: (m) => m,
79
- custom: (m) => m,
80
- assistant: (m) => readContent(m),
81
- toolResult: (m) => scrubSlot(readContent(m), "details", deepScrub),
82
- bashExecution: (m) => scrubSlot(scrubSlot(m, "command", textSlot), "output", textSlot),
83
- compactionSummary: (m) => scrubSlot(m, "summary", textSlot),
93
+ user: {
94
+ role: "verbatim", content: "verbatim", timestamp: "verbatim", enginePrefixChars: "verbatim", engineSegments: "verbatim",
95
+ engineMinted: "verbatim", engineNoteKey: "verbatim", provenance: "verbatim", actor: "verbatim",
96
+ },
97
+ custom: { role: "verbatim", customType: "verbatim", content: "verbatim", display: "verbatim", details: "verbatim", provenance: "verbatim", timestamp: "verbatim" },
98
+ assistant: {
99
+ role: "verbatim", content: "parts", errorMessage: "text", diagnostics: "structure", repetition: "structure",
100
+ api: "verbatim", provider: "verbatim", model: "verbatim", responseModel: "verbatim", responseId: "verbatim", requestId: "verbatim",
101
+ isApiErrorMessage: "verbatim", apiErrorStatus: "verbatim", apiErrorCode: "verbatim", usage: "verbatim", usageMissing: "verbatim",
102
+ stopReason: "verbatim", errorKind: "verbatim", inputTooLongRuledOut: "verbatim", partialFinalized: "verbatim",
103
+ staticReasoningCut: "verbatim", timestamp: "verbatim",
104
+ },
105
+ toolResult: { role: "verbatim", toolCallId: "verbatim", toolName: "verbatim", content: "parts", details: "structure", isError: "verbatim", timestamp: "verbatim" },
106
+ bashExecution: {
107
+ role: "verbatim", command: "text", output: "text", exitCode: "verbatim", cancelled: "verbatim", truncated: "verbatim",
108
+ fullOutputPath: "verbatim", timestamp: "verbatim", excludeFromContext: "verbatim",
109
+ },
110
+ compactionSummary: {
111
+ role: "verbatim", summary: "text", details: "structure", tokensBefore: "verbatim", timestamp: "verbatim", elidedMessages: "verbatim",
112
+ unsummarizedMessages: "verbatim", tokensAfter: "verbatim", firstKeptEntryId: "verbatim",
113
+ },
84
114
  };
85
115
  function readMessage(m) {
86
- if (m !== null && typeof m === "object" && !Array.isArray(m)) {
116
+ if (isPlainRecord(m)) {
87
117
  const role = m.role;
88
118
  if (typeof role === "string" && Object.hasOwn(MESSAGE_READ_TREATMENT, role)) {
89
- return MESSAGE_READ_TREATMENT[role](m);
119
+ return readByTable(m, MESSAGE_READ_TREATMENT[role]);
90
120
  }
91
121
  }
92
122
  return deepScrub(m);
@@ -15,8 +15,9 @@
15
15
  *
16
16
  * 本模块按**server 侧可知的同三项**算一份「若此刻提交会不会武装」的裁决,供壳(cli `sema doctor permissions`)
17
17
  * 与人自查。三项分别对应三个未武装原因(判序 = 谓词的合取序,先判到的先报):
18
- * · `mode_not_auto` —— 本次意图不是 auto(`?permissionMode=` 查询串给的;缺席 = 按 auto 意图答),或意图经本部署的运维入口地板
19
- * (`AUTONOMY=plan`)钳过之后不是 auto —— 与受理门同一只钳,提交时那个词到不了引擎;
18
+ * · `mode_not_auto` —— 本次意图不是 auto(`?permissionMode=` 查询串给的;缺席 = 按 auto 意图答),且原样到得了引擎(本部署的入口地板没改它);
19
+ * · `operator_floor` —— 本部署的运维入口地板(`AUTONOMY=plan`)把本次意图改成了别的词、那个词不是 auto(`runtime-governance.ts`
20
+ * `entryPermissionMode` 的 `clamped`,与受理门同一只函数)—— 到得了引擎的词由运维定,出路在运维换档,不在请求的词;
20
21
  * · `deployment_incapable` —— 装配面没挂 `RunnerDeps.autoMode` 分类器席位(生产装配恒挂;桩/一次性 CLI 不挂);
21
22
  * · `org_denied` —— per-principal caps 的 `autoMode === false` 且本地 deny 未设(center 显式 deny、center
22
23
  * 硬失败的 fail-closed 拒项);
@@ -35,7 +36,8 @@
35
36
  * 规则集少一整支;**不再读任何 dist 文本**。
36
37
  *
37
38
  * ## 闭集纪律([ref])
38
- * 六词是 core [ref] seam ⑤ + [ref] PM 裁定的闭集;`AUTO_MODE_UNARMED_REASON_SEMANTICS` 是 `Record<Reason, string>` 锁步表——
39
+ * 七词:core [ref] seam ⑤ + [ref] PM 裁定的六词 + `operator_floor`(意图被运维入口地板改词;与 `local_denied` / `org_denied` 同理按「指路不同」拆出);
40
+ * `AUTO_MODE_UNARMED_REASON_SEMANTICS` 是 `Record<Reason, string>` 锁步表——
39
41
  * 词表加员而语义表不加、或反之,都是编译红。本面**不**给壳发 prose(机器码上 wire,人话归壳),语义表只
40
42
  * 服务于文档/测试对拍。
41
43
  *
@@ -76,12 +78,13 @@ export type ClassifierRoute = {
76
78
  readonly id: undefined;
77
79
  readonly fault: string;
78
80
  };
79
- /** core [ref] seam ⑤ 的未武装原因闭集(六词,[ref] 加 settings_denied;顺序 = 判序)。 */
80
- export declare const AUTO_MODE_UNARMED_REASONS: readonly ["mode_not_auto", "deployment_incapable", "org_denied", "local_denied", "settings_denied", "resolver_fault"];
81
+ /** 未武装原因闭集(七词:core [ref] seam ⑤ + [ref] settings_denied + 入口地板改词 operator_floor;顺序 = 判序)。 */
82
+ export declare const AUTO_MODE_UNARMED_REASONS: readonly ["mode_not_auto", "operator_floor", "deployment_incapable", "org_denied", "local_denied", "settings_denied", "resolver_fault"];
81
83
  export type AutoModeUnarmedReason = (typeof AUTO_MODE_UNARMED_REASONS)[number];
82
84
  /** 锁步表:词表与语义一一对应(加员/删员任一侧不同步 = tsc 红)。语义只供文档/测试,不上 wire。 */
83
85
  export declare const AUTO_MODE_UNARMED_REASON_SEMANTICS: {
84
- readonly mode_not_auto: "本次意图不是 auto(壳发的 permissionMode 不是 \"auto\"),或意图经本部署的运维入口地板(AUTONOMY=plan)钳过之后不是 auto —— 与受理门同一只钳,提交时那个词到不了引擎";
86
+ readonly mode_not_auto: "本次意图不是 auto(壳发的 permissionMode 不是 \"auto\"),且原样到得了引擎(本部署的运维入口地板没改它)—— 出路是请求 auto";
87
+ readonly operator_floor: "本部署的运维入口地板(AUTONOMY=plan)把本次意图改成了别的词、那个词不是 auto —— 与受理门同一只函数,提交时到得了引擎的词由运维定;出路是找运维换档,改请求的词到不了 auto";
85
88
  readonly deployment_incapable: "装配面没挂 RunnerDeps.autoMode 分类器席位(本部署/本进程形不具备分类器)";
86
89
  readonly org_denied: "per-principal caps.autoMode === false 且本地 deny 未设:center 显式 deny / center 硬失败 fail-closed 拒项";
87
90
  readonly local_denied: "本地 catalog 键 PERMISSIONS_DISABLE_AUTO_MODE=true(center/settings 键名 permissions.disableAutoMode)把 caps.autoMode 折成 false";
@@ -120,8 +123,9 @@ export type AutoModeVerdict = {
120
123
  * 调用方经门验证的身份(匿名 ⇒ undefined ⇒ center 客户端答 undefined,本地 deny 层照样罩)。
121
124
  *
122
125
  * 意图那一项判的是**提交之后到得了引擎的那个词**:本次意图先过本部署的运维入口地板(`floor`,调用方传 `runtime-governance.ts`
123
- * `autonomyModeFloor(<活 config 的 AUTONOMY>)` —— 与受理门盖章读同一只属主),钳法 = core `clampPermissionMode`(受理门 `stampEntryPermissionMode`
124
- * 用的同一只钳;地板缺席 ⇒ 原样)。`AUTONOMY=plan` 部署上任何意图都被钳成 `plan` ⇒ `mode_not_auto`(闭集词复用,不另铸)。
126
+ * `autonomyModeFloor(<活 config 的 AUTONOMY>)` —— 与受理门盖章读同一只属主),经 `entryPermissionMode`(受理门盖章调的同一只函数)。
127
+ * 到得了引擎的词不是 auto ⇒ 未武装,原因按「谁定了那个词」分:地板改了词(`clamped`)⇒ `operator_floor`;词原样 ⇒ `mode_not_auto`。
128
+ * `AUTONOMY=plan` 部署上意图缺席 / auto / 任何比 plan 松的词 ⇒ `operator_floor`;意图本身是 `plan` ⇒ `mode_not_auto`。
125
129
  *
126
130
  * 🔴 resolver 抛错**不吞**:合成腿的每一层都自带 fail-closed/缺席臂(center 客户端 catch-all、grant 腿 onFault),
127
131
  * 走到这里还在抛 = 装配缺陷,读面 500 比一句编出来的 reason 诚实。
@@ -1,5 +1,6 @@
1
- import { clampPermissionMode, classifierSeatModel } from "@sema-agent/core";
1
+ import { classifierSeatModel } from "@sema-agent/core";
2
2
  import { resolveMainModel } from "./model-select.js";
3
+ import { entryPermissionMode } from "./runtime-governance.js";
3
4
  export function classifierRouteFor(deps) {
4
5
  try {
5
6
  const main = resolveMainModel(deps);
@@ -9,9 +10,10 @@ export function classifierRouteFor(deps) {
9
10
  return { id: undefined, fault: err instanceof Error ? err.message : String(err) };
10
11
  }
11
12
  }
12
- export const AUTO_MODE_UNARMED_REASONS = ["mode_not_auto", "deployment_incapable", "org_denied", "local_denied", "settings_denied", "resolver_fault"];
13
+ export const AUTO_MODE_UNARMED_REASONS = ["mode_not_auto", "operator_floor", "deployment_incapable", "org_denied", "local_denied", "settings_denied", "resolver_fault"];
13
14
  export const AUTO_MODE_UNARMED_REASON_SEMANTICS = {
14
- mode_not_auto: "本次意图不是 auto(壳发的 permissionMode 不是 \"auto\"),或意图经本部署的运维入口地板(AUTONOMY=plan)钳过之后不是 auto —— 与受理门同一只钳,提交时那个词到不了引擎",
15
+ mode_not_auto: "本次意图不是 auto(壳发的 permissionMode 不是 \"auto\"),且原样到得了引擎(本部署的运维入口地板没改它)—— 出路是请求 auto",
16
+ operator_floor: "本部署的运维入口地板(AUTONOMY=plan)把本次意图改成了别的词、那个词不是 auto —— 与受理门同一只函数,提交时到得了引擎的词由运维定;出路是找运维换档,改请求的词到不了 auto",
15
17
  deployment_incapable: "装配面没挂 RunnerDeps.autoMode 分类器席位(本部署/本进程形不具备分类器)",
16
18
  org_denied: "per-principal caps.autoMode === false 且本地 deny 未设:center 显式 deny / center 硬失败 fail-closed 拒项",
17
19
  local_denied: "本地 catalog 键 PERMISSIONS_DISABLE_AUTO_MODE=true(center/settings 键名 permissions.disableAutoMode)把 caps.autoMode 折成 false",
@@ -21,10 +23,9 @@ export const AUTO_MODE_UNARMED_REASON_SEMANTICS = {
21
23
  export async function judgeAutoModeArming(face, input) {
22
24
  const route = face?.seatMounted ? face.classifierModel?.() : undefined;
23
25
  const model = route !== undefined ? { model: route } : {};
24
- const intent = input.requestedMode ?? "auto";
25
- const entry = input.floor === undefined ? intent : clampPermissionMode(intent, input.floor).effective;
26
- if (entry !== "auto")
27
- return { armed: false, reason: "mode_not_auto", ...model };
26
+ const entry = entryPermissionMode(input.requestedMode ?? "auto", input.floor);
27
+ if (entry.effective !== "auto")
28
+ return { armed: false, reason: entry.clamped ? "operator_floor" : "mode_not_auto", ...model };
28
29
  if (face === undefined || !face.seatMounted)
29
30
  return { armed: false, reason: "deployment_incapable" };
30
31
  let caps;
@@ -133,6 +133,7 @@ export async function createConfigCenterRuntime(ctx) {
133
133
  logger.warn("config_entry_dropped", {
134
134
  domain: d.domain,
135
135
  entry: d.entry,
136
+ reasonCode: d.reasonCode,
136
137
  error: d.error,
137
138
  version: r.effective.version,
138
139
  phase,
@@ -419,6 +420,8 @@ export async function createConfigCenterRuntime(ctx) {
419
420
  logger.warn("config_lkg_unusable", { path: lkgPath, reason: "center credential identity changed while the LKG was being read — not used", note: "env fallback serves; the current identity's next pull re-establishes its own LKG" });
420
421
  return null;
421
422
  }
423
+ if (read.migrated !== undefined)
424
+ logger.info("config_lkg_migrated", { to: lkgPath, note: read.migrated });
422
425
  logger.info("config_loaded_lkg", { path: lkgPath, savedAt: read.lkg.savedAt, why, scope, note: lkgReadNote(scope) });
423
426
  const lkgEffective = read.lkg.effective;
424
427
  candidateIdentity.set(lkgEffective, identity);
@@ -95,7 +95,7 @@ export const CONFIG_CATALOG = [
95
95
  r("CONFIG_CATALOG_ENABLED", "auth", "boolean", "本目录端点 GET /v1/config/catalog 的旋钮(operator-only 面;合规部署可关 ⇒ 路径 404 + 能力位翻假)", { staticDefault: "true", danger: SEC, configKey: "configCatalogEnabled" }),
96
96
  r("CONFIG_LKG_DURABLE", "integrations", "boolean", "LKG 跨重启 handoff(严格 'true' 才开:重启先用 LKG 起服,tier 候选借此落地)", { staticDefault: "false" }),
97
97
  r("CONFIG_LKG_ENABLED", "integrations", "boolean", "last-known-good 配置盘缓存读写开关(仅远程 provider 有意义)", { staticDefault: "true", configKey: "configLkgEnabled" }),
98
- r("CONFIG_LKG_PATH", "integrations", "path", "LKG 落盘路径(设了 ⇒ 派生读拒行:这条路径与它的真实落点各一行,按路径段串匹配 —— 任意深度出现即拒、大小写不敏感,用户仓里同段串的路径一并读不到;空串拒启)", { derivedDefaultNote: "缺席 ⇒ 配置根(SEMA_CONFIG_DIR,缺省 ~/.sema)下按 worker + 凭证身份分箱派生", configKey: "configLkgPath" }),
98
+ r("CONFIG_LKG_PATH", "integrations", "path", "LKG 落盘路径(设了 ⇒ 派生一条读拒行 = 这条绝对路径:根锚定,只守这一个文件,经符号链接的目录时引擎沿链连目标一起守;大小写不敏感;用户仓里同尾的路径读得到;空串拒启)", { derivedDefaultNote: "缺席 ⇒ 配置根(SEMA_CONFIG_DIR,缺省 ~/.sema)下按 worker + 凭证身份分箱派生", configKey: "configLkgPath" }),
99
99
  r("CONFIG_LOCAL_DIR", "integrations", "path", "本地配置源根(config.d/<domain>.json);兼作数据根回落链一环", { configKey: "configLocalDir", danger: DATA }),
100
100
  r("CONFIG_PROVIDER", "integrations", "enum", "配置源双模选择(闭集,逐字比较;认不出的词 ⇒ 拒启,S-841)", { enumValues: CONFIG_PROVIDER_WORDS, derivedDefaultNote: "缺席:SEMA_REGISTRY_URL 在场 ⇒ remote,否则纯 env(无管道)", configKey: "configProvider" }),
101
101
  r("CONFIG_REQUIRE_ROSTER", "integrations", "boolean", "远程 registry 非 dryRun 时布防 ready 门:roster 未落地 ⇒ 计费提交 503 + /health ready:false(严格 'true')", { staticDefault: "false" }),
@@ -44,7 +44,7 @@ export interface ApplyReport extends ConfigApplyReport {
44
44
  readonly fullyApplied: boolean;
45
45
  /** S-768:本候选目录的模型拒因判决 —— 逐条(`entries`,stage 判出的那份候选的,键已过名筛)+ 组拒码(`plane`,模型面组被拒时在场,
46
46
  * 判决那刻当数据带出)。落地与组拒同一只来源:两者都描述**这一份**候选(组拒时引擎自己的拒因表不换代,这里仍报被拒的那份)。
47
- * 候选不管模型面(零启用条目、零墓碑)⇒ `entries` 空、无 `plane`。`/health.modelRefusals` 与心跳的唯一数据源(config-center 投影)。 */
47
+ * 候选不管模型面(一条启用条目也没有 —— 幸存的与被丢的都算)⇒ `entries` 空、无 `plane`。`/health.modelRefusals` 与心跳的唯一数据源(config-center 投影)。 */
48
48
  readonly modelRefusals: ModelRefusalVerdict;
49
49
  }
50
50
  /**
@@ -166,6 +166,7 @@ function commitStaged(config, staged) {
166
166
  config.modelApiKeyEnv = mp.modelApiKeyEnv;
167
167
  config.modelApiKeys = mp.modelApiKeys;
168
168
  config.modelRefusals = mp.modelRefusals;
169
+ config.droppedCatalogEntries = mp.droppedCatalogEntries;
169
170
  mutateInPlace(config.modelQuotaWeights, mp.quotaWeights);
170
171
  mutateInPlace(config.models, mp.models);
171
172
  config.model = config.models.default;
@@ -190,7 +191,7 @@ function commitStaged(config, staged) {
190
191
  }
191
192
  export const MODEL_PLANE_DOMAINS = ["models", "collab"];
192
193
  const MODEL_PLANE_SURFACES = {
193
- models: ["models", "model", "modelRefusals", "modelApiKeyEnv", "modelApiKeys", "modelQuotaWeights", "roles", "tiers", "atModelAllowlist"],
194
+ models: ["models", "model", "modelRefusals", "droppedCatalogEntries", "modelApiKeyEnv", "modelApiKeys", "modelQuotaWeights", "roles", "tiers", "atModelAllowlist"],
194
195
  collab: ["collabWorkflows"],
195
196
  };
196
197
  const MODEL_PLANE_GROUP_KEYS = MODEL_PLANE_DOMAINS.flatMap((d) => MODEL_PLANE_SURFACES[d]);
@@ -230,8 +231,8 @@ function modelPlaneGroupReport(group) {
230
231
  const ROLE_BINDING_UNAVAILABLE_HINT = "tasks resolving this role fail: alias/tier expansion cannot mint this name; the engine refuses it loud — bind the role to a usable model, or make the entry usable (see the entry's own warn)";
231
232
  function stageModelPlane(config, eff, logger, sealedKeys) {
232
233
  const enabled = (eff.models?.models ?? []).filter((m) => m.enabled !== false);
233
- const verdict = judgeCatalogEntries(enabled, { anthropicRouteMounted: config.anthropic !== undefined }, eff.models?.droppedIdentities);
234
- if (enabled.length === 0 && Object.keys(verdict.refused).length === 0 && verdict.malformed.length === 0)
234
+ const verdict = judgeCatalogEntries(enabled, { anthropicRouteMounted: config.anthropic !== undefined }, eff.models?.droppedCatalogEntries);
235
+ if (enabled.length === 0 && verdict.malformed.length === 0 && verdict.dropped.every((row) => row.identity?.enabled === false))
235
236
  return { stages: { collab: stageCollabDomain(eff) } };
236
237
  {
237
238
  for (const [name, r] of Object.entries(verdict.refused))
@@ -347,6 +348,7 @@ function stageModelPlane(config, eff, logger, sealedKeys) {
347
348
  modelApiKeyEnv,
348
349
  modelApiKeys,
349
350
  modelRefusals: { ...verdict.refused },
351
+ droppedCatalogEntries: [...verdict.dropped],
350
352
  quotaWeights,
351
353
  roles,
352
354
  activeTiers,
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readEffectiveWire, PrincipalCapsWire } from "@sema-agent/settings-schema";
3
3
  import { centerPromptsFromEffective } from "../prompts-domain-validate.js";
4
- import { splitFoldedReadWarnings, withDroppedIdentities } from "./read-warnings.js";
4
+ import { splitFoldedReadWarnings, withDroppedCatalogEntries } from "./read-warnings.js";
5
5
  import { CENTER_PULL_TIMEOUT_MS, centerFetch } from "./center-request.js";
6
6
  import { answerStillCurrent } from "../center-credential.js";
7
7
  export async function fetchEffective(center, etag, fetchImpl = fetch, worker) {
@@ -18,8 +18,8 @@ export async function fetchEffective(center, etag, fetchImpl = fetch, worker) {
18
18
  delete wire.prompts;
19
19
  else
20
20
  wire.prompts = centerPrompts;
21
- const { domainErrors, droppedEntries, droppedIdentities } = splitFoldedReadWarnings(warnings);
22
- const effective = withDroppedIdentities(wire, droppedIdentities);
21
+ const { domainErrors, droppedEntries, droppedCatalogEntries } = splitFoldedReadWarnings(warnings);
22
+ const effective = withDroppedCatalogEntries(wire, droppedCatalogEntries);
23
23
  return {
24
24
  effective,
25
25
  etag: res.headers.get("etag") ?? undefined,