diff --git a/src/files/loader.ts b/src/files/loader.ts index 791c19d..c668f96 100644 --- a/src/files/loader.ts +++ b/src/files/loader.ts @@ -1,7 +1,3 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import fg from "fast-glob"; - export interface LoadFilesOptions { files?: Record; uploadDirectory?: { @@ -15,6 +11,41 @@ export interface FileEntry { content: Buffer; } +type FastGlobFn = ( + source: string | string[], + options?: import("fast-glob").Options, +) => Promise; + +// Lazy-loaded Node.js dependencies +let cachedDeps: { + fs: typeof import("node:fs/promises"); + path: typeof import("node:path"); + fg: FastGlobFn; +} | null = null; + +async function loadNodeDependencies() { + if (cachedDeps) { + return cachedDeps; + } + + try { + const [fs, path, fgModule] = await Promise.all([ + import("node:fs/promises"), + import("node:path"), + import("fast-glob"), + ]); + // fast-glob uses `export =` so dynamic import wraps it as { default: ... } + const fg = fgModule.default as FastGlobFn; + cachedDeps = { fs, path, fg }; + return cachedDeps; + } catch { + throw new Error( + "uploadDirectory requires Node.js. " + + "In browser environments, use the 'files' option to provide file contents directly instead.", + ); + } +} + /** * Stream files from inline definitions and/or a directory on disk. * Yields files one at a time to avoid loading everything into memory. @@ -35,6 +66,7 @@ export async function* streamFiles( // Stream from directory (skip files already yielded from inline) if (options.uploadDirectory) { + const { fs, path, fg } = await loadNodeDependencies(); const { source, include = "**/*" } = options.uploadDirectory; const absoluteSource = path.resolve(source); @@ -66,6 +98,7 @@ export async function getFilePaths( const paths: string[] = []; if (options.uploadDirectory) { + const { path, fg } = await loadNodeDependencies(); const { source, include = "**/*" } = options.uploadDirectory; const absoluteSource = path.resolve(source); diff --git a/src/posix-path.test.ts b/src/posix-path.test.ts new file mode 100644 index 0000000..e586937 --- /dev/null +++ b/src/posix-path.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { posixJoin, posixResolve } from "./posix-path.js"; + +describe("posixJoin", () => { + it("joins simple segments", () => { + expect(posixJoin("a", "b", "c")).toBe("a/b/c"); + }); + + it("handles leading slash", () => { + expect(posixJoin("/workspace", "src", "file.ts")).toBe( + "/workspace/src/file.ts", + ); + }); + + it("normalizes double slashes", () => { + expect(posixJoin("/workspace/", "/src")).toBe("/workspace/src"); + }); + + it("resolves . segments", () => { + expect(posixJoin("a", ".", "b")).toBe("a/b"); + }); + + it("resolves .. segments", () => { + expect(posixJoin("a", "b", "..", "c")).toBe("a/c"); + }); + + it("handles empty segments", () => { + expect(posixJoin("a", "", "b")).toBe("a/b"); + }); +}); + +describe("posixResolve", () => { + it("resolves relative path against base", () => { + expect(posixResolve("/workspace", "src/file.ts")).toBe( + "/workspace/src/file.ts", + ); + }); + + it("returns absolute path as-is (normalized)", () => { + expect(posixResolve("/workspace", "/other/file.ts")).toBe("/other/file.ts"); + }); + + it("handles .. in relative path", () => { + expect(posixResolve("/workspace/src", "../file.ts")).toBe( + "/workspace/file.ts", + ); + }); + + it("handles . in relative path", () => { + expect(posixResolve("/workspace", "./file.ts")).toBe("/workspace/file.ts"); + }); + + it("normalizes the result", () => { + expect(posixResolve("/workspace//", "src//file.ts")).toBe( + "/workspace/src/file.ts", + ); + }); +}); diff --git a/src/posix-path.ts b/src/posix-path.ts new file mode 100644 index 0000000..9e26817 --- /dev/null +++ b/src/posix-path.ts @@ -0,0 +1,49 @@ +/** + * Browser-compatible POSIX path utilities. + * These functions handle forward-slash paths without Node.js dependencies. + */ + +/** + * Join path segments with forward slashes. + * Normalizes the result to remove redundant slashes and resolve . and .. + */ +export function posixJoin(...segments: string[]): string { + const joined = segments.filter(Boolean).join("/"); + return normalizePosixPath(joined); +} + +/** + * Resolve a path against a base directory. + * If the path is absolute (starts with /), return it normalized. + * Otherwise, join it with the base and normalize. + */ +export function posixResolve(base: string, relativePath: string): string { + if (relativePath.startsWith("/")) { + return normalizePosixPath(relativePath); + } + return normalizePosixPath(`${base}/${relativePath}`); +} + +/** + * Normalize a POSIX path by resolving . and .. segments and removing duplicate slashes. + */ +function normalizePosixPath(p: string): string { + const isAbsolute = p.startsWith("/"); + const segments = p.split("/").filter((s) => s !== "" && s !== "."); + const result: string[] = []; + + for (const segment of segments) { + if (segment === "..") { + if (result.length > 0 && result[result.length - 1] !== "..") { + result.pop(); + } else if (!isAbsolute) { + result.push(".."); + } + } else { + result.push(segment); + } + } + + const normalized = result.join("/"); + return isAbsolute ? `/${normalized}` : normalized || "."; +} diff --git a/src/sandbox/just-bash.ts b/src/sandbox/just-bash.ts index 40f2d12..46b8223 100644 --- a/src/sandbox/just-bash.ts +++ b/src/sandbox/just-bash.ts @@ -45,11 +45,13 @@ export async function createJustBashSandbox( // Dynamic import to handle optional peer dependency let Bash: typeof import("just-bash").Bash; let OverlayFs: typeof import("just-bash").OverlayFs | undefined; + let nodePath: typeof import("node:path"); try { const module = await import("just-bash"); Bash = module.Bash; OverlayFs = module.OverlayFs; + nodePath = await import("node:path"); } catch { throw new Error( 'just-bash is not installed. Either install it with "npm install just-bash" or provide your own sandbox via the sandbox option.', @@ -61,7 +63,9 @@ export async function createJustBashSandbox( if (options.overlayRoot && OverlayFs) { // Use OverlayFs for copy-on-write over a real directory - const overlay = new OverlayFs({ root: options.overlayRoot }); + // Resolve to absolute path for OverlayFs + const absoluteRoot = nodePath.resolve(options.overlayRoot); + const overlay = new OverlayFs({ root: absoluteRoot }); mountPoint = overlay.getMountPoint(); bashEnv = new Bash({ fs: overlay, diff --git a/src/tool.ts b/src/tool.ts index 655e485..ccbbcba 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -1,5 +1,5 @@ -import path from "node:path"; import { getFilePaths, streamFiles } from "./files/loader.js"; +import { posixJoin } from "./posix-path.js"; import { createJustBashSandbox, isJustBash, @@ -99,7 +99,7 @@ export async function createBashTool( uploadDirectory: options.uploadDirectory, })) { batch.push({ - path: path.posix.join(destination, file.path), + path: posixJoin(destination, file.path), content: file.content, }); @@ -120,9 +120,8 @@ export async function createBashTool( if (options.uploadDirectory && !options.files) { // Use OverlayFs for uploadDirectory (avoids loading all files into memory) - const overlayRoot = path.resolve(options.uploadDirectory.source); const result = await createJustBashSandbox({ - overlayRoot, + overlayRoot: options.uploadDirectory.source, }); sandbox = result; @@ -152,7 +151,7 @@ export async function createBashTool( files: options.files, uploadDirectory: options.uploadDirectory, })) { - const absolutePath = path.posix.join(destination, file.path); + const absolutePath = posixJoin(destination, file.path); filesWithDestination[absolutePath] = file.content.toString("utf-8"); } diff --git a/src/tools/read-file.ts b/src/tools/read-file.ts index 63f47b9..1933eec 100644 --- a/src/tools/read-file.ts +++ b/src/tools/read-file.ts @@ -1,6 +1,6 @@ -import nodePath from "node:path"; import { tool } from "ai"; import { z } from "zod"; +import { posixResolve } from "../posix-path.js"; import type { Sandbox } from "../types.js"; const readFileSchema = z.object({ @@ -20,7 +20,7 @@ export function createReadFileTool(options: CreateReadFileToolOptions) { description: "Read the contents of a file from the sandbox.", inputSchema: readFileSchema, execute: async ({ path }) => { - const resolvedPath = nodePath.posix.resolve(cwd, path); + const resolvedPath = posixResolve(cwd, path); const content = await sandbox.readFile(resolvedPath); return { content }; }, diff --git a/src/tools/write-file.ts b/src/tools/write-file.ts index 6a31363..12a9856 100644 --- a/src/tools/write-file.ts +++ b/src/tools/write-file.ts @@ -1,6 +1,6 @@ -import nodePath from "node:path"; import { tool } from "ai"; import { z } from "zod"; +import { posixResolve } from "../posix-path.js"; import type { Sandbox } from "../types.js"; const writeFileSchema = z.object({ @@ -22,7 +22,7 @@ export function createWriteFileTool(options: CreateWriteFileToolOptions) { "Write content to a file in the sandbox. Creates parent directories if needed.", inputSchema: writeFileSchema, execute: async ({ path, content }) => { - const resolvedPath = nodePath.posix.resolve(cwd, path); + const resolvedPath = posixResolve(cwd, path); await sandbox.writeFiles([{ path: resolvedPath, content }]); return { success: true }; },