import type { OcxProviderConfig } from "../types"; import { assessUrlDestination, DestinationDnsResolutionError, providerAllowsPrivateNetwork, providerDestinationConfigError, resolvePublicAddresses, } from "./destination-policy"; import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, schemeMatchedProxyFor } from "./proxy-env"; import { InvalidProviderEgressError, resolveProviderEgress } from "./provider-egress"; import { publicProviderBaseUrl } from "./provider-url"; type ProviderGetInit = Omit; type ProviderPostInit = ProviderGetInit & { body: string }; type ProviderOutboundConfig = Pick & { fetch?: typeof globalThis.fetch; }; export interface ProviderOutboundDependencies { resolveAddresses?: typeof resolvePublicAddresses; pinnedGet?: typeof pinnedHttpGet; pinnedPost?: typeof pinnedHttpPost; /** * Canonical-URL proof for the transparent fake-IP exception (Clash TUN mode * without proxy env). Injected so this transport core stays decoupled from * the registry module; production compares the final request URL against the * registry's own fixed discovery URL. Defaults to "not canonical" so a caller * that forgets the seam fails closed, never open. */ isCanonicalUrl?: (name: string, url: string) => boolean; } export class ProviderOutboundPolicyError extends Error { override readonly name = "ProviderOutboundPolicyError"; } function pickPinnedAddress(addresses: Array<{ address: string; family: number }>): { address: string; family: number } { return addresses.find(address => address.family === 4) ?? addresses[0]!; } /** * Registry-owned fake-IP transparency exception (Clash/Surge/Mihomo TUN mode). * * Under TUN mode the packet path intercepts the fake-IP destination itself, so a * canonical registry destination whose local DNS answers include Clash fake-IP * space (198.18.0.0/15 or fdfe:dcba:9876::/48) is reachable by pin-connecting through the TUN — no * outbound HTTP(S) proxy env is required. The exception is deliberately narrow: * * - hostname-only: a literal 198.18.x.x URL never reaches it (the literal gate * in `resolvePublicAddresses` rejects before DNS answers are examined); * - canonical-URL-only: `isCanonicalUrl` must prove the FINAL request URL is * the registry's own fixed discovery URL for this provider (not merely that * the provider NAME matches — OAuth/forward names match any baseUrl by * design, and the bearer is pinned to the registry destination independently * in `buildModelsRequest`). A retargeted row or a renamed custom row sends * its credential to the registry URL anyway, so the proof must be on the URL * actually fetched. The check is injected so the transport core stays * decoupled from the registry module; * - per-answer validation: benchmark and public answers may coexist. The exception * does not admit loopback/RFC1918/link-local/metadata companions; those still * follow the resolver's private-network policy. Benchmark admission leaves * `privateNetwork` false; proxy/NO_PROXY semantics are unchanged. * * Image/Lab fetch never passes the underlying flag and is unaffected. */ function transparentFakeIpException( url: string, parsed: URL, isCanonicalUrl: (name: string, url: string) => boolean, name: string, ): boolean { if (noProxyMatches(parsed)) return false; return isCanonicalUrl(name, url); } let proxyBoundaryWarned = false; let proxyDnsDegradationWarned = false; function warnProxyBoundaryOnce(): void { if (proxyBoundaryWarned) return; proxyBoundaryWarned = true; console.warn( "[opencodex] Provider outbound proxy mode preserves Bun proxy/NO_PROXY routing and validates " + "the URL plus available local DNS results; the final route and peer cannot be pinned locally.", ); } function warnProxyDnsDegradationOnce(): void { if (proxyDnsDegradationWarned) return; proxyDnsDegradationWarned = true; console.warn( "[opencodex] Local DNS could not resolve a proxied provider hostname; continuing after URL/literal checks. " + "The proxy-selected peer cannot be verified or pinned locally.", ); } export async function providerRedirectError(response: Response, requestUrl: string): Promise { if (response.status < 300 || response.status >= 400) return null; try { await response.body?.cancel(); } catch { /* ignore cancellation failures */ } const location = response.headers.get("location"); let target = "the final upstream URL"; if (location) { try { target = publicProviderBaseUrl(new URL(location, requestUrl).toString()); } catch { /* keep fallback */ } } return `provider returned ${response.status} redirect to ${target}; configure the final provider URL directly`; } /** * Default client identity for proxy-originated provider outbound requests. * * Every request through the provider outbound wrapper — connection tests, model * discovery, quota probes — is initiated by the proxy itself, so there is no * client request to inherit a User-Agent from, and the pinned Node-style * transport sends none. WAF/CDN front ends commonly answer UA-less requests * with a 403 that surfaced as "provider added but no models" (#5104). A caller * that materializes its own User-Agent — registry static headers, provider * `headers`, or a vendor-specific client fingerprint — keeps that value and is * never given a second User-Agent; this only fills the name nobody claimed. * The value is what survives, not its spelling: both the pinned transport and * the SOCKS transport rebuild the set through `new Headers()`, which lowercases * every name before it reaches the wire. Inference traffic never uses this * wrapper, so the client-fingerprint rationale of #1751 is unaffected. */ const PROVIDER_OUTBOUND_DEFAULT_USER_AGENT = "opencodex"; function hasUserAgentHeader(headers: HeadersInit | null | undefined): boolean { if (!headers) return false; if (headers instanceof Headers) return headers.has("user-agent"); if (Array.isArray(headers)) return headers.some(([name]) => name.toLowerCase() === "user-agent"); return Object.keys(headers).some(name => name.toLowerCase() === "user-agent"); } function withDefaultOutboundUserAgent( init: ProviderGetInit | ProviderPostInit, ): ProviderGetInit | ProviderPostInit { const headers = init.headers; if (hasUserAgentHeader(headers)) return init; if (headers instanceof Headers) { const merged = new Headers(headers); merged.set("User-Agent", PROVIDER_OUTBOUND_DEFAULT_USER_AGENT); return { ...init, headers: merged }; } if (Array.isArray(headers)) { return { ...init, headers: [...headers, ["User-Agent", PROVIDER_OUTBOUND_DEFAULT_USER_AGENT]] }; } return { ...init, headers: { ...(headers ?? {}), "User-Agent": PROVIDER_OUTBOUND_DEFAULT_USER_AGENT } }; } async function providerOutboundRequest( name: string, provider: ProviderOutboundConfig, url: string, method: "GET" | "POST", rawInit: ProviderGetInit | ProviderPostInit, dependencies: ProviderOutboundDependencies = {}, ): Promise { // See PROVIDER_OUTBOUND_DEFAULT_USER_AGENT: this wrapper only carries proxy-originated // diagnostic traffic, so it identifies itself unless the caller already did. const init = withDefaultOutboundUserAgent(rawInit); const postUrl = method === "POST" ? new URL(url) : undefined; if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") { throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS"); } // A provider entry keeps unknown configuration keys, so `fetch` can arrive as a value the // operator wrote into the file rather than an executor a caller attached. Calling that would // throw inside discovery and fail the provider for a reason nothing in its configuration // explains; the built-in transport is what a configured value means. if (typeof provider.fetch === "function") { // A caller-owned executor decides its own transport, so a provider egress route cannot be // applied to it. Refusing is the only honest answer: running the executor anyway would send // the request by whatever route that executor picked while the configuration says otherwise. if (resolveProviderEgress({ providerName: name, provider, url }).kind !== "inherit") { throw new InvalidProviderEgressError( "proxy", "a caller-supplied fetch executor owns its own routing, so this route cannot be applied", `providers.${name}.proxy cannot be applied to a caller-supplied fetch executor; ` + "remove the provider egress override or the custom executor", ); } // A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config // checks and redirect blocking, but does not provide the resolved-address guarantees of // the built-in transport. Main-request migration must define that executor contract first. const assessment = assessUrlDestination(url); if (assessment?.kind === "metadata" || assessment?.kind === "link-local" || assessment?.kind === "unspecified") { throw new ProviderOutboundPolicyError(`provider URL targets ${assessment.detail}`); } // Registry defaults count here too, not just the operator flag: a stock Ollama entry is // local by definition and previously passed config validation only to be refused at the // fetch (#758). Metadata/link-local/unspecified were already rejected above. const allowPrivate = providerAllowsPrivateNetwork(name, provider); if (!allowPrivate) { const destinationError = providerDestinationConfigError(name, { baseUrl: url, allowPrivateNetwork: false, }); if (destinationError) throw new ProviderOutboundPolicyError(destinationError); } return provider.fetch(url, { ...init, method, redirect: "manual" }); } const parsed = postUrl ?? new URL(url); // The provider's own route, decided against this request URL. `inherit` leaves every value // below exactly as the global decision computed it. const egress = resolveProviderEgress({ providerName: name, provider, url: parsed }); const providerProxy = egress.kind === "proxy" ? egress.proxyUrl : null; // Snapshot the proxy fetch would actually use once, before the DNS await, so admission // and transport below reason about the same value. `null` here means "no proxy fetch // would actually use", even if some other proxy variable is set. const globalProxy = effectiveProxyFor(parsed); // The request leaves the DNS-pinned transport only when a proxy will actually carry it: // a proxy variable fetch would use for this URL that NO_PROXY does not exempt. // A scheme-mismatched or unusable variable, a NO_PROXY match, or an ALL_PROXY // this target's scheme cannot use must not downgrade pinning or admit // proxy-only DNS answers. // // A provider route replaces that decision outright rather than combining with it. An // explicit provider proxy applies even where global NO_PROXY exempts the host, because the // operator named this proxy for this provider; `providers..noProxy` is the exemption // that belongs to that choice, and `resolveProviderEgress` has already applied it. A // provider pinned to `direct` keeps the DNS-pinned transport, which reaches the peer // through no proxy at all — the one route on this path that needs nothing from Bun. const proxyApplies = egress.kind === "inherit" ? globalProxy !== null && !noProxyMatches(parsed) : providerProxy !== null; const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false); // The IPv6 fake-IP gate keeps its stricter documented condition — a // scheme-matched variable or a SOCKS5 ALL_PROXY, never a non-SOCKS // ALL_PROXY — even when proxyApplies admits one for the transport // decision, because admission binds the fetch to this value explicitly. // An explicit provider proxy is exactly such a binding: the fetch below is pinned to it. const bindingProxy = egress.kind === "inherit" ? schemeMatchedProxyFor(parsed) : providerProxy; const allowMihomoIpv6FakeIp = (bindingProxy !== null && (providerProxy !== null || !noProxyMatches(parsed))) || transparentFakeIpException(url, parsed, isCanonicalUrl, name); const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet; const pinnedPost = dependencies.pinnedPost ?? pinnedHttpPost; const allowPrivate = providerAllowsPrivateNetwork(name, provider); let resolved: Awaited>; try { resolved = await resolveAddresses(url, { context: "provider URL", allowPrivateNetwork: allowPrivate, // Clash/Surge/Mihomo fake-IP DNS (198.18.0.0/15) answers are admitted only // when this exact host will use the configured outbound proxy: the hostname // then rides the proxy as an ordinary CONNECT instead of failing as a private // destination or being pin-connected to the fake-IP (credit #1748). A NO_PROXY // match is a direct route, so it keeps the benchmark answer rejected. Image/Lab // fetch never passes this flag. // // TUN-mode transparency: with no proxy env, Clash/Surge/Mihomo TUN still // intercepts the fake-IP destination itself, so the REGISTRY's own fixed // discovery URL stays reachable by pin-connecting through the TUN. The // proof is on the final request URL — not the provider name — because an // OAuth/forward name matches any baseUrl by design while the bearer is // pinned to the registry destination independently. allowBenchmarkAddresses: proxyApplies || transparentFakeIpException(url, parsed, isCanonicalUrl, name), // Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted either when bound // to a scheme-matched proxy (#3462) or under the TUN transparency exception for a // canonical registry/accounting destination. allowMihomoIpv6FakeIp, }); } catch (error) { const dnsResolutionFailed = error instanceof DestinationDnsResolutionError || (error instanceof Error && error.name === "DestinationDnsResolutionError"); if (!dnsResolutionFailed) { throw new ProviderOutboundPolicyError(error instanceof Error ? error.message : "provider destination was blocked"); } if (!proxyApplies) throw error; warnProxyBoundaryOnce(); warnProxyDnsDegradationOnce(); // An explicit provider proxy stays pinned through the degradation too; re-inferring the // route from the environment here would quietly move the request to a different exit. return configuredOutboundFetch(url, { ...init, method, redirect: "manual", ...(providerProxy ? { proxy: providerProxy } : {}), }); } // A canonical TUN exception with no scheme-matched proxy must retain the // validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present. if (proxyApplies && !resolved.privateNetwork) { warnProxyBoundaryOnce(); // When the Mihomo exception could have admitted an answer, pin the transport to the // proxy the admission assumed instead of letting fetch re-infer it from the environment. // An explicit provider proxy is always pinned, for the same reason and unconditionally: // the operator named the exit for this provider, so the environment must not re-decide it. const proxy = providerProxy ?? ((allowMihomoIpv6FakeIp && bindingProxy) ? bindingProxy : undefined); return configuredOutboundFetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) }); } if (proxyApplies && resolved.privateNetwork) { const hostname = normalizeProxyHostname(parsed.hostname); throw new Error( `provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`, ); } const requestOptions = { headers: init.headers, rejectUnauthorized: true, context: "provider response", }; const pinned = pickPinnedAddress(resolved.addresses); if (method === "POST") { return pinnedPost(url, pinned, (init as ProviderPostInit).body, init.signal ?? undefined, requestOptions); } return pinnedGet(url, pinned, init.signal ?? undefined, requestOptions); } export async function providerOutboundGet( name: string, provider: ProviderOutboundConfig, url: string, init: ProviderGetInit = {}, dependencies: ProviderOutboundDependencies = {}, ): Promise { return providerOutboundRequest(name, provider, url, "GET", init, dependencies); } export async function providerOutboundPost( name: string, provider: ProviderOutboundConfig, url: string, init: ProviderPostInit, dependencies: ProviderOutboundDependencies = {}, ): Promise { return providerOutboundRequest(name, provider, url, "POST", init, dependencies); }