import { describe, expect } from "bun:test" import { Deferred, Effect, Fiber, Layer } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionTable } from "@opencode-ai/core/permission/sql" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionTable } from "@opencode-ai/core/session/sql" import { eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" const database = Database.layerFromPath(":memory:") const current = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/project") })), ) const events = EventV2.layer.pipe(Layer.provide(database)) const sessions = SessionV2.layer.pipe(Layer.provide(database)) const saved = PermissionSaved.layer.pipe(Layer.provide(database)) const layer = PermissionV2.locationLayer.pipe( Layer.provideMerge(database), Layer.provideMerge(events), Layer.provideMerge(current), Layer.provideMerge(sessions), Layer.provideMerge(saved), ) const it = testEffect(layer) function setup(rules: PermissionV2.Ruleset = []) { return Effect.gen(function* () { const { db } = yield* Database.Service yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .onConflictDoNothing() .run() .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ id: SessionV2.ID.make("ses_test"), project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test", agent: "test", }) .onConflictDoNothing() .run() .pipe(Effect.orDie) yield* setRules(rules) }) } function setRules(rules: PermissionV2.Ruleset) { return Effect.gen(function* () { const agents = yield* AgentV2.Service const update = yield* agents.transform() yield* update((editor) => editor.update(AgentV2.ID.make("test"), (agent) => { agent.permissions = [...rules] }), ) }) } function assertion(input: Partial = {}) { return { id: PermissionV2.ID.create("per_test"), sessionID: SessionV2.ID.make("ses_test"), action: "read", resources: ["src/index.ts"], ...input, } satisfies PermissionV2.AssertInput } function waitForRequest() { return Effect.gen(function* () { const service = yield* PermissionV2.Service const events = yield* EventV2.Service const asked = yield* Deferred.make() const unsubscribe = yield* events.listen((event) => event.type === PermissionV2.Event.Asked.type ? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid) : Effect.void, ) yield* Effect.addFinalizer(() => unsubscribe) const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped) const request = yield* Deferred.await(asked) return { service, fiber, request } }) } describe("PermissionV2", () => { it.effect("returns the evaluated effect and only queues prompts", () => Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) const service = yield* PermissionV2.Service expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" }) expect(yield* service.list()).toEqual([]) yield* setRules([{ action: "read", resource: "*", effect: "deny" }]) expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" }) expect(yield* service.list()).toEqual([]) yield* setRules([]) expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" }) expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined() }), ) it.effect("allows and denies from explicit rules without asking", () => Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) const service = yield* PermissionV2.Service yield* service.assert(assertion()) yield* setRules([{ action: "read", resource: "*", effect: "deny" }]) const denied = yield* service.assert(assertion()).pipe(Effect.flip) expect(denied).toBeInstanceOf(PermissionV2.DeniedError) expect(yield* service.list()).toEqual([]) }), ) it.effect("resolves an asked permission once", () => Effect.gen(function* () { yield* setup() const { service, fiber, request } = yield* waitForRequest() expect(yield* service.list()).toEqual([request]) expect(yield* service.forSession(request.sessionID)).toEqual([request]) expect(yield* service.forSession(SessionV2.ID.make("ses_other"))).toEqual([]) expect(yield* service.get(request.id)).toEqual(request) yield* service.reply({ requestID: request.id, reply: "once" }) yield* Fiber.join(fiber) expect(yield* service.list()).toEqual([]) expect(yield* service.get(request.id)).toBeUndefined() }), ) it.effect("stores and removes saved resources for a project", () => Effect.gen(function* () { yield* setup() const service = yield* PermissionV2.Service const asked = yield* Deferred.make() const events = yield* EventV2.Service const unsubscribe = yield* events.listen((event) => event.type === PermissionV2.Event.Asked.type ? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid) : Effect.void, ) yield* Effect.addFinalizer(() => unsubscribe) const fiber = yield* service.assert(assertion({ save: ["src/*"] })).pipe(Effect.forkScoped) const request = yield* Deferred.await(asked) yield* service.reply({ requestID: request.id, reply: "always" }) yield* Fiber.join(fiber) const { db } = yield* Database.Service expect( yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Project.ID.global)).all(), ).toMatchObject([{ action: "read", resource: "src/*" }]) const saved = yield* PermissionSaved.Service const id = (yield* saved.list())[0]!.id expect(yield* saved.list()).toEqual([{ id, projectID: Project.ID.global, action: "read", resource: "src/*" }]) yield* service.assert(assertion({ id: PermissionV2.ID.create("per_next"), resources: ["src/next.ts"] })) yield* saved.remove(id) expect(yield* saved.list()).toEqual([]) }), ) })