dsh-tiddlywiki 0.19.2 → 0.19.3

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,7 +21,7 @@
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'
@@ -30,7 +30,7 @@ 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)
@@ -482,6 +487,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
482
487
  json(res, { ok: false, error: 'session is required' }, 400)
483
488
  return
484
489
  }
490
+ // The id becomes part of a tiddler title (`$:/temp/dsh/session-summary/<id>`)
491
+ // and is echoed into the summary wikitext — keep it to a safe charset
492
+ // instead of trusting the request body (v0.19.3).
493
+ if (!/^[A-Za-z0-9._:-]{1,120}$/.test(session)) {
494
+ json(res, { ok: false, error: 'session id has an unsupported format' }, 400)
495
+ return
496
+ }
485
497
  const client = deps.getClient()
486
498
  if (client === undefined) {
487
499
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
@@ -495,7 +507,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
495
507
  const result = await writeSessionSummary(client, sq, session)
496
508
  json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH })
497
509
  } catch (err) {
498
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
510
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
499
511
  }
500
512
  }
501
513
 
@@ -545,7 +557,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
545
557
  .sort((a, b) => b.updatedAt - a.updatedAt)
546
558
  json(res, { ok: true, items })
547
559
  } catch (err) {
548
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
560
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
549
561
  }
550
562
  }
551
563
 
@@ -622,7 +634,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
622
634
  permissions,
623
635
  })
624
636
  } catch (err) {
625
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
637
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
626
638
  }
627
639
  }
628
640
 
@@ -659,7 +671,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
659
671
  }
660
672
  json(res, { ok: true, requestId, sessionId })
661
673
  } catch (err) {
662
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
674
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
663
675
  }
664
676
  }
665
677
 
@@ -709,6 +721,26 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
709
721
  json(res, { ok: false, error: 'session service unavailable' }, 503)
710
722
  return
711
723
  }
724
+ // Validate the agent preset (工作模式) BEFORE any side effect (v0.19.3):
725
+ // the old code created the cwd directory + workspace first, so an unknown
726
+ // mode left an orphaned directory behind a 500 from `sc.create`.
727
+ if (mode !== undefined) {
728
+ const ap = deps.getAgentPresets()
729
+ if (ap === undefined) {
730
+ json(res, { ok: false, error: 'agent presets service unavailable' }, 503)
731
+ return
732
+ }
733
+ try {
734
+ const presets = await ap.list()
735
+ if (!presets.some((preset) => preset.id === mode)) {
736
+ json(res, { ok: false, error: `unknown agent preset "${mode}" (available: ${presets.map((preset) => preset.id).join(', ')})` }, 400)
737
+ return
738
+ }
739
+ } catch (err) {
740
+ json(res, { ok: false, error: `cannot validate agent preset: ${err instanceof Error ? err.message : String(err)}` }, 503)
741
+ return
742
+ }
743
+ }
712
744
  const ws = deps.getWorkspaceRegistry()
713
745
  if (cwd.length > 0) {
714
746
  // Only absolute paths, and never clobber an existing non-directory:
@@ -763,7 +795,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
763
795
  permissionApplied,
764
796
  })
765
797
  } catch (err) {
766
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
798
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
767
799
  }
768
800
  }
769
801
 
@@ -812,7 +844,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
812
844
  json(res, { ok: false, error: err.message, conflict: true }, 409)
813
845
  return
814
846
  }
815
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
847
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
816
848
  }
817
849
  }
818
850
 
@@ -843,7 +875,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
843
875
  json(res, { ok: false, error: err.message, conflict: true }, 409)
844
876
  return
845
877
  }
846
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
878
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
847
879
  }
848
880
  }
849
881
 
@@ -875,7 +907,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
875
907
  items: tags.map((tag) => ({ tag, count: counts.get(tag) ?? 0 })),
876
908
  })
877
909
  } catch (err) {
878
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
910
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
879
911
  }
880
912
  }
881
913
 
@@ -903,7 +935,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
903
935
  })),
904
936
  })
905
937
  } catch (err) {
906
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
938
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
907
939
  }
908
940
  }
909
941
 
@@ -955,7 +987,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
955
987
  fields: flattenTiddlerFields(t),
956
988
  })
957
989
  } catch (err) {
958
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
990
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
959
991
  }
960
992
  }
961
993
 
@@ -999,7 +1031,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
999
1031
  })),
1000
1032
  })
1001
1033
  } catch (err) {
1002
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
1034
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
1003
1035
  }
1004
1036
  }
1005
1037
 
@@ -1017,7 +1049,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1017
1049
  }
1018
1050
  json(res, { ok: true, status: deps.server.status().status })
1019
1051
  } catch (err) {
1020
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
1052
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
1021
1053
  }
1022
1054
  }
1023
1055
 
@@ -1087,7 +1119,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1087
1119
  lastSync: new Date().toISOString(),
1088
1120
  }, pushed.ok ? 200 : 502)
1089
1121
  } catch (err) {
1090
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
1122
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
1091
1123
  } finally {
1092
1124
  endMutation()
1093
1125
  }
@@ -1126,16 +1158,31 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1126
1158
  }
1127
1159
  const filesDir = join(deps.getWikiPath(), 'files')
1128
1160
  await mkdir(filesDir, { recursive: true })
1161
+ // Collision avoidance with an ATOMIC create (v0.19.3): the old code did
1162
+ // `access(candidate)` then `writeFile(candidate)`, so (a) any access error
1163
+ // (EACCES/EBUSY, not just ENOENT) was read as "name free" and (b) two
1164
+ // concurrent uploads could both pick the same name and one silently
1165
+ // replaced the other — contradicting the route's own "nothing is ever
1166
+ // overwritten" contract. `flag: 'wx'` fails with EEXIST instead, and we
1167
+ // walk the suffix chain on that error only. Bounded, so a pathological
1168
+ // directory cannot spin this loop forever.
1129
1169
  const ext = extname(name)
1130
1170
  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}`
1171
+ let candidate = ''
1172
+ let wrote = false
1173
+ for (let i = 0; i <= 10_000 && !wrote; i++) {
1174
+ candidate = i === 0 ? name : `${stem}-${i}${ext}`
1175
+ try {
1176
+ await writeFile(join(filesDir, candidate), buf, { flag: 'wx' })
1177
+ wrote = true
1178
+ } catch (err) {
1179
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
1180
+ }
1181
+ }
1182
+ if (!wrote) {
1183
+ json(res, { ok: false, error: '同名文件过多,请换一个文件名' }, 409)
1184
+ return
1137
1185
  }
1138
- await writeFile(join(filesDir, candidate), buf)
1139
1186
  deps.autoCommit()
1140
1187
  invalidateGitStatus()
1141
1188
  json(res, {
@@ -1216,6 +1263,32 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1216
1263
  }
1217
1264
  }
1218
1265
 
1266
+ /**
1267
+ * Titles the browser-facing TW proxies must never serve (v0.19.3).
1268
+ *
1269
+ * `$:/plugins/dsh-tiddlywiki/config` holds the shared tokens and the git
1270
+ * remote (possibly with a PAT); both `/tw` and `/api` forward ANY path to the
1271
+ * loopback TW child, whose TiddlyWeb REST answers for `$:/…` titles — a
1272
+ * route-level guard on `/get` was therefore trivially bypassed by
1273
+ * `GET /dsh-tiddlywiki/tw/recipes/default/tiddlers/%24%3A%2Fplugins%2F…`
1274
+ * (verified). The whole plugin namespace is blocked: nothing under it is
1275
+ * needed by the TW frontend, and the host itself talks to TW directly.
1276
+ */
1277
+ const BLOCKED_PROXY_TITLE_PREFIXES = ['$:/plugins/dsh-tiddlywiki/']
1278
+
1279
+ /** True when a proxied pathname addresses a blocked (secret-bearing) tiddler. */
1280
+ const isBlockedProxyPath = (pathname: string): boolean => {
1281
+ const marker = '/tiddlers/'
1282
+ const at = pathname.indexOf(marker)
1283
+ if (at < 0) return false
1284
+ const raw = pathname.slice(at + marker.length)
1285
+ let title = raw
1286
+ try {
1287
+ title = decodeURIComponent(raw)
1288
+ } catch { /* malformed encoding: check the raw form */ }
1289
+ return BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix))
1290
+ }
1291
+
1219
1292
  /** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
1220
1293
  const handleApiProxy = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
1221
1294
  if (rejectCrossSiteWrite(req, res)) return
@@ -1224,9 +1297,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1224
1297
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
1225
1298
  return
1226
1299
  }
1227
- const url = new URL(req.url ?? '/', 'http://127.0.0.1')
1228
- const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, '') || '/'
1229
1300
  try {
1301
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
1302
+ const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, '') || '/'
1303
+ if (isBlockedProxyPath(rest)) {
1304
+ json(res, { ok: false, error: 'system tiddler not exposed' }, 403)
1305
+ return
1306
+ }
1230
1307
  // Share the /tw proxy's header forwarding so `authorization`/`cookie`
1231
1308
  // reach the TW child: in locked-down mode (auth.username configured) the
1232
1309
  // /api passthrough used to 401 on every call because it dropped them.
@@ -1266,12 +1343,22 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1266
1343
  }
1267
1344
  const url = new URL(req.url ?? '/', 'http://127.0.0.1')
1268
1345
  const rest = url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), '') || '/'
1346
+ if (isBlockedProxyPath(rest)) {
1347
+ json(res, { ok: false, error: 'system tiddler not exposed' }, 403)
1348
+ return
1349
+ }
1269
1350
  try {
1270
1351
  const method = (req.method ?? 'GET').toUpperCase()
1271
1352
  const headers = forwardHeaders(req.headers)
1272
1353
  // TW's CSRF gate requires X-Requested-With on writes; forward it through.
1273
1354
  if (method === 'PUT' || method === 'DELETE' || method === 'POST') headers['x-requested-with'] = 'TiddlyWiki'
1274
- const init: RequestInit = { method, headers, signal: AbortSignal.timeout(30_000) }
1355
+ // Abort the upstream fetch when the CLIENT goes away: without this the TW
1356
+ // child kept streaming a large attachment into the DSH process until the
1357
+ // 30s timeout fired, long after the browser had cancelled (v0.19.3).
1358
+ const abort = new AbortController()
1359
+ const timeout = AbortSignal.timeout(30_000)
1360
+ const signal = typeof AbortSignal.any === 'function' ? AbortSignal.any([abort.signal, timeout]) : timeout
1361
+ const init: RequestInit = { method, headers, signal }
1275
1362
  if (method === 'PUT' || method === 'POST') init.body = await readBodyBuffer(req, MAX_UPLOAD_BYTES)
1276
1363
  const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init)
1277
1364
  const responseHeaders: Record<string, string> = {
@@ -1300,7 +1387,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1300
1387
  await new Promise<void>((resolveP, rejectP) => {
1301
1388
  body.on('error', rejectP)
1302
1389
  res.on('error', rejectP)
1303
- res.on('close', () => resolveP())
1390
+ res.on('close', () => {
1391
+ // Client hung up (aborted download / closed tab): stop pulling from the
1392
+ // TW child instead of draining it until the timeout.
1393
+ try { abort.abort() } catch { /* already aborted */ }
1394
+ try { body.destroy() } catch { /* already closed */ }
1395
+ resolveP()
1396
+ })
1304
1397
  res.on('finish', () => resolveP())
1305
1398
  body.pipe(res)
1306
1399
  })
@@ -1313,25 +1406,29 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1313
1406
  }
1314
1407
  }
1315
1408
 
1409
+ // Every handler goes through guardHandler (v0.19.3): a rejection — including a
1410
+ // synchronous throw before the handler's own try, e.g. `new URL(req.url)` —
1411
+ // becomes a 413/500 JSON response instead of an unhandled rejection that
1412
+ // would exit the dsh web process with the request hanging.
1316
1413
  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) } }),
1414
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/status`, handler: guardHandler(handleStatus) }),
1415
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: guardHandler(handleNote) }),
1416
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/edit`, handler: guardHandler(handleEdit) }),
1417
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/tags`, handler: guardHandler(handleTags) }),
1418
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/recent`, handler: guardHandler(handleRecent) }),
1419
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/get`, handler: guardHandler(handleGet) }),
1420
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/search`, handler: guardHandler(handleSearch) }),
1421
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/render`, handler: guardHandler(handleRender) }),
1422
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/sync`, handler: guardHandler(handleSync) }),
1423
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: guardHandler(handleUpload) }),
1424
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: guardHandler(handleRestart) }),
1425
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/session/summary`, handler: guardHandler(handleSessionSummary) }),
1426
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/sessions`, handler: guardHandler(handleAgentSessions) }),
1427
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/modes`, handler: guardHandler(handleAgentModes) }),
1428
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/send`, handler: guardHandler(handleAgentSend) }),
1429
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/create`, handler: guardHandler(handleAgentCreate) }),
1430
+ ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: guardHandler(handleApiProxy) }),
1431
+ ctx.webServer.register({ kind: 'prefix', path: `${TW_PROXY_PREFIX}`, handler: guardHandler(handleTwProxy) }),
1335
1432
  ]
1336
1433
  return () => {
1337
1434
  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/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'