dsh-tiddlywiki 0.26.5 → 0.26.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/LICENSE +21 -21
- package/README.md +3 -1
- package/cordis.patch.yml +13 -13
- package/lib/client.js +27 -27
- package/lib/index.js +89 -17
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/endpoints.ts +27 -0
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/note-widget.ts +8 -5
- package/src/client/state.ts +39 -39
- package/src/client/status-cache.ts +7 -0
- package/src/client/toast.ts +22 -22
- package/src/client/tw-frame.ts +20 -4
- package/src/host/http.ts +34 -1
- package/src/host/routes.ts +16 -3
- package/src/host/tools.ts +56 -15
- package/docs/code-review-2026-09-17.md +0 -151
package/src/host/tools.ts
CHANGED
|
@@ -392,23 +392,47 @@ function rewriteRefs(text: string, oldTitle: string, newTitle: string): { text:
|
|
|
392
392
|
}
|
|
393
393
|
|
|
394
394
|
/**
|
|
395
|
-
* Insert `addition`
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
395
|
+
* Insert `addition` into the section introduced by `heading` (Markdown `#`/`##`…
|
|
396
|
+
* or wikitext `!`/`!!`…). Used by tiddlywiki_append so an agent can add to one
|
|
397
|
+
* section of a long note without rewriting the whole file.
|
|
398
|
+
*
|
|
399
|
+
* **落点语义**:插在「该标题之后、**下一个任意级别的标题**之前」。所以当标题后面
|
|
400
|
+
* 紧跟子标题时(`## 4. 定时任务` + `### 槽位表`),内容落在两者**之间**,也就是
|
|
401
|
+
* 「章节开头」而不是「整节末尾」——这一点以前只在 schema 里写成「段落的末尾」,
|
|
402
|
+
* 容易让调用方误会(v0.26.6 起描述与回执都写清楚了)。
|
|
403
|
+
*
|
|
404
|
+
* **返回 `matched`**:`false` = 没找到该标题,文本被追加到**文末**。调用方**必须**
|
|
405
|
+
* 把它透出去(v0.26.6 之前这个回退是静默的,回执照样打「段落「X」」,
|
|
406
|
+
* 于是一次定位失败看起来像成功——Agent 没有任何办法自检)。
|
|
407
|
+
*
|
|
408
|
+
* **CRLF(v0.26.6 修复)**:tiddler 正文可能是 CRLF(Windows 上写的/同步来的笔记),
|
|
409
|
+
* 而 `split('\n')` 会让每行结尾都留着 `\r`。JS 里 `(.*)` 不匹配 `\r`、`$` 也不匹配
|
|
410
|
+
* `\r` 之前的位置,于是 `/^#{1,6}\s+(.*)$/` 对**每一个**标题都匹配失败 ⇒ 所有带
|
|
411
|
+
* `heading` 的 append 都静默落到了文末。**只在 CRLF 笔记上复现**(纯 LF 笔记一切正常),
|
|
412
|
+
* 这就是它像「偶发」的原因。现在只在**匹配时**剥掉行尾 CR,原文行尾一个字节都不动;
|
|
413
|
+
* 并且新块用**文档自己的行尾**写入,避免 CRLF 笔记被越写越乱(混入 LF 块)。
|
|
399
414
|
*/
|
|
400
|
-
function insertIntoSection(base: string, heading: string, addition: string): string {
|
|
415
|
+
function insertIntoSection(base: string, heading: string, addition: string): { text: string; matched: boolean } {
|
|
401
416
|
const lines = base.split('\n')
|
|
402
417
|
const headingText = (line: string): string | null => {
|
|
403
|
-
|
|
418
|
+
// 只在匹配时剥离行尾 CR:CRLF 笔记的每一行都会带上它。
|
|
419
|
+
const s = line.replace(/\r$/, '')
|
|
420
|
+
const md = /^#{1,6}\s+(.*)$/.exec(s)
|
|
404
421
|
if (md !== null) return (md[1] ?? '').trim()
|
|
405
|
-
const tw = /^!{1,6}\s*(.*)$/.exec(
|
|
422
|
+
const tw = /^!{1,6}\s*(.*)$/.exec(s)
|
|
406
423
|
if (tw !== null) return (tw[1] ?? '').trim()
|
|
407
424
|
return null
|
|
408
425
|
}
|
|
409
426
|
const start = lines.findIndex((line) => headingText(line) === heading)
|
|
427
|
+
// 新块跟随文档主流行尾:CRLF 文档里不要再塞 LF 块。
|
|
428
|
+
const eol = base.includes('\r\n') ? '\r\n' : '\n'
|
|
429
|
+
const adapt = (s: string): string => (eol === '\n' ? s.replace(/\r\n/g, '\n') : s.replace(/\r\n/g, '\n').split('\n').join('\r\n'))
|
|
430
|
+
const block = adapt(addition)
|
|
410
431
|
if (start < 0) {
|
|
411
|
-
return
|
|
432
|
+
return {
|
|
433
|
+
text: base.trim().length === 0 ? block : `${base.replace(/\s+$/, '')}${eol}${eol}${block}`,
|
|
434
|
+
matched: false,
|
|
435
|
+
}
|
|
412
436
|
}
|
|
413
437
|
let end = lines.length
|
|
414
438
|
for (let i = start + 1; i < lines.length; i++) {
|
|
@@ -419,8 +443,11 @@ function insertIntoSection(base: string, heading: string, addition: string): str
|
|
|
419
443
|
}
|
|
420
444
|
const before = lines.slice(0, end).join('\n').replace(/\s+$/, '')
|
|
421
445
|
const after = lines.slice(end).join('\n')
|
|
422
|
-
const head = `${before}
|
|
423
|
-
return
|
|
446
|
+
const head = `${before}${eol}${eol}${block}`
|
|
447
|
+
return {
|
|
448
|
+
text: after.trim().length === 0 ? head : `${head}${eol}${eol}${after.replace(/^\s+/, '')}`,
|
|
449
|
+
matched: true,
|
|
450
|
+
}
|
|
424
451
|
}
|
|
425
452
|
|
|
426
453
|
|
|
@@ -1100,12 +1127,12 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
|
|
|
1100
1127
|
// ── tiddlywiki_append ────────────────────────────────────────────────────
|
|
1101
1128
|
register(defineTool({
|
|
1102
1129
|
name: 'tiddlywiki_append',
|
|
1103
|
-
description: '向已有 tiddler 追加/前插文本,或写入指定标题段落的末尾(无需先读全文、不会整篇覆盖)——适合日志、批注、清单的增量写入。条目不存在时默认新建(createIfMissing=false 则报错)。**写入既有条目走与 tiddlywiki_put 同一套写策略**:原有 tags、自定义字段与**内容类型**全部保留(不会把 Markdown 笔记改成 wikitext、也不会把 CSS 改成 Markdown);可用 fields 显式覆盖字段/类型。新建条目未指定内容类型时才默认 text/markdown(并像 tiddlywiki_put 一样自动补 agent-written 与 `ws/<项目名>` 工作区标记)。⚠️ 本工具内部是「读全文 → 整篇回写」,属于覆盖写入:改前请先 `tiddlywiki_get`,并把读到的 `modified` 作为 `expectedModified` 传回(v0.25.0 起支持),否则人类在 TW
|
|
1130
|
+
description: '向已有 tiddler 追加/前插文本,或写入指定标题段落的末尾(无需先读全文、不会整篇覆盖)——适合日志、批注、清单的增量写入。条目不存在时默认新建(createIfMissing=false 则报错)。**写入既有条目走与 tiddlywiki_put 同一套写策略**:原有 tags、自定义字段与**内容类型**全部保留(不会把 Markdown 笔记改成 wikitext、也不会把 CSS 改成 Markdown);可用 fields 显式覆盖字段/类型。新建条目未指定内容类型时才默认 text/markdown(并像 tiddlywiki_put 一样自动补 agent-written 与 `ws/<项目名>` 工作区标记)。⚠️ 本工具内部是「读全文 → 整篇回写」,属于覆盖写入:改前请先 `tiddlywiki_get`,并把读到的 `modified` 作为 `expectedModified` 传回(v0.25.0 起支持),否则人类在 TW 编辑器里的并发修改会被静默回滚。带 `heading` 时返回 `headingMatched`:`false` = 没找到该标题、文本已追加到**文末**(CRLF 行尾的笔记也能正确定位)。',
|
|
1104
1131
|
parameters: {
|
|
1105
1132
|
title: { type: 'string', description: 'tiddler 标题', required: true },
|
|
1106
1133
|
text: { type: 'string', description: '要追加/前插的文本(默认按 Markdown 写;既有条目保持它自己的内容类型)', required: true },
|
|
1107
1134
|
mode: { type: 'string', enum: ['append', 'prepend'], description: '可选:append(默认,追加到末尾)/ prepend(插到开头)' },
|
|
1108
|
-
heading: { type: 'string', description: '可选:append
|
|
1135
|
+
heading: { type: 'string', description: '可选:append 时改为写入指定标题的位置(Markdown # 或 wikitext ! 标题,按标题文本匹配、忽略标题级别)。**落点 = 该标题之后、下一个任意级别标题之前**:若该标题后紧跟子标题,内容插在两者之间(即章节开头),**不是整节末尾**。标题不存在时不报错,改为追加到文末,并在回执与返回的 headingMatched=false 里明确说明(别把「没命中」当「写进去了」)' },
|
|
1109
1136
|
createIfMissing: { type: 'boolean', description: '可选:条目不存在时是否新建(默认 true)' },
|
|
1110
1137
|
tags: { type: 'array', items: { type: 'string' }, description: '可选:标签(不传则保留既有条目的原标签,传空数组 [] 表示清空全部标签;新建条目会额外自动补 agent-written)' },
|
|
1111
1138
|
fields: { type: 'object', additionalProperties: true, description: '可选:显式覆盖的自定义字段(如 {"type":"text/css"})。不传则保留既有条目的原字段与内容类型' },
|
|
@@ -1116,7 +1143,14 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
|
|
|
1116
1143
|
normalizeArgs: normalizeFieldsInArgs,
|
|
1117
1144
|
output: {
|
|
1118
1145
|
render: (_args, value: AppendResult) => {
|
|
1119
|
-
|
|
1146
|
+
// v0.26.6:标题没定位到时必须说出来。以前回执无条件打「段落「X」」,
|
|
1147
|
+
// 于是「静默追加到文末」看起来跟「精确写进那一节」一模一样。
|
|
1148
|
+
const headingNote = value.heading === null
|
|
1149
|
+
? ''
|
|
1150
|
+
: value.headingMatched === false
|
|
1151
|
+
? ` · ⚠️ 未找到标题「${value.heading}」,已改为追加到文末`
|
|
1152
|
+
: ` · 段落「${value.heading}」`
|
|
1153
|
+
const lines = [`${value.created ? '已新建并写入' : '已增量写入'} tiddler「${value.title}」(${value.mode}${headingNote}):新增 ${value.added} 字符,现共 ${value.total} 字符。`]
|
|
1120
1154
|
if (value.type !== null) lines.push(`类型: ${value.type}${value.typeDefaulted === true ? '(新建且未指定,已默认 markdown)' : ''}`)
|
|
1121
1155
|
if (value.typeChanged !== undefined) lines.push(`⚠️ 内容类型已从 ${value.typeChanged.from} 改为 ${value.typeChanged.to}`)
|
|
1122
1156
|
if (value.workspace !== undefined) lines.push(`已自动标记工作区: ${WORKSPACE_TAG_PREFIX}${value.workspace}(字段 ${WORKSPACE_FIELD})`)
|
|
@@ -1145,8 +1179,11 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
|
|
|
1145
1179
|
const base = existing?.text ?? ''
|
|
1146
1180
|
const addition = args.text
|
|
1147
1181
|
let next: string
|
|
1182
|
+
let headingMatched: boolean | undefined
|
|
1148
1183
|
if (mode === 'append' && typeof args.heading === 'string' && args.heading.trim().length > 0) {
|
|
1149
|
-
|
|
1184
|
+
const placed = insertIntoSection(base, args.heading.trim(), addition)
|
|
1185
|
+
next = placed.text
|
|
1186
|
+
headingMatched = placed.matched
|
|
1150
1187
|
} else if (mode === 'prepend') {
|
|
1151
1188
|
next = base.trim().length === 0 ? addition : `${addition}\n\n${base}`
|
|
1152
1189
|
} else {
|
|
@@ -1177,6 +1214,10 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
|
|
|
1177
1214
|
heading: mode === 'append' && typeof args.heading === 'string' && args.heading.trim().length > 0
|
|
1178
1215
|
? args.heading.trim()
|
|
1179
1216
|
: null,
|
|
1217
|
+
// v0.26.6:给了 heading 时它到底有没有定位成功。false = 没找到该标题、文本被
|
|
1218
|
+
// 追加到了**文末**。此前这个回退是静默的,回执还照打「段落「X」」,
|
|
1219
|
+
// 导致一次定位失败看起来完全像成功(Agent 无法自检)。
|
|
1220
|
+
...(headingMatched !== undefined ? { headingMatched } : {}),
|
|
1180
1221
|
created: existing === undefined,
|
|
1181
1222
|
added: addition.length,
|
|
1182
1223
|
total: next.length,
|
|
@@ -1716,7 +1757,7 @@ interface RenameResult { ok: boolean; from: string; to: string; refsUpdated: num
|
|
|
1716
1757
|
interface DeleteResult { ok: boolean; title: string; /** true = moved to the trash (recoverable). */ trashed?: boolean; trashTitle?: string }
|
|
1717
1758
|
interface TrashItem { title: string; at?: string; of?: string }
|
|
1718
1759
|
interface TrashResult { action: string; message: string; items?: TrashItem[] }
|
|
1719
|
-
interface AppendResult { ok: boolean; title: string; mode: 'append' | 'prepend'; heading: string | null; created: boolean; added: number; total: number; type?: string | null; typeDefaulted?: boolean; typeChanged?: { from: string; to: string }; /** 新建时自动打上的工作区标记(v0.24.0)。 */ workspace?: string }
|
|
1760
|
+
interface AppendResult { ok: boolean; title: string; mode: 'append' | 'prepend'; heading: string | null; /** v0.26.6:给了 heading 时该标题是否真的定位成功(false = 未找到,文本已追加到文末)。 */ headingMatched?: boolean; created: boolean; added: number; total: number; type?: string | null; typeDefaulted?: boolean; typeChanged?: { from: string; to: string }; /** 新建时自动打上的工作区标记(v0.24.0)。 */ workspace?: string }
|
|
1720
1761
|
interface BacklinkHit { title: string; refs: number; via: 'link' | 'tag'; modified: string | null }
|
|
1721
1762
|
interface BacklinkResult { title: string; total: number; linkCount: number; tagCount: number; items: BacklinkHit[] }
|
|
1722
1763
|
interface AttachResult { ok: boolean; title: string; mime: string; bytes: number; chars: number; source: string | null; embedInto: string | null }
|
|
@@ -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=` 的路由断言。
|