botholomew 0.12.3 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -68
- package/package.json +3 -3
- package/src/chat/agent.ts +42 -82
- package/src/chat/session.ts +29 -25
- package/src/commands/capabilities.ts +1 -1
- package/src/commands/context.ts +177 -926
- package/src/commands/db.ts +9 -13
- package/src/commands/init.ts +4 -1
- package/src/commands/nuke.ts +57 -90
- package/src/commands/schedule.ts +103 -124
- package/src/commands/skill.ts +2 -2
- package/src/commands/task.ts +86 -95
- package/src/commands/thread.ts +107 -112
- package/src/commands/worker.ts +88 -88
- package/src/constants.ts +93 -16
- package/src/context/capabilities.ts +10 -10
- package/src/context/fetcher.ts +9 -10
- package/src/context/reindex.ts +189 -0
- package/src/context/store.ts +630 -0
- package/src/db/doctor.ts +1 -8
- package/src/db/embeddings.ts +227 -175
- package/src/db/sql/19-disk_backed_index.sql +36 -0
- package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
- package/src/fs/atomic.ts +217 -0
- package/src/fs/compat.ts +86 -0
- package/src/fs/sandbox.ts +279 -0
- package/src/init/index.ts +69 -52
- package/src/init/templates.ts +1 -1
- package/src/mcpx/client.ts +1 -1
- package/src/schedules/schema.ts +19 -0
- package/src/schedules/store.ts +296 -0
- package/src/skills/commands.ts +1 -3
- package/src/tasks/schema.ts +47 -0
- package/src/tasks/store.ts +486 -0
- package/src/threads/store.ts +559 -0
- package/src/tools/capabilities/refresh.ts +42 -21
- package/src/tools/context/pipe.ts +15 -71
- package/src/tools/context/update-beliefs.ts +3 -3
- package/src/tools/context/update-goals.ts +3 -3
- package/src/tools/dir/create.ts +26 -23
- package/src/tools/dir/size.ts +46 -17
- package/src/tools/dir/tree.ts +73 -279
- package/src/tools/file/copy.ts +50 -24
- package/src/tools/file/count-lines.ts +34 -10
- package/src/tools/file/delete.ts +44 -23
- package/src/tools/file/edit.ts +39 -14
- package/src/tools/file/exists.ts +12 -26
- package/src/tools/file/info.ts +25 -85
- package/src/tools/file/move.ts +39 -24
- package/src/tools/file/read.ts +32 -80
- package/src/tools/file/write.ts +14 -91
- package/src/tools/registry.ts +3 -7
- package/src/tools/schedule/create.ts +2 -2
- package/src/tools/schedule/list.ts +7 -3
- package/src/tools/search/fuse.ts +12 -33
- package/src/tools/search/index.ts +36 -43
- package/src/tools/search/regexp.ts +29 -17
- package/src/tools/search/semantic.ts +137 -51
- package/src/tools/skill/delete.ts +1 -1
- package/src/tools/skill/list.ts +1 -1
- package/src/tools/skill/write.ts +1 -1
- package/src/tools/task/create.ts +41 -16
- package/src/tools/task/delete.ts +3 -3
- package/src/tools/task/list.ts +6 -3
- package/src/tools/task/update.ts +31 -9
- package/src/tools/task/view.ts +6 -6
- package/src/tools/thread/list.ts +2 -2
- package/src/tools/thread/search.ts +208 -0
- package/src/tools/thread/view.ts +50 -5
- package/src/tools/worker/spawn.ts +28 -14
- package/src/tui/App.tsx +12 -19
- package/src/tui/components/ContextPanel.tsx +83 -316
- package/src/tui/components/SchedulePanel.tsx +34 -48
- package/src/tui/components/StatusBar.tsx +15 -15
- package/src/tui/components/TaskPanel.tsx +34 -38
- package/src/tui/components/ThreadPanel.tsx +29 -38
- package/src/tui/components/WorkerPanel.tsx +21 -19
- package/src/tui/markdown.ts +2 -8
- package/src/types/file-imports.d.ts +9 -0
- package/src/utils/title.ts +5 -7
- package/src/utils/v7-date.ts +47 -0
- package/src/worker/heartbeat.ts +46 -24
- package/src/worker/index.ts +13 -15
- package/src/worker/llm.ts +30 -37
- package/src/worker/prompt.ts +19 -41
- package/src/worker/schedules.ts +48 -69
- package/src/worker/spawn.ts +11 -11
- package/src/worker/tick.ts +39 -43
- package/src/workers/store.ts +247 -0
- package/src/commands/tools.ts +0 -367
- package/src/context/describer.ts +0 -140
- package/src/context/drives.ts +0 -110
- package/src/context/ingest.ts +0 -162
- package/src/context/refresh.ts +0 -183
- package/src/db/context.ts +0 -637
- package/src/db/daemon-state.ts +0 -6
- package/src/db/reembed.ts +0 -113
- package/src/db/schedules.ts +0 -213
- package/src/db/tasks.ts +0 -347
- package/src/db/threads.ts +0 -276
- package/src/db/workers.ts +0 -212
- package/src/tools/context/list-drives.ts +0 -36
- package/src/tools/context/refresh.ts +0 -165
- package/src/tools/context/search.ts +0 -54
package/src/fs/atomic.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
open,
|
|
5
|
+
readFile,
|
|
6
|
+
rename,
|
|
7
|
+
rm,
|
|
8
|
+
stat,
|
|
9
|
+
unlink,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Write `content` to `targetPath` atomically: write to a sibling temp file,
|
|
15
|
+
* fsync, then rename. The rename is atomic on POSIX same-filesystem; the
|
|
16
|
+
* fsync ensures the file's bytes are durable before the rename commits.
|
|
17
|
+
*
|
|
18
|
+
* `tempSuffix` may be set to ensure two writers don't collide on a temp
|
|
19
|
+
* filename in the same directory (use the worker id for status updates).
|
|
20
|
+
*/
|
|
21
|
+
export async function atomicWrite(
|
|
22
|
+
targetPath: string,
|
|
23
|
+
content: string | Uint8Array,
|
|
24
|
+
opts: { tempSuffix?: string } = {},
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
27
|
+
const suffix = opts.tempSuffix ?? `${process.pid}.${Date.now()}`;
|
|
28
|
+
const tmp = `${targetPath}.tmp.${suffix}`;
|
|
29
|
+
const fh = await open(tmp, "w", 0o644);
|
|
30
|
+
try {
|
|
31
|
+
if (typeof content === "string") {
|
|
32
|
+
await fh.writeFile(content, "utf-8");
|
|
33
|
+
} else {
|
|
34
|
+
await fh.writeFile(content);
|
|
35
|
+
}
|
|
36
|
+
await fh.sync();
|
|
37
|
+
} finally {
|
|
38
|
+
await fh.close();
|
|
39
|
+
}
|
|
40
|
+
await rename(tmp, targetPath);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read a file's contents along with its mtime in a single call so callers can
|
|
45
|
+
* detect concurrent modification before committing an update. Returns null if
|
|
46
|
+
* the file doesn't exist.
|
|
47
|
+
*/
|
|
48
|
+
export async function readWithMtime(
|
|
49
|
+
path: string,
|
|
50
|
+
): Promise<{ content: string; mtimeMs: number } | null> {
|
|
51
|
+
let st: Awaited<ReturnType<typeof stat>>;
|
|
52
|
+
try {
|
|
53
|
+
st = await stat(path);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
const content = await readFile(path, "utf-8");
|
|
59
|
+
return { content, mtimeMs: st.mtimeMs };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class MtimeConflictError extends Error {
|
|
63
|
+
constructor(
|
|
64
|
+
readonly path: string,
|
|
65
|
+
readonly expectedMtimeMs: number,
|
|
66
|
+
readonly actualMtimeMs: number,
|
|
67
|
+
) {
|
|
68
|
+
super(
|
|
69
|
+
`concurrent modification detected for ${path}: expected mtime ${expectedMtimeMs}, found ${actualMtimeMs}`,
|
|
70
|
+
);
|
|
71
|
+
this.name = "MtimeConflictError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Atomic write guarded by mtime. Re-stats the target right before the rename
|
|
77
|
+
* commit; if it has changed since `expectedMtimeMs`, throws MtimeConflictError
|
|
78
|
+
* without touching the file. Use for read-modify-write of user-editable files
|
|
79
|
+
* (tasks, schedules) so a concurrent vim save doesn't get clobbered.
|
|
80
|
+
*
|
|
81
|
+
* A target that has been deleted between the caller's read and the rename
|
|
82
|
+
* is also a conflict (the row is gone — we must not silently resurrect it),
|
|
83
|
+
* so an ENOENT raises MtimeConflictError too.
|
|
84
|
+
*/
|
|
85
|
+
export async function atomicWriteIfUnchanged(
|
|
86
|
+
targetPath: string,
|
|
87
|
+
content: string,
|
|
88
|
+
expectedMtimeMs: number,
|
|
89
|
+
opts: { tempSuffix?: string } = {},
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
92
|
+
const suffix = opts.tempSuffix ?? `${process.pid}.${Date.now()}`;
|
|
93
|
+
const tmp = `${targetPath}.tmp.${suffix}`;
|
|
94
|
+
const fh = await open(tmp, "w", 0o644);
|
|
95
|
+
try {
|
|
96
|
+
await fh.writeFile(content, "utf-8");
|
|
97
|
+
await fh.sync();
|
|
98
|
+
} finally {
|
|
99
|
+
await fh.close();
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const st = await stat(targetPath);
|
|
103
|
+
if (st.mtimeMs !== expectedMtimeMs) {
|
|
104
|
+
await unlink(tmp).catch(() => {});
|
|
105
|
+
throw new MtimeConflictError(targetPath, expectedMtimeMs, st.mtimeMs);
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
await unlink(tmp).catch(() => {});
|
|
109
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
110
|
+
// Target was deleted between the caller's read and our write. Refuse
|
|
111
|
+
// to resurrect it — this is a conflict, the same as a stale mtime.
|
|
112
|
+
throw new MtimeConflictError(targetPath, expectedMtimeMs, 0);
|
|
113
|
+
}
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
await rename(tmp, targetPath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class LockHeldError extends Error {
|
|
120
|
+
constructor(
|
|
121
|
+
readonly lockPath: string,
|
|
122
|
+
readonly heldBy: string | null,
|
|
123
|
+
) {
|
|
124
|
+
super(`lock ${lockPath} is held${heldBy ? ` by ${heldBy}` : ""}`);
|
|
125
|
+
this.name = "LockHeldError";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Acquire an exclusive lock by creating a sentinel file with O_EXCL. Returns
|
|
131
|
+
* the path to the lockfile (release with releaseLock). If another worker
|
|
132
|
+
* already holds the lock, throws LockHeldError including the holder's id.
|
|
133
|
+
*
|
|
134
|
+
* The lockfile body is JSON containing the workerId and acquired_at, useful
|
|
135
|
+
* for the reaper to identify dead-worker locks.
|
|
136
|
+
*/
|
|
137
|
+
export async function acquireLock(
|
|
138
|
+
lockPath: string,
|
|
139
|
+
workerId: string,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
142
|
+
const body = JSON.stringify({
|
|
143
|
+
worker_id: workerId,
|
|
144
|
+
acquired_at: new Date().toISOString(),
|
|
145
|
+
pid: process.pid,
|
|
146
|
+
});
|
|
147
|
+
let fh: Awaited<ReturnType<typeof open>>;
|
|
148
|
+
try {
|
|
149
|
+
fh = await open(
|
|
150
|
+
lockPath,
|
|
151
|
+
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
|
152
|
+
0o644,
|
|
153
|
+
);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
|
156
|
+
const heldBy = await readLockHolder(lockPath);
|
|
157
|
+
throw new LockHeldError(lockPath, heldBy);
|
|
158
|
+
}
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
await fh.writeFile(body, "utf-8");
|
|
163
|
+
await fh.sync();
|
|
164
|
+
} finally {
|
|
165
|
+
await fh.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function readLockHolder(lockPath: string): Promise<string | null> {
|
|
170
|
+
try {
|
|
171
|
+
const text = await readFile(lockPath, "utf-8");
|
|
172
|
+
const parsed = JSON.parse(text);
|
|
173
|
+
return typeof parsed.worker_id === "string" ? parsed.worker_id : null;
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function releaseLock(lockPath: string): Promise<void> {
|
|
180
|
+
try {
|
|
181
|
+
await unlink(lockPath);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Run `fn` while holding a lock. Releases the lock even if `fn` throws.
|
|
189
|
+
* Throws LockHeldError if the lock can't be acquired immediately.
|
|
190
|
+
*/
|
|
191
|
+
export async function withLock<T>(
|
|
192
|
+
lockPath: string,
|
|
193
|
+
workerId: string,
|
|
194
|
+
fn: () => Promise<T>,
|
|
195
|
+
): Promise<T> {
|
|
196
|
+
await acquireLock(lockPath, workerId);
|
|
197
|
+
try {
|
|
198
|
+
return await fn();
|
|
199
|
+
} finally {
|
|
200
|
+
await releaseLock(lockPath);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Recursively remove a directory if it exists. Used by init --force.
|
|
206
|
+
*/
|
|
207
|
+
export async function rmrf(path: string): Promise<void> {
|
|
208
|
+
await rm(path, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build an absolute path without going through the sandbox helper. Useful
|
|
213
|
+
* for internal (non-user-supplied) paths derived from constants.
|
|
214
|
+
*/
|
|
215
|
+
export function joinSafe(...parts: string[]): string {
|
|
216
|
+
return join(...parts);
|
|
217
|
+
}
|
package/src/fs/compat.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { homedir, platform } from "node:os";
|
|
2
|
+
import { resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type IncompatibleSync =
|
|
5
|
+
| "icloud"
|
|
6
|
+
| "dropbox"
|
|
7
|
+
| "google-drive"
|
|
8
|
+
| "onedrive";
|
|
9
|
+
|
|
10
|
+
export interface FilesystemCompatIssue {
|
|
11
|
+
kind: IncompatibleSync;
|
|
12
|
+
detail: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detect whether `projectDir` lives inside a sync-overlay filesystem where
|
|
17
|
+
* `rename` and `O_EXCL` semantics aren't reliable. These overlays can
|
|
18
|
+
* resurrect deleted files, double-sync writes, and conflict-rename, all of
|
|
19
|
+
* which break our claim/atomic-write model.
|
|
20
|
+
*/
|
|
21
|
+
export function detectIncompatibleFilesystem(
|
|
22
|
+
projectDir: string,
|
|
23
|
+
): FilesystemCompatIssue | null {
|
|
24
|
+
const abs = resolve(projectDir);
|
|
25
|
+
const home = homedir();
|
|
26
|
+
const os = platform();
|
|
27
|
+
|
|
28
|
+
// macOS iCloud Drive
|
|
29
|
+
if (os === "darwin") {
|
|
30
|
+
const icloud = `${home}${sep}Library${sep}Mobile Documents`;
|
|
31
|
+
if (abs.startsWith(icloud + sep) || abs === icloud) {
|
|
32
|
+
return {
|
|
33
|
+
kind: "icloud",
|
|
34
|
+
detail: `path is inside iCloud Drive (${icloud})`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Dropbox (default location, varies; this is best-effort)
|
|
40
|
+
const dropbox = `${home}${sep}Dropbox`;
|
|
41
|
+
if (abs.startsWith(dropbox + sep) || abs === dropbox) {
|
|
42
|
+
return { kind: "dropbox", detail: `path is inside ${dropbox}` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Google Drive (macOS: ~/Library/CloudStorage/GoogleDrive-*; legacy: ~/Google Drive)
|
|
46
|
+
if (os === "darwin") {
|
|
47
|
+
const cloudStorage = `${home}${sep}Library${sep}CloudStorage`;
|
|
48
|
+
if (abs.startsWith(cloudStorage + sep)) {
|
|
49
|
+
return {
|
|
50
|
+
kind: "google-drive",
|
|
51
|
+
detail: `path is inside macOS CloudStorage (${cloudStorage})`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const legacyGdrive = `${home}${sep}Google Drive`;
|
|
56
|
+
if (abs.startsWith(legacyGdrive + sep) || abs === legacyGdrive) {
|
|
57
|
+
return { kind: "google-drive", detail: `path is inside ${legacyGdrive}` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// OneDrive
|
|
61
|
+
const onedrive = `${home}${sep}OneDrive`;
|
|
62
|
+
if (abs.startsWith(onedrive + sep) || abs === onedrive) {
|
|
63
|
+
return { kind: "onedrive", detail: `path is inside ${onedrive}` };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Throw a clear error when running in an incompatible filesystem unless
|
|
71
|
+
* `force` is set. Used by `init` and the worker bootstrap.
|
|
72
|
+
*/
|
|
73
|
+
export function assertCompatibleFilesystem(
|
|
74
|
+
projectDir: string,
|
|
75
|
+
force: boolean,
|
|
76
|
+
): void {
|
|
77
|
+
const issue = detectIncompatibleFilesystem(projectDir);
|
|
78
|
+
if (!issue) return;
|
|
79
|
+
if (force) return;
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Refusing to operate inside ${issue.kind}: ${issue.detail}.\n` +
|
|
82
|
+
`Sync-overlay filesystems can resurrect deleted files and break atomic ` +
|
|
83
|
+
`claim semantics, which Botholomew depends on for tasks and schedules.\n` +
|
|
84
|
+
`Move the project to a regular local directory, or pass --force to override.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, normalize, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
const MAX_PATH_LENGTH = 4096;
|
|
6
|
+
|
|
7
|
+
export class PathEscapeError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
message: string,
|
|
10
|
+
readonly userPath: string,
|
|
11
|
+
readonly resolvedPath?: string,
|
|
12
|
+
) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "PathEscapeError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SandboxOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Restrict the resolved path to a subtree of the root (e.g., "context").
|
|
21
|
+
* The area is appended to the canonical root and used as the containment
|
|
22
|
+
* boundary instead of the root itself.
|
|
23
|
+
*/
|
|
24
|
+
area?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Allow the resolved path to equal the root/area itself (for directory
|
|
27
|
+
* operations like list/tree). Default true.
|
|
28
|
+
*/
|
|
29
|
+
allowRoot?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let cachedCanonicalRoot: string | null = null;
|
|
33
|
+
let cachedRawRoot: string | null = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the project root once at startup so all subsequent containment
|
|
37
|
+
* checks compare against the canonical (symlink-followed) path. Idempotent
|
|
38
|
+
* per root.
|
|
39
|
+
*/
|
|
40
|
+
export function setCanonicalRoot(rawRoot: string): string {
|
|
41
|
+
if (cachedRawRoot === rawRoot && cachedCanonicalRoot) {
|
|
42
|
+
return cachedCanonicalRoot;
|
|
43
|
+
}
|
|
44
|
+
const canonical = realpathSync(rawRoot);
|
|
45
|
+
cachedRawRoot = rawRoot;
|
|
46
|
+
cachedCanonicalRoot = canonical;
|
|
47
|
+
return canonical;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getCanonicalRoot(rawRoot: string): string {
|
|
51
|
+
if (cachedRawRoot === rawRoot && cachedCanonicalRoot) {
|
|
52
|
+
return cachedCanonicalRoot;
|
|
53
|
+
}
|
|
54
|
+
return setCanonicalRoot(rawRoot);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a user-supplied path against the project root with traversal and
|
|
59
|
+
* symlink protection. Always use this for any path that an agent tool may
|
|
60
|
+
* touch — never `path.resolve` directly.
|
|
61
|
+
*
|
|
62
|
+
* Rules:
|
|
63
|
+
* 1. NUL bytes / overlong paths rejected.
|
|
64
|
+
* 2. Input is NFC-normalized so macOS NFD-after-vim doesn't desync the index.
|
|
65
|
+
* 3. After path.resolve, the result must be inside the (canonical) root or
|
|
66
|
+
* `<root>/<area>`.
|
|
67
|
+
* 4. `..` components after normalization are rejected as defense in depth.
|
|
68
|
+
* 5. Every existing path component is `lstat`'d; any symlink is rejected.
|
|
69
|
+
* Hardlinks are out of scope by design.
|
|
70
|
+
*
|
|
71
|
+
* Returns the absolute, canonical path safe to pass to fs APIs.
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveInRoot(
|
|
74
|
+
rawRoot: string,
|
|
75
|
+
userPath: string,
|
|
76
|
+
opts: SandboxOptions = {},
|
|
77
|
+
): Promise<string> {
|
|
78
|
+
validateUserPath(userPath);
|
|
79
|
+
const normalized = userPath.normalize("NFC");
|
|
80
|
+
|
|
81
|
+
const canonicalRoot = getCanonicalRoot(rawRoot);
|
|
82
|
+
const boundary = opts.area
|
|
83
|
+
? resolve(canonicalRoot, opts.area)
|
|
84
|
+
: canonicalRoot;
|
|
85
|
+
|
|
86
|
+
const resolved = resolve(boundary, normalized);
|
|
87
|
+
ensureContainment(resolved, boundary, opts.allowRoot ?? true, userPath);
|
|
88
|
+
|
|
89
|
+
await assertNoSymlinkComponents(resolved, canonicalRoot);
|
|
90
|
+
return resolved;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Synchronous variant for callers that can't use async (rare). Same semantics.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveInRootSync(
|
|
97
|
+
rawRoot: string,
|
|
98
|
+
userPath: string,
|
|
99
|
+
opts: SandboxOptions = {},
|
|
100
|
+
): string {
|
|
101
|
+
validateUserPath(userPath);
|
|
102
|
+
const normalized = userPath.normalize("NFC");
|
|
103
|
+
|
|
104
|
+
const canonicalRoot = getCanonicalRoot(rawRoot);
|
|
105
|
+
const boundary = opts.area
|
|
106
|
+
? resolve(canonicalRoot, opts.area)
|
|
107
|
+
: canonicalRoot;
|
|
108
|
+
|
|
109
|
+
const resolved = resolve(boundary, normalized);
|
|
110
|
+
ensureContainment(resolved, boundary, opts.allowRoot ?? true, userPath);
|
|
111
|
+
|
|
112
|
+
assertNoSymlinkComponentsSync(resolved, canonicalRoot);
|
|
113
|
+
return resolved;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateUserPath(userPath: string): void {
|
|
117
|
+
if (typeof userPath !== "string") {
|
|
118
|
+
throw new PathEscapeError("path must be a string", String(userPath));
|
|
119
|
+
}
|
|
120
|
+
if (userPath.includes("\0")) {
|
|
121
|
+
throw new PathEscapeError("path contains NUL byte", userPath);
|
|
122
|
+
}
|
|
123
|
+
if (userPath.length > MAX_PATH_LENGTH) {
|
|
124
|
+
throw new PathEscapeError(
|
|
125
|
+
`path exceeds maximum length (${MAX_PATH_LENGTH})`,
|
|
126
|
+
userPath,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ensureContainment(
|
|
132
|
+
resolved: string,
|
|
133
|
+
boundary: string,
|
|
134
|
+
allowRoot: boolean,
|
|
135
|
+
userPath: string,
|
|
136
|
+
): void {
|
|
137
|
+
if (resolved === boundary) {
|
|
138
|
+
if (!allowRoot) {
|
|
139
|
+
throw new PathEscapeError(
|
|
140
|
+
"path resolves to the area root",
|
|
141
|
+
userPath,
|
|
142
|
+
resolved,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!resolved.startsWith(boundary + sep)) {
|
|
148
|
+
throw new PathEscapeError(
|
|
149
|
+
`path escapes project root: ${userPath}`,
|
|
150
|
+
userPath,
|
|
151
|
+
resolved,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
// Defense in depth: even a successful prefix check shouldn't pass a `..`
|
|
155
|
+
// segment after normalization. (path.resolve collapses these, but we double
|
|
156
|
+
// check in case the normalize step introduces one.)
|
|
157
|
+
const rel = resolved.slice(boundary.length + 1);
|
|
158
|
+
if (rel.split(sep).some((seg) => seg === ".." || seg === "." || seg === "")) {
|
|
159
|
+
throw new PathEscapeError(
|
|
160
|
+
`path contains forbidden component: ${userPath}`,
|
|
161
|
+
userPath,
|
|
162
|
+
resolved,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function assertNoSymlinkComponents(
|
|
168
|
+
resolved: string,
|
|
169
|
+
canonicalRoot: string,
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
// Walk from canonical root toward the leaf, lstat'ing each existing
|
|
172
|
+
// component. The root itself is canonical (already realpath'd) so we skip
|
|
173
|
+
// it; we only care that nothing the agent writes goes through a symlink.
|
|
174
|
+
const rel = resolved.slice(canonicalRoot.length);
|
|
175
|
+
if (!rel || rel === sep) return;
|
|
176
|
+
const parts = rel.split(sep).filter((p) => p.length > 0);
|
|
177
|
+
let current = canonicalRoot;
|
|
178
|
+
for (const part of parts) {
|
|
179
|
+
current = current + sep + part;
|
|
180
|
+
try {
|
|
181
|
+
const st = await lstat(current);
|
|
182
|
+
if (st.isSymbolicLink()) {
|
|
183
|
+
throw new PathEscapeError(
|
|
184
|
+
`path traverses a symlink: ${current}`,
|
|
185
|
+
resolved,
|
|
186
|
+
current,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
191
|
+
// Component doesn't exist yet — nothing to verify, and the create
|
|
192
|
+
// call that follows will be performed within an already-vetted parent.
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assertNoSymlinkComponentsSync(
|
|
201
|
+
resolved: string,
|
|
202
|
+
canonicalRoot: string,
|
|
203
|
+
): void {
|
|
204
|
+
const rel = resolved.slice(canonicalRoot.length);
|
|
205
|
+
if (!rel || rel === sep) return;
|
|
206
|
+
const parts = rel.split(sep).filter((p) => p.length > 0);
|
|
207
|
+
let current = canonicalRoot;
|
|
208
|
+
for (const part of parts) {
|
|
209
|
+
current = current + sep + part;
|
|
210
|
+
try {
|
|
211
|
+
const st = lstatSync(current);
|
|
212
|
+
if (st.isSymbolicLink()) {
|
|
213
|
+
throw new PathEscapeError(
|
|
214
|
+
`path traverses a symlink: ${current}`,
|
|
215
|
+
resolved,
|
|
216
|
+
current,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Convert an absolute resolved path back to a project-relative path (with
|
|
228
|
+
* forward slashes, suitable for display and storage).
|
|
229
|
+
*/
|
|
230
|
+
export function toRelativePath(
|
|
231
|
+
rawRoot: string,
|
|
232
|
+
absolute: string,
|
|
233
|
+
area?: string,
|
|
234
|
+
): string {
|
|
235
|
+
const canonicalRoot = getCanonicalRoot(rawRoot);
|
|
236
|
+
const boundary = area ? resolve(canonicalRoot, area) : canonicalRoot;
|
|
237
|
+
if (absolute === boundary) return "";
|
|
238
|
+
if (!absolute.startsWith(boundary + sep)) {
|
|
239
|
+
throw new PathEscapeError(
|
|
240
|
+
`path is outside ${area ?? "root"}`,
|
|
241
|
+
absolute,
|
|
242
|
+
absolute,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return absolute
|
|
246
|
+
.slice(boundary.length + 1)
|
|
247
|
+
.split(sep)
|
|
248
|
+
.join("/");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Reject absolute paths and obvious traversal at the API boundary so error
|
|
253
|
+
* messages are clear (vs. relying on the resolver to also catch them).
|
|
254
|
+
*/
|
|
255
|
+
export function assertRelative(userPath: string): void {
|
|
256
|
+
if (typeof userPath !== "string" || userPath.length === 0) {
|
|
257
|
+
throw new PathEscapeError("path is required", String(userPath));
|
|
258
|
+
}
|
|
259
|
+
if (isAbsolute(userPath)) {
|
|
260
|
+
throw new PathEscapeError(
|
|
261
|
+
`path must be project-relative, not absolute: ${userPath}`,
|
|
262
|
+
userPath,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const norm = normalize(userPath);
|
|
266
|
+
if (
|
|
267
|
+
norm === ".." ||
|
|
268
|
+
norm.startsWith(`..${sep}`) ||
|
|
269
|
+
norm.includes(`${sep}..${sep}`)
|
|
270
|
+
) {
|
|
271
|
+
throw new PathEscapeError(`path escapes project: ${userPath}`, userPath);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** For tests: clear cached canonical root so fresh setup() calls resolve fresh. */
|
|
276
|
+
export function _resetSandboxCacheForTests(): void {
|
|
277
|
+
cachedCanonicalRoot = null;
|
|
278
|
+
cachedRawRoot = null;
|
|
279
|
+
}
|