botholomew 0.12.5 → 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.
Files changed (103) hide show
  1. package/README.md +91 -68
  2. package/package.json +2 -2
  3. package/src/chat/agent.ts +42 -82
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +177 -926
  7. package/src/commands/db.ts +9 -13
  8. package/src/commands/init.ts +4 -1
  9. package/src/commands/nuke.ts +57 -90
  10. package/src/commands/schedule.ts +103 -124
  11. package/src/commands/skill.ts +2 -2
  12. package/src/commands/task.ts +86 -95
  13. package/src/commands/thread.ts +107 -112
  14. package/src/commands/worker.ts +88 -88
  15. package/src/constants.ts +93 -16
  16. package/src/context/capabilities.ts +10 -10
  17. package/src/context/fetcher.ts +9 -10
  18. package/src/context/reindex.ts +189 -0
  19. package/src/context/store.ts +630 -0
  20. package/src/db/doctor.ts +1 -8
  21. package/src/db/embeddings.ts +227 -175
  22. package/src/db/sql/19-disk_backed_index.sql +36 -0
  23. package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
  24. package/src/fs/atomic.ts +217 -0
  25. package/src/fs/compat.ts +86 -0
  26. package/src/fs/sandbox.ts +279 -0
  27. package/src/init/index.ts +69 -52
  28. package/src/init/templates.ts +1 -1
  29. package/src/mcpx/client.ts +1 -1
  30. package/src/schedules/schema.ts +19 -0
  31. package/src/schedules/store.ts +296 -0
  32. package/src/skills/commands.ts +1 -3
  33. package/src/tasks/schema.ts +47 -0
  34. package/src/tasks/store.ts +486 -0
  35. package/src/threads/store.ts +559 -0
  36. package/src/tools/capabilities/refresh.ts +42 -21
  37. package/src/tools/context/pipe.ts +15 -71
  38. package/src/tools/context/update-beliefs.ts +3 -3
  39. package/src/tools/context/update-goals.ts +3 -3
  40. package/src/tools/dir/create.ts +26 -23
  41. package/src/tools/dir/size.ts +46 -17
  42. package/src/tools/dir/tree.ts +73 -279
  43. package/src/tools/file/copy.ts +50 -24
  44. package/src/tools/file/count-lines.ts +34 -10
  45. package/src/tools/file/delete.ts +44 -23
  46. package/src/tools/file/edit.ts +39 -14
  47. package/src/tools/file/exists.ts +12 -26
  48. package/src/tools/file/info.ts +25 -85
  49. package/src/tools/file/move.ts +39 -24
  50. package/src/tools/file/read.ts +32 -80
  51. package/src/tools/file/write.ts +14 -91
  52. package/src/tools/registry.ts +3 -7
  53. package/src/tools/schedule/create.ts +2 -2
  54. package/src/tools/schedule/list.ts +7 -3
  55. package/src/tools/search/fuse.ts +12 -33
  56. package/src/tools/search/index.ts +36 -43
  57. package/src/tools/search/regexp.ts +29 -17
  58. package/src/tools/search/semantic.ts +137 -51
  59. package/src/tools/skill/delete.ts +1 -1
  60. package/src/tools/skill/list.ts +1 -1
  61. package/src/tools/skill/write.ts +1 -1
  62. package/src/tools/task/create.ts +41 -16
  63. package/src/tools/task/delete.ts +3 -3
  64. package/src/tools/task/list.ts +6 -3
  65. package/src/tools/task/update.ts +31 -9
  66. package/src/tools/task/view.ts +6 -6
  67. package/src/tools/thread/list.ts +2 -2
  68. package/src/tools/thread/search.ts +208 -0
  69. package/src/tools/thread/view.ts +50 -5
  70. package/src/tools/worker/spawn.ts +28 -14
  71. package/src/tui/App.tsx +12 -19
  72. package/src/tui/components/ContextPanel.tsx +83 -316
  73. package/src/tui/components/SchedulePanel.tsx +34 -48
  74. package/src/tui/components/StatusBar.tsx +15 -15
  75. package/src/tui/components/TaskPanel.tsx +34 -38
  76. package/src/tui/components/ThreadPanel.tsx +29 -38
  77. package/src/tui/components/WorkerPanel.tsx +21 -19
  78. package/src/tui/markdown.ts +2 -8
  79. package/src/utils/title.ts +5 -7
  80. package/src/utils/v7-date.ts +47 -0
  81. package/src/worker/heartbeat.ts +46 -24
  82. package/src/worker/index.ts +13 -15
  83. package/src/worker/llm.ts +30 -37
  84. package/src/worker/prompt.ts +19 -41
  85. package/src/worker/schedules.ts +48 -69
  86. package/src/worker/spawn.ts +11 -11
  87. package/src/worker/tick.ts +39 -43
  88. package/src/workers/store.ts +247 -0
  89. package/src/commands/tools.ts +0 -367
  90. package/src/context/describer.ts +0 -140
  91. package/src/context/drives.ts +0 -110
  92. package/src/context/ingest.ts +0 -162
  93. package/src/context/refresh.ts +0 -183
  94. package/src/db/context.ts +0 -637
  95. package/src/db/daemon-state.ts +0 -6
  96. package/src/db/reembed.ts +0 -113
  97. package/src/db/schedules.ts +0 -213
  98. package/src/db/tasks.ts +0 -347
  99. package/src/db/threads.ts +0 -276
  100. package/src/db/workers.ts +0 -212
  101. package/src/tools/context/list-drives.ts +0 -36
  102. package/src/tools/context/refresh.ts +0 -165
  103. package/src/tools/context/search.ts +0 -54
@@ -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
+ }
@@ -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
+ }