From 6f923a28f77b08bda6eaa5eafc41b96dfcbcb8df Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 14 Jun 2026 23:51:40 +0800 Subject: [PATCH] feat(pluginhost): implement host authentication callbacks and add tests - Introduced `auth_callbacks` for handling host authentication list, get, runtime, and save operations. - Added extensive unit tests to validate functionality, including disk fallback and runtime-specific cases. - Created example implementation in Go to demonstrate host callback integrations. --- examples/plugin/Makefile | 2 +- examples/plugin/README.md | 20 +- examples/plugin/README_CN.md | 20 +- .../plugin/host-callback-auth-files/README.md | 89 +++ .../plugin/host-callback-auth-files/go/go.mod | 7 + .../host-callback-auth-files/go/main.go | 531 ++++++++++++++ internal/api/server.go | 2 + internal/pluginhost/auth_callbacks.go | 651 ++++++++++++++++++ internal/pluginhost/auth_callbacks_test.go | 249 +++++++ internal/pluginhost/host.go | 2 + internal/pluginhost/host_callbacks.go | 8 + sdk/pluginabi/types.go | 4 + sdk/pluginabi/types_test.go | 12 + sdk/pluginapi/types.go | 112 +++ 14 files changed, 1706 insertions(+), 3 deletions(-) create mode 100644 examples/plugin/host-callback-auth-files/README.md create mode 100644 examples/plugin/host-callback-auth-files/go/go.mod create mode 100644 examples/plugin/host-callback-auth-files/go/main.go create mode 100644 internal/pluginhost/auth_callbacks.go create mode 100644 internal/pluginhost/auth_callbacks_test.go diff --git a/examples/plugin/Makefile b/examples/plugin/Makefile index 066756f7c..a3cf251e8 100644 --- a/examples/plugin/Makefile +++ b/examples/plugin/Makefile @@ -1,4 +1,4 @@ -EXAMPLES := simple model auth frontend-auth executor protocol-format request-translator request-normalizer response-translator response-normalizer thinking usage cli management-api host-callback +EXAMPLES := simple model auth frontend-auth executor protocol-format request-translator request-normalizer response-translator response-normalizer thinking usage cli management-api host-callback host-callback-auth-files host-model-callback LANGUAGES := go c rust BIN_DIR := $(CURDIR)/bin BUILD_DIR := $(BIN_DIR)/build diff --git a/examples/plugin/README.md b/examples/plugin/README.md index 59bd5a434..a29b38c9d 100644 --- a/examples/plugin/README.md +++ b/examples/plugin/README.md @@ -4,7 +4,8 @@ This directory contains standard dynamic library plugin examples for the CLIProx ## Layout -- `simple/`: full provider-native skeleton that declares every supported capability. +- `simple/`- : Go-only plugin resource that calls host auth file callbacks (, , , ). +- : full provider-native skeleton that declares every supported capability. - `model/`: model capability only. - `auth/`: auth provider capability only. - `frontend-auth/`: frontend auth provider capability only. @@ -22,6 +23,7 @@ This directory contains standard dynamic library plugin examples for the CLIProx - `cli/`: command-line capability only. - `management-api/`: Management API and resource capability only. - `host-callback/`: minimal plugin resource that demonstrates host callbacks. +- `host-callback-auth-files/`: Go-only plugin resource that calls host auth file callbacks. - `host-model-callback/`: Go-only plugin resource that calls the host model execution callbacks. Most standard capability examples contain `go/`, `c/`, and `rust/` subdirectories. Specialized examples may provide only the implementation language they need. @@ -39,6 +41,22 @@ plugins: fast: false ``` + + +## Host Auth Files Callback + +`host-callback-auth-files` declares the Management API capability and exposes a browser resource named `Host Auth Files`. The resource demonstrates `host.auth.list`, `host.auth.get` (physical JSON file), `host.auth.get_runtime`, and `host.auth.save`. + +```yaml +plugins: + configs: + host-callback-auth-files: + enabled: true + priority: 1 +``` + +See `host-callback-auth-files/README.md` for URL examples. + ## Host Model Callback `host-model-callback` declares the Management API capability and exposes a browser resource named `Host Model Callback`. The resource calls `host.model.execute` for non-streaming requests and `host.model.execute_stream` plus `host.model.stream_read` for streaming requests. It demonstrates explicit stream close with `host.model.stream_close` and an `implicit_close=true` option for RPC-scope host cleanup. diff --git a/examples/plugin/README_CN.md b/examples/plugin/README_CN.md index 2fe650e02..b1987e7c6 100644 --- a/examples/plugin/README_CN.md +++ b/examples/plugin/README_CN.md @@ -1,4 +1,5 @@ -# 标准动态库插件示例 +- :仅 Go 实现的插件资源,演示 host 凭证文件回调(、、、)。 +- # 标准动态库插件示例 本目录包含 CLIProxyAPI C ABI 的标准动态库插件示例。 @@ -22,6 +23,7 @@ - `cli/`:只演示命令行扩展能力。 - `management-api/`:只演示 Management API 和资源扩展能力。 - `host-callback/`:使用最小插件资源演示宿主回调。 +- `host-callback-auth-files/`:仅 Go 实现的插件资源,演示 host 凭证文件回调。 - `host-model-callback/`:仅 Go 实现的插件资源,演示调用宿主模型执行回调。 多数标准能力示例都包含 `go/`、`c/` 和 `rust/` 三个子目录。专用示例可能只提供所需的实现语言。 @@ -39,6 +41,22 @@ plugins: fast: false ``` + + +## Host Auth Files 回调 + +`host-callback-auth-files` 声明 Management API 能力,并暴露名为 `Host Auth Files` 的浏览器资源,演示 `host.auth.list`、`host.auth.get`(物理 JSON 文件)、`host.auth.get_runtime` 与 `host.auth.save`。 + +```yaml +plugins: + configs: + host-callback-auth-files: + enabled: true + priority: 1 +``` + +详见 `host-callback-auth-files/README.md`。 + ## Host Model Callback `host-model-callback` 声明 Management API 能力,并暴露名为 `Host Model Callback` 的浏览器资源。该资源在非流式请求中调用 `host.model.execute`,在流式请求中调用 `host.model.execute_stream` 和 `host.model.stream_read`。它演示了通过 `host.model.stream_close` 显式关闭流,也提供 `implicit_close=true` 用于演示 RPC 作用域结束时的宿主隐式清理。 diff --git a/examples/plugin/host-callback-auth-files/README.md b/examples/plugin/host-callback-auth-files/README.md new file mode 100644 index 000000000..7bd488023 --- /dev/null +++ b/examples/plugin/host-callback-auth-files/README.md @@ -0,0 +1,89 @@ +# Host Callback Auth Files Plugin + +This Go-only plugin demonstrates how a plugin-owned browser resource can call the host auth file callbacks: + +- `host.auth.list` +- `host.auth.get` +- `host.auth.get_runtime` +- `host.auth.save` + +## Purpose and Scope + +The plugin registers a Management API resource named `Host Auth Files` at `/status`. CPA exposes it under: + +```text +/v0/resource/plugins/host-callback-auth-files/status +``` + +The resource reads URL query parameters, calls the host auth callbacks, and renders the result in HTML. It does not implement executor, translator, auth provider, or scheduler capabilities. + +## Build + +From this directory: + +```bash +cd go +go build -buildmode=c-shared -o host-callback-auth-files.dylib . +rm -f host-callback-auth-files.dylib host-callback-auth-files.h +``` + +Use the platform extension expected by your target system: + +- `.dylib` on macOS +- `.so` on Linux +- `.dll` on Windows + +## Configuration + +Build the dynamic library and place it under the configured plugin directory with a basename that matches the plugin ID. For example, `plugins/host-callback-auth-files.dylib` maps to `plugins.configs.host-callback-auth-files`. + +```yaml +plugins: + enabled: true + dir: "plugins" + configs: + host-callback-auth-files: + enabled: true + priority: 1 +``` + +This plugin does not define plugin-specific configuration fields. + +## Resource URL Examples + +List all auth files: + +```text +http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=list +``` + +Read physical JSON by auth index: + +```text +http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=get&auth_index= +``` + +Read runtime info by auth index: + +```text +http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=runtime&auth_index= +``` + +Save physical JSON: + +```text +http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=save&name=example-auth.json&json=%7B%22type%22%3A%22gemini%22%2C%22email%22%3A%22demo%40example.com%22%2C%22api_key%22%3A%22demo-key%22%7D +``` + +## Parameters + +- `op`: one of `list`, `get`, `runtime`, `save`. Default is `list`. +- `auth_index`: required for `get` and `runtime`. +- `name`: required for `save`. Must end with `.json`. +- `json`: required for `save`. Must be valid JSON. + +## Notes + +- `host.auth.get` returns the physical auth file JSON. +- `host.auth.get_runtime` returns runtime credential metadata. +- `host.auth.save` writes the JSON to the auth directory and upserts the runtime auth record. diff --git a/examples/plugin/host-callback-auth-files/go/go.mod b/examples/plugin/host-callback-auth-files/go/go.mod new file mode 100644 index 000000000..c67dbc66f --- /dev/null +++ b/examples/plugin/host-callback-auth-files/go/go.mod @@ -0,0 +1,7 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/host-callback-auth-files/go + +go 1.26.0 + +require github.com/router-for-me/CLIProxyAPI/v7 v7.0.0 + +replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../.. diff --git a/examples/plugin/host-callback-auth-files/go/main.go b/examples/plugin/host-callback-auth-files/go/main.go new file mode 100644 index 000000000..256637628 --- /dev/null +++ b/examples/plugin/host-callback-auth-files/go/main.go @@ -0,0 +1,531 @@ +package main + +/* +#include +#include + +typedef struct { + void* ptr; + size_t len; +} cliproxy_buffer; + +typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_host_free_fn)(void*, size_t); + +typedef struct { + uint32_t abi_version; + void* host_ctx; + cliproxy_host_call_fn call; + cliproxy_host_free_fn free_buffer; +} cliproxy_host_api; + +typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_plugin_free_fn)(void*, size_t); +typedef void (*cliproxy_plugin_shutdown_fn)(void); + +typedef struct { + uint32_t abi_version; + cliproxy_plugin_call_fn call; + cliproxy_plugin_free_fn free_buffer; + cliproxy_plugin_shutdown_fn shutdown; +} cliproxy_plugin_api; + +extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*); +extern void cliproxyPluginFree(void*, size_t); +extern void cliproxyPluginShutdown(void); + +static const cliproxy_host_api* stored_host; + +static void store_host_api(const cliproxy_host_api* host) { + stored_host = host; +} + +static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) { + if (stored_host == NULL || stored_host->call == NULL) { + return 1; + } + return stored_host->call(stored_host->host_ctx, method, request, request_len, response); +} + +static void free_host_buffer(void* ptr, size_t len) { + if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) { + stored_host->free_buffer(ptr, len); + } +} +*/ +import "C" + +import ( + "bytes" + "encoding/json" + "fmt" + "html" + "net/http" + "net/url" + "strings" + "unsafe" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +const ( + pluginName = "host-callback-auth-files" + resourcePath = "/status" + resourceContentType = "text/html; charset=utf-8" +) + +type envelope struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result,omitempty"` + Error *envelopeError `json:"error,omitempty"` +} + +type envelopeError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type registration struct { + SchemaVersion uint32 `json:"schema_version"` + Metadata pluginapi.Metadata `json:"metadata"` + Capabilities registrationCapabilities `json:"capabilities"` +} + +type registrationCapabilities struct { + ManagementAPI bool `json:"management_api"` +} + +type managementRegistration struct { + Resources []managementResource `json:"resources,omitempty"` +} + +type managementResource struct { + Path string `json:"Path"` + Menu string `json:"Menu"` + Description string `json:"Description"` +} + +type managementRequest struct { + Method string + Path string + Headers http.Header + Query url.Values + Body []byte + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type managementResponse struct { + StatusCode int `json:"StatusCode"` + Headers http.Header `json:"Headers"` + Body []byte `json:"Body"` +} + +type authListResponse struct { + Files []pluginapi.HostAuthFileEntry `json:"files"` +} + +type authOpOptions struct { + Op string + AuthIndex string + Name string + JSON json.RawMessage +} + +func main() {} + +//export cliproxy_plugin_init +func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int { + if plugin == nil { + return 1 + } + C.store_host_api(host) + plugin.abi_version = C.uint32_t(pluginabi.ABIVersion) + plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall) + plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree) + plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown) + return 0 +} + +//export cliproxyPluginCall +func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int { + if response != nil { + response.ptr = nil + response.len = 0 + } + if method == nil { + writeResponse(response, errorEnvelope("invalid_method", "method is required")) + return 1 + } + var requestBytes []byte + if request != nil && requestLen > 0 { + requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen)) + } + raw, errHandle := handleMethod(C.GoString(method), requestBytes) + if errHandle != nil { + writeResponse(response, errorEnvelope("plugin_error", errHandle.Error())) + return 1 + } + writeResponse(response, raw) + return 0 +} + +//export cliproxyPluginFree +func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) { + if ptr != nil { + C.free(ptr) + } + _ = len +} + +//export cliproxyPluginShutdown +func cliproxyPluginShutdown() {} + +func handleMethod(method string, request []byte) ([]byte, error) { + switch method { + case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure: + return okEnvelope(pluginRegistration()) + case pluginabi.MethodManagementRegister: + return okEnvelope(managementRegistration{ + Resources: []managementResource{{ + Path: resourcePath, + Menu: "Host Auth Files", + Description: "Lists auth files and demonstrates host.auth list/get/runtime/save callbacks.", + }}, + }) + case pluginabi.MethodManagementHandle: + return handleManagement(request) + default: + return errorEnvelope("unknown_method", "unknown method: "+method), nil + } +} + +func pluginRegistration() registration { + return registration{ + SchemaVersion: pluginabi.SchemaVersion, + Metadata: pluginapi.Metadata{ + Name: pluginName, + Version: "0.1.0", + Author: "router-for-me", + GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI", + Logo: "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png", + ConfigFields: []pluginapi.ConfigField{}, + }, + Capabilities: registrationCapabilities{ + ManagementAPI: true, + }, + } +} + +func handleManagement(raw []byte) ([]byte, error) { + var req managementRequest + if len(raw) > 0 { + if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil { + return nil, fmt.Errorf("decode management request: %w", errUnmarshal) + } + } + opts, errOptions := optionsFromManagementRequest(req) + if errOptions != nil { + page := renderPage(opts, nil, errOptions.Error()) + return okEnvelope(htmlResponse(http.StatusBadRequest, page)) + } + result, errRun := runAuthOp(opts) + if errRun != nil { + page := renderPage(opts, nil, errRun.Error()) + return okEnvelope(htmlResponse(http.StatusOK, page)) + } + page := renderPage(opts, result, "") + return okEnvelope(htmlResponse(http.StatusOK, page)) +} + +func optionsFromManagementRequest(req managementRequest) (authOpOptions, error) { + opts := authOpOptions{Op: "list"} + if len(req.Body) > 0 { + var bodyOpts authOpOptions + if errUnmarshal := json.Unmarshal(req.Body, &bodyOpts); errUnmarshal != nil { + return opts, fmt.Errorf("decode JSON request body: %w", errUnmarshal) + } + applyAuthOpOptions(&opts, bodyOpts) + } + if errApply := applyQueryAuthOptions(&opts, req.Query); errApply != nil { + return opts, errApply + } + return opts, nil +} + +func applyAuthOpOptions(dst *authOpOptions, src authOpOptions) { + if strings.TrimSpace(src.Op) != "" { + dst.Op = strings.ToLower(strings.TrimSpace(src.Op)) + } + if strings.TrimSpace(src.AuthIndex) != "" { + dst.AuthIndex = strings.TrimSpace(src.AuthIndex) + } + if strings.TrimSpace(src.Name) != "" { + dst.Name = strings.TrimSpace(src.Name) + } + if len(src.JSON) > 0 && string(src.JSON) != "null" { + dst.JSON = append(json.RawMessage(nil), src.JSON...) + } +} + +func applyQueryAuthOptions(opts *authOpOptions, query url.Values) error { + if query == nil { + return nil + } + if raw := strings.TrimSpace(query.Get("op")); raw != "" { + opts.Op = strings.ToLower(raw) + } + if raw := strings.TrimSpace(query.Get("auth_index")); raw != "" { + opts.AuthIndex = raw + } + if raw := strings.TrimSpace(query.Get("name")); raw != "" { + opts.Name = raw + } + if raw := strings.TrimSpace(query.Get("json")); raw != "" { + if !json.Valid([]byte(raw)) { + return fmt.Errorf("query json must be valid JSON") + } + opts.JSON = json.RawMessage(raw) + } + return nil +} + +func runAuthOp(opts authOpOptions) (any, error) { + switch opts.Op { + case "list", "": + return callHostAuthList() + case "get": + if opts.AuthIndex == "" { + return nil, fmt.Errorf("auth_index is required for op=get") + } + return callHostAuthGet(opts.AuthIndex) + case "runtime", "get_runtime": + if opts.AuthIndex == "" { + return nil, fmt.Errorf("auth_index is required for op=runtime") + } + return callHostAuthGetRuntime(opts.AuthIndex) + case "save": + if opts.Name == "" { + return nil, fmt.Errorf("name is required for op=save") + } + if len(opts.JSON) == 0 { + return nil, fmt.Errorf("json is required for op=save") + } + return callHostAuthSave(opts.Name, opts.JSON) + default: + return nil, fmt.Errorf("unknown op %q: use list, get, runtime, or save", opts.Op) + } +} + +func callHostAuthList() (authListResponse, error) { + result, errCall := callHost(pluginabi.MethodHostAuthList, map[string]any{}) + if errCall != nil { + return authListResponse{}, errCall + } + var resp authListResponse + if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil { + return authListResponse{}, fmt.Errorf("decode host.auth.list result: %w", errUnmarshal) + } + return resp, nil +} + +func callHostAuthGet(authIndex string) (pluginapi.HostAuthGetResponse, error) { + result, errCall := callHost(pluginabi.MethodHostAuthGet, pluginapi.HostAuthGetRequest{AuthIndex: authIndex}) + if errCall != nil { + return pluginapi.HostAuthGetResponse{}, errCall + } + var resp pluginapi.HostAuthGetResponse + if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil { + return pluginapi.HostAuthGetResponse{}, fmt.Errorf("decode host.auth.get result: %w", errUnmarshal) + } + return resp, nil +} + +func callHostAuthGetRuntime(authIndex string) (pluginapi.HostAuthGetRuntimeResponse, error) { + result, errCall := callHost(pluginabi.MethodHostAuthGetRuntime, pluginapi.HostAuthGetRequest{AuthIndex: authIndex}) + if errCall != nil { + return pluginapi.HostAuthGetRuntimeResponse{}, errCall + } + var resp pluginapi.HostAuthGetRuntimeResponse + if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil { + return pluginapi.HostAuthGetRuntimeResponse{}, fmt.Errorf("decode host.auth.get_runtime result: %w", errUnmarshal) + } + return resp, nil +} + +func callHostAuthSave(name string, rawJSON json.RawMessage) (pluginapi.HostAuthSaveResponse, error) { + result, errCall := callHost(pluginabi.MethodHostAuthSave, pluginapi.HostAuthSaveRequest{ + Name: name, + JSON: rawJSON, + }) + if errCall != nil { + return pluginapi.HostAuthSaveResponse{}, errCall + } + var resp pluginapi.HostAuthSaveResponse + if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil { + return pluginapi.HostAuthSaveResponse{}, fmt.Errorf("decode host.auth.save result: %w", errUnmarshal) + } + return resp, nil +} + +func callHost(method string, payload any) (json.RawMessage, error) { + rawPayload, errMarshal := json.Marshal(payload) + if errMarshal != nil { + return nil, fmt.Errorf("marshal host callback payload %s: %w", method, errMarshal) + } + cMethod := C.CString(method) + defer C.free(unsafe.Pointer(cMethod)) + + var response C.cliproxy_buffer + var requestPtr *C.uint8_t + if len(rawPayload) > 0 { + cPayload := C.CBytes(rawPayload) + if cPayload == nil { + return nil, fmt.Errorf("allocate host callback payload %s", method) + } + defer C.free(cPayload) + requestPtr = (*C.uint8_t)(cPayload) + } + callCode := C.call_host_api(cMethod, requestPtr, C.size_t(len(rawPayload)), &response) + var rawResponse []byte + if response.ptr != nil && response.len > 0 { + rawResponse = C.GoBytes(response.ptr, C.int(response.len)) + } + if response.ptr != nil { + C.free_host_buffer(response.ptr, response.len) + } + if len(rawResponse) == 0 { + return nil, fmt.Errorf("host callback %s returned no response, code=%d", method, int(callCode)) + } + + var env envelope + if errUnmarshal := json.Unmarshal(rawResponse, &env); errUnmarshal != nil { + return nil, fmt.Errorf("decode host callback envelope %s: %w", method, errUnmarshal) + } + if !env.OK { + if env.Error != nil { + return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message) + } + return nil, fmt.Errorf("host callback %s failed", method) + } + if callCode != 0 { + return nil, fmt.Errorf("host callback %s returned code=%d", method, int(callCode)) + } + return append(json.RawMessage(nil), env.Result...), nil +} + +func htmlResponse(statusCode int, body []byte) managementResponse { + return managementResponse{ + StatusCode: statusCode, + Headers: http.Header{ + "content-type": []string{resourceContentType}, + }, + Body: body, + } +} + +func renderPage(opts authOpOptions, result any, errText string) []byte { + var out bytes.Buffer + out.WriteString("Host Auth Files") + out.WriteString("") + out.WriteString("
") + out.WriteString("

Host Auth Files

") + out.WriteString("
") + writeDefinition(&out, "op", opts.Op) + if opts.AuthIndex != "" { + writeDefinition(&out, "auth_index", opts.AuthIndex) + } + if opts.Name != "" { + writeDefinition(&out, "name", opts.Name) + } + out.WriteString("
") + if errText != "" { + out.WriteString("

Error

")
+		out.WriteString(html.EscapeString(errText))
+		out.WriteString("
") + } + if result != nil { + out.WriteString("

Result

")
+		out.WriteString(html.EscapeString(prettyJSON(result)))
+		out.WriteString("
") + } + out.WriteString("

Usage

    ") + out.WriteString("
  • ?op=list
  • ") + out.WriteString("
  • ?op=get&auth_index=<AUTH_INDEX>
  • ") + out.WriteString("
  • ?op=runtime&auth_index=<AUTH_INDEX>
  • ") + out.WriteString("
  • ?op=save&name=example.json&json=...
  • ") + out.WriteString("
") + out.WriteString("
") + return out.Bytes() +} + +func writeDefinition(out *bytes.Buffer, key string, value string) { + out.WriteString("
") + out.WriteString(html.EscapeString(key)) + out.WriteString("
") + out.WriteString(html.EscapeString(value)) + out.WriteString("
") +} + +func prettyBody(raw []byte) string { + var buf bytes.Buffer + if errIndent := json.Indent(&buf, raw, "", " "); errIndent == nil { + return buf.String() + } + return string(raw) +} + +func prettyJSON(v any) string { + raw, errMarshal := json.MarshalIndent(v, "", " ") + if errMarshal != nil { + return fmt.Sprintf("%v", v) + } + return string(raw) +} + +func okEnvelope(v any) ([]byte, error) { + raw, errMarshal := json.Marshal(v) + if errMarshal != nil { + return nil, errMarshal + } + return json.Marshal(envelope{OK: true, Result: raw}) +} + +func errorEnvelope(code, message string) []byte { + raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}}) + return raw +} + +func writeResponse(response *C.cliproxy_buffer, raw []byte) { + if response == nil || len(raw) == 0 { + return + } + ptr := C.CBytes(raw) + if ptr == nil { + return + } + response.ptr = ptr + response.len = C.size_t(len(raw)) +} + +func cloneHeader(headers http.Header) http.Header { + if headers == nil { + return nil + } + cloned := make(http.Header, len(headers)) + for key, values := range headers { + cloned[key] = append([]string(nil), values...) + } + return cloned +} + +func cloneValues(values url.Values) url.Values { + if values == nil { + return nil + } + cloned := make(url.Values, len(values)) + for key, items := range values { + cloned[key] = append([]string(nil), items...) + } + return cloned +} diff --git a/internal/api/server.go b/internal/api/server.go index 742db4be1..67d4bd687 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -318,6 +318,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk s.handlers.SetPluginHost(optionState.pluginHost) if optionState.pluginHost != nil { optionState.pluginHost.SetModelExecutor(s.handlers) + optionState.pluginHost.SetAuthManager(authManager) } // Save initial YAML snapshot s.oldConfigYaml, _ = yaml.Marshal(cfg) @@ -1650,6 +1651,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { s.handlers.SetPluginHost(s.pluginHost) if s.pluginHost != nil { s.pluginHost.SetModelExecutor(s.handlers) + s.pluginHost.SetAuthManager(s.handlers.AuthManager) } if s.mgmt != nil { diff --git a/internal/pluginhost/auth_callbacks.go b/internal/pluginhost/auth_callbacks.go new file mode 100644 index 000000000..f05329402 --- /dev/null +++ b/internal/pluginhost/auth_callbacks.go @@ -0,0 +1,651 @@ +package pluginhost + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "time" + + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +type rpcHostAuthGetRequest struct { + AuthIndex string `json:"auth_index"` +} + +type rpcHostAuthListResponse struct { + Files []pluginapi.HostAuthFileEntry `json:"files"` +} + +type rpcHostAuthGetResponse struct { + AuthIndex string `json:"auth_index"` + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + JSON json.RawMessage `json:"json"` +} + +func (h *Host) SetAuthManager(manager *coreauth.Manager) { + if h == nil { + return + } + h.mu.Lock() + h.authManager = manager + h.mu.Unlock() +} + +func (h *Host) currentAuthManager() *coreauth.Manager { + if h == nil { + return nil + } + h.mu.Lock() + manager := h.authManager + h.mu.Unlock() + return manager +} + +func (h *Host) callHostAuthList(ctx context.Context, request []byte) ([]byte, error) { + _ = ctx + if len(bytesTrimSpace(request)) > 0 { + var req map[string]any + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, fmt.Errorf("decode host auth list request: %w", errUnmarshal) + } + } + entries, errList := h.listAuthFiles() + if errList != nil { + return nil, errList + } + return marshalRPCResult(rpcHostAuthListResponse{Files: entries}) +} + +func (h *Host) callHostAuthGet(ctx context.Context, request []byte) ([]byte, error) { + _ = ctx + var req rpcHostAuthGetRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, fmt.Errorf("decode host auth get request: %w", errUnmarshal) + } + authIndex := strings.TrimSpace(req.AuthIndex) + if authIndex == "" { + return nil, fmt.Errorf("auth_index is required") + } + auth, rawJSON, errGet := h.authPhysicalJSONByIndex(authIndex) + if errGet != nil { + return nil, errGet + } + name := strings.TrimSpace(auth.FileName) + if name == "" { + name = strings.TrimSpace(auth.ID) + } + path := strings.TrimSpace(authAttribute(auth, "path")) + return marshalRPCResult(rpcHostAuthGetResponse{ + AuthIndex: authIndex, + Name: name, + Path: path, + JSON: json.RawMessage(rawJSON), + }) +} + +func (h *Host) callHostAuthGetRuntime(ctx context.Context, request []byte) ([]byte, error) { + _ = ctx + var req rpcHostAuthGetRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, fmt.Errorf("decode host auth get runtime request: %w", errUnmarshal) + } + authIndex := strings.TrimSpace(req.AuthIndex) + if authIndex == "" { + return nil, fmt.Errorf("auth_index is required") + } + auth, errGet := h.authByIndex(authIndex) + if errGet != nil { + return nil, errGet + } + entry := h.buildHostAuthFileEntry(auth) + if entry == nil { + return nil, fmt.Errorf("auth runtime info not found for auth_index %s", authIndex) + } + return marshalRPCResult(pluginapi.HostAuthGetRuntimeResponse{Auth: *entry}) +} + +func (h *Host) callHostAuthSave(ctx context.Context, request []byte) ([]byte, error) { + var req pluginapi.HostAuthSaveRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, fmt.Errorf("decode host auth save request: %w", errUnmarshal) + } + name, rawJSON, errValidate := validateHostAuthSaveRequest(req) + if errValidate != nil { + return nil, errValidate + } + path, errSave := h.saveAuthFile(ctx, name, rawJSON) + if errSave != nil { + return nil, errSave + } + return marshalRPCResult(pluginapi.HostAuthSaveResponse{ + Name: name, + Path: path, + }) +} + +func (h *Host) listAuthFiles() ([]pluginapi.HostAuthFileEntry, error) { + manager := h.currentAuthManager() + if manager != nil { + auths := manager.List() + entries := make([]pluginapi.HostAuthFileEntry, 0, len(auths)) + for _, auth := range auths { + if entry := h.buildHostAuthFileEntry(auth); entry != nil { + entries = append(entries, *entry) + } + } + sort.Slice(entries, func(i, j int) bool { + return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) + }) + return entries, nil + } + return h.listAuthFilesFromDisk() +} + +func (h *Host) listAuthFilesFromDisk() ([]pluginapi.HostAuthFileEntry, error) { + authDir := h.resolvedAuthDir() + if authDir == "" { + return nil, fmt.Errorf("auth directory is unavailable") + } + entries, errReadDir := os.ReadDir(authDir) + if errReadDir != nil { + return nil, fmt.Errorf("failed to read auth dir: %w", errReadDir) + } + files := make([]pluginapi.HostAuthFileEntry, 0) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(strings.ToLower(name), ".json") { + continue + } + full := filepath.Join(authDir, name) + fileEntry := pluginapi.HostAuthFileEntry{ + Name: name, + Source: "file", + Path: full, + } + if info, errInfo := entry.Info(); errInfo == nil { + fileEntry.Size = info.Size() + fileEntry.ModTime = info.ModTime() + } + if data, errRead := os.ReadFile(full); errRead == nil { + var metadata map[string]any + if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal == nil { + if provider, ok := metadata["type"].(string); ok { + fileEntry.Type = strings.TrimSpace(provider) + fileEntry.Provider = fileEntry.Type + } + if email, ok := metadata["email"].(string); ok { + fileEntry.Email = strings.TrimSpace(email) + } + if projectID, ok := metadata["project_id"].(string); ok { + fileEntry.ProjectID = strings.TrimSpace(projectID) + } + if rawPriority, ok := metadata["priority"]; ok { + if priority, okPriority := parsePriorityValue(rawPriority); okPriority { + fileEntry.Priority = priority + } + } + if note, ok := metadata["note"].(string); ok { + fileEntry.Note = strings.TrimSpace(note) + } + if websockets, okWebsockets := parseWebsocketsValue(metadata["websockets"]); okWebsockets { + fileEntry.Websockets = websockets + } + } + } + files = append(files, fileEntry) + } + sort.Slice(files, func(i, j int) bool { + return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) + }) + return files, nil +} + +func (h *Host) authByIndex(authIndex string) (*coreauth.Auth, error) { + authIndex = strings.TrimSpace(authIndex) + if authIndex == "" { + return nil, fmt.Errorf("auth_index is required") + } + manager := h.currentAuthManager() + if manager == nil { + return nil, fmt.Errorf("core auth manager unavailable") + } + for _, auth := range manager.List() { + if auth == nil { + continue + } + auth.EnsureIndex() + if auth.Index == authIndex { + return auth, nil + } + } + return nil, fmt.Errorf("auth not found for auth_index %s", authIndex) +} + +func (h *Host) authPhysicalJSONByIndex(authIndex string) (*coreauth.Auth, []byte, error) { + auth, errGet := h.authByIndex(authIndex) + if errGet != nil { + return nil, nil, errGet + } + path := strings.TrimSpace(authAttribute(auth, "path")) + if path == "" { + return nil, nil, fmt.Errorf("auth file path not found for auth_index %s", authIndex) + } + data, errRead := os.ReadFile(path) + if errRead != nil { + if os.IsNotExist(errRead) { + return nil, nil, fmt.Errorf("auth file not found for auth_index %s", authIndex) + } + return nil, nil, fmt.Errorf("failed to read auth file: %w", errRead) + } + if len(bytesTrimSpace(data)) == 0 { + return nil, nil, fmt.Errorf("auth file is empty for auth_index %s", authIndex) + } + var metadata map[string]any + if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil { + return nil, nil, fmt.Errorf("invalid auth file for auth_index %s: %w", authIndex, errUnmarshal) + } + return auth, data, nil +} + +func validateHostAuthSaveRequest(req pluginapi.HostAuthSaveRequest) (string, []byte, error) { + name := strings.TrimSpace(req.Name) + if isUnsafeAuthFileName(name) { + return "", nil, fmt.Errorf("invalid auth file name") + } + if !strings.HasSuffix(strings.ToLower(name), ".json") { + return "", nil, fmt.Errorf("auth file name must end with .json") + } + rawJSON := bytesTrimSpace(req.JSON) + if len(rawJSON) == 0 { + return "", nil, fmt.Errorf("json is required") + } + var metadata map[string]any + if errUnmarshal := json.Unmarshal(rawJSON, &metadata); errUnmarshal != nil { + return "", nil, fmt.Errorf("invalid auth json: %w", errUnmarshal) + } + return filepath.Base(name), rawJSON, nil +} + +func (h *Host) saveAuthFile(ctx context.Context, name string, data []byte) (string, error) { + authDir := h.resolvedAuthDir() + if authDir == "" { + return "", fmt.Errorf("auth directory is unavailable") + } + dst := filepath.Join(authDir, filepath.Base(name)) + if !filepath.IsAbs(dst) { + if abs, errAbs := filepath.Abs(dst); errAbs == nil { + dst = abs + } + } + auth, errBuild := h.buildAuthFromFileData(dst, data) + if errBuild != nil { + return "", errBuild + } + if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil { + return "", fmt.Errorf("failed to write auth file: %w", errWrite) + } + if errUpsert := h.upsertAuthRecord(ctx, auth); errUpsert != nil { + return "", errUpsert + } + return dst, nil +} + +func (h *Host) buildAuthFromFileData(path string, data []byte) (*coreauth.Auth, error) { + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("auth path is empty") + } + if data == nil { + var errRead error + data, errRead = os.ReadFile(path) + if errRead != nil { + return nil, fmt.Errorf("failed to read auth file: %w", errRead) + } + } + metadata := make(map[string]any) + if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil { + return nil, fmt.Errorf("invalid auth file: %w", errUnmarshal) + } + provider, _ := metadata["type"].(string) + if strings.TrimSpace(provider) == "" { + provider = "unknown" + } + label := provider + if email, ok := metadata["email"].(string); ok && strings.TrimSpace(email) != "" { + label = strings.TrimSpace(email) + } + authID := h.authIDForPath(path) + if authID == "" { + authID = path + } + auth := &coreauth.Auth{ + ID: authID, + Provider: provider, + FileName: filepath.Base(path), + Label: label, + Status: coreauth.StatusActive, + Attributes: map[string]string{ + "path": path, + "source": path, + }, + Metadata: metadata, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if manager := h.currentAuthManager(); manager != nil { + if existing, ok := manager.GetByID(authID); ok { + auth.CreatedAt = existing.CreatedAt + auth.LastRefreshedAt = existing.LastRefreshedAt + auth.NextRetryAfter = existing.NextRetryAfter + auth.Runtime = existing.Runtime + } + } + coreauth.ApplyCustomHeadersFromMetadata(auth) + return auth, nil +} + +func (h *Host) upsertAuthRecord(ctx context.Context, auth *coreauth.Auth) error { + manager := h.currentAuthManager() + if manager == nil || auth == nil { + return nil + } + if existing, ok := manager.GetByID(auth.ID); ok { + auth.CreatedAt = existing.CreatedAt + _, errUpdate := manager.Update(ctx, auth) + return errUpdate + } + _, errRegister := manager.Register(ctx, auth) + return errRegister +} + +func isUnsafeAuthFileName(name string) bool { + if strings.TrimSpace(name) == "" { + return true + } + if strings.ContainsAny(name, "/\\") { + return true + } + if filepath.VolumeName(name) != "" { + return true + } + return false +} + +func (h *Host) buildHostAuthFileEntry(auth *coreauth.Auth) *pluginapi.HostAuthFileEntry { + if auth == nil { + return nil + } + auth.EnsureIndex() + runtimeOnly := isRuntimeOnlyAuth(auth) + if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) { + return nil + } + path := strings.TrimSpace(authAttribute(auth, "path")) + if path == "" && !runtimeOnly { + return nil + } + name := strings.TrimSpace(auth.FileName) + if name == "" { + name = auth.ID + } + entry := &pluginapi.HostAuthFileEntry{ + ID: auth.ID, + AuthIndex: auth.Index, + Name: name, + Type: strings.TrimSpace(auth.Provider), + Provider: strings.TrimSpace(auth.Provider), + Label: auth.Label, + Status: string(auth.Status), + StatusMessage: auth.StatusMessage, + Disabled: auth.Disabled, + Unavailable: auth.Unavailable, + RuntimeOnly: runtimeOnly, + Source: "memory", + Success: auth.Success, + Failed: auth.Failed, + RecentRequests: hostRecentRequests(auth), + } + if email := authEmail(auth); email != "" { + entry.Email = email + } + if projectID := authProjectID(auth); projectID != "" { + entry.ProjectID = projectID + } + if accountType, account := auth.AccountInfo(); accountType != "" || account != "" { + entry.AccountType = accountType + entry.Account = account + } + if !auth.CreatedAt.IsZero() { + entry.CreatedAt = auth.CreatedAt + } + if !auth.UpdatedAt.IsZero() { + entry.ModTime = auth.UpdatedAt + entry.UpdatedAt = auth.UpdatedAt + } + if !auth.LastRefreshedAt.IsZero() { + entry.LastRefresh = auth.LastRefreshedAt + } + if !auth.NextRetryAfter.IsZero() { + entry.NextRetryAfter = auth.NextRetryAfter + } + if path != "" { + entry.Path = path + entry.Source = "file" + if info, err := os.Stat(path); err == nil { + entry.Size = info.Size() + entry.ModTime = info.ModTime() + } else if os.IsNotExist(err) { + if !runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled || strings.EqualFold(strings.TrimSpace(auth.StatusMessage), "removed via management api")) { + return nil + } + entry.Source = "memory" + } + } + if p := strings.TrimSpace(authAttribute(auth, "priority")); p != "" { + if parsed, err := strconv.Atoi(p); err == nil { + entry.Priority = parsed + } + } else if auth.Metadata != nil { + if rawPriority, ok := auth.Metadata["priority"]; ok { + if priority, okPriority := parsePriorityValue(rawPriority); okPriority { + entry.Priority = priority + } + } + } + if note := strings.TrimSpace(authAttribute(auth, "note")); note != "" { + entry.Note = note + } else if auth.Metadata != nil { + if rawNote, ok := auth.Metadata["note"].(string); ok { + entry.Note = strings.TrimSpace(rawNote) + } + } + if websockets, ok := authWebsocketsValue(auth); ok { + entry.Websockets = websockets + } + return entry +} + +func (h *Host) resolvedAuthDir() string { + if h == nil { + return "" + } + h.mu.Lock() + authDir := "" + if h.runtimeConfig != nil { + authDir = strings.TrimSpace(h.runtimeConfig.AuthDir) + } + h.mu.Unlock() + if authDir == "" { + return "" + } + authDir = filepath.Clean(authDir) + if !filepath.IsAbs(authDir) { + if abs, errAbs := filepath.Abs(authDir); errAbs == nil { + authDir = abs + } + } + return authDir +} + +func (h *Host) authIDForPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + path = filepath.Clean(path) + if !filepath.IsAbs(path) { + if abs, errAbs := filepath.Abs(path); errAbs == nil { + path = abs + } + } + id := path + if authDir := h.resolvedAuthDir(); authDir != "" { + if rel, errRel := filepath.Rel(authDir, path); errRel == nil && rel != "" { + id = rel + } + } + if runtime.GOOS == "windows" { + id = strings.ToLower(id) + } + return id +} + +func authEmail(auth *coreauth.Auth) string { + if auth == nil { + return "" + } + if auth.Metadata != nil { + if v, ok := auth.Metadata["email"].(string); ok { + return strings.TrimSpace(v) + } + } + if auth.Attributes != nil { + if v := strings.TrimSpace(auth.Attributes["email"]); v != "" { + return v + } + if v := strings.TrimSpace(auth.Attributes["account_email"]); v != "" { + return v + } + } + return "" +} + +func authProjectID(auth *coreauth.Auth) string { + if auth == nil { + return "" + } + if auth.Metadata != nil { + if v, ok := auth.Metadata["project_id"].(string); ok { + if projectID := strings.TrimSpace(v); projectID != "" { + return projectID + } + } + } + if auth.Attributes != nil { + if projectID := strings.TrimSpace(auth.Attributes["project_id"]); projectID != "" { + return projectID + } + if projectID := strings.TrimSpace(auth.Attributes["gemini_virtual_project"]); projectID != "" { + return projectID + } + } + return "" +} + +func authAttribute(auth *coreauth.Auth, key string) string { + if auth == nil || len(auth.Attributes) == 0 { + return "" + } + return auth.Attributes[key] +} + +func isRuntimeOnlyAuth(auth *coreauth.Auth) bool { + if auth == nil || len(auth.Attributes) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true") +} + +func authWebsocketsValue(auth *coreauth.Auth) (bool, bool) { + if auth == nil { + return false, false + } + if auth.Attributes != nil { + if raw := strings.TrimSpace(auth.Attributes["websockets"]); raw != "" { + parsed, errParse := strconv.ParseBool(raw) + if errParse == nil { + return parsed, true + } + } + } + if auth.Metadata == nil { + return false, false + } + return parseWebsocketsValue(auth.Metadata["websockets"]) +} + +func parsePriorityValue(raw any) (int, bool) { + switch v := raw.(type) { + case int: + return v, true + case int32: + return int(v), true + case int64: + return int(v), true + case float64: + return int(v), true + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(v)) + if err == nil { + return parsed, true + } + } + return 0, false +} + +func parseWebsocketsValue(raw any) (bool, bool) { + switch v := raw.(type) { + case bool: + return v, true + case string: + parsed, errParse := strconv.ParseBool(strings.TrimSpace(v)) + if errParse == nil { + return parsed, true + } + } + return false, false +} + +func bytesTrimSpace(raw []byte) []byte { + return []byte(strings.TrimSpace(string(raw))) +} + +func hostRecentRequests(auth *coreauth.Auth) []pluginapi.HostRecentRequestEntry { + if auth == nil { + return nil + } + snapshot := auth.RecentRequestsSnapshot(time.Now()) + if len(snapshot) == 0 { + return nil + } + out := make([]pluginapi.HostRecentRequestEntry, 0, len(snapshot)) + for _, entry := range snapshot { + out = append(out, pluginapi.HostRecentRequestEntry{ + Time: entry.Time, + Success: entry.Success, + Failed: entry.Failed, + }) + } + return out +} diff --git a/internal/pluginhost/auth_callbacks_test.go b/internal/pluginhost/auth_callbacks_test.go new file mode 100644 index 000000000..c9c079449 --- /dev/null +++ b/internal/pluginhost/auth_callbacks_test.go @@ -0,0 +1,249 @@ +package pluginhost + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +type memoryAuthStorage struct { + payload []byte +} + +func (s *memoryAuthStorage) RawJSON() []byte { + if s == nil { + return nil + } + return append([]byte(nil), s.payload...) +} +func (s *memoryAuthStorage) SaveTokenToFile(authFilePath string) error { + if s == nil || len(s.payload) == 0 { + return fmt.Errorf("memory auth storage payload is empty") + } + return os.WriteFile(authFilePath, s.payload, 0o600) +} + +func TestHostAuthListCallbackUsesAuthManager(t *testing.T) { + authDir := t.TempDir() + path := filepath.Join(authDir, "gemini-a.json") + if errWrite := os.WriteFile(path, []byte(`{"type":"gemini","email":"a@example.com","api_key":"k1"}`), 0o600); errWrite != nil { + t.Fatalf("write auth file: %v", errWrite) + } + + auth := &coreauth.Auth{ + ID: "gemini-a.json", + Provider: "gemini", + FileName: "gemini-a.json", + Label: "a@example.com", + Status: coreauth.StatusActive, + Attributes: map[string]string{ + "path": path, + "source": path, + }, + Metadata: map[string]any{ + "type": "gemini", + "email": "a@example.com", + "api_key": "k1", + }, + Storage: &memoryAuthStorage{payload: []byte(`{"type":"gemini","email":"a@example.com","api_key":"k1"}`)}, + } + auth.EnsureIndex() + + host := New() + host.runtimeConfig = &config.Config{AuthDir: authDir} + host.SetAuthManager(coreauth.NewManager(nil, nil, nil)) + if _, errRegister := host.currentAuthManager().Register(context.Background(), auth); errRegister != nil { + t.Fatalf("register auth: %v", errRegister) + } + + rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthList, nil) + if errCall != nil { + t.Fatalf("callFromPlugin() error = %v", errCall) + } + resp, errDecode := decodeRPCEnvelope[rpcHostAuthListResponse](rawResp) + if errDecode != nil { + t.Fatalf("decode response: %v", errDecode) + } + if len(resp.Files) != 1 { + t.Fatalf("files = %#v, want one entry", resp.Files) + } + entry := resp.Files[0] + if entry.AuthIndex != auth.Index || entry.Name != "gemini-a.json" || entry.Email != "a@example.com" { + t.Fatalf("entry = %#v, want auth index and file metadata", entry) + } +} + +func TestHostAuthGetCallbackReturnsPhysicalJSONByAuthIndex(t *testing.T) { + authDir := t.TempDir() + path := filepath.Join(authDir, "gemini-b.json") + if errWrite := os.WriteFile(path, []byte(`{"type":"gemini","email":"b@example.com","api_key":"k2"}`), 0o600); errWrite != nil { + t.Fatalf("write auth file: %v", errWrite) + } + + auth := &coreauth.Auth{ + ID: "gemini-b.json", + Provider: "gemini", + FileName: "gemini-b.json", + Label: "b@example.com", + Status: coreauth.StatusActive, + Attributes: map[string]string{ + "path": path, + "source": path, + }, + Metadata: map[string]any{ + "type": "gemini", + "email": "b@example.com", + "api_key": "k2", + }, + Storage: &memoryAuthStorage{payload: []byte(`{"type":"gemini","email":"b@example.com","api_key":"changed"}`)}, + } + auth.EnsureIndex() + + host := New() + host.SetAuthManager(coreauth.NewManager(nil, nil, nil)) + if _, errRegister := host.currentAuthManager().Register(context.Background(), auth); errRegister != nil { + t.Fatalf("register auth: %v", errRegister) + } + + req, errMarshal := json.Marshal(pluginapi.HostAuthGetRequest{AuthIndex: auth.Index}) + if errMarshal != nil { + t.Fatalf("marshal request: %v", errMarshal) + } + rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthGet, req) + if errCall != nil { + t.Fatalf("callFromPlugin() error = %v", errCall) + } + resp, errDecode := decodeRPCEnvelope[rpcHostAuthGetResponse](rawResp) + if errDecode != nil { + t.Fatalf("decode response: %v", errDecode) + } + if resp.AuthIndex != auth.Index || resp.Name != "gemini-b.json" { + t.Fatalf("response = %#v, want auth index and name", resp) + } + var decoded map[string]any + if errUnmarshal := json.Unmarshal(resp.JSON, &decoded); errUnmarshal != nil { + t.Fatalf("unmarshal auth json: %v", errUnmarshal) + } + if decoded["email"] != "b@example.com" || decoded["api_key"] != "k2" { + t.Fatalf("decoded json = %#v, want credential payload", decoded) + } +} + +func TestHostAuthListCallbackFallsBackToDisk(t *testing.T) { + authDir := t.TempDir() + path := filepath.Join(authDir, "claude-a.json") + if errWrite := os.WriteFile(path, []byte(`{"type":"claude","email":"c@example.com"}`), 0o600); errWrite != nil { + t.Fatalf("write auth file: %v", errWrite) + } + + host := New() + host.runtimeConfig = &config.Config{AuthDir: authDir} + + rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthList, nil) + if errCall != nil { + t.Fatalf("callFromPlugin() error = %v", errCall) + } + resp, errDecode := decodeRPCEnvelope[rpcHostAuthListResponse](rawResp) + if errDecode != nil { + t.Fatalf("decode response: %v", errDecode) + } + if len(resp.Files) != 1 { + t.Fatalf("files = %#v, want one disk entry", resp.Files) + } + entry := resp.Files[0] + if entry.Name != "claude-a.json" || entry.Type != "claude" || entry.Email != "c@example.com" { + t.Fatalf("entry = %#v, want disk metadata", entry) + } + if entry.ModTime.IsZero() { + t.Fatalf("entry modtime is zero: %#v", entry) + } + _ = time.Now() +} + +func TestHostAuthGetRuntimeCallbackReturnsRuntimeInfo(t *testing.T) { + auth := &coreauth.Auth{ + ID: "gemini-runtime.json", + Provider: "gemini", + FileName: "gemini-runtime.json", + Label: "runtime@example.com", + Status: coreauth.StatusActive, + Attributes: map[string]string{ + "runtime_only": "true", + }, + Metadata: map[string]any{ + "type": "gemini", + "email": "runtime@example.com", + "api_key": "runtime-key", + }, + Storage: &memoryAuthStorage{payload: []byte(`{"type":"gemini","email":"runtime@example.com","api_key":"runtime-key"}`)}, + } + auth.EnsureIndex() + + host := New() + host.SetAuthManager(coreauth.NewManager(nil, nil, nil)) + if _, errRegister := host.currentAuthManager().Register(context.Background(), auth); errRegister != nil { + t.Fatalf("register auth: %v", errRegister) + } + + req, errMarshal := json.Marshal(pluginapi.HostAuthGetRequest{AuthIndex: auth.Index}) + if errMarshal != nil { + t.Fatalf("marshal request: %v", errMarshal) + } + rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthGetRuntime, req) + if errCall != nil { + t.Fatalf("callFromPlugin() error = %v", errCall) + } + resp, errDecode := decodeRPCEnvelope[pluginapi.HostAuthGetRuntimeResponse](rawResp) + if errDecode != nil { + t.Fatalf("decode response: %v", errDecode) + } + if resp.Auth.AuthIndex != auth.Index || resp.Auth.RuntimeOnly != true || resp.Auth.Email != "runtime@example.com" { + t.Fatalf("response = %#v, want runtime auth entry", resp.Auth) + } +} + +func TestHostAuthSaveCallbackWritesPhysicalFile(t *testing.T) { + authDir := t.TempDir() + host := New() + host.runtimeConfig = &config.Config{AuthDir: authDir} + host.SetAuthManager(coreauth.NewManager(nil, nil, nil)) + + req, errMarshal := json.Marshal(pluginapi.HostAuthSaveRequest{ + Name: "saved.json", + JSON: json.RawMessage(`{"type":"gemini","email":"saved@example.com","api_key":"saved-key"}`), + }) + if errMarshal != nil { + t.Fatalf("marshal request: %v", errMarshal) + } + rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthSave, req) + if errCall != nil { + t.Fatalf("callFromPlugin() error = %v", errCall) + } + resp, errDecode := decodeRPCEnvelope[pluginapi.HostAuthSaveResponse](rawResp) + if errDecode != nil { + t.Fatalf("decode response: %v", errDecode) + } + if resp.Name != "saved.json" { + t.Fatalf("response = %#v, want saved file name", resp) + } + data, errRead := os.ReadFile(resp.Path) + if errRead != nil { + t.Fatalf("read saved file: %v", errRead) + } + if string(data) != `{"type":"gemini","email":"saved@example.com","api_key":"saved-key"}` { + t.Fatalf("saved file = %q, want credential json", string(data)) + } + auths := host.currentAuthManager().List() + if len(auths) != 1 || auths[0].FileName != "saved.json" { + t.Fatalf("auths = %#v, want one registered auth", auths) + } +} diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go index 26e2a2d9a..4c3f85503 100644 --- a/internal/pluginhost/host.go +++ b/internal/pluginhost/host.go @@ -11,6 +11,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" log "github.com/sirupsen/logrus" @@ -40,6 +41,7 @@ type Host struct { loaded map[string]*loadedPlugin fused map[string]string runtimeConfig *config.Config + authManager *coreauth.Manager modelExecutor modelExecutor modelClientIDs map[string]struct{} executorModelClientIDs map[string]struct{} diff --git a/internal/pluginhost/host_callbacks.go b/internal/pluginhost/host_callbacks.go index a573fbc33..615c7dc4a 100644 --- a/internal/pluginhost/host_callbacks.go +++ b/internal/pluginhost/host_callbacks.go @@ -119,6 +119,14 @@ func (h *Host) callFromPlugin(ctx context.Context, method string, request []byte return h.callHostStreamClose(request) case pluginabi.MethodHostLog: return h.callHostLog(ctx, request) + case pluginabi.MethodHostAuthList: + return h.callHostAuthList(ctx, request) + case pluginabi.MethodHostAuthGet: + return h.callHostAuthGet(ctx, request) + case pluginabi.MethodHostAuthGetRuntime: + return h.callHostAuthGetRuntime(ctx, request) + case pluginabi.MethodHostAuthSave: + return h.callHostAuthSave(ctx, request) default: return nil, fmt.Errorf("unsupported host callback %s", method) } diff --git a/sdk/pluginabi/types.go b/sdk/pluginabi/types.go index fcaf7f184..8be2e8ba7 100644 --- a/sdk/pluginabi/types.go +++ b/sdk/pluginabi/types.go @@ -67,6 +67,10 @@ const ( MethodHostStreamEmit = "host.stream.emit" MethodHostStreamClose = "host.stream.close" MethodHostLog = "host.log" + MethodHostAuthList = "host.auth.list" + MethodHostAuthGet = "host.auth.get" + MethodHostAuthGetRuntime = "host.auth.get_runtime" + MethodHostAuthSave = "host.auth.save" ) type Envelope struct { diff --git a/sdk/pluginabi/types_test.go b/sdk/pluginabi/types_test.go index 3c3f14453..85cd13b0a 100644 --- a/sdk/pluginabi/types_test.go +++ b/sdk/pluginabi/types_test.go @@ -60,6 +60,18 @@ func TestMethodNamesAreStable(t *testing.T) { if MethodHostModelStreamClose != "host.model.stream_close" { t.Fatalf("MethodHostModelStreamClose = %q", MethodHostModelStreamClose) } + if MethodHostAuthList != "host.auth.list" { + t.Fatalf("MethodHostAuthList = %q", MethodHostAuthList) + } + if MethodHostAuthGet != "host.auth.get" { + t.Fatalf("MethodHostAuthGet = %q", MethodHostAuthGet) + } + if MethodHostAuthGetRuntime != "host.auth.get_runtime" { + t.Fatalf("MethodHostAuthGetRuntime = %q", MethodHostAuthGetRuntime) + } + if MethodHostAuthSave != "host.auth.save" { + t.Fatalf("MethodHostAuthSave = %q", MethodHostAuthSave) + } if MethodExecutorExecuteStream != "executor.execute_stream" { t.Fatalf("MethodExecutorExecuteStream = %q", MethodExecutorExecuteStream) } diff --git a/sdk/pluginapi/types.go b/sdk/pluginapi/types.go index 7aa117132..f5521f2c0 100644 --- a/sdk/pluginapi/types.go +++ b/sdk/pluginapi/types.go @@ -3,6 +3,7 @@ package pluginapi import ( "context" + "encoding/json" "net/http" "net/url" "time" @@ -586,6 +587,117 @@ type HostModelStreamCloseRequest struct { StreamID string `json:"stream_id"` } +type HostRecentRequestEntry struct { + // Time is the recent request bucket label. + Time string `json:"time"` + // Success is the success count in the bucket. + Success int64 `json:"success"` + // Failed is the failure count in the bucket. + Failed int64 `json:"failed"` +} + +// HostAuthFileEntry describes one credential exposed through host auth callbacks. +type HostAuthFileEntry struct { + // ID identifies the credential record. + ID string `json:"id,omitempty"` + // AuthIndex is the stable runtime credential index. + AuthIndex string `json:"auth_index,omitempty"` + // Name is the credential file name or runtime identifier. + Name string `json:"name"` + // Type is the credential provider type. + Type string `json:"type,omitempty"` + // Provider is the credential provider key. + Provider string `json:"provider,omitempty"` + // Label is the human-readable credential label. + Label string `json:"label,omitempty"` + // Status is the current credential status. + Status string `json:"status,omitempty"` + // StatusMessage carries the latest status detail. + StatusMessage string `json:"status_message,omitempty"` + // Disabled reports whether the credential is disabled. + Disabled bool `json:"disabled,omitempty"` + // Unavailable reports whether the credential is currently unavailable. + Unavailable bool `json:"unavailable,omitempty"` + // RuntimeOnly reports whether the credential has no backing auth file. + RuntimeOnly bool `json:"runtime_only,omitempty"` + // Source reports whether the credential came from file or memory. + Source string `json:"source,omitempty"` + // Path is the backing auth file path when available. + Path string `json:"path,omitempty"` + // Size is the backing auth file size when available. + Size int64 `json:"size,omitempty"` + // ModTime is the last modification time when available. + ModTime time.Time `json:"modtime,omitempty"` + // UpdatedAt is the last credential update time. + UpdatedAt time.Time `json:"updated_at,omitempty"` + // CreatedAt is the credential creation time. + CreatedAt time.Time `json:"created_at,omitempty"` + // LastRefresh is the last refresh timestamp. + LastRefresh time.Time `json:"last_refresh,omitempty"` + // NextRetryAfter is the next retry timestamp. + NextRetryAfter time.Time `json:"next_retry_after,omitempty"` + // Email is the credential email when available. + Email string `json:"email,omitempty"` + // ProjectID is the credential project identifier when available. + ProjectID string `json:"project_id,omitempty"` + // AccountType is the credential account type when available. + AccountType string `json:"account_type,omitempty"` + // Account is the credential account identifier when available. + Account string `json:"account,omitempty"` + // Priority is the credential routing priority when available. + Priority int `json:"priority,omitempty"` + // Note is the credential note when available. + Note string `json:"note,omitempty"` + // Websockets reports whether websocket mode is enabled when available. + Websockets bool `json:"websockets,omitempty"` + // Success is the recent success count. + Success int64 `json:"success,omitempty"` + // Failed is the recent failure count. + Failed int64 `json:"failed,omitempty"` + // RecentRequests is the recent request snapshot. + RecentRequests []HostRecentRequestEntry `json:"recent_requests,omitempty"` +} + +// HostAuthGetRequest asks the host for credential JSON by auth index. +type HostAuthGetRequest struct { + // AuthIndex identifies the credential index. + AuthIndex string `json:"auth_index"` +} + +// HostAuthGetResponse returns credential JSON resolved by auth index. +type HostAuthGetResponse struct { + // AuthIndex identifies the credential index. + AuthIndex string `json:"auth_index"` + // Name is the credential file name or runtime identifier. + Name string `json:"name,omitempty"` + // Path is the backing auth file path when available. + Path string `json:"path,omitempty"` + // JSON contains the credential JSON payload. + JSON json.RawMessage `json:"json"` +} + +// HostAuthGetRuntimeResponse returns runtime credential information by auth index. +type HostAuthGetRuntimeResponse struct { + // Auth is the runtime credential entry. + Auth HostAuthFileEntry `json:"auth"` +} + +// HostAuthSaveRequest asks the host to persist credential JSON to a physical auth file. +type HostAuthSaveRequest struct { + // Name is the target auth file name. It must end with .json. + Name string `json:"name"` + // JSON contains the credential JSON payload to save. + JSON json.RawMessage `json:"json"` +} + +// HostAuthSaveResponse reports the saved physical auth file. +type HostAuthSaveResponse struct { + // Name is the saved auth file name. + Name string `json:"name"` + // Path is the saved auth file path. + Path string `json:"path"` +} + // HTTPRequest describes an upstream HTTP request issued through the host. type HTTPRequest struct { // Method is the HTTP method.