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.
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +271 -27
- package/index.mjs +99 -7
- package/package.json +1 -1
- package/public/announcements.json +8 -0
- package/public/app.js +247 -14
- package/public/desktop/desktop.css +3 -0
- package/public/desktop/desktop.html +6 -5
- package/public/desktop/desktop.js +169 -5
- package/public/index.html +13 -5
- package/public/styles.css +6 -0
- package/public/update.json +12 -12
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1379
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1619
|
+
return legacyFallback('no generated RPC adapter')
|
|
1535
1620
|
}
|
|
1536
1621
|
|
|
1537
1622
|
const response = await callUpstreamRemote(endpoint, args, rpcId)
|
|
1538
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
3818
|
-
|
|
3819
|
-
|
|
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
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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.
|
|
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
|
}
|