dsh-instruction-memory 1.4.0 → 1.4.1

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,123 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.4.1] — 2026-10-22
11
+
12
+ 运行期缺陷(桌面端 / 网页版审计)与安全审查(对 1.4.0 源码)发现的问题修复,
13
+ 加上双宿主(网页端 :8080 / 桌面端 :19387,宿主均为 0.2.1-rc.2)适配性审计后的加固。
14
+
15
+ ### 修复
16
+
17
+ 桌面端 / 网页版审计(宿主 `0.2.1-rc.2`、Electron 44.7.0)发现的三条运行期缺陷:
18
+
19
+ - **指令里出现 `{{…}}` 会让整个 Agent 回合崩溃**:`systemPrompt.section()` 注册时没有关掉宿主的
20
+ 模板插值。宿主 `renderPrompt()` 默认对每个 section 做 `{{变量}}` 替换,遇到无法解析的引用直接抛
21
+ `unknown prompt variable "{{x}}" in section "instruction-memory"`;而 agent-loop 在 `step()` 里
22
+ 裸调 `renderPrompt()`(无 try/catch),于是一条写着 `proxy_pass {{upstream}};` 的指令
23
+ (Nginx 片段、Vue/Jinja/Handlebars 模板都算)就会终止当前回合。现在注册时显式传
24
+ `interpolate: false`,该 section 按字面量渲染。
25
+ - **桌面端「批量加标签」点击即抛异常**:Electron 44 把 `window.prompt` 替换成
26
+ `function () { throw new Error("prompt() is not supported.") }`,并经 contextBridge 推进主世界,
27
+ `typeof` 仍返回 `'function'`,原来的类型守卫形同虚设。现在调用点用 try/catch 判定
28
+ (`batchTagOutcome()`):prompt 缺失或抛错时改用面板内的行内输入框;用户主动取消(返回 `null`)
29
+ 仍按「取消」处理,不会被误判成"环境不支持"。
30
+ - **桌面 profile 缺依赖声明**:`profiles/desktop/package.json` 的 `dsh.profile.bundles` 列了
31
+ `dsh-instruction-memory`,`dependencies` 却没有它、`pnpm-lock.yaml` 也无记录,运行期靠手工
32
+ junction 维持。该 profile 下次 `pnpm install`(插件增删/更新都会触发)会清掉链接,随后 `dsh`
33
+ 只在 stderr 打一行 `skipping profile bundle` 就静默跳过,插件在桌面端凭空消失。现在补齐
34
+ `dependencies` 里的 `link:` 声明。
35
+
36
+ 安全审查(对 1.4.0 源码)发现的问题:
37
+
38
+ - **并发提交可绕过 30 秒冷却、重复投递**:冷却时间戳原本在 SMTP 会话结束后才写入,
39
+ 两个同时到达的请求都能通过检查、各发一封。现在冷却在发送开始前就占位,失败时立即释放。
40
+ - **面板 15 秒超时短于宿主的投递上限**:SMTP 慢时页面报"超时",而邮件仍在路上,
41
+ 用户重试就会收到重复邮件。面板改为最多等待 90 秒,宿主新增整封投递上限 `SMTP_TOTAL_TIMEOUT_MS`(60 秒);
42
+ `verify.mjs` 固定两者的大小关系。
43
+ - **配置文件里 `smtp.pass` 写成数字时被静默丢弃**:端口/`secure` 早已按 JSON 标量处理,密码却只接受字符串,
44
+ 导致"密码明明填了却认证失败"。现在数字会被字符串化,对象/数组则明确报 `smtp.pass 类型无效`。
45
+ - **`to` 与 `toEncrypted` 同时存在时明文静默优先**:会让"我已加密"变成假象,损坏的密文也不会被发现。
46
+ 现在直接拒绝并说明原因;环境变量 `..._FEEDBACK_TO` 仍可临时覆盖。
47
+ - **地址校验过于宽松**:`EMAIL_RE` 原本放行 NUL 等控制字节与非 ASCII 地址,
48
+ 而 `buildRawMail` / `sendSmtpMail` 依赖调用方先校验。现在校验排除控制字节,
49
+ 非 ASCII 地址明确报错(未实现 SMTPUTF8),且组信与发信入口各自再做一次换行/控制字符断言。
50
+ - **`\bLOGIN\b` 误判 AUTH 机制**:EHLO 中任何含独立单词 `LOGIN` 的扩展都会让客户端错误选择 `AUTH LOGIN`。
51
+ 现在只解析 `AUTH` 能力行(RFC 4954)。
52
+ - **响应行缓冲无上限**:行为异常或恶意的服务器可持续发送无 CRLF 数据撑大内存,
53
+ 现在上限 64 KiB,超出即断开并报 `SMTP 响应过长`。
54
+ - **超长主题只生成一个 RFC 2047 编码词**:最长约 240 字符,部分客户端无法解码。
55
+ 现在按 45 字节切分为多个、每个不超过 75 字符的编码词。
56
+ - **日志脱敏漏掉 SMTP 用户名**:服务器拒绝文本可能回显登录账号,现在用户名也一并隐藏。
57
+
58
+ ### 优化(源码审查第二轮)
59
+
60
+ - **相对 `$DSH_HOME` 会让存储路径保持相对**:`resolveStorePath()` 原样返回
61
+ `rel-home\instruction-memory\memory.json`,与 README「设置页显示完整绝对路径」矛盾,
62
+ `dirname()` 还会退化成 `.`,同一份存储在进程内的身份取决于路径写法。现在相对的
63
+ `$DSH_HOME` 在启动时按 cwd 解析为绝对路径(空白仍视为未设置);`install.mjs` 同步修正。
64
+ - **陈旧锁删不掉时保存会永久挂起**:`acquireStoreLock()` 的 `unlink` 失败被吞掉后直接
65
+ `continue`,绕过了下面的 deadline 判断;只要锁文件持续不可删(Windows 句柄未共享删除、
66
+ 只读卷),这次保存就永远不返回,后续所有磁盘操作都堵在同一队列里。deadline 现在在每次
67
+ 尝试的**顶部**判断,且每次失败都先等待 50 ms 再重试。
68
+ - **面板把每字段上限硬编码**:编辑器写死 120/6000/200 与标签 8/24,而 `snapshot.limits`
69
+ 只发布了部分上限——宿主改上限后面板会接受加载器随后截断的文本。现在
70
+ `maxTitle`/`maxContent`/`maxWhen`/`maxTagsPerEntry`/`maxTagLength` 全部随快照发布,
71
+ 编辑器按快照取值(对旧宿主仍保留原默认值)。
72
+ - **历史快照只按份数保留,磁盘占用无上限**:满配存储一份快照约 3.6 MB,20 份约 72 MB,
73
+ 自定义备份目录指向云盘时等于同量上传。新增按设备的 **32 MB 字节预算**,超出时从最旧
74
+ 逐出、最新一份永远保留;策略抽成纯函数 `planHistoryPrune()` 便于单测。
75
+ - **SSE 每帧都推全量快照**:满配时约 1.24 M 字符(中文约 3.4 MB)/帧,且每次保存推给
76
+ 每个打开的设置页。设置页现在以 `?lite=1` 订阅,宿主只推 `{ rev, lite: true }`(约 40
77
+ 字节),客户端发现 rev 落后再拉 `state`;不带该参数的旧客户端仍收到完整快照,双向兼容。
78
+ - **可访问性**:表单 `<label>` 全部补 `htmlFor`/`id`(此前 0 个关联,点标签不聚焦、读屏
79
+ 不关联);「展开全部/收起」从可点击 `div` 改为 `<button aria-expanded>`;错误与提示分别
80
+ 补 `role="alert"` / `role="status"`;标签删除按钮补可访问名「移除标签 X」。
81
+ - **编辑器错误提示不可见**:条目编辑器是提前返回的分支,不渲染面板级错误行——空标题保存、
82
+ 冲突或写盘失败时 `setError()` 设了消息却什么都不显示。现在编辑器内自带错误行(并带
83
+ `role="alert"`)。
84
+ - **发布包体积**:`docs/screenshot.png` 由 262 KB 压到 97 KB(同尺寸调色板量化),
85
+ 整个 tarball 从 435 KB 降到约 270 KB。
86
+ - **发布前跑测试**:`prepublishOnly` 现在执行 `check-metadata.mjs && npm test`,本地
87
+ `npm publish` 不再能绕过测试套件(CI 的 publish.yml 原本就会跑)。
88
+
89
+ ### 优化(双宿主审计跟进,宿主 0.2.1-rc.2 / Electron 44)
90
+
91
+ - **破坏性确认的降级策略前后矛盾**:批量删除、集合删除、形象删除、单条删除写的是
92
+ `typeof window.confirm === 'function' && !window.confirm(…)`——没有对话框时条件整体为
93
+ `false`,删除**照常执行**;而版本回滚与整体恢复是「没有对话框就拒绝」。同一类不可恢复
94
+ 操作在缺 `window.confirm` 的宿主里行为相反。现在统一走 `askConfirm(message, unavailable)`:
95
+ 持久数据类默认 `deny`(无对话框即拒绝,并在面板显示 `confirm.unavailable`,不再静默吞掉
96
+ 点击),只有「丢弃未保存草稿」类(退出编辑器、重载源码视图)用 `allow`——那里拒绝会把
97
+ 人困在死按钮上,且不涉及已落盘数据,`verify.mjs` 对此有既有守护。
98
+ 返回值改为三态 `yes`/`no`/`unavailable`,用户主动取消不会被误报成「环境不支持」;
99
+ `confirm` **存在但调用即抛**(Electron 44 给 `prompt()` 用的正是这种桩)按缺失处理,
100
+ 而原先的 `typeof` 判断对这种桩完全失效。
101
+ - **窄窗口双列表单挤成竖排断字**:`.im-grid2` 固定 `1fr 1fr` 且没有任何断点,桌面端把窗口
102
+ 拉窄后每列不足 250px,标签与占位文本逐词换行。新增 560px 断点:表单转单列,批量标签
103
+ 输入去掉 160px 下限并独占一行(此前会横向溢出)。
104
+ - **双端冒烟没有一条可复现的命令**:新增 `live-host-smoke.mjs`(`npm run smoke:hosts`),
105
+ 一次测完 `state` 的 p50/p95、SSE 首事件、注入注册与字符数、以及两端快照的逐字段一致性;
106
+ 任一项超阈值(p50 > 50 ms)或快照出现 `injection.pulls` 之外的差异即以退出码 1 失败。
107
+ 脚本内全部请求走 Node `http` 直连,**不继承 `http_proxy`**——本机代理指向 `127.0.0.1:9708`,
108
+ 连回环请求也转发,审计期间正是它让 SSE 读数从 0.7 ms 虚高到 6 ms。
109
+ `--require-pulls` 把「该宿主从未经历真实 Agent 回合」判为失败,用于桌面端注入链路的验收。
110
+ 该脚本**刻意不接入 `npm test`**:CI 没有桌面端,接进去必然常红。
111
+
112
+ ### 测试
113
+
114
+ - `verify.mjs` 新增运行期缺陷的回归用例:注册的 section 必须带 `interpolate: false`
115
+ (宿主渲染期崩溃的守卫)、`__batchTagOutcome()` 的四种输入(正常作答 / `null` 取消 /
116
+ 抛错 / 非字符串答案)。并在宿主 `0.2.1-rc.2` 的**真实** `renderPrompt()` 上端到端确认:
117
+ 带该标志时含 `{{upstream}}` 的指令按字面量渲染,去掉标志则复现原报错。
118
+ - `feedback-test.mjs` 新增上述每一条的回归用例:并发路由(只投递一封、且不额外建连)、
119
+ 数字/非法类型 `pass`、`to`+`toEncrypted` 冲突、控制字节与非 ASCII 地址、
120
+ 组信头部注入断言、长主题切分与还原、AUTH 机制解析、60 秒总上限、无 CRLF 洪泛的有界缓冲。
121
+ 假 SMTP 服务器支持自定义 EHLO 能力行与洪泛选项。
122
+ - 本轮新增:`smoke-test.mjs` 覆盖相对 `$DSH_HOME` 的绝对化与 `planHistoryPrune()` 的
123
+ 份数/字节/保底规则;`contract-test.mjs` 断言五个逐字段上限随快照发布且与代码一致,
124
+ 并验证 `?lite=1` 的连接帧与变更帧都是紧凑 rev 标记、普通流仍收完整快照;
125
+ `ui-test.mjs` 断言每个字段标签都指向真实控件、校验错误带 `role="alert"`。
126
+
10
127
  ## [1.4.0] — 2026-10-18
11
128
 
12
129
  ### 新增
package/README.md CHANGED
@@ -44,6 +44,7 @@ dsh plugin --profile web remove dsh-instruction-memory
44
44
  | `smoke-test.mjs` | 注入渲染逻辑的单元测试(含「只渲染激活配置集」的隔离断言与 cwd 红线) |
45
45
  | `host-range-test.mjs` | 宿主版本范围契约测试:固定矩阵 × 两种 semver 判定模式,并把 `peerDependencies`(**被执行的那个**)、`dsh.engines.dsh`、`dsh.compatibility.dsh` 三处声明锁到同一个生成值 |
46
46
  | `lint.mjs` | 语法门禁:`node --check` 解析所有随包发布与本仓库运行的 JS 文件,已并入 `npm test` |
47
+ | `live-host-smoke.mjs` | 双端冒烟:对**正在运行**的 web / 桌面宿主各测 `state` p50/p95、SSE 首事件、注入注册与字符数,并比对两端快照(除 `injection.pulls` 外须逐字段相同);`npm run smoke:hosts`,`--require-pulls` 可把「从未经历真实 Agent 回合」判为失败。**刻意不进 `npm test`**——CI 没有桌面端。请求走 Node `http` 直连,绕开 `http_proxy`(否则回环请求也会被代理转发,读数虚高) |
47
48
  | `docs/demo.html` | 离线演示页:桩掉 DSH 外壳、加载真实 Client 半渲染设置页(不随 npm 包发布) |
48
49
  | `memory.schema.json` | 手改存储文件用的 JSON Schema(随包发布,并被宿主复制到 `memory.json` 旁边) |
49
50
 
@@ -69,6 +70,8 @@ dsh plugin --profile web remove dsh-instruction-memory
69
70
  多选与上方的筛选/搜索叠加(「全选」只作用于当前可见条目)。整批修改在宿主侧是**单次写入**
70
71
  (只产生一次落盘、一次修订号递增与一次同步推送),全部条目已是目标状态时不写盘;批量删除需确认。
71
72
  批量的启停/优先级/标签调整不重盖更新时间,因此不会改变同优先级下的注入先后。
73
+ 「加标签」在浏览器里用原生输入框;**桌面版(Electron)不提供 `window.prompt`**(调用即抛错),
74
+ 此时自动改用面板内的行内输入框(回车提交、Esc 取消),功能不受影响。
72
75
  - **用户形象**:除了"要求 AI 怎么做"的指令,你还可以记录**关于你本人**的信息(身份背景、喜好、性格、
73
76
  长期追求等),每张形象卡片可单独勾选「使用该形象」——可以一个都不用、只用一个、或同时勾选多个
74
77
  (多个会作为同一画像的不同侧面综合)。启用后,形象段会以「## 关于用户(用户形象)」为抬头注入在
@@ -104,7 +107,8 @@ dsh plugin --profile web remove dsh-instruction-memory
104
107
 
105
108
  路径优先级与官方 `@deepseek-ai/dsh-home-paths` 一致:显式 `$DSH_HOME` → `~/.dsh`。
106
109
  **空白的 `$DSH_HOME` 视为未设置**,绝不会退回当前工作目录——否则用户换个目录启动
107
- DSH 就会得到第二份空记忆,看起来像"记忆消失"。
110
+ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DSH_HOME`(如 `./data`)会在
111
+ 启动时按当前目录解析为绝对路径,因此设置页显示的始终是完整路径。
108
112
 
109
113
  > 1.0.0 之前的构建曾把数据写在宿主进程 cwd 下(`.dsh-instruction-memory.json`)。
110
114
  > 首次启动新版本时会自动迁移到上述位置,并把原文件改名为 `*.migrated` 保留。
@@ -242,6 +246,9 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
242
246
  - 每台设备**各保留最近 20 份**(按文件名里的设备标签分组),逐出最旧的。两台机器共用一个
243
247
  备份目录时,谁也不会把对方的历史剪掉。不符合该命名的文件(手工放的、别的工具写的、
244
248
  云盘的冲突副本)**一律不碰**。
249
+ - 份数之外还有**32 MB 字节预算**(同样按设备计算):满配存储(200 条 × 6000 字符)的一份
250
+ 快照约 3.6 MB,只按份数保留会到约 72 MB;超出预算时从最旧的开始逐出,但**最新一份永远
251
+ 保留**(哪怕它自己就超过预算)。
245
252
  - **为什么放在 `history/` 子目录**:`memory.json` 所在目录被 `fs.watch` 监听以做外部改动
246
253
  自动刷新,快照绝不能混进去触发重载;子目录不在非递归监听的范围内,文件名过滤也会兜底。
247
254
  - 设置页「版本历史」里可展开列表(时间、rev、条目/形象数、体积、是否来自其他设备),
@@ -399,7 +406,7 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
399
406
 
400
407
  | 变量 | 必填 | 说明 |
401
408
  | --- | --- | --- |
402
- | `DSH_INSTRUCTION_MEMORY_FEEDBACK_TO` | 是 | 收件邮箱(单个地址) |
409
+ | `DSH_INSTRUCTION_MEMORY_FEEDBACK_TO` | 是 | 收件邮箱(单个地址,仅 ASCII;非 ASCII 地址会明确报错而不是发出乱码头) |
403
410
  | `DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_HOST` | 是 | SMTP 服务器主机名 |
404
411
  | `DSH_INSTRUCTION_MEMORY_FEEDBACK_FROM` | 否 | 发件地址,默认取 SMTP 用户名;部分服务商要求与登录账号同域 |
405
412
  | `DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_PORT` | 否 | 1–65535,默认按 `_SMTP_SECURE` 取 465 / 587 |
@@ -467,11 +474,13 @@ smtp://用户:密码@smtp.example.com:587?secure=0&from=bot@example.com
467
474
  `DSH_INSTRUCTION_MEMORY_FEEDBACK_SECRET=……`
468
475
  2. 用插件自带的小工具生成密文(密钥从环境变量读取,不会打印到终端):
469
476
  `node lib/mailer.js encrypt-to you@example.com`
470
- 3. 把输出的 `im1:…` 粘贴为 `toEncrypted`,删除 `"to"`,然后重启 DSH(环境变量只在启动时读取)。
477
+ 3. 把输出的 `im1:…` 粘贴为 `toEncrypted`,删除 `"to"`(同时保留两者会被拒绝),然后重启 DSH
478
+ (环境变量只在启动时读取)。
471
479
 
472
480
  实现是 AES-256-GCM + scrypt 派生密钥,密文带认证标签:改错一个字符或密钥不匹配都会明确报
473
- `无法解密反馈收件邮箱`,不会静默换收件人。若同时存在 `to`(环境变量或文件),明文优先,
474
- 方便临时覆盖。
481
+ `无法解密反馈收件邮箱`,不会静默换收件人。配置文件里同时留下 `to` 和 `toEncrypted` 会被**直接拒绝**
482
+ (否则明文一直有效,加密只是摆设);环境变量 `DSH_INSTRUCTION_MEMORY_FEEDBACK_TO` 仍优先于
483
+ 文件里的 `toEncrypted`,用于临时改收件人。
475
484
 
476
485
  **能力边界(请勿误解)**:SMTP 信封必须把收件人明文交给服务器,TLS 只保护传输链路;
477
486
  本加密保护的是配置文件**静态副本**,不防"能以同一 Windows 用户身份读取环境变量和文件"的攻击者,
@@ -488,11 +497,17 @@ smtp://用户:密码@smtp.example.com:587?secure=0&from=bot@example.com
488
497
  - **拒绝在明文连接上发送密码**:服务器不提供 STARTTLS 时会直接失败,而不是降级。
489
498
  - 主题与正文均为 UTF-8(RFC 2047 编码主题、base64 正文),中文不会乱码。
490
499
  - 成功后清空草稿并提示"反馈已发送";失败时保留草稿,页面显示原因,宿主日志记一行脱敏错误。
491
- - 默认 30 秒冷却:同一进程内连续提交会被拒绝(防误触重复发信),可用上面的变量调整。
500
+ - 面板为一次发送最多等待 90 秒,宿主自身的整封投递上限是 60 秒:面板总比宿主晚放弃,
501
+ 因此"超时"提示出现时邮件不会再迟到地发出去。
502
+ - 默认 30 秒冷却:同一进程内连续提交会被拒绝(防误触重复发信),可用上面的变量调整;
503
+ 冷却在发送**开始**前就占位,所以两个同时到达的请求也只有一个会被投递。
492
504
 
493
505
  常见失败:`尚未配置反馈收件邮箱` → 环境变量/文件未生效(环境变量改完要重启 DSH;
494
506
  配置文件每次发送都会重新读取,无需重启);`反馈收件邮箱已加密,但未设置 …SECRET` 或
495
507
  `无法解密反馈收件邮箱` → 密钥缺失/不匹配,重新执行 `encrypt-to` 并同步更新环境变量;
508
+ `配置文件同时包含 to 与 toEncrypted` → 删掉明文 `to`;`反馈收件邮箱含非 ASCII 字符`
509
+ → 换成 ASCII 地址(未实现 SMTPUTF8);`配置文件 smtp.pass 类型无效` → 密码写成 JSON 字符串;
510
+ `SMTP 总耗时超过 60000 ms` → 服务端长时间无响应,检查主机/端口/网络;
496
511
  `SMTP 连接失败` → 主机/端口/防火墙;`STARTTLS 失败` → 服务商要求 465 隐式 TLS;
497
512
  `认证失败` → 用户名/密码或应用专用密码,部分邮箱需先开启 SMTP 服务;
498
513
  `发件地址被拒绝` → 显式设置 `FROM` 为与账号同域的地址。
Binary file