@dnalec/dsh-auto-approve 0.2.0 → 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/locales.mjs CHANGED
@@ -99,7 +99,15 @@ export const zh = {
99
99
  'detail.script': '脚本',
100
100
  'detail.sql': 'SQL',
101
101
  'detail.prompt': '提示词',
102
+ 'detail.input': '输入',
103
+ 'detail.text': '文本',
104
+ 'detail.body': '正文',
105
+ 'detail.message': '消息',
106
+ 'detail.pattern': '模式',
107
+ 'detail.selector': '选择器',
102
108
  'detail.cwd': '工作目录',
109
+ 'detail.workdir': '命令工作目录',
110
+ 'detail.empty': '(空)',
103
111
  'detail.sandbox': '沙箱',
104
112
  'detail.justification': '模型理由',
105
113
  'detail.pipe': '管道',
@@ -122,6 +130,8 @@ export const zh = {
122
130
  'set.loadFailed': '加载失败:{error}',
123
131
  'set.resetConfirm': '再点一次确认恢复',
124
132
  'set.presetMissingTitle': '权限预设',
133
+ 'set.driftTitle': '权限预设表需要更新',
134
+ 'set.driftBody': 'DSH 出厂权限预设表多了 {keys}。本 profile 的 permission 行由本插件写入,patch 会整块替换 config,所以不会自动出现这些预设;请更新插件或手工合并该行。',
125
135
  'set.presetMissingSub': '插件启动时会写入 auto-approve。当前未检测到,可手动补写。',
126
136
  'set.presetWrite': '写入权限预设',
127
137
  'set.presetWriteNote': '写入后需重启 dsh web',
@@ -131,6 +141,7 @@ export const zh = {
131
141
  'set.modeWsHint': '工作区内直接放行,越出工作区需审核。',
132
142
  'set.modeRo': '只读',
133
143
  'set.modeRoHint': '工作区内写操作也需审核。',
144
+ 'set.modeHint': '点选即写入。改完需重启 dsh web,并重新选择「自动审批」或开新会话。',
134
145
  'set.save': '保存',
135
146
  'set.hotOk': '已生效(热更新,无需重启)',
136
147
  'set.modeSaved': '已写入。请重启 dsh web,并重新选择「自动审批」或开新会话',
@@ -170,11 +181,17 @@ export const zh = {
170
181
  'set.modelDefaultEffort': '模型默认',
171
182
  'set.effortOff': 'off(建议)',
172
183
  'set.judgeTimeoutMs': '审核超时(ms):',
173
- 'set.judgeSaved': '审核模型已保存',
184
+ 'set.judgeSaved': '审核设置已保存',
174
185
  'set.judgeLang': '提示词语言',
175
186
  'set.judgeLangZh': '中文',
176
187
  'set.judgeLangEn': 'English',
177
- 'set.judgeLangHint': '只改发给审核模型的框架语言,不会改当前审核表。恢复默认审核表时使用这里选的语言。',
188
+ 'set.judgeLangHint': '切换发给审核模型的框架语言与卡片标签,不会改当前审核表。下面的提示词按此语言编辑;恢复默认审核表也用这里选的语言。',
189
+ 'set.judgePrompt': '审核提示词',
190
+ 'set.judgePromptHint': '按所选语言内置默认提示词。用 {{criteria}} 插入当前审核表;删掉占位符时会把审核表附在末尾。与审核模型、超时一起保存。',
191
+ 'set.judgePromptCustom': '已自定义',
192
+ 'set.judgePromptDefault': '默认',
193
+ 'set.resetJudgePrompt': '恢复默认提示词',
194
+ 'set.resetJudgePromptOk': '已恢复默认提示词',
178
195
  'set.overwriteOk': '已覆盖损坏的插件配置',
179
196
  'err.missingPayload': '工具参数不完整,转人工',
180
197
  'err.missingPayloadUncaptured': '未捕获工具参数,转人工',
@@ -215,6 +232,7 @@ export const zh = {
215
232
  'err.judgeUpstream': '审核模型路由失败:{error}',
216
233
  'err.noPresetsKey': 'permission 条目缺少 presets 键,请手动添加',
217
234
  'err.preset': '写入预设失败:{error}',
235
+ 'err.presetSandboxMissing': '预设里没有 sandbox 行,无法写入沙箱模式;请检查 profile 的 cordis.patch.yml',
218
236
  'rpc.unavailable': 'connection.rpc 不可用',
219
237
  'rpc.failed': 'RPC 失败',
220
238
  }
@@ -313,7 +331,15 @@ export const en = {
313
331
  'detail.script': 'Script',
314
332
  'detail.sql': 'SQL',
315
333
  'detail.prompt': 'Prompt',
334
+ 'detail.input': 'Input',
335
+ 'detail.text': 'Text',
336
+ 'detail.body': 'Body',
337
+ 'detail.message': 'Message',
338
+ 'detail.pattern': 'Pattern',
339
+ 'detail.selector': 'Selector',
316
340
  'detail.cwd': 'Working directory',
341
+ 'detail.workdir': 'Command working directory',
342
+ 'detail.empty': '(empty)',
317
343
  'detail.sandbox': 'Sandbox',
318
344
  'detail.justification': 'Model justification',
319
345
  'detail.pipe': 'Pipeline',
@@ -336,6 +362,8 @@ export const en = {
336
362
  'set.loadFailed': 'Failed to load: {error}',
337
363
  'set.resetConfirm': 'Click again to confirm restore',
338
364
  'set.presetMissingTitle': 'Permission preset',
365
+ 'set.driftTitle': 'Permission preset table needs updating',
366
+ 'set.driftBody': 'The shipped permission table has new presets: {keys}. This profile\'s permission row is written by this plugin, and a patch replaces that config wholesale, so they will not appear automatically. Update the plugin or merge that row by hand.',
339
367
  'set.presetMissingSub': 'Startup writes auto-approve. It was not detected; you can write it now.',
340
368
  'set.presetWrite': 'Write permission preset',
341
369
  'set.presetWriteNote': 'Restart dsh web after writing',
@@ -345,6 +373,7 @@ export const en = {
345
373
  'set.modeWsHint': 'In-workspace writes skip approval; outside the workspace still goes through the judge.',
346
374
  'set.modeRo': 'Read-only',
347
375
  'set.modeRoHint': 'In-workspace writes also go through the judge.',
376
+ 'set.modeHint': 'Clicking a mode writes it. Then restart dsh web and re-select Auto-approve or start a new session.',
348
377
  'set.save': 'Save',
349
378
  'set.hotOk': 'Applied (live; no restart)',
350
379
  'set.modeSaved': 'Written. Restart dsh web and re-select Auto-approve or start a new session',
@@ -384,11 +413,17 @@ export const en = {
384
413
  'set.modelDefaultEffort': 'Model default',
385
414
  'set.effortOff': 'off (recommended)',
386
415
  'set.judgeTimeoutMs': 'Judge timeout (ms):',
387
- 'set.judgeSaved': 'Judge model saved',
416
+ 'set.judgeSaved': 'Judge settings saved',
388
417
  'set.judgeLang': 'Prompt language',
389
418
  'set.judgeLangZh': 'Chinese',
390
419
  'set.judgeLangEn': 'English',
391
- 'set.judgeLangHint': 'Changes only the judge prompt framework, not the current criteria table. Restore default criteria uses this language.',
420
+ 'set.judgeLangHint': 'Switches the judge prompt framework and card labels, not the current criteria table. The prompt below is edited per language; restore default criteria also uses this language.',
421
+ 'set.judgePrompt': 'Judge prompt',
422
+ 'set.judgePromptHint': 'Built-in default for the selected language. Use {{criteria}} to insert the current criteria table; if the placeholder is removed, the table is appended. Saved with the judge model and timeout.',
423
+ 'set.judgePromptCustom': 'Custom',
424
+ 'set.judgePromptDefault': 'Default',
425
+ 'set.resetJudgePrompt': 'Restore default prompt',
426
+ 'set.resetJudgePromptOk': 'Default prompt restored',
392
427
  'set.overwriteOk': 'Corrupt plugin config overwritten',
393
428
  'err.missingPayload': 'Tool arguments are incomplete; sending to human',
394
429
  'err.missingPayloadUncaptured': 'Tool arguments were not captured; sending to human',
@@ -429,6 +464,7 @@ export const en = {
429
464
  'err.judgeUpstream': 'Judge route failed: {error}',
430
465
  'err.noPresetsKey': 'The permission entry has no presets key; add it manually',
431
466
  'err.preset': 'Failed to write preset: {error}',
467
+ 'err.presetSandboxMissing': 'The preset has no sandbox line; cannot set the sandbox mode. Check the profile cordis.patch.yml',
432
468
  'rpc.unavailable': 'connection.rpc is unavailable',
433
469
  'rpc.failed': 'RPC failed',
434
470
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnalec/dsh-auto-approve",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "DeepSeek Harness 自动审批门控 + 设置页",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -22,7 +22,8 @@
22
22
 
23
23
  "scripts": {
24
24
  "test": "node --test tests/*.test.mjs",
25
- "check": "node --check src/index.mjs && node --check src/rules.mjs && node --check src/preset-patch.mjs && node --check src/util.mjs && node --check locales.mjs && node --check client.js"
25
+ "check": "node --check src/index.mjs && node --check src/rules.mjs && node --check src/preset-patch.mjs && node --check src/util.mjs && node --check locales.mjs && node --check client.js && node --check scripts/sync-locales.mjs && node scripts/sync-locales.mjs",
26
+ "locales:sync": "node scripts/sync-locales.mjs --write"
26
27
  },
27
28
  "keywords": [
28
29
  "deepseek-harness",
package/src/index.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  import {
18
18
  NAME,
19
19
  pathsFor,
20
+ resolveProfilePatchPath,
20
21
  tryLoadJson,
21
22
  saveJson,
22
23
  audit as appendAudit,
@@ -37,12 +38,15 @@ import {
37
38
  parseReason,
38
39
  matchKeywordBuckets,
39
40
  buildJudgePrompt,
41
+ resolveJudgePromptTemplate,
42
+ shippedJudgePromptTemplate,
40
43
  parseJudgeClassify,
41
44
  pickToolArgs,
42
45
  toolArgsTruncated,
43
46
  clipToolArgsForEvent,
44
47
  formatKeywordHay,
45
48
  formatAllowKeywordHay,
49
+ formatPathKeywordHay,
46
50
  formatJudgeCard,
47
51
  hasToolPayload,
48
52
  rememberCachedCall,
@@ -53,12 +57,25 @@ import {
53
57
  mutateAllowlistOp,
54
58
  fail,
55
59
  effectiveJudgeTimeoutMs,
60
+ judgeMaxTokens,
56
61
  } from './rules.mjs'
57
- import { getSetupState, migratePresetCopy, setAutoApproveSandbox } from './preset-patch.mjs'
62
+ import { dirname } from 'node:path'
63
+ import {
64
+ getSetupState,
65
+ migratePresetCopy,
66
+ presetDrift,
67
+ readBasePresetKeys,
68
+ setAutoApproveSandbox,
69
+ } from './preset-patch.mjs'
58
70
 
59
71
  export const name = NAME
60
- /** webServer 本身不用,只为等 web 起来再注册 RPC。 */
61
- 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']
62
79
 
63
80
  /** Host Session 的工作目录在 header.cwd,没有 session.cwd。 */
64
81
  export function readSessionCwd(session) {
@@ -80,6 +97,8 @@ export function rpcFail(code, details) {
80
97
  */
81
98
  export function apply(ctx, rawConfig = {}) {
82
99
  const paths = pathsFor()
100
+ // 真实 profile 目录从 ctx.baseUrl 推导;拿不到才回落到 profiles/web。
101
+ paths.profilePatch = resolveProfilePatchPath(ctx, rawConfig, paths.profilePatch)
83
102
  ensureDir(paths.auto)
84
103
  migratePresetCopy(paths.profilePatch)
85
104
 
@@ -132,10 +151,24 @@ export function apply(ctx, rawConfig = {}) {
132
151
  if (presetSetup.ok && presetSetup.needRestart) {
133
152
  console.log(`[${NAME}] 已写入 auto-approve 权限预设(sandbox=${pluginCfg.presetSandbox});live patch 重载后会话权限会出现「自动审批」`)
134
153
  } else if (!presetSetup.ok) {
135
- console.error(`[${NAME}] 写入 auto-approve 预设失败`, presetSetup.code || presetSetup.error)
154
+ console.error(
155
+ `[${NAME}] 写入 auto-approve 预设失败(${presetSetup.code || presetSetup.error || 'err.preset'}):${paths.profilePatch}`,
156
+ presetSetup.details || '',
157
+ )
136
158
  }
137
159
  }
138
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
+ }
171
+ }
139
172
  let eventSeq = maxEventId(paths.events)
140
173
  let rpcTail = Promise.resolve()
141
174
  function enqueueRpc(fn) {
@@ -151,6 +184,18 @@ export function apply(ctx, rawConfig = {}) {
151
184
  const pendingCalls = new Map()
152
185
  const log = (line) => console.log(`[${NAME}] ${line}`)
153
186
  const audit = (line) => appendAudit(paths.audit, line)
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
+ }
154
199
  const llm = ctx.llm
155
200
  const permissionPresets = ctx.permissionPresets
156
201
  const agentDefaultModel = ctx.get('agentDefaultModel')
@@ -324,11 +369,11 @@ export function apply(ctx, rawConfig = {}) {
324
369
  }
325
370
 
326
371
  /**
327
- * 调用审核模型。不要传 messages.system:newapi 会映射成 developer 角色导致 400。
328
- * 分类提示全部折进 user 文本。
372
+ * 调用审核模型。不要传 messages.system:部分 OpenAI 兼容网关会把 system 映射成 developer 导致 400。
373
+ * 分类提示全部折进 user 文本。带推理档位时输出预算要留出推理 token,否则空文本会被当成解析失败。
329
374
  */
330
375
  async function callJudge(userText, signal, route, system) {
331
- const prompt = system || buildJudgePrompt(allowlist.criteria, pluginCfg.judgePromptLang)
376
+ const prompt = system || buildJudgePrompt(allowlist.criteria, pluginCfg.judgePromptLang, resolveJudgePromptTemplate(pluginCfg, pluginCfg.judgePromptLang))
332
377
  const opts = {
333
378
  provider: route.provider,
334
379
  model: route.model,
@@ -337,7 +382,7 @@ export function apply(ctx, rawConfig = {}) {
337
382
  content: [{ type: 'text', text: prompt + '\n\n' + userText }],
338
383
  }],
339
384
  temperature: 0,
340
- maxTokens: 256,
385
+ maxTokens: judgeMaxTokens(route.reasoningEffort),
341
386
  signal,
342
387
  }
343
388
  if (route.reasoningEffort) opts.reasoningEffort = route.reasoningEffort
@@ -359,7 +404,7 @@ export function apply(ctx, rawConfig = {}) {
359
404
  const criteria = allowlist.criteria || DEFAULT_CRITERIA
360
405
  const lang = normalizeJudgePromptLang(pluginCfg.judgePromptLang)
361
406
  const user = formatJudgeCard(toolName, mode, justification, args, cwd, lang)
362
- const text = await callJudge(user, signal, route, buildJudgePrompt(criteria, lang))
407
+ const text = await callJudge(user, signal, route, buildJudgePrompt(criteria, lang, resolveJudgePromptTemplate(pluginCfg, lang)))
363
408
  try {
364
409
  return { ...parseJudgeClassify(text, criteria), raw: String(text || '').slice(0, 800) }
365
410
  } catch (error) {
@@ -368,10 +413,24 @@ export function apply(ctx, rawConfig = {}) {
368
413
  }
369
414
  }
370
415
 
371
- 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)
372
423
  const runOnce = async () => {
424
+ if (cancelled()) return { aborted: true }
373
425
  const controller = new AbortController()
374
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
+ }
375
434
  const timed = new Promise((resolve) => {
376
435
  cancelTimer = ctx.timeout(() => resolve({ timedOut: true }), timeoutMs)
377
436
  })
@@ -380,18 +439,23 @@ export function apply(ctx, rawConfig = {}) {
380
439
  .then((r) => ({ ...r, timedOut: false }))
381
440
  .catch((error) => ({ judgeError: error }))
382
441
  const result = await Promise.race([call, timed])
442
+ if (cancelled()) return { aborted: true }
383
443
  if (result.judgeError) throw result.judgeError
384
444
  return result
385
445
  } finally {
386
446
  if (typeof cancelTimer === 'function') {
387
447
  try { cancelTimer() } catch { /* disposer */ }
388
448
  }
449
+ if (linkable && typeof outerSignal.removeEventListener === 'function') {
450
+ outerSignal.removeEventListener('abort', onOuterAbort)
451
+ }
389
452
  controller.abort(`${NAME}: ${label} 结束`)
390
453
  }
391
454
  }
392
455
  let last = { failed: true }
393
456
  try {
394
457
  const first = await runOnce()
458
+ if (first.aborted) return first
395
459
  if (!first.timedOut) return first
396
460
  last = { failed: true, timedOut: true, errorCode: 'err.judgeTimeout', error: 'err.judgeTimeout', errorMs: String(timeoutMs) }
397
461
  console.warn(`[${NAME}] ${label} 超时(${timeoutMs}ms),转人工`)
@@ -413,6 +477,7 @@ export function apply(ctx, rawConfig = {}) {
413
477
  }
414
478
  try {
415
479
  const second = await runOnce()
480
+ if (second.aborted) return second
416
481
  if (!second.timedOut) return second
417
482
  last = { failed: true, timedOut: true, errorCode: 'err.judgeRetryTimeout', error: 'err.judgeRetryTimeout', errorMs: String(timeoutMs) }
418
483
  console.warn(`[${NAME}] ${label} 重试超时(${timeoutMs}ms)`)
@@ -431,7 +496,7 @@ export function apply(ctx, rawConfig = {}) {
431
496
  }
432
497
  }
433
498
 
434
- async function judgeOperation(toolName, mode, justification, args, cwd) {
499
+ async function judgeOperation(toolName, mode, justification, args, cwd, requestSignal) {
435
500
  const route = await resolveJudgeRoute()
436
501
  const meta = {
437
502
  provider: route.provider || '',
@@ -447,7 +512,11 @@ export function apply(ctx, rawConfig = {}) {
447
512
  (signal) => judgeOnce(toolName, mode, justification, args, signal, route, cwd),
448
513
  '审核模型',
449
514
  timeoutMs,
515
+ requestSignal,
450
516
  )
517
+ if (result.aborted) {
518
+ return { aborted: true, criterion: 'other', reason: '', ...meta }
519
+ }
451
520
  if (result.failed) {
452
521
  return {
453
522
  action: 'human',
@@ -588,7 +657,8 @@ export function apply(ctx, rawConfig = {}) {
588
657
  return toHuman('missing-payload', 'other', { judgeReason: why })
589
658
  }
590
659
  const hay = formatKeywordHay(toolName, reason, toolArgs, sessionCwd)
591
- const kw = matchKeywordBuckets(hay, allowlist, formatAllowKeywordHay(toolArgs))
660
+ const pathHay = formatPathKeywordHay(toolArgs, sessionCwd)
661
+ const kw = matchKeywordBuckets(hay, allowlist, formatAllowKeywordHay(toolArgs), pathHay)
592
662
 
593
663
  const eventDetail = { args: toolArgs, cwd: sessionCwd }
594
664
  if (kw && kw.action === 'reject') {
@@ -617,9 +687,15 @@ export function apply(ctx, rawConfig = {}) {
617
687
  return toHuman('keyword-human', '')
618
688
  }
619
689
 
620
- const judged = await judgeOperation(toolName, mode, justification, toolArgs, sessionCwd)
690
+ const judged = await judgeOperation(toolName, mode, justification, toolArgs, sessionCwd, req.signal)
621
691
  const criterion = judged.criterion || 'other'
622
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
+ }
623
699
  if (judged.failed) {
624
700
  audit(`FAILED ${toolName} mode=${mode || 'none'} → 人工 | ${judged.error || reason.slice(0, 120)}`)
625
701
  return toHuman('judge-failed', criterion, {
@@ -703,8 +779,12 @@ export function apply(ctx, rawConfig = {}) {
703
779
  rejectKeywords: shippedRejectKeywords(),
704
780
  humanKeywords: [],
705
781
  criteria: shippedCriteria(pluginCfg.judgePromptLang),
782
+ judgePrompts: {
783
+ zh: shippedJudgePromptTemplate('zh'),
784
+ en: shippedJudgePromptTemplate('en'),
785
+ },
706
786
  },
707
- setup: getSetupState(paths.profilePatch),
787
+ setup: presetSetupState(),
708
788
  plugin: pluginCfg,
709
789
  pluginCorrupt: pluginCfgCorrupt,
710
790
  providers: (() => {
@@ -766,6 +846,9 @@ export function apply(ctx, rawConfig = {}) {
766
846
  if (body && Object.prototype.hasOwnProperty.call(body, 'presetSandbox')) {
767
847
  preset = setAutoApproveSandbox(paths.profilePatch, pluginCfg.presetSandbox)
768
848
  if (preset && !preset.ok) {
849
+ // 沙箱没写进 patch 就整个回滚:不能让 config.json 说 read-only、patch 还是全权限。
850
+ pluginCfg = prev
851
+ persistPluginCfg({ overwriteCorrupt: Boolean(body.overwriteCorrupt) })
769
852
  return rpcFail(preset.code || 'err.preset', preset.details || { error: String(preset.error || '') })
770
853
 
771
854
  }
@@ -780,7 +863,7 @@ export function apply(ctx, rawConfig = {}) {
780
863
  persistPluginCfg()
781
864
  }
782
865
  audit('CONFIG plugin 已更新')
783
- return { ok: true, value: { ok: true, plugin: pluginCfg, preset, setup: getSetupState(paths.profilePatch) } }
866
+ return { ok: true, value: { ok: true, plugin: pluginCfg, preset, setup: presetSetupState() } }
784
867
  }
785
868
  if (endpoint === 'judge-catalog') {
786
869
  const provider = String(body.provider || configuredRoute().provider)
@@ -860,5 +943,5 @@ export function apply(ctx, rawConfig = {}) {
860
943
  }
861
944
  })
862
945
 
863
- log(`已挂载:关键词→审核表 sandbox=${pluginCfg.presetSandbox}`)
946
+ log(`已挂载:关键词→审核表 sandbox=${pluginCfg.presetSandbox} profilePatch=${paths.profilePatch}`)
864
947
  }