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.
@@ -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
- const url = new URL(raw)
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 { resolve(data === '' ? {} : JSON.parse(data)) } catch (cause) {
1886
- const error = new Error('request body must be valid JSON', { cause })
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 gatewayEnabled = options.gatewayEnabled === true
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 setGatewayEnabled = (enabled, reason) => {
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
- gatewayEnabled = enabled
2099
+ gatewayMode = mode
2100
+ gatewayEnabled = mode !== 'disabled'
2060
2101
  waitExpiresAt = null
2061
- connectedSinceEnabled = false
2062
- if (enabled) {
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.size > 0) return
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 ${enabled ? 'enabled' : 'disabled'}${reason ? `: ${reason}` : ''}`)
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
- // Startup-config enablement (gatewayEnabled: true in cordis.patch.yml) is a
2307
- // standing-service decision made by the operator: do NOT arm the no-device
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 (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
2368
- setGatewayEnabled(body.enabled, 'changed from management UI')
2369
- sendJson(res, 200, { gatewayEnabled, waitExpiresAt, connectedClients: clients.size })
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.2",
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",