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