@sema-agent/client-core 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,183 @@
16
16
  > 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
17
17
  > `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
18
18
 
19
+ ## 0.36.0(未发布)
20
+
21
+ **server 7.33.0 两 wire 键的过境批 + 一条跨仓文档失真的订正。行为面**两条**,都是 additive 透传
22
+ (缺席臂逐字节不变):安全类 ask 出身位到得了卡口;park 门第一次在 wire 上有了非秘密身份。**
23
+
24
+ - **`requiresRealApproval` 帧键镜像 + 卡口透传(#283,[4390]③ 请托的正位解)**:core 5.37 起在
25
+ `AskRequest` 上铸的**安全类 ask 出身位**(生产者 = `createUnverifiableDeletePolicy` /
26
+ `createTranscriptIntegrityPolicy` 与 PreToolUse hook 族),server **7.33.0** 起投到 `tool_approval`
27
+ 帧**顶层**。此前 `ToolApprovalFrame` 与 `ApprovalCardRequest` 都不含它 ⇒ 包边界的逐键 stamp 白名单
28
+ 把它剥掉 ⇒ 壳侧早已预埋的读器(认顶层 + `risk.*` 两拼法)读到的**恒是缺席**,「这门拒绝
29
+ blanket-allow」这条出身在端上不可达。本版补齐两处 + `TOOL_APPROVAL_FRAME_KEYS_MIRROR` 17→**18** 键。
30
+ 🔴 **契约 = 真才带,缺席绝不编 `false`**(故类型是 `true` 而非 `boolean`,与 `governanceForced`
31
+ 同族):缺席 = 这不是一次安全类 ask,是**正常的否定形**,不是坏形也不是「老 server」的同义词。
32
+ ⚠️ **与卡内那一位的在场契约刻意不同、别混**:durable `ApprovalCard.risk.requiresRealApproval` 是
33
+ **恒在布尔**,本键走帧顶层只在为真时在场 ⇒ 「帧上没有」与「卡上是 false」是两条不同的陈述。
34
+ ⚠️ **耐久腿刻意零 stamp**:sdk 7.1.0 的 `PendingCheckpoint` / `riskDescriptor` 两处都**没有**这一位
35
+ (server 那份在**卡内** `risk` 子树,不是行上的键)⇒ 与 `ruleEvidence` 同裁,诚实零投影 + 写明理由,
36
+ 绝不猜载体名;行为钉在 `run-durable-card-display-keys-test.mjs` ⑨ 段(四种最像的载体名摆在行上,
37
+ 卡入参必须一个都不长出来)。上游补位后按 `probeCause` 的双源合流形跟批。
38
+ ⚠️ **领先 SDK 锚一代**:sdk 7.1.0 的 `TOOL_APPROVAL_FRAME_KEYS` 尚无本键 ⇒ 对账门
39
+ (`run-approval-frame-keys-test.mjs`)记一条 **AHEAD_OF_ANCHOR 带退出条件登记**,SDK 追平当天
40
+ 该登记自红逼删(#144 `persistedRuleShadowed` / #280 `probeCause` 同形第三例)。
41
+ - **`pendingGate.checkpointId` 窄读 + 单向同一性谓词(#285 件2)**:server 7.33.0 起把 core 5.42.0 的
42
+ `Checkpoint.checkpointId`(`mintCheckpointId` 铸 = `cp_`+uuid)投到 409 `conflict.session_active_run`
43
+ 体 + 其 SSE done 帧的 `pendingGate` 上。这是 park 门在 wire 上的**第一个非秘密稳定身份** —— 此前唯一
44
+ 单射的键是 `token`,而 token 是秘密能力永不上 wire,消费端只能拿 `(sessionId, kind)` 这种非单射组合
45
+ 去猜。`ActiveRunPendingGate` 补 `checkpointId?: string`,窄读口径与 `kind`/`decidePath` 同族
46
+ (**非空串才置键**,坏形降缺席)。
47
+ 🔴 **刻意不做 `cp_`+UUID 格式正则、也不叠长度上限**,与 server 铸点同裁:上游亲跑验过,严格格式钉
48
+ **买不到安全性**(32 位 hex token 按 uuid 分组后逐字满足该正则,剥掉装饰就是完整能力键),只额外
49
+ 买来「core 换形我方静默丢键」的代价;长度域已在 server 投影处夹取,客户端再叠一道**更窄**的域 =
50
+ 上游放宽当天我方静默丢键,而丢键与 legacy 行在运维面上不可分辨。
51
+ 🔴 **新导出 `pendingGateIsProvablyDifferent(prev, next)`**(公面 748→**749**):`true` ⇔ 两侧都带
52
+ `checkpointId` 且不等,其余一律 `false`。**`false` 的语义是「证不出」,不是「同一道门」** —— 上游
53
+ 写死了可用性边界(server 选行是**无序 `LIMIT 1`** 且同 session 可并存多条 pending 行,同一情形连续
54
+ 两次 409 完全可能报出不同 id;也可能 id 不变而 `activeTaskId` 已换),只背书「id 变了 ⇒ 确实不是
55
+ 刚才那一行」这**一个**方向。归包的理由 = 这个谓词的价值全在**它拒绝回答的那一半**:三端各自手搓时
56
+ 「两个 id 相等就当同一张卡去重」是最自然也最容易写出的一行,而它恰恰是上游明说买不到的方向。
57
+ ⚠️ **辖域如实,别预支「两面能对上」**:上游本批只投到这**一个**载体;另外三条 park 读面
58
+ (`/v1/assistant/inbox`、`GET /v1/approvals`、`/v1/approvals/stream`)今天都没有这一格(后两条要给
59
+ checkpoint 表反范式一列 = SQL 面双库门)⇒ 拿本键去 join durable 队列行必然落空,而落空与 legacy
60
+ 行同形。**缺席是三成因合流**(legacy 行 / 值没过 server 的信任边界校验 / 整只材料读取失败),
61
+ 读作「不知道这道门叫什么」,不是「这道门没有身份」,更不是「这一定是条老行」。
62
+ - **文档订正三件(无行为面)**:
63
+ - `docs/INTEGRATION-CLIENTS.md` **§4 cancel 语义**与 §8-B checklist 的对应行改写为**两动词语义表**
64
+ —— 原文「取消一个 suspended run 必须用 deny,绝不 `runs.cancel`(对 suspended run 会 409)」已被
65
+ 装机 SDK 7.1.0 标 **stale**(`runs.d.ts` `cancel` JSDoc 逐字:服务端 [868] 起直接就地取消
66
+ suspended/needs_review 跑,**409 现在只剩 CAS race**)。照旧文档做会把「停」实现成「放它接着跑」。
67
+ 新表按**意图**分:`deny` = 否掉**这一道门**(run 继续,模型拿到拒绝继续跑);`cancel` = 终结
68
+ **整条 run**。同批加一条 **SDK 声明对账格**(`run-integration-doc-freshness-test.mjs` ⑦ 段):
69
+ 档里这段的判据锚在装机 SDK 的 `cancel` JSDoc 上,上游哪天改回去/改别的说法,门当天红。
70
+ - **勘误(#[4399]④)**:`## 0.33.0` 段把 `injectedSubmissionRow` 列进了
71
+ `adapter/activeRunSelfHeal` 的导出清单,**实为 module-private**(`src/adapter/activeRunSelfHeal.ts`
72
+ 的 `function injectedSubmissionRow(...)`,无 `export`,亦不在 `public-export-baseline.json` 里)。
73
+ 该段是**冻结面**不可改写 ⇒ 勘误记在此。三端按名收账时不必找这个导出:同批的
74
+ `RejectedSubmissionOrigin` / `SelfHealSubmissionDisposition` / `selfHealSubmissionDisposition()`
75
+ 才是真导出,行为经 `activeRunSelfHealRow` 的第四参进。
76
+ - **§0a 版本锚**刷到本版(此前停在 0.31.0 / peer floor `>=6.17.2`,与实况 0.35.0 / `>=7.1.0` 两处皆漂)。
77
+
78
+ **已知局限(本版新增)**:`requiresRealApproval` 与 `checkpointId` 都**只有活卡帧/409 这一条腿**,
79
+ 耐久面各自无对偶(理由与代价见上,均已在门里登记)。接入面已知局限的完整台账见
80
+ `docs/INTEGRATION-CLIENTS.md` §6e/§7,**只读本档会漏**。
81
+
82
+ ## 0.35.0(2026-08-18)
83
+
84
+ **0.34.0 发版补扫的收尾批(请求面三件 + 门的一件流程化)。行为面**两条**,都在
85
+ `request/taskRequest.ts`:快照通道不再替具名通道做决定;畸形快照从「降空照发」改**响亮拒**。**
86
+
87
+ - 🔴 **快照通道让位(F1,权限/治理方向)**:`settings.<resolved>` 是**开放集 spread**(快照子键
88
+ 原样就是 wire 键),所以它天生是一条**第二通道**。0.34.0 只剥了「车道异名」子键(表里那行的
89
+ `lanes` 不含本车道),于是**两条车道都登记**的具名子键剥不到 —— 而它们各有自己的治理门:
90
+ `settings.hooks` 的具名通道在「工作区未受信 / 管理侧明令关停全部 hooks / 该检查本身抛错」时判
91
+ **不投**(端给的 `settings.hooks` 就是 `undefined`),可快照里那份**没过那道门**的 hooks 照样被
92
+ 摊上 wire ⇒ 关停令等于没下。根因是让位靠的是**合并序**(具名键排在快照之后覆盖它),而合并序
93
+ 只在具名通道**有值**时管用,治理门否决时的表现恰恰是**没值**。
94
+ 修形 = **结构剥离**:凡具名通道管着的子键(表里有 `settings.<sub>` 行的 —— 今天
95
+ `hooks` / `webSearch` / `ultracode`),快照一概不产;「谁有资格决定这个键上不上 wire」只有一个
96
+ 答案 = 那条具名通道和它的门。**剥得窄**:快照**表外**的子键(`permissions` / `env` / `model` / …)
97
+ 原样摊开(那是开放集的全部意义);`outputStyle` 没有车道行(live 兜底层的位)故不受影响。
98
+ ⚠️ **端影响面**:若某宿主此前**只**把 hooks/webSearch 放进 `input.settings.resolved`、从不走具名
99
+ 通道,这一版起那两个键不再上 wire —— 改法是把值放进具名位(`input.settings.hooks` /
100
+ `.webSearch`),那条路自带治理门。这不是新差异面:两条车道的表现完全对称。
101
+ **已核过的消费面**(不是推断):`sema-cli` 的 print/交互两条车道都走具名位供 hooks(它自己的投影层
102
+ 就把 hooks 从快照里剥掉了),`sema-web-client` 的座位台账把 `settings.<resolved>` 显式登记为
103
+ `supplied: false`,`sema-web` 只用具名 `settings.ultracode` —— 今天**唯一**的快照供给方是 cli,
104
+ 两套壳消费端常驻门按本版构建实跑仍绿。第三方(仓外)宿主无法穷举,故按公开面纪律记在本段。
105
+ - 🔴 **畸形快照响亮拒(F3,fail-closed)**:`input.settings.resolved` 为 `undefined` / `null` =
106
+ **合法缺席**(端没解析出快照;老写法拿 null 当空快照传),照旧降空、请求照发 —— 这一半与
107
+ 0.34.0 逐字不变。其余非 plain object 形(数组 / 原始值 / boxed 包装对象 / `Map` …)从 0.34.0 的
108
+ 「一律降空对象」改判 **`TypeError`**:这一位摊开的正是**已解析权限面**,降空 = 请求带着「被剥掉的
109
+ `permissions.deny/ask`」照发出去,引擎侧看到一个权限更宽的会话而没有任何人会知道。上游产出坏了
110
+ 就该停在提交前。错误文本**只报形状**(`typeof` / `[object …]` / prototype 载体名),不回显快照内容
111
+ (快照里有 env 与权限面,错误路径也是外溢面)。
112
+ 🔴 **合法载体按 prototype 判,不按自报标签判**:`Object.prototype.toString.call(x) === '[object Object]'`
113
+ 对**普通类实例**同样成立,`Symbol.toStringTag` 还能让任意载体自报这个标签;而摊开用的是
114
+ `Object.entries`(**只取自有可枚举键**)—— 于是一个把 `permissions` 挂在**原型 getter** 上的载体会
115
+ 摊出**空快照**、请求照发,正是本条要堵的 fail-open 形本身。所以合法载体只有两种:`Object.prototype`
116
+ 直系(对象字面量)与 `null` 原型(`Object.create(null)` 无污染字典)—— 它们的自有可枚举键集**就是**
117
+ 全部内容。类实例 / `Map` / `Date` / boxed 包装对象 / 数组一律拒。
118
+ 判据 **realm 无关**:不拿「本 realm 的 `Object.prototype`」做身份比较 —— iframe / `node:vm` / 另一个渲染
119
+ 进程里的对象字面量各有自己的 `Object.prototype`,身份比较会把这些**完全合法**的载体误判成异形,在
120
+ web / desktop 宿主上变成提交前硬失败。改判**原型链层数**(字面量的原型的原型恒 `null`),该拒的照旧拒。
121
+ 🔴 **权限面子树同样递归校**:只校最外层不够 —— `{ permissions: new Map([['deny',['Write']]]) }` 外层
122
+ 合法,而 `JSON.stringify` 把那只 Map 变成 `"permissions":{}`,deny 规则**静默消失**(同一个权限变宽形,
123
+ 深一层)。`permissions` 子树按「序列化后还是不是同一份内容」递归判:plain record / 数组 / JSON 原始值
124
+ 合法,`Map`/`Set`/类实例/boxed/环拒;错误文本给**路径**(如 `permissions.deny[0]`)不给值。判据
125
+ **不猜 schema**(不要求 deny 是字符串数组 —— 那会在 server 契约扩形那天误红)。同一条不变量还堵两个
126
+ **序列化级**形:①**任何一层**上可 call 的 `toJSON`(自有 / 非枚举 / 原型链 / 数组子类都算 —— 一个
127
+ `deny=['Write']` 挂个非枚举 `toJSON(){return []}` 就能让 wire 上出 `"deny":[]`,而按键遍历的判据一个字
128
+ 都不会说,所以判据也走**属性查找**);②**非有限数**(`NaN`/`±Infinity` 序列化成 `null`,规则位变 null)。
129
+ 数组长度按原生 `ToLength` **只转换一次**(先存下来再逐轮比较,会让「第一次给 3、以后给 1」的 length 少发
130
+ 规则),且转换用**抽象 ToNumber**(一元 `+`)—— `Number()` 会把**对象强制出来的 BigInt**
131
+ (`Object(0n)` / `valueOf()=>0n` / `Symbol.toPrimitive()=>0n`)悄悄变成 0,而原生在同样输入上是抛的;
132
+ `+Infinity` / BigInt / symbol 长度按契约① 响亮拒(归 0 = 静默发一个空 deny),`NaN` / 负数 / `-Infinity`
133
+ 归 0(与原生发 `[]` 一致)。
134
+ 🔴 **判过就地重建,绝不放原引用出门**:只校不建是「校 A 发 B」——属性访问器能第一次(校)给
135
+ `['Write']`、第二次(`JSON.stringify`)给 `[]`;`toJSON` 也能第一次查是 `undefined`、第二次是函数。
136
+ **任何只读活对象的判据都拦不住这一类**,那不是判据写错了,是结构错了。所以校过的值逐层重建成新的
137
+ 字面结构、请求体带走**那一份**:每个属性只读一次,出门那份不带访问器 / 不带 `toJSON` / 不带原型 ——
138
+ 「校的就是发的」从承诺变成构造事实,且提交后端再改原对象也影响不到已构造的请求体。合法输入的字节
139
+ 与修前逐字相同(重建只丢 `JSON.stringify` 本来就会丢的东西)。重建**自己**也不许成为丢内容的那一环:
140
+ 数组按**捕获长度 + 下标**走(不调载体自带的 `entries` —— 一个覆盖了 `entries` 的 `['Write']` 能让重建
141
+ 产出 `[]`,而原生序列化照发 `["Write"]`)、record **键先捕获值逐个即读即建**(`Object.entries` 会把值
142
+ 先读齐,后一个 getter 就能清空前一个已交出的数组)、写回用 `defineProperty`(键可能是 `JSON.parse`
143
+ 造出的 `__proto__` **数据键**,`rec[k]=v` 会打到原型 setter 上:键丢 + 换原型)。
144
+ 同一条「即读即建」在**外层快照**那一圈也成立(否则内层修好了、外层照漏):`env`/`model` 位上的 getter
145
+ 能在 permissions 被重建前清空它;而 `permissions` 是**函数**载体时,不许被「函数值一律不进」那条
146
+ 静默吃掉(修前整键消失 = 权限变宽)—— 权限面的畸形只有一个诚实终态:拒。
147
+ 重建出来的 record 是**无原型**的(`Object.create(null)`):既忠实(源本来就允许无原型字典)、也少一条
148
+ 改写面(构造之后往 `Object.prototype` 装 `toJSON` 改写不到它)。⚠️ **边界如实**:重建出来的**数组**必须
149
+ 是真数组(`Array.isArray` 与序列化成 `[...]` 都靠 `Array.prototype`),所以「事后污染内建原型」这条在
150
+ 数组层**无解** —— 那种进程里每个对象、每个库都已不可信,不是本层射程。这半边同样有判据钉着现状
151
+ (数组层污染仍改写得到),真关掉那天来改那一条 = 变强,不是回归。
152
+ ⚠️ **同类边界之二**:`getPrototypeOf` 被 Proxy 陷阱撒谎的载体过得了载体判据(那个读法本身可陷阱化,
153
+ 而同 realm 内没有**可移植**的「这是不是 Proxy」读法 —— `util.types.isProxy` 是 Node 专有,本包三端共用
154
+ 不引 node 内建)。但它**不是本层新增的丢失面**:同一个 proxy 交给原生 `JSON.stringify` 也照样出 `{}`,
155
+ 即本层「发的字节 = 原生序列化那一份」这条不变量没破,丢的是那个 proxy 自己声称的东西;而一个**故意**
156
+ 撒谎的宿主本来就能直接传 `{}`,不需要这个花招。真要关这一类,得把归一化上移到「受信 resolver 出口发
157
+ **烙印/已物化**记录」的结构(= 上面那个包级规范化工单),不是在这里再加一条读法。两条边界都有判据
158
+ 钉着现状(不假装拦住了 + 零额外丢失),不是漏。
159
+ - **本位的契约明文**(三条按序,别指望同时最大化;源码头注与判据同文):① **载体形上 fail-closed,刻意
160
+ 比原生序列化严** —— 原生对 `Map`/类实例是**静默成功**(出 `{}`),那正是要堵的「悄悄发一份更宽的权限
161
+ 面」,所以「原生能发的我们拒了」在畸形载体上**是设计**;② 被接受的输入,**发出的字节 = 原生对该输入的
162
+ 序列化**,且读的次序照原生(`toJSON` 单次查询在捕获前、原型读在捕获后);③ 由 ①② 推出并明文认领的
163
+ 后果:若那次原生要求的 `toJSON` 读取本身把载体的**形**改了(访问器顺手换掉自己的原型),推后的形判据
164
+ 看到改后的形 ⇒ **拒** —— 序列化中途自我变形的载体就是坏产出,拒是响亮失败而非静默变宽。要让这一类也
165
+ 「照原生发」,得靠受信 resolver 出口发烙印/已物化记录(= 上面那个包级规范化工单)。三条各有判据钉着。
166
+ ⚠️ **射程刻意只到 `permissions`**:整份请求体的 JSON-safe 深净化仍是独立工单(自 0.34.0 在册)。
167
+ 边界理由 = `permissions` 是权限方向那一位,内容丢一条 = 会话权限变宽;`env`/`model` 之类配置位的
168
+ 同款畸形是功能缺失,会被人当场看见。该边界本身有一条判据钉着(`env` 的同款畸形**不拦**),要动就得
169
+ 改那条,不许悄悄漂。**其它带权限含义的位(`permissionMode` / `excludeTools` / 目录根族 / `agents` /
170
+ `hooks`)今天没有等价强制** —— 逐个挑两三个补只会造出下一个任意边界,故整批留给那个包级闸口工单,
171
+ 并已在 `docs/INTEGRATION-CLIENTS.md` **P-15d** 逐位点名登记(端别推断「本包会替我洗干净请求体」)。⚠️ 这一条**改写了 0.34.0 段里「脏快照一律降空对象」那句承诺**
172
+ (已发段是冻结面,故不回改那一段;以本段为准)。现网可达性:今天的宿主投影是白名单形、产不出
173
+ 畸形值 ⇒ 实际触发面是 JS 调用方与版本偏斜的宿主。非 live 车道不受影响(那份快照根本不摊开)。
174
+ - **`resumeAtMode` 补进矩阵合写行(F2,判据面)**:`REQUEST_FIELD_MATRIX` 的
175
+ `resumeAt/rewindFiles/rewindFilesTo` 行漏了第四键 —— `resumeAtMode` 是 `rewindWireCaps`
176
+ 的 `SeamRewindSpec` 真 wire 键,且 `rewindSpecForMode` 的 `both` / `conversation` 两臂**恒返**它。
177
+ 后果:真 `/rewind` 还原 turn 上 `unregisteredRequestKeys(req,'interactive')` 会点名
178
+ `resumeAtMode` —— 一条**误报**,而它教端去白名单化一个真键。行改
179
+ `resumeAt/resumeAtMode/rewindFiles/rewindFilesTo`,四键同生同灭;车道事实一字未动(`print` 车道
180
+ 出现这四键仍点名 —— `/rewind` 是交互命令)。
181
+ - **内部改名**(非公开面,零 API 影响):构造器侧的快照处理助手 `resolvedForLane` →
182
+ `resolvedSnapshotForWire`(剥离集合已与车道无关,旧名会误导;0.34.0 段里的旧名是历史记述)。
183
+ - **门**(流程面,零行为):`run-integration-doc-freshness-test` 的两阶段冻结账协议**阶段一落地** ——
184
+ ④b 的 (i) 与 (iii) 两条红(「刚 bump」这个状态先撞哪条取决于 CHANGELOG 段有没有带「(未发布)」
185
+ 标记,所以**两条都挂**)现在直接给出「bump 的**同一个 commit** 里插 `{ version, pending: true }` 行」
186
+ 的操作原文,`FROZEN` 头注补一条 bump 步骤义务。判据语义一字未改;修的是「红只陈述缺什么、不说
187
+ 怎么办」导致阶段一被拖到发布后补(0.33.0 / 0.34.0 两版实际都是先账后补)。本版是第一次照新协议走
188
+ (账上先挂 `pending: true`)。
189
+ ⚠️ **机器守住的是什么、没守住的是什么**(别把这条读成比它更强):门机械校的是「同一棵**工作树**里
190
+ version / CHANGELOG 段 / `pending` 账行三者齐备」(④a0 + ④b),以及指引原文不许烂掉(两条正控)。
191
+ 「三者落在**同一个 commit**」这一步是**文档义务**,不是机器判据 —— 要机器判就得回溯 git 史找
192
+ 「引入当前 version 的那个 commit」,而这道门恰恰要在**尚未提交**的状态下跑(发版批的常态),
193
+ history 判据在那一刻必然误红。宁可少声称,也不装一道会自己喊狼来了的门。
194
+ - 常驻门:`run-client-core-pure-test` B8 段 62 → 79 断言(零松量)。
195
+
19
196
  ## 0.34.0(2026-08-17)
20
197
 
21
198
  **#292 P1(cli 黑板 [4285]/[4309] 定谳)——`REQUEST_FIELD_MATRIX` 的 `settings.<resolved>` 行补
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.33.0
38
+ **Version:** 0.36.0
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -50,7 +50,56 @@ export interface ActiveRunPendingGate {
50
50
  * 证据」。消费方只做**在场才说**的加法:纯归因一句,不参与任何分诊/动作。
51
51
  */
52
52
  readonly governanceForced?: true;
53
+ /**
54
+ * 这道 park 门的**非秘密稳定身份**(#285 件2,0.36.0;server ≥7.33.0 `PendingGateMaterial.checkpointId`
55
+ * / core 5.42.0 `Checkpoint.checkpointId`,`mintCheckpointId` 铸 = `cp_`+uuid)。逐字过境,本层零加工。
56
+ *
57
+ * 🔴 **它补的是一个真实的空洞**:park 门此前在 wire 上**没有任何稳定身份** —— 唯一单射的键是
58
+ * `token`,而 token 是秘密能力、永不上 wire,于是消费端只能拿 `(sessionId, kind)` 这种非单射组合
59
+ * 去猜「这次的门」和「上次的门」是不是同一道。本键是为此存在的非秘密孪生身份(identification vs
60
+ * capability,两条独立的轴;**独立铸造,不是 token 摘要**)。
61
+ *
62
+ * 🔴 **缺席是三成因合流的一个形**(server 逐条成文,别只记第一条):① 该键诞生前 park 的 legacy 行;
63
+ * ② 行上有值但没过 server 的信任边界校验(空串/超长/**含 token**)⇒ 数据污染,而它在运维面上
64
+ * 与 ① **不可分辨**(server 自己登记的残留);③ 整只 `pendingGate` 读取失败(那时本键连位置都没有)。
65
+ * ⇒ 读作「**不知道这道门叫什么**」,**不是**「这道门没有身份」,更**不是**「这一定是条老行」。
66
+ *
67
+ * 🔴 **不是跨调用的稳定去重键**(逐条出自 server 侧 `PendingGateMaterial.checkpointId` 的顶注 ——
68
+ * 那段是上游自己的对抗复审验真后如实登记的,不是本包的推测):
69
+ * 同 session 可并存两条 pending checkpoint(表上**没有** per-session 唯一约束),
70
+ * 而 server 选行是**无序 `LIMIT 1`** ⇒ 同一情形连续两次 409 完全可能报出**不同**的 id(选中了另一行),
71
+ * 也可能 id 不变而 `activeTaskId` 已换。本材料的**每一格**(kind/decidePath/governanceForced/本键)
72
+ * 共享这条归属不确定性,本键是**继承**它、不是引入它。
73
+ * ⇒ 可安全用于:落日志/排障关联,以及 {@link pendingGateIsProvablyDifferent} 那个**单向**判断。
74
+ * **不可**用于:跨 turn 的卡去重键、「id 相同 ⇒ 同一道门」的反向推理。
75
+ *
76
+ * ⚠️ **辖域如实**:上游本批只把这一位投到**这一个载体**(409 `conflict.session_active_run` 体 +
77
+ * 其 SSE done 帧)。另外三条 park 读面(`/v1/assistant/inbox`、`GET /v1/approvals`、
78
+ * `/v1/approvals/stream`)今天**都没有**这一格,且补它们的代价不同(后两条要给 checkpoint 表反范式
79
+ * 一列 = SQL 面双库门)。⇒ **别在消费码里预支「两面能对上」** —— 拿本键去 join durable 队列行必然
80
+ * 落空,而落空与 legacy 行同形。
81
+ */
82
+ readonly checkpointId?: string;
53
83
  }
84
+ /**
85
+ * `pendingGate` 的**单向**同一性判断(#285 件2,0.36.0):「我现在看到的这道门,能不能**证明**它
86
+ * 不是刚才那一道?」
87
+ *
88
+ * `true` ⇔ 两侧都带 {@link ActiveRunPendingGate.checkpointId} **且**两者不等。其余一律 `false`。
89
+ *
90
+ * 🔴 **`false` 的语义是「证不出」,不是「同一道门」** —— 这不是措辞洁癖,是上游写死的可用性边界:
91
+ * server 选行是无序 `LIMIT 1` 且同 session 可并存多条 pending 行,所以「id 相同」推不出「同一道门」
92
+ * (id 不变而 `activeTaskId` 已换是成文的可能形),「id 缺席」更只是「不知道它叫什么」(三成因合流)。
93
+ * 上游明确只背书**这一个方向**:「id 变了 ⇒ 我看到的确实不是刚才那一行」。
94
+ *
95
+ * 🔴 **为什么归包而不是让三端各写一遍**:这个谓词的价值全在**它拒绝回答的那一半**。三端各自手搓时,
96
+ * 「两个 id 相等就当同一张卡去重」是最自然、也最容易写出来的一行 —— 而它恰恰是上游明说买不到的那个
97
+ * 方向,写出来就是一条按错误前提去重的卡链。把可答的那半做成唯一入口,不可答的那半就没有顺手的写法。
98
+ *
99
+ * ⚠️ 它**不做**任何 kind/decidePath 比较:那两位回答的是「哪种门/去哪决议」,与「是不是同一行」正交
100
+ * (同一行的 kind 不会变,不同行的 kind 完全可能相同)。要判种类变化请直接读 `kind`。
101
+ */
102
+ export declare function pendingGateIsProvablyDifferent(prev: ActiveRunPendingGate | null | undefined, next: ActiveRunPendingGate | null | undefined): boolean;
54
103
  /** #114/[C65] 单源化:三端共用的 409 active-run 拒收终帧判别(富信号形,包级唯一权威)。
55
104
  * 🔧 2026-08-02 事实纠正([C77]③):原文写的是「与壳/desktop **各自**照抄件同义……各端换包
56
105
  * 导入删本地抄件」,而 desktop 全树扫描(`activeRunBusySignal` / `session_active_run` /
@@ -10,6 +10,32 @@ import { coerceOutput, publishSubagentContentEvent } from '../subagentContentSto
10
10
  import { ACTIVE_RUN_BUSY_ERROR_CODE, OUTPUT_INVALID, isLimitsExceededCode } from '../engineErrorCodes.js';
11
11
  /** 本文件发的 chrome 事件全在 leader lane(子代内容在上面就被 divert 走了)。 */
12
12
  const MAIN = { lane: 'main' };
13
+ /**
14
+ * `pendingGate` 的**单向**同一性判断(#285 件2,0.36.0):「我现在看到的这道门,能不能**证明**它
15
+ * 不是刚才那一道?」
16
+ *
17
+ * `true` ⇔ 两侧都带 {@link ActiveRunPendingGate.checkpointId} **且**两者不等。其余一律 `false`。
18
+ *
19
+ * 🔴 **`false` 的语义是「证不出」,不是「同一道门」** —— 这不是措辞洁癖,是上游写死的可用性边界:
20
+ * server 选行是无序 `LIMIT 1` 且同 session 可并存多条 pending 行,所以「id 相同」推不出「同一道门」
21
+ * (id 不变而 `activeTaskId` 已换是成文的可能形),「id 缺席」更只是「不知道它叫什么」(三成因合流)。
22
+ * 上游明确只背书**这一个方向**:「id 变了 ⇒ 我看到的确实不是刚才那一行」。
23
+ *
24
+ * 🔴 **为什么归包而不是让三端各写一遍**:这个谓词的价值全在**它拒绝回答的那一半**。三端各自手搓时,
25
+ * 「两个 id 相等就当同一张卡去重」是最自然、也最容易写出来的一行 —— 而它恰恰是上游明说买不到的那个
26
+ * 方向,写出来就是一条按错误前提去重的卡链。把可答的那半做成唯一入口,不可答的那半就没有顺手的写法。
27
+ *
28
+ * ⚠️ 它**不做**任何 kind/decidePath 比较:那两位回答的是「哪种门/去哪决议」,与「是不是同一行」正交
29
+ * (同一行的 kind 不会变,不同行的 kind 完全可能相同)。要判种类变化请直接读 `kind`。
30
+ */
31
+ export function pendingGateIsProvablyDifferent(prev, next) {
32
+ const a = prev?.checkpointId;
33
+ const b = next?.checkpointId;
34
+ // 任一侧缺席 ⇒ 证不出(缺席 = 不知道这道门叫什么,三成因合流;绝不当成「不同」)。
35
+ if (typeof a !== 'string' || typeof b !== 'string')
36
+ return false;
37
+ return a !== b;
38
+ }
13
39
  /**
14
40
  * 判别一个**原始 AgentEvent** 是不是 409 active-run 拒收终帧;非 busy ⇒ null。
15
41
  * 判据=结构两腿(canonical `errorCode` 优先 → `activeTaskId` 在场);人话文案腿已随 #117 提货
@@ -51,10 +77,18 @@ export function activeRunBusySignal(ev) {
51
77
  // 出身位(A-028.1):**只认严格 true**。任何别的形(false / 'true' / 1 / 缺席)一律不置键 ——
52
78
  // 键在场即渲徽标类文案,把一个含糊值读成「治理强制」= 对用户下一个证不出的断言。
53
79
  const g = rawGate.governanceForced;
80
+ // 身份位(#285 件2):**非空串才置键**,坏形一律降缺席 —— 与 kind/decidePath 同族口径。
81
+ // 🔴 **刻意不做 `cp_`+UUID 格式正则、也不叠长度上限**,与 server 铸点同裁:上游亲跑验过,严格
82
+ // 格式钉**买不到安全性**(32 位 hex token 按 uuid 分组后逐字满足该正则,剥掉装饰就是完整能力键),
83
+ // 只额外买来「core 换形我方静默丢键」的代价;长度域已在 server 投影处夹取,客户端再叠一道
84
+ // **更窄**的域 = 上游放宽当天我方静默丢键,而丢键与 legacy 行在运维面上不可分辨。
85
+ // 窄读域只许等于或宽于铸点域([4050] 随批立的纪律)。
86
+ const cp = rawGate.checkpointId;
54
87
  pendingGate = {
55
88
  kind: typeof k === 'string' && k.length > 0 ? k : null,
56
89
  decidePath: typeof d === 'string' && d.length > 0 ? d : null,
57
90
  ...(g === true ? { governanceForced: true } : {}),
91
+ ...(typeof cp === 'string' && cp.length > 0 ? { checkpointId: cp } : {}),
58
92
  };
59
93
  }
60
94
  return {
@@ -268,6 +268,28 @@ export interface ApprovalCardRequest {
268
268
  * UNTRUSTED-for-display:只渲染,绝不回喂模型/工具入参。
269
269
  */
270
270
  ruleEvidence?: unknown;
271
+ /**
272
+ * 安全类 ask 出身位(#283,0.36.0;server ≥7.33.0 / core 5.37)——原样来自
273
+ * {@link ToolApprovalFrame.requiresRealApproval}(**单源:活卡帧腿**;durable 行腿今天无对偶,
274
+ * 见帧上同名键的 JSDoc 末段)。在场 = 这只 ask **拒绝 blanket-allow**:一切自动放行必须让位
275
+ * (含记住的规则、`allow_session`、bypass 姿态)——那正是[4390]③ 预埋壳侧读器时写死的消费语义。
276
+ * 缺席 = 这不是一次安全类 ask / 老 server(两者同形,不猜),卡形与 0.35.0 字节不变。
277
+ *
278
+ * 🔴 **缺席绝不折成 `false`**(类型 `true` 而非 `boolean`,与 {@link governanceForced} 同族纪律):
279
+ * 折成 false 会把「没有证据」渲成「已确认这是普通 ask」,而这一位的整个存在理由就是不让自动放行
280
+ * 在安全类门上悄悄生效。
281
+ * 🔴 **与 {@link governanceForced} 分键不合流**:那位答「门是运维治理层下的」,本位答「引擎侧安全
282
+ * 策略/hook 判这门必须真人过目」。两句话对用户的下一步建议不同(改不改 permissionMode),
283
+ * 合并即谎报出身。
284
+ * 🔴 **展示 + 放行闸,不是裁决输入**:它不改变本次决断的任何字节,只约束宿主**自动**决断的资格;
285
+ * 人真按下的 allow 仍是 allow。
286
+ * 🔴 **分工写死,别把「包搬运了」读成「包挡住了」**:本包只保证这一位**到得了卡口**,
287
+ * 它**不**改写、不否决、也不收窄你回给 wire 的决断动词 —— `surfaceToolApprovalFrameAndRespond`
288
+ * 对 {@link ApprovalCardDecision} 的映射与 0.35.0 逐字节相同。⇒ 「在场时不要走自动放行」「在场时
289
+ * 卡上要不要还给人一个会话级选项」这两条策略**属于宿主**,包不替你执行;要它变成包级强制,是一次
290
+ * **行为面**改动(会改到人已按下的决断的字节),按宪法三问单独立项,不在本过境批内。
291
+ */
292
+ requiresRealApproval?: true;
271
293
  }
272
294
  /**
273
295
  * 🔴 **拆缝口** —— 弹「三选卡」并等人的决断。壳 = vendored CC `PermissionRequest`;
@@ -433,6 +455,30 @@ export interface ToolApprovalFrame {
433
455
  * 端呈前消毒,只渲染绝不回喂模型/工具入参。
434
456
  */
435
457
  ruleEvidence?: unknown;
458
+ /**
459
+ * server ≥7.33.0(#283,core 5.37 起铸;**ADDITIVE**,`"tool_approval"` only。来源锚 = engine fixture
460
+ * `@sema-agent/server/dist/tool-approval.d.ts` 的同名键)——这只 ask 是一次**安全类** ask:
461
+ * 它**拒绝 blanket-allow**。出身是 core 自己的两条安全策略(`createUnverifiableDeletePolicy` /
462
+ * `createTranscriptIntegrityPolicy`)与 PreToolUse hook 族在 `AskRequest.requiresRealApproval` 上铸的位。
463
+ *
464
+ * 🔴 **契约 = 真才带,缺席绝不编 `false`**({@link governanceForced} 同形,故类型是 `true` 而不是
465
+ * `boolean`):缺席 = 这不是一次安全类 ask —— 那是**正常的否定形**,不是坏形、也不是「老 server」的
466
+ * 同义词(两者今天同形,不猜)。
467
+ * 🔴 **与卡内那一位的在场契约刻意不同、别混**(server `ApprovalCardSchema` 顶注逐字):durable
468
+ * `ApprovalCard.risk.requiresRealApproval` 是**恒在布尔**(投影层已把缺席按 `=== true` 归一化),
469
+ * 而本键走帧顶层、只在为真时在场。⇒ 「帧上没有」与「卡上是 false」是两条不同的陈述,别互相推导。
470
+ * 🔴 **与 {@link governanceForced} 刻意分列不合并**:那个键答「门是谁下的」(运维治理层
471
+ * AUTONOMY/commandPolicy/守卫集),本键答「这门为什么掀不掉」(引擎侧安全类策略/hook 出身)。
472
+ * 合并会让任一方谎报出身 —— 而消费端对这两句话的正确反应不同:治理位该劝「别去改
473
+ * permissionMode」,本位该劝「这一次必须真人过目,记住的规则与 bypass 姿态都让位」。
474
+ * 🔴 本键**领先** SDK 运行期锚一代(sdk 7.1.0 的 `TOOL_APPROVAL_FRAME_KEYS` 尚无)⇒ 对账门
475
+ * (run-approval-frame-keys-test.mjs)AHEAD_OF_ANCHOR 带退出条件登记,#144 `persistedRuleShadowed`
476
+ * 与 #280 `probeCause` 同形先例:SDK 锚补上当天登记自红逼删。
477
+ * 耐久路今天**无对偶**(sdk 7.1.0 的 `PendingCheckpoint` / `riskDescriptor` 均未声明本键;server 侧
478
+ * 那份在**卡内** `risk` 子树,不是行上的键)⇒ durable 行 → 卡那条腿不 stamp,行为钉在
479
+ * run-durable-card-display-keys-test.mjs ⑨ 段;上游补位后按 {@link probeCause} 的双源合流形跟批。
480
+ */
481
+ requiresRealApproval?: true;
436
482
  }
437
483
  /** {@link ToolApprovalFrame.delegation} 的形(命名形,不用内联匿名 —— typeshape 门 B4 棘轮口径)。 */
438
484
  export interface ToolApprovalDelegation {
@@ -448,7 +494,7 @@ export interface ToolApprovalDelegation {
448
494
  * `TOOL_APPROVAL_FRAME_KEYS` 比对——SDK additive 增键时对账当天红,不再人肉追平。
449
495
  * 下面两个类型钉保证镜像与 interface 本身不可能漂移(少键/多键都是编译错)。
450
496
  */
451
- export declare const TOOL_APPROVAL_FRAME_KEYS_MIRROR: readonly ["type", "approvalId", "toolCallId", "toolName", "sourceTaskId", "fromSubagent", "sourceAgentName", "message", "args", "argsOmitted", "governanceForced", "ruleSuggestions", "persistedRuleShadowed", "probeCause", "ruleEvidence", "delegation", "outcome"];
497
+ export declare const TOOL_APPROVAL_FRAME_KEYS_MIRROR: readonly ["type", "approvalId", "toolCallId", "toolName", "sourceTaskId", "fromSubagent", "sourceAgentName", "message", "args", "argsOmitted", "governanceForced", "ruleSuggestions", "persistedRuleShadowed", "probeCause", "ruleEvidence", "requiresRealApproval", "delegation", "outcome"];
452
498
  /** 子代帧判别:显式键 fromSubagent(core 1.378 RB-39②)优先;缺席退 sourceTaskId 在场性权宜式
453
499
  * (server 1.258 [1549]①3,旧代际兼容)。 */
454
500
  export declare function isFromSubagent(frame: ToolApprovalFrame): boolean;
@@ -370,6 +370,11 @@ export const TOOL_APPROVAL_FRAME_KEYS_MIRROR = [
370
370
  // ⚠️ 与上面两例**不同形**:sdk 7.1.0 的运行期锚**已经含**本键(node 直读实证)⇒ 这是一次**追平**,
371
371
  // 不是领先,故**不**进 AHEAD_OF_ANCHOR(往那张表里塞一个锚已有的键,它的第二条退出条件当场红)。
372
372
  'ruleEvidence',
373
+ // #283(client-core 0.36.0):server 7.33.0 起真发 `requiresRealApproval`(core 5.37 铸的安全类 ask
374
+ // 出身位,投到帧顶层)。⚠️ 与 `ruleEvidence` **不同形**、与 `persistedRuleShadowed`/`probeCause` 同形:
375
+ // sdk 7.1.0 的运行期锚**尚无**本键(node 直读实证:锚 17 项)⇒ 这是一次**领先**,进对账门的
376
+ // AHEAD_OF_ANCHOR 带退出条件登记(SDK 追平当天那条登记自红逼删,回到逐元素相等)。
377
+ 'requiresRealApproval',
373
378
  'delegation',
374
379
  'outcome',
375
380
  ];
@@ -617,6 +622,11 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
617
622
  // 🔴 呈现谓词归端(无治理部署下每只 ask 都带全具名缺席,[4050] live 实测 / [4051] 定性);
618
623
  // 本层若替端做「全 not_wired 就别给了」的过滤,就是替引擎把一条真事实湮灭掉。
619
624
  ...(isWireRecordCarrier(frame.ruleEvidence) ? { ruleEvidence: frame.ruleEvidence } : {}),
625
+ // #283(0.36.0):安全类 ask 出身位透传([4390]③ 请托的正位解 —— 壳侧读器早已预埋,缺的一直是
626
+ // 包边界这一格)。条件 stamp **只认严格 true**,与 governanceForced 同一条纪律:缺席的语义是
627
+ // 「这不是一次安全类 ask」,把它折成显式 false 会让宿主把「没有证据」读成「已确认可自动放行」,
628
+ // 而本键存在的全部理由正是在这种门上把自动放行(记住的规则 / allow_session / bypass 姿态)拦下。
629
+ ...(frame.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
620
630
  });
621
631
  const decision = card.kind === 'allow' ? (card.allowSession ? 'allow_session' : 'allow') : 'deny';
622
632
  if (card.kind === 'failed') {
@@ -173,7 +173,7 @@ export type BuiltTaskRequest = TaskRequest;
173
173
  * 🔴 开放集**不是**整只 `settings` 免检(#292 P1 codex 复审第二轮 [medium] 采纳):表里点名了、
174
174
  * 而 lanes 不含本车道的子键(如 print 车道的 `settings.ultracode`)照旧**点名** —— 那些键是
175
175
  * **可枚举的已知量**,放过它们等于让「ultracode 仅 interactive」这条登记在真 wire 面失效。
176
- * `buildTaskRequest` 内的 `resolvedForLane` 只管住构造器自己那一份;端在构造之后仍能往请求体上
176
+ * `buildTaskRequest` 内的 `resolvedSnapshotForWire` 只管住构造器自己那一份;端在构造之后仍能往请求体上
177
177
  * 塞键(print lane 修前正是「自己另拼一份」),而本函数的整个存在理由就是替**真上 wire 的那份**
178
178
  * 过表。两道门缺一不可。
179
179
  *
@@ -59,7 +59,11 @@ export const REQUEST_FIELD_MATRIX = [
59
59
  { field: 'reasoningEffort', lanes: ['interactive'], live: false, why: '`/effort` 拨盘存在 AppState,print 无 AppState', gap: true },
60
60
  { field: 'model', lanes: ['interactive'], live: false, why: '`/model` 中途换模型读 toolUseContext.options.mainLoopModel;print 的模型走 MODEL_ID/--model 另一条路', gap: true },
61
61
  { field: 'images', lanes: ['interactive'], live: false, why: '贴图提交是交互动作,`-p` 的 stdin 没有图片块' },
62
- { field: 'resumeAt/rewindFiles/rewindFilesTo', lanes: ['interactive'], live: false, why: 'E18 `/rewind` 是交互命令' },
62
+ // ⚠️ 四键一条(0.34.1 补第四键 `resumeAtMode`):它是 `rewindWireCaps` `SeamRewindSpec` wire 键,
63
+ // 且 `rewindSpecForMode` 的 `both` / `conversation` 两臂**恒返**它(排他截语义 —— 回到目标消息
64
+ // **之前**)。修前合写项只列三键 ⇒ 真 `/rewind` 还原 turn 上 `unregisteredRequestKeys` 会点名
65
+ // `resumeAtMode`,而那正是「本层不许有未登记键」这条判据的误报,会教端去白名单化真键。
66
+ { field: 'resumeAt/resumeAtMode/rewindFiles/rewindFilesTo', lanes: ['interactive'], live: false, why: 'E18 `/rewind` 是交互命令;四键同生同灭(resumeAtMode 是 both/conversation 两臂恒带的排他截语义位)' },
63
67
  { field: 'clientContext', lanes: ['interactive'], live: false, why: 'IANA 时区 + 可选邮箱 → core 本地化 `# Environment` 的 today;print 同样跑在用户机器上,缺席让引擎误标 (UTC)', gap: true },
64
68
  { field: 'scratchpadDir', lanes: ['interactive'], live: true, why: '[816]③ per-session 暂存目录;print 也有 session,缺席让 `-p` 的工具写不进 exemptDir', gap: true },
65
69
  ];
@@ -80,50 +84,304 @@ const laneHas = (field, lane) => REQUEST_FIELD_MATRIX.find(f => f.field === fiel
80
84
  /**
81
85
  * 本车道**不拥有**的具名 settings 子键(= 表里有 `settings.<sub>` 行、而该行的 lanes 不含本车道)。
82
86
  *
83
- * 为什么要有(#292 P1 codex 对抗复审 [medium] 采纳):`settings.<resolved>` 是**开放集 spread**
84
- * —— 快照里的子键原样就是 wire 键。所以一旦某条车道能摊开快照,它就能借快照绕过那条车道
85
- * **专属**子键的 stamp 门:`resolved:{ultracode:true}` print 车道会真的生成
86
- * `settings.ultracode:true`,而表里那一行写的是 interactive 独有。更坏的是它**不会被点名** ——
87
- * 修前更坏的是它**不会被点名**(开放集口径曾对整只 `settings` 放行);现在 `unregisteredRequestKeys`
88
- * 对「表内异车道子键」照旧点名,两道门配对成立 —— 但构造器这一道仍不可省:端在构造之后还能塞键,
89
- * 而构造器这一道管的是「本包自己产的那份不许含异车道键」。
90
- * 「表说了算,不是调用方说了算」要成立,就必须对**两条通道**都成立:具名通道过 `on()`,
91
- * 开放集通道过本函数。今天的端(cli `toWireSettings` 是白名单投影)不产 ultracode,所以这是
92
- * **潜在**旁路而不是现网缺陷 —— 但下一条车道专属子键落表时,它就是现网缺陷。
87
+ * 用处只有一个:{@link unregisteredRequestKeys} 的判据面。`settings.<resolved>` 是**开放集 spread**
88
+ * —— 快照里的子键原样就是 wire 键,所以那条车道的 `settings` 走开放集口径(表外子键放行)。但
89
+ * 开放集**不是**整只 `settings` 免检:一个「表里点名了、却不属于本车道」的子键(今天 = print
90
+ * `settings.ultracode`)是**可枚举的已知量**,放过它等于让那条登记在真 wire 面失效。反过来,本车道
91
+ * **自己**那些具名子键合法出现在 wire 上(它们从具名通道来),所以本集合按车道排除,不含它们。
93
92
  *
94
- * 🔴 只剥「表里点名了、且不属于本车道」的键:表外子键(快照自己的 permissions/env/model/…)
95
- * 原样摊开(那是开放集的全部意义),`outputStyle` 无车道行(live 兜底层的位)故不受影响。
93
+ * 🔴 构造器侧用的是另一个集合({@link namedSettingsSubKeys},全集)—— 两个集合不是同一件事,别合并:
94
+ * 判据问的是「这个键该不该出现在 wire 上」,构造器问的是「这个键该由哪条通道决定」。
96
95
  */
97
96
  const foreignSettingsSubKeys = (lane) => REQUEST_FIELD_MATRIX.filter(f => f.field.startsWith('settings.') && f.field !== 'settings.<resolved>' && !f.lanes.includes(lane)).map(f => f.field.slice('settings.'.length));
98
97
  /**
99
- * 快照 摊开前先剥掉本车道不拥有的具名子键(见 {@link foreignSettingsSubKeys})。
98
+ * **具名通道管着的**全部 settings 子键 —— 表里凡有 `settings.<sub>` 行的都算,**不分车道**。
100
99
  *
101
- * 🔴 非对象一律降空对象(#292 P1 codex 复审第二轮 [medium] 采纳):型面写的是
102
- * `Record<string, unknown> | undefined`,但本包是**发出去的 npm 公开面** —— JS 调用方、版本偏斜的
103
- * 宿主、以及「端把 null 当空快照传」的老写法都到得了这里。修前那一版只判 `undefined`,`null` 会
104
- * 走到 `Object.entries(null)` 当场 TypeError ⇒ headless 请求在发出**之前**就崩,而交互车道不崩 =
105
- * 新造一条两车道差异。旧式 `?? {}` 对 null 是容忍的,这里把容忍面扩到「一切非对象」(降空 =
106
- * 不 stamp = wire 上最小的诚实形;绝不把一个字符串 spread 成 `{0:'a',1:'b'}` 那种垃圾键)。
100
+ * {@link foreignSettingsSubKeys} 的分工(两个集合,两件事):
101
+ * · foreign(**异车道**子集)= 判据面用 —— wire 上出现一个「表里点名了、却不属于本车道」的
102
+ * 子键要被点名;本车道自己那些具名子键**合法**出现在 wire 上(它们从具名通道来)。
103
+ * · named(**全集**)= 构造器面用 —— 快照通道对具名通道管着的键**一律让位**,理由见下。
107
104
  */
108
- const resolvedForLane = (resolved, lane) => {
109
- if (resolved === null || resolved === undefined || typeof resolved !== 'object')
105
+ const namedSettingsSubKeys = () => REQUEST_FIELD_MATRIX.filter(f => f.field.startsWith('settings.') && f.field !== 'settings.<resolved>').map(f => f.field.slice('settings.'.length));
106
+ /** 快照里**权限方向**那一位的键名(唯一享有「摊得全 + 就地重建」强制的位;射程理由见头注 ③)。 */
107
+ const PERMISSION_FACE_KEY = 'permissions';
108
+ /**
109
+ * 形状描述(错误文本用):只报**形**,一个字节的内容都不带(快照里有 env / 权限面)。
110
+ *
111
+ * 载体名(prototype 的 constructor 名)是代码标识符不是用户数据,可以报 —— 它是「你传进来的是个
112
+ * 什么东西」这个问题唯一有用的线索。取值整段包在 catch 里:proxy / 带副作用 getter 的载体能让
113
+ * `toString` 与 `constructor` 都抛错,而**构造错误文本时抛错**会把「拒」这个判决换成一个面目全非的
114
+ * 异常。判决是拒,描述只是线索,取不到就报 unreadable。
115
+ */
116
+ const shapeTag = (v) => {
117
+ try {
118
+ const proto = Object.getPrototypeOf(v);
119
+ const carrier = proto === null
120
+ ? 'null-prototype'
121
+ : String(proto?.constructor?.name ?? 'anonymous');
122
+ return `typeof=${typeof v} / ${Object.prototype.toString.call(v)} / prototype=${carrier}`;
123
+ }
124
+ catch {
125
+ // 形状取不到 ⇒ 只报 typeof(拒本身已经成立,描述缺一半不影响判决)。
126
+ return `typeof=${typeof v} / shape=unreadable`;
127
+ }
128
+ };
129
+ /**
130
+ * plain object 判别 —— 按 **prototype** 判,不按 `Object.prototype.toString` 的标签判。
131
+ *
132
+ * 🔴 标签判法是**假的**(0.34.1 codex 对抗复审 [high] 采纳):`[object Object]` 对**普通类实例**同样
133
+ * 成立,而 `Symbol.toStringTag` 还能让任意载体自报这个标签。放它过去之后,下面的摊开用的是
134
+ * `Object.entries`(**只取自有可枚举键**)—— 于是一个把 `permissions.deny/ask` 挂在**原型 getter**
135
+ * 上的载体会摊出一个**空快照**,请求照发 = 权限静默变宽,正是本节要堵的那个 fail-open 形。
136
+ *
137
+ * 所以合法载体只有两种:**对象字面量**(原型链只有一层)与 `null` 原型(`Object.create(null)`,常见于
138
+ * 「无污染字典」写法)。两者的自有可枚举键集**就是**全部内容,`Object.entries` 不会漏。类实例 /
139
+ * `Map` / `Date` / boxed 包装对象 / 数组一律拒 —— 它们要么内容不在自有键上,要么摊开就是垃圾键;
140
+ * 「上游产出坏了」比「悄悄发一个更宽的权限面」更该被人看见。
141
+ *
142
+ * 🔴 判据 **realm 无关**(0.34.1 codex 对抗复审第八轮 [medium] 采纳):不拿「**本** realm 的
143
+ * `Object.prototype`」做身份比较 —— iframe / `node:vm` / 另一个渲染进程里的对象字面量各有**自己的**
144
+ * `Object.prototype`,身份比较会把这些**完全合法、JSON 忠实**的载体误判成异形,在 web / desktop 宿主
145
+ * 上变成提交前的硬失败。
146
+ *
147
+ * 🔴 但「realm 无关」不等于「按原型链**层数**判」(第九轮 [medium] 采纳):`Object.create(X)`(X 是个
148
+ * 自造的**无原型**对象)与 `class … extends null` 的实例都只有一层,却能把 `deny` 挂在那层**继承**位上
149
+ * —— 而摊开只看自有键(`JSON.stringify` 也只看自有键),于是规则整条不见。所以判的是「直接原型是不是
150
+ * **某个 realm 的**内建 `Object.prototype`」:取那层原型的**自有** `constructor`,要求它是个函数且
151
+ * 源码文本与本 realm 的 `Object` 逐字相同(内建函数的源码是 `function Object() { [native code] }`,
152
+ * 跨 realm 一致、而 `Map`/自定义类各不相同)。自造原型没有自有 `constructor` ⇒ 拒。
153
+ */
154
+ const isPlainRecord = (v) => {
155
+ if (typeof v !== 'object' || v === null)
156
+ return false;
157
+ const proto = Object.getPrototypeOf(v);
158
+ if (proto === null)
159
+ return true; // Object.create(null) 无污染字典:自有键就是全部内容
160
+ if (typeof proto !== 'object')
161
+ return false;
162
+ if (!Object.prototype.hasOwnProperty.call(proto, 'constructor'))
163
+ return false;
164
+ const ctor = proto.constructor;
165
+ if (typeof ctor !== 'function')
166
+ return false;
167
+ if (Function.prototype.toString.call(ctor) !== Function.prototype.toString.call(Object))
168
+ return false;
169
+ // 🔴 还要求「这个原型**就是**那个构造器的 prototype」(第十轮 [high] 采纳):只比源码文本可以被
170
+ // **冒充** —— 自造一个无原型对象、给它塞 `constructor: Object`,源码文本自然对得上,规则却挂在
171
+ // 它的继承位上。真 `Object.prototype`(任何 realm)恒满足 `Object.prototype === proto`,冒充者不满足。
172
+ return ctor.prototype === proto;
173
+ };
174
+ const materializeJsonFaithful = (v, path, seen = new Set()) => {
175
+ if (v === null)
176
+ return { value: null };
177
+ const t = typeof v;
178
+ if (t === 'string' || t === 'boolean')
179
+ return { value: v };
180
+ if (t === 'number') {
181
+ return Number.isFinite(v) ? { value: v } : { bad: `${path}(非有限数 ⇒ 序列化成 null)` };
182
+ }
183
+ // function / symbol / bigint / undefined(数组元素形)都不是 JSON 值
184
+ if (t !== 'object')
185
+ return { bad: `${path}(typeof=${t} 不是 JSON 值)` };
186
+ const o = v;
187
+ if (seen.has(o))
188
+ return { bad: `${path}(循环引用)` };
189
+ // 🔴 读的**次序**要跟原生 `JSON.stringify` 一致,而它只在两件事上有定义(第十三轮 [high] 采纳):
190
+ // ① 它**先查一次 `toJSON`**、再枚举内容 —— 所以这一查必须排在捕获**之前**,而且只查一次
191
+ // (`toJSON` 可以是个带副作用的**访问器**:实测一个「push 一条规则再返回 undefined」的 getter
192
+ // 能让原生序列化出 `{"deny":["Write"]}`,我们若晚查一步就会发 `{"deny":[]}` = 比原生更宽);
193
+ // ② 它**从不读原型** —— 所以 `isPlainRecord` 那一读只能排在捕获**之后**(见下:那一读可被陷阱化
194
+ // 并带副作用,排在前面就是本层自己引入的丢失面)。
195
+ // 一句话:**原生会做的读,按它的次序做;原生不做的读,一律推到捕获之后。**
196
+ const toJSONProp = o.toJSON;
197
+ if (typeof toJSONProp === 'function')
198
+ return { bad: `${path}(自报 toJSON ⇒ 字节被改写)` };
199
+ seen.add(o);
200
+ try {
201
+ if (Array.isArray(o)) {
202
+ // 🔴 **不调载体自己的实例方法**(第五轮 [high]):`o.entries()` 是可覆盖的 —— 一个 `['Write']`
203
+ // 自带一个什么都不产的 `entries` 就能让本函数重建出 `[]`,而原生 `JSON.stringify` 照发
204
+ // `["Write"]`:那样**重建自己**成了丢规则的那一环。改按捕获长度 + 数字下标走
205
+ // (`Array.isArray` 与 `length` 不经用户方法),与 stringify 读数组的方式一致。
206
+ // 🔴 长度**只强制转换一次**,且按真正的 `ToLength` 边界(第十五轮 [high] + 第十六轮 [high]):
207
+ // ① 存下来的可能是个**对象**(Proxy 的 get 陷阱能让 `length` 返回带 `valueOf` 的对象),
208
+ // 于是 `i < len` 每轮都重新求值 —— 一个「第一次给 3、以后给 1」的 length 能让原生发三条规则、
209
+ // 我们只发一条 = 掉规则。原生走 `ToLength(Get(O,'length'))` **一次**,这里也只转一次。
210
+ // ② `!isFinite ⇒ 0` 是**错的**:实测原生对 `length: Infinity` 抛 `RangeError`、对 BigInt 抛
211
+ // `TypeError`(ToNumber 不接 BigInt),而归 0 会让我们**静默发一个空 deny** = 权限变宽。
212
+ // 两形按契约① 响亮拒。`NaN` / 负数 / `-Infinity` 归 0 才是与原生一致的(原生也发 `[]`)。
213
+ const rawLen = o.length;
214
+ if (typeof rawLen === 'bigint' || typeof rawLen === 'symbol') {
215
+ return { bad: `${path}(length 是 ${typeof rawLen} ⇒ 原生序列化直接抛,归 0 会静默发空表)` };
216
+ }
217
+ // 🔴 转换要用**抽象 ToNumber**,而 `Number(x)` 不是它(第十七轮 [high],实测):`Number()` 接受
218
+ // **对象强制出来的 BigInt**(`{valueOf(){return 0n}}` / `Object(0n)` / `Symbol.toPrimitive→0n`
219
+ // 都得 0),而原生 `JSON.stringify` 在同样输入上抛 `TypeError` ⇒ 归 0 就成了「静默发空 deny」。
220
+ // 一元 `+` **就是** ToNumber:BigInt / symbol 一律抛,接住它翻成本层的「拒」。只转一次不变。
221
+ let lenNum;
222
+ try {
223
+ lenNum = +rawLen;
224
+ }
225
+ catch {
226
+ // ToNumber 抛(BigInt / symbol,含对象强制形)⇒ 原生也抛,拒。
227
+ return { bad: `${path}(length 按 ToNumber 转换即抛(BigInt/symbol 形)⇒ 原生序列化也抛,归 0 会静默发空表)` };
228
+ }
229
+ if (lenNum === Number.POSITIVE_INFINITY) {
230
+ return { bad: `${path}(length 是 +Infinity ⇒ 原生序列化抛 RangeError,归 0 会静默发空表)` };
231
+ }
232
+ // ToLength 的其余分支:NaN / 负数 / -Infinity ⇒ 0;有限数截断后封顶 2^53-1。
233
+ const len = Number.isNaN(lenNum)
234
+ ? 0
235
+ : Math.min(Math.max(0, Math.trunc(lenNum)), Number.MAX_SAFE_INTEGER);
236
+ const items = [];
237
+ for (let i = 0; i < len; i++) {
238
+ const r = materializeJsonFaithful(o[i], `${path}[${i}]`, seen);
239
+ if ('bad' in r)
240
+ return r;
241
+ items.push(r.value);
242
+ }
243
+ return { value: items }; // 重建:原数组(可能是子类/带访问器/带覆盖方法)不出门
244
+ }
245
+ // 🔴 重建成**无原型**记录(0.34.1 codex 对抗复审第七轮 [high] 的可采半场):
246
+ // ① 忠实 —— 源本来就允许 `Object.create(null)` 字典,重建成 `{}` 等于把载体形换掉了;
247
+ // ② 少一条改写面 —— 无原型记录不会继承任何**事后**装到 `Object.prototype` 上的 `toJSON`。
248
+ // ⚠️ 但这只关掉了 record 那一半:重建出来的**数组**必须是真数组(`Array.isArray` / 序列化成
249
+ // `[...]` 都依赖 `Array.prototype`),所以「构造之后有人污染内建原型」这条**在数组上无解** ——
250
+ // 那种进程里每个对象、每个库都已经不可信了,不是本层能兜的射程。少声称,别装。
251
+ const rec = Object.create(null);
252
+ // 🔴 键先捕获、值**逐个即读即建**(第五轮 [high]):`Object.entries` 会把所有值先读齐再递归拷贝,
253
+ // 于是后一个属性的 getter 能在前一个值被拷走**之前**把它清空(拿到的是同一个引用)。
254
+ // stringify 也是「先取键、再逐键读值」,按它的次序走才叫忠实。
255
+ for (const k of Object.keys(o)) {
256
+ const val = o[k];
257
+ if (val === undefined)
258
+ continue; // record 值缺席 = 该键不存在,序列化丢它是对的
259
+ const r = materializeJsonFaithful(val, `${path}.${k}`, seen);
260
+ if ('bad' in r)
261
+ return r;
262
+ // 🔴 `defineProperty` 而不是 `rec[k] = …`(第五轮 [high]):`k` 可以是 `__proto__` —— 那是
263
+ // `JSON.parse` 出来的**普通数据键**(stringify 照发),而 `rec['__proto__'] = v` 会打到
264
+ // `Object.prototype` 的 setter 上:键整个丢掉、还顺手换了目标对象的原型。
265
+ Object.defineProperty(rec, k, { value: r.value, enumerable: true, writable: true, configurable: true });
266
+ }
267
+ // 🔴 判据的读法**一律排在捕获之后**(0.34.1 codex 对抗复审第十二轮 [high] 采纳):`toJSON` 走属性
268
+ // 查找、`isPlainRecord` 走 `getPrototypeOf`/`constructor` —— 这三种读法**都可以带副作用**
269
+ // (`getPrototypeOf` 陷阱在被问的那一刻把 `deny` 清空),而**原生 `JSON.stringify` 从不问原型**。
270
+ // 校在捕获之前 = 本层自己的读法成了丢内容的那一环(比被动撒谎更糟:那是我们引入的丢失面)。
271
+ // 次序改成「先按 stringify 的口径捕获,再校原件,校不过就丢掉捕获物」⇒ 校本身再有副作用也
272
+ // 影响不到已经捕获的那份,而拒绝面一字未变。
273
+ if (!isPlainRecord(o))
274
+ return { bad: `${path}(${shapeTag(o)})` };
275
+ return { value: rec }; // 重建:出门的是无原型纯记录,访问器/原型/隐藏位都留在原对象上
276
+ }
277
+ finally {
278
+ seen.delete(o);
279
+ }
280
+ };
281
+ /**
282
+ * 快照 → 摊开成 `settings` 子键前的两道处理:**具名通道让位** + **畸形响亮拒**。
283
+ *
284
+ * ## ① 具名通道管着的键一律让位(0.34.1)
285
+ *
286
+ * 快照是**开放集 spread**(子键原样就是 wire 键),所以它天生是一条**第二通道**。0.34.0 只剥了
287
+ * 「车道异名」键(表里那行的 lanes 不含本车道),于是**两条车道都登记**的具名键剥不到 ——
288
+ * 而那些键各有自己的治理门:`settings.hooks` 的具名通道在「工作区未受信 / 管理侧明令关停全部
289
+ * hooks / 检查本身抛错」时判**不投**(端给的 `settings.hooks` 就是 `undefined`),可快照里那份
290
+ * **没过那道门**的 hooks 照样被摊上 wire ⇒ 关停令等于没下。
291
+ *
292
+ * 所以让位不能靠**合并序**(具名键排在快照之后覆盖它)—— 那只在具名通道**有值**时有效,而
293
+ * 治理门否决时的表现恰恰是**没值**。修形 = 结构剥离:凡具名通道管着的子键,快照一概不产。
294
+ * 「谁该决定这个键上不上 wire」只有一个答案:那条具名通道(和它的门)。
295
+ *
296
+ * 🔴 剥得**窄**:只剥表里点名的具名子键。快照**表外**的子键(permissions / env / model / …)
297
+ * 原样摊开 —— 那是开放集的全部意义;`outputStyle` 没有车道行(它是 live 兜底层的位)故不受影响。
298
+ *
299
+ * ## ② 畸形快照响亮拒(0.34.1,fail-closed)
300
+ *
301
+ * `undefined` / `null` = **合法缺席**(端没解析出快照;老写法拿 null 当空快照传),照旧降空对象。
302
+ * 其余非 plain-object 形(数组 / 原始值 / boxed 包装对象 / Map / **类实例**…)= **上游产出坏了**:
303
+ * 0.34.0 把它们一律降空,可这一位摊开的正是**已解析权限面** —— 降空 = 请求带着「被剥掉的
304
+ * deny/ask」照发出去,引擎侧看到的是一个权限更宽的会话,而且没有任何人会知道。宁可停在提交前:
305
+ * `TypeError` 带形状描述(合法载体的判据见 {@link isPlainRecord} —— 「摊得全」是它的全部理由)。
306
+ * (本包是发出去的 npm 公开面:JS 调用方与版本偏斜的宿主都到得了这里,型面拦不住。)
307
+ *
308
+ * ## ③ 权限面**子树**同样要摊得全(0.34.1 codex 对抗复审第二轮 [high] 采纳)
309
+ *
310
+ * 只校最外层是不够的:`{ permissions: new Map([['deny',['Write']]]) }` 的外层是合法字面量,可
311
+ * `JSON.stringify` 把那只 Map 序列化成 `"permissions":{}` —— deny 规则**静默消失**,与 ② 要堵的
312
+ * 是同一个权限变宽形,只是深了一层。所以 `permissions` 子树递归过 {@link jsonFaithfulPath}:
313
+ * plain record / 数组 / JSON 原始值才算摊得全,`Map`/`Set`/类实例/boxed/环一律拒。
314
+ *
315
+ * 🔴 **射程刻意只到 `permissions`**,不做整份请求体的深净化:那是「包级 wire 载荷 JSON-safe
316
+ * 规范化」独立工单(同一条论证对 `agents`/`hooks`/`attachments` 一样成立),不在本批。这条边界
317
+ * 不是随手划的 —— `permissions` 是**权限方向**的那一位:它的内容丢一条 = 会话权限变宽;而
318
+ * `env`/`model` 之类配置位的同款畸形是功能缺失,会被人当场看见,不是安全面静默偏移。
319
+ * 判据形状**不猜 schema**(不要求「deny 必须是字符串数组」——那会在 server 契约扩形那天误红),
320
+ * 只问「序列化后还是不是同一份内容」。
321
+ */
322
+ const resolvedSnapshotForWire = (resolved) => {
323
+ if (resolved === undefined || resolved === null)
110
324
  return {};
111
- if (Array.isArray(resolved))
112
- return {}; // 数组也是 object,但 spread 出来是索引键,同属垃圾形
113
- const foreign = foreignSettingsSubKeys(lane);
325
+ // 原始值 / 函数 / symbol:没有任何可捕获的内容,立刻拒(不必先捕获 —— 拒得早不会丢东西)
326
+ if (typeof resolved !== 'object') {
327
+ throw new TypeError(`buildTaskRequest: settings.resolved 只接受 plain object 或缺席(undefined/null)—— 收到 ${shapeTag(resolved)}。` +
328
+ '这一位摊开的是已解析权限面(permissions.deny/ask 等子键即 wire 键):把畸形值降成空对象' +
329
+ '等于让请求带着被剥掉的权限面发出去(deny 静默失效),所以在提交前拒。' +
330
+ '错误文本只报形状,不回显快照内容。');
331
+ }
332
+ // 🔴 **外层的载体判据也排在捕获之后**(第十二轮 [high],与内层同一条):`isPlainRecord` 要读
333
+ // `getPrototypeOf` —— 那是**原生序列化从不做**的一次读,而它可以被陷阱化并带副作用(被问原型的
334
+ // 那一刻把 `permissions.deny` 清空)。所以先按 stringify 的口径把内容取走(下面的循环会把权限面
335
+ // 当场物化成脱钩副本),原件的形状判据留到最后;校不过就整体拒,拒绝面一字未变。
336
+ const named = namedSettingsSubKeys();
114
337
  const out = {};
115
- for (const [k, v] of Object.entries(resolved)) {
116
- if (foreign.includes(k))
338
+ // 🔴 **键先捕获、值逐个即读即处理**(0.34.1 codex 对抗复审第六轮 [high]):`Object.entries(resolved)`
339
+ // 会把**所有兄弟键**的值先读齐 —— 于是一个 `env`/`model` 位上的 getter 能在 `permissions` 被
340
+ // 重建**之前**把它的 deny 数组清空(拿到的是同一个引用)。这与内层那条(见
341
+ // {@link materializeJsonFaithful} 第五条)是同一个病、只是高一层:两层都必须按 stringify 的
342
+ // 次序走(先取键、再逐键读值),否则「校的就是发的」在外层又漏了一次。
343
+ for (const k of Object.keys(resolved)) {
344
+ if (named.includes(k))
117
345
  continue;
346
+ const v = resolved[k];
347
+ if (k === PERMISSION_FACE_KEY) {
348
+ // 值缺席 = 该键不存在(与内层 record 同一条口径),整键不出现。
349
+ if (v === undefined)
350
+ continue;
351
+ // ③ 权限面:**即读即校即建**(射程与理由见头注 ③;报的是**路径**不是值)。
352
+ // 🔴 函数载体也必须走这条 —— 修前它先被下面那条「函数值一律不进」吃掉,于是
353
+ // `permissions: () => …`(哪怕自带 deny)**整键静默消失** = 请求带着更宽的权限面发出去,
354
+ // 正是本节要堵的形。权限面的畸形只有一个诚实终态:拒。
355
+ const checked = materializeJsonFaithful(v, PERMISSION_FACE_KEY);
356
+ if ('bad' in checked) {
357
+ throw new TypeError(`buildTaskRequest: settings.resolved 的权限面子树在 ${checked.bad} 处不是 JSON 可摊全的载体 —— ` +
358
+ '序列化后那一处的内容会消失或变形(Map/Set/类实例/boxed 包装对象 stringify 成 `{}`,' +
359
+ '非有限数成 null,函数整键消失,任何一层上的 `toJSON` 会整只改写字节,环直接抛),' +
360
+ '而权限面丢一条 = 会话权限静默变宽。合法载体 = 对象字面量 / 数组 / JSON 原始值(有限数)。' +
361
+ '错误文本只报路径与形,不回显内容。');
362
+ }
363
+ // 出门的是重建那一份(不是原引用):端此后再动原对象、或原对象上挂着访问器,都影响不到已
364
+ // 提交的这份请求体。合法输入的字节与修前逐字相同(重建只丢 stringify 本来就会丢的东西)。
365
+ out[k] = checked.value;
366
+ continue;
367
+ }
118
368
  // 🔴 函数值一律不进(#292 P1 codex 复审第三轮 [medium] 采纳):JSON wire 上没有函数,而一个
119
369
  // **自有 `toJSON`** 会在序列化那一刻整只改写 `settings` 的字节 —— 实测
120
370
  // `resolved={permissions:…, toJSON(){return {ultracode:true}}}` 能让 `JSON.stringify(req)` 出
121
371
  // `settings:{ultracode:true}`,把上面那道车道剥离(以及一切按对象查的判据)整体绕过。
122
372
  // 「真上 wire 的那份」= 序列化后的字节,所以过滤必须发生在**值**这一层,而不是只看键名。
373
+ // (权限面**不**走这条:静默丢弃在那一位上是权限变宽,见上面那臂。)
123
374
  if (typeof v === 'function')
124
375
  continue;
125
376
  out[k] = v;
126
377
  }
378
+ // 内容都取走之后才校外层载体的形(见上面那条红线:这一读可能带副作用,不许排在捕获之前)。
379
+ if (!isPlainRecord(resolved)) {
380
+ throw new TypeError(`buildTaskRequest: settings.resolved 只接受 plain object 或缺席(undefined/null)—— 收到 ${shapeTag(resolved)}。` +
381
+ '这一位摊开的是已解析权限面(permissions.deny/ask 等子键即 wire 键):把畸形值降成空对象' +
382
+ '等于让请求带着被剥掉的权限面发出去(deny 静默失效),所以在提交前拒。' +
383
+ '错误文本只报形状,不回显快照内容。');
384
+ }
127
385
  return out;
128
386
  };
129
387
  /**
@@ -151,11 +409,13 @@ export function buildTaskRequest(input, lane) {
151
409
  // ultracode 只在交互车道进表 ⇒ print 传了也不 stamp(表说了算,不是调用方说了算)。
152
410
  // resolved 两车道都进表(#292 P1)——它是**开放集 spread**(子键即 wire 键),所以走
153
411
  // `laneHas + live` 而不是 `on()`:`on()` 判的是「这个键值非空」,而这里要判的是「这份快照要不要
154
- // 摊开」。合并序恒是 resolved 先、具名子键后 —— 具名通道(webSearch/hooks/outputStyle)各有自己
155
- // 的门,同名时必须赢过快照里那份(见 cli settingsRulesWire 的 hooks 剥离注)
412
+ // 摊开」。
413
+ // 🔴 具名通道**赢过快照**这件事不靠下面的合并序(0.34.1):合并序只在具名通道**有值**时管用,
414
+ // 而具名门否决时的表现恰恰是**没值**(端不给这个键)。让位改由 `resolvedSnapshotForWire`
415
+ // 做**结构剥离** —— 具名通道管着的子键快照一概不产,顺序此后只是可读性,不再是安全依据。
156
416
  const s = input.settings ?? {};
157
417
  const settingsOut = {
158
- ...(laneHas('settings.<resolved>', lane) && live ? resolvedForLane(s.resolved, lane) : {}),
418
+ ...(laneHas('settings.<resolved>', lane) && live ? resolvedSnapshotForWire(s.resolved) : {}),
159
419
  ...(on('settings.webSearch', s.webSearch) ? { webSearch: s.webSearch } : {}),
160
420
  ...(on('settings.ultracode', s.ultracode) ? { ultracode: s.ultracode } : {}),
161
421
  ...(on('settings.hooks', s.hooks) ? { hooks: s.hooks } : {}),
@@ -171,7 +431,7 @@ export function buildTaskRequest(input, lane) {
171
431
  ...(on('images', input.images) && (input.images?.length ?? 0) > 0 ? { images: input.images } : {}),
172
432
  ...(on('skills', input.skills) ? { skills: input.skills } : {}),
173
433
  ...(on('mcpServers', input.mcpServers) ? { mcpServers: input.mcpServers } : {}),
174
- ...(on('resumeAt/rewindFiles/rewindFilesTo', input.rewind) ? input.rewind : {}),
434
+ ...(on('resumeAt/resumeAtMode/rewindFiles/rewindFilesTo', input.rewind) ? input.rewind : {}),
175
435
  ...(on('permissionMode', input.permissionMode) ? { permissionMode: input.permissionMode } : {}),
176
436
  ...(on('sandboxImageProfile', input.sandboxImageProfile)
177
437
  ? { sandboxImageProfile: input.sandboxImageProfile }
@@ -293,7 +553,7 @@ export function applyLiveRequestDefaults(req, host) {
293
553
  * 🔴 开放集**不是**整只 `settings` 免检(#292 P1 codex 复审第二轮 [medium] 采纳):表里点名了、
294
554
  * 而 lanes 不含本车道的子键(如 print 车道的 `settings.ultracode`)照旧**点名** —— 那些键是
295
555
  * **可枚举的已知量**,放过它们等于让「ultracode 仅 interactive」这条登记在真 wire 面失效。
296
- * `buildTaskRequest` 内的 `resolvedForLane` 只管住构造器自己那一份;端在构造之后仍能往请求体上
556
+ * `buildTaskRequest` 内的 `resolvedSnapshotForWire` 只管住构造器自己那一份;端在构造之后仍能往请求体上
297
557
  * 塞键(print lane 修前正是「自己另拼一份」),而本函数的整个存在理由就是替**真上 wire 的那份**
298
558
  * 过表。两道门缺一不可。
299
559
  *
@@ -304,7 +564,7 @@ export function unregisteredRequestKeys(req, lane) {
304
564
  /** live 兜底层的键名(表项写法含 `|` 备选位与括号说明,取裸键名)。 */
305
565
  const liveDefaults = new Set(LIVE_DEFAULT_FIELDS.flatMap(row => row.split('|')).map(name => name.replace(/\(.*\)$/, '').trim()));
306
566
  const laneRows = REQUEST_FIELD_MATRIX.filter(f => f.lanes.includes(lane));
307
- /** 顶层键 → 表项(`resumeAt/rewindFiles/rewindFilesTo` 这类斜杠合写项逐键展开)。 */
567
+ /** 顶层键 → 表项(`resumeAt/resumeAtMode/rewindFiles/rewindFilesTo` 这类斜杠合写项逐键展开)。 */
308
568
  const laneTopKeys = new Set();
309
569
  for (const row of laneRows) {
310
570
  if (row.field.startsWith('settings.'))
@@ -15,22 +15,23 @@
15
15
 
16
16
  ## §0 版本锚与重扫纪律
17
17
 
18
- ### 0a. 版本锚(2026-08-16)
18
+ ### 0a. 版本锚(2026-08-18)
19
19
 
20
20
  | 项 | 值 | 真源 |
21
21
  |---|---|---|
22
- | 本包 | `@sema-agent/client-core` **0.31.0** | `package.json` `version` |
23
- | peer:wire 契约 | `@sema-agent/sdk` **>=6.17.2**(value-level,非 type-only) | `package.json` `peerDependencies` |
22
+ | 本包 | `@sema-agent/client-core` **0.36.0** | `package.json` `version` |
23
+ | peer:wire 契约 | `@sema-agent/sdk` **>=7.1.0**(value-level,非 type-only) | `package.json` `peerDependencies` |
24
24
  | peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
25
25
  | runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
26
- | 公开导出面 | **748** 个运行期符号(+ 37 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
26
+ | 公开导出面 | **749** 个运行期符号(+ 37 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
27
27
  | 常驻门 | 以 `scripts/gates-manifest.json` 的 `suites` 长度为准(**本档不抄这个数**) | `scripts/gates-manifest.json`;`npm test` 的名单等值门与它逐名对账 |
28
28
  | 沿革档 | 0.29.0 起建 `CHANGELOG.md`;更早批次记账在 `src/index.ts` 文件头 + `docs/REFACTOR-LEDGER.md` | — |
29
29
 
30
- ⚠️ **本表描述的是工作树(即将发布的 0.31.0),不是 npm 上那一版**:npm `0.29.0` 是发布 commit
31
- `0ab959d`,它的 peer floor 是 **`>=6.16.0`**,也没有 0.30.0 段里那几件(relay / durable 卡两展示键 /
32
- SDK 行形 type 再导出 / P-26 铸口 / #158 移交)。装着 npm `0.29.0` 的端**按 `CHANGELOG.md` 的
33
- `## 0.29.0` 段对表**,不要按本表 —— 本表的组合在 npm 上今天还不存在。
30
+ ⚠️ **本表描述的是工作树(即将发布的 0.36.0),不是 npm 上那一版**:npm 上今天最新的是 `0.35.0`
31
+ (发布 commit `9e21c3f`),它**没有** 0.36.0 段里那两件 wire 过境
32
+ (`ToolApprovalFrame.requiresRealApproval` 帧键镜像 + 卡口透传 / `ActiveRunPendingGate.checkpointId`
33
+ `pendingGateIsProvablyDifferent`)。装着 npm `0.35.0` 的端**按 `CHANGELOG.md` `## 0.35.0` 段
34
+ 对表**,不要按本表 —— 本表的组合在 npm 上今天还不存在。
34
35
 
35
36
  🔴 **本表里仍然手抄的数字都有门看着**(#252,2026-08-14):`scripts/run-integration-doc-freshness-test.mjs`
36
37
  ① 段把 638 / 32 / §2b 十六域名数之和 / 191 / 4 / 33 逐个对 `public-export-baseline.json` 算出来的值,
@@ -99,7 +100,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
99
100
 
100
101
  ## §2 公共导出面地图(按域)
101
102
 
102
- > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**748** 项)。
103
+ > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**749** 项)。
103
104
  > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
104
105
  > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
105
106
  > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
@@ -109,7 +110,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
109
110
 
110
111
  `public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
111
112
  `scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
112
- 实测:748 项 **100% 是运行期导出,零 type-only**。
113
+ 实测:749 项 **100% 是运行期导出,零 type-only**。
113
114
 
114
115
  **推论(端必须知道)**:
115
116
  - barrel 导出的**类型**面比 707 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
@@ -118,12 +119,12 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
118
119
  端依赖这些类型是合法的,但**不要**拿基线 diff 当"类型面没变"的证据。
119
120
  - `src/agentSession/contract.ts` 对基线贡献 **0** 项(纯类型模块,`export *` 在 dist 里是空转发)。
120
121
 
121
- 748 项的内部构成(帮助端估读表大小):**221** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
122
+ 749 项的内部构成(帮助端估读表大小):**221** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
122
123
  (矩阵、键集、env 名、锚串)而非可调用物;**4** 项是 PascalCase 运行期值
123
124
  (`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError`);
124
125
  **39** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6;其中 `engineNamespaceKeyFor` 是命名巧合 —— 参数是 baseUrl 不是 sessionKey,见域 14)。
125
126
 
126
- ### 2b. 域图(16 域,逐域计数之和 = 748)
127
+ ### 2b. 域图(16 域,逐域计数之和 = 749)
127
128
 
128
129
  | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
129
130
  |---|---|---|---|---|---|
@@ -141,7 +142,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
141
142
  | 12 | **workflow 与后台工作视图** | 19 | `projectWorkflowRun` · `createLiveWorkflowSource` · `ensureWorkflowActivityLedger` · `readWorkflowActivityLedger` · `stopWorkflowActivityLedger` · `resetWorkflowActivityLedgers` · `createBackgroundView` · `projectBackgroundView` · `recordWorkflowAgentTaskId` · `agentDisplayStatus` | 活过一个 turn 的长任务读面:workflow run + 跨 session 后台任务归一表(`assistant.tasks` 与 fleet SSE **两源独立降级**) | `src/workflow.ts`、`src/workflowClient.ts`、`src/workflowMonitor.ts`、`src/agentSession/backgroundView.ts`(+ 纯类型 `src/agentSession/contract.ts`) |
142
143
  | 13 | **座位 IPC 契约** | 33 | `LOCAL_SESSIONS_SPEC` · `SEAT_METHOD_NAMES` · `SEAT_EVENT_TYPES` · `isLocalSessionEvent` · `isToolPermissionRequest` · `toolPermissionRequestId` · `SEAT_VALIDATOR_KEY_COVERAGE` | desktop↔web 座位 IPC 契约的**单一真源**(此前两边各一份、名字零重合 ⇒ 编译器永远不会告诉你它们漂了)。🔴 加 verb 忘了加 `LOCAL_SESSIONS_SPEC` **不报错**:preload 不注册 channel、渲染端读到 `undefined` | `src/seatContract.ts`(**零 import**,纯类型 + 常量 + 纯谓词) |
143
144
  | 14 | **宿主端口与会话槽** | 26 | `installHost` · `installHostFor` · `hostPortMisses(For)` · `DEFAULT_SESSION_KEY` · `hostEnv` · `unrefTimer` · `parseLocaleTag` / `pickUiLanguage`(#244 F4 族D A-028.20:locale tag 手术单源 + UI 语言判定;与 `resolveRegionHint` 双出口成文 —— 语言偏好域 en/zh ≠ 地址可达域 cn/intl/unknown,`zh-Hant` 前者 zh 后者 intl 是设计)· `engineNamespaceKeyFor` / `mergeSessionMapRecord` / `mergeEngineEntry`(A-028.12:会话 id 映射单一键形 + merge 判定;存储经 `SessionMapStorePort` 归端 —— cli 文件锁/原子写,web localStorage)| 进程/端级装配层(settings/fs/queue/timers/session/log/probe),与 per-turn 的 `AdapterContext` **分层**。头注的判定规则:**这个能力每 turn 都会变吗?** 会 ⇒ `ctx`;不会 ⇒ `installHost` | `src/host.ts`、`src/hostEnv.ts`、`src/sessionSlot.ts`、`src/unrefTimer.ts`、`src/env/{localeGeo,localeTag,uiLanguage}.ts`、`src/sessionMap.ts` |
144
- | 15 | **控制面与传输** | 69 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` · `INTERACTIVE_WAY_OUT`(默认出路串单源)· `normalizeWirePrincipal`(A-028.10:principal 在场性 trim 原语 —— 全空白=缺席不发头,engineWireTarget 两臂/makeEngineWireClient/壳 livePrincipal 同尺)· `classifyTurnWireError` / `isWireTransportError` / `isPreStreamDrainingReject` / `isResumeAtRejection` / `drainingRetryDelayMs` / `scenarioDenyFromError` + `WIRE_NETWORK_ERROR_PATTERN`(A-028.11/.13:turn 错误分型判定半场,人话文案与渲染归端) | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`src/principalWire.ts`、`src/wireErrorTriage.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts` |
145
+ | 15 | **控制面与传输** | 70 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` · `INTERACTIVE_WAY_OUT`(默认出路串单源)· `normalizeWirePrincipal`(A-028.10:principal 在场性 trim 原语 —— 全空白=缺席不发头,engineWireTarget 两臂/makeEngineWireClient/壳 livePrincipal 同尺)· `classifyTurnWireError` / `isWireTransportError` / `isPreStreamDrainingReject` / `isResumeAtRejection` / `drainingRetryDelayMs` / `scenarioDenyFromError` + `WIRE_NETWORK_ERROR_PATTERN`(A-028.11/.13:turn 错误分型判定半场,人话文案与渲染归端) | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`src/principalWire.ts`、`src/wireErrorTriage.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts` |
145
146
  | 16 | **引擎词汇表与包自检** | 41 | `CONFIG_REFUSAL_CODES` / `isConfigRefusalCode` · `STOP_CONFLICT_CODES` · `isInterruptedToolEndCode` · `isRewindFamilyCode` · `CLIENT_VERBS` · `compensationSplitViolations` | 三端分臂共用的**去字面化** `errorCode` 词表(病根正是三端各抄一份字面);编译期 verb 闭合门;搬迁补偿登记表 | `src/engineErrorCodes.ts`(34 项;A-028.11/.13 补 `DRAINING_ERROR_CODE`/`SCENARIO_NOT_ALLOWED_ERROR_CODE`/`RESUME_AT_ERROR_CODE_PREFIX`)、`src/classifierVerdictWire.ts`、`src/compensations.ts`、`src/clientSlice.ts` |
146
147
 
147
148
  🔴 **`engineErrorCodes` 的开集纪律**(该文件头注逐字):这些 `ReadonlySet` / 前缀谓词一律是**识别表**,
@@ -369,7 +370,7 @@ durable park 腿走 `HitlBridge.decideTool(outcome, toolUseID, opts, preResolved
369
370
  | **D-1 两元组 verbatim 回显** | `boundCallId` + `boundInputHash` 逐字回显进 `decide`,**绝不本地重算 hash**(`bindingOf`)。409 ⇒ `HitlSafetyError('binding_mismatch')`,调用方**重新呈现,绝不自动重试**(一次 decide 绝不双act)。pure 门 B7 段对这两段做**字节级**断言,改一个字符就红 |
370
371
  | `reason` 上限 | `MAX_DENY_REASON_CHARS = 4096`;超限 server **413 `reason_too_large`**,丢的不是归因而是**整次决断**(413 ⇒ 决断没送达 ⇒ run 留 suspended)。包内 `denyReasonForWire(reason, tag)` 截断 + 留痕 |
371
372
  | 缺省拒因 | `DEFAULT_DENY_REASON = 'The user rejected this tool use'`(不带归因时逐字不变) |
372
- | **cancel 语义** | 🔴 取消一个 suspended run 必须**用 deny**,**绝不** `runs.cancel`(对 suspended run 会 409) |
373
+ | **cancel vs deny(两动词,按意图选)** | 🔴 **按「你要停的是哪一样」选,不是按 run 的状态选**。`deny`(本节两条决断腿)= 否掉**这一道门**:决断送达后 run **继续**,模型拿到一条拒绝继续跑 —— 这是「不许它做这件事」。`runs.cancel` = 终结**整条 run**:server [868] 起对 `suspended` / `needs_review` 的 run **就地取消**(把待决 checkpoint 结清 + 行终态化,ack 带 `errorCode:"cancelled"`),这是「别跑了」。⚠️ **本行 0.36.0 前的原文是失真的**(SDK 7.1.0 已标 stale):它写的是「取消 suspended run 必须用 deny,绝不 `runs.cancel`(对 suspended run 会 409)」—— 照那句做会把「停」实现成「放它接着跑」(deny 只关掉一道门,run 照跑)。`cancel` 的 409 **今天只剩 lost CAS race**(待决门被并发决掉/过期 ⇒ 重读再试),**不再**是「suspended 一律 409」。判据锚 = 装机 SDK `dist/resources/runs.d.ts` 的 `cancel` JSDoc(常驻门 ⑦ 段对账,上游改说法当天红) |
373
374
  | 空作答 fail-loud | `answerQuestion` 三形一律抛 `HitlSafetyError('empty_answer')`、**一次 decide 都不发**:空 `answers[]` / 任一条 `selected[]` 为空 / 任一条 `header` 为空串。理由:`{answers:[]}` 在 wire 上另有确切含义(question 域 deny 的 NO_HUMAN 形),当 approve 发出去 = **拿 deny 的载荷冒充 approve** |
374
375
  | **回执** | ⚠️ `decideTool` / `answerQuestion` 的返回型是 **`Promise<unknown>`** —— 本包**不结构化读** durable `/decide` 的响应体,**没有** 4a 那样的 ack 消费层。见 §7b 缺口 **P-7**(不是 P-6:P-6 是 `compaction_outcome`) |
375
376
  | TOCTOU | 调用方若已经用 `findPendingForTask` 取过 pending 行(呈卡用的那一行),**必须**经 `preResolvedPending` 传进来 —— 否则本方法自己再 `approvals.list()` 一次,两次独立取数可能落在**不同的行**上(「人看到的行」≠「decide 解析的行」) |
@@ -700,6 +701,7 @@ CHANGELOG 0.29.0「已知局限」段与相应 JSDoc 都有成文。**别在读
700
701
  | **P-4** | med | **`approval_revoke` 在 SDK union 里连成员都没有**(SDK 顶注:known asymmetry,`Registering it is an open item for the next batch`)⇒ 本包不可能有 case ⇒ 运行期落 `dropped('unknown_arm')`;审批链也看不见它(`isToolApprovalFrame` 只认两帧)。全仓 `grep -rn "revoke"` = **0** | `src/adapter/downstream/eventToSdkMessage.ts` 的 `default` 臂;`src/hitl/toolApprovalWire.ts` 的 `isToolApprovalFrame` | 🔴 **引擎撤卡时本包不会替你撤那张卡** —— 被撤的 ask 会一直留在屏上,直到它自己的 5 分钟 TTL / deny 路径触发。要 revoke 语义的端只能自己接 raw named-SSE 腿并撤自己的卡(`unknown_arm` 的 drop 至少留了一行痕) |
701
702
  | **P-5** | 真缺口(未定级) | **durable 重放腿上,一条被重放的 `question` 今天不会再打开覆盖层** —— 覆盖层的入口是 `liveQuestionStore` 的 **live demux 写口**,不是投影函数。交互 REPL 无损,「断线后按 `Last-Event-ID` 续读」场景下是真缺口。补它属**行为面**改动(先要答「重放一条已过 5min TTL 的问题该不该弹窗」),按宪法三问单独走 | `src/adapter/downstream/eventToSdkMessage.ts` 的 `question`/`question_complete`/`elicitation`/`elicitation_complete` 臂(缺口逐字记在该处);`src/liveQuestionStore.ts` 头注(`LIVE-ONLY + SAME-REPLICA … No durable resume anchor`) | `respondToQuestion` 当 best-effort 用(404 = 「已经放掉了」,dismiss,**绝不重试**);重连后的恢复走 **409 自愈树 + 自己的 `/v1/approvals` 列举**,**不要**指望重放的 `question` 帧能弹出覆盖层 |
702
703
  | **P-6** | low | `compaction_outcome`(压缩**非成功结局**:mooted/failed)在本切片 `not_in_slice`;「压缩失败让用户看见」是 chrome/HUD 面的活,**今天两端都还没接**(adapt 臂表同样无此臂) | `src/adapter/downstream/eventToSdkMessage.ts` 的 `case 'compaction_outcome'` | 压缩失败对用户**不可见**。🔴 **不许**由投影切片顺手编一个假 transcript 形来假装接上了 |
704
+ | **P-32** | 在册局限(0.36.0 两 wire 键过境后的**辖域**,非缺陷) @cli @web @desktop | **两键各只有一条腿,别按「两面能对上」写码**。① **`requiresRealApproval`**(#283):只在**活卡帧**腿(`ToolApprovalFrame` → `ApprovalCardRequest`);**耐久腿零 stamp** —— sdk 7.1.0 的 `PendingCheckpoint` / `riskDescriptor` 都没有这一位(server 那份在**卡内** `risk` 子树,不是行上的键),包侧刻意不猜载体名。② **`checkpointId`**(#285 件2):只在 **409 `conflict.session_active_run` 体 + 其 SSE done 帧**的 `pendingGate`;另外三条 park 读面(`/v1/assistant/inbox`、`GET /v1/approvals`、`/v1/approvals/stream`)**都没有**这一格(后两条要给 checkpoint 表反范式一列 = SQL 面双库门,属另一批) | `src/hitl/toolApprovalWire.ts`(帧腿 stamp / durable 腿刻意零 stamp,两处 JSDoc 逐条写明理由)· `src/adapter/runStream.ts`(`ActiveRunPendingGate.checkpointId` + `pendingGateIsProvablyDifferent`)· 行为钉:`run-durable-card-display-keys-test.mjs` ⑨ 段(四种最像的载体名摆在行上,卡入参一个都不许长出来)· `run-selfheal-reopen-test.mjs` G1/G1b | ① **`requiresRealApproval` 在场 ⇒ 一切自动放行让位**(记住的规则 / `allow_session` / bypass 姿态),**缺席绝不读成 `false`**(缺席 = 不是安全类 ask **或** 老 server,两者同形不猜);durable 卡上今天**恒缺席**,别据此认为「durable 门都不是安全类」。② **`checkpointId` 只做两件事**:落日志/排障关联,以及经 `pendingGateIsProvablyDifferent(prev, next)` 做**单向**判断「id 变了 ⇒ 不是刚才那一行」。🔴 **`false` 是「证不出」不是「同一道门」**,🔴 **绝不**当跨调用去重键(server 选行是**无序 `LIMIT 1`**、同 session 可并存多条 pending 行 ⇒ 同一情形连续两次 409 可能报不同 id;也可能 id 不变而 `activeTaskId` 已换),🔴 **绝不**拿它去 join durable 队列行(必然落空,且落空与 legacy 行同形)。缺席是**三成因合流**(legacy 行 / 值没过 server 信任边界校验 / 整只材料读取失败),读作「不知道这道门叫什么」 |
703
705
 
704
706
  ### 7b. 决断链缺口
705
707
 
@@ -724,8 +726,10 @@ CHANGELOG 0.29.0「已知局限」段与相应 JSDoc 都有成文。**别在读
724
726
 
725
727
  | ID | 级别 | 一句话现状 | 实现锚 | 端**今天**必须怎么办 |
726
728
  |---|---|---|---|---|
727
- | **P-15** | med(6 → **5**,#292 P1 结清一条) | `REQUEST_FIELD_MATRIX` 有 **5 个字段登记为 `gap: true`**(表内 `gap:true` 的定义逐字 = 「这条差异**没有正当理由,是漏的**」),全部是 **print/headless 车道缺席**:`settings.ultracode` · `reasoningEffort` · `model` · `clientContext` · `scratchpadDir`。表内点名的后果:`clientContext` 缺席 ⇒ **引擎误标 (UTC)**;`scratchpadDir` 缺席 ⇒ **`-p` 的工具写不进 exemptDir**。**已结清**:`settings.<resolved>`(0.34.0 / #292 P1 —— 版本号与 CHANGELOG 段**同一个**,codex 复审 [low] 抓的正是两处不一致)—— 它的缺席是**权限方向**的(`-p` 上用户 settings 的 `permissions.deny/ask` 整体不被引擎求值,cli [4208] 实测),现两车道都 stamp | `src/request/taskRequest.ts`(`REQUEST_FIELD_MATRIX` 的 `gap` 列) | headless 车道上这五项**确实不上 wire**。端不要在 print 车道假设它们在场;补齐是**行为改动**,要单独一条测试,不许端侧偷加。`settings.<resolved>` 反过来:print 车道现在**会**摊开 resolver 快照 ⇒ 端必须把值放进 `input.settings.resolved`(端不给值仍是零 stamp,不会凭空出现),且该车道 `settings` 子键走**开放集**口径:`unregisteredRequestKeys` 只放行**表外**动态子键(快照自己的 permissions/env/model/… 不可枚举),**表内但不属于本车道**的子键(今天 = print 的 `settings.ultracode`)仍会被点名 —— 端不许把它白名单化,那条红是真的;另:`settings` 子键值为**函数**(如自有 `toJSON`,能在序列化时整只改写字节)恒被点名且构造器不 stamp |
729
+ | **P-15** | med(6 → **5**,#292 P1 结清一条) | `REQUEST_FIELD_MATRIX` 有 **5 个字段登记为 `gap: true`**(表内 `gap:true` 的定义逐字 = 「这条差异**没有正当理由,是漏的**」),全部是 **print/headless 车道缺席**:`settings.ultracode` · `reasoningEffort` · `model` · `clientContext` · `scratchpadDir`。表内点名的后果:`clientContext` 缺席 ⇒ **引擎误标 (UTC)**;`scratchpadDir` 缺席 ⇒ **`-p` 的工具写不进 exemptDir**。**已结清**:`settings.<resolved>`(0.34.0 / #292 P1 —— 版本号与 CHANGELOG 段**同一个**,codex 复审 [low] 抓的正是两处不一致)—— 它的缺席是**权限方向**的(`-p` 上用户 settings 的 `permissions.deny/ask` 整体不被引擎求值,cli [4208] 实测),现两车道都 stamp | `src/request/taskRequest.ts`(`REQUEST_FIELD_MATRIX` 的 `gap` 列) | headless 车道上这五项**确实不上 wire**。端不要在 print 车道假设它们在场;补齐是**行为改动**,要单独一条测试,不许端侧偷加。`settings.<resolved>` 反过来:print 车道现在**会**摊开 resolver 快照 ⇒ 端必须把值放进 `input.settings.resolved`(端不给值仍是零 stamp,不会凭空出现),且该车道 `settings` 子键走**开放集**口径:`unregisteredRequestKeys` 只放行**表外**动态子键(快照自己的 permissions/env/model/… 不可枚举),**表内但不属于本车道**的子键(今天 = print 的 `settings.ultracode`)仍会被点名 —— 端不许把它白名单化,那条红是真的;另:`settings` 子键值为**函数**(如自有 `toJSON`,能在序列化时整只改写字节)恒被点名且构造器不 stamp。🔴 **0.35.0 起快照通道对具名通道让位**:见 P-15c |
728
730
  | **P-15b** | 🔴 权限方向 | `REQUEST_FIELD_MATRIX` 的 stamp 门对**未登记键静默丢弃** —— 表里点名的真实危险形逐字:**「用户显式排除的工具被静默放回」(权限方向回归,类型层不报)**。`excludeTools` 是真 wire 键、早在 seatContract 的 `START_SESSION_OPTION_KEYS` 里,却曾长期在矩阵外;**今天只有 desktop 在发它** | `src/request/taskRequest.ts`(`excludeTools` 行)、`src/seatContract.ts`(`START_SESSION_OPTION_KEYS`) | 端自拼 taskReq 的键**必须**先进矩阵;上 wire 前跑 `unregisteredRequestKeys(req, lane)` 并**当红对待**,别当 lint |
731
+ | **P-15c** | 🔴 治理方向(0.35.0 行为改动) | `settings.<resolved>` 快照是**开放集 spread**(子键即 wire 键)⇒ 它天生是一条**第二通道**。0.34.0 只剥「车道异名」子键,于是**两条车道都登记**的具名子键剥不到 —— 而它们各有治理门:`hooksForWire()` 是 fail-closed(无 `SettingsPort` / 工作区未受信 / 管理侧关停全部 hooks / 检查抛错 ⇒ 返 `undefined`),此时快照里那份**没过门**的 `hooks` 照样上 wire = 关停令等于没下。让位修前靠**合并序**(具名键覆盖快照),而合并序只在具名通道**有值**时管用,门否决时恰恰**没值**。0.35.0 改**结构剥离**:凡表里有 `settings.<sub>` 行的子键(`hooks`/`webSearch`/`ultracode`),快照一概不产;表外子键(`permissions`/`env`/`model`/…)原样摊开 | `src/request/taskRequest.ts`(`namedSettingsSubKeys` / `resolvedSnapshotForWire`) | ① 具名键**必须走具名位**:`input.settings.hooks` / `.webSearch` / `.ultracode` —— 只塞进 `input.settings.resolved` 的宿主从 0.35.0 起那两个键**不再上 wire**(两条车道对称,不是新差异面);② `input.settings.resolved` 只放**表外**的 resolver 产物;③ 该位为 `undefined`/`null` = 合法缺席(照旧降空照发),**合法载体只有对象字面量与 `null` 原型字典**;其余形(数组/原始值/boxed 包装对象/`Map`/**类实例**)抛 `TypeError`。判据锚 **prototype 层数(realm 无关 —— iframe/vm/另一渲染进程的字面量照过)不锚自报标签**:类实例与 `Symbol.toStringTag` 伪造都能自报 `[object Object]`,而摊开走 `Object.entries`(只取自有可枚举键)⇒ 权限面挂在原型 getter 上会摊出空快照、请求照发。这一位摊开的是已解析权限面,降空 = 带着被剥掉的 `deny/ask` 发出去,故 fail-closed;端别 catch 掉它当没事,那是上游产出坏了 —— 把快照**平摊成对象字面量**再传即可;④ `permissions` **子树**也递归校「序列化后还是同一份内容吗」:嵌套 `Map`/`Set`/类实例/boxed/环、**任何一层**上可 call 的 `toJSON`(自有/非枚举/原型链/数组子类)、**非有限数**(`NaN`/`±Infinity`)都 ⇒ `TypeError`,报路径如 `permissions.deny[0]`,不猜 schema;射程刻意只到 `permissions`(整体深净化 = 独立工单)。⚠️ 两条**成文边界**(各有判据钉住现状,不是漏):① 构造之后污染 `Array.prototype`(重建出来的数组必须是真数组);② `getPrototypeOf` 被 Proxy 陷阱撒谎的载体(同 realm 内无可移植的 Proxy 读法)——两者都**不新增丢失面**(发的字节 = 原生序列化那一份),要关得靠「受信 resolver 出口发烙印/已物化记录」那个结构 |
732
+ | **P-15d** | 已知缺口(权限方向;0.35.0 登记,**刻意未在本批闭合**) | 0.35.0 把「序列化后还是同一份内容吗」这条不变量**只**落到 `settings.resolved.permissions` 子树(校 + 就地重建)。**同一条论证对其它带权限含义的位一样成立,而它们今天没有等价强制**:`permissionMode`(字符串位;非串载体的 `toJSON` 能自己决定 wire 上那个词)· `excludeTools`(丢一个元素 = **用户显式排除的工具被放回**,与 P-15b 同一方向)· `additionalDirectories` / `additionalReadDirectories`(读写边界根)· `agents` / `hooks` / `attachments`(对象位,同款 `toJSON` 改写面)。**为什么不在本批一起做**:逐个挑两三个字段补,只会造出下一个同样任意的边界;正解是**一次**把「wire 载荷 JSON-safe 规范化」做成包级闸口(该工单自 0.34.0 起在册),覆盖所有位并同批建判据 | `src/request/taskRequest.ts`(`materializeJsonFaithful` 的射程 = `permissions`;边界本身有一条判据钉着:非权限位的同款畸形**不拦**) | 端**不要**推断「本包会替我把请求体洗干净」——今天只有 `permissions` 子树有这个保证。上 wire 的值请自己保证是 JSON 原生形(字面量 / 数组 / 字符串 / 有限数):别拿类实例、`Map`、带 `toJSON` 的包装对象、访问器对象当载体。尤其 `permissionMode` / `excludeTools`:前者决定整会话的审批姿态,后者丢一个元素就是权限变宽 |
729
733
  | **P-16** | 提货单 S 组(**BREAKING**,P2 裁决**未填**) | 三条签名级 BREAKING 在册未决,**约束三端的 port 实现**:REF-CC-133(`SettingsPort.getSettingsForSource` 返回裸 `unknown`)· REF-CC-134(`ModelFacingTaskOutput`)· REF-CC-135(`errCodes`,**且是行为改动**:脏形不再原样透传) | `docs/REFACTOR-LEDGER.md` REF-CC-133/134/135 | 端实现 `SettingsPort` 时不要依赖当前的宽返回型;`errCodes` 的脏形透传行为**会变** |
730
734
  | **P-17** | 在册 | 两条 wave2 残余:REF-CC-059(`NeutralDelta` 命名已落,**泄漏门没建**)· REF-CC-063(自锚已修,`gate:line-anchor` 口未做) | `docs/refactor/WAVE2-RESIDUALS.md` | 端无直接动作;别把这两条当已闭合 |
731
735
  | **P-18** | 已知缺口(dueDate 2027-02-01) | fleet 行的 `currentAction`:旧端(server <1.288)只发 `currentAction` ⇒ 那一档上本视图**拿不到活动文本**(登记 `kind:'known-gap'`,自述「这是**已知缺口**不是设计取舍」) | `src/fleet/fleetProjection.ts` | 老引擎上 fleet 行没有活动文本;端做兜底渲染 |
@@ -841,7 +845,7 @@ reason 里写明「枚举器盲区形」。已知两形:
841
845
  - [ ] 接审批帧腿时**填 `AskGateWireDeps.approvalLane`**(缺席 = `note` 恒不发,fail-closed 且不报错)
842
846
  - [ ] ack 五位按 §4a 三列表消费:**缺席一律当未知**,`rememberApplied === false` 与 `updatedInputForwarded === false` 必须响亮告知
843
847
  - [ ] durable 腿:`decideTool` 前把**呈卡用的那一行** pending 经 `preResolvedPending` 传进去(TOCTOU)
844
- - [ ] durable 腿:取消 suspended run **用 deny,不用 `runs.cancel`**
848
+ - [ ] durable 腿:**按意图**选动词 —— 否掉这一道门用 **deny**(run 继续),终结整条 run 用 **`runs.cancel`**(server [868] 起对 suspended/needs_review 就地取消;409 只剩 CAS race)。⚠️ 0.36.0 修:原行写的「取消 suspended 必须用 deny、绝不 cancel」已被 SDK 7.1.0 标 stale,详见 §4 「cancel vs deny」行
845
849
  - [ ] `HitlSafetyError` 按 **`.code` 结构化判型**,`instanceof` 只作加强(跨 realm / 双实例)
846
850
  - [ ] `ReopenCardVerdict.presented`:要用这个位就自己在呈现层实现回执;不实现就**缺席**(缺席不降级),**别填 `false` 当占位**
847
851
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",