dsh-zen-remote 2.0.2 → 2.2.0

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.
Files changed (118) hide show
  1. package/AGENTS.md +15 -1
  2. package/README.md +11 -5
  3. package/README.zh-CN.md +11 -5
  4. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
  5. package/docs/adr/0002-relay-emulates-websocket.md +8 -0
  6. package/docs/interface.md +3 -1
  7. package/docs/plugin-bridge.md +140 -0
  8. package/docs/remote-access.en.md +57 -0
  9. package/docs/remote-access.md +63 -0
  10. package/lib/adapters/dsh-better-sidebar/index.js +144 -0
  11. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
  12. package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
  13. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
  14. package/lib/adapters/index.js +17 -0
  15. package/lib/adapters/index.js.map +1 -0
  16. package/lib/adapters/types.js +17 -0
  17. package/lib/adapters/types.js.map +1 -0
  18. package/lib/admin-routes.js +51 -0
  19. package/lib/admin-routes.js.map +1 -1
  20. package/lib/client-routes.js +18 -1
  21. package/lib/client-routes.js.map +1 -1
  22. package/lib/client.js +449 -45
  23. package/lib/config.js +27 -0
  24. package/lib/config.js.map +1 -1
  25. package/lib/http.js +10 -0
  26. package/lib/http.js.map +1 -1
  27. package/lib/index.js +246 -0
  28. package/lib/index.js.map +1 -1
  29. package/lib/intercept.js +5 -2
  30. package/lib/intercept.js.map +1 -1
  31. package/lib/plugin-adapter-core.js +385 -0
  32. package/lib/plugin-adapter-core.js.map +1 -0
  33. package/lib/plugin-bridge.js +318 -0
  34. package/lib/plugin-bridge.js.map +1 -0
  35. package/lib/plugin-loopback.js +207 -0
  36. package/lib/plugin-loopback.js.map +1 -0
  37. package/lib/plugin-route-intercept.js +1103 -0
  38. package/lib/plugin-route-intercept.js.map +1 -0
  39. package/lib/plugin-tunnel.js +416 -0
  40. package/lib/plugin-tunnel.js.map +1 -0
  41. package/lib/relay-client.js +340 -4
  42. package/lib/relay-client.js.map +1 -1
  43. package/lib/relay-server.js +665 -7
  44. package/lib/relay-server.js.map +1 -1
  45. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
  46. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
  47. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
  48. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
  49. package/lib/types/adapters/index.d.ts +17 -0
  50. package/lib/types/adapters/index.d.ts.map +1 -0
  51. package/lib/types/adapters/types.d.ts +42 -0
  52. package/lib/types/adapters/types.d.ts.map +1 -0
  53. package/lib/types/admin-routes.d.ts +16 -0
  54. package/lib/types/admin-routes.d.ts.map +1 -1
  55. package/lib/types/client/effects/remote-session.d.ts +0 -22
  56. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  57. package/lib/types/client/locales.d.ts +16 -0
  58. package/lib/types/client/locales.d.ts.map +1 -1
  59. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  60. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  61. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  62. package/lib/types/client-data/plugin-bridge.d.ts +79 -0
  63. package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
  64. package/lib/types/client-data/remote-status.d.ts +20 -0
  65. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  66. package/lib/types/client-data/settings-form.d.ts +37 -1
  67. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  68. package/lib/types/client-routes.d.ts +13 -0
  69. package/lib/types/client-routes.d.ts.map +1 -1
  70. package/lib/types/config.d.ts +8 -0
  71. package/lib/types/config.d.ts.map +1 -1
  72. package/lib/types/http.d.ts +7 -0
  73. package/lib/types/http.d.ts.map +1 -1
  74. package/lib/types/index.d.ts +4 -0
  75. package/lib/types/index.d.ts.map +1 -1
  76. package/lib/types/intercept.d.ts.map +1 -1
  77. package/lib/types/plugin-adapter-core.d.ts +263 -0
  78. package/lib/types/plugin-adapter-core.d.ts.map +1 -0
  79. package/lib/types/plugin-bridge.d.ts +187 -0
  80. package/lib/types/plugin-bridge.d.ts.map +1 -0
  81. package/lib/types/plugin-loopback.d.ts +104 -0
  82. package/lib/types/plugin-loopback.d.ts.map +1 -0
  83. package/lib/types/plugin-route-intercept.d.ts +114 -0
  84. package/lib/types/plugin-route-intercept.d.ts.map +1 -0
  85. package/lib/types/plugin-tunnel.d.ts +96 -0
  86. package/lib/types/plugin-tunnel.d.ts.map +1 -0
  87. package/lib/types/relay-client.d.ts +81 -1
  88. package/lib/types/relay-client.d.ts.map +1 -1
  89. package/lib/types/relay-server.d.ts +32 -1
  90. package/lib/types/relay-server.d.ts.map +1 -1
  91. package/llms.txt +1 -0
  92. package/package.json +2 -2
  93. package/scripts/check-plugin-bridge-ui.mjs +168 -0
  94. package/scripts/check-settings-form.mjs +3 -1
  95. package/src/adapters/dsh-better-sidebar/index.ts +144 -0
  96. package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
  97. package/src/adapters/index.ts +19 -0
  98. package/src/adapters/types.ts +44 -0
  99. package/src/admin-routes.ts +64 -0
  100. package/src/client/effects/remote-session.ts +82 -10
  101. package/src/client/locales.ts +36 -0
  102. package/src/client/settings/SettingsSection.tsx +107 -3
  103. package/src/client/styles/remote-session.css.ts +70 -31
  104. package/src/client-data/plugin-bridge.ts +150 -0
  105. package/src/client-data/remote-status.ts +30 -0
  106. package/src/client-data/settings-form.ts +78 -0
  107. package/src/client-routes.ts +29 -1
  108. package/src/config.ts +32 -0
  109. package/src/http.ts +12 -0
  110. package/src/index.ts +252 -0
  111. package/src/intercept.ts +5 -2
  112. package/src/plugin-adapter-core.ts +530 -0
  113. package/src/plugin-bridge.ts +435 -0
  114. package/src/plugin-loopback.ts +299 -0
  115. package/src/plugin-route-intercept.ts +1193 -0
  116. package/src/plugin-tunnel.ts +470 -0
  117. package/src/relay-client.ts +413 -4
  118. package/src/relay-server.ts +687 -9
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The plugin bridge's loopback executor (PB-3): performs one GET against one
3
+ * of THIS server's own plugin routes, over real loopback HTTP, so the
4
+ * plugin's own handler — its authentication, its req/res semantics — runs
5
+ * exactly as it would for a local browser (docs/adr/0001). The relay route
6
+ * hands this module a validated path + query and gets the plugin's answer
7
+ * back as a status code plus a byte stream to pump at the sub-client.
8
+ *
9
+ * Why the cookie dance: DSH 0.1.2+ requires a signed cookie on EVERY route.
10
+ * The gateway half solved this for browsers by "playing courier" — one
11
+ * `GET /?token=…` (the only URL that accepts a token), then the 303's
12
+ * `set-cookie` straight onto the client (lib/lan-gate-server.cjs
13
+ * attemptAuthExchange). This module is the same courier for the relay: it
14
+ * keeps ONE cookie in memory for the process, exchanges it once on first
15
+ * use, and re-exchanges exactly once when the plugin route answers 401 (a
16
+ * rotated signing key). The exchange is the lan-gate.mjs wiring's shape:
17
+ * `connection.authenticatedUrl(protocol//127.0.0.1:port)` builds the token
18
+ * URL — absent on 0.1.1 hosts, where there is nothing to exchange and every
19
+ * bridged call fails `loopback-auth` honestly.
20
+ *
21
+ * The cookie never leaves this module: no log, no state file, no response —
22
+ * it exists only as the `cookie` request header on the loopback hop.
23
+ *
24
+ * Transport is node:http / node:https on purpose: the plugin's answer must
25
+ * stream (chunk after chunk, with real backpressure) and must die the
26
+ * moment the sub-client hangs up (an AbortSignal reaches the socket), and
27
+ * 0.2.1's self-signed https needs `rejectUnauthorized: false` — a per-
28
+ * request knob fetch cannot express. The loosened verification is safe BY
29
+ * CONSTRUCTION: the connection is dialed to the literal 127.0.0.1 the
30
+ * wiring pinned, never to a name that could be spoofed; the worst an
31
+ * on-path attacker could forge is data from the machine's own web server
32
+ * to itself.
33
+ */
34
+
35
+ import http from 'node:http'
36
+ import https from 'node:https'
37
+ import type { IncomingMessage } from 'node:http'
38
+ import { ADAPTER_RESPONSE_HEADER_ALLOWLIST } from './plugin-adapter-core.js'
39
+
40
+ /** The adapter allowlist's headers off one loopback answer, verbatim. */
41
+ function extraAdapterHeadersOf(response: IncomingMessage): Record<string, string> {
42
+ const extra: Record<string, string> = {}
43
+ for (const name of ADAPTER_RESPONSE_HEADER_ALLOWLIST) {
44
+ const value = response.headers[name]
45
+ if (typeof value === 'string' && value !== '') extra[name] = value
46
+ }
47
+ return extra
48
+ }
49
+
50
+ /** The loopback bind info, re-read live per request by the wiring. */
51
+ export interface PluginLoopbackTarget {
52
+ protocol: 'http:' | 'https:'
53
+ port: number
54
+ }
55
+
56
+ /** The request headers this bridge passes through to the plugin. */
57
+ export interface PluginLoopbackHeaders {
58
+ accept?: string
59
+ range?: string
60
+ /** PB-9: a forwarded request body's content type. */
61
+ contentType?: string
62
+ }
63
+
64
+ /** The plugin route's answer, headers whitelisted to what the relay may
65
+ * forward. `stream` is the RAW IncomingMessage: the caller reads it (and
66
+ * only it) — the module has already drained every other outcome.
67
+ * `extraHeaders` (PB-12) carries the ADAPTER allowlist — the headers an
68
+ * adapter's answer may hand the browser verbatim (the HTML preview's CSP
69
+ * sandbox, download dispositions, range headers); the cooperative relay
70
+ * branch ignores it. */
71
+ export interface PluginLoopbackResponse {
72
+ status: number
73
+ contentType: string | undefined
74
+ contentLength: string | undefined
75
+ cacheControl: string | undefined
76
+ extraHeaders: Record<string, string>
77
+ stream: IncomingMessage
78
+ }
79
+
80
+ /** The one failure the relay maps onto 502 `loopback-auth`: the cookie
81
+ * exchange could not run (no token URL on this host) or did not yield a
82
+ * cookie (wrong token, a non-redirect answer, a network error). */
83
+ export class LoopbackAuthError extends Error {
84
+ constructor(message: string) {
85
+ super(message)
86
+ this.name = 'LoopbackAuthError'
87
+ }
88
+ }
89
+
90
+ function isLoopbackAuthError(error: unknown): error is LoopbackAuthError {
91
+ return error instanceof LoopbackAuthError
92
+ }
93
+
94
+ export interface CreatePluginLoopbackOptions {
95
+ /** The loopback bind, read live per request (a restarted web server may
96
+ * have moved; a snapshot would keep dialing the old port). */
97
+ getTarget: () => PluginLoopbackTarget
98
+ /** The authenticated token URL (the lan-gate wiring's
99
+ * `connection.authenticatedUrl(protocol//127.0.0.1:port)` shape), read
100
+ * live per exchange; `undefined` — a host without the method — means the
101
+ * cookie can never be obtained and every bridged call fails
102
+ * `loopback-auth`. */
103
+ getTokenUrl: () => string | undefined
104
+ }
105
+
106
+ export interface PluginLoopback {
107
+ /** The cookie the tunnel executor (PB-10) needs to write its OWN upgrade
108
+ * request — a raw socket dial, which cannot ride this module's `request`
109
+ * (it owns the cookie internally). Resolves the current pair, exchanging
110
+ * once when none is cached; empty string means the host has no token URL
111
+ * and the tunnel cannot authenticate. The cookie stays in memory. */
112
+ cookieHeader(): Promise<string>
113
+ /** One validated request against the local plugin route. `path` arrives
114
+ * undecoded and `query` is the RELAY-CHECKED serialization (the caller
115
+ * never passes raw wire input — the parse-once discipline). `body` (PB-9)
116
+ * is the relay request's body streamed verbatim (an IncomingMessage or a
117
+ * byte stream), `null` for bodyless methods; it is pumped into the
118
+ * loopback request as-is — sizing it is the ROUTE's job, transport is
119
+ * this module's. Throws {@link LoopbackAuthError} when the cookie cannot
120
+ * be obtained; every other failure (target unreachable, the signal
121
+ * aborted, the body dying mid-flight) rejects with the underlying error
122
+ * and is the caller's `internal`/hang-up case. */
123
+ request(
124
+ method: string,
125
+ path: string,
126
+ query: string,
127
+ headers: PluginLoopbackHeaders,
128
+ body: import('node:http').IncomingMessage | import('node:stream').Readable | null,
129
+ signal: AbortSignal,
130
+ ): Promise<PluginLoopbackResponse>
131
+ }
132
+
133
+ /** One raw loopback request that never follows redirects and resolves with
134
+ * the raw IncomingMessage (the caller drains or streams it). `body`, when
135
+ * given, is pumped into the request verbatim (PB-9's write forwarding); a
136
+ * body dying mid-flight destroys the request, which rejects the promise.
137
+ * The loosened certificate verification (PB-3-fix) applies ONLY to a
138
+ * literal `127.0.0.1` hostname — the plugin hop the wiring pins — never to
139
+ * the token exchange when its URL names anything else. */
140
+ function rawRequest(
141
+ target: { protocol: string; hostname: string; port: number },
142
+ method: string,
143
+ pathWithQuery: string,
144
+ headers: Record<string, string>,
145
+ body: import('node:http').IncomingMessage | import('node:stream').Readable | null,
146
+ signal: AbortSignal,
147
+ ): Promise<IncomingMessage> {
148
+ return new Promise((resolve, reject) => {
149
+ const transport = target.protocol === 'https:' ? https : http
150
+ // The signal reaching request() tears the socket down on abort — the
151
+ // sub-client's hang-up propagates here without extra wiring.
152
+ const request = transport.request(
153
+ {
154
+ protocol: target.protocol,
155
+ hostname: target.hostname,
156
+ port: target.port,
157
+ method,
158
+ path: pathWithQuery,
159
+ headers,
160
+ // 0.2.1 serves self-signed https; the dial is the literal loopback
161
+ // address (see the module comment), so certificate identity carries
162
+ // no information to verify — and ONLY there: any other hostname
163
+ // (the token URL's, should the wiring ever point it elsewhere) keeps
164
+ // full verification.
165
+ ...(target.protocol === 'https:' && target.hostname === '127.0.0.1' ? { rejectUnauthorized: false } : {}),
166
+ signal,
167
+ },
168
+ resolve,
169
+ )
170
+ request.on('error', reject)
171
+ if (body === null) {
172
+ request.end()
173
+ return
174
+ }
175
+ // The body streams through as-is: node's pipe owns the backpressure and
176
+ // ends the request when the body ends. A source error (the relay
177
+ // request's socket dying) tears the loopback request down, which
178
+ // rejects through the handler above.
179
+ body.on('error', (error: Error) => {
180
+ request.destroy(error)
181
+ })
182
+ body.pipe(request)
183
+ })
184
+ }
185
+
186
+ /** The `name=value` pair of the FIRST set-cookie header, or undefined. */
187
+ function cookiePairOf(setCookies: string[] | undefined): string | undefined {
188
+ const first = setCookies?.[0]
189
+ if (first === undefined) return undefined
190
+ const pair = first.split(';', 1)[0] ?? ''
191
+ return pair.includes('=') && pair !== '' ? pair : undefined
192
+ }
193
+
194
+ /**
195
+ * Build the loopback executor. The cookie lives only in this closure.
196
+ */
197
+ export function createPluginLoopback(options: CreatePluginLoopbackOptions): PluginLoopback {
198
+ let cookie: string | undefined
199
+
200
+ /** One `GET <tokenUrl>` (never redirected — node:http already does not
201
+ * follow): a 3xx carrying a set-cookie yields the pair; anything else is
202
+ * an exchange failure. The token response body is always drained and
203
+ * discarded — it is the DSH homepage, none of this module's business. */
204
+ const exchangeCookie = async (signal: AbortSignal): Promise<void> => {
205
+ const tokenUrl = options.getTokenUrl()
206
+ if (tokenUrl === undefined) throw new LoopbackAuthError('this host exposes no authenticated token URL')
207
+ let url: URL
208
+ try {
209
+ url = new URL(tokenUrl)
210
+ } catch {
211
+ throw new LoopbackAuthError('the token URL does not parse')
212
+ }
213
+ let response: IncomingMessage
214
+ try {
215
+ response = await rawRequest(
216
+ { protocol: url.protocol, hostname: url.hostname, port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80) },
217
+ 'GET',
218
+ `${url.pathname}${url.search}`,
219
+ { accept: 'text/html' },
220
+ null,
221
+ signal,
222
+ )
223
+ } catch (error) {
224
+ if (signal.aborted) throw error
225
+ throw new LoopbackAuthError(`the token exchange request failed: ${error instanceof Error ? error.message : String(error)}`)
226
+ }
227
+ const status = response.statusCode ?? 0
228
+ const pair = cookiePairOf(response.headers['set-cookie'])
229
+ response.resume()
230
+ if (status >= 300 && status < 400 && pair !== undefined) {
231
+ cookie = pair
232
+ return
233
+ }
234
+ throw new LoopbackAuthError(`the token exchange answered ${status}${pair === undefined ? ' without a cookie' : ''}`)
235
+ }
236
+
237
+ return {
238
+ async cookieHeader() {
239
+ if (cookie === undefined) await exchangeCookie(new AbortController().signal)
240
+ return cookie ?? ''
241
+ },
242
+ async request(method, path, query, headers, body, signal) {
243
+ const target = options.getTarget()
244
+ const pathWithQuery = query === '' ? path : `${path}?${query}`
245
+ // A body cannot be replayed: the first attempt pumps it into the
246
+ // loopback socket, and a 401 arriving afterwards leaves nothing to
247
+ // retry with. So a body-bearing request WARMS the cookie first (one
248
+ // exchange, no plugin request), and a 401 after that travels verbatim
249
+ // — the plugin's own auth answer, like any other status. Bodyless
250
+ // requests keep the full 401 → re-exchange → retry ladder.
251
+ if (body !== null && cookie === undefined) {
252
+ await exchangeCookie(signal)
253
+ }
254
+ const attempt = async (): Promise<IncomingMessage> => {
255
+ const requestHeaders: Record<string, string> = { host: `127.0.0.1:${target.port}` }
256
+ if (headers.accept !== undefined) requestHeaders.accept = headers.accept
257
+ if (headers.range !== undefined) requestHeaders.range = headers.range
258
+ // PB-9: the forwarded body's content type — the ONE body header the
259
+ // bridge passes through.
260
+ if (headers.contentType !== undefined) requestHeaders['content-type'] = headers.contentType
261
+ // The cookie rides ONLY here. An absent cookie still attempts the
262
+ // route — a plugin route that skipped DSH auth answers honestly.
263
+ if (cookie !== undefined) requestHeaders.cookie = cookie
264
+ return rawRequest(
265
+ { protocol: target.protocol, hostname: '127.0.0.1', port: target.port },
266
+ method,
267
+ pathWithQuery,
268
+ requestHeaders,
269
+ body,
270
+ signal,
271
+ )
272
+ }
273
+ let response = await attempt()
274
+ if (response.statusCode === 401 && body === null) {
275
+ // Stale or missing cookie: exchange ONCE and retry. A 401 after the
276
+ // fresh cookie is the plugin's own answer — it travels to the
277
+ // sub-client verbatim, like any other status.
278
+ response.resume()
279
+ cookie = undefined
280
+ await exchangeCookie(signal)
281
+ response = await attempt()
282
+ }
283
+ return {
284
+ status: response.statusCode ?? 0,
285
+ contentType: typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : undefined,
286
+ contentLength: typeof response.headers['content-length'] === 'string' ? response.headers['content-length'] : undefined,
287
+ cacheControl: typeof response.headers['cache-control'] === 'string' ? response.headers['cache-control'] : undefined,
288
+ // PB-12: the adapter allowlist — the HTML preview's CSP sandbox, a
289
+ // download's content-disposition, range headers. Collected here so
290
+ // the adapter forwards can hand them to the browser; the relay's
291
+ // cooperative branch ignores the field.
292
+ extraHeaders: extraAdapterHeadersOf(response),
293
+ stream: response,
294
+ }
295
+ },
296
+ }
297
+ }
298
+
299
+ export { isLoopbackAuthError }