tool-webfetch.test.ts 11 KB

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