dsh-instruction-memory 1.4.5 → 1.4.7

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,68 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.4.7] — 2026-10-31
11
+
12
+ **文档版**:没有一行运行时代码变更,只把 2026-10-31「插件在 DSH Go(Android)上的适配性」
13
+ 审计结论写进 README,并补上新跑的一组实机证据。
14
+
15
+ ### 新增证据
16
+
17
+ - `0.2.1-rc.4`(正是 DSH Go 锁定的内核版本)在隔离 `DSH_HOME` 上复跑
18
+ `compat-1016-live-check.mjs`:**21/21**;注入 `chars=292 / budget=4000`、`state` p95 1.09 ms、
19
+ SSE 首帧 0.71 ms。证据文件 `dsh-memory-verification/out/compat-1016-live-rc4-audit.json`。
20
+
21
+ ### 文档
22
+
23
+ - README「请求来源防护」新增一节:**DSH Go 的鉴权来自平台补丁** `runtime/patches/route-guard.diff`
24
+ (钉在 `0.2.1-rc.4`),在 `route.handler` 之前对 `exact`/`prefix` 路由统一执行
25
+ `connection.requestRejection()`,缺 cookie → 401、`connection` 未挂载 → 503;
26
+ 并写明这层保护是**平台属性**——手动 `dsh web` 不带 `--patch`、或升级到锚点对不上的内核,
27
+ 就会回退成"仅回环、无鉴权"。
28
+ - README「兼容性」新增「在 DSH Go(Android)上运行」:引擎经 `/usr/bin/env -i` 启动,
29
+ `HOME=/root`、`DSH_HOME=/root/.dsh`(App 侧设置的 `HOME` 对引擎无效);数据落在
30
+ `/root/.dsh/instruction-memory/memory.json`(App 私有、与桌面端不共享);配置入口是网页岛
31
+ (本插件没有 config schema);发现页索引 `api.dshfind.com/market/dsh-index.json` 仍只登记
32
+ `1.4.2`,要最新版需在 guest shell 里 `dsh plugin --profile web add dsh-instruction-memory`。
33
+
34
+ ## [1.4.6] — 2026-10-30
35
+
36
+ 针对 1.4.5 的三方复核(架构 / 安全 / 验收)结论做的修复版本。安全面**没有新增对外暴露**,
37
+ 下面的每一条都是"本该做对但做漏了"的一致性缺陷;无鉴权路由属已知设计取舍,本版不改。
38
+
39
+ ### 修复
40
+
41
+ - **非回环绑定的 `Origin` 校验此前是自证**(`requestOriginVerdict`):绑定具体地址时 Host 校验
42
+ 关闭,而 `Origin` 仍与**请求自带的 `Host`** 比较——两者都由攻击者提供,于是
43
+ `Host: attacker.example` + `Origin: http://attacker.example` 在 LAN 绑定上直接通过。现在绑定
44
+ 具体地址时 `Origin` 必须等于**该绑定地址**;通配绑定(`0.0.0.0` / `::`)仍无从得知合法来源,
45
+ 行为不变。回环绑定不受影响,`127.0.0.1` / `localhost` / `[::1]` / `[::ffff:127.0.0.1]` 互访照旧。
46
+ - **`export-to-dir` 的成功消息泄露绝对路径**:消息里回的是 `已写入 C:\Users\<账号>\...`,而同
47
+ 一响应的 `storage.path` 早已折叠——1.4.4 的路径折叠漏了这条命令。现在同样折叠。
48
+ - **`storage.migratedFrom` 未折叠**:迁移提示里的旧库绝对路径原样下发,现与其它路径字段一致。
49
+ - **`displayPath` 大小写敏感**:Windows 上 `c:\users\me` 与 `C:\Users\me` 是同一目录,早期按
50
+ 字符串前缀比较,大小写不同就不折叠,等于把原始家目录漏出去。现在 Windows 路径比较不区分大小写。
51
+ - **错误文本的折叠与快照不一致**:`describeErrorSafe` 在 Windows 折成 `~`,而快照字段用
52
+ `%USERPROFILE%`(资源管理器/CMD 都会展开,`~` 不会)。两者统一为同一 token。
53
+ - **`install.mjs` 在读写 manifest 之前就动了文件系统**:`--remove` 先删 junction 再解析
54
+ `package.json`,安装分支先建 junction/滚备份再解析——一个损坏的 manifest 就留下"依赖还在、
55
+ junction 没了"的半状态,而脚本只抛一个 JSON 解析错误。现在两种情况都**先解析、后动手**,
56
+ 失败时明确指向 `.bak` 备份且不改动任何文件。
57
+
58
+ ### 明确未修
59
+
60
+ - **设置路由没有鉴权**:插件路由挂在宿主 Web 服务器上,宿主只保护自己的路由(无 token 时 `/` 返回
61
+ 401),插件这条路径仍是同端口无鉴权可读写。宿主 `0.2.1-rc.5` 未提供任何鉴权 API,插件层无法
62
+ 自行补上而不与宿主 token 逻辑打架。默认回环绑定下只有本机可达;`--host <LAN IP>` 时才暴露到
63
+ 网络,README「对外发布」一节已标注。本版不声称此项已解决。
64
+
65
+ ### 测试
66
+
67
+ - 新增断言:非回环绑定的 Host+Origin 伪造组合必须 403、回环绑定的映射地址写法仍须放行、
68
+ `export-to-dir` 消息与快照全部路径字段不含原始家目录、Windows 不同大小写的家目录仍折叠、
69
+ `install.mjs` 在 manifest 损坏时(装/卸两个方向)不改动文件系统并给出恢复指引。
70
+ - 契约范围表补入 `0.2.1-rc.5`(本轮复核的宿主版本)。
71
+ - 全套 6 个套件 945 条断言通过。
10
72
  ## [1.4.5] — 2026-10-29
11
73
 
12
74
  对 1.4.4 发布物与最新桌面端 / 网页端复核(桌面 `0.2.1-rc.3`,网页 `0.2.1-rc.4` 与
package/README.md CHANGED
@@ -215,7 +215,9 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
215
215
  > "没活干还在动"的副作用。现在文件由**第一个真正需要它的动作**创建:一次保存、一次导入,
216
216
  > 或设置页读取 `state`(面板打开时就会读)。
217
217
 
218
- > **快照里的路径是折叠过的**(1.4.4 起):`storage.path` / `backupPath` / `historyDir` / `schemaFile` 都把家目录折叠成**平台可用的占位符**——1.4.5 起 Windows 为 `%USERPROFILE%`(资源管理器、CMD、PowerShell 都会展开;1.4.4 曾用 `~`,但资源管理器不展开它),macOS/Linux 为 `~`——只用于显示;读写、备份、监听仍用绝对路径。
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
 
@@ -583,6 +586,20 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
583
586
  路径前缀为 `/instruction-memory/api`,代理转发规则需覆盖它。
584
587
  5. **限制来源**:至少用代理层做 IP 白名单或加 Basic Auth——插件本身不提供任何鉴权。
585
588
 
589
+ ### 在 DSH Go(Android)上:鉴权来自平台补丁,不是本插件
590
+
591
+ DSH Go 给官方 Web 服务器包了一层**构建期补丁** `runtime/patches/route-guard.diff`(钉在
592
+ `0.2.1-rc.4`,`pins.json` 记了 before/after SHA-256,锚点对不上时构建直接失败):它在
593
+ `route.handler` 之前对**每一条命名路由**调用 `connection.requestRejection()`——`exact` 与
594
+ `prefix` 都算,本插件的 `/instruction-memory/api` 属后者;放行名单只有 `/connect` 与
595
+ `/oauth/callback`;缺 cookie → **401**,`connection` 尚未挂载 → **503**(fail-closed),拒绝响应
596
+ 带 `x-dsh-go-guard: reject`。因此 Android 上"同机其他 App 直接打本插件路由"这条路已被平台挡住;
597
+ 上面几节描述的"无鉴权"缺口只适用于桌面端 / 网页端的端口。
598
+
599
+ ⚠️ 这层保护是**平台属性,不是插件属性**:在 guest shell 里手动 `dsh web`(不带 `--patch`),
600
+ 或在 guest 内把内核升级到补丁锚点对不上的版本,鉴权就回退成"仅回环、无鉴权"。判断装机是否
601
+ 仍受保护,只看启动命令行里有没有 `--patch /etc/dsh-app/dsh-web.patch.yml`。
602
+
586
603
  ## 兼容性
587
604
 
588
605
  同一串范围写在**三处**,而**只有一处会被宿主强制执行**——这一点以前写错过,现在按实测更正:
@@ -719,6 +736,30 @@ FAIL LIVE GUARD: the installed host 0.1.8-rc.1 is admitted by both modes -> ..
719
736
  `host-range-test.mjs` 会断言 `peerDependencies` 与另外两处字段**逐字节一致**,并断言 peer 只声明这一个包名——
720
737
  三处任一被手改都会在测试里失败。
721
738
 
739
+ ### 在 DSH Go(Android)上运行
740
+
741
+ DSH Go(Android 客户端)内置的引擎就是**真实 DSH**,锁定 `@deepseek-ai/dsh` **`0.2.1-rc.4`**
742
+ (`runtime/locks/dsh-0.2.1-rc.4/package.json`),在 PRoot rootfs 内以
743
+ `dsh web --patch /etc/dsh-app/dsh-web.patch.yml --host 127.0.0.1 --port N --no-open` 运行;
744
+ 环境经 `/usr/bin/env -i` 清空后只注入 `HOME=/root`、`DSH_HOME=/root/.dsh`——**App 侧设置的
745
+ `HOME` 对引擎无效**,别按字面读 `DshProcess.kt`。
746
+
747
+ - **兼容性**:`0.2.1-rc.4` 正好是上表①档已实测的版本。2026-10-31 又用同一个 rc.4 在隔离的
748
+ `DSH_HOME` 上复跑一遍 **21/21**(`compat-1016-live-check.mjs`),注入实测
749
+ `chars=292 / budget=4000`、`state` p95 1.09 ms、SSE 首帧 0.71 ms;证据
750
+ `dsh-memory-verification/out/compat-1016-live-rc4-audit.json`。
751
+ - **数据位置**:`/root/.dsh/instruction-memory/memory.json`,在 App 私有目录的 rootfs 内,
752
+ 与桌面端**不是同一份**;快照里的路径以 `~` 显示,不泄露绝对路径。卸载 App 会连 rootfs 一起清空,
753
+ 卸载**插件**保留数据。
754
+ - **配置入口**:原生设置页依赖 schema 表单,本插件没有 config schema,所以那一行点进去是
755
+ **网页岛**(WebView 里加载的官方 Web UI),`settings.section` 的落点在网页客户端里。
756
+ 若从未打开过任何网页岛,这一行可能尚未出现。
757
+ - **市场版本可能滞后**:DSH Go 的发现页只读 `api.dshfind.com/market/dsh-index.json`,该文件对本
758
+ 插件在 2026-10-31 仍只登记 `1.4.2`(npm latest 已是 1.4.6)。要装最新版,在 guest shell 里执行
759
+ `dsh plugin --profile web add dsh-instruction-memory`,直接取 npm 上的版本。
760
+ - **双端记忆不共享**:手机与"遥控 PC"各自维护自己的 `memory.json`,没有同步——这是当前形态,
761
+ 不是缺陷。
762
+
722
763
  ## 发布(维护者)
723
764
 
724
765
  **正规流程是推 tag,由 CI 通过 OIDC(Trusted Publishing)发布**,不需要任何长期令牌:
package/install.mjs CHANGED
@@ -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,6 +159,8 @@ 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 {
@@ -159,7 +183,6 @@ if (existsSync(backupPath)) {
159
183
  copyFileSync(pkgPath, backupPath)
160
184
  console.log('backed up profile package.json -> ' + backupPath)
161
185
 
162
- const pkg = readPkg()
163
186
  let changed = false
164
187
 
165
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
@@ -210,8 +210,13 @@ export function displayPath(value, home = homedir(), platform = process.platform
210
210
  const base = home.endsWith(sep) ? home.slice(0, -sep.length) : home
211
211
  if (base === '') return value
212
212
  const token = platform === 'win32' ? '%USERPROFILE%' : '~'
213
- if (value === base) return token
214
- if (value.startsWith(base + sep)) return token + value.slice(base.length)
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)
215
220
  return value
216
221
  }
217
222
 
@@ -1098,6 +1103,20 @@ const isLoopbackName = (name) => {
1098
1103
  return false
1099
1104
  }
1100
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
+
1101
1120
  /**
1102
1121
  * Why the settings-page route checks `Host`, `Origin` and `Sec-Fetch-Site` at
1103
1122
  * all, when it already refuses any content-type that is not
@@ -1136,8 +1155,15 @@ const isLoopbackName = (name) => {
1136
1155
  * same origin, so nothing legitimate is lost. On a non-loopback bind the server
1137
1156
  * may sit behind a proxy that legitimately rewrites ports, so the same-site
1138
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.
1139
1165
  */
1140
- export function requestOriginVerdict(headers, guarded) {
1166
+ export function requestOriginVerdict(headers, guarded, expectedHostname = '') {
1141
1167
  const read = (name) => {
1142
1168
  if (headers === null || typeof headers !== 'object') return ''
1143
1169
  const value = headers[name]
@@ -1177,8 +1203,23 @@ export function requestOriginVerdict(headers, guarded) {
1177
1203
  // (sandboxed iframe, file://) parses to no hostname and is refused.
1178
1204
  // Canonical IP comparison covers the two spellings of one address:
1179
1205
  // ::1 vs [::1], and ::ffff:127.0.0.1 vs ::ffff:7f00:1.
1180
- if (originHostname === '' || !sameHostname(originHostname, requestHostname)) {
1181
- 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
+ }
1182
1223
  }
1183
1224
  // On loopback the port IS part of the origin, and a different local port is
1184
1225
  // a different application: refuse it when both sides state one. Skipped on
@@ -1647,6 +1688,12 @@ export function apply(ctx) {
1647
1688
  * temporal-dead-zone hazard (F12 publishes it as `security.hostGuarded`).
1648
1689
  */
1649
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 = ''
1650
1697
  /**
1651
1698
  * F7: the pending mute-expiry timer, or null. Re-armed by scheduleMuteTimer
1652
1699
  * (from syncSection) and cleared in the effect cleanup.
@@ -1702,7 +1749,11 @@ export function apply(ctx) {
1702
1749
  if (storeDir !== '' && storeDir !== '.') text = text.split(storeDir).join('<记忆目录>')
1703
1750
  try {
1704
1751
  const home = homedir()
1705
- 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
+ }
1706
1757
  } catch {
1707
1758
  // No home to redact: keep the text as it is.
1708
1759
  }
@@ -2908,7 +2959,7 @@ export function apply(ctx) {
2908
2959
  error: state.storageError,
2909
2960
  warning: state.storageWarning !== null ? state.storageWarning : state.backupError,
2910
2961
  savedAt: state.savedAt,
2911
- migratedFrom: state.migratedFrom,
2962
+ migratedFrom: state.migratedFrom === null ? null : displayPath(state.migratedFrom),
2912
2963
  /** false until the store file has actually been written (see readFromDisk step 3). */
2913
2964
  onDisk: state.storeExists,
2914
2965
  /** Backup location (F18): '' means "next to the store file". */
@@ -3831,7 +3882,7 @@ export function apply(ctx) {
3831
3882
  } catch (err) {
3832
3883
  return { ok: false, saved: false, message: '写入备份目录失败:' + describeErrorSafe(err), snapshot: snapshot() }
3833
3884
  }
3834
- return { ok: true, saved: false, message: '已写入 ' + target, snapshot: snapshot() }
3885
+ return { ok: true, saved: false, message: '已写入 ' + displayPath(target), snapshot: snapshot() }
3835
3886
  })
3836
3887
 
3837
3888
  /**
@@ -4492,7 +4543,7 @@ export function apply(ctx) {
4492
4543
  * `?lite=1` gets `{ rev, lite: true }` frames instead (see pushSnapshot).
4493
4544
  */
4494
4545
  const handleEventStream = (req, res) => {
4495
- const verdict = requestOriginVerdict(req.headers, guardHost)
4546
+ const verdict = requestOriginVerdict(req.headers, guardHost, expectedOriginHostname)
4496
4547
  if (!verdict.ok) {
4497
4548
  logInfo('route rejected a request: ' + verdict.reason)
4498
4549
  res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
@@ -4601,7 +4652,7 @@ export function apply(ctx) {
4601
4652
  // Origin/Host guard, before anything else is read. See
4602
4653
  // requestOriginVerdict() for why the content-type rule below is not enough
4603
4654
  // on its own.
4604
- const verdict = requestOriginVerdict(req.headers, guardHost)
4655
+ const verdict = requestOriginVerdict(req.headers, guardHost, expectedOriginHostname)
4605
4656
  if (!verdict.ok) {
4606
4657
  logInfo('route rejected a request: ' + verdict.reason)
4607
4658
  res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
@@ -4702,6 +4753,9 @@ export function apply(ctx) {
4702
4753
  // the README.
4703
4754
  const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
4704
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)
4705
4759
  // F12: the settings page can only warn about a non-loopback bind if the
4706
4760
  // snapshot carries what the route actually saw at registration time.
4707
4761
  state.boundHost = boundHost
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.4.5",
3
+ "version": "1.4.7",
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",