dsh-jiuling-remote-mobile 1.0.20 → 1.0.24
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/client.js +305 -13
- package/lib/index.js +424 -42
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -305,6 +305,78 @@ window.__ModuleLoader__.load({
|
|
|
305
305
|
*/
|
|
306
306
|
var mirrorSlugs = {};
|
|
307
307
|
|
|
308
|
+
/**
|
|
309
|
+
* 「用户刚刚在新建对话界面选中的那个工作区」的 cwd —— 只在**会话行还没带上
|
|
310
|
+
* cwd** 的那一小段时间里当兜底判据用。
|
|
311
|
+
*
|
|
312
|
+
* ⚠️⚠️ **为什么需要它(用户报障,2026-10-17 真机复现)**:
|
|
313
|
+
*
|
|
314
|
+
* 在**新建对话界面**(还没有任何消息)切到电脑工作区时,`sessions.list`
|
|
315
|
+
* 里那一行**还没有 cwd**(会话刚物化,宿主没回填),于是
|
|
316
|
+
* `isRemoteSession()` 只能退回 `remoteSessionIds[sessionId]` —— 而新建的
|
|
317
|
+
* 本地壳会话**不在里面**,结论是「本地」。后果:
|
|
318
|
+
*
|
|
319
|
+
* - 模型菜单只显示手机的模型(电脑的组被 `pickWorkspaceGroups` 筛掉);
|
|
320
|
+
* - 预设列表同理,仍是手机那份。
|
|
321
|
+
*
|
|
322
|
+
* 用户原话:「我在待选的工作区里面选择了默认工作区,也就是我手机的工作区,
|
|
323
|
+
* 这个预设它没有改回来,模型列表也没有改回来」——反过来切到电脑时也一样。
|
|
324
|
+
*
|
|
325
|
+
* 兜底只影响**这一小段窗口**:一旦列表行带回 cwd,`cwdOf()` 优先,这个值
|
|
326
|
+
* 自动失效(见 `isRemoteSession` 的判据顺序),不会长期歪掉。
|
|
327
|
+
*/
|
|
328
|
+
var pendingWorkspaceCwd = null;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* `pendingWorkspaceCwd` 的**过期时刻**(毫秒时间戳)。
|
|
332
|
+
*
|
|
333
|
+
* ⚠️ 为什么必须有这个:官方工作区下拉菜单走的是
|
|
334
|
+
* `workspaceNavigation.openWorkspace()`(`ConversationContent` 的
|
|
335
|
+
* `selectWorkspace`),**完全不经过本插件** —— 所以「用户切回默认工作区」
|
|
336
|
+
* 这个动作我这边收不到通知,没法在那个时刻清掉 `pendingWorkspaceCwd`。
|
|
337
|
+
*
|
|
338
|
+
* 不设过期的话,一旦某次在电脑工作区设过它、而之后那个会话行的 cwd
|
|
339
|
+
* 又一直读不到,兜底值就会**永久**把「本地会话」判成电脑会话,用户看到
|
|
340
|
+
* 的正是「切回默认工作区,模型和预设都没改回来」。
|
|
341
|
+
*
|
|
342
|
+
* 取 30 秒:足够覆盖「建会话 → 列表行回填 cwd」这一段(真机是几百毫秒),
|
|
343
|
+
* 又不至于让一个陈旧值活太久。
|
|
344
|
+
*/
|
|
345
|
+
var pendingWorkspaceExpiresAt = 0;
|
|
346
|
+
|
|
347
|
+
/** `pendingWorkspaceCwd` 的有效期(毫秒)。 */
|
|
348
|
+
var PENDING_WORKSPACE_TTL = 30000;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 记下「刚选中的工作区」并开始计时。
|
|
352
|
+
*
|
|
353
|
+
* @param cwd - 工作区路径;传 `null`/空串表示**立刻作废**。
|
|
354
|
+
*/
|
|
355
|
+
function setPendingWorkspace(cwd) {
|
|
356
|
+
if (typeof cwd !== 'string' || cwd === '') {
|
|
357
|
+
pendingWorkspaceCwd = null;
|
|
358
|
+
pendingWorkspaceExpiresAt = 0;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
pendingWorkspaceCwd = cwd;
|
|
362
|
+
pendingWorkspaceExpiresAt = Date.now() + PENDING_WORKSPACE_TTL;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 取当前有效的兜底 cwd(过期就当没有)。
|
|
367
|
+
*
|
|
368
|
+
* @returns 有效的 cwd 或 `null`。
|
|
369
|
+
*/
|
|
370
|
+
function currentPendingWorkspace() {
|
|
371
|
+
if (pendingWorkspaceCwd === null) return null;
|
|
372
|
+
if (Date.now() > pendingWorkspaceExpiresAt) {
|
|
373
|
+
pendingWorkspaceCwd = null;
|
|
374
|
+
pendingWorkspaceExpiresAt = 0;
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return pendingWorkspaceCwd;
|
|
378
|
+
}
|
|
379
|
+
|
|
308
380
|
/**
|
|
309
381
|
* 从 cwd 取出镜像工作区的 slug(与宿主 `desktopPathOfMirror` 同构)。
|
|
310
382
|
*
|
|
@@ -331,7 +403,17 @@ window.__ModuleLoader__.load({
|
|
|
331
403
|
* 工作区就是隔离条件本身。解绑后 `mirrorSlugs` 清空 ⇒ 全部回到本地,
|
|
332
404
|
* 不需要再去清每个会话的旧标记。
|
|
333
405
|
*
|
|
334
|
-
* 只有列表行还没带 cwd 的那一两帧(会话刚建好)才退回
|
|
406
|
+
* 只有列表行还没带 cwd 的那一两帧(会话刚建好)才退回
|
|
407
|
+
* `pendingWorkspaceCwd` / `remoteSessionIds`。
|
|
408
|
+
*
|
|
409
|
+
* ⚠️ **判据顺序不能换**:
|
|
410
|
+
* 1. `cwdOf(sessionId)` —— 会话自己的 cwd,最准;
|
|
411
|
+
* 2. `pendingWorkspaceCwd` —— 用户在新建界面刚选的工作区(见它的注释,
|
|
412
|
+
* 这是修「新建界面模型/预设不跟随工作区」的那一条);
|
|
413
|
+
* 3. `remoteSessionIds` —— 宿主显式标记过的会话。
|
|
414
|
+
*
|
|
415
|
+
* 第 2 条只在第 1 条读不到时才生效,所以一旦会话行带回 cwd,
|
|
416
|
+
* 它就自动让位,不会跟真实 cwd 打架。
|
|
335
417
|
*
|
|
336
418
|
* @param sessionId - 会话 id。
|
|
337
419
|
* @param cwdOf - `sessionId → cwd` 的同步读取函数。
|
|
@@ -343,6 +425,12 @@ window.__ModuleLoader__.load({
|
|
|
343
425
|
var slug = mirrorSlugOf(cwd);
|
|
344
426
|
return slug !== undefined && mirrorSlugs[slug] === true;
|
|
345
427
|
}
|
|
428
|
+
// 会话自己的 cwd 还没进列表行 —— 用「刚选中的工作区」兜底(会过期)。
|
|
429
|
+
var pending = currentPendingWorkspace();
|
|
430
|
+
if (pending !== null) {
|
|
431
|
+
var pendingSlug = mirrorSlugOf(pending);
|
|
432
|
+
return pendingSlug !== undefined && mirrorSlugs[pendingSlug] === true;
|
|
433
|
+
}
|
|
346
434
|
return remoteSessionIds[sessionId] === true;
|
|
347
435
|
}
|
|
348
436
|
|
|
@@ -717,22 +805,215 @@ window.__ModuleLoader__.load({
|
|
|
717
805
|
function makeCwdOf(ctx) {
|
|
718
806
|
return function (sessionId) {
|
|
719
807
|
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
808
|
+
var row;
|
|
720
809
|
var sessions = ctx.sessions;
|
|
721
|
-
if (sessions
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
810
|
+
if (sessions !== undefined && sessions !== null) {
|
|
811
|
+
var list = sessions.list;
|
|
812
|
+
if (list !== undefined && list !== null && typeof list.getSnapshot === 'function') {
|
|
813
|
+
var snapshot = list.getSnapshot();
|
|
814
|
+
if (snapshot !== undefined && snapshot !== null) {
|
|
815
|
+
var byId = snapshot.byId;
|
|
816
|
+
if (byId !== undefined && byId !== null) row = byId[sessionId];
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (row !== undefined && row !== null) {
|
|
821
|
+
var cwd = row.cwd;
|
|
822
|
+
if (typeof cwd === 'string' && cwd !== '') return cwd;
|
|
823
|
+
}
|
|
824
|
+
// ⚠️ **会话行没有 cwd(甚至整行都还没出现)时,退回「它所属工作区的路径」。**
|
|
825
|
+
//
|
|
826
|
+
// 新建对话界面(还没有消息)那一行常常只有 `workspaceId`、没有 `cwd`:
|
|
827
|
+
// `cwd` 要等宿主把会话真正物化后才回填。这一小段窗口里如果直接
|
|
828
|
+
// 返回 undefined,`isRemoteSession()` 就把「电脑工作区的新会话」
|
|
829
|
+
// 判成本地 ⇒ 模型菜单只显示手机模型、预设也不换。
|
|
830
|
+
//
|
|
831
|
+
// 用户报障原话:「在待选的工作区里面选择了默认工作区…这个预设它没有
|
|
832
|
+
// 改回来,模型列表也没有改回来」——两个方向都是这个原因。
|
|
833
|
+
//
|
|
834
|
+
// 三条退路,先精确后宽松:
|
|
835
|
+
// 1. `pendingWorkspaceCwd` —— 用户刚在新建界面选中的工作区(见其注释);
|
|
836
|
+
// 2. `workspacePathOfSession` —— 按工作区的 `sessionIds` 反查
|
|
837
|
+
// (不依赖会话行本身有没有字段,所以 `row` 缺失也照样能用);
|
|
838
|
+
// 3. `workspacePathOf` —— 按 `row.workspaceId` 直查。
|
|
839
|
+
//
|
|
840
|
+
// 工作区快照是**同步**可读的,所以仍然满足「模型菜单每帧同步读」。
|
|
841
|
+
if (typeof pendingWorkspaceCwd === 'string' && pendingWorkspaceCwd !== '') {
|
|
842
|
+
var pendingNow = currentPendingWorkspace();
|
|
843
|
+
if (pendingNow !== null) return pendingNow;
|
|
844
|
+
}
|
|
845
|
+
var bySession = workspacePathOfSession(ctx, sessionId);
|
|
846
|
+
if (bySession !== undefined) return bySession;
|
|
730
847
|
if (row === undefined || row === null) return undefined;
|
|
731
|
-
|
|
732
|
-
return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
|
|
848
|
+
return workspacePathOf(ctx, row.workspaceId);
|
|
733
849
|
};
|
|
734
850
|
}
|
|
735
851
|
|
|
852
|
+
/**
|
|
853
|
+
* 收集所有可能挂着工作区快照的宿主对象。
|
|
854
|
+
*
|
|
855
|
+
* ⚠️ 用 `ctx.get('workspaces')` 而不是只靠 `ctx.workspaces`:cordis 的
|
|
856
|
+
* `get()` 不受 `inject` 列表约束(`inject` 只影响属性访问的代理),
|
|
857
|
+
* 所以即使将来 `inject` 改了,这条路仍然能拿到服务。
|
|
858
|
+
*
|
|
859
|
+
* @param ctx - client 根上下文。
|
|
860
|
+
* @returns 候选宿主数组(可能为空)。
|
|
861
|
+
*/
|
|
862
|
+
function workspaceHostsOf(ctx) {
|
|
863
|
+
var hosts = [];
|
|
864
|
+
var push = function (value) {
|
|
865
|
+
if (value === undefined || value === null) return;
|
|
866
|
+
for (var at = 0; at < hosts.length; at += 1) {
|
|
867
|
+
if (hosts[at] === value) return;
|
|
868
|
+
}
|
|
869
|
+
hosts.push(value);
|
|
870
|
+
};
|
|
871
|
+
try {
|
|
872
|
+
if (typeof ctx.get === 'function') push(ctx.get('workspaces'));
|
|
873
|
+
} catch (error) {
|
|
874
|
+
/* 服务没起就跳过 */
|
|
875
|
+
}
|
|
876
|
+
push(ctx.workspaces);
|
|
877
|
+
push(ctx.sessions);
|
|
878
|
+
push(ctx.uiWorkspace);
|
|
879
|
+
return hosts;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* 按 `workspaceId` 查工作区的目录路径 —— **同步**,读不到就 `undefined`。
|
|
884
|
+
*
|
|
885
|
+
* 官方 `workspaces` 服务的快照长这样(`dsh-api-workspace-controller` 的
|
|
886
|
+
* `buildSnapshot()` → `{items, archivedSessionIds, pinnedSessionIds, …}`),
|
|
887
|
+
* 每个 item 由 `workspaceView()` 投影出来:
|
|
888
|
+
*
|
|
889
|
+
* {workspaceId, path, title, sessionIds, createdAt, updatedAt}
|
|
890
|
+
*
|
|
891
|
+
* ⚠️ 主键叫 **`workspaceId`**,不是 `id` —— 按 `id` 找永远找不到。
|
|
892
|
+
* ⚠️ 路径字段是 **`path`**(`workspace.path`)。
|
|
893
|
+
*
|
|
894
|
+
* @param ctx - client 根上下文。
|
|
895
|
+
* @param workspaceId - 工作区 id。
|
|
896
|
+
* @returns 工作区路径;查不到时 `undefined`。
|
|
897
|
+
*/
|
|
898
|
+
function workspacePathOf(ctx, workspaceId) {
|
|
899
|
+
if (typeof workspaceId !== 'string' || workspaceId === '') return undefined;
|
|
900
|
+
var hosts = workspaceHostsOf(ctx);
|
|
901
|
+
for (var ht = 0; ht < hosts.length; ht += 1) {
|
|
902
|
+
var host = hosts[ht];
|
|
903
|
+
var stores = [host.list, host.workspaces, host];
|
|
904
|
+
for (var st = 0; st < stores.length; st += 1) {
|
|
905
|
+
var value = pathFromWorkspaceStore(stores[st], workspaceId);
|
|
906
|
+
if (value !== undefined) return value;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return undefined;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* 从一个「工作区 store / 列表 / 数组」里取指定 id 的路径。
|
|
914
|
+
*
|
|
915
|
+
* 抽出来是为了让 `workspacePathOf` 能把多个来源串起来试,而不用把
|
|
916
|
+
* 「快照怎么取」的细节重复四遍。
|
|
917
|
+
*
|
|
918
|
+
* 兼容三种主键写法(`workspaceId` / `id`)与四种路径字段写法,
|
|
919
|
+
* 免得官方换个字段名就静默失效。
|
|
920
|
+
*
|
|
921
|
+
* @param store - 可能是 store、列表、数组或映射。
|
|
922
|
+
* @param workspaceId - 工作区 id。
|
|
923
|
+
* @returns 路径;没有就 `undefined`。
|
|
924
|
+
*/
|
|
925
|
+
function pathFromWorkspaceStore(store, workspaceId) {
|
|
926
|
+
var items = workspaceItemsOf(store);
|
|
927
|
+
if (items === undefined) return undefined;
|
|
928
|
+
var found;
|
|
929
|
+
if (Array.isArray(items)) {
|
|
930
|
+
for (var at = 0; at < items.length; at += 1) {
|
|
931
|
+
var row = items[at];
|
|
932
|
+
if (!row) continue;
|
|
933
|
+
if (row.workspaceId === workspaceId || row.id === workspaceId) { found = row; break; }
|
|
934
|
+
}
|
|
935
|
+
} else {
|
|
936
|
+
found = items[workspaceId];
|
|
937
|
+
}
|
|
938
|
+
return found === undefined || found === null ? undefined : pathOfWorkspaceRow(found);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* 从会话 id 反查它所属工作区的路径 —— **同步**,读不到就 `undefined`。
|
|
943
|
+
*
|
|
944
|
+
* 比「先拿 `row.workspaceId` 再查工作区」更直接:新建对话那一行**连
|
|
945
|
+
* `workspaceId` 都可能还没填**,但它一定已经出现在某个工作区的
|
|
946
|
+
* `sessionIds` 里(官方建会话时先挂到工作区,再物化 cwd)。
|
|
947
|
+
*
|
|
948
|
+
* ⚠️ 一个会话理论上只属于一个工作区,所以命中即返回,不用遍历完。
|
|
949
|
+
*
|
|
950
|
+
* @param ctx - client 根上下文。
|
|
951
|
+
* @param sessionId - 会话 id。
|
|
952
|
+
* @returns 所属工作区路径;查不到时 `undefined`。
|
|
953
|
+
*/
|
|
954
|
+
function workspacePathOfSession(ctx, sessionId) {
|
|
955
|
+
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
956
|
+
var hosts = workspaceHostsOf(ctx);
|
|
957
|
+
for (var ht = 0; ht < hosts.length; ht += 1) {
|
|
958
|
+
var host = hosts[ht];
|
|
959
|
+
var stores = [host.list, host.workspaces, host];
|
|
960
|
+
for (var st = 0; st < stores.length; st += 1) {
|
|
961
|
+
var items = workspaceItemsOf(stores[st]);
|
|
962
|
+
if (items === undefined || !Array.isArray(items)) continue;
|
|
963
|
+
for (var at = 0; at < items.length; at += 1) {
|
|
964
|
+
var row = items[at];
|
|
965
|
+
if (!row || !Array.isArray(row.sessionIds)) continue;
|
|
966
|
+
if (row.sessionIds.indexOf(sessionId) === -1) continue;
|
|
967
|
+
var value = pathOfWorkspaceRow(row);
|
|
968
|
+
if (value !== undefined) return value;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return undefined;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* 从一个「工作区 store / 列表 / 数组」里取出 item 集合。
|
|
977
|
+
*
|
|
978
|
+
* @param store - 可能是 store、列表、数组或映射。
|
|
979
|
+
* @returns 数组或 `{id: row}` 映射;取不到就 `undefined`。
|
|
980
|
+
*/
|
|
981
|
+
function workspaceItemsOf(store) {
|
|
982
|
+
if (store === undefined || store === null) return undefined;
|
|
983
|
+
var list;
|
|
984
|
+
if (typeof store.getSnapshot === 'function') {
|
|
985
|
+
try { list = store.getSnapshot(); } catch (error) { return undefined; }
|
|
986
|
+
} else if (typeof store.list === 'function') {
|
|
987
|
+
try { list = store.list(); } catch (error) { return undefined; }
|
|
988
|
+
} else if (Array.isArray(store)) {
|
|
989
|
+
list = store;
|
|
990
|
+
} else {
|
|
991
|
+
return undefined;
|
|
992
|
+
}
|
|
993
|
+
if (list === undefined || list === null) return undefined;
|
|
994
|
+
// 官方快照是 `{items, …}`;也兼容直接给数组或 `{byId}`。
|
|
995
|
+
if (Array.isArray(list)) return list;
|
|
996
|
+
if (Array.isArray(list.items)) return list.items;
|
|
997
|
+
if (list.byId !== undefined && list.byId !== null) return list.byId;
|
|
998
|
+
return undefined;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* 取一行工作区的路径(按几个候选字段名试)。
|
|
1003
|
+
*
|
|
1004
|
+
* @param row - 工作区行。
|
|
1005
|
+
* @returns 路径;没有就 `undefined`。
|
|
1006
|
+
*/
|
|
1007
|
+
function pathOfWorkspaceRow(row) {
|
|
1008
|
+
if (row === undefined || row === null) return undefined;
|
|
1009
|
+
var keys = ['path', 'cwd', 'root', 'directory'];
|
|
1010
|
+
for (var kt = 0; kt < keys.length; kt += 1) {
|
|
1011
|
+
var value = row[keys[kt]];
|
|
1012
|
+
if (typeof value === 'string' && value !== '') return value;
|
|
1013
|
+
}
|
|
1014
|
+
return undefined;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
736
1017
|
/**
|
|
737
1018
|
* 调宿主桥的一条 RPC(令牌只在引擎进程里,浏览器侧不持有)。
|
|
738
1019
|
*
|
|
@@ -1021,6 +1302,10 @@ window.__ModuleLoader__.load({
|
|
|
1021
1302
|
/** 「手机」页签:采纳为本地工作区。 */
|
|
1022
1303
|
var pickLocal = function () {
|
|
1023
1304
|
if (loc.path === '') return;
|
|
1305
|
+
// ⚠️ **必须清掉电脑那条兜底**(见 `pendingWorkspaceCwd`):用户从
|
|
1306
|
+
// 电脑工作区切回手机默认工作区时,若不清,模型/预设会继续按
|
|
1307
|
+
// 「电脑」筛 —— 正是用户报的「切回默认工作区,预设和模型都没改回来」。
|
|
1308
|
+
setPendingWorkspace(null);
|
|
1024
1309
|
onPicked(loc.path);
|
|
1025
1310
|
};
|
|
1026
1311
|
|
|
@@ -1308,6 +1593,13 @@ window.__ModuleLoader__.load({
|
|
|
1308
1593
|
// ⚠️ **先登记、再打开**,顺序反了就会静默失败(见 `registerLocal`)。
|
|
1309
1594
|
return settleLocal(result).then(function () {
|
|
1310
1595
|
if (!aliveRef.current) return;
|
|
1596
|
+
// ⚠️ 会话行还没带上 cwd 之前,模型/预设菜单靠这个值判断
|
|
1597
|
+
// 「这是电脑工作区」(见 `pendingWorkspaceCwd`)。不设的话,
|
|
1598
|
+
// 新建出来的电脑会话在那一小段窗口里会被判成本地,
|
|
1599
|
+
// 模型菜单显示手机模型 —— 用户报障就是这个。
|
|
1600
|
+
if (result && typeof result.cwd === 'string' && result.cwd !== '') {
|
|
1601
|
+
setPendingWorkspace(result.cwd);
|
|
1602
|
+
}
|
|
1311
1603
|
patch({ creating: false });
|
|
1312
1604
|
onCancel();
|
|
1313
1605
|
if (openSession !== undefined && result && result.sessionId) {
|
|
@@ -1749,7 +2041,7 @@ window.__ModuleLoader__.load({
|
|
|
1749
2041
|
//#endregion
|
|
1750
2042
|
|
|
1751
2043
|
/** 需要宿主提供的服务:slot 注册表、工作区浏览与导航、会话数据。 */
|
|
1752
|
-
const inject = ['slots', 'uiWorkspace', 'sessions'];
|
|
2044
|
+
const inject = ['slots', 'uiWorkspace', 'sessions', 'workspaces'];
|
|
1753
2045
|
|
|
1754
2046
|
/**
|
|
1755
2047
|
* Client 插件主体:注册「手机 / 电脑」双页签的目录选择对话框 + 订阅流式帧。
|
package/lib/index.js
CHANGED
|
@@ -75,6 +75,13 @@ const SESSIONS_PATH = join(STATE_DIR, 'sessions.json');
|
|
|
75
75
|
/** 默认隧道地址。 */
|
|
76
76
|
const DEFAULT_ENDPOINT = 'https://dsh-remote.jiulingssh.xyz';
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* 凭据只允许发往这个 DNS 区(见 `assertTrustedHost`)。
|
|
80
|
+
*
|
|
81
|
+
* ⚠️ 与配对服务端的 `CF_ZONE_NAME` 是同一个常量,改一处必须改两处。
|
|
82
|
+
*/
|
|
83
|
+
const CF_ZONE_NAME = 'jiulingssh.xyz';
|
|
84
|
+
|
|
78
85
|
/**
|
|
79
86
|
* 配对服务地址。
|
|
80
87
|
*
|
|
@@ -262,6 +269,16 @@ function saveRemoteSessions(set, logger) {
|
|
|
262
269
|
* @returns 响应。
|
|
263
270
|
*/
|
|
264
271
|
async function remoteFetch(url, init) {
|
|
272
|
+
// ⚠️⚠️ **禁止跟随重定向**(外部审计 M7)。实测:302 跨源会**带着
|
|
273
|
+
// `x-dsh-remote-token`** 打到攻击者的主机上,307 则会把整个请求体
|
|
274
|
+
//(含 `{"pairCode":…}`)原样重发过去。端点被攻陷、或上游插一个重定向,
|
|
275
|
+
// 令牌/配对码就送出去了。
|
|
276
|
+
//
|
|
277
|
+
// `redirect: 'manual'` 让 fetch 把 3xx 当普通响应返回(`res.ok === false`),
|
|
278
|
+
// 于是上层按 HTTP 错误处理 —— 这正是我们要的:**宁可不配,也不把凭据送去
|
|
279
|
+
// 一个不像我们服务器的地方。**
|
|
280
|
+
init = { redirect: 'manual', ...init };
|
|
281
|
+
assertTrustedHost(url);
|
|
265
282
|
const pool = await remotePool();
|
|
266
283
|
// ① 首选:undici 专用池(关掉 autoSelectFamily,Android 上最稳)。
|
|
267
284
|
// ② 备用:`node:https`(强制 IPv4,不依赖 undici 解析)。
|
|
@@ -271,9 +288,26 @@ async function remoteFetch(url, init) {
|
|
|
271
288
|
// (请求到了)又有界面上的 `fetch failed`(请求没到)—— 说明是**间歇性**
|
|
272
289
|
// 建连失败。只修「undici 没解析到」那一种情形,另一种照样挂。
|
|
273
290
|
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
291
|
+
// ⚠️ 重试的**安全性由调用方的幂等键保证**,不能靠「fetch 抛错就一定没送到」
|
|
292
|
+
// 这个假设(外部审计 L6)。
|
|
293
|
+
//
|
|
294
|
+
// 原来这里的注释写的是「`fetch()` 抛错意味着建连或响应头阶段就失败了,
|
|
295
|
+
// 重发不会产生重复副作用」—— 这句话**不成立**:连接可能在服务器**已经处理完
|
|
296
|
+
// 请求**、响应头还没回来时断掉(隧道掐连接、手机切网、CF 边缘超时都会这样)。
|
|
297
|
+
// 那时重发就是**同一件事做两遍**。
|
|
298
|
+
//
|
|
299
|
+
// 对只读调用(`listDirectory` / `modelCatalog` / `presetList`)无所谓;
|
|
300
|
+
// 对写操作要靠幂等键:
|
|
301
|
+
//
|
|
302
|
+
// | 方法 | 幂等键 |
|
|
303
|
+
// |---|---|
|
|
304
|
+
// | `prompt` | **`requestId`** —— 手机端**总是**带上(见 `controller.prompt`),
|
|
305
|
+
// | | 电脑端用它做 `hasPromptRequest` 去重 |
|
|
306
|
+
// | `createSession` | `sessionId`(同 id 重复创建是复用) |
|
|
307
|
+
// | `cancel` | 天然幂等 |
|
|
308
|
+
//
|
|
309
|
+
// ⚠️ 所以**新增写操作时必须一起想清楚它的幂等键**;没有幂等键的写操作
|
|
310
|
+
// **不能**走这条重试路径(否则一次建连抖动 = 一次重复副作用)。
|
|
277
311
|
const attempts = [];
|
|
278
312
|
if (pool !== null) {
|
|
279
313
|
try {
|
|
@@ -295,6 +329,43 @@ async function remoteFetch(url, init) {
|
|
|
295
329
|
}
|
|
296
330
|
}
|
|
297
331
|
|
|
332
|
+
/**
|
|
333
|
+
* 端点域名白名单:只往 `*.jiulingssh.xyz` 送凭据。
|
|
334
|
+
*
|
|
335
|
+
* ## 为什么需要(外部审计 M7)
|
|
336
|
+
*
|
|
337
|
+
* `payload.hostname` 是**网络对端**给的字段(配对服务器的响应、或 `mobile.json`
|
|
338
|
+
* 里被改过的值),而调用方会拿它拼 URL 去发请求 —— 请求里带着 `accessToken`。
|
|
339
|
+
* 审计实测的两种绕过:
|
|
340
|
+
* ① `https://a@evil.com` 这类 **userinfo 写法**,人眼看是 `a@`,实际 host 是
|
|
341
|
+
* `evil.com`;
|
|
342
|
+
* ② 上游插一个 302/307 重定向(已由 `redirect:'manual'` 挡掉,但域名白名单是
|
|
343
|
+
* 第二道,且能挡住「直接写一个外域」这种更朴素的写法)。
|
|
344
|
+
*
|
|
345
|
+
* ⚠️ **用 `new URL(...).hostname` 判,不要用字符串 `includes`**:`includes` 对
|
|
346
|
+
* `evil.com/?x=jiulingssh.xyz` 和 `jiulingssh.xyz.evil.com` 都会误判为通过。
|
|
347
|
+
* ⚠️ 判的是**后缀且必须是子域边界**:`evil-jiulingssh.xyz` 不该通过,
|
|
348
|
+
* 所以比的是 `.jiulingssh.xyz` 结尾(或正好等于 `jiulingssh.xyz`)。
|
|
349
|
+
*
|
|
350
|
+
* @param url - 完整 URL。
|
|
351
|
+
* @throws 当目标域名不在白名单里时。
|
|
352
|
+
*/
|
|
353
|
+
function assertTrustedHost(url) {
|
|
354
|
+
let host;
|
|
355
|
+
try {
|
|
356
|
+
host = new URL(url).hostname.toLowerCase();
|
|
357
|
+
} catch {
|
|
358
|
+
throw new Error('目标地址不是合法 URL,已拒绝发送凭据');
|
|
359
|
+
}
|
|
360
|
+
const zone = CF_ZONE_NAME.toLowerCase();
|
|
361
|
+
if (host === zone || host.endsWith(`.${zone}`)) return;
|
|
362
|
+
// ⚠️ **放行 loopback**:本机调试 / 自建反代 / 测试用的就是 `127.0.0.1`,
|
|
363
|
+
// 而且往 loopback 送凭据**不构成外泄**(出不了这台机器)。少了这一条,
|
|
364
|
+
// 所有走本机端点的测试与自建部署都会直接报错。
|
|
365
|
+
if (host === '127.0.0.1' || host === 'localhost' || host === '[::1]' || host === '::1') return;
|
|
366
|
+
throw new Error(`拒绝对 ${host} 发送凭据(只允许 ${zone} 下的地址或本机)`);
|
|
367
|
+
}
|
|
368
|
+
|
|
298
369
|
/**
|
|
299
370
|
* 是否是**主动中止**(不是网络故障)。
|
|
300
371
|
*
|
|
@@ -589,6 +660,51 @@ async function claimPairing(code) {
|
|
|
589
660
|
return saveConfig(endpoint, token);
|
|
590
661
|
}
|
|
591
662
|
|
|
663
|
+
/**
|
|
664
|
+
* 目录缓存专用的**空对象**:没有原型,因此不可能被 `__proto__` 之类的键污染。
|
|
665
|
+
*
|
|
666
|
+
* ## 为什么(外部审计 5.2e)
|
|
667
|
+
*
|
|
668
|
+
* `desktopCache.dirs` 的键来自 `params.path`(前端传上来的电脑路径)。
|
|
669
|
+
* 用普通对象 `{}` 时,`dirs['__proto__'] = x` 走的是**原型设置**语义
|
|
670
|
+
* (而不是添一个属性)⇒ 改写这个对象的原型链。审计标为「自伤为主」——
|
|
671
|
+
* 确实不是远程接管,但那是因为这个对象的原型恰好没被别处依赖;
|
|
672
|
+
* 「恰好没被依赖」不是安全边界,是运气。
|
|
673
|
+
*
|
|
674
|
+
* `Object.create(null)` 让 `__proto__` 变回一个**普通字符串键**,
|
|
675
|
+
* 污染这条路从语义上就不存在了。同时把 `__proto__` 显式拒掉:
|
|
676
|
+
* 它本来也不是一个合法的电脑路径。
|
|
677
|
+
*
|
|
678
|
+
* @returns 无原型空对象。
|
|
679
|
+
*/
|
|
680
|
+
function emptyDirCache() {
|
|
681
|
+
return Object.create(null);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* 这个键能不能当目录缓存的键。
|
|
686
|
+
*
|
|
687
|
+
* @param path - 前端传来的路径。
|
|
688
|
+
* @returns 是否可接受。
|
|
689
|
+
*/
|
|
690
|
+
function isSafeCacheKey(path) {
|
|
691
|
+
return typeof path === 'string' && path !== '' && path !== '__proto__' && path !== 'constructor';
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* 单个镜像事件的字节上限(4 MiB,外部审计 5.2b)。
|
|
696
|
+
*
|
|
697
|
+
* `mirrorEvent` 把**公网那台电脑**发来的事件原样 `append` 进手机本地会话。
|
|
698
|
+
* 没有上限时,对端只要发一条 `data` 巨大(或条数无限)的事件,就能把手机存储
|
|
699
|
+
* 写满 —— 而会话日志是**只增不减**的(`session.v4.jsonl`),写满之后 App
|
|
700
|
+
* 连自己的配置都写不进去。
|
|
701
|
+
*
|
|
702
|
+
* 4 MiB 的依据:正常事件里最大的是 `assistant/message` 的正文与工具输出,
|
|
703
|
+
* 实测都在几十 KB 量级;附件字节**不走这条路**(它们以 content part 的形式
|
|
704
|
+
* 在 RPC 里传,已经在 `RPC_BODY_LIMIT` 里限过)。所以 4 MiB 对合法事件极其宽松。
|
|
705
|
+
*/
|
|
706
|
+
const MIRROR_EVENT_LIMIT = 4 * 1024 * 1024;
|
|
707
|
+
|
|
592
708
|
/**
|
|
593
709
|
* 把一个电脑事件写进手机本地会话。
|
|
594
710
|
*
|
|
@@ -617,14 +733,34 @@ async function claimPairing(code) {
|
|
|
617
733
|
* @returns 是否写入了。
|
|
618
734
|
*/
|
|
619
735
|
function mirrorEvent(session, event, state) {
|
|
736
|
+
// 同理:`type` 必须是字符串,否则 `session.append` 会拿到非字符串的键。
|
|
737
|
+
if (typeof event.type !== 'string' || event.type === '') {
|
|
738
|
+
throw new Error('事件流里的事件缺少 type,已拒收');
|
|
739
|
+
}
|
|
620
740
|
// ⚠️ `session/end-seed` 必须跳过:手机端没有对应的一条,写进去会让后续全部错位。
|
|
621
741
|
// 它只会出现在电脑日志的最开头,所以偏移量只认一次 —— 否则 SSE 重连补发
|
|
622
742
|
// 历史时会被反复累加,后续事件的折算 seq 越算越小。
|
|
743
|
+
//
|
|
744
|
+
// ⚠️ 这一条**排在最前面且不写入任何内容**,所以它不需要 `seq` 也安全:
|
|
745
|
+
// 唯一副作用是把 `skippedSeeds` 置 1(只置一次)。
|
|
623
746
|
if (event.type === 'session/end-seed') {
|
|
624
747
|
if (state.skippedSeeds === 0) state.skippedSeeds = 1;
|
|
625
748
|
return false;
|
|
626
749
|
}
|
|
627
|
-
|
|
750
|
+
// ⚠️ **`seq` 必须是数字,不是数字一律拒收**(外部审计 5.2c / L8)。
|
|
751
|
+
//
|
|
752
|
+
// 原来的写法是 `if (typeof event.seq === 'number') { ...对齐检查... }` ——
|
|
753
|
+
// 也就是说 `seq` 为 `undefined` / 字符串 / `null` 时**整段对齐检查被跳过**,
|
|
754
|
+
// 事件直接 `append` 进去。而这条流的对端是**公网上的那台电脑**:它(或链路
|
|
755
|
+
// 中间任何人)只要发一条没有 `seq` 的事件,就能往手机本地会话里插一条
|
|
756
|
+
// 不被对齐检查约束的记录 —— 后面所有事件的 seq 随之错位,搬运逻辑失效。
|
|
757
|
+
//
|
|
758
|
+
// 对齐检查是**正确性的地基**,它的前置条件不能是「攻击者给面子」。
|
|
759
|
+
// 所以这里倒过来写:先证明 `seq` 合法,再谈对齐;不合法就不认这条事件。
|
|
760
|
+
if (typeof event.seq !== 'number' || !Number.isInteger(event.seq) || event.seq < 0) {
|
|
761
|
+
throw new Error(`事件流里的 seq 不是非负整数(${JSON.stringify(event.seq)}),已拒收`);
|
|
762
|
+
}
|
|
763
|
+
{
|
|
628
764
|
const expected = event.seq - state.skippedSeeds;
|
|
629
765
|
if (expected < session.seq) return false; // 重连补发的历史,已写过
|
|
630
766
|
if (expected > session.seq) {
|
|
@@ -633,6 +769,24 @@ function mirrorEvent(session, event, state) {
|
|
|
633
769
|
);
|
|
634
770
|
}
|
|
635
771
|
}
|
|
772
|
+
// ⚠️ **大小上限**(外部审计 5.2b):会话日志只增不减,一条巨型事件就能把
|
|
773
|
+
// 手机存储写满,之后 App 连自己的配置都写不进去。这里在 `append` **之前**判,
|
|
774
|
+
// 因为 `append` 一旦落盘就没法撤。
|
|
775
|
+
//
|
|
776
|
+
// 判据用 `JSON.stringify` 后的字节数(`Buffer.byteLength`):`event.data`
|
|
777
|
+
// 的结构千变万化(正文、工具输出、patch 文本),逐个字段估不现实,
|
|
778
|
+
// 序列化长度才是真正会落盘的那份体积。
|
|
779
|
+
let serialized;
|
|
780
|
+
try {
|
|
781
|
+
serialized = JSON.stringify(event.data ?? null);
|
|
782
|
+
} catch (error) {
|
|
783
|
+
throw new Error(`事件 data 无法序列化(${String(error?.message ?? error)}),已拒收`);
|
|
784
|
+
}
|
|
785
|
+
if (Buffer.byteLength(serialized, 'utf8') > MIRROR_EVENT_LIMIT) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
`事件 data 超过 ${Math.floor(MIRROR_EVENT_LIMIT / (1024 * 1024))} MiB(type=${event.type}),已拒收`,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
636
790
|
const opts = {};
|
|
637
791
|
if (event.surfaceOp !== undefined) opts.surfaceOp = event.surfaceOp;
|
|
638
792
|
if (event.sourceEventSeqs !== undefined) opts.sourceEventSeqs = event.sourceEventSeqs;
|
|
@@ -640,6 +794,154 @@ function mirrorEvent(session, event, state) {
|
|
|
640
794
|
return true;
|
|
641
795
|
}
|
|
642
796
|
|
|
797
|
+
/** 降级(= 拿不到引擎判据)只告警一次,别刷屏。 */
|
|
798
|
+
let bridgeDegradedWarned = false;
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* `/api/dsh-remote/rpc` 请求体的上限(48 MiB)。
|
|
802
|
+
*
|
|
803
|
+
* ## 为什么必须有限制(外部审计 5.2a)
|
|
804
|
+
*
|
|
805
|
+
* 原来 handler 里是裸的 `req.on('data', c => chunks.push(c))`:
|
|
806
|
+
*
|
|
807
|
+
* - **没有上限**:手机上任一能打 loopback 的进程(或重载窗口内的任一页面)
|
|
808
|
+
* 只要 `POST` 一个无限长的 body,就能让引擎进程把内存吃干净
|
|
809
|
+
* ⇒ OOM / 引擎重启(会话全中断)。这是**DoS**,不是「资源紧张」。
|
|
810
|
+
* - **没有 `content-type` 校验**:跨站表单可以直接 `POST` 一个
|
|
811
|
+
* `text/plain` 的 body(那是**不会触发 CORS 预检**的三种 content-type 之一),
|
|
812
|
+
* 于是「body 必须是 JSON」这层防护可以绕过 —— 配合 `seq` 非数字等
|
|
813
|
+
* (见 `mirrorEvent`)就能构造非预期的写入。
|
|
814
|
+
*
|
|
815
|
+
* ## 为什么是 48 MiB
|
|
816
|
+
*
|
|
817
|
+
* 图片附件是 **base64 内联**在 body 里的(见 `controller.prompt` 的注释),
|
|
818
|
+
* base64 会膨胀 4/3,再算上 JSON 转义 ⇒ 32 MiB 的原图约 43 MiB 的 body。
|
|
819
|
+
* 48 MiB 够放一张 32 MiB 的图,同时远小于「能把引擎搞死」的量级。
|
|
820
|
+
* 手机拍照一般 3–8 MiB,正常使用碰不到这个上限。
|
|
821
|
+
*/
|
|
822
|
+
const RPC_BODY_LIMIT = 48 * 1024 * 1024;
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* 电脑 → 手机的 SSE 单条消息上限(8 MiB,外部审计 5.2b)。
|
|
826
|
+
*
|
|
827
|
+
* `follow()` 里用 `buffer` 累积到 `\n\n` 才切一条消息。对手(公网上的那台电脑,
|
|
828
|
+
* 或链路中间任何人)只要**一直不发空行**,`buffer` 就会无限增长 ⇒ 手机 OOM。
|
|
829
|
+
* 8 MiB 对正常的一帧(AI 正文分片,KB 量级)有巨大余量,
|
|
830
|
+
* 超过就说明对端不是在发 SSE,直接判为协议错误断开。
|
|
831
|
+
*/
|
|
832
|
+
const SSE_BUFFER_LIMIT = 8 * 1024 * 1024;
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* 读一个请求体,**带上限和类型检查**(外部审计 5.2a)。
|
|
836
|
+
*
|
|
837
|
+
* @param req - HTTP 请求。
|
|
838
|
+
* @param limit - 上限字节数。
|
|
839
|
+
* @returns `{ ok:true, text }` 或 `{ ok:false, code, message }`。
|
|
840
|
+
*/
|
|
841
|
+
function readBodyLimited(req, limit) {
|
|
842
|
+
return new Promise((resolve) => {
|
|
843
|
+
const chunks = [];
|
|
844
|
+
let size = 0;
|
|
845
|
+
let settled = false;
|
|
846
|
+
const done = (value) => {
|
|
847
|
+
if (settled) return;
|
|
848
|
+
settled = true;
|
|
849
|
+
resolve(value);
|
|
850
|
+
};
|
|
851
|
+
req.on('data', (chunk) => {
|
|
852
|
+
if (settled) return;
|
|
853
|
+
size += chunk.length;
|
|
854
|
+
if (size > limit) {
|
|
855
|
+
// ⚠️ 超限就**立刻**停:不能继续 push 到内存里再判断,
|
|
856
|
+
// 那样上限形同虚设(攻击者还是把内存吃光了)。
|
|
857
|
+
done({
|
|
858
|
+
ok: false,
|
|
859
|
+
code: 413,
|
|
860
|
+
message: `请求体过大(上限 ${Math.floor(limit / (1024 * 1024))} MiB)`,
|
|
861
|
+
});
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
chunks.push(chunk);
|
|
865
|
+
});
|
|
866
|
+
req.on('end', () => done({ ok: true, text: Buffer.concat(chunks).toString('utf8') }));
|
|
867
|
+
req.on('error', (error) =>
|
|
868
|
+
done({ ok: false, code: 400, message: `读取请求体失败: ${String(error?.message ?? error)}` }),
|
|
869
|
+
);
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* 桥路由的准入检查。
|
|
875
|
+
*
|
|
876
|
+
* ## ⚠️ 为什么必须自己加(外部审计 D5)
|
|
877
|
+
*
|
|
878
|
+
* 引擎的 `webserver` 只按 pathname 查表,**路由命中前不做任何鉴权**;
|
|
879
|
+
* 引擎自己的准入(Host / `sec-fetch-site` / Origin / 浏览器会话 cookie)加在
|
|
880
|
+
* **它自己那条 `/api` 前缀路由的 handler 里**。而 `match()` 是「**精确表先命中**」——
|
|
881
|
+
* 本插件的 `kind:'exact'` 路由 `/api/dsh-remote/rpc` 会比引擎的 `/api` 前缀
|
|
882
|
+
* **先**被选中,于是引擎那层门根本不会被调用。
|
|
883
|
+
*
|
|
884
|
+
* 后果(审计原文):手机上任一其它 App(Android loopback 跨 App 可达)或任一
|
|
885
|
+
* 页面都能打 `/api/dsh-remote/rpc` —— `pair`(把手机改指到别人的电脑)、
|
|
886
|
+
* `unpair`、`listDesktop`、`createSession` 全都能打;`GET /api/dsh-remote/stream`
|
|
887
|
+
* 更是直接读走**全部远程会话的流式 AI 正文**。
|
|
888
|
+
*
|
|
889
|
+
* ## 做法:**复用引擎自己的判据**,不自己重写一套
|
|
890
|
+
*
|
|
891
|
+
* `connection` 服务暴露了 `requestRejection(request)` —— 它内部就是
|
|
892
|
+
* ①`isTrustedApiRequest`(Host 必须 loopback 或受信 + `sec-fetch-site !== 'cross-site'`
|
|
893
|
+
* + Origin 与 Host 同源)②`browserAuth.isAuthenticated`(**浏览器会话 cookie**)。
|
|
894
|
+
*
|
|
895
|
+
* ⚠️ **不自己照抄这两条的实现**:引擎升级判据时(比如加一条新的 fetch metadata
|
|
896
|
+
* 检查),复用它就自动跟上;照抄的副本会悄悄过期,那才是真正的隐患。
|
|
897
|
+
*
|
|
898
|
+
* ⚠️ **第二条(cookie)才是关键**:只判 Host 是 loopback 挡不住同机的其它 App ——
|
|
899
|
+
* 它们打 `127.0.0.1` 时 Host 也是 loopback。而 WebView 页面世界的请求天然带
|
|
900
|
+
* 引擎签发的会话 cookie,原生 App 拿不到。所以这道门对页面透明、对其它 App 关闭。
|
|
901
|
+
*
|
|
902
|
+
* ## ⚠️ 取不到 `connection` 一律 **fail-closed(503)**,绝不降级(外部审计 5.1)
|
|
903
|
+
*
|
|
904
|
+
* 第一版在这里回退到「Host + Origin + sec-fetch-site」的弱判据 —— 那是
|
|
905
|
+
* **fail-open**:弱判据里**没有 cookie 这一条**,而非浏览器客户端(本机任意
|
|
906
|
+
* 其它 App)天然不带 `sec-fetch-site`,于是全过。等于把上面刚关上的门又打开。
|
|
907
|
+
*
|
|
908
|
+
* 更糟的是它**有一个必然出现的窗口**:cordis 的 `ctx.get(name, strict)` 对
|
|
909
|
+
* 尚未 ACTIVE 的提供者返回 `undefined`(`cordis/lib/index.js:770`),而
|
|
910
|
+
* client-connection 的 `apply` 里 `await BrowserAuth.create(...)` 排在
|
|
911
|
+
* `provide('connection')` **之前** ⇒ 引擎启动 / 插件重载 / `cordis.patch.yml`
|
|
912
|
+
* HMR 期间,本插件同步注册的路由**已经在收请求**,而 `connection` 还没就绪。
|
|
913
|
+
* 审计已用手机 `engine.bin` 自带的那份 cordis 合成复现:窗口内探针全放行。
|
|
914
|
+
*
|
|
915
|
+
* 所以这里只有两种结局:**能拿到服务 → 用引擎的判据**;**拿不到 → 503**。
|
|
916
|
+
* 「服务缺失」是暂时不可用,「降级」是一直不安全 —— 后者严重得多,不能拿
|
|
917
|
+
* 可用性换。同理,`requestRejection` 自己抛错也一律 503:判据没跑成,
|
|
918
|
+
* 就等于没有判据,不能默认放行。
|
|
919
|
+
*
|
|
920
|
+
* @param ctx - Cordis 上下文。
|
|
921
|
+
* @param req - 请求。
|
|
922
|
+
* @returns 拒绝时的 HTTP 状态码,放行时 `undefined`。
|
|
923
|
+
*/
|
|
924
|
+
function bridgeRejection(ctx, req) {
|
|
925
|
+
const connection = ctx.get?.('connection');
|
|
926
|
+
if (connection === undefined || typeof connection.requestRejection !== 'function') {
|
|
927
|
+
if (!bridgeDegradedWarned) {
|
|
928
|
+
bridgeDegradedWarned = true;
|
|
929
|
+
ctx.logger?.warn?.(
|
|
930
|
+
'[dsh-remote-mobile] connection 服务尚未就绪,桥路由一律 503(fail-closed,不降级到弱判据)',
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
return 503;
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
return connection.requestRejection(req);
|
|
937
|
+
} catch (error) {
|
|
938
|
+
ctx.logger?.warn?.(
|
|
939
|
+
`[dsh-remote-mobile] connection.requestRejection 抛错,桥路由一律 503(fail-closed): ${String(error?.message ?? error)}`,
|
|
940
|
+
);
|
|
941
|
+
return 503;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
643
945
|
/**
|
|
644
946
|
* 插件入口。
|
|
645
947
|
*
|
|
@@ -702,7 +1004,7 @@ export function apply(ctx) {
|
|
|
702
1004
|
* 会大面积显示过期内容。所以 `dirs` 只装「用户实际看过的那几层的下一层」,
|
|
703
1005
|
* 上限见 `DESKTOP_DIRS_MAX`。过期窗口由「刷新」按钮关闭。
|
|
704
1006
|
*/
|
|
705
|
-
const desktopCache = { catalog: null, presets: null, directory: null, dirs:
|
|
1007
|
+
const desktopCache = { catalog: null, presets: null, directory: null, dirs: emptyDirCache(), at: 0 };
|
|
706
1008
|
|
|
707
1009
|
/** `desktopCache.dirs` 的容量上限(按插入顺序淘汰最旧的那些)。 */
|
|
708
1010
|
const DESKTOP_DIRS_MAX = 200;
|
|
@@ -775,6 +1077,9 @@ export function apply(ctx) {
|
|
|
775
1077
|
* @param listing - 该层的列表结果。
|
|
776
1078
|
*/
|
|
777
1079
|
function rememberDir(path, listing) {
|
|
1080
|
+
// ⚠️ 拒掉 `__proto__` / `constructor`(外部审计 5.2e)。配合
|
|
1081
|
+
// `emptyDirCache()` 的无原型对象,这是双保险:前者防语义,后者防意外。
|
|
1082
|
+
if (!isSafeCacheKey(path)) return;
|
|
778
1083
|
desktopCache.dirs[path] = listing;
|
|
779
1084
|
const keys = Object.keys(desktopCache.dirs);
|
|
780
1085
|
for (let at = 0; at + DESKTOP_DIRS_MAX < keys.length; at += 1) {
|
|
@@ -933,9 +1238,24 @@ export function apply(ctx) {
|
|
|
933
1238
|
/**
|
|
934
1239
|
* 跟随电脑的一个会话:durable 事件写手机本地,流式帧广播给浏览器。
|
|
935
1240
|
*
|
|
1241
|
+
* ⚠️ **未配对时直接不跟**(外部审计 I2)。
|
|
1242
|
+
*
|
|
1243
|
+
* `callRemote` 有「令牌为空 ⇒ 抛错」的守卫,但 `follow()` 原来没有:它会拿着
|
|
1244
|
+
* **空令牌**去连 `config.endpoint`,然后套着退避重连循环一直重试下去。两个后果:
|
|
1245
|
+
*
|
|
1246
|
+
* - 未配对的状态下(首次装、解绑之后、用户手改 `mobile.json` 清空令牌),
|
|
1247
|
+
* 后台持续打一个空令牌的请求 —— 那是**对着一台陌生服务器敲门**,
|
|
1248
|
+
* 日志里刷满失败,还白耗手机流量与电。
|
|
1249
|
+
* - 如果 `endpoint` 恰好指向某台仍在跑的电脑,那条空令牌请求会拿到 401,
|
|
1250
|
+
* 但重连退避会一直重试(永不放弃),这类循环在移动端是电量和流量杀手。
|
|
1251
|
+
*
|
|
1252
|
+
* 调用方(`handleBridge` 的 `createSession` / 恢复流程)在未配对时本来就
|
|
1253
|
+
* 不会走到这里 —— 这道守卫是**兜住所有调用路径**,不依赖调用方记得检查。
|
|
1254
|
+
*
|
|
936
1255
|
* @param sessionId - 两端同名的会话 id。
|
|
937
1256
|
*/
|
|
938
1257
|
const follow = (sessionId) => {
|
|
1258
|
+
if (config.token === EMPTY_TOKEN) return;
|
|
939
1259
|
if (follows.has(sessionId)) return;
|
|
940
1260
|
const controller = new AbortController();
|
|
941
1261
|
follows.set(sessionId, controller);
|
|
@@ -948,13 +1268,17 @@ export function apply(ctx) {
|
|
|
948
1268
|
* 折算把已写过的跳过,不会重复。
|
|
949
1269
|
*/
|
|
950
1270
|
const once = async () => {
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1271
|
+
// ⚠️⚠️ **令牌走 header,不走 URL query**(外部审计 M3)。
|
|
1272
|
+
// `?token=…` 会进 Cloudflare 隧道日志、任何反代/网关的 access log、
|
|
1273
|
+
// 以及浏览器历史(如果哪天这条被页面直接打开)。桌面端本来就支持
|
|
1274
|
+
// `x-dsh-remote-token` 头,所以这里换掉没有任何兼容代价 ——
|
|
1275
|
+
// 而这条是 node 发起的请求,**本来就不需要** query 形式
|
|
1276
|
+
//(query 形式只为浏览器 `EventSource` 保留,它设不了自定义头)。
|
|
1277
|
+
const url = `${config.endpoint}/api/dsh-remote/stream?session=${encodeURIComponent(sessionId)}`;
|
|
954
1278
|
// ⚠️ 必须走 `remoteFetch`(自带 dispatcher):全局 fetch 的 dispatcher
|
|
955
1279
|
// 在 Android 上连不通公网,见 `remoteFetch` 的注释。
|
|
956
1280
|
const res = await remoteFetch(url, {
|
|
957
|
-
headers: { accept: 'text/event-stream' },
|
|
1281
|
+
headers: { accept: 'text/event-stream', 'x-dsh-remote-token': config.token },
|
|
958
1282
|
signal: controller.signal,
|
|
959
1283
|
});
|
|
960
1284
|
if (!res.ok || res.body === null) throw new Error(`HTTP ${res.status}`);
|
|
@@ -963,6 +1287,16 @@ export function apply(ctx) {
|
|
|
963
1287
|
let buffer = '';
|
|
964
1288
|
for await (const chunk of res.body) {
|
|
965
1289
|
buffer += decoder.decode(chunk, { stream: true });
|
|
1290
|
+
// ⚠️ **buffer 必须有上限**(外部审计 5.2b):SSE 靠空行分隔消息,
|
|
1291
|
+
// 若对端一直不发空行(恶意电脑 / 中间人 / 隧道注入),
|
|
1292
|
+
// `buffer` 会无限增长直到手机 OOM。这是**输入侧**的 DoS,
|
|
1293
|
+
// 而这条流的对端是「公网上的那台电脑」—— 正是不可信的那一侧。
|
|
1294
|
+
//
|
|
1295
|
+
// 上限取 8 MiB:正常一帧(AI 正文分片)是几百字节到几 KB 量级,
|
|
1296
|
+
// 8 MiB 已经远超任何合法帧;超了说明对端不是在发 SSE。
|
|
1297
|
+
if (buffer.length > SSE_BUFFER_LIMIT) {
|
|
1298
|
+
throw new Error(`SSE 单条消息超过 ${Math.floor(SSE_BUFFER_LIMIT / (1024 * 1024))} MiB,已断开`);
|
|
1299
|
+
}
|
|
966
1300
|
// SSE 以空行分隔消息;末段可能不完整,留在 buffer 里。
|
|
967
1301
|
let split;
|
|
968
1302
|
while ((split = buffer.indexOf('\n\n')) >= 0) {
|
|
@@ -1141,12 +1475,26 @@ export function apply(ctx) {
|
|
|
1141
1475
|
//
|
|
1142
1476
|
// 把前端的 requestId 原样带过去:电脑端会把它写进
|
|
1143
1477
|
// `source.rpcId`,前端凭这个结算「提交中」状态并显示用户气泡。
|
|
1478
|
+
//
|
|
1479
|
+
// ⚠️⚠️ **`requestId` 是这条调用在跨通道重试下的幂等键,必须总是有**
|
|
1480
|
+
// (外部审计 L6)。原来写的是
|
|
1481
|
+
// `...(request.requestId === undefined ? {} : { requestId: request.requestId })`
|
|
1482
|
+
// —— 前端偶尔不带(某些调用路径、旧缓存的前端包)时,这里就**没有幂等键**,
|
|
1483
|
+
// 而 `remoteFetch` 在第一条通道失败后会**原样重发**这个 body。
|
|
1484
|
+
// 后果是用户在电脑上**看到同一条消息发了两次**,AI 也就跑了两轮。
|
|
1485
|
+
//
|
|
1486
|
+
// 所以缺省时在这里补一个:用 `randomUUID()` 而不是「前端那个可能重复的
|
|
1487
|
+
// 时间戳」,因为它必须每次都是新的(否则去重逻辑会把正常消息当成重复的)。
|
|
1488
|
+
// 传了就用传的 —— 前端的 requestId 是它结算 UI 的依据,不能换。
|
|
1144
1489
|
await callRemote(config, 'prompt', {
|
|
1145
1490
|
sessionId: request.sessionId,
|
|
1146
1491
|
text,
|
|
1147
1492
|
// 附件形态一并带上(没有附件时给空数组,电脑端会退回纯文本)。
|
|
1148
1493
|
content,
|
|
1149
|
-
|
|
1494
|
+
requestId:
|
|
1495
|
+
typeof request.requestId === 'string' && request.requestId !== ''
|
|
1496
|
+
? request.requestId
|
|
1497
|
+
: randomUUID(),
|
|
1150
1498
|
});
|
|
1151
1499
|
// 确保事件流在线(断线时重连,补齐这段时间的事件)。
|
|
1152
1500
|
follow(request.sessionId);
|
|
@@ -1506,41 +1854,60 @@ export function apply(ctx) {
|
|
|
1506
1854
|
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
|
|
1507
1855
|
res.end(JSON.stringify(payload));
|
|
1508
1856
|
};
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1857
|
+
// ⚠️ 前置准入,必须在解析 body 之前(见 `bridgeRejection` 的 docstring)。
|
|
1858
|
+
const rejected = bridgeRejection(ctx, req);
|
|
1859
|
+
if (rejected !== undefined) {
|
|
1860
|
+
res.writeHead(rejected);
|
|
1861
|
+
res.end();
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
// ⚠️ **必须校验 content-type**(外部审计 5.2a):跨站表单能直接发
|
|
1865
|
+
// `text/plain`,而那是不触发 CORS 预检的三种类型之一;本插件的门
|
|
1866
|
+
// 是非浏览器的(原生 App / 重载窗口内的页面)也过得了。
|
|
1867
|
+
// 这一条把「body 必须是 JSON」变成硬约束,而不是靠 JSON.parse 兜底。
|
|
1868
|
+
const contentType = String(req.headers['content-type'] ?? '');
|
|
1869
|
+
if (!contentType.toLowerCase().includes('application/json')) {
|
|
1870
|
+
reply(415, { error: 'content-type 必须是 application/json' });
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
readBodyLimited(req, RPC_BODY_LIMIT).then((read) => {
|
|
1874
|
+
if (!read.ok) {
|
|
1875
|
+
// ⚠️ 超限/读错时**断开连接**:不把剩下的字节继续收进来,
|
|
1876
|
+
// 否则「限制」只是不发回应,内存照样被吃光。
|
|
1877
|
+
reply(read.code, { error: read.message });
|
|
1878
|
+
req.destroy();
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1512
1881
|
let body;
|
|
1513
1882
|
try {
|
|
1514
|
-
body = JSON.parse(
|
|
1883
|
+
body = JSON.parse(read.text);
|
|
1515
1884
|
} catch (error) {
|
|
1516
1885
|
reply(400, { error: `bad json: ${String(error?.message ?? error)}` });
|
|
1517
1886
|
return;
|
|
1518
1887
|
}
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
})
|
|
1540
|
-
reply(200, { ok:
|
|
1541
|
-
|
|
1542
|
-
reply(200, { ok: false, error: String(error?.message ?? error) });
|
|
1543
|
-
}
|
|
1888
|
+
handleBridge(ctx, config, {
|
|
1889
|
+
remoteSessions,
|
|
1890
|
+
follow,
|
|
1891
|
+
// 解绑要能停掉全部旧连接、清掉全部搬运状态(见 `unpair`)。
|
|
1892
|
+
follows,
|
|
1893
|
+
mirrorStates,
|
|
1894
|
+
// ⚠️ **必须显式传进来**:`handleBridge` 是模块级函数,作用域里
|
|
1895
|
+
// 没有 `apply()` 的闭包变量;漏传就是 `workspaceMap is not defined`,
|
|
1896
|
+
// 用户点「在此新建对话」时必然失败(1.0.16 的真实报障)。
|
|
1897
|
+
workspaceMap,
|
|
1898
|
+
persist: () => saveRemoteSessions(remoteSessions, ctx.logger),
|
|
1899
|
+
// 模型/预设的静默缓存:配对后预拉、刷新按钮回写(见 desktopCache)。
|
|
1900
|
+
desktopCache,
|
|
1901
|
+
prefetch: prefetchDesktop,
|
|
1902
|
+
// ⚠️ 同 `workspaceMap`:`handleBridge` 是模块级函数,闭包里的
|
|
1903
|
+
// `prefetchDirs` / `rememberDir` 它看不见,必须显式传。
|
|
1904
|
+
prefetchDirs,
|
|
1905
|
+
rememberDir,
|
|
1906
|
+
body,
|
|
1907
|
+
}).then(
|
|
1908
|
+
(result) => reply(200, { ok: true, result }),
|
|
1909
|
+
(error) => reply(200, { ok: false, error: String(error?.message ?? error) }),
|
|
1910
|
+
);
|
|
1544
1911
|
});
|
|
1545
1912
|
},
|
|
1546
1913
|
}),
|
|
@@ -1554,6 +1921,14 @@ export function apply(ctx) {
|
|
|
1554
1921
|
kind: 'exact',
|
|
1555
1922
|
path: ROUTE_STREAM,
|
|
1556
1923
|
handler: (req, res) => {
|
|
1924
|
+
// ⚠️ 同上:`GET /api/dsh-remote/stream` 会把**全部远程会话的
|
|
1925
|
+
// 流式 AI 正文**推给任何一个订阅者(外部审计 D5),必须有门。
|
|
1926
|
+
const rejected = bridgeRejection(ctx, req);
|
|
1927
|
+
if (rejected !== undefined) {
|
|
1928
|
+
res.writeHead(rejected);
|
|
1929
|
+
res.end();
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1557
1932
|
res.writeHead(200, {
|
|
1558
1933
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
1559
1934
|
'cache-control': 'no-store, no-transform',
|
|
@@ -1736,7 +2111,14 @@ async function handleBridge(ctx, config, io) {
|
|
|
1736
2111
|
case 'listDesktop': {
|
|
1737
2112
|
const wantRoot = params.path === undefined || params.path === null || params.path === '';
|
|
1738
2113
|
const path = wantRoot ? '' : params.path;
|
|
1739
|
-
|
|
2114
|
+
// ⚠️ 读缓存也要过 `isSafeCacheKey`(外部审计 5.2e):`dirs['__proto__']`
|
|
2115
|
+
// 在无原型对象上是 `undefined`,但在**普通对象**上会返回 `Object.prototype`
|
|
2116
|
+
// ⇒ 缓存命中一个不是列表的东西。这里两个条件都要求。
|
|
2117
|
+
const cached = wantRoot
|
|
2118
|
+
? io.desktopCache?.directory
|
|
2119
|
+
: isSafeCacheKey(path)
|
|
2120
|
+
? io.desktopCache?.dirs?.[path]
|
|
2121
|
+
: undefined;
|
|
1740
2122
|
if (cached != null) {
|
|
1741
2123
|
// 命中也要预取下一层:用户还没离开这一屏。
|
|
1742
2124
|
io.prefetchDirs?.(cached);
|
|
@@ -1807,7 +2189,7 @@ async function handleBridge(ctx, config, io) {
|
|
|
1807
2189
|
// ⚠️ **刷新要把子层缓存整棵作废**:用户点刷新就是要看电脑现状,
|
|
1808
2190
|
// 留着旧的子层,点进去看到的还是刷新前的内容(根层作废了、
|
|
1809
2191
|
// 子层没作废,比两处都不作废更难解释)。
|
|
1810
|
-
io.desktopCache.dirs =
|
|
2192
|
+
io.desktopCache.dirs = emptyDirCache();
|
|
1811
2193
|
if (
|
|
1812
2194
|
result.directory?.ok === true &&
|
|
1813
2195
|
io.desktopCache.directory?.path === result.directory.value?.path
|
|
@@ -2008,7 +2390,7 @@ async function handleBridge(ctx, config, io) {
|
|
|
2008
2390
|
// **累加**的(用户每浏览一层写一格),不清的话换一台电脑配对后,
|
|
2009
2391
|
// 旧电脑那些子目录还会命中缓存 —— 用户点进去看到的是上一台电脑的内容。
|
|
2010
2392
|
if (io.desktopCache !== undefined) {
|
|
2011
|
-
io.desktopCache.dirs =
|
|
2393
|
+
io.desktopCache.dirs = emptyDirCache();
|
|
2012
2394
|
io.desktopCache.directory = null;
|
|
2013
2395
|
}
|
|
2014
2396
|
io.persist();
|