diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index ffc6871a20..868e42351d 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -212,6 +212,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noProxy?` | `string \| string[]` | Destinations this provider reaches directly, using `NO_PROXY` host-pattern syntax. A match bypasses both this provider's own proxy and an inherited global proxy. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, maxConcurrentRequests?, 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. `maxConcurrentRequests` is a positive integer cap on in-flight requests. A provider or model rule may use the concurrency cap alone; 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 or narrow concurrency. Queue waits do not consume the upstream response-header timeout. HTTP and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. A concurrency-capped canonical Responses WebSocket turn uses HTTP/SSE so its lease can be released when the response body completes, errors, or is cancelled. For `runTurn` adapters, including Cursor, the cap counts active turns rather than physical sends: RunSSE and BidiAppend may overlap within one turn, while another turn waits. Follow-up sends still obey start intervals. | | `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`. | +| `tlsProfile?` | `"antigravity-browser"` | **Use at your own risk.** Opt-in browser-like TLS handshake (through the optional `wreq-js` dependency) for the canonical `google-antigravity` OAuth provider. It changes only how the connection looks on the wire; it is not an official Google client, and it does not change what Google's terms allow. Google can still detect, rate-limit, suspend, or ban the account you signed in with, and you alone carry that risk. It is off by default and accepted only with the Google adapter, Cloud Code Assist mode, and Google's canonical HTTPS Antigravity hosts. Redirects stay manual. The provider's own `proxy`/`noProxy` route is carried by the TLS transport; a route it cannot keep fails the request instead of leaving by another path, which includes any direct route while `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` is set. `GET /api/providers` reports the profile state as `pending`, `active`, or `failed`. Omitting the field keeps the normal Bun transport and never loads the dependency. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | | `chatCompletionsPath?` | `string` | Relative resource path for `openai-chat` requests, the mirror of `responsesPath` and subject to the same shape rules. Needed when one upstream serves Chat Completions and Responses under different prefixes: a per-model wire override changes the adapter and leaves `baseUrl` alone, so without this an opted-in Chat request would be sent to the Responses base. Z.AI is the shipped example. | | `allowEncryptedV2AgentTasks?` | `boolean` | Disabled by default. Trust a direct key-auth `openai-responses` provider to consume or relay opaque encrypted V2 sub-agent tasks unchanged. Eligible routes skip `agentTaskRecovery`; all other routes keep the existing recovery or fail-closed behavior. OpenCodex does not decrypt, translate, or recover tasks sent through this opt-in. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9a0eaea95b..5f1b415ee8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1331,6 +1331,8 @@ "provider-model-discovery-contract.test.ts": "providers", "provider-outbound-private-network.test.ts": "providers", "provider-outbound.test.ts": "providers", + "provider-runtime-fetch.test.ts": "providers", + "provider-tls-profile.test.ts": "providers", "provider-payload.test.ts": "gui", "provider-quota-label-sanitize.test.ts": "providers", "provider-quota-observed-marker.test.ts": "providers", diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index dd135d9036..3b0ea04a50 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -57,6 +57,7 @@ import { COMBO_NAMESPACE, comboConfigIssues } from "../../combos/types"; import { routingProfileIssues } from "../../routing/profile"; import { POLICY_NAMESPACE } from "../../routing/profile-namespace"; import { providerDestinationConfigError } from "../../lib/destination-policy"; +import { providerTlsProfileConfigError } from "../../lib/provider-tls-profile"; import { redactSecretString } from "../../lib/redact"; import { openRouterRoutingConfigError } from "../../providers/openrouter-routing"; import { vercelGatewayRoutingConfigError } from "../../providers/vercel-gateway-routing"; @@ -476,6 +477,14 @@ export const configSchema = z.object({ }); } } + const tlsProfileError = providerTlsProfileConfigError(name, provider); + if (tlsProfileError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "tlsProfile"], + message: tlsProfileError, + }); + } 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 bce878456d..654c1217a3 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -294,6 +294,7 @@ export const providerConfigSchema = z.object({ autoReviewModelOverrides: autoReviewModelOverridesSchema.optional(), adapter: z.string().min(1), baseUrl: z.string().min(1), + tlsProfile: z.literal("antigravity-browser").optional(), alias: z.string().optional(), modelAliases: z.record(z.string(), z.string()).optional(), modelDisplayNames: modelDisplayNamesSchema.optional(), diff --git a/src/lib/provider-runtime-fetch.ts b/src/lib/provider-runtime-fetch.ts new file mode 100644 index 0000000000..1bdf9bdb4a --- /dev/null +++ b/src/lib/provider-runtime-fetch.ts @@ -0,0 +1,23 @@ +import type { OcxProviderConfig } from "../types"; + +export const RUNTIME_PROVIDER_FETCH = Symbol("opencodex.provider.runtime-fetch"); + +export interface RuntimeProviderFetch { + providerName: string; + origins: readonly string[]; + fetch: typeof globalThis.fetch; +} + +/** Return only an explicitly injected executor matching the provider and exact destination origin. */ +export function runtimeProviderFetch( + provider: OcxProviderConfig, + providerName: string | undefined, +): typeof globalThis.fetch | undefined { + const runtime = (provider as OcxProviderConfig & { [RUNTIME_PROVIDER_FETCH]?: RuntimeProviderFetch })[RUNTIME_PROVIDER_FETCH]; + if (!runtime || runtime.providerName !== providerName) return undefined; + try { + return runtime.origins.includes(new URL(provider.baseUrl).origin) ? runtime.fetch : undefined; + } catch { + return undefined; + } +} diff --git a/src/lib/provider-tls-profile.ts b/src/lib/provider-tls-profile.ts new file mode 100644 index 0000000000..2ac5931d2a --- /dev/null +++ b/src/lib/provider-tls-profile.ts @@ -0,0 +1,221 @@ +import type { OcxProviderConfig } from "../types"; +import { redactSecretString } from "./redact"; +import { runtimeProviderFetch } from "./provider-runtime-fetch"; +import { markEgressTransparentExecutor } from "./provider-egress"; +import { + outboundProxyConfigured, + proxyEnvPresent, + resolveProxyRoute, + socks5ProxyFromEnv, + type ProxyEnvMap, +} from "./proxy-env"; + +export type ProviderTlsProfile = "antigravity-browser"; +/** + * `pending` means the profile is configured and valid but no request has used it yet; the + * dashboard must not report a configured profile as `disabled` before its first send. + */ +export type ProviderTlsProfileStatus = "disabled" | "pending" | "active" | "failed"; +export const ANTIGRAVITY_TLS_HOSTS = new Set([ + "daily-cloudcode-pa.googleapis.com", + "cloudcode-pa.googleapis.com", +]); +type TlsRuntime = { + fetch(input: string | URL | Request, init?: RequestInit): Promise; + resolveProxyRoute?: typeof resolveProxyRoute; + env?: ProxyEnvMap; +}; +let status = new Map(); +let runtime: TlsRuntime | undefined; + +export function isCanonicalAntigravityUrl(input: string | URL): boolean { + try { + const url = new URL(input); + return ( + url.protocol === "https:" && + (url.port === "" || url.port === "443") && + !url.username && + !url.password && + ANTIGRAVITY_TLS_HOSTS.has(url.hostname.toLowerCase()) + ); + } catch { + return false; + } +} + +export function providerTlsProfileConfigError( + providerName: string, + provider: Pick< + OcxProviderConfig, + "adapter" | "authMode" | "googleMode" | "baseUrl" | "tlsProfile" + >, +): string | null { + if (provider.tlsProfile === undefined) return null; + if (provider.tlsProfile !== "antigravity-browser") + return "tlsProfile must be antigravity-browser"; + if ( + providerName !== "google-antigravity" || + provider.adapter !== "google" || + provider.authMode !== "oauth" || + provider.googleMode !== "cloud-code-assist" || + !isCanonicalAntigravityUrl(provider.baseUrl) + ) { + return "tlsProfile antigravity-browser requires the canonical Google Antigravity OAuth destination"; + } + return null; +} + +export function getProviderTlsProfileStatus( + name: string, + configured?: boolean, +): ProviderTlsProfileStatus { + const recorded = status.get(name); + if (configured === undefined) return recorded ?? "disabled"; + if (!configured) return "disabled"; + return recorded === undefined || recorded === "disabled" ? "pending" : recorded; +} + +/** The `/api/providers` fragment for a configured profile; empty when the provider has none. */ +export function providerTlsProfileDiagnostic( + name: string, + provider: Pick, +): { tlsProfile?: { profile: ProviderTlsProfile; status: ProviderTlsProfileStatus } } { + if (provider.tlsProfile === undefined) return {}; + return { tlsProfile: { profile: provider.tlsProfile, status: getProviderTlsProfileStatus(name, true) } }; +} + +export function resetProviderTlsProfileForTests(): void { + status = new Map(); + runtime = undefined; +} + +export function setProviderTlsRuntimeForTest( + next: TlsRuntime | undefined, +): void { + runtime = next; +} + +function preserveTransportError(error: unknown): Error { + // A configured proxy URL can carry user:pass@, and the native transport may echo it. The + // shared redactor does not mask URL userinfo, so strip it here before the message travels. + const message = redactSecretString( + error instanceof Error ? error.message : "provider TLS transport failed", + ).replace(/\/\/[^/@\s]+@/g, "//@"); + const name = error instanceof Error ? error.name : "Error"; + if (name === "AbortError" || name === "TimeoutError") + return new DOMException(message, name); + const wrapped = new Error(message); + wrapped.name = name; + return wrapped; +} + +/** Proxy schemes the native TLS transport can carry for a route decided elsewhere. */ +const TLS_PROXY_PROTOCOLS = new Set(["http:", "https:", "socks5:", "socks5h:"]); + +function requireDirect(env: ProxyEnvMap): Record { + // The native transport reads HTTP_PROXY/HTTPS_PROXY/ALL_PROXY itself whenever no proxy option + // is given, and it has no per-request "direct" switch. A direct route is therefore only + // honoured when no proxy variable exists at all; otherwise omitting the option would send the + // credential through the environment proxy the operator routed this request away from. + if (outboundProxyConfigured(env)) { + throw new Error("provider TLS profile cannot force a direct connection while a proxy environment variable is set"); + } + return {}; +} + +/** + * The proxy option expressing this send's route on the native transport, or a refusal. + * + * `sendWithConnectionPolicy` resolves the per-provider egress (`providers..proxy` / + * `noProxy`) and passes it as `init.proxy`: a URL, `false` for direct, or absent when the + * provider inherits the global environment route. + */ +function tlsProxyOption(init: RequestInit | undefined, destination: string | URL): { proxy?: string } { + const env = runtime?.env ?? process.env; + const decided = init !== undefined && Object.hasOwn(init, "proxy") + ? (init as RequestInit & { proxy?: unknown }).proxy + : undefined; + if (typeof decided === "string") { + let protocol: string; + try { + protocol = new URL(decided).protocol; + } catch { + throw new Error("provider TLS profile cannot preserve configured proxy semantics"); + } + if (!TLS_PROXY_PROTOCOLS.has(protocol)) { + throw new Error("provider TLS profile cannot preserve configured proxy semantics"); + } + return { proxy: decided }; + } + if (decided === false) return requireDirect(env); + const route = (runtime?.resolveProxyRoute ?? resolveProxyRoute)(new URL(destination), env); + if (route.kind === "fallback") { + // `resolveProxyRoute` only classifies HTTP(S) proxies; an inherited ALL_PROXY of socks5:// + // or socks5h:// is the route the ordinary outbound path takes through socks5ProxyFromEnv(). + // Carry that same route when no HTTPS-specific variable outranks it, instead of refusing + // every Antigravity send for an operator whose only global proxy is SOCKS. + const socks = proxyEnvPresent("HTTPS_PROXY", env) ? undefined : socks5ProxyFromEnv(env); + if (socks) return { proxy: socks.trim() }; + throw new Error("provider TLS profile cannot preserve configured proxy semantics"); + } + if (route.kind === "proxy") return { proxy: route.proxy }; + return requireDirect(env); +} + +export function providerTlsFetch( + name: string, + provider: Pick< + OcxProviderConfig, + "adapter" | "authMode" | "googleMode" | "baseUrl" | "tlsProfile" + >, + fallback: typeof globalThis.fetch, +): typeof globalThis.fetch { + if (provider.tlsProfile === undefined) { + status.set(name, "disabled"); + return fallback; + } + if (providerTlsProfileConfigError(name, provider)) { + status.set(name, "failed"); + return (async () => { + throw new Error("invalid provider TLS profile"); + }) as unknown as typeof globalThis.fetch; + } + // Transparent to provider egress: the route decided at the physical send arrives as + // `init.proxy` and is either carried by the native transport or refused below. + return markEgressTransparentExecutor((async (input, init) => { + const destination = + typeof input === "string" || input instanceof URL ? input : input.url; + if (!isCanonicalAntigravityUrl(destination)) { + status.set(name, "failed"); + throw new Error("provider TLS profile refused noncanonical destination"); + } + try { + const configured = runtimeProviderFetch( + provider as OcxProviderConfig, + name, + ); + const proxyOption = tlsProxyOption(init, destination); + const mod = + configured === undefined + ? runtime ?? ((await import("wreq-js")) as unknown as TlsRuntime) + : undefined; + const { proxy: _decidedRoute, ...rest } = (init ?? {}) as RequestInit & { proxy?: unknown }; + const response = await (configured ?? mod!.fetch)(input, { + ...rest, + redirect: "manual", + browser: "chrome_142", + os: "windows", + ...proxyOption, + } as RequestInit & { browser: string; os: string }); + status.set(name, "active"); + return response; + } catch (error) { + if (init?.signal?.aborted && error === init.signal.reason) { + // A caller cancellation says nothing about the profile's health. + throw error; + } + status.set(name, "failed"); + throw preserveTransportError(error); + } + }) as typeof globalThis.fetch); +} diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index a12fbf8808..191165b8e7 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -142,6 +142,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { desktopExecutor: "none", unsafeAllowNativeLocalExec: "none", nativeLocalExec: "none", + tlsProfile: "none", } as const satisfies Record; function fieldsWithRole(role: ModelRenameRole): string[] { diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index e53662b844..f552913d68 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -32,6 +32,7 @@ import { } from "../config/provider-validation"; import { providerDestinationConfigError } from "../lib/destination-policy"; import { providerEgressConfigError } from "../lib/provider-egress"; +import { providerTlsProfileConfigError } from "../lib/provider-tls-profile"; import { redactSecretString } from "../lib/redact"; import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy"; import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry"; @@ -780,6 +781,11 @@ export function providerManagementConfigError( return `provider ${name} must not include codexAccountMode`; } const typed = provider as unknown as OcxProviderConfig; + // Every write path (POST, PUT, reload, both PATCH passes) funnels through here, so a PATCH + // that changes authMode/baseUrl/adapter under a retained tlsProfile is refused before it + // persists a row the config schema would later reject as document-fatal. + const tlsProfileError = providerTlsProfileConfigError(name, typed); + if (tlsProfileError) return `provider ${JSON.stringify(redactSecretString(name))} ${tlsProfileError}`; const baseUrlError = providerBaseUrlConfigError(typed.baseUrl); if (baseUrlError) return `provider ${name} ${baseUrlError}`; if (effectiveGoogleMode(name, typed) === "vertex" && typed.location !== undefined) { @@ -1110,6 +1116,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { desktopExecutor: "redacted", unsafeAllowNativeLocalExec: "editor", nativeLocalExec: "editor", + tlsProfile: "editor", } as const satisfies Record; type ProviderFieldWithPolicy = { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 7f94f7d214..b0372cb3a2 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -137,6 +137,7 @@ import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostR import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { providerTlsProfileDiagnostic } from "../../lib/provider-tls-profile"; type ProviderPatchApplication = | { error: string } @@ -930,8 +931,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise ({ - name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, - hasApiKey: !!p.apiKey, + name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, hasApiKey: !!p.apiKey, // Presence only (#959 review): header names and values never leave the process. hasHeaders: !!p.headers && Object.keys(p.headers).length > 0, allowPrivateNetwork: p.allowPrivateNetwork === true, @@ -962,7 +962,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise): void => { - base.preconnect?.(...args); + // A TLS profile owns the handshake; a Bun preconnect would open a differently fingerprinted one. + if (transport === base) base.preconnect?.(...args); }; // Rebuilt dispatches must use the same physical-send boundary as ordinary HTTP sends. // Return the original 3xx so the response owner retains its retry/health/relay contract. @@ -266,7 +269,7 @@ export function providerFetch( // that decided for itself has already marked the init and this pass defers to that decision. const dispatch = markEgressTransparentExecutor(Object.assign( (input: Parameters[0], init?: RequestInit) => - sendWithConnectionPolicy(base, input, init, egressBinding), + sendWithConnectionPolicy(transport, input, init, egressBinding), { preconnect }, ) as typeof globalThis.fetch); const httpFetch = Object.assign( @@ -281,7 +284,7 @@ export function providerFetch( // the override may rebuild against a different host and select a different transport, and // refusing on this destination would reject a request whose real route is fine. if (options.dispatchOverride) egressFor(input); - else providerEgressSendInit(egressBinding, base, input); + else providerEgressSendInit(egressBinding, transport, input); // The hook inspects the outgoing headers and refuses the send by throwing; it is not a // mutator, and the copy it receives is deliberately not threaded onward. `Connection` // is decided inside `dispatch`, which runs after this, so the fresh-connection policy diff --git a/src/types/provider.ts b/src/types/provider.ts index e2fda13932..5b0ae79798 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -274,6 +274,8 @@ export interface ModelCapabilities { } export interface OcxProviderConfig { + /** Optional browser-compatible outbound TLS profile; disabled by default. */ + tlsProfile?: "antigravity-browser"; /** Optional short provider namespace used only at request/catalog presentation time. */ alias?: string; /** Native model id -> short, slash-free request alias. */ diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 380db4be9e..3f3ad1a3a4 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -161,7 +161,8 @@ by transports that can preserve that request-local decision: | OAuth-backed quota probes in `src/providers/quota/vendor-probes-oauth.ts` and `src/providers/quota/devin.ts` | Not honoured | `fetchXaiQuota`, `fetchAnthropicQuota`, `fetchCursorQuota`, `fetchDevinQuota` and their neighbours receive a provider name and a token rather than a provider config. | | API-key validation probes in `src/oauth/key-providers.ts` | Not honoured | `validateApiKey` receives a `KeyLoginProvider` derived preset, which carries no egress fields, and its caller builds the real provider record afterwards. | | Responses WebSocket upstream in `src/server/responses/ws-upstream.ts` | Not directly | The WebSocket dial selects its proxy from the process environment. An explicit provider route therefore serves that provider's turns over HTTP/SSE instead and emits one warning per provider per process. | -| Caller-supplied `provider.fetch` executor | Not honoured | The caller owns that executor's transport. An explicit provider route is refused instead of being ignored. | +| Caller-supplied `provider.fetch` executor | Not honoured | The caller owns that executor's transport. An explicit provider route is refused instead of being ignored. When the Antigravity TLS profile is enabled on the same provider, the profile owns the physical send and the caller-supplied executor is not used. | +| Opt-in Antigravity TLS profile in `src/lib/provider-tls-profile.ts` (`providers.google-antigravity.tlsProfile`) | Honoured or refused | Selected inside `providerFetch` only for the canonical Antigravity OAuth provider and destination, and marked egress-transparent. A decided HTTP(S) or SOCKS5(H) route is passed to the native `wreq-js` transport; an inherited route is resolved from the environment. The native transport reads proxy variables itself and has no per-request direct switch, so a direct route (`"direct"`, `noProxy`, or a global `NO_PROXY` match) is refused while any outbound proxy variable is set. | | Cursor's default HTTP/2 transport in `src/adapters/cursor/live-transport.ts` | Not honoured | The native HTTP/2 dial does not consume the provider route. | | Coding-agent subprocess providers in `src/adapters/coding-agent/turn.ts` | Not honoured | Their scoped child environment omits proxy variables, so a provider route is not projected into the subprocess. | | Compatibility Lab pinned sender in `src/lib/lab-live-pinned-sender.ts` | Not honoured | The sender uses the approved pinned address and does not resolve a provider route. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2f53a09d95..7d49e18444 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1342,6 +1342,8 @@ "provider-model-discovery-contract.test.ts": "providers", "provider-outbound-private-network.test.ts": "providers", "provider-outbound.test.ts": "providers", + "provider-runtime-fetch.test.ts": "providers", + "provider-tls-profile.test.ts": "providers", "provider-payload.test.ts": "gui", "provider-quota-label-sanitize.test.ts": "providers", "provider-quota-observed-marker.test.ts": "providers", diff --git a/tests/providers/provider-runtime-fetch.test.ts b/tests/providers/provider-runtime-fetch.test.ts new file mode 100644 index 0000000000..2fb545137e --- /dev/null +++ b/tests/providers/provider-runtime-fetch.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test"; +import { RUNTIME_PROVIDER_FETCH, runtimeProviderFetch } from "../../src/lib/provider-runtime-fetch"; + +test("runtime provider fetch is scoped to provider and exact origin", () => { + const fetcher = async () => new Response("ok"); + const provider = { adapter: "openai-chat", baseUrl: "https://example.com", [RUNTIME_PROVIDER_FETCH]: { providerName: "p", origins: ["https://example.com"], fetch: fetcher } } as any; + expect(runtimeProviderFetch(provider, "p")).toBe(fetcher); + expect(runtimeProviderFetch(provider, "other")).toBeUndefined(); + expect(runtimeProviderFetch({ ...provider, baseUrl: "https://example.net" }, "p")).toBeUndefined(); +}); diff --git a/tests/providers/provider-tls-profile.test.ts b/tests/providers/provider-tls-profile.test.ts new file mode 100644 index 0000000000..d5beb5918a --- /dev/null +++ b/tests/providers/provider-tls-profile.test.ts @@ -0,0 +1,344 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + getProviderTlsProfileStatus, + isCanonicalAntigravityUrl, + providerTlsProfileConfigError, + providerTlsFetch, + providerTlsProfileDiagnostic, + resetProviderTlsProfileForTests, + setProviderTlsRuntimeForTest, +} from "../../src/lib/provider-tls-profile"; +import { isEgressTransparentExecutor } from "../../src/lib/provider-egress"; +import { providerManagementConfigError } from "../../src/server/auth-cors"; +import { providerFetch } from "../../src/server/responses/fetch-helpers"; +import type { OcxProviderConfig } from "../../src/types"; + +afterEach(() => resetProviderTlsProfileForTests()); + +describe("provider TLS profile", () => { + test("accepts only canonical Antigravity HTTPS origins", () => { + expect( + isCanonicalAntigravityUrl("https://cloudcode-pa.googleapis.com"), + ).toBe(true); + expect( + isCanonicalAntigravityUrl("https://cloudcode-pa.googleapis.com:443"), + ).toBe(true); + expect( + isCanonicalAntigravityUrl("http://cloudcode-pa.googleapis.com"), + ).toBe(false); + expect(isCanonicalAntigravityUrl("https://evil.example")).toBe(false); + }); + + test("rejects malformed profiles before fallback dispatch", async () => { + const provider = { + adapter: "openai-chat", + authMode: "key", + googleMode: undefined, + baseUrl: "https://evil.example", + tlsProfile: "antigravity-browser" as const, + }; + expect(providerTlsProfileConfigError("evil", provider)).toBeString(); + const fetcher = providerTlsFetch( + "evil", + provider, + async () => new Response("sent"), + ); + await expect(fetcher("https://evil.example")).rejects.toThrow( + "invalid provider TLS profile", + ); + }); + + test("uses manual redirects, browser profile, and caller abort signal", async () => { + let seen: RequestInit | undefined; + setProviderTlsRuntimeForTest({ + env: {}, + fetch: async (_input, init) => { + seen = init; + return new Response("ok"); + }, + }); + const signal = new AbortController().signal; + const provider = { + adapter: "google", + authMode: "oauth", + googleMode: "cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + tlsProfile: "antigravity-browser" as const, + }; + await providerTlsFetch( + "google-antigravity", + provider, + fetch, + )("https://cloudcode-pa.googleapis.com/v1", { signal }); + expect(seen?.redirect).toBe("manual"); + expect(seen?.signal).toBe(signal); + expect((seen as any)?.browser).toBe("chrome_142"); + expect(getProviderTlsProfileStatus("google-antigravity")).toBe("active"); + }); + + test("passes supported proxy semantics to the TLS transport", async () => { + let seen: RequestInit | undefined; + setProviderTlsRuntimeForTest({ + env: {}, + fetch: async (_input, init) => { + seen = init; + return new Response("ok"); + }, + resolveProxyRoute: () => ({ + kind: "proxy", + proxy: "http://127.0.0.1:9191", + }), + }); + const provider = { + adapter: "google", + authMode: "oauth", + googleMode: "cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + tlsProfile: "antigravity-browser" as const, + }; + await providerTlsFetch( + "google-antigravity", + provider, + fetch, + )("https://cloudcode-pa.googleapis.com/v1"); + expect((seen as RequestInit & { proxy?: string }).proxy).toBe( + "http://127.0.0.1:9191", + ); + expect(getProviderTlsProfileStatus("google-antigravity")).toBe("active"); + }); + + test("fails closed when configured proxy semantics cannot be preserved", async () => { + let called = false; + setProviderTlsRuntimeForTest({ + env: {}, + fetch: async () => { + called = true; + return new Response("unexpected"); + }, + resolveProxyRoute: () => ({ kind: "fallback" }), + }); + const provider = { + adapter: "google", + authMode: "oauth", + googleMode: "cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + tlsProfile: "antigravity-browser" as const, + }; + const fetcher = providerTlsFetch("google-antigravity", provider, fetch); + await expect( + fetcher("https://cloudcode-pa.googleapis.com/v1"), + ).rejects.toThrow("cannot preserve configured proxy semantics"); + expect(called).toBe(false); + expect(getProviderTlsProfileStatus("google-antigravity")).toBe("failed"); + }); + + test("redacts credential text from transport errors", async () => { + setProviderTlsRuntimeForTest({ + env: {}, + fetch: async () => { + throw new Error("Authorization: Bearer super-secret"); + }, + }); + const provider = { + adapter: "google", + authMode: "oauth", + googleMode: "cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + tlsProfile: "antigravity-browser" as const, + }; + await expect( + providerTlsFetch( + "google-antigravity", + provider, + fetch, + )("https://cloudcode-pa.googleapis.com/v1"), + ).rejects.toThrow("[REDACTED]"); + await expect( + providerTlsFetch( + "google-antigravity", + provider, + fetch, + )("https://cloudcode-pa.googleapis.com/v1"), + ).rejects.not.toThrow("super-secret"); + }); + test("preserves exact abort reason identity when transport rejects with active signal reason", async () => { + const customReason = new Error("caller-owned cancel"); + const controller = new AbortController(); + controller.abort(customReason); + + setProviderTlsRuntimeForTest({ + env: {}, + fetch: async () => { + throw customReason; + }, + }); + + const provider = { + adapter: "google", + authMode: "oauth", + googleMode: "cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + tlsProfile: "antigravity-browser" as const, + }; + + const fetcher = providerTlsFetch("google-antigravity", provider, fetch); + let caught: unknown; + try { + await fetcher("https://cloudcode-pa.googleapis.com/v1", { + signal: controller.signal, + }); + } catch (err) { + caught = err; + } + expect(caught).toBe(customReason); + expect(getProviderTlsProfileStatus("google-antigravity")).not.toBe("failed"); + }); + + test("strips proxy userinfo from transport errors", async () => { + setProviderTlsRuntimeForTest({ + env: {}, + fetch: async () => { + throw new Error("proxy connect failed: http://alice:hunter2@127.0.0.1:8080"); + }, + }); + const provider = { + adapter: "google", + authMode: "oauth", + googleMode: "cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + tlsProfile: "antigravity-browser" as const, + }; + let caught: unknown; + try { + await providerTlsFetch("google-antigravity", provider, fetch)("https://cloudcode-pa.googleapis.com/v1"); + } catch (err) { + caught = err; + } + expect(String((caught as Error).message)).not.toContain("hunter2"); + expect(String((caught as Error).message)).toContain("//@127.0.0.1:8080"); + }); + + const canonical = { + adapter: "google", + authMode: "oauth", + googleMode: "cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + tlsProfile: "antigravity-browser" as const, + }; + + function captureRuntime(env: Record) { + const seen: { init?: RequestInit & { proxy?: unknown }; calls: number } = { calls: 0 }; + setProviderTlsRuntimeForTest({ + env, + fetch: async (_input, init) => { + seen.calls += 1; + seen.init = init; + return new Response("ok"); + }, + }); + return seen; + } + + test("carries a per-provider proxy route decided at the physical send", async () => { + const seen = captureRuntime({ HTTPS_PROXY: "http://global.invalid:1" }); + const fetcher = providerTlsFetch("google-antigravity", canonical, fetch); + await fetcher("https://cloudcode-pa.googleapis.com/v1", { proxy: "socks5://127.0.0.1:1080" } as RequestInit); + expect(seen.init?.proxy).toBe("socks5://127.0.0.1:1080"); + }); + + test("honours a direct route only when no proxy environment exists", async () => { + const clean = captureRuntime({}); + await providerTlsFetch("google-antigravity", canonical, fetch)( + "https://cloudcode-pa.googleapis.com/v1", { proxy: false } as RequestInit); + expect(clean.calls).toBe(1); + expect(clean.init !== undefined && Object.hasOwn(clean.init, "proxy")).toBe(false); + + const proxied = captureRuntime({ HTTP_PROXY: "http://global.invalid:1" }); + await expect(providerTlsFetch("google-antigravity", canonical, fetch)( + "https://cloudcode-pa.googleapis.com/v1", { proxy: false } as RequestInit)) + .rejects.toThrow("cannot force a direct connection"); + expect(proxied.calls).toBe(0); + expect(getProviderTlsProfileStatus("google-antigravity", true)).toBe("failed"); + }); + + test("refuses a NO_PROXY bypass the native transport would not honour", async () => { + const seen = captureRuntime({ HTTPS_PROXY: "http://global.invalid:1", NO_PROXY: "cloudcode-pa.googleapis.com" }); + await expect(providerTlsFetch("google-antigravity", canonical, fetch)("https://cloudcode-pa.googleapis.com/v1")) + .rejects.toThrow("cannot force a direct connection"); + expect(seen.calls).toBe(0); + }); + + test("refuses a decided proxy scheme the native transport cannot carry", async () => { + const seen = captureRuntime({}); + await expect(providerTlsFetch("google-antigravity", canonical, fetch)( + "https://cloudcode-pa.googleapis.com/v1", { proxy: "ftp://127.0.0.1:21" } as RequestInit)) + .rejects.toThrow("cannot preserve configured proxy semantics"); + expect(seen.calls).toBe(0); + }); + + test("carries an inherited SOCKS5 ALL_PROXY route like the ordinary outbound path", async () => { + for (const socks of ["socks5://127.0.0.1:1080", "socks5h://127.0.0.1:1080"]) { + const seen = captureRuntime({ ALL_PROXY: socks }); + await providerTlsFetch("google-antigravity", canonical, fetch)("https://cloudcode-pa.googleapis.com/v1"); + expect(seen.calls).toBe(1); + expect(seen.init?.proxy).toBe(socks); + } + }); + + test("still refuses an inherited route when an HTTPS proxy variable outranks the SOCKS fallback", async () => { + const seen = captureRuntime({ HTTPS_PROXY: "ftp://global.invalid:21", ALL_PROXY: "socks5://127.0.0.1:1080" }); + await expect(providerTlsFetch("google-antigravity", canonical, fetch)("https://cloudcode-pa.googleapis.com/v1")) + .rejects.toThrow("cannot preserve configured proxy semantics"); + expect(seen.calls).toBe(0); + }); + + test("the management write boundary refuses a row that keeps tlsProfile after leaving eligibility", () => { + const keyAuth = providerManagementConfigError("google-antigravity", { ...canonical, authMode: "key" }); + expect(keyAuth).toContain("tlsProfile antigravity-browser requires"); + const moved = providerManagementConfigError("google-antigravity", { ...canonical, baseUrl: "https://example.com" }); + expect(moved).toContain("tlsProfile antigravity-browser requires"); + const renamed = providerManagementConfigError("antigravity-copy", canonical); + expect(renamed).toContain("tlsProfile antigravity-browser requires"); + const eligible = providerManagementConfigError("google-antigravity", canonical); + expect(eligible ?? "").not.toContain("tlsProfile"); + }); + + test("is transparent to provider egress and reports pending before its first send", () => { + const fetcher = providerTlsFetch("google-antigravity", canonical, fetch); + expect(isEgressTransparentExecutor(fetcher)).toBe(true); + expect(getProviderTlsProfileStatus("google-antigravity", true)).toBe("pending"); + expect(getProviderTlsProfileStatus("google-antigravity", false)).toBe("disabled"); + expect(providerTlsProfileDiagnostic("google-antigravity", canonical)).toEqual({ + tlsProfile: { profile: "antigravity-browser", status: "pending" }, + }); + expect(providerTlsProfileDiagnostic("gemini", {})).toEqual({}); + }); + + test("providerFetch routes the profile through the per-provider egress decision", async () => { + const seen = captureRuntime({}); + const provider = { ...canonical, proxy: "http://provider-proxy.invalid:3128" } as unknown as OcxProviderConfig; + const response = await providerFetch(provider, undefined, { providerName: "google-antigravity" })( + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent", + { method: "POST", body: "{}" }, + ); + expect(await response.text()).toBe("ok"); + expect(seen.calls).toBe(1); + expect(seen.init?.proxy).toBe("http://provider-proxy.invalid:3128/"); + expect(seen.init?.redirect).toBe("manual"); + expect(getProviderTlsProfileStatus("google-antigravity", true)).toBe("active"); + }); + + test("providerFetch leaves providers without the profile on their own executor", async () => { + const seen = captureRuntime({}); + let baseCalls = 0; + const provider = { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + fetch: async () => { baseCalls += 1; return new Response("base"); }, + } as unknown as OcxProviderConfig; + await providerFetch(provider, undefined, { providerName: "gemini" })("https://generativelanguage.googleapis.com/v1beta/models"); + expect(baseCalls).toBe(1); + expect(seen.calls).toBe(0); + expect(getProviderTlsProfileStatus("gemini")).toBe("disabled"); + }); +}); diff --git a/tests/responses/responses-fetch-helpers-boundary.test.ts b/tests/responses/responses-fetch-helpers-boundary.test.ts index 455c3d0d67..1f59e4ef4b 100644 --- a/tests/responses/responses-fetch-helpers-boundary.test.ts +++ b/tests/responses/responses-fetch-helpers-boundary.test.ts @@ -46,6 +46,7 @@ describe("Responses fetch-helper import boundary", () => { test("loads only transport-owned runtime dependencies", () => { expect(expectRuntimeImportBoundary(readFileSync(helperPath, "utf8"))).toEqual([ "../../lib/provider-egress", + "../../lib/provider-tls-profile", "../../lib/proxy-env", "../../lib/redact", "../../lib/upstream-http-version",