@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.
Files changed (60) hide show
  1. package/README.md +9 -2
  2. package/clients/dsh-remote/agent/README.md +412 -0
  3. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  4. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  5. package/clients/dsh-remote/agent/codex.mjs +736 -0
  6. package/clients/dsh-remote/agent/detect.mjs +399 -0
  7. package/clients/dsh-remote/agent/events.mjs +511 -0
  8. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  9. package/clients/dsh-remote/agent/index.mjs +339 -0
  10. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  11. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  12. package/clients/dsh-remote/agent/proc.mjs +179 -0
  13. package/clients/dsh-remote/agent/version.mjs +148 -0
  14. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  15. package/clients/dsh-remote/decision-poller.mjs +235 -0
  16. package/clients/dsh-remote/dsh-bridge.mjs +208 -1
  17. package/clients/dsh-remote/mobile-adapter.mjs +775 -11
  18. package/clients/dsh-remote/push-reporter.mjs +533 -0
  19. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  20. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  21. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  22. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  23. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  24. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  25. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  26. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  27. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  28. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  29. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  30. package/clients/dsh-remote/test/bridge-e2ee-reject-observable.test.mjs +90 -0
  31. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  32. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +172 -0
  33. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +188 -4
  34. package/clients/dsh-remote/test/push-reporter.test.mjs +380 -0
  35. package/dsh-setup.mjs +5 -0
  36. package/package.json +2 -2
  37. package/packages/dsh-remote-web/lib/client.js +350 -8
  38. package/packages/dsh-remote-web/lib/index.js +26 -0
  39. package/packages/dsh-remote-web/package.json +1 -1
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  53. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  54. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +208 -1
  55. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +775 -11
  56. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  57. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +5 -0
  58. package/packages/dsh-remote-web/test/activation-entry.test.mjs +407 -0
  59. package/packages/dsh-remote-web/test/e2ee-reject-diag.test.mjs +70 -0
  60. package/packages/dsh-remote-web/test/setup-output.test.mjs +16 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * 子进程治理 —— 起 Claude Code、**可靠地**把它和它的工具子进程一起杀掉。
3
+ *
4
+ * ## 为什么单独一个模块(第 1 周实测教训,报告 §4.5)
5
+ *
6
+ * 实测:经 `cli-wrapper.cjs` 启动时,对 **wrapper 的 node 进程**发 SIGTERM,
7
+ * wrapper 退出了,但**原生 claude 被 reparent 到 PPID=1 继续活着**(因为 wrapper 用
8
+ * `spawnSync`,阻塞时不会转发信号)。
9
+ *
10
+ * 对用户的后果很重:手机上点"停止" → 界面显示已停止 → **Agent 仍在跑,还在执行工具**。
11
+ * 所以:
12
+ * · posix:`detached:true` 建**独立进程组**,停止时 `process.kill(-pid, …)` 杀整组;
13
+ * · Windows:预留 `taskkill /PID <pid> /T /F` 分支(**未在本轮真机验证**,见 README 的"未验证项");
14
+ * · 先 SIGTERM 留 3s 收尾,再 SIGKILL —— 直接 SIGKILL 会让 mcp/hook 清理逻辑没机会跑。
15
+ *
16
+ * ## 可测性
17
+ *
18
+ * `spawn` / `kill` / `execFile` / 计时器全部可注入:单测用假实现验证"杀组、升级、兜底"三条路径,
19
+ * **不会真的起 claude、也不会真的杀进程**。
20
+ */
21
+
22
+ import { spawn as nodeSpawn, execFile as nodeExecFile } from "node:child_process";
23
+
24
+ /** 默认宽容期:SIGTERM 后等多久再 SIGKILL。 */
25
+ export const DEFAULT_GRACE_MS = 3000;
26
+
27
+ /**
28
+ * 启动 Agent 子进程。
29
+ *
30
+ * - **参数数组**,永不拼 shell 字符串(用户输入不经过命令行解析);
31
+ * - posix 用 `detached:true` 建独立进程组(这样 `kill(-pid)` 才能一次杀干净);
32
+ * - Windows `windowsHide:true`(不弹黑框),且不 detached(Windows 语义不同,杀树走 taskkill)。
33
+ *
34
+ * @param {{command:string,args?:string[],cwd?:string,env?:object,stdio?:Array}} spec
35
+ * @param {{spawn?:Function,platform?:string}} deps
36
+ */
37
+ export function spawnAgentProcess(
38
+ { command, args = [], cwd, env, stdio = ["ignore", "pipe", "pipe"] },
39
+ { spawn = nodeSpawn, platform = process.platform, windowsHide = true } = {}
40
+ ) {
41
+ if (!command) throw new Error("spawnAgentProcess: 缺少 command");
42
+ const detached = platform !== "win32";
43
+ const child = spawn(command, args, {
44
+ cwd,
45
+ env: env || process.env,
46
+ stdio,
47
+ detached,
48
+ windowsHide,
49
+ shell: false // 绝不经过 shell
50
+ });
51
+ return {
52
+ child,
53
+ pid: child.pid,
54
+ // 有独立进程组时,-pid 才是"整组";Windows 没有这个概念
55
+ group: detached,
56
+ detached,
57
+ platform
58
+ };
59
+ }
60
+
61
+ /** 进程是否还活着。ESRCH=不存在;EPERM=存在但没权限(也算活着)。 */
62
+ export function isProcessAlive(pid, { kill = process.kill } = {}) {
63
+ if (!Number.isInteger(pid) || pid <= 0) return false;
64
+ try {
65
+ kill(pid, 0);
66
+ return true;
67
+ } catch (err) {
68
+ return err?.code === "EPERM";
69
+ }
70
+ }
71
+
72
+ /** 睡 ms(可注入计时器,单测不需要真等 3 秒)。 */
73
+ function sleep(ms, { timers = { setTimeout, clearTimeout } } = {}) {
74
+ return new Promise((resolve) => {
75
+ const t = timers.setTimeout(resolve, ms);
76
+ // 允许 fake timer 返回非 Timer 对象(无所谓,只要能触发)
77
+ void t;
78
+ });
79
+ }
80
+
81
+ /**
82
+ * 杀掉一个 Agent 及其后代。
83
+ *
84
+ * posix:先 `kill(-pid, SIGTERM)`(整组)→ 等 graceMs → 还活着就 `kill(-pid, SIGKILL)`;
85
+ * 组信号 ESRCH(组已没了/不是组长)时退回单进程信号。
86
+ * win32:`taskkill /PID <pid> /T /F`。**本轮未真机验证**,返回 `verified:false`,
87
+ * 上层不得向用户宣称"已确认杀死",只能按"已请求"显示。
88
+ *
89
+ * @returns {Promise<{ok:boolean, method:string, escalated:boolean, verified:boolean, error?:string}>}
90
+ */
91
+ export async function terminateProcessTree(
92
+ pid,
93
+ {
94
+ platform = process.platform,
95
+ kill = process.kill,
96
+ execFile = nodeExecFile,
97
+ timers = { setTimeout, clearTimeout },
98
+ graceMs = DEFAULT_GRACE_MS,
99
+ log = () => {}
100
+ } = {}
101
+ ) {
102
+ if (!Number.isInteger(pid) || pid <= 0) {
103
+ return { ok: false, method: "noop", escalated: false, verified: false, error: "invalid-pid" };
104
+ }
105
+ const alive = () => isProcessAlive(pid, { kill });
106
+
107
+ // ── Windows:taskkill /T /F(杀整棵树)。未验证,只报"已请求"。
108
+ if (platform === "win32") {
109
+ try {
110
+ await new Promise((resolve, reject) => {
111
+ execFile("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, (err) =>
112
+ err ? reject(err) : resolve()
113
+ );
114
+ });
115
+ log({ level: "info", event: "proc.kill", method: "taskkill-tree", pid, verified: false });
116
+ return { ok: true, method: "taskkill-tree", escalated: false, verified: false };
117
+ } catch (err) {
118
+ // 兜底:至少杀掉主进程
119
+ try {
120
+ kill(pid, "SIGKILL");
121
+ log({ level: "warn", event: "proc.kill", method: "taskkill-fallback-kill", pid, error: String(err?.message || err).slice(0, 120) });
122
+ return { ok: true, method: "taskkill-fallback-kill", escalated: false, verified: false, error: "taskkill-failed" };
123
+ } catch (err2) {
124
+ return { ok: false, method: "taskkill", escalated: false, verified: false, error: String(err2?.message || err2).slice(0, 120) };
125
+ }
126
+ }
127
+ }
128
+
129
+ // ── posix:优先杀进程组
130
+ let method = "group-term";
131
+ let signalled = false;
132
+ try {
133
+ kill(-pid, "SIGTERM");
134
+ signalled = true;
135
+ } catch (err) {
136
+ if (err?.code === "ESRCH") {
137
+ // 组不存在(未 detached,或已经是孤儿)→ 退回单进程
138
+ try {
139
+ kill(pid, "SIGTERM");
140
+ method = "pid-term";
141
+ signalled = true;
142
+ } catch (err2) {
143
+ if (err2?.code === "ESRCH") {
144
+ log({ level: "info", event: "proc.kill", method: "already-gone", pid });
145
+ return { ok: true, method: "already-gone", escalated: false, verified: true };
146
+ }
147
+ return { ok: false, method: "pid-term", escalated: false, verified: false, error: String(err2?.message || err2).slice(0, 120) };
148
+ }
149
+ } else if (err?.code === "EPERM") {
150
+ return { ok: false, method: "group-term", escalated: false, verified: false, error: "eperm" };
151
+ } else {
152
+ return { ok: false, method: "group-term", escalated: false, verified: false, error: String(err?.message || err).slice(0, 120) };
153
+ }
154
+ }
155
+
156
+ log({ level: "info", event: "proc.term", method, pid, signalled });
157
+ await sleep(graceMs, { timers });
158
+
159
+ if (!alive()) {
160
+ log({ level: "info", event: "proc.kill", method: `${method}-done`, pid });
161
+ return { ok: true, method, escalated: false, verified: true };
162
+ }
163
+
164
+ // 升级 SIGKILL(整组 + 单进程都试;组杀不到时单进程是最后保障)
165
+ try {
166
+ kill(-pid, "SIGKILL");
167
+ } catch {
168
+ /* 组可能已散 */
169
+ }
170
+ try {
171
+ kill(pid, "SIGKILL");
172
+ } catch (err) {
173
+ if (err?.code !== "ESRCH") {
174
+ return { ok: false, method: `${method}-kill9`, escalated: true, verified: false, error: String(err?.message || err).slice(0, 120) };
175
+ }
176
+ }
177
+ log({ level: "warn", event: "proc.kill", method: `${method}-kill9`, pid, escalated: true });
178
+ return { ok: true, method: `${method}-kill9`, escalated: true, verified: true };
179
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Claude Code 版本门禁 —— 判断"这台机器上的 CLI 能不能接受远程审批"。
3
+ *
4
+ * ## 为什么需要它(M2 第 1 周实测结论,报告 §2.2)
5
+ *
6
+ * 我们靠 `--settings` 注入一个 `PermissionRequest` 钩子来接管审批。实测:
7
+ * · Claude Code **2.1.318**:钩子被调用、决策被采纳(debug 日志 `executePermissionRequestHooks`);
8
+ * · Claude Code **2.1.195**:`permissions.ask` 规则生效、动作被拒,但 **`PermissionRequest`
9
+ * 钩子一次都没有被咨询**(`--debug hooks` 里零次 `executePermissionRequestHooks`)。
10
+ *
11
+ * 也就是说:旧版本上动作仍会被 Claude Code 自己拒绝,**但我们的手机端永远收不到审批请求** ——
12
+ * 这是**静默失败**,比"明确不可用"危险得多(用户会以为"没消息=没风险")。
13
+ *
14
+ * 因此策略是:**版本不够就不启动桥接、不渲染"可审批"入口**,并把原因如实返回给上层。
15
+ *
16
+ * ## 阈值为什么默认 2.1.318
17
+ *
18
+ * 实测只确定了 `2.1.195 < 门槛 ≤ 2.1.318`。默认取**已验证可用**的一端(2.1.318)是保守选择:
19
+ * 宁可多挡一些用户(提示它升级),也不能让旧版本静默丢审批。
20
+ * 阈值可用 `DSH_AGENT_MIN_CLAUDE_VERSION` 覆盖(业主/CI 二分版本时用),见 README。
21
+ */
22
+
23
+ /** 默认最低支持版本:M2 实测唯一确认可用的版本(见报告 §2.3)。 */
24
+ export const DEFAULT_MIN_CLAUDE_VERSION = "2.1.318";
25
+
26
+ /** 版本门禁的环境变量名(可覆盖阈值;用于版本二分与灰度)。 */
27
+ export const MIN_VERSION_ENV = "DSH_AGENT_MIN_CLAUDE_VERSION";
28
+
29
+ /**
30
+ * 从 `claude --version` 的输出里解析版本。
31
+ *
32
+ * 实测输出形如 `2.1.195 (Claude Code)`;也兼容 `claude 2.1.318`、纯 `2.1.318`。
33
+ * 解析失败**不抛错**(返回 null)—— 调用方要把它当成"未知版本"走保守分支,
34
+ * 因为抛错会让 bridge 启动路径多出一个崩溃面。
35
+ *
36
+ * @param {string} text CLI 输出
37
+ * @returns {{major:number,minor:number,patch:number,prerelease:string,raw:string}|null}
38
+ */
39
+ export function parseClaudeVersion(text) {
40
+ const raw = String(text ?? "").trim();
41
+ if (!raw) return null;
42
+ // 取第一个 "x.y.z"(可能带 -beta.1 之类的预发布后缀)
43
+ const m = /(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(raw);
44
+ if (!m) return null;
45
+ return {
46
+ major: Number(m[1]),
47
+ minor: Number(m[2]),
48
+ patch: Number(m[3]),
49
+ prerelease: m[4] || "",
50
+ raw
51
+ };
52
+ }
53
+
54
+ /**
55
+ * 语义化版本比较:a<b → -1,a==b → 0,a>b → 1。
56
+ *
57
+ * 只比较数字段;同一数字段下**预发布 < 正式版**(2.1.318-beta < 2.1.318)。
58
+ * 非法输入按"最低"处理(返回 -1 对待),由门禁层决定怎么报错。
59
+ *
60
+ * @param {object|null} a
61
+ * @param {object|null} b
62
+ */
63
+ export function compareVersions(a, b) {
64
+ const aa = a || { major: 0, minor: 0, patch: 0, prerelease: "" };
65
+ const bb = b || { major: 0, minor: 0, patch: 0, prerelease: "" };
66
+ for (const k of ["major", "minor", "patch"]) {
67
+ if (aa[k] !== bb[k]) return aa[k] < bb[k] ? -1 : 1;
68
+ }
69
+ const pa = String(aa.prerelease || "");
70
+ const pb = String(bb.prerelease || "");
71
+ if (pa === pb) return 0;
72
+ if (!pa) return 1; // 正式版 > 预发布
73
+ if (!pb) return -1;
74
+ return pa < pb ? -1 : 1;
75
+ }
76
+
77
+ /**
78
+ * 通用别名:`parseClaudeVersion` 本质是"从任意 CLI 输出里抓第一个 x.y.z",
79
+ * Codex(`codex-cli 0.150.1`)等其它 harness 也直接复用,避免各写一套解析。
80
+ */
81
+ export const parseVersion = parseClaudeVersion;
82
+
83
+ /** 把版本对象格式化成 `2.1.318`(预发布保留后缀)。 */
84
+ export function formatVersion(v) {
85
+ if (!v) return "";
86
+ const base = `${v.major}.${v.minor}.${v.patch}`;
87
+ return v.prerelease ? `${base}-${v.prerelease}` : base;
88
+ }
89
+
90
+ /**
91
+ * 解析阈值(env 可覆盖)。非法值**回落到默认值**并给出 warning ——
92
+ * 门禁的配置写错时,宁可挡用户(默认保守)也不能放行。
93
+ *
94
+ * @param {Record<string,string|undefined>} env
95
+ * @returns {{min:object,minText:string,source:"env"|"default",warning?:string}}
96
+ */
97
+ export function resolveMinVersion(env = {}) {
98
+ const def = parseClaudeVersion(DEFAULT_MIN_CLAUDE_VERSION);
99
+ const raw = String(env?.[MIN_VERSION_ENV] ?? "").trim();
100
+ if (!raw) return { min: def, minText: DEFAULT_MIN_CLAUDE_VERSION, source: "default" };
101
+ const parsed = parseClaudeVersion(raw);
102
+ if (!parsed) {
103
+ return {
104
+ min: def,
105
+ minText: DEFAULT_MIN_CLAUDE_VERSION,
106
+ source: "default",
107
+ warning: `${MIN_VERSION_ENV}=${raw} 不是合法版本号,已回落到默认阈值 ${DEFAULT_MIN_CLAUDE_VERSION}`
108
+ };
109
+ }
110
+ return { min: parsed, minText: formatVersion(parsed), source: "env" };
111
+ }
112
+
113
+ /**
114
+ * 版本门禁判定。
115
+ *
116
+ * status 取值(上层 UI 直接映射文案):
117
+ * · `ok` —— 版本达标,可启用审批
118
+ * · `version-unsupported` —— 版本低于阈值,**不启用**(明确原因)
119
+ * · `version-unknown` —— 解析不出/拿不到版本,**不启用**(保守)
120
+ *
121
+ * @param {{version:object|null, min:object, minText?:string, versionText?:string}} input
122
+ */
123
+ export function checkVersionGate({ version, min, minText, versionText } = {}) {
124
+ if (!version) {
125
+ return {
126
+ ok: false,
127
+ status: "version-unknown",
128
+ reason: "无法解析 Claude Code 版本(`claude --version` 输出不认识或探测失败)",
129
+ minText: minText || formatVersion(min)
130
+ };
131
+ }
132
+ const cmp = compareVersions(version, min);
133
+ if (cmp < 0) {
134
+ return {
135
+ ok: false,
136
+ status: "version-unsupported",
137
+ reason: `Claude Code ${versionText || formatVersion(version)} 低于最低支持版本 ${minText || formatVersion(min)},远程审批不可用`,
138
+ required: minText || formatVersion(min),
139
+ actual: versionText || formatVersion(version)
140
+ };
141
+ }
142
+ return {
143
+ ok: true,
144
+ status: "ok",
145
+ required: minText || formatVersion(min),
146
+ actual: versionText || formatVersion(version)
147
+ };
148
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * approval-coordinator — 把「Claude Code 的审批请求」接到「手机端回执」上。
3
+ *
4
+ * 链路:
5
+ * Claude Code 要升权 → 我们的钩子收到请求(requestId)
6
+ * → 本模块登记一个待决 Promise,并让 bridge 上报一条 approval 事件(手机收到推送)
7
+ * → 用户在手机上点「允许一次 / 拒绝」
8
+ * → decision-poller 拉到回执 → 调本模块 `resolve(requestId, decision)` → Promise 落地
9
+ * → 钩子回写 allow/deny,Claude Code 继续或放弃
10
+ *
11
+ * 设计边界(每一条都重要)
12
+ * ---------------------------------------------------------------
13
+ * 1. **fail-closed**:TTL 到点(默认 90s,与钩子一致)、stop()、上报失败 → 一律 `deny`。
14
+ * 「没人拍板」绝不能被当成「同意」。
15
+ * 2. **只有一次**:同一条请求只 settle 一次;重复 resolve 返回 false(幂等,不报错)。
16
+ * 3. **不泄漏原文**:上报体里的 `summary` 用于服务端风险分级(必须给),但**日志里永远只有
17
+ * toolName/requestId**,不记录命令全文。
18
+ * 4. **不阻塞**:上报是 fire-and-forget;上报抛错也只在超时后 deny(不会让钩子挂死)。
19
+ * 5. **有界**:同时在等的请求上限(默认 8,与钩子并发闸一致),超限直接 deny 并告警。
20
+ */
21
+
22
+ export const DEFAULT_APPROVAL_TTL_MS = 90_000;
23
+ export const MAX_PENDING_APPROVALS = 8;
24
+
25
+ /** 拒绝时回给模型/用户的中文说明(会原样进 Claude 上下文)。 */
26
+ export const DENY_MESSAGES = Object.freeze({
27
+ timeout: "等待手机审批超时,已按拒绝处理",
28
+ stopped: "bridge 关闭,未决审批一律拒绝",
29
+ overflow: "同时在等的审批过多,已拒绝(请稍后重试)",
30
+ cancelled: "审批已取消"
31
+ });
32
+
33
+ /**
34
+ * @param {{
35
+ * report?: (payload: {requestId:string, toolName:string, kind:string, summary:string}) => any,
36
+ * ttlMs?: number,
37
+ * maxPending?: number,
38
+ * now?: () => number,
39
+ * log?: (level:string, message:string, meta?:object) => void,
40
+ * setTimeoutImpl?: (fn:Function, ms:number)=>any,
41
+ * clearTimeoutImpl?: (t:any)=>void,
42
+ * }} options
43
+ */
44
+ export function createApprovalCoordinator(options = {}) {
45
+ const report = typeof options.report === "function" ? options.report : null;
46
+ const ttlMs = Number.isFinite(options.ttlMs) ? options.ttlMs : DEFAULT_APPROVAL_TTL_MS;
47
+ const maxPending = Number.isFinite(options.maxPending) ? options.maxPending : MAX_PENDING_APPROVALS;
48
+ const now = typeof options.now === "function" ? options.now : () => Date.now();
49
+ const log = typeof options.log === "function" ? options.log : () => {};
50
+ const setT = typeof options.setTimeoutImpl === "function" ? options.setTimeoutImpl : setTimeout;
51
+ const clearT = typeof options.clearTimeoutImpl === "function" ? options.clearTimeoutImpl : clearTimeout;
52
+
53
+ /** requestId -> {resolve, timer, at, toolName} */
54
+ const pending = new Map();
55
+ const stats = { asked: 0, allowed: 0, denied: 0, expired: 0, overflow: 0, stopped: false };
56
+
57
+ function settle(requestId, decision, message, reason) {
58
+ const entry = pending.get(requestId);
59
+ if (!entry) return false;
60
+ pending.delete(requestId);
61
+ try { clearT(entry.timer); } catch { /* 忽略 */ }
62
+ const d = decision === "allow" ? "allow" : "deny";
63
+ if (d === "allow") stats.allowed += 1;
64
+ else stats.denied += 1;
65
+ if (reason === "timeout") stats.expired += 1;
66
+ log("info", `[approval] ${requestId} → ${d}${reason ? `(${reason})` : ""}`);
67
+ try { entry.resolve({ decision: d, message }); } catch { /* 调用方自己兜 */ }
68
+ return true;
69
+ }
70
+
71
+ return {
72
+ /**
73
+ * 钩子调用:等手机拍板。返回 `{decision:"allow"|"deny", message?}`。
74
+ * @param {{requestId:string, toolName?:string, kind?:string, detail?:string, detailTruncated?:boolean, expiresAt?:number}} req
75
+ */
76
+ ask(req) {
77
+ if (stats.stopped) {
78
+ return Promise.resolve({ decision: "deny", message: DENY_MESSAGES.stopped });
79
+ }
80
+ const requestId = String(req && req.requestId ? req.requestId : "");
81
+ if (!requestId) {
82
+ return Promise.resolve({ decision: "deny", message: "审批请求缺少 id" });
83
+ }
84
+ if (pending.size >= maxPending) {
85
+ stats.overflow += 1;
86
+ log("warn", `[approval] 待决审批已达上限 ${maxPending},直接拒绝 ${requestId}`);
87
+ return Promise.resolve({ decision: "deny", message: DENY_MESSAGES.overflow });
88
+ }
89
+ if (pending.has(requestId)) {
90
+ // 同一请求重复进来(钩子重试):沿用第一次的 Promise,不重复上报
91
+ return pending.get(requestId).promise;
92
+ }
93
+ let resolveFn;
94
+ const promise = new Promise((resolve) => { resolveFn = resolve; });
95
+ const timer = setT(() => {
96
+ settle(requestId, "deny", DENY_MESSAGES.timeout, "timeout");
97
+ }, ttlMs);
98
+ if (timer && typeof timer.unref === "function") timer.unref();
99
+ pending.set(requestId, { resolve: resolveFn, timer, at: now(), toolName: String((req && req.toolName) || ""), promise });
100
+ stats.asked += 1;
101
+
102
+ // 上报:让手机收到推送。失败不影响超时兜底(到点仍然 deny)。
103
+ if (report) {
104
+ try {
105
+ void Promise.resolve(report({
106
+ requestId,
107
+ toolName: String((req && req.toolName) || ""),
108
+ kind: String((req && req.kind) || ""),
109
+ // summary 只用于服务端风险分级;默认不会进推送正文(服务端 notify_include_detail=0)
110
+ summary: String((req && req.detail) || (req && req.toolName) || "").slice(0, 200)
111
+ })).catch((e) => {
112
+ log("warn", `[approval] 上报失败(等超时拒绝):${e && e.message ? e.message : e}`);
113
+ });
114
+ } catch (e) {
115
+ log("warn", `[approval] 上报异常(等超时拒绝):${e && e.message ? e.message : e}`);
116
+ }
117
+ } else {
118
+ log("warn", "[approval] 没有上报通道,本次审批只能等超时拒绝");
119
+ }
120
+ return promise;
121
+ },
122
+
123
+ /**
124
+ * 回执到达时调用。返回 true = 这条回执属于审批请求(已消费);false = 不归我管(交给 DSH 那条路)。
125
+ * @param {string} requestId
126
+ * @param {"allow"|"deny"} decision
127
+ */
128
+ resolve(requestId, decision) {
129
+ const id = String(requestId || "");
130
+ if (!pending.has(id)) return false;
131
+ settle(id, decision, undefined, decision === "allow" ? undefined : "denied-by-user");
132
+ return true;
133
+ },
134
+
135
+ /** 关闭:所有未决一律拒绝(绝不留悬挂的 Promise)。 */
136
+ stop() {
137
+ stats.stopped = true;
138
+ const ids = [...pending.keys()];
139
+ for (const id of ids) settle(id, "deny", DENY_MESSAGES.stopped, "stopped");
140
+ return ids.length;
141
+ },
142
+
143
+ status() {
144
+ return { ...stats, pending: pending.size, ttlMs, maxPending };
145
+ },
146
+
147
+ /** 仅供单测:当前待决 id 列表。 */
148
+ _pendingIds() { return [...pending.keys()]; }
149
+ };
150
+ }