= ({
return (
<>
- {text}
+ {/* The paragraph's auto direction ignores word boxes with their own dir. */}
+ {text}
{words.map((word, wordIndex) => (
-
+
{word.map((letter, letterIndex) => {
const index =
words.slice(0, wordIndex).reduce((acc, w) => acc + w.length, 0) + letterIndex;
diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts
index 2dd82eaa88a..6ef043dbe16 100644
--- a/packages/data-provider/src/config.spec.ts
+++ b/packages/data-provider/src/config.spec.ts
@@ -7,6 +7,7 @@ import {
bedrockModels,
configSchema,
codeEnvironmentUserConfigSchema,
+ CODE_ENVIRONMENT_ADMISSION_MAX_MS,
excludedKeys,
resolveEndpointType,
webSearchSchema,
@@ -583,6 +584,105 @@ describe('attached code environment user config schema', () => {
expect(codeEnvironmentUserConfigSchema.parse({ limits: {} })).toEqual({ limits: {} });
});
+ it.each([1_000, 15_000, CODE_ENVIRONMENT_ADMISSION_MAX_MS])(
+ 'accepts a bounded %i ms command admission allowance',
+ (minCommandAdmissionMs) => {
+ expect(codeEnvironmentUserConfigSchema.parse({ limits: { minCommandAdmissionMs } })).toEqual({
+ limits: { minCommandAdmissionMs },
+ });
+ },
+ );
+
+ it.each([0, -1, 0.5, 999, CODE_ENVIRONMENT_ADMISSION_MAX_MS + 1, NaN, Infinity])(
+ 'rejects an invalid command admission allowance of %s',
+ (minCommandAdmissionMs) => {
+ expect(
+ codeEnvironmentUserConfigSchema.safeParse({ limits: { minCommandAdmissionMs } }).success,
+ ).toBe(false);
+ },
+ );
+
+ it.each([20_001, 90_000])(
+ 'preserves omission of the command admission allowance with a fitting %i ms budget',
+ (maxRequestTimeoutMs) => {
+ expect(codeEnvironmentUserConfigSchema.parse({ limits: { maxRequestTimeoutMs } })).toEqual({
+ limits: { maxRequestTimeoutMs },
+ });
+ },
+ );
+
+ it.each([5_000, 10_002, 15_000, 20_000])(
+ 'rejects an undersized %i ms request budget with the default command reserve',
+ (maxRequestTimeoutMs) => {
+ const parsed = codeEnvironmentUserConfigSchema.safeParse({ limits: { maxRequestTimeoutMs } });
+ expect(parsed.success).toBe(false);
+ if (!parsed.success) {
+ expect(parsed.error.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ path: ['limits', 'maxRequestTimeoutMs'] }),
+ ]),
+ );
+ }
+ },
+ );
+
+ it.each([
+ { maxRequestTimeoutMs: 90_000, minCommandAdmissionMs: 79_999 },
+ { maxRequestTimeoutMs: 11_001, minCommandAdmissionMs: 1_000 },
+ { maxRequestTimeoutMs: 610_000, minCommandAdmissionMs: 300_000 },
+ ])('accepts an admission reserve with execution time left: %j', (limits) => {
+ expect(codeEnvironmentUserConfigSchema.parse({ limits })).toEqual({ limits });
+ });
+
+ it.each([
+ { maxRequestTimeoutMs: 90_000, minCommandAdmissionMs: 80_000 },
+ { maxRequestTimeoutMs: 90_000, minCommandAdmissionMs: 100_000 },
+ { maxRequestTimeoutMs: 11_000, minCommandAdmissionMs: 1_000 },
+ ])('rejects a command reserve that cannot fit inside its request budget: %j', (limits) => {
+ const parsed = codeEnvironmentUserConfigSchema.safeParse({ limits });
+ expect(parsed.success).toBe(false);
+ if (!parsed.success) {
+ expect(parsed.error.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ path: ['limits', 'minCommandAdmissionMs'] }),
+ ]),
+ );
+ }
+ });
+
+ it('allows a command reserve without a request budget (the legacy per-attempt path)', () => {
+ expect(
+ codeEnvironmentUserConfigSchema.parse({ limits: { minCommandAdmissionMs: 300_000 } }),
+ ).toEqual({ limits: { minCommandAdmissionMs: 300_000 } });
+ });
+
+ it.each([
+ { maxRequestTimeoutMs: 90_000, minCommandAdmissionMs: 100_000 },
+ { maxRequestTimeoutMs: 15_000 },
+ ])('rejects an impossible command reserve in the top-level deployment config: %j', (limits) => {
+ expect(
+ configSchema.safeParse({
+ version: '1.0',
+ endpoints: {
+ agents: {
+ statefulCodeSessions: {
+ allowedEnvironments: ['user'],
+ environments: [
+ {
+ id: 'personal-vm',
+ name: 'Personal VM',
+ type: 'attached',
+ baseURL: 'https://code.example.com/v1',
+ configSchema: { limits },
+ },
+ ],
+ },
+ },
+ },
+ }).success,
+ ).toBe(false);
+ });
+
it('accepts typed permission controls exposed by the administrator', () => {
const result = configSchema.safeParse({
version: '1.0',
@@ -602,7 +702,11 @@ describe('attached code environment user config schema', () => {
fileWrite: { allowed: ['allow', 'ask', 'deny'], default: 'ask' },
commandExecution: { allowed: ['ask', 'deny'], default: 'ask' },
},
- limits: { maxCommandTimeoutMs: 120000, maxRequestTimeoutMs: 125_000 },
+ limits: {
+ maxCommandTimeoutMs: 120000,
+ maxRequestTimeoutMs: 125_000,
+ minCommandAdmissionMs: 15_000,
+ },
},
},
],
@@ -621,7 +725,9 @@ describe('attached code environment user config schema', () => {
statefulCodeSessions: {
environments: [
{
- configSchema: { limits: { maxRequestTimeoutMs: 125_000 } },
+ configSchema: {
+ limits: { maxRequestTimeoutMs: 125_000, minCommandAdmissionMs: 15_000 },
+ },
},
],
},
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index bc84677a2fd..815ec69d87e 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -1207,9 +1207,15 @@ export const CODE_ENVIRONMENT_COMMAND_TIMEOUT_HARD_MAX_MS = 5 * 60_000;
export const CODE_ENVIRONMENT_QUEUE_WAIT_DEFAULT_MS = 5 * 60_000;
/** Code API's per-request admission ceiling, independent of the retry horizon. */
export const CODE_ENVIRONMENT_ADMISSION_MAX_MS = 5 * 60_000;
-/** Maximum opt-in HTTP budget: five minutes of admission and execution plus ten seconds for settlement and delivery. */
+/** Minimum command admission time reserved inside an opted-in HTTP budget. */
+export const CODE_ENVIRONMENT_COMMAND_ADMISSION_DEFAULT_MS = 10_000;
+/** Five seconds each for command settlement and transport delivery. */
+const CODE_ENVIRONMENT_COMMAND_BUDGET_GRACE_MS = 10_000;
+/** Maximum opt-in HTTP budget: five minutes of admission and execution plus settlement and delivery. */
export const CODE_ENVIRONMENT_REQUEST_TIMEOUT_HARD_MAX_MS =
- CODE_ENVIRONMENT_ADMISSION_MAX_MS + CODE_ENVIRONMENT_COMMAND_TIMEOUT_HARD_MAX_MS + 10_000;
+ CODE_ENVIRONMENT_ADMISSION_MAX_MS +
+ CODE_ENVIRONMENT_COMMAND_TIMEOUT_HARD_MAX_MS +
+ CODE_ENVIRONMENT_COMMAND_BUDGET_GRACE_MS;
/**
* Typed user-tunable surface for one attached code environment. Omitted fields
@@ -1256,8 +1262,33 @@ export const codeEnvironmentUserConfigSchema = z
.min(1)
.max(CODE_ENVIRONMENT_REQUEST_TIMEOUT_HARD_MAX_MS)
.optional(),
+ /** Admission allowance before local dispatch overhead for a Bash command inside
+ * maxRequestTimeoutMs. Omission reserves ten seconds; ignored without a total HTTP budget. */
+ minCommandAdmissionMs: z
+ .number()
+ .int()
+ .min(1_000)
+ .max(CODE_ENVIRONMENT_ADMISSION_MAX_MS)
+ .optional(),
})
.strict()
+ .superRefine((limits, context) => {
+ if (
+ limits.maxRequestTimeoutMs == null ||
+ limits.maxRequestTimeoutMs >
+ (limits.minCommandAdmissionMs ?? CODE_ENVIRONMENT_COMMAND_ADMISSION_DEFAULT_MS) +
+ CODE_ENVIRONMENT_COMMAND_BUDGET_GRACE_MS
+ ) {
+ return;
+ }
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: [
+ limits.minCommandAdmissionMs == null ? 'maxRequestTimeoutMs' : 'minCommandAdmissionMs',
+ ],
+ message: 'Command admission and settlement reserves must leave time for execution',
+ });
+ })
.optional(),
})
.strict();
diff --git a/packages/data-provider/src/runSteps.spec.ts b/packages/data-provider/src/runSteps.spec.ts
index 547e1f27e72..f21453fea93 100644
--- a/packages/data-provider/src/runSteps.spec.ts
+++ b/packages/data-provider/src/runSteps.spec.ts
@@ -1,9 +1,33 @@
import {
+ getRunStepClosedAt,
+ getRunStepCloseMetadata,
getRunStepDurationMs,
isReportableRunStepDuration,
MIN_REPORTABLE_RUN_STEP_DURATION_MS,
} from './runSteps';
+describe('getRunStepClosedAt', () => {
+ it('accepts a close time even if the emitter did not report when the step opened', () => {
+ expect(getRunStepClosedAt({ closed_at: 1_750_000_000_000 })).toBe(1_750_000_000_000);
+ });
+
+ it('only stamps validated close times on saved content', () => {
+ expect(getRunStepCloseMetadata({ closed_at: 1_750_000_000_000 })).toEqual({
+ runStepClosedAt: 1_750_000_000_000,
+ });
+ expect(getRunStepCloseMetadata({ closed_at: NaN })).toEqual({});
+ });
+
+ it('rejects absent, non-date, and clock-skewed stamps', () => {
+ expect(getRunStepClosedAt({})).toBeUndefined();
+ expect(getRunStepClosedAt({ closed_at: NaN })).toBeUndefined();
+ expect(getRunStepClosedAt({ closed_at: Infinity })).toBeUndefined();
+ expect(getRunStepClosedAt({ closed_at: 0 })).toBeUndefined();
+ expect(getRunStepClosedAt({ closed_at: 1e20 })).toBeUndefined();
+ expect(getRunStepClosedAt({ created_at: 2000, closed_at: 1000 })).toBeUndefined();
+ });
+});
+
describe('getRunStepDurationMs', () => {
it('returns the elapsed time between the two stamps', () => {
expect(getRunStepDurationMs({ created_at: 1000, closed_at: 4500 })).toBe(3500);
diff --git a/packages/data-provider/src/runSteps.ts b/packages/data-provider/src/runSteps.ts
index 445b14f2960..ff8233c1777 100644
--- a/packages/data-provider/src/runSteps.ts
+++ b/packages/data-provider/src/runSteps.ts
@@ -14,6 +14,26 @@ export interface RunStepTimestamps {
closed_at?: number;
}
+/** Host-reported close time; omit absent or unusable clocks instead of guessing. */
+export function getRunStepClosedAt(closed: RunStepTimestamps): number | undefined {
+ const { closed_at: closedAt, created_at: createdAt } = closed;
+ if (
+ typeof closedAt !== 'number' ||
+ !Number.isFinite(closedAt) ||
+ closedAt <= 0 ||
+ !Number.isFinite(new Date(closedAt).getTime()) ||
+ (typeof createdAt === 'number' && Number.isFinite(createdAt) && createdAt > closedAt)
+ ) {
+ return undefined;
+ }
+ return closedAt;
+}
+
+export function getRunStepCloseMetadata(closed: RunStepTimestamps): { runStepClosedAt?: number } {
+ const closedAt = getRunStepClosedAt(closed);
+ return closedAt == null ? {} : { runStepClosedAt: closedAt };
+}
+
/**
* Below this, a duration is noise rather than information: sub-second tool
* calls are the common case, and labelling every one of them `· 0.3s` adds a
diff --git a/packages/data-provider/src/types/content.ts b/packages/data-provider/src/types/content.ts
index bdeeac8c7e7..d751a15afd2 100644
--- a/packages/data-provider/src/types/content.ts
+++ b/packages/data-provider/src/types/content.ts
@@ -160,6 +160,8 @@ export type PartMetadata = {
* at render time.
*/
runStepDurationMs?: number;
+ /** Host-reported close time in epoch milliseconds, when valid. Absent on older content. */
+ runStepClosedAt?: number;
/**
* Stamped by the background harvester when a detached task's final output
* replaces the dispatch handle in `tool_call.output`. The handle JSON and
diff --git a/packages/data-schemas/src/app/resolution.spec.ts b/packages/data-schemas/src/app/resolution.spec.ts
index 594f87b6799..533ff4b12f3 100644
--- a/packages/data-schemas/src/app/resolution.spec.ts
+++ b/packages/data-schemas/src/app/resolution.spec.ts
@@ -1,7 +1,13 @@
import { INTERFACE_PERMISSION_FIELDS, PermissionTypes } from 'librechat-data-provider';
+import type { TCustomConfig } from 'librechat-data-provider';
import type { AppConfig, IConfig } from '~/types';
+import {
+ mergeConfigOverrides,
+ getConfigFieldIssues,
+ applyConfigTombstones,
+ getConfigOverrideIssues,
+} from './resolution';
import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities';
-import { mergeConfigOverrides } from './resolution';
function fakeConfig(
overrides: Record,
@@ -235,9 +241,12 @@ describe('mergeConfigOverrides', () => {
});
it('replaces plain arrays (no merge key) instead of concatenating', () => {
- const configs = [fakeConfig({ endpoints: ['anthropic', 'google'] }, 10)];
- const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record;
- expect(result.endpoints).toEqual(['anthropic', 'google']);
+ const base = { registration: { allowedDomains: ['base.com'] } } as unknown as AppConfig;
+ const configs = [fakeConfig({ registration: { allowedDomains: ['a.com', 'b.com'] } }, 10)];
+ const result = mergeConfigOverrides(base, configs) as unknown as {
+ registration: { allowedDomains: string[] };
+ };
+ expect(result.registration.allowedDomains).toEqual(['a.com', 'b.com']);
});
it('merges endpoints.custom arrays by name instead of replacing', () => {
@@ -422,11 +431,11 @@ describe('mergeConfigOverrides', () => {
expect(baseConfig).toEqual(original);
});
- it('handles null override values', () => {
+ it('keeps the base value under a null override the schema does not allow', () => {
const configs = [fakeConfig({ interface: { modelSelect: null } }, 10)];
const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record;
const iface = result.interfaceConfig as Record;
- expect(iface.modelSelect).toBeNull();
+ expect(iface.modelSelect).toBe(true);
});
it('skips configs with no overrides object', () => {
@@ -880,6 +889,219 @@ describe('mergeConfigOverrides', () => {
});
});
+describe('mergeConfigOverrides: filtered MCP servers', () => {
+ it('does not let a filtered process-backed server shape a later partial', () => {
+ const merged = mergeConfigOverrides({} as AppConfig, [
+ fakeConfig({ mcpServers: { injected: { type: 'stdio', command: 'node', args: ['x'] } } }, 10),
+ fakeConfig({ mcpServers: { injected: { title: 'Injected' } } }, 20, undefined, 'other'),
+ ]) as unknown as { mcpConfig?: Record };
+
+ expect(merged.mcpConfig?.injected).toEqual({ title: 'Injected' });
+ });
+});
+
+describe('mergeConfigOverrides: invalid stored overrides', () => {
+ const base = {
+ interfaceConfig: { contextCost: true, customWelcome: 'base' },
+ registration: { oauthStateTtlMs: 600_000, allowedDomains: ['base.com'] },
+ endpoints: {
+ custom: [{ name: 'groq', baseURL: 'https://base', apiKey: 'k', models: { default: ['m'] } }],
+ },
+ } as unknown as AppConfig;
+
+ it('keeps the base value when a stored override field fails the schema', () => {
+ const merged = mergeConfigOverrides(base, [
+ fakeConfig(
+ {
+ interface: { contextCost: 'yes', customWelcome: 'override' },
+ registration: { oauthStateTtlMs: 5, allowedDomains: ['override.com'] },
+ },
+ 10,
+ ),
+ ]) as unknown as Record>;
+
+ expect(merged.interfaceConfig).toEqual({ contextCost: true, customWelcome: 'override' });
+ expect(merged.registration).toEqual({
+ oauthStateTtlMs: 600_000,
+ allowedDomains: ['override.com'],
+ });
+ });
+
+ it('drops only the invalid field of a merged array item', () => {
+ const merged = mergeConfigOverrides(base, [
+ fakeConfig(
+ { endpoints: { custom: [{ name: 'groq', baseURL: 'https://o', models: 5 }] } },
+ 10,
+ ),
+ ]) as unknown as { endpoints: { custom: Array> } };
+
+ expect(merged.endpoints.custom).toEqual([
+ { name: 'groq', baseURL: 'https://o', apiKey: 'k', models: { default: ['m'] } },
+ ]);
+ });
+
+ it('reports an earlier merged array item that repeats a merge key', () => {
+ const custom = [
+ { name: 'x', models: { default: ['m'] } },
+ { name: 'x', baseURL: 5 },
+ ];
+ expect(getConfigOverrideIssues({ endpoints: { custom } })).toEqual([
+ {
+ path: 'endpoints.custom.0',
+ segments: ['endpoints', 'custom', '0'],
+ code: 'duplicate_merge_key',
+ },
+ {
+ path: 'endpoints.custom.1.baseURL',
+ segments: ['endpoints', 'custom', '1', 'baseURL'],
+ code: 'invalid_type',
+ },
+ ]);
+
+ const merged = mergeConfigOverrides({} as AppConfig, [
+ fakeConfig({ endpoints: { custom } }, 10),
+ ]) as unknown as { endpoints: { custom: unknown[] } };
+ expect(merged.endpoints.custom).toEqual([{ name: 'x' }]);
+ });
+
+ it('drops a key the accepted union option does not define, keeping the rest', () => {
+ const merged = mergeConfigOverrides({} as AppConfig, [
+ fakeConfig(
+ { memory: { agent: { enabled: true, id: 5, provider: 'openAI', model: 'gpt-4o' } } },
+ 10,
+ ),
+ ]) as unknown as { memory: { agent: Record } };
+
+ expect(merged.memory.agent).toEqual({ enabled: true, provider: 'openAI', model: 'gpt-4o' });
+ });
+
+ it('drops a merged array item that is not an object', () => {
+ const merged = mergeConfigOverrides({} as AppConfig, [
+ fakeConfig({ endpoints: { custom: [null, 'bad', { name: 'kept', baseURL: 'x' }] } }, 10),
+ ]) as unknown as { endpoints: { custom: unknown[] } };
+
+ expect(merged.endpoints.custom).toEqual([{ name: 'kept', baseURL: 'x' }]);
+ });
+
+ it('drops a merged array item that has no merge key', () => {
+ const merged = mergeConfigOverrides({} as AppConfig, [
+ fakeConfig(
+ { endpoints: { custom: [{ baseURL: 'https://keyless' }, { name: 'kept', baseURL: 'x' }] } },
+ 10,
+ ),
+ ]) as unknown as { endpoints: { custom: Array> } };
+
+ expect(merged.endpoints.custom).toEqual([{ name: 'kept', baseURL: 'x' }]);
+ });
+
+ it('strips an invalid field under a record key that contains a dot', () => {
+ const merged = mergeConfigOverrides(
+ {
+ config: { mcpServers: { 'team.prod': { url: 'https://mcp', timeout: 1000 } } },
+ mcpConfig: { 'team.prod': { url: 'https://mcp', timeout: 1000 } },
+ } as unknown as AppConfig,
+ [fakeConfig({ mcpServers: { 'team.prod': { timeout: 'x', initTimeout: 500 } } }, 10)],
+ ) as unknown as { mcpConfig: Record> };
+
+ expect(merged.mcpConfig['team.prod']).toEqual({
+ url: 'https://mcp',
+ timeout: 1000,
+ initTimeout: 500,
+ });
+ });
+
+ it('ignores a stored overrides document that is not an object', () => {
+ const merged = mergeConfigOverrides(base, [
+ fakeConfig(['stray'] as unknown as Record, 10),
+ ]) as unknown as Record;
+
+ expect(merged).toEqual(base);
+ expect(merged).not.toHaveProperty('0');
+ });
+
+ it('drops a replaced-array item that fails a refinement, keeping its valid siblings', () => {
+ const merged = mergeConfigOverrides({} as AppConfig, [
+ fakeConfig(
+ {
+ messageFilter: {
+ pii: {
+ customPatterns: [
+ { id: 'bad', label: 'Bad', regex: '(unclosed' },
+ { id: 'ok', label: 'Ok', regex: 'x+' },
+ ],
+ },
+ },
+ },
+ 10,
+ ),
+ ]) as unknown as { messageFilter: { pii: { customPatterns: Array<{ id: string }> } } };
+
+ expect(merged.messageFilter.pii.customPatterns.map((pattern) => pattern.id)).toEqual(['ok']);
+ });
+
+ it('drops a replaced array item left incomplete by a repair and keeps other sections', () => {
+ const merged = mergeConfigOverrides(baseConfig, [
+ fakeConfig(
+ {
+ interface: { customWelcome: 'kept' },
+ endpoints: {
+ azureOpenAI: {
+ groups: [{ group: 'g', apiKey: 'k', instanceName: 'i', version: 'v', models: 5 }],
+ },
+ },
+ },
+ 10,
+ ),
+ ]) as unknown as {
+ interfaceConfig: Record;
+ endpoints: unknown;
+ };
+
+ expect(merged.interfaceConfig.customWelcome).toBe('kept');
+ expect(merged.endpoints).toEqual(baseConfig.endpoints);
+ });
+
+ it('keeps a lower layer that relies on a higher layer for a required field', () => {
+ const merged = mergeConfigOverrides({} as AppConfig, [
+ fakeConfig(
+ {
+ cloudfront: {
+ imageSigning: 'cookies',
+ cookieDomain: '.example.com',
+ requireSignedAccess: true,
+ },
+ },
+ 10,
+ ),
+ fakeConfig({ cloudfront: { domain: 'https://cdn.example.com' } }, 20, undefined, 'other'),
+ ]) as unknown as { cloudfront: Record };
+
+ expect(merged.cloudfront).toEqual({
+ imageSigning: 'cookies',
+ cookieDomain: '.example.com',
+ requireSignedAccess: true,
+ domain: 'https://cdn.example.com',
+ });
+ });
+
+ it('lets a lower-priority valid override survive a higher-priority invalid one', () => {
+ const merged = mergeConfigOverrides(base, [
+ fakeConfig({ interface: { contextCost: false } }, 10),
+ fakeConfig({ interface: { contextCost: 'no' } }, 20, undefined, 'other'),
+ ]) as unknown as { interfaceConfig: Record };
+
+ expect(merged.interfaceConfig.contextCost).toBe(false);
+ });
+
+ it('leaves fields the schema does not define untouched', () => {
+ const merged = mergeConfigOverrides(base, [
+ fakeConfig({ registration: { enabled: false } }, 10),
+ ]) as unknown as { registration: Record };
+
+ expect(merged.registration.enabled).toBe(false);
+ });
+});
+
describe('INTERFACE_PERMISSION_FIELDS', () => {
it('contains all expected permission fields', () => {
const expected = [
@@ -914,3 +1136,367 @@ describe('INTERFACE_PERMISSION_FIELDS', () => {
}
});
});
+
+describe('getConfigOverrideIssues', () => {
+ const paths = (issues: Array<{ path: string }>) => issues.map((issue) => issue.path);
+
+ it('accepts a partial section whose supplied fields are valid', () => {
+ expect(
+ getConfigOverrideIssues({
+ registration: { allowedDomains: ['a.com'] },
+ interface: { schedules: { maxPerUser: 2 } },
+ mcpServers: { github: { timeout: 5000 } },
+ }),
+ ).toEqual([]);
+ });
+
+ it('reports each invalid supplied field by its dot-path', () => {
+ expect(
+ paths(
+ getConfigOverrideIssues({
+ registration: { oauthStateTtlMs: 5 },
+ balance: { enabled: 'yes' },
+ interface: { contextCost: null },
+ }),
+ ),
+ ).toEqual(['registration.oauthStateTtlMs', 'balance.enabled', 'interface.contextCost']);
+ });
+
+ it('judges a union by the branch the value was written for', () => {
+ expect(paths(getConfigOverrideIssues({ memory: { agent: { id: 5 } } }))).toEqual([
+ 'memory.agent.id',
+ ]);
+ expect(
+ paths(getConfigOverrideIssues({ memory: { agent: { id: 5, provider: 'openAI' } } })),
+ ).toEqual(['memory.agent.id']);
+ expect(getConfigOverrideIssues({ memory: { agent: { id: 'agent_1' } } })).toEqual([]);
+ expect(
+ paths(getConfigOverrideIssues({ interface: { schedules: { maxPerUser: 'x' } } })),
+ ).toEqual(['interface.schedules.maxPerUser']);
+ });
+
+ it('applies refinements to the values an override supplies', () => {
+ expect(
+ paths(
+ getConfigOverrideIssues({
+ messageFilter: {
+ pii: { customPatterns: [{ id: 'p1', label: 'Bad', regex: '(unclosed' }] },
+ },
+ }),
+ ),
+ ).toEqual(['messageFilter.pii.customPatterns.0.regex']);
+ expect(
+ paths(
+ getConfigOverrideIssues({
+ cloudfront: {
+ domain: 'https://cdn.example.com',
+ imageSigning: 'none',
+ requireSignedAccess: true,
+ },
+ }),
+ ),
+ ).toEqual(['cloudfront.requireSignedAccess']);
+ });
+
+ it('leaves a write that relies on the base for related or required fields to the merge', () => {
+ const base = {
+ cloudfront: {
+ domain: 'https://cdn.example.com',
+ imageSigning: 'cookies',
+ cookieDomain: '.example.com',
+ },
+ } as Partial;
+ expect(getConfigOverrideIssues({ cloudfront: { requireSignedAccess: true } }, base)).toEqual(
+ [],
+ );
+ expect(getConfigOverrideIssues({ endpoints: { azureOpenAI: { assistants: true } } })).toEqual(
+ [],
+ );
+ });
+
+ it('reports a key another union option defines when the accepted option drops it', () => {
+ const agent = { enabled: true, id: 5, provider: 'openAI', model: 'gpt-4o' };
+ expect(getConfigOverrideIssues({ memory: { agent } })).toEqual([
+ { path: 'memory.agent.id', segments: ['memory', 'agent', 'id'], code: 'union_dropped_key' },
+ ]);
+ expect(
+ getConfigOverrideIssues({ memory: { agent: { provider: 'openAI', model: 'gpt-4o' } } }),
+ ).toEqual([]);
+ expect(
+ getConfigOverrideIssues({ memory: { agent: { id: 'a', provider: 'openAI', unknown: 1 } } }),
+ ).toEqual([
+ {
+ path: 'memory.agent.provider',
+ segments: ['memory', 'agent', 'provider'],
+ code: 'union_dropped_key',
+ },
+ ]);
+ });
+
+ it('requires the merge key on custom endpoint items and maps merged items back by it', () => {
+ expect(getConfigOverrideIssues({ endpoints: { custom: [{ baseURL: 'https://a' }] } })).toEqual([
+ {
+ path: 'endpoints.custom.0',
+ segments: ['endpoints', 'custom', '0'],
+ code: 'missing_merge_key',
+ },
+ ]);
+ const base = {
+ endpoints: {
+ custom: [
+ { name: 'a', apiKey: 'k', baseURL: 'https://a', models: { default: ['m'] } },
+ { name: 'b', apiKey: 'k', baseURL: 'https://b', models: { default: ['m'] } },
+ ],
+ },
+ } as Partial;
+ expect(
+ paths(getConfigOverrideIssues({ endpoints: { custom: [{ name: 'b', models: 5 }] } }, base)),
+ ).toEqual(['endpoints.custom.0.models']);
+ });
+
+ it('keeps a record key that contains a dot as one segment', () => {
+ expect(
+ getConfigOverrideIssues({ mcpServers: { 'team.prod': { timeout: 'x' } } }, {
+ mcpServers: { 'team.prod': { url: 'https://mcp' } },
+ } as Partial),
+ ).toEqual([expect.objectContaining({ segments: ['mcpServers', 'team.prod', 'timeout'] })]);
+ });
+
+ it('accepts keys the schema does not define and stored secret shapes', () => {
+ expect(
+ getConfigOverrideIssues({ unknownSection: 5, registration: { enabled: false } }),
+ ).toEqual([]);
+ expect(
+ getConfigOverrideIssues({
+ endpoints: {
+ custom: [
+ {
+ name: 'x',
+ apiKey: 'v3:enc',
+ apiKeyPreview: 'sk-...',
+ baseURL: 'https://x',
+ models: { default: ['m'] },
+ },
+ ],
+ },
+ ocr: { apiKey: '' },
+ }),
+ ).toEqual([]);
+ });
+
+ it('rejects an overrides document that is not an object', () => {
+ expect(getConfigOverrideIssues(['stray'])).toEqual([
+ { path: '', segments: [], code: 'invalid_document' },
+ ]);
+ });
+});
+
+describe('getConfigOverrideIssues: items addressed by id', () => {
+ it('attributes a refinement that names an array item by its id to that item', () => {
+ const environment = (id: string, extra: Record = {}) => ({
+ id,
+ name: id,
+ type: 'managed',
+ baseURL: 'https://code.example.com',
+ ...extra,
+ });
+ const issues = getConfigOverrideIssues({
+ endpoints: {
+ agents: {
+ statefulCodeSessions: {
+ allowedEnvironments: ['user'],
+ environments: [
+ environment('worker-a'),
+ environment('worker-b', { pairing: { workerId: 'w1', tokenEnv: 'TOKEN' } }),
+ ],
+ },
+ },
+ },
+ });
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ path: 'endpoints.agents.statefulCodeSessions.environments.1.pairing',
+ code: 'custom',
+ }),
+ );
+ });
+});
+
+describe('applyConfigTombstones', () => {
+ it('removes tombstoned base paths except the ones a write clears', () => {
+ const base = {
+ cloudfront: { domain: 'https://cdn.example.com', imageSigning: 'cookies' },
+ } as Partial;
+ const tombstones = ['cloudfront.imageSigning'];
+ expect(applyConfigTombstones(base, tombstones)).toEqual({
+ cloudfront: { domain: 'https://cdn.example.com' },
+ });
+ expect(applyConfigTombstones(base, tombstones, new Set(tombstones))).toEqual(base);
+ expect(
+ getConfigOverrideIssues(
+ { cloudfront: { requireSignedAccess: true } },
+ applyConfigTombstones(base, tombstones),
+ ).map((issue) => issue.path),
+ ).toEqual(['cloudfront.requireSignedAccess']);
+ });
+});
+
+describe('getConfigFieldIssues', () => {
+ it('checks each written path the way the stored override would hold it', () => {
+ expect(getConfigFieldIssues({ 'registration.oauthStateTtlMs': 120_000 })).toEqual([]);
+ expect(
+ getConfigFieldIssues({ 'registration.oauthStateTtlMs': 5 }).map((issue) => issue.path),
+ ).toEqual(['registration.oauthStateTtlMs']);
+ });
+
+ it('applies a record refinement to a single written key', () => {
+ const base = {
+ endpoints: {
+ azureOpenAI: {
+ groups: [
+ {
+ group: 'g',
+ apiKey: 'k',
+ instanceName: 'i',
+ version: '2024-02-01',
+ models: { 'gpt-4o': { deploymentName: 'gpt-4o' } },
+ },
+ ],
+ },
+ },
+ } as Partial;
+ expect(
+ getConfigFieldIssues(
+ { 'endpoints.azureOpenAI.groups': base.endpoints?.azureOpenAI?.groups },
+ base,
+ ),
+ ).toEqual([]);
+ expect(
+ getConfigFieldIssues(
+ {
+ 'endpoints.azureOpenAI.groups': [
+ { ...base.endpoints?.azureOpenAI?.groups?.[0], addParams: { web_search: 'yes' } },
+ ],
+ },
+ base,
+ ).map((issue) => issue.path),
+ ).toEqual(['endpoints.azureOpenAI.groups.0.addParams.web_search']);
+ });
+
+ it('judges a write together with the fields the principal already overrides', () => {
+ const cloudfront = {
+ domain: 'https://cdn.example.com',
+ imageSigning: 'cookies',
+ cookieDomain: '.example.com',
+ };
+ expect(
+ getConfigFieldIssues(
+ { 'cloudfront.requireSignedAccess': true },
+ {},
+ {
+ overrides: { cloudfront },
+ },
+ ),
+ ).toEqual([]);
+ expect(
+ getConfigFieldIssues(
+ { 'cloudfront.requireSignedAccess': true },
+ {},
+ {
+ overrides: { cloudfront: { ...cloudfront, imageSigning: 'none' } },
+ },
+ ).map((issue) => issue.path),
+ ).toEqual(['cloudfront.requireSignedAccess']);
+ expect(
+ getConfigFieldIssues(
+ { 'interface.customWelcome': 'hi' },
+ {},
+ {
+ overrides: { interface: { contextCost: 'yes' } },
+ },
+ ),
+ ).toEqual([]);
+ });
+
+ it('reports a stored related field the write makes invalid', () => {
+ expect(
+ getConfigFieldIssues(
+ { 'cloudfront.imageSigning': 'none' },
+ {},
+ {
+ overrides: {
+ cloudfront: {
+ domain: 'https://cdn.example.com',
+ imageSigning: 'cookies',
+ cookieDomain: '.example.com',
+ requireSignedAccess: true,
+ },
+ },
+ },
+ ).map((issue) => issue.path),
+ ).toEqual(['cloudfront.requireSignedAccess']);
+ });
+
+ it('validates on a base with the remaining tombstones of the principal applied', () => {
+ const base = {
+ cloudfront: {
+ domain: 'https://cdn.example.com',
+ imageSigning: 'cookies',
+ cookieDomain: '.example.com',
+ },
+ } as Partial;
+ expect(getConfigFieldIssues({ 'cloudfront.requireSignedAccess': true }, base)).toEqual([]);
+ expect(
+ getConfigFieldIssues({ 'cloudfront.requireSignedAccess': true }, base, {
+ overrides: {},
+ tombstones: ['cloudfront.imageSigning'],
+ }).map((issue) => issue.path),
+ ).toEqual(['cloudfront.requireSignedAccess']);
+ expect(
+ getConfigFieldIssues({ 'cloudfront.imageSigning': 'cookies' }, base, {
+ overrides: { cloudfront: { requireSignedAccess: true } },
+ tombstones: ['cloudfront.imageSigning'],
+ }),
+ ).toEqual([]);
+ });
+
+ it('rejects a path past a field that holds a value', () => {
+ expect(
+ getConfigFieldIssues({ 'interface.contextCost.foo': true }).map((issue) => issue.path),
+ ).toEqual(['interface.contextCost']);
+ expect(getConfigFieldIssues({ 'registration.unknownField.foo': 1 })).toEqual([]);
+ });
+
+ it('rejects an indexed write into a merged-by-name array', () => {
+ expect(
+ getConfigFieldIssues({ 'endpoints.custom.0.models': { default: ['m'] } }).map(
+ (issue) => issue.path,
+ ),
+ ).toEqual(['endpoints.custom']);
+ });
+
+ it('rejects an indexed write into a stored merged-by-name array', () => {
+ const base = {
+ endpoints: {
+ custom: [{ name: 'a', apiKey: 'k', baseURL: 'https://a', models: { default: ['m'] } }],
+ },
+ } as unknown as Partial;
+ const stored = { overrides: { endpoints: { custom: [{ name: 'a', baseURL: 'https://o' }] } } };
+
+ expect(getConfigFieldIssues({ 'endpoints.custom.0.name': 'b' }, base, stored)).toEqual([
+ {
+ path: 'endpoints.custom',
+ segments: ['endpoints', 'custom'],
+ code: 'indexed_merge_key_write',
+ },
+ ]);
+ expect(
+ getConfigFieldIssues(
+ { 'endpoints.custom': [{ name: 'a', baseURL: 'https://p' }] },
+ base,
+ stored,
+ ),
+ ).toEqual([]);
+ });
+});
diff --git a/packages/data-schemas/src/app/resolution.ts b/packages/data-schemas/src/app/resolution.ts
index 26b7a5b5bb8..1bd85676a66 100644
--- a/packages/data-schemas/src/app/resolution.ts
+++ b/packages/data-schemas/src/app/resolution.ts
@@ -5,14 +5,18 @@ import {
RUNTIME_CONFIG_INTERFACE_FIELDS,
PERMISSION_SUB_KEYS,
isProcessMCPServerConfig,
+ configSchema,
} from 'librechat-data-provider';
import type { TCustomConfig } from 'librechat-data-provider';
import type { AppConfig, IConfig } from '~/types';
import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities';
+import { getTombstonePathsToClear } from '~/methods/config';
+import logger from '~/config/winston';
type AnyObject = { [key: string]: unknown };
const MAX_MERGE_DEPTH = 10;
+const MAX_STRIP_PASSES = 16;
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/** Filters are a fail-closed security boundary even during mixed-package rollouts. */
const BASE_ONLY_OVERRIDE_SECTIONS = new Set(['filters', ...BASE_ONLY_CONFIG_SECTIONS]);
@@ -238,6 +242,476 @@ function deepMerge(target: T, source: AnyObject, depth = 0,
return result as T;
}
+export type ConfigOverrideIssue = {
+ /** Dot-path of the override node the issue is attributed to, in YAML (`TCustomConfig`) keys. */
+ path: string;
+ /** The same location as keys, unambiguous when a record key itself contains a dot. */
+ segments: string[];
+ /**
+ * A stable, machine-readable reason: a zod issue code (`invalid_type`, `custom`, ...),
+ * `missing_merge_key`, `duplicate_merge_key`, `indexed_merge_key_write`, `union_dropped_key`, or `invalid_document`. Schema messages are not carried because
+ * they can echo the submitted values.
+ */
+ code: string;
+};
+
+type IssuePath = Array;
+
+function isPlainObject(value: unknown): value is AnyObject {
+ return value != null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function hasOwn(target: object, key: string): boolean {
+ return Object.prototype.hasOwnProperty.call(target, key);
+}
+
+function toIssue(segments: string[], code: string): ConfigOverrideIssue {
+ return { path: segments.join('.'), segments, code };
+}
+
+/**
+ * The override item an issue path segment names: by the merge key for merged-by-key arrays
+ * (the merged index differs from the override's; the last item with a key is the one merged), by index, or by `id` for refinements that
+ * address an item by its identifier.
+ */
+function findItemIndex(
+ node: unknown[],
+ key: string,
+ keyField: string | undefined,
+ mergedItem: unknown,
+): number {
+ if (keyField) {
+ for (let index = node.length - 1; index >= 0; index--) {
+ const item = node[index];
+ if (
+ isPlainObject(item) &&
+ isPlainObject(mergedItem) &&
+ item[keyField] === mergedItem[keyField]
+ ) {
+ return index;
+ }
+ }
+ return -1;
+ }
+ if (/^\d+$/.test(key)) {
+ return Number(key);
+ }
+ return node.findIndex((item) => isPlainObject(item) && item.id === key);
+}
+
+/**
+ * The override node an issue in the merged config belongs to: the deepest node the
+ * overrides supply on the issue's path. Items of merged-by-key arrays are matched by their
+ * key, since the merged index differs from the override's. `undefined` means the overrides
+ * did not supply anything on the path, so the issue is the base's own.
+ */
+function attributeIssue(
+ overrides: AnyObject,
+ merged: AnyObject,
+ issuePath: IssuePath,
+): string[] | undefined {
+ const segments: string[] = [];
+ let node: unknown = overrides;
+ let mergedNode: unknown = merged;
+ for (const part of issuePath) {
+ const key = String(part);
+ if (Array.isArray(node)) {
+ const arrayPath = segments.join('.');
+ const keyField = hasOwn(ARRAY_MERGE_KEYS, arrayPath)
+ ? ARRAY_MERGE_KEYS[arrayPath]
+ : undefined;
+ const mergedItem = Array.isArray(mergedNode) ? mergedNode[Number(key)] : undefined;
+ const index = findItemIndex(node, key, keyField, mergedItem);
+ if (keyField && index < 0) {
+ return undefined;
+ }
+ if (!Number.isInteger(index) || index < 0 || index >= node.length) {
+ break;
+ }
+ segments.push(String(index));
+ node = node[index];
+ mergedNode = Array.isArray(mergedNode)
+ ? mergedNode[keyField ? Number(key) : index]
+ : undefined;
+ continue;
+ }
+ if (!isPlainObject(node) || !hasOwn(node, key)) {
+ break;
+ }
+ segments.push(key);
+ node = node[key];
+ mergedNode = isPlainObject(mergedNode) ? mergedNode[key] : undefined;
+ }
+ return segments.length > 0 ? segments : undefined;
+}
+
+/**
+ * Whether the override node at `segments` is final: at or inside an array the merge replaces
+ * (any array not merged by key), so no other layer can supply what it leaves out.
+ */
+function isReplacedArrayNode(overrides: AnyObject, segments: string[]): boolean {
+ let node: unknown = overrides;
+ for (let index = 0; index < segments.length; index++) {
+ node = Array.isArray(node)
+ ? node[Number(segments[index])]
+ : (node as AnyObject)[segments[index]];
+ const path = segments.slice(0, index + 1).join('.');
+ if (Array.isArray(node) && !hasOwn(ARRAY_MERGE_KEYS, path)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Items of a merged-by-key array must be objects with their own key: the merge drops any
+ * other item (or keeps it as is when nothing lies beneath the array). A repeated key is
+ * reported on the earlier items: the last one is what the merge keeps.
+ */
+function getMergeKeyIssues(overrides: AnyObject): ConfigOverrideIssue[] {
+ return Object.entries(ARRAY_MERGE_KEYS).flatMap(([arrayPath, keyField]) => {
+ const segments = arrayPath.split('.');
+ let node: unknown = overrides;
+ for (const segment of segments) {
+ node = isPlainObject(node) && hasOwn(node, segment) ? node[segment] : undefined;
+ }
+ if (!Array.isArray(node)) {
+ return [];
+ }
+ const lastIndex = new Map();
+ node.forEach((item, index) => isPlainObject(item) && lastIndex.set(item[keyField], index));
+ return node.flatMap((item, index) => {
+ if (!isPlainObject(item) || typeof item[keyField] !== 'string' || item[keyField] === '') {
+ return [toIssue([...segments, String(index)], 'missing_merge_key')];
+ }
+ return lastIndex.get(item[keyField]) === index
+ ? []
+ : [toIssue([...segments, String(index)], 'duplicate_merge_key')];
+ });
+ });
+}
+
+type SchemaIssue = NonNullable<
+ ReturnType['error']
+>['issues'][number];
+
+/**
+ * A union reports one issue at its own path; the branch whose failures are fewest, then
+ * deepest, is the shape the value was written for, so its issues locate the actual fault.
+ */
+function expandUnionIssues(issues: SchemaIssue[], depth = 0): SchemaIssue[] {
+ return issues.flatMap((issue) => {
+ if (issue.code !== 'invalid_union' || depth >= MAX_MERGE_DEPTH) {
+ return [issue];
+ }
+ const branches = issue.unionErrors.map((error) => error.issues);
+ const closest = branches.reduce((best, branch) => {
+ if (!best || branch.length < best.length) {
+ return branch;
+ }
+ const depthOf = (list: SchemaIssue[]) => Math.max(0, ...list.map((i) => i.path.length));
+ return branch.length === best.length && depthOf(branch) > depthOf(best) ? branch : best;
+ }, undefined);
+ return closest && closest.length > 0 ? expandUnionIssues(closest, depth + 1) : [issue];
+ });
+}
+
+/** The parts of a zod schema the stripped-key walk reads, without depending on zod. */
+type SchemaNode = {
+ _def: {
+ typeName?: string;
+ innerType?: SchemaNode;
+ schema?: SchemaNode;
+ type?: SchemaNode;
+ valueType?: SchemaNode;
+ options?: SchemaNode[] | Map;
+ };
+ shape?: Record;
+ safeParse: (value: unknown) => { success: boolean };
+};
+
+/** The schema beneath optional, nullable, default and refinement wrappers. */
+function unwrapSchema(schema: SchemaNode): SchemaNode {
+ let node = schema;
+ for (let depth = 0; depth < MAX_MERGE_DEPTH; depth++) {
+ const inner = node._def.innerType ?? node._def.schema;
+ if (!inner) {
+ break;
+ }
+ node = inner;
+ }
+ return node;
+}
+
+/**
+ * Keys a union dropped: an object parses with the first union option that accepts it, and
+ * that option strips keys it does not define, so a key another option defines (and would
+ * type-check) is kept raw in the stored override but never validated. Keys no option defines
+ * are unknown keys and stay accepted.
+ */
+function findUnionDroppedKeys(
+ schema: SchemaNode,
+ value: unknown,
+ path: IssuePath,
+ depth = 0,
+): IssuePath[] {
+ if (depth >= MAX_MERGE_DEPTH) {
+ return [];
+ }
+ const node = unwrapSchema(schema);
+ const { typeName } = node._def;
+ if (typeName === 'ZodObject' && node.shape && isPlainObject(value)) {
+ const shape = node.shape;
+ return Object.keys(value)
+ .filter((key) => hasOwn(shape, key))
+ .flatMap((key) => findUnionDroppedKeys(shape[key], value[key], [...path, key], depth + 1));
+ }
+ if (typeName === 'ZodArray' && node._def.type && Array.isArray(value)) {
+ const item = node._def.type;
+ return value.flatMap((entry, index) =>
+ findUnionDroppedKeys(item, entry, [...path, index], depth + 1),
+ );
+ }
+ if (typeName === 'ZodRecord' && node._def.valueType && isPlainObject(value)) {
+ const entrySchema = node._def.valueType;
+ return Object.entries(value).flatMap(([key, entry]) =>
+ findUnionDroppedKeys(entrySchema, entry, [...path, key], depth + 1),
+ );
+ }
+ if (
+ (typeName !== 'ZodUnion' && typeName !== 'ZodDiscriminatedUnion') ||
+ !node._def.options ||
+ !isPlainObject(value)
+ ) {
+ return [];
+ }
+ const options = [...node._def.options.values()];
+ const accepted = options.find((option) => option.safeParse(value).success);
+ if (!accepted) {
+ return [];
+ }
+ const acceptedShape = unwrapSchema(accepted).shape;
+ const defined = new Set(
+ options.flatMap((option) => Object.keys(unwrapSchema(option).shape ?? {})),
+ );
+ const dropped = acceptedShape
+ ? Object.keys(value)
+ .filter((key) => !hasOwn(acceptedShape, key) && defined.has(key))
+ .map((key) => [...path, key])
+ : [];
+ return [...dropped, ...findUnionDroppedKeys(accepted, value, path, depth + 1)];
+}
+
+/**
+ * Checks config overrides the way they apply: merged over `base` (YAML-shaped), each
+ * section they touch parsed with its `configSchema` schema, and every failure attributed
+ * to the override node that caused it. Unions, refinements, required fields and defaults
+ * are therefore judged on the merged result, not on the patch alone. An issue caused only
+ * by leaving something out (a required or related field another layer may supply) is not
+ * reported, except inside an array the merge replaces, where nothing can supply it. Keys the schema does not define are accepted unchanged.
+ */
+export function getConfigOverrideIssues(
+ overrides: unknown,
+ base: Partial = {},
+): ConfigOverrideIssue[] {
+ if (!isPlainObject(overrides)) {
+ return [toIssue([], 'invalid_document')];
+ }
+ const merged = deepMerge(base as AnyObject, overrides);
+ const issues = getMergeKeyIssues(overrides);
+ const seen = new Set(issues.map((issue) => issue.path));
+ const shape = configSchema.shape;
+ for (const section of Object.keys(overrides)) {
+ if (!hasOwn(shape, section)) {
+ continue;
+ }
+ const schema = shape[section as keyof typeof shape] as unknown as SchemaNode;
+ const result = shape[section as keyof typeof shape].safeParse(merged[section]);
+ const dropped = findUnionDroppedKeys(schema, merged[section], []).map((path) => ({
+ code: 'union_dropped_key',
+ path,
+ }));
+ const schemaIssues = result.success ? [] : expandUnionIssues(result.error.issues);
+ for (const issue of [...dropped, ...schemaIssues]) {
+ if (issue.code === 'unrecognized_keys') {
+ continue;
+ }
+ const issuePath: IssuePath = [section, ...issue.path];
+ const segments = attributeIssue(overrides, merged, issuePath);
+ if (
+ !segments ||
+ (segments.length < issuePath.length && !isReplacedArrayNode(overrides, segments))
+ ) {
+ continue;
+ }
+ const path = segments.join('.');
+ if (seen.has(path)) {
+ continue;
+ }
+ seen.add(path);
+ issues.push(toIssue(segments, issue.code));
+ }
+ }
+ return issues;
+}
+
+/** Sets a dot-path the way a Mongo `$set` on `overrides.` does, without mutating. */
+function setPath(target: unknown, segments: string[], value: unknown): unknown {
+ if (segments.length === 0) {
+ return value;
+ }
+ const [segment, ...rest] = segments;
+ if (Array.isArray(target) && /^\d+$/.test(segment)) {
+ const next = [...target];
+ next[Number(segment)] = setPath(next[Number(segment)], rest, value);
+ return next;
+ }
+ const next: AnyObject = isPlainObject(target) ? { ...target } : {};
+ next[segment] = setPath(next[segment], rest, value);
+ return next;
+}
+
+function isRelatedPath(a: string[], b: string[]): boolean {
+ const length = Math.min(a.length, b.length);
+ return a.slice(0, length).every((segment, index) => segment === b[index]);
+}
+
+/**
+ * The base a principal's override lands on: `base` without the paths the principal
+ * tombstones, except those in `cleared` (tombstones the pending write removes).
+ */
+export function applyConfigTombstones(
+ base: Partial,
+ tombstones: unknown[] | undefined,
+ cleared: Set = new Set(),
+): Partial {
+ return (tombstones ?? [])
+ .filter((path): path is string => typeof path === 'string' && !cleared.has(path))
+ .reduce((current, path) => deletePath(current, path), base as AnyObject);
+}
+
+/**
+ * Checks dot-path field writes on top of the principal's stored config, building the
+ * result the way `patchConfigFields` stores it: a Mongo `$set` on each `overrides.`,
+ * clearing the tombstones those paths clear. The principal's remaining tombstones are
+ * applied to the base. An issue is reported when it touches a written path, or when the
+ * write introduced it elsewhere (a related field the write made invalid); issues the stored
+ * config already had are left to merge time so they do not block an unrelated write.
+ */
+export function getConfigFieldIssues(
+ fields: Record,
+ base: Partial = {},
+ stored?: { overrides?: unknown; tombstones?: unknown[] } | null,
+): ConfigOverrideIssue[] {
+ const written = Object.keys(fields).map((fieldPath) => fieldPath.split('.'));
+ const cleared = new Set(Object.keys(fields).flatMap(getTombstonePathsToClear));
+ const effectiveBase = applyConfigTombstones(base, stored?.tombstones, cleared);
+ const storedOverrides = isPlainObject(stored?.overrides) ? stored.overrides : {};
+ const candidate = Object.entries(fields).reduce(
+ (current, [fieldPath, value]) => setPath(current, fieldPath.split('.'), value),
+ storedOverrides,
+ );
+ /**
+ * An item of a merged-by-key array is identified by its key, not its position: an indexed
+ * write can rename the stored item, which the runtime merge then treats as a new one.
+ */
+ const indexed = Object.keys(ARRAY_MERGE_KEYS).flatMap((arrayPath) => {
+ const arraySegments = arrayPath.split('.');
+ return written.some(
+ (segments) =>
+ segments.length > arraySegments.length && isRelatedPath(segments, arraySegments),
+ )
+ ? [toIssue(arraySegments, 'indexed_merge_key_write')]
+ : [];
+ });
+ if (indexed.length > 0) {
+ return indexed;
+ }
+ const existing = new Set(
+ getConfigOverrideIssues(storedOverrides, effectiveBase).map((issue) => issue.path),
+ );
+ return getConfigOverrideIssues(candidate, effectiveBase).filter(
+ (issue) =>
+ !existing.has(issue.path) ||
+ written.some((segments) => isRelatedPath(issue.segments, segments)),
+ );
+}
+
+function omitPath(target: unknown, segments: string[]): unknown {
+ const [segment, ...rest] = segments;
+ if (Array.isArray(target)) {
+ const index = Number(segment);
+ if (!Number.isInteger(index) || index < 0 || index >= target.length) {
+ return target;
+ }
+ const next = [...target];
+ if (rest.length === 0) {
+ next.splice(index, 1);
+ } else {
+ next[index] = omitPath(next[index], rest);
+ }
+ return next;
+ }
+ if (!isPlainObject(target) || !hasOwn(target, segment)) {
+ return target;
+ }
+ const next = { ...target };
+ const child = rest.length === 0 ? undefined : omitPath(next[segment], rest);
+ /** A node its repairs emptied supplies nothing, so the value beneath it stays instead. */
+ const emptied = child != null && typeof child === 'object' && Object.keys(child).length === 0;
+ if (rest.length === 0 || emptied) {
+ delete next[segment];
+ } else {
+ next[segment] = child;
+ }
+ return next;
+}
+
+/**
+ * Drops the override nodes that make the merged config fail `configSchema`, so an invalid
+ * stored value (written before write-time validation, by an older server, or one that only
+ * fails once layered over other overrides) leaves the value beneath it in place. A layer
+ * is not judged on what it leaves out, since a higher-priority layer may supply it.
+ */
+function stripInvalidOverrides(config: IConfig, base: Partial): AnyObject {
+ const principal = `${config.principalType}/${config.principalId}`;
+ let stripped: unknown = config.overrides;
+ /**
+ * Removing a field can make a related field it supplies fail, so check again while
+ * removals make progress. If they stop, only the sections that still fail are dropped.
+ */
+ for (let pass = 0; pass < MAX_STRIP_PASSES; pass++) {
+ const issues = getConfigOverrideIssues(stripped, base);
+ if (issues.length === 0) {
+ return stripped as AnyObject;
+ }
+ if (issues.some((issue) => issue.segments.length === 0)) {
+ logger.warn(`[mergeConfigOverrides] Ignoring malformed overrides document for ${principal}`);
+ return {};
+ }
+ const before = stripped;
+ for (let index = issues.length - 1; index >= 0; index--) {
+ const { path, segments, code } = issues[index];
+ logger.warn(
+ `[mergeConfigOverrides] Ignoring invalid override "${path}" for ${principal} (${code})`,
+ );
+ stripped = omitPath(stripped, segments);
+ }
+ if (stripped === before) {
+ break;
+ }
+ }
+ const failing = new Set(
+ getConfigOverrideIssues(stripped, base).map((issue) => issue.segments[0]),
+ );
+ logger.warn(
+ `[mergeConfigOverrides] Ignoring still-invalid sections ${[...failing].join(', ')} for ${principal}`,
+ );
+ return Object.fromEntries(
+ Object.entries(stripped as AnyObject).filter(([section]) => !failing.has(section)),
+ );
+}
+
function filterMCPServerOverrides(value: unknown, current: unknown): AnyObject {
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
return {};
@@ -289,6 +763,8 @@ export function mergeConfigOverrides(baseConfig: AppConfig, configs: IConfig[]):
const sorted = [...configs].sort((a, b) => a.priority - b.priority);
let merged = { ...baseConfig };
+ /** The YAML-shaped config the next override lands on, for validating it in place. */
+ let raw: Partial = baseConfig.config ?? {};
for (const config of sorted) {
const isBasePrincipal = config.principalId?.toString() === BASE_CONFIG_PRINCIPAL_ID;
if (Array.isArray(config.tombstones)) {
@@ -299,25 +775,30 @@ export function mergeConfigOverrides(baseConfig: AppConfig, configs: IConfig[]):
(isBasePrincipal || !BASE_PRINCIPAL_OVERRIDE_SECTIONS.has(path.split('.')[0]))
) {
merged = deleteConfigPath(merged, remapOverridePath(path));
+ raw = deletePath(raw as AnyObject, path) as Partial;
}
}
}
if (config.overrides && typeof config.overrides === 'object') {
const remapped: AnyObject = {};
- for (const [key, value] of Object.entries(config.overrides)) {
+ const applied: AnyObject = {};
+ for (const [key, value] of Object.entries(stripInvalidOverrides(config, raw))) {
if (
BASE_ONLY_OVERRIDE_SECTIONS.has(key) ||
(!isBasePrincipal && BASE_PRINCIPAL_OVERRIDE_SECTIONS.has(key))
) {
continue;
}
+ applied[key] = value;
const mappedKey = OVERRIDE_KEY_MAP[key as keyof typeof OVERRIDE_KEY_MAP] ?? key;
if (mappedKey === 'mcpConfig') {
remapped[mappedKey] = filterMCPServerOverrides(
value,
(merged as unknown as AnyObject)[mappedKey],
);
+ /** A server the filter removed must not complete a later layer's partial of it. */
+ applied[key] = remapped[mappedKey];
} else if (
key === 'interface' &&
value != null &&
@@ -359,6 +840,7 @@ export function mergeConfigOverrides(baseConfig: AppConfig, configs: IConfig[]):
}
}
merged = deepMerge(merged, remapped);
+ raw = deepMerge(raw as AnyObject, applied) as Partial;
}
}
diff --git a/packages/data-schemas/src/methods/config.ts b/packages/data-schemas/src/methods/config.ts
index 16f94efd9ea..d3051506ab7 100644
--- a/packages/data-schemas/src/methods/config.ts
+++ b/packages/data-schemas/src/methods/config.ts
@@ -6,7 +6,8 @@ import type { IConfig } from '~/types';
import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities';
import { escapeRegExp } from '~/utils/string';
-function getTombstonePathsToClear(fieldPath: string): string[] {
+/** Tombstones a field write clears: the written path and its ancestors below the section. */
+export function getTombstonePathsToClear(fieldPath: string): string[] {
const parts = fieldPath.split('.');
if (parts.length <= 1) {
return [fieldPath];