diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 9a0b822f52..cbf88b0ad7 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -141,6 +141,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | --- | --- | --- | | `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`), `codebuddy`, `qoder`. | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | +| `proxy?` | `string` | Optional provider-specific HTTP(S) proxy URL. When omitted, the provider inherits the server-level `proxy`, `noProxy`, and global `proxy: "auto"` behavior. Only `http://` and `https://` URLs are accepted; provider-level `auto`, `direct`, `null`, empty/whitespace values, SOCKS, and malformed URLs are rejected. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | @@ -312,6 +313,29 @@ These fields are available in `config.json`, the provider management API, and th JSON provider editor. Dedicated form controls are not present. Native root stamps record the previous value and restore it on removal when the stamped value has not been changed externally. +### Per-provider HTTP(S) proxy + +Set `providers..proxy` when one provider should use a different HTTP(S) proxy from +the server-level route: + +```jsonc +{ + "providers": { + "deepseek": { + "proxy": "http://127.0.0.1:7897" + } + } +} +``` + +Configure this field through the provider configuration file or a supported management API; the dashboard editor does not expose or round-trip proxy URLs because they may contain credentials. + +An explicit provider proxy is applied to the provider's core HTTP/SSE and provider outbound +requests. On the verified HTTP/HTTPS fetch paths, it takes precedence over the inherited +global `NO_PROXY` decision. Provider-level direct routing, automatic proxy discovery, and +`null` are not supported; omit the field to inherit the server-level behavior. Proxy credentials +may be included in the URL and are redacted from dashboard responses. + ### Discovered model display names Use `modelDisplayNames` when a provider returns machine friendly ids but the Codex model picker diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 30bcc28b47..1c882f13c3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1072,6 +1072,9 @@ "provider-discovery-log-suppression.test.ts": "providers", "provider-id-rewrite.test.ts": "providers", "provider-key-store.test.ts": "providers", + "provider-egress-fetch.test.ts": "responses", + "provider-egress-outbound.test.ts": "providers", + "provider-egress.test.ts": "lib", "provider-live-models.test.ts": "providers", "provider-model-aliases.test.ts": "providers", "provider-model-discovery-contract.test.ts": "providers", diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index cf66a6eab6..de4211f146 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -151,6 +151,25 @@ export function upstreamHttpVersionConfigError(value: unknown): string | null { return null; } +/** Validates an optional provider HTTP(S) proxy URL. */ +export function providerProxyConfigError(value: unknown): string | null { + if (value === undefined) return null; + if (typeof value !== "string") return "proxy must be a string URL or omitted"; + const trimmed = value.trim(); + if (!trimmed) return "proxy must not be empty; omit the field to inherit the global proxy behavior"; + const lowered = trimmed.toLowerCase(); + if (lowered === "direct") return "proxy direct is not supported in Phase 1A; use global noProxy for direct routes"; + if (lowered === "auto") return "proxy auto is not supported in Phase 1A; omit the field to inherit the global proxy behavior"; + let protocol: string; + try { + protocol = new URL(trimmed).protocol; + } catch { + return "proxy must be a valid http(s) proxy URL"; + } + if (protocol !== "http:" && protocol !== "https:") return "proxy must be an http(s) proxy URL"; + return null; + } + export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null { if (value === undefined) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index da959ce60d..9aee2737d1 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -35,6 +35,7 @@ import { positiveIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, + providerProxyConfigError, reasoningSummaryDeliveryRecordConfigError, } from "../provider-validation"; import { @@ -367,6 +368,14 @@ export const configSchema = z.object({ }); } } + const proxyError = providerProxyConfigError((provider as { proxy?: unknown }).proxy); + if (proxyError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "proxy"], + message: proxyError, + }); + } const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers); if (headersError) { ctx.addIssue({ diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index f9deab442b..a828ae6571 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -251,6 +251,8 @@ export const providerConfigSchema = z.object({ decodesNativeCompactionBlobs: z.boolean().optional(), allowEncryptedV2AgentTasks: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), + // Optional provider HTTP(S) proxy override; absent inherits global routing. + proxy: z.string().min(1).optional(), // The management API accepts `null` as "clear this", so a config written before the POST // canonicalization below can hold one on disk. Rejecting it here would send the operator // through invalid-config recovery for a value the API told them was fine. diff --git a/src/lib/provider-egress.ts b/src/lib/provider-egress.ts new file mode 100644 index 0000000000..58f10165ea --- /dev/null +++ b/src/lib/provider-egress.ts @@ -0,0 +1,116 @@ +// Resolves provider-scoped HTTP(S) proxy overrides. +import type { OcxProviderConfig } from "../types"; + +export class InvalidProviderEgressError extends Error { + override readonly name = "InvalidProviderEgressError"; +} + +export interface ProviderEgressContext { + providerName: string; + modelId?: string; + provider: Pick; + url: string | URL; + purpose?: string; +} + +export type ProviderEgress = + | { kind: "inherit" } + | { kind: "proxy"; proxyUrl: string; routeKey: string }; + +function egressFailure(providerName: string, reason: string, purpose?: string): never { + const scope = purpose ? " (" + purpose + ")" : ""; + throw new InvalidProviderEgressError( + "providers." + providerName + ".proxy is invalid" + scope + ": " + reason + ". " + + "Phase 1 supports only an explicit http(s) proxy URL; omit the field to inherit global behavior." + ); +} + +function fnv1aHex(input: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + const unsigned = hash >>> 0; + return unsigned.toString(16).padStart(8, "0"); +} + +// Builds a credential-free connection reuse key. +export function providerEgressRouteKey(proxyUrl: string): string { + const parsed = new URL(proxyUrl); + const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80"); + return "proxy|" + parsed.protocol + "//" + parsed.hostname.toLowerCase() + "|" + port + "|" + fnv1aHex(proxyUrl); +} + +// Returns a credential-free proxy origin for logs. +export function sanitizeProxyUrlForLog(proxyUrl: string): string { + try { + return new URL(proxyUrl).origin; + } catch { + return ""; + } +} + +export function describeProviderEgressForLog(egress: ProviderEgress): string { + if (egress.kind === "inherit") return "inherit"; + return "proxy(" + sanitizeProxyUrlForLog(egress.proxyUrl) + " route=" + egress.routeKey + ")"; +} + +export function egressRequestUrl(input: string | URL | Request): URL | null { + try { + if (typeof input === "string") return new URL(input); + if (input instanceof URL) return new URL(input.toString()); + return new URL(input.url); + } catch { + return null; + } +} + +function parseExplicitProxyUrl(providerName: string, trimmed: string, purpose?: string): URL { + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return egressFailure(providerName, "proxy is not a parseable absolute URL", purpose); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return egressFailure( + providerName, + "unsupported proxy scheme " + sanitizeProxyUrlForLog(trimmed) + "; Phase 1 supports only http(s)", + purpose + ); + } + return parsed; +} + +export function resolveProviderEgress(context: ProviderEgressContext): ProviderEgress { + const providerName = context.providerName; + const provider = context.provider; + const purpose = context.purpose; + const raw = provider.proxy; + if (raw === undefined) return { kind: "inherit" }; + if (raw === null) { + return egressFailure(providerName, "proxy null is not accepted; omit the field to inherit or use an explicit http(s) proxy URL", purpose); + } + if (typeof raw !== "string") { + return egressFailure(providerName, "proxy must be a string URL or omitted", purpose); + } + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return egressFailure(providerName, "empty proxy value is not DIRECT; omit the field to inherit", purpose); + } + const lowered = trimmed.toLowerCase(); + if (lowered === "direct") { + return egressFailure(providerName, "direct has no safe request-scoped transport on this runtime; use global noProxy", purpose); + } + if (lowered === "auto") { + return egressFailure(providerName, "provider auto proxy is deferred in Phase 1A; omit the field to inherit the global proxy behavior", purpose); + } + const parsed = parseExplicitProxyUrl(providerName, trimmed, purpose); + const target = egressRequestUrl(context.url); + if (target === null) { + return egressFailure(providerName, "target URL is not parseable", purpose); + } + const proxyUrl = parsed.toString(); + return { kind: "proxy", proxyUrl: proxyUrl, routeKey: providerEgressRouteKey(proxyUrl) }; +} diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 334f46dad5..de5ee2323b 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -8,11 +8,12 @@ import { } from "./destination-policy"; import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; import { effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } 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 & { +type ProviderOutboundConfig = Pick & { fetch?: typeof globalThis.fetch; }; export interface ProviderOutboundDependencies { @@ -116,7 +117,15 @@ async function providerOutboundRequest( if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") { throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS"); } + // Resolve provider egress before DNS and transport policy. + const egress = resolveProviderEgress({ providerName: name, provider, url, purpose: "providerOutbound" }); if (provider.fetch) { + // Explicit routes require the built-in executor. + if (egress.kind === "proxy") { + throw new InvalidProviderEgressError( + "providers." + name + ".proxy cannot be honored through a caller-owned fetch 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. @@ -138,13 +147,16 @@ async function providerOutboundRequest( return provider.fetch(url, { ...init, method, redirect: "manual" }); } const parsed = postUrl ?? new URL(url); - const proxyConfigured = outboundProxyConfigured(); + // Explicit routes override inherited NO_PROXY selection. + const explicitProxy = egress.kind === "proxy" ? egress.proxyUrl : null; + const proxyConfigured = explicitProxy !== null || outboundProxyConfigured(); // Snapshot the scheme-matched proxy 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 effectiveProxy = effectiveProxyFor(parsed); + const effectiveProxy = explicitProxy ?? effectiveProxyFor(parsed); + const noProxyBypass = explicitProxy !== null ? false : noProxyMatches(parsed); const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false); - const allowMihomoIpv6FakeIp = (effectiveProxy !== null && !noProxyMatches(parsed)) + const allowMihomoIpv6FakeIp = (effectiveProxy !== null && !noProxyBypass) || transparentFakeIpException(url, parsed, isCanonicalUrl, name); const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet; @@ -168,7 +180,7 @@ async function providerOutboundRequest( // 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: (proxyConfigured && !noProxyMatches(parsed)) + allowBenchmarkAddresses: (proxyConfigured && !noProxyBypass) || 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 @@ -184,7 +196,8 @@ async function providerOutboundRequest( if (!proxyConfigured) throw error; warnProxyBoundaryOnce(); warnProxyDnsDegradationOnce(); - return globalThis.fetch(url, { ...init, method, redirect: "manual" }); + // Preserve an explicit provider route on the fallback request. + return globalThis.fetch(url, { ...init, method, redirect: "manual", ...(explicitProxy ? { proxy: explicitProxy } : {}) }); } // A canonical TUN exception with no scheme-matched proxy must retain the // validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present. @@ -195,7 +208,7 @@ async function providerOutboundRequest( const proxy = (allowMihomoIpv6FakeIp && effectiveProxy) ? effectiveProxy : undefined; return globalThis.fetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) }); } - if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) { + if (proxyConfigured && resolved.privateNetwork && !noProxyBypass) { 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`, diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index be6bfd3fca..c0341f4ab2 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -24,6 +24,7 @@ import { positiveIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, + providerProxyConfigError, reasoningSummaryDeliveryRecordConfigError, upstreamHttpVersionConfigError, } from "../config/provider-validation"; @@ -756,6 +757,11 @@ export function providerManagementConfigError( if (destinationError) return `provider ${name} ${destinationError}`; const headersError = providerHeadersConfigError(typed.headers); if (headersError) return `provider ${name} ${headersError}`; + // Reject provider proxy values the request-scoped transport cannot honor. + const proxyError = providerProxyConfigError(typed.proxy); + if (proxyError) { + return `provider ${JSON.stringify(redactSecretString(name))} ${proxyError}`; + } const retryOn429Error = retryOn429PolicyConfigError(raw.retryOn429); if (retryOn429Error) { // The provider name is caller-controlled and can be token-shaped; redact and JSON-escape @@ -921,6 +927,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = { allowEncryptedV2AgentTasks: "editor", allowPrivateNetwork: "editor", upstreamHttpVersion: "editor", + // Keep proxy credentials out of dashboard responses. + proxy: "redacted", upstreamWebsocket: "editor", directGeminiWireRenames: "editor", disabled: "editor", diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 00bdbdc0f2..beec81b7ef 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -9,6 +9,7 @@ import type { OcxProviderConfig } from "../../types"; import type { WsData } from "../ws-bridge"; import { waitForProviderRequestSlot } from "../../providers/request-pacing"; import { withUpstreamHttpVersion } from "../../lib/upstream-http-version"; +import { InvalidProviderEgressError, resolveProviderEgress } from "../../lib/provider-egress"; import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; export { withUpstreamHttpVersion }; @@ -70,7 +71,9 @@ export function providerFetch( runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), options: ProviderFetchOptions = {}, ): ProviderFetch { - const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch; + const customExecutor = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch; + const base = customExecutor ?? globalThis.fetch; + const providerName = options.providerName ?? "unknown"; const preconnect = (...args: Parameters): void => { base.preconnect?.(...args); }; @@ -81,10 +84,36 @@ export function providerFetch( base(input, { ...init, redirect: "manual" }), { preconnect }, ) as typeof globalThis.fetch; + // Resolve provider egress before pacing or dispatch. + const resolveEgress = (input: Parameters[0]) => + resolveProviderEgress({ + providerName, + modelId: options.modelId, + provider, + url: typeof input === "string" || input instanceof URL ? input : input.url, + purpose: "providerFetch", + }); const httpFetch = Object.assign( async (input: Parameters[0], init?: RequestInit) => { + const egress = resolveEgress(input); + if (egress.kind === "proxy") { + if (input instanceof Request) { + throw new InvalidProviderEgressError( + "providers." + providerName + ".proxy cannot be honored for a prebuilt Request on this runtime; pass a URL instead." + ); + } + if (customExecutor) { + throw new InvalidProviderEgressError( + "providers." + providerName + ".proxy cannot be honored through a caller-owned fetch executor." + ); + } + } options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))); - const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }; + const dispatchInit = { + ...withUpstreamHttpVersion(input, init, provider), + timeout: 0, + ...(egress.kind === "proxy" ? { proxy: egress.proxyUrl } : {}), + }; return options.dispatchOverride ? options.dispatchOverride(input, dispatchInit, dispatch) : dispatch(input, dispatchInit); @@ -96,6 +125,10 @@ export function providerFetch( // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details. const unpaced = async (input: Parameters[0], init?: RequestInit) => { const upstreamWebsocket = provider.upstreamWebsocket === true; + if (resolveEgress(input).kind === "proxy") { + // Use HTTP/SSE before WebSocket dispatch for explicit routes. + return httpFetch(input, init); + } if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) { // The fallback has to be the same HTTP fetch the non-WS branch would have // used, protocol pin included: a WS turn that falls back is serving the @@ -116,6 +149,8 @@ export function providerFetch( : Promise.resolve(); }; const wrapped = async (input: Parameters[0], init?: RequestInit) => { + // Validate before pacing or dispatch. + resolveEgress(input); await waitForPacing(init?.signal ?? undefined); return unpaced(input, init); }; diff --git a/src/types/provider.ts b/src/types/provider.ts index 49c9132b0a..4f9b306a12 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -359,6 +359,8 @@ export interface OcxProviderConfig { * link-local, or unique-local upstreams. Metadata endpoints remain blocked. */ allowPrivateNetwork?: boolean; + /** Optional provider HTTP(S) proxy override; absent inherits global routing. */ + proxy?: string; /** * Pin the HTTP version used for upstream provider requests. Bun's fetch negotiates * HTTP/2 via TLS ALPN by default; some Cloudflare-fronted SSE endpoints hang on diff --git a/structure/config.md b/structure/config.md index e756575a91..8c0958a3d2 100644 --- a/structure/config.md +++ b/structure/config.md @@ -322,3 +322,14 @@ The text-only consumer reads exact inputModalities declarations before legacy hi ## Catalog auto-refresh `catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults off: an absent key, an explicit false, and a malformed value all leave the scheduler dormant. `src/config/feature-flags.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` is the module-singleton interval `src/server/background-lifecycle.ts` starts beside the quota reset poller; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures) and carries no provider or account detail. + +## Provider-scoped HTTP proxy + +`providers..proxy` is an optional absolute HTTP(S) proxy URL. Absence inherits +the process-wide route; null, empty, direct, auto and non-HTTP(S) schemes reject. +`src/config/provider-validation.ts` supplies the shared value check, while +`src/config/schema/leaf-validators.ts` owns the provider field shape and +`src/config/schema/config-schema.ts` applies the refinement. The compatibility +facade must not duplicate those validators. Management DTOs omit proxy URLs, +which may contain credentials; unrelated provider writes preserve the value. +The transport limitations are specified in [provider egress](transports/inventory.md#provider-scoped-http-proxy). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 91d527750a..da83e71ef2 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -76,7 +76,7 @@ both execution paths. Provider connection tests and live model discovery share the GET-only provider outbound wrapper. Direct HTTP(S) resolves once and pins the validated address; HTTPS preserves the original Host/SNI and always verifies certificates. Proxy-configured requests stay on Bun fetch so HTTP(S)_PROXY, -ALL_PROXY, and NO_PROXY semantics remain authoritative. The wrapper classifies successful local DNS answers, but +ALL_PROXY, and NO_PROXY semantics remain authoritative when no explicit provider proxy is configured. The wrapper classifies successful local DNS answers, but only a typed DNS-resolution failure degrades to proxy resolution; every literal, metadata, and resolved-address policy error still rejects. Proxy mode logs once that the proxy-selected peer cannot be pinned. Private destinations additionally require allowPrivateNetwork plus NO_PROXY. @@ -150,3 +150,21 @@ Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#r Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. + +## Provider-scoped HTTP proxy + +`src/lib/provider-egress.ts` resolves an explicit provider HTTP(S) proxy before +pacing or dispatch. `src/server/responses/fetch-helpers.ts` carries it on HTTP/SSE +requests, retaining physical-send admission, dispatch override and redirect +blocking; this explicit route does not enter the native WebSocket fast path. +Prebuilt Request inputs and caller-owned fetch executors reject rather than +silently ignoring the requested proxy. + +`src/lib/provider-outbound.ts` applies the same override to its discovery and +quota requests. Explicit proxy selection overrides inherited NO_PROXY routing, +but does not relax literal or resolved-destination checks. A private destination +that would require direct, peer-pinned transport is still rejected on an explicit +proxy route. Proxy-selected peers remain unpinnable by the local process. +The regression cases live in `tests/lib/provider-egress.test.ts`, +`tests/providers/provider-egress-outbound.test.ts` and +`tests/responses/provider-egress-fetch.test.ts`. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5553d5a7e1..39c31a1a1d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -900,6 +900,9 @@ "provider-discovery-log-suppression.test.ts": "providers", "provider-id-rewrite.test.ts": "providers", "provider-key-store.test.ts": "providers", + "provider-egress-fetch.test.ts": "responses", + "provider-egress-outbound.test.ts": "providers", + "provider-egress.test.ts": "lib", "provider-live-models.test.ts": "providers", "provider-model-aliases.test.ts": "providers", "provider-model-discovery-contract.test.ts": "providers", diff --git a/tests/lib/provider-egress.test.ts b/tests/lib/provider-egress.test.ts new file mode 100644 index 0000000000..06e356fe00 --- /dev/null +++ b/tests/lib/provider-egress.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + describeProviderEgressForLog, + egressRequestUrl, + InvalidProviderEgressError, + providerEgressRouteKey, + resolveProviderEgress, + sanitizeProxyUrlForLog, +} from "../../src/lib/provider-egress"; +import type { OcxProviderConfig } from "../../src/types"; + +type ProxyHolder = Pick; + +function holder(proxy?: unknown): ProxyHolder { + return proxy === undefined ? {} : { proxy: proxy as string }; +} + +const ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; +let savedEnv: Record; + +function clearProxyEnv(): void { + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +} + +afterEach(() => { + if (!savedEnv) return; + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } +}); + +describe("provider egress contract", () => { + test("absent proxy inherits the global path", () => { + clearProxyEnv(); + expect(resolveProviderEgress({ providerName: "openai", provider: holder(), url: "https://api.example/v1" })).toEqual({ kind: "inherit" }); + }); + + test("explicit undefined proxy inherits the global path", () => { + clearProxyEnv(); + expect(resolveProviderEgress({ providerName: "openai", provider: { proxy: undefined }, url: "https://api.example/v1" })).toEqual({ kind: "inherit" }); + }); + + test("http and https proxy URLs resolve to an explicit route", () => { + clearProxyEnv(); + const http = resolveProviderEgress({ providerName: "xai", provider: holder("http://127.0.0.1:7897"), url: "https://api.example/v1" }); + const https = resolveProviderEgress({ providerName: "xai", provider: holder("https://proxy.example:8443"), url: "https://api.example/v1" }); + expect(http.kind).toBe("proxy"); + expect(https.kind).toBe("proxy"); + if (http.kind === "proxy" && https.kind === "proxy") { + expect(http.proxyUrl).toContain("127.0.0.1:7897"); + expect(https.proxyUrl).toContain("proxy.example:8443"); + expect(http.routeKey).not.toBe(https.routeKey); + } + }); + + test("distinct proxies and distinct credentials map to distinct route keys", () => { + clearProxyEnv(); + const keyA = providerEgressRouteKey("http://127.0.0.1:8080"); + const keyB = providerEgressRouteKey("http://127.0.0.1:8081"); + const keyUser1 = providerEgressRouteKey("http://alice:s3cret-1@127.0.0.1:8080"); + const keyUser2 = providerEgressRouteKey("http://alice:s3cret-2@127.0.0.1:8080"); + expect(new Set([keyA, keyB, keyUser1, keyUser2]).size).toBe(4); + for (const key of [keyA, keyB, keyUser1, keyUser2]) { + expect(key).not.toContain("s3cret"); + expect(key).not.toContain("alice"); + } + }); + + test("log forms never carry proxy credentials", () => { + clearProxyEnv(); + expect(sanitizeProxyUrlForLog("http://alice:s3cret-1@127.0.0.1:8080")).toBe("http://127.0.0.1:8080"); + const egress = resolveProviderEgress({ providerName: "xai", provider: holder("http://alice:s3cret-1@127.0.0.1:8080"), url: "https://api.example/v1" }); + const label = describeProviderEgressForLog(egress); + expect(label).not.toContain("s3cret"); + expect(label).not.toContain("alice"); + expect(describeProviderEgressForLog({ kind: "inherit" })).toBe("inherit"); + }); + + test("direct, null, empty, and whitespace fail closed", () => { + clearProxyEnv(); + for (const proxy of ["direct", "DIRECT", " Direct ", "auto", "AUTO", null, "", " "]) { + expect(() => resolveProviderEgress({ providerName: "deepseek", provider: holder(proxy), url: "https://api.example/v1" })).toThrow(InvalidProviderEgressError); + } + }); + + test("unknown schemes, socks, and garbage fail closed without echoing credentials", () => { + clearProxyEnv(); + const bad = ["socks5://127.0.0.1:1080", "socks5://bob:s3cret-9@127.0.0.1:1080", "ftp://127.0.0.1:21", "not a url", "http://", "gopher://127.0.0.1:70"]; + for (const proxy of bad) { + let message = ""; + try { + resolveProviderEgress({ providerName: "xai", provider: holder(proxy), url: "https://api.example/v1" }); + } catch (error) { + expect(error).toBeInstanceOf(InvalidProviderEgressError); + message = error instanceof Error ? error.message : String(error); + } + expect(message.length).toBeGreaterThan(0); + expect(message).not.toContain("s3cret"); + } + }); + + test("non-string proxy values fail closed", () => { + clearProxyEnv(); + expect(() => resolveProviderEgress({ providerName: "xai", provider: { proxy: 8080 } as unknown as ProxyHolder, url: "https://api.example/v1" })).toThrow(InvalidProviderEgressError); + }); + + test("auto is deferred and fails closed even with a proxy snapshot present", () => { + clearProxyEnv(); + process.env.HTTPS_PROXY = "http://127.0.0.1:7890"; + expect(() => resolveProviderEgress({ providerName: "openrouter", provider: holder("auto"), url: "https://api.example/v1" })).toThrow(InvalidProviderEgressError); + delete process.env.HTTPS_PROXY; + delete process.env.https_proxy; + expect(() => resolveProviderEgress({ providerName: "openrouter", provider: holder("auto"), url: "https://api.example/v1" })).toThrow(InvalidProviderEgressError); + }); + + test("modelId is accepted for the future extension and ignored today", () => { + clearProxyEnv(); + const egress = resolveProviderEgress({ providerName: "openrouter", modelId: "deepseek/deepseek-v4", provider: holder("http://127.0.0.1:7897"), url: "https://api.example/v1" }); + expect(egress.kind).toBe("proxy"); + }); + + test("explicit provider proxy fails closed when the request URL is unparseable", () => { + clearProxyEnv(); + expect(() => + resolveProviderEgress({ providerName: "xai", provider: holder("http://127.0.0.1:7897"), url: "not a url" }) + ).toThrow(InvalidProviderEgressError); + }); + + test("invalid explicit proxy still fails closed when the request URL is unparseable", () => { + clearProxyEnv(); + expect(() => + resolveProviderEgress({ providerName: "xai", provider: holder("socks5://127.0.0.1:1080"), url: "not a url" }) + ).toThrow(InvalidProviderEgressError); + }); + + test("egressRequestUrl handles string, URL, and Request inputs", () => { + expect(egressRequestUrl("https://api.example/v1")?.protocol).toBe("https:"); + expect(egressRequestUrl(new URL("https://api.example/v1"))?.host).toBe("api.example"); + expect(egressRequestUrl(new Request("https://api.example/v1"))?.pathname).toBe("/v1"); + expect(egressRequestUrl("not a url")).toBeNull(); + }); +}); diff --git a/tests/providers/provider-config-validation.test.ts b/tests/providers/provider-config-validation.test.ts index fc3a2808ca..63971d67bf 100644 --- a/tests/providers/provider-config-validation.test.ts +++ b/tests/providers/provider-config-validation.test.ts @@ -10,9 +10,17 @@ import { positiveIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, + providerProxyConfigError, reasoningSummaryDeliveryRecordConfigError, upstreamHttpVersionConfigError, } from "../../src/config/provider-validation"; +import { + parseProviderEditorConfigDTO, + providerEditorConfigDTO, + providerManagementConfigError, + safeConfigDTO, +} from "../../src/server/auth-cors"; +import type { OcxConfig } from "../../src/types"; describe("provider config validation leaf", () => { test("accepts only credential-free HTTP(S) base URLs", () => { @@ -45,6 +53,23 @@ describe("provider config validation leaf", () => { expect(upstreamHttpVersionConfigError("h3")).toContain("must be one of"); }); + test("accepts only absent or explicit http(s) provider proxy URLs", () => { + expect(providerProxyConfigError(undefined)).toBeNull(); + expect(providerProxyConfigError("http://127.0.0.1:7897")).toBeNull(); + expect(providerProxyConfigError("https://proxy.example:8443")).toBeNull(); + for (const value of [null, 8080, true, {}, []]) { + expect(providerProxyConfigError(value)).toContain("string URL"); + } + for (const value of ["", " "]) { + expect(providerProxyConfigError(value)).toContain("must not be empty"); + } + expect(providerProxyConfigError("direct")).toContain("Phase 1A"); + expect(providerProxyConfigError("auto")).toContain("Phase 1A"); + for (const value of ["not a url", "socks5://127.0.0.1:1080", "ftp://127.0.0.1:21", "gopher://127.0.0.1:70"]) { + expect(providerProxyConfigError(value)).toContain("http(s)"); + } + }); + test("requires own-property positive integer maps", () => { expect(positiveIntegerRecordConfigError({ model: 1 }, "limits")).toBeNull(); expect(positiveIntegerRecordConfigError(Object.create({ inherited: 1 }), "limits")).toContain("own properties"); @@ -116,3 +141,66 @@ describe("provider config validation leaf", () => { expect(error).toContain("[REDACTED]"); }); }); + +describe("provider proxy management boundary", () => { + const manageableBase = { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + }; + + test("management validation accepts absent and explicit http(s) proxy URLs", () => { + expect(providerManagementConfigError("xai", { ...manageableBase })).toBeNull(); + expect( + providerManagementConfigError("xai", { ...manageableBase, proxy: "http://127.0.0.1:7897" }), + ).toBeNull(); + expect( + providerManagementConfigError("xai", { ...manageableBase, proxy: "https://proxy.example:8443" }), + ).toBeNull(); + }); + + test("management validation rejects invalid proxy values before persistence", () => { + for (const proxy of [null, 8080, true, "", " ", "direct", "auto", "not a url", "socks5://127.0.0.1:1080", "ftp://127.0.0.1:21"]) { + expect(providerManagementConfigError("xai", { ...manageableBase, proxy })).not.toBeNull(); + } + }); + + test("management proxy errors never echo embedded credentials", () => { + const error = providerManagementConfigError("xai", { + ...manageableBase, + proxy: "socks5://bob:s3cret-9@127.0.0.1:1080", + }); + expect(error).not.toBeNull(); + expect(error).not.toContain("s3cret-9"); + expect(error).not.toContain("bob"); + }); + + test("credential-bearing proxy stays out of dashboard DTOs", () => { + const config = { + port: 10100, + defaultProvider: "xai", + providers: { + xai: { + ...manageableBase, + proxy: "http://alice:s3cret-1@127.0.0.1:7897", + }, + }, + } as unknown as OcxConfig; + const serialized = JSON.stringify(safeConfigDTO(config)); + expect(serialized).not.toContain("\"proxy\""); + expect(serialized).not.toContain("alice"); + expect(serialized).not.toContain("s3cret-1"); + const editorSerialized = JSON.stringify(providerEditorConfigDTO(config)); + expect(editorSerialized).not.toContain("\"proxy\""); + expect(editorSerialized).not.toContain("s3cret-1"); + }); + + test("raw editor round-trip refuses the redacted proxy field", () => { + const parsed = parseProviderEditorConfigDTO({ + defaultProvider: "xai", + providers: { + xai: { ...manageableBase, proxy: "http://127.0.0.1:7897" }, + }, + }); + expect(parsed.ok).toBe(false); + }); +}); diff --git a/tests/providers/provider-egress-outbound.test.ts b/tests/providers/provider-egress-outbound.test.ts new file mode 100644 index 0000000000..78fca0c7c9 --- /dev/null +++ b/tests/providers/provider-egress-outbound.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { InvalidProviderEgressError } from "../../src/lib/provider-egress"; +import type { ProviderOutboundDependencies } from "../../src/lib/provider-outbound"; +import { providerOutboundGet, providerOutboundPost, ProviderOutboundPolicyError } from "../../src/lib/provider-outbound"; + +const PROXY_B = "http://127.0.0.1:7897"; +const PROXY_B_URL = new URL(PROXY_B).toString(); +const DISCOVERY_URL = "https://provider.example/v1/models"; + +const ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; +let savedEnv: Record; +const realFetch = globalThis.fetch; + +function clearProxyEnv(): void { + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +} + +afterEach(() => { + globalThis.fetch = realFetch; + if (!savedEnv) return; + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } +}); + +function publicDependencies(captured?: { benchmark?: boolean }): { dependencies: ProviderOutboundDependencies; resolveAddresses: ReturnType } { + const resolveAddresses = mock(async (_url: string, options?: { allowBenchmarkAddresses?: boolean }) => { + if (captured) captured.benchmark = options?.allowBenchmarkAddresses; + return { hostname: "provider.example", addresses: [{ address: "93.184.216.34", family: 4 }], privateNetwork: false }; + }); + return { resolveAddresses, dependencies: { resolveAddresses: resolveAddresses as unknown as ProviderOutboundDependencies["resolveAddresses"] } }; +} + +function stubGlobalFetch(): { seen: Array<{ url: unknown; init?: RequestInit }>; spy: ReturnType } { + const seen: Array<{ url: unknown; init?: RequestInit }> = []; + const spy = mock(async (url: unknown, init?: RequestInit) => { + seen.push({ url, init }); + return new Response("{}"); + }); + globalThis.fetch = spy as unknown as typeof globalThis.fetch; + return { seen, spy }; +} + +describe("providerOutbound egress wiring", () => { + test("explicit custom proxy pins discovery GET request-scoped", async () => { + clearProxyEnv(); + const { seen } = stubGlobalFetch(); + const { dependencies } = publicDependencies(); + const response = await providerOutboundGet("acme", { baseUrl: "https://provider.example", proxy: PROXY_B }, DISCOVERY_URL, {}, dependencies); + expect(await response.text()).toBe("{}"); + expect(seen.length).toBe(1); + expect(seen[0]?.url).toBe(DISCOVERY_URL); + const init = seen[0]?.init as Record; + expect(init?.proxy).toBe(PROXY_B_URL); + expect(init?.method).toBe("GET"); + expect(init?.redirect).toBe("manual"); + }); + + test("explicit custom proxy wins over a global NO_PROXY hit", async () => { + clearProxyEnv(); + process.env.NO_PROXY = "provider.example"; + process.env.no_proxy = "provider.example"; + const captured: { benchmark?: boolean } = {}; + const { seen } = stubGlobalFetch(); + const { dependencies } = publicDependencies(captured); + await providerOutboundGet("acme", { baseUrl: "https://provider.example", proxy: PROXY_B }, DISCOVERY_URL, {}, dependencies); + expect(seen.length).toBe(1); + expect((seen[0]?.init as Record)?.proxy).toBe(PROXY_B_URL); + expect(captured.benchmark).toBe(true); + }); + + test("explicit custom proxy pins management POST with manual redirect", async () => { + clearProxyEnv(); + const { seen } = stubGlobalFetch(); + const { dependencies } = publicDependencies(); + await providerOutboundPost("acme", { baseUrl: "https://provider.example", proxy: PROXY_B }, "https://provider.example/v1/usage", { body: "{}" }, dependencies); + expect(seen.length).toBe(1); + const init = seen[0]?.init as Record; + expect(init?.proxy).toBe(PROXY_B_URL); + expect(init?.method).toBe("POST"); + expect(init?.redirect).toBe("manual"); + }); + + test("invalid proxy fails closed before DNS or transport", async () => { + clearProxyEnv(); + const { spy } = stubGlobalFetch(); + const { dependencies, resolveAddresses } = publicDependencies(); + await expect(providerOutboundGet("acme", { baseUrl: "https://provider.example", proxy: "direct" }, DISCOVERY_URL, {}, dependencies)).rejects.toBeInstanceOf(InvalidProviderEgressError); + expect(resolveAddresses.mock.calls.length).toBe(0); + expect(spy.mock.calls.length).toBe(0); + }); + + test("destination policy still blocks metadata targets under explicit proxy", async () => { + clearProxyEnv(); + const { spy } = stubGlobalFetch(); + // Real resolver: a literal metadata IP is rejected without any DNS lookup. + await expect(providerOutboundGet("acme", { baseUrl: "https://provider.example", proxy: PROXY_B }, "https://169.254.169.254/latest", {}, {})).rejects.toBeInstanceOf(ProviderOutboundPolicyError); + expect(spy.mock.calls.length).toBe(0); + }); + + test("inherit without proxy env keeps the pinned direct path (legacy)", async () => { + clearProxyEnv(); + const { spy } = stubGlobalFetch(); + let pinnedCalled = 0; + const dependencies: ProviderOutboundDependencies = { + resolveAddresses: (async () => ({ hostname: "provider.example", addresses: [{ address: "93.184.216.34", family: 4 }], privateNetwork: false })) as unknown as ProviderOutboundDependencies["resolveAddresses"], + pinnedGet: (async () => { pinnedCalled++; return new Response("pinned"); }) as unknown as ProviderOutboundDependencies["pinnedGet"], + }; + const response = await providerOutboundGet("acme", { baseUrl: "https://provider.example" }, DISCOVERY_URL, {}, dependencies); + expect(await response.text()).toBe("pinned"); + expect(pinnedCalled).toBe(1); + expect(spy.mock.calls.length).toBe(0); + }); +}); diff --git a/tests/responses/provider-egress-fetch.test.ts b/tests/responses/provider-egress-fetch.test.ts new file mode 100644 index 0000000000..fdbd858fad --- /dev/null +++ b/tests/responses/provider-egress-fetch.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { InvalidProviderEgressError } from "../../src/lib/provider-egress"; +import { providerFetch } from "../../src/server/responses/fetch-helpers"; +import { CODEX_RESPONSES_HTTP_URL } from "../../src/server/responses/codex-ws-request"; +import type { OcxProviderConfig } from "../../src/types"; + +const PROXY_B = "http://127.0.0.1:7897"; +const PROXY_B_URL = new URL(PROXY_B).toString(); +const TARGET = "https://provider.example/v1/responses"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function baseProvider(extra?: Partial): OcxProviderConfig { + return { adapter: "openai-responses", baseUrl: "https://provider.example/v1", ...extra } as OcxProviderConfig; +} + +function stubGlobalFetch(): { seen: Array<{ input: unknown; init?: RequestInit }>; spy: ReturnType } { + const seen: Array<{ input: unknown; init?: RequestInit }> = []; + const spy = mock(async (input: unknown, init?: RequestInit) => { + seen.push({ input, init }); + return new Response("ok"); + }); + globalThis.fetch = spy as unknown as typeof globalThis.fetch; + return { seen, spy }; +} + +describe("providerFetch egress wiring", () => { + test("inherit sends without a request-scoped proxy (legacy behavior)", async () => { + const { seen } = stubGlobalFetch(); + const fetch = providerFetch(baseProvider()); + const response = await fetch(TARGET, { method: "POST", body: "{}" }); + expect(await response.text()).toBe("ok"); + expect(seen.length).toBe(1); + expect("proxy" in (seen[0]?.init ?? {})).toBe(false); + }); + + test("explicit custom proxy is pinned request-scoped", async () => { + const { seen } = stubGlobalFetch(); + const fetch = providerFetch(baseProvider({ proxy: PROXY_B })); + await fetch(TARGET, { method: "POST", body: "{}" }); + expect(seen.length).toBe(1); + expect((seen[0]?.init as Record)?.proxy).toBe(PROXY_B_URL); + }); + + test("explicit proxy skips the WS fast lane before dispatch (HTTP fallback)", async () => { + const { seen } = stubGlobalFetch(); + const fetch = providerFetch(baseProvider({ proxy: PROXY_B })); + const init = { method: "POST", body: JSON.stringify({ stream: true, model: "m" }) }; + const response = await fetch(CODEX_RESPONSES_HTTP_URL, init); + expect(await response.text()).toBe("ok"); + expect(seen.length).toBe(1); + expect(seen[0]?.input).toBe(CODEX_RESPONSES_HTTP_URL); + expect((seen[0]?.init as Record)?.proxy).toBe(PROXY_B_URL); + }); + + test("invalid proxy fails closed before anything dispatches", async () => { + const { seen, spy } = stubGlobalFetch(); + const fetch = providerFetch(baseProvider({ proxy: "direct" })); + await expect(fetch(TARGET, { method: "POST", body: "{}" })).rejects.toBeInstanceOf(InvalidProviderEgressError); + expect(spy.mock.calls.length).toBe(0); + expect(seen.length).toBe(0); + }); + + test("invalid proxy fails closed on WS-eligible turns without dialing", async () => { + const { spy } = stubGlobalFetch(); + const fetch = providerFetch(baseProvider({ proxy: "socks5://127.0.0.1:1080" })); + const init = { method: "POST", body: JSON.stringify({ stream: true, model: "m" }) }; + await expect(fetch(CODEX_RESPONSES_HTTP_URL, init)).rejects.toBeInstanceOf(InvalidProviderEgressError); + expect(spy.mock.calls.length).toBe(0); + }); + + test("prebuilt Request with explicit proxy fails closed", async () => { + const { spy } = stubGlobalFetch(); + const fetch = providerFetch(baseProvider({ proxy: PROXY_B })); + await expect(fetch(new Request(TARGET), { method: "POST", body: "{}" })).rejects.toBeInstanceOf(InvalidProviderEgressError); + expect(spy.mock.calls.length).toBe(0); + }); + + test("caller-owned executor with explicit proxy fails closed", async () => { + const executor = mock(async () => new Response("must-not-send")); + const fetch = providerFetch(baseProvider({ proxy: PROXY_B, fetch: executor as unknown as typeof globalThis.fetch })); + await expect(fetch(TARGET, { method: "POST", body: "{}" })).rejects.toBeInstanceOf(InvalidProviderEgressError); + expect(executor.mock.calls.length).toBe(0); + }); + + test("caller-owned executor without proxy keeps legacy behavior", async () => { + const seen: Array<{ input: unknown; init?: RequestInit }> = []; + const executor = mock(async (input: unknown, init?: RequestInit) => { + seen.push({ input, init }); + return new Response("ok"); + }); + const fetch = providerFetch(baseProvider({ fetch: executor as unknown as typeof globalThis.fetch })); + await fetch(TARGET, { method: "POST", body: "{}" }); + expect(seen.length).toBe(1); + expect("proxy" in (seen[0]?.init ?? {})).toBe(false); + }); +}); diff --git a/tests/responses/responses-fetch-helpers-boundary.test.ts b/tests/responses/responses-fetch-helpers-boundary.test.ts index 55130f57cf..fdbfe44ef8 100644 --- a/tests/responses/responses-fetch-helpers-boundary.test.ts +++ b/tests/responses/responses-fetch-helpers-boundary.test.ts @@ -45,6 +45,7 @@ function expectRuntimeImportBoundary(source: string): string[] { describe("Responses fetch-helper import boundary", () => { test("loads only transport-owned runtime dependencies", () => { expect(expectRuntimeImportBoundary(readFileSync(helperPath, "utf8"))).toEqual([ + "../../lib/provider-egress", "../../lib/upstream-http-version", "../../providers/request-pacing", "./ws-upstream", diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index e4ecb0625d..3cd6c8eb08 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -860,6 +860,112 @@ describe("provider management validation", () => { } }); + test("provider POST rejects invalid provider.proxy before persistence", async () => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + for (const proxy of ["direct", "socks5://127.0.0.1:1080"]) { + const response = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "relay-proxy", + provider: { adapter: "openai-chat", baseUrl: "https://relay.example/v1", proxy }, + }), + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: expect.stringContaining("proxy"), + }); + } + expect(loadConfig().providers["relay-proxy"]).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + + test("provider POST persists a valid HTTP(S) provider.proxy", async () => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockResolvedValue(null); + const server = startServer(0); + try { + const create = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "relay-proxy", + provider: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + proxy: "http://127.0.0.1:7897", + }, + }), + }); + expect(create.status).toBe(200); + expect(loadConfig().providers["relay-proxy"]?.proxy).toBe("http://127.0.0.1:7897"); + } finally { + resolvedError.mockRestore(); + await server.stop(true); + } + }); + + test("provider PATCH keeps proxy outside the Phase 1A field mask", async () => { + // Phase 1A intentionally exposes proxy on config-file POST only: proxy is a + // redacted dashboard field, so the PUT editor denies it and the PATCH mask does + // not adopt it. This test locks that boundary instead of forcing PATCH support. + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockResolvedValue(null); + const server = startServer(0); + try { + const create = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "relay-proxy", + provider: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + proxy: "http://127.0.0.1:7897", + }, + }), + }); + expect(create.status).toBe(200); + + const note = await fetch(new URL("/api/providers?name=relay-proxy", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ note: "operator note" }), + }); + expect(note.status).toBe(200); + expect(loadConfig().providers["relay-proxy"]?.proxy).toBe("http://127.0.0.1:7897"); + + const proxyPatch = await fetch(new URL("/api/providers?name=relay-proxy", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ proxy: "http://127.0.0.1:7898" }), + }); + expect(proxyPatch.status).toBe(400); + expect(loadConfig().providers["relay-proxy"]?.proxy).toBe("http://127.0.0.1:7897"); + } finally { + resolvedError.mockRestore(); + await server.stop(true); + } + }); + test("provider management rejects modelCosts rows with extra fields", () => { const error = providerManagementConfigError("blsc", { adapter: "openai-chat",