@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/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
+ }