diff --git a/src/lib/db/proxies/guards.ts b/src/lib/db/proxies/guards.ts index 1032e5b47c..3265e432cc 100644 --- a/src/lib/db/proxies/guards.ts +++ b/src/lib/db/proxies/guards.ts @@ -23,7 +23,11 @@ export function isGlobalProxyEnabled(db: ReturnType): bool * #6246 fail-closed guard for a connection with an assigned dead proxy pool. * Explicitly disabling proxying globally or for the connection allows direct egress. */ -export function hasBlockingProxyAssignment(connectionId: string, providerId?: string): boolean { +export function hasBlockingProxyAssignment( + connectionId: string, + providerId?: string, + comboName?: string | null +): boolean { try { const db = getDbInstance(); if (!isGlobalProxyEnabled(db)) return false; @@ -33,16 +37,19 @@ export function hasBlockingProxyAssignment(connectionId: string, providerId?: st .get(connectionId) as { provider?: string | null; proxy_enabled?: number } | undefined; if (conn && conn.proxy_enabled === 0) return false; const provider = conn?.provider ?? providerId ?? null; + // #13469: include combo scope — a fully dead combo-scoped pool must not + // fall through to direct egress. const dead = db .prepare( `SELECT 1 FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE ((a.scope = 'account' AND a.scope_id = ?) OR (a.scope = 'provider' AND a.scope_id = ?) - OR (a.scope = 'global')) + OR (a.scope = 'global') + OR (a.scope = 'combo' AND a.scope_id = ?)) AND NOT ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) - .get(connectionId, provider); + .get(connectionId, provider, comboName ?? null); return !!dead; } catch { return false; diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index bca9f349d2..a377853d1b 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1935,7 +1935,12 @@ async function handleSingleModelChat( } let proxyInfo; try { - proxyInfo = await safeResolveProxy(credentials.connectionId, apiKeyInfo?.id, provider); + proxyInfo = await safeResolveProxy( + credentials.connectionId, + apiKeyInfo?.id, + provider, + comboName + ); } catch (error) { releaseOAuthSession(); throw error; diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index d86e3fc242..841b1db11e 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -991,7 +991,8 @@ export function decideProxyResolutionFailure( export async function safeResolveProxy( connectionId: string, apiKeyId?: string, - providerId?: string + providerId?: string, + comboName?: string | null ) { try { const resolved = await resolveProxyForConnection(connectionId, apiKeyId, providerId); @@ -1001,7 +1002,7 @@ export async function safeResolveProxy( // opts back into direct). Explicit "proxy off" is not a leak (see the guard). if ( !(resolved as { proxy?: unknown } | null)?.proxy && - hasBlockingProxyAssignment(connectionId, providerId) + hasBlockingProxyAssignment(connectionId, providerId, comboName) ) { return decideProxyResolutionFailure( Object.assign( diff --git a/tests/unit/proxy-assigned-unavailable-6246.test.ts b/tests/unit/proxy-assigned-unavailable-6246.test.ts index 0f829b25e8..de73573f44 100644 --- a/tests/unit/proxy-assigned-unavailable-6246.test.ts +++ b/tests/unit/proxy-assigned-unavailable-6246.test.ts @@ -25,6 +25,7 @@ process.env.API_KEY_SECRET = "test-secret"; const core = await import("../../src/lib/db/core.ts"); const proxiesDb = await import("../../src/lib/db/proxies.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); +const { safeResolveProxy } = await import("../../src/sse/handlers/chatHelpers.ts"); async function resetStorage() { core.resetDbInstance(); @@ -140,6 +141,61 @@ test("BLOCKS: a dead GLOBAL proxy assignment blocks any connection", async () => ); }); +test("BLOCKS: a dead COMBO-scoped proxy assignment when the combo name is threaded through (#13469)", async () => { + await resetStorage(); + const connId = await makeConnection(); + const proxy = await proxiesDb.createProxy({ + name: "Dead combo proxy", + type: "http", + host: "127.0.0.1", + port: 9006, + }); + await proxiesDb.updateProxy(proxy!.id, { status: "inactive" }); + await proxiesDb.assignProxyToScope("combo", "my-combo", proxy!.id); + + assert.equal( + proxiesDb.hasBlockingProxyAssignment(connId, "openai", "my-combo"), + true, + "a dead combo-scoped proxy must block when the combo name is passed" + ); + assert.equal( + proxiesDb.hasBlockingProxyAssignment(connId, "openai", "some-other-combo"), + false, + "a dead combo-scoped proxy must NOT block a request routed under a different combo" + ); + assert.equal( + proxiesDb.hasBlockingProxyAssignment(connId, "openai"), + false, + "without a combo name (e.g. a non-combo request), the combo-scoped assignment is irrelevant" + ); +}); + +test("WIRING #13469: safeResolveProxy fails closed end-to-end for a dead combo-scoped pool", async () => { + await resetStorage(); + const connId = await makeConnection(); + const proxy = await proxiesDb.createProxy({ + name: "Dead combo proxy (wiring)", + type: "http", + host: "127.0.0.1", + port: 9007, + }); + await proxiesDb.updateProxy(proxy!.id, { status: "inactive" }); + await proxiesDb.assignProxyToScope("combo", "wired-combo", proxy!.id); + + await assert.rejects( + () => safeResolveProxy(connId, undefined, "openai", "wired-combo"), + (err: unknown) => (err as { code?: string }).code === "PROXY_ASSIGNED_UNAVAILABLE", + "safeResolveProxy must thread comboName through to the guard and block instead of leaking direct egress" + ); + + const resolved = await safeResolveProxy(connId, undefined, "openai"); + assert.deepEqual( + resolved, + { proxy: null, level: "direct", levelId: null }, + "the combo scope must not affect a plain (non-combo) request on the same connection" + ); +}); + test("BLOCKS: a dead no-auth provider proxy assignment", async () => { await resetStorage(); const proxy = await proxiesDb.createProxy({