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

@@ -20,8 +20,8 @@ This directory contains standard dynamic library plugin examples for the CLIProx
- `thinking/`: thinking applier capability only.
- `usage/`: usage observer capability only.
- `cli/`: command-line capability only.
- `management-api/`: Management API capability only.
- `host-callback/`: minimal Management API route that demonstrates host callbacks.
- `management-api/`: Management API and resource capability only.
- `host-callback/`: minimal plugin resource that demonstrates host callbacks.
Most standard capability examples contain `go/`, `c/`, and `rust/` subdirectories. Specialized examples may provide only the implementation language they need.
@@ -68,4 +68,6 @@ Artifacts are written to `examples/plugin/bin`.
`protocol-format` uses a minimal executor because format declarations belong to executor capabilities.
`host-callback` uses a minimal Management API route because host callbacks are invoked from plugin methods and are not standalone capabilities.
`host-callback` uses a minimal plugin resource because host callbacks are invoked from plugin methods and are not standalone capabilities.
Menu resources returned by `management.register` through the `resources` field are exposed by CPA under `/v0/resource/plugins/<pluginID>/...`. Authenticated plugin Management API routes remain under `/v0/management/...`.

View File

@@ -20,8 +20,8 @@
- `thinking/`:只演示 Thinking 处理能力。
- `usage/`:只演示 Usage 观察能力。
- `cli/`:只演示命令行扩展能力。
- `management-api/`:只演示 Management API 扩展能力。
- `host-callback/`:使用最小 Management API 路由演示宿主回调。
- `management-api/`:只演示 Management API 和资源扩展能力。
- `host-callback/`:使用最小插件资源演示宿主回调。
多数标准能力示例都包含 `go/``c/``rust/` 三个子目录。专用示例可能只提供所需的实现语言。
@@ -68,4 +68,6 @@ make -C examples/plugin build
`protocol-format` 使用最小执行器承载,因为格式声明属于执行器能力。
`host-callback` 使用最小 Management API 路由承载,因为宿主回调只能从插件方法内部发起,不是独立能力。
`host-callback` 使用最小插件资源承载,因为宿主回调只能从插件方法内部发起,不是独立能力。
`management.register` 通过 `resources` 字段返回的菜单资源会由 CPA 暴露在 `/v0/resource/plugins/<pluginID>/...` 下。需要认证的插件自有 Management API 路由仍保留在 `/v0/management/...` 下。

View File

@@ -84,14 +84,14 @@ static int plugin_call(const char* method, const uint8_t* request, size_t reques
return 0;
}
if (strcmp(method, "management.register") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-host-callback-c/status\",\"Menu\":\"Host Callback\",\"Description\":\"Host callback example carried by a minimal Management API route.\"}]}}");
write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-c/status.\"}]}}");
return 0;
}
if (strcmp(method, "management.handle") == 0) {
call_host("host.log", "{\"level\":\"info\",\"message\":\"example-host-callback-c host callback log\",\"fields\":{\"plugin\":\"example-host-callback-c\"}}");
call_host("host.http.do", "{\"method\":\"GET\",\"url\":\"https://example.com\",\"headers\":{\"user-agent\":[\"example-host-callback-c\"]}}");
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stYyJ9\"}}");
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}}");
return 0;
}
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");

View File

@@ -131,11 +131,11 @@ func handleMethod(method string) ([]byte, error) {
case "plugin.reconfigure":
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}")
case "management.register":
return okEnvelopeJSON("{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-host-callback-go/status\",\"Menu\":\"Host Callback\",\"Description\":\"Host callback example carried by a minimal Management API route.\"}]}")
return okEnvelopeJSON("{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-go/status.\"}]}")
case "management.handle":
callHost("host.log", []byte(`{"level":"info","message":"example-host-callback-go host callback log","fields":{"plugin":"example-host-callback-go"}}`))
callHost("host.http.do", []byte(`{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-go"]}}`))
return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stZ28ifQ==\"}")
return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}")
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}

View File

@@ -68,10 +68,10 @@ unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, requ
let _ = request;
let _ = request_len;
match method {
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"management.register" => { write_response(response, "{\"ok\":true,\"result\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-host-callback-rust/status\",\"Menu\":\"Host Callback\",\"Description\":\"Host callback example carried by a minimal Management API route.\"}]}}"); 0 },"management.handle" => {
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"management.register" => { write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-rust/status.\"}]}}"); 0 },"management.handle" => {
call_host("host.log", r#"{"level":"info","message":"example-host-callback-rust host callback log","fields":{"plugin":"example-host-callback-rust"}}"#);
call_host("host.http.do", r#"{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-rust"]}}"#);
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stcnVzdCJ9\"}}"); 0 },
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}}"); 0 },
_ => {
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
0

View File

@@ -84,11 +84,11 @@ static int plugin_call(const char* method, const uint8_t* request, size_t reques
return 0;
}
if (strcmp(method, "management.register") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-c/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}}");
write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Management API\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-management-api-c/status.\"}]}}");
return 0;
}
if (strcmp(method, "management.handle") == 0) {
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLWMifQ==\"}}");
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}}");
return 0;
}
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");

View File

@@ -131,9 +131,9 @@ func handleMethod(method string) ([]byte, error) {
case "plugin.reconfigure":
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}")
case "management.register":
return okEnvelopeJSON("{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-go/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}")
return okEnvelopeJSON("{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Management API\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-management-api-go/status.\"}]}")
case "management.handle":
return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLWdvIn0=\"}")
return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}")
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}

View File

@@ -68,7 +68,7 @@ unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, requ
let _ = request;
let _ = request_len;
match method {
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"management.register" => { write_response(response, "{\"ok\":true,\"result\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-rust/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}}"); 0 },"management.handle" => { write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLXJ1c3QifQ==\"}}"); 0 },
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"management.register" => { write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Management API\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-management-api-rust/status.\"}]}}"); 0 },"management.handle" => { write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}}"); 0 },
_ => {
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
0

View File

@@ -187,7 +187,9 @@ PUT /v0/management/plugins/{pluginID}/config
PATCH /v0/management/plugins/{pluginID}/config
```
Plugin-owned Management API routes are registered through `management.register` and handled through `management.handle`.
Plugin-owned Management API routes are registered through the `routes` field of `management.register` and handled through `management.handle`.
Browser-navigable menu resources are registered through the `resources` field of `management.register`. CPA exposes those resources under `/v0/resource/plugins/<pluginID>/...`; for example, a plugin with ID `example` and resource path `/status` is served as `/v0/resource/plugins/example/status`.
## Trust Boundary

View File

@@ -185,7 +185,9 @@ PUT /v0/management/plugins/{pluginID}/config
PATCH /v0/management/plugins/{pluginID}/config
```
插件自有 Management API 路由通过 `management.register` 注册,通过 `management.handle` 处理。
插件自有 Management API 路由通过 `management.register` `routes` 字段注册,通过 `management.handle` 处理。
可由浏览器直接访问的菜单资源通过 `management.register``resources` 字段注册。CPA 会将这些资源暴露在 `/v0/resource/plugins/<pluginID>/...` 下;例如插件 ID 为 `example` 且资源路径为 `/status` 时,最终路径是 `/v0/resource/plugins/example/status`
## 信任边界

View File

@@ -84,8 +84,8 @@ static const char* CLI_REGISTER_RESPONSE =
static const char* CLI_EXECUTE_RESPONSE =
"{\"ok\":true,\"result\":{\"Stdout\":\"cGx1Z2luIGV4YW1wbGUgYyBjb21tYW5kCg==\",\"ExitCode\":0}}";
static const char* MANAGEMENT_REGISTER_RESPONSE =
"{\"ok\":true,\"result\":{\"Routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-c/status\","
"\"Menu\":\"Example C Plugin\",\"Description\":\"Shows example C plugin status.\"}]}}";
"{\"ok\":true,\"result\":{\"Resources\":[{\"Path\":\"/status\","
"\"Menu\":\"Example C Plugin\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-c/status.\"}]}}";
static const char* UNKNOWN_METHOD_RESPONSE =
"{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}";
static const char* INVALID_METHOD_RESPONSE =
@@ -421,7 +421,7 @@ static char* make_http_response(const uint8_t* request, size_t request_len) {
char* url = extract_json_string(json, "URL");
char* path = extract_json_string(json, "Path");
char* method_escaped = json_escape(method == NULL ? "GET" : method);
char* target_escaped = json_escape(url != NULL ? url : (path == NULL ? "/plugins/example-c/status" : path));
char* target_escaped = json_escape(url != NULL ? url : (path == NULL ? "/v0/resource/plugins/example-c/status" : path));
char* body_json = format_string(
"{\"plugin\":\"example-c\",\"method\":\"%s\",\"target\":\"%s\"}",
method_escaped == NULL ? "" : method_escaped,

View File

@@ -100,7 +100,8 @@ type streamResponse struct {
}
type managementRegistrationResponse struct {
Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
Resources []pluginapi.ResourceRoute `json:"resources,omitempty"`
}
func main() {}
@@ -207,14 +208,18 @@ func handleMethod(method string, request []byte) ([]byte, error) {
case pluginabi.MethodCommandLineExecute:
return okEnvelope(pluginapi.CommandLineExecutionResponse{Stdout: []byte("plugin example command\n")})
case pluginabi.MethodManagementRegister:
return okEnvelope(managementRegistrationResponse{Routes: []pluginapi.ManagementRoute{{
Method: http.MethodGet,
Path: "/plugins/example/status",
// CPA exposes menu resources under /v0/resource/plugins/<plugin-id>/.
return okEnvelope(managementRegistrationResponse{Resources: []pluginapi.ResourceRoute{{
Path: "/status",
Menu: "Example Plugin",
Description: "Shows example plugin status.",
Description: "Shows example plugin status as a browser-navigable resource.",
}}})
case pluginabi.MethodManagementHandle:
return okEnvelope(pluginapi.ManagementResponse{StatusCode: http.StatusOK, Body: []byte(`{"plugin":"example"}`)})
return okEnvelope(pluginapi.ManagementResponse{
StatusCode: http.StatusOK,
Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
Body: []byte(`<!doctype html><title>Example Plugin</title><main>Example Plugin</main>`),
})
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}

View File

@@ -18,7 +18,7 @@ const FRONTEND_AUTH_RESPONSE: &str = r#"{"ok":true,"result":{"Authenticated":tru
const STREAM_RESPONSE: &str = r#"{"ok":true,"result":{"headers":{"content-type":["text/event-stream"]},"chunks":[{"Payload":"cGx1Z2luLWV4YW1wbGUtcnVzdAo="}]}}"#;
const CLI_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Flags":[{"Name":"plugin-example-rust-command","Usage":"Run the example Rust ABI plugin command","Type":"bool"}]}}"#;
const CLI_EXECUTE_RESPONSE: &str = r#"{"ok":true,"result":{"Stdout":"cGx1Z2luIGV4YW1wbGUgcnVzdCBjb21tYW5kCg==","ExitCode":0}}"#;
const MANAGEMENT_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Routes":[{"Method":"GET","Path":"/plugins/example-rust/status","Menu":"Example Rust Plugin","Description":"Shows example Rust plugin status."}]}}"#;
const MANAGEMENT_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Resources":[{"Path":"/status","Menu":"Example Rust Plugin","Description":"CPA exposes this menu resource under /v0/resource/plugins/example-rust/status."}]}}"#;
const UNKNOWN_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#;
const INVALID_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#;
@@ -170,7 +170,7 @@ fn make_http_response(request: &[u8]) -> String {
let method = extract_json_string(&json, "Method").unwrap_or_else(|| "GET".to_string());
let target = extract_json_string(&json, "URL")
.or_else(|| extract_json_string(&json, "Path"))
.unwrap_or_else(|| "/plugins/example-rust/status".to_string());
.unwrap_or_else(|| "/v0/resource/plugins/example-rust/status".to_string());
let body = format!(
r#"{{"plugin":"example-rust","method":"{}","target":"{}"}}"#,
json_escape(&method),

View File

@@ -382,7 +382,7 @@ func (s *Server) homeHeartbeatMiddleware() gin.HandlerFunc {
}
if c != nil && c.Request != nil {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || path == "/management.html" {
if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || strings.HasPrefix(path, "/v0/resource/plugins/") || path == "/management.html" {
c.Next()
return
}
@@ -811,6 +811,10 @@ func (s *Server) pluginManagementNoRoute(c *gin.Context) {
return
}
path := c.Request.URL.Path
if strings.HasPrefix(path, "/v0/resource/plugins/") {
s.pluginResourceNoRoute(c)
return
}
if path != "/v0/management" && !strings.HasPrefix(path, "/v0/management/") {
c.AbortWithStatus(http.StatusNotFound)
return
@@ -837,6 +841,24 @@ func (s *Server) pluginManagementNoRoute(c *gin.Context) {
c.AbortWithStatus(http.StatusNotFound)
}
func (s *Server) pluginResourceNoRoute(c *gin.Context) {
if s == nil || c == nil || c.Request == nil || c.Request.URL == nil {
if c != nil {
c.AbortWithStatus(http.StatusNotFound)
}
return
}
if s.cfg == nil || s.cfg.Home.Enabled || s.pluginHost == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
if s.pluginHost.ServeResourceHTTP(c.Writer, c.Request) {
c.Abort()
return
}
c.AbortWithStatus(http.StatusNotFound)
}
func (s *Server) serveManagementControlPanel(c *gin.Context) {
cfg := s.cfg
if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {

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

View File

@@ -109,7 +109,7 @@ type Capabilities struct {
UsagePlugin UsagePlugin
// CommandLinePlugin declares and handles plugin-owned command-line flags.
CommandLinePlugin CommandLinePlugin
// ManagementAPI declares plugin-owned diagnostic Management API routes.
// ManagementAPI declares plugin-owned diagnostic Management API and resource routes.
ManagementAPI ManagementAPI
}
@@ -921,7 +921,7 @@ type CommandLineExecutionResponse struct {
ExitCode int
}
// ManagementAPI declares plugin-owned Management API routes.
// ManagementAPI declares plugin-owned Management API and resource routes.
type ManagementAPI interface {
RegisterManagement(context.Context, ManagementRegistrationRequest) (ManagementRegistrationResponse, error)
}
@@ -932,12 +932,16 @@ type ManagementRegistrationRequest struct {
Plugin Metadata
// BasePath is the only Management API prefix plugins may register under.
BasePath string
// ResourceBasePath is the plugin resource prefix for browser-navigable resources.
ResourceBasePath string
}
// ManagementRegistrationResponse lists plugin-owned Management API routes.
// ManagementRegistrationResponse lists plugin-owned Management API and resource routes.
type ManagementRegistrationResponse struct {
// Routes contains the exact Management API routes to expose.
Routes []ManagementRoute
// Resources contains browser-navigable plugin resources exposed under /v0/resource/plugins/<pluginID>/.
Resources []ResourceRoute
}
// ManagementRoute describes one plugin-owned Management API route.
@@ -946,15 +950,27 @@ type ManagementRoute struct {
Method string
// Path is an exact path under /v0/management/. Relative paths are resolved under that prefix.
Path string
// Menu is the optional management UI menu label for GET routes.
// Menu is a legacy resource menu label. GET routes with Menu are registered under /v0/resource/plugins/<pluginID>/.
Menu string
// Description explains the management route for UI display.
// Description explains the legacy resource menu entry for UI display.
Description string
// Handler processes matching Management API requests.
Handler ManagementHandler
}
// ManagementHandler handles one plugin-owned Management API route.
// ResourceRoute describes one plugin-owned browser-navigable resource route.
type ResourceRoute struct {
// Path is an exact path under /v0/resource/plugins/<pluginID>/. Relative paths are resolved under that prefix.
Path string
// Menu is the management UI menu label for this GET resource.
Menu string
// Description explains the resource route for UI display.
Description string
// Handler processes matching resource requests. Resource requests are not management-authenticated.
Handler ManagementHandler
}
// ManagementHandler handles one plugin-owned Management API or resource route.
type ManagementHandler interface {
HandleManagement(context.Context, ManagementRequest) (ManagementResponse, error)
}

View File

@@ -48,16 +48,15 @@ func TestMetadataConfigFieldsExposePluginSchema(t *testing.T) {
}
}
func TestManagementRouteMenuFieldsExposeManagementUIHints(t *testing.T) {
route := ManagementRoute{
Method: "GET",
Path: "/plugins/example/status",
func TestResourceRouteMenuFieldsExposeManagementUIHints(t *testing.T) {
route := ResourceRoute{
Path: "/status",
Menu: "Example Status",
Description: "Shows example plugin status.",
Handler: compileTimePlugin{},
}
if route.Menu == "" || route.Description == "" {
t.Fatalf("management route missing menu fields: %#v", route)
t.Fatalf("resource route missing menu fields: %#v", route)
}
}