From 2a5a25d5a22518c122b602e06e3d557f58eee94e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 28 Aug 2026 23:49:56 +0300 Subject: [PATCH] fix(small-model): harden configured headers --- packages/web/server/lib/small-model/call.js | 34 ++++++--- .../web/server/lib/small-model/call.test.js | 71 ++++++++++++++++++- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 1d2c2c5f..9a7d4e95 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -19,6 +19,18 @@ const DEFAULT_MAX_OUTPUT_TOKENS = 4_000; const USER_AGENT = 'opencode/1.0 openchamber'; +const mergeHeadersCaseInsensitive = (base, overrides) => { + const merged = { ...base }; + for (const [name, value] of Object.entries(overrides || {})) { + const existingName = Object.keys(merged).find((key) => key.toLowerCase() === name.toLowerCase()); + if (existingName) { + delete merged[existingName]; + } + merged[name] = value; + } + return merged; +}; + const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token'; const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses'; @@ -157,11 +169,10 @@ const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, }); const response = await fetch(`${trimmedBase}/chat/completions`, { method: 'POST', - headers: { + headers: mergeHeadersCaseInsensitive({ 'Content-Type': 'application/json', Accept: 'application/json', - ...headers, - }, + }, headers), body: JSON.stringify({ model: modelID, messages: [ @@ -510,7 +521,7 @@ const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, sys // Custom provider configuration support // --------------------------------------------------------------------------- -const resolveConfigApiKey = (value, workingDirectory, providerID) => { +const resolveConfigValue = (value, workingDirectory, providerID, headerName = null) => { const envMatch = value.match(/^\{env:([^}]+)\}$/i); if (envMatch) { return process.env[envMatch[1].trim()]?.trim() || null; @@ -531,7 +542,12 @@ const resolveConfigApiKey = (value, workingDirectory, providerID) => { { config: layers.customConfig, filePath: layers.paths.customPath }, { config: layers.projectConfig, filePath: layers.paths.projectPath }, { config: layers.userConfig, filePath: layers.paths.userPath }, - ].find(({ config }) => config?.provider?.[providerID]?.options?.apiKey === value); + ].find(({ config }) => { + const options = config?.provider?.[providerID]?.options; + return headerName + ? options?.headers?.[headerName] === value + : options?.apiKey === value; + }); resolvedPath = path.resolve(source?.filePath ? path.dirname(source.filePath) : workingDirectory || process.cwd(), configuredPath); } @@ -540,7 +556,7 @@ const resolveConfigApiKey = (value, workingDirectory, providerID) => { if (!key) throw new Error('empty file'); return key; } catch { - throw new Error(`Failed to resolve configured apiKey file for provider "${providerID}"`); + throw new Error(`Failed to resolve configured ${headerName ? `header "${headerName}"` : 'apiKey'} file for provider "${providerID}"`); } }; @@ -561,7 +577,7 @@ const readConfiguredHeaders = (providerCfg, workingDirectory, providerID) => { // Config headers are strings; a malformed entry is skipped rather than // stringified into a header the gateway would reject. if (String(value) !== value) continue; - const resolved = resolveConfigApiKey(value.trim(), workingDirectory, providerID); + const resolved = resolveConfigValue(value.trim(), workingDirectory, providerID, name); if (resolved) headers[name] = resolved; } return Object.keys(headers).length ? headers : null; @@ -574,7 +590,7 @@ const readProviderConfig = (workingDirectory, providerID) => { if (!providerCfg || typeof providerCfg !== 'object') return null; const baseURL = typeof providerCfg?.options?.baseURL === 'string' ? providerCfg.options.baseURL.trim() : null; const rawApiKey = typeof providerCfg?.options?.apiKey === 'string' ? providerCfg.options.apiKey.trim() : null; - const apiKey = rawApiKey ? resolveConfigApiKey(rawApiKey, workingDirectory, providerID) : null; + const apiKey = rawApiKey ? resolveConfigValue(rawApiKey, workingDirectory, providerID) : null; return { baseURL, headers: readConfiguredHeaders(providerCfg, workingDirectory, providerID), @@ -779,7 +795,7 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider baseURL, // Configured headers last: a gateway that authenticates on its own header // must be able to override the bearer default rather than sit beside it. - headers: { Authorization: `Bearer ${apiKey}`, ...(providerConfig?.headers || {}) }, + headers: mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers), modelID, prompt, system, diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 7fd5fb1c..7c2a5315 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -5,8 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // readConfig reads merged opencode config layers from disk; mock it so each // test controls the provider config without touching the filesystem. call.js -// imports only readConfig from shared.js, so the rest of that module is left -// untouched for this file. +// imports the config readers and a plain-object predicate from shared.js, so +// the rest of that module is left untouched for this file. vi.mock('../opencode/shared.js', () => ({ readConfig: vi.fn(), readConfigLayers: vi.fn(), @@ -70,6 +70,7 @@ describe('callSmallModel — custom provider config', () => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); delete process.env.OPENCHAMBER_TEST_PROVIDER_KEY; + delete process.env.OPENCHAMBER_TEST_GATEWAY_KEY; }); describe('config-supplied credentials (no auth.json entry)', () => { @@ -158,6 +159,72 @@ describe('callSmallModel — custom provider config', () => { expect(init.headers.Authorization).toBe('Bearer sk-config'); }); + it('resolves a relative header file from the config layer that defines it', async () => { + const configPath = '/config/opencode.json'; + const secretPath = '/config/gateway-key'; + vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath === secretPath) return 'sub-key\n'; + throw new Error(`Unexpected file read: ${filePath}`); + }); + const provider = { + custom: { + options: { + apiKey: 'sk-config', + baseURL: 'https://proxy.example.test/v1', + headers: { 'x-gateway-key': '{file:./gateway-key}' }, + }, + }, + }; + readConfig.mockReturnValue({ provider }); + readConfigLayers.mockReturnValue({ + customConfig: {}, + projectConfig: {}, + userConfig: { provider }, + paths: { customPath: null, projectPath: '/project/opencode.json', userPath: configPath }, + }); + fetchMock.mockResolvedValue(ok('hello')); + + await callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/project', + providerID: 'custom', + modelID: 'model', + prompt: 'hi', + }); + + expect(lastCall(fetchMock).init.headers['x-gateway-key']).toBe('sub-key'); + expect(fs.readFileSync).toHaveBeenCalledWith(secretPath, 'utf8'); + }); + + it('overrides Authorization without depending on header-name casing', async () => { + readConfig.mockReturnValue({ + provider: { + custom: { + options: { + apiKey: 'sk-config', + baseURL: 'https://proxy.example.test/v1', + headers: { authorization: 'Basic gateway-token' }, + }, + }, + }, + }); + fetchMock.mockResolvedValue(ok('hello')); + + await callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/project', + providerID: 'custom', + modelID: 'model', + prompt: 'hi', + }); + + const headers = lastCall(fetchMock).init.headers; + expect(headers.authorization).toBe('Basic gateway-token'); + expect(headers.Authorization).toBeUndefined(); + }); + it('uses apiKey and baseURL from provider config when no auth.json entry exists', async () => { readConfig.mockReturnValue({ provider: {