@mrrisega/dsh-remote 0.7.10-beta.3 → 0.7.12-beta.1
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 +220 -1
- package/clients/dsh-remote/im-format.mjs +372 -0
- 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/im-format.test.mjs +229 -0
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +203 -2
- package/clients/dsh-remote/test/wecom-bot.test.mjs +1718 -0
- package/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/clients/dsh-remote/wecom-bot.mjs +2670 -0
- package/dsh-setup.mjs +95 -9
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +1377 -292
- package/packages/dsh-remote-web/lib/index.js +419 -12
- 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 +18 -3
- 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 +400 -0
- package/packages/dsh-remote-web/test/lib-panel-ia.mjs +98 -0
- package/packages/dsh-remote-web/test/login-fast.test.mjs +104 -0
- package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +17 -6
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +996 -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 +14 -5
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +69 -119
- package/packages/dsh-remote-web/test/self-manage.test.mjs +5 -1
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +42 -25
- package/packages/dsh-remote-web/test/sms-captcha-ui.test.mjs +15 -2
- package/packages/dsh-remote-web/test/telemetry.test.mjs +6 -4
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +57 -130
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PermissionBridge —— 把 Claude Code 的 `PermissionRequest` 钩子接成"本机 HTTP 服务"。
|
|
3
|
+
*
|
|
4
|
+
* ## 它到底做什么
|
|
5
|
+
*
|
|
6
|
+
* Claude Code ──POST JSON──▶ 127.0.0.1:<随机端口>/permission?t=<token>
|
|
7
|
+
* │
|
|
8
|
+
* ├─ 校验 token / 事件名 / 体积 / 并发
|
|
9
|
+
* ├─ 归一化成一条审批请求(toolName / kind / detail)
|
|
10
|
+
* ├─ 交给可注入的 decide()(M2 第 3 周会接手机端;现在默认 fail-closed)
|
|
11
|
+
* └─ 回写实测确认过的响应体(报告 §3.1):
|
|
12
|
+
* {hookSpecificOutput:{hookEventName:"PermissionRequest",
|
|
13
|
+
* decision:{behavior:"allow"|"deny", message?}}}
|
|
14
|
+
*
|
|
15
|
+
* ## 三个"必须"来自实测,不是猜的
|
|
16
|
+
*
|
|
17
|
+
* ① **响应体形状必须完全正确**:第 1 周实测 8 种变体,只有 `hookSpecificOutput.hookEventName`
|
|
18
|
+
* + `decision.behavior` 这一种生效;缺字段/拼错/非 JSON/空 body/HTTP 500 **一律等于无决策 = 拒绝**。
|
|
19
|
+
* (所以本模块对"异常输入"是**不返回决策**,让上游按 fail-closed 处理,而不是自己编一个 allow。)
|
|
20
|
+
* ② **超时 = 拒绝**:`timeout` 单位是秒且被上游遵守;我们自己的决策窗口默认 90s,
|
|
21
|
+
* 到期主动回 deny,不做"没回复就放行"。
|
|
22
|
+
* ③ **临时 settings 只写权限钩子**:不改用户配置;文件 0600(里面有 token);`stop()` 必删。
|
|
23
|
+
*
|
|
24
|
+
* ## 隐私
|
|
25
|
+
*
|
|
26
|
+
* `decide()` 拿得到 `detail`(命令原文/文件路径——手机审批要看),但 **log 回调永远拿不到原文**:
|
|
27
|
+
* 只记 `{requestId, toolName, kind, outcome, durationMs}`。命令原文也不落盘(临时 settings 里只有 URL)。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import crypto from "node:crypto";
|
|
31
|
+
import fsSync from "node:fs";
|
|
32
|
+
import http from "node:http";
|
|
33
|
+
import os from "node:os";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
|
|
36
|
+
/** 默认决策窗口(ms)。留出"手机解锁 + 用户拍板"的时间,又明显短于钩子默认 600s。 */
|
|
37
|
+
export const DEFAULT_DECISION_TTL_MS = 90_000;
|
|
38
|
+
/** 写进临时 settings 的 hook timeout(秒)。90s 窗口 + 30s 网络/重试余量。 */
|
|
39
|
+
export const DEFAULT_HOOK_TIMEOUT_SEC = 120;
|
|
40
|
+
/** 请求体上限:钩子 body 就是 tool_input,正常几 KB;超过它多半不是我们的钩子。 */
|
|
41
|
+
export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
|
42
|
+
/** 同时等待决策的请求上限(超了直接拒绝,不排队——排队会把内存和时延都拖长)。 */
|
|
43
|
+
export const DEFAULT_MAX_PENDING = 8;
|
|
44
|
+
/** 回给用户的默认拒绝文案(不含命令原文)。 */
|
|
45
|
+
export const DEFAULT_DENY_MESSAGE = "远程审批未完成,已按拒绝处理";
|
|
46
|
+
|
|
47
|
+
/** 工具名 → ACP 的 kind 词表(设计稿 §2.3 直接沿用)。 */
|
|
48
|
+
const TOOL_KIND = Object.freeze({
|
|
49
|
+
Bash: "execute",
|
|
50
|
+
BashOutput: "execute",
|
|
51
|
+
KillShell: "execute",
|
|
52
|
+
Read: "read",
|
|
53
|
+
NotebookRead: "read",
|
|
54
|
+
Write: "edit",
|
|
55
|
+
Edit: "edit",
|
|
56
|
+
MultiEdit: "edit",
|
|
57
|
+
NotebookEdit: "edit",
|
|
58
|
+
Glob: "search",
|
|
59
|
+
Grep: "search",
|
|
60
|
+
WebFetch: "fetch",
|
|
61
|
+
WebSearch: "fetch",
|
|
62
|
+
Task: "other",
|
|
63
|
+
TodoWrite: "other",
|
|
64
|
+
ExitPlanMode: "switch_mode"
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** 工具名 → 给人看的一句话(**不含**命令原文)。 */
|
|
68
|
+
const TOOL_TITLE = Object.freeze({
|
|
69
|
+
Bash: "执行命令",
|
|
70
|
+
Read: "读取文件",
|
|
71
|
+
Write: "写入文件",
|
|
72
|
+
Edit: "修改文件",
|
|
73
|
+
MultiEdit: "修改文件",
|
|
74
|
+
NotebookEdit: "修改笔记本",
|
|
75
|
+
Glob: "查找文件",
|
|
76
|
+
Grep: "搜索内容",
|
|
77
|
+
WebFetch: "访问网络",
|
|
78
|
+
WebSearch: "联网搜索",
|
|
79
|
+
Task: "派发子任务"
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** 映射工具类别;未知工具按 `other`(不猜)。 */
|
|
83
|
+
export function toolKindOf(toolName) {
|
|
84
|
+
return TOOL_KIND[toolName] || "other";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 固定的两个审批选项(实测没有 permission_suggestions,不能依赖上游给)。 */
|
|
88
|
+
export function permissionOptions() {
|
|
89
|
+
return [
|
|
90
|
+
{ optionId: "allow-once", kind: "allow_once", name: "允许一次" },
|
|
91
|
+
{ optionId: "reject-once", kind: "reject_once", name: "拒绝" }
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 生成实测确认过的响应体(报告 §3.1 第 1、2 行)。
|
|
97
|
+
* @param {"allow"|"deny"} decision
|
|
98
|
+
* @param {string} [message] 仅拒绝时有意义(会原样进入模型上下文)
|
|
99
|
+
*/
|
|
100
|
+
export function buildDecisionResponse(decision, message) {
|
|
101
|
+
if (decision === "allow") {
|
|
102
|
+
return {
|
|
103
|
+
hookSpecificOutput: {
|
|
104
|
+
hookEventName: "PermissionRequest",
|
|
105
|
+
decision: { behavior: "allow" }
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const out = { behavior: "deny" };
|
|
110
|
+
const msg = String(message ?? "").trim();
|
|
111
|
+
if (msg) out.message = msg.slice(0, 1024);
|
|
112
|
+
return { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: out } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 生成临时 `--settings` 文件内容。
|
|
117
|
+
*
|
|
118
|
+
* 只声明 `PermissionRequest` 一个钩子(http 类型,实测 PermissionRequest 支持 http;
|
|
119
|
+
* SessionStart 不支持,所以别往这里加别的钩子)。`matcher:"*"` 实测可用。
|
|
120
|
+
*/
|
|
121
|
+
export function buildPermissionSettings({ port, token, hookTimeoutSec = DEFAULT_HOOK_TIMEOUT_SEC, matcher = "*" }) {
|
|
122
|
+
const url = `http://127.0.0.1:${port}/permission?t=${encodeURIComponent(token)}`;
|
|
123
|
+
return {
|
|
124
|
+
hooks: {
|
|
125
|
+
PermissionRequest: [
|
|
126
|
+
{
|
|
127
|
+
matcher,
|
|
128
|
+
hooks: [{ type: "http", url, timeout: hookTimeoutSec }]
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 截断(detail 可能很长;给手机看的,不需要全文)。 */
|
|
136
|
+
function truncate(text, max) {
|
|
137
|
+
const s = String(text ?? "");
|
|
138
|
+
if (s.length <= max) return { text: s, truncated: false };
|
|
139
|
+
return { text: s.slice(0, max), truncated: true };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 把钩子请求体归一化。**未知形状不猜**:返回 `{ok:false}`,由调用方按拒绝处理。
|
|
144
|
+
*
|
|
145
|
+
* @param {{body:object, requestId:string, now:number, ttlMs:number, maxDetailChars:number}} input
|
|
146
|
+
*/
|
|
147
|
+
export function normalizePermissionRequest({ body, requestId, now, ttlMs, maxDetailChars = 1024 }) {
|
|
148
|
+
if (!body || typeof body !== "object") return { ok: false, reason: "body 不是对象" };
|
|
149
|
+
if (body.hook_event_name !== "PermissionRequest") {
|
|
150
|
+
return { ok: false, reason: `未知钩子事件: ${String(body.hook_event_name || "").slice(0, 40)}` };
|
|
151
|
+
}
|
|
152
|
+
const toolName = String(body.tool_name || "").slice(0, 64);
|
|
153
|
+
if (!toolName) return { ok: false, reason: "缺少 tool_name" };
|
|
154
|
+
const input = body.tool_input && typeof body.tool_input === "object" ? body.tool_input : {};
|
|
155
|
+
|
|
156
|
+
// detail:Bash 取命令原文,文件类工具取路径(都截断;命令原文只给 decide(),不进日志)
|
|
157
|
+
let detailRaw = "";
|
|
158
|
+
if (typeof input.command === "string") detailRaw = input.command;
|
|
159
|
+
else if (typeof input.file_path === "string") detailRaw = input.file_path;
|
|
160
|
+
else if (typeof input.path === "string") detailRaw = input.path;
|
|
161
|
+
else if (typeof input.url === "string") detailRaw = input.url;
|
|
162
|
+
else if (typeof input.pattern === "string") detailRaw = input.pattern;
|
|
163
|
+
const detail = truncate(detailRaw, maxDetailChars);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
request: {
|
|
168
|
+
requestId,
|
|
169
|
+
sessionId: typeof body.session_id === "string" ? body.session_id : "",
|
|
170
|
+
cwd: typeof body.cwd === "string" ? body.cwd : "",
|
|
171
|
+
permissionMode: typeof body.permission_mode === "string" ? body.permission_mode : "",
|
|
172
|
+
toolName,
|
|
173
|
+
kind: toolKindOf(toolName),
|
|
174
|
+
title: `${toolName}: ${TOOL_TITLE[toolName] || "需要确认"}`,
|
|
175
|
+
detail: detail.text,
|
|
176
|
+
detailTruncated: detail.truncated,
|
|
177
|
+
options: permissionOptions(),
|
|
178
|
+
createdAt: now,
|
|
179
|
+
expiresAt: now + ttlMs
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 常量时间比较 token(避免按字节提前返回)。长度不同直接 false。 */
|
|
185
|
+
export function tokenMatches(expected, actual) {
|
|
186
|
+
const a = Buffer.from(String(expected ?? ""), "utf8");
|
|
187
|
+
const b = Buffer.from(String(actual ?? ""), "utf8");
|
|
188
|
+
if (a.length === 0 || a.length !== b.length) return false;
|
|
189
|
+
try {
|
|
190
|
+
return crypto.timingSafeEqual(a, b);
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 创建 PermissionBridge(**未 start 前不占端口、不写文件、默认不启用**)。
|
|
198
|
+
*
|
|
199
|
+
* @param {object} opts
|
|
200
|
+
* @param {number} [opts.port=0] 0 = 随机端口(生产默认;测试可注入固定端口)
|
|
201
|
+
* @param {string} [opts.host="127.0.0.1"] 只允许回环(广域绑定直接抛错)
|
|
202
|
+
* @param {number} [opts.ttlMs] 决策窗口,默认 90s
|
|
203
|
+
* @param {number} [opts.hookTimeoutSec] 写进 settings 的 timeout(秒),默认 120
|
|
204
|
+
* @param {(req:object)=>Promise<{decision:"allow"|"deny",message?:string}>} [opts.decide] 决策来源;缺省 fail-closed
|
|
205
|
+
* @param {(entry:object)=>void} [opts.log] 结构化日志回调(**不会收到命令原文**)
|
|
206
|
+
* @param {object} [opts.timers] {setTimeout, clearTimeout} 可注入(测试)
|
|
207
|
+
* @param {()=>number} [opts.now] 时钟可注入
|
|
208
|
+
* @param {object} [opts.fs] {mkdirSync, writeFileSync, rmSync, mkdtempSync} 可注入
|
|
209
|
+
* @param {string} [opts.settingsDir] 临时 settings 的父目录(默认 os.tmpdir())
|
|
210
|
+
* @param {string} [opts.token] URL token(默认随机 16B hex)
|
|
211
|
+
*/
|
|
212
|
+
export function createPermissionBridge({
|
|
213
|
+
port = 0,
|
|
214
|
+
host = "127.0.0.1",
|
|
215
|
+
ttlMs = DEFAULT_DECISION_TTL_MS,
|
|
216
|
+
hookTimeoutSec = DEFAULT_HOOK_TIMEOUT_SEC,
|
|
217
|
+
decide,
|
|
218
|
+
log,
|
|
219
|
+
timers = { setTimeout, clearTimeout },
|
|
220
|
+
now = () => Date.now(),
|
|
221
|
+
fs = {
|
|
222
|
+
mkdirSync: fsSync.mkdirSync,
|
|
223
|
+
mkdtempSync: fsSync.mkdtempSync,
|
|
224
|
+
writeFileSync: fsSync.writeFileSync,
|
|
225
|
+
rmSync: fsSync.rmSync
|
|
226
|
+
},
|
|
227
|
+
settingsDir,
|
|
228
|
+
token = crypto.randomBytes(16).toString("hex"),
|
|
229
|
+
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
|
|
230
|
+
maxPending = DEFAULT_MAX_PENDING,
|
|
231
|
+
maxDetailChars = 1024
|
|
232
|
+
} = {}) {
|
|
233
|
+
if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") {
|
|
234
|
+
throw new Error(`PermissionBridge 只允许绑回环地址,收到 host=${host}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const pending = new Map(); // requestId → {timer, settle}
|
|
238
|
+
let server = null;
|
|
239
|
+
let dir = "";
|
|
240
|
+
let settingsPath = "";
|
|
241
|
+
let started = false;
|
|
242
|
+
|
|
243
|
+
const logger = typeof log === "function" ? log : () => {};
|
|
244
|
+
|
|
245
|
+
/** 结构化日志:只记元数据,**绝不带 detail/命令原文**。 */
|
|
246
|
+
function logEntry(entry) {
|
|
247
|
+
try {
|
|
248
|
+
logger(entry);
|
|
249
|
+
} catch {
|
|
250
|
+
/* 日志失败不得影响审批(沿用仓库 logger 约定) */
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 结算所有 pending:**一律 deny**(停止/关闭时绝不能"挂着",更不能放行)。
|
|
256
|
+
* 先把 HTTP 响应发完再关端口,避免 client 侧等到超时。
|
|
257
|
+
*/
|
|
258
|
+
function cleanupPending() {
|
|
259
|
+
for (const [, p] of pending) {
|
|
260
|
+
try {
|
|
261
|
+
p.finish("deny", "审批通道已关闭,已按拒绝处理", "stopped");
|
|
262
|
+
} catch {
|
|
263
|
+
/* finish 内部幂等,异常不影响后续清理 */
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
timers.clearTimeout(p.timer);
|
|
267
|
+
} catch {
|
|
268
|
+
/* ignore */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
pending.clear();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function writeSettings() {
|
|
275
|
+
const parent = settingsDir || os.tmpdir();
|
|
276
|
+
dir = fs.mkdtempSync(path.join(parent, "dsh-agent-perm-"));
|
|
277
|
+
settingsPath = path.join(dir, "settings.json");
|
|
278
|
+
const json = buildPermissionSettings({ port: server.address().port, token, hookTimeoutSec });
|
|
279
|
+
fs.writeFileSync(settingsPath, JSON.stringify(json, null, 2) + "\n", { mode: 0o600 });
|
|
280
|
+
return json;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function removeSettings() {
|
|
284
|
+
if (!dir) return;
|
|
285
|
+
try {
|
|
286
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
287
|
+
} catch {
|
|
288
|
+
/* 清理失败不阻断(残留目录在 tmp 下) */
|
|
289
|
+
}
|
|
290
|
+
dir = "";
|
|
291
|
+
settingsPath = "";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function sendJson(res, status, body) {
|
|
295
|
+
const text = JSON.stringify(body);
|
|
296
|
+
res.writeHead(status, {
|
|
297
|
+
"content-type": "application/json",
|
|
298
|
+
"content-length": Buffer.byteLength(text),
|
|
299
|
+
"cache-control": "no-store"
|
|
300
|
+
});
|
|
301
|
+
res.end(text);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 等一个决策:decide() 与 TTL 计时器赛跑。
|
|
306
|
+
* 任何一个先到都 settle 一次(幂等);TTL 先到 → deny。
|
|
307
|
+
*/
|
|
308
|
+
function awaitDecision(request) {
|
|
309
|
+
return new Promise((resolve) => {
|
|
310
|
+
let settled = false;
|
|
311
|
+
const finish = (decision, message, outcome) => {
|
|
312
|
+
if (settled) return;
|
|
313
|
+
settled = true;
|
|
314
|
+
const p = pending.get(request.requestId);
|
|
315
|
+
if (p) {
|
|
316
|
+
try {
|
|
317
|
+
timers.clearTimeout(p.timer);
|
|
318
|
+
} catch {
|
|
319
|
+
/* ignore */
|
|
320
|
+
}
|
|
321
|
+
pending.delete(request.requestId);
|
|
322
|
+
}
|
|
323
|
+
logEntry({
|
|
324
|
+
level: outcome === "allow" ? "info" : "warn",
|
|
325
|
+
event: "permission.decision",
|
|
326
|
+
requestId: request.requestId,
|
|
327
|
+
toolName: request.toolName,
|
|
328
|
+
kind: request.kind,
|
|
329
|
+
outcome,
|
|
330
|
+
durationMs: now() - request.createdAt
|
|
331
|
+
});
|
|
332
|
+
resolve({ decision, message });
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const timer = timers.setTimeout(() => finish("deny", DEFAULT_DENY_MESSAGE, "expired"), ttlMs);
|
|
336
|
+
pending.set(request.requestId, { timer, finish });
|
|
337
|
+
|
|
338
|
+
logEntry({
|
|
339
|
+
level: "info",
|
|
340
|
+
event: "permission.request",
|
|
341
|
+
requestId: request.requestId,
|
|
342
|
+
toolName: request.toolName,
|
|
343
|
+
kind: request.kind,
|
|
344
|
+
detailTruncated: request.detailTruncated,
|
|
345
|
+
expiresAt: request.expiresAt
|
|
346
|
+
// 刻意不含 detail:命令原文不进日志
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
let result;
|
|
350
|
+
try {
|
|
351
|
+
result = typeof decide === "function" ? decide(request) : null;
|
|
352
|
+
} catch (err) {
|
|
353
|
+
finish("deny", `审批处理器异常: ${err?.message || err}`, "error");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
Promise.resolve(result)
|
|
357
|
+
.then((d) => {
|
|
358
|
+
if (!d || typeof d !== "object") return finish("deny", "审批处理器未返回决策", "error");
|
|
359
|
+
if (d.cancelled) return finish("deny", "审批已取消", "cancelled");
|
|
360
|
+
const decision = d.decision === "allow" ? "allow" : "deny";
|
|
361
|
+
finish(decision, d.message, decision);
|
|
362
|
+
})
|
|
363
|
+
.catch((err) => finish("deny", `审批处理器异常: ${err?.message || err}`, "error"));
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function handle(req, res) {
|
|
368
|
+
const requestId = crypto.randomUUID();
|
|
369
|
+
const startedAt = now();
|
|
370
|
+
let url;
|
|
371
|
+
try {
|
|
372
|
+
url = new URL(req.url, `http://${host}`);
|
|
373
|
+
} catch {
|
|
374
|
+
sendJson(res, 400, { error: "bad-url" });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
379
|
+
sendJson(res, 200, { ok: true, pending: pending.size, started });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (req.method !== "POST" || url.pathname !== "/permission") {
|
|
383
|
+
sendJson(res, 404, { error: "not-found" });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// ① token(t 参数):不对就 403。**不返回决策体**,让上游按无决策失败关闭。
|
|
387
|
+
if (!tokenMatches(token, url.searchParams.get("t") || "")) {
|
|
388
|
+
logEntry({ level: "warn", event: "permission.reject", reason: "bad-token", requestId });
|
|
389
|
+
sendJson(res, 403, { error: "forbidden" });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (pending.size >= maxPending) {
|
|
393
|
+
logEntry({ level: "warn", event: "permission.reject", reason: "too-many-pending", requestId });
|
|
394
|
+
sendJson(res, 429, { error: "too-many-pending" });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ② 读 body(限流;超限直接 413)
|
|
399
|
+
const chunks = [];
|
|
400
|
+
let size = 0;
|
|
401
|
+
let tooBig = false;
|
|
402
|
+
for await (const chunk of req) {
|
|
403
|
+
size += chunk.length;
|
|
404
|
+
if (size > maxBodyBytes) {
|
|
405
|
+
tooBig = true;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
chunks.push(chunk);
|
|
409
|
+
}
|
|
410
|
+
if (tooBig) {
|
|
411
|
+
logEntry({ level: "warn", event: "permission.reject", reason: "body-too-large", requestId });
|
|
412
|
+
sendJson(res, 413, { error: "body-too-large" });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let body;
|
|
417
|
+
try {
|
|
418
|
+
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
419
|
+
} catch {
|
|
420
|
+
logEntry({ level: "warn", event: "permission.reject", reason: "invalid-json", requestId });
|
|
421
|
+
sendJson(res, 400, { error: "invalid-json" });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ③ 归一化;形状不认识就 400(上游无决策 → 拒绝,符合 fail-closed)
|
|
426
|
+
const normalized = normalizePermissionRequest({ body, requestId, now: now(), ttlMs, maxDetailChars });
|
|
427
|
+
if (!normalized.ok) {
|
|
428
|
+
logEntry({ level: "warn", event: "permission.reject", reason: "unknown-shape", requestId, detail: normalized.reason });
|
|
429
|
+
sendJson(res, 400, { error: "unknown-shape" });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ④ 客户端提前断开:不再等待(记一条,决策丢弃;**绝不因此放行**)
|
|
434
|
+
const onAborted = () => {
|
|
435
|
+
const p = pending.get(requestId);
|
|
436
|
+
if (p) {
|
|
437
|
+
try {
|
|
438
|
+
timers.clearTimeout(p.timer);
|
|
439
|
+
} catch {
|
|
440
|
+
/* ignore */
|
|
441
|
+
}
|
|
442
|
+
pending.delete(requestId);
|
|
443
|
+
}
|
|
444
|
+
logEntry({ level: "warn", event: "permission.aborted", requestId, durationMs: now() - startedAt });
|
|
445
|
+
};
|
|
446
|
+
req.on("aborted", onAborted);
|
|
447
|
+
|
|
448
|
+
// ⑤ 等决策 → 回写实测形状
|
|
449
|
+
const { decision, message } = await awaitDecision(normalized.request);
|
|
450
|
+
req.off("aborted", onAborted);
|
|
451
|
+
sendJson(res, 200, buildDecisionResponse(decision, message));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
/** 启动:占端口 → 写临时 settings。失败抛错(由上层转成"不可用",不静默)。 */
|
|
456
|
+
async start() {
|
|
457
|
+
if (started) throw new Error("PermissionBridge 已经启动");
|
|
458
|
+
server = http.createServer((req, res) => {
|
|
459
|
+
// client 提前断开时写响应会 emit error;吞掉,别让未处理 error 带走进程
|
|
460
|
+
res.on("error", () => {});
|
|
461
|
+
handle(req, res).catch((err) => {
|
|
462
|
+
// 兜底:任何未预期异常都不许把进程带崩,也不返回 allow
|
|
463
|
+
logEntry({ level: "error", event: "permission.internal-error", message: String(err?.message || err).slice(0, 200) });
|
|
464
|
+
try {
|
|
465
|
+
if (!res.headersSent) sendJson(res, 500, { error: "internal" });
|
|
466
|
+
else res.end();
|
|
467
|
+
} catch {
|
|
468
|
+
/* client gone */
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
server.keepAliveTimeout = 1000;
|
|
473
|
+
await new Promise((resolve, reject) => {
|
|
474
|
+
server.once("error", reject);
|
|
475
|
+
server.listen(port, host, resolve);
|
|
476
|
+
});
|
|
477
|
+
started = true;
|
|
478
|
+
const settings = writeSettings();
|
|
479
|
+
logEntry({
|
|
480
|
+
level: "info",
|
|
481
|
+
event: "permission-bridge.started",
|
|
482
|
+
port: server.address().port,
|
|
483
|
+
ttlMs,
|
|
484
|
+
hookTimeoutSec
|
|
485
|
+
});
|
|
486
|
+
return {
|
|
487
|
+
ok: true,
|
|
488
|
+
port: server.address().port,
|
|
489
|
+
host,
|
|
490
|
+
url: `http://${host}:${server.address().port}`,
|
|
491
|
+
settingsPath,
|
|
492
|
+
settings,
|
|
493
|
+
token,
|
|
494
|
+
ttlMs,
|
|
495
|
+
pendingCount: () => pending.size,
|
|
496
|
+
stop: () => this.stop()
|
|
497
|
+
};
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
/** 停止:关端口、清计时器、删临时 settings。**幂等**。 */
|
|
501
|
+
async stop() {
|
|
502
|
+
cleanupPending();
|
|
503
|
+
removeSettings();
|
|
504
|
+
if (server) {
|
|
505
|
+
const s = server;
|
|
506
|
+
server = null;
|
|
507
|
+
started = false;
|
|
508
|
+
await new Promise((resolve) => {
|
|
509
|
+
try {
|
|
510
|
+
s.close(() => resolve());
|
|
511
|
+
// keep-alive 连接不会自己走,主动断开(Node ≥18.2;老版本用可选链跳过)
|
|
512
|
+
s.closeAllConnections?.();
|
|
513
|
+
} catch {
|
|
514
|
+
resolve();
|
|
515
|
+
}
|
|
516
|
+
// 兜底:等 200ms 还不关就强制断开(避免测试/退出挂住)
|
|
517
|
+
setTimeout(resolve, 200).unref?.();
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
logEntry({ level: "info", event: "permission-bridge.stopped" });
|
|
521
|
+
return { ok: true };
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
/** 探针(测试/挂载自检用)。 */
|
|
525
|
+
status() {
|
|
526
|
+
return {
|
|
527
|
+
started,
|
|
528
|
+
port: server ? server.address()?.port || 0 : 0,
|
|
529
|
+
settingsPath,
|
|
530
|
+
pendingCount: pending.size
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
}
|