diff --git a/bun.lock b/bun.lock index 6161766917..da14252047 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,9 @@ "@types/bun": "1.4.2", "typescript": "7.0.2", }, + "optionalDependencies": { + "wreq-js": "2.3.1", + }, }, }, "trustedDependencies": [ @@ -310,6 +313,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "wreq-js": ["wreq-js@2.3.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-vaKasaKeskrDKEuuO5Q5uamEG9a6FrF5ZSicH7TCvYS4RxF7/gzaU/vYqwJzcs+uydyJPVWY1KCvfVCgp0tiGA=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 5f3fc99649..0c9a1a2b08 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -129,6 +129,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `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. | | `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`. | +| `tlsProfile?` | `"antigravity-browser"` | Opt-in browser-compatible TLS transport for the canonical `google-antigravity` OAuth provider. It is accepted only with the Google adapter, Cloud Code Assist mode, and Google's canonical HTTPS Antigravity hosts. Redirects remain manual, configured proxy routing must be preserved or the request fails closed, and omission keeps the normal Bun transport without loading the optional TLS dependency. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | | `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. | | `upstreamWebsocket?` | `boolean` | Opt-in upstream Responses WebSocket transport for `openai-responses` requests (default false). When the upstream supports the Responses WebSocket protocol, streaming POST requests to the configured Responses path (default `/v1/responses`) are dialed as WSS over an HTTPS base URL and re-encoded to SSE for the usual pipeline. Forward providers use `{baseUrl}/responses`; key-auth providers use `responsesPath`, or the legacy `/v1/responses` fallback. This mirrors the canonical ChatGPT backend optimization for OpenAI-compatible gateways (for example sub2api) whose WebSocket ingress is measurably faster than its SSE queue. Plain HTTP remains on SSE; non-Responses paths and `openai-chat` requests stay on HTTP. | diff --git a/package.json b/package.json index 936324ccec..1e9df08fb1 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,9 @@ "@types/bun": "1.4.2", "typescript": "7.0.2" }, + "optionalDependencies": { + "wreq-js": "2.3.1" + }, "overrides": { "@hono/node-server": "2.1.0", "fast-uri": "^3.1.7", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b5ea45c4a3..49ca523154 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -984,6 +984,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-observed-marker.test.ts": "providers", "provider-quota.test.ts": "providers", diff --git a/src/config.ts b/src/config.ts index 4311e54eef..86a1bd29a2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -65,6 +65,7 @@ import { recordOwnedConfigPath } from "./lib/config-ownership"; import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { redactSecretString } from "./lib/redact"; +import { providerTlsProfileConfigError } from "./lib/provider-tls-profile"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases"; import { MODEL_DISCOVERY_MAX_MODELS } from "./providers/model-discovery-limits"; @@ -581,6 +582,7 @@ const providerConfigSchema = z.object({ modelPinnedReasoningEfforts: modelPinnedEffortsSchema.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(), @@ -1420,6 +1422,14 @@ const configSchema = z.object({ message: responsesPathError, }); } + 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/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..80d315066d --- /dev/null +++ b/src/lib/provider-tls-profile.ts @@ -0,0 +1,136 @@ +import type { OcxProviderConfig } from "../types"; +import { redactSecretString } from "./redact"; +import { runtimeProviderFetch } from "./provider-runtime-fetch"; +import { resolveProxyRoute } from "./proxy-env"; + +export type ProviderTlsProfile = "antigravity-browser"; +export type ProviderTlsProfileStatus = "disabled" | "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; +}; +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, +): ProviderTlsProfileStatus { + return status.get(name) ?? "disabled"; +} + +export function resetProviderTlsProfileForTests(): void { + status = new Map(); + runtime = undefined; +} + +export function setProviderTlsRuntimeForTest( + next: TlsRuntime | undefined, +): void { + runtime = next; +} + +function preserveTransportError(error: unknown): Error { + const message = redactSecretString( + error instanceof Error ? error.message : "provider TLS transport failed", + ); + 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; +} + +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; + } + return (async (input, init) => { + const destination = + typeof input === "string" || input instanceof URL ? input : input.url; + if (!isCanonicalAntigravityUrl(destination)) + throw new Error("provider TLS profile refused noncanonical destination"); + try { + const configured = runtimeProviderFetch( + provider as OcxProviderConfig, + name, + ); + const mod = + runtime ?? ((await import("wreq-js")) as unknown as TlsRuntime); + const proxyRoute = (runtime?.resolveProxyRoute ?? resolveProxyRoute)( + new URL(destination), + ); + if (proxyRoute.kind === "fallback") + throw new Error( + "provider TLS profile cannot preserve configured proxy semantics", + ); + const response = await (configured ?? mod.fetch)(input, { + ...init, + redirect: "manual", + browser: "chrome_142", + os: "windows", + ...(proxyRoute.kind === "proxy" ? { proxy: proxyRoute.proxy } : {}), + } as RequestInit & { browser: string; os: string }); + status.set(name, "active"); + return response; + } catch (error) { + status.set(name, "failed"); + throw preserveTransportError(error); + } + }) as typeof globalThis.fetch; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index d103721f35..e95b55daf1 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -888,6 +888,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/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 00bdbdc0f2..10a66c04ad 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 { providerTlsFetch } from "../../lib/provider-tls-profile"; import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; export { withUpstreamHttpVersion }; @@ -71,6 +72,7 @@ export function providerFetch( options: ProviderFetchOptions = {}, ): ProviderFetch { const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch; + const transport = options.providerName ? providerTlsFetch(options.providerName, provider, base) : base; const preconnect = (...args: Parameters): void => { base.preconnect?.(...args); }; @@ -78,7 +80,7 @@ export function providerFetch( // Return the original 3xx so the response owner retains its retry/health/relay contract. const dispatch = Object.assign( (input: Parameters[0], init?: RequestInit) => - base(input, { ...init, redirect: "manual" }), + transport(input, { ...init, redirect: "manual" }), { preconnect }, ) as typeof globalThis.fetch; const httpFetch = Object.assign( diff --git a/src/types/provider.ts b/src/types/provider.ts index 0beb5d3371..d33f7cae19 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -228,6 +228,8 @@ export type TierDecision = * retries are allowed; OAuth/forward credentials and local runtimes are never replayed. */ 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/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d772205061..57562a1bb0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -819,6 +819,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-observed-marker.test.ts": "providers", "provider-quota.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..b6c73f0e81 --- /dev/null +++ b/tests/providers/provider-tls-profile.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + getProviderTlsProfileStatus, + isCanonicalAntigravityUrl, + providerTlsProfileConfigError, + providerTlsFetch, + resetProviderTlsProfileForTests, + setProviderTlsRuntimeForTest, +} from "../../src/lib/provider-tls-profile"; + +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({ + 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({ + 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({ + 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({ + 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"); + }); +}); diff --git a/tests/responses/responses-fetch-helpers-boundary.test.ts b/tests/responses/responses-fetch-helpers-boundary.test.ts index 55130f57cf..2a1eeb09c4 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-tls-profile", "../../lib/upstream-http-version", "../../providers/request-pacing", "./ws-upstream",