diff --git a/CHANGELOG.md b/CHANGELOG.md index b274ee9c..1fe30448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Fixed + +- **Plugin CLI overlays** — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict. + ## [1.0.53] - 2026-07-21 This release promotes the validated `v1.0.53-beta.7` baseline to stable. It adds enterprise onboarding, declarative shortcuts, Sheet/Aitable writes, multi-account profiles, and broader personal IM events, while hardening authentication and the guarded release path. diff --git a/internal/app/plugin_registration_residual_test.go b/internal/app/plugin_registration_residual_test.go index ea943d7b..ffbf83ad 100644 --- a/internal/app/plugin_registration_residual_test.go +++ b/internal/app/plugin_registration_residual_test.go @@ -163,6 +163,73 @@ func TestPluginDescriptorBlankIdentityAndDistributionOwnership(t *testing.T) { } } +func TestReplaceableFallbackIdentitySurvivesDistributionConflictChecks(t *testing.T) { + isolatePluginRuntime(t) + SetDynamicServers([]mcptypes.ServerDescriptor{ + { + Key: "conference", + Endpoint: "https://example.com/conference/mcp", + CLI: mcptypes.CLIOverlay{ID: "conference"}, + }, + { + Key: "chat", + Endpoint: "https://example.com/chat/mcp", + CLI: mcptypes.CLIOverlay{ID: "chat"}, + }, + }) + + root := &cobra.Command{Use: "dws"} + root.AddCommand(&cobra.Command{Use: "conference", Hidden: true}) + distributionProducts := DirectRuntimeProductIDs() + + conferenceDescriptor := mcptypes.ServerDescriptor{ + Key: "conference-local", + DisplayName: "conference/conference-local", + CLI: mcptypes.CLIOverlay{ID: "conference-local", Command: "conference"}, + } + if pluginDescriptorConflictsWithDistribution(root, conferenceDescriptor, distributionProducts) { + t.Fatal("replaceable fallback identity blocked plugin server selection") + } + + chatDescriptor := mcptypes.ServerDescriptor{ + Key: "chat-local", + DisplayName: "chat/chat-local", + CLI: mcptypes.CLIOverlay{ID: "chat-local", Command: "chat"}, + } + if !pluginDescriptorConflictsWithDistribution(root, chatDescriptor, distributionProducts) { + t.Fatal("non-replaceable distribution product no longer conflicts") + } + + reservedDescriptor := mcptypes.ServerDescriptor{ + Key: "auth-local", + DisplayName: "auth/auth-local", + CLI: mcptypes.CLIOverlay{ID: "auth-local", Command: "auth"}, + } + if !pluginDescriptorConflictsWithDistribution(root, reservedDescriptor, distributionProducts) { + t.Fatal("reserved command name no longer conflicts") + } + + first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "conference"}} + second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "other"}} + accepted := selectPluginServerCandidates(root, []pluginServerCandidate{ + {owner: first, descriptor: conferenceDescriptor}, + { + owner: second, + descriptor: mcptypes.ServerDescriptor{ + Key: "conference-other", + DisplayName: "other/conference-other", + CLI: mcptypes.CLIOverlay{ID: "conference-other", Command: "conference"}, + }, + }, + }) + if len(accepted) != 1 { + t.Fatalf("accepted candidates = %d, want the first conference plugin only", len(accepted)) + } + if accepted[0].owner != first { + t.Fatalf("accepted owner = %q, want the first conference plugin", accepted[0].owner.Manifest.Name) + } +} + func TestAddPluginCommandsSafeFiltersConflictingAliases(t *testing.T) { root := &cobra.Command{Use: "dws"} root.AddCommand(&cobra.Command{Use: "taken"}) diff --git a/internal/app/root.go b/internal/app/root.go index e6707d27..2d9db090 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -1026,6 +1026,9 @@ func selectPluginServerCandidates( distributionProducts := DirectRuntimeProductIDs() owners := make(map[string]pluginIdentityOwner) for identity := range distributionProducts { + if replaceablePluginFallbacks[identity] { + continue + } owners[identity] = pluginIdentityOwner{serverKey: "distribution"} } @@ -1134,6 +1137,12 @@ func pluginDescriptorConflictsWithDistribution( if candidate == "" { continue } + if !reservedCommands[candidate] && replaceablePluginFallbacks[candidate] { + // The distribution ships only a hidden compatibility fallback for + // this name; plugins may claim it and the later command merge in + // addPluginCommandsSafe still rejects visible non-fallback owners. + continue + } if reservedCommands[candidate] || distributionProducts[candidate] || distributionRootOwns(root, candidate) {