Compare commits

...
3 changed files with 104 additions and 17 deletions
+15 -11
View File
@@ -4,6 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Environment } from "./environment/index.js"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { AbsolutePath } from "./schema.js"
@@ -52,7 +53,7 @@ export interface Interface {
* from the Location. Paths outside it require separate `external_directory`
* approval. This does not approve the mutation.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, Environment.Failed>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
@@ -62,7 +63,7 @@ const slash = (value: string) => value.replaceAll("\\", "/")
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const environment = yield* Environment.Service
const location = yield* Location.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
@@ -73,14 +74,14 @@ const layer = Layer.effect(
resource: slash(path.relative(location.directory, absolute) || "."),
} satisfies Target
}
// Probe through the Location environment so workspace-backed Locations classify
// the target against the sandbox filesystem rather than the server host.
const type =
input.kind === "directory"
? "Directory"
: input.kind === "file"
? "File"
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
?.type
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
input.kind ??
(yield* Environment.typeFollowing(environment.files, absolute).pipe(
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
))
const externalDirectory = type === "directory" ? absolute : path.dirname(absolute)
const externalResource = slash(path.join(externalDirectory, "*"))
return {
absolute,
@@ -90,7 +91,10 @@ const layer = Layer.effect(
directory: externalDirectory,
resource: externalResource,
save: slash(
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
path.join(
(yield* Project.root(environment.files, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
"*",
),
),
},
} satisfies Target
@@ -103,5 +107,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node],
deps: [Environment.node, Location.node],
})
+20 -6
View File
@@ -7,6 +7,7 @@ import path from "path"
import { AbsolutePath } from "./schema.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import type { Files } from "./environment/index.js"
import { Worktree } from "@opencode-ai/schema/worktree"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
@@ -38,12 +39,25 @@ export interface Resolved {
}
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, input: AbsolutePath) {
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
Effect.map((matches) => (matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined)),
Effect.orElseSucceed(() => undefined),
)
})
// Probes go through the caller's Environment files so workspace-backed Locations walk the
// Location filesystem rather than the server host. Any probe failure yields no root.
export const root = Effect.fn("Project.root")((files: Files, input: AbsolutePath) =>
Effect.gen(function* () {
let current: string = input
while (true) {
for (const target of [".git", ".hg"]) {
const found = yield* files.stat(path.join(current, target)).pipe(
Effect.as(true),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
)
if (found) return AbsolutePath.make(current)
}
const parent = path.dirname(current)
if (parent === current) return undefined
current = parent
}
}).pipe(Effect.orElseSucceed(() => undefined)),
)
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
@@ -3,10 +3,13 @@ import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { tmpdir } from "./fixture/tmpdir"
import { hostEnvironmentLayer } from "./fixture/environment"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
@@ -17,6 +20,30 @@ function provide(directory: string) {
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[Environment.node, hostEnvironmentLayer],
]),
)
}
function provideMemory(directory: string, memory: Environment.MemoryDriver) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(directory), workspaceID: Workspace.ID.make("wrk_test") }),
),
),
],
[
Environment.node,
Layer.succeed(
Environment.Service,
Environment.Service.of({ files: Environment.makeFiles(memory), spawner: memory.spawner }),
),
],
]),
)
}
@@ -190,6 +217,48 @@ describe("LocationMutation", () => {
),
)
it.live("classifies an external target against the location environment, not the server host", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const memory = Environment.makeMemoryDriver()
const files = Environment.makeFiles(memory)
yield* files.mkdir("/workspace/project")
yield* files.mkdir("/remote/data")
yield* memory.symlink("/remote/data", "/remote/link")
yield* Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
// The directory exists only in the location environment; the server host has no /remote.
expect((yield* mutation.resolve({ path: "/remote/data" })).externalDirectory).toMatchObject({
directory: "/remote/data",
resource: "/remote/data/*",
})
// A final symlink is followed when classifying the boundary.
expect((yield* mutation.resolve({ path: "/remote/link" })).externalDirectory).toMatchObject({
directory: "/remote/link",
resource: "/remote/link/*",
})
}).pipe(provideMemory("/workspace/project", memory))
}),
)
it.live("derives the external save boundary from the location environment project root", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const memory = Environment.makeMemoryDriver()
const files = Environment.makeFiles(memory)
yield* files.mkdir("/workspace/project")
yield* files.mkdir("/remote/repo/.git")
yield* Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "/remote/repo/nested/file.txt" })
expect(target.externalDirectory).toMatchObject({
directory: "/remote/repo/nested",
resource: "/remote/repo/nested/*",
save: "/remote/repo/*",
})
}).pipe(provideMemory("/workspace/project", memory))
}),
)
test("ignores unknown mutation input fields", () => {
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({