dsh-mobile-plugin 0.1.99 → 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.
- package/README.md +14 -5
- package/dsh-mobile-credentials/lib/credentials.js +405 -84
- package/lib/client.js +31 -8
- package/lib/http/routing.js +165 -9
- package/lib/impl.js +79 -5
- package/lib/index.js +1 -1
- package/lib/mirror/pusher.cjs +35 -4
- package/lib/mobile/approvals.js +62 -15
- package/lib/mobile/auth.js +46 -26
- package/lib/mobile/credential-upgrade.js +129 -0
- package/lib/mobile/entry-window.js +32 -0
- package/lib/mobile/events.js +14 -4
- package/lib/mobile/extensions.js +279 -0
- package/lib/mobile/history-derive.js +159 -0
- package/lib/mobile/host-bridge/event-synthesis.js +40 -1
- package/lib/mobile/pairing.js +150 -64
- package/lib/mobile/plugin-self.js +187 -0
- package/lib/mobile/push-notify-route.js +37 -0
- package/lib/mobile/push-notify.js +84 -0
- package/lib/mobile/questions.js +75 -10
- package/lib/mobile/relay-control.js +534 -0
- package/lib/mobile/session-cache.js +19 -1
- package/lib/mobile/session-file.js +78 -4
- package/lib/mobile/session-list-shortcut.js +38 -0
- package/lib/mobile/session-model-options.js +3 -1
- package/lib/mobile/session-models.js +15 -3
- package/lib/mobile/session-send.js +21 -3
- package/lib/mobile/session-snapshot.js +12 -11
- package/lib/mobile/sessions.js +20 -0
- package/lib/mobile/stats-collect.cjs +55 -12
- package/lib/mobile/stats-ledger.cjs +140 -21
- package/lib/mobile/stream-registry.js +73 -0
- package/lib/mobile/stream.js +103 -18
- package/lib/mobile/uploads.js +79 -3
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +25 -2
- package/lib/relay/lan-proxy.js +283 -122
- package/lib/relay/protocol.js +4 -2
- package/lib/relay/tunnel.js +20 -14
- package/lib/routes/control-plane.js +135 -9
- package/lib/routes/mobile-api.js +87 -7
- package/lib/shared/e2ee-pair.js +91 -0
- package/lib/shared/e2ee-plain.js +79 -0
- package/lib/shared/e2ee-request.js +331 -0
- package/lib/shared/e2ee.js +173 -0
- package/lib/shared/http-util.js +27 -4
- package/lib/shared/list-signal.js +16 -0
- package/lib/shared/pair-throttle.js +150 -0
- package/lib/shared/sse-write.js +16 -5
- package/lib/shared/via-header.js +65 -0
- package/lib/shell/runtime.js +147 -42
- package/lib/shell/self-check-after-swap.js +93 -0
- package/lib/shell/updater.js +36 -2
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/package.json +3 -2
package/lib/mobile/approvals.js
CHANGED
|
@@ -19,9 +19,12 @@
|
|
|
19
19
|
* 哪条回答通道都不影响它),本桥据此铸一个自己的 rpcId 并把卡推给正在看这个会话的
|
|
20
20
|
* 手机。approvalId 直接用宿主自己的 id —— App 靠它把 `approval/decided` 对上、自动
|
|
21
21
|
* 收掉那张卡。
|
|
22
|
-
* 2. 听 `approval/request` 钩子(**prepend
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* 2. 听 `approval/request` 钩子(**prepend**,见下):把这台手机挂成候选人。手机没在看
|
|
23
|
+
* 这个会话时与下游竞速:下游(桌面窗口 / ACP / 宿主默认的 fail-closed)照旧拿到请求、
|
|
24
|
+
* 卡片照旧出现,手机答得早就用手机的,桌面答得早就用桌面的,输的那张卡由结算帧收掉。
|
|
25
|
+
* 手机**正在看**这个会话时则不转发,由手机独家作答 —— 桌面那张卡挂在被转发的请求上,
|
|
26
|
+
* 本桥没有替它收尾的口子,转发出去、手机先答,桌面那张就永远悬着(2026-10-23,
|
|
27
|
+
* 与提问桥同一条坑)。
|
|
25
28
|
* 3. `/approval/respond` 拿 rpcId 找到挂起项,把结论交回钩子 —— 这才是真正让 agent
|
|
26
29
|
* 继续往下跑的那一步。
|
|
27
30
|
*
|
|
@@ -263,16 +266,23 @@ function bindRequest(entry, request) {
|
|
|
263
266
|
/**
|
|
264
267
|
* 挂起这条请求直到有人给结论。
|
|
265
268
|
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
+
* 传了 `next` 就与下游竞速(手机没在看这个会话时的路径):`next()` 一调,桌面窗口那张卡
|
|
270
|
+
* 照旧出现。手机答得早就用手机的;下游答得早就用下游的 —— 谁先给结论谁算数,输的那张卡
|
|
271
|
+
* 由结算帧收掉。两边都没人答时这里会一直挂着,和今天宿主自己的行为一致(审批本来就等人工)。
|
|
272
|
+
*
|
|
273
|
+
* **不传 `next`(手机正在看这个会话)就是手机独家作答**:不往下游转发,桌面从源头就不弹卡
|
|
274
|
+
* ——桌面那张卡挂在「被转发的请求」上,而本桥没有替那条请求收尾的口子(收尾权在宿主网关
|
|
275
|
+
* 手里),手机答完它只会永远悬着,与提问桥同一条坑(2026-10-23)。独家作答后结论照常走
|
|
276
|
+
* `approval/decided` 落进会话记录,桌面看得到裁决结果,只是没有可点的卡。
|
|
269
277
|
*/
|
|
270
278
|
function holdForPhone(entry, next) {
|
|
271
|
-
const downstream =
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
279
|
+
const downstream = next === undefined
|
|
280
|
+
? new Promise(() => {})
|
|
281
|
+
: Promise.resolve().then(() => next()).then((outcome) => {
|
|
282
|
+
const value = DECISIONS.has(outcome) ? outcome : 'unavailable';
|
|
283
|
+
settle(entry, value);
|
|
284
|
+
return value;
|
|
285
|
+
});
|
|
276
286
|
return Promise.race([entry.wait.then((result) => result.outcome), downstream]);
|
|
277
287
|
}
|
|
278
288
|
|
|
@@ -286,7 +296,9 @@ function onApprovalRequest(request, next) {
|
|
|
286
296
|
reason: typeof request.reason === 'string' ? request.reason : undefined,
|
|
287
297
|
}, { unboundOnly: true });
|
|
288
298
|
bindRequest(entry, request);
|
|
289
|
-
|
|
299
|
+
// 手机正在看这个会话就不往下游转发(不传 next):桌面从源头不弹卡,由手机独家作答。
|
|
300
|
+
// 没有观众时照旧竞速,纯桌面使用一字不改。
|
|
301
|
+
return holdForPhone(entry, watcherCount(sessionId) > 0 ? undefined : next);
|
|
290
302
|
}
|
|
291
303
|
|
|
292
304
|
/**
|
|
@@ -315,6 +327,10 @@ function installApprovalBridge(ctx) {
|
|
|
315
327
|
*
|
|
316
328
|
* 先查本桥的挂起项;查不到就交回 `ctx.apiProxy.respond`(0.1.x 宿主那份真 apiProxy 才是
|
|
317
329
|
* 它的归宿;0.2.x 的兼容桥会如实回 `host-unsupported`,于是落到下面那条 409)。
|
|
330
|
+
*
|
|
331
|
+
* `respond` 的返回形态两条通道不同(0.1.x 是 Promise、0.2.x 兼容桥是同步对象),
|
|
332
|
+
* 所以这里不能直接 `.catch()` —— 同步对象上没有这个方法,会抛 TypeError 被路由守卫
|
|
333
|
+
* 兜成 500。详见 `mobile/questions.js` 的 `respondViaLegacyProxy`,两处同一口径。
|
|
318
334
|
*/
|
|
319
335
|
async function handleApprovalRespond(ctx, req, res) {
|
|
320
336
|
const body = await readJsonBody(req);
|
|
@@ -330,17 +346,48 @@ async function handleApprovalRespond(ctx, req, res) {
|
|
|
330
346
|
settle(entry, body.outcome);
|
|
331
347
|
return sendJson(res, 200, { ok: true, accepted: true });
|
|
332
348
|
}
|
|
333
|
-
const receipt = await ctx
|
|
349
|
+
const receipt = await respondViaLegacyProxy(ctx, {
|
|
334
350
|
type: 'client-response',
|
|
335
351
|
rpcId: body.rpcId,
|
|
336
352
|
result: { ok: true, value: { sessionId: body.sessionId, approvalId: body.approvalId, outcome: body.outcome } },
|
|
337
|
-
})
|
|
353
|
+
});
|
|
338
354
|
if (!receipt || receipt.accepted !== true) {
|
|
339
|
-
|
|
355
|
+
// 本桥不认识、旧通道也不认:这条审批在**我们这一侧**已经死了(挂起项只活在进程内,
|
|
356
|
+
// 插件重载 / 宿主重启就没了)。
|
|
357
|
+
//
|
|
358
|
+
// 与提问桥同一口径(见 questions.js 同位置):**不推结算帧**。手机端要靠这张卡把
|
|
359
|
+
// 失败原因写出来(`ApprovalPanel` 的 error 行),卡一收掉就跟着卸载,那句话没地方落。
|
|
360
|
+
return sendJson(res, 409, {
|
|
361
|
+
ok: false,
|
|
362
|
+
error: {
|
|
363
|
+
code: 'not-pending',
|
|
364
|
+
message: '这条审批已经不在等待结论(插件重载或宿主重启后挂起的审批会失效),请回桌面端处理。',
|
|
365
|
+
},
|
|
366
|
+
});
|
|
340
367
|
}
|
|
341
368
|
sendJson(res, 200, { ok: true, accepted: true });
|
|
342
369
|
}
|
|
343
370
|
|
|
371
|
+
/**
|
|
372
|
+
* 把结论交回旧通道的 `ctx.apiProxy.respond`,**Promise 与同步对象两种返回都收**。
|
|
373
|
+
*
|
|
374
|
+
* 两条通道形态不同:0.1.x 的真 apiProxy 回 Promise,0.2.x 的兼容桥
|
|
375
|
+
* (`mobile/host-bridge.js`)回同步的 `{result:{ok:false,error:{code:'host-unsupported'}}}`——
|
|
376
|
+
* 同步对象上没有 `.catch`,早先直接链 Promise 会在 0.2.x 抛 TypeError、被路由守卫兜成 500。
|
|
377
|
+
* 抛错与「通道说不认」一律折算成 null,调用方据此如实回 409。
|
|
378
|
+
*/
|
|
379
|
+
async function respondViaLegacyProxy(ctx, message) {
|
|
380
|
+
const proxy = ctx && ctx.apiProxy;
|
|
381
|
+
if (!proxy || typeof proxy.respond !== 'function') return null;
|
|
382
|
+
try {
|
|
383
|
+
const reply = proxy.respond(message);
|
|
384
|
+
const settled = await reply;
|
|
385
|
+
return settled && typeof settled === 'object' ? settled : null;
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
344
391
|
module.exports = {
|
|
345
392
|
installApprovalBridge,
|
|
346
393
|
handleApprovalRespond,
|
package/lib/mobile/auth.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* dsh-mobile
|
|
4
|
+
* dsh-mobile 鉴权:每设备凭据(T1)查库 + 旧 HMAC token 兜底。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* 新配对只发一枚 `<instanceId>.<exp>.<random>`,它同时是 Bearer、
|
|
7
|
+
* `dsh_lan` cookie 与 `X-Dsh-Device-Credential`。服务端按整串的 sha256 查凭据库,
|
|
8
|
+
* **查到就以记录为准**(吊销 / 过期立即拒绝,不再回退);查不到才回退到旧的
|
|
9
|
+
* `<instanceId>.<exp>.<hmac(lanSecret)>` 无状态令牌 —— 那条只为升级前配过的老 App 保留。
|
|
10
|
+
*
|
|
11
|
+
* 顺序(凭据头 → Bearer → 旧 HMAC)是这里唯一要紧的东西。允许「本机签发的凭据被吊销后
|
|
12
|
+
* 回退去验 HMAC」等于吊销永远不生效:那个 HMAC token 一年有效。
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
const fs = require('node:fs');
|
|
11
|
-
const os = require('node:os');
|
|
12
|
-
const path = require('node:path');
|
|
13
16
|
|
|
14
17
|
const sharedToken = require('../shared/dshtoken');
|
|
15
18
|
const credentials = require('../shared/credentials-entry.js').credentialsEntry();
|
|
@@ -28,7 +31,7 @@ function loadRelayState() {
|
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
/**
|
|
34
|
+
/** 校验旧的无状态移动 token:`<instanceId>.<exp>.<hmac(lanSecret)>`。 */
|
|
32
35
|
function verifyMobileToken(token) {
|
|
33
36
|
if (!token) return false;
|
|
34
37
|
const state = loadRelayState();
|
|
@@ -37,42 +40,59 @@ function verifyMobileToken(token) {
|
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
|
-
*
|
|
43
|
+
* 请求出示的凭据原文,取不到返回 null。
|
|
41
44
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
45
|
+
* 接受**任意非空串**,不再只认 `dshc_` 前缀:协议 v2 的 T1 是 `<实例号>.<exp>.<随机>`,
|
|
46
|
+
* 旧版是 `dshc_…`,升级前的老 App 还有 HMAC Bearer —— 三种都从这里过,由查库结果决定
|
|
47
|
+
* 走哪条路。App 把设备凭据放在独立的 `X-Dsh-Device-Credential` 头里、`Authorization`
|
|
48
|
+
* 留给宿主 token;`Authorization` 分支是给「只有一枚 Bearer 的旧客户端」的兜底。
|
|
44
49
|
*/
|
|
45
50
|
function presentedCredential(req) {
|
|
46
51
|
const raw = req.headers['x-dsh-device-credential'];
|
|
47
52
|
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
48
|
-
if (typeof header === 'string' && header.
|
|
53
|
+
if (typeof header === 'string' && header.trim()) return header.trim();
|
|
49
54
|
const m = /^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '');
|
|
50
|
-
return m
|
|
55
|
+
return m ? m[1] : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 请求是否持有有效凭据。 */
|
|
59
|
+
function authorized(req) {
|
|
60
|
+
return authOutcome(req).ok;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
64
|
+
* 鉴权结果,**带上「为什么被拒」**(§3.8)。
|
|
65
|
+
*
|
|
66
|
+
* 只回 `true/false` 的话,被吊销的手机看到的永远是「请求失败(HTTP 401)」——用户既不知道
|
|
67
|
+
* 自己是被踢了,也不知道该重新扫码。所以判定分三种:通过、被吊销、已过期。
|
|
57
68
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* 吊销就永远不生效,第 2 条也就形同虚设。
|
|
69
|
+
* 通过时顺手 `renew`(§3.9 的滑动续期)并交出记录:装配层的 `onAuthorized` 拿它记
|
|
70
|
+
* 「最近连接」,两件事都只在鉴权通过后才有意义。
|
|
61
71
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* 「公网配对后回到局域网直连」会直接 401。
|
|
72
|
+
* @returns {{ ok: true, record?: object }
|
|
73
|
+
* | { ok: false, reason: 'revoked' | 'expired' | 'unauthorized' }}
|
|
65
74
|
*/
|
|
66
|
-
function
|
|
75
|
+
function authOutcome(req) {
|
|
67
76
|
const presented = presentedCredential(req);
|
|
68
77
|
if (presented) {
|
|
69
78
|
const state = loadRelayState();
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
if (state && state.instanceId) {
|
|
80
|
+
const known = credentials.lookup(presented, state.instanceId);
|
|
81
|
+
if (known) {
|
|
82
|
+
if (known.revokedAt) return { ok: false, reason: 'revoked' };
|
|
83
|
+
if (known.expiresAt && known.expiresAt <= Date.now()) return { ok: false, reason: 'expired' };
|
|
84
|
+
// 还剩不到一半就顺手续期:所有设备会在同一天集体掉线,而这件事完全可以用一次
|
|
85
|
+
// 现成的调用避免 —— 用户不需要为此做任何事。
|
|
86
|
+
try { credentials.renew(known); } catch { /* 续期失败不影响这一次请求 */ }
|
|
87
|
+
return { ok: true, record: known };
|
|
88
|
+
}
|
|
89
|
+
// 查不到就是「本机没签发过它」:可能是中转签的凭据(那条库在中转那边),也可能是
|
|
90
|
+
// 升级前的 HMAC token。两种都往下走旧令牌校验,不在这里判死。
|
|
91
|
+
}
|
|
73
92
|
}
|
|
74
93
|
const m = /^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '');
|
|
75
|
-
|
|
94
|
+
if (m && verifyMobileToken(m[1])) return { ok: true };
|
|
95
|
+
return { ok: false, reason: 'unauthorized' };
|
|
76
96
|
}
|
|
77
97
|
|
|
78
|
-
module.exports = { loadRelayState, presentedCredential, verifyMobileToken, authorized };
|
|
98
|
+
module.exports = { loadRelayState, presentedCredential, verifyMobileToken, authorized, authOutcome };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 旧凭据原地升级:把协议 v1 的会话(HMAC 令牌 / 只带配对码的 `dshc_`)换成带设备主密钥的
|
|
5
|
+
* T1,让一台早就配好的手机不用重新扫码就能开始加密。
|
|
6
|
+
*
|
|
7
|
+
* **主密钥以手机为准**(2026-10-24 修):手机在本地生成 `deviceKey` 与 `keyId`,这次升级
|
|
8
|
+
* 请求里明文带上,宿主原样存进凭据库、把同一个 `keyId` 回给手机。宿主**不能**自己生成
|
|
9
|
+
* 一把再回标识 —— 那会让两端各拿一把钥匙,升级完手机立刻按 v2 封请求,而宿主用另一把
|
|
10
|
+
* 验签,之后每一条请求都是 `e2ee-bad-signature`,比不升级更糟。
|
|
11
|
+
*
|
|
12
|
+
* 明文上行是一次性的取舍:老会话手上只有旧凭据,既没有二维码里那张票据,也没有任何别的
|
|
13
|
+
* 保密通道(局域网直连与公网 IPv6 直连都是明文 HTTP,中转本来就按「不可信的那一跳」设计)。
|
|
14
|
+
* 不这么做就只能让用户重新扫一次码。升级之后所有流量仍由这把钥匙端到端加密。
|
|
15
|
+
*
|
|
16
|
+
* 换成什么不在本次判断里:拿到完整一套(凭据 + 密钥 + 标识)才回补丁,缺一样就当没升级。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const crypto = require('node:crypto');
|
|
20
|
+
const { sendJson } = require('../shared/http-util.js');
|
|
21
|
+
const { DEVICE_KEY_RE, KEY_ID_RE } = require('../shared/e2ee-pair.js');
|
|
22
|
+
|
|
23
|
+
/** 请求出示的设备凭据:独立头优先,`Authorization` 只用于兼容早期客户端。 */
|
|
24
|
+
function presentedCredential(req) {
|
|
25
|
+
const header = req.headers && req.headers['x-dsh-device-credential'];
|
|
26
|
+
if (typeof header === 'string' && header) return header;
|
|
27
|
+
const match = /^Bearer\s+([^\s]+)$/.exec(String((req.headers && req.headers.authorization) || ''));
|
|
28
|
+
return match ? match[1] : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 手机这次带上来的密钥材料;没带或形状不对都返回 null。 */
|
|
32
|
+
function deviceSecretOf(body) {
|
|
33
|
+
if (!body || typeof body !== 'object') return null;
|
|
34
|
+
const device = body.device && typeof body.device === 'object' ? body.device : {};
|
|
35
|
+
const pick = (key) => {
|
|
36
|
+
const top = body[key];
|
|
37
|
+
if (typeof top === 'string' && top) return top;
|
|
38
|
+
const inner = device[key];
|
|
39
|
+
return typeof inner === 'string' && inner ? inner : '';
|
|
40
|
+
};
|
|
41
|
+
const deviceKey = pick('deviceKey');
|
|
42
|
+
const keyId = pick('keyId');
|
|
43
|
+
if (!deviceKey && !keyId) return null;
|
|
44
|
+
return { deviceKey, keyId };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 处理 `POST /api/mobile/v1/device-credential/upgrade`。
|
|
49
|
+
*
|
|
50
|
+
* @param {object} req
|
|
51
|
+
* @param {object} res
|
|
52
|
+
* @param {{ instanceId: string, store: object, log?: Function }} deps
|
|
53
|
+
*/
|
|
54
|
+
function handleCredentialUpgrade(req, res, deps) {
|
|
55
|
+
const instanceId = String((deps && deps.instanceId) || '');
|
|
56
|
+
const store = deps && deps.store;
|
|
57
|
+
if (!store) return sendJson(res, 503, { ok: false, error: { code: 'unavailable', message: '凭据库不可用' } });
|
|
58
|
+
|
|
59
|
+
const presented = presentedCredential(req);
|
|
60
|
+
const record = presented ? store.lookup(presented, instanceId) : null;
|
|
61
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
62
|
+
const meta = body.device && typeof body.device === 'object' ? body.device : body;
|
|
63
|
+
const deviceId = String(meta.deviceId || meta.id || (record && record.deviceId) || '');
|
|
64
|
+
|
|
65
|
+
const secret = deviceSecretOf(body);
|
|
66
|
+
|
|
67
|
+
// 没带密钥材料:只可能是旧客户端或它没准备好。已经在 T1 上的顺手续期并如实回一句
|
|
68
|
+
// 「没换东西」;还在 v1 上的什么都不动 —— 宿主不代生成密钥(见文件头)。
|
|
69
|
+
if (!secret) {
|
|
70
|
+
if (record && record.deviceKey && record.keyId) {
|
|
71
|
+
store.renew(record);
|
|
72
|
+
return sendJson(res, 200, {
|
|
73
|
+
ok: true,
|
|
74
|
+
upgraded: false,
|
|
75
|
+
proto: 2,
|
|
76
|
+
instanceId,
|
|
77
|
+
token: presented,
|
|
78
|
+
cookie: `dsh_lan=${presented}`,
|
|
79
|
+
deviceCredential: presented,
|
|
80
|
+
keyId: record.keyId,
|
|
81
|
+
deviceId: record.deviceId,
|
|
82
|
+
credentialExpiresAt: record.expiresAt || null,
|
|
83
|
+
serverTime: Date.now(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return sendJson(res, 200, {
|
|
87
|
+
ok: true,
|
|
88
|
+
upgraded: false,
|
|
89
|
+
proto: 1,
|
|
90
|
+
instanceId,
|
|
91
|
+
token: presented || '',
|
|
92
|
+
cookie: presented ? `dsh_lan=${presented}` : '',
|
|
93
|
+
deviceCredential: presented || '',
|
|
94
|
+
deviceId,
|
|
95
|
+
serverTime: Date.now(),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 带了却不合形状:这是实现错了,不是「还没准备好」。写进凭据库会变成一台永远验不过的
|
|
100
|
+
// 设备,所以当场拒掉。
|
|
101
|
+
if (!DEVICE_KEY_RE.test(secret.deviceKey) || !KEY_ID_RE.test(secret.keyId)) {
|
|
102
|
+
return sendJson(res, 400, {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: { code: 'bad-device-secret', message: 'deviceKey / keyId 形状不对' },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const issued = store.issue(instanceId, { ...meta, deviceId }, '', undefined, {
|
|
109
|
+
deviceKey: secret.deviceKey,
|
|
110
|
+
keyId: secret.keyId,
|
|
111
|
+
});
|
|
112
|
+
// 旧记录**不立刻吊销**:无法确认手机真的把新的存下来了,立刻吊销会把一台正常设备锁在
|
|
113
|
+
// 门外。让它自然过期,期间它仍走旧 HMAC 兜底那条路。
|
|
114
|
+
return sendJson(res, 200, {
|
|
115
|
+
ok: true,
|
|
116
|
+
upgraded: true,
|
|
117
|
+
proto: 2,
|
|
118
|
+
instanceId,
|
|
119
|
+
token: issued.credential,
|
|
120
|
+
cookie: `dsh_lan=${issued.credential}`,
|
|
121
|
+
deviceCredential: issued.credential,
|
|
122
|
+
keyId: secret.keyId,
|
|
123
|
+
deviceId: issued.deviceId,
|
|
124
|
+
credentialExpiresAt: issued.expiresAt,
|
|
125
|
+
serverTime: Date.now(),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { handleCredentialUpgrade, presentedCredential, deviceSecretOf };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 入口窗口的取数参数(`GET /session/messages?thin=1`,只对不带 beforeSeq / afterSeq 的
|
|
5
|
+
* 一次性入口窗口生效;往上翻页与增量补拉保持完整)。
|
|
6
|
+
*
|
|
7
|
+
* 宿主 paginate 认 `turnWindow`:给 `minMessages` / `minTurns` 两个下限,它往回跨过至少
|
|
8
|
+
* 这么多条消息、这么多轮 turn/start 之后,停在**一轮的开头**。入口要的正是这个边界 ——
|
|
9
|
+
* 按条数硬切会切在一轮中间,长回合(每步一条 assistant 消息)里用户自己那句话就被挤出
|
|
10
|
+
* 窗口了,进对话看到的只剩这一轮的后半段。
|
|
11
|
+
*
|
|
12
|
+
* `maxMessages` 是宿主的硬上限,量的是**消息条数**(含过程步)。按轮取整时它必须大于
|
|
13
|
+
* 「一轮最多能有多少步」,否则截断点仍落在一轮中间、边界又回到按条数切。
|
|
14
|
+
*
|
|
15
|
+
* 2000 不是随手取的上限:本机 1420 个真实回合量下来,最长一轮 576 条消息、超过 500 的
|
|
16
|
+
* 有 3 个、超过 200 的有 18 个。它只是**上界**,宿主在跨过 `minTurns` 且遇到 turn/start
|
|
17
|
+
* 时就会停,所以正常情况下读的仍是那几十条;只有碰上超长回合才会真往上读到 2000。
|
|
18
|
+
*/
|
|
19
|
+
const ENTRY_THIN_MAX_MESSAGES = 2000;
|
|
20
|
+
const ENTRY_THIN_MIN_TURNS = 6;
|
|
21
|
+
|
|
22
|
+
/** 把入口窗口的取数参数写进宿主请求体(就地修改并返回,调用方只关心副作用)。 */
|
|
23
|
+
function entryWindowPayload(payload, limit) {
|
|
24
|
+
payload.maxMessages = ENTRY_THIN_MAX_MESSAGES;
|
|
25
|
+
payload.turnWindow = {
|
|
26
|
+
minMessages: Math.min(Math.max(limit, 1), ENTRY_THIN_MAX_MESSAGES),
|
|
27
|
+
minTurns: ENTRY_THIN_MIN_TURNS,
|
|
28
|
+
};
|
|
29
|
+
return payload;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { entryWindowPayload, ENTRY_THIN_MAX_MESSAGES, ENTRY_THIN_MIN_TURNS };
|
package/lib/mobile/events.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
'use strict';
|
|
20
20
|
|
|
21
|
+
const streamRegistry = require('./stream-registry.js');
|
|
22
|
+
const { createFrameSealer } = require('./stream.js');
|
|
21
23
|
const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
|
|
22
24
|
const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
|
|
23
25
|
const { desktopViewSignal: sharedDesktopViewSignal } = require('../shared/desktop-view-signal.js');
|
|
@@ -53,10 +55,13 @@ function logDeviceStream(message, error) {
|
|
|
53
55
|
* 返回值与会话流一致:`ok:false` 该收尾,`shed:true` 这条被丢掉(调用方必须断流),
|
|
54
56
|
* `needDrain:true` 写进去了但底下已经写不动。
|
|
55
57
|
*/
|
|
56
|
-
function writeSse(res, payload, frame) {
|
|
58
|
+
function writeSse(res, payload, frame, sealFrame) {
|
|
57
59
|
return sharedWriteSse(res, payload, {
|
|
58
60
|
frame,
|
|
59
61
|
namespace: 'device',
|
|
62
|
+
// 协议 v2 的会话上这条流也逐帧封装:`App` 判定「这条会话是加密的」之后,收到一帧
|
|
63
|
+
// 明文就当畸形帧计数,连着十帧就把整条流判死、退回 15 秒轮询(2026-10-24 现场)。
|
|
64
|
+
sealFrame: typeof sealFrame === 'function' ? sealFrame : null,
|
|
60
65
|
shedWhenBuffered: () => true,
|
|
61
66
|
});
|
|
62
67
|
}
|
|
@@ -133,17 +138,22 @@ function listRelevantPayload(rawPayload) {
|
|
|
133
138
|
* the envelope only provides a stable event identity for future clients.
|
|
134
139
|
*/
|
|
135
140
|
async function handleDeviceStream(ctx, req, res) {
|
|
141
|
+
// 协议 v2:与 /session/stream 一样逐帧封装,头必须在 writeHead 之前声明。
|
|
142
|
+
const sealFrame = createFrameSealer(req);
|
|
136
143
|
try {
|
|
137
144
|
res.writeHead(200, {
|
|
138
145
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
139
146
|
'cache-control': 'no-cache',
|
|
140
147
|
connection: 'keep-alive',
|
|
148
|
+
...(sealFrame ? { 'x-dsh-proto': '2' } : {}),
|
|
141
149
|
});
|
|
142
150
|
} catch { return; }
|
|
143
151
|
|
|
144
152
|
res.write(': connected\n\n');
|
|
145
153
|
const ac = new AbortController();
|
|
146
|
-
|
|
154
|
+
// 按设备登记这条流:吊销时宿主主动断它(§3.3)。设备级流同样是「开着就一直收」。
|
|
155
|
+
const untrack = streamRegistry.track(req.__dshRecord && req.__dshRecord.deviceId, res);
|
|
156
|
+
req.on('close', () => { untrack(); ac.abort(); });
|
|
147
157
|
// 写坏掉的连接不该把宿主整条流拖住,也不该让 'error' 在 res 上无人接住而变成未捕获异常。
|
|
148
158
|
// (守卫 on 的存在是为了那些只实现 writeHead/write/end 的最小假体。)
|
|
149
159
|
if (typeof res.on === 'function') res.on('error', () => ac.abort());
|
|
@@ -154,7 +164,7 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
154
164
|
type: 'device/event',
|
|
155
165
|
version: 1,
|
|
156
166
|
payload: devicePayload,
|
|
157
|
-
}, null);
|
|
167
|
+
}, null, sealFrame);
|
|
158
168
|
if (!result.ok) return false;
|
|
159
169
|
// shed 不是成功:不能继续读,否则这一帧代表的变更永远补不回来。
|
|
160
170
|
if (result.shed) return false;
|
|
@@ -291,7 +301,7 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
291
301
|
payload,
|
|
292
302
|
};
|
|
293
303
|
// Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
|
|
294
|
-
const sent = writeSse(res, envelope, frame);
|
|
304
|
+
const sent = writeSse(res, envelope, frame, sealFrame);
|
|
295
305
|
if (sent.shed) { clientGone = true; break; }
|
|
296
306
|
if (!sent.ok || sent.needDrain) { clientGone = true; break; }
|
|
297
307
|
framesSent += 1;
|