From 71c57ea647fbc376d1207f11d851c09504c9c02d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 25 Aug 2026 10:37:05 +0900 Subject: [PATCH 1/4] release: v2.32.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f73ed2d0e5..063ecfe73e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.32.0", + "version": "2.32.1", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From ec51e42d745d2645bcb22cb67855fa053ba1778e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 25 Aug 2026 20:25:22 +0900 Subject: [PATCH 2/4] release: v2.33.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 063ecfe73e..6f8499ffbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.32.1", + "version": "2.33.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From aaa9eaf37058965373dc42d1ca344e987950b6b6 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 2 Sep 2026 18:43:29 +0900 Subject: [PATCH 3/4] fix(release): pass the bump job's permissions through the reusable-workflow call (#3262) Both v2.40.0 release dispatches (33615174183 preview, 33615177849 main) died at startup_failure: a workflow_call cannot grant its callee more than the calling job holds, and dev-version-bump.yml's job declares contents+pull- requests write. #3129 wired the call but never dispatched a release, so this is its first live run. The caller job now declares exactly the callee's two permissions; no other job in release.yml gains anything. Co-authored-by: jun (cherry picked from commit 7ce0ba51834740d7b4d5ec4793f6572d84624409) --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 458bb67e0a..261aece1d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,6 +67,14 @@ jobs: bump-dev-version: needs: publish if: ${{ inputs.dry-run != true }} + # A reusable-workflow CALL cannot grant the callee more than the calling job holds, + # and GitHub refuses the whole run at startup when the called workflow's own job + # declares permissions the caller did not pass down ("startup_failure", runs + # 33615174183 / 33615177849 — the first dispatches since #3129 wired this call). + # The callee's job declares exactly these two; nothing else in this file gains them. + permissions: + contents: write + pull-requests: write uses: ./.github/workflows/dev-version-bump.yml with: released-version: v${{ inputs.version }} From 00a7947592ff8b1b8bc66201250c0c1c3aa788bb Mon Sep 17 00:00:00 2001 From: luvs01 Date: Thu, 3 Sep 2026 15:12:52 +0900 Subject: [PATCH 4/4] fix(security): scope keychain references to providers --- src/codex/catalog/provider-fetch.ts | 2 +- src/images/plan.ts | 8 +++--- src/lib/lab-live-route-production.ts | 2 +- src/oauth/index.ts | 2 +- src/providers/key-store.ts | 16 +++++++++--- src/providers/openai-sidecar.ts | 4 +-- src/providers/quota.ts | 38 ++++++++++++++-------------- src/router.ts | 8 +++--- src/server/responses/compact.ts | 6 ++--- tests/provider-key-store.test.ts | 33 ++++++++++++++++++------ 10 files changed, 73 insertions(+), 46 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 66b7f63a88..19777d924c 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1283,7 +1283,7 @@ function observedModelsAuthResolver( resolve(name, provider) { if (provider.authMode === "forward") return { apiKey: undefined, observed: true }; if (provider.authMode !== "oauth") { - return { apiKey: resolveProviderApiKey(provider.apiKey), observed: true }; + return { apiKey: resolveProviderApiKey(provider.apiKey, name), observed: true }; } const observation = observeActiveOAuthAccessToken(name, authStoreBuffer); diff --git a/src/images/plan.ts b/src/images/plan.ts index 980e8f7977..394cb58eb0 100644 --- a/src/images/plan.ts +++ b/src/images/plan.ts @@ -35,9 +35,9 @@ export function findXaiProvider(config: OcxConfig): { name: string; provider: Oc * OAuth / Grok CLI proxy transport is not used here (that path is chat-oriented and not a * supported Images transport), so oauth-only configs deliberately do not arm the bridge. */ -export function resolveXaiImageApiKey(provider: OcxProviderConfig): string | undefined { +export function resolveXaiImageApiKey(provider: OcxProviderConfig, providerName = "xai"): string | undefined { if (provider.authMode === "oauth") return undefined; - const apiKey = resolveProviderApiKey(provider.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(provider.apiKey, providerName)?.trim(); return apiKey || undefined; } @@ -73,7 +73,7 @@ export async function planImageBridge( if (host === "api.openai.com") return undefined; const found = findXaiProvider(config); if (!found) return undefined; - const token = resolveXaiImageApiKey(found.provider); + const token = resolveXaiImageApiKey(found.provider, found.name); if (!token) return undefined; // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override. const registryEntry = getProviderRegistryEntry("xai"); @@ -138,7 +138,7 @@ export async function planVideoBridge( if (host === "api.openai.com") return undefined; const found = findXaiProvider(config); if (!found) return undefined; - const token = resolveXaiImageApiKey(found.provider); + const token = resolveXaiImageApiKey(found.provider, found.name); if (!token) return undefined; // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override. const registryEntry = getProviderRegistryEntry("xai"); diff --git a/src/lib/lab-live-route-production.ts b/src/lib/lab-live-route-production.ts index 1e84b34406..8cc9669d43 100644 --- a/src/lib/lab-live-route-production.ts +++ b/src/lib/lab-live-route-production.ts @@ -76,7 +76,7 @@ async function buildLabProviderAuthHeaders( throw new TransportError("harness_failure", "oauth refresh unavailable"); } } else { - const apiKey = resolveProviderApiKey(provider.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(provider.apiKey, routeContext.providerId)?.trim(); if (!apiKey) throw new TransportError("auth_blocked", "missing api key"); if (provider.adapter === "anthropic" && provider.apiKeyTransport === "x-api-key") { headers["x-api-key"] = apiKey; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 92bc74b3fa..2eb4247b85 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1044,7 +1044,7 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf return undefined; } } - return resolveProviderApiKey(prov.apiKey); + return resolveProviderApiKey(prov.apiKey, name); } function modelDiscoveryTransportSeed(providerName: string, prov: OcxProviderConfig): OcxProviderConfig { diff --git a/src/providers/key-store.ts b/src/providers/key-store.ts index bf8ec3198f..df44cae158 100644 --- a/src/providers/key-store.ts +++ b/src/providers/key-store.ts @@ -53,6 +53,11 @@ function keychainAccount(reference: string): string { return reference.slice(KEYCHAIN_REFERENCE_PREFIX.length); } +function keychainReferenceBelongsToProvider(reference: string, providerName: string): boolean { + const account = keychainAccount(reference); + return account === providerName || account.startsWith(`${providerName}/`); +} + function readKeychain(account: string): string | undefined { const cached = resolvedCache.get(account); if (cached !== undefined) return cached; @@ -76,9 +81,12 @@ function readKeychain(account: string): string | undefined { * Single resolver for provider key material: env references, keychain references, or the * literal value. Every request-time read of `apiKey` goes through here. */ -export function resolveProviderApiKey(value: string | undefined): string | undefined { +export function resolveProviderApiKey(value: string | undefined, providerName: string): string | undefined { if (!value) return undefined; - if (isKeychainReference(value)) return readKeychain(keychainAccount(value)); + if (isKeychainReference(value)) { + if (!keychainReferenceBelongsToProvider(value, providerName)) return undefined; + return readKeychain(keychainAccount(value)); + } return resolveEnvValue(value); } @@ -174,6 +182,9 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string): const pool = provider.apiKeyPool ?? []; const resolved = new Map(); const refs = [provider.apiKey, ...pool.map(e => e.key)].filter(isKeychainReference); + if (refs.some(ref => !keychainReferenceBelongsToProvider(ref, name))) { + return { ok: false, error: "provider contains a keychain reference owned by another provider", status: 400 }; + } for (const ref of refs) { const account = keychainAccount(ref); if (resolved.has(account)) continue; @@ -194,4 +205,3 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string): saveConfigPreservingClaudeCode(config); return { ok: true, restored: resolved.size }; } - diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 00ca95dd1b..56c7795a71 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -198,7 +198,7 @@ export function selectOpenAiImagesProvider(config: OcxConfig): OpenAiImagesProvi && provider.authMode !== "forward" && provider.baseUrl.replace(/\/+$/, "") === "https://api.openai.com/v1" ) { - const apiKey = resolveProviderApiKey(provider.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(provider.apiKey, OPENAI_API_PROVIDER_ID)?.trim(); if (apiKey) selection.keyed = { providerName: OPENAI_API_PROVIDER_ID, provider, apiKey }; } return selection; @@ -236,7 +236,7 @@ export function selectImagesProvider(config: OcxConfig): OpenAiImagesProviderSel }; } - const apiKey = resolveProviderApiKey(provider.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(provider.apiKey, providerName)?.trim(); if (!apiKey) { return { forwardCandidates: [], error: `images.provider "${providerName}" has no usable API key` }; } diff --git a/src/providers/quota.ts b/src/providers/quota.ts index ca7e9eb993..d33e3642e0 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -154,7 +154,7 @@ function cacheKey(config: OcxConfig): string { const providers = Object.entries(config.providers) .map(([name, provider]) => { const resolvedKey = typeof provider.apiKey === "string" - ? resolveProviderApiKey(provider.apiKey)?.trim() + ? resolveProviderApiKey(provider.apiKey, name)?.trim() : undefined; const activeKeyId = resolvedKey ? apiKeyPoolEntryId(resolvedKey) : "none"; return `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}:${activeKeyId}`; @@ -371,7 +371,7 @@ function firstFinite(record: Record | null, names: string[]): n async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Promise { // Never send a configured API key to a lookalike host or through a redirect. if (!isCanonicalA6apiBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}` } as const; const [subscriptionResponse, tokenResponse] = await Promise.all([ @@ -465,7 +465,7 @@ function parseOpenCodeGoUsageWindow(value: unknown): { percent: number; resetAt? async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig): Promise { // Never send a configured API key when the provider destination is not the built-in Go endpoint. if (!isCanonicalOpenCodeGoBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const response = await fetch(OPENCODE_GO_USAGE_URL, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, @@ -511,7 +511,7 @@ async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig) async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig): Promise { // Never send a configured API key to a lookalike host or through a redirect. if (!isCanonicalOpenRouterBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const response = await fetch(`${OPENROUTER_BASE_URL}/key`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, @@ -558,7 +558,7 @@ async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig) */ async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalDeepSeekBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const response = await fetch(`${DEEPSEEK_BASE_URL}/user/balance`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, @@ -603,7 +603,7 @@ async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): */ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalClineBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const response = await fetch(`${CLINE_BASE_URL}/api/v1/users/me/plan/usage-limits`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, @@ -751,7 +751,7 @@ function parseZaiQuotaLegacyFields(data: Record | null): Provid */ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const normalized = normalizedBaseUrl(config.baseUrl); const monitorHost = normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4` @@ -794,7 +794,7 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi */ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalMinimaxBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const cnHost = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.minimaxi.com"); const remainsUrl = cnHost ? "https://api.minimaxi.com/v1/token_plan/remains" : MINIMAX_REMAINS_URL; @@ -838,7 +838,7 @@ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): P */ async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalMoonshotBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const host = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.moonshot.cn") ? "https://api.moonshot.cn/v1" : MOONSHOT_BASE_URL; const response = await fetch(`${host}/users/me/balance`, { @@ -882,7 +882,7 @@ async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): */ async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalVeniceBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const response = await fetch(`${VENICE_BASE_URL}/billing/balance`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, @@ -925,7 +925,7 @@ async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Pr */ async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalSyntheticBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const response = await fetch(`${SYNTHETIC_BASE_URL}/quotas`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, @@ -973,7 +973,7 @@ async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): */ async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalDeepInfraBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const response = await fetch(`${DEEPINFRA_BASE_URL}/payment/checklist?compute_owed=true`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, @@ -1015,7 +1015,7 @@ async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig): */ async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalNeuralwattBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + const apiKey = resolveProviderApiKey(config.apiKey, provider)?.trim(); if (!apiKey) return null; const response = await fetch(`${NEURALWATT_BASE_URL}/quota`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, @@ -1809,7 +1809,7 @@ function parseKimiQuotaPayload(value: unknown): ProviderQuota | null { return hasQuotaRows(quota) ? quota : null; } -async function resolveKimiQuotaBearer(config: OcxProviderConfig): Promise { +async function resolveKimiQuotaBearer(provider: string, config: OcxProviderConfig): Promise { if (config.authMode === "oauth") { try { return await getValidAccessToken("kimi"); @@ -1820,14 +1820,14 @@ async function resolveKimiQuotaBearer(config: OcxProviderConfig): Promise { // Never release credentials to a user-edited or lookalike provider host. if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null; - const accessToken = await resolveKimiQuotaBearer(config); + const accessToken = await resolveKimiQuotaBearer(provider, config); if (!accessToken) return null; const response = await fetch(KIMI_CODE_USAGE_URL, { headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` }, @@ -1917,7 +1917,7 @@ async function fetchCommandCodeSpend( } /** OAuth access token or ACTIVE Provider-API key for the Command Code quota probe. */ -async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise { +async function resolveCommandCodeQuotaBearer(provider: string, config: OcxProviderConfig): Promise { if (config.authMode === "oauth") { try { return await getValidAccessToken("command-code"); @@ -1927,7 +1927,7 @@ async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise } // ACTIVE key only: a quota bar for a different account than the one routing // requests is a wrong meter, not a helpful one. - return resolveProviderApiKey(config.apiKey)?.trim() || null; + return resolveProviderApiKey(config.apiKey, provider)?.trim() || null; } /** @@ -1938,7 +1938,7 @@ async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig): Promise { // Never release credentials to a user-edited or lookalike provider host. if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null; - const bearer = await resolveCommandCodeQuotaBearer(config); + const bearer = await resolveCommandCodeQuotaBearer(provider, config); if (!bearer) return null; const whoamiBody = await fetchCommandCodeJson(COMMAND_CODE_WHOAMI_URL, bearer); const whoami = asRecord(whoamiBody?.data) ?? whoamiBody; diff --git a/src/router.ts b/src/router.ts index 874af4633c..59cee9e351 100644 --- a/src/router.ts +++ b/src/router.ts @@ -284,8 +284,8 @@ export function resetCompactionFallbackWarningsForTests(): void { compactionFallbackWarnings.clear(); } -function usableResolvedApiKey(apiKey: string | undefined): string | undefined { - const resolved = resolveProviderApiKey(apiKey); +function usableResolvedApiKey(apiKey: string | undefined, providerName: string): string | undefined { + const resolved = resolveProviderApiKey(apiKey, providerName); return typeof resolved === "string" && resolved.trim().length > 0 ? resolved : undefined; } @@ -293,9 +293,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); if (!registryEntry || !providerMatchesRegistryTransportWithStaticGuards(providerName, provider)) { assertProviderDestinationAllowed(providerName, provider); - return { ...provider, apiKey: usableResolvedApiKey(provider.apiKey) }; + return { ...provider, apiKey: usableResolvedApiKey(provider.apiKey, providerName) }; } - const resolvedApiKey = usableResolvedApiKey(provider.apiKey); + const resolvedApiKey = usableResolvedApiKey(provider.apiKey, providerName); const staticModelCatalog = !providerSupportsLiveModelDiscovery(providerName, provider); const repairLegacyMimoFreeAuth = providerName === "mimo-free" && staticModelCatalog diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 570c415acf..65e8c4f326 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -371,7 +371,7 @@ async function refreshPoolCompactContext(args: { async function resolveAlternateCompactContext(args: { req: Request; config: OcxConfig; - route: { provider: OcxProviderConfig; codexAccountMode?: CodexAccountMode }; + route: { providerName: string; provider: OcxProviderConfig; codexAccountMode?: CodexAccountMode }; selectedModelId: string | undefined; excludeAccountId: string | null; turnAdmissionLease?: AdmissionLease; @@ -401,7 +401,7 @@ async function resolveAlternateCompactContext(args: { headers.set("authorization", `Bearer ${override.accessToken}`); headers.set("chatgpt-account-id", override.chatgptAccountId); } - if (provider.apiKey) headers.set("authorization", `Bearer ${resolveProviderApiKey(provider.apiKey)}`); + if (provider.apiKey) headers.set("authorization", `Bearer ${resolveProviderApiKey(provider.apiKey, route.providerName)}`); return { authCtx, provider, headers }; } catch (err) { if (err instanceof CodexMainProfileDrainingError) { @@ -640,7 +640,7 @@ export async function handleResponsesCompact( ? CODEX_FORWARD_BASE_URL : (compactProvider.baseUrl ?? "").replace(/\/+$/, ""); if (compactProvider.authMode !== "forward" && compactProvider.apiKey) { - headers.set("authorization", `Bearer ${resolveProviderApiKey(compactProvider.apiKey)}`); + headers.set("authorization", `Bearer ${resolveProviderApiKey(compactProvider.apiKey, route.providerName)}`); } const { reasoning: _reasoning, ...compactBodyRaw } = raw as typeof raw & { reasoning?: unknown }; // The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's diff --git a/tests/provider-key-store.test.ts b/tests/provider-key-store.test.ts index 94fe8e313f..da832de4d0 100644 --- a/tests/provider-key-store.test.ts +++ b/tests/provider-key-store.test.ts @@ -74,9 +74,9 @@ describe("provider key resolver (#1221)", () => { test("plain and env values resolve exactly as before", () => { process.env.OCX_TEST_KEY_REF = "from-env"; try { - expect(resolveProviderApiKey(SECRET)).toBe(SECRET); - expect(resolveProviderApiKey("${OCX_TEST_KEY_REF}")).toBe("from-env"); - expect(resolveProviderApiKey(undefined)).toBeUndefined(); + expect(resolveProviderApiKey(SECRET, "relay")).toBe(SECRET); + expect(resolveProviderApiKey("${OCX_TEST_KEY_REF}", "relay")).toBe("from-env"); + expect(resolveProviderApiKey(undefined, "relay")).toBeUndefined(); } finally { delete process.env.OCX_TEST_KEY_REF; } @@ -86,7 +86,9 @@ describe("provider key resolver (#1221)", () => { const { store, factory } = fakeKeychain(); store.set(`${PROVIDER_KEYCHAIN_SERVICE}\u0000relay`, SECRET); setProviderKeychainEntryFactoryForTests(factory); - expect(resolveProviderApiKey("keychain:relay")).toBe(SECRET); + expect(resolveProviderApiKey("keychain:relay", "relay")).toBe(SECRET); + expect(resolveProviderApiKey("keychain:relay", "attacker")).toBeUndefined(); + expect(routedProviderConfig("attacker", { adapter: "openai-chat", baseUrl: "https://attacker.example/v1", apiKey: "keychain:relay" }).apiKey).toBeUndefined(); // routing clone carries the resolved secret so adapters keep working unchanged expect(routedProviderConfig("relay", { adapter: "openai-chat", baseUrl: "https://relay.example/v1", apiKey: "keychain:relay" }).apiKey).toBe(SECRET); @@ -95,8 +97,8 @@ describe("provider key resolver (#1221)", () => { console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; try { setProviderKeychainEntryFactoryForTests(fakeKeychain({ unavailable: true }).factory); - expect(resolveProviderApiKey("keychain:relay")).toBeUndefined(); - expect(resolveProviderApiKey("keychain:relay")).toBeUndefined(); + expect(resolveProviderApiKey("keychain:relay", "relay")).toBeUndefined(); + expect(resolveProviderApiKey("keychain:relay", "relay")).toBeUndefined(); } finally { console.warn = original; } @@ -133,7 +135,7 @@ describe("store / restore", () => { expect(onDisk).toContain("keychain:relay/a1"); expect(store.size).toBe(2); // resolves back to the plaintext at request time - expect(resolveProviderApiKey(config.providers.relay!.apiKey)).toBe(SECRET); + expect(resolveProviderApiKey(config.providers.relay!.apiKey, "relay")).toBe(SECRET); const restored = restoreProviderKeyFromKeychain(config, "relay"); expect(restored).toEqual({ ok: true, restored: 2 }); @@ -156,6 +158,22 @@ describe("store / restore", () => { expect(probeProviderKeychain().available).toBe(false); }); + test("restore refuses keychain references owned by another provider", () => { + const { store, factory } = fakeKeychain(); + store.set(`${PROVIDER_KEYCHAIN_SERVICE}\u0000victim`, SECRET); + setProviderKeychainEntryFactoryForTests(factory); + const config = loadConfig(); + config.providers.relay!.apiKey = "keychain:victim"; + + expect(restoreProviderKeyFromKeychain(config, "relay")).toEqual({ + ok: false, + error: "provider contains a keychain reference owned by another provider", + status: 400, + }); + expect(config.providers.relay!.apiKey).toBe("keychain:victim"); + expect(store.get(`${PROVIDER_KEYCHAIN_SERVICE}\u0000victim`)).toBe(SECRET); + }); + test("management route: GET reports store kind, POST store/restore round-trips", async () => { const { factory } = fakeKeychain(); setProviderKeychainEntryFactoryForTests(factory); @@ -192,4 +210,3 @@ describe("store / restore", () => { } }); }); -