@linxin666/dsh-remote-web-ui 0.2.9 → 0.3.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 (83) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +41 -8
  3. package/README.zh.md +7 -2
  4. package/lib/client.js +65 -34
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +645 -61
  7. package/lib/mobile.js +874 -64
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/index.d.ts.map +1 -1
  10. package/lib/types/client/index.js +35 -5
  11. package/lib/types/client/remote-channel.d.ts +4 -5
  12. package/lib/types/client/remote-channel.d.ts.map +1 -1
  13. package/lib/types/client/remote-channel.js +13 -28
  14. package/lib/types/http.d.ts +37 -8
  15. package/lib/types/http.d.ts.map +1 -1
  16. package/lib/types/http.js +76 -11
  17. package/lib/types/index.d.ts.map +1 -1
  18. package/lib/types/index.js +25 -1
  19. package/lib/types/mobile/api.d.ts +57 -0
  20. package/lib/types/mobile/api.d.ts.map +1 -1
  21. package/lib/types/mobile/api.js +29 -0
  22. package/lib/types/mobile/markdown.d.ts +7 -1
  23. package/lib/types/mobile/markdown.d.ts.map +1 -1
  24. package/lib/types/mobile/markdown.js +16 -7
  25. package/lib/types/mobile/markdown.test.js +8 -0
  26. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  27. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  28. package/lib/types/mobile/mobile-styles.js +325 -6
  29. package/lib/types/mobile/views/ChatView.d.ts +10 -0
  30. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  31. package/lib/types/mobile/views/ChatView.js +205 -8
  32. package/lib/types/mobile/views/ChatView.stream.test.d.ts +2 -0
  33. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +1 -0
  34. package/lib/types/mobile/views/ChatView.stream.test.js +205 -0
  35. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  36. package/lib/types/mobile/views/WorkspaceView.js +46 -5
  37. package/lib/types/mobile/views/WorkspaceView.test.js +129 -2
  38. package/lib/types/mobile-api.d.ts +9 -3
  39. package/lib/types/mobile-api.d.ts.map +1 -1
  40. package/lib/types/mobile-api.js +61 -16
  41. package/lib/types/mobile-pending.d.ts +38 -0
  42. package/lib/types/mobile-pending.d.ts.map +1 -0
  43. package/lib/types/mobile-pending.js +59 -0
  44. package/lib/types/paired-model-catalog.d.ts +17 -0
  45. package/lib/types/paired-model-catalog.d.ts.map +1 -0
  46. package/lib/types/paired-model-catalog.js +252 -0
  47. package/lib/types/pairing.d.ts +13 -4
  48. package/lib/types/pairing.d.ts.map +1 -1
  49. package/lib/types/pairing.js +13 -5
  50. package/lib/types/remote-api.d.ts.map +1 -1
  51. package/lib/types/remote-channel-boot.d.ts +26 -0
  52. package/lib/types/remote-channel-boot.d.ts.map +1 -0
  53. package/lib/types/remote-channel-boot.js +128 -0
  54. package/lib/types/remote-channel-rules.d.ts +40 -0
  55. package/lib/types/remote-channel-rules.d.ts.map +1 -0
  56. package/lib/types/remote-channel-rules.js +32 -0
  57. package/lib/types/routes.d.ts.map +1 -1
  58. package/lib/types/routes.js +12 -23
  59. package/lib/types/uuid-polyfill.d.ts +10 -0
  60. package/lib/types/uuid-polyfill.d.ts.map +1 -0
  61. package/lib/types/uuid-polyfill.js +27 -0
  62. package/package.json +1 -1
  63. package/src/client/index.ts +41 -5
  64. package/src/client/remote-channel.ts +18 -28
  65. package/src/http.ts +87 -12
  66. package/src/index.ts +27 -2
  67. package/src/mobile/api.ts +91 -0
  68. package/src/mobile/markdown.test.ts +9 -0
  69. package/src/mobile/markdown.ts +17 -7
  70. package/src/mobile/mobile-styles.ts +326 -6
  71. package/src/mobile/views/ChatView.stream.test.tsx +231 -0
  72. package/src/mobile/views/ChatView.tsx +328 -10
  73. package/src/mobile/views/WorkspaceView.test.tsx +163 -2
  74. package/src/mobile/views/WorkspaceView.tsx +152 -26
  75. package/src/mobile-api.ts +59 -17
  76. package/src/mobile-pending.ts +88 -0
  77. package/src/paired-model-catalog.ts +224 -0
  78. package/src/pairing.ts +13 -5
  79. package/src/remote-api.ts +0 -1
  80. package/src/remote-channel-boot.ts +131 -0
  81. package/src/remote-channel-rules.ts +62 -0
  82. package/src/routes.ts +13 -24
  83. package/src/uuid-polyfill.ts +28 -0
@@ -10,7 +10,7 @@
10
10
  import { z } from 'zod';
11
11
  import { UnknownLanAddressError } from "./pairing.js";
12
12
  import { isLoopbackClient, readCookie } from "./gate.js";
13
- import { readBoundedJson, writeJson } from "./http.js";
13
+ import { readJsonBody, writeJson } from "./http.js";
14
14
  /**
15
15
  * Browser-trust fence for the /api/pair routes, mirroring the connection
16
16
  * package's internal fence semantics (Host/Origin based, DNS-rebinding and
@@ -113,25 +113,16 @@ export const revokePayloadSchema = z.object({
113
113
  });
114
114
  export const pairActionPayloadSchema = z.object({}).passthrough();
115
115
  /**
116
- * Parse a pair request body through schema. A missing/empty or non-object
117
- * body is treated as an empty object (the same way the previous manual reads
118
- * yielded absent fields), and a value that fails the schema returns
119
- * `undefined` so the caller can answer with the existing error shape.
116
+ * Parse a pair request body through schema. A missing/empty, unparseable
117
+ * or non-object body (shared readJsonBody with objectOnly yields null for
118
+ * all of them) is treated as an empty object — the desktop stop/heartbeat
119
+ * send no body — and a value that fails the schema returns `undefined` so
120
+ * the caller can answer with the existing error shape.
120
121
  */
121
122
  function parsePairPayload(schema, body) {
122
123
  const result = schema.safeParse(body ?? {});
123
124
  return result.success ? result.data : undefined;
124
125
  }
125
- /** Read a request body up to MAX_BODY_BYTES and parse it as JSON (undefined on failure). */
126
- async function readJsonBody(req) {
127
- try {
128
- const parsed = await readBoundedJson(req, MAX_BODY_BYTES);
129
- return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
130
- }
131
- catch {
132
- return undefined;
133
- }
134
- }
135
126
  /** The SSE fan-out for desktop panel status. */
136
127
  export class PairingEventsStream {
137
128
  streams = new Set();
@@ -242,7 +233,7 @@ export function makeRoutes(deps) {
242
233
  writeJson(res, 403, { ok: false, code: 'forbidden' });
243
234
  return;
244
235
  }
245
- const body = await readJsonBody(req);
236
+ const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true });
246
237
  const payload = parsePairPayload(issuePayloadSchema, body);
247
238
  if (payload === undefined) {
248
239
  writeJson(res, 400, { ok: false, code: 'bad-payload' });
@@ -292,7 +283,7 @@ export function makeRoutes(deps) {
292
283
  writeJson(res, 429, { ok: false, code: 'rate-limited' });
293
284
  return;
294
285
  }
295
- const body = await readJsonBody(req);
286
+ const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true });
296
287
  const payload = parsePairPayload(acceptPayloadSchema, body);
297
288
  if (payload === undefined) {
298
289
  writeJson(res, 400, { ok: false, code: 'bad-payload' });
@@ -307,13 +298,11 @@ export function makeRoutes(deps) {
307
298
  // No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
308
299
  // work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
309
300
  // top-level navigations working while blocking cross-site subrequests.
310
- res.writeHead(200, {
311
- 'content-type': 'application/json; charset=utf-8',
301
+ writeJson(res, 200, { ok: true, deviceId: result.deviceId }, {
312
302
  'set-cookie': [
313
303
  `${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
314
304
  ],
315
305
  });
316
- res.end(JSON.stringify({ ok: true, deviceId: result.deviceId }));
317
306
  };
318
307
  const handleStop = async (req, res) => {
319
308
  if (!requireMethod(req, res, 'POST'))
@@ -322,7 +311,7 @@ export function makeRoutes(deps) {
322
311
  writeJson(res, 403, { ok: false, code: 'forbidden' });
323
312
  return;
324
313
  }
325
- const body = await readJsonBody(req);
314
+ const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true });
326
315
  if (parsePairPayload(pairActionPayloadSchema, body) === undefined) {
327
316
  writeJson(res, 400, { ok: false, code: 'bad-payload' });
328
317
  return;
@@ -337,7 +326,7 @@ export function makeRoutes(deps) {
337
326
  writeJson(res, 403, { ok: false, code: 'forbidden' });
338
327
  return;
339
328
  }
340
- const body = await readJsonBody(req);
329
+ const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true });
341
330
  const payload = parsePairPayload(revokePayloadSchema, body);
342
331
  if (payload === undefined) {
343
332
  writeJson(res, 400, { ok: false, code: 'bad-payload' });
@@ -357,7 +346,7 @@ export function makeRoutes(deps) {
357
346
  writeJson(res, 403, { ok: false, code: 'forbidden' });
358
347
  return;
359
348
  }
360
- const body = await readJsonBody(req);
349
+ const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true });
361
350
  if (parsePairPayload(pairActionPayloadSchema, body) === undefined) {
362
351
  writeJson(res, 400, { ok: false, code: 'bad-payload' });
363
352
  return;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Polyfill for crypto.randomUUID for non-secure contexts (LAN HTTP)
3
+ * where the browser does not expose crypto.randomUUID.
4
+ *
5
+ * The script string is injected as an inline <script> in the desktop HTML
6
+ * head so the SDK's mintRpcId() sees a working randomUUID even on plain
7
+ * http:// origins (#1024).
8
+ */
9
+ export declare const UUID_POLYFILL_SCRIPT: string;
10
+ //# sourceMappingURL=uuid-polyfill.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uuid-polyfill.d.ts","sourceRoot":"","sources":["../../src/uuid-polyfill.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,eAAO,MAAM,oBAAoB,QAkBvB,CAAA"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Polyfill for crypto.randomUUID for non-secure contexts (LAN HTTP)
3
+ * where the browser does not expose crypto.randomUUID.
4
+ *
5
+ * The script string is injected as an inline <script> in the desktop HTML
6
+ * head so the SDK's mintRpcId() sees a working randomUUID even on plain
7
+ * http:// origins (#1024).
8
+ */
9
+ export const UUID_POLYFILL_SCRIPT = [
10
+ '(function(){',
11
+ 'var g=typeof globalThis!=="undefined"?globalThis:typeof window!=="undefined"?window:self;',
12
+ 'if(typeof g.crypto==="undefined"){g.crypto={};}',
13
+ 'if(typeof g.crypto.randomUUID!=="function"){',
14
+ 'g.crypto.randomUUID=function(){',
15
+ 'var a=new Uint8Array(16);',
16
+ 'if(g.crypto.getRandomValues){g.crypto.getRandomValues(a);}',
17
+ 'else{for(var j=0;j<16;j++){a[j]=Math.random()*256|0;}}',
18
+ 'a[6]=(a[6]&0x0f)|0x40;',
19
+ 'a[8]=(a[8]&0x3f)|0x80;',
20
+ 'var h="",x="0123456789abcdef";',
21
+ 'for(var i=0;i<16;i++){h+=x[a[i]>>4]+x[a[i]&0x0f];',
22
+ 'if(i===3||i===5||i===7||i===9)h+="-";}',
23
+ 'return h;',
24
+ '};',
25
+ '}',
26
+ '})();',
27
+ ].join('');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Scan-to-pair mobile and PC remote control for the dsh web GUI: QR entry beside the settings button, one-time pairing tokens, a gated /remote/api desktop channel, live device status, and revocable sessions",
4
- "version": "0.2.9",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -24,7 +24,14 @@ import { RemoteSettingsCard, RemoteSettingsCardController, type RemoteSettings }
24
24
  import { en, zh, type RemoteKey } from './locales.ts'
25
25
  import { PAIR_FAILED_MARKER, runPairBootFlow } from './deep-link.ts'
26
26
  import { readPairGatePolicy, sendHeartbeat } from './pair-api.ts'
27
- import { channelTransition, installRemoteChannel, isLoopbackHostname, remoteChannelRequired } from './remote-channel.ts'
27
+ import {
28
+ channelTransition,
29
+ installRemoteChannel,
30
+ isLoopbackHostname,
31
+ remoteChannelRequired,
32
+ REMOTE_CHANNEL_BOOT_GLOBAL,
33
+ type RemoteChannelBootSeat,
34
+ } from './remote-channel.ts'
28
35
  import { FenceNotice } from './FenceNotice.tsx'
29
36
 
30
37
  export type { RemoteEntryProps } from './RemoteEntry.tsx'
@@ -228,16 +235,41 @@ export function apply(ctx: ClientContext): void {
228
235
  settingsScope.getSnapshot(),
229
236
  hostPairingPolicy,
230
237
  )
238
+ // The parse-time boot patch (issue #987), when the served index carried
239
+ // it: already installed before any boot entry ran, so adopting its seat
240
+ // beats patching a second time (which would double-rewrite onto
241
+ // /remote/remote/...).
242
+ const bootSeat = (): RemoteChannelBootSeat | undefined =>
243
+ (window as unknown as Record<string, RemoteChannelBootSeat | undefined>)[REMOTE_CHANNEL_BOOT_GLOBAL]
231
244
  const syncChannel = (): void => {
232
245
  const transition = channelTransition(channelActive(), disposeChannel !== undefined)
233
246
  if (transition === 'install') {
234
- disposeChannel = ctx.effect(() => {
235
- const restore = installRemoteChannel(window, { onUnpaired: handleUnpaired, onPaired: hideFenceNotice })
236
- return restore
237
- }, 'remote-web-ui: remote desktop channel')
247
+ const seat = bootSeat()
248
+ if (seat !== undefined) {
249
+ seat.onUnpaired = handleUnpaired
250
+ seat.onPaired = hideFenceNotice
251
+ // Replay a signal raised before adoption (early unpaired responses).
252
+ if (seat.pendingUnpaired) {
253
+ seat.pendingUnpaired = false
254
+ handleUnpaired()
255
+ }
256
+ disposeChannel = ctx.effect(() => () => {
257
+ seat.onUnpaired = null
258
+ seat.onPaired = null
259
+ }, 'remote-web-ui: remote desktop channel (boot patch)')
260
+ } else {
261
+ disposeChannel = ctx.effect(() => {
262
+ const restore = installRemoteChannel(window, { onUnpaired: handleUnpaired, onPaired: hideFenceNotice })
263
+ return restore
264
+ }, 'remote-web-ui: remote desktop channel')
265
+ }
238
266
  } else if (transition === 'retire' && disposeChannel !== undefined) {
239
267
  disposeChannel()
240
268
  disposeChannel = undefined
269
+ // Retire the provisional parse-time install with the channel: the
270
+ // desktop now rides plain /api, so the rewrite must go (its seat
271
+ // removes the global; a later re-activation patches afresh).
272
+ bootSeat()?.restore()
241
273
  // Retire the notice with the channel: once requirePairingForLan turns
242
274
  // off (or the plugin is disabled) the desktop rides plain /api again,
243
275
  // so an unpaired notice raised while the channel was briefly active
@@ -245,6 +277,10 @@ export function apply(ctx: ClientContext): void {
245
277
  // installed channel is the only path that raises the notice, so with
246
278
  // the channel gone nothing can re-raise it (issue #808).
247
279
  hideFenceNotice()
280
+ } else if (transition === 'none' && !channelActive()) {
281
+ // The channel was never adopted (policy settled to off before apply
282
+ // ran): the provisional boot patch still retires.
283
+ bootSeat()?.restore()
248
284
  }
249
285
  }
250
286
  settingsScope.subscribe(syncChannel)
@@ -24,27 +24,17 @@
24
24
  * the given window and returns their restore.
25
25
  */
26
26
 
27
- /** The gated mirror prefix (must match src/remote-methods.ts). */
28
- export const REMOTE_PREFIX = '/remote'
27
+ import {
28
+ REMOTE_API_PREFIX,
29
+ REMOTE_CHANNEL_RULES,
30
+ REMOTE_PREFIX,
31
+ } from '../remote-channel-rules.ts'
29
32
 
30
- /** Connection-plugin method prefix under the gated channel. */
31
- export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`
33
+ export { REMOTE_API_PREFIX, REMOTE_PREFIX }
34
+ export type { RemoteChannelBootSeat } from '../remote-channel-rules.ts'
35
+ export { REMOTE_CHANNEL_BOOT_GLOBAL } from '../remote-channel-rules.ts'
32
36
 
33
- const API_PREFIX = '/api/'
34
- const PAIR_PREFIX = '/api/pair/'
35
- const UPDATE_PREFIX = '/api/update/'
36
- const DESKTOP_LAUNCHER_PREFIX = '/api/dsh-desktop-launcher'
37
- const SETTINGS_BRIDGE_PREFIX = '/api/dsh-web-ui-settings'
38
- const SIDEBAR_PREFIX = '/sidebar/'
39
- const GIT_PREFIX = '/git/'
40
- const PET_PREFIX = '/pet/'
41
- const WS_PATHS = new Set([
42
- '/api/events.mux',
43
- '/api/events.host',
44
- '/sidebar/ws/terminal',
45
- '/sidebar/ws/agent-terminals',
46
- '/api/dsh-ssh/terminal',
47
- ])
37
+ const RULES = REMOTE_CHANNEL_RULES
48
38
 
49
39
  /** Minimal settings snapshot used by the remote channel decision. */
50
40
  export interface RemoteChannelSettingsSnapshot {
@@ -85,14 +75,14 @@ export function isLoopbackHostname(hostname: string): boolean {
85
75
  * @param pathname - the request URL pathname.
86
76
  */
87
77
  export function shouldRewriteFetchPath(pathname: string): boolean {
88
- if (pathname.startsWith(PAIR_PREFIX)) return false
89
- if (pathname.startsWith(UPDATE_PREFIX)) return false
90
- if (pathname === DESKTOP_LAUNCHER_PREFIX || pathname.startsWith(`${DESKTOP_LAUNCHER_PREFIX}/`)) return false
91
- if (pathname === SETTINGS_BRIDGE_PREFIX || pathname.startsWith(`${SETTINGS_BRIDGE_PREFIX}/`)) return false
92
- if (pathname.startsWith(API_PREFIX)) return true
93
- if (pathname.startsWith(SIDEBAR_PREFIX) || pathname === '/sidebar') return true
94
- if (pathname.startsWith(GIT_PREFIX) || pathname === '/git') return true
95
- if (pathname.startsWith(PET_PREFIX) || pathname === '/pet') return true
78
+ if (pathname.startsWith(RULES.pairPrefix)) return false
79
+ if (pathname.startsWith(RULES.updatePrefix)) return false
80
+ if (pathname === RULES.desktopLauncherPrefix || pathname.startsWith(`${RULES.desktopLauncherPrefix}/`)) return false
81
+ if (pathname === RULES.settingsBridgePrefix || pathname.startsWith(`${RULES.settingsBridgePrefix}/`)) return false
82
+ if (pathname.startsWith(RULES.apiPrefix)) return true
83
+ if (pathname.startsWith(RULES.sidebarPrefix) || pathname === '/sidebar') return true
84
+ if (pathname.startsWith(RULES.gitPrefix) || pathname === '/git') return true
85
+ if (pathname.startsWith(RULES.petPrefix) || pathname === '/pet') return true
96
86
  return false
97
87
  }
98
88
 
@@ -101,7 +91,7 @@ export function shouldRewriteFetchPath(pathname: string): boolean {
101
91
  * @param pathname - the WebSocket URL pathname.
102
92
  */
103
93
  export function shouldRewriteWsPath(pathname: string): boolean {
104
- return WS_PATHS.has(pathname)
94
+ return RULES.wsPaths.includes(pathname)
105
95
  }
106
96
 
107
97
  /** The gated twin of one fenced path (`/remote` + original pathname). */
package/src/http.ts CHANGED
@@ -1,21 +1,35 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/http.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
- * Shared HTTP helpers for the route families: one JSON writer and one
3
- * bounded JSON body reader. Previously copy-pasted across routes.ts,
4
- * update-routes.ts, and mobile-api.ts with drifting failure contracts.
3
+ * Shared JSON body/response helpers for the host route families: one strict
4
+ * bounded body reader, one lenient bounded body reader, one JSON object
5
+ * narrow, and one JSON writer. Previously these were copy-pasted across the
6
+ * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
+ * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
+ * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
9
+ *
10
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
11
+ * edit this shared source and re-run the sync instead of editing a copy.
12
+ * Consumer code is migrated onto it in follow-up waves; no call site changes
13
+ * belong in the same change as its introduction.
14
+ * @module dsh-web-ui-shared/host/http
5
15
  */
6
16
 
7
- import type { IncomingMessage, ServerResponse } from 'node:http'
17
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
8
18
 
9
- /** One JSON response. */
10
- export function writeJson(res: ServerResponse, status: number, body: unknown): void {
11
- const payload = JSON.stringify(body)
12
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
13
- res.end(payload)
14
- }
19
+ /** Default body cap for readJsonBody: 64 KiB. */
20
+ const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024
21
+
22
+ /** Family-default JSON response headers; callers may append or override. */
23
+ const JSON_HEADERS = {
24
+ 'content-type': 'application/json; charset=utf-8',
25
+ 'referrer-policy': 'no-referrer',
26
+ } satisfies OutgoingHttpHeaders
15
27
 
16
28
  /**
17
- * Read a request body up to maxBytes and parse it as JSON.
18
- * @throws 'body too large' beyond the cap, or the JSON.parse error.
29
+ * Strict bounded body reader: parse a request body of at most maxBytes as
30
+ * JSON.
31
+ * @throws 'body too large' past the cap, or the JSON.parse error for an
32
+ * invalid or empty payload.
19
33
  */
20
34
  export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
21
35
  const chunks: Buffer[] = []
@@ -28,3 +42,64 @@ export async function readBoundedJson(req: IncomingMessage, maxBytes: number): P
28
42
  }
29
43
  return JSON.parse(Buffer.concat(chunks).toString('utf8'))
30
44
  }
45
+
46
+ /**
47
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
48
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
49
+ * Overflow destroys the request instead of draining the remainder (no drain
50
+ * call, matching the current repo-wide behavior); callers must not keep
51
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
52
+ * also yield null.
53
+ */
54
+ export async function readJsonBody(
55
+ req: IncomingMessage,
56
+ opts: { maxBytes?: number; objectOnly?: boolean } = {},
57
+ ): Promise<unknown | null> {
58
+ const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES
59
+ const chunks: Buffer[] = []
60
+ let size = 0
61
+ for await (const chunk of req) {
62
+ const buffer = chunk as Buffer
63
+ size += buffer.length
64
+ if (size > maxBytes) {
65
+ req.destroy()
66
+ return null
67
+ }
68
+ chunks.push(buffer)
69
+ }
70
+ const text = Buffer.concat(chunks).toString('utf8')
71
+ if (text === '') return null
72
+ try {
73
+ const parsed: unknown = JSON.parse(text)
74
+ if (opts.objectOnly && !isJsonObject(parsed)) return null
75
+ return parsed
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** Whether a value is a JSON object: typeof object, not null, not an array. */
82
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
83
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
84
+ }
85
+
86
+ /** Narrow a value to a JSON object, or undefined when it is not one. */
87
+ export function asJsonObject(value: unknown): Record<string, unknown> | undefined {
88
+ return isJsonObject(value) ? value : undefined
89
+ }
90
+
91
+ /**
92
+ * Write one JSON response. Default headers are the family defaults
93
+ * (content-type and referrer-policy); caller headers are appended or
94
+ * override them.
95
+ */
96
+ export function writeJson(
97
+ res: ServerResponse,
98
+ status: number,
99
+ body: unknown,
100
+ headers: OutgoingHttpHeaders = {},
101
+ ): void {
102
+ const payload = JSON.stringify(body)
103
+ res.writeHead(status, { ...JSON_HEADERS, ...headers })
104
+ res.end(payload)
105
+ }
package/src/index.ts CHANGED
@@ -23,8 +23,10 @@ import { RemoteWebUiPairing } from './pairing-access.ts'
23
23
  import { isTrustedApiRequest, makeRoutes } from './routes.ts'
24
24
  import { makeMobileRoutes } from './mobile-routes.ts'
25
25
  import { makeMobileApiRoutes } from './mobile-api.ts'
26
+ import { PendingTracker } from './mobile-pending.ts'
27
+ import { makePairedModelCatalogRoutes } from './paired-model-catalog.ts'
26
28
  import { makeRemoteApiRoutes, makeRemoteApiUpgradeRoutes } from './remote-api.ts'
27
- import { anyExposed, claimPostureKey, postureTargets, probePosture, releasePostureKey } from './posture.ts'
29
+ import { claimPostureKey, postureTargets, probePosture, releasePostureKey } from './posture.ts'
28
30
  import { lanIPv4Addresses } from './lan.ts'
29
31
  import { TunnelManager, type TunnelInfo } from './tunnel.ts'
30
32
  import {
@@ -37,6 +39,8 @@ import {
37
39
  } from './update.ts'
38
40
  import { makeUpdateRoutes } from './update-routes.ts'
39
41
  import { mountOnce } from './mount-once.ts'
42
+ import { REMOTE_CHANNEL_BOOT_SCRIPT } from './remote-channel-boot.ts'
43
+ import { UUID_POLYFILL_SCRIPT } from './uuid-polyfill.ts'
40
44
 
41
45
  declare module '@deepseek-ai/cordis' {
42
46
  interface Events {
@@ -356,8 +360,9 @@ function applyImpl(ctx: Context, config?: Config): void {
356
360
  ...makeRoutes({ service, lanAddresses, requirePairingForLan: () => resolve().requirePairingForLan }),
357
361
  ...makeMobileRoutes(),
358
362
  ...(apiProxy !== undefined
359
- ? makeMobileApiRoutes({ service, apiProxy, mobileEnterToSend: () => resolve().mobileEnterToSend })
363
+ ? makeMobileApiRoutes({ service, apiProxy, pendingTracker: new PendingTracker(), mobileEnterToSend: () => resolve().mobileEnterToSend })
360
364
  : []),
365
+ ...(apiProxy !== undefined ? makePairedModelCatalogRoutes({ service, apiProxy, lanAddresses }) : []),
361
366
  // The remote desktop channel: policy-gated `/remote` prefix that
362
367
  // re-issues fenced paths to loopback (see remote-api.ts). The live
363
368
  // requirePairingForLan is re-read per request, same as the gate listener
@@ -482,6 +487,26 @@ function applyImpl(ctx: Context, config?: Config): void {
482
487
  // re-probe unless the target set is unchanged.
483
488
  runPostureProbe()
484
489
  }
490
+ // Inject the crypto.randomUUID polyfill before any other script runs, so that
491
+ // the main bundle doesn't crash on non-secure contexts (LAN HTTP)
492
+ ctx.effect(() => ctx.on('webserver/index-inject', (table) => {
493
+ table.push({ kind: 'script', placement: 'head', text: UUID_POLYFILL_SCRIPT })
494
+ }), 'remote-web-ui: uuid polyfill')
495
+
496
+ // Issue #987: the browser-half channel patch installs at this plugin's
497
+ // boot entry, but dsh-client-connection boots earlier and opens its event
498
+ // streams unrewritten — on a non-loopback origin the SDK fence rejects
499
+ // them and the workspace list never loads. Contribute the rewrite as a
500
+ // parse-time head script so it is active before ANY boot entry runs; the
501
+ // client apply adopts the installed seat instead of patching twice. The
502
+ // row follows the live steady-state decision (enabled + pairing gate); the
503
+ // script itself skips loopback origins.
504
+ ctx.effect(() => ctx.on('webserver/index-inject', (table) => {
505
+ const value = resolve()
506
+ if (!value.enabled || !value.requirePairingForLan) return
507
+ table.push({ kind: 'script', placement: 'head', text: REMOTE_CHANNEL_BOOT_SCRIPT })
508
+ }), 'remote-web-ui: remote channel boot patch')
509
+
485
510
  installSettingsSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
486
511
  setSource: (source) => {
487
512
  current = source
package/src/mobile/api.ts CHANGED
@@ -129,3 +129,94 @@ export async function selectModel(
129
129
  ...(selection.reasoningEffort !== undefined ? { reasoningEffort: selection.reasoningEffort } : {}),
130
130
  })
131
131
  }
132
+
133
+ /* ── pending approvals / questions (#1025) ───────────────────────────── */
134
+
135
+ /** One pending tool approval awaiting the user's decision. */
136
+ export interface PendingApproval {
137
+ approvalId: string
138
+ toolName: string
139
+ callId?: string
140
+ reason?: string
141
+ }
142
+
143
+ /** One pending question group awaiting the user's answer. */
144
+ export interface PendingQuestionItem {
145
+ id: string
146
+ question: string
147
+ detail?: string
148
+ header?: string
149
+ options?: Array<{ label: string; description?: string }>
150
+ multiSelect?: boolean
151
+ }
152
+
153
+ /** The pending state for one session. */
154
+ export interface PendingState {
155
+ approvals: PendingApproval[]
156
+ questions: PendingQuestionItem[]
157
+ }
158
+
159
+ /** Fetch pending approvals and questions for one session (polling fallback data source). */
160
+ export async function fetchPending(sessionId: string): Promise<PendingState> {
161
+ return await callUnary<PendingState>('mobile.pending', { sessionId })
162
+ }
163
+
164
+ /** Submit an approval decision (allowed-once or rejected). */
165
+ export async function respondApproval(
166
+ sessionId: string,
167
+ approvalId: string,
168
+ outcome: 'allowed-once' | 'rejected',
169
+ ): Promise<void> {
170
+ await callUnary<unknown>('mobile.respond', {
171
+ sessionId,
172
+ type: 'approval',
173
+ approvalId,
174
+ outcome,
175
+ })
176
+ }
177
+
178
+ /** Submit answers to a question group. */
179
+ export async function respondQuestion(
180
+ sessionId: string,
181
+ answers: Array<{ id: string; selected: string[]; custom?: string }>,
182
+ ): Promise<void> {
183
+ await callUnary<unknown>('mobile.respond', {
184
+ sessionId,
185
+ type: 'question',
186
+ answers,
187
+ })
188
+ }
189
+
190
+ /* ── directory browsing / workspace creation (#977) ──────────────────── */
191
+
192
+ /** One entry in a directory listing. */
193
+ export interface DirectoryEntry {
194
+ name: string
195
+ path: string
196
+ hidden: boolean
197
+ }
198
+
199
+ /** The host directory listing result (one level, with ancestor breadcrumbs). */
200
+ export interface DirectoryListing {
201
+ path: string
202
+ home: string
203
+ crumbs: DirectoryEntry[]
204
+ entries: DirectoryEntry[]
205
+ truncated: boolean
206
+ }
207
+
208
+ /** The result of creating a workspace from an existing directory. */
209
+ export interface WorkspaceCreateResult {
210
+ workspace: WorkspaceView
211
+ created: boolean
212
+ }
213
+
214
+ /** Browse one directory level on the host (defaults to home directory). */
215
+ export async function listDirectory(path?: string): Promise<DirectoryListing> {
216
+ return await callUnary<DirectoryListing>('host.listDirectory', path === undefined ? {} : { path })
217
+ }
218
+
219
+ /** Create a workspace from an existing host directory (does not mkdir). */
220
+ export async function createWorkspace(path: string): Promise<WorkspaceCreateResult> {
221
+ return await callUnary<WorkspaceCreateResult>('workspace.create', { path })
222
+ }
@@ -79,4 +79,13 @@ describe('escapeHtml', () => {
79
79
  it('escapes all five HTML-significant characters', () => {
80
80
  expect(escapeHtml('<&>"\'')).toBe('&lt;&amp;&gt;&quot;&#39;')
81
81
  })
82
+
83
+ it('equals the five sequential passes on mixed, pre-escaped and plain input', () => {
84
+ // Single-pass map replacement must never diverge from the sequential
85
+ // five-pass escape (including double-escaping of literal entities).
86
+ expect(escapeHtml('a & b < c > d " e \' f')).toBe('a &amp; b &lt; c &gt; d &quot; e &#39; f')
87
+ expect(escapeHtml('&amp; &lt; &gt;')).toBe('&amp;amp; &amp;lt; &amp;gt;')
88
+ expect(escapeHtml('plain text 123')).toBe('plain text 123')
89
+ expect(escapeHtml('')).toBe('')
90
+ })
82
91
  })
@@ -10,14 +10,24 @@
10
10
  * @module dsh-remote-web-ui/mobile/markdown
11
11
  */
12
12
 
13
- /** Escape HTML special characters. */
13
+ /** HTML special-character map for {@link escapeHtml}. */
14
+ const HTML_ESCAPE_MAP: Record<string, string> = {
15
+ '&': '&amp;',
16
+ '<': '&lt;',
17
+ '>': '&gt;',
18
+ '"': '&quot;',
19
+ "'": '&#39;',
20
+ }
21
+
22
+ /**
23
+ * Escape HTML special characters. One regex pass replaces all five
24
+ * characters through the map; the output is identical to five sequential
25
+ * passes (each replacement string contains none of the escaped characters),
26
+ * and the common no-special-character case scans the string only once
27
+ * instead of five times.
28
+ */
14
29
  export function escapeHtml(text: string): string {
15
- return text
16
- .replace(/&/g, '&amp;')
17
- .replace(/</g, '&lt;')
18
- .replace(/>/g, '&gt;')
19
- .replace(/"/g, '&quot;')
20
- .replace(/'/g, '&#39;')
30
+ return text.replace(/[&<>"']/g, char => HTML_ESCAPE_MAP[char] ?? char)
21
31
  }
22
32
 
23
33
  /**