shell.test.ts 40 KB

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