@mrrisega/dsh-remote 0.7.9 → 0.7.10-beta.10
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 +9 -2
- package/clients/dsh-remote/agent/README.md +412 -0
- package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/clients/dsh-remote/agent/codex.mjs +736 -0
- package/clients/dsh-remote/agent/detect.mjs +399 -0
- package/clients/dsh-remote/agent/events.mjs +511 -0
- package/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/clients/dsh-remote/agent/index.mjs +339 -0
- package/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/clients/dsh-remote/agent/proc.mjs +179 -0
- package/clients/dsh-remote/agent/version.mjs +148 -0
- package/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/clients/dsh-remote/decision-poller.mjs +235 -0
- package/clients/dsh-remote/dsh-bridge.mjs +208 -1
- package/clients/dsh-remote/mobile-adapter.mjs +775 -11
- package/clients/dsh-remote/push-reporter.mjs +533 -0
- package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
- package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
- package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
- package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
- package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
- package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
- package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
- package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
- package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
- package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
- package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
- package/clients/dsh-remote/test/bridge-e2ee-reject-observable.test.mjs +90 -0
- package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +172 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +188 -4
- package/clients/dsh-remote/test/push-reporter.test.mjs +380 -0
- package/dsh-setup.mjs +5 -0
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +350 -8
- package/packages/dsh-remote-web/lib/index.js +26 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +208 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +775 -11
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +5 -0
- package/packages/dsh-remote-web/test/activation-entry.test.mjs +407 -0
- package/packages/dsh-remote-web/test/e2ee-reject-diag.test.mjs +70 -0
- package/packages/dsh-remote-web/test/setup-output.test.mjs +16 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decision-poller — 把"用户在手机上点的允许/拒绝"从 SaaS 拉回来,落到电脑端。
|
|
3
|
+
*
|
|
4
|
+
* 链路(配合服务端 `/api/notify/pending`):
|
|
5
|
+
* 手机点按钮 → POST /api/notify/decision(服务端存)
|
|
6
|
+
* → 本模块每 3s GET /api/notify/pending?device_id=…
|
|
7
|
+
* → 对每条决策调用注入的 `apply(item)`(bridge 里 = dsh-events 的 answerApproval)
|
|
8
|
+
* → POST /api/notify/ack 确认,服务端不再返回
|
|
9
|
+
*
|
|
10
|
+
* 设计边界(每一条都重要)
|
|
11
|
+
* ---------------------------------------------------------------
|
|
12
|
+
* 1. **默认不启动**:只有「事件上报已在跑」时才起(没有上报就没有通知,也就不可能有回执)。
|
|
13
|
+
* 2. **绝不阻塞主链路**:所有异常吞掉只记日志;一个 tick 失败不影响下一个 tick。
|
|
14
|
+
* 3. **幂等**:apply 成功才 ack;apply 失败(例如会话已结束)也 ack —— 否则会每 3s 重放同一条
|
|
15
|
+
* 并把用户"一直点不掉"的旧决策反复怼给 DSH。失败会记 warn,不影响其它决策。
|
|
16
|
+
* 4. **有界**:单次最多处理 20 条(服务端也限 20),串行 apply,避免并发打爆 dsh。
|
|
17
|
+
* 5. **过期不应用**:服务端已过滤;这里再按 expires_at 兜一次(时钟漂移时宁可丢也不误放行)。
|
|
18
|
+
*
|
|
19
|
+
* 安全语义:`allow` 只能映射成 **allowed-once**("允许一次"),绝不映射成"永久放行";
|
|
20
|
+
* `deny` 映射成 `rejected`。任何未知值 → 当 deny 处理(fail-closed)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_POLL_INTERVAL_MS = 3_000;
|
|
24
|
+
export const MAX_ITEMS_PER_TICK = 20;
|
|
25
|
+
export const REQUEST_TIMEOUT_MS = 8_000;
|
|
26
|
+
|
|
27
|
+
/** 手机端的 decision → dsh-events 的 outcome(未知一律 deny,fail-closed)。 */
|
|
28
|
+
export function toDshOutcome(decision) {
|
|
29
|
+
return String(decision) === "allow" ? "allowed-once" : "rejected";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{
|
|
34
|
+
* apiBase: string,
|
|
35
|
+
* deviceId: string,
|
|
36
|
+
* tokenProvider: () => (string|Promise<string>),
|
|
37
|
+
* apply: (item: {event_id:string, request_id:string, decision:"allow"|"deny", id:string}) => Promise<any>,
|
|
38
|
+
* fetchImpl?: typeof fetch,
|
|
39
|
+
* now?: () => number,
|
|
40
|
+
* intervalMs?: number,
|
|
41
|
+
* log?: (level:string, message:string, meta?:object) => void,
|
|
42
|
+
* }} options
|
|
43
|
+
*/
|
|
44
|
+
export function createDecisionPoller(options = {}) {
|
|
45
|
+
const apiBase = String(options.apiBase || "").replace(/\/+$/, "");
|
|
46
|
+
const deviceId = String(options.deviceId || "");
|
|
47
|
+
const tokenProvider = typeof options.tokenProvider === "function" ? options.tokenProvider : () => "";
|
|
48
|
+
const apply = typeof options.apply === "function" ? options.apply : null;
|
|
49
|
+
const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : globalThis.fetch;
|
|
50
|
+
const now = typeof options.now === "function" ? options.now : () => Date.now();
|
|
51
|
+
const intervalMs = Number.isFinite(options.intervalMs) ? options.intervalMs : DEFAULT_POLL_INTERVAL_MS;
|
|
52
|
+
const log = typeof options.log === "function" ? options.log : () => {};
|
|
53
|
+
|
|
54
|
+
const stats = { started: false, polls: 0, applied: 0, skipped: 0, failed: 0, acked: 0, lastError: "" };
|
|
55
|
+
/**
|
|
56
|
+
* 已应用过的决策 id(有界 LRU)。
|
|
57
|
+
*
|
|
58
|
+
* 为什么需要:服务端是"至少一次"语义 —— 我们 ack 丢了或超时未 ack,同一条回执会被重投。
|
|
59
|
+
* 若每次都重新 `apply()`,会把同一条审批反复回执给 DSH(服务端虽是幂等的 already_decided,
|
|
60
|
+
* 但会刷日志、也会让"允许一次"看起来像被允许多次)。所以按 id 去重:见过的只 ack 不再应用。
|
|
61
|
+
*/
|
|
62
|
+
const appliedIds = new Map();
|
|
63
|
+
let timer = null;
|
|
64
|
+
let stopped = false;
|
|
65
|
+
let ticking = false;
|
|
66
|
+
|
|
67
|
+
function schedule(delayMs) {
|
|
68
|
+
if (stopped) return;
|
|
69
|
+
if (timer) return;
|
|
70
|
+
timer = setTimeout(() => {
|
|
71
|
+
timer = null;
|
|
72
|
+
void tick();
|
|
73
|
+
}, Math.max(0, delayMs));
|
|
74
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function tick() {
|
|
78
|
+
if (stopped || ticking) return;
|
|
79
|
+
ticking = true;
|
|
80
|
+
try {
|
|
81
|
+
stats.polls += 1;
|
|
82
|
+
const token = await Promise.resolve(tokenProvider()).catch(() => "");
|
|
83
|
+
if (!token) {
|
|
84
|
+
stats.lastError = "no_token";
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const url = `${apiBase}/api/notify/pending?device_id=${encodeURIComponent(deviceId)}`;
|
|
88
|
+
const res = await fetchWithTimeout(url, {
|
|
89
|
+
method: "GET",
|
|
90
|
+
headers: { authorization: `Bearer ${token}` }
|
|
91
|
+
}, fetchImpl, REQUEST_TIMEOUT_MS);
|
|
92
|
+
if (!res) { stats.lastError = "no_response"; return; }
|
|
93
|
+
if (res.status === 401) {
|
|
94
|
+
// token 过期:等下一次 device-login 刷新,继续轮询没有意义但也不该停(刷新后要能恢复)
|
|
95
|
+
stats.lastError = "auth_failed";
|
|
96
|
+
log("warn", "[decision] 设备 token 失效,跳过本轮(等下次登录刷新)");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (res.status === 403) {
|
|
100
|
+
// device_mismatch:这台机器的 device_id 已登记在别的账号下 —— 重试永远不会成功,停下。
|
|
101
|
+
stats.lastError = "device_mismatch";
|
|
102
|
+
log("warn", "[decision] 本机设备不属于当前账号(device_mismatch),已停止决策轮询");
|
|
103
|
+
stopped = true;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (res.status === 400) {
|
|
107
|
+
// invalid_device_id(缺/超长):配置问题,重试无意义
|
|
108
|
+
stats.lastError = "invalid_device_id";
|
|
109
|
+
log("warn", "[decision] device_id 非法,已停止决策轮询");
|
|
110
|
+
stopped = true;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (res.status !== 200) { stats.lastError = `http_${res.status}`; return; }
|
|
114
|
+
const body = await safeJson(res);
|
|
115
|
+
const items = Array.isArray(body && body.items) ? body.items.slice(0, MAX_ITEMS_PER_TICK) : [];
|
|
116
|
+
if (items.length === 0) { stats.lastError = ""; return; }
|
|
117
|
+
|
|
118
|
+
const ackIds = [];
|
|
119
|
+
for (const item of items) {
|
|
120
|
+
const id = String(item && item.id ? item.id : "");
|
|
121
|
+
const eventId = String(item && item.event_id ? item.event_id : "");
|
|
122
|
+
const requestId = String(item && item.request_id ? item.request_id : "");
|
|
123
|
+
const decision = String(item && item.decision ? item.decision : "");
|
|
124
|
+
const expiresAt = Number(item && item.expires_at ? item.expires_at : 0);
|
|
125
|
+
// 服务端"至少一次"语义:redeliver_count>0 表示这条之前可能已经投递过(我们回过 ack 但响应丢了)。
|
|
126
|
+
// 不改行为(仍照常应用并 ack),只在日志留痕,便于排查"为什么同一条审批被应用两次"。
|
|
127
|
+
const redeliverCount = Number(item && item.redeliver_count ? item.redeliver_count : 0);
|
|
128
|
+
if (redeliverCount > 0) {
|
|
129
|
+
log("warn", `[decision] 收到重投回执(第 ${redeliverCount} 次) request_id=${requestId || "-"}`);
|
|
130
|
+
}
|
|
131
|
+
if (expiresAt > 0 && expiresAt < now()) {
|
|
132
|
+
// 服务端已过期却仍返回(时钟漂移):不应用,但 ack 掉避免反复拉
|
|
133
|
+
stats.failed += 1;
|
|
134
|
+
if (id) ackIds.push(id);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (id && appliedIds.has(id)) {
|
|
138
|
+
// 重投的同一条:之前已应用过 → 只 ack,不再 apply(幂等)
|
|
139
|
+
stats.skipped += 1;
|
|
140
|
+
ackIds.push(id);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!apply || !requestId) {
|
|
144
|
+
stats.failed += 1;
|
|
145
|
+
if (id) ackIds.push(id);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
await apply({
|
|
150
|
+
id,
|
|
151
|
+
event_id: eventId,
|
|
152
|
+
request_id: requestId,
|
|
153
|
+
decision: decision === "allow" ? "allow" : "deny",
|
|
154
|
+
outcome: toDshOutcome(decision)
|
|
155
|
+
});
|
|
156
|
+
stats.applied += 1;
|
|
157
|
+
if (id) {
|
|
158
|
+
appliedIds.set(id, now());
|
|
159
|
+
if (appliedIds.size > 500) {
|
|
160
|
+
const first = appliedIds.keys().next();
|
|
161
|
+
if (!first.done) appliedIds.delete(first.value);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
// 应用失败(会话已结束/事件已作废)也必须 ack,否则每 3s 重放同一条
|
|
166
|
+
stats.failed += 1;
|
|
167
|
+
stats.lastError = String(e && e.message ? e.message : e);
|
|
168
|
+
log("warn", `[decision] 应用失败(已跳过并确认): ${stats.lastError}`);
|
|
169
|
+
}
|
|
170
|
+
if (id) ackIds.push(id);
|
|
171
|
+
}
|
|
172
|
+
if (ackIds.length > 0) await ack(token, ackIds, apiBase, fetchImpl, stats, log);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
stats.lastError = String(e && e.message ? e.message : e);
|
|
175
|
+
log("warn", `[decision] 轮询异常(已忽略):${stats.lastError}`);
|
|
176
|
+
} finally {
|
|
177
|
+
ticking = false;
|
|
178
|
+
if (!stopped) schedule(intervalMs);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
start() {
|
|
184
|
+
if (stats.started || stopped) return stats;
|
|
185
|
+
if (!apiBase || !deviceId || !apply) {
|
|
186
|
+
stats.lastError = !apiBase ? "no_api_base" : !deviceId ? "no_device" : "no_apply";
|
|
187
|
+
return stats;
|
|
188
|
+
}
|
|
189
|
+
stats.started = true;
|
|
190
|
+
schedule(intervalMs);
|
|
191
|
+
return stats;
|
|
192
|
+
},
|
|
193
|
+
stop() {
|
|
194
|
+
stopped = true;
|
|
195
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
196
|
+
stats.started = false;
|
|
197
|
+
},
|
|
198
|
+
status() { return { ...stats }; },
|
|
199
|
+
/** 仅供单测:跑一次 tick(不排下一轮)。 */
|
|
200
|
+
_tick: tick
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function ack(token, ids, apiBase, fetchImpl, stats, log) {
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetchWithTimeout(`${apiBase}/api/notify/ack`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
209
|
+
body: JSON.stringify({ ids: ids.slice(0, 50) })
|
|
210
|
+
}, fetchImpl, REQUEST_TIMEOUT_MS);
|
|
211
|
+
if (res && res.status === 200) {
|
|
212
|
+
stats.acked += ids.length;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
log("warn", `[decision] ack 失败(HTTP ${res ? res.status : "无响应"}),下轮会重复拉取(幂等,无副作用)`);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
log("warn", `[decision] ack 异常(已忽略):${e && e.message ? e.message : e}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function fetchWithTimeout(url, init, fetchImpl, timeoutMs) {
|
|
222
|
+
if (typeof fetchImpl !== "function") return null;
|
|
223
|
+
const ctl = typeof AbortController === "function" ? new AbortController() : null;
|
|
224
|
+
const timer = ctl ? setTimeout(() => { try { ctl.abort(); } catch { /* 忽略 */ } }, timeoutMs) : null;
|
|
225
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
226
|
+
try {
|
|
227
|
+
return await fetchImpl(url, ctl ? { ...init, signal: ctl.signal } : init);
|
|
228
|
+
} finally {
|
|
229
|
+
if (timer) clearTimeout(timer);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function safeJson(r) {
|
|
234
|
+
try { return await r.json(); } catch { return null; }
|
|
235
|
+
}
|
|
@@ -114,6 +114,10 @@ import {
|
|
|
114
114
|
// ⚠️ 微信流量只走「腾讯 ilink ↔ 本机」,**不经我们的 relay**;DSH 事件走本机回环。
|
|
115
115
|
// 总开关 DSH_WECHAT=0(默认开启);未绑定时只起控制面,不起长轮询。
|
|
116
116
|
import { createWeChatRuntime } from "./wechat-runtime.mjs";
|
|
117
|
+
import { createPushReporter } from "./push-reporter.mjs";
|
|
118
|
+
import { createDecisionPoller } from "./decision-poller.mjs";
|
|
119
|
+
import { createApprovalCoordinator } from "./approval-coordinator.mjs";
|
|
120
|
+
import { createAgentRuntime } from "./agent/index.mjs";
|
|
117
121
|
|
|
118
122
|
// ---------- 强制直连:清除代理环境变量 ----------
|
|
119
123
|
// 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
|
|
@@ -288,7 +292,44 @@ function stampBridgeHeartbeat() {
|
|
|
288
292
|
const now = Date.now();
|
|
289
293
|
if (now - lastStateHeartbeatAt < STATE_HEARTBEAT_MIN_MS) return;
|
|
290
294
|
lastStateHeartbeatAt = now;
|
|
291
|
-
persistBridgeState({
|
|
295
|
+
persistBridgeState({
|
|
296
|
+
pid: process.pid,
|
|
297
|
+
heartbeat_at: now,
|
|
298
|
+
// 【0.7.10 / W1-T3】E2EE 死会话拒绝计数(见 noteE2eeWsReject 的说明)。
|
|
299
|
+
// 跟着心跳一起落盘:不额外写盘、不加定时器;旧面板/旧插件读到多余字段一律忽略。
|
|
300
|
+
e2ee_ws_reject_locked: e2eeWsRejectStats.locked,
|
|
301
|
+
e2ee_ws_reject_disabled: e2eeWsRejectStats.disabled,
|
|
302
|
+
e2ee_ws_reject_at: e2eeWsRejectStats.lastAt
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 【0.7.10 / W1-T3】E2EE 会话拒绝计数 —— **只做可观测,不改任何协议行为**。
|
|
308
|
+
*
|
|
309
|
+
* 生产复盘(2026-10):WS 升级被拒里 71.8% 是 `e2ee_session_locked`(死会话),涉 163 用户;
|
|
310
|
+
* 0.7.3+ 已闭环(手机侧清内存会话 + 连续 3 次失败作废交接单 + 每页一次受控 reload;
|
|
311
|
+
* bridge 侧回 `retryable:true, action:"rehandshake"` 让手机自动重握手)。但「死会话占比
|
|
312
|
+
* 降到多少」此前只在 nginx/router 侧可见,客户端自己答不出来。
|
|
313
|
+
*
|
|
314
|
+
* 这里把本进程的拒绝次数记下来、随心跳写进状态文件(面板「复制诊断信息」会带上一行),
|
|
315
|
+
* 于是支持包/诊断信息本身就能回答「这台机器还在被死会话打吗」。
|
|
316
|
+
* 落盘节流 ≥10s:死循环期一台设备一天能拒近万次,绝不能每拒一次就写盘。
|
|
317
|
+
*/
|
|
318
|
+
const e2eeWsRejectStats = { locked: 0, disabled: 0, lastAt: 0 };
|
|
319
|
+
const E2EE_REJECT_FLUSH_MIN_MS = 10_000;
|
|
320
|
+
let lastE2eeRejectFlushAt = 0;
|
|
321
|
+
function noteE2eeWsReject(kind) {
|
|
322
|
+
if (kind === "disabled") e2eeWsRejectStats.disabled += 1;
|
|
323
|
+
else e2eeWsRejectStats.locked += 1;
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
e2eeWsRejectStats.lastAt = now;
|
|
326
|
+
if (now - lastE2eeRejectFlushAt < E2EE_REJECT_FLUSH_MIN_MS) return;
|
|
327
|
+
lastE2eeRejectFlushAt = now;
|
|
328
|
+
persistBridgeState({
|
|
329
|
+
e2ee_ws_reject_locked: e2eeWsRejectStats.locked,
|
|
330
|
+
e2ee_ws_reject_disabled: e2eeWsRejectStats.disabled,
|
|
331
|
+
e2ee_ws_reject_at: e2eeWsRejectStats.lastAt
|
|
332
|
+
});
|
|
292
333
|
}
|
|
293
334
|
|
|
294
335
|
// ---------- 安装来源 / 版本(服务端设备行统计约定) ----------
|
|
@@ -1714,6 +1755,7 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
1714
1755
|
const tagged = parseWsE2eeParams(path);
|
|
1715
1756
|
if (tagged) {
|
|
1716
1757
|
if (!e2ee.enabled) {
|
|
1758
|
+
noteE2eeWsReject("disabled"); // 可观测计数(见 noteE2eeWsReject;不改行为)
|
|
1717
1759
|
sendWsOpenReject(send, id, 1008, "e2ee_disabled:bridge 未启用端到端加密(请用明文连接)");
|
|
1718
1760
|
return;
|
|
1719
1761
|
}
|
|
@@ -1721,6 +1763,7 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
1721
1763
|
if (!esess || !esess.verified) {
|
|
1722
1764
|
// 会话可能刚被重启/被 LRU 挤掉:把「可自动重握手」写成机器可读信号,
|
|
1723
1765
|
// 手机侧据此自动重建会话,而不是弹「请重新解锁」让用户输密码(生产 3964 次/设备)。
|
|
1766
|
+
noteE2eeWsReject("locked");
|
|
1724
1767
|
console.error(`[bridge] e2ee ws 拒绝(会话未解锁/未验证): ${path}`);
|
|
1725
1768
|
sendWsOpenReject(send, id, 1008, "e2ee_session_locked:加密会话已失效,需要重新握手", {
|
|
1726
1769
|
retryable: true,
|
|
@@ -2801,6 +2844,155 @@ function currentUpstreamForChannel() {
|
|
|
2801
2844
|
return UPSTREAM;
|
|
2802
2845
|
}
|
|
2803
2846
|
|
|
2847
|
+
/**
|
|
2848
|
+
* 推送上报(2026-11-05):把"任务完成/需要拍板/出错"上报给 SaaS,由服务端规则引擎决定是否推送。
|
|
2849
|
+
*
|
|
2850
|
+
* 为什么放在这里有独立开关:它是**可选增强**,任何失败都不得影响远程访问主链路。
|
|
2851
|
+
* 真正的启动条件由 push-reporter 自己问服务端(`/api/push/public-key` 的 enabled+subscribed),
|
|
2852
|
+
* 本地只有 `DSH_PUSH_REPORT=0` 能强制关闭。
|
|
2853
|
+
*/
|
|
2854
|
+
let pushReporter = null;
|
|
2855
|
+
let PUSH_TOKEN = "";
|
|
2856
|
+
|
|
2857
|
+
function startPushReporter() {
|
|
2858
|
+
if (String(process.env.DSH_PUSH_REPORT || "") === "0") {
|
|
2859
|
+
console.log("[push] 上报已禁用(DSH_PUSH_REPORT=0)");
|
|
2860
|
+
return;
|
|
2861
|
+
}
|
|
2862
|
+
try {
|
|
2863
|
+
if (pushReporter) return;
|
|
2864
|
+
pushReporter = createPushReporter({
|
|
2865
|
+
apiBase: API_BASE,
|
|
2866
|
+
deviceId: DEVICE_ID,
|
|
2867
|
+
tokenProvider: () => PUSH_TOKEN,
|
|
2868
|
+
upstream: () => currentUpstreamForChannel(),
|
|
2869
|
+
cookie: harnessCookieOf,
|
|
2870
|
+
detail: String(process.env.DSH_PUSH_DETAIL || "") === "1",
|
|
2871
|
+
log: (level, message) => {
|
|
2872
|
+
try {
|
|
2873
|
+
if (level === "warn") console.warn(message);
|
|
2874
|
+
else console.log(message);
|
|
2875
|
+
} catch { /* 忽略 */ }
|
|
2876
|
+
}
|
|
2877
|
+
});
|
|
2878
|
+
// 不 await:启动探测失败绝不影响隧道
|
|
2879
|
+
void pushReporter.start().then((st) => {
|
|
2880
|
+
try {
|
|
2881
|
+
if (st && st.started) {
|
|
2882
|
+
ensureApprovalCoordinator();
|
|
2883
|
+
startDecisionPoller();
|
|
2884
|
+
startAgentRuntime();
|
|
2885
|
+
}
|
|
2886
|
+
} catch { /* 忽略 */ }
|
|
2887
|
+
}).catch(() => {});
|
|
2888
|
+
} catch (e) {
|
|
2889
|
+
// 可选增强失败不得冒泡
|
|
2890
|
+
console.warn(`[push] 上报器初始化失败(已忽略):${e && e.message ? e.message : e}`);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
/**
|
|
2895
|
+
* 多 Agent 接入(2026-11-06):默认**关闭**,只有 `DSH_AGENT_ENABLED=1` 才起。
|
|
2896
|
+
*
|
|
2897
|
+
* 为什么默认关:这条链路要 spawn 用户的 Claude Code、开本机回环端口、写临时 settings,
|
|
2898
|
+
* 且手机端审批还没接通 —— 没有消费方时开机就跑它,是在用户机器上白花钱、白担风险。
|
|
2899
|
+
* 启用后若任何一步不满足(没装 CLI / 版本太低 / 已有审批钩子),runtime 会返回明确状态
|
|
2900
|
+
* (disabled / cli-missing / version-unsupported / existing-hooks / bridge-error),
|
|
2901
|
+
* **绝不静默降级**,也绝不抛错影响远程访问主链路。
|
|
2902
|
+
*/
|
|
2903
|
+
let agentRuntime = null;
|
|
2904
|
+
|
|
2905
|
+
function startAgentRuntime() {
|
|
2906
|
+
if (String(process.env.DSH_AGENT_ENABLED || "") !== "1") return;
|
|
2907
|
+
try {
|
|
2908
|
+
if (agentRuntime) return;
|
|
2909
|
+
const coordinator = ensureApprovalCoordinator();
|
|
2910
|
+
agentRuntime = createAgentRuntime({
|
|
2911
|
+
// 手机审批:钩子 → 协调器(等手机回执)→ allow/deny;没有协调器时 runtime 内部 fail-closed
|
|
2912
|
+
decide: coordinator ? (req) => coordinator.ask(req) : undefined,
|
|
2913
|
+
log: (e) => {
|
|
2914
|
+
try {
|
|
2915
|
+
const level = e && e.level === "error" ? "error" : e && e.level === "warn" ? "warn" : "info";
|
|
2916
|
+
console[level]("[agent] " + JSON.stringify(e));
|
|
2917
|
+
} catch { /* 忽略 */ }
|
|
2918
|
+
}
|
|
2919
|
+
});
|
|
2920
|
+
// 不 await:失败也只在日志里体现,主链路照常
|
|
2921
|
+
void agentRuntime.start().then((r) => {
|
|
2922
|
+
try {
|
|
2923
|
+
const st = r && r.status ? r.status : "unknown";
|
|
2924
|
+
console.log(`[agent] 运行时状态=${st} mode=${(r && r.mode) || "-"}${r && r.reason ? " reason=" + r.reason : ""}`);
|
|
2925
|
+
persistBridgeState({ agent: { status: st, mode: (r && r.mode) || null, enabled: true, at: Date.now() } });
|
|
2926
|
+
} catch { /* 忽略 */ }
|
|
2927
|
+
}).catch((e) => {
|
|
2928
|
+
console.warn(`[agent] 启动异常(已忽略):${e && e.message ? e.message : e}`);
|
|
2929
|
+
});
|
|
2930
|
+
} catch (e) {
|
|
2931
|
+
console.warn(`[agent] 初始化失败(已忽略):${e && e.message ? e.message : e}`);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
/**
|
|
2936
|
+
* 决策轮询(2026-11-06):把"手机上点的允许/拒绝"拉回电脑端并写回 DSH。
|
|
2937
|
+
*
|
|
2938
|
+
* 只有"事件上报已在跑"时才起 —— 没有上报就没有通知,也就不可能有回执;
|
|
2939
|
+
* 所有异常吞掉只记日志,绝不阻塞隧道。
|
|
2940
|
+
*/
|
|
2941
|
+
/**
|
|
2942
|
+
* 审批协调器(2026-11-06):把 Claude Code 的审批请求接到手机回执上。
|
|
2943
|
+
* 只有"推送上报已在跑"时才创建 —— 没有手机通道,等 90 秒再拒绝毫无意义。
|
|
2944
|
+
*/
|
|
2945
|
+
let approvalCoordinator = null;
|
|
2946
|
+
|
|
2947
|
+
function ensureApprovalCoordinator() {
|
|
2948
|
+
if (approvalCoordinator) return approvalCoordinator;
|
|
2949
|
+
try {
|
|
2950
|
+
approvalCoordinator = createApprovalCoordinator({
|
|
2951
|
+
report: (info) => pushReporter && pushReporter.reportApproval ? pushReporter.reportApproval(info) : false,
|
|
2952
|
+
log: (level, message) => {
|
|
2953
|
+
try {
|
|
2954
|
+
if (level === "warn") console.warn(message);
|
|
2955
|
+
else console.log(message);
|
|
2956
|
+
} catch { /* 忽略 */ }
|
|
2957
|
+
}
|
|
2958
|
+
});
|
|
2959
|
+
} catch (e) {
|
|
2960
|
+
console.warn(`[approval] 协调器初始化失败(已忽略):${e && e.message ? e.message : e}`);
|
|
2961
|
+
approvalCoordinator = null;
|
|
2962
|
+
}
|
|
2963
|
+
return approvalCoordinator;
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
let decisionPoller = null;
|
|
2967
|
+
|
|
2968
|
+
function startDecisionPoller() {
|
|
2969
|
+
try {
|
|
2970
|
+
if (decisionPoller) return;
|
|
2971
|
+
decisionPoller = createDecisionPoller({
|
|
2972
|
+
apiBase: API_BASE,
|
|
2973
|
+
deviceId: DEVICE_ID,
|
|
2974
|
+
tokenProvider: () => PUSH_TOKEN,
|
|
2975
|
+
apply: async (item) => {
|
|
2976
|
+
// ① 先看是不是"本地 Agent(Claude Code)"的审批 —— 协调器认得就消费掉
|
|
2977
|
+
try {
|
|
2978
|
+
if (approvalCoordinator && approvalCoordinator.resolve(item.request_id, item.decision)) return;
|
|
2979
|
+
} catch { /* 继续走 DSH 那条路 */ }
|
|
2980
|
+
// ② 否则按 DSH 审批回执处理:手机上的"允许"只能映射成 allowed-once,绝不升级成永久放行
|
|
2981
|
+
return pushReporter.answerApproval(item.request_id, item.outcome);
|
|
2982
|
+
},
|
|
2983
|
+
log: (level, message) => {
|
|
2984
|
+
try {
|
|
2985
|
+
if (level === "warn") console.warn(message);
|
|
2986
|
+
else console.log(message);
|
|
2987
|
+
} catch { /* 忽略 */ }
|
|
2988
|
+
}
|
|
2989
|
+
});
|
|
2990
|
+
decisionPoller.start();
|
|
2991
|
+
} catch (e) {
|
|
2992
|
+
console.warn(`[decision] 轮询器初始化失败(已忽略):${e && e.message ? e.message : e}`);
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2804
2996
|
function startWeChat() {
|
|
2805
2997
|
if (WECHAT_DISABLED) {
|
|
2806
2998
|
console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
|
|
@@ -2983,6 +3175,7 @@ async function runTunnel() {
|
|
|
2983
3175
|
// (真机日志实测:同一个桥接启动里出现两条并发的 device-login)。
|
|
2984
3176
|
// 而 /api/device-login 是按出口 IP 限流的 —— 每次启动都多花一份登录额度没有道理。
|
|
2985
3177
|
WECHAT_TIER_TOKEN = { value: token, at: Date.now() };
|
|
3178
|
+
PUSH_TOKEN = token;
|
|
2986
3179
|
// 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)。
|
|
2987
3180
|
// `pid` 一并写入 = **bridge 自证身份**:插件可以用「state.pid 活着 + heartbeat_at 新鲜」
|
|
2988
3181
|
// 判断桥是否真的在跑,不必依赖按镜像名(node.exe)扫描 —— 桌面端宿主拉起的进程名不是 node.exe。
|
|
@@ -2998,6 +3191,10 @@ async function runTunnel() {
|
|
|
2998
3191
|
}
|
|
2999
3192
|
// 隧道已发起(或已进入锁定等待)后再起微信:主链路优先,微信永不阻塞远程访问
|
|
3000
3193
|
startWeChat();
|
|
3194
|
+
// 同理:推送上报也是可选增强,最后起、失败静默
|
|
3195
|
+
startPushReporter();
|
|
3196
|
+
// 多 Agent 运行时:默认关,只有 DSH_AGENT_ENABLED=1 **且** 推送通道已就绪时才起
|
|
3197
|
+
//(没有手机通道 = 审批无处可问,起了只会卡住用户)
|
|
3001
3198
|
setTimeout(() => {
|
|
3002
3199
|
console.log(`[bridge] 隧道模式运行中(上游 ${UPSTREAM},Ctrl-C 退出)`);
|
|
3003
3200
|
}, 1000);
|
|
@@ -3043,6 +3240,13 @@ async function main() {
|
|
|
3043
3240
|
console.log(`[bridge] 收到 ${sig},正在优雅退出...`);
|
|
3044
3241
|
// 退出前把 E2EE 会话库刷一次(滑动 TTL 的 usedAt 要落盘;崩溃也能恢复到最多 60s 前)
|
|
3045
3242
|
try { e2ee.flushStore(); } catch { /* 已内部兜底 */ }
|
|
3243
|
+
// 退出前尽力把待上报的事件发出去(最多等 1s,绝不阻塞退出)
|
|
3244
|
+
try { if (decisionPoller) decisionPoller.stop(); } catch { /* 已内部兜底 */ }
|
|
3245
|
+
// 未决审批一律拒绝(绝不留悬挂的 Promise)
|
|
3246
|
+
try { if (approvalCoordinator) approvalCoordinator.stop(); } catch { /* 已内部兜底 */ }
|
|
3247
|
+
try { if (pushReporter) await pushReporter.stop(); } catch { /* 已内部兜底 */ }
|
|
3248
|
+
// 多 Agent 运行时:关端口、删临时 settings、未决审批一律 deny(runtime 内部保证幂等)
|
|
3249
|
+
try { if (agentRuntime) await agentRuntime.stop(); } catch { /* 已内部兜底 */ }
|
|
3046
3250
|
await stopWeChat();
|
|
3047
3251
|
process.exit(0);
|
|
3048
3252
|
};
|
|
@@ -3188,6 +3392,9 @@ export function __wsSessionState(id) {
|
|
|
3188
3392
|
return { opened: s.opened === true, pending: s.pending ? s.pending.length : 0, cancelled: s.cancelled === true, failed: s.failed === true };
|
|
3189
3393
|
}
|
|
3190
3394
|
|
|
3395
|
+
// test hook:E2EE 拒绝计数快照(W1-T3 可观测字段;状态文件里同名 e2ee_ws_reject_* 字段的来源)
|
|
3396
|
+
export function __e2eeRejectStatsForTest() { return { ...e2eeWsRejectStats }; }
|
|
3397
|
+
|
|
3191
3398
|
// 直接运行(node dsh-bridge.mjs)时启动服务;被测试 import 时只导出协议函数。
|
|
3192
3399
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
3193
3400
|
if (isMain) {
|