permission-behavior.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. import { describe, expect, test } from "bun:test"
  2. import type { AgentSideConnection, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
  3. import fs from "node:fs/promises"
  4. import os from "node:os"
  5. import path from "node:path"
  6. import { streamTurn } from "../../src/acp/event"
  7. import { syncEditedFiles } from "../../src/acp/permission"
  8. import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
  9. type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
  10. type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
  11. Partial<Pick<AgentSideConnection, "writeTextFile">>
  12. type Fixture = ReturnType<typeof createSseFixture>
  13. describe("acp permission behavior", () => {
  14. test("does not sync edits when writeTextFile was not advertised", async () => {
  15. const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
  16. await syncEditedFiles({
  17. connection: {
  18. writeTextFile: async (input) => {
  19. writes.push(input)
  20. return {}
  21. },
  22. },
  23. writeTextFile: false,
  24. sessionID: "ses_no_write",
  25. cwd: "/workspace",
  26. toolName: "edit",
  27. toolInput: { filePath: "/workspace/file.ts" },
  28. metadata: {},
  29. })
  30. expect(writes).toEqual([])
  31. })
  32. test("forwards allow-once and allow-always selections to the generated client", async () => {
  33. const permissionRequests: RequestPermissionRequest[] = []
  34. const fixture = createSseFixture({
  35. onPrompt({ id, send }) {
  36. send(durableEvent("session.input.promoted", { sessionID: "ses_allow", inputID: id }))
  37. send(
  38. permissionAsked("ses_allow", "perm_once", {
  39. action: "shell",
  40. metadata: { command: "printf hello" },
  41. source: { type: "tool", messageID: "msg_allow", callID: "call_once" },
  42. }),
  43. )
  44. send(
  45. permissionAsked("ses_allow", "perm_always", {
  46. action: "read",
  47. metadata: { path: "/workspace/file.ts" },
  48. source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
  49. }),
  50. )
  51. send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
  52. },
  53. })
  54. const connection = {
  55. sessionUpdate: async () => {},
  56. requestPermission: async (request) => {
  57. permissionRequests.push(request)
  58. return {
  59. outcome: {
  60. outcome: "selected",
  61. optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always",
  62. },
  63. }
  64. },
  65. } satisfies Connection
  66. try {
  67. await startTurn(fixture, connection, "ses_allow", "input_allow")
  68. expect(permissionRequests[0]).toMatchObject({
  69. sessionId: "ses_allow",
  70. toolCall: {
  71. toolCallId: "call_once",
  72. status: "pending",
  73. title: "printf hello",
  74. kind: "execute",
  75. locations: [{ path: "/workspace" }],
  76. rawInput: { command: "printf hello", cwd: "/workspace" },
  77. },
  78. options: [
  79. { optionId: "once", kind: "allow_once", name: "Allow once" },
  80. { optionId: "always", kind: "allow_always", name: "Always allow" },
  81. { optionId: "reject", kind: "reject_once", name: "Reject" },
  82. ],
  83. })
  84. expect(permissionRequests[1]).toMatchObject({
  85. sessionId: "ses_allow",
  86. toolCall: {
  87. toolCallId: "call_always",
  88. status: "pending",
  89. title: "/workspace/file.ts",
  90. kind: "read",
  91. locations: [{ path: "/workspace/file.ts" }],
  92. rawInput: { path: "/workspace/file.ts" },
  93. },
  94. })
  95. expect(permissionReplies(fixture)).toEqual([
  96. ["perm_once", "once"],
  97. ["perm_always", "always"],
  98. ])
  99. } finally {
  100. await fixture.stop()
  101. }
  102. })
  103. test("preserves external directory permission context", async () => {
  104. const permissionRequests: RequestPermissionRequest[] = []
  105. const fixture = createSseFixture({
  106. onPrompt({ id, send }) {
  107. send(durableEvent("session.input.promoted", { sessionID: "ses_external", inputID: id }))
  108. send(
  109. permissionAsked("ses_external", "perm_external", {
  110. action: "external_directory",
  111. metadata: {
  112. command: "mkdir -p /tmp/outside",
  113. description: "Create external directory",
  114. directories: ["/tmp/outside"],
  115. patterns: ["/tmp/outside/*"],
  116. },
  117. }),
  118. )
  119. send(durableEvent("session.execution.succeeded", { sessionID: "ses_external" }))
  120. },
  121. })
  122. const connection = {
  123. sessionUpdate: async () => {},
  124. requestPermission: async (request) => {
  125. permissionRequests.push(request)
  126. return { outcome: { outcome: "selected", optionId: "once" } } as const
  127. },
  128. } satisfies Connection
  129. try {
  130. await startTurn(fixture, connection, "ses_external", "input_external")
  131. expect(permissionRequests[0]?.toolCall).toMatchObject({
  132. title: "Create external directory",
  133. locations: [{ path: "/tmp/outside" }],
  134. rawInput: {
  135. command: "mkdir -p /tmp/outside",
  136. description: "Create external directory",
  137. directories: ["/tmp/outside"],
  138. patterns: ["/tmp/outside/*"],
  139. },
  140. })
  141. } finally {
  142. await fixture.stop()
  143. }
  144. })
  145. test("previews edits during approval and syncs the completed file", async () => {
  146. const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
  147. const file = path.join(cwd, "file.ts")
  148. await fs.writeFile(file, "before")
  149. const permissionRequests: RequestPermissionRequest[] = []
  150. const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
  151. const fixture = createSseFixture({
  152. onPrompt({ id, send }) {
  153. send(durableEvent("session.input.promoted", { sessionID: "ses_edit", inputID: id }))
  154. send(
  155. durableEvent("session.tool.input.started", {
  156. sessionID: "ses_edit",
  157. assistantMessageID: "msg_edit",
  158. callID: "call_edit",
  159. name: "edit",
  160. }),
  161. )
  162. send(
  163. durableEvent("session.tool.called", {
  164. sessionID: "ses_edit",
  165. assistantMessageID: "msg_edit",
  166. callID: "call_edit",
  167. input: { path: "file.ts", oldString: "before", newString: "after" },
  168. executed: false,
  169. }),
  170. )
  171. send(
  172. permissionAsked("ses_edit", "perm_edit", {
  173. action: "edit",
  174. source: { type: "tool", messageID: "msg_edit", callID: "call_edit" },
  175. }),
  176. )
  177. },
  178. async onPermissionReply({ send }) {
  179. await fs.writeFile(file, "after")
  180. send(
  181. durableEvent("session.tool.success", {
  182. sessionID: "ses_edit",
  183. assistantMessageID: "msg_edit",
  184. callID: "call_edit",
  185. metadata: { files: [{ file: "file.ts" }], replacements: 1 },
  186. content: [{ type: "text", text: "edited" }],
  187. executed: true,
  188. }),
  189. )
  190. send(durableEvent("session.execution.succeeded", { sessionID: "ses_edit" }))
  191. },
  192. })
  193. const connection = {
  194. sessionUpdate: async () => {},
  195. requestPermission: async (request) => {
  196. permissionRequests.push(request)
  197. return { outcome: { outcome: "selected", optionId: "once" } } as const
  198. },
  199. writeTextFile: async (request) => {
  200. writes.push(request)
  201. return {}
  202. },
  203. } satisfies Connection
  204. try {
  205. await startTurn(fixture, connection, "ses_edit", "input_edit", cwd)
  206. expect(permissionRequests[0]?.toolCall).toMatchObject({
  207. title: "file.ts",
  208. kind: "edit",
  209. locations: [{ path: "file.ts" }],
  210. content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
  211. })
  212. expect(writes).toEqual([{ sessionId: "ses_edit", path: file, content: "after" }])
  213. } finally {
  214. await fixture.stop()
  215. await fs.rm(cwd, { recursive: true, force: true })
  216. }
  217. })
  218. test("previews and syncs each file in a patch", async () => {
  219. const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-patch-permission-"))
  220. await Promise.all([
  221. fs.writeFile(path.join(cwd, "first.ts"), "one\n"),
  222. fs.writeFile(path.join(cwd, "second.ts"), "alpha\n"),
  223. ])
  224. const patchText = [
  225. "*** Begin Patch",
  226. "*** Update File: first.ts",
  227. "@@",
  228. "-one",
  229. "+two",
  230. "*** Update File: second.ts",
  231. "@@",
  232. "-alpha",
  233. "+beta",
  234. "*** End Patch",
  235. ].join("\n")
  236. const permissionRequests: RequestPermissionRequest[] = []
  237. const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
  238. const fixture = createSseFixture({
  239. onPrompt({ id, send }) {
  240. send(durableEvent("session.input.promoted", { sessionID: "ses_patch", inputID: id }))
  241. send(
  242. durableEvent("session.tool.input.started", {
  243. sessionID: "ses_patch",
  244. assistantMessageID: "msg_patch",
  245. callID: "call_patch",
  246. name: "patch",
  247. }),
  248. )
  249. send(
  250. durableEvent("session.tool.called", {
  251. sessionID: "ses_patch",
  252. assistantMessageID: "msg_patch",
  253. callID: "call_patch",
  254. input: { patchText },
  255. executed: false,
  256. }),
  257. )
  258. send(
  259. permissionAsked("ses_patch", "perm_patch", {
  260. action: "edit",
  261. source: { type: "tool", messageID: "msg_patch", callID: "call_patch" },
  262. }),
  263. )
  264. },
  265. async onPermissionReply({ send }) {
  266. await Promise.all([
  267. fs.writeFile(path.join(cwd, "first.ts"), "two\n"),
  268. fs.writeFile(path.join(cwd, "second.ts"), "beta\n"),
  269. ])
  270. send(
  271. durableEvent("session.tool.success", {
  272. sessionID: "ses_patch",
  273. assistantMessageID: "msg_patch",
  274. callID: "call_patch",
  275. metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
  276. content: [{ type: "text", text: "patched" }],
  277. executed: true,
  278. }),
  279. )
  280. send(durableEvent("session.execution.succeeded", { sessionID: "ses_patch" }))
  281. },
  282. })
  283. const connection = {
  284. sessionUpdate: async () => {},
  285. requestPermission: async (request) => {
  286. permissionRequests.push(request)
  287. return { outcome: { outcome: "selected", optionId: "once" } } as const
  288. },
  289. writeTextFile: async (request) => {
  290. writes.push(request)
  291. return {}
  292. },
  293. } satisfies Connection
  294. try {
  295. await startTurn(fixture, connection, "ses_patch", "input_patch", cwd)
  296. expect(permissionRequests[0]?.toolCall).toMatchObject({
  297. title: "2 files",
  298. kind: "edit",
  299. locations: [{ path: "first.ts" }, { path: "second.ts" }],
  300. content: [
  301. { type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
  302. { type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
  303. ],
  304. })
  305. expect(writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
  306. { sessionId: "ses_patch", path: path.join(cwd, "first.ts"), content: "two\n" },
  307. { sessionId: "ses_patch", path: path.join(cwd, "second.ts"), content: "beta\n" },
  308. ])
  309. } finally {
  310. await fixture.stop()
  311. await fs.rm(cwd, { recursive: true, force: true })
  312. }
  313. })
  314. test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
  315. const fixture = createSseFixture({
  316. onPrompt({ id, send }) {
  317. send(durableEvent("session.input.promoted", { sessionID: "ses_reject", inputID: id }))
  318. send(permissionAsked("ses_reject", "perm_selected_reject"))
  319. send(permissionAsked("ses_reject", "perm_cancelled"))
  320. send(permissionAsked("ses_reject", "perm_failed"))
  321. send(durableEvent("session.execution.succeeded", { sessionID: "ses_reject" }))
  322. },
  323. })
  324. const connection = {
  325. sessionUpdate: async () => {},
  326. requestPermission: async (request): Promise<RequestPermissionResponse> => {
  327. if (request.toolCall.toolCallId === "perm_selected_reject") {
  328. return { outcome: { outcome: "selected", optionId: "reject" } }
  329. }
  330. if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
  331. throw new Error("client permission UI failed")
  332. },
  333. } satisfies Connection
  334. try {
  335. const response = await startTurn(fixture, connection, "ses_reject", "input_reject")
  336. expect(response).toMatchObject({ stopReason: "end_turn" })
  337. expect(permissionReplies(fixture)).toEqual([
  338. ["perm_selected_reject", "reject"],
  339. ["perm_cancelled", "reject"],
  340. ["perm_failed", "reject"],
  341. ])
  342. } finally {
  343. await fixture.stop()
  344. }
  345. })
  346. test("serializes permission requests and replies within one session", async () => {
  347. const firstRequested = Promise.withResolvers<void>()
  348. const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
  349. const permissionRequests: RequestPermissionRequest[] = []
  350. const fixture = createSseFixture({
  351. onPrompt({ id, send }) {
  352. send(durableEvent("session.input.promoted", { sessionID: "ses_serial", inputID: id }))
  353. send(permissionAsked("ses_serial", "perm_1"))
  354. send(permissionAsked("ses_serial", "perm_2"))
  355. send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
  356. },
  357. })
  358. const connection = {
  359. sessionUpdate: async () => {},
  360. requestPermission: async (request) => {
  361. permissionRequests.push(request)
  362. if (request.toolCall.toolCallId === "perm_1") {
  363. firstRequested.resolve()
  364. return releaseFirst.promise
  365. }
  366. return { outcome: { outcome: "selected", optionId: "always" } } as const
  367. },
  368. } satisfies Connection
  369. const result = startTurn(fixture, connection, "ses_serial", "input_serial")
  370. try {
  371. await withTimeout(firstRequested.promise, "first permission was not requested")
  372. expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
  373. expect(permissionReplies(fixture)).toEqual([])
  374. releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
  375. await withTimeout(result, "serialized permission turn did not finish")
  376. expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
  377. expect(permissionReplies(fixture)).toEqual([
  378. ["perm_1", "once"],
  379. ["perm_2", "always"],
  380. ])
  381. } finally {
  382. releaseFirst.resolve({ outcome: { outcome: "cancelled" } })
  383. await result.catch(() => undefined)
  384. await fixture.stop()
  385. }
  386. })
  387. test("does not let one session's blocked permission stall another session", async () => {
  388. const blockedRequested = Promise.withResolvers<void>()
  389. const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
  390. const promptIDs = new Map<string, string>()
  391. const updates: SessionUpdateParams[] = []
  392. const fixture = createSseFixture({
  393. onPrompt({ sessionID, id, send }) {
  394. promptIDs.set(sessionID, id)
  395. if (promptIDs.size !== 2) return
  396. const blockedID = promptIDs.get("ses_blocked")
  397. const freeID = promptIDs.get("ses_free")
  398. if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
  399. send(durableEvent("session.input.promoted", { sessionID: "ses_blocked", inputID: blockedID }))
  400. send(durableEvent("session.input.promoted", { sessionID: "ses_free", inputID: freeID }))
  401. send(permissionAsked("ses_blocked", "perm_blocked"))
  402. send(
  403. ephemeralEvent("session.text.delta", {
  404. sessionID: "ses_free",
  405. assistantMessageID: "msg_free",
  406. ordinal: 0,
  407. delta: "session B continued",
  408. }),
  409. )
  410. send(
  411. durableEvent("session.step.ended", {
  412. sessionID: "ses_free",
  413. assistantMessageID: "msg_free",
  414. finish: "stop",
  415. cost: 0,
  416. tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
  417. }),
  418. )
  419. send(durableEvent("session.execution.succeeded", { sessionID: "ses_free" }))
  420. send(durableEvent("session.execution.succeeded", { sessionID: "ses_blocked" }))
  421. },
  422. })
  423. const connection = {
  424. sessionUpdate: async (update) => {
  425. updates.push(update)
  426. },
  427. requestPermission: async () => {
  428. blockedRequested.resolve()
  429. return releaseBlocked.promise
  430. },
  431. } satisfies Connection
  432. const blocked = startTurn(fixture, connection, "ses_blocked", "input_blocked")
  433. const free = startTurn(fixture, connection, "ses_free", "input_free")
  434. try {
  435. await withTimeout(blockedRequested.promise, "blocked permission was not requested")
  436. const response = await withTimeout(free, "free session was stalled by another session's permission")
  437. expect(response).toMatchObject({ stopReason: "end_turn" })
  438. expect(updates).toContainEqual({
  439. sessionId: "ses_free",
  440. update: {
  441. sessionUpdate: "agent_message_chunk",
  442. messageId: "msg_free",
  443. content: { type: "text", text: "session B continued" },
  444. },
  445. })
  446. expect(permissionReplies(fixture)).toEqual([])
  447. releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
  448. await withTimeout(blocked, "blocked session did not resume after permission selection")
  449. expect(permissionReplies(fixture)).toEqual([["perm_blocked", "once"]])
  450. } finally {
  451. releaseBlocked.resolve({ outcome: { outcome: "cancelled" } })
  452. await Promise.all([blocked.catch(() => undefined), free.catch(() => undefined)])
  453. await fixture.stop()
  454. }
  455. })
  456. })
  457. function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inputID: string, cwd = "/workspace") {
  458. return streamTurn({
  459. client: fixture.client,
  460. connection,
  461. sessionID,
  462. cwd,
  463. start: { type: "input", id: inputID },
  464. writeTextFile: true,
  465. control: { cancelled: false, admission: new AbortController() },
  466. submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
  467. })
  468. }
  469. function permissionAsked(
  470. sessionID: string,
  471. id: string,
  472. input: {
  473. readonly action?: string
  474. readonly metadata?: Record<string, unknown>
  475. readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
  476. } = {},
  477. ) {
  478. return ephemeralEvent("permission.asked", {
  479. id,
  480. sessionID,
  481. action: input.action ?? "shell",
  482. resources: ["*"],
  483. metadata: input.metadata ?? { command: "printf hello" },
  484. ...(input.source ? { source: input.source } : {}),
  485. })
  486. }
  487. function permissionReplies(fixture: Fixture) {
  488. return fixture.requests.flatMap((request): Array<[string, string]> => {
  489. const match = /^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/.exec(request.path)
  490. if (!match?.[1] || !request.body || typeof request.body !== "object") return []
  491. const reply = Reflect.get(request.body, "reply")
  492. return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
  493. })
  494. }