read.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import { describe, expect, test } from "bun:test"
  2. import path from "path"
  3. import { ReadTool } from "../../src/tool/read"
  4. import { Instance } from "../../src/project/instance"
  5. import { tmpdir } from "../fixture/fixture"
  6. import { PermissionNext } from "../../src/permission/next"
  7. import { Agent } from "../../src/agent/agent"
  8. const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
  9. const ctx = {
  10. sessionID: "test",
  11. messageID: "",
  12. callID: "",
  13. agent: "build",
  14. abort: AbortSignal.any([]),
  15. metadata: () => {},
  16. ask: async () => {},
  17. }
  18. describe("tool.read external_directory permission", () => {
  19. test("allows reading absolute path inside project directory", async () => {
  20. await using tmp = await tmpdir({
  21. init: async (dir) => {
  22. await Bun.write(path.join(dir, "test.txt"), "hello world")
  23. },
  24. })
  25. await Instance.provide({
  26. directory: tmp.path,
  27. fn: async () => {
  28. const read = await ReadTool.init()
  29. const result = await read.execute({ filePath: path.join(tmp.path, "test.txt") }, ctx)
  30. expect(result.output).toContain("hello world")
  31. },
  32. })
  33. })
  34. test("allows reading file in subdirectory inside project directory", async () => {
  35. await using tmp = await tmpdir({
  36. init: async (dir) => {
  37. await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content")
  38. },
  39. })
  40. await Instance.provide({
  41. directory: tmp.path,
  42. fn: async () => {
  43. const read = await ReadTool.init()
  44. const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "test.txt") }, ctx)
  45. expect(result.output).toContain("nested content")
  46. },
  47. })
  48. })
  49. test("asks for external_directory permission when reading absolute path outside project", async () => {
  50. await using outerTmp = await tmpdir({
  51. init: async (dir) => {
  52. await Bun.write(path.join(dir, "secret.txt"), "secret data")
  53. },
  54. })
  55. await using tmp = await tmpdir({ git: true })
  56. await Instance.provide({
  57. directory: tmp.path,
  58. fn: async () => {
  59. const read = await ReadTool.init()
  60. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  61. const testCtx = {
  62. ...ctx,
  63. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  64. requests.push(req)
  65. },
  66. }
  67. await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
  68. const extDirReq = requests.find((r) => r.permission === "external_directory")
  69. expect(extDirReq).toBeDefined()
  70. expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path))).toBe(true)
  71. },
  72. })
  73. })
  74. test("asks for external_directory permission when reading relative path outside project", async () => {
  75. await using tmp = await tmpdir({ git: true })
  76. await Instance.provide({
  77. directory: tmp.path,
  78. fn: async () => {
  79. const read = await ReadTool.init()
  80. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  81. const testCtx = {
  82. ...ctx,
  83. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  84. requests.push(req)
  85. },
  86. }
  87. // This will fail because file doesn't exist, but we can check if permission was asked
  88. await read.execute({ filePath: "../outside.txt" }, testCtx).catch(() => {})
  89. const extDirReq = requests.find((r) => r.permission === "external_directory")
  90. expect(extDirReq).toBeDefined()
  91. },
  92. })
  93. })
  94. test("does not ask for external_directory permission when reading inside project", async () => {
  95. await using tmp = await tmpdir({
  96. git: true,
  97. init: async (dir) => {
  98. await Bun.write(path.join(dir, "internal.txt"), "internal content")
  99. },
  100. })
  101. await Instance.provide({
  102. directory: tmp.path,
  103. fn: async () => {
  104. const read = await ReadTool.init()
  105. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  106. const testCtx = {
  107. ...ctx,
  108. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  109. requests.push(req)
  110. },
  111. }
  112. await read.execute({ filePath: path.join(tmp.path, "internal.txt") }, testCtx)
  113. const extDirReq = requests.find((r) => r.permission === "external_directory")
  114. expect(extDirReq).toBeUndefined()
  115. },
  116. })
  117. })
  118. })
  119. describe("tool.read env file blocking", () => {
  120. const cases: [string, boolean][] = [
  121. [".env", true],
  122. [".env.local", true],
  123. [".env.production", true],
  124. [".env.development.local", true],
  125. [".env.example", false],
  126. [".envrc", false],
  127. ["environment.ts", false],
  128. ]
  129. describe.each(["build", "plan"])("agent=%s", (agentName) => {
  130. test.each(cases)("%s blocked=%s", async (filename, blocked) => {
  131. await using tmp = await tmpdir({
  132. init: (dir) => Bun.write(path.join(dir, filename), "content"),
  133. })
  134. await Instance.provide({
  135. directory: tmp.path,
  136. fn: async () => {
  137. const agent = await Agent.get(agentName)
  138. const ctxWithPermissions = {
  139. ...ctx,
  140. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  141. for (const pattern of req.patterns) {
  142. const rule = PermissionNext.evaluate(req.permission, pattern, agent.permission)
  143. if (rule.action === "deny") {
  144. throw new PermissionNext.DeniedError(agent.permission)
  145. }
  146. }
  147. },
  148. }
  149. const read = await ReadTool.init()
  150. const promise = read.execute({ filePath: path.join(tmp.path, filename) }, ctxWithPermissions)
  151. if (blocked) {
  152. await expect(promise).rejects.toThrow(PermissionNext.DeniedError)
  153. } else {
  154. expect((await promise).output).toContain("content")
  155. }
  156. },
  157. })
  158. })
  159. })
  160. })
  161. describe("tool.read truncation", () => {
  162. test("truncates large file by bytes and sets truncated metadata", async () => {
  163. await using tmp = await tmpdir({
  164. init: async (dir) => {
  165. const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
  166. await Bun.write(path.join(dir, "large.json"), content)
  167. },
  168. })
  169. await Instance.provide({
  170. directory: tmp.path,
  171. fn: async () => {
  172. const read = await ReadTool.init()
  173. const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx)
  174. expect(result.metadata.truncated).toBe(true)
  175. expect(result.output).toContain("Output truncated at")
  176. expect(result.output).toContain("bytes")
  177. },
  178. })
  179. })
  180. test("truncates by line count when limit is specified", async () => {
  181. await using tmp = await tmpdir({
  182. init: async (dir) => {
  183. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  184. await Bun.write(path.join(dir, "many-lines.txt"), lines)
  185. },
  186. })
  187. await Instance.provide({
  188. directory: tmp.path,
  189. fn: async () => {
  190. const read = await ReadTool.init()
  191. const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
  192. expect(result.metadata.truncated).toBe(true)
  193. expect(result.output).toContain("File has more lines")
  194. expect(result.output).toContain("line0")
  195. expect(result.output).toContain("line9")
  196. expect(result.output).not.toContain("line10")
  197. },
  198. })
  199. })
  200. test("does not truncate small file", async () => {
  201. await using tmp = await tmpdir({
  202. init: async (dir) => {
  203. await Bun.write(path.join(dir, "small.txt"), "hello world")
  204. },
  205. })
  206. await Instance.provide({
  207. directory: tmp.path,
  208. fn: async () => {
  209. const read = await ReadTool.init()
  210. const result = await read.execute({ filePath: path.join(tmp.path, "small.txt") }, ctx)
  211. expect(result.metadata.truncated).toBe(false)
  212. expect(result.output).toContain("End of file")
  213. },
  214. })
  215. })
  216. test("respects offset parameter", async () => {
  217. await using tmp = await tmpdir({
  218. init: async (dir) => {
  219. const lines = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n")
  220. await Bun.write(path.join(dir, "offset.txt"), lines)
  221. },
  222. })
  223. await Instance.provide({
  224. directory: tmp.path,
  225. fn: async () => {
  226. const read = await ReadTool.init()
  227. const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
  228. expect(result.output).toContain("line10")
  229. expect(result.output).toContain("line14")
  230. expect(result.output).not.toContain("line0")
  231. expect(result.output).not.toContain("line15")
  232. },
  233. })
  234. })
  235. test("truncates long lines", async () => {
  236. await using tmp = await tmpdir({
  237. init: async (dir) => {
  238. const longLine = "x".repeat(3000)
  239. await Bun.write(path.join(dir, "long-line.txt"), longLine)
  240. },
  241. })
  242. await Instance.provide({
  243. directory: tmp.path,
  244. fn: async () => {
  245. const read = await ReadTool.init()
  246. const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx)
  247. expect(result.output).toContain("...")
  248. expect(result.output.length).toBeLessThan(3000)
  249. },
  250. })
  251. })
  252. test("image files set truncated to false", async () => {
  253. await using tmp = await tmpdir({
  254. init: async (dir) => {
  255. // 1x1 red PNG
  256. const png = Buffer.from(
  257. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
  258. "base64",
  259. )
  260. await Bun.write(path.join(dir, "image.png"), png)
  261. },
  262. })
  263. await Instance.provide({
  264. directory: tmp.path,
  265. fn: async () => {
  266. const read = await ReadTool.init()
  267. const result = await read.execute({ filePath: path.join(tmp.path, "image.png") }, ctx)
  268. expect(result.metadata.truncated).toBe(false)
  269. expect(result.attachments).toBeDefined()
  270. expect(result.attachments?.length).toBe(1)
  271. },
  272. })
  273. })
  274. test("large image files are properly attached without error", async () => {
  275. await Instance.provide({
  276. directory: FIXTURES_DIR,
  277. fn: async () => {
  278. const read = await ReadTool.init()
  279. const result = await read.execute({ filePath: path.join(FIXTURES_DIR, "large-image.png") }, ctx)
  280. expect(result.metadata.truncated).toBe(false)
  281. expect(result.attachments).toBeDefined()
  282. expect(result.attachments?.length).toBe(1)
  283. expect(result.attachments?.[0].type).toBe("file")
  284. },
  285. })
  286. })
  287. })