diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index c75001fcf6..45e1ebfab8 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -1,7 +1,7 @@ const { z } = require('zod'); const fs = require('fs').promises; const { nanoid } = require('nanoid'); -const { logger } = require('@librechat/data-schemas'); +const { logger, encryptV2 } = require('@librechat/data-schemas'); const { refreshS3Url, agentCreateSchema, @@ -64,8 +64,22 @@ const toPlainObject = (value) => value && typeof value.toObject === 'function' ? value.toObject() : value; const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0; +const ENCRYPTED_V2_VALUE = /^[a-f0-9]{32}:[a-f0-9]+$/i; -const normalizeLangfuseConfig = (incoming, existing) => { +const encryptSensitiveValue = async (value) => encryptV2(encodeURIComponent(value)); + +const normalizeLangfuseSecret = async (value, options = {}) => { + if (!isNonEmptyString(value)) { + return undefined; + } + const trimmed = value.trim(); + if (options.preserveEncrypted === true && ENCRYPTED_V2_VALUE.test(trimmed)) { + return trimmed; + } + return await encryptSensitiveValue(trimmed); +}; + +const normalizeLangfuseConfig = async (incoming, existing, options = {}) => { if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { return incoming; } @@ -84,9 +98,13 @@ const normalizeLangfuseConfig = (incoming, existing) => { } if (isNonEmptyString(incoming.secretKey)) { - normalized.secretKey = incoming.secretKey.trim(); + normalized.secretKey = await normalizeLangfuseSecret(incoming.secretKey, { + preserveEncrypted: options.preserveIncomingEncrypted === true, + }); } else if (isNonEmptyString(existingConfig.secretKey)) { - normalized.secretKey = existingConfig.secretKey; + normalized.secretKey = await normalizeLangfuseSecret(existingConfig.secretKey, { + preserveEncrypted: true, + }); } return Object.keys(normalized).length > 0 ? normalized : undefined; @@ -349,7 +367,7 @@ const createAgentHandler = async (req, res) => { } if (agentData.langfuse) { - agentData.langfuse = normalizeLangfuseConfig(agentData.langfuse); + agentData.langfuse = await normalizeLangfuseConfig(agentData.langfuse); if (!agentData.langfuse) { delete agentData.langfuse; } @@ -628,7 +646,10 @@ const updateAgentHandler = async (req, res) => { } if (updateData.langfuse) { - updateData.langfuse = normalizeLangfuseConfig(updateData.langfuse, existingAgent.langfuse); + updateData.langfuse = await normalizeLangfuseConfig( + updateData.langfuse, + existingAgent.langfuse, + ); if (!updateData.langfuse) { delete updateData.langfuse; } @@ -1120,7 +1141,7 @@ const uploadAgentAvatarHandler = async (req, res) => { logger.error('[/:agent_id/avatar] Error invalidating avatar refresh cache', cacheErr); } - res.status(201).json(updatedAgent); + res.status(201).json(redactLangfuseSecret(updatedAgent)); } catch (error) { const message = 'An error occurred while updating the Agent Avatar'; logger.error( @@ -1204,6 +1225,15 @@ const revertAgentVersionHandler = async (req, res) => { } } + if (updatedAgent.langfuse) { + const normalizedLangfuse = await normalizeLangfuseConfig(updatedAgent.langfuse, undefined, { + preserveIncomingEncrypted: true, + }); + if (normalizedLangfuse) { + revertUpdates.langfuse = normalizedLangfuse; + } + } + if (Object.keys(revertUpdates).length > 0) { updatedAgent = await db.updateAgent({ id }, revertUpdates, { updatingUserId: req.user.id }); } @@ -1216,7 +1246,7 @@ const revertAgentVersionHandler = async (req, res) => { delete updatedAgent.author; } - return res.json(updatedAgent); + return res.json(redactLangfuseSecret(updatedAgent)); } catch (error) { logger.error('[/agents/:id/revert] Error reverting Agent version', error); res.status(500).json({ error: error.message }); diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 88b2821b25..7c6718ce81 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -1,7 +1,15 @@ +process.env.CREDS_KEY = + process.env.CREDS_KEY?.length === 64 + ? process.env.CREDS_KEY + : '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +process.env.CREDS_IV = + process.env.CREDS_IV?.length === 32 ? process.env.CREDS_IV : '0123456789abcdef0123456789abcdef'; + const mongoose = require('mongoose'); +const fs = require('fs').promises; const { nanoid } = require('nanoid'); const { v4: uuidv4 } = require('uuid'); -const { agentSchema, fileSchema } = require('@librechat/data-schemas'); +const { agentSchema, fileSchema, encryptV2, decryptV2 } = require('@librechat/data-schemas'); const { FileSources, PermissionBits } = require('librechat-data-provider'); const { MongoMemoryServer } = require('mongodb-memory-server'); @@ -74,6 +82,7 @@ const { createAgent: createAgentHandler, getAgent: getAgentHandler, duplicateAgent: duplicateAgentHandler, + uploadAgentAvatar: uploadAgentAvatarHandler, revertAgentVersion: revertAgentVersionHandler, updateAgent: updateAgentHandler, getListAgents: getListAgentsHandler, @@ -86,12 +95,17 @@ const { } = require('~/server/services/PermissionService'); const { refreshS3Url } = require('@librechat/api'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { resizeAvatar } = require('~/server/services/Files/images/avatar'); /** * @type {import('mongoose').Model} */ let Agent; +const encryptStoredSecret = async (value) => encryptV2(encodeURIComponent(value)); +const decryptStoredSecret = async (value) => decodeURIComponent(await decryptV2(value)); + describe('Agent Controllers - Mass Assignment Protection', () => { let mongoServer; let mockReq; @@ -203,12 +217,12 @@ describe('Agent Controllers - Mass Assignment Protection', () => { }); const agentInDb = await Agent.findOne({ id: createdAgent.id }).lean(); - expect(agentInDb.langfuse).toEqual({ - enabled: true, - publicKey: 'pk-test', - secretKey: 'sk-test', - baseUrl: 'https://cloud.langfuse.com', - }); + expect(agentInDb.langfuse.enabled).toBe(true); + expect(agentInDb.langfuse.publicKey).toBe('pk-test'); + expect(agentInDb.langfuse.secretKey).not.toBe('sk-test'); + expect(agentInDb.langfuse.secretKey).toContain(':'); + expect(await decryptStoredSecret(agentInDb.langfuse.secretKey)).toBe('sk-test'); + expect(agentInDb.langfuse.baseUrl).toBe('https://cloud.langfuse.com'); }); test('should reject creation with unauthorized fields (mass assignment protection)', async () => { @@ -785,13 +799,14 @@ describe('Agent Controllers - Mass Assignment Protection', () => { }); test('should preserve existing Langfuse secret when update sends an empty secret', async () => { + const encryptedOriginal = await encryptStoredSecret('sk-original'); await Agent.updateOne( { id: existingAgentId }, { langfuse: { enabled: true, publicKey: 'pk-original', - secretKey: 'sk-original', + secretKey: encryptedOriginal, baseUrl: 'https://cloud.langfuse.com', }, }, @@ -819,22 +834,22 @@ describe('Agent Controllers - Mass Assignment Protection', () => { }); const agentInDb = await Agent.findOne({ id: existingAgentId }).lean(); - expect(agentInDb.langfuse).toEqual({ - enabled: true, - publicKey: 'pk-updated', - secretKey: 'sk-original', - baseUrl: 'https://us.cloud.langfuse.com', - }); + expect(agentInDb.langfuse.enabled).toBe(true); + expect(agentInDb.langfuse.publicKey).toBe('pk-updated'); + expect(agentInDb.langfuse.secretKey).toBe(encryptedOriginal); + expect(await decryptStoredSecret(agentInDb.langfuse.secretKey)).toBe('sk-original'); + expect(agentInDb.langfuse.baseUrl).toBe('https://us.cloud.langfuse.com'); }); test('should update Langfuse secret explicitly while redacting it in update response', async () => { + const encryptedOriginal = await encryptStoredSecret('sk-original'); await Agent.updateOne( { id: existingAgentId }, { langfuse: { enabled: true, publicKey: 'pk-original', - secretKey: 'sk-original', + secretKey: encryptedOriginal, baseUrl: 'https://cloud.langfuse.com', }, }, @@ -857,7 +872,50 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(updatedAgent.langfuse.secretKey).toBe(''); const agentInDb = await Agent.findOne({ id: existingAgentId }).lean(); - expect(agentInDb.langfuse.secretKey).toBe('sk-updated'); + expect(agentInDb.langfuse.secretKey).not.toBe('sk-updated'); + expect(agentInDb.langfuse.secretKey).not.toBe(encryptedOriginal); + expect(await decryptStoredSecret(agentInDb.langfuse.secretKey)).toBe('sk-updated'); + }); + + test('uploadAgentAvatarHandler should redact Langfuse secret in response', async () => { + await Agent.updateOne( + { id: existingAgentId }, + { + langfuse: { + enabled: true, + publicKey: 'pk-avatar', + secretKey: 'sk-avatar', + baseUrl: 'https://cloud.langfuse.com', + }, + }, + ); + + const readFileSpy = jest.spyOn(fs, 'readFile').mockResolvedValue(Buffer.from('avatar')); + const unlinkSpy = jest.spyOn(fs, 'unlink').mockResolvedValue(); + getStrategyFunctions.mockReturnValue({ + processAvatar: jest.fn().mockResolvedValue('avatars/new-avatar.png'), + }); + resizeAvatar.mockResolvedValue(Buffer.from('resized-avatar')); + + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.user.tenantId = 'tenant-1'; + mockReq.params.agent_id = existingAgentId; + mockReq.config = {}; + mockReq.file = { + path: '/tmp/avatar.png', + }; + + await uploadAgentAvatarHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(201); + const updatedAgent = mockRes.json.mock.calls[0][0]; + expect(updatedAgent.langfuse.secretKey).toBe(''); + + const agentInDb = await Agent.findOne({ id: existingAgentId }).lean(); + expect(agentInDb.langfuse.secretKey).toBe('sk-avatar'); + + readFileSpy.mockRestore(); + unlinkSpy.mockRestore(); }); test('should return 404 for non-existent agent', async () => { @@ -1159,6 +1217,44 @@ describe('Agent Controllers - Mass Assignment Protection', () => { const agentInDb = await Agent.findOne({ id: agent.id }).lean(); expect(agentInDb.tool_resources.file_search.file_ids).toEqual([ownedFileId]); }); + + test('revertAgentVersionHandler should redact Langfuse secret in response', async () => { + const agentAuthorId = new mongoose.Types.ObjectId(); + const agent = await Agent.create({ + id: `agent_${uuidv4()}`, + name: 'Current Agent', + provider: 'openai', + model: 'gpt-4', + author: agentAuthorId, + versions: [ + { + name: 'Historical Agent', + provider: 'openai', + model: 'gpt-4', + langfuse: { + enabled: true, + publicKey: 'pk-version', + secretKey: 'sk-version', + baseUrl: 'https://cloud.langfuse.com', + }, + }, + ], + }); + + mockReq.user.id = agentAuthorId.toString(); + mockReq.params.id = agent.id; + mockReq.body = { version_index: 0 }; + + await revertAgentVersionHandler(mockReq, mockRes); + + expect(mockRes.json).toHaveBeenCalled(); + const updatedAgent = mockRes.json.mock.calls[0][0]; + expect(updatedAgent.langfuse.secretKey).toBe(''); + + const agentInDb = await Agent.findOne({ id: agent.id }).lean(); + expect(agentInDb.langfuse.secretKey).not.toBe('sk-version'); + expect(await decryptStoredSecret(agentInDb.langfuse.secretKey)).toBe('sk-version'); + }); }); describe('Mass Assignment Attack Scenarios', () => { diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts index 62ed847df4..de1ea59637 100644 --- a/packages/api/src/agents/run.spec.ts +++ b/packages/api/src/agents/run.spec.ts @@ -1,5 +1,5 @@ import { Providers } from '@librechat/agents'; -import { logger } from '@librechat/data-schemas'; +import { logger, decryptV2 } from '@librechat/data-schemas'; import { ToolMessage, AIMessage, HumanMessage } from '@librechat/agents/langchain/messages'; import { @@ -8,6 +8,18 @@ import { extractDiscoveredToolsFromHistory, } from './run'; +jest.mock('@librechat/data-schemas', () => { + const actual = jest.requireActual('@librechat/data-schemas'); + return { + ...actual, + decryptV2: jest.fn(async (value: string) => + value === '0123456789abcdef0123456789abcdef:736b2d6167656e74' + ? encodeURIComponent('sk-agent') + : value, + ), + }; +}); + type LangfuseRunAgent = Parameters[0]; type LangfuseAppConfig = NonNullable[1]>; @@ -188,14 +200,14 @@ describe('resolveEffectiveLangfuseConfig', () => { delete process.env.LANGFUSE_TEST_MISSING_KEY; }); - it('returns undefined when no tenant or agent config is supplied', () => { + it('returns undefined when no tenant or agent config is supplied', async () => { expect( - resolveEffectiveLangfuseConfig(createLangfuseAgent(), createLangfuseAppConfig()), + await resolveEffectiveLangfuseConfig(createLangfuseAgent(), createLangfuseAppConfig()), ).toBeUndefined(); }); - it('uses tenant defaults and resolves env var refs', () => { - const result = resolveEffectiveLangfuseConfig( + it('uses tenant defaults and resolves env var refs', async () => { + const result = await resolveEffectiveLangfuseConfig( createLangfuseAgent(), createLangfuseAppConfig({ enabled: true, @@ -213,8 +225,8 @@ describe('resolveEffectiveLangfuseConfig', () => { }); }); - it('overlays non-empty agent fields on tenant defaults', () => { - const result = resolveEffectiveLangfuseConfig( + it('overlays non-empty agent fields on tenant defaults', async () => { + const result = await resolveEffectiveLangfuseConfig( createLangfuseAgent({ enabled: true, publicKey: 'pk-agent', @@ -235,8 +247,8 @@ describe('resolveEffectiveLangfuseConfig', () => { }); }); - it('enables tracing with valid keys and no base URL', () => { - const result = resolveEffectiveLangfuseConfig( + it('enables tracing with valid keys and no base URL', async () => { + const result = await resolveEffectiveLangfuseConfig( createLangfuseAgent(), createLangfuseAppConfig({ enabled: true, @@ -252,8 +264,31 @@ describe('resolveEffectiveLangfuseConfig', () => { }); }); - it('lets an agent explicitly disable tracing over enabled tenant defaults', () => { - const result = resolveEffectiveLangfuseConfig( + it('decrypts encrypted agent secret keys before resolving the effective config', async () => { + const encryptedSecret = '0123456789abcdef0123456789abcdef:736b2d6167656e74'; + const result = await resolveEffectiveLangfuseConfig( + createLangfuseAgent({ + enabled: true, + publicKey: 'pk-agent', + secretKey: encryptedSecret, + }), + createLangfuseAppConfig({ + enabled: true, + publicKey: 'pk-tenant', + secretKey: 'sk-tenant', + }), + ); + + expect(decryptV2).toHaveBeenCalledWith(encryptedSecret); + expect(result).toEqual({ + enabled: true, + publicKey: 'pk-agent', + secretKey: 'sk-agent', + }); + }); + + it('lets an agent explicitly disable tracing over enabled tenant defaults', async () => { + const result = await resolveEffectiveLangfuseConfig( createLangfuseAgent({ enabled: false, }), @@ -268,8 +303,8 @@ describe('resolveEffectiveLangfuseConfig', () => { expect(result).toEqual({ enabled: false }); }); - it('disables tracing and warns when credentials are unresolved', () => { - const result = resolveEffectiveLangfuseConfig( + it('disables tracing and warns when credentials are unresolved', async () => { + const result = await resolveEffectiveLangfuseConfig( createLangfuseAgent(), createLangfuseAppConfig({ enabled: true, diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 147451fe1f..4df2e88bee 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1,4 +1,4 @@ -import { logger } from '@librechat/data-schemas'; +import { logger, decryptV2 } from '@librechat/data-schemas'; import { Run, Providers, Constants } from '@librechat/agents'; import { KnownEndpoints, @@ -278,6 +278,7 @@ function isNonEmptyString(value: unknown): value is string { } const UNRESOLVED_ENV_VAR_PLACEHOLDER = /\$\{[^}]+\}/; +const ENCRYPTED_V2_VALUE = /^[a-f0-9]{32}:[a-f0-9]+$/i; function hasUnresolvedPlaceholder(value: string): boolean { return UNRESOLVED_ENV_VAR_PLACEHOLDER.test(value); @@ -298,7 +299,11 @@ type EffectiveLangfuseConfig = enabled: false; }; -function resolveLangfuseValue(agentValue?: string, tenantValue?: string): string | undefined { +async function resolveLangfuseValue( + agentValue?: string, + tenantValue?: string, + options: { encrypted?: boolean; agentId?: string } = {}, +): Promise { const rawValue = isNonEmptyString(agentValue) ? agentValue : isNonEmptyString(tenantValue) @@ -308,7 +313,21 @@ function resolveLangfuseValue(agentValue?: string, tenantValue?: string): string return undefined; } - const resolved = extractEnvVariable(rawValue); + let value = rawValue; + if (options.encrypted === true && ENCRYPTED_V2_VALUE.test(value)) { + try { + value = decodeURIComponent(await decryptV2(value)); + } catch (error) { + logger.warn( + `[createRun] Langfuse tracing disabled for agent ${ + options.agentId ?? 'unknown' + }; failed to decrypt secretKey: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } + } + + const resolved = extractEnvVariable(value); if (!isNonEmptyString(resolved) || hasUnresolvedPlaceholder(resolved)) { return undefined; } @@ -316,10 +335,10 @@ function resolveLangfuseValue(agentValue?: string, tenantValue?: string): string return resolved; } -export function resolveEffectiveLangfuseConfig( +export async function resolveEffectiveLangfuseConfig( agent: RunAgent, appConfig?: AppConfig, -): EffectiveLangfuseConfig | undefined { +): Promise { const tenantLangfuse = appConfig?.langfuse; const agentLangfuse = agent.langfuse; @@ -332,9 +351,12 @@ export function resolveEffectiveLangfuseConfig( return { enabled: false }; } - const publicKey = resolveLangfuseValue(agentLangfuse?.publicKey, tenantLangfuse?.publicKey); - const secretKey = resolveLangfuseValue(agentLangfuse?.secretKey, tenantLangfuse?.secretKey); - const baseUrl = resolveLangfuseValue(agentLangfuse?.baseUrl, tenantLangfuse?.baseUrl); + const publicKey = await resolveLangfuseValue(agentLangfuse?.publicKey, tenantLangfuse?.publicKey); + const secretKey = await resolveLangfuseValue(agentLangfuse?.secretKey, tenantLangfuse?.secretKey, { + encrypted: true, + agentId: agent.id, + }); + const baseUrl = await resolveLangfuseValue(agentLangfuse?.baseUrl, tenantLangfuse?.baseUrl); if (!publicKey || !secretKey) { const missingFields = [ @@ -697,14 +719,14 @@ function anyAgentHasCodeEnv(agents: RunAgent[]): boolean { * explicit child agents loaded in `agent.subagentAgentConfigs`. Returns an empty * array when subagents are disabled or no spawn targets are available. */ -function buildSubagentConfigs( +async function buildSubagentConfigs( agent: RunAgent, agentInput: AgentInputs, - toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, + toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => Promise, state: SubagentBuildState, ancestors: Set = new Set(), depth = 0, -): SubagentConfig[] { +): Promise { if (!agent.subagents?.enabled) { return []; } @@ -754,7 +776,7 @@ function buildSubagentConfigs( * skips both the field stamping and the registry mutation at the * source so children truly start fresh. */ - const childInputs = toInput(child, { isSubagent: true }); + const childInputs = await toInput(child, { isSubagent: true }); /** * Recursively resolve the child's own spawn targets so multi-level * delegation (A → B → C) works. Without this, a child whose own @@ -763,7 +785,7 @@ function buildSubagentConfigs( * `subagentConfigs`, and that only runs for the outer agents in * `agents[]`. Cycle-safe via `nextAncestors`. */ - const grandchildConfigs = buildSubagentConfigs( + const grandchildConfigs = await buildSubagentConfigs( child, childInputs, toInput, @@ -858,7 +880,10 @@ export async function createRun({ ? extractDiscoveredToolsFromHistory(messages) : new Set(); - const buildAgentInput = (agent: RunAgent, opts: { isSubagent?: boolean } = {}): AgentInputs => { + const buildAgentInput = async ( + agent: RunAgent, + opts: { isSubagent?: boolean } = {}, + ): Promise => { const isSubagent = opts.isSubagent === true; const provider = (providerEndpointMap[ @@ -981,7 +1006,7 @@ export async function createRun({ ); const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint); - const langfuse = resolveEffectiveLangfuseConfig(agent, appConfig); + const langfuse = await resolveEffectiveLangfuseConfig(agent, appConfig); const agentInput: AgentInputs & { langfuse?: LangfuseConfig } = { provider, reasoningKey, @@ -1015,8 +1040,8 @@ export async function createRun({ rootAgentIds: agents.map((agent) => agent.id), }; for (const agent of agents) { - const agentInput = buildAgentInput(agent); - const subagentConfigs = buildSubagentConfigs( + const agentInput = await buildAgentInput(agent); + const subagentConfigs = await buildSubagentConfigs( agent, agentInput, buildAgentInput,