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.
- package/PROTOCOL.md +109 -6
- package/README.md +226 -30
- package/bin/setup-ip.mjs +329 -0
- package/cordis.patch.yml +10 -0
- package/docs/assets/mobile-device-management.png +0 -0
- package/lib/client.js +425 -0
- package/lib/devices.js +209 -0
- package/lib/{index.js → index.mjs} +427 -18
- package/package.json +33 -4
|
@@ -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":
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
608
|
-
|
|
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=${
|
|
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
|
-
|
|
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: ${
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
+
}
|