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