Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions src/files/loader.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
uploadDirectory?: {
Expand All @@ -15,6 +11,41 @@ export interface FileEntry {
content: Buffer;
}

type FastGlobFn = (
source: string | string[],
options?: import("fast-glob").Options,
) => Promise<string[]>;

// 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.
Expand All @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
58 changes: 58 additions & 0 deletions src/posix-path.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
49 changes: 49 additions & 0 deletions src/posix-path.ts
Original file line number Diff line number Diff line change
@@ -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 || ".";
}
6 changes: 5 additions & 1 deletion src/sandbox/just-bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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,
Expand Down
9 changes: 4 additions & 5 deletions src/tool.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
});

Expand All @@ -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;

Expand Down Expand Up @@ -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");
}

Expand Down
4 changes: 2 additions & 2 deletions src/tools/read-file.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 };
},
Expand Down
4 changes: 2 additions & 2 deletions src/tools/write-file.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 };
},
Expand Down
Loading