tool-webfetch.test.ts 12 KB

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