From 19a94734d0f44d7cc2c23e94e8f91727201fb686 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 24 Jun 2026 16:56:47 -0400 Subject: [PATCH 1/7] fix: Preserve array config entries on save --- src/components/configuration/ConfigPage.tsx | 55 +++---------- src/components/configuration/utils.test.ts | 87 +++++++++++++++------ src/components/configuration/utils.ts | 68 ++++++++++++++++ src/server/scopes.ts | 86 ++++++++++++++++++-- 4 files changed, 223 insertions(+), 73 deletions(-) diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index b4319fb..24fb801 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -31,7 +31,7 @@ import { } from '@/utils'; import { useLocalize, useHighlightRef, useActiveSection, useCapabilities } from '@/hooks'; import { CONFIG_TABS, OTHER_TAB, SECTION_META, HIDDEN_SECTIONS } from './configMeta'; -import { mergeIndexedArrayEdits, partitionScopeResetPaths } from './utils'; +import { applyConfigEdit, mergeIndexedArrayEdits, partitionScopeResetPaths } from './utils'; import { validateMcpCrossField } from './sections/McpServersRenderer'; import { ScopeSelector, ScopeTriggerButton } from './ScopeSelector'; import { StickyActionBar } from '@/components/shared'; @@ -385,51 +385,14 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi return next; }); setEditedValues((prev) => { - const baseline = scopeBaseline[path]; - const match = - value === baseline || - (typeof value === 'object' && - typeof baseline === 'object' && - JSON.stringify(value) === JSON.stringify(baseline)); - /** A container-path undefined write must survive; baseline only stores leaves, so it would otherwise match `undefined === undefined` and get pruned. baselineContainerPaths catches the orphaned empty-object case where leaf-derived intermediates miss the entry. */ - const isContainerDelete = - value === undefined && - (baselineIntermediates.has(path) || baselineContainerPaths.has(path)); - /** When the user deleted a container entry and is now writing descendants under it (delete-then-recreate of an MCP server), the new leaf must persist even if it matches baseline so the post-DELETE recreate is not missing required fields, and the ancestor-undefined must outlive the descendant write so handleConfirmSave can DELETE the entry before PATCHing the new leaves. Walk ancestors directly instead of scanning every pending edit; rename/remove emit one onChange per leaf and the prior O(n)-per-call scan compounded to O(n*m) work per event. */ - const hasPendingAncestorDelete = (() => { - let lastDot = path.lastIndexOf('.'); - while (lastDot > 0) { - const ancestor = path.slice(0, lastDot); - if (ancestor in prev && prev[ancestor] === undefined) return true; - lastDot = ancestor.lastIndexOf('.'); - } - return false; - })(); - if (match && !isContainerDelete && !hasPendingAncestorDelete) { - const next = { ...prev }; - delete next[path]; - return next; - } - const next = { ...prev, [path]: value }; - if (Array.isArray(value)) { - const prefix = `${path}.`; - for (const k of Object.keys(next)) { - if (k.startsWith(prefix) && /\.\d+$/.test(k)) delete next[k]; - } - } - const indexMatch = /^(.+)\.\d+$/.exec(path); - if (indexMatch) delete next[indexMatch[1]]; - /** Two-way dedup: drop ancestors that the new leaf supersedes AND descendants that the new parent supersedes. An ancestor whose value is `undefined` expresses "delete this whole subtree" and must outlive subsequent descendant writes so DELETE-then-PATCH ordering at save time can fully replace the entry instead of leaking stale fields. */ - for (const existing of Object.keys(next)) { - if (existing === path) continue; - const newIsDescendant = path.startsWith(`${existing}.`); - const newIsAncestor = existing.startsWith(`${path}.`); - if (newIsDescendant && next[existing] === undefined) continue; - if (newIsDescendant || newIsAncestor) { - delete next[existing]; - } - } - return next; + return applyConfigEdit( + prev, + path, + value, + scopeBaseline, + baselineIntermediates, + baselineContainerPaths, + ); }); }, [scopeBaseline, baselineIntermediates, baselineContainerPaths], diff --git a/src/components/configuration/utils.test.ts b/src/components/configuration/utils.test.ts index 8ec0398..7921d2a 100644 --- a/src/components/configuration/utils.test.ts +++ b/src/components/configuration/utils.test.ts @@ -6,6 +6,7 @@ import { splitUnionTypes, partitionScopeResetPaths, mergeIndexedArrayEdits, + applyConfigEdit, } from './utils'; import { createField } from '@/test/fixtures'; @@ -270,10 +271,9 @@ describe('mergeIndexedArrayEdits', () => { }); it('merges into an existing parent without clobbering its keys', () => { - const merged = mergeIndexedArrayEdits( - { modelSpecs: { enforce: true, prioritize: false } }, - [['modelSpecs.list.0', { name: 'a' }]], - ); + const merged = mergeIndexedArrayEdits({ modelSpecs: { enforce: true, prioritize: false } }, [ + ['modelSpecs.list.0', { name: 'a' }], + ]); expect(merged.modelSpecs).toEqual({ enforce: true, prioritize: false, @@ -325,23 +325,68 @@ describe('mergeIndexedArrayEdits', () => { }); it('walks deep parent chains, creating each missing level', () => { - const merged = mergeIndexedArrayEdits({}, [ - ['endpoints.custom.deep.list.0', { name: 'x' }], - ]); + const merged = mergeIndexedArrayEdits({}, [['endpoints.custom.deep.list.0', { name: 'x' }]]); expect(merged).toEqual({ endpoints: { custom: { deep: { list: [{ name: 'x' }] } } }, }); }); }); +describe('applyConfigEdit', () => { + it('updates a pending whole-array edit when a newly-added entry is typed into', () => { + const prev = { + 'modelSpecs.list': [{}, { name: 'smart-assistant' }], + }; + const result = applyConfigEdit( + prev, + 'modelSpecs.list.0', + { name: 'TEST1' }, + {}, + new Set(), + new Set(), + ); + expect(result).toEqual({ + 'modelSpecs.list': [{ name: 'TEST1' }, { name: 'smart-assistant' }], + }); + expect(result).not.toHaveProperty('modelSpecs.list.0'); + }); + + it('keeps per-index edits when no parent array edit is pending', () => { + const result = applyConfigEdit( + {}, + 'modelSpecs.list.0', + { name: 'TEST1' }, + {}, + new Set(), + new Set(), + ); + expect(result).toEqual({ + 'modelSpecs.list.0': { name: 'TEST1' }, + }); + }); + + it('drops stale indexed edits when a whole-array edit is queued', () => { + const result = applyConfigEdit( + { 'modelSpecs.list.0': { name: 'old' } }, + 'modelSpecs.list', + [{ name: 'new' }], + {}, + new Set(), + new Set(), + ); + expect(result).toEqual({ + 'modelSpecs.list': [{ name: 'new' }], + }); + }); +}); + describe('partitionScopeResetPaths', () => { it('routes whole MCP entry resets to tombstones', () => { expect( - partitionScopeResetPaths([ - 'mcpServers.github', - 'mcpServers.github.url', - 'interface.modelSelect', - ], new Set(['github'])), + partitionScopeResetPaths( + ['mcpServers.github', 'mcpServers.github.url', 'interface.modelSelect'], + new Set(['github']), + ), ).toEqual({ resetPaths: ['mcpServers.github.url', 'interface.modelSelect'], tombstonePaths: ['mcpServers.github'], @@ -350,10 +395,10 @@ describe('partitionScopeResetPaths', () => { it('routes whole MCP entry resets to unsets when the entry is scope-local', () => { expect( - partitionScopeResetPaths([ - 'mcpServers.scopeOnly', - 'mcpServers.inherited', - ], new Set(['inherited'])), + partitionScopeResetPaths( + ['mcpServers.scopeOnly', 'mcpServers.inherited'], + new Set(['inherited']), + ), ).toEqual({ resetPaths: ['mcpServers.scopeOnly'], tombstonePaths: ['mcpServers.inherited'], @@ -362,12 +407,10 @@ describe('partitionScopeResetPaths', () => { it('preserves input order within reset and tombstone groups', () => { expect( - partitionScopeResetPaths([ - 'mcpServers.alpha', - 'registration.enabled', - 'mcpServers.beta', - 'endpoints.custom.0', - ], new Set(['alpha', 'beta'])), + partitionScopeResetPaths( + ['mcpServers.alpha', 'registration.enabled', 'mcpServers.beta', 'endpoints.custom.0'], + new Set(['alpha', 'beta']), + ), ).toEqual({ resetPaths: ['registration.enabled', 'endpoints.custom.0'], tombstonePaths: ['mcpServers.alpha', 'mcpServers.beta'], diff --git a/src/components/configuration/utils.ts b/src/components/configuration/utils.ts index a9ca17d..729159b 100644 --- a/src/components/configuration/utils.ts +++ b/src/components/configuration/utils.ts @@ -1,5 +1,7 @@ import type * as t from '@/types'; +const INDEXED_ARRAY_PATH_RE = /^(.+)\.(\d+)$/; + export function inferKVType(v: t.ConfigValue): t.KVValueType { if (typeof v === 'boolean') return 'boolean'; if (typeof v === 'number') return 'number'; @@ -249,6 +251,72 @@ export function partitionScopeResetPaths( return { resetPaths, tombstonePaths }; } +export function applyConfigEdit( + prev: t.FlatConfigMap, + path: string, + value: t.ConfigValue, + baseline: t.FlatConfigMap, + baselineIntermediates: Set, + baselineContainerPaths: Set, +): t.FlatConfigMap { + const indexMatch = INDEXED_ARRAY_PATH_RE.exec(path); + if (indexMatch) { + const [, arrayPath, indexStr] = indexMatch; + const pendingArray = prev[arrayPath]; + if (Array.isArray(pendingArray)) { + const next = { ...prev }; + const arr = [...pendingArray]; + arr[Number(indexStr)] = value; + next[arrayPath] = arr; + for (const existing of Object.keys(next)) { + if (existing.startsWith(`${arrayPath}.`)) delete next[existing]; + } + return next; + } + } + + const baselineValue = baseline[path]; + const match = + value === baselineValue || + (typeof value === 'object' && + typeof baselineValue === 'object' && + JSON.stringify(value) === JSON.stringify(baselineValue)); + const isContainerDelete = + value === undefined && (baselineIntermediates.has(path) || baselineContainerPaths.has(path)); + const hasPendingAncestorDelete = (() => { + let lastDot = path.lastIndexOf('.'); + while (lastDot > 0) { + const ancestor = path.slice(0, lastDot); + if (ancestor in prev && prev[ancestor] === undefined) return true; + lastDot = ancestor.lastIndexOf('.'); + } + return false; + })(); + if (match && !isContainerDelete && !hasPendingAncestorDelete) { + const next = { ...prev }; + delete next[path]; + return next; + } + const next = { ...prev, [path]: value }; + if (Array.isArray(value)) { + const prefix = `${path}.`; + for (const k of Object.keys(next)) { + if (k.startsWith(prefix) && INDEXED_ARRAY_PATH_RE.test(k)) delete next[k]; + } + } + if (indexMatch) delete next[indexMatch[1]]; + for (const existing of Object.keys(next)) { + if (existing === path) continue; + const newIsDescendant = path.startsWith(`${existing}.`); + const newIsAncestor = existing.startsWith(`${path}.`); + if (newIsDescendant && next[existing] === undefined) continue; + if (newIsDescendant || newIsAncestor) { + delete next[existing]; + } + } + return next; +} + /** * Merge indexed-array edits (entries whose flat path ends in `.`) into * a config tree. Each indexed edit's value replaces the array element at that diff --git a/src/server/scopes.ts b/src/server/scopes.ts index 6d970bf..bbca683 100644 --- a/src/server/scopes.ts +++ b/src/server/scopes.ts @@ -22,6 +22,8 @@ import { requireAnyCapability } from './capabilities'; import { safeFieldPath } from './utils/validation'; import { apiFetch } from './utils/api'; +const INDEXED_ARRAY_RE = /^(.+)\.(\d+)$/; + // ── Dot-path helpers ───────────────────────────────────────────────── function deepGet(obj: object, path: string): unknown { @@ -34,6 +36,78 @@ function deepGet(obj: object, path: string): unknown { return current; } +async function getScopeOverrides( + apiType: PrincipalType, + principalId: string, +): Promise> { + const response = await apiFetch( + `/api/admin/config/${apiType}/${encodeURIComponent(principalId)}`, + ); + if (response.status === 404) return {}; + if (!response.ok) throw new Error(`Failed to fetch config: ${response.status}`); + const { config } = (await response.json()) as AdminConfigResponse; + return (config.overrides ?? {}) as Record; +} + +async function getBaseConfig(): Promise> { + const response = await apiFetch('/api/admin/config/base'); + if (!response.ok) throw new Error(`Failed to fetch base config: ${response.status}`); + const { config } = (await response.json()) as { config: Record }; + return config; +} + +async function mergeIndexedArrayEntriesForScope( + apiType: PrincipalType, + principalId: string, + entries: Array<{ fieldPath: string; value: unknown }>, +): Promise> { + const indexed = new Map>(); + const rest: Array<{ fieldPath: string; value: unknown }> = []; + const restByPath = new Map(); + + for (const entry of entries) { + const match = INDEXED_ARRAY_RE.exec(entry.fieldPath); + if (!match) { + restByPath.set(entry.fieldPath, rest.length); + rest.push(entry); + continue; + } + const [, arrayPath, indexStr] = match; + if (!indexed.has(arrayPath)) indexed.set(arrayPath, new Map()); + indexed.get(arrayPath)!.set(Number(indexStr), entry.value); + } + + if (indexed.size === 0) return entries; + + const [scopeOverrides, baseConfig] = await Promise.all([ + getScopeOverrides(apiType, principalId), + getBaseConfig(), + ]); + + for (const [arrayPath, updates] of indexed) { + const restIndex = restByPath.get(arrayPath); + const pending = restIndex === undefined ? undefined : rest[restIndex]?.value; + const scopeValue = deepGet(scopeOverrides, arrayPath); + const baseValue = deepGet(baseConfig, arrayPath); + let source = baseValue; + if (Array.isArray(scopeValue)) source = scopeValue; + if (Array.isArray(pending)) source = pending; + const arr = Array.isArray(source) ? [...source] : []; + for (const [idx, value] of updates) { + arr[idx] = value; + } + const merged = { fieldPath: arrayPath, value: arr }; + if (restIndex === undefined) { + restByPath.set(arrayPath, rest.length); + rest.push(merged); + } else { + rest[restIndex] = merged; + } + } + + return rest; +} + // ── API helpers ────────────────────────────────────────────────────── function apiConfigToScope(config: AdminConfig, nameMap?: Map): t.ConfigScope { @@ -209,13 +283,14 @@ export const saveFieldProfileValueFn = createServerFn({ method: 'POST' }) ]); if (isInterfacePermissionPath(data.fieldPath)) return { success: true }; const apiType = data.principalType; + const entries = await mergeIndexedArrayEntriesForScope(apiType, data.principalId, [ + { fieldPath: data.fieldPath, value: data.value }, + ]); const response = await apiFetch( `/api/admin/config/${apiType}/${encodeURIComponent(data.principalId)}/fields`, { method: 'PATCH', - body: JSON.stringify({ - entries: [{ fieldPath: data.fieldPath, value: data.value }], - }), + body: JSON.stringify({ entries }), }, ); @@ -263,11 +338,12 @@ export const bulkSaveProfileValuesFn = createServerFn({ method: 'POST' }) const filtered = data.entries.filter((e) => !isInterfacePermissionPath(e.fieldPath)); if (filtered.length === 0) return { success: true, count: 0 }; const apiType = data.principalType; + const entries = await mergeIndexedArrayEntriesForScope(apiType, data.principalId, filtered); const response = await apiFetch( `/api/admin/config/${apiType}/${encodeURIComponent(data.principalId)}/fields`, { method: 'PATCH', - body: JSON.stringify({ entries: filtered }), + body: JSON.stringify({ entries }), }, ); @@ -277,7 +353,7 @@ export const bulkSaveProfileValuesFn = createServerFn({ method: 'POST' }) (err as { error?: string }).error ?? `Failed to save fields: ${response.status}`, ); } - return { success: true, count: filtered.length }; + return { success: true, count: entries.length }; }, ); From 6b732186d2e382f3021e573fe01e489d80540296 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 24 Jun 2026 17:08:59 -0400 Subject: [PATCH 2/7] fix: Guard indexed array saves with schema checks --- src/server/config.test.ts | 20 +++++++++++++++++--- src/server/config.ts | 25 +++++++++++++++++++------ src/server/scopes.ts | 11 +++++------ 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 67a04a1..e2fd7d9 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -14,6 +14,7 @@ import { flattenTree, resolveSubSchema, validateFieldValue, + parseIndexedArrayPath, } from './config'; interface ZodV3Schema extends t.ZodSchemaLike { @@ -677,9 +678,9 @@ describe('validateFieldValue', () => { }); it('validates a nested field reached through a union (header value must be string)', () => { - expect( - validateFieldValue('mcpServers.foo.headers.Authorization', 'Bearer xyz'), - ).toEqual({ success: true }); + expect(validateFieldValue('mcpServers.foo.headers.Authorization', 'Bearer xyz')).toEqual({ + success: true, + }); const bad = validateFieldValue('mcpServers.foo.headers.Authorization', 42); expect(bad.success).toBe(false); }); @@ -1034,6 +1035,19 @@ describe('resolveSubSchema for endpoints', () => { }); }); +describe('parseIndexedArrayPath', () => { + it('accepts numeric suffixes when the parent path is an array', () => { + expect(parseIndexedArrayPath('endpoints.custom.0')).toEqual({ + arrayPath: 'endpoints.custom', + index: 0, + }); + }); + + it('rejects numeric suffixes when the parent path is a record', () => { + expect(parseIndexedArrayPath('mcpServers.foo.headers.2024')).toBeNull(); + }); +}); + describe('validateFieldValue for endpoints', () => { const validEndpoint = { name: 'TestEndpoint', diff --git a/src/server/config.ts b/src/server/config.ts index 9acf71d..704cd85 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -30,6 +30,8 @@ const WRAPPER_TYPES = new Set([ 'ZodPipeline', ]); +const INDEXED_ARRAY_RE = /^(.+)\.(\d+)$/; + function unwrapSchema(schema: t.ZodSchemaLike): t.ZodSchemaLike { const seen = new Set(); let current = schema; @@ -583,6 +585,19 @@ export function validateFieldValue( return { success: true }; } +export function parseIndexedArrayPath( + fieldPath: string, +): { arrayPath: string; index: number } | null { + const match = INDEXED_ARRAY_RE.exec(fieldPath); + if (!match) return null; + const [, arrayPath, indexStr] = match; + const schema = resolveSubSchema(configSchema as t.ZodSchemaLike, arrayPath.split('.')); + if (!schema) return null; + const unwrapped = unwrapSchema(schema); + if (unwrapped?._def?.typeName !== 'ZodArray') return null; + return { arrayPath, index: Number(indexStr) }; +} + /** Shared queryOptions for the schema tree used by command palette search. */ export const configSchemaTreeOptions = queryOptions({ queryKey: ['configSchemaTree'], @@ -871,8 +886,6 @@ export const baseConfigOptions = queryOptions({ staleTime: 30_000, }); -const INDEXED_ARRAY_RE = /^(.+)\.(\d+)$/; - async function mergeIndexedArrayEntries( entries: Array<{ fieldPath: string; value: unknown }>, mergedPaths?: Set, @@ -881,11 +894,11 @@ async function mergeIndexedArrayEntries( const rest: Array<{ fieldPath: string; value: unknown }> = []; for (const entry of entries) { - const match = INDEXED_ARRAY_RE.exec(entry.fieldPath); - if (match) { - const [, arrayPath, indexStr] = match; + const parsed = parseIndexedArrayPath(entry.fieldPath); + if (parsed) { + const { arrayPath, index } = parsed; if (!indexed.has(arrayPath)) indexed.set(arrayPath, new Map()); - indexed.get(arrayPath)!.set(Number(indexStr), entry.value); + indexed.get(arrayPath)!.set(index, entry.value); } else { rest.push(entry); } diff --git a/src/server/scopes.ts b/src/server/scopes.ts index bbca683..d9959f6 100644 --- a/src/server/scopes.ts +++ b/src/server/scopes.ts @@ -21,8 +21,7 @@ import { BASE_CONFIG_PRINCIPAL_ID } from './constants'; import { requireAnyCapability } from './capabilities'; import { safeFieldPath } from './utils/validation'; import { apiFetch } from './utils/api'; - -const INDEXED_ARRAY_RE = /^(.+)\.(\d+)$/; +import { parseIndexedArrayPath } from './config'; // ── Dot-path helpers ───────────────────────────────────────────────── @@ -66,15 +65,15 @@ async function mergeIndexedArrayEntriesForScope( const restByPath = new Map(); for (const entry of entries) { - const match = INDEXED_ARRAY_RE.exec(entry.fieldPath); - if (!match) { + const parsed = parseIndexedArrayPath(entry.fieldPath); + if (!parsed) { restByPath.set(entry.fieldPath, rest.length); rest.push(entry); continue; } - const [, arrayPath, indexStr] = match; + const { arrayPath, index } = parsed; if (!indexed.has(arrayPath)) indexed.set(arrayPath, new Map()); - indexed.get(arrayPath)!.set(Number(indexStr), entry.value); + indexed.get(arrayPath)!.set(index, entry.value); } if (indexed.size === 0) return entries; From e8eac9d86f32eb0c096aee632ed05150e4c5eb5b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 24 Jun 2026 17:12:26 -0400 Subject: [PATCH 3/7] fix: Normalize scoped array save inputs --- src/components/configuration/ConfigPage.tsx | 7 ++--- src/server/config.test.ts | 27 ++++++++++++++++ src/server/config.ts | 2 +- src/server/scopes.ts | 6 ++-- src/utils/format.test.ts | 35 ++++++++++++--------- 5 files changed, 53 insertions(+), 24 deletions(-) diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 24fb801..5dcc345 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -21,7 +21,6 @@ import { import { flattenObject, unflattenObject, - serializeKVPairs, deepSerializeKVPairs, normalizeImportConfig, hasConfigCapability, @@ -537,9 +536,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi .filter((p) => editedValues[p] !== undefined) .map((p) => ({ fieldPath: p, - value: /\.\d+$/.test(p) - ? deepSerializeKVPairs(editedValues[p]) - : serializeKVPairs(editedValues[p]), + value: deepSerializeKVPairs(editedValues[p]), })); const resets = touched.filter((p) => editedValues[p] === undefined); const inheritedMcpKeys = (() => { @@ -631,7 +628,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const serializedEditedValues = useMemo(() => { const result: t.FlatConfigMap = {}; for (const [k, v] of Object.entries(editedValues)) { - result[k] = /\.\d+$/.test(k) ? deepSerializeKVPairs(v) : serializeKVPairs(v); + result[k] = deepSerializeKVPairs(v); } return result; }, [editedValues]); diff --git a/src/server/config.test.ts b/src/server/config.test.ts index e2fd7d9..45fdfe8 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -15,6 +15,7 @@ import { resolveSubSchema, validateFieldValue, parseIndexedArrayPath, + normalizeAppServiceKeys, } from './config'; interface ZodV3Schema extends t.ZodSchemaLike { @@ -1048,6 +1049,32 @@ describe('parseIndexedArrayPath', () => { }); }); +describe('normalizeAppServiceKeys', () => { + it('maps Azure groupMap output to canonical groups arrays', () => { + const normalized = normalizeAppServiceKeys({ + endpoints: { + azureOpenAI: { + isValid: true, + groupMap: { + default: { apiKey: 'key-a', instanceName: 'instance-a' }, + fallback: { apiKey: 'key-b', instanceName: 'instance-b' }, + }, + errors: ['ignored'], + modelNames: ['ignored'], + }, + }, + }); + expect(normalized.endpoints).toEqual({ + azureOpenAI: { + groups: [ + { group: 'default', apiKey: 'key-a', instanceName: 'instance-a' }, + { group: 'fallback', apiKey: 'key-b', instanceName: 'instance-b' }, + ], + }, + }); + }); +}); + describe('validateFieldValue for endpoints', () => { const validEndpoint = { name: 'TestEndpoint', diff --git a/src/server/config.ts b/src/server/config.ts index 704cd85..cd46d67 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -792,7 +792,7 @@ function normalizeEndpointValue(value: t.ConfigValue): t.ConfigValue { return value; } -function normalizeAppServiceKeys( +export function normalizeAppServiceKeys( raw: Record, ): Record { const result: Record = {}; diff --git a/src/server/scopes.ts b/src/server/scopes.ts index d9959f6..8f34723 100644 --- a/src/server/scopes.ts +++ b/src/server/scopes.ts @@ -21,7 +21,7 @@ import { BASE_CONFIG_PRINCIPAL_ID } from './constants'; import { requireAnyCapability } from './capabilities'; import { safeFieldPath } from './utils/validation'; import { apiFetch } from './utils/api'; -import { parseIndexedArrayPath } from './config'; +import { normalizeAppServiceKeys, parseIndexedArrayPath } from './config'; // ── Dot-path helpers ───────────────────────────────────────────────── @@ -51,8 +51,8 @@ async function getScopeOverrides( async function getBaseConfig(): Promise> { const response = await apiFetch('/api/admin/config/base'); if (!response.ok) throw new Error(`Failed to fetch base config: ${response.status}`); - const { config } = (await response.json()) as { config: Record }; - return config; + const { config } = (await response.json()) as { config: Record }; + return normalizeAppServiceKeys(config); } async function mergeIndexedArrayEntriesForScope( diff --git a/src/utils/format.test.ts b/src/utils/format.test.ts index 8dd0d32..19843bc 100644 --- a/src/utils/format.test.ts +++ b/src/utils/format.test.ts @@ -12,16 +12,12 @@ describe('serializeKVPairs', () => { }); it('handles json valueType by parsing JSON string', () => { - const pairs = [ - { key: 'config', value: '{"nested": true}', valueType: 'json' as const }, - ]; + const pairs = [{ key: 'config', value: '{"nested": true}', valueType: 'json' as const }]; expect(serializeKVPairs(pairs)).toEqual({ config: { nested: true } }); }); it('falls back to string for invalid json', () => { - const pairs = [ - { key: 'bad', value: '{not json', valueType: 'json' as const }, - ]; + const pairs = [{ key: 'bad', value: '{not json', valueType: 'json' as const }]; expect(serializeKVPairs(pairs)).toEqual({ bad: '{not json' }); }); @@ -77,9 +73,7 @@ describe('deepSerializeKVPairs', () => { it('serializes KV pairs with json type in nested objects', () => { const value = { name: 'Test', - addParams: [ - { key: 'config', value: '{"nested": {"deep": true}}', valueType: 'json' }, - ], + addParams: [{ key: 'config', value: '{"nested": {"deep": true}}', valueType: 'json' }], }; const result = deepSerializeKVPairs(value) as Record; expect(result.addParams).toEqual({ config: { nested: { deep: true } } }); @@ -95,11 +89,24 @@ describe('deepSerializeKVPairs', () => { expect(models.fetch).toBe(true); }); + it('serializes KV pairs inside array object entries', () => { + const value = [ + { + name: 'TestAPI', + headers: [{ key: 'Authorization', value: 'Bearer ${TOKEN}', valueType: 'string' }], + }, + ]; + expect(deepSerializeKVPairs(value)).toEqual([ + { + name: 'TestAPI', + headers: { Authorization: 'Bearer ${TOKEN}' }, + }, + ]); + }); + it('handles headers (string-only record) correctly', () => { const value = { - headers: [ - { key: 'x-api-key', value: '${KEY}', valueType: 'string' }, - ], + headers: [{ key: 'x-api-key', value: '${KEY}', valueType: 'string' }], }; const result = deepSerializeKVPairs(value) as Record; expect(result.headers).toEqual({ 'x-api-key': '${KEY}' }); @@ -120,9 +127,7 @@ describe('deepSerializeKVPairs', () => { models: { default: ['gpt-4'], fetch: true }, titleConvo: true, titleModel: 'current_model', - headers: [ - { key: 'Authorization', value: 'Bearer ${TOKEN}', valueType: 'string' }, - ], + headers: [{ key: 'Authorization', value: 'Bearer ${TOKEN}', valueType: 'string' }], addParams: [ { key: 'stream', value: 'true', valueType: 'boolean' }, { key: 'config', value: '{"key": "value"}', valueType: 'json' }, From c729f8a321444305b3ecbcdf81a75639aaa16e3f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 24 Jun 2026 17:16:05 -0400 Subject: [PATCH 4/7] fix: Normalize indexed base array fallbacks --- src/server/config.test.ts | 45 +++++++++++++++++++++++++++++++++++++++ src/server/config.ts | 26 ++++++++++++++-------- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 45fdfe8..e614ef5 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -16,6 +16,7 @@ import { validateFieldValue, parseIndexedArrayPath, normalizeAppServiceKeys, + mergeIndexedArrayEntriesIntoBase, } from './config'; interface ZodV3Schema extends t.ZodSchemaLike { @@ -1050,6 +1051,24 @@ describe('parseIndexedArrayPath', () => { }); describe('normalizeAppServiceKeys', () => { + it('maps MCP AppService output to canonical mcpServers records', () => { + const normalized = normalizeAppServiceKeys({ + mcpConfig: { + filesystem: { + type: 'stdio', + args: ['server.js', '--root', '/tmp'], + }, + }, + }); + expect(normalized).not.toHaveProperty('mcpConfig'); + expect(normalized.mcpServers).toEqual({ + filesystem: { + type: 'stdio', + args: ['server.js', '--root', '/tmp'], + }, + }); + }); + it('maps Azure groupMap output to canonical groups arrays', () => { const normalized = normalizeAppServiceKeys({ endpoints: { @@ -1075,6 +1094,32 @@ describe('normalizeAppServiceKeys', () => { }); }); +describe('mergeIndexedArrayEntriesIntoBase', () => { + it('merges indexed MCP array edits from AppService fallback aliases', () => { + const mergedPaths = new Set(); + const result = mergeIndexedArrayEntriesIntoBase( + [{ fieldPath: 'mcpServers.filesystem.args.1', value: '--workspace' }], + { + mcpConfig: { + filesystem: { + type: 'stdio', + args: ['server.js', '--root', '/tmp'], + }, + }, + }, + mergedPaths, + ); + + expect(result).toEqual([ + { + fieldPath: 'mcpServers.filesystem.args', + value: ['server.js', '--workspace', '/tmp'], + }, + ]); + expect(mergedPaths.has('mcpServers.filesystem.args')).toBe(true); + }); +}); + describe('validateFieldValue for endpoints', () => { const validEndpoint = { name: 'TestEndpoint', diff --git a/src/server/config.ts b/src/server/config.ts index cd46d67..c1f6d18 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -886,12 +886,14 @@ export const baseConfigOptions = queryOptions({ staleTime: 30_000, }); -async function mergeIndexedArrayEntries( +export function mergeIndexedArrayEntriesIntoBase( entries: Array<{ fieldPath: string; value: unknown }>, + baseConfig: Record, mergedPaths?: Set, -): Promise> { +): Array<{ fieldPath: string; value: unknown }> { const indexed = new Map>(); const rest: Array<{ fieldPath: string; value: unknown }> = []; + const normalizedBaseConfig = normalizeAppServiceKeys(baseConfig); for (const entry of entries) { const parsed = parseIndexedArrayPath(entry.fieldPath); @@ -906,15 +908,9 @@ async function mergeIndexedArrayEntries( if (indexed.size === 0) return entries; - const baseResponse = await apiFetch('/api/admin/config/base'); - if (!baseResponse.ok) throw new Error(`Failed to fetch base config: ${baseResponse.status}`); - const { config: baseConfig } = (await baseResponse.json()) as { - config: Record; - }; - for (const [arrayPath, updates] of indexed) { const segments = arrayPath.split('.'); - let current: unknown = baseConfig; + let current: unknown = normalizedBaseConfig; for (const seg of segments) { if (current == null || typeof current !== 'object') { current = undefined; @@ -933,6 +929,18 @@ async function mergeIndexedArrayEntries( return rest; } +async function mergeIndexedArrayEntries( + entries: Array<{ fieldPath: string; value: unknown }>, + mergedPaths?: Set, +): Promise> { + const baseResponse = await apiFetch('/api/admin/config/base'); + if (!baseResponse.ok) throw new Error(`Failed to fetch base config: ${baseResponse.status}`); + const { config: baseConfig } = (await baseResponse.json()) as { + config: Record; + }; + return mergeIndexedArrayEntriesIntoBase(entries, baseConfig, mergedPaths); +} + export const saveBaseConfigFn = createServerFn({ method: 'POST' }) .inputValidator( z.object({ From 21256edd05b0c1825bb4c26fbd4a0d3435605936 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 24 Jun 2026 17:19:14 -0400 Subject: [PATCH 5/7] fix: Preserve legacy indexed scope arrays --- src/server/config.test.ts | 47 +++++++++++++++++++++++++++++++++++++++ src/server/config.ts | 16 ++++++++++++- src/server/scopes.ts | 14 +++++++----- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/server/config.test.ts b/src/server/config.test.ts index e614ef5..6bce8b5 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -15,6 +15,7 @@ import { resolveSubSchema, validateFieldValue, parseIndexedArrayPath, + toConfigArraySource, normalizeAppServiceKeys, mergeIndexedArrayEntriesIntoBase, } from './config'; @@ -1050,6 +1051,21 @@ describe('parseIndexedArrayPath', () => { }); }); +describe('toConfigArraySource', () => { + it('converts legacy numeric-key array objects to arrays', () => { + expect( + toConfigArraySource({ + 0: { name: 'first' }, + 2: { name: 'third' }, + }), + ).toEqual([{ name: 'first' }, undefined, { name: 'third' }]); + }); + + it('rejects non-index object keys', () => { + expect(toConfigArraySource({ 0: 'zero', current: 'not-array' })).toBeUndefined(); + }); +}); + describe('normalizeAppServiceKeys', () => { it('maps MCP AppService output to canonical mcpServers records', () => { const normalized = normalizeAppServiceKeys({ @@ -1118,6 +1134,37 @@ describe('mergeIndexedArrayEntriesIntoBase', () => { ]); expect(mergedPaths.has('mcpServers.filesystem.args')).toBe(true); }); + + it('preserves legacy numeric-key array objects while merging', () => { + const result = mergeIndexedArrayEntriesIntoBase( + [ + { + fieldPath: 'endpoints.custom.1', + value: { name: 'edited', baseURL: 'https://edited.example.com' }, + }, + ], + { + endpoints: { + custom: { + 0: { name: 'first', baseURL: 'https://first.example.com' }, + 1: { name: 'second', baseURL: 'https://second.example.com' }, + 2: { name: 'third', baseURL: 'https://third.example.com' }, + }, + }, + }, + ); + + expect(result).toEqual([ + { + fieldPath: 'endpoints.custom', + value: [ + { name: 'first', baseURL: 'https://first.example.com' }, + { name: 'edited', baseURL: 'https://edited.example.com' }, + { name: 'third', baseURL: 'https://third.example.com' }, + ], + }, + ]); + }); }); describe('validateFieldValue for endpoints', () => { diff --git a/src/server/config.ts b/src/server/config.ts index c1f6d18..367e7f5 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -31,6 +31,7 @@ const WRAPPER_TYPES = new Set([ ]); const INDEXED_ARRAY_RE = /^(.+)\.(\d+)$/; +const ARRAY_INDEX_KEY_RE = /^(0|[1-9]\d*)$/; function unwrapSchema(schema: t.ZodSchemaLike): t.ZodSchemaLike { const seen = new Set(); @@ -598,6 +599,19 @@ export function parseIndexedArrayPath( return { arrayPath, index: Number(indexStr) }; } +export function toConfigArraySource(value: unknown): unknown[] | undefined { + if (Array.isArray(value)) return [...value]; + if (!value || typeof value !== 'object') return undefined; + const arrayValue: unknown[] = []; + for (const [key, entryValue] of Object.entries(value as Record)) { + if (!ARRAY_INDEX_KEY_RE.test(key)) return undefined; + const index = Number(key); + if (!Number.isSafeInteger(index)) return undefined; + arrayValue[index] = entryValue; + } + return arrayValue; +} + /** Shared queryOptions for the schema tree used by command palette search. */ export const configSchemaTreeOptions = queryOptions({ queryKey: ['configSchemaTree'], @@ -918,7 +932,7 @@ export function mergeIndexedArrayEntriesIntoBase( } current = (current as Record)[seg]; } - const arr = Array.isArray(current) ? [...current] : []; + const arr = toConfigArraySource(current) ?? []; for (const [idx, value] of updates) { arr[idx] = value; } diff --git a/src/server/scopes.ts b/src/server/scopes.ts index 8f34723..42e1805 100644 --- a/src/server/scopes.ts +++ b/src/server/scopes.ts @@ -21,7 +21,7 @@ import { BASE_CONFIG_PRINCIPAL_ID } from './constants'; import { requireAnyCapability } from './capabilities'; import { safeFieldPath } from './utils/validation'; import { apiFetch } from './utils/api'; -import { normalizeAppServiceKeys, parseIndexedArrayPath } from './config'; +import { normalizeAppServiceKeys, parseIndexedArrayPath, toConfigArraySource } from './config'; // ── Dot-path helpers ───────────────────────────────────────────────── @@ -45,7 +45,7 @@ async function getScopeOverrides( if (response.status === 404) return {}; if (!response.ok) throw new Error(`Failed to fetch config: ${response.status}`); const { config } = (await response.json()) as AdminConfigResponse; - return (config.overrides ?? {}) as Record; + return normalizeAppServiceKeys((config.overrides ?? {}) as Record); } async function getBaseConfig(): Promise> { @@ -88,10 +88,12 @@ async function mergeIndexedArrayEntriesForScope( const pending = restIndex === undefined ? undefined : rest[restIndex]?.value; const scopeValue = deepGet(scopeOverrides, arrayPath); const baseValue = deepGet(baseConfig, arrayPath); - let source = baseValue; - if (Array.isArray(scopeValue)) source = scopeValue; - if (Array.isArray(pending)) source = pending; - const arr = Array.isArray(source) ? [...source] : []; + let source = toConfigArraySource(baseValue); + const scopeSource = toConfigArraySource(scopeValue); + const pendingSource = toConfigArraySource(pending); + if (scopeSource) source = scopeSource; + if (pendingSource) source = pendingSource; + const arr = source ?? []; for (const [idx, value] of updates) { arr[idx] = value; } From c5aa1a79a95844e730851f490931a304d0247d8b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 24 Jun 2026 17:31:39 -0400 Subject: [PATCH 6/7] fix: Skip base lookup for scalar saves --- src/server/config.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/server/config.ts b/src/server/config.ts index 367e7f5..0b6618c 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -907,7 +907,6 @@ export function mergeIndexedArrayEntriesIntoBase( ): Array<{ fieldPath: string; value: unknown }> { const indexed = new Map>(); const rest: Array<{ fieldPath: string; value: unknown }> = []; - const normalizedBaseConfig = normalizeAppServiceKeys(baseConfig); for (const entry of entries) { const parsed = parseIndexedArrayPath(entry.fieldPath); @@ -921,6 +920,7 @@ export function mergeIndexedArrayEntriesIntoBase( } if (indexed.size === 0) return entries; + const normalizedBaseConfig = normalizeAppServiceKeys(baseConfig); for (const [arrayPath, updates] of indexed) { const segments = arrayPath.split('.'); @@ -943,10 +943,18 @@ export function mergeIndexedArrayEntriesIntoBase( return rest; } +function hasIndexedArrayEntry(entries: Array<{ fieldPath: string; value: unknown }>): boolean { + for (const entry of entries) { + if (parseIndexedArrayPath(entry.fieldPath)) return true; + } + return false; +} + async function mergeIndexedArrayEntries( entries: Array<{ fieldPath: string; value: unknown }>, mergedPaths?: Set, ): Promise> { + if (!hasIndexedArrayEntry(entries)) return entries; const baseResponse = await apiFetch('/api/admin/config/base'); if (!baseResponse.ok) throw new Error(`Failed to fetch base config: ${baseResponse.status}`); const { config: baseConfig } = (await baseResponse.json()) as { From 9e59163629436557e3049cb7f4f5ba862d90b34b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 24 Jun 2026 17:44:02 -0400 Subject: [PATCH 7/7] fix: Overlay legacy scoped array entries --- src/server/config.test.ts | 35 +++++++++++++++++++++++++++++++++++ src/server/config.ts | 30 ++++++++++++++++++++++++++++++ src/server/scopes.ts | 9 ++------- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 6bce8b5..ec25c06 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -17,6 +17,7 @@ import { parseIndexedArrayPath, toConfigArraySource, normalizeAppServiceKeys, + mergeConfigArraySources, mergeIndexedArrayEntriesIntoBase, } from './config'; @@ -1066,6 +1067,40 @@ describe('toConfigArraySource', () => { }); }); +describe('mergeConfigArraySources', () => { + it('overlays legacy numeric-key overrides onto inherited arrays', () => { + expect( + mergeConfigArraySources( + [ + { name: 'base-a', baseURL: 'https://a.example.com' }, + { name: 'base-b', baseURL: 'https://b.example.com' }, + { name: 'base-c', baseURL: 'https://c.example.com' }, + ], + { + 1: { name: 'scope-b', baseURL: 'https://scope.example.com' }, + }, + undefined, + ), + ).toEqual([ + { name: 'base-a', baseURL: 'https://a.example.com' }, + { name: 'scope-b', baseURL: 'https://scope.example.com' }, + { name: 'base-c', baseURL: 'https://c.example.com' }, + ]); + }); + + it('treats real arrays as complete overrides', () => { + expect(mergeConfigArraySources(['base-a', 'base-b'], ['scope-only'], undefined)).toEqual([ + 'scope-only', + ]); + }); + + it('applies pending numeric-key entries after scoped overlays', () => { + expect( + mergeConfigArraySources(['base-a', 'base-b', 'base-c'], { 1: 'scope-b' }, { 2: 'pending-c' }), + ).toEqual(['base-a', 'scope-b', 'pending-c']); + }); +}); + describe('normalizeAppServiceKeys', () => { it('maps MCP AppService output to canonical mcpServers records', () => { const normalized = normalizeAppServiceKeys({ diff --git a/src/server/config.ts b/src/server/config.ts index 0b6618c..b568b1e 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -601,7 +601,12 @@ export function parseIndexedArrayPath( export function toConfigArraySource(value: unknown): unknown[] | undefined { if (Array.isArray(value)) return [...value]; + return toIndexedArrayObjectSource(value); +} + +export function toIndexedArrayObjectSource(value: unknown): unknown[] | undefined { if (!value || typeof value !== 'object') return undefined; + if (Array.isArray(value)) return undefined; const arrayValue: unknown[] = []; for (const [key, entryValue] of Object.entries(value as Record)) { if (!ARRAY_INDEX_KEY_RE.test(key)) return undefined; @@ -612,6 +617,31 @@ export function toConfigArraySource(value: unknown): unknown[] | undefined { return arrayValue; } +function overlayArraySource(source: unknown[], overlay: unknown[]): unknown[] { + const result = [...source]; + for (const key of Object.keys(overlay)) { + const index = Number(key); + result[index] = overlay[index]; + } + return result; +} + +function mergeConfigArraySource(source: unknown[], value: unknown): unknown[] { + if (Array.isArray(value)) return [...value]; + const overlay = toIndexedArrayObjectSource(value); + return overlay ? overlayArraySource(source, overlay) : source; +} + +export function mergeConfigArraySources( + baseValue: unknown, + overrideValue: unknown, + pendingValue: unknown, +): unknown[] { + const baseSource = toConfigArraySource(baseValue) ?? []; + const overrideSource = mergeConfigArraySource(baseSource, overrideValue); + return mergeConfigArraySource(overrideSource, pendingValue); +} + /** Shared queryOptions for the schema tree used by command palette search. */ export const configSchemaTreeOptions = queryOptions({ queryKey: ['configSchemaTree'], diff --git a/src/server/scopes.ts b/src/server/scopes.ts index 42e1805..12a2e93 100644 --- a/src/server/scopes.ts +++ b/src/server/scopes.ts @@ -21,7 +21,7 @@ import { BASE_CONFIG_PRINCIPAL_ID } from './constants'; import { requireAnyCapability } from './capabilities'; import { safeFieldPath } from './utils/validation'; import { apiFetch } from './utils/api'; -import { normalizeAppServiceKeys, parseIndexedArrayPath, toConfigArraySource } from './config'; +import { normalizeAppServiceKeys, parseIndexedArrayPath, mergeConfigArraySources } from './config'; // ── Dot-path helpers ───────────────────────────────────────────────── @@ -88,12 +88,7 @@ async function mergeIndexedArrayEntriesForScope( const pending = restIndex === undefined ? undefined : rest[restIndex]?.value; const scopeValue = deepGet(scopeOverrides, arrayPath); const baseValue = deepGet(baseConfig, arrayPath); - let source = toConfigArraySource(baseValue); - const scopeSource = toConfigArraySource(scopeValue); - const pendingSource = toConfigArraySource(pending); - if (scopeSource) source = scopeSource; - if (pendingSource) source = pendingSource; - const arr = source ?? []; + const arr = mergeConfigArraySources(baseValue, scopeValue, pending); for (const [idx, value] of updates) { arr[idx] = value; }