Skip to content

Commit 2b9efd7

Browse files
committed
fix: remove merge-level type guard, rely on fb()/fbArray() for fallback
The type guard blocked valid user overrides when the system config had a malformed type (e.g., system 'true' string vs user true boolean). Since fb()/fbArray() already handle normalization fallback, the guard was redundant for normalized fields. For raw pass-through fields (featureFlagOverrides, viewedSplashScreens, serverSshHost), add targeted inline type validation with system fallback instead of a blanket merge-level guard. Adds test: valid user overrides win even when system has wrong type.
1 parent ad8ef90 commit 2b9efd7

File tree

2 files changed

+74
-31
lines changed

2 files changed

+74
-31
lines changed

src/node/config.test.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ describe("Config", () => {
612612
// User's intentional [] should win over system defaults
613613
expect(loaded.hiddenModels).toEqual([]);
614614
});
615-
it("type-mismatched user values do not displace system defaults", () => {
615+
it("type-mismatched user values fall back to system via normalization", () => {
616616
writeSystemConfig({
617617
mdnsAdvertisementEnabled: true,
618618
updateChannel: "stable",
@@ -629,12 +629,47 @@ describe("Config", () => {
629629

630630
const loaded = config.loadConfigOrDefault();
631631

632-
// System defaults survive because type-mismatched user values are rejected
632+
// System defaults survive: fb() falls back for booleans/strings,
633+
// inline type check falls back for raw objects (featureFlagOverrides)
633634
expect(loaded.mdnsAdvertisementEnabled).toBe(true);
634635
expect(loaded.updateChannel).toBe("stable");
635636
expect(loaded.featureFlagOverrides).toEqual({ flagA: "on" });
636637
});
637638

639+
it("valid user overrides win even when system has wrong type", () => {
640+
writeSystemConfig({
641+
// Admin typo: "true" (string) instead of true (boolean)
642+
mdnsAdvertisementEnabled: "true" as unknown as boolean,
643+
projects: [],
644+
});
645+
writeUserConfig({
646+
// User has the correct type — should not be blocked
647+
mdnsAdvertisementEnabled: false,
648+
projects: [],
649+
});
650+
651+
const loaded = config.loadConfigOrDefault();
652+
653+
// User's valid boolean wins over system's malformed string
654+
expect(loaded.mdnsAdvertisementEnabled).toBe(false);
655+
});
656+
it("non-object user value does not displace system object (runtimeEnablement)", () => {
657+
writeSystemConfig({
658+
runtimeEnablement: { docker: false },
659+
projects: [],
660+
});
661+
writeUserConfig({
662+
// Malformed: string instead of object
663+
runtimeEnablement: "oops" as unknown as Record<string, unknown>,
664+
projects: [],
665+
});
666+
667+
const loaded = config.loadConfigOrDefault();
668+
669+
// System object survives — merge rejects non-object user value for object fields
670+
expect(loaded.runtimeEnablement).toEqual({ docker: false });
671+
});
672+
638673
it("same-type but invalid user values fall back to system via normalization", () => {
639674
writeSystemConfig({
640675
updateChannel: "stable",

src/node/config.ts

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -336,33 +336,26 @@ export class Config {
336336
if (userVal === undefined) continue;
337337

338338
const systemVal = systemRecord[key];
339-
340-
// Type guard: reject user values whose JSON type fundamentally differs
341-
// from the system default. Without this, a user value like "oops" for a
342-
// boolean field would overwrite the valid system boolean, only to be
343-
// stripped to undefined by normalization — silently losing the baseline.
344-
if (systemVal !== undefined) {
345-
const sysIsArr = Array.isArray(systemVal);
346-
const usrIsArr = Array.isArray(userVal);
347-
if (sysIsArr !== usrIsArr || (!sysIsArr && typeof systemVal !== typeof userVal)) {
348-
continue;
339+
// For scalar/primitive fields, no type guard here — fb()/fbArray() in
340+
// the normalization section reject invalid merged values and fall back
341+
// to the raw system value. A blanket type guard would block valid user
342+
// overrides when the system config itself has a typo (e.g., system
343+
// "true" string vs user true boolean).
344+
//
345+
// For object fields, we protect the system object from being replaced
346+
// by a non-object user value (e.g., runtimeEnablement: "oops"). This
347+
// is safe because a non-object can never be a valid override for an
348+
// object-valued setting.
349+
if (systemVal != null && typeof systemVal === "object" && !Array.isArray(systemVal)) {
350+
if (userVal != null && typeof userVal === "object" && !Array.isArray(userVal)) {
351+
// LIMITATION: This is a shallow key-level merge — user sub-values replace
352+
// system sub-values without per-value validation. E.g., a user setting
353+
// featureFlagOverrides.flagA = "bogus" overwrites the admin's "on". This
354+
// is accepted because validation lives in consumers (getFeatureFlagOverride),
355+
// not the config merge layer, and the set of valid values varies per key.
356+
mergedRecord[key] = { ...systemVal, ...userVal };
349357
}
350-
}
351-
352-
if (
353-
systemVal != null &&
354-
typeof systemVal === "object" &&
355-
!Array.isArray(systemVal) &&
356-
userVal != null &&
357-
typeof userVal === "object" &&
358-
!Array.isArray(userVal)
359-
) {
360-
// LIMITATION: This is a shallow key-level merge — user sub-values replace
361-
// system sub-values without per-value validation. E.g., a user setting
362-
// featureFlagOverrides.flagA = "bogus" overwrites the admin's "on". This
363-
// is accepted because validation lives in consumers (getFeatureFlagOverride),
364-
// not the config merge layer, and the set of valid values varies per key.
365-
mergedRecord[key] = { ...systemVal, ...userVal };
358+
// else: user value is non-object — skip it, system object survives.
366359
} else {
367360
mergedRecord[key] = userVal;
368361
}
@@ -618,7 +611,11 @@ export class Config {
618611
parsed.mdnsServiceName,
619612
systemParsed?.mdnsServiceName
620613
),
621-
serverSshHost: parsed.serverSshHost,
614+
serverSshHost: fb(
615+
parseOptionalNonEmptyString,
616+
parsed.serverSshHost,
617+
systemParsed?.serverSshHost
618+
),
622619
serverAuthGithubOwner: fb(
623620
parseOptionalNonEmptyString,
624621
parsed.serverAuthGithubOwner,
@@ -629,7 +626,11 @@ export class Config {
629626
parsed.defaultProjectDir,
630627
systemParsed?.defaultProjectDir
631628
),
632-
viewedSplashScreens: parsed.viewedSplashScreens,
629+
viewedSplashScreens: fbArray(
630+
parseOptionalStringArray,
631+
parsed.viewedSplashScreens,
632+
systemParsed?.viewedSplashScreens
633+
),
633634
layoutPresets,
634635
taskSettings,
635636
muxGatewayEnabled,
@@ -639,7 +640,14 @@ export class Config {
639640
agentAiDefaults,
640641
// Legacy fields are still parsed and returned for downgrade compatibility.
641642
subagentAiDefaults: legacySubagentAiDefaults,
642-
featureFlagOverrides: parsed.featureFlagOverrides,
643+
// featureFlagOverrides is a raw pass-through (no normalizer) — validate
644+
// that the merged value is actually a plain object before accepting it.
645+
featureFlagOverrides:
646+
parsed.featureFlagOverrides != null &&
647+
typeof parsed.featureFlagOverrides === "object" &&
648+
!Array.isArray(parsed.featureFlagOverrides)
649+
? parsed.featureFlagOverrides
650+
: systemParsed?.featureFlagOverrides,
643651
useSSH2Transport: fb(
644652
parseOptionalBoolean,
645653
parsed.useSSH2Transport,

0 commit comments

Comments
 (0)