diff --git a/changelog.d/fixes/7993-noauth-proxy-routing.md b/changelog.d/fixes/7993-noauth-proxy-routing.md new file mode 100644 index 0000000000..3a929f87c6 --- /dev/null +++ b/changelog.d/fixes/7993-noauth-proxy-routing.md @@ -0,0 +1 @@ +- fix(providers): route noauth opencode-zen connections through their assigned proxy diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 9e3f689c44..0d3906acc8 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -75,6 +75,7 @@ import { } from "./sessionAffinityPin"; import { isNoAuthProviderBlockedBySettings } from "./noAuthProviderSettings"; import { resolveAccountProxiesFromRegistry } from "./noAuthProxyResolution"; +import { getNoAuthHydrationProviderIds } from "./noAuthProviderSiblings"; import * as log from "../utils/logger"; import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck"; @@ -728,28 +729,40 @@ function buildSyntheticNoAuthCredentials(providerSpecificData: JsonRecord = {}): }; } +/** Merge one connection's fingerprints/accountProxies into `hydrated`, first-wins. */ +function mergeNoAuthProviderSpecificData(hydrated: JsonRecord, conn: { providerSpecificData?: unknown }): void { + const psd = conn.providerSpecificData; + if (!psd || typeof psd !== "object") return; + const record = psd as JsonRecord; + if (Array.isArray(record.fingerprints) && !Array.isArray(hydrated.fingerprints)) { + hydrated.fingerprints = record.fingerprints; + } + if (Array.isArray(record.accountProxies) && !Array.isArray(hydrated.accountProxies)) { + hydrated.accountProxies = record.accountProxies; + } +} + /** * #4954 / #5217 (Gap 1) — no-auth providers persist a connection row whose * `providerSpecificData` carries `fingerprints` + `accountProxies`. Hydrate those * and resolve by-id Proxy Pool references to live records (./noAuthProxyResolution) * so the executor gets a resolved inline `proxy`. Best-effort: failures → empty. + * + * #7993: also checks sibling ids (e.g. "opencode-zen" -> "opencode") so a + * proxy/fingerprint row saved under the no-auth id is still found when + * credentials are hydrated for the apikey-gateway id that shares its public + * endpoint. */ async function loadNoAuthProviderSpecificData(providerId: string): Promise { try { - const connectionsRaw = await getProviderConnections({ provider: providerId }); - const connections = (Array.isArray(connectionsRaw) ? connectionsRaw : []).map( - toProviderConnection - ); + const providerIdsToQuery = getNoAuthHydrationProviderIds(providerId); const hydrated: JsonRecord = {}; - for (const conn of connections) { - const psd = conn.providerSpecificData; - if (!psd || typeof psd !== "object") continue; - if (Array.isArray(psd.fingerprints) && !Array.isArray(hydrated.fingerprints)) { - hydrated.fingerprints = psd.fingerprints; - } - if (Array.isArray(psd.accountProxies) && !Array.isArray(hydrated.accountProxies)) { - hydrated.accountProxies = psd.accountProxies; - } + for (const pid of providerIdsToQuery) { + const connectionsRaw = await getProviderConnections({ provider: pid }); + const connections = (Array.isArray(connectionsRaw) ? connectionsRaw : []).map( + toProviderConnection + ); + for (const conn of connections) mergeNoAuthProviderSpecificData(hydrated, conn); } if (Array.isArray(hydrated.accountProxies)) { hydrated.accountProxies = await resolveAccountProxiesFromRegistry(hydrated.accountProxies); diff --git a/src/sse/services/noAuthProviderSiblings.ts b/src/sse/services/noAuthProviderSiblings.ts new file mode 100644 index 0000000000..5b36a99494 --- /dev/null +++ b/src/sse/services/noAuthProviderSiblings.ts @@ -0,0 +1,24 @@ +/** + * #7993 — "OpenCode Free" is served by TWO distinct provider identities that + * are never unified: the no-auth "opencode" provider (NOAUTH_PROVIDERS, + * alias "oc" — the id the NoAuthAccountCard UI writes fingerprints + + * accountProxies onto via a `provider_connections` row) and the + * "opencode-zen" / "opencode-go" APIKEY_PROVIDERS gateways (which carry + * `anonymousFallback: true` for the same public endpoint). + * + * A model string resolved to the canonical/full prefix ("opencode/") + * routes to "opencode-zen", not "opencode" (see the #2901 guard in + * open-sse/services/model.ts). When that happens, credential hydration for + * "opencode-zen" must still be able to find the user's proxy/fingerprint + * config, which physically lives on the "opencode" connection row — this + * sibling map lets `loadNoAuthProviderSpecificData()` look there too. + */ +const NOAUTH_SIBLING_PROVIDER_IDS: Record = { + "opencode-zen": ["opencode"], + "opencode-go": ["opencode"], +}; + +/** Provider ids to query when hydrating no-auth `providerSpecificData` for `providerId`. */ +export function getNoAuthHydrationProviderIds(providerId: string): string[] { + return [providerId, ...(NOAUTH_SIBLING_PROVIDER_IDS[providerId] || [])]; +} diff --git a/tests/unit/7993-noauth-proxy-routing.test.ts b/tests/unit/7993-noauth-proxy-routing.test.ts new file mode 100644 index 0000000000..78a7312605 --- /dev/null +++ b/tests/unit/7993-noauth-proxy-routing.test.ts @@ -0,0 +1,130 @@ +/** + * #7993 — "OpenCode Free" is served by TWO distinct provider identities that + * are never unified: the no-auth "opencode" provider (NOAUTH_PROVIDERS — + * the id the NoAuthAccountCard UI writes fingerprints + accountProxies onto + * via a `provider_connections` row) and the "opencode-zen" APIKEY_PROVIDERS + * gateway (anonymousFallback: true, resolved from the canonical + * "opencode/" prefix via the #2901 alias override in + * open-sse/services/model.ts). + * + * Before the fix, `getProviderCredentials("opencode-zen")` fell through to + * `maybeSyntheticNoAuthFallback("opencode-zen", ...)`, which hydrated + * `providerSpecificData` by querying `provider_connections` filtered by + * `provider === "opencode-zen"` — a DIFFERENT id than the one the user's + * connection row is saved under ("opencode") — so the assigned proxy was + * silently dropped and the request egressed direct. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import net from "node:net"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-7993-noauth-proxy-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { getProviderCredentials } = await import("../../src/sse/services/auth.ts"); +const { createProviderConnection } = await import("../../src/lib/db/providers.ts"); +const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts"); +const { resolveProxyForRequest } = await import("../../open-sse/utils/proxyFetch.ts"); + +const log = { debug() {}, info() {}, warn() {}, error() {} }; +const FINGERPRINT = "cccccccccccccccccccccccccccccccc"; + +let proxyServer: net.Server; +let proxyPort = 0; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve((server.address() as net.AddressInfo).port); + }); + }); +} + +test.before(async () => { + proxyServer = net.createServer((s) => s.destroy()); + proxyPort = await listen(proxyServer); + + // Mirror exactly what the NoAuthAccountCard UI writes: a `provider_connections` + // row filed under the no-auth id "opencode" (NOT "opencode-zen"), carrying the + // configured account proxy. + await createProviderConnection({ + provider: "opencode", + authType: "no-auth", + name: "opencode-noauth-account", + isActive: true, + providerSpecificData: { + fingerprints: [FINGERPRINT], + accountProxies: [ + { + fingerprint: FINGERPRINT, + proxy: { type: "http", host: "127.0.0.1", port: proxyPort }, + }, + ], + }, + }); +}); + +test.after(() => { + proxyServer?.close(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#7993 getProviderCredentials('opencode-zen') hydrates the proxy saved under the sibling 'opencode' connection", async () => { + const creds = (await getProviderCredentials("opencode-zen")) as { + connectionId?: string; + providerSpecificData?: { fingerprints?: unknown; accountProxies?: unknown }; + } | null; + + assert.ok(creds, "opencode-zen must resolve to synthetic no-auth credentials"); + assert.equal(creds!.connectionId, "noauth"); + const psd = creds!.providerSpecificData || {}; + assert.ok( + Array.isArray(psd.fingerprints) && psd.fingerprints.length === 1, + `expected the sibling opencode connection's fingerprints to be hydrated, got ${JSON.stringify(psd)}` + ); + assert.ok( + Array.isArray(psd.accountProxies) && psd.accountProxies.length === 1, + `expected the sibling opencode connection's accountProxies to be hydrated, got ${JSON.stringify(psd)}` + ); +}); + +test("#7993 a canonical 'opencode/' resolved combo/catalog target egresses through the assigned proxy, not direct", async () => { + const creds = await getProviderCredentials("opencode-zen"); + + const exec = new OpencodeExecutor("opencode-zen"); + let observedSource: string | null = null; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: unknown) => { + const url = typeof input === "string" ? input : (input as { url?: string })?.url || String(input); + observedSource = resolveProxyForRequest(url).source; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + + try { + const result = await exec.execute({ + model: "grok-code", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: creds as never, + log, + }); + assert.strictEqual((result as { response: Response }).response.status, 200); + } finally { + globalThis.fetch = originalFetch; + } + + assert.strictEqual( + observedSource, + "context", + `combo/catalog-path ('opencode-zen') must ALSO egress through the assigned proxy — got source=${observedSource}, expected 'context'` + ); +});