From f71c0ba29c122d780a5413c06df8125239595c64 Mon Sep 17 00:00:00 2001 From: shenghuojiushiheyang Date: Sun, 6 Sep 2026 11:37:47 +0800 Subject: [PATCH 1/2] feat(proxy): add per-provider HTTP proxy overrides --- .../docs/reference/configuration/providers.md | 22 +++ scripts/test-layout/layout.json | 3 + src/config.ts | 11 ++ src/config/provider-validation.ts | 19 +++ src/lib/provider-egress.ts | 114 ++++++++++++++ src/lib/provider-outbound.ts | 27 +++- src/server/auth-cors.ts | 8 + src/server/responses/fetch-helpers.ts | 39 ++++- src/types/provider.ts | 2 + tests/fixtures/test-layout-expected.json | 3 + tests/lib/provider-egress.test.ts | 145 ++++++++++++++++++ .../provider-config-validation.test.ts | 88 +++++++++++ .../provider-egress-outbound.test.ts | 119 ++++++++++++++ tests/responses/provider-egress-fetch.test.ts | 100 ++++++++++++ .../responses-fetch-helpers-boundary.test.ts | 1 + .../management-provider-validation.test.ts | 106 +++++++++++++ 16 files changed, 798 insertions(+), 9 deletions(-) create mode 100644 src/lib/provider-egress.ts create mode 100644 tests/lib/provider-egress.test.ts create mode 100644 tests/providers/provider-egress-outbound.test.ts create mode 100644 tests/responses/provider-egress-fetch.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index b0792b7367..efe23c3655 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -122,6 +122,7 @@ predictions. Explicit provider/model price overrides still take precedence. | --- | --- | --- | | `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`). | | `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. | @@ -209,6 +210,27 @@ to the native default as a single choice. Defaults must belong to the final list the catalog projection, not stored configuration or arbitrary gateway models sharing a GPT name. See [custom native catalog examples](/guides/codex-app-models/). +### 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" + } + } +} +``` + +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 60e0f22a0d..a32b85542f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -962,6 +962,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.ts b/src/config.ts index d25aa07ad2..6541ba568f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,7 @@ import { positiveIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, + providerProxyConfigError, reasoningSummaryDeliveryRecordConfigError, upstreamHttpVersionConfigError, } from "./config/provider-validation"; @@ -548,6 +549,8 @@ 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. @@ -1343,6 +1346,14 @@ const configSchema = z.object({ message: responsesPathError, }); } + 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/provider-validation.ts b/src/config/provider-validation.ts index 326914a758..ae1fb6a912 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -80,6 +80,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/lib/provider-egress.ts b/src/lib/provider-egress.ts new file mode 100644 index 0000000000..9c48bbbe69 --- /dev/null +++ b/src/lib/provider-egress.ts @@ -0,0 +1,114 @@ +// 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 { kind: "inherit" }; + 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 0dd49910fb..009d1b7fb6 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -19,6 +19,7 @@ import { positiveIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, + providerProxyConfigError, reasoningSummaryDeliveryRecordConfigError, upstreamHttpVersionConfigError, } from "../config/provider-validation"; @@ -634,6 +635,11 @@ export function providerManagementConfigError(name: unknown, provider: unknown): 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 @@ -789,6 +795,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 b6365be4be..21bd8f3a49 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,14 +71,42 @@ 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); }; + // 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, base) : base(input, dispatchInit); @@ -89,6 +118,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 @@ -109,6 +142,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 97a359506a..9a03c0abc5 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -288,6 +288,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/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fe613ac71f..c69e93d0ae 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -797,6 +797,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..4dcba44d21 --- /dev/null +++ b/tests/lib/provider-egress.test.ts @@ -0,0 +1,145 @@ +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("unparseable request URLs keep legacy behavior", () => { + clearProxyEnv(); + expect(resolveProviderEgress({ providerName: "xai", provider: holder("http://127.0.0.1:7897"), url: "not a url" })).toEqual({ kind: "inherit" }); + }); + + 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 09bfb1e67c..2c8c3984a1 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -526,6 +526,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", From 7fd3a1c899708dd449b3f55ba518270f9c9c7749 Mon Sep 17 00:00:00 2001 From: shenghuojiushiheyang Date: Mon, 7 Sep 2026 20:31:05 +0800 Subject: [PATCH 2/2] fix(proxy): fail closed on malformed targets --- src/lib/provider-egress.ts | 4 +++- tests/lib/provider-egress.test.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/provider-egress.ts b/src/lib/provider-egress.ts index 9c48bbbe69..58f10165ea 100644 --- a/src/lib/provider-egress.ts +++ b/src/lib/provider-egress.ts @@ -108,7 +108,9 @@ export function resolveProviderEgress(context: ProviderEgressContext): ProviderE } const parsed = parseExplicitProxyUrl(providerName, trimmed, purpose); const target = egressRequestUrl(context.url); - if (target === null) return { kind: "inherit" }; + 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/tests/lib/provider-egress.test.ts b/tests/lib/provider-egress.test.ts index 4dcba44d21..06e356fe00 100644 --- a/tests/lib/provider-egress.test.ts +++ b/tests/lib/provider-egress.test.ts @@ -124,9 +124,11 @@ describe("provider egress contract", () => { expect(egress.kind).toBe("proxy"); }); - test("unparseable request URLs keep legacy behavior", () => { + 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" })).toEqual({ kind: "inherit" }); + 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", () => {