Skip to content

Commit 2ca5b9c

Browse files
Roman Chernyakclaude
andcommitted
fix(config): copy all ServerConfig fields in CopyServerConfig
CopyServerConfig was hand-copying a subset of ServerConfig fields and had drifted as new fields were added — it silently omitted EnabledTools, DisabledTools, ReconnectOnUse, HealthCheckInterval, ToolDiscoveryInterval, SourceRegistryID, SourceRegistryProvenance, and AuthBroker. Because MergeServerConfig starts from CopyServerConfig(base), any config merge/patch (e.g. upstream_servers patch, the Web UI server-config edit) round-trips the server through CopyServerConfig and drops these fields. The user-visible symptom: editing an unrelated field on a server that has a disabled_tools / enabled_tools allowlist, a reconnect-on-use flag, or per-server health/discovery cadence overrides silently wipes those settings. Copy the missing fields: scalars and slices directly (deep-copying the allowlist slices like Args), the *Duration overrides by value (matching the AutoApproveToolChanges pattern), and the AuthBroker block by value (a no-op empty stub in the personal edition). Tests: TestCopyServerConfig_PreservesAllowlistAndOverrides asserts each field is copied and that slices/pointers are independent of the source; TestMergeServerConfig_PreservesDisabledToolsOnUnrelatedPatch is the end-to-end regression for the patch path. Note: the same record<->config field mapping is hand-duplicated in the storage layer (SaveUpstreamServer / GetUpstreamServer / ListUpstreamServers / ListQuarantinedUpstreamServers); ListQuarantinedUpstreamServers has drifted similarly. A follow-up could collapse all of these onto a single helper to prevent the next field from drifting again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d578440 commit 2ca5b9c

2 files changed

Lines changed: 112 additions & 12 deletions

File tree

internal/config/merge.go

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -528,25 +528,36 @@ func CopyServerConfig(src *ServerConfig) *ServerConfig {
528528
}
529529

530530
dst := &ServerConfig{
531-
Name: src.Name,
532-
URL: src.URL,
533-
Protocol: src.Protocol,
534-
Command: src.Command,
535-
WorkingDir: src.WorkingDir,
536-
Enabled: src.Enabled,
537-
Quarantined: src.Quarantined,
538-
SkipQuarantine: src.SkipQuarantine,
539-
Shared: src.Shared,
540-
Created: src.Created,
541-
Updated: src.Updated,
542-
LauncherWaitTimeout: src.LauncherWaitTimeout,
531+
Name: src.Name,
532+
URL: src.URL,
533+
Protocol: src.Protocol,
534+
Command: src.Command,
535+
WorkingDir: src.WorkingDir,
536+
Enabled: src.Enabled,
537+
Quarantined: src.Quarantined,
538+
SkipQuarantine: src.SkipQuarantine,
539+
Shared: src.Shared,
540+
Created: src.Created,
541+
Updated: src.Updated,
542+
LauncherWaitTimeout: src.LauncherWaitTimeout,
543+
ReconnectOnUse: src.ReconnectOnUse,
544+
SourceRegistryID: src.SourceRegistryID,
545+
SourceRegistryProvenance: src.SourceRegistryProvenance,
543546
}
544547

545548
// Copy slices
546549
if src.Args != nil {
547550
dst.Args = make([]string, len(src.Args))
548551
copy(dst.Args, src.Args)
549552
}
553+
if src.EnabledTools != nil {
554+
dst.EnabledTools = make([]string, len(src.EnabledTools))
555+
copy(dst.EnabledTools, src.EnabledTools)
556+
}
557+
if src.DisabledTools != nil {
558+
dst.DisabledTools = make([]string, len(src.DisabledTools))
559+
copy(dst.DisabledTools, src.DisabledTools)
560+
}
550561

551562
// Copy maps
552563
if src.Env != nil {
@@ -568,6 +579,24 @@ func CopyServerConfig(src *ServerConfig) *ServerConfig {
568579
dst.AutoApproveToolChanges = &autoApprove
569580
}
570581

582+
// Copy *Duration overrides by value to avoid shared pointer state (spec 074)
583+
if src.HealthCheckInterval != nil {
584+
hc := *src.HealthCheckInterval
585+
dst.HealthCheckInterval = &hc
586+
}
587+
if src.ToolDiscoveryInterval != nil {
588+
td := *src.ToolDiscoveryInterval
589+
dst.ToolDiscoveryInterval = &td
590+
}
591+
592+
// Copy the per-upstream auth-broker block by value (spec 074, server edition).
593+
// In the personal edition AuthBrokerConfig is an empty stub struct, so this is
594+
// a no-op there; copying by value keeps the pointer from being shared.
595+
if src.AuthBroker != nil {
596+
broker := *src.AuthBroker
597+
dst.AuthBroker = &broker
598+
}
599+
571600
// Copy nested structs
572601
dst.Isolation = copyIsolationConfig(src.Isolation)
573602
dst.OAuth = copyOAuthConfig(src.OAuth)

internal/config/merge_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,3 +997,74 @@ func TestCopyServerConfig_AllFields(t *testing.T) {
997997
t.Error("CopyServerConfig(nil) should return nil")
998998
}
999999
}
1000+
1001+
// TestCopyServerConfig_PreservesAllowlistAndOverrides guards the fields that
1002+
// were silently dropped by CopyServerConfig before this fix: the per-tool
1003+
// allowlist/denylist, ReconnectOnUse, the spec-074 *Duration overrides, and the
1004+
// source-registry provenance. A drop here loses a server's disabled_tools and
1005+
// per-server cadence overrides on any config merge/patch round-trip.
1006+
func TestCopyServerConfig_PreservesAllowlistAndOverrides(t *testing.T) {
1007+
hc := Duration(30 * time.Second)
1008+
td := Duration(5 * time.Minute)
1009+
src := &ServerConfig{
1010+
Name: "srv",
1011+
ReconnectOnUse: true,
1012+
EnabledTools: []string{"a", "b"},
1013+
DisabledTools: []string{"c"},
1014+
HealthCheckInterval: &hc,
1015+
ToolDiscoveryInterval: &td,
1016+
SourceRegistryID: "reg-1",
1017+
SourceRegistryProvenance: "official",
1018+
}
1019+
1020+
dst := CopyServerConfig(src)
1021+
1022+
if !dst.ReconnectOnUse {
1023+
t.Error("ReconnectOnUse was dropped")
1024+
}
1025+
if dst.SourceRegistryID != "reg-1" {
1026+
t.Errorf("SourceRegistryID: got %q, want %q", dst.SourceRegistryID, "reg-1")
1027+
}
1028+
if dst.SourceRegistryProvenance != "official" {
1029+
t.Errorf("SourceRegistryProvenance: got %q, want %q", dst.SourceRegistryProvenance, "official")
1030+
}
1031+
if !reflect.DeepEqual(dst.EnabledTools, src.EnabledTools) {
1032+
t.Errorf("EnabledTools: got %v, want %v", dst.EnabledTools, src.EnabledTools)
1033+
}
1034+
if !reflect.DeepEqual(dst.DisabledTools, src.DisabledTools) {
1035+
t.Errorf("DisabledTools: got %v, want %v", dst.DisabledTools, src.DisabledTools)
1036+
}
1037+
if dst.HealthCheckInterval == nil || *dst.HealthCheckInterval != hc {
1038+
t.Errorf("HealthCheckInterval: got %v, want %v", dst.HealthCheckInterval, hc)
1039+
}
1040+
if dst.ToolDiscoveryInterval == nil || *dst.ToolDiscoveryInterval != td {
1041+
t.Errorf("ToolDiscoveryInterval: got %v, want %v", dst.ToolDiscoveryInterval, td)
1042+
}
1043+
1044+
// Deep copy: mutating the source must not affect the copy.
1045+
src.DisabledTools[0] = "mutated"
1046+
if dst.DisabledTools[0] == "mutated" {
1047+
t.Error("DisabledTools is aliased, not deep-copied")
1048+
}
1049+
*src.HealthCheckInterval = Duration(time.Hour)
1050+
if *dst.HealthCheckInterval == Duration(time.Hour) {
1051+
t.Error("HealthCheckInterval pointer is shared, not copied by value")
1052+
}
1053+
}
1054+
1055+
// TestMergeServerConfig_PreservesDisabledToolsOnUnrelatedPatch is the
1056+
// end-to-end regression: patching an unrelated field on a server that has a
1057+
// disabled_tools denylist must not wipe the denylist (it previously did,
1058+
// because MergeServerConfig starts from CopyServerConfig(base)).
1059+
func TestMergeServerConfig_PreservesDisabledToolsOnUnrelatedPatch(t *testing.T) {
1060+
base := &ServerConfig{Name: "srv", DisabledTools: []string{"danger"}}
1061+
patch := &ServerConfig{Env: map[string]string{"K": "V"}}
1062+
1063+
merged, _, err := MergeServerConfig(base, patch, MergeOptions{})
1064+
if err != nil {
1065+
t.Fatalf("MergeServerConfig: %v", err)
1066+
}
1067+
if len(merged.DisabledTools) != 1 || merged.DisabledTools[0] != "danger" {
1068+
t.Errorf("DisabledTools dropped on unrelated patch: got %v, want [danger]", merged.DisabledTools)
1069+
}
1070+
}

0 commit comments

Comments
 (0)