Skip to content

Commit d87f175

Browse files
author
alex
committed
feat(webfetch): route through fetch-use when BROWSER_USE_API_KEY is set
Adds @browser-use/bcode-browser/fetch-use Effect service that POSTs to https://fetch.browser-use.com/fetch with the X-Browser-Use-API-Key header. The webfetch tool consults fetchUse.enabled and swaps the HTTP call when the key is present, preserving the existing native HttpClient + cloudflare-retry path otherwise. Opt-out: BCODE_NO_FETCH_USE=1. Decisions ref: decisions.md S3.3 / ROADMAP B1+B2. No webfetch schema changes (session_id / output_format / proxy_country deferred until evals show demand). B3 retired by Phase H.
1 parent 69d6d41 commit d87f175

8 files changed

Lines changed: 211 additions & 49 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// fetch-use — Effect service that proxies HTTP requests through Browser Use's
2+
// fetch-use cloud (Chrome JA4 fingerprint, HTTP/2 header order, session-based
3+
// cookie persistence). See `memory/browsercode/fetch_use_reference.md` for
4+
// the API shape; decisions.md §3.3 + ROADMAP B1 for the rationale.
5+
//
6+
// The layer is always constructible. `enabled` reflects whether
7+
// BROWSER_USE_API_KEY is set and the user hasn't opted out via
8+
// BCODE_NO_FETCH_USE=1. Consumers (webfetch.ts) check `enabled` and fall back
9+
// to native HttpClient when false.
10+
11+
import { Context, Effect, Layer } from "effect"
12+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
13+
14+
const ENDPOINT = "https://fetch.browser-use.com/fetch"
15+
const DEFAULT_TIMEOUT_MS = 30_000
16+
17+
// Mirrors the Go FetchResponse type at
18+
// github.com/browser-use/fetch-use/internal/types/types.go. `headers` is
19+
// http.Header — `map[string][]string` over the wire — so each value is an
20+
// array of strings, not a single string.
21+
interface FetchUseRaw {
22+
status_code: number
23+
headers?: Record<string, string[]>
24+
body?: string
25+
body_base64?: string
26+
is_binary?: boolean
27+
error?: string
28+
}
29+
30+
export interface FetchOptions {
31+
readonly timeoutMs?: number
32+
}
33+
34+
export interface FetchResult {
35+
readonly body: ArrayBuffer
36+
readonly contentType: string
37+
readonly statusCode: number
38+
}
39+
40+
export interface Interface {
41+
readonly enabled: boolean
42+
readonly fetch: (url: string, opts?: FetchOptions) => Effect.Effect<FetchResult, Error>
43+
}
44+
45+
export class Service extends Context.Service<Service, Interface>()("@browser-use/FetchUse") {}
46+
47+
const headerValue = (h: Record<string, string[]> | undefined, key: string): string => {
48+
if (!h) return ""
49+
for (const [k, v] of Object.entries(h)) {
50+
if (k.toLowerCase() === key.toLowerCase()) return v[0] ?? ""
51+
}
52+
return ""
53+
}
54+
55+
export const layer = Layer.effect(
56+
Service,
57+
Effect.gen(function* () {
58+
const http = yield* HttpClient.HttpClient
59+
const apiKey = process.env.BROWSER_USE_API_KEY ?? ""
60+
const enabled = apiKey.length > 0 && process.env.BCODE_NO_FETCH_USE !== "1"
61+
62+
const fetch = (url: string, opts?: FetchOptions) =>
63+
Effect.gen(function* () {
64+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS
65+
const request = yield* HttpClientRequest.post(ENDPOINT).pipe(
66+
HttpClientRequest.setHeaders({
67+
"Content-Type": "application/json",
68+
"X-Browser-Use-API-Key": apiKey,
69+
}),
70+
HttpClientRequest.bodyJson({ url, timeout_ms: timeoutMs }),
71+
)
72+
const response = yield* HttpClient.filterStatusOk(http).execute(request)
73+
const data = (yield* response.json) as unknown as FetchUseRaw
74+
if (data.error) return yield* Effect.fail(new Error(`fetch-use: ${data.error}`))
75+
76+
const body =
77+
data.is_binary && data.body_base64
78+
? new Uint8Array(Buffer.from(data.body_base64, "base64")).buffer
79+
: new TextEncoder().encode(data.body ?? "").buffer
80+
81+
return {
82+
body: body as ArrayBuffer,
83+
contentType: headerValue(data.headers, "Content-Type"),
84+
statusCode: data.status_code,
85+
}
86+
}).pipe(Effect.mapError((e) => (e instanceof Error ? e : new Error(String(e)))))
87+
88+
return Service.of({ enabled, fetch })
89+
}),
90+
)
91+
92+
export * as FetchUse from "./fetch-use"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// FetchUse smoke tests.
2+
//
3+
// Unit: layer is constructible, `enabled` reflects env vars correctly.
4+
// Live: when BROWSER_USE_API_KEY is set, end-to-end POST to fetch.browser-use.com
5+
// returns body bytes + content-type. Skipped without the key.
6+
7+
import { expect, test } from "bun:test"
8+
import { Effect, Layer } from "effect"
9+
import { FetchHttpClient } from "effect/unstable/http"
10+
import { FetchUse } from "../src/fetch-use"
11+
12+
const haveKey = !!process.env.BROWSER_USE_API_KEY
13+
14+
test("layer constructs and exposes `enabled`", async () => {
15+
const enabled = await Effect.gen(function* () {
16+
const svc = yield* FetchUse.Service
17+
return svc.enabled
18+
}).pipe(
19+
Effect.provide(FetchUse.layer.pipe(Layer.provide(FetchHttpClient.layer))),
20+
Effect.runPromise,
21+
)
22+
expect(typeof enabled).toBe("boolean")
23+
expect(enabled).toBe(haveKey && process.env.BCODE_NO_FETCH_USE !== "1")
24+
})
25+
26+
test.skipIf(!haveKey)("live: fetches httpbin and returns body + content-type", async () => {
27+
const result = await Effect.gen(function* () {
28+
const svc = yield* FetchUse.Service
29+
return yield* svc.fetch("https://httpbin.org/get")
30+
}).pipe(
31+
Effect.provide(FetchUse.layer.pipe(Layer.provide(FetchHttpClient.layer))),
32+
Effect.runPromise,
33+
)
34+
35+
expect(result.statusCode).toBe(200)
36+
expect(result.contentType).toContain("application/json")
37+
const text = new TextDecoder().decode(result.body)
38+
const data = JSON.parse(text)
39+
expect(data.url).toBe("https://httpbin.org/get")
40+
})

packages/opencode/src/tool/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import path from "path"
3333
import { pathToFileURL } from "url"
3434
import { Effect, Layer, Context } from "effect"
3535
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
36+
import { FetchUse } from "@browser-use/bcode-browser/fetch-use"
3637
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
3738
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
3839
import { Ripgrep } from "../file/ripgrep"
@@ -85,6 +86,7 @@ export const layer: Layer.Layer<
8586
| AppFileSystem.Service
8687
| Bus.Service
8788
| HttpClient.HttpClient
89+
| FetchUse.Service
8890
| ChildProcessSpawner
8991
| Ripgrep.Service
9092
| Format.Service
@@ -349,6 +351,7 @@ export const defaultLayer = Layer.suspend(() =>
349351
Layer.provide(Instruction.defaultLayer),
350352
Layer.provide(AppFileSystem.defaultLayer),
351353
Layer.provide(Bus.layer),
354+
Layer.provide(FetchUse.layer),
352355
Layer.provide(FetchHttpClient.layer),
353356
Layer.provide(Format.defaultLayer),
354357
Layer.provide(CrossSpawnSpawner.defaultLayer),

packages/opencode/src/tool/webfetch.ts

Lines changed: 66 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Effect, Schema } from "effect"
22
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3+
import { FetchUse } from "@browser-use/bcode-browser/fetch-use"
34
import * as Tool from "./tool"
45
import TurndownService from "turndown"
56
import DESCRIPTION from "./webfetch.txt"
@@ -24,6 +25,7 @@ export const WebFetchTool = Tool.define(
2425
Effect.gen(function* () {
2526
const http = yield* HttpClient.HttpClient
2627
const httpOk = HttpClient.filterStatusOk(http)
28+
const fetchUse = yield* FetchUse.Service
2729

2830
return {
2931
description: DESCRIPTION,
@@ -47,61 +49,77 @@ export const WebFetchTool = Tool.define(
4749

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

50-
// Build Accept header based on requested format with q parameters for fallbacks
51-
let acceptHeader = "*/*"
52-
switch (params.format) {
53-
case "markdown":
54-
acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
55-
break
56-
case "text":
57-
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
58-
break
59-
case "html":
60-
acceptHeader =
61-
"text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
62-
break
63-
default:
64-
acceptHeader =
65-
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
66-
}
67-
const headers = {
68-
"User-Agent":
69-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
70-
Accept: acceptHeader,
71-
"Accept-Language": "en-US,en;q=0.9",
72-
}
52+
// BrowserCode: when fetch-use is enabled (BROWSER_USE_API_KEY set,
53+
// BCODE_NO_FETCH_USE != "1"), proxy through it for Chrome JA4
54+
// fingerprinting + HTTP/2 header order. Falls back to native
55+
// HttpClient with cloudflare-retry when disabled.
56+
const { arrayBuffer, contentType } = yield* (fetchUse.enabled
57+
? fetchUse
58+
.fetch(params.url, { timeoutMs: timeout })
59+
.pipe(Effect.map((r) => ({ arrayBuffer: r.body, contentType: r.contentType })))
60+
: Effect.gen(function* () {
61+
// Build Accept header based on requested format with q parameters for fallbacks
62+
let acceptHeader = "*/*"
63+
switch (params.format) {
64+
case "markdown":
65+
acceptHeader =
66+
"text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
67+
break
68+
case "text":
69+
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
70+
break
71+
case "html":
72+
acceptHeader =
73+
"text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
74+
break
75+
default:
76+
acceptHeader =
77+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
78+
}
79+
const headers = {
80+
"User-Agent":
81+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
82+
Accept: acceptHeader,
83+
"Accept-Language": "en-US,en;q=0.9",
84+
}
7385

74-
const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers))
75-
76-
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
77-
const response = yield* httpOk.execute(request).pipe(
78-
Effect.catchIf(
79-
(err) =>
80-
err.reason._tag === "StatusCodeError" &&
81-
err.reason.response.status === 403 &&
82-
err.reason.response.headers["cf-mitigated"] === "challenge",
83-
() =>
84-
httpOk.execute(
85-
HttpClientRequest.get(params.url).pipe(
86-
HttpClientRequest.setHeaders({ ...headers, "User-Agent": "browsercode" }),
86+
const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers))
87+
88+
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
89+
const response = yield* httpOk.execute(request).pipe(
90+
Effect.catchIf(
91+
(err) =>
92+
err.reason._tag === "StatusCodeError" &&
93+
err.reason.response.status === 403 &&
94+
err.reason.response.headers["cf-mitigated"] === "challenge",
95+
() =>
96+
httpOk.execute(
97+
HttpClientRequest.get(params.url).pipe(
98+
HttpClientRequest.setHeaders({ ...headers, "User-Agent": "browsercode" }),
99+
),
100+
),
87101
),
88-
),
89-
),
90-
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }),
91-
)
92-
93-
// Check content length
94-
const contentLength = response.headers["content-length"]
95-
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
96-
throw new Error("Response too large (exceeds 5MB limit)")
97-
}
102+
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }),
103+
)
104+
105+
// Check content length
106+
const contentLength = response.headers["content-length"]
107+
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
108+
throw new Error("Response too large (exceeds 5MB limit)")
109+
}
110+
111+
const arrayBuffer = yield* response.arrayBuffer
112+
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
113+
throw new Error("Response too large (exceeds 5MB limit)")
114+
}
115+
116+
return { arrayBuffer, contentType: response.headers["content-type"] || "" }
117+
}))
98118

99-
const arrayBuffer = yield* response.arrayBuffer
100119
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
101120
throw new Error("Response too large (exceeds 5MB limit)")
102121
}
103122

104-
const contentType = response.headers["content-type"] || ""
105123
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
106124
const title = `${params.url} (${contentType})`
107125

packages/opencode/test/session/prompt.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { Shell } from "../../src/shell/shell"
3939
import { Snapshot } from "../../src/snapshot"
4040
import { ToolRegistry } from "@/tool/registry"
4141
import { Truncate } from "@/tool/truncate"
42+
import { FetchUse } from "@browser-use/bcode-browser/fetch-use"
4243
import * as Log from "@opencode-ai/core/util/log"
4344
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
4445
import * as Database from "../../src/storage/db"
@@ -176,6 +177,7 @@ function makeHttp() {
176177
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
177178
const registry = ToolRegistry.layer.pipe(
178179
Layer.provide(Skill.defaultLayer),
180+
Layer.provide(FetchUse.layer),
179181
Layer.provide(FetchHttpClient.layer),
180182
Layer.provide(CrossSpawnSpawner.defaultLayer),
181183
Layer.provide(Ripgrep.defaultLayer),

packages/opencode/test/session/snapshot-tool-race.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { SessionRunState } from "../../src/session/run-state"
5050
import { SessionStatus } from "../../src/session/status"
5151
import { Snapshot } from "../../src/snapshot"
5252
import { ToolRegistry } from "@/tool/registry"
53+
import { FetchUse } from "@browser-use/bcode-browser/fetch-use"
5354
import { Truncate } from "@/tool/truncate"
5455
import { AppFileSystem } from "@opencode-ai/core/filesystem"
5556
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -126,6 +127,7 @@ function makeHttp() {
126127
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
127128
const registry = ToolRegistry.layer.pipe(
128129
Layer.provide(Skill.defaultLayer),
130+
Layer.provide(FetchUse.layer),
129131
Layer.provide(FetchHttpClient.layer),
130132
Layer.provide(CrossSpawnSpawner.defaultLayer),
131133
Layer.provide(Ripgrep.defaultLayer),

packages/opencode/test/tool/registry.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { LSP } from "@/lsp/lsp"
1919
import { Instruction } from "@/session/instruction"
2020
import { Bus } from "@/bus"
2121
import { FetchHttpClient } from "effect/unstable/http"
22+
import { FetchUse } from "@browser-use/bcode-browser/fetch-use"
2223
import { Format } from "@/format"
2324
import { Ripgrep } from "@/file/ripgrep"
2425
import * as Truncate from "@/tool/truncate"
@@ -42,6 +43,7 @@ const registryLayer = ToolRegistry.layer.pipe(
4243
Layer.provide(Instruction.defaultLayer),
4344
Layer.provide(AppFileSystem.defaultLayer),
4445
Layer.provide(Bus.layer),
46+
Layer.provide(FetchUse.layer),
4547
Layer.provide(FetchHttpClient.layer),
4648
Layer.provide(Format.defaultLayer),
4749
Layer.provide(node),

packages/opencode/test/tool/webfetch.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
22
import path from "path"
33
import { Effect, Layer } from "effect"
44
import { FetchHttpClient } from "effect/unstable/http"
5+
import { FetchUse } from "@browser-use/bcode-browser/fetch-use"
56
import { Agent } from "../../src/agent/agent"
67
import { Truncate } from "@/tool/truncate"
78
import { Instance } from "../../src/project/instance"
@@ -31,7 +32,9 @@ function exec(args: { url: string; format: "text" | "markdown" | "html" }) {
3132
return WebFetchTool.pipe(
3233
Effect.flatMap((info) => info.init()),
3334
Effect.flatMap((tool) => tool.execute(args, ctx)),
34-
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
35+
Effect.provide(
36+
Layer.mergeAll(FetchUse.layer.pipe(Layer.provide(FetchHttpClient.layer)), FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer),
37+
),
3538
Effect.runPromise,
3639
)
3740
}

0 commit comments

Comments
 (0)