bash.test.ts 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194
  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: "build",
  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 = "OPENCODE_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. expect(bashReq!.always[0]).toBe("ls *")
  926. },
  927. })
  928. })
  929. })
  930. describe("tool.bash abort", () => {
  931. test("preserves output when aborted", async () => {
  932. await Instance.provide({
  933. directory: projectRoot,
  934. fn: async () => {
  935. const bash = await initBash()
  936. const controller = new AbortController()
  937. const collected: string[] = []
  938. const res = await Effect.runPromise(
  939. bash.execute(
  940. {
  941. command: `echo before && sleep 30`,
  942. description: "Long running command",
  943. },
  944. {
  945. ...ctx,
  946. abort: controller.signal,
  947. metadata: (input) =>
  948. Effect.sync(() => {
  949. const output = (input.metadata as { output?: string })?.output
  950. if (output && output.includes("before") && !controller.signal.aborted) {
  951. collected.push(output)
  952. controller.abort()
  953. }
  954. }),
  955. },
  956. ),
  957. )
  958. expect(res.output).toContain("before")
  959. expect(res.output).toContain("User aborted the command")
  960. expect(collected.length).toBeGreaterThan(0)
  961. },
  962. })
  963. }, 15_000)
  964. test("terminates command on timeout", async () => {
  965. await Instance.provide({
  966. directory: projectRoot,
  967. fn: async () => {
  968. const bash = await initBash()
  969. const result = await Effect.runPromise(
  970. bash.execute(
  971. {
  972. command: `echo started && sleep 60`,
  973. description: "Timeout test",
  974. timeout: 500,
  975. },
  976. ctx,
  977. ),
  978. )
  979. expect(result.output).toContain("started")
  980. expect(result.output).toContain("bash tool terminated command after exceeding timeout")
  981. },
  982. })
  983. }, 15_000)
  984. test.skipIf(process.platform === "win32")("captures stderr in output", async () => {
  985. await Instance.provide({
  986. directory: projectRoot,
  987. fn: async () => {
  988. const bash = await initBash()
  989. const result = await Effect.runPromise(
  990. bash.execute(
  991. {
  992. command: `echo stdout_msg && echo stderr_msg >&2`,
  993. description: "Stderr test",
  994. },
  995. ctx,
  996. ),
  997. )
  998. expect(result.output).toContain("stdout_msg")
  999. expect(result.output).toContain("stderr_msg")
  1000. expect(result.metadata.exit).toBe(0)
  1001. },
  1002. })
  1003. })
  1004. test("returns non-zero exit code", async () => {
  1005. await Instance.provide({
  1006. directory: projectRoot,
  1007. fn: async () => {
  1008. const bash = await initBash()
  1009. const result = await Effect.runPromise(
  1010. bash.execute(
  1011. {
  1012. command: `exit 42`,
  1013. description: "Non-zero exit",
  1014. },
  1015. ctx,
  1016. ),
  1017. )
  1018. expect(result.metadata.exit).toBe(42)
  1019. },
  1020. })
  1021. })
  1022. test("streams metadata updates progressively", async () => {
  1023. await Instance.provide({
  1024. directory: projectRoot,
  1025. fn: async () => {
  1026. const bash = await initBash()
  1027. const updates: string[] = []
  1028. const result = await Effect.runPromise(
  1029. bash.execute(
  1030. {
  1031. command: `echo first && sleep 0.1 && echo second`,
  1032. description: "Streaming test",
  1033. },
  1034. {
  1035. ...ctx,
  1036. metadata: (input) =>
  1037. Effect.sync(() => {
  1038. const output = (input.metadata as { output?: string })?.output
  1039. if (output) updates.push(output)
  1040. }),
  1041. },
  1042. ),
  1043. )
  1044. expect(result.output).toContain("first")
  1045. expect(result.output).toContain("second")
  1046. expect(updates.length).toBeGreaterThan(1)
  1047. },
  1048. })
  1049. })
  1050. })
  1051. describe("tool.bash truncation", () => {
  1052. test("truncates output exceeding line limit", async () => {
  1053. await Instance.provide({
  1054. directory: projectRoot,
  1055. fn: async () => {
  1056. const bash = await initBash()
  1057. const lineCount = Truncate.MAX_LINES + 500
  1058. const result = await Effect.runPromise(
  1059. bash.execute(
  1060. {
  1061. command: fill("lines", lineCount),
  1062. description: "Generate lines exceeding limit",
  1063. },
  1064. ctx,
  1065. ),
  1066. )
  1067. mustTruncate(result)
  1068. expect(result.output).toContain("truncated")
  1069. expect(result.output).toContain("The tool call succeeded but the output was truncated")
  1070. },
  1071. })
  1072. })
  1073. test("truncates output exceeding byte limit", async () => {
  1074. await Instance.provide({
  1075. directory: projectRoot,
  1076. fn: async () => {
  1077. const bash = await initBash()
  1078. const byteCount = Truncate.MAX_BYTES + 10000
  1079. const result = await Effect.runPromise(
  1080. bash.execute(
  1081. {
  1082. command: fill("bytes", byteCount),
  1083. description: "Generate bytes exceeding limit",
  1084. },
  1085. ctx,
  1086. ),
  1087. )
  1088. mustTruncate(result)
  1089. expect(result.output).toContain("truncated")
  1090. expect(result.output).toContain("The tool call succeeded but the output was truncated")
  1091. },
  1092. })
  1093. })
  1094. test("does not truncate small output", async () => {
  1095. await Instance.provide({
  1096. directory: projectRoot,
  1097. fn: async () => {
  1098. const bash = await initBash()
  1099. const result = await Effect.runPromise(
  1100. bash.execute(
  1101. {
  1102. command: "echo hello",
  1103. description: "Echo hello",
  1104. },
  1105. ctx,
  1106. ),
  1107. )
  1108. expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
  1109. expect(result.output).toContain("hello")
  1110. },
  1111. })
  1112. })
  1113. test("full output is saved to file when truncated", async () => {
  1114. await Instance.provide({
  1115. directory: projectRoot,
  1116. fn: async () => {
  1117. const bash = await initBash()
  1118. const lineCount = Truncate.MAX_LINES + 100
  1119. const result = await Effect.runPromise(
  1120. bash.execute(
  1121. {
  1122. command: fill("lines", lineCount),
  1123. description: "Generate lines for file check",
  1124. },
  1125. ctx,
  1126. ),
  1127. )
  1128. mustTruncate(result)
  1129. const filepath = (result.metadata as { outputPath?: string }).outputPath
  1130. expect(filepath).toBeTruthy()
  1131. const saved = await Filesystem.readText(filepath!)
  1132. const lines = saved.trim().split(/\r?\n/)
  1133. expect(lines.length).toBe(lineCount)
  1134. expect(lines[0]).toBe("1")
  1135. expect(lines[lineCount - 1]).toBe(String(lineCount))
  1136. },
  1137. })
  1138. })
  1139. })