diff --git a/packages/bcode-browser/src/fetch-use.ts b/packages/bcode-browser/src/fetch-use.ts index 83562b982..8d8f87fe3 100644 --- a/packages/bcode-browser/src/fetch-use.ts +++ b/packages/bcode-browser/src/fetch-use.ts @@ -1,12 +1,13 @@ // FetchUse — Effect service that proxies HTTP through Browser Use's fetch-use // cloud (Chrome JA4, HTTP/2 header order, session cookies). Decisions §3.3. -// `enabled` is true when BROWSER_USE_API_KEY is set; webfetch.ts combines -// this with the user's `experimental.fetch_use` opencode.json setting. +// `enabled` selects this route when direct credentials are complete or any +// scoped-proxy setting is present. Partial proxy config stays selected and +// errors instead of silently falling back to native HTTP. import { Context, Effect, Layer } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" -const ENDPOINT = "https://fetch.browser-use.com/fetch" +const DEFAULT_ENDPOINT = "https://fetch.browser-use.com/fetch" export interface FetchResult { readonly body: ArrayBuffer @@ -27,33 +28,49 @@ export class Service extends Context.Service Effect.Effect }>()("@browser-use/FetchUse") {} -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const apiKey = process.env.BROWSER_USE_API_KEY ?? "" - return Service.of({ - enabled: apiKey.length > 0, - fetch: (url, { timeoutMs }) => - Effect.gen(function* () { - const request = yield* HttpClientRequest.post(ENDPOINT).pipe( - HttpClientRequest.setHeaders({ "Content-Type": "application/json", "X-Browser-Use-API-Key": apiKey }), - HttpClientRequest.bodyJson({ url, timeout_ms: timeoutMs }), - ) - const response = yield* HttpClient.filterStatusOk(http).execute(request) - const data = (yield* response.json) as unknown as FetchUseRaw - if (data.error) return yield* Effect.fail(new Error(`fetch-use: ${data.error}`)) - // Mirror native path's filterStatusOk: surface upstream HTTP errors as failures. - if (data.status_code >= 400) return yield* Effect.fail(new Error(`fetch-use: HTTP ${data.status_code}`)) - const body = data.is_binary && data.body_base64 - ? (new Uint8Array(Buffer.from(data.body_base64, "base64")).buffer as ArrayBuffer) - : (new TextEncoder().encode(data.body ?? "").buffer as ArrayBuffer) - const ct = - Object.entries(data.headers ?? {}).find(([k]) => k.toLowerCase() === "content-type")?.[1]?.[0] ?? "" - return { body, contentType: ct } - }).pipe(Effect.mapError((e) => (e instanceof Error ? e : new Error(String(e))))), - }) - }), -) +export const makeLayer = (options: { proxyUrl: string; apiKey: string; proxyToken: string }) => + Layer.effect( + Service, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const hasProxySetting = options.proxyUrl.length > 0 || options.proxyToken.length > 0 + const proxyEnabled = options.proxyUrl.length > 0 && options.proxyToken.length > 0 + const directEnabled = !hasProxySetting && options.apiKey.length > 0 + return Service.of({ + enabled: hasProxySetting || directEnabled, + fetch: (url, { timeoutMs }) => + Effect.gen(function* () { + if (!proxyEnabled && !directEnabled) { + return yield* Effect.fail(new Error("fetch-use credentials are missing or partially configured")) + } + const auth = proxyEnabled + ? { Authorization: `Bearer ${options.proxyToken}` } + : { "X-Browser-Use-API-Key": options.apiKey } + const request = yield* HttpClientRequest.post(proxyEnabled ? options.proxyUrl : DEFAULT_ENDPOINT).pipe( + HttpClientRequest.setHeaders({ "Content-Type": "application/json", ...auth }), + HttpClientRequest.bodyJson({ url, timeout_ms: timeoutMs }), + ) + const response = yield* HttpClient.filterStatusOk(http).execute(request) + const data = (yield* response.json) as unknown as FetchUseRaw + if (data.error) return yield* Effect.fail(new Error(`fetch-use: ${data.error}`)) + // Mirror native path's filterStatusOk: surface upstream HTTP errors as failures. + if (data.status_code >= 400) return yield* Effect.fail(new Error(`fetch-use: HTTP ${data.status_code}`)) + const body = + data.is_binary && data.body_base64 + ? (new Uint8Array(Buffer.from(data.body_base64, "base64")).buffer as ArrayBuffer) + : (new TextEncoder().encode(data.body ?? "").buffer as ArrayBuffer) + const ct = + Object.entries(data.headers ?? {}).find(([k]) => k.toLowerCase() === "content-type")?.[1]?.[0] ?? "" + return { body, contentType: ct } + }).pipe(Effect.mapError((e) => (e instanceof Error ? e : new Error(String(e))))), + }) + }), + ) + +export const layer = makeLayer({ + proxyUrl: process.env.BROWSER_USE_FETCH_URL ?? "", + apiKey: process.env.BROWSER_USE_API_KEY ?? "", + proxyToken: process.env.BROWSER_USE_FETCH_TOKEN ?? "", +}) export * as FetchUse from "./fetch-use" diff --git a/packages/bcode-browser/test/fetch-use.test.ts b/packages/bcode-browser/test/fetch-use.test.ts index 753118d57..876fac977 100644 --- a/packages/bcode-browser/test/fetch-use.test.ts +++ b/packages/bcode-browser/test/fetch-use.test.ts @@ -1,6 +1,6 @@ // FetchUse smoke tests. // -// Unit: layer is constructible, `enabled` reflects BROWSER_USE_API_KEY presence. +// Unit: layer is constructible, `enabled` reflects direct and proxy credentials. // Live: when the key is set, end-to-end POST to fetch.browser-use.com returns // body bytes + content-type. Skipped without the key. Config-based // opt-in (experimental.fetch_use=true) is enforced in webfetch.ts, @@ -11,16 +11,17 @@ import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { FetchUse } from "../src/fetch-use" -const haveKey = !!process.env.BROWSER_USE_API_KEY +const haveProxySetting = !!process.env.BROWSER_USE_FETCH_URL || !!process.env.BROWSER_USE_FETCH_TOKEN +const fetchUseSelected = haveProxySetting || !!process.env.BROWSER_USE_API_KEY -test("layer constructs and exposes `enabled` reflecting env", async () => { +test("layer selects fetch-use for direct or scoped-proxy configuration", async () => { const enabled = await Effect.gen(function* () { return (yield* FetchUse.Service).enabled }).pipe(Effect.provide(FetchUse.layer.pipe(Layer.provide(FetchHttpClient.layer))), Effect.runPromise) - expect(enabled).toBe(haveKey) + expect(enabled).toBe(fetchUseSelected) }) -test.skipIf(!haveKey)("live: fetches httpbin and returns body + content-type", async () => { +test.skipIf(!process.env.BROWSER_USE_API_KEY)("live: fetches httpbin and returns body + content-type", async () => { const result = await Effect.gen(function* () { return yield* (yield* FetchUse.Service).fetch("https://httpbin.org/get", { timeoutMs: 30_000 }) }).pipe(Effect.provide(FetchUse.layer.pipe(Layer.provide(FetchHttpClient.layer))), Effect.runPromise) @@ -28,3 +29,61 @@ test.skipIf(!haveKey)("live: fetches httpbin and returns body + content-type", a expect(result.contentType).toContain("application/json") expect(JSON.parse(new TextDecoder().decode(result.body)).url).toBe("https://httpbin.org/get") }) + +test("scoped proxy token uses bearer auth instead of the API-key header", async () => { + const request = { authorization: "", apiKey: "", body: {} as Record } + const server = Bun.serve({ + port: 0, + fetch: async (incoming) => { + request.authorization = incoming.headers.get("authorization") ?? "" + request.apiKey = incoming.headers.get("x-browser-use-api-key") ?? "" + request.body = (await incoming.json()) as Record + return Response.json({ + status_code: 200, + headers: { "content-type": ["text/plain"] }, + body: "proxied", + }) + }, + }) + + try { + const result = await Effect.gen(function* () { + const service = yield* FetchUse.Service + expect(service.enabled).toBe(true) + return yield* service.fetch("https://example.com/page", { timeoutMs: 12_345 }) + }).pipe( + Effect.provide( + FetchUse.makeLayer({ + proxyUrl: server.url.toString(), + apiKey: "", + proxyToken: "test-proxy-token", + }).pipe(Layer.provide(FetchHttpClient.layer)), + ), + Effect.runPromise, + ) + + expect(request.authorization).toBe("Bearer test-proxy-token") + expect(request.apiKey).toBe("") + expect(request.body).toEqual({ url: "https://example.com/page", timeout_ms: 12_345 }) + expect(new TextDecoder().decode(result.body)).toBe("proxied") + } finally { + server.stop(true) + } +}) + +test.each([ + { proxyUrl: "https://proxy.example/fetch", proxyToken: "", apiKey: "test-direct-key" }, + { proxyUrl: "", proxyToken: "test-proxy-token", apiKey: "test-direct-key" }, +])("partial proxy configuration fails closed", async (options) => { + const result = await Effect.gen(function* () { + const service = yield* FetchUse.Service + // Keep this route selected so webfetch cannot silently use native HTTP. + expect(service.enabled).toBe(true) + return yield* Effect.flip(service.fetch("https://example.com", { timeoutMs: 1_000 })) + }).pipe( + Effect.provide(FetchUse.makeLayer(options).pipe(Layer.provide(FetchHttpClient.layer))), + Effect.runPromise, + ) + + expect(result.message).toContain("partially configured") +})