fix(plugins): expose saved plugin config

This commit is contained in:
LTbinglingfeng
2026-06-13 04:51:07 +08:00
parent b2b5d10b75
commit b60ec43944
5 changed files with 262 additions and 0 deletions

View File

@@ -183,6 +183,7 @@ The native plugin management endpoints remain:
```text
GET /v0/management/plugins
PATCH /v0/management/plugins/{pluginID}/enabled
GET /v0/management/plugins/{pluginID}/config
PUT /v0/management/plugins/{pluginID}/config
PATCH /v0/management/plugins/{pluginID}/config
```

View File

@@ -149,6 +149,50 @@ func (h *Handler) ListPlugins(c *gin.Context) {
})
}
// GetPluginConfig returns the preserved plugins.configs.<id> object as JSON.
func (h *Handler) GetPluginConfig(c *gin.Context) {
id, okID := pluginIDFromRequest(c)
if !okID {
return
}
if h == nil || h.cfg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"})
return
}
h.mu.Lock()
item, configured := h.cfg.Plugins.Configs[id]
pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir)
host := h.pluginHost
h.mu.Unlock()
if configured {
body, errBody := pluginConfigJSONObject(item)
if errBody != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_config_encode_failed", "message": errBody.Error()})
return
}
c.JSON(http.StatusOK, body)
return
}
if pluginRegistered(host, id) {
c.JSON(http.StatusOK, gin.H{})
return
}
discovered, errDiscover := pluginDiscovered(pluginsDir, id)
if errDiscover != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errDiscover.Error()})
return
}
if discovered {
c.JSON(http.StatusOK, gin.H{})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"})
}
// PatchPluginEnabled updates plugins.configs.<id>.enabled without touching plugins.enabled.
func (h *Handler) PatchPluginEnabled(c *gin.Context) {
id, okID := pluginIDFromRequest(c)
@@ -263,6 +307,31 @@ func pluginInstanceEnabled(item config.PluginInstanceConfig) bool {
return *item.Enabled
}
func pluginRegistered(host *pluginhost.Host, id string) bool {
if host == nil {
return false
}
for _, info := range host.RegisteredPlugins() {
if info.ID == id {
return true
}
}
return false
}
func pluginDiscovered(pluginsDir string, id string) (bool, error) {
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir)
if errDiscover != nil {
return false, errDiscover
}
for _, file := range files {
if file.ID == id {
return true, nil
}
}
return false, nil
}
func pluginConfigFields(fields []pluginapi.ConfigField) []pluginConfigFieldInfo {
out := make([]pluginConfigFieldInfo, 0, len(fields))
for _, field := range fields {
@@ -344,6 +413,18 @@ func pluginConfigNode(item config.PluginInstanceConfig) *yaml.Node {
return node
}
func pluginConfigJSONObject(item config.PluginInstanceConfig) (map[string]any, error) {
value, errValue := yamlNodeToJSONValue(pluginConfigNode(item))
if errValue != nil {
return nil, errValue
}
body, ok := value.(map[string]any)
if !ok || body == nil {
return map[string]any{}, nil
}
return body, nil
}
func pluginInstanceConfigFromNode(node *yaml.Node) (config.PluginInstanceConfig, error) {
if node == nil {
node = emptyYAMLMappingNode()
@@ -407,6 +488,52 @@ func yamlNodeFromJSONValue(value any) (*yaml.Node, error) {
}
}
func yamlNodeToJSONValue(node *yaml.Node) (any, error) {
if node == nil {
return nil, nil
}
switch node.Kind {
case yaml.MappingNode:
out := make(map[string]any, len(node.Content)/2)
for index := 0; index+1 < len(node.Content); index += 2 {
key := node.Content[index]
value := node.Content[index+1]
if key == nil {
continue
}
child, errChild := yamlNodeToJSONValue(value)
if errChild != nil {
return nil, fmt.Errorf("%s: %w", key.Value, errChild)
}
out[key.Value] = child
}
return out, nil
case yaml.SequenceNode:
out := make([]any, 0, len(node.Content))
for _, childNode := range node.Content {
child, errChild := yamlNodeToJSONValue(childNode)
if errChild != nil {
return nil, errChild
}
out = append(out, child)
}
return out, nil
case yaml.ScalarNode:
if node.Tag == "!!str" || node.Tag == "" {
return node.Value, nil
}
var value any
if errDecode := node.Decode(&value); errDecode != nil {
return nil, errDecode
}
return value, nil
case yaml.AliasNode:
return yamlNodeToJSONValue(node.Alias)
default:
return nil, fmt.Errorf("unsupported YAML node kind %d", node.Kind)
}
}
func emptyYAMLMappingNode() *yaml.Node {
return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
}

View File

@@ -102,6 +102,117 @@ func TestListPluginsIncludesScannedAndConfiguredPlugins(t *testing.T) {
}
}
func TestGetPluginConfigReturnsPreservedRawConfig(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Configs: map[string]config.PluginInstanceConfig{
"sample": pluginConfigFromYAML(t, `
enabled: false
priority: 7
mode: safe
allowed_models:
- gemini-2.5-pro
- claude-sonnet-4
options:
retries: 2
strict: true
`),
},
},
},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "sample"}}
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins/sample/config", nil)
h.GetPluginConfig(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var body struct {
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
Mode string `json:"mode"`
AllowedModels []string `json:"allowed_models"`
Options map[string]any `json:"options"`
}
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
t.Fatalf("decode response: %v; body=%s", errDecode, rec.Body.String())
}
if body.Enabled || body.Priority != 7 || body.Mode != "safe" {
t.Fatalf("base fields = enabled %v priority %d mode %q, want false 7 safe", body.Enabled, body.Priority, body.Mode)
}
if len(body.AllowedModels) != 2 || body.AllowedModels[0] != "gemini-2.5-pro" || body.AllowedModels[1] != "claude-sonnet-4" {
t.Fatalf("allowed_models = %#v", body.AllowedModels)
}
if body.Options["retries"] != float64(2) || body.Options["strict"] != true {
t.Fatalf("options = %#v", body.Options)
}
}
func TestGetPluginConfigReturnsEmptyObjectForKnownUnconfiguredPlugin(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
pluginsDir := writeManagementPluginFile(t, "scanned")
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Dir: pluginsDir,
},
},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "scanned"}}
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins/scanned/config", nil)
h.GetPluginConfig(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var body map[string]any
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
t.Fatalf("decode response: %v; body=%s", errDecode, rec.Body.String())
}
if len(body) != 0 {
t.Fatalf("body = %#v, want empty object", body)
}
}
func TestGetPluginConfigReturnsNotFoundForUnknownPlugin(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "missing"}}
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins/missing/config", nil)
h.GetPluginConfig(c)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
}
}
func TestPatchPluginEnabledUpdatesOnlyPluginConfig(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)

View File

@@ -615,6 +615,7 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/plugin-store", s.mgmt.ListPluginStore)
mgmt.POST("/plugin-store/:id/install", s.mgmt.InstallPluginFromStore)
mgmt.PATCH("/plugins/:id/enabled", s.mgmt.PatchPluginEnabled)
mgmt.GET("/plugins/:id/config", s.mgmt.GetPluginConfig)
mgmt.PUT("/plugins/:id/config", s.mgmt.PutPluginConfig)
mgmt.PATCH("/plugins/:id/config", s.mgmt.PatchPluginConfig)

View File

@@ -182,6 +182,10 @@ func TestManagementPluginsRouteRegistered(t *testing.T) {
t.Setenv("MANAGEMENT_PASSWORD", "test-management-key")
server := newTestServer(t)
enabled := true
server.cfg.Plugins.Configs = map[string]proxyconfig.PluginInstanceConfig{
"sample": {Enabled: &enabled, Priority: 4},
}
req := httptest.NewRequest(http.MethodGet, "/v0/management/plugins", nil)
req.Header.Set("Authorization", "Bearer test-management-key")
@@ -202,6 +206,24 @@ func TestManagementPluginsRouteRegistered(t *testing.T) {
if payload.Plugins == nil {
t.Fatalf("plugins field = nil, want array; body=%s", rr.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/v0/management/plugins/sample/config", nil)
req.Header.Set("Authorization", "Bearer test-management-key")
rr = httptest.NewRecorder()
server.engine.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("config status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
}
var configPayload struct {
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
}
if errUnmarshal := json.Unmarshal(rr.Body.Bytes(), &configPayload); errUnmarshal != nil {
t.Fatalf("unmarshal config response: %v body=%s", errUnmarshal, rr.Body.String())
}
if !configPayload.Enabled || configPayload.Priority != 4 {
t.Fatalf("plugin config = %#v, want enabled true priority 4", configPayload)
}
}
func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) {