mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-16 13:24:55 +08:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 作用域结束时的宿主隐式清理。
|
||||
|
||||
89
examples/plugin/host-callback-auth-files/README.md
Normal file
89
examples/plugin/host-callback-auth-files/README.md
Normal file
@@ -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=<AUTH_INDEX>
|
||||
```
|
||||
|
||||
Read runtime info by auth index:
|
||||
|
||||
```text
|
||||
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=runtime&auth_index=<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.
|
||||
7
examples/plugin/host-callback-auth-files/go/go.mod
Normal file
7
examples/plugin/host-callback-auth-files/go/go.mod
Normal file
@@ -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 => ../../../..
|
||||
531
examples/plugin/host-callback-auth-files/go/main.go
Normal file
531
examples/plugin/host-callback-auth-files/go/main.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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("<!doctype html><html><head><meta charset=\"utf-8\"><title>Host Auth Files</title>")
|
||||
out.WriteString("<style>body{font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;margin:2rem;line-height:1.45;color:#1f2933}code,pre{background:#f3f4f6;border-radius:6px}code{padding:.1rem .3rem}pre{padding:1rem;overflow:auto;white-space:pre-wrap}dl{display:grid;grid-template-columns:max-content 1fr;gap:.35rem 1rem}dt{font-weight:600}dd{margin:0}.error{color:#b42318}</style>")
|
||||
out.WriteString("</head><body><main>")
|
||||
out.WriteString("<h1>Host Auth Files</h1>")
|
||||
out.WriteString("<dl>")
|
||||
writeDefinition(&out, "op", opts.Op)
|
||||
if opts.AuthIndex != "" {
|
||||
writeDefinition(&out, "auth_index", opts.AuthIndex)
|
||||
}
|
||||
if opts.Name != "" {
|
||||
writeDefinition(&out, "name", opts.Name)
|
||||
}
|
||||
out.WriteString("</dl>")
|
||||
if errText != "" {
|
||||
out.WriteString("<h2>Error</h2><pre class=\"error\">")
|
||||
out.WriteString(html.EscapeString(errText))
|
||||
out.WriteString("</pre>")
|
||||
}
|
||||
if result != nil {
|
||||
out.WriteString("<h2>Result</h2><pre>")
|
||||
out.WriteString(html.EscapeString(prettyJSON(result)))
|
||||
out.WriteString("</pre>")
|
||||
}
|
||||
out.WriteString("<h2>Usage</h2><ul>")
|
||||
out.WriteString("<li><code>?op=list</code></li>")
|
||||
out.WriteString("<li><code>?op=get&auth_index=<AUTH_INDEX></code></li>")
|
||||
out.WriteString("<li><code>?op=runtime&auth_index=<AUTH_INDEX></code></li>")
|
||||
out.WriteString("<li><code>?op=save&name=example.json&json=...</code></li>")
|
||||
out.WriteString("</ul>")
|
||||
out.WriteString("</main></body></html>")
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func writeDefinition(out *bytes.Buffer, key string, value string) {
|
||||
out.WriteString("<dt>")
|
||||
out.WriteString(html.EscapeString(key))
|
||||
out.WriteString("</dt><dd><code>")
|
||||
out.WriteString(html.EscapeString(value))
|
||||
out.WriteString("</code></dd>")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
651
internal/pluginhost/auth_callbacks.go
Normal file
651
internal/pluginhost/auth_callbacks.go
Normal file
@@ -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
|
||||
}
|
||||
249
internal/pluginhost/auth_callbacks_test.go
Normal file
249
internal/pluginhost/auth_callbacks_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user