From f2df0ea62bce1246bc4e2facf053210a2e950ff5 Mon Sep 17 00:00:00 2001 From: Yorgos K Date: Wed, 29 Apr 2026 02:09:54 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Filter=20`user?= =?UTF-8?q?=5Fprovided`=20Sentinel=20in=20Tool=20Credential=20Loading=20(#?= =?UTF-8?q?12840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When GOOGLE_KEY=user_provided is set as an endpoint config, the loadAuthValues() function in credentials.js would pass the literal string 'user_provided' to tools via the || fallback chain. This caused Gemini Image Tools to fail at runtime with an invalid API key error, as initializeGeminiClient() received the sentinel value instead of a real key. The fix aligns loadAuthValues() with checkPluginAuth() in format.ts, which already correctly excludes user_provided and empty/whitespace values. Now loadAuthValues() skips these values and continues to the next field in the fallback chain or falls through to user DB values. Added regression tests covering: - user_provided sentinel is skipped, DB value used instead - Fallback chain continues past user_provided to next field - Empty and whitespace env values are skipped - Real env values are returned correctly - Optional fields with sentinel values handled gracefully --- api/server/services/Tools/credentials.js | 10 +- api/server/services/Tools/credentials.spec.js | 134 ++++++++++++++++++ 2 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 api/server/services/Tools/credentials.spec.js diff --git a/api/server/services/Tools/credentials.js b/api/server/services/Tools/credentials.js index b50a2460d4..a289134a5a 100644 --- a/api/server/services/Tools/credentials.js +++ b/api/server/services/Tools/credentials.js @@ -1,3 +1,4 @@ +const { AuthType } = require('librechat-data-provider'); const { getUserPluginAuthValue } = require('~/server/services/PluginService'); /** @@ -19,17 +20,18 @@ const loadAuthValues = async ({ userId, authFields, optional, throwError = true */ const findAuthValue = async (fields) => { for (const field of fields) { - let value = process.env[field]; - if (value) { - return { authField: field, authValue: value }; + const envValue = process.env[field]; + if (envValue && envValue.trim() !== '' && envValue !== AuthType.USER_PROVIDED) { + return { authField: field, authValue: envValue }; } + let value; try { value = await getUserPluginAuthValue(userId, field, throwError); } catch (err) { if (optional && optional.has(field)) { return { authField: field, authValue: undefined }; } - if (field === fields[fields.length - 1] && !value) { + if (field === fields[fields.length - 1]) { throw err; } } diff --git a/api/server/services/Tools/credentials.spec.js b/api/server/services/Tools/credentials.spec.js new file mode 100644 index 0000000000..727edbd9f3 --- /dev/null +++ b/api/server/services/Tools/credentials.spec.js @@ -0,0 +1,134 @@ +const { AuthType } = require('librechat-data-provider'); + +jest.mock('~/server/services/PluginService', () => ({ + getUserPluginAuthValue: jest.fn(), +})); + +const { getUserPluginAuthValue } = require('~/server/services/PluginService'); +const { loadAuthValues } = require('./credentials'); + +describe('loadAuthValues', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return env value when set to a real key', async () => { + process.env.MY_API_KEY = 'real-key-123'; + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['MY_API_KEY'], + }); + + expect(result).toEqual({ MY_API_KEY: 'real-key-123' }); + }); + + it('should skip user_provided sentinel and try user DB value', async () => { + process.env.GOOGLE_KEY = AuthType.USER_PROVIDED; + getUserPluginAuthValue.mockResolvedValue('user-stored-key'); + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['GOOGLE_KEY'], + }); + + expect(getUserPluginAuthValue).toHaveBeenCalledWith('user1', 'GOOGLE_KEY', true); + expect(result).toEqual({ GOOGLE_KEY: 'user-stored-key' }); + }); + + it('should skip user_provided and continue to next field in fallback chain', async () => { + process.env.GOOGLE_KEY = AuthType.USER_PROVIDED; + process.env.GOOGLE_SERVICE_KEY_FILE = '/path/to/service-account.json'; + getUserPluginAuthValue.mockRejectedValue(new Error('No auth found')); + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['GEMINI_API_KEY||GOOGLE_KEY||GOOGLE_SERVICE_KEY_FILE'], + }); + + expect(result).toEqual({ GOOGLE_SERVICE_KEY_FILE: '/path/to/service-account.json' }); + }); + + it('should skip empty and whitespace-only env values', async () => { + process.env.EMPTY_KEY = ''; + process.env.WHITESPACE_KEY = ' '; + process.env.REAL_KEY = 'valid'; + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['EMPTY_KEY||WHITESPACE_KEY||REAL_KEY'], + }); + + expect(result).toEqual({ REAL_KEY: 'valid' }); + }); + + it('should not return user_provided as an auth value', async () => { + process.env.GOOGLE_KEY = AuthType.USER_PROVIDED; + getUserPluginAuthValue.mockResolvedValue(null); + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['GOOGLE_KEY'], + throwError: false, + }); + + expect(result).toEqual({}); + }); + + it('should return env value without calling DB when env is valid', async () => { + process.env.MY_KEY = 'valid-key'; + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['MY_KEY'], + }); + + expect(result).toEqual({ MY_KEY: 'valid-key' }); + expect(getUserPluginAuthValue).not.toHaveBeenCalled(); + }); + + it('should return real env value from first matching field in fallback chain', async () => { + process.env.GEMINI_API_KEY = 'gemini-key'; + process.env.GOOGLE_KEY = 'google-key'; + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['GEMINI_API_KEY||GOOGLE_KEY'], + }); + + expect(result).toEqual({ GEMINI_API_KEY: 'gemini-key' }); + }); + + it('should return undefined for optional field when sentinel is filtered and DB throws', async () => { + process.env.GOOGLE_KEY = AuthType.USER_PROVIDED; + getUserPluginAuthValue.mockRejectedValue(new Error('No auth found')); + + const optional = new Set(['GOOGLE_KEY']); + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['GOOGLE_KEY'], + optional, + }); + + expect(result).toEqual({ GOOGLE_KEY: undefined }); + }); + + it('should not leak sentinel through catch path when DB lookup throws', async () => { + process.env.GOOGLE_KEY = AuthType.USER_PROVIDED; + getUserPluginAuthValue.mockRejectedValue(new Error('No auth found')); + + await expect( + loadAuthValues({ + userId: 'user1', + authFields: ['GOOGLE_KEY'], + }), + ).rejects.toThrow('No auth found'); + }); +});