dsh-remote-plugin 0.6.21 → 0.6.23

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.
Binary file
package/gateway.cjs CHANGED
@@ -183,6 +183,8 @@ const CAPABILITIES = Object.freeze({
183
183
  dshLifecycle: DSH_CONTROL_SUPPORT.supported ? 2 : 0,
184
184
  centralAnnouncements: 2,
185
185
  feedback: 1,
186
+ compatibilityAdapter: 2,
187
+ diagnostics: 1,
186
188
  deviceKeys: 1,
187
189
  healthProbes: 1,
188
190
  resumableUploads: 2,
@@ -1340,6 +1342,58 @@ const eventCollectors = { mux: null, host: null }
1340
1342
  // both serve the same zero-build clients.
1341
1343
  let upstreamApiFlavor = 'unknown'
1342
1344
  let upstreamApiFlavorProbe = null
1345
+ let upstreamApiFlavorCheckedAt = 0
1346
+ let upstreamApiFlavorChangedAt = 0
1347
+ let compatibleCollectorFlavor = ''
1348
+ let compatibleCollectorRestartScheduled = false
1349
+ const UPSTREAM_API_FLAVOR_RECHECK_MS = durationEnv('DSH_REMOTE_UPSTREAM_API_RECHECK_MS', 15_000, 3_000, 10 * 60_000)
1350
+ const COMPATIBILITY_LOG_MAX = durationEnv('DSH_REMOTE_COMPATIBILITY_LOG_MAX', 80, 10, 500)
1351
+ const compatibilityLog = []
1352
+
1353
+ // Compatibility diagnostics deliberately record protocol facts only. RPC payloads,
1354
+ // token values, cookies, host paths, and DSH conversation content must never leave
1355
+ // the local machine as part of a support report.
1356
+ function redactDiagnosticText(value, limit = 240) {
1357
+ return String(value || '')
1358
+ .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [redacted]')
1359
+ .replace(/\b(token|access[_-]?token|cookie|authorization)(\s*[=:]\s*)[^\s,;]+/gi, '$1$2[redacted]')
1360
+ .replace(/https?:\/\/[^\s,;]+/gi, '<url>')
1361
+ .replace(/(?:[A-Za-z]:)?[/\\](?:[^\s/\\]+[/\\])+[^\s/\\]*/g, '<path>')
1362
+ .replace(/[\r\n\t]+/g, ' ')
1363
+ .slice(0, limit)
1364
+ }
1365
+
1366
+ function recordCompatibility(kind, fields = {}) {
1367
+ const item = {
1368
+ at: new Date().toISOString(),
1369
+ kind: String(kind || 'unknown').replace(/[^a-z0-9._/-]/gi, '').slice(0, 48) || 'unknown',
1370
+ ...fields,
1371
+ }
1372
+ if (item.method !== undefined) item.method = String(item.method).replace(/[^a-z0-9._/-]/gi, '').slice(0, 120)
1373
+ if (item.detail !== undefined) item.detail = redactDiagnosticText(item.detail)
1374
+ if (item.status !== undefined) item.status = Number(item.status) || 0
1375
+ compatibilityLog.push(item)
1376
+ while (compatibilityLog.length > COMPATIBILITY_LOG_MAX) compatibilityLog.shift()
1377
+ }
1378
+
1379
+ function diagnosticErrorCategory(value) {
1380
+ const text = String(value || '').toLowerCase()
1381
+ if (!text) return ''
1382
+ if (/timeout|timed out|abort/.test(text)) return 'timeout'
1383
+ if (/401|403|auth|cookie/.test(text)) return 'authentication'
1384
+ if (/404|405|501|not found|unavailable/.test(text)) return 'method-unavailable'
1385
+ if (/websocket|socket|econn|network/.test(text)) return 'transport'
1386
+ return 'upstream-error'
1387
+ }
1388
+
1389
+ function scheduleCompatibleCollectorRestart() {
1390
+ if (compatibleCollectorRestartScheduled) return
1391
+ compatibleCollectorRestartScheduled = true
1392
+ setImmediate(() => {
1393
+ compatibleCollectorRestartScheduled = false
1394
+ void startCompatibleEventCollectors()
1395
+ })
1396
+ }
1343
1397
  const modernState = {
1344
1398
  home: '',
1345
1399
  eventClientId: '',
@@ -1362,21 +1416,34 @@ async function callUpstreamRemote(endpoint, args, rpcId = crypto.randomUUID()) {
1362
1416
  }
1363
1417
 
1364
1418
  async function detectUpstreamApiFlavor(force = false) {
1365
- if (!force && upstreamApiFlavor !== 'unknown') return upstreamApiFlavor
1419
+ const fresh = Date.now() - upstreamApiFlavorCheckedAt < UPSTREAM_API_FLAVOR_RECHECK_MS
1420
+ if (!force && upstreamApiFlavor !== 'unknown' && fresh) return upstreamApiFlavor
1366
1421
  if (!force && upstreamApiFlavorProbe) return upstreamApiFlavorProbe
1367
1422
  upstreamApiFlavorProbe = (async () => {
1423
+ const previous = upstreamApiFlavor
1368
1424
  try {
1369
1425
  const probe = await callUpstreamRemote('session/list', { _request: {} })
1370
- upstreamApiFlavor = probe.status === 200
1426
+ const detected = probe.status === 200
1371
1427
  && probe.body?.result?.ok === true
1372
1428
  && Array.isArray(probe.body?.result?.value?.items)
1373
1429
  ? 'modern'
1374
- : 'legacy'
1430
+ : ((probe.status === 404 || probe.status === 405 || (probe.status >= 200 && probe.status < 300)) ? 'legacy' : previous)
1431
+ // A transient DSH restart or an authentication problem must not turn a known
1432
+ // modern server into "legacy" and strand its live event stream.
1433
+ if (detected !== 'unknown') upstreamApiFlavor = detected
1434
+ upstreamApiFlavorCheckedAt = Date.now()
1375
1435
  if (upstreamApiFlavor === 'modern' && probe.body?.result?.ok) {
1376
1436
  updateModernSessions(probe.body.result.value?.items)
1377
1437
  }
1378
- } catch {
1379
- upstreamApiFlavor = 'legacy'
1438
+ if (previous !== upstreamApiFlavor) {
1439
+ upstreamApiFlavorChangedAt = upstreamApiFlavorCheckedAt
1440
+ recordCompatibility('protocol-switch', { from: previous, to: upstreamApiFlavor, status: probe.status })
1441
+ scheduleCompatibleCollectorRestart()
1442
+ }
1443
+ } catch (error) {
1444
+ upstreamApiFlavorCheckedAt = Date.now()
1445
+ if (upstreamApiFlavor === 'unknown') upstreamApiFlavor = 'legacy'
1446
+ recordCompatibility('protocol-probe-failed', { detail: error?.message || error })
1380
1447
  } finally {
1381
1448
  upstreamApiFlavorProbe = null
1382
1449
  }
@@ -1404,6 +1471,23 @@ function modernError(message, code = 'upstream-incompatible', details = {}) {
1404
1471
  return { ok: false, error: { code, message, details } }
1405
1472
  }
1406
1473
 
1474
+ function modernResponseNeedsLegacyFallback(response) {
1475
+ if (!response) return false
1476
+ if ([404, 405, 501].includes(Number(response.status))) return true
1477
+ const code = String(response.body?.result?.error?.code || response.body?.error?.code || response.body?.error || '').toLowerCase()
1478
+ return ['method-unavailable', 'not-found', 'unsupported-method', 'unsupported_endpoint'].includes(code)
1479
+ }
1480
+
1481
+ function remoteFailureKind(response) {
1482
+ const raw = String(response?.body?.result?.error?.code || response?.body?.error?.code || response?.body?.error || '')
1483
+ const code = raw.replace(/[^a-z0-9._/-]/gi, '').slice(0, 80)
1484
+ return code || `http-${Number(response?.status) || 0}`
1485
+ }
1486
+
1487
+ function legacyFallback(reason) {
1488
+ return { __dshRemoteLegacyFallback: true, reason: redactDiagnosticText(reason, 120) }
1489
+ }
1490
+
1407
1491
  function legacyHistoryValue(value, summary) {
1408
1492
  const records = Array.isArray(value?.records) ? value.records : []
1409
1493
  return {
@@ -1432,6 +1516,7 @@ async function translateModernRpc(method, payload, rpcId) {
1432
1516
  }
1433
1517
  if (method === 'session.list') {
1434
1518
  const response = await refreshModernSessions()
1519
+ if (modernResponseNeedsLegacyFallback(response)) return legacyFallback('generated RPC session/list unavailable')
1435
1520
  return response.body || legacyEnvelope(rpcId, modernError('DSH session/list returned no JSON response'))
1436
1521
  }
1437
1522
  if (method === 'session.history') {
@@ -1468,7 +1553,7 @@ async function translateModernRpc(method, payload, rpcId) {
1468
1553
  }
1469
1554
  } else if (method.startsWith('session.')) {
1470
1555
  const verb = method.slice('session.'.length)
1471
- if (!['search', 'create', 'selectModel', 'rename', 'fork', 'prompt', 'attachment', 'updateQueue', 'cancel'].includes(verb)) return null
1556
+ if (!['search', 'create', 'selectModel', 'rename', 'fork', 'prompt', 'attachment', 'updateQueue', 'cancel'].includes(verb)) return legacyFallback('no generated RPC adapter')
1472
1557
  endpoint = 'session/' + verb
1473
1558
  const request = verb === 'prompt' && !payload.requestId
1474
1559
  ? { ...payload, requestId: crypto.randomUUID() }
@@ -1476,12 +1561,12 @@ async function translateModernRpc(method, payload, rpcId) {
1476
1561
  args = verb === 'search' ? { request } : { request }
1477
1562
  } else if (method.startsWith('workspace.')) {
1478
1563
  const verb = method.slice('workspace.'.length)
1479
- if (!['create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore', 'archiveSession'].includes(verb)) return null
1564
+ if (!['create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore', 'archiveSession'].includes(verb)) return legacyFallback('no generated RPC adapter')
1480
1565
  endpoint = 'workspace/' + verb
1481
1566
  args = { request: payload }
1482
1567
  } else if (method.startsWith('goal.')) {
1483
1568
  const verb = method.slice('goal.'.length)
1484
- if (!['create', 'edit', 'pause', 'resume', 'complete', 'clear'].includes(verb)) return null
1569
+ if (!['create', 'edit', 'pause', 'resume', 'complete', 'clear'].includes(verb)) return legacyFallback('no generated RPC adapter')
1485
1570
  endpoint = 'goals/' + verb
1486
1571
  args = verb === 'create'
1487
1572
  ? { agentId: payload.sessionId, request: { objective: payload.objective, ...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }) } }
@@ -1508,7 +1593,7 @@ async function translateModernRpc(method, payload, rpcId) {
1508
1593
  args = {}
1509
1594
  } else if (method.startsWith('credentials.')) {
1510
1595
  const verb = method.slice('credentials.'.length)
1511
- if (!['describe', 'set', 'unset'].includes(verb)) return null
1596
+ if (!['describe', 'set', 'unset'].includes(verb)) return legacyFallback('no generated RPC adapter')
1512
1597
  endpoint = 'credentials/' + verb
1513
1598
  args = verb === 'describe' ? { refs: payload.refs } : verb === 'set' ? { ref: payload.ref, value: payload.value } : { ref: payload.ref }
1514
1599
  if (verb !== 'describe') transform = () => ({})
@@ -1531,11 +1616,15 @@ async function translateModernRpc(method, payload, rpcId) {
1531
1616
  }
1532
1617
  transform = models => ({ models: Array.isArray(models) ? models : [] })
1533
1618
  } else {
1534
- return null
1619
+ return legacyFallback('no generated RPC adapter')
1535
1620
  }
1536
1621
 
1537
1622
  const response = await callUpstreamRemote(endpoint, args, rpcId)
1538
- if (!response.body?.result?.ok) return response.body || legacyEnvelope(rpcId, modernError(`DSH ${endpoint} returned no JSON response`))
1623
+ if (modernResponseNeedsLegacyFallback(response)) return legacyFallback(`generated RPC ${endpoint} unavailable`)
1624
+ if (!response.body?.result?.ok) {
1625
+ recordCompatibility('generated-rpc-error', { method, status: response.status, detail: remoteFailureKind(response) })
1626
+ return response.body || legacyEnvelope(rpcId, modernError(`DSH ${endpoint} returned no JSON response`))
1627
+ }
1539
1628
  return legacyEnvelope(rpcId, { ok: true, value: transform(response.body.result.value) })
1540
1629
  }
1541
1630
 
@@ -1989,6 +2078,12 @@ function applyModernRemoteEvent(ws, value) {
1989
2078
  legacyPush('host', { type: 'host/session-removed', sessionId: args[0] })
1990
2079
  } else if (value.event === 'api-session/status') {
1991
2080
  legacyPush('host', { type: 'host/session-status', sessionId: args[0], running: !!args[1] })
2081
+ } else if (value.event === 'api-session/activity') {
2082
+ const sessionId = String(args[0] || '')
2083
+ const updatedAt = Number(args[1]) || Date.now()
2084
+ const summary = modernState.sessions.get(sessionId)
2085
+ if (summary) modernState.sessions.set(sessionId, { ...summary, updatedAt })
2086
+ legacyPush('host', { type: 'host/session-activity', sessionId, updatedAt })
1992
2087
  } else if (value.event === 'api-session/error') {
1993
2088
  legacyPush('host', { type: 'host/agent-error', sessionId: args[0], message: String(args[1] || '') })
1994
2089
  } else {
@@ -2106,7 +2201,16 @@ function startModernEventCollector() {
2106
2201
  }
2107
2202
 
2108
2203
  async function startCompatibleEventCollectors() {
2109
- if (await detectUpstreamApiFlavor() === 'modern') {
2204
+ const flavor = await detectUpstreamApiFlavor()
2205
+ if (compatibleCollectorFlavor === flavor && eventCollectors.mux) return
2206
+ for (const collector of new Set(Object.values(eventCollectors).filter(Boolean))) {
2207
+ try { collector.close?.() } catch {}
2208
+ }
2209
+ eventCollectors.mux = null
2210
+ eventCollectors.host = null
2211
+ compatibleCollectorFlavor = flavor
2212
+ recordCompatibility('collector-mode', { detail: flavor })
2213
+ if (flavor === 'modern') {
2110
2214
  const collector = startModernEventCollector()
2111
2215
  eventCollectors.mux = collector
2112
2216
  eventCollectors.host = collector
@@ -2355,6 +2459,52 @@ async function validatePollVote(payload) {
2355
2459
  return result
2356
2460
  }
2357
2461
 
2462
+ function compatibilityDiagnostics() {
2463
+ const events = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
2464
+ connected: state.connected === true,
2465
+ reconnects: Number(state.reconnects) || 0,
2466
+ lastError: diagnosticErrorCategory(state.lastError),
2467
+ lastCloseCode: Number(state.lastCloseCode) || 0,
2468
+ }]))
2469
+ return {
2470
+ schema: 1,
2471
+ capturedAt: new Date().toISOString(),
2472
+ gateway: { version: VERSION, protocol: PROTOCOL_VERSION, platform: process.platform, node: process.versions.node },
2473
+ upstream: {
2474
+ apiFlavor: upstreamApiFlavor,
2475
+ checkedAt: upstreamApiFlavorCheckedAt ? new Date(upstreamApiFlavorCheckedAt).toISOString() : '',
2476
+ changedAt: upstreamApiFlavorChangedAt ? new Date(upstreamApiFlavorChangedAt).toISOString() : '',
2477
+ collectorFlavor: compatibleCollectorFlavor,
2478
+ },
2479
+ events,
2480
+ recent: compatibilityLog.slice(-20),
2481
+ }
2482
+ }
2483
+
2484
+ function serveDiagnostics(req, res, url) {
2485
+ cors(res)
2486
+ if (req.method === 'OPTIONS') {
2487
+ res.writeHead(204)
2488
+ res.end()
2489
+ return
2490
+ }
2491
+ if (req.method !== 'GET') {
2492
+ res.writeHead(405, { allow: 'GET' })
2493
+ res.end()
2494
+ return
2495
+ }
2496
+ if (!authorized(req, url)) {
2497
+ authFailures++
2498
+ touchDevice(req, { failedAuth: true })
2499
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
2500
+ res.end(JSON.stringify({ error: 'unauthorized' }))
2501
+ return
2502
+ }
2503
+ touchDevice(req)
2504
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
2505
+ res.end(JSON.stringify(compatibilityDiagnostics()))
2506
+ }
2507
+
2358
2508
  function serveFeedback(req, res, url) {
2359
2509
  cors(res)
2360
2510
  if (req.method === 'OPTIONS') {
@@ -2391,6 +2541,7 @@ function serveFeedback(req, res, url) {
2391
2541
  let message = String(payload.message || '').trim()
2392
2542
  const contact = String(payload.contact || '').trim()
2393
2543
  const appVersion = String(payload.appVersion || '').trim()
2544
+ const includeDiagnostics = payload.includeDiagnostics === true
2394
2545
  if (!['bug', 'suggestion', 'other', 'poll'].includes(type)) {
2395
2546
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
2396
2547
  res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other|poll' }))
@@ -2442,6 +2593,7 @@ function serveFeedback(req, res, url) {
2442
2593
  appVersion: appVersion || 'unknown',
2443
2594
  gatewayVersion: VERSION,
2444
2595
  clientIp: maskIp(ip),
2596
+ ...(includeDiagnostics ? { diagnostics: compatibilityDiagnostics() } : {}),
2445
2597
  ...(pollVote || {})
2446
2598
  }),
2447
2599
  signal: AbortSignal.timeout(8000)
@@ -2834,6 +2986,19 @@ async function loadFsWorkspaceRoots(force = false) {
2834
2986
  let value
2835
2987
  if (await detectUpstreamApiFlavor() === 'modern') {
2836
2988
  value = modernState.workspaces
2989
+ // The slash protocol receives workspace state over a stream. Immediately
2990
+ // after a protocol switch that baseline may not have arrived yet; retain
2991
+ // a working dotted workspace.list implementation when this particular
2992
+ // DSH release still exposes it instead of denying an otherwise valid root.
2993
+ if (!Array.isArray(value?.items) || !value.items.length) {
2994
+ try {
2995
+ const legacy = await forwardLegacyRpc(new URL('/api/workspace.list', UPSTREAM), JSON.stringify({
2996
+ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace.list', payload: {},
2997
+ }))
2998
+ const body = JSON.parse(legacy.raw || '{}')
2999
+ if (legacy.status === 200 && body?.result?.ok) value = body.result.value
3000
+ } catch {}
3001
+ }
2837
3002
  } else {
2838
3003
  const target = new URL('/api/workspace.list', UPSTREAM)
2839
3004
  const res = await fetch(target, {
@@ -3769,21 +3934,94 @@ function serveWorkbench(req, res, url) {
3769
3934
  }
3770
3935
 
3771
3936
  // ---------- /api 代理 ----------
3937
+ const ADAPTIVE_RPC_METHODS = new Set([
3938
+ 'host.describe', 'session.list', 'session.history', 'session.models', 'session.search',
3939
+ 'session.create', 'session.selectModel', 'session.rename', 'session.fork', 'session.prompt',
3940
+ 'session.attachment', 'session.updateQueue', 'session.cancel', 'workspace.list',
3941
+ 'workspace.create', 'workspace.rename', 'workspace.delete', 'workspace.insertBefore',
3942
+ 'workspace.insertSessionBefore', 'workspace.archiveSession', 'goal.create', 'goal.edit',
3943
+ 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear', 'subagent.list',
3944
+ 'subagent.interrupt', 'settings.describe', 'settings.mutate', 'settings.openDocument',
3945
+ 'credentials.describe', 'credentials.set', 'credentials.unset', 'llm.providers', 'llm.discoverModels',
3946
+ ])
3947
+
3948
+ function readApiRequest(req, maxBytes = 4 * 1024 * 1024) {
3949
+ return new Promise((resolve, reject) => {
3950
+ let raw = ''
3951
+ req.setEncoding('utf8')
3952
+ req.on('data', chunk => {
3953
+ raw += chunk
3954
+ if (Buffer.byteLength(raw) > maxBytes) reject(new Error('request body too large'))
3955
+ })
3956
+ req.once('end', () => resolve(raw))
3957
+ req.once('error', reject)
3958
+ req.once('aborted', () => reject(new Error('request aborted')))
3959
+ })
3960
+ }
3961
+
3962
+ async function forwardLegacyRpc(url, raw) {
3963
+ const target = new URL(url.pathname + url.search, UPSTREAM)
3964
+ const response = await fetch(target, {
3965
+ method: 'POST',
3966
+ headers: dshUpstreamHeaders({ 'content-type': 'application/json' }),
3967
+ body: raw,
3968
+ signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS),
3969
+ })
3970
+ return { status: response.status, headers: response.headers, raw: await response.text() }
3971
+ }
3972
+
3973
+ function sendBufferedUpstreamResponse(res, response) {
3974
+ cors(res)
3975
+ res.writeHead(response.status || 502, {
3976
+ 'content-type': response.headers?.get?.('content-type') || 'application/json; charset=utf-8',
3977
+ 'cache-control': 'no-store',
3978
+ })
3979
+ res.end(response.raw || '')
3980
+ }
3981
+
3982
+ async function proxyLegacyRpcWithModernFallback(req, res, url) {
3983
+ const raw = await readApiRequest(req)
3984
+ const legacy = await forwardLegacyRpc(url, raw)
3985
+ if (![404, 405, 501].includes(legacy.status)) {
3986
+ if (legacy.status >= 400) recordCompatibility('legacy-rpc-error', { method: url.pathname.slice('/api/'.length), status: legacy.status })
3987
+ sendBufferedUpstreamResponse(res, legacy)
3988
+ return
3989
+ }
3990
+ let body
3991
+ try { body = JSON.parse(raw || '{}') } catch { body = null }
3992
+ if (body?.type !== 'client-request' || typeof body.rpcId !== 'string' || !ADAPTIVE_RPC_METHODS.has(body.method)) {
3993
+ sendBufferedUpstreamResponse(res, legacy)
3994
+ return
3995
+ }
3996
+ recordCompatibility('legacy-404-fallback', { method: body.method, status: legacy.status })
3997
+ const translated = await translateModernRpc(body.method, body.payload || {}, body.rpcId)
3998
+ if (!translated || translated.__dshRemoteLegacyFallback) {
3999
+ sendBufferedUpstreamResponse(res, legacy)
4000
+ return
4001
+ }
4002
+ // A generated RPC completed, so subsequent requests and live collectors can use
4003
+ // the modern contract immediately instead of waiting for the periodic probe.
4004
+ if (body.method !== 'host.describe') {
4005
+ const previous = upstreamApiFlavor
4006
+ upstreamApiFlavor = 'modern'
4007
+ upstreamApiFlavorCheckedAt = Date.now()
4008
+ if (previous !== 'modern') {
4009
+ upstreamApiFlavorChangedAt = upstreamApiFlavorCheckedAt
4010
+ recordCompatibility('protocol-switch', { from: previous, to: 'modern', detail: 'legacy dotted RPC returned 404' })
4011
+ scheduleCompatibleCollectorRestart()
4012
+ }
4013
+ }
4014
+ cors(res)
4015
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
4016
+ res.end(JSON.stringify(translated))
4017
+ }
4018
+
3772
4019
  async function proxyModernApi(req, res, url) {
3773
4020
  if (req.method !== 'POST' || url.pathname.startsWith('/remote/')) return false
3774
4021
  if (await detectUpstreamApiFlavor() !== 'modern') return false
3775
4022
  let raw = ''
3776
4023
  try {
3777
- raw = await new Promise((resolve, reject) => {
3778
- req.setEncoding('utf8')
3779
- req.on('data', chunk => {
3780
- raw += chunk
3781
- if (raw.length > 4 * 1024 * 1024) reject(new Error('request body too large'))
3782
- })
3783
- req.once('end', () => resolve(raw))
3784
- req.once('error', reject)
3785
- req.once('aborted', () => reject(new Error('request aborted')))
3786
- })
4024
+ raw = await readApiRequest(req)
3787
4025
  const body = JSON.parse(raw || '{}')
3788
4026
  cors(res)
3789
4027
  if (url.pathname === '/api/respond') {
@@ -3814,9 +4052,10 @@ async function proxyModernApi(req, res, url) {
3814
4052
  return true
3815
4053
  }
3816
4054
  const translated = await translateModernRpc(body.method, body.payload || {}, body.rpcId)
3817
- if (translated === null) {
3818
- res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' })
3819
- res.end(JSON.stringify(legacyEnvelope(body.rpcId, modernError(`Remote method ${body.method} is unavailable on this DSH version`, 'method-unavailable'))))
4055
+ if (translated?.__dshRemoteLegacyFallback) {
4056
+ recordCompatibility('modern-legacy-fallback', { method: body.method, detail: translated.reason })
4057
+ const legacy = await forwardLegacyRpc(url, raw)
4058
+ sendBufferedUpstreamResponse(res, legacy)
3820
4059
  return true
3821
4060
  }
3822
4061
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
@@ -3832,7 +4071,11 @@ async function proxyModernApi(req, res, url) {
3832
4071
  }
3833
4072
  }
3834
4073
 
3835
- function proxyLegacyApi(req, res, url) {
4074
+ async function proxyLegacyApi(req, res, url) {
4075
+ const method = url.pathname.startsWith('/api/') ? decodeURIComponent(url.pathname.slice('/api/'.length)) : ''
4076
+ if (req.method === 'POST' && ADAPTIVE_RPC_METHODS.has(method)) {
4077
+ return proxyLegacyRpcWithModernFallback(req, res, url)
4078
+ }
3836
4079
  const headers = {}
3837
4080
  for (const [k, v] of Object.entries(req.headers)) {
3838
4081
  if (v === undefined) continue
@@ -3905,7 +4148,7 @@ function proxyApi(req, res, url) {
3905
4148
  return
3906
4149
  }
3907
4150
  void proxyModernApi(req, res, url).then(handled => {
3908
- if (!handled) proxyLegacyApi(req, res, url)
4151
+ if (!handled) return proxyLegacyApi(req, res, url)
3909
4152
  }).catch(error => {
3910
4153
  cors(res)
3911
4154
  if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
@@ -3994,6 +4237,7 @@ const server = http.createServer(async (req, res) => {
3994
4237
  const url = new URL(req.url, 'http://dsh-remote.local')
3995
4238
  if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return await serveFs(req, res, url)
3996
4239
  if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
4240
+ if (url.pathname === '/diagnostics') return serveDiagnostics(req, res, url)
3997
4241
  if (url.pathname === '/feedback') return serveFeedback(req, res, url)
3998
4242
  if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
3999
4243
  if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
package/index.mjs CHANGED
@@ -19,6 +19,13 @@ export const inject = ['webServer', 'commands', 'agents', 'connection']
19
19
  const MOUNT = '/remote'
20
20
  const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
21
21
  const INDEX_FILE = 'index.html'
22
+ const LONG_RUNNING_COMMAND_TIMEOUT_MS = 120_000
23
+ const DEFAULT_COMMAND_TIMEOUT_MS = 30_000
24
+ const LONG_RUNNING_COMMANDS = new Set(['export'])
25
+ const COMMAND_OPERATION_RETENTION_MS = 10 * 60_000
26
+ // 已登记的命令由 HTTP 请求之外的独立 promise 驱动。这样浏览器断连或等待到期
27
+ // 都不会中止长操作;状态仅保存在进程内,DSH 重启后会话事件重新成为权威来源。
28
+ const commandOperations = new Map()
22
29
  const GATEWAY_SCRIPT = fileURLToPath(new URL('./gateway.cjs', import.meta.url))
23
30
  const gatewayInstalled = existsSync(GATEWAY_SCRIPT)
24
31
  // 本地网关管理 API 代理: 让插件抽屉显示与网关管理页完全一致的数据。
@@ -530,6 +537,56 @@ function commandWasExecuted(result) {
530
537
  return result !== false && result?.executed !== false
531
538
  }
532
539
 
540
+ function remoteCommandAuthorized(req) {
541
+ const expected = gatewayToken()
542
+ return !!expected && req.headers.authorization === `Bearer ${expected}`
543
+ }
544
+
545
+ function commandOperationSnapshot(operation) {
546
+ if (!operation) return { active: false, phase: 'idle', command: '', startedAt: 0, endedAt: 0, message: '' }
547
+ return {
548
+ active: operation.active === true,
549
+ phase: operation.phase || 'idle',
550
+ command: String(operation.command || ''),
551
+ startedAt: Number(operation.startedAt) || 0,
552
+ endedAt: Number(operation.endedAt) || 0,
553
+ message: String(operation.message || ''),
554
+ }
555
+ }
556
+
557
+ async function executeRegisteredCommand(ctx, agent, line, signal) {
558
+ return ctx.commands.execute.length === 3
559
+ ? ctx.commands.execute(agent, line, signal)
560
+ : ctx.commands.execute(agent, line, [], signal)
561
+ }
562
+
563
+ function beginCommandOperation(ctx, agent, sessionId, line, command) {
564
+ const existing = commandOperations.get(sessionId)
565
+ if (existing?.active) return { operation: existing, reused: true }
566
+ const operation = { active: true, phase: 'running', command, startedAt: Date.now(), endedAt: 0, message: '' }
567
+ commandOperations.set(sessionId, operation)
568
+ // 不把已登记命令绑定到 HTTP 超时:真实 command/done 才是完成边界。
569
+ // 处理器仍接收可用 AbortSignal,以符合命令执行器的契约。
570
+ const signal = new AbortController().signal
571
+ void executeRegisteredCommand(ctx, agent, line, signal).then((result) => {
572
+ operation.active = false
573
+ operation.endedAt = Date.now()
574
+ operation.phase = result?.result?.kind === 'error' ? 'failed' : 'complete'
575
+ operation.message = String(result?.result?.text || '')
576
+ }, (error) => {
577
+ operation.active = false
578
+ operation.endedAt = Date.now()
579
+ operation.phase = 'failed'
580
+ operation.message = error?.message || String(error)
581
+ }).finally(() => {
582
+ const cleanup = setTimeout(() => {
583
+ if (commandOperations.get(sessionId) === operation && !operation.active) commandOperations.delete(sessionId)
584
+ }, COMMAND_OPERATION_RETENTION_MS)
585
+ cleanup.unref?.()
586
+ })
587
+ return { operation, reused: false }
588
+ }
589
+
533
590
  async function resolveFile(pathname) {
534
591
  let abs = targetPath(pathname)
535
592
  if (abs === null) return null
@@ -757,6 +814,28 @@ async function serveStatic(req, res, ctx) {
757
814
  return
758
815
  }
759
816
 
817
+ // 命令状态:由插件进程跟踪长期操作,客户端重连后可恢复可见状态。
818
+ if (pathname === `${MOUNT}/api/command-status`) {
819
+ if (req.method !== 'GET') {
820
+ res.writeHead(405, { allow: 'GET' })
821
+ res.end()
822
+ return
823
+ }
824
+ if (!remoteCommandAuthorized(req)) {
825
+ sendJson(res, 401, { ok: false, message: 'unauthorized' })
826
+ return
827
+ }
828
+ const sessionId = new URL(req.url ?? '/', 'http://x').searchParams.get('sessionId') || ''
829
+ if (!sessionId) {
830
+ sendJson(res, 400, { ok: false, message: 'sessionId required' })
831
+ return
832
+ }
833
+ const operation = commandOperationSnapshot(commandOperations.get(sessionId))
834
+ // compact 为上一版客户端保留,新的客户端统一读取 operation。
835
+ sendJson(res, 200, { ok: true, operation, compact: operation.command === 'compact' ? operation : commandOperationSnapshot(null) })
836
+ return
837
+ }
838
+
760
839
  // 斜杠命令桥接:客户端 → 网关 → 插件端点 → ctx.commands.execute
761
840
  if (pathname === `${MOUNT}/api/command`) {
762
841
  if (req.method !== 'POST') {
@@ -764,9 +843,7 @@ async function serveStatic(req, res, ctx) {
764
843
  res.end()
765
844
  return
766
845
  }
767
- const auth = req.headers.authorization || ''
768
- const expected = gatewayToken()
769
- if (!expected || auth !== `Bearer ${expected}`) {
846
+ if (!remoteCommandAuthorized(req)) {
770
847
  sendJson(res, 401, { ok: false, message: 'unauthorized' })
771
848
  return
772
849
  }
@@ -804,10 +881,25 @@ async function serveStatic(req, res, ctx) {
804
881
  sendJson(res, 200, { ok: true, executed: false, debug: { resolvePath, commandNames, reason: 'unknown-command' } })
805
882
  return
806
883
  }
807
- const signal = AbortSignal.timeout(30000)
808
- const result = ctx.commands.execute.length === 3
809
- ? await ctx.commands.execute(agent, line, signal)
810
- : await ctx.commands.execute(agent, line, [], signal)
884
+ // /export 的成功结果必须先回到客户端,由客户端再发起 ZIP 下载;其他已登记
885
+ // 命令统一异步受理,避免把未知耗时绑定到一条 HTTP 请求。
886
+ if (name && name !== 'export') {
887
+ const { operation, reused } = beginCommandOperation(ctx, agent, sessionId, line, name)
888
+ sendJson(res, 202, {
889
+ ok: true,
890
+ executed: true,
891
+ accepted: true,
892
+ operation: commandOperationSnapshot(operation),
893
+ compact: name === 'compact' ? commandOperationSnapshot(operation) : undefined,
894
+ debug: { resolvePath, commandNames, reused },
895
+ })
896
+ return
897
+ }
898
+ // /export 在持久化历史较大时会等待刷新;其他同步命令沿用较短超时。
899
+ const signal = AbortSignal.timeout(LONG_RUNNING_COMMANDS.has(name)
900
+ ? LONG_RUNNING_COMMAND_TIMEOUT_MS
901
+ : DEFAULT_COMMAND_TIMEOUT_MS)
902
+ const result = await executeRegisteredCommand(ctx, agent, line, signal)
811
903
  sendJson(res, 200, { ok: true, executed: commandWasExecuted(result), debug: { resolvePath, commandNames } })
812
904
  } catch (e) {
813
905
  sendJson(res, 200, { ok: false, message: e?.message || String(e) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.21",
3
+ "version": "0.6.23",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -108,6 +108,14 @@
108
108
  { "id": "other", "label": "其他品牌" }
109
109
  ]
110
110
  }
111
+ },
112
+ {
113
+ "id": "2026-08-31-plugin-management-demand",
114
+ "title": "关于下个大版本插件管理功能的需求征集",
115
+ "content": "大家好,dsh-Remote 下个大版本将尝试引入插件管理相关功能,目前仍处于规划和探索阶段,具体功能范围会根据实际需求进一步评估。\n\n如果你有常用的管理插件,或者希望 dsh-Remote 支持某些插件管理及插件内功能,欢迎通过反馈渠道提交建议。也可以尽量说明具体使用场景、希望实现的操作以及当前遇到的不便,这会帮助我们开发出更符合实际需求的功能。\n\n如果需要加入用户交流群,QQ群号可以在往期公告中找到。\n\n感谢大家的支持与反馈!",
116
+ "minVersion": "",
117
+ "maxVersion": "",
118
+ "publishedAt": "2026-08-31T12:38:43+08:00"
111
119
  }
112
120
  ]
113
121
  }