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

@@ -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),