From e296d6dce703f77431646f64adfb785e96465e82 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:25:10 -0700 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A7=A9=20fix:=20Forward-Compatible=20?= =?UTF-8?q?Enum=20Validation=20for=20Config=20Import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configs written for LibreChat releases newer than the bundled librechat-data-provider schema previously failed YAML import with Invalid enum value errors, for example agent capabilities such as subagents and skills before the schema caught up. Import parsing and field validation now treat unknown string enum values as forward compatible and preserve them verbatim, while structural and type errors, including non string enum values, still block. The import parser removes the unknown values so the schema parse can apply defaults and key stripping, then restores them at their original positions, falling back to the regular hard failure whenever safe restoration is not possible. Fixes #72 --- src/server/config.test.ts | 165 +++++++++++++++++++++++++++++ src/server/config.ts | 212 ++++++++++++++++++++++++++++---------- src/types/config.ts | 7 ++ 3 files changed, 330 insertions(+), 54 deletions(-) diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 931bc7a5..f14e44c1 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -12,6 +12,7 @@ import { extractSchemaTree, getZodTypeName, flattenTree, + parseYamlConfig, resolveSubSchema, validateFieldValue, parseIndexedArrayPath, @@ -688,6 +689,20 @@ describe('validateFieldValue', () => { const bad = validateFieldValue('mcpServers.foo.headers.Authorization', 42); expect(bad.success).toBe(false); }); + + it('accepts unknown string enum values for agent capabilities (forward compatibility)', () => { + const result = validateFieldValue('endpoints.agents.capabilities', [ + 'execute_code', + 'capability_from_newer_librechat', + 'web_search', + ]); + expect(result).toEqual({ success: true }); + }); + + it('rejects numeric values in enum arrays', () => { + const result = validateFieldValue('endpoints.agents.capabilities', ['execute_code', 123]); + expect(result.success).toBe(false); + }); }); /* --------------------------------------------------------------------------- @@ -1283,3 +1298,153 @@ describe('validateFieldValue for endpoints', () => { expect(result).toEqual({ success: true }); }); }); + +/* --------------------------------------------------------------------------- + * Regression — issue #72. Configs written for a LibreChat release newer than + * the bundled librechat-data-provider schema must still import: unknown string + * enum values (new agent capabilities, etc.) are preserved as-is instead of + * hard-failing, while structurally invalid configs keep failing. + * -----------------------------------------------------------------------*/ + +describe('parseYamlConfig', () => { + const issue72Capabilities = [ + 'execute_code', + 'file_search', + 'web_search', + 'artifacts', + 'subagents', + 'actions', + 'context', + 'skills', + 'tools', + 'chain', + 'ocr', + ]; + + const issue72Yaml = ` +version: 1.3.12 +endpoints: + bedrock: + disabled: false + agents: + disableBuilder: false + allowedProviders: + - "bedrock" + capabilities: +${issue72Capabilities.map((c) => ` - '${c}'`).join('\n')} +`; + + const yamlWithCapabilities = (capabilities: string[]) => ` +version: 1.3.12 +endpoints: + agents: + capabilities: +${capabilities.map((c) => ` - '${c}'`).join('\n')} +`; + + const capabilitiesOf = (appConfig: Record | null) => { + const endpoints = appConfig?.endpoints as Record | undefined; + const agents = endpoints?.agents as Record | undefined; + return agents?.capabilities; + }; + + it('imports the issue #72 config including subagents and skills capabilities', () => { + const result = parseYamlConfig(issue72Yaml); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(capabilitiesOf(result.appConfig)).toEqual(issue72Capabilities); + }); + + it('preserves enum values from LibreChat releases newer than the bundled schema', () => { + const result = parseYamlConfig( + yamlWithCapabilities(['execute_code', 'capability_from_newer_librechat', 'web_search']), + ); + expect(result.success).toBe(true); + expect(capabilitiesOf(result.appConfig)).toEqual([ + 'execute_code', + 'capability_from_newer_librechat', + 'web_search', + ]); + }); + + it('preserves multiple unknown enum values at their original positions', () => { + const result = parseYamlConfig( + yamlWithCapabilities(['future_first', 'tools', 'future_mid', 'ocr', 'future_last']), + ); + expect(result.success).toBe(true); + expect(capabilitiesOf(result.appConfig)).toEqual([ + 'future_first', + 'tools', + 'future_mid', + 'ocr', + 'future_last', + ]); + }); + + it('still parses the rest of the config when unknown enum values are preserved', () => { + const result = parseYamlConfig(` +version: 1.3.12 +cache: true +endpoints: + agents: + disableBuilder: false + capabilities: + - 'capability_from_newer_librechat' +`); + expect(result.success).toBe(true); + expect(result.appConfig?.version).toBe('1.3.12'); + expect(result.appConfig?.cache).toBe(true); + }); + + it('rejects structurally invalid configs', () => { + const result = parseYamlConfig(` +version: 1.3.12 +endpoints: + agents: + capabilities: 'not-an-array' +`); + expect(result.success).toBe(false); + expect(result.error).toBe('Config validation failed'); + expect(result.appConfig).toBeNull(); + }); + + it('rejects configs mixing unknown enum values with structural errors', () => { + const result = parseYamlConfig(` +version: 1.3.12 +endpoints: + agents: + disableBuilder: 'not-a-boolean' + capabilities: + - 'capability_from_newer_librechat' +`); + expect(result.success).toBe(false); + expect(result.error).toBe('Config validation failed'); + expect(result.validationErrors).toEqual([ + expect.objectContaining({ path: 'endpoints.agents.disableBuilder' }), + ]); + }); + + it('rejects numeric values in enum arrays', () => { + const result = parseYamlConfig(` +version: 1.3.12 +endpoints: + agents: + capabilities: + - 123 +`); + expect(result.success).toBe(false); + expect(result.error).toBe('Config validation failed'); + }); + + it('rejects invalid YAML syntax', () => { + const result = parseYamlConfig('version: [unclosed'); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid YAML syntax'); + }); + + it('rejects YAML that is not an object', () => { + const result = parseYamlConfig('just a string'); + expect(result.success).toBe(false); + expect(result.error).toBe('YAML did not produce a valid configuration object'); + }); +}); diff --git a/src/server/config.ts b/src/server/config.ts index 32635157..a5c3b23f 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -556,6 +556,14 @@ export function resolveSubSchema( return current; } +/** Enum mismatches where the received value is a string are forward-compatible: + * newer LibreChat releases add enum members (e.g. agent capabilities) that the + * bundled `librechat-data-provider` schema does not know about yet. Numeric or + * otherwise non-string values are genuine type errors and stay blocking. */ +function isUnknownEnumIssue(issue: t.ZodIssueLike): boolean { + return issue.code === 'invalid_enum_value' && typeof issue.received === 'string'; +} + export function validateFieldValue( fieldPath: string, value: unknown, @@ -574,12 +582,15 @@ export function validateFieldValue( subSchema as { safeParse: (v: unknown) => { success: boolean; - error?: { issues: Array<{ message: string; path: (string | number)[] }> }; + error?: { issues: t.ZodIssueLike[] }; }; } ).safeParse(value); if (!result.success && result.error) { - const messages = result.error.issues.map((i) => i.message); + const issues = result.error.issues ?? []; + const blocking = issues.filter((i) => !isUnknownEnumIssue(i)); + if (issues.length > 0 && blocking.length === 0) return { success: true }; + const messages = blocking.map((i) => i.message); return { success: false, error: messages.join('; ') || 'Validation failed' }; } } @@ -670,65 +681,158 @@ export const getConfigSchemaFields = createServerFn({ method: 'GET' }).handler(a } }); -export const parseImportedYaml = createServerFn({ method: 'POST' }) - .inputValidator(z.object({ yamlContent: z.string() })) - .handler(async ({ data }: { data: { yamlContent: string } }) => { - let rawConfig: unknown; - try { - rawConfig = yaml.load(data.yamlContent, { schema: yaml.JSON_SCHEMA }); - } catch (parseError) { - console.error('Failed to parse imported YAML content:', parseError); - return { - success: false, - error: 'Invalid YAML syntax. Please check the content for syntax errors.', - validationErrors: undefined, - appConfig: null, - }; - } +function getContainerAt( + root: Record, + path: (string | number)[], +): Record | unknown[] | null { + let current: unknown = root; + for (const segment of path) { + if (!current || typeof current !== 'object') return null; + current = (current as Record)[segment as string]; + } + if (!current || typeof current !== 'object') return null; + return current as Record | unknown[]; +} - if (!rawConfig || typeof rawConfig !== 'object') { - return { - success: false, - error: 'YAML did not produce a valid configuration object', - validationErrors: undefined, - appConfig: null, - }; +/** + * Removes the unknown enum values behind forward-compatible issues so the + * config re-parses cleanly (applying schema defaults, transforms, and + * unknown-key stripping), then splices the original values back into the + * parsed output at their original positions. Returns `null` when any value + * cannot be safely removed and restored, in which case the caller falls back + * to the regular hard validation failure — never a silently mutated config. + */ +function preserveUnknownEnumValues( + rawConfig: Record, + issues: t.ZodIssueLike[], +): Record | null { + const cleaned = structuredClone(rawConfig); + const arrayRemovals = new Map< + string, + { path: (string | number)[]; entries: Array<{ index: number; value: unknown }> } + >(); + const scalarRemovals: Array<{ path: (string | number)[]; value: unknown }> = []; + + for (const issue of issues) { + if (issue.path.length === 0) return null; + const parentPath = issue.path.slice(0, -1); + const lastSegment = issue.path[issue.path.length - 1]; + const container = getContainerAt(cleaned, parentPath); + if (!container) return null; + if (Array.isArray(container)) { + if (typeof lastSegment !== 'number') return null; + const key = parentPath.join('.'); + const removal = arrayRemovals.get(key) ?? { path: parentPath, entries: [] }; + removal.entries.push({ index: lastSegment, value: container[lastSegment] }); + arrayRemovals.set(key, removal); + } else { + scalarRemovals.push({ path: issue.path, value: container[lastSegment as string] }); + delete container[lastSegment as string]; } + } - const result = configSchema.safeParse(rawConfig); - - if (!result.success) { - return { - success: false, - error: 'Config validation failed', - validationErrors: result.error.errors.map( - (e: { path: (string | number)[]; message: string }) => ({ - path: e.path.join('.'), - message: e.message, - }), - ), - appConfig: null, - }; - } + for (const { path, entries } of arrayRemovals.values()) { + const arrayValue = getContainerAt(cleaned, path); + if (!Array.isArray(arrayValue)) return null; + entries.sort((a, b) => b.index - a.index); + for (const { index } of entries) arrayValue.splice(index, 1); + } + + const reparsed = configSchema.safeParse(cleaned); + if (!reparsed.success) return null; + const output = reparsed.data as Record; + + for (const { path, entries } of arrayRemovals.values()) { + const arrayValue = getContainerAt(output, path); + if (!Array.isArray(arrayValue)) return null; + entries.sort((a, b) => a.index - b.index); + for (const { index, value } of entries) arrayValue.splice(index, 0, value); + } + for (const { path, value } of scalarRemovals) { + const container = getContainerAt(output, path.slice(0, -1)); + if (!container || Array.isArray(container)) return null; + container[path[path.length - 1] as string] = value; + } + + return output as Record; +} - /** - * `librechat-data-provider` and `@librechat/data-schemas` both migrated - * to tsdown (upstream #13578, #13597) and now ship dual `.d.cts` + `.d.mts` - * declaration files. Under `moduleResolution: bundler`, TS treats - * `TCustomConfig` resolved through one declaration path as nominally - * distinct from `TCustomConfig` resolved through the other, even when - * structurally identical. That collision shows up here as "Two different - * types with this name exist, but they are unrelated" in the ServerFn - * registration. The consumer (ImportYamlDialog) treats appConfig as - * `Record`, so widening the return is the local fix. - */ +export function parseYamlConfig(yamlContent: string): { + success: boolean; + error: string | undefined; + validationErrors: Array<{ path: string; message: string }> | undefined; + appConfig: Record | null; +} { + let rawConfig: unknown; + try { + rawConfig = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }); + } catch (parseError) { + console.error('Failed to parse imported YAML content:', parseError); return { - success: true, - error: undefined, + success: false, + error: 'Invalid YAML syntax. Please check the content for syntax errors.', validationErrors: undefined, - appConfig: result.data as Record, + appConfig: null, }; - }); + } + + if (!rawConfig || typeof rawConfig !== 'object') { + return { + success: false, + error: 'YAML did not produce a valid configuration object', + validationErrors: undefined, + appConfig: null, + }; + } + + const result = configSchema.safeParse(rawConfig); + + if (!result.success) { + const issues = result.error.errors as t.ZodIssueLike[]; + const blocking = issues.filter((i) => !isUnknownEnumIssue(i)); + + if (blocking.length === 0) { + const appConfig = preserveUnknownEnumValues(rawConfig as Record, issues); + if (appConfig) { + return { success: true, error: undefined, validationErrors: undefined, appConfig }; + } + } + + return { + success: false, + error: 'Config validation failed', + validationErrors: (blocking.length > 0 ? blocking : issues).map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + appConfig: null, + }; + } + + /** + * `librechat-data-provider` and `@librechat/data-schemas` both migrated + * to tsdown (upstream #13578, #13597) and now ship dual `.d.cts` + `.d.mts` + * declaration files. Under `moduleResolution: bundler`, TS treats + * `TCustomConfig` resolved through one declaration path as nominally + * distinct from `TCustomConfig` resolved through the other, even when + * structurally identical. That collision shows up here as "Two different + * types with this name exist, but they are unrelated" in the ServerFn + * registration. The consumer (ImportYamlDialog) treats appConfig as + * `Record`, so widening the return is the local fix. + */ + return { + success: true, + error: undefined, + validationErrors: undefined, + appConfig: result.data as Record, + }; +} + +export const parseImportedYaml = createServerFn({ method: 'POST' }) + .inputValidator(z.object({ yamlContent: z.string() })) + .handler(async ({ data }: { data: { yamlContent: string } }) => + parseYamlConfig(data.yamlContent), + ); function getFieldDefault(schema: t.ZodSchemaLike): { hasDefault: boolean; value: unknown } { let current = schema; diff --git a/src/types/config.ts b/src/types/config.ts index 52e40f59..1897cb69 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -62,6 +62,13 @@ export interface ZodSchemaLike { shape?: Record; } +export interface ZodIssueLike { + code?: string; + received?: unknown; + message: string; + path: (string | number)[]; +} + export interface FieldValidationError { fieldPath: string; error: string; From dce28a113e3b74492a14b999a11699a5fd257903 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:31:58 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=94=94=20feat:=20Surface=20Preserved?= =?UTF-8?q?=20Enum=20Values=20on=20Import=20and=20in=20the=20Editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import result now carries the list of enum values that were preserved for forward compatibility, and ImportYamlDialog shows a non-blocking notice naming them before the config is applied. This restores the typo visibility that strict validation used to provide. ListField now renders a current value that is missing from the known options as an additional select option, so a preserved value from a newer LibreChat release displays correctly instead of the browser showing the first option while state holds the real value. --- .../configuration/ImportYamlDialog.test.tsx | 129 ++++++++++++++++++ .../configuration/ImportYamlDialog.tsx | 15 ++ .../configuration/fields/ListField.test.tsx | 111 +++++++++++++++ .../configuration/fields/ListField.tsx | 2 + src/locales/en/translation.json | 1 + src/server/config.test.ts | 15 ++ src/server/config.ts | 18 ++- src/types/config-ui.ts | 5 + 8 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 src/components/configuration/ImportYamlDialog.test.tsx create mode 100644 src/components/configuration/fields/ListField.test.tsx diff --git a/src/components/configuration/ImportYamlDialog.test.tsx b/src/components/configuration/ImportYamlDialog.test.tsx new file mode 100644 index 00000000..ad64f0df --- /dev/null +++ b/src/components/configuration/ImportYamlDialog.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { parseImportedYaml } from '@/server'; +import { ImportYamlDialog } from './ImportYamlDialog'; + +vi.mock('@/hooks/useLocalize', () => { + const localize = (key: string, options?: Record) => + options ? `${key} ${Object.values(options).join(' ')}` : key; + return { default: () => localize, useLocalize: () => localize }; +}); + +vi.mock('@/server', () => ({ + parseImportedYaml: vi.fn(), + createRoleFn: vi.fn(), + createGroupFn: vi.fn(), + availableScopesOptions: { + queryKey: ['availableScopes'], + queryFn: async () => [], + }, +})); + +interface MockChildrenProps { + children?: React.ReactNode; +} +interface MockDialogProps extends MockChildrenProps { + open?: boolean; +} +interface MockDialogContentProps extends MockChildrenProps { + title?: string; +} +interface MockButtonProps { + label: string; + onClick?: () => void; + disabled?: boolean; +} +interface MockAlertProps { + text: string; +} + +vi.mock('@clickhouse/click-ui', () => { + const Dialog = ({ open, children }: MockDialogProps) => (open ?
{children}
: null); + Dialog.Content = ({ title, children }: MockDialogContentProps) => ( +
+ {title} + {children} +
+ ); + const Tabs = ({ children }: MockChildrenProps) =>
{children}
; + Tabs.TriggersList = ({ children }: MockChildrenProps) =>
{children}
; + Tabs.Trigger = ({ children }: MockChildrenProps) => ; + Tabs.Content = ({ children }: MockChildrenProps) =>
{children}
; + return { + Dialog, + Tabs, + Icon: ({ name }: { name: string }) => {name}, + Alert: ({ text }: MockAlertProps) =>
{text}
, + Button: ({ label, onClick, disabled }: MockButtonProps) => ( + + ), + }; +}); + +const parseImportedYamlMock = vi.mocked(parseImportedYaml); + +function renderDialog() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +async function validateYaml() { + fireEvent.change(screen.getByLabelText('com_config_import_paste'), { + target: { value: 'version: 1.3.12' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'com_config_import_validate' })); + await screen.findByRole('radiogroup'); +} + +describe('ImportYamlDialog preserved values notice', () => { + beforeEach(() => { + parseImportedYamlMock.mockReset(); + }); + + it('shows a non-blocking notice listing values the panel does not recognize', async () => { + parseImportedYamlMock.mockResolvedValue({ + success: true, + error: undefined, + validationErrors: undefined, + preservedValues: [ + { path: 'endpoints.agents.capabilities.4', value: 'subagents' }, + { path: 'endpoints.agents.capabilities.7', value: 'skills' }, + ], + appConfig: { version: '1.3.12' }, + }); + + renderDialog(); + await validateYaml(); + + const notice = screen.getByRole('status'); + expect(notice).toHaveTextContent('com_config_import_preserved_notice 2 subagents, skills'); + expect(screen.getByRole('button', { name: 'com_config_import_apply' })).not.toBeDisabled(); + }); + + it('shows no notice when every value is recognized', async () => { + parseImportedYamlMock.mockResolvedValue({ + success: true, + error: undefined, + validationErrors: undefined, + preservedValues: undefined, + appConfig: { version: '1.3.12' }, + }); + + renderDialog(); + await validateYaml(); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/configuration/ImportYamlDialog.tsx b/src/components/configuration/ImportYamlDialog.tsx index 0cdd5ae9..41f81e22 100644 --- a/src/components/configuration/ImportYamlDialog.tsx +++ b/src/components/configuration/ImportYamlDialog.tsx @@ -5,6 +5,7 @@ import { Icon, Button, Dialog, Tabs } from '@clickhouse/click-ui'; import type * as t from '@/types'; import { availableScopesOptions, createGroupFn, createRoleFn, parseImportedYaml } from '@/server'; import { getScopeTypeConfig } from '@/constants'; +import { InfoBanner } from './InfoBanner'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; @@ -24,6 +25,7 @@ export function ImportYamlDialog({ const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [validationErrors, setValidationErrors] = useState(); + const [preservedValues, setPreservedValues] = useState([]); const [step, setStep] = useState('input'); const [parsedConfig, setParsedConfig] = useState | null>(null); @@ -52,6 +54,7 @@ export function ImportYamlDialog({ setLoading(false); setError(undefined); setValidationErrors(undefined); + setPreservedValues([]); setStep('input'); setParsedConfig(null); setTargetMode('base'); @@ -117,6 +120,7 @@ export function ImportYamlDialog({ if (result.appConfig && typeof result.appConfig === 'object') { setParsedConfig(result.appConfig as Record); + setPreservedValues(result.preservedValues ?? []); setStep('target'); } } catch (err) { @@ -277,6 +281,17 @@ export function ImportYamlDialog({ {step === 'target' && (
+ {preservedValues.length > 0 && ( +
+ p.value))].join(', '), + })} + /> +
+ )}

{localize('com_config_import_target')}

diff --git a/src/components/configuration/fields/ListField.test.tsx b/src/components/configuration/fields/ListField.test.tsx new file mode 100644 index 00000000..6f791b86 --- /dev/null +++ b/src/components/configuration/fields/ListField.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ListField } from './ListField'; + +vi.mock('@/hooks/useLocalize', () => ({ + default: () => (key: string) => key, + useLocalize: () => (key: string) => key, +})); + +interface MockButtonProps { + label: string; + onClick?: () => void; + disabled?: boolean; +} +interface MockIconButtonProps { + onClick?: () => void; + 'aria-label'?: string; +} + +vi.mock('@clickhouse/click-ui', () => ({ + Button: ({ label, onClick, disabled }: MockButtonProps) => ( + + ), + IconButton: ({ onClick, 'aria-label': ariaLabel }: MockIconButtonProps) => ( +