History

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.

Luis Pater committed 5 months agov7.1.59

Commit 44ea9ab1 parent 2aeb41c22 files · +341 −69Browse files

Unified · Split

22 files
  1. examples/plugin/README.md +5 −3
  2. examples/plugin/README_CN.md +5 −3
  3. examples/plugin/host-callback/c/src/plugin.c +2 −2
  4. examples/plugin/host-callback/go/main.go +2 −2
  5. examples/plugin/host-callback/rust/src/lib.rs +2 −2
  6. examples/plugin/management-api/c/src/plugin.c +2 −2
  7. examples/plugin/management-api/go/main.go +2 −2
  8. examples/plugin/management-api/rust/src/lib.rs +1 −1
  9. examples/plugin/simple/README.md +3 −1
  10. examples/plugin/simple/README_CN.md +3 −1
  11. examples/plugin/simple/c/src/plugin.c +3 −3
  12. examples/plugin/simple/go/main.go +11 −6
  13. examples/plugin/simple/rust/src/lib.rs +2 −2
  14. internal/api/server.go +23 −1
  15. internal/pluginhost/host.go +7 −0
  16. internal/pluginhost/management.go +156 −4
  17. internal/pluginhost/management_test.go +76 −15
  18. internal/pluginhost/rpc_client.go +6 −1
  19. internal/pluginhost/rpc_schema.go +2 −1
  20. internal/pluginhost/snapshot.go +2 −6
  21. sdk/pluginapi/types.go +22 −6
  22. sdk/pluginapi/types_test.go +4 −5
examples/plugin/README.md+5−3
@@ -20,8 +20,8 @@ This directory contains standard dynamic library plugin examples for the CLIProx · 19 unmodified lines
20- `thinking/`: thinking applier capability only.20- `thinking/`: thinking applier capability only.
21- `usage/`: usage observer capability only.21- `usage/`: usage observer capability only.
22- `cli/`: command-line capability only.22- `cli/`: command-line capability only.
23- `management-api/`: Management API capability only.23- `management-api/`: Management API and resource capability only.
24- `host-callback/`: minimal Management API route that demonstrates host callbacks.24- `host-callback/`: minimal plugin resource that demonstrates host callbacks.
25 25
26Most standard capability examples contain `go/`, `c/`, and `rust/` subdirectories. Specialized examples may provide only the implementation language they need.26Most standard capability examples contain `go/`, `c/`, and `rust/` subdirectories. Specialized examples may provide only the implementation language they need.
27 27
@@ -68,4 +68,6 @@ Artifacts are written to `examples/plugin/bin`. · 40 unmodified lines
68 68
69`protocol-format` uses a minimal executor because format declarations belong to executor capabilities.69`protocol-format` uses a minimal executor because format declarations belong to executor capabilities.
70 70
71`host-callback` uses a minimal Management API route because host callbacks are invoked from plugin methods and are not standalone capabilities.71`host-callback` uses a minimal plugin resource because host callbacks are invoked from plugin methods and are not standalone capabilities.
72
73Menu 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/...`.
examples/plugin/README_CN.md+5−3
@@ -20,8 +20,8 @@ · 19 unmodified lines
20- `thinking/`:只演示 Thinking 处理能力。20- `thinking/`:只演示 Thinking 处理能力。
21- `usage/`:只演示 Usage 观察能力。21- `usage/`:只演示 Usage 观察能力。
22- `cli/`:只演示命令行扩展能力。22- `cli/`:只演示命令行扩展能力。
23- `management-api/`:只演示 Management API 扩展能力。23- `management-api/`:只演示 Management API 和资源扩展能力。
24- `host-callback/`:使用最小 Management API 路由演示宿主回调。24- `host-callback/`:使用最小插件资源演示宿主回调。
25 25
26多数标准能力示例都包含 `go/`、`c/` 和 `rust/` 三个子目录。专用示例可能只提供所需的实现语言。26多数标准能力示例都包含 `go/`、`c/` 和 `rust/` 三个子目录。专用示例可能只提供所需的实现语言。
27 27
@@ -68,4 +68,6 @@ make -C examples/plugin build · 40 unmodified lines
68 68
69`protocol-format` 使用最小执行器承载,因为格式声明属于执行器能力。69`protocol-format` 使用最小执行器承载,因为格式声明属于执行器能力。
70 70
71`host-callback` 使用最小 Management API 路由承载,因为宿主回调只能从插件方法内部发起,不是独立能力。71`host-callback` 使用最小插件资源承载,因为宿主回调只能从插件方法内部发起,不是独立能力。
72
73`management.register` 通过 `resources` 字段返回的菜单资源会由 CPA 暴露在 `/v0/resource/plugins/<pluginID>/...` 下。需要认证的插件自有 Management API 路由仍保留在 `/v0/management/...` 下。
examples/plugin/host-callback/c/src/plugin.c+2−2
@@ -84,14 +84,14 @@ static int plugin_call(const char* method, const uint8_t* request, size_t reques · 83 unmodified lines
84 return 0;84 return 0;
85 }85 }
86 if (strcmp(method, "management.register") == 0) {86 if (strcmp(method, "management.register") == 0) {
87 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.\"}]}}");87 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.\"}]}}");
88 return 0;88 return 0;
89 }89 }
90 if (strcmp(method, "management.handle") == 0) {90 if (strcmp(method, "management.handle") == 0) {
91 call_host("host.log", "{\"level\":\"info\",\"message\":\"example-host-callback-c host callback log\",\"fields\":{\"plugin\":\"example-host-callback-c\"}}");91 call_host("host.log", "{\"level\":\"info\",\"message\":\"example-host-callback-c host callback log\",\"fields\":{\"plugin\":\"example-host-callback-c\"}}");
92 call_host("host.http.do", "{\"method\":\"GET\",\"url\":\"https://example.com\",\"headers\":{\"user-agent\":[\"example-host-callback-c\"]}}");92 call_host("host.http.do", "{\"method\":\"GET\",\"url\":\"https://example.com\",\"headers\":{\"user-agent\":[\"example-host-callback-c\"]}}");
93 93
94 write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stYyJ9\"}}");94 write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}}");
95 return 0;95 return 0;
96 }96 }
97 write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");97 write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
examples/plugin/host-callback/go/main.go+2−2
@@ -131,11 +131,11 @@ func handleMethod(method string) ([]byte, error) { · 130 unmodified lines
131 case "plugin.reconfigure":131 case "plugin.reconfigure":
132 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}}")132 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}}")
133 case "management.register":133 case "management.register":
134 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.\"}]}")134 return okEnvelopeJSON("{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-go/status.\"}]}")
135 case "management.handle":135 case "management.handle":
136 callHost("host.log", []byte(`{"level":"info","message":"example-host-callback-go host callback log","fields":{"plugin":"example-host-callback-go"}}`))136 callHost("host.log", []byte(`{"level":"info","message":"example-host-callback-go host callback log","fields":{"plugin":"example-host-callback-go"}}`))
137 callHost("host.http.do", []byte(`{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-go"]}}`))137 callHost("host.http.do", []byte(`{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-go"]}}`))
138 return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stZ28ifQ==\"}")138 return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}")
139 default:139 default:
140 return errorEnvelope("unknown_method", "unknown method: "+method), nil140 return errorEnvelope("unknown_method", "unknown method: "+method), nil
141 }141 }
examples/plugin/host-callback/rust/src/lib.rs+2−2
@@ -68,10 +68,10 @@ unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, requ · 67 unmodified lines
68 let _ = request;68 let _ = request;
69 let _ = request_len;69 let _ = request_len;
70 match method {70 match method {
71 "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" => {71 "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" => {
72 call_host("host.log", r#"{"level":"info","message":"example-host-callback-rust host callback log","fields":{"plugin":"example-host-callback-rust"}}"#);72 call_host("host.log", r#"{"level":"info","message":"example-host-callback-rust host callback log","fields":{"plugin":"example-host-callback-rust"}}"#);
73 call_host("host.http.do", r#"{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-rust"]}}"#);73 call_host("host.http.do", r#"{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-rust"]}}"#);
74 write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stcnVzdCJ9\"}}"); 0 },74 write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}}"); 0 },
75 _ => {75 _ => {
76 write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);76 write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
77 077 0
examples/plugin/management-api/c/src/plugin.c+2−2
@@ -84,11 +84,11 @@ static int plugin_call(const char* method, const uint8_t* request, size_t reques · 83 unmodified lines
84 return 0;84 return 0;
85 }85 }
86 if (strcmp(method, "management.register") == 0) {86 if (strcmp(method, "management.register") == 0) {
87 write_response(response, "{\"ok\":true,\"result\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-c/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}}");87 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.\"}]}}");
88 return 0;88 return 0;
89 }89 }
90 if (strcmp(method, "management.handle") == 0) {90 if (strcmp(method, "management.handle") == 0) {
91 write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLWMifQ==\"}}");91 write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}}");
92 return 0;92 return 0;
93 }93 }
94 write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");94 write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
examples/plugin/management-api/go/main.go+2−2
@@ -131,9 +131,9 @@ func handleMethod(method string) ([]byte, error) { · 130 unmodified lines
131 case "plugin.reconfigure":131 case "plugin.reconfigure":
132 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}}")132 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}}")
133 case "management.register":133 case "management.register":
134 return okEnvelopeJSON("{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-go/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}")134 return okEnvelopeJSON("{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Management API\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-management-api-go/status.\"}]}")
135 case "management.handle":135 case "management.handle":
136 return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLWdvIn0=\"}")136 return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}")
137 default:137 default:
138 return errorEnvelope("unknown_method", "unknown method: "+method), nil138 return errorEnvelope("unknown_method", "unknown method: "+method), nil
139 }139 }
examples/plugin/management-api/rust/src/lib.rs+1−1
@@ -68,7 +68,7 @@ unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, requ · 67 unmodified lines
68 let _ = request;68 let _ = request;
69 let _ = request_len;69 let _ = request_len;
70 match method {70 match method {
71 "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 },71 "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 },
72 _ => {72 _ => {
73 write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);73 write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
74 074 0
examples/plugin/simple/README.md+3−1
@@ -187,7 +187,9 @@ PUT /v0/management/plugins/{pluginID}/config · 186 unmodified lines
187PATCH /v0/management/plugins/{pluginID}/config187PATCH /v0/management/plugins/{pluginID}/config
188```188```
189 189
190Plugin-owned Management API routes are registered through `management.register` and handled through `management.handle`.190Plugin-owned Management API routes are registered through the `routes` field of `management.register` and handled through `management.handle`.
191
192Browser-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`.
191 193
192## Trust Boundary194## Trust Boundary
193 195
examples/plugin/simple/README_CN.md+3−1
@@ -185,7 +185,9 @@ PUT /v0/management/plugins/{pluginID}/config · 184 unmodified lines
185PATCH /v0/management/plugins/{pluginID}/config185PATCH /v0/management/plugins/{pluginID}/config
186```186```
187 187
188插件自有 Management API 路由通过 `management.register` 注册,通过 `management.handle` 处理。188插件自有 Management API 路由通过 `management.register` 的 `routes` 字段注册,并通过 `management.handle` 处理。
189
190可由浏览器直接访问的菜单资源通过 `management.register` 的 `resources` 字段注册。CPA 会将这些资源暴露在 `/v0/resource/plugins/<pluginID>/...` 下;例如插件 ID 为 `example` 且资源路径为 `/status` 时,最终路径是 `/v0/resource/plugins/example/status`。
189 191
190## 信任边界192## 信任边界
191 193
examples/plugin/simple/c/src/plugin.c+3−3
@@ -84,8 +84,8 @@ static const char* CLI_REGISTER_RESPONSE = · 83 unmodified lines
84static const char* CLI_EXECUTE_RESPONSE =84static const char* CLI_EXECUTE_RESPONSE =
85 "{\"ok\":true,\"result\":{\"Stdout\":\"cGx1Z2luIGV4YW1wbGUgYyBjb21tYW5kCg==\",\"ExitCode\":0}}";85 "{\"ok\":true,\"result\":{\"Stdout\":\"cGx1Z2luIGV4YW1wbGUgYyBjb21tYW5kCg==\",\"ExitCode\":0}}";
86static const char* MANAGEMENT_REGISTER_RESPONSE =86static const char* MANAGEMENT_REGISTER_RESPONSE =
87 "{\"ok\":true,\"result\":{\"Routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-c/status\","87 "{\"ok\":true,\"result\":{\"Resources\":[{\"Path\":\"/status\","
88 "\"Menu\":\"Example C Plugin\",\"Description\":\"Shows example C plugin status.\"}]}}";88 "\"Menu\":\"Example C Plugin\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-c/status.\"}]}}";
89static const char* UNKNOWN_METHOD_RESPONSE =89static const char* UNKNOWN_METHOD_RESPONSE =
90 "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}";90 "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}";
91static const char* INVALID_METHOD_RESPONSE =91static const char* INVALID_METHOD_RESPONSE =
@@ -421,7 +421,7 @@ static char* make_http_response(const uint8_t* request, size_t request_len) { · 329 unmodified lines
421 char* url = extract_json_string(json, "URL");421 char* url = extract_json_string(json, "URL");
422 char* path = extract_json_string(json, "Path");422 char* path = extract_json_string(json, "Path");
423 char* method_escaped = json_escape(method == NULL ? "GET" : method);423 char* method_escaped = json_escape(method == NULL ? "GET" : method);
424 char* target_escaped = json_escape(url != NULL ? url : (path == NULL ? "/plugins/example-c/status" : path));424 char* target_escaped = json_escape(url != NULL ? url : (path == NULL ? "/v0/resource/plugins/example-c/status" : path));
425 char* body_json = format_string(425 char* body_json = format_string(
426 "{\"plugin\":\"example-c\",\"method\":\"%s\",\"target\":\"%s\"}",426 "{\"plugin\":\"example-c\",\"method\":\"%s\",\"target\":\"%s\"}",
427 method_escaped == NULL ? "" : method_escaped,427 method_escaped == NULL ? "" : method_escaped,
examples/plugin/simple/go/main.go+11−6
@@ -100,7 +100,8 @@ type streamResponse struct { · 99 unmodified lines
100}100}
101 101
102type managementRegistrationResponse struct {102type managementRegistrationResponse struct {
103 Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`103 Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
104 Resources []pluginapi.ResourceRoute `json:"resources,omitempty"`
104}105}
105 106
106func main() {}107func main() {}
@@ -207,14 +208,18 @@ func handleMethod(method string, request []byte) ([]byte, error) { · 100 unmodified lines
207 case pluginabi.MethodCommandLineExecute:208 case pluginabi.MethodCommandLineExecute:
208 return okEnvelope(pluginapi.CommandLineExecutionResponse{Stdout: []byte("plugin example command\n")})209 return okEnvelope(pluginapi.CommandLineExecutionResponse{Stdout: []byte("plugin example command\n")})
209 case pluginabi.MethodManagementRegister:210 case pluginabi.MethodManagementRegister:
210 return okEnvelope(managementRegistrationResponse{Routes: []pluginapi.ManagementRoute{{211 // CPA exposes menu resources under /v0/resource/plugins/<plugin-id>/.
211 Method: http.MethodGet,212 return okEnvelope(managementRegistrationResponse{Resources: []pluginapi.ResourceRoute{{
212 Path: "/plugins/example/status",213 Path: "/status",
213 Menu: "Example Plugin",214 Menu: "Example Plugin",
214 Description: "Shows example plugin status.",215 Description: "Shows example plugin status as a browser-navigable resource.",
215 }}})216 }}})
216 case pluginabi.MethodManagementHandle:217 case pluginabi.MethodManagementHandle:
217 return okEnvelope(pluginapi.ManagementResponse{StatusCode: http.StatusOK, Body: []byte(`{"plugin":"example"}`)})218 return okEnvelope(pluginapi.ManagementResponse{
219 StatusCode: http.StatusOK,
220 Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
221 Body: []byte(`<!doctype html><title>Example Plugin</title><main>Example Plugin</main>`),
222 })
218 default:223 default:
219 return errorEnvelope("unknown_method", "unknown method: "+method), nil224 return errorEnvelope("unknown_method", "unknown method: "+method), nil
220 }225 }
examples/plugin/simple/rust/src/lib.rs+2−2
@@ -18,7 +18,7 @@ const FRONTEND_AUTH_RESPONSE: &str = r#"{"ok":true,"result":{"Authenticated":tru · 17 unmodified lines
18const STREAM_RESPONSE: &str = r#"{"ok":true,"result":{"headers":{"content-type":["text/event-stream"]},"chunks":[{"Payload":"cGx1Z2luLWV4YW1wbGUtcnVzdAo="}]}}"#;18const STREAM_RESPONSE: &str = r#"{"ok":true,"result":{"headers":{"content-type":["text/event-stream"]},"chunks":[{"Payload":"cGx1Z2luLWV4YW1wbGUtcnVzdAo="}]}}"#;
19const CLI_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Flags":[{"Name":"plugin-example-rust-command","Usage":"Run the example Rust ABI plugin command","Type":"bool"}]}}"#;19const CLI_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Flags":[{"Name":"plugin-example-rust-command","Usage":"Run the example Rust ABI plugin command","Type":"bool"}]}}"#;
20const CLI_EXECUTE_RESPONSE: &str = r#"{"ok":true,"result":{"Stdout":"cGx1Z2luIGV4YW1wbGUgcnVzdCBjb21tYW5kCg==","ExitCode":0}}"#;20const CLI_EXECUTE_RESPONSE: &str = r#"{"ok":true,"result":{"Stdout":"cGx1Z2luIGV4YW1wbGUgcnVzdCBjb21tYW5kCg==","ExitCode":0}}"#;
21const 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."}]}}"#;21const 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."}]}}"#;
22const UNKNOWN_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#;22const UNKNOWN_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#;
23const INVALID_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#;23const INVALID_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#;
24 24
@@ -170,7 +170,7 @@ fn make_http_response(request: &[u8]) -> String { · 145 unmodified lines
170 let method = extract_json_string(&json, "Method").unwrap_or_else(|| "GET".to_string());170 let method = extract_json_string(&json, "Method").unwrap_or_else(|| "GET".to_string());
171 let target = extract_json_string(&json, "URL")171 let target = extract_json_string(&json, "URL")
172 .or_else(|| extract_json_string(&json, "Path"))172 .or_else(|| extract_json_string(&json, "Path"))
173 .unwrap_or_else(|| "/plugins/example-rust/status".to_string());173 .unwrap_or_else(|| "/v0/resource/plugins/example-rust/status".to_string());
174 let body = format!(174 let body = format!(
175 r#"{{"plugin":"example-rust","method":"{}","target":"{}"}}"#,175 r#"{{"plugin":"example-rust","method":"{}","target":"{}"}}"#,
176 json_escape(&method),176 json_escape(&method),
internal/api/server.go+23−1
@@ -382,7 +382,7 @@ func (s *Server) homeHeartbeatMiddleware() gin.HandlerFunc { · 381 unmodified lines
382 }382 }
383 if c != nil && c.Request != nil {383 if c != nil && c.Request != nil {
384 path := c.Request.URL.Path384 path := c.Request.URL.Path
385 if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || path == "/management.html" {385 if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || strings.HasPrefix(path, "/v0/resource/plugins/") || path == "/management.html" {
386 c.Next()386 c.Next()
387 return387 return
388 }388 }
@@ -811,6 +811,10 @@ func (s *Server) pluginManagementNoRoute(c *gin.Context) { · 422 unmodified lines
811 return811 return
812 }812 }
813 path := c.Request.URL.Path813 path := c.Request.URL.Path
814 if strings.HasPrefix(path, "/v0/resource/plugins/") {
815 s.pluginResourceNoRoute(c)
816 return
817 }
814 if path != "/v0/management" && !strings.HasPrefix(path, "/v0/management/") {818 if path != "/v0/management" && !strings.HasPrefix(path, "/v0/management/") {
815 c.AbortWithStatus(http.StatusNotFound)819 c.AbortWithStatus(http.StatusNotFound)
816 return820 return
@@ -837,6 +841,24 @@ func (s *Server) pluginManagementNoRoute(c *gin.Context) { · 20 unmodified lines
837 c.AbortWithStatus(http.StatusNotFound)841 c.AbortWithStatus(http.StatusNotFound)
838}842}
839 843
844func (s *Server) pluginResourceNoRoute(c *gin.Context) {
845 if s == nil || c == nil || c.Request == nil || c.Request.URL == nil {
846 if c != nil {
847 c.AbortWithStatus(http.StatusNotFound)
848 }
849 return
850 }
851 if s.cfg == nil || s.cfg.Home.Enabled || s.pluginHost == nil {
852 c.AbortWithStatus(http.StatusNotFound)
853 return
854 }
855 if s.pluginHost.ServeResourceHTTP(c.Writer, c.Request) {
856 c.Abort()
857 return
858 }
859 c.AbortWithStatus(http.StatusNotFound)
860}
861
840func (s *Server) serveManagementControlPanel(c *gin.Context) {862func (s *Server) serveManagementControlPanel(c *gin.Context) {
841 cfg := s.cfg863 cfg := s.cfg
842 if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {864 if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {
internal/pluginhost/host.go+7−0
@@ -37,6 +37,7 @@ type Host struct { · 36 unmodified lines
37 commandLineFlags map[string]commandLineFlagRecord37 commandLineFlags map[string]commandLineFlagRecord
38 commandLineHits map[string]struct{}38 commandLineHits map[string]struct{}
39 managementRoutes map[string]managementRouteRecord39 managementRoutes map[string]managementRouteRecord
40 resourceRoutes map[string]resourceRouteRecord
40 streams *streamBridge41 streams *streamBridge
41 httpStreams *hostHTTPStreamBridge42 httpStreams *hostHTTPStreamBridge
42 callbackContexts *callbackContextRegistry43 callbackContexts *callbackContextRegistry
@@ -58,6 +59,7 @@ func New() *Host { · 15 unmodified lines
58 commandLineFlags: make(map[string]commandLineFlagRecord),59 commandLineFlags: make(map[string]commandLineFlagRecord),
59 commandLineHits: make(map[string]struct{}),60 commandLineHits: make(map[string]struct{}),
60 managementRoutes: make(map[string]managementRouteRecord),61 managementRoutes: make(map[string]managementRouteRecord),
62 resourceRoutes: make(map[string]resourceRouteRecord),
61 streams: newStreamBridge(),63 streams: newStreamBridge(),
62 httpStreams: newHostHTTPStreamBridge(),64 httpStreams: newHostHTTPStreamBridge(),
63 callbackContexts: newCallbackContextRegistry(),65 callbackContexts: newCallbackContextRegistry(),
@@ -93,6 +95,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { · 29 unmodified lines
93 h.runtimeConfig = cfg95 h.runtimeConfig = cfg
94 96
95 if !rc.Enabled {97 if !rc.Enabled {
98 h.managementRoutes = make(map[string]managementRouteRecord)
99 h.resourceRoutes = make(map[string]resourceRouteRecord)
96 h.snapshot.Store(emptySnapshot())100 h.snapshot.Store(emptySnapshot())
97 h.mu.Unlock()101 h.mu.Unlock()
98 h.refreshThinkingProviders(nil)102 h.refreshThinkingProviders(nil)
@@ -102,6 +106,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { · 3 unmodified lines
102 files, errSelect := selectPluginFiles(rc.Dir)106 files, errSelect := selectPluginFiles(rc.Dir)
103 if errSelect != nil {107 if errSelect != nil {
104 log.Warnf("pluginhost: failed to select plugin files: %v", errSelect)108 log.Warnf("pluginhost: failed to select plugin files: %v", errSelect)
109 h.managementRoutes = make(map[string]managementRouteRecord)
110 h.resourceRoutes = make(map[string]resourceRouteRecord)
105 h.snapshot.Store(emptySnapshot())111 h.snapshot.Store(emptySnapshot())
106 h.mu.Unlock()112 h.mu.Unlock()
107 h.refreshThinkingProviders(nil)113 h.refreshThinkingProviders(nil)
@@ -187,6 +193,7 @@ func (h *Host) ShutdownAll() { · 79 unmodified lines
187 h.commandLineFlags = make(map[string]commandLineFlagRecord)193 h.commandLineFlags = make(map[string]commandLineFlagRecord)
188 h.commandLineHits = make(map[string]struct{})194 h.commandLineHits = make(map[string]struct{})
189 h.managementRoutes = make(map[string]managementRouteRecord)195 h.managementRoutes = make(map[string]managementRouteRecord)
196 h.resourceRoutes = make(map[string]resourceRouteRecord)
190 h.snapshot.Store(emptySnapshot())197 h.snapshot.Store(emptySnapshot())
191 h.mu.Unlock()198 h.mu.Unlock()
192 199
internal/pluginhost/management.go+156−4
@@ -12,20 +12,30 @@ import ( · 11 unmodified lines
12 log "github.com/sirupsen/logrus"12 log "github.com/sirupsen/logrus"
13)13)
14 14
15const managementBasePath = "/v0/management"15const (
16 managementBasePath = "/v0/management"
17 resourcePluginBasePath = "/v0/resource/plugins"
18 legacyPluginRoutePrefix = "/plugins"
19)
16 20
17type managementRouteRecord struct {21type managementRouteRecord struct {
18 pluginID string22 pluginID string
19 route pluginapi.ManagementRoute23 route pluginapi.ManagementRoute
20}24}
21 25
22// RegisterManagementRoutes rebuilds the plugin-owned Management API route table.26type resourceRouteRecord struct {
27 pluginID string
28 route pluginapi.ResourceRoute
29}
30
31// RegisterManagementRoutes rebuilds the plugin-owned Management API and resource route tables.
23func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string]struct{}) {32func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string]struct{}) {
24 if h == nil {33 if h == nil {
25 return34 return
26 }35 }
27 36
28 nextRoutes := make(map[string]managementRouteRecord)37 nextRoutes := make(map[string]managementRouteRecord)
38 nextResources := make(map[string]resourceRouteRecord)
29 for _, record := range h.Snapshot().records {39 for _, record := range h.Snapshot().records {
30 plugin := record.plugin.Capabilities.ManagementAPI40 plugin := record.plugin.Capabilities.ManagementAPI
31 if plugin == nil || h.isPluginFused(record.id) {41 if plugin == nil || h.isPluginFused(record.id) {
@@ -36,12 +46,19 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string · 4 unmodified lines
36 log.Warnf("pluginhost: management registrar %s failed: %v", record.id, errRegister)46 log.Warnf("pluginhost: management registrar %s failed: %v", record.id, errRegister)
37 continue47 continue
38 }48 }
49
39 for _, item := range resp.Routes {50 for _, item := range resp.Routes {
40 method, path, okRoute := normalizeManagementRoute(item)51 method, path, okRoute := normalizeManagementRoute(item)
41 if !okRoute {52 if !okRoute {
42 log.Warnf("pluginhost: plugin %s declared invalid management route %s %s", record.id, item.Method, item.Path)53 log.Warnf("pluginhost: plugin %s declared invalid management route %s %s", record.id, item.Method, item.Path)
43 continue54 continue
44 }55 }
56 if routeDeclaresLegacyMenuResource(method, item) {
57 if !registerResourceRoute(nextResources, record.id, resourceRouteFromManagementRoute(item)) {
58 log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path)
59 }
60 continue
61 }
45 key := managementRouteKey(method, path)62 key := managementRouteKey(method, path)
46 if _, exists := reserved[key]; exists {63 if _, exists := reserved[key]; exists {
47 log.Warnf("pluginhost: plugin %s management route %s conflicts with an existing route and was skipped", record.id, key)64 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 · 10 unmodified lines
58 route: item,75 route: item,
59 }76 }
60 }77 }
78
79 for _, item := range resp.Resources {
80 if !registerResourceRoute(nextResources, record.id, item) {
81 log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path)
82 }
83 }
61 }84 }
62 85
63 h.mu.Lock()86 h.mu.Lock()
64 h.managementRoutes = nextRoutes87 h.managementRoutes = nextRoutes
88 h.resourceRoutes = nextResources
65 h.mu.Unlock()89 h.mu.Unlock()
66}90}
67 91
@@ -77,8 +101,9 @@ func (h *Host) callManagementRegistrar(ctx context.Context, record capabilityRec · 9 unmodified lines
77 }101 }
78 }()102 }()
79 return plugin.RegisterManagement(ctx, pluginapi.ManagementRegistrationRequest{103 return plugin.RegisterManagement(ctx, pluginapi.ManagementRegistrationRequest{
80 Plugin: record.meta,104 Plugin: record.meta,
81 BasePath: managementBasePath,105 BasePath: managementBasePath,
106 ResourceBasePath: resourcePluginBasePath + "/" + record.id,
82 })107 })
83}108}
84 109
@@ -118,6 +143,75 @@ func normalizeManagementRoute(item pluginapi.ManagementRoute) (string, string, b · 33 unmodified lines
118 return method, fullPath, true143 return method, fullPath, true
119}144}
120 145
146func routeDeclaresLegacyMenuResource(method string, item pluginapi.ManagementRoute) bool {
147 return strings.EqualFold(strings.TrimSpace(method), http.MethodGet) && strings.TrimSpace(item.Menu) != ""
148}
149
150func resourceRouteFromManagementRoute(item pluginapi.ManagementRoute) pluginapi.ResourceRoute {
151 return pluginapi.ResourceRoute{
152 Path: item.Path,
153 Menu: item.Menu,
154 Description: item.Description,
155 Handler: item.Handler,
156 }
157}
158
159func registerResourceRoute(routes map[string]resourceRouteRecord, pluginID string, item pluginapi.ResourceRoute) bool {
160 path, okRoute := normalizeResourceRoute(pluginID, item)
161 if !okRoute {
162 return false
163 }
164 key := managementRouteKey(http.MethodGet, path)
165 if _, exists := routes[key]; exists {
166 log.Warnf("pluginhost: plugin %s resource route %s conflicts with a higher-priority plugin and was skipped", pluginID, key)
167 return true
168 }
169 item.Path = path
170 routes[key] = resourceRouteRecord{
171 pluginID: pluginID,
172 route: item,
173 }
174 return true
175}
176
177func normalizeResourceRoute(pluginID string, item pluginapi.ResourceRoute) (string, bool) {
178 if item.Handler == nil {
179 return "", false
180 }
181 pluginID = strings.TrimSpace(pluginID)
182 if pluginID == "" {
183 return "", false
184 }
185
186 path := strings.TrimSpace(item.Path)
187 if path == "" {
188 return "", false
189 }
190 if !strings.HasPrefix(path, "/") {
191 path = "/" + path
192 }
193
194 pluginBasePath := resourcePluginBasePath + "/" + pluginID
195 if strings.HasPrefix(path, pluginBasePath+"/") {
196 path = strings.TrimPrefix(path, pluginBasePath)
197 } else if strings.HasPrefix(path, legacyPluginRoutePrefix+"/"+pluginID+"/") {
198 path = strings.TrimPrefix(path, legacyPluginRoutePrefix+"/"+pluginID)
199 }
200 path = strings.TrimRight(path, "/")
201 if path == "" {
202 return "", false
203 }
204
205 fullPath := pluginBasePath + path
206 if !strings.HasPrefix(fullPath, pluginBasePath+"/") {
207 return "", false
208 }
209 if strings.ContainsAny(fullPath, " \t\r\n") || strings.Contains(fullPath, ":") || strings.Contains(fullPath, "*") || strings.Contains(fullPath, "..") {
210 return "", false
211 }
212 return fullPath, true
213}
214
121func managementRouteKey(method, path string) string {215func managementRouteKey(method, path string) string {
122 return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)216 return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
123}217}
@@ -178,6 +272,50 @@ func (h *Host) ServeManagementHTTP(w http.ResponseWriter, r *http.Request) bool · 54 unmodified lines
178 return true272 return true
179}273}
180 274
275// ServeResourceHTTP dispatches an unauthenticated browser-navigable resource request to a plugin route.
276func (h *Host) ServeResourceHTTP(w http.ResponseWriter, r *http.Request) bool {
277 if h == nil || w == nil || r == nil || r.URL == nil {
278 return false
279 }
280 if !strings.EqualFold(r.Method, http.MethodGet) {
281 return false
282 }
283 key := managementRouteKey(http.MethodGet, r.URL.Path)
284 h.mu.Lock()
285 record, okRoute := h.resourceRoutes[key]
286 h.mu.Unlock()
287 if !okRoute || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
288 return false
289 }
290
291 resp, errHandle := h.callResourceHandler(r.Context(), record, pluginapi.ManagementRequest{
292 Method: http.MethodGet,
293 Path: r.URL.Path,
294 Headers: cloneHeader(r.Header),
295 Query: cloneValues(r.URL.Query()),
296 })
297 if errHandle != nil {
298 log.Warnf("pluginhost: resource handler %s failed: %v", record.pluginID, errHandle)
299 http.Error(w, "plugin resource handler failed", http.StatusBadGateway)
300 return true
301 }
302
303 for keyHeader, values := range resp.Headers {
304 for _, value := range values {
305 w.Header().Add(keyHeader, value)
306 }
307 }
308 statusCode := resp.StatusCode
309 if statusCode == 0 {
310 statusCode = http.StatusOK
311 }
312 w.WriteHeader(statusCode)
313 if _, errWrite := w.Write(resp.Body); errWrite != nil {
314 log.Warnf("pluginhost: failed to write plugin resource response: %v", errWrite)
315 }
316 return true
317}
318
181func (h *Host) callManagementHandler(ctx context.Context, record managementRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {319func (h *Host) callManagementHandler(ctx context.Context, record managementRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
182 if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) {320 if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
183 return pluginapi.ManagementResponse{}, nil321 return pluginapi.ManagementResponse{}, nil
@@ -191,3 +329,17 @@ func (h *Host) callManagementHandler(ctx context.Context, record managementRoute · 7 unmodified lines
191 }()329 }()
192 return record.route.Handler.HandleManagement(ctx, req)330 return record.route.Handler.HandleManagement(ctx, req)
193}331}
332
333func (h *Host) callResourceHandler(ctx context.Context, record resourceRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
334 if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
335 return pluginapi.ManagementResponse{}, nil
336 }
337 defer func() {
338 if recovered := recover(); recovered != nil {
339 h.fusePlugin(record.pluginID, "ResourceHandler.HandleManagement", recovered)
340 resp = pluginapi.ManagementResponse{}
341 err = fmt.Errorf("resource handler panic: %v", recovered)
342 }
343 }()
344 return record.route.Handler.HandleManagement(ctx, req)
345}
internal/pluginhost/management_test.go+76−15
@@ -91,18 +91,77 @@ func TestManagementHandlerPanicFusesPlugin(t *testing.T) { · 90 unmodified lines
91 }91 }
92}92}
93 93
94func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) {94func TestServeResourceHTTPDispatchesPluginResource(t *testing.T) {
95 plugin := &managementPluginDouble{95 host := newHostWithRecords(capabilityRecord{
96 routes: []pluginapi.ManagementRoute{96 id: "resource",
97 {97 plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
98 Method: http.MethodGet,98 ManagementAPI: &managementPluginDouble{resources: []pluginapi.ResourceRoute{{
99 Path: "/plugins/menu/status",99 Path: "/status",
100 Menu: "Status",100 Menu: "Status",
101 Description: "Shows plugin status.",101 Description: "Shows plugin status.",
102 Handler: managementHandlerFunc(func(_ context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
103 if req.Path != "/v0/resource/plugins/resource/status" {
104 t.Fatalf("resource request path = %q, want normalized resource path", req.Path)
105 }
106 return pluginapi.ManagementResponse{
107 Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
108 Body: []byte("<!doctype html><title>resource</title>"),
109 }, nil
110 }),
111 }}},
112 }},
113 })
114 host.RegisterManagementRoutes(context.Background(), nil)
115
116 req := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/resource/status", nil)
117 rec := httptest.NewRecorder()
118 if !host.ServeResourceHTTP(rec, req) {
119 t.Fatal("ServeResourceHTTP() = false, want true")
120 }
121 if rec.Code != http.StatusOK || rec.Body.String() != "<!doctype html><title>resource</title>" {
122 t.Fatalf("response = %d %q, want 200 html", rec.Code, rec.Body.String())
123 }
124 if got := rec.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
125 t.Fatalf("Content-Type = %q, want text/html; charset=utf-8", got)
126 }
127}
128
129func TestLegacyGETManagementMenuRegistersAsResource(t *testing.T) {
130 host := newHostWithRecords(capabilityRecord{
131 id: "legacy",
132 plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
133 ManagementAPI: &managementPluginDouble{routes: []pluginapi.ManagementRoute{{
134 Method: http.MethodGet,
135 Path: "/plugins/legacy/status",
136 Menu: "Legacy Status",
137 Description: "Shows legacy plugin status.",
102 Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {138 Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
103 return pluginapi.ManagementResponse{}, nil139 return pluginapi.ManagementResponse{Body: []byte("legacy")}, nil
104 }),140 }),
105 },141 }}},
142 }},
143 })
144 host.RegisterManagementRoutes(context.Background(), nil)
145
146 managementReq := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/legacy/status", nil)
147 managementRec := httptest.NewRecorder()
148 if host.ServeManagementHTTP(managementRec, managementReq) {
149 t.Fatal("legacy menu route was served as Management API route")
150 }
151
152 resourceReq := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/legacy/status", nil)
153 resourceRec := httptest.NewRecorder()
154 if !host.ServeResourceHTTP(resourceRec, resourceReq) {
155 t.Fatal("legacy menu route was not served as resource route")
156 }
157 if resourceRec.Body.String() != "legacy" {
158 t.Fatalf("resource body = %q, want legacy", resourceRec.Body.String())
159 }
160}
161
162func TestRegisteredPluginsIncludesResourceMenus(t *testing.T) {
163 plugin := &managementPluginDouble{
164 routes: []pluginapi.ManagementRoute{
106 {165 {
107 Method: http.MethodGet,166 Method: http.MethodGet,
108 Path: "/plugins/menu/hidden",167 Path: "/plugins/menu/hidden",
@@ -110,11 +169,12 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) { · 1 unmodified line
110 return pluginapi.ManagementResponse{}, nil169 return pluginapi.ManagementResponse{}, nil
111 }),170 }),
112 },171 },
172 },
173 resources: []pluginapi.ResourceRoute{
113 {174 {
114 Method: http.MethodPost,175 Path: "/status",
115 Path: "/plugins/menu/run",176 Menu: "Status",
116 Menu: "Run",177 Description: "Shows plugin status.",
117 Description: "Runs a plugin action.",
118 Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {178 Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
119 return pluginapi.ManagementResponse{}, nil179 return pluginapi.ManagementResponse{}, nil
120 }),180 }),
@@ -136,17 +196,18 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) { · 15 unmodified lines
136 t.Fatalf("RegisteredPlugins()[0].Menus = %#v, want one visible GET menu", plugins[0].Menus)196 t.Fatalf("RegisteredPlugins()[0].Menus = %#v, want one visible GET menu", plugins[0].Menus)
137 }197 }
138 menu := plugins[0].Menus[0]198 menu := plugins[0].Menus[0]
139 if menu.Path != "/v0/management/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." {199 if menu.Path != "/v0/resource/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." {
140 t.Fatalf("menu = %#v, want normalized status menu", menu)200 t.Fatalf("menu = %#v, want normalized status menu", menu)
141 }201 }
142}202}
143 203
144type managementPluginDouble struct {204type managementPluginDouble struct {
145 routes []pluginapi.ManagementRoute205 routes []pluginapi.ManagementRoute
206 resources []pluginapi.ResourceRoute
146}207}
147 208
148func (p *managementPluginDouble) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) {209func (p *managementPluginDouble) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) {
149 return pluginapi.ManagementRegistrationResponse{Routes: p.routes}, nil210 return pluginapi.ManagementRegistrationResponse{Routes: p.routes, Resources: p.resources}, nil
150}211}
151 212
152type managementHandlerFunc func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error)213type managementHandlerFunc func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error)
internal/pluginhost/rpc_client.go+6−1
@@ -498,7 +498,12 @@ func (a *rpcPluginAdapter) RegisterManagement(ctx context.Context, req pluginapi · 497 unmodified lines
498 route.Handler = a498 route.Handler = a
499 routes = append(routes, route)499 routes = append(routes, route)
500 }500 }
501 return pluginapi.ManagementRegistrationResponse{Routes: routes}, nil501 resources := make([]pluginapi.ResourceRoute, 0, len(resp.Resources))
502 for _, route := range resp.Resources {
503 route.Handler = a
504 resources = append(resources, route)
505 }
506 return pluginapi.ManagementRegistrationResponse{Routes: routes, Resources: resources}, nil
502}507}
503 508
504func (a *rpcPluginAdapter) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {509func (a *rpcPluginAdapter) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
internal/pluginhost/rpc_schema.go+2−1
@@ -103,7 +103,8 @@ type rpcThinkingApplyRequest struct { · 102 unmodified lines
103}103}
104 104
105type rpcManagementRegistrationResponse struct {105type rpcManagementRegistrationResponse struct {
106 Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`106 Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
107 Resources []pluginapi.ResourceRoute `json:"resources,omitempty"`
107}108}
108 109
109type rpcEmptyResponse struct{}110type rpcEmptyResponse struct{}
internal/pluginhost/snapshot.go+2−6
@@ -1,7 +1,6 @@
1package pluginhost1package pluginhost
2 2
3import (3import (
4 "net/http"
5 "sort"4 "sort"
6 "strings"5 "strings"
7 6
@@ -29,7 +28,7 @@ type RegisteredPluginInfo struct { · 21 unmodified lines
29 Menus []RegisteredPluginMenu28 Menus []RegisteredPluginMenu
30}29}
31 30
32// RegisteredPluginMenu describes a plugin-owned GET Management API menu entry.31// RegisteredPluginMenu describes a plugin-owned resource menu entry.
33type RegisteredPluginMenu struct {32type RegisteredPluginMenu struct {
34 Path string33 Path string
35 Menu string34 Menu string
@@ -67,10 +66,7 @@ func (h *Host) registeredPluginMenus() map[string][]RegisteredPluginMenu { · 31 unmodified lines
67 }66 }
68 h.mu.Lock()67 h.mu.Lock()
69 defer h.mu.Unlock()68 defer h.mu.Unlock()
70 for _, record := range h.managementRoutes {69 for _, record := range h.resourceRoutes {
71 if !strings.EqualFold(strings.TrimSpace(record.route.Method), http.MethodGet) {
72 continue
73 }
74 menu := strings.TrimSpace(record.route.Menu)70 menu := strings.TrimSpace(record.route.Menu)
75 if menu == "" {71 if menu == "" {
76 continue72 continue
sdk/pluginapi/types.go+22−6
@@ -109,7 +109,7 @@ type Capabilities struct { · 108 unmodified lines
109 UsagePlugin UsagePlugin109 UsagePlugin UsagePlugin
110 // CommandLinePlugin declares and handles plugin-owned command-line flags.110 // CommandLinePlugin declares and handles plugin-owned command-line flags.
111 CommandLinePlugin CommandLinePlugin111 CommandLinePlugin CommandLinePlugin
112 // ManagementAPI declares plugin-owned diagnostic Management API routes.112 // ManagementAPI declares plugin-owned diagnostic Management API and resource routes.
113 ManagementAPI ManagementAPI113 ManagementAPI ManagementAPI
114}114}
115 115
@@ -921,7 +921,7 @@ type CommandLineExecutionResponse struct { · 805 unmodified lines
921 ExitCode int921 ExitCode int
922}922}
923 923
924// ManagementAPI declares plugin-owned Management API routes.924// ManagementAPI declares plugin-owned Management API and resource routes.
925type ManagementAPI interface {925type ManagementAPI interface {
926 RegisterManagement(context.Context, ManagementRegistrationRequest) (ManagementRegistrationResponse, error)926 RegisterManagement(context.Context, ManagementRegistrationRequest) (ManagementRegistrationResponse, error)
927}927}
@@ -932,12 +932,16 @@ type ManagementRegistrationRequest struct { · 4 unmodified lines
932 Plugin Metadata932 Plugin Metadata
933 // BasePath is the only Management API prefix plugins may register under.933 // BasePath is the only Management API prefix plugins may register under.
934 BasePath string934 BasePath string
935 // ResourceBasePath is the plugin resource prefix for browser-navigable resources.
936 ResourceBasePath string
935}937}
936 938
937// ManagementRegistrationResponse lists plugin-owned Management API routes.939// ManagementRegistrationResponse lists plugin-owned Management API and resource routes.
938type ManagementRegistrationResponse struct {940type ManagementRegistrationResponse struct {
939 // Routes contains the exact Management API routes to expose.941 // Routes contains the exact Management API routes to expose.
940 Routes []ManagementRoute942 Routes []ManagementRoute
943 // Resources contains browser-navigable plugin resources exposed under /v0/resource/plugins/<pluginID>/.
944 Resources []ResourceRoute
941}945}
942 946
943// ManagementRoute describes one plugin-owned Management API route.947// ManagementRoute describes one plugin-owned Management API route.
@@ -946,15 +950,27 @@ type ManagementRoute struct { · 2 unmodified lines
946 Method string950 Method string
947 // Path is an exact path under /v0/management/. Relative paths are resolved under that prefix.951 // Path is an exact path under /v0/management/. Relative paths are resolved under that prefix.
948 Path string952 Path string
949 // Menu is the optional management UI menu label for GET routes.953 // Menu is a legacy resource menu label. GET routes with Menu are registered under /v0/resource/plugins/<pluginID>/.
950 Menu string954 Menu string
951 // Description explains the management route for UI display.955 // Description explains the legacy resource menu entry for UI display.
952 Description string956 Description string
953 // Handler processes matching Management API requests.957 // Handler processes matching Management API requests.
954 Handler ManagementHandler958 Handler ManagementHandler
955}959}
956 960
957// ManagementHandler handles one plugin-owned Management API route.961// ResourceRoute describes one plugin-owned browser-navigable resource route.
962type ResourceRoute struct {
963 // Path is an exact path under /v0/resource/plugins/<pluginID>/. Relative paths are resolved under that prefix.
964 Path string
965 // Menu is the management UI menu label for this GET resource.
966 Menu string
967 // Description explains the resource route for UI display.
968 Description string
969 // Handler processes matching resource requests. Resource requests are not management-authenticated.
970 Handler ManagementHandler
971}
972
973// ManagementHandler handles one plugin-owned Management API or resource route.
958type ManagementHandler interface {974type ManagementHandler interface {
959 HandleManagement(context.Context, ManagementRequest) (ManagementResponse, error)975 HandleManagement(context.Context, ManagementRequest) (ManagementResponse, error)
960}976}
sdk/pluginapi/types_test.go+4−5
@@ -48,16 +48,15 @@ func TestMetadataConfigFieldsExposePluginSchema(t *testing.T) { · 47 unmodified lines
48 }48 }
49}49}
50 50
51func TestManagementRouteMenuFieldsExposeManagementUIHints(t *testing.T) {51func TestResourceRouteMenuFieldsExposeManagementUIHints(t *testing.T) {
52 route := ManagementRoute{52 route := ResourceRoute{
53 Method: "GET",53 Path: "/status",
54 Path: "/plugins/example/status",
55 Menu: "Example Status",54 Menu: "Example Status",
56 Description: "Shows example plugin status.",55 Description: "Shows example plugin status.",
57 Handler: compileTimePlugin{},56 Handler: compileTimePlugin{},
58 }57 }
59 if route.Menu == "" || route.Description == "" {58 if route.Menu == "" || route.Description == "" {
60 t.Fatalf("management route missing menu fields: %#v", route)59 t.Fatalf("resource route missing menu fields: %#v", route)
61 }60 }
62}61}
63 62