@sema-agent/client-core 0.34.0 → 0.35.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 +114 -0
- package/README.md +1 -1
- package/dist/request/taskRequest.d.ts +1 -1
- package/dist/request/taskRequest.js +293 -33
- package/docs/INTEGRATION-CLIENTS.md +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,120 @@
|
|
|
16
16
|
> 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
|
|
17
17
|
> `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
|
|
18
18
|
|
|
19
|
+
## 0.35.0(2026-08-18)
|
|
20
|
+
|
|
21
|
+
**0.34.0 发版补扫的收尾批(请求面三件 + 门的一件流程化)。行为面**两条**,都在
|
|
22
|
+
`request/taskRequest.ts`:快照通道不再替具名通道做决定;畸形快照从「降空照发」改**响亮拒**。**
|
|
23
|
+
|
|
24
|
+
- 🔴 **快照通道让位(F1,权限/治理方向)**:`settings.<resolved>` 是**开放集 spread**(快照子键
|
|
25
|
+
原样就是 wire 键),所以它天生是一条**第二通道**。0.34.0 只剥了「车道异名」子键(表里那行的
|
|
26
|
+
`lanes` 不含本车道),于是**两条车道都登记**的具名子键剥不到 —— 而它们各有自己的治理门:
|
|
27
|
+
`settings.hooks` 的具名通道在「工作区未受信 / 管理侧明令关停全部 hooks / 该检查本身抛错」时判
|
|
28
|
+
**不投**(端给的 `settings.hooks` 就是 `undefined`),可快照里那份**没过那道门**的 hooks 照样被
|
|
29
|
+
摊上 wire ⇒ 关停令等于没下。根因是让位靠的是**合并序**(具名键排在快照之后覆盖它),而合并序
|
|
30
|
+
只在具名通道**有值**时管用,治理门否决时的表现恰恰是**没值**。
|
|
31
|
+
修形 = **结构剥离**:凡具名通道管着的子键(表里有 `settings.<sub>` 行的 —— 今天
|
|
32
|
+
`hooks` / `webSearch` / `ultracode`),快照一概不产;「谁有资格决定这个键上不上 wire」只有一个
|
|
33
|
+
答案 = 那条具名通道和它的门。**剥得窄**:快照**表外**的子键(`permissions` / `env` / `model` / …)
|
|
34
|
+
原样摊开(那是开放集的全部意义);`outputStyle` 没有车道行(live 兜底层的位)故不受影响。
|
|
35
|
+
⚠️ **端影响面**:若某宿主此前**只**把 hooks/webSearch 放进 `input.settings.resolved`、从不走具名
|
|
36
|
+
通道,这一版起那两个键不再上 wire —— 改法是把值放进具名位(`input.settings.hooks` /
|
|
37
|
+
`.webSearch`),那条路自带治理门。这不是新差异面:两条车道的表现完全对称。
|
|
38
|
+
**已核过的消费面**(不是推断):`sema-cli` 的 print/交互两条车道都走具名位供 hooks(它自己的投影层
|
|
39
|
+
就把 hooks 从快照里剥掉了),`sema-web-client` 的座位台账把 `settings.<resolved>` 显式登记为
|
|
40
|
+
`supplied: false`,`sema-web` 只用具名 `settings.ultracode` —— 今天**唯一**的快照供给方是 cli,
|
|
41
|
+
两套壳消费端常驻门按本版构建实跑仍绿。第三方(仓外)宿主无法穷举,故按公开面纪律记在本段。
|
|
42
|
+
- 🔴 **畸形快照响亮拒(F3,fail-closed)**:`input.settings.resolved` 为 `undefined` / `null` =
|
|
43
|
+
**合法缺席**(端没解析出快照;老写法拿 null 当空快照传),照旧降空、请求照发 —— 这一半与
|
|
44
|
+
0.34.0 逐字不变。其余非 plain object 形(数组 / 原始值 / boxed 包装对象 / `Map` …)从 0.34.0 的
|
|
45
|
+
「一律降空对象」改判 **`TypeError`**:这一位摊开的正是**已解析权限面**,降空 = 请求带着「被剥掉的
|
|
46
|
+
`permissions.deny/ask`」照发出去,引擎侧看到一个权限更宽的会话而没有任何人会知道。上游产出坏了
|
|
47
|
+
就该停在提交前。错误文本**只报形状**(`typeof` / `[object …]` / prototype 载体名),不回显快照内容
|
|
48
|
+
(快照里有 env 与权限面,错误路径也是外溢面)。
|
|
49
|
+
🔴 **合法载体按 prototype 判,不按自报标签判**:`Object.prototype.toString.call(x) === '[object Object]'`
|
|
50
|
+
对**普通类实例**同样成立,`Symbol.toStringTag` 还能让任意载体自报这个标签;而摊开用的是
|
|
51
|
+
`Object.entries`(**只取自有可枚举键**)—— 于是一个把 `permissions` 挂在**原型 getter** 上的载体会
|
|
52
|
+
摊出**空快照**、请求照发,正是本条要堵的 fail-open 形本身。所以合法载体只有两种:`Object.prototype`
|
|
53
|
+
直系(对象字面量)与 `null` 原型(`Object.create(null)` 无污染字典)—— 它们的自有可枚举键集**就是**
|
|
54
|
+
全部内容。类实例 / `Map` / `Date` / boxed 包装对象 / 数组一律拒。
|
|
55
|
+
判据 **realm 无关**:不拿「本 realm 的 `Object.prototype`」做身份比较 —— iframe / `node:vm` / 另一个渲染
|
|
56
|
+
进程里的对象字面量各有自己的 `Object.prototype`,身份比较会把这些**完全合法**的载体误判成异形,在
|
|
57
|
+
web / desktop 宿主上变成提交前硬失败。改判**原型链层数**(字面量的原型的原型恒 `null`),该拒的照旧拒。
|
|
58
|
+
🔴 **权限面子树同样递归校**:只校最外层不够 —— `{ permissions: new Map([['deny',['Write']]]) }` 外层
|
|
59
|
+
合法,而 `JSON.stringify` 把那只 Map 变成 `"permissions":{}`,deny 规则**静默消失**(同一个权限变宽形,
|
|
60
|
+
深一层)。`permissions` 子树按「序列化后还是不是同一份内容」递归判:plain record / 数组 / JSON 原始值
|
|
61
|
+
合法,`Map`/`Set`/类实例/boxed/环拒;错误文本给**路径**(如 `permissions.deny[0]`)不给值。判据
|
|
62
|
+
**不猜 schema**(不要求 deny 是字符串数组 —— 那会在 server 契约扩形那天误红)。同一条不变量还堵两个
|
|
63
|
+
**序列化级**形:①**任何一层**上可 call 的 `toJSON`(自有 / 非枚举 / 原型链 / 数组子类都算 —— 一个
|
|
64
|
+
`deny=['Write']` 挂个非枚举 `toJSON(){return []}` 就能让 wire 上出 `"deny":[]`,而按键遍历的判据一个字
|
|
65
|
+
都不会说,所以判据也走**属性查找**);②**非有限数**(`NaN`/`±Infinity` 序列化成 `null`,规则位变 null)。
|
|
66
|
+
数组长度按原生 `ToLength` **只转换一次**(先存下来再逐轮比较,会让「第一次给 3、以后给 1」的 length 少发
|
|
67
|
+
规则),且转换用**抽象 ToNumber**(一元 `+`)—— `Number()` 会把**对象强制出来的 BigInt**
|
|
68
|
+
(`Object(0n)` / `valueOf()=>0n` / `Symbol.toPrimitive()=>0n`)悄悄变成 0,而原生在同样输入上是抛的;
|
|
69
|
+
`+Infinity` / BigInt / symbol 长度按契约① 响亮拒(归 0 = 静默发一个空 deny),`NaN` / 负数 / `-Infinity`
|
|
70
|
+
归 0(与原生发 `[]` 一致)。
|
|
71
|
+
🔴 **判过就地重建,绝不放原引用出门**:只校不建是「校 A 发 B」——属性访问器能第一次(校)给
|
|
72
|
+
`['Write']`、第二次(`JSON.stringify`)给 `[]`;`toJSON` 也能第一次查是 `undefined`、第二次是函数。
|
|
73
|
+
**任何只读活对象的判据都拦不住这一类**,那不是判据写错了,是结构错了。所以校过的值逐层重建成新的
|
|
74
|
+
字面结构、请求体带走**那一份**:每个属性只读一次,出门那份不带访问器 / 不带 `toJSON` / 不带原型 ——
|
|
75
|
+
「校的就是发的」从承诺变成构造事实,且提交后端再改原对象也影响不到已构造的请求体。合法输入的字节
|
|
76
|
+
与修前逐字相同(重建只丢 `JSON.stringify` 本来就会丢的东西)。重建**自己**也不许成为丢内容的那一环:
|
|
77
|
+
数组按**捕获长度 + 下标**走(不调载体自带的 `entries` —— 一个覆盖了 `entries` 的 `['Write']` 能让重建
|
|
78
|
+
产出 `[]`,而原生序列化照发 `["Write"]`)、record **键先捕获值逐个即读即建**(`Object.entries` 会把值
|
|
79
|
+
先读齐,后一个 getter 就能清空前一个已交出的数组)、写回用 `defineProperty`(键可能是 `JSON.parse`
|
|
80
|
+
造出的 `__proto__` **数据键**,`rec[k]=v` 会打到原型 setter 上:键丢 + 换原型)。
|
|
81
|
+
同一条「即读即建」在**外层快照**那一圈也成立(否则内层修好了、外层照漏):`env`/`model` 位上的 getter
|
|
82
|
+
能在 permissions 被重建前清空它;而 `permissions` 是**函数**载体时,不许被「函数值一律不进」那条
|
|
83
|
+
静默吃掉(修前整键消失 = 权限变宽)—— 权限面的畸形只有一个诚实终态:拒。
|
|
84
|
+
重建出来的 record 是**无原型**的(`Object.create(null)`):既忠实(源本来就允许无原型字典)、也少一条
|
|
85
|
+
改写面(构造之后往 `Object.prototype` 装 `toJSON` 改写不到它)。⚠️ **边界如实**:重建出来的**数组**必须
|
|
86
|
+
是真数组(`Array.isArray` 与序列化成 `[...]` 都靠 `Array.prototype`),所以「事后污染内建原型」这条在
|
|
87
|
+
数组层**无解** —— 那种进程里每个对象、每个库都已不可信,不是本层射程。这半边同样有判据钉着现状
|
|
88
|
+
(数组层污染仍改写得到),真关掉那天来改那一条 = 变强,不是回归。
|
|
89
|
+
⚠️ **同类边界之二**:`getPrototypeOf` 被 Proxy 陷阱撒谎的载体过得了载体判据(那个读法本身可陷阱化,
|
|
90
|
+
而同 realm 内没有**可移植**的「这是不是 Proxy」读法 —— `util.types.isProxy` 是 Node 专有,本包三端共用
|
|
91
|
+
不引 node 内建)。但它**不是本层新增的丢失面**:同一个 proxy 交给原生 `JSON.stringify` 也照样出 `{}`,
|
|
92
|
+
即本层「发的字节 = 原生序列化那一份」这条不变量没破,丢的是那个 proxy 自己声称的东西;而一个**故意**
|
|
93
|
+
撒谎的宿主本来就能直接传 `{}`,不需要这个花招。真要关这一类,得把归一化上移到「受信 resolver 出口发
|
|
94
|
+
**烙印/已物化**记录」的结构(= 上面那个包级规范化工单),不是在这里再加一条读法。两条边界都有判据
|
|
95
|
+
钉着现状(不假装拦住了 + 零额外丢失),不是漏。
|
|
96
|
+
- **本位的契约明文**(三条按序,别指望同时最大化;源码头注与判据同文):① **载体形上 fail-closed,刻意
|
|
97
|
+
比原生序列化严** —— 原生对 `Map`/类实例是**静默成功**(出 `{}`),那正是要堵的「悄悄发一份更宽的权限
|
|
98
|
+
面」,所以「原生能发的我们拒了」在畸形载体上**是设计**;② 被接受的输入,**发出的字节 = 原生对该输入的
|
|
99
|
+
序列化**,且读的次序照原生(`toJSON` 单次查询在捕获前、原型读在捕获后);③ 由 ①② 推出并明文认领的
|
|
100
|
+
后果:若那次原生要求的 `toJSON` 读取本身把载体的**形**改了(访问器顺手换掉自己的原型),推后的形判据
|
|
101
|
+
看到改后的形 ⇒ **拒** —— 序列化中途自我变形的载体就是坏产出,拒是响亮失败而非静默变宽。要让这一类也
|
|
102
|
+
「照原生发」,得靠受信 resolver 出口发烙印/已物化记录(= 上面那个包级规范化工单)。三条各有判据钉着。
|
|
103
|
+
⚠️ **射程刻意只到 `permissions`**:整份请求体的 JSON-safe 深净化仍是独立工单(自 0.34.0 在册)。
|
|
104
|
+
边界理由 = `permissions` 是权限方向那一位,内容丢一条 = 会话权限变宽;`env`/`model` 之类配置位的
|
|
105
|
+
同款畸形是功能缺失,会被人当场看见。该边界本身有一条判据钉着(`env` 的同款畸形**不拦**),要动就得
|
|
106
|
+
改那条,不许悄悄漂。**其它带权限含义的位(`permissionMode` / `excludeTools` / 目录根族 / `agents` /
|
|
107
|
+
`hooks`)今天没有等价强制** —— 逐个挑两三个补只会造出下一个任意边界,故整批留给那个包级闸口工单,
|
|
108
|
+
并已在 `docs/INTEGRATION-CLIENTS.md` **P-15d** 逐位点名登记(端别推断「本包会替我洗干净请求体」)。⚠️ 这一条**改写了 0.34.0 段里「脏快照一律降空对象」那句承诺**
|
|
109
|
+
(已发段是冻结面,故不回改那一段;以本段为准)。现网可达性:今天的宿主投影是白名单形、产不出
|
|
110
|
+
畸形值 ⇒ 实际触发面是 JS 调用方与版本偏斜的宿主。非 live 车道不受影响(那份快照根本不摊开)。
|
|
111
|
+
- **`resumeAtMode` 补进矩阵合写行(F2,判据面)**:`REQUEST_FIELD_MATRIX` 的
|
|
112
|
+
`resumeAt/rewindFiles/rewindFilesTo` 行漏了第四键 —— `resumeAtMode` 是 `rewindWireCaps`
|
|
113
|
+
的 `SeamRewindSpec` 真 wire 键,且 `rewindSpecForMode` 的 `both` / `conversation` 两臂**恒返**它。
|
|
114
|
+
后果:真 `/rewind` 还原 turn 上 `unregisteredRequestKeys(req,'interactive')` 会点名
|
|
115
|
+
`resumeAtMode` —— 一条**误报**,而它教端去白名单化一个真键。行改
|
|
116
|
+
`resumeAt/resumeAtMode/rewindFiles/rewindFilesTo`,四键同生同灭;车道事实一字未动(`print` 车道
|
|
117
|
+
出现这四键仍点名 —— `/rewind` 是交互命令)。
|
|
118
|
+
- **内部改名**(非公开面,零 API 影响):构造器侧的快照处理助手 `resolvedForLane` →
|
|
119
|
+
`resolvedSnapshotForWire`(剥离集合已与车道无关,旧名会误导;0.34.0 段里的旧名是历史记述)。
|
|
120
|
+
- **门**(流程面,零行为):`run-integration-doc-freshness-test` 的两阶段冻结账协议**阶段一落地** ——
|
|
121
|
+
④b 的 (i) 与 (iii) 两条红(「刚 bump」这个状态先撞哪条取决于 CHANGELOG 段有没有带「(未发布)」
|
|
122
|
+
标记,所以**两条都挂**)现在直接给出「bump 的**同一个 commit** 里插 `{ version, pending: true }` 行」
|
|
123
|
+
的操作原文,`FROZEN` 头注补一条 bump 步骤义务。判据语义一字未改;修的是「红只陈述缺什么、不说
|
|
124
|
+
怎么办」导致阶段一被拖到发布后补(0.33.0 / 0.34.0 两版实际都是先账后补)。本版是第一次照新协议走
|
|
125
|
+
(账上先挂 `pending: true`)。
|
|
126
|
+
⚠️ **机器守住的是什么、没守住的是什么**(别把这条读成比它更强):门机械校的是「同一棵**工作树**里
|
|
127
|
+
version / CHANGELOG 段 / `pending` 账行三者齐备」(④a0 + ④b),以及指引原文不许烂掉(两条正控)。
|
|
128
|
+
「三者落在**同一个 commit**」这一步是**文档义务**,不是机器判据 —— 要机器判就得回溯 git 史找
|
|
129
|
+
「引入当前 version 的那个 commit」,而这道门恰恰要在**尚未提交**的状态下跑(发版批的常态),
|
|
130
|
+
history 判据在那一刻必然误红。宁可少声称,也不装一道会自己喊狼来了的门。
|
|
131
|
+
- 常驻门:`run-client-core-pure-test` B8 段 62 → 79 断言(零松量)。
|
|
132
|
+
|
|
19
133
|
## 0.34.0(2026-08-17)
|
|
20
134
|
|
|
21
135
|
**#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.
|
|
38
|
+
**Version:** 0.35.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
|
|
@@ -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` 内的 `
|
|
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
|
-
|
|
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
|
-
*
|
|
84
|
-
* —— 快照里的子键原样就是 wire
|
|
85
|
-
*
|
|
86
|
-
* `settings.ultracode
|
|
87
|
-
*
|
|
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
|
-
* 🔴
|
|
95
|
-
*
|
|
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
|
-
*
|
|
98
|
+
* **具名通道管着的**全部 settings 子键 —— 表里凡有 `settings.<sub>` 行的都算,**不分车道**。
|
|
100
99
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* 新造一条两车道差异。旧式 `?? {}` 对 null 是容忍的,这里把容忍面扩到「一切非对象」(降空 =
|
|
106
|
-
* 不 stamp = wire 上最小的诚实形;绝不把一个字符串 spread 成 `{0:'a',1:'b'}` 那种垃圾键)。
|
|
100
|
+
* 与 {@link foreignSettingsSubKeys} 的分工(两个集合,两件事):
|
|
101
|
+
* · foreign(**异车道**子集)= 判据面用 —— 真 wire 上出现一个「表里点名了、却不属于本车道」的
|
|
102
|
+
* 子键要被点名;本车道自己那些具名子键**合法**出现在 wire 上(它们从具名通道来)。
|
|
103
|
+
* · named(**全集**)= 构造器面用 —— 快照通道对具名通道管着的键**一律让位**,理由见下。
|
|
107
104
|
*/
|
|
108
|
-
const
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
//
|
|
155
|
-
//
|
|
412
|
+
// 摊开」。
|
|
413
|
+
// 🔴 具名通道**赢过快照**这件事不靠下面的合并序(0.34.1):合并序只在具名通道**有值**时管用,
|
|
414
|
+
// 而具名门否决时的表现恰恰是**没值**(端不给这个键)。让位改由 `resolvedSnapshotForWire`
|
|
415
|
+
// 做**结构剥离** —— 具名通道管着的子键快照一概不产,顺序此后只是可读性,不再是安全依据。
|
|
156
416
|
const s = input.settings ?? {};
|
|
157
417
|
const settingsOut = {
|
|
158
|
-
...(laneHas('settings.<resolved>', lane) && live ?
|
|
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` 内的 `
|
|
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.'))
|
|
@@ -724,8 +724,10 @@ CHANGELOG 0.29.0「已知局限」段与相应 JSDoc 都有成文。**别在读
|
|
|
724
724
|
|
|
725
725
|
| ID | 级别 | 一句话现状 | 实现锚 | 端**今天**必须怎么办 |
|
|
726
726
|
|---|---|---|---|---|
|
|
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 |
|
|
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。🔴 **0.35.0 起快照通道对具名通道让位**:见 P-15c |
|
|
728
728
|
| **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 |
|
|
729
|
+
| **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 出口发烙印/已物化记录」那个结构 |
|
|
730
|
+
| **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
731
|
| **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
732
|
| **P-17** | 在册 | 两条 wave2 残余:REF-CC-059(`NeutralDelta` 命名已落,**泄漏门没建**)· REF-CC-063(自锚已修,`gate:line-anchor` 口未做) | `docs/refactor/WAVE2-RESIDUALS.md` | 端无直接动作;别把这两条当已闭合 |
|
|
731
733
|
| **P-18** | 已知缺口(dueDate 2027-02-01) | fleet 行的 `currentAction`:旧端(server <1.288)只发 `currentAction` ⇒ 那一档上本视图**拿不到活动文本**(登记 `kind:'known-gap'`,自述「这是**已知缺口**不是设计取舍」) | `src/fleet/fleetProjection.ts` | 老引擎上 fleet 行没有活动文本;端做兜底渲染 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/client-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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",
|