@mrrisega/dsh-remote 0.7.10-beta.3 → 0.7.11-beta.3
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/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 +214 -1
- package/clients/dsh-remote/push-reporter.mjs +54 -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/decision-poller.test.mjs +328 -0
- package/clients/dsh-remote/test/wecom-bot.test.mjs +1438 -0
- package/clients/dsh-remote/wecom-bot.mjs +2444 -0
- package/dsh-setup.mjs +18 -3
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +1048 -223
- package/packages/dsh-remote-web/lib/index.js +299 -2
- 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 +258 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +2444 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
- package/packages/dsh-remote-web/test/activation-entry.test.mjs +11 -1
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +8 -3
- package/packages/dsh-remote-web/test/connect-ui.test.mjs +16 -4
- package/packages/dsh-remote-web/test/im-bots.test.mjs +359 -0
- package/packages/dsh-remote-web/test/lib-panel-ia.mjs +98 -0
- package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +13 -5
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +798 -0
- package/packages/dsh-remote-web/test/password-reset.test.mjs +11 -1
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +78 -0
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +10 -1
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +20 -8
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +30 -21
- package/packages/dsh-remote-web/test/sms-captcha-ui.test.mjs +15 -2
- package/packages/dsh-remote-web/test/telemetry.test.mjs +5 -3
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +57 -130
|
@@ -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,14 @@ 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";
|
|
121
|
+
// 企业微信「智能机器人」长连接通道(task-21,2026-11-07):Bot ID + Secret 直连官方 WebSocket,
|
|
122
|
+
// 电脑在线即可双向联动。**默认关闭**:没有 `im-bots.json` / 未启用 / 缺凭据时什么都不起。
|
|
123
|
+
// 与微信机器人通道(iLink 长轮询)互相独立:两条通道可同时开,也可以都不开。
|
|
124
|
+
import { createWecomBotController } from "./wecom-bot.mjs";
|
|
117
125
|
|
|
118
126
|
// ---------- 强制直连:清除代理环境变量 ----------
|
|
119
127
|
// 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
|
|
@@ -2840,6 +2848,183 @@ function currentUpstreamForChannel() {
|
|
|
2840
2848
|
return UPSTREAM;
|
|
2841
2849
|
}
|
|
2842
2850
|
|
|
2851
|
+
/**
|
|
2852
|
+
* 推送上报(2026-11-05):把"任务完成/需要拍板/出错"上报给 SaaS,由服务端规则引擎决定是否推送。
|
|
2853
|
+
*
|
|
2854
|
+
* 为什么放在这里有独立开关:它是**可选增强**,任何失败都不得影响远程访问主链路。
|
|
2855
|
+
* 真正的启动条件由 push-reporter 自己问服务端(`/api/push/public-key` 的 enabled+subscribed),
|
|
2856
|
+
* 本地只有 `DSH_PUSH_REPORT=0` 能强制关闭。
|
|
2857
|
+
*/
|
|
2858
|
+
let pushReporter = null;
|
|
2859
|
+
let PUSH_TOKEN = "";
|
|
2860
|
+
|
|
2861
|
+
function startPushReporter() {
|
|
2862
|
+
if (String(process.env.DSH_PUSH_REPORT || "") === "0") {
|
|
2863
|
+
console.log("[push] 上报已禁用(DSH_PUSH_REPORT=0)");
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
try {
|
|
2867
|
+
if (pushReporter) return;
|
|
2868
|
+
pushReporter = createPushReporter({
|
|
2869
|
+
apiBase: API_BASE,
|
|
2870
|
+
deviceId: DEVICE_ID,
|
|
2871
|
+
tokenProvider: () => PUSH_TOKEN,
|
|
2872
|
+
upstream: () => currentUpstreamForChannel(),
|
|
2873
|
+
cookie: harnessCookieOf,
|
|
2874
|
+
detail: String(process.env.DSH_PUSH_DETAIL || "") === "1",
|
|
2875
|
+
log: (level, message) => {
|
|
2876
|
+
try {
|
|
2877
|
+
if (level === "warn") console.warn(message);
|
|
2878
|
+
else console.log(message);
|
|
2879
|
+
} catch { /* 忽略 */ }
|
|
2880
|
+
}
|
|
2881
|
+
});
|
|
2882
|
+
// 不 await:启动探测失败绝不影响隧道
|
|
2883
|
+
void pushReporter.start().then((st) => {
|
|
2884
|
+
try {
|
|
2885
|
+
if (st && st.started) {
|
|
2886
|
+
ensureApprovalCoordinator();
|
|
2887
|
+
startDecisionPoller();
|
|
2888
|
+
startAgentRuntime();
|
|
2889
|
+
}
|
|
2890
|
+
} catch { /* 忽略 */ }
|
|
2891
|
+
}).catch(() => {});
|
|
2892
|
+
} catch (e) {
|
|
2893
|
+
// 可选增强失败不得冒泡
|
|
2894
|
+
console.warn(`[push] 上报器初始化失败(已忽略):${e && e.message ? e.message : e}`);
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
/**
|
|
2899
|
+
* 多 Agent 接入(2026-11-06):默认**关闭**,只有 `DSH_AGENT_ENABLED=1` 才起。
|
|
2900
|
+
*
|
|
2901
|
+
* 为什么默认关:这条链路要 spawn 用户的 Claude Code、开本机回环端口、写临时 settings,
|
|
2902
|
+
* 且手机端审批还没接通 —— 没有消费方时开机就跑它,是在用户机器上白花钱、白担风险。
|
|
2903
|
+
* 启用后若任何一步不满足(没装 CLI / 版本太低 / 已有审批钩子),runtime 会返回明确状态
|
|
2904
|
+
* (disabled / cli-missing / version-unsupported / existing-hooks / bridge-error),
|
|
2905
|
+
* **绝不静默降级**,也绝不抛错影响远程访问主链路。
|
|
2906
|
+
*/
|
|
2907
|
+
let agentRuntime = null;
|
|
2908
|
+
|
|
2909
|
+
function startAgentRuntime() {
|
|
2910
|
+
if (String(process.env.DSH_AGENT_ENABLED || "") !== "1") return;
|
|
2911
|
+
try {
|
|
2912
|
+
if (agentRuntime) return;
|
|
2913
|
+
const coordinator = ensureApprovalCoordinator();
|
|
2914
|
+
agentRuntime = createAgentRuntime({
|
|
2915
|
+
// 手机审批:钩子 → 协调器(等手机回执)→ allow/deny;没有协调器时 runtime 内部 fail-closed
|
|
2916
|
+
decide: coordinator ? (req) => coordinator.ask(req) : undefined,
|
|
2917
|
+
log: (e) => {
|
|
2918
|
+
try {
|
|
2919
|
+
const level = e && e.level === "error" ? "error" : e && e.level === "warn" ? "warn" : "info";
|
|
2920
|
+
console[level]("[agent] " + JSON.stringify(e));
|
|
2921
|
+
} catch { /* 忽略 */ }
|
|
2922
|
+
}
|
|
2923
|
+
});
|
|
2924
|
+
// 不 await:失败也只在日志里体现,主链路照常
|
|
2925
|
+
void agentRuntime.start().then((r) => {
|
|
2926
|
+
try {
|
|
2927
|
+
const st = r && r.status ? r.status : "unknown";
|
|
2928
|
+
console.log(`[agent] 运行时状态=${st} mode=${(r && r.mode) || "-"}${r && r.reason ? " reason=" + r.reason : ""}`);
|
|
2929
|
+
persistBridgeState({ agent: { status: st, mode: (r && r.mode) || null, enabled: true, at: Date.now() } });
|
|
2930
|
+
} catch { /* 忽略 */ }
|
|
2931
|
+
}).catch((e) => {
|
|
2932
|
+
console.warn(`[agent] 启动异常(已忽略):${e && e.message ? e.message : e}`);
|
|
2933
|
+
});
|
|
2934
|
+
} catch (e) {
|
|
2935
|
+
console.warn(`[agent] 初始化失败(已忽略):${e && e.message ? e.message : e}`);
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
/**
|
|
2940
|
+
* 决策轮询(2026-11-06):把"手机上点的允许/拒绝"拉回电脑端并写回 DSH。
|
|
2941
|
+
*
|
|
2942
|
+
* 只有"事件上报已在跑"时才起 —— 没有上报就没有通知,也就不可能有回执;
|
|
2943
|
+
* 所有异常吞掉只记日志,绝不阻塞隧道。
|
|
2944
|
+
*/
|
|
2945
|
+
/**
|
|
2946
|
+
* 审批协调器(2026-11-06):把 Claude Code 的审批请求接到手机回执上。
|
|
2947
|
+
* 只有"推送上报已在跑"时才创建 —— 没有手机通道,等 90 秒再拒绝毫无意义。
|
|
2948
|
+
*/
|
|
2949
|
+
let approvalCoordinator = null;
|
|
2950
|
+
|
|
2951
|
+
function ensureApprovalCoordinator() {
|
|
2952
|
+
if (approvalCoordinator) return approvalCoordinator;
|
|
2953
|
+
try {
|
|
2954
|
+
approvalCoordinator = createApprovalCoordinator({
|
|
2955
|
+
report: (info) => pushReporter && pushReporter.reportApproval ? pushReporter.reportApproval(info) : false,
|
|
2956
|
+
log: (level, message) => {
|
|
2957
|
+
try {
|
|
2958
|
+
if (level === "warn") console.warn(message);
|
|
2959
|
+
else console.log(message);
|
|
2960
|
+
} catch { /* 忽略 */ }
|
|
2961
|
+
}
|
|
2962
|
+
});
|
|
2963
|
+
} catch (e) {
|
|
2964
|
+
console.warn(`[approval] 协调器初始化失败(已忽略):${e && e.message ? e.message : e}`);
|
|
2965
|
+
approvalCoordinator = null;
|
|
2966
|
+
}
|
|
2967
|
+
return approvalCoordinator;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
let decisionPoller = null;
|
|
2971
|
+
|
|
2972
|
+
/**
|
|
2973
|
+
* 把「人做的审批决定」落到电脑端(**手机拍板与企微卡片共用同一条路**)。
|
|
2974
|
+
*
|
|
2975
|
+
* 顺序与 decision-poller 原来的 apply 完全一致,抽出来只为让企微卡片复用:
|
|
2976
|
+
* ① 先看是不是"本地 Agent(Claude Code)"的审批 —— 协调器认得就消费掉;
|
|
2977
|
+
* ② 否则按 DSH 审批回执处理:allow 只映射成 `allowed-once`,**绝不升级成永久放行**。
|
|
2978
|
+
*
|
|
2979
|
+
* @param {string} requestId DSH 的审批事件 id
|
|
2980
|
+
* @param {"allow"|"deny"} decision
|
|
2981
|
+
* @returns {Promise<{ok:boolean, via?:string, reason?:string}>} 永不抛
|
|
2982
|
+
*/
|
|
2983
|
+
async function applyApprovalDecision(requestId, decision) {
|
|
2984
|
+
const id = String(requestId || "");
|
|
2985
|
+
if (!id) return { ok: false, reason: "缺少审批请求 id" };
|
|
2986
|
+
const decided = decision === "allow" ? "allow" : "deny";
|
|
2987
|
+
try {
|
|
2988
|
+
if (approvalCoordinator && approvalCoordinator.resolve(id, decided)) return { ok: true, via: "coordinator" };
|
|
2989
|
+
} catch { /* 继续走 DSH 那条路 */ }
|
|
2990
|
+
try {
|
|
2991
|
+
if (!pushReporter || typeof pushReporter.answerApproval !== "function") {
|
|
2992
|
+
return { ok: false, reason: "审批回执通道未就绪(事件订阅还没连上)" };
|
|
2993
|
+
}
|
|
2994
|
+
await pushReporter.answerApproval(id, decided === "allow" ? "allowed-once" : "rejected");
|
|
2995
|
+
return { ok: true, via: "dsh" };
|
|
2996
|
+
} catch (e) {
|
|
2997
|
+
return { ok: false, reason: redactText(e) };
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
function startDecisionPoller() {
|
|
3002
|
+
try {
|
|
3003
|
+
if (decisionPoller) return;
|
|
3004
|
+
decisionPoller = createDecisionPoller({
|
|
3005
|
+
apiBase: API_BASE,
|
|
3006
|
+
deviceId: DEVICE_ID,
|
|
3007
|
+
tokenProvider: () => PUSH_TOKEN,
|
|
3008
|
+
apply: async (item) => {
|
|
3009
|
+
// 手机拍板与企微卡片走同一条路(见 applyApprovalDecision 的说明)。
|
|
3010
|
+
// 失败必须 throw:decision-poller 据此计入 failed 并写日志(沿用改动前的语义)。
|
|
3011
|
+
const r = await applyApprovalDecision(item.request_id, item.decision);
|
|
3012
|
+
if (!r || r.ok !== true) throw new Error((r && r.reason) || "审批回执未能送达电脑端");
|
|
3013
|
+
return r;
|
|
3014
|
+
},
|
|
3015
|
+
log: (level, message) => {
|
|
3016
|
+
try {
|
|
3017
|
+
if (level === "warn") console.warn(message);
|
|
3018
|
+
else console.log(message);
|
|
3019
|
+
} catch { /* 忽略 */ }
|
|
3020
|
+
}
|
|
3021
|
+
});
|
|
3022
|
+
decisionPoller.start();
|
|
3023
|
+
} catch (e) {
|
|
3024
|
+
console.warn(`[decision] 轮询器初始化失败(已忽略):${e && e.message ? e.message : e}`);
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
|
|
2843
3028
|
function startWeChat() {
|
|
2844
3029
|
if (WECHAT_DISABLED) {
|
|
2845
3030
|
console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
|
|
@@ -2898,6 +3083,63 @@ async function stopWeChat() {
|
|
|
2898
3083
|
}
|
|
2899
3084
|
}
|
|
2900
3085
|
|
|
3086
|
+
/* ── 企业微信「智能机器人」长连接(task-21)──────────────────────────────────────
|
|
3087
|
+
*
|
|
3088
|
+
* 为什么默认关闭:这是**可选增强**,多一条常驻 WebSocket + 明文 secret 文件;
|
|
3089
|
+
* 没有配置的用户不应该为此付任何代价(零连接、零文件、零日志噪音)。
|
|
3090
|
+
*
|
|
3091
|
+
* 启动条件(全部满足才起):<relayDir>/im-bots.json 存在 && wecom.enabled === true
|
|
3092
|
+
* && botId 与 secret 都非空。开关:
|
|
3093
|
+
* · DSH_IM_BOTS=0 —— 整个功能强制关闭(连配置都不看)
|
|
3094
|
+
* · DSH_IM_BOT_NOTIFY=0 —— 只关出站通知(入站转发照常)
|
|
3095
|
+
* · DSH_PUSH_REPORT=0 —— 既有推送总闸,同样关掉本通知(口径一致)
|
|
3096
|
+
*
|
|
3097
|
+
* 配置热更新由 controller 自己 fs.watchFile(2s) 完成(改凭据/停用都不需要重启 bridge);
|
|
3098
|
+
* 任何异常都只记日志 —— 绝不影响远程访问主链路。
|
|
3099
|
+
*/
|
|
3100
|
+
let imBots = null;
|
|
3101
|
+
|
|
3102
|
+
function startImBots() {
|
|
3103
|
+
try {
|
|
3104
|
+
if (imBots) return;
|
|
3105
|
+
imBots = createWecomBotController({
|
|
3106
|
+
relayDir: RELAY_DIR,
|
|
3107
|
+
// 订阅器要连的本机 dsh web:传取值器,宿主换端口时下一次重连能跟上
|
|
3108
|
+
botOptions: {
|
|
3109
|
+
upstream: () => currentUpstreamForChannel(),
|
|
3110
|
+
cookie: harnessCookieOf
|
|
3111
|
+
},
|
|
3112
|
+
// 企微审批卡片点按钮 → 与手机拍板同一条路(协调器 → DSH 回执);失败返回 {ok:false} 让卡片显示"没送达"
|
|
3113
|
+
onApprovalDecision: (requestId, decision) => applyApprovalDecision(requestId, decision),
|
|
3114
|
+
log: (level, message) => {
|
|
3115
|
+
try {
|
|
3116
|
+
if (level === "warn" || level === "error") console.warn(message);
|
|
3117
|
+
else console.log(message);
|
|
3118
|
+
} catch { /* 忽略 */ }
|
|
3119
|
+
}
|
|
3120
|
+
});
|
|
3121
|
+
// 不 await:配置读取/连接都是可选增强,失败绝不影响隧道
|
|
3122
|
+
void imBots.start().catch((e) => {
|
|
3123
|
+
console.warn(`[bridge] 企业微信机器人通道启动失败(不影响远程访问): ${redactText(e)}`);
|
|
3124
|
+
});
|
|
3125
|
+
} catch (e) {
|
|
3126
|
+
console.warn(`[bridge] 企业微信机器人通道初始化失败(不影响远程访问): ${redactText(e)}`);
|
|
3127
|
+
imBots = null;
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
/** 优雅退出企业微信机器人通道(关长连接 + 停配置监听)。失败只记日志。 */
|
|
3132
|
+
async function stopImBots() {
|
|
3133
|
+
const c = imBots;
|
|
3134
|
+
imBots = null;
|
|
3135
|
+
if (!c) return;
|
|
3136
|
+
try {
|
|
3137
|
+
await c.stop();
|
|
3138
|
+
} catch (e) {
|
|
3139
|
+
console.warn(`[bridge] 企业微信机器人通道停止失败(忽略): ${redactText(e)}`);
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
|
|
2901
3143
|
function redactText(e) {
|
|
2902
3144
|
return String(e && e.message ? e.message : e).slice(0, 200);
|
|
2903
3145
|
}
|
|
@@ -3022,6 +3264,7 @@ async function runTunnel() {
|
|
|
3022
3264
|
// (真机日志实测:同一个桥接启动里出现两条并发的 device-login)。
|
|
3023
3265
|
// 而 /api/device-login 是按出口 IP 限流的 —— 每次启动都多花一份登录额度没有道理。
|
|
3024
3266
|
WECHAT_TIER_TOKEN = { value: token, at: Date.now() };
|
|
3267
|
+
PUSH_TOKEN = token;
|
|
3025
3268
|
// 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)。
|
|
3026
3269
|
// `pid` 一并写入 = **bridge 自证身份**:插件可以用「state.pid 活着 + heartbeat_at 新鲜」
|
|
3027
3270
|
// 判断桥是否真的在跑,不必依赖按镜像名(node.exe)扫描 —— 桌面端宿主拉起的进程名不是 node.exe。
|
|
@@ -3037,6 +3280,12 @@ async function runTunnel() {
|
|
|
3037
3280
|
}
|
|
3038
3281
|
// 隧道已发起(或已进入锁定等待)后再起微信:主链路优先,微信永不阻塞远程访问
|
|
3039
3282
|
startWeChat();
|
|
3283
|
+
// 同理:推送上报也是可选增强,最后起、失败静默
|
|
3284
|
+
startPushReporter();
|
|
3285
|
+
// 企业微信「智能机器人」长连接(默认关闭;没有 im-bots.json 就是 no-op)
|
|
3286
|
+
startImBots();
|
|
3287
|
+
// 多 Agent 运行时:默认关,只有 DSH_AGENT_ENABLED=1 **且** 推送通道已就绪时才起
|
|
3288
|
+
//(没有手机通道 = 审批无处可问,起了只会卡住用户)
|
|
3040
3289
|
setTimeout(() => {
|
|
3041
3290
|
console.log(`[bridge] 隧道模式运行中(上游 ${UPSTREAM},Ctrl-C 退出)`);
|
|
3042
3291
|
}, 1000);
|
|
@@ -3082,7 +3331,16 @@ async function main() {
|
|
|
3082
3331
|
console.log(`[bridge] 收到 ${sig},正在优雅退出...`);
|
|
3083
3332
|
// 退出前把 E2EE 会话库刷一次(滑动 TTL 的 usedAt 要落盘;崩溃也能恢复到最多 60s 前)
|
|
3084
3333
|
try { e2ee.flushStore(); } catch { /* 已内部兜底 */ }
|
|
3334
|
+
// 退出前尽力把待上报的事件发出去(最多等 1s,绝不阻塞退出)
|
|
3335
|
+
try { if (decisionPoller) decisionPoller.stop(); } catch { /* 已内部兜底 */ }
|
|
3336
|
+
// 未决审批一律拒绝(绝不留悬挂的 Promise)
|
|
3337
|
+
try { if (approvalCoordinator) approvalCoordinator.stop(); } catch { /* 已内部兜底 */ }
|
|
3338
|
+
try { if (pushReporter) await pushReporter.stop(); } catch { /* 已内部兜底 */ }
|
|
3339
|
+
// 多 Agent 运行时:关端口、删临时 settings、未决审批一律 deny(runtime 内部保证幂等)
|
|
3340
|
+
try { if (agentRuntime) await agentRuntime.stop(); } catch { /* 已内部兜底 */ }
|
|
3085
3341
|
await stopWeChat();
|
|
3342
|
+
// 企业微信机器人长连接:关连接 + 停配置监听(在 process.exit 之前)
|
|
3343
|
+
await stopImBots();
|
|
3086
3344
|
process.exit(0);
|
|
3087
3345
|
};
|
|
3088
3346
|
process.on("SIGTERM", () => { shutdown("SIGTERM"); });
|