dsh-remote-control-plugin 0.0.0-stage → 0.3.1

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/index.js ADDED
@@ -0,0 +1,1011 @@
1
+ import { createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { createServer as createHttpServer } from 'node:http'
4
+ import { createServer as createHttpsServer } from 'node:https'
5
+ import { isIP } from 'node:net'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { createFabric } from './host/fabric.js'
10
+ import { createRelayLink } from './host/relay-link.js'
11
+ import { createRelay } from './host/relay-core.js'
12
+
13
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
14
+ const WEB_DIR = path.join(HERE, 'web')
15
+ const COOKIE_NAME = 'dsh_rc'
16
+ const MAX_BODY_BYTES = 1024 * 1024
17
+
18
+ const STATIC_FILES = {
19
+ '/': ['index.html', 'text/html; charset=utf-8'],
20
+ '/index.html': ['index.html', 'text/html; charset=utf-8'],
21
+ '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
22
+ '/app.css': ['app.css', 'text/css; charset=utf-8'],
23
+ '/icon.svg': ['icon.svg', 'image/svg+xml'],
24
+ }
25
+
26
+ export const inject = ['sessionController']
27
+
28
+ /**
29
+ * Phone-friendly remote control for this Host's Sessions.
30
+ *
31
+ * The plugin owns its own listener instead of reusing the loopback web
32
+ * carrier, so it never depends on the GUI's boot payload, cookies, or the
33
+ * `/api` bridge. Everything it renders is derived from
34
+ * `ctx.sessionController` (list / follow / prompt / cancel / create).
35
+ */
36
+ export function apply(ctx, rawConfig) {
37
+ const log = pickLogger(ctx)
38
+ const config = normalizeConfig(rawConfig)
39
+ const stateFile = path.join(dshHome(), 'remote-control.json')
40
+ const peersFile = path.join(dshHome(), 'remote-control-peers.json')
41
+ const streams = new Set()
42
+ const published = { urls: [], code: '' }
43
+ const info = { port: config.port, proto: config.tls ? 'https' : 'http' }
44
+ const identity = { userId: null }
45
+
46
+ let server
47
+ let state
48
+ let fabric
49
+ let relayLink
50
+ let relayCore
51
+ let hostedKey = ''
52
+
53
+ const sessions = {
54
+ async list() {
55
+ const value = await ctx.sessionController.list({}, AbortSignal.timeout(15000))
56
+ return (value?.items ?? [])
57
+ .filter((item) => item.origin !== 'subagent')
58
+ .map((item) => {
59
+ const projections = item.projections?.values ?? {}
60
+ const title = firstString(projections.title, projections.sessionTitle, projections['session-title'])
61
+ return {
62
+ id: item.sessionId,
63
+ title: title || '',
64
+ running: Boolean(item.running),
65
+ blank: Boolean(item.blank),
66
+ updatedAt: item.updatedAt ?? 0,
67
+ cwd: item.cwd ?? '',
68
+ }
69
+ })
70
+ .sort((a, b) => b.updatedAt - a.updatedAt)
71
+ },
72
+ follow(sessionId, signal) {
73
+ return ctx.sessionController.follow(
74
+ { address: { kind: 'session', sessionId }, assistantStream: true },
75
+ signal,
76
+ )
77
+ },
78
+ prompt(sessionId, text) {
79
+ return ctx.sessionController.prompt({
80
+ requestId: randomUUID(),
81
+ sessionId,
82
+ mode: 'queue',
83
+ content: [{ type: 'text', text }],
84
+ clientTimeZone: timeZone(),
85
+ }, AbortSignal.timeout(30000))
86
+ },
87
+ cancel(sessionId) {
88
+ return ctx.sessionController.cancel({ sessionId })
89
+ },
90
+ }
91
+
92
+ fabric = createFabric({
93
+ log,
94
+ config,
95
+ stateFile: peersFile,
96
+ self: () => ({
97
+ deviceId: state?.deviceId ?? '',
98
+ deviceName: config.deviceName,
99
+ userId: identity.userId,
100
+ port: info.port,
101
+ proto: info.proto,
102
+ }),
103
+ sessions,
104
+ })
105
+
106
+ const ready = loadState(stateFile, config, log)
107
+ .then(async (loaded) => {
108
+ state = loaded
109
+ await refreshIdentity()
110
+ await start()
111
+ try {
112
+ await fabric.start()
113
+ } catch (error) {
114
+ log.warn(`[remote-control] peer discovery disabled: ${error?.message || error}`)
115
+ }
116
+ if (config.relayUrl && config.relayKey) {
117
+ try {
118
+ await startRelay(config.relayUrl, config.relayKey)
119
+ } catch (error) {
120
+ log.warn(`[remote-control] relay disabled: ${error?.message || error}`)
121
+ }
122
+ } else {
123
+ const persisted = await loadRelayConfig()
124
+ if (persisted?.hosted && persisted.key) {
125
+ try {
126
+ await startHostedRelay(persisted.key)
127
+ } catch (error) {
128
+ log.warn(`[remote-control] hosted relay disabled: ${error?.message || error}`)
129
+ }
130
+ } else if (persisted) {
131
+ try {
132
+ await startRelay(persisted.url, persisted.key)
133
+ } catch (error) {
134
+ log.warn(`[remote-control] relay disabled: ${error?.message || error}`)
135
+ }
136
+ }
137
+ }
138
+ const refresh = setInterval(() => { refreshIdentity().catch(() => {}) }, 60000)
139
+ refresh.unref?.()
140
+ ctx.effect(() => () => clearInterval(refresh))
141
+ })
142
+ .catch((error) => {
143
+ log.error(`[remote-control] disabled: ${error?.message || error}`)
144
+ })
145
+
146
+ async function refreshIdentity() {
147
+ const account = typeof ctx.get === 'function' ? safely(() => ctx.get('deepseekAccount')) : undefined
148
+ if (!account || typeof account.getDeviceIdentity !== 'function') return
149
+ try {
150
+ const value = await account.getDeviceIdentity()
151
+ identity.userId = value?.userId ?? null
152
+ } catch { /* signed out or unavailable */ }
153
+ }
154
+
155
+ const relayFile = path.join(dshHome(), 'remote-control-relay.json')
156
+
157
+ async function loadRelayConfig() {
158
+ try {
159
+ const raw = JSON.parse(await readFile(relayFile, 'utf8'))
160
+ if (raw && typeof raw.url === 'string' && typeof raw.key === 'string') return { url: raw.url, key: raw.key }
161
+ } catch (error) {
162
+ if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${relayFile}: ${error?.message || error}`)
163
+ }
164
+ return null
165
+ }
166
+
167
+ async function saveRelayConfig(value) {
168
+ try {
169
+ await writeFile(relayFile, JSON.stringify(value, null, 2), { mode: 0o600 })
170
+ } catch (error) {
171
+ log.warn(`[remote-control] could not persist ${relayFile}: ${error?.message || error}`)
172
+ }
173
+ }
174
+
175
+ async function clearRelayConfig() {
176
+ try { await rm(relayFile, { force: true }) } catch { /* ignore */ }
177
+ }
178
+
179
+ async function startRelay(url, key) {
180
+ const link = createRelayLink({
181
+ url,
182
+ key,
183
+ deviceId: state.deviceId,
184
+ deviceName: config.deviceName,
185
+ userId: identity.userId,
186
+ localPort: info.port,
187
+ log,
188
+ })
189
+ // Connect first: a failed join must not tear down a working relay link.
190
+ await link.start()
191
+ stopRelay()
192
+ relayLink = link
193
+ fabric.setRelay(link)
194
+ log.info(`[remote-control] relay connected: ${url}`)
195
+ }
196
+
197
+ function stopRelay() {
198
+ if (!relayLink) return
199
+ try { relayLink.stop() } catch { /* ignore */ }
200
+ relayLink = undefined
201
+ try { fabric?.setRelay(undefined) } catch { /* ignore */ }
202
+ }
203
+
204
+ /** Serve the relay from this Host and point our own link at it. */
205
+ async function startHostedRelay(key) {
206
+ stopHostedRelay()
207
+ relayCore = createRelay({ log })
208
+ hostedKey = key
209
+ try {
210
+ await startRelay(`http://127.0.0.1:${info.port}`, key)
211
+ } catch (error) {
212
+ stopHostedRelay()
213
+ throw error
214
+ }
215
+ }
216
+
217
+ function stopHostedRelay() {
218
+ if (relayCore) {
219
+ try { relayCore.close() } catch { /* ignore */ }
220
+ relayCore = undefined
221
+ }
222
+ hostedKey = ''
223
+ }
224
+
225
+ function shareUrls() {
226
+ if (config.publicUrl) return [config.publicUrl.replace(/\/+$/, '')]
227
+ return accessUrls(config, info.port, info.proto)
228
+ }
229
+
230
+ ctx.effect(() => () => {
231
+ for (const controller of streams) {
232
+ try { controller.abort() } catch { /* ignore */ }
233
+ }
234
+ streams.clear()
235
+ try { relayLink?.stop() } catch { /* ignore */ }
236
+ try { relayCore?.close() } catch { /* ignore */ }
237
+ try { fabric?.stop() } catch { /* ignore */ }
238
+ if (server) {
239
+ try { server.close() } catch { /* ignore */ }
240
+ server = undefined
241
+ }
242
+ })
243
+
244
+ // Discoverability: the local GUI can show the pairing code and reachable URLs.
245
+ // The handler refuses any non-loopback Host, so it never leaks over a LAN bind.
246
+ const webServer = typeof ctx.get === 'function' ? safely(() => ctx.get('webServer')) : undefined
247
+ if (webServer && typeof webServer.register === 'function') {
248
+ ctx.effect(() => webServer.register({
249
+ kind: 'exact',
250
+ path: '/remote-control',
251
+ handler: (req, res) => serveInfo(req, res, published),
252
+ }))
253
+ ctx.effect(() => webServer.register({
254
+ kind: 'prefix',
255
+ path: '/remote-control/api',
256
+ handler: (req, res) => {
257
+ handleConsole(req, res).catch((error) => {
258
+ log.warn(`[remote-control] console call failed: ${error?.message || error}`)
259
+ try { json(res, 500, { error: 'internal', message: String(error?.message || error) }) } catch { /* ignore */ }
260
+ })
261
+ },
262
+ }))
263
+ }
264
+
265
+ async function handleConsole(req, res) {
266
+ if (!consoleAllowed(req)) return json(res, 403, { error: 'forbidden' })
267
+ if (!fabric) return json(res, 503, { error: 'starting' })
268
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
269
+ const action = url.pathname.slice('/remote-control/api'.length)
270
+
271
+ if (action === '/state' && req.method === 'GET') {
272
+ const snapshot = fabric.snapshot()
273
+ const urls = relayCore ? shareUrls() : []
274
+ snapshot.relay = {
275
+ url: relayLink?.url() ?? '',
276
+ connected: relayLink?.connected() ?? false,
277
+ peers: relayLink?.peers().length ?? 0,
278
+ hosted: Boolean(relayCore),
279
+ urls,
280
+ invite: relayCore && hostedKey && urls[0] ? `${urls[0]} ${hostedKey}` : '',
281
+ }
282
+ return json(res, 200, snapshot)
283
+ }
284
+ if (action === '/relay/host' && req.method === 'POST') {
285
+ const body = await readJson(req)
286
+ if (body?.enabled === false) {
287
+ stopRelay()
288
+ stopHostedRelay()
289
+ await clearRelayConfig()
290
+ return json(res, 200, { ok: true })
291
+ }
292
+ let key = typeof body?.key === 'string' && body.key.length >= 16 ? body.key : ''
293
+ if (!key) {
294
+ const persisted = await loadRelayConfig()
295
+ key = persisted?.hosted && persisted.key ? persisted.key : randomBytes(16).toString('hex')
296
+ }
297
+ try {
298
+ await startHostedRelay(key)
299
+ } catch (error) {
300
+ return json(res, 502, { error: 'relay-failed', message: String(error?.message || error) })
301
+ }
302
+ const urls = shareUrls()
303
+ await saveRelayConfig({ hosted: true, url: urls[0] ?? '', key })
304
+ log.info(`[remote-control] hosting a relay at ${urls[0] ?? '(no address)'}`)
305
+ return json(res, 200, { ok: true, urls, invite: `${urls[0] ?? ''} ${key}` })
306
+ }
307
+ if (action === '/relay' && req.method === 'POST') {
308
+ const body = await readJson(req)
309
+ const url = String(body?.url || '').trim()
310
+ const key = String(body?.key || '')
311
+ let parsed
312
+ try { parsed = new URL(url) } catch { return json(res, 400, { error: 'bad-url', message: 'Relay URL must be absolute.' }) }
313
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
314
+ return json(res, 400, { error: 'bad-url', message: 'Relay URL must use http or https.' })
315
+ }
316
+ if (key.length < 16) {
317
+ return json(res, 400, { error: 'bad-key', message: 'Workspace key must be at least 16 characters.' })
318
+ }
319
+ try {
320
+ await startRelay(url, key)
321
+ } catch (error) {
322
+ return json(res, 502, { error: 'relay-unreachable', message: String(error?.message || error) })
323
+ }
324
+ stopHostedRelay()
325
+ await saveRelayConfig({ hosted: false, url, key })
326
+ return json(res, 200, { ok: true })
327
+ }
328
+ if (action === '/relay/clear' && req.method === 'POST') {
329
+ stopRelay()
330
+ stopHostedRelay()
331
+ await clearRelayConfig()
332
+ return json(res, 200, { ok: true })
333
+ }
334
+ if (action === '/approve' && req.method === 'POST') {
335
+ const body = await readJson(req)
336
+ return json(res, 200, await fabric.approveRequest(String(body?.requestId || '')))
337
+ }
338
+ if (action === '/deny' && req.method === 'POST') {
339
+ const body = await readJson(req)
340
+ return json(res, 200, fabric.denyRequest(String(body?.requestId || '')))
341
+ }
342
+ if (action === '/connect' && req.method === 'POST') {
343
+ const body = await readJson(req)
344
+ return json(res, 200, await fabric.connect(String(body?.deviceId || '')))
345
+ }
346
+
347
+ const match = action.match(/^\/peer\/([^/]+)\/(sessions|stream|prompt|cancel)$/)
348
+ if (match) {
349
+ const deviceId = decodeURIComponent(match[1])
350
+ const operation = match[2]
351
+ if (operation === 'sessions' && req.method === 'GET') {
352
+ const reply = await fabric.callPeer(deviceId, 'GET', '/peer/sessions')
353
+ return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
354
+ }
355
+ if (operation === 'stream' && req.method === 'GET') {
356
+ return fabric.proxyStream(deviceId, String(url.searchParams.get('sessionId') || ''), res)
357
+ }
358
+ if (operation === 'prompt' && req.method === 'POST') {
359
+ const body = await readJson(req)
360
+ const reply = await fabric.callPeer(deviceId, 'POST', '/peer/prompt', {
361
+ sessionId: body?.sessionId,
362
+ text: body?.text,
363
+ })
364
+ return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
365
+ }
366
+ if (operation === 'cancel' && req.method === 'POST') {
367
+ const body = await readJson(req)
368
+ const reply = await fabric.callPeer(deviceId, 'POST', '/peer/cancel', { sessionId: body?.sessionId })
369
+ return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
370
+ }
371
+ }
372
+ return json(res, 404, { error: 'not-found' })
373
+ }
374
+
375
+ async function start() {
376
+ const options = {}
377
+ let factory = createHttpServer
378
+ let scheme = 'http'
379
+ if (config.tls) {
380
+ const [cert, key] = await Promise.all([
381
+ readFile(path.resolve(config.tls.certFile)),
382
+ readFile(path.resolve(config.tls.keyFile)),
383
+ ])
384
+ options.cert = cert
385
+ options.key = key
386
+ factory = createHttpsServer
387
+ scheme = 'https'
388
+ }
389
+
390
+ server = factory(options, (req, res) => {
391
+ handle(req, res).catch((error) => {
392
+ log.error(`[remote-control] request failed: ${error?.stack || error}`)
393
+ try { json(res, 500, { error: 'internal', message: String(error?.message || error) }) } catch { /* ignore */ }
394
+ })
395
+ })
396
+ server.on('clientError', (_error, socket) => {
397
+ try { socket.destroy() } catch { /* ignore */ }
398
+ })
399
+
400
+ await new Promise((resolve, reject) => {
401
+ const onError = (error) => reject(error)
402
+ server.once('error', onError)
403
+ server.listen(config.port, config.host, () => {
404
+ server.off('error', onError)
405
+ resolve()
406
+ })
407
+ })
408
+
409
+ server.on('error', (error) => log.error(`[remote-control] listener error: ${error?.message || error}`))
410
+
411
+ const port = server.address()?.port ?? config.port
412
+ info.port = port
413
+ info.proto = scheme
414
+ const urls = accessUrls(config, port, scheme)
415
+ published.urls = urls
416
+ published.code = state.code
417
+ log.info(`[remote-control] pairing code: ${state.code}`)
418
+ log.info(`[remote-control] open one of: ${urls.join(' ')}`)
419
+ log.info(`[remote-control] state file: ${stateFile} (delete it to rotate the code)`)
420
+ }
421
+
422
+ async function handle(req, res) {
423
+ const host = String(req.headers.host || '')
424
+ if (!hostAllowed(host, config)) {
425
+ return json(res, 403, { error: 'host-not-allowed', message: 'This Host header is not trusted by the remote-control listener.' })
426
+ }
427
+
428
+ const url = new URL(req.url || '/', `http://${host || 'localhost'}`)
429
+ const pathname = url.pathname
430
+
431
+ if (pathname === '/peer' || pathname.startsWith('/peer/')) {
432
+ return fabric.handlePeer(req, res, url)
433
+ }
434
+
435
+ if (relayCore && pathname.startsWith('/v1/')) {
436
+ return relayCore.handle(req, res)
437
+ }
438
+
439
+ if (req.method === 'GET' && STATIC_FILES[pathname]) {
440
+ const [file, type] = STATIC_FILES[pathname]
441
+ return serveStatic(res, file, type)
442
+ }
443
+
444
+ if (pathname === '/api/me') {
445
+ return json(res, 200, {
446
+ authenticated: isAuthenticated(req, state.secret),
447
+ name: 'DSH Remote Control',
448
+ version: '0.1.0',
449
+ })
450
+ }
451
+
452
+ if (pathname === '/api/pair' && req.method === 'POST') {
453
+ return pair(req, res)
454
+ }
455
+
456
+ if (pathname.startsWith('/api/')) {
457
+ if (!isAuthenticated(req, state.secret)) {
458
+ return json(res, 401, { error: 'unauthorized' })
459
+ }
460
+ if (req.method === 'POST' && !sameOrigin(req, config)) {
461
+ return json(res, 403, { error: 'origin-not-allowed' })
462
+ }
463
+ }
464
+
465
+ if (pathname === '/api/logout' && req.method === 'POST') {
466
+ res.setHeader('set-cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`)
467
+ return json(res, 200, { ok: true })
468
+ }
469
+
470
+ if (pathname === '/api/sessions' && req.method === 'GET') {
471
+ if (!ctx.sessionController) return json(res, 503, { error: 'session-controller-unavailable' })
472
+ return listSessions(res)
473
+ }
474
+
475
+ if (pathname === '/api/stream' && req.method === 'GET') {
476
+ if (!ctx.sessionController) return json(res, 503, { error: 'session-controller-unavailable' })
477
+ return streamSession(req, res, url)
478
+ }
479
+
480
+ if (pathname === '/api/prompt' && req.method === 'POST') {
481
+ if (!ctx.sessionController) return json(res, 503, { error: 'session-controller-unavailable' })
482
+ return promptSession(req, res)
483
+ }
484
+
485
+ if (pathname === '/api/cancel' && req.method === 'POST') {
486
+ if (!ctx.sessionController) return json(res, 503, { error: 'session-controller-unavailable' })
487
+ return cancelSession(req, res)
488
+ }
489
+
490
+ if (pathname === '/api/new-session' && req.method === 'POST') {
491
+ if (!ctx.sessionController) return json(res, 503, { error: 'session-controller-unavailable' })
492
+ return newSession(req, res)
493
+ }
494
+
495
+ return json(res, 404, { error: 'not-found' })
496
+ }
497
+
498
+ async function pair(req, res) {
499
+ if (!sameOrigin(req, config)) return json(res, 403, { error: 'origin-not-allowed' })
500
+ const body = await readJson(req)
501
+ const code = typeof body?.code === 'string' ? body.code.trim() : ''
502
+ const ip = clientIp(req)
503
+ if (tooManyAttempts(ip)) {
504
+ return json(res, 429, { error: 'rate-limited', message: 'Too many attempts. Wait a few minutes and try again.' })
505
+ }
506
+ if (!constantTimeEquals(code, state.code)) {
507
+ recordFailure(ip)
508
+ log.warn(`[remote-control] rejected pairing attempt from ${ip}`)
509
+ return json(res, 401, { error: 'bad-code', message: 'That pairing code is not correct.' })
510
+ }
511
+ recordSuccess(ip)
512
+ const token = issueToken(state.secret, config.cookieDays)
513
+ const attributes = [
514
+ `${COOKIE_NAME}=${token}`,
515
+ 'Path=/',
516
+ 'HttpOnly',
517
+ 'SameSite=Strict',
518
+ `Max-Age=${Math.round(Math.max(1, config.cookieDays) * 86400)}`,
519
+ ]
520
+ if (config.tls) attributes.push('Secure')
521
+ res.setHeader('set-cookie', attributes.join('; '))
522
+ log.info(`[remote-control] paired a browser from ${ip}`)
523
+ return json(res, 200, { ok: true })
524
+ }
525
+
526
+ async function listSessions(res) {
527
+ const items = await sessions.list()
528
+ return json(res, 200, { items })
529
+ }
530
+
531
+ async function streamSession(req, res, url) {
532
+ const sessionId = String(url.searchParams.get('sessionId') || '')
533
+ if (!sessionId) return json(res, 400, { error: 'missing-session' })
534
+
535
+ const controller = new AbortController()
536
+ streams.add(controller)
537
+ res.writeHead(200, {
538
+ 'content-type': 'text/event-stream; charset=utf-8',
539
+ 'cache-control': 'no-store, no-transform',
540
+ connection: 'keep-alive',
541
+ 'x-accel-buffering': 'no',
542
+ })
543
+ res.write(': connected\n\n')
544
+
545
+ const heartbeat = setInterval(() => {
546
+ try { res.write(': ping\n\n') } catch { /* ignore */ }
547
+ }, 15000)
548
+ heartbeat.unref?.()
549
+
550
+ let closed = false
551
+ const cleanup = () => {
552
+ if (closed) return
553
+ closed = true
554
+ clearInterval(heartbeat)
555
+ streams.delete(controller)
556
+ try { controller.abort() } catch { /* ignore */ }
557
+ try { res.end() } catch { /* ignore */ }
558
+ }
559
+ req.on('close', cleanup)
560
+ req.on('aborted', cleanup)
561
+
562
+ try {
563
+ const frames = ctx.sessionController.follow(
564
+ { address: { kind: 'session', sessionId }, assistantStream: true },
565
+ controller.signal,
566
+ )
567
+ for await (const frame of frames) {
568
+ if (res.writableEnded || res.destroyed) break
569
+ res.write(`data: ${JSON.stringify(frame)}\n\n`)
570
+ }
571
+ } catch (error) {
572
+ if (!controller.signal.aborted) {
573
+ const payload = { type: 'remote-error', code: error?.code ?? 'error', message: error?.message ?? String(error) }
574
+ try { res.write(`data: ${JSON.stringify(payload)}\n\n`) } catch { /* ignore */ }
575
+ log.warn(`[remote-control] follow failed for ${sessionId}: ${error?.message || error}`)
576
+ }
577
+ } finally {
578
+ cleanup()
579
+ }
580
+ }
581
+
582
+ async function promptSession(req, res) {
583
+ const body = await readJson(req)
584
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
585
+ const text = typeof body?.text === 'string' ? body.text : ''
586
+ const mode = body?.mode === 'steer' ? 'steer' : 'queue'
587
+ if (!sessionId) return json(res, 400, { error: 'missing-session' })
588
+ if (!text.trim()) return json(res, 400, { error: 'empty-prompt', message: 'Write a message first.' })
589
+
590
+ const accepted = await ctx.sessionController.prompt({
591
+ requestId: randomUUID(),
592
+ sessionId,
593
+ mode,
594
+ content: [{ type: 'text', text }],
595
+ clientTimeZone: timeZone(),
596
+ }, AbortSignal.timeout(30000))
597
+ return json(res, 200, accepted ?? { accepted: true })
598
+ }
599
+
600
+ async function cancelSession(req, res) {
601
+ const body = await readJson(req)
602
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
603
+ if (!sessionId) return json(res, 400, { error: 'missing-session' })
604
+ const value = ctx.sessionController.cancel({ sessionId })
605
+ return json(res, 200, value ?? { accepted: true })
606
+ }
607
+
608
+ async function newSession(req, res) {
609
+ const body = await readJson(req)
610
+ const request = {}
611
+ if (typeof body?.cwd === 'string' && body.cwd.trim()) request.cwd = body.cwd.trim()
612
+ else if (config.defaultCwd) request.cwd = config.defaultCwd
613
+ const value = await ctx.sessionController.create(request)
614
+ return json(res, 200, value)
615
+ }
616
+
617
+ async function serveStatic(res, file, type) {
618
+ try {
619
+ const data = await readFile(path.join(WEB_DIR, file))
620
+ res.writeHead(200, {
621
+ 'content-type': type,
622
+ 'cache-control': 'no-store',
623
+ 'content-length': data.length,
624
+ 'x-content-type-options': 'nosniff',
625
+ })
626
+ res.end(data)
627
+ } catch {
628
+ json(res, 404, { error: 'not-found' })
629
+ }
630
+ }
631
+
632
+ function json(res, status, value) {
633
+ const body = JSON.stringify(value)
634
+ res.writeHead(status, {
635
+ 'content-type': 'application/json; charset=utf-8',
636
+ 'content-length': Buffer.byteLength(body),
637
+ 'cache-control': 'no-store',
638
+ 'x-content-type-options': 'nosniff',
639
+ })
640
+ res.end(body)
641
+ }
642
+ }
643
+
644
+ /* ------------------------------------------------------------------ */
645
+ /* loopback discovery page */
646
+ /* ------------------------------------------------------------------ */
647
+
648
+ function serveInfo(req, res, published) {
649
+ const headers = {
650
+ 'content-type': 'text/html; charset=utf-8',
651
+ 'cache-control': 'no-store',
652
+ 'x-content-type-options': 'nosniff',
653
+ }
654
+ const host = String(req.headers?.host || '')
655
+ const hostname = host.startsWith('[') ? host.slice(1, host.indexOf(']')) : host.split(':')[0]
656
+ const local = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
657
+ if (!local) {
658
+ res.writeHead(403, headers)
659
+ res.end(renderInfoPage('Local only', '<p class="muted">Open this page from the harness computer itself.</p>'))
660
+ return
661
+ }
662
+
663
+ const code = published.code || 'starting…'
664
+ const urls = published.urls.length > 0 ? published.urls : []
665
+ const items = urls.length > 0
666
+ ? urls.map((url) => `<li><a href="${escapeHtml(url)}" target="_blank" rel="noreferrer">${escapeHtml(url)}</a></li>`).join('')
667
+ : '<li class="muted">starting…</li>'
668
+ const body = `
669
+ <p class="muted">Open one of these addresses on your phone or another computer, then enter the pairing code.</p>
670
+ <ul class="urls">${items}</ul>
671
+ <div class="code" aria-label="Pairing code">${escapeHtml(code)}</div>
672
+ <p class="muted small">The code is stored in <code>remote-control.json</code> inside your DSH home directory. Deleting that file rotates the code and forgets every paired device.</p>`
673
+ res.writeHead(200, headers)
674
+ res.end(renderInfoPage('Remote Control', body, code))
675
+ }
676
+
677
+ function renderInfoPage(title, body) {
678
+ return `<!doctype html>
679
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
680
+ <title>${escapeHtml(title)}</title>
681
+ <style>
682
+ :root { color-scheme: dark light; --bg:#0b0d12; --fg:#e8ecf4; --muted:#93a0b5; --line:#232936; --accent:#5b8bff; }
683
+ @media (prefers-color-scheme: light) { :root { --bg:#f5f7fb; --fg:#14181f; --muted:#5d6779; --line:#dde2ec; --accent:#2f6bff; } }
684
+ body { margin:0; min-height:100vh; display:grid; place-items:center; background:var(--bg); color:var(--fg);
685
+ font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; padding:24px; }
686
+ .card { width:100%; max-width:520px; border:1px solid var(--line); border-radius:18px; padding:24px; background:color-mix(in srgb, var(--fg) 3%, var(--bg)); }
687
+ h1 { font-size:18px; margin:0 0 12px; }
688
+ .muted { color:var(--muted); }
689
+ .small { font-size:13px; margin-top:18px; }
690
+ .urls { padding-left:18px; margin:12px 0 18px; }
691
+ .urls a { color:var(--accent); word-break:break-all; }
692
+ .code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:34px; letter-spacing:.28em;
693
+ text-align:center; padding:14px; border:1px dashed var(--line); border-radius:14px; }
694
+ code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
695
+ </style></head>
696
+ <body><div class="card"><h1>${escapeHtml(title)}</h1>${body}</div></body></html>`
697
+ }
698
+
699
+ function escapeHtml(value) {
700
+ return String(value).replace(/[&<>"']/g, (character) => ({
701
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
702
+ }[character]))
703
+ }
704
+
705
+ /* ------------------------------------------------------------------ */
706
+ /* configuration */
707
+ /* ------------------------------------------------------------------ */
708
+
709
+ function normalizeConfig(raw = {}) {
710
+ const config = {
711
+ host: typeof raw?.host === 'string' && raw.host.trim() ? raw.host.trim() : '0.0.0.0',
712
+ port: Number.isInteger(raw?.port) ? raw.port : 57330,
713
+ publicUrl: typeof raw?.publicUrl === 'string' ? raw.publicUrl.trim() : '',
714
+ trustedHosts: Array.isArray(raw?.trustedHosts) ? raw.trustedHosts.filter((entry) => typeof entry === 'string') : [],
715
+ code: typeof raw?.code === 'string' ? raw.code.trim() : '',
716
+ cookieDays: Number.isFinite(raw?.cookieDays) ? raw.cookieDays : 30,
717
+ defaultCwd: typeof raw?.defaultCwd === 'string' ? raw.defaultCwd.trim() : '',
718
+ discovery: raw?.discovery !== false,
719
+ discoveryPort: Number.isInteger(raw?.discoveryPort) ? raw.discoveryPort : 57331,
720
+ relayUrl: typeof raw?.relayUrl === 'string' ? raw.relayUrl.trim() : '',
721
+ relayKey: typeof raw?.relayKey === 'string' ? raw.relayKey : '',
722
+ deviceName: typeof raw?.deviceName === 'string' && raw.deviceName.trim() ? raw.deviceName.trim() : os.hostname(),
723
+ tls: raw?.tls && typeof raw.tls === 'object' ? { certFile: raw.tls.certFile, keyFile: raw.tls.keyFile } : null,
724
+ }
725
+ if (!(config.port >= 0 && config.port <= 65535)) throw new Error('remote-control: `port` must be 0-65535')
726
+ if (!(config.discoveryPort >= 1 && config.discoveryPort <= 65535)) throw new Error('remote-control: `discoveryPort` must be 1-65535')
727
+ if (config.code && !/^\d{4,12}$/.test(config.code)) throw new Error('remote-control: `code` must be 4-12 digits')
728
+ if (config.cookieDays <= 0) throw new Error('remote-control: `cookieDays` must be positive')
729
+ if (config.tls && (!config.tls.certFile || !config.tls.keyFile)) throw new Error('remote-control: `tls` needs both certFile and keyFile')
730
+ if (config.publicUrl) {
731
+ try {
732
+ new URL(config.publicUrl)
733
+ } catch {
734
+ throw new Error('remote-control: `publicUrl` must be an absolute http(s) URL')
735
+ }
736
+ }
737
+ if (config.relayUrl) {
738
+ let parsed
739
+ try {
740
+ parsed = new URL(config.relayUrl)
741
+ } catch {
742
+ throw new Error('remote-control: `relayUrl` must be an absolute http(s) URL')
743
+ }
744
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
745
+ throw new Error('remote-control: `relayUrl` must use http or https')
746
+ }
747
+ if (!config.relayKey || config.relayKey.length < 16) {
748
+ throw new Error('remote-control: `relayKey` must be at least 16 characters when `relayUrl` is set')
749
+ }
750
+ }
751
+ return config
752
+ }
753
+
754
+ function dshHome() {
755
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
756
+ }
757
+
758
+ async function loadState(file, config, log) {
759
+ let persisted
760
+ try {
761
+ persisted = JSON.parse(await readFile(file, 'utf8'))
762
+ } catch (error) {
763
+ if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${file}: ${error?.message || error}`)
764
+ }
765
+ if (persisted && typeof persisted.secret === 'string' && persisted.secret.length >= 32) {
766
+ const state = {
767
+ secret: persisted.secret,
768
+ code: config.code || (typeof persisted.code === 'string' ? persisted.code : generateCode()),
769
+ createdAt: persisted.createdAt ?? Date.now(),
770
+ deviceId: typeof persisted.deviceId === 'string' && persisted.deviceId ? persisted.deviceId : newDeviceId(),
771
+ }
772
+ if (state.deviceId !== persisted.deviceId) await persistState(file, state, log)
773
+ return state
774
+ }
775
+ const state = {
776
+ secret: randomBytes(32).toString('hex'),
777
+ code: config.code || generateCode(),
778
+ createdAt: Date.now(),
779
+ deviceId: newDeviceId(),
780
+ }
781
+ await persistState(file, state, log)
782
+ return state
783
+ }
784
+
785
+ async function persistState(file, state, log) {
786
+ try {
787
+ await mkdir(path.dirname(file), { recursive: true })
788
+ await writeFile(file, JSON.stringify(state, null, 2), { mode: 0o600 })
789
+ } catch (error) {
790
+ log.warn(`[remote-control] could not persist ${file}: ${error?.message || error}`)
791
+ }
792
+ }
793
+
794
+ function newDeviceId() {
795
+ return `dsh-${randomBytes(8).toString('hex')}`
796
+ }
797
+
798
+ function generateCode() {
799
+ return String(randomInt(0, 1_000_000)).padStart(6, '0')
800
+ }
801
+
802
+ function timeZone() {
803
+ try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return undefined }
804
+ }
805
+
806
+ function firstString(...candidates) {
807
+ for (const candidate of candidates) {
808
+ if (typeof candidate === 'string' && candidate.trim()) return candidate.trim()
809
+ }
810
+ return ''
811
+ }
812
+
813
+ function pickLogger(ctx) {
814
+ const candidate = typeof ctx?.logger === 'function' ? safely(() => ctx.logger('remote-control')) : ctx?.logger
815
+ if (candidate && typeof candidate.info === 'function') return candidate
816
+ return console
817
+ }
818
+
819
+ function safely(fn) {
820
+ try { return fn() } catch { return undefined }
821
+ }
822
+
823
+ /* ------------------------------------------------------------------ */
824
+ /* authentication */
825
+ /* ------------------------------------------------------------------ */
826
+
827
+ function parseCookies(header) {
828
+ const jar = {}
829
+ if (typeof header !== 'string') return jar
830
+ for (const part of header.split(';')) {
831
+ const index = part.indexOf('=')
832
+ if (index <= 0) continue
833
+ jar[part.slice(0, index).trim()] = part.slice(index + 1).trim()
834
+ }
835
+ return jar
836
+ }
837
+
838
+ function hmac(secret, value) {
839
+ return createHmac('sha256', secret).update(value).digest('hex')
840
+ }
841
+
842
+ function issueToken(secret, days) {
843
+ const expires = Date.now() + Math.max(1, days) * 86_400_000
844
+ return `${expires}.${hmac(secret, String(expires))}`
845
+ }
846
+
847
+ function isAuthenticated(req, secret) {
848
+ const raw = parseCookies(req.headers?.cookie)[COOKIE_NAME]
849
+ if (!raw) return false
850
+ const dot = raw.lastIndexOf('.')
851
+ if (dot <= 0) return false
852
+ const expires = raw.slice(0, dot)
853
+ const signature = raw.slice(dot + 1)
854
+ if (!/^\d{1,16}$/.test(expires) || Number(expires) < Date.now()) return false
855
+ const expected = Buffer.from(hmac(secret, expires), 'hex')
856
+ const actual = Buffer.from(signature, 'hex')
857
+ return actual.length === expected.length && timingSafeEqual(actual, expected)
858
+ }
859
+
860
+ function constantTimeEquals(value, expected) {
861
+ const left = Buffer.from(String(value))
862
+ const right = Buffer.from(String(expected))
863
+ return left.length === right.length && timingSafeEqual(left, right)
864
+ }
865
+
866
+ const attempts = new Map()
867
+
868
+ function clientIp(req) {
869
+ return String(req.socket?.remoteAddress || 'unknown')
870
+ }
871
+
872
+ function tooManyAttempts(ip) {
873
+ const entry = attempts.get(ip)
874
+ if (!entry) return false
875
+ if (entry.resetAt < Date.now()) { attempts.delete(ip); return false }
876
+ return entry.failures >= 10
877
+ }
878
+
879
+ function recordFailure(ip) {
880
+ const now = Date.now()
881
+ const entry = attempts.get(ip)
882
+ if (!entry || entry.resetAt < now) {
883
+ attempts.set(ip, { failures: 1, resetAt: now + 10 * 60_000 })
884
+ return
885
+ }
886
+ entry.failures += 1
887
+ }
888
+
889
+ function recordSuccess(ip) {
890
+ attempts.delete(ip)
891
+ }
892
+
893
+ /* ------------------------------------------------------------------ */
894
+ /* request trust */
895
+ /* ------------------------------------------------------------------ */
896
+
897
+ function hostAllowed(header, config) {
898
+ const host = String(header || '').trim()
899
+ if (!host) return false
900
+ const hostname = host.startsWith('[') ? host.slice(1, host.indexOf(']')) : host.split(':')[0]
901
+ const name = hostname.toLowerCase()
902
+ if (!name) return false
903
+ if (name === 'localhost') return true
904
+ if (config.publicUrl) {
905
+ try {
906
+ if (new URL(config.publicUrl).hostname.toLowerCase() === name) return true
907
+ } catch { /* ignore */ }
908
+ }
909
+ if (config.trustedHosts.some((entry) => entry.toLowerCase() === name || entry.toLowerCase() === host.toLowerCase())) return true
910
+ if (isIP(name)) {
911
+ if (name === '127.0.0.1' || name === '::1') return true
912
+ return isWildcard(config.host) || normalizeAddress(name) === normalizeAddress(config.host)
913
+ }
914
+ return false
915
+ }
916
+
917
+ function sameOrigin(req, config) {
918
+ const origin = req.headers?.origin
919
+ if (!origin || origin === 'null') return true
920
+ try {
921
+ const parsed = new URL(origin)
922
+ const host = String(req.headers.host || '').toLowerCase()
923
+ if (parsed.host.toLowerCase() === host) return true
924
+ if (config.publicUrl && parsed.host.toLowerCase() === new URL(config.publicUrl).host.toLowerCase()) return true
925
+ return false
926
+ } catch {
927
+ return false
928
+ }
929
+ }
930
+
931
+ /**
932
+ * The Devices panel talks to this Host half through the GUI's own origin.
933
+ * Same-origin browser fetches are allowed; a non-browser caller must be local.
934
+ */
935
+ function consoleAllowed(req) {
936
+ const site = req.headers?.['sec-fetch-site']
937
+ if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return false
938
+ if (!site && !isLoopbackAddress(req.socket?.remoteAddress)) return false
939
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
940
+ const origin = req.headers?.origin
941
+ if (typeof origin === 'string' && origin && origin !== 'null') {
942
+ try {
943
+ if (new URL(origin).host !== String(req.headers.host || '')) return false
944
+ } catch {
945
+ return false
946
+ }
947
+ }
948
+ }
949
+ return true
950
+ }
951
+
952
+ function isLoopbackAddress(address) {
953
+ const value = String(address || '')
954
+ return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1'
955
+ }
956
+
957
+ function isWildcard(host) {
958
+ return host === '0.0.0.0' || host === '::' || host === '*'
959
+ }
960
+
961
+ function normalizeAddress(value) {
962
+ return String(value).toLowerCase().replace(/^\[|\]$/g, '')
963
+ }
964
+
965
+ function accessUrls(config, port, scheme) {
966
+ if (config.publicUrl) return [config.publicUrl.replace(/\/+$/, '') + '/']
967
+ if (!isWildcard(config.host)) return [`${scheme}://${formatHost(config.host)}:${port}/`]
968
+ const addresses = []
969
+ for (const entries of Object.values(os.networkInterfaces())) {
970
+ for (const entry of entries ?? []) {
971
+ if (entry.internal) continue
972
+ if (entry.family !== 'IPv4' && entry.family !== 4) continue
973
+ addresses.push(`${scheme}://${entry.address}:${port}/`)
974
+ }
975
+ }
976
+ if (addresses.length === 0) addresses.push(`${scheme}://127.0.0.1:${port}/`)
977
+ return addresses
978
+ }
979
+
980
+ function formatHost(host) {
981
+ return host.includes(':') ? `[${host}]` : host
982
+ }
983
+
984
+ /* ------------------------------------------------------------------ */
985
+ /* request helpers */
986
+ /* ------------------------------------------------------------------ */
987
+
988
+ function readJson(req) {
989
+ return new Promise((resolve, reject) => {
990
+ const chunks = []
991
+ let size = 0
992
+ req.on('data', (chunk) => {
993
+ size += chunk.length
994
+ if (size > MAX_BODY_BYTES) {
995
+ reject(new Error('request body too large'))
996
+ req.destroy()
997
+ return
998
+ }
999
+ chunks.push(chunk)
1000
+ })
1001
+ req.on('end', () => {
1002
+ if (chunks.length === 0) return resolve({})
1003
+ try {
1004
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
1005
+ } catch (error) {
1006
+ reject(error)
1007
+ }
1008
+ })
1009
+ req.on('error', reject)
1010
+ })
1011
+ }