feat(pluginhost): introduce browser-navigable plugin resources in Management API

- Added `resources` field in `management.register` for defining browser-accessible resources.
- Updated examples and documentation to reflect resource-based paths under `/v0/resource/plugins/<pluginID>/...`.
- Replaced legacy `GET` menu routes with resource-based implementations for consistent plugin behavior.
- Enhanced request handling for resource paths, including proper response headers and streamlined test coverage.
This commit is contained in:
Luis Pater
2026-06-09 22:46:27 +08:00
parent 2aeb41cecf
commit 44ea9abced
22 changed files with 342 additions and 70 deletions

View File

@@ -37,6 +37,7 @@ type Host struct {
commandLineFlags map[string]commandLineFlagRecord
commandLineHits map[string]struct{}
managementRoutes map[string]managementRouteRecord
resourceRoutes map[string]resourceRouteRecord
streams *streamBridge
httpStreams *hostHTTPStreamBridge
callbackContexts *callbackContextRegistry
@@ -58,6 +59,7 @@ func New() *Host {
commandLineFlags: make(map[string]commandLineFlagRecord),
commandLineHits: make(map[string]struct{}),
managementRoutes: make(map[string]managementRouteRecord),
resourceRoutes: make(map[string]resourceRouteRecord),
streams: newStreamBridge(),
httpStreams: newHostHTTPStreamBridge(),
callbackContexts: newCallbackContextRegistry(),
@@ -93,6 +95,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
h.runtimeConfig = cfg
if !rc.Enabled {
h.managementRoutes = make(map[string]managementRouteRecord)
h.resourceRoutes = make(map[string]resourceRouteRecord)
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()
h.refreshThinkingProviders(nil)
@@ -102,6 +106,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
files, errSelect := selectPluginFiles(rc.Dir)
if errSelect != nil {
log.Warnf("pluginhost: failed to select plugin files: %v", errSelect)
h.managementRoutes = make(map[string]managementRouteRecord)
h.resourceRoutes = make(map[string]resourceRouteRecord)
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()
h.refreshThinkingProviders(nil)
@@ -187,6 +193,7 @@ func (h *Host) ShutdownAll() {
h.commandLineFlags = make(map[string]commandLineFlagRecord)
h.commandLineHits = make(map[string]struct{})
h.managementRoutes = make(map[string]managementRouteRecord)
h.resourceRoutes = make(map[string]resourceRouteRecord)
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()

View File

@@ -12,20 +12,30 @@ import (
log "github.com/sirupsen/logrus"
)
const managementBasePath = "/v0/management"
const (
managementBasePath = "/v0/management"
resourcePluginBasePath = "/v0/resource/plugins"
legacyPluginRoutePrefix = "/plugins"
)
type managementRouteRecord struct {
pluginID string
route pluginapi.ManagementRoute
}
// RegisterManagementRoutes rebuilds the plugin-owned Management API route table.
type resourceRouteRecord struct {
pluginID string
route pluginapi.ResourceRoute
}
// RegisterManagementRoutes rebuilds the plugin-owned Management API and resource route tables.
func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string]struct{}) {
if h == nil {
return
}
nextRoutes := make(map[string]managementRouteRecord)
nextResources := make(map[string]resourceRouteRecord)
for _, record := range h.Snapshot().records {
plugin := record.plugin.Capabilities.ManagementAPI
if plugin == nil || h.isPluginFused(record.id) {
@@ -36,12 +46,19 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string
log.Warnf("pluginhost: management registrar %s failed: %v", record.id, errRegister)
continue
}
for _, item := range resp.Routes {
method, path, okRoute := normalizeManagementRoute(item)
if !okRoute {
log.Warnf("pluginhost: plugin %s declared invalid management route %s %s", record.id, item.Method, item.Path)
continue
}
if routeDeclaresLegacyMenuResource(method, item) {
if !registerResourceRoute(nextResources, record.id, resourceRouteFromManagementRoute(item)) {
log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path)
}
continue
}
key := managementRouteKey(method, path)
if _, exists := reserved[key]; exists {
log.Warnf("pluginhost: plugin %s management route %s conflicts with an existing route and was skipped", record.id, key)
@@ -58,10 +75,17 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string
route: item,
}
}
for _, item := range resp.Resources {
if !registerResourceRoute(nextResources, record.id, item) {
log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path)
}
}
}
h.mu.Lock()
h.managementRoutes = nextRoutes
h.resourceRoutes = nextResources
h.mu.Unlock()
}
@@ -77,8 +101,9 @@ func (h *Host) callManagementRegistrar(ctx context.Context, record capabilityRec
}
}()
return plugin.RegisterManagement(ctx, pluginapi.ManagementRegistrationRequest{
Plugin: record.meta,
BasePath: managementBasePath,
Plugin: record.meta,
BasePath: managementBasePath,
ResourceBasePath: resourcePluginBasePath + "/" + record.id,
})
}
@@ -118,6 +143,75 @@ func normalizeManagementRoute(item pluginapi.ManagementRoute) (string, string, b
return method, fullPath, true
}
func routeDeclaresLegacyMenuResource(method string, item pluginapi.ManagementRoute) bool {
return strings.EqualFold(strings.TrimSpace(method), http.MethodGet) && strings.TrimSpace(item.Menu) != ""
}
func resourceRouteFromManagementRoute(item pluginapi.ManagementRoute) pluginapi.ResourceRoute {
return pluginapi.ResourceRoute{
Path: item.Path,
Menu: item.Menu,
Description: item.Description,
Handler: item.Handler,
}
}
func registerResourceRoute(routes map[string]resourceRouteRecord, pluginID string, item pluginapi.ResourceRoute) bool {
path, okRoute := normalizeResourceRoute(pluginID, item)
if !okRoute {
return false
}
key := managementRouteKey(http.MethodGet, path)
if _, exists := routes[key]; exists {
log.Warnf("pluginhost: plugin %s resource route %s conflicts with a higher-priority plugin and was skipped", pluginID, key)
return true
}
item.Path = path
routes[key] = resourceRouteRecord{
pluginID: pluginID,
route: item,
}
return true
}
func normalizeResourceRoute(pluginID string, item pluginapi.ResourceRoute) (string, bool) {
if item.Handler == nil {
return "", false
}
pluginID = strings.TrimSpace(pluginID)
if pluginID == "" {
return "", false
}
path := strings.TrimSpace(item.Path)
if path == "" {
return "", false
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
pluginBasePath := resourcePluginBasePath + "/" + pluginID
if strings.HasPrefix(path, pluginBasePath+"/") {
path = strings.TrimPrefix(path, pluginBasePath)
} else if strings.HasPrefix(path, legacyPluginRoutePrefix+"/"+pluginID+"/") {
path = strings.TrimPrefix(path, legacyPluginRoutePrefix+"/"+pluginID)
}
path = strings.TrimRight(path, "/")
if path == "" {
return "", false
}
fullPath := pluginBasePath + path
if !strings.HasPrefix(fullPath, pluginBasePath+"/") {
return "", false
}
if strings.ContainsAny(fullPath, " \t\r\n") || strings.Contains(fullPath, ":") || strings.Contains(fullPath, "*") || strings.Contains(fullPath, "..") {
return "", false
}
return fullPath, true
}
func managementRouteKey(method, path string) string {
return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
}
@@ -178,6 +272,50 @@ func (h *Host) ServeManagementHTTP(w http.ResponseWriter, r *http.Request) bool
return true
}
// ServeResourceHTTP dispatches an unauthenticated browser-navigable resource request to a plugin route.
func (h *Host) ServeResourceHTTP(w http.ResponseWriter, r *http.Request) bool {
if h == nil || w == nil || r == nil || r.URL == nil {
return false
}
if !strings.EqualFold(r.Method, http.MethodGet) {
return false
}
key := managementRouteKey(http.MethodGet, r.URL.Path)
h.mu.Lock()
record, okRoute := h.resourceRoutes[key]
h.mu.Unlock()
if !okRoute || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
return false
}
resp, errHandle := h.callResourceHandler(r.Context(), record, pluginapi.ManagementRequest{
Method: http.MethodGet,
Path: r.URL.Path,
Headers: cloneHeader(r.Header),
Query: cloneValues(r.URL.Query()),
})
if errHandle != nil {
log.Warnf("pluginhost: resource handler %s failed: %v", record.pluginID, errHandle)
http.Error(w, "plugin resource handler failed", http.StatusBadGateway)
return true
}
for keyHeader, values := range resp.Headers {
for _, value := range values {
w.Header().Add(keyHeader, value)
}
}
statusCode := resp.StatusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
w.WriteHeader(statusCode)
if _, errWrite := w.Write(resp.Body); errWrite != nil {
log.Warnf("pluginhost: failed to write plugin resource response: %v", errWrite)
}
return true
}
func (h *Host) callManagementHandler(ctx context.Context, record managementRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
return pluginapi.ManagementResponse{}, nil
@@ -191,3 +329,17 @@ func (h *Host) callManagementHandler(ctx context.Context, record managementRoute
}()
return record.route.Handler.HandleManagement(ctx, req)
}
func (h *Host) callResourceHandler(ctx context.Context, record resourceRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
return pluginapi.ManagementResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.pluginID, "ResourceHandler.HandleManagement", recovered)
resp = pluginapi.ManagementResponse{}
err = fmt.Errorf("resource handler panic: %v", recovered)
}
}()
return record.route.Handler.HandleManagement(ctx, req)
}

View File

@@ -91,18 +91,77 @@ func TestManagementHandlerPanicFusesPlugin(t *testing.T) {
}
}
func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) {
plugin := &managementPluginDouble{
routes: []pluginapi.ManagementRoute{
{
Method: http.MethodGet,
Path: "/plugins/menu/status",
func TestServeResourceHTTPDispatchesPluginResource(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "resource",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ManagementAPI: &managementPluginDouble{resources: []pluginapi.ResourceRoute{{
Path: "/status",
Menu: "Status",
Description: "Shows plugin status.",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{}, nil
Handler: managementHandlerFunc(func(_ context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
if req.Path != "/v0/resource/plugins/resource/status" {
t.Fatalf("resource request path = %q, want normalized resource path", req.Path)
}
return pluginapi.ManagementResponse{
Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
Body: []byte("<!doctype html><title>resource</title>"),
}, nil
}),
},
}}},
}},
})
host.RegisterManagementRoutes(context.Background(), nil)
req := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/resource/status", nil)
rec := httptest.NewRecorder()
if !host.ServeResourceHTTP(rec, req) {
t.Fatal("ServeResourceHTTP() = false, want true")
}
if rec.Code != http.StatusOK || rec.Body.String() != "<!doctype html><title>resource</title>" {
t.Fatalf("response = %d %q, want 200 html", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
t.Fatalf("Content-Type = %q, want text/html; charset=utf-8", got)
}
}
func TestLegacyGETManagementMenuRegistersAsResource(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "legacy",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ManagementAPI: &managementPluginDouble{routes: []pluginapi.ManagementRoute{{
Method: http.MethodGet,
Path: "/plugins/legacy/status",
Menu: "Legacy Status",
Description: "Shows legacy plugin status.",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{Body: []byte("legacy")}, nil
}),
}}},
}},
})
host.RegisterManagementRoutes(context.Background(), nil)
managementReq := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/legacy/status", nil)
managementRec := httptest.NewRecorder()
if host.ServeManagementHTTP(managementRec, managementReq) {
t.Fatal("legacy menu route was served as Management API route")
}
resourceReq := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/legacy/status", nil)
resourceRec := httptest.NewRecorder()
if !host.ServeResourceHTTP(resourceRec, resourceReq) {
t.Fatal("legacy menu route was not served as resource route")
}
if resourceRec.Body.String() != "legacy" {
t.Fatalf("resource body = %q, want legacy", resourceRec.Body.String())
}
}
func TestRegisteredPluginsIncludesResourceMenus(t *testing.T) {
plugin := &managementPluginDouble{
routes: []pluginapi.ManagementRoute{
{
Method: http.MethodGet,
Path: "/plugins/menu/hidden",
@@ -110,11 +169,12 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) {
return pluginapi.ManagementResponse{}, nil
}),
},
},
resources: []pluginapi.ResourceRoute{
{
Method: http.MethodPost,
Path: "/plugins/menu/run",
Menu: "Run",
Description: "Runs a plugin action.",
Path: "/status",
Menu: "Status",
Description: "Shows plugin status.",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{}, nil
}),
@@ -136,17 +196,18 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) {
t.Fatalf("RegisteredPlugins()[0].Menus = %#v, want one visible GET menu", plugins[0].Menus)
}
menu := plugins[0].Menus[0]
if menu.Path != "/v0/management/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." {
if menu.Path != "/v0/resource/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." {
t.Fatalf("menu = %#v, want normalized status menu", menu)
}
}
type managementPluginDouble struct {
routes []pluginapi.ManagementRoute
routes []pluginapi.ManagementRoute
resources []pluginapi.ResourceRoute
}
func (p *managementPluginDouble) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) {
return pluginapi.ManagementRegistrationResponse{Routes: p.routes}, nil
return pluginapi.ManagementRegistrationResponse{Routes: p.routes, Resources: p.resources}, nil
}
type managementHandlerFunc func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error)

View File

@@ -498,7 +498,12 @@ func (a *rpcPluginAdapter) RegisterManagement(ctx context.Context, req pluginapi
route.Handler = a
routes = append(routes, route)
}
return pluginapi.ManagementRegistrationResponse{Routes: routes}, nil
resources := make([]pluginapi.ResourceRoute, 0, len(resp.Resources))
for _, route := range resp.Resources {
route.Handler = a
resources = append(resources, route)
}
return pluginapi.ManagementRegistrationResponse{Routes: routes, Resources: resources}, nil
}
func (a *rpcPluginAdapter) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {

View File

@@ -103,7 +103,8 @@ type rpcThinkingApplyRequest struct {
}
type rpcManagementRegistrationResponse struct {
Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
Resources []pluginapi.ResourceRoute `json:"resources,omitempty"`
}
type rpcEmptyResponse struct{}

View File

@@ -1,7 +1,6 @@
package pluginhost
import (
"net/http"
"sort"
"strings"
@@ -29,7 +28,7 @@ type RegisteredPluginInfo struct {
Menus []RegisteredPluginMenu
}
// RegisteredPluginMenu describes a plugin-owned GET Management API menu entry.
// RegisteredPluginMenu describes a plugin-owned resource menu entry.
type RegisteredPluginMenu struct {
Path string
Menu string
@@ -67,10 +66,7 @@ func (h *Host) registeredPluginMenus() map[string][]RegisteredPluginMenu {
}
h.mu.Lock()
defer h.mu.Unlock()
for _, record := range h.managementRoutes {
if !strings.EqualFold(strings.TrimSpace(record.route.Method), http.MethodGet) {
continue
}
for _, record := range h.resourceRoutes {
menu := strings.TrimSpace(record.route.Menu)
if menu == "" {
continue