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.
@@ -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
- return devices
103
- .filter((device) => !device.revokedAt)
104
- .map((device) => publicDevice(device, online.get(device.id) || 0))
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 = pairing.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: pairing.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
 
@@ -13,7 +13,7 @@ function requireArray(value, endpoint) {
13
13
  return value
14
14
  }
15
15
 
16
- export const DSH_VERSION = '0.1.7-rc.2'
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 rejectUpgrade(socket, status, message) {
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\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`)
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(name)
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
- handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
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
+ }