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/README.md +1 -1
- package/cordis.patch.yml +6 -0
- package/lib/client.js +101 -41
- package/lib/dir-listing.js +167 -0
- package/lib/dlp.js +28 -2
- package/lib/enroll.js +2 -2
- package/lib/files.js +118 -12
- package/lib/git.js +20 -5
- package/lib/hello.js +53 -3
- package/lib/history/compress.js +40 -0
- package/lib/history/coverage.js +21 -0
- package/lib/history/fold-summary.js +472 -0
- package/lib/history/fold.js +184 -0
- package/lib/history/session-snapshot.js +75 -24
- package/lib/history/summary-window.js +168 -0
- package/lib/history.js +243 -16
- package/lib/index.js +29 -0
- package/lib/jobs-roster.js +377 -0
- package/lib/link.js +68 -7
- package/lib/logs.js +242 -0
- package/lib/pairing.js +60 -9
- package/lib/router.js +170 -5
- package/lib/routes.js +34 -2
- package/lib/session-digest.js +59 -4
- package/lib/state.js +3 -3
- package/lib/status.js +4 -1
- package/lib/unicode.js +116 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
}
|
package/lib/session-digest.js
CHANGED
|
@@ -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
|
-
|
|
21
|
+
// `agentAvailable` 曾是"零读取点",018 之后列表行要用它区分"会话是否还有活实例"
|
|
22
|
+
// (`ActivityStore` 与 `session.activity.*`),因此**不再剔除**。留空数组是为了保住
|
|
23
|
+
// 「剔除机制 + 逐字节稳定」这两条不变式;将来要剔字段仍从这里加。
|
|
24
|
+
export const DROPPED_ITEM_KEYS = Object.freeze([])
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
|
-
*
|
|
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
|
-
* `
|
|
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', '
|
|
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
|
|
7
|
-
*
|
|
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
|
-
+ ` "
|
|
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.
|
|
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",
|