feat(core): route hosted mutation resolution and writes through workspace environment
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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)]],
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user