dsh-plugin-mobile-gateway 0.4.1 → 0.5.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 +123 -15
- package/README.md +32 -8
- package/cordis.patch.yml +6 -0
- package/docs/assets/whale-girl-ios-app-promo-16x9.png +0 -0
- package/docs/blog-mobile-gateway.md +542 -0
- package/lib/client.js +31 -9
- package/lib/index.mjs +259 -8
- package/package.json +2 -2
package/lib/index.mjs
CHANGED
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
// { "type": "agent-presets" } -> preset roster (+ isDefault)
|
|
49
49
|
// { "type": "defaults" } -> default agent preset + default permission
|
|
50
50
|
// { "type": "set-default", "target": "agent-preset"|"permission", "value" }
|
|
51
|
+
// { "type": "question-answer", "rpcId", "sessionId", "answers": [...] }
|
|
52
|
+
// { "type": "question-cancel", "rpcId", "sessionId" }
|
|
51
53
|
// server -> client: { "kind": "hello", "protocol": 2, "authenticated", "device"?, "port", "clients" }
|
|
52
54
|
// { "kind": "pong", "at" }
|
|
53
55
|
// { "kind": "subscribed", "sessionId" }
|
|
@@ -55,8 +57,12 @@
|
|
|
55
57
|
// { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
|
|
56
58
|
// { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
|
|
57
59
|
// { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
|
|
60
|
+
// { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
|
|
61
|
+
// { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
|
|
62
|
+
// { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
|
|
58
63
|
import fs from 'node:fs'
|
|
59
64
|
import fsp from 'node:fs/promises'
|
|
65
|
+
import http from 'node:http'
|
|
60
66
|
import os from 'node:os'
|
|
61
67
|
import path from 'node:path'
|
|
62
68
|
import crypto from 'node:crypto'
|
|
@@ -67,6 +73,13 @@ import QRCode from 'qrcode'
|
|
|
67
73
|
|
|
68
74
|
const { createRegistry } = devicesModule
|
|
69
75
|
|
|
76
|
+
const PLUGIN_VERSION = (() => {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
79
|
+
} catch {
|
|
80
|
+
return 'unknown'
|
|
81
|
+
}
|
|
82
|
+
})()
|
|
70
83
|
const MAX_PREVIEW = 400
|
|
71
84
|
const LOG_FILE = '/tmp/mobile-gateway.log'
|
|
72
85
|
const DEFAULT_WS_PATH = '/ws/mobile'
|
|
@@ -88,6 +101,10 @@ const Config = Schema.object({
|
|
|
88
101
|
pairingTtlMs: Schema.natural().min(30_000).max(15 * 60 * 1000).default(DEFAULT_PAIRING_TTL_MS),
|
|
89
102
|
allowQueryToken: Schema.boolean().default(false),
|
|
90
103
|
publicUrlFile: Schema.string().default('/etc/dsh-mobile-gateway/public-url'),
|
|
104
|
+
lanEnabled: Schema.boolean().default(false),
|
|
105
|
+
lanHost: Schema.string().default('0.0.0.0'),
|
|
106
|
+
lanPort: Schema.natural().min(1).max(65535).default(3081),
|
|
107
|
+
lanAdvertiseHost: Schema.string().default(''),
|
|
91
108
|
})
|
|
92
109
|
|
|
93
110
|
function log(line) {
|
|
@@ -665,7 +682,7 @@ function extractClientDeviceId(req) {
|
|
|
665
682
|
}
|
|
666
683
|
|
|
667
684
|
function rejectUpgrade(socket, status, message) {
|
|
668
|
-
const reason = status === 401 ? 'Unauthorized' : status === 503 ? 'Service Unavailable' : 'Bad Request'
|
|
685
|
+
const reason = status === 401 ? 'Unauthorized' : status === 403 ? 'Forbidden' : status === 404 ? 'Not Found' : status === 503 ? 'Service Unavailable' : 'Bad Request'
|
|
669
686
|
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
687
|
socket.destroy()
|
|
671
688
|
}
|
|
@@ -675,6 +692,47 @@ function isLocalHostname(hostname) {
|
|
|
675
692
|
return normalized === 'localhost' || normalized === '::1' || /^127(?:\.\d{1,3}){3}$/.test(normalized)
|
|
676
693
|
}
|
|
677
694
|
|
|
695
|
+
function isPrivateNetworkHostname(hostname) {
|
|
696
|
+
let normalized = hostname.replace(/^\[|\]$/g, '').split('%')[0].toLowerCase()
|
|
697
|
+
if (normalized.startsWith('::ffff:')) normalized = normalized.slice('::ffff:'.length)
|
|
698
|
+
if (isLocalHostname(normalized) || normalized.endsWith('.local')) return true
|
|
699
|
+
const octets = normalized.split('.').map((part) => Number(part))
|
|
700
|
+
if (octets.length === 4 && octets.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
|
|
701
|
+
return octets[0] === 10 ||
|
|
702
|
+
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
|
703
|
+
(octets[0] === 192 && octets[1] === 168) ||
|
|
704
|
+
(octets[0] === 169 && octets[1] === 254)
|
|
705
|
+
}
|
|
706
|
+
return /^(?:f[cd][0-9a-f]{2}:|fe[89ab][0-9a-f]:)/i.test(normalized)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function privateLanAddresses() {
|
|
710
|
+
const physical = []
|
|
711
|
+
const fallback = []
|
|
712
|
+
const virtualInterface = /^(?:docker|br-|veth|utun|awdl|llw|vmnet|vbox|virbr|tailscale|wg)/i
|
|
713
|
+
for (const [name, records] of Object.entries(os.networkInterfaces())) {
|
|
714
|
+
for (const record of records || []) {
|
|
715
|
+
if (!record || record.internal || record.family !== 'IPv4' || !isPrivateNetworkHostname(record.address)) continue
|
|
716
|
+
const target = virtualInterface.test(name) ? fallback : physical
|
|
717
|
+
if (!target.includes(record.address)) target.push(record.address)
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return physical.length ? physical : fallback
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function lanWebSocketUrls(options, wsPath, boundPort) {
|
|
724
|
+
if (!options.lanEnabled) return []
|
|
725
|
+
const advertised = typeof options.lanAdvertiseHost === 'string' ? options.lanAdvertiseHost.trim() : ''
|
|
726
|
+
const hosts = advertised
|
|
727
|
+
? [advertised]
|
|
728
|
+
: options.lanHost && options.lanHost !== '0.0.0.0' && options.lanHost !== '::'
|
|
729
|
+
? [options.lanHost]
|
|
730
|
+
: privateLanAddresses()
|
|
731
|
+
return hosts
|
|
732
|
+
.filter((host) => isPrivateNetworkHostname(host))
|
|
733
|
+
.map((host) => `ws://${host.includes(':') ? `[${host}]` : host}:${boundPort}${wsPath}`)
|
|
734
|
+
}
|
|
735
|
+
|
|
678
736
|
function badRequest(message) {
|
|
679
737
|
const error = new Error(message)
|
|
680
738
|
error.status = 400
|
|
@@ -690,9 +748,9 @@ function normalizePublicUrl(value, req, wsPath) {
|
|
|
690
748
|
const url = new URL(raw)
|
|
691
749
|
if (url.protocol === 'https:') url.protocol = 'wss:'
|
|
692
750
|
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)')
|
|
751
|
+
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost and private LAN addresses)')
|
|
694
752
|
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:' && !
|
|
753
|
+
if (url.protocol === 'ws:' && !isPrivateNetworkHostname(url.hostname)) throw badRequest('publicUrl must use wss:// outside localhost or a private LAN')
|
|
696
754
|
return url.toString()
|
|
697
755
|
}
|
|
698
756
|
|
|
@@ -775,12 +833,22 @@ const plugin = {
|
|
|
775
833
|
pairingTtlMs: DEFAULT_PAIRING_TTL_MS,
|
|
776
834
|
allowQueryToken: false,
|
|
777
835
|
publicUrlFile: '/etc/dsh-mobile-gateway/public-url',
|
|
836
|
+
lanEnabled: false,
|
|
837
|
+
lanHost: '0.0.0.0',
|
|
838
|
+
lanPort: 3081,
|
|
839
|
+
lanAdvertiseHost: '',
|
|
778
840
|
...(config || {}),
|
|
779
841
|
}
|
|
780
842
|
const wsPath = options.path
|
|
781
843
|
if (typeof wsPath !== 'string' || !wsPath.startsWith('/') || wsPath.endsWith('/') || wsPath.includes('?') || wsPath.includes('#')) {
|
|
782
844
|
throw new Error('mobile-gateway: path must be an absolute pathname without a trailing slash, query, or fragment')
|
|
783
845
|
}
|
|
846
|
+
if (options.lanEnabled === true && (typeof options.lanHost !== 'string' || !options.lanHost.trim())) {
|
|
847
|
+
throw new Error('mobile-gateway: lanHost must be a non-empty listen address')
|
|
848
|
+
}
|
|
849
|
+
if (options.lanEnabled === true && options.lanAdvertiseHost && !isPrivateNetworkHostname(options.lanAdvertiseHost)) {
|
|
850
|
+
throw new Error('mobile-gateway: lanAdvertiseHost must be localhost or a private LAN address')
|
|
851
|
+
}
|
|
784
852
|
let requireAuth = options.requireAuth !== false
|
|
785
853
|
const adminLoopbackOnly = options.adminLoopbackOnly !== false
|
|
786
854
|
const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
|
|
@@ -795,6 +863,8 @@ const plugin = {
|
|
|
795
863
|
},
|
|
796
864
|
})
|
|
797
865
|
const clients = new Set()
|
|
866
|
+
const pendingQuestions = new Map()
|
|
867
|
+
const questionMuxAbort = new AbortController()
|
|
798
868
|
let counter = 0
|
|
799
869
|
let gatewayEnabled = options.gatewayEnabled === true
|
|
800
870
|
let waitExpiresAt = null
|
|
@@ -802,6 +872,10 @@ const plugin = {
|
|
|
802
872
|
let connectedSinceEnabled = false
|
|
803
873
|
let lastAuthRejectLogAt = 0
|
|
804
874
|
let suppressedAuthRejects = 0
|
|
875
|
+
let lanServer = null
|
|
876
|
+
let lanListening = false
|
|
877
|
+
let lanListenError = null
|
|
878
|
+
let lanBoundPort = options.lanPort
|
|
805
879
|
const configuredPublicUrl = () => {
|
|
806
880
|
if (typeof options.publicUrl === 'string' && options.publicUrl.trim()) return options.publicUrl.trim()
|
|
807
881
|
if (typeof options.publicUrlFile !== 'string' || !options.publicUrlFile.trim()) return ''
|
|
@@ -852,6 +926,72 @@ const plugin = {
|
|
|
852
926
|
}
|
|
853
927
|
}
|
|
854
928
|
|
|
929
|
+
const questionFrameFor = (rpcId, payload, replay = false) => ({
|
|
930
|
+
kind: 'question-requested',
|
|
931
|
+
rpcId: String(rpcId),
|
|
932
|
+
sessionId: String(payload.sessionId),
|
|
933
|
+
questions: payload.questions,
|
|
934
|
+
...(replay ? { replay: true } : {}),
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
const broadcastQuestionFrame = (frame) => {
|
|
938
|
+
const wire = JSON.stringify(frame)
|
|
939
|
+
for (const client of clients) {
|
|
940
|
+
if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
|
|
941
|
+
if (client.readyState === 1) client.send(wire)
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const respondToQuestion = async (msg, cancel = false) => {
|
|
946
|
+
const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
|
|
947
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
|
|
948
|
+
if (!rpcId || !sessionId) {
|
|
949
|
+
return { kind: 'error', code: 'bad-request', message: `${msg.type} requires rpcId and sessionId`, requestType: msg.type }
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (cancel) {
|
|
953
|
+
const pending = pendingQuestions.get(rpcId)
|
|
954
|
+
if (pending && pending.sessionId !== sessionId) {
|
|
955
|
+
return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
|
|
956
|
+
}
|
|
957
|
+
} else if (!Array.isArray(msg.answers)) {
|
|
958
|
+
return { kind: 'error', code: 'bad-request', message: 'question-answer requires an answers array', requestType: msg.type, sessionId }
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const result = cancel
|
|
962
|
+
? { ok: false, error: { code: 'cancelled', message: 'question cancelled by mobile user', details: {} } }
|
|
963
|
+
: {
|
|
964
|
+
ok: true,
|
|
965
|
+
value: {
|
|
966
|
+
sessionId,
|
|
967
|
+
answer: {
|
|
968
|
+
answers: msg.answers.map((answer) => ({
|
|
969
|
+
id: answer && answer.id,
|
|
970
|
+
selected: answer && answer.selected,
|
|
971
|
+
...(answer && answer.custom !== undefined ? { custom: answer.custom } : {}),
|
|
972
|
+
})),
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
try {
|
|
978
|
+
const receipt = await api.respond({ type: 'client-response', rpcId, result })
|
|
979
|
+
log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
|
|
980
|
+
return {
|
|
981
|
+
kind: 'question-response',
|
|
982
|
+
rpcId,
|
|
983
|
+
sessionId,
|
|
984
|
+
action: cancel ? 'cancel' : 'answer',
|
|
985
|
+
accepted: receipt.accepted,
|
|
986
|
+
...(!receipt.accepted ? { reason: receipt.reason } : {}),
|
|
987
|
+
}
|
|
988
|
+
} catch (error) {
|
|
989
|
+
const message = error && error.message ? error.message : String(error)
|
|
990
|
+
log(`question response failed: rpcId=${rpcId} ${message}`)
|
|
991
|
+
return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
855
995
|
if (gatewayEnabled) setGatewayEnabled(true, 'enabled by startup config')
|
|
856
996
|
|
|
857
997
|
log(`applying: path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
|
|
@@ -874,12 +1014,22 @@ const plugin = {
|
|
|
874
1014
|
const p = url.pathname
|
|
875
1015
|
if (req.method === 'GET' && p === '/mgw/status') {
|
|
876
1016
|
sendJson(res, 200, {
|
|
1017
|
+
version: PLUGIN_VERSION,
|
|
877
1018
|
requireAuth,
|
|
878
1019
|
gatewayEnabled,
|
|
879
1020
|
waitExpiresAt,
|
|
880
1021
|
connectedClients: clients.size,
|
|
881
1022
|
wsPath,
|
|
882
1023
|
publicUrl: configuredPublicUrl() || null,
|
|
1024
|
+
lan: {
|
|
1025
|
+
enabled: options.lanEnabled === true,
|
|
1026
|
+
listening: lanListening,
|
|
1027
|
+
host: options.lanHost,
|
|
1028
|
+
port: lanBoundPort,
|
|
1029
|
+
urls: lanWebSocketUrls(options, wsPath, lanBoundPort),
|
|
1030
|
+
requireAuth: true,
|
|
1031
|
+
error: lanListenError,
|
|
1032
|
+
},
|
|
883
1033
|
pairingTtlMs: options.pairingTtlMs,
|
|
884
1034
|
queryTokenAllowed: !!options.allowQueryToken,
|
|
885
1035
|
})
|
|
@@ -960,9 +1110,7 @@ const plugin = {
|
|
|
960
1110
|
})
|
|
961
1111
|
log(`management routes registered: /mgw (loopbackOnly=${adminLoopbackOnly})`)
|
|
962
1112
|
|
|
963
|
-
const
|
|
964
|
-
path: wsPath,
|
|
965
|
-
handler(req, socket, head) {
|
|
1113
|
+
const handleMobileUpgrade = (req, socket, head, transport = {}) => {
|
|
966
1114
|
if (!gatewayEnabled) {
|
|
967
1115
|
rejectUpgrade(socket, 503, 'mobile gateway is disabled')
|
|
968
1116
|
return
|
|
@@ -988,7 +1136,11 @@ const plugin = {
|
|
|
988
1136
|
device = registry.authenticate(credential.value, extractClientDeviceId(req))
|
|
989
1137
|
}
|
|
990
1138
|
|
|
991
|
-
|
|
1139
|
+
// The separately exposed LAN listener is always authenticated. The
|
|
1140
|
+
// management UI's debug auth switch only affects the loopback DSH
|
|
1141
|
+
// listener and can never turn a LAN endpoint into an open control
|
|
1142
|
+
// plane.
|
|
1143
|
+
if ((requireAuth || transport.lan === true) && !device) {
|
|
992
1144
|
logAuthRejected(req)
|
|
993
1145
|
rejectUpgrade(socket, 401, 'missing or invalid device credential')
|
|
994
1146
|
return
|
|
@@ -1026,6 +1178,10 @@ const plugin = {
|
|
|
1026
1178
|
} else if (msg.type === 'unsubscribe') {
|
|
1027
1179
|
ws.filterSessionId = undefined
|
|
1028
1180
|
ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
|
|
1181
|
+
} else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
|
|
1182
|
+
respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
|
|
1183
|
+
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
1184
|
+
})
|
|
1029
1185
|
} else if (msg.type === 'message') {
|
|
1030
1186
|
admitMessage(api, msg).then((frame) => {
|
|
1031
1187
|
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
@@ -1062,16 +1218,67 @@ const plugin = {
|
|
|
1062
1218
|
ws.send(JSON.stringify({
|
|
1063
1219
|
kind: 'hello',
|
|
1064
1220
|
protocol: 2,
|
|
1065
|
-
port: webServer.port,
|
|
1221
|
+
port: transport.port || webServer.port,
|
|
1066
1222
|
clients: clients.size,
|
|
1067
1223
|
authenticated: !!device,
|
|
1068
1224
|
...(device ? { device: { id: device.id, name: device.name } } : {}),
|
|
1069
1225
|
}))
|
|
1226
|
+
for (const pending of pendingQuestions.values()) {
|
|
1227
|
+
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
1228
|
+
ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
|
|
1229
|
+
}
|
|
1070
1230
|
})
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const disposeUpgrade = webServer.registerUpgrade({
|
|
1234
|
+
path: wsPath,
|
|
1235
|
+
handler(req, socket, head) {
|
|
1236
|
+
handleMobileUpgrade(req, socket, head, { port: webServer.port })
|
|
1071
1237
|
},
|
|
1072
1238
|
})
|
|
1073
1239
|
log(`upgrade route registered: ${wsPath}`)
|
|
1074
1240
|
|
|
1241
|
+
// DSH intentionally keeps its own WebUI listener on loopback. When LAN
|
|
1242
|
+
// mode is enabled, expose a second, narrowly scoped listener that accepts
|
|
1243
|
+
// only the authenticated mobile WebSocket path. It does not serve the
|
|
1244
|
+
// WebUI or any /mgw management route.
|
|
1245
|
+
if (options.lanEnabled === true) {
|
|
1246
|
+
lanServer = http.createServer((req, res) => {
|
|
1247
|
+
sendJson(res, 404, { error: 'not-found' })
|
|
1248
|
+
})
|
|
1249
|
+
lanServer.on('upgrade', (req, socket, head) => {
|
|
1250
|
+
let pathname
|
|
1251
|
+
try {
|
|
1252
|
+
pathname = new URL(req.url || '/', 'http://lan.invalid').pathname
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
rejectUpgrade(socket, 400, 'invalid WebSocket path')
|
|
1255
|
+
return
|
|
1256
|
+
}
|
|
1257
|
+
if (pathname !== wsPath) {
|
|
1258
|
+
rejectUpgrade(socket, 404, 'not found')
|
|
1259
|
+
return
|
|
1260
|
+
}
|
|
1261
|
+
if (!req.socket || !req.socket.remoteAddress || !isPrivateNetworkHostname(req.socket.remoteAddress)) {
|
|
1262
|
+
rejectUpgrade(socket, 403, 'LAN listener accepts private-network clients only')
|
|
1263
|
+
return
|
|
1264
|
+
}
|
|
1265
|
+
handleMobileUpgrade(req, socket, head, { lan: true, port: lanBoundPort })
|
|
1266
|
+
})
|
|
1267
|
+
lanServer.on('error', (error) => {
|
|
1268
|
+
lanListening = false
|
|
1269
|
+
lanListenError = error && error.message ? error.message : String(error)
|
|
1270
|
+
log(`LAN listener failed: ${lanListenError}`)
|
|
1271
|
+
})
|
|
1272
|
+
lanServer.listen(options.lanPort, options.lanHost, () => {
|
|
1273
|
+
lanListening = true
|
|
1274
|
+
lanListenError = null
|
|
1275
|
+
const address = lanServer.address()
|
|
1276
|
+
if (address && typeof address === 'object') lanBoundPort = address.port
|
|
1277
|
+
const urls = lanWebSocketUrls(options, wsPath, lanBoundPort)
|
|
1278
|
+
log(`LAN listener ready: ${options.lanHost}:${lanBoundPort}${wsPath}${urls.length ? ` (${urls.join(', ')})` : ''}; authentication forced`)
|
|
1279
|
+
})
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1075
1282
|
const disposeEvents = ctx.on('session/event', (session, event) => {
|
|
1076
1283
|
if (clients.size === 0) return
|
|
1077
1284
|
const wire = buildWireEvent(session, event)
|
|
@@ -1084,11 +1291,55 @@ const plugin = {
|
|
|
1084
1291
|
})
|
|
1085
1292
|
log('session/event listener attached')
|
|
1086
1293
|
|
|
1294
|
+
const questionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
|
|
1295
|
+
? (async () => {
|
|
1296
|
+
try {
|
|
1297
|
+
for await (const envelope of api.events.mux(
|
|
1298
|
+
{ rpcId: crypto.randomUUID(), payload: {} },
|
|
1299
|
+
questionMuxAbort.signal,
|
|
1300
|
+
)) {
|
|
1301
|
+
const payload = envelope && envelope.payload
|
|
1302
|
+
if (!payload || typeof payload.type !== 'string') continue
|
|
1303
|
+
if (payload.type === 'question/requested') {
|
|
1304
|
+
const rpcId = String(envelope.rpcId)
|
|
1305
|
+
const pending = {
|
|
1306
|
+
rpcId,
|
|
1307
|
+
sessionId: String(payload.sessionId),
|
|
1308
|
+
questions: payload.questions,
|
|
1309
|
+
}
|
|
1310
|
+
pendingQuestions.set(rpcId, pending)
|
|
1311
|
+
broadcastQuestionFrame(questionFrameFor(rpcId, pending))
|
|
1312
|
+
log(`question requested: rpcId=${rpcId} session=${pending.sessionId} questions=${Array.isArray(pending.questions) ? pending.questions.length : 0}`)
|
|
1313
|
+
} else if (payload.type === 'question/resolved') {
|
|
1314
|
+
const questionRpcId = String(payload.questionRpcId)
|
|
1315
|
+
pendingQuestions.delete(questionRpcId)
|
|
1316
|
+
broadcastQuestionFrame({
|
|
1317
|
+
kind: 'question-resolved',
|
|
1318
|
+
rpcId: questionRpcId,
|
|
1319
|
+
sessionId: String(payload.sessionId),
|
|
1320
|
+
outcome: payload.outcome,
|
|
1321
|
+
})
|
|
1322
|
+
log(`question resolved: rpcId=${questionRpcId} session=${payload.sessionId} outcome=${payload.outcome}`)
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
} catch (error) {
|
|
1326
|
+
if (!questionMuxAbort.signal.aborted) {
|
|
1327
|
+
log(`question mux failed: ${error && error.message ? error.message : String(error)}`)
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
})()
|
|
1331
|
+
: null
|
|
1332
|
+
if (questionMuxTask) log('api gateway question mux attached')
|
|
1333
|
+
else log('api gateway question mux unavailable; human-in-the-loop disabled')
|
|
1334
|
+
|
|
1087
1335
|
ctx.effect(() => () => {
|
|
1088
1336
|
if (waitTimer) clearTimeout(waitTimer)
|
|
1337
|
+
questionMuxAbort.abort()
|
|
1338
|
+
pendingQuestions.clear()
|
|
1089
1339
|
disposeUpgrade()
|
|
1090
1340
|
disposeMgmt()
|
|
1091
1341
|
disposeEvents()
|
|
1342
|
+
if (lanServer) lanServer.close()
|
|
1092
1343
|
for (const client of clients) client.terminate()
|
|
1093
1344
|
clients.clear()
|
|
1094
1345
|
wss.close()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"bin": "bin/setup-ip.mjs",
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node test/setup-ip.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs"
|
|
28
|
+
"test": "node test/setup-ip.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs"
|
|
29
29
|
},
|
|
30
30
|
"exports": {
|
|
31
31
|
".": "./lib/index.mjs",
|