dsh-tiddlywiki 0.19.2 → 0.19.4

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.
@@ -21,16 +21,16 @@
21
21
  */
22
22
  import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'
23
23
  import { createHash, timingSafeEqual } from 'node:crypto'
24
- import { mkdir, writeFile, access, stat } from 'node:fs/promises'
24
+ import { mkdir, writeFile, stat } from 'node:fs/promises'
25
25
  import { basename, extname, isAbsolute, join } from 'node:path'
26
26
  import { Readable } from 'node:stream'
27
27
  import type { TiddlyWebClient } from './tw-api.ts'
28
- import { TEXT_LIST_FILTER, isBinaryType, toIsoDateString } from './tw-api.ts'
28
+ import { isBinaryType, toIsoDateString } from './tw-api.ts'
29
29
  import type { WikiServer } from './wiki.ts'
30
30
  import type { GitFace } from './git.ts'
31
31
  import { PATH_PREFIX, TW_PROXY_PREFIX, TW_PROXY_PATH } from './wiki.ts'
32
32
  import { writeSessionSummary, type SessionQueryFace } from './session-summary.ts'
33
- import { readBody, readBodyBuffer, json, rejectCrossSiteWrite, rejectNonRead, MAX_PROXY_BODY_BYTES, MAX_UPLOAD_BYTES } from './http.ts'
33
+ import { readBody, readBodyBuffer, json, guardHandler, errorStatus, rejectCrossSiteWrite, rejectNonRead, MAX_PROXY_BODY_BYTES, MAX_UPLOAD_BYTES } from './http.ts'
34
34
  import { sanitizeTwFragment } from './sanitize.ts'
35
35
  import { flushPendingWrites } from './seeds.ts'
36
36
  import { WriteConflictError, assertNoConflict, buildWriteTiddler, flattenTiddlerFields } from './write-policy.ts'
@@ -247,6 +247,11 @@ function sanitizeUploadName(input: unknown): string {
247
247
  .replace(/^\.+/, '')
248
248
  .trim()
249
249
  if (name.length === 0 || name === '.' || name === '..') return ''
250
+ // Windows stores a trailing dot/space verbatim but never strips it, and
251
+ // `extname('evil.html.')` is just `.` — so the extension denylist was
252
+ // bypassable with `evil.html.` (v0.19.3). Canonicalize before the check.
253
+ name = name.replace(/[. ]+$/, '')
254
+ if (name.length === 0) return ''
250
255
  // `CON`, `NUL`, `COM1…` are not creatable on Windows — prefix instead of 500.
251
256
  if (WINDOWS_RESERVED_NAMES.test(name)) name = `_${name}`
252
257
  if (name.length > 160) name = name.slice(0, 160)
@@ -293,6 +298,28 @@ function snippetOf(text: string, max = 120): string {
293
298
  return flat.length <= max ? flat : `${flat.slice(0, max)}…`
294
299
  }
295
300
 
301
+ /** Max `limit` accepted by the list routes (bounded payloads, v0.19.4). */
302
+ const MAX_LIST_LIMIT = 200
303
+
304
+ /** Max `limit` accepted by `/tags` — the tag list is a small wrapper around one
305
+ * full listing, so a larger cap is fine while still bounding the payload. */
306
+ const MAX_TAGS_LIMIT = 500
307
+
308
+ /** Clamp a `limit` query param. `fallback` covers absent/unparsable values. */
309
+ function readLimit(url: URL, fallback: number, max = MAX_LIST_LIMIT): number {
310
+ const raw = Number(url.searchParams.get('limit') ?? fallback)
311
+ return Number.isFinite(raw) ? Math.max(1, Math.min(Math.floor(raw), max)) : fallback
312
+ }
313
+
314
+ /** Optional `limit` query param: `undefined` when absent/unparsable (= no cap). */
315
+ function readOptionalLimit(url: URL, max: number): number | undefined {
316
+ const raw = url.searchParams.get('limit')
317
+ if (raw === null || raw.trim().length === 0) return undefined
318
+ const parsed = Number(raw)
319
+ if (!Number.isFinite(parsed)) return undefined
320
+ return Math.max(1, Math.min(Math.floor(parsed), max))
321
+ }
322
+
296
323
  /** Default note title: `YYYY-MM-DD HH:mm` (design doc D6). */
297
324
  function timestampTitle(date = new Date()): string {
298
325
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
@@ -482,6 +509,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
482
509
  json(res, { ok: false, error: 'session is required' }, 400)
483
510
  return
484
511
  }
512
+ // The id becomes part of a tiddler title (`$:/temp/dsh/session-summary/<id>`)
513
+ // and is echoed into the summary wikitext — keep it to a safe charset
514
+ // instead of trusting the request body (v0.19.3).
515
+ if (!/^[A-Za-z0-9._:-]{1,120}$/.test(session)) {
516
+ json(res, { ok: false, error: 'session id has an unsupported format' }, 400)
517
+ return
518
+ }
485
519
  const client = deps.getClient()
486
520
  if (client === undefined) {
487
521
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
@@ -495,7 +529,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
495
529
  const result = await writeSessionSummary(client, sq, session)
496
530
  json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH })
497
531
  } catch (err) {
498
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
532
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
499
533
  }
500
534
  }
501
535
 
@@ -545,7 +579,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
545
579
  .sort((a, b) => b.updatedAt - a.updatedAt)
546
580
  json(res, { ok: true, items })
547
581
  } catch (err) {
548
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
582
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
549
583
  }
550
584
  }
551
585
 
@@ -622,7 +656,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
622
656
  permissions,
623
657
  })
624
658
  } catch (err) {
625
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
659
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
626
660
  }
627
661
  }
628
662
 
@@ -659,7 +693,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
659
693
  }
660
694
  json(res, { ok: true, requestId, sessionId })
661
695
  } catch (err) {
662
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
696
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
663
697
  }
664
698
  }
665
699
 
@@ -709,6 +743,26 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
709
743
  json(res, { ok: false, error: 'session service unavailable' }, 503)
710
744
  return
711
745
  }
746
+ // Validate the agent preset (工作模式) BEFORE any side effect (v0.19.3):
747
+ // the old code created the cwd directory + workspace first, so an unknown
748
+ // mode left an orphaned directory behind a 500 from `sc.create`.
749
+ if (mode !== undefined) {
750
+ const ap = deps.getAgentPresets()
751
+ if (ap === undefined) {
752
+ json(res, { ok: false, error: 'agent presets service unavailable' }, 503)
753
+ return
754
+ }
755
+ try {
756
+ const presets = await ap.list()
757
+ if (!presets.some((preset) => preset.id === mode)) {
758
+ json(res, { ok: false, error: `unknown agent preset "${mode}" (available: ${presets.map((preset) => preset.id).join(', ')})` }, 400)
759
+ return
760
+ }
761
+ } catch (err) {
762
+ json(res, { ok: false, error: `cannot validate agent preset: ${err instanceof Error ? err.message : String(err)}` }, 503)
763
+ return
764
+ }
765
+ }
712
766
  const ws = deps.getWorkspaceRegistry()
713
767
  if (cwd.length > 0) {
714
768
  // Only absolute paths, and never clobber an existing non-directory:
@@ -763,7 +817,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
763
817
  permissionApplied,
764
818
  })
765
819
  } catch (err) {
766
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
820
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
767
821
  }
768
822
  }
769
823
 
@@ -812,7 +866,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
812
866
  json(res, { ok: false, error: err.message, conflict: true }, 409)
813
867
  return
814
868
  }
815
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
869
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
816
870
  }
817
871
  }
818
872
 
@@ -843,14 +897,19 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
843
897
  json(res, { ok: false, error: err.message, conflict: true }, 409)
844
898
  return
845
899
  }
846
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
900
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
847
901
  }
848
902
  }
849
903
 
850
904
  /** Distinct non-system tags for the quick-note tag autocomplete. The flat
851
905
  * `tags` array feeds note-widget's chip autocomplete; the parallel `items`
852
906
  * (tag → tiddler count) feeds the reply-stream `tiddlywiki_list_tags` tool
853
- * card, so both consumers share one endpoint. */
907
+ * card, so both consumers share one endpoint.
908
+ *
909
+ * Query params (v0.19.4): `limit` caps the payload (absent = every tag, so
910
+ * the autocomplete keeps its full vocabulary), `sort=count` orders by usage
911
+ * (default `alpha`). `tags`/`items` are always the same set in the same
912
+ * order, plus `total`/`truncated` so a capped caller can say "N of M". */
854
913
  const handleTags = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
855
914
  if (rejectNonRead(req, res)) return
856
915
  const client = deps.getClient()
@@ -859,23 +918,23 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
859
918
  return
860
919
  }
861
920
  try {
862
- // Text-bearing tiddlers only (server-side filter): tags that exist solely
863
- // on binary attachments must not show up in the autocomplete / tool card.
864
- const items = await client.list(TEXT_LIST_FILTER, false)
865
- const counts = new Map<string, number>()
866
- for (const item of items) {
867
- for (const tag of item.tags ?? []) {
868
- if (tag.length > 0 && !tag.startsWith('$:/')) counts.set(tag, (counts.get(tag) ?? 0) + 1)
869
- }
870
- }
871
- const tags = [...counts.keys()].sort((a, b) => a.localeCompare(b, 'zh'))
921
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
922
+ const limit = readOptionalLimit(url, MAX_TAGS_LIMIT)
923
+ const byCount = (url.searchParams.get('sort') ?? 'alpha') === 'count'
924
+ // Text-bearing tiddlers only (server-side filter) — the same counting the
925
+ // `tiddlywiki_list_tags` tool uses, so the card and the model agree.
926
+ const { total, tags: all } = await client.tagStats()
927
+ const ordered = byCount ? all : [...all].sort((a, b) => a.tag.localeCompare(b.tag, 'zh'))
928
+ const picked = limit === undefined ? ordered : ordered.slice(0, limit)
872
929
  json(res, {
873
930
  ok: true,
874
- tags,
875
- items: tags.map((tag) => ({ tag, count: counts.get(tag) ?? 0 })),
931
+ tags: picked.map((t) => t.tag),
932
+ items: picked,
933
+ total,
934
+ truncated: picked.length < total,
876
935
  })
877
936
  } catch (err) {
878
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
937
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
879
938
  }
880
939
  }
881
940
 
@@ -889,8 +948,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
889
948
  }
890
949
  try {
891
950
  const url = new URL(req.url ?? '/', 'http://127.0.0.1')
892
- const limitRaw = Number(url.searchParams.get('limit') ?? 15)
893
- const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(Math.floor(limitRaw), 200)) : 15
951
+ const limit = readLimit(url, 15)
894
952
  const items = await client.recent(limit)
895
953
  json(res, {
896
954
  ok: true,
@@ -903,7 +961,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
903
961
  })),
904
962
  })
905
963
  } catch (err) {
906
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
964
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
907
965
  }
908
966
  }
909
967
 
@@ -955,7 +1013,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
955
1013
  fields: flattenTiddlerFields(t),
956
1014
  })
957
1015
  } catch (err) {
958
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
1016
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
959
1017
  }
960
1018
  }
961
1019
 
@@ -979,8 +1037,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
979
1037
  const tag = url.searchParams.get('tag') ?? undefined
980
1038
  const since = url.searchParams.get('since') ?? undefined
981
1039
  const type = url.searchParams.get('type') ?? undefined
982
- const limitRaw = Number(url.searchParams.get('limit') ?? 30)
983
- const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(Math.floor(limitRaw), 200)) : 30
1040
+ const limit = readLimit(url, 30)
984
1041
  const { items, total } = await client.search(query, { tags, tag, since, type, limit })
985
1042
  json(res, {
986
1043
  ok: true,
@@ -999,7 +1056,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
999
1056
  })),
1000
1057
  })
1001
1058
  } catch (err) {
1002
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
1059
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
1003
1060
  }
1004
1061
  }
1005
1062
 
@@ -1017,7 +1074,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1017
1074
  }
1018
1075
  json(res, { ok: true, status: deps.server.status().status })
1019
1076
  } catch (err) {
1020
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
1077
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
1021
1078
  }
1022
1079
  }
1023
1080
 
@@ -1087,7 +1144,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1087
1144
  lastSync: new Date().toISOString(),
1088
1145
  }, pushed.ok ? 200 : 502)
1089
1146
  } catch (err) {
1090
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
1147
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
1091
1148
  } finally {
1092
1149
  endMutation()
1093
1150
  }
@@ -1126,16 +1183,31 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1126
1183
  }
1127
1184
  const filesDir = join(deps.getWikiPath(), 'files')
1128
1185
  await mkdir(filesDir, { recursive: true })
1186
+ // Collision avoidance with an ATOMIC create (v0.19.3): the old code did
1187
+ // `access(candidate)` then `writeFile(candidate)`, so (a) any access error
1188
+ // (EACCES/EBUSY, not just ENOENT) was read as "name free" and (b) two
1189
+ // concurrent uploads could both pick the same name and one silently
1190
+ // replaced the other — contradicting the route's own "nothing is ever
1191
+ // overwritten" contract. `flag: 'wx'` fails with EEXIST instead, and we
1192
+ // walk the suffix chain on that error only. Bounded, so a pathological
1193
+ // directory cannot spin this loop forever.
1129
1194
  const ext = extname(name)
1130
1195
  const stem = ext.length > 0 ? name.slice(0, -ext.length) : name
1131
- // Collision avoidance: files/some.txt, files/some-1.txt, … (bounded, so a
1132
- // pathological directory cannot spin this loop forever).
1133
- let candidate = name
1134
- for (let i = 1; i <= 10_000; i++) {
1135
- try { await access(join(filesDir, candidate)) } catch { break }
1136
- candidate = `${stem}-${i}${ext}`
1196
+ let candidate = ''
1197
+ let wrote = false
1198
+ for (let i = 0; i <= 10_000 && !wrote; i++) {
1199
+ candidate = i === 0 ? name : `${stem}-${i}${ext}`
1200
+ try {
1201
+ await writeFile(join(filesDir, candidate), buf, { flag: 'wx' })
1202
+ wrote = true
1203
+ } catch (err) {
1204
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
1205
+ }
1206
+ }
1207
+ if (!wrote) {
1208
+ json(res, { ok: false, error: '同名文件过多,请换一个文件名' }, 409)
1209
+ return
1137
1210
  }
1138
- await writeFile(join(filesDir, candidate), buf)
1139
1211
  deps.autoCommit()
1140
1212
  invalidateGitStatus()
1141
1213
  json(res, {
@@ -1216,6 +1288,32 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1216
1288
  }
1217
1289
  }
1218
1290
 
1291
+ /**
1292
+ * Titles the browser-facing TW proxies must never serve (v0.19.3).
1293
+ *
1294
+ * `$:/plugins/dsh-tiddlywiki/config` holds the shared tokens and the git
1295
+ * remote (possibly with a PAT); both `/tw` and `/api` forward ANY path to the
1296
+ * loopback TW child, whose TiddlyWeb REST answers for `$:/…` titles — a
1297
+ * route-level guard on `/get` was therefore trivially bypassed by
1298
+ * `GET /dsh-tiddlywiki/tw/recipes/default/tiddlers/%24%3A%2Fplugins%2F…`
1299
+ * (verified). The whole plugin namespace is blocked: nothing under it is
1300
+ * needed by the TW frontend, and the host itself talks to TW directly.
1301
+ */
1302
+ const BLOCKED_PROXY_TITLE_PREFIXES = ['$:/plugins/dsh-tiddlywiki/']
1303
+
1304
+ /** True when a proxied pathname addresses a blocked (secret-bearing) tiddler. */
1305
+ const isBlockedProxyPath = (pathname: string): boolean => {
1306
+ const marker = '/tiddlers/'
1307
+ const at = pathname.indexOf(marker)
1308
+ if (at < 0) return false
1309
+ const raw = pathname.slice(at + marker.length)
1310
+ let title = raw
1311
+ try {
1312
+ title = decodeURIComponent(raw)
1313
+ } catch { /* malformed encoding: check the raw form */ }
1314
+ return BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix))
1315
+ }
1316
+
1219
1317
  /** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
1220
1318
  const handleApiProxy = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
1221
1319
  if (rejectCrossSiteWrite(req, res)) return
@@ -1224,9 +1322,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1224
1322
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
1225
1323
  return
1226
1324
  }
1227
- const url = new URL(req.url ?? '/', 'http://127.0.0.1')
1228
- const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, '') || '/'
1229
1325
  try {
1326
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
1327
+ const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, '') || '/'
1328
+ if (isBlockedProxyPath(rest)) {
1329
+ json(res, { ok: false, error: 'system tiddler not exposed' }, 403)
1330
+ return
1331
+ }
1230
1332
  // Share the /tw proxy's header forwarding so `authorization`/`cookie`
1231
1333
  // reach the TW child: in locked-down mode (auth.username configured) the
1232
1334
  // /api passthrough used to 401 on every call because it dropped them.
@@ -1266,12 +1368,22 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1266
1368
  }
1267
1369
  const url = new URL(req.url ?? '/', 'http://127.0.0.1')
1268
1370
  const rest = url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), '') || '/'
1371
+ if (isBlockedProxyPath(rest)) {
1372
+ json(res, { ok: false, error: 'system tiddler not exposed' }, 403)
1373
+ return
1374
+ }
1269
1375
  try {
1270
1376
  const method = (req.method ?? 'GET').toUpperCase()
1271
1377
  const headers = forwardHeaders(req.headers)
1272
1378
  // TW's CSRF gate requires X-Requested-With on writes; forward it through.
1273
1379
  if (method === 'PUT' || method === 'DELETE' || method === 'POST') headers['x-requested-with'] = 'TiddlyWiki'
1274
- const init: RequestInit = { method, headers, signal: AbortSignal.timeout(30_000) }
1380
+ // Abort the upstream fetch when the CLIENT goes away: without this the TW
1381
+ // child kept streaming a large attachment into the DSH process until the
1382
+ // 30s timeout fired, long after the browser had cancelled (v0.19.3).
1383
+ const abort = new AbortController()
1384
+ const timeout = AbortSignal.timeout(30_000)
1385
+ const signal = typeof AbortSignal.any === 'function' ? AbortSignal.any([abort.signal, timeout]) : timeout
1386
+ const init: RequestInit = { method, headers, signal }
1275
1387
  if (method === 'PUT' || method === 'POST') init.body = await readBodyBuffer(req, MAX_UPLOAD_BYTES)
1276
1388
  const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init)
1277
1389
  const responseHeaders: Record<string, string> = {
@@ -1300,7 +1412,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1300
1412
  await new Promise<void>((resolveP, rejectP) => {
1301
1413
  body.on('error', rejectP)
1302
1414
  res.on('error', rejectP)
1303
- res.on('close', () => resolveP())
1415
+ res.on('close', () => {
1416
+ // Client hung up (aborted download / closed tab): stop pulling from the
1417
+ // TW child instead of draining it until the timeout.
1418
+ try { abort.abort() } catch { /* already aborted */ }
1419
+ try { body.destroy() } catch { /* already closed */ }
1420
+ resolveP()
1421
+ })
1304
1422
  res.on('finish', () => resolveP())
1305
1423
  body.pipe(res)
1306
1424
  })
@@ -1313,25 +1431,29 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1313
1431
  }
1314
1432
  }
1315
1433
 
1434
+ // Every handler goes through guardHandler (v0.19.3): a rejection — including a
1435
+ // synchronous throw before the handler's own try, e.g. `new URL(req.url)` —
1436
+ // becomes a 413/500 JSON response instead of an unhandled rejection that
1437
+ // would exit the dsh web process with the request hanging.
1316
1438
  const disposers = [
1317
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/status`, handler: (req, res) => { void handleStatus(req, res) } }),
1318
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: (req, res) => { void handleNote(req, res) } }),
1319
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/edit`, handler: (req, res) => { void handleEdit(req, res) } }),
1320
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/tags`, handler: (req, res) => { void handleTags(req, res) } }),
1321
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/recent`, handler: (req, res) => { void handleRecent(req, res) } }),
1322
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/get`, handler: (req, res) => { void handleGet(req, res) } }),
1323
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/search`, handler: (req, res) => { void handleSearch(req, res) } }),
1324
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/render`, handler: (req, res) => { void handleRender(req, res) } }),
1325
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/sync`, handler: (req, res) => { void handleSync(req, res) } }),
1326
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: (req, res) => { void handleUpload(req, res) } }),
1327
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: (req, res) => { void handleRestart(req, res) } }),
1328
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/session/summary`, handler: (req, res) => { void handleSessionSummary(req, res) } }),
1329
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/sessions`, handler: (req, res) => { void handleAgentSessions(req, res) } }),
1330
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/modes`, handler: (req, res) => { void handleAgentModes(req, res) } }),
1331
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/send`, handler: (req, res) => { void handleAgentSend(req, res) } }),
1332
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/create`, handler: (req, res) => { void handleAgentCreate(req, res) } }),
1333
- ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: (req, res) => { void handleApiProxy(req, res) } }),
1334
- ctx.webServer.register({ kind: 'prefix', path: `${TW_PROXY_PREFIX}`, handler: (req, res) => { void handleTwProxy(req, res) } }),
1439
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/status`, handler: guardHandler(handleStatus) }),
1440
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: guardHandler(handleNote) }),
1441
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/edit`, handler: guardHandler(handleEdit) }),
1442
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/tags`, handler: guardHandler(handleTags) }),
1443
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/recent`, handler: guardHandler(handleRecent) }),
1444
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/get`, handler: guardHandler(handleGet) }),
1445
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/search`, handler: guardHandler(handleSearch) }),
1446
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/render`, handler: guardHandler(handleRender) }),
1447
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/sync`, handler: guardHandler(handleSync) }),
1448
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: guardHandler(handleUpload) }),
1449
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: guardHandler(handleRestart) }),
1450
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/session/summary`, handler: guardHandler(handleSessionSummary) }),
1451
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/sessions`, handler: guardHandler(handleAgentSessions) }),
1452
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/modes`, handler: guardHandler(handleAgentModes) }),
1453
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/send`, handler: guardHandler(handleAgentSend) }),
1454
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/create`, handler: guardHandler(handleAgentCreate) }),
1455
+ ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: guardHandler(handleApiProxy) }),
1456
+ ctx.webServer.register({ kind: 'prefix', path: `${TW_PROXY_PREFIX}`, handler: guardHandler(handleTwProxy) }),
1335
1457
  ]
1336
1458
  return () => {
1337
1459
  for (const dispose of disposers) dispose()
@@ -204,7 +204,7 @@ function scanSnapshot(snap: SessionLogSnapshot | undefined, subagent: boolean, c
204
204
  title: args.newTitle,
205
205
  action: 'produced',
206
206
  time: t,
207
- detail: old !== undefined ? `重命名自「${old}」` : undefined,
207
+ detail: old !== undefined && isPlausibleTitle(old) ? `重命名自「${old}」` : undefined,
208
208
  subagent,
209
209
  })
210
210
  }
@@ -243,12 +243,29 @@ function scanSnapshot(snap: SessionLogSnapshot | undefined, subagent: boolean, c
243
243
  }
244
244
  }
245
245
 
246
+ /**
247
+ * 内联文本净化(v0.19.3):汇总 wikitext 会被 TW 渲染成 HTML 片段再注入 DSH 页面,
248
+ * 而 TW 的解析器**原样透传 HTML**。凡是来自会话日志/请求体的字符串(sessionId、
249
+ * rename 的旧标题、检索词)都要先转义 HTML 元字符并去掉控制字符,别指望上层净化。
250
+ */
251
+ export function escapeInline(text: string, max = 200): string {
252
+ return text
253
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
254
+ .replace(/&/g, '&amp;')
255
+ .replace(/</g, '&lt;')
256
+ .replace(/>/g, '&gt;')
257
+ .replace(/[[\]|]/g, ' ')
258
+ .slice(0, max)
259
+ }
260
+
246
261
  /** 当前状态(存在?标签?修改时间?摘要)。 */
247
262
  interface TitleState {
248
263
  exists: boolean
249
264
  tags: string[]
250
265
  modified?: string
251
266
  snippet?: string
267
+ /** 查询失败(非 404):状态未知,不能报「已删除/不存在」。 */
268
+ unknown?: boolean
252
269
  }
253
270
 
254
271
  /** 并发(8 路)查询每篇笔记的当前状态(404 → 不存在/已删除)。 */
@@ -266,7 +283,10 @@ async function enrichTitles(client: TiddlyWebClient, titles: string[]): Promise<
266
283
  snippet: flat.length > 48 ? `${flat.slice(0, 48)}…` : flat,
267
284
  }]
268
285
  } catch {
269
- return [title, { exists: false, tags: [] }]
286
+ // 只有 404 才算「不存在」(tw-api 的 get 只在 404 返回 undefined):
287
+ // 超时/5xx/服务重启中的查询失败必须报「状态未知」,否则一篇还在的笔记
288
+ // 会被标成「已删除」——正是项目里反复强调的「读取失败 ≠ 条目不存在」。
289
+ return [title, { exists: false, tags: [], unknown: true }]
270
290
  }
271
291
  }
272
292
  // Bounded batches: parallel within a batch, insertion order preserved across
@@ -305,7 +325,7 @@ function buildWikitext(
305
325
  lines.push('! 会话相关 wiki 汇总')
306
326
  lines.push('')
307
327
  lines.push(
308
- `本页列出会话 \`${sessionId}\`(含其后代子代理)在本会话中产生 / 读取 / 检索过的知识库笔记。`,
328
+ `本页列出会话 \`${escapeInline(sessionId, 120)}\`(含其后代子代理)在本会话中产生 / 读取 / 检索过的知识库笔记。`,
309
329
  )
310
330
  lines.push('')
311
331
  const producedCount = producedTitles.length
@@ -332,16 +352,18 @@ function buildWikitext(
332
352
 
333
353
  const entryLine = (title: string, state: TitleState | undefined, entry: NoteEntry): string => {
334
354
  const bits: string[] = [`[[${title}]]`]
335
- if (state === undefined || !state.exists) {
355
+ if (state !== undefined && state.unknown === true) {
356
+ bits.push('⚠️ 状态未知(查询失败)')
357
+ } else if (state === undefined || !state.exists) {
336
358
  bits.push('⚠️ 已删除/不存在')
337
359
  } else {
338
- if (state.tags.length > 0) bits.push(`标签 ${state.tags.slice(0, 6).join('、')}${state.tags.length > 6 ? '…' : ''}`)
360
+ if (state.tags.length > 0) bits.push(`标签 ${state.tags.slice(0, 6).map((tag) => escapeInline(tag, 40)).join('、')}${state.tags.length > 6 ? '…' : ''}`)
339
361
  const mod = fmtModified(state.modified)
340
362
  if (mod.length > 0) bits.push(`修改 ${mod}`)
341
363
  }
342
364
  const t = fmtTime(entry.time)
343
365
  if (t.length > 0) bits.push(`会话内 ${t}`)
344
- if (entry.detail !== undefined) bits.push(entry.detail)
366
+ if (entry.detail !== undefined) bits.push(escapeInline(entry.detail, 200))
345
367
  if (entry.subagent) bits.push('(子代理)')
346
368
  return `* ${bits.join(' · ')}`
347
369
  }
@@ -367,8 +389,8 @@ function buildWikitext(
367
389
  for (const r of collected.searches) {
368
390
  const bits: string[] = []
369
391
  if (r.kind === 'search') {
370
- bits.push(`\`tiddlywiki_search\` query=「${r.query.length > 0 ? r.query : '(空)'}」`)
371
- if (r.tags.length > 0) bits.push(`tags ${r.tags.join('、')}`)
392
+ bits.push(`\`tiddlywiki_search\` query=「${r.query.length > 0 ? escapeInline(r.query, 120) : '(空)'}」`)
393
+ if (r.tags.length > 0) bits.push(`tags ${r.tags.map((tag) => escapeInline(tag, 40)).join('、')}`)
372
394
  } else {
373
395
  bits.push('`tiddlywiki_recent`(最近修改)')
374
396
  }
package/src/host/tools.ts CHANGED
@@ -345,21 +345,32 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
345
345
  // ── tiddlywiki_list_tags ─────────────────────────────────────────────────
346
346
  register(defineTool({
347
347
  name: 'tiddlywiki_list_tags',
348
- description: '列出 TiddlyWiki 知识库现有的非系统标签及各自计数(按使用次数降序),方便决定给笔记打什么 tag。',
349
- parameters: {},
348
+ description: '列出 TiddlyWiki 知识库现有的非系统标签及各自计数(按使用次数降序),方便决定给笔记打什么 tag。默认最多返回 200 个(`limit` 可调,上限 1000),被截断时结果里会带 total/truncated。',
349
+ parameters: {
350
+ limit: { type: 'integer', description: '可选:最多返回多少个标签(按使用次数降序),默认 200,上限 1000。' },
351
+ },
350
352
  output: {
351
353
  schema: { type: 'json' },
352
354
  render: (_args, value: TagListResult) => {
353
355
  if (value.tags.length === 0) return [{ type: 'text', text: '知识库暂无标签。' }]
354
- const lines = [`现有标签(${value.tags.length} 个,按使用次数降序):`]
356
+ const lines = [
357
+ value.truncated
358
+ ? `现有标签(共 ${value.total} 个,仅列出使用最多的 ${value.tags.length} 个,按使用次数降序):`
359
+ : `现有标签(${value.total} 个,按使用次数降序):`,
360
+ ]
355
361
  for (const t of value.tags) lines.push(`- ${t.tag} × ${t.count}`)
362
+ if (value.truncated) lines.push(`(其余 ${value.total - value.tags.length} 个较少使用的标签未列出;需要时可提高 limit 重试)`)
356
363
  return [{ type: 'text', text: lines.join('\n') }]
357
364
  },
358
365
  },
359
- execute: async (): Promise<TagListResult> => {
366
+ execute: async (args: { limit?: number }): Promise<TagListResult> => {
360
367
  const wiki = requireWiki()
361
- const tags = await wiki.listTags()
362
- return { count: tags.length, tags }
368
+ const stats = await wiki.tagStats()
369
+ const limit = typeof args.limit === 'number' && Number.isFinite(args.limit)
370
+ ? Math.max(1, Math.min(Math.floor(args.limit), 1000))
371
+ : 200
372
+ const tags = stats.tags.slice(0, limit)
373
+ return { count: tags.length, total: stats.total, truncated: tags.length < stats.total, tags }
363
374
  },
364
375
  }))
365
376
 
@@ -1100,7 +1111,7 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
1100
1111
  interface SearchHit { title: string; tags: string[]; modified: string | null; snippet: string }
1101
1112
  interface SearchResult { query: string; tags: string[]; since: string | null; type: string | null; field: string | null; value: string | null; total: number; results: SearchHit[] }
1102
1113
  interface RecentResult { since: string | null; results: SearchHit[] }
1103
- interface TagListResult { count: number; tags: Array<{ tag: string; count: number }> }
1114
+ interface TagListResult { count: number; total: number; truncated: boolean; tags: Array<{ tag: string; count: number }> }
1104
1115
  interface GetResult {
1105
1116
  notFound: boolean
1106
1117
  title: string
@@ -456,8 +456,19 @@ export class TiddlyWebClient {
456
456
  /**
457
457
  * Distinct non-system tags with their tiddler counts, most-used first then
458
458
  * zh-locale. One skinny listing request (no text payloads).
459
+ *
460
+ * `limit` (v0.19.4) caps the returned array after sorting; the caller loses
461
+ * the tail count unless it also asks `tagStats()`.
459
462
  */
460
- async listTags(): Promise<Array<{ tag: string; count: number }>> {
463
+ async listTags(limit?: number): Promise<Array<{ tag: string; count: number }>> {
464
+ const tags = (await this.tagStats()).tags
465
+ if (limit === undefined) return tags
466
+ return tags.slice(0, Math.max(1, Math.min(Math.floor(limit), 1000)))
467
+ }
468
+
469
+ /** Full tag vocabulary + its size, so callers that cap can still report the
470
+ * total ("showing 200 of N") — one listing request either way. */
471
+ async tagStats(): Promise<{ total: number; tags: Array<{ tag: string; count: number }> }> {
461
472
  // TEXT_LIST_FILTER (server-side, whitelist self-healed) so a tag that only
462
473
  // hangs off binary attachments is not counted as a knowledge-base tag
463
474
  // (v0.19.0 — the unfiltered skinny listing included image/book-page tags).
@@ -470,9 +481,10 @@ export class TiddlyWebClient {
470
481
  map.set(tag, (map.get(tag) ?? 0) + 1)
471
482
  }
472
483
  }
473
- return [...map.entries()]
484
+ const tags = [...map.entries()]
474
485
  .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'zh'))
475
486
  .map(([tag, count]) => ({ tag, count }))
487
+ return { total: tags.length, tags }
476
488
  }
477
489
  }
478
490
 
package/src/index.ts CHANGED
@@ -57,7 +57,8 @@ export {
57
57
  export { openInTwEditor, registerRoutes } from './host/routes.ts'
58
58
  export { writeSessionSummary, SESSION_SUMMARY_PREFIX } from './host/routes.ts'
59
59
  export type { SessionQueryFace, SessionSummaryResult } from './host/routes.ts'
60
- export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, ensurePlugin, bundledCatalog, ensureLanguage, normalizeThemes } from './host/admin.ts'
60
+ export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, ensurePlugin, bundledCatalog, ensureLanguage, normalizeThemes, MASKED_SECRET, maskConfigSecrets, stripMaskedSecrets } from './host/admin.ts'
61
+ export { escapeInline } from './host/session-summary.ts'
61
62
  export { seedDocNote, DOC_NOTE_TITLE, DOC_NOTE_TAG, DOC_NOTE_TEXT } from './host/seed-notes.ts'
62
63
  export { seedStarterDocs, STARTER_DOCS_ITEMS, STARTER_DOCS_MARKER_TITLE, DSH_DOCS_TAG } from './host/seed-starter-docs.ts'
63
64
  export { seedSendToAgent, SEND_TO_AGENT_PLUGIN_TITLE, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_BUNDLE_TEXT } from './host/seed-send-to-agent.ts'