dsh-remote-control-plugin 0.3.5 → 0.4.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/index.js CHANGED
@@ -9,6 +9,10 @@ import { fileURLToPath } from 'node:url'
9
9
  import { createFabric } from './host/fabric.js'
10
10
  import { createRelayLink } from './host/relay-link.js'
11
11
  import { createRelay } from './host/relay-core.js'
12
+ import { checkRelayUrl, ensureReachable } from './host/reachability.js'
13
+ import { qrSvg } from './host/qr.js'
14
+ import { createUploadManager } from './host/uploads.js'
15
+ import { loadOrCreateIdentity } from './host/identity.js'
12
16
 
13
17
  const HERE = path.dirname(fileURLToPath(import.meta.url))
14
18
  const WEB_DIR = path.join(HERE, 'web')
@@ -38,6 +42,7 @@ export function apply(ctx, rawConfig) {
38
42
  const config = normalizeConfig(rawConfig)
39
43
  const stateFile = path.join(dshHome(), 'remote-control.json')
40
44
  const peersFile = path.join(dshHome(), 'remote-control-peers.json')
45
+ const identityFile = path.join(dshHome(), 'remote-control-identity.json')
41
46
  const streams = new Set()
42
47
  const published = { urls: [], code: '' }
43
48
  const info = { port: config.port, proto: config.tls ? 'https' : 'http' }
@@ -48,7 +53,10 @@ export function apply(ctx, rawConfig) {
48
53
  let fabric
49
54
  let relayLink
50
55
  let relayCore
56
+ let deviceIdentity
51
57
  let hostedKey = ''
58
+ let hostedPairCode = ''
59
+ let joinPairCode = ''
52
60
 
53
61
  const sessions = {
54
62
  async list() {
@@ -75,12 +83,29 @@ export function apply(ctx, rawConfig) {
75
83
  signal,
76
84
  )
77
85
  },
78
- prompt(sessionId, text) {
86
+ async prompt(sessionId, input) {
87
+ const text = typeof input === 'string' ? input : String(input?.text ?? '')
88
+ const requested = typeof input === 'object' && Array.isArray(input?.attachments) ? input.attachments : []
89
+ const parts = []
90
+ if (text.trim()) parts.push({ type: 'text', text })
91
+ for (const item of requested) {
92
+ if (!item || typeof item !== 'object') continue
93
+ if (item.type === 'image' || item.type === 'file') {
94
+ if (item.attachment && typeof item.attachment === 'object') parts.push({ type: item.type, attachment: item.attachment })
95
+ }
96
+ }
97
+ if (parts.length === 0) throw new Error('empty prompt')
98
+ // Admit through the attachment service so encoded images are normalized
99
+ // and durable references pass unchanged.
100
+ const attachments = typeof ctx.get === 'function' ? safely(() => ctx.get('attachments')) : undefined
101
+ const content = attachments && typeof attachments.admitPromptContent === 'function'
102
+ ? await attachments.admitPromptContent(parts)
103
+ : parts
79
104
  return ctx.sessionController.prompt({
80
105
  requestId: randomUUID(),
81
106
  sessionId,
82
107
  mode: 'queue',
83
- content: [{ type: 'text', text }],
108
+ content,
84
109
  clientTimeZone: timeZone(),
85
110
  }, AbortSignal.timeout(30000))
86
111
  },
@@ -92,10 +117,15 @@ export function apply(ctx, rawConfig) {
92
117
  * remote read must not have side effects on the target.
93
118
  */
94
119
  async config(sessionId) {
95
- const values = {}
96
- const liveSession = typeof ctx.sessions?.get === 'function' ? ctx.sessions.get(sessionId) : undefined
97
- const agent = typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined
98
- const out = { sessionId, live: Boolean(liveSession), running: Boolean(agent) }
120
+ const out = { sessionId }
121
+ // Cordis throws when a service is read as a property without `inject`,
122
+ // so every optional service is reached through ctx.get inside a guard.
123
+ let liveSession
124
+ let agent
125
+ try { liveSession = ctx.get?.('sessions')?.get?.(sessionId) } catch { /* not injected */ }
126
+ try { agent = ctx.get?.('agents')?.get?.(sessionId) } catch { /* not injected */ }
127
+ out.live = Boolean(liveSession)
128
+ out.running = Boolean(agent)
99
129
 
100
130
  try {
101
131
  const catalog = await ctx.sessionController.modelCatalog()
@@ -148,7 +178,9 @@ export function apply(ctx, rawConfig) {
148
178
 
149
179
  const needsLive = typeof patch.permission === 'string' || typeof patch.plan === 'boolean'
150
180
  if (needsLive) {
151
- let agent = typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined
181
+ let agent
182
+ let session
183
+ try { agent = ctx.get?.('agents')?.get?.(sessionId) } catch { /* not injected */ }
152
184
  if (!agent) {
153
185
  try {
154
186
  const resolved = await ctx.sessionController.resolveAgent(sessionId)
@@ -157,7 +189,7 @@ export function apply(ctx, rawConfig) {
157
189
  failures.push(`resume: ${error?.message || error}`)
158
190
  }
159
191
  }
160
- const session = typeof ctx.sessions?.get === 'function' ? ctx.sessions.get(sessionId) : undefined
192
+ try { session = ctx.get?.('sessions')?.get?.(sessionId) } catch { /* not injected */ }
161
193
 
162
194
  if (typeof patch.permission === 'string') {
163
195
  try {
@@ -187,16 +219,30 @@ export function apply(ctx, rawConfig) {
187
219
  },
188
220
  }
189
221
 
222
+ const uploads = createUploadManager({
223
+ log,
224
+ getAttachments: () => (typeof ctx.get === 'function' ? safely(() => ctx.get('attachments')) : undefined),
225
+ })
226
+
190
227
  fabric = createFabric({
191
228
  log,
192
229
  config,
193
230
  stateFile: peersFile,
231
+ store: createCredentialStore(ctx, peersFile, log),
232
+ uploads,
233
+ identity: () => deviceIdentity,
234
+ limits: { rateMax: config.rateMax, rateWindowMs: config.rateWindowMs },
194
235
  self: () => ({
195
236
  deviceId: state?.deviceId ?? '',
196
237
  deviceName: config.deviceName,
197
238
  userId: identity.userId,
198
239
  port: info.port,
199
240
  proto: info.proto,
241
+ relayUrl: relayLink?.url() ?? '',
242
+ pairCode: hostedPairCode || joinPairCode || config.pairCode || '',
243
+ publicKey: deviceIdentity?.ed25519?.publicKeyPem ?? '',
244
+ fingerprint: deviceIdentity?.fingerprint ?? '',
245
+ agreementKey: deviceIdentity?.x25519?.publicKeyPem ?? '',
200
246
  }),
201
247
  sessions,
202
248
  })
@@ -204,6 +250,11 @@ export function apply(ctx, rawConfig) {
204
250
  const ready = loadState(stateFile, config, log)
205
251
  .then(async (loaded) => {
206
252
  state = loaded
253
+ deviceIdentity = await loadOrCreateIdentity(
254
+ identityFile,
255
+ log,
256
+ createCredentialStore(ctx, identityFile, log, 'remote-control/identity'),
257
+ )
207
258
  await refreshIdentity()
208
259
  await start()
209
260
  try {
@@ -221,11 +272,12 @@ export function apply(ctx, rawConfig) {
221
272
  const persisted = await loadRelayConfig()
222
273
  if (persisted?.hosted && persisted.key) {
223
274
  try {
224
- await startHostedRelay(persisted.key)
275
+ await startHostedRelay(persisted.key, persisted.pairCode)
225
276
  } catch (error) {
226
277
  log.warn(`[remote-control] hosted relay disabled: ${error?.message || error}`)
227
278
  }
228
279
  } else if (persisted) {
280
+ joinPairCode = typeof persisted.pairCode === 'string' ? persisted.pairCode : ''
229
281
  try {
230
282
  await startRelay(persisted.url, persisted.key)
231
283
  } catch (error) {
@@ -305,10 +357,11 @@ export function apply(ctx, rawConfig) {
305
357
  }
306
358
 
307
359
  /** Serve the relay from this Host and point our own link at it. */
308
- async function startHostedRelay(key) {
360
+ async function startHostedRelay(key, code) {
309
361
  stopHostedRelay()
310
362
  relayCore = createRelay({ log })
311
363
  hostedKey = key
364
+ hostedPairCode = code || generatePairCode()
312
365
  try {
313
366
  await startRelay(`http://127.0.0.1:${info.port}`, key)
314
367
  } catch (error) {
@@ -323,6 +376,7 @@ export function apply(ctx, rawConfig) {
323
376
  relayCore = undefined
324
377
  }
325
378
  hostedKey = ''
379
+ hostedPairCode = ''
326
380
  }
327
381
 
328
382
  function shareUrls() {
@@ -335,6 +389,7 @@ export function apply(ctx, rawConfig) {
335
389
  try { controller.abort() } catch { /* ignore */ }
336
390
  }
337
391
  streams.clear()
392
+ try { uploads?.close() } catch { /* ignore */ }
338
393
  try { relayLink?.stop() } catch { /* ignore */ }
339
394
  try { relayCore?.close() } catch { /* ignore */ }
340
395
  try { fabric?.stop() } catch { /* ignore */ }
@@ -380,11 +435,13 @@ export function apply(ctx, rawConfig) {
380
435
  peers: relayLink?.peers().length ?? 0,
381
436
  hosted: Boolean(relayCore),
382
437
  urls,
383
- invite: relayCore && hostedKey && urls[0] ? `${urls[0]} ${hostedKey}` : '',
438
+ invite: relayCore && hostedKey && urls[0] ? `${urls[0]} ${hostedKey}#${hostedPairCode}` : '',
439
+ pairCode: hostedPairCode || joinPairCode || '',
384
440
  }
385
441
  return json(res, 200, snapshot)
386
442
  }
387
443
  if (action === '/relay/host' && req.method === 'POST') {
444
+ await ready
388
445
  const body = await readJson(req)
389
446
  if (body?.enabled === false) {
390
447
  stopRelay()
@@ -393,40 +450,66 @@ export function apply(ctx, rawConfig) {
393
450
  return json(res, 200, { ok: true })
394
451
  }
395
452
  let key = typeof body?.key === 'string' && body.key.length >= 16 ? body.key : ''
453
+ const persisted = await loadRelayConfig()
396
454
  if (!key) {
397
- const persisted = await loadRelayConfig()
398
455
  key = persisted?.hosted && persisted.key ? persisted.key : randomBytes(16).toString('hex')
399
456
  }
457
+ const code = persisted?.pairCode && /^\d{4,8}$/.test(persisted.pairCode) ? persisted.pairCode : ''
400
458
  try {
401
- await startHostedRelay(key)
459
+ await startHostedRelay(key, code)
402
460
  } catch (error) {
403
461
  return json(res, 502, { error: 'relay-failed', message: String(error?.message || error) })
404
462
  }
405
463
  const urls = shareUrls()
406
- await saveRelayConfig({ hosted: true, url: urls[0] ?? '', key })
407
- log.info(`[remote-control] hosting a relay at ${urls[0] ?? '(no address)'}`)
408
- return json(res, 200, { ok: true, urls, invite: `${urls[0] ?? ''} ${key}` })
464
+ let reachability = null
465
+ try {
466
+ reachability = await ensureReachable({ port: info.port, allowInsecure: config.allowInsecureRelay })
467
+ } catch { /* diagnostics only */ }
468
+ const inviteBase = reachability?.ok && reachability.url ? reachability.url : (urls[0] ?? '')
469
+ const allUrls = reachability?.ok && reachability.url ? [reachability.url, ...urls] : urls
470
+ await saveRelayConfig({ hosted: true, url: inviteBase, key, pairCode: hostedPairCode })
471
+ log.info(`[remote-control] hosting a relay at ${inviteBase || '(no address)'}`)
472
+ return json(res, 200, {
473
+ ok: true,
474
+ urls: allUrls,
475
+ pairCode: hostedPairCode,
476
+ reachability,
477
+ invite: `${inviteBase} ${key}#${hostedPairCode}`,
478
+ })
409
479
  }
410
480
  if (action === '/relay' && req.method === 'POST') {
481
+ await ready
411
482
  const body = await readJson(req)
412
- const url = String(body?.url || '').trim()
413
- const key = String(body?.key || '')
414
- let parsed
415
- try { parsed = new URL(url) } catch { return json(res, 400, { error: 'bad-url', message: 'Relay URL must be absolute.' }) }
416
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
417
- return json(res, 400, { error: 'bad-url', message: 'Relay URL must use http or https.' })
418
- }
483
+ let url = String(body?.url || '').trim()
484
+ let key = String(body?.key || '')
485
+ let code = String(body?.pairCode || '')
486
+ // Invite form "<url> <key>#<code>": accept it whole or already split.
487
+ const urlHash = url.indexOf('#')
488
+ if (urlHash >= 0) { code = code || url.slice(urlHash + 1); url = url.slice(0, urlHash) }
489
+ const keyHash = key.indexOf('#')
490
+ if (keyHash >= 0) { code = code || key.slice(keyHash + 1); key = key.slice(0, keyHash) }
491
+ const verdict = checkRelayUrl(url, { allowInsecure: config.allowInsecureRelay })
492
+ if (!verdict.ok) return json(res, 400, { error: verdict.error, message: verdict.message })
419
493
  if (key.length < 16) {
420
494
  return json(res, 400, { error: 'bad-key', message: 'Workspace key must be at least 16 characters.' })
421
495
  }
496
+ joinPairCode = /^\d{4,8}$/.test(code) ? code : ''
422
497
  try {
423
498
  await startRelay(url, key)
424
499
  } catch (error) {
425
500
  return json(res, 502, { error: 'relay-unreachable', message: String(error?.message || error) })
426
501
  }
427
502
  stopHostedRelay()
428
- await saveRelayConfig({ hosted: false, url, key })
429
- return json(res, 200, { ok: true })
503
+ await saveRelayConfig({ hosted: false, url, key, pairCode: joinPairCode })
504
+ return json(res, 200, { ok: true, pairCode: joinPairCode })
505
+ }
506
+ if (action === '/relay/reachability' && req.method === 'GET') {
507
+ await ready
508
+ if (!relayCore) {
509
+ return json(res, 409, { error: 'not-hosting', message: 'Create a relay on this device first.' })
510
+ }
511
+ const result = await ensureReachable({ port: info.port, allowInsecure: config.allowInsecureRelay })
512
+ return json(res, 200, result)
430
513
  }
431
514
  if (action === '/relay/clear' && req.method === 'POST') {
432
515
  stopRelay()
@@ -434,6 +517,33 @@ export function apply(ctx, rawConfig) {
434
517
  await clearRelayConfig()
435
518
  return json(res, 200, { ok: true })
436
519
  }
520
+ if (action === '/qr' && req.method === 'GET') {
521
+ const text = String(url.searchParams.get('text') || '')
522
+ if (!text || text.length > 106) return json(res, 400, { error: 'bad-text' })
523
+ res.writeHead(200, { 'content-type': 'image/svg+xml; charset=utf-8', 'cache-control': 'no-store' })
524
+ return res.end(qrSvg(text))
525
+ }
526
+ if (action.startsWith('/upload/') && req.method === 'POST') {
527
+ const name = action.slice('/upload/'.length)
528
+ const handler = uploads?.[name]
529
+ if (typeof handler !== 'function') return json(res, 404, { error: 'not-found' })
530
+ let body
531
+ try {
532
+ body = await readJson(req, 8 * 1024 * 1024)
533
+ } catch {
534
+ return json(res, 400, { error: 'bad-body' })
535
+ }
536
+ const result = await handler(body)
537
+ return json(res, result.status ?? 200, result)
538
+ }
539
+ if (action === '/revoke' && req.method === 'POST') {
540
+ const body = await readJson(req)
541
+ return json(res, 200, fabric.revoke(String(body?.deviceId || '')))
542
+ }
543
+ if (action === '/scopes' && req.method === 'POST') {
544
+ const body = await readJson(req)
545
+ return json(res, 200, fabric.setScopes(String(body?.deviceId || ''), body?.scopes))
546
+ }
437
547
  if (action === '/approve' && req.method === 'POST') {
438
548
  const body = await readJson(req)
439
549
  return json(res, 200, await fabric.approveRequest(String(body?.requestId || '')))
@@ -815,6 +925,75 @@ function escapeHtml(value) {
815
925
  }[character]))
816
926
  }
817
927
 
928
+ /* ------------------------------------------------------------------ */
929
+ /* approval secret store */
930
+ /* ------------------------------------------------------------------ */
931
+
932
+ /**
933
+ * Peer grants live in the Host credential store as a `grant` record, the
934
+ * sanctioned secret store, with the legacy 0600 JSON file as a fallback: a
935
+ * credential failure may never stop the plugin from starting, and a first run
936
+ * migrates the file content into the record.
937
+ */
938
+ function createCredentialStore(ctx, legacyFile, log, key = 'remote-control/peers') {
939
+ const file = {
940
+ async load() {
941
+ try {
942
+ return JSON.parse(await readFile(legacyFile, 'utf8'))
943
+ } catch (error) {
944
+ if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${legacyFile}: ${error?.message || error}`)
945
+ return null
946
+ }
947
+ },
948
+ async save(value) {
949
+ try {
950
+ await mkdir(path.dirname(legacyFile), { recursive: true })
951
+ await writeFile(legacyFile, JSON.stringify(value, null, 2), { mode: 0o600 })
952
+ } catch (error) {
953
+ log.warn(`[remote-control] could not persist ${legacyFile}: ${error?.message || error}`)
954
+ }
955
+ },
956
+ }
957
+
958
+ let credentials
959
+ try {
960
+ credentials = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
961
+ } catch {
962
+ credentials = undefined
963
+ }
964
+ if (!credentials || typeof credentials.readRecord !== 'function' || typeof credentials.modifyRecord !== 'function') {
965
+ return file
966
+ }
967
+
968
+ return {
969
+ async load() {
970
+ try {
971
+ const record = await credentials.readRecord(key)
972
+ if (record && record.kind === 'grant' && record.payload) return record.payload
973
+ } catch (error) {
974
+ log.warn(`[remote-control] credential record read failed: ${error?.message || error}`)
975
+ }
976
+ const legacy = await file.load()
977
+ if (legacy) {
978
+ try {
979
+ await credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: legacy }))
980
+ log.info('[remote-control] migrated peer grants into the credential store')
981
+ } catch { /* the file copy remains authoritative */ }
982
+ }
983
+ return legacy
984
+ },
985
+ async save(value) {
986
+ try {
987
+ await credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: value }))
988
+ return
989
+ } catch (error) {
990
+ log.warn(`[remote-control] credential record write failed, using the file fallback: ${error?.message || error}`)
991
+ }
992
+ await file.save(value)
993
+ },
994
+ }
995
+ }
996
+
818
997
  /* ------------------------------------------------------------------ */
819
998
  /* configuration */
820
999
  /* ------------------------------------------------------------------ */
@@ -832,6 +1011,14 @@ function normalizeConfig(raw = {}) {
832
1011
  discoveryPort: Number.isInteger(raw?.discoveryPort) ? raw.discoveryPort : 57331,
833
1012
  relayUrl: typeof raw?.relayUrl === 'string' ? raw.relayUrl.trim() : '',
834
1013
  relayKey: typeof raw?.relayKey === 'string' ? raw.relayKey : '',
1014
+ allowInsecureRelay: raw?.allowInsecureRelay === true,
1015
+ pairCode: typeof raw?.pairCode === 'string' ? raw.pairCode.trim() : '',
1016
+ rateMax: Number.isInteger(raw?.rateMax) ? raw.rateMax : 120,
1017
+ rateWindowMs: Number.isInteger(raw?.rateWindowMs) ? raw.rateWindowMs : 10_000,
1018
+ punchPort: Number.isInteger(raw?.punchPort) ? raw.punchPort : 0,
1019
+ stunHost: raw?.stunHost === false ? false : (typeof raw?.stunHost === 'string' ? raw.stunHost : 'stun.l.google.com'),
1020
+ stunPort: Number.isInteger(raw?.stunPort) ? raw.stunPort : 19302,
1021
+ stunTimeoutMs: Number.isInteger(raw?.stunTimeoutMs) ? raw.stunTimeoutMs : 1500,
835
1022
  deviceName: typeof raw?.deviceName === 'string' && raw.deviceName.trim() ? raw.deviceName.trim() : os.hostname(),
836
1023
  tls: raw?.tls && typeof raw.tls === 'object' ? { certFile: raw.tls.certFile, keyFile: raw.tls.keyFile } : null,
837
1024
  }
@@ -848,15 +1035,8 @@ function normalizeConfig(raw = {}) {
848
1035
  }
849
1036
  }
850
1037
  if (config.relayUrl) {
851
- let parsed
852
- try {
853
- parsed = new URL(config.relayUrl)
854
- } catch {
855
- throw new Error('remote-control: `relayUrl` must be an absolute http(s) URL')
856
- }
857
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
858
- throw new Error('remote-control: `relayUrl` must use http or https')
859
- }
1038
+ const verdict = checkRelayUrl(config.relayUrl, { allowInsecure: config.allowInsecureRelay })
1039
+ if (!verdict.ok) throw new Error(`remote-control: ${verdict.message}`)
860
1040
  if (!config.relayKey || config.relayKey.length < 16) {
861
1041
  throw new Error('remote-control: `relayKey` must be at least 16 characters when `relayUrl` is set')
862
1042
  }
@@ -912,6 +1092,10 @@ function generateCode() {
912
1092
  return String(randomInt(0, 1_000_000)).padStart(6, '0')
913
1093
  }
914
1094
 
1095
+ function generatePairCode() {
1096
+ return String(randomInt(0, 1_000_000)).padStart(6, '0')
1097
+ }
1098
+
915
1099
  function timeZone() {
916
1100
  try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return undefined }
917
1101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-control-plugin",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Remote control for DSH: a Devices panel that discovers other DSH desktops signed into the same account, pairs with them once, and drives their sessions.",
6
6
  "exports": {