From c8b9a0be0f02869ed2dc3e7a3a07d9e6318cbe9f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 15:58:16 +0000 Subject: [PATCH] Tolerate ${ENV_VAR} placeholders when importing librechat.yaml The YAML import validation ran the LibreChat configSchema, whose MCP url field resolves ${ENV_VAR} placeholders against process.env before URL validation. The admin panel doesn't have each deployment's env vars set, so placeholders like ${MASTRA_INTERNAL_URL} stayed literal and failed URL validation, blocking a single canonical config shared across environments. Sanitize the parsed config before validation by swapping every string holding a ${...} placeholder for a unique schema-safe sentinel, then restore the originals verbatim after validation and AppService run, so placeholders are preserved in the imported config. https://claude.ai/code/session_015i1Rwi3ms2TbF1upWF7LbX --- src/server/config.ts | 14 +++- src/server/utils/envPlaceholder.test.ts | 98 +++++++++++++++++++++++++ src/server/utils/envPlaceholder.ts | 77 +++++++++++++++++++ 3 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 src/server/utils/envPlaceholder.test.ts create mode 100644 src/server/utils/envPlaceholder.ts diff --git a/src/server/config.ts b/src/server/config.ts index 148bf65..610ed95 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -17,6 +17,7 @@ import { requireAnyCapability, requireAllSectionCapabilities, } from './capabilities'; +import { sanitizeEnvPlaceholders, restoreEnvPlaceholders } from './utils/envPlaceholder'; import { BASE_CONFIG_PRINCIPAL_ID } from './constants'; import { safeFieldPath } from './utils/validation'; import { flattenObject } from '@/utils/format'; @@ -589,7 +590,9 @@ export const parseImportedYaml = createServerFn({ method: 'POST' }) }; } - const result = configSchema.safeParse(rawConfig); + const { sanitized, placeholders } = sanitizeEnvPlaceholders(rawConfig as t.ConfigValue); + + const result = configSchema.safeParse(sanitized); if (!result.success) { return { @@ -607,13 +610,18 @@ export const parseImportedYaml = createServerFn({ method: 'POST' }) try { const appConfig = await AppService({ config: result.data }); - return { success: true, error: undefined, validationErrors: undefined, appConfig }; + return { + success: true, + error: undefined, + validationErrors: undefined, + appConfig: restoreEnvPlaceholders(appConfig as t.ConfigValue, placeholders), + }; } catch (appServiceError) { console.warn( 'AppService failed for imported config, falling back to raw config:', appServiceError instanceof Error ? appServiceError.message : appServiceError, ); - const fallbackConfig = result.data; + const fallbackConfig = restoreEnvPlaceholders(result.data as t.ConfigValue, placeholders); return { success: true, error: undefined, diff --git a/src/server/utils/envPlaceholder.test.ts b/src/server/utils/envPlaceholder.test.ts new file mode 100644 index 0000000..72cc5f5 --- /dev/null +++ b/src/server/utils/envPlaceholder.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { configSchema } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { sanitizeEnvPlaceholders, restoreEnvPlaceholders } from './envPlaceholder'; + +describe('sanitizeEnvPlaceholders', () => { + it('replaces env placeholders with unique sentinels and records the originals', () => { + const config: t.ConfigValue = { + mcpServers: { + posts: { + type: 'sse', + url: '${MASTRA_INTERNAL_URL}', + headers: { Authorization: 'Bearer ${MASTRA_API_KEY}' }, + }, + }, + }; + + const { sanitized, placeholders } = sanitizeEnvPlaceholders(config); + const server = (sanitized as { mcpServers: { posts: Record } }) + .mcpServers.posts; + + expect(server.url).not.toContain('${'); + expect(placeholders.get(server.url as string)).toBe('${MASTRA_INTERNAL_URL}'); + expect([...placeholders.values()]).toContain('Bearer ${MASTRA_API_KEY}'); + }); + + it('leaves placeholder-free strings untouched and records nothing', () => { + const config: t.ConfigValue = { version: '1.2.3', cache: true, count: 5 }; + + const { sanitized, placeholders } = sanitizeEnvPlaceholders(config); + + expect(sanitized).toEqual(config); + expect(placeholders.size).toBe(0); + }); + + it('handles placeholders embedded inside a larger string', () => { + const { sanitized, placeholders } = sanitizeEnvPlaceholders('${BASE_URL}/api/sse'); + + expect(sanitized).not.toContain('${'); + expect(placeholders.get(sanitized as string)).toBe('${BASE_URL}/api/sse'); + }); +}); + +describe('restoreEnvPlaceholders', () => { + it('round-trips a config back to its original placeholders', () => { + const config: t.ConfigValue = { + endpoints: ['${A}', 'static', '${B}/path'], + nested: { key: '${SECRET}', flag: false }, + }; + + const { sanitized, placeholders } = sanitizeEnvPlaceholders(config); + const restored = restoreEnvPlaceholders(sanitized, placeholders); + + expect(sanitized).not.toEqual(config); + expect(restored).toEqual(config); + }); + + it('returns the value unchanged when there are no placeholders', () => { + const value: t.ConfigValue = { a: 1, b: ['x'] }; + + expect(restoreEnvPlaceholders(value, new Map())).toEqual(value); + }); +}); + +describe('integration with the real LibreChat configSchema', () => { + const placeholderConfig: t.ConfigValue = { + version: '1.0.0', + mcpServers: { + posts: { + type: 'sse', + url: '${MASTRA_INTERNAL_URL}', + headers: { Authorization: 'Bearer ${MASTRA_API_KEY}' }, + timeout: 30000, + }, + }, + }; + + it('cannot validate the raw placeholder url because env resolution leaves it literal', () => { + expect(() => configSchema.safeParse(placeholderConfig)).toThrow(/MASTRA_INTERNAL_URL/); + }); + + it('accepts the sanitized config and restores the placeholder after validation', () => { + const { sanitized, placeholders } = sanitizeEnvPlaceholders(placeholderConfig); + const result = configSchema.safeParse(sanitized); + + expect(result.success).toBe(true); + if (!result.success) return; + + const restored = restoreEnvPlaceholders(result.data as t.ConfigValue, placeholders); + const server = (restored as { mcpServers: { posts: Record } }) + .mcpServers.posts; + + expect(server.url).toBe('${MASTRA_INTERNAL_URL}'); + expect((server.headers as Record).Authorization).toBe( + 'Bearer ${MASTRA_API_KEY}', + ); + }); +}); diff --git a/src/server/utils/envPlaceholder.ts b/src/server/utils/envPlaceholder.ts new file mode 100644 index 0000000..02b8e8d --- /dev/null +++ b/src/server/utils/envPlaceholder.ts @@ -0,0 +1,77 @@ +import type * as t from '@/types'; + +/** + * LibreChat resolves `${ENV_VAR}` placeholders against `process.env` at config + * load time (via `extractEnvVariable`) before schema validation. The admin + * panel only manages the canonical config and does not — and should not — have + * every deployment's environment variables set, so those placeholders would + * otherwise fail format validators such as `z.string().url()`. + * + * To keep full structural validation while tolerating placeholders, every + * string containing a `${...}` placeholder is swapped for a unique, schema-safe + * sentinel (a valid URL, which also satisfies plain string constraints) before + * validation, then restored verbatim afterwards. + */ +const ENV_VAR_PATTERN = /\$\{[^}]+\}/; + +const SENTINEL_PREFIX = 'https://env-placeholder.invalid/'; + +export interface SanitizedConfig { + sanitized: t.ConfigValue; + placeholders: Map; +} + +function mapObject( + node: { [key: string]: t.ConfigValue }, + transform: (value: t.ConfigValue) => t.ConfigValue, +): { [key: string]: t.ConfigValue } { + const result: { [key: string]: t.ConfigValue } = {}; + for (const key of Object.keys(node)) { + result[key] = transform(node[key]); + } + return result; +} + +/** + * Replaces every string holding a `${ENV_VAR}` placeholder with a unique + * schema-safe sentinel, returning the sanitized config and the sentinel → original map. + */ +export function sanitizeEnvPlaceholders(value: t.ConfigValue): SanitizedConfig { + const placeholders = new Map(); + let counter = 0; + + const walk = (node: t.ConfigValue): t.ConfigValue => { + if (typeof node === 'string') { + if (!ENV_VAR_PATTERN.test(node)) return node; + const sentinel = `${SENTINEL_PREFIX}${counter++}`; + placeholders.set(sentinel, node); + return sentinel; + } + if (Array.isArray(node)) return node.map(walk); + if (node && typeof node === 'object') return mapObject(node, walk); + return node; + }; + + return { sanitized: walk(value), placeholders }; +} + +/** + * Restores original `${ENV_VAR}` placeholder strings by swapping each sentinel + * back to its source value. Matching is by exact equality, since the sentinels + * are not normalized by URL or string validators. + */ +export function restoreEnvPlaceholders( + value: t.ConfigValue, + placeholders: Map, +): t.ConfigValue { + if (placeholders.size === 0) return value; + + const walk = (node: t.ConfigValue): t.ConfigValue => { + if (typeof node === 'string') return placeholders.get(node) ?? node; + if (Array.isArray(node)) return node.map(walk); + if (node && typeof node === 'object') return mapObject(node, walk); + return node; + }; + + return walk(value); +}