Skip to content

Commit 3a39b2d

Browse files
committed
Make bash-tool work in the browser
1 parent a7d44f1 commit 3a39b2d

File tree

7 files changed

+157
-14
lines changed

7 files changed

+157
-14
lines changed

src/files/loader.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
import fs from "node:fs/promises";
2-
import path from "node:path";
3-
import fg from "fast-glob";
4-
51
export interface LoadFilesOptions {
62
files?: Record<string, string>;
73
uploadDirectory?: {
@@ -15,6 +11,41 @@ export interface FileEntry {
1511
content: Buffer;
1612
}
1713

14+
type FastGlobFn = (
15+
source: string | string[],
16+
options?: import("fast-glob").Options,
17+
) => Promise<string[]>;
18+
19+
// Lazy-loaded Node.js dependencies
20+
let cachedDeps: {
21+
fs: typeof import("node:fs/promises");
22+
path: typeof import("node:path");
23+
fg: FastGlobFn;
24+
} | null = null;
25+
26+
async function loadNodeDependencies() {
27+
if (cachedDeps) {
28+
return cachedDeps;
29+
}
30+
31+
try {
32+
const [fs, path, fgModule] = await Promise.all([
33+
import("node:fs/promises"),
34+
import("node:path"),
35+
import("fast-glob"),
36+
]);
37+
// fast-glob uses `export =` so dynamic import wraps it as { default: ... }
38+
const fg = fgModule.default as FastGlobFn;
39+
cachedDeps = { fs, path, fg };
40+
return cachedDeps;
41+
} catch {
42+
throw new Error(
43+
"uploadDirectory requires Node.js. " +
44+
"In browser environments, use the 'files' option to provide file contents directly instead.",
45+
);
46+
}
47+
}
48+
1849
/**
1950
* Stream files from inline definitions and/or a directory on disk.
2051
* Yields files one at a time to avoid loading everything into memory.
@@ -35,6 +66,7 @@ export async function* streamFiles(
3566

3667
// Stream from directory (skip files already yielded from inline)
3768
if (options.uploadDirectory) {
69+
const { fs, path, fg } = await loadNodeDependencies();
3870
const { source, include = "**/*" } = options.uploadDirectory;
3971
const absoluteSource = path.resolve(source);
4072

@@ -66,6 +98,7 @@ export async function getFilePaths(
6698
const paths: string[] = [];
6799

68100
if (options.uploadDirectory) {
101+
const { path, fg } = await loadNodeDependencies();
69102
const { source, include = "**/*" } = options.uploadDirectory;
70103
const absoluteSource = path.resolve(source);
71104

src/posix-path.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, it } from "vitest";
2+
import { posixJoin, posixResolve } from "./posix-path.js";
3+
4+
describe("posixJoin", () => {
5+
it("joins simple segments", () => {
6+
expect(posixJoin("a", "b", "c")).toBe("a/b/c");
7+
});
8+
9+
it("handles leading slash", () => {
10+
expect(posixJoin("/workspace", "src", "file.ts")).toBe(
11+
"/workspace/src/file.ts",
12+
);
13+
});
14+
15+
it("normalizes double slashes", () => {
16+
expect(posixJoin("/workspace/", "/src")).toBe("/workspace/src");
17+
});
18+
19+
it("resolves . segments", () => {
20+
expect(posixJoin("a", ".", "b")).toBe("a/b");
21+
});
22+
23+
it("resolves .. segments", () => {
24+
expect(posixJoin("a", "b", "..", "c")).toBe("a/c");
25+
});
26+
27+
it("handles empty segments", () => {
28+
expect(posixJoin("a", "", "b")).toBe("a/b");
29+
});
30+
});
31+
32+
describe("posixResolve", () => {
33+
it("resolves relative path against base", () => {
34+
expect(posixResolve("/workspace", "src/file.ts")).toBe(
35+
"/workspace/src/file.ts",
36+
);
37+
});
38+
39+
it("returns absolute path as-is (normalized)", () => {
40+
expect(posixResolve("/workspace", "/other/file.ts")).toBe("/other/file.ts");
41+
});
42+
43+
it("handles .. in relative path", () => {
44+
expect(posixResolve("/workspace/src", "../file.ts")).toBe(
45+
"/workspace/file.ts",
46+
);
47+
});
48+
49+
it("handles . in relative path", () => {
50+
expect(posixResolve("/workspace", "./file.ts")).toBe("/workspace/file.ts");
51+
});
52+
53+
it("normalizes the result", () => {
54+
expect(posixResolve("/workspace//", "src//file.ts")).toBe(
55+
"/workspace/src/file.ts",
56+
);
57+
});
58+
});

src/posix-path.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Browser-compatible POSIX path utilities.
3+
* These functions handle forward-slash paths without Node.js dependencies.
4+
*/
5+
6+
/**
7+
* Join path segments with forward slashes.
8+
* Normalizes the result to remove redundant slashes and resolve . and ..
9+
*/
10+
export function posixJoin(...segments: string[]): string {
11+
const joined = segments.filter(Boolean).join("/");
12+
return normalizePosixPath(joined);
13+
}
14+
15+
/**
16+
* Resolve a path against a base directory.
17+
* If the path is absolute (starts with /), return it normalized.
18+
* Otherwise, join it with the base and normalize.
19+
*/
20+
export function posixResolve(base: string, relativePath: string): string {
21+
if (relativePath.startsWith("/")) {
22+
return normalizePosixPath(relativePath);
23+
}
24+
return normalizePosixPath(`${base}/${relativePath}`);
25+
}
26+
27+
/**
28+
* Normalize a POSIX path by resolving . and .. segments and removing duplicate slashes.
29+
*/
30+
function normalizePosixPath(p: string): string {
31+
const isAbsolute = p.startsWith("/");
32+
const segments = p.split("/").filter((s) => s !== "" && s !== ".");
33+
const result: string[] = [];
34+
35+
for (const segment of segments) {
36+
if (segment === "..") {
37+
if (result.length > 0 && result[result.length - 1] !== "..") {
38+
result.pop();
39+
} else if (!isAbsolute) {
40+
result.push("..");
41+
}
42+
} else {
43+
result.push(segment);
44+
}
45+
}
46+
47+
const normalized = result.join("/");
48+
return isAbsolute ? `/${normalized}` : normalized || ".";
49+
}

src/sandbox/just-bash.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,13 @@ export async function createJustBashSandbox(
4545
// Dynamic import to handle optional peer dependency
4646
let Bash: typeof import("just-bash").Bash;
4747
let OverlayFs: typeof import("just-bash").OverlayFs | undefined;
48+
let nodePath: typeof import("node:path");
4849

4950
try {
5051
const module = await import("just-bash");
5152
Bash = module.Bash;
5253
OverlayFs = module.OverlayFs;
54+
nodePath = await import("node:path");
5355
} catch {
5456
throw new Error(
5557
'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(
6163

6264
if (options.overlayRoot && OverlayFs) {
6365
// Use OverlayFs for copy-on-write over a real directory
64-
const overlay = new OverlayFs({ root: options.overlayRoot });
66+
// Resolve to absolute path for OverlayFs
67+
const absoluteRoot = nodePath.resolve(options.overlayRoot);
68+
const overlay = new OverlayFs({ root: absoluteRoot });
6569
mountPoint = overlay.getMountPoint();
6670
bashEnv = new Bash({
6771
fs: overlay,

src/tool.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import path from "node:path";
21
import { getFilePaths, streamFiles } from "./files/loader.js";
2+
import { posixJoin } from "./posix-path.js";
33
import {
44
createJustBashSandbox,
55
isJustBash,
@@ -99,7 +99,7 @@ export async function createBashTool(
9999
uploadDirectory: options.uploadDirectory,
100100
})) {
101101
batch.push({
102-
path: path.posix.join(destination, file.path),
102+
path: posixJoin(destination, file.path),
103103
content: file.content,
104104
});
105105

@@ -120,9 +120,8 @@ export async function createBashTool(
120120

121121
if (options.uploadDirectory && !options.files) {
122122
// Use OverlayFs for uploadDirectory (avoids loading all files into memory)
123-
const overlayRoot = path.resolve(options.uploadDirectory.source);
124123
const result = await createJustBashSandbox({
125-
overlayRoot,
124+
overlayRoot: options.uploadDirectory.source,
126125
});
127126
sandbox = result;
128127

@@ -152,7 +151,7 @@ export async function createBashTool(
152151
files: options.files,
153152
uploadDirectory: options.uploadDirectory,
154153
})) {
155-
const absolutePath = path.posix.join(destination, file.path);
154+
const absolutePath = posixJoin(destination, file.path);
156155
filesWithDestination[absolutePath] = file.content.toString("utf-8");
157156
}
158157

src/tools/read-file.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import nodePath from "node:path";
21
import { tool } from "ai";
32
import { z } from "zod";
3+
import { posixResolve } from "../posix-path.js";
44
import type { Sandbox } from "../types.js";
55

66
const readFileSchema = z.object({
@@ -20,7 +20,7 @@ export function createReadFileTool(options: CreateReadFileToolOptions) {
2020
description: "Read the contents of a file from the sandbox.",
2121
inputSchema: readFileSchema,
2222
execute: async ({ path }) => {
23-
const resolvedPath = nodePath.posix.resolve(cwd, path);
23+
const resolvedPath = posixResolve(cwd, path);
2424
const content = await sandbox.readFile(resolvedPath);
2525
return { content };
2626
},

src/tools/write-file.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import nodePath from "node:path";
21
import { tool } from "ai";
32
import { z } from "zod";
3+
import { posixResolve } from "../posix-path.js";
44
import type { Sandbox } from "../types.js";
55

66
const writeFileSchema = z.object({
@@ -22,7 +22,7 @@ export function createWriteFileTool(options: CreateWriteFileToolOptions) {
2222
"Write content to a file in the sandbox. Creates parent directories if needed.",
2323
inputSchema: writeFileSchema,
2424
execute: async ({ path, content }) => {
25-
const resolvedPath = nodePath.posix.resolve(cwd, path);
25+
const resolvedPath = posixResolve(cwd, path);
2626
await sandbox.writeFiles([{ path: resolvedPath, content }]);
2727
return { success: true };
2828
},

0 commit comments

Comments
 (0)