@dnalec/dsh-auto-approve 0.1.0
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/LICENSE +21 -0
- package/README.md +152 -0
- package/README.zh.md +152 -0
- package/client.js +1312 -0
- package/cordis.patch.yml +5 -0
- package/locales.mjs +398 -0
- package/package.json +65 -0
- package/src/index.mjs +1143 -0
- package/src/preset-patch.mjs +164 -0
- package/src/provisioning.mjs +131 -0
- package/src/qqbot.mjs +313 -0
- package/src/rules.mjs +900 -0
- package/src/tickets.mjs +277 -0
- package/src/util.mjs +242 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-auto-approve — Host 半。
|
|
3
|
+
*
|
|
4
|
+
* 职责:在 `approval/request` 瀑布上做自动审批门控;人工请求同时走网页框和 QQ。
|
|
5
|
+
* 不注入 agent 会话;QQ 入站只进票据经纪。
|
|
6
|
+
*
|
|
7
|
+
* 管道(仅当会话预设为「自动审批」):
|
|
8
|
+
* 1. 关键词:拒绝 > 人工 > 允许(只匹配工具名 + command + 路径)
|
|
9
|
+
* 2. 审核表:模型只输出类别 id,程序按表执行允许 / 拒绝 / 人工
|
|
10
|
+
* 缺工具参数、解析失败、超时、插件异常 → 转人工,禁止在空卡片上自动放行。
|
|
11
|
+
*
|
|
12
|
+
* 预设沙箱 `presetSandbox` 是 auto-approve 的底线,不是管道步骤。
|
|
13
|
+
* `danger-full-access` 不因模式名短路。
|
|
14
|
+
*
|
|
15
|
+
* 命名导出 name / inject / apply。禁止 default export(Loader unwrapExports 会丢掉 inject)。
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
NAME,
|
|
19
|
+
pathsFor,
|
|
20
|
+
tryLoadJson,
|
|
21
|
+
saveJson,
|
|
22
|
+
saveSecretJson,
|
|
23
|
+
audit as appendAudit,
|
|
24
|
+
readEventsSince,
|
|
25
|
+
maxEventId,
|
|
26
|
+
appendEvent,
|
|
27
|
+
maskSecret,
|
|
28
|
+
ensureDir,
|
|
29
|
+
forkAbortSignal,
|
|
30
|
+
replaceRequestSignal,
|
|
31
|
+
} from './util.mjs'
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_DENY_KEYWORDS,
|
|
34
|
+
shippedRejectKeywords,
|
|
35
|
+
DEFAULT_CRITERIA,
|
|
36
|
+
shippedCriteria,
|
|
37
|
+
normalizeJudgePromptLang,
|
|
38
|
+
normalizeAllowlist,
|
|
39
|
+
mergePluginConfig,
|
|
40
|
+
parseReason,
|
|
41
|
+
matchKeywordBuckets,
|
|
42
|
+
buildJudgePrompt,
|
|
43
|
+
parseJudgeClassify,
|
|
44
|
+
pickToolArgs,
|
|
45
|
+
toolArgsTruncated,
|
|
46
|
+
clipToolArgsForEvent,
|
|
47
|
+
formatKeywordHay,
|
|
48
|
+
formatAllowKeywordHay,
|
|
49
|
+
formatJudgeCard,
|
|
50
|
+
hasToolPayload,
|
|
51
|
+
rememberCachedCall,
|
|
52
|
+
takeCachedCall,
|
|
53
|
+
lookupCriteria,
|
|
54
|
+
cloneAllowlist,
|
|
55
|
+
copyAllowlistInto,
|
|
56
|
+
mutateAllowlistOp,
|
|
57
|
+
effectiveJudgeTimeoutMs,
|
|
58
|
+
} from './rules.mjs'
|
|
59
|
+
import { getSetupState, migratePresetCopy, setAutoApproveSandbox } from './preset-patch.mjs'
|
|
60
|
+
import { createTicketBroker, parseApprovalReply, isBindConfirmText, formatPendingList, formatApprovalPush, formatApprovalKeyboard, formatOutcomeLabel } from './tickets.mjs'
|
|
61
|
+
import { createQQBot } from './qqbot.mjs'
|
|
62
|
+
import { startQqProvisioning } from './provisioning.mjs'
|
|
63
|
+
|
|
64
|
+
export const name = NAME
|
|
65
|
+
/** webServer 本身不用,只为等 web 起来再注册 RPC。 */
|
|
66
|
+
export const inject = ['approval', 'permissionPresets', 'llm', 'timer', 'webServer']
|
|
67
|
+
|
|
68
|
+
/** 设置页 rule-op:改草稿再写盘,失败不碰内存中的活对象。`other` 不能删。 */
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 插件入口。热更新规则文件;权限预设写入 profile patch 后需重启才进会话下拉。
|
|
72
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
73
|
+
* @param {object} [rawConfig]
|
|
74
|
+
*/
|
|
75
|
+
export function apply(ctx, rawConfig = {}) {
|
|
76
|
+
const paths = pathsFor()
|
|
77
|
+
ensureDir(paths.auto)
|
|
78
|
+
ensureDir(paths.bridge)
|
|
79
|
+
migratePresetCopy(paths.profilePatch)
|
|
80
|
+
|
|
81
|
+
// 损坏的 allowlist 只用内存默认,绝不写盘,避免把用户规则清掉。
|
|
82
|
+
const loadedAllowlist = tryLoadJson(paths.allowlist)
|
|
83
|
+
let allowlist
|
|
84
|
+
let allowlistCorrupt = false
|
|
85
|
+
if (!loadedAllowlist.ok) {
|
|
86
|
+
console.error(`[${NAME}] allowlist 无法读取,本进程用默认规则且不覆盖磁盘`, loadedAllowlist.error)
|
|
87
|
+
allowlist = normalizeAllowlist(null)
|
|
88
|
+
allowlistCorrupt = true
|
|
89
|
+
} else {
|
|
90
|
+
allowlist = normalizeAllowlist(loadedAllowlist.value)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const loadedPlugin = tryLoadJson(paths.pluginConfig)
|
|
94
|
+
let pluginCfgCorrupt = false
|
|
95
|
+
let pluginCfg
|
|
96
|
+
if (!loadedPlugin.ok) {
|
|
97
|
+
console.error(`[${NAME}] 插件配置无法读取,本进程用默认且不覆盖磁盘`, loadedPlugin.error)
|
|
98
|
+
pluginCfgCorrupt = true
|
|
99
|
+
pluginCfg = mergePluginConfig(rawConfig, null)
|
|
100
|
+
} else {
|
|
101
|
+
pluginCfg = mergePluginConfig(rawConfig, loadedPlugin.missing ? null : loadedPlugin.value)
|
|
102
|
+
}
|
|
103
|
+
if (loadedAllowlist.ok && loadedAllowlist.missing && Number(pluginCfg.judge.timeoutMs) > 0) {
|
|
104
|
+
allowlist.judgeTimeoutMs = Number(pluginCfg.judge.timeoutMs)
|
|
105
|
+
}
|
|
106
|
+
const diskVersion = (loadedAllowlist.ok && loadedAllowlist.value && typeof loadedAllowlist.value === 'object')
|
|
107
|
+
? (Number(loadedAllowlist.value.version) || 0)
|
|
108
|
+
: 0
|
|
109
|
+
if (loadedAllowlist.ok && (loadedAllowlist.missing || diskVersion < allowlist.version)) {
|
|
110
|
+
saveJson(paths.allowlist, allowlist)
|
|
111
|
+
}
|
|
112
|
+
const presetSetup = setAutoApproveSandbox(paths.profilePatch, pluginCfg.presetSandbox)
|
|
113
|
+
if (presetSetup.ok && presetSetup.needRestart) {
|
|
114
|
+
console.log(`[${NAME}] 已写入 auto-approve 权限预设(sandbox=${pluginCfg.presetSandbox});live patch 重载后会话权限会出现「自动审批」`)
|
|
115
|
+
} else if (!presetSetup.ok) {
|
|
116
|
+
console.error(`[${NAME}] 写入 auto-approve 预设失败`, presetSetup.error)
|
|
117
|
+
}
|
|
118
|
+
let eventSeq = maxEventId(paths.events)
|
|
119
|
+
const tickets = createTicketBroker()
|
|
120
|
+
let rpcTail = Promise.resolve()
|
|
121
|
+
function enqueueRpc(fn) {
|
|
122
|
+
const run = rpcTail.then(fn, fn)
|
|
123
|
+
rpcTail = run.then(() => undefined, () => undefined)
|
|
124
|
+
return run
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* tools/pre-execute 缓存的工具卡片叶子字段。
|
|
128
|
+
* DSH 的 approval/request 没有 command/path,必须提前记下。
|
|
129
|
+
* key 为 sessionId:callId,避免多会话共用 call-0 互相覆盖。
|
|
130
|
+
*/
|
|
131
|
+
const pendingCalls = new Map()
|
|
132
|
+
/** @type {{ chatId: string, userId: string, username?: string, isGroup: boolean, ts: string, preview: string }[]} */
|
|
133
|
+
const recentChats = []
|
|
134
|
+
let qq = null
|
|
135
|
+
let bindHintAt = 0
|
|
136
|
+
|
|
137
|
+
const log = (line) => console.log(`[${NAME}] ${line}`)
|
|
138
|
+
const audit = (line) => appendAudit(paths.audit, line)
|
|
139
|
+
|
|
140
|
+
function reloadAllowlist() {
|
|
141
|
+
const loaded = tryLoadJson(paths.allowlist)
|
|
142
|
+
if (!loaded.ok) {
|
|
143
|
+
allowlistCorrupt = true
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (loaded.missing) return
|
|
147
|
+
allowlist = normalizeAllowlist(loaded.value)
|
|
148
|
+
allowlistCorrupt = false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function reloadPluginCfg() {
|
|
152
|
+
const loaded = tryLoadJson(paths.pluginConfig)
|
|
153
|
+
if (!loaded.ok) {
|
|
154
|
+
pluginCfgCorrupt = true
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
pluginCfgCorrupt = false
|
|
158
|
+
pluginCfg = mergePluginConfig(rawConfig, loaded.missing ? null : loaded.value)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function persistPluginCfg(opts) {
|
|
162
|
+
if (pluginCfgCorrupt && !(opts && opts.overwriteCorrupt)) return false
|
|
163
|
+
if (!saveJson(paths.pluginConfig, pluginCfg)) {
|
|
164
|
+
reloadPluginCfg()
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
pluginCfgCorrupt = false
|
|
168
|
+
return true
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function applyRuleOp(op, kind, value) {
|
|
172
|
+
if (allowlistCorrupt && op !== 'reset') {
|
|
173
|
+
return { ok: false, error: '规则文件损坏,拒绝覆盖。请先「恢复默认」写回出厂规则,或修好磁盘上的 allowlist.json' }
|
|
174
|
+
}
|
|
175
|
+
const draft = cloneAllowlist(allowlist)
|
|
176
|
+
const result = mutateAllowlistOp(draft, op, kind, value)
|
|
177
|
+
if (!result.ok) return result
|
|
178
|
+
if (!saveJson(paths.allowlist, draft)) return { ok: false, error: '写入 allowlist 失败' }
|
|
179
|
+
copyAllowlistInto(allowlist, draft)
|
|
180
|
+
allowlistCorrupt = false
|
|
181
|
+
if (result.auditLine) audit(result.auditLine)
|
|
182
|
+
return result
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function clipJudgeForEvent(j) {
|
|
186
|
+
if (!j || typeof j !== 'object') return undefined
|
|
187
|
+
const out = {}
|
|
188
|
+
const put = (key, max, asBool) => {
|
|
189
|
+
if (j[key] === undefined || j[key] === null || j[key] === '') return
|
|
190
|
+
if (asBool) {
|
|
191
|
+
if (j[key]) out[key] = true
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
out[key] = String(j[key]).slice(0, max)
|
|
195
|
+
}
|
|
196
|
+
put('provider', 80)
|
|
197
|
+
put('model', 120)
|
|
198
|
+
put('effort', 40)
|
|
199
|
+
put('criterion', 40)
|
|
200
|
+
put('action', 20)
|
|
201
|
+
put('label', 80)
|
|
202
|
+
put('reason', 600)
|
|
203
|
+
put('raw', 800)
|
|
204
|
+
put('error', 400)
|
|
205
|
+
put('failed', 0, true)
|
|
206
|
+
put('timedOut', 0, true)
|
|
207
|
+
return Object.keys(out).length ? out : undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function recordEvent(sessionId, toolName, mode, reason, justification, verdict, opts) {
|
|
211
|
+
eventSeq += 1
|
|
212
|
+
const o = opts || {}
|
|
213
|
+
const ev = {
|
|
214
|
+
id: eventSeq,
|
|
215
|
+
ts: new Date().toISOString(),
|
|
216
|
+
sessionId: String(sessionId || ''),
|
|
217
|
+
tool: String(toolName || 'unknown'),
|
|
218
|
+
mode: String(mode || ''),
|
|
219
|
+
reason: String(reason || '').slice(0, 600),
|
|
220
|
+
justification: String(justification || '').slice(0, 400),
|
|
221
|
+
verdict: String(verdict || 'auto'),
|
|
222
|
+
}
|
|
223
|
+
if (o.kind) ev.kind = o.kind
|
|
224
|
+
if (o.category) ev.category = o.category
|
|
225
|
+
if (o.judgeReason) ev.judgeReason = String(o.judgeReason).slice(0, 600)
|
|
226
|
+
if (o.path) ev.path = o.path
|
|
227
|
+
if (o.ticket !== undefined) ev.ticket = o.ticket
|
|
228
|
+
if (o.source) ev.source = o.source
|
|
229
|
+
if (o.cwd) ev.cwd = String(o.cwd).slice(0, 400)
|
|
230
|
+
if (o.keyword) ev.keyword = String(o.keyword).slice(0, 120)
|
|
231
|
+
const args = clipToolArgsForEvent(o.args)
|
|
232
|
+
if (Object.keys(args).length) ev.args = args
|
|
233
|
+
const judge = clipJudgeForEvent(o.judge)
|
|
234
|
+
if (judge) ev.judge = judge
|
|
235
|
+
try {
|
|
236
|
+
appendEvent(paths.events, ev)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.error(`[${NAME}] 记录审批事件失败`, error)
|
|
239
|
+
}
|
|
240
|
+
return ev
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let qqbotCorrupt = false
|
|
244
|
+
function loadQqCreds() {
|
|
245
|
+
const loaded = tryLoadJson(paths.qqbot)
|
|
246
|
+
if (!loaded.ok) {
|
|
247
|
+
qqbotCorrupt = true
|
|
248
|
+
return { appId: '', appSecret: '', ownerUserOpenid: '' }
|
|
249
|
+
}
|
|
250
|
+
qqbotCorrupt = false
|
|
251
|
+
const data = loaded.value || {}
|
|
252
|
+
return {
|
|
253
|
+
appId: String(data.appId || ''),
|
|
254
|
+
appSecret: String(data.appSecret || ''),
|
|
255
|
+
ownerUserOpenid: String(data.ownerUserOpenid || ''),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function persistQqCreds(next) {
|
|
260
|
+
saveSecretJson(paths.qqbot, next)
|
|
261
|
+
qqbotCorrupt = false
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function rememberChat(msg) {
|
|
265
|
+
const preview = String(msg.text || '').slice(0, 80)
|
|
266
|
+
const ts = new Date().toISOString()
|
|
267
|
+
const idx = recentChats.findIndex((c) => c.chatId === msg.chatId && c.userId === msg.userId)
|
|
268
|
+
const row = {
|
|
269
|
+
chatId: msg.chatId,
|
|
270
|
+
userId: msg.userId,
|
|
271
|
+
username: msg.username,
|
|
272
|
+
isGroup: Boolean(msg.isGroup),
|
|
273
|
+
ts,
|
|
274
|
+
preview,
|
|
275
|
+
}
|
|
276
|
+
if (idx >= 0) recentChats.splice(idx, 1)
|
|
277
|
+
recentChats.unshift(row)
|
|
278
|
+
if (recentChats.length > 20) recentChats.length = 20
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function qqSend(chatId, text, extra) {
|
|
282
|
+
if (!qq || !chatId) return false
|
|
283
|
+
try {
|
|
284
|
+
await qq.send(chatId, text, extra)
|
|
285
|
+
return true
|
|
286
|
+
} catch (error) {
|
|
287
|
+
console.error(`[${NAME}] QQ 发送失败`, error)
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function inboundAllowed(msg) {
|
|
293
|
+
reloadPluginCfg()
|
|
294
|
+
const { chatId, userId } = pluginCfg.notify
|
|
295
|
+
if (!chatId) return { kind: 'unbound' }
|
|
296
|
+
if (msg.chatId !== chatId) return { kind: 'ignore' }
|
|
297
|
+
if (String(chatId).startsWith('g:')) {
|
|
298
|
+
if (!userId || msg.userId !== userId) return { kind: 'ignore' }
|
|
299
|
+
}
|
|
300
|
+
return { kind: 'ok' }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function handleInbound(msg) {
|
|
304
|
+
rememberChat(msg)
|
|
305
|
+
const gate = inboundAllowed(msg)
|
|
306
|
+
if (gate.kind === 'ignore') return
|
|
307
|
+
|
|
308
|
+
if (gate.kind === 'unbound') {
|
|
309
|
+
// 私人 bot:未绑定 chatId 时,任意 C2C「是」绑定该聊天。文档已标明。
|
|
310
|
+
if (msg.isGroup) return
|
|
311
|
+
const yes = isBindConfirmText(msg.text)
|
|
312
|
+
if (yes) {
|
|
313
|
+
pluginCfg.notify.chatId = msg.chatId
|
|
314
|
+
if (!persistPluginCfg()) {
|
|
315
|
+
await qqSend(msg.chatId, '绑定失败:无法写入配置文件。')
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
await qqSend(msg.chatId, '已将本聊天设为审批通知目标。之后人工审批会推到这里。')
|
|
319
|
+
audit(`QQBIND chatId=${msg.chatId}`)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
const now = Date.now()
|
|
323
|
+
if (now - bindHintAt > 60_000) {
|
|
324
|
+
bindHintAt = now
|
|
325
|
+
await qqSend(msg.chatId, '是否将本聊天用作审批通知?回复「是」确认。')
|
|
326
|
+
}
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const parsed = parseApprovalReply(msg.text, tickets.pendingCount())
|
|
331
|
+
if (!parsed) return
|
|
332
|
+
|
|
333
|
+
const reply = async (text) => { await qqSend(msg.chatId, text) }
|
|
334
|
+
|
|
335
|
+
if (parsed.kind === 'need-number') {
|
|
336
|
+
await reply('有多条待处理审批,请带短号。' + formatPendingList(tickets.listPending()))
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let n = parsed.n
|
|
341
|
+
if (parsed.kind === 'allow-bare' || parsed.kind === 'reject-bare') {
|
|
342
|
+
const sole = tickets.solePending()
|
|
343
|
+
if (!sole) {
|
|
344
|
+
await reply('当前没有待处理审批')
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
n = sole.n
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const recent = tickets.recentOutcome(n)
|
|
351
|
+
if (recent) {
|
|
352
|
+
await reply(`#${n} 已处理`)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
const ticket = tickets.get(n)
|
|
356
|
+
if (!ticket) {
|
|
357
|
+
await reply(`没有待处理的 #${n},${formatPendingList(tickets.listPending())}`)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const allow = parsed.kind === 'allow' || parsed.kind === 'allow-bare'
|
|
362
|
+
const ok = tickets.answer(n, {
|
|
363
|
+
outcome: allow ? 'allowed-once' : 'rejected',
|
|
364
|
+
})
|
|
365
|
+
if (!ok) await reply(`没有待处理的 #${n}`)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let qqGen = 0
|
|
369
|
+
/** @type {{ controller: AbortController, handle?: { cancel: () => void }, status: string, qrDataUrl: string, expiresAt: number, error: string } | null} */
|
|
370
|
+
let provisionAttempt = null
|
|
371
|
+
|
|
372
|
+
function provisionSnapshot() {
|
|
373
|
+
const a = provisionAttempt
|
|
374
|
+
if (!a) return { status: '', qrDataUrl: '', expiresAt: 0, error: '' }
|
|
375
|
+
return {
|
|
376
|
+
status: a.status || '',
|
|
377
|
+
qrDataUrl: a.qrDataUrl || '',
|
|
378
|
+
expiresAt: a.expiresAt || 0,
|
|
379
|
+
error: a.error || '',
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function cancelProvisioning() {
|
|
384
|
+
const attempt = provisionAttempt
|
|
385
|
+
provisionAttempt = null
|
|
386
|
+
if (!attempt) return
|
|
387
|
+
try { attempt.controller.abort() } catch { /* ignore */ }
|
|
388
|
+
try { await Promise.resolve(attempt.handle && attempt.handle.cancel()) } catch { /* ignore */ }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function startProvisioning() {
|
|
392
|
+
await cancelProvisioning()
|
|
393
|
+
const controller = new AbortController()
|
|
394
|
+
const attempt = {
|
|
395
|
+
controller,
|
|
396
|
+
status: '登录中',
|
|
397
|
+
qrDataUrl: '',
|
|
398
|
+
expiresAt: 0,
|
|
399
|
+
error: '',
|
|
400
|
+
}
|
|
401
|
+
provisionAttempt = attempt
|
|
402
|
+
try {
|
|
403
|
+
const handle = await startQqProvisioning({
|
|
404
|
+
onQr(qr) {
|
|
405
|
+
if (provisionAttempt !== attempt) return
|
|
406
|
+
attempt.qrDataUrl = qr.dataUrl
|
|
407
|
+
attempt.expiresAt = qr.expiresAt
|
|
408
|
+
attempt.status = '等待扫码'
|
|
409
|
+
},
|
|
410
|
+
onStatus(status) {
|
|
411
|
+
if (provisionAttempt === attempt) attempt.status = status
|
|
412
|
+
},
|
|
413
|
+
async onCredentials(credentials) {
|
|
414
|
+
if (provisionAttempt !== attempt) return
|
|
415
|
+
attempt.status = '保存凭据'
|
|
416
|
+
const appId = String(credentials.appId || '')
|
|
417
|
+
const appSecret = String(credentials.appSecret || '')
|
|
418
|
+
const owner = String(credentials.ownerUserOpenid || '')
|
|
419
|
+
const prev = loadQqCreds()
|
|
420
|
+
persistQqCreds({
|
|
421
|
+
appId,
|
|
422
|
+
appSecret,
|
|
423
|
+
ownerUserOpenid: owner || prev.ownerUserOpenid || '',
|
|
424
|
+
})
|
|
425
|
+
reloadPluginCfg()
|
|
426
|
+
if (owner && !pluginCfg.notify.chatId) {
|
|
427
|
+
pluginCfg.notify.chatId = owner
|
|
428
|
+
persistPluginCfg()
|
|
429
|
+
audit(`QQBIND chatId=${owner} (扫码者)`)
|
|
430
|
+
}
|
|
431
|
+
audit('CONFIG qqbot 扫码凭据已保存并重连')
|
|
432
|
+
await startQq()
|
|
433
|
+
if (provisionAttempt !== attempt) return
|
|
434
|
+
attempt.status = '已连接'
|
|
435
|
+
attempt.qrDataUrl = ''
|
|
436
|
+
attempt.expiresAt = 0
|
|
437
|
+
},
|
|
438
|
+
onFailure(error) {
|
|
439
|
+
if (provisionAttempt !== attempt) return
|
|
440
|
+
const aborted = controller.signal.aborted
|
|
441
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
442
|
+
log(`扫码失败: ${detail}`)
|
|
443
|
+
attempt.status = aborted ? '已取消' : '扫码失败'
|
|
444
|
+
attempt.error = aborted ? '' : '平台扫码服务暂时不可用或二维码已过期,请重新扫码。'
|
|
445
|
+
},
|
|
446
|
+
}, controller.signal)
|
|
447
|
+
if (provisionAttempt === attempt) attempt.handle = handle
|
|
448
|
+
return { ok: true }
|
|
449
|
+
} catch (error) {
|
|
450
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
451
|
+
log(`扫码启动失败: ${detail}`)
|
|
452
|
+
const missing = /未安装|ERR_MODULE_NOT_FOUND|Cannot find/i.test(detail)
|
|
453
|
+
const message = missing
|
|
454
|
+
? '未安装扫码依赖。请在插件目录执行 npm install 后重试。'
|
|
455
|
+
: '无法启动扫码流程,请检查网络后重试。'
|
|
456
|
+
if (provisionAttempt === attempt) {
|
|
457
|
+
attempt.status = '扫码启动失败'
|
|
458
|
+
attempt.error = message
|
|
459
|
+
}
|
|
460
|
+
return { ok: false, error: message }
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function startQq() {
|
|
465
|
+
const gen = ++qqGen
|
|
466
|
+
const prev = qq
|
|
467
|
+
qq = null
|
|
468
|
+
if (prev) {
|
|
469
|
+
try { await prev.stop() } catch (error) {
|
|
470
|
+
console.error(`[${NAME}] 停止旧 QQ 连接失败`, error)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (gen !== qqGen) return
|
|
474
|
+
const creds = loadQqCreds()
|
|
475
|
+
const bot = createQQBot(creds, log)
|
|
476
|
+
bot.setMessageHandler((m) => { void handleInbound(m).catch((e) => console.error(`[${NAME}] inbound`, e)) })
|
|
477
|
+
if (gen !== qqGen) {
|
|
478
|
+
try { await bot.stop() } catch { /* superseded */ }
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
qq = bot
|
|
482
|
+
if (creds.appId && creds.appSecret) void bot.start()
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
void startQq()
|
|
486
|
+
ctx.effect(() => () => {
|
|
487
|
+
qqGen += 1
|
|
488
|
+
tickets.dispose()
|
|
489
|
+
void cancelProvisioning()
|
|
490
|
+
void qq?.stop()
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
const llm = ctx.llm
|
|
494
|
+
const permissionPresets = ctx.permissionPresets
|
|
495
|
+
const agentDefaultModel = ctx.get('agentDefaultModel')
|
|
496
|
+
|
|
497
|
+
function fallbackSelection() {
|
|
498
|
+
try {
|
|
499
|
+
const sel = agentDefaultModel && typeof agentDefaultModel.currentSelection === 'function'
|
|
500
|
+
? agentDefaultModel.currentSelection()
|
|
501
|
+
: undefined
|
|
502
|
+
if (sel && sel.provider && sel.model) return { provider: sel.provider, model: sel.model }
|
|
503
|
+
} catch (error) {
|
|
504
|
+
console.error(`[${NAME}] agentDefaultModel.currentSelection() failed`, error)
|
|
505
|
+
}
|
|
506
|
+
return null
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function configuredRoute() {
|
|
510
|
+
reloadPluginCfg()
|
|
511
|
+
const fb = fallbackSelection()
|
|
512
|
+
const provider = String(pluginCfg.judge.provider || '').trim() || (fb && fb.provider) || ''
|
|
513
|
+
const model = String(pluginCfg.judge.model || '').trim() || (fb && fb.model) || ''
|
|
514
|
+
const reasoningEffort = String(pluginCfg.judge.reasoningEffort || '').trim()
|
|
515
|
+
return { provider, model, reasoningEffort }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function resolveJudgeRoute() {
|
|
519
|
+
const route = configuredRoute()
|
|
520
|
+
if (!route.provider || !route.model) {
|
|
521
|
+
return { ok: false, error: '未配置审核模型', ...route }
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
const info = await llm.resolveModelInfo(route.provider, route.model)
|
|
525
|
+
const efforts = (info && info.reasoning && Array.isArray(info.reasoning.efforts))
|
|
526
|
+
? info.reasoning.efforts.map((e) => e.id)
|
|
527
|
+
: []
|
|
528
|
+
if (route.reasoningEffort && efforts.length > 0 && !efforts.includes(route.reasoningEffort)) {
|
|
529
|
+
return { ok: false, error: `reasoningEffort ${route.reasoningEffort} 不受支持`, ...route }
|
|
530
|
+
}
|
|
531
|
+
return { ok: true, ...route, info }
|
|
532
|
+
} catch (error) {
|
|
533
|
+
return { ok: false, error: String((error && error.message) || error), ...route }
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* 调用审核模型。不要传 messages.system:newapi 会映射成 developer 角色导致 400。
|
|
539
|
+
* 分类提示全部折进 user 文本。
|
|
540
|
+
*/
|
|
541
|
+
async function callJudge(userText, signal, route, system) {
|
|
542
|
+
const prompt = system || buildJudgePrompt(allowlist.criteria, pluginCfg.judgePromptLang)
|
|
543
|
+
const opts = {
|
|
544
|
+
provider: route.provider,
|
|
545
|
+
model: route.model,
|
|
546
|
+
messages: [{
|
|
547
|
+
role: 'user',
|
|
548
|
+
content: [{ type: 'text', text: prompt + '\n\n' + userText }],
|
|
549
|
+
}],
|
|
550
|
+
temperature: 0,
|
|
551
|
+
maxTokens: 256,
|
|
552
|
+
signal,
|
|
553
|
+
}
|
|
554
|
+
if (route.reasoningEffort) opts.reasoningEffort = route.reasoningEffort
|
|
555
|
+
let text = ''
|
|
556
|
+
for await (const chunk of llm.stream(opts)) {
|
|
557
|
+
if (chunk.type === 'text-delta') text += chunk.text
|
|
558
|
+
else if (chunk.type === 'finish' && (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted')) {
|
|
559
|
+
const failure = chunk.reason.failure && chunk.reason.failure.message ? chunk.reason.failure.message : chunk.reason.kind
|
|
560
|
+
throw new Error('审核模型调用失败: ' + failure)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return text
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function judgeOnce(toolName, mode, justification, args, signal, route, cwd) {
|
|
567
|
+
const criteria = allowlist.criteria || DEFAULT_CRITERIA
|
|
568
|
+
const lang = normalizeJudgePromptLang(pluginCfg.judgePromptLang)
|
|
569
|
+
const user = formatJudgeCard(toolName, mode, justification, args, cwd, lang)
|
|
570
|
+
const text = await callJudge(user, signal, route, buildJudgePrompt(criteria, lang))
|
|
571
|
+
try {
|
|
572
|
+
return { ...parseJudgeClassify(text, criteria), raw: String(text || '').slice(0, 800) }
|
|
573
|
+
} catch (error) {
|
|
574
|
+
error.raw = String(text || '').slice(0, 800)
|
|
575
|
+
throw error
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function withRetry(runFn, label, timeoutMs) {
|
|
580
|
+
const runOnce = async () => {
|
|
581
|
+
const controller = new AbortController()
|
|
582
|
+
let cancelTimer
|
|
583
|
+
const timed = new Promise((resolve) => {
|
|
584
|
+
cancelTimer = ctx.timeout(() => resolve({ timedOut: true }), timeoutMs)
|
|
585
|
+
})
|
|
586
|
+
try {
|
|
587
|
+
const call = runFn(controller.signal)
|
|
588
|
+
.then((r) => ({ ...r, timedOut: false }))
|
|
589
|
+
.catch((error) => ({ judgeError: error }))
|
|
590
|
+
const result = await Promise.race([call, timed])
|
|
591
|
+
if (result.judgeError) throw result.judgeError
|
|
592
|
+
return result
|
|
593
|
+
} finally {
|
|
594
|
+
if (typeof cancelTimer === 'function') {
|
|
595
|
+
try { cancelTimer() } catch { /* disposer */ }
|
|
596
|
+
}
|
|
597
|
+
controller.abort(`${NAME}: ${label} 结束`)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
let last = { failed: true }
|
|
601
|
+
try {
|
|
602
|
+
const first = await runOnce()
|
|
603
|
+
if (!first.timedOut) return first
|
|
604
|
+
last = { failed: true, timedOut: true, error: `超时(${timeoutMs}ms)` }
|
|
605
|
+
console.warn(`[${NAME}] ${label} 超时(${timeoutMs}ms),转人工`)
|
|
606
|
+
return last
|
|
607
|
+
} catch (error) {
|
|
608
|
+
last = {
|
|
609
|
+
failed: true,
|
|
610
|
+
error: String((error && error.message) || error),
|
|
611
|
+
raw: error && error.raw ? String(error.raw).slice(0, 800) : '',
|
|
612
|
+
}
|
|
613
|
+
console.error(`[${NAME}] ${label} 异常,重试 1 次`, error)
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const second = await runOnce()
|
|
617
|
+
if (!second.timedOut) return second
|
|
618
|
+
last = { failed: true, timedOut: true, error: `重试超时(${timeoutMs}ms)` }
|
|
619
|
+
console.warn(`[${NAME}] ${label} 重试超时(${timeoutMs}ms)`)
|
|
620
|
+
return last
|
|
621
|
+
} catch (error) {
|
|
622
|
+
last = {
|
|
623
|
+
failed: true,
|
|
624
|
+
error: String((error && error.message) || error),
|
|
625
|
+
raw: error && error.raw ? String(error.raw).slice(0, 800) : last.raw,
|
|
626
|
+
}
|
|
627
|
+
console.error(`[${NAME}] ${label} 重试仍异常`, error)
|
|
628
|
+
return last
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function judgeOperation(toolName, mode, justification, args, cwd) {
|
|
633
|
+
const route = await resolveJudgeRoute()
|
|
634
|
+
const meta = {
|
|
635
|
+
provider: route.provider || '',
|
|
636
|
+
model: route.model || '',
|
|
637
|
+
effort: route.reasoningEffort || '',
|
|
638
|
+
}
|
|
639
|
+
if (!route.ok) {
|
|
640
|
+
audit(`FAILED judge route: ${route.error}`)
|
|
641
|
+
return { action: 'human', criterion: 'other', reason: '', failed: true, error: route.error, ...meta }
|
|
642
|
+
}
|
|
643
|
+
const timeoutMs = effectiveJudgeTimeoutMs(allowlist, pluginCfg)
|
|
644
|
+
const result = await withRetry(
|
|
645
|
+
(signal) => judgeOnce(toolName, mode, justification, args, signal, route, cwd),
|
|
646
|
+
'审核模型',
|
|
647
|
+
timeoutMs,
|
|
648
|
+
)
|
|
649
|
+
if (result.failed) {
|
|
650
|
+
return {
|
|
651
|
+
action: 'human',
|
|
652
|
+
criterion: 'other',
|
|
653
|
+
reason: '',
|
|
654
|
+
failed: true,
|
|
655
|
+
timedOut: Boolean(result.timedOut),
|
|
656
|
+
error: result.error || '审核失败',
|
|
657
|
+
raw: result.raw || '',
|
|
658
|
+
...meta,
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const row = lookupCriteria(allowlist.criteria, result.criterion)
|
|
662
|
+
return { ...result, action: row.action, label: row.label, ...meta }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function applyHumanOutcome(ctxInfo, outcome, extra) {
|
|
666
|
+
const { sessionId, toolName, mode, reason, justification, category, path, ticket, args, cwd, judgeReason, judge } = ctxInfo
|
|
667
|
+
const source = (extra && extra.source) || 'web'
|
|
668
|
+
audit(`OUTCOME ${toolName} outcome=${outcome} source=${source} ticket=${ticket || '-'} | ${reason.slice(0, 80)}`)
|
|
669
|
+
const detail = { category, path, ticket, source, args, cwd, judgeReason, judge }
|
|
670
|
+
|
|
671
|
+
if (outcome === 'allowed-once') {
|
|
672
|
+
recordEvent(sessionId, toolName, mode, reason, justification, 'manual-approved', {
|
|
673
|
+
kind: 'manual-approved', ...detail,
|
|
674
|
+
})
|
|
675
|
+
} else if (outcome === 'rejected') {
|
|
676
|
+
recordEvent(sessionId, toolName, mode, reason, justification, 'manual-rejected', {
|
|
677
|
+
kind: 'manual-rejected', ...detail,
|
|
678
|
+
})
|
|
679
|
+
} else {
|
|
680
|
+
recordEvent(sessionId, toolName, mode, reason, justification, String(outcome || 'cancelled'), {
|
|
681
|
+
kind: outcome === 'cancelled' ? 'manual-cancelled' : 'manual-unavailable',
|
|
682
|
+
...detail,
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* 网页 next() 与 QQ 票据竞速,谁先答谁赢。
|
|
689
|
+
* 120s 只是 QQ 提醒,不是全局超时;网页框一直等到有人答或会话取消。
|
|
690
|
+
*/
|
|
691
|
+
async function forwardToHuman(info, next, req) {
|
|
692
|
+
reloadPluginCfg()
|
|
693
|
+
const ticket = tickets.allocate(info)
|
|
694
|
+
recordEvent(info.sessionId, info.toolName, info.mode, info.reason, info.justification, 'manual-pending', {
|
|
695
|
+
kind: 'manual-pending',
|
|
696
|
+
category: info.category || '',
|
|
697
|
+
path: info.path,
|
|
698
|
+
ticket: ticket.n,
|
|
699
|
+
args: info.args,
|
|
700
|
+
cwd: info.cwd,
|
|
701
|
+
judgeReason: info.judgeReason,
|
|
702
|
+
judge: info.judge,
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
const notify = pluginCfg.notify
|
|
706
|
+
const timeoutSecs = Number(notify.timeoutSecs) > 0 ? Number(notify.timeoutSecs) : 120
|
|
707
|
+
let pushed = false
|
|
708
|
+
if (notify.enabled && qq && qq.connected() && notify.chatId) {
|
|
709
|
+
const ok = await qqSend(notify.chatId, formatApprovalPush(ticket, timeoutSecs), {
|
|
710
|
+
keyboard: formatApprovalKeyboard(
|
|
711
|
+
ticket.n,
|
|
712
|
+
String(notify.chatId).startsWith('g:') ? notify.userId : '',
|
|
713
|
+
),
|
|
714
|
+
})
|
|
715
|
+
if (ok) {
|
|
716
|
+
pushed = true
|
|
717
|
+
audit(`PUSH #${ticket.n} chat=${notify.chatId} ${info.toolName}`)
|
|
718
|
+
} else {
|
|
719
|
+
audit(`PUSH_FAIL #${ticket.n} ${info.toolName}`)
|
|
720
|
+
}
|
|
721
|
+
} else {
|
|
722
|
+
audit(`PUSH_SKIP #${ticket.n} enabled=${notify.enabled} connected=${Boolean(qq && qq.connected())} chat=${notify.chatId || ''} ${info.toolName}`)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const onAbort = () => {
|
|
726
|
+
const live = tickets.get(ticket.n)
|
|
727
|
+
if (!live || live.settled) return
|
|
728
|
+
tickets.discard(ticket.n, 'abort')
|
|
729
|
+
if (pushed) void qqSend(notify.chatId, `#${ticket.n} 已取消`)
|
|
730
|
+
}
|
|
731
|
+
const requestSignal = req.signal
|
|
732
|
+
// 网页框听这个 fork:QQ 先答时 abort 它即可关框,不能 abort 原始 req.signal(整单会 cancelled)。
|
|
733
|
+
const webSignal = forkAbortSignal(requestSignal)
|
|
734
|
+
replaceRequestSignal(req, webSignal.signal)
|
|
735
|
+
if (requestSignal) {
|
|
736
|
+
if (requestSignal.aborted) {
|
|
737
|
+
onAbort()
|
|
738
|
+
webSignal.abort()
|
|
739
|
+
await applyHumanOutcome({ ...info, ticket: ticket.n }, 'cancelled', { source: 'abort' })
|
|
740
|
+
return 'cancelled'
|
|
741
|
+
}
|
|
742
|
+
requestSignal.addEventListener('abort', onAbort, { once: true })
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
let timeoutHandle
|
|
746
|
+
if (pushed) {
|
|
747
|
+
timeoutHandle = ctx.timeout(() => {
|
|
748
|
+
if (ticket.settled) return
|
|
749
|
+
void qqSend(notify.chatId, `#${ticket.n} 请在网页继续`)
|
|
750
|
+
}, timeoutSecs * 1000)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
try {
|
|
754
|
+
const webP = Promise.resolve()
|
|
755
|
+
.then(() => next())
|
|
756
|
+
.then((outcome) => ({ source: 'web', outcome }))
|
|
757
|
+
.catch((error) => {
|
|
758
|
+
console.error(`[${NAME}] web next() 失败`, error)
|
|
759
|
+
return { source: 'web', outcome: 'unavailable' }
|
|
760
|
+
})
|
|
761
|
+
const imP = ticket.wait.then((result) => result)
|
|
762
|
+
|
|
763
|
+
const first = await Promise.race([webP, imP])
|
|
764
|
+
if (first.source === 'qq' && first.outcome) {
|
|
765
|
+
await applyHumanOutcome({ ...info, ticket: ticket.n }, first.outcome, {
|
|
766
|
+
source: 'qq',
|
|
767
|
+
})
|
|
768
|
+
if (pushed) {
|
|
769
|
+
void qqSend(notify.chatId, first.outcome === 'allowed-once'
|
|
770
|
+
? `#${ticket.n} 已批准`
|
|
771
|
+
: `#${ticket.n} 已拒绝`)
|
|
772
|
+
}
|
|
773
|
+
return first.outcome
|
|
774
|
+
}
|
|
775
|
+
if (first.source === 'web') {
|
|
776
|
+
tickets.discard(ticket.n, 'web')
|
|
777
|
+
await applyHumanOutcome({ ...info, ticket: ticket.n }, first.outcome, { source: 'web' })
|
|
778
|
+
if (pushed) void qqSend(notify.chatId, `#${ticket.n} 已在网页${formatOutcomeLabel(first.outcome)}`)
|
|
779
|
+
return first.outcome
|
|
780
|
+
}
|
|
781
|
+
const outcome = first.outcome || 'cancelled'
|
|
782
|
+
await applyHumanOutcome({ ...info, ticket: ticket.n }, outcome, { source: first.source || 'abort' })
|
|
783
|
+
return outcome
|
|
784
|
+
} finally {
|
|
785
|
+
webSignal.abort()
|
|
786
|
+
if (timeoutHandle && typeof timeoutHandle === 'function') {
|
|
787
|
+
try { timeoutHandle() } catch { /* disposer */ }
|
|
788
|
+
}
|
|
789
|
+
if (requestSignal) requestSignal.removeEventListener('abort', onAbort)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// 必须在工具体升级审批之前记下参数;用完在 approval/request 里 take 掉。
|
|
794
|
+
ctx.on('tools/pre-execute', (exec, next) => {
|
|
795
|
+
try {
|
|
796
|
+
const id = exec && exec.callId
|
|
797
|
+
if (id) {
|
|
798
|
+
const session = exec.agent && exec.agent.session
|
|
799
|
+
const sid = session && typeof session.id === 'string' ? session.id : ''
|
|
800
|
+
rememberCachedCall(pendingCalls, sid, id, pickToolArgs(exec.arguments))
|
|
801
|
+
}
|
|
802
|
+
} catch (error) {
|
|
803
|
+
console.error(`[${NAME}] 记录工具参数失败`, error)
|
|
804
|
+
}
|
|
805
|
+
return next()
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
ctx.on('approval/request', async (req, next) => {
|
|
809
|
+
let humanFallback = null
|
|
810
|
+
try {
|
|
811
|
+
// 非「自动审批」预设交给系统默认 ask,本插件不管。
|
|
812
|
+
reloadAllowlist()
|
|
813
|
+
reloadPluginCfg()
|
|
814
|
+
const session = req.agent && req.agent.session
|
|
815
|
+
if (!session) return next()
|
|
816
|
+
let preset
|
|
817
|
+
try {
|
|
818
|
+
preset = permissionPresets.current(session)
|
|
819
|
+
} catch (error) {
|
|
820
|
+
console.error(`[${NAME}] permissionPresets.current failed`, error)
|
|
821
|
+
return next()
|
|
822
|
+
}
|
|
823
|
+
if (pluginCfg.onlyAutoApprovePreset !== false && preset !== 'auto-approve') return next()
|
|
824
|
+
if (req.signal && req.signal.aborted) return next()
|
|
825
|
+
|
|
826
|
+
const toolName = String(req.toolName || 'unknown')
|
|
827
|
+
const reason = String(req.reason || '')
|
|
828
|
+
const { mode, justification } = parseReason(reason)
|
|
829
|
+
const sessionId = typeof session.id === 'string' ? session.id : ''
|
|
830
|
+
const sessionCwd = (typeof session.cwd === 'string' && session.cwd) ? session.cwd : ''
|
|
831
|
+
const cached = takeCachedCall(pendingCalls, sessionId, req.callId)
|
|
832
|
+
const toolArgs = pickToolArgs(cached.args || {})
|
|
833
|
+
const baseInfo = {
|
|
834
|
+
sessionId, toolName, mode, reason, justification, cwd: sessionCwd, callId: req.callId || '',
|
|
835
|
+
args: toolArgs,
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const toHuman = (path, category, extra) => forwardToHuman({
|
|
839
|
+
...baseInfo,
|
|
840
|
+
path,
|
|
841
|
+
category: category || '',
|
|
842
|
+
key: extra && extra.key,
|
|
843
|
+
confirmed: extra && extra.confirmed,
|
|
844
|
+
threshold: extra && extra.threshold,
|
|
845
|
+
judgeReason: extra && extra.judgeReason,
|
|
846
|
+
judge: extra && extra.judge,
|
|
847
|
+
}, next, req)
|
|
848
|
+
humanFallback = toHuman
|
|
849
|
+
// 没看见命令/路径就转人工,禁止关键词允许或模型标 safe。
|
|
850
|
+
if (!cached.found || !hasToolPayload(toolArgs)) {
|
|
851
|
+
const why = cached.found ? '工具参数不完整,转人工' : '未捕获工具参数,转人工'
|
|
852
|
+
audit(`HUMAN ${toolName} mode=${mode || 'none'} missing-payload | ${why}`)
|
|
853
|
+
return toHuman('missing-payload', 'other', { judgeReason: why })
|
|
854
|
+
}
|
|
855
|
+
const hay = formatKeywordHay(toolName, reason, toolArgs)
|
|
856
|
+
const kw = matchKeywordBuckets(hay, allowlist, formatAllowKeywordHay(toolArgs))
|
|
857
|
+
const eventDetail = { args: toolArgs, cwd: sessionCwd }
|
|
858
|
+
if (kw && kw.action === 'reject') {
|
|
859
|
+
audit(`REJECT ${toolName} mode=${mode || 'none'} keyword | ${reason.slice(0, 160)}`)
|
|
860
|
+
recordEvent(sessionId, toolName, mode, reason, justification, 'keyword-reject', {
|
|
861
|
+
kind: 'auto', path: 'keyword-reject', ...eventDetail,
|
|
862
|
+
})
|
|
863
|
+
return 'rejected'
|
|
864
|
+
}
|
|
865
|
+
if (toolArgsTruncated(toolArgs)) {
|
|
866
|
+
const why = '工具参数过长已截断,转人工(禁止按前缀自动放行)'
|
|
867
|
+
audit(`HUMAN ${toolName} mode=${mode || 'none'} truncated-payload | ${why}`)
|
|
868
|
+
return toHuman('truncated-payload', 'other', { judgeReason: why })
|
|
869
|
+
}
|
|
870
|
+
if (kw) {
|
|
871
|
+
if (kw.action === 'allow') {
|
|
872
|
+
audit(`ALLOW ${toolName} mode=${mode || 'none'} keyword | ${reason.slice(0, 160)}`)
|
|
873
|
+
recordEvent(sessionId, toolName, mode, reason, justification, 'keyword-allow', {
|
|
874
|
+
kind: 'auto', path: 'keyword-allow', ...eventDetail,
|
|
875
|
+
})
|
|
876
|
+
return 'allowed-once'
|
|
877
|
+
}
|
|
878
|
+
audit(`HUMAN ${toolName} mode=${mode || 'none'} keyword | ${reason.slice(0, 160)}`)
|
|
879
|
+
return toHuman('keyword-human', '')
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const judged = await judgeOperation(toolName, mode, justification, toolArgs, sessionCwd)
|
|
883
|
+
const criterion = judged.criterion || 'other'
|
|
884
|
+
const judgeReason = judged.reason || ''
|
|
885
|
+
if (judged.failed) {
|
|
886
|
+
audit(`FAILED ${toolName} mode=${mode || 'none'} → 人工 | ${judged.error || reason.slice(0, 120)}`)
|
|
887
|
+
return toHuman('judge-failed', criterion, {
|
|
888
|
+
judgeReason: judged.error || judgeReason,
|
|
889
|
+
judge: judged,
|
|
890
|
+
})
|
|
891
|
+
}
|
|
892
|
+
if (judged.action === 'reject') {
|
|
893
|
+
audit(`REJECT ${toolName} mode=${mode || 'none'} criteria=${criterion} | ${judgeReason || reason.slice(0, 120)}`)
|
|
894
|
+
recordEvent(sessionId, toolName, mode, reason, justification, 'criteria-reject', {
|
|
895
|
+
kind: 'auto', category: criterion, path: 'criteria-reject', judgeReason, judge: judged, ...eventDetail,
|
|
896
|
+
})
|
|
897
|
+
return 'rejected'
|
|
898
|
+
}
|
|
899
|
+
if (judged.action === 'allow') {
|
|
900
|
+
audit(`ALLOW ${toolName} mode=${mode || 'none'} criteria=${criterion} | ${judgeReason || reason.slice(0, 120)}`)
|
|
901
|
+
recordEvent(sessionId, toolName, mode, reason, justification, 'criteria-allow', {
|
|
902
|
+
kind: 'auto', category: criterion, path: 'criteria-allow', judgeReason, judge: judged, ...eventDetail,
|
|
903
|
+
})
|
|
904
|
+
return 'allowed-once'
|
|
905
|
+
}
|
|
906
|
+
audit(`HUMAN ${toolName} mode=${mode || 'none'} criteria=${criterion} | ${judgeReason || reason.slice(0, 120)}`)
|
|
907
|
+
return toHuman('criteria-human', criterion, { judgeReason, judge: judged })
|
|
908
|
+
} catch (error) {
|
|
909
|
+
console.error(`[${NAME}] 判断过程出错,回退人工`, error)
|
|
910
|
+
if (humanFallback) {
|
|
911
|
+
try {
|
|
912
|
+
return await humanFallback('plugin-error', 'other', {
|
|
913
|
+
judgeReason: String((error && error.message) || error),
|
|
914
|
+
})
|
|
915
|
+
} catch (again) {
|
|
916
|
+
console.error(`[${NAME}] 转人工仍失败,交回系统默认`, again)
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return next()
|
|
920
|
+
}
|
|
921
|
+
}, { prepend: true })
|
|
922
|
+
|
|
923
|
+
// ---- 鉴权 RPC:挂在已有 /api 通道上。不要用 rpc.handle 开独立前缀——
|
|
924
|
+
// handle() 在 connection 自己的 ctx 上访问 webServer,而 connection 只注入
|
|
925
|
+
// credentials,必炸 "webServer without inject",设置页 POST 落到 SPA → 405。
|
|
926
|
+
ctx.inject(['connection'], (c) => {
|
|
927
|
+
const connection = c.connection
|
|
928
|
+
if (!connection || !connection.fetch || typeof connection.fetch.register !== 'function') {
|
|
929
|
+
console.warn(`[${NAME}] connection.fetch 不可用,设置页/提示条 RPC 未注册(门控与 QQ 仍工作)`)
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function dispatch(endpoint, payload) {
|
|
934
|
+
try {
|
|
935
|
+
reloadAllowlist()
|
|
936
|
+
reloadPluginCfg()
|
|
937
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
938
|
+
if (endpoint === 'snapshot') {
|
|
939
|
+
const creds = loadQqCreds()
|
|
940
|
+
return {
|
|
941
|
+
ok: true,
|
|
942
|
+
value: {
|
|
943
|
+
config: {
|
|
944
|
+
version: allowlist.version || 17,
|
|
945
|
+
corrupt: allowlistCorrupt,
|
|
946
|
+
rejectKeywords: allowlist.rejectKeywords || [],
|
|
947
|
+
humanKeywords: allowlist.humanKeywords || [],
|
|
948
|
+
allowKeywords: allowlist.allowKeywords || [],
|
|
949
|
+
denyKeywords: allowlist.humanKeywords || [],
|
|
950
|
+
criteria: allowlist.criteria || [],
|
|
951
|
+
judgeTimeoutMs: allowlist.judgeTimeoutMs || 20000,
|
|
952
|
+
},
|
|
953
|
+
predefined: {
|
|
954
|
+
denyKeywords: DEFAULT_DENY_KEYWORDS,
|
|
955
|
+
rejectKeywords: shippedRejectKeywords(),
|
|
956
|
+
humanKeywords: [],
|
|
957
|
+
criteria: shippedCriteria(pluginCfg.judgePromptLang),
|
|
958
|
+
},
|
|
959
|
+
setup: getSetupState(paths.profilePatch),
|
|
960
|
+
plugin: pluginCfg,
|
|
961
|
+
pluginCorrupt: pluginCfgCorrupt,
|
|
962
|
+
qq: {
|
|
963
|
+
status: qq ? qq.status() : '未启动',
|
|
964
|
+
connected: Boolean(qq && qq.connected()),
|
|
965
|
+
hasSecret: Boolean(creds.appId && creds.appSecret),
|
|
966
|
+
credsCorrupt: qqbotCorrupt,
|
|
967
|
+
appIdMasked: maskSecret(creds.appId),
|
|
968
|
+
recentChats,
|
|
969
|
+
provisioning: provisionSnapshot(),
|
|
970
|
+
},
|
|
971
|
+
providers: (() => {
|
|
972
|
+
try {
|
|
973
|
+
return (llm.listProviders() || []).map((p) => ({ id: p.id, name: p.name || p.id }))
|
|
974
|
+
} catch { return [] }
|
|
975
|
+
})(),
|
|
976
|
+
fallback: fallbackSelection() || { provider: '', model: '' },
|
|
977
|
+
},
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (endpoint === 'events') {
|
|
981
|
+
const sessionId = String(body.sessionId || '')
|
|
982
|
+
if (!sessionId) {
|
|
983
|
+
return { ok: false, error: { code: 'events', message: '需要 sessionId', details: {} } }
|
|
984
|
+
}
|
|
985
|
+
const since = Number.parseInt(String(body.since || '0'), 10) || 0
|
|
986
|
+
return { ok: true, value: { events: readEventsSince(paths.events, sessionId, since) } }
|
|
987
|
+
}
|
|
988
|
+
if (endpoint === 'rule-op') {
|
|
989
|
+
let value = body.value
|
|
990
|
+
if (String(body.kind || '') === 'criteria' && String(body.op || '') === 'reset') {
|
|
991
|
+
const lang = normalizeJudgePromptLang(
|
|
992
|
+
(value && typeof value === 'object' && value.lang) || pluginCfg.judgePromptLang,
|
|
993
|
+
)
|
|
994
|
+
value = { lang }
|
|
995
|
+
}
|
|
996
|
+
const result = applyRuleOp(String(body.op || ''), String(body.kind || ''), value)
|
|
997
|
+
if (result.ok && String(body.kind || '') === 'judgeTimeoutMs') {
|
|
998
|
+
pluginCfg.judge.timeoutMs = allowlist.judgeTimeoutMs
|
|
999
|
+
persistPluginCfg()
|
|
1000
|
+
}
|
|
1001
|
+
return result.ok ? { ok: true, value: result } : { ok: false, error: { code: 'rule', message: result.error || '失败', details: {} } }
|
|
1002
|
+
}
|
|
1003
|
+
if (endpoint === 'setup') {
|
|
1004
|
+
return { ok: true, value: setAutoApproveSandbox(paths.profilePatch, pluginCfg.presetSandbox) }
|
|
1005
|
+
}
|
|
1006
|
+
if (endpoint === 'save-plugin') {
|
|
1007
|
+
if (pluginCfgCorrupt && !body.overwriteCorrupt) {
|
|
1008
|
+
return { ok: false, error: { code: 'save', message: '插件配置损坏,拒绝覆盖。请修好磁盘文件,或点「覆盖损坏配置」', details: {} } }
|
|
1009
|
+
}
|
|
1010
|
+
const next = mergePluginConfig(pluginCfg, body)
|
|
1011
|
+
const prev = pluginCfg
|
|
1012
|
+
pluginCfg = next
|
|
1013
|
+
if (!persistPluginCfg({ overwriteCorrupt: Boolean(body.overwriteCorrupt) })) {
|
|
1014
|
+
pluginCfg = prev
|
|
1015
|
+
return { ok: false, error: { code: 'save', message: '写入配置失败', details: {} } }
|
|
1016
|
+
}
|
|
1017
|
+
let preset = null
|
|
1018
|
+
if (body && Object.prototype.hasOwnProperty.call(body, 'presetSandbox')) {
|
|
1019
|
+
preset = setAutoApproveSandbox(paths.profilePatch, pluginCfg.presetSandbox)
|
|
1020
|
+
}
|
|
1021
|
+
if (typeof body.judgeTimeoutMs === 'number') {
|
|
1022
|
+
const timeoutResult = applyRuleOp('set', 'judgeTimeoutMs', body.judgeTimeoutMs)
|
|
1023
|
+
if (!timeoutResult.ok) {
|
|
1024
|
+
return { ok: false, error: { code: 'rule', message: timeoutResult.error || '写入超时失败', details: {} } }
|
|
1025
|
+
}
|
|
1026
|
+
pluginCfg.judge.timeoutMs = allowlist.judgeTimeoutMs
|
|
1027
|
+
persistPluginCfg()
|
|
1028
|
+
}
|
|
1029
|
+
audit('CONFIG plugin 已更新')
|
|
1030
|
+
return { ok: true, value: { ok: true, plugin: pluginCfg, preset, setup: getSetupState(paths.profilePatch) } }
|
|
1031
|
+
}
|
|
1032
|
+
if (endpoint === 'save-qq-creds') {
|
|
1033
|
+
await cancelProvisioning()
|
|
1034
|
+
const appId = String(body.appId || '').trim()
|
|
1035
|
+
const appSecret = String(body.appSecret || '').trim()
|
|
1036
|
+
const prev = loadQqCreds()
|
|
1037
|
+
const next = {
|
|
1038
|
+
appId: appId || prev.appId,
|
|
1039
|
+
appSecret: appSecret || prev.appSecret,
|
|
1040
|
+
ownerUserOpenid: prev.ownerUserOpenid || '',
|
|
1041
|
+
}
|
|
1042
|
+
persistQqCreds(next)
|
|
1043
|
+
await startQq()
|
|
1044
|
+
audit('CONFIG qqbot 凭据已更新并重连')
|
|
1045
|
+
return { ok: true, value: { ok: true, status: qq ? qq.status() : '未启动' } }
|
|
1046
|
+
}
|
|
1047
|
+
if (endpoint === 'qq-provision') {
|
|
1048
|
+
const result = await startProvisioning()
|
|
1049
|
+
return result.ok
|
|
1050
|
+
? { ok: true, value: { ok: true, status: provisionAttempt ? provisionAttempt.status : '' } }
|
|
1051
|
+
: { ok: false, error: { code: 'provision', message: result.error || '扫码启动失败', details: {} } }
|
|
1052
|
+
}
|
|
1053
|
+
if (endpoint === 'qq-cancel-provision') {
|
|
1054
|
+
await cancelProvisioning()
|
|
1055
|
+
return { ok: true, value: { ok: true } }
|
|
1056
|
+
}
|
|
1057
|
+
if (endpoint === 'set-chat') {
|
|
1058
|
+
pluginCfg.notify.chatId = String(body.chatId || '')
|
|
1059
|
+
pluginCfg.notify.userId = String(body.userId || '')
|
|
1060
|
+
if (!persistPluginCfg()) {
|
|
1061
|
+
return { ok: false, error: { code: 'save', message: '写入配置失败', details: {} } }
|
|
1062
|
+
}
|
|
1063
|
+
audit(`CONFIG notify chatId=${pluginCfg.notify.chatId}`)
|
|
1064
|
+
return { ok: true, value: { ok: true, notify: pluginCfg.notify } }
|
|
1065
|
+
}
|
|
1066
|
+
if (endpoint === 'reconnect-qq') {
|
|
1067
|
+
await startQq()
|
|
1068
|
+
return { ok: true, value: { ok: true, status: qq ? qq.status() : '未启动' } }
|
|
1069
|
+
}
|
|
1070
|
+
if (endpoint === 'judge-catalog') {
|
|
1071
|
+
const provider = String(body.provider || configuredRoute().provider)
|
|
1072
|
+
let models = []
|
|
1073
|
+
try {
|
|
1074
|
+
models = (await llm.listModels(provider)).map((m) => ({ id: m.id, name: m.name || m.id }))
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
return { ok: false, error: { code: 'catalog', message: String((error && error.message) || error), details: {} } }
|
|
1077
|
+
}
|
|
1078
|
+
return { ok: true, value: { provider, models } }
|
|
1079
|
+
}
|
|
1080
|
+
if (endpoint === 'judge-info') {
|
|
1081
|
+
const provider = String(body.provider || '')
|
|
1082
|
+
const model = String(body.model || '')
|
|
1083
|
+
try {
|
|
1084
|
+
const info = await llm.resolveModelInfo(provider, model)
|
|
1085
|
+
const efforts = (info.reasoning && info.reasoning.efforts) || []
|
|
1086
|
+
return {
|
|
1087
|
+
ok: true,
|
|
1088
|
+
value: {
|
|
1089
|
+
provider: info.provider,
|
|
1090
|
+
id: info.id,
|
|
1091
|
+
name: info.name,
|
|
1092
|
+
efforts: efforts.map((e) => ({ id: e.id, name: e.name || e.id })),
|
|
1093
|
+
defaultEffort: info.reasoning && info.reasoning.defaultEffort,
|
|
1094
|
+
},
|
|
1095
|
+
}
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
return { ok: false, error: { code: 'info', message: String((error && error.message) || error), details: {} } }
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return { ok: false, error: { code: 'unknown', message: `unknown endpoint ${endpoint}`, details: {} } }
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
return { ok: false, error: { code: 'internal', message: String((error && error.message) || error), details: {} } }
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** 设置页 / 提示条 / 历史。仅 GUI;模型点不到这些按钮。 */
|
|
1107
|
+
async function serve(request) {
|
|
1108
|
+
let body
|
|
1109
|
+
try {
|
|
1110
|
+
body = await request.json()
|
|
1111
|
+
} catch {
|
|
1112
|
+
return new Response('body is not JSON', { status: 400 })
|
|
1113
|
+
}
|
|
1114
|
+
const rpcId = body && typeof body.rpcId === 'string' ? body.rpcId : 'invalid-request'
|
|
1115
|
+
const packed = body && body.payload && typeof body.payload === 'object' ? body.payload : {}
|
|
1116
|
+
const endpoint = String(packed.endpoint || '')
|
|
1117
|
+
const payload = packed.payload && typeof packed.payload === 'object' ? packed.payload : {}
|
|
1118
|
+
try {
|
|
1119
|
+
const result = await enqueueRpc(() => dispatch(endpoint, payload))
|
|
1120
|
+
return Response.json({ type: 'server-response', rpcId: rpcId, result: result })
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
return new Response('handler failure: ' + String(error), { status: 500 })
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
try {
|
|
1127
|
+
c.effect(
|
|
1128
|
+
() => connection.fetch.register({
|
|
1129
|
+
path: '/api/dsh-auto-approve',
|
|
1130
|
+
methods: ['POST'],
|
|
1131
|
+
requestBody: 'buffered',
|
|
1132
|
+
fetch: serve,
|
|
1133
|
+
}),
|
|
1134
|
+
`${NAME}: /api/dsh-auto-approve`,
|
|
1135
|
+
)
|
|
1136
|
+
log('RPC 已注册:/api/dsh-auto-approve')
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
console.error(`[${NAME}] RPC 注册失败,设置页将无法加载`, error)
|
|
1139
|
+
}
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
log(`已挂载:关键词→审核表 sandbox=${pluginCfg.presetSandbox};QQ=${qq ? qq.status() : '无'}`)
|
|
1143
|
+
}
|