dsh-instruction-memory 1.4.2 → 1.4.3

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
@@ -7,6 +7,67 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.4.3] — 2026-10-27
11
+
12
+ 技术方案评审团(3 名专家独立评审 + 逐条复现)列出的缺陷**全部修复**,并补上对应的回归断言。
13
+ 其中两条是**数据安全级**的:都会让用户以为"已保存"而实际丢数据。
14
+
15
+ ### 修复(数据安全)
16
+
17
+ - **跨进程静默覆盖(P0)**:两个 DSH 进程共享同一 `$DSH_HOME` 时,后保存的进程会把**自己那份
18
+ 过期内存**整份写回磁盘,另一个窗口刚保存的条目静默消失,调用方还收到"已保存"。原因是写盘时
19
+ 只把 `rev` 递增到 `max(自己, 磁盘)+1`(看起来单调,内容却是旧的)。现在写盘前先读磁盘 `rev`,
20
+ 发现磁盘更新即**拒绝写入**并提示"另一个 DSH 进程刚保存过,请重试"。
21
+ - **`.bak` 被不可用的主文件覆盖(P0)**:备份保护只判断"主文件字节数不为 0",于是一份**非空但
22
+ 解析不了**的 `memory.json`(截断、手工改坏、空白)会把唯一完好的 `.bak` 覆盖掉——恰好在最需要
23
+ 备份的时刻销毁它。现在只有**解析得出合法存储**(或主文件缺失/为空)才覆盖 `.bak`。
24
+
25
+ ### 修复(预算与注入)
26
+
27
+ - **用户形象绕过注入字数上限(P1)**:形象段此前完全独立于预算,十几个填满的形象能在指令预算
28
+ 还没被考虑之前注入约 5 万字符。现在形象与指令**共用同一个上限**:形象优先占用,指令拿余量,
29
+ 超出部分按顺序截断并说明;形象自身仍有 4000 字符硬上限。`text.length ≤ budget` 与
30
+ `profileChars ≤ min(4000, budget)` 在 3000 组随机存储形状上零越界。
31
+ - **用量条只计指令、不计形象(P1)**:宿主按 `entryChars + profileChars` 裁剪,面板却只显示
32
+ `entryChars`,于是宿主已经在截断、面板仍显示"很空"。现在两者都计入(老宿主缺 `entryChars`
33
+ 时不重复累加)。
34
+ - **代理对截断(P2)**:按 UTF-16 长度截断会切在代理对中间,产生半个 emoji/CJK 扩展 B 字符。
35
+ 新增 `cutText()`,截断时若末位是高代理项则少切一位;`cleanText()` 与所有截断点统一走它。
36
+
37
+ ### 修复(请求门禁)
38
+
39
+ - **端口不参与 `Origin` 比对(P1)**:`127.0.0.1:1` 这类同主机不同端口的页面被当成同源。
40
+ 现在要求 scheme + 主机名 + 端口一致(默认端口 80/443 归一为无端口)。
41
+ - **`same-site` 被放行(P1)**:回环绑定下同站判定不看端口,等于放行任意本地端口上的页面改写
42
+ 长期指令。现在回环(受保护)时 `cross-site` 与 `same-site` 都拒绝;非回环绑定只拒绝
43
+ `cross-site`(反向代理可能合法改写端口)。
44
+ - **FQDN 尾点绕过(P2)**:`localhost.` 与 `localhost` 被当作两个主机名。`hostnameOf()` 统一
45
+ 去尾点。
46
+ - **重复 `Host` 头(P2)**:多个 `Host` 头取第一个即可被前置代理注入绕过,改为直接拒绝。
47
+ - **`content-type` 用子串匹配(P2)**:`text/plain; x=application/json` 这类可绕过,改为精确
48
+ 解析 media type 并拒绝参数夹带。
49
+ - **SSE 连接无上限(P2)**:订阅数不设限可被本机进程耗尽。新增连接上限,超出即以 503 拒绝。
50
+
51
+ ### 修复(其他)
52
+
53
+ - **`{enabled:true, disabledUntil:0}` 被拒(P2)**:两者都表示"未暂停",却被当成矛盾组合拒绝。
54
+ 现在只有**真实暂停**(未来时刻)与总开关同传才算矛盾。
55
+ - **`install.mjs --remove` 半卸载(P2)**:链接路径是真实目录(pnpm 物化的副本)时拒绝删除,
56
+ 但拒绝之后仍然把 `dependencies` 与 `dsh.profile.bundles` 里的条目删掉了——插件还在磁盘上,
57
+ profile 却报告它不存在。现在拒绝即**原样退出**,不碰 `package.json`。
58
+ - **`install.mjs` 卸载时 `unlinkSync` 抛错(P2)**:链接被占用时抛出未捕获异常,看起来像崩溃。
59
+ 现在捕获并提示"关闭 DSH 后重试",退出码非 0。
60
+ - **版本历史去重键含 `rev`(P2)**:同一份内容在不同 `rev` 下被重复记为两份。去重键改为内容哈希。
61
+ - **集合复制的命名(P2)**:派生集合的名字没做去重,可能出现两个同名集合。
62
+ - **临时文件与 0600 权限(P2)**:临时文件改为 `0600` 创建,避免短暂以默认权限暴露存储内容。
63
+
64
+ ### 测试
65
+
66
+ - 新增 12 项 `install.mjs` 断言(真实子进程跑安装/卸载/幂等/拒绝半卸载)。
67
+ - 新增 4 项跨进程与备份保护断言(真实双 handler 实例 + 三种不可用主文件)。
68
+ - 新增 4 项静音断言与 2 项用量条断言;`npm test` 全套通过(`lint` 在本机沙箱内因禁止管道子进程
69
+ 而误报,改用逐文件 `node --check` 等价校验)。
70
+
10
71
  ## [1.4.2] — 2026-10-24
11
72
 
12
73
  适配性复核(宿主 `0.2.1-rc.3`,桌面端 + 网页端)。**没有任何运行时代码改动**——这一版把复核证据
package/README.md CHANGED
@@ -77,8 +77,11 @@ dsh plugin --profile web remove dsh-instruction-memory
77
77
  (多个会作为同一画像的不同侧面综合)。启用后,形象段会以「## 关于用户(用户形象)」为抬头注入在
78
78
  指令块**之前**,抬头明确把它限定为"仅供调整回答侧重":AI 应在**保证回答正确、完整、客观且扣题**的
79
79
  前提下据此调整解释深浅、详略、举例与建议方向,**不得为了迎合而改变事实、省略风险提示或偏离问题本身**,
80
- 无关时直接忽略。形象不是让 AI 扮演的角色。形象段独立于指令字数上限,始终完整注入、不做预算裁剪;
81
- 页面会显示已启用形象的合计字符数,便于你权衡提示词体积。总开关关闭时形象与指令一起停用。
80
+ 无关时直接忽略。形象不是让 AI 扮演的角色。形象段与指令**共用同一个注入字数上限**:
81
+ 形象优先占用,指令只拿剩下的余量(早期版本让形象完全绕过预算,十几个填满的形象能在指令预算还没被
82
+ 考虑之前就塞进约 5 万字符)。形象自身另有 4000 字符的硬上限,超出部分按顺序截断并说明。
83
+ 页面上的用量条**同时计入**形象与指令,所以它显示的比例就是宿主真正用来裁剪的那个数字。
84
+ 总开关关闭时形象与指令一起停用。
82
85
 
83
86
  总开关关闭时,该提示词段落(指令与用户形象)**整个注销**,系统提示词与未安装本插件时逐字节一致;
84
87
  总开关开启但没有任何生效指令、也没有启用形象时,段落同样不出现。
@@ -131,6 +134,10 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
131
134
  > 记忆看起来凭空消失,而真实数据其实好好躺在 `.bak` 里。改为复制后主文件在原子替换成功
132
135
  > 前始终待在原位;同时启动时若发现 `memory.json` 缺失而 `.bak` 可用,会**从备份恢复并写回**
133
136
  > 主路径,而不是从零开始。
137
+ >
138
+ > 覆盖 `.bak` 之前会先确认**主文件确实可用**:只有解析得出合法存储(或主文件为空/缺失)才覆盖。
139
+ > 早期版本只判断"字节数不为 0",于是一份被截断的 `memory.json`(非空但解析不了)会把唯一完好的
140
+ > `.bak` 覆盖掉——恰好在最需要它的时刻销毁了它。现在"非空但不可解析"的主文件**不会**动 `.bak`。
134
141
 
135
142
  ### 多实例与多窗口(并发保护)
136
143
 
@@ -144,6 +151,11 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
144
151
  请求都带上它看到的 `rev`(`baseRev`);若与服务端当前 `rev` 不一致(另一个窗口/进程刚
145
152
  提交过),服务端拒绝本次修改并返回**最新快照**,面板自动刷新并提示重试——防止旧窗口
146
153
  把别的窗口刚删除的条目"复活"回去。不带 `baseRev` 的旧客户端保持原有行为。
154
+ - **内存与磁盘的修订号比对**:写盘前先读一次磁盘上的 `rev`。若磁盘已经被另一个进程推到更高的
155
+ `rev`,说明本进程内存里的那份快照已经过期,此时**拒绝写入并提示"另一个 DSH 进程刚保存过"**,
156
+ 而不是继续写。早期版本只把 `rev` 递增到 `max(自己, 磁盘)+1`,看起来修订号单调,实际上是把
157
+ 过期内存整份盖回磁盘——另一个窗口刚存的东西会**静默消失**,调用方还收到"已保存"。
158
+ `baseRev` 是"同一进程内"的乐观锁,这一条补的是"跨进程"的那一半。
147
159
 
148
160
  ### 外部改动自动刷新(SSE)
149
161
 
@@ -533,13 +545,15 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
533
545
 
534
546
  - 绑定到**任何非回环地址**(`--host 0.0.0.0`、`::` 或具体 IP,即主动对外发布)时 Host 校验**自动关闭**——那种情况下无法预知合法 Host,强行限制只会打断所有正常客户端;`Origin` 校验仍然生效。
535
547
  - 无 `Host` 的 HTTP/1.1 请求由 Node 的 HTTP 解析器先以 400 拒掉,根本到不了路由。
536
- - 端口不参与 `Origin` 比对(反向代理可能改写端口),主机名才是重绑定攻击的对象。
548
+ - **端口参与 `Origin` 比对**(早期版本忽略端口,于是 `127.0.0.1:1` 这种同主机不同端口的页面也算同源):比对前先把默认端口归一(`http:80` / `https:443` 视同未写端口),其余情况要求 scheme、主机名、端口三者一致。`Host` 末尾的 FQDN 点(`localhost.`)会被忽略,避免同一台机器的两种写法被当成跨源。
537
549
 
538
550
  此外路由还读取 **`Sec-Fetch-Site`**(Fetch Metadata):现代浏览器给每个请求都打上它,
539
551
  即使某些技巧(如 `no-cors` fetch)不带 `Origin`,`evil.com` 发来的请求仍会被标记为
540
- `cross-site`——命中即 **403**。仅拒绝 `cross-site`;`same-site` 必须放行(同站判定不看端口,
541
- GUI 与路由本就同主机不同端口),`none`(用户直接导航)放行;curl、Node 与 2020 年前的
542
- 老浏览器根本不发这个头,也不受影响(它们由 Host 与 content-type 校验兜底)。
552
+ `cross-site`——命中即 **403**。在回环绑定(受保护)时 **`cross-site` 与 `same-site` 都拒绝**:
553
+ "同站"判定不看端口,而回环上的任何其他端口都是别人跑的本地服务,放行 `same-site` 等于让任意
554
+ 本地页面改写你的长期指令。`none`(用户直接导航)放行;curl、Node 与 2020 年前的老浏览器根本
555
+ 不发这个头,也不受影响(它们由 Host 与 content-type 校验兜底)。非回环绑定(主动对外发布)时
556
+ 只拒绝 `cross-site`,因为反向代理可能合法地改写端口。
543
557
 
544
558
  ### 经反向代理对外发布(自行承担风险)
545
559
 
package/lib/client.js CHANGED
@@ -1224,9 +1224,18 @@ window.__ModuleLoader__.load({
1224
1224
  // `entryChars` is the budgeted half. Older hosts publish neither field,
1225
1225
  // and there `chars` is already instructions-only, so the fallback is
1226
1226
  // correct on both.
1227
- const used = typeof inj.entryChars === 'number' && inj.entryChars >= 0
1227
+ const entryUsed = typeof inj.entryChars === 'number' && inj.entryChars >= 0
1228
1228
  ? inj.entryChars
1229
1229
  : (typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0)
1230
+ // The Host caps `entryChars + profileChars` against the same `budget`, so
1231
+ // measuring `entryChars` alone made the meter understate usage by the
1232
+ // whole persona block and read green while the Host was truncating.
1233
+ // Counted only alongside `entryChars`: without it the fallback above is
1234
+ // already the whole section, and adding profiles again would double-count.
1235
+ const profileUsed = typeof inj.entryChars === 'number' && inj.entryChars >= 0
1236
+ && typeof inj.profileChars === 'number' && inj.profileChars >= 0
1237
+ ? inj.profileChars : 0
1238
+ const used = entryUsed + profileUsed
1230
1239
  const countFlagged = (list, wantTruncated) =>
1231
1240
  Array.isArray(list)
1232
1241
  ? list.filter((item) => item !== null && typeof item === 'object'