dsh-plugin-mobile-gateway 0.7.7 → 0.8.1
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 +38 -12
- package/README.md +30 -22
- package/cordis.patch.yml +4 -0
- package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -0
- package/docs/multi-gateway-app-integration.md +2 -1
- package/lib/client.js +254 -18
- package/lib/cloudflare-tunnel.mjs +268 -0
- package/lib/cloudflared-binary.mjs +114 -0
- package/lib/dsh-host-adapter.mjs +20 -6
- package/lib/gateway-tools.mjs +36 -0
- package/lib/index.mjs +215 -44
- package/lib/web-restart.mjs +47 -0
- package/package.json +3 -3
package/lib/index.mjs
CHANGED
|
@@ -132,6 +132,8 @@ import devicesModule from './devices.js'
|
|
|
132
132
|
import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
|
|
133
133
|
import { createSessionFollower } from './session-follower.mjs'
|
|
134
134
|
import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
135
|
+
import { createCloudflareTunnel } from './cloudflare-tunnel.mjs'
|
|
136
|
+
import { webRestartAvailable, prepareWebRestart } from './gateway-tools.mjs'
|
|
135
137
|
import QRCode from 'qrcode'
|
|
136
138
|
|
|
137
139
|
const { createRegistry } = devicesModule
|
|
@@ -225,6 +227,9 @@ const Config = Schema.object({
|
|
|
225
227
|
lanHost: Schema.string().default('0.0.0.0'),
|
|
226
228
|
lanPort: Schema.natural().min(1).max(65535).default(3081),
|
|
227
229
|
lanAdvertiseHost: Schema.string().default(''),
|
|
230
|
+
cloudflaredPath: Schema.string().default('cloudflared'),
|
|
231
|
+
cloudflarePort: Schema.natural().min(1).max(65535).default(3082),
|
|
232
|
+
cloudflareStateFile: Schema.string().default(''),
|
|
228
233
|
})
|
|
229
234
|
|
|
230
235
|
function log(line) {
|
|
@@ -323,7 +328,8 @@ function buildWireEvent(session, event) {
|
|
|
323
328
|
case 'tool/result': {
|
|
324
329
|
let preview = ''
|
|
325
330
|
for (const block of (d.message && d.message.content) || []) {
|
|
326
|
-
|
|
331
|
+
const content = block?.type === 'tool-result' ? block.content || [] : [block]
|
|
332
|
+
for (const inner of content) {
|
|
327
333
|
if (inner.type === 'text' && typeof inner.text === 'string') preview += inner.text
|
|
328
334
|
}
|
|
329
335
|
}
|
|
@@ -333,9 +339,10 @@ function buildWireEvent(session, event) {
|
|
|
333
339
|
type: 'tool/result',
|
|
334
340
|
turn: d.turn,
|
|
335
341
|
step: d.step,
|
|
336
|
-
callId: d.message
|
|
337
|
-
isError: !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
|
|
342
|
+
callId: d.message?.toolCallId ?? d.message?.source?.callId,
|
|
343
|
+
isError: d.message?.isError === true || !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
|
|
338
344
|
preview,
|
|
345
|
+
...(d.error ? { error: d.error } : {}),
|
|
339
346
|
},
|
|
340
347
|
})
|
|
341
348
|
}
|
|
@@ -1122,7 +1129,7 @@ function validateHistoryCursor(msg, sessionId, field) {
|
|
|
1122
1129
|
if (msg.historyFormatVersion !== SESSION_FORMAT_VERSION) {
|
|
1123
1130
|
return { kind: 'error', code: 'history-format-mismatch', requestType: msg.type, sessionId,
|
|
1124
1131
|
historyFormatVersion: SESSION_FORMAT_VERSION, resetRequired: true,
|
|
1125
|
-
message:
|
|
1132
|
+
message: `Reload the Session baseline before using a history or fork cursor; historyFormatVersion must be ${SESSION_FORMAT_VERSION}` }
|
|
1126
1133
|
}
|
|
1127
1134
|
if (msg[field] !== undefined && (!Number.isSafeInteger(msg[field]) || msg[field] < 0)) {
|
|
1128
1135
|
return { kind: 'error', code: 'bad-request', requestType: msg.type, sessionId,
|
|
@@ -1220,7 +1227,9 @@ async function loadCommandOptions(api, command, sessionId) {
|
|
|
1220
1227
|
const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
|
|
1221
1228
|
if (history.kind !== 'history') return history
|
|
1222
1229
|
const permissions = history.projections && history.projections.values && history.projections.values.permissions
|
|
1223
|
-
|
|
1230
|
+
const catalog = await proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {})
|
|
1231
|
+
if (catalog.kind === 'error') return catalog
|
|
1232
|
+
if (!permissions || typeof permissions.currentValue !== 'string' || !Array.isArray(catalog.options)) {
|
|
1224
1233
|
return {
|
|
1225
1234
|
kind: 'error',
|
|
1226
1235
|
code: 'command-options-unavailable',
|
|
@@ -1233,7 +1242,7 @@ async function loadCommandOptions(api, command, sessionId) {
|
|
|
1233
1242
|
kind: 'command-options',
|
|
1234
1243
|
sessionId,
|
|
1235
1244
|
command,
|
|
1236
|
-
options:
|
|
1245
|
+
options: catalog.options.map((option) => ({
|
|
1237
1246
|
id: String(option.value),
|
|
1238
1247
|
label: String(option.name || option.value),
|
|
1239
1248
|
...(option.description ? { description: String(option.description) } : {}),
|
|
@@ -1660,11 +1669,18 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1660
1669
|
return proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
|
|
1661
1670
|
}
|
|
1662
1671
|
if (msg.type === 'permission-options') {
|
|
1663
|
-
const frame = await
|
|
1672
|
+
const [frame, catalog] = await Promise.all([
|
|
1673
|
+
proxyQuery(api, 'permission-options', api.settings.describe.bind(api.settings), {}),
|
|
1674
|
+
proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {}),
|
|
1675
|
+
])
|
|
1664
1676
|
if (frame.kind !== 'permission-options') return frame
|
|
1677
|
+
if (catalog.kind === 'error') return catalog
|
|
1665
1678
|
const out = {
|
|
1666
1679
|
kind: 'permission-options',
|
|
1667
1680
|
namespace: (frame.namespaces || []).find((n) => n.ns === 'permission') || null,
|
|
1681
|
+
options: catalog.options,
|
|
1682
|
+
defaultOptions: catalog.defaultOptions,
|
|
1683
|
+
defaultPreset: catalog.defaultPreset,
|
|
1668
1684
|
}
|
|
1669
1685
|
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
1670
1686
|
if (sessionId) {
|
|
@@ -1672,7 +1688,8 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1672
1688
|
const hist = await querySessionProjection(api, msg.type, sessionId)
|
|
1673
1689
|
if (hist.kind === 'error') return hist
|
|
1674
1690
|
if (hist.kind === 'history' && hist.projections && hist.projections.values) {
|
|
1675
|
-
|
|
1691
|
+
const current = hist.projections.values.permissions
|
|
1692
|
+
out.sessionPermissions = current ? { ...current, options: catalog.options } : null
|
|
1676
1693
|
}
|
|
1677
1694
|
}
|
|
1678
1695
|
return out
|
|
@@ -1777,18 +1794,66 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1777
1794
|
const method = msg.type === 'goal-pause' ? 'pause' : msg.type === 'goal-resume' ? 'resume' : 'clear'
|
|
1778
1795
|
return mutateGoal(api, msg.type, method, { sessionId: sessionId.value, ref: ref.value })
|
|
1779
1796
|
}
|
|
1797
|
+
if (msg.type === 'schedule-catalog') {
|
|
1798
|
+
return proxyQuery(api, msg.type, api.schedule.catalog.bind(api.schedule), {})
|
|
1799
|
+
}
|
|
1800
|
+
if (msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
|
|
1801
|
+
msg.type === 'schedule-update' || msg.type === 'schedule-delete') {
|
|
1802
|
+
const sessionId = requireSessionId(msg)
|
|
1803
|
+
if (sessionId.error) return sessionId.error
|
|
1804
|
+
const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? { requestId: msg.requestId } : {}
|
|
1805
|
+
const payload = { sessionId: sessionId.value }
|
|
1806
|
+
if (msg.type !== 'schedule-list') {
|
|
1807
|
+
if (typeof msg.id !== 'string' || !msg.id.trim()) {
|
|
1808
|
+
return { kind: 'error', code: 'bad-request', message: `${msg.type} requires an id`, requestType: msg.type, sessionId: sessionId.value, ...requestId }
|
|
1809
|
+
}
|
|
1810
|
+
payload.id = msg.id.trim()
|
|
1811
|
+
}
|
|
1812
|
+
if (msg.type === 'schedule-history') {
|
|
1813
|
+
if (!Number.isSafeInteger(msg.limit) || msg.limit < 1 || msg.limit > 100 ||
|
|
1814
|
+
(msg.before !== undefined && (typeof msg.before !== 'string' || !msg.before.trim()))) {
|
|
1815
|
+
return { kind: 'error', code: 'bad-request', message: 'schedule-history requires limit 1..100 and an optional non-empty before cursor', requestType: msg.type, sessionId: sessionId.value, ...requestId }
|
|
1816
|
+
}
|
|
1817
|
+
payload.limit = msg.limit
|
|
1818
|
+
if (msg.before !== undefined) payload.before = msg.before
|
|
1819
|
+
}
|
|
1820
|
+
if (msg.type === 'schedule-update') {
|
|
1821
|
+
if (!msg.expected || typeof msg.expected !== 'object' || Array.isArray(msg.expected) || msg.expected.id !== payload.id) {
|
|
1822
|
+
return { kind: 'error', code: 'bad-request', message: 'schedule-update requires the complete expected record with a matching id', requestType: msg.type, sessionId: sessionId.value, ...requestId }
|
|
1823
|
+
}
|
|
1824
|
+
// Catalog rows add Host management metadata. The Remote update contract
|
|
1825
|
+
// requires the original ScheduleRecord only.
|
|
1826
|
+
payload.expected = Object.fromEntries(
|
|
1827
|
+
['id', 'kind', 'title', 'prompt', 'scheduledAt', 'afterSeconds', 'everySeconds', 'time', 'timeZone', 'weekdays', 'expression']
|
|
1828
|
+
.filter((field) => Object.hasOwn(msg.expected, field))
|
|
1829
|
+
.map((field) => [field, msg.expected[field]]),
|
|
1830
|
+
)
|
|
1831
|
+
for (const field of ['title', 'prompt']) {
|
|
1832
|
+
if (msg[field] !== undefined) payload[field] = msg[field]
|
|
1833
|
+
}
|
|
1834
|
+
if (msg.change !== undefined) payload.change = msg.change
|
|
1835
|
+
}
|
|
1836
|
+
const method = msg.type.slice('schedule-'.length)
|
|
1837
|
+
const frame = await proxyQuery(api, msg.type, api.schedule[method].bind(api.schedule), payload)
|
|
1838
|
+
return { ...frame, sessionId: sessionId.value, ...requestId }
|
|
1839
|
+
}
|
|
1780
1840
|
if (msg.type === 'agent-presets') {
|
|
1781
1841
|
return proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
|
|
1782
1842
|
}
|
|
1783
1843
|
if (msg.type === 'defaults') {
|
|
1784
|
-
const
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1844
|
+
const [presets, catalog] = await Promise.all([
|
|
1845
|
+
proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {}),
|
|
1846
|
+
proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {}),
|
|
1847
|
+
])
|
|
1848
|
+
if (presets.kind === 'error') return presets
|
|
1849
|
+
if (catalog.kind === 'error') return catalog
|
|
1788
1850
|
return {
|
|
1789
1851
|
kind: 'defaults',
|
|
1790
|
-
agentPresetDefault:
|
|
1791
|
-
permissionDefault:
|
|
1852
|
+
agentPresetDefault: presets.presets?.find((preset) => preset.isDefault)?.id ?? null,
|
|
1853
|
+
permissionDefault: catalog.defaultPreset ?? null,
|
|
1854
|
+
permissionDefaultOptions: catalog.defaultOptions ?? [],
|
|
1855
|
+
// rc.2 removed this roster flag; only an explicit older-host false disables selection.
|
|
1856
|
+
modeSelectionEnabled: presets.modeSelectionEnabled !== false,
|
|
1792
1857
|
}
|
|
1793
1858
|
}
|
|
1794
1859
|
if (msg.type === 'set-default') {
|
|
@@ -1800,8 +1865,21 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1800
1865
|
if (!value) {
|
|
1801
1866
|
return { kind: 'error', code: 'bad-request', message: 'set-default requires a value', requestType: 'set-default' }
|
|
1802
1867
|
}
|
|
1803
|
-
const
|
|
1804
|
-
|
|
1868
|
+
const catalog = target === 'agent-preset'
|
|
1869
|
+
? await proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
|
|
1870
|
+
: await proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {})
|
|
1871
|
+
if (catalog.kind === 'error') return catalog
|
|
1872
|
+
if (target === 'agent-preset' && catalog.modeSelectionEnabled === false) {
|
|
1873
|
+
return { kind: 'error', code: 'mode-selection-disabled', message: 'Agent mode selection is disabled', requestType: 'set-default' }
|
|
1874
|
+
}
|
|
1875
|
+
const choices = target === 'agent-preset'
|
|
1876
|
+
? catalog.presets?.filter((preset) => !preset.broken).map((preset) => preset.id)
|
|
1877
|
+
: catalog.defaultOptions?.map((option) => option.value)
|
|
1878
|
+
if (!choices?.includes(value)) {
|
|
1879
|
+
return { kind: 'error', code: 'bad-request', message: 'default value is not available', requestType: 'set-default' }
|
|
1880
|
+
}
|
|
1881
|
+
const ns = target === 'agent-preset' ? 'agent-preset-registry' : 'permission'
|
|
1882
|
+
const patch = target === 'agent-preset' ? { selectedDefault: value } : { defaultPreset: value }
|
|
1805
1883
|
const updated = await proxyQuery(api, 'set-default', api.settings.update.bind(api.settings), { ns, patch })
|
|
1806
1884
|
if (updated.kind !== 'set-default') return updated
|
|
1807
1885
|
log(`default updated: ${ns}.${Object.keys(patch)[0]} = ${value}`)
|
|
@@ -2075,6 +2153,9 @@ const plugin = {
|
|
|
2075
2153
|
lanHost: '0.0.0.0',
|
|
2076
2154
|
lanPort: 3081,
|
|
2077
2155
|
lanAdvertiseHost: '',
|
|
2156
|
+
cloudflaredPath: 'cloudflared',
|
|
2157
|
+
cloudflarePort: 3082,
|
|
2158
|
+
cloudflareStateFile: '',
|
|
2078
2159
|
...(config || {}),
|
|
2079
2160
|
}
|
|
2080
2161
|
const wsPath = options.path
|
|
@@ -2087,6 +2168,9 @@ const plugin = {
|
|
|
2087
2168
|
if (options.lanEnabled === true && options.lanAdvertiseHost && !isPrivateNetworkHostname(options.lanAdvertiseHost)) {
|
|
2088
2169
|
throw new Error('mobile-gateway: lanAdvertiseHost must be localhost or a private LAN address')
|
|
2089
2170
|
}
|
|
2171
|
+
if (typeof options.cloudflaredPath !== 'string' || !options.cloudflaredPath.trim()) {
|
|
2172
|
+
throw new Error('mobile-gateway: cloudflaredPath must be a nonempty executable path')
|
|
2173
|
+
}
|
|
2090
2174
|
let requireAuth = options.requireAuth !== false
|
|
2091
2175
|
const adminLoopbackOnly = options.adminLoopbackOnly !== false
|
|
2092
2176
|
const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
|
|
@@ -2136,6 +2220,7 @@ const plugin = {
|
|
|
2136
2220
|
let lanListening = false
|
|
2137
2221
|
let lanListenError = null
|
|
2138
2222
|
let lanBoundPort = options.lanPort
|
|
2223
|
+
let tunnel = null
|
|
2139
2224
|
const configuredPublicUrl = () => {
|
|
2140
2225
|
if (typeof options.publicUrl === 'string' && options.publicUrl.trim()) return options.publicUrl.trim()
|
|
2141
2226
|
if (typeof options.publicUrlFile !== 'string' || !options.publicUrlFile.trim()) return ''
|
|
@@ -2157,6 +2242,7 @@ const plugin = {
|
|
|
2157
2242
|
...extra,
|
|
2158
2243
|
...configuredEndpoints,
|
|
2159
2244
|
...(publicUrl ? [publicUrl] : []),
|
|
2245
|
+
...(tunnel?.snapshot().publicUrl ? [tunnel.snapshot().publicUrl] : []),
|
|
2160
2246
|
...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
|
|
2161
2247
|
])])
|
|
2162
2248
|
}
|
|
@@ -2183,11 +2269,13 @@ const plugin = {
|
|
|
2183
2269
|
log(`failed to persist automatic gateway disable: ${error.message}`)
|
|
2184
2270
|
}
|
|
2185
2271
|
log('mobile gateway automatically disabled: no device connected before timeout')
|
|
2272
|
+
void tunnel?.reconcile()
|
|
2186
2273
|
}, options.gatewayWaitTimeoutMs)
|
|
2187
2274
|
} else if (mode === 'disabled') {
|
|
2188
2275
|
for (const client of clients) client.close(4004, 'mobile gateway disabled')
|
|
2189
2276
|
}
|
|
2190
2277
|
log(`mobile gateway mode=${mode}${reason ? `: ${reason}` : ''}`)
|
|
2278
|
+
void tunnel?.reconcile()
|
|
2191
2279
|
}
|
|
2192
2280
|
|
|
2193
2281
|
const logAuthRejected = (req) => {
|
|
@@ -2221,6 +2309,7 @@ const plugin = {
|
|
|
2221
2309
|
toolName: payload.toolName,
|
|
2222
2310
|
...(payload.callId !== undefined ? { callId: payload.callId } : {}),
|
|
2223
2311
|
...(payload.reason !== undefined ? { reason: payload.reason } : {}),
|
|
2312
|
+
...(payload.displayReason !== undefined ? { displayReason: payload.displayReason } : {}),
|
|
2224
2313
|
...(replay ? { replay: true } : {}),
|
|
2225
2314
|
})
|
|
2226
2315
|
|
|
@@ -2520,6 +2609,7 @@ const plugin = {
|
|
|
2520
2609
|
log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
|
|
2521
2610
|
|
|
2522
2611
|
// ---- device management routes (loopback-gated admin surface) ----
|
|
2612
|
+
let restartPending = false
|
|
2523
2613
|
const disposeMgmt = webServer.register({
|
|
2524
2614
|
kind: 'prefix',
|
|
2525
2615
|
path: '/mgw',
|
|
@@ -2536,6 +2626,7 @@ const plugin = {
|
|
|
2536
2626
|
const url = new URL(req.url || '/', 'http://x')
|
|
2537
2627
|
const p = url.pathname
|
|
2538
2628
|
if (req.method === 'GET' && p === '/mgw/status') {
|
|
2629
|
+
const profile = ctx.get?.('profileContext')
|
|
2539
2630
|
sendJson(res, 200, {
|
|
2540
2631
|
version: PLUGIN_VERSION,
|
|
2541
2632
|
requireAuth,
|
|
@@ -2546,8 +2637,13 @@ const plugin = {
|
|
|
2546
2637
|
waitExpiresAt,
|
|
2547
2638
|
connectedClients: clients.size,
|
|
2548
2639
|
webPort: webServer.port,
|
|
2640
|
+
webPid: process.pid,
|
|
2549
2641
|
wsPath,
|
|
2550
|
-
publicUrl:
|
|
2642
|
+
publicUrl: tunnel?.snapshot().enabled && tunnel.snapshot().mode === 'quick'
|
|
2643
|
+
? tunnel.snapshot().publicUrl
|
|
2644
|
+
: tunnel?.snapshot().publicUrl || configuredPublicUrl() || null,
|
|
2645
|
+
platform: process.platform,
|
|
2646
|
+
cloudflare: tunnel?.snapshot() || null,
|
|
2551
2647
|
lan: {
|
|
2552
2648
|
enabled: options.lanEnabled === true,
|
|
2553
2649
|
listening: lanListening,
|
|
@@ -2559,7 +2655,49 @@ const plugin = {
|
|
|
2559
2655
|
},
|
|
2560
2656
|
pairingTtlMs: options.pairingTtlMs,
|
|
2561
2657
|
queryTokenAllowed: !!options.allowQueryToken,
|
|
2658
|
+
tools: {
|
|
2659
|
+
restartWeb: webRestartAvailable(profile),
|
|
2660
|
+
stopWeb: webRestartAvailable(profile),
|
|
2661
|
+
},
|
|
2562
2662
|
})
|
|
2663
|
+
} else if (req.method === 'POST' && p === '/mgw/tools/restart-web') {
|
|
2664
|
+
if (!isLoopback(req)) {
|
|
2665
|
+
sendJson(res, 403, { error: 'forbidden', message: 'Web restart is loopback-only' })
|
|
2666
|
+
return
|
|
2667
|
+
}
|
|
2668
|
+
if (!webRestartAvailable(ctx.get?.('profileContext'))) {
|
|
2669
|
+
sendJson(res, 409, { error: 'unavailable', message: '只能在通过 dsh web 启动的 Web profile 中重启' })
|
|
2670
|
+
return
|
|
2671
|
+
}
|
|
2672
|
+
if (restartPending) {
|
|
2673
|
+
sendJson(res, 409, { error: 'restart-pending', message: 'DSH Web 正在重启' })
|
|
2674
|
+
return
|
|
2675
|
+
}
|
|
2676
|
+
restartPending = true
|
|
2677
|
+
try {
|
|
2678
|
+
await prepareWebRestart()
|
|
2679
|
+
} catch (error) {
|
|
2680
|
+
restartPending = false
|
|
2681
|
+
throw error
|
|
2682
|
+
}
|
|
2683
|
+
res.once('finish', () => setTimeout(() => process.kill(process.pid, 'SIGTERM'), 100))
|
|
2684
|
+
sendJson(res, 202, { restarting: true })
|
|
2685
|
+
} else if (req.method === 'POST' && p === '/mgw/tools/stop-web') {
|
|
2686
|
+
if (!isLoopback(req)) {
|
|
2687
|
+
sendJson(res, 403, { error: 'forbidden', message: 'Web shutdown is loopback-only' })
|
|
2688
|
+
return
|
|
2689
|
+
}
|
|
2690
|
+
if (!webRestartAvailable(ctx.get?.('profileContext'))) {
|
|
2691
|
+
sendJson(res, 409, { error: 'unavailable', message: '只能停止通过 dsh web 启动的 Web profile' })
|
|
2692
|
+
return
|
|
2693
|
+
}
|
|
2694
|
+
if (restartPending) {
|
|
2695
|
+
sendJson(res, 409, { error: 'restart-pending', message: 'DSH Web 正在重启或停止' })
|
|
2696
|
+
return
|
|
2697
|
+
}
|
|
2698
|
+
restartPending = true
|
|
2699
|
+
res.once('finish', () => setTimeout(() => process.kill(process.pid, 'SIGTERM'), 100))
|
|
2700
|
+
sendJson(res, 202, { stopping: true })
|
|
2563
2701
|
} else if (req.method === 'GET' && p === '/mgw/public-setup') {
|
|
2564
2702
|
sendJson(res, 200, await systemHelperStatus())
|
|
2565
2703
|
} else if (req.method === 'POST' && p === '/mgw/public-setup') {
|
|
@@ -2578,6 +2716,12 @@ const plugin = {
|
|
|
2578
2716
|
if (body.mode === undefined && typeof body.enabled !== 'boolean') throw badRequest('mode or boolean enabled is required')
|
|
2579
2717
|
setGatewayMode(body.mode !== undefined ? body.mode : (body.enabled ? 'temporary' : 'disabled'), 'changed from management UI')
|
|
2580
2718
|
sendJson(res, 200, { gatewayEnabled, gatewayMode, waitExpiresAt, connectedClients: clients.size })
|
|
2719
|
+
} else if (req.method === 'POST' && p === '/mgw/cloudflare') {
|
|
2720
|
+
const body = await readBody(req)
|
|
2721
|
+
await tunnel.configure(body)
|
|
2722
|
+
if (body.enabled && gatewayMode !== 'persistent') setGatewayMode('persistent', 'Cloudflare Tunnel enabled from management UI')
|
|
2723
|
+
await tunnel.reconcile()
|
|
2724
|
+
sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
|
|
2581
2725
|
} else if (req.method === 'POST' && p === '/mgw/auth') {
|
|
2582
2726
|
const body = await readBody(req)
|
|
2583
2727
|
if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
|
|
@@ -2798,7 +2942,9 @@ const plugin = {
|
|
|
2798
2942
|
msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
|
|
2799
2943
|
msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
|
|
2800
2944
|
msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
|
|
2801
|
-
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear'
|
|
2945
|
+
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear' ||
|
|
2946
|
+
msg.type === 'schedule-catalog' || msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
|
|
2947
|
+
msg.type === 'schedule-update' || msg.type === 'schedule-delete') {
|
|
2802
2948
|
handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
|
|
2803
2949
|
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2804
2950
|
})
|
|
@@ -2844,6 +2990,7 @@ const plugin = {
|
|
|
2844
2990
|
'commands',
|
|
2845
2991
|
'tasks',
|
|
2846
2992
|
'goals',
|
|
2993
|
+
'schedule-management',
|
|
2847
2994
|
'session-cancel',
|
|
2848
2995
|
'queue-control',
|
|
2849
2996
|
'session-archive',
|
|
@@ -2917,6 +3064,18 @@ const plugin = {
|
|
|
2917
3064
|
})
|
|
2918
3065
|
}
|
|
2919
3066
|
|
|
3067
|
+
tunnel = createCloudflareTunnel({
|
|
3068
|
+
file: options.cloudflareStateFile || `${deviceFile}.cloudflare.json`,
|
|
3069
|
+
port: options.cloudflarePort,
|
|
3070
|
+
wsPath,
|
|
3071
|
+
executable: options.cloudflaredPath,
|
|
3072
|
+
supported: process.platform === 'darwin' || process.platform === 'win32',
|
|
3073
|
+
isGatewayEnabled: () => gatewayEnabled,
|
|
3074
|
+
onUpgrade: handleMobileUpgrade,
|
|
3075
|
+
log,
|
|
3076
|
+
})
|
|
3077
|
+
void tunnel.reconcile()
|
|
3078
|
+
|
|
2920
3079
|
const disposeEvents = ctx.on('session/event', (session, event) => {
|
|
2921
3080
|
api.observeSessionEvent(String(session.id), event)
|
|
2922
3081
|
if (clients.size === 0) return
|
|
@@ -2947,6 +3106,11 @@ const plugin = {
|
|
|
2947
3106
|
})
|
|
2948
3107
|
log('session/event listener attached')
|
|
2949
3108
|
|
|
3109
|
+
// The Host notification has no payload: readers refetch the task catalog.
|
|
3110
|
+
const disposeScheduleChanged = ctx.on('schedule/changed', () => {
|
|
3111
|
+
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'schedule-changed' })
|
|
3112
|
+
})
|
|
3113
|
+
|
|
2950
3114
|
const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
|
|
2951
3115
|
const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
|
|
2952
3116
|
if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
|
|
@@ -3002,6 +3166,7 @@ const plugin = {
|
|
|
3002
3166
|
toolName: request.toolName,
|
|
3003
3167
|
...(request.callId !== undefined ? { callId: request.callId } : {}),
|
|
3004
3168
|
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
3169
|
+
...(request.displayReason !== undefined ? { displayReason: request.displayReason } : {}),
|
|
3005
3170
|
next,
|
|
3006
3171
|
downstreamAbort,
|
|
3007
3172
|
resolve,
|
|
@@ -3040,49 +3205,53 @@ const plugin = {
|
|
|
3040
3205
|
log(`session archive state forwarded: count=${next.length}`)
|
|
3041
3206
|
}
|
|
3042
3207
|
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3208
|
+
// DSH rc.1 carries pending messages in the durable inbox projection.
|
|
3209
|
+
// session/control no longer emits a separate queues baseline or queue frames.
|
|
3210
|
+
const queueItemsFromInbox = (value, endpoint) => {
|
|
3211
|
+
if (value == null) return []
|
|
3212
|
+
if (typeof value !== 'object' || Array.isArray(value)
|
|
3213
|
+
|| !Array.isArray(value['next-turn']) || !Array.isArray(value['next-step'])) {
|
|
3214
|
+
throw new Error(`${endpoint} returned an invalid inbox projection`)
|
|
3215
|
+
}
|
|
3216
|
+
const convert = (messages, target) => messages.map((message) => {
|
|
3217
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)
|
|
3053
3218
|
|| typeof message.id !== 'string' || !message.id
|
|
3054
3219
|
|| !Array.isArray(message.content)
|
|
3055
|
-
||
|
|
3056
|
-
|
|
3220
|
+
|| !message.source || typeof message.source.kind !== 'string'
|
|
3221
|
+
|| (message.source.rpcId !== undefined && typeof message.source.rpcId !== 'string')) {
|
|
3222
|
+
throw new Error(`${endpoint} returned an invalid inbox message`)
|
|
3057
3223
|
}
|
|
3058
3224
|
return {
|
|
3059
|
-
id:
|
|
3060
|
-
placement:
|
|
3061
|
-
...(
|
|
3225
|
+
id: message.id,
|
|
3226
|
+
placement: target === 'next-turn' ? 'queued' : message.source.kind === 'user' ? 'steering' : 'context',
|
|
3227
|
+
...(message.source.rpcId === undefined ? {} : { rpcId: message.source.rpcId }),
|
|
3062
3228
|
message: { id: message.id, content: message.content },
|
|
3063
3229
|
}
|
|
3064
3230
|
})
|
|
3231
|
+
return [...convert(value['next-turn'], 'next-turn'), ...convert(value['next-step'], 'next-step')]
|
|
3065
3232
|
}
|
|
3066
3233
|
|
|
3067
|
-
const installSessionQueueBaseline = (
|
|
3068
|
-
if (!
|
|
3069
|
-
throw new Error('session/control baseline returned invalid
|
|
3234
|
+
const installSessionQueueBaseline = (projections) => {
|
|
3235
|
+
if (!projections || typeof projections !== 'object' || Array.isArray(projections)) {
|
|
3236
|
+
throw new Error('session/control baseline returned invalid projections')
|
|
3070
3237
|
}
|
|
3071
3238
|
const next = new Map()
|
|
3072
|
-
for (const [sessionId,
|
|
3073
|
-
|
|
3239
|
+
for (const [sessionId, baseline] of Object.entries(projections)) {
|
|
3240
|
+
const items = queueItemsFromInbox(baseline?.values?.inbox, 'session/control baseline')
|
|
3241
|
+
if (items.length > 0) next.set(sessionId, items)
|
|
3074
3242
|
}
|
|
3075
3243
|
sessionQueues = next
|
|
3076
3244
|
broadcastSessionMetadataFrame({ kind: 'session-queues', queues: Object.fromEntries(next) })
|
|
3077
3245
|
log(`session queue baseline forwarded: sessions=${next.size}`)
|
|
3078
3246
|
}
|
|
3079
3247
|
|
|
3080
|
-
const installSessionQueue = (sessionIdValue,
|
|
3248
|
+
const installSessionQueue = (sessionIdValue, inboxValue) => {
|
|
3081
3249
|
const sessionId = typeof sessionIdValue === 'string' && sessionIdValue ? sessionIdValue : null
|
|
3082
3250
|
if (!sessionId) throw new Error('session/control returned an invalid queue sessionId')
|
|
3083
|
-
const items =
|
|
3251
|
+
const items = queueItemsFromInbox(inboxValue, 'session/control inbox')
|
|
3084
3252
|
if (sessionQueues === null) sessionQueues = new Map()
|
|
3085
|
-
sessionQueues.set(sessionId, items)
|
|
3253
|
+
if (items.length > 0) sessionQueues.set(sessionId, items)
|
|
3254
|
+
else sessionQueues.delete(sessionId)
|
|
3086
3255
|
broadcastSessionMetadataFrame({ kind: 'session-queue', sessionId, items })
|
|
3087
3256
|
log(`session queue forwarded: session=${sessionId} items=${items.length}`)
|
|
3088
3257
|
}
|
|
@@ -3143,9 +3312,9 @@ const plugin = {
|
|
|
3143
3312
|
for await (const frame of stream) {
|
|
3144
3313
|
if (frame?.type === 'baseline') {
|
|
3145
3314
|
installProjectionBaseline(frame.value?.projections)
|
|
3146
|
-
installSessionQueueBaseline(frame.value?.
|
|
3147
|
-
} else if (frame?.type === '
|
|
3148
|
-
installSessionQueue(frame.sessionId, frame.
|
|
3315
|
+
installSessionQueueBaseline(frame.value?.projections)
|
|
3316
|
+
} else if (frame?.type === 'projection' && frame.key === 'inbox') {
|
|
3317
|
+
installSessionQueue(frame.sessionId, frame.value)
|
|
3149
3318
|
} else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
|
|
3150
3319
|
const sessionId = String(frame.sessionId)
|
|
3151
3320
|
const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
|
|
@@ -3179,9 +3348,11 @@ const plugin = {
|
|
|
3179
3348
|
disposeUpgrade()
|
|
3180
3349
|
disposeMgmt()
|
|
3181
3350
|
disposeEvents()
|
|
3351
|
+
disposeScheduleChanged()
|
|
3182
3352
|
disposeQuestions()
|
|
3183
3353
|
disposeApprovals()
|
|
3184
3354
|
if (lanServer) lanServer.close()
|
|
3355
|
+
tunnel.dispose()
|
|
3185
3356
|
for (const client of clients) {
|
|
3186
3357
|
client.sessionFollower?.stop()
|
|
3187
3358
|
client.terminate()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Detached handoff process: only launched by the loopback-only management API.
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { openSync, closeSync, appendFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
const logFile = '/tmp/mobile-gateway.log'
|
|
6
|
+
const note = (message) => {
|
|
7
|
+
try { appendFileSync(logFile, `[${new Date().toISOString()}] web restart: ${message}\n`) } catch {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const spec = JSON.parse(Buffer.from(process.argv[2] || '', 'base64url').toString())
|
|
12
|
+
if (!Number.isSafeInteger(spec.pid) || spec.pid <= 0 || typeof spec.execPath !== 'string'
|
|
13
|
+
|| !Array.isArray(spec.execArgv) || !Array.isArray(spec.argv) || typeof spec.cwd !== 'string') {
|
|
14
|
+
throw new Error('invalid restart handoff')
|
|
15
|
+
}
|
|
16
|
+
const deadline = Date.now() + 30_000
|
|
17
|
+
while (Date.now() < deadline) {
|
|
18
|
+
try { process.kill(spec.pid, 0) } catch (error) {
|
|
19
|
+
if (error.code === 'ESRCH') break
|
|
20
|
+
throw error
|
|
21
|
+
}
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
process.kill(spec.pid, 0)
|
|
26
|
+
throw new Error('old DSH Web process did not exit within 30 seconds')
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error.code !== 'ESRCH') throw error
|
|
29
|
+
}
|
|
30
|
+
const fd = openSync(logFile, 'a')
|
|
31
|
+
try {
|
|
32
|
+
const child = spawn(spec.execPath, [...spec.execArgv, ...spec.argv], {
|
|
33
|
+
cwd: spec.cwd, env: process.env, detached: true, stdio: ['ignore', fd, fd],
|
|
34
|
+
})
|
|
35
|
+
await new Promise((resolve, reject) => {
|
|
36
|
+
child.once('spawn', resolve)
|
|
37
|
+
child.once('error', reject)
|
|
38
|
+
})
|
|
39
|
+
child.unref()
|
|
40
|
+
note(`started DSH Web pid=${child.pid}`)
|
|
41
|
+
} finally {
|
|
42
|
+
closeSync(fd)
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
note(error?.message || String(error))
|
|
46
|
+
process.exitCode = 1
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"description": "Authenticated mobile gateway for DeepSeek Harness with LAN and Cloudflare Tunnel access, device pairing, and session sync",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
7
7
|
"bin",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"bin": "bin/setup-ip.mjs",
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
|
|
29
|
+
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node test/gateway-tools.test.mjs && node test/multi-gateway.test.mjs"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./lib/index.mjs",
|