@yeaft/webchat-agent 1.0.527 → 1.0.528
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/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/engine.js +13 -1
- package/yeaft/sub-agent/execution-control.js +16 -9
- package/yeaft/sub-agent/liveness.js +20 -12
- package/yeaft/sub-agent/runner.js +12 -15
- package/yeaft/sub-agent/status.js +5 -5
- package/yeaft/tools/activation.js +1 -1
- package/yeaft/tools/agent.js +21 -40
- package/yeaft/tools/apply-patch.js +442 -134
- package/yeaft/tools/bash.js +11 -9
- package/yeaft/tools/exit-worktree.js +1 -1
- package/yeaft/tools/file-read.js +36 -4
- package/yeaft/tools/git-read.js +29 -9
- package/yeaft/tools/list-agents.js +60 -58
- package/yeaft/tools/list-tasks.js +31 -3
- package/yeaft/tools/process-runner.js +29 -1
- package/yeaft/tools/registry.js +18 -0
- package/yeaft/tools/update-agent.js +2 -1
- package/yeaft/tools/wait-agent.js +17 -17
- package/yeaft/tools/web-fetch.js +6 -1
|
@@ -1,129 +1,450 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* apply-patch.js —
|
|
2
|
+
* apply-patch.js — Validate and apply a unified diff patch to files.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The complete patch and every source file are validated before the first
|
|
5
|
+
* write. Filesystem writes are still sequential and therefore are not a
|
|
6
|
+
* cross-file transaction when a runtime I/O error occurs.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { defineTool } from './types.js';
|
|
9
|
-
import { readFile,
|
|
10
|
-
import { existsSync } from 'fs';
|
|
11
|
-
import { resolve,
|
|
10
|
+
import { readFile, mkdir, lstat, realpath, open } from 'fs/promises';
|
|
11
|
+
import { existsSync, constants } from 'fs';
|
|
12
|
+
import { dirname, posix, resolve, win32 } from 'path';
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
const NO_NEWLINE_MARKER = '\';
|
|
15
|
+
|
|
16
|
+
function patchError(message, lineNumber) {
|
|
17
|
+
const suffix = lineNumber == null ? '' : ` at patch line ${lineNumber}`;
|
|
18
|
+
return new Error(`${message}${suffix}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parsePatchTarget(line, prefix, lineNumber) {
|
|
19
22
|
const raw = line.slice(4).replace(/\r$/, '');
|
|
20
23
|
const tabIndex = raw.indexOf('\t');
|
|
21
24
|
let filePath = (tabIndex === -1 ? raw : raw.slice(0, tabIndex)).trim();
|
|
22
|
-
if (!filePath) throw
|
|
23
|
-
if (/[\u0000-\
|
|
24
|
-
throw
|
|
25
|
+
if (!filePath) throw patchError('Patch target path is empty', lineNumber);
|
|
26
|
+
if (/[\u0000-\u001f\u007f]/.test(filePath)) {
|
|
27
|
+
throw patchError('Patch target path contains control characters', lineNumber);
|
|
28
|
+
}
|
|
29
|
+
if (filePath === '/dev/null') return null;
|
|
30
|
+
if (filePath.startsWith('"') || filePath.endsWith('"')) {
|
|
31
|
+
throw patchError('Quoted patch target paths are not supported', lineNumber);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (filePath.startsWith(`${prefix}/`)) filePath = filePath.slice(2);
|
|
35
|
+
if (!filePath || filePath === '.' || filePath.includes('\\')
|
|
36
|
+
|| posix.isAbsolute(filePath) || win32.isAbsolute(filePath)) {
|
|
37
|
+
throw patchError(`Patch target must be a relative path: ${filePath}`, lineNumber);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const normalized = posix.normalize(filePath);
|
|
41
|
+
if (normalized === '..' || normalized.startsWith('../')
|
|
42
|
+
|| filePath.split('/').includes('..')) {
|
|
43
|
+
throw patchError(`Patch target escapes the working directory: ${filePath}`, lineNumber);
|
|
44
|
+
}
|
|
45
|
+
return normalized;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseHunkHeader(line, lineNumber) {
|
|
49
|
+
const match = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?\r?$/);
|
|
50
|
+
if (!match) throw patchError('Malformed hunk header', lineNumber);
|
|
51
|
+
|
|
52
|
+
const hunk = {
|
|
53
|
+
oldStart: Number.parseInt(match[1], 10),
|
|
54
|
+
oldCount: match[2] == null ? 1 : Number.parseInt(match[2], 10),
|
|
55
|
+
newStart: Number.parseInt(match[3], 10),
|
|
56
|
+
newCount: match[4] == null ? 1 : Number.parseInt(match[4], 10),
|
|
57
|
+
lines: [],
|
|
58
|
+
};
|
|
59
|
+
if (![hunk.oldStart, hunk.oldCount, hunk.newStart, hunk.newCount].every(Number.isSafeInteger)) {
|
|
60
|
+
throw patchError('Hunk ranges must be safe integers', lineNumber);
|
|
61
|
+
}
|
|
62
|
+
if (hunk.oldStart === 0 && hunk.oldCount !== 0) {
|
|
63
|
+
throw patchError('A non-empty old range must start at line 1 or later', lineNumber);
|
|
64
|
+
}
|
|
65
|
+
if (hunk.newStart === 0 && hunk.newCount !== 0) {
|
|
66
|
+
throw patchError('A non-empty new range must start at line 1 or later', lineNumber);
|
|
67
|
+
}
|
|
68
|
+
return hunk;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function oldLineIndex(hunk) {
|
|
72
|
+
return hunk.oldCount === 0 ? hunk.oldStart : hunk.oldStart - 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function newLineIndex(hunk) {
|
|
76
|
+
return hunk.newCount === 0 ? hunk.newStart : hunk.newStart - 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateParsedFile(fileDiff, lineNumber) {
|
|
80
|
+
if (fileDiff.oldFile == null && fileDiff.newFile == null) {
|
|
81
|
+
throw patchError('Both patch targets cannot be /dev/null', lineNumber);
|
|
82
|
+
}
|
|
83
|
+
if (fileDiff.newFile == null) {
|
|
84
|
+
throw patchError('File deletion patches are not supported', lineNumber);
|
|
85
|
+
}
|
|
86
|
+
if (fileDiff.oldFile != null && fileDiff.oldFile !== fileDiff.newFile) {
|
|
87
|
+
throw patchError('File rename patches are not supported', lineNumber);
|
|
88
|
+
}
|
|
89
|
+
if (fileDiff.hunks.length === 0) {
|
|
90
|
+
throw patchError(`Patch for ${fileDiff.file} has no hunks`, lineNumber);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let previousOldEnd = 0;
|
|
94
|
+
let cumulativeDelta = 0;
|
|
95
|
+
for (const hunk of fileDiff.hunks) {
|
|
96
|
+
const oldIndex = oldLineIndex(hunk);
|
|
97
|
+
const newIndex = newLineIndex(hunk);
|
|
98
|
+
if (oldIndex < previousOldEnd) {
|
|
99
|
+
throw patchError(`Patch hunks for ${fileDiff.file} overlap or are out of order`, lineNumber);
|
|
100
|
+
}
|
|
101
|
+
if (newIndex !== oldIndex + cumulativeDelta) {
|
|
102
|
+
throw patchError(`New line range for ${fileDiff.file} is inconsistent with earlier hunks`, lineNumber);
|
|
103
|
+
}
|
|
104
|
+
previousOldEnd = oldIndex + hunk.oldCount;
|
|
105
|
+
cumulativeDelta += hunk.newCount - hunk.oldCount;
|
|
25
106
|
}
|
|
26
|
-
if (filePath.startsWith('b/')) filePath = filePath.slice(2);
|
|
27
|
-
return filePath === '/dev/null' ? null : filePath;
|
|
28
107
|
}
|
|
29
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Parse the supported unified diff contract.
|
|
111
|
+
* @param {string} patch
|
|
112
|
+
* @returns {Array<{file: string, oldFile: string|null, newFile: string, hunks: Array}>}
|
|
113
|
+
*/
|
|
30
114
|
export function parsePatch(patch) {
|
|
31
|
-
|
|
115
|
+
if (typeof patch !== 'string' || patch.length === 0) {
|
|
116
|
+
throw new Error('Patch is empty');
|
|
117
|
+
}
|
|
118
|
+
|
|
32
119
|
const lines = patch.split('\n');
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
files.push(currentFile);
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Hunk header: @@ -start,count +start,count @@
|
|
54
|
-
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
55
|
-
if (hunkMatch) {
|
|
56
|
-
if (!currentFile) continue;
|
|
57
|
-
currentHunk = {
|
|
58
|
-
oldStart: parseInt(hunkMatch[1], 10),
|
|
59
|
-
oldCount: hunkMatch[2] ? parseInt(hunkMatch[2], 10) : 1,
|
|
60
|
-
newStart: parseInt(hunkMatch[3], 10),
|
|
61
|
-
newCount: hunkMatch[4] ? parseInt(hunkMatch[4], 10) : 1,
|
|
62
|
-
lines: [],
|
|
120
|
+
if (lines.at(-1) === '') lines.pop();
|
|
121
|
+
const files = [];
|
|
122
|
+
const seenFiles = new Set();
|
|
123
|
+
let i = 0;
|
|
124
|
+
|
|
125
|
+
while (i < lines.length) {
|
|
126
|
+
const line = lines[i].replace(/\r$/, '');
|
|
127
|
+
let gitHeader = null;
|
|
128
|
+
let hasNewFileMode = false;
|
|
129
|
+
|
|
130
|
+
if (line.startsWith('diff --git ')) {
|
|
131
|
+
const match = line.match(/^diff --git (\S+) (\S+)$/);
|
|
132
|
+
if (!match) throw patchError('Malformed or unsupported diff --git header', i + 1);
|
|
133
|
+
gitHeader = {
|
|
134
|
+
oldFile: parsePatchTarget(`--- ${match[1]}`, 'a', i + 1),
|
|
135
|
+
newFile: parsePatchTarget(`+++ ${match[2]}`, 'b', i + 1),
|
|
63
136
|
};
|
|
64
|
-
|
|
65
|
-
|
|
137
|
+
if (gitHeader.oldFile !== gitHeader.newFile) {
|
|
138
|
+
throw patchError('File rename patches are not supported', i + 1);
|
|
139
|
+
}
|
|
140
|
+
i++;
|
|
141
|
+
|
|
142
|
+
let hasIndex = false;
|
|
143
|
+
while (i < lines.length) {
|
|
144
|
+
const metadata = lines[i].replace(/\r$/, '');
|
|
145
|
+
if (/^new file mode \d+$/.test(metadata)) {
|
|
146
|
+
if (metadata !== 'new file mode 100644') {
|
|
147
|
+
throw patchError('Only regular non-executable new files are supported; mode changes are not applied', i + 1);
|
|
148
|
+
}
|
|
149
|
+
if (hasNewFileMode || hasIndex) {
|
|
150
|
+
throw patchError('Misplaced or duplicate new file mode header', i + 1);
|
|
151
|
+
}
|
|
152
|
+
hasNewFileMode = true;
|
|
153
|
+
i++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (/^index [0-9a-fA-F]+\.\.[0-9a-fA-F]+(?: \d+)?$/.test(metadata)) {
|
|
157
|
+
if (hasIndex) throw patchError('Duplicate index header', i + 1);
|
|
158
|
+
hasIndex = true;
|
|
159
|
+
i++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (i >= lines.length || !lines[i].replace(/\r$/, '').startsWith('--- ')) {
|
|
167
|
+
const unexpected = i < lines.length ? lines[i].replace(/\r$/, '') : '<end of patch>';
|
|
168
|
+
throw patchError(`Unsupported or unexpected patch header: ${unexpected}`, i + 1);
|
|
66
169
|
}
|
|
67
170
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
171
|
+
const oldFile = parsePatchTarget(lines[i], 'a', i + 1);
|
|
172
|
+
i++;
|
|
173
|
+
if (i >= lines.length || !lines[i].replace(/\r$/, '').startsWith('+++ ')) {
|
|
174
|
+
throw patchError('A --- file header must be followed by a +++ file header', i + 1);
|
|
175
|
+
}
|
|
176
|
+
const newFile = parsePatchTarget(lines[i], 'b', i + 1);
|
|
177
|
+
if (i + 1 < lines.length && lines[i + 1].replace(/\r$/, '').startsWith('+++ ')) {
|
|
178
|
+
throw patchError('Unexpected duplicate +++ file header', i + 2);
|
|
71
179
|
}
|
|
180
|
+
if (gitHeader && (oldFile ?? gitHeader.oldFile) !== gitHeader.oldFile) {
|
|
181
|
+
throw patchError('--- target does not match diff --git header', i);
|
|
182
|
+
}
|
|
183
|
+
if (gitHeader && newFile !== gitHeader.newFile) {
|
|
184
|
+
throw patchError('+++ target does not match diff --git header', i + 1);
|
|
185
|
+
}
|
|
186
|
+
if (hasNewFileMode && oldFile != null) {
|
|
187
|
+
throw patchError('new file mode requires --- /dev/null', i);
|
|
188
|
+
}
|
|
189
|
+
const file = newFile || oldFile;
|
|
190
|
+
const fileDiff = { file, oldFile, newFile, hunks: [] };
|
|
191
|
+
i++;
|
|
192
|
+
|
|
193
|
+
if (seenFiles.has(file)) {
|
|
194
|
+
throw patchError(`Patch contains duplicate file target: ${file}`, i);
|
|
195
|
+
}
|
|
196
|
+
seenFiles.add(file);
|
|
197
|
+
|
|
198
|
+
while (i < lines.length) {
|
|
199
|
+
const current = lines[i].replace(/\r$/, '');
|
|
200
|
+
if (current === '' || current.startsWith('--- ')
|
|
201
|
+
|| /^diff --git /.test(current)) break;
|
|
202
|
+
if (!current.startsWith('@@ ')) {
|
|
203
|
+
throw patchError(`Unsupported or unexpected patch content: ${current}`, i + 1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const hunk = parseHunkHeader(current, i + 1);
|
|
207
|
+
i++;
|
|
208
|
+
let oldCount = 0;
|
|
209
|
+
let newCount = 0;
|
|
210
|
+
let changed = false;
|
|
211
|
+
|
|
212
|
+
while (oldCount < hunk.oldCount || newCount < hunk.newCount) {
|
|
213
|
+
if (i >= lines.length) throw patchError('Hunk ended before its declared ranges', i + 1);
|
|
214
|
+
const rawDiffLine = lines[i].replace(/\r$/, '');
|
|
215
|
+
const type = rawDiffLine[0];
|
|
216
|
+
if (![' ', '+', '-'].includes(type)) {
|
|
217
|
+
throw patchError('Hunk ended before its declared ranges', i + 1);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const entry = { type, text: rawDiffLine.slice(1), noNewline: false };
|
|
221
|
+
if (type !== '+') oldCount++;
|
|
222
|
+
if (type !== '-') newCount++;
|
|
223
|
+
if (type !== ' ') changed = true;
|
|
224
|
+
if (oldCount > hunk.oldCount || newCount > hunk.newCount) {
|
|
225
|
+
throw patchError('Hunk body exceeds its declared ranges', i + 1);
|
|
226
|
+
}
|
|
227
|
+
hunk.lines.push(entry);
|
|
228
|
+
i++;
|
|
229
|
+
|
|
230
|
+
if (i < lines.length && lines[i].replace(/\r$/, '') === NO_NEWLINE_MARKER) {
|
|
231
|
+
entry.noNewline = true;
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!changed) throw patchError('Hunk contains no additions or deletions', i);
|
|
237
|
+
const oldEntries = hunk.lines.filter(entry => entry.type !== '+');
|
|
238
|
+
const newEntries = hunk.lines.filter(entry => entry.type !== '-');
|
|
239
|
+
const markedEntries = hunk.lines.filter(entry => entry.noNewline);
|
|
240
|
+
for (const entry of markedEntries) {
|
|
241
|
+
const lastOldEntry = entry.type === '+' || oldEntries.at(-1) === entry;
|
|
242
|
+
const lastNewEntry = entry.type === '-' || newEntries.at(-1) === entry;
|
|
243
|
+
if (!lastOldEntry || !lastNewEntry) {
|
|
244
|
+
throw patchError('No-newline marker must describe the final line of its range', i);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
hunk.oldNoNewline = oldEntries.at(-1)?.noNewline === true;
|
|
248
|
+
hunk.newNoNewline = newEntries.at(-1)?.noNewline === true;
|
|
249
|
+
hunk.oldLines = oldEntries.map(entry => entry.text);
|
|
250
|
+
hunk.newLines = newEntries.map(entry => entry.text);
|
|
251
|
+
fileDiff.hunks.push(hunk);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
validateParsedFile(fileDiff, i + 1);
|
|
255
|
+
files.push(fileDiff);
|
|
72
256
|
}
|
|
73
257
|
|
|
258
|
+
if (files.length === 0) throw new Error('No valid file diffs found in patch');
|
|
74
259
|
return files;
|
|
75
260
|
}
|
|
76
261
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
262
|
+
function splitFileContent(content) {
|
|
263
|
+
const lines = [];
|
|
264
|
+
const terminators = [];
|
|
265
|
+
const pattern = /([^\n]*)(\n|$)/g;
|
|
266
|
+
for (const match of content.matchAll(pattern)) {
|
|
267
|
+
if (!match[0]) break;
|
|
268
|
+
const crlf = match[2] && match[1].endsWith('\r');
|
|
269
|
+
lines.push(crlf ? match[1].slice(0, -1) : match[1]);
|
|
270
|
+
terminators.push(crlf ? '\r\n' : match[2]);
|
|
271
|
+
}
|
|
272
|
+
return { lines, terminators, endsWithNewline: content.endsWith('\n'),
|
|
273
|
+
eol: terminators.find(Boolean) || '\n' };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function applyHunks(content, fileDiff) {
|
|
277
|
+
const source = splitFileContent(content);
|
|
278
|
+
const output = [];
|
|
279
|
+
let cursor = 0;
|
|
280
|
+
|
|
281
|
+
for (const hunk of fileDiff.hunks) {
|
|
282
|
+
const start = oldLineIndex(hunk);
|
|
283
|
+
if (start > source.lines.length || start + hunk.oldCount > source.lines.length) {
|
|
284
|
+
throw new Error(`Hunk source range is outside ${fileDiff.file}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (hunk.oldCount === 0 && start > 0 && !source.terminators[start - 1]) {
|
|
288
|
+
throw new Error(`Insertion after an unterminated line requires replacing that line in ${fileDiff.file}`);
|
|
289
|
+
}
|
|
290
|
+
const actual = source.lines.slice(start, start + hunk.oldCount);
|
|
291
|
+
for (let index = 0; index < hunk.oldLines.length; index++) {
|
|
292
|
+
if (actual[index] !== hunk.oldLines[index]) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`Hunk context mismatch in ${fileDiff.file} at line ${start + index + 1}: `
|
|
295
|
+
+ `expected ${JSON.stringify(hunk.oldLines[index])}, found ${JSON.stringify(actual[index])}`,
|
|
296
|
+
);
|
|
96
297
|
}
|
|
97
298
|
}
|
|
98
299
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
300
|
+
const oldTouchesEof = start + hunk.oldCount === source.lines.length;
|
|
301
|
+
if (hunk.oldNoNewline && (!oldTouchesEof || source.endsWithNewline)) {
|
|
302
|
+
throw new Error(`Old no-newline marker does not match ${fileDiff.file}`);
|
|
303
|
+
}
|
|
304
|
+
if (oldTouchesEof && hunk.oldCount > 0 && !hunk.oldNoNewline && !source.endsWithNewline) {
|
|
305
|
+
throw new Error(`Expected a newline at end of ${fileDiff.file}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (hunk.newNoNewline && (!oldTouchesEof || hunk !== fileDiff.hunks.at(-1))) {
|
|
309
|
+
throw new Error(`New no-newline marker must describe the final output line in ${fileDiff.file}`);
|
|
310
|
+
}
|
|
311
|
+
// Preserve every untouched line's terminator, including mixed LF/CRLF.
|
|
312
|
+
for (let index = cursor; index < start; index++) {
|
|
313
|
+
output.push(source.lines[index] + source.terminators[index]);
|
|
314
|
+
}
|
|
315
|
+
let oldCursor = start;
|
|
316
|
+
for (const entry of hunk.lines) {
|
|
317
|
+
if (entry.type === ' ') {
|
|
318
|
+
output.push(source.lines[oldCursor] + source.terminators[oldCursor]);
|
|
319
|
+
oldCursor++;
|
|
320
|
+
} else if (entry.type === '-') {
|
|
321
|
+
oldCursor++;
|
|
322
|
+
} else {
|
|
323
|
+
const eol = source.terminators[Math.max(start, oldCursor - 1)]
|
|
324
|
+
|| source.terminators[oldCursor] || source.eol;
|
|
325
|
+
output.push(entry.text + (entry.noNewline ? '' : eol));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
cursor = start + hunk.oldCount;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (let index = cursor; index < source.lines.length; index++) {
|
|
332
|
+
output.push(source.lines[index] + source.terminators[index]);
|
|
333
|
+
}
|
|
334
|
+
return output.join('');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Lexical containment is checked by parsePatch. Reject symlinks at every
|
|
338
|
+
// existing component, and remember identities to detect changes before I/O.
|
|
339
|
+
// These checks are not an OS sandbox against hostile concurrent directory swaps.
|
|
340
|
+
async function inspectTarget(cwd, file) {
|
|
341
|
+
const parts = file.split('/');
|
|
342
|
+
const identities = [];
|
|
343
|
+
let current = cwd;
|
|
344
|
+
for (let index = -1; index < parts.length; index++) {
|
|
345
|
+
if (index >= 0) current = resolve(current, parts[index]);
|
|
346
|
+
let info;
|
|
347
|
+
try { info = await lstat(current); }
|
|
348
|
+
catch (error) { if (error.code === 'ENOENT') break; throw error; }
|
|
349
|
+
if (info.isSymbolicLink()) throw new Error(`Symbolic link target is not supported: ${file}`);
|
|
350
|
+
if (index < parts.length - 1 && !info.isDirectory()) {
|
|
351
|
+
throw new Error(`Patch parent is not a directory: ${file}`);
|
|
352
|
+
}
|
|
353
|
+
if (index === parts.length - 1 && !info.isFile()) throw new Error(`Patch target is not a regular file: ${file}`);
|
|
354
|
+
identities.push({ path: current, dev: info.dev, ino: info.ino });
|
|
355
|
+
}
|
|
356
|
+
return identities;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function verifyTarget(cwd, plan) {
|
|
360
|
+
const current = await inspectTarget(cwd, plan.file);
|
|
361
|
+
for (const old of plan.identities) {
|
|
362
|
+
if (!current.some(now => now.path === old.path && now.dev === old.dev && now.ino === old.ino)) {
|
|
363
|
+
throw new Error(`Patch target changed after validation: ${plan.file}`);
|
|
364
|
+
}
|
|
102
365
|
}
|
|
366
|
+
return current;
|
|
367
|
+
}
|
|
103
368
|
|
|
104
|
-
|
|
369
|
+
async function writePlan(cwd, plan, signal) {
|
|
370
|
+
if (signal?.aborted) throw new Error('Patch cancelled before write');
|
|
371
|
+
await verifyTarget(cwd, plan);
|
|
372
|
+
await mkdir(dirname(plan.absPath), { recursive: true });
|
|
373
|
+
await verifyTarget(cwd, plan);
|
|
374
|
+
const flags = plan.oldFile == null ? constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL
|
|
375
|
+
: constants.O_RDWR | (constants.O_NOFOLLOW || 0);
|
|
376
|
+
const handle = await open(plan.absPath, flags, 0o666);
|
|
377
|
+
try {
|
|
378
|
+
const identities = await verifyTarget(cwd, plan);
|
|
379
|
+
const opened = await handle.stat();
|
|
380
|
+
const target = identities.at(-1);
|
|
381
|
+
if (!opened.isFile() || target?.dev !== opened.dev || target?.ino !== opened.ino) {
|
|
382
|
+
throw new Error(`Patch target replaced during open: ${plan.file}`);
|
|
383
|
+
}
|
|
384
|
+
if (plan.oldFile != null && await handle.readFile('utf8') !== plan.original) {
|
|
385
|
+
throw new Error(`Patch content changed after validation: ${plan.file}`);
|
|
386
|
+
}
|
|
387
|
+
await verifyTarget(cwd, plan);
|
|
388
|
+
if (signal?.aborted) throw new Error('Patch cancelled before write');
|
|
389
|
+
const bytes = Buffer.from(plan.content, 'utf8');
|
|
390
|
+
let offset = 0;
|
|
391
|
+
while (offset < bytes.length) {
|
|
392
|
+
const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset, offset);
|
|
393
|
+
if (!bytesWritten) throw new Error('Patch write made no progress');
|
|
394
|
+
offset += bytesWritten;
|
|
395
|
+
}
|
|
396
|
+
await handle.truncate(bytes.length);
|
|
397
|
+
} finally { await handle.close(); }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function preparePatch(cwd, fileDiffs) {
|
|
401
|
+
const plans = [];
|
|
402
|
+
for (const fileDiff of fileDiffs) {
|
|
403
|
+
const absPath = resolve(cwd, fileDiff.file);
|
|
404
|
+
const identities = await inspectTarget(cwd, fileDiff.file);
|
|
405
|
+
const exists = existsSync(absPath);
|
|
406
|
+
if (fileDiff.oldFile == null && exists) {
|
|
407
|
+
throw new Error(`New file already exists: ${fileDiff.file}`);
|
|
408
|
+
}
|
|
409
|
+
if (fileDiff.oldFile != null && !exists) {
|
|
410
|
+
throw new Error(`File not found: ${fileDiff.file}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const content = exists ? await readFile(absPath, 'utf8') : '';
|
|
414
|
+
plans.push({
|
|
415
|
+
...fileDiff,
|
|
416
|
+
absPath,
|
|
417
|
+
identities,
|
|
418
|
+
original: content,
|
|
419
|
+
content: applyHunks(content, fileDiff),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return plans;
|
|
105
423
|
}
|
|
106
424
|
|
|
107
425
|
export default defineTool({
|
|
108
426
|
name: 'ApplyPatch',
|
|
109
427
|
description: {
|
|
110
|
-
en: `Apply a unified diff patch to files.
|
|
428
|
+
en: `Apply a standard unified diff patch to one or more files.
|
|
111
429
|
|
|
112
|
-
|
|
113
|
-
Supports patching multiple files in a single diff.
|
|
430
|
+
The complete patch is parsed and all original context/deletion lines are matched before any file is written. Multiple hunks per file and multiple files are supported; new files use the standard /dev/null header. Targets must be relative paths that stay within the working directory.
|
|
114
431
|
|
|
115
432
|
Guidelines:
|
|
116
433
|
- Provide standard unified diff format (--- a/file, +++ b/file, @@ hunks)
|
|
117
|
-
-
|
|
118
|
-
-
|
|
119
|
-
|
|
434
|
+
- Include exact current context; stale or malformed patches make no changes
|
|
435
|
+
- Use one file header with multiple ordered hunks for several edits in a file
|
|
436
|
+
- Symlink targets/ancestors are rejected and checked again before writing; this is not a sandbox against hostile concurrent filesystem mutation
|
|
437
|
+
- Validation is all-or-nothing, but filesystem writes are not transactional: a runtime I/O failure can leave earlier files changed and the failed file partially written`,
|
|
438
|
+
zh: `将标准 unified diff 补丁应用到一个或多个文件。
|
|
120
439
|
|
|
121
|
-
|
|
440
|
+
写入任何文件前,会完整解析补丁并匹配全部原上下文和删除行。支持同文件多个块和跨文件补丁;新文件使用标准 /dev/null 头。目标必须是工作目录内的相对路径。
|
|
122
441
|
|
|
123
442
|
使用指南:
|
|
124
|
-
- 提供标准 unified diff 格式(--- a/file
|
|
125
|
-
-
|
|
126
|
-
-
|
|
443
|
+
- 提供标准 unified diff 格式(--- a/file、+++ b/file、@@ 块)
|
|
444
|
+
- 上下文必须与当前内容精确一致;过时或畸形补丁不会产生修改
|
|
445
|
+
- 同一文件的多处编辑使用一个文件头和多个有序块
|
|
446
|
+
- 拒绝符号链接目标和祖先,写入前再次检查;这不等同于防御恶意并发文件系统修改的 sandbox
|
|
447
|
+
- 验证是全有或全无的,但文件系统写入不是事务:运行时 I/O 失败可能保留之前已写入的文件,失败文件也可能只写入一部分`
|
|
127
448
|
},
|
|
128
449
|
parameters: {
|
|
129
450
|
type: 'object',
|
|
@@ -131,8 +452,8 @@ Guidelines:
|
|
|
131
452
|
patch: {
|
|
132
453
|
type: 'string',
|
|
133
454
|
description: {
|
|
134
|
-
en: 'The unified diff patch content',
|
|
135
|
-
zh: 'unified diff 补丁内容',
|
|
455
|
+
en: 'The standard unified diff patch content',
|
|
456
|
+
zh: '标准 unified diff 补丁内容',
|
|
136
457
|
},
|
|
137
458
|
},
|
|
138
459
|
},
|
|
@@ -143,56 +464,43 @@ Guidelines:
|
|
|
143
464
|
isDestructive: () => false,
|
|
144
465
|
async execute(input, ctx) {
|
|
145
466
|
const { patch } = input;
|
|
146
|
-
if (!patch) return JSON.stringify({ error: 'patch is required' });
|
|
147
|
-
|
|
148
|
-
const cwd = ctx?.cwd || process.cwd();
|
|
467
|
+
if (!patch) return JSON.stringify({ errorEffect: 'none', error: 'patch is required' });
|
|
149
468
|
|
|
469
|
+
let cwd;
|
|
470
|
+
let plans;
|
|
150
471
|
try {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const absPath = resolve(cwd, fileDiff.file);
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
let content;
|
|
164
|
-
if (existsSync(absPath)) {
|
|
165
|
-
content = await readFile(absPath, 'utf-8');
|
|
166
|
-
} else {
|
|
167
|
-
// New file
|
|
168
|
-
await mkdir(dirname(absPath), { recursive: true });
|
|
169
|
-
content = '';
|
|
170
|
-
}
|
|
472
|
+
const parsed = parsePatch(patch);
|
|
473
|
+
cwd = await realpath(ctx?.cwd || process.cwd());
|
|
474
|
+
plans = await preparePatch(cwd, parsed);
|
|
475
|
+
} catch (err) {
|
|
476
|
+
return JSON.stringify({
|
|
477
|
+
errorEffect: 'none',
|
|
478
|
+
error: `Patch validation failed: ${err.message}`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
171
481
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
482
|
+
const results = [];
|
|
483
|
+
for (let index = 0; index < plans.length; index++) {
|
|
484
|
+
const plan = plans[index];
|
|
485
|
+
try {
|
|
486
|
+
await writePlan(cwd, plan, ctx?.signal);
|
|
487
|
+
results.push({ file: plan.file, success: true, hunks: plan.hunks.length });
|
|
488
|
+
} catch (err) {
|
|
489
|
+
return JSON.stringify({
|
|
490
|
+
errorEffect: 'unknown',
|
|
491
|
+
error: `Patch write failed for ${plan.file}: ${err.message}`,
|
|
492
|
+
results,
|
|
493
|
+
failedFile: plan.file,
|
|
494
|
+
remainingFiles: plans.slice(index + 1).map(item => item.file),
|
|
495
|
+
warning: 'The patch was fully validated, but filesystem writes are not transactional; successful files listed in results may already be changed, and the failed file may be partially written.',
|
|
496
|
+
}, null, 2);
|
|
187
497
|
}
|
|
188
|
-
|
|
189
|
-
const successCount = results.filter(r => r.success).length;
|
|
190
|
-
return JSON.stringify({
|
|
191
|
-
results,
|
|
192
|
-
summary: `Applied patch to ${successCount}/${results.length} files`,
|
|
193
|
-
}, null, 2);
|
|
194
|
-
} catch (err) {
|
|
195
|
-
return JSON.stringify({ error: `Failed to apply patch: ${err.message}` });
|
|
196
498
|
}
|
|
499
|
+
|
|
500
|
+
return JSON.stringify({
|
|
501
|
+
success: true,
|
|
502
|
+
results,
|
|
503
|
+
summary: `Applied ${results.reduce((sum, result) => sum + result.hunks, 0)} hunk(s) to ${results.length} file(s)`,
|
|
504
|
+
}, null, 2);
|
|
197
505
|
},
|
|
198
506
|
});
|