@dnalec/dsh-auto-approve 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * dsh-auto-approve — Host 半。
3
3
  *
4
- * 职责:在 `approval/request` 瀑布上做自动审批门控;人工请求同时走网页框和 QQ。
5
- * 不注入 agent 会话;QQ 入站只进票据经纪。
6
- *
4
+ * 职责:在 `approval/request` 瀑布上做自动审批门控。
5
+ * 允许 / 拒绝直接返回 outcome;转人工则 `await next()` 交给原网页审批框。
6
+ * 不改 req,不 abort req.signal,不平行结算。
7
7
  * 管道(仅当会话预设为「自动审批」):
8
- * 1. 关键词:拒绝 > 人工 > 允许(只匹配工具名 + command + 路径)
8
+ * 1. 关键词:拒绝 > 人工 > 允许(只匹配工具名 + command + 路径 + workdir)
9
9
  * 2. 审核表:模型只输出类别 id,程序按表执行允许 / 拒绝 / 人工
10
10
  * 缺工具参数、解析失败、超时、插件异常 → 转人工,禁止在空卡片上自动放行。
11
11
  *
@@ -17,17 +17,14 @@
17
17
  import {
18
18
  NAME,
19
19
  pathsFor,
20
+ resolveProfilePatchPath,
20
21
  tryLoadJson,
21
22
  saveJson,
22
- saveSecretJson,
23
23
  audit as appendAudit,
24
24
  readEventsSince,
25
25
  maxEventId,
26
26
  appendEvent,
27
- maskSecret,
28
27
  ensureDir,
29
- forkAbortSignal,
30
- replaceRequestSignal,
31
28
  } from './util.mjs'
32
29
  import {
33
30
  DEFAULT_DENY_KEYWORDS,
@@ -37,15 +34,19 @@ import {
37
34
  normalizeJudgePromptLang,
38
35
  normalizeAllowlist,
39
36
  mergePluginConfig,
37
+ pickMigratablePluginConfig,
40
38
  parseReason,
41
39
  matchKeywordBuckets,
42
40
  buildJudgePrompt,
41
+ resolveJudgePromptTemplate,
42
+ shippedJudgePromptTemplate,
43
43
  parseJudgeClassify,
44
44
  pickToolArgs,
45
45
  toolArgsTruncated,
46
46
  clipToolArgsForEvent,
47
47
  formatKeywordHay,
48
48
  formatAllowKeywordHay,
49
+ formatPathKeywordHay,
49
50
  formatJudgeCard,
50
51
  hasToolPayload,
51
52
  rememberCachedCall,
@@ -54,18 +55,40 @@ import {
54
55
  cloneAllowlist,
55
56
  copyAllowlistInto,
56
57
  mutateAllowlistOp,
58
+ fail,
57
59
  effectiveJudgeTimeoutMs,
60
+ judgeMaxTokens,
58
61
  } 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'
62
+ import { dirname } from 'node:path'
63
+ import {
64
+ getSetupState,
65
+ migratePresetCopy,
66
+ presetDrift,
67
+ readBasePresetKeys,
68
+ setAutoApproveSandbox,
69
+ } from './preset-patch.mjs'
63
70
 
64
71
  export const name = NAME
65
- /** webServer 本身不用,只为等 web 起来再注册 RPC。 */
66
- export const inject = ['approval', 'permissionPresets', 'llm', 'timer', 'webServer']
72
+ /**
73
+ * 只列门控真正需要的服务。**不要**放 `webServer`:cordis 把插件 `inject` 当必需服务,
74
+ * 缺一个就停在 PENDING、apply 完全不执行(`vendor/cordis/src/fiber.ts`),
75
+ * 而 `webserver` 行只在 web-app bundle 里 —— headless / acp / sdk 组合下会连审批门控一起失踪。
76
+ * RPC 侧自己用 `ctx.inject(['connection'], …)`,`connection.fetch.register` 不需要 webServer。
77
+ */
78
+ export const inject = ['approval', 'permissionPresets', 'llm', 'timer']
79
+
80
+ /** Host Session 的工作目录在 header.cwd,没有 session.cwd。 */
81
+ export function readSessionCwd(session) {
82
+ const cwd = session && session.header && typeof session.header.cwd === 'string' ? session.header.cwd : ''
83
+ return cwd
84
+ }
67
85
 
68
- /** 设置页 rule-op:改草稿再写盘,失败不碰内存中的活对象。`other` 不能删。 */
86
+ /** Connection RPC 失败必须带 message,否则 client parseConnectionResponse 会 TypeError。 */
87
+ export function rpcFail(code, details) {
88
+ const c = String(code || 'err.internal')
89
+ const d = details && typeof details === 'object' && !Array.isArray(details) ? details : {}
90
+ return { ok: false, error: { code: c, message: c, details: d } }
91
+ }
69
92
 
70
93
  /**
71
94
  * 插件入口。热更新规则文件;权限预设写入 profile patch 后需重启才进会话下拉。
@@ -74,8 +97,9 @@ export const inject = ['approval', 'permissionPresets', 'llm', 'timer', 'webServ
74
97
  */
75
98
  export function apply(ctx, rawConfig = {}) {
76
99
  const paths = pathsFor()
100
+ // 真实 profile 目录从 ctx.baseUrl 推导;拿不到才回落到 profiles/web。
101
+ paths.profilePatch = resolveProfilePatchPath(ctx, rawConfig, paths.profilePatch)
77
102
  ensureDir(paths.auto)
78
- ensureDir(paths.bridge)
79
103
  migratePresetCopy(paths.profilePatch)
80
104
 
81
105
  // 损坏的 allowlist 只用内存默认,绝不写盘,避免把用户规则清掉。
@@ -93,12 +117,21 @@ export function apply(ctx, rawConfig = {}) {
93
117
  const loadedPlugin = tryLoadJson(paths.pluginConfig)
94
118
  let pluginCfgCorrupt = false
95
119
  let pluginCfg
120
+ let migratedPlugin = false
96
121
  if (!loadedPlugin.ok) {
97
122
  console.error(`[${NAME}] 插件配置无法读取,本进程用默认且不覆盖磁盘`, loadedPlugin.error)
98
123
  pluginCfgCorrupt = true
99
124
  pluginCfg = mergePluginConfig(rawConfig, null)
125
+ } else if (loadedPlugin.missing) {
126
+ const legacy = tryLoadJson(paths.legacyPluginConfig)
127
+ const picked = (legacy.ok && !legacy.missing) ? pickMigratablePluginConfig(legacy.value) : null
128
+ pluginCfg = mergePluginConfig(rawConfig, picked)
129
+ if (picked && saveJson(paths.pluginConfig, pluginCfg)) {
130
+ migratedPlugin = true
131
+ console.log(`[${NAME}] 已从 approval-bridge/config.json 迁移判定配置到 auto-approve/config.json`)
132
+ }
100
133
  } else {
101
- pluginCfg = mergePluginConfig(rawConfig, loadedPlugin.missing ? null : loadedPlugin.value)
134
+ pluginCfg = mergePluginConfig(rawConfig, loadedPlugin.value)
102
135
  }
103
136
  if (loadedAllowlist.ok && loadedAllowlist.missing && Number(pluginCfg.judge.timeoutMs) > 0) {
104
137
  allowlist.judgeTimeoutMs = Number(pluginCfg.judge.timeoutMs)
@@ -109,14 +142,34 @@ export function apply(ctx, rawConfig = {}) {
109
142
  if (loadedAllowlist.ok && (loadedAllowlist.missing || diskVersion < allowlist.version)) {
110
143
  saveJson(paths.allowlist, allowlist)
111
144
  }
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)
145
+ // 损坏配置不写沙箱。缺失配置若 patch 里已有 auto-approve,也不用默认 workspace-write 去加宽。
146
+ if (!pluginCfgCorrupt) {
147
+ const already = getSetupState(paths.profilePatch).configured
148
+ const writeSandbox = (loadedPlugin.ok && !loadedPlugin.missing) || migratedPlugin || !already
149
+ if (writeSandbox) {
150
+ const presetSetup = setAutoApproveSandbox(paths.profilePatch, pluginCfg.presetSandbox)
151
+ if (presetSetup.ok && presetSetup.needRestart) {
152
+ console.log(`[${NAME}] 已写入 auto-approve 权限预设(sandbox=${pluginCfg.presetSandbox});live patch 重载后会话权限会出现「自动审批」`)
153
+ } else if (!presetSetup.ok) {
154
+ console.error(
155
+ `[${NAME}] 写入 auto-approve 预设失败(${presetSetup.code || presetSetup.error || 'err.preset'}):${paths.profilePatch}`,
156
+ presetSetup.details || '',
157
+ )
158
+ }
159
+ }
160
+ }
161
+ {
162
+ // 出厂预设表比本 profile 那份新:只警告,不自动改写用户文件(patch 里 config 是整块替换)。
163
+ const drift = presetSetupState().drift
164
+ if (drift.baseKnown && drift.missing.length) {
165
+ console.warn(
166
+ `[${NAME}] DSH 出厂权限预设表多了 ${drift.missing.join(', ')},`
167
+ + `但 profile 的 permission 行是本插件写入的副本(patch 整块替换 config),不会自动包含;`
168
+ + `请更新插件或手工合并 ${paths.profilePatch}`,
169
+ )
170
+ }
117
171
  }
118
172
  let eventSeq = maxEventId(paths.events)
119
- const tickets = createTicketBroker()
120
173
  let rpcTail = Promise.resolve()
121
174
  function enqueueRpc(fn) {
122
175
  const run = rpcTail.then(fn, fn)
@@ -129,14 +182,24 @@ export function apply(ctx, rawConfig = {}) {
129
182
  * key 为 sessionId:callId,避免多会话共用 call-0 互相覆盖。
130
183
  */
131
184
  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
185
  const log = (line) => console.log(`[${NAME}] ${line}`)
138
186
  const audit = (line) => appendAudit(paths.audit, line)
139
187
 
188
+ /**
189
+ * setup 状态 + 预设表漂移。
190
+ * 插件写进 profile patch 的 `permission` 行会整块替换 base 的 config(patch 语义不做深合并),
191
+ * 所以 DSH 出厂表新增预设时本 profile 不会有:读 base 的 patch 比一比,只提示、不自动改写。
192
+ */
193
+ function presetSetupState() {
194
+ const setup = getSetupState(paths.profilePatch)
195
+ const base = readBasePresetKeys(dirname(paths.profilePatch))
196
+ if (!base.ok) return { ...setup, drift: { baseKnown: false, missing: [], extra: [] } }
197
+ return { ...setup, drift: { baseKnown: true, baseKeys: base.keys, ...presetDrift(base.keys, setup.presets) } }
198
+ }
199
+ const llm = ctx.llm
200
+ const permissionPresets = ctx.permissionPresets
201
+ const agentDefaultModel = ctx.get('agentDefaultModel')
202
+
140
203
  function reloadAllowlist() {
141
204
  const loaded = tryLoadJson(paths.allowlist)
142
205
  if (!loaded.ok) {
@@ -170,12 +233,12 @@ export function apply(ctx, rawConfig = {}) {
170
233
 
171
234
  function applyRuleOp(op, kind, value) {
172
235
  if (allowlistCorrupt && op !== 'reset') {
173
- return { ok: false, error: '规则文件损坏,拒绝覆盖。请先「恢复默认」写回出厂规则,或修好磁盘上的 allowlist.json' }
236
+ return fail('err.allowlistCorrupt')
174
237
  }
175
238
  const draft = cloneAllowlist(allowlist)
176
239
  const result = mutateAllowlistOp(draft, op, kind, value)
177
240
  if (!result.ok) return result
178
- if (!saveJson(paths.allowlist, draft)) return { ok: false, error: '写入 allowlist 失败' }
241
+ if (!saveJson(paths.allowlist, draft)) return fail('err.allowlistWrite')
179
242
  copyAllowlistInto(allowlist, draft)
180
243
  allowlistCorrupt = false
181
244
  if (result.auditLine) audit(result.auditLine)
@@ -202,6 +265,10 @@ export function apply(ctx, rawConfig = {}) {
202
265
  put('reason', 600)
203
266
  put('raw', 800)
204
267
  put('error', 400)
268
+ put('errorCode', 80)
269
+ put('errorMs', 20)
270
+ put('errorDetail', 400)
271
+ put('errorEffort', 40)
205
272
  put('failed', 0, true)
206
273
  put('timedOut', 0, true)
207
274
  return Object.keys(out).length ? out : undefined
@@ -224,7 +291,6 @@ export function apply(ctx, rawConfig = {}) {
224
291
  if (o.category) ev.category = o.category
225
292
  if (o.judgeReason) ev.judgeReason = String(o.judgeReason).slice(0, 600)
226
293
  if (o.path) ev.path = o.path
227
- if (o.ticket !== undefined) ev.ticket = o.ticket
228
294
  if (o.source) ev.source = o.source
229
295
  if (o.cwd) ev.cwd = String(o.cwd).slice(0, 400)
230
296
  if (o.keyword) ev.keyword = String(o.keyword).slice(0, 120)
@@ -240,260 +306,28 @@ export function apply(ctx, rawConfig = {}) {
240
306
  return ev
241
307
  }
242
308
 
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
309
+ function emitDecision(leaf) {
402
310
  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 }
311
+ ctx.emit('auto-approve/decision', leaf)
449
312
  } 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 }
313
+ console.error(`[${NAME}] auto-approve/decision 失败`, error)
461
314
  }
462
315
  }
463
316
 
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()
317
+ function decisionLeaf(info, extra) {
318
+ const e = extra || {}
319
+ const leaf = {
320
+ sessionId: String(info.sessionId || ''),
321
+ tool: String(info.toolName || ''),
322
+ path: String(e.path || info.path || ''),
323
+ verdict: String(e.verdict || ''),
324
+ }
325
+ if (e.outcome) leaf.outcome = String(e.outcome)
326
+ if (e.category || info.category) leaf.category = String(e.category || info.category || '')
327
+ if (e.judgeReason || info.judgeReason) leaf.judgeReason = String(e.judgeReason || info.judgeReason || '').slice(0, 600)
328
+ return leaf
483
329
  }
484
330
 
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
331
  function fallbackSelection() {
498
332
  try {
499
333
  const sel = agentDefaultModel && typeof agentDefaultModel.currentSelection === 'function'
@@ -518,7 +352,7 @@ export function apply(ctx, rawConfig = {}) {
518
352
  async function resolveJudgeRoute() {
519
353
  const route = configuredRoute()
520
354
  if (!route.provider || !route.model) {
521
- return { ok: false, error: '未配置审核模型', ...route }
355
+ return { ok: false, code: 'err.judgeUnconfigured', ...route }
522
356
  }
523
357
  try {
524
358
  const info = await llm.resolveModelInfo(route.provider, route.model)
@@ -526,20 +360,20 @@ export function apply(ctx, rawConfig = {}) {
526
360
  ? info.reasoning.efforts.map((e) => e.id)
527
361
  : []
528
362
  if (route.reasoningEffort && efforts.length > 0 && !efforts.includes(route.reasoningEffort)) {
529
- return { ok: false, error: `reasoningEffort ${route.reasoningEffort} 不受支持`, ...route }
363
+ return { ok: false, code: 'err.judgeEffort', details: { effort: route.reasoningEffort }, ...route }
530
364
  }
531
365
  return { ok: true, ...route, info }
532
366
  } catch (error) {
533
- return { ok: false, error: String((error && error.message) || error), ...route }
367
+ return { ok: false, code: 'err.judgeUpstream', details: { error: String((error && error.message) || error) }, ...route }
534
368
  }
535
369
  }
536
370
 
537
371
  /**
538
- * 调用审核模型。不要传 messages.system:newapi 会映射成 developer 角色导致 400。
539
- * 分类提示全部折进 user 文本。
372
+ * 调用审核模型。不要传 messages.system:部分 OpenAI 兼容网关会把 system 映射成 developer 导致 400。
373
+ * 分类提示全部折进 user 文本。带推理档位时输出预算要留出推理 token,否则空文本会被当成解析失败。
540
374
  */
541
375
  async function callJudge(userText, signal, route, system) {
542
- const prompt = system || buildJudgePrompt(allowlist.criteria, pluginCfg.judgePromptLang)
376
+ const prompt = system || buildJudgePrompt(allowlist.criteria, pluginCfg.judgePromptLang, resolveJudgePromptTemplate(pluginCfg, pluginCfg.judgePromptLang))
543
377
  const opts = {
544
378
  provider: route.provider,
545
379
  model: route.model,
@@ -548,7 +382,7 @@ export function apply(ctx, rawConfig = {}) {
548
382
  content: [{ type: 'text', text: prompt + '\n\n' + userText }],
549
383
  }],
550
384
  temperature: 0,
551
- maxTokens: 256,
385
+ maxTokens: judgeMaxTokens(route.reasoningEffort),
552
386
  signal,
553
387
  }
554
388
  if (route.reasoningEffort) opts.reasoningEffort = route.reasoningEffort
@@ -557,7 +391,10 @@ export function apply(ctx, rawConfig = {}) {
557
391
  if (chunk.type === 'text-delta') text += chunk.text
558
392
  else if (chunk.type === 'finish' && (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted')) {
559
393
  const failure = chunk.reason.failure && chunk.reason.failure.message ? chunk.reason.failure.message : chunk.reason.kind
560
- throw new Error('审核模型调用失败: ' + failure)
394
+ const err = new Error('err.judgeCall')
395
+ err.code = 'err.judgeCall'
396
+ err.details = { error: String(failure) }
397
+ throw err
561
398
  }
562
399
  }
563
400
  return text
@@ -567,7 +404,7 @@ export function apply(ctx, rawConfig = {}) {
567
404
  const criteria = allowlist.criteria || DEFAULT_CRITERIA
568
405
  const lang = normalizeJudgePromptLang(pluginCfg.judgePromptLang)
569
406
  const user = formatJudgeCard(toolName, mode, justification, args, cwd, lang)
570
- const text = await callJudge(user, signal, route, buildJudgePrompt(criteria, lang))
407
+ const text = await callJudge(user, signal, route, buildJudgePrompt(criteria, lang, resolveJudgePromptTemplate(pluginCfg, lang)))
571
408
  try {
572
409
  return { ...parseJudgeClassify(text, criteria), raw: String(text || '').slice(0, 800) }
573
410
  } catch (error) {
@@ -576,10 +413,24 @@ export function apply(ctx, rawConfig = {}) {
576
413
  }
577
414
  }
578
415
 
579
- async function withRetry(runFn, label, timeoutMs) {
416
+ /**
417
+ * 单次判定 + 超时 + 重试。
418
+ * `outerSignal` 是审批请求自己的取消信号:请求被取消后不该继续烧模型调用。
419
+ * 注意**不要**去 abort `req.signal`,这里只观察它。
420
+ */
421
+ async function withRetry(runFn, label, timeoutMs, outerSignal) {
422
+ const cancelled = () => Boolean(outerSignal && outerSignal.aborted)
580
423
  const runOnce = async () => {
424
+ if (cancelled()) return { aborted: true }
581
425
  const controller = new AbortController()
582
426
  let cancelTimer
427
+ const onOuterAbort = () => controller.abort(`${NAME}: ${label} 请求已取消`)
428
+ const linkable = outerSignal && typeof outerSignal.addEventListener === 'function'
429
+ if (linkable) {
430
+ outerSignal.addEventListener('abort', onOuterAbort, { once: true })
431
+ // 竞态窗口:cancelled() 之后、addEventListener 之前就 abort 了,事件不会再触发。
432
+ if (outerSignal.aborted) onOuterAbort()
433
+ }
583
434
  const timed = new Promise((resolve) => {
584
435
  cancelTimer = ctx.timeout(() => resolve({ timedOut: true }), timeoutMs)
585
436
  })
@@ -588,40 +439,56 @@ export function apply(ctx, rawConfig = {}) {
588
439
  .then((r) => ({ ...r, timedOut: false }))
589
440
  .catch((error) => ({ judgeError: error }))
590
441
  const result = await Promise.race([call, timed])
442
+ if (cancelled()) return { aborted: true }
591
443
  if (result.judgeError) throw result.judgeError
592
444
  return result
593
445
  } finally {
594
446
  if (typeof cancelTimer === 'function') {
595
447
  try { cancelTimer() } catch { /* disposer */ }
596
448
  }
449
+ if (linkable && typeof outerSignal.removeEventListener === 'function') {
450
+ outerSignal.removeEventListener('abort', onOuterAbort)
451
+ }
597
452
  controller.abort(`${NAME}: ${label} 结束`)
598
453
  }
599
454
  }
600
455
  let last = { failed: true }
601
456
  try {
602
457
  const first = await runOnce()
458
+ if (first.aborted) return first
603
459
  if (!first.timedOut) return first
604
- last = { failed: true, timedOut: true, error: `超时(${timeoutMs}ms)` }
460
+ last = { failed: true, timedOut: true, errorCode: 'err.judgeTimeout', error: 'err.judgeTimeout', errorMs: String(timeoutMs) }
605
461
  console.warn(`[${NAME}] ${label} 超时(${timeoutMs}ms),转人工`)
606
462
  return last
607
463
  } catch (error) {
608
464
  last = {
609
465
  failed: true,
610
- error: String((error && error.message) || error),
466
+ errorCode: error && error.code ? error.code : 'err.judgeFailed',
467
+ error: error && error.code ? error.code : 'err.judgeFailed',
468
+ errorDetail: error && error.details && error.details.error ? String(error.details.error) : '',
611
469
  raw: error && error.raw ? String(error.raw).slice(0, 800) : '',
612
470
  }
471
+ const code = error && error.code
472
+ if (code === 'err.judgeParse' || code === 'err.judgeEmpty') {
473
+ console.error(`[${NAME}] ${label} 输出无法解析,转人工`, error)
474
+ return last
475
+ }
613
476
  console.error(`[${NAME}] ${label} 异常,重试 1 次`, error)
614
477
  }
615
478
  try {
616
479
  const second = await runOnce()
480
+ if (second.aborted) return second
617
481
  if (!second.timedOut) return second
618
- last = { failed: true, timedOut: true, error: `重试超时(${timeoutMs}ms)` }
482
+ last = { failed: true, timedOut: true, errorCode: 'err.judgeRetryTimeout', error: 'err.judgeRetryTimeout', errorMs: String(timeoutMs) }
619
483
  console.warn(`[${NAME}] ${label} 重试超时(${timeoutMs}ms)`)
620
484
  return last
621
485
  } catch (error) {
622
486
  last = {
623
487
  failed: true,
624
- error: String((error && error.message) || error),
488
+ errorCode: error && error.code ? error.code : 'err.judgeFailed',
489
+ error: error && error.code ? error.code : 'err.judgeFailed',
490
+ errorDetail: error && error.details && error.details.error ? String(error.details.error) : '',
491
+ errorMs: last.errorMs,
625
492
  raw: error && error.raw ? String(error.raw).slice(0, 800) : last.raw,
626
493
  }
627
494
  console.error(`[${NAME}] ${label} 重试仍异常`, error)
@@ -629,7 +496,7 @@ export function apply(ctx, rawConfig = {}) {
629
496
  }
630
497
  }
631
498
 
632
- async function judgeOperation(toolName, mode, justification, args, cwd) {
499
+ async function judgeOperation(toolName, mode, justification, args, cwd, requestSignal) {
633
500
  const route = await resolveJudgeRoute()
634
501
  const meta = {
635
502
  provider: route.provider || '',
@@ -637,15 +504,19 @@ export function apply(ctx, rawConfig = {}) {
637
504
  effort: route.reasoningEffort || '',
638
505
  }
639
506
  if (!route.ok) {
640
- audit(`FAILED judge route: ${route.error}`)
641
- return { action: 'human', criterion: 'other', reason: '', failed: true, error: route.error, ...meta }
507
+ audit(`FAILED judge route: ${route.code || route.error || ''}`)
508
+ return { action: 'human', criterion: 'other', reason: '', failed: true, errorCode: route.code || 'err.judgeUnconfigured', error: route.code || 'err.judgeUnconfigured', errorDetail: route.details && route.details.error ? String(route.details.error) : '', errorEffort: route.details && route.details.effort ? String(route.details.effort) : '', ...meta }
642
509
  }
643
510
  const timeoutMs = effectiveJudgeTimeoutMs(allowlist, pluginCfg)
644
511
  const result = await withRetry(
645
512
  (signal) => judgeOnce(toolName, mode, justification, args, signal, route, cwd),
646
513
  '审核模型',
647
514
  timeoutMs,
515
+ requestSignal,
648
516
  )
517
+ if (result.aborted) {
518
+ return { aborted: true, criterion: 'other', reason: '', ...meta }
519
+ }
649
520
  if (result.failed) {
650
521
  return {
651
522
  action: 'human',
@@ -653,7 +524,10 @@ export function apply(ctx, rawConfig = {}) {
653
524
  reason: '',
654
525
  failed: true,
655
526
  timedOut: Boolean(result.timedOut),
656
- error: result.error || '审核失败',
527
+ errorCode: result.errorCode || result.error || 'err.judgeFailed',
528
+ error: result.errorCode || result.error || 'err.judgeFailed',
529
+ errorMs: result.errorMs || '',
530
+ errorDetail: result.errorDetail || '',
657
531
  raw: result.raw || '',
658
532
  ...meta,
659
533
  }
@@ -662,12 +536,10 @@ export function apply(ctx, rawConfig = {}) {
662
536
  return { ...result, action: row.action, label: row.label, ...meta }
663
537
  }
664
538
 
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
-
539
+ function applyHumanOutcome(ctxInfo, outcome) {
540
+ const { sessionId, toolName, mode, reason, justification, category, path, args, cwd, judgeReason, judge } = ctxInfo
541
+ audit(`OUTCOME ${toolName} outcome=${outcome} source=web | ${reason.slice(0, 80)}`)
542
+ const detail = { category, path, source: 'web', args, cwd, judgeReason, judge }
671
543
  if (outcome === 'allowed-once') {
672
544
  recordEvent(sessionId, toolName, mode, reason, justification, 'manual-approved', {
673
545
  kind: 'manual-approved', ...detail,
@@ -685,108 +557,38 @@ export function apply(ctx, rawConfig = {}) {
685
557
  }
686
558
 
687
559
  /**
688
- * 网页 next() 与 QQ 票据竞速,谁先答谁赢。
689
- * 120s 只是 QQ 提醒,不是全局超时;网页框一直等到有人答或会话取消。
560
+ * 转人工:记 pending,再把同一条请求交给瀑布里的下一个 answerer(网页框)。
561
+ * 必须 await next() 并把 outcome 原样返回,观察者才能看到人工结果。
690
562
  */
691
- async function forwardToHuman(info, next, req) {
692
- reloadPluginCfg()
693
- const ticket = tickets.allocate(info)
563
+ async function forwardToHuman(info, next) {
694
564
  recordEvent(info.sessionId, info.toolName, info.mode, info.reason, info.justification, 'manual-pending', {
695
565
  kind: 'manual-pending',
696
566
  category: info.category || '',
697
567
  path: info.path,
698
- ticket: ticket.n,
699
568
  args: info.args,
700
569
  cwd: info.cwd,
701
570
  judgeReason: info.judgeReason,
702
571
  judge: info.judge,
703
572
  })
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
-
573
+ emitDecision(decisionLeaf(info, { verdict: 'human', path: info.path }))
753
574
  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
575
+ const outcome = await next()
576
+ try {
577
+ applyHumanOutcome(info, outcome)
578
+ emitDecision(decisionLeaf(info, { verdict: 'human', path: info.path, outcome }))
579
+ } catch (error) {
580
+ console.error(`[${NAME}] 记录人工结果失败`, error)
780
581
  }
781
- const outcome = first.outcome || 'cancelled'
782
- await applyHumanOutcome({ ...info, ticket: ticket.n }, outcome, { source: first.source || 'abort' })
783
582
  return outcome
784
- } finally {
785
- webSignal.abort()
786
- if (timeoutHandle && typeof timeoutHandle === 'function') {
787
- try { timeoutHandle() } catch { /* disposer */ }
583
+ } catch (error) {
584
+ console.error(`[${NAME}] 网页审批框失败`, error)
585
+ try {
586
+ applyHumanOutcome(info, 'unavailable')
587
+ emitDecision(decisionLeaf(info, { verdict: 'human', path: info.path, outcome: 'unavailable' }))
588
+ } catch (again) {
589
+ console.error(`[${NAME}] 记录 unavailable 失败`, again)
788
590
  }
789
- if (requestSignal) requestSignal.removeEventListener('abort', onAbort)
591
+ return 'unavailable'
790
592
  }
791
593
  }
792
594
 
@@ -807,6 +609,7 @@ export function apply(ctx, rawConfig = {}) {
807
609
 
808
610
  ctx.on('approval/request', async (req, next) => {
809
611
  let humanFallback = null
612
+ let forwarded = false
810
613
  try {
811
614
  // 非「自动审批」预设交给系统默认 ask,本插件不管。
812
615
  reloadAllowlist()
@@ -827,7 +630,8 @@ export function apply(ctx, rawConfig = {}) {
827
630
  const reason = String(req.reason || '')
828
631
  const { mode, justification } = parseReason(reason)
829
632
  const sessionId = typeof session.id === 'string' ? session.id : ''
830
- const sessionCwd = (typeof session.cwd === 'string' && session.cwd) ? session.cwd : ''
633
+ const sessionCwd = readSessionCwd(session)
634
+
831
635
  const cached = takeCachedCall(pendingCalls, sessionId, req.callId)
832
636
  const toolArgs = pickToolArgs(cached.args || {})
833
637
  const baseInfo = {
@@ -835,35 +639,38 @@ export function apply(ctx, rawConfig = {}) {
835
639
  args: toolArgs,
836
640
  }
837
641
 
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)
642
+ const toHuman = (path, category, extra) => {
643
+ forwarded = true
644
+ return forwardToHuman({
645
+ ...baseInfo,
646
+ path,
647
+ category: category || '',
648
+ judgeReason: extra && extra.judgeReason,
649
+ judge: extra && extra.judge,
650
+ }, next)
651
+ }
848
652
  humanFallback = toHuman
849
653
  // 没看见命令/路径就转人工,禁止关键词允许或模型标 safe。
850
654
  if (!cached.found || !hasToolPayload(toolArgs)) {
851
- const why = cached.found ? '工具参数不完整,转人工' : '未捕获工具参数,转人工'
655
+ const why = cached.found ? 'err.missingPayload' : 'err.missingPayloadUncaptured'
852
656
  audit(`HUMAN ${toolName} mode=${mode || 'none'} missing-payload | ${why}`)
853
657
  return toHuman('missing-payload', 'other', { judgeReason: why })
854
658
  }
855
- const hay = formatKeywordHay(toolName, reason, toolArgs)
856
- const kw = matchKeywordBuckets(hay, allowlist, formatAllowKeywordHay(toolArgs))
659
+ const hay = formatKeywordHay(toolName, reason, toolArgs, sessionCwd)
660
+ const pathHay = formatPathKeywordHay(toolArgs, sessionCwd)
661
+ const kw = matchKeywordBuckets(hay, allowlist, formatAllowKeywordHay(toolArgs), pathHay)
662
+
857
663
  const eventDetail = { args: toolArgs, cwd: sessionCwd }
858
664
  if (kw && kw.action === 'reject') {
859
665
  audit(`REJECT ${toolName} mode=${mode || 'none'} keyword | ${reason.slice(0, 160)}`)
860
666
  recordEvent(sessionId, toolName, mode, reason, justification, 'keyword-reject', {
861
667
  kind: 'auto', path: 'keyword-reject', ...eventDetail,
862
668
  })
669
+ emitDecision(decisionLeaf(baseInfo, { verdict: 'keyword-reject', path: 'keyword-reject', outcome: 'rejected' }))
863
670
  return 'rejected'
864
671
  }
865
672
  if (toolArgsTruncated(toolArgs)) {
866
- const why = '工具参数过长已截断,转人工(禁止按前缀自动放行)'
673
+ const why = 'err.truncatedPayload'
867
674
  audit(`HUMAN ${toolName} mode=${mode || 'none'} truncated-payload | ${why}`)
868
675
  return toHuman('truncated-payload', 'other', { judgeReason: why })
869
676
  }
@@ -873,15 +680,22 @@ export function apply(ctx, rawConfig = {}) {
873
680
  recordEvent(sessionId, toolName, mode, reason, justification, 'keyword-allow', {
874
681
  kind: 'auto', path: 'keyword-allow', ...eventDetail,
875
682
  })
683
+ emitDecision(decisionLeaf(baseInfo, { verdict: 'keyword-allow', path: 'keyword-allow', outcome: 'allowed-once' }))
876
684
  return 'allowed-once'
877
685
  }
878
686
  audit(`HUMAN ${toolName} mode=${mode || 'none'} keyword | ${reason.slice(0, 160)}`)
879
687
  return toHuman('keyword-human', '')
880
688
  }
881
689
 
882
- const judged = await judgeOperation(toolName, mode, justification, toolArgs, sessionCwd)
690
+ const judged = await judgeOperation(toolName, mode, justification, toolArgs, sessionCwd, req.signal)
883
691
  const criterion = judged.criterion || 'other'
884
692
  const judgeReason = judged.reason || ''
693
+ if (judged.aborted) {
694
+ // 请求已被取消(工具执行 signal abort)。审批服务会以 cancelled 结算并丢弃迟到结果,
695
+ // 这里不再占坑、也不再调 next(),避免取消后还弹人工框。
696
+ audit(`CANCEL ${toolName} mode=${mode || 'none'} judge aborted`)
697
+ return 'cancelled'
698
+ }
885
699
  if (judged.failed) {
886
700
  audit(`FAILED ${toolName} mode=${mode || 'none'} → 人工 | ${judged.error || reason.slice(0, 120)}`)
887
701
  return toHuman('judge-failed', criterion, {
@@ -894,6 +708,9 @@ export function apply(ctx, rawConfig = {}) {
894
708
  recordEvent(sessionId, toolName, mode, reason, justification, 'criteria-reject', {
895
709
  kind: 'auto', category: criterion, path: 'criteria-reject', judgeReason, judge: judged, ...eventDetail,
896
710
  })
711
+ emitDecision(decisionLeaf(baseInfo, {
712
+ verdict: 'criteria-reject', path: 'criteria-reject', outcome: 'rejected', category: criterion, judgeReason,
713
+ }))
897
714
  return 'rejected'
898
715
  }
899
716
  if (judged.action === 'allow') {
@@ -901,16 +718,24 @@ export function apply(ctx, rawConfig = {}) {
901
718
  recordEvent(sessionId, toolName, mode, reason, justification, 'criteria-allow', {
902
719
  kind: 'auto', category: criterion, path: 'criteria-allow', judgeReason, judge: judged, ...eventDetail,
903
720
  })
721
+ emitDecision(decisionLeaf(baseInfo, {
722
+ verdict: 'criteria-allow', path: 'criteria-allow', outcome: 'allowed-once', category: criterion, judgeReason,
723
+ }))
904
724
  return 'allowed-once'
905
725
  }
906
726
  audit(`HUMAN ${toolName} mode=${mode || 'none'} criteria=${criterion} | ${judgeReason || reason.slice(0, 120)}`)
907
727
  return toHuman('criteria-human', criterion, { judgeReason, judge: judged })
908
728
  } catch (error) {
909
729
  console.error(`[${NAME}] 判断过程出错,回退人工`, error)
730
+ if (forwarded) return 'unavailable'
910
731
  if (humanFallback) {
911
732
  try {
912
733
  return await humanFallback('plugin-error', 'other', {
913
- judgeReason: String((error && error.message) || error),
734
+ judgeReason: (error && error.code) || 'err.pluginError',
735
+ judge: {
736
+ errorCode: (error && error.code) || 'err.pluginError',
737
+ errorDetail: String((error && error.message) || error),
738
+ },
914
739
  })
915
740
  } catch (again) {
916
741
  console.error(`[${NAME}] 转人工仍失败,交回系统默认`, again)
@@ -926,7 +751,7 @@ export function apply(ctx, rawConfig = {}) {
926
751
  ctx.inject(['connection'], (c) => {
927
752
  const connection = c.connection
928
753
  if (!connection || !connection.fetch || typeof connection.fetch.register !== 'function') {
929
- console.warn(`[${NAME}] connection.fetch 不可用,设置页/提示条 RPC 未注册(门控与 QQ 仍工作)`)
754
+ console.warn(`[${NAME}] connection.fetch 不可用,设置页/提示条 RPC 未注册(门控仍工作)`)
930
755
  return
931
756
  }
932
757
 
@@ -936,12 +761,11 @@ export function apply(ctx, rawConfig = {}) {
936
761
  reloadPluginCfg()
937
762
  const body = payload && typeof payload === 'object' ? payload : {}
938
763
  if (endpoint === 'snapshot') {
939
- const creds = loadQqCreds()
940
764
  return {
941
765
  ok: true,
942
766
  value: {
943
767
  config: {
944
- version: allowlist.version || 17,
768
+ version: allowlist.version || 18,
945
769
  corrupt: allowlistCorrupt,
946
770
  rejectKeywords: allowlist.rejectKeywords || [],
947
771
  humanKeywords: allowlist.humanKeywords || [],
@@ -955,19 +779,14 @@ export function apply(ctx, rawConfig = {}) {
955
779
  rejectKeywords: shippedRejectKeywords(),
956
780
  humanKeywords: [],
957
781
  criteria: shippedCriteria(pluginCfg.judgePromptLang),
782
+ judgePrompts: {
783
+ zh: shippedJudgePromptTemplate('zh'),
784
+ en: shippedJudgePromptTemplate('en'),
785
+ },
958
786
  },
959
- setup: getSetupState(paths.profilePatch),
787
+ setup: presetSetupState(),
960
788
  plugin: pluginCfg,
961
789
  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
790
  providers: (() => {
972
791
  try {
973
792
  return (llm.listProviders() || []).map((p) => ({ id: p.id, name: p.name || p.id }))
@@ -980,7 +799,8 @@ export function apply(ctx, rawConfig = {}) {
980
799
  if (endpoint === 'events') {
981
800
  const sessionId = String(body.sessionId || '')
982
801
  if (!sessionId) {
983
- return { ok: false, error: { code: 'events', message: '需要 sessionId', details: {} } }
802
+ return rpcFail('err.needSessionId')
803
+
984
804
  }
985
805
  const since = Number.parseInt(String(body.since || '0'), 10) || 0
986
806
  return { ok: true, value: { events: readEventsSince(paths.events, sessionId, since) } }
@@ -998,74 +818,52 @@ export function apply(ctx, rawConfig = {}) {
998
818
  pluginCfg.judge.timeoutMs = allowlist.judgeTimeoutMs
999
819
  persistPluginCfg()
1000
820
  }
1001
- return result.ok ? { ok: true, value: result } : { ok: false, error: { code: 'rule', message: result.error || '失败', details: {} } }
821
+ return result.ok ? { ok: true, value: result } : rpcFail(result.code || 'err.allowlistWrite', result.details || {})
822
+
1002
823
  }
1003
824
  if (endpoint === 'setup') {
1004
- return { ok: true, value: setAutoApproveSandbox(paths.profilePatch, pluginCfg.presetSandbox) }
825
+ const setupResult = setAutoApproveSandbox(paths.profilePatch, pluginCfg.presetSandbox)
826
+ if (!setupResult.ok) {
827
+ return rpcFail(setupResult.code || 'err.preset', setupResult.details || { error: String(setupResult.error || '') })
828
+
829
+ }
830
+ return { ok: true, value: setupResult }
1005
831
  }
1006
832
  if (endpoint === 'save-plugin') {
1007
833
  if (pluginCfgCorrupt && !body.overwriteCorrupt) {
1008
- return { ok: false, error: { code: 'save', message: '插件配置损坏,拒绝覆盖。请修好磁盘文件,或点「覆盖损坏配置」', details: {} } }
834
+ return rpcFail('err.pluginCorrupt')
835
+
1009
836
  }
1010
837
  const next = mergePluginConfig(pluginCfg, body)
1011
838
  const prev = pluginCfg
1012
839
  pluginCfg = next
1013
840
  if (!persistPluginCfg({ overwriteCorrupt: Boolean(body.overwriteCorrupt) })) {
1014
841
  pluginCfg = prev
1015
- return { ok: false, error: { code: 'save', message: '写入配置失败', details: {} } }
842
+ return rpcFail('err.pluginWrite')
843
+
1016
844
  }
1017
845
  let preset = null
1018
846
  if (body && Object.prototype.hasOwnProperty.call(body, 'presetSandbox')) {
1019
847
  preset = setAutoApproveSandbox(paths.profilePatch, pluginCfg.presetSandbox)
848
+ if (preset && !preset.ok) {
849
+ // 沙箱没写进 patch 就整个回滚:不能让 config.json 说 read-only、patch 还是全权限。
850
+ pluginCfg = prev
851
+ persistPluginCfg({ overwriteCorrupt: Boolean(body.overwriteCorrupt) })
852
+ return rpcFail(preset.code || 'err.preset', preset.details || { error: String(preset.error || '') })
853
+
854
+ }
1020
855
  }
1021
856
  if (typeof body.judgeTimeoutMs === 'number') {
1022
857
  const timeoutResult = applyRuleOp('set', 'judgeTimeoutMs', body.judgeTimeoutMs)
1023
858
  if (!timeoutResult.ok) {
1024
- return { ok: false, error: { code: 'rule', message: timeoutResult.error || '写入超时失败', details: {} } }
859
+ return rpcFail(timeoutResult.code || 'err.allowlistWrite', timeoutResult.details || {})
860
+
1025
861
  }
1026
862
  pluginCfg.judge.timeoutMs = allowlist.judgeTimeoutMs
1027
863
  persistPluginCfg()
1028
864
  }
1029
865
  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() : '未启动' } }
866
+ return { ok: true, value: { ok: true, plugin: pluginCfg, preset, setup: presetSetupState() } }
1069
867
  }
1070
868
  if (endpoint === 'judge-catalog') {
1071
869
  const provider = String(body.provider || configuredRoute().provider)
@@ -1073,7 +871,8 @@ export function apply(ctx, rawConfig = {}) {
1073
871
  try {
1074
872
  models = (await llm.listModels(provider)).map((m) => ({ id: m.id, name: m.name || m.id }))
1075
873
  } catch (error) {
1076
- return { ok: false, error: { code: 'catalog', message: String((error && error.message) || error), details: {} } }
874
+ return rpcFail('err.catalog', { error: String((error && error.message) || error) })
875
+
1077
876
  }
1078
877
  return { ok: true, value: { provider, models } }
1079
878
  }
@@ -1094,12 +893,15 @@ export function apply(ctx, rawConfig = {}) {
1094
893
  },
1095
894
  }
1096
895
  } catch (error) {
1097
- return { ok: false, error: { code: 'info', message: String((error && error.message) || error), details: {} } }
896
+ return rpcFail('err.info', { error: String((error && error.message) || error) })
897
+
1098
898
  }
1099
899
  }
1100
- return { ok: false, error: { code: 'unknown', message: `unknown endpoint ${endpoint}`, details: {} } }
900
+ return rpcFail('err.unknownEndpoint', { endpoint: String(endpoint || '') })
901
+
1101
902
  } catch (error) {
1102
- return { ok: false, error: { code: 'internal', message: String((error && error.message) || error), details: {} } }
903
+ return rpcFail('err.internal', { error: String((error && error.message) || error) })
904
+
1103
905
  }
1104
906
  }
1105
907
 
@@ -1109,7 +911,8 @@ export function apply(ctx, rawConfig = {}) {
1109
911
  try {
1110
912
  body = await request.json()
1111
913
  } catch {
1112
- return new Response('body is not JSON', { status: 400 })
914
+ return Response.json({ type: 'server-response', rpcId: 'invalid-request', result: rpcFail('err.badBody') })
915
+
1113
916
  }
1114
917
  const rpcId = body && typeof body.rpcId === 'string' ? body.rpcId : 'invalid-request'
1115
918
  const packed = body && body.payload && typeof body.payload === 'object' ? body.payload : {}
@@ -1119,7 +922,8 @@ export function apply(ctx, rawConfig = {}) {
1119
922
  const result = await enqueueRpc(() => dispatch(endpoint, payload))
1120
923
  return Response.json({ type: 'server-response', rpcId: rpcId, result: result })
1121
924
  } catch (error) {
1122
- return new Response('handler failure: ' + String(error), { status: 500 })
925
+ return Response.json({ type: 'server-response', rpcId: rpcId, result: rpcFail('err.internal', { error: String(error) }) })
926
+
1123
927
  }
1124
928
  }
1125
929
 
@@ -1139,5 +943,5 @@ export function apply(ctx, rawConfig = {}) {
1139
943
  }
1140
944
  })
1141
945
 
1142
- log(`已挂载:关键词→审核表 sandbox=${pluginCfg.presetSandbox};QQ=${qq ? qq.status() : '无'}`)
946
+ log(`已挂载:关键词→审核表 sandbox=${pluginCfg.presetSandbox} profilePatch=${paths.profilePatch}`)
1143
947
  }