tool-webfetch.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  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 { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  8. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  9. import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
  10. import { testEffect } from "./lib/effect"
  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. const truncations: ToolOutputStore.TruncateInput[] = []
  15. let respond = (_request: HttpClientRequest.HttpClientRequest) =>
  16. Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
  17. let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
  18. Effect.succeed({ content: input.content, truncated: false })
  19. const http = Layer.succeed(
  20. HttpClient.HttpClient,
  21. HttpClient.make((request) =>
  22. Effect.sync(() => requests.push({ url: request.url, headers: request.headers })).pipe(
  23. Effect.andThen(respond(request)),
  24. Effect.map((response) => HttpClientResponse.fromWeb(request, response)),
  25. ),
  26. ),
  27. )
  28. const permission = Layer.succeed(
  29. PermissionV2.Service,
  30. PermissionV2.Service.of({
  31. assert: (input) => Effect.sync(() => assertions.push(input)),
  32. ask: () => Effect.die("unused"),
  33. reply: () => Effect.die("unused"),
  34. get: () => Effect.die("unused"),
  35. forSession: () => Effect.die("unused"),
  36. list: () => Effect.die("unused"),
  37. }),
  38. )
  39. const resources = Layer.succeed(
  40. ToolOutputStore.Service,
  41. ToolOutputStore.Service.of({
  42. limits: () => Effect.die("unused"),
  43. write: () => Effect.die("unused"),
  44. truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
  45. read: () => Effect.die("unused"),
  46. cleanup: () => Effect.die("unused"),
  47. }),
  48. )
  49. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  50. const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(http), Layer.provide(resources))
  51. const it = testEffect(Layer.mergeAll(registry, permission, http, resources, webfetch))
  52. const fetchWebfetch = WebFetchTool.layer.pipe(
  53. Layer.provide(registry),
  54. Layer.provide(FetchHttpClient.layer),
  55. Layer.provide(resources),
  56. )
  57. const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, resources, fetchWebfetch))
  58. const reset = () => {
  59. requests.length = 0
  60. assertions.length = 0
  61. truncations.length = 0
  62. respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
  63. truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
  64. }
  65. const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") => ({
  66. sessionID,
  67. call: { type: "tool-call" as const, id, name: "webfetch", input },
  68. })
  69. describe("WebFetchTool helpers", () => {
  70. test("defaults format and rejects invalid timeout controls", () => {
  71. const decode = Schema.decodeUnknownSync(WebFetchTool.Parameters)
  72. expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com", format: "markdown" })
  73. expect(() => decode({ url: "https://example.com", timeout: 0 })).toThrow()
  74. expect(() => decode({ url: "https://example.com", timeout: WebFetchTool.MAX_TIMEOUT_SECONDS + 1 })).toThrow()
  75. })
  76. test("ports HTML text and markdown conversions without active content", () => {
  77. const html = "<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong></p><style>.bad {}</style>"
  78. expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide")
  79. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide**")
  80. })
  81. })
  82. describe("WebFetchTool contribution", () => {
  83. it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () =>
  84. Effect.gen(function* () {
  85. reset()
  86. const registry = yield* ToolRegistry.Service
  87. const url = "http://example.com/public"
  88. expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["webfetch"])
  89. expect(yield* registry.settle(call({ url, format: "text", timeout: 4 }))).toEqual({
  90. result: { type: "text", value: "hello" },
  91. output: {
  92. structured: { url, contentType: "text/plain", format: "text", output: "hello", truncated: false },
  93. content: [{ type: "text", text: "hello" }],
  94. },
  95. })
  96. expect(assertions).toEqual([
  97. { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
  98. ])
  99. expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
  100. }),
  101. )
  102. it.effect("accepts localhost URLs with the same requested-URL permission check", () =>
  103. Effect.gen(function* () {
  104. reset()
  105. const registry = yield* ToolRegistry.Service
  106. const url = "http://localhost/private"
  107. expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({
  108. type: "text",
  109. value: "hello",
  110. })
  111. expect(assertions).toEqual([
  112. { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
  113. ])
  114. expect(requests.map((request) => request.url)).toEqual([url])
  115. }),
  116. )
  117. live.effect("follows redirects while approving only the requested URL", () =>
  118. Effect.acquireUseRelease(
  119. Effect.sync(() =>
  120. Bun.serve({
  121. port: 0,
  122. fetch: (request) =>
  123. new URL(request.url).pathname === "/redirect"
  124. ? new Response("", { status: 302, headers: { location: "/target" } })
  125. : new Response("redirected", { headers: { "content-type": "text/plain" } }),
  126. }),
  127. ),
  128. (server) =>
  129. Effect.gen(function* () {
  130. reset()
  131. const registry = yield* ToolRegistry.Service
  132. const url = new URL("/redirect", server.url).toString()
  133. expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({ type: "text", value: "redirected" })
  134. expect(assertions).toEqual([
  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. expect(yield* registry.execute(call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
  146. type: "error",
  147. value: "Unable to fetch file:///etc/passwd",
  148. })
  149. expect(assertions).toEqual([])
  150. expect(requests).toEqual([])
  151. }),
  152. )
  153. it.effect("converts HTML to requested markdown and text", () =>
  154. Effect.gen(function* () {
  155. reset()
  156. respond = () =>
  157. Effect.succeed(
  158. new Response("<h1>Hello</h1><p>world</p><script>bad()</script>", {
  159. headers: { "content-type": "text/html; charset=utf-8" },
  160. }),
  161. )
  162. const registry = yield* ToolRegistry.Service
  163. expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
  164. type: "text",
  165. value: "# Hello\n\nworld",
  166. })
  167. expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
  168. type: "text",
  169. value: "Helloworld",
  170. })
  171. }),
  172. )
  173. it.effect("exposes managed overflow through an opaque resource URI", () =>
  174. Effect.gen(function* () {
  175. reset()
  176. truncate = (input) =>
  177. Effect.succeed({
  178. content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
  179. truncated: true,
  180. resource: new ToolOutputStore.Resource({
  181. uri: "tool-output://opaque",
  182. mime: input.mime ?? "text/plain",
  183. size: input.content.length,
  184. }),
  185. })
  186. const registry = yield* ToolRegistry.Service
  187. const settled = yield* registry.settle(call({ url: "https://1.1.1.1", format: "html" }, "call-overflow"))
  188. expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("tool-output://opaque") })
  189. expect(settled.output?.structured).toMatchObject({
  190. truncated: true,
  191. resource: { uri: "tool-output://opaque", mime: "text/html" },
  192. })
  193. expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "hello", mime: "text/html" }])
  194. }),
  195. )
  196. it.effect("rejects declared and streamed oversized bodies", () =>
  197. Effect.gen(function* () {
  198. reset()
  199. const registry = yield* ToolRegistry.Service
  200. respond = () =>
  201. Effect.succeed(
  202. new Response("small", {
  203. headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) },
  204. }),
  205. )
  206. expect(yield* registry.execute(call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
  207. type: "error",
  208. value: "Unable to fetch https://1.1.1.1/declared",
  209. })
  210. respond = () =>
  211. Effect.succeed(
  212. new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
  213. )
  214. expect(yield* registry.execute(call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
  215. type: "error",
  216. value: "Unable to fetch https://1.1.1.1/streamed",
  217. })
  218. }),
  219. )
  220. it.effect("keeps images and files unsupported until typed settlement can carry attachments", () =>
  221. Effect.gen(function* () {
  222. reset()
  223. const registry = yield* ToolRegistry.Service
  224. respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
  225. expect(yield* registry.execute(call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
  226. type: "error",
  227. value: "Unable to fetch https://1.1.1.1/image",
  228. })
  229. respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
  230. expect(yield* registry.execute(call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
  231. type: "error",
  232. value: "Unable to fetch https://1.1.1.1/file",
  233. })
  234. expect(truncations).toEqual([])
  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* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
  249. type: "text",
  250. value: "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* registry
  263. .execute(call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }))
  264. .pipe(Effect.forkChild)
  265. yield* TestClock.adjust(Duration.seconds(1))
  266. expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" })
  267. }),
  268. )
  269. })