tool-webfetch.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import { describe, expect, test } from "bun:test"
  2. import { Duration, Effect, Fiber, Layer, Schema } from "effect"
  3. import * as TestClock from "effect/testing/TestClock"
  4. import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  7. import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
  8. import { PermissionV2 } from "@opencode-ai/core/permission"
  9. import { SessionV2 } from "@opencode-ai/core/session"
  10. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  11. import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
  12. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  13. import { testEffect } from "./lib/effect"
  14. import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
  15. const sessionID = SessionV2.ID.make("ses_webfetch_test")
  16. const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
  17. const assertions: PermissionV2.AssertInput[] = []
  18. let respond = (_request: HttpClientRequest.HttpClientRequest) =>
  19. Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
  20. const http = Layer.succeed(
  21. HttpClient.HttpClient,
  22. HttpClient.make((request) =>
  23. Effect.sync(() => requests.push({ url: request.url, headers: request.headers })).pipe(
  24. Effect.andThen(respond(request)),
  25. Effect.map((response) => HttpClientResponse.fromWeb(request, response)),
  26. ),
  27. ),
  28. )
  29. const permission = Layer.succeed(
  30. PermissionV2.Service,
  31. PermissionV2.Service.of({
  32. assert: (input) => Effect.sync(() => assertions.push(input)),
  33. ask: () => Effect.die("unused"),
  34. reply: () => Effect.die("unused"),
  35. get: () => Effect.die("unused"),
  36. forSession: () => Effect.die("unused"),
  37. list: () => Effect.die("unused"),
  38. }),
  39. )
  40. const toolLayer = (replacements: LayerNode.Replacements = []) =>
  41. AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [
  42. [PermissionV2.node, permission],
  43. [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
  44. ...replacements,
  45. ])
  46. const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]]))
  47. const live = testEffect(toolLayer())
  48. const reset = () => {
  49. requests.length = 0
  50. assertions.length = 0
  51. respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
  52. }
  53. const call = (input: typeof WebFetchTool.Input.Type, id = "call-webfetch") => ({
  54. sessionID,
  55. ...toolIdentity,
  56. call: { type: "tool-call" as const, id, name: "webfetch", input },
  57. })
  58. describe("WebFetchTool helpers", () => {
  59. test("defaults format and rejects invalid timeout controls", () => {
  60. const decode = Schema.decodeUnknownSync(WebFetchTool.Input)
  61. expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com", format: "markdown" })
  62. expect(() => decode({ url: "https://example.com", timeout: 0 })).toThrow()
  63. expect(() => decode({ url: "https://example.com", timeout: WebFetchTool.MAX_TIMEOUT_SECONDS + 1 })).toThrow()
  64. })
  65. test("ports HTML text and markdown conversions without active content", () => {
  66. const html = "<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong></p><style>.bad {}</style>"
  67. expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide")
  68. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide**")
  69. })
  70. })
  71. describe("WebFetchTool registration", () => {
  72. it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () =>
  73. Effect.gen(function* () {
  74. reset()
  75. const registry = yield* ToolRegistry.Service
  76. const url = "http://example.com/public"
  77. expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
  78. expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
  79. result: { type: "text", value: "hello" },
  80. output: {
  81. structured: { url, contentType: "text/plain", format: "text", output: "hello" },
  82. content: [{ type: "text", text: "hello" }],
  83. },
  84. })
  85. expect(assertions).toMatchObject([
  86. { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
  87. ])
  88. expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
  89. }),
  90. )
  91. it.effect("accepts localhost URLs with the same requested-URL permission check", () =>
  92. Effect.gen(function* () {
  93. reset()
  94. const registry = yield* ToolRegistry.Service
  95. const url = "http://localhost/private"
  96. expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
  97. type: "text",
  98. value: "hello",
  99. })
  100. expect(assertions).toMatchObject([
  101. { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
  102. ])
  103. expect(requests.map((request) => request.url)).toEqual([url])
  104. }),
  105. )
  106. live.effect("follows redirects while approving only the requested URL", () =>
  107. Effect.acquireUseRelease(
  108. Effect.sync(() =>
  109. Bun.serve({
  110. port: 0,
  111. fetch: (request) =>
  112. new URL(request.url).pathname === "/redirect"
  113. ? new Response("", { status: 302, headers: { location: "/target" } })
  114. : new Response("redirected", { headers: { "content-type": "text/plain" } }),
  115. }),
  116. ),
  117. (server) =>
  118. Effect.gen(function* () {
  119. reset()
  120. const registry = yield* ToolRegistry.Service
  121. const url = new URL("/redirect", server.url).toString()
  122. expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
  123. type: "text",
  124. value: "redirected",
  125. })
  126. expect(assertions).toMatchObject([
  127. { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
  128. ])
  129. }),
  130. (server) => Effect.promise(() => server.stop(true)),
  131. ),
  132. )
  133. it.effect("rejects non-HTTP schemes before permission or transport", () =>
  134. Effect.gen(function* () {
  135. reset()
  136. const registry = yield* ToolRegistry.Service
  137. expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
  138. type: "error",
  139. value: "Unable to fetch file:///etc/passwd",
  140. })
  141. expect(assertions).toEqual([])
  142. expect(requests).toEqual([])
  143. }),
  144. )
  145. it.effect("converts HTML to requested markdown and text", () =>
  146. Effect.gen(function* () {
  147. reset()
  148. respond = () =>
  149. Effect.succeed(
  150. new Response("<h1>Hello</h1><p>world</p><script>bad()</script>", {
  151. headers: { "content-type": "text/html; charset=utf-8" },
  152. }),
  153. )
  154. const registry = yield* ToolRegistry.Service
  155. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
  156. type: "text",
  157. value: "# Hello\n\nworld",
  158. })
  159. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
  160. type: "text",
  161. value: "Helloworld",
  162. })
  163. }),
  164. )
  165. it.effect("returns an error result when HTML-to-Markdown conversion throws", () =>
  166. Effect.gen(function* () {
  167. reset()
  168. respond = () =>
  169. Effect.succeed(
  170. new Response("<div>".repeat(10_000) + "content" + "</div>".repeat(10_000), {
  171. headers: { "content-type": "text/html" },
  172. }),
  173. )
  174. const registry = yield* ToolRegistry.Service
  175. const url = "https://1.1.1.1/deep-html"
  176. expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({
  177. type: "error",
  178. value: `Unable to fetch ${url}`,
  179. })
  180. }),
  181. )
  182. it.effect("rejects declared and streamed oversized bodies", () =>
  183. Effect.gen(function* () {
  184. reset()
  185. const registry = yield* ToolRegistry.Service
  186. respond = () =>
  187. Effect.succeed(
  188. new Response("small", {
  189. headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) },
  190. }),
  191. )
  192. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
  193. type: "error",
  194. value: "Unable to fetch https://1.1.1.1/declared",
  195. })
  196. respond = () =>
  197. Effect.succeed(
  198. new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
  199. )
  200. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
  201. type: "error",
  202. value: "Unable to fetch https://1.1.1.1/streamed",
  203. })
  204. }),
  205. )
  206. it.effect("keeps images and files unsupported until typed settlement can carry attachments", () =>
  207. Effect.gen(function* () {
  208. reset()
  209. const registry = yield* ToolRegistry.Service
  210. respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
  211. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
  212. type: "error",
  213. value: "Unable to fetch https://1.1.1.1/image",
  214. })
  215. respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
  216. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
  217. type: "error",
  218. value: "Unable to fetch https://1.1.1.1/file",
  219. })
  220. }),
  221. )
  222. it.effect("retries Cloudflare challenges with an honest user agent", () =>
  223. Effect.gen(function* () {
  224. reset()
  225. let count = 0
  226. respond = () =>
  227. Effect.succeed(
  228. ++count === 1
  229. ? new Response("challenge", { status: 403, headers: { "cf-mitigated": "challenge" } })
  230. : new Response("ok", { headers: { "content-type": "text/plain" } }),
  231. )
  232. const registry = yield* ToolRegistry.Service
  233. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
  234. type: "text",
  235. value: "ok",
  236. })
  237. expect(requests).toHaveLength(2)
  238. expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0")
  239. expect(requests[1]?.headers["user-agent"]).toBe("opencode")
  240. }),
  241. )
  242. it.effect("times out stalled requests", () =>
  243. Effect.gen(function* () {
  244. reset()
  245. respond = () => Effect.never
  246. const registry = yield* ToolRegistry.Service
  247. const fiber = yield* executeTool(
  248. registry,
  249. call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }),
  250. ).pipe(Effect.forkChild)
  251. yield* TestClock.adjust(Duration.seconds(1))
  252. expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" })
  253. }),
  254. )
  255. })