bash.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import { describe, expect, test } from "bun:test"
  2. import path from "path"
  3. import { BashTool } from "../../src/tool/bash"
  4. import { Instance } from "../../src/project/instance"
  5. import { tmpdir } from "../fixture/fixture"
  6. import type { PermissionNext } from "../../src/permission/next"
  7. import { Truncate } from "../../src/tool/truncation"
  8. const ctx = {
  9. sessionID: "test",
  10. messageID: "",
  11. callID: "",
  12. agent: "build",
  13. abort: AbortSignal.any([]),
  14. metadata: () => {},
  15. ask: async () => {},
  16. }
  17. const projectRoot = path.join(__dirname, "../..")
  18. describe("tool.bash", () => {
  19. test("basic", async () => {
  20. await Instance.provide({
  21. directory: projectRoot,
  22. fn: async () => {
  23. const bash = await BashTool.init()
  24. const result = await bash.execute(
  25. {
  26. command: "echo 'test'",
  27. description: "Echo test message",
  28. },
  29. ctx,
  30. )
  31. expect(result.metadata.exit).toBe(0)
  32. expect(result.metadata.output).toContain("test")
  33. },
  34. })
  35. })
  36. })
  37. describe("tool.bash permissions", () => {
  38. test("asks for bash permission with correct pattern", async () => {
  39. await using tmp = await tmpdir({ git: true })
  40. await Instance.provide({
  41. directory: tmp.path,
  42. fn: async () => {
  43. const bash = await BashTool.init()
  44. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  45. const testCtx = {
  46. ...ctx,
  47. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  48. requests.push(req)
  49. },
  50. }
  51. await bash.execute(
  52. {
  53. command: "echo hello",
  54. description: "Echo hello",
  55. },
  56. testCtx,
  57. )
  58. expect(requests.length).toBe(1)
  59. expect(requests[0].permission).toBe("bash")
  60. expect(requests[0].patterns).toContain("echo hello")
  61. },
  62. })
  63. })
  64. test("asks for bash permission with multiple commands", async () => {
  65. await using tmp = await tmpdir({ git: true })
  66. await Instance.provide({
  67. directory: tmp.path,
  68. fn: async () => {
  69. const bash = await BashTool.init()
  70. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  71. const testCtx = {
  72. ...ctx,
  73. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  74. requests.push(req)
  75. },
  76. }
  77. await bash.execute(
  78. {
  79. command: "echo foo && echo bar",
  80. description: "Echo twice",
  81. },
  82. testCtx,
  83. )
  84. expect(requests.length).toBe(1)
  85. expect(requests[0].permission).toBe("bash")
  86. expect(requests[0].patterns).toContain("echo foo")
  87. expect(requests[0].patterns).toContain("echo bar")
  88. },
  89. })
  90. })
  91. test("asks for external_directory permission when cd to parent", async () => {
  92. await using tmp = await tmpdir({ git: true })
  93. await Instance.provide({
  94. directory: tmp.path,
  95. fn: async () => {
  96. const bash = await BashTool.init()
  97. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  98. const testCtx = {
  99. ...ctx,
  100. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  101. requests.push(req)
  102. },
  103. }
  104. await bash.execute(
  105. {
  106. command: "cd ../",
  107. description: "Change to parent directory",
  108. },
  109. testCtx,
  110. )
  111. const extDirReq = requests.find((r) => r.permission === "external_directory")
  112. expect(extDirReq).toBeDefined()
  113. },
  114. })
  115. })
  116. test("asks for external_directory permission when workdir is outside project", async () => {
  117. await using tmp = await tmpdir({ git: true })
  118. await Instance.provide({
  119. directory: tmp.path,
  120. fn: async () => {
  121. const bash = await BashTool.init()
  122. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  123. const testCtx = {
  124. ...ctx,
  125. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  126. requests.push(req)
  127. },
  128. }
  129. await bash.execute(
  130. {
  131. command: "ls",
  132. workdir: "/tmp",
  133. description: "List /tmp",
  134. },
  135. testCtx,
  136. )
  137. const extDirReq = requests.find((r) => r.permission === "external_directory")
  138. expect(extDirReq).toBeDefined()
  139. expect(extDirReq!.patterns).toContain("/tmp")
  140. },
  141. })
  142. })
  143. test("does not ask for external_directory permission when rm inside project", async () => {
  144. await using tmp = await tmpdir({ git: true })
  145. await Instance.provide({
  146. directory: tmp.path,
  147. fn: async () => {
  148. const bash = await BashTool.init()
  149. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  150. const testCtx = {
  151. ...ctx,
  152. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  153. requests.push(req)
  154. },
  155. }
  156. await Bun.write(path.join(tmp.path, "tmpfile"), "x")
  157. await bash.execute(
  158. {
  159. command: "rm tmpfile",
  160. description: "Remove tmpfile",
  161. },
  162. testCtx,
  163. )
  164. const extDirReq = requests.find((r) => r.permission === "external_directory")
  165. expect(extDirReq).toBeUndefined()
  166. },
  167. })
  168. })
  169. test("includes always patterns for auto-approval", async () => {
  170. await using tmp = await tmpdir({ git: true })
  171. await Instance.provide({
  172. directory: tmp.path,
  173. fn: async () => {
  174. const bash = await BashTool.init()
  175. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  176. const testCtx = {
  177. ...ctx,
  178. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  179. requests.push(req)
  180. },
  181. }
  182. await bash.execute(
  183. {
  184. command: "git log --oneline -5",
  185. description: "Git log",
  186. },
  187. testCtx,
  188. )
  189. expect(requests.length).toBe(1)
  190. expect(requests[0].always.length).toBeGreaterThan(0)
  191. expect(requests[0].always.some((p) => p.endsWith("*"))).toBe(true)
  192. },
  193. })
  194. })
  195. test("does not ask for bash permission when command is cd only", async () => {
  196. await using tmp = await tmpdir({ git: true })
  197. await Instance.provide({
  198. directory: tmp.path,
  199. fn: async () => {
  200. const bash = await BashTool.init()
  201. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  202. const testCtx = {
  203. ...ctx,
  204. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  205. requests.push(req)
  206. },
  207. }
  208. await bash.execute(
  209. {
  210. command: "cd .",
  211. description: "Stay in current directory",
  212. },
  213. testCtx,
  214. )
  215. const bashReq = requests.find((r) => r.permission === "bash")
  216. expect(bashReq).toBeUndefined()
  217. },
  218. })
  219. })
  220. })
  221. describe("tool.bash truncation", () => {
  222. test("truncates output exceeding line limit", async () => {
  223. await Instance.provide({
  224. directory: projectRoot,
  225. fn: async () => {
  226. const bash = await BashTool.init()
  227. const lineCount = Truncate.MAX_LINES + 500
  228. const result = await bash.execute(
  229. {
  230. command: `seq 1 ${lineCount}`,
  231. description: "Generate lines exceeding limit",
  232. },
  233. ctx,
  234. )
  235. expect((result.metadata as any).truncated).toBe(true)
  236. expect(result.output).toContain("truncated")
  237. expect(result.output).toContain("The tool call succeeded but the output was truncated")
  238. },
  239. })
  240. })
  241. test("truncates output exceeding byte limit", async () => {
  242. await Instance.provide({
  243. directory: projectRoot,
  244. fn: async () => {
  245. const bash = await BashTool.init()
  246. const byteCount = Truncate.MAX_BYTES + 10000
  247. const result = await bash.execute(
  248. {
  249. command: `head -c ${byteCount} /dev/zero | tr '\\0' 'a'`,
  250. description: "Generate bytes exceeding limit",
  251. },
  252. ctx,
  253. )
  254. expect((result.metadata as any).truncated).toBe(true)
  255. expect(result.output).toContain("truncated")
  256. expect(result.output).toContain("The tool call succeeded but the output was truncated")
  257. },
  258. })
  259. })
  260. test("does not truncate small output", async () => {
  261. await Instance.provide({
  262. directory: projectRoot,
  263. fn: async () => {
  264. const bash = await BashTool.init()
  265. const result = await bash.execute(
  266. {
  267. command: "echo hello",
  268. description: "Echo hello",
  269. },
  270. ctx,
  271. )
  272. expect((result.metadata as any).truncated).toBe(false)
  273. expect(result.output).toBe("hello\n")
  274. },
  275. })
  276. })
  277. test("full output is saved to file when truncated", async () => {
  278. await Instance.provide({
  279. directory: projectRoot,
  280. fn: async () => {
  281. const bash = await BashTool.init()
  282. const lineCount = Truncate.MAX_LINES + 100
  283. const result = await bash.execute(
  284. {
  285. command: `seq 1 ${lineCount}`,
  286. description: "Generate lines for file check",
  287. },
  288. ctx,
  289. )
  290. expect((result.metadata as any).truncated).toBe(true)
  291. const filepath = (result.metadata as any).outputPath
  292. expect(filepath).toBeTruthy()
  293. const saved = await Bun.file(filepath).text()
  294. const lines = saved.trim().split("\n")
  295. expect(lines.length).toBe(lineCount)
  296. expect(lines[0]).toBe("1")
  297. expect(lines[lineCount - 1]).toBe(String(lineCount))
  298. },
  299. })
  300. })
  301. })