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
5 changes: 5 additions & 0 deletions .changeset/computer-bounded-filesystem.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

Expose bounded workspace byte reads through RPC and return paginated directory listings with file metadata.
10 changes: 10 additions & 0 deletions packages/computer/src/stub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,16 @@ describe("WorkspaceStub", () => {
});
});

it("fs.readFile forwards ranged stream options", async () => {
await withStub(async (ws) => {
const stub = ws.stub();
await stub.fs.writeFile("/bin", new Uint8Array([1, 2, 3, 4, 5]));
const stream = await stub.fs.readFile("/bin", { byteOffset: 1, byteLength: 3 });
const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
expect(Array.from(bytes)).toEqual([2, 3, 4]);
});
});

it("fs.readdir forwards bounded-read options", async () => {
await withStub(async (ws) => {
const stub = ws.stub();
Expand Down
5 changes: 5 additions & 0 deletions packages/computer/src/stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ export class WorkspaceFilesystemStub extends RpcTarget {

readFile(path: string): Promise<ReadableStream<Uint8Array>>;
readFile(path: string, encoding: "utf8"): Promise<string>;
readFile(
path: string,
options: ReadFileOptions & { encoding?: undefined },
): Promise<ReadableStream<Uint8Array>>;
readFile(path: string, options: ReadFileOptions & { encoding: "utf8" }): Promise<string>;
readFile(path: string, options: ReadFileOptions): Promise<string | ReadableStream<Uint8Array>>;
readFile(
path: string,
Expand Down
144 changes: 128 additions & 16 deletions packages/computer/src/tools/ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,28 +179,109 @@ function memoryStore(options: {
}

describe("WorkspaceFileStore", () => {
it("slices byte ranges while reading chunks from Workspace.fs", async () => {
const workspace = makeWorkspace();
await workspace.fs.mkdir("/workspace", { recursive: true });
await workspace.fs.writeFile("/workspace/range.txt", bytes("abcdefghij"));
it("opens one ranged stream instead of issuing repeated range calls", async () => {
const calls: Array<{ byteOffset?: number; byteLength?: number }> = [];
const content = bytes("abcdefghij");
const workspace = {
fs: {
async stat() {
throw new Error("stat must not be called by readChunks");
},
async readRange() {
throw new Error("readRange must not be called by readChunks");
},
async readFile(
_path: string,
options: { byteOffset?: number; byteLength?: number } = {},
): Promise<ReadableStream<Uint8Array>> {
calls.push(options);
const start = options.byteOffset ?? 0;
const end = options.byteLength === undefined ? undefined : start + options.byteLength;
return new ReadableStream({
start(controller) {
controller.enqueue(content.slice(start, end));
controller.close();
},
});
},
async writeFile() {},
async mkdir() {},
async rm() {},
},
};
const store = new WorkspaceFileStore(workspace);

await expect(
drainChunks(store.readChunks("/workspace/range.txt", 2, 5)).then(decode),
).resolves.toBe("cdefg");
expect(calls).toEqual([{ byteOffset: 2, byteLength: 5 }]);
});

it("still validates the path for a zero-length read", async () => {
const store = new WorkspaceFileStore(makeWorkspace());

await expect(drainChunks(store.readChunks("/missing", 0, 0))).rejects.toMatchObject({
code: "ENOENT",
});
});

it("rejects directories instead of treating them as empty files", async () => {
const workspace = makeWorkspace();
await workspace.fs.mkdir("/directory");
const store = new WorkspaceFileStore(workspace);

await expect(drainChunks(store.readChunks("/directory"))).rejects.toMatchObject({
code: "EISDIR",
});
});

it("keeps a real multi-chunk workspace read on one snapshot", async () => {
const workspace = makeWorkspace();
await workspace.fs.mkdir("/workspace", { recursive: true });
const original = new Uint8Array(600_000);
original.fill(0x41, 0, 500_000);
original.fill(0x42, 500_000);
await workspace.fs.writeFile("/workspace/large.bin", original);
const store = new WorkspaceFileStore(workspace);

const chunks = store.readChunks("/workspace/large.bin")[Symbol.asyncIterator]();
const first = await chunks.next();
expect(first.done).toBe(false);
await workspace.fs.writeFile(
"/workspace/large.bin",
new Uint8Array(original.length).fill(0x43),
);

const parts = [first.value];
while (true) {
const next = await chunks.next();
if (next.done) break;
parts.push(next.value);
}
const result = await drainChunks(
(async function* () {
yield* parts;
})(),
);
expect(result.byteLength).toBe(original.byteLength);
expect(result.every((value, index) => value === original[index])).toBe(true);
});

it("cancels read streams when a byte range stops before EOF", async () => {
it("cancels a ranged stream when its consumer stops early", async () => {
let cancelled = false;
const workspace = {
fs: {
async stat() {
return { size: 10, mtime: 1, mode: 0o100644, isFile: true, isDirectory: false };
throw new Error("stat must not be called by readChunks");
},
async readRange() {
throw new Error("readRange must not be called by readChunks");
},
async readFile() {
return new ReadableStream<Uint8Array>({
async readFile(): Promise<ReadableStream<Uint8Array>> {
return new ReadableStream({
start(controller) {
controller.enqueue(bytes("abcdefghij"));
controller.enqueue(bytes("first"));
controller.enqueue(bytes("second"));
},
cancel() {
cancelled = true;
Expand All @@ -210,16 +291,11 @@ describe("WorkspaceFileStore", () => {
async writeFile() {},
async mkdir() {},
async rm() {},
async readdir() {
return [];
},
},
};
const store = new WorkspaceFileStore(workspace);

await expect(
drainChunks(store.readChunks("/workspace/range.txt", 2, 5)).then(decode),
).resolves.toBe("cdefg");
for await (const _chunk of store.readChunks("/workspace/range.txt")) break;
expect(cancelled).toBe(true);
});
});
Expand Down Expand Up @@ -255,7 +331,17 @@ describe("createAITools filesystem tools", () => {
);
await expect(executeTool(tools.ls, { path: "/workspace/notes" })).resolves.toEqual({
path: "/workspace/notes",
entries: [{ name: "todo.txt", isFile: true, isDirectory: false }],
count: 1,
entries: [
{
name: "todo.txt",
size: 8,
mtime: 1_700_000_000_000,
isFile: true,
isDirectory: false,
isSymbolicLink: false,
},
],
});
await expect(
executeTool(tools.read, { path: "/workspace/notes/todo.txt", limit: 1 }),
Expand All @@ -279,6 +365,32 @@ describe("createAITools filesystem tools", () => {
);
});

it("paginates ls results and reports a continuation offset", async () => {
const workspace = makeWorkspace();
await workspace.fs.mkdir("/workspace", { recursive: true });
for (const name of ["a", "b", "c"]) {
await workspace.fs.writeFile(`/workspace/${name}`, name);
}
const tools = createAITools({ workspace });

await expect(
executeTool(tools.ls, { path: "/workspace", limit: 2, offset: 0 }),
).resolves.toMatchObject({
count: 2,
entries: [
{ name: "a", size: 1 },
{ name: "b", size: 1 },
],
nextOffset: 2,
});
await expect(
executeTool(tools.ls, { path: "/workspace", limit: 2, offset: 2 }),
).resolves.toMatchObject({
count: 1,
entries: [{ name: "c", size: 1 }],
});
});

it("preserves file mode when write overwrites an existing file", async () => {
const writes: Array<{ path: string; content: string; mode?: number }> = [];
const tool = createWriteTool({
Expand Down
61 changes: 51 additions & 10 deletions packages/computer/src/tools/fs/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,75 @@ import { z } from "zod";

export interface ListWorkspaceLike {
fs: {
readdir(path: string): Promise<Array<{ name: string; isFile: boolean; isDirectory: boolean }>>;
readdir(
path: string,
options?: { limit?: number; offset?: number },
): Promise<
Array<{
name: string;
size: number;
mtime: number;
isFile: boolean;
isDirectory: boolean;
isSymbolicLink: boolean;
}>
>;
};
}

export interface ListToolOptions {
workspace: ListWorkspaceLike;
}

const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 1000;

const inputSchema = z.object({
path: z.string().describe("Absolute directory path to list, e.g. /workspace/src."),
limit: z
.number()
.int()
.min(1)
.max(MAX_LIMIT)
.optional()
.describe(`Maximum entries to return. Defaults to ${DEFAULT_LIMIT}.`),
offset: z.number().int().min(0).optional().describe("Number of entries to skip in name order."),
});

export function createListTool(options: ListToolOptions): Tool<z.infer<typeof inputSchema>> {
return tool({
description:
"List entries in a workspace directory. Returns each entry name and whether it is a file or directory.",
"List entries in a workspace directory with file sizes and modification times. Use limit and offset to page through large directories.",
inputSchema,
execute: async ({ path }) => {
execute: async ({ path, limit, offset }) => {
try {
const entries = await options.workspace.fs.readdir(path);
return {
const pageSize = limit ?? DEFAULT_LIMIT;
const pageOffset = offset ?? 0;
const entries = await options.workspace.fs.readdir(path, {
limit: pageSize + 1,
offset: pageOffset,
});
const truncated = entries.length > pageSize;
const page = (truncated ? entries.slice(0, pageSize) : entries).map((entry) => ({
name: entry.name,
size: entry.size,
mtime: entry.mtime,
isFile: entry.isFile,
isDirectory: entry.isDirectory,
isSymbolicLink: entry.isSymbolicLink,
}));
const result: {
path: string;
count: number;
entries: typeof page;
nextOffset?: number;
} = {
path,
entries: entries.map((entry) => ({
name: entry.name,
isFile: entry.isFile,
isDirectory: entry.isDirectory,
})),
count: page.length,
entries: page,
};
if (truncated) result.nextOffset = pageOffset + pageSize;
return result;
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
Expand Down
Loading
Loading