bash.test.ts 42 KB

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