dsh-tiddlywiki 0.26.4 → 0.26.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/LICENSE +21 -21
- package/README.md +6 -1
- package/cordis.patch.yml +13 -13
- package/lib/index.js +148 -27
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/state.ts +39 -39
- package/src/client/toast.ts +22 -22
- package/src/host/tools.ts +90 -18
- package/src/host/write-policy.ts +72 -2
- package/src/index.ts +1 -0
- package/src/sdk.ts +14 -2
- package/docs/code-review-2026-09-17.md +0 -151
package/src/host/write-policy.ts
CHANGED
|
@@ -117,15 +117,81 @@ export function cleanTiddler(t: Tiddler): Tiddler {
|
|
|
117
117
|
return out
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** 是不是一个「平铺对象」(`fields` 唯一合法的形状;数组不算)。 */
|
|
121
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
122
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 错误信息里的短样本(不把整段正文塞进回执)。 */
|
|
126
|
+
function excerpt(value: string, max = 60): string {
|
|
127
|
+
const flat = value.replace(/\s+/g, ' ').trim()
|
|
128
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 归一化调用方给的 `fields`(v0.26.5)。
|
|
133
|
+
*
|
|
134
|
+
* 背景(真实事故):`tiddlywiki_put`/`batch_put`/`append` 的参数 schema 曾把它声明为
|
|
135
|
+
* `{ type: 'json' }`,而 DSH 的 schema 里 `type:'json'` 是**纯注解**——编译出来的
|
|
136
|
+
* 节点连 `type` 都没有(见 `src/sdk.ts` 的 `compileValue`)。模型于是不知道它是对象,
|
|
137
|
+
* 把 `{"review-after":"2026-12-22"}` 当**字符串**发来;字符串走到
|
|
138
|
+
* `withWorkspaceMark` 的 `{ ...(fields ?? {}) }` 被按字符展开成
|
|
139
|
+
* `{0:'{', 1:'"', 2:'r', …}`,再被当成几十个单字符字段写进条目,真正的自定义字段
|
|
140
|
+
* 一个也没落(用户 2026-09-23 实测复现两次)。
|
|
141
|
+
*
|
|
142
|
+
* 这条修复同时钉两头:工具 schema 改成真正的 object(`additionalProperties: true`),
|
|
143
|
+
* 以及在任何展开/遍历之前先在这里归一化。语义:
|
|
144
|
+
*
|
|
145
|
+
* | 输入 | 结果 |
|
|
146
|
+
* |---|---|
|
|
147
|
+
* | `undefined` / `null` / 空串 | `undefined`(保留基底,不动任何字段) |
|
|
148
|
+
* | 平铺对象 | 原样返回 |
|
|
149
|
+
* | JSON 字符串(对象) | 解析后使用——兼容仍按字符串发参的模型 |
|
|
150
|
+
* | 数组 / 数字 / 布尔 / 解析不出对象的字符串 | **抛错** |
|
|
151
|
+
*
|
|
152
|
+
* 最后一条是刻意的:宁可给模型一条可读的报错让它重发,也**绝不**静默丢字段或把
|
|
153
|
+
* 字符串按字符拆成垃圾字段(那正是这次事故的形态)。
|
|
154
|
+
*/
|
|
155
|
+
export function normalizeFieldsArg(fields: unknown): Record<string, unknown> | undefined {
|
|
156
|
+
if (fields === undefined || fields === null) return undefined
|
|
157
|
+
if (typeof fields === 'string') {
|
|
158
|
+
const trimmed = fields.trim()
|
|
159
|
+
if (trimmed.length === 0) return undefined
|
|
160
|
+
let parsed: unknown
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(trimmed)
|
|
163
|
+
} catch {
|
|
164
|
+
parsed = undefined
|
|
165
|
+
}
|
|
166
|
+
if (isPlainObject(parsed)) return parsed
|
|
167
|
+
throw new Error(
|
|
168
|
+
`fields 必须是一个对象(如 {"review-after":"2026-12-22"}),`
|
|
169
|
+
+ `收到无法解析成对象的字符串:${excerpt(fields)}`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
if (isPlainObject(fields)) return fields
|
|
173
|
+
throw new Error(
|
|
174
|
+
`fields 必须是一个对象(如 {"review-after":"2026-12-22"}),收到 ${Array.isArray(fields) ? '数组' : typeof fields}`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
120
178
|
/**
|
|
121
179
|
* 合并调用方显式提供的自定义字段(跳过保留字段与 undefined)。
|
|
122
180
|
*
|
|
123
181
|
* 模块私有(v0.22.8):它只被 buildWriteTiddler 调用,导出会让「写策略只有一个
|
|
124
182
|
* 入口」这条约定出现第二个可绕过的门。`verify-write-policy.mjs` 断的是
|
|
125
183
|
* `buildWriteTiddler`/`cleanTiddler` 的对外行为,不需要这个内部步骤。
|
|
184
|
+
*
|
|
185
|
+
* v0.26.5:非对象一律**抛错**,不再静默 return。旧实现的静默 return 让模型看到
|
|
186
|
+
* 「字段没写进去」却收不到任何错误(事故的第二个症状);配合 `withWorkspaceMark`
|
|
187
|
+
* 的字符串展开,才有了「几十个单字符字段」那一步。现在任何绕过
|
|
188
|
+
* `normalizeFieldsArg` 的调用方都会在这里被拦住,而不是往人类笔记上写垃圾。
|
|
126
189
|
*/
|
|
127
190
|
function applyCustomFields(tiddler: Tiddler, fields: Record<string, unknown> | undefined): void {
|
|
128
|
-
if (fields === undefined || fields === null
|
|
191
|
+
if (fields === undefined || fields === null) return
|
|
192
|
+
if (!isPlainObject(fields)) {
|
|
193
|
+
throw new Error(`内部错误:fields 必须是对象,收到 ${Array.isArray(fields) ? '数组' : typeof fields}`)
|
|
194
|
+
}
|
|
129
195
|
for (const [key, value] of Object.entries(fields)) {
|
|
130
196
|
if (RESERVED_TIDDLER_FIELDS.has(key)) continue
|
|
131
197
|
if (value === undefined) continue
|
|
@@ -194,7 +260,11 @@ export interface BuildWriteOptions {
|
|
|
194
260
|
existing?: Tiddler | undefined
|
|
195
261
|
/** 显式标签:给了就整体替换,没给就保留基底里的原标签。 */
|
|
196
262
|
tags?: string[] | undefined
|
|
197
|
-
/**
|
|
263
|
+
/**
|
|
264
|
+
* 显式自定义字段(逐个覆盖在基底之上)。必须是平铺对象——调用方应先用
|
|
265
|
+
* `normalizeFieldsArg()` 归一化(工具层允许模型按 JSON 字符串发来),
|
|
266
|
+
* 传字符串/数组会在 `applyCustomFields` 里抛错,绝不按字符拆开(v0.26.5)。
|
|
267
|
+
*/
|
|
198
268
|
fields?: Record<string, unknown> | undefined
|
|
199
269
|
/** 新建条目时是否自动补 `agent-written`(agent 工具 true,人类入口 false)。 */
|
|
200
270
|
agentTag?: boolean
|
package/src/index.ts
CHANGED
|
@@ -71,6 +71,7 @@ export {
|
|
|
71
71
|
buildWriteTiddler,
|
|
72
72
|
cleanTiddler,
|
|
73
73
|
flattenTiddlerFields,
|
|
74
|
+
normalizeFieldsArg,
|
|
74
75
|
} from './host/write-policy.ts'
|
|
75
76
|
export { openInTwEditor, registerRoutes } from './host/routes.ts'
|
|
76
77
|
export { writeSessionSummary, SESSION_SUMMARY_PREFIX } from './host/routes.ts'
|
package/src/sdk.ts
CHANGED
|
@@ -155,6 +155,15 @@ export interface DefineToolOptions<A, V> {
|
|
|
155
155
|
readonly name: string
|
|
156
156
|
readonly description: string
|
|
157
157
|
readonly parameters: Readonly<Record<string, ParameterSpec>>
|
|
158
|
+
/**
|
|
159
|
+
* 可选:在 schema 预校验**之前**归一化一次参数(v0.26.5)。
|
|
160
|
+
*
|
|
161
|
+
* 用途只有一个:`{ type: 'json' }` 编译后没有任何类型约束(见 `compileValue`),
|
|
162
|
+
* 模型可能把本该是对象的值按 JSON 字符串发来。把「解析成对象」这一步放在校验
|
|
163
|
+
* 之前,模型就不用先撞一次 `arguments.fields must be an object` 再重发;放在
|
|
164
|
+
* 校验之后则做不到(校验会先抛错)。抛出的错误照常变成工具错误。
|
|
165
|
+
*/
|
|
166
|
+
normalizeArgs?(args: Record<string, unknown>): Record<string, unknown>
|
|
158
167
|
readonly output: {
|
|
159
168
|
render(args: A, value: V): Array<{ type: 'text'; text: string }>
|
|
160
169
|
}
|
|
@@ -197,11 +206,14 @@ export function defineTool<A extends Record<string, unknown>, V>(options: Define
|
|
|
197
206
|
},
|
|
198
207
|
},
|
|
199
208
|
async execute(args, exec) {
|
|
200
|
-
const
|
|
209
|
+
const normalized = options.normalizeArgs !== undefined
|
|
210
|
+
? options.normalizeArgs(args as Record<string, unknown>)
|
|
211
|
+
: args
|
|
212
|
+
const violations = validateValue(parameters, normalized, 'arguments')
|
|
201
213
|
if (violations.length > 0) {
|
|
202
214
|
throw new Error(`Error: invalid arguments: ${violations.join('; ')}`)
|
|
203
215
|
}
|
|
204
|
-
return userExecute(
|
|
216
|
+
return userExecute(normalized as A, exec)
|
|
205
217
|
},
|
|
206
218
|
}
|
|
207
219
|
}
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
# dsh-tiddlywiki 全面代码审查(v0.22.10,2026-09-17)
|
|
2
|
-
|
|
3
|
-
审查对象:`1d67543`(v0.22.10)工作区,覆盖 host 数据写路径、HTTP/路由/安全层、进程/生命周期/存储/seed、以及全部 client 代码。
|
|
4
|
-
|
|
5
|
-
**结论**:本地四套校验(`verify:static` / `verify:unit` / `verify:e2e` / `verify:large`)全绿,`typecheck` 通过,`npm run build` 重建 `lib/` 与提交版**逐字节一致**(无脏构建产物)。但审查发现 **3 处 P0/P1 安全问题**——v0.19.3 / v0.20.0 建立的「插件密钥命名空间不可经代理读出」这条防线**实测已被绕过**。
|
|
6
|
-
|
|
7
|
-
标注约定:✅ = 我在本机实测/读码亲自复现;○ = 读码确认(未运行时复现)。
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## P0 — 安全
|
|
12
|
-
|
|
13
|
-
### 1. ✅ 代理的「密钥命名空间」封锁可用**百分号编码的单段路径**绕过
|
|
14
|
-
|
|
15
|
-
`src/host/routes.ts:565-575`(`isBlockedProxyPath`,用于 `/api` 与 `/tw`)
|
|
16
|
-
|
|
17
|
-
守卫只在路径里含**字面量 `/tiddlers/`** 时才生效。而 TW core 的 `get-tiddler-html.js` 路由是 `path = /^\/([^\/]+)$/`(**单段**,用 `decodeURIComponentSafe` 解码),于是把 `/` 编码成 `%2F` 就能绕开那个字面量匹配。
|
|
18
|
-
|
|
19
|
-
本机实测(只读 GET):
|
|
20
|
-
|
|
21
|
-
| 请求 | 结果 |
|
|
22
|
-
|---|---|
|
|
23
|
-
| `GET /dsh-tiddlywiki/tw/tiddlers/$:/plugins/dsh-tiddlywiki/config` | **403**(守卫生效) |
|
|
24
|
-
| `GET /dsh-tiddlywiki/tw/%24%3A%2Fplugins%2Fdsh-tiddlywiki%2Fconfig` | **200**,676 字节插件配置 JSON |
|
|
25
|
-
| `GET /dsh-tiddlywiki/api/%24%3A%2F...%2Fconfig` | **200**(同上) |
|
|
26
|
-
| `GET /dsh-tiddlywiki/get?title=...` | 403(这条路由没问题) |
|
|
27
|
-
|
|
28
|
-
同一编码手法对 `/api` 同样有效。**这次没有泄密**:本机 `$:/plugins/dsh-tiddlywiki/config` 里没有 `bridge.token` / `auth.password`,`git.remote` 也是不含凭据的纯 https 地址——但 README 恰恰叫用户去设 `bridge.token`,一旦设了,任何**同源**读取方(页面内 XSS、被注入的 TW 内容、其它插件)即可拿到明文。
|
|
29
|
-
|
|
30
|
-
**修复**:别再用 `/tiddlers/` 这个标记做判定。应把整条被代理路径解码、归一后,判断解码结果是否落在受保护标题上(覆盖 TW 各条路由形态),或在转发前对每个路径段解码后比对。
|
|
31
|
-
|
|
32
|
-
### 2. ✅ `/tw/` 索引页把整个 store(含插件配置)原样吐出
|
|
33
|
-
|
|
34
|
-
`src/host/routes.ts:1474-1553`(`handleTwProxy`)
|
|
35
|
-
|
|
36
|
-
`$:/core/save/all`(`templates/save-all.tid`)没有排除插件命名空间,所以 `GET /dsh-tiddlywiki/tw/` 会内嵌每一条 tiddler。本机实测:**33,643,875 字节**,且正文中确实包含 `$:/plugins/dsh-tiddlywiki/config`。
|
|
37
|
-
|
|
38
|
-
这条路径完全绕过了 `/admin/state` 的密钥打码(`admin.ts:385-414`)。
|
|
39
|
-
|
|
40
|
-
**修复**:在 store 的 publish 过滤里排除 `$:/plugins/dsh-tiddlywiki/`;更彻底的做法是共享 token 根本不进 wiki tiddler。
|
|
41
|
-
|
|
42
|
-
### 3. ✅ `POST /render` 只挡了 `title` 分支,`text` 分支的**转写**可读出被保护条目
|
|
43
|
-
|
|
44
|
-
`src/host/routes.ts:1394-1405`
|
|
45
|
-
|
|
46
|
-
v0.20.0 的加固只覆盖 `title`;`text` 分支未校验,而 TW 的 `/render` 会解析 `{{…}}` 转写,`contextTitle` 同样未校验。本机实测:
|
|
47
|
-
|
|
48
|
-
```
|
|
49
|
-
POST /render {"text":"{{$:/plugins/dsh-tiddlywiki/config}}","type":"text/vnd.tiddlywiki"}
|
|
50
|
-
→ 200,2744 字节,净化后的片段里是配置 JSON 的完整内容
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
即:加固过的这条路由,本身仍可把密钥渲染出来。
|
|
54
|
-
|
|
55
|
-
**修复**:`text` 分支拒绝含受保护标题的正文,并校验 `contextTitle`;或把转写放进受限上下文。
|
|
56
|
-
|
|
57
|
-
---
|
|
58
|
-
|
|
59
|
-
## P1/P2 — 数据安全与生命周期
|
|
60
|
-
|
|
61
|
-
### 4. ○ `tiddlywiki_delete`:回收站索引的守卫发生在**破坏性步骤之后**
|
|
62
|
-
|
|
63
|
-
`src/host/tools.ts:761`(`wiki.delete`)先于 `:768-769`(`readTrashIndex` + 中止判定)。
|
|
64
|
-
|
|
65
|
-
于是「快照 PUT 成功 → 原条目已删除 → 索引读失败」时,工具报的是「本次操作已中止」,但实际上原标题**已经没了**,而回收站副本**没进索引**:`trash list` 看不到、`restore` 找不到、`empty` 清不掉——成为永久孤儿(只能从 git 里捞)。
|
|
66
|
-
|
|
67
|
-
代码注释(`tools.ts:762-767`)与回归测试(`verify-audit-fixes.mjs:335`)都声称「先读索引再删除」,与实际执行顺序相反;这也是测试为何没抓到它的原因。
|
|
68
|
-
|
|
69
|
-
**修复**:把 `readTrashIndex` 与该守卫上移到快照 PUT 之前;若保留删除后失败的可能,错误信息里必须带上 `trashTitle` 以便恢复。
|
|
70
|
-
|
|
71
|
-
### 5. ○ `assertNoConflict` 把两个令牌当**或**处理,且 `revision` 不跨重启存活
|
|
72
|
-
|
|
73
|
-
`src/host/write-policy.ts:276-279`
|
|
74
|
-
|
|
75
|
-
先判 `modified` 命中即 return;再判 `revision` 命中即 return。TW 的 `revision` 是 `core/modules/wiki.js` 里的内存 hashmap(`changeCount`,**不持久化**,重启后每条回到 1)——我已在 `node_modules/tiddlywiki/core/modules/wiki.js:151-155` 确认。
|
|
76
|
-
|
|
77
|
-
后果:agent 读到 `revision: 1` → 人类在 TW 里改动 → 一次 pull/重启让 revision 复位 → agent 带 `expectedRevision: 1` 的写入被**接受**,人类的改动被静默覆盖。更糟的是**同时传两个令牌并不更安全**:`modified` 不匹配时会落到 `revision` 分支继续写。
|
|
78
|
-
|
|
79
|
-
**修复**:调用方给了哪个令牌就必须匹配哪个,任一不匹配即拒;并修正三处工具描述(`tools.ts:503`、`719`、`973`)不要暗示 revision 跨重启有效。
|
|
80
|
-
|
|
81
|
-
### 6. ✅ `fs.watch` 没有 `'error'` 监听器,可让整个 dsh web 进程退出
|
|
82
|
-
|
|
83
|
-
`src/index.ts:279-294`(`watchWiki`)
|
|
84
|
-
|
|
85
|
-
只挂了 `'change'`。Node 的 EventEmitter 在**没有 `'error'` 监听器**时会把 `error` 事件抛成未捕获异常(本机已用最小复现确认),而宿主没有 `uncaughtException` 处理器。`fs.watch` 的 `error` 是**异步**事件(Linux `ENOSPC` 用尽 inotify watch、Windows `EPERM`),外面那个 try/catch 只能接住同步创建失败。
|
|
86
|
-
|
|
87
|
-
**修复**:`watcher.on('error', () => {})`(我们自己的写入仍会 `touch()` 自动提交,退化是安全的)。
|
|
88
|
-
|
|
89
|
-
### 7. ○ `stop()` 不能取消在途的 `start()`,可泄漏孤儿 TW 子进程
|
|
90
|
-
|
|
91
|
-
`src/host/wiki.ts:550-587` vs `303`/`307`/`351`
|
|
92
|
-
|
|
93
|
-
`startOnce()` 在 `await this.ensureWiki()`(302)与 `await this.findFreePort()`(307)处让出执行权;`stop()` 在此期间把 `this.child` 取走并置空(559-560)。等 `startOnce()` 继续走到 `spawn()`(351)并赋值 `this.child` 时,**已经没人会去杀它**了。
|
|
94
|
-
|
|
95
|
-
可达路径:`scheduleRestart()` 的定时器在调用 `start()` **之前**就清掉了 `this.restartTimer`(517),所以随后的 `stop()` 既没定时器可取消、也看不到子进程;以及 `index.ts:884-888` 那个 3 秒 teardown race 超时。
|
|
96
|
-
|
|
97
|
-
**修复**:在 `spawn()` 前立刻复查 `if (this.stopping) return this.status()`(`stopping` 已有,无需新状态)。
|
|
98
|
-
|
|
99
|
-
### 8. ○ 回滚失败会让自动提交**永久停摆**
|
|
100
|
-
|
|
101
|
-
`src/host/wiki-switch.ts:86-99` + `src/index.ts:753-754`
|
|
102
|
-
|
|
103
|
-
外层失败路径已经先跑过 `teardownExtras()`(753,会 dispose AutoCommitter 与 fs watcher)再 `applyLocation`。而 `restore()` 里 `startServer()`(90)排在 `setupExtras()`(92)**之前**——若回滚时 `startServer()` 抛错(例如旧 wiki 撞上硬就绪上限),`catch` 直接返回,`setupExtras()` 永不执行,且没有任何其它路径会重新装配。此后所有 wiki 写入都不会再被提交,而旧子进程可能因「迟到就绪」复探仍在服务,`/status` 看起来一切正常。
|
|
104
|
-
|
|
105
|
-
**修复**:在 `restore()` 里用 `finally` 重新装配 extras。
|
|
106
|
-
|
|
107
|
-
### 9. ○ `runSwitch` 缺 `disposed` 守卫
|
|
108
|
-
|
|
109
|
-
`src/index.ts:736-770`。`disposed` 在作用域内(571 声明)但 `runSwitch` 从不检查:热重载/关闭期间的在途切换会重新挂上 committer+watcher,并可能在 teardown 已 `stop()` 之后 spawn 出新的 TW 子进程。
|
|
110
|
-
|
|
111
|
-
---
|
|
112
|
-
|
|
113
|
-
## P3 — 低危 / 行为瑕疵
|
|
114
|
-
|
|
115
|
-
10. ✅ **`escapePromptBraces` 对连续花括号失效**(`src/host/prompt.ts:142-144`)。`replace(/\{\{/g, …)` 只替换**不重叠**匹配,本机实测:3 个及以上连续 `{` 会残留 `{{`(`{{{name}}}` → `{<ZWSP>{{name}}}`),而 DSH 对未知变量**直接抛错**,会炸掉整个系统提示词装配——正是该函数存在的理由。现有断言(`verify-prompt.mjs:141`)只覆盖最简单的单个 `{{cwd}}`。**修复**:循环替换至无 `{{`,或在任意相邻两括号间插入 ZWSP。
|
|
116
|
-
11. ✅ **`readLimit` 把空参数当 0**(`src/host/routes.ts:315-318`):`?limit=` → `Number('' ?? 15)` = 0 → 夹到 1。本机实测 `/recent?limit=` 返回 `limit=1`,而 `/recent` 是 15。**修复**:空值按缺省处理(`readOptionalLimit` 已是对的)。
|
|
117
|
-
12. ✅ **`git pull` 失败后无条件 `rebase --abort`**(`src/host/git.ts:159-160`):只要 pull 失败就 abort——包括用户在 wiki 仓库里**自己正在做的 rebase**。本机真实仓库复现:`rebase in progress: True` → `pull --rebase` 报 `Pulling is not possible because you have unmerged files` → 插件执行 `rebase --abort` → 用户的 rebase 与冲突解决被清掉(提交仍在 reflog/ORIG_HEAD,可恢复)。**修复**:仅在确有 rebase 进行中且失败原因是冲突时才 abort。
|
|
118
|
-
13. ○ **clip-bridge 的 OPTIONS 预检排在 Host 白名单之前**(`src/host/clip-bridge.ts:688` 早于 `:694`):DNS-rebinding 页面能拿到 204 + `Access-Control-Allow-Origin: *` + `Allow-Private-Network: true`(仅信息泄露:确认回环端口有服务;后续 POST 仍 403)。
|
|
119
|
-
14. ○ **`ensureTiddlerTimestamps` 在只给了 `created` 时把 `modified` 钉死成 `created`**(`src/host/tw-api.ts:189`)。真实受害者就是配置 tiddler:`config.ts:215-221` 每次保存都只带 `created`,于是 `modified` 永远停在首次写入的时刻——与该文件 `198-200` 行自述的意图(「modified tracks edits」)相反。**本机磁盘实测**:`$__plugins_dsh-tiddlywiki_config.json.meta` 里 `created` 与 `modified` **完全相同**(`20260917033457660`)。`routes.ts:447-457` 因为显式传了 `modified` 才躲过。**修复**:缺的那一半用 `now`(仍保留「绝不改写已有值」的保证)。
|
|
120
|
-
15. ○ **`append` 在 `mode=prepend` 时回执里谎报段落**(`src/host/tools.ts:884` vs `:908`,render 在 `:866`):`heading` 只在 append 分支生效,却无条件回显,模型会以为写进了「日志」段。
|
|
121
|
-
16. ○ **`$:/` 系统条目其实会被软删除**(`src/host/tools.ts:743-744`):注释写「system tiddlers are never nested」,但条件只排除了 `TRASH_PREFIX`。`delete('$:/dsh-tiddlywiki/trash-index')` 会把索引本身移入回收站,随后读到 404 → 当成「回收站为空」→ 索引被重写成 1 条,**丢掉全部回收站记录**(正是 v0.19.5 想防的那类损失,只是换了扇门)。
|
|
122
|
-
17. ○ **`/render` 内层 catch 把超限/中断报成 400**(`src/host/routes.ts:1377-1382`):`body too large` 本应 413。
|
|
123
|
-
18. ○ **`put` 能把二进制附件改成损坏的文本**(`src/host/tools.ts:534`):按设计保留基底 `type`,于是对 `type: image/png` 的既有附件写入 markdown,type 仍是 `image/png` → 图裂,且回执里 `typeChanged` 为空(类型确实没变)。
|
|
124
|
-
|
|
125
|
-
---
|
|
126
|
-
|
|
127
|
-
## 已复核为正确的部分(尽力想证伪但没成功)
|
|
128
|
-
|
|
129
|
-
- **写策略的字段保留**:`cleanTiddler` 确实摊平了 `get-tiddler.js` 塞进 `fields` 的未知字段;`applyCustomFields` 拒绝 `title/text/tags/created/modified` 而放行 `type`;「覆盖保留 type/tags/自定义字段」的契约成立。
|
|
130
|
-
- **v0.22.10 的时间戳语义**:`formatTiddlerDate` ⇄ `parseTiddlerDate` 对 **1970–9999 年间所有可达时刻**往返一致(含 `.000`、`+1ms`、`+999ms`、1969 年末);`stampTiddlerTimes` 始终从基底取 `created`、从 `now` 取 `modified`,不会让 `modified` 早于 `created`;覆盖不会丢原 `created`;`ensureTiddlerTimestamps` 只补缺、绝不改写(回收站快照、恢复、`/edit` 草稿的显式值都存活)。仅当 `new Date()` 落到公元 1000 年前/10000 年后才不往返——生产不可达。
|
|
131
|
-
- **回收站快照/恢复的时间戳**:删除时保留原 `created`+`modified`,恢复同理,所以恢复的笔记不会跳到「最近修改」顶部;索引的 404 与读失败区分正确。
|
|
132
|
-
- **`batch_put` 有界并发**:结果按下标回填、逐条容错、计数器只在同步段内改、`autoCommit()` 在循环外只调一次。
|
|
133
|
-
- **`attach` 覆盖保护**:同名默认拒绝、需 `force`;附件本身与 `noteTitle` 嵌入都走共享写策略。
|
|
134
|
-
- **HTTP 层的方法校验**:routes.ts 与 admin.ts 的每一个写 handler 都声明了方法、每一个读 handler 都用 `rejectNonRead`,29 处注册全被 `guardHandler` 包裹;`readBodyStream` 在 `aborted`/`close` 上会 settle,不会挂死请求。
|
|
135
|
-
- **`/admin/restart` 即便没走 `beginMutation` 也不可能双重重启**:`WikiServer.start()/restart()` 是单飞的。
|
|
136
|
-
- **密钥打码**:`maskConfigSecrets`/`stripMaskedSecrets` 对 `bridge.token`、`auth.password`、`ui.sendToAgent.token`、`git.remote` 的往返正确;本机 `/status` 返回的是不含凭据的 remote。
|
|
137
|
-
- **SSRF 守卫**:`isPrivateAddress`/`ipv6ToBytes` 是逐字节且完备的(`::1`、`::ffff:7f00:1`、NAT64、6to4、IPv4-compatible 都判私网),解析不可判时 fail-closed;`resolvePublicTarget` 用自定义 `lookup` **钉住**已校验 IP 防 rebinding,每一跳重定向都重新校验;`hostAllowed` 字母精确;`safeTokenEqual` 两侧哈希。
|
|
138
|
-
- **seed 的 ONE-SHOT 语义**:只有 404 才算缺失(`readSeedTiddler`/`presentOf`/`inspectSeedContent`),非 force 绝不覆盖已存在条目;`refreshSeedMarker` 跳过无变化的 PUT。
|
|
139
|
-
- **子进程退出处理**:身份检查防止被替换的旧子进程清掉新子进程的状态;`stop()` 会先清「迟到就绪」复探。
|
|
140
|
-
- **净化器**:`codePointToChar` 已把数字实体夹在 `0x10FFFF`,`�` 不再抛 `RangeError`。
|
|
141
|
-
- **client 半部**:我逐文件读了 `tw-frame.ts`、`panel.ts`、`index.ts`、`sidebar-entry.ts`、`theme-sync.ts`、`quick-note-dock.ts`、`session-summary.ts`、`status-cache.ts`、`sync-button.ts`、`editor-popup.ts`、`knowledge-fab.ts`、`tool-views.ts`、`note-widget.ts` 的关键段:定时器/监听/observer/ResizeObserver 在 dispose 路径上都被回收、可见性都在 `await` 之后复查、`iframe.src` 从不作为加载判据(只用 `dataset.loaded`)、`contentDocument` 读取都有守卫、`dangerouslySetInnerHTML` 只吃宿主净化过的 `/render` 输出。**未发现新缺陷。**
|
|
142
|
-
|
|
143
|
-
---
|
|
144
|
-
|
|
145
|
-
## 建议的修复顺序
|
|
146
|
-
|
|
147
|
-
1. **P0 三连**(安全,同一个根因族):代理路径判定重写(#1)+ store 排除插件命名空间(#2)+ `/render` 的 `text`/`contextTitle` 校验(#3)。
|
|
148
|
-
2. **P1/P2 数据安全**:#4(删除前先读索引)、#5(令牌 AND 语义 + revision 不可持久化的措辞)、#6(`fs.watch` error 监听)、#7(`spawn` 前复查 `stopping`)、#8(`restore()` 用 finally 重装 extras)、#9(`runSwitch` 的 disposed 守卫)。
|
|
149
|
-
3. **P3**:#10–#18,其中 #10(提示词转义)、#11(空 limit)、#14(配置 `modified` 钉死)最值得顺手带上。
|
|
150
|
-
|
|
151
|
-
守门建议:#1/#2/#3 各加一条 E2E(用编码路径与转写正文断言 403),#4 加一条「索引读失败时原条目必须仍在」的顺序断言,#5 加一条「modified 不匹配即便 revision 相同也必须拒」的纯函数断言,#6/#7 各加一条源码级或注入式断言,#10 扩展现有 `verify-prompt.mjs` 断言(连续花括号),#11 加一条空 `?limit=` 的路由断言。
|