From 97a5497664ca32c865e36e6f48098b4cc1382849 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 5 Aug 2026 20:58:39 -0400 Subject: [PATCH] feat(core): serve hosted filesystem reads through workspace environment --- packages/core/src/filesystem.ts | 66 ++++++++++ packages/core/src/location-services.ts | 1 + .../core/test/workspace-filesystem.test.ts | 119 ++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 packages/core/test/workspace-filesystem.test.ts diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index c6a358440b..baf30a1898 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -8,6 +8,7 @@ import { Location } from "./location" import { PositiveInt, RelativePath } from "./schema" import { FileSystemSearch } from "./filesystem/search" import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem" +import { WorkspaceEnvironment } from "./workspace/environment" export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem" export const ReadInput = Schema.Struct({ @@ -115,3 +116,68 @@ export const node = makeLocationNode({ layer: baseLayer, deps: [FSUtil.node, Location.node, FileSystemSearch.node], }) + +const containsPosix = (parent: string, child: string) => { + const relative = path.posix.relative(parent, child) + return relative === "" || (!relative.startsWith("..") && !path.posix.isAbsolute(relative)) +} + +// Mirrors baseLayer over WorkspaceEnvironment.Files with posix path rules. +// Host filesystem services never see provider paths. +const hostedLayer = Layer.effect( + Service, + Effect.gen(function* () { + const env = yield* WorkspaceEnvironment.Service + const location = yield* Location.Service + const root = yield* env.files.realPath(location.directory).pipe(Effect.orDie) + const resolve = Effect.fnUntraced(function* (input?: RelativePath) { + const absolute = path.posix.resolve(location.directory, input ?? ".") + if (!containsPosix(location.directory, absolute)) + return yield* Effect.die(new Error("Path escapes the location")) + const real = yield* env.files.realPath(absolute).pipe(Effect.orDie) + if (!containsPosix(root, real)) return yield* Effect.die(new Error("Path escapes the location")) + return { absolute, real, directory: location.directory, root } + }) + return Service.of({ + find: () => Effect.die(new Error("find is not supported for hosted locations yet")), + read: Effect.fn("FileSystem.read")(function* (input) { + const target = yield* resolve(input.path) + const info = yield* env.files.stat(target.real).pipe(Effect.orDie) + if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) + return { + content: yield* env.files.read(target.real).pipe(Effect.orDie), + mime: FSUtil.mimeType(target.real), + } + }), + list: Effect.fn("FileSystem.list")(function* (input = {}) { + const target = yield* resolve(input.path) + const info = yield* env.files.stat(target.real).pipe(Effect.orDie) + if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) + return yield* env.files.list(target.real).pipe( + Effect.orDie, + Effect.map((items) => + items + .flatMap((item) => { + if (item.type !== "file" && item.type !== "directory") return [] + const absolute = path.posix.join(target.absolute, item.name) + const relative = path.posix.relative(target.directory, absolute) + return [ + Entry.make({ + path: RelativePath.make(relative + (item.type === "directory" ? "/" : "")), + type: item.type, + }), + ] + }) + .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)), + ), + ) + }), + }) + }), +) + +export const hostedNode = makeLocationNode({ + service: Service, + layer: hostedLayer, + deps: [WorkspaceEnvironment.node, Location.node], +}) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 2c9ffe39f5..b49257a059 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -129,6 +129,7 @@ export function buildLocationServiceMap( [Location.node, Location.hostedBoundNode(ref, workspaceID)], [Config.node, Config.configured({ project: false })], [WorkspaceEnvironment.node, WorkspaceEnvironment.hostedNode(workspaceID)], + [FileSystem.node, FileSystem.hostedNode], ] : [[Location.node, Location.boundNode(ref)]], ) diff --git a/packages/core/test/workspace-filesystem.test.ts b/packages/core/test/workspace-filesystem.test.ts new file mode 100644 index 0000000000..94091e0783 --- /dev/null +++ b/packages/core/test/workspace-filesystem.test.ts @@ -0,0 +1,119 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { make } from "effect/unstable/process/ChildProcessSpawner" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" +import { Workspace } from "@opencode-ai/core/workspace" +import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment" +import { testEffect } from "./lib/effect" + +const workspaceID = Workspace.ID.make("wrk_test") +const ROOT = "/workspace" + +/** In-memory environment: file paths to contents, no symlinks. */ +const memoryEnvironment = (files: Record) => { + const encoder = new TextEncoder() + const store = new Map(Object.entries(files).map(([key, value]) => [key, Uint8Array.from(encoder.encode(value))])) + const isDirectory = (path: string) => + path === ROOT || Array.from(store.keys()).some((key) => key.startsWith(path + "/")) + const exists = (path: string) => store.has(path) || isDirectory(path) + const fail = (operation: string, path: string) => + Effect.fail(new WorkspaceEnvironment.NotFoundError({ path })).pipe( + Effect.annotateLogs({ operation }), + ) as Effect.Effect + return WorkspaceEnvironment.make({ + platform: "linux", + directory: ROOT, + files: { + stat: (path) => + store.has(path) + ? Effect.succeed({ type: "File" as const }) + : isDirectory(path) + ? Effect.succeed({ type: "Directory" as const }) + : fail("stat", path), + realPath: (path) => (exists(path) ? Effect.succeed(path) : fail("realPath", path)), + read: (path) => { + const content = store.get(path) + return content ? Effect.succeed(content) : fail("read", path) + }, + list: (path) => { + if (!isDirectory(path)) return fail("list", path) + const names = new Map() + for (const key of store.keys()) { + if (!key.startsWith(path + "/")) continue + const rest = key.slice(path.length + 1) + const [head] = rest.split("/") + if (head) names.set(head, rest.includes("/") ? "directory" : "file") + } + return Effect.succeed(Array.from(names, ([name, type]) => ({ name, type }))) + }, + write: (path, content) => Effect.sync(() => void store.set(path, Uint8Array.from(content))), + }, + process: make(() => Effect.die(new Error("no processes in the memory environment"))), + shell: WorkspaceEnvironment.linuxShell, + }) +} + +const environment = memoryEnvironment({ + "/workspace/README.md": "# hello\n", + "/workspace/src/index.ts": "export {}\n", + "/workspace/src/util/deep.ts": "export const deep = 1\n", +}) + +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of({ + directory: AbsolutePath.make(ROOT), + workspaceID, + project: { + id: Project.ID.global, + directory: AbsolutePath.make(ROOT), + canonical: AbsolutePath.make("/"), + }, + }), +) + +const it = testEffect( + AppNodeBuilder.build(FileSystem.hostedNode, [ + [WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, environment)], + [Location.node, locationLayer], + ]), +) + +describe("hosted FileSystem", () => { + it.effect("reads a file through the environment", () => + Effect.gen(function* () { + const filesystem = yield* FileSystem.Service + const result = yield* filesystem.read({ path: RelativePath.make("README.md") }) + expect(new TextDecoder().decode(result.content)).toBe("# hello\n") + expect(result.mime).toBe("text/markdown") + }), + ) + + it.effect("lists directories before files with posix separators", () => + Effect.gen(function* () { + const filesystem = yield* FileSystem.Service + const entries = yield* filesystem.list({ path: RelativePath.make("src") }) + expect(entries.map((entry) => String(entry.path))).toEqual(["src/util/", "src/index.ts"]) + }), + ) + + it.effect("lists the root when no path is given", () => + Effect.gen(function* () { + const filesystem = yield* FileSystem.Service + const entries = yield* filesystem.list() + expect(entries.map((entry) => String(entry.path))).toEqual(["src/", "README.md"]) + }), + ) + + it.effect("refuses paths that escape the workspace", () => + Effect.gen(function* () { + const filesystem = yield* FileSystem.Service + const exit = yield* filesystem.read({ path: RelativePath.make("../etc/passwd") }).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), + ) +})