@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.
- package/README.i18n.yaml +2 -2
- package/README.md +41 -8
- package/README.zh.md +7 -2
- package/lib/client.js +65 -34
- package/lib/client.js.map +1 -1
- package/lib/index.js +645 -61
- package/lib/mobile.js +874 -64
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +35 -5
- package/lib/types/client/remote-channel.d.ts +4 -5
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +13 -28
- package/lib/types/http.d.ts +37 -8
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +76 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +25 -1
- package/lib/types/mobile/api.d.ts +57 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +29 -0
- package/lib/types/mobile/markdown.d.ts +7 -1
- package/lib/types/mobile/markdown.d.ts.map +1 -1
- package/lib/types/mobile/markdown.js +16 -7
- package/lib/types/mobile/markdown.test.js +8 -0
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +325 -6
- package/lib/types/mobile/views/ChatView.d.ts +10 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +205 -8
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +2 -0
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.stream.test.js +205 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +46 -5
- package/lib/types/mobile/views/WorkspaceView.test.js +129 -2
- package/lib/types/mobile-api.d.ts +9 -3
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +61 -16
- package/lib/types/mobile-pending.d.ts +38 -0
- package/lib/types/mobile-pending.d.ts.map +1 -0
- package/lib/types/mobile-pending.js +59 -0
- package/lib/types/paired-model-catalog.d.ts +17 -0
- package/lib/types/paired-model-catalog.d.ts.map +1 -0
- package/lib/types/paired-model-catalog.js +252 -0
- package/lib/types/pairing.d.ts +13 -4
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +13 -5
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.d.ts +26 -0
- package/lib/types/remote-channel-boot.d.ts.map +1 -0
- package/lib/types/remote-channel-boot.js +128 -0
- package/lib/types/remote-channel-rules.d.ts +40 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -0
- package/lib/types/remote-channel-rules.js +32 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +12 -23
- package/lib/types/uuid-polyfill.d.ts +10 -0
- package/lib/types/uuid-polyfill.d.ts.map +1 -0
- package/lib/types/uuid-polyfill.js +27 -0
- package/package.json +1 -1
- package/src/client/index.ts +41 -5
- package/src/client/remote-channel.ts +18 -28
- package/src/http.ts +87 -12
- package/src/index.ts +27 -2
- package/src/mobile/api.ts +91 -0
- package/src/mobile/markdown.test.ts +9 -0
- package/src/mobile/markdown.ts +17 -7
- package/src/mobile/mobile-styles.ts +326 -6
- package/src/mobile/views/ChatView.stream.test.tsx +231 -0
- package/src/mobile/views/ChatView.tsx +328 -10
- package/src/mobile/views/WorkspaceView.test.tsx +163 -2
- package/src/mobile/views/WorkspaceView.tsx +152 -26
- package/src/mobile-api.ts +59 -17
- package/src/mobile-pending.ts +88 -0
- package/src/paired-model-catalog.ts +224 -0
- package/src/pairing.ts +13 -5
- package/src/remote-api.ts +0 -1
- package/src/remote-channel-boot.ts +131 -0
- package/src/remote-channel-rules.ts +62 -0
- package/src/routes.ts +13 -24
- package/src/uuid-polyfill.ts +28 -0
package/lib/types/routes.js
CHANGED
|
@@ -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 {
|
|
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
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
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.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
package/src/client/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
28
|
-
|
|
27
|
+
import {
|
|
28
|
+
REMOTE_API_PREFIX,
|
|
29
|
+
REMOTE_CHANNEL_RULES,
|
|
30
|
+
REMOTE_PREFIX,
|
|
31
|
+
} from '../remote-channel-rules.ts'
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
export
|
|
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
|
|
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(
|
|
89
|
-
if (pathname.startsWith(
|
|
90
|
-
if (pathname ===
|
|
91
|
-
if (pathname ===
|
|
92
|
-
if (pathname.startsWith(
|
|
93
|
-
if (pathname.startsWith(
|
|
94
|
-
if (pathname.startsWith(
|
|
95
|
-
if (pathname.startsWith(
|
|
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
|
|
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
|
|
3
|
-
* bounded
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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 {
|
|
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('<&>"'')
|
|
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 & b < c > d " e ' f')
|
|
87
|
+
expect(escapeHtml('& < >')).toBe('&amp; &lt; &gt;')
|
|
88
|
+
expect(escapeHtml('plain text 123')).toBe('plain text 123')
|
|
89
|
+
expect(escapeHtml('')).toBe('')
|
|
90
|
+
})
|
|
82
91
|
})
|
package/src/mobile/markdown.ts
CHANGED
|
@@ -10,14 +10,24 @@
|
|
|
10
10
|
* @module dsh-remote-web-ui/mobile/markdown
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/** HTML special-character map for {@link escapeHtml}. */
|
|
14
|
+
const HTML_ESCAPE_MAP: Record<string, string> = {
|
|
15
|
+
'&': '&',
|
|
16
|
+
'<': '<',
|
|
17
|
+
'>': '>',
|
|
18
|
+
'"': '"',
|
|
19
|
+
"'": ''',
|
|
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, '&')
|
|
17
|
-
.replace(/</g, '<')
|
|
18
|
-
.replace(/>/g, '>')
|
|
19
|
-
.replace(/"/g, '"')
|
|
20
|
-
.replace(/'/g, ''')
|
|
30
|
+
return text.replace(/[&<>"']/g, char => HTML_ESCAPE_MAP[char] ?? char)
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
/**
|