dsh-instruction-memory 1.4.4 → 1.4.6

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,73 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.4.6] — 2026-10-30
11
+
12
+ 针对 1.4.5 的三方复核(架构 / 安全 / 验收)结论做的修复版本。安全面**没有新增对外暴露**,
13
+ 下面的每一条都是"本该做对但做漏了"的一致性缺陷;无鉴权路由属已知设计取舍,本版不改。
14
+
15
+ ### 修复
16
+
17
+ - **非回环绑定的 `Origin` 校验此前是自证**(`requestOriginVerdict`):绑定具体地址时 Host 校验
18
+ 关闭,而 `Origin` 仍与**请求自带的 `Host`** 比较——两者都由攻击者提供,于是
19
+ `Host: attacker.example` + `Origin: http://attacker.example` 在 LAN 绑定上直接通过。现在绑定
20
+ 具体地址时 `Origin` 必须等于**该绑定地址**;通配绑定(`0.0.0.0` / `::`)仍无从得知合法来源,
21
+ 行为不变。回环绑定不受影响,`127.0.0.1` / `localhost` / `[::1]` / `[::ffff:127.0.0.1]` 互访照旧。
22
+ - **`export-to-dir` 的成功消息泄露绝对路径**:消息里回的是 `已写入 C:\Users\<账号>\...`,而同
23
+ 一响应的 `storage.path` 早已折叠——1.4.4 的路径折叠漏了这条命令。现在同样折叠。
24
+ - **`storage.migratedFrom` 未折叠**:迁移提示里的旧库绝对路径原样下发,现与其它路径字段一致。
25
+ - **`displayPath` 大小写敏感**:Windows 上 `c:\users\me` 与 `C:\Users\me` 是同一目录,早期按
26
+ 字符串前缀比较,大小写不同就不折叠,等于把原始家目录漏出去。现在 Windows 路径比较不区分大小写。
27
+ - **错误文本的折叠与快照不一致**:`describeErrorSafe` 在 Windows 折成 `~`,而快照字段用
28
+ `%USERPROFILE%`(资源管理器/CMD 都会展开,`~` 不会)。两者统一为同一 token。
29
+ - **`install.mjs` 在读写 manifest 之前就动了文件系统**:`--remove` 先删 junction 再解析
30
+ `package.json`,安装分支先建 junction/滚备份再解析——一个损坏的 manifest 就留下"依赖还在、
31
+ junction 没了"的半状态,而脚本只抛一个 JSON 解析错误。现在两种情况都**先解析、后动手**,
32
+ 失败时明确指向 `.bak` 备份且不改动任何文件。
33
+
34
+ ### 明确未修
35
+
36
+ - **设置路由没有鉴权**:插件路由挂在宿主 Web 服务器上,宿主只保护自己的路由(无 token 时 `/` 返回
37
+ 401),插件这条路径仍是同端口无鉴权可读写。宿主 `0.2.1-rc.5` 未提供任何鉴权 API,插件层无法
38
+ 自行补上而不与宿主 token 逻辑打架。默认回环绑定下只有本机可达;`--host <LAN IP>` 时才暴露到
39
+ 网络,README「对外发布」一节已标注。本版不声称此项已解决。
40
+
41
+ ### 测试
42
+
43
+ - 新增断言:非回环绑定的 Host+Origin 伪造组合必须 403、回环绑定的映射地址写法仍须放行、
44
+ `export-to-dir` 消息与快照全部路径字段不含原始家目录、Windows 不同大小写的家目录仍折叠、
45
+ `install.mjs` 在 manifest 损坏时(装/卸两个方向)不改动文件系统并给出恢复指引。
46
+ - 契约范围表补入 `0.2.1-rc.5`(本轮复核的宿主版本)。
47
+ - 全套 6 个套件 945 条断言通过。
48
+ ## [1.4.5] — 2026-10-29
49
+
50
+ 对 1.4.4 发布物与最新桌面端 / 网页端复核(桌面 `0.2.1-rc.3`,网页 `0.2.1-rc.4` 与
51
+ `0.2.2-alpha.2`)后修复的缺陷。
52
+
53
+ ### 修复
54
+
55
+ - **发布包自依赖**:1.4.4 的 `dependencies` 里混入了 `dsh-instruction-memory@^1.4.3`,
56
+ pnpm 安装时会多拉一份嵌套的 1.4.3(`pnpm-lock.yaml` 同时出现 1.4.3 与 1.4.4)。
57
+ 已删除整个 `dependencies`(本插件没有运行时依赖),并新增发布前与 `verify.mjs` 断言,
58
+ 禁止包依赖自身。
59
+ - **Windows 上折叠路径不可用**:快照把家目录折叠成 `~`,Windows 资源管理器 / CMD 不会展开它。
60
+ 现在 Windows 折叠为 `%USERPROFILE%`(资源管理器、CMD、PowerShell 都会展开),macOS/Linux
61
+ 仍为 `~`;新增 `expandDisplayPath()` 作为 `displayPath()` 的逆函数,README 与代码注释同步。
62
+ - **L3 验证脚本假阴性**:`compat-1016-live-check.mjs` 直接用折叠后的 `storage.path` 与绝对
63
+ `DSH_HOME` 比较,1.4.4 之后 `store-inside-home` / `store-on-disk` 必然 FAIL。现在先展开
64
+ 平台占位符再判定;隔离宿主复验 **21/21 PASS**。
65
+ - **全新 profile 安装失败**:`install.mjs` 在 `profiles/web/node_modules` 不存在时
66
+ `symlinkSync` 报 `ENOENT`。现在先 `mkdirSync(dirname(link), { recursive: true })`,
67
+ 并新增“没有 node_modules 的全新 profile”回归测试。
68
+
69
+ ### 兼容区间
70
+
71
+ - 尾部收窄为 `>=0.2.0-0 <0.3.0-0`:1.4.4 写的 `<0.4.0-0` 仍会放行未发布的 `0.3.0`,
72
+ 与“新 minor 必须显式加一行”的承诺不符。`0.3.0` 与 `0.3.0-rc.1` 现在在默认与
73
+ `includePrerelease` 两种语义下都被拒绝。
74
+ - 更正 1.4.4 说明中的版本错误:npm `alpha` 标签当时是 `0.2.2-alpha.2`,不是 `alpha.5`;
75
+ `0.2.2-alpha.2` 已在网页隔离宿主实机启动验证(路由 200、section 注册、设置页渲染)。
76
+
10
77
  ## [1.4.4] — 2026-10-29
11
78
 
12
79
  最新版审计(桌面端 0.2.1-rc.3、网页端同一宿主,并对照 npm `next` 上的 0.2.1-rc.4)发现的缺陷修复。
@@ -21,8 +88,8 @@
21
88
 
22
89
  ### 兼容区间
23
90
 
24
- - 尾部 `|| >=0.2.0-0` **没有上界**:`0.3.0`、`1.0.0`、`9.9.9` 都被判兼容。现收窄为 `>=0.2.0-0 <0.4.0-0`。
25
- - 新增 `>=0.2.2-alpha.0 <0.3.0-0`:semver 只在同一 tuple 上匹配预发布号,`0.2.0` 那段够不到 `0.2.2-alpha.N`(npm `alpha` 标签当前即 `0.2.2-alpha.5`)。该线**只做过契约比对,尚未实机启动**。
91
+ - 尾部 `|| >=0.2.0-0` **没有上界**:`0.3.0`、`1.0.0`、`9.9.9` 都被判兼容。现收窄为 `>=0.2.0-0 <0.4.0-0`(仍会放行 `0.3.0`,1.4.5 进一步收窄为 `<0.3.0-0`)。
92
+ - 新增 `>=0.2.2-alpha.0 <0.3.0-0`:semver 只在同一 tuple 上匹配预发布号,`0.2.0` 那段够不到 `0.2.2-alpha.N`(npm `alpha` 标签当时为 `0.2.2-alpha.2`;原文误写为 `alpha.5`,1.4.5 更正)。该线在 1.4.4 时只做过契约比对;1.4.5 已实机启动验证。
26
93
  - 三处声明(`peerDependencies` / `dsh.engines.dsh` / `dsh.compatibility.dsh`)仍由 `host-range-test.mjs` 生成并校验一致。
27
94
 
28
95
  ### 说明(未改动,但需要知道)
package/README.md CHANGED
@@ -99,7 +99,7 @@ dsh plugin --profile web remove dsh-instruction-memory
99
99
 
100
100
  ## 存储
101
101
 
102
- 记忆保存在 **DSH 数据根目录**下(设置页会显示完整绝对路径):
102
+ 记忆保存在 **DSH 数据根目录**下(设置页显示折叠后的路径:Windows 为 `%USERPROFILE%`,macOS/Linux 为 `~`;真实绝对路径只用于读写):
103
103
 
104
104
  ```
105
105
  <DSH_HOME>/instruction-memory/memory.json # DSH_HOME 默认为 ~/.dsh
@@ -215,7 +215,9 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
215
215
  > "没活干还在动"的副作用。现在文件由**第一个真正需要它的动作**创建:一次保存、一次导入,
216
216
  > 或设置页读取 `state`(面板打开时就会读)。
217
217
 
218
- > **快照里的路径是折叠过的**(1.4.4 起):`storage.path` / `backupPath` / `historyDir` / `schemaFile` 都把家目录折成 `~`,只用于显示;读写、备份、监听仍用绝对路径。
218
+ > **快照里的路径是折叠过的**(1.4.4 起):`storage.path` / `backupPath` / `historyDir` / `schemaFile` / `backupDir` / `migratedFrom`,以及 `export-to-dir` 成功消息里的目标路径(1.4.6 起)都把家目录折叠成**平台可用的占位符**——1.4.5 起 Windows 为 `%USERPROFILE%`(资源管理器、CMD、PowerShell 都会展开;1.4.4 曾用 `~`,但资源管理器不展开它),macOS/Linux 为 `~`——只用于显示;读写、备份、监听仍用绝对路径。
219
+ > 折叠在 Windows 上**大小写不敏感**(`c:\users\me` 与 `C:\Users\me` 是同一个目录,早期实现按字符串前缀比较,换个大小写就把原始家目录漏了出去)。
220
+ > **`install.mjs` 先读 `package.json` 再动文件系统**(1.4.6 起):装/卸都如此。早期先把 junction 建好(或删掉)再解析 manifest,一个损坏的 `package.json` 就能留下"依赖还在、junction 没了"(或反过来)的半状态,而脚本当时只抛一个 JSON 解析错误。
219
221
  > `injection.available` 也不再恒为 `true`——提示词区块注册失败时它是 `false`。
220
222
  > 设置页依赖宿主的 `slots` 服务:`@deepseek-ai/cordis` 没有"可选注入",所以这是硬依赖,
221
223
  > 宿主不提供时插件不装载设置页(只打印一行说明)。
@@ -549,6 +551,7 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
549
551
  ```
550
552
 
551
553
  - 绑定到**任何非回环地址**(`--host 0.0.0.0`、`::` 或具体 IP,即主动对外发布)时 Host 校验**自动关闭**——那种情况下无法预知合法 Host,强行限制只会打断所有正常客户端;`Origin` 校验仍然生效。
554
+ 但"仍然生效"必须拿**对的对象**比:`Host` 此刻受攻击者控制,把 `Origin` 与它相比等于自己跟自己比。1.4.6 起,绑定的是**具体地址**(不是 `0.0.0.0` / `::` 这种通配)时,`Origin` 的主机名必须等于**该绑定地址**(可用 DNS 名或 IP,`::1` / `::ffff:127.0.0.1` 这类同一地址的不同写法仍然等价)。回环绑定不受此改动影响:那里 Host 已被限制在回环名单内,客户端用 `127.0.0.1`、`localhost`、`[::1]` 或 `[::ffff:127.0.0.1]` 访问都能继续工作。
552
555
  - 无 `Host` 的 HTTP/1.1 请求由 Node 的 HTTP 解析器先以 400 拒掉,根本到不了路由。
553
556
  - **端口参与 `Origin` 比对**(早期版本忽略端口,于是 `127.0.0.1:1` 这种同主机不同端口的页面也算同源):比对前先把默认端口归一(`http:80` / `https:443` 视同未写端口),其余情况要求 scheme、主机名、端口三者一致。`Host` 末尾的 FQDN 点(`localhost.`)会被忽略,避免同一台机器的两种写法被当成跨源。
554
557
 
@@ -598,15 +601,15 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
598
601
  同一串范围(三处逐字节一致,由测试锁定):
599
602
 
600
603
  ```
601
- >=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.2-alpha.0 <0.3.0-0 || >=0.2.0-0 <0.4.0-0
604
+ >=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.2-alpha.0 <0.3.0-0 || >=0.2.0-0 <0.3.0-0
602
605
  ```
603
606
 
604
607
  **验证范围与证据等级**——三者不可混为一谈,下面每一行都标了它到底证明了什么:
605
608
 
606
609
  | 等级 | 证明什么 | 覆盖 |
607
610
  | --- | --- | --- |
608
- | ① **真实启动** | 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入**组装后的提示**、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) | `0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2` · `0.1.5-rc.3` · **`0.2.0-rc.1`** ⁽*⁾ · **`0.2.0-rc.2`** ⁽**⁾ · `0.2.1-alpha.2` ⁽***⁾ · **`0.2.1-alpha.3`** ⁽***⁾ · **`0.2.1-alpha.4`** ⁽***⁾ · **`0.2.1-rc.2`** · **`0.2.1-rc.3`** ⁽⁴⁾ |
609
- | ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`systemPrompt.section()`、`assemble()` 是否求值**函数型** `text`、`FILE_REFERENCE=900` / `TOOL_BASH=1000` 仍在(决定 order 950 是否空档)、重名注册是否抛错、`webServer.register()` / `get host()`、`ctx.inject()` / `ctx.effect()`、`dsh.client.inject` 两包在该版本是否存在且声明了 `dsh.client` | 声明范围接住的**全部 18 个已发布版本**(`0.1.3-alpha.2` → `0.2.1-alpha.4`) |
611
+ | ① **真实启动** | 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入**组装后的提示**、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) | `0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2` · `0.1.5-rc.3` · **`0.2.0-rc.1`** ⁽*⁾ · **`0.2.0-rc.2`** ⁽**⁾ · `0.2.1-alpha.2` ⁽***⁾ · **`0.2.1-alpha.3`** ⁽***⁾ · **`0.2.1-alpha.4`** ⁽***⁾ · **`0.2.1-rc.2`** · **`0.2.1-rc.3`** ⁽⁴⁾ · **`0.2.1-rc.4`** ⁽⁵⁾ · **`0.2.2-alpha.2`** ⁽⁵⁾ |
612
+ | ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`systemPrompt.section()`、`assemble()` 是否求值**函数型** `text`、`FILE_REFERENCE=900` / `TOOL_BASH=1000` 仍在(决定 order 950 是否空档)、重名注册是否抛错、`webServer.register()` / `get host()`、`ctx.inject()` / `ctx.effect()`、`dsh.client.inject` 两包在该版本是否存在且声明了 `dsh.client` | 截至 2026-10-16 审计时,声明范围接住的 **18 个已发布版本**(`0.1.3-alpha.2` → `0.2.1-alpha.4`);后续新增版本按发布补充 |
610
613
  | ③ **真实执行** | 把插件**真的挂载**进该版本的 `@deepseek-ai/cordis`,跑装载 / 路由注册 / 卸载共 9 项断言 | cordis `4.0.2` 与 `4.0.4`(各 9/9) |
611
614
 
612
615
  ⁽*⁾ **`0.2.0-rc.1` 的①档有一条要说明**:插件装载、注入进入组装后的提示、HTTP 路由可应答三项**均已实测**(路由 `HTTP 200`;`injection.registered=true`、`chars=437`、**`pulls=38`**——即 0.2.0-rc.1 的 agent loop 真的求值了 38 次函数型 `text`)。第四项"客户端模块被下发"**没有直接抓取**:首页需要鉴权(`/` 返回 `401`),我无法取到 `window.__DSH_BOOT__`。这一项的支撑是间接但强的两条:① `dsh-client-modules` 在合成阶段对 `dsh.client` 声明做硬校验,不合格会抛 `ClientPackageCompositionError` 直接导致启动失败——宿主正常启动即说明声明通过了 0.2.0-rc.1 的校验;② 本插件用到的三个客户端契约包(`-slots` / `-renderer` / `-settings`)相对升级前真正在跑的 `0.1.7-rc.2` **逐字节未变**。**未做人工视觉确认**(没有打开设置页看)。
@@ -617,11 +620,13 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
617
620
 
618
621
  ⁽⁴⁾ **`0.2.1-rc.2` / `0.2.1-rc.3` 的①档**(2026-10-24,插件 1.4.2):`rc.3` 上隔离 `DSH_HOME` + 真实 `dsh web` 宿主驱动 **21 项 HTTP 断言 21/21 PASS**,桌面端活体 `pulls=5052`、p50 0.72 ms;并且**首次直接抓到了客户端模块的下发证据**——boot HTML 的预加载清单含 `dsh-instruction-memory/client.js`,该合并 bundle 返回 `200 text/javascript`(2,204,270 字节),正文含 Client 半源码与「指令记忆」文案。因此上一条 ⁽*⁾ 里"无法直接抓到 `window.__DSH_BOOT__`"的限制**已对 rc.3 解除**。`rc.2` 未单独起宿主,靠它与 `rc.3` 的逐文件字节比对(仅 `package.json` 与一行构建号不同)承接结论。证据:`dsh-memory-verification/out/compat-1016-live-latest-rc3.json`、`…-rc3b.json`。仍**未做设置页人工视觉确认**。
619
622
 
620
- 此外 `host-range-test.mjs` 的固定矩阵覆盖 `0.1.6-alpha.2`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2` 与 `0.2.1-alpha.1`~`0.2.1-alpha.4`(回归守卫把最新验证过的宿主逐名钉住),并会**读取本机实际安装的宿主版本**做实时断言。
623
+ ⁽⁵⁾ **`0.2.1-rc.4` / `0.2.2-alpha.2` 的①档**(2026-10-29,插件 1.4.4 → 1.4.5):隔离 `DSH_HOME` + 真实 `dsh web` 宿主,两个版本各跑 21 项 HTTP 断言;两端均 **21/21 PASS**(1.4.4 首次跑出的 2 项 FAIL 是验证脚本未展开折叠路径造成的假阴性,1.4.5 修正脚本后复验通过)。客户端 bundle 实际下发:rc.4 `2,211,701` 字节、alpha.2 `2,370,024` 字节,正文均含 Client 半源码与「指令记忆」文案。**首次做了设置页人工视觉确认**——两个宿主上「指令记忆」栏目均完整渲染。桌面端 `0.2.1-rc.3` 活体 `pulls=653`、p50 0.95 ms、`problems=[]`。仍**未跑真实 Agent 回合**(隔离宿主无模型凭证,`pulls=0`)。
624
+
625
+ 此外 `host-range-test.mjs` 的固定矩阵覆盖 `0.1.6-alpha.2`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.1`~`0.2.1-alpha.4`、`0.2.1-rc.2`~`0.2.1-rc.4` 与 `0.2.2-alpha.2`(回归守卫把最新验证过的宿主逐名钉住),并会**读取本机实际安装的宿主版本**做实时断言。
621
626
 
622
627
  **关于 1.0.10 新增的 `ctx.inject`。** 这是新版唯一的硬依赖增量:`inject` 去掉了 `webServer`,设置页路由改为非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber(原因见上)。为此逐个核验了 **cordis 全部 8 个已发布版本**(`4.0.1-rc.1` → `4.0.5-alpha.2`)——**每一个都具备 `ctx.inject`**(其中 4.0.2 / 4.0.4 另做了真实挂载执行,4.0.5-alpha.1 与 4.0.5-alpha.2 为源码符号核验),因此声明范围内不存在因它而失效的宿主版本。
623
628
 
624
- **关于声明范围是否"超范围承诺"。** 已对全部 **33 个**已发布宿主版本核验(2026-10-05 穷举 30 个;新增版本随发布补充——`0.2.1-alpha.2` 于 2026-10-10、`0.2.1-alpha.3` / `0.2.1-alpha.4` 于 2026-10-16,npm 官方 registry):范围内 **18 个**版本的 API 面**全部齐备,超范围承诺 0 项**,且默认与 `includePrerelease` 两种语义在这 18 个版本上零分歧。范围外的 15 个版本(`0.0.1-rc.1` → `0.1.2-rc.1`)API 面**同样齐备**,但它们**从未真实启动验证过**,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。
629
+ **关于声明范围是否"超范围承诺"。** 截至 2026-10-16 已对当时全部 **33 个**已发布宿主版本核验(2026-10-05 穷举 30 个;后续版本随发布补充——`0.2.1-alpha.2` 于 2026-10-10、`0.2.1-alpha.3` / `0.2.1-alpha.4` 于 2026-10-16,npm 官方 registry):当时范围内 **18 个**版本的 API 面**全部齐备,超范围承诺 0 项**,且默认与 `includePrerelease` 两种语义在这 18 个版本上零分歧。范围外的 15 个版本(`0.0.1-rc.1` → `0.1.2-rc.1`)API 面**同样齐备**,但它们**从未真实启动验证过**,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。
625
630
 
626
631
  **关于 `0.2.1-alpha.1`(2026-10-03 发布,1.0.16 起覆盖)。** 它是前述"未来 minor 预发布版本在默认语义下接不住"的**第一个真实发生案例**:1.0.15 的范围在宿主门禁与市场判定(均用 `includePrerelease`)下放行它,但在 npm/pnpm 默认 peer 解析下拒绝(ERESOLVE);其耦合面(9 个契约点)经最近 10 版本矩阵核验全部齐备,cordis 为 `4.0.5-alpha.1`。1.0.16 新增 `>=0.2.1-alpha.0 <0.3.0-0` 枚举段后两种语义一致放行。**该版本仅有契约层证据,未做真机启动实测。**
627
632
 
@@ -643,7 +648,7 @@ major.minor.patch 且自身带预发布标签的比较符"。这条规则是**
643
648
  `0.1.8-rc.1` 这类版本 tuple 与所有比较符都不同,在 **npm 默认语义**下**永远匹配不到**——与
644
649
  `^0.1.0` 无法匹配 `0.1.8-rc.1` 是同一个限制。正式版(`0.1.8`、`0.2.0`、`1.0.0` 等)不受影响。
645
650
  这个限制不再只是假设:**`0.2.1-alpha.1`(2026-10-03 发布)就是第一个真实撞上它的版本**,
646
- 1.0.16 已通过新增 `0.2.1` 枚举段闭合;下一个未声明 tuple(如 `0.2.2-rc.1`、`0.3.0-rc.1`)
651
+ 1.0.16 已通过新增 `0.2.1` 枚举段闭合;下一个未声明 tuple(如 `0.2.3-rc.1`、`0.3.0-rc.1`)
647
652
  仍会重演同一现象,需要发一版加一行——host-range 的实时守卫会在本机升级到该宿主时
648
653
  直接让测试失败并给出可直接套用的修复行。
649
654
 
package/install.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * The profile package.json is backed up before the first modification.
19
19
  */
20
- import { existsSync, readFileSync, writeFileSync, symlinkSync, lstatSync, unlinkSync, copyFileSync } from 'node:fs'
20
+ import { existsSync, readFileSync, writeFileSync, symlinkSync, lstatSync, unlinkSync, copyFileSync, mkdirSync } from 'node:fs'
21
21
  import { homedir } from 'node:os'
22
22
  import { dirname, join, resolve } from 'node:path'
23
23
  import { fileURLToPath } from 'node:url'
@@ -43,6 +43,27 @@ function readPkg() {
43
43
  return JSON.parse(readFileSync(pkgPath, 'utf8'))
44
44
  }
45
45
 
46
+ /**
47
+ * Read the profile manifest BEFORE touching anything on disk.
48
+ *
49
+ * Reading it late turned one unparsable `package.json` (interrupted write, hand
50
+ * edit, failed market install) into a half state: `--remove` had already
51
+ * unlinked the junction when the parse threw, and `install` had already created
52
+ * the junction and rolled the backup, so the profile either kept dependency
53
+ * entries for a plugin that is gone or listed a link that was never enabled.
54
+ * Failing first keeps the filesystem exactly as it was.
55
+ */
56
+ function readPkgOrExit() {
57
+ try {
58
+ return readPkg()
59
+ } catch (err) {
60
+ console.error('cannot read ' + pkgPath + ': ' + (err && err.message ? err.message : String(err)))
61
+ console.error(' it is not valid JSON — an interrupted write or a hand edit is the usual cause.')
62
+ console.error(' nothing was changed. Restore it from ' + backupPath + ' and run this again.')
63
+ process.exit(1)
64
+ }
65
+ }
66
+
46
67
  function writePkg(pkg) {
47
68
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
48
69
  }
@@ -56,6 +77,8 @@ function bundlesOf(pkg) {
56
77
 
57
78
  if (remove) {
58
79
  let touched = false
80
+ // Fail before any filesystem change: see readPkgOrExit.
81
+ const pkg = readPkgOrExit()
59
82
  // `link` is normally a junction, and unlinking one removes only the link —
60
83
  // the real package stays where it is. It can also be a REAL directory (pnpm
61
84
  // materialises `link:` dependencies into node_modules as a copy), and there
@@ -97,7 +120,6 @@ if (remove) {
97
120
  console.log('\nNOT UNINSTALLED — see the refusal above.')
98
121
  process.exit(process.exitCode)
99
122
  }
100
- const pkg = readPkg()
101
123
  let changed = false
102
124
  if (pkg.dependencies !== undefined && Object.hasOwn(pkg.dependencies, NAME)) {
103
125
  delete pkg.dependencies[NAME]
@@ -137,9 +159,15 @@ if (!existsSync(join(PROF, 'package.json'))) {
137
159
  process.exit(1)
138
160
  }
139
161
 
162
+ const pkg = readPkgOrExit()
163
+
140
164
  if (existsSync(link)) {
141
165
  console.log('junction already present: ' + link)
142
166
  } else {
167
+ // A freshly initialised profile has no node_modules directory yet; symlink()
168
+ // does not create the parent, so this used to fail with ENOENT on exactly
169
+ // the "new profile" case this script exists to support.
170
+ mkdirSync(dirname(link), { recursive: true })
143
171
  symlinkSync(SRC, link, 'junction')
144
172
  console.log('junction created: ' + link)
145
173
  }
@@ -155,7 +183,6 @@ if (existsSync(backupPath)) {
155
183
  copyFileSync(pkgPath, backupPath)
156
184
  console.log('backed up profile package.json -> ' + backupPath)
157
185
 
158
- const pkg = readPkg()
159
186
  let changed = false
160
187
 
161
188
  // Step 3: a link: dependency, so the market lists it under 「已安装」.
package/lib/client.js CHANGED
@@ -3647,19 +3647,75 @@ window.__ModuleLoader__.load({
3647
3647
  return h('div', { className: 'im-root' }, header, list)
3648
3648
  }
3649
3649
 
3650
+ const SLOT_RETRY_LIMIT = 20
3651
+ const SLOT_RETRY_DELAY_MS = 50
3652
+
3653
+ /**
3654
+ * Resolve the slot service, mounting the panel as soon as it exists.
3655
+ *
3656
+ * `slots` is a declared injection (see exports.inject below), so a host that
3657
+ * honours declarations holds this plugin until the service is published. A
3658
+ * host that resolves injections lazily — or publishes the service late —
3659
+ * used to leave the panel silently missing: one console.error, no retry,
3660
+ * and a settings page the user never sees while the plugin still looks
3661
+ * installed. Retrying for ~1s covers that window; after the last attempt the
3662
+ * failure is loud and names what is missing.
3663
+ */
3650
3664
  function apply(ctx) {
3651
3665
  // Kept for lazy service lookups: the official locale service may be
3652
3666
  // published before or after this plugin activates.
3653
3667
  runtimeCtx = ctx
3654
- // `slots` is a declared injection (see exports.inject below), so the
3655
- // runtime holds this plugin until the slot service exists. The fallback
3656
- // covers a runtime that resolves injections lazily instead.
3657
- const slots = ctx.slots !== undefined ? ctx.slots : ctx.get('slots')
3658
- if (slots === undefined) {
3659
- console.error('[instruction-memory] slots service unavailable; settings page not registered')
3668
+
3669
+ const resolveSlots = () => (ctx.slots !== undefined ? ctx.slots : ctx.get('slots'))
3670
+
3671
+ // A retry can fire after the fiber was disposed (plugin disabled, host
3672
+ // shutting down); registering into a dead context throws, and an uncaught
3673
+ // throw inside a timer is invisible to the loader that would normally
3674
+ // report it. Treat it as "nothing to mount" instead.
3675
+ const safeMount = (target) => {
3676
+ try {
3677
+ mountPanel(ctx, target)
3678
+ return true
3679
+ } catch {
3680
+ return false
3681
+ }
3682
+ }
3683
+
3684
+ const immediate = resolveSlots()
3685
+ if (immediate !== undefined) {
3686
+ safeMount(immediate)
3660
3687
  return
3661
3688
  }
3662
3689
 
3690
+ let attempt = 0
3691
+ const retry = () => {
3692
+ attempt += 1
3693
+ const late = resolveSlots()
3694
+ if (late !== undefined) {
3695
+ safeMount(late)
3696
+ return
3697
+ }
3698
+ if (attempt >= SLOT_RETRY_LIMIT) {
3699
+ console.error(
3700
+ '[instruction-memory] slots service unavailable after '
3701
+ + (SLOT_RETRY_LIMIT * SLOT_RETRY_DELAY_MS) + 'ms of retries; settings page not registered',
3702
+ )
3703
+ return
3704
+ }
3705
+ schedule(retry)
3706
+ }
3707
+ schedule(retry)
3708
+ }
3709
+
3710
+ function schedule(fn) {
3711
+ const timer = setTimeout(fn, SLOT_RETRY_DELAY_MS)
3712
+ // Never hold a process open just to finish retrying.
3713
+ if (timer !== null && typeof timer === 'object' && typeof timer.unref === 'function') timer.unref()
3714
+ return timer
3715
+ }
3716
+
3717
+ function mountPanel(ctx, slots) {
3718
+
3663
3719
  // Own stylesheet: one <style> element created and removed with this fiber.
3664
3720
  const styleEl = document.createElement('style')
3665
3721
  styleEl.setAttribute('data-dsh-plugin', 'instruction-memory')
package/lib/index.js CHANGED
@@ -195,18 +195,48 @@ export function resolveStorePath(env = process.env, home = homedir()) {
195
195
  }
196
196
 
197
197
  /**
198
- * Fold the user's home directory to `~` for every path published in the
199
- * snapshot. The settings page only needs to show WHICH file is in use; the
200
- * absolute path adds nothing there but does put the account name on screen
201
- * (and into any screenshot or support paste). The real paths stay untouched
202
- * everywhere else — writes, backups and the watcher all use the absolute form.
198
+ * Fold the user's home directory for every path published in the snapshot.
199
+ * The settings page only needs to show WHICH file is in use; the absolute path
200
+ * adds nothing there but does put the account name on screen (and into any
201
+ * screenshot or support paste). The real paths stay untouched everywhere else
202
+ * — writes, backups and the watcher all use the absolute form.
203
+ *
204
+ * The token is platform-usable on purpose: Windows Explorer, CMD and PowerShell
205
+ * all expand `%USERPROFILE%`, while they do NOT expand `~` in an arbitrary path
206
+ * (only PowerShell treats it specially in some contexts). POSIX keeps `~`.
203
207
  */
204
- export function displayPath(value, home = homedir()) {
208
+ export function displayPath(value, home = homedir(), platform = process.platform) {
205
209
  if (typeof value !== 'string' || value === '') return value
206
210
  const base = home.endsWith(sep) ? home.slice(0, -sep.length) : home
207
211
  if (base === '') return value
208
- if (value === base) return '~'
209
- if (value.startsWith(base + sep)) return '~' + value.slice(base.length)
212
+ const token = platform === 'win32' ? '%USERPROFILE%' : '~'
213
+ // Windows paths are case-insensitive: `c:\users\me` is the same directory as
214
+ // `C:\Users\me`, and a mismatch here would silently publish the raw home.
215
+ const fold = platform === 'win32' ? (text) => text.toLowerCase() : (text) => text
216
+ const cmpValue = fold(value)
217
+ const cmpBase = fold(base)
218
+ if (cmpValue === cmpBase) return token
219
+ if (cmpValue.startsWith(cmpBase + sep)) return token + value.slice(base.length)
220
+ return value
221
+ }
222
+
223
+ /**
224
+ * The inverse of {@link displayPath}, for tooling that has to compare a
225
+ * published path with a real filesystem path. Both tokens are accepted so a
226
+ * snapshot from either platform can be expanded on either platform; a path
227
+ * that carries no token (or is already absolute) is returned unchanged.
228
+ */
229
+ export function expandDisplayPath(value, home = homedir(), platform = process.platform) {
230
+ if (typeof value !== 'string' || value === '') return value
231
+ const base = home.endsWith(sep) ? home.slice(0, -sep.length) : home
232
+ if (base === '') return value
233
+ const tokens = platform === 'win32' ? ['%USERPROFILE%', '~'] : ['~', '%USERPROFILE%']
234
+ for (const token of tokens) {
235
+ if (value === token) return base
236
+ if (value.startsWith(token + '/') || value.startsWith(token + '\\')) {
237
+ return base + value.slice(token.length)
238
+ }
239
+ }
210
240
  return value
211
241
  }
212
242
 
@@ -1073,6 +1103,20 @@ const isLoopbackName = (name) => {
1073
1103
  return false
1074
1104
  }
1075
1105
 
1106
+ /**
1107
+ * Whether a bind address accepts every local address instead of naming one.
1108
+ *
1109
+ * Only a concrete bind address tells us which authority a legitimate client
1110
+ * must use; `0.0.0.0`, `::` (and the empty default) tell us nothing, because the
1111
+ * client may legitimately have reached any of this machine's addresses.
1112
+ */
1113
+ export function isWildcardBindHost(value) {
1114
+ const name = normalizeHostname(String(value === undefined || value === null ? '' : value).trim().toLowerCase())
1115
+ if (name === '' || name === '*' || name === '0.0.0.0') return true
1116
+ const canonical = canonicalIp(name)
1117
+ return canonical !== null && /^(:?0{4}:){7}0{4}$/.test(canonical)
1118
+ }
1119
+
1076
1120
  /**
1077
1121
  * Why the settings-page route checks `Host`, `Origin` and `Sec-Fetch-Site` at
1078
1122
  * all, when it already refuses any content-type that is not
@@ -1111,8 +1155,15 @@ const isLoopbackName = (name) => {
1111
1155
  * same origin, so nothing legitimate is lost. On a non-loopback bind the server
1112
1156
  * may sit behind a proxy that legitimately rewrites ports, so the same-site
1113
1157
  * rule is left off there and the README documents the trade-off.
1158
+ *
1159
+ * When the server is bound to a CONCRETE address (`--host 192.168.1.5`), that
1160
+ * address — not the request's `Host` header — is the authority a browser had to
1161
+ * use, so `Origin` must match it. Comparing `Origin` with `Host` is worthless
1162
+ * there: both are attacker-controlled, so a published deployment used to accept
1163
+ * `Host: attacker.example` plus `Origin: attacker.example`. Wildcard binds keep
1164
+ * the old comparison, because the address the client used is unknowable then.
1114
1165
  */
1115
- export function requestOriginVerdict(headers, guarded) {
1166
+ export function requestOriginVerdict(headers, guarded, expectedHostname = '') {
1116
1167
  const read = (name) => {
1117
1168
  if (headers === null || typeof headers !== 'object') return ''
1118
1169
  const value = headers[name]
@@ -1152,8 +1203,23 @@ export function requestOriginVerdict(headers, guarded) {
1152
1203
  // (sandboxed iframe, file://) parses to no hostname and is refused.
1153
1204
  // Canonical IP comparison covers the two spellings of one address:
1154
1205
  // ::1 vs [::1], and ::ffff:127.0.0.1 vs ::ffff:7f00:1.
1155
- if (originHostname === '' || !sameHostname(originHostname, requestHostname)) {
1156
- return { ok: false, reason: 'Origin 与 Host 不同源:' + origin }
1206
+ // On a NON-loopback bind the request's own Host header proves nothing: it is
1207
+ // attacker-controlled and the loopback allowlist is off, so the Origin is
1208
+ // held to the concrete address the server actually bound instead.
1209
+ //
1210
+ // On a loopback bind the allowlist has already restricted Host to loopback
1211
+ // names, and aliasing is legitimate there: a client may reach the same
1212
+ // server as ::1, 127.0.0.1 or ::ffff:127.0.0.1, and comparing Origin to Host
1213
+ // is what keeps those spellings working (the mapped/hex forms canonicalise).
1214
+ const expected = !guarded && typeof expectedHostname === 'string' ? expectedHostname : ''
1215
+ const authority = expected !== '' ? expected : requestHostname
1216
+ if (originHostname === '' || !sameHostname(originHostname, authority)) {
1217
+ return {
1218
+ ok: false,
1219
+ reason: expected !== ''
1220
+ ? 'Origin 与服务端绑定的地址不一致:' + origin + '(绑定 ' + expected + ')'
1221
+ : 'Origin 与 Host 不同源:' + origin,
1222
+ }
1157
1223
  }
1158
1224
  // On loopback the port IS part of the origin, and a different local port is
1159
1225
  // a different application: refuse it when both sides state one. Skipped on
@@ -1622,6 +1688,12 @@ export function apply(ctx) {
1622
1688
  * temporal-dead-zone hazard (F12 publishes it as `security.hostGuarded`).
1623
1689
  */
1624
1690
  let guardHost = true
1691
+ /**
1692
+ * The authority a browser had to use to reach this route, when the bind
1693
+ * address is concrete (`192.168.1.5`, `[::1]`). Empty for a wildcard/unknown
1694
+ * bind, where `Origin` can only be compared with the request's `Host`.
1695
+ */
1696
+ let expectedOriginHostname = ''
1625
1697
  /**
1626
1698
  * F7: the pending mute-expiry timer, or null. Re-armed by scheduleMuteTimer
1627
1699
  * (from syncSection) and cleared in the effect cleanup.
@@ -1677,7 +1749,11 @@ export function apply(ctx) {
1677
1749
  if (storeDir !== '' && storeDir !== '.') text = text.split(storeDir).join('<记忆目录>')
1678
1750
  try {
1679
1751
  const home = homedir()
1680
- if (typeof home === 'string' && home !== '') text = text.split(home).join('~')
1752
+ // Same token as `displayPath` so a folded path looks identical wherever
1753
+ // the user meets it (snapshot fields and error text).
1754
+ if (typeof home === 'string' && home !== '') {
1755
+ text = text.split(home).join(process.platform === 'win32' ? '%USERPROFILE%' : '~')
1756
+ }
1681
1757
  } catch {
1682
1758
  // No home to redact: keep the text as it is.
1683
1759
  }
@@ -2883,7 +2959,7 @@ export function apply(ctx) {
2883
2959
  error: state.storageError,
2884
2960
  warning: state.storageWarning !== null ? state.storageWarning : state.backupError,
2885
2961
  savedAt: state.savedAt,
2886
- migratedFrom: state.migratedFrom,
2962
+ migratedFrom: state.migratedFrom === null ? null : displayPath(state.migratedFrom),
2887
2963
  /** false until the store file has actually been written (see readFromDisk step 3). */
2888
2964
  onDisk: state.storeExists,
2889
2965
  /** Backup location (F18): '' means "next to the store file". */
@@ -3806,7 +3882,7 @@ export function apply(ctx) {
3806
3882
  } catch (err) {
3807
3883
  return { ok: false, saved: false, message: '写入备份目录失败:' + describeErrorSafe(err), snapshot: snapshot() }
3808
3884
  }
3809
- return { ok: true, saved: false, message: '已写入 ' + target, snapshot: snapshot() }
3885
+ return { ok: true, saved: false, message: '已写入 ' + displayPath(target), snapshot: snapshot() }
3810
3886
  })
3811
3887
 
3812
3888
  /**
@@ -4467,7 +4543,7 @@ export function apply(ctx) {
4467
4543
  * `?lite=1` gets `{ rev, lite: true }` frames instead (see pushSnapshot).
4468
4544
  */
4469
4545
  const handleEventStream = (req, res) => {
4470
- const verdict = requestOriginVerdict(req.headers, guardHost)
4546
+ const verdict = requestOriginVerdict(req.headers, guardHost, expectedOriginHostname)
4471
4547
  if (!verdict.ok) {
4472
4548
  logInfo('route rejected a request: ' + verdict.reason)
4473
4549
  res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
@@ -4576,7 +4652,7 @@ export function apply(ctx) {
4576
4652
  // Origin/Host guard, before anything else is read. See
4577
4653
  // requestOriginVerdict() for why the content-type rule below is not enough
4578
4654
  // on its own.
4579
- const verdict = requestOriginVerdict(req.headers, guardHost)
4655
+ const verdict = requestOriginVerdict(req.headers, guardHost, expectedOriginHostname)
4580
4656
  if (!verdict.ok) {
4581
4657
  logInfo('route rejected a request: ' + verdict.reason)
4582
4658
  res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
@@ -4677,6 +4753,9 @@ export function apply(ctx) {
4677
4753
  // the README.
4678
4754
  const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
4679
4755
  guardHost = boundHost === '' || isLoopbackName(boundHost)
4756
+ // A concrete address names the authority a legitimate client had to use, so
4757
+ // the Origin check can hold it to that instead of to a spoofable Host.
4758
+ expectedOriginHostname = isWildcardBindHost(boundHost) ? '' : normalizeHostname(boundHost)
4680
4759
  // F12: the settings page can only warn about a non-loopback bind if the
4681
4760
  // snapshot carries what the route actually saw at registration time.
4682
4761
  state.boundHost = boundHost
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,7 +51,7 @@
51
51
  "node": ">=20"
52
52
  },
53
53
  "peerDependencies": {
54
- "@deepseek-ai/dsh-system-prompt": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.2-alpha.0 <0.3.0-0 || >=0.2.0-0 <0.4.0-0"
54
+ "@deepseek-ai/dsh-system-prompt": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.2-alpha.0 <0.3.0-0 || >=0.2.0-0 <0.3.0-0"
55
55
  },
56
56
  "publishConfig": {
57
57
  "access": "public"
@@ -75,13 +75,10 @@
75
75
  "platform": "web"
76
76
  },
77
77
  "engines": {
78
- "dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.2-alpha.0 <0.3.0-0 || >=0.2.0-0 <0.4.0-0"
78
+ "dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.2-alpha.0 <0.3.0-0 || >=0.2.0-0 <0.3.0-0"
79
79
  },
80
80
  "compatibility": {
81
- "dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.2-alpha.0 <0.3.0-0 || >=0.2.0-0 <0.4.0-0"
81
+ "dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.2-alpha.0 <0.3.0-0 || >=0.2.0-0 <0.3.0-0"
82
82
  }
83
- },
84
- "dependencies": {
85
- "dsh-instruction-memory": "^1.4.3"
86
83
  }
87
84
  }