read.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { Cause, Effect, Exit, Layer } from "effect"
  3. import path from "path"
  4. import { Agent } from "../../src/agent/agent"
  5. import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
  6. import { AppFileSystem } from "../../src/filesystem"
  7. import { FileTime } from "../../src/file/time"
  8. import { LSP } from "../../src/lsp"
  9. import { Permission } from "../../src/permission"
  10. import { Instance } from "../../src/project/instance"
  11. import { SessionID, MessageID } from "../../src/session/schema"
  12. import { Instruction } from "../../src/session/instruction"
  13. import { ReadTool } from "../../src/tool/read"
  14. import { Tool } from "../../src/tool/tool"
  15. import { Filesystem } from "../../src/util/filesystem"
  16. import { provideInstance, tmpdirScoped } from "../fixture/fixture"
  17. import { testEffect } from "../lib/effect"
  18. const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
  19. afterEach(async () => {
  20. await Instance.disposeAll()
  21. })
  22. const ctx = {
  23. sessionID: SessionID.make("ses_test"),
  24. messageID: MessageID.make(""),
  25. callID: "",
  26. agent: "build",
  27. abort: AbortSignal.any([]),
  28. messages: [],
  29. metadata: () => Effect.void,
  30. ask: () => Effect.void,
  31. }
  32. const it = testEffect(
  33. Layer.mergeAll(
  34. Agent.defaultLayer,
  35. AppFileSystem.defaultLayer,
  36. CrossSpawnSpawner.defaultLayer,
  37. FileTime.defaultLayer,
  38. Instruction.defaultLayer,
  39. LSP.defaultLayer,
  40. ),
  41. )
  42. const init = Effect.fn("ReadToolTest.init")(function* () {
  43. const info = yield* ReadTool
  44. return yield* info.init()
  45. })
  46. const run = Effect.fn("ReadToolTest.run")(function* (
  47. args: Tool.InferParameters<typeof ReadTool>,
  48. next: Tool.Context = ctx,
  49. ) {
  50. const tool = yield* init()
  51. return yield* tool.execute(args, next)
  52. })
  53. const exec = Effect.fn("ReadToolTest.exec")(function* (
  54. dir: string,
  55. args: Tool.InferParameters<typeof ReadTool>,
  56. next: Tool.Context = ctx,
  57. ) {
  58. return yield* provideInstance(dir)(run(args, next))
  59. })
  60. const fail = Effect.fn("ReadToolTest.fail")(function* (
  61. dir: string,
  62. args: Tool.InferParameters<typeof ReadTool>,
  63. next: Tool.Context = ctx,
  64. ) {
  65. const exit = yield* exec(dir, args, next).pipe(Effect.exit)
  66. if (Exit.isFailure(exit)) {
  67. const err = Cause.squash(exit.cause)
  68. return err instanceof Error ? err : new Error(String(err))
  69. }
  70. throw new Error("expected read to fail")
  71. })
  72. const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
  73. const glob = (p: string) =>
  74. process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
  75. const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) {
  76. const fs = yield* AppFileSystem.Service
  77. yield* fs.writeWithDirs(p, content)
  78. })
  79. const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
  80. const fs = yield* AppFileSystem.Service
  81. return yield* fs.readFileString(p)
  82. })
  83. const asks = () => {
  84. const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  85. return {
  86. items,
  87. next: {
  88. ...ctx,
  89. ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
  90. Effect.sync(() => {
  91. items.push(req)
  92. }),
  93. },
  94. }
  95. }
  96. describe("tool.read external_directory permission", () => {
  97. it.live("allows reading absolute path inside project directory", () =>
  98. Effect.gen(function* () {
  99. const dir = yield* tmpdirScoped()
  100. yield* put(path.join(dir, "test.txt"), "hello world")
  101. const result = yield* exec(dir, { filePath: path.join(dir, "test.txt") })
  102. expect(result.output).toContain("hello world")
  103. }),
  104. )
  105. it.live("allows reading file in subdirectory inside project directory", () =>
  106. Effect.gen(function* () {
  107. const dir = yield* tmpdirScoped()
  108. yield* put(path.join(dir, "subdir", "test.txt"), "nested content")
  109. const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "test.txt") })
  110. expect(result.output).toContain("nested content")
  111. }),
  112. )
  113. it.live("asks for external_directory permission when reading absolute path outside project", () =>
  114. Effect.gen(function* () {
  115. const outer = yield* tmpdirScoped()
  116. const dir = yield* tmpdirScoped({ git: true })
  117. yield* put(path.join(outer, "secret.txt"), "secret data")
  118. const { items, next } = asks()
  119. yield* exec(dir, { filePath: path.join(outer, "secret.txt") }, next)
  120. const ext = items.find((item) => item.permission === "external_directory")
  121. expect(ext).toBeDefined()
  122. expect(ext!.patterns).toContain(glob(path.join(outer, "*")))
  123. }),
  124. )
  125. if (process.platform === "win32") {
  126. it.live("normalizes read permission paths on Windows", () =>
  127. Effect.gen(function* () {
  128. const dir = yield* tmpdirScoped({ git: true })
  129. yield* put(path.join(dir, "test.txt"), "hello world")
  130. const { items, next } = asks()
  131. const target = path.join(dir, "test.txt")
  132. const alt = target
  133. .replace(/^[A-Za-z]:/, "")
  134. .replaceAll("\\", "/")
  135. .toLowerCase()
  136. yield* exec(dir, { filePath: alt }, next)
  137. const read = items.find((item) => item.permission === "read")
  138. expect(read).toBeDefined()
  139. expect(read!.patterns).toEqual([full(target)])
  140. }),
  141. )
  142. }
  143. it.live("asks for directory-scoped external_directory permission when reading external directory", () =>
  144. Effect.gen(function* () {
  145. const outer = yield* tmpdirScoped()
  146. const dir = yield* tmpdirScoped({ git: true })
  147. yield* put(path.join(outer, "external", "a.txt"), "a")
  148. const { items, next } = asks()
  149. yield* exec(dir, { filePath: path.join(outer, "external") }, next)
  150. const ext = items.find((item) => item.permission === "external_directory")
  151. expect(ext).toBeDefined()
  152. expect(ext!.patterns).toContain(glob(path.join(outer, "external", "*")))
  153. }),
  154. )
  155. it.live("asks for external_directory permission when reading relative path outside project", () =>
  156. Effect.gen(function* () {
  157. const dir = yield* tmpdirScoped({ git: true })
  158. const { items, next } = asks()
  159. yield* fail(dir, { filePath: "../outside.txt" }, next)
  160. const ext = items.find((item) => item.permission === "external_directory")
  161. expect(ext).toBeDefined()
  162. }),
  163. )
  164. it.live("does not ask for external_directory permission when reading inside project", () =>
  165. Effect.gen(function* () {
  166. const dir = yield* tmpdirScoped({ git: true })
  167. yield* put(path.join(dir, "internal.txt"), "internal content")
  168. const { items, next } = asks()
  169. yield* exec(dir, { filePath: path.join(dir, "internal.txt") }, next)
  170. const ext = items.find((item) => item.permission === "external_directory")
  171. expect(ext).toBeUndefined()
  172. }),
  173. )
  174. })
  175. describe("tool.read env file permissions", () => {
  176. const cases: [string, boolean][] = [
  177. [".env", true],
  178. [".env.local", true],
  179. [".env.production", true],
  180. [".env.development.local", true],
  181. [".env.example", false],
  182. [".envrc", false],
  183. ["environment.ts", false],
  184. ]
  185. for (const agentName of ["build", "plan"] as const) {
  186. describe(`agent=${agentName}`, () => {
  187. for (const [filename, shouldAsk] of cases) {
  188. it.live(`${filename} asks=${shouldAsk}`, () =>
  189. Effect.gen(function* () {
  190. const dir = yield* tmpdirScoped()
  191. yield* put(path.join(dir, filename), "content")
  192. const asked = yield* provideInstance(dir)(
  193. Effect.gen(function* () {
  194. const agent = yield* Agent.Service
  195. const info = yield* agent.get(agentName)
  196. let asked = false
  197. const next = {
  198. ...ctx,
  199. ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
  200. Effect.sync(() => {
  201. for (const pattern of req.patterns) {
  202. const rule = Permission.evaluate(req.permission, pattern, info.permission)
  203. if (rule.action === "ask" && req.permission === "read") {
  204. asked = true
  205. }
  206. if (rule.action === "deny") {
  207. throw new Permission.DeniedError({ ruleset: info.permission })
  208. }
  209. }
  210. }),
  211. }
  212. yield* run({ filePath: path.join(dir, filename) }, next)
  213. return asked
  214. }),
  215. )
  216. expect(asked).toBe(shouldAsk)
  217. }),
  218. )
  219. }
  220. })
  221. }
  222. })
  223. describe("tool.read truncation", () => {
  224. it.live("truncates large file by bytes and sets truncated metadata", () =>
  225. Effect.gen(function* () {
  226. const dir = yield* tmpdirScoped()
  227. const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
  228. const target = 60 * 1024
  229. const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
  230. yield* put(path.join(dir, "large.json"), content)
  231. const result = yield* exec(dir, { filePath: path.join(dir, "large.json") })
  232. expect(result.metadata.truncated).toBe(true)
  233. expect(result.output).toContain("Output capped at")
  234. expect(result.output).toContain("Use offset=")
  235. }),
  236. )
  237. it.live("truncates by line count when limit is specified", () =>
  238. Effect.gen(function* () {
  239. const dir = yield* tmpdirScoped()
  240. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  241. yield* put(path.join(dir, "many-lines.txt"), lines)
  242. const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 })
  243. expect(result.metadata.truncated).toBe(true)
  244. expect(result.output).toContain("Showing lines 1-10 of 100")
  245. expect(result.output).toContain("Use offset=11")
  246. expect(result.output).toContain("line0")
  247. expect(result.output).toContain("line9")
  248. expect(result.output).not.toContain("line10")
  249. }),
  250. )
  251. it.live("does not truncate small file", () =>
  252. Effect.gen(function* () {
  253. const dir = yield* tmpdirScoped()
  254. yield* put(path.join(dir, "small.txt"), "hello world")
  255. const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
  256. expect(result.metadata.truncated).toBe(false)
  257. expect(result.output).toContain("End of file")
  258. }),
  259. )
  260. it.live("respects offset parameter", () =>
  261. Effect.gen(function* () {
  262. const dir = yield* tmpdirScoped()
  263. const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
  264. yield* put(path.join(dir, "offset.txt"), lines)
  265. const result = yield* exec(dir, { filePath: path.join(dir, "offset.txt"), offset: 10, limit: 5 })
  266. expect(result.output).toContain("10: line10")
  267. expect(result.output).toContain("14: line14")
  268. expect(result.output).not.toContain("9: line10")
  269. expect(result.output).not.toContain("15: line15")
  270. expect(result.output).toContain("line10")
  271. expect(result.output).toContain("line14")
  272. expect(result.output).not.toContain("line0")
  273. expect(result.output).not.toContain("line15")
  274. }),
  275. )
  276. it.live("throws when offset is beyond end of file", () =>
  277. Effect.gen(function* () {
  278. const dir = yield* tmpdirScoped()
  279. const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
  280. yield* put(path.join(dir, "short.txt"), lines)
  281. const err = yield* fail(dir, { filePath: path.join(dir, "short.txt"), offset: 4, limit: 5 })
  282. expect(err.message).toContain("Offset 4 is out of range for this file (3 lines)")
  283. }),
  284. )
  285. it.live("allows reading empty file at default offset", () =>
  286. Effect.gen(function* () {
  287. const dir = yield* tmpdirScoped()
  288. yield* put(path.join(dir, "empty.txt"), "")
  289. const result = yield* exec(dir, { filePath: path.join(dir, "empty.txt") })
  290. expect(result.metadata.truncated).toBe(false)
  291. expect(result.output).toContain("End of file - total 0 lines")
  292. }),
  293. )
  294. it.live("throws when offset > 1 for empty file", () =>
  295. Effect.gen(function* () {
  296. const dir = yield* tmpdirScoped()
  297. yield* put(path.join(dir, "empty.txt"), "")
  298. const err = yield* fail(dir, { filePath: path.join(dir, "empty.txt"), offset: 2 })
  299. expect(err.message).toContain("Offset 2 is out of range for this file (0 lines)")
  300. }),
  301. )
  302. it.live("does not mark final directory page as truncated", () =>
  303. Effect.gen(function* () {
  304. const dir = yield* tmpdirScoped()
  305. yield* Effect.forEach(
  306. Array.from({ length: 10 }, (_, i) => i),
  307. (i) => put(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`),
  308. {
  309. concurrency: "unbounded",
  310. },
  311. )
  312. const result = yield* exec(dir, { filePath: path.join(dir, "dir"), offset: 6, limit: 5 })
  313. expect(result.metadata.truncated).toBe(false)
  314. expect(result.output).not.toContain("Showing 5 of 10 entries")
  315. }),
  316. )
  317. it.live("truncates long lines", () =>
  318. Effect.gen(function* () {
  319. const dir = yield* tmpdirScoped()
  320. yield* put(path.join(dir, "long-line.txt"), "x".repeat(3000))
  321. const result = yield* exec(dir, { filePath: path.join(dir, "long-line.txt") })
  322. expect(result.output).toContain("(line truncated to 2000 chars)")
  323. expect(result.output.length).toBeLessThan(3000)
  324. }),
  325. )
  326. it.live("image files set truncated to false", () =>
  327. Effect.gen(function* () {
  328. const dir = yield* tmpdirScoped()
  329. const png = Buffer.from(
  330. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
  331. "base64",
  332. )
  333. yield* put(path.join(dir, "image.png"), png)
  334. const result = yield* exec(dir, { filePath: path.join(dir, "image.png") })
  335. expect(result.metadata.truncated).toBe(false)
  336. expect(result.attachments).toBeDefined()
  337. expect(result.attachments?.length).toBe(1)
  338. expect(result.attachments?.[0]).not.toHaveProperty("id")
  339. expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
  340. expect(result.attachments?.[0]).not.toHaveProperty("messageID")
  341. }),
  342. )
  343. it.live("large image files are properly attached without error", () =>
  344. Effect.gen(function* () {
  345. const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
  346. expect(result.metadata.truncated).toBe(false)
  347. expect(result.attachments).toBeDefined()
  348. expect(result.attachments?.length).toBe(1)
  349. expect(result.attachments?.[0].type).toBe("file")
  350. expect(result.attachments?.[0]).not.toHaveProperty("id")
  351. expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
  352. expect(result.attachments?.[0]).not.toHaveProperty("messageID")
  353. }),
  354. )
  355. it.live(".fbs files (FlatBuffers schema) are read as text, not images", () =>
  356. Effect.gen(function* () {
  357. const dir = yield* tmpdirScoped()
  358. const fbs = `namespace MyGame;
  359. table Monster {
  360. pos:Vec3;
  361. name:string;
  362. inventory:[ubyte];
  363. }
  364. root_type Monster;`
  365. yield* put(path.join(dir, "schema.fbs"), fbs)
  366. const result = yield* exec(dir, { filePath: path.join(dir, "schema.fbs") })
  367. expect(result.attachments).toBeUndefined()
  368. expect(result.output).toContain("namespace MyGame")
  369. expect(result.output).toContain("table Monster")
  370. }),
  371. )
  372. })
  373. describe("tool.read loaded instructions", () => {
  374. it.live("loads AGENTS.md from parent directory and includes in metadata", () =>
  375. Effect.gen(function* () {
  376. const dir = yield* tmpdirScoped()
  377. yield* put(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
  378. yield* put(path.join(dir, "subdir", "nested", "test.txt"), "test content")
  379. const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "nested", "test.txt") })
  380. expect(result.output).toContain("test content")
  381. expect(result.output).toContain("system-reminder")
  382. expect(result.output).toContain("Test Instructions")
  383. expect(result.metadata.loaded).toBeDefined()
  384. expect(result.metadata.loaded).toContain(path.join(dir, "subdir", "AGENTS.md"))
  385. }),
  386. )
  387. })
  388. describe("tool.read binary detection", () => {
  389. it.live("rejects text extension files with null bytes", () =>
  390. Effect.gen(function* () {
  391. const dir = yield* tmpdirScoped()
  392. const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
  393. yield* put(path.join(dir, "null-byte.txt"), bytes)
  394. const err = yield* fail(dir, { filePath: path.join(dir, "null-byte.txt") })
  395. expect(err.message).toContain("Cannot read binary file")
  396. }),
  397. )
  398. it.live("rejects known binary extensions", () =>
  399. Effect.gen(function* () {
  400. const dir = yield* tmpdirScoped()
  401. yield* put(path.join(dir, "module.wasm"), "not really wasm")
  402. const err = yield* fail(dir, { filePath: path.join(dir, "module.wasm") })
  403. expect(err.message).toContain("Cannot read binary file")
  404. }),
  405. )
  406. })