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.
- package/PROTOCOL.md +109 -6
- package/README.md +180 -30
- package/cordis.patch.yml +8 -0
- package/docs/assets/mobile-device-management.png +0 -0
- package/lib/client.js +423 -0
- package/lib/devices.js +209 -0
- package/lib/{index.js → index.mjs} +412 -18
- package/package.json +28 -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,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":
|
|
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
|
+
})
|
|
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
|
-
|
|
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
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ${
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
+
}
|