@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
@@ -0,0 +1,224 @@
1
+ /** Paired-only model discovery and adoption routes; generic privileged RPCs stay loopback-only. */
2
+ import type { IncomingMessage, ServerResponse } from 'node:http'
3
+ import type { ApiProxy, ModelProviderGroup, RpcId, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-host-apiproxy/api'
4
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
5
+ import { z } from 'zod'
6
+ import type { PairingService } from './pairing.ts'
7
+ import { readCookie } from './gate.ts'
8
+ import { readBoundedJson, writeJson } from './http.ts'
9
+ import { isTrustedApiRequest, publicHostOf } from './routes.ts'
10
+
11
+ const MAX_BODY_BYTES = 16 * 1024
12
+ const MAX_IDENTIFIER_LENGTH = 160
13
+ const MAX_DISPLAY_NAME_LENGTH = 240
14
+ const MAX_TOKEN_LIMIT = 10_000_000
15
+ const REASONING_EFFORTS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
16
+ type ReasoningEffort = typeof REASONING_EFFORTS[number]
17
+ const stringField = (max: number) => z.string().trim().min(1).max(max).refine(value => !/[\r\n\0]/.test(value), 'must not contain a newline or NUL')
18
+ const providerSchema = z.object({ provider: stringField(MAX_IDENTIFIER_LENGTH) }).strict()
19
+ const modelSchema = z.object({
20
+ id: stringField(MAX_IDENTIFIER_LENGTH),
21
+ name: stringField(MAX_DISPLAY_NAME_LENGTH).optional(),
22
+ contextWindow: z.number().int().safe().positive().max(MAX_TOKEN_LIMIT).optional(),
23
+ maxTokens: z.number().int().safe().positive().max(MAX_TOKEN_LIMIT).optional(),
24
+ reasoningEfforts: z.array(z.enum(REASONING_EFFORTS)).min(1).refine(
25
+ efforts => efforts.every((effort, index) => index === 0 || REASONING_EFFORTS.indexOf(efforts[index - 1]!) < REASONING_EFFORTS.indexOf(effort)),
26
+ 'reasoning efforts must be unique and in canonical order',
27
+ ).optional(),
28
+ }).strict()
29
+ const upsertSchema = z.object({ provider: stringField(MAX_IDENTIFIER_LENGTH), model: modelSchema }).strict()
30
+
31
+ interface ModelProfile { id: string, name?: string, contextWindow?: number, maxTokens?: number, reasoningEfforts?: ReasoningEffort[] }
32
+ interface EligibleProvider { provider: string, displayName: string, namespace: SettingsNamespaceView, profile: Record<string, unknown> }
33
+ interface CatalogValue { groups: ModelProviderGroup[], failures: Array<{ id: string, name: string, message: string }> }
34
+
35
+ export const PAIRED_MODEL_CATALOG_PATHS = {
36
+ catalog: '/api/pair/model-catalog',
37
+ discover: '/api/pair/model-catalog/discover',
38
+ upsert: '/api/pair/model-catalog/upsert',
39
+ } as const
40
+
41
+ export interface PairedModelCatalogDeps {
42
+ service: PairingService
43
+ apiProxy: ApiProxy
44
+ /** The LAN IP literals the host fence accepts, mirroring the /api/pair fence. */
45
+ lanAddresses: readonly string[]
46
+ }
47
+
48
+ let rpcSequence = 0
49
+ function request<T>(payload: T): { rpcId: RpcId, payload: T } {
50
+ rpcSequence += 1
51
+ return { rpcId: `paired-model-catalog-${Date.now().toString(36)}-${rpcSequence.toString(36)}` as RpcId, payload }
52
+ }
53
+ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) }
54
+ function valueOf<T>(response: RpcResponse<T>): T | undefined { return response.result.ok ? response.result.value : undefined }
55
+ function errorCode(response: RpcResponse<unknown>): string | undefined { return response.result.ok ? undefined : response.result.error.code }
56
+ function reject(res: ServerResponse, status: number, error: string): void { writeJson(res, status, { error }) }
57
+ function publicProviderId(value: string): string {
58
+ const normalized = value.replace(/[\r\n\0]/g, ' ').trim().slice(0, MAX_IDENTIFIER_LENGTH)
59
+ return normalized === '' ? 'unknown' : normalized
60
+ }
61
+
62
+ function publicCatalog(value: CatalogValue): CatalogValue {
63
+ return {
64
+ groups: value.groups.map(group => ({ id: group.id, name: group.name, models: group.models.map(model => ({
65
+ id: model.id, name: model.name,
66
+ ...model.description === undefined ? {} : { description: model.description },
67
+ ...model.reasoning === undefined ? {} : { reasoning: model.reasoning },
68
+ })) })),
69
+ failures: value.failures.map(failure => ({
70
+ id: failure.id,
71
+ name: failure.name,
72
+ message: `model catalog unavailable for provider ${publicProviderId(failure.id)}`,
73
+ })),
74
+ }
75
+ }
76
+ function publicCandidates(models: Array<{ id: string, name?: string, contextWindow?: number, maxTokens?: number }>): unknown[] {
77
+ return models.map(model => ({ id: model.id,
78
+ ...model.name === undefined ? {} : { name: model.name },
79
+ ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
80
+ ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
81
+ }))
82
+ }
83
+ function fieldsOf(model: ModelProfile, includeId: boolean): Record<string, unknown> {
84
+ const fields: Record<string, unknown> = {
85
+ ...includeId ? { id: model.id } : {},
86
+ ...model.name === undefined ? {} : { name: model.name },
87
+ ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
88
+ ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
89
+ }
90
+ if (model.reasoningEfforts !== undefined) fields.reasoningEfforts = Object.fromEntries(model.reasoningEfforts.map(effort => [effort, effort === 'off' ? null : effort]))
91
+ return fields
92
+ }
93
+ function groupFor(catalog: CatalogValue, provider: string): ModelProviderGroup | undefined { return catalog.groups.find(group => group.id === provider) }
94
+ async function readCatalog(apiProxy: ApiProxy): Promise<CatalogValue | undefined> {
95
+ try { return valueOf(await apiProxy.llm.models(request({}))) } catch { return undefined }
96
+ }
97
+
98
+ /** Resolve one exact active llm-pi-ai provider address, never an input-supplied path. */
99
+ async function eligibleProvider(apiProxy: ApiProxy, requested: string): Promise<{ provider?: EligibleProvider, status?: number, error?: string }> {
100
+ let providersResponse: Awaited<ReturnType<ApiProxy['llm']['providers']>>
101
+ let settingsResponse: Awaited<ReturnType<ApiProxy['settings']['describe']>>
102
+ try {
103
+ providersResponse = await apiProxy.llm.providers(request({}))
104
+ settingsResponse = await apiProxy.settings.describe(request({}))
105
+ } catch { return { status: 502, error: 'model catalog capability is unavailable' } }
106
+ const providers = valueOf(providersResponse)
107
+ const settings = valueOf(settingsResponse)
108
+ if (providers === undefined || settings === undefined) return { status: 502, error: 'model catalog capability is unavailable' }
109
+ const route = providers.providers.find(candidate => candidate.provider === requested)
110
+ if (route === undefined) return { status: 404, error: `unknown provider ${requested}` }
111
+ if (!settings.writable || !route.active || route.settingsNs !== 'llm-pi-ai' || route.settingsPath.length !== 2
112
+ || route.settingsPath[0] !== 'providers' || route.settingsPath[1] !== requested) return { status: 403, error: `provider ${requested} is not eligible for the paired model catalog` }
113
+ const namespace = settings.namespaces.find(candidate => candidate.ns === 'llm-pi-ai')
114
+ const root = namespace !== undefined && isRecord(namespace.value) ? namespace.value : undefined
115
+ const providersValue = root !== undefined && isRecord(root.providers) ? root.providers : undefined
116
+ const profile = providersValue !== undefined && isRecord(providersValue[requested]) ? providersValue[requested] : undefined
117
+ if (namespace === undefined || profile === undefined) return { status: 403, error: `provider ${requested} is not eligible for the paired model catalog` }
118
+ return { provider: { provider: requested, displayName: route.displayName, namespace, profile } }
119
+ }
120
+ function updatedModels(existing: unknown[], model: ModelProfile): unknown[] {
121
+ const next = existing.map(entry => isRecord(entry) ? { ...entry } : entry)
122
+ const index = next.findIndex(entry => isRecord(entry) && entry.id === model.id)
123
+ const patch = fieldsOf(model, true)
124
+ if (index >= 0 && isRecord(next[index])) next[index] = { ...next[index], ...patch }
125
+ else next.push(patch)
126
+ return next
127
+ }
128
+ function mutationPlan(profile: Record<string, unknown>, provider: string, model: ModelProfile, catalog: CatalogValue): { path: string[], value: unknown } | undefined {
129
+ if (Array.isArray(profile.models)) return { path: ['providers', provider, 'models'], value: updatedModels(profile.models, model) }
130
+ const group = groupFor(catalog, provider)
131
+ if (group?.models.some(entry => entry.id === model.id) === true) {
132
+ const overrides = isRecord(profile.modelOverrides) ? profile.modelOverrides : undefined
133
+ const current = overrides?.[model.id]
134
+ const existing: Record<string, unknown> = isRecord(current) ? current : {}
135
+ return { path: ['providers', provider, 'modelOverrides', model.id], value: { ...existing, ...fieldsOf(model, false) } }
136
+ }
137
+ if (group === undefined || catalog.failures.some(failure => failure.id === provider)) return undefined
138
+ return { path: ['providers', provider, 'models'], value: [...group.models.map(entry => ({ id: entry.id })), fieldsOf(model, true)] }
139
+ }
140
+ function paired(req: IncomingMessage, service: PairingService): boolean {
141
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
142
+ return deviceId !== undefined && service.touchDevice(deviceId)
143
+ }
144
+ async function bodyOf<T>(req: IncomingMessage, schema: z.ZodType<T>): Promise<T | undefined> {
145
+ try { return schema.parse(await readBoundedJson(req, MAX_BODY_BYTES)) } catch { return undefined }
146
+ }
147
+
148
+ /** Build the narrow paired-device model catalog routes. */
149
+ export function makePairedModelCatalogRoutes(deps: PairedModelCatalogDeps): WebRoute[] {
150
+ const { service, apiProxy, lanAddresses } = deps
151
+ /** Same fence as the /api/pair family: loopback, the advertised LAN literals, or the configured public host. */
152
+ const fence = (req: IncomingMessage): boolean => {
153
+ const publicHost = publicHostOf(service.publicBaseUrl)
154
+ return isTrustedApiRequest(req, publicHost === undefined ? lanAddresses : [...lanAddresses, publicHost])
155
+ }
156
+ const gate = (req: IncomingMessage, res: ServerResponse): boolean => {
157
+ if (!fence(req)) {
158
+ req.resume()
159
+ reject(res, 403, 'paired model catalog is not reachable for this origin')
160
+ return false
161
+ }
162
+ if (!paired(req, service)) {
163
+ req.resume()
164
+ reject(res, 403, 'paired model catalog requires a live paired device')
165
+ return false
166
+ }
167
+ return true
168
+ }
169
+ const catalog = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
170
+ if (!gate(req, res)) return
171
+ if (req.method !== 'GET') return reject(res, 405, 'method not allowed')
172
+ let providersResponse: Awaited<ReturnType<ApiProxy['llm']['providers']>>
173
+ let settingsResponse: Awaited<ReturnType<ApiProxy['settings']['describe']>>
174
+ try { providersResponse = await apiProxy.llm.providers(request({})); settingsResponse = await apiProxy.settings.describe(request({})) } catch { return reject(res, 502, 'model catalog capability is unavailable') }
175
+ const providers = valueOf(providersResponse)
176
+ const settings = valueOf(settingsResponse)
177
+ if (providers === undefined || settings === undefined) return reject(res, 502, 'model catalog capability is unavailable')
178
+ const namespace = settings.namespaces.find(candidate => candidate.ns === 'llm-pi-ai')
179
+ const providerMap = namespace !== undefined && isRecord(namespace.value) && isRecord(namespace.value.providers) ? namespace.value.providers : undefined
180
+ const eligible = settings.writable && providerMap !== undefined ? providers.providers.filter(candidate => candidate.active && candidate.settingsNs === 'llm-pi-ai'
181
+ && candidate.settingsPath.length === 2 && candidate.settingsPath[0] === 'providers' && candidate.settingsPath[1] === candidate.provider && isRecord(providerMap[candidate.provider]))
182
+ .map(candidate => ({ provider: candidate.provider, displayName: candidate.displayName })) : []
183
+ writeJson(res, 200, { capability: 'paired-model-catalog', providers: eligible })
184
+ }
185
+ const discover = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
186
+ if (!gate(req, res)) return
187
+ if (req.method !== 'POST') return reject(res, 405, 'method not allowed')
188
+ const body = await bodyOf(req, providerSchema)
189
+ if (body === undefined) return reject(res, 400, 'invalid model catalog request')
190
+ const eligible = await eligibleProvider(apiProxy, body.provider)
191
+ if (eligible.provider === undefined) return reject(res, eligible.status ?? 502, eligible.error ?? 'model catalog capability is unavailable')
192
+ try {
193
+ const result = valueOf(await apiProxy.llm.discoverModels(request({ settingsNs: 'llm-pi-ai', provider: eligible.provider.provider })))
194
+ if (result === undefined) return reject(res, 502, `model discovery failed for provider ${eligible.provider.provider}`)
195
+ writeJson(res, 200, { models: publicCandidates(result.models) })
196
+ } catch { reject(res, 502, `model discovery failed for provider ${eligible.provider.provider}`) }
197
+ }
198
+ const upsert = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
199
+ if (!gate(req, res)) return
200
+ if (req.method !== 'POST') return reject(res, 405, 'method not allowed')
201
+ const body = await bodyOf(req, upsertSchema)
202
+ if (body === undefined) return reject(res, 400, 'invalid model catalog request')
203
+ const eligible = await eligibleProvider(apiProxy, body.provider)
204
+ if (eligible.provider === undefined) return reject(res, eligible.status ?? 502, eligible.error ?? 'model catalog capability is unavailable')
205
+ const before = await readCatalog(apiProxy)
206
+ if (before === undefined) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`)
207
+ const plan = mutationPlan(eligible.provider.profile, body.provider, body.model, before)
208
+ if (plan === undefined) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`)
209
+ let mutation: Awaited<ReturnType<ApiProxy['settings']['mutate']>>
210
+ try { mutation = await apiProxy.settings.mutate(request({ ns: 'llm-pi-ai', expectedRevision: eligible.provider.namespace.revision, ops: [{ op: 'set', path: plan.path, value: plan.value }] })) } catch { return reject(res, 502, `model update failed for provider ${body.provider}`) }
211
+ const code = errorCode(mutation)
212
+ if (code === 'settings-conflict') return reject(res, 409, `model update conflicted for provider ${body.provider}`)
213
+ if (code === 'settings-rejected') return reject(res, 422, `model update was rejected for provider ${body.provider}`)
214
+ if (code !== undefined) return reject(res, 502, `model update failed for provider ${body.provider}`)
215
+ const after = await readCatalog(apiProxy)
216
+ if (after === undefined) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`)
217
+ writeJson(res, 200, publicCatalog(after))
218
+ }
219
+ return [
220
+ { kind: 'exact', path: PAIRED_MODEL_CATALOG_PATHS.catalog, handler: catalog },
221
+ { kind: 'exact', path: PAIRED_MODEL_CATALOG_PATHS.discover, handler: discover },
222
+ { kind: 'exact', path: PAIRED_MODEL_CATALOG_PATHS.upsert, handler: upsert },
223
+ ]
224
+ }
package/src/pairing.ts CHANGED
@@ -169,10 +169,13 @@ export const defaultClock: PairingClock = {
169
169
  }
170
170
 
171
171
  /**
172
- * The pairing state machine. All mutations notify state listeners after the
173
- * commit point that makes them true, and notification dedupes against the
174
- * last emitted snapshot — time-driven transitions (a device aging offline)
175
- * surface on the next sweep without any mutation.
172
+ * The pairing state machine. Structural mutations issue/accept/stop/revoke
173
+ * and config updates (LAN bases, tunnel, posture) notify state listeners
174
+ * after the commit point that makes them true, and notification dedupes
175
+ * against the last emitted snapshot. Presence-only updates (touchDevice /
176
+ * heartbeat) just mark the store dirty and broadcast on the next sweep,
177
+ * which also surfaces time-driven transitions (a device aging offline)
178
+ * without any mutation.
176
179
  */
177
180
  export class PairingService {
178
181
  private readonly tokens = new Map<string, TokenRecord>()
@@ -432,6 +435,12 @@ export class PairingService {
432
435
  * The api/gate path: record activity for a device id and report whether
433
436
  * the request may proceed. Unknown or revoked ids (including any device
434
437
  * after stop() or idle expiry) are refused.
438
+ *
439
+ * Presence refreshes are throttled on purpose: every gated request (and
440
+ * the mobile SSE keepalive) lands here, so a broadcast per call would fan
441
+ * a full snapshot out to every status stream on the hot path. The refresh
442
+ * only updates lastSeenAt and marks the store dirty; the snapshot reaches
443
+ * listeners at the next sweep() (structural changes notify immediately).
435
444
  * @param deviceId - the cookie value of the requesting device.
436
445
  * @returns true when the device session is live and was refreshed.
437
446
  */
@@ -440,7 +449,6 @@ export class PairingService {
440
449
  if (session === undefined) return false
441
450
  session.lastSeenAt = this.clock.now()
442
451
  this.dirty = true
443
- this.notify()
444
452
  return true
445
453
  }
446
454
 
package/src/remote-api.ts CHANGED
@@ -33,7 +33,6 @@ import {
33
33
  DESKTOP_LAUNCHER_PATH,
34
34
  LOOPBACK_ONLY_METHODS,
35
35
  PLUGIN_MANAGER_PATH,
36
- REMOTE_API_PATHS,
37
36
  REMOTE_PREFIX,
38
37
  REMOTE_UPGRADE_PATHS,
39
38
  WEB_UI_SETTINGS_BRIDGE_PATH,
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Parse-time remote-channel boot patch (issue #987): the browser-half patch
3
+ * (client/remote-channel.ts) installs when this plugin's boot entry runs,
4
+ * but `dsh-client-connection` boots earlier and opens its event streams
5
+ * unrewritten — on a non-loopback origin the SDK fence rejects them and the
6
+ * workspace list never loads. The host therefore inlines this classic script
7
+ * right after the opening <head> tag (webserver/index-inject), so the
8
+ * fetch/WebSocket/EventSource/src rewrite is active before ANY boot entry
9
+ * executes. The plugin's client apply later adopts the installed seat
10
+ * (hooks + pending unpaired signal) instead of patching twice.
11
+ *
12
+ * The rewrite decisions are generated from REMOTE_CHANNEL_RULES, the same
13
+ * data the browser patch consumes — the two cannot drift apart. The script
14
+ * self-skips on loopback origins and never throws.
15
+ * @module @linxin666/dsh-remote-web-ui/remote-channel-boot
16
+ */
17
+
18
+ import { REMOTE_CHANNEL_BOOT_GLOBAL, REMOTE_CHANNEL_RULES, type RemoteChannelRules } from './remote-channel-rules.ts'
19
+
20
+ /**
21
+ * Build the inline boot script. The result contains no `</script` sequence
22
+ * (the injection contract) and installs a {@link RemoteChannelBootSeat} on
23
+ * the window global.
24
+ */
25
+ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_CHANNEL_RULES): string {
26
+ const json = JSON.stringify(rules)
27
+ const seat = JSON.stringify(REMOTE_CHANNEL_BOOT_GLOBAL)
28
+ // Modern syntax is fine: module scripts already gate the GUI to current
29
+ // browsers. Keep everything inside the IIFE and fail closed.
30
+ return '(function(){' +
31
+ 'try{' +
32
+ 'var w=window,loc=w.location,h=loc.hostname;' +
33
+ // Loopback origins keep the original paths (mirrors isLoopbackHostname).
34
+ "if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
35
+ 'var R=' + json + ';' +
36
+ 'function sf(p){' +
37
+ 'if(p.indexOf(R.pairPrefix)===0)return false;' +
38
+ 'if(p.indexOf(R.updatePrefix)===0)return false;' +
39
+ 'if(p===R.desktopLauncherPrefix||p.indexOf(R.desktopLauncherPrefix+"/")===0)return false;' +
40
+ 'if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+"/")===0)return false;' +
41
+ 'if(p.indexOf(R.apiPrefix)===0)return true;' +
42
+ 'if(p.indexOf(R.sidebarPrefix)===0||p==="/sidebar")return true;' +
43
+ 'if(p.indexOf(R.gitPrefix)===0||p==="/git")return true;' +
44
+ 'if(p.indexOf(R.petPrefix)===0||p==="/pet")return true;' +
45
+ 'return false}' +
46
+ 'function sw(p){return R.wsPaths.indexOf(p)!==-1}' +
47
+ 'function rp(p){return R.remotePrefix+p}' +
48
+ 'function so(u){return u.origin===loc.origin}' +
49
+ 'function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}' +
50
+ 'if(u.origin!==loc.origin)return raw;' +
51
+ 'if(!sf(u.pathname))return raw;' +
52
+ 'u.pathname=rp(u.pathname);' +
53
+ 'if(raw.charAt(0)==="/"&&raw.charAt(1)!=="/")return u.pathname+u.search+u.hash;' +
54
+ 'return u.href}' +
55
+ // The unpaired code reader mirrors isUnpairedDenied's envelope shapes.
56
+ 'function uc(v){' +
57
+ 'if(typeof v!=="object"||v===null)return undefined;' +
58
+ 'var n=v.result;' +
59
+ 'if(typeof n==="object"&&n!==null){' +
60
+ 'var e=n.error;' +
61
+ 'if(typeof e==="object"&&e!==null&&typeof e.code==="string")return e.code}' +
62
+ 'var t=v.error;' +
63
+ 'if(typeof t==="object"&&t!==null&&typeof t.code==="string")return t.code;' +
64
+ 'return undefined}' +
65
+ 'var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};' +
66
+ 'function denied(res){' +
67
+ 'if(res.status!==403)return false;' +
68
+ 'return res.clone().json().then(function(b){return uc(b)==="unpaired"}).catch(function(){return false})}' +
69
+ 'function signal(unpaired){' +
70
+ 'if(unpaired){' +
71
+ 'if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true' +
72
+ '}else{' +
73
+ 'seat.pendingUnpaired=false;' +
74
+ 'if(seat.onPaired)seat.onPaired()}}' +
75
+ 'var of=w.fetch;' +
76
+ 'w.fetch=function(input,init){' +
77
+ 'var raw=typeof input==="string"||input instanceof URL?input.toString():input.url;' +
78
+ 'var url=new URL(raw,loc.href);' +
79
+ 'if(so(url)&&sf(url.pathname)){' +
80
+ 'var next=new URL(url);' +
81
+ 'next.pathname=rp(url.pathname);' +
82
+ 'var target=typeof input==="string"||input instanceof URL?next.toString():new Request(next,input);' +
83
+ 'return Promise.resolve(of.call(w,target,init)).then(function(res){' +
84
+ // denied() is sync-false for non-403 and a promise otherwise.
85
+ 'void Promise.resolve(denied(res)).then(signal);' +
86
+ 'return res})}' +
87
+ 'return of.call(w,input,init)};' +
88
+ 'var OW=w.WebSocket;' +
89
+ 'w.WebSocket=function(url,protocols){' +
90
+ 'var p=new URL(url.toString(),loc.href);' +
91
+ 'var o=p.protocol==="wss:"?"https://"+p.host:p.protocol==="ws:"?"http://"+p.host:"";' +
92
+ 'if(o!==""&&o===loc.origin&&sw(p.pathname)){' +
93
+ 'var nx=new URL(p);' +
94
+ 'nx.pathname=rp(p.pathname);' +
95
+ 'return protocols!==undefined?new OW(nx,protocols):new OW(nx)}' +
96
+ 'return protocols!==undefined?new OW(url,protocols):new OW(url)};' +
97
+ 'w.WebSocket.prototype=OW.prototype;' +
98
+ 'w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;' +
99
+ 'var OE=w.EventSource;' +
100
+ 'if(OE!==undefined){' +
101
+ 'w.EventSource=function(url,cfg){' +
102
+ 'var p=new URL(url.toString(),loc.href);' +
103
+ 'if(so(p)&&sf(p.pathname)){' +
104
+ 'var nx=new URL(p);' +
105
+ 'nx.pathname=rp(p.pathname);' +
106
+ 'return new OE(nx,cfg)}' +
107
+ 'return new OE(url,cfg)};' +
108
+ 'w.EventSource.prototype=OE.prototype}' +
109
+ // Resource src accessors (img/script/iframe), mirroring patchSrcAccessor.
110
+ 'var restores=[];' +
111
+ 'function patchSrc(C){' +
112
+ 'if(C===undefined)return;' +
113
+ 'var d=Object.getOwnPropertyDescriptor(C.prototype,"src");' +
114
+ 'if(d===undefined||d.configurable===false||d.set===undefined)return;' +
115
+ 'var os=d.set;' +
116
+ 'Object.defineProperty(C.prototype,"src",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});' +
117
+ 'restores.push(function(){Object.defineProperty(C.prototype,"src",d)})}' +
118
+ 'patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);' +
119
+ 'seat.restore=function(){' +
120
+ 'w.fetch=of;' +
121
+ 'w.WebSocket=OW;' +
122
+ 'if(OE!==undefined)w.EventSource=OE;' +
123
+ 'for(var i=0;i<restores.length;i++)restores[i]();' +
124
+ 'try{delete w[' + seat + ']}catch(e){w[' + seat + ']=undefined}};' +
125
+ 'w[' + seat + ']=seat' +
126
+ '}catch(e){}' +
127
+ '})();'
128
+ }
129
+
130
+ /** The script this plugin contributes; built once from the live rules. */
131
+ export const REMOTE_CHANNEL_BOOT_SCRIPT: string = buildRemoteChannelBootScript()
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The remote-channel rewrite contract as pure data (issue #987): both the
3
+ * browser patch (client/remote-channel.ts) and the parse-time boot patch
4
+ * (remote-channel-boot.ts, inlined into index.html by the host) decide from
5
+ * these tables, so the two can never drift apart.
6
+ * @module @linxin666/dsh-remote-web-ui/remote-channel-rules
7
+ */
8
+
9
+ /** The gated mirror prefix (must match src/remote-methods.ts). */
10
+ export const REMOTE_PREFIX = '/remote'
11
+
12
+ /** Connection-plugin method prefix under the gated channel. */
13
+ export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`
14
+
15
+ /** Every decision input of the remote-channel rewrite, JSON-serializable. */
16
+ export interface RemoteChannelRules {
17
+ readonly remotePrefix: string
18
+ readonly apiPrefix: string
19
+ readonly pairPrefix: string
20
+ readonly updatePrefix: string
21
+ readonly desktopLauncherPrefix: string
22
+ readonly settingsBridgePrefix: string
23
+ readonly sidebarPrefix: string
24
+ readonly gitPrefix: string
25
+ readonly petPrefix: string
26
+ readonly wsPaths: readonly string[]
27
+ }
28
+
29
+ /** The live rule set. */
30
+ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
31
+ remotePrefix: REMOTE_PREFIX,
32
+ apiPrefix: '/api/',
33
+ pairPrefix: '/api/pair/',
34
+ updatePrefix: '/api/update/',
35
+ desktopLauncherPrefix: '/api/dsh-desktop-launcher',
36
+ settingsBridgePrefix: '/api/dsh-web-ui-settings',
37
+ sidebarPrefix: '/sidebar/',
38
+ gitPrefix: '/git/',
39
+ petPrefix: '/pet/',
40
+ wsPaths: [
41
+ '/api/events.mux',
42
+ '/api/events.host',
43
+ '/sidebar/ws/terminal',
44
+ '/sidebar/ws/agent-terminals',
45
+ '/api/dsh-ssh/terminal',
46
+ ],
47
+ }
48
+
49
+ /** The window global the boot patch publishes its seat under. */
50
+ export const REMOTE_CHANNEL_BOOT_GLOBAL = '__DSH_REMOTE_CHANNEL_BOOT__'
51
+
52
+ /**
53
+ * The seat the parse-time boot patch installs: hook seats the plugin's
54
+ * client apply adopts, a pending-unpaired flag for signals raised before
55
+ * adoption, and restore() retiring the patch (also removes the global).
56
+ */
57
+ export interface RemoteChannelBootSeat {
58
+ onUnpaired: (() => void) | null
59
+ onPaired: (() => void) | null
60
+ pendingUnpaired: boolean
61
+ restore(): void
62
+ }
package/src/routes.ts CHANGED
@@ -13,7 +13,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
13
13
  import { z, type ZodType } from 'zod'
14
14
  import { UnknownLanAddressError, type PairingService, type PairingSnapshot } from './pairing.ts'
15
15
  import { isLoopbackClient, readCookie } from './gate.ts'
16
- import { readBoundedJson, writeJson } from './http.ts'
16
+ import { readJsonBody, writeJson } from './http.ts'
17
17
 
18
18
  /**
19
19
  * Browser-trust fence for the /api/pair routes, mirroring the connection
@@ -115,26 +115,17 @@ export const revokePayloadSchema = z.object({
115
115
  export const pairActionPayloadSchema = z.object({}).passthrough()
116
116
 
117
117
  /**
118
- * Parse a pair request body through schema. A missing/empty or non-object
119
- * body is treated as an empty object (the same way the previous manual reads
120
- * yielded absent fields), and a value that fails the schema returns
121
- * `undefined` so the caller can answer with the existing error shape.
118
+ * Parse a pair request body through schema. A missing/empty, unparseable
119
+ * or non-object body (shared readJsonBody with objectOnly yields null for
120
+ * all of them) is treated as an empty object — the desktop stop/heartbeat
121
+ * send no body — and a value that fails the schema returns `undefined` so
122
+ * the caller can answer with the existing error shape.
122
123
  */
123
- function parsePairPayload<T>(schema: ZodType<T>, body: Record<string, unknown> | undefined): T | undefined {
124
+ function parsePairPayload<T>(schema: ZodType<T>, body: unknown | null): T | undefined {
124
125
  const result = schema.safeParse(body ?? {})
125
126
  return result.success ? result.data : undefined
126
127
  }
127
128
 
128
- /** Read a request body up to MAX_BODY_BYTES and parse it as JSON (undefined on failure). */
129
- async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
130
- try {
131
- const parsed: unknown = await readBoundedJson(req, MAX_BODY_BYTES)
132
- return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
133
- } catch {
134
- return undefined
135
- }
136
- }
137
-
138
129
  /** One open desktop status stream. */
139
130
  interface StatusStream {
140
131
  res: ServerResponse
@@ -265,7 +256,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
265
256
  writeJson(res, 403, { ok: false, code: 'forbidden' })
266
257
  return
267
258
  }
268
- const body = await readJsonBody(req)
259
+ const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true })
269
260
  const payload = parsePairPayload(issuePayloadSchema, body)
270
261
  if (payload === undefined) {
271
262
  writeJson(res, 400, { ok: false, code: 'bad-payload' })
@@ -313,7 +304,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
313
304
  writeJson(res, 429, { ok: false, code: 'rate-limited' })
314
305
  return
315
306
  }
316
- const body = await readJsonBody(req)
307
+ const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true })
317
308
  const payload = parsePairPayload(acceptPayloadSchema, body)
318
309
  if (payload === undefined) {
319
310
  writeJson(res, 400, { ok: false, code: 'bad-payload' })
@@ -328,13 +319,11 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
328
319
  // No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
329
320
  // work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
330
321
  // top-level navigations working while blocking cross-site subrequests.
331
- res.writeHead(200, {
332
- 'content-type': 'application/json; charset=utf-8',
322
+ writeJson(res, 200, { ok: true, deviceId: result.deviceId }, {
333
323
  'set-cookie': [
334
324
  `${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
335
325
  ],
336
326
  })
337
- res.end(JSON.stringify({ ok: true, deviceId: result.deviceId }))
338
327
  }
339
328
 
340
329
  const handleStop = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
@@ -343,7 +332,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
343
332
  writeJson(res, 403, { ok: false, code: 'forbidden' })
344
333
  return
345
334
  }
346
- const body = await readJsonBody(req)
335
+ const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true })
347
336
  if (parsePairPayload(pairActionPayloadSchema, body) === undefined) {
348
337
  writeJson(res, 400, { ok: false, code: 'bad-payload' })
349
338
  return
@@ -358,7 +347,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
358
347
  writeJson(res, 403, { ok: false, code: 'forbidden' })
359
348
  return
360
349
  }
361
- const body = await readJsonBody(req)
350
+ const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true })
362
351
  const payload = parsePairPayload(revokePayloadSchema, body)
363
352
  if (payload === undefined) {
364
353
  writeJson(res, 400, { ok: false, code: 'bad-payload' })
@@ -378,7 +367,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
378
367
  writeJson(res, 403, { ok: false, code: 'forbidden' })
379
368
  return
380
369
  }
381
- const body = await readJsonBody(req)
370
+ const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true })
382
371
  if (parsePairPayload(pairActionPayloadSchema, body) === undefined) {
383
372
  writeJson(res, 400, { ok: false, code: 'bad-payload' })
384
373
  return
@@ -0,0 +1,28 @@
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
+
10
+ export const UUID_POLYFILL_SCRIPT = [
11
+ '(function(){',
12
+ 'var g=typeof globalThis!=="undefined"?globalThis:typeof window!=="undefined"?window:self;',
13
+ 'if(typeof g.crypto==="undefined"){g.crypto={};}',
14
+ 'if(typeof g.crypto.randomUUID!=="function"){',
15
+ 'g.crypto.randomUUID=function(){',
16
+ 'var a=new Uint8Array(16);',
17
+ 'if(g.crypto.getRandomValues){g.crypto.getRandomValues(a);}',
18
+ 'else{for(var j=0;j<16;j++){a[j]=Math.random()*256|0;}}',
19
+ 'a[6]=(a[6]&0x0f)|0x40;',
20
+ 'a[8]=(a[8]&0x3f)|0x80;',
21
+ 'var h="",x="0123456789abcdef";',
22
+ 'for(var i=0;i<16;i++){h+=x[a[i]>>4]+x[a[i]&0x0f];',
23
+ 'if(i===3||i===5||i===7||i===9)h+="-";}',
24
+ 'return h;',
25
+ '};',
26
+ '}',
27
+ '})();',
28
+ ].join('')