dsh-escalation-review 0.2.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.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * config-schema.js —— 插件配置的 schemastery schema(即宿主侧的 `export const Config`)。
3
+ *
4
+ * 为什么字段要标 `.volatile()`:0.1.7 的 `SettingsForms.describe()` 用 `volatileForm(schema)` 过滤,
5
+ * **没有任何 volatile 字段的条目会被整个跳过**(设置页里什么都不显示)。放置规则:
6
+ * list / inner / dict-key 的子节点不能标 volatile(否则 `validateVolatilePlacement` 抛错)。
7
+ *
8
+ * 为什么布尔/数组以**文本字段**暴露:0.1.7 的字段辅助只有 `settingsTextField` / `settingsNumberField`,
9
+ * 且 `SettingsValueField` 内部是单行 input;文本值由 lib/index.js 的 `sanitize()` 解析成规范值。
10
+ */
11
+ const dual = (zh, en) => ({ zh, en })
12
+
13
+ /** 卡片里的表单 schema(字段名 = 配置键名)。 */
14
+ export function buildSchema(z) {
15
+ const shape = {
16
+ mode: z
17
+ .union([z.const('observe'), z.const('enforce')])
18
+ .default('enforce')
19
+ .description(
20
+ dual(
21
+ '观察:只评审记录,照旧弹窗问你;自动批准:评审 allow 直接放行、deny 直接拒绝。⚠️ 只在「介入越界审批」开关打开后才有意义(开关关闭时插件完全零介入)。本项是调试项,配置页默认不显示。',
22
+ 'observe: review and log only; enforce: auto-approve on pass. Only meaningful while the "Take over escalations" switch is on (off = no intervention at all). A debugging field, hidden on the config page by default.',
23
+ ),
24
+ ),
25
+ selftestModule: z.string().default('').hidden().description(dual('可选:附加自测模块的绝对路径', 'Optional absolute path to an additional self-test module')),
26
+ selfTest: z
27
+ .boolean()
28
+ .default(false)
29
+ .description(
30
+ dual(
31
+ '打开后,第一次越界会用一批「伪造的待审动作」跑一遍策略自检(绝不执行工具,约 9 次模型调用)',
32
+ 'Run the fabricated-action policy self-test once (no tool is ever executed)',
33
+ ),
34
+ ),
35
+ allowedHosts: z
36
+ .array(z.string())
37
+ .default([])
38
+ .description(
39
+ dual(
40
+ '低风险主机白名单:命中这些主机的常规网络操作按 low 放行(把凭据/隐私发往外部仍按 high 拒绝)',
41
+ 'Hosts treated as low risk for ordinary network access',
42
+ ),
43
+ ),
44
+ policyExtra: z
45
+ .string()
46
+ .default('')
47
+ .description(dual('追加到评审策略末尾的自定义规则(自然语言,随便写)', 'Extra policy text appended to the reviewer prompt')),
48
+ provider: z
49
+ .string()
50
+ .default('')
51
+ .description(dual('reviewer 使用的 provider;留空则跟随当前会话', 'Reviewer provider; empty = follow the session')),
52
+ model: z
53
+ .string()
54
+ .default('')
55
+ .description(dual('reviewer 使用的 model;留空则跟随当前会话(可指定更便宜快的模型)', 'Reviewer model; empty = follow the session')),
56
+ reasoningEffort: z
57
+ .string()
58
+ .default('')
59
+ .description(
60
+ dual(
61
+ '评审调用的思考强度;留空=跟随当前会话/模型默认。可选:off / minimal / low / medium / high / xhigh / max(模型支持哪些档位由该模型决定)',
62
+ 'Reasoning effort for the reviewer call; empty = follow the session/model default (off/minimal/low/medium/high/xhigh/max)',
63
+ ),
64
+ ),
65
+ timeoutMs: z
66
+ .number()
67
+ .min(1000)
68
+ .max(300000)
69
+ .step(1000)
70
+ .default(45000)
71
+ .description(dual('单次评审超时(毫秒);超时按「评审失败」处理', 'Review timeout in ms; a timeout counts as review failure')),
72
+ attemptTimeoutMs: z
73
+ .number()
74
+ .default(30000)
75
+ .description(dual('单次请求超时(毫秒);超时后按 retryDelayMs 重试', 'Per-request timeout (ms); a timeout is retried after retryDelayMs')),
76
+ minAttemptMs: z
77
+ .number()
78
+ .default(2000)
79
+ .description(dual('最小重试预算(毫秒):扣掉等待后剩余预算低于此值就不再重试', 'Minimum budget for another attempt (ms): skip the retry when the remaining budget after the delay is below this')),
80
+ retryDelayMs: z
81
+ .number()
82
+ .default(5000)
83
+ .description(dual('重试间隔(毫秒);生产评审与策略自检共用', 'Retry delay (ms); shared by reviews and the policy self-test')),
84
+ probeRunner: z
85
+ .string()
86
+ .default('inproc')
87
+ .description(dual('只读探针执行方式:inproc=进程内(默认,不 spawn 进程)| shell=沙箱内只读命令', 'Probe runner: inproc (default, no spawn) | shell (read-only command inside the sandbox)')),
88
+ failMode: z
89
+ .union([z.const('deny'), z.const('ask')])
90
+ .default('deny')
91
+ .description(dual('评审失败/超时怎么办:deny 直接拒绝(fail-closed);ask 交回人工', 'On review failure: deny (fail-closed) or ask the user')),
92
+ denyMode: z
93
+ .union([z.const('deny'), z.const('ask')])
94
+ .default('deny')
95
+ .description(dual('评审判定为拒绝怎么办:deny 直接拒绝;ask 交回人工', 'On a deny verdict: refuse outright or ask the user')),
96
+ selfTestText: z
97
+ .string()
98
+ .default('')
99
+ .description(
100
+ dual(
101
+ '策略自检:填 true 会在第一次越界后用一批「伪造动作」跑一遍评审策略(绝不执行工具);留空表示不改动',
102
+ 'Policy self-test: "true" runs the fabricated-action self-test once; blank leaves it unchanged',
103
+ ),
104
+ ),
105
+ allowedHostsText: z
106
+ .string()
107
+ .default('')
108
+ .description(
109
+ dual(
110
+ '低风险主机白名单(逗号或换行分隔)。填了就以它为准;留空表示沿用文件配置里的 allowedHosts',
111
+ 'Comma-separated low-risk host allowlist; blank keeps the file configuration',
112
+ ),
113
+ ),
114
+ logPath: z
115
+ .string()
116
+ .default('')
117
+ .hidden()
118
+ .description(dual('评审日志路径;留空用 $DSH_HOME/escalation-review.log', 'Review log path')),
119
+ historyLimit: z.number().min(1).default(12).hidden().description(dual('喂给 reviewer 的「用户指令」上限条数', 'Retained-instruction budget')),
120
+ transcriptLimit: z.number().min(1).default(40).hidden().description(dual('喂给 reviewer 的 transcript 事件上限', 'Transcript budget')),
121
+ textLimit: z.number().min(100).default(900).hidden().description(dual('单条证据文本的截断长度', 'Per-item text limit')),
122
+ // 介入开关:enabled 是规范化布尔(供 YAML / host config 层用,不进 UI);
123
+ // enabledText 是配置页的文本形态('true'/'false'),由 sanitize() 解析成 enabled。
124
+ // ⚠️ 这两个键必须**同时**出现在 schema 与 EDITABLE 里:设置层写入会对每个路径做
125
+ // isVolatilePath 校验,schema 里没有的键必然被拒("Config field … is not volatile"),
126
+ // 表现为"开关选了但存不下去"。
127
+ enabled: z.boolean().default(false).hidden().description(dual('是否接管沙箱越界审批(规范化布尔)', 'Take over sandbox-escalation approvals (normalized boolean)')),
128
+ enabledText: z.string().default('false').description(dual('介入开关(配置页文本形态:true/false)', 'Take-over switch (form text: true/false)')),
129
+ devMode: z.string().default('').description(dual('开发者模式:显示调试项(运行模式 / 策略自检)', 'Developer mode: show debugging fields')),
130
+ }
131
+
132
+ // ⚠️ 0.1.7 的 SettingsForms 只把 **`meta.volatile`** 的字段投影成可编辑表单:
133
+ // volatileForm(schema):`meta.volatile` 直接整棵通过;否则逐字段过滤,非 volatile 的一律丢掉;
134
+ // 一个都没有 → 整个条目被跳过 → 设置页里什么都不显示(这就是"没有设置界面"的原因)。
135
+ // 放置规则:list/inner/dict-key 的子节点不能标 volatile(会抛 "volatile fields require a fixed
136
+ // object path…"),所以只标这几个顶层字段。
137
+ //
138
+ // 设置页(Plugins 页里的配置页)由**客户端半边**渲染,用的官方控件是
139
+ // `SettingsFormModel` + `settingsTextField` / `settingsNumberField`(只有文本与数字两种)。
140
+ // 因此布尔/数组以**文本字段**暴露,由 lib/index.js 的 sanitize() 解析成规范值。
141
+ const EDITABLE = [
142
+ 'enabledText',
143
+ 'devMode',
144
+ 'mode',
145
+ 'selfTestText',
146
+ 'allowedHostsText',
147
+ 'policyExtra',
148
+ 'provider',
149
+ 'model',
150
+ 'reasoningEffort',
151
+ 'timeoutMs',
152
+ 'failMode',
153
+ 'denyMode',
154
+ 'probeRunner',
155
+ 'attemptTimeoutMs',
156
+ 'retryDelayMs',
157
+ 'minAttemptMs',
158
+ ]
159
+ for (const key of EDITABLE) shape[key] = shape[key].volatile()
160
+
161
+ return z.object(shape)
162
+ }
package/lib/config.js ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * config.js —— 配置控制面(与插件入口解耦,可单独阅读与单测)
3
+ *
4
+ * 优先级(低 → 高):
5
+ * 1) 插件目录 config.json 2) $DSH_HOME/escalation-review.config.json
6
+ * 3) 显式 configPath 4) 宿主 config(apply 的第二参)
7
+ * 另有第 5 层在 index.js 里:Host 设置的 **user 覆盖层**(配置页保存的那层,每 2 秒重读 → 免重启生效)。
8
+ *
9
+ * 之所以把 BOOT_LOG / 默认值放这里:它们与"配置从哪来、落到哪"是同一件事,
10
+ * 而 index.js 只关心控制流(钩子接线)。
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs'
13
+ import { homedir, tmpdir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ const DEFAULT_TIMEOUT_MS = 100_000
18
+ const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000
19
+ const DEFAULT_RETRY_DELAY_MS = 5_000
20
+ const DEFAULT_MIN_ATTEMPT_MS = 2_000
21
+ const DEFAULT_HISTORY_LIMIT = 12
22
+ const DEFAULT_TRANSCRIPT_LIMIT = 40
23
+ const DEFAULT_TEXT_LIMIT = 900
24
+ const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url)))
25
+ const DEFAULT_LOG_NAME = 'dsh-escalation-review.log'
26
+
27
+ /** 诊断日志:落在 $DSH_HOME(不依赖可能被重定向/删掉的 TMP)。 */
28
+ const BOOT_LOG = (() => {
29
+ try {
30
+ const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.length > 0
31
+ ? process.env.DSH_HOME
32
+ : join(homedir(), '.dsh')
33
+ return join(home, 'escalation-review.log')
34
+ } catch {
35
+ return join(tmpdir(), DEFAULT_LOG_NAME)
36
+ }
37
+ })()
38
+
39
+ // ─────────────────────────────────────────────────────────────── 配置(控制面)
40
+
41
+
42
+ /** 标准思考强度档位(模型不支持某档时由 provider 决定,评审失败即按 failMode 处理)。 */
43
+ export const REASONING_EFFORTS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
44
+
45
+ export const DEFAULT_CONFIG = {
46
+ devMode: false, // 开发者模式:配置页显示调试项(运行模式 / 策略自检)
47
+ enabled: false, // 介入开关:true 时接管**所有**沙箱越界的审批(与权限预设、profile 完全解耦)
48
+ enabledText: '', // GUI 表单用('true'/'false' 文本);非空时覆盖 enabled
49
+ mode: 'enforce', // observe | enforce(随包默认 enforce:开关打开后按评审结果放行/拒绝)
50
+ selfTest: false, // true:第一次越界后用**伪造动作**跑一遍策略自测(绝不执行工具)
51
+ selfTestText: '', // GUI 表单用(文本);非空时覆盖 selfTest('true'/'false')
52
+ selftestModule: '', // 可选:附加自测模块的绝对路径(留空则按装载器的默认来源查找)
53
+ timeoutMs: DEFAULT_TIMEOUT_MS,
54
+ attemptTimeoutMs: DEFAULT_ATTEMPT_TIMEOUT_MS, // 单次尝试上限
55
+ retryDelayMs: DEFAULT_RETRY_DELAY_MS, // 两次尝试之间的等待
56
+ minAttemptMs: DEFAULT_MIN_ATTEMPT_MS, // 剩余预算低于此值就不再重试
57
+ reasoningEffort: '', // 评审调用的思考强度:''=跟随会话/模型默认;否则 off|minimal|low|medium|high|xhigh|max
58
+ failMode: 'deny', // 评审失败/超时:deny | ask
59
+ denyMode: 'deny', // 政策拒绝:deny | ask(ask 时交回人工,对齐 Codex 非 strict 档)
60
+ probeRunner: 'inproc', // 只读探针执行方式:inproc=进程内(默认,不 spawn)| shell=沙箱内只读命令
61
+ provider: '',
62
+ model: '',
63
+ allowedHosts: [],
64
+ allowedHostsText: '', // GUI 表单用(逗号/换行分隔);非空时覆盖 allowedHosts
65
+ policyExtra: '',
66
+ logPath: '',
67
+ historyLimit: DEFAULT_HISTORY_LIMIT,
68
+ transcriptLimit: DEFAULT_TRANSCRIPT_LIMIT,
69
+ textLimit: DEFAULT_TEXT_LIMIT,
70
+ }
71
+
72
+ /** 容忍 `//` 与块注释的 JSON 解析(字符串内的注释符不误伤)。 */
73
+ export function parseConfigText(text) {
74
+ const source = String(text)
75
+ let out = ''
76
+ let inString = false
77
+ let escaped = false
78
+ for (let i = 0; i < source.length; i += 1) {
79
+ const ch = source[i]
80
+ if (inString) {
81
+ out += ch
82
+ if (escaped) escaped = false
83
+ else if (ch === '\\') escaped = true
84
+ else if (ch === '"') inString = false
85
+ continue
86
+ }
87
+ if (ch === '"') {
88
+ inString = true
89
+ out += ch
90
+ continue
91
+ }
92
+ if (ch === '/' && source[i + 1] === '/') {
93
+ while (i < source.length && source[i] !== '\n') i += 1
94
+ out += '\n'
95
+ continue
96
+ }
97
+ if (ch === '/' && source[i + 1] === '*') {
98
+ i += 2
99
+ while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i += 1
100
+ i += 1
101
+ continue
102
+ }
103
+ out += ch
104
+ }
105
+ const value = JSON.parse(out)
106
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
107
+ throw new Error('config must be a JSON object')
108
+ }
109
+ return value
110
+ }
111
+
112
+ function sanitize(patch) {
113
+ const out = {}
114
+ if (patch === null || typeof patch !== 'object') return out
115
+ if (patch.mode === 'observe' || patch.mode === 'enforce') out.mode = patch.mode
116
+ // 思考强度:只接受标准档位 id('' 表示跟随会话/模型默认)
117
+ if (typeof patch.reasoningEffort === 'string') {
118
+ const effort = patch.reasoningEffort.trim()
119
+ if (effort === '' || REASONING_EFFORTS.has(effort)) out.reasoningEffort = effort
120
+ }
121
+ if (patch.failMode === 'deny' || patch.failMode === 'ask') out.failMode = patch.failMode
122
+ if (patch.denyMode === 'deny' || patch.denyMode === 'ask') out.denyMode = patch.denyMode
123
+ if (patch.probeRunner === 'inproc' || patch.probeRunner === 'shell') out.probeRunner = patch.probeRunner
124
+ if (typeof patch.selftestModule === 'string' && patch.selftestModule.trim().length > 0) out.selftestModule = patch.selftestModule.trim()
125
+ if (typeof patch.selfTest === 'boolean') out.selfTest = patch.selfTest
126
+ if (Number.isFinite(patch.timeoutMs) && patch.timeoutMs >= 1000) out.timeoutMs = Math.min(Math.floor(patch.timeoutMs), 300_000)
127
+ if (Number.isFinite(patch.attemptTimeoutMs) && patch.attemptTimeoutMs >= 1000) out.attemptTimeoutMs = Math.min(Math.floor(patch.attemptTimeoutMs), 300_000)
128
+ if (Number.isFinite(patch.retryDelayMs) && patch.retryDelayMs >= 0) out.retryDelayMs = Math.min(Math.floor(patch.retryDelayMs), 60_000)
129
+ if (Number.isFinite(patch.minAttemptMs) && patch.minAttemptMs >= 0) out.minAttemptMs = Math.min(Math.floor(patch.minAttemptMs), 60_000)
130
+ for (const key of ['provider', 'model', 'policyExtra', 'logPath', 'configPath']) {
131
+ if (typeof patch[key] === 'string') out[key] = patch[key]
132
+ }
133
+ if (Array.isArray(patch.allowedHosts)) {
134
+ out.allowedHosts = patch.allowedHosts.filter((h) => typeof h === 'string' && h.trim().length > 0).map((h) => h.trim())
135
+ }
136
+ for (const key of ['historyLimit', 'transcriptLimit', 'textLimit']) {
137
+ if (Number.isFinite(patch[key]) && patch[key] > 0) out[key] = Math.floor(patch[key])
138
+ }
139
+
140
+ // GUI 表单(设置页)用的是**文本字段**:官方的 SettingsFormModel 只带 settingsTextField /
141
+ // settingsNumberField 两种控件,所以布尔/数组以文本暴露,在这里解析成规范值。
142
+ if (typeof patch.selfTestText === 'string' && patch.selfTestText.trim().length > 0) {
143
+ out.selfTestText = patch.selfTestText.trim()
144
+ out.selfTest = /^(true|1|yes|on|开|是)$/i.test(out.selfTestText)
145
+ }
146
+ if (typeof patch.devMode === 'boolean') out.devMode = patch.devMode
147
+ if (typeof patch.devModeText === 'string' && patch.devModeText.trim().length > 0) out.devMode = /^(true|1|yes|on|开|是)$/i.test(patch.devModeText.trim())
148
+ if (typeof patch.enabled === 'boolean') out.enabled = patch.enabled
149
+ if (typeof patch.enabledText === 'string' && patch.enabledText.trim().length > 0) {
150
+ out.enabledText = patch.enabledText.trim()
151
+ out.enabled = /^(true|1|yes|on|开|是)$/i.test(out.enabledText)
152
+ }
153
+ if (typeof patch.allowedHostsText === 'string' && patch.allowedHostsText.trim().length > 0) {
154
+ out.allowedHostsText = patch.allowedHostsText.trim()
155
+ out.allowedHosts = out.allowedHostsText
156
+ .split(/[,\n;]/)
157
+ .map((h) => h.trim())
158
+ .filter((h) => h.length > 0)
159
+ }
160
+ return out
161
+ }
162
+
163
+ /**
164
+ * 解析生效配置。优先级(低 → 高):
165
+ * 1) 插件目录 config.json 2) $DSH_HOME/escalation-review.config.json 3) 显式 configPath 4) 宿主 config
166
+ */
167
+ export function resolveConfig(options = {}) {
168
+ const dshHome = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.length > 0
169
+ ? process.env.DSH_HOME
170
+ : join(homedir(), '.dsh')
171
+ const candidates = [
172
+ join(PACKAGE_DIR, 'config.json'),
173
+ join(dshHome, 'escalation-review.config.json'),
174
+ // GUI 配置卡写出来的用户覆盖(由根平面的伴生 entry lib/settings-bridge.js 维护)→ 文件层里优先级最高
175
+ join(dshHome, 'escalation-review.ui.json'),
176
+ ]
177
+ if (typeof options.configPath === 'string' && options.configPath.length > 0) candidates.push(options.configPath)
178
+
179
+ let merged = { ...DEFAULT_CONFIG }
180
+ const loaded = []
181
+ const errors = []
182
+ for (const file of candidates) {
183
+ if (!existsSync(file)) continue
184
+ try {
185
+ merged = { ...merged, ...sanitize(parseConfigText(readFileSync(file, 'utf8'))) }
186
+ loaded.push(file)
187
+ } catch (error) {
188
+ errors.push(`${file}: ${String(error?.message ?? error)}`)
189
+ }
190
+ }
191
+ merged = { ...merged, ...sanitize(options) }
192
+ merged.loadedConfigFiles = loaded
193
+ merged.configErrors = errors
194
+ merged.logPath = merged.logPath.length > 0 ? merged.logPath : BOOT_LOG
195
+ return merged
196
+ }
197
+
198
+ // index.js 需要这些常量(实现仍是本模块私有,这里只是显式再导出)
199
+ export {
200
+ BOOT_LOG,
201
+ sanitize,
202
+ DEFAULT_HISTORY_LIMIT,
203
+ DEFAULT_TEXT_LIMIT,
204
+ DEFAULT_TIMEOUT_MS,
205
+ DEFAULT_ATTEMPT_TIMEOUT_MS,
206
+ DEFAULT_RETRY_DELAY_MS,
207
+ DEFAULT_MIN_ATTEMPT_MS,
208
+ DEFAULT_TRANSCRIPT_LIMIT,
209
+ PACKAGE_DIR,
210
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * decisions.js —— `tools/pre-execute` 的动作构造(从 index.js 抽出的纯函数)
3
+ *
4
+ * 三态契约沿用 DSH:返回 `next()`(放行到下游)/ `{kind:'deny'|'ask'}`。
5
+ * 抽出来的理由:这段文案与分支在 index.js 里连着被改了三轮(熔断、失败处理、denyMode),
6
+ * 属于"每次都进的补丁点";纯函数化之后可单测,index.js 只负责接线。
7
+ *
8
+ * 这里**不做任何 IO、不读配置**:所有输入由调用方给出,输出就是钩子该返回的对象。
9
+ *
10
+ * 文案语言(2026-09-27 定):
11
+ * · **不提供 `displayReason`** —— 客户端的取值是 `displayReason ?? reason`,即 displayReason 优先;
12
+ * 而官方那个字段是"由发起方提供译文",我提供不了所有语言(只有 en/zh 会把日语用户也盖成中英)。
13
+ * · 本地化交给**模型**:策略里要求 reviewer 用"用户在本会话里使用的语言"写 reason/rationale。
14
+ * · 因此插件只加一个**语言中性**的 `escalation-review:` 前缀,可见内容主要是模型那段话。
15
+ */
16
+
17
+ /**
18
+ * 评审失败(异常/超时)的动作 —— fail-closed:只有显式 `failMode: 'ask'` 才交回人工。
19
+ * @param {object} input - { gated, cfg, exec, message }
20
+ */
21
+ export function reviewFailureAction({ gated, cfg, exec, message, deniedName, deniedCode }) {
22
+ // 未启用「自动批准」时插件不介入,失败也不改变任何行为(零介入原则)
23
+ if (!gated) return { kind: 'pass' }
24
+ if (cfg.failMode === 'ask') {
25
+ return {
26
+ kind: 'ask',
27
+ reason: `escalation-review: review failed (${message}) — your call`,
28
+ }
29
+ }
30
+ return {
31
+ kind: 'deny',
32
+ reason: `escalation-review: review failed, body not executed — ${message}`,
33
+ info: { name: deniedName, code: deniedCode, reason: message },
34
+ }
35
+ }
36
+
37
+ /** 连续拒绝熔断后的动作:不再拒绝,交回人工(照 Codex circuit_breaker:避免把任务卡死)。 */
38
+ export function circuitBreakerAction({ exec, breaker }) {
39
+ return {
40
+ kind: 'ask',
41
+ reason: `escalation-review: ${breaker.consecutive} consecutive denials — asking you instead of blocking`,
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 评审给出拒绝时的动作:按 `denyMode` 决定拒绝还是交回人工。
47
+ * (放行路径不在这里 —— 它还要向 agent 注册一次性审批 answerer,属于宿主接线。)
48
+ * @param {object} input - { cfg, exec, decision, deniedName, deniedCode }
49
+ */
50
+ export function reviewRejectedAction({ cfg, exec, decision, deniedName, deniedCode }) {
51
+ const hasReason = decision?.reason !== undefined && decision.reason !== null
52
+ // 分层(2026-09-28 按用户口径定):评审器判 allow/deny/ask,**是否交回人工由配置 denyMode 决定**。
53
+ // 评审器的 ask 只表示"我判不了"(证据自相矛盾/范围无法确定),它**不能越过配置**去决定问人 ——
54
+ // 否则 denyMode: deny 会被架空(自测里"无授权改 hosts"就是这么变成 ask 的)。
55
+ if (cfg.denyMode === 'ask') {
56
+ return {
57
+ kind: 'ask',
58
+ reason: `escalation-review: denied${hasReason ? ` — ${decision.reason}` : ''}`,
59
+ }
60
+ }
61
+ return {
62
+ kind: 'deny',
63
+ // 理由必须进**顶层 reason**:宿主把这一行显示给模型(info.reason 模型看不到)
64
+ reason: `escalation-review: rejected, body not executed${hasReason ? ` — ${decision.reason}` : ''}`,
65
+ info: { name: deniedName, code: deniedCode, ...(hasReason ? { reason: decision.reason } : {}) },
66
+ }
67
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * dsh-packages.js —— 解析并导入 @deepseek-ai/* 包
3
+ *
4
+ * 为什么需要它:插件用 GUI 的「添加插件」安装时是 **link:** 依赖(软链到源码目录),
5
+ * 于是模块 URL 落在 workspace 里,而 ESM 是**按模块自身路径**往上找 node_modules 的
6
+ * —— workspace 树里没有 @deepseek-ai/*,裸 import 会 ERR_MODULE_NOT_FOUND
7
+ * (实测:copy 安装时能解析,link 安装后不能)。
8
+ *
9
+ * 解法:用 createRequire 以 **profile 的 package.json** 为解析根拿到绝对路径,再 import。
10
+ *
11
+ * ⚠️ 关键(2026-09-27 实测定位,踩了很久):
12
+ * `require.resolve()` 是按 **`require` 条件**解析的 → 对双构建包(如 `@deepseek-ai/schemastery`:
13
+ * `exports['.'].import = lib/index.mjs`、`require = lib/index.cjs`)拿到的是 **CJS 入口**。
14
+ * 而在 **Electron 主进程**里 `import()` 那个 CJS 会 `ERR_REQUIRE_ESM_RACE_CONDITION`
15
+ * (CJS 内部再去 require ESM 实现,撞上加载竞态)→ 表现为"拿不到 schemastery" → `Config = undefined`
16
+ * → 设置命名空间不被服务 → 配置页永远不出现。
17
+ * 所以这里**主动按 `import` 条件挑入口**,并且在 `.cjs` 有同名 `.mjs` 时优先 `.mjs`。
18
+ */
19
+
20
+ import { existsSync, readFileSync } from 'node:fs'
21
+ import { createRequire } from 'node:module'
22
+ import { homedir } from 'node:os'
23
+ import { dirname, join } from 'node:path'
24
+ import { pathToFileURL } from 'node:url'
25
+
26
+ /** 上一次成功导入时命中的解析根(给日志用:别再写死"按 profile")。 */
27
+ let lastRoot = null
28
+
29
+ /** 最近一次成功导入所使用的解析根(应用自身 / profile / 裸 import / asar 猜测)。 */
30
+ export function lastResolutionRoot() {
31
+ return lastRoot
32
+ }
33
+
34
+ /** 解析根候选:**运行中的应用自身优先**,再 desktop profile,再 profiles 根。 */
35
+ function resolutionRoots() {
36
+ const home =
37
+ typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.length > 0 ? process.env.DSH_HOME : join(homedir(), '.dsh')
38
+ const profile = process.env.DSH_PROFILE
39
+ const roots = []
40
+ // ⚠️ 优先用**运行中的应用自身**内置的包(新版把 0.2.0 的包放在 app.asar 内层 dsh/ 下)。
41
+ // 升级后 profile 的 node_modules 可能仍是旧版本(实测 0.1.7-rc.2),先解析 profile 会加载到旧包。
42
+ if (typeof process.resourcesPath === 'string' && process.resourcesPath.length > 0) {
43
+ roots.push(join(process.resourcesPath, 'app.asar', 'dsh', 'package.json'))
44
+ roots.push(join(process.resourcesPath, 'app', 'dsh', 'package.json'))
45
+ }
46
+ if (typeof profile === 'string' && profile.length > 0) roots.push(join(home, 'profiles', profile, 'package.json'))
47
+ roots.push(join(home, 'profiles', 'desktop', 'package.json'), join(home, 'profiles', 'package.json'))
48
+ return roots
49
+ }
50
+
51
+ /** 从一个 `exports` 条件对象里挑出 ESM 入口。 */
52
+ function pickImportTarget(value) {
53
+ if (typeof value === 'string') return value
54
+ if (value === null || value === undefined || typeof value !== 'object') return undefined
55
+ return value.import ?? value.node ?? value.module ?? value.default
56
+ }
57
+
58
+ /** 按 `import` 条件把一个包说明符解析成**绝对文件路径**(拿不到就返回 undefined)。 */
59
+ function resolveEsmEntry(req, specifier) {
60
+ try {
61
+ const packageJson = req.resolve(`${specifier}/package.json`)
62
+ if (existsSync(packageJson)) {
63
+ const pkg = JSON.parse(readFileSync(packageJson, 'utf8'))
64
+ const target = pickImportTarget(pkg.exports?.['.']) ?? pkg.module ?? pkg.main
65
+ if (typeof target === 'string') {
66
+ const file = join(dirname(packageJson), target)
67
+ if (existsSync(file)) return file
68
+ }
69
+ }
70
+ } catch {
71
+ /* 包没导出 ./package.json → 走下面的通用回退 */
72
+ }
73
+ const resolved = req.resolve(specifier)
74
+ if (typeof resolved === 'string' && resolved.endsWith('.cjs')) {
75
+ const asMjs = resolved.replace(/\.cjs$/, '.mjs')
76
+ if (existsSync(asMjs)) return asMjs
77
+ }
78
+ return resolved
79
+ }
80
+
81
+ /**
82
+ * 导入一个 DSH 内部包。失败时 throw,并把每次尝试的原因带在 error.attempts 上。
83
+ * @param specifier - 例如 '@deepseek-ai/schemastery'
84
+ */
85
+ export async function importDshPackage(specifier) {
86
+ const attempts = []
87
+ for (const manifest of resolutionRoots()) {
88
+ if (!existsSync(manifest)) continue
89
+ try {
90
+ const req = createRequire(manifest)
91
+ const entry = resolveEsmEntry(req, specifier)
92
+ lastRoot = manifest
93
+ return await import(pathToFileURL(entry).href)
94
+ } catch (error) {
95
+ attempts.push(`${manifest}: ${error?.code ?? error?.message ?? error}`)
96
+ }
97
+ }
98
+ try {
99
+ const bare = await import(specifier)
100
+ lastRoot = 'bare import'
101
+ return bare
102
+ } catch (error) {
103
+ attempts.push(`bare import: ${error?.code ?? error?.message ?? error}`)
104
+ }
105
+ const resources = typeof process.resourcesPath === 'string' && process.resourcesPath.length > 0 ? process.resourcesPath : ''
106
+ if (resources.length > 0) {
107
+ const guess = join(resources, 'app.asar', 'dsh', 'node_modules', ...specifier.split('/'), 'lib', 'index.mjs')
108
+ try {
109
+ lastRoot = guess
110
+ return await import(pathToFileURL(guess).href)
111
+ } catch (error) {
112
+ attempts.push(`asar guess ${guess}: ${error?.code ?? error?.message ?? error}`)
113
+ }
114
+ }
115
+ const failure = new Error(`cannot import ${specifier}`)
116
+ failure.attempts = attempts
117
+ throw failure
118
+ }
119
+
120
+ /** 拿到默认导出的“类 zod”对象(schemastery 有 default / 命名导出两种形态)。 */
121
+ export async function importSchema() {
122
+ const mod = await importDshPackage('@deepseek-ai/schemastery')
123
+ const schema = mod?.default ?? mod
124
+ if (schema === undefined || typeof schema.object !== 'function') {
125
+ throw new Error('@deepseek-ai/schemastery did not expose a schema builder')
126
+ }
127
+ return schema
128
+ }