bash.test.ts 11 KB

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