bash.test.ts 41 KB

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