bash.test.ts 13 KB

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