dsh-tiddlywiki 0.23.4 → 0.23.5

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/README.md CHANGED
@@ -29,7 +29,7 @@
29
29
  | 🎨 **自定义样式** | 「自定义样式」seed:编辑器美化 / 窄屏侧栏隐藏 / menubar 加高 / 批注弹窗等 5 张通用样式表,新 wiki 也能一键初始化(可选,v0.16.22) |
30
30
  | 📝 **可配置的注入提示词** | 插件注入每个会话的「TiddlyWiki 持久知识库」提示词可在设置页配置:**默认精简版**(~1.7KB,只保留工具 schema 表达不了的约定——同步纪律 / 标签约定 / 链接格式),可选**完整版**(额外附一份**由工具注册表实时生成**的参数索引,不会再过期);`extra` 追加自定义规范、`override` 整段接管、可整体停用;**保存后无需重启 dsh web**(section 即时重注册,当前会话下一步即生效),设置页可**按表单当前值预览**即将注入的全文(未保存的形态切换也立刻可见,v0.21.0 / v0.22.7) |
31
31
  | 🤖 **Agent 工具** | 15 个 `tiddlywiki_*` 工具:检索(**相关度排序 + 命中处片段 + 字段过滤**)、读写、**增量追加**、批量、重命名、**软删除/回收站**、**反向链接**、**附件入库**、**知识库体检**、git 同步与冲突解决(v0.19.0;检索/最近仍在**服务端**排除二进制附件,大 wiki 上从 515MB/17s 降到 ~0.4s) |
32
- | 🛡️ **不会被覆盖的写入** | 所有写入路径(agent 工具 **与** 快速笔记/编辑器路由)都**先读后写**:不传 tags 就保留原有标签、自定义字段与**内容类型**(`text/css`/wikitext 等不会被重置成 Markdown,v0.20.1);`tiddlywiki_put(..., expectedModified/expectedRevision)` 与 `tiddlywiki_delete(..., expectedModified/expectedRevision)` 乐观并发——读取后若有人(在 TW 编辑器里)改过,写入/删除被拒绝(HTTP 409)而不是静默覆盖或丢进回收站;`tiddlywiki_attach` 的同名标题**默认拒绝**(要覆盖必须 `force: true`);`tiddlywiki_delete` 默认**软删除进回收站**,`tiddlywiki_trash` 可恢复(v0.19.0 / v0.19.1 / v0.19.5) |
32
+ | 🛡️ **不会被覆盖的写入** | 所有写入路径(agent 工具 **与** 快速笔记/编辑器路由)都**先读后写**:不传 tags 就保留原有标签、自定义字段与**内容类型**(`text/css`/wikitext 等不会被重置成 Markdown,v0.20.1);`tiddlywiki_put(..., expectedModified/expectedRevision)` 与 `tiddlywiki_delete(..., expectedModified/expectedRevision)` 乐观并发——读取后若有人(在 TW 编辑器里)改过,写入/删除被拒绝(HTTP 409)而不是静默覆盖或丢进回收站;**给了哪个令牌就必须匹配哪个**(v0.23.5:两个令牌是 AND 不是 OR——`revision` 是 TW 的内存计数器、重启会复位,旧逻辑会因此静默放行跨重启的覆盖);`tiddlywiki_attach` 的同名标题**默认拒绝**(要覆盖必须 `force: true`);`tiddlywiki_delete` 默认**软删除进回收站**,`tiddlywiki_trash` 可恢复(v0.19.0 / v0.19.1 / v0.19.5 / v0.23.5) |
33
33
  | 🕒 **时间戳不再丢** | 插件写入的每条笔记都会带上 TW 的 `created`/`modified`(17 位紧凑 UTC,与 TW 编辑器逐字节一致):新建 = 两者都取当前时刻,覆盖 = **保留原 `created`、刷新 `modified`**。此前这两个字段被当成「TW 服务端会补」而丢弃,而服务端**从不补**——缺 `modified` 的条目会被 TW 的 `sortTiddlers` 当空串,在 `+[!sort[modified]]` 页面上**直接沉到最后一名**(表现为「新日记没被收录」)。`TiddlyWebClient.put()` 还有一道兜底,任何写路径都不会写出无时间戳的条目(v0.22.5) |
34
34
  | 🧼 **渲染片段净化** | 回复流卡片与会话汇总注入的 TW 片段先经 **host 白名单净化**(丢 `iframe`/`script`/`svg`/`on*`/`javascript:`/`data:text/html` 等)——TW 自己的解析器只剥 `on*`,`<iframe src="javascript:…">` 会原样通过并在 DSH 页面里执行(v0.19.1 修复的存储型 XSS) |
35
35
  | 🔒 **写路由方法校验** | 每个写路由只接受自己的 HTTP 方法:跨站 `GET /sync`、`GET /restart`、`GET /upload` 一律 405 且无副作用(v0.19.0 修复了「任意网页一张 `<img>` 即可触发 pull/commit/push」的 CSRF 面) |
@@ -405,6 +405,7 @@ lib/ # 预构建产物(发布含 lib/**,提交入库;
405
405
 
406
406
  > 最近几个主要版本的一句话记录(完整变更见 [Releases](https://github.com/bbqisbbq/dsh-tiddlywiki/releases) / git log)。
407
407
 
408
+ - **v0.23.5**(2026-09-18):**修 15 处审计缺陷**(第三轮全面审查列出的 18 条,逐条核实后修掉其中 15 条真缺陷;跳过 3 条纯理论性的信息泄露。完整审计报告见知识库笔记「dsh-tiddlywiki 第三轮代码审计」——审计报告是工作文档,不再进 git)。按性质分四组。**① 数据安全(最重)**:`tiddlywiki_delete` 原先**先删原条目、后读回收站索引**,于是「快照已写 → 原条目已删 → 索引读失败」时报"已中止",但笔记其实已经没了、且没进索引——`trash list` 看不见、`restore` 找不到、`empty` 清不掉,成为永久孤儿(只能从 git 捞);现在**读校验索引排在所有破坏性步骤之前**,中止即等于"没删"。同一处还有两个洞:`$:/` 系统条目会被**软删除**(删掉 `trash-index` 自己 → 下一次读 404 → 被当成"回收站为空" → 索引被重写成 1 条,丢掉全部记录),现在系统条目一律走永久删除分支,且**删回收站索引被显式拒绝**(要清空请用 `trash action=empty`)。`tiddlywiki_put` 不再把**二进制附件**写成文本(保留基底 `type: image/png` 会让 base64 解不出、图裂而回执说"类型没变";要转换得显式 `fields.type`)。`git pull` 不再**无条件 `rebase --abort`**——此前只要 pull 失败(含没配 remote、离线等与被 rebase 无关的原因),用户在 wiki 仓库里正在做的 rebase 与冲突解决就被清掉;现在只 abort「我们自己发起」的那个。乐观并发的两个令牌从 **OR 改回 AND**:`revision` 是 TW 的内存计数器(重启/拉取后复位),旧逻辑「`modified` 命中就放行、否则看 `revision`」会让跨重启的写入被判"一致"而**静默覆盖**人类改动,同时传两个令牌也**并不更安全**。配置 tiddler 的 `modified` 不再被钉死成 `created`(`ensureTiddlerTimestamps` 只补缺的那一半,用 now)。**② 崩溃与资源泄漏**:`fs.watch` 补上常驻 `error` 监听(Node 在无监听器时把 `error` 抛成未捕获异常 → 整个 `dsh web` 进程退出;Windows EPERM / Linux ENOSPC 都能触发);`WikiServer.startOnce()` 在 `spawn` 前**复查 `stopping`**(`stop()` 可在 `await findFreePort()` 期间跑完,此后 spawn 的子进程没人杀 = 孤儿 TW);知识库切换**回滚失败也必须重装 extras**(此前从 catch 直接返回、跳过 `setupExtras()`,此后所有 wiki 写入都不再被提交,而"迟到就绪"复探让 `/status` 看着一切正常——静默停摆);`runSwitch` 补 `disposed` 守卫(热重载/关闭期间的在途切换不再重新挂上 committer/watcher,也不在 teardown 之后再 spawn TW)。**③ 行为瑕疵**:`escapePromptBraces` 对**连续花括号**失效(`{{{name}}}` 会残留 `{{`,而 DSH 对未知变量直接抛错、能炸掉整个系统提示词装配正是该函数存在的理由);`?limit=` 空值不再被夹成 1(`Number('' ?? fallback)` 是 0,`/recent?limit=` 曾只返回 1 条而不是默认 15);`tiddlywiki_append` 在 `mode=prepend` 时不再回执谎报「写进了某段落」(`heading` 只对 append 生效);`POST /render` 的超限 body 从 400 改为 **413**。**④ 两处零成本的安全收口**:代理路径判定不再只看字面量 `/tiddlers/`——TW core 的 `get-tiddler-html` 路由是**单段**(`/^\/([^\/]+)$/`),把 `/` 编码成 `%2F` 即可绕过旧守卫(本机实测:修复前 `/tw/...%2Fconfig` 与 `/api/...%2Fconfig` 都返回 **200 + 715 字节配置 JSON**);`/render` 的 **`text` 分支**也不再是旁路(TW 会解析 `{{…}}` 转写,实测修复前 `{"text":"{{$:/plugins/dsh-tiddlywiki/config}}"}` 返回 200 + 3123 字节配置正文,净化器只管标签、管不住正文),`contextTitle` 一并校验。**守门**:`verify-write-policy` +6 条(令牌 AND 语义 +「证据不足判冲突」+ `onlyCreated` 不再钉死 modified)、`verify-prompt` +1 条连续花括号、`verify-frame-guards` +4 条源码级生命周期断言(`fs.watch` error 监听 / spawn 前复查 `stopping` / 回滚 finally / `runSwitch` disposed)、`verify-audit-fixes` +4 条 E2E(删除顺序:索引读失败后原条目必须仍在、`$:/` 与索引不得被软删、二进制附件拒写、`?limit=` 空值)、`verify-conflict-config-guards` +1 条**真 git E2E**(在真 rebase 中途 pull 失败,用户的 rebase 必须原样保留)、selftest +8 条(编码/双编码代理路径 403、`/render` 转写与 `contextTitle` 403、普通转写仍 200)。**全部 15 条修复都做过反向验证**:把修复还原后,新断言稳定变红。
408
409
  - **v0.23.4**(2026-09-18):**三处硬化,全部来自 v0.23.3 上线当天暴露的真实事故**。① **提交前拦冲突(数据安全)**:`git pull --rebase --autostash` 的 **autostash 重新应用**冲突后,`rebase --abort` 已无事可 abort,冲突标记留在工作树里,随后 60s 的 AutoCommitter 照常 `git add -A && commit` —— `<<<<<<< Updated upstream` 就这样被**永久提交并推送**进了 wiki 的配置 tiddler(`0b19ca6`,2026-09-17),插件随即解析不了自己的配置(微信发布一直是关的、`prompt.extra` 静默失效)。现在 `GitFace.conflictState()` 三重探测(进行中的 rebase / 未合并路径 / **工作树里残留的冲突块**),`commit()` 直接抛 `GitConflictStateError` 拒绝提交;`/sync` 回 409 + 文件名、agent 工具返回结构化失败、AutoCommitter 去重上报一次(不去刷屏);`pull()` 在 abort 后**复检**(autostash 形态正是 abort 之后才暴露);`/status` 与设置页状态行显示「冲突未解决(N 个文件,已阻止提交)」。⚠️ `MERGE_HEAD`/`CHERRY_PICK_HEAD` 故意**不**硬拒——`git commit` 正是收尾它们的动作(真 git E2E 抓出了这个设计错误)。② **配置解析失败拒绝写(数据丢失)**:`ConfigStore.set()` 在存量 tiddler 解析失败时,原先拿**空缓存**合并 → 一次设置页保存就把配置写成只剩表单里那几个字段的"残版"(18 日 13:38 实测:`prompt.extra`/`git.remote`/`ui.*` 全丢)。现在抛 `ConfigUnreadableError`、不 PUT,并把原因经 `/admin/state` 的 `configError` 渲染成设置页顶部红色横幅;瞬时**读**失败仍走"合并到缓存"(回归断言守住)。③ **adapter 版本校验**:`/wechat/ready` 原先只数文件在不在,旧版 adapter(不认识 `--title-file`、标题仍是必填位置参数)也会报"就绪",点按钮才在 opencli 里报"缺少必填参数"。现在按**定义**判新旧(`weixin-flow.js` 必须 `export function resolveNoteTitle(`、两个入口必须声明 `name: 'titleFile'`——**不是**字符串出现,注释里提到不算),`missing`(不存在)与 `stale`(过旧)语义互斥;按钮预检与路由 503 分别说「版本过旧」和「缺少发布脚本」。bundle 升 `0.2.0`。守门:新增 `scripts/verify-conflict-config-guards.mjs`(23 条:伪 exec 纯逻辑 + **真起临时 git 仓库的冲突 E2E**(真 merge 冲突 / 真残留标记,且解决后必须能提交)+ ConfigStore 拒绝写/回归/接线断言;**已反向验证**:拆掉 `contentScan` 或配置拒绝各红 2 条),`verify-wechat-publish.mjs` 17→24 条(+注释里提及新符号必须判旧、真源码符号对齐),bundle 守门 +3 条。
409
410
  - **v0.23.3**(2026-09-18):**TW 笔记工具栏新增「发布到公众号」按钮**(可选功能,与既有 CLI 等价,只是不用敲命令)。链路:TW 按钮 → `POST /dsh-tiddlywiki/wechat/publish` → 宿主 spawn `opencli weixin publish-note` → `GET …/wechat/publish/status` 轮询 → 覆盖层显示进度与日志尾部。**只到草稿箱为止**,发表仍由人工扫码(沿用 v0.23.2 定的策略)。三处硬骨头:① **标题不进 argv**——Windows 上 `opencli` 是 `.cmd` shim,必须经 cmd.exe 启动,于是标题里的 `&`/`|`/`^`/引号成了命令注入面、中文标题还会被 cmd 的代码页解成乱码;改成宿主写 UTF-8 文件 + adapter 新增 **`--title-file`**(位置参数照旧可用,两者都给时位置参数优先)。② **任务化而非同步请求**——命令要跑几十秒到几分钟,`POST` 立即返回 `jobId`,单并发(第二次 409)、15 分钟上限,超时用 `taskkill /T` 杀**整棵进程树**(只杀 cmd.exe 会留下孤儿 node 抓着 job 目录,实测 `EBUSY`)。③ **可选功能默认不打扰**——`wechat.enabled` 关着时三条 `/wechat/*` 路由一律 403、按钮插件不写进 wiki(新增 gated 起步 seed `wechat-publish`,与 `publish-spec`/`wechat-setup` 同进同退)。新增 `src/host/wechat-publish.ts`(命令组装纯函数 + job registry + 就绪探测)、三条路由(方法/同源守卫 + 可选 `x-wechat-publish-token`;`wechat.token` 与其它密钥一样在 `/admin/state` 打码)、TW bundle `$:/plugins/dsh/wechat-publish`(`scripts/bundle/wechat-publish/` + build/gen/verify 流水线,`wechat-publish.bundle.json` 逐字节守门)。守门:`scripts/verify-wechat-publish.mjs`(17 条:纯函数 + **真跑 spawn 的「假 opencli」E2E**(含标题不进 argv 的逐字断言)+ 真实 HTTP 路由守卫;**已反向验证**:拆掉 `guardWechat` 即红)+ `scripts/verify-wechat-publish-bundle.mjs`(进 `verify:static`)+ `verify-wechat-adapters.mjs` 新增「两个 publish adapter 都必须走 `resolveNoteTitle`」。
410
411
  - **v0.23.2**(2026-09-17):**多图发布命令 `publish-note-imgs` + 发布策略定为「到草稿箱为止」**。TW 笔记内嵌 `[img[...]]` 经 `/render` 是 data URI,微信存草稿会过滤非 mmbiz 图——新命令先把正文全部图片经 DataTransfer 逐张上传 CDN(轮询计数到期望值),按 DOM 顺序重写正文 src 再 insertHTML,从正文第一张设封面;发布前同样读 `pub-state`/`no-publish`(只告警不阻断)。`selectCoverFromContent` 封面校验改轮询,修「实际成功却报失败」的假阴性(实测 `list_ex` 的 `cover` 已是 mmbiz 而流程报失败)。安装脚本 FILES 收编至 5 个 adapter,守门断言同步。**策略**:adapter 负责到草稿落盘为止,发表(含原创声明/作者/留言等确认弹窗 + 管理员扫码)由人工完成——`--publish` 保留但 best-effort,遇弹窗会等到超时。文档新增:发表确认弹窗说明、`stale page identity` 续作法、`list_ex`/`appmsgpublish` 核实命令、GitHub ext release 落后 Web Store 备注、npm allowScripts 拦 postinstall 无害备注、**eval 诊断后台必须过滤不可见 toast 残留**(踩过:把残留文案当实时状态误判账号被拦,实际账号正常)。
package/lib/index.js CHANGED
@@ -270,6 +270,7 @@ var GitFace = class {
270
270
  timeout: QUICK_TIMEOUT_MS
271
271
  });
272
272
  const beforeHead = before.ok ? before.stdout.trim() : "";
273
+ const preexistingRebase = await this.hasRebaseInProgress(dir);
273
274
  const r = await this.exec([
274
275
  "pull",
275
276
  "--rebase",
@@ -292,20 +293,37 @@ var GitFace = class {
292
293
  };
293
294
  }
294
295
  const conflictFiles = await this.unmergedFiles(dir);
295
- await this.exec(["rebase", "--abort"], {
296
+ const rebaseNow = await this.hasRebaseInProgress(dir);
297
+ const ourRebase = rebaseNow && !preexistingRebase;
298
+ if (ourRebase) await this.exec(["rebase", "--abort"], {
296
299
  cwd: dir,
297
300
  timeout: HEAVY_TIMEOUT_MS
298
301
  });
299
302
  const after = await this.conflictState(dir);
300
303
  const files = after.conflicted ? [.../* @__PURE__ */ new Set([...conflictFiles, ...after.files])] : conflictFiles;
301
304
  const note = after.conflicted ? `(rebase --abort 之后工作树仍有冲突:${after.reason})` : "";
305
+ const userRebaseNote = rebaseNow && !ourRebase ? "(检测到你自己的 rebase 正在进行中,已保留不动——请先手动完成或 abort 它再同步)" : "";
302
306
  const reason = (r.stderr.trim() || r.stdout.trim()).slice(0, 500);
303
307
  return {
304
308
  ok: false,
305
- message: files.length > 0 ? `conflict in ${files.join(", ")} (rebase aborted)${note}: ${reason}` : `pull failed${note}: ${reason}`,
309
+ message: files.length > 0 ? `conflict in ${files.join(", ")}${ourRebase ? " (rebase aborted)" : ""}${note}${userRebaseNote}: ${reason}` : `pull failed${note}${userRebaseNote}: ${reason}`,
306
310
  ...files.length > 0 ? { conflictFiles: files } : {}
307
311
  };
308
312
  }
313
+ /** Is a rebase in progress in `dir`? (`REBASE_HEAD` or the on-disk state dirs.) */
314
+ async hasRebaseInProgress(dir) {
315
+ const r = await this.exec([
316
+ "rev-parse",
317
+ "-q",
318
+ "--verify",
319
+ "REBASE_HEAD"
320
+ ], {
321
+ cwd: dir,
322
+ timeout: QUICK_TIMEOUT_MS
323
+ });
324
+ if (r.ok && r.stdout.trim().length > 0) return true;
325
+ return existsSync(join(dir, ".git", "rebase-merge")) || existsSync(join(dir, ".git", "rebase-apply"));
326
+ }
309
327
  async push(dir) {
310
328
  const r = await this.exec(["push"], {
311
329
  cwd: dir,
@@ -750,7 +768,7 @@ function ensureTiddlerTimestamps(tiddler, now = /* @__PURE__ */ new Date()) {
750
768
  const hasCreated = typeof tiddler.created === "string" && tiddler.created.trim().length > 0;
751
769
  const hasModified = typeof tiddler.modified === "string" && tiddler.modified.trim().length > 0;
752
770
  if (!hasCreated) tiddler.created = hasModified ? tiddler.modified : stamp;
753
- if (!hasModified) tiddler.modified = hasCreated ? tiddler.created : stamp;
771
+ if (!hasModified) tiddler.modified = stamp;
754
772
  }
755
773
  /** Split TW's whitespace-joined tags string into an array. */
756
774
  function normalizeTags(tags) {
@@ -1461,6 +1479,10 @@ var WikiServer = class {
1461
1479
  args.push(`readers=${this.options.username}`);
1462
1480
  args.push(`writers=${this.options.username}`);
1463
1481
  } else args.push(`anon-username=${ANON_USERNAME}`);
1482
+ if (this.stopping) {
1483
+ this.log("start aborted: stop() was requested while resolving the port");
1484
+ return this.status();
1485
+ }
1464
1486
  this.log(`spawn: ${process.execPath} ${args.map((a) => /^password=/.test(a) ? "password=***" : a).join(" ")}`);
1465
1487
  const child = spawn(process.execPath, args, {
1466
1488
  cwd: this.wikiPath,
@@ -2974,7 +2996,7 @@ function normalizePromptMode(value) {
2974
2996
  * (the interpolation scanner no longer sees a `{{` group).
2975
2997
  */
2976
2998
  function escapePromptBraces(text) {
2977
- return text.replace(/\{\{/g, "{​{");
2999
+ return text.replace(/\{(?=\{)/g, "{​");
2978
3000
  }
2979
3001
  /** Governance block: how to write without clobbering human edits. */
2980
3002
  const WRITE_RULES = `### 写入与并发
@@ -5057,16 +5079,34 @@ function buildWriteTiddler(title, text, options = {}) {
5057
5079
  /**
5058
5080
  * 乐观并发守卫:调用方可以传 `tiddlywiki_get` 读到的 `modified` 或 `revision`;
5059
5081
  * 与当前值不一致就拒绝写入,避免覆盖人类在 TW 编辑器里的并发修改。
5082
+ *
5083
+ * ⚠️ 两个令牌是 **AND**,不是 OR(v0.23.5 修复)。旧实现「`modified` 命中就放行、
5084
+ * 否则再看 `revision`」,有两个真实缺陷:
5085
+ * 1. `revision` 是 TW `wiki.js` 里的**内存**计数器(`changeCount`),**不持久化**,
5086
+ * 重启后每条回到 1。调用方读到 `revision: 1` → 人类改动 → 一次 pull/重启让
5087
+ * 计数复位 → 带 `expectedRevision: 1` 的写入被判「一致」而**静默覆盖**人类改动。
5088
+ * 2. 同时传两个令牌**并不会更安全**:`modified` 不匹配时会落到 `revision` 分支
5089
+ * 继续写,等于调用方以为有两重保护、实际只有一重。
5090
+ * 现在:**给了哪个令牌就必须匹配哪个**,任一不匹配即拒。只给一个令牌时语义不变
5091
+ * (`revision` 仍是「刚 PUT 还没落盘」条目的唯一可用令牌,只是不再当作强令牌)。
5060
5092
  */
5061
5093
  function assertNoConflict(title, existing, expected) {
5062
5094
  if (expected.force === true || existing === void 0) return;
5063
- if (!(expected.expectedModified !== void 0 || expected.expectedRevision !== void 0)) return;
5064
- const expectedMs = parseTiddlerDate(expected.expectedModified);
5065
- const currentMs = parseTiddlerDate(existing.modified);
5066
- if (expectedMs !== void 0 && currentMs !== void 0 && expectedMs === currentMs) return;
5095
+ const wantsModified = expected.expectedModified !== void 0;
5096
+ const wantsRevision = expected.expectedRevision !== void 0;
5097
+ if (!wantsModified && !wantsRevision) return;
5098
+ let modifiedOk = true;
5099
+ if (wantsModified) {
5100
+ const expectedMs = parseTiddlerDate(expected.expectedModified);
5101
+ const currentMs = parseTiddlerDate(existing.modified);
5102
+ modifiedOk = expectedMs !== void 0 && currentMs !== void 0 && expectedMs === currentMs;
5103
+ }
5067
5104
  const currentRevision = existing.revision;
5068
- if (expected.expectedRevision !== void 0 && currentRevision !== void 0 && String(currentRevision) === String(expected.expectedRevision)) return;
5069
- throw new WriteConflictError(`写入冲突:tiddler「${title}」在你读取之后已被改动(当前 revision=${currentRevision ?? "?"} modified=${toIsoDateString(existing.modified) ?? "?"};期望 revision=${expected.expectedRevision ?? "?"} modified=${expected.expectedModified ?? "?"})。请重新读取最新内容后重试;确认要用你的版本覆盖时可传 force: true。`);
5105
+ const revisionOk = wantsRevision ? currentRevision !== void 0 && String(currentRevision) === String(expected.expectedRevision) : true;
5106
+ if (modifiedOk && revisionOk) return;
5107
+ const failed = [!modifiedOk ? "modified" : null, !revisionOk ? "revision" : null].filter(Boolean).join("、");
5108
+ const revisionHint = !revisionOk && modifiedOk ? "(注意:revision 是 TW 的内存计数器,重启/拉取后会复位——它只能证明「读到过」,长时间跨度的写入请用 expectedModified)" : "";
5109
+ throw new WriteConflictError(`写入冲突:tiddler「${title}」在你读取之后已被改动(不匹配的令牌:${failed};当前 revision=${currentRevision ?? "?"} modified=${toIsoDateString(existing.modified) ?? "?"};期望 revision=${expected.expectedRevision ?? "?"} modified=${expected.expectedModified ?? "?"})。${revisionHint}请重新读取最新内容后重试;确认要用你的版本覆盖时可传 force: true。`);
5070
5110
  }
5071
5111
  //#endregion
5072
5112
  //#region src/sdk.ts
@@ -5915,8 +5955,10 @@ function redactLogLines(logs) {
5915
5955
  const MAX_TAGS_LIMIT = 500;
5916
5956
  /** Clamp a `limit` query param. `fallback` covers absent/unparsable values. */
5917
5957
  function readLimit(url, fallback, max = MAX_LIST_LIMIT) {
5918
- const raw = Number(url.searchParams.get("limit") ?? fallback);
5919
- return Number.isFinite(raw) ? Math.max(1, Math.min(Math.floor(raw), max)) : fallback;
5958
+ const raw = url.searchParams.get("limit");
5959
+ if (raw === null || raw.trim().length === 0) return fallback;
5960
+ const parsed = Number(raw);
5961
+ return Number.isFinite(parsed) ? Math.max(1, Math.min(Math.floor(parsed), max)) : fallback;
5920
5962
  }
5921
5963
  /** Optional `limit` query param: `undefined` when absent/unparsable (= no cap). */
5922
5964
  function readOptionalLimit(url, max) {
@@ -6088,17 +6130,40 @@ function registerRoutes(ctx, deps) {
6088
6130
  const BLOCKED_PROXY_TITLE_PREFIXES = ["$:/plugins/dsh-tiddlywiki/"];
6089
6131
  /** True when a caller-supplied tiddler title addresses the secret namespace. */
6090
6132
  const isBlockedProxyTitle = (title) => BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix));
6091
- /** True when a proxied pathname addresses a blocked (secret-bearing) tiddler. */
6133
+ /**
6134
+ * True when a proxied pathname addresses a blocked (secret-bearing) tiddler.
6135
+ *
6136
+ * Decodes the WHOLE path rather than looking for a literal `/tiddlers/` marker
6137
+ * (v0.23.5). TW core's `get-tiddler-html.js` route is a SINGLE segment
6138
+ * (`path = /^\/([^\/]+)$/`) decoded with `decodeURIComponentSafe`, so
6139
+ * `GET /tw/%24%3A%2Fplugins%2Fdsh-tiddlywiki%2Fconfig` reached the config
6140
+ * tiddler while the old marker check saw no `/tiddlers/` at all (verified
6141
+ * before the fix: 200, 715 bytes of config JSON on both `/tw` and `/api`).
6142
+ * Raw, once- and twice-decoded forms are all checked so double-encoding cannot
6143
+ * slip through either.
6144
+ */
6092
6145
  const isBlockedProxyPath = (pathname) => {
6093
- const at = pathname.indexOf("/tiddlers/");
6094
- if (at < 0) return false;
6095
- const raw = pathname.slice(at + 10);
6096
- let title = raw;
6097
- try {
6098
- title = decodeURIComponent(raw);
6099
- } catch {}
6100
- return isBlockedProxyTitle(title);
6146
+ let candidate = pathname;
6147
+ for (let i = 0; i < 3; i += 1) {
6148
+ if (BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => candidate.includes(prefix))) return true;
6149
+ let next = candidate;
6150
+ try {
6151
+ next = decodeURIComponent(candidate);
6152
+ } catch {}
6153
+ if (next === candidate) break;
6154
+ candidate = next;
6155
+ }
6156
+ return BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => candidate.includes(prefix));
6101
6157
  };
6158
+ /**
6159
+ * Does caller-supplied CONTENT reference the protected namespace? (v0.23.5)
6160
+ * TW's `/render` resolves `{{…}}` transclusions server-side, so the `text`
6161
+ * branch was a second way to print the config tiddler: verified before the fix,
6162
+ * `POST /render {"text":"{{$:/plugins/dsh-tiddlywiki/config}}"}` returned 200
6163
+ * with the config JSON inside `<pre><code>` — the fragment sanitizer only strips
6164
+ * tags, it cannot know the text is a secret.
6165
+ */
6166
+ const referencesBlockedTitle = (value) => value !== void 0 && BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => value.includes(prefix));
6102
6167
  const handleStatus = async (req, res) => {
6103
6168
  if (rejectNonRead(req, res)) return;
6104
6169
  const view = deps.server.status();
@@ -7006,7 +7071,15 @@ function registerRoutes(ctx, deps) {
7006
7071
  let body = {};
7007
7072
  try {
7008
7073
  body = JSON.parse(await readBody(req, MAX_PROXY_BODY_BYTES));
7009
- } catch {
7074
+ } catch (err) {
7075
+ const status = errorStatus(err);
7076
+ if (status !== 500) {
7077
+ json(res, {
7078
+ ok: false,
7079
+ error: err instanceof Error ? err.message : String(err)
7080
+ }, status);
7081
+ return;
7082
+ }
7010
7083
  json(res, {
7011
7084
  ok: false,
7012
7085
  error: "invalid JSON body"
@@ -7029,6 +7102,13 @@ function registerRoutes(ctx, deps) {
7029
7102
  }, 403);
7030
7103
  return;
7031
7104
  }
7105
+ if (referencesBlockedTitle(text) || referencesBlockedTitle(typeof body.contextTitle === "string" ? body.contextTitle : void 0)) {
7106
+ json(res, {
7107
+ ok: false,
7108
+ error: "system tiddler not exposed"
7109
+ }, 403);
7110
+ return;
7111
+ }
7032
7112
  const request = title.length > 0 ? { title } : {
7033
7113
  text,
7034
7114
  ...typeof body.type === "string" && body.type.length > 0 ? { type: body.type } : {},
@@ -9699,6 +9779,10 @@ function registerTiddlywikiTools(ctx, deps) {
9699
9779
  force: args.force
9700
9780
  });
9701
9781
  const tags = normalizeTagArg(args.tags);
9782
+ if (existing !== void 0 && isBinaryType(typeof existing.type === "string" ? existing.type : void 0)) {
9783
+ const wantedType = typeof args.fields?.type === "string" ? args.fields.type : void 0;
9784
+ if (!(args.force === true || wantedType !== void 0 && !isBinaryType(wantedType))) throw new Error(`tiddlywiki_put: 「${args.title}」是二进制附件(type=${String(existing.type)},正文为 base64),直接写文本会把附件写坏。要替换附件请用 tiddlywiki_attach;确实要转成文本条目,请显式传 fields: {"type":"text/markdown"}(或 force: true)。`);
9785
+ }
9702
9786
  const { tiddler, typeDefaulted } = buildWriteTiddler(args.title, args.text, {
9703
9787
  existing,
9704
9788
  tags,
@@ -9957,7 +10041,9 @@ function registerTiddlywikiTools(ctx, deps) {
9957
10041
  expectedRevision: args.expectedRevision,
9958
10042
  force: args.force
9959
10043
  });
9960
- if (!(existing !== void 0 && args.permanent !== true && !args.title.startsWith("$:/dsh-tiddlywiki/trash/"))) {
10044
+ const canTrash = existing !== void 0 && args.permanent !== true && !args.title.startsWith("$:/dsh-tiddlywiki/trash/") && !args.title.startsWith("$:/");
10045
+ if (args.title === "$:/dsh-tiddlywiki/trash-index" && existing !== void 0) throw new Error(`tiddlywiki_delete: 「${TRASH_INDEX_TITLE}」是回收站索引,删掉它会让回收站里的条目全部变成不可恢复的孤儿(列不出、恢复不了、也清不掉)。要清空回收站请用 tiddlywiki_trash action=empty;确实要丢弃索引请显式传 permanent: true。`);
10046
+ if (!canTrash) {
9961
10047
  await wiki.delete(args.title);
9962
10048
  deps.autoCommit();
9963
10049
  return {
@@ -9968,6 +10054,8 @@ function registerTiddlywikiTools(ctx, deps) {
9968
10054
  }
9969
10055
  const trashTitle = trashTitleFor(args.title);
9970
10056
  const at = (/* @__PURE__ */ new Date()).toISOString();
10057
+ const index = await readTrashIndex(wiki);
10058
+ if (!index.readOk || index.corrupted) throw new TrashIndexUnavailableError();
9971
10059
  const { tiddler: trashTiddler } = buildWriteTiddler(trashTitle, existing.text ?? "", { existing });
9972
10060
  trashTiddler.created = existing.created ?? trashTiddler.created;
9973
10061
  trashTiddler.modified = existing.modified ?? trashTiddler.modified;
@@ -9977,8 +10065,6 @@ function registerTiddlywikiTools(ctx, deps) {
9977
10065
  "trash-at": at
9978
10066
  });
9979
10067
  await wiki.delete(args.title);
9980
- const index = await readTrashIndex(wiki);
9981
- if (!index.readOk || index.corrupted) throw new TrashIndexUnavailableError();
9982
10068
  index.entries.push({
9983
10069
  trash: trashTitle,
9984
10070
  of: args.title,
@@ -10148,7 +10234,7 @@ function registerTiddlywikiTools(ctx, deps) {
10148
10234
  ok: true,
10149
10235
  title,
10150
10236
  mode,
10151
- heading: typeof args.heading === "string" && args.heading.trim().length > 0 ? args.heading.trim() : null,
10237
+ heading: mode === "append" && typeof args.heading === "string" && args.heading.trim().length > 0 ? args.heading.trim() : null,
10152
10238
  created: existing === void 0,
10153
10239
  added: addition.length,
10154
10240
  total: next.length,
@@ -10853,12 +10939,17 @@ async function switchWiki(deps, input) {
10853
10939
  });
10854
10940
  await deps.startServer();
10855
10941
  await deps.reloadConfig();
10856
- deps.setupExtras();
10857
10942
  deps.log?.(`wiki switch rolled back to ${previousPath}`);
10858
10943
  return true;
10859
10944
  } catch (err) {
10860
10945
  deps.log?.(`wiki switch rollback FAILED: ${err instanceof Error ? err.message : String(err)}`);
10861
10946
  return false;
10947
+ } finally {
10948
+ try {
10949
+ deps.setupExtras();
10950
+ } catch (err) {
10951
+ deps.log?.(`wiki switch rollback: re-arming extras failed: ${err instanceof Error ? err.message : String(err)}`);
10952
+ }
10862
10953
  }
10863
10954
  };
10864
10955
  try {
@@ -11021,6 +11112,9 @@ function watchWiki(wikiPath, onChange) {
11021
11112
  const watchers = [];
11022
11113
  for (const dir of [join(wikiPath, "tiddlers"), wikiPath]) try {
11023
11114
  const watcher = watch(dir, { persistent: false }, () => onChange());
11115
+ watcher.on("error", (err) => {
11116
+ console.warn("[dsh-tiddlywiki] wiki watcher error (auto-commit still runs on our own writes):", err.message);
11117
+ });
11024
11118
  watchers.push(watcher);
11025
11119
  } catch {}
11026
11120
  return () => {
@@ -11450,9 +11544,14 @@ function apply(ctx, rawConfig = {}) {
11450
11544
  error: "正在切换知识库,请稍候再试",
11451
11545
  rolledBack: true
11452
11546
  };
11547
+ if (disposed) return {
11548
+ ok: false,
11549
+ error: "插件正在卸载,已取消切换",
11550
+ rolledBack: false
11551
+ };
11453
11552
  switching = true;
11454
11553
  try {
11455
- return await switchWiki({
11554
+ const result = await switchWiki({
11456
11555
  currentLocation: () => server.currentLocation,
11457
11556
  currentPath: () => wikiPath,
11458
11557
  stopServer: () => server.stop(),
@@ -11483,6 +11582,8 @@ function apply(ctx, rawConfig = {}) {
11483
11582
  savePointer: persist,
11484
11583
  log: (message) => console.warn("[dsh-tiddlywiki]", message)
11485
11584
  }, target);
11585
+ if (disposed) teardownCommitter();
11586
+ return result;
11486
11587
  } finally {
11487
11588
  switching = false;
11488
11589
  }