permission.test.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import { describe, expect } from "bun:test"
  2. import { Deferred, Effect, Fiber, Layer } from "effect"
  3. import { AgentV2 } from "@opencode-ai/core/agent"
  4. import { Database } from "@opencode-ai/core/database/database"
  5. import { EventV2 } from "@opencode-ai/core/event"
  6. import { Location } from "@opencode-ai/core/location"
  7. import { PermissionV2 } from "@opencode-ai/core/permission"
  8. import { PermissionTable } from "@opencode-ai/core/permission/sql"
  9. import { PermissionSaved } from "@opencode-ai/core/permission/saved"
  10. import { Project } from "@opencode-ai/core/project"
  11. import { ProjectTable } from "@opencode-ai/core/project/sql"
  12. import { AbsolutePath } from "@opencode-ai/core/schema"
  13. import { SessionV2 } from "@opencode-ai/core/session"
  14. import { SessionTable } from "@opencode-ai/core/session/sql"
  15. import { eq } from "drizzle-orm"
  16. import { location } from "./fixture/location"
  17. import { testEffect } from "./lib/effect"
  18. const database = Database.layerFromPath(":memory:")
  19. const current = Layer.succeed(
  20. Location.Service,
  21. Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
  22. )
  23. const events = EventV2.layer.pipe(Layer.provide(database))
  24. const sessions = SessionV2.layer.pipe(Layer.provide(database))
  25. const saved = PermissionSaved.layer.pipe(Layer.provide(database))
  26. const layer = PermissionV2.locationLayer.pipe(
  27. Layer.provideMerge(database),
  28. Layer.provideMerge(events),
  29. Layer.provideMerge(current),
  30. Layer.provideMerge(sessions),
  31. Layer.provideMerge(saved),
  32. )
  33. const it = testEffect(layer)
  34. function setup(rules: PermissionV2.Ruleset = []) {
  35. return Effect.gen(function* () {
  36. const { db } = yield* Database.Service
  37. yield* db
  38. .insert(ProjectTable)
  39. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  40. .onConflictDoNothing()
  41. .run()
  42. .pipe(Effect.orDie)
  43. yield* db
  44. .insert(SessionTable)
  45. .values({
  46. id: SessionV2.ID.make("ses_test"),
  47. project_id: Project.ID.global,
  48. slug: "test",
  49. directory: "/project",
  50. title: "test",
  51. version: "test",
  52. agent: "test",
  53. })
  54. .onConflictDoNothing()
  55. .run()
  56. .pipe(Effect.orDie)
  57. yield* setRules(rules)
  58. })
  59. }
  60. function setRules(rules: PermissionV2.Ruleset) {
  61. return Effect.gen(function* () {
  62. const agents = yield* AgentV2.Service
  63. const update = yield* agents.transform()
  64. yield* update((editor) =>
  65. editor.update(AgentV2.ID.make("test"), (agent) => {
  66. agent.permissions = [...rules]
  67. }),
  68. )
  69. })
  70. }
  71. function assertion(input: Partial<PermissionV2.AssertInput> = {}) {
  72. return {
  73. id: PermissionV2.ID.create("per_test"),
  74. sessionID: SessionV2.ID.make("ses_test"),
  75. action: "read",
  76. resources: ["src/index.ts"],
  77. ...input,
  78. } satisfies PermissionV2.AssertInput
  79. }
  80. function waitForRequest() {
  81. return Effect.gen(function* () {
  82. const service = yield* PermissionV2.Service
  83. const events = yield* EventV2.Service
  84. const asked = yield* Deferred.make<PermissionV2.Request>()
  85. const unsubscribe = yield* events.listen((event) =>
  86. event.type === PermissionV2.Event.Asked.type
  87. ? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
  88. : Effect.void,
  89. )
  90. yield* Effect.addFinalizer(() => unsubscribe)
  91. const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
  92. const request = yield* Deferred.await(asked)
  93. return { service, fiber, request }
  94. })
  95. }
  96. describe("PermissionV2", () => {
  97. it.effect("returns the evaluated effect and only queues prompts", () =>
  98. Effect.gen(function* () {
  99. yield* setup([{ action: "read", resource: "*", effect: "allow" }])
  100. const service = yield* PermissionV2.Service
  101. expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" })
  102. expect(yield* service.list()).toEqual([])
  103. yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
  104. expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" })
  105. expect(yield* service.list()).toEqual([])
  106. yield* setRules([])
  107. expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" })
  108. expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined()
  109. }),
  110. )
  111. it.effect("allows and denies from explicit rules without asking", () =>
  112. Effect.gen(function* () {
  113. yield* setup([{ action: "read", resource: "*", effect: "allow" }])
  114. const service = yield* PermissionV2.Service
  115. yield* service.assert(assertion())
  116. yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
  117. const denied = yield* service.assert(assertion()).pipe(Effect.flip)
  118. expect(denied).toBeInstanceOf(PermissionV2.DeniedError)
  119. expect(yield* service.list()).toEqual([])
  120. }),
  121. )
  122. it.effect("resolves an asked permission once", () =>
  123. Effect.gen(function* () {
  124. yield* setup()
  125. const { service, fiber, request } = yield* waitForRequest()
  126. expect(yield* service.list()).toEqual([request])
  127. expect(yield* service.forSession(request.sessionID)).toEqual([request])
  128. expect(yield* service.forSession(SessionV2.ID.make("ses_other"))).toEqual([])
  129. expect(yield* service.get(request.id)).toEqual(request)
  130. yield* service.reply({ requestID: request.id, reply: "once" })
  131. yield* Fiber.join(fiber)
  132. expect(yield* service.list()).toEqual([])
  133. expect(yield* service.get(request.id)).toBeUndefined()
  134. }),
  135. )
  136. it.effect("stores and removes saved resources for a project", () =>
  137. Effect.gen(function* () {
  138. yield* setup()
  139. const service = yield* PermissionV2.Service
  140. const asked = yield* Deferred.make<PermissionV2.Request>()
  141. const events = yield* EventV2.Service
  142. const unsubscribe = yield* events.listen((event) =>
  143. event.type === PermissionV2.Event.Asked.type
  144. ? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
  145. : Effect.void,
  146. )
  147. yield* Effect.addFinalizer(() => unsubscribe)
  148. const fiber = yield* service.assert(assertion({ save: ["src/*"] })).pipe(Effect.forkScoped)
  149. const request = yield* Deferred.await(asked)
  150. yield* service.reply({ requestID: request.id, reply: "always" })
  151. yield* Fiber.join(fiber)
  152. const { db } = yield* Database.Service
  153. expect(
  154. yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Project.ID.global)).all(),
  155. ).toMatchObject([{ action: "read", resource: "src/*" }])
  156. const saved = yield* PermissionSaved.Service
  157. const id = (yield* saved.list())[0]!.id
  158. expect(yield* saved.list()).toEqual([{ id, projectID: Project.ID.global, action: "read", resource: "src/*" }])
  159. yield* service.assert(assertion({ id: PermissionV2.ID.create("per_next"), resources: ["src/next.ts"] }))
  160. yield* saved.remove(id)
  161. expect(yield* saved.list()).toEqual([])
  162. }),
  163. )
  164. })