fix(core): present client certificates on both the Bun and Node runtimes

This commit is contained in:
Aiden Cline
2026-11-08 09:59:18 -06:00
Unverified
parent c59944796b
commit a213ef5124
9 changed files with 78 additions and 12 deletions
+1
View File
@@ -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:",
},
+7
View File
@@ -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:"
},
+6 -2
View File
@@ -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
+18 -10
View File
@@ -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),
}),
)
}),
)
@@ -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"
@@ -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<string, Agent>()
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"
@@ -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<string, unknown>
}
@@ -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"
@@ -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