diff --git a/examples/plugin/simple/README.md b/examples/plugin/simple/README.md index b8a8895e8..bf2f4966c 100644 --- a/examples/plugin/simple/README.md +++ b/examples/plugin/simple/README.md @@ -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 ``` diff --git a/internal/api/handlers/management/plugins.go b/internal/api/handlers/management/plugins.go index 6896265d8..0665a01b4 100644 --- a/internal/api/handlers/management/plugins.go +++ b/internal/api/handlers/management/plugins.go @@ -149,6 +149,50 @@ func (h *Handler) ListPlugins(c *gin.Context) { }) } +// GetPluginConfig returns the preserved plugins.configs. 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..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"} } diff --git a/internal/api/handlers/management/plugins_test.go b/internal/api/handlers/management/plugins_test.go index 7506cebf6..b88c2c567 100644 --- a/internal/api/handlers/management/plugins_test.go +++ b/internal/api/handlers/management/plugins_test.go @@ -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) diff --git a/internal/api/server.go b/internal/api/server.go index f7bed664e..d6e2fb83c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 3556a581d..5669c07ba 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -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) {