dsh-relay-phone 0.11.0 → 0.11.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-relay-phone",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Remote-control your local DeepSeek Harness from a phone through your own zero-knowledge relay: phone-first page, QR pairing, end-to-end encryption, approvals, full desktop UI fallback.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -72,6 +72,7 @@
72
72
  polling: false,
73
73
  timer: null,
74
74
  busy: false,
75
+ text: '', // everything received so far; the host only sends new lines
75
76
  },
76
77
  }
77
78
 
@@ -2597,7 +2598,8 @@
2597
2598
  $('terminalTitle').textContent = '终端'
2598
2599
  try {
2599
2600
  await ensureTerminal()
2600
- await refreshTerminal()
2601
+ state.terminal.text = ''
2602
+ await refreshTerminal(true)
2601
2603
  startTerminalPoll()
2602
2604
  } catch (error) {
2603
2605
  $('terminalOut').textContent = '终端不可用:' + (error.message || error)
@@ -2607,6 +2609,7 @@
2607
2609
  function closeTerminal() {
2608
2610
  $('terminalPanel').hidden = true
2609
2611
  state.terminal.open = false
2612
+ state.terminal.text = ''
2610
2613
  stopTerminalPoll()
2611
2614
  }
2612
2615
 
@@ -2660,15 +2663,22 @@
2660
2663
  .replace(/\u001b(\[[0-9;?]*)?$/g, '')
2661
2664
  }
2662
2665
 
2663
- async function refreshTerminal() {
2666
+ async function refreshTerminal(reset) {
2664
2667
  if (!state.terminal.id || !state.terminal.open) return
2665
2668
  var result = await rpc('terminal.read', {
2666
2669
  sessionId: state.currentId,
2667
2670
  terminalId: state.terminal.id,
2668
2671
  count: 300,
2672
+ reset: reset === true,
2669
2673
  })
2670
2674
  var out = $('terminalOut')
2671
- var text = plainTerminalText(result.text) || '(无输出)'
2675
+ // The bridge sends only the lines this phone has not seen; append them,
2676
+ // and replace the buffer whenever it says the gap cannot be bridged.
2677
+ var chunk = plainTerminalText(result.text)
2678
+ if (result.appended !== true) state.terminal.text = chunk
2679
+ else if (chunk) state.terminal.text = (state.terminal.text || '') + (state.terminal.text ? '\n' : '') + chunk
2680
+ if (state.terminal.text.length > 200000) state.terminal.text = state.terminal.text.slice(-200000)
2681
+ var text = state.terminal.text || '(无输出)'
2672
2682
  if (out.textContent !== text) {
2673
2683
  out.textContent = text
2674
2684
  out.scrollTop = out.scrollHeight
@@ -2714,12 +2724,13 @@
2714
2724
  var created = await rpc('terminal.create', { sessionId: state.currentId })
2715
2725
  state.terminal.id = created.id
2716
2726
  state.terminal.name = created.name || created.type
2717
- $('terminalOut').textContent = created.motd || ''
2727
+ state.terminal.text = plainTerminalText(created.motd)
2728
+ $('terminalOut').textContent = state.terminal.text || ''
2718
2729
  toast('已新建终端')
2719
2730
  } catch (error) {
2720
2731
  toast('新建终端失败:' + (error.message || error))
2721
2732
  }
2722
- refreshTerminal().catch(function () {})
2733
+ refreshTerminal(true).catch(function () {})
2723
2734
  }
2724
2735
 
2725
2736
  async function terminalKill() {
@@ -85,6 +85,7 @@ export class MobileBridge {
85
85
  this.continued = new Map() // key (`q:<callId>`) -> {sessionId, callId, questions}
86
86
  this.titles = new Map() // sessionId -> title from list rows
87
87
  this.ownTerminals = new Map() // `${sessionId}:${id}` -> plugin-owned fallback PTY
88
+ this.terminalCursors = new Map() // `${sessionId}:${id}` -> last line index sent to the phone
88
89
  this.disposers = []
89
90
  this.started = false
90
91
  this.phoneCount = 0
@@ -989,8 +990,10 @@ export class MobileBridge {
989
990
  const sessionId = params?.sessionId
990
991
  const id = params?.terminalId
991
992
  if (typeof sessionId !== 'string' || typeof id !== 'string') throw fail('bad-request', 'sessionId and terminalId required')
993
+ const key = `${sessionId}:${id}`
994
+ if (params?.reset === true) this.terminalCursors.delete(key)
992
995
  const owned = this.ownTerminal(sessionId, id)
993
- if (owned) return this.readOwnTerminal(owned, Number.isFinite(params?.offset) ? params.offset : 0, clampInt(params?.count, 20, 400, 120))
996
+ if (owned) return this.slimTerminal(key, this.readOwnTerminal(owned, Number.isFinite(params?.offset) ? params.offset : 0, clampInt(params?.count, 20, 400, 120)))
994
997
  const agent = await this.resolveLiveAgent(sessionId)
995
998
  const registry = this.presetTerminals(agent)
996
999
  if (!registry) throw fail('unavailable', 'terminal service unavailable for this agent')
@@ -998,13 +1001,36 @@ export class MobileBridge {
998
1001
  offset: Number.isFinite(params?.offset) ? params.offset : 0,
999
1002
  count: clampInt(params?.count, 20, 400, 120),
1000
1003
  })
1001
- return {
1004
+ return this.slimTerminal(key, {
1002
1005
  text: result?.text ?? '',
1003
1006
  totalLines: result?.totalLines ?? 0,
1004
1007
  lineBegin: result?.lineBegin ?? 0,
1005
1008
  lineEnd: result?.lineEnd ?? 0,
1006
1009
  truncated: result?.truncated === true,
1007
- }
1010
+ })
1011
+ }
1012
+
1013
+ /**
1014
+ * Send only what the phone has not seen yet.
1015
+ *
1016
+ * The panel polls once a second, and re-sending the last 300 lines every time
1017
+ * is what made an open terminal the most expensive thing on the relay. The
1018
+ * read above stays as wide as before (it is a local read), but the wire now
1019
+ * carries just the new lines; when nothing changed the reply is an empty
1020
+ * string. `appended` tells the page whether to append or replace, and a jumped
1021
+ * `lineBegin` (ring buffer overflow) forces a replace so no gap is hidden.
1022
+ */
1023
+ slimTerminal(key, fresh) {
1024
+ const previous = this.terminalCursors.get(key)
1025
+ this.terminalCursors.set(key, fresh.lineEnd)
1026
+ if (!previous || fresh.lineBegin > previous) {
1027
+ return { ...fresh, appended: false }
1028
+ }
1029
+ const skip = Math.max(0, previous - fresh.lineBegin)
1030
+ if (!fresh.text) return { ...fresh, text: '', appended: true }
1031
+ const lines = fresh.text.split('\n')
1032
+ if (skip >= lines.length) return { ...fresh, text: '', appended: true }
1033
+ return { ...fresh, text: lines.slice(skip).join('\n'), appended: true }
1008
1034
  }
1009
1035
 
1010
1036
  async terminalSend(params) {
package/src/panel.js CHANGED
@@ -89,9 +89,17 @@ export async function registerPanel(ctx, service) {
89
89
  kind: 'exact',
90
90
  path: '/relay-bridge/panel',
91
91
  handler: async (req, res) => {
92
- // Same ticket policy as /relay-bridge/api: relayed requests already
93
- // carry a ticket; a local page load mints one so its write actions
94
- // authenticate.
92
+ // Desktop-only. This page hands out the pairing entry URL, which embeds
93
+ // the device PSK (`#k=`); a relayed request is a phone, and a phone that
94
+ // could read it would be able to pair unlimited extra devices. The pump
95
+ // marks relayed requests, so this cannot be forged by the client.
96
+ if (req.headers['x-dsh-relay']) {
97
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
98
+ res.end('dsh-relay: the management panel is desktop-only. Open it on 127.0.0.1.\n')
99
+ return
100
+ }
101
+ // Same ticket policy as /relay-bridge/api: a local page load mints one so
102
+ // its write actions authenticate.
95
103
  if (!service.pairing.sessionForHeaders(req.headers)) {
96
104
  if (!isLoopback(req.socket?.remoteAddress ?? '')) {
97
105
  res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' })
@@ -117,6 +125,13 @@ export async function registerPanel(ctx, service) {
117
125
  res.end(JSON.stringify(value))
118
126
  }
119
127
 
128
+ // Desktop-only, for the same reason as the page: the snapshot contains
129
+ // the pairing URL with the device PSK, and the relay key can be changed
130
+ // from here.
131
+ if (req.headers['x-dsh-relay']) {
132
+ return reply(403, { ok: false, error: 'desktop-only' })
133
+ }
134
+
120
135
  // Pairing-ticket gate. Requests arriving through the relay already
121
136
  // carry a valid ticket (the pump refuses ticketless traffic), so this
122
137
  // check only ever fires for DIRECT local access — the local user's own
package/src/pump.js CHANGED
@@ -365,6 +365,17 @@ export class PumpManager {
365
365
  return
366
366
  }
367
367
 
368
+ // The plugin's own console is a desktop tool: it prints the pairing URL
369
+ // (which carries the device PSK) and can change the relay key. A phone that
370
+ // reached it would be able to pair unlimited extra devices, so relayed
371
+ // requests never see it, ticketed or not.
372
+ if (stream.relay && info.path.startsWith('/relay-bridge/')) {
373
+ auditLog(`relayed request to the management panel blocked: ${info.path}`)
374
+ this.respondInline(sid, 403, 'Forbidden', 'dsh-relay-phone: the management panel is desktop-only\n')
375
+ this.closeStream(sid)
376
+ return
377
+ }
378
+
368
379
  if (!session) {
369
380
  auditLog(`unauthenticated ${info.method} ${info.path} -> pairing page`)
370
381
  this.respond(sid, 200, 'text/html; charset=utf-8',
@@ -530,10 +541,12 @@ export class PumpManager {
530
541
  if (lower.startsWith('connection:')) continue
531
542
  if (lower.startsWith('accept-encoding:')) continue
532
543
  if (lower.startsWith('x-relay-enc:') || lower.startsWith('x-relay-ct:')) continue
544
+ if (lower.startsWith('x-dsh-relay:')) continue // the pump sets its own
533
545
  headers.push(line)
534
546
  }
535
547
  headers.unshift(`Host: ${target.authority}`)
536
548
  headers.unshift(`Origin: ${target.origin}`)
549
+ headers.push('x-dsh-relay: 1')
537
550
  if (authCookie) headers.push(`Cookie: ${authCookie}`)
538
551
  headers.push(`Content-Type: ${info.headers.get('x-relay-ct') ?? 'text/plain;charset=UTF-8'}`)
539
552
  headers.push(`Content-Length: ${plain.length}`)
@@ -603,6 +616,8 @@ export class PumpManager {
603
616
  hasHost = true
604
617
  } else if (/^origin:/i.test(line)) {
605
618
  rewritten.push(`Origin: ${target.origin}`)
619
+ } else if (/^x-dsh-relay:/i.test(line)) {
620
+ // Dropped: the pump adds its own marker below.
606
621
  } else if (/^cookie:/i.test(line)) {
607
622
  // The phone's relay ticket authenticates it to the pump, not to DSH;
608
623
  // upstream speaks the browser-session credential instead.
@@ -621,6 +636,10 @@ export class PumpManager {
621
636
  }
622
637
  if (!hasHost) rewritten.unshift(`Host: ${target.authority}`)
623
638
  if (!isUpgrade && !hasConnection) rewritten.push('Connection: close')
639
+ // Mark the request as relayed. The plugin's own console uses this to refuse
640
+ // phone-side access: it hands out pairing links, which embed the device PSK.
641
+ // Any inbound copy was dropped in the loop above, so a client cannot forge it.
642
+ rewritten.push('x-dsh-relay: 1')
624
643
  if (authCookie) rewritten.push(`Cookie: ${authCookie}`)
625
644
  const out = `${lines[0]}\r\n${rewritten.join('\r\n')}\r\n\r\n`
626
645
 
@@ -12,6 +12,16 @@ import { auditLog } from './identity.js'
12
12
  const CONNECT_TIMEOUT = 15000
13
13
  const HEARTBEAT_INTERVAL = 20000
14
14
 
15
+ // Reasons a multi-tenant relay can reject a registration, in words the panel
16
+ // and the phone can show without translation.
17
+ const REJECT_TEXT = {
18
+ 'quota-exceeded': '今日流量已用完,明天 0 点恢复',
19
+ 'key-disabled': '这个 relay 密钥已被停用',
20
+ 'unknown-key': 'relay 不认识这个密钥(可能已失效)',
21
+ 'replaced': '同样的密钥在另一处接入,这一条被中继断开',
22
+ 'bad key proof': '密钥不正确',
23
+ }
24
+
15
25
  export class RelayClient extends EventTarget {
16
26
  constructor(options) {
17
27
  super()
@@ -140,7 +150,15 @@ export class RelayClient extends EventTarget {
140
150
  }, HEARTBEAT_INTERVAL)
141
151
  ws.on('pong', () => { this.pongMisses = 0 })
142
152
 
143
- ws.send(JSON.stringify({ t: 'hello', v: 1, id: this.identity.id, gate: this.identity.gate }))
153
+ // keyId lets a multi-tenant relay find the tenant without being told the
154
+ // key: it is the same hash both sides derive, so nothing new to configure.
155
+ ws.send(JSON.stringify({
156
+ t: 'hello',
157
+ v: 1,
158
+ id: this.identity.id,
159
+ gate: this.identity.gate,
160
+ keyId: crypto.createHash('sha256').update(String(this.relayKey)).digest('hex').slice(0, 12),
161
+ }))
144
162
  })
145
163
 
146
164
  ws.on('message', (data, isBinary) => {
@@ -155,11 +173,18 @@ export class RelayClient extends EventTarget {
155
173
  }
156
174
  if (msg.t === 'registered') {
157
175
  this.backoff = 1000
176
+ this.backoffCap = 30000
158
177
  this.emitStatus('connected', url)
159
178
  return
160
179
  }
161
180
  if (msg.t === 'rejected') {
162
- this.emitStatus('rejected', msg.reason ?? 'rejected by relay')
181
+ const reason = String(msg.reason ?? 'rejected by relay')
182
+ this.emitStatus('rejected', REJECT_TEXT[reason] ?? reason)
183
+ // Retrying fast cannot fix these: back off hard so a suspended or
184
+ // exhausted key does not hammer the relay all day.
185
+ if (reason === 'quota-exceeded') this.slowDown(5 * 60 * 1000)
186
+ else if (reason === 'key-disabled' || reason === 'unknown-key') this.slowDown(15 * 60 * 1000)
187
+ else if (reason === 'replaced') this.slowDown(60 * 1000)
163
188
  return
164
189
  }
165
190
  if (msg.t === 'close' && typeof msg.sid === 'number') {
@@ -184,11 +209,18 @@ export class RelayClient extends EventTarget {
184
209
  scheduleReconnect(detail) {
185
210
  if (this.stopped) return
186
211
  this.emitStatus('reconnecting', detail)
187
- const delay = Math.min(30000, this.backoff) * (0.75 + Math.random() * 0.5)
188
- this.backoff = Math.min(30000, this.backoff * 2)
212
+ const cap = this.backoffCap ?? 30000
213
+ const delay = Math.min(cap, this.backoff) * (0.75 + Math.random() * 0.5)
214
+ this.backoff = Math.min(cap, this.backoff * 2)
189
215
  this.reconnectTimer = setTimeout(() => this.connect(), delay)
190
216
  }
191
217
 
218
+ /** Widen the retry window after a rejection that retrying cannot fix. */
219
+ slowDown(delayMs) {
220
+ this.backoffCap = delayMs
221
+ this.backoff = Math.max(this.backoff, delayMs)
222
+ }
223
+
192
224
  // -- data -----------------------------------------------------------------
193
225
  sendStream(sid, chunk) {
194
226
  if (!this.connected()) return false