dsh-plugin-mobile-gateway 0.1.17 → 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.
@@ -10,6 +10,11 @@
10
10
  // so it enters the target session exactly like a browser-submitted prompt.
11
11
  //
12
12
  // Wire protocol (JSON text frames):
13
+ // first pair: Sec-WebSocket-Protocol: dsh-mobile-v1, dsh-pair.<one-time-code>
14
+ // X-DSH-Device-ID: <stable installation UUID>
15
+ // -> { "kind": "paired", "token", "device" } (exactly once)
16
+ // later connects: Authorization: Bearer <device-token>
17
+ // or Sec-WebSocket-Protocol: dsh-mobile-v1, dsh-auth.<device-token>
13
18
  // client -> server: { "type": "ping" }
14
19
  // { "type": "subscribe", "sessionId": "..." } (optional filter)
15
20
  // { "type": "unsubscribe" }
@@ -43,24 +48,46 @@
43
48
  // { "type": "agent-presets" } -> preset roster (+ isDefault)
44
49
  // { "type": "defaults" } -> default agent preset + default permission
45
50
  // { "type": "set-default", "target": "agent-preset"|"permission", "value" }
46
- // server -> client: { "kind": "hello", "protocol": 1, "port", "clients" }
51
+ // server -> client: { "kind": "hello", "protocol": 2, "authenticated", "device"?, "port", "clients" }
47
52
  // { "kind": "pong", "at" }
48
53
  // { "kind": "subscribed", "sessionId" }
49
54
  // { "kind": "sent", "sessionId", "mode", "command"? }
50
55
  // { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
51
56
  // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
52
57
  // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
53
- 'use strict'
58
+ import fs from 'node:fs'
59
+ import fsp from 'node:fs/promises'
60
+ import os from 'node:os'
61
+ import path from 'node:path'
62
+ import crypto from 'node:crypto'
63
+ import Schema from '@deepseek-ai/schemastery'
64
+ import { WebSocketServer } from 'ws'
65
+ import devicesModule from './devices.js'
66
+ import QRCode from 'qrcode'
54
67
 
55
- const fs = require('node:fs')
56
- const fsp = require('node:fs/promises')
57
- const os = require('node:os')
58
- const path = require('node:path')
59
- const crypto = require('node:crypto')
60
- const { WebSocketServer } = require('ws')
68
+ const { createRegistry } = devicesModule
61
69
 
62
70
  const MAX_PREVIEW = 400
63
71
  const LOG_FILE = '/tmp/mobile-gateway.log'
72
+ const DEFAULT_WS_PATH = '/ws/mobile'
73
+ const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
74
+ const DEFAULT_GATEWAY_WAIT_TIMEOUT_MS = 5 * 60 * 1000
75
+ const MAX_MANAGEMENT_BODY_BYTES = 16 * 1024
76
+
77
+ // Cordis validates this schema at plugin load and fills these defaults. Keep
78
+ // the defaults conservative: installing the bundle must never create an
79
+ // unauthenticated network control plane.
80
+ const Config = Schema.object({
81
+ path: Schema.string().default(DEFAULT_WS_PATH),
82
+ requireAuth: Schema.boolean().default(true),
83
+ gatewayEnabled: Schema.boolean().default(false),
84
+ gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
85
+ adminLoopbackOnly: Schema.boolean().default(true),
86
+ publicUrl: Schema.string().default(''),
87
+ deviceFile: Schema.string().default(''),
88
+ pairingTtlMs: Schema.natural().min(30_000).max(15 * 60 * 1000).default(DEFAULT_PAIRING_TTL_MS),
89
+ allowQueryToken: Schema.boolean().default(false),
90
+ })
64
91
 
65
92
  function log(line) {
66
93
  const msg = `[${new Date().toISOString()}] ${line}`
@@ -590,8 +617,140 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
590
617
  return null
591
618
  }
592
619
 
593
- module.exports = {
620
+ // ---------------------------------------------------------------------------
621
+ // Device auth: a QR carries a short-lived one-time pairing code. Claiming it
622
+ // over WebSocket yields a long-lived bearer token exactly once. The registry
623
+ // stores only the token digest. Management endpoints are local-machine only by
624
+ // default and mutating requests also require a same-origin browser context.
625
+ // ---------------------------------------------------------------------------
626
+ function isLoopback(req) {
627
+ const addr = req.socket && req.socket.remoteAddress
628
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'
629
+ }
630
+
631
+ function parseProtocols(req) {
632
+ const value = req.headers['sec-websocket-protocol']
633
+ if (typeof value !== 'string') return []
634
+ return value.split(',').map((entry) => entry.trim()).filter(Boolean)
635
+ }
636
+
637
+ function extractCredential(req, allowQueryToken) {
638
+ const authorization = req.headers.authorization
639
+ if (typeof authorization === 'string') {
640
+ const match = /^Bearer\s+([A-Za-z0-9_-]{43})$/i.exec(authorization.trim())
641
+ if (match) return { kind: 'token', value: match[1] }
642
+ }
643
+
644
+ for (const protocol of parseProtocols(req)) {
645
+ if (protocol.startsWith('dsh-auth.')) return { kind: 'token', value: protocol.slice('dsh-auth.'.length) }
646
+ if (protocol.startsWith('dsh-pair.')) return { kind: 'pairing', value: protocol.slice('dsh-pair.'.length) }
647
+ }
648
+
649
+ try {
650
+ const query = new URL(req.url || '/', 'http://localhost').searchParams
651
+ const pairing = query.get('pairingCode')
652
+ if (pairing) return { kind: 'pairing', value: pairing }
653
+ const token = allowQueryToken && query.get('token')
654
+ if (token) return { kind: 'token', value: token }
655
+ } catch (error) { /* ignore */ }
656
+ return undefined
657
+ }
658
+
659
+ function extractClientDeviceId(req) {
660
+ const value = req.headers['x-dsh-device-id']
661
+ if (typeof value !== 'string') return undefined
662
+ const normalized = value.trim()
663
+ return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
664
+ }
665
+
666
+ function rejectUpgrade(socket, status, message) {
667
+ const reason = status === 401 ? 'Unauthorized' : status === 503 ? 'Service Unavailable' : 'Bad Request'
668
+ socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`)
669
+ socket.destroy()
670
+ }
671
+
672
+ function isLocalHostname(hostname) {
673
+ const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase()
674
+ return normalized === 'localhost' || normalized === '::1' || /^127(?:\.\d{1,3}){3}$/.test(normalized)
675
+ }
676
+
677
+ function badRequest(message) {
678
+ const error = new Error(message)
679
+ error.status = 400
680
+ return error
681
+ }
682
+
683
+ function normalizePublicUrl(value, req, wsPath) {
684
+ let raw = typeof value === 'string' ? value.trim() : ''
685
+ if (!raw) {
686
+ const host = req.headers.host || `127.0.0.1`
687
+ raw = `${req.socket && req.socket.encrypted ? 'wss' : 'ws'}://${host}${wsPath}`
688
+ }
689
+ const url = new URL(raw)
690
+ if (url.protocol === 'https:') url.protocol = 'wss:'
691
+ if (url.protocol === 'http:') url.protocol = 'ws:'
692
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost)')
693
+ if (url.username || url.password || url.search || url.hash) throw badRequest('publicUrl must not contain credentials, query parameters, or a fragment')
694
+ if (url.protocol === 'ws:' && !isLocalHostname(url.hostname)) throw badRequest('publicUrl must use wss:// for non-localhost endpoints')
695
+ return url.toString()
696
+ }
697
+
698
+ function isSameOrigin(req) {
699
+ const origin = req.headers.origin
700
+ if (origin === undefined) return true
701
+ if (typeof origin !== 'string' || typeof req.headers.host !== 'string') return false
702
+ try {
703
+ return new URL(origin).host === req.headers.host
704
+ } catch (error) {
705
+ return false
706
+ }
707
+ }
708
+
709
+ function readBody(req) {
710
+ return new Promise((resolve, reject) => {
711
+ let data = ''
712
+ let bytes = 0
713
+ let oversized = false
714
+ req.on('data', (chunk) => {
715
+ bytes += chunk.length
716
+ if (bytes > MAX_MANAGEMENT_BODY_BYTES) oversized = true
717
+ else data += chunk
718
+ })
719
+ req.on('end', () => {
720
+ if (oversized) {
721
+ const error = new Error('request body is too large')
722
+ error.status = 413
723
+ reject(error)
724
+ return
725
+ }
726
+ try { resolve(data === '' ? {} : JSON.parse(data)) } catch (cause) {
727
+ const error = new Error('request body must be valid JSON', { cause })
728
+ error.status = 400
729
+ reject(error)
730
+ }
731
+ })
732
+ req.on('error', reject)
733
+ })
734
+ }
735
+
736
+ function securityHeaders(contentType) {
737
+ return {
738
+ 'Content-Type': contentType,
739
+ 'Cache-Control': 'no-store',
740
+ 'X-Content-Type-Options': 'nosniff',
741
+ 'Referrer-Policy': 'no-referrer',
742
+ }
743
+ }
744
+
745
+ function sendJson(res, status, value) {
746
+ const body = JSON.stringify(value)
747
+ res.writeHead(status, { ...securityHeaders('application/json; charset=utf-8'), 'Content-Length': Buffer.byteLength(body) })
748
+ res.end(body)
749
+ }
750
+
751
+ const plugin = {
594
752
  name: 'mobile-gateway',
753
+ Config,
595
754
  // Hard dependencies: the gateway registers on the host web server and
596
755
  // admits mobile messages through the host API gateway. Cordis must not
597
756
  // activate this row before either service exists.
@@ -604,20 +763,235 @@ module.exports = {
604
763
  const api = ctx.apiProxy
605
764
  const typertGateway = ctx.typertGateway
606
765
  const agentDefaultModel = ctx.agentDefaultModel
607
- const path = (config && config.path) || '/ws/mobile'
608
- const wss = new WebSocketServer({ noServer: true })
766
+ const options = {
767
+ path: DEFAULT_WS_PATH,
768
+ requireAuth: true,
769
+ gatewayEnabled: false,
770
+ gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
771
+ adminLoopbackOnly: true,
772
+ publicUrl: '',
773
+ deviceFile: '',
774
+ pairingTtlMs: DEFAULT_PAIRING_TTL_MS,
775
+ allowQueryToken: false,
776
+ ...(config || {}),
777
+ }
778
+ const wsPath = options.path
779
+ if (typeof wsPath !== 'string' || !wsPath.startsWith('/') || wsPath.endsWith('/') || wsPath.includes('?') || wsPath.includes('#')) {
780
+ throw new Error('mobile-gateway: path must be an absolute pathname without a trailing slash, query, or fragment')
781
+ }
782
+ let requireAuth = options.requireAuth !== false
783
+ const adminLoopbackOnly = options.adminLoopbackOnly !== false
784
+ const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
785
+ const registry = createRegistry(deviceFile, { pairingTtlMs: options.pairingTtlMs })
786
+ const wss = new WebSocketServer({
787
+ noServer: true,
788
+ maxPayload: 1024 * 1024,
789
+ // Authentication data may ride in requested subprotocols, but a secret
790
+ // must never be echoed as the negotiated protocol.
791
+ handleProtocols(protocols) {
792
+ return protocols.has('dsh-mobile-v1') ? 'dsh-mobile-v1' : false
793
+ },
794
+ })
609
795
  const clients = new Set()
610
796
  let counter = 0
797
+ let gatewayEnabled = options.gatewayEnabled === true
798
+ let waitExpiresAt = null
799
+ let waitTimer = null
800
+ let connectedSinceEnabled = false
801
+ let lastAuthRejectLogAt = 0
802
+ let suppressedAuthRejects = 0
803
+
804
+ const setGatewayEnabled = (enabled, reason) => {
805
+ if (waitTimer) clearTimeout(waitTimer)
806
+ waitTimer = null
807
+ gatewayEnabled = enabled
808
+ waitExpiresAt = null
809
+ connectedSinceEnabled = false
810
+ if (enabled) {
811
+ waitExpiresAt = Date.now() + options.gatewayWaitTimeoutMs
812
+ waitTimer = setTimeout(() => {
813
+ waitTimer = null
814
+ if (!gatewayEnabled || connectedSinceEnabled || clients.size > 0) return
815
+ gatewayEnabled = false
816
+ waitExpiresAt = null
817
+ log('mobile gateway automatically disabled: no device connected before timeout')
818
+ }, options.gatewayWaitTimeoutMs)
819
+ } else {
820
+ for (const client of clients) client.close(4004, 'mobile gateway disabled')
821
+ }
822
+ log(`mobile gateway ${enabled ? 'enabled' : 'disabled'}${reason ? `: ${reason}` : ''}`)
823
+ }
611
824
 
612
- log(`applying: path=${path}, webServer.port=${webServer.port}, apiProxy=${api ? 'present' : 'absent'}`)
825
+ const logAuthRejected = (req) => {
826
+ const now = Date.now()
827
+ if (now - lastAuthRejectLogAt >= 30_000) {
828
+ const suffix = suppressedAuthRejects ? ` (${suppressedAuthRejects} repeated attempts suppressed)` : ''
829
+ const remote = (req.socket && req.socket.remoteAddress) || 'unknown'
830
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin.slice(0, 160) : '-'
831
+ const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'].slice(0, 160) : '-'
832
+ log(`auth rejected: missing or invalid device credential; remote=${remote}; origin=${origin}; user-agent=${userAgent}${suffix}`)
833
+ lastAuthRejectLogAt = now
834
+ suppressedAuthRejects = 0
835
+ } else {
836
+ suppressedAuthRejects += 1
837
+ }
838
+ }
839
+
840
+ if (gatewayEnabled) setGatewayEnabled(true, 'enabled by startup config')
841
+
842
+ log(`applying: path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
843
+
844
+ // ---- device management routes (loopback-gated admin surface) ----
845
+ const disposeMgmt = webServer.register({
846
+ kind: 'prefix',
847
+ path: '/mgw',
848
+ handler: async (req, res) => {
849
+ try {
850
+ if (adminLoopbackOnly && !isLoopback(req)) {
851
+ sendJson(res, 403, { error: 'forbidden', message: 'management API is loopback-only' })
852
+ return
853
+ }
854
+ if (req.method !== 'GET' && req.method !== 'HEAD' && !isSameOrigin(req)) {
855
+ sendJson(res, 403, { error: 'forbidden', message: 'cross-origin management request rejected' })
856
+ return
857
+ }
858
+ const url = new URL(req.url || '/', 'http://x')
859
+ const p = url.pathname
860
+ if (req.method === 'GET' && p === '/mgw/status') {
861
+ sendJson(res, 200, {
862
+ requireAuth,
863
+ gatewayEnabled,
864
+ waitExpiresAt,
865
+ connectedClients: clients.size,
866
+ wsPath,
867
+ publicUrl: options.publicUrl || null,
868
+ pairingTtlMs: options.pairingTtlMs,
869
+ queryTokenAllowed: !!options.allowQueryToken,
870
+ })
871
+ } else if (req.method === 'POST' && p === '/mgw/gateway') {
872
+ const body = await readBody(req)
873
+ if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
874
+ setGatewayEnabled(body.enabled, 'changed from management UI')
875
+ sendJson(res, 200, { gatewayEnabled, waitExpiresAt, connectedClients: clients.size })
876
+ } else if (req.method === 'POST' && p === '/mgw/auth') {
877
+ const body = await readBody(req)
878
+ if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
879
+ requireAuth = body.enabled
880
+ let disconnected = 0
881
+ if (requireAuth) {
882
+ for (const client of clients) {
883
+ if (!client.deviceId) {
884
+ disconnected += 1
885
+ client.close(4003, 'authentication enabled')
886
+ }
887
+ }
888
+ }
889
+ log(`device authentication ${requireAuth ? 'enabled' : 'disabled'} from management UI`)
890
+ sendJson(res, 200, { requireAuth, disconnected })
891
+ } else if (req.method === 'GET' && p === '/mgw/devices') {
892
+ sendJson(res, 200, { devices: registry.list() })
893
+ } else if (req.method === 'POST' && p === '/mgw/pair') {
894
+ if (!gatewayEnabled) {
895
+ const error = new Error('enable the mobile gateway before creating a pairing code')
896
+ error.status = 409
897
+ throw error
898
+ }
899
+ const body = await readBody(req)
900
+ const name = typeof body.name === 'string' ? body.name : undefined
901
+ const publicUrl = normalizePublicUrl(body.publicUrl || options.publicUrl, req, wsPath)
902
+ const pairing = registry.createPairing(name)
903
+ const payload = {
904
+ version: 2,
905
+ publicUrl,
906
+ pairingCode: pairing.code,
907
+ expiresAt: pairing.expiresAt,
908
+ }
909
+ // QR/manual pairing has one canonical wire representation: the
910
+ // UTF-8 JSON payload encoded as unpadded Base64URL. Base64URL is
911
+ // copy-safe and QR-safe (`+`, `/`, and `=` never appear), but is
912
+ // encoding rather than encryption; secrecy still comes from the
913
+ // short TTL and single-use pairing code.
914
+ const qrPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
915
+ const svg = await QRCode.toString(qrPayload, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' })
916
+ log(`pairing created: ${pairing.name} (${pairing.id}), expires=${new Date(pairing.expiresAt).toISOString()}`)
917
+ sendJson(res, 201, {
918
+ pairing: { id: pairing.id, name: pairing.name, expiresAt: pairing.expiresAt },
919
+ payload,
920
+ qrPayload,
921
+ svg,
922
+ })
923
+ } else if (req.method === 'POST' && p.startsWith('/mgw/devices/') && p.endsWith('/revoke')) {
924
+ const id = decodeURIComponent(p.slice('/mgw/devices/'.length, -'/revoke'.length))
925
+ const revoked = registry.revoke(id)
926
+ if (revoked) {
927
+ for (const client of clients) {
928
+ if (client.deviceId === id) client.close(4003, 'device revoked')
929
+ }
930
+ log(`device revoked: ${id}`)
931
+ }
932
+ sendJson(res, revoked ? 200 : 404, { revoked })
933
+ } else if (req.method === 'GET' && p === '/mgw') {
934
+ res.writeHead(302, { ...securityHeaders('text/plain; charset=utf-8'), Location: '/' })
935
+ res.end('Open the device manager from the DSH sidebar.')
936
+ } else {
937
+ sendJson(res, 404, { error: 'not-found' })
938
+ }
939
+ } catch (error) {
940
+ log(`mgmt route failed: ${error && error.message ? error.message : String(error)}`)
941
+ const status = Number.isInteger(error && error.status) ? error.status : (error instanceof TypeError ? 400 : 500)
942
+ sendJson(res, status, { error: status < 500 ? 'bad-request' : 'internal', message: error && error.message ? error.message : String(error) })
943
+ }
944
+ },
945
+ })
946
+ log(`management routes registered: /mgw (loopbackOnly=${adminLoopbackOnly})`)
613
947
 
614
948
  const disposeUpgrade = webServer.registerUpgrade({
615
- path,
949
+ path: wsPath,
616
950
  handler(req, socket, head) {
951
+ if (!gatewayEnabled) {
952
+ rejectUpgrade(socket, 503, 'mobile gateway is disabled')
953
+ return
954
+ }
955
+ let device
956
+ let paired
957
+ const credential = extractCredential(req, !!options.allowQueryToken)
958
+ if (credential && credential.kind === 'pairing') {
959
+ const clientDeviceId = extractClientDeviceId(req)
960
+ if (!clientDeviceId) {
961
+ log('pairing rejected: missing X-DSH-Device-ID (outdated client)')
962
+ rejectUpgrade(socket, 400, 'pairing requires X-DSH-Device-ID; update the iOS client')
963
+ return
964
+ }
965
+ paired = registry.claimPairing(credential.value, clientDeviceId)
966
+ device = paired && paired.device
967
+ if (!device) {
968
+ log('pairing rejected: invalid, expired, or already-used code')
969
+ rejectUpgrade(socket, 401, 'invalid or expired pairing code')
970
+ return
971
+ }
972
+ } else if (credential && credential.kind === 'token') {
973
+ device = registry.authenticate(credential.value, extractClientDeviceId(req))
974
+ }
975
+
976
+ if (requireAuth && !device) {
977
+ logAuthRejected(req)
978
+ rejectUpgrade(socket, 401, 'missing or invalid device credential')
979
+ return
980
+ }
981
+
617
982
  wss.handleUpgrade(req, socket, head, (ws) => {
618
983
  const id = ++counter
619
984
  ws.filterSessionId = undefined
985
+ ws.deviceId = device && device.id
620
986
  clients.add(ws)
987
+ if (device) registry.connected(device.id)
988
+ if (!connectedSinceEnabled) {
989
+ connectedSinceEnabled = true
990
+ waitExpiresAt = null
991
+ if (waitTimer) clearTimeout(waitTimer)
992
+ waitTimer = null
993
+ log('mobile gateway wait completed: device connected')
994
+ }
621
995
 
622
996
  ws.on('message', (data) => {
623
997
  let msg
@@ -639,7 +1013,7 @@ module.exports = {
639
1013
  ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
640
1014
  } else if (msg.type === 'message') {
641
1015
  admitMessage(api, msg).then((frame) => {
642
- if (frame) ws.send(JSON.stringify(frame))
1016
+ if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
643
1017
  })
644
1018
  } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' ||
645
1019
  msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' ||
@@ -649,7 +1023,7 @@ module.exports = {
649
1023
  msg.type === 'session-stats' || msg.type === 'default-model' ||
650
1024
  msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers') {
651
1025
  handleQuery(api, typertGateway, agentDefaultModel, msg).then((frame) => {
652
- if (frame) ws.send(JSON.stringify(frame))
1026
+ if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
653
1027
  })
654
1028
  } else {
655
1029
  ws.send(JSON.stringify({ kind: 'error', message: 'unknown message type: ' + msg.type }))
@@ -658,15 +1032,30 @@ module.exports = {
658
1032
 
659
1033
  ws.on('close', () => {
660
1034
  clients.delete(ws)
1035
+ if (ws.deviceId) registry.disconnected(ws.deviceId)
661
1036
  log(`client disconnected (id=${id}, remaining=${clients.size})`)
662
1037
  })
663
1038
 
664
- log(`client connected (id=${id}, total=${clients.size})`)
665
- ws.send(JSON.stringify({ kind: 'hello', protocol: 1, port: webServer.port, clients: clients.size }))
1039
+ log(`client connected (id=${id}, total=${clients.size})${device ? ' device=' + device.name : ''}`)
1040
+ if (paired) {
1041
+ ws.send(JSON.stringify({
1042
+ kind: 'paired',
1043
+ token: paired.token,
1044
+ device: paired.device,
1045
+ }))
1046
+ }
1047
+ ws.send(JSON.stringify({
1048
+ kind: 'hello',
1049
+ protocol: 2,
1050
+ port: webServer.port,
1051
+ clients: clients.size,
1052
+ authenticated: !!device,
1053
+ ...(device ? { device: { id: device.id, name: device.name } } : {}),
1054
+ }))
666
1055
  })
667
1056
  },
668
1057
  })
669
- log(`upgrade route registered: ${path}`)
1058
+ log(`upgrade route registered: ${wsPath}`)
670
1059
 
671
1060
  const disposeEvents = ctx.on('session/event', (session, event) => {
672
1061
  if (clients.size === 0) return
@@ -681,7 +1070,9 @@ module.exports = {
681
1070
  log('session/event listener attached')
682
1071
 
683
1072
  ctx.effect(() => () => {
1073
+ if (waitTimer) clearTimeout(waitTimer)
684
1074
  disposeUpgrade()
1075
+ disposeMgmt()
685
1076
  disposeEvents()
686
1077
  for (const client of clients) client.terminate()
687
1078
  clients.clear()
@@ -690,3 +1081,6 @@ module.exports = {
690
1081
  })
691
1082
  },
692
1083
  }
1084
+
1085
+ export { Config, admitMessage, handleQuery }
1086
+ export default plugin
package/package.json CHANGED
@@ -1,20 +1,44 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.1.17",
3
+ "version": "0.3.0",
4
4
  "description": "Persistent WebSocket gateway for dsh: exposes /ws/mobile on the web server and forwards agent session output to connected mobile clients",
5
- "main": "lib/index.js",
5
+ "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "lib",
8
+ "docs",
8
9
  "cordis.patch.yml",
9
10
  "PROTOCOL.md"
10
11
  ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/Clarklevis1995/dsh-plugin-mobile-gateway.git"
15
+ },
16
+ "homepage": "https://github.com/Clarklevis1995/dsh-plugin-mobile-gateway#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/Clarklevis1995/dsh-plugin-mobile-gateway/issues"
19
+ },
11
20
  "dependencies": {
12
- "ws": "^8.18.0"
21
+ "@deepseek-ai/schemastery": "^3.18.1",
22
+ "ws": "^8.18.0",
23
+ "qrcode": "^1.5.4"
24
+ },
25
+ "exports": {
26
+ ".": "./lib/index.mjs",
27
+ "./client": "./lib/client.js",
28
+ "./package.json": "./package.json"
13
29
  },
14
30
  "dsh": {
15
31
  "bundle": {
16
32
  "patch": "./cordis.patch.yml"
33
+ },
34
+ "client": {
35
+ "platform": "web",
36
+ "inject": [
37
+ "@deepseek-ai/dsh-client-runtime",
38
+ "@deepseek-ai/dsh-client-ui-layout",
39
+ "@deepseek-ai/dsh-client-ui-sidebar"
40
+ ]
17
41
  }
18
42
  },
19
43
  "license": "MIT"
20
- }
44
+ }