botholomew 0.13.0 → 0.14.2
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/package.json +1 -1
- package/src/chat/agent.ts +17 -4
- package/src/commands/context.ts +35 -9
- package/src/context/fetcher-errors.ts +8 -0
- package/src/context/fetcher.ts +96 -27
- package/src/context/markdown-converter.ts +186 -0
- package/src/context/store.ts +209 -36
- package/src/fs/sandbox.ts +18 -4
- package/src/tools/dir/create.ts +1 -1
- package/src/tools/dir/tree.ts +3 -2
- package/src/tools/file/copy.ts +1 -1
- package/src/tools/file/delete.ts +11 -2
- package/src/tools/file/edit.ts +1 -1
- package/src/tools/file/info.ts +3 -1
- package/src/tools/file/move.ts +1 -1
- package/src/tools/file/write.ts +1 -1
- package/src/tools/registry.ts +5 -0
- package/src/tools/tool.ts +5 -0
- package/src/tools/util/sleep.ts +77 -0
- package/src/tui/components/SleepProgress.tsx +70 -0
- package/src/tui/components/ToolCall.tsx +10 -0
- package/src/utils/frontmatter.ts +10 -2
package/src/context/store.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
copyFile as fsCopyFile,
|
|
4
4
|
readFile as fsReadFile,
|
|
5
5
|
rename as fsRename,
|
|
6
|
+
lstat,
|
|
6
7
|
mkdir,
|
|
7
8
|
readdir,
|
|
8
9
|
rm,
|
|
@@ -69,12 +70,22 @@ export interface ContextEntry {
|
|
|
69
70
|
path: string;
|
|
70
71
|
is_directory: boolean;
|
|
71
72
|
is_textual: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* True when the entry's path under `context/` is a symlink (set from
|
|
75
|
+
* `lstat`). The agent can read and delete the link, but writes that
|
|
76
|
+
* traverse a symlink fail with PathEscapeError so external content is
|
|
77
|
+
* never modified.
|
|
78
|
+
*/
|
|
79
|
+
is_symlink: boolean;
|
|
72
80
|
size: number;
|
|
73
81
|
mime_type: string;
|
|
74
82
|
mtime: Date;
|
|
75
83
|
content_hash: string | null;
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
/** Hard cap on directory recursion across walks; defends against pathological symlink graphs. */
|
|
87
|
+
const MAX_WALK_DEPTH = 32;
|
|
88
|
+
|
|
78
89
|
const TEXTUAL_EXTENSIONS = new Set([
|
|
79
90
|
"md",
|
|
80
91
|
"markdown",
|
|
@@ -156,12 +167,18 @@ export function normalizeContextPath(path: string): string {
|
|
|
156
167
|
|
|
157
168
|
/**
|
|
158
169
|
* Resolve a context-relative path to an absolute filesystem path under
|
|
159
|
-
* `<projectDir>/context/`. Throws PathEscapeError on traversal,
|
|
160
|
-
*
|
|
170
|
+
* `<projectDir>/context/`. Throws PathEscapeError on traversal, NUL bytes,
|
|
171
|
+
* or attempts to resolve into a protected area.
|
|
172
|
+
*
|
|
173
|
+
* `allowSymlinks` is the opt-in for read-side callers (read, list, tree,
|
|
174
|
+
* info, reindex, delete). Mutating callers (write, edit, mv, cp, mkdir)
|
|
175
|
+
* leave it `false` so user-placed symlinks under `context/` cannot be
|
|
176
|
+
* traversed to modify external content.
|
|
161
177
|
*/
|
|
162
178
|
async function resolveContext(
|
|
163
179
|
projectDir: string,
|
|
164
180
|
path: string,
|
|
181
|
+
opts: { allowSymlinks?: boolean } = {},
|
|
165
182
|
): Promise<string> {
|
|
166
183
|
const normalized = normalizeContextPath(path);
|
|
167
184
|
if (PROTECTED_AREAS.has(normalized)) {
|
|
@@ -172,6 +189,7 @@ async function resolveContext(
|
|
|
172
189
|
}
|
|
173
190
|
return resolveInRoot(projectDir, fromPosix(normalized), {
|
|
174
191
|
area: CONTEXT_DIR,
|
|
192
|
+
allowSymlinks: opts.allowSymlinks,
|
|
175
193
|
});
|
|
176
194
|
}
|
|
177
195
|
|
|
@@ -195,7 +213,7 @@ export async function fileExists(
|
|
|
195
213
|
projectDir: string,
|
|
196
214
|
path: string,
|
|
197
215
|
): Promise<boolean> {
|
|
198
|
-
const abs = await resolveContext(projectDir, path);
|
|
216
|
+
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
199
217
|
try {
|
|
200
218
|
await stat(abs);
|
|
201
219
|
return true;
|
|
@@ -209,20 +227,45 @@ export async function getInfo(
|
|
|
209
227
|
projectDir: string,
|
|
210
228
|
path: string,
|
|
211
229
|
): Promise<ContextEntry | null> {
|
|
212
|
-
const abs = await resolveContext(projectDir, path);
|
|
213
|
-
let
|
|
230
|
+
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
231
|
+
let lst: Awaited<ReturnType<typeof lstat>>;
|
|
214
232
|
try {
|
|
215
|
-
|
|
233
|
+
lst = await lstat(abs);
|
|
216
234
|
} catch (err) {
|
|
217
235
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
218
236
|
throw err;
|
|
219
237
|
}
|
|
238
|
+
const isSymlink = lst.isSymbolicLink();
|
|
239
|
+
let st: Awaited<ReturnType<typeof stat>>;
|
|
240
|
+
if (isSymlink) {
|
|
241
|
+
try {
|
|
242
|
+
st = await stat(abs);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
// Broken symlink — surface as a zero-byte symlink entry.
|
|
245
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
246
|
+
return {
|
|
247
|
+
path: normalizeContextPath(path),
|
|
248
|
+
is_directory: false,
|
|
249
|
+
is_textual: false,
|
|
250
|
+
is_symlink: true,
|
|
251
|
+
size: 0,
|
|
252
|
+
mime_type: "application/octet-stream",
|
|
253
|
+
mtime: lst.mtime,
|
|
254
|
+
content_hash: null,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
st = lst;
|
|
261
|
+
}
|
|
220
262
|
const normalized = normalizeContextPath(path);
|
|
221
263
|
if (st.isDirectory()) {
|
|
222
264
|
return {
|
|
223
265
|
path: normalized,
|
|
224
266
|
is_directory: true,
|
|
225
267
|
is_textual: false,
|
|
268
|
+
is_symlink: isSymlink,
|
|
226
269
|
size: 0,
|
|
227
270
|
mime_type: "inode/directory",
|
|
228
271
|
mtime: st.mtime,
|
|
@@ -234,6 +277,7 @@ export async function getInfo(
|
|
|
234
277
|
path: normalized,
|
|
235
278
|
is_directory: false,
|
|
236
279
|
is_textual: textual,
|
|
280
|
+
is_symlink: isSymlink,
|
|
237
281
|
size: st.size,
|
|
238
282
|
mime_type: mime,
|
|
239
283
|
mtime: st.mtime,
|
|
@@ -245,7 +289,7 @@ export async function readContextFile(
|
|
|
245
289
|
projectDir: string,
|
|
246
290
|
path: string,
|
|
247
291
|
): Promise<string> {
|
|
248
|
-
const abs = await resolveContext(projectDir, path);
|
|
292
|
+
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
249
293
|
let st: Awaited<ReturnType<typeof stat>>;
|
|
250
294
|
try {
|
|
251
295
|
st = await stat(abs);
|
|
@@ -298,31 +342,42 @@ export async function deleteContextPath(
|
|
|
298
342
|
projectDir: string,
|
|
299
343
|
path: string,
|
|
300
344
|
opts: { recursive?: boolean } = {},
|
|
301
|
-
): Promise<{ removed: number; was_directory: boolean }> {
|
|
302
|
-
const abs = await resolveContext(projectDir, path);
|
|
345
|
+
): Promise<{ removed: number; was_directory: boolean; was_symlink: boolean }> {
|
|
346
|
+
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
303
347
|
const normalized = normalizeContextPath(path);
|
|
304
348
|
if (normalized === "") {
|
|
305
349
|
throw new PathEscapeError("refusing to delete the context root", path);
|
|
306
350
|
}
|
|
307
|
-
let
|
|
351
|
+
let lst: Awaited<ReturnType<typeof lstat>>;
|
|
308
352
|
try {
|
|
309
|
-
|
|
353
|
+
lst = await lstat(abs);
|
|
310
354
|
} catch (err) {
|
|
311
355
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
312
356
|
throw new NotFoundError(normalized);
|
|
313
357
|
}
|
|
314
358
|
throw err;
|
|
315
359
|
}
|
|
316
|
-
|
|
360
|
+
// A symlink (to a file or a directory, broken or not) is removed with a
|
|
361
|
+
// plain unlink — never follow into the target. This is what enforces
|
|
362
|
+
// "the symlink can be deleted, but not the original content".
|
|
363
|
+
if (lst.isSymbolicLink()) {
|
|
364
|
+
await unlink(abs);
|
|
365
|
+
return { removed: 1, was_directory: false, was_symlink: true };
|
|
366
|
+
}
|
|
367
|
+
if (lst.isDirectory()) {
|
|
317
368
|
if (!opts.recursive) {
|
|
318
369
|
throw new IsDirectoryError(normalized);
|
|
319
370
|
}
|
|
320
371
|
const removedPaths = await collectFiles(abs);
|
|
321
372
|
await rm(abs, { recursive: true, force: false });
|
|
322
|
-
return {
|
|
373
|
+
return {
|
|
374
|
+
removed: removedPaths.length,
|
|
375
|
+
was_directory: true,
|
|
376
|
+
was_symlink: false,
|
|
377
|
+
};
|
|
323
378
|
}
|
|
324
379
|
await unlink(abs);
|
|
325
|
-
return { removed: 1, was_directory: false };
|
|
380
|
+
return { removed: 1, was_directory: false, was_symlink: false };
|
|
326
381
|
}
|
|
327
382
|
|
|
328
383
|
export async function moveContextPath(
|
|
@@ -392,7 +447,7 @@ export async function listContextDir(
|
|
|
392
447
|
path: string,
|
|
393
448
|
opts: { recursive?: boolean } = {},
|
|
394
449
|
): Promise<ContextEntry[]> {
|
|
395
|
-
const abs = await resolveContext(projectDir, path);
|
|
450
|
+
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
396
451
|
let st: Awaited<ReturnType<typeof stat>>;
|
|
397
452
|
try {
|
|
398
453
|
st = await stat(abs);
|
|
@@ -406,11 +461,15 @@ export async function listContextDir(
|
|
|
406
461
|
throw new NotDirectoryError(normalizeContextPath(path));
|
|
407
462
|
}
|
|
408
463
|
const out: ContextEntry[] = [];
|
|
464
|
+
const visited = new Set<string>();
|
|
465
|
+
visited.add(`${st.dev}:${st.ino}`);
|
|
409
466
|
await walk(
|
|
410
467
|
abs,
|
|
411
468
|
canonicalContextRoot(projectDir),
|
|
412
469
|
opts.recursive ?? false,
|
|
413
470
|
out,
|
|
471
|
+
visited,
|
|
472
|
+
0,
|
|
414
473
|
);
|
|
415
474
|
out.sort((a, b) => a.path.localeCompare(b.path));
|
|
416
475
|
return out;
|
|
@@ -421,27 +480,58 @@ async function walk(
|
|
|
421
480
|
contextRoot: string,
|
|
422
481
|
recursive: boolean,
|
|
423
482
|
acc: ContextEntry[],
|
|
483
|
+
visited: Set<string>,
|
|
484
|
+
depth: number,
|
|
424
485
|
): Promise<void> {
|
|
486
|
+
if (depth >= MAX_WALK_DEPTH) return;
|
|
425
487
|
const names = await readdir(dir);
|
|
426
488
|
for (const name of names) {
|
|
427
489
|
if (name.startsWith(".")) continue;
|
|
428
490
|
const abs = join(dir, name);
|
|
429
491
|
const rel = toPosix(relative(contextRoot, abs));
|
|
430
|
-
const
|
|
431
|
-
const
|
|
432
|
-
|
|
492
|
+
const lst = await lstat(abs);
|
|
493
|
+
const isSymlink = lst.isSymbolicLink();
|
|
494
|
+
let st: Awaited<ReturnType<typeof stat>>;
|
|
495
|
+
if (isSymlink) {
|
|
496
|
+
try {
|
|
497
|
+
st = await stat(abs);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// Broken symlink — surface as a zero-byte symlink leaf so the agent
|
|
500
|
+
// can see and remove it, but don't try to recurse into it.
|
|
501
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
502
|
+
acc.push({
|
|
503
|
+
path: rel,
|
|
504
|
+
is_directory: false,
|
|
505
|
+
is_textual: false,
|
|
506
|
+
is_symlink: true,
|
|
507
|
+
size: 0,
|
|
508
|
+
mime_type: "application/octet-stream",
|
|
509
|
+
mtime: lst.mtime,
|
|
510
|
+
content_hash: null,
|
|
511
|
+
});
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
throw err;
|
|
515
|
+
}
|
|
516
|
+
} else {
|
|
517
|
+
st = lst;
|
|
518
|
+
}
|
|
433
519
|
if (st.isDirectory()) {
|
|
434
520
|
acc.push({
|
|
435
521
|
path: rel,
|
|
436
522
|
is_directory: true,
|
|
437
523
|
is_textual: false,
|
|
524
|
+
is_symlink: isSymlink,
|
|
438
525
|
size: 0,
|
|
439
526
|
mime_type: "inode/directory",
|
|
440
527
|
mtime: st.mtime,
|
|
441
528
|
content_hash: null,
|
|
442
529
|
});
|
|
443
530
|
if (recursive) {
|
|
444
|
-
|
|
531
|
+
const key = `${st.dev}:${st.ino}`;
|
|
532
|
+
if (visited.has(key)) continue;
|
|
533
|
+
visited.add(key);
|
|
534
|
+
await walk(abs, contextRoot, recursive, acc, visited, depth + 1);
|
|
445
535
|
}
|
|
446
536
|
} else if (st.isFile()) {
|
|
447
537
|
const { mime, textual } = inferMimeType(rel);
|
|
@@ -449,6 +539,7 @@ async function walk(
|
|
|
449
539
|
path: rel,
|
|
450
540
|
is_directory: false,
|
|
451
541
|
is_textual: textual,
|
|
542
|
+
is_symlink: isSymlink,
|
|
452
543
|
size: st.size,
|
|
453
544
|
mime_type: mime,
|
|
454
545
|
mtime: st.mtime,
|
|
@@ -458,10 +549,25 @@ async function walk(
|
|
|
458
549
|
}
|
|
459
550
|
}
|
|
460
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Collect all real file paths under `absDir`, following symlinks (including
|
|
554
|
+
* symlinked directories) once each. Used for delete-count reporting and
|
|
555
|
+
* `dirSizeBytes`. Symlinked entries are returned as the *symlink path*
|
|
556
|
+
* relative to the walk root, not the resolved target — callers like the
|
|
557
|
+
* delete reporter want the agent-visible path. Cycles are prevented via a
|
|
558
|
+
* `dev:ino` visited set seeded with `absDir` itself.
|
|
559
|
+
*/
|
|
461
560
|
async function collectFiles(absDir: string): Promise<string[]> {
|
|
462
561
|
const out: string[] = [];
|
|
463
|
-
const
|
|
464
|
-
|
|
562
|
+
const visited = new Set<string>();
|
|
563
|
+
try {
|
|
564
|
+
const rootSt = await stat(absDir);
|
|
565
|
+
visited.add(`${rootSt.dev}:${rootSt.ino}`);
|
|
566
|
+
} catch {
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
async function recurse(d: string, depth: number): Promise<void> {
|
|
570
|
+
if (depth >= MAX_WALK_DEPTH) return;
|
|
465
571
|
let names: string[];
|
|
466
572
|
try {
|
|
467
573
|
names = await readdir(d);
|
|
@@ -470,13 +576,24 @@ async function collectFiles(absDir: string): Promise<string[]> {
|
|
|
470
576
|
}
|
|
471
577
|
for (const name of names) {
|
|
472
578
|
const abs = join(d, name);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
579
|
+
let st: Awaited<ReturnType<typeof stat>>;
|
|
580
|
+
try {
|
|
581
|
+
st = await stat(abs);
|
|
582
|
+
} catch {
|
|
583
|
+
// Broken symlink or permission issue — skip silently.
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (st.isDirectory()) {
|
|
587
|
+
const key = `${st.dev}:${st.ino}`;
|
|
588
|
+
if (visited.has(key)) continue;
|
|
589
|
+
visited.add(key);
|
|
590
|
+
await recurse(abs, depth + 1);
|
|
591
|
+
} else if (st.isFile()) {
|
|
592
|
+
out.push(abs);
|
|
593
|
+
}
|
|
477
594
|
}
|
|
478
595
|
}
|
|
479
|
-
await recurse(absDir);
|
|
596
|
+
await recurse(absDir, 0);
|
|
480
597
|
return out;
|
|
481
598
|
}
|
|
482
599
|
|
|
@@ -484,6 +601,7 @@ export interface TreeNode {
|
|
|
484
601
|
name: string;
|
|
485
602
|
path: string;
|
|
486
603
|
is_directory: boolean;
|
|
604
|
+
is_symlink?: boolean;
|
|
487
605
|
size?: number;
|
|
488
606
|
children?: TreeNode[];
|
|
489
607
|
}
|
|
@@ -493,7 +611,14 @@ export async function buildTree(
|
|
|
493
611
|
path: string,
|
|
494
612
|
maxDepth = 16,
|
|
495
613
|
): Promise<TreeNode> {
|
|
496
|
-
const abs = await resolveContext(projectDir, path);
|
|
614
|
+
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
615
|
+
const lst = await lstat(abs).catch((err) => {
|
|
616
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
617
|
+
throw new NotFoundError(normalizeContextPath(path));
|
|
618
|
+
}
|
|
619
|
+
throw err;
|
|
620
|
+
});
|
|
621
|
+
const isSymlink = lst.isSymbolicLink();
|
|
497
622
|
let st: Awaited<ReturnType<typeof stat>>;
|
|
498
623
|
try {
|
|
499
624
|
st = await stat(abs);
|
|
@@ -511,10 +636,13 @@ export async function buildTree(
|
|
|
511
636
|
name,
|
|
512
637
|
path: rel,
|
|
513
638
|
is_directory: false,
|
|
639
|
+
...(isSymlink ? { is_symlink: true } : {}),
|
|
514
640
|
size: st.size,
|
|
515
641
|
};
|
|
516
642
|
}
|
|
517
|
-
|
|
643
|
+
const visited = new Set<string>();
|
|
644
|
+
visited.add(`${st.dev}:${st.ino}`);
|
|
645
|
+
return treeRecurse(abs, rel, name, root, maxDepth, visited, isSymlink);
|
|
518
646
|
}
|
|
519
647
|
|
|
520
648
|
async function treeRecurse(
|
|
@@ -523,11 +651,14 @@ async function treeRecurse(
|
|
|
523
651
|
name: string,
|
|
524
652
|
contextRoot: string,
|
|
525
653
|
depthLeft: number,
|
|
654
|
+
visited: Set<string>,
|
|
655
|
+
isSymlink: boolean,
|
|
526
656
|
): Promise<TreeNode> {
|
|
527
657
|
const node: TreeNode = {
|
|
528
658
|
name,
|
|
529
659
|
path: rel,
|
|
530
660
|
is_directory: true,
|
|
661
|
+
...(isSymlink ? { is_symlink: true } : {}),
|
|
531
662
|
children: [],
|
|
532
663
|
};
|
|
533
664
|
if (depthLeft <= 0) return node;
|
|
@@ -538,24 +669,66 @@ async function treeRecurse(
|
|
|
538
669
|
return node;
|
|
539
670
|
}
|
|
540
671
|
names.sort((a, b) => a.localeCompare(b));
|
|
541
|
-
const { lstat } = await import("node:fs/promises");
|
|
542
672
|
const children = node.children ?? [];
|
|
543
673
|
for (const name of names) {
|
|
544
674
|
if (name.startsWith(".")) continue;
|
|
545
675
|
const childAbs = join(abs, name);
|
|
546
|
-
const
|
|
547
|
-
|
|
676
|
+
const lst = await lstat(childAbs);
|
|
677
|
+
const childIsSymlink = lst.isSymbolicLink();
|
|
678
|
+
let childSt: Awaited<ReturnType<typeof stat>>;
|
|
679
|
+
if (childIsSymlink) {
|
|
680
|
+
try {
|
|
681
|
+
childSt = await stat(childAbs);
|
|
682
|
+
} catch (err) {
|
|
683
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
684
|
+
// Broken symlink — render as zero-byte leaf so it shows in the tree.
|
|
685
|
+
children.push({
|
|
686
|
+
name,
|
|
687
|
+
path: toPosix(relative(contextRoot, childAbs)),
|
|
688
|
+
is_directory: false,
|
|
689
|
+
is_symlink: true,
|
|
690
|
+
size: 0,
|
|
691
|
+
});
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
throw err;
|
|
695
|
+
}
|
|
696
|
+
} else {
|
|
697
|
+
childSt = lst;
|
|
698
|
+
}
|
|
548
699
|
const childRel = toPosix(relative(contextRoot, childAbs));
|
|
549
|
-
if (
|
|
700
|
+
if (childSt.isDirectory()) {
|
|
701
|
+
const key = `${childSt.dev}:${childSt.ino}`;
|
|
702
|
+
if (visited.has(key)) {
|
|
703
|
+
// Cycle — render as a stub directory with no children.
|
|
704
|
+
children.push({
|
|
705
|
+
name,
|
|
706
|
+
path: childRel,
|
|
707
|
+
is_directory: true,
|
|
708
|
+
...(childIsSymlink ? { is_symlink: true } : {}),
|
|
709
|
+
children: [],
|
|
710
|
+
});
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
visited.add(key);
|
|
550
714
|
children.push(
|
|
551
|
-
await treeRecurse(
|
|
715
|
+
await treeRecurse(
|
|
716
|
+
childAbs,
|
|
717
|
+
childRel,
|
|
718
|
+
name,
|
|
719
|
+
contextRoot,
|
|
720
|
+
depthLeft - 1,
|
|
721
|
+
visited,
|
|
722
|
+
childIsSymlink,
|
|
723
|
+
),
|
|
552
724
|
);
|
|
553
|
-
} else if (
|
|
725
|
+
} else if (childSt.isFile()) {
|
|
554
726
|
children.push({
|
|
555
727
|
name,
|
|
556
728
|
path: childRel,
|
|
557
729
|
is_directory: false,
|
|
558
|
-
|
|
730
|
+
...(childIsSymlink ? { is_symlink: true } : {}),
|
|
731
|
+
size: childSt.size,
|
|
559
732
|
});
|
|
560
733
|
}
|
|
561
734
|
}
|
|
@@ -567,7 +740,7 @@ export async function dirSizeBytes(
|
|
|
567
740
|
projectDir: string,
|
|
568
741
|
path: string,
|
|
569
742
|
): Promise<{ files: number; bytes: number }> {
|
|
570
|
-
const abs = await resolveContext(projectDir, path);
|
|
743
|
+
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
571
744
|
let st: Awaited<ReturnType<typeof stat>>;
|
|
572
745
|
try {
|
|
573
746
|
st = await stat(abs);
|
package/src/fs/sandbox.ts
CHANGED
|
@@ -27,6 +27,15 @@ export interface SandboxOptions {
|
|
|
27
27
|
* operations like list/tree). Default true.
|
|
28
28
|
*/
|
|
29
29
|
allowRoot?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Permit user-placed symlinks anywhere along the resolved path. The
|
|
32
|
+
* containment check on the user-supplied path is unchanged — only the
|
|
33
|
+
* lstat-walk that rejects symlink components is skipped. Read-side
|
|
34
|
+
* callers (read, list, tree, reindex) opt in; mutating callers do not,
|
|
35
|
+
* so the agent can never write through a user symlink to external
|
|
36
|
+
* content.
|
|
37
|
+
*/
|
|
38
|
+
allowSymlinks?: boolean;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
let cachedCanonicalRoot: string | null = null;
|
|
@@ -65,8 +74,9 @@ export function getCanonicalRoot(rawRoot: string): string {
|
|
|
65
74
|
* 3. After path.resolve, the result must be inside the (canonical) root or
|
|
66
75
|
* `<root>/<area>`.
|
|
67
76
|
* 4. `..` components after normalization are rejected as defense in depth.
|
|
68
|
-
* 5. Every existing path component is `lstat`'d; any symlink is rejected
|
|
69
|
-
*
|
|
77
|
+
* 5. Every existing path component is `lstat`'d; any symlink is rejected
|
|
78
|
+
* unless `allowSymlinks` is set (read-only callers opt in so users can
|
|
79
|
+
* symlink content into `<root>/context/`). Hardlinks are out of scope.
|
|
70
80
|
*
|
|
71
81
|
* Returns the absolute, canonical path safe to pass to fs APIs.
|
|
72
82
|
*/
|
|
@@ -86,7 +96,9 @@ export async function resolveInRoot(
|
|
|
86
96
|
const resolved = resolve(boundary, normalized);
|
|
87
97
|
ensureContainment(resolved, boundary, opts.allowRoot ?? true, userPath);
|
|
88
98
|
|
|
89
|
-
|
|
99
|
+
if (!opts.allowSymlinks) {
|
|
100
|
+
await assertNoSymlinkComponents(resolved, canonicalRoot);
|
|
101
|
+
}
|
|
90
102
|
return resolved;
|
|
91
103
|
}
|
|
92
104
|
|
|
@@ -109,7 +121,9 @@ export function resolveInRootSync(
|
|
|
109
121
|
const resolved = resolve(boundary, normalized);
|
|
110
122
|
ensureContainment(resolved, boundary, opts.allowRoot ?? true, userPath);
|
|
111
123
|
|
|
112
|
-
|
|
124
|
+
if (!opts.allowSymlinks) {
|
|
125
|
+
assertNoSymlinkComponentsSync(resolved, canonicalRoot);
|
|
126
|
+
}
|
|
113
127
|
return resolved;
|
|
114
128
|
}
|
|
115
129
|
|
package/src/tools/dir/create.ts
CHANGED
|
@@ -18,7 +18,7 @@ const outputSchema = z.object({
|
|
|
18
18
|
export const contextCreateDirTool = {
|
|
19
19
|
name: "context_create_dir",
|
|
20
20
|
description:
|
|
21
|
-
"[[ bash equivalent command: mkdir -p ]] Create a directory (recursively) under context/.",
|
|
21
|
+
"[[ bash equivalent command: mkdir -p ]] Create a directory (recursively) under context/. Paths that traverse a user symlink fail with PathEscapeError.",
|
|
22
22
|
group: "context",
|
|
23
23
|
inputSchema,
|
|
24
24
|
outputSchema,
|
package/src/tools/dir/tree.ts
CHANGED
|
@@ -21,7 +21,8 @@ export interface BuildContextTreeResult {
|
|
|
21
21
|
function renderTree(node: TreeNode, prefix = "", isLast = true): string[] {
|
|
22
22
|
const lines: string[] = [];
|
|
23
23
|
const connector = prefix === "" ? "" : isLast ? "└── " : "├── ";
|
|
24
|
-
const
|
|
24
|
+
const base = node.is_directory ? `${node.name}/` : node.name;
|
|
25
|
+
const label = node.is_symlink ? `${base} -> (symlink)` : base;
|
|
25
26
|
lines.push(`${prefix}${connector}${label}`);
|
|
26
27
|
if (node.is_directory && node.children) {
|
|
27
28
|
const childPrefix =
|
|
@@ -72,7 +73,7 @@ const outputSchema = z.object({
|
|
|
72
73
|
export const contextTreeTool = {
|
|
73
74
|
name: "context_tree",
|
|
74
75
|
description:
|
|
75
|
-
"[[ bash equivalent command: tree ]] Render the file tree under context/ (or a sub-directory).",
|
|
76
|
+
"[[ bash equivalent command: tree ]] Render the file tree under context/ (or a sub-directory). Symlinks are followed for listing and indexing; entries that are symlinks are tagged ' -> (symlink)' in the output.",
|
|
76
77
|
group: "context",
|
|
77
78
|
inputSchema,
|
|
78
79
|
outputSchema,
|
package/src/tools/file/copy.ts
CHANGED
|
@@ -26,7 +26,7 @@ const outputSchema = z.object({
|
|
|
26
26
|
export const contextCopyTool = {
|
|
27
27
|
name: "context_copy",
|
|
28
28
|
description:
|
|
29
|
-
"[[ bash equivalent command: cp ]] Copy a file under context/ to a new path.",
|
|
29
|
+
"[[ bash equivalent command: cp ]] Copy a file under context/ to a new path. Source/destination paths that traverse a user symlink fail with PathEscapeError.",
|
|
30
30
|
group: "context",
|
|
31
31
|
inputSchema,
|
|
32
32
|
outputSchema,
|
package/src/tools/file/delete.ts
CHANGED
|
@@ -21,6 +21,7 @@ const inputSchema = z.object({
|
|
|
21
21
|
const outputSchema = z.object({
|
|
22
22
|
deleted: z.number(),
|
|
23
23
|
was_directory: z.boolean(),
|
|
24
|
+
was_symlink: z.boolean(),
|
|
24
25
|
is_error: z.boolean(),
|
|
25
26
|
error_type: z.string().optional(),
|
|
26
27
|
message: z.string().optional(),
|
|
@@ -30,7 +31,7 @@ const outputSchema = z.object({
|
|
|
30
31
|
export const contextDeleteTool = {
|
|
31
32
|
name: "context_delete",
|
|
32
33
|
description:
|
|
33
|
-
"[[ bash equivalent command: rm -r ]] Delete a file or (with recursive=true) a directory under context/.",
|
|
34
|
+
"[[ bash equivalent command: rm -r ]] Delete a file or (with recursive=true) a directory under context/. Symlinks are unlinked without touching their target — `recursive` is not required for a symlinked directory.",
|
|
34
35
|
group: "context",
|
|
35
36
|
inputSchema,
|
|
36
37
|
outputSchema,
|
|
@@ -42,16 +43,23 @@ export const contextDeleteTool = {
|
|
|
42
43
|
return {
|
|
43
44
|
deleted: result.removed,
|
|
44
45
|
was_directory: result.was_directory,
|
|
46
|
+
was_symlink: result.was_symlink,
|
|
45
47
|
is_error: false,
|
|
46
48
|
};
|
|
47
49
|
} catch (err) {
|
|
48
50
|
if (err instanceof NotFoundError) {
|
|
49
51
|
if (input.force) {
|
|
50
|
-
return {
|
|
52
|
+
return {
|
|
53
|
+
deleted: 0,
|
|
54
|
+
was_directory: false,
|
|
55
|
+
was_symlink: false,
|
|
56
|
+
is_error: false,
|
|
57
|
+
};
|
|
51
58
|
}
|
|
52
59
|
return {
|
|
53
60
|
deleted: 0,
|
|
54
61
|
was_directory: false,
|
|
62
|
+
was_symlink: false,
|
|
55
63
|
is_error: true,
|
|
56
64
|
error_type: "not_found",
|
|
57
65
|
message: `No file at context/${err.path}`,
|
|
@@ -61,6 +69,7 @@ export const contextDeleteTool = {
|
|
|
61
69
|
return {
|
|
62
70
|
deleted: 0,
|
|
63
71
|
was_directory: true,
|
|
72
|
+
was_symlink: false,
|
|
64
73
|
is_error: true,
|
|
65
74
|
error_type: "is_directory",
|
|
66
75
|
message: `context/${err.path} is a directory`,
|
package/src/tools/file/edit.ts
CHANGED
|
@@ -33,7 +33,7 @@ const outputSchema = z.object({
|
|
|
33
33
|
export const contextEditTool = {
|
|
34
34
|
name: "context_edit",
|
|
35
35
|
description:
|
|
36
|
-
"[[ bash equivalent command: patch ]] Apply line-range patches to a file under context/. Each patch specifies start_line/end_line/content.",
|
|
36
|
+
"[[ bash equivalent command: patch ]] Apply line-range patches to a file under context/. Each patch specifies start_line/end_line/content. Edits that traverse a user symlink fail with PathEscapeError — delete the symlink first or copy the content to a real path.",
|
|
37
37
|
group: "context",
|
|
38
38
|
inputSchema,
|
|
39
39
|
outputSchema,
|
package/src/tools/file/info.ts
CHANGED
|
@@ -10,6 +10,7 @@ const fileSchema = z.object({
|
|
|
10
10
|
path: z.string(),
|
|
11
11
|
is_directory: z.boolean(),
|
|
12
12
|
is_textual: z.boolean(),
|
|
13
|
+
is_symlink: z.boolean(),
|
|
13
14
|
mime_type: z.string(),
|
|
14
15
|
size: z.number(),
|
|
15
16
|
lines: z.number(),
|
|
@@ -28,7 +29,7 @@ const outputSchema = z.object({
|
|
|
28
29
|
export const contextInfoTool = {
|
|
29
30
|
name: "context_info",
|
|
30
31
|
description:
|
|
31
|
-
"[[ bash equivalent command: stat ]] Show metadata for a path under context/: size, MIME type, line count, mtime, content hash.",
|
|
32
|
+
"[[ bash equivalent command: stat ]] Show metadata for a path under context/: size, MIME type, line count, mtime, content hash. `is_symlink` is true when the path is a user-placed symlink.",
|
|
32
33
|
group: "context",
|
|
33
34
|
inputSchema,
|
|
34
35
|
outputSchema,
|
|
@@ -52,6 +53,7 @@ export const contextInfoTool = {
|
|
|
52
53
|
path: info.path,
|
|
53
54
|
is_directory: info.is_directory,
|
|
54
55
|
is_textual: info.is_textual,
|
|
56
|
+
is_symlink: info.is_symlink,
|
|
55
57
|
mime_type: info.mime_type,
|
|
56
58
|
size: info.size,
|
|
57
59
|
lines,
|
package/src/tools/file/move.ts
CHANGED
|
@@ -25,7 +25,7 @@ const outputSchema = z.object({
|
|
|
25
25
|
export const contextMoveTool = {
|
|
26
26
|
name: "context_move",
|
|
27
27
|
description:
|
|
28
|
-
"[[ bash equivalent command: mv ]] Move or rename a file/directory under context/.",
|
|
28
|
+
"[[ bash equivalent command: mv ]] Move or rename a file/directory under context/. Source/destination paths that traverse a user symlink fail with PathEscapeError.",
|
|
29
29
|
group: "context",
|
|
30
30
|
inputSchema,
|
|
31
31
|
outputSchema,
|
package/src/tools/file/write.ts
CHANGED
|
@@ -28,7 +28,7 @@ const outputSchema = z.object({
|
|
|
28
28
|
export const contextWriteTool = {
|
|
29
29
|
name: "context_write",
|
|
30
30
|
description:
|
|
31
|
-
"[[ bash equivalent command: tee ]] Write text content to a file under context/. Fails if the path already exists unless on_conflict='overwrite'.",
|
|
31
|
+
"[[ bash equivalent command: tee ]] Write text content to a file under context/. Fails if the path already exists unless on_conflict='overwrite'. Writes that traverse a user symlink fail with PathEscapeError — delete the symlink first or write to a real path.",
|
|
32
32
|
group: "context",
|
|
33
33
|
inputSchema,
|
|
34
34
|
outputSchema,
|
package/src/tools/registry.ts
CHANGED
|
@@ -50,6 +50,8 @@ import { listThreadsTool } from "./thread/list.ts";
|
|
|
50
50
|
import { searchThreadsTool } from "./thread/search.ts";
|
|
51
51
|
import { viewThreadTool } from "./thread/view.ts";
|
|
52
52
|
import { registerTool } from "./tool.ts";
|
|
53
|
+
// Util tools
|
|
54
|
+
import { sleepTool } from "./util/sleep.ts";
|
|
53
55
|
// Worker tools
|
|
54
56
|
import { spawnWorkerTool } from "./worker/spawn.ts";
|
|
55
57
|
|
|
@@ -111,6 +113,9 @@ export function registerAllTools(): void {
|
|
|
111
113
|
registerTool(mcpInfoTool);
|
|
112
114
|
registerTool(mcpExecTool);
|
|
113
115
|
|
|
116
|
+
// Util
|
|
117
|
+
registerTool(sleepTool);
|
|
118
|
+
|
|
114
119
|
// Worker
|
|
115
120
|
registerTool(spawnWorkerTool);
|
|
116
121
|
}
|