| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291 |
- import { describe, expect, test } from "bun:test"
- import { Duration, Effect, Fiber, Layer, Schema } from "effect"
- import * as TestClock from "effect/testing/TestClock"
- import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { LayerNode } from "@opencode-ai/util/effect/layer-node"
- import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
- import { PermissionV2 } from "@opencode-ai/core/permission"
- import { SessionV2 } from "@opencode-ai/core/session"
- import { ToolRegistry } from "@opencode-ai/core/tool/registry"
- import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
- import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
- import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
- import { Image } from "@opencode-ai/core/image"
- import { testEffect } from "./lib/effect"
- import { imagePassthrough } from "./lib/image"
- import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
- const webFetchToolNode = makeLocationNode({
- name: "test/webfetch-tool-plugin",
- layer: Layer.effectDiscard(registerToolPlugin(WebFetchTool.Plugin)),
- deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient],
- })
- const sessionID = SessionV2.ID.make("ses_webfetch_test")
- const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
- const assertions: PermissionV2.AssertInput[] = []
- let respond = (_request: HttpClientRequest.HttpClientRequest) =>
- Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
- const http = Layer.succeed(
- HttpClient.HttpClient,
- HttpClient.make((request) =>
- Effect.sync(() => requests.push({ url: request.url, headers: request.headers })).pipe(
- Effect.andThen(respond(request)),
- Effect.map((response) => HttpClientResponse.fromWeb(request, response)),
- ),
- ),
- )
- const permission = Layer.succeed(
- PermissionV2.Service,
- PermissionV2.Service.of({
- assert: (input) => Effect.sync(() => assertions.push(input)),
- ask: () => Effect.die("unused"),
- reply: () => Effect.die("unused"),
- get: () => Effect.die("unused"),
- forSession: () => Effect.die("unused"),
- list: () => Effect.die("unused"),
- }),
- )
- const toolLayer = (replacements: LayerNode.Replacements = []) =>
- AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, webFetchToolNode]), [
- [PermissionV2.node, permission],
- [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
- [Image.node, imagePassthrough],
- ...replacements,
- ])
- const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]]))
- const live = testEffect(toolLayer())
- const reset = () => {
- requests.length = 0
- assertions.length = 0
- respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
- }
- const call = (input: typeof WebFetchTool.Input.Type, id = "call-webfetch") => ({
- sessionID,
- ...toolIdentity,
- call: { type: "tool-call" as const, id, name: "webfetch", input },
- })
- describe("WebFetchTool helpers", () => {
- test("defaults format and rejects invalid timeout controls", () => {
- const decode = Schema.decodeUnknownSync(WebFetchTool.Input)
- expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com", format: "markdown" })
- expect(() => decode({ url: "https://example.com", timeout: 0 })).toThrow()
- expect(() => decode({ url: "https://example.com", timeout: WebFetchTool.MAX_TIMEOUT_SECONDS + 1 })).toThrow()
- })
- test("ports HTML text and markdown conversions without active content", () => {
- const html = "<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong></p><style>.bad {}</style>"
- expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide")
- expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide**")
- })
- })
- describe("WebFetchTool registration", () => {
- it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () =>
- Effect.gen(function* () {
- reset()
- const registry = yield* ToolRegistry.Service
- const url = "http://example.com/public"
- expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
- expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
- result: { type: "text", value: "hello" },
- output: {
- structured: { url, contentType: "text/plain", format: "text", output: "hello" },
- content: [{ type: "text", text: "hello" }],
- },
- })
- expect(assertions).toMatchObject([
- { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
- ])
- expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
- }),
- )
- it.effect("accepts localhost URLs with the same requested-URL permission check", () =>
- Effect.gen(function* () {
- reset()
- const registry = yield* ToolRegistry.Service
- const url = "http://localhost/private"
- expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
- type: "text",
- value: "hello",
- })
- expect(assertions).toMatchObject([
- { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
- ])
- expect(requests.map((request) => request.url)).toEqual([url])
- }),
- )
- live.effect("follows redirects while approving only the requested URL", () =>
- Effect.acquireUseRelease(
- Effect.sync(() =>
- Bun.serve({
- port: 0,
- fetch: (request) =>
- new URL(request.url).pathname === "/redirect"
- ? new Response("", { status: 302, headers: { location: "/target" } })
- : new Response("redirected", { headers: { "content-type": "text/plain" } }),
- }),
- ),
- (server) =>
- Effect.gen(function* () {
- reset()
- const registry = yield* ToolRegistry.Service
- const url = new URL("/redirect", server.url).toString()
- expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
- type: "text",
- value: "redirected",
- })
- expect(assertions).toMatchObject([
- { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
- ])
- }),
- (server) => Effect.promise(() => server.stop(true)),
- ),
- )
- it.effect("rejects non-HTTP schemes before permission or transport", () =>
- Effect.gen(function* () {
- reset()
- const registry = yield* ToolRegistry.Service
- expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
- type: "error",
- value: "Unable to fetch file:///etc/passwd",
- })
- expect(assertions).toEqual([])
- expect(requests).toEqual([])
- }),
- )
- it.effect("converts HTML to requested markdown and text", () =>
- Effect.gen(function* () {
- reset()
- respond = () =>
- Effect.succeed(
- new Response("<h1>Hello</h1><p>world</p><script>bad()</script>", {
- headers: { "content-type": "text/html; charset=utf-8" },
- }),
- )
- const registry = yield* ToolRegistry.Service
- expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
- type: "text",
- value: "# Hello\n\nworld",
- })
- expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
- type: "text",
- value: "Helloworld",
- })
- }),
- )
- it.effect("returns an error result when HTML-to-Markdown conversion throws", () =>
- Effect.gen(function* () {
- reset()
- respond = () =>
- Effect.succeed(
- new Response("<div>".repeat(10_000) + "content" + "</div>".repeat(10_000), {
- headers: { "content-type": "text/html" },
- }),
- )
- const registry = yield* ToolRegistry.Service
- const url = "https://1.1.1.1/deep-html"
- expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({
- type: "error",
- value: `Unable to fetch ${url}`,
- })
- }),
- )
- it.effect("rejects declared and streamed oversized bodies", () =>
- Effect.gen(function* () {
- reset()
- const registry = yield* ToolRegistry.Service
- respond = () =>
- Effect.succeed(
- new Response("small", {
- headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) },
- }),
- )
- expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
- type: "error",
- value: "Unable to fetch https://1.1.1.1/declared",
- })
- respond = () =>
- Effect.succeed(
- new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
- )
- expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
- type: "error",
- value: "Unable to fetch https://1.1.1.1/streamed",
- })
- }),
- )
- it.effect("keeps images and files unsupported until typed settlement can carry attachments", () =>
- Effect.gen(function* () {
- reset()
- const registry = yield* ToolRegistry.Service
- respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
- expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
- type: "error",
- value: "Unable to fetch https://1.1.1.1/image",
- })
- respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
- expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
- type: "error",
- value: "Unable to fetch https://1.1.1.1/file",
- })
- }),
- )
- it.effect("retries Cloudflare challenges with an honest user agent", () =>
- Effect.gen(function* () {
- reset()
- let count = 0
- respond = () =>
- Effect.succeed(
- ++count === 1
- ? new Response("challenge", { status: 403, headers: { "cf-mitigated": "challenge" } })
- : new Response("ok", { headers: { "content-type": "text/plain" } }),
- )
- const registry = yield* ToolRegistry.Service
- expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
- type: "text",
- value: "ok",
- })
- expect(requests).toHaveLength(2)
- expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0")
- expect(requests[1]?.headers["user-agent"]).toBe("opencode")
- }),
- )
- it.effect("times out stalled requests", () =>
- Effect.gen(function* () {
- reset()
- respond = () => Effect.never
- const registry = yield* ToolRegistry.Service
- const fiber = yield* executeTool(
- registry,
- call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }),
- ).pipe(Effect.forkChild)
- yield* TestClock.adjust(Duration.seconds(1))
- expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" })
- }),
- )
- })
|