dsh-instruction-memory 1.4.7 → 1.5.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 +154 -0
- package/README.md +117 -19
- package/install.mjs +79 -10
- package/lib/client.js +269 -26
- package/lib/index.js +856 -86
- package/lib/mailer.js +50 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,160 @@
|
|
|
7
7
|
> 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
|
|
8
8
|
> 这些历史 tag **不应回填**——npm 上的版本号已被占用。
|
|
9
9
|
|
|
10
|
+
## [1.5.0] — 2026-11-04
|
|
11
|
+
|
|
12
|
+
针对 2026-11-04 全面复核(数据安全 / 并发 / 注入 / SMTP / 设置面板)逐项修复。主线是
|
|
13
|
+
**“合法 JSON 但不是本插件的存储”这一整类输入**:1.4.8 会把 `[]`、`123`、`{"version":"3"}`
|
|
14
|
+
当作未声明格式的空 store 载入,然后在用户下一次保存时用空库覆盖磁盘。
|
|
15
|
+
|
|
16
|
+
### 数据安全
|
|
17
|
+
|
|
18
|
+
- **存储形状门**:新增 `storeShapeVerdict()`,区分「不是对象」「对象但不是本插件的存储」
|
|
19
|
+
「空对象」「合规存储」。前两种不再被采纳,写入被暂停并给出可操作的提示;用户可经历史回滚 /
|
|
20
|
+
备份整体替换 / 修正文件后重新载入恢复,首次成功写盘后自动解除。
|
|
21
|
+
- **`.bak` 轮转守卫兑现注释承诺**:旧实现只验 `JSON.parse`,畸形主文件会覆盖唯一的好备份;
|
|
22
|
+
现在只有通过形状门的文件才配称为“上一版备份”。
|
|
23
|
+
- **版本声明收紧**:`{"version":"3"}`、`{"version":"2.0"}` 这类字符串声明不再被当作“未声明版本”
|
|
24
|
+
而静默降级覆盖,一律拒绝写入。
|
|
25
|
+
- **`adoptParsedStore` 改用同一形状判定**(导入 / 整体替换路径)。
|
|
26
|
+
- **旧位置迁移**:cwd 里的历史文件不是存储时忽略并保留原文件,不再迁移。
|
|
27
|
+
|
|
28
|
+
### 并发与持久性
|
|
29
|
+
|
|
30
|
+
- **锁回收不再误删**:陈旧锁在 `unlink` 前二次 `stat` 比对 mtime/size,`LOCK_STALE_MS` 10s → 30s。
|
|
31
|
+
- **临时文件唯一化**:`memory.json.tmp-<pid>-<uuid>`,两个进程不会再写同一个临时文件。
|
|
32
|
+
- **落盘栅栏**:tmp 写入 `fsync`,备份复制后 `fsync`,`rename` 后对目录 `fsync`。
|
|
33
|
+
- **重载后强制真写盘**:`readFromDisk` 重置 `lastPersistedKey`,消除“内容巧合相同 → 跳写却报
|
|
34
|
+
saved:true”导致的面板与磁盘分叉;跳写分支同步更新 `savedAt`。
|
|
35
|
+
- 历史清理的设备预算与全局兜底在注释中改为如实描述(跨设备删除是有意保留的例外)。
|
|
36
|
+
|
|
37
|
+
### 注入与快照
|
|
38
|
+
|
|
39
|
+
- `buildProfilesBlock` 删除死变量;`snapshot()` 的 `injection.profiles` 恢复 `truncated` 标记。
|
|
40
|
+
|
|
41
|
+
### 反馈邮件(零依赖 SMTP)
|
|
42
|
+
|
|
43
|
+
- EHLO 失败(500/502/504)回落 HELO。
|
|
44
|
+
- 多行响应以最后一行的状态码为准;跨 TCP 块的 UTF-8 用 `StringDecoder` 解码。
|
|
45
|
+
- 服务器未声明 LOGIN/PLAIN 时直接给出机制清单,不再盲目发 `AUTH LOGIN`。
|
|
46
|
+
- `Message-ID` 只接受形如 `local@domain` 的值,其余回退生成;加解密与投递统一走 `mailboxVerdict`。
|
|
47
|
+
|
|
48
|
+
### 设置面板
|
|
49
|
+
|
|
50
|
+
- **乱序响应不再回退视图**:`absorb()` 拒绝 rev 更旧的快照。
|
|
51
|
+
- **初始加载失败可自愈**:lite 帧在 `view === null` 时也触发重新拉取,并新增「重新载入」按钮。
|
|
52
|
+
- **冲突后不再无限重试**:源码保存的 `baseRev` 在冲突响应后同步为最新 rev;单条记忆在冲突时
|
|
53
|
+
会先请求确认再覆盖对方修改。
|
|
54
|
+
- **`busy` 改为计数**:首个返回的请求不再提前解锁按钮。
|
|
55
|
+
- **卸载后不再 setState**:组件级 `disposed` 标志。
|
|
56
|
+
- **历史回滚先过体积门**,确认文案带上快照文件名;**切换记忆集前检查用户形象草稿**是否未保存。
|
|
57
|
+
- `normalizeResult` 校验 `data` 是对象且 `entries` 是数组;`resolveSlots()` 的异常不再逃出挂载守卫。
|
|
58
|
+
|
|
59
|
+
## [1.4.8] — 2026-11-03
|
|
60
|
+
|
|
61
|
+
针对 2026-11-03 三方复核(架构 / 安全 / 验收)下达的 R1–R11 逐项修复,以及同日第二轮复核
|
|
62
|
+
(同一批专家 + 我方对运行中实例的 HTTP 探针)追加的 9 项。宿主侧没有新 API 可用,所以安全项
|
|
63
|
+
走的是"能用宿主的就用,用不上就 fail-closed"。
|
|
64
|
+
|
|
65
|
+
### 安全
|
|
66
|
+
|
|
67
|
+
- **设置路由接入宿主的鉴权门**:注册后向宿主申请 `connection` 服务,每个请求先过
|
|
68
|
+
`connection.requestRejection(req)`——宿主 403(Host/Origin 不被信任)与 401(未登录)
|
|
69
|
+
原样回绝,鉴权组件抛错按 401 处理(守卫坏掉不能变成敞开的门)。没有该服务时**非回环绑定
|
|
70
|
+
一律 503**:此前 `--host 0.0.0.0` 的机器等于把"改写此后每一轮注入内容"的写入口敞开给
|
|
71
|
+
整个局域网。回环绑定(默认部署)不受影响,仍然吃 Host / Origin / Sec-Fetch-Site 三道本地校验。
|
|
72
|
+
- **`DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS`**:回环绑定下显式放行额外的 `Host` / `Origin`
|
|
73
|
+
主机名(`hosts` 别名、Tailscale MagicDNS、隧道域名)。逗号分隔,可写完整 origin / 带端口 /
|
|
74
|
+
IPv6 方括号,统一折成小写、去端口、去括号的主机名;只放宽回环绑定那两处校验,端口比对不变。
|
|
75
|
+
- **备份位置与导出目录默认限定在 `$DSH_HOME` 内**:该路由在没有鉴权门的 profile 上等于
|
|
76
|
+
"仅回环可访问",而 `backupDir` 此前接受任意绝对路径(设置时探针写入、之后每次保存都写副本),
|
|
77
|
+
是一个"把磁盘写满"的原语。改用非 `$DSH_HOME` 路径必须显式设
|
|
78
|
+
`DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP=1`;手改存储文件把 `backupDir` 指到外面同样被拒。
|
|
79
|
+
- **导出 2 秒节流**:整份存储(满配约 1.2 M 字符)连点不再变成写放大。
|
|
80
|
+
- **报错文本折叠自定义备份目录**为 `<备份目录>`(云盘路径常带账号名),此前只有当它位于家目录
|
|
81
|
+
之下才会被折叠。
|
|
82
|
+
- **状态目录按 0700 创建**(`instruction-memory/`、`history/`、备份目录),不再继承 umask 的 0755。
|
|
83
|
+
|
|
84
|
+
### 复核跟进(第二轮,同日)
|
|
85
|
+
|
|
86
|
+
- **有鉴权门时不再重复判 `Origin`**(P1,可用性缺陷):此前门放行后插件仍用自己的
|
|
87
|
+
`requestOriginVerdict` 复审,而它只知道**绑定地址**——绑 `192.168.1.5`、经反向代理用
|
|
88
|
+
`https://dsh.example.com` 访问一律 403。宿主那道门已经用**它**的 `trustedHosts` 与绑定地址
|
|
89
|
+
判过同一件事,且知道对外域名,所以门放行后跳过本地那套 Host/Origin/Sec-Fetch-Site 判定;
|
|
90
|
+
门不存在时它仍是唯一防线(非回环绑定 503 不变)。README 原先"Origin 与转发 Host 一致即可"
|
|
91
|
+
的说法同时更正。新增契约断言:三种非回环绑定下,带门的反代域名请求 200、无门的仍是 503。
|
|
92
|
+
- **升级路径上的目录权限**:`mkdir({mode:0o700})` 不会作用到已存在的目录,1.4.7 建的
|
|
93
|
+
`instruction-memory/`、`history/` 升级后仍是 0755——实测本机就是 755。现在启动时对**已存在**
|
|
94
|
+
的目录做一次 best-effort `chmod 0700`(不创建目录:首次运行保持惰性,仍由"启动不建目录"的
|
|
95
|
+
契约钉住);写入路径统一走 `hardenDir()`。
|
|
96
|
+
- **面板鉴权状态可见**:快照新增 `security.authFence`;非回环绑定的告警分两文案——门已挂载时
|
|
97
|
+
说明"仍需登录",而不是谎称"无鉴权可访问"。
|
|
98
|
+
- **门被 dispose 时不再静默**:宿主撤销 `connection` 服务后,若插件仍在挂载,记一条日志并把
|
|
99
|
+
`authFence` 反映为 false,而不是无声退回回环规则。
|
|
100
|
+
- **提示词拉取计数不再能中断整轮**:`assembledSectionText()` 里的 `logDebug` 移进 try——宿主
|
|
101
|
+
组装提示词时不包 try/catch,日志实现抛错会直接毁掉整轮对话。
|
|
102
|
+
- **`SECTION_ORDER` 改为向宿主询问**:`systemPrompt.getSectionOrder()` 存在时取
|
|
103
|
+
`FILE_REFERENCE`(900) 与 `TOOL_BASH`(1000) 的中点,宿主表将来在 950 插入条目也不会变成
|
|
104
|
+
"静默并列、按字典序"。取不到时仍回落 950。
|
|
105
|
+
- **`feedback.json` 权限提示**:该文件可能含 SMTP 凭据,权限对 group/others 开放时记一条日志
|
|
106
|
+
(插件不创建这个文件,只能提示)。
|
|
107
|
+
- **威胁模型表述更正**:README 不再暗示"模型绝对写不进去"——插件不注册模型工具,但同 UID
|
|
108
|
+
的进程(含被授予 shell 的 agent)可以直接改存储文件,轮询会照常载入。
|
|
109
|
+
- **`--host 0.0.0.0` 文档更正**:本宿主在加载期就拒绝通配绑定,示例改为具体 IP,并说明对外
|
|
110
|
+
域名要写进宿主 `dsh-client-connection` 的 `trustedHosts`(插件的
|
|
111
|
+
`DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS` 只对没有门的 profile 生效)。
|
|
112
|
+
|
|
113
|
+
### 修复
|
|
114
|
+
|
|
115
|
+
- **`fs.watch` 兜底轮询**:WSL 的 drvfs(`/mnt/c`、`/mnt/d`)、部分网络盘与容器挂载上
|
|
116
|
+
`fs.watch` 可能从不触发,而"从不触发"与"存储没变"无法区分——没有事件可用于触发降级。现在
|
|
117
|
+
宿主**总是**并行跑 5 秒的 `size:mtime:ino` 签名轮询,变化时走与 watcher 相同的重读路径;
|
|
118
|
+
原生文件系统上它看到的签名就是 watcher 刚处理过的那个,不产生额外重载。定时器 `unref()`。
|
|
119
|
+
- **子路径挂载时设置页打错 API 基址**:客户端改用以 `document.baseURI` 解析的绝对 URL,
|
|
120
|
+
GUI 不再假定自己挂在站点根上。
|
|
121
|
+
- **设置页挂载失败曾经完全静默**:slot 挂载抛错时现在有可见告警,而不是"入口消失但日志空白"。
|
|
122
|
+
- **`install.mjs` 把 profile 写死成 `web`**:解析顺序改为 `$DSH_PROFILE_DIR` → `--profile <名>`
|
|
123
|
+
→ `$DSH_PROFILE` → `web`;目标 profile 不存在(没有 `package.json`)时**拒绝写入**并列出
|
|
124
|
+
`<DSH_HOME>/profiles/` 下真正有 profile 的目录,而不是装进一个不会被加载的目录后打印成功。
|
|
125
|
+
|
|
126
|
+
### 测试与工具
|
|
127
|
+
|
|
128
|
+
- **`host-range-test.mjs` 不再只认 web profile**:枚举 `$DSH_HOME/profiles/*/node_modules`、
|
|
129
|
+
`profiles/node_modules` 与全局安装去找 `semver` 和 `@deepseek-ai/dsh/package.json`。此前在
|
|
130
|
+
superagent 这类 profile 上探测不到宿主版本,测试会以 `ALL PASS (skipped)` 假绿。
|
|
131
|
+
- **5 个套件的临时目录移出仓库**:改用 `os.tmpdir()` + `mkdtemp()`,崩溃的运行不再在检出的
|
|
132
|
+
仓库里留下 `.test-dsh-home*` / `.test-install-*`;spawn `install.mjs` 的子进程环境里剥掉
|
|
133
|
+
继承来的 `DSH_PROFILE_DIR` / `DSH_PROFILE`,夹具 home 不会被新解析顺序顶掉。
|
|
134
|
+
- **`lint.mjs` 增加静态断言**:`lib/` 内不得出现 `@deepseek-ai/*` 的包名导入(插件只通过宿主
|
|
135
|
+
传入的上下文对象接触宿主;按包名 import 会把插件钉死在某个安装位置)。
|
|
136
|
+
- **契约 / 设置页测试跟到 fail-closed 语义**:非回环绑定 + 有鉴权门 → 200 且面板显示安全警告;
|
|
137
|
+
没有门 → 503 且面板显示被拒原因(此前断言"面板能读快照并警告",与新行为相反)。
|
|
138
|
+
- 全套 7 个套件(含 `lint.mjs`)在宿主 `0.2.2-alpha.3` / WSL drvfs 上全绿。
|
|
139
|
+
|
|
140
|
+
### 源码复核的第 2 轮补丁(2026-11-03)
|
|
141
|
+
|
|
142
|
+
上面那批改动做了一次逐行复核,补了 5 处;前两条都出在"守卫自己判断得对不对"上。
|
|
143
|
+
|
|
144
|
+
- **"在 `$DSH_HOME` 内"改为按真实路径判断**:`isInsideDshRoot` 原来只比字符串前缀。Windows 上
|
|
145
|
+
建 junction 不需要任何权限,所以 `$DSH_HOME` 里一个指向外部的链接就能让"备份位置限定"形同
|
|
146
|
+
虚设——已用临时 junction 复现:词法检查通过、`realpath` 在外部。现在两端都取 `realpath`
|
|
147
|
+
比较,尚不存在的目标按"最深的已存在祖先的真实路径 + 待创建部分"判断(负向对照:把判断退回
|
|
148
|
+
词法版本,新断言会失败)。
|
|
149
|
+
- **`$DSH_HOME` 由解析得出,不再反推**:`dirname(dirname(storePath))` 今天恰好等于它,但存储
|
|
150
|
+
目录多一层、或文件上移一层,这个"守卫的根"就会静默变成**父目录**。新增
|
|
151
|
+
`resolveDshRoot()`(`resolveStorePath` 改为复用它),`$DSH_HOME=/` 这类极端值也不再无声放开。
|
|
152
|
+
- **`--profile` 只接受名字**:`install.mjs` 直接把参数拼进 `profiles/`,`--profile ../../x`
|
|
153
|
+
可以逃出 profile 根(仍需目标已有 `package.json`)。现在用 `^[a-z0-9][a-z0-9._-]*$` 校验,
|
|
154
|
+
不合规就报错退出并提示改用 `$DSH_PROFILE_DIR`。
|
|
155
|
+
- **`fs.watch` 失败不再静默**:watcher 报错时只 `stopWatching()`,之后仅靠 5 秒轮询兜底,
|
|
156
|
+
用户与日志都看不到"已经降级"。现在先记一行 `logInfo` 再降级。
|
|
157
|
+
- **文档两处对齐**:非回环绑定那一行补上"该结论以宿主发布了 `connection` 为前提,否则一律 503"
|
|
158
|
+
的交叉引用;`DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS` 一节写明它**不放宽** `Sec-Fetch-Site`
|
|
159
|
+
(反代 + 子域若发出 `same-site` 仍 403,应改用隧道)。
|
|
160
|
+
- **补齐这两类开关的测试**:可信主机名 8 条(放行 / 其他名仍拒 / 外来 `Origin` 仍拒 /
|
|
161
|
+
`same-site` 仍拒 / `cross-site` 仍拒 / 空表不放宽 / 解析折叠 / 垃圾项丢弃),备份根 4 条
|
|
162
|
+
(默认拒绝并在拒绝时不落库、`=1` 后放行、链接解析到外部被拒)。全套 7 个套件 **965 条断言** 全绿。
|
|
163
|
+
|
|
10
164
|
## [1.4.7] — 2026-10-31
|
|
11
165
|
|
|
12
166
|
**文档版**:没有一行运行时代码变更,只把 2026-10-31「插件在 DSH Go(Android)上的适配性」
|
package/README.md
CHANGED
|
@@ -22,11 +22,33 @@ dsh plugin --profile web add dsh-instruction-memory
|
|
|
22
22
|
dsh plugin --profile web remove dsh-instruction-memory
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
### 从仓库安装(开发)
|
|
26
|
+
|
|
27
|
+
不让插件市场参与、直接从本仓库检出运行时装用 `install.mjs`:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
node install.mjs # 装进"解析出来的那个 profile"
|
|
31
|
+
node install.mjs --profile superagent # 指定 profile
|
|
32
|
+
node install.mjs --profile superagent --remove
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
profile 的解析顺序是 **`$DSH_PROFILE_DIR` → `--profile <名字>` → `$DSH_PROFILE` → `web`**。
|
|
36
|
+
`$DSH_PROFILE_DIR` 是宿主给每个子进程导出的"我正在用的 profile 目录",所以在 DSH 的终端里
|
|
37
|
+
直接跑会落到当前 profile(这一点与 `dsh plugin` 不同,后者要求显式 `--profile`)。目标 profile
|
|
38
|
+
里没有 `package.json` 时脚本**拒绝写入**,并列出 `<DSH_HOME>/profiles/` 下真正存在的 profile
|
|
39
|
+
名——早先版本把 `profiles/web` 写死,装进不存在(或不会被加载)的目录后仍然打印成功。
|
|
40
|
+
|
|
25
41
|
## 只由用户本人维护
|
|
26
42
|
|
|
27
|
-
**这份记忆的内容只属于用户。**
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
**这份记忆的内容只属于用户。** 本插件刻意**不注册任何面向模型的工具**,也不会主动生成
|
|
44
|
+
条目:写入入口只有设置页(以及用户本人直接编辑存储文件),因此不会出现 AI"顺手"给你
|
|
45
|
+
记下一条指令的情况。
|
|
46
|
+
|
|
47
|
+
但这不等于"模型绝对写不进去":存储文件在你自己 UID 下,**任何**以同一用户身份运行、
|
|
48
|
+
具备文件写入能力的进程(包括被你授予 shell 的 agent,以及本 profile 的
|
|
49
|
+
`danger-full-access`)都能直接改它;插件的外部改动监听(`fs.watch` + 5 秒轮询签名)还会
|
|
50
|
+
在**下一轮**把它当作正常编辑载入。要挡住这条路,请限制 agent 的文件权限,或把存储目录
|
|
51
|
+
留在受控位置——本插件的承诺只是"不提供模型工具",不是"阻止同权限进程"。
|
|
30
52
|
|
|
31
53
|
条目内容由用户输入并被原样注入,本插件不对其做语义判断或自动生成。
|
|
32
54
|
|
|
@@ -43,7 +65,7 @@ dsh plugin --profile web remove dsh-instruction-memory
|
|
|
43
65
|
| `verify.mjs` | 装载验证:按 DSH 的方式加载两半,并逐项断言 `memory.schema.json` 与代码中的上限一致 |
|
|
44
66
|
| `smoke-test.mjs` | 注入渲染逻辑的单元测试(含「只渲染激活配置集」的隔离断言与 cwd 红线) |
|
|
45
67
|
| `host-range-test.mjs` | 宿主版本范围契约测试:固定矩阵 × 两种 semver 判定模式,并把 `peerDependencies`(**被执行的那个**)、`dsh.engines.dsh`、`dsh.compatibility.dsh` 三处声明锁到同一个生成值 |
|
|
46
|
-
| `lint.mjs` |
|
|
68
|
+
| `lint.mjs` | 静态门禁,已并入 `npm test`:`node --check` 解析所有随包发布与本仓库运行的 JS 文件,并断言 `lib/` 里不出现 `@deepseek-ai/*` 的包名导入(插件只该通过宿主传进来的上下文对象接触宿主,按包名 import 会把插件钉死在某个安装位置,在 headless / CI / 检出目录里直接解析失败) |
|
|
47
69
|
| `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`(否则回环请求也会被代理转发,读数虚高) |
|
|
48
70
|
| `docs/demo.html` | 离线演示页:桩掉 DSH 外壳、加载真实 Client 半渲染设置页(不随 npm 包发布) |
|
|
49
71
|
| `memory.schema.json` | 手改存储文件用的 JSON Schema(随包发布,并被宿主复制到 `memory.json` 旁边) |
|
|
@@ -113,6 +135,10 @@ dsh plugin --profile web remove dsh-instruction-memory
|
|
|
113
135
|
DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DSH_HOME`(如 `./data`)会在
|
|
114
136
|
启动时按当前目录解析为绝对路径,因此设置页显示的始终是完整路径。
|
|
115
137
|
|
|
138
|
+
插件创建 `instruction-memory/` 与 `history/` 时按 **0700** 建目录(只给当前用户;umask
|
|
139
|
+
只会让它更严):这些文件里存的是会被注入每一轮对话的长期指令,同一台机器上的其他用户
|
|
140
|
+
没有理由读得到。
|
|
141
|
+
|
|
116
142
|
> 1.0.0 之前的构建曾把数据写在宿主进程 cwd 下(`.dsh-instruction-memory.json`)。
|
|
117
143
|
> 首次启动新版本时会自动迁移到上述位置,并把原文件改名为 `*.migrated` 保留。
|
|
118
144
|
|
|
@@ -178,6 +204,11 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
|
|
|
178
204
|
重连失败期间面板照常可用(「重新载入」仍是兜底),不会弹出错误。
|
|
179
205
|
- 编辑器中未保存的草稿**不受推送影响**;保存时按当前 `rev` 提交,外部若已抢先修改
|
|
180
206
|
会被冲突拒绝拦下。
|
|
207
|
+
- **兜底轮询**:`fs.watch` 在 WSL 的 drvfs(`/mnt/c`、`/mnt/d`)、部分网络盘和容器挂载上
|
|
208
|
+
可能**从不触发**,而"从不触发"与"存储没变"在事件层面无法区分——没有事件可以用来触发降级。
|
|
209
|
+
因此宿主**总是**同时跑一个 5 秒轮询(比较 `size:mtime:ino` 签名),签名变化时走与 watcher
|
|
210
|
+
完全相同的重读路径;在原生文件系统上它看到的签名正是 watcher 刚处理过的那个,什么也不做
|
|
211
|
+
(内容比较会再挡一层)。定时器 `unref()`,不会把宿主进程留住。
|
|
181
212
|
|
|
182
213
|
响应信封中的 `saved` 字段语义为「**本次操作是否把存储写入了磁盘**」,只反映真实写入:
|
|
183
214
|
普通读取(`state`/`reload`/`export-data`)一律 `false`;首次运行时 `state` 物化文件、
|
|
@@ -325,6 +356,16 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
|
|
|
325
356
|
`memory.json.bak` 始终留在存储目录,因为那是"下次启动必须能找到"的东西。
|
|
326
357
|
- 设置时会**当场校验**:必须是绝对路径、目录存在、且实际写入一个探针文件成功;不满足就直接
|
|
327
358
|
拒绝并说明原因(例如"目录不存在或不可访问"),而不是等到某次保存悄悄失败。
|
|
359
|
+
- **默认只允许 `$DSH_HOME` 之内的路径**:在没有 `connection` 服务的 profile 上,这条路由
|
|
360
|
+
等于"仅回环可访问",而备份位置此前接受**任意**绝对路径(设置时探针写入、之后每次保存都写
|
|
361
|
+
一份副本)。那是一个"把磁盘写满"的原语,所以现在非 `$DSH_HOME` 的路径一律拒绝,并说明要
|
|
362
|
+
把这些设置放在别的盘(比如 Windows 上的 D:)就得显式打开:
|
|
363
|
+
`DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP=1`(重启宿主生效)。导出目录受同一条限制;
|
|
364
|
+
手改存储文件把 `backupDir` 指到外面也同样被拒——这条路不能变成创建任意目录的通道。
|
|
365
|
+
- **导出有 2 秒节流**:两次「导出到备份目录」之间要隔 2 秒,否则提示"还需等几秒"。导出写的是
|
|
366
|
+
整份存储(满配约 1.2 M 字符),连点不该变成写放大。
|
|
367
|
+
- 报错文本会折叠路径:自定义备份目录 → `<备份目录>`(云盘路径常带账号名)、记忆目录 →
|
|
368
|
+
`<记忆目录>`、家目录 → `~` / `%USERPROFILE%`,与设置页其他位置显示的一致。
|
|
328
369
|
- **它是本机设置**:从备份文件"整体替换"或回滚历史时**不会**被改写——否则一台机器上的路径
|
|
329
370
|
会被带到另一台机器上,把备份悄悄写到一个不存在的目录里。
|
|
330
371
|
- 多设备建议:把备份位置指向本地云盘目录,**只让导出文件与 `history/` 快照同步**;
|
|
@@ -550,8 +591,20 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
|
|
|
550
591
|
[instruction-memory] route rejected a request: Host 头不是回环地址(可能是 DNS 重绑定攻击):evil.com:8080
|
|
551
592
|
```
|
|
552
593
|
|
|
553
|
-
-
|
|
554
|
-
|
|
594
|
+
- 绑定到**非回环地址**(只能是具体 IP——当前宿主在加载期就拒绝 `0.0.0.0` / `::` 这类通配绑定,
|
|
595
|
+
所以"绑通配对外发布"在本宿主上起不来)时,本插件的 Host 校验**自动关闭**,此时**有没有
|
|
596
|
+
`connection` 服务决定一切**:
|
|
597
|
+
- **有**:每个请求先过宿主的鉴权门(Host / `Origin` / `Sec-Fetch-Site` + 登录 cookie),
|
|
598
|
+
本插件**不再重复判 Origin**。插件只知道自己绑定的地址,不知道你对外用的域名;再判一次
|
|
599
|
+
只会把合法的反代客户端全部 403(1.4.8 早期版本就是这样,属可用性缺陷,非漏洞)。
|
|
600
|
+
要让域名访问通过,去宿主那边把域名写进 `dsh-client-connection` 的 `trustedHosts`。
|
|
601
|
+
- **没有**:非回环绑定对任何请求都是 503(见下面「宿主的鉴权门」一节),别只按这一行判断
|
|
602
|
+
能不能对外发布。
|
|
603
|
+
- 那套"`Origin` 必须等于绑定地址"的规则(1.4.6 引入)现在**只用于没有 `connection` 服务**的
|
|
604
|
+
profile:那里它是唯一防线,`Host` 受攻击者控制,把 `Origin` 与它相比等于自己跟自己比,
|
|
605
|
+
所以改为与绑定地址比(`::1` / `::ffff:127.0.0.1` 这类同一地址的不同写法仍等价)。回环绑定
|
|
606
|
+
不受影响:那里 Host 已被限制在回环名单内,客户端用 `127.0.0.1`、`localhost`、`[::1]` 或
|
|
607
|
+
`[::ffff:127.0.0.1]` 访问都能继续工作。
|
|
555
608
|
- 无 `Host` 的 HTTP/1.1 请求由 Node 的 HTTP 解析器先以 400 拒掉,根本到不了路由。
|
|
556
609
|
- **端口参与 `Origin` 比对**(早期版本忽略端口,于是 `127.0.0.1:1` 这种同主机不同端口的页面也算同源):比对前先把默认端口归一(`http:80` / `https:443` 视同未写端口),其余情况要求 scheme、主机名、端口三者一致。`Host` 末尾的 FQDN 点(`localhost.`)会被忽略,避免同一台机器的两种写法被当成跨源。
|
|
557
610
|
|
|
@@ -560,31 +613,76 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
|
|
|
560
613
|
`cross-site`——命中即 **403**。在回环绑定(受保护)时 **`cross-site` 与 `same-site` 都拒绝**:
|
|
561
614
|
"同站"判定不看端口,而回环上的任何其他端口都是别人跑的本地服务,放行 `same-site` 等于让任意
|
|
562
615
|
本地页面改写你的长期指令。`none`(用户直接导航)放行;curl、Node 与 2020 年前的老浏览器根本
|
|
563
|
-
不发这个头,也不受影响(它们由 Host 与 content-type
|
|
564
|
-
|
|
616
|
+
不发这个头,也不受影响(它们由 Host 与 content-type 校验兜底)。
|
|
617
|
+
|
|
618
|
+
注意这一整套 Host / `Origin` / `Sec-Fetch-Site` 判定**只在没有 `connection` 服务时执行**。
|
|
619
|
+
有门时它由宿主做(宿主接受回环、`trustedHosts` 与监听地址,并校验 `Origin` 与 `Host` 同源、
|
|
620
|
+
`Sec-Fetch-Site` 不是 `cross-site`),本插件不再重复判——重复判会把反代域名全部 403;
|
|
621
|
+
没有门时非回环绑定直接 503,根本走不到这里。
|
|
622
|
+
|
|
623
|
+
### 宿主的鉴权门(connection):非回环绑定没有它就直接拒绝
|
|
624
|
+
|
|
625
|
+
路由挂载后会向宿主申请 `connection` 服务(DSH 的 Web / Go 端都提供,负责把浏览器会话换成
|
|
626
|
+
"这个人登录了"):
|
|
627
|
+
|
|
628
|
+
- **有这个服务**:每个请求先过 `connection.requestRejection(req)`。宿主返回 4xx 就按同样的
|
|
629
|
+
状态码回绝(401 未登录 / 403 未被信任),请求不进入任何逻辑;鉴权组件自己抛错按 **401**
|
|
630
|
+
处理——坏掉的守卫不能变成敞开的门。通过之后**不再重复**本插件自己的 Host / `Origin` 判定:
|
|
631
|
+
宿主那道门已经用**它**的 `trustedHosts` 与绑定地址判过同一件事,而插件只知道绑定地址,
|
|
632
|
+
再判一次会把反代域名访问全部 403(1.4.8 修复)。`Sec-Fetch-Site`、content-type、请求体上限
|
|
633
|
+
这些只属于本路由的规则照旧生效。
|
|
634
|
+
- **没有这个服务**:回环绑定照旧工作(默认部署,靠本地那三道防线);**非回环绑定一律 503**:
|
|
635
|
+
`该路由在非回环绑定下需要宿主的 connection 服务提供鉴权;当前 profile 没有该服务,已拒绝请求`。
|
|
636
|
+
早先没有这一条,把服务器绑到局域网地址(`--host <具体 IP>`)的机器等于把"改写此后每一轮
|
|
637
|
+
注入内容"的写入口敞开给整个局域网。设置页在这种状态下会直接显示这条 503 的原因,而不是装作存储是空的。
|
|
638
|
+
|
|
639
|
+
### 可信主机名(`DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS`)
|
|
640
|
+
|
|
641
|
+
回环绑定下 `Host` 必须是回环名。若你的访问方式让浏览器发出的 `Host` 不是 `127.0.0.1` /
|
|
642
|
+
`localhost` / `[::1]`——`hosts` 里把 `dsh.lan` 指向 127.0.0.1、Tailscale 的 MagicDNS 名、
|
|
643
|
+
或隧道把域名透传进来——可以在启动宿主的环境里显式放行(**仅在没有 `connection` 服务的
|
|
644
|
+
profile 上生效**;有那道门时,对外的域名要写进宿主的 `dsh-client-connection` →
|
|
645
|
+
`trustedHosts`,那是宿主自己的配置):
|
|
646
|
+
|
|
647
|
+
```sh
|
|
648
|
+
DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS="dsh.lan,app.example:8443,https://notes.lan"
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
逗号分隔;写完整 origin、带端口、带 IPv6 方括号都行,解析时统一折成小写、去端口、去方括号的
|
|
652
|
+
主机名,与 `Host` / `Origin` 解析出来的形式一致。它只放宽**回环绑定**下的这两处校验
|
|
653
|
+
(`Host` 白名单、`Origin` 的主机名),不改变"非回环绑定必须带鉴权门"这条;`Origin` 的端口
|
|
654
|
+
比对仍然生效。**它也不放宽 `Sec-Fetch-Site`**:回环绑定下 `same-site` 依旧拒绝("同站"不看端口,
|
|
655
|
+
回环上的任何其他端口都是别人的服务),所以经反代 + 子域访问时若浏览器发出 `same-site`,
|
|
656
|
+
即使主机名已可信也仍会拿到 403——这种情况请改用隧道(全程回环同源),或让代理保持
|
|
657
|
+
页面的同源形状。
|
|
565
658
|
|
|
566
659
|
### 经反向代理对外发布(自行承担风险)
|
|
567
660
|
|
|
568
661
|
默认部署(回环绑定 + 本机浏览器)不需要这一节。若你把 DSH 的 Web 服务器绑定到非回环地址
|
|
569
|
-
|
|
662
|
+
(**只能是具体 IP**,例如 `--host 192.168.1.5`;本宿主拒绝 `0.0.0.0` / `::` 通配绑定)并置于
|
|
663
|
+
nginx / Caddy 等反向代理之后远程访问,需要注意:
|
|
570
664
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
`
|
|
574
|
-
|
|
575
|
-
|
|
665
|
+
**先说清楚代价**:宿主**没有发布 `connection` 服务**时,该路由就没有任何鉴权,而且非回环
|
|
666
|
+
绑定会被插件直接 503——所以下面的步骤要能用,前提是宿主发布了这个服务。发布之后,鉴权
|
|
667
|
+
完全是宿主的责任:插件强制走 `connection.requestRejection()`,不再自己判 `Host` / `Origin` /
|
|
668
|
+
`Sec-Fetch-Site`(它只知道绑定地址,不知道你的对外域名)。宿主那道门拦的是"未登录的浏览器
|
|
669
|
+
会话";带着有效会话 Cookie 的请求,以及你宿主配置里信任的来源,都能直接读写这份记忆。
|
|
670
|
+
**更安全的替代是隧道方案**(SSH 端口转发 / Tailscale / WireGuard):宿主仍绑定
|
|
671
|
+
`127.0.0.1`,远程客户端通过隧道以回环身份访问,默认部署的那套校验原样生效。
|
|
576
672
|
|
|
577
673
|
仍要走反向代理时,代理必须满足:
|
|
578
674
|
|
|
579
|
-
1. **原样转发 `Host`**(nginx:`proxy_set_header Host $host
|
|
580
|
-
|
|
581
|
-
`Host` 会得到 403。
|
|
582
|
-
2. **不要注入 `Origin`**:代理自己添加的 `Origin`
|
|
675
|
+
1. **原样转发 `Host`**(nginx:`proxy_set_header Host $host;`)。宿主鉴权门把 `Host` 与
|
|
676
|
+
"监听地址 + 它自己的 `trustedHosts`"比对,所以对外域名要么等于绑定地址,要么写进
|
|
677
|
+
`dsh-client-connection` 的 `trustedHosts`;改写或剥掉 `Host` 会得到 403。
|
|
678
|
+
2. **不要注入 `Origin`**:代理自己添加的 `Origin` 头若与转发的 `Host` 不同源,会被宿主当作
|
|
679
|
+
跨源拒绝。
|
|
583
680
|
3. **透传 `Sec-Fetch-Site`**:代理不应剥掉 Fetch Metadata 头;正常同站访问浏览器发的是
|
|
584
681
|
`same-origin`,不受影响。
|
|
585
682
|
4. **透传 `content-type` 与请求体**:路由只接受 `application/json` 的 POST,
|
|
586
683
|
路径前缀为 `/instruction-memory/api`,代理转发规则需覆盖它。
|
|
587
|
-
5. **限制来源**:至少用代理层做 IP 白名单或加 Basic Auth
|
|
684
|
+
5. **限制来源**:至少用代理层做 IP 白名单或加 Basic Auth——插件本身不带任何鉴权,它只会
|
|
685
|
+
转用宿主发布的 `connection` 服务(没有该服务时非回环绑定直接 503)。
|
|
588
686
|
|
|
589
687
|
### 在 DSH Go(Android)上:鉴权来自平台补丁,不是本插件
|
|
590
688
|
|
package/install.mjs
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Install / uninstall dsh-instruction-memory into
|
|
2
|
+
* Install / uninstall dsh-instruction-memory into a DSH profile.
|
|
3
3
|
*
|
|
4
|
-
* node install.mjs
|
|
5
|
-
* node install.mjs --
|
|
4
|
+
* node install.mjs # install into the default profile
|
|
5
|
+
* node install.mjs --profile superagent # install into a named profile
|
|
6
|
+
* node install.mjs --remove # roll back completely
|
|
7
|
+
*
|
|
8
|
+
* The profile is resolved in the same order the boot uses:
|
|
9
|
+
* $DSH_PROFILE_DIR (a path), then --profile <name>, then $DSH_PROFILE, then
|
|
10
|
+
* 'web'. A profile that holds no package.json is refused before anything is
|
|
11
|
+
* written, and the profiles that do exist are listed.
|
|
6
12
|
*
|
|
7
13
|
* Three idempotent steps:
|
|
8
14
|
* 1. make the package resolvable from the profile's node_modules (junction)
|
|
@@ -17,7 +23,7 @@
|
|
|
17
23
|
*
|
|
18
24
|
* The profile package.json is backed up before the first modification.
|
|
19
25
|
*/
|
|
20
|
-
import { existsSync, readFileSync, writeFileSync, symlinkSync, lstatSync, unlinkSync, copyFileSync, mkdirSync } from 'node:fs'
|
|
26
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, symlinkSync, lstatSync, unlinkSync, copyFileSync, mkdirSync } from 'node:fs'
|
|
21
27
|
import { homedir } from 'node:os'
|
|
22
28
|
import { dirname, join, resolve } from 'node:path'
|
|
23
29
|
import { fileURLToPath } from 'node:url'
|
|
@@ -29,7 +35,75 @@ import { fileURLToPath } from 'node:url'
|
|
|
29
35
|
const DSH_HOME = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
|
|
30
36
|
? resolve(process.env.DSH_HOME.trim())
|
|
31
37
|
: join(homedir(), '.dsh')
|
|
32
|
-
|
|
38
|
+
|
|
39
|
+
// Which profile to install into.
|
|
40
|
+
//
|
|
41
|
+
// This used to be hardcoded to 'web'. On a machine whose DSH runs the
|
|
42
|
+
// `superagent` profile the script created a junction and a bundle entry inside
|
|
43
|
+
// a profile nothing loads, then printed INSTALLED — the plugin looked installed
|
|
44
|
+
// and stayed invisible. Resolve it instead, with the same precedence the boot
|
|
45
|
+
// uses (an explicit directory wins over a name; the environment wins over the
|
|
46
|
+
// default):
|
|
47
|
+
//
|
|
48
|
+
// $DSH_PROFILE_DIR (a path)
|
|
49
|
+
// --profile <name>
|
|
50
|
+
// $DSH_PROFILE (a name)
|
|
51
|
+
// 'web'
|
|
52
|
+
const PROFILES_ROOT = join(DSH_HOME, 'profiles')
|
|
53
|
+
|
|
54
|
+
function profileNameFromArgv() {
|
|
55
|
+
const index = process.argv.indexOf('--profile')
|
|
56
|
+
if (index === -1) return null
|
|
57
|
+
const value = process.argv[index + 1]
|
|
58
|
+
if (value === undefined || value.startsWith('-')) {
|
|
59
|
+
console.error('--profile needs a profile name, e.g. --profile superagent')
|
|
60
|
+
console.error(' nothing was changed.')
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(value)) {
|
|
64
|
+
console.error('--profile takes a profile NAME (e.g. superagent), not a path: ' + value)
|
|
65
|
+
console.error(' use --profile to pick a name, or $DSH_PROFILE_DIR for an explicit directory.')
|
|
66
|
+
console.error(' nothing was changed.')
|
|
67
|
+
process.exit(1)
|
|
68
|
+
}
|
|
69
|
+
return value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const explicitProfileDir = typeof process.env.DSH_PROFILE_DIR === 'string' && process.env.DSH_PROFILE_DIR.trim() !== ''
|
|
73
|
+
? resolve(process.env.DSH_PROFILE_DIR.trim())
|
|
74
|
+
: null
|
|
75
|
+
const profileName = profileNameFromArgv()
|
|
76
|
+
?? (typeof process.env.DSH_PROFILE === 'string' && process.env.DSH_PROFILE.trim() !== ''
|
|
77
|
+
? process.env.DSH_PROFILE.trim()
|
|
78
|
+
: null)
|
|
79
|
+
?? 'web'
|
|
80
|
+
const PROF = explicitProfileDir ?? join(PROFILES_ROOT, profileName)
|
|
81
|
+
|
|
82
|
+
/** Profile directories that actually hold a manifest, for the failure message. */
|
|
83
|
+
function visibleProfiles() {
|
|
84
|
+
try {
|
|
85
|
+
return readdirSync(PROFILES_ROOT, { withFileTypes: true })
|
|
86
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(PROFILES_ROOT, entry.name, 'package.json')))
|
|
87
|
+
.map((entry) => entry.name)
|
|
88
|
+
.sort()
|
|
89
|
+
} catch {
|
|
90
|
+
return []
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Fail before any filesystem change: writing into a profile that does not
|
|
95
|
+
// exist would create `profiles/<typo>/` and report success for an install that
|
|
96
|
+
// can never load.
|
|
97
|
+
if (!existsSync(join(PROF, 'package.json'))) {
|
|
98
|
+
const visible = visibleProfiles()
|
|
99
|
+
console.error('profile not found: ' + PROF)
|
|
100
|
+
console.error(' choose one with --profile <name> (or set $DSH_PROFILE / $DSH_PROFILE_DIR).')
|
|
101
|
+
console.error(visible.length > 0
|
|
102
|
+
? ' profiles available under ' + PROFILES_ROOT + ': ' + visible.join(', ')
|
|
103
|
+
: ' no profile with a package.json under ' + PROFILES_ROOT)
|
|
104
|
+
console.error(' nothing was changed.')
|
|
105
|
+
process.exit(1)
|
|
106
|
+
}
|
|
33
107
|
// This script lives in the package root, so the source is wherever it runs from.
|
|
34
108
|
const SRC = dirname(fileURLToPath(import.meta.url))
|
|
35
109
|
const NAME = 'dsh-instruction-memory'
|
|
@@ -154,11 +228,6 @@ if (!existsSync(SRC)) {
|
|
|
154
228
|
process.exit(1)
|
|
155
229
|
}
|
|
156
230
|
|
|
157
|
-
if (!existsSync(join(PROF, 'package.json'))) {
|
|
158
|
-
console.error('web profile not found at ' + PROF + '(DSH 尚未初始化该 profile?)')
|
|
159
|
-
process.exit(1)
|
|
160
|
-
}
|
|
161
|
-
|
|
162
231
|
const pkg = readPkgOrExit()
|
|
163
232
|
|
|
164
233
|
if (existsSync(link)) {
|