read.test.ts 17 KB

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