feat(core): serve hosted filesystem reads through workspace environment

This commit is contained in:
Kit Langton
2026-08-05 20:58:39 -04:00
parent e544b8927b
commit 97a5497664
3 changed files with 186 additions and 0 deletions
+66
View File
@@ -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],
})
+1
View File
@@ -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)]],
)
@@ -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<string, string>) => {
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<never, WorkspaceEnvironment.NotFoundError>
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<string, "file" | "directory">()
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")
}),
)
})