@trim21/personal-pi-extensions 0.1.641 → 0.1.643
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 +2 -2
- package/package.json +1 -1
- package/src/claude-code/files.ts +26 -34
- package/src/lib/lsp/client.ts +23 -11
- package/src/lib/lsp/lsp.ts +32 -39
- package/src/lib/lsp/rename-tool.ts +4 -8
- package/src/lib/write-guard.ts +41 -66
- package/src/opencode/files.ts +31 -42
package/README.md
CHANGED
|
@@ -137,7 +137,7 @@ bwrap 已集成进 bash 工具实现(opencode 风格 `bash` 位于 `src/openco
|
|
|
137
137
|
写保护直接内置在各写工具(opencode 风格 `write`/`edit`、Claude Code 风格 `Write`/`Edit`)内部,通过 `src/lib/write-guard.ts` 的 `guardWriteAccess` 实现。读取工具(`read`、`ls`、`find`、`grep`)不受限制。
|
|
138
138
|
|
|
139
139
|
- workspace 内或 `/tmp` 下的路径自动放行
|
|
140
|
-
- 外部路径需通过确认对话框由用户审批(**Approve once** / **Block** / **Block with reason**),对话框内以 ```diff
|
|
140
|
+
- 外部路径需通过确认对话框由用户审批(**Approve once** / **Block** / **Block with reason**),对话框内以 ```diff 代码块展示将要发生的变更;预览由「将要写入的内容」直接算出,批准后写的就是这份内容(批准前若文件被外部改动则拒绝写入)
|
|
141
141
|
- headless(无 UI)会话直接拒绝外部写入;Windows 上不提供审批路径,工作区外写入一律拒绝
|
|
142
142
|
- `/bwrap-deny-request` 生效期间外部写入按无理由拒绝处理(`user deny <tool>: blocked`)
|
|
143
143
|
- 无需配置,随各写工具自动生效
|
|
@@ -253,7 +253,7 @@ opencode 风格工具集,随 `src/opencode/index.ts` 一次加载:`read` / `
|
|
|
253
253
|
|
|
254
254
|
### edit
|
|
255
255
|
|
|
256
|
-
`edit` 使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数复制自 opencode,匹配引擎位于 `src/opencode/edit-engine.ts
|
|
256
|
+
`edit` 使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数复制自 opencode,匹配引擎位于 `src/opencode/edit-engine.ts`。唯一的有意差异是去掉了原版的转义规范化(EscapeNormalizedReplacer):它会把源码里合法的 `\n`、`\t` 等转义序列当成模型多转义的产物做启发式反转义,可能改坏内容。
|
|
257
257
|
|
|
258
258
|
支持的匹配策略:
|
|
259
259
|
|
package/package.json
CHANGED
package/src/claude-code/files.ts
CHANGED
|
@@ -345,31 +345,6 @@ export function registerFileTools(
|
|
|
345
345
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
346
346
|
signal?.throwIfAborted();
|
|
347
347
|
const filePath = resolveToolFilePath(params.file_path, ctx.cwd);
|
|
348
|
-
await guardWriteAccess(ctx, {
|
|
349
|
-
toolName: "Edit",
|
|
350
|
-
absolutePath: filePath,
|
|
351
|
-
// 审批预览必须与落盘同语义:这里注入本工具自己的精确匹配(空 old_string
|
|
352
|
-
// 是创建/填充文件,按整文件写入预览)
|
|
353
|
-
change:
|
|
354
|
-
params.old_string === ""
|
|
355
|
-
? { kind: "write", newText: params.new_string }
|
|
356
|
-
: {
|
|
357
|
-
kind: "edit",
|
|
358
|
-
oldText: params.old_string,
|
|
359
|
-
newText: params.new_string,
|
|
360
|
-
apply: (content) => ({
|
|
361
|
-
contentOld: content,
|
|
362
|
-
contentNew: applyExactEdit(
|
|
363
|
-
content,
|
|
364
|
-
params.old_string,
|
|
365
|
-
params.new_string,
|
|
366
|
-
params.replace_all ?? false,
|
|
367
|
-
),
|
|
368
|
-
}),
|
|
369
|
-
},
|
|
370
|
-
policy,
|
|
371
|
-
signal,
|
|
372
|
-
});
|
|
373
348
|
const [message, details, diagnostics] = await withFileMutationQueue<
|
|
374
349
|
[string, FileToolDetails, DiagnosticReport]
|
|
375
350
|
>(filePath, async () => {
|
|
@@ -381,11 +356,12 @@ export function registerFileTools(
|
|
|
381
356
|
// 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
|
|
382
357
|
if (oldString === "") {
|
|
383
358
|
let exists = true;
|
|
359
|
+
let contentOld = "";
|
|
384
360
|
try {
|
|
385
361
|
const value = await stat(filePath);
|
|
386
362
|
if (value.isFile()) {
|
|
387
|
-
|
|
388
|
-
if (
|
|
363
|
+
contentOld = await readFile(filePath, "utf8");
|
|
364
|
+
if (contentOld.trim() !== "") {
|
|
389
365
|
throw new Error("Cannot create new file - file already exists.");
|
|
390
366
|
}
|
|
391
367
|
}
|
|
@@ -396,6 +372,14 @@ export function registerFileTools(
|
|
|
396
372
|
throw error;
|
|
397
373
|
}
|
|
398
374
|
}
|
|
375
|
+
// 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
|
|
376
|
+
await guardWriteAccess(ctx, {
|
|
377
|
+
toolName: "Edit",
|
|
378
|
+
absolutePath: filePath,
|
|
379
|
+
mutation: { contentOld, contentNew: newString },
|
|
380
|
+
policy,
|
|
381
|
+
signal,
|
|
382
|
+
});
|
|
399
383
|
if (!exists) {
|
|
400
384
|
await mkdir(dirname(filePath), { recursive: true });
|
|
401
385
|
}
|
|
@@ -458,6 +442,13 @@ export function registerFileTools(
|
|
|
458
442
|
signal?.throwIfAborted();
|
|
459
443
|
const original = content.toString("utf8");
|
|
460
444
|
const restored = applyExactEdit(original, oldString, newString, replaceAll);
|
|
445
|
+
await guardWriteAccess(ctx, {
|
|
446
|
+
toolName: "Edit",
|
|
447
|
+
absolutePath: filePath,
|
|
448
|
+
mutation: { contentOld: original, contentNew: restored },
|
|
449
|
+
policy,
|
|
450
|
+
signal,
|
|
451
|
+
});
|
|
461
452
|
await writeFile(filePath, restored, "utf8");
|
|
462
453
|
const snapshot = snapshotOf(restored);
|
|
463
454
|
state.reads.set(key, snapshot);
|
|
@@ -532,13 +523,6 @@ export function registerFileTools(
|
|
|
532
523
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
533
524
|
signal?.throwIfAborted();
|
|
534
525
|
const filePath = resolveToolFilePath(params.file_path, ctx.cwd);
|
|
535
|
-
await guardWriteAccess(ctx, {
|
|
536
|
-
toolName: "Write",
|
|
537
|
-
absolutePath: filePath,
|
|
538
|
-
change: { kind: "write", newText: params.content },
|
|
539
|
-
policy,
|
|
540
|
-
signal,
|
|
541
|
-
});
|
|
542
526
|
const [message, details, diagnostics] = await withFileMutationQueue<
|
|
543
527
|
[string, FileToolDetails, DiagnosticReport]
|
|
544
528
|
>(filePath, async () => {
|
|
@@ -558,6 +542,14 @@ export function registerFileTools(
|
|
|
558
542
|
}
|
|
559
543
|
}
|
|
560
544
|
signal?.throwIfAborted();
|
|
545
|
+
// 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
|
|
546
|
+
await guardWriteAccess(ctx, {
|
|
547
|
+
toolName: "Write",
|
|
548
|
+
absolutePath: filePath,
|
|
549
|
+
mutation: { contentOld: original ?? "", contentNew: params.content },
|
|
550
|
+
policy,
|
|
551
|
+
signal,
|
|
552
|
+
});
|
|
561
553
|
await mkdir(dirname(filePath), { recursive: true });
|
|
562
554
|
await writeFile(filePath, params.content, "utf8");
|
|
563
555
|
const snapshot = snapshotOf(params.content);
|
package/src/lib/lsp/client.ts
CHANGED
|
@@ -282,21 +282,33 @@ export const clientDefaults = {
|
|
|
282
282
|
maxOpenDocuments: 32,
|
|
283
283
|
} as const;
|
|
284
284
|
|
|
285
|
-
|
|
285
|
+
/**
|
|
286
|
+
* 客户端可调项的唯一定义:`create()` 的覆盖参数、`lsp.ts` 的 `ResolvedLspConfig`
|
|
287
|
+
* 与 `resolveConfig` 共用同一份字段集合,新增旋钮只需在这里声明一次即可被
|
|
288
|
+
* 配置解析与客户端构造同时要求。超时均为解析后的毫秒数,缺省值见 clientDefaults。
|
|
289
|
+
*/
|
|
290
|
+
export interface LspClientOptions {
|
|
291
|
+
/** push 诊断去抖(ms)。 */
|
|
292
|
+
diagnosticsDebounceMs: number;
|
|
293
|
+
/** document 模式诊断等待上限(ms)。 */
|
|
294
|
+
diagnosticsDocumentWaitTimeoutMs: number;
|
|
295
|
+
/** 上一份诊断为空的文档的等待上限(ms);缺省见 clientDefaults。 */
|
|
296
|
+
diagnosticsSilentWaitTimeoutMs: number;
|
|
297
|
+
/** full 模式诊断等待上限(ms)。 */
|
|
298
|
+
diagnosticsFullWaitTimeoutMs: number;
|
|
299
|
+
/** 单次 pull 诊断请求超时(ms)。 */
|
|
300
|
+
diagnosticsRequestTimeoutMs: number;
|
|
301
|
+
/** 服务器 initialize 握手超时(ms)。 */
|
|
302
|
+
initializeTimeoutMs: number;
|
|
303
|
+
/** 驻留文档上限(LRU 容量,缺省 32);超过时淘汰最久未使用并 didClose。 */
|
|
304
|
+
maxOpenDocuments: number;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface CreateInput extends Partial<LspClientOptions> {
|
|
286
308
|
serverID: string;
|
|
287
309
|
server: LspServerHandle;
|
|
288
310
|
root: string;
|
|
289
311
|
directory: string;
|
|
290
|
-
/** 可覆盖的超时参数(缺省用 client 默认值,由全局/本地 lsp.json 配置注入)。 */
|
|
291
|
-
diagnosticsDebounceMs?: number;
|
|
292
|
-
diagnosticsDocumentWaitTimeoutMs?: number;
|
|
293
|
-
/** 上一份诊断为空的文档的等待上限;缺省见 clientDefaults。 */
|
|
294
|
-
diagnosticsSilentWaitTimeoutMs?: number;
|
|
295
|
-
diagnosticsFullWaitTimeoutMs?: number;
|
|
296
|
-
diagnosticsRequestTimeoutMs?: number;
|
|
297
|
-
initializeTimeoutMs?: number;
|
|
298
|
-
/** 驻留文档上限(LRU 容量,缺省 32);超过时淘汰最久未使用并 didClose。 */
|
|
299
|
-
maxOpenDocuments?: number;
|
|
300
312
|
/** 覆盖 rename 覆盖校验的轮询节奏(缺省见 DEFAULT_RENAME_VERIFICATION_TIMING)。 */
|
|
301
313
|
renameVerificationTiming?: Partial<RenameVerificationTiming>;
|
|
302
314
|
}
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type Diagnostic,
|
|
38
38
|
type Info as LspClient,
|
|
39
39
|
type InspectLocation,
|
|
40
|
+
type LspClientOptions,
|
|
40
41
|
LspMethodNotSupportedError,
|
|
41
42
|
RenameNotPossibleError,
|
|
42
43
|
WATCH_KIND_CHANGE,
|
|
@@ -68,7 +69,7 @@ const watchConfigSchema = Type.Object({
|
|
|
68
69
|
ignore: Type.Optional(Type.Array(Type.String())),
|
|
69
70
|
});
|
|
70
71
|
|
|
71
|
-
/** lsp.json 的配置项(全局与本地同构;只描述用户可写的原始形态,缺省见
|
|
72
|
+
/** lsp.json 的配置项(全局与本地同构;只描述用户可写的原始形态,缺省见 watchDefaults / clientDefaults)。 */
|
|
72
73
|
const lspConfigSchema = Type.Object({
|
|
73
74
|
/** 配置文件版本(当前 1);未知版本会被 typebox 严格校验拒绝。 */
|
|
74
75
|
version: Type.Optional(Type.Number()),
|
|
@@ -78,7 +79,7 @@ const lspConfigSchema = Type.Object({
|
|
|
78
79
|
enabled: Type.Optional(Type.Array(Type.String())),
|
|
79
80
|
/** 从启用集中排除的服务器 id(缺省 = 无)。 */
|
|
80
81
|
disabled: Type.Optional(Type.Array(Type.String())),
|
|
81
|
-
/** 工作区文件监听配置(缺省全部字段见
|
|
82
|
+
/** 工作区文件监听配置(缺省全部字段见 watchDefaults)。 */
|
|
82
83
|
watch: Type.Optional(watchConfigSchema),
|
|
83
84
|
/** 驻留文档上限(LRU 容量),超过时淘汰最久未使用并 didClose。 */
|
|
84
85
|
maxOpenDocuments: Type.Optional(Type.Number({ minimum: 1 })),
|
|
@@ -99,18 +100,12 @@ const lspConfigSchema = Type.Object({
|
|
|
99
100
|
/** 配置值(单文件解析结果);超时字段为原始写法(number 或字符串),换算在 resolveConfig。 */
|
|
100
101
|
export type LspConfig = Static<typeof lspConfigSchema>;
|
|
101
102
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
enabled: true,
|
|
109
|
-
debounceMs: 300,
|
|
110
|
-
flushMs: 1_000,
|
|
111
|
-
maxBatch: 500,
|
|
112
|
-
},
|
|
113
|
-
maxOpenDocuments: clientDefaults.maxOpenDocuments,
|
|
103
|
+
/** 解析期缺省:watch 段(无 client 对应项,数值在此集中);客户端参数缺省见 clientDefaults。 */
|
|
104
|
+
const watchDefaults = {
|
|
105
|
+
enabled: true,
|
|
106
|
+
debounceMs: 300,
|
|
107
|
+
flushMs: 1_000,
|
|
108
|
+
maxBatch: 500,
|
|
114
109
|
} as const;
|
|
115
110
|
|
|
116
111
|
/** 服务器启动失败后自动重试的冷却(ms);冷却内跳过,之后下次触碰自动重试。 */
|
|
@@ -127,8 +122,12 @@ interface EffectiveWatchConfig {
|
|
|
127
122
|
ignore: string[];
|
|
128
123
|
}
|
|
129
124
|
|
|
130
|
-
/**
|
|
131
|
-
|
|
125
|
+
/**
|
|
126
|
+
* 全局 + 本地合并并解析后的生效配置:所有字段为确定值,无"未配置"歧义。
|
|
127
|
+
* 客户端参数的整体形状复用 `LspClientOptions`(定义在 client.ts,与
|
|
128
|
+
* `create()` 的覆盖参数同源),因此这里不再逐字段重声明。
|
|
129
|
+
*/
|
|
130
|
+
export interface ResolvedLspConfig extends LspClientOptions {
|
|
132
131
|
/** 合并后的服务器定义(未配置任何服务器时为空表)。 */
|
|
133
132
|
servers: Record<string, ServerConfig>;
|
|
134
133
|
/** enabled 白名单(undefined = 全部启用)。 */
|
|
@@ -137,15 +136,6 @@ export interface ResolvedLspConfig {
|
|
|
137
136
|
disabled: Set<string> | undefined;
|
|
138
137
|
/** 工作区监听配置(缺省值已应用)。 */
|
|
139
138
|
watch: EffectiveWatchConfig;
|
|
140
|
-
/** 驻留文档 LRU 容量。 */
|
|
141
|
-
maxOpenDocuments: number;
|
|
142
|
-
/** 以下超时均为换算后的毫秒数(缺省见 configDefaults / clientDefaults)。 */
|
|
143
|
-
diagnosticsDebounceMs: number;
|
|
144
|
-
diagnosticsDocumentWaitTimeoutMs: number;
|
|
145
|
-
diagnosticsSilentWaitTimeoutMs: number;
|
|
146
|
-
diagnosticsFullWaitTimeoutMs: number;
|
|
147
|
-
diagnosticsRequestTimeoutMs: number;
|
|
148
|
-
initializeTimeoutMs: number;
|
|
149
139
|
}
|
|
150
140
|
|
|
151
141
|
/** "500" → 500、"5s" → 5000、"1m" → 60000;无效字符串返回 NaN(由调用方兜底缺省)。 */
|
|
@@ -177,7 +167,7 @@ function toMs(value: number | string | undefined): number | undefined {
|
|
|
177
167
|
}
|
|
178
168
|
|
|
179
169
|
/**
|
|
180
|
-
* 把合并后的原始配置解析为生效配置:应用
|
|
170
|
+
* 把合并后的原始配置解析为生效配置:应用 watchDefaults / clientDefaults 缺省、字符串时长换算、
|
|
181
171
|
* 白名单转 Set;per-server 的 workingDir / rootMarkers 互斥校验在此完成
|
|
182
172
|
* (配置解析期抛错,与 enabled 白名单校验同层)。
|
|
183
173
|
*/
|
|
@@ -195,13 +185,13 @@ export function resolveConfig(raw: LspConfig): ResolvedLspConfig {
|
|
|
195
185
|
enabled: raw.enabled === undefined ? undefined : new Set(raw.enabled),
|
|
196
186
|
disabled: raw.disabled === undefined ? undefined : new Set(raw.disabled),
|
|
197
187
|
watch: {
|
|
198
|
-
enabled: raw.watch?.enabled ??
|
|
199
|
-
debounceMs: toMs(raw.watch?.debounceMs) ??
|
|
200
|
-
flushMs:
|
|
201
|
-
maxBatch: raw.watch?.maxBatch ??
|
|
188
|
+
enabled: raw.watch?.enabled ?? watchDefaults.enabled,
|
|
189
|
+
debounceMs: toMs(raw.watch?.debounceMs) ?? watchDefaults.debounceMs,
|
|
190
|
+
flushMs: watchDefaults.flushMs,
|
|
191
|
+
maxBatch: raw.watch?.maxBatch ?? watchDefaults.maxBatch,
|
|
202
192
|
ignore: raw.watch?.ignore ?? [],
|
|
203
193
|
},
|
|
204
|
-
maxOpenDocuments: raw.maxOpenDocuments ??
|
|
194
|
+
maxOpenDocuments: raw.maxOpenDocuments ?? clientDefaults.maxOpenDocuments,
|
|
205
195
|
diagnosticsDebounceMs: toMs(raw.diagnosticsDebounceMs) ?? clientDefaults.diagnosticsDebounceMs,
|
|
206
196
|
diagnosticsDocumentWaitTimeoutMs:
|
|
207
197
|
toMs(raw.diagnosticsDocumentWaitTimeoutMs) ?? clientDefaults.diagnosticsDocumentWaitTimeoutMs,
|
|
@@ -848,19 +838,22 @@ export function createLspService(
|
|
|
848
838
|
reportStartupFailure(key, adapter.id, root, "binary not found", notify);
|
|
849
839
|
return;
|
|
850
840
|
}
|
|
841
|
+
const overrides: Partial<LspClientOptions> = {};
|
|
842
|
+
if (adapter.diagnosticsWaitMs !== undefined) {
|
|
843
|
+
overrides.diagnosticsDocumentWaitTimeoutMs = adapter.diagnosticsWaitMs;
|
|
844
|
+
}
|
|
845
|
+
if (adapter.startupTimeoutMs !== undefined) {
|
|
846
|
+
overrides.initializeTimeoutMs = adapter.startupTimeoutMs;
|
|
847
|
+
}
|
|
851
848
|
const client = await create({
|
|
852
849
|
serverID: adapter.id,
|
|
853
850
|
server: handle,
|
|
854
851
|
root,
|
|
855
852
|
directory: cwd,
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
diagnosticsFullWaitTimeoutMs: config.diagnosticsFullWaitTimeoutMs,
|
|
861
|
-
diagnosticsRequestTimeoutMs: config.diagnosticsRequestTimeoutMs,
|
|
862
|
-
initializeTimeoutMs: adapter.startupTimeoutMs ?? config.initializeTimeoutMs,
|
|
863
|
-
maxOpenDocuments: config.maxOpenDocuments,
|
|
853
|
+
// 客户端参数整体取自生效配置(ResolvedLspConfig extends LspClientOptions),
|
|
854
|
+
// 不做逐字段搬运:新增旋钮时漏接线会静默失效,这里靠展开消除该位置。
|
|
855
|
+
...config,
|
|
856
|
+
...overrides,
|
|
864
857
|
});
|
|
865
858
|
if (state.closing || state.disabled) {
|
|
866
859
|
await client.shutdown();
|
|
@@ -194,18 +194,14 @@ export function registerLspRenameTool(
|
|
|
194
194
|
if (applied.length === 0) {
|
|
195
195
|
throw new Error("LSP rename returned no edits");
|
|
196
196
|
}
|
|
197
|
+
// 先逐个文件审批、再逐个写盘:用户在第二个文件上拒绝时不该留下半个 rename。
|
|
198
|
+
// 变更前后内容已由 expandWorkspaceEdit 在内存里算好,审批框展示的就是要写入的
|
|
199
|
+
// 内容;对话框停留期间的外部改动由写保护的指纹校验兜住。
|
|
197
200
|
for (const fileEdit of applied) {
|
|
198
201
|
await guardWriteAccess(ctx, {
|
|
199
202
|
toolName: "lsp-rename",
|
|
200
203
|
absolutePath: fileEdit.path,
|
|
201
|
-
|
|
202
|
-
kind: "edit",
|
|
203
|
-
oldText: fileEdit.oldText,
|
|
204
|
-
newText: fileEdit.newText,
|
|
205
|
-
// LSP 编辑已在内存里展开成整文件的 old/new,落盘是覆盖写:预览直接用
|
|
206
|
-
// 这一对文本,不必再把整份旧内容交给匹配引擎去找
|
|
207
|
-
apply: () => ({ contentOld: fileEdit.oldText, contentNew: fileEdit.newText }),
|
|
208
|
-
},
|
|
204
|
+
mutation: { contentOld: fileEdit.oldText, contentNew: fileEdit.newText },
|
|
209
205
|
policy: options.policy,
|
|
210
206
|
signal,
|
|
211
207
|
});
|
package/src/lib/write-guard.ts
CHANGED
|
@@ -11,17 +11,17 @@
|
|
|
11
11
|
* - `/bwrap-deny-request` (non-sandbox request policy) refuses outside writes
|
|
12
12
|
* outright, with the same error as the user picking "Block".
|
|
13
13
|
*
|
|
14
|
-
* Callers have already parsed their tool arguments
|
|
15
|
-
* resolved pieces: the raw target path and the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Callers have already parsed their tool arguments and computed the result, so the
|
|
15
|
+
* guard takes the resolved pieces: the raw target path and the mutation (the file's
|
|
16
|
+
* content before and after). The preview is rendered from those two strings, so the
|
|
17
|
+
* approved diff is the change that gets written.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { readFile } from "node:fs/promises";
|
|
21
20
|
import { basename, isAbsolute, relative, sep } from "node:path";
|
|
22
21
|
|
|
23
22
|
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
24
23
|
|
|
24
|
+
import { digestIfExists, snapshotOf } from "./file-reads.js";
|
|
25
25
|
import { fenceCodeBlock } from "./markdown.js";
|
|
26
26
|
import type { RequestPolicy } from "./request-policy.js";
|
|
27
27
|
|
|
@@ -59,80 +59,52 @@ function wrapDiff(patch: string): string {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
62
|
+
* 待审批的写入:内容由调用方先读盘并算好,批准后按 `contentNew` 落盘。
|
|
63
|
+
*
|
|
64
|
+
* 两份内容都是文件原始内容(保留 BOM 与行尾),不是匹配引擎的中间产物——这样
|
|
65
|
+
* 预览就是磁盘前后的真实对照,批准后的指纹校验也能与磁盘字节逐字节对上。
|
|
65
66
|
*/
|
|
66
|
-
export
|
|
67
|
+
export interface FileMutation {
|
|
68
|
+
/** 变更前的完整文件内容(文件不存在时为 "")。 */
|
|
67
69
|
readonly contentOld: string;
|
|
70
|
+
/** 变更后的完整内容——即批准后写进磁盘的字节。 */
|
|
68
71
|
readonly contentNew: string;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
/** 整文件写入:预览是完整的增删 patch,不需要匹配实现。 */
|
|
72
|
-
export interface FileWriteChange {
|
|
73
|
-
readonly kind: "write";
|
|
74
|
-
readonly newText: string;
|
|
75
72
|
}
|
|
76
73
|
|
|
77
|
-
/**
|
|
78
|
-
* 替换:`apply` 是调用方自己的匹配实现(精确匹配、模糊匹配,或已展开好的整文件
|
|
79
|
-
* 编辑)。匹配不上时抛错即可,写保护会退化为参数 diff。
|
|
80
|
-
*/
|
|
81
|
-
export interface FileEditChange {
|
|
82
|
-
readonly kind: "edit";
|
|
83
|
-
readonly oldText: string;
|
|
84
|
-
readonly newText: string;
|
|
85
|
-
readonly apply: PendingChangeApply;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** The pending file change, described by the caller from already-parsed args. */
|
|
89
|
-
export type PendingChange = FileWriteChange | FileEditChange;
|
|
90
|
-
|
|
91
74
|
/** 预览统一按 LF 渲染:CRLF 文件的 diff 每行都带 \r,在对话框里显示成乱码。 */
|
|
92
75
|
function normalizeToLF(text: string): string {
|
|
93
76
|
return text.replaceAll("\r\n", "\n");
|
|
94
77
|
}
|
|
95
78
|
|
|
96
|
-
/**
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
79
|
+
/** 待审批写入的 diff 代码块:由将落盘的内容直接算出,不做第二次匹配。 */
|
|
80
|
+
export function renderMutationPreview(resolvedPath: string, mutation: FileMutation): string {
|
|
81
|
+
return wrapDiff(
|
|
82
|
+
generateUnifiedPatch(
|
|
83
|
+
basename(resolvedPath),
|
|
84
|
+
normalizeToLF(mutation.contentOld),
|
|
85
|
+
normalizeToLF(mutation.contentNew),
|
|
86
|
+
2,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
101
89
|
}
|
|
102
90
|
|
|
91
|
+
/** 空内容的指纹:文件不存在与空文件在审批视角下等价。 */
|
|
92
|
+
const EMPTY_DIGEST = snapshotOf("").digest;
|
|
93
|
+
|
|
103
94
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
95
|
+
* 批准后重新取一次磁盘指纹:用户停留在对话框上的这段时间文件可能被外部改动,
|
|
96
|
+
* 此时按审批所用的旧内容算出的 `contentNew` 会覆盖别人的改动。
|
|
106
97
|
*/
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
let fileContent = "";
|
|
112
|
-
try {
|
|
113
|
-
fileContent = await readFile(resolvedPath, "utf8");
|
|
114
|
-
} catch {
|
|
115
|
-
// Unreadable or missing file: treat as empty so writes show as full additions.
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (change.kind === "write") {
|
|
119
|
-
// Whole-file write: show the full addition/replacement patch.
|
|
120
|
-
return wrapDiff(generateUnifiedPatch(basename(resolvedPath), fileContent, change.newText, 2));
|
|
98
|
+
async function requireApprovedContentStillCurrent(opts: WriteGuardOptions): Promise<void> {
|
|
99
|
+
const { mutation } = opts;
|
|
100
|
+
if (mutation === undefined) {
|
|
101
|
+
return;
|
|
121
102
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return wrapDiff(
|
|
127
|
-
generateUnifiedPatch(
|
|
128
|
-
basename(resolvedPath),
|
|
129
|
-
normalizeToLF(contentOld),
|
|
130
|
-
normalizeToLF(contentNew),
|
|
131
|
-
2,
|
|
132
|
-
),
|
|
103
|
+
const current = (await digestIfExists(opts.absolutePath)) ?? EMPTY_DIGEST;
|
|
104
|
+
if (current !== snapshotOf(mutation.contentOld).digest) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.",
|
|
133
107
|
);
|
|
134
|
-
} catch {
|
|
135
|
-
return wrapDiff(parameterDiff(change.oldText, change.newText));
|
|
136
108
|
}
|
|
137
109
|
}
|
|
138
110
|
|
|
@@ -159,9 +131,10 @@ export interface WriteGuardOptions {
|
|
|
159
131
|
/** The resolved absolute target path (caller has already parsed its args). */
|
|
160
132
|
absolutePath: string;
|
|
161
133
|
/**
|
|
162
|
-
*
|
|
134
|
+
* 待审批的写入(变更前后的完整内容);缺省时审批对话框不展示 diff 预览,
|
|
135
|
+
* 仅按路径审批(流式落盘的工具用不上它)。
|
|
163
136
|
*/
|
|
164
|
-
|
|
137
|
+
mutation?: FileMutation;
|
|
165
138
|
/** 调用方所在扩展入口持有的非沙盒请求策略(跨入口一致时绑同一个 pi.events)。 */
|
|
166
139
|
policy: RequestPolicy;
|
|
167
140
|
/** 工具调用的中止信号:透传给审批对话框,工具被取消时对话框一起关掉。 */
|
|
@@ -204,7 +177,8 @@ export async function guardWriteAccess(
|
|
|
204
177
|
}
|
|
205
178
|
|
|
206
179
|
for (;;) {
|
|
207
|
-
const diffPreview =
|
|
180
|
+
const diffPreview =
|
|
181
|
+
opts.mutation === undefined ? undefined : renderMutationPreview(absolutePath, opts.mutation);
|
|
208
182
|
const title =
|
|
209
183
|
`Model requests write access outside workspace:\n\n` +
|
|
210
184
|
` Tool: ${opts.toolName}\n` +
|
|
@@ -220,6 +194,7 @@ export async function guardWriteAccess(
|
|
|
220
194
|
throw new Error(`user deny ${opts.toolName}: cancelled`);
|
|
221
195
|
}
|
|
222
196
|
if (choice === "Approve once") {
|
|
197
|
+
await requireApprovedContentStillCurrent(opts);
|
|
223
198
|
return;
|
|
224
199
|
}
|
|
225
200
|
if (choice === "Block") {
|
package/src/opencode/files.ts
CHANGED
|
@@ -632,27 +632,6 @@ function registerEditTool(
|
|
|
632
632
|
|
|
633
633
|
const absolutePath = isAbsolute(filePath) ? filePath : resolvePath(ctx.cwd, filePath);
|
|
634
634
|
|
|
635
|
-
await guardWriteAccess(ctx, {
|
|
636
|
-
toolName: "edit",
|
|
637
|
-
absolutePath,
|
|
638
|
-
// 审批预览必须与落盘同语义:注入本工具自己的模糊匹配引擎(空 oldString
|
|
639
|
-
// 是创建新文件,按整文件写入预览)
|
|
640
|
-
change:
|
|
641
|
-
oldString === ""
|
|
642
|
-
? { kind: "write", newText: newString }
|
|
643
|
-
: {
|
|
644
|
-
kind: "edit",
|
|
645
|
-
oldText: oldString,
|
|
646
|
-
newText: newString,
|
|
647
|
-
apply: (content) => {
|
|
648
|
-
const applied = applyEdit(content, oldString, newString, replaceAll);
|
|
649
|
-
return { contentOld: applied.contentOld, contentNew: applied.contentNew };
|
|
650
|
-
},
|
|
651
|
-
},
|
|
652
|
-
policy,
|
|
653
|
-
signal,
|
|
654
|
-
});
|
|
655
|
-
|
|
656
635
|
const [message, details, diagnostics] = await withFileMutationQueue(
|
|
657
636
|
absolutePath,
|
|
658
637
|
async () => {
|
|
@@ -677,6 +656,14 @@ function registerEditTool(
|
|
|
677
656
|
);
|
|
678
657
|
}
|
|
679
658
|
// opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
|
|
659
|
+
// 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
|
|
660
|
+
await guardWriteAccess(ctx, {
|
|
661
|
+
toolName: "edit",
|
|
662
|
+
absolutePath,
|
|
663
|
+
mutation: { contentOld: "", contentNew: newString },
|
|
664
|
+
policy,
|
|
665
|
+
signal,
|
|
666
|
+
});
|
|
680
667
|
await mkdir(dirname(absolutePath), { recursive: true });
|
|
681
668
|
signal?.throwIfAborted();
|
|
682
669
|
await writeFile(absolutePath, newString, "utf8");
|
|
@@ -720,6 +707,13 @@ function registerEditTool(
|
|
|
720
707
|
|
|
721
708
|
const applied = applyEdit(rawContent, oldString, newString, replaceAll);
|
|
722
709
|
signal?.throwIfAborted();
|
|
710
|
+
await guardWriteAccess(ctx, {
|
|
711
|
+
toolName: "edit",
|
|
712
|
+
absolutePath,
|
|
713
|
+
mutation: { contentOld: rawContent, contentNew: applied.finalContent },
|
|
714
|
+
policy,
|
|
715
|
+
signal,
|
|
716
|
+
});
|
|
723
717
|
await writeFile(absolutePath, applied.finalContent, "utf8");
|
|
724
718
|
const snapshot = snapshotOf(applied.finalContent);
|
|
725
719
|
state.reads.set(key, snapshot);
|
|
@@ -810,13 +804,6 @@ function registerWriteTool(
|
|
|
810
804
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
811
805
|
const { filePath: rawPath, content } = params;
|
|
812
806
|
const absolutePath = resolvePath(ctx.cwd, rawPath);
|
|
813
|
-
await guardWriteAccess(ctx, {
|
|
814
|
-
toolName: "write",
|
|
815
|
-
absolutePath,
|
|
816
|
-
change: { kind: "write", newText: content },
|
|
817
|
-
policy,
|
|
818
|
-
signal,
|
|
819
|
-
});
|
|
820
807
|
const dir = dirname(absolutePath);
|
|
821
808
|
|
|
822
809
|
const [message, details, diagnostics] = await withFileMutationQueue(
|
|
@@ -832,29 +819,31 @@ function registerWriteTool(
|
|
|
832
819
|
}
|
|
833
820
|
|
|
834
821
|
// opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
|
|
835
|
-
// 否则用新内容自带的 BOM
|
|
822
|
+
// 否则用新内容自带的 BOM。旧内容整读一次,既是 BOM 的来源,也是审批预览
|
|
823
|
+
// 的「变更前内容」。
|
|
836
824
|
let existing: Buffer | undefined;
|
|
837
825
|
try {
|
|
838
|
-
|
|
839
|
-
try {
|
|
840
|
-
existing = Buffer.alloc(3);
|
|
841
|
-
const { bytesRead } = await fh.read(existing, 0, 3, 0);
|
|
842
|
-
if (bytesRead < 3) {
|
|
843
|
-
existing = undefined;
|
|
844
|
-
}
|
|
845
|
-
} finally {
|
|
846
|
-
await fh.close();
|
|
847
|
-
}
|
|
826
|
+
existing = await readFile(absolutePath);
|
|
848
827
|
} catch {
|
|
849
|
-
//
|
|
828
|
+
// 文件不存在:无旧文件
|
|
850
829
|
}
|
|
851
830
|
const { bom: desiredBom, text: nextText } = resolveBom(existing, content);
|
|
831
|
+
const finalContent = desiredBom + nextText;
|
|
832
|
+
|
|
833
|
+
// 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
|
|
834
|
+
await guardWriteAccess(ctx, {
|
|
835
|
+
toolName: "write",
|
|
836
|
+
absolutePath,
|
|
837
|
+
mutation: { contentOld: existing?.toString("utf8") ?? "", contentNew: finalContent },
|
|
838
|
+
policy,
|
|
839
|
+
signal,
|
|
840
|
+
});
|
|
852
841
|
|
|
853
842
|
await mkdir(dir, { recursive: true });
|
|
854
843
|
signal?.throwIfAborted();
|
|
855
|
-
await writeFile(absolutePath,
|
|
844
|
+
await writeFile(absolutePath, finalContent, "utf8");
|
|
856
845
|
// 写后记账(key 在写入前已算过):紧接着的 edit 不该再要求重新 read
|
|
857
|
-
const snapshot = snapshotOf(
|
|
846
|
+
const snapshot = snapshotOf(finalContent);
|
|
858
847
|
state.reads.set(key, snapshot);
|
|
859
848
|
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
860
849
|
signal,
|