@linxin666/dsh-remote-web-ui 0.2.4 → 0.2.5

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 (89) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +148 -77
  3. package/README.zh.md +32 -26
  4. package/lib/client.js +602 -67
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +667 -32
  7. package/lib/mobile.js +3 -1
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/FenceNotice.d.ts +21 -0
  10. package/lib/types/client/FenceNotice.d.ts.map +1 -0
  11. package/lib/types/client/FenceNotice.js +10 -0
  12. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  13. package/lib/types/client/RemoteEntry.js +18 -11
  14. package/lib/types/client/RemotePanel.d.ts +10 -4
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +7 -6
  17. package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +3 -1
  20. package/lib/types/client/device-name.d.ts +3 -0
  21. package/lib/types/client/device-name.d.ts.map +1 -0
  22. package/lib/types/client/device-name.js +30 -0
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +48 -0
  25. package/lib/types/client/locales.d.ts +51 -1
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +79 -29
  28. package/lib/types/client/pair-api.d.ts +35 -0
  29. package/lib/types/client/pair-api.d.ts.map +1 -1
  30. package/lib/types/client/pair-api.js +39 -0
  31. package/lib/types/client/remote-channel.d.ts +93 -0
  32. package/lib/types/client/remote-channel.d.ts.map +1 -0
  33. package/lib/types/client/remote-channel.js +246 -0
  34. package/lib/types/dsh-home.d.ts +17 -0
  35. package/lib/types/dsh-home.d.ts.map +1 -0
  36. package/lib/types/dsh-home.js +34 -0
  37. package/lib/types/gate.d.ts.map +1 -1
  38. package/lib/types/gate.js +5 -2
  39. package/lib/types/index.d.ts +19 -12
  40. package/lib/types/index.d.ts.map +1 -1
  41. package/lib/types/index.js +73 -5
  42. package/lib/types/loopback-proxy.d.ts +28 -0
  43. package/lib/types/loopback-proxy.d.ts.map +1 -0
  44. package/lib/types/loopback-proxy.js +112 -0
  45. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  46. package/lib/types/mobile/views/SessionListView.js +6 -1
  47. package/lib/types/mobile-api.d.ts.map +1 -1
  48. package/lib/types/mobile-api.js +6 -2
  49. package/lib/types/mobile-routes.js +1 -1
  50. package/lib/types/pairing.d.ts +63 -11
  51. package/lib/types/pairing.d.ts.map +1 -1
  52. package/lib/types/pairing.js +137 -20
  53. package/lib/types/pairing.persist.test.js +60 -5
  54. package/lib/types/posture.d.ts +75 -0
  55. package/lib/types/posture.d.ts.map +1 -0
  56. package/lib/types/posture.js +116 -0
  57. package/lib/types/remote-api.d.ts +60 -0
  58. package/lib/types/remote-api.d.ts.map +1 -0
  59. package/lib/types/remote-api.js +181 -0
  60. package/lib/types/remote-methods.d.ts +33 -0
  61. package/lib/types/remote-methods.d.ts.map +1 -0
  62. package/lib/types/remote-methods.js +54 -0
  63. package/lib/types/routes.d.ts +4 -0
  64. package/lib/types/routes.d.ts.map +1 -1
  65. package/lib/types/routes.js +33 -4
  66. package/package.json +2 -2
  67. package/src/client/FenceNotice.tsx +43 -0
  68. package/src/client/RemoteEntry.tsx +19 -9
  69. package/src/client/RemotePanel.tsx +83 -10
  70. package/src/client/RemoteSettingsCard.tsx +16 -0
  71. package/src/client/device-name.ts +31 -0
  72. package/src/client/index.ts +46 -0
  73. package/src/client/locales.ts +79 -29
  74. package/src/client/pair-api.ts +60 -0
  75. package/src/client/remote-channel.ts +269 -0
  76. package/src/client/remote.module.css +240 -16
  77. package/src/dsh-home.ts +36 -0
  78. package/src/gate.ts +5 -2
  79. package/src/index.ts +89 -17
  80. package/src/loopback-proxy.ts +126 -0
  81. package/src/mobile/views/SessionListView.tsx +6 -1
  82. package/src/mobile-api.ts +6 -2
  83. package/src/mobile-routes.ts +1 -1
  84. package/src/pairing.persist.test.ts +64 -5
  85. package/src/pairing.ts +167 -20
  86. package/src/posture.ts +154 -0
  87. package/src/remote-api.ts +217 -0
  88. package/src/remote-methods.ts +62 -0
  89. package/src/routes.ts +33 -4
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The remote desktop data channel: `/remote` is this plugin's own prefix, so
3
+ * the paired-device cookie is the access control (exactly like `/m/api`).
4
+ * After that gate, every fenced same-origin path the browser rewrote here is
5
+ * re-issued to 127.0.0.1 as a loopback-shaped request so sibling plugin
6
+ * fences (and the connection plugin's `/api`) accept it — no `--trusted-host`
7
+ * and no per-plugin pairing consult.
8
+ *
9
+ * Security model:
10
+ * - Every request must carry a live paired-device cookie, enforced before
11
+ * any bytes are forwarded and before any host call.
12
+ * - The SDK's loopback-only privileged methods (native dialogs, the settings
13
+ * plane, credentials — the `PRIVILEGED_METHODS` set of client-connection)
14
+ * are denied here. The set is pinned by tests/remote-contract.spec.ts.
15
+ * - `/api/pair/*`, `/api/update/*`, `/api/plugin-manager/*`,
16
+ * `/api/dsh-desktop-launcher/*` and `/api/dsh-web-ui-settings/*` stay physically local.
17
+ * - Everything else is HTTP- or WebSocket-proxied to the local port with
18
+ * Host rewritten, Origin and cookies dropped, and a synthetic same-origin
19
+ * browser marker added after authentication. Plugin loopback fences then
20
+ * pass. The pairing cookie never leaves this process.
21
+ */
22
+
23
+ import type { IncomingMessage, ServerResponse } from 'node:http'
24
+ import type { Duplex } from 'node:stream'
25
+ import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
26
+ import type { PairingService } from './pairing.ts'
27
+ import { readCookie } from './gate.ts'
28
+ import { writeJson } from './http.ts'
29
+ import { proxyLoopbackHttp, proxyLoopbackUpgrade } from './loopback-proxy.ts'
30
+ import {
31
+ DESKTOP_LAUNCHER_PATH,
32
+ LOOPBACK_ONLY_METHODS,
33
+ PLUGIN_MANAGER_PATH,
34
+ REMOTE_API_PATHS,
35
+ REMOTE_PREFIX,
36
+ REMOTE_UPGRADE_PATHS,
37
+ WEB_UI_SETTINGS_BRIDGE_PATH,
38
+ } from './remote-methods.ts'
39
+
40
+ export {
41
+ DESKTOP_LAUNCHER_PATH,
42
+ LOOPBACK_ONLY_METHODS,
43
+ PLUGIN_MANAGER_PATH,
44
+ REMOTE_API_PATHS,
45
+ REMOTE_PREFIX,
46
+ REMOTE_UPGRADE_PATHS,
47
+ WEB_UI_SETTINGS_BRIDGE_PATH,
48
+ } from './remote-methods.ts'
49
+ export { REMOTE_API_PREFIX } from './remote-methods.ts'
50
+
51
+ const ALLOWED_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'])
52
+
53
+ /** Reject traversal and empty segments; allow plugin file-path characters. */
54
+ function isSafeSegment(segment: string): boolean {
55
+ if (segment === '') return false
56
+ let decoded: string
57
+ try {
58
+ decoded = decodeURIComponent(segment)
59
+ } catch {
60
+ return false
61
+ }
62
+ return decoded !== '.' && decoded !== '..' && !decoded.includes('/') && !decoded.includes('\\') && !decoded.includes('\0')
63
+ }
64
+
65
+ /** Route-family dependencies. */
66
+ export interface RemoteApiDeps {
67
+ /** The pairing service (device gate + cookie name). */
68
+ service: PairingService
69
+ /** The local webServer port the loopback proxy connects to. */
70
+ port: number
71
+ }
72
+
73
+ /** One SDK-shaped error envelope (keeps the desktop client's parse path intact). */
74
+ function envelopeError(res: ServerResponse, status: number, rpcId: string, code: string, message: string): void {
75
+ writeJson(res, status, {
76
+ type: 'server-response',
77
+ rpcId,
78
+ result: { ok: false, error: { code, message, details: { issues: [] } } },
79
+ })
80
+ }
81
+
82
+ /**
83
+ * Map `/remote/...` to the inner path, or undefined when the outer path is
84
+ * not a safe rewrite target.
85
+ */
86
+ export function innerPathOf(pathname: string): string | undefined {
87
+ if (pathname === REMOTE_PREFIX || pathname === `${REMOTE_PREFIX}/`) return undefined
88
+ if (!pathname.startsWith(`${REMOTE_PREFIX}/`)) return undefined
89
+ const rest = pathname.slice(REMOTE_PREFIX.length)
90
+ if (!rest.startsWith('/')) return undefined
91
+ const segments = rest.slice(1).split('/')
92
+ if (segments.length === 0 || segments.some(segment => !isSafeSegment(segment))) {
93
+ return undefined
94
+ }
95
+ return rest
96
+ }
97
+
98
+ /**
99
+ * Whether a paired inner path must stay physically local.
100
+ * @returns a denial message, or undefined when the path may be proxied.
101
+ */
102
+ export function loopbackOnlyDenial(innerPath: string): string | undefined {
103
+ if (innerPath === '/api/pair' || innerPath.startsWith('/api/pair/')) {
104
+ return 'pairing endpoints stay loopback-only and stay unreachable from a paired remote desktop'
105
+ }
106
+ if (innerPath === '/api/update' || innerPath.startsWith('/api/update/')) {
107
+ return 'update endpoints stay loopback-only and stay unreachable from a paired remote desktop'
108
+ }
109
+ if (innerPath === PLUGIN_MANAGER_PATH || innerPath.startsWith(`${PLUGIN_MANAGER_PATH}/`)) {
110
+ return 'plugin-manager stays loopback-only and stays unreachable from a paired remote desktop'
111
+ }
112
+ if (innerPath === DESKTOP_LAUNCHER_PATH || innerPath.startsWith(`${DESKTOP_LAUNCHER_PATH}/`)) {
113
+ return 'desktop-launcher endpoints stay loopback-only and stay unreachable from a paired remote desktop'
114
+ }
115
+ if (innerPath === WEB_UI_SETTINGS_BRIDGE_PATH || innerPath.startsWith(`${WEB_UI_SETTINGS_BRIDGE_PATH}/`)) {
116
+ return 'settings-bridge endpoints stay loopback-only and stay unreachable from a paired remote desktop'
117
+ }
118
+ if (!innerPath.startsWith('/api/')) return undefined
119
+ const method = innerPath.slice('/api/'.length)
120
+ if (method !== '' && !method.includes('/') && LOOPBACK_ONLY_METHODS.has(method)) {
121
+ return `${method} is loopback-only and stays unreachable from a paired remote desktop`
122
+ }
123
+ return undefined
124
+ }
125
+
126
+ /**
127
+ * Build the remote desktop channel HTTP routes.
128
+ * @param deps - pairing service + local port.
129
+ * @returns the routes to register on webServer.
130
+ */
131
+ export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
132
+ const { service, port } = deps
133
+
134
+ const handler = (req: IncomingMessage, res: ServerResponse): void => {
135
+ // Cookie gate first — same order as /m/api. Do not buffer an unpaired body.
136
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
137
+ const paired = deviceId !== undefined && service.touchDevice(deviceId)
138
+ if (!paired) {
139
+ req.resume()
140
+ envelopeError(res, 403, 'invalid-request', 'unpaired', 'this device is not paired with the desktop')
141
+ return
142
+ }
143
+
144
+ const method = req.method ?? 'GET'
145
+ if (!ALLOWED_METHODS.has(method)) {
146
+ req.resume()
147
+ res.writeHead(405).end()
148
+ return
149
+ }
150
+
151
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
152
+ const inner = innerPathOf(url.pathname)
153
+ if (inner === undefined) {
154
+ req.resume()
155
+ res.writeHead(404).end()
156
+ return
157
+ }
158
+ const denied = loopbackOnlyDenial(inner)
159
+ if (denied !== undefined) {
160
+ req.resume()
161
+ envelopeError(res, 403, 'invalid-request', 'forbidden', denied)
162
+ return
163
+ }
164
+
165
+ proxyLoopbackHttp(req, res, port, `${inner}${url.search}`)
166
+ }
167
+
168
+ return [{ kind: 'prefix', path: REMOTE_PREFIX, handler }]
169
+ }
170
+
171
+ /**
172
+ * Map one outer upgrade URL onto the loopback path (query string included).
173
+ */
174
+ export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: string): string {
175
+ if (reqUrl === undefined || reqUrl === '') return fallbackPath
176
+ let url: URL
177
+ try {
178
+ url = new URL(reqUrl, 'http://127.0.0.1')
179
+ } catch {
180
+ return fallbackPath
181
+ }
182
+ const inner = innerPathOf(url.pathname)
183
+ if (inner === undefined) return fallbackPath
184
+ return `${inner}${url.search}`
185
+ }
186
+
187
+ /**
188
+ * Build the WebSocket upgrade routes for the event streams and known plugin
189
+ * sockets. webServer matches upgrades by exact path.
190
+ * @param deps - pairing service + local port.
191
+ * @returns the upgrade routes to register on webServer.
192
+ */
193
+ export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute[] {
194
+ const { service, port } = deps
195
+
196
+ const handlerFor = (fallbackPath: string) => (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
197
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
198
+ if (deviceId === undefined || !service.touchDevice(deviceId)) {
199
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
200
+ socket.destroy()
201
+ return
202
+ }
203
+ const inner = upgradeInnerPath(req.url, fallbackPath)
204
+ const denied = loopbackOnlyDenial(inner.split('?')[0] ?? inner)
205
+ if (denied !== undefined) {
206
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
207
+ socket.destroy()
208
+ return
209
+ }
210
+ proxyLoopbackUpgrade(req, socket, head, port, inner)
211
+ }
212
+
213
+ return REMOTE_UPGRADE_PATHS.map((path) => ({
214
+ path,
215
+ handler: handlerFor(path.slice(REMOTE_PREFIX.length)),
216
+ }))
217
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Remote desktop channel constants — SDK-independent so tests and the
3
+ * client half can pin them without importing the host SDK graph.
4
+ */
5
+
6
+ /** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
7
+ export const REMOTE_PREFIX = '/remote'
8
+
9
+ /** Connection-plugin method prefix under the gated channel. */
10
+ export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`
11
+
12
+ /** WebSocket event-stream paths served by the channel (client rewrites to these). */
13
+ export const REMOTE_API_PATHS = {
14
+ mux: `${REMOTE_API_PREFIX}/events.mux`,
15
+ host: `${REMOTE_API_PREFIX}/events.host`,
16
+ } as const
17
+
18
+ /**
19
+ * Exact upgrade paths registered on webServer (the SDK matches upgrades by
20
+ * exact path, not prefix). Query strings ride on the request URL.
21
+ */
22
+ export const REMOTE_UPGRADE_PATHS = [
23
+ REMOTE_API_PATHS.mux,
24
+ REMOTE_API_PATHS.host,
25
+ `${REMOTE_PREFIX}/sidebar/ws/terminal`,
26
+ `${REMOTE_PREFIX}/sidebar/ws/agent-terminals`,
27
+ `${REMOTE_API_PREFIX}/dsh-ssh/terminal`,
28
+ ] as const
29
+
30
+ /** Plugin-manager HTTP prefix: install/remove stay physically local. */
31
+ export const PLUGIN_MANAGER_PATH = '/api/plugin-manager'
32
+
33
+ /** Desktop-launcher HTTP prefix: shortcut create and host shutdown stay physically local. */
34
+ export const DESKTOP_LAUNCHER_PATH = '/api/dsh-desktop-launcher'
35
+
36
+ /** Family settings-bridge HTTP prefix: describe/mutate stay physically local. */
37
+ export const WEB_UI_SETTINGS_BRIDGE_PATH = '/api/dsh-web-ui-settings'
38
+
39
+ /**
40
+ * Loopback-only methods of the host API surface, mirrored from
41
+ * client-connection's `PRIVILEGED_METHODS` (pinned by
42
+ * tests/remote-contract.spec.ts against the installed SDK). They stay
43
+ * unreachable from a paired remote desktop, matching the SDK's own stance
44
+ * that the configuration plane is loopback-same-origin only.
45
+ */
46
+ export const LOOPBACK_ONLY_METHODS = new Set([
47
+ 'agentPreset.read',
48
+ 'agentPreset.copy',
49
+ 'agentPreset.openDocument',
50
+ 'agentPreset.remove',
51
+ 'host.pickDirectory',
52
+ 'host.openPath',
53
+ 'settings.describe',
54
+ 'settings.openDocument',
55
+ 'settings.update',
56
+ 'settings.replace',
57
+ 'settings.mutate',
58
+ 'credentials.describe',
59
+ 'credentials.set',
60
+ 'credentials.unset',
61
+ 'llm.discoverModels',
62
+ ])
package/src/routes.ts CHANGED
@@ -88,6 +88,7 @@ export const PAIR_PATHS = {
88
88
  issue: '/api/pair/issue',
89
89
  accept: '/api/pair/accept',
90
90
  stop: '/api/pair/stop',
91
+ revoke: '/api/pair/revoke',
91
92
  heartbeat: '/api/pair/heartbeat',
92
93
  status: '/api/pair/status',
93
94
  events: '/api/pair/events',
@@ -108,6 +109,9 @@ export const issuePayloadSchema = z.object({
108
109
  export const acceptPayloadSchema = z.object({
109
110
  token: z.string().default(''),
110
111
  })
112
+ export const revokePayloadSchema = z.object({
113
+ deviceId: z.string().min(1),
114
+ })
111
115
  export const pairActionPayloadSchema = z.object({}).passthrough()
112
116
 
113
117
  /**
@@ -310,7 +314,8 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
310
314
  writeJson(res, 400, { ok: false, code: 'bad-payload' })
311
315
  return
312
316
  }
313
- const result = service.accept(payload.token)
317
+ const ua = req.headers['user-agent']
318
+ const result = service.accept(payload.token, typeof ua === 'string' ? ua : undefined)
314
319
  if (!result.ok) {
315
320
  writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code })
316
321
  return
@@ -342,6 +347,26 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
342
347
  writeJson(res, 200, { ok: true })
343
348
  }
344
349
 
350
+ const handleRevoke = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
351
+ if (!requireMethod(req, res, 'POST')) return
352
+ if (!loopbackFence(req)) {
353
+ writeJson(res, 403, { ok: false, code: 'forbidden' })
354
+ return
355
+ }
356
+ const body = await readJsonBody(req)
357
+ const payload = parsePairPayload(revokePayloadSchema, body)
358
+ if (payload === undefined) {
359
+ writeJson(res, 400, { ok: false, code: 'bad-payload' })
360
+ return
361
+ }
362
+ const revoked = service.revoke(payload.deviceId)
363
+ if (!revoked) {
364
+ writeJson(res, 404, { ok: false, code: 'unknown-device' })
365
+ return
366
+ }
367
+ writeJson(res, 200, { ok: true })
368
+ }
369
+
345
370
  const handleHeartbeat = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
346
371
  if (!requireMethod(req, res, 'POST')) return
347
372
  if (!lanFence(req)) {
@@ -371,10 +396,13 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
371
396
  const paired = deviceId !== undefined && service.hasDevice(deviceId)
372
397
  const snapshot = service.snapshot()
373
398
  // Unpaired LAN/tunnel clients get only the pairing-relevant fields; the
374
- // token expiry, device roster, and public tunnel URL are an oracle for
375
- // targeting and timing and stay behind a live device cookie.
399
+ // token expiry, public tunnel URL, and counts are an oracle for targeting
400
+ // and stay behind a live device cookie. The per-device roster (ids are
401
+ // session credentials) is never returned here — only the loopback events
402
+ // stream carries it to the desktop panel.
403
+ const { devices: _devices, ...rest } = snapshot
376
404
  const visible = paired
377
- ? snapshot
405
+ ? rest
378
406
  : { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses }
379
407
  writeJson(res, 200, { ok: true, paired, ...visible })
380
408
  }
@@ -394,6 +422,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
394
422
  { kind: 'exact', path: PAIR_PATHS.issue, handler: handleIssue },
395
423
  { kind: 'exact', path: PAIR_PATHS.accept, handler: handleAccept },
396
424
  { kind: 'exact', path: PAIR_PATHS.stop, handler: handleStop },
425
+ { kind: 'exact', path: PAIR_PATHS.revoke, handler: handleRevoke },
397
426
  { kind: 'exact', path: PAIR_PATHS.heartbeat, handler: handleHeartbeat },
398
427
  { kind: 'exact', path: PAIR_PATHS.status, handler: handleStatus },
399
428
  { kind: 'exact', path: PAIR_PATHS.events, handler: handleEvents },