permission.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import { afterEach, describe, expect, it } from "bun:test"
  2. import type {
  3. AgentSideConnection,
  4. RequestPermissionRequest,
  5. RequestPermissionResponse,
  6. SessionUpdate,
  7. } from "@agentclientprotocol/sdk"
  8. import type { Event, OpencodeClient } from "@kirincode-ai/sdk/v2"
  9. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  10. import { createTwoFilesPatch } from "diff"
  11. import { Effect, ManagedRuntime } from "effect"
  12. import { mkdtemp, rm } from "node:fs/promises"
  13. import { tmpdir } from "node:os"
  14. import path from "node:path"
  15. import { ACPEvent } from "@/acp/event"
  16. import { ACPSession } from "@/acp/session"
  17. type PermissionEvent = Extract<Event, { type: "permission.asked" }>
  18. type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
  19. type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
  20. const cleanupDirs: string[] = []
  21. afterEach(async () => {
  22. await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
  23. })
  24. const pollUntil = async (
  25. check: () => boolean | Promise<boolean>,
  26. message: string,
  27. opts?: { timeoutMs?: number; intervalMs?: number },
  28. ) => {
  29. const started = Date.now()
  30. while (true) {
  31. if (await check()) return
  32. if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
  33. await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
  34. }
  35. }
  36. function makeSessionService() {
  37. return ManagedRuntime.make(LayerNode.compile(ACPSession.node)).runSync(
  38. ACPSession.Service.use((service) => Effect.succeed(service)),
  39. )
  40. }
  41. function createHarness(
  42. requestPermission: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse> = () =>
  43. Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }),
  44. ) {
  45. const replies: PermissionReplyParams[] = []
  46. const requests: RequestPermissionRequest[] = []
  47. const updates: SessionUpdateParams[] = []
  48. const session = makeSessionService()
  49. const sdk = {
  50. permission: {
  51. reply: (params: PermissionReplyParams) => {
  52. replies.push(params)
  53. return Promise.resolve({ data: true })
  54. },
  55. },
  56. session: {
  57. message: () => Promise.resolve({ data: undefined }),
  58. },
  59. } as unknown as OpencodeClient
  60. const connection = {
  61. requestPermission: (params: RequestPermissionRequest) => {
  62. requests.push(params)
  63. return requestPermission(params)
  64. },
  65. sessionUpdate: (params: SessionUpdateParams) => {
  66. updates.push(params)
  67. return Promise.resolve()
  68. },
  69. } satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate">
  70. const subscription = new ACPEvent.Subscription({ sdk, connection, session })
  71. return { connection, replies, requests, sdk, session, subscription, updates }
  72. }
  73. async function createSession(session: ACPSession.Interface, sessionId: string, cwd = "/workspace") {
  74. await Effect.runPromise(session.create({ id: sessionId, cwd }))
  75. }
  76. async function createKnownTextPart(
  77. session: ACPSession.Interface,
  78. sessionId: string,
  79. messageId: string,
  80. partId: string,
  81. ) {
  82. await Effect.runPromise(
  83. session.recordPartMetadata({
  84. sessionId,
  85. messageId,
  86. partId,
  87. partType: "text",
  88. role: "assistant",
  89. }),
  90. )
  91. }
  92. function permissionAsked(
  93. sessionID: string,
  94. id: string,
  95. input: {
  96. permission?: string
  97. metadata?: Record<string, unknown>
  98. tool?: { messageID: string; callID: string }
  99. } = {},
  100. ) {
  101. return {
  102. id: `evt_${id}`,
  103. type: "permission.asked",
  104. properties: {
  105. id,
  106. sessionID,
  107. permission: input.permission ?? "bash",
  108. patterns: ["*"],
  109. metadata: input.metadata ?? { command: "printf hello" },
  110. always: [],
  111. ...(input.tool ? { tool: input.tool } : {}),
  112. },
  113. } as PermissionEvent
  114. }
  115. function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
  116. return {
  117. id: `evt_${sessionID}_${messageID}_${partID}`,
  118. type: "message.part.delta",
  119. properties: {
  120. sessionID,
  121. messageID,
  122. partID,
  123. field: "text",
  124. delta,
  125. },
  126. } as Event
  127. }
  128. function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
  129. return updates
  130. .filter((item) => item.sessionId === sessionId)
  131. .map((item) => item.update)
  132. .filter((update): update is Extract<SessionUpdate, { sessionUpdate: "agent_message_chunk" }> => {
  133. return update.sessionUpdate === "agent_message_chunk"
  134. })
  135. .map((update) => (update.content.type === "text" ? update.content.text : ""))
  136. .join("")
  137. }
  138. async function tempFile(name: string, content: string) {
  139. const dir = await mkdtemp(path.join(tmpdir(), "opencode-acp-permission-"))
  140. cleanupDirs.push(dir)
  141. const file = path.join(dir, name)
  142. await Bun.write(file, content)
  143. return file
  144. }
  145. describe("acp permissions", () => {
  146. it("sends requestPermission and replies with the selected outcome", async () => {
  147. const harness = createHarness()
  148. await createSession(harness.session, "ses_a")
  149. harness.subscription.handle(permissionAsked("ses_a", "perm_1", { tool: { messageID: "msg_1", callID: "call_1" } }))
  150. await pollUntil(() => harness.replies.length === 1, "permission was never replied")
  151. expect(harness.requests[0]).toMatchObject({
  152. sessionId: "ses_a",
  153. toolCall: {
  154. toolCallId: "call_1",
  155. status: "pending",
  156. title: "printf hello",
  157. rawInput: { command: "printf hello" },
  158. kind: "execute",
  159. locations: [],
  160. },
  161. options: [
  162. { optionId: "once", kind: "allow_once", name: "Allow once" },
  163. { optionId: "always", kind: "allow_always", name: "Always allow" },
  164. { optionId: "reject", kind: "reject_once", name: "Reject" },
  165. ],
  166. })
  167. expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
  168. })
  169. it("uses permission metadata for non-shell titles", async () => {
  170. const harness = createHarness()
  171. await createSession(harness.session, "ses_a")
  172. harness.subscription.handle(
  173. permissionAsked("ses_a", "perm_fetch", {
  174. permission: "webfetch",
  175. metadata: {
  176. url: "https://example.com/docs",
  177. format: "markdown",
  178. },
  179. tool: { messageID: "msg_1", callID: "call_1" },
  180. }),
  181. )
  182. await pollUntil(() => harness.replies.length === 1, "webfetch permission was never replied")
  183. expect(harness.requests[0]?.toolCall).toMatchObject({
  184. toolCallId: "call_1",
  185. title: "https://example.com/docs",
  186. kind: "fetch",
  187. rawInput: { url: "https://example.com/docs", format: "markdown" },
  188. })
  189. })
  190. it("includes a diff content block for edit permission metadata", async () => {
  191. const filepath = await tempFile("file.ts", "before\n")
  192. const harness = createHarness()
  193. await createSession(harness.session, "ses_a")
  194. harness.subscription.handle(
  195. permissionAsked("ses_a", "perm_edit", {
  196. permission: "edit",
  197. metadata: {
  198. filepath,
  199. diff: createTwoFilesPatch(filepath, filepath, "before\n", "after\n"),
  200. },
  201. tool: { messageID: "msg_1", callID: "call_1" },
  202. }),
  203. )
  204. await pollUntil(() => harness.replies.length === 1, "edit permission was never replied")
  205. expect(harness.requests[0]?.toolCall).toMatchObject({
  206. toolCallId: "call_1",
  207. title: filepath,
  208. kind: "edit",
  209. locations: [{ path: filepath }],
  210. content: [
  211. {
  212. type: "diff",
  213. path: filepath,
  214. oldText: "before\n",
  215. newText: "after\n",
  216. },
  217. ],
  218. })
  219. })
  220. it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => {
  221. const first = await tempFile("first.ts", "one\n")
  222. const second = await tempFile("second.ts", "alpha\n")
  223. const harness = createHarness()
  224. await createSession(harness.session, "ses_a")
  225. harness.subscription.handle(
  226. permissionAsked("ses_a", "perm_patch", {
  227. permission: "edit",
  228. metadata: {
  229. filepath: "first.ts, second.ts",
  230. files: [
  231. {
  232. filePath: first,
  233. relativePath: "first.ts",
  234. patch: createTwoFilesPatch(first, first, "one\n", "two\n"),
  235. },
  236. {
  237. filePath: second,
  238. relativePath: "second.ts",
  239. patch: createTwoFilesPatch(second, second, "alpha\n", "beta\n"),
  240. },
  241. ],
  242. },
  243. tool: { messageID: "msg_1", callID: "call_1" },
  244. }),
  245. )
  246. await pollUntil(() => harness.replies.length === 1, "apply_patch permission was never replied")
  247. expect(harness.requests[0]?.toolCall).toMatchObject({
  248. toolCallId: "call_1",
  249. title: "2 files",
  250. locations: [{ path: first }, { path: second }],
  251. content: [
  252. {
  253. type: "diff",
  254. path: first,
  255. oldText: "one\n",
  256. newText: "two\n",
  257. },
  258. {
  259. type: "diff",
  260. path: second,
  261. oldText: "alpha\n",
  262. newText: "beta\n",
  263. },
  264. ],
  265. })
  266. })
  267. it("forwards external_directory metadata and locations to requestPermission", async () => {
  268. const harness = createHarness()
  269. await createSession(harness.session, "ses_a")
  270. harness.subscription.handle(
  271. permissionAsked("ses_a", "perm_external", {
  272. permission: "external_directory",
  273. metadata: {
  274. command: "mkdir -p /tmp/outside",
  275. description: "Create external directory",
  276. directories: ["/tmp/outside"],
  277. patterns: ["/tmp/outside/*"],
  278. },
  279. tool: { messageID: "msg_1", callID: "call_1" },
  280. }),
  281. )
  282. await pollUntil(() => harness.replies.length === 1, "external_directory permission was never replied")
  283. expect(harness.requests[0]).toMatchObject({
  284. sessionId: "ses_a",
  285. toolCall: {
  286. toolCallId: "call_1",
  287. status: "pending",
  288. title: "Create external directory",
  289. rawInput: {
  290. command: "mkdir -p /tmp/outside",
  291. description: "Create external directory",
  292. directories: ["/tmp/outside"],
  293. patterns: ["/tmp/outside/*"],
  294. },
  295. locations: [{ path: "/tmp/outside" }],
  296. },
  297. })
  298. })
  299. it("rejects non-selected outcomes", async () => {
  300. const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } }))
  301. await createSession(harness.session, "ses_a")
  302. harness.subscription.handle(permissionAsked("ses_a", "perm_cancelled"))
  303. await pollUntil(() => harness.replies.length === 1, "cancelled permission was never replied")
  304. expect(harness.replies[0]).toMatchObject({ requestID: "perm_cancelled", reply: "reject" })
  305. })
  306. it("rejects when requestPermission fails", async () => {
  307. const harness = createHarness(() => Promise.reject(new Error("client permission UI failed")))
  308. await createSession(harness.session, "ses_a")
  309. harness.subscription.handle(permissionAsked("ses_a", "perm_failed"))
  310. await pollUntil(() => harness.replies.length === 1, "failed permission was never rejected")
  311. expect(harness.replies[0]).toMatchObject({ requestID: "perm_failed", reply: "reject" })
  312. })
  313. it("does not let a blocked session A permission block session B message updates", async () => {
  314. let releasePermission: (() => void) | undefined
  315. const blocked = new Promise<RequestPermissionResponse>((resolve) => {
  316. releasePermission = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
  317. })
  318. const harness = createHarness(() => blocked)
  319. await createSession(harness.session, "ses_a")
  320. await createSession(harness.session, "ses_b")
  321. await createKnownTextPart(harness.session, "ses_b", "msg_b", "part_b")
  322. harness.subscription.handle(permissionAsked("ses_a", "perm_blocked"))
  323. await pollUntil(() => harness.requests.length === 1, "blocked permission was never requested")
  324. await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "session_b_message"))
  325. expect(textFromUpdates(harness.updates, "ses_b")).toBe("session_b_message")
  326. expect(harness.replies).toHaveLength(0)
  327. releasePermission?.()
  328. await pollUntil(() => harness.replies.length === 1, "blocked permission was never replied after release")
  329. })
  330. it("serializes permission requests per session", async () => {
  331. let releaseFirst: (() => void) | undefined
  332. const first = new Promise<RequestPermissionResponse>((resolve) => {
  333. releaseFirst = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
  334. })
  335. const harness = createHarness(() =>
  336. harness.requests.length === 1 ? first : Promise.resolve({ outcome: { outcome: "selected", optionId: "always" } }),
  337. )
  338. await createSession(harness.session, "ses_a")
  339. harness.subscription.handle(permissionAsked("ses_a", "perm_1"))
  340. harness.subscription.handle(permissionAsked("ses_a", "perm_2"))
  341. await pollUntil(() => harness.requests.length === 1, "first permission was never requested")
  342. expect(harness.requests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
  343. releaseFirst?.()
  344. await pollUntil(() => harness.requests.length === 2, "second permission was not requested after first resolved")
  345. await pollUntil(() => harness.replies.length === 2, "serialized permissions were not both replied")
  346. expect(harness.replies.map((reply) => [reply.requestID, reply.reply])).toEqual([
  347. ["perm_1", "once"],
  348. ["perm_2", "always"],
  349. ])
  350. })
  351. })