dsh-mobile-plugin 0.0.0-stage → 0.1.58
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 +159 -2
- package/config/agent-presets/standard/agent.cordis.yml +170 -0
- package/config/agent-presets/standard/preset.yml +3 -0
- package/cordis.mobile-host.yml +48 -0
- package/cordis.patch.yml +21 -0
- package/dsh-mobile-credentials/lib/credentials.js +135 -0
- package/dsh-mobile-credentials/package.json +13 -0
- package/host.js +128 -0
- package/lib/auto-archive.cjs +98 -0
- package/lib/client.js +690 -0
- package/lib/desktop-half-stats.cjs +101 -0
- package/lib/desktop-view.cjs +109 -0
- package/lib/host-guard.cjs +230 -0
- package/lib/host-patches.cjs +46 -0
- package/lib/http/package.json +3 -0
- package/lib/http/routing.js +84 -0
- package/lib/impl.js +406 -0
- package/lib/mirror/pusher.cjs +706 -0
- package/lib/mirror-snapshot.cjs +39 -0
- package/lib/mobile/agent-presets.js +182 -0
- package/lib/mobile/attachments.js +116 -0
- package/lib/mobile/auth.js +78 -0
- package/lib/mobile/completion.js +114 -0
- package/lib/mobile/credentials.js +150 -0
- package/lib/mobile/directory-browse.js +295 -0
- package/lib/mobile/envelope.js +51 -0
- package/lib/mobile/events.js +293 -0
- package/lib/mobile/history-derive.js +608 -0
- package/lib/mobile/host-bridge/endpoints.js +189 -0
- package/lib/mobile/host-bridge/event-synthesis.js +232 -0
- package/lib/mobile/host-bridge/shims.js +218 -0
- package/lib/mobile/host-bridge/transport.js +168 -0
- package/lib/mobile/host-bridge.js +71 -0
- package/lib/mobile/host-settings.js +282 -0
- package/lib/mobile/idempotency.js +78 -0
- package/lib/mobile/jobs.js +97 -0
- package/lib/mobile/local-images.js +153 -0
- package/lib/mobile/package.json +3 -0
- package/lib/mobile/plugins.js +149 -0
- package/lib/mobile/questions.js +71 -0
- package/lib/mobile/references.js +209 -0
- package/lib/mobile/rpc.js +76 -0
- package/lib/mobile/session-cache.js +263 -0
- package/lib/mobile/session-goal.js +44 -0
- package/lib/mobile/session-log.js +94 -0
- package/lib/mobile/session-mirror.js +177 -0
- package/lib/mobile/session-models.js +153 -0
- package/lib/mobile/session-presets.js +52 -0
- package/lib/mobile/session-search.js +65 -0
- package/lib/mobile/session-send.js +278 -0
- package/lib/mobile/session-snapshot.js +142 -0
- package/lib/mobile/sessions.js +462 -0
- package/lib/mobile/skills.js +80 -0
- package/lib/mobile/stream.js +268 -0
- package/lib/mobile/subagents.js +265 -0
- package/lib/mobile/workspaces.js +214 -0
- package/lib/panel/index.html +33 -0
- package/lib/read-receipt.cjs +124 -0
- package/lib/reconnect-scheduler.cjs +37 -0
- package/lib/relay/core.js +77 -0
- package/lib/relay/lan-network.cjs +85 -0
- package/lib/relay/lan-proxy.js +593 -0
- package/lib/relay/package.json +3 -0
- package/lib/relay/pairing-policy.js +45 -0
- package/lib/relay/port-allocator.cjs +60 -0
- package/lib/relay/preview.js +374 -0
- package/lib/relay/protocol.js +177 -0
- package/lib/relay/status.js +145 -0
- package/lib/relay/tunnel.js +721 -0
- package/lib/routes/control-plane.js +377 -0
- package/lib/routes/mobile-api.js +208 -0
- package/lib/routes/package.json +3 -0
- package/lib/session-cleanup.cjs +153 -0
- package/lib/shared/credentials-entry.js +48 -0
- package/lib/shared/dshtoken.js +62 -0
- package/lib/shared/fresh-request.js +35 -0
- package/lib/shared/http-util.js +71 -0
- package/lib/shared/list-revision.js +51 -0
- package/lib/shared/list-signal.js +87 -0
- package/lib/shared/package.json +3 -0
- package/lib/shared/paths.js +27 -0
- package/lib/shared/session-signal.js +110 -0
- package/lib/shared/workspace-guard.js +127 -0
- package/lib/sidecar-facade-worker.cjs +353 -0
- package/lib/sidecar-facade.cjs +141 -0
- package/lib/sidecar-lifecycle.cjs +246 -0
- package/package.json +74 -4
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「桌面页面里的客户端半边」活体台账。
|
|
5
|
+
*
|
|
6
|
+
* 绿点的两条同步通道都是**单向轮询 + 无回执**:客户端半边每 2s 一次 GET/POST,宿主
|
|
7
|
+
* 只管收下,收没收到、跑没跑,宿主侧一点痕迹都没有。2026-10-10 的绿点不同步故障里,
|
|
8
|
+
* 正是这一点把排查卡住了 —— 桌面看得见绿点、手机看得见绿点,却没人能说出「桌面那一半
|
|
9
|
+
* 到底有没有来敲过门」,于是「通道断了」和「通道通了但没清掉」两种可能一直分不开。
|
|
10
|
+
*
|
|
11
|
+
* 这里只记**计数与最近时刻**(不含任何会话内容、不含 id),经 `/api/relay/status` 的
|
|
12
|
+
* `desktopHalf` 字段暴露。三个计数是一组对照:
|
|
13
|
+
* - `readReceiptGets`:方向二(手机读过 → 桌面灭灯)的输入,**无条件 2s 轮询**,
|
|
14
|
+
* 所以它只要在涨,就说明这条 effect 真的在跑;
|
|
15
|
+
* - `desktopReadPosts`:方向一(桌面读过 → 手机灭灯)的输出,只在「桌面绿点刚掉出去」
|
|
16
|
+
* 时才会发,所以它不涨既可能是「没人读过」也可能是「没检测出来」;
|
|
17
|
+
* - `desktopViewPosts`:同一个半边、同一套 fetch 的**已知可用**对照组(侧栏展开态),
|
|
18
|
+
* 它涨而别的两个不涨,等于「半边活着,是那两条 effect 没跑」。
|
|
19
|
+
*
|
|
20
|
+
* 刻意不做上限/轮转:三个整数加三个时刻,涨到天荒地老也只是几个数字。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const state = {
|
|
24
|
+
/** 宿主进程起算的时刻,便于判断「计数很老」还是「刚重启过」。 */
|
|
25
|
+
since: Date.now(),
|
|
26
|
+
readReceiptGets: 0,
|
|
27
|
+
readReceiptLastAt: 0,
|
|
28
|
+
desktopReadPosts: 0,
|
|
29
|
+
desktopReadLastAt: 0,
|
|
30
|
+
/** 累计上报过的会话 id 数(不是条数:一次 POST 可以带多个 id)。 */
|
|
31
|
+
desktopReadIds: 0,
|
|
32
|
+
/** 累计真正清掉的设备维度条目数(0 说明上报的 id 在宿主这边本来就没亮)。 */
|
|
33
|
+
desktopReadCleared: 0,
|
|
34
|
+
desktopViewPosts: 0,
|
|
35
|
+
desktopViewLastAt: 0,
|
|
36
|
+
/** 客户端半边自检上报:次数 / 最近一次时刻 / 第一次时刻 / 最近一份内容。 */
|
|
37
|
+
clientReports: 0,
|
|
38
|
+
clientLastAt: 0,
|
|
39
|
+
clientSeenAt: 0,
|
|
40
|
+
client: null,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** 客户端半边拉了一次已读回执台账(方向二的输入侧)。 */
|
|
44
|
+
function noteReadReceiptGet() {
|
|
45
|
+
state.readReceiptGets += 1;
|
|
46
|
+
state.readReceiptLastAt = Date.now();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 客户端半边报了一次「桌面读过了」。 */
|
|
50
|
+
function noteDesktopRead({ ids = 0, cleared = 0 } = {}) {
|
|
51
|
+
state.desktopReadPosts += 1;
|
|
52
|
+
state.desktopReadLastAt = Date.now();
|
|
53
|
+
state.desktopReadIds += Number.isFinite(ids) ? ids : 0;
|
|
54
|
+
state.desktopReadCleared += Number.isFinite(cleared) ? cleared : 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 客户端半边推了一次桌面侧栏展开态(对照组)。 */
|
|
58
|
+
function noteDesktopViewPost() {
|
|
59
|
+
state.desktopViewPosts += 1;
|
|
60
|
+
state.desktopViewLastAt = Date.now();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 客户端半边的**自检上报**:它在页面里能不能看见那两个服务、两条同步各跑了几轮。
|
|
65
|
+
*
|
|
66
|
+
* 这是唯一能回答「那半边到底看见了什么」的通道 —— 宿主能数出「它没来敲门」,但数不出
|
|
67
|
+
* 「它为什么没来」。字段全部白名单收敛到布尔/短字符串/整数:这份体是页面递进来的,
|
|
68
|
+
* 入口已经限长,这里再挡一道,免得把任意对象灌进 status 视图。
|
|
69
|
+
*/
|
|
70
|
+
const CLIENT_REPORT_KEYS = [
|
|
71
|
+
'loaded', 'effectUiSession', 'effectSessions',
|
|
72
|
+
'getUiSession', 'getSessions', 'getRetain', 'injectUiSession', 'injectSessions',
|
|
73
|
+
'receiptGets', 'readPosts', 'unreadCount', 'lastError',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function noteClientHalf(report) {
|
|
77
|
+
const clean = {};
|
|
78
|
+
if (report && typeof report === 'object' && !Array.isArray(report)) {
|
|
79
|
+
for (const key of CLIENT_REPORT_KEYS) {
|
|
80
|
+
const value = report[key];
|
|
81
|
+
if (typeof value === 'boolean' || typeof value === 'number') {
|
|
82
|
+
clean[key] = typeof value === 'number' && !Number.isFinite(value) ? null : value;
|
|
83
|
+
} else if (typeof value === 'string') {
|
|
84
|
+
clean[key] = value.slice(0, 120);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
state.clientReports += 1;
|
|
89
|
+
state.clientLastAt = Date.now();
|
|
90
|
+
state.clientSeenAt = state.clientSeenAt || state.clientLastAt;
|
|
91
|
+
state.client = clean;
|
|
92
|
+
return clean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 当前计数副本;status 视图只读它,绝不改。 */
|
|
96
|
+
function snapshot() {
|
|
97
|
+
const { client, ...counters } = state;
|
|
98
|
+
return { ...counters, client: client ? { ...client } : null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { noteReadReceiptGet, noteDesktopRead, noteDesktopViewPost, noteClientHalf, snapshot };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 桌面端工作区视图快照(侧边栏展开/折叠)的宿主侧存储。
|
|
5
|
+
*
|
|
6
|
+
* 桌面端把「哪些工作区分组是展开的」只记在**浏览器 localStorage**
|
|
7
|
+
* (`dsh.workspace.view.v5`,见 @deepseek-ai/dsh-client-ui-workspace 的
|
|
8
|
+
* createWorkspaceViewStore → dsh-client-store 的 localStorage 持久化),
|
|
9
|
+
* 宿主进程、中转和手机都读不到它。插件因此带了一个客户端半边(lib/client.js):
|
|
10
|
+
* 桌面页面里每 2 秒读一次这份状态,变了就 POST `/api/relay/desktop-view`,
|
|
11
|
+
* 由本模块落到 `$DSH_HOME/desktop-view.json`,再由 `/api/mobile/v1/desktop-view`
|
|
12
|
+
* 下发给 App。
|
|
13
|
+
*
|
|
14
|
+
* 语义与桌面端严格一致:`groupExpansion` 以 **workspaceId** 为键(未分组分组固定为
|
|
15
|
+
* 空串 `''`),只有**显式 true** 表示展开,缺省=折叠;桌面端在「当前会话所属分组
|
|
16
|
+
* 还没有记录」时会自动展开它并落盘,所以「当前会话所在工作区」通常也在里面。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const paths = require('./shared/paths');
|
|
22
|
+
|
|
23
|
+
/** 单个键(workspaceId)长度上限;超长的一律丢弃(真实 id 只有十几字节)。 */
|
|
24
|
+
const MAX_KEY_LENGTH = 200;
|
|
25
|
+
/** 键数量上限:桌面端可能的分组数远小于它,这里只用于防灌爆。 */
|
|
26
|
+
const MAX_KEYS = 512;
|
|
27
|
+
/** 上报来源标识(桌面端的 localStorage 键名)长度上限。 */
|
|
28
|
+
const MAX_SOURCE_LENGTH = 64;
|
|
29
|
+
|
|
30
|
+
/** 快照文件:$DSH_HOME/desktop-view.json(DSH_HOME 在调用时解析,便于测试注入)。 */
|
|
31
|
+
function stateFile() {
|
|
32
|
+
return path.join(paths.dshHome(), 'desktop-view.json');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** null = 还没有可用快照;否则 { groupExpansion, receivedAt, source }。 */
|
|
36
|
+
let cached = null;
|
|
37
|
+
let loaded = false;
|
|
38
|
+
|
|
39
|
+
/** 归一化上报的 groupExpansion:只保留布尔值、限长限量;形状不对返回 null。 */
|
|
40
|
+
function normalizeGroupExpansion(input) {
|
|
41
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
42
|
+
const out = {};
|
|
43
|
+
let count = 0;
|
|
44
|
+
for (const rawKey of Object.keys(input)) {
|
|
45
|
+
const value = input[rawKey];
|
|
46
|
+
if (typeof value !== 'boolean') continue;
|
|
47
|
+
const key = rawKey.trim();
|
|
48
|
+
if (key.length > MAX_KEY_LENGTH) continue;
|
|
49
|
+
out[key] = value;
|
|
50
|
+
count += 1;
|
|
51
|
+
if (count >= MAX_KEYS) break;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function load() {
|
|
57
|
+
if (loaded) return cached;
|
|
58
|
+
loaded = true;
|
|
59
|
+
try {
|
|
60
|
+
const raw = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
|
|
61
|
+
const groupExpansion = normalizeGroupExpansion(raw && raw.groupExpansion);
|
|
62
|
+
if (!groupExpansion) {
|
|
63
|
+
cached = null;
|
|
64
|
+
return cached;
|
|
65
|
+
}
|
|
66
|
+
cached = {
|
|
67
|
+
groupExpansion,
|
|
68
|
+
receivedAt: Number.isFinite(raw && raw.receivedAt) ? raw.receivedAt : 0,
|
|
69
|
+
source: typeof (raw && raw.source) === 'string' ? raw.source : '',
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
// 文件不存在 / 损坏都按「桌面端还没上报过」处理:App 侧退回折叠缺省。
|
|
73
|
+
cached = null;
|
|
74
|
+
}
|
|
75
|
+
return cached;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 当前快照的副本;从未收到过上报时返回 null。 */
|
|
79
|
+
function snapshot() {
|
|
80
|
+
const value = load();
|
|
81
|
+
if (!value) return null;
|
|
82
|
+
return {
|
|
83
|
+
groupExpansion: { ...value.groupExpansion },
|
|
84
|
+
updatedAt: value.receivedAt,
|
|
85
|
+
source: value.source,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 记录一次桌面页面上报;非法形状抛错(调用方回 400)。返回落盘后的快照。 */
|
|
90
|
+
function update(input, now = Date.now()) {
|
|
91
|
+
const groupExpansion = normalizeGroupExpansion(input && input.groupExpansion);
|
|
92
|
+
if (!groupExpansion) throw new Error('groupExpansion 必须是对象');
|
|
93
|
+
const rawSource = input && (typeof input.source === 'string' ? input.source : input.storageKey);
|
|
94
|
+
const next = {
|
|
95
|
+
groupExpansion,
|
|
96
|
+
receivedAt: now,
|
|
97
|
+
source: typeof rawSource === 'string' ? rawSource.slice(0, MAX_SOURCE_LENGTH) : '',
|
|
98
|
+
};
|
|
99
|
+
const file = stateFile();
|
|
100
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
101
|
+
const tmp = `${file}.tmp`;
|
|
102
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
103
|
+
fs.renameSync(tmp, file);
|
|
104
|
+
cached = next;
|
|
105
|
+
loaded = true;
|
|
106
|
+
return snapshot();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { stateFile, snapshot, update };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 宿主进程级错误边界(crash guard)。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么要这个模块
|
|
7
|
+
*
|
|
8
|
+
* DSH 宿主是**长驻进程**:装在桌面版 DSH 里(macOS/Windows),也被 systemd 以
|
|
9
|
+
* `Restart=on-failure` 托管(服务器无头实例)。任何一次真正逃逸到事件循环的异常都会
|
|
10
|
+
* 带走整个 DSH——用户那边表现为「DSH 崩了/插件把 DSH 搞挂了」,而且桌面端连个
|
|
11
|
+
* 插件隔离层都没有,插件的 bug 直接就是宿主的 bug。
|
|
12
|
+
*
|
|
13
|
+
* 路由层其实已经被兜住了(`dsh-host-webserver` 给每个 handler 挂了 `.catch`),
|
|
14
|
+
* 但**进程层那层兜底在本插件里是完全没有的**:`grep -rn "unhandledRejection\|uncaughtException"`
|
|
15
|
+
* 在整个 dsh-mobile-plugin/ 与 dsh-mobile-server/ 里零命中。缺口正在于:
|
|
16
|
+
*
|
|
17
|
+
* 1. **游离的 Promise 拒绝**。Node 15+ 起 `unhandledRejection` 默认行为是 *throw*,
|
|
18
|
+
* 也就是**杀进程**。插件里凡是 `void someAsync()`(fire-and-forget)的地方,
|
|
19
|
+
* 一个没 catch 的 reject 就等于一次 DSH 崩溃。这类点极多:自动归档轮询、
|
|
20
|
+
* 镜像推送、隧道重连、遥测上报……任意一个后台任务炸一次就够了。
|
|
21
|
+
* 2. **第三方回调里抛错**。宿主事件流、WS 回调、timer 回调抛出的异常没有 try 包裹,
|
|
22
|
+
* 直接升级为 `uncaughtException`。
|
|
23
|
+
*
|
|
24
|
+
* ## 策略:记录 + 保持存活,连续 5 次才退出
|
|
25
|
+
*
|
|
26
|
+
* 单个异常通常不致命(最常见的是"某个后台任务这一次没成")。一刀切退出等于把
|
|
27
|
+
* 偶发故障变成必然崩溃;一味吞掉又会掩盖真正的崩溃循环。所以取中间值:
|
|
28
|
+
*
|
|
29
|
+
* - **记录**:每一条都进内存账本,带时间/类型/消息/是否致命,供 `status()` 暴露
|
|
30
|
+
* (「及时阻止」的前提是能看见);
|
|
31
|
+
* - **保持存活**:`unhandledRejection` 与单次 `uncaughtException` 都只记录并继续跑,
|
|
32
|
+
* 不退出。这把「插件的偶发 bug」和「DSH 崩溃」解耦了;
|
|
33
|
+
* - **熔断**:只有 `uncaughtException` 计入致命计数,且**必须落在时间窗内**。
|
|
34
|
+
* 超过 `windowMs` 没再犯就重新计数——这样捕获的是「崩溃循环」(秒级连炸),
|
|
35
|
+
* 而不是「跑三个月偶发 5 次」的正常长驻。真正熔断时再主动退出,交给
|
|
36
|
+
* `Restart=on-failure` / 桌面端重启拉起,拿到一个干净的新进程。
|
|
37
|
+
*
|
|
38
|
+
* ## 为什么把状态机和进程接线拆开
|
|
39
|
+
*
|
|
40
|
+
* `createGuard()` 是纯状态机(时间/日志/退出都注入),测试可以完全确定性地驱动它,
|
|
41
|
+
* 不必真的往 `process` 上挂处理器、也不会在跑测试时把测试进程搞死;
|
|
42
|
+
* `installProcessGuard()` 才是那层薄薄的进程接线。这个拆分是本模块能被守卫测试
|
|
43
|
+
* 覆盖的前提。
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const FATAL_FAULT_COUNT = 5;
|
|
47
|
+
const CONSECUTIVE_FAULT_WINDOW_MS = 60 * 1000;
|
|
48
|
+
const FAULT_LEDGER_LIMIT = 50;
|
|
49
|
+
const FATAL_EXIT_CODE = 1;
|
|
50
|
+
|
|
51
|
+
/** 把任意 throw 出来的值压成一行可读文本。 */
|
|
52
|
+
function describeError(err) {
|
|
53
|
+
if (err === null || err === undefined) return String(err);
|
|
54
|
+
if (err instanceof Error) return err.stack || `${err.name}: ${err.message}`;
|
|
55
|
+
if (typeof err === 'object') {
|
|
56
|
+
try { return JSON.stringify(err); } catch { return Object.prototype.toString.call(err); }
|
|
57
|
+
}
|
|
58
|
+
return String(err);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 建立一个错误边界状态机(不碰 process)。
|
|
63
|
+
*
|
|
64
|
+
* @param {object} [options]
|
|
65
|
+
* @param {number} [options.fatalCount] 窗口内多少个 uncaughtException 判定致命,缺省 5
|
|
66
|
+
* @param {number} [options.windowMs] 「连续」的时间窗,缺省 60s
|
|
67
|
+
* @param {number} [options.ledgerLimit] 账本上限,缺省 50
|
|
68
|
+
* @param {() => number} [options.now] 注入时钟,便于测试
|
|
69
|
+
* @param {(line: string) => void} [options.log] 注入日志,便于测试
|
|
70
|
+
* @param {(code: number) => void} [options.exit] 注入退出,便于测试
|
|
71
|
+
* @returns {{handleUncaughtException: Function, handleUnhandledRejection: Function,
|
|
72
|
+
* record: Function, faults: Function, stats: Function, clear: Function,
|
|
73
|
+
* isFatal: Function}}
|
|
74
|
+
*/
|
|
75
|
+
function createGuard(options = {}) {
|
|
76
|
+
const fatalCount = options.fatalCount ?? FATAL_FAULT_COUNT;
|
|
77
|
+
const windowMs = options.windowMs ?? CONSECUTIVE_FAULT_WINDOW_MS;
|
|
78
|
+
const ledgerLimit = options.ledgerLimit ?? FAULT_LEDGER_LIMIT;
|
|
79
|
+
const now = options.now ?? (() => Date.now());
|
|
80
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
81
|
+
const log = options.log ?? ((line) => { process.stderr.write(`${line}\n`); });
|
|
82
|
+
|
|
83
|
+
/** @type {{at: number, kind: string, message: string, fatal: boolean}[]} */
|
|
84
|
+
let ledger = [];
|
|
85
|
+
let consecutive = 0;
|
|
86
|
+
let lastFatalKindAt = 0;
|
|
87
|
+
let fatalEver = false;
|
|
88
|
+
|
|
89
|
+
function record(kind, message, fatal) {
|
|
90
|
+
// 满了就丢最旧的:账本是用来回答「刚才发生了什么」,不是完整审计日志。
|
|
91
|
+
if (ledger.length >= ledgerLimit) ledger.shift();
|
|
92
|
+
ledger.push({ at: now(), kind, message, fatal: Boolean(fatal) });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
/**
|
|
97
|
+
* `process.on('uncaughtException')` 的处理函数。
|
|
98
|
+
* 记一笔,计入连续计数,够数才退出。
|
|
99
|
+
*/
|
|
100
|
+
handleUncaughtException(err) {
|
|
101
|
+
const at = now();
|
|
102
|
+
// 「连续」= 距离上一个致命类异常没超过窗口。超窗则重新从 1 数,
|
|
103
|
+
// 这样零星故障不会累加到熔断,只有崩溃循环才会。
|
|
104
|
+
consecutive = (lastFatalKindAt !== 0 && at - lastFatalKindAt <= windowMs) ? consecutive + 1 : 1;
|
|
105
|
+
lastFatalKindAt = at;
|
|
106
|
+
const fatal = consecutive >= fatalCount;
|
|
107
|
+
if (fatal) fatalEver = true;
|
|
108
|
+
record('uncaughtException', describeError(err), fatal);
|
|
109
|
+
log(`[dsh-mobile-plugin] 未捕获异常(连续 ${consecutive}/${fatalCount}${fatal ? ',判定崩溃循环,将退出' : ''}): ${describeError(err)}`);
|
|
110
|
+
if (fatal) exit(FATAL_EXIT_CODE);
|
|
111
|
+
return fatal;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `process.on('unhandledRejection')` 的处理函数。
|
|
116
|
+
*
|
|
117
|
+
* 刻意**不计入熔断**:游离 reject 在长驻服务里非常常见(一次后台推送失败就
|
|
118
|
+
* 一个),把它们累加成退出条件会制造新的崩溃源。挂上处理器本身就已经把
|
|
119
|
+
* Node 的默认「throw 并杀进程」改成了「记录并继续」。
|
|
120
|
+
*/
|
|
121
|
+
handleUnhandledRejection(reason) {
|
|
122
|
+
record('unhandledRejection', describeError(reason), false);
|
|
123
|
+
log(`[dsh-mobile-plugin] 未处理的 Promise 拒绝(已忽略): ${describeError(reason)}`);
|
|
124
|
+
return false;
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
/** 手动记一条非致命故障(例如某个 handler 自己 catch 后上报的)。 */
|
|
128
|
+
recordFault(kind, message) {
|
|
129
|
+
record(kind, String(message), false);
|
|
130
|
+
return false;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/** 账本快照(按时间正序)。 */
|
|
134
|
+
faults() { return ledger.slice(); },
|
|
135
|
+
|
|
136
|
+
/** 供 `status()` 暴露的汇总。 */
|
|
137
|
+
stats() {
|
|
138
|
+
return {
|
|
139
|
+
total: ledger.length,
|
|
140
|
+
consecutive,
|
|
141
|
+
lastAt: ledger.length ? ledger[ledger.length - 1].at : 0,
|
|
142
|
+
fatalCount,
|
|
143
|
+
windowMs,
|
|
144
|
+
fatal: fatalEver,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/** 清空账本与计数(测试用 / 运维排障后复位)。 */
|
|
149
|
+
clear() { ledger = []; consecutive = 0; lastFatalKindAt = 0; fatalEver = false; },
|
|
150
|
+
|
|
151
|
+
/** 当前是否已经熔断过。 */
|
|
152
|
+
isFatal() { return fatalEver; },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @type {ReturnType<typeof createGuard>|null} 已安装的边界,供 safeAsync 复用。 */
|
|
157
|
+
let installed = null;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 把状态机接到真实 process 上。**必须在任何业务代码之前调用**,
|
|
161
|
+
* 否则启动阶段的游离 reject 仍然会按 Node 默认行为杀进程。
|
|
162
|
+
*
|
|
163
|
+
* @param {object} [options] 同 createGuard,另可传 `target`(默认 process)便于测试
|
|
164
|
+
* @returns {{guard: ReturnType<typeof createGuard>, uninstall: () => void}}
|
|
165
|
+
*/
|
|
166
|
+
function installProcessGuard(options = {}) {
|
|
167
|
+
const { target = process, ...rest } = options;
|
|
168
|
+
const guard = rest.guard ?? createGuard(rest);
|
|
169
|
+
const onUncaught = (err) => { guard.handleUncaughtException(err); };
|
|
170
|
+
const onRejection = (reason) => { guard.handleUnhandledRejection(reason); };
|
|
171
|
+
target.on('uncaughtException', onUncaught);
|
|
172
|
+
target.on('unhandledRejection', onRejection);
|
|
173
|
+
installed = guard;
|
|
174
|
+
return {
|
|
175
|
+
guard,
|
|
176
|
+
uninstall() {
|
|
177
|
+
target.off('uncaughtException', onUncaught);
|
|
178
|
+
target.off('unhandledRejection', onRejection);
|
|
179
|
+
if (installed === guard) installed = null;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 取当前已安装的边界(没装返回 null)。 */
|
|
185
|
+
function activeGuard() { return installed; }
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 把一个可能抛/可能 reject 的调用包成「永不把异常带出去」的函数。
|
|
189
|
+
*
|
|
190
|
+
* 这是本模块想复用的那一块:插件里大量 fire-and-forget 的调用点(自动归档轮询、
|
|
191
|
+
* 镜像推送、遥测、隧道重连……)都可以直接套一层,再也不必各自 try/catch,
|
|
192
|
+
* 也再也不会有一处忘了 catch 就把 DSH 带走。
|
|
193
|
+
*
|
|
194
|
+
* @param {Function} fn 原始函数
|
|
195
|
+
* @param {string} [label] 出错时用于定位的来源名
|
|
196
|
+
* @param {ReturnType<typeof createGuard>|null} [guard] 指定边界,缺省用已安装的那个
|
|
197
|
+
* @returns {(...args: any[]) => Promise<void>} 永不 reject
|
|
198
|
+
*/
|
|
199
|
+
function safeAsync(fn, label = 'anonymous', guard = null) {
|
|
200
|
+
return function guarded(...args) {
|
|
201
|
+
const report = (err) => {
|
|
202
|
+
const g = guard ?? installed;
|
|
203
|
+
const message = `${label}: ${describeError(err)}`;
|
|
204
|
+
if (g) g.recordFault('safeAsync', message);
|
|
205
|
+
else process.stderr.write(`[dsh-mobile-plugin] 未保护的异步调用 ${message}\n`);
|
|
206
|
+
};
|
|
207
|
+
try {
|
|
208
|
+
return Promise.resolve(fn(...args)).then(
|
|
209
|
+
() => undefined,
|
|
210
|
+
(err) => { report(err); },
|
|
211
|
+
);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
// 同步抛也要吞:调用点写的是 fire-and-forget,不该因为参数错就把进程带走。
|
|
214
|
+
report(err);
|
|
215
|
+
return Promise.resolve();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
createGuard,
|
|
222
|
+
installProcessGuard,
|
|
223
|
+
activeGuard,
|
|
224
|
+
safeAsync,
|
|
225
|
+
describeError,
|
|
226
|
+
FATAL_FAULT_COUNT,
|
|
227
|
+
CONSECUTIVE_FAULT_WINDOW_MS,
|
|
228
|
+
FAULT_LEDGER_LIMIT,
|
|
229
|
+
FATAL_EXIT_CODE,
|
|
230
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 无头宿主拼 patch 层时的两处修正。抽成独立文件是为了能直接测:host.js 一 import
|
|
5
|
+
* 就 `boot()` 整个 Cordis 宿主,测试碰不得。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 摘掉「按裸包名挂载本插件」的 patch 行。
|
|
10
|
+
*
|
|
11
|
+
* `cordis.patch.yml` 里有一行 `- insert: [{ id: dsh-mobile, name: 'dsh-mobile-plugin' }]`,
|
|
12
|
+
* 那是给**桌面端**用的:DSH 的插件管理器把这个包装进
|
|
13
|
+
* `~/.dsh/profiles/<profile>/node_modules`,Loader 从那里按包名解析得到 `lib/index.js`。
|
|
14
|
+
*
|
|
15
|
+
* 无头宿主没有那次安装 —— 插件**就是** Loader 正在跑的那个包,没人把它放进
|
|
16
|
+
* `node_modules` —— 于是这一行解析不到。而 loader 对解析失败**不是**留一条警告,是整棵树
|
|
17
|
+
* 加载失败:
|
|
18
|
+
*
|
|
19
|
+
* dsh-mobile-plugin: boot failed: Error: dsh-mobile-plugin: plugin tree failed to load:
|
|
20
|
+
* failed to apply loader entry include (cordis:include): failed to import loader entry
|
|
21
|
+
* dsh-mobile (dsh-mobile-plugin): Cannot find package 'dsh-mobile-plugin'
|
|
22
|
+
* code: 'ERR_MODULE_NOT_FOUND'
|
|
23
|
+
*
|
|
24
|
+
* (实测:服务器宿主从引入这一行的提交起就再也起不来了。)宿主自己用绝对路径另插一行
|
|
25
|
+
* `mobile-api` 挂载本插件,所以这一行在这里只有害处。
|
|
26
|
+
*
|
|
27
|
+
* 不改 `cordis.patch.yml` 本身:桌面端还要靠它。只在无头宿主装载前从内存里摘掉。
|
|
28
|
+
*
|
|
29
|
+
* @param {Array} patches 已加载的 patch 层(loadOverlayPatches 的返回值)
|
|
30
|
+
* @param {string} binName 本插件的包名
|
|
31
|
+
* @returns {Array} 新数组;空的 insert 壳会被整条去掉
|
|
32
|
+
*/
|
|
33
|
+
function dropBareNameSelfMounts(patches, binName) {
|
|
34
|
+
return patches.flatMap((patch) => {
|
|
35
|
+
if (!patch || !Array.isArray(patch.insert)) return [patch]
|
|
36
|
+
const kept = patch.insert.filter((row) => !(row && typeof row === 'object' && row.name === binName))
|
|
37
|
+
if (kept.length === patch.insert.length) return [patch]
|
|
38
|
+
const rest = { ...patch }
|
|
39
|
+
delete rest.insert
|
|
40
|
+
// 整条 patch 就只剩这一行时,连壳一起去掉(空 insert 合法但没有意义)。
|
|
41
|
+
if (kept.length > 0) return [{ ...rest, insert: kept }]
|
|
42
|
+
return Object.keys(rest).length > 0 ? [rest] : []
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { dropBareNameSelfMounts }
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 插件 HTTP 路由基建:控制面与移动端两套守卫的共用地基。
|
|
5
|
+
*
|
|
6
|
+
* 这些函数此前是 lib/index.js 里的局部闭包,紧挨着 65 条路由注册写在一起。它们
|
|
7
|
+
* 本身是纯函数(只依赖 req/res),却因为住在 apply() 里而无法单测——而这几条守卫
|
|
8
|
+
* 恰好是最该有测试的地方:它们挡的是「谁能调控制面」「谁能调移动端」,写错一行的
|
|
9
|
+
* 后果是鉴权被绕过而不是某个页面显示不出来。
|
|
10
|
+
*
|
|
11
|
+
* 抽出来单独放一个文件是为了让它们能被直接 require 并喂假 req/res 测试。抽出后
|
|
12
|
+
* 两套守卫并排放在一个文件里,肉眼就能对照出「控制面多挡了跨站与 method,移动端只
|
|
13
|
+
* 挡 token」这个刻意的不对称。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
17
|
+
|
|
18
|
+
// Relay pairing cookies and instance headers are routing credentials only;
|
|
19
|
+
// never allow them to reach the local control plane.
|
|
20
|
+
const remoteRelayRequest = (req) => Boolean(
|
|
21
|
+
(typeof req.headers['x-dsh-instance'] === 'string' && req.headers['x-dsh-instance'])
|
|
22
|
+
|| /(?:^|;\s*)dsh_(?:pair|lan)=/.test(String(req.headers.cookie || '')),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// Cross-site guard for the control plane.
|
|
26
|
+
//
|
|
27
|
+
// `localOnly` alone only rejected requests that carried relay routing
|
|
28
|
+
// credentials, so a plain cross-site `GET /api/relay/revoke-all` from any page
|
|
29
|
+
// the user had open still executed (the routes had no method check either).
|
|
30
|
+
// Rejecting cross-site fetches and pinning each route to one method closes
|
|
31
|
+
// that: a control-plane call now has to be a same-origin top-level/`same-origin`
|
|
32
|
+
// request, and it has to use the documented verb.
|
|
33
|
+
const crossSiteRequest = (req) => {
|
|
34
|
+
if (String(req.headers['sec-fetch-site'] || '').toLowerCase() === 'cross-site') return true
|
|
35
|
+
const origin = req.headers.origin
|
|
36
|
+
if (typeof origin !== 'string' || !origin) return false
|
|
37
|
+
const host = req.headers.host
|
|
38
|
+
if (typeof host !== 'string' || !host) return true
|
|
39
|
+
try { return new URL(origin).host !== host } catch { return true }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// `method` 可以是单个方法名,也可以是方法名数组(同一个路径上挂 GET+POST,
|
|
43
|
+
// 例如 /api/relay/desktop-view 既读快照又收回传)。单方法时的 405 文案保持不变。
|
|
44
|
+
const localOnly = (method, handler) => async (req, res) => {
|
|
45
|
+
if (remoteRelayRequest(req)) return sendJson(res, 403, { ok: false, error: { code: 'local_only', message: '控制面仅允许本机访问' } })
|
|
46
|
+
if (crossSiteRequest(req)) return sendJson(res, 403, { ok: false, error: { code: 'cross_site', message: '控制面拒绝跨站请求' } })
|
|
47
|
+
const allowed = Array.isArray(method) ? method : [method]
|
|
48
|
+
const actual = String(req.method || 'GET').toUpperCase()
|
|
49
|
+
if (!allowed.includes(actual)) {
|
|
50
|
+
const label = Array.isArray(method) ? method.join(' / ') : method
|
|
51
|
+
return sendJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: `仅支持 ${label}` } })
|
|
52
|
+
}
|
|
53
|
+
return handler(req, res)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const readJson = (req) => readJsonBody(req, 256 * 1024).then(body => body || {})
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 移动端路由守卫:token + 把 handler 抛出的异常统一收成 500 信封。
|
|
60
|
+
*
|
|
61
|
+
* 与 localOnly 的不对称是刻意的:移动端请求本来就要从公网进来,跨站不是它的风险;
|
|
62
|
+
* 它的风险是「没带 token 也能读别人的会话」,所以只校验 token。
|
|
63
|
+
*
|
|
64
|
+
* @param {object} ctx 插件 ctx,透传给 handler
|
|
65
|
+
* @param {(req: object) => boolean} authorized 移动 token 校验,由调用方注入
|
|
66
|
+
* @returns {(handler: (ctx: object, req: object, res: object) => Promise<unknown>) => (req: object, res: object) => Promise<unknown>}
|
|
67
|
+
*/
|
|
68
|
+
const createGuarded = (ctx, authorized) => (handler) => async (req, res) => {
|
|
69
|
+
if (!authorized(req)) return sendJson(res, 401, { ok: false, error: { code: 'unauthorized', message: '缺少或无效的移动 token' } })
|
|
70
|
+
try { await handler(ctx, req, res) } catch (error) {
|
|
71
|
+
sendJson(res, 500, { ok: false, error: { code: 'internal', message: String(error?.message || error) } })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
remoteRelayRequest,
|
|
77
|
+
crossSiteRequest,
|
|
78
|
+
localOnly,
|
|
79
|
+
readJson,
|
|
80
|
+
createGuarded,
|
|
81
|
+
// shared/http-util 重出一次:这个文件里的守卫全都用它写响应,
|
|
82
|
+
// 让「路由基建」成为一处完整的地基,而不是散在三个文件里各写一半。
|
|
83
|
+
sendJson,
|
|
84
|
+
};
|