dsh-tiddlywiki 0.5.0 → 0.7.2
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 +22 -5
- package/lib/client.bundle.js +5 -3
- package/lib/client.js +5 -3
- package/lib/index.js +309 -5
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/note-widget.ts +5 -1
- package/src/client/panel.ts +20 -6
- package/src/host/config.ts +7 -0
- package/src/host/routes.ts +225 -8
- package/src/host/wiki.ts +12 -0
- package/src/index.ts +48 -5
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tiddlywiki",
|
|
3
|
-
"description": "TiddlyWiki 5 as the DSH persistent knowledge base: tiddlywiki_* agent tools (search with filters/recent/tags/batch/rename + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column, a quick-note card with draft auto-save & recent-notes picker behind a single knowledge FAB, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "TiddlyWiki 5 as the DSH persistent knowledge base: tiddlywiki_* agent tools (search with filters/recent/tags/batch/rename + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column (same-origin proxy, works over Tailscale/LAN/domain), a quick-note card with draft auto-save & recent-notes picker behind a single knowledge FAB, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
|
|
4
|
+
"version": "0.7.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -634,7 +634,11 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
634
634
|
}
|
|
635
635
|
clearDraft()
|
|
636
636
|
hideDraftBanner()
|
|
637
|
-
|
|
637
|
+
// twUrl is the same-origin proxy path (e.g. /dsh-tiddlywiki/tw/);
|
|
638
|
+
// resolve it against this page's origin so the popup works from any
|
|
639
|
+
// host/domain DSH is reached on (loopback, LAN, Tailscale, HTTPS).
|
|
640
|
+
const popupUrl = `${new URL(payload.twUrl, location.origin).href}#${encodeURIComponent(payload.draftTitle)}`
|
|
641
|
+
openEditorPopup(popupUrl, payload.title ?? title)
|
|
638
642
|
toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
|
|
639
643
|
} catch (err) {
|
|
640
644
|
toast(`打开失败:${err instanceof Error ? err.message : String(err)}`)
|
package/src/client/panel.ts
CHANGED
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
* Toggling rides the shared PanelState; cross-plugin exclusivity rides the
|
|
11
11
|
* `dsh-panel-activate` event (same protocol as dsh-taskboard).
|
|
12
12
|
*
|
|
13
|
-
* The iframe points
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* The iframe points at the SAME-ORIGIN TW proxy (`<origin>/dsh-tiddlywiki/tw/`,
|
|
14
|
+
* remote-access mode R1): the browser only talks to the DSH origin — which it
|
|
15
|
+
* already reaches over loopback, LAN, Tailscale, a domain or HTTPS — and DSH
|
|
16
|
+
* proxies to the loopback TW child. The panel reads /status first and only
|
|
17
|
+
* sets iframe.src when the service is actually running. Because the proxy URL
|
|
18
|
+
* is origin-relative and port-independent, a TW restart never reloads the
|
|
19
|
+
* iframe, so an in-progress edit keeps its unsaved state.
|
|
17
20
|
*
|
|
18
21
|
* @module dsh-tiddlywiki/client/panel
|
|
19
22
|
*/
|
|
@@ -76,6 +79,8 @@ interface StatusPayload {
|
|
|
76
79
|
ok?: boolean
|
|
77
80
|
status: string
|
|
78
81
|
url?: string
|
|
82
|
+
/** Same-origin TW proxy path (e.g. /dsh-tiddlywiki/tw/); the iframe base. */
|
|
83
|
+
twProxy?: string
|
|
79
84
|
wikiPath?: string
|
|
80
85
|
error?: string
|
|
81
86
|
note?: { tag?: string }
|
|
@@ -232,9 +237,18 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
232
237
|
showError('无法访问 /dsh-tiddlywiki/status')
|
|
233
238
|
return
|
|
234
239
|
}
|
|
235
|
-
if (payload.status === 'running'
|
|
240
|
+
if (payload.status === 'running') {
|
|
236
241
|
refreshAttempts = 0
|
|
237
|
-
|
|
242
|
+
// Same-origin proxy URL: build from the page's own origin so it works no
|
|
243
|
+
// matter which host/domain the user reached DSH on. Fall back to the
|
|
244
|
+
// legacy loopback `url` for older servers that do not send twProxy.
|
|
245
|
+
if (typeof payload.twProxy === 'string') {
|
|
246
|
+
showFrame(new URL(payload.twProxy, window.location.origin).href)
|
|
247
|
+
} else if (typeof payload.url === 'string') {
|
|
248
|
+
showFrame(payload.url)
|
|
249
|
+
} else {
|
|
250
|
+
showError('服务未返回编辑器地址')
|
|
251
|
+
}
|
|
238
252
|
return
|
|
239
253
|
}
|
|
240
254
|
if (payload.status === 'starting') {
|
package/src/host/config.ts
CHANGED
|
@@ -27,6 +27,13 @@ export interface PluginConfigShape {
|
|
|
27
27
|
showPanelStatus?: boolean
|
|
28
28
|
/** 是否在界面右下角显示「同步」悬浮按钮(默认 true)。 */
|
|
29
29
|
showSyncButton?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* 一键发送给 Agent(TW 笔记 → DSH 会话注入)。
|
|
32
|
+
* `enabled: false` 时路由 403、TW 端按钮点击提示未启用;`token` 非空时
|
|
33
|
+
* 路由要求请求头 `x-send-to-agent-token` 与之匹配;`endpoint` 可覆盖
|
|
34
|
+
* TW 端默认的 `location.origin + /dsh-tiddlywiki`。
|
|
35
|
+
*/
|
|
36
|
+
sendToAgent?: { enabled?: boolean; endpoint?: string; token?: string }
|
|
30
37
|
}
|
|
31
38
|
uiLanguage?: string
|
|
32
39
|
[key: string]: unknown
|
package/src/host/routes.ts
CHANGED
|
@@ -7,20 +7,24 @@
|
|
|
7
7
|
* | /dsh-tiddlywiki/note | POST | quick-note → independent tiddler |
|
|
8
8
|
* | /dsh-tiddlywiki/restart | POST | one-click retry/restart of the TW child |
|
|
9
9
|
* | /dsh-tiddlywiki/api/* | any | passthrough to the TW service (JSON) |
|
|
10
|
+
* | /dsh-tiddlywiki/tw/* | any | SAME-ORIGIN TW proxy (index + files + TiddlyWeb API) |
|
|
10
11
|
*
|
|
11
|
-
* Matching is exact-over-prefix, so the exact routes win and the `/api`
|
|
12
|
-
*
|
|
13
|
-
* so no CORS is involved.
|
|
12
|
+
* Matching is exact-over-prefix, so the exact routes win and the `/api` /
|
|
13
|
+
* `/tw` prefixes catch the rest. Client calls are same-origin (the DSH web
|
|
14
|
+
* server), so no CORS is involved. The `/tw` proxy is the remote-access
|
|
15
|
+
* bridge: it serves the ENTIRE TW frontend to the browser through the DSH
|
|
16
|
+
* origin (see TW_PROXY_PATH in wiki.ts), so the embedded editor works no
|
|
17
|
+
* matter which host/domain the user reaches DSH on.
|
|
14
18
|
*
|
|
15
19
|
* @module dsh-tiddlywiki/host/routes
|
|
16
20
|
*/
|
|
17
|
-
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
21
|
+
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'
|
|
18
22
|
import { mkdir, writeFile, access } from 'node:fs/promises'
|
|
19
23
|
import { basename, extname, join } from 'node:path'
|
|
20
24
|
import type { TiddlyWebClient } from './tw-api.ts'
|
|
21
25
|
import type { WikiServer } from './wiki.ts'
|
|
22
26
|
import type { GitFace } from './git.ts'
|
|
23
|
-
import { PATH_PREFIX } from './wiki.ts'
|
|
27
|
+
import { PATH_PREFIX, TW_PROXY_PREFIX, TW_PROXY_PATH } from './wiki.ts'
|
|
24
28
|
|
|
25
29
|
export const ROUTE_PREFIX = PATH_PREFIX
|
|
26
30
|
|
|
@@ -43,6 +47,32 @@ export interface WebServerFace {
|
|
|
43
47
|
register(route: { kind: 'exact' | 'prefix'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }): () => void
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Structural face over the DSH `sessionController` service (a subset of
|
|
52
|
+
* dsh-api-session-controller). Only the methods the agent-send routes need are
|
|
53
|
+
* declared; the runtime instance is a real Service, never inspected data.
|
|
54
|
+
*/
|
|
55
|
+
export interface SessionControllerFace {
|
|
56
|
+
prompt(
|
|
57
|
+
request: { requestId: string; sessionId: string; mode: 'queue' | 'steer'; content: Array<{ type: 'text'; text: string }> },
|
|
58
|
+
signal: AbortSignal,
|
|
59
|
+
): Promise<{ accepted: boolean }>
|
|
60
|
+
list(
|
|
61
|
+
request: { cursor?: string },
|
|
62
|
+
signal: AbortSignal,
|
|
63
|
+
): Promise<{
|
|
64
|
+
items: Array<{
|
|
65
|
+
sessionId: string
|
|
66
|
+
updatedAt?: number
|
|
67
|
+
running?: boolean
|
|
68
|
+
blank?: boolean
|
|
69
|
+
parentSessionId?: string
|
|
70
|
+
cwd?: string
|
|
71
|
+
}>
|
|
72
|
+
}>
|
|
73
|
+
create(request: { cwd?: string; workspaceId?: string }): Promise<{ sessionId: string }>
|
|
74
|
+
}
|
|
75
|
+
|
|
46
76
|
/** Effective UI visibility flags returned by /status (mirror index.ts). */
|
|
47
77
|
export interface UiDefaultsPublic {
|
|
48
78
|
showQuickNote: boolean
|
|
@@ -58,6 +88,13 @@ export interface RouteDeps {
|
|
|
58
88
|
noteDefaults: () => { tag: string }
|
|
59
89
|
uiDefaults: () => UiDefaultsPublic
|
|
60
90
|
getWikiPath: () => string
|
|
91
|
+
/** Optional DSH sessionController service (agent-send routes only); resolved
|
|
92
|
+
* lazily per request because it may register after webServer appears. */
|
|
93
|
+
getSessionController: () => SessionControllerFace | undefined
|
|
94
|
+
/** Whether the one-click send-to-agent feature is enabled (config switch). */
|
|
95
|
+
sendToAgentEnabled: () => boolean
|
|
96
|
+
/** Optional shared token that must match `x-send-to-agent-token` when set. */
|
|
97
|
+
sendToAgentToken: () => string
|
|
61
98
|
}
|
|
62
99
|
|
|
63
100
|
function readBody(req: IncomingMessage, limit = MAX_BODY_BYTES): Promise<string> {
|
|
@@ -97,6 +134,26 @@ function readBodyBuffer(req: IncomingMessage, limit = MAX_UPLOAD_BYTES): Promise
|
|
|
97
134
|
})
|
|
98
135
|
}
|
|
99
136
|
|
|
137
|
+
/** Header names forwarded to the upstream TW service by the proxy routes. */
|
|
138
|
+
const FORWARD_HEADER_NAMES = [
|
|
139
|
+
'accept', 'accept-encoding', 'content-type', 'cookie', 'authorization',
|
|
140
|
+
'if-none-match', 'if-modified-since', 'origin', 'referer', 'user-agent',
|
|
141
|
+
] as const
|
|
142
|
+
|
|
143
|
+
/** Copy a safe, string-valued subset of the request headers upstream. */
|
|
144
|
+
function forwardHeaders(headers: IncomingHttpHeaders): Record<string, string> {
|
|
145
|
+
const out: Record<string, string> = {}
|
|
146
|
+
for (const name of FORWARD_HEADER_NAMES) {
|
|
147
|
+
// Index through the string index signature so known header names (typed
|
|
148
|
+
// `string`) do not hide the `string[]` repeat case via their specific
|
|
149
|
+
// property declarations.
|
|
150
|
+
const value: string | string[] | undefined = headers[name as string]
|
|
151
|
+
if (typeof value === 'string') out[name] = value
|
|
152
|
+
else if (Array.isArray(value) && value.length > 0) out[name] = value.join(', ')
|
|
153
|
+
}
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
100
157
|
/**
|
|
101
158
|
* Sanitize an uploaded filename into a safe bare name (no path separators,
|
|
102
159
|
* no `..`, no control characters). Returns '' when nothing usable remains.
|
|
@@ -202,7 +259,116 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
202
259
|
} catch {
|
|
203
260
|
gitSummary = null
|
|
204
261
|
}
|
|
205
|
-
json(res, { ok: true, ...view, git: gitSummary, note: { tag: deps.noteDefaults().tag }, ui: deps.uiDefaults() })
|
|
262
|
+
json(res, { ok: true, ...view, twProxy: TW_PROXY_PATH, git: gitSummary, note: { tag: deps.noteDefaults().tag }, ui: deps.uiDefaults() })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* GET /dsh-tiddlywiki/agent/sessions — visible ordinary sessions for the TW
|
|
267
|
+
* one-click picker (excludes subagent sessions, activity-descending).
|
|
268
|
+
*/
|
|
269
|
+
const handleAgentSessions = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
270
|
+
try {
|
|
271
|
+
const sc = deps.getSessionController()
|
|
272
|
+
if (sc === undefined) {
|
|
273
|
+
json(res, { ok: false, error: 'session service unavailable' }, 503)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
const list = await sc.list({}, AbortSignal.timeout(10_000))
|
|
277
|
+
const items = (list.items ?? [])
|
|
278
|
+
.filter((s) => s.parentSessionId === undefined)
|
|
279
|
+
.map((s) => ({
|
|
280
|
+
sessionId: s.sessionId,
|
|
281
|
+
cwd: s.cwd ?? null,
|
|
282
|
+
running: !!s.running,
|
|
283
|
+
blank: !!s.blank,
|
|
284
|
+
updatedAt: s.updatedAt ?? 0,
|
|
285
|
+
}))
|
|
286
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
287
|
+
json(res, { ok: true, items })
|
|
288
|
+
} catch (err) {
|
|
289
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* POST /dsh-tiddlywiki/agent/send — deliver a note to one agent session as a
|
|
295
|
+
* queued user message (sessionController.prompt, the same API the GUI chat
|
|
296
|
+
* input uses). Guards: feature switch, optional shared token, body shape.
|
|
297
|
+
*/
|
|
298
|
+
const handleAgentSend = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
299
|
+
try {
|
|
300
|
+
if (!deps.sendToAgentEnabled()) {
|
|
301
|
+
json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
const token = deps.sendToAgentToken().trim()
|
|
305
|
+
if (token.length > 0) {
|
|
306
|
+
const got = req.headers['x-send-to-agent-token']
|
|
307
|
+
const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
|
|
308
|
+
if (value !== token) {
|
|
309
|
+
json(res, { ok: false, error: 'unauthorized' }, 401)
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const body = JSON.parse(await readBody(req)) as { sessionId?: unknown; text?: unknown }
|
|
314
|
+
const sessionId = typeof body.sessionId === 'string' && body.sessionId.trim().length > 0 ? body.sessionId.trim() : ''
|
|
315
|
+
const text = typeof body.text === 'string' && body.text.trim().length > 0 ? body.text.trim() : ''
|
|
316
|
+
if (sessionId.length === 0 || text.length === 0) {
|
|
317
|
+
json(res, { ok: false, error: 'sessionId and text are required' }, 400)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
const sc = deps.getSessionController()
|
|
321
|
+
if (sc === undefined) {
|
|
322
|
+
json(res, { ok: false, error: 'session service unavailable' }, 503)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
const requestId = `tw-send-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
326
|
+
await sc.prompt(
|
|
327
|
+
{ requestId, sessionId, mode: 'queue', content: [{ type: 'text', text }] },
|
|
328
|
+
AbortSignal.timeout(20_000),
|
|
329
|
+
)
|
|
330
|
+
json(res, { ok: true, requestId, sessionId })
|
|
331
|
+
} catch (err) {
|
|
332
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* POST /dsh-tiddlywiki/agent/create — create (or adopt) one ordinary session,
|
|
338
|
+
* optionally inside a workspace path. The picker uses it for "new workspace /
|
|
339
|
+
* new session"; the created session's cwd becomes its workspace. The directory
|
|
340
|
+
* is materialised so a brand-new workspace actually exists on disk.
|
|
341
|
+
*/
|
|
342
|
+
const handleAgentCreate = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
343
|
+
try {
|
|
344
|
+
if (!deps.sendToAgentEnabled()) {
|
|
345
|
+
json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
const token = deps.sendToAgentToken().trim()
|
|
349
|
+
if (token.length > 0) {
|
|
350
|
+
const got = req.headers['x-send-to-agent-token']
|
|
351
|
+
const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
|
|
352
|
+
if (value !== token) {
|
|
353
|
+
json(res, { ok: false, error: 'unauthorized' }, 401)
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const body = JSON.parse(await readBody(req)) as { cwd?: unknown }
|
|
358
|
+
const cwd = typeof body.cwd === 'string' ? body.cwd.trim() : ''
|
|
359
|
+
const sc = deps.getSessionController()
|
|
360
|
+
if (sc === undefined) {
|
|
361
|
+
json(res, { ok: false, error: 'session service unavailable' }, 503)
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
if (cwd.length > 0) {
|
|
365
|
+
await mkdir(cwd, { recursive: true })
|
|
366
|
+
}
|
|
367
|
+
const created = await sc.create({ cwd: cwd.length > 0 ? cwd : undefined })
|
|
368
|
+
json(res, { ok: true, sessionId: created.sessionId, cwd: cwd || null })
|
|
369
|
+
} catch (err) {
|
|
370
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
371
|
+
}
|
|
206
372
|
}
|
|
207
373
|
|
|
208
374
|
const handleNote = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
@@ -241,7 +407,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
241
407
|
const text = typeof body.text === 'string' ? body.text : ''
|
|
242
408
|
const result = await openInTwEditor(client, title, text, tags)
|
|
243
409
|
deps.autoCommit()
|
|
244
|
-
json(res, { ok: true, ...result, twUrl:
|
|
410
|
+
json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH })
|
|
245
411
|
} catch (err) {
|
|
246
412
|
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
247
413
|
}
|
|
@@ -426,7 +592,9 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
426
592
|
ok: true,
|
|
427
593
|
name: candidate,
|
|
428
594
|
path: `files/${candidate}`,
|
|
429
|
-
|
|
595
|
+
// Same-origin proxy URL: the embedded TW editor resolves image links
|
|
596
|
+
// against the DSH origin, so a root-absolute `/files/...` would miss.
|
|
597
|
+
url: `${TW_PROXY_PATH}files/${encodeURIComponent(candidate)}`,
|
|
430
598
|
size: buf.length,
|
|
431
599
|
type: req.headers['content-type'] ?? 'application/octet-stream',
|
|
432
600
|
})
|
|
@@ -465,6 +633,51 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
465
633
|
}
|
|
466
634
|
}
|
|
467
635
|
|
|
636
|
+
/**
|
|
637
|
+
* SAME-ORIGIN proxy /dsh-tiddlywiki/tw/<rest> → TW root /<rest>. Serves the
|
|
638
|
+
* ENTIRE TW frontend (index HTML, /files/*, the TiddlyWeb API) to the
|
|
639
|
+
* browser through the DSH origin, so the embedded editor works from any
|
|
640
|
+
* host/domain the user reaches DSH on (loopback, LAN, Tailscale, domain,
|
|
641
|
+
* HTTPS). The browser never talks to the loopback TW child directly; DSH
|
|
642
|
+
* does, on the same machine. Binary responses are buffered losslessly
|
|
643
|
+
* (arrayBuffer) — unlike the /api JSON proxy, this route must never .text().
|
|
644
|
+
*/
|
|
645
|
+
const handleTwProxy = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
646
|
+
const client = deps.getClient()
|
|
647
|
+
if (client === undefined) {
|
|
648
|
+
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
652
|
+
const rest = url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), '') || '/'
|
|
653
|
+
try {
|
|
654
|
+
const method = (req.method ?? 'GET').toUpperCase()
|
|
655
|
+
const headers = forwardHeaders(req.headers)
|
|
656
|
+
// TW's CSRF gate requires X-Requested-With on writes; forward it through.
|
|
657
|
+
if (method === 'PUT' || method === 'DELETE' || method === 'POST') headers['x-requested-with'] = 'TiddlyWiki'
|
|
658
|
+
const init: RequestInit = { method, headers, signal: AbortSignal.timeout(30_000) }
|
|
659
|
+
if (method === 'PUT' || method === 'POST') init.body = await readBodyBuffer(req, MAX_UPLOAD_BYTES)
|
|
660
|
+
const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init)
|
|
661
|
+
const data = Buffer.from(await upstream.arrayBuffer())
|
|
662
|
+
const responseHeaders: Record<string, string> = {
|
|
663
|
+
'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream',
|
|
664
|
+
'cache-control': upstream.headers.get('cache-control') ?? 'no-store',
|
|
665
|
+
}
|
|
666
|
+
for (const name of ['etag', 'last-modified', 'content-disposition']) {
|
|
667
|
+
const value = upstream.headers.get(name)
|
|
668
|
+
if (value !== null) responseHeaders[name] = value
|
|
669
|
+
}
|
|
670
|
+
res.writeHead(upstream.status, responseHeaders)
|
|
671
|
+
res.end(data)
|
|
672
|
+
} catch (err) {
|
|
673
|
+
if (res.headersSent) {
|
|
674
|
+
res.end()
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 502)
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
468
681
|
const disposers = [
|
|
469
682
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/status`, handler: (req, res) => { void handleStatus(req, res) } }),
|
|
470
683
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: (req, res) => { void handleNote(req, res) } }),
|
|
@@ -475,7 +688,11 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
475
688
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/sync`, handler: (req, res) => { void handleSync(req, res) } }),
|
|
476
689
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: (req, res) => { void handleUpload(req, res) } }),
|
|
477
690
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: (req, res) => { void handleRestart(req, res) } }),
|
|
691
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/sessions`, handler: (req, res) => { void handleAgentSessions(req, res) } }),
|
|
692
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/send`, handler: (req, res) => { void handleAgentSend(req, res) } }),
|
|
693
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/create`, handler: (req, res) => { void handleAgentCreate(req, res) } }),
|
|
478
694
|
ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: (req, res) => { void handleApiProxy(req, res) } }),
|
|
695
|
+
ctx.webServer.register({ kind: 'prefix', path: `${TW_PROXY_PREFIX}`, handler: (req, res) => { void handleTwProxy(req, res) } }),
|
|
479
696
|
]
|
|
480
697
|
return () => {
|
|
481
698
|
for (const dispose of disposers) dispose()
|
package/src/host/wiki.ts
CHANGED
|
@@ -30,6 +30,18 @@ import type { Readable } from 'node:stream'
|
|
|
30
30
|
/** The DSH webserver route prefix (NOT a TW path-prefix; see module header). */
|
|
31
31
|
export const PATH_PREFIX = '/dsh-tiddlywiki'
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Same-origin TW proxy route on the DSH webserver (remote-access mode, R1).
|
|
35
|
+
* The browser only ever talks to the DSH origin — which it already reaches
|
|
36
|
+
* over loopback, LAN, Tailscale, a domain or HTTPS — and DSH proxies to the
|
|
37
|
+
* loopback TW child. TW's frontend is pointed at this prefix via the
|
|
38
|
+
* `$:/config/tiddlyweb/host` tiddler so every API call stays same-origin.
|
|
39
|
+
*/
|
|
40
|
+
export const TW_PROXY_PREFIX = `${PATH_PREFIX}/tw`
|
|
41
|
+
|
|
42
|
+
/** The proxy base path (trailing slash) handed to browsers / TW's frontend. */
|
|
43
|
+
export const TW_PROXY_PATH = `${TW_PROXY_PREFIX}/`
|
|
44
|
+
|
|
33
45
|
/** How long to wait for the wiki to answer /status. */
|
|
34
46
|
const READY_TIMEOUT_MS = 20_000
|
|
35
47
|
|
package/src/index.ts
CHANGED
|
@@ -22,13 +22,13 @@ import { watch, type FSWatcher } from 'node:fs'
|
|
|
22
22
|
import { writeFile } from 'node:fs/promises'
|
|
23
23
|
import { join } from 'node:path'
|
|
24
24
|
import { AutoCommitter, GitFace } from './host/git.ts'
|
|
25
|
-
import { registerRoutes, type WebServerFace } from './host/routes.ts'
|
|
25
|
+
import { registerRoutes, type SessionControllerFace, type WebServerFace } from './host/routes.ts'
|
|
26
26
|
import { ConfigStore, deepMerge, type PluginConfigShape } from './host/config.ts'
|
|
27
27
|
import { registerAdminRoutes, ensureLanguage, resolveTwRoot, type AdminDeps } from './host/admin.ts'
|
|
28
28
|
import { seedDocNote, DOC_NOTE_TITLE } from './host/seed-notes.ts'
|
|
29
29
|
import { TiddlyWebClient } from './host/tw-api.ts'
|
|
30
30
|
import { registerTiddlywikiTools, type ToolsDeps } from './host/tools.ts'
|
|
31
|
-
import { PATH_PREFIX, WikiServer, type WikiServerOptions } from './host/wiki.ts'
|
|
31
|
+
import { PATH_PREFIX, TW_PROXY_PATH, TW_PROXY_PREFIX, WikiServer, type WikiServerOptions } from './host/wiki.ts'
|
|
32
32
|
import { dshHomePath, defineTool } from './sdk.ts'
|
|
33
33
|
|
|
34
34
|
/** Cordis plugin name (also the client loader id / profile row id). */
|
|
@@ -38,7 +38,7 @@ export const name = 'dsh-tiddlywiki'
|
|
|
38
38
|
export const inject = ['tools', 'systemPrompt']
|
|
39
39
|
|
|
40
40
|
/** Re-exports for the headless selftest and future consumers. */
|
|
41
|
-
export { AutoCommitter, GitFace, PATH_PREFIX, TiddlyWebClient, WikiServer, dshHomePath, defineTool }
|
|
41
|
+
export { AutoCommitter, GitFace, PATH_PREFIX, TW_PROXY_PATH, TW_PROXY_PREFIX, TiddlyWebClient, WikiServer, dshHomePath, defineTool }
|
|
42
42
|
export { ConfigStore, deepMerge } from './host/config.ts'
|
|
43
43
|
export { openInTwEditor, registerRoutes } from './host/routes.ts'
|
|
44
44
|
export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, bundledCatalog, ensureLanguage, normalizeThemes } from './host/admin.ts'
|
|
@@ -76,7 +76,7 @@ interface ResolvedConfig {
|
|
|
76
76
|
port: number
|
|
77
77
|
git: { autoCommit: boolean; debounceMs: number; remote: string; branch: string }
|
|
78
78
|
note: { tag: string }
|
|
79
|
-
ui: { showQuickNote: boolean; showPanelStatus: boolean; showSyncButton: boolean }
|
|
79
|
+
ui: { showQuickNote: boolean; showPanelStatus: boolean; showSyncButton: boolean; sendToAgent: { enabled: boolean } }
|
|
80
80
|
auth: { username?: string; password?: string }
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -86,10 +86,35 @@ const DEFAULTS: ResolvedConfig = {
|
|
|
86
86
|
port: 0,
|
|
87
87
|
git: { autoCommit: true, debounceMs: 60_000, remote: '', branch: 'main' },
|
|
88
88
|
note: { tag: 'inbox' },
|
|
89
|
-
ui: { showQuickNote: true, showPanelStatus: true, showSyncButton: true },
|
|
89
|
+
ui: { showQuickNote: true, showPanelStatus: true, showSyncButton: true, sendToAgent: { enabled: true } },
|
|
90
90
|
auth: { username: '', password: '' },
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** Config tiddler steering TW's frontend API base (tiddlywebadaptor). */
|
|
94
|
+
export const TW_WEB_HOST_TIDDLER = '$:/config/tiddlyweb/host'
|
|
95
|
+
/** The legacy default value this plugin replaces with the same-origin proxy. */
|
|
96
|
+
const TW_WEB_HOST_DEFAULT = '$protocol$//$host$/'
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Point TW's frontend at the same-origin DSH proxy (remote-access mode, R1).
|
|
100
|
+
* The tiddlywebadaptor builds every API URL from $:/config/tiddlyweb/host; its
|
|
101
|
+
* default `$protocol$//$host$/` resolves to the iframe's origin ROOT, which
|
|
102
|
+
* 404s whenever the browser is not on the same machine as TW. Written only
|
|
103
|
+
* when the tiddler is missing or still the legacy default — a user override is
|
|
104
|
+
* honored (e.g. someone who really does expose TW on a dedicated origin).
|
|
105
|
+
*/
|
|
106
|
+
export async function ensureTwWebHost(client: TiddlyWebClient | undefined): Promise<void> {
|
|
107
|
+
if (client === undefined) return
|
|
108
|
+
let current: string | undefined
|
|
109
|
+
try {
|
|
110
|
+
current = (await client.get(TW_WEB_HOST_TIDDLER))?.text
|
|
111
|
+
} catch {
|
|
112
|
+
current = undefined
|
|
113
|
+
}
|
|
114
|
+
if (current !== undefined && current.trim() !== TW_WEB_HOST_DEFAULT) return
|
|
115
|
+
await client.put({ title: TW_WEB_HOST_TIDDLER, text: TW_PROXY_PATH, type: 'text/plain', tags: [] })
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
/** Expand $VAR / ${VAR} / %VAR% from process.env (config uses $DSH_HOME). */
|
|
94
119
|
function expandEnvPath(input: string): string {
|
|
95
120
|
return input
|
|
@@ -282,6 +307,13 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
|
|
|
282
307
|
try {
|
|
283
308
|
await server.start()
|
|
284
309
|
await configStore.load(client())
|
|
310
|
+
// Point TW's frontend at the same-origin DSH proxy (remote-access mode):
|
|
311
|
+
// runs before git bootstrap so the config tiddler joins the first commit.
|
|
312
|
+
try {
|
|
313
|
+
await ensureTwWebHost(client())
|
|
314
|
+
} catch (err) {
|
|
315
|
+
console.warn('[dsh-tiddlywiki] ensuring $:/config/tiddlyweb/host:', err)
|
|
316
|
+
}
|
|
285
317
|
// Seeds (run after the effective config is loaded):
|
|
286
318
|
// - doc note: ONE-SHOT — a fresh wiki gets the plugin guide; deleting
|
|
287
319
|
// it afterwards survives restarts (see seedDocNote).
|
|
@@ -321,6 +353,11 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
|
|
|
321
353
|
// Routes + settings-panel admin surface (lazy webServer).
|
|
322
354
|
ctx.inject(['webServer'], (webCtx: HostCtx) => {
|
|
323
355
|
const ws = (webCtx as unknown as { webServer: WebServerFace }).webServer
|
|
356
|
+
// sessionController is a core host service; read it LAZILY per request (via
|
|
357
|
+
// the plugin root ctx) because it may be registered after webServer, and the
|
|
358
|
+
// agent routes must work whenever a request actually arrives.
|
|
359
|
+
const getSessionController = (): SessionControllerFace | undefined =>
|
|
360
|
+
ctx.get('sessionController') as SessionControllerFace | undefined
|
|
324
361
|
const disposeRoutes = registerRoutes({ webServer: ws }, {
|
|
325
362
|
server,
|
|
326
363
|
getClient: client,
|
|
@@ -329,6 +366,12 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
|
|
|
329
366
|
noteDefaults: () => ({ tag: effectiveNoteTag() }),
|
|
330
367
|
uiDefaults: () => effectiveUi(),
|
|
331
368
|
getWikiPath: () => wikiPath,
|
|
369
|
+
getSessionController,
|
|
370
|
+
sendToAgentEnabled: () => eff().ui?.sendToAgent?.enabled !== false,
|
|
371
|
+
sendToAgentToken: () => {
|
|
372
|
+
const token = eff().ui?.sendToAgent?.token
|
|
373
|
+
return typeof token === 'string' ? token : ''
|
|
374
|
+
},
|
|
332
375
|
})
|
|
333
376
|
const adminDeps: AdminDeps = {
|
|
334
377
|
server,
|