dsh-plugin-mobile-link 0.6.5 → 0.7.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.
package/lib/routes.js CHANGED
@@ -94,6 +94,7 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
94
94
  const guard = makeGuard(ctx, logger)
95
95
  const STATUS_ROUTE = '/mobile-link/status'
96
96
  const PAIR_ROUTE = '/mobile-link/pair-code'
97
+ const REGENERATE_ROUTE = '/mobile-link/pair-code/regenerate'
97
98
  const disposers = []
98
99
 
99
100
  disposers.push(ctx.webServer.register({
@@ -131,10 +132,14 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
131
132
  sendJson(res, 400, { ok: false, message: 'invalid JSON body' })
132
133
  return
133
134
  }
134
- const ttlMs = ttlFrom(body.ttlMs, Number(config.pairTtlMs) || 10 * 60 * 1000)
135
+ // 032:调用方**没提** ttlMs 就要一张持久码(面板走这条路);显式给了才限时,
136
+ // 那是老行为,留给还在用的调用方。
137
+ const wantsTtl = Object.prototype.hasOwnProperty.call(body, 'ttlMs')
138
+ const ttlMs = wantsTtl ? ttlFrom(body.ttlMs, Number(config.pairTtlMs) || 10 * 60 * 1000) : null
135
139
  try {
136
140
  const minted = await agent.mintPairCode({ ttlMs })
137
- logger.info?.(`mobile-link: minted a pairing code for ${minted.agentId}`)
141
+ logger.info?.(`mobile-link: minted a ${minted.persistent ? 'persistent' : 'time-limited'}`
142
+ + ` pairing code for ${minted.agentId}`)
138
143
  // The picture travels with the code. It used to be a route of its own
139
144
  // (`GET /mobile-link/qr`) that a person opened in a browser; the pairing
140
145
  // entry is now the connector's own panel in the DSH client, so the one
@@ -148,5 +153,32 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
148
153
  },
149
154
  }))
150
155
 
156
+ disposers.push(ctx.webServer.register({
157
+ kind: 'exact',
158
+ path: REGENERATE_ROUTE,
159
+ handler: async (req, res) => {
160
+ if (guard(req, res, { requireFence: true })) return
161
+ if (req.method !== 'POST') {
162
+ res.statusCode = 405
163
+ res.setHeader('allow', 'POST')
164
+ res.end()
165
+ return
166
+ }
167
+ const body = await readJsonBody(req)
168
+ if (body === undefined) {
169
+ sendJson(res, 400, { ok: false, message: 'invalid JSON body' })
170
+ return
171
+ }
172
+ try {
173
+ const minted = await agent.regeneratePairCode()
174
+ logger.info?.(`mobile-link: regenerated the pairing code for ${minted.agentId}`
175
+ + ` (revoked ${minted.revoked})`)
176
+ sendJson(res, 200, { ok: true, ...minted, svg: qrSvg(minted.qrPayload) })
177
+ } catch (error) {
178
+ sendJson(res, 502, { ok: false, code: 'relay/unavailable', message: String(error?.message ?? error) })
179
+ }
180
+ },
181
+ }))
182
+
151
183
  return disposers
152
184
  }
@@ -12,25 +12,33 @@
12
12
  */
13
13
 
14
14
  import { deflateRawSync } from 'node:zlib'
15
+ import { createHash } from 'node:crypto'
15
16
 
16
17
  /** 方法名(设备发起,中转不校验方法名,所以不需要中转改动)。 */
17
18
  export const SESSION_DIGEST_METHOD = '_link/sessionDigest'
18
19
 
19
20
  /** 会话项里剔掉的字段:零读取点(`agentAvailable` 1.8%)。 */
20
- export const DROPPED_ITEM_KEYS = Object.freeze(['agentAvailable'])
21
+ // `agentAvailable` 曾是"零读取点",018 之后列表行要用它区分"会话是否还有活实例"
22
+ // (`ActivityStore` 与 `session.activity.*`),因此**不再剔除**。留空数组是为了保住
23
+ // 「剔除机制 + 逐字节稳定」这两条不变式;将来要剔字段仍从这里加。
24
+ export const DROPPED_ITEM_KEYS = Object.freeze([])
21
25
 
22
26
  /**
23
- * 投影键里剔掉的字段(合计 56.7%)。
27
+ * 投影键里剔掉的字段(合计约 55%)。
24
28
  *
25
29
  * 每一条都能在 App/DSHKit 里复查"零读取点":
26
30
  * `turnOutline` 33.4%(每轮 prompt/response 原文)、`imageLimits` 6.2%(上传限制)、
27
31
  * `sessionStats` 4.2%、`todos` 2.6%、`subagentCatalog` 2.6%、`contextBreakdown` 2.5%、
28
32
  * `sessionListMetadata` 1.9%(唯一被读的 `blank` 在顶层已有同名字段)、
29
- * `subagentTiming` 1.5%、`agentPreset` 0.8%(`SessionCreateRequest.agentPreset` 是请求字段)。
33
+ * `agentPreset` 0.8%(`SessionCreateRequest.agentPreset` 是请求字段)。
34
+ *
35
+ * **2026-10-20 起不再剔除 `subagentTiming`**(原 1.5%):018 的活动可见性靠它判断
36
+ * "某子会话当前是否在跑"与已跑时长;剔除后手机永远看不到「N 智能体」。
37
+ * 同理 `agentAvailable` 也不再剔除。代价是摘要通道多约 1.5% 字节,换来功能可用。
30
38
  */
31
39
  export const DROPPED_PROJECTION_KEYS = Object.freeze([
32
40
  'turnOutline', 'imageLimits', 'sessionStats', 'todos', 'subagentCatalog',
33
- 'contextBreakdown', 'sessionListMetadata', 'subagentTiming', 'agentPreset',
41
+ 'contextBreakdown', 'sessionListMetadata', 'agentPreset',
34
42
  ])
35
43
 
36
44
  /**
@@ -115,3 +123,50 @@ export function encodeDigest(value, encoding) {
115
123
  rawBytes: raw.length,
116
124
  }
117
125
  }
126
+
127
+ /** 单条会话的稳定指纹:内容不变则指纹不变(用于增量比较)。 */
128
+ export function digestFingerprint(item) {
129
+ try {
130
+ return createHash('sha1').update(JSON.stringify(item)).digest('hex').slice(0, 16)
131
+ } catch {
132
+ return 'unhashable'
133
+ }
134
+ }
135
+
136
+ /** 一批会话的指纹表(id → 指纹)。 */
137
+ export function digestFingerprints(items) {
138
+ const map = new Map()
139
+ for (const item of items ?? []) {
140
+ if (item && typeof item.id === 'string') map.set(item.id, digestFingerprint(item))
141
+ }
142
+ return map
143
+ }
144
+
145
+ /** 指纹表的版本:指纹集合不变则版本不变。 */
146
+ export function fingerprintsVersion(map) {
147
+ const keys = [...map.keys()].sort()
148
+ const digest = createHash('sha1')
149
+ for (const key of keys) digest.update(key).update('\u0000').update(map.get(key)).update('\u0001')
150
+ return digest.digest('hex').slice(0, 16)
151
+ }
152
+
153
+ /**
154
+ * 相对上一次指纹表的增量。
155
+ *
156
+ * 入参 `previous` 为 Map 或 null;返回 changed(新增或内容变化的项)、
157
+ * removed(消失的 id)与新版本。`previous` 为空时调用方应按全量处理。
158
+ */
159
+ export function digestDelta(previous, items) {
160
+ const list = Array.isArray(items) ? items : []
161
+ const next = digestFingerprints(list)
162
+ const changed = []
163
+ for (const item of list) {
164
+ if (!item || typeof item.id !== 'string') continue
165
+ if (!previous || previous.get(item.id) !== next.get(item.id)) changed.push(item)
166
+ }
167
+ const removed = []
168
+ if (previous) {
169
+ for (const id of previous.keys()) if (!next.has(id)) removed.push(id)
170
+ }
171
+ return { changed, removed, version: fingerprintsVersion(next), fingerprints: next }
172
+ }
package/lib/state.js CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * The file holds the long-lived `agentId` / `agentSecret` for this computer.
5
5
  * There are two ways it gets there, and they are deliberately indistinguishable
6
- * afterwards: the relay operator provisions it by hand (relay/admin.py
7
- * `agent-register --write-config`), or the person running the computer redeems
6
+ * afterwards: the relay operator provisions it by hand (`dsh-relay admin
7
+ * agent-register --write-config`), or the person running the computer redeems
8
8
  * an invite code themselves (`dsh-mobile-link enroll`, see enroll.js).
9
9
  * Environment variables and the plugin config block override the file, which is
10
10
  * how the integration test injects a throwaway identity.
@@ -109,7 +109,7 @@ export async function resolveIdentity({ stateFile, agentId, agentSecret, relayUr
109
109
  `mobile-link: this computer is not registered yet. Run "dsh-mobile-link enroll`
110
110
  + ` --invite <code> --relay <relay-url>" with the invite code you were given, or set`
111
111
  + ` ${INVITE_ENV} and restart DSH. (Provisioning by hand is still possible:`
112
- + ` "python3 relay/admin.py agent-register --account <acc> --name <name>`
112
+ + ` "dsh-relay admin agent-register --account <acc> --name <name>`
113
113
  + ` --write-config ${path}".)`,
114
114
  { stateFile: path },
115
115
  )
package/lib/status.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * 函数只读 agent 的字段,不持有状态,因此可以直接被单测调用。
7
7
  */
8
8
 
9
- import { SERVER_CAPABILITIES, SERVER_VERSION } from './hello.js'
9
+ import { SERVER_CAPABILITIES, SERVER_VERSION, hostVersion } from './hello.js'
10
10
 
11
11
  const PROTOCOL_VERSION = 1
12
12
 
@@ -93,6 +93,9 @@ export function statusSnapshot(agent) {
93
93
  // Same facts as `_link/hello`, which is the channel the app actually uses:
94
94
  // this route only exists on the direct path.
95
95
  serverVersion: SERVER_VERSION,
96
+ // 宿主 DSH 的版本(`null` = 这个宿主没把版本放进环境)。与 `serverVersion`
97
+ // 是两个命名空间:前者是连接器,后者是跑它的 DSH。
98
+ hostVersion: hostVersion(),
96
99
  capabilities: SERVER_CAPABILITIES,
97
100
  enroll: {
98
101
  // A phone (or the user's browser) can read this to find out that the
package/lib/unicode.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * UTF-16 安全边界:落单代理项修复与按码元安全截断。
3
+ *
4
+ * 为什么需要(2026-10-17 事故的同一个根):
5
+ *
6
+ * JS 字符串按 UTF-16 码元索引。凡是 `text.slice(0, n)` 这类截断,n 落在代理对
7
+ * 中间时就会留下**落单的高代理项**(`\ud83d`),序列化成 JSON 后是 `"\ud83d…"`。
8
+ * 这种帧不是合法 Unicode:中转要按 UTF-8 计费,编码直接抛错(Python 版当年就是
9
+ * 这么把连接打死的);iOS 的 `JSONDecoder` 会整条拒绝(`Missing low code point
10
+ * in surrogate pair`)。中转现在会用 U+FFFD 修好再转发,但**生产端不该发出这种
11
+ * 帧**——它是本连接器的出口,帧有效性归这里。
12
+ *
13
+ * 两件事分开:
14
+ * 1. `repairLoneSurrogates`:出口兜底。帧里已经有落单代理项时替换成 U+FFFD,
15
+ * 与中转的修复口径一致;
16
+ * 2. `truncateSafe` / `sliceRangeSafe`:自己截断时就不制造落单代理项(预览、
17
+ * 搜索片段、diff 正文)。兜底只防别人的数据,自己造出来的必须从源头修。
18
+ */
19
+
20
+ /** JSON 转义里的四位十六进制(大小写都认)。 */
21
+ const HEX_UNIT = /^[0-9a-fA-F]{4}$/
22
+
23
+ function hexUnitAt(text, at) {
24
+ if (at + 4 > text.length) return -1
25
+ const raw = text.slice(at, at + 4)
26
+ return HEX_UNIT.test(raw) ? Number.parseInt(raw, 16) : -1
27
+ }
28
+
29
+ /** 这个反斜杠本身是不是被转义了(前面有奇数个连续反斜杠)。 */
30
+ function isEscapedBackslash(text, index) {
31
+ let count = 0
32
+ for (let i = index - 1; i >= 0 && text[i] === '\\'; i -= 1) count += 1
33
+ return count % 2 === 1
34
+ }
35
+
36
+ function isHighUnit(unit) {
37
+ return unit >= 0xd800 && unit <= 0xdbff
38
+ }
39
+
40
+ function isLowUnit(unit) {
41
+ return unit >= 0xdc00 && unit <= 0xdfff
42
+ }
43
+
44
+ /**
45
+ * 扫描一段 JSON 文本,把**落单**的 `\uD800`–`\uDFFF` 转义换成 `\ufffd`。
46
+ *
47
+ * 只认真正的转义(`\\ud83d` 是字面量);合法配对原样保留。替换串与转义同为
48
+ * 6 个字符,帧长度不变。返回值带修复条数,调用方据此决定要不要记一条告警。
49
+ *
50
+ * @param {string} text JSON 文本
51
+ * @returns {{ text: string, repaired: number }}
52
+ */
53
+ export function repairLoneSurrogatesWithCount(text) {
54
+ if (typeof text !== 'string') return { text, repaired: 0 }
55
+ // 廉价前置判断:正常帧连 `\ud`/`\uD` 都没有,不做完整扫描。
56
+ if (text.indexOf('\\ud') < 0 && text.indexOf('\\uD') < 0) return { text, repaired: 0 }
57
+
58
+ let out = ''
59
+ let last = 0
60
+ let repaired = 0
61
+ for (let i = 0; i + 5 < text.length; i += 1) {
62
+ if (text[i] !== '\\' || text[i + 1] !== 'u') continue
63
+ if (isEscapedBackslash(text, i)) continue
64
+ const unit = hexUnitAt(text, i + 2)
65
+ if (unit < 0) continue
66
+ if (isHighUnit(unit)) {
67
+ let paired = false
68
+ if (text[i + 6] === '\\' && text[i + 7] === 'u') {
69
+ const next = hexUnitAt(text, i + 8)
70
+ paired = isLowUnit(next)
71
+ }
72
+ if (paired) {
73
+ i += 11 // 整个 `\uXXXX\uXXXX` 是合法配对:跳过 12 个字符
74
+ continue
75
+ }
76
+ } else if (!isLowUnit(unit)) {
77
+ continue
78
+ }
79
+ out += `${text.slice(last, i)}\\ufffd`
80
+ last = i + 6
81
+ repaired += 1
82
+ }
83
+ if (repaired === 0) return { text, repaired: 0 }
84
+ return { text: out + text.slice(last), repaired }
85
+ }
86
+
87
+ /** `repairLoneSurrogatesWithCount` 只取文本的那一半。 */
88
+ export function repairLoneSurrogates(text) {
89
+ return repairLoneSurrogatesWithCount(text).text
90
+ }
91
+
92
+ /**
93
+ * 截到 `end` 个**码元**为止,且不把代理对切开。
94
+ *
95
+ * 与 `text.slice(0, end)` 的差别只有一种:第 `end` 个码元是高代理项时把它让给
96
+ * 后半截(结果最多少 1 个码元),从而不会留下落单的高代理项。
97
+ */
98
+ export function truncateSafe(text, end) {
99
+ if (typeof text !== 'string' || !(end > 0)) return ''
100
+ if (text.length <= end) return text
101
+ const unit = text.charCodeAt(end - 1)
102
+ return text.slice(0, isHighUnit(unit) ? end - 1 : end)
103
+ }
104
+
105
+ /**
106
+ * `text.slice(start, end)` 的安全版本:起点若落在低代理项上就后移一位,终点若
107
+ * 落在高代理项上就前移一位;两端都不会制造落单代理项。
108
+ */
109
+ export function sliceRangeSafe(text, start, end) {
110
+ if (typeof text !== 'string') return ''
111
+ let from = Math.max(0, Math.min(Number(start) || 0, text.length))
112
+ let to = Math.max(from, Math.min(Number(end) || 0, text.length))
113
+ if (isLowUnit(text.charCodeAt(from))) from += 1
114
+ if (isHighUnit(text.charCodeAt(to - 1))) to -= 1
115
+ return text.slice(from, Math.max(from, to))
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-link",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "description": "Put your PC's DeepSeek Harness in your pocket: dials a relay over WSS so the native iOS client reaches this machine even without a public IP.",
5
5
  "keywords": [
6
6
  "dsh",