@mrweicodes/dsh-permgate 1.3.11 → 1.5.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/README.md +211 -211
- package/client.js +273 -36
- package/index.js +1470 -395
- package/package.json +12 -2
package/index.js
CHANGED
|
@@ -3,15 +3,42 @@
|
|
|
3
3
|
// 配置持久化于 $DSH_HOME/dsh-permgate/config.json(用户级、不进任何 git 仓库)。
|
|
4
4
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
5
|
import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
|
6
|
-
import { join as pathJoin } from 'node:path'
|
|
7
|
-
import { existsSync as fsExistsSync, readFileSync as fsReadFileSync, readdirSync as fsReaddirSync } from 'node:fs'
|
|
8
|
-
|
|
9
|
-
const
|
|
6
|
+
import { join as pathJoin, resolve as pathResolve, isAbsolute as pathIsAbsolute } from 'node:path'
|
|
7
|
+
import { existsSync as fsExistsSync, readFileSync as fsReadFileSync, readdirSync as fsReaddirSync, unlinkSync as fsUnlinkSync, lstatSync as fsLstatSync, realpathSync as fsRealpathSync } from 'node:fs'
|
|
8
|
+
import { homedir as osHomedir } from 'node:os'
|
|
9
|
+
const CATS = ['directory', 'command', 'read', 'image', 'edit', 'undo', 'subagent', 'doomloop']
|
|
10
|
+
const EXC_CATS = ['directory', 'command', 'read', 'image', 'edit', 'undo']
|
|
11
|
+
// 分类枚举清单派生:三处工具 schema 的 enum 直接引用,避免新增分类时逐处漏改
|
|
12
|
+
const CATEGORY_ENUM = CATS.slice()
|
|
13
|
+
const EXC_CATEGORY_ENUM = EXC_CATS.slice()
|
|
10
14
|
const MODES = ['ask', 'allow', 'deny']
|
|
11
15
|
const ALL_MODES = ['ask', 'allow', 'deny', 'inherit']
|
|
12
16
|
const MAX_DECISIONS = 30
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
// 快捷工具预设:无文件/命令语义、只能按工具名设默认动作的清单(设置页据此展示,新配置按 QUICK_DEFAULTS 落默认)
|
|
18
|
+
// 低风险观测/会话类工具默认放行,避免每次都弹窗;
|
|
19
|
+
// 其余工具默认询问(ask):mcp__*(外装 MCP)、改权限配置的 perm_*(只读的 perm_status 除外)、
|
|
20
|
+
// 以及管理动态插件的 cordis_run/stop/undefine —— perm_* 曾经被 decide 无条件放行,等于
|
|
21
|
+
// 「AI 可自我提权、且全程无弹窗」;纳入本清单后统一按 ask 裁决,只有用户在设置页显式改成 allow
|
|
22
|
+
// 才会静默放行(预设默认优先于兜底策略,改兜底也不会漏)。
|
|
23
|
+
const QUICK_DEFAULTS = {
|
|
24
|
+
web_search: 'ask', skill: 'allow', grep: 'allow', glob: 'allow', web_fetch: 'ask',
|
|
25
|
+
ask_user_question: 'allow', todo_write: 'allow', list_agents: 'allow',
|
|
26
|
+
job_list: 'allow', job_output: 'allow', job_kill: 'allow',
|
|
27
|
+
get_goal: 'allow', create_goal: 'allow', update_goal: 'allow',
|
|
28
|
+
send_message: 'allow', interrupt_agent: 'allow',
|
|
29
|
+
present: 'allow', exit_plan_mode: 'allow',
|
|
30
|
+
cordis_define: 'allow', cordis_inspect_list: 'allow', cordis_inspect_query: 'allow', cordis_inspect_self: 'allow',
|
|
31
|
+
// 改权限配置 = 元操作,一律先问;只有 perm_status 是只读查询,默认放行(想看随时能看)
|
|
32
|
+
perm_status: 'allow', perm_set_category: 'ask', perm_set_fallback: 'ask', perm_set_editor_kernel: 'ask',
|
|
33
|
+
perm_add_exception: 'ask', perm_remove_exception: 'ask', perm_set_quick: 'ask',
|
|
34
|
+
perm_add_rule: 'ask', perm_remove_rule: 'ask', perm_reload: 'ask',
|
|
35
|
+
// cordis_run 在宿主进程里执行代码、stop/undefine 管理(可移除)动态插件 —— 同属元操作,一律先问
|
|
36
|
+
cordis_run: 'ask', cordis_stop: 'ask', cordis_undefine: 'ask',
|
|
37
|
+
}
|
|
38
|
+
// 单一来源:预设清单由 QUICK_DEFAULTS 的键派生(顺序即键的插入顺序),
|
|
39
|
+
// 避免「清单」与「默认值」两份定义在新增工具时漂移(设置页展示与 locked 迁移共用这一份)
|
|
40
|
+
const QUICK_PRESET = Object.keys(QUICK_DEFAULTS)
|
|
41
|
+
// eslint-disable-next-line no-unused-vars -- 有意保留:记录「审批已改为永不超时」前的历史口径
|
|
15
42
|
const ASK_TIMEOUT_MS = 300000 // 保留常量(历史/文档用途);审批已改为永不超时
|
|
16
43
|
const DECIDE_CHOICES = ['allow', 'deny', 'allow-global', 'allow-project', 'deny-global', 'deny-project']
|
|
17
44
|
const REPEAT_STREAK = 4
|
|
@@ -19,17 +46,167 @@ const PS_KEYWORDS = { foreach: 1, if: 1, else: 1, elseif: 1, for: 1, while: 1, d
|
|
|
19
46
|
// 子命令路由器命令族:候选细化到「git status *」这一粒度,而不是一放全放「git *」
|
|
20
47
|
const ROUTER_CMDS = { git: 1, npm: 1, pnpm: 1, yarn: 1, docker: 1, kubectl: 1, dotnet: 1, cargo: 1, go: 1, gh: 1, pip: 1, uv: 1, conda: 1 }
|
|
21
48
|
|
|
22
|
-
const FILE_READ_TOOLS = { read: 1
|
|
49
|
+
const FILE_READ_TOOLS = { read: 1 }
|
|
50
|
+
// 图片读取单列一类:判定链与 read 完全同构(工作区外先过「目录访问」闸,再过本分类 + 路径例外),
|
|
51
|
+
// 但配置与默认值都独立 —— 「读文件」的设置不管读图,且 image 默认 ask(read 默认 allow),
|
|
52
|
+
// 老配置升级后读图会先询问(v1 的 locked 配置保持 deny),是否放宽由用户自己决定。
|
|
53
|
+
const FILE_IMAGE_TOOLS = { read_image: 1 }
|
|
23
54
|
const FILE_WRITE_TOOLS = { write: 1, edit: 1 }
|
|
24
55
|
const COMMAND_TOOLS = { pwsh: 1, bash: 1 }
|
|
25
56
|
const SUBAGENT_TOOLS = { subagent: 1, subagent_fork: 1, workflow: 1, ralph: 1 }
|
|
26
57
|
|
|
58
|
+
// str_replace_editor 的写命令(view 只读;undo_edit 只抛 E_UNSUPPORTED、不写盘,故不计入写)
|
|
59
|
+
const SRE_WRITE_CMDS = { create: 1, str_replace: 1, insert: 1 }
|
|
60
|
+
|
|
61
|
+
// str_replace_editor 的内核:DSH 内置(官方语义,insert_line 0 基、插到该行之后)
|
|
62
|
+
// 或 dsh-better-edit 的同名 shadow 覆盖(1 基、插到该行之前)。两者语义相反,
|
|
63
|
+
// 预览必须按实际生效的那个算,否则会把插入位置画到错误的地方。
|
|
64
|
+
const EDITOR_KERNELS = ['auto', 'builtin', 'shadow']
|
|
65
|
+
const EDITOR_KERNEL_VALUES = ['auto', 'builtin', 'shadow', 'inherit']
|
|
66
|
+
|
|
67
|
+
// str_replace_editor 命令名提取统一:isFileWrite/isFileRead 与各预览分支共用同一解析口径,
|
|
68
|
+
// 避免命令字符串解析在多处独立演化(create 等命令的判定曾在两处各写一份)
|
|
69
|
+
function sreCommand(args) {
|
|
70
|
+
try { return String((args && args.command) || '') } catch (e) { return '' }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 文件写工具判定:write/edit 原生工具,或 str_replace_editor 的写命令
|
|
74
|
+
function isFileWrite(name, args) {
|
|
75
|
+
if (FILE_WRITE_TOOLS[name]) return true
|
|
76
|
+
if (name !== 'str_replace_editor') return false
|
|
77
|
+
return !!SRE_WRITE_CMDS[sreCommand(args)]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 文件读(文本)工具判定:read,或 str_replace_editor 的 view
|
|
81
|
+
function isFileRead(name, args) {
|
|
82
|
+
if (FILE_READ_TOOLS[name]) return true
|
|
83
|
+
if (name !== 'str_replace_editor') return false
|
|
84
|
+
return sreCommand(args) === 'view'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 图片读工具判定:read_image(参数同样取 file_path,路径解析复用 pathArg)
|
|
88
|
+
function isFileImage(name) {
|
|
89
|
+
return !!FILE_IMAGE_TOOLS[name]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 撤销类工具(dsh-better-edit 的 undo_last_edit):会写盘但不是「编辑」——它恢复既有内容、
|
|
93
|
+
// 不接受调用方提供的新内容,因此单列一类(undo),默认询问。
|
|
94
|
+
const UNDO_TOOLS = { undo_last_edit: 1 }
|
|
95
|
+
function isUndo(name) { return !!UNDO_TOOLS[name] }
|
|
96
|
+
|
|
97
|
+
// 工具自身所属的路径类分类(不含 directory 闸):工作区外审批的「仅此文件」候选要写哪个分类,
|
|
98
|
+
// 不能靠 entry.cat —— 它只记录「作出决定的那道闸」,directory 处于 ask 时反映不出工具本身属于哪类。
|
|
99
|
+
function pathToolCat(name, args) {
|
|
100
|
+
if (isFileWrite(name, args)) return 'edit'
|
|
101
|
+
if (isFileImage(name)) return 'image'
|
|
102
|
+
if (isUndo(name)) return 'undo'
|
|
103
|
+
if (isFileRead(name, args)) return 'read'
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 「可预览文件内容」判定:详情 diff 与「打开文件」路由共用同一口径(写类/文本读类/图片类/撤销类),
|
|
108
|
+
// 避免两处判据分叉导致「面板有对比但打开文件报不支持」
|
|
109
|
+
function isPreviewableFileTool(name, args) {
|
|
110
|
+
return !!(isFileWrite(name, args) || isFileRead(name, args) || isFileImage(name) || isUndo(name))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── 图片嗅探(详情缩略图用)────────────────────────────────────────
|
|
114
|
+
// read_image 支持 PNG/JPEG/WebP/GIF。这里不引图像库,直接按各格式文件头取格式与像素尺寸;
|
|
115
|
+
// 只在「详情」预览通道里用,嗅探失败即视为不可预览,不影响权限判定本身。
|
|
116
|
+
const IMAGE_MIME = { png: 'image/png', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
|
|
117
|
+
// 缩略图体积上限:data URL 比原字节还要大 1/3,超过就只给格式/尺寸、不返回图片本体
|
|
118
|
+
const IMAGE_MAX_BYTES = 2 * 1024 * 1024
|
|
119
|
+
// 像素/边长上限:服务端不做降采样,原图直接内联给浏览器解码,故这里等于「弹窗解码预算」——
|
|
120
|
+
// 16 MP ≈ 4096×4096 ≈ 64MB RGBA;边长闸与像素闸同量级,避免小体积超大清屏图(解压炸弹)。
|
|
121
|
+
const IMAGE_MAX_PIXELS = 16 * 1000 * 1000
|
|
122
|
+
const IMAGE_MAX_DIM = 4096
|
|
123
|
+
// 头部读取上限:JPEG 的 SOF 段可能落在较后面,64KB 足以覆盖常规图片
|
|
124
|
+
const IMAGE_HEAD_BYTES = 64 * 1024
|
|
125
|
+
|
|
126
|
+
function be32(b, o) { return ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0 }
|
|
127
|
+
function le16(b, o) { return b[o] | (b[o + 1] << 8) }
|
|
128
|
+
function le24(b, o) { return b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) }
|
|
129
|
+
|
|
130
|
+
// 返回 { format, width, height };无法识别返回 null;尺寸取不到时宽高为 null
|
|
131
|
+
function sniffImage(b) {
|
|
132
|
+
if (!b || b.length < 16) return null
|
|
133
|
+
// PNG:89 50 4E 47 0D 0A 1A 0A,IHDR 宽高在固定偏移(大端 32 位)
|
|
134
|
+
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
|
|
135
|
+
if (b.length < 24) return { format: 'png', width: null, height: null }
|
|
136
|
+
return { format: 'png', width: be32(b, 16), height: be32(b, 20) }
|
|
137
|
+
}
|
|
138
|
+
// GIF87a / GIF89a:逻辑屏幕宽高(小端 16 位)
|
|
139
|
+
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) {
|
|
140
|
+
return { format: 'gif', width: le16(b, 6), height: le16(b, 8) }
|
|
141
|
+
}
|
|
142
|
+
// JPEG:FF D8 之后逐段跳过,遇 SOFn 帧头取高宽(大端)
|
|
143
|
+
if (b[0] === 0xff && b[1] === 0xd8) {
|
|
144
|
+
let i = 2
|
|
145
|
+
while (i + 9 < b.length) {
|
|
146
|
+
if (b[i] !== 0xff) { i++; continue }
|
|
147
|
+
const m = b[i + 1]
|
|
148
|
+
if (m === 0xff) { i++; continue } // 段间填充字节
|
|
149
|
+
if (m === 0x01 || (m >= 0xd0 && m <= 0xd8)) { i += 2; continue } // 无长度字段的段
|
|
150
|
+
const len = (b[i + 2] << 8) | b[i + 3]
|
|
151
|
+
if (len < 2) break
|
|
152
|
+
const isSof = (m >= 0xc0 && m <= 0xc3) || (m >= 0xc5 && m <= 0xc7) || (m >= 0xc9 && m <= 0xcb) || (m >= 0xcd && m <= 0xcf)
|
|
153
|
+
if (isSof) return { format: 'jpeg', height: (b[i + 5] << 8) | b[i + 6], width: (b[i + 7] << 8) | b[i + 8] }
|
|
154
|
+
if (m === 0xda) break // SOS:之后是压缩数据,不会再有尺寸段
|
|
155
|
+
i += 2 + len
|
|
156
|
+
}
|
|
157
|
+
return { format: 'jpeg', width: null, height: null }
|
|
158
|
+
}
|
|
159
|
+
// WebP:RIFF....WEBP + 变体块
|
|
160
|
+
if (b.length >= 30 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {
|
|
161
|
+
const cc = String.fromCharCode(b[12], b[13], b[14], b[15])
|
|
162
|
+
if (cc === 'VP8X') return { format: 'webp', width: le24(b, 24) + 1, height: le24(b, 27) + 1 }
|
|
163
|
+
if (cc === 'VP8 ') return { format: 'webp', width: (b[26] | (b[27] << 8)) & 0x3fff, height: (b[28] | (b[29] << 8)) & 0x3fff }
|
|
164
|
+
if (cc === 'VP8L') {
|
|
165
|
+
const bits = (b[21] | (b[22] << 8) | (b[23] << 16) | (b[24] << 24)) >>> 0
|
|
166
|
+
return { format: 'webp', width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }
|
|
167
|
+
}
|
|
168
|
+
return { format: 'webp', width: null, height: null }
|
|
169
|
+
}
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// target 归一化统一:缺失/非法一律落到 global(三个设置路由共用,避免漏改某处把项目设置写进全局)
|
|
174
|
+
function normTarget(a) {
|
|
175
|
+
return a && a.target === 'project' ? 'project' : 'global'
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 路径归一化(better-edit store 路径匹配共用):大小写/斜杠/尾斜杠归一化
|
|
179
|
+
function normPathKey(p) {
|
|
180
|
+
return String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 「文件过大」预检统一口径:size 为字节数(多字节 UTF-8 下 ≥ 字符数),超过上限即可安全提前拒绝;
|
|
184
|
+
// 各分支读盘后另有字符数兜底(磁盘全文 + 新增文本总长),两层守卫互补
|
|
185
|
+
function fileTooLarge(info, maxChars) {
|
|
186
|
+
return !!(info && typeof info.size === 'number' && info.size > maxChars)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 「文件过大」字符数兜底统一:磁盘全文 + 新增文本总长超过 DIFF_MAX_CHARS 即拒绝。
|
|
190
|
+
// 各读盘分支共用,避免上限口径多份独立演化(文案由调用方按场景选择)。
|
|
191
|
+
// 常量必须定义在模块作用域:overMaxChars 是模块级函数,访问不到 apply() 内的局部常量
|
|
192
|
+
const DIFF_MAX_CHARS = 1048576
|
|
193
|
+
|
|
194
|
+
function overMaxChars(a, b) {
|
|
195
|
+
return String(a == null ? '' : a).length + String(b == null ? '' : b).length > DIFF_MAX_CHARS
|
|
196
|
+
}
|
|
197
|
+
|
|
27
198
|
// 双语文案:bi(zh, en) 生成 {zh,en};L(obj, lang) 按语言取值(缺省回退中文)
|
|
28
199
|
const bi = (zh, en) => ({ zh, en })
|
|
29
200
|
const L = (o, lang) => (o && (o[lang] || o.zh)) || ''
|
|
30
201
|
// 语言参数归一化:只有 en 用英文,其余(缺失/空/非法)一律中文
|
|
31
202
|
const normLang = (v) => (v === 'en' ? 'en' : 'zh')
|
|
32
203
|
|
|
204
|
+
// 「读取失败」错误对象统一构造:各读盘/预检分支共用,避免中英文案与字段在多处独立演化
|
|
205
|
+
const readFail = (e) => {
|
|
206
|
+
const emsg = (e && e.message ? e.message : String(e))
|
|
207
|
+
return { zh: '读取失败: ' + emsg, en: 'Read failed: ' + emsg }
|
|
208
|
+
}
|
|
209
|
+
|
|
33
210
|
export default {
|
|
34
211
|
inject: ['fs', 'sandboxPolicy', 'tools', 'webServer', 'timer', 'approval', 'permissionPresets', 'sessions'],
|
|
35
212
|
apply(ctx) {
|
|
@@ -46,6 +223,10 @@ export default {
|
|
|
46
223
|
let loaded = false
|
|
47
224
|
let agentRef = null
|
|
48
225
|
let dshHomeCache = null
|
|
226
|
+
// home 解析失败后的抑制窗口:load/persist 一次流程内会多次调用 resolveDshHome,
|
|
227
|
+
// 失败即重试会导致每次调用都重新 spawn cmd 探测并刷错误日志
|
|
228
|
+
let dshHomeFailAt = 0
|
|
229
|
+
const HOME_FAIL_TTL_MS = 200
|
|
49
230
|
let config = freshConfig()
|
|
50
231
|
let loadError = null
|
|
51
232
|
let saveError = null
|
|
@@ -68,20 +249,21 @@ export default {
|
|
|
68
249
|
}
|
|
69
250
|
|
|
70
251
|
function freshConfig() {
|
|
71
|
-
const g = { quickTools: {}, custom: [], sandboxMode: 'danger-full-access' }
|
|
252
|
+
const g = { quickTools: {}, custom: [], sandboxMode: 'danger-full-access', fallbackMode: 'ask', editorKernel: 'auto' }
|
|
72
253
|
for (const c of CATS) g[c] = freshCategory(c, false)
|
|
73
254
|
for (const k of Object.keys(QUICK_DEFAULTS)) g.quickTools[k] = QUICK_DEFAULTS[k]
|
|
74
255
|
return { global: g, projects: {} }
|
|
75
256
|
}
|
|
76
257
|
|
|
77
258
|
function freshProject() {
|
|
78
|
-
const pb = { quickTools: {}, custom: [], sandboxMode: 'inherit' }
|
|
259
|
+
const pb = { quickTools: {}, custom: [], sandboxMode: 'inherit', fallbackMode: 'inherit', editorKernel: 'inherit' }
|
|
79
260
|
for (const c of CATS) pb[c] = freshCategory(c, true)
|
|
80
261
|
return pb
|
|
81
262
|
}
|
|
82
263
|
|
|
83
264
|
function freshCategory(key, inheritDefault) {
|
|
84
|
-
|
|
265
|
+
// 默认 ask:不可逆/越界/涉及外部执行或输入的分类(含读图)从严;read/subagent 这类只读或可回收的默认放行
|
|
266
|
+
const cat = { mode: inheritDefault ? 'inherit' : (key === 'directory' || key === 'command' || key === 'edit' || key === 'undo' || key === 'image' || key === 'doomloop' ? 'ask' : 'allow') }
|
|
85
267
|
if (EXC_CATS.indexOf(key) !== -1) cat.exceptions = []
|
|
86
268
|
return cat
|
|
87
269
|
}
|
|
@@ -133,13 +315,13 @@ export default {
|
|
|
133
315
|
|
|
134
316
|
function buildConfig(parsed) {
|
|
135
317
|
const g = parsed.global && typeof parsed.global === 'object' ? parsed.global : {}
|
|
136
|
-
const global = { quickTools: normalizeQuick(g.quickTools), custom: Array.isArray(g.custom) ? g.custom.map(normalizeRule).filter(Boolean) : [], sandboxMode: ['workspace-write', 'danger-full-access'].indexOf(g.sandboxMode) !== -1 ? g.sandboxMode : 'danger-full-access' }
|
|
318
|
+
const global = { quickTools: normalizeQuick(g.quickTools), custom: Array.isArray(g.custom) ? g.custom.map(normalizeRule).filter(Boolean) : [], sandboxMode: ['workspace-write', 'danger-full-access'].indexOf(g.sandboxMode) !== -1 ? g.sandboxMode : 'danger-full-access', fallbackMode: MODES.indexOf(g.fallbackMode) !== -1 ? g.fallbackMode : 'ask', editorKernel: EDITOR_KERNELS.indexOf(g.editorKernel) !== -1 ? g.editorKernel : 'auto' }
|
|
137
319
|
for (const c of CATS) global[c] = normalizeCategory(g[c], c, false)
|
|
138
320
|
const projects = {}
|
|
139
321
|
const rawProjects = parsed.projects && typeof parsed.projects === 'object' ? parsed.projects : {}
|
|
140
322
|
for (const key of Object.keys(rawProjects)) {
|
|
141
323
|
const p = rawProjects[key] && typeof rawProjects[key] === 'object' ? rawProjects[key] : {}
|
|
142
|
-
const pb = { quickTools: normalizeQuick(p.quickTools), custom: Array.isArray(p.custom) ? p.custom.map(normalizeRule).filter(Boolean) : [], sandboxMode: ['workspace-write', 'danger-full-access', 'inherit'].indexOf(p.sandboxMode) !== -1 ? p.sandboxMode : 'inherit' }
|
|
324
|
+
const pb = { quickTools: normalizeQuick(p.quickTools), custom: Array.isArray(p.custom) ? p.custom.map(normalizeRule).filter(Boolean) : [], sandboxMode: ['workspace-write', 'danger-full-access', 'inherit'].indexOf(p.sandboxMode) !== -1 ? p.sandboxMode : 'inherit', fallbackMode: ALL_MODES.indexOf(p.fallbackMode) !== -1 ? p.fallbackMode : 'inherit', editorKernel: EDITOR_KERNEL_VALUES.indexOf(p.editorKernel) !== -1 ? p.editorKernel : 'inherit' }
|
|
143
325
|
for (const c of CATS) pb[c] = normalizeCategory(p[c], c, true)
|
|
144
326
|
projects[key] = pb
|
|
145
327
|
}
|
|
@@ -151,7 +333,10 @@ export default {
|
|
|
151
333
|
const oldMode = ['off', 'permissive', 'locked'].indexOf(g.mode) !== -1 ? g.mode : 'off'
|
|
152
334
|
const cfg = freshConfig()
|
|
153
335
|
const map = { off: 'allow', permissive: 'allow', locked: 'deny' }
|
|
154
|
-
|
|
336
|
+
// image 是本版新增的从严分类,不套用老模式映射:off/permissive 老配置按新默认 ask
|
|
337
|
+
// (升级后读图先询问,由用户决定是否放宽),locked 仍保持 deny,避免比旧行为更松。
|
|
338
|
+
for (const c of CATS) cfg.global[c].mode = c === 'image' ? (oldMode === 'locked' ? 'deny' : 'ask') : (map[oldMode] || 'allow')
|
|
339
|
+
cfg.global.fallbackMode = map[oldMode]
|
|
155
340
|
cfg.global.doomloop.mode = oldMode === 'off' ? 'allow' : 'ask'
|
|
156
341
|
if (oldMode === 'locked') {
|
|
157
342
|
for (const k of Object.keys(cfg.global.quickTools)) cfg.global.quickTools[k] = 'deny'
|
|
@@ -160,13 +345,19 @@ export default {
|
|
|
160
345
|
const rawProjects = parsed.projects && typeof parsed.projects === 'object' ? parsed.projects : {}
|
|
161
346
|
for (const key of Object.keys(rawProjects)) {
|
|
162
347
|
const p = rawProjects[key] && typeof rawProjects[key] === 'object' ? rawProjects[key] : {}
|
|
163
|
-
const
|
|
348
|
+
const explicitMode = ['off', 'permissive', 'locked'].indexOf(p.mode) !== -1
|
|
349
|
+
const pm = explicitMode ? p.mode : 'off'
|
|
164
350
|
const pb = { quickTools: {}, custom: Array.isArray(p.rules) ? p.rules.map(normalizeRule).filter(Boolean) : [] }
|
|
165
351
|
for (const c of CATS) {
|
|
166
352
|
pb[c] = freshCategory(c, true)
|
|
353
|
+
// 同上:image 不套用老模式 —— 未显式配置的项目保持 inherit(跟随全局 ask),locked 仍 deny
|
|
354
|
+
if (c === 'image') { if (pm === 'locked') pb[c].mode = 'deny'; continue }
|
|
167
355
|
pb[c].mode = pm === 'off' ? 'allow' : (map[pm] || 'allow')
|
|
168
356
|
}
|
|
169
357
|
pb.doomloop.mode = pm === 'off' ? 'allow' : 'ask'
|
|
358
|
+
// 仅显式配置过旧模式的项目保留旧行为(off→allow);未显式配置(缺省 off)用 inherit 跟随全局,
|
|
359
|
+
// 否则之后全局收紧兜底时这些老项目仍按 allow 静默放行
|
|
360
|
+
pb.fallbackMode = explicitMode ? (pm === 'off' ? 'allow' : map[pm]) : 'inherit'
|
|
170
361
|
if (pm === 'locked') {
|
|
171
362
|
for (const k of QUICK_PRESET) pb.quickTools[k] = 'deny'
|
|
172
363
|
}
|
|
@@ -283,7 +474,7 @@ export default {
|
|
|
283
474
|
// 写类工具 + 目标在工作区外 + 会话沙箱受限(workspace-write)→ 需要沙箱升级
|
|
284
475
|
function needsUpgrade(exec) {
|
|
285
476
|
try {
|
|
286
|
-
if (!
|
|
477
|
+
if (!isFileWrite(exec.name, exec.arguments) && !isUndo(exec.name)) return false
|
|
287
478
|
const fp = pathArg(exec.arguments)
|
|
288
479
|
if (!fp || !isOutside(fp, root)) return false
|
|
289
480
|
const agent = (exec && exec.agent) || agentRef
|
|
@@ -320,18 +511,55 @@ export default {
|
|
|
320
511
|
// 兜底:异常/取消路径残留的升级在下次调用前写回
|
|
321
512
|
function flushStaleUpgrades() {
|
|
322
513
|
if (!upgradedCalls.size) return
|
|
323
|
-
for (const
|
|
514
|
+
for (const rec of upgradedCalls.values()) {
|
|
324
515
|
try { if (rec && rec.session) setSandboxMode(rec.session, rec.prev || 'workspace-write') } catch (e) {}
|
|
325
516
|
}
|
|
326
517
|
upgradedCalls.clear()
|
|
327
518
|
}
|
|
328
519
|
|
|
520
|
+
// 从宿主环境变量/系统 home 解析 DSH home(跨平台,且能省掉一次 cmd 子进程探测):
|
|
521
|
+
// DSH_HOME 本身即 home;否则 用户家目录 + '/.dsh'
|
|
522
|
+
function homeFromEnv() {
|
|
523
|
+
try {
|
|
524
|
+
const win = process.platform === 'win32'
|
|
525
|
+
// win32 上还要求盘符或 UNC 前缀(charCode 92 为反斜杠、47 为斜杠),
|
|
526
|
+
// 避免 /c/Users/x 这类取值被 path.resolve 解析到当前盘
|
|
527
|
+
const isWinAbs = (s) => (s.length > 2 && s.charAt(1) === ':' && (s.charCodeAt(2) === 92 || s.charCodeAt(2) === 47)) || (s.charCodeAt(0) === 92 && s.charCodeAt(1) === 92)
|
|
528
|
+
const localAbs = (v) => {
|
|
529
|
+
const s = String(v == null ? '' : v).trim()
|
|
530
|
+
if (!s || !pathIsAbsolute(s)) return null
|
|
531
|
+
if (win && !isWinAbs(s)) return null
|
|
532
|
+
return norm(s)
|
|
533
|
+
}
|
|
534
|
+
// 顺序与 harness(@deepseek-ai/dsh-home-paths)一致:DSH_HOME → os.homedir()/.dsh → HOME/USERPROFILE
|
|
535
|
+
const dh = localAbs(process.env.DSH_HOME)
|
|
536
|
+
if (dh) return dh
|
|
537
|
+
const oh = osHomedir()
|
|
538
|
+
if (oh) return norm(String(oh) + '/.dsh')
|
|
539
|
+
const h = localAbs(process.env.HOME || process.env.USERPROFILE)
|
|
540
|
+
if (h) return norm(h + '/.dsh')
|
|
541
|
+
} catch (e) {}
|
|
542
|
+
return null
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// home 配置目标路径统一:ensureTarget/persist/probeHomeConfig/load 共用同一拼接,
|
|
546
|
+
// 避免同一路径多处内联后漂移(读一个文件、写另一个文件)
|
|
547
|
+
async function homeConfigTarget(home) {
|
|
548
|
+
return await fs.resolve(String(home) + '/dsh-permgate/config.json')
|
|
549
|
+
}
|
|
550
|
+
|
|
329
551
|
async function resolveDshHome() {
|
|
330
552
|
if (dshHomeCache !== null) return dshHomeCache
|
|
331
|
-
|
|
553
|
+
// 失败不缓存(初始化早期 subprocess 可能未就绪,保持 null 以便后续重试),
|
|
554
|
+
// 但抑制短时间内重复重试:load/persist 一次流程会多次调用本函数
|
|
555
|
+
if (dshHomeFailAt && Date.now() - dshHomeFailAt < HOME_FAIL_TTL_MS) return null
|
|
556
|
+
// 先看宿主环境变量与系统 home(跨平台):cmd 探测只在 Windows 可用,
|
|
557
|
+
// 仅依赖它会让 macOS/Linux 上 home 恒解析失败、配置永久无法落盘
|
|
558
|
+
const envHome = homeFromEnv()
|
|
559
|
+
if (envHome) { dshHomeCache = envHome; return dshHomeCache }
|
|
332
560
|
try {
|
|
333
561
|
const sub = ctx.get('subprocess')
|
|
334
|
-
if (!sub) return dshHomeCache
|
|
562
|
+
if (!sub) { dshHomeFailAt = Date.now(); return dshHomeCache }
|
|
335
563
|
const exe = await sub.resolveExecutable('cmd')
|
|
336
564
|
const tryEcho = async (expr) => {
|
|
337
565
|
const handle = sub.spawn({
|
|
@@ -357,6 +585,7 @@ export default {
|
|
|
357
585
|
} catch (e) {
|
|
358
586
|
console.error('[permgate] resolveDshHome error:', e)
|
|
359
587
|
}
|
|
588
|
+
dshHomeFailAt = Date.now()
|
|
360
589
|
return dshHomeCache
|
|
361
590
|
}
|
|
362
591
|
|
|
@@ -369,8 +598,14 @@ export default {
|
|
|
369
598
|
root = base
|
|
370
599
|
rootSource = source
|
|
371
600
|
const home = await resolveDshHome()
|
|
372
|
-
const
|
|
373
|
-
|
|
601
|
+
const resolved = home
|
|
602
|
+
? await homeConfigTarget(home)
|
|
603
|
+
: await fs.resolve(base ? base + '/.dsh/.permgate.json' : '.dsh/.permgate.json')
|
|
604
|
+
// 配置路径发生切换时不重置磁盘快照:persist 的防覆盖守卫(磁盘内容 vs 快照)依赖它,
|
|
605
|
+
// 置空会让守卫整段跳过,可能用「旧路径加载的内存配置」静默覆盖新路径上已存在的配置。
|
|
606
|
+
// 切换后由 load() 对新目标重新建立快照基线;若 persist 在切换后未经 load 直接保存,
|
|
607
|
+
// 守卫会因新旧目标内容不一致而拒绝并提示「重新加载配置文件」,方向安全。
|
|
608
|
+
target = resolved
|
|
374
609
|
return target
|
|
375
610
|
}
|
|
376
611
|
|
|
@@ -394,22 +629,10 @@ export default {
|
|
|
394
629
|
await handle.done
|
|
395
630
|
return true
|
|
396
631
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const sub = ctx.get('subprocess')
|
|
402
|
-
if (!sub) return false
|
|
403
|
-
const exe = await sub.resolveExecutable('cmd')
|
|
404
|
-
const winPath = String(dir).replace(/\//g, '\\')
|
|
405
|
-
const handle = sub.spawn({
|
|
406
|
-
argv: [exe, '/c', 'mkdir', winPath],
|
|
407
|
-
cwd: String(root || '.').replace(/\//g, '\\'),
|
|
408
|
-
stdio: { stdin: 'ignore', stdout: { maxBytes: 8192 }, stderr: { maxBytes: 8192 } },
|
|
409
|
-
graceMs: 5000,
|
|
410
|
-
})
|
|
411
|
-
await handle.done
|
|
412
|
-
return true
|
|
632
|
+
// home 不可用(初始化早期 subprocess 未就绪等)时不再创建目录:旧实现在此
|
|
633
|
+
// mkdir <root>/.dsh,而 home 恢复后配置写回 home,项目里只留下一个空 .dsh。
|
|
634
|
+
// 配置目录应与实际写入位置一致;home 不可用是暂时状态,重试即恢复。
|
|
635
|
+
return false
|
|
413
636
|
} catch (e) {
|
|
414
637
|
console.error('[permgate] ensureConfigDir error:', e)
|
|
415
638
|
return false
|
|
@@ -481,6 +704,8 @@ export default {
|
|
|
481
704
|
function matchCommand(pat, hay) {
|
|
482
705
|
const p = String(pat || '')
|
|
483
706
|
const h = String(hay || '')
|
|
707
|
+
// 空 pattern 不匹配任何命令(否则 indexOf('') === 0 恒真,等于放行所有命令)
|
|
708
|
+
if (!p) return false
|
|
484
709
|
if (p.indexOf('*') === -1 && p.indexOf('?') === -1) return h.toLowerCase().indexOf(p.toLowerCase()) !== -1
|
|
485
710
|
let body = p
|
|
486
711
|
let tail = '.*'
|
|
@@ -531,7 +756,15 @@ export default {
|
|
|
531
756
|
}
|
|
532
757
|
|
|
533
758
|
function ensureProject() {
|
|
534
|
-
|
|
759
|
+
// 与 projectBlock() 同口径查找:迁移来的 key 可能只是大小写/斜杠形式不同,
|
|
760
|
+
// 若这里用精确 root 查找会另建一个条目,同一项目出现两个 key(面板改动看似无效)
|
|
761
|
+
const key = norm(root).toLowerCase()
|
|
762
|
+
const projs = config.projects || {}
|
|
763
|
+
for (const k of Object.keys(projs)) {
|
|
764
|
+
if (norm(k).toLowerCase() === key) return projs[k]
|
|
765
|
+
}
|
|
766
|
+
if (!config.projects) config.projects = {}
|
|
767
|
+
config.projects[root] = freshProject()
|
|
535
768
|
return config.projects[root]
|
|
536
769
|
}
|
|
537
770
|
|
|
@@ -544,6 +777,75 @@ export default {
|
|
|
544
777
|
return true
|
|
545
778
|
}
|
|
546
779
|
|
|
780
|
+
// 覆盖语义统一:project 显式配置优先、inherit 穿透到 global、缺省用 def。
|
|
781
|
+
// fallback 与分类 mode 共用(缺省值不同:fallback='ask'、分类='allow'),避免覆盖判定独立演化
|
|
782
|
+
function firstEffective(projVal, globalVal, def) {
|
|
783
|
+
if (projVal && projVal !== 'inherit') return projVal
|
|
784
|
+
return globalVal || def
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// 编辑器内核判别:str_replace_editor 可能被 dsh-better-edit 的同名 shadow 实现覆盖,
|
|
788
|
+
// 两者 insert 的 insert_line 语义相反(内置 0 基、插到该行之后 / shadow 1 基、插到该行之前),
|
|
789
|
+
// 预览必须按实际生效的那个算,否则会把插入位置画到错误的地方。
|
|
790
|
+
// 判别顺序:显式配置 > 工具描述探测 > 回退内置(内置始终存在)。
|
|
791
|
+
function editorKernelSetting() {
|
|
792
|
+
const proj = projectBlock()
|
|
793
|
+
return firstEffective(proj && proj.editorKernel, config.global.editorKernel, 'auto')
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function detectEditorKernel(exec) {
|
|
797
|
+
try {
|
|
798
|
+
const tools = ctx.tools
|
|
799
|
+
if (!tools || typeof tools.get !== 'function') return null
|
|
800
|
+
const def = tools.get('str_replace_editor', (exec && exec.agent) || agentRef)
|
|
801
|
+
// 内置的「AFTER the line」只出现在 insert_line 的**参数**描述里(顶层描述没有该短语),
|
|
802
|
+
// 故把参数描述一并纳入匹配,使两种内核都能被正向识别,而不是让内置只能靠回退
|
|
803
|
+
const top = def && typeof def.description === 'string' ? def.description : ''
|
|
804
|
+
const params = def && def.parameters && typeof def.parameters === 'object' ? def.parameters : null
|
|
805
|
+
// defineTool 编译后 parameters 是 JSON Schema({type:'object', properties:{...}}),
|
|
806
|
+
// 参数描述在 properties.insert_line.description;兼容可能存在的旧式扁平结构
|
|
807
|
+
const props = params && params.properties && typeof params.properties === 'object' ? params.properties : params
|
|
808
|
+
const insDesc = (props && props.insert_line && typeof props.insert_line.description === 'string') ? props.insert_line.description : ''
|
|
809
|
+
const desc = top + '\n' + insDesc
|
|
810
|
+
if (!desc.trim()) return null
|
|
811
|
+
// shadow: "inserts new line(s) before insert_line (1-indexed, lines+1 appends)"
|
|
812
|
+
if (/before\s+insert_line/i.test(desc) || /1-indexed/i.test(desc)) return 'shadow'
|
|
813
|
+
// 内置: "The `new_str` will be inserted AFTER the line `insert_line`"
|
|
814
|
+
if (/AFTER the line/i.test(desc)) return 'builtin'
|
|
815
|
+
return null
|
|
816
|
+
} catch (e) { return null }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function resolveEditorKernel(exec) {
|
|
820
|
+
const setting = editorKernelSetting()
|
|
821
|
+
if (setting === 'builtin' || setting === 'shadow') return { kernel: setting, source: 'config' }
|
|
822
|
+
const detected = detectEditorKernel(exec)
|
|
823
|
+
if (detected) return { kernel: detected, source: 'detected' }
|
|
824
|
+
return { kernel: 'builtin', source: 'fallback' }
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function setEditorKernel(targetKey, mode) {
|
|
828
|
+
const allowed = targetKey === 'global' ? EDITOR_KERNELS : EDITOR_KERNEL_VALUES
|
|
829
|
+
if (allowed.indexOf(mode) === -1) return false
|
|
830
|
+
const block = targetKey === 'global' ? config.global : ensureProject()
|
|
831
|
+
block.editorKernel = mode
|
|
832
|
+
return true
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// 兜底策略:未匹配任何规则的调用如何处理(project 覆盖 global,默认 ask)
|
|
836
|
+
function fallbackMode() {
|
|
837
|
+
const proj = projectBlock()
|
|
838
|
+
return firstEffective(proj && proj.fallbackMode, config.global.fallbackMode, 'ask')
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function setFallbackMode(targetKey, mode) {
|
|
842
|
+
const allowed = targetKey === 'global' ? MODES : ALL_MODES
|
|
843
|
+
if (allowed.indexOf(mode) === -1) return false
|
|
844
|
+
const block = targetKey === 'global' ? config.global : ensureProject()
|
|
845
|
+
block.fallbackMode = mode
|
|
846
|
+
return true
|
|
847
|
+
}
|
|
848
|
+
|
|
547
849
|
function matchException(r, value, kind) {
|
|
548
850
|
if (kind === 'path') return matchGlob(r.path, value)
|
|
549
851
|
return matchCommand(r.match, value)
|
|
@@ -559,14 +861,17 @@ export default {
|
|
|
559
861
|
const gl = Array.isArray(gCat.exceptions) ? gCat.exceptions : []
|
|
560
862
|
for (const r of gl) if (matchException(r, value, kind)) return { action: r.action, ruleId: r.id, reason: (r.action === 'deny' && r.reason) ? r.reason : undefined }
|
|
561
863
|
}
|
|
562
|
-
const mode = (pCat && pCat.mode
|
|
864
|
+
const mode = firstEffective(pCat && pCat.mode, gCat.mode, 'allow')
|
|
563
865
|
return { action: mode, ruleId: null }
|
|
564
866
|
}
|
|
565
867
|
|
|
566
868
|
function pathArg(args) {
|
|
567
869
|
try {
|
|
568
870
|
if (!args || typeof args !== 'object') return null
|
|
569
|
-
//
|
|
871
|
+
// 工具参数里的文件路径:str_replace_editor(内置与 better-edit shadow)只读 path,
|
|
872
|
+
// 而 read/write 用 file_path。若统一让 file_path 优先,agent 同时传两个字段时就会
|
|
873
|
+
// 「审查/预览看一个文件、实际写另一个文件」,故先按工具语义取 path
|
|
874
|
+
if (typeof args.command === 'string' && typeof args.path === 'string') return args.path
|
|
570
875
|
if (typeof args.file_path === 'string') return args.file_path
|
|
571
876
|
if (typeof args.path === 'string') return args.path
|
|
572
877
|
return null
|
|
@@ -602,7 +907,6 @@ export default {
|
|
|
602
907
|
// 过大时走 fallback 旧式 ± 视图(前 200 变更行 + 截断计数,不阻塞审批)。
|
|
603
908
|
// DIFF_MAX_CHARS:对比双方文本总长上限(edit 为磁盘全文+新文本;write 为磁盘+内容)。
|
|
604
909
|
// 1MB 覆盖常见大文件(如打包产物);超限返回「文件过大,无法生成对比」。
|
|
605
|
-
const DIFF_MAX_CHARS = 1048576
|
|
606
910
|
const DIFF_MAX_LINES = 200
|
|
607
911
|
// Myers 中间区行数预算:超限走旧式 fallback(避免 trace 内存暴涨)。2048 行最坏时
|
|
608
912
|
// trace 累计约 33MB 瞬时分配 + 数百万次迭代(服务端主线程);512 行时约 2MB/数十万次,
|
|
@@ -616,14 +920,35 @@ export default {
|
|
|
616
920
|
function splitDiffLines(s) {
|
|
617
921
|
return normEol(s).split('\n')
|
|
618
922
|
}
|
|
619
|
-
|
|
923
|
+
|
|
924
|
+
// 统计字符串 [0, end) 区间的换行数:只计数不物化数组
|
|
925
|
+
//(避免为取一个行号对最大 1MB 文本 split 出数十万元素的数组)
|
|
926
|
+
function countNewlines(s, end) {
|
|
927
|
+
const t = String(s == null ? '' : s)
|
|
928
|
+
const n = Math.min(typeof end === 'number' ? end : t.length, t.length)
|
|
929
|
+
let c = 0
|
|
930
|
+
for (let i = 0; i < n; i++) if (t.charCodeAt(i) === 10) c++
|
|
931
|
+
return c
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// 公共前缀/后缀长度(行对齐共用):computeLineDiff 与 undo 窗口预览复用,避免同一算法两份实现漂移
|
|
935
|
+
function commonPrefixLen(a, b) {
|
|
936
|
+
const maxP = Math.min(a.length, b.length)
|
|
937
|
+
let p = 0
|
|
938
|
+
while (p < maxP && a[p] === b[p]) p++
|
|
939
|
+
return p
|
|
940
|
+
}
|
|
941
|
+
function commonSuffixLen(a, b, prefix) {
|
|
942
|
+
let s = 0
|
|
943
|
+
while (s < a.length - prefix && s < b.length - prefix && a[a.length - 1 - s] === b[b.length - 1 - s]) s++
|
|
944
|
+
return s
|
|
945
|
+
}
|
|
946
|
+
function computeLineDiff(oldText, newText, baseLine) {
|
|
947
|
+
const b = baseLine || 1
|
|
620
948
|
const oldLines = splitDiffLines(oldText)
|
|
621
949
|
const newLines = splitDiffLines(newText)
|
|
622
|
-
|
|
623
|
-
const
|
|
624
|
-
while (prefix < maxP && oldLines[prefix] === newLines[prefix]) prefix++
|
|
625
|
-
let suffix = 0
|
|
626
|
-
while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix++
|
|
950
|
+
const prefix = commonPrefixLen(oldLines, newLines)
|
|
951
|
+
const suffix = commonSuffixLen(oldLines, newLines, prefix)
|
|
627
952
|
const removed = oldLines.slice(prefix, oldLines.length - suffix)
|
|
628
953
|
const added = newLines.slice(prefix, newLines.length - suffix)
|
|
629
954
|
const lines = []
|
|
@@ -633,7 +958,7 @@ export default {
|
|
|
633
958
|
for (let i = 0; i < n; i++) {
|
|
634
959
|
if (lines.length >= DIFF_MAX_LINES) break
|
|
635
960
|
// 行号:差异区从 prefix+1 行开始;删除行标旧文件行号,新增行标新文件行号
|
|
636
|
-
const no = String(prefix + i +
|
|
961
|
+
const no = String(prefix + i + b).padStart(4, ' ')
|
|
637
962
|
if (i < removed.length) { lines.push('- ' + no + ' ' + removed[i]); shownR++ }
|
|
638
963
|
if (i < added.length) { lines.push('+ ' + no + ' ' + added[i]); shownA++ }
|
|
639
964
|
}
|
|
@@ -731,11 +1056,8 @@ export default {
|
|
|
731
1056
|
const base = baseLine || 1
|
|
732
1057
|
const oldLines = splitDiffLines(oldText)
|
|
733
1058
|
const newLines = splitDiffLines(newText)
|
|
734
|
-
|
|
735
|
-
const
|
|
736
|
-
while (p < maxP && oldLines[p] === newLines[p]) p++
|
|
737
|
-
let s = 0
|
|
738
|
-
while (s < oldLines.length - p && s < newLines.length - p && oldLines[oldLines.length - 1 - s] === newLines[newLines.length - 1 - s]) s++
|
|
1059
|
+
const p = commonPrefixLen(oldLines, newLines)
|
|
1060
|
+
const s = commonSuffixLen(oldLines, newLines, p)
|
|
739
1061
|
const midA = oldLines.slice(p, oldLines.length - s)
|
|
740
1062
|
const midB = newLines.slice(p, newLines.length - s)
|
|
741
1063
|
// 完全相同(含空窗口):无差异,直接返回空 ops。
|
|
@@ -749,7 +1071,7 @@ export default {
|
|
|
749
1071
|
// (added - removed === midB.length - midA.length),Myers 结果必被丢弃,直接走 fallback,
|
|
750
1072
|
// 避免无谓的 O((N+M)*D) 计算与 trace 内存。
|
|
751
1073
|
if (Math.abs(midA.length - midB.length) > DIFF_MAX_LINES) {
|
|
752
|
-
const d = computeLineDiff(oldText, newText)
|
|
1074
|
+
const d = computeLineDiff(oldText, newText, base)
|
|
753
1075
|
return { ok: true, kind, file: fp, fallback: true, added: d.added, removed: d.removed, lines: d.lines, truncated: d.truncated }
|
|
754
1076
|
}
|
|
755
1077
|
if (midA.length + midB.length <= DIFF_BUDGET_LINES) {
|
|
@@ -774,7 +1096,7 @@ export default {
|
|
|
774
1096
|
return { ok: true, kind, file: fp, added, removed, ops: out, truncated: 0 }
|
|
775
1097
|
}
|
|
776
1098
|
}
|
|
777
|
-
const d = computeLineDiff(oldText, newText)
|
|
1099
|
+
const d = computeLineDiff(oldText, newText, base)
|
|
778
1100
|
return { ok: true, kind, file: fp, fallback: true, added: d.added, removed: d.removed, lines: d.lines, truncated: d.truncated }
|
|
779
1101
|
}
|
|
780
1102
|
// 新文件(write 到不存在路径):全部为新增行
|
|
@@ -805,53 +1127,101 @@ export default {
|
|
|
805
1127
|
return null
|
|
806
1128
|
} catch (e) { return null }
|
|
807
1129
|
}
|
|
1130
|
+
// better-edit store 定位缓存(projRoot → store 路径):runtime 目录扫描是 30+ 次文件 IO,
|
|
1131
|
+
// 每次 undo/edit 预览详情都重复执行;projRoot 在会话内不变,缓存安全
|
|
1132
|
+
const betterEditStoreCache = new Map()
|
|
1133
|
+
// store 缓存失效统一:DB 打开/查询失败时清掉正缓存,避免坏结果被固化
|
|
1134
|
+
function invalidateStoreCache(projRoot) {
|
|
1135
|
+
try { betterEditStoreCache.delete(projRoot) } catch (e) {}
|
|
1136
|
+
}
|
|
808
1137
|
// 扫描 better-edit runtime 目录,返回匹配 projRoot 的 store 路径(.wsPath sidecar 匹配)
|
|
809
1138
|
function betterEditStoreFor(projRoot) {
|
|
1139
|
+
if (betterEditStoreCache.has(projRoot)) return betterEditStoreCache.get(projRoot)
|
|
1140
|
+
let found = null
|
|
810
1141
|
try {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1142
|
+
// 与 homeFromEnv()/harness 同口径:DSH_HOME 本身即 home,其它情况才是「用户家目录 + '/.dsh'」
|
|
1143
|
+
const base = homeFromEnv()
|
|
1144
|
+
if (base) {
|
|
1145
|
+
const rt = pathJoin(base, 'plugins', 'dsh-better-edit', 'runtime')
|
|
1146
|
+
if (fsExistsSync(rt)) {
|
|
1147
|
+
const want = normPathKey(projRoot)
|
|
1148
|
+
for (const dir of fsReaddirSync(rt)) {
|
|
1149
|
+
const full = pathJoin(rt, dir)
|
|
1150
|
+
const wsPath = pathJoin(full, '.wsPath')
|
|
1151
|
+
let ws = null
|
|
1152
|
+
try { ws = fsReadFileSync(wsPath, 'utf8').trim() } catch (e) {}
|
|
1153
|
+
if (ws && normPathKey(ws) === want) {
|
|
1154
|
+
const store = pathJoin(full, 'hash-store.sqlite')
|
|
1155
|
+
if (fsExistsSync(store)) { found = store; break }
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
827
1158
|
}
|
|
828
1159
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1160
|
+
} catch (e) { found = null }
|
|
1161
|
+
// 负缓存修复:store 未创建(found=null)时不缓存,避免 better-edit 后续创建 store 后
|
|
1162
|
+
// 本会话永远找不到;正结果仍缓存(命中路径性能不受影响)
|
|
1163
|
+
if (found) betterEditStoreCache.set(projRoot, found)
|
|
1164
|
+
return found
|
|
1165
|
+
}
|
|
1166
|
+
// store 查询路径统一提取:resolve 结果对象 → targetKey(realpath)优先,displayPath 次之,fallback 兜底。
|
|
1167
|
+
// betterEditHashesFor 与 buildUndoDiffData 共用,避免 fallback 语义分叉('' vs fp)
|
|
1168
|
+
// resolve 结果对象 → 原生路径字符串统一口径:processPath(targetKey=realpath)优先,
|
|
1169
|
+
// 其次 targetKey/displayPath,最后 fallback。store 查询、持久化路径比较与迁移清理共用,
|
|
1170
|
+
// 避免「比较用路径」与「操作用路径」分叉(曾导致删除落到 realpath 指向的工作区外文件)
|
|
1171
|
+
function pathString(v, fallback) {
|
|
1172
|
+
if (typeof v === 'string') return v
|
|
1173
|
+
if (v && typeof v === 'object') {
|
|
1174
|
+
const viaProcess = fs.processPath && fs.processPath(v)
|
|
1175
|
+
return String(viaProcess || v.targetKey || v.displayPath || fallback || '')
|
|
1176
|
+
}
|
|
1177
|
+
return String(v == null ? (fallback || '') : v)
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// store 查询路径统一提取:betterEditHashesFor 与 buildUndoDiffData 共用(复用统一 pathString)
|
|
1181
|
+
function extractStorePath(v, fallback) {
|
|
1182
|
+
return pathString(v, fallback)
|
|
1183
|
+
}
|
|
1184
|
+
// better-edit store 行查找统一:WHERE path 精确查询(走主键),miss 时全表按归一化匹配回退。
|
|
1185
|
+
// undo/snapshots 两表共用,避免查询策略独立演化(table/cols 均为内部常量,无注入面)
|
|
1186
|
+
function storeRowByPath(db, table, cols, rawPath, want) {
|
|
1187
|
+
const exact = String(rawPath || '')
|
|
1188
|
+
const row = db.prepare('SELECT ' + cols + ' FROM ' + table + ' WHERE path = ?').get(exact) || null
|
|
1189
|
+
if (row) return row
|
|
1190
|
+
// 回退:只取主键列做归一化匹配,命中后再按主键取大列——一次 miss 不应把整表大字段
|
|
1191
|
+
// (undo 的 content/result_content 是编辑前后全文)全部物化为 JS 字符串
|
|
1192
|
+
const keys = db.prepare('SELECT path FROM ' + table).all()
|
|
1193
|
+
let hit = null
|
|
1194
|
+
for (const r of keys) { if (normPathKey(r.path) === want) { hit = r.path; break } }
|
|
1195
|
+
if (hit === null) return null
|
|
1196
|
+
return db.prepare('SELECT ' + cols + ' FROM ' + table + ' WHERE path = ?').get(hit) || null
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
async function betterEditHashesFor(projRoot, targetPath, fallbackFp) {
|
|
834
1200
|
const storePath = betterEditStoreFor(projRoot)
|
|
835
1201
|
if (!storePath) return null
|
|
836
1202
|
try {
|
|
837
1203
|
const { DatabaseSync } = await import('node:sqlite')
|
|
838
|
-
|
|
839
|
-
|
|
1204
|
+
// fsService.resolve 可能返回 {displayPath, targetKey} 对象(dsh-fs-local resolveLocalTarget),
|
|
1205
|
+
// 先提取路径字符串,否则 String() 恒得 '[object Object]',WHERE 与归一化匹配全部落空
|
|
1206
|
+
// targetKey(realpath)优先:store 的 path 列存的是磁盘真实大小写,用输入大小写的
|
|
1207
|
+
// displayPath 做 WHERE 在 Windows 上大小写不一致时确定 miss、回退全表载入大列
|
|
1208
|
+
const rawPath = extractStorePath(targetPath, fallbackFp || '')
|
|
1209
|
+
const want = normPathKey(rawPath)
|
|
840
1210
|
const db = new DatabaseSync(storePath, { readOnly: true })
|
|
841
1211
|
try {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1212
|
+
// 优先按 path 精确查询(snapshots 表 path 是主键,可走索引,避免全表载入全部 hashes);
|
|
1213
|
+
// 存储格式与磁盘路径可能不完全一致(大小写/斜杠),miss 时回退全表按归一化匹配。
|
|
1214
|
+
const row = storeRowByPath(db, 'snapshots', 'path, hashes', rawPath, want)
|
|
1215
|
+
if (row) {
|
|
1216
|
+
try {
|
|
1217
|
+
const arr = JSON.parse(row.hashes)
|
|
1218
|
+
if (Array.isArray(arr)) return arr
|
|
1219
|
+
} catch (e) {}
|
|
1220
|
+
return null
|
|
851
1221
|
}
|
|
852
1222
|
return null
|
|
853
1223
|
} finally { try { db.close() } catch (e) {} }
|
|
854
|
-
} catch (e) { return null }
|
|
1224
|
+
} catch (e) { invalidateStoreCache(projRoot); return null }
|
|
855
1225
|
}
|
|
856
1226
|
|
|
857
1227
|
// ── 从磁盘内容重算 better-edit 行 hash ─────────────────────────────
|
|
@@ -875,8 +1245,9 @@ export default {
|
|
|
875
1245
|
if (beHasherP) return beHasherP
|
|
876
1246
|
beHasherP = (async () => {
|
|
877
1247
|
// 在 better-edit 的安装树里找 xxhash-wasm 的 esm 入口
|
|
878
|
-
|
|
879
|
-
const base =
|
|
1248
|
+
// 与 homeFromEnv()/harness 同口径(见 betterEditStoreFor)
|
|
1249
|
+
const base = homeFromEnv()
|
|
1250
|
+
if (!base) return null
|
|
880
1251
|
const profileNm = pathJoin(base, 'profiles', 'web', 'node_modules', '.pnpm')
|
|
881
1252
|
const dirs = fsExistsSync(profileNm) ? fsReaddirSync(profileNm) : []
|
|
882
1253
|
let entry = null
|
|
@@ -944,11 +1315,10 @@ export default {
|
|
|
944
1315
|
let maxLine = -Infinity
|
|
945
1316
|
for (const idx of indices) {
|
|
946
1317
|
const raw = edits[idx]
|
|
947
|
-
|
|
948
|
-
if (Array.isArray(raw) && raw.length >= 3) e = { remove_from: raw[0], remove_to: raw[1], replacement_text: raw[2] }
|
|
1318
|
+
const e = editTuple(raw)
|
|
949
1319
|
const fromHash = betterEditAnchor(e && e.remove_from)
|
|
950
1320
|
const toHash = betterEditAnchor(e && e.remove_to)
|
|
951
|
-
const repl = (
|
|
1321
|
+
const repl = editReplNorm(raw)
|
|
952
1322
|
if (!fromHash || !toHash) return null
|
|
953
1323
|
// 在当前(已部分应用)的内容上找锚点:仅未变行的原 hash 有效
|
|
954
1324
|
const start = curHash.indexOf(fromHash)
|
|
@@ -978,18 +1348,227 @@ export default {
|
|
|
978
1348
|
} catch (e) { return null }
|
|
979
1349
|
}
|
|
980
1350
|
|
|
1351
|
+
// 撤销(undo_last_edit)的对比数据:读 better-edit 的 undo 行取「撤销后内容」,
|
|
1352
|
+
// 与磁盘当前内容做窗口 diff。文件在编辑后被改动时 better-edit 会拒绝撤销(E_UNDO_STALE),
|
|
1353
|
+
// 此处按同一条件提示,避免展示一次不会执行的变化。
|
|
1354
|
+
// 窗口化 diff 共用:给定变化区(1 基 changeStart 起始行 + old/new 侧变化行数),
|
|
1355
|
+
// 生成前后各 W 行的展示窗口并交给 diffPayloadOrFallback。undo/insert 预览共用,
|
|
1356
|
+
// 避免窗口公式多份独立演化(曾因此产生 insert 预览 off-by-one)。
|
|
1357
|
+
function windowedDiffPayload(fp, oldLines, newLines, changeStart, oldSpan, newSpan, W) {
|
|
1358
|
+
const winStart = Math.max(1, changeStart - W)
|
|
1359
|
+
const winOldText = oldLines.slice(winStart - 1, Math.min(oldLines.length, changeStart - 1 + oldSpan + W)).join('\n')
|
|
1360
|
+
const winNewText = newLines.slice(winStart - 1, Math.min(newLines.length, changeStart - 1 + newSpan + W)).join('\n')
|
|
1361
|
+
return diffPayloadOrFallback(fp, winOldText, winNewText, 'modified', winStart)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// 插入预览统一:内置(0 基 after)与 shadow(1 基 before)只差插入点索引与上限的换算,
|
|
1365
|
+
// 参数校验、越界文案与窗口渲染全部共用,避免两侧独立演化(该公式历史上出现过 off-by-one)
|
|
1366
|
+
function previewInsert(fp, oldLines, addedLines, at, maxAt) {
|
|
1367
|
+
if (!Number.isInteger(at) || at < 0) return { ok: false, error: bi('insert_line 无效,无法预览', 'Invalid insert_line; cannot preview') }
|
|
1368
|
+
if (at > maxAt) return { ok: false, error: bi('插入位置超出文件范围', 'Insert position is beyond end of file') }
|
|
1369
|
+
// 只构造窗口范围(±W 行)再交给 diffPayloadOrFallback:避免为渲染小窗口深拷贝整文件行数组
|
|
1370
|
+
const W = 200
|
|
1371
|
+
const winStart = Math.max(0, at - W)
|
|
1372
|
+
const winEnd = Math.min(oldLines.length, at + W)
|
|
1373
|
+
const winOld = oldLines.slice(winStart, winEnd)
|
|
1374
|
+
const k = at - winStart
|
|
1375
|
+
const winNew = winOld.slice(0, k).concat(addedLines, winOld.slice(k))
|
|
1376
|
+
return diffPayloadOrFallback(fp, winOld.join('\n'), winNew.join('\n'), 'modified', winStart + 1)
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
async function buildUndoDiffData(entry, fsService, fp) {
|
|
1380
|
+
if (!fp) return { ok: false, error: bi('缺少文件路径', 'Missing file path') }
|
|
1381
|
+
let row = null
|
|
1382
|
+
// 先 stat + size 预检,再读 DB:避免 >1MB 文件先全量载入 undo 大行(content/result_content 各约等于文件大小)
|
|
1383
|
+
const st = await statTargetChecked(fp, entry.projRoot, fsService)
|
|
1384
|
+
if (!st.ok) return st
|
|
1385
|
+
const target = st.target
|
|
1386
|
+
const info = st.info
|
|
1387
|
+
try {
|
|
1388
|
+
const storePath = betterEditStoreFor(entry.projRoot)
|
|
1389
|
+
const resolved0 = target
|
|
1390
|
+
if (storePath) {
|
|
1391
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
1392
|
+
const db = new DatabaseSync(storePath, { readOnly: true })
|
|
1393
|
+
try {
|
|
1394
|
+
const rawPath = extractStorePath(resolved0, fp)
|
|
1395
|
+
const want = normPathKey(rawPath)
|
|
1396
|
+
// undo 行 content/result_content 为历史全文(编辑时大小、无上限):读行前先做 SQL 层
|
|
1397
|
+
// 大小预检,避免 >1MB 历史行被全量载入后才被字符数兜底拒绝(WHERE 精确命中时有效)
|
|
1398
|
+
const lenRow = db.prepare('SELECT LENGTH(content) + LENGTH(result_content) AS total FROM undo WHERE path = ?').get(String(rawPath || ''))
|
|
1399
|
+
if (lenRow && typeof lenRow.total === 'number' && lenRow.total > DIFF_MAX_CHARS) {
|
|
1400
|
+
return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
|
|
1401
|
+
}
|
|
1402
|
+
// 优先按 path 精确查询(undo 表 path 是主键,可走索引,避免全表载入全部历史编辑内容);
|
|
1403
|
+
// 存储格式与磁盘路径可能不完全一致(大小写/斜杠),miss 时回退全表按归一化匹配。
|
|
1404
|
+
row = storeRowByPath(db, 'undo', 'path, content, result_content, bom, ending', rawPath, want)
|
|
1405
|
+
} finally { try { db.close() } catch (e) {} }
|
|
1406
|
+
}
|
|
1407
|
+
} catch (e) { invalidateStoreCache(entry.projRoot); row = null }
|
|
1408
|
+
if (!row) return { ok: false, error: bi('该文件没有可撤销的编辑记录,撤销会被跳过', 'No undo history for this file; the undo will be skipped') }
|
|
1409
|
+
try {
|
|
1410
|
+
const curText = await fsService.readText(target)
|
|
1411
|
+
const after = row.content === null || row.content === undefined ? '' : String(row.content)
|
|
1412
|
+
if (overMaxChars(curText, after)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
|
|
1413
|
+
// 与 better-edit 的校验口径对齐:better-edit 用 undo 行的 bom/ending 做字节级精确比较,
|
|
1414
|
+
// 撤销校验含 BOM 与行尾。这里先做归一化比较,再对 BOM/行尾做敏感复核——
|
|
1415
|
+
// 仅行尾(CRLF↔LF)或 BOM 差异的覆盖在 better-edit 会返回 E_UNDO_STALE,预览同步按 stale 处理。
|
|
1416
|
+
const resultContent = row.result_content === null || row.result_content === undefined ? '' : String(row.result_content)
|
|
1417
|
+
const normTxt = (s) => normEol(String(s || '').replace(/^\uFEFF/, ''))
|
|
1418
|
+
if (normTxt(curText) !== normTxt(resultContent)) {
|
|
1419
|
+
return { ok: false, error: bi('文件在该次编辑后已被改动,撤销不会执行(无变化)', 'File changed after that edit; the undo will not run (no change)') }
|
|
1420
|
+
}
|
|
1421
|
+
const undoBom = row.bom === '\uFEFF' ? '\uFEFF' : ''
|
|
1422
|
+
const undoEnding = row.ending === '\r\n' || row.ending === '\r' ? row.ending : '\n'
|
|
1423
|
+
// 磁盘真实 BOM 状态:readText 经 TextDecoder 解码会剥离前导 BOM,故直接读前 3 字节比对
|
|
1424
|
+
// EF BB BF;读不到时回退到「stat.size 与文本字节数比对」的旧判据,与 undo.bom 不一致视为 stale
|
|
1425
|
+
if (typeof info.size === 'number') {
|
|
1426
|
+
let diskHasBom = false
|
|
1427
|
+
try {
|
|
1428
|
+
// readBytes 的 maxBytes 是「整文件上限」(超限直接抛 FS_TOO_LARGE),
|
|
1429
|
+
// 取文件头要用区间读取;不支持时由 catch 回退到 size 判据
|
|
1430
|
+
const head = (fsService.readByteRange && target) ? await fsService.readByteRange(target, { offset: 0, length: 3 }, undefined) : null
|
|
1431
|
+
diskHasBom = !!(head && head.length >= 3 && head[0] === 0xEF && head[1] === 0xBB && head[2] === 0xBF)
|
|
1432
|
+
} catch (e) {
|
|
1433
|
+
diskHasBom = info.size === Buffer.byteLength(curText, 'utf8') + 3
|
|
1434
|
+
}
|
|
1435
|
+
const wantBom = undoBom !== ''
|
|
1436
|
+
if (diskHasBom !== wantBom) {
|
|
1437
|
+
return { ok: false, error: bi('文件 BOM 在该次编辑后已被改动,撤销不会执行(无变化)', 'File BOM changed after that edit; the undo will not run (no change)') }
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
// 内容与行尾精确比较:curText 保留原始 CRLF;resultContent 为 \n 规范化存储,按 undo.ending 还原
|
|
1441
|
+
// undoEnding 为 '\n'(LF 文件,最常见)时该 replace 是恒等变换,短路避免整串副本
|
|
1442
|
+
const exactResult = undoEnding === '\n' ? String(resultContent) : String(resultContent).replace(/\n/g, undoEnding)
|
|
1443
|
+
if (curText !== exactResult) {
|
|
1444
|
+
return { ok: false, error: bi('文件行尾/编码在该次编辑后已被改动,撤销不会执行(无变化)', 'File line endings or encoding changed after that edit; the undo will not run (no change)') }
|
|
1445
|
+
}
|
|
1446
|
+
const oldLines = splitDiffLines(curText)
|
|
1447
|
+
const newLines = splitDiffLines(after)
|
|
1448
|
+
const p = commonPrefixLen(oldLines, newLines)
|
|
1449
|
+
const s = commonSuffixLen(oldLines, newLines, p)
|
|
1450
|
+
return windowedDiffPayload(fp, oldLines, newLines, p + 1, oldLines.length - s - p, newLines.length - s - p, 200)
|
|
1451
|
+
} catch (e) {
|
|
1452
|
+
return { ok: false, error: readFail(e) }
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
// edits 条目统一解析:元组 [remove_from, remove_to, replacement_text] 或对象 {remove_from, remove_to, replacement_text}
|
|
1456
|
+
function editTuple(raw) {
|
|
1457
|
+
if (Array.isArray(raw) && raw.length >= 3) return { remove_from: raw[0], remove_to: raw[1], replacement_text: raw[2] }
|
|
1458
|
+
return raw || {}
|
|
1459
|
+
}
|
|
1460
|
+
// replacement_text 统一提取(不含行尾归一化,供「过大」上限等上界用途)
|
|
1461
|
+
function editReplacement(raw) {
|
|
1462
|
+
const t = editTuple(raw)
|
|
1463
|
+
return typeof t.replacement_text === 'string' ? t.replacement_text : ''
|
|
1464
|
+
}
|
|
1465
|
+
// replacement_text 统一提取(行尾归一化,供补丁应用/窗口 diff 用途)
|
|
1466
|
+
function editReplNorm(raw) {
|
|
1467
|
+
const t = editTuple(raw)
|
|
1468
|
+
return (t && typeof t.replacement_text === 'string') ? t.replacement_text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') : ''
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
// 统一「resolve→stat→存在/类型/size」预检(不 readText):readTargetChecked、undo 预检与
|
|
1473
|
+
// 图片详情(skipSizeCheck + 自定义「不存在」文案)共用,
|
|
1474
|
+
// 避免预检检查与文案多份独立演化(曾因此出现 size 预检形式漂移)
|
|
1475
|
+
// 配置目标存在性判定(守卫方向敏感):stat 失败按「存在」处理——
|
|
1476
|
+
// 宁可拒绝写入并提示,也不静默覆盖已有配置
|
|
1477
|
+
async function configExists(fsService, p) {
|
|
1478
|
+
try { return (await fsService.stat(p)) !== undefined } catch (e) { return true }
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// opts.skipSizeCheck:图片详情另有自己的体积上限(IMAGE_MAX_BYTES),不套用 DIFF_MAX_CHARS 预检
|
|
1482
|
+
// opts.notFoundZh/notFoundEn:调用方覆盖「文件不存在」文案(图片详情用「图片不存在」)
|
|
1483
|
+
async function statTargetChecked(fp, projRoot, fsService, opts) {
|
|
1484
|
+
const o = opts || {}
|
|
1485
|
+
try {
|
|
1486
|
+
const target = await fsService.resolve(resolveArgPath(fp, projRoot))
|
|
1487
|
+
const info = await fsService.stat(target)
|
|
1488
|
+
if (info === undefined) return { ok: false, error: bi(o.notFoundZh || '文件不存在', o.notFoundEn || 'File not found') }
|
|
1489
|
+
if (info.type !== 'file') return { ok: false, error: bi('不是普通文件', 'Not a regular file') }
|
|
1490
|
+
if (!o.skipSizeCheck && fileTooLarge(info, DIFF_MAX_CHARS)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
|
|
1491
|
+
return { ok: true, target, info }
|
|
1492
|
+
} catch (e) {
|
|
1493
|
+
return { ok: false, error: readFail(e) }
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// 统一「预检 + readText」:返回 {ok:true,target,info,text} 或 {ok:false,error}。
|
|
1498
|
+
// 写分支各读盘入口共用;preText 非空时直接复用(调用方已完成预检读盘,如 str_replace 唯一性检查),避免双读盘
|
|
1499
|
+
async function readTargetChecked(fp, projRoot, fsService, preText) {
|
|
1500
|
+
if (preText !== null && preText !== undefined) return { ok: true, target: null, info: null, text: preText }
|
|
1501
|
+
const st = await statTargetChecked(fp, projRoot, fsService)
|
|
1502
|
+
if (!st.ok) return st
|
|
1503
|
+
try {
|
|
1504
|
+
const text = await fsService.readText(st.target)
|
|
1505
|
+
return { ok: true, target: st.target, info: st.info, text }
|
|
1506
|
+
} catch (e) {
|
|
1507
|
+
return { ok: false, error: readFail(e) }
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
981
1511
|
// 按审批 entry 生成对比数据(/permgate/file-diff 路由用;失败返回 {ok:false,error},不支持返回 null)
|
|
1512
|
+
// 图片详情数据:格式/尺寸 + 缩略图(data URL)。失败一律 {ok:false,error},客户端显示错误文本;
|
|
1513
|
+
// 超过体积上限则只给格式/尺寸并标记 tooLarge,不返回图片本体。
|
|
1514
|
+
async function buildImageDiffData(entry, fsService, fp) {
|
|
1515
|
+
if (!fp) return { ok: false, error: bi('缺少文件路径', 'Missing file path') }
|
|
1516
|
+
// 预检复用 statTargetChecked:跳过文本 diff 的字符数上限(图片另有 IMAGE_MAX_BYTES)
|
|
1517
|
+
const st = await statTargetChecked(fp, entry.projRoot, fsService, { skipSizeCheck: true, notFoundZh: '图片不存在', notFoundEn: 'Image not found' })
|
|
1518
|
+
if (!st.ok) return { ok: false, error: st.error }
|
|
1519
|
+
const target = st.target
|
|
1520
|
+
const size = Number(st.info.size) || 0
|
|
1521
|
+
let head = null
|
|
1522
|
+
let whole = null
|
|
1523
|
+
try {
|
|
1524
|
+
// 体积在预览预算内:一次性整读,既用于嗅探也直接用于内联,避免同一文件被读两遍
|
|
1525
|
+
// (照片常见的 64KB~2MB 区间原本会读两次);体积超预算或读不到 size 时只读头部窗口,
|
|
1526
|
+
// 先确认格式与尺寸,再决定要不要整读。
|
|
1527
|
+
if (size > 0 && size <= IMAGE_MAX_BYTES) {
|
|
1528
|
+
whole = await fsService.readBytes(target, undefined, IMAGE_MAX_BYTES)
|
|
1529
|
+
head = whole.subarray(0, Math.min(whole.length, IMAGE_HEAD_BYTES))
|
|
1530
|
+
} else {
|
|
1531
|
+
const len = Math.max(16, Math.min(size || IMAGE_HEAD_BYTES, IMAGE_HEAD_BYTES))
|
|
1532
|
+
head = await fsService.readByteRange(target, { offset: 0, length: len }, undefined)
|
|
1533
|
+
}
|
|
1534
|
+
} catch (e) {
|
|
1535
|
+
return { ok: false, error: readFail(e) }
|
|
1536
|
+
}
|
|
1537
|
+
// 已整读时直接用整份缓冲嗅探:JPEG 的 SOF 段可能落在 64KB 头部窗口之外,
|
|
1538
|
+
// 而已持有全部字节(≤ IMAGE_MAX_BYTES),不必因为窗口取不到尺寸就放弃预览。
|
|
1539
|
+
const meta = sniffImage(whole || head)
|
|
1540
|
+
if (!meta) return { ok: false, error: bi('无法预览:不是可识别的图片(仅支持 PNG/JPEG/WebP/GIF)', 'Cannot preview: not a recognized image (PNG/JPEG/WebP/GIF)') }
|
|
1541
|
+
const out = { ok: true, kind: 'image', file: fp, format: meta.format, mime: IMAGE_MIME[meta.format] || '', width: meta.width, height: meta.height, size }
|
|
1542
|
+
// 尺寸未知(JPEG 的 SOF 段被 >64KB 的元数据段推到头部窗口之外、畸形段、WebP 未知 chunk 等)时,
|
|
1543
|
+
// 像素与边长闸无从判断,一律不内联本体,否则「弹窗解码预算」会被 2MB 以内的高压缩比图绕过。
|
|
1544
|
+
const sizeKnown = !!(meta.width && meta.height)
|
|
1545
|
+
const pixelOver = !!(sizeKnown && (meta.width * meta.height > IMAGE_MAX_PIXELS || meta.width > IMAGE_MAX_DIM || meta.height > IMAGE_MAX_DIM))
|
|
1546
|
+
if (!sizeKnown) { out.sizeUnknown = true; return out }
|
|
1547
|
+
if (size > IMAGE_MAX_BYTES || pixelOver) { out.tooLarge = true; if (size > IMAGE_MAX_BYTES) out.limit = IMAGE_MAX_BYTES; return out }
|
|
1548
|
+
try {
|
|
1549
|
+
// 预算内已整读过就直接复用;否则(头部窗口内已判合规而 size 未知)再整读一次
|
|
1550
|
+
const bytes = whole || await fsService.readBytes(target, undefined, IMAGE_MAX_BYTES)
|
|
1551
|
+
out.dataUrl = 'data:' + (out.mime || 'application/octet-stream') + ';base64,' + Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64')
|
|
1552
|
+
} catch (e) {
|
|
1553
|
+
// 不带图片本体,客户端按「暂无可用的缩略图」提示;记录真实错误,避免失败原因不可见
|
|
1554
|
+
console.error('[permgate] image preview error:', e)
|
|
1555
|
+
}
|
|
1556
|
+
return out
|
|
1557
|
+
}
|
|
1558
|
+
|
|
982
1559
|
async function buildFileDiffData(entry, fsService) {
|
|
983
1560
|
const name = entry.tool
|
|
984
1561
|
const args = parseEntryArgs(entry)
|
|
985
1562
|
const fp = pathArg(args)
|
|
986
|
-
if (
|
|
1563
|
+
if (isUndo(name)) return await buildUndoDiffData(entry, fsService, fp)
|
|
1564
|
+
if (isFileImage(name)) return await buildImageDiffData(entry, fsService, fp)
|
|
1565
|
+
if (isFileRead(name, args)) {
|
|
987
1566
|
if (!fp) return { ok: false, error: bi('缺少文件路径', 'Missing file path') }
|
|
1567
|
+
// 与图片/撤销详情共用预检单点;read 走窗口化读取,不需要 DIFF_MAX_CHARS 体积闸
|
|
1568
|
+
const st = await statTargetChecked(fp, entry.projRoot, fsService, { skipSizeCheck: true })
|
|
1569
|
+
if (!st.ok) return st
|
|
988
1570
|
try {
|
|
989
|
-
const target =
|
|
990
|
-
const info = await fsService.stat(target)
|
|
991
|
-
if (info === undefined) return { ok: false, error: bi('文件不存在', 'File not found') }
|
|
992
|
-
if (info.type !== 'file') return { ok: false, error: bi('不是普通文件', 'Not a regular file') }
|
|
1571
|
+
const target = st.target
|
|
993
1572
|
// 窗口化读取:只取 offset/limit 附近(前后各 W 行)的内容,流式消费到窗口末尾即停,
|
|
994
1573
|
// 不整读大文件;末尾省略行数未知,由客户端显示通用提示。
|
|
995
1574
|
// 资源上限:offset/limit 来自 agent 工具参数(不可信),且文件中可能存在无换行的
|
|
@@ -999,8 +1578,23 @@ export default {
|
|
|
999
1578
|
const MAX_LIMIT = 4096
|
|
1000
1579
|
const MAX_BYTES = 262144
|
|
1001
1580
|
const MAX_LINE = 65536
|
|
1002
|
-
|
|
1003
|
-
|
|
1581
|
+
// str_replace_editor 的 view 用 view_range([start, end],1 基,end=-1 表示到文件尾);
|
|
1582
|
+
// 预览据此换算 offset/limit,否则展示区域与实际读取不符(恒为文件开头)
|
|
1583
|
+
let vOffset = args.offset
|
|
1584
|
+
let vLimit = args.limit
|
|
1585
|
+
if (name === 'str_replace_editor' && Array.isArray(args.view_range) && args.view_range.length >= 2) {
|
|
1586
|
+
const vs = Number(args.view_range[0])
|
|
1587
|
+
const ve = Number(args.view_range[1])
|
|
1588
|
+
if (Number.isFinite(vs) && vs > 0) {
|
|
1589
|
+
vOffset = vs
|
|
1590
|
+
// end=-1 表示到文件尾(受 MAX_LIMIT 截断);end<start 等非法组合会被工具报错,预览同样提示失败
|
|
1591
|
+
if (Number.isFinite(ve) && ve === -1) vLimit = MAX_LIMIT
|
|
1592
|
+
else if (Number.isFinite(ve) && ve >= vs) vLimit = ve - vs + 1
|
|
1593
|
+
else return { ok: false, error: bi('view_range 不合法,该命令将失败(无改动可预览)', 'Invalid view_range; the command will fail (no change to preview)') }
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
const offset = Number.isFinite(vOffset) && vOffset > 0 ? Math.floor(vOffset) : 1
|
|
1597
|
+
const limit = Math.min(Number.isFinite(vLimit) && vLimit > 0 ? Math.floor(vLimit) : 200, MAX_LIMIT)
|
|
1004
1598
|
const winStart = Math.max(1, offset - W)
|
|
1005
1599
|
const winEnd = offset + limit - 1 + W
|
|
1006
1600
|
const out = []
|
|
@@ -1050,196 +1644,240 @@ export default {
|
|
|
1050
1644
|
} catch (e) {
|
|
1051
1645
|
// 注意:不能与字符串直接拼接(bi() 返回 {zh,en} 对象,+ 会得到 "[object Object]");
|
|
1052
1646
|
// 返回双语对象,由路由侧 L(r.error, lang) 按语言取值。
|
|
1053
|
-
|
|
1054
|
-
return { ok: false, error: { zh: '读取失败: ' + emsg, en: 'Read failed: ' + emsg } }
|
|
1647
|
+
return { ok: false, error: readFail(e) }
|
|
1055
1648
|
}
|
|
1056
1649
|
}
|
|
1057
|
-
|
|
1650
|
+
// str_replace_editor 的 undo_edit 命令不写盘(better-edit 直接抛 E_UNSUPPORTED),无改动可预览
|
|
1651
|
+
if (name === 'str_replace_editor' && sreCommand(args) === 'undo_edit') {
|
|
1652
|
+
return { ok: false, error: bi('该命令没有可预览的改动', 'This command has no previewable change') }
|
|
1653
|
+
}
|
|
1654
|
+
if (isFileWrite(name, args)) {
|
|
1655
|
+
// str_replace 唯一性检查已读盘时缓存文本,供下方 edit 分支复用,避免同一文件双读盘
|
|
1656
|
+
let sreText = null
|
|
1058
1657
|
if (!fp) return { ok: false, error: bi('缺少文件路径', 'Missing file path') }
|
|
1059
|
-
|
|
1658
|
+
// str_replace_editor 参数适配:折算成 write/edit 的等价形式复用下面的成熟路径
|
|
1659
|
+
// (create→write 全文;str_replace/insert→edit 补丁;undo_edit 无改动内容可预览)
|
|
1660
|
+
let tool = name
|
|
1661
|
+
// sreKind:本次预览的 str_replace_editor 子命令(非 sre 工具时为空串),
|
|
1662
|
+
// 供下方 write 分支特判 create 复用,避免同一命令在两处各判一次
|
|
1663
|
+
let sreKind = ''
|
|
1664
|
+
// str_replace_editor 只有被 shadow 覆盖时 insert_line 才是 1 基/插到该行之前;
|
|
1665
|
+
// 内核在审批发起时判定并存入 entry(entry 生命周期内不变);resolveEditorKernel 恒返回
|
|
1666
|
+
// builtin|shadow,故这里只在异常数据下用 'builtin' 兜底,不再重复做一次探测
|
|
1667
|
+
const kernel = entry.editorKernel || 'builtin'
|
|
1668
|
+
if (name === 'str_replace_editor') {
|
|
1669
|
+
const cmd = sreCommand(args)
|
|
1670
|
+
sreKind = cmd
|
|
1671
|
+
if (cmd === 'create') {
|
|
1672
|
+
// create 拒绝覆盖已存在文件(E_FILE_EXISTS,不写盘):已存在时不能生成覆盖 diff。
|
|
1673
|
+
// 存在性检查由下方 write 分支的 stat 承担(此处不再重复 resolve+stat)
|
|
1674
|
+
tool = 'write'
|
|
1675
|
+
args.content = typeof args.file_text === 'string' ? args.file_text : ''
|
|
1676
|
+
} else if (cmd === 'str_replace') {
|
|
1677
|
+
// 两种内核在 old_str 多次匹配时都拒绝写盘(内置抛 FS_AMBIGUOUS_EDIT、shadow 同要求唯一匹配),
|
|
1678
|
+
// 故不做内核分叉:一律按「不唯一即失败」提示,避免展示一次永远不会发生的替换
|
|
1679
|
+
const oldStr = typeof args.old_str === 'string' ? args.old_str : ''
|
|
1680
|
+
if (oldStr) {
|
|
1681
|
+
const rd = await readTargetChecked(fp, entry.projRoot, fsService)
|
|
1682
|
+
if (!rd.ok) return rd
|
|
1683
|
+
let count = 0
|
|
1684
|
+
let at = 0
|
|
1685
|
+
while (count < 2 && (at = rd.text.indexOf(oldStr, at)) !== -1) { count++; at += oldStr.length }
|
|
1686
|
+
if (count > 1) {
|
|
1687
|
+
return { ok: false, error: bi('old_str 出现多次,该命令将失败(无改动可预览)', 'old_str occurs multiple times; the command will fail (no change to preview)') }
|
|
1688
|
+
}
|
|
1689
|
+
sreText = rd.text
|
|
1690
|
+
}
|
|
1691
|
+
tool = 'edit'
|
|
1692
|
+
args.old_string = oldStr
|
|
1693
|
+
args.new_string = typeof args.new_str === 'string' ? args.new_str : ''
|
|
1694
|
+
} else if (cmd === 'insert') {
|
|
1695
|
+
// insert 的 insert_line 语义随内核相反:DSH 内置 0 基、插到该行之后(官方语义);
|
|
1696
|
+
// dsh-better-edit shadow 1 基、插到该行之前。按审批发起时判定的内核解释,
|
|
1697
|
+
// 否则会把插入位置画到错误的地方。
|
|
1698
|
+
// 不能折算成 old_string='' 的 edit —— 那会让 rawIdx 恒为 -1,插入位置被伪造成
|
|
1699
|
+
// 「文件开头第 1 行」。这里读盘后按真实插入点生成窗口 diff,行号与实际执行一致。
|
|
1700
|
+
// null/''/false 等占位值不得折算为 0:内置取参把 null 视为未提供并报 required,
|
|
1701
|
+
// 折算成 0 会被当成合法的 0 基位置,预览出一次必定失败的插入
|
|
1702
|
+
const rawInsLine = args.insert_line
|
|
1703
|
+
const insLine = (rawInsLine === null || rawInsLine === undefined || rawInsLine === '' || rawInsLine === false) ? NaN : Number(rawInsLine)
|
|
1704
|
+
const insText = typeof args.new_str === 'string' ? args.new_str : ''
|
|
1705
|
+
const rd = await readTargetChecked(fp, entry.projRoot, fsService)
|
|
1706
|
+
if (!rd.ok) return rd
|
|
1707
|
+
const fileText = rd.text
|
|
1708
|
+
if (overMaxChars(fileText, insText)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
|
|
1709
|
+
const addedLines = splitDiffLines(insText)
|
|
1710
|
+
if (kernel === 'builtin') {
|
|
1711
|
+
// 官方语义:insert_line 0 基,插入到该 index 之前(= 第 insert_line 行之后),范围 [0, 行数]
|
|
1712
|
+
const oldLines = splitDiffLines(fileText)
|
|
1713
|
+
return previewInsert(fp, oldLines, addedLines, insLine, oldLines.length)
|
|
1714
|
+
}
|
|
1715
|
+
// shadow:insert_line 1 基、插入到该行之前;空文件行数组为 []、上限「去尾换行行数 + 1」
|
|
1716
|
+
const oldLines = fileText.length === 0 ? [] : splitDiffLines(fileText)
|
|
1717
|
+
const maxInsert = fileText.length === 0 ? 1 : (fileText.endsWith('\n') ? oldLines.length : oldLines.length + 1)
|
|
1718
|
+
return previewInsert(fp, oldLines, addedLines, insLine - 1, maxInsert - 1)
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
if (tool === 'edit') {
|
|
1060
1722
|
// dsh-better-edit 兼容:{path, edits:[[remove_from,remove_to,replacement_text],...]} hash 锚点格式。
|
|
1061
1723
|
// 与旧格式(old_string/new_string)互斥,优先识别 edits 数组。
|
|
1062
1724
|
if (Array.isArray(args.edits) && args.edits.length > 0) {
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1725
|
+
const rd = await readTargetChecked(fp, entry.projRoot, fsService)
|
|
1726
|
+
if (!rd.ok) return rd
|
|
1727
|
+
const fileText = rd.text
|
|
1728
|
+
const target = rd.target
|
|
1729
|
+
// 「文件过大」兜底:磁盘全文 + 全部 replacement_text 总长(agent 可控,必须计入上限)
|
|
1730
|
+
let replTotal = 0
|
|
1731
|
+
for (const raw of args.edits) { replTotal += editReplacement(raw).length }
|
|
1732
|
+
if (overMaxChars(fileText, replTotal)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
|
|
1733
|
+
// 1) 优先 store 快照;2) 快照过期则从磁盘内容重算(xxh32 复刻);3) 都失败再降级
|
|
1734
|
+
let hashes = await betterEditHashesFor(entry.projRoot, target, fp)
|
|
1735
|
+
if (!hashes || !Array.isArray(hashes) || hashes.length === 0) {
|
|
1736
|
+
hashes = await betterEditHashesFromDisk(fileText)
|
|
1737
|
+
}
|
|
1738
|
+
if (!hashes || !Array.isArray(hashes) || hashes.length === 0) {
|
|
1739
|
+
// 无任何 hash 来源:无法映射锚点,退回补丁意图展示(至少显示替换文本)
|
|
1740
|
+
const intent = args.edits.map(editReplacement).join('\n')
|
|
1741
|
+
return diffPayloadOrFallback(fp, '', intent, 'modified')
|
|
1742
|
+
}
|
|
1743
|
+
const applied = applyBetterEdits(fileText, args.edits, hashes)
|
|
1744
|
+
if (applied === null) {
|
|
1745
|
+
// 锚点失效或 store 与磁盘不一致:退回补丁意图展示
|
|
1746
|
+
const intent = args.edits.map(editReplacement).join('\n')
|
|
1747
|
+
return diffPayloadOrFallback(fp, '', intent, 'modified')
|
|
1748
|
+
}
|
|
1749
|
+
// 分窗口 diff:把相距较远的 edits 分成多组(相邻间隔 ≤ 2W 同组),
|
|
1750
|
+
// 每组独立生成一个小窗口 diff 再拼接——避免单个大窗口把中间大段未变内容
|
|
1751
|
+
// 算成 +N/-N 假变更(如 +300 -300)。
|
|
1752
|
+
// 关键:new 侧不从「整文件应用后按行号切片」——行数变化(如 1 行换 52 行)会让
|
|
1753
|
+
// new 侧整体偏移,窗口尾部与 old 错位,把大量未变行误判为变更(+52/-52、+342/-342
|
|
1754
|
+
// 等假象)。改为以 old 窗口行为基底、仅在该窗口内应用本组 edits(跟踪 offset),
|
|
1755
|
+
// 使 old/new 覆盖同一内容区域、行号天然对齐。
|
|
1756
|
+
const W = 200
|
|
1757
|
+
const oldLines = splitDiffLines(String(fileText).replace(/\r\n/g, '\n').replace(/\r/g, '\n'))
|
|
1758
|
+
// 每个 edit 的原始行区间(0 基)
|
|
1759
|
+
const editRanges = args.edits.map((raw) => {
|
|
1760
|
+
const e = editTuple(raw)
|
|
1761
|
+
const a = betterEditAnchor(e && e.remove_from)
|
|
1762
|
+
const b = betterEditAnchor(e && e.remove_to)
|
|
1763
|
+
let s = -1, t = -1
|
|
1764
|
+
if (a && hashes) { const i = hashes.indexOf(a); if (i >= 0) s = i }
|
|
1765
|
+
if (b && hashes) { const i = hashes.indexOf(b); if (i >= 0) t = i }
|
|
1766
|
+
if (s < 0 && t >= 0) s = t
|
|
1767
|
+
if (t < 0 && s >= 0) t = s
|
|
1768
|
+
return { s, t }
|
|
1769
|
+
})
|
|
1770
|
+
// 分组:按起始行排序,间隔 > 2W 开新组
|
|
1771
|
+
const order = args.edits.map((_, i) => i).sort((x, y) => editRanges[x].s - editRanges[y].s)
|
|
1772
|
+
const groups = []
|
|
1773
|
+
let cur = null
|
|
1774
|
+
for (const i of order) {
|
|
1775
|
+
const line = editRanges[i].s
|
|
1776
|
+
if (line < 0) continue
|
|
1777
|
+
if (!cur || line - cur.max > 2 * W) {
|
|
1778
|
+
cur = { min: line, max: line, indices: [i] }
|
|
1779
|
+
groups.push(cur)
|
|
1780
|
+
} else {
|
|
1781
|
+
cur.max = Math.max(cur.max, line)
|
|
1782
|
+
cur.indices.push(i)
|
|
1074
1783
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1784
|
+
}
|
|
1785
|
+
const allOps = []
|
|
1786
|
+
let totalAdded = 0
|
|
1787
|
+
let totalRemoved = 0
|
|
1788
|
+
for (const g of groups) {
|
|
1789
|
+
let gMin0 = Infinity, gMax0 = -Infinity
|
|
1790
|
+
for (const i of g.indices) {
|
|
1791
|
+
const r = editRanges[i]
|
|
1792
|
+
if (r.s < gMin0) gMin0 = r.s
|
|
1793
|
+
if (r.t > gMax0) gMax0 = r.t
|
|
1082
1794
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1795
|
+
if (gMin0 === Infinity) continue
|
|
1796
|
+
const gMin = Math.max(1, gMin0 + 1 - W)
|
|
1797
|
+
const gOldEnd = Math.min(oldLines.length, gMax0 + 1 + W)
|
|
1798
|
+
// 新侧:以 old 窗口为基底,仅应用本组 edits,跟踪 offset
|
|
1799
|
+
const local = oldLines.slice(gMin - 1, gOldEnd)
|
|
1800
|
+
let off = 0
|
|
1801
|
+
let resolved = true
|
|
1802
|
+
for (const i of g.indices) {
|
|
1803
|
+
const r = editRanges[i]
|
|
1804
|
+
const raw = args.edits[i]
|
|
1805
|
+
const repl = editReplNorm(raw)
|
|
1806
|
+
const ls = r.s - (gMin - 1) + off
|
|
1807
|
+
const lt = r.t - (gMin - 1) + off
|
|
1808
|
+
if (ls < 0 || lt < ls || lt > local.length) { resolved = false; break }
|
|
1809
|
+
const replLines = repl === '' ? [] : repl.split('\n')
|
|
1810
|
+
local.splice(ls, lt - ls + 1, ...replLines)
|
|
1811
|
+
off += replLines.length - (lt - ls + 1)
|
|
1091
1812
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
const a = betterEditAnchor(e && e.remove_from)
|
|
1105
|
-
const b = betterEditAnchor(e && e.remove_to)
|
|
1106
|
-
let s = -1, t = -1
|
|
1107
|
-
if (a && hashes) { const i = hashes.indexOf(a); if (i >= 0) s = i }
|
|
1108
|
-
if (b && hashes) { const i = hashes.indexOf(b); if (i >= 0) t = i }
|
|
1109
|
-
if (s < 0 && t >= 0) s = t
|
|
1110
|
-
if (t < 0 && s >= 0) t = s
|
|
1111
|
-
return { s, t }
|
|
1112
|
-
})
|
|
1113
|
-
// 分组:按起始行排序,间隔 > 2W 开新组
|
|
1114
|
-
const order = args.edits.map((_, i) => i).sort((x, y) => editRanges[x].s - editRanges[y].s)
|
|
1115
|
-
const groups = []
|
|
1116
|
-
let cur = null
|
|
1117
|
-
for (const i of order) {
|
|
1118
|
-
const line = editRanges[i].s
|
|
1119
|
-
if (line < 0) continue
|
|
1120
|
-
if (!cur || line - cur.max > 2 * W) {
|
|
1121
|
-
cur = { min: line, max: line, indices: [i] }
|
|
1122
|
-
groups.push(cur)
|
|
1123
|
-
} else {
|
|
1124
|
-
cur.max = Math.max(cur.max, line)
|
|
1125
|
-
cur.indices.push(i)
|
|
1126
|
-
}
|
|
1813
|
+
if (!resolved) continue
|
|
1814
|
+
const oldWin = oldLines.slice(gMin - 1, gOldEnd).join('\n')
|
|
1815
|
+
const newWin = local.join('\n')
|
|
1816
|
+
const p = diffPayloadOrFallback(fp, oldWin, newWin, 'modified', gMin)
|
|
1817
|
+
if (!p || !p.ok) continue
|
|
1818
|
+
if (p.fallback) return p
|
|
1819
|
+
totalAdded += p.added || 0
|
|
1820
|
+
totalRemoved += p.removed || 0
|
|
1821
|
+
if (Array.isArray(p.ops)) {
|
|
1822
|
+
for (const op of p.ops) allOps.push(op)
|
|
1823
|
+
} else if (p.lines) {
|
|
1824
|
+
return p
|
|
1127
1825
|
}
|
|
1128
|
-
const allOps = []
|
|
1129
|
-
let totalAdded = 0
|
|
1130
|
-
let totalRemoved = 0
|
|
1131
|
-
for (const g of groups) {
|
|
1132
|
-
let gMin0 = Infinity, gMax0 = -Infinity
|
|
1133
|
-
for (const i of g.indices) {
|
|
1134
|
-
const r = editRanges[i]
|
|
1135
|
-
if (r.s < gMin0) gMin0 = r.s
|
|
1136
|
-
if (r.t > gMax0) gMax0 = r.t
|
|
1137
|
-
}
|
|
1138
|
-
if (gMin0 === Infinity) continue
|
|
1139
|
-
const gMin = Math.max(1, gMin0 + 1 - W)
|
|
1140
|
-
const gOldEnd = Math.min(oldLines.length, gMax0 + 1 + W)
|
|
1141
|
-
// 新侧:以 old 窗口为基底,仅应用本组 edits,跟踪 offset
|
|
1142
|
-
const local = oldLines.slice(gMin - 1, gOldEnd)
|
|
1143
|
-
let off = 0
|
|
1144
|
-
let resolved = true
|
|
1145
|
-
for (const i of g.indices) {
|
|
1146
|
-
const r = editRanges[i]
|
|
1147
|
-
const raw = args.edits[i]
|
|
1148
|
-
const e = Array.isArray(raw) && raw.length >= 3 ? { remove_from: raw[0], remove_to: raw[1], replacement_text: raw[2] } : raw
|
|
1149
|
-
const repl = (e && typeof e.replacement_text === 'string') ? e.replacement_text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') : ''
|
|
1150
|
-
const ls = r.s - (gMin - 1) + off
|
|
1151
|
-
const lt = r.t - (gMin - 1) + off
|
|
1152
|
-
if (ls < 0 || lt < ls || lt > local.length) { resolved = false; break }
|
|
1153
|
-
const replLines = repl === '' ? [] : repl.split('\n')
|
|
1154
|
-
local.splice(ls, lt - ls + 1, ...replLines)
|
|
1155
|
-
off += replLines.length - (lt - ls + 1)
|
|
1156
|
-
}
|
|
1157
|
-
if (!resolved) continue
|
|
1158
|
-
const oldWin = oldLines.slice(gMin - 1, gOldEnd).join('\n')
|
|
1159
|
-
const newWin = local.join('\n')
|
|
1160
|
-
const p = diffPayloadOrFallback(fp, oldWin, newWin, 'modified', gMin)
|
|
1161
|
-
if (!p || !p.ok) continue
|
|
1162
|
-
if (p.fallback) return p
|
|
1163
|
-
totalAdded += p.added || 0
|
|
1164
|
-
totalRemoved += p.removed || 0
|
|
1165
|
-
if (Array.isArray(p.ops)) {
|
|
1166
|
-
for (const op of p.ops) allOps.push(op)
|
|
1167
|
-
} else if (p.lines) {
|
|
1168
|
-
return p
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
if (allOps.length === 0 && totalAdded === 0 && totalRemoved === 0) {
|
|
1172
|
-
return diffPayloadOrFallback(fp, oldLines.join('\n'), splitDiffLines(applied.text).join('\n'), 'modified', 1)
|
|
1173
|
-
}
|
|
1174
|
-
return { ok: true, kind: 'modified', file: fp, added: totalAdded, removed: totalRemoved, ops: allOps, truncated: 0, grouped: true }
|
|
1175
|
-
} catch (e) {
|
|
1176
|
-
const emsg = (e && e.message ? e.message : String(e))
|
|
1177
|
-
return { ok: false, error: { zh: '读取失败: ' + emsg, en: 'Read failed: ' + emsg } }
|
|
1178
1826
|
}
|
|
1827
|
+
if (allOps.length === 0 && totalAdded === 0 && totalRemoved === 0) {
|
|
1828
|
+
return diffPayloadOrFallback(fp, oldLines.join('\n'), splitDiffLines(applied.text).join('\n'), 'modified', 1)
|
|
1829
|
+
}
|
|
1830
|
+
return { ok: true, kind: 'modified', file: fp, added: totalAdded, removed: totalRemoved, ops: allOps, truncated: 0, grouped: true }
|
|
1179
1831
|
}
|
|
1180
1832
|
const oldText = typeof args.old_string === 'string' ? args.old_string : ''
|
|
1181
1833
|
const newText = typeof args.new_string === 'string' ? args.new_string : ''
|
|
1182
|
-
if (oldText
|
|
1834
|
+
if (overMaxChars(oldText, newText)) return { ok: false, error: bi('内容过大,无法生成对比', 'Content too large to compare') }
|
|
1183
1835
|
// 关键:edit 是补丁式,仅对比 old_string/new_string 会丢失文件上下文(抽屉只会显示
|
|
1184
1836
|
// 补丁那几行)。改为读取磁盘当前内容、应用补丁后,取改动前后各 W 行的窗口做 diff——
|
|
1185
1837
|
// 行号从真实位置起算,payload 恒定小,大文件无需整文件对比(write 才是整文件语义)。
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
idx = normIdx
|
|
1214
|
-
baseText = fileNorm
|
|
1215
|
-
oldLen = oldNorm.length
|
|
1216
|
-
eolNormalized = true
|
|
1217
|
-
}
|
|
1838
|
+
const rd = await readTargetChecked(fp, entry.projRoot, fsService, sreText)
|
|
1839
|
+
if (!rd.ok) return rd
|
|
1840
|
+
const fileText = rd.text
|
|
1841
|
+
if (overMaxChars(fileText, newText)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
|
|
1842
|
+
// 行尾处理:磁盘文件可能是 CRLF/CR 而工具参数为 LF。优先按原始文本字面匹配
|
|
1843
|
+
// (预览与实际 edit 结果一致);字面匹配失败且磁盘含 CR/CRLF 时,退而按 \n
|
|
1844
|
+
// 归一化匹配构建预览窗口(与 splitDiffLines 同一归一化规则),并在 payload 上
|
|
1845
|
+
// 标记 eolNormalized——此时预览仅为意图展示:实际 edit 按原始字节字面匹配
|
|
1846
|
+
// 仍可能失败,由客户端提示,避免审批者基于"假成功"预览做决策。
|
|
1847
|
+
const rawIdx = oldText ? fileText.indexOf(oldText) : -1
|
|
1848
|
+
// oldNorm/newNorm 为补丁级小字符串,供行数统计与归一化预览共用;fileNorm
|
|
1849
|
+
// 为全文件副本,仅在字面匹配失败且文件确实含 \r 时才构建(避免常见路径对
|
|
1850
|
+
// 最多 1MB 文件做两趟全量 replace 扫描)。
|
|
1851
|
+
const oldNorm = normEol(oldText)
|
|
1852
|
+
const newNorm = normEol(newText)
|
|
1853
|
+
let eolNormalized = false
|
|
1854
|
+
let idx = rawIdx
|
|
1855
|
+
let baseText = fileText
|
|
1856
|
+
let oldLen = oldText.length
|
|
1857
|
+
if (rawIdx === -1 && oldNorm && fileText.indexOf('\r') !== -1) {
|
|
1858
|
+
const fileNorm = normEol(fileText)
|
|
1859
|
+
const normIdx = fileNorm.indexOf(oldNorm)
|
|
1860
|
+
if (normIdx !== -1) {
|
|
1861
|
+
idx = normIdx
|
|
1862
|
+
baseText = fileNorm
|
|
1863
|
+
oldLen = oldNorm.length
|
|
1864
|
+
eolNormalized = true
|
|
1218
1865
|
}
|
|
1219
|
-
if (idx === -1) {
|
|
1220
|
-
// 磁盘内容已与提案脱节(旧文本未找到):退回补丁级对比,至少展示改动意图
|
|
1221
|
-
return diffPayloadOrFallback(fp, oldText, newText, 'modified')
|
|
1222
|
-
}
|
|
1223
|
-
const applied = baseText.slice(0, idx) + (eolNormalized ? newNorm : newText) + baseText.slice(idx + oldLen)
|
|
1224
|
-
const W = 200
|
|
1225
|
-
const oldLines = splitDiffLines(baseText)
|
|
1226
|
-
const newLines = splitDiffLines(applied)
|
|
1227
|
-
const lineStart = baseText.slice(0, idx).split('\n').length
|
|
1228
|
-
const oldCnt = splitDiffLines(oldNorm).length
|
|
1229
|
-
const newCnt = splitDiffLines(newNorm).length
|
|
1230
|
-
const winStart = Math.max(1, lineStart - W)
|
|
1231
|
-
const winOldEnd = Math.min(oldLines.length, lineStart + oldCnt - 1 + W)
|
|
1232
|
-
const winNewEnd = Math.min(newLines.length, lineStart + newCnt - 1 + W)
|
|
1233
|
-
const winOldText = oldLines.slice(winStart - 1, winOldEnd).join('\n')
|
|
1234
|
-
const winNewText = newLines.slice(winStart - 1, winNewEnd).join('\n')
|
|
1235
|
-
const payload = diffPayloadOrFallback(fp, winOldText, winNewText, 'modified', winStart)
|
|
1236
|
-
// diffPayloadOrFallback 恒返回 ok:true 的 payload(失败时返回 fallback 视图而非 ok:false)
|
|
1237
|
-
if (eolNormalized) payload.eolNormalized = true
|
|
1238
|
-
return payload
|
|
1239
|
-
} catch (e) {
|
|
1240
|
-
const emsg = (e && e.message ? e.message : String(e))
|
|
1241
|
-
return { ok: false, error: { zh: '读取失败: ' + emsg, en: 'Read failed: ' + emsg } }
|
|
1242
1866
|
}
|
|
1867
|
+
if (idx === -1) {
|
|
1868
|
+
// 磁盘内容已与提案脱节(旧文本未找到):退回补丁级对比,至少展示改动意图
|
|
1869
|
+
return diffPayloadOrFallback(fp, oldText, newText, 'modified')
|
|
1870
|
+
}
|
|
1871
|
+
const applied = baseText.slice(0, idx) + (eolNormalized ? newNorm : newText) + baseText.slice(idx + oldLen)
|
|
1872
|
+
const oldLines = splitDiffLines(baseText)
|
|
1873
|
+
const newLines = splitDiffLines(applied)
|
|
1874
|
+
const lineStart = 1 + countNewlines(baseText, idx)
|
|
1875
|
+
const oldCnt = splitDiffLines(oldNorm).length
|
|
1876
|
+
const newCnt = splitDiffLines(newNorm).length
|
|
1877
|
+
const payload = windowedDiffPayload(fp, oldLines, newLines, lineStart, oldCnt, newCnt, 200)
|
|
1878
|
+
// diffPayloadOrFallback 恒返回 ok:true 的 payload(失败时返回 fallback 视图而非 ok:false)
|
|
1879
|
+
if (eolNormalized) payload.eolNormalized = true
|
|
1880
|
+
return payload
|
|
1243
1881
|
}
|
|
1244
1882
|
const content = typeof args.content === 'string' ? args.content : ''
|
|
1245
1883
|
if (!content || content.length > DIFF_MAX_CHARS) return { ok: false, error: bi('内容缺失或过大', 'Content missing or too large') }
|
|
@@ -1247,16 +1885,19 @@ export default {
|
|
|
1247
1885
|
const target = await fsService.resolve(resolveArgPath(fp, entry.projRoot))
|
|
1248
1886
|
const info = await fsService.stat(target)
|
|
1249
1887
|
if (info === undefined) return newFilePayload(fp, content)
|
|
1888
|
+
// create 拒绝覆盖已存在文件:write 分支已 stat,此处特判(避免 create 分支重复 resolve+stat)
|
|
1889
|
+
if (sreKind === 'create') {
|
|
1890
|
+
return { ok: false, error: bi('create 不会覆盖已存在的文件(该命令将失败),无改动可预览', 'create will fail: file already exists; no change to preview') }
|
|
1891
|
+
}
|
|
1250
1892
|
if (info.type !== 'file') return { ok: false, error: bi('不是普通文件', 'Not a regular file') }
|
|
1251
|
-
if (info
|
|
1893
|
+
if (fileTooLarge(info, DIFF_MAX_CHARS)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
|
|
1252
1894
|
const oldText = await fsService.readText(target)
|
|
1253
|
-
if (oldText
|
|
1895
|
+
if (overMaxChars(oldText, content)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
|
|
1254
1896
|
return diffPayloadOrFallback(fp, oldText, content, 'modified')
|
|
1255
1897
|
} catch (e) {
|
|
1256
1898
|
// 注意:不能与字符串直接拼接(bi() 返回 {zh,en} 对象,+ 会得到 "[object Object]");
|
|
1257
1899
|
// 返回双语对象,由路由侧 L(r.error, lang) 按语言取值。
|
|
1258
|
-
|
|
1259
|
-
return { ok: false, error: { zh: '读取失败: ' + emsg, en: 'Read failed: ' + emsg } }
|
|
1900
|
+
return { ok: false, error: readFail(e) }
|
|
1260
1901
|
}
|
|
1261
1902
|
}
|
|
1262
1903
|
return null
|
|
@@ -1271,7 +1912,18 @@ export default {
|
|
|
1271
1912
|
if (!r) return false
|
|
1272
1913
|
const s = norm(p)
|
|
1273
1914
|
const abs = (s.indexOf('/') === 0 || /^[a-zA-Z]:/.test(s)) ? s : r + '/' + s
|
|
1274
|
-
|
|
1915
|
+
// 盘根 'G:'(norm 剥掉尾斜杠)补回根斜杠:pathResolve('G:') 会落到 cwd 而非盘根;
|
|
1916
|
+
// UNC 根 pathResolve 输出带尾部分隔符,前缀判断需按「rr 已以分隔符结尾」分支处理
|
|
1917
|
+
const fixRoot = (v) => pathResolve(/^[a-zA-Z]:$/.test(v) ? v + '/' : v)
|
|
1918
|
+
const ra = fixRoot(abs)
|
|
1919
|
+
const rr = fixRoot(r)
|
|
1920
|
+
const lowerRa = ra.toLowerCase()
|
|
1921
|
+
const lowerRr = rr.toLowerCase()
|
|
1922
|
+
// 根本身(含盘根/UNC 根,rr 可能以分隔符结尾)为区内;前缀比较带分隔符边界
|
|
1923
|
+
if (lowerRa === lowerRr) return false
|
|
1924
|
+
const rrEndSep = lowerRr.endsWith('/') || lowerRr.endsWith('\\')
|
|
1925
|
+
if (rrEndSep) return lowerRa.indexOf(lowerRr) !== 0
|
|
1926
|
+
return lowerRa.indexOf(lowerRr + '/') !== 0 && lowerRa.indexOf(lowerRr + '\\') !== 0
|
|
1275
1927
|
}
|
|
1276
1928
|
|
|
1277
1929
|
function callKey(name, args) {
|
|
@@ -1298,7 +1950,11 @@ export default {
|
|
|
1298
1950
|
for (const k of Object.keys(gMap)) {
|
|
1299
1951
|
if (matchGlob(k, name)) return { action: gMap[k] }
|
|
1300
1952
|
}
|
|
1301
|
-
|
|
1953
|
+
// 预设工具的默认动作同样是「决策默认」:配置里缺席(升级前生成的老配置不含新键)时按
|
|
1954
|
+
// QUICK_DEFAULTS 裁决,与面板显示走同一条链(项目键 → 全局键 → 预设默认 → 兜底),
|
|
1955
|
+
// 新老配置行为一致;显式配置项与 migrateOld 的 locked deny 仍优先于此。
|
|
1956
|
+
const def = Object.prototype.hasOwnProperty.call(QUICK_DEFAULTS, name) ? QUICK_DEFAULTS[name] : null
|
|
1957
|
+
return def ? { action: def, isDefault: true } : null
|
|
1302
1958
|
}
|
|
1303
1959
|
|
|
1304
1960
|
function textOfBlock(b) {
|
|
@@ -1353,7 +2009,9 @@ export default {
|
|
|
1353
2009
|
}
|
|
1354
2010
|
if (d.kind === 'path' && v) {
|
|
1355
2011
|
if (d.cat === 'read') return bi('读取文件 ' + v, 'Read file ' + v)
|
|
2012
|
+
if (d.cat === 'image') return bi('读取图片 ' + v, 'Read image ' + v)
|
|
1356
2013
|
if (d.cat === 'edit') return bi('写入/修改文件 ' + v, 'Write/modify file ' + v)
|
|
2014
|
+
if (d.cat === 'undo') return bi('撤销操作(恢复上次编辑前的内容):' + v, 'Undo edit (revert last edit): ' + v)
|
|
1357
2015
|
return bi('访问路径 ' + v, 'Access path ' + v)
|
|
1358
2016
|
}
|
|
1359
2017
|
if (d.cat === 'doomloop') return bi('重复操作拦截:' + t + ' 连续多次相同调用,疑似循环', 'Doom Loop: ' + t + ' repeated identically, possible loop')
|
|
@@ -1383,19 +2041,34 @@ export default {
|
|
|
1383
2041
|
const t = (zh, en) => (lang === 'en' ? en : zh)
|
|
1384
2042
|
try {
|
|
1385
2043
|
if (!args || typeof args !== 'object') return lines
|
|
1386
|
-
const fp =
|
|
1387
|
-
if (
|
|
2044
|
+
const fp = pathArg(args)
|
|
2045
|
+
if (isUndo(name)) {
|
|
2046
|
+
push(t('撤销', 'Undo'), fp ? baseName(fp) : '', fp ? { path: fp } : undefined)
|
|
2047
|
+
if (fp) push(t('路径', 'Path'), fp, { path: fp })
|
|
2048
|
+
} else if (isFileRead(name, args)) {
|
|
1388
2049
|
const target = fp || args.path || ''
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
push(name === 'read_image' ? t('读取图片', 'Read image') : t('读取', 'Read'), target ? baseName(target) : '', fp ? clickable : undefined)
|
|
1392
|
-
if (fp) push(t('路径', 'Path'), fp, clickable)
|
|
2050
|
+
push(t('读取', 'Read'), target ? baseName(target) : '', fp ? { path: fp } : undefined)
|
|
2051
|
+
if (fp) push(t('路径', 'Path'), fp, { path: fp })
|
|
1393
2052
|
if (args.offset !== undefined) push(t('偏移', 'Offset'), args.offset)
|
|
1394
2053
|
if (args.limit !== undefined) push(t('行数', 'Lines'), args.limit)
|
|
1395
|
-
} else if (
|
|
1396
|
-
|
|
2054
|
+
} else if (isFileImage(name)) {
|
|
2055
|
+
// 图片不做文本预览,路径也不可点击(「打开文件」白名单只含文本/文档类,点了也打不开)
|
|
2056
|
+
const target = fp || args.path || ''
|
|
2057
|
+
push(t('读取图片', 'Read image'), target ? baseName(target) : '', undefined)
|
|
2058
|
+
if (fp) push(t('路径', 'Path'), fp, undefined)
|
|
2059
|
+
} else if (isFileWrite(name, args)) {
|
|
2060
|
+
const isSre = name === 'str_replace_editor'
|
|
2061
|
+
const sreCmd = isSre ? sreCommand(args) : ''
|
|
2062
|
+
const wLabel = isSre
|
|
2063
|
+
? (sreCmd === 'create' ? t('创建', 'Create') : t('编辑', 'Edit'))
|
|
2064
|
+
: (name === 'edit' ? t('修改', 'Edit') : t('写入', 'Write'))
|
|
2065
|
+
push(wLabel, fp ? baseName(fp) : '', fp ? { path: fp } : undefined)
|
|
1397
2066
|
if (fp) push(t('路径', 'Path'), fp, { path: fp })
|
|
1398
|
-
const content = typeof args.content === 'string'
|
|
2067
|
+
const content = typeof args.content === 'string'
|
|
2068
|
+
? args.content
|
|
2069
|
+
: (typeof args.new_string === 'string'
|
|
2070
|
+
? args.new_string
|
|
2071
|
+
: (typeof args.new_str === 'string' ? args.new_str : (typeof args.file_text === 'string' ? args.file_text : '')))
|
|
1399
2072
|
if (content) push(t('内容', 'Content'), content.length > 140 ? content.slice(0, 140) + '…(共 ' + content.length + ' 字符)' : content)
|
|
1400
2073
|
} else if (COMMAND_TOOLS[name]) {
|
|
1401
2074
|
push(t('命令', 'Command'), args.command || '')
|
|
@@ -1411,23 +2084,62 @@ export default {
|
|
|
1411
2084
|
return lines
|
|
1412
2085
|
}
|
|
1413
2086
|
|
|
2087
|
+
// 工作区外路径类工具的合并矩阵单点(read / image / edit / undo 共用):
|
|
2088
|
+
// 先过「目录访问」闸,再过工具自身分类闸;任一 deny → 拒绝,任一 ask → 询问,否则放行。
|
|
2089
|
+
// cat 取「真正作出决定的那道闸」:它决定弹窗候选(加入例外)写到哪个分类的例外里——
|
|
2090
|
+
// 若只写自身分类而目录闸仍是 ask,用户点「允许」后同一个文件会反复弹窗、且候选会因已存在而消失。
|
|
2091
|
+
// 分类名文案:决定由哪道闸作出,reason 就用哪道闸的名字(否则 cat 已是自身分类、
|
|
2092
|
+
// ruleId 也指向自身分类的例外,文案却写「目录权限」,用户会去改错分类的配置)
|
|
2093
|
+
// deny / ask / allow 三个分支都遵循这一条:cat、前缀、ruleId 三者必须同源。
|
|
2094
|
+
const OUTSIDE_PREFIX = {
|
|
2095
|
+
directory: ['目录权限:', 'Directory permission: '],
|
|
2096
|
+
read: ['读取权限:', 'Read permission: '],
|
|
2097
|
+
image: ['读取图片权限:', 'Read image permission: '],
|
|
2098
|
+
edit: ['编辑权限:', 'Edit permission: '],
|
|
2099
|
+
undo: ['撤销权限:', 'Undo permission: '],
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
function outsideMatrix(catKey, fp) {
|
|
2103
|
+
const d = resolveCategory('directory', fp, 'path')
|
|
2104
|
+
const e = resolveCategory(catKey, fp, 'path')
|
|
2105
|
+
if (d.action === 'deny' || e.action === 'deny') {
|
|
2106
|
+
const src = d.action === 'deny' ? d : e
|
|
2107
|
+
const cat = src === d ? 'directory' : catKey
|
|
2108
|
+
const p = OUTSIDE_PREFIX[cat] || OUTSIDE_PREFIX.directory
|
|
2109
|
+
return { action: 'deny', src, cat, pz: p[0], pe: p[1] }
|
|
2110
|
+
}
|
|
2111
|
+
if (d.action === 'ask' || e.action === 'ask') {
|
|
2112
|
+
const cat = d.action === 'ask' ? 'directory' : catKey
|
|
2113
|
+
const p = OUTSIDE_PREFIX[cat] || OUTSIDE_PREFIX.directory
|
|
2114
|
+
return { action: 'ask', src: null, cat, pz: p[0], pe: p[1] }
|
|
2115
|
+
}
|
|
2116
|
+
// allow:与 deny/ask 同口径——由哪道闸的例外实际放行,cat 就跟随哪道闸,
|
|
2117
|
+
// 保证 reason 前缀、ruleId、cat 三者同源(否则文案写「读取图片权限:」
|
|
2118
|
+
// 而括号里的例外 id 属于目录闸,用户会去错分类找一条不存在的规则)。
|
|
2119
|
+
const src = d.ruleId ? d : (e.ruleId ? e : null)
|
|
2120
|
+
const cat = src === d ? 'directory' : catKey
|
|
2121
|
+
const p = OUTSIDE_PREFIX[cat] || OUTSIDE_PREFIX.directory
|
|
2122
|
+
return { action: 'allow', src, cat, pz: p[0], pe: p[1] }
|
|
2123
|
+
}
|
|
2124
|
+
|
|
1414
2125
|
function decide(exec) {
|
|
1415
2126
|
const name = exec.name
|
|
1416
2127
|
const args = exec.arguments
|
|
1417
2128
|
// deny 例外可携带自定义拒绝原因;有则用自定义文案,无则回退「(例外 id)」标注
|
|
1418
2129
|
const exReason = (d) => {
|
|
1419
|
-
if (d && d.action === 'deny' && d.reason) return d.reason
|
|
2130
|
+
if (d && d.action === 'deny' && d.reason) return '(' + d.reason + ')'
|
|
1420
2131
|
if (d && d.ruleId) return '(例外 ' + d.ruleId + ')'
|
|
1421
2132
|
return ''
|
|
1422
2133
|
}
|
|
1423
2134
|
const exReasonEn = (d) => {
|
|
1424
|
-
if (d && d.action === 'deny' && d.reason) return d.reason
|
|
2135
|
+
if (d && d.action === 'deny' && d.reason) return ' (' + d.reason + ')'
|
|
1425
2136
|
if (d && d.ruleId) return ' (exception ' + d.ruleId + ')'
|
|
1426
2137
|
return ''
|
|
1427
2138
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
2139
|
+
// 注意:perm_* 曾在此被无条件放行,等于给 AI 留了一条「自我提权且无弹窗」的后门
|
|
2140
|
+
// (perm_set_category / perm_set_fallback / perm_add_exception … 改动即生效)。
|
|
2141
|
+
// 现在不再特判,统一走下面的正常判定链(自定义规则 → 分类 → 快捷工具 → 兜底),
|
|
2142
|
+
// 其默认动作由 QUICK_DEFAULTS 钉成 ask;设置面板走 /permgate/* HTTP 路由、不经这里,不受影响。
|
|
1431
2143
|
if (sessionPresetName(exec) !== 'custom-review') {
|
|
1432
2144
|
return { action: 'allow', reason: bi('会话未选择「自定义审查」,由 DSH 权限预设处理', 'Session has not selected "Custom Review"; handled by DSH permission presets'), cat: null, value: null, kind: null }
|
|
1433
2145
|
}
|
|
@@ -1446,41 +2158,67 @@ export default {
|
|
|
1446
2158
|
return { action: rule.action, ruleId: rule.id, reason: rule.reason || bi('自定义规则 ' + rule.id + ' 命中', 'Custom rule ' + rule.id + ' matched'), cat: 'custom', value: null, kind: 'rule' }
|
|
1447
2159
|
}
|
|
1448
2160
|
}
|
|
1449
|
-
if (
|
|
2161
|
+
if (isFileRead(name, args)) {
|
|
1450
2162
|
const fp = pathArg(args)
|
|
1451
2163
|
if (fp && isOutside(fp, root)) {
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
const
|
|
1455
|
-
return { action:
|
|
2164
|
+
// 与 image/edit/undo 同口径:工作区外先过「目录访问」闸,再过「读取文件」闸。
|
|
2165
|
+
// 只取 directory 的动作,会让 read 分类的 mode 与 deny 例外在跨工作区时完全不生效。
|
|
2166
|
+
const m = outsideMatrix('read', fp)
|
|
2167
|
+
if (m.action === 'deny') return { action: 'deny', reason: bi(m.pz + '拒绝读取工作区外文件 ' + fp + exReason(m.src), m.pe + 'read outside workspace denied ' + fp + exReasonEn(m.src)), ruleId: m.src.ruleId, cat: m.cat, value: fp, kind: 'path' }
|
|
2168
|
+
if (m.action === 'ask') return { action: 'ask', reason: bi(m.pz + '读取工作区外文件 ' + fp + '(需确认)', m.pe + 'read outside workspace ' + fp + ' (requires confirmation)'), ruleId: null, cat: m.cat, value: fp, kind: 'path' }
|
|
2169
|
+
return { action: 'allow', reason: bi(m.pz + '读取工作区外文件 ' + fp + (m.src ? exReason(m.src) : ''), m.pe + 'read outside workspace ' + fp + (m.src ? exReasonEn(m.src) : '')), ruleId: m.src ? m.src.ruleId : null, cat: m.cat, value: fp, kind: 'path' }
|
|
1456
2170
|
}
|
|
1457
2171
|
const d = resolveCategory('read', fp, 'path')
|
|
1458
2172
|
const exZh = exReason(d)
|
|
1459
2173
|
const exEn = exReasonEn(d)
|
|
1460
2174
|
return { action: d.action, reason: bi('读取权限' + (fp ? ':' + fp : '') + exZh, 'Read permission' + (fp ? ': ' + fp : '') + exEn), ruleId: d.ruleId, cat: 'read', value: fp, kind: 'path' }
|
|
1461
2175
|
}
|
|
1462
|
-
|
|
2176
|
+
// 读图与读文件同一条判定链(工作区外先过「目录访问」闸,再落本分类 + 路径例外),
|
|
2177
|
+
// 只是分类从 read 换成 image:两边的默认动作与例外各自独立配置。
|
|
2178
|
+
if (isFileImage(name)) {
|
|
1463
2179
|
const fp = pathArg(args)
|
|
1464
2180
|
if (fp && isOutside(fp, root)) {
|
|
1465
|
-
//
|
|
1466
|
-
//
|
|
1467
|
-
const
|
|
1468
|
-
|
|
1469
|
-
if (
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
2181
|
+
// 工作区外读图:directory + image 合并矩阵(不能用 directory 的动作短路,
|
|
2182
|
+
// 否则 image 默认 ask 与 image 的 deny/路径例外在跨工作区场景下全部失效)。
|
|
2183
|
+
const m = outsideMatrix('image', fp)
|
|
2184
|
+
if (m.action === 'deny') return { action: 'deny', reason: bi(m.pz + '拒绝读取工作区外图片 ' + fp + exReason(m.src), m.pe + 'image read outside workspace denied ' + fp + exReasonEn(m.src)), ruleId: m.src.ruleId, cat: m.cat, value: fp, kind: 'path' }
|
|
2185
|
+
if (m.action === 'ask') return { action: 'ask', reason: bi(m.pz + '读取工作区外图片 ' + fp + '(需确认)', m.pe + 'read image outside workspace ' + fp + ' (requires confirmation)'), ruleId: null, cat: m.cat, value: fp, kind: 'path' }
|
|
2186
|
+
return { action: 'allow', reason: bi(m.pz + '读取工作区外图片 ' + fp + (m.src ? exReason(m.src) : ''), m.pe + 'read image outside workspace ' + fp + (m.src ? exReasonEn(m.src) : '')), ruleId: m.src ? m.src.ruleId : null, cat: m.cat, value: fp, kind: 'path' }
|
|
2187
|
+
}
|
|
2188
|
+
const d = resolveCategory('image', fp, 'path')
|
|
2189
|
+
const exZh = exReason(d)
|
|
2190
|
+
const exEn = exReasonEn(d)
|
|
2191
|
+
return { action: d.action, reason: bi('读取图片权限' + (fp ? ':' + fp : '') + exZh, 'Read image permission' + (fp ? ': ' + fp : '') + exEn), ruleId: d.ruleId, cat: 'image', value: fp, kind: 'path' }
|
|
2192
|
+
}
|
|
2193
|
+
if (isFileWrite(name, args)) {
|
|
2194
|
+
const fp = pathArg(args)
|
|
2195
|
+
if (fp && isOutside(fp, root)) {
|
|
2196
|
+
// 工作区外写入:directory + edit 合并矩阵(与读图/撤销同口径,单点在 outsideMatrix)。
|
|
2197
|
+
const m = outsideMatrix('edit', fp)
|
|
2198
|
+
if (m.action === 'deny') return { action: 'deny', reason: bi(m.pz + '拒绝写入工作区外 ' + fp + exReason(m.src), m.pe + 'write to outside workspace denied ' + fp + exReasonEn(m.src)), ruleId: m.src.ruleId, cat: m.cat, value: fp, kind: 'path' }
|
|
2199
|
+
if (m.action === 'ask') return { action: 'ask', reason: bi(m.pz + '访问工作区外 ' + fp + '(写入需确认)', m.pe + 'access outside workspace ' + fp + ' (write requires confirmation)'), ruleId: null, cat: m.cat, value: fp, kind: 'path' }
|
|
2200
|
+
return { action: 'allow', reason: bi(m.pz + '访问工作区外 ' + fp + (m.src ? exReason(m.src) : ''), m.pe + 'access outside workspace ' + fp + (m.src ? exReasonEn(m.src) : '')), ruleId: m.src ? m.src.ruleId : null, cat: m.cat, value: fp, kind: 'path' }
|
|
1478
2201
|
}
|
|
1479
2202
|
const d = resolveCategory('edit', fp, 'path')
|
|
1480
2203
|
const exZh = exReason(d)
|
|
1481
2204
|
const exEn = exReasonEn(d)
|
|
1482
2205
|
return { action: d.action, reason: bi('编辑权限' + (fp ? ':' + fp : '') + exZh, 'Edit permission' + (fp ? ': ' + fp : '') + exEn), ruleId: d.ruleId, cat: 'edit', value: fp, kind: 'path' }
|
|
1483
2206
|
}
|
|
2207
|
+
// 撤销:会写盘但不是「编辑」——恢复既有内容、不接受调用方提供的新内容,故单列一类(默认询问)。
|
|
2208
|
+
// 与写类一致:工作区外仍先过「目录访问」闸(directory + undo 合并矩阵)。
|
|
2209
|
+
if (isUndo(name)) {
|
|
2210
|
+
const fp = pathArg(args)
|
|
2211
|
+
if (fp && isOutside(fp, root)) {
|
|
2212
|
+
const m = outsideMatrix('undo', fp)
|
|
2213
|
+
if (m.action === 'deny') return { action: 'deny', reason: bi(m.pz + '拒绝撤销工作区外 ' + fp + exReason(m.src), m.pe + 'undo outside workspace denied ' + fp + exReasonEn(m.src)), ruleId: m.src.ruleId, cat: m.cat, value: fp, kind: 'path' }
|
|
2214
|
+
if (m.action === 'ask') return { action: 'ask', reason: bi(m.pz + '撤销工作区外文件 ' + fp + '(需确认)', m.pe + 'undo outside workspace ' + fp + ' (requires confirmation)'), ruleId: null, cat: m.cat, value: fp, kind: 'path' }
|
|
2215
|
+
return { action: 'allow', reason: bi(m.pz + '撤销工作区外文件 ' + fp + (m.src ? exReason(m.src) : ''), m.pe + 'undo outside workspace ' + fp + (m.src ? exReasonEn(m.src) : '')), ruleId: m.src ? m.src.ruleId : null, cat: m.cat, value: fp, kind: 'path' }
|
|
2216
|
+
}
|
|
2217
|
+
const d = resolveCategory('undo', fp, 'path')
|
|
2218
|
+
const exZh = exReason(d)
|
|
2219
|
+
const exEn = exReasonEn(d)
|
|
2220
|
+
return { action: d.action, reason: bi('撤销权限' + (fp ? ':' + fp : '') + exZh, 'Undo permission' + (fp ? ': ' + fp : '') + exEn), ruleId: d.ruleId, cat: 'undo', value: fp, kind: 'path' }
|
|
2221
|
+
}
|
|
1484
2222
|
if (COMMAND_TOOLS[name]) {
|
|
1485
2223
|
const cmd = commandArg(args)
|
|
1486
2224
|
// 命令的所有可识别命令 token 均已命中 allow 例外 → 视为已覆盖,直接放行(不再弹窗)
|
|
@@ -1497,8 +2235,15 @@ export default {
|
|
|
1497
2235
|
return { action: d.action, reason: bi('启动子代理' + exReason(d), 'Spawn subagent' + exReasonEn(d)), ruleId: d.ruleId, cat: 'subagent', value: null, kind: null }
|
|
1498
2236
|
}
|
|
1499
2237
|
const q = quickAction(name)
|
|
1500
|
-
if (q)
|
|
1501
|
-
|
|
2238
|
+
if (q) {
|
|
2239
|
+
// 命中预设默认值时区分措辞,避免把「默认动作」说成用户显式设置
|
|
2240
|
+
const label = q.isDefault ? '快捷默认' : '快捷设置'
|
|
2241
|
+
const labelEn = q.isDefault ? 'Quick default' : 'Quick setting'
|
|
2242
|
+
return { action: q.action, reason: bi(label + ':' + name + ' → ' + q.action, labelEn + ': ' + name + ' → ' + q.action), ruleId: null, cat: 'quick', value: name, kind: 'tool' }
|
|
2243
|
+
}
|
|
2244
|
+
const fb = fallbackMode()
|
|
2245
|
+
if (fb === 'allow') return { action: 'allow', reason: bi('未匹配任何规则,放行', 'No rule matched, allowed'), cat: null, value: null, kind: null }
|
|
2246
|
+
return { action: fb, reason: bi('未匹配任何规则,按兜底策略处理:' + fb, 'No rule matched; handled by fallback policy: ' + fb), ruleId: null, cat: 'fallback', value: name, kind: 'tool' }
|
|
1502
2247
|
}
|
|
1503
2248
|
|
|
1504
2249
|
function recordDecision(d, exec) {
|
|
@@ -1510,7 +2255,9 @@ export default {
|
|
|
1510
2255
|
const s = String(p).replace(/\\/g, '/').replace(/\/+$/, '')
|
|
1511
2256
|
const idx = s.lastIndexOf('/')
|
|
1512
2257
|
let dir = idx >= 0 ? s.slice(0, idx) : s
|
|
1513
|
-
|
|
2258
|
+
// 盘根(G:)本身就是父目录,直接拼 /*;若补分隔符会得到 G://*,而 norm 不折叠中间双斜杠,
|
|
2259
|
+
// 该 glob 匹配不到任何真实路径,会让「整个目录」候选写出的例外永不生效
|
|
2260
|
+
if (/^[a-zA-Z]:$/.test(dir)) return dir + '/*'
|
|
1514
2261
|
if (!dir) dir = '/'
|
|
1515
2262
|
return dir + '/*'
|
|
1516
2263
|
}
|
|
@@ -1646,7 +2393,8 @@ export default {
|
|
|
1646
2393
|
|
|
1647
2394
|
function buildCandidates(entry) {
|
|
1648
2395
|
const out = []
|
|
1649
|
-
const push = (label, value, kind) => out.push({ id: 'c' + Math.random().toString(36).slice(2, 8), label, value, kind })
|
|
2396
|
+
const push = (label, value, kind, writes) => out.push({ id: 'c' + Math.random().toString(36).slice(2, 8), label, value, kind, writes: Array.isArray(writes) ? writes : [{ cat: entry.cat, kind, value }] })
|
|
2397
|
+
const t = (zh, en) => (uiLang === 'en' ? en : zh)
|
|
1650
2398
|
if (entry.kind === 'command' && entry.value) {
|
|
1651
2399
|
const parts = splitCommandSegments(String(entry.value))
|
|
1652
2400
|
const seen = {}
|
|
@@ -1662,9 +2410,36 @@ export default {
|
|
|
1662
2410
|
push(label, val, 'command')
|
|
1663
2411
|
}
|
|
1664
2412
|
} else if (entry.kind === 'path' && entry.value) {
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
2413
|
+
// 例外按 glob 匹配:文件路径若含 * 或 ?,写成的例外会比「仅此路径」宽得多
|
|
2414
|
+
// (例如 ** 会匹配整个子树)——这类路径不生成以「文件」为粒度的候选,避免文案与授权范围不符。
|
|
2415
|
+
// 注意:[ ] 在 globToRegExp 里被转义成字面量、不是通配符,故不拦(否则含 [1] 的合法文件名会被误伤)。
|
|
2416
|
+
const hasGlobMeta = (p) => /[*?]/.test(String(p || ''))
|
|
2417
|
+
// 含 .. 段的路径同样不能用来拼目录 glob:glob 只做字符串匹配、不折叠 ..,
|
|
2418
|
+
// 写出的模式会命中解析后与「整个目录」文案完全不同的路径(授权面更大)。
|
|
2419
|
+
const hasParentSeg = (p) => /(^|[\\/])\.\.([\\/]|$)/.test(String(p || ''))
|
|
2420
|
+
const catKey = entry.toolCat && EXC_CATS.indexOf(entry.toolCat) !== -1 ? entry.toolCat : entry.cat
|
|
2421
|
+
const outsideHere = !!(catKey && catKey !== 'directory' && isOutside(entry.value, root))
|
|
2422
|
+
// 候选文案用的分类名:说明这条候选会放开「哪一类操作」,避免文案与落盘的例外分类不符
|
|
2423
|
+
const KIND_LABEL = { read: ['读取文件', 'file reads'], image: ['读取图片', 'image reads'], edit: ['写入/编辑', 'write/edits'], undo: ['撤销操作', 'undo actions'] }
|
|
2424
|
+
const kindLabel = KIND_LABEL[catKey] || ['此类操作', 'this kind of operation']
|
|
2425
|
+
if (outsideHere) {
|
|
2426
|
+
// 工作区外路径:两道闸各给一条候选。「整个目录」写 directory 与该分类的目录 glob ——
|
|
2427
|
+
// 点一次后该类操作在该目录下都不再询问;「仅此文件」写自身分类例外 + directory 的精确路径例外。
|
|
2428
|
+
// 目录 glob 只对普通路径生成:路径自身含通配符或 .. 段时,拼出的 glob 会匹配到该目录之外
|
|
2429
|
+
// 的路径(dirGlob / norm / globToRegExp 都不折叠 ..),授权面会超过「整个目录」的文案。
|
|
2430
|
+
const globSafe = !hasGlobMeta(entry.value) && !hasParentSeg(entry.value)
|
|
2431
|
+
const glob = globSafe ? dirGlob(entry.value) : ''
|
|
2432
|
+
const hasDirGlob = globSafe && alreadyInProject(glob, 'path', 'directory')
|
|
2433
|
+
const hasKindGlob = globSafe && alreadyInProject(glob, 'path', catKey)
|
|
2434
|
+
if (globSafe && (!hasDirGlob || !hasKindGlob)) {
|
|
2435
|
+
push(t('整个目录:' + kindLabel[0] + '(允许时同时写入「' + kindLabel[0] + '」与「目录访问」两条例外;拒绝时只写入「' + kindLabel[0] + '」,不连带封禁该目录的其它类型操作):', 'Whole directory: ' + kindLabel[1] + ' (an allow writes both the ' + kindLabel[1] + ' rule and the directory-access rule; a deny writes only the ' + kindLabel[1] + ' rule and does not block other kinds of operations in that directory): ') + glob, glob, 'path', [{ cat: 'directory', kind: 'path', value: glob }, { cat: catKey, kind: 'path', value: glob }])
|
|
2436
|
+
}
|
|
2437
|
+
if (!hasGlobMeta(entry.value) && !alreadyInProject(entry.value, 'path', catKey)) {
|
|
2438
|
+
push(t('仅此文件:' + kindLabel[0] + '(允许时同时写入「' + kindLabel[0] + '」与「目录访问」的精确路径例外;拒绝时只写入「' + kindLabel[0] + '」,不连带封禁该路径的其它类型操作):', 'Only this file: ' + kindLabel[1] + ' (an allow writes exact-path rules for both the ' + kindLabel[1] + ' and directory access; a deny writes only the ' + kindLabel[1] + ' rule and does not block other kinds of operations on that path): ') + entry.value, entry.value, 'path', [{ cat: catKey, kind: 'path', value: entry.value }, { cat: 'directory', kind: 'path', value: entry.value }])
|
|
2439
|
+
}
|
|
2440
|
+
} else if (!hasGlobMeta(entry.value)) {
|
|
2441
|
+
if (!alreadyInProject(entry.value, 'path', entry.cat)) push(entry.value, entry.value, 'path')
|
|
2442
|
+
}
|
|
1668
2443
|
}
|
|
1669
2444
|
// 其余分类无「例外」候选:快捷工具(web_search/skill 等)走 quickTools 设置;
|
|
1670
2445
|
// 子代理/重复操作只有模式默认值 —— 均不生成候选
|
|
@@ -1677,7 +2452,6 @@ export default {
|
|
|
1677
2452
|
let onAbort = null
|
|
1678
2453
|
const id = 'p' + Math.random().toString(36).slice(2, 10)
|
|
1679
2454
|
const argsJson = safeJson(exec.arguments)
|
|
1680
|
-
const argsPreview = argsJson && argsJson.length > 160 ? argsJson.slice(0, 160) + '…' : (argsJson || '')
|
|
1681
2455
|
const taskText = argDescription(exec.arguments) || recentUserText(exec)
|
|
1682
2456
|
const entry = {
|
|
1683
2457
|
id,
|
|
@@ -1694,8 +2468,14 @@ export default {
|
|
|
1694
2468
|
// 审批发起时的项目根:root 是跨会话共享的闭包变量,随后可能被其他会话覆盖,
|
|
1695
2469
|
// 打相对路径/对比/打开文件必须用发起会话自己的根
|
|
1696
2470
|
projRoot: root || null,
|
|
1697
|
-
//
|
|
1698
|
-
|
|
2471
|
+
// 编辑/写入,或带文件路径的读取 → 弹窗「详情」默认展开、按需取数据
|
|
2472
|
+
// (写类=diff,读类=窗口化内容;图片是整图 data URL,改由客户端默认收起、点开才拉取)
|
|
2473
|
+
hasDiff: isPreviewableFileTool(exec.name, exec.arguments) && !!pathArg(exec.arguments),
|
|
2474
|
+
imagePreview: isFileImage(exec.name) === true,
|
|
2475
|
+
toolCat: pathToolCat(exec.name, exec.arguments),
|
|
2476
|
+
// str_replace_editor 的内核在审批发起时定下(insert 的 insert_line 语义随内核相反),
|
|
2477
|
+
// 详情预览按发起时的实际内核解释,避免中途判别漂移
|
|
2478
|
+
editorKernel: resolveEditorKernel(exec).kernel,
|
|
1699
2479
|
resolve,
|
|
1700
2480
|
cleanup() {
|
|
1701
2481
|
if (onAbort && exec.signal) { try { exec.signal.removeEventListener('abort', onAbort) } catch (e) {} }
|
|
@@ -1720,36 +2500,89 @@ export default {
|
|
|
1720
2500
|
})
|
|
1721
2501
|
}
|
|
1722
2502
|
|
|
1723
|
-
|
|
2503
|
+
// 例外落盘单点:按「分类 + 类型(path/command/custom)+ 值」写入目标块(缺省全局,
|
|
2504
|
+
// 候选写入由调用方显式指定项目块)。
|
|
2505
|
+
// 候选各自携带目标分类(buildCandidates 的 writes),不再由 entry.cat 统一决定——
|
|
2506
|
+
// 否则「仅允许此文件」这类要写两条例外(自身分类 + 目录闸)的候选会写错分类。
|
|
2507
|
+
function addProjectException(cat, kind, value, decision, opts) {
|
|
2508
|
+
const o = opts || {}
|
|
2509
|
+
const target = o.target === 'project' ? 'project' : 'global'
|
|
2510
|
+
// reason 仅用于 deny,且与 normalizeException 同口径(trim + 截断 200):
|
|
2511
|
+
// 否则「写入当下」与「重新加载后」的条目字段会不一致(allow 的 reason 会被丢弃)。
|
|
2512
|
+
const reason = decision === 'deny' && o.reason ? String(o.reason).trim().slice(0, 200) : undefined
|
|
2513
|
+
// 新条目一律插到数组头部:resolveCategory 只取首个匹配,即「最新决定先生效」;
|
|
2514
|
+
// 同方向重复写入直接跳过并返回既有条目,避免同一决定在列表里堆积。
|
|
2515
|
+
const build = (extra) => Object.assign({ id: 'e' + Math.random().toString(36).slice(2, 8), action: decision }, extra, reason ? { reason } : {})
|
|
1724
2516
|
try {
|
|
1725
|
-
const block = ensureProject()
|
|
1726
|
-
if (kind === 'path' &&
|
|
1727
|
-
const
|
|
1728
|
-
if (!
|
|
1729
|
-
const idx =
|
|
1730
|
-
if (idx !== -1) {
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
block
|
|
1742
|
-
return
|
|
2517
|
+
const block = target === 'global' ? config.global : ensureProject()
|
|
2518
|
+
if (kind === 'path' && cat && cat !== 'command' && EXC_CATS.indexOf(cat) !== -1) {
|
|
2519
|
+
const c = block[cat] || freshCategory(cat, target === 'project')
|
|
2520
|
+
if (!c.exceptions) c.exceptions = []
|
|
2521
|
+
const idx = c.exceptions.findIndex((r) => r.path === value && r.action === decision)
|
|
2522
|
+
if (idx !== -1) {
|
|
2523
|
+
// 命中既有同向条目:提到数组头部,否则它会被前面的反向旧条目遮蔽(resolveCategory 只取首个匹配),
|
|
2524
|
+
// 用户的决定等于被静默丢弃;带新理由时一并回写(reason 已在入口按 deny + trim + 200 规范化)。
|
|
2525
|
+
const hit = c.exceptions.splice(idx, 1)[0]
|
|
2526
|
+
if (reason) hit.reason = reason
|
|
2527
|
+
c.exceptions.unshift(hit)
|
|
2528
|
+
block[cat] = c
|
|
2529
|
+
return hit
|
|
2530
|
+
}
|
|
2531
|
+
const item = build({ path: value })
|
|
2532
|
+
c.exceptions.unshift(item)
|
|
2533
|
+
block[cat] = c
|
|
2534
|
+
return item
|
|
2535
|
+
}
|
|
2536
|
+
if (kind === 'path') return null
|
|
2537
|
+
if (kind === 'command' && cat === 'command') {
|
|
2538
|
+
const c = block.command || freshCategory('command', target === 'project')
|
|
2539
|
+
if (!c.exceptions) c.exceptions = []
|
|
2540
|
+
const idx = c.exceptions.findIndex((r) => r.match === value && r.action === decision)
|
|
2541
|
+
if (idx !== -1) {
|
|
2542
|
+
const hit = c.exceptions.splice(idx, 1)[0]
|
|
2543
|
+
if (reason) hit.reason = reason
|
|
2544
|
+
c.exceptions.unshift(hit)
|
|
2545
|
+
block.command = c
|
|
2546
|
+
return hit
|
|
2547
|
+
}
|
|
2548
|
+
const item = build({ match: value })
|
|
2549
|
+
c.exceptions.unshift(item)
|
|
2550
|
+
block.command = c
|
|
2551
|
+
return item
|
|
1743
2552
|
}
|
|
2553
|
+
if (kind === 'command') return null
|
|
1744
2554
|
if (!block.custom) block.custom = []
|
|
1745
2555
|
const idx = block.custom.findIndex((r) => r.tool === value)
|
|
1746
|
-
if (idx !== -1) { block.custom[idx].action = decision; return }
|
|
1747
|
-
|
|
2556
|
+
if (idx !== -1) { block.custom[idx].action = decision; return block.custom[idx] }
|
|
2557
|
+
const item = { id: 'r' + Math.random().toString(36).slice(2, 8), action: decision, tool: value }
|
|
2558
|
+
block.custom.unshift(item)
|
|
2559
|
+
return item
|
|
1748
2560
|
} catch (e) {
|
|
1749
|
-
console.error('[permgate]
|
|
2561
|
+
console.error('[permgate] addProjectException error:', e)
|
|
2562
|
+
return null
|
|
1750
2563
|
}
|
|
1751
2564
|
}
|
|
1752
2565
|
|
|
2566
|
+
// 例外删除单点(/permgate/remove-exception 路由与 perm_remove_exception 工具共用):
|
|
2567
|
+
// 判定只取数组首个匹配,故同一 path 上可能并存方向相反的多条(最新在前生效)。
|
|
2568
|
+
// 删除严格按 id:只删用户点的那一行。同值同向的其它条目(方向交替写入可能累积)留给用户
|
|
2569
|
+
// 自行逐条清理,否则删一条历史行会连带删掉正在生效的那条,权限会静默变化。
|
|
2570
|
+
function removeExceptionEntries(block, catKey, id) {
|
|
2571
|
+
if (EXC_CATS.indexOf(catKey) === -1) return { removed: false, reason: '该分类不支持例外' }
|
|
2572
|
+
const c = block && block[catKey]
|
|
2573
|
+
if (!c || !Array.isArray(c.exceptions)) return { removed: false, reason: '例外列表不存在' }
|
|
2574
|
+
const target = c.exceptions.find((r) => r.id === id)
|
|
2575
|
+
if (!target) return { removed: false, reason: '未找到 id=' + id }
|
|
2576
|
+
const key = catKey === 'command' ? 'match' : 'path'
|
|
2577
|
+
const value = target[key]
|
|
2578
|
+
const kept = c.exceptions.filter((r) => r.id !== id)
|
|
2579
|
+
const count = c.exceptions.length - kept.length
|
|
2580
|
+
c.exceptions = kept
|
|
2581
|
+
// 同值、方向相反的条目可能仍然留在列表里并继续生效:回传给 UI 提示,避免用户以为已彻底清除
|
|
2582
|
+
const remaining = kept.filter((r) => r[key] === value).length
|
|
2583
|
+
return { removed: true, count, remaining, exception: target }
|
|
2584
|
+
}
|
|
2585
|
+
|
|
1753
2586
|
function addRememberedRule(entry, action, target) {
|
|
1754
2587
|
try {
|
|
1755
2588
|
const block = target === 'project' ? ensureProject() : config.global
|
|
@@ -1759,21 +2592,13 @@ export default {
|
|
|
1759
2592
|
return
|
|
1760
2593
|
}
|
|
1761
2594
|
if (entry.kind === 'path' && entry.cat && EXC_CATS.indexOf(entry.cat) !== -1 && entry.value) {
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
if (idx !== -1) { cat.exceptions[idx].action = action; block[entry.cat] = cat; return }
|
|
1766
|
-
cat.exceptions.unshift({ id: 'e' + Math.random().toString(36).slice(2, 8), action, path: entry.value })
|
|
1767
|
-
block[entry.cat] = cat
|
|
2595
|
+
// 复用例外写入单点。注意:choice 是用户在弹窗上的显式选择(「拒绝并加入项目黑名单」),
|
|
2596
|
+
// 不能套用候选双写场景的「deny 不写 directory 例外」过滤,否则显式决定会被静默丢弃。
|
|
2597
|
+
addProjectException(entry.cat, 'path', String(entry.value), action, { target })
|
|
1768
2598
|
return
|
|
1769
2599
|
}
|
|
1770
2600
|
if (entry.kind === 'command' && entry.cat === 'command' && entry.value) {
|
|
1771
|
-
|
|
1772
|
-
if (!cat.exceptions) cat.exceptions = []
|
|
1773
|
-
const idx = cat.exceptions.findIndex((r) => r.match === entry.value)
|
|
1774
|
-
if (idx !== -1) { cat.exceptions[idx].action = action; block.command = cat; return }
|
|
1775
|
-
cat.exceptions.unshift({ id: 'e' + Math.random().toString(36).slice(2, 8), action, match: entry.value })
|
|
1776
|
-
block.command = cat
|
|
2601
|
+
addProjectException('command', 'command', String(entry.value), action, { target })
|
|
1777
2602
|
return
|
|
1778
2603
|
}
|
|
1779
2604
|
if (!block.custom) block.custom = []
|
|
@@ -1789,11 +2614,44 @@ export default {
|
|
|
1789
2614
|
|
|
1790
2615
|
// 最近一次成功读取/写入的磁盘原文:persist 前与磁盘比对,防止覆盖外部手工编辑
|
|
1791
2616
|
let lastDiskJson = null
|
|
2617
|
+
// 加载失败哨兵:load 未能建立磁盘基线时置此值,persist 守卫据此拒绝保存,
|
|
2618
|
+
// 防止「配置存在但读取失败」后下一次 persist 静默覆盖磁盘上的用户配置
|
|
2619
|
+
const LOAD_FAILED_MARK = '\u0000__PERMGATE_LOAD_FAILED__'
|
|
1792
2620
|
|
|
1793
2621
|
async function persist(exec) {
|
|
1794
2622
|
try {
|
|
1795
|
-
|
|
2623
|
+
let t = await ensureTarget(exec)
|
|
1796
2624
|
await ensureConfigDir()
|
|
2625
|
+
// 配置只应写到 home:home 不可用时会退到 <root>/.dsh/.permgate.json,而 DSH 的 writeText
|
|
2626
|
+
// 会自动 mkdir 父目录——一旦落盘就在项目里留下 .dsh(home 恢复后这份配置还会成为孤儿)。
|
|
2627
|
+
// 故此处直接拒绝,等 home 就绪后由 load() 重新落盘。
|
|
2628
|
+
const homeNow = await resolveDshHome()
|
|
2629
|
+
const pathOf = (v) => norm(pathString(v)).toLowerCase()
|
|
2630
|
+
// home 归属判定两侧必须同口径:pathOf 取的是 realpath(processPath),故 home 前缀也要由
|
|
2631
|
+
// 解析后的 home 目标派生——否则 home 含 junction/符号链接时前缀恒不匹配,保存会被永久拒绝
|
|
2632
|
+
const homeTarget = homeNow ? await homeConfigTarget(homeNow) : null
|
|
2633
|
+
const homePrefix = homeTarget ? pathOf(homeTarget).replace(/[\\/][^\\/]*$/, '/') : ''
|
|
2634
|
+
const inHome = !!(homeTarget && homePrefix && pathOf(t).indexOf(homePrefix) === 0)
|
|
2635
|
+
// home 已恢复但 target 仍停在回退路径时就地重算,省掉一次「重新加载配置文件」
|
|
2636
|
+
if (!inHome && homeTarget) {
|
|
2637
|
+
target = homeTarget
|
|
2638
|
+
t = target
|
|
2639
|
+
// 目标切换后旧基线不再属于该目标:基线为空却直接放行,会用内存里的(可能默认)配置
|
|
2640
|
+
// 覆盖 home 上已存在的用户配置——故 home 已有文件时拒绝保存并要求先重新加载
|
|
2641
|
+
if (lastDiskJson === null && await configExists(fs, t)) {
|
|
2642
|
+
saveError = uiLang === 'en' ? 'Config target switched to DSH home; reload the config file before saving.' : '配置路径已切换到 DSH home,请先「重新加载配置文件」再保存'
|
|
2643
|
+
broadcast({ type: 'status' })
|
|
2644
|
+
return false
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
if (!homeTarget || !homePrefix || pathOf(t).indexOf(homePrefix) !== 0) {
|
|
2648
|
+
// 区分「home 不可用」与「目标不在 home 下」:前者稍后重试即可,后者需要重新加载配置
|
|
2649
|
+
saveError = homeTarget
|
|
2650
|
+
? (uiLang === 'en' ? 'Config target is outside the DSH home; reload the config file before saving.' : '配置目标不在 DSH home 下,请先「重新加载配置文件」再保存')
|
|
2651
|
+
: (uiLang === 'en' ? 'DSH home is not ready; save skipped to avoid creating a stray .dsh in the project. Retry shortly.' : 'DSH home 未就绪,已跳过保存(避免在项目里产生 .dsh);稍后重试即可')
|
|
2652
|
+
broadcast({ type: 'status' })
|
|
2653
|
+
return false
|
|
2654
|
+
}
|
|
1797
2655
|
// 防覆盖守卫:配置在加载后被外部修改(手工编辑、其他实例写入)时拒绝保存,
|
|
1798
2656
|
// 避免静默覆盖用户规则;点击「重新加载配置文件」后守卫自动放行。
|
|
1799
2657
|
try {
|
|
@@ -1803,7 +2661,16 @@ export default {
|
|
|
1803
2661
|
broadcast({ type: 'status' })
|
|
1804
2662
|
return false
|
|
1805
2663
|
}
|
|
1806
|
-
} catch (e) {
|
|
2664
|
+
} catch (e) {
|
|
2665
|
+
// 读取失败:先用 stat 区分「目标不存在」与「存在但读不出」——
|
|
2666
|
+
// 目标不存在(首次落盘 / 迁移到新路径)视为尚无磁盘内容,允许写入并重建基线;
|
|
2667
|
+
// 已建立过基线却读不出(被占用、权限、IO 错误)时保守拒绝,避免静默覆盖用户手工编辑的规则
|
|
2668
|
+
if (await configExists(fs, t) && lastDiskJson !== null) {
|
|
2669
|
+
saveError = uiLang === 'en' ? 'Config file unreadable; save cancelled. Check the file (locked / permission / non-text encoding), then click "Reload config file".' : '配置文件无法读取,已取消保存;请检查该文件(被占用/权限/非文本编码)后再点击「重新加载配置文件」'
|
|
2670
|
+
broadcast({ type: 'status' })
|
|
2671
|
+
return false
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
1807
2674
|
const writePolicy = { mode: 'danger-full-access', workspaceRoot: root }
|
|
1808
2675
|
await fs.writeText(t, JSON.stringify(config, null, 2), undefined, undefined, writePolicy)
|
|
1809
2676
|
lastDiskJson = JSON.stringify(config, null, 2)
|
|
@@ -1816,9 +2683,73 @@ export default {
|
|
|
1816
2683
|
}
|
|
1817
2684
|
}
|
|
1818
2685
|
|
|
2686
|
+
// 项目残留配置并入(迁移用):只采纳 projects 段——工作区内的 .dsh/.permgate.json 可能随
|
|
2687
|
+
// 仓库分发或被 agent 写入(不可信),其 global 段一律忽略,避免 clone 即得的宽松「全局」
|
|
2688
|
+
// 策略覆盖用户配置;源里若只有旧格式 global(无 projects)则不迁移,从而不会走 migrateOld
|
|
2689
|
+
// (旧模式 permissive 会被映射成全 allow)。源不可解析或无可迁移内容时返回 null。
|
|
2690
|
+
function projectsFromConfig(srcText) {
|
|
2691
|
+
try {
|
|
2692
|
+
const src = JSON.parse(String(srcText == null ? '' : srcText))
|
|
2693
|
+
if (!src || typeof src !== 'object') return null
|
|
2694
|
+
const projects = src.projects && typeof src.projects === 'object' ? src.projects : null
|
|
2695
|
+
if (!projects) return null
|
|
2696
|
+
// 只接纳当前工作区自己的条目:工作区文件可能随仓库分发或被 agent 写入(不可信),
|
|
2697
|
+
// 其他 key 会被 cleanupStaleProjects 逐个 resolve/stat(UNC 会触发网络访问),
|
|
2698
|
+
// 也会长期套用于别的项目,故一律丢弃
|
|
2699
|
+
const rootKey = normPathKey(root)
|
|
2700
|
+
const keep = {}
|
|
2701
|
+
if (rootKey) {
|
|
2702
|
+
for (const key of Object.keys(projects)) {
|
|
2703
|
+
if (normPathKey(key) !== rootKey) continue
|
|
2704
|
+
keep[key] = projects[key]
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
if (!Object.keys(keep).length) return null
|
|
2708
|
+
return JSON.stringify({ projects: keep })
|
|
2709
|
+
} catch (e) { return null }
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
// 迁移成功后清理项目残留配置文件(删除失败只影响清理,不影响已完成的落盘)
|
|
2713
|
+
function removeMigratedSource(p) {
|
|
2714
|
+
try {
|
|
2715
|
+
const raw = pathString(p)
|
|
2716
|
+
// 迁移源必须是工作区内那个字面文件:targetKey 为 realpath,符号链接会让删除落到
|
|
2717
|
+
// 工作区外的真实文件,故以「父目录 realpath + 文件名」构造期望路径,再与文件 realpath 比较
|
|
2718
|
+
if (!raw) return false
|
|
2719
|
+
let want = ''
|
|
2720
|
+
try { want = pathJoin(fsRealpathSync(pathResolve(root, '.dsh')), '.permgate.json') } catch (e2) { return false }
|
|
2721
|
+
let st = null
|
|
2722
|
+
try { st = fsLstatSync(raw) } catch (e2) { return false }
|
|
2723
|
+
if (!st || !st.isFile()) return false
|
|
2724
|
+
const real = fsRealpathSync(raw)
|
|
2725
|
+
if (normPathKey(real) !== normPathKey(want)) return false
|
|
2726
|
+
fsUnlinkSync(real)
|
|
2727
|
+
return true
|
|
2728
|
+
} catch (e) { return false }
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// home 配置探测统一(missing 分支重试与非 missing 分支校验共用):
|
|
2732
|
+
// 返回 {target, text}(可读)、{target, missing:true}(不存在)、{target, readFailed:true}(存在但读失败)、null(home 不可用)
|
|
2733
|
+
async function probeHomeConfig() {
|
|
2734
|
+
const homeNow = await resolveDshHome()
|
|
2735
|
+
if (!homeNow) return null
|
|
2736
|
+
const target = await homeConfigTarget(homeNow)
|
|
2737
|
+
if (!target) return null
|
|
2738
|
+
const exists = await configExists(fs, target)
|
|
2739
|
+
if (!exists) return { target, missing: true }
|
|
2740
|
+
try {
|
|
2741
|
+
const text = await fs.readText(target)
|
|
2742
|
+
if (text === null) return { target, readFailed: true }
|
|
2743
|
+
return { target, text }
|
|
2744
|
+
} catch (e) { return { target, readFailed: true } }
|
|
2745
|
+
}
|
|
2746
|
+
|
|
1819
2747
|
async function load(exec) {
|
|
1820
2748
|
try {
|
|
1821
2749
|
const t = await ensureTarget(exec)
|
|
2750
|
+
let migratedFromProject = false
|
|
2751
|
+
let migratedFromPath = null
|
|
2752
|
+
let projReadFailed = false
|
|
1822
2753
|
let text = null
|
|
1823
2754
|
let missing = false
|
|
1824
2755
|
try {
|
|
@@ -1832,13 +2763,80 @@ export default {
|
|
|
1832
2763
|
// 文件不存在 → 首次运行,落盘默认配置;
|
|
1833
2764
|
// 存在但读取失败 → 保留内存配置并提示,绝不静默覆盖磁盘(避免误删规则)
|
|
1834
2765
|
if (missing) {
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
await
|
|
2766
|
+
// 初始化早期 subprocess 可能未就绪导致 home 解析失败、target 落到项目目录;
|
|
2767
|
+
// 落盘默认配置前重试一次 home 定位,优先复用 home 配置(避免在项目内新建配置文件)
|
|
2768
|
+
const hp = await probeHomeConfig()
|
|
2769
|
+
if (hp) {
|
|
2770
|
+
if (hp.text !== undefined) {
|
|
2771
|
+
target = hp.target
|
|
2772
|
+
text = hp.text
|
|
2773
|
+
} else if (hp.readFailed) {
|
|
2774
|
+
// home 配置存在但读取失败:保留内存配置并提示,绝不静默覆盖磁盘
|
|
2775
|
+
lastDiskJson = LOAD_FAILED_MARK
|
|
2776
|
+
loadError = uiLang === 'en' ? 'Cannot read config file: ' + hp.target : '无法读取配置文件: ' + hp.target
|
|
2777
|
+
return
|
|
2778
|
+
} else {
|
|
2779
|
+
// home 配置不存在:检查项目目录残留配置(1.3.x 竞态期可能 persist 到项目
|
|
2780
|
+
// .dsh/.permgate.json),存在且可读则迁移为初始配置(解析后落盘 homeT),避免用户规则静默丢失
|
|
2781
|
+
const projCfg = root ? await fs.resolve(root + '/.dsh/.permgate.json') : null
|
|
2782
|
+
if (projCfg) {
|
|
2783
|
+
const projExists = await configExists(fs, projCfg)
|
|
2784
|
+
if (projExists) {
|
|
2785
|
+
try {
|
|
2786
|
+
const projText = await fs.readText(projCfg)
|
|
2787
|
+
// 并入项目残留配置:只采纳 projects 段(工作区内文件不可信,其 global 一律忽略),
|
|
2788
|
+
// 迁移成功落盘后再删除源文件
|
|
2789
|
+
const mergedText = projectsFromConfig(projText)
|
|
2790
|
+
if (mergedText !== null) { target = hp.target; text = mergedText; migratedFromProject = true; migratedFromPath = projCfg }
|
|
2791
|
+
} catch (e) {
|
|
2792
|
+
// 残留存在但读不出(权限/占用/非 UTF-8):与 home 的 readFailed 同口径——置哨兵并提示,
|
|
2793
|
+
// 且不落默认配置,避免项目残留里的用户规则被静默放弃(此前会永久跳过迁移)
|
|
2794
|
+
projReadFailed = true
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
if (text === null) {
|
|
2801
|
+
// 项目残留配置存在却读不出:不落默认配置(否则 home 一旦写入就再也不迁移),
|
|
2802
|
+
// 置哨兵并提示,修复该文件后重新加载即可继续迁移
|
|
2803
|
+
if (projReadFailed) {
|
|
2804
|
+
lastDiskJson = LOAD_FAILED_MARK
|
|
2805
|
+
loadError = uiLang === 'en' ? 'Cannot read the project residual config; migration skipped. Fix that file and reload.' : '项目残留配置无法读取,已跳过迁移;修复该文件后重新加载即可'
|
|
2806
|
+
return
|
|
2807
|
+
}
|
|
2808
|
+
loadError = null
|
|
2809
|
+
config = freshConfig()
|
|
2810
|
+
// 正常初始化路径:清除加载失败哨兵,允许创建默认配置
|
|
2811
|
+
lastDiskJson = null
|
|
2812
|
+
// home 可能在本次探测中已恢复:显式把 target 切回 home,避免默认配置
|
|
2813
|
+
// 落到项目回退路径(DSH 的 writeText 会自动 mkdir,从而在项目里留下 .dsh)
|
|
2814
|
+
const homeNow = await resolveDshHome()
|
|
2815
|
+
if (homeNow) target = await homeConfigTarget(homeNow)
|
|
2816
|
+
await persist(exec)
|
|
2817
|
+
return
|
|
2818
|
+
}
|
|
1838
2819
|
} else {
|
|
2820
|
+
// 配置存在但读取失败:置加载失败哨兵,阻止后续 persist 静默覆盖
|
|
2821
|
+
lastDiskJson = LOAD_FAILED_MARK
|
|
1839
2822
|
loadError = uiLang === 'en' ? 'Cannot read config file: ' + t : '无法读取配置文件: ' + t
|
|
2823
|
+
return
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
// home 配置优先:竞态期 target 可能落在项目目录且项目残留配置存在(missing=false 时
|
|
2827
|
+
// 上面不会重试 home)。此时若 home 可解析且 home 配置存在,切回 home,避免永久使用项目旧配置。
|
|
2828
|
+
// 只有当 target 不是 home 配置(竞态期落在项目回退路径)时才探测:否则会对同一份
|
|
2829
|
+
// 文件重复 resolve+stat+readText(load 顶部已读过一次)
|
|
2830
|
+
if (!missing && text !== null) {
|
|
2831
|
+
const homeNow = await resolveDshHome()
|
|
2832
|
+
const homeTarget = homeNow ? await homeConfigTarget(homeNow) : null
|
|
2833
|
+
if (homeTarget && normPathKey(pathString(homeTarget)) !== normPathKey(pathString(target))) {
|
|
2834
|
+
const hp = await probeHomeConfig()
|
|
2835
|
+
if (hp && hp.text !== undefined && hp.target !== target) {
|
|
2836
|
+
target = hp.target
|
|
2837
|
+
text = hp.text
|
|
2838
|
+
}
|
|
1840
2839
|
}
|
|
1841
|
-
return
|
|
1842
2840
|
}
|
|
1843
2841
|
const parsed = JSON.parse(text)
|
|
1844
2842
|
if (!parsed || typeof parsed !== 'object') throw new Error('根节点必须是对象')
|
|
@@ -1846,10 +2844,16 @@ export default {
|
|
|
1846
2844
|
config = isOld ? migrateOld(parsed) : buildConfig(parsed)
|
|
1847
2845
|
lastDiskJson = String(text).trim()
|
|
1848
2846
|
loadError = null
|
|
1849
|
-
|
|
2847
|
+
saveError = null
|
|
2848
|
+
if (isOld || migratedFromProject) {
|
|
2849
|
+
const saved = await persist(exec)
|
|
2850
|
+
// 迁移成功落盘后才删除项目残留文件,避免写盘失败导致配置丢失
|
|
2851
|
+
if (saved && migratedFromPath) removeMigratedSource(migratedFromPath)
|
|
2852
|
+
}
|
|
1850
2853
|
broadcast({ type: 'status' })
|
|
1851
2854
|
} catch (e) {
|
|
1852
2855
|
loadError = '配置解析失败: ' + ((e && e.message) || String(e))
|
|
2856
|
+
lastDiskJson = LOAD_FAILED_MARK
|
|
1853
2857
|
}
|
|
1854
2858
|
}
|
|
1855
2859
|
|
|
@@ -1858,7 +2862,7 @@ export default {
|
|
|
1858
2862
|
const proj = projectBlock()
|
|
1859
2863
|
const effective = {}
|
|
1860
2864
|
for (const c of CATS) {
|
|
1861
|
-
effective[c] = (proj && proj[c] && proj[c].mode
|
|
2865
|
+
effective[c] = firstEffective(proj && proj[c] && proj[c].mode, config.global[c] && config.global[c].mode, 'allow')
|
|
1862
2866
|
}
|
|
1863
2867
|
const stats = { deny: 0, ask: 0 }
|
|
1864
2868
|
for (const d of decisions) {
|
|
@@ -1889,6 +2893,10 @@ export default {
|
|
|
1889
2893
|
global: config.global.quickTools || {},
|
|
1890
2894
|
project: (proj && proj.quickTools) || {},
|
|
1891
2895
|
},
|
|
2896
|
+
// 预设清单下发(单一来源为 QUICK_DEFAULTS):浏览器半边不再硬编码工具名清单
|
|
2897
|
+
quickPreset: QUICK_PRESET,
|
|
2898
|
+
// 预设默认动作一并下发:面板未配置行按「项目键 → 全局键 → 预设默认 → 兜底」显示,与 quickAction 同链
|
|
2899
|
+
quickDefaults: QUICK_DEFAULTS,
|
|
1892
2900
|
custom: {
|
|
1893
2901
|
global: config.global.custom || [],
|
|
1894
2902
|
project: (proj && proj.custom) || [],
|
|
@@ -1900,6 +2908,8 @@ export default {
|
|
|
1900
2908
|
stats,
|
|
1901
2909
|
recentDecisions: decisions.slice(-10).map((d) => Object.assign({}, d, { reason: typeof d.reason === 'string' ? d.reason : L(d.reason, l) })),
|
|
1902
2910
|
cats: CATS,
|
|
2911
|
+
editorKernel: { setting: editorKernelSetting(), ...resolveEditorKernel(exec) },
|
|
2912
|
+
fallback: { global: config.global.fallbackMode || 'ask', project: (proj && proj.fallbackMode) || 'inherit', effective: fallbackMode() },
|
|
1903
2913
|
excCats: EXC_CATS,
|
|
1904
2914
|
modes: MODES,
|
|
1905
2915
|
allModes: ALL_MODES,
|
|
@@ -1983,7 +2993,7 @@ export default {
|
|
|
1983
2993
|
const argsPreview = e.argsJson && e.argsJson.length > 160 ? e.argsJson.slice(0, 160) + '…' : (e.argsJson || '')
|
|
1984
2994
|
const reason = typeof e.reason === 'string' ? e.reason : L(e.reason, lang)
|
|
1985
2995
|
const intent = typeof e.intent === 'string' ? e.intent : L(e.intent, lang)
|
|
1986
|
-
out.push({ id: e.id, tool: e.tool, reason, ts: e.ts, args: argsPreview, intent, candidates: e.candidates || [], argLines: e.argLines || [], hasDiff: e.hasDiff === true })
|
|
2996
|
+
out.push({ id: e.id, tool: e.tool, reason, ts: e.ts, args: argsPreview, intent, candidates: e.candidates || [], argLines: e.argLines || [], hasDiff: e.hasDiff === true, imagePreview: e.imagePreview === true })
|
|
1987
2997
|
}
|
|
1988
2998
|
return json(res, out)
|
|
1989
2999
|
}
|
|
@@ -2012,13 +3022,37 @@ export default {
|
|
|
2012
3022
|
if (!entry) return json(res, { error: lang === 'en' ? 'Approval request not found or expired' : '审批请求不存在或已过期' })
|
|
2013
3023
|
let allow = false
|
|
2014
3024
|
let ruleCount = 0
|
|
3025
|
+
// 例外落盘单点(候选路径与旧形态规则共用):内含「拒绝时不写 directory 例外」这道安全过滤
|
|
3026
|
+
// 与计数,避免同一决定因入口不同而落盘范围不同;候选写入一律显式落到项目块。
|
|
3027
|
+
const writeException = (cat, kind, value, decision) => {
|
|
3028
|
+
if (decision === 'deny' && cat === 'directory') return
|
|
3029
|
+
addProjectException(cat, kind, value, decision, { target: 'project' })
|
|
3030
|
+
ruleCount++
|
|
3031
|
+
}
|
|
3032
|
+
const writeCandidate = (cand, decision) => {
|
|
3033
|
+
for (const w of cand.writes) writeException(w.cat, w.kind || 'path', String(w.value), decision)
|
|
3034
|
+
}
|
|
2015
3035
|
if (typeof a.action === 'string' && (a.action === 'allow' || a.action === 'deny')) {
|
|
2016
3036
|
allow = a.action === 'allow'
|
|
2017
3037
|
if (Array.isArray(a.rules)) {
|
|
2018
3038
|
for (const r of a.rules) {
|
|
2019
|
-
if (r
|
|
2020
|
-
|
|
2021
|
-
|
|
3039
|
+
if (!r || (r.decision !== 'allow' && r.decision !== 'deny')) continue
|
|
3040
|
+
// 整体为拒绝时不接受 allow 方向的规则:前端可能残留「允许此项」的勾选,
|
|
3041
|
+
// 若不拦就会变成「本次拒绝 + 持久化一条 allow 例外」,与用户显式拒绝的意图相反。
|
|
3042
|
+
if (!allow && r.decision === 'allow') continue
|
|
3043
|
+
const cand = r.id ? (entry.candidates || []).find((c) => c.id === r.id) : null
|
|
3044
|
+
if (cand && Array.isArray(cand.writes) && cand.writes.length) {
|
|
3045
|
+
writeCandidate(cand, r.decision)
|
|
3046
|
+
continue
|
|
3047
|
+
}
|
|
3048
|
+
if (r.value) {
|
|
3049
|
+
// 旧形态(无候选 id):按 value 反查候选,复用它的 writes,避免只落一条例外而导致同一文件反复弹窗
|
|
3050
|
+
const byValue = (entry.candidates || []).find((c) => c.value === String(r.value))
|
|
3051
|
+
if (byValue && Array.isArray(byValue.writes) && byValue.writes.length) {
|
|
3052
|
+
writeCandidate(byValue, r.decision)
|
|
3053
|
+
continue
|
|
3054
|
+
}
|
|
3055
|
+
writeException(entry.cat, r.kind || null, String(r.value), r.decision)
|
|
2022
3056
|
}
|
|
2023
3057
|
}
|
|
2024
3058
|
}
|
|
@@ -2042,12 +3076,26 @@ export default {
|
|
|
2042
3076
|
}
|
|
2043
3077
|
if (pathname === '/permgate/set-sandbox' && method === 'POST') {
|
|
2044
3078
|
await init(exec)
|
|
2045
|
-
const target = a
|
|
3079
|
+
const target = normTarget(a)
|
|
2046
3080
|
if (!setSandboxConfig(target, a.mode)) return json(res, { error: '非法沙箱参数: target=' + target + ' mode=' + a.mode })
|
|
2047
3081
|
await persist(exec)
|
|
2048
3082
|
syncSandbox(exec)
|
|
2049
3083
|
return json(res, statusView(exec))
|
|
2050
3084
|
}
|
|
3085
|
+
if (pathname === '/permgate/set-fallback' && method === 'POST') {
|
|
3086
|
+
await init(exec)
|
|
3087
|
+
const target = normTarget(a)
|
|
3088
|
+
if (!setFallbackMode(target, a.mode)) return json(res, { error: '非法兜底参数: target=' + target + ' mode=' + a.mode })
|
|
3089
|
+
await persist(exec)
|
|
3090
|
+
return json(res, statusView(exec))
|
|
3091
|
+
}
|
|
3092
|
+
if (pathname === '/permgate/set-editor-kernel' && method === 'POST') {
|
|
3093
|
+
await init(exec)
|
|
3094
|
+
const target = normTarget(a)
|
|
3095
|
+
if (!setEditorKernel(target, a.mode)) return json(res, { error: '非法内核参数: target=' + target + ' mode=' + a.mode })
|
|
3096
|
+
await persist(exec)
|
|
3097
|
+
return json(res, statusView(exec))
|
|
3098
|
+
}
|
|
2051
3099
|
if (pathname === '/permgate/set-categories' && method === 'POST') {
|
|
2052
3100
|
await init(exec)
|
|
2053
3101
|
for (const t of ['global', 'project']) {
|
|
@@ -2084,23 +3132,20 @@ export default {
|
|
|
2084
3132
|
if (!a.match || !String(a.match)) return json(res, { error: 'match 不能为空' })
|
|
2085
3133
|
const e = normalizeException({ id: 'e' + Math.random().toString(36).slice(2, 8), action: a.action, reason: a.reason, path: a.category === 'command' ? undefined : a.match, match: a.category === 'command' ? a.match : undefined }, a.category)
|
|
2086
3134
|
if (!e) return json(res, { error: '非法的例外参数' })
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
3135
|
+
// 例外写入统一走 addProjectException:与候选写入共用「同方向不重复、新决定插头部」的语义,
|
|
3136
|
+
// 否则面板新加的例外会排在历史条目之后,被 resolveCategory 的首个匹配静默屏蔽。
|
|
3137
|
+
const written = addProjectException(a.category, a.category === 'command' ? 'command' : 'path', a.match, a.action, { target: a.target, reason: a.reason })
|
|
3138
|
+
if (!written) return json(res, { error: '例外未写入:分类/参数不支持' })
|
|
2091
3139
|
await persist(exec)
|
|
2092
|
-
return json(res, { added:
|
|
3140
|
+
return json(res, { added: written, status: statusView(exec) })
|
|
2093
3141
|
}
|
|
2094
3142
|
if (pathname === '/permgate/remove-exception' && method === 'POST') {
|
|
2095
3143
|
await init(exec)
|
|
2096
3144
|
const block = a.target === 'project' ? ensureProject() : config.global
|
|
2097
|
-
const
|
|
2098
|
-
if (!
|
|
2099
|
-
const idx = cat.exceptions.findIndex((r) => r.id === a.id)
|
|
2100
|
-
if (idx === -1) return json(res, { removed: false, reason: '未找到 id=' + a.id })
|
|
2101
|
-
const removed = cat.exceptions.splice(idx, 1)[0]
|
|
3145
|
+
const del = removeExceptionEntries(block, a.category, a.id)
|
|
3146
|
+
if (!del.removed) return json(res, { removed: false, reason: del.reason })
|
|
2102
3147
|
await persist(exec)
|
|
2103
|
-
return json(res, { removed: true, exception:
|
|
3148
|
+
return json(res, { removed: true, exception: del.exception, removedCount: del.count, remaining: del.remaining, status: statusView(exec) })
|
|
2104
3149
|
}
|
|
2105
3150
|
if (pathname === '/permgate/add-rule' && method === 'POST') {
|
|
2106
3151
|
await init(exec)
|
|
@@ -2124,6 +3169,8 @@ export default {
|
|
|
2124
3169
|
return json(res, { removed: true, rule: removed, status: statusView(exec) })
|
|
2125
3170
|
}
|
|
2126
3171
|
if (pathname === '/permgate/reload' && method === 'POST') {
|
|
3172
|
+
// 重置 target 缓存:强制重新解析配置路径,保证竞态残留的项目路径配置可切回 home
|
|
3173
|
+
target = null
|
|
2127
3174
|
await load(exec)
|
|
2128
3175
|
return json(res, statusView(exec))
|
|
2129
3176
|
}
|
|
@@ -2156,8 +3203,8 @@ export default {
|
|
|
2156
3203
|
if (pathname === '/permgate/open-file' && method === 'POST') {
|
|
2157
3204
|
const entry = pendingApprovals.get(a.id)
|
|
2158
3205
|
if (!entry) return json(res, { ok: false, error: lang === 'en' ? 'Approval request not found or expired' : '审批请求不存在或已过期' })
|
|
2159
|
-
if (!FILE_READ_TOOLS[entry.tool] && !FILE_WRITE_TOOLS[entry.tool]) return json(res, { ok: false, error: lang === 'en' ? 'Unsupported tool for opening file' : '该审批不支持打开文件' })
|
|
2160
3206
|
const args = parseEntryArgs(entry)
|
|
3207
|
+
if (!isPreviewableFileTool(entry.tool, args)) return json(res, { ok: false, error: lang === 'en' ? 'Unsupported tool for opening file' : '该审批不支持打开文件' })
|
|
2161
3208
|
const fp = pathArg(args)
|
|
2162
3209
|
if (!fp) return json(res, { ok: false, error: lang === 'en' ? 'Missing file path' : '缺少文件路径' })
|
|
2163
3210
|
// 仅允许文本/文档类扩展名(点开头文件如 .gitignore 视为无扩展名,Windows 不会执行)
|
|
@@ -2242,7 +3289,7 @@ export default {
|
|
|
2242
3289
|
|
|
2243
3290
|
registerTool({
|
|
2244
3291
|
name: 'perm_status',
|
|
2245
|
-
description: '查看权限网关(permgate)当前生效的分类默认(
|
|
3292
|
+
description: '查看权限网关(permgate)当前生效的分类默认(目录/命令/读取/读取图片/编辑/撤销操作/子代理/重复操作)、例外、快捷工具、自定义规则、最近决策与配置路径。',
|
|
2246
3293
|
parameters: {},
|
|
2247
3294
|
output: { schema: { type: 'json' }, render: renderer() },
|
|
2248
3295
|
async execute(_args, exec) { await init(exec); return statusView(exec) },
|
|
@@ -2250,10 +3297,10 @@ export default {
|
|
|
2250
3297
|
|
|
2251
3298
|
registerTool({
|
|
2252
3299
|
name: 'perm_set_category',
|
|
2253
|
-
description: '设置一个权限分类的默认动作。分类: directory=目录访问(工作区外), command=执行命令, read=读取文件, edit=编辑文件, subagent=启动子代理, doomloop=重复操作。动作: ask=询问, allow=允许, deny=拒绝; 项目(target=project)还支持 inherit=继承全局。',
|
|
3300
|
+
description: '设置一个权限分类的默认动作。分类: directory=目录访问(工作区外), command=执行命令, read=读取文件, image=读取图片, edit=编辑文件, undo=撤销操作(恢复上次编辑前的内容), subagent=启动子代理, doomloop=重复操作。动作: ask=询问, allow=允许, deny=拒绝; 项目(target=project)还支持 inherit=继承全局。',
|
|
2254
3301
|
parameters: {
|
|
2255
3302
|
target: { type: 'string', required: true, enum: ['global', 'project'] },
|
|
2256
|
-
category: { type: 'string', required: true, enum:
|
|
3303
|
+
category: { type: 'string', required: true, enum: CATEGORY_ENUM },
|
|
2257
3304
|
mode: { type: 'string', required: true, enum: ['ask', 'allow', 'deny', 'inherit'], description: '目标动作;inherit 仅适用于项目' },
|
|
2258
3305
|
},
|
|
2259
3306
|
output: { schema: { type: 'json' }, render: renderer() },
|
|
@@ -2266,12 +3313,44 @@ export default {
|
|
|
2266
3313
|
},
|
|
2267
3314
|
})
|
|
2268
3315
|
|
|
3316
|
+
registerTool({
|
|
3317
|
+
name: 'perm_set_fallback',
|
|
3318
|
+
description: '设置「未匹配任何规则」时的兜底动作(默认 ask=询问):ask=每个未匹配的调用都弹审批;allow=直接放行;deny=直接拒绝。directory/command/read/image/edit/undo/subagent/doomloop 之外的所有工具调用都归兜底策略。项目(target=project)还支持 inherit=继承全局。',
|
|
3319
|
+
parameters: {
|
|
3320
|
+
target: { type: 'string', required: true, enum: ['global', 'project'] },
|
|
3321
|
+
mode: { type: 'string', required: true, enum: ALL_MODES, description: '兜底动作;inherit 仅适用于项目' },
|
|
3322
|
+
},
|
|
3323
|
+
output: { schema: { type: 'json' }, render: renderer() },
|
|
3324
|
+
async execute(args, exec) {
|
|
3325
|
+
await init(exec)
|
|
3326
|
+
if (!setFallbackMode(args.target, args.mode)) return { error: '非法的 target/mode 组合' }
|
|
3327
|
+
await persist(exec)
|
|
3328
|
+
return statusView(exec)
|
|
3329
|
+
},
|
|
3330
|
+
})
|
|
3331
|
+
|
|
3332
|
+
registerTool({
|
|
3333
|
+
name: 'perm_set_editor_kernel',
|
|
3334
|
+
description: '设置 str_replace_editor 的内核(默认 auto=自动判别):auto=按当前实际注册的工具描述判别;builtin=DSH 内置(官方语义,insert_line 0 基、插入到该行之后);shadow=dsh-better-edit 的覆盖实现(insert_line 1 基、插入到该行之前)。两者 insert 的 insert_line 语义相反,判别错误会让审批弹窗展示错误位置的改动。项目(target=project)还支持 inherit=继承全局。',
|
|
3335
|
+
parameters: {
|
|
3336
|
+
target: { type: 'string', required: true, enum: ['global', 'project'] },
|
|
3337
|
+
mode: { type: 'string', required: true, enum: EDITOR_KERNEL_VALUES, description: '内核判别方式;inherit 仅适用于项目' },
|
|
3338
|
+
},
|
|
3339
|
+
output: { schema: { type: 'json' }, render: renderer() },
|
|
3340
|
+
async execute(args, exec) {
|
|
3341
|
+
await init(exec)
|
|
3342
|
+
if (!setEditorKernel(args.target, args.mode)) return { error: '非法的 target/mode 组合' }
|
|
3343
|
+
await persist(exec)
|
|
3344
|
+
return statusView(exec)
|
|
3345
|
+
},
|
|
3346
|
+
})
|
|
3347
|
+
|
|
2269
3348
|
registerTool({
|
|
2270
3349
|
name: 'perm_add_exception',
|
|
2271
|
-
description: '给分类添加一条例外。directory/read/edit 分类用 path(路径 glob,支持 * 与 ** 通配,如 G:/MCP/**、**/*.env);command 分类用 match(命令名或子串,支持 * 通配任意剩余,如 Get-Item * / git status)。例外优先于分类默认动作,仅 allow/deny。',
|
|
3350
|
+
description: '给分类添加一条例外。directory/read/image/edit/undo 分类用 path(路径 glob,支持 * 与 ** 通配,如 G:/MCP/**、**/*.env);command 分类用 match(命令名或子串,支持 * 通配任意剩余,如 Get-Item * / git status)。例外优先于分类默认动作,仅 allow/deny。',
|
|
2272
3351
|
parameters: {
|
|
2273
3352
|
target: { type: 'string', required: true, enum: ['global', 'project'] },
|
|
2274
|
-
category: { type: 'string', required: true, enum:
|
|
3353
|
+
category: { type: 'string', required: true, enum: EXC_CATEGORY_ENUM },
|
|
2275
3354
|
match: { type: 'string', required: true, description: '路径 glob 或命令名/子串(* 匹配任意剩余)' },
|
|
2276
3355
|
action: { type: 'string', required: true, enum: ['allow', 'deny'], description: '命中例外后的动作' },
|
|
2277
3356
|
reason: { type: 'string', description: '自定义拒绝原因(仅 deny 例外生效;allow 例外忽略)' },
|
|
@@ -2283,12 +3362,11 @@ export default {
|
|
|
2283
3362
|
if (!args.match || !String(args.match)) return { error: 'match 不能为空' }
|
|
2284
3363
|
const e = normalizeException({ id: 'e' + Math.random().toString(36).slice(2, 8), action: args.action, reason: args.reason, path: args.category === 'command' ? undefined : args.match, match: args.category === 'command' ? args.match : undefined }, args.category)
|
|
2285
3364
|
if (!e) return { error: '非法的例外参数' }
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
if (!
|
|
2289
|
-
block[args.category].exceptions.push(e)
|
|
3365
|
+
// 与面板路由共用同一写入点:同方向不重复、新决定插到数组头部,避免被历史条目遮蔽。
|
|
3366
|
+
const written = addProjectException(args.category, args.category === 'command' ? 'command' : 'path', args.match, args.action, { target: args.target, reason: args.reason })
|
|
3367
|
+
if (!written) return { error: '例外未写入:分类/参数不支持' }
|
|
2290
3368
|
await persist(exec)
|
|
2291
|
-
return { added:
|
|
3369
|
+
return { added: written, status: statusView(exec) }
|
|
2292
3370
|
},
|
|
2293
3371
|
})
|
|
2294
3372
|
|
|
@@ -2297,20 +3375,17 @@ export default {
|
|
|
2297
3375
|
description: '按 id 删除一条分类例外(id 见 perm_status 返回的 exceptions 或 perm_add_exception 返回)。',
|
|
2298
3376
|
parameters: {
|
|
2299
3377
|
target: { type: 'string', required: true, enum: ['global', 'project'] },
|
|
2300
|
-
category: { type: 'string', required: true, enum:
|
|
3378
|
+
category: { type: 'string', required: true, enum: EXC_CATEGORY_ENUM },
|
|
2301
3379
|
id: { type: 'string', required: true },
|
|
2302
3380
|
},
|
|
2303
3381
|
output: { schema: { type: 'json' }, render: renderer() },
|
|
2304
3382
|
async execute(args, exec) {
|
|
2305
3383
|
await init(exec)
|
|
2306
3384
|
const block = args.target === 'project' ? ensureProject() : config.global
|
|
2307
|
-
const
|
|
2308
|
-
if (!
|
|
2309
|
-
const idx = cat.exceptions.findIndex((r) => r.id === args.id)
|
|
2310
|
-
if (idx === -1) return { removed: false, reason: '未找到 id=' + args.id, status: statusView(exec) }
|
|
2311
|
-
const removed = cat.exceptions.splice(idx, 1)[0]
|
|
3385
|
+
const del = removeExceptionEntries(block, args.category, args.id)
|
|
3386
|
+
if (!del.removed) return { removed: false, reason: del.reason, status: statusView(exec) }
|
|
2312
3387
|
await persist(exec)
|
|
2313
|
-
return { removed: true, exception:
|
|
3388
|
+
return { removed: true, exception: del.exception, removedCount: del.count, remaining: del.remaining, status: statusView(exec) }
|
|
2314
3389
|
},
|
|
2315
3390
|
})
|
|
2316
3391
|
|