dsh-relay-phone 0.18.0 → 0.18.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.18.0",
3
+ "version": "0.18.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",
@@ -246,6 +246,24 @@ export function buildBootSource(id, gate) {
246
246
  }
247
247
 
248
248
  var BOUND = 0x02
249
+
250
+ // The target the host will see, not the URL the browser resolved.
251
+ //
252
+ // DSH calls relative URLs (for example api/session/list), which resolve against
253
+ // the page at /c/<id>/ and come back with the relay prefix attached. The host
254
+ // strips that prefix before comparing, so the sealed value must be stripped
255
+ // too - a prefix the client adds and the host removes is a guaranteed mismatch.
256
+ function boundTarget(absolute) {
257
+ var path = absolute.pathname
258
+ if (PREFIX && path.indexOf(PREFIX) === 0) path = path.slice(PREFIX.length) || '/'
259
+ var search = absolute.search
260
+ if (search) {
261
+ var parts = search.slice(1).split('&').filter(function (part) { return part.indexOf('g=') !== 0 })
262
+ search = parts.length > 0 && parts[0] !== '' ? '?' + parts.join('&') : ''
263
+ }
264
+ return path + search
265
+ }
266
+
249
267
  function sealBound(method, target, contentType, bytes) {
250
268
  var enc = new TextEncoder()
251
269
  var header = enc.encode(JSON.stringify({ m: method, t: target, c: contentType || '' }))
@@ -385,7 +403,7 @@ export function buildBootSource(id, gate) {
385
403
  // with the host cookie.
386
404
  headers.set('x-relay-bind', bytesToBase64(sealBound(
387
405
  options.method || 'GET',
388
- absolute.pathname + absolute.search,
406
+ boundTarget(absolute),
389
407
  '',
390
408
  new Uint8Array(8).fill(0),
391
409
  )))
@@ -404,7 +422,7 @@ export function buildBootSource(id, gate) {
404
422
  // Bind the body to the request it was made for: method, path+query and
405
423
  // content-type travel inside the envelope, so the relay cannot re-route
406
424
  // a captured request to another endpoint.
407
- body: sealBound(options.method || 'GET', absolute.pathname + absolute.search, contentType, bytes),
425
+ body: sealBound(options.method || 'GET', boundTarget(absolute), contentType, bytes),
408
426
  })).then(maybeUnwrap)
409
427
  }).catch(function (error) {
410
428
  // A body-serialization failure must not silently downgrade to plaintext.
package/src/pump.js CHANGED
@@ -721,7 +721,11 @@ export class PumpManager {
721
721
  return index === -1 ? [value, ''] : [value.slice(0, index), value.slice(index + 1)]
722
722
  }
723
723
  const [outerPath, outerQuery] = splitTarget(info.target ?? info.path)
724
- const [innerPath, innerQuery] = splitTarget(String(bound.t))
724
+ // A browser that resolved a relative URL against the page seals the relay
725
+ // prefix too (`/c/<id>/api/x`); strip it the same way the router does. The
726
+ // sealed value still names the method and path, so this removes no authority.
727
+ const sealedTarget = String(bound.t).replace(/^\/c\/[A-Za-z0-9]{4,16}(?=\/|$)/, '') || '/'
728
+ const [innerPath, innerQuery] = splitTarget(sealedTarget)
725
729
  if (outerPath !== innerPath) {
726
730
  return `path ${outerPath.slice(0, 60)} != sealed ${innerPath.slice(0, 60)}`
727
731
  }
@@ -988,6 +992,10 @@ export class PumpManager {
988
992
  upstream.setNoDelay(true)
989
993
  upstream.on('connect', () => {
990
994
  upstream.write(out)
995
+ // Frames are NOT flushed here: the host has not answered 101 yet, and a
996
+ // byte written before the switch looks like a pipelined HTTP request to
997
+ // it. Whether the browser's first frame beat the 101 was a race, which is
998
+ // why the mux sometimes sat silent and the page then retried.
991
999
  this.pumpPhoneWs(sid, stream)
992
1000
  })
993
1001
  upstream.on('data', (data) => {
@@ -1001,6 +1009,8 @@ export class PumpManager {
1001
1009
  this.client?.sendStream(sid, stream.wsServerBuf.subarray(0, idx + 4))
1002
1010
  stream.wsServerBuf = stream.wsServerBuf.subarray(idx + 4)
1003
1011
  stream.wsHandshakeDone = true
1012
+ // The switch is real now: release whatever the phone already sent.
1013
+ this.pumpPhoneWs(sid, stream)
1004
1014
  }
1005
1015
  this.pumpServerWs(sid, stream)
1006
1016
  })
@@ -1080,6 +1090,8 @@ export class PumpManager {
1080
1090
  // -- websocket sealing ---------------------------------------------------------------
1081
1091
  pumpPhoneWs(sid, stream) {
1082
1092
  if (!stream.upstream || stream.upstream.destroyed) return
1093
+ // Not before the host answered 101: see the comment at the upgrade handler.
1094
+ if (!stream.wsHandshakeDone) return
1083
1095
  for (;;) {
1084
1096
  const parsed = parseFrame(stream.wsPhoneBuf)
1085
1097
  if (!parsed) return