@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.
Files changed (68) hide show
  1. package/clients/dsh-remote/agent/README.md +412 -0
  2. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  3. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  4. package/clients/dsh-remote/agent/codex.mjs +736 -0
  5. package/clients/dsh-remote/agent/detect.mjs +399 -0
  6. package/clients/dsh-remote/agent/events.mjs +511 -0
  7. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  8. package/clients/dsh-remote/agent/index.mjs +339 -0
  9. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  10. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  11. package/clients/dsh-remote/agent/proc.mjs +179 -0
  12. package/clients/dsh-remote/agent/version.mjs +148 -0
  13. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  14. package/clients/dsh-remote/decision-poller.mjs +235 -0
  15. package/clients/dsh-remote/dsh-bridge.mjs +214 -1
  16. package/clients/dsh-remote/push-reporter.mjs +54 -0
  17. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  18. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  19. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  20. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  21. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  22. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  23. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  24. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  25. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  26. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  27. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  28. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  29. package/clients/dsh-remote/test/wecom-bot.test.mjs +1438 -0
  30. package/clients/dsh-remote/wecom-bot.mjs +2444 -0
  31. package/dsh-setup.mjs +18 -3
  32. package/package.json +2 -2
  33. package/packages/dsh-remote-web/lib/client.js +1048 -223
  34. package/packages/dsh-remote-web/lib/index.js +299 -2
  35. package/packages/dsh-remote-web/package.json +1 -1
  36. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  37. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  38. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  39. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +258 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +2444 -0
  53. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
  54. package/packages/dsh-remote-web/test/activation-entry.test.mjs +11 -1
  55. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +8 -3
  56. package/packages/dsh-remote-web/test/connect-ui.test.mjs +16 -4
  57. package/packages/dsh-remote-web/test/im-bots.test.mjs +359 -0
  58. package/packages/dsh-remote-web/test/lib-panel-ia.mjs +98 -0
  59. package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +13 -5
  60. package/packages/dsh-remote-web/test/panel-ia.test.mjs +798 -0
  61. package/packages/dsh-remote-web/test/password-reset.test.mjs +11 -1
  62. package/packages/dsh-remote-web/test/plugin-link.test.mjs +78 -0
  63. package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +10 -1
  64. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +20 -8
  65. package/packages/dsh-remote-web/test/settings-entry.test.mjs +30 -21
  66. package/packages/dsh-remote-web/test/sms-captcha-ui.test.mjs +15 -2
  67. package/packages/dsh-remote-web/test/telemetry.test.mjs +5 -3
  68. 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
+ }
@@ -115,6 +115,13 @@ import {
115
115
  // 总开关 DSH_WECHAT=0(默认开启);未绑定时只起控制面,不起长轮询。
116
116
  import { createWeChatRuntime } from "./wechat-runtime.mjs";
117
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";
118
125
 
119
126
  // ---------- 强制直连:清除代理环境变量 ----------
120
127
  // 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
@@ -2873,13 +2880,151 @@ function startPushReporter() {
2873
2880
  }
2874
2881
  });
2875
2882
  // 不 await:启动探测失败绝不影响隧道
2876
- void pushReporter.start().catch(() => {});
2883
+ void pushReporter.start().then((st) => {
2884
+ try {
2885
+ if (st && st.started) {
2886
+ ensureApprovalCoordinator();
2887
+ startDecisionPoller();
2888
+ startAgentRuntime();
2889
+ }
2890
+ } catch { /* 忽略 */ }
2891
+ }).catch(() => {});
2877
2892
  } catch (e) {
2878
2893
  // 可选增强失败不得冒泡
2879
2894
  console.warn(`[push] 上报器初始化失败(已忽略):${e && e.message ? e.message : e}`);
2880
2895
  }
2881
2896
  }
2882
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
+
2883
3028
  function startWeChat() {
2884
3029
  if (WECHAT_DISABLED) {
2885
3030
  console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
@@ -2938,6 +3083,63 @@ async function stopWeChat() {
2938
3083
  }
2939
3084
  }
2940
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
+
2941
3143
  function redactText(e) {
2942
3144
  return String(e && e.message ? e.message : e).slice(0, 200);
2943
3145
  }
@@ -3080,6 +3282,10 @@ async function runTunnel() {
3080
3282
  startWeChat();
3081
3283
  // 同理:推送上报也是可选增强,最后起、失败静默
3082
3284
  startPushReporter();
3285
+ // 企业微信「智能机器人」长连接(默认关闭;没有 im-bots.json 就是 no-op)
3286
+ startImBots();
3287
+ // 多 Agent 运行时:默认关,只有 DSH_AGENT_ENABLED=1 **且** 推送通道已就绪时才起
3288
+ //(没有手机通道 = 审批无处可问,起了只会卡住用户)
3083
3289
  setTimeout(() => {
3084
3290
  console.log(`[bridge] 隧道模式运行中(上游 ${UPSTREAM},Ctrl-C 退出)`);
3085
3291
  }, 1000);
@@ -3126,8 +3332,15 @@ async function main() {
3126
3332
  // 退出前把 E2EE 会话库刷一次(滑动 TTL 的 usedAt 要落盘;崩溃也能恢复到最多 60s 前)
3127
3333
  try { e2ee.flushStore(); } catch { /* 已内部兜底 */ }
3128
3334
  // 退出前尽力把待上报的事件发出去(最多等 1s,绝不阻塞退出)
3335
+ try { if (decisionPoller) decisionPoller.stop(); } catch { /* 已内部兜底 */ }
3336
+ // 未决审批一律拒绝(绝不留悬挂的 Promise)
3337
+ try { if (approvalCoordinator) approvalCoordinator.stop(); } catch { /* 已内部兜底 */ }
3129
3338
  try { if (pushReporter) await pushReporter.stop(); } catch { /* 已内部兜底 */ }
3339
+ // 多 Agent 运行时:关端口、删临时 settings、未决审批一律 deny(runtime 内部保证幂等)
3340
+ try { if (agentRuntime) await agentRuntime.stop(); } catch { /* 已内部兜底 */ }
3130
3341
  await stopWeChat();
3342
+ // 企业微信机器人长连接:关连接 + 停配置监听(在 process.exit 之前)
3343
+ await stopImBots();
3131
3344
  process.exit(0);
3132
3345
  };
3133
3346
  process.on("SIGTERM", () => { shutdown("SIGTERM"); });
@@ -424,6 +424,60 @@ export function createPushReporter(options = {}) {
424
424
  stats.started = false;
425
425
  },
426
426
 
427
+ /**
428
+ * 上报一条"本地 Agent(Claude Code)在等拍板"的事件,让手机收到推送。
429
+ *
430
+ * 与 DSH 事件那条路的区别:这里没有 DSH 事件节点,payload 由我们直接构造;
431
+ * `summary` 只用于服务端风险分级(默认不进推送正文,见服务端 `notify_include_detail`),
432
+ * **本模块不把它写进日志**。
433
+ *
434
+ * @param {{requestId:string, toolName?:string, kind?:string, summary?:string}} info
435
+ * @returns {boolean} 是否真的进了队列(没启动/没订阅时返回 false)
436
+ */
437
+ reportApproval(info = {}) {
438
+ const requestId = String(info.requestId || "");
439
+ if (!requestId) return false;
440
+ if (!stats.started) return false;
441
+ const toolName = String(info.toolName || "");
442
+ const summary = String(info.summary || toolName || "").slice(0, MAX_BODY_LEN);
443
+ const payload = {
444
+ event_id: `${deviceId}:approval:${requestId}`.replace(/[^A-Za-z0-9._:-]/g, "_").slice(0, MAX_EVENT_ID_LEN),
445
+ device_id: deviceId,
446
+ kind: NOTIFY_KINDS.APPROVAL_REQUEST,
447
+ at: now(),
448
+ session: { id: "" },
449
+ title: "需要你拍板",
450
+ body: toolName ? `Claude Code 想执行:${toolName}` : "Claude Code 在等你允许一步操作",
451
+ approval: {
452
+ request_id: requestId,
453
+ summary,
454
+ options: [
455
+ { value: "allowed-once", label: "允许一次" },
456
+ { value: "rejected", label: "拒绝" }
457
+ ]
458
+ }
459
+ };
460
+ return enqueue(payload);
461
+ },
462
+
463
+ /**
464
+ * 把一条审批回执写回 DSH(供决策轮询器调用)。
465
+ *
466
+ * 为什么放在这里:DSH 的审批回执要用订阅器里的 `clientId`(+ 上游地址/cookie),
467
+ * 而订阅器归本模块所有;另起一个订阅器既浪费一条 WS,也会让"谁有资格回执"变得模糊。
468
+ *
469
+ * @param {string} eventId DSH 的审批事件 id(= 上报体里的 approval.request_id)
470
+ * @param {"allowed-once"|"rejected"} outcome
471
+ * @returns {Promise<any>} 原样返回订阅器结果;没有订阅器时抛错(由调用方兜底)
472
+ */
473
+ async answerApproval(eventId, outcome) {
474
+ const sub = subscriber;
475
+ if (!sub || typeof sub.answerApproval !== "function") {
476
+ throw new Error("push-reporter: 事件订阅器未就绪,暂时无法回执审批");
477
+ }
478
+ return sub.answerApproval(eventId, outcome);
479
+ },
480
+
427
481
  /** 只读状态(面板/诊断用;不包含任何事件内容)。 */
428
482
  status() {
429
483
  return { ...stats, queued: queue.length };