dsh-plugin-mobile-gateway 0.7.2 → 0.7.3
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 +23 -1
- package/README.md +34 -1
- package/cordis.patch.yml +8 -0
- package/docs/multi-gateway-app-integration.md +124 -0
- package/docs/multi-gateway-phase1-acceptance.md +179 -0
- package/docs/multi-gateway-todo.md +137 -0
- package/docs/runtime-architecture.architecture.json +264 -0
- package/docs/runtime-architecture.html +15001 -0
- package/docs/runtime-architecture.visual-check.html +32 -0
- package/docs/runtime-architecture.visual-check.json +548 -0
- package/lib/client.js +30 -21
- package/lib/gateway-state.mjs +57 -0
- package/lib/index.mjs +70 -20
- package/package.json +2 -2
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
export const GATEWAY_MODES = ['disabled', 'temporary', 'persistent']
|
|
6
|
+
|
|
7
|
+
// A dedicated file keeps installation identity independent of device revocation.
|
|
8
|
+
// A malformed file must fail startup rather than silently replace that identity.
|
|
9
|
+
export function createGatewayState(file) {
|
|
10
|
+
const validate = (value) => {
|
|
11
|
+
if (!value || value.version !== 1 ||
|
|
12
|
+
typeof value.gatewayId !== 'string' ||
|
|
13
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.gatewayId) ||
|
|
14
|
+
(value.mode !== null && !GATEWAY_MODES.includes(value.mode))) {
|
|
15
|
+
throw new Error('invalid gateway identity or mode')
|
|
16
|
+
}
|
|
17
|
+
return value
|
|
18
|
+
}
|
|
19
|
+
const read = () => validate(JSON.parse(fs.readFileSync(file, 'utf8')))
|
|
20
|
+
const write = (value, initial = false) => {
|
|
21
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
22
|
+
const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
23
|
+
try {
|
|
24
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
|
|
25
|
+
if (initial) fs.linkSync(tmp, file) // Never overwrite a concurrently created identity.
|
|
26
|
+
else fs.renameSync(tmp, file)
|
|
27
|
+
} finally {
|
|
28
|
+
fs.rmSync(tmp, { force: true })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
let state
|
|
32
|
+
try {
|
|
33
|
+
try {
|
|
34
|
+
state = read()
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.code !== 'ENOENT') throw error
|
|
37
|
+
state = { version: 1, gatewayId: crypto.randomUUID(), mode: null }
|
|
38
|
+
try { write(state, true) } catch (cause) {
|
|
39
|
+
if (cause.code !== 'EEXIST') throw cause
|
|
40
|
+
state = read()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
fs.chmodSync(file, 0o600)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(`failed to load gateway state ${file}: ${error.message}`, { cause: error })
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
get gatewayId() { return state.gatewayId },
|
|
49
|
+
get mode() { return state.mode },
|
|
50
|
+
setMode(mode) {
|
|
51
|
+
if (!GATEWAY_MODES.includes(mode)) throw new TypeError('invalid gateway mode')
|
|
52
|
+
const next = { ...state, mode }
|
|
53
|
+
write(next)
|
|
54
|
+
state = next
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
package/lib/index.mjs
CHANGED
|
@@ -129,6 +129,7 @@ import Schema from '@deepseek-ai/schemastery'
|
|
|
129
129
|
import { WebSocketServer } from 'ws'
|
|
130
130
|
import devicesModule from './devices.js'
|
|
131
131
|
import { createDshHostAdapter } from './dsh-host-adapter.mjs'
|
|
132
|
+
import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
132
133
|
import QRCode from 'qrcode'
|
|
133
134
|
|
|
134
135
|
const { createRegistry } = devicesModule
|
|
@@ -201,6 +202,10 @@ const Config = Schema.object({
|
|
|
201
202
|
path: Schema.string().default(DEFAULT_WS_PATH),
|
|
202
203
|
requireAuth: Schema.boolean().default(true),
|
|
203
204
|
gatewayEnabled: Schema.boolean().default(false),
|
|
205
|
+
gatewayMode: Schema.union(GATEWAY_MODES),
|
|
206
|
+
gatewayStateFile: Schema.string().default(''),
|
|
207
|
+
gatewayName: Schema.string().default(''),
|
|
208
|
+
endpoints: Schema.array(Schema.string()).default([]),
|
|
204
209
|
gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
|
|
205
210
|
maxPayloadBytes: Schema.natural().min(1024 * 1024).max(160 * 1024 * 1024).default(DEFAULT_MAX_WS_PAYLOAD_BYTES),
|
|
206
211
|
fileDownloadsEnabled: Schema.boolean().default(true),
|
|
@@ -1845,11 +1850,13 @@ function normalizePublicUrl(value, req, wsPath) {
|
|
|
1845
1850
|
const host = req.headers.host || `127.0.0.1`
|
|
1846
1851
|
raw = `${req.socket && req.socket.encrypted ? 'wss' : 'ws'}://${host}${wsPath}`
|
|
1847
1852
|
}
|
|
1848
|
-
|
|
1853
|
+
let url
|
|
1854
|
+
try { url = new URL(raw) } catch { throw badRequest('invalid WebSocket endpoint URL') }
|
|
1849
1855
|
if (url.protocol === 'https:') url.protocol = 'wss:'
|
|
1850
1856
|
if (url.protocol === 'http:') url.protocol = 'ws:'
|
|
1851
1857
|
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost and private LAN addresses)')
|
|
1852
1858
|
if (url.username || url.password || url.search || url.hash) throw badRequest('publicUrl must not contain credentials, query parameters, or a fragment')
|
|
1859
|
+
if (['0.0.0.0', '[::]', '::'].includes(url.hostname)) throw badRequest('endpoint must not use an unspecified listen address')
|
|
1853
1860
|
if (url.protocol === 'ws:' && !isPrivateNetworkHostname(url.hostname)) throw badRequest('publicUrl must use wss:// outside localhost or a private LAN')
|
|
1854
1861
|
return url.toString()
|
|
1855
1862
|
}
|
|
@@ -1882,8 +1889,12 @@ function readBody(req) {
|
|
|
1882
1889
|
reject(error)
|
|
1883
1890
|
return
|
|
1884
1891
|
}
|
|
1885
|
-
try {
|
|
1886
|
-
const
|
|
1892
|
+
try {
|
|
1893
|
+
const body = data === '' ? {} : JSON.parse(data)
|
|
1894
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new TypeError('expected an object')
|
|
1895
|
+
resolve(body)
|
|
1896
|
+
} catch (cause) {
|
|
1897
|
+
const error = new Error('request body must be a JSON object', { cause })
|
|
1887
1898
|
error.status = 400
|
|
1888
1899
|
reject(error)
|
|
1889
1900
|
}
|
|
@@ -1974,6 +1985,9 @@ const plugin = {
|
|
|
1974
1985
|
path: DEFAULT_WS_PATH,
|
|
1975
1986
|
requireAuth: true,
|
|
1976
1987
|
gatewayEnabled: false,
|
|
1988
|
+
gatewayStateFile: '',
|
|
1989
|
+
gatewayName: '',
|
|
1990
|
+
endpoints: [],
|
|
1977
1991
|
gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
|
|
1978
1992
|
maxPayloadBytes: DEFAULT_MAX_WS_PAYLOAD_BYTES,
|
|
1979
1993
|
fileDownloadsEnabled: true,
|
|
@@ -2006,6 +2020,17 @@ const plugin = {
|
|
|
2006
2020
|
let requireAuth = options.requireAuth !== false
|
|
2007
2021
|
const adminLoopbackOnly = options.adminLoopbackOnly !== false
|
|
2008
2022
|
const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
|
|
2023
|
+
if (options.gatewayMode !== undefined && !GATEWAY_MODES.includes(options.gatewayMode)) throw new Error('invalid gatewayMode')
|
|
2024
|
+
if (typeof options.gatewayName !== 'string' || options.gatewayName.trim().length > 80) throw new Error('gatewayName must be at most 80 characters')
|
|
2025
|
+
const normalizeEndpoints = (values) => {
|
|
2026
|
+
if (!Array.isArray(values) || values.length > 16 || values.some((value) => typeof value !== 'string' || !value.trim() || value.length > 2048)) {
|
|
2027
|
+
throw badRequest('endpoints must be an array of at most 16 nonempty URLs (2048 characters each)')
|
|
2028
|
+
}
|
|
2029
|
+
return [...new Set(values.map((value) => normalizePublicUrl(value, { headers: {} }, wsPath)))]
|
|
2030
|
+
}
|
|
2031
|
+
const configuredEndpoints = normalizeEndpoints(options.endpoints)
|
|
2032
|
+
const gatewayState = createGatewayState(options.gatewayStateFile || `${deviceFile}.gateway.json`)
|
|
2033
|
+
const gatewayIdentity = { gatewayId: gatewayState.gatewayId, gatewayName: options.gatewayName.trim() || os.hostname().slice(0, 80) }
|
|
2009
2034
|
const registry = createRegistry(deviceFile, { pairingTtlMs: options.pairingTtlMs })
|
|
2010
2035
|
const wss = new WebSocketServer({
|
|
2011
2036
|
noServer: true,
|
|
@@ -2029,7 +2054,8 @@ const plugin = {
|
|
|
2029
2054
|
let archivedSessionIds = null
|
|
2030
2055
|
let sessionQueues = null
|
|
2031
2056
|
let counter = 0
|
|
2032
|
-
let
|
|
2057
|
+
let gatewayMode = gatewayState.mode ?? options.gatewayMode ?? (options.gatewayEnabled === true ? 'persistent' : 'disabled')
|
|
2058
|
+
let gatewayEnabled = gatewayMode !== 'disabled'
|
|
2033
2059
|
let waitExpiresAt = null
|
|
2034
2060
|
let waitTimer = null
|
|
2035
2061
|
let connectedSinceEnabled = false
|
|
@@ -2053,25 +2079,44 @@ const plugin = {
|
|
|
2053
2079
|
}
|
|
2054
2080
|
}
|
|
2055
2081
|
|
|
2056
|
-
const
|
|
2082
|
+
const advertisedEndpoints = (primary, extra = []) => {
|
|
2083
|
+
const publicUrl = configuredPublicUrl()
|
|
2084
|
+
return normalizeEndpoints([...new Set([
|
|
2085
|
+
...(primary ? [primary] : []),
|
|
2086
|
+
...extra,
|
|
2087
|
+
...configuredEndpoints,
|
|
2088
|
+
...(publicUrl ? [publicUrl] : []),
|
|
2089
|
+
...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
|
|
2090
|
+
])])
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
const setGatewayMode = (mode, reason, persist = true) => {
|
|
2094
|
+
if (!GATEWAY_MODES.includes(mode)) throw badRequest('mode must be disabled, temporary, or persistent')
|
|
2095
|
+
// Commit before changing the live service, so a failed save never reports success.
|
|
2096
|
+
if (persist) gatewayState.setMode(mode)
|
|
2057
2097
|
if (waitTimer) clearTimeout(waitTimer)
|
|
2058
2098
|
waitTimer = null
|
|
2059
|
-
|
|
2099
|
+
gatewayMode = mode
|
|
2100
|
+
gatewayEnabled = mode !== 'disabled'
|
|
2060
2101
|
waitExpiresAt = null
|
|
2061
|
-
connectedSinceEnabled =
|
|
2062
|
-
if (
|
|
2102
|
+
connectedSinceEnabled = [...clients].some((client) => client.readyState === 1)
|
|
2103
|
+
if (mode === 'temporary' && !connectedSinceEnabled) {
|
|
2063
2104
|
waitExpiresAt = Date.now() + options.gatewayWaitTimeoutMs
|
|
2064
2105
|
waitTimer = setTimeout(() => {
|
|
2065
2106
|
waitTimer = null
|
|
2066
|
-
if (!gatewayEnabled || connectedSinceEnabled || clients.
|
|
2107
|
+
if (!gatewayEnabled || connectedSinceEnabled || [...clients].some((client) => client.readyState === 1)) return
|
|
2067
2108
|
gatewayEnabled = false
|
|
2109
|
+
gatewayMode = 'disabled'
|
|
2068
2110
|
waitExpiresAt = null
|
|
2111
|
+
try { gatewayState.setMode('disabled') } catch (error) {
|
|
2112
|
+
log(`failed to persist automatic gateway disable: ${error.message}`)
|
|
2113
|
+
}
|
|
2069
2114
|
log('mobile gateway automatically disabled: no device connected before timeout')
|
|
2070
2115
|
}, options.gatewayWaitTimeoutMs)
|
|
2071
|
-
} else {
|
|
2116
|
+
} else if (mode === 'disabled') {
|
|
2072
2117
|
for (const client of clients) client.close(4004, 'mobile gateway disabled')
|
|
2073
2118
|
}
|
|
2074
|
-
log(`mobile gateway
|
|
2119
|
+
log(`mobile gateway mode=${mode}${reason ? `: ${reason}` : ''}`)
|
|
2075
2120
|
}
|
|
2076
2121
|
|
|
2077
2122
|
const logAuthRejected = (req) => {
|
|
@@ -2303,12 +2348,8 @@ const plugin = {
|
|
|
2303
2348
|
}
|
|
2304
2349
|
}
|
|
2305
2350
|
|
|
2306
|
-
//
|
|
2307
|
-
|
|
2308
|
-
// auto-close. That safety net stays attached to the ephemeral panel toggle,
|
|
2309
|
-
// where it belongs. gatewayEnabled is already true from options at this point,
|
|
2310
|
-
// so nothing else is needed here beyond the log line.
|
|
2311
|
-
if (gatewayEnabled) log('mobile gateway enabled by startup config: standing mode, no auto-close timer')
|
|
2351
|
+
// Saved user choice wins; a temporary startup gets a fresh first-connection window.
|
|
2352
|
+
setGatewayMode(gatewayMode, 'startup', false)
|
|
2312
2353
|
|
|
2313
2354
|
log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
|
|
2314
2355
|
|
|
@@ -2333,6 +2374,9 @@ const plugin = {
|
|
|
2333
2374
|
version: PLUGIN_VERSION,
|
|
2334
2375
|
requireAuth,
|
|
2335
2376
|
gatewayEnabled,
|
|
2377
|
+
gatewayMode,
|
|
2378
|
+
...gatewayIdentity,
|
|
2379
|
+
endpoints: advertisedEndpoints(),
|
|
2336
2380
|
waitExpiresAt,
|
|
2337
2381
|
connectedClients: clients.size,
|
|
2338
2382
|
webPort: webServer.port,
|
|
@@ -2364,9 +2408,10 @@ const plugin = {
|
|
|
2364
2408
|
sendJson(res, 200, result)
|
|
2365
2409
|
} else if (req.method === 'POST' && p === '/mgw/gateway') {
|
|
2366
2410
|
const body = await readBody(req)
|
|
2367
|
-
if (
|
|
2368
|
-
|
|
2369
|
-
|
|
2411
|
+
if (body.mode !== undefined && body.enabled !== undefined) throw badRequest('provide mode or enabled, not both')
|
|
2412
|
+
if (body.mode === undefined && typeof body.enabled !== 'boolean') throw badRequest('mode or boolean enabled is required')
|
|
2413
|
+
setGatewayMode(body.mode !== undefined ? body.mode : (body.enabled ? 'temporary' : 'disabled'), 'changed from management UI')
|
|
2414
|
+
sendJson(res, 200, { gatewayEnabled, gatewayMode, waitExpiresAt, connectedClients: clients.size })
|
|
2370
2415
|
} else if (req.method === 'POST' && p === '/mgw/auth') {
|
|
2371
2416
|
const body = await readBody(req)
|
|
2372
2417
|
if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
|
|
@@ -2393,12 +2438,15 @@ const plugin = {
|
|
|
2393
2438
|
const body = await readBody(req)
|
|
2394
2439
|
const name = typeof body.name === 'string' ? body.name : undefined
|
|
2395
2440
|
const publicUrl = normalizePublicUrl(body.publicUrl || configuredPublicUrl(), req, wsPath)
|
|
2441
|
+
const endpoints = advertisedEndpoints(publicUrl, normalizeEndpoints(body.endpoints ?? []))
|
|
2396
2442
|
const pairing = registry.createPairing(name)
|
|
2397
2443
|
const payload = {
|
|
2398
2444
|
version: 2,
|
|
2399
2445
|
publicUrl,
|
|
2400
2446
|
pairingCode: pairing.code,
|
|
2401
2447
|
expiresAt: pairing.expiresAt,
|
|
2448
|
+
...gatewayIdentity,
|
|
2449
|
+
endpoints,
|
|
2402
2450
|
}
|
|
2403
2451
|
// QR/manual pairing has one canonical wire representation: the
|
|
2404
2452
|
// UTF-8 JSON payload encoded as unpadded Base64URL. Base64URL is
|
|
@@ -2568,10 +2616,12 @@ const plugin = {
|
|
|
2568
2616
|
kind: 'paired',
|
|
2569
2617
|
token: paired.token,
|
|
2570
2618
|
device: paired.device,
|
|
2619
|
+
...gatewayIdentity,
|
|
2571
2620
|
}))
|
|
2572
2621
|
}
|
|
2573
2622
|
ws.send(JSON.stringify({
|
|
2574
2623
|
kind: 'hello',
|
|
2624
|
+
...gatewayIdentity,
|
|
2575
2625
|
protocol: 3,
|
|
2576
2626
|
capabilities: [
|
|
2577
2627
|
'split-channels',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Secure mobile gateway for DeepSeek Harness with split realtime/control channels, editable queues, stop/resume, archive/rename sync, tasks, goals, commands, approvals, and file transfers",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"bin": "bin/setup-ip.mjs",
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs"
|
|
29
|
+
"test": "node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./lib/index.mjs",
|