feat(core): route hosted mutation resolution and writes through workspace environment

This commit is contained in:
Kit Langton
2026-08-05 21:03:24 -04:00
parent 97a5497664
commit 14e7b547c5
6 changed files with 291 additions and 49 deletions
+64
View File
@@ -5,6 +5,7 @@ import { Context, Effect, Layer } from "effect"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
import { WorkspaceEnvironment } from "./workspace/environment"
export interface Target {
readonly canonical: string
@@ -90,6 +91,69 @@ const layer = Layer.effect(
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
// Same cooperative locking, writes through WorkspaceEnvironment.Files.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
const encoder = new TextEncoder()
const locks = KeyedMutex.makeUnsafe<string>()
const withTargetLock =
(target: Target) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
const writeResult = (target: Target, existed: boolean): WriteResult => ({
operation: "write",
target: target.canonical,
resource: target.resource,
existed,
})
const exists = (path: string) =>
env.files.stat(path).pipe(
Effect.as(true),
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(false)),
Effect.orDie,
)
const bytes = (content: string | Uint8Array) => (typeof content === "string" ? encoder.encode(content) : content)
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const existed = yield* exists(input.target.canonical)
yield* env.files.write(input.target.canonical, bytes(input.content)).pipe(Effect.orDie)
return writeResult(input.target, existed)
}),
),
)
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const next = Bom.split(input.content)
const current = yield* env.files.read(input.target.canonical).pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
Effect.orDie,
)
const text = Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)
yield* env.files.write(input.target.canonical, bytes(text)).pipe(Effect.orDie)
return writeResult(input.target, current !== undefined)
}),
),
)
return Service.of({ write, writeTextPreservingBom })
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node],
})
/**
* Deferred until the corresponding integrations exist.
*/
+72 -1
View File
@@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { Project } from "./project"
import { AbsolutePath } from "./schema"
import { WorkspaceEnvironment } from "./workspace/environment"
export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
@@ -25,7 +26,7 @@ export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String,
reason: Schema.Literal("non_directory_ancestor"),
reason: Schema.Literals(["non_directory_ancestor", "outside_workspace"]),
}) {}
export interface ExternalDirectoryAuthorization {
@@ -155,3 +156,73 @@ export const node = makeLocationNode({
layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node],
})
// Mirrors the local resolve walk over WorkspaceEnvironment.Files with posix
// rules. Hosted paths are never external: everything outside the Location is
// rejected instead of routed to external_directory approval, because the
// approval boundary vocabulary is host-relative.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
const location = yield* Location.Service
function notFound<A>(effect: Effect.Effect<A, WorkspaceEnvironment.Error | WorkspaceEnvironment.NotFoundError>) {
return effect.pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
Effect.orDie,
)
}
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
const existing = yield* notFound(env.files.realPath(absolute))
if (existing !== undefined) {
const info = yield* notFound(env.files.stat(existing))
if (info === undefined) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
return {
canonical: existing,
type: info.type,
directory: info.type === "Directory" ? existing : path.posix.dirname(existing),
} satisfies ResolvedPath
}
let anchor = path.posix.dirname(absolute)
while (true) {
const canonical = yield* notFound(env.files.realPath(anchor))
if (canonical !== undefined) {
const info = yield* notFound(env.files.stat(canonical))
if (info === undefined || info.type !== "Directory") {
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
}
return {
canonical: path.posix.resolve(canonical, path.posix.relative(anchor, absolute)),
directory: canonical,
} satisfies ResolvedPath
}
const parent = path.posix.dirname(anchor)
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
anchor = parent
}
})
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const absolute = path.posix.resolve(location.directory, input.path)
const relative = path.posix.relative(location.directory, absolute)
if (relative.startsWith("..") || path.posix.isAbsolute(relative))
return yield* new PathError({ path: absolute, reason: "outside_workspace" })
const resolved = yield* resolvePath(absolute)
return {
canonical: resolved.canonical,
resource: relative || ".",
} satisfies Target
})
return Service.of({ resolve })
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer.pipe(Layer.orDie),
deps: [WorkspaceEnvironment.node, Location.node],
})
+2
View File
@@ -130,6 +130,8 @@ export function buildLocationServiceMap(
[Config.node, Config.configured({ project: false })],
[WorkspaceEnvironment.node, WorkspaceEnvironment.hostedNode(workspaceID)],
[FileSystem.node, FileSystem.hostedNode],
[LocationMutation.node, LocationMutation.hostedNode],
[FileMutation.node, FileMutation.hostedNode],
]
: [[Location.node, Location.boundNode(ref)]],
)
+65
View File
@@ -0,0 +1,65 @@
import { Effect } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
export const ROOT = "/workspace"
export interface MemoryEnvironment {
readonly environment: WorkspaceEnvironment.Interface
/** Live view of stored file contents by absolute path. */
readonly contents: (path: string) => string | undefined
readonly paths: () => string[]
}
/**
* In-memory workspace environment rooted at /workspace: file paths to
* contents, directories implied by keys, no symlinks, no processes.
*/
export const memoryEnvironment = (files: Record<string, string>): MemoryEnvironment => {
const encoder = new TextEncoder()
const decoder = new TextDecoder()
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 = (path: string) => Effect.fail(new WorkspaceEnvironment.NotFoundError({ path }))
const environment = 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(path),
realPath: (path) => (exists(path) ? Effect.succeed(path) : fail(path)),
read: (path) => {
const content = store.get(path)
return content ? Effect.succeed(content) : fail(path)
},
list: (path) => {
if (!isDirectory(path)) return fail(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,
})
return {
environment,
contents: (path) => {
const stored = store.get(path)
return stored ? decoder.decode(stored) : undefined
},
paths: () => Array.from(store.keys()).sort(),
}
}
@@ -1,6 +1,5 @@
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"
@@ -9,55 +8,11 @@ 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"
import { memoryEnvironment, ROOT } from "./lib/workspace"
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({
const memory = memoryEnvironment({
"/workspace/README.md": "# hello\n",
"/workspace/src/index.ts": "export {}\n",
"/workspace/src/util/deep.ts": "export const deep = 1\n",
@@ -78,7 +33,7 @@ const locationLayer = Layer.succeed(
const it = testEffect(
AppNodeBuilder.build(FileSystem.hostedNode, [
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, environment)],
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
[Location.node, locationLayer],
]),
)
@@ -0,0 +1,85 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
import { memoryEnvironment, ROOT } from "./lib/workspace"
const workspaceID = Workspace.ID.make("wrk_test")
const memory = memoryEnvironment({
"/workspace/README.md": "# hello\n",
"/workspace/src/index.ts": "export {}\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(LayerNode.group([LocationMutation.hostedNode, FileMutation.hostedNode]), [
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
[Location.node, locationLayer],
]),
)
describe("hosted mutation", () => {
it.effect("resolves an existing file to its canonical path and relative resource", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "README.md" })
expect(target.canonical).toBe("/workspace/README.md")
expect(target.resource).toBe("README.md")
expect(target.externalDirectory).toBeUndefined()
}),
)
it.effect("resolves a missing path below an existing directory", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "src/created/new.ts" })
expect(target.canonical).toBe("/workspace/src/created/new.ts")
expect(target.resource).toBe("src/created/new.ts")
}),
)
it.effect("rejects paths outside the workspace instead of granting external access", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const error = yield* mutation.resolve({ path: "/etc/passwd" }).pipe(Effect.flip)
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "outside_workspace" })
}),
)
it.effect("writes new and existing files through the environment", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const created = yield* mutation.resolve({ path: "notes/todo.md" })
const first = yield* files.write({ target: created, content: "- ship it\n" })
expect(first.existed).toBe(false)
expect(memory.contents("/workspace/notes/todo.md")).toBe("- ship it\n")
const second = yield* files.writeTextPreservingBom({ target: created, content: "- shipped\n" })
expect(second.existed).toBe(true)
expect(memory.contents("/workspace/notes/todo.md")).toBe("- shipped\n")
}),
)
})