Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 208 additions & 30 deletions packages/opencode/src/tool/webfetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Context, Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import dns from "node:dns/promises"
import net from "node:net"
import * as Tool from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
Expand All @@ -9,6 +12,120 @@ import { isImageAttachment } from "@/util/media"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
const MAX_REDIRECTS = 5

/**
* Escape hatch for the SSRF guard below. Users who legitimately need to fetch
* private/internal addresses (e.g. a dev server on localhost) can set
* OPENCODE_WEBFETCH_ALLOW_PRIVATE=1; tests provide this reference directly.
*/
export const AllowPrivateFetch = Context.Reference<boolean>("~opencode/webfetch/AllowPrivateFetch", {
defaultValue: () => {
const value = process.env["OPENCODE_WEBFETCH_ALLOW_PRIVATE"]
return value === "1" || value === "true"
},
})

// SSRF protection: reject targets that resolve to loopback, link-local, or
// private address ranges. Without this, a webfetch (once permitted) could reach
// internal services or cloud metadata endpoints (e.g. 169.254.169.254).

function isPrivateIPv4(p: readonly number[]): boolean {
const [a, b, c] = p as [number, number, number]
if (a === 0) return true // "this" network (0.0.0.0/8)
if (a === 10) return true // private (10.0.0.0/8)
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT (100.64.0.0/10)
if (a === 127) return true // loopback (127.0.0.0/8)
if (a === 169 && b === 254) return true // link-local (169.254.0.0/16)
if (a === 172 && b >= 16 && b <= 31) return true // private (172.16.0.0/12)
if (a === 192 && b === 0 && (c === 0 || c === 2)) return true // IETF (192.0.0.0/24) + TEST-NET-1 (192.0.2.0/24)
if (a === 192 && b === 168) return true // private (192.168.0.0/16)
if (a === 198 && (b === 18 || b === 19)) return true // benchmarking (198.18.0.0/15)
if (a === 198 && b === 51 && c === 100) return true // TEST-NET-2 (198.51.100.0/24)
if (a === 203 && b === 0 && c === 113) return true // TEST-NET-3 (203.0.113.0/24)
if (a >= 224) return true // multicast (224.0.0.0/4), reserved (240.0.0.0/4), broadcast
return false
}

/**
* Expand a valid IPv6 address into its 8 16-bit words. Handles `::`
* compression, trailing dotted-quad (`::ffff:127.0.0.1`), and zone ids
* (`fe80::1%eth0`). Returns null when the string is not valid IPv6. String
* prefix checks are NOT sufficient here: `0:0:0:0:0:0:0:1` is loopback but
* doesn't contain "::1", and fe80::/10 spans first words fe80–febf.
*/
function ipv6Words(ip: string): number[] | null {
let addr = ip.split("%")[0]!
if (!net.isIPv6(addr)) return null
// Convert a trailing dotted-quad into its two hextets.
const tail = addr.slice(addr.lastIndexOf(":") + 1)
if (tail.includes(".")) {
const v4 = tail.split(".").map(Number)
const hex = `${((v4[0]! << 8) | v4[1]!).toString(16)}:${((v4[2]! << 8) | v4[3]!).toString(16)}`
addr = addr.slice(0, addr.lastIndexOf(":") + 1) + hex
}
const [head = "", rest] = addr.split("::")
const headWords = head === "" ? [] : head.split(":").map((h) => parseInt(h, 16))
if (!addr.includes("::")) return headWords.length === 8 ? headWords : null
const restWords = !rest ? [] : rest.split(":").map((h) => parseInt(h, 16))
const fill = 8 - headWords.length - restWords.length
if (fill < 0) return null
return [...headWords, ...Array(fill).fill(0), ...restWords]
}

function embeddedV4(hi: number, lo: number): number[] {
return [(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255]
}

function isPrivateIPv6(w: number[]): boolean {
const zeroThrough = (n: number) => w.slice(0, n).every((x) => x === 0)
if (zeroThrough(7) && w[7]! <= 1) return true // unspecified (::) and loopback (::1)
const w0 = w[0]!
if ((w0 & 0xffc0) === 0xfe80) return true // link-local (fe80::/10)
if ((w0 & 0xffc0) === 0xfec0) return true // site-local, deprecated (fec0::/10)
if ((w0 & 0xfe00) === 0xfc00) return true // unique local (fc00::/7)
if ((w0 & 0xff00) === 0xff00) return true // multicast (ff00::/8)
// Addresses that embed an IPv4 target inherit its privacy:
if (zeroThrough(5) && w[5] === 0xffff) return isPrivateIPv4(embeddedV4(w[6]!, w[7]!)) // IPv4-mapped (::ffff:0:0/96)
if (w0 === 0x64 && w[1] === 0xff9b && w.slice(2, 6).every((x) => x === 0))
return isPrivateIPv4(embeddedV4(w[6]!, w[7]!)) // NAT64 (64:ff9b::/96)
if (w0 === 0x2002) return isPrivateIPv4(embeddedV4(w[1]!, w[2]!)) // 6to4 (2002::/16)
return false
}

export function isPrivateIp(ip: string): boolean {
if (net.isIPv4(ip)) return isPrivateIPv4(ip.split(".").map(Number))
const words = ipv6Words(ip)
if (words) return isPrivateIPv6(words)
return false
}

const ALLOW_PRIVATE_HINT = "Set OPENCODE_WEBFETCH_ALLOW_PRIVATE=1 to allow fetching private/internal addresses."

export async function assertPublicUrl(rawUrl: string): Promise<void> {
// URL.hostname keeps brackets for IPv6 literals (e.g. "[::1]"); strip them so net.isIP works.
const host = new URL(rawUrl).hostname.replace(/^\[|\]$/g, "")
// Strip a trailing dot ("localhost." is the same host as "localhost").
const lower = host.toLowerCase().replace(/\.$/, "")
if (
lower === "localhost" ||
lower.endsWith(".localhost") ||
lower.endsWith(".local") ||
// Covers cloud-internal DNS such as metadata.google.internal and *.internal on GCP.
lower === "internal" ||
lower.endsWith(".internal")
) {
throw new Error(`Refusing to fetch internal host: ${host}. ${ALLOW_PRIVATE_HINT}`)
}
if (net.isIP(host)) {
if (isPrivateIp(host)) throw new Error(`Refusing to fetch private/loopback address: ${host}. ${ALLOW_PRIVATE_HINT}`)
return
}
const records = await dns.lookup(host, { all: true })
if (records.some((record) => isPrivateIp(record.address))) {
throw new Error(`Refusing to fetch host that resolves to a private address: ${host}. ${ALLOW_PRIVATE_HINT}`)
}
}

export const Parameters = Schema.Struct({
url: Schema.String.annotate({ description: "The URL to fetch content from" }),
Expand All @@ -25,7 +142,6 @@ export const WebFetchTool = Tool.define(
"webfetch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const httpOk = HttpClient.filterStatusOk(http)

return {
description: DESCRIPTION,
Expand All @@ -47,6 +163,19 @@ export const WebFetchTool = Tool.define(
},
})

// SSRF guard: block loopback / link-local / private targets. Redirect
// hops are re-validated in the fetch loop below. Remaining known gap:
// DNS rebinding (a hostname that resolves public here but private when
// the client re-resolves it) — closing it fully requires pinning the
// validated IP into the connection, which fetch does not expose.
const allowPrivate = yield* AllowPrivateFetch
if (!allowPrivate) {
yield* Effect.tryPromise({
try: () => assertPublicUrl(params.url),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
}

Comment on lines +166 to +178

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Does effect's FetchHttpClient or undici support pinning a resolved IP address (custom DNS lookup) per request to prevent DNS rebinding?

💡 Result:

Yes, it is possible to pin a resolved IP address in undici to prevent DNS rebinding [1][2][3]. While Effect's FetchHttpClient layer is designed for environments like Bun, browsers, or the global Node.js fetch [4], the underlying undici library (used by Node.js or when using NodeHttpClient) provides the necessary control via its Agent configuration [5][6]. To pin an IP address and close the DNS-rebinding time-of-check to time-of-use (TOCTOU) gap, you must provide a custom lookup function within the connect options of an undici Agent [1][7][3]. This hook allows you to perform your own DNS resolution (or use dns.lookup), validate the IP address (e.g., against a blocklist of private/internal ranges), and then return that validated IP so the socket connects directly to it, bypassing any further resolution [1][8]. Example implementation: import { Agent } from 'undici'; import dns from 'node:dns'; // Create a custom Agent with a pinned DNS lookup const safeAgent = new Agent({ connect: { lookup: (hostname, options, callback) => { // 1. Perform custom resolution dns.lookup(hostname, options, (err, address, family) => { if (err) return callback(err, address, family); // 2. Validate address (e.g., check for private IP ranges) if (isUnsafe(address)) { return callback(new Error('SSRF blocked'), address, family); } // 3. Return the validated, pinned address callback(null, address, family); }); }, }, }); // Use the agent for your request // const client = new Client(origin, { dispatcher: safeAgent }); // or // await fetch(url, { dispatcher: safeAgent }); Note that if you are using Effect, you should ensure you are utilizing the undici-compatible HTTP client (e.g., NodeHttpClient.layerUndici) rather than the generic FetchHttpClient layer, as the latter might rely on the global fetch environment where dispatcher injection may be restricted or have different compatibility characteristics [9][4][3]. Using undici directly allows you to control the socket-level connection details required for robust SSRF defense [1][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and nearby redirect/URL validation logic.
sed -n '1,320p' packages/opencode/src/tool/webfetch.ts

# Find the HTTP client implementation and any undici/custom-dispatcher usage.
rg -n "FetchHttpClient|NodeHttpClient|undici|dispatcher|lookup|assertPublicUrl|redirect" packages/opencode/src -S

Repository: CreatorGhost/TheCode

Length of output: 24147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant module shapes before reading specific slices.
ast-grep outline packages/opencode/src/tool/webfetch.ts --view expanded || true
ast-grep outline packages/opencode/src -g '*.ts' --view expanded | head -n 200 || true

Repository: CreatorGhost/TheCode

Length of output: 1171


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Probe whether a concrete dispatcher/lookup hook is available in the codebase.
rg -n "new Agent|lookup:|custom.*lookup|setGlobalDispatcher|Agent\(" packages/opencode/src -S

Repository: CreatorGhost/TheCode

Length of output: 1874


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether the package uses Effect's FetchHttpClient or NodeHttpClient elsewhere.
rg -n "FetchHttpClient|NodeHttpClient|layerUndici|HttpClient" packages/opencode/src packages/opencode -g '*.ts' -S

Repository: CreatorGhost/TheCode

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read package dependency info if available to infer runtime/fetch backend.
cat package.json 2>/dev/null || true
find packages/opencode -maxdepth 2 -name package.json -print -exec sed -n '1,220p' {} \;

Repository: CreatorGhost/TheCode

Length of output: 11129


🌐 Web query:

Effect unstable/http FetchHttpClient.RequestInit dispatcher custom lookup undici pin IP

💡 Result:

To pin an IP address or use a custom DNS lookup in Node.js using Undici, you must provide a custom lookup function within the connect options of a dispatcher (such as Client, Pool, or Agent) [1][2]. The standard approach involves creating an instance of a dispatcher with a connect configuration that overrides the default DNS lookup behavior [2]. Example using Undici Client: import { Client } from 'undici'; import dns from 'node:dns'; const client = new Client('https://example.com', { connect: { lookup: (hostname, options, callback) => { // Pin to a specific IP address callback(null, [{ address: '1.2.3.4', family: 4 }]); } } }); In the context of the Effect framework (e.g., @effect/platform/FetchHttpClient), you can provide a custom dispatcher by configuring the underlying HTTP client [3]. Because FetchHttpClient relies on Undici for its implementation, you can instantiate an Agent or Client with the custom connect options shown above and pass it to your fetch requests or provide it via the Effect layer [1][3]. Note that the dispatcher option in fetch or RequestInit is used to override the default global dispatcher [4]. Ensure that when you use a custom dispatcher, it is configured correctly to handle the requirements of your specific application [5]. The lookup function signature follows the standard Node.js dns.lookup callback pattern [6][7].

Citations:


🌐 Web query:

Bun fetch dispatcher lookup undici custom Agent DNS rebinding

💡 Result:

In Bun, using a custom Undici Agent to control DNS resolution—a common technique to prevent DNS rebinding—has historically faced compatibility issues because Bun's native fetch did not always fully respect custom dispatchers or their connect functions [1][2]. However, as of August 2025, Bun has implemented support for custom connect functions within the dispatcher option for fetch [3]. This improvement ensures that when you provide an Undici Agent (configured with a custom connect lookup) to the fetch dispatcher option, Bun correctly utilizes that logic to resolve hostnames [3]. To implement a DNS-rebinding-safe client in Bun, you can follow this pattern: 1. Perform your own DNS lookup for the target hostname [4]. 2. Validate the resolved IP address against your security policy (e.g., ensuring it is not a private or loopback address) [4]. 3. Create an Undici Agent with a custom connect.lookup function that returns your pre-validated IP address [4]. 4. Pass this Agent as the dispatcher option to your fetch request [4]. Example implementation: import { Agent } from "undici"; import dns from "dns/promises"; // 1. Resolve and validate IP manually const { address } = await dns.lookup("example.com"); // 2. Define custom dispatcher const dispatcher = new Agent({ connect: { lookup: (_hostname, _options, cb) => { // Force the agent to use the validated IP cb(null, address, 4); }, }, }); // 3. Use in fetch const response = await fetch("https://example.com", { dispatcher }); While Bun continues to improve its Undici compatibility to support libraries like fetch-socks or custom agents [5][2], always verify your specific Bun version if you encounter unexpected behavior, as some features were implemented recently as part of maintaining ecosystem compatibility [3][5].

Citations:


🌐 Web query:

"effect/unstable/http" "FetchHttpClient.RequestInit" RequestInit service dispatcher

💡 Result:

In Effect, FetchHttpClient is an implementation of the HttpClient service provided by the @effect/platform package [1][2]. The RequestInit configuration for FetchHttpClient allows you to customize the underlying Fetch API request settings [3][1]. To customize the FetchHttpClient, you provide a configuration object using the FetchHttpClient.RequestInit tag [1][2]. This is typically done by creating a Layer that provides this configuration to the runtime [1][2]. Example usage: import { FetchHttpClient, HttpClient } from "@effect/platform" import { Effect, Layer } from "effect" const CustomFetchLive = FetchHttpClient.layer.pipe( Layer.provide( Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include", cache: "force-cache" // Example of customizing cache behavior [4] }))) const program = Effect.gen(function* { const client = yield* HttpClient.HttpClient const response = yield* client.get("https://jsonplaceholder.typicode.com/posts/1") const json = yield* response.json console.log(json) }).pipe( Effect.scoped, Effect.provide(CustomFetchLive)) Regarding a "service dispatcher," the HttpClient service in Effect functions as an abstraction over HTTP operations [5]. It does not typically use a "dispatcher" by that specific name in its public API; rather, it uses the Effect environment (Context) to provide the implementation (e.g., FetchHttpClient) and uses standard Effect piping and layering for dependency injection [1][2][5]. The HttpClient interface provides methods like get, post, put, and del, which are executed using the provided client implementation [5][6]. If you require custom dispatching or middleware logic, you can use HttpClient.mapRequest, HttpClient.mapRequestEffect, or create custom Layers that wrap or augment the base HttpClient implementation [5][6][7].

Citations:


🌐 Web query:

site:effect.website FetchHttpClient RequestInit dispatcher undici

💡 Result:

In the context of the Effect ecosystem, particularly within @effect/platform-node, the undici library is utilized as a backend for HTTP requests [1]. When using FetchHttpClient, which serves as an implementation of the HttpClient interface, the underlying communication is handled by undici [1][2]. Regarding the use of dispatcher with FetchHttpClient and RequestInit: 1. Undici Dispatcher: The undici library uses a Dispatcher to manage HTTP requests. When FetchHttpClient is employed, it is typically configured via a layer (e.g., FetchHttpClient.layer or layerUndici) [3][4]. If you need to customize the undici client (such as providing a specific dispatcher), this is generally handled at the layer creation level when configuring the HttpClient service, rather than through individual RequestInit objects [3][2]. 2. RequestInit: The standard RequestInit interface (used by the native fetch API) does not natively support an undici-specific dispatcher property. RequestInit is intended for standard fetch options like method, headers, and body [5]. 3. Implementation Detail: Because FetchHttpClient is an Effect-based abstraction, configuration that requires underlying engine details—like a custom undici.Dispatcher—is managed through Effect's dependency injection system (Layers) [3][2]. You should configure the HttpClient layer with your required undici settings (like a custom Agent or Dispatcher) when providing the layer to your application, ensuring that all requests made through the injected HttpClient utilize the desired configuration [3][2]. If you are encountering issues where a global undici dispatcher is not being picked up or needs to be explicitly defined, verify that your layer configuration in the Effect program correctly injects the HttpClient with the undici client configured to your specifications [6][4].

Citations:


Close the DNS-rebinding gap at the transport layer. assertPublicUrl() only validates one DNS answer before the request; the fetch path can still re-resolve for the actual connection and each redirect hop. FetchHttpClient.RequestInit doesn’t expose per-request IP pinning, so this needs a custom HttpClient/dispatcher layer that reuses the validated address for the socket.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/tool/webfetch.ts` around lines 170 - 182, Replace the
current assertPublicUrl-only protection in the web fetch flow with a
transport-layer HttpClient/dispatcher that resolves and validates the target,
then pins and reuses the validated address for the connection and redirect hops.
Integrate this custom transport into the fetch request path while preserving the
allowPrivate bypass and existing error handling.

const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)

// Build Accept header based on requested format with q parameters for fallbacks
Expand All @@ -73,35 +202,78 @@ export const WebFetchTool = Tool.define(
"Accept-Language": "en-US,en;q=0.9",
}

const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers))

// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
const response = yield* httpOk.execute(request).pipe(
Effect.catchIf(
(err) =>
err.reason._tag === "StatusCodeError" &&
err.reason.response.status === 403 &&
err.reason.response.headers["cf-mitigated"] === "challenge",
() =>
httpOk.execute(
HttpClientRequest.get(params.url).pipe(
HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }),
),
),
),
// Redirects are followed manually so that EVERY hop is re-validated by
// the SSRF guard — otherwise a public URL could 302 to an internal
// address and the client would follow it silently. `redirect: "manual"`
// makes the fetch-based client surface 3xx responses; a client layer
// that still auto-follows simply never yields a 3xx here and degrades
// to the previous behavior.
const fetchOnce = (url: string) =>
http.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers))).pipe(
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
Effect.flatMap((response) =>
response.status === 403 && response.headers["cf-mitigated"] === "challenge"
? http.execute(
HttpClientRequest.get(url).pipe(
HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }),
),
)
: Effect.succeed(response),
),
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
)

const response = yield* Effect.gen(function* () {
let url = params.url
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const response = yield* fetchOnce(url)
const location = response.headers["location"]
if (response.status >= 300 && response.status < 400 && location) {
const next = new URL(location, url)
if (next.protocol !== "http:" && next.protocol !== "https:") {
throw new Error(`Refusing to follow redirect to non-http(s) URL: ${next}`)
}
if (!allowPrivate) {
yield* Effect.tryPromise({
try: () => assertPublicUrl(next.toString()),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
}
url = next.toString()
continue
}
if (response.status < 200 || response.status >= 300) {
throw new Error(`Request failed with status code: ${response.status}`)
}
return response
}
throw new Error(`Too many redirects (limit: ${MAX_REDIRECTS})`)
}).pipe(
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }),
)

// Check content length
// Check content length up front when advertised.
const contentLength = response.headers["content-length"]
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}

const arrayBuffer = yield* response.arrayBuffer
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
// Stream the body and abort as soon as the size cap is exceeded, instead of
// buffering the entire (possibly unbounded) response into memory first.
const chunks: Uint8Array[] = []
let received = 0
yield* response.stream.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
received += chunk.length
if (received > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
chunks.push(chunk)
}),
),
)
const arrayBuffer = Buffer.concat(chunks)

const contentType = response.headers["content-type"] || ""
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
Expand Down Expand Up @@ -155,21 +327,27 @@ export const WebFetchTool = Tool.define(
}),
)

function extractTextFromHTML(html: string) {
// Tags whose text content should never appear in extracted output.
const NON_TEXT_TAGS = new Set(["script", "style", "noscript", "iframe", "object", "embed"])

export function extractTextFromHTML(html: string) {
let text = ""
// Count only opens/closes of non-text tags by name. The previous version
// incremented on *every* nested open tag once inside a skipped region, which
// could desync (and skip the rest of the document) when a void/self-closing
// element did not emit a matching close. Tracking skip tags by name is robust
// to unbalanced void elements.
let skipDepth = 0

const parser = new Parser({
onopentag(name) {
if (skipDepth > 0 || ["script", "style", "noscript", "iframe", "object", "embed"].includes(name)) {
skipDepth++
}
if (NON_TEXT_TAGS.has(name)) skipDepth++
},
ontext(input) {
if (skipDepth === 0) text += input
},
onclosetag() {
if (skipDepth > 0) skipDepth--
onclosetag(name) {
if (NON_TEXT_TAGS.has(name) && skipDepth > 0) skipDepth--
},
})

Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/test/tool/webfetch-extract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test, expect, describe } from "bun:test"
import { extractTextFromHTML } from "../../src/tool/webfetch"

describe("extractTextFromHTML", () => {
test("keeps visible text and drops tags", () => {
expect(extractTextFromHTML("<p>Hello <b>world</b></p>")).toBe("Hello world")
})
test("removes <script> content", () => {
expect(extractTextFromHTML("<p>a</p><script>var x=1;</script><p>b</p>")).toBe("ab")
})
test("removes <style> content", () => {
expect(extractTextFromHTML("<style>.x{color:red}</style><p>c</p>")).toBe("c")
})
test("removes <iframe> content", () => {
expect(extractTextFromHTML("<p>vis</p><iframe>frame</iframe><p>vis2</p>")).toBe("visvis2")
})
test("a void element (<br>) does not desync skip tracking", () => {
// Regression: previously any nested open tag inside a skipped region could
// leave skipDepth stuck, swallowing the rest of the document. The <br> here
// must not prevent "after" (outside the script) from being extracted.
expect(extractTextFromHTML("<p>one<br>two</p><script>HIDDEN</script>after")).toBe("onetwoafter")
})
test("nested skip tags are fully removed", () => {
expect(extractTextFromHTML("<div>keep<script>no<style>x</style>more</script>tail</div>")).toBe("keeptail")
})
})
Loading
Loading