permission-behavior.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  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", id: "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", id: "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("routes foreground child permissions through the parent ACP session", async () => {
  146. const permissionRequests: RequestPermissionRequest[] = []
  147. const fixture = createSseFixture({
  148. onPrompt({ id, send }) {
  149. send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
  150. send(
  151. durableEvent("session.created", {
  152. sessionID: "ses_child",
  153. slug: "ses_child",
  154. projectID: "project",
  155. location: { directory: "/workspace" },
  156. parentID: "ses_parent",
  157. title: "Review code",
  158. version: "test",
  159. }),
  160. )
  161. send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
  162. send(
  163. permissionAsked("ses_child", "perm_child", {
  164. action: "read",
  165. metadata: { path: "/workspace/child.ts" },
  166. source: { type: "tool", messageID: "msg_child", id: "call_child" },
  167. }),
  168. )
  169. send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
  170. send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
  171. },
  172. })
  173. const connection = {
  174. sessionUpdate: async () => {},
  175. requestPermission: async (request) => {
  176. permissionRequests.push(request)
  177. return { outcome: { outcome: "selected", optionId: "once" } } as const
  178. },
  179. } satisfies Connection
  180. try {
  181. await startTurn(fixture, connection, "ses_parent", "input_parent")
  182. expect(permissionRequests).toHaveLength(1)
  183. expect(permissionRequests[0]).toMatchObject({
  184. sessionId: "ses_parent",
  185. toolCall: {
  186. toolCallId: "ses_child:call_child",
  187. title: "Review code: /workspace/child.ts",
  188. },
  189. })
  190. expect(fixture.requests).toContainEqual(
  191. expect.objectContaining({
  192. method: "POST",
  193. path: "/api/session/ses_child/permission/perm_child/reply",
  194. }),
  195. )
  196. } finally {
  197. await fixture.stop()
  198. }
  199. })
  200. test("previews edits during approval and syncs the completed file", async () => {
  201. const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
  202. const file = path.join(cwd, "file.ts")
  203. await fs.writeFile(file, "before")
  204. const permissionRequests: RequestPermissionRequest[] = []
  205. const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
  206. const fixture = createSseFixture({
  207. onPrompt({ id, send }) {
  208. send(durableEvent("session.input.promoted", { sessionID: "ses_edit", inputID: id }))
  209. send(
  210. durableEvent("session.tool.input.started", {
  211. sessionID: "ses_edit",
  212. assistantMessageID: "msg_edit",
  213. id: "call_edit",
  214. name: "edit",
  215. }),
  216. )
  217. send(
  218. durableEvent("session.tool.called", {
  219. sessionID: "ses_edit",
  220. assistantMessageID: "msg_edit",
  221. id: "call_edit",
  222. input: { path: "file.ts", oldString: "before", newString: "after" },
  223. executed: false,
  224. }),
  225. )
  226. send(
  227. permissionAsked("ses_edit", "perm_edit", {
  228. action: "edit",
  229. source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
  230. }),
  231. )
  232. },
  233. async onPermissionReply({ send }) {
  234. await fs.writeFile(file, "after")
  235. send(
  236. durableEvent("session.tool.success", {
  237. sessionID: "ses_edit",
  238. assistantMessageID: "msg_edit",
  239. id: "call_edit",
  240. metadata: { files: [{ file: "file.ts" }], replacements: 1 },
  241. content: [{ type: "text", text: "edited" }],
  242. executed: true,
  243. }),
  244. )
  245. send(durableEvent("session.execution.succeeded", { sessionID: "ses_edit" }))
  246. },
  247. })
  248. const connection = {
  249. sessionUpdate: async () => {},
  250. requestPermission: async (request) => {
  251. permissionRequests.push(request)
  252. return { outcome: { outcome: "selected", optionId: "once" } } as const
  253. },
  254. writeTextFile: async (request) => {
  255. writes.push(request)
  256. return {}
  257. },
  258. } satisfies Connection
  259. try {
  260. await startTurn(fixture, connection, "ses_edit", "input_edit", cwd)
  261. expect(permissionRequests[0]?.toolCall).toMatchObject({
  262. title: "file.ts",
  263. kind: "edit",
  264. locations: [{ path: "file.ts" }],
  265. content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
  266. })
  267. expect(writes).toEqual([{ sessionId: "ses_edit", path: file, content: "after" }])
  268. } finally {
  269. await fixture.stop()
  270. await fs.rm(cwd, { recursive: true, force: true })
  271. }
  272. })
  273. test("previews and syncs each file in a patch", async () => {
  274. const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-patch-permission-"))
  275. await Promise.all([
  276. fs.writeFile(path.join(cwd, "first.ts"), "one\n"),
  277. fs.writeFile(path.join(cwd, "second.ts"), "alpha\n"),
  278. ])
  279. const patchText = [
  280. "*** Begin Patch",
  281. "*** Update File: first.ts",
  282. "@@",
  283. "-one",
  284. "+two",
  285. "*** Update File: second.ts",
  286. "@@",
  287. "-alpha",
  288. "+beta",
  289. "*** End Patch",
  290. ].join("\n")
  291. const permissionRequests: RequestPermissionRequest[] = []
  292. const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
  293. const fixture = createSseFixture({
  294. onPrompt({ id, send }) {
  295. send(durableEvent("session.input.promoted", { sessionID: "ses_patch", inputID: id }))
  296. send(
  297. durableEvent("session.tool.input.started", {
  298. sessionID: "ses_patch",
  299. assistantMessageID: "msg_patch",
  300. id: "call_patch",
  301. name: "patch",
  302. }),
  303. )
  304. send(
  305. durableEvent("session.tool.called", {
  306. sessionID: "ses_patch",
  307. assistantMessageID: "msg_patch",
  308. id: "call_patch",
  309. input: { patchText },
  310. executed: false,
  311. }),
  312. )
  313. send(
  314. permissionAsked("ses_patch", "perm_patch", {
  315. action: "edit",
  316. source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
  317. }),
  318. )
  319. },
  320. async onPermissionReply({ send }) {
  321. await Promise.all([
  322. fs.writeFile(path.join(cwd, "first.ts"), "two\n"),
  323. fs.writeFile(path.join(cwd, "second.ts"), "beta\n"),
  324. ])
  325. send(
  326. durableEvent("session.tool.success", {
  327. sessionID: "ses_patch",
  328. assistantMessageID: "msg_patch",
  329. id: "call_patch",
  330. metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
  331. content: [{ type: "text", text: "patched" }],
  332. executed: true,
  333. }),
  334. )
  335. send(durableEvent("session.execution.succeeded", { sessionID: "ses_patch" }))
  336. },
  337. })
  338. const connection = {
  339. sessionUpdate: async () => {},
  340. requestPermission: async (request) => {
  341. permissionRequests.push(request)
  342. return { outcome: { outcome: "selected", optionId: "once" } } as const
  343. },
  344. writeTextFile: async (request) => {
  345. writes.push(request)
  346. return {}
  347. },
  348. } satisfies Connection
  349. try {
  350. await startTurn(fixture, connection, "ses_patch", "input_patch", cwd)
  351. expect(permissionRequests[0]?.toolCall).toMatchObject({
  352. title: "2 files",
  353. kind: "edit",
  354. locations: [{ path: "first.ts" }, { path: "second.ts" }],
  355. content: [
  356. { type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
  357. { type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
  358. ],
  359. })
  360. expect(writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
  361. { sessionId: "ses_patch", path: path.join(cwd, "first.ts"), content: "two\n" },
  362. { sessionId: "ses_patch", path: path.join(cwd, "second.ts"), content: "beta\n" },
  363. ])
  364. } finally {
  365. await fixture.stop()
  366. await fs.rm(cwd, { recursive: true, force: true })
  367. }
  368. })
  369. test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
  370. const fixture = createSseFixture({
  371. onPrompt({ id, send }) {
  372. send(durableEvent("session.input.promoted", { sessionID: "ses_reject", inputID: id }))
  373. send(permissionAsked("ses_reject", "perm_selected_reject"))
  374. send(permissionAsked("ses_reject", "perm_cancelled"))
  375. send(permissionAsked("ses_reject", "perm_failed"))
  376. send(durableEvent("session.execution.succeeded", { sessionID: "ses_reject" }))
  377. },
  378. })
  379. const connection = {
  380. sessionUpdate: async () => {},
  381. requestPermission: async (request): Promise<RequestPermissionResponse> => {
  382. if (request.toolCall.toolCallId === "perm_selected_reject") {
  383. return { outcome: { outcome: "selected", optionId: "reject" } }
  384. }
  385. if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
  386. throw new Error("client permission UI failed")
  387. },
  388. } satisfies Connection
  389. try {
  390. const response = await startTurn(fixture, connection, "ses_reject", "input_reject")
  391. expect(response).toMatchObject({ stopReason: "end_turn" })
  392. expect(permissionReplies(fixture)).toEqual([
  393. ["perm_selected_reject", "reject"],
  394. ["perm_cancelled", "reject"],
  395. ["perm_failed", "reject"],
  396. ])
  397. } finally {
  398. await fixture.stop()
  399. }
  400. })
  401. test("serializes permission requests and replies within one session", async () => {
  402. const firstRequested = Promise.withResolvers<void>()
  403. const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
  404. const permissionRequests: RequestPermissionRequest[] = []
  405. const fixture = createSseFixture({
  406. onPrompt({ id, send }) {
  407. send(durableEvent("session.input.promoted", { sessionID: "ses_serial", inputID: id }))
  408. send(permissionAsked("ses_serial", "perm_1"))
  409. send(permissionAsked("ses_serial", "perm_2"))
  410. send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
  411. },
  412. })
  413. const connection = {
  414. sessionUpdate: async () => {},
  415. requestPermission: async (request) => {
  416. permissionRequests.push(request)
  417. if (request.toolCall.toolCallId === "perm_1") {
  418. firstRequested.resolve()
  419. return releaseFirst.promise
  420. }
  421. return { outcome: { outcome: "selected", optionId: "always" } } as const
  422. },
  423. } satisfies Connection
  424. const result = startTurn(fixture, connection, "ses_serial", "input_serial")
  425. try {
  426. await withTimeout(firstRequested.promise, "first permission was not requested")
  427. expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
  428. expect(permissionReplies(fixture)).toEqual([])
  429. releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
  430. await withTimeout(result, "serialized permission turn did not finish")
  431. expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
  432. expect(permissionReplies(fixture)).toEqual([
  433. ["perm_1", "once"],
  434. ["perm_2", "always"],
  435. ])
  436. } finally {
  437. releaseFirst.resolve({ outcome: { outcome: "cancelled" } })
  438. await result.catch(() => undefined)
  439. await fixture.stop()
  440. }
  441. })
  442. test("does not let one session's blocked permission stall another session", async () => {
  443. const blockedRequested = Promise.withResolvers<void>()
  444. const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
  445. const promptIDs = new Map<string, string>()
  446. const updates: SessionUpdateParams[] = []
  447. const fixture = createSseFixture({
  448. onPrompt({ sessionID, id, send }) {
  449. promptIDs.set(sessionID, id)
  450. if (promptIDs.size !== 2) return
  451. const blockedID = promptIDs.get("ses_blocked")
  452. const freeID = promptIDs.get("ses_free")
  453. if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
  454. send(durableEvent("session.input.promoted", { sessionID: "ses_blocked", inputID: blockedID }))
  455. send(durableEvent("session.input.promoted", { sessionID: "ses_free", inputID: freeID }))
  456. send(permissionAsked("ses_blocked", "perm_blocked"))
  457. send(
  458. ephemeralEvent("session.text.delta", {
  459. sessionID: "ses_free",
  460. assistantMessageID: "msg_free",
  461. ordinal: 0,
  462. delta: "session B continued",
  463. }),
  464. )
  465. send(
  466. durableEvent("session.step.ended", {
  467. sessionID: "ses_free",
  468. assistantMessageID: "msg_free",
  469. finish: "stop",
  470. cost: 0,
  471. tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
  472. }),
  473. )
  474. send(durableEvent("session.execution.succeeded", { sessionID: "ses_free" }))
  475. send(durableEvent("session.execution.succeeded", { sessionID: "ses_blocked" }))
  476. },
  477. })
  478. const connection = {
  479. sessionUpdate: async (update) => {
  480. updates.push(update)
  481. },
  482. requestPermission: async () => {
  483. blockedRequested.resolve()
  484. return releaseBlocked.promise
  485. },
  486. } satisfies Connection
  487. const blocked = startTurn(fixture, connection, "ses_blocked", "input_blocked")
  488. const free = startTurn(fixture, connection, "ses_free", "input_free")
  489. try {
  490. await withTimeout(blockedRequested.promise, "blocked permission was not requested")
  491. const response = await withTimeout(free, "free session was stalled by another session's permission")
  492. expect(response).toMatchObject({ stopReason: "end_turn" })
  493. expect(updates).toContainEqual({
  494. sessionId: "ses_free",
  495. update: {
  496. sessionUpdate: "agent_message_chunk",
  497. messageId: "msg_free",
  498. content: { type: "text", text: "session B continued" },
  499. },
  500. })
  501. expect(permissionReplies(fixture)).toEqual([])
  502. releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
  503. await withTimeout(blocked, "blocked session did not resume after permission selection")
  504. expect(permissionReplies(fixture)).toEqual([["perm_blocked", "once"]])
  505. } finally {
  506. releaseBlocked.resolve({ outcome: { outcome: "cancelled" } })
  507. await Promise.all([blocked.catch(() => undefined), free.catch(() => undefined)])
  508. await fixture.stop()
  509. }
  510. })
  511. })
  512. function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inputID: string, cwd = "/workspace") {
  513. return streamTurn({
  514. client: fixture.client,
  515. connection,
  516. sessionID,
  517. cwd,
  518. start: { type: "input", id: inputID },
  519. writeTextFile: true,
  520. control: { cancelled: false, admission: new AbortController() },
  521. submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
  522. })
  523. }
  524. function permissionAsked(
  525. sessionID: string,
  526. id: string,
  527. input: {
  528. readonly action?: string
  529. readonly metadata?: Record<string, unknown>
  530. readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
  531. } = {},
  532. ) {
  533. return ephemeralEvent("permission.asked", {
  534. id,
  535. sessionID,
  536. action: input.action ?? "shell",
  537. resources: ["*"],
  538. metadata: input.metadata ?? { command: "printf hello" },
  539. ...(input.source ? { source: input.source } : {}),
  540. })
  541. }
  542. function permissionReplies(fixture: Fixture) {
  543. return fixture.requests.flatMap((request): Array<[string, string]> => {
  544. const match = /^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/.exec(request.path)
  545. if (!match?.[1] || !request.body || typeof request.body !== "object") return []
  546. const reply = Reflect.get(request.body, "reply")
  547. return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
  548. })
  549. }