read.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  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 "@opencode-ai/shared/filesystem"
  7. import { LSP } from "../../src/lsp"
  8. import { Permission } from "../../src/permission"
  9. import { Instance } from "../../src/project/instance"
  10. import { SessionID, MessageID } from "../../src/session/schema"
  11. import { Instruction } from "../../src/session/instruction"
  12. import { ReadTool } from "../../src/tool/read"
  13. import { Truncate } from "../../src/tool"
  14. import { Tool } from "../../src/tool"
  15. import { Filesystem } from "../../src/util"
  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: "code", // kilocode_change
  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. Instruction.defaultLayer,
  38. LSP.defaultLayer,
  39. Truncate.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. // kilocode_change start - renamed from "build" to "code"
  186. for (const agentName of ["code", "plan"] as const) {
  187. // kilocode_change end
  188. describe(`agent=${agentName}`, () => {
  189. for (const [filename, shouldAsk] of cases) {
  190. it.live(`${filename} asks=${shouldAsk}`, () =>
  191. Effect.gen(function* () {
  192. const dir = yield* tmpdirScoped()
  193. yield* put(path.join(dir, filename), "content")
  194. const asked = yield* provideInstance(dir)(
  195. Effect.gen(function* () {
  196. const agent = yield* Agent.Service
  197. const info = yield* agent.get(agentName)
  198. let asked = false
  199. const next = {
  200. ...ctx,
  201. ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
  202. Effect.sync(() => {
  203. for (const pattern of req.patterns) {
  204. const rule = Permission.evaluate(req.permission, pattern, info.permission)
  205. if (rule.action === "ask" && req.permission === "read") {
  206. asked = true
  207. }
  208. if (rule.action === "deny") {
  209. throw new Permission.DeniedError({ ruleset: info.permission })
  210. }
  211. }
  212. }),
  213. }
  214. yield* run({ filePath: path.join(dir, filename) }, next)
  215. return asked
  216. }),
  217. )
  218. expect(asked).toBe(shouldAsk)
  219. }),
  220. )
  221. }
  222. })
  223. }
  224. })
  225. describe("tool.read truncation", () => {
  226. it.live("truncates large file by bytes and sets truncated metadata", () =>
  227. Effect.gen(function* () {
  228. const dir = yield* tmpdirScoped()
  229. const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
  230. const target = 60 * 1024
  231. const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
  232. yield* put(path.join(dir, "large.json"), content)
  233. const result = yield* exec(dir, { filePath: path.join(dir, "large.json") })
  234. expect(result.metadata.truncated).toBe(true)
  235. expect(result.output).toContain("Output capped at")
  236. expect(result.output).toContain("Use offset=")
  237. }),
  238. )
  239. it.live("truncates by line count when limit is specified", () =>
  240. Effect.gen(function* () {
  241. const dir = yield* tmpdirScoped()
  242. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  243. yield* put(path.join(dir, "many-lines.txt"), lines)
  244. const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 })
  245. expect(result.metadata.truncated).toBe(true)
  246. expect(result.output).toContain("Showing lines 1-10 of 100")
  247. expect(result.output).toContain("Use offset=11")
  248. expect(result.output).toContain("line0")
  249. expect(result.output).toContain("line9")
  250. expect(result.output).not.toContain("line10")
  251. }),
  252. )
  253. it.live("does not truncate small file", () =>
  254. Effect.gen(function* () {
  255. const dir = yield* tmpdirScoped()
  256. yield* put(path.join(dir, "small.txt"), "hello world")
  257. const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
  258. expect(result.metadata.truncated).toBe(false)
  259. expect(result.output).toContain("End of file")
  260. }),
  261. )
  262. it.live("respects offset parameter", () =>
  263. Effect.gen(function* () {
  264. const dir = yield* tmpdirScoped()
  265. const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
  266. yield* put(path.join(dir, "offset.txt"), lines)
  267. const result = yield* exec(dir, { filePath: path.join(dir, "offset.txt"), offset: 10, limit: 5 })
  268. expect(result.output).toContain("10: line10")
  269. expect(result.output).toContain("14: line14")
  270. expect(result.output).not.toContain("9: line10")
  271. expect(result.output).not.toContain("15: line15")
  272. expect(result.output).toContain("line10")
  273. expect(result.output).toContain("line14")
  274. expect(result.output).not.toContain("line0")
  275. expect(result.output).not.toContain("line15")
  276. }),
  277. )
  278. it.live("throws when offset is beyond end of file", () =>
  279. Effect.gen(function* () {
  280. const dir = yield* tmpdirScoped()
  281. const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
  282. yield* put(path.join(dir, "short.txt"), lines)
  283. const err = yield* fail(dir, { filePath: path.join(dir, "short.txt"), offset: 4, limit: 5 })
  284. expect(err.message).toContain("Offset 4 is out of range for this file (3 lines)")
  285. }),
  286. )
  287. it.live("allows reading empty file at default offset", () =>
  288. Effect.gen(function* () {
  289. const dir = yield* tmpdirScoped()
  290. yield* put(path.join(dir, "empty.txt"), "")
  291. const result = yield* exec(dir, { filePath: path.join(dir, "empty.txt") })
  292. expect(result.metadata.truncated).toBe(false)
  293. expect(result.output).toContain("End of file - total 0 lines")
  294. }),
  295. )
  296. it.live("throws when offset > 1 for empty file", () =>
  297. Effect.gen(function* () {
  298. const dir = yield* tmpdirScoped()
  299. yield* put(path.join(dir, "empty.txt"), "")
  300. const err = yield* fail(dir, { filePath: path.join(dir, "empty.txt"), offset: 2 })
  301. expect(err.message).toContain("Offset 2 is out of range for this file (0 lines)")
  302. }),
  303. )
  304. it.live("does not mark final directory page as truncated", () =>
  305. Effect.gen(function* () {
  306. const dir = yield* tmpdirScoped()
  307. yield* Effect.forEach(
  308. Array.from({ length: 10 }, (_, i) => i),
  309. (i) => put(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`),
  310. {
  311. concurrency: "unbounded",
  312. },
  313. )
  314. const result = yield* exec(dir, { filePath: path.join(dir, "dir"), offset: 6, limit: 5 })
  315. expect(result.metadata.truncated).toBe(false)
  316. expect(result.output).not.toContain("Showing 5 of 10 entries")
  317. }),
  318. )
  319. it.live("truncates long lines", () =>
  320. Effect.gen(function* () {
  321. const dir = yield* tmpdirScoped()
  322. yield* put(path.join(dir, "long-line.txt"), "x".repeat(3000))
  323. const result = yield* exec(dir, { filePath: path.join(dir, "long-line.txt") })
  324. expect(result.output).toContain("(line truncated to 2000 chars)")
  325. expect(result.output.length).toBeLessThan(3000)
  326. }),
  327. )
  328. it.live("image files set truncated to false", () =>
  329. Effect.gen(function* () {
  330. const dir = yield* tmpdirScoped()
  331. const png = Buffer.from(
  332. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
  333. "base64",
  334. )
  335. yield* put(path.join(dir, "image.png"), png)
  336. const result = yield* exec(dir, { filePath: path.join(dir, "image.png") })
  337. expect(result.metadata.truncated).toBe(false)
  338. expect(result.attachments).toBeDefined()
  339. expect(result.attachments?.length).toBe(1)
  340. expect(result.attachments?.[0]).not.toHaveProperty("id")
  341. expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
  342. expect(result.attachments?.[0]).not.toHaveProperty("messageID")
  343. }),
  344. )
  345. it.live("detects attachment media from file contents", () =>
  346. Effect.gen(function* () {
  347. const dir = yield* tmpdirScoped()
  348. const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01])
  349. yield* put(path.join(dir, "image.bin"), jpeg)
  350. const result = yield* exec(dir, { filePath: path.join(dir, "image.bin") })
  351. expect(result.output).toBe("Image read successfully")
  352. expect(result.attachments?.[0].mime).toBe("image/jpeg")
  353. expect(result.attachments?.[0].url.startsWith("data:image/jpeg;base64,")).toBe(true)
  354. }),
  355. )
  356. it.live("large image files are properly attached without error", () =>
  357. Effect.gen(function* () {
  358. const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
  359. expect(result.metadata.truncated).toBe(false)
  360. expect(result.attachments).toBeDefined()
  361. expect(result.attachments?.length).toBe(1)
  362. expect(result.attachments?.[0].type).toBe("file")
  363. expect(result.attachments?.[0]).not.toHaveProperty("id")
  364. expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
  365. expect(result.attachments?.[0]).not.toHaveProperty("messageID")
  366. }),
  367. )
  368. it.live(".fbs files (FlatBuffers schema) are read as text, not images", () =>
  369. Effect.gen(function* () {
  370. const dir = yield* tmpdirScoped()
  371. const fbs = `namespace MyGame;
  372. table Monster {
  373. pos:Vec3;
  374. name:string;
  375. inventory:[ubyte];
  376. }
  377. root_type Monster;`
  378. yield* put(path.join(dir, "schema.fbs"), fbs)
  379. const result = yield* exec(dir, { filePath: path.join(dir, "schema.fbs") })
  380. expect(result.attachments).toBeUndefined()
  381. expect(result.output).toContain("namespace MyGame")
  382. expect(result.output).toContain("table Monster")
  383. }),
  384. )
  385. })
  386. describe("tool.read loaded instructions", () => {
  387. it.live("loads AGENTS.md from parent directory and includes in metadata", () =>
  388. Effect.gen(function* () {
  389. const dir = yield* tmpdirScoped()
  390. yield* put(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
  391. yield* put(path.join(dir, "subdir", "nested", "test.txt"), "test content")
  392. const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "nested", "test.txt") })
  393. expect(result.output).toContain("test content")
  394. expect(result.output).toContain("system-reminder")
  395. expect(result.output).toContain("Test Instructions")
  396. expect(result.metadata.loaded).toBeDefined()
  397. expect(result.metadata.loaded).toContain(path.join(dir, "subdir", "AGENTS.md"))
  398. }),
  399. )
  400. })
  401. describe("tool.read binary detection", () => {
  402. it.live("rejects text extension files with null bytes", () =>
  403. Effect.gen(function* () {
  404. const dir = yield* tmpdirScoped()
  405. const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
  406. yield* put(path.join(dir, "null-byte.txt"), bytes)
  407. const err = yield* fail(dir, { filePath: path.join(dir, "null-byte.txt") })
  408. expect(err.message).toContain("Cannot read binary file")
  409. }),
  410. )
  411. it.live("rejects known binary extensions", () =>
  412. Effect.gen(function* () {
  413. const dir = yield* tmpdirScoped()
  414. yield* put(path.join(dir, "module.wasm"), "not really wasm")
  415. const err = yield* fail(dir, { filePath: path.join(dir, "module.wasm") })
  416. expect(err.message).toContain("Cannot read binary file")
  417. }),
  418. )
  419. })