tool-shell.test.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834
  1. import fs from "fs/promises"
  2. import { realpathSync } from "node:fs"
  3. import os from "os"
  4. import path from "path"
  5. import { describe, expect } from "bun:test"
  6. import { Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
  7. import { Money } from "@opencode-ai/schema/money"
  8. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  9. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  10. import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
  11. import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
  12. import { Database } from "@opencode-ai/core/database/database"
  13. import { Bus } from "@opencode-ai/core/bus"
  14. import { Config } from "@opencode-ai/core/config"
  15. import { Environment } from "@opencode-ai/core/environment/index"
  16. import { FSUtil } from "@opencode-ai/util/fs-util"
  17. import { Global } from "@opencode-ai/util/global"
  18. import { Location } from "@opencode-ai/core/location"
  19. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  20. import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
  21. import { Model } from "@opencode-ai/core/model"
  22. import { Provider } from "@opencode-ai/core/provider"
  23. import { AbsolutePath } from "@opencode-ai/core/schema"
  24. import { Agent } from "@opencode-ai/core/agent"
  25. import { Job } from "@opencode-ai/core/job"
  26. import { Session } from "@opencode-ai/core/session"
  27. import { SessionEvent } from "@opencode-ai/core/session/event"
  28. import { SessionExecution } from "@opencode-ai/core/session/execution"
  29. import { SessionMessage } from "@opencode-ai/core/session/message"
  30. import { SessionStore } from "@opencode-ai/core/session/store"
  31. import { Permission } from "@opencode-ai/core/permission"
  32. import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
  33. import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
  34. import { Shell } from "@opencode-ai/core/shell"
  35. import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
  36. import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
  37. import { ToolOutput } from "@opencode-ai/core/tool-output"
  38. import { Tool } from "@opencode-ai/core/tool"
  39. import { tmpdir } from "./fixture/tmpdir"
  40. import { tempGlobalLayer } from "./fixture/global"
  41. import { testEffect } from "./lib/effect"
  42. import { permissionLayer } from "./lib/permission"
  43. import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
  44. const sessionID = Session.ID.make("ses_shell_tool_test")
  45. const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
  46. const assertions: Permission.AssertInput[] = []
  47. const allowedActions = new Set<string>()
  48. let denyAction: string | undefined
  49. let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
  50. const permission = permissionLayer({
  51. allowsAll: (input) => Effect.succeed(allowedActions.has(input.action)),
  52. assert: (input) =>
  53. Effect.sync(() => assertions.push(input)).pipe(
  54. Effect.andThen(Effect.suspend(() => afterPermission(input))),
  55. Effect.andThen(
  56. input.action === denyAction
  57. ? Effect.fail(
  58. new Permission.BlockedError({
  59. rules: [],
  60. permission: input.action,
  61. resources: input.resources,
  62. }),
  63. )
  64. : Effect.void,
  65. ),
  66. ),
  67. })
  68. const reset = () => {
  69. assertions.length = 0
  70. allowedActions.clear()
  71. denyAction = undefined
  72. afterPermission = () => Effect.void
  73. }
  74. const executionNode = makeGlobalNode({
  75. service: SessionExecution.Service,
  76. layer: Layer.effect(
  77. SessionExecution.Service,
  78. Effect.gen(function* () {
  79. const bus = yield* Bus.Service
  80. const store = yield* SessionStore.Service
  81. const complete = Effect.fn("ShellTest.complete")(function* (id: Session.ID) {
  82. const session = yield* store.get(id)
  83. if (!session) return
  84. const assistantMessageID = SessionMessage.ID.create()
  85. yield* bus.publish(SessionEvent.Step.Started, {
  86. sessionID: id,
  87. assistantMessageID,
  88. agent: session.agent ?? Agent.ID.make("code"),
  89. model: sessionModel,
  90. })
  91. yield* bus.publish(SessionEvent.Text.Started, {
  92. sessionID: id,
  93. assistantMessageID,
  94. ordinal: 0,
  95. })
  96. yield* bus.publish(SessionEvent.Text.Ended, {
  97. sessionID: id,
  98. assistantMessageID,
  99. ordinal: 0,
  100. text: "ok",
  101. })
  102. yield* bus.publish(SessionEvent.Step.Ended, {
  103. sessionID: id,
  104. assistantMessageID,
  105. finish: "stop",
  106. cost: Money.USD.zero,
  107. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  108. })
  109. })
  110. return SessionExecution.Service.of({
  111. active: Effect.succeed(new Set()),
  112. resume: complete,
  113. wake: () => Effect.void,
  114. wakeActive: () => Effect.void,
  115. interrupt: () => Effect.void,
  116. awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
  117. })
  118. }),
  119. ),
  120. deps: [Bus.node, SessionStore.node],
  121. })
  122. const shellPluginSupervisor = makeLocationNode({
  123. service: PluginSupervisor.Service,
  124. layer: Layer.effect(
  125. PluginSupervisor.Service,
  126. registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
  127. ),
  128. deps: [
  129. Config.node,
  130. Environment.node,
  131. LocationMutation.node,
  132. Permission.node,
  133. PluginRuntime.node,
  134. Shell.node,
  135. Tool.node,
  136. ],
  137. })
  138. const nodes = LayerNode.group([
  139. Database.node,
  140. Bus.node,
  141. Job.node,
  142. Session.node,
  143. SessionExecution.node,
  144. PluginRuntime.providerNode,
  145. LocationServiceMap.node,
  146. filesystem,
  147. FSUtil.node,
  148. Global.node,
  149. ])
  150. const replacements = [
  151. [SessionExecution.node, executionNode],
  152. [Permission.node, permission],
  153. [Global.node, tempGlobalLayer],
  154. ] satisfies LayerNode.Replacements
  155. const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
  156. const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
  157. const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
  158. sessionID,
  159. ...toolIdentity,
  160. call: { type: "tool-call" as const, id, name: "shell", input },
  161. })
  162. const isWindows = process.platform === "win32"
  163. const cwdCommand = isWindows ? "(Get-Location).Path; Start-Sleep -Milliseconds 100" : "pwd"
  164. const helloCommand = isWindows ? "[Console]::Out.Write('hello'); Start-Sleep -Milliseconds 100" : "printf hello"
  165. const stderrCommand = isWindows
  166. ? "[Console]::Error.Write('stderr only'); Start-Sleep -Milliseconds 100"
  167. : "printf 'stderr only' >&2"
  168. const mixedOutputCommand = isWindows
  169. ? "[Console]::Out.Write('stdout'); Start-Sleep -Milliseconds 50; [Console]::Error.Write('stderr'); Start-Sleep -Milliseconds 100"
  170. : "printf stdout; sleep 0.05; printf stderr >&2"
  171. const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
  172. const timeoutOutputCommand = isWindows
  173. ? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
  174. : "printf 'before timeout'; sleep 60"
  175. const bodyExitCommand = isWindows
  176. ? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
  177. : "printf body && exit 7"
  178. const overflowCommand = (bytes: number) =>
  179. isWindows
  180. ? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
  181. : `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
  182. const lineOverflowCommand = isWindows
  183. ? "[Console]::Out.Write('one' + [Environment]::NewLine + 'two' + [Environment]::NewLine + 'three')"
  184. : "printf 'one\\ntwo\\nthree'"
  185. const progressOverflowCommand = (bytes: number, release: string) =>
  186. isWindows
  187. ? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
  188. : `head -c ${bytes} /dev/zero | tr '\\0' 'x'; while [ ! -e '${release}' ]; do sleep 0.05; done`
  189. const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) =>
  190. Effect.gen(function* () {
  191. const sessions = yield* Session.Service
  192. const location = Location.Ref.make({ directory: AbsolutePath.make(directory) })
  193. yield* sessions.create({
  194. id: sessionID,
  195. title: "shell test",
  196. location,
  197. model: sessionModel,
  198. })
  199. const locations = yield* LocationServiceMap.Service
  200. const locationLayer = locations.get(location)
  201. return yield* Effect.gen(function* () {
  202. yield* (yield* PluginSupervisor.Service).flush
  203. const registry = yield* Tool.Service
  204. return yield* body(registry)
  205. }).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
  206. })
  207. describe("ShellTool", () => {
  208. productionIt.live(
  209. "registers and returns real successful output from the active Location",
  210. () =>
  211. Effect.acquireUseRelease(
  212. Effect.promise(() => tmpdir()),
  213. (tmp) => {
  214. reset()
  215. return withSession(tmp.path, (registry) =>
  216. Effect.gen(function* () {
  217. const definitions = yield* toolDefinitions(registry)
  218. const definition = definitions.find((tool) => tool.name === "shell")
  219. expect(definition?.description).toStartWith("Execute a shell command and return its output.")
  220. expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
  221. // Code Mode receives the declared output schema, including the command output text.
  222. expect(definition?.outputSchema).toHaveProperty("properties.output")
  223. expect(
  224. (yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
  225. (tool) => tool.name,
  226. ),
  227. ).not.toContain("shell")
  228. const settled = yield* executeTool(registry, call({ command: helloCommand }))
  229. expect(settled.status).toBe("completed")
  230. expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
  231. expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
  232. expect(settled.content?.[1]).toMatchObject({
  233. type: "text",
  234. text: expect.stringContaining("Command exited with code 0."),
  235. })
  236. expect(assertions).toMatchObject([
  237. {
  238. sessionID,
  239. action: "shell",
  240. resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
  241. },
  242. ])
  243. expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
  244. }),
  245. )
  246. },
  247. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  248. ),
  249. { timeout: 15_000 },
  250. )
  251. it.live("resolves a relative workdir from the active Location", () =>
  252. Effect.acquireUseRelease(
  253. Effect.promise(() => tmpdir()),
  254. (tmp) => {
  255. reset()
  256. return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
  257. Effect.andThen(
  258. withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
  259. ),
  260. Effect.andThen((settled) =>
  261. Effect.sync(() =>
  262. expect(settled.content?.[0]).toMatchObject({
  263. type: "text",
  264. text: expect.stringContaining(realpathSync(path.join(tmp.path, "src"))),
  265. }),
  266. ),
  267. ),
  268. )
  269. },
  270. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  271. ),
  272. )
  273. it.live("reports a missing workdir", () =>
  274. Effect.acquireUseRelease(
  275. Effect.promise(() => tmpdir()),
  276. (tmp) => {
  277. reset()
  278. return withSession(tmp.path, (registry) =>
  279. executeTool(registry, call({ command: cwdCommand, workdir: "missing" })),
  280. ).pipe(
  281. Effect.andThen((settled) =>
  282. Effect.sync(() =>
  283. expect(settled).toEqual({
  284. status: "error",
  285. error: {
  286. type: "unknown",
  287. message: `Working directory does not exist: ${path.join(tmp.path, "missing")}`,
  288. },
  289. }),
  290. ),
  291. ),
  292. )
  293. },
  294. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  295. ),
  296. )
  297. it.live(
  298. "permissions compound commands separately",
  299. () =>
  300. Effect.acquireUseRelease(
  301. Effect.promise(() => tmpdir()),
  302. (tmp) => {
  303. reset()
  304. return withSession(tmp.path, (registry) =>
  305. executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
  306. ).pipe(
  307. Effect.andThen(
  308. Effect.sync(() => {
  309. expect(assertions).toHaveLength(1)
  310. expect(assertions[0]).toMatchObject({
  311. resources: ["printf one", "printf two"],
  312. save: ["printf *", "printf *"],
  313. })
  314. }),
  315. ),
  316. )
  317. },
  318. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  319. ),
  320. { timeout: 15_000 },
  321. )
  322. it.live(
  323. "skips command decomposition when shell and external directories are unrestricted",
  324. () =>
  325. Effect.acquireUseRelease(
  326. Effect.promise(() => tmpdir()),
  327. (tmp) => {
  328. reset()
  329. allowedActions.add("shell")
  330. allowedActions.add("external_directory")
  331. return withSession(tmp.path, (registry) =>
  332. executeTool(registry, call({ command: "printf one && printf two" }, "call-unrestricted")),
  333. ).pipe(
  334. Effect.andThen(
  335. Effect.sync(() => {
  336. expect(assertions).toEqual([])
  337. }),
  338. ),
  339. )
  340. },
  341. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  342. ),
  343. { timeout: 15_000 },
  344. )
  345. it.live(
  346. "captures stderr-only and mixed stdout/stderr output",
  347. () =>
  348. Effect.acquireUseRelease(
  349. Effect.promise(() => tmpdir()),
  350. (tmp) => {
  351. reset()
  352. return withSession(tmp.path, (registry) =>
  353. Effect.gen(function* () {
  354. const stderr = yield* executeTool(registry, call({ command: stderrCommand }, "call-stderr"))
  355. expect(stderr.metadata).toMatchObject({ exit: 0, truncated: false })
  356. expect(stderr.content?.[0]).toEqual({ type: "text", text: "stderr only" })
  357. const mixed = yield* executeTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
  358. expect(mixed.metadata).toMatchObject({ exit: 0, truncated: false })
  359. const output = mixed.content?.[0]?.type === "text" ? mixed.content[0].text : ""
  360. expect(output).toContain("stdout")
  361. expect(output).toContain("stderr")
  362. }),
  363. )
  364. },
  365. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  366. ),
  367. { timeout: 15_000 },
  368. )
  369. it.live("rejects a workdir that stops being a directory during approval", () =>
  370. Effect.acquireUseRelease(
  371. Effect.promise(() => tmpdir()),
  372. (tmp) => {
  373. reset()
  374. const workdir = path.join(tmp.path, "src")
  375. afterPermission = (input) =>
  376. input.action === "shell"
  377. ? Effect.promise(async () => {
  378. await fs.rm(workdir, { recursive: true })
  379. await fs.writeFile(workdir, "not a directory")
  380. }).pipe(Effect.orDie)
  381. : Effect.void
  382. return Effect.promise(() => fs.mkdir(workdir)).pipe(
  383. Effect.andThen(
  384. withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
  385. ),
  386. Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))),
  387. )
  388. },
  389. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  390. ),
  391. )
  392. it.live(
  393. "approves an explicit external workdir before shell execution",
  394. () =>
  395. Effect.acquireUseRelease(
  396. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  397. ([active, outside]) => {
  398. reset()
  399. return withSession(active.path, (registry) =>
  400. executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
  401. ).pipe(
  402. Effect.andThen(
  403. Effect.sync(() => {
  404. expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
  405. expect(assertions[0]).toMatchObject({
  406. resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
  407. })
  408. }),
  409. ),
  410. )
  411. },
  412. ([active, outside]) =>
  413. Effect.promise(() =>
  414. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  415. ),
  416. ),
  417. { timeout: 15_000 },
  418. )
  419. it.live(
  420. "approves an external directory used by a directory-change command",
  421. () =>
  422. Effect.acquireUseRelease(
  423. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  424. ([active, outside]) => {
  425. reset()
  426. const command = isWindows
  427. ? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
  428. : `cd '${outside.path}' && pwd`
  429. return withSession(active.path, (registry) =>
  430. executeTool(registry, call({ command }, "call-external-cd")),
  431. ).pipe(
  432. Effect.andThen(
  433. Effect.sync(() => {
  434. expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
  435. expect(assertions[0]).toMatchObject({
  436. resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
  437. })
  438. }),
  439. ),
  440. )
  441. },
  442. ([active, outside]) =>
  443. Effect.promise(() =>
  444. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  445. ),
  446. ),
  447. { timeout: 15_000 },
  448. )
  449. it.live("approves an expanded external home directory", () =>
  450. Effect.acquireUseRelease(
  451. Effect.promise(() => tmpdir()),
  452. (tmp) => {
  453. reset()
  454. const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd"
  455. return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe(
  456. Effect.andThen(
  457. Effect.sync(() => {
  458. expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
  459. expect(assertions[0]?.resources[0]).toStartWith(os.homedir().replaceAll("\\", "/"))
  460. }),
  461. ),
  462. )
  463. },
  464. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  465. ),
  466. )
  467. it.live(
  468. "does not execute after external-directory or shell denial",
  469. () =>
  470. Effect.acquireUseRelease(
  471. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  472. ([active, outside]) =>
  473. Effect.gen(function* () {
  474. reset()
  475. denyAction = "external_directory"
  476. yield* withSession(active.path, (registry) =>
  477. executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
  478. )
  479. expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
  480. reset()
  481. denyAction = "shell"
  482. yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
  483. expect(assertions.map((item) => item.action)).toEqual(["shell"])
  484. }),
  485. ([active, outside]) =>
  486. Effect.promise(() =>
  487. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  488. ),
  489. ),
  490. { timeout: 15_000 },
  491. )
  492. it.live("does not add external-directory permission for an experimental portable heredoc", () =>
  493. Effect.acquireUseRelease(
  494. Effect.promise(() => tmpdir()),
  495. (tmp) =>
  496. Effect.gen(function* () {
  497. if (isWindows) return
  498. reset()
  499. denyAction = "external_directory"
  500. yield* Effect.promise(() =>
  501. Bun.write(
  502. path.join(tmp.path, "opencode.json"),
  503. JSON.stringify({ experimental: { portable_shell_scanner: true } }),
  504. ),
  505. )
  506. const settled = yield* withSession(tmp.path, (registry) =>
  507. executeTool(registry, call({ command: "cat <<'EOF'\nhello\nEOF" }, "call-portable-heredoc")),
  508. )
  509. expect(settled.status).toBe("completed")
  510. expect(assertions.map((item) => item.action)).toEqual(["shell"])
  511. expect(settled.content?.[0]).toMatchObject({ type: "text", text: "hello\n" })
  512. }),
  513. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  514. ),
  515. )
  516. it.live("keeps non-zero exits useful", () =>
  517. Effect.acquireUseRelease(
  518. Effect.promise(() => tmpdir()),
  519. (tmp) => {
  520. reset()
  521. return withSession(tmp.path, (registry) =>
  522. executeTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
  523. ).pipe(
  524. Effect.andThen((settled) =>
  525. Effect.sync(() => {
  526. expect(settled.status).toBe("completed")
  527. expect(settled.metadata).toMatchObject({ exit: 7, truncated: false })
  528. expect(settled.content?.[0]).toEqual({ type: "text", text: "body" })
  529. expect(settled.content?.[1]).toMatchObject({
  530. type: "text",
  531. text: expect.stringContaining("Command exited with code 7"),
  532. })
  533. }),
  534. ),
  535. )
  536. },
  537. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  538. ),
  539. )
  540. it.live(
  541. "truncates the model view and points at the saved output file when output overflows",
  542. () =>
  543. Effect.acquireUseRelease(
  544. Effect.promise(() => tmpdir()),
  545. (tmp) => {
  546. reset()
  547. const bytes = ToolOutput.MAX_BYTES + 1024
  548. return withSession(tmp.path, (registry) =>
  549. executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
  550. ).pipe(
  551. Effect.andThen((settled) =>
  552. Effect.sync(() => {
  553. expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
  554. const content = settled.content?.[0]
  555. if (!content || content.type !== "text") throw new Error("Expected text content")
  556. expect(content.text.includes("output-start")).toBe(false)
  557. expect(content.text.includes("output-end")).toBe(true)
  558. expect(content).toMatchObject({
  559. type: "text",
  560. text: expect.stringContaining("output truncated; full output saved to:"),
  561. })
  562. }),
  563. ),
  564. )
  565. },
  566. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  567. ),
  568. { timeout: 15_000 },
  569. )
  570. it.live("uses configured line limits", () =>
  571. Effect.acquireUseRelease(
  572. Effect.promise(() => tmpdir()),
  573. (tmp) => {
  574. reset()
  575. return Effect.gen(function* () {
  576. yield* Effect.promise(() =>
  577. Bun.write(
  578. path.join(tmp.path, "opencode.json"),
  579. JSON.stringify({ tool_output: { max_lines: 2, max_bytes: 1_000 } }),
  580. ),
  581. )
  582. const settled = yield* withSession(tmp.path, (registry) =>
  583. executeTool(registry, call({ command: lineOverflowCommand }, "call-line-overflow")),
  584. )
  585. expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
  586. const content = settled.content?.[0]
  587. if (!content || content.type !== "text") throw new Error("Expected text content")
  588. expect(content.text).not.toContain("one")
  589. // Windows shells emit CRLF; the assertion targets line limits, not line endings.
  590. expect(content.text.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
  591. expect(content.text).toContain("output truncated; full output saved to:")
  592. })
  593. },
  594. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  595. ),
  596. )
  597. it.live(
  598. "reports the shell ID for a running command",
  599. () =>
  600. Effect.acquireUseRelease(
  601. Effect.promise(() => tmpdir()),
  602. (tmp) => {
  603. reset()
  604. const release = "shell-progress-release"
  605. const releasePath = path.join(tmp.path, release)
  606. return withSession(tmp.path, (registry) =>
  607. Effect.gen(function* () {
  608. const observed = yield* Deferred.make<string>()
  609. yield* executeTool(registry, {
  610. ...call({ command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) }, "call-progress"),
  611. progress: (update) =>
  612. Effect.gen(function* () {
  613. if (typeof update.shellID !== "string") return
  614. yield* Deferred.succeed(observed, update.shellID)
  615. yield* Effect.promise(() => fs.writeFile(releasePath, ""))
  616. }),
  617. })
  618. expect(yield* Deferred.await(observed)).toMatch(/^sh_/)
  619. }).pipe(Effect.ensuring(Effect.promise(() => fs.writeFile(releasePath, "")).pipe(Effect.ignore))),
  620. )
  621. },
  622. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  623. ),
  624. { timeout: 15_000 },
  625. )
  626. it.live(
  627. "reports shell ID progress once",
  628. () =>
  629. Effect.acquireUseRelease(
  630. Effect.promise(() => tmpdir()),
  631. (tmp) => {
  632. reset()
  633. return withSession(tmp.path, (registry) =>
  634. Effect.gen(function* () {
  635. const updates: Tool.Metadata[] = []
  636. yield* executeTool(registry, {
  637. ...call({ command: helloCommand }, "call-shell-id-progress"),
  638. progress: (update) => Effect.sync(() => updates.push(update)),
  639. })
  640. expect(updates).toHaveLength(1)
  641. expect(updates[0]?.shellID).toMatch(/^sh_/)
  642. }),
  643. )
  644. },
  645. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  646. ),
  647. { timeout: 15_000 },
  648. )
  649. it.live(
  650. "returns a useful timeout outcome",
  651. () =>
  652. Effect.acquireUseRelease(
  653. Effect.promise(() => tmpdir()),
  654. (tmp) => {
  655. reset()
  656. return withSession(tmp.path, (registry) =>
  657. executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 500 })),
  658. ).pipe(
  659. Effect.andThen((settled) =>
  660. Effect.sync(() => {
  661. expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
  662. expect(settled.content?.[0]).toMatchObject({
  663. type: "text",
  664. text: expect.stringContaining("before timeout"),
  665. })
  666. expect(settled.content?.[1]).toMatchObject({
  667. type: "text",
  668. text: expect.stringContaining("Command timed out"),
  669. })
  670. }),
  671. ),
  672. )
  673. },
  674. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  675. ),
  676. { timeout: 15_000 },
  677. )
  678. it.live("returns the shell id for a background command", () =>
  679. Effect.acquireUseRelease(
  680. Effect.promise(() => tmpdir()),
  681. (tmp) => {
  682. reset()
  683. return withSession(tmp.path, (registry) =>
  684. Effect.gen(function* () {
  685. const bus = yield* Bus.Service
  686. const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
  687. Stream.filter((event) => event.data.sessionID === sessionID && event.data.item.type === "synthetic"),
  688. Stream.runHead,
  689. Effect.forkScoped({ startImmediately: true }),
  690. )
  691. const settled = yield* executeTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
  692. const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
  693. expect(settled.metadata).toMatchObject({ truncated: false })
  694. expect(shellID).toStartWith("sh_")
  695. const shell = yield* Shell.Service
  696. if (!shellID) return
  697. const id = ShellSchema.ID.make(shellID)
  698. expect((yield* shell.list()).map((info) => info.id)).toContain(id)
  699. expect((yield* shell.wait(id)).status).toBe("timeout")
  700. expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
  701. description: idleCommand,
  702. metadata: {
  703. source: "shell",
  704. state: "completed",
  705. },
  706. })
  707. }),
  708. )
  709. },
  710. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  711. ),
  712. )
  713. it.live("updates and clears a running shell timeout", () =>
  714. Effect.acquireUseRelease(
  715. Effect.promise(() => tmpdir()),
  716. (tmp) => {
  717. reset()
  718. return withSession(tmp.path, (registry) =>
  719. Effect.gen(function* () {
  720. const shell = yield* Shell.Service
  721. const timed = yield* executeTool(
  722. registry,
  723. call({ command: idleCommand, background: true }, "call-updated-timeout"),
  724. )
  725. const timedID = timed.metadata?.shellID
  726. expect(typeof timedID).toBe("string")
  727. if (typeof timedID !== "string") return
  728. const timedShellID = ShellSchema.ID.make(timedID)
  729. yield* shell.timeout(timedShellID, 50)
  730. expect((yield* shell.wait(timedShellID)).status).toBe("timeout")
  731. const cleared = yield* executeTool(
  732. registry,
  733. call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"),
  734. )
  735. const clearedID = cleared.metadata?.shellID
  736. expect(typeof clearedID).toBe("string")
  737. if (typeof clearedID !== "string") return
  738. const clearedShellID = ShellSchema.ID.make(clearedID)
  739. yield* shell.timeout(clearedShellID, 0)
  740. yield* Effect.sleep(Duration.millis(100))
  741. expect((yield* shell.get(clearedShellID)).status).toBe("running")
  742. yield* shell.remove(clearedShellID)
  743. }),
  744. )
  745. },
  746. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  747. ),
  748. )
  749. it.live("backgrounds a foreground command when the session is signaled", () =>
  750. Effect.acquireUseRelease(
  751. Effect.promise(() => tmpdir()),
  752. (tmp) => {
  753. reset()
  754. return withSession(tmp.path, (registry) =>
  755. Effect.gen(function* () {
  756. const jobs = yield* Job.Service
  757. const scope = yield* Scope.Scope
  758. const waiting = yield* executeTool(
  759. registry,
  760. call({ command: idleCommand, timeout: 50 }, "call-background-signal"),
  761. ).pipe(Effect.forkIn(scope, { startImmediately: true }))
  762. const backgroundWhenReady = (remaining = 1000): Effect.Effect<Job.Info[], Error> =>
  763. Effect.gen(function* () {
  764. const backgrounded = yield* jobs.backgroundAll({ sessionID })
  765. if (backgrounded.length > 0) return backgrounded
  766. if (remaining <= 0) return yield* Effect.fail(new Error("Timed out waiting for foreground shell job"))
  767. yield* Effect.promise(() => Bun.sleep(1))
  768. return yield* backgroundWhenReady(remaining - 1)
  769. })
  770. expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
  771. const settled = yield* Fiber.join(waiting)
  772. const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
  773. expect(settled.metadata).toMatchObject({ truncated: false })
  774. expect(settled.content?.[0]).toEqual({
  775. type: "text",
  776. text: "The command was moved to the background.",
  777. })
  778. expect(settled.content?.[1]).toMatchObject({
  779. type: "text",
  780. text: expect.stringContaining("DO NOT sleep, poll"),
  781. })
  782. expect(shellID).toStartWith("sh_")
  783. const shell = yield* Shell.Service
  784. if (!shellID) return
  785. const id = ShellSchema.ID.make(shellID)
  786. yield* Effect.sleep(Duration.millis(100))
  787. expect((yield* shell.get(id)).status).toBe("running")
  788. expect((yield* shell.list()).map((info) => info.id)).toContain(id)
  789. yield* shell.remove(id)
  790. }),
  791. )
  792. },
  793. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
  794. ),
  795. )
  796. })