diff --git a/bun.lock b/bun.lock index 51ebb3f1069..1fe141c9046 100644 --- a/bun.lock +++ b/bun.lock @@ -249,6 +249,7 @@ "jose": "6.0.11", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", + "undici": "8.12.3", "which": "6.0.1", "zod": "catalog:", }, diff --git a/packages/core/package.json b/packages/core/package.json index 07ab612afba..939756c8873 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -64,6 +64,12 @@ "node": "./src/image/photon-wasm.node.ts", "default": "./src/image/photon-wasm.bun.ts" }, + "#client-tls": { + "workerd": "./src/effect/client-tls/client-tls.workerd.ts", + "bun": "./src/effect/client-tls/client-tls.bun.ts", + "node": "./src/effect/client-tls/client-tls.node.ts", + "default": "./src/effect/client-tls/client-tls.bun.ts" + }, "#v1-migration": { "types": "./src/database/v1-migration.bun.ts", "bun": "./src/database/v1-migration.bun.ts", @@ -129,6 +135,7 @@ "jose": "6.0.11", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", + "undici": "8.12.3", "which": "6.0.1", "zod": "catalog:" }, diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 4058f513f13..ba9299f689c 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -3,6 +3,7 @@ export * as AISDK from "./aisdk.js" import { makeLocationNode } from "@opencode/util/effect/app-node" import { httpFetch } from "@opencode/util/effect/app-node-platform" import { APICallError } from "@ai-sdk/provider" +import { ClientTls } from "#client-tls" import type { JSONSchema7, JSONValue, @@ -161,9 +162,12 @@ function prepareOptions(model: RuntimeInfo, pkg: string, outbound: Fetch) { } } + const https = String(input instanceof Request ? input.url : input).startsWith("https:") + const certified = tls && https ? ClientTls.sender(tls) : undefined + if (tls && https && !certified) throw new Error("Client certificates aren't supported on this runtime") // A followed redirect keeps the TLS options, so it would present the client certificate to another host. - if (tls) Object.assign(opts, { tls, redirect: "error" }) - const send: Fetch = isFetch(customFetch) ? customFetch : outbound + if (certified) Object.assign(opts, certified.init, { redirect: "error" }) + const send: Fetch = certified ? certified.fetch : isFetch(customFetch) ? customFetch : outbound const middleware = httpMiddleware.getStore() const res = await ( middleware diff --git a/packages/core/src/effect/app-node-platform.ts b/packages/core/src/effect/app-node-platform.ts index ae4afdf5288..d4de27864ef 100644 --- a/packages/core/src/effect/app-node-platform.ts +++ b/packages/core/src/effect/app-node-platform.ts @@ -1,9 +1,10 @@ import { LLMClient, RequestExecutor } from "@opencode/ai/route" import { Effect, Layer, Option } from "effect" -import { FetchHttpClient, HttpClient } from "effect/http" +import { FetchHttpClient, HttpClient, HttpClientError } from "effect/http" import { Socket } from "effect/socket" import { makeGlobalNode } from "@opencode/util/effect/app-node" import { httpClient } from "@opencode/util/effect/app-node-platform" +import { ClientTls } from "#client-tls" import { WebSocketConstructor } from "./websocket-constructor.js" // Bun's fetch ends a request after five quiet minutes, before provider routes' own header and chunk timeouts. @@ -18,8 +19,8 @@ const withoutBunTimeout = Layer.effect( }), ) -// Presents the model's client certificate (provider `settings.tls`) on its HTTPS requests. The TLS handshake only -// sends it to servers that request one. +// Presents the model's client certificate (provider `settings.tls`) on its HTTPS requests, through this runtime's +// certificate-capable fetch. The TLS handshake only sends it to servers that request one. const withClientCertificate = Layer.effect( HttpClient.HttpClient, Effect.gen(function* () { @@ -28,18 +29,25 @@ const withClientCertificate = Layer.effect( Effect.gen(function* () { const tls = yield* RequestExecutor.CurrentTls if (!tls || !request.url.startsWith("https:")) return yield* effect - const init: BunFetchRequestInit = Option.getOrElse( - yield* Effect.serviceOption(FetchHttpClient.RequestInit), - () => ({}), - ) - return yield* effect.pipe( + const sender = ClientTls.sender(tls) + if (!sender) + return yield* new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "Client certificates aren't supported on this runtime", + }), + }) + const init: RequestInit = Option.getOrElse(yield* Effect.serviceOption(FetchHttpClient.RequestInit), () => ({})) + return yield* HttpClient.execute(request).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, sender.fetch), Effect.provideService(FetchHttpClient.RequestInit, { ...init, - tls: { ...init.tls, ...tls }, + ...sender.init, // A followed redirect keeps the TLS options, so it would present the certificate to whatever host the // server names. redirect: init.redirect === "manual" ? "manual" : "error", - } as BunFetchRequestInit), + }), ) }), ) diff --git a/packages/core/src/effect/client-tls/client-tls.bun.ts b/packages/core/src/effect/client-tls/client-tls.bun.ts new file mode 100644 index 00000000000..1ab10156444 --- /dev/null +++ b/packages/core/src/effect/client-tls/client-tls.bun.ts @@ -0,0 +1,8 @@ +import type { Sender, Tls } from "./client-tls.js" + +// Bun's fetch takes the client certificate per request. +export function sender(tls: Tls): Sender | undefined { + return { fetch: globalThis.fetch, init: { tls } } +} + +export * as ClientTls from "./client-tls.bun.js" diff --git a/packages/core/src/effect/client-tls/client-tls.node.ts b/packages/core/src/effect/client-tls/client-tls.node.ts new file mode 100644 index 00000000000..f4ea00d0345 --- /dev/null +++ b/packages/core/src/effect/client-tls/client-tls.node.ts @@ -0,0 +1,17 @@ +// The deep path keeps Vite, which runs under Bun where "undici" is a built-in, from leaving the import external. +import { Agent, fetch } from "undici/index.js" +import type { Sender, Tls } from "./client-tls.js" + +// Node's fetch only takes a client certificate through an undici dispatcher, and a dispatcher from the npm package +// only works with that package's own fetch, not Node's bundled one. One agent per certificate keeps its connections. +const agents = new Map() + +export function sender(tls: Tls): Sender | undefined { + const id = JSON.stringify([tls.cert, tls.key, tls.passphrase]) + const agent = agents.get(id) ?? new Agent({ connect: { cert: tls.cert, key: tls.key, passphrase: tls.passphrase } }) + agents.set(id, agent) + // SAFETY: undici's fetch implements the WHATWG fetch contract that FetchHttpClient and the AI SDK call. + return { fetch: fetch as unknown as typeof globalThis.fetch, init: { dispatcher: agent } } +} + +export * as ClientTls from "./client-tls.node.js" diff --git a/packages/core/src/effect/client-tls/client-tls.ts b/packages/core/src/effect/client-tls/client-tls.ts new file mode 100644 index 00000000000..ac62d4680df --- /dev/null +++ b/packages/core/src/effect/client-tls/client-tls.ts @@ -0,0 +1,12 @@ +/** PEM client certificate and private key, with the passphrase of an encrypted key. */ +export interface Tls { + readonly cert: string + readonly key: string + readonly passphrase?: string +} + +/** The `fetch` and request options that present a client certificate on this runtime. */ +export interface Sender { + readonly fetch: typeof globalThis.fetch + readonly init: Record +} diff --git a/packages/core/src/effect/client-tls/client-tls.workerd.ts b/packages/core/src/effect/client-tls/client-tls.workerd.ts new file mode 100644 index 00000000000..f1adea63fdb --- /dev/null +++ b/packages/core/src/effect/client-tls/client-tls.workerd.ts @@ -0,0 +1,8 @@ +import type { Sender, Tls } from "./client-tls.js" + +// Workers' fetch can't present a client certificate. Embedders use an mTLS certificate binding's fetch instead. +export function sender(_tls: Tls): Sender | undefined { + return undefined +} + +export * as ClientTls from "./client-tls.workerd.js" diff --git a/services/site/src/docs/content/network.mdx b/services/site/src/docs/content/network.mdx index a1756a470c9..202cf616a8d 100644 --- a/services/site/src/docs/content/network.mdx +++ b/services/site/src/docs/content/network.mdx @@ -117,6 +117,7 @@ TLS handshake only sends it to servers that ask for one. - `cert` and `key` are PEM text. Use `{file:...}` to read them from files. A file that can't be read leaves `tls` unset. - `passphrase` is only needed for an encrypted key. +- Works on the Bun and Node builds. A server on Cloudflare Workers can't present a client certificate through `fetch`. - Requests with a client certificate don't follow redirects, so the certificate isn't presented to another host. Every model request of that provider presents the certificate: chat, compaction, image and decision models. A provider