@theokit/sdk-tools 0.19.0 → 0.20.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/CHANGELOG.md +30 -0
- package/dist/index.cjs +370 -139
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -6
- package/dist/index.d.ts +16 -6
- package/dist/index.js +372 -141
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname,
|
|
1
|
+
import { rm, mkdir, writeFile, readFile, readdir, open, stat, copyFile } from 'fs/promises';
|
|
2
|
+
import { dirname, relative, join, isAbsolute, resolve, sep } from 'path';
|
|
3
3
|
import { Tool, ConfigurationError } from '@theokit/sdk';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { existsSync, statSync, mkdirSync, writeFileSync, realpathSync, readFileSync, lstatSync, readlinkSync, readdirSync } from 'fs';
|
|
@@ -62,8 +62,8 @@ function realpathOfDeepestExisting(path) {
|
|
|
62
62
|
} catch {
|
|
63
63
|
}
|
|
64
64
|
try {
|
|
65
|
-
const
|
|
66
|
-
if (
|
|
65
|
+
const stat3 = lstatSync(path);
|
|
66
|
+
if (stat3.isSymbolicLink()) {
|
|
67
67
|
const target = readlinkSync(path);
|
|
68
68
|
const parentReal = realpathOfDeepestExisting(dirname(path));
|
|
69
69
|
const parentBase = parentReal ?? dirname(path);
|
|
@@ -102,129 +102,360 @@ function isForbiddenPath(input) {
|
|
|
102
102
|
return false;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// src/internal/v4a-patch.ts
|
|
106
|
+
var BEGIN = "*** Begin Patch";
|
|
107
|
+
var END = "*** End Patch";
|
|
108
|
+
var ADD = "*** Add File: ";
|
|
109
|
+
var DELETE = "*** Delete File: ";
|
|
110
|
+
var UPDATE = "*** Update File: ";
|
|
111
|
+
var MOVE = "*** Move to: ";
|
|
112
|
+
var EOF_MARK = "*** End of File";
|
|
113
|
+
var CTX = "@@ ";
|
|
114
|
+
var CTX_EMPTY = "@@";
|
|
115
|
+
var V4APatchError = class extends Error {
|
|
116
|
+
constructor(message) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.name = "V4APatchError";
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
function parseV4A(patch) {
|
|
122
|
+
const lines = patch.split("\n").map((l) => l.endsWith("\r") ? l.slice(0, -1) : l);
|
|
123
|
+
let i = 0;
|
|
124
|
+
while (i < lines.length && lines[i].trim() !== BEGIN) i++;
|
|
125
|
+
if (i >= lines.length) throw new V4APatchError("Invalid patch: missing '*** Begin Patch'");
|
|
126
|
+
i++;
|
|
127
|
+
const hunks = [];
|
|
128
|
+
while (i < lines.length) {
|
|
129
|
+
if (lines[i].trim() === END) return hunks;
|
|
130
|
+
const step = parseNextHunk(lines, i);
|
|
131
|
+
if (step.hunk) hunks.push(step.hunk);
|
|
132
|
+
i = step.next;
|
|
133
|
+
}
|
|
134
|
+
throw new V4APatchError("Invalid patch: missing '*** End Patch'");
|
|
135
|
+
}
|
|
136
|
+
function parseNextHunk(lines, i) {
|
|
137
|
+
const line = lines[i];
|
|
138
|
+
if (line.startsWith(ADD)) {
|
|
139
|
+
const [hunk, next] = parseAdd(lines, i);
|
|
140
|
+
return { hunk, next };
|
|
141
|
+
}
|
|
142
|
+
if (line.startsWith(DELETE)) {
|
|
143
|
+
return { hunk: { kind: "delete", path: line.slice(DELETE.length).trim() }, next: i + 1 };
|
|
144
|
+
}
|
|
145
|
+
if (line.startsWith(UPDATE)) {
|
|
146
|
+
const [hunk, next] = parseUpdate(lines, i);
|
|
147
|
+
return { hunk, next };
|
|
148
|
+
}
|
|
149
|
+
if (line.trim() === "") return { next: i + 1 };
|
|
150
|
+
throw new V4APatchError(`Invalid patch hunk on line ${i + 1}: unexpected '${line}'`);
|
|
151
|
+
}
|
|
152
|
+
function parseAdd(lines, start) {
|
|
153
|
+
const path = lines[start].slice(ADD.length).trim();
|
|
154
|
+
let i = start + 1;
|
|
155
|
+
const body = [];
|
|
156
|
+
while (i < lines.length && lines[i].startsWith("+")) {
|
|
157
|
+
body.push(lines[i].slice(1));
|
|
158
|
+
i++;
|
|
159
|
+
}
|
|
160
|
+
const content = body.length > 0 ? `${body.join("\n")}
|
|
161
|
+
` : "";
|
|
162
|
+
return [{ kind: "add", path, content }, i];
|
|
163
|
+
}
|
|
164
|
+
function parseUpdate(lines, start) {
|
|
165
|
+
const path = lines[start].slice(UPDATE.length).trim();
|
|
166
|
+
let i = start + 1;
|
|
167
|
+
let movePath = null;
|
|
168
|
+
if (i < lines.length && lines[i].startsWith(MOVE)) {
|
|
169
|
+
movePath = lines[i].slice(MOVE.length).trim();
|
|
170
|
+
i++;
|
|
171
|
+
}
|
|
172
|
+
const chunks = [];
|
|
173
|
+
let cur = null;
|
|
174
|
+
for (; i < lines.length; i++) {
|
|
175
|
+
const line = lines[i];
|
|
176
|
+
if (isHunkBoundary(line)) break;
|
|
177
|
+
cur = feedLine(chunks, cur, line, path, i + 1);
|
|
178
|
+
}
|
|
179
|
+
pushChunk(chunks, cur, path);
|
|
180
|
+
if (chunks.length === 0) throw new V4APatchError(`Update file hunk for '${path}' is empty`);
|
|
181
|
+
return [{ kind: "update", path, movePath, chunks }, i];
|
|
182
|
+
}
|
|
183
|
+
function isHunkBoundary(line) {
|
|
184
|
+
return line.trim() === END || line.startsWith(ADD) || line.startsWith(DELETE) || line.startsWith(UPDATE);
|
|
185
|
+
}
|
|
186
|
+
function emptyChunk() {
|
|
187
|
+
return { context: null, oldLines: [], newLines: [], eof: false };
|
|
188
|
+
}
|
|
189
|
+
function pushChunk(chunks, cur, path) {
|
|
190
|
+
if (!cur) return;
|
|
191
|
+
if (cur.oldLines.length === 0 && cur.newLines.length === 0) {
|
|
192
|
+
throw new V4APatchError(`Update hunk for '${path}' has an empty change block`);
|
|
193
|
+
}
|
|
194
|
+
chunks.push(cur);
|
|
195
|
+
}
|
|
196
|
+
function feedLine(chunks, cur, line, path, lineNum) {
|
|
197
|
+
if (line === CTX_EMPTY || line.startsWith(CTX)) {
|
|
198
|
+
pushChunk(chunks, cur, path);
|
|
199
|
+
return {
|
|
200
|
+
context: line === CTX_EMPTY ? null : line.slice(CTX.length),
|
|
201
|
+
oldLines: [],
|
|
202
|
+
newLines: [],
|
|
203
|
+
eof: false
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (line.trim() === EOF_MARK) {
|
|
207
|
+
if (!cur) throw new V4APatchError(`'*** End of File' before any change in '${path}'`);
|
|
208
|
+
cur.eof = true;
|
|
209
|
+
return cur;
|
|
210
|
+
}
|
|
211
|
+
return appendChangeLine(cur, line, path, lineNum);
|
|
212
|
+
}
|
|
213
|
+
function appendChangeLine(cur, line, path, lineNum) {
|
|
214
|
+
const c = cur ?? emptyChunk();
|
|
215
|
+
if (line.startsWith("+")) {
|
|
216
|
+
c.newLines.push(line.slice(1));
|
|
217
|
+
return c;
|
|
218
|
+
}
|
|
219
|
+
if (line.startsWith("-")) {
|
|
220
|
+
c.oldLines.push(line.slice(1));
|
|
221
|
+
return c;
|
|
222
|
+
}
|
|
223
|
+
if (line === "" || line.startsWith(" ")) {
|
|
224
|
+
const content = line === "" ? "" : line.slice(1);
|
|
225
|
+
c.oldLines.push(content);
|
|
226
|
+
c.newLines.push(content);
|
|
227
|
+
return c;
|
|
228
|
+
}
|
|
229
|
+
throw new V4APatchError(
|
|
230
|
+
`Unexpected line in update hunk for '${path}' (line ${lineNum}): every line must start with ' ', '+', or '-': '${line}'`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
var UNICODE_MAP = {
|
|
234
|
+
"\u2010": "-",
|
|
235
|
+
"\u2011": "-",
|
|
236
|
+
"\u2012": "-",
|
|
237
|
+
"\u2013": "-",
|
|
238
|
+
"\u2014": "-",
|
|
239
|
+
"\u2015": "-",
|
|
240
|
+
"\u2018": "'",
|
|
241
|
+
"\u2019": "'",
|
|
242
|
+
"\u201C": '"',
|
|
243
|
+
"\u201D": '"',
|
|
244
|
+
"\xA0": " ",
|
|
245
|
+
"\u2007": " ",
|
|
246
|
+
"\u202F": " "
|
|
247
|
+
};
|
|
248
|
+
function normalise(s) {
|
|
249
|
+
let out = "";
|
|
250
|
+
for (const ch of s) out += UNICODE_MAP[ch] ?? ch;
|
|
251
|
+
return out.trim();
|
|
252
|
+
}
|
|
253
|
+
var LADDER = [
|
|
254
|
+
(a, b) => a === b,
|
|
255
|
+
(a, b) => a.replace(/\s+$/, "") === b.replace(/\s+$/, ""),
|
|
256
|
+
(a, b) => a.trim() === b.trim(),
|
|
257
|
+
(a, b) => normalise(a) === normalise(b)
|
|
258
|
+
];
|
|
259
|
+
function matchesAt(lines, pattern, at, eq) {
|
|
260
|
+
for (let k = 0; k < pattern.length; k++) {
|
|
261
|
+
if (!eq(lines[at + k], pattern[k])) return false;
|
|
262
|
+
}
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
function seekSequence(lines, pattern, searchStart, eof) {
|
|
266
|
+
if (pattern.length === 0) return searchStart;
|
|
267
|
+
if (pattern.length > lines.length) return null;
|
|
268
|
+
const starts = eof ? [Math.max(searchStart, lines.length - pattern.length), searchStart] : [searchStart];
|
|
269
|
+
for (const start of starts) {
|
|
270
|
+
const hit = searchFrom(lines, pattern, start);
|
|
271
|
+
if (hit !== null) return hit;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
function searchFrom(lines, pattern, start) {
|
|
276
|
+
for (const eq of LADDER) {
|
|
277
|
+
for (let i = start; i <= lines.length - pattern.length; i++) {
|
|
278
|
+
if (matchesAt(lines, pattern, i, eq)) return i;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
function applyUpdateChunks(content, path, chunks) {
|
|
284
|
+
const lines = content.split("\n");
|
|
285
|
+
const edits = [];
|
|
286
|
+
let cursor = 0;
|
|
287
|
+
for (const chunk of chunks) {
|
|
288
|
+
if (chunk.context !== null) {
|
|
289
|
+
const ctxAt = seekSequence(lines, [chunk.context], cursor, false);
|
|
290
|
+
if (ctxAt === null) {
|
|
291
|
+
throw new V4APatchError(`Failed to find context '${chunk.context}' in ${path}`);
|
|
292
|
+
}
|
|
293
|
+
cursor = ctxAt + 1;
|
|
294
|
+
}
|
|
295
|
+
const at = seekSequence(lines, chunk.oldLines, cursor, chunk.eof);
|
|
296
|
+
if (at === null) {
|
|
297
|
+
throw new V4APatchError(
|
|
298
|
+
`Failed to find expected lines in ${path}:
|
|
299
|
+
${chunk.oldLines.join("\n")}`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
edits.push({ start: at, oldLen: chunk.oldLines.length, newLines: chunk.newLines });
|
|
303
|
+
cursor = at + chunk.oldLines.length;
|
|
304
|
+
}
|
|
305
|
+
edits.sort((a, b) => b.start - a.start);
|
|
306
|
+
for (const e of edits) {
|
|
307
|
+
lines.splice(e.start, e.oldLen, ...e.newLines);
|
|
308
|
+
}
|
|
309
|
+
return lines.join("\n");
|
|
310
|
+
}
|
|
311
|
+
|
|
105
312
|
// src/apply-patch.ts
|
|
106
313
|
function createApplyPatchTool(opts) {
|
|
107
314
|
const { projectRoot } = opts;
|
|
108
315
|
return Tool.create({
|
|
109
316
|
name: "apply_patch",
|
|
110
|
-
description: "Apply a
|
|
317
|
+
description: "Apply a Codex-style V4A patch. The patch is `*** Begin Patch` \u2026 `*** End Patch` wrapping one or more hunks: `*** Add File: <path>` (then `+`lines), `*** Delete File: <path>`, or `*** Update File: <path>` (optional `*** Move to: <path>`) with `@@`-anchored `+` (add) / `-` (remove) / ` ` (context) lines. Read a file first so your context/removed lines match. Applied atomically \u2014 a mismatch anywhere aborts the whole patch with zero writes; each path is security-checked. Returns { ok, files_patched } or { ok: false, error }.",
|
|
111
318
|
inputSchema: z.object({
|
|
112
|
-
patch: z.string().min(1).describe("
|
|
319
|
+
patch: z.string().min(1).describe("V4A patch: *** Begin Patch \u2026 *** End Patch.")
|
|
113
320
|
}),
|
|
114
|
-
|
|
115
|
-
handler: async ({ patch }) => {
|
|
116
|
-
const hunks = parsePatch(patch);
|
|
117
|
-
if (hunks.length === 0) {
|
|
118
|
-
return JSON.stringify({ ok: false, error: "parse_error", detail: "no file hunks found" });
|
|
119
|
-
}
|
|
120
|
-
for (const hunk of hunks) {
|
|
121
|
-
if (isForbiddenPath(hunk.file)) {
|
|
122
|
-
return JSON.stringify({ ok: false, error: "forbidden_path", path: hunk.file });
|
|
123
|
-
}
|
|
124
|
-
try {
|
|
125
|
-
const abs = safePathJoin(projectRoot, hunk.file);
|
|
126
|
-
assertNoSymlinkEscape(abs, projectRoot);
|
|
127
|
-
} catch (err) {
|
|
128
|
-
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
129
|
-
return JSON.stringify({ ok: false, error: "path_traversal", path: hunk.file });
|
|
130
|
-
}
|
|
131
|
-
throw err;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
const patched = [];
|
|
135
|
-
for (const hunk of hunks) {
|
|
136
|
-
const absolutePath = safePathJoin(projectRoot, hunk.file);
|
|
137
|
-
let content;
|
|
138
|
-
try {
|
|
139
|
-
content = await readFile(absolutePath, "utf-8");
|
|
140
|
-
} catch (err) {
|
|
141
|
-
const e = err;
|
|
142
|
-
if (e.code === "ENOENT") {
|
|
143
|
-
content = "";
|
|
144
|
-
} else {
|
|
145
|
-
throw err;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
const result = applyHunks(content, hunk.changes);
|
|
149
|
-
if (result === null) {
|
|
150
|
-
return JSON.stringify({
|
|
151
|
-
ok: false,
|
|
152
|
-
error: "patch_failed",
|
|
153
|
-
path: hunk.file,
|
|
154
|
-
detail: "hunk context mismatch"
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
if (content !== "") {
|
|
158
|
-
await copyFile(absolutePath, `${absolutePath}.bak`);
|
|
159
|
-
}
|
|
160
|
-
await mkdir(dirname(absolutePath), { recursive: true });
|
|
161
|
-
await writeFile(absolutePath, result, "utf-8");
|
|
162
|
-
patched.push(hunk.file);
|
|
163
|
-
}
|
|
164
|
-
return JSON.stringify({ ok: true, files_patched: patched });
|
|
165
|
-
}
|
|
321
|
+
handler: async ({ patch }) => applyV4APatch(projectRoot, patch)
|
|
166
322
|
});
|
|
167
323
|
}
|
|
168
|
-
function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
324
|
+
async function applyV4APatch(projectRoot, patch) {
|
|
325
|
+
let hunks;
|
|
326
|
+
try {
|
|
327
|
+
hunks = parseV4A(patch);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
const detail = err instanceof V4APatchError ? err.message : String(err);
|
|
330
|
+
return JSON.stringify({ ok: false, error: "parse_error", detail });
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const plan = await buildPlan(projectRoot, hunks);
|
|
334
|
+
if ("error" in plan) return plan.error;
|
|
335
|
+
await executePlan(plan.ops);
|
|
336
|
+
return JSON.stringify({ ok: true, files_patched: plan.patched });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
return JSON.stringify({
|
|
339
|
+
ok: false,
|
|
340
|
+
error: "io_error",
|
|
341
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function hunkTargets(hunk) {
|
|
346
|
+
if (hunk.kind === "update" && hunk.movePath) return [hunk.path, hunk.movePath];
|
|
347
|
+
return [hunk.path];
|
|
348
|
+
}
|
|
349
|
+
async function buildPlan(projectRoot, hunks) {
|
|
350
|
+
const dup = firstDuplicateTarget(hunks);
|
|
351
|
+
if (dup !== null) {
|
|
352
|
+
return { error: JSON.stringify({ ok: false, error: "duplicate_target", path: dup }) };
|
|
353
|
+
}
|
|
354
|
+
const ops = [];
|
|
355
|
+
const patched = [];
|
|
356
|
+
for (const hunk of hunks) {
|
|
357
|
+
const planned = await planHunk(projectRoot, hunk);
|
|
358
|
+
if ("error" in planned) return planned;
|
|
359
|
+
ops.push(...planned.ops);
|
|
360
|
+
patched.push(hunk.kind === "update" ? hunk.movePath ?? hunk.path : hunk.path);
|
|
361
|
+
}
|
|
362
|
+
return { ops, patched };
|
|
363
|
+
}
|
|
364
|
+
function firstDuplicateTarget(hunks) {
|
|
365
|
+
const seen = /* @__PURE__ */ new Set();
|
|
366
|
+
for (const hunk of hunks) {
|
|
367
|
+
for (const t of hunkTargets(hunk)) {
|
|
368
|
+
if (seen.has(t)) return t;
|
|
369
|
+
seen.add(t);
|
|
180
370
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
} else
|
|
189
|
-
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
async function executePlan(ops) {
|
|
375
|
+
for (const op of ops) {
|
|
376
|
+
if ("rm" in op) {
|
|
377
|
+
await rm(op.rm, { force: true });
|
|
378
|
+
} else {
|
|
379
|
+
await mkdir(dirname(op.write.abs), { recursive: true });
|
|
380
|
+
await writeFile(op.write.abs, op.write.content, "utf-8");
|
|
190
381
|
}
|
|
191
382
|
}
|
|
192
|
-
return hunks;
|
|
193
383
|
}
|
|
194
|
-
function
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (change.type === "context") {
|
|
209
|
-
if (origIdx >= originalLines.length || originalLines[origIdx] !== change.content) {
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
result.push(change.content);
|
|
213
|
-
origIdx++;
|
|
214
|
-
} else if (change.type === "remove") {
|
|
215
|
-
if (origIdx >= originalLines.length || originalLines[origIdx] !== change.content) {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
origIdx++;
|
|
219
|
-
} else if (change.type === "add") {
|
|
220
|
-
result.push(change.content);
|
|
384
|
+
async function pathExists(abs) {
|
|
385
|
+
try {
|
|
386
|
+
await stat(abs);
|
|
387
|
+
return true;
|
|
388
|
+
} catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function planHunk(projectRoot, hunk) {
|
|
393
|
+
const scope = v4aScope(projectRoot, hunk.path);
|
|
394
|
+
if ("error" in scope) return scope;
|
|
395
|
+
if (hunk.kind === "add") {
|
|
396
|
+
if (await pathExists(scope.abs)) {
|
|
397
|
+
return { error: JSON.stringify({ ok: false, error: "file_exists", path: hunk.path }) };
|
|
221
398
|
}
|
|
399
|
+
return { ops: [{ write: { abs: scope.abs, content: hunk.content } }] };
|
|
222
400
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
401
|
+
if (hunk.kind === "delete") {
|
|
402
|
+
if (!await pathExists(scope.abs)) {
|
|
403
|
+
return { error: JSON.stringify({ ok: false, error: "not_found", path: hunk.path }) };
|
|
404
|
+
}
|
|
405
|
+
return { ops: [{ rm: scope.abs }] };
|
|
406
|
+
}
|
|
407
|
+
return planUpdate(projectRoot, hunk, scope.abs);
|
|
408
|
+
}
|
|
409
|
+
async function planUpdate(projectRoot, hunk, abs) {
|
|
410
|
+
let content;
|
|
411
|
+
try {
|
|
412
|
+
content = await readFile(abs, "utf-8");
|
|
413
|
+
} catch (err) {
|
|
414
|
+
if (err.code === "ENOENT") {
|
|
415
|
+
return { error: JSON.stringify({ ok: false, error: "not_found", path: hunk.path }) };
|
|
416
|
+
}
|
|
417
|
+
throw err;
|
|
418
|
+
}
|
|
419
|
+
let updated;
|
|
420
|
+
try {
|
|
421
|
+
updated = applyUpdateChunks(content, hunk.path, hunk.chunks);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
if (err instanceof V4APatchError) {
|
|
424
|
+
return {
|
|
425
|
+
error: JSON.stringify({
|
|
426
|
+
ok: false,
|
|
427
|
+
error: "patch_failed",
|
|
428
|
+
path: hunk.path,
|
|
429
|
+
detail: err.message
|
|
430
|
+
})
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
throw err;
|
|
434
|
+
}
|
|
435
|
+
if (!hunk.movePath) return { ops: [{ write: { abs, content: updated } }] };
|
|
436
|
+
const dest = v4aScope(projectRoot, hunk.movePath);
|
|
437
|
+
if ("error" in dest) return dest;
|
|
438
|
+
return { ops: [{ write: { abs: dest.abs, content: updated } }, { rm: abs }] };
|
|
439
|
+
}
|
|
440
|
+
var FORBIDDEN_SEGMENTS = /* @__PURE__ */ new Set([".env", ".git", "node_modules", ".theo"]);
|
|
441
|
+
function isForbiddenRel(rel) {
|
|
442
|
+
return rel.split(/[/\\]/).filter(Boolean).some((s) => s !== ".env.example" && (FORBIDDEN_SEGMENTS.has(s) || /^\.env\./.test(s)));
|
|
443
|
+
}
|
|
444
|
+
function v4aScope(projectRoot, file) {
|
|
445
|
+
let abs;
|
|
446
|
+
try {
|
|
447
|
+
abs = safePathJoin(projectRoot, file);
|
|
448
|
+
assertNoSymlinkEscape(abs, projectRoot);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
451
|
+
return { error: JSON.stringify({ ok: false, error: "path_traversal", path: file }) };
|
|
452
|
+
}
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
if (isForbiddenPath(file) || isForbiddenRel(relative(projectRoot, abs))) {
|
|
456
|
+
return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
|
|
226
457
|
}
|
|
227
|
-
return
|
|
458
|
+
return { abs };
|
|
228
459
|
}
|
|
229
460
|
function createSessionArtifactStore(options) {
|
|
230
461
|
const { dir } = options;
|
|
@@ -328,7 +559,7 @@ var LINE_MATCH_LADDER = [
|
|
|
328
559
|
(s) => normalizeUnicode(s).trim()
|
|
329
560
|
// unicode + trim (loosest)
|
|
330
561
|
];
|
|
331
|
-
function
|
|
562
|
+
function matchesAt2(lines, pattern, i, norm) {
|
|
332
563
|
for (let p = 0; p < pattern.length; p++) {
|
|
333
564
|
const lineAt = lines[i + p];
|
|
334
565
|
const patAt = pattern[p];
|
|
@@ -339,7 +570,7 @@ function matchesAt(lines, pattern, i, norm) {
|
|
|
339
570
|
function findHits(lines, pattern, norm) {
|
|
340
571
|
const hits = [];
|
|
341
572
|
for (let i = 0; i + pattern.length <= lines.length; i++) {
|
|
342
|
-
if (
|
|
573
|
+
if (matchesAt2(lines, pattern, i, norm)) hits.push(i);
|
|
343
574
|
}
|
|
344
575
|
return hits;
|
|
345
576
|
}
|
|
@@ -1371,29 +1602,29 @@ function createListDirTool(opts) {
|
|
|
1371
1602
|
path: z.string().min(1).describe("Project-relative directory path. Use '.' for root.")
|
|
1372
1603
|
}),
|
|
1373
1604
|
handler: async ({ path }, ctx) => {
|
|
1374
|
-
const
|
|
1375
|
-
if (
|
|
1605
|
+
const relative3 = path === "" || path === "." ? "." : path;
|
|
1606
|
+
if (relative3 !== "." && isForbiddenPath(relative3)) {
|
|
1376
1607
|
return JSON.stringify({ ok: false, error: "forbidden_path", path });
|
|
1377
1608
|
}
|
|
1378
1609
|
if (filesystem) {
|
|
1379
1610
|
const backend = await resolveFilesystem(filesystem, ctx ?? {});
|
|
1380
|
-
return listViaBackend(backend,
|
|
1611
|
+
return listViaBackend(backend, relative3, path, max);
|
|
1381
1612
|
}
|
|
1382
|
-
return listViaLocalFs(projectRoot,
|
|
1613
|
+
return listViaLocalFs(projectRoot, relative3, path, max);
|
|
1383
1614
|
}
|
|
1384
1615
|
});
|
|
1385
1616
|
}
|
|
1386
|
-
async function listViaLocalFs(projectRoot,
|
|
1387
|
-
const boundary = resolveDirBoundary(
|
|
1617
|
+
async function listViaLocalFs(projectRoot, relative3, originalPath, max) {
|
|
1618
|
+
const boundary = resolveDirBoundary(relative3, projectRoot, originalPath);
|
|
1388
1619
|
if ("error" in boundary) return boundary.error;
|
|
1389
1620
|
const readResult = await readDirSafe(boundary.absolutePath, originalPath);
|
|
1390
1621
|
if ("error" in readResult) return readResult.error;
|
|
1391
1622
|
return formatListing(readResult.dirents, max);
|
|
1392
1623
|
}
|
|
1393
|
-
async function listViaBackend(backend,
|
|
1624
|
+
async function listViaBackend(backend, relative3, originalPath, max) {
|
|
1394
1625
|
let names;
|
|
1395
1626
|
try {
|
|
1396
|
-
names = await backend.list(
|
|
1627
|
+
names = await backend.list(relative3);
|
|
1397
1628
|
} catch (err) {
|
|
1398
1629
|
if (err instanceof FileNotFoundError) {
|
|
1399
1630
|
return JSON.stringify({ ok: false, error: "not_found", path: originalPath });
|
|
@@ -1407,7 +1638,7 @@ async function listViaBackend(backend, relative2, originalPath, max) {
|
|
|
1407
1638
|
const windowed = names.slice(0, max);
|
|
1408
1639
|
const entries = await Promise.all(
|
|
1409
1640
|
windowed.map(async (name) => {
|
|
1410
|
-
const child =
|
|
1641
|
+
const child = relative3 === "." ? name : `${relative3}/${name}`;
|
|
1411
1642
|
let type = "file";
|
|
1412
1643
|
try {
|
|
1413
1644
|
type = (await backend.stat(child)).isDirectory ? "directory" : "file";
|
|
@@ -1418,9 +1649,9 @@ async function listViaBackend(backend, relative2, originalPath, max) {
|
|
|
1418
1649
|
);
|
|
1419
1650
|
return JSON.stringify({ ok: true, entries, truncated: totalCount > max, totalCount });
|
|
1420
1651
|
}
|
|
1421
|
-
function resolveDirBoundary(
|
|
1652
|
+
function resolveDirBoundary(relative3, projectRoot, originalPath) {
|
|
1422
1653
|
try {
|
|
1423
|
-
const absolutePath =
|
|
1654
|
+
const absolutePath = relative3 === "." ? projectRoot : safePathJoin(projectRoot, relative3);
|
|
1424
1655
|
assertNoSymlinkEscape(absolutePath, projectRoot);
|
|
1425
1656
|
return { absolutePath };
|
|
1426
1657
|
} catch (err) {
|
|
@@ -1642,22 +1873,22 @@ function createReadFileTool(opts) {
|
|
|
1642
1873
|
}
|
|
1643
1874
|
async function readViaBackend(backend, path, view, onRead) {
|
|
1644
1875
|
try {
|
|
1645
|
-
const
|
|
1646
|
-
if (
|
|
1876
|
+
const stat3 = await backend.stat(path);
|
|
1877
|
+
if (stat3.size > MAX_FILE_SIZE) {
|
|
1647
1878
|
return JSON.stringify({
|
|
1648
1879
|
ok: false,
|
|
1649
1880
|
error: "too_large",
|
|
1650
1881
|
path,
|
|
1651
|
-
size:
|
|
1882
|
+
size: stat3.size,
|
|
1652
1883
|
limit: MAX_FILE_SIZE
|
|
1653
1884
|
});
|
|
1654
1885
|
}
|
|
1655
1886
|
const raw = await backend.readFile(path);
|
|
1656
1887
|
if (raw.includes("\0")) {
|
|
1657
|
-
return JSON.stringify({ ok: false, error: "binary_file", path, size:
|
|
1888
|
+
return JSON.stringify({ ok: false, error: "binary_file", path, size: stat3.size });
|
|
1658
1889
|
}
|
|
1659
|
-
onRead?.(
|
|
1660
|
-
return JSON.stringify({ ok: true, content: renderView(raw, view), size:
|
|
1890
|
+
onRead?.(stat3.mtimeMs);
|
|
1891
|
+
return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat3.size });
|
|
1661
1892
|
} catch (err) {
|
|
1662
1893
|
if (err instanceof FileNotFoundError) {
|
|
1663
1894
|
return JSON.stringify({ ok: false, error: "not_found", path });
|
|
@@ -1696,22 +1927,22 @@ async function openHandleSafe(absolutePath, path) {
|
|
|
1696
1927
|
}
|
|
1697
1928
|
}
|
|
1698
1929
|
async function readContent(handle, path, view, onRead) {
|
|
1699
|
-
const
|
|
1700
|
-
if (
|
|
1930
|
+
const stat3 = await handle.stat();
|
|
1931
|
+
if (stat3.size > MAX_FILE_SIZE) {
|
|
1701
1932
|
return JSON.stringify({
|
|
1702
1933
|
ok: false,
|
|
1703
1934
|
error: "too_large",
|
|
1704
1935
|
path,
|
|
1705
|
-
size:
|
|
1936
|
+
size: stat3.size,
|
|
1706
1937
|
limit: MAX_FILE_SIZE
|
|
1707
1938
|
});
|
|
1708
1939
|
}
|
|
1709
|
-
if (await isBinaryProbe(handle, Number(
|
|
1710
|
-
return JSON.stringify({ ok: false, error: "binary_file", path, size:
|
|
1940
|
+
if (await isBinaryProbe(handle, Number(stat3.size))) {
|
|
1941
|
+
return JSON.stringify({ ok: false, error: "binary_file", path, size: stat3.size });
|
|
1711
1942
|
}
|
|
1712
1943
|
const raw = await handle.readFile({ encoding: "utf-8" });
|
|
1713
|
-
onRead?.(
|
|
1714
|
-
return JSON.stringify({ ok: true, content: renderView(raw, view), size:
|
|
1944
|
+
onRead?.(stat3.mtimeMs);
|
|
1945
|
+
return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat3.size });
|
|
1715
1946
|
}
|
|
1716
1947
|
async function isBinaryProbe(handle, size) {
|
|
1717
1948
|
const probeLen = Math.min(BINARY_PROBE_BYTES, size);
|
|
@@ -2627,12 +2858,12 @@ async function writeViaBackend(backend, path, content, guard) {
|
|
|
2627
2858
|
if (rbw) return rbw;
|
|
2628
2859
|
expectedMtime = current ?? void 0;
|
|
2629
2860
|
}
|
|
2630
|
-
const
|
|
2861
|
+
const stat3 = await backend.writeFile(
|
|
2631
2862
|
path,
|
|
2632
2863
|
content,
|
|
2633
2864
|
expectedMtime !== void 0 ? { expectedMtime } : void 0
|
|
2634
2865
|
);
|
|
2635
|
-
return JSON.stringify({ ok: true, path, bytes:
|
|
2866
|
+
return JSON.stringify({ ok: true, path, bytes: stat3.size });
|
|
2636
2867
|
} catch (err) {
|
|
2637
2868
|
return backendErrorToJson(err, path);
|
|
2638
2869
|
}
|
|
@@ -2660,8 +2891,8 @@ async function isBinaryFile(absolutePath) {
|
|
|
2660
2891
|
return false;
|
|
2661
2892
|
}
|
|
2662
2893
|
try {
|
|
2663
|
-
const
|
|
2664
|
-
const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(
|
|
2894
|
+
const stat3 = await handle.stat();
|
|
2895
|
+
const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(stat3.size));
|
|
2665
2896
|
if (probeLen <= 0) return false;
|
|
2666
2897
|
const probe = Buffer.alloc(probeLen);
|
|
2667
2898
|
const { bytesRead } = await handle.read(probe, 0, probeLen, 0);
|