dsh-remote-plugin 0.6.19 → 0.6.21

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/gateway.cjs CHANGED
@@ -78,6 +78,7 @@ const UPSTREAM_AUTHORITY = `${UPSTREAM.hostname}${UPSTREAM.port ? ':' + UPSTREAM
78
78
  const DSH_HEALTH_PATH = String(process.env.DSH_HEALTH_PATH || '/').startsWith('/')
79
79
  ? String(process.env.DSH_HEALTH_PATH || '/')
80
80
  : '/' + String(process.env.DSH_HEALTH_PATH)
81
+ const DSH_UPSTREAM_COOKIE_FILE = process.env.DSH_REMOTE_DSH_COOKIE_FILE || path.join(os.homedir(), '.dsh-remote', 'dsh-upstream.cookie')
81
82
  const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
82
83
  const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
83
84
  const DEVICE_KEYS_FILE = process.env.DSH_REMOTE_DEVICE_KEYS || path.join(os.homedir(), '.dsh-remote', 'device-keys.json')
@@ -94,6 +95,24 @@ const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 1
94
95
  const HTTP_HEADERS_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_HEADERS_TIMEOUT_MS', 120000, 1000, 10 * 60 * 1000)
95
96
  const HTTP_KEEPALIVE_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_KEEPALIVE_TIMEOUT_MS', 65000, 1000, 10 * 60 * 1000)
96
97
 
98
+ /** 读取插件兑换的新版 DSH 会话 Cookie;动态读取允许 DSH 重启后原地刷新。 */
99
+ function dshUpstreamCookie() {
100
+ try {
101
+ const value = fs.readFileSync(DSH_UPSTREAM_COOKIE_FILE, 'utf8').trim()
102
+ if (!value.includes('=') || value.length > 4096 || /[\0\r\n]/.test(value)) return ''
103
+ return value
104
+ } catch {
105
+ return ''
106
+ }
107
+ }
108
+
109
+ function dshUpstreamHeaders(base = {}) {
110
+ const headers = { ...base }
111
+ const cookie = dshUpstreamCookie()
112
+ if (cookie) headers.cookie = cookie
113
+ return headers
114
+ }
115
+
97
116
  // 更新检查: GitHub 为默认源, 可用环境变量覆盖(国内镜像 / 代理)
98
117
  const UPDATE_CHECK_URL = process.env.UPDATE_CHECK_URL ||
99
118
  'https://api.github.com/repos/Blank-not-black/dsh-Remote/releases/latest'
@@ -1055,6 +1074,7 @@ async function probeDshUpstream() {
1055
1074
  const startedAt = Date.now()
1056
1075
  try {
1057
1076
  const probe = await fetch(new URL(DSH_HEALTH_PATH, UPSTREAM), {
1077
+ headers: dshUpstreamHeaders(),
1058
1078
  signal: AbortSignal.timeout(Math.min(2500, UPSTREAM_REQUEST_TIMEOUT_MS)),
1059
1079
  cache: 'no-store',
1060
1080
  })
@@ -1314,6 +1334,211 @@ const eventCollectorState = {
1314
1334
  }
1315
1335
  const eventCollectors = { mux: null, host: null }
1316
1336
 
1337
+ // DSH 0.1.2-alpha.1 replaced dotted RPC names and the two downlink sockets with
1338
+ // generated slash RPCs over one logical-stream mux. Keep the public Remote
1339
+ // contract stable here so older DSH releases and newer generated Remotes can
1340
+ // both serve the same zero-build clients.
1341
+ let upstreamApiFlavor = 'unknown'
1342
+ let upstreamApiFlavorProbe = null
1343
+ const modernState = {
1344
+ home: '',
1345
+ eventClientId: '',
1346
+ sessions: new Map(),
1347
+ sessionCursors: new Map(),
1348
+ workspaces: { items: [], archivedSessionIds: [] },
1349
+ pendingEvents: new Map(),
1350
+ }
1351
+
1352
+ async function callUpstreamRemote(endpoint, args, rpcId = crypto.randomUUID()) {
1353
+ const target = new URL('/api/' + endpoint, UPSTREAM)
1354
+ const response = await fetch(target, {
1355
+ method: 'POST',
1356
+ headers: dshUpstreamHeaders({ 'content-type': 'application/json' }),
1357
+ body: JSON.stringify({ type: 'client-request', rpcId, method: endpoint, payload: { args } }),
1358
+ signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS),
1359
+ })
1360
+ const body = await response.json().catch(() => null)
1361
+ return { status: response.status, body }
1362
+ }
1363
+
1364
+ async function detectUpstreamApiFlavor(force = false) {
1365
+ if (!force && upstreamApiFlavor !== 'unknown') return upstreamApiFlavor
1366
+ if (!force && upstreamApiFlavorProbe) return upstreamApiFlavorProbe
1367
+ upstreamApiFlavorProbe = (async () => {
1368
+ try {
1369
+ const probe = await callUpstreamRemote('session/list', { _request: {} })
1370
+ upstreamApiFlavor = probe.status === 200
1371
+ && probe.body?.result?.ok === true
1372
+ && Array.isArray(probe.body?.result?.value?.items)
1373
+ ? 'modern'
1374
+ : 'legacy'
1375
+ if (upstreamApiFlavor === 'modern' && probe.body?.result?.ok) {
1376
+ updateModernSessions(probe.body.result.value?.items)
1377
+ }
1378
+ } catch {
1379
+ upstreamApiFlavor = 'legacy'
1380
+ } finally {
1381
+ upstreamApiFlavorProbe = null
1382
+ }
1383
+ return upstreamApiFlavor
1384
+ })()
1385
+ return upstreamApiFlavorProbe
1386
+ }
1387
+
1388
+ function updateModernSessions(items) {
1389
+ if (!Array.isArray(items)) return
1390
+ modernState.sessions.clear()
1391
+ for (const item of items) {
1392
+ if (!item?.sessionId) continue
1393
+ modernState.sessions.set(item.sessionId, item)
1394
+ const cursor = Number(item.projections?.asOfSeq)
1395
+ if (Number.isSafeInteger(cursor) && cursor >= -1) modernState.sessionCursors.set(item.sessionId, cursor)
1396
+ }
1397
+ }
1398
+
1399
+ function legacyEnvelope(rpcId, result) {
1400
+ return { rpcId, result }
1401
+ }
1402
+
1403
+ function modernError(message, code = 'upstream-incompatible', details = {}) {
1404
+ return { ok: false, error: { code, message, details } }
1405
+ }
1406
+
1407
+ function legacyHistoryValue(value, summary) {
1408
+ const records = Array.isArray(value?.records) ? value.records : []
1409
+ return {
1410
+ events: records.map(record => ({ event: record?.event })).filter(entry => entry.event),
1411
+ hasMore: !!value?.hasMore,
1412
+ ...(summary?.projections ? { projections: summary.projections } : {}),
1413
+ }
1414
+ }
1415
+
1416
+ async function refreshModernSessions() {
1417
+ const response = await callUpstreamRemote('session/list', { _request: {} })
1418
+ if (response.status === 200 && response.body?.result?.ok) updateModernSessions(response.body.result.value?.items)
1419
+ return response
1420
+ }
1421
+
1422
+ async function translateModernRpc(method, payload, rpcId) {
1423
+ let endpoint = ''
1424
+ let args = {}
1425
+ let transform = value => value
1426
+
1427
+ if (method === 'host.describe') {
1428
+ return legacyEnvelope(rpcId, { ok: true, value: { home: modernState.home || os.homedir(), canOpenPath: false } })
1429
+ }
1430
+ if (method === 'workspace.list') {
1431
+ return legacyEnvelope(rpcId, { ok: true, value: modernState.workspaces })
1432
+ }
1433
+ if (method === 'session.list') {
1434
+ const response = await refreshModernSessions()
1435
+ return response.body || legacyEnvelope(rpcId, modernError('DSH session/list returned no JSON response'))
1436
+ }
1437
+ if (method === 'session.history') {
1438
+ let summary = modernState.sessions.get(payload.sessionId)
1439
+ if (!summary) {
1440
+ await refreshModernSessions()
1441
+ summary = modernState.sessions.get(payload.sessionId)
1442
+ }
1443
+ const throughSeq = modernState.sessionCursors.get(payload.sessionId) ?? Number(summary?.projections?.asOfSeq)
1444
+ if (!Number.isSafeInteger(throughSeq) || throughSeq < -1) {
1445
+ return legacyEnvelope(rpcId, modernError('DSH did not expose a history cursor for this session', 'session-not-found'))
1446
+ }
1447
+ endpoint = 'session/page'
1448
+ args = { request: {
1449
+ address: { kind: 'session', sessionId: payload.sessionId },
1450
+ throughSeq,
1451
+ ...(payload.beforeSeq === undefined ? {} : { beforeSeq: payload.beforeSeq }),
1452
+ ...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
1453
+ } }
1454
+ transform = value => legacyHistoryValue(value, summary)
1455
+ } else if (method === 'session.models') {
1456
+ endpoint = 'session/modelCatalog'
1457
+ args = {}
1458
+ transform = catalog => {
1459
+ const summary = modernState.sessions.get(payload.sessionId)
1460
+ const projected = summary?.projections?.values?.modelSelection
1461
+ const current = projected?.next || projected?.lastUsed || catalog?.default
1462
+ return {
1463
+ current,
1464
+ routable: !!current && (!Array.isArray(catalog?.routableProviders) || catalog.routableProviders.includes(current.provider)),
1465
+ groups: catalog?.groups || [],
1466
+ failures: catalog?.failures || [],
1467
+ }
1468
+ }
1469
+ } else if (method.startsWith('session.')) {
1470
+ const verb = method.slice('session.'.length)
1471
+ if (!['search', 'create', 'selectModel', 'rename', 'fork', 'prompt', 'attachment', 'updateQueue', 'cancel'].includes(verb)) return null
1472
+ endpoint = 'session/' + verb
1473
+ const request = verb === 'prompt' && !payload.requestId
1474
+ ? { ...payload, requestId: crypto.randomUUID() }
1475
+ : payload
1476
+ args = verb === 'search' ? { request } : { request }
1477
+ } else if (method.startsWith('workspace.')) {
1478
+ const verb = method.slice('workspace.'.length)
1479
+ if (!['create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore', 'archiveSession'].includes(verb)) return null
1480
+ endpoint = 'workspace/' + verb
1481
+ args = { request: payload }
1482
+ } else if (method.startsWith('goal.')) {
1483
+ const verb = method.slice('goal.'.length)
1484
+ if (!['create', 'edit', 'pause', 'resume', 'complete', 'clear'].includes(verb)) return null
1485
+ endpoint = 'goals/' + verb
1486
+ args = verb === 'create'
1487
+ ? { agentId: payload.sessionId, request: { objective: payload.objective, ...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }) } }
1488
+ : { agentId: payload.sessionId, ref: payload.ref, ...(verb === 'edit' ? { request: { ...(payload.objective === undefined ? {} : { objective: payload.objective }), ...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }) } } : {}) }
1489
+ transform = value => verb === 'create'
1490
+ ? value
1491
+ : verb === 'clear'
1492
+ ? { cleared: true }
1493
+ : { ref: { id: value?.id, revision: value?.revision } }
1494
+ } else if (method === 'subagent.list') {
1495
+ endpoint = 'subagents/list'
1496
+ args = { parentSessionId: payload.parentSessionId }
1497
+ } else if (method === 'subagent.interrupt') {
1498
+ endpoint = 'subagents/interruptByParent'
1499
+ args = { childSessionId: payload.childSessionId, parentSessionId: payload.parentSessionId, mode: payload.mode }
1500
+ } else if (method === 'settings.describe') {
1501
+ endpoint = 'settings/describe'
1502
+ args = {}
1503
+ } else if (method === 'settings.mutate') {
1504
+ endpoint = 'settings/mutate'
1505
+ args = { ns: payload.ns, ops: payload.ops, expectedRevision: payload.expectedRevision }
1506
+ } else if (method === 'settings.openDocument') {
1507
+ endpoint = 'settings/openSettingsDocument'
1508
+ args = {}
1509
+ } else if (method.startsWith('credentials.')) {
1510
+ const verb = method.slice('credentials.'.length)
1511
+ if (!['describe', 'set', 'unset'].includes(verb)) return null
1512
+ endpoint = 'credentials/' + verb
1513
+ args = verb === 'describe' ? { refs: payload.refs } : verb === 'set' ? { ref: payload.ref, value: payload.value } : { ref: payload.ref }
1514
+ if (verb !== 'describe') transform = () => ({})
1515
+ } else if (method === 'llm.providers') {
1516
+ endpoint = 'llm/listConfigurableProviders'
1517
+ args = {}
1518
+ transform = values => ({ providers: (Array.isArray(values) ? values : []).map(entry => ({
1519
+ provider: entry.provider,
1520
+ displayName: entry.displayName,
1521
+ settingsNs: entry.settingsNs,
1522
+ settingsPath: entry.settingsPath || [],
1523
+ active: true,
1524
+ ...(entry.declared === undefined ? {} : { declared: entry.declared }),
1525
+ })) })
1526
+ } else if (method === 'llm.discoverModels') {
1527
+ endpoint = 'llm/discoverModels'
1528
+ args = {
1529
+ settingsNs: payload.settingsNs,
1530
+ request: Object.fromEntries(Object.entries(payload).filter(([key, value]) => key !== 'settingsNs' && value !== undefined)),
1531
+ }
1532
+ transform = models => ({ models: Array.isArray(models) ? models : [] })
1533
+ } else {
1534
+ return null
1535
+ }
1536
+
1537
+ 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`))
1539
+ return legacyEnvelope(rpcId, { ok: true, value: transform(response.body.result.value) })
1540
+ }
1541
+
1317
1542
  /** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
1318
1543
  function truncateEventValue(v, depth = 0) {
1319
1544
  if (typeof v === 'string') return v.length > EVENT_MAX_STRING ? v.slice(0, EVENT_MAX_STRING) + '…[truncated]' : v
@@ -1530,7 +1755,8 @@ function startEventCollector(kind) {
1530
1755
  if (stopped) return
1531
1756
  let current
1532
1757
  try {
1533
- current = new WebSocket(url)
1758
+ const headers = dshUpstreamHeaders()
1759
+ current = Object.keys(headers).length ? new WebSocket(url, { headers }) : new WebSocket(url)
1534
1760
  ws = current
1535
1761
  } catch (err) {
1536
1762
  state.lastError = String(err?.message || err)
@@ -1616,6 +1842,280 @@ function startEventCollector(kind) {
1616
1842
  }
1617
1843
  }
1618
1844
 
1845
+ function legacyPush(kind, payload, rpcId = crypto.randomUUID()) {
1846
+ pushEvent(kind, { rpcId, payload })
1847
+ }
1848
+
1849
+ function openModernSessionStream(ws, sessionId) {
1850
+ if (!sessionId || ws.readyState !== 1) return
1851
+ const streamId = 'session:' + sessionId
1852
+ ws.send(JSON.stringify({
1853
+ type: 'open', streamId, endpoint: 'session/follow',
1854
+ payload: { args: { request: { address: { kind: 'session', sessionId } } } },
1855
+ }))
1856
+ }
1857
+
1858
+ function applyModernControlFrame(value) {
1859
+ if (value?.type === 'baseline') {
1860
+ for (const [sessionId, items] of Object.entries(value.value?.queues || {})) {
1861
+ legacyPush('mux', { type: 'session/queue', sessionId, items })
1862
+ }
1863
+ for (const [sessionId, jobs] of Object.entries(value.value?.jobs || {})) {
1864
+ legacyPush('mux', { type: 'session/jobs', sessionId, jobs })
1865
+ }
1866
+ for (const [sessionId, block] of Object.entries(value.value?.projections || {})) {
1867
+ for (const [key, projection] of Object.entries(block?.values || {})) {
1868
+ legacyPush('mux', { type: 'session/projection', sessionId, key, value: projection, seq: block?.asOfSeq ?? 0 })
1869
+ }
1870
+ }
1871
+ return
1872
+ }
1873
+ if (value?.type === 'queue') legacyPush('mux', { type: 'session/queue', sessionId: value.sessionId, items: value.items || [] })
1874
+ else if (value?.type === 'jobs') legacyPush('mux', { type: 'session/jobs', sessionId: value.sessionId, jobs: value.jobs || [] })
1875
+ else if (value?.type === 'projection') legacyPush('mux', {
1876
+ type: 'session/projection', sessionId: value.sessionId, key: value.key, value: value.value, seq: value.seq,
1877
+ })
1878
+ }
1879
+
1880
+ function applyModernWorkspaceFrame(value) {
1881
+ if (value?.type === 'baseline') {
1882
+ modernState.workspaces = {
1883
+ items: Array.isArray(value.value?.items) ? value.value.items : [],
1884
+ archivedSessionIds: Array.isArray(value.value?.archivedSessionIds) ? value.value.archivedSessionIds : [],
1885
+ }
1886
+ return
1887
+ }
1888
+ if (value?.type === 'upsert' && value.workspace) {
1889
+ const items = modernState.workspaces.items.filter(item => item.workspaceId !== value.workspace.workspaceId)
1890
+ items.push(value.workspace)
1891
+ modernState.workspaces = { ...modernState.workspaces, items }
1892
+ legacyPush('host', { type: 'host/workspace-changed', workspace: value.workspace })
1893
+ } else if (value?.type === 'remove') {
1894
+ modernState.workspaces = {
1895
+ ...modernState.workspaces,
1896
+ items: modernState.workspaces.items.filter(item => item.workspaceId !== value.workspaceId),
1897
+ }
1898
+ legacyPush('host', { type: 'host/workspace-removed', workspaceId: value.workspaceId })
1899
+ } else if (value?.type === 'order') {
1900
+ const byId = new Map(modernState.workspaces.items.map(item => [item.workspaceId, item]))
1901
+ const ordered = (value.workspaceIds || []).map(id => byId.get(id)).filter(Boolean)
1902
+ for (const item of modernState.workspaces.items) if (!value.workspaceIds?.includes(item.workspaceId)) ordered.push(item)
1903
+ modernState.workspaces = { ...modernState.workspaces, items: ordered }
1904
+ legacyPush('host', { type: 'host/workspace-order-changed', workspaceIds: value.workspaceIds || [] })
1905
+ } else if (value?.type === 'archived') {
1906
+ modernState.workspaces = { ...modernState.workspaces, archivedSessionIds: value.archivedSessionIds || [] }
1907
+ legacyPush('host', { type: 'host/archived-sessions-changed', archivedSessionIds: value.archivedSessionIds || [] })
1908
+ }
1909
+ }
1910
+
1911
+ function applyModernSessionFrame(sessionId, value) {
1912
+ if (value?.type === 'snapshot') {
1913
+ modernState.sessionCursors.set(sessionId, value.cursor)
1914
+ legacyPush('mux', { type: 'session/subscribed', sessionId, lastSeq: value.cursor })
1915
+ for (const record of value.records || []) {
1916
+ if (record?.event) legacyPush('mux', { type: 'session/event', sessionId, event: record.event })
1917
+ }
1918
+ for (const [key, projection] of Object.entries(value.projections?.values || {})) {
1919
+ legacyPush('mux', { type: 'session/projection', sessionId, key, value: projection, seq: value.projections?.asOfSeq ?? value.cursor })
1920
+ }
1921
+ return
1922
+ }
1923
+ if (value?.type === 'event' && value.event) {
1924
+ modernState.sessionCursors.set(sessionId, value.event.seq)
1925
+ legacyPush('mux', { type: 'session/event', sessionId, event: value.event })
1926
+ }
1927
+ }
1928
+
1929
+ function resolveModernPendingEvent(eventId, cancelled = false) {
1930
+ const pending = modernState.pendingEvents.get(eventId)
1931
+ if (!pending) return
1932
+ modernState.pendingEvents.delete(eventId)
1933
+ if (pending.event === 'approval/request') {
1934
+ legacyPush('mux', {
1935
+ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: eventId,
1936
+ outcome: cancelled ? 'cancelled' : pending.outcome,
1937
+ })
1938
+ } else if (pending.event === 'user-questions/request') {
1939
+ legacyPush('mux', {
1940
+ type: 'question/resolved', sessionId: pending.sessionId, questionRpcId: eventId,
1941
+ outcome: cancelled ? 'cancelled' : 'answered',
1942
+ })
1943
+ }
1944
+ }
1945
+
1946
+ function applyModernRemoteEvent(ws, value) {
1947
+ if (value?.type === 'ready') {
1948
+ modernState.eventClientId = value.clientId || ''
1949
+ modernState.home = value.host?.home || modernState.home
1950
+ return
1951
+ }
1952
+ if (value?.type === 'cancel') {
1953
+ resolveModernPendingEvent(value.eventId, true)
1954
+ return
1955
+ }
1956
+ if (value?.type === 'waterfall') {
1957
+ const sessionId = value.agentId
1958
+ modernState.pendingEvents.set(value.eventId, { event: value.event, sessionId, outcome: '' })
1959
+ if (value.event === 'approval/request') {
1960
+ legacyPush('mux', {
1961
+ type: 'approval/requested', sessionId, approvalId: value.eventId,
1962
+ toolName: value.request?.toolName || '',
1963
+ ...(value.request?.callId === undefined ? {} : { callId: value.request.callId }),
1964
+ ...(value.request?.reason === undefined ? {} : { reason: value.request.reason }),
1965
+ }, value.eventId)
1966
+ } else if (value.event === 'user-questions/request') {
1967
+ legacyPush('mux', { type: 'question/requested', sessionId, questions: value.request?.questions || [] }, value.eventId)
1968
+ }
1969
+ return
1970
+ }
1971
+ if (value?.type !== 'emit') return
1972
+ const args = value.args || []
1973
+ if (value.event === 'api-session/added') {
1974
+ const summary = args[0]
1975
+ if (summary?.sessionId) {
1976
+ modernState.sessions.set(summary.sessionId, summary)
1977
+ openModernSessionStream(ws, summary.sessionId)
1978
+ legacyPush('host', {
1979
+ type: 'host/session-added', sessionId: summary.sessionId, blank: !!summary.blank,
1980
+ ...(summary.parentSessionId === undefined ? {} : { parentSessionId: summary.parentSessionId }),
1981
+ ...(summary.origin === undefined ? {} : { origin: summary.origin }),
1982
+ ...(summary.cwd === undefined ? {} : { cwd: summary.cwd }),
1983
+ })
1984
+ }
1985
+ } else if (value.event === 'api-session/removed') {
1986
+ modernState.sessions.delete(args[0])
1987
+ modernState.sessionCursors.delete(args[0])
1988
+ try { ws.send(JSON.stringify({ type: 'cancel', streamId: 'session:' + args[0] })) } catch {}
1989
+ legacyPush('host', { type: 'host/session-removed', sessionId: args[0] })
1990
+ } else if (value.event === 'api-session/status') {
1991
+ legacyPush('host', { type: 'host/session-status', sessionId: args[0], running: !!args[1] })
1992
+ } else if (value.event === 'api-session/error') {
1993
+ legacyPush('host', { type: 'host/agent-error', sessionId: args[0], message: String(args[1] || '') })
1994
+ } else {
1995
+ legacyPush('host', { type: 'host/remote-event', event: value.event, args })
1996
+ }
1997
+ }
1998
+
1999
+ function startModernEventCollector() {
2000
+ if (typeof WebSocket !== 'function') return null
2001
+ let ws = null
2002
+ let stopped = false
2003
+ let retryTimer = null
2004
+ let connectTimer = null
2005
+ let attempt = 0
2006
+ const scheme = UPSTREAM.protocol === 'https:' ? 'wss' : 'ws'
2007
+ const url = `${scheme}://${UPSTREAM_AUTHORITY}/api/remote.mux`
2008
+ const setConnected = (connected, error = '') => {
2009
+ for (const state of Object.values(eventCollectorState)) {
2010
+ state.connected = connected
2011
+ if (connected) {
2012
+ state.lastConnectAt = Date.now()
2013
+ state.lastError = ''
2014
+ state.attempt = 0
2015
+ } else if (error) state.lastError = error
2016
+ }
2017
+ }
2018
+ const schedule = () => {
2019
+ if (stopped || retryTimer) return
2020
+ const delay = Math.round(Math.min(1500 * Math.pow(2, attempt++), 60000) * (0.8 + Math.random() * 0.4))
2021
+ retryTimer = setTimeout(() => { retryTimer = null; connect() }, delay)
2022
+ retryTimer.unref?.()
2023
+ }
2024
+ const connect = () => {
2025
+ if (stopped) return
2026
+ let current
2027
+ try {
2028
+ const headers = dshUpstreamHeaders()
2029
+ current = Object.keys(headers).length ? new WebSocket(url, { headers }) : new WebSocket(url)
2030
+ ws = current
2031
+ } catch (error) {
2032
+ setConnected(false, String(error?.message || error))
2033
+ schedule()
2034
+ return
2035
+ }
2036
+ let finished = false
2037
+ const finish = (code = 0, reason = '', error = '') => {
2038
+ if (finished) return
2039
+ finished = true
2040
+ clearTimeout(connectTimer)
2041
+ modernState.eventClientId = ''
2042
+ setConnected(false, error)
2043
+ for (const state of Object.values(eventCollectorState)) {
2044
+ state.lastCloseCode = Number(code) || 0
2045
+ state.lastCloseReason = String(reason || '')
2046
+ state.reconnects++
2047
+ }
2048
+ if (ws === current) ws = null
2049
+ if (!stopped) schedule()
2050
+ }
2051
+ connectTimer = setTimeout(() => {
2052
+ finish(0, '', 'websocket connect timeout')
2053
+ try { current.close() } catch {}
2054
+ }, WS_UPGRADE_TIMEOUT_MS)
2055
+ connectTimer.unref?.()
2056
+ current.onopen = () => {
2057
+ if (finished || stopped || ws !== current) return
2058
+ clearTimeout(connectTimer)
2059
+ attempt = 0
2060
+ setConnected(true)
2061
+ current.send(JSON.stringify({ type: 'open', streamId: 'events', endpoint: '$events', payload: { args: {} } }))
2062
+ current.send(JSON.stringify({ type: 'open', streamId: 'control', endpoint: 'session/control', payload: { args: {} } }))
2063
+ current.send(JSON.stringify({ type: 'open', streamId: 'workspaces', endpoint: 'workspace/follow', payload: { args: {} } }))
2064
+ for (const sessionId of modernState.sessions.keys()) openModernSessionStream(current, sessionId)
2065
+ }
2066
+ current.onmessage = ev => {
2067
+ try {
2068
+ const data = typeof ev.data === 'string' ? ev.data : Buffer.isBuffer(ev.data) ? ev.data.toString() : String(ev.data)
2069
+ const frame = JSON.parse(data)
2070
+ if (frame.type === 'error') {
2071
+ const message = frame.error?.message || 'modern stream error'
2072
+ for (const state of Object.values(eventCollectorState)) state.lastError = message
2073
+ legacyPush('mux', { type: 'stream/error', error: frame.error || { message } })
2074
+ return
2075
+ }
2076
+ if (frame.type !== 'item') return
2077
+ if (frame.streamId === 'events') applyModernRemoteEvent(current, frame.value)
2078
+ else if (frame.streamId === 'control') applyModernControlFrame(frame.value)
2079
+ else if (frame.streamId === 'workspaces') applyModernWorkspaceFrame(frame.value)
2080
+ else if (frame.streamId.startsWith('session:')) applyModernSessionFrame(frame.streamId.slice(8), frame.value)
2081
+ } catch {}
2082
+ }
2083
+ current.onclose = ev => finish(ev?.code, ev?.reason)
2084
+ current.onerror = err => {
2085
+ finish(0, '', String(err?.error?.message || err?.message || 'websocket error'))
2086
+ try { current.close() } catch {}
2087
+ }
2088
+ }
2089
+ connect()
2090
+ return {
2091
+ kind: 'modern',
2092
+ reconnectNow() {
2093
+ if (stopped || ws?.readyState === 0 || ws?.readyState === 1) return
2094
+ clearTimeout(retryTimer)
2095
+ retryTimer = null
2096
+ attempt = 0
2097
+ connect()
2098
+ },
2099
+ close() {
2100
+ stopped = true
2101
+ clearTimeout(retryTimer)
2102
+ clearTimeout(connectTimer)
2103
+ try { ws?.close() } catch {}
2104
+ },
2105
+ }
2106
+ }
2107
+
2108
+ async function startCompatibleEventCollectors() {
2109
+ if (await detectUpstreamApiFlavor() === 'modern') {
2110
+ const collector = startModernEventCollector()
2111
+ eventCollectors.mux = collector
2112
+ eventCollectors.host = collector
2113
+ return
2114
+ }
2115
+ eventCollectors.mux = startEventCollector('mux')
2116
+ eventCollectors.host = startEventCollector('host')
2117
+ }
2118
+
1619
2119
  // ---------- 统计 API ----------
1620
2120
  let statsScanning = false
1621
2121
  async function scanStatsOnce(delay) {
@@ -2048,6 +2548,7 @@ function upstreamReachable(cb) {
2048
2548
  port: UPSTREAM_PORT,
2049
2549
  method: 'GET',
2050
2550
  path: '/health',
2551
+ headers: dshUpstreamHeaders(),
2051
2552
  timeout: 1500
2052
2553
  }, (res) => {
2053
2554
  res.resume()
@@ -2330,16 +2831,21 @@ async function loadFsWorkspaceRoots(force = false) {
2330
2831
  if (fsWorkspaceRootsFetch) return fsWorkspaceRootsFetch
2331
2832
  fsWorkspaceRootsFetch = (async () => {
2332
2833
  try {
2333
- const target = new URL('/api/workspace.list', UPSTREAM)
2334
- const res = await fetch(target, {
2335
- method: 'POST',
2336
- headers: { 'content-type': 'application/json' },
2337
- body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace.list', payload: {} }),
2338
- signal: AbortSignal.timeout(Math.min(8000, UPSTREAM_REQUEST_TIMEOUT_MS)),
2339
- })
2340
- if (!res.ok) throw new Error(`workspace.list HTTP ${res.status}`)
2341
- const body = await res.json()
2342
- const value = body?.result?.ok ? body.result.value : null
2834
+ let value
2835
+ if (await detectUpstreamApiFlavor() === 'modern') {
2836
+ value = modernState.workspaces
2837
+ } else {
2838
+ const target = new URL('/api/workspace.list', UPSTREAM)
2839
+ const res = await fetch(target, {
2840
+ method: 'POST',
2841
+ headers: dshUpstreamHeaders({ 'content-type': 'application/json' }),
2842
+ body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace.list', payload: {} }),
2843
+ signal: AbortSignal.timeout(Math.min(8000, UPSTREAM_REQUEST_TIMEOUT_MS)),
2844
+ })
2845
+ if (!res.ok) throw new Error(`workspace.list HTTP ${res.status}`)
2846
+ const body = await res.json()
2847
+ value = body?.result?.ok ? body.result.value : null
2848
+ }
2343
2849
  const items = Array.isArray(value?.items) ? value.items : []
2344
2850
  const roots = [...new Set(items.map(fsWorkspacePath).filter(Boolean))]
2345
2851
  const reals = roots.map(root => { try { return fs.realpathSync(root) } catch { return null } }).filter(Boolean)
@@ -3263,38 +3769,84 @@ function serveWorkbench(req, res, url) {
3263
3769
  }
3264
3770
 
3265
3771
  // ---------- /api 代理 ----------
3266
- function proxyApi(req, res, url) {
3267
- if (req.method === 'OPTIONS') {
3268
- cors(res)
3269
- res.writeHead(204)
3270
- res.end()
3271
- return
3272
- }
3273
- const ok = authorized(req, url)
3274
- touchDevice(req, ok ? {} : { failedAuth: true })
3275
- if (!ok) {
3276
- authFailures++
3772
+ async function proxyModernApi(req, res, url) {
3773
+ if (req.method !== 'POST' || url.pathname.startsWith('/remote/')) return false
3774
+ if (await detectUpstreamApiFlavor() !== 'modern') return false
3775
+ let raw = ''
3776
+ 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
+ })
3787
+ const body = JSON.parse(raw || '{}')
3277
3788
  cors(res)
3278
- res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
3279
- res.end(JSON.stringify({ error: 'unauthorized' }))
3280
- return
3789
+ if (url.pathname === '/api/respond') {
3790
+ const eventId = body.rpcId
3791
+ const pending = modernState.pendingEvents.get(eventId)
3792
+ if (!pending || !modernState.eventClientId) {
3793
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
3794
+ res.end(JSON.stringify({ accepted: false }))
3795
+ return true
3796
+ }
3797
+ const value = body.result?.value
3798
+ const answer = pending.event === 'approval/request' ? value?.outcome : value?.answer
3799
+ pending.outcome = answer
3800
+ const response = await callUpstreamRemote('$events/result', {
3801
+ clientId: modernState.eventClientId,
3802
+ eventId,
3803
+ outcome: { kind: 'result', value: answer },
3804
+ })
3805
+ const accepted = response.status === 200 && response.body?.result?.ok === true
3806
+ if (accepted) resolveModernPendingEvent(eventId, false)
3807
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
3808
+ res.end(JSON.stringify({ accepted }))
3809
+ return true
3810
+ }
3811
+ if (body.type !== 'client-request' || typeof body.rpcId !== 'string' || typeof body.method !== 'string') {
3812
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
3813
+ res.end(JSON.stringify({ error: 'bad-request' }))
3814
+ return true
3815
+ }
3816
+ 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'))))
3820
+ return true
3821
+ }
3822
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
3823
+ res.end(JSON.stringify(translated))
3824
+ return true
3825
+ } catch (error) {
3826
+ if (!res.headersSent) {
3827
+ cors(res)
3828
+ res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
3829
+ }
3830
+ if (!res.writableEnded) res.end(JSON.stringify({ error: 'upstream-incompatible', detail: String(error?.message || error) }))
3831
+ return true
3281
3832
  }
3833
+ }
3282
3834
 
3835
+ function proxyLegacyApi(req, res, url) {
3283
3836
  const headers = {}
3284
3837
  for (const [k, v] of Object.entries(req.headers)) {
3285
3838
  if (v === undefined) continue
3286
3839
  const key = k.toLowerCase()
3287
- if (['host', 'authorization', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
3840
+ if (['host', 'authorization', 'cookie', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
3288
3841
  'proxy-connection', 'accept-encoding', 'origin', 'referer',
3289
3842
  'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user',
3290
3843
  'x-dsh-remote-client'].includes(key)) continue
3291
3844
  headers[k] = v
3292
3845
  }
3293
3846
  headers.host = UPSTREAM.host
3847
+ Object.assign(headers, dshUpstreamHeaders())
3294
3848
  // /remote/* 由 DSH 插件端点处理;插件侧用网关自身 token 鉴权。
3295
- if (url.pathname.startsWith('/remote/')) {
3296
- headers.authorization = 'Bearer ' + TOKEN
3297
- }
3849
+ if (url.pathname.startsWith('/remote/')) headers.authorization = 'Bearer ' + TOKEN
3298
3850
 
3299
3851
  let responseDone = false
3300
3852
  const upstreamReq = UPSTREAM_TRANSPORT.request({
@@ -3336,6 +3888,31 @@ function proxyApi(req, res, url) {
3336
3888
  req.pipe(upstreamReq)
3337
3889
  }
3338
3890
 
3891
+ function proxyApi(req, res, url) {
3892
+ if (req.method === 'OPTIONS') {
3893
+ cors(res)
3894
+ res.writeHead(204)
3895
+ res.end()
3896
+ return
3897
+ }
3898
+ const ok = authorized(req, url)
3899
+ touchDevice(req, ok ? {} : { failedAuth: true })
3900
+ if (!ok) {
3901
+ authFailures++
3902
+ cors(res)
3903
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
3904
+ res.end(JSON.stringify({ error: 'unauthorized' }))
3905
+ return
3906
+ }
3907
+ void proxyModernApi(req, res, url).then(handled => {
3908
+ if (!handled) proxyLegacyApi(req, res, url)
3909
+ }).catch(error => {
3910
+ cors(res)
3911
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
3912
+ if (!res.writableEnded) res.end(JSON.stringify({ error: 'upstream-unreachable', detail: String(error?.message || error) }))
3913
+ })
3914
+ }
3915
+
3339
3916
  // ---------- 其它 ----------
3340
3917
  async function serveHealth(req, res, url) {
3341
3918
  const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
@@ -3364,7 +3941,7 @@ async function serveHealth(req, res, url) {
3364
3941
  const ctrl = new AbortController()
3365
3942
  timer = setTimeout(() => ctrl.abort(), 5000)
3366
3943
  const probeUrl = new URL(DSH_HEALTH_PATH, UPSTREAM).toString()
3367
- const probe = await fetch(probeUrl, { signal: ctrl.signal, cache: 'no-store' })
3944
+ const probe = await fetch(probeUrl, { headers: dshUpstreamHeaders(), signal: ctrl.signal, cache: 'no-store' })
3368
3945
  upstreamReachable = true
3369
3946
  upstreamStatus = probe.status
3370
3947
  upstreamOk = probe.ok
@@ -3637,7 +4214,7 @@ server.on('upgrade', (req, socket, head) => {
3637
4214
  for (const [k, v] of Object.entries(req.headers)) {
3638
4215
  if (v === undefined) continue
3639
4216
  const key = k.toLowerCase()
3640
- if (['host', 'authorization', 'connection', 'upgrade', 'sec-websocket-key',
4217
+ if (['host', 'authorization', 'cookie', 'connection', 'upgrade', 'sec-websocket-key',
3641
4218
  'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol',
3642
4219
  'proxy-connection', 'accept-encoding', 'origin', 'referer',
3643
4220
  'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user',
@@ -3645,6 +4222,7 @@ server.on('upgrade', (req, socket, head) => {
3645
4222
  headers[k] = v
3646
4223
  }
3647
4224
  headers.host = UPSTREAM.host
4225
+ Object.assign(headers, dshUpstreamHeaders())
3648
4226
  headers.connection = 'Upgrade'
3649
4227
  headers.upgrade = 'websocket'
3650
4228
  if (req.headers['sec-websocket-key']) headers['sec-websocket-key'] = req.headers['sec-websocket-key']
@@ -3747,8 +4325,7 @@ server.listen(PORT, HOST, () => {
3747
4325
  }
3748
4326
  console.log(' 上游: ' + UPSTREAM.origin + ' (Ctrl+C 退出)')
3749
4327
  // 事件轮询缓冲:网关自身上游 WS 采集,断线自动重连
3750
- eventCollectors.mux = startEventCollector('mux')
3751
- eventCollectors.host = startEventCollector('host')
4328
+ void startCompatibleEventCollectors()
3752
4329
  // 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
3753
4330
  setTimeout(() => checkForUpdates(false), 8000)
3754
4331
  setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)