dsh-plugin-mobile-gateway 0.7.7 → 0.9.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 +68 -12
- package/README.md +32 -22
- package/cordis.patch.yml +4 -0
- package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -0
- package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -0
- package/docs/multi-gateway-app-integration.md +2 -1
- package/lib/client.js +273 -18
- package/lib/cloudflare-tunnel.mjs +282 -0
- package/lib/cloudflared-binary.mjs +114 -0
- package/lib/dsh-host-adapter.mjs +47 -6
- package/lib/gateway-tools.mjs +36 -0
- package/lib/index.mjs +267 -47
- package/lib/plugin-management.mjs +194 -0
- package/lib/web-restart.mjs +47 -0
- package/package.json +3 -3
package/lib/index.mjs
CHANGED
|
@@ -68,6 +68,8 @@ import { stringifyWireFrame } from './wire-json.mjs'
|
|
|
68
68
|
// { "type": "goal", "sessionId" } -> current goal projection
|
|
69
69
|
// { "type": "goal-edit", "sessionId", "ref", "objective"?, "maxGoalRounds"? }
|
|
70
70
|
// { "type": "goal-pause"|"goal-resume"|"goal-clear", "sessionId", "ref" }
|
|
71
|
+
// { "type": "plugin-catalog"|"plugin-inspect"|"plugin-install"|"plugin-settings"|..., "requestId", ... }
|
|
72
|
+
// -> paired control connection; see PROTOCOL.md section 18
|
|
71
73
|
// { "type": "agent-presets" } -> preset roster (+ isDefault)
|
|
72
74
|
// { "type": "defaults" } -> default agent preset + default permission
|
|
73
75
|
// { "type": "set-default", "target": "agent-preset"|"permission", "value" }
|
|
@@ -75,7 +77,7 @@ import { stringifyWireFrame } from './wire-json.mjs'
|
|
|
75
77
|
// { "type": "question-cancel", "rpcId", "sessionId" }
|
|
76
78
|
// { "type": "approval-response", "rpcId", "sessionId", "approvalId",
|
|
77
79
|
// "outcome": "allowed-once"|"rejected" }
|
|
78
|
-
// server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "session-cancel", "queue-control", "session-archive", "session-rename", "file-downloads"],
|
|
80
|
+
// server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "plugin-management-v1", "session-cancel", "queue-control", "session-archive", "session-rename", "file-downloads"],
|
|
79
81
|
// "authenticated", "device"?, "port", "clients" }
|
|
80
82
|
// { "kind": "pong", "at" }
|
|
81
83
|
// { "kind": "subscribed", "sessionId" }
|
|
@@ -130,8 +132,11 @@ import Schema from '@deepseek-ai/schemastery'
|
|
|
130
132
|
import { WebSocketServer } from 'ws'
|
|
131
133
|
import devicesModule from './devices.js'
|
|
132
134
|
import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
|
|
135
|
+
import { handlePluginQuery, PLUGIN_REQUEST_TYPES } from './plugin-management.mjs'
|
|
133
136
|
import { createSessionFollower } from './session-follower.mjs'
|
|
134
137
|
import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
138
|
+
import { createCloudflareTunnel } from './cloudflare-tunnel.mjs'
|
|
139
|
+
import { webRestartAvailable, prepareWebRestart } from './gateway-tools.mjs'
|
|
135
140
|
import QRCode from 'qrcode'
|
|
136
141
|
|
|
137
142
|
const { createRegistry } = devicesModule
|
|
@@ -225,6 +230,9 @@ const Config = Schema.object({
|
|
|
225
230
|
lanHost: Schema.string().default('0.0.0.0'),
|
|
226
231
|
lanPort: Schema.natural().min(1).max(65535).default(3081),
|
|
227
232
|
lanAdvertiseHost: Schema.string().default(''),
|
|
233
|
+
cloudflaredPath: Schema.string().default('cloudflared'),
|
|
234
|
+
cloudflarePort: Schema.natural().min(1).max(65535).default(3082),
|
|
235
|
+
cloudflareStateFile: Schema.string().default(''),
|
|
228
236
|
})
|
|
229
237
|
|
|
230
238
|
function log(line) {
|
|
@@ -323,7 +331,8 @@ function buildWireEvent(session, event) {
|
|
|
323
331
|
case 'tool/result': {
|
|
324
332
|
let preview = ''
|
|
325
333
|
for (const block of (d.message && d.message.content) || []) {
|
|
326
|
-
|
|
334
|
+
const content = block?.type === 'tool-result' ? block.content || [] : [block]
|
|
335
|
+
for (const inner of content) {
|
|
327
336
|
if (inner.type === 'text' && typeof inner.text === 'string') preview += inner.text
|
|
328
337
|
}
|
|
329
338
|
}
|
|
@@ -333,9 +342,10 @@ function buildWireEvent(session, event) {
|
|
|
333
342
|
type: 'tool/result',
|
|
334
343
|
turn: d.turn,
|
|
335
344
|
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),
|
|
345
|
+
callId: d.message?.toolCallId ?? d.message?.source?.callId,
|
|
346
|
+
isError: d.message?.isError === true || !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
|
|
338
347
|
preview,
|
|
348
|
+
...(d.error ? { error: d.error } : {}),
|
|
339
349
|
},
|
|
340
350
|
})
|
|
341
351
|
}
|
|
@@ -1122,7 +1132,7 @@ function validateHistoryCursor(msg, sessionId, field) {
|
|
|
1122
1132
|
if (msg.historyFormatVersion !== SESSION_FORMAT_VERSION) {
|
|
1123
1133
|
return { kind: 'error', code: 'history-format-mismatch', requestType: msg.type, sessionId,
|
|
1124
1134
|
historyFormatVersion: SESSION_FORMAT_VERSION, resetRequired: true,
|
|
1125
|
-
message:
|
|
1135
|
+
message: `Reload the Session baseline before using a history or fork cursor; historyFormatVersion must be ${SESSION_FORMAT_VERSION}` }
|
|
1126
1136
|
}
|
|
1127
1137
|
if (msg[field] !== undefined && (!Number.isSafeInteger(msg[field]) || msg[field] < 0)) {
|
|
1128
1138
|
return { kind: 'error', code: 'bad-request', requestType: msg.type, sessionId,
|
|
@@ -1220,7 +1230,9 @@ async function loadCommandOptions(api, command, sessionId) {
|
|
|
1220
1230
|
const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
|
|
1221
1231
|
if (history.kind !== 'history') return history
|
|
1222
1232
|
const permissions = history.projections && history.projections.values && history.projections.values.permissions
|
|
1223
|
-
|
|
1233
|
+
const catalog = await proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {})
|
|
1234
|
+
if (catalog.kind === 'error') return catalog
|
|
1235
|
+
if (!permissions || typeof permissions.currentValue !== 'string' || !Array.isArray(catalog.options)) {
|
|
1224
1236
|
return {
|
|
1225
1237
|
kind: 'error',
|
|
1226
1238
|
code: 'command-options-unavailable',
|
|
@@ -1233,7 +1245,7 @@ async function loadCommandOptions(api, command, sessionId) {
|
|
|
1233
1245
|
kind: 'command-options',
|
|
1234
1246
|
sessionId,
|
|
1235
1247
|
command,
|
|
1236
|
-
options:
|
|
1248
|
+
options: catalog.options.map((option) => ({
|
|
1237
1249
|
id: String(option.value),
|
|
1238
1250
|
label: String(option.name || option.value),
|
|
1239
1251
|
...(option.description ? { description: String(option.description) } : {}),
|
|
@@ -1326,7 +1338,8 @@ function parseAgentPreset(msg, requestType, required = false) {
|
|
|
1326
1338
|
}
|
|
1327
1339
|
|
|
1328
1340
|
// Dispatch one mobile query frame; returns the wire frame to send back.
|
|
1329
|
-
async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
1341
|
+
async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
|
|
1342
|
+
if (PLUGIN_REQUEST_TYPES.has(msg.type)) return handlePluginQuery(api, msg, pluginContext)
|
|
1330
1343
|
if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
|
|
1331
1344
|
const session = requireSessionId(msg)
|
|
1332
1345
|
const preset = parseAgentPreset(msg, msg.type, msg.type === 'select-agent-preset')
|
|
@@ -1660,11 +1673,18 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1660
1673
|
return proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
|
|
1661
1674
|
}
|
|
1662
1675
|
if (msg.type === 'permission-options') {
|
|
1663
|
-
const frame = await
|
|
1676
|
+
const [frame, catalog] = await Promise.all([
|
|
1677
|
+
proxyQuery(api, 'permission-options', api.settings.describe.bind(api.settings), {}),
|
|
1678
|
+
proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {}),
|
|
1679
|
+
])
|
|
1664
1680
|
if (frame.kind !== 'permission-options') return frame
|
|
1681
|
+
if (catalog.kind === 'error') return catalog
|
|
1665
1682
|
const out = {
|
|
1666
1683
|
kind: 'permission-options',
|
|
1667
1684
|
namespace: (frame.namespaces || []).find((n) => n.ns === 'permission') || null,
|
|
1685
|
+
options: catalog.options,
|
|
1686
|
+
defaultOptions: catalog.defaultOptions,
|
|
1687
|
+
defaultPreset: catalog.defaultPreset,
|
|
1668
1688
|
}
|
|
1669
1689
|
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
1670
1690
|
if (sessionId) {
|
|
@@ -1672,7 +1692,8 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1672
1692
|
const hist = await querySessionProjection(api, msg.type, sessionId)
|
|
1673
1693
|
if (hist.kind === 'error') return hist
|
|
1674
1694
|
if (hist.kind === 'history' && hist.projections && hist.projections.values) {
|
|
1675
|
-
|
|
1695
|
+
const current = hist.projections.values.permissions
|
|
1696
|
+
out.sessionPermissions = current ? { ...current, options: catalog.options } : null
|
|
1676
1697
|
}
|
|
1677
1698
|
}
|
|
1678
1699
|
return out
|
|
@@ -1777,18 +1798,66 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1777
1798
|
const method = msg.type === 'goal-pause' ? 'pause' : msg.type === 'goal-resume' ? 'resume' : 'clear'
|
|
1778
1799
|
return mutateGoal(api, msg.type, method, { sessionId: sessionId.value, ref: ref.value })
|
|
1779
1800
|
}
|
|
1801
|
+
if (msg.type === 'schedule-catalog') {
|
|
1802
|
+
return proxyQuery(api, msg.type, api.schedule.catalog.bind(api.schedule), {})
|
|
1803
|
+
}
|
|
1804
|
+
if (msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
|
|
1805
|
+
msg.type === 'schedule-update' || msg.type === 'schedule-delete') {
|
|
1806
|
+
const sessionId = requireSessionId(msg)
|
|
1807
|
+
if (sessionId.error) return sessionId.error
|
|
1808
|
+
const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? { requestId: msg.requestId } : {}
|
|
1809
|
+
const payload = { sessionId: sessionId.value }
|
|
1810
|
+
if (msg.type !== 'schedule-list') {
|
|
1811
|
+
if (typeof msg.id !== 'string' || !msg.id.trim()) {
|
|
1812
|
+
return { kind: 'error', code: 'bad-request', message: `${msg.type} requires an id`, requestType: msg.type, sessionId: sessionId.value, ...requestId }
|
|
1813
|
+
}
|
|
1814
|
+
payload.id = msg.id.trim()
|
|
1815
|
+
}
|
|
1816
|
+
if (msg.type === 'schedule-history') {
|
|
1817
|
+
if (!Number.isSafeInteger(msg.limit) || msg.limit < 1 || msg.limit > 100 ||
|
|
1818
|
+
(msg.before !== undefined && (typeof msg.before !== 'string' || !msg.before.trim()))) {
|
|
1819
|
+
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 }
|
|
1820
|
+
}
|
|
1821
|
+
payload.limit = msg.limit
|
|
1822
|
+
if (msg.before !== undefined) payload.before = msg.before
|
|
1823
|
+
}
|
|
1824
|
+
if (msg.type === 'schedule-update') {
|
|
1825
|
+
if (!msg.expected || typeof msg.expected !== 'object' || Array.isArray(msg.expected) || msg.expected.id !== payload.id) {
|
|
1826
|
+
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 }
|
|
1827
|
+
}
|
|
1828
|
+
// Catalog rows add Host management metadata. The Remote update contract
|
|
1829
|
+
// requires the original ScheduleRecord only.
|
|
1830
|
+
payload.expected = Object.fromEntries(
|
|
1831
|
+
['id', 'kind', 'title', 'prompt', 'scheduledAt', 'afterSeconds', 'everySeconds', 'time', 'timeZone', 'weekdays', 'expression']
|
|
1832
|
+
.filter((field) => Object.hasOwn(msg.expected, field))
|
|
1833
|
+
.map((field) => [field, msg.expected[field]]),
|
|
1834
|
+
)
|
|
1835
|
+
for (const field of ['title', 'prompt']) {
|
|
1836
|
+
if (msg[field] !== undefined) payload[field] = msg[field]
|
|
1837
|
+
}
|
|
1838
|
+
if (msg.change !== undefined) payload.change = msg.change
|
|
1839
|
+
}
|
|
1840
|
+
const method = msg.type.slice('schedule-'.length)
|
|
1841
|
+
const frame = await proxyQuery(api, msg.type, api.schedule[method].bind(api.schedule), payload)
|
|
1842
|
+
return { ...frame, sessionId: sessionId.value, ...requestId }
|
|
1843
|
+
}
|
|
1780
1844
|
if (msg.type === 'agent-presets') {
|
|
1781
1845
|
return proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
|
|
1782
1846
|
}
|
|
1783
1847
|
if (msg.type === 'defaults') {
|
|
1784
|
-
const
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1848
|
+
const [presets, catalog] = await Promise.all([
|
|
1849
|
+
proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {}),
|
|
1850
|
+
proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {}),
|
|
1851
|
+
])
|
|
1852
|
+
if (presets.kind === 'error') return presets
|
|
1853
|
+
if (catalog.kind === 'error') return catalog
|
|
1788
1854
|
return {
|
|
1789
1855
|
kind: 'defaults',
|
|
1790
|
-
agentPresetDefault:
|
|
1791
|
-
permissionDefault:
|
|
1856
|
+
agentPresetDefault: presets.presets?.find((preset) => preset.isDefault)?.id ?? null,
|
|
1857
|
+
permissionDefault: catalog.defaultPreset ?? null,
|
|
1858
|
+
permissionDefaultOptions: catalog.defaultOptions ?? [],
|
|
1859
|
+
// rc.2 removed this roster flag; only an explicit older-host false disables selection.
|
|
1860
|
+
modeSelectionEnabled: presets.modeSelectionEnabled !== false,
|
|
1792
1861
|
}
|
|
1793
1862
|
}
|
|
1794
1863
|
if (msg.type === 'set-default') {
|
|
@@ -1800,8 +1869,21 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1800
1869
|
if (!value) {
|
|
1801
1870
|
return { kind: 'error', code: 'bad-request', message: 'set-default requires a value', requestType: 'set-default' }
|
|
1802
1871
|
}
|
|
1803
|
-
const
|
|
1804
|
-
|
|
1872
|
+
const catalog = target === 'agent-preset'
|
|
1873
|
+
? await proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
|
|
1874
|
+
: await proxyQuery(api, 'permission-catalog', api.permissionPresets.catalog.bind(api.permissionPresets), {})
|
|
1875
|
+
if (catalog.kind === 'error') return catalog
|
|
1876
|
+
if (target === 'agent-preset' && catalog.modeSelectionEnabled === false) {
|
|
1877
|
+
return { kind: 'error', code: 'mode-selection-disabled', message: 'Agent mode selection is disabled', requestType: 'set-default' }
|
|
1878
|
+
}
|
|
1879
|
+
const choices = target === 'agent-preset'
|
|
1880
|
+
? catalog.presets?.filter((preset) => !preset.broken).map((preset) => preset.id)
|
|
1881
|
+
: catalog.defaultOptions?.map((option) => option.value)
|
|
1882
|
+
if (!choices?.includes(value)) {
|
|
1883
|
+
return { kind: 'error', code: 'bad-request', message: 'default value is not available', requestType: 'set-default' }
|
|
1884
|
+
}
|
|
1885
|
+
const ns = target === 'agent-preset' ? 'agent-preset-registry' : 'permission'
|
|
1886
|
+
const patch = target === 'agent-preset' ? { selectedDefault: value } : { defaultPreset: value }
|
|
1805
1887
|
const updated = await proxyQuery(api, 'set-default', api.settings.update.bind(api.settings), { ns, patch })
|
|
1806
1888
|
if (updated.kind !== 'set-default') return updated
|
|
1807
1889
|
log(`default updated: ${ns}.${Object.keys(patch)[0]} = ${value}`)
|
|
@@ -2075,6 +2157,9 @@ const plugin = {
|
|
|
2075
2157
|
lanHost: '0.0.0.0',
|
|
2076
2158
|
lanPort: 3081,
|
|
2077
2159
|
lanAdvertiseHost: '',
|
|
2160
|
+
cloudflaredPath: 'cloudflared',
|
|
2161
|
+
cloudflarePort: 3082,
|
|
2162
|
+
cloudflareStateFile: '',
|
|
2078
2163
|
...(config || {}),
|
|
2079
2164
|
}
|
|
2080
2165
|
const wsPath = options.path
|
|
@@ -2087,6 +2172,9 @@ const plugin = {
|
|
|
2087
2172
|
if (options.lanEnabled === true && options.lanAdvertiseHost && !isPrivateNetworkHostname(options.lanAdvertiseHost)) {
|
|
2088
2173
|
throw new Error('mobile-gateway: lanAdvertiseHost must be localhost or a private LAN address')
|
|
2089
2174
|
}
|
|
2175
|
+
if (typeof options.cloudflaredPath !== 'string' || !options.cloudflaredPath.trim()) {
|
|
2176
|
+
throw new Error('mobile-gateway: cloudflaredPath must be a nonempty executable path')
|
|
2177
|
+
}
|
|
2090
2178
|
let requireAuth = options.requireAuth !== false
|
|
2091
2179
|
const adminLoopbackOnly = options.adminLoopbackOnly !== false
|
|
2092
2180
|
const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
|
|
@@ -2112,6 +2200,7 @@ const plugin = {
|
|
|
2112
2200
|
},
|
|
2113
2201
|
})
|
|
2114
2202
|
const clients = new Set()
|
|
2203
|
+
const pluginInstallOwners = new Map()
|
|
2115
2204
|
const pendingQuestions = new Map()
|
|
2116
2205
|
const pendingApprovals = new Map()
|
|
2117
2206
|
const fileTransfers = createFileTransferManager(api, options)
|
|
@@ -2136,6 +2225,7 @@ const plugin = {
|
|
|
2136
2225
|
let lanListening = false
|
|
2137
2226
|
let lanListenError = null
|
|
2138
2227
|
let lanBoundPort = options.lanPort
|
|
2228
|
+
let tunnel = null
|
|
2139
2229
|
const configuredPublicUrl = () => {
|
|
2140
2230
|
if (typeof options.publicUrl === 'string' && options.publicUrl.trim()) return options.publicUrl.trim()
|
|
2141
2231
|
if (typeof options.publicUrlFile !== 'string' || !options.publicUrlFile.trim()) return ''
|
|
@@ -2157,6 +2247,7 @@ const plugin = {
|
|
|
2157
2247
|
...extra,
|
|
2158
2248
|
...configuredEndpoints,
|
|
2159
2249
|
...(publicUrl ? [publicUrl] : []),
|
|
2250
|
+
...(tunnel?.snapshot().publicUrl ? [tunnel.snapshot().publicUrl] : []),
|
|
2160
2251
|
...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
|
|
2161
2252
|
])])
|
|
2162
2253
|
}
|
|
@@ -2183,11 +2274,13 @@ const plugin = {
|
|
|
2183
2274
|
log(`failed to persist automatic gateway disable: ${error.message}`)
|
|
2184
2275
|
}
|
|
2185
2276
|
log('mobile gateway automatically disabled: no device connected before timeout')
|
|
2277
|
+
void tunnel?.reconcile()
|
|
2186
2278
|
}, options.gatewayWaitTimeoutMs)
|
|
2187
2279
|
} else if (mode === 'disabled') {
|
|
2188
2280
|
for (const client of clients) client.close(4004, 'mobile gateway disabled')
|
|
2189
2281
|
}
|
|
2190
2282
|
log(`mobile gateway mode=${mode}${reason ? `: ${reason}` : ''}`)
|
|
2283
|
+
void tunnel?.reconcile()
|
|
2191
2284
|
}
|
|
2192
2285
|
|
|
2193
2286
|
const logAuthRejected = (req) => {
|
|
@@ -2221,6 +2314,7 @@ const plugin = {
|
|
|
2221
2314
|
toolName: payload.toolName,
|
|
2222
2315
|
...(payload.callId !== undefined ? { callId: payload.callId } : {}),
|
|
2223
2316
|
...(payload.reason !== undefined ? { reason: payload.reason } : {}),
|
|
2317
|
+
...(payload.displayReason !== undefined ? { displayReason: payload.displayReason } : {}),
|
|
2224
2318
|
...(replay ? { replay: true } : {}),
|
|
2225
2319
|
})
|
|
2226
2320
|
|
|
@@ -2520,6 +2614,7 @@ const plugin = {
|
|
|
2520
2614
|
log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
|
|
2521
2615
|
|
|
2522
2616
|
// ---- device management routes (loopback-gated admin surface) ----
|
|
2617
|
+
let restartPending = false
|
|
2523
2618
|
const disposeMgmt = webServer.register({
|
|
2524
2619
|
kind: 'prefix',
|
|
2525
2620
|
path: '/mgw',
|
|
@@ -2536,6 +2631,7 @@ const plugin = {
|
|
|
2536
2631
|
const url = new URL(req.url || '/', 'http://x')
|
|
2537
2632
|
const p = url.pathname
|
|
2538
2633
|
if (req.method === 'GET' && p === '/mgw/status') {
|
|
2634
|
+
const profile = ctx.get?.('profileContext')
|
|
2539
2635
|
sendJson(res, 200, {
|
|
2540
2636
|
version: PLUGIN_VERSION,
|
|
2541
2637
|
requireAuth,
|
|
@@ -2546,8 +2642,13 @@ const plugin = {
|
|
|
2546
2642
|
waitExpiresAt,
|
|
2547
2643
|
connectedClients: clients.size,
|
|
2548
2644
|
webPort: webServer.port,
|
|
2645
|
+
webPid: process.pid,
|
|
2549
2646
|
wsPath,
|
|
2550
|
-
publicUrl:
|
|
2647
|
+
publicUrl: tunnel?.snapshot().enabled && tunnel.snapshot().mode === 'quick'
|
|
2648
|
+
? tunnel.snapshot().publicUrl
|
|
2649
|
+
: tunnel?.snapshot().publicUrl || configuredPublicUrl() || null,
|
|
2650
|
+
platform: process.platform,
|
|
2651
|
+
cloudflare: tunnel?.snapshot() || null,
|
|
2551
2652
|
lan: {
|
|
2552
2653
|
enabled: options.lanEnabled === true,
|
|
2553
2654
|
listening: lanListening,
|
|
@@ -2559,7 +2660,49 @@ const plugin = {
|
|
|
2559
2660
|
},
|
|
2560
2661
|
pairingTtlMs: options.pairingTtlMs,
|
|
2561
2662
|
queryTokenAllowed: !!options.allowQueryToken,
|
|
2663
|
+
tools: {
|
|
2664
|
+
restartWeb: webRestartAvailable(profile),
|
|
2665
|
+
stopWeb: webRestartAvailable(profile),
|
|
2666
|
+
},
|
|
2562
2667
|
})
|
|
2668
|
+
} else if (req.method === 'POST' && p === '/mgw/tools/restart-web') {
|
|
2669
|
+
if (!isLoopback(req)) {
|
|
2670
|
+
sendJson(res, 403, { error: 'forbidden', message: 'Web restart is loopback-only' })
|
|
2671
|
+
return
|
|
2672
|
+
}
|
|
2673
|
+
if (!webRestartAvailable(ctx.get?.('profileContext'))) {
|
|
2674
|
+
sendJson(res, 409, { error: 'unavailable', message: '只能在通过 dsh web 启动的 Web profile 中重启' })
|
|
2675
|
+
return
|
|
2676
|
+
}
|
|
2677
|
+
if (restartPending) {
|
|
2678
|
+
sendJson(res, 409, { error: 'restart-pending', message: 'DSH Web 正在重启' })
|
|
2679
|
+
return
|
|
2680
|
+
}
|
|
2681
|
+
restartPending = true
|
|
2682
|
+
try {
|
|
2683
|
+
await prepareWebRestart()
|
|
2684
|
+
} catch (error) {
|
|
2685
|
+
restartPending = false
|
|
2686
|
+
throw error
|
|
2687
|
+
}
|
|
2688
|
+
res.once('finish', () => setTimeout(() => process.kill(process.pid, 'SIGTERM'), 100))
|
|
2689
|
+
sendJson(res, 202, { restarting: true })
|
|
2690
|
+
} else if (req.method === 'POST' && p === '/mgw/tools/stop-web') {
|
|
2691
|
+
if (!isLoopback(req)) {
|
|
2692
|
+
sendJson(res, 403, { error: 'forbidden', message: 'Web shutdown is loopback-only' })
|
|
2693
|
+
return
|
|
2694
|
+
}
|
|
2695
|
+
if (!webRestartAvailable(ctx.get?.('profileContext'))) {
|
|
2696
|
+
sendJson(res, 409, { error: 'unavailable', message: '只能停止通过 dsh web 启动的 Web profile' })
|
|
2697
|
+
return
|
|
2698
|
+
}
|
|
2699
|
+
if (restartPending) {
|
|
2700
|
+
sendJson(res, 409, { error: 'restart-pending', message: 'DSH Web 正在重启或停止' })
|
|
2701
|
+
return
|
|
2702
|
+
}
|
|
2703
|
+
restartPending = true
|
|
2704
|
+
res.once('finish', () => setTimeout(() => process.kill(process.pid, 'SIGTERM'), 100))
|
|
2705
|
+
sendJson(res, 202, { stopping: true })
|
|
2563
2706
|
} else if (req.method === 'GET' && p === '/mgw/public-setup') {
|
|
2564
2707
|
sendJson(res, 200, await systemHelperStatus())
|
|
2565
2708
|
} else if (req.method === 'POST' && p === '/mgw/public-setup') {
|
|
@@ -2578,6 +2721,15 @@ const plugin = {
|
|
|
2578
2721
|
if (body.mode === undefined && typeof body.enabled !== 'boolean') throw badRequest('mode or boolean enabled is required')
|
|
2579
2722
|
setGatewayMode(body.mode !== undefined ? body.mode : (body.enabled ? 'temporary' : 'disabled'), 'changed from management UI')
|
|
2580
2723
|
sendJson(res, 200, { gatewayEnabled, gatewayMode, waitExpiresAt, connectedClients: clients.size })
|
|
2724
|
+
} else if (req.method === 'POST' && p === '/mgw/cloudflare') {
|
|
2725
|
+
const body = await readBody(req)
|
|
2726
|
+
await tunnel.configure(body)
|
|
2727
|
+
if (body.enabled && gatewayMode !== 'persistent') setGatewayMode('persistent', 'Cloudflare Tunnel enabled from management UI')
|
|
2728
|
+
await tunnel.reconcile()
|
|
2729
|
+
sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
|
|
2730
|
+
} else if (req.method === 'POST' && p === '/mgw/cloudflare/restart') {
|
|
2731
|
+
await tunnel.restart()
|
|
2732
|
+
sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
|
|
2581
2733
|
} else if (req.method === 'POST' && p === '/mgw/auth') {
|
|
2582
2734
|
const body = await readBody(req)
|
|
2583
2735
|
if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
|
|
@@ -2798,8 +2950,25 @@ const plugin = {
|
|
|
2798
2950
|
msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
|
|
2799
2951
|
msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
|
|
2800
2952
|
msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
|
|
2801
|
-
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear'
|
|
2802
|
-
|
|
2953
|
+
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear' ||
|
|
2954
|
+
msg.type === 'schedule-catalog' || msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
|
|
2955
|
+
msg.type === 'schedule-update' || msg.type === 'schedule-delete' ||
|
|
2956
|
+
PLUGIN_REQUEST_TYPES.has(msg.type)) {
|
|
2957
|
+
if (PLUGIN_REQUEST_TYPES.has(msg.type) && !ws.deviceId) {
|
|
2958
|
+
sendFrame({ kind: 'error', code: 'authentication-required', requestType: msg.type,
|
|
2959
|
+
...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
|
|
2960
|
+
message: 'Plugin management requires a paired device' })
|
|
2961
|
+
return
|
|
2962
|
+
}
|
|
2963
|
+
if (msg.type === 'plugin-install' && pluginInstallOwners.has(msg.requestId)) {
|
|
2964
|
+
sendFrame({ kind: 'error', code: 'plugin-install/in-progress', requestType: msg.type,
|
|
2965
|
+
requestId: msg.requestId, message: 'An installation already uses this requestId' })
|
|
2966
|
+
return
|
|
2967
|
+
}
|
|
2968
|
+
handleQuery(api, host, agentDefaultModel, msg, {
|
|
2969
|
+
onInstallStart: (requestId) => pluginInstallOwners.set(requestId, ws),
|
|
2970
|
+
onInstallEnd: (requestId) => pluginInstallOwners.delete(requestId),
|
|
2971
|
+
}).then((frame) => {
|
|
2803
2972
|
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2804
2973
|
})
|
|
2805
2974
|
} else {
|
|
@@ -2844,6 +3013,8 @@ const plugin = {
|
|
|
2844
3013
|
'commands',
|
|
2845
3014
|
'tasks',
|
|
2846
3015
|
'goals',
|
|
3016
|
+
'schedule-management',
|
|
3017
|
+
'plugin-management-v1',
|
|
2847
3018
|
'session-cancel',
|
|
2848
3019
|
'queue-control',
|
|
2849
3020
|
'session-archive',
|
|
@@ -2917,6 +3088,18 @@ const plugin = {
|
|
|
2917
3088
|
})
|
|
2918
3089
|
}
|
|
2919
3090
|
|
|
3091
|
+
tunnel = createCloudflareTunnel({
|
|
3092
|
+
file: options.cloudflareStateFile || `${deviceFile}.cloudflare.json`,
|
|
3093
|
+
port: options.cloudflarePort,
|
|
3094
|
+
wsPath,
|
|
3095
|
+
executable: options.cloudflaredPath,
|
|
3096
|
+
supported: process.platform === 'darwin' || process.platform === 'win32',
|
|
3097
|
+
isGatewayEnabled: () => gatewayEnabled,
|
|
3098
|
+
onUpgrade: handleMobileUpgrade,
|
|
3099
|
+
log,
|
|
3100
|
+
})
|
|
3101
|
+
void tunnel.reconcile()
|
|
3102
|
+
|
|
2920
3103
|
const disposeEvents = ctx.on('session/event', (session, event) => {
|
|
2921
3104
|
api.observeSessionEvent(String(session.id), event)
|
|
2922
3105
|
if (clients.size === 0) return
|
|
@@ -2947,6 +3130,31 @@ const plugin = {
|
|
|
2947
3130
|
})
|
|
2948
3131
|
log('session/event listener attached')
|
|
2949
3132
|
|
|
3133
|
+
// The Host notification has no payload: readers refetch the task catalog.
|
|
3134
|
+
const disposeScheduleChanged = ctx.on('schedule/changed', () => {
|
|
3135
|
+
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'schedule-changed' })
|
|
3136
|
+
})
|
|
3137
|
+
|
|
3138
|
+
const disposePluginsChanged = ctx.on('plugin-manager/changed', (change) => {
|
|
3139
|
+
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'plugins-changed', reason: change?.reason })
|
|
3140
|
+
})
|
|
3141
|
+
const disposePluginSettingsChanged = ctx.on('settings/document-updated', (ns, revision) => {
|
|
3142
|
+
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'plugin-settings-changed', ns, revision })
|
|
3143
|
+
})
|
|
3144
|
+
const sendPluginInstallEvent = (kind, event) => {
|
|
3145
|
+
const owner = pluginInstallOwners.get(event?.requestId)
|
|
3146
|
+
if (owner?.readyState === 1) owner.send(stringifyWireFrame({ kind, ...event }))
|
|
3147
|
+
}
|
|
3148
|
+
const disposePluginInstallState = ctx.on('plugin-manager/install-state', (progress) => {
|
|
3149
|
+
sendPluginInstallEvent('plugin-install-state', progress)
|
|
3150
|
+
})
|
|
3151
|
+
const disposePluginInstallLog = ctx.on('plugin-manager/install-log', (chunk) => {
|
|
3152
|
+
if (!chunk?.requestId) return
|
|
3153
|
+
const { requestId, jobId, stream, text, exitCode } = chunk
|
|
3154
|
+
sendPluginInstallEvent('plugin-install-log', { requestId, jobId, stream, text,
|
|
3155
|
+
...(exitCode === undefined ? {} : { exitCode }) })
|
|
3156
|
+
})
|
|
3157
|
+
|
|
2950
3158
|
const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
|
|
2951
3159
|
const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
|
|
2952
3160
|
if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
|
|
@@ -3002,6 +3210,7 @@ const plugin = {
|
|
|
3002
3210
|
toolName: request.toolName,
|
|
3003
3211
|
...(request.callId !== undefined ? { callId: request.callId } : {}),
|
|
3004
3212
|
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
3213
|
+
...(request.displayReason !== undefined ? { displayReason: request.displayReason } : {}),
|
|
3005
3214
|
next,
|
|
3006
3215
|
downstreamAbort,
|
|
3007
3216
|
resolve,
|
|
@@ -3040,49 +3249,53 @@ const plugin = {
|
|
|
3040
3249
|
log(`session archive state forwarded: count=${next.length}`)
|
|
3041
3250
|
}
|
|
3042
3251
|
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3252
|
+
// DSH rc.1 carries pending messages in the durable inbox projection.
|
|
3253
|
+
// session/control no longer emits a separate queues baseline or queue frames.
|
|
3254
|
+
const queueItemsFromInbox = (value, endpoint) => {
|
|
3255
|
+
if (value == null) return []
|
|
3256
|
+
if (typeof value !== 'object' || Array.isArray(value)
|
|
3257
|
+
|| !Array.isArray(value['next-turn']) || !Array.isArray(value['next-step'])) {
|
|
3258
|
+
throw new Error(`${endpoint} returned an invalid inbox projection`)
|
|
3259
|
+
}
|
|
3260
|
+
const convert = (messages, target) => messages.map((message) => {
|
|
3261
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)
|
|
3053
3262
|
|| typeof message.id !== 'string' || !message.id
|
|
3054
3263
|
|| !Array.isArray(message.content)
|
|
3055
|
-
||
|
|
3056
|
-
|
|
3264
|
+
|| !message.source || typeof message.source.kind !== 'string'
|
|
3265
|
+
|| (message.source.rpcId !== undefined && typeof message.source.rpcId !== 'string')) {
|
|
3266
|
+
throw new Error(`${endpoint} returned an invalid inbox message`)
|
|
3057
3267
|
}
|
|
3058
3268
|
return {
|
|
3059
|
-
id:
|
|
3060
|
-
placement:
|
|
3061
|
-
...(
|
|
3269
|
+
id: message.id,
|
|
3270
|
+
placement: target === 'next-turn' ? 'queued' : message.source.kind === 'user' ? 'steering' : 'context',
|
|
3271
|
+
...(message.source.rpcId === undefined ? {} : { rpcId: message.source.rpcId }),
|
|
3062
3272
|
message: { id: message.id, content: message.content },
|
|
3063
3273
|
}
|
|
3064
3274
|
})
|
|
3275
|
+
return [...convert(value['next-turn'], 'next-turn'), ...convert(value['next-step'], 'next-step')]
|
|
3065
3276
|
}
|
|
3066
3277
|
|
|
3067
|
-
const installSessionQueueBaseline = (
|
|
3068
|
-
if (!
|
|
3069
|
-
throw new Error('session/control baseline returned invalid
|
|
3278
|
+
const installSessionQueueBaseline = (projections) => {
|
|
3279
|
+
if (!projections || typeof projections !== 'object' || Array.isArray(projections)) {
|
|
3280
|
+
throw new Error('session/control baseline returned invalid projections')
|
|
3070
3281
|
}
|
|
3071
3282
|
const next = new Map()
|
|
3072
|
-
for (const [sessionId,
|
|
3073
|
-
|
|
3283
|
+
for (const [sessionId, baseline] of Object.entries(projections)) {
|
|
3284
|
+
const items = queueItemsFromInbox(baseline?.values?.inbox, 'session/control baseline')
|
|
3285
|
+
if (items.length > 0) next.set(sessionId, items)
|
|
3074
3286
|
}
|
|
3075
3287
|
sessionQueues = next
|
|
3076
3288
|
broadcastSessionMetadataFrame({ kind: 'session-queues', queues: Object.fromEntries(next) })
|
|
3077
3289
|
log(`session queue baseline forwarded: sessions=${next.size}`)
|
|
3078
3290
|
}
|
|
3079
3291
|
|
|
3080
|
-
const installSessionQueue = (sessionIdValue,
|
|
3292
|
+
const installSessionQueue = (sessionIdValue, inboxValue) => {
|
|
3081
3293
|
const sessionId = typeof sessionIdValue === 'string' && sessionIdValue ? sessionIdValue : null
|
|
3082
3294
|
if (!sessionId) throw new Error('session/control returned an invalid queue sessionId')
|
|
3083
|
-
const items =
|
|
3295
|
+
const items = queueItemsFromInbox(inboxValue, 'session/control inbox')
|
|
3084
3296
|
if (sessionQueues === null) sessionQueues = new Map()
|
|
3085
|
-
sessionQueues.set(sessionId, items)
|
|
3297
|
+
if (items.length > 0) sessionQueues.set(sessionId, items)
|
|
3298
|
+
else sessionQueues.delete(sessionId)
|
|
3086
3299
|
broadcastSessionMetadataFrame({ kind: 'session-queue', sessionId, items })
|
|
3087
3300
|
log(`session queue forwarded: session=${sessionId} items=${items.length}`)
|
|
3088
3301
|
}
|
|
@@ -3143,9 +3356,9 @@ const plugin = {
|
|
|
3143
3356
|
for await (const frame of stream) {
|
|
3144
3357
|
if (frame?.type === 'baseline') {
|
|
3145
3358
|
installProjectionBaseline(frame.value?.projections)
|
|
3146
|
-
installSessionQueueBaseline(frame.value?.
|
|
3147
|
-
} else if (frame?.type === '
|
|
3148
|
-
installSessionQueue(frame.sessionId, frame.
|
|
3359
|
+
installSessionQueueBaseline(frame.value?.projections)
|
|
3360
|
+
} else if (frame?.type === 'projection' && frame.key === 'inbox') {
|
|
3361
|
+
installSessionQueue(frame.sessionId, frame.value)
|
|
3149
3362
|
} else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
|
|
3150
3363
|
const sessionId = String(frame.sessionId)
|
|
3151
3364
|
const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
|
|
@@ -3179,14 +3392,21 @@ const plugin = {
|
|
|
3179
3392
|
disposeUpgrade()
|
|
3180
3393
|
disposeMgmt()
|
|
3181
3394
|
disposeEvents()
|
|
3395
|
+
disposeScheduleChanged()
|
|
3396
|
+
disposePluginsChanged()
|
|
3397
|
+
disposePluginSettingsChanged()
|
|
3398
|
+
disposePluginInstallState()
|
|
3399
|
+
disposePluginInstallLog()
|
|
3182
3400
|
disposeQuestions()
|
|
3183
3401
|
disposeApprovals()
|
|
3184
3402
|
if (lanServer) lanServer.close()
|
|
3403
|
+
tunnel.dispose()
|
|
3185
3404
|
for (const client of clients) {
|
|
3186
3405
|
client.sessionFollower?.stop()
|
|
3187
3406
|
client.terminate()
|
|
3188
3407
|
}
|
|
3189
3408
|
clients.clear()
|
|
3409
|
+
pluginInstallOwners.clear()
|
|
3190
3410
|
wss.close()
|
|
3191
3411
|
log('plugin stopped, all sockets closed')
|
|
3192
3412
|
})
|