dsh-mobile-plugin 0.1.100 → 0.2.0

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.
Files changed (56) hide show
  1. package/README.md +14 -5
  2. package/dsh-mobile-credentials/lib/credentials.js +405 -84
  3. package/lib/client.js +31 -8
  4. package/lib/http/routing.js +165 -9
  5. package/lib/impl.js +79 -5
  6. package/lib/index.js +1 -1
  7. package/lib/mirror/pusher.cjs +35 -4
  8. package/lib/mobile/approvals.js +62 -15
  9. package/lib/mobile/auth.js +46 -26
  10. package/lib/mobile/credential-upgrade.js +129 -0
  11. package/lib/mobile/entry-window.js +32 -0
  12. package/lib/mobile/events.js +14 -4
  13. package/lib/mobile/extensions.js +279 -0
  14. package/lib/mobile/history-derive.js +159 -0
  15. package/lib/mobile/host-bridge/event-synthesis.js +40 -1
  16. package/lib/mobile/pairing.js +150 -64
  17. package/lib/mobile/plugin-self.js +187 -0
  18. package/lib/mobile/push-notify-route.js +37 -0
  19. package/lib/mobile/push-notify.js +84 -0
  20. package/lib/mobile/questions.js +75 -10
  21. package/lib/mobile/relay-control.js +534 -0
  22. package/lib/mobile/session-cache.js +19 -1
  23. package/lib/mobile/session-file.js +78 -4
  24. package/lib/mobile/session-list-shortcut.js +38 -0
  25. package/lib/mobile/session-model-options.js +3 -1
  26. package/lib/mobile/session-models.js +15 -3
  27. package/lib/mobile/session-send.js +21 -3
  28. package/lib/mobile/session-snapshot.js +12 -11
  29. package/lib/mobile/sessions.js +20 -0
  30. package/lib/mobile/stats-collect.cjs +55 -12
  31. package/lib/mobile/stats-ledger.cjs +140 -21
  32. package/lib/mobile/stream-registry.js +73 -0
  33. package/lib/mobile/stream.js +103 -18
  34. package/lib/mobile/uploads.js +79 -3
  35. package/lib/mobile/workspaces.js +39 -8
  36. package/lib/panel/index.html +11 -11
  37. package/lib/relay/core.js +25 -2
  38. package/lib/relay/lan-proxy.js +283 -122
  39. package/lib/relay/protocol.js +4 -2
  40. package/lib/relay/tunnel.js +20 -14
  41. package/lib/routes/control-plane.js +135 -9
  42. package/lib/routes/mobile-api.js +87 -7
  43. package/lib/shared/e2ee-pair.js +91 -0
  44. package/lib/shared/e2ee-plain.js +79 -0
  45. package/lib/shared/e2ee-request.js +331 -0
  46. package/lib/shared/e2ee.js +173 -0
  47. package/lib/shared/http-util.js +27 -4
  48. package/lib/shared/list-signal.js +16 -0
  49. package/lib/shared/pair-throttle.js +150 -0
  50. package/lib/shared/sse-write.js +16 -5
  51. package/lib/shared/via-header.js +65 -0
  52. package/lib/shell/runtime.js +147 -42
  53. package/lib/shell/self-check-after-swap.js +93 -0
  54. package/lib/shell/updater.js +36 -2
  55. package/lib/sidecar-facade-worker.cjs +70 -6
  56. package/package.json +3 -2
@@ -26,6 +26,11 @@ const { lanUrls } = require('./relay/lan-network.cjs')
26
26
  const core = require('./relay/core.js')
27
27
  const { createReconnectScheduler } = require('./reconnect-scheduler.cjs')
28
28
  const { isMirrorSnapshotFresh } = require('./mirror-snapshot.cjs')
29
+ // 凭据库在这个进程里**只读**(§3.7):写盘只有宿主主进程一家。签发与吊销都经 IPC 交给它,
30
+ // 本地这份按文件指纹重载 —— 两个进程各持一份内存副本、各自整份覆写,后写的赢,于是
31
+ // 「经中转配上的手机回局域网 401」「吊销被回滚」这两条必然出现。
32
+ const credentials = require('./shared/credentials-entry.js').credentialsEntry()
33
+ if (typeof credentials.setReadOnly === 'function') credentials.setReadOnly(true)
29
34
 
30
35
  const parentPort = Number(process.env.DSH_SIDECAR_PARENT_PORT)
31
36
  const token = process.env.DSH_SIDECAR_TOKEN
@@ -104,6 +109,28 @@ class BodyTooLarge extends Error {}
104
109
  * 留 64KB 余量给帧头。推得再大也只是这一次 413,隧道安然无恙。
105
110
  */
106
111
  const TUNNEL_SEND_MAX_BODY = 8 * 1024 * 1024 - 64 * 1024
112
+
113
+ /**
114
+ * 推给中转的那一份镜像:只留中转真的会读的字段。
115
+ *
116
+ * 桌面推上来的整份载荷里有四块:`sessions`、`workspaces`、`messages`、`stats`/`models`。
117
+ * 中转(`server/relay/rust/src/mirror.rs` 的 `remember`)只落**前两块**,另外三块
118
+ * 解析完就丢——它只解决「宿主不在线时手机还看得见清单」,会话正文有自己那条
119
+ * `/session/messages` 通道。而正文快照恰好是最大的一块:实测一个 200 条消息的会话
120
+ * 快照约 58KB,而它在每个会话事件上都会被重推一次。
121
+ *
122
+ * 所以**上隧道的那一份裁掉三块,本地快照(localSnapshot)照旧留全份**——局域网直连的
123
+ * 手机走 `snapshotRead` 读的就是本地那份,裁了它等于把读取隔离一起砍掉。
124
+ *
125
+ * 裁字段而不是改推送策略:宿主那侧「推什么、什么时候推」一个字都不用改,两侧版本
126
+ * 不必同时上线(老 worker + 新宿主也照旧工作,只是白推;新 worker + 老宿主也照旧)。
127
+ */
128
+ function frameForTunnel(body) {
129
+ if (!body || typeof body !== 'object') return { kind: 'raw', data: body }
130
+ if (body.kind !== 'sync') return body
131
+ const { messages, stats, models, ...rest } = body
132
+ return rest
133
+ }
107
134
  // /configure 只送 relay.json 那几十个字段,给 64KB 绰绰有余;放宽只会让误用更难发现。
108
135
  const CONFIGURE_MAX_BODY = 64 * 1024
109
136
  function readBody(req, limit) {
@@ -146,6 +173,12 @@ function upstream() { return `http://127.0.0.1:${config.upstreamPort}` }
146
173
  // 读取隔离:把移动端高频读请求(sessions/workspaces/messages/stats)从本地快照直接回,
147
174
  // 不再打扰主进程。快照缺失或路径不在清单内时返回 null → 调用方回源主进程。
148
175
  function snapshotRead(info) {
176
+ // **协议 v2 的会话不走这条捷径**:这里直接回序列化好的 JSON,绕开了主进程的守卫 ——
177
+ // 请求信封没验、响应也没封。表现是「加密会话的高频读(列表 / 工作区 / 消息 / 统计)
178
+ // 全程明文过中转」,而且静默(App 收到明文只是降级,不报错)。回 null 让调用方回源
179
+ // 主进程,那条路有 `rev` 条件刷新,稳态只回几十字节,比这里回 38KB 还省。
180
+ const proto = info && info.headers ? String(info.headers['x-dsh-proto'] || info.headers['X-Dsh-Proto'] || '') : ''
181
+ if (proto.trim() === '2') return null
149
182
  // 镜像只在中转连着时才会有新版本推来(原因见 mirror-snapshot.cjs 的说明):冻结的快照必须
150
183
  // 回源主进程,否则中转一断、局域网仍健康时,新建的会话/工作区在 App 里永远不出现。
151
184
  if (!isMirrorSnapshotFresh(localSnapshot)) return null
@@ -160,6 +193,16 @@ function snapshotRead(info) {
160
193
  : null
161
194
  if (!body || typeof body !== 'object' || body.ok !== true) return null
162
195
  if (pathname === '/api/mobile/v1/session/messages') {
196
+ // **按内容裁的入口窗口(`thin=1`)不走这条捷径**:镜像里存的是一份完整窗口
197
+ // (最多 200 条消息),对着一轮跑过上百步的会话,它照样可能整条挤掉用户那句话 ——
198
+ // 那正是「进对话只剩 AI 的半轮」这个缺陷本身。裁剪是主进程按轮算的,这里回 null 让它
199
+ // 回源;入口那一发进对话只发一次,不是轮询。
200
+ if (url.searchParams.get('thin') === '1') return null
201
+ // 带 beforeSeq 的是**翻页**(往上翻历史 / 补取某一轮的过程明细):镜像只有尾部一份窗口,
202
+ // 它答不了「比这条更早」——早年这里直接回整份,调用方按 seq 自己过滤,看着能用,
203
+ // 但只要一轮比镜像窗口还长,补取就会在原地转三圈什么也拿不到。回源主进程。
204
+ const beforeSeqRaw = url.searchParams.get('beforeSeq')
205
+ if (beforeSeqRaw !== null && beforeSeqRaw !== '') return null
163
206
  const afterSeqRaw = url.searchParams.get('afterSeq')
164
207
  if (afterSeqRaw !== null && afterSeqRaw !== '' && /^\d+$/.test(afterSeqRaw)) {
165
208
  const after = Number(afterSeqRaw)
@@ -186,7 +229,9 @@ function tunnelSignature(c) {
186
229
  }
187
230
  /** 「这份配置里哪几项决定局域网代理要重建」。 */
188
231
  function lanSignature(c) {
189
- return JSON.stringify([Boolean(c.enabled), Boolean(c.lanEnabled), c.lanPort || 0, c.upstreamPort || 0, c.instanceId || '', c.pairingCode || '', c.lanSecret || ''])
232
+ // **配对码与票据不在其中**:它们由 `currentPairing` 现读,改它们不该重建监听 ——
233
+ // 配对成功就要换一次(§5.1),跟着重建会把刚握上的连接掐掉。
234
+ return JSON.stringify([Boolean(c.enabled), Boolean(c.lanEnabled), c.lanPort || 0, c.upstreamPort || 0, c.instanceId || '', c.lanSecret || ''])
190
235
  }
191
236
  /** 「这份配置里哪几项决定网络监视器要不要重排」。 */
192
237
  function monitorSignature(c) {
@@ -237,7 +282,7 @@ function startTunnel() {
237
282
  stopTunnel(); tunnelState = 'connecting'
238
283
  publishState()
239
284
  const current = new RelayTunnel({
240
- relayUrl: core.tunnelWsUrl(config.relayUrl), instanceId: config.instanceId, pairingCode: config.pairingCode,
285
+ relayUrl: core.tunnelWsUrl(config.relayUrl), instanceId: config.instanceId,
241
286
  name: config.name, upstream: upstream(),
242
287
  registerSecret: config.registerSecret,
243
288
  lanUrls: currentLanUrls(),
@@ -275,10 +320,22 @@ function startLan() {
275
320
  stopLan(); lanState = 'connecting'
276
321
  publishState()
277
322
  const current = createLanProxy({
278
- lanPort: config.lanPort, pairingCode: config.pairingCode, instanceId: config.instanceId, lanSecret: config.lanSecret,
323
+ lanPort: config.lanPort, instanceId: config.instanceId, lanSecret: config.lanSecret,
324
+ // 配对码与票据走取值器:配对成功之后宿主会换掉它们(§5.1),而重建局域网代理会把
325
+ // 刚握上的连接全掐掉 —— 换值不该动监听。
326
+ currentPairing: () => ({ code: config.pairingCode, ticket: config.pairingTicket || '' }),
279
327
  upstreamPort: config.upstreamPort,
280
328
  readSnapshot: snapshotRead,
281
329
  onLog: (...args) => process.stderr.write(`[dsh-ai-hub sidecar] ${args.join(' ')}\n`),
330
+ // 配对成功之后换码 + 换票据:走宿主的控制面(那是唯一能写状态文件的地方)。
331
+ onPaired: () => {
332
+ void parentJson('/api/relay/internal/rotate-pairing', {}).catch(() => undefined)
333
+ },
334
+ // 签发交给主进程(唯一写方):旁路自己写盘会和主进程那份互相覆盖。
335
+ issueCredential: async (device, extra) => {
336
+ const result = await parentJson('/api/relay/internal/issue-credential', { device: device || {}, extra: extra || {} })
337
+ return result.data || result
338
+ },
282
339
  onApprove: async (device) => {
283
340
  try {
284
341
  const result = await parentJson('/api/relay/internal/approval', { device: device || {} })
@@ -306,10 +363,11 @@ function startLan() {
306
363
  }
307
364
  async function configure(next) {
308
365
  if (!next || typeof next !== 'object' || Array.isArray(next)) throw new Error('invalid configuration')
309
- const allowed = ['enabled', 'relayUrl', 'instanceId', 'pairingCode', 'name', 'upstreamPort', 'lanPort', 'lanEnabled', 'lanSecret', 'registerSecret']
366
+ const allowed = ['enabled', 'relayUrl', 'instanceId', 'pairingCode', 'pairingTicket', 'name', 'upstreamPort', 'lanPort', 'lanEnabled', 'lanSecret', 'registerSecret']
310
367
  if (Object.keys(next).some(key => !allowed.includes(key))) throw new Error('unknown configuration field')
311
368
  if (typeof next.instanceId !== 'string' || next.instanceId.length > 128) throw new Error('invalid instanceId')
312
369
  if (typeof next.pairingCode !== 'string' || !/^\d{6}$/.test(next.pairingCode)) throw new Error('invalid pairingCode')
370
+ if (next.pairingTicket !== undefined && (typeof next.pairingTicket !== 'string' || !/^[A-Za-z0-9_-]{0,64}$/.test(next.pairingTicket))) throw new Error('invalid pairingTicket')
313
371
  if (typeof next.upstreamPort !== 'number' || !Number.isInteger(next.upstreamPort) || next.upstreamPort < 1 || next.upstreamPort > 65535) throw new Error('invalid upstreamPort')
314
372
  if (next.lanPort !== undefined && (!Number.isInteger(next.lanPort) || next.lanPort < 1024 || next.lanPort > 65535)) throw new Error('invalid lanPort')
315
373
  if (next.registerSecret !== undefined && (typeof next.registerSecret !== 'string' || next.registerSecret.length > 512)) throw new Error('invalid registerSecret')
@@ -333,7 +391,8 @@ async function configure(next) {
333
391
  }
334
392
  if (!tunnelNeedsRebuild && tunnel) {
335
393
  const patch = {}
336
- if (previous.pairingCode !== config.pairingCode) patch.pairingCode = config.pairingCode
394
+ // **配对码不再进注册帧**(§5.2):中转从来没读过它(`ws.rs` 原文「不保存也不校验」),
395
+ // 而带上它就等于把「谁能配这台机器」的一半凭证交给链路上的第三方。
337
396
  if (previous.name !== config.name) patch.name = config.name
338
397
  if (lanNeedsRebuild) patch.lanUrls = currentLanUrls()
339
398
  if (Object.keys(patch).length) tunnel.updateRegistration(patch)
@@ -365,7 +424,12 @@ const server = http.createServer(async (req, res) => {
365
424
  // 桌面→relay 主动推送(移动端镜像缓存):复用隧道立即 sendJson;同时在本地保留快照供读取隔离。
366
425
  if (body && typeof body === 'object' && body.kind === 'sync') localSnapshot = body;
367
426
  if (tunnel && tunnel.connected) {
368
- tunnel.sendJson(T.sync, 0, body && typeof body === 'object' ? body : { kind: 'raw', data: body });
427
+ // kind === 'push' 是 iOS 推送帧(pushRegister / pushSend),帧类型由 pushFrame
428
+ // 决定,不再一律塞进 sync——中转按帧类型分发,塞错类型等于发到黑洞。
429
+ const frameType = body && typeof body === 'object' && body.kind === 'push'
430
+ ? (body.pushFrame === 'register' ? T.pushRegister : T.pushSend)
431
+ : T.sync;
432
+ tunnel.sendJson(frameType, 0, frameForTunnel(body));
369
433
  return send(res, 202, { ok: true });
370
434
  }
371
435
  return send(res, 503, { ok: false, error: 'tunnel not connected' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.100",
3
+ "version": "0.2.0",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,7 +42,8 @@
42
42
  },
43
43
  "scripts": {
44
44
  "start": "node host.js",
45
- "test": "node --test test/*.test.cjs && node test/lan-direct-e2e.cjs && node test/sidecar-facade.cjs"
45
+ "test": "node --test test/*.test.cjs && node test/lan-direct-e2e.cjs && node test/sidecar-facade.cjs",
46
+ "release:check": "node scripts/preflight-release.mjs"
46
47
  },
47
48
  "dependencies": {
48
49
  "@deepseek-ai/dsh-tool-ask-user": "0.2.1-rc.2",