dsh-plugin-mobile-gateway 0.8.1 → 0.10.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 +37 -2
- package/README.md +16 -4
- package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -0
- package/lib/client.js +424 -130
- package/lib/cloudflare-tunnel.mjs +43 -2
- package/lib/devices.js +23 -7
- package/lib/dsh-host-adapter.mjs +28 -1
- package/lib/gateway-probe.mjs +6 -0
- package/lib/index.mjs +72 -11
- package/lib/plugin-management.mjs +194 -0
- package/lib/public-endpoint-check.mjs +61 -0
- package/package.json +3 -3
|
@@ -4,6 +4,7 @@ import path from 'node:path'
|
|
|
4
4
|
import crypto from 'node:crypto'
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
6
6
|
import { ensureCloudflared } from './cloudflared-binary.mjs'
|
|
7
|
+
import { checkPublicEndpoint } from './public-endpoint-check.mjs'
|
|
7
8
|
|
|
8
9
|
function hostnameOf(value) {
|
|
9
10
|
if (typeof value !== 'string') throw new TypeError('Cloudflare hostname is required')
|
|
@@ -61,7 +62,7 @@ function writeState(file, value) {
|
|
|
61
62
|
// Cloudflare can never reach the DSH WebUI or the /mgw management API through it.
|
|
62
63
|
export function createCloudflareTunnel({ file, port = 3082, wsPath, executable = 'cloudflared',
|
|
63
64
|
supported = true, isGatewayEnabled, onUpgrade, log = () => {}, spawnProcess = spawn,
|
|
64
|
-
prepareExecutable = ensureCloudflared }) {
|
|
65
|
+
prepareExecutable = ensureCloudflared, probeEndpoint = checkPublicEndpoint }) {
|
|
65
66
|
let settings = supported ? readState(file) : { version: 1, enabled: false, mode: 'quick', hostname: '', token: '' }
|
|
66
67
|
let server = null
|
|
67
68
|
let child = null
|
|
@@ -73,6 +74,22 @@ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable =
|
|
|
73
74
|
let quickUrl = null
|
|
74
75
|
let queue = Promise.resolve()
|
|
75
76
|
const sockets = new Set()
|
|
77
|
+
let endpointHealth = { state: 'unchecked', message: '尚未检测公网 DNS、TLS 和 WebSocket 路由。' }
|
|
78
|
+
let healthGeneration = 0
|
|
79
|
+
let healthPending = null
|
|
80
|
+
const checkEndpoint = () => {
|
|
81
|
+
const endpoint = publicUrl()
|
|
82
|
+
if (!shouldRun() || !endpoint || phase !== 'online') return Promise.resolve(snapshot())
|
|
83
|
+
if (healthPending) return healthPending
|
|
84
|
+
const generation = healthGeneration
|
|
85
|
+
endpointHealth = { state: 'checking', message: '正在检测公网 DNS、TLS 和 WebSocket 路由…' }
|
|
86
|
+
const pending = Promise.resolve().then(() => probeEndpoint(endpoint)).catch(() => ({ state: 'network-error', message: '公网检测失败,请稍后重试。', checkedAt: new Date().toISOString() })).then((health) => {
|
|
87
|
+
if (generation === healthGeneration) endpointHealth = health
|
|
88
|
+
return snapshot()
|
|
89
|
+
}).finally(() => { if (healthPending === pending) healthPending = null })
|
|
90
|
+
healthPending = pending
|
|
91
|
+
return pending
|
|
92
|
+
}
|
|
76
93
|
|
|
77
94
|
const shouldRun = () => supported && !disposed && settings.enabled && isGatewayEnabled()
|
|
78
95
|
const publicUrl = () => settings.mode === 'quick'
|
|
@@ -88,9 +105,14 @@ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable =
|
|
|
88
105
|
port,
|
|
89
106
|
state: !settings.enabled ? 'disabled' : !isGatewayEnabled() ? 'waiting-for-gateway' : phase,
|
|
90
107
|
error,
|
|
108
|
+
endpointHealth,
|
|
109
|
+
publicReady: phase === 'online' && shouldRun() && endpointHealth.state === 'reachable',
|
|
91
110
|
})
|
|
92
111
|
|
|
93
112
|
const stop = async () => {
|
|
113
|
+
healthGeneration += 1
|
|
114
|
+
healthPending = null
|
|
115
|
+
endpointHealth = { state: 'unchecked', message: '尚未检测公网 DNS、TLS 和 WebSocket 路由。' }
|
|
94
116
|
if (retryTimer) clearTimeout(retryTimer)
|
|
95
117
|
retryTimer = null
|
|
96
118
|
const previous = child
|
|
@@ -147,8 +169,10 @@ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable =
|
|
|
147
169
|
.replace(/eyJ[A-Za-z0-9._=-]+/g, '[redacted]').trim().slice(-500)
|
|
148
170
|
}
|
|
149
171
|
if (registered && (!quick || quickUrl)) {
|
|
172
|
+
const firstOnline = phase !== 'online'
|
|
150
173
|
phase = 'online'
|
|
151
174
|
error = null
|
|
175
|
+
if (firstOnline) void checkEndpoint()
|
|
152
176
|
}
|
|
153
177
|
}
|
|
154
178
|
running.stderr?.on('data', onOutput)
|
|
@@ -156,6 +180,9 @@ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable =
|
|
|
156
180
|
const exited = (reason) => {
|
|
157
181
|
if (child !== running) return
|
|
158
182
|
child = null
|
|
183
|
+
healthGeneration += 1
|
|
184
|
+
healthPending = null
|
|
185
|
+
endpointHealth = { state: 'unchecked', message: '隧道已断开,需重新检测公网入口。' }
|
|
159
186
|
quickUrl = null
|
|
160
187
|
phase = 'error'
|
|
161
188
|
error = reason || diagnostics || 'cloudflared exited unexpectedly'
|
|
@@ -263,6 +290,20 @@ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable =
|
|
|
263
290
|
return snapshot()
|
|
264
291
|
}
|
|
265
292
|
|
|
293
|
+
const restart = () => {
|
|
294
|
+
queue = queue.catch(() => {}).then(async () => {
|
|
295
|
+
if (!shouldRun()) {
|
|
296
|
+
const cause = new Error('请先开启移动网关和 Cloudflare Tunnel')
|
|
297
|
+
cause.status = 409
|
|
298
|
+
throw cause
|
|
299
|
+
}
|
|
300
|
+
await stop()
|
|
301
|
+
await start()
|
|
302
|
+
return snapshot()
|
|
303
|
+
})
|
|
304
|
+
return queue
|
|
305
|
+
}
|
|
306
|
+
|
|
266
307
|
const dispose = () => { disposed = true; void stop() }
|
|
267
|
-
return { snapshot, configure, reconcile, dispose }
|
|
308
|
+
return { snapshot, configure, reconcile, restart, checkEndpoint, dispose }
|
|
268
309
|
}
|
package/lib/devices.js
CHANGED
|
@@ -99,9 +99,18 @@ function createRegistry(file, options = {}) {
|
|
|
99
99
|
|
|
100
100
|
return {
|
|
101
101
|
list() {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
const active = devices.filter((device) => !device.revokedAt)
|
|
103
|
+
const counts = new Map()
|
|
104
|
+
const indices = new Map()
|
|
105
|
+
for (const device of active) counts.set(device.name, (counts.get(device.name) || 0) + 1)
|
|
106
|
+
return active.map((device) => {
|
|
107
|
+
const index = (indices.get(device.name) || 0) + 1
|
|
108
|
+
indices.set(device.name, index)
|
|
109
|
+
return {
|
|
110
|
+
...publicDevice(device, online.get(device.id) || 0),
|
|
111
|
+
displayName: counts.get(device.name) > 1 ? `${device.name} (${index})` : device.name,
|
|
112
|
+
}
|
|
113
|
+
})
|
|
105
114
|
},
|
|
106
115
|
|
|
107
116
|
count() {
|
|
@@ -125,7 +134,7 @@ function createRegistry(file, options = {}) {
|
|
|
125
134
|
return typeof code === 'string' && pairings.has(digest(code))
|
|
126
135
|
},
|
|
127
136
|
|
|
128
|
-
claimPairing(code, clientDeviceId) {
|
|
137
|
+
claimPairing(code, clientDeviceId, clientDeviceName) {
|
|
129
138
|
prunePairings()
|
|
130
139
|
if (typeof code !== 'string' || code === '') return undefined
|
|
131
140
|
const codeHash = digest(code)
|
|
@@ -143,13 +152,13 @@ function createRegistry(file, options = {}) {
|
|
|
143
152
|
// A valid one-time pairing code authorizes credential rotation. Keep
|
|
144
153
|
// the stable server-side device record while replacing its token, so
|
|
145
154
|
// re-pairing one iOS installation never creates duplicate rows.
|
|
146
|
-
device.name =
|
|
155
|
+
device.name = typeof clientDeviceName === 'string' && clientDeviceName.trim() ? clientDeviceName.trim().slice(0, 80) : '未命名设备'
|
|
147
156
|
device.tokenHash = digest(token)
|
|
148
157
|
device.clientDeviceId = normalizedClientDeviceId
|
|
149
158
|
} else {
|
|
150
159
|
device = {
|
|
151
160
|
id: pairing.id,
|
|
152
|
-
name:
|
|
161
|
+
name: typeof clientDeviceName === 'string' && clientDeviceName.trim() ? clientDeviceName.trim().slice(0, 80) : '未命名设备',
|
|
153
162
|
clientDeviceId: normalizedClientDeviceId,
|
|
154
163
|
tokenHash: digest(token),
|
|
155
164
|
createdAt: Date.now(),
|
|
@@ -162,7 +171,7 @@ function createRegistry(file, options = {}) {
|
|
|
162
171
|
return { device: publicDevice(device, 0), token }
|
|
163
172
|
},
|
|
164
173
|
|
|
165
|
-
authenticate(token, clientDeviceId) {
|
|
174
|
+
authenticate(token, clientDeviceId, clientDeviceName) {
|
|
166
175
|
if (typeof token !== 'string' || token === '') return undefined
|
|
167
176
|
const tokenHash = digest(token)
|
|
168
177
|
const device = devices.find((candidate) => !candidate.revokedAt && safeEqualHex(candidate.tokenHash, tokenHash))
|
|
@@ -177,6 +186,13 @@ function createRegistry(file, options = {}) {
|
|
|
177
186
|
save()
|
|
178
187
|
}
|
|
179
188
|
}
|
|
189
|
+
if (device && typeof clientDeviceName === 'string' && clientDeviceName.trim()) {
|
|
190
|
+
const name = clientDeviceName.trim().slice(0, 80)
|
|
191
|
+
if (device.name !== name) {
|
|
192
|
+
device.name = name
|
|
193
|
+
save()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
180
196
|
return device ? publicDevice(device, online.get(device.id) || 0) : undefined
|
|
181
197
|
},
|
|
182
198
|
|
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -13,7 +13,7 @@ function requireArray(value, endpoint) {
|
|
|
13
13
|
return value
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export const DSH_VERSION = '0.
|
|
16
|
+
export const DSH_VERSION = '0.2.0-rc.2'
|
|
17
17
|
export const SESSION_FORMAT_VERSION = 4
|
|
18
18
|
|
|
19
19
|
export function readHistoryRecords(records) {
|
|
@@ -247,12 +247,39 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
247
247
|
},
|
|
248
248
|
settings: {
|
|
249
249
|
describe: (_payload = {}, signal) => invoke('settings', 'describe', {}, signal),
|
|
250
|
+
mutate: (payload, signal) => invoke('settings', 'mutate', {
|
|
251
|
+
ns: payload.ns,
|
|
252
|
+
ops: payload.ops,
|
|
253
|
+
expectedRevision: payload.expectedRevision,
|
|
254
|
+
}, signal),
|
|
250
255
|
update: (payload, signal) => invoke('settings', 'update', {
|
|
251
256
|
ns: payload.ns,
|
|
252
257
|
patch: payload.patch,
|
|
253
258
|
...(payload.expectedRevision === undefined ? {} : { expectedRevision: payload.expectedRevision }),
|
|
254
259
|
}, signal),
|
|
255
260
|
},
|
|
261
|
+
plugins: {
|
|
262
|
+
inventory: (_payload = {}, signal) => invoke('pluginInventory', 'list', {}, signal),
|
|
263
|
+
listBundles: (signal) => invoke('pluginManager', 'listBundles', {}, signal),
|
|
264
|
+
listPlugins: (signal) => invoke('pluginManager', 'listPlugins', {}, signal),
|
|
265
|
+
registries: (signal) => invoke('pluginManager', 'registries', {}, signal),
|
|
266
|
+
inspect: (spec, options, signal) => invoke('pluginManager', 'inspect', {
|
|
267
|
+
spec, ...(options === undefined ? {} : { options }),
|
|
268
|
+
}, signal),
|
|
269
|
+
installBundle: (spec, options) => invoke('pluginManager', 'installBundle', { spec, options }),
|
|
270
|
+
waitForInstall: (requestId) => invoke('pluginManager', 'waitForInstall', { requestId }),
|
|
271
|
+
cancelInstall: (requestId) => invoke('pluginManager', 'cancelInstall', { requestId }),
|
|
272
|
+
setBundleEnabled: (name, enabled) => invoke('pluginManager', 'setBundleEnabled', { name, enabled }),
|
|
273
|
+
setPluginEnabled: (id, enabled) => invoke('pluginManager', 'setPluginEnabled', { id, enabled }),
|
|
274
|
+
removeBundle: (name) => invoke('pluginManager', 'removeBundle', { name }),
|
|
275
|
+
listVersionExemptions: () => invoke('pluginManager', 'listVersionExemptions', {}),
|
|
276
|
+
setVersionExemption: (packageVersion, runtimeVersion, enabled, acceptRisk) => invoke(
|
|
277
|
+
'pluginManager', 'setVersionExemption', {
|
|
278
|
+
packageVersion, runtimeVersion, enabled,
|
|
279
|
+
...(acceptRisk === undefined ? {} : { acceptRisk }),
|
|
280
|
+
},
|
|
281
|
+
),
|
|
282
|
+
},
|
|
256
283
|
permissionPresets: {
|
|
257
284
|
catalog: (_payload = {}, signal) => invoke('permissionPresets', 'catalog', {}, signal),
|
|
258
285
|
},
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Echo only a strictly bounded, non-secret challenge on an unauthenticated
|
|
2
|
+
// rejection. This does not authorize a device or expose management data.
|
|
3
|
+
export function gatewayProbeHeader(value) {
|
|
4
|
+
return typeof value === 'string' && /^[a-f0-9]{32}$/.test(value)
|
|
5
|
+
? `X-DSH-Gateway-Probe: ${value}\r\n` : ''
|
|
6
|
+
}
|
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,9 +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'
|
|
135
138
|
import { createCloudflareTunnel } from './cloudflare-tunnel.mjs'
|
|
139
|
+
import { gatewayProbeHeader } from './gateway-probe.mjs'
|
|
136
140
|
import { webRestartAvailable, prepareWebRestart } from './gateway-tools.mjs'
|
|
137
141
|
import QRCode from 'qrcode'
|
|
138
142
|
|
|
@@ -1335,7 +1339,8 @@ function parseAgentPreset(msg, requestType, required = false) {
|
|
|
1335
1339
|
}
|
|
1336
1340
|
|
|
1337
1341
|
// Dispatch one mobile query frame; returns the wire frame to send back.
|
|
1338
|
-
async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
1342
|
+
async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
|
|
1343
|
+
if (PLUGIN_REQUEST_TYPES.has(msg.type)) return handlePluginQuery(api, msg, pluginContext)
|
|
1339
1344
|
if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
|
|
1340
1345
|
const session = requireSessionId(msg)
|
|
1341
1346
|
const preset = parseAgentPreset(msg, msg.type, msg.type === 'select-agent-preset')
|
|
@@ -1934,9 +1939,16 @@ function extractClientDeviceId(req) {
|
|
|
1934
1939
|
return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
|
|
1935
1940
|
}
|
|
1936
1941
|
|
|
1937
|
-
function
|
|
1942
|
+
function extractClientDeviceName(req) {
|
|
1943
|
+
const value = req.headers['x-dsh-device-name']
|
|
1944
|
+
if (typeof value !== 'string' || value.length > 512 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return undefined
|
|
1945
|
+
const name = Buffer.from(value, 'base64').toString('utf8').trim()
|
|
1946
|
+
return name && !/[\u0000-\u001f\u007f\ufffd]/.test(name) ? name.slice(0, 80) : undefined
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function rejectUpgrade(socket, status, message, probe) {
|
|
1938
1950
|
const reason = status === 401 ? 'Unauthorized' : status === 403 ? 'Forbidden' : status === 404 ? 'Not Found' : status === 503 ? 'Service Unavailable' : 'Bad Request'
|
|
1939
|
-
socket.write(`HTTP/1.1 ${status} ${reason}\r\
|
|
1951
|
+
socket.write(`HTTP/1.1 ${status} ${reason}\r\n${gatewayProbeHeader(probe)}Connection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`)
|
|
1940
1952
|
socket.destroy()
|
|
1941
1953
|
}
|
|
1942
1954
|
|
|
@@ -2196,6 +2208,7 @@ const plugin = {
|
|
|
2196
2208
|
},
|
|
2197
2209
|
})
|
|
2198
2210
|
const clients = new Set()
|
|
2211
|
+
const pluginInstallOwners = new Map()
|
|
2199
2212
|
const pendingQuestions = new Map()
|
|
2200
2213
|
const pendingApprovals = new Map()
|
|
2201
2214
|
const fileTransfers = createFileTransferManager(api, options)
|
|
@@ -2210,6 +2223,7 @@ const plugin = {
|
|
|
2210
2223
|
let sessionProjectionBaselines = {}
|
|
2211
2224
|
let counter = 0
|
|
2212
2225
|
let gatewayMode = gatewayState.mode ?? options.gatewayMode ?? (options.gatewayEnabled === true ? 'persistent' : 'disabled')
|
|
2226
|
+
const gatewayInstanceId = crypto.randomUUID()
|
|
2213
2227
|
let gatewayEnabled = gatewayMode !== 'disabled'
|
|
2214
2228
|
let waitExpiresAt = null
|
|
2215
2229
|
let waitTimer = null
|
|
@@ -2638,6 +2652,7 @@ const plugin = {
|
|
|
2638
2652
|
connectedClients: clients.size,
|
|
2639
2653
|
webPort: webServer.port,
|
|
2640
2654
|
webPid: process.pid,
|
|
2655
|
+
gatewayInstanceId,
|
|
2641
2656
|
wsPath,
|
|
2642
2657
|
publicUrl: tunnel?.snapshot().enabled && tunnel.snapshot().mode === 'quick'
|
|
2643
2658
|
? tunnel.snapshot().publicUrl
|
|
@@ -2722,6 +2737,12 @@ const plugin = {
|
|
|
2722
2737
|
if (body.enabled && gatewayMode !== 'persistent') setGatewayMode('persistent', 'Cloudflare Tunnel enabled from management UI')
|
|
2723
2738
|
await tunnel.reconcile()
|
|
2724
2739
|
sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
|
|
2740
|
+
} else if (req.method === 'POST' && p === '/mgw/cloudflare/check') {
|
|
2741
|
+
await tunnel.checkEndpoint()
|
|
2742
|
+
sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
|
|
2743
|
+
} else if (req.method === 'POST' && p === '/mgw/cloudflare/restart') {
|
|
2744
|
+
await tunnel.restart()
|
|
2745
|
+
sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
|
|
2725
2746
|
} else if (req.method === 'POST' && p === '/mgw/auth') {
|
|
2726
2747
|
const body = await readBody(req)
|
|
2727
2748
|
if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
|
|
@@ -2746,10 +2767,9 @@ const plugin = {
|
|
|
2746
2767
|
throw error
|
|
2747
2768
|
}
|
|
2748
2769
|
const body = await readBody(req)
|
|
2749
|
-
const name = typeof body.name === 'string' ? body.name : undefined
|
|
2750
2770
|
const publicUrl = normalizePublicUrl(body.publicUrl || configuredPublicUrl(), req, wsPath)
|
|
2751
2771
|
const endpoints = advertisedEndpoints(publicUrl, normalizeEndpoints(body.endpoints ?? []))
|
|
2752
|
-
const pairing = registry.createPairing(
|
|
2772
|
+
const pairing = registry.createPairing()
|
|
2753
2773
|
const payload = {
|
|
2754
2774
|
version: 2,
|
|
2755
2775
|
publicUrl,
|
|
@@ -2812,7 +2832,7 @@ const plugin = {
|
|
|
2812
2832
|
rejectUpgrade(socket, 400, 'pairing requires X-DSH-Device-ID; update the iOS client')
|
|
2813
2833
|
return
|
|
2814
2834
|
}
|
|
2815
|
-
paired = registry.claimPairing(credential.value, clientDeviceId)
|
|
2835
|
+
paired = registry.claimPairing(credential.value, clientDeviceId, extractClientDeviceName(req))
|
|
2816
2836
|
device = paired && paired.device
|
|
2817
2837
|
if (!device) {
|
|
2818
2838
|
log('pairing rejected: invalid, expired, or already-used code')
|
|
@@ -2820,7 +2840,7 @@ const plugin = {
|
|
|
2820
2840
|
return
|
|
2821
2841
|
}
|
|
2822
2842
|
} else if (credential && credential.kind === 'token') {
|
|
2823
|
-
device = registry.authenticate(credential.value, extractClientDeviceId(req))
|
|
2843
|
+
device = registry.authenticate(credential.value, extractClientDeviceId(req), extractClientDeviceName(req))
|
|
2824
2844
|
}
|
|
2825
2845
|
|
|
2826
2846
|
// The separately exposed LAN listener is always authenticated. The
|
|
@@ -2829,7 +2849,7 @@ const plugin = {
|
|
|
2829
2849
|
// plane.
|
|
2830
2850
|
if ((requireAuth || transport.lan === true) && !device) {
|
|
2831
2851
|
logAuthRejected(req)
|
|
2832
|
-
rejectUpgrade(socket, 401, 'missing or invalid device credential')
|
|
2852
|
+
rejectUpgrade(socket, 401, 'missing or invalid device credential', req.headers['x-dsh-gateway-probe'])
|
|
2833
2853
|
return
|
|
2834
2854
|
}
|
|
2835
2855
|
|
|
@@ -2944,8 +2964,23 @@ const plugin = {
|
|
|
2944
2964
|
msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
|
|
2945
2965
|
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear' ||
|
|
2946
2966
|
msg.type === 'schedule-catalog' || msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
|
|
2947
|
-
msg.type === 'schedule-update' || msg.type === 'schedule-delete'
|
|
2948
|
-
|
|
2967
|
+
msg.type === 'schedule-update' || msg.type === 'schedule-delete' ||
|
|
2968
|
+
PLUGIN_REQUEST_TYPES.has(msg.type)) {
|
|
2969
|
+
if (PLUGIN_REQUEST_TYPES.has(msg.type) && !ws.deviceId) {
|
|
2970
|
+
sendFrame({ kind: 'error', code: 'authentication-required', requestType: msg.type,
|
|
2971
|
+
...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
|
|
2972
|
+
message: 'Plugin management requires a paired device' })
|
|
2973
|
+
return
|
|
2974
|
+
}
|
|
2975
|
+
if (msg.type === 'plugin-install' && pluginInstallOwners.has(msg.requestId)) {
|
|
2976
|
+
sendFrame({ kind: 'error', code: 'plugin-install/in-progress', requestType: msg.type,
|
|
2977
|
+
requestId: msg.requestId, message: 'An installation already uses this requestId' })
|
|
2978
|
+
return
|
|
2979
|
+
}
|
|
2980
|
+
handleQuery(api, host, agentDefaultModel, msg, {
|
|
2981
|
+
onInstallStart: (requestId) => pluginInstallOwners.set(requestId, ws),
|
|
2982
|
+
onInstallEnd: (requestId) => pluginInstallOwners.delete(requestId),
|
|
2983
|
+
}).then((frame) => {
|
|
2949
2984
|
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2950
2985
|
})
|
|
2951
2986
|
} else {
|
|
@@ -2991,6 +3026,7 @@ const plugin = {
|
|
|
2991
3026
|
'tasks',
|
|
2992
3027
|
'goals',
|
|
2993
3028
|
'schedule-management',
|
|
3029
|
+
'plugin-management-v1',
|
|
2994
3030
|
'session-cancel',
|
|
2995
3031
|
'queue-control',
|
|
2996
3032
|
'session-archive',
|
|
@@ -3111,6 +3147,26 @@ const plugin = {
|
|
|
3111
3147
|
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'schedule-changed' })
|
|
3112
3148
|
})
|
|
3113
3149
|
|
|
3150
|
+
const disposePluginsChanged = ctx.on('plugin-manager/changed', (change) => {
|
|
3151
|
+
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'plugins-changed', reason: change?.reason })
|
|
3152
|
+
})
|
|
3153
|
+
const disposePluginSettingsChanged = ctx.on('settings/document-updated', (ns, revision) => {
|
|
3154
|
+
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'plugin-settings-changed', ns, revision })
|
|
3155
|
+
})
|
|
3156
|
+
const sendPluginInstallEvent = (kind, event) => {
|
|
3157
|
+
const owner = pluginInstallOwners.get(event?.requestId)
|
|
3158
|
+
if (owner?.readyState === 1) owner.send(stringifyWireFrame({ kind, ...event }))
|
|
3159
|
+
}
|
|
3160
|
+
const disposePluginInstallState = ctx.on('plugin-manager/install-state', (progress) => {
|
|
3161
|
+
sendPluginInstallEvent('plugin-install-state', progress)
|
|
3162
|
+
})
|
|
3163
|
+
const disposePluginInstallLog = ctx.on('plugin-manager/install-log', (chunk) => {
|
|
3164
|
+
if (!chunk?.requestId) return
|
|
3165
|
+
const { requestId, jobId, stream, text, exitCode } = chunk
|
|
3166
|
+
sendPluginInstallEvent('plugin-install-log', { requestId, jobId, stream, text,
|
|
3167
|
+
...(exitCode === undefined ? {} : { exitCode }) })
|
|
3168
|
+
})
|
|
3169
|
+
|
|
3114
3170
|
const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
|
|
3115
3171
|
const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
|
|
3116
3172
|
if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
|
|
@@ -3349,6 +3405,10 @@ const plugin = {
|
|
|
3349
3405
|
disposeMgmt()
|
|
3350
3406
|
disposeEvents()
|
|
3351
3407
|
disposeScheduleChanged()
|
|
3408
|
+
disposePluginsChanged()
|
|
3409
|
+
disposePluginSettingsChanged()
|
|
3410
|
+
disposePluginInstallState()
|
|
3411
|
+
disposePluginInstallLog()
|
|
3352
3412
|
disposeQuestions()
|
|
3353
3413
|
disposeApprovals()
|
|
3354
3414
|
if (lanServer) lanServer.close()
|
|
@@ -3358,6 +3418,7 @@ const plugin = {
|
|
|
3358
3418
|
client.terminate()
|
|
3359
3419
|
}
|
|
3360
3420
|
clients.clear()
|
|
3421
|
+
pluginInstallOwners.clear()
|
|
3361
3422
|
wss.close()
|
|
3362
3423
|
log('plugin stopped, all sockets closed')
|
|
3363
3424
|
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
const OFFICIAL_CONFIGURATION_ITEMS = Object.freeze([
|
|
2
|
+
{ id: 'shell', namespaces: ['bash-sandbox', 'pwsh-sandbox'] },
|
|
3
|
+
{ id: 'agent-loop', namespaces: ['agent-loop'] },
|
|
4
|
+
{ id: 'subagent', namespaces: ['subagent', 'subagent-model-selection-settings'] },
|
|
5
|
+
{ id: 'web-search', namespaces: ['web-search-deepseek'] },
|
|
6
|
+
])
|
|
7
|
+
|
|
8
|
+
export const PLUGIN_REQUEST_TYPES = new Set([
|
|
9
|
+
'plugin-catalog',
|
|
10
|
+
'plugin-registries',
|
|
11
|
+
'plugin-inspect',
|
|
12
|
+
'plugin-install',
|
|
13
|
+
'plugin-install-status',
|
|
14
|
+
'plugin-install-cancel',
|
|
15
|
+
'plugin-bundle-set-enabled',
|
|
16
|
+
'plugin-entry-set-enabled',
|
|
17
|
+
'plugin-remove',
|
|
18
|
+
'plugin-settings',
|
|
19
|
+
'plugin-settings-mutate',
|
|
20
|
+
'plugin-version-exemptions',
|
|
21
|
+
'plugin-version-exemption-set',
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
const FLAT_RESPONSE_TYPES = new Set([
|
|
25
|
+
'plugin-catalog', 'plugin-registries', 'plugin-settings',
|
|
26
|
+
'plugin-settings-mutate', 'plugin-version-exemptions',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
function record(value) {
|
|
30
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function nonempty(value, maxLength = 2048) {
|
|
34
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function badRequest(type, requestId, message) {
|
|
38
|
+
return { kind: 'error', code: 'bad-request', message, requestType: type,
|
|
39
|
+
...(requestId === undefined ? {} : { requestId }) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function registryOption(msg, type, requestId) {
|
|
43
|
+
if (msg.registry === undefined) return { value: undefined }
|
|
44
|
+
if (msg.registry === null || (nonempty(msg.registry) && /^https?:\/\//i.test(msg.registry))) {
|
|
45
|
+
return { value: msg.registry }
|
|
46
|
+
}
|
|
47
|
+
return { error: badRequest(type, requestId, 'registry must be null or an HTTP(S) URL') }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireName(msg, field, type, requestId) {
|
|
51
|
+
if (!nonempty(msg[field], 512)) return { error: badRequest(type, requestId, `${field} must be a non-empty string`) }
|
|
52
|
+
return { value: msg[field].trim() }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function pluginCatalog(api) {
|
|
56
|
+
const inventory = await api.plugins.inventory()
|
|
57
|
+
if (!record(inventory) || !Array.isArray(inventory.entries)) throw new Error('pluginInventory/list returned an invalid inventory')
|
|
58
|
+
if (inventory.managementAvailable !== true) {
|
|
59
|
+
return { managementAvailable: false, bundles: [], plugins: [], officialItems: [], settingsWritable: false }
|
|
60
|
+
}
|
|
61
|
+
const [bundles, plugins, settings] = await Promise.all([
|
|
62
|
+
api.plugins.listBundles(),
|
|
63
|
+
api.plugins.listPlugins(),
|
|
64
|
+
api.settings.describe(),
|
|
65
|
+
])
|
|
66
|
+
if (!Array.isArray(bundles) || !Array.isArray(plugins) || !Array.isArray(settings?.namespaces)) {
|
|
67
|
+
throw new Error('plugin manager returned an invalid catalog')
|
|
68
|
+
}
|
|
69
|
+
const available = new Set(settings.namespaces.map(item => item.ns))
|
|
70
|
+
const officialItems = OFFICIAL_CONFIGURATION_ITEMS
|
|
71
|
+
.filter(item => item.namespaces.some(ns => available.has(ns)))
|
|
72
|
+
.map(item => ({ id: item.id, namespaces: item.namespaces.filter(ns => available.has(ns)) }))
|
|
73
|
+
return {
|
|
74
|
+
managementAvailable: true,
|
|
75
|
+
bundles: bundles.map(bundle => ({
|
|
76
|
+
...bundle,
|
|
77
|
+
experimental: bundle.name.startsWith('@deepseek-ai/dsh-experimental-'),
|
|
78
|
+
})),
|
|
79
|
+
plugins,
|
|
80
|
+
officialItems,
|
|
81
|
+
settingsWritable: settings.writable === true,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The mobile wire owns its request names and correlation ids. The Host adapter
|
|
87
|
+
* owns Typert endpoint names and argument descriptors.
|
|
88
|
+
*/
|
|
89
|
+
export async function handlePluginQuery(api, msg, { onInstallStart, onInstallEnd } = {}) {
|
|
90
|
+
const type = msg?.type
|
|
91
|
+
if (!PLUGIN_REQUEST_TYPES.has(type)) return null
|
|
92
|
+
const requestId = nonempty(msg.requestId, 128) ? msg.requestId.trim() : undefined
|
|
93
|
+
if (!requestId) return badRequest(type, undefined, `${type} requires a requestId`)
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
let value
|
|
97
|
+
let kind = type
|
|
98
|
+
if (type === 'plugin-catalog') {
|
|
99
|
+
value = await pluginCatalog(api)
|
|
100
|
+
} else if (type === 'plugin-registries') {
|
|
101
|
+
value = await api.plugins.registries()
|
|
102
|
+
} else if (type === 'plugin-inspect' || type === 'plugin-install') {
|
|
103
|
+
const spec = requireName(msg, 'spec', type, requestId)
|
|
104
|
+
if (spec.error) return spec.error
|
|
105
|
+
const registry = registryOption(msg, type, requestId)
|
|
106
|
+
if (registry.error) return registry.error
|
|
107
|
+
if (type === 'plugin-inspect') {
|
|
108
|
+
value = await api.plugins.inspect(spec.value, registry.value === undefined ? undefined : { registry: registry.value })
|
|
109
|
+
kind = 'plugin-inspection'
|
|
110
|
+
} else {
|
|
111
|
+
if (msg.enabled !== undefined && typeof msg.enabled !== 'boolean') {
|
|
112
|
+
return badRequest(type, requestId, 'enabled must be a boolean')
|
|
113
|
+
}
|
|
114
|
+
if (msg.approvedBuilds !== undefined && (!Array.isArray(msg.approvedBuilds)
|
|
115
|
+
|| msg.approvedBuilds.length > 100 || msg.approvedBuilds.some(name => !nonempty(name, 512)))) {
|
|
116
|
+
return badRequest(type, requestId, 'approvedBuilds must be an array of package names')
|
|
117
|
+
}
|
|
118
|
+
const options = {
|
|
119
|
+
requestId,
|
|
120
|
+
...(msg.enabled === undefined ? {} : { enabled: msg.enabled }),
|
|
121
|
+
...(registry.value === undefined ? {} : { registry: registry.value }),
|
|
122
|
+
...(msg.approvedBuilds === undefined ? {} : { approvedBuilds: msg.approvedBuilds }),
|
|
123
|
+
}
|
|
124
|
+
onInstallStart?.(requestId)
|
|
125
|
+
try {
|
|
126
|
+
value = await api.plugins.installBundle(spec.value, options)
|
|
127
|
+
} finally {
|
|
128
|
+
onInstallEnd?.(requestId)
|
|
129
|
+
}
|
|
130
|
+
kind = 'plugin-install-result'
|
|
131
|
+
}
|
|
132
|
+
} else if (type === 'plugin-install-status' || type === 'plugin-install-cancel') {
|
|
133
|
+
const installId = requireName(msg, 'installId', type, requestId)
|
|
134
|
+
if (installId.error) return installId.error
|
|
135
|
+
value = type === 'plugin-install-status'
|
|
136
|
+
? await api.plugins.waitForInstall(installId.value)
|
|
137
|
+
: await api.plugins.cancelInstall(installId.value)
|
|
138
|
+
} else if (type === 'plugin-bundle-set-enabled' || type === 'plugin-entry-set-enabled') {
|
|
139
|
+
const field = type === 'plugin-bundle-set-enabled' ? 'name' : 'entryId'
|
|
140
|
+
const target = requireName(msg, field, type, requestId)
|
|
141
|
+
if (target.error) return target.error
|
|
142
|
+
if (typeof msg.enabled !== 'boolean') return badRequest(type, requestId, 'enabled must be a boolean')
|
|
143
|
+
value = type === 'plugin-bundle-set-enabled'
|
|
144
|
+
? await api.plugins.setBundleEnabled(target.value, msg.enabled)
|
|
145
|
+
: await api.plugins.setPluginEnabled(target.value, msg.enabled)
|
|
146
|
+
} else if (type === 'plugin-remove') {
|
|
147
|
+
const name = requireName(msg, 'name', type, requestId)
|
|
148
|
+
if (name.error) return name.error
|
|
149
|
+
value = await api.plugins.removeBundle(name.value)
|
|
150
|
+
} else if (type === 'plugin-settings') {
|
|
151
|
+
const described = await api.settings.describe()
|
|
152
|
+
if (!record(described) || !Array.isArray(described.namespaces)) throw new Error('settings/describe returned an invalid response')
|
|
153
|
+
if (msg.ns !== undefined) {
|
|
154
|
+
const ns = requireName(msg, 'ns', type, requestId)
|
|
155
|
+
if (ns.error) return ns.error
|
|
156
|
+
const namespace = described.namespaces.find(item => item.ns === ns.value)
|
|
157
|
+
if (!namespace) return { kind: 'error', code: 'plugin-settings/not-found',
|
|
158
|
+
message: `settings namespace is unavailable: ${ns.value}`, requestType: type, requestId }
|
|
159
|
+
value = { writable: described.writable === true, namespace }
|
|
160
|
+
} else {
|
|
161
|
+
value = described
|
|
162
|
+
}
|
|
163
|
+
} else if (type === 'plugin-settings-mutate') {
|
|
164
|
+
const ns = requireName(msg, 'ns', type, requestId)
|
|
165
|
+
if (ns.error) return ns.error
|
|
166
|
+
if (!Number.isSafeInteger(msg.expectedRevision) || msg.expectedRevision < 0) {
|
|
167
|
+
return badRequest(type, requestId, 'expectedRevision must be a non-negative integer')
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(msg.ops) || msg.ops.length < 1 || msg.ops.length > 100
|
|
170
|
+
|| msg.ops.some(op => !record(op) || !['set', 'unset'].includes(op.op)
|
|
171
|
+
|| !Array.isArray(op.path) || op.path.some(part => !nonempty(part, 256))
|
|
172
|
+
|| (op.op === 'set' && !Object.hasOwn(op, 'value')))) {
|
|
173
|
+
return badRequest(type, requestId, 'ops must contain valid set or unset path edits')
|
|
174
|
+
}
|
|
175
|
+
value = await api.settings.mutate({ ns: ns.value, ops: msg.ops, expectedRevision: msg.expectedRevision })
|
|
176
|
+
} else if (type === 'plugin-version-exemptions') {
|
|
177
|
+
value = await api.plugins.listVersionExemptions()
|
|
178
|
+
} else if (type === 'plugin-version-exemption-set') {
|
|
179
|
+
const packageVersion = requireName(msg, 'packageVersion', type, requestId)
|
|
180
|
+
const runtimeVersion = requireName(msg, 'runtimeVersion', type, requestId)
|
|
181
|
+
if (packageVersion.error || runtimeVersion.error) return packageVersion.error || runtimeVersion.error
|
|
182
|
+
if (typeof msg.enabled !== 'boolean' || (msg.acceptRisk !== undefined && typeof msg.acceptRisk !== 'boolean')) {
|
|
183
|
+
return badRequest(type, requestId, 'enabled and acceptRisk must be booleans')
|
|
184
|
+
}
|
|
185
|
+
value = await api.plugins.setVersionExemption(packageVersion.value, runtimeVersion.value,
|
|
186
|
+
msg.enabled, msg.acceptRisk)
|
|
187
|
+
}
|
|
188
|
+
return { kind, requestId, ...(FLAT_RESPONSE_TYPES.has(type) && record(value)
|
|
189
|
+
? value : { result: value }) }
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return { kind: 'error', code: error?.code || 'internal',
|
|
192
|
+
message: error?.message || String(error), requestType: type, requestId }
|
|
193
|
+
}
|
|
194
|
+
}
|