bash.test.ts 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Layer, ManagedRuntime } from "effect"
  3. import os from "os"
  4. import path from "path"
  5. import { Shell } from "../../src/shell/shell"
  6. import { BashTool } from "../../src/tool/bash"
  7. import { Instance } from "../../src/project/instance"
  8. import { Filesystem } from "../../src/util/filesystem"
  9. import { tmpdir } from "../fixture/fixture"
  10. import type { Permission } from "../../src/permission"
  11. import { Agent } from "../../src/agent/agent"
  12. import { Truncate } from "../../src/tool/truncate"
  13. import { SessionID, MessageID } from "../../src/session/schema"
  14. import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
  15. import { AppFileSystem } from "../../src/filesystem"
  16. import { Plugin } from "../../src/plugin"
  17. const runtime = ManagedRuntime.make(
  18. Layer.mergeAll(
  19. CrossSpawnSpawner.defaultLayer,
  20. AppFileSystem.defaultLayer,
  21. Plugin.defaultLayer,
  22. Truncate.defaultLayer,
  23. Agent.defaultLayer,
  24. ),
  25. )
  26. function initBash() {
  27. return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init())))
  28. }
  29. const ctx = {
  30. sessionID: SessionID.make("ses_test"),
  31. messageID: MessageID.make(""),
  32. callID: "",
  33. agent: "code", // kilocode_change
  34. abort: AbortSignal.any([]),
  35. messages: [],
  36. metadata: () => Effect.void,
  37. ask: () => Effect.void,
  38. }
  39. Shell.acceptable.reset()
  40. const quote = (text: string) => `"${text}"`
  41. const squote = (text: string) => `'${text}'`
  42. const projectRoot = path.join(__dirname, "../..")
  43. const bin = quote(process.execPath.replaceAll("\\", "/"))
  44. const bash = (() => {
  45. const shell = Shell.acceptable()
  46. if (Shell.name(shell) === "bash") return shell
  47. return Shell.gitbash()
  48. })()
  49. const shells = (() => {
  50. if (process.platform !== "win32") {
  51. const shell = Shell.acceptable()
  52. return [{ label: Shell.name(shell), shell }]
  53. }
  54. const list = [bash, Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || Bun.which("cmd.exe")]
  55. .filter((shell): shell is string => Boolean(shell))
  56. .map((shell) => ({ label: Shell.name(shell), shell }))
  57. return list.filter(
  58. (item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i,
  59. )
  60. })()
  61. const PS = new Set(["pwsh", "powershell"])
  62. const ps = shells.filter((item) => PS.has(item.label))
  63. const sh = () => Shell.name(Shell.acceptable())
  64. const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text))
  65. const fill = (mode: "lines" | "bytes", n: number) => {
  66. const code =
  67. mode === "lines"
  68. ? "console.log(Array.from({length:Number(Bun.argv[1])},(_,i)=>i+1).join(String.fromCharCode(10)))"
  69. : "process.stdout.write(String.fromCharCode(97).repeat(Number(Bun.argv[1])))"
  70. const text = `${bin} -e ${evalarg(code)} ${n}`
  71. if (PS.has(sh())) return `& ${text}`
  72. return text
  73. }
  74. const glob = (p: string) =>
  75. process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
  76. const forms = (dir: string) => {
  77. if (process.platform !== "win32") return [dir]
  78. const full = Filesystem.normalizePath(dir)
  79. const slash = full.replaceAll("\\", "/")
  80. const root = slash.replace(/^[A-Za-z]:/, "")
  81. return Array.from(new Set([full, slash, root, root.toLowerCase()]))
  82. }
  83. const withShell = (item: { label: string; shell: string }, fn: () => Promise<void>) => async () => {
  84. const prev = process.env.SHELL
  85. process.env.SHELL = item.shell
  86. Shell.acceptable.reset()
  87. Shell.preferred.reset()
  88. try {
  89. await fn()
  90. } finally {
  91. if (prev === undefined) delete process.env.SHELL
  92. else process.env.SHELL = prev
  93. Shell.acceptable.reset()
  94. Shell.preferred.reset()
  95. }
  96. }
  97. const each = (name: string, fn: (item: { label: string; shell: string }) => Promise<void>) => {
  98. for (const item of shells) {
  99. test(
  100. `${name} [${item.label}]`,
  101. withShell(item, () => fn(item)),
  102. )
  103. }
  104. }
  105. const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
  106. ...ctx,
  107. ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
  108. Effect.sync(() => {
  109. requests.push(req)
  110. if (stop) throw stop
  111. }),
  112. })
  113. const mustTruncate = (result: {
  114. metadata: { truncated?: boolean; exit?: number | null } & Record<string, unknown>
  115. output: string
  116. }) => {
  117. if (result.metadata.truncated) return
  118. throw new Error(
  119. [`shell: ${process.env.SHELL || ""}`, `exit: ${String(result.metadata.exit)}`, "output:", result.output].join("\n"),
  120. )
  121. }
  122. describe("tool.bash", () => {
  123. each("basic", async () => {
  124. await Instance.provide({
  125. directory: projectRoot,
  126. fn: async () => {
  127. const bash = await initBash()
  128. const result = await Effect.runPromise(
  129. bash.execute(
  130. {
  131. command: "echo test",
  132. description: "Echo test message",
  133. },
  134. ctx,
  135. ),
  136. )
  137. expect(result.metadata.exit).toBe(0)
  138. expect(result.metadata.output).toContain("test")
  139. },
  140. })
  141. })
  142. })
  143. describe("tool.bash permissions", () => {
  144. each("asks for bash permission with correct pattern", async () => {
  145. await using tmp = await tmpdir()
  146. await Instance.provide({
  147. directory: tmp.path,
  148. fn: async () => {
  149. const bash = await initBash()
  150. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  151. await Effect.runPromise(
  152. bash.execute(
  153. {
  154. command: "echo hello",
  155. description: "Echo hello",
  156. },
  157. capture(requests),
  158. ),
  159. )
  160. expect(requests.length).toBe(1)
  161. expect(requests[0].permission).toBe("bash")
  162. expect(requests[0].patterns).toContain("echo hello")
  163. },
  164. })
  165. })
  166. each("asks for bash permission with multiple commands", async () => {
  167. await using tmp = await tmpdir()
  168. await Instance.provide({
  169. directory: tmp.path,
  170. fn: async () => {
  171. const bash = await initBash()
  172. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  173. await Effect.runPromise(
  174. bash.execute(
  175. {
  176. command: "echo foo && echo bar",
  177. description: "Echo twice",
  178. },
  179. capture(requests),
  180. ),
  181. )
  182. expect(requests.length).toBe(1)
  183. expect(requests[0].permission).toBe("bash")
  184. expect(requests[0].patterns).toContain("echo foo")
  185. expect(requests[0].patterns).toContain("echo bar")
  186. },
  187. })
  188. })
  189. for (const item of ps) {
  190. test(
  191. `parses PowerShell conditionals for permission prompts [${item.label}]`,
  192. withShell(item, async () => {
  193. await Instance.provide({
  194. directory: projectRoot,
  195. fn: async () => {
  196. const bash = await initBash()
  197. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  198. await Effect.runPromise(
  199. bash.execute(
  200. {
  201. command: "Write-Host foo; if ($?) { Write-Host bar }",
  202. description: "Check PowerShell conditional",
  203. },
  204. capture(requests),
  205. ),
  206. )
  207. const bashReq = requests.find((r) => r.permission === "bash")
  208. expect(bashReq).toBeDefined()
  209. expect(bashReq!.patterns).toContain("Write-Host foo")
  210. expect(bashReq!.patterns).toContain("Write-Host bar")
  211. expect(bashReq!.always).toContain("Write-Host *")
  212. },
  213. })
  214. }),
  215. )
  216. }
  217. each("asks for external_directory permission for wildcard external paths", async () => {
  218. await Instance.provide({
  219. directory: projectRoot,
  220. fn: async () => {
  221. const bash = await initBash()
  222. const err = new Error("stop after permission")
  223. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  224. const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
  225. const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*"
  226. await expect(
  227. Effect.runPromise(
  228. bash.execute(
  229. {
  230. command: `cat ${file}`,
  231. description: "Read wildcard path",
  232. },
  233. capture(requests, err),
  234. ),
  235. ),
  236. ).rejects.toThrow(err.message)
  237. const extDirReq = requests.find((r) => r.permission === "external_directory")
  238. expect(extDirReq).toBeDefined()
  239. expect(extDirReq!.patterns).toContain(want)
  240. },
  241. })
  242. })
  243. if (process.platform === "win32") {
  244. if (bash) {
  245. test(
  246. "asks for nested bash command permissions [bash]",
  247. withShell({ label: "bash", shell: bash }, async () => {
  248. await using outerTmp = await tmpdir({
  249. init: async (dir) => {
  250. await Bun.write(path.join(dir, "outside.txt"), "x")
  251. },
  252. })
  253. await Instance.provide({
  254. directory: projectRoot,
  255. fn: async () => {
  256. const bash = await initBash()
  257. const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/")
  258. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  259. await Effect.runPromise(
  260. bash.execute(
  261. {
  262. command: `echo $(cat "${file}")`,
  263. description: "Read nested bash file",
  264. },
  265. capture(requests),
  266. ),
  267. )
  268. const extDirReq = requests.find((r) => r.permission === "external_directory")
  269. const bashReq = requests.find((r) => r.permission === "bash")
  270. expect(extDirReq).toBeDefined()
  271. expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*")))
  272. expect(bashReq).toBeDefined()
  273. expect(bashReq!.patterns).toContain(`cat "${file}"`)
  274. },
  275. })
  276. }),
  277. )
  278. }
  279. }
  280. if (process.platform === "win32") {
  281. for (const item of ps) {
  282. test(
  283. `asks for external_directory permission for PowerShell paths after switches [${item.label}]`,
  284. withShell(item, async () => {
  285. await Instance.provide({
  286. directory: projectRoot,
  287. fn: async () => {
  288. const bash = await initBash()
  289. const err = new Error("stop after permission")
  290. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  291. await expect(
  292. Effect.runPromise(
  293. bash.execute(
  294. {
  295. command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
  296. description: "Copy Windows ini",
  297. },
  298. capture(requests, err),
  299. ),
  300. ),
  301. ).rejects.toThrow(err.message)
  302. const extDirReq = requests.find((r) => r.permission === "external_directory")
  303. expect(extDirReq).toBeDefined()
  304. expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*")))
  305. },
  306. })
  307. }),
  308. )
  309. }
  310. for (const item of ps) {
  311. test(
  312. `asks for nested PowerShell command permissions [${item.label}]`,
  313. withShell(item, async () => {
  314. await Instance.provide({
  315. directory: projectRoot,
  316. fn: async () => {
  317. const bash = await initBash()
  318. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  319. const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
  320. await Effect.runPromise(
  321. bash.execute(
  322. {
  323. command: `Write-Output $(Get-Content ${file})`,
  324. description: "Read nested PowerShell file",
  325. },
  326. capture(requests),
  327. ),
  328. )
  329. const extDirReq = requests.find((r) => r.permission === "external_directory")
  330. const bashReq = requests.find((r) => r.permission === "bash")
  331. expect(extDirReq).toBeDefined()
  332. expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*")))
  333. expect(bashReq).toBeDefined()
  334. expect(bashReq!.patterns).toContain(`Get-Content ${file}`)
  335. },
  336. })
  337. }),
  338. )
  339. }
  340. for (const item of ps) {
  341. test(
  342. `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`,
  343. withShell(item, async () => {
  344. await using tmp = await tmpdir()
  345. await Instance.provide({
  346. directory: tmp.path,
  347. fn: async () => {
  348. const bash = await initBash()
  349. const err = new Error("stop after permission")
  350. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  351. await expect(
  352. Effect.runPromise(
  353. bash.execute(
  354. {
  355. command: 'Get-Content "C:../outside.txt"',
  356. description: "Read drive-relative file",
  357. },
  358. capture(requests, err),
  359. ),
  360. ),
  361. ).rejects.toThrow(err.message)
  362. expect(requests[0]?.permission).toBe("external_directory")
  363. if (requests[0]?.permission !== "external_directory") return
  364. expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*")))
  365. },
  366. })
  367. }),
  368. )
  369. }
  370. for (const item of ps) {
  371. test(
  372. `asks for external_directory permission for $HOME PowerShell paths [${item.label}]`,
  373. withShell(item, async () => {
  374. await Instance.provide({
  375. directory: projectRoot,
  376. fn: async () => {
  377. const bash = await initBash()
  378. const err = new Error("stop after permission")
  379. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  380. await expect(
  381. Effect.runPromise(
  382. bash.execute(
  383. {
  384. command: 'Get-Content "$HOME/.ssh/config"',
  385. description: "Read home config",
  386. },
  387. capture(requests, err),
  388. ),
  389. ),
  390. ).rejects.toThrow(err.message)
  391. expect(requests[0]?.permission).toBe("external_directory")
  392. if (requests[0]?.permission !== "external_directory") return
  393. expect(requests[0].patterns).toContain(glob(path.join(os.homedir(), ".ssh", "*")))
  394. },
  395. })
  396. }),
  397. )
  398. }
  399. for (const item of ps) {
  400. test(
  401. `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`,
  402. withShell(item, async () => {
  403. await using tmp = await tmpdir()
  404. await Instance.provide({
  405. directory: tmp.path,
  406. fn: async () => {
  407. const bash = await initBash()
  408. const err = new Error("stop after permission")
  409. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  410. await expect(
  411. Effect.runPromise(
  412. bash.execute(
  413. {
  414. command: 'Get-Content "$PWD/../outside.txt"',
  415. description: "Read pwd-relative file",
  416. },
  417. capture(requests, err),
  418. ),
  419. ),
  420. ).rejects.toThrow(err.message)
  421. expect(requests[0]?.permission).toBe("external_directory")
  422. if (requests[0]?.permission !== "external_directory") return
  423. expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*")))
  424. },
  425. })
  426. }),
  427. )
  428. }
  429. for (const item of ps) {
  430. test(
  431. `asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`,
  432. withShell(item, async () => {
  433. await Instance.provide({
  434. directory: projectRoot,
  435. fn: async () => {
  436. const bash = await initBash()
  437. const err = new Error("stop after permission")
  438. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  439. await expect(
  440. Effect.runPromise(
  441. bash.execute(
  442. {
  443. command: 'Get-Content "$PSHOME/outside.txt"',
  444. description: "Read pshome file",
  445. },
  446. capture(requests, err),
  447. ),
  448. ),
  449. ).rejects.toThrow(err.message)
  450. expect(requests[0]?.permission).toBe("external_directory")
  451. if (requests[0]?.permission !== "external_directory") return
  452. expect(requests[0].patterns).toContain(glob(path.join(path.dirname(item.shell), "*")))
  453. },
  454. })
  455. }),
  456. )
  457. }
  458. for (const item of ps) {
  459. test(
  460. `asks for external_directory permission for missing PowerShell env paths [${item.label}]`,
  461. withShell(item, async () => {
  462. const key = "KILO_TEST_MISSING"
  463. const prev = process.env[key]
  464. delete process.env[key]
  465. try {
  466. await Instance.provide({
  467. directory: projectRoot,
  468. fn: async () => {
  469. const bash = await initBash()
  470. const err = new Error("stop after permission")
  471. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  472. const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
  473. await expect(
  474. Effect.runPromise(
  475. bash.execute(
  476. {
  477. command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
  478. description: "Read Windows ini with missing env",
  479. },
  480. capture(requests, err),
  481. ),
  482. ),
  483. ).rejects.toThrow(err.message)
  484. const extDirReq = requests.find((r) => r.permission === "external_directory")
  485. expect(extDirReq).toBeDefined()
  486. expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*")))
  487. },
  488. })
  489. } finally {
  490. if (prev === undefined) delete process.env[key]
  491. else process.env[key] = prev
  492. }
  493. }),
  494. )
  495. }
  496. for (const item of ps) {
  497. test(
  498. `asks for external_directory permission for PowerShell env paths [${item.label}]`,
  499. withShell(item, async () => {
  500. await Instance.provide({
  501. directory: projectRoot,
  502. fn: async () => {
  503. const bash = await initBash()
  504. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  505. await Effect.runPromise(
  506. bash.execute(
  507. {
  508. command: "Get-Content $env:WINDIR/win.ini",
  509. description: "Read Windows ini from env",
  510. },
  511. capture(requests),
  512. ),
  513. )
  514. const extDirReq = requests.find((r) => r.permission === "external_directory")
  515. expect(extDirReq).toBeDefined()
  516. expect(extDirReq!.patterns).toContain(
  517. Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
  518. )
  519. },
  520. })
  521. }),
  522. )
  523. }
  524. for (const item of ps) {
  525. test(
  526. `asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`,
  527. withShell(item, async () => {
  528. await Instance.provide({
  529. directory: projectRoot,
  530. fn: async () => {
  531. const bash = await initBash()
  532. const err = new Error("stop after permission")
  533. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  534. await expect(
  535. Effect.runPromise(
  536. bash.execute(
  537. {
  538. command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`,
  539. description: "Read Windows ini from FileSystem provider",
  540. },
  541. capture(requests, err),
  542. ),
  543. ),
  544. ).rejects.toThrow(err.message)
  545. expect(requests[0]?.permission).toBe("external_directory")
  546. if (requests[0]?.permission !== "external_directory") return
  547. expect(requests[0].patterns).toContain(
  548. Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
  549. )
  550. },
  551. })
  552. }),
  553. )
  554. }
  555. for (const item of ps) {
  556. test(
  557. `asks for external_directory permission for braced PowerShell env paths [${item.label}]`,
  558. withShell(item, async () => {
  559. await Instance.provide({
  560. directory: projectRoot,
  561. fn: async () => {
  562. const bash = await initBash()
  563. const err = new Error("stop after permission")
  564. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  565. await expect(
  566. Effect.runPromise(
  567. bash.execute(
  568. {
  569. command: "Get-Content ${env:WINDIR}/win.ini",
  570. description: "Read Windows ini from braced env",
  571. },
  572. capture(requests, err),
  573. ),
  574. ),
  575. ).rejects.toThrow(err.message)
  576. expect(requests[0]?.permission).toBe("external_directory")
  577. if (requests[0]?.permission !== "external_directory") return
  578. expect(requests[0].patterns).toContain(
  579. Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
  580. )
  581. },
  582. })
  583. }),
  584. )
  585. }
  586. for (const item of ps) {
  587. test(
  588. `treats Set-Location like cd for permissions [${item.label}]`,
  589. withShell(item, async () => {
  590. await Instance.provide({
  591. directory: projectRoot,
  592. fn: async () => {
  593. const bash = await initBash()
  594. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  595. await Effect.runPromise(
  596. bash.execute(
  597. {
  598. command: "Set-Location C:/Windows",
  599. description: "Change location",
  600. },
  601. capture(requests),
  602. ),
  603. )
  604. const extDirReq = requests.find((r) => r.permission === "external_directory")
  605. const bashReq = requests.find((r) => r.permission === "bash")
  606. expect(extDirReq).toBeDefined()
  607. expect(extDirReq!.patterns).toContain(
  608. Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
  609. )
  610. expect(bashReq).toBeUndefined()
  611. },
  612. })
  613. }),
  614. )
  615. }
  616. for (const item of ps) {
  617. test(
  618. `does not add nested PowerShell expressions to permission prompts [${item.label}]`,
  619. withShell(item, async () => {
  620. await Instance.provide({
  621. directory: projectRoot,
  622. fn: async () => {
  623. const bash = await initBash()
  624. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  625. await Effect.runPromise(
  626. bash.execute(
  627. {
  628. command: "Write-Output ('a' * 3)",
  629. description: "Write repeated text",
  630. },
  631. capture(requests),
  632. ),
  633. )
  634. const bashReq = requests.find((r) => r.permission === "bash")
  635. expect(bashReq).toBeDefined()
  636. expect(bashReq!.patterns).not.toContain("a * 3")
  637. expect(bashReq!.always).not.toContain("a *")
  638. },
  639. })
  640. }),
  641. )
  642. }
  643. }
  644. each("asks for external_directory permission when cd to parent", async () => {
  645. await using tmp = await tmpdir()
  646. await Instance.provide({
  647. directory: tmp.path,
  648. fn: async () => {
  649. const bash = await initBash()
  650. const err = new Error("stop after permission")
  651. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  652. await expect(
  653. Effect.runPromise(
  654. bash.execute(
  655. {
  656. command: "cd ../",
  657. description: "Change to parent directory",
  658. },
  659. capture(requests, err),
  660. ),
  661. ),
  662. ).rejects.toThrow(err.message)
  663. const extDirReq = requests.find((r) => r.permission === "external_directory")
  664. expect(extDirReq).toBeDefined()
  665. },
  666. })
  667. })
  668. each("asks for external_directory permission when workdir is outside project", async () => {
  669. await using tmp = await tmpdir()
  670. await Instance.provide({
  671. directory: tmp.path,
  672. fn: async () => {
  673. const bash = await initBash()
  674. const err = new Error("stop after permission")
  675. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  676. await expect(
  677. Effect.runPromise(
  678. bash.execute(
  679. {
  680. command: "echo ok",
  681. workdir: os.tmpdir(),
  682. description: "Echo from temp dir",
  683. },
  684. capture(requests, err),
  685. ),
  686. ),
  687. ).rejects.toThrow(err.message)
  688. const extDirReq = requests.find((r) => r.permission === "external_directory")
  689. expect(extDirReq).toBeDefined()
  690. expect(extDirReq!.patterns).toContain(glob(path.join(os.tmpdir(), "*")))
  691. },
  692. })
  693. })
  694. if (process.platform === "win32") {
  695. test("normalizes external_directory workdir variants on Windows", async () => {
  696. const err = new Error("stop after permission")
  697. await using outerTmp = await tmpdir()
  698. await using tmp = await tmpdir()
  699. await Instance.provide({
  700. directory: tmp.path,
  701. fn: async () => {
  702. const bash = await initBash()
  703. const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*"))
  704. for (const dir of forms(outerTmp.path)) {
  705. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  706. await expect(
  707. Effect.runPromise(
  708. bash.execute(
  709. {
  710. command: "echo ok",
  711. workdir: dir,
  712. description: "Echo from external dir",
  713. },
  714. capture(requests, err),
  715. ),
  716. ),
  717. ).rejects.toThrow(err.message)
  718. const extDirReq = requests.find((r) => r.permission === "external_directory")
  719. expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({
  720. dir,
  721. patterns: [want],
  722. always: [want],
  723. })
  724. }
  725. },
  726. })
  727. })
  728. if (bash) {
  729. test(
  730. "uses Git Bash /tmp semantics for external workdir",
  731. withShell({ label: "bash", shell: bash }, async () => {
  732. await Instance.provide({
  733. directory: projectRoot,
  734. fn: async () => {
  735. const bash = await initBash()
  736. const err = new Error("stop after permission")
  737. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  738. const want = glob(path.join(os.tmpdir(), "*"))
  739. await expect(
  740. Effect.runPromise(
  741. bash.execute(
  742. {
  743. command: "echo ok",
  744. workdir: "/tmp",
  745. description: "Echo from Git Bash tmp",
  746. },
  747. capture(requests, err),
  748. ),
  749. ),
  750. ).rejects.toThrow(err.message)
  751. expect(requests[0]).toMatchObject({
  752. permission: "external_directory",
  753. patterns: [want],
  754. always: [want],
  755. })
  756. },
  757. })
  758. }),
  759. )
  760. test(
  761. "uses Git Bash /tmp semantics for external file paths",
  762. withShell({ label: "bash", shell: bash }, async () => {
  763. await Instance.provide({
  764. directory: projectRoot,
  765. fn: async () => {
  766. const bash = await initBash()
  767. const err = new Error("stop after permission")
  768. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  769. const want = glob(path.join(os.tmpdir(), "*"))
  770. await expect(
  771. Effect.runPromise(
  772. bash.execute(
  773. {
  774. command: "cat /tmp/opencode-does-not-exist",
  775. description: "Read Git Bash tmp file",
  776. },
  777. capture(requests, err),
  778. ),
  779. ),
  780. ).rejects.toThrow(err.message)
  781. expect(requests[0]).toMatchObject({
  782. permission: "external_directory",
  783. patterns: [want],
  784. always: [want],
  785. })
  786. },
  787. })
  788. }),
  789. )
  790. }
  791. }
  792. each("asks for external_directory permission when file arg is outside project", async () => {
  793. await using outerTmp = await tmpdir({
  794. init: async (dir) => {
  795. await Bun.write(path.join(dir, "outside.txt"), "x")
  796. },
  797. })
  798. await using tmp = await tmpdir()
  799. await Instance.provide({
  800. directory: tmp.path,
  801. fn: async () => {
  802. const bash = await initBash()
  803. const err = new Error("stop after permission")
  804. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  805. const filepath = path.join(outerTmp.path, "outside.txt")
  806. await expect(
  807. Effect.runPromise(
  808. bash.execute(
  809. {
  810. command: `cat ${filepath}`,
  811. description: "Read external file",
  812. },
  813. capture(requests, err),
  814. ),
  815. ),
  816. ).rejects.toThrow(err.message)
  817. const extDirReq = requests.find((r) => r.permission === "external_directory")
  818. const expected = glob(path.join(outerTmp.path, "*"))
  819. expect(extDirReq).toBeDefined()
  820. expect(extDirReq!.patterns).toContain(expected)
  821. expect(extDirReq!.always).toContain(expected)
  822. },
  823. })
  824. })
  825. each("does not ask for external_directory permission when rm inside project", async () => {
  826. await using tmp = await tmpdir({
  827. init: async (dir) => {
  828. await Bun.write(path.join(dir, "tmpfile"), "x")
  829. },
  830. })
  831. await Instance.provide({
  832. directory: tmp.path,
  833. fn: async () => {
  834. const bash = await initBash()
  835. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  836. await Effect.runPromise(
  837. bash.execute(
  838. {
  839. command: `rm -rf ${path.join(tmp.path, "nested")}`,
  840. description: "Remove nested dir",
  841. },
  842. capture(requests),
  843. ),
  844. )
  845. const extDirReq = requests.find((r) => r.permission === "external_directory")
  846. expect(extDirReq).toBeUndefined()
  847. },
  848. })
  849. })
  850. each("includes always patterns for auto-approval", async () => {
  851. await using tmp = await tmpdir()
  852. await Instance.provide({
  853. directory: tmp.path,
  854. fn: async () => {
  855. const bash = await initBash()
  856. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  857. await Effect.runPromise(
  858. bash.execute(
  859. {
  860. command: "git log --oneline -5",
  861. description: "Git log",
  862. },
  863. capture(requests),
  864. ),
  865. )
  866. expect(requests.length).toBe(1)
  867. expect(requests[0].always.length).toBeGreaterThan(0)
  868. expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true)
  869. },
  870. })
  871. })
  872. each("does not ask for bash permission when command is cd only", async () => {
  873. await using tmp = await tmpdir()
  874. await Instance.provide({
  875. directory: tmp.path,
  876. fn: async () => {
  877. const bash = await initBash()
  878. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  879. await Effect.runPromise(
  880. bash.execute(
  881. {
  882. command: "cd .",
  883. description: "Stay in current directory",
  884. },
  885. capture(requests),
  886. ),
  887. )
  888. const bashReq = requests.find((r) => r.permission === "bash")
  889. expect(bashReq).toBeUndefined()
  890. },
  891. })
  892. })
  893. each("matches redirects in permission pattern", async () => {
  894. await using tmp = await tmpdir()
  895. await Instance.provide({
  896. directory: tmp.path,
  897. fn: async () => {
  898. const bash = await initBash()
  899. const err = new Error("stop after permission")
  900. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  901. await expect(
  902. Effect.runPromise(
  903. bash.execute(
  904. { command: "echo test > output.txt", description: "Redirect test output" },
  905. capture(requests, err),
  906. ),
  907. ),
  908. ).rejects.toThrow(err.message)
  909. const bashReq = requests.find((r) => r.permission === "bash")
  910. expect(bashReq).toBeDefined()
  911. expect(bashReq!.patterns).toContain("echo test > output.txt")
  912. },
  913. })
  914. })
  915. each("always pattern has space before wildcard to not include different commands", async () => {
  916. await using tmp = await tmpdir()
  917. await Instance.provide({
  918. directory: tmp.path,
  919. fn: async () => {
  920. const bash = await initBash()
  921. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  922. await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests)))
  923. const bashReq = requests.find((r) => r.permission === "bash")
  924. expect(bashReq).toBeDefined()
  925. // kilocode_change start — arity prefix produces "ls *" with space before wildcard
  926. expect(bashReq!.always).toContain("ls *")
  927. expect(bashReq!.patterns).toContain("ls -la")
  928. // kilocode_change end
  929. },
  930. })
  931. })
  932. })
  933. describe("tool.bash abort", () => {
  934. test("preserves output when aborted", async () => {
  935. await Instance.provide({
  936. directory: projectRoot,
  937. fn: async () => {
  938. const bash = await initBash()
  939. const controller = new AbortController()
  940. const collected: string[] = []
  941. const res = await Effect.runPromise(
  942. bash.execute(
  943. {
  944. command: `echo before && sleep 30`,
  945. description: "Long running command",
  946. },
  947. {
  948. ...ctx,
  949. abort: controller.signal,
  950. metadata: (input) =>
  951. Effect.sync(() => {
  952. const output = (input.metadata as { output?: string })?.output
  953. if (output && output.includes("before") && !controller.signal.aborted) {
  954. collected.push(output)
  955. controller.abort()
  956. }
  957. }),
  958. },
  959. ),
  960. )
  961. expect(res.output).toContain("before")
  962. expect(res.output).toContain("User aborted the command")
  963. expect(collected.length).toBeGreaterThan(0)
  964. },
  965. })
  966. }, 15_000)
  967. test("terminates command on timeout", async () => {
  968. await Instance.provide({
  969. directory: projectRoot,
  970. fn: async () => {
  971. const bash = await initBash()
  972. const result = await Effect.runPromise(
  973. bash.execute(
  974. {
  975. command: `echo started && sleep 60`,
  976. description: "Timeout test",
  977. timeout: 500,
  978. },
  979. ctx,
  980. ),
  981. )
  982. expect(result.output).toContain("started")
  983. expect(result.output).toContain("bash tool terminated command after exceeding timeout")
  984. expect(result.output).toContain("retry with a larger timeout value in milliseconds")
  985. },
  986. })
  987. }, 15_000)
  988. test.skipIf(process.platform === "win32")("captures stderr in output", async () => {
  989. await Instance.provide({
  990. directory: projectRoot,
  991. fn: async () => {
  992. const bash = await initBash()
  993. const result = await Effect.runPromise(
  994. bash.execute(
  995. {
  996. command: `echo stdout_msg && echo stderr_msg >&2`,
  997. description: "Stderr test",
  998. },
  999. ctx,
  1000. ),
  1001. )
  1002. expect(result.output).toContain("stdout_msg")
  1003. expect(result.output).toContain("stderr_msg")
  1004. expect(result.metadata.exit).toBe(0)
  1005. },
  1006. })
  1007. })
  1008. test("returns non-zero exit code", async () => {
  1009. await Instance.provide({
  1010. directory: projectRoot,
  1011. fn: async () => {
  1012. const bash = await initBash()
  1013. const result = await Effect.runPromise(
  1014. bash.execute(
  1015. {
  1016. command: `exit 42`,
  1017. description: "Non-zero exit",
  1018. },
  1019. ctx,
  1020. ),
  1021. )
  1022. expect(result.metadata.exit).toBe(42)
  1023. },
  1024. })
  1025. })
  1026. test("streams metadata updates progressively", async () => {
  1027. await Instance.provide({
  1028. directory: projectRoot,
  1029. fn: async () => {
  1030. const bash = await initBash()
  1031. const updates: string[] = []
  1032. const result = await Effect.runPromise(
  1033. bash.execute(
  1034. {
  1035. command: `echo first && sleep 0.1 && echo second`,
  1036. description: "Streaming test",
  1037. },
  1038. {
  1039. ...ctx,
  1040. metadata: (input) =>
  1041. Effect.sync(() => {
  1042. const output = (input.metadata as { output?: string })?.output
  1043. if (output) updates.push(output)
  1044. }),
  1045. },
  1046. ),
  1047. )
  1048. expect(result.output).toContain("first")
  1049. expect(result.output).toContain("second")
  1050. expect(updates.length).toBeGreaterThan(1)
  1051. },
  1052. })
  1053. })
  1054. })
  1055. describe("tool.bash truncation", () => {
  1056. test("truncates output exceeding line limit", async () => {
  1057. await Instance.provide({
  1058. directory: projectRoot,
  1059. fn: async () => {
  1060. const bash = await initBash()
  1061. const lineCount = Truncate.MAX_LINES + 500
  1062. const result = await Effect.runPromise(
  1063. bash.execute(
  1064. {
  1065. command: fill("lines", lineCount),
  1066. description: "Generate lines exceeding limit",
  1067. },
  1068. ctx,
  1069. ),
  1070. )
  1071. mustTruncate(result)
  1072. expect(result.output).toContain("truncated")
  1073. expect(result.output).toContain("The tool call succeeded but the output was truncated")
  1074. },
  1075. })
  1076. })
  1077. test("truncates output exceeding byte limit", async () => {
  1078. await Instance.provide({
  1079. directory: projectRoot,
  1080. fn: async () => {
  1081. const bash = await initBash()
  1082. const byteCount = Truncate.MAX_BYTES + 10000
  1083. const result = await Effect.runPromise(
  1084. bash.execute(
  1085. {
  1086. command: fill("bytes", byteCount),
  1087. description: "Generate bytes exceeding limit",
  1088. },
  1089. ctx,
  1090. ),
  1091. )
  1092. mustTruncate(result)
  1093. expect(result.output).toContain("truncated")
  1094. expect(result.output).toContain("The tool call succeeded but the output was truncated")
  1095. },
  1096. })
  1097. })
  1098. test("does not truncate small output", async () => {
  1099. await Instance.provide({
  1100. directory: projectRoot,
  1101. fn: async () => {
  1102. const bash = await initBash()
  1103. const result = await Effect.runPromise(
  1104. bash.execute(
  1105. {
  1106. command: "echo hello",
  1107. description: "Echo hello",
  1108. },
  1109. ctx,
  1110. ),
  1111. )
  1112. expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
  1113. expect(result.output).toContain("hello")
  1114. },
  1115. })
  1116. })
  1117. test("full output is saved to file when truncated", async () => {
  1118. await Instance.provide({
  1119. directory: projectRoot,
  1120. fn: async () => {
  1121. const bash = await initBash()
  1122. const lineCount = Truncate.MAX_LINES + 100
  1123. const result = await Effect.runPromise(
  1124. bash.execute(
  1125. {
  1126. command: fill("lines", lineCount),
  1127. description: "Generate lines for file check",
  1128. },
  1129. ctx,
  1130. ),
  1131. )
  1132. mustTruncate(result)
  1133. const filepath = (result.metadata as { outputPath?: string }).outputPath
  1134. expect(filepath).toBeTruthy()
  1135. const saved = await Filesystem.readText(filepath!)
  1136. const lines = saved.trim().split(/\r?\n/)
  1137. expect(lines.length).toBe(lineCount)
  1138. expect(lines[0]).toBe("1")
  1139. expect(lines[lineCount - 1]).toBe(String(lineCount))
  1140. },
  1141. })
  1142. })
  1143. })