|
|
@@ -11,6 +11,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
|
|
import { SessionV2 } from "@opencode-ai/core/session"
|
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
|
import { Global } from "@opencode-ai/core/global"
|
|
|
+import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
|
|
import { location } from "./fixture/location"
|
|
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
|
|
import { ReadTool } from "@opencode-ai/core/tool/read"
|
|
|
@@ -97,6 +98,32 @@ const infrastructure = Layer.mergeAll(
|
|
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
|
|
|
Global.layerWith({ data: Global.Path.data }),
|
|
|
)
|
|
|
+const mutation = Layer.succeed(
|
|
|
+ LocationMutation.Service,
|
|
|
+ LocationMutation.Service.of({
|
|
|
+ resolve: (input) => {
|
|
|
+ if (input.path === missingPath)
|
|
|
+ return Effect.fail(new LocationMutation.PathError({ path: input.path, reason: "non_directory_ancestor" }))
|
|
|
+ const canonical = path.resolve(process.cwd(), input.path)
|
|
|
+ const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
|
|
|
+ const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
|
|
|
+ const directory = path.dirname(canonical)
|
|
|
+ const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
|
|
+ return Effect.succeed({
|
|
|
+ canonical,
|
|
|
+ resource,
|
|
|
+ externalDirectory: external
|
|
|
+ ? {
|
|
|
+ action: "external_directory" as const,
|
|
|
+ directory,
|
|
|
+ resource: externalResource,
|
|
|
+ save: externalResource,
|
|
|
+ }
|
|
|
+ : undefined,
|
|
|
+ })
|
|
|
+ },
|
|
|
+ }),
|
|
|
+)
|
|
|
const unavailableImage = Layer.succeed(
|
|
|
Image.Service,
|
|
|
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
|
|
@@ -107,19 +134,21 @@ const read = ReadTool.layer.pipe(
|
|
|
Layer.provide(permission),
|
|
|
Layer.provide(config),
|
|
|
Layer.provide(image),
|
|
|
+ Layer.provide(mutation),
|
|
|
Layer.provide(infrastructure),
|
|
|
)
|
|
|
-const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read))
|
|
|
+const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read))
|
|
|
const unavailableRead = ReadTool.layer.pipe(
|
|
|
Layer.provide(registry),
|
|
|
Layer.provide(reader),
|
|
|
Layer.provide(permission),
|
|
|
Layer.provide(config),
|
|
|
Layer.provide(unavailableImage),
|
|
|
+ Layer.provide(mutation),
|
|
|
Layer.provide(infrastructure),
|
|
|
)
|
|
|
const itWithoutResizer = testEffect(
|
|
|
- Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead),
|
|
|
+ Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead),
|
|
|
)
|
|
|
const sessionID = SessionV2.ID.make("ses_read_tool_test")
|
|
|
|
|
|
@@ -174,6 +203,32 @@ describe("ReadTool", () => {
|
|
|
}),
|
|
|
)
|
|
|
|
|
|
+ it.effect("asks for external_directory approval before reading an external absolute path", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const registry = yield* ToolRegistry.Service
|
|
|
+ const external = path.join(path.parse(process.cwd()).root, "external-read", "notes.txt")
|
|
|
+
|
|
|
+ expect(
|
|
|
+ yield* executeTool(registry, {
|
|
|
+ sessionID,
|
|
|
+ ...toolIdentity,
|
|
|
+ call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
|
|
|
+ }),
|
|
|
+ ).toMatchObject({ type: "json" })
|
|
|
+ expect(assertions).toMatchObject([
|
|
|
+ {
|
|
|
+ sessionID,
|
|
|
+ action: "external_directory",
|
|
|
+ resources: [path.join(path.dirname(external), "*").replaceAll("\\", "/")],
|
|
|
+ },
|
|
|
+ { sessionID, action: "read", resources: [external.replaceAll("\\", "/")], save: ["*"] },
|
|
|
+ ])
|
|
|
+ expect(readCalls).toEqual([
|
|
|
+ { input: AbsolutePath.make(external), page: { offset: undefined, limit: undefined } },
|
|
|
+ ])
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
it.effect("returns a small PNG as native media instead of durable base64 text", () =>
|
|
|
Effect.gen(function* () {
|
|
|
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|