diff --git a/README.md b/README.md index 6aac73c..519d349 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [](https://help.router-for.me/plugin/development.html) [](LICENSE) -[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 动态 C ABI 插件,用于提供 **Command Code** 凭据认证、上游配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard)。 +[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 动态 C ABI 插件,用于提供 **Command Code** 上游配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard)。 --- @@ -16,7 +16,6 @@ - [构建插件](#构建插件) - [安装与目录结构](#安装与目录结构) - [宿主配置 (`config.yaml`)](#宿主配置-configyaml) - - [凭据文件配置](#凭据文件配置) - [管理端点与资源页](#管理端点与资源页) - [1. 浏览器资源页 (`QuotaCard`)](#1-浏览器资源页-quotacard) - [2. 管理 API: 查询用量 (`GET`)](#2-管理-api-查询用量-get) @@ -32,12 +31,12 @@ 1. **标准 C ABI 兼容**: - 导出 `cliproxy_plugin_init`、`cliproxyPluginCall`、`cliproxyPluginFree`、`cliproxyPluginShutdown`。 - 遵照 CLIProxyAPI 官方 JSON Envelope 规范(`ok`, `result`, `error`)。 -2. **双核心能力声明**: - - `auth_provider`: 参与凭据识别、加载、解析与刷新。 - - `management_api`: 注册插件自有的管理端点与浏览器资源页面。 -3. **凭据自动解析 (`auth.parse`)**: - - 自动识别 `commandcode-*.json` 凭据文件、`type: "commandcode"` 配置或包含 `session_token` / Cookie 的凭据。 - - 提取并规范化 `__Secure-commandcode_prod_.session_token`,存入宿主持久化凭据库。 +2. **纯粹的管理监控能力 (`management_api`)**: + - 注册插件自有的用量管理端点与浏览器嵌入式仪表盘资源页面。 + - 无多余的 OAuth 提供商注册,不污染 CLIProxyAPI 后台的 OAuth 授权列表。 +3. **Session Token 灵活提取与支持**: + - 支持在 `config.yaml` 配置或在配额页面上直接输入。 + - 支持纯 token 或完整 Cookie 字符串(自动提取 `__Secure-commandcode_prod_.session_token`)。 4. **精确用量与双滑动窗口限额解析**: - 上游接口:`GET https://api.commandcode.ai/internal/billing/credits`。 - 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。 @@ -55,16 +54,15 @@ ┌────────────────────────────────────────────────────────┐ │ CLIProxyAPI │ │ │ -│ ┌─────────────────────────┐ ┌─────────────────────┐ │ -│ │ Auth Management │ │ Management Center │ │ -│ │ (reads auths/*.json) │ │ (/v0/management) │ │ -│ └───────────┬─────────────┘ └──────────┬──────────┘ │ -│ │ C ABI │ C ABI │ -│ ▼ ▼ │ +│ ┌─────────────────────┐ │ +│ │ Management Center │ │ +│ │ (/v0/management) │ │ +│ └──────────┬──────────┘ │ +│ │ C ABI │ +│ ▼ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ cliproxy-plugin-commandcode.dylib/.so │ │ │ │ │ │ -│ │ • auth.identifier / auth.parse │ │ │ │ • management.register / management.handle │ │ │ │ • Usage Parser & Window Limits Formatter │ │ │ │ • Embedded Single-file HTML/CSS/JS QuotaCard │ │ @@ -126,34 +124,10 @@ plugins: commandcode: enabled: true priority: 1 - session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" + session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串 api_base: "https://api.commandcode.ai" # 可选,默认为官方接口 ``` -### 凭据文件配置 - -除了在 `config.yaml` 中全局配置,你也可以在 CLIProxyAPI 的 `auths/` 凭据目录下创建凭据文件(如 `auths/commandcode-main.json`): - -```json -{ - "type": "commandcode", - "session_token": "YOUR_COMMANDCODE_SESSION_TOKEN", - "email": "user@example.com", - "label": "Command Code Pro" -} -``` - -或者直接放入浏览器 Cookie: - -```json -{ - "type": "commandcode", - "cookie": "__Secure-commandcode_prod_.session_token=YOUR_COMMANDCODE_SESSION_TOKEN; Path=/;" -} -``` - -插件的 `auth.parse` 会自动拦截并完成凭据加载。 - --- ## 管理端点与资源页 diff --git a/plugin/auth.go b/plugin/auth.go index 533db64..21fd77a 100644 --- a/plugin/auth.go +++ b/plugin/auth.go @@ -1,31 +1,12 @@ package plugin import ( - "encoding/json" - "fmt" - "path/filepath" "regexp" "strings" - "time" ) var cookieRegex = regexp.MustCompile(`(?:^|;\s*)__Secure-commandcode_prod_\.session_token=([^;]+)`) -// RawAuthContent represents possible structures inside a commandcode credential JSON file. -type RawAuthContent struct { - Type string `json:"type"` - Provider string `json:"provider"` - ID string `json:"id"` - Label string `json:"label"` - Name string `json:"name"` - Email string `json:"email"` - SessionToken string `json:"session_token"` - CommandCodeSession string `json:"commandcode_session_token"` - Cookie string `json:"cookie"` - Token string `json:"token"` - UpstreamBase string `json:"api_base"` -} - // ExtractSessionToken extracts the clean session token from a raw string or cookie string. func ExtractSessionToken(raw string) string { raw = strings.TrimSpace(raw) @@ -54,113 +35,3 @@ func FormatSessionCookie(token string) string { return "__Secure-commandcode_prod_.session_token=" + clean } -// ParseAuth handles auth.parse requests for Command Code credentials. -func ParseAuth(req AuthParseRequest) (AuthParseResponse, error) { - lowerFileName := strings.ToLower(req.FileName) - isCommandCodeFile := strings.HasPrefix(lowerFileName, "commandcode") && strings.HasSuffix(lowerFileName, ".json") - isCommandCodeProvider := strings.EqualFold(req.Provider, PluginID) - - var content RawAuthContent - var rawMap map[string]any - if len(req.RawJSON) > 0 { - if err := json.Unmarshal(req.RawJSON, &content); err == nil { - _ = json.Unmarshal(req.RawJSON, &rawMap) - } - } - - isExplicitCommandCode := strings.EqualFold(content.Type, PluginID) || - strings.EqualFold(content.Provider, PluginID) || - content.SessionToken != "" || - content.CommandCodeSession != "" || - strings.Contains(content.Cookie, "__Secure-commandcode_prod_.session_token") - - if !isCommandCodeFile && !isCommandCodeProvider && !isExplicitCommandCode { - return AuthParseResponse{Handled: false}, nil - } - - // Extract session token - token := content.SessionToken - if token == "" { - token = content.CommandCodeSession - } - if token == "" && content.Cookie != "" { - token = ExtractSessionToken(content.Cookie) - } - if token == "" && (isCommandCodeFile || isCommandCodeProvider || isExplicitCommandCode) { - token = content.Token - } - token = ExtractSessionToken(token) - - // Determine ID - authID := content.ID - if authID == "" && req.FileName != "" { - base := filepath.Base(req.FileName) - authID = strings.TrimSuffix(base, filepath.Ext(base)) - } - if authID == "" { - authID = "commandcode-default" - } - - // Determine Label - label := content.Label - if label == "" { - label = content.Name - } - if label == "" && content.Email != "" { - label = fmt.Sprintf("Command Code (%s)", content.Email) - } - if label == "" { - label = fmt.Sprintf("Command Code (%s)", authID) - } - - // Build clean StorageJSON - storageMap := map[string]any{ - "type": PluginID, - "provider": PluginID, - "session_token": token, - } - if content.Email != "" { - storageMap["email"] = content.Email - } - if content.Label != "" { - storageMap["label"] = content.Label - } - if content.UpstreamBase != "" { - storageMap["api_base"] = content.UpstreamBase - } - for k, v := range rawMap { - if _, exists := storageMap[k]; !exists { - storageMap[k] = v - } - } - storageJSON, _ := json.Marshal(storageMap) - - metadata := map[string]any{ - "type": PluginID, - "session_token": token, - } - if content.Email != "" { - metadata["email"] = content.Email - } - - attributes := map[string]string{ - "provider": PluginID, - } - - authData := AuthData{ - Provider: PluginID, - ID: authID, - FileName: req.FileName, - Label: label, - Disabled: false, - StorageJSON: storageJSON, - Metadata: metadata, - Attributes: attributes, - NextRefreshAfter: time.Now().Add(24 * time.Hour).UTC(), - } - - return AuthParseResponse{ - Handled: true, - Auth: authData, - }, nil -} diff --git a/plugin/auth_test.go b/plugin/auth_test.go index 22d1917..6eceb28 100644 --- a/plugin/auth_test.go +++ b/plugin/auth_test.go @@ -1,7 +1,6 @@ package plugin import ( - "encoding/json" "testing" ) @@ -60,107 +59,3 @@ func TestFormatSessionCookie(t *testing.T) { t.Errorf("FormatSessionCookie() from cookie = %q, want %q", gotCookie, want) } } - -func TestParseAuth_ExplicitJSON(t *testing.T) { - raw := []byte(`{ - "type": "commandcode", - "session_token": "test-session-token-xyz", - "email": "user@example.com", - "label": "My Command Code Auth" - }`) - - resp, err := ParseAuth(AuthParseRequest{ - FileName: "custom.json", - RawJSON: raw, - }) - if err != nil { - t.Fatalf("ParseAuth error: %v", err) - } - if !resp.Handled { - t.Fatal("expected Handled=true for explicit commandcode type") - } - - auth := resp.Auth - if auth.Provider != PluginID { - t.Errorf("Provider = %q, want %q", auth.Provider, PluginID) - } - if auth.ID != "custom" { - t.Errorf("ID = %q, want %q", auth.ID, "custom") - } - if auth.Label != "My Command Code Auth" { - t.Errorf("Label = %q, want %q", auth.Label, "My Command Code Auth") - } - - var storage map[string]any - if err := json.Unmarshal(auth.StorageJSON, &storage); err != nil { - t.Fatalf("failed to unmarshal StorageJSON: %v", err) - } - if storage["session_token"] != "test-session-token-xyz" { - t.Errorf("StorageJSON session_token = %v, want test-session-token-xyz", storage["session_token"]) - } - - if auth.Metadata["session_token"] != "test-session-token-xyz" { - t.Errorf("Metadata session_token = %v, want test-session-token-xyz", auth.Metadata["session_token"]) - } -} - -func TestParseAuth_FileNameMatch(t *testing.T) { - raw := []byte(`{ - "token": "tok_987654" - }`) - - resp, err := ParseAuth(AuthParseRequest{ - FileName: "commandcode-work.json", - RawJSON: raw, - }) - if err != nil { - t.Fatalf("ParseAuth error: %v", err) - } - if !resp.Handled { - t.Fatal("expected Handled=true for commandcode-*.json filename") - } - if resp.Auth.ID != "commandcode-work" { - t.Errorf("ID = %q, want commandcode-work", resp.Auth.ID) - } - if resp.Auth.Metadata["session_token"] != "tok_987654" { - t.Errorf("session_token = %v, want tok_987654", resp.Auth.Metadata["session_token"]) - } -} - -func TestParseAuth_CookieFormat(t *testing.T) { - raw := []byte(`{ - "cookie": "__Secure-commandcode_prod_.session_token=cookie_tok_456; Path=/" - }`) - - resp, err := ParseAuth(AuthParseRequest{ - FileName: "any.json", - RawJSON: raw, - }) - if err != nil { - t.Fatalf("ParseAuth error: %v", err) - } - if !resp.Handled { - t.Fatal("expected Handled=true for cookie with __Secure-commandcode_prod_.session_token") - } - if resp.Auth.Metadata["session_token"] != "cookie_tok_456" { - t.Errorf("session_token = %v, want cookie_tok_456", resp.Auth.Metadata["session_token"]) - } -} - -func TestParseAuth_UnrelatedFile(t *testing.T) { - raw := []byte(`{ - "type": "openai", - "api_key": "sk-123456" - }`) - - resp, err := ParseAuth(AuthParseRequest{ - FileName: "openai-test.json", - RawJSON: raw, - }) - if err != nil { - t.Fatalf("ParseAuth error: %v", err) - } - if resp.Handled { - t.Fatal("expected Handled=false for unrelated credential file") - } -} diff --git a/plugin/plugin.go b/plugin/plugin.go index a83350c..2cdfad5 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -6,7 +6,6 @@ import ( "fmt" "strings" "sync" - "time" "gopkg.in/yaml.v3" ) @@ -14,7 +13,7 @@ import ( const ( PluginID = "commandcode" PluginName = "commandcode" - PluginVersion = "0.1.0" + PluginVersion = "0.2.1" PluginAuthor = "zgs225" PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode" PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg" @@ -113,17 +112,6 @@ func (p *Plugin) HandleMethod(method string, requestBytes []byte) ([]byte, error case "plugin.quiesce", "plugin.shutdown": return OkEnvelope(map[string]any{"shutdown": true}) - case "auth.identifier": - return OkEnvelope(IdentifierResponse{Identifier: PluginID}) - case "auth.parse": - return p.handleAuthParse(requestBytes) - case "auth.login.start": - return p.handleAuthLoginStart() - case "auth.login.poll": - return p.handleAuthLoginPoll() - case "auth.refresh": - return p.handleAuthRefresh(requestBytes) - case "management.register": return p.handleManagementRegister() case "management.handle": @@ -163,7 +151,6 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) { }, }, Capabilities: RegistrationCapability{ - AuthProvider: true, ManagementAPI: true, }, }) @@ -179,67 +166,6 @@ func (p *Plugin) handleReconfigure(raw []byte) ([]byte, error) { return p.handleRegister(raw) } -func (p *Plugin) handleAuthParse(raw []byte) ([]byte, error) { - var req AuthParseRequest - if len(raw) > 0 { - if err := json.Unmarshal(raw, &req); err != nil { - return ErrorEnvelope("invalid_request", "failed to parse AuthParseRequest: "+err.Error()), nil - } - } - - resp, err := ParseAuth(req) - if err != nil { - return ErrorEnvelope("auth_parse_error", err.Error()), nil - } - - // Cache token in config if config doesn't have one yet - if resp.Handled && resp.Auth.Metadata != nil { - if tok, ok := resp.Auth.Metadata["session_token"].(string); ok && tok != "" { - if p.config.GetSessionToken() == "" { - p.config.SetSessionToken(tok) - } - } - } - - return OkEnvelope(resp) -} - -func (p *Plugin) handleAuthLoginStart() ([]byte, error) { - return OkEnvelope(map[string]any{ - "Provider": PluginID, - "URL": "https://commandcode.ai", - "State": "manual", - "ExpiresAt": time.Now().Add(5 * time.Minute).UTC(), - }) -} - -func (p *Plugin) handleAuthLoginPoll() ([]byte, error) { - return OkEnvelope(map[string]any{ - "Status": "error", - "Message": "Command Code interactive login is not supported; please configure session_token or provide a commandcode-*.json credential file", - }) -} - -func (p *Plugin) handleAuthRefresh(raw []byte) ([]byte, error) { - var req AuthRefreshRequest - if len(raw) > 0 { - _ = json.Unmarshal(raw, &req) - } - - authData := AuthData{ - Provider: PluginID, - ID: req.AuthID, - StorageJSON: req.StorageJSON, - Metadata: req.Metadata, - Attributes: req.Attributes, - NextRefreshAfter: time.Now().Add(24 * time.Hour).UTC(), - } - return OkEnvelope(AuthRefreshResponse{ - Auth: authData, - NextRefreshAfter: authData.NextRefreshAfter, - }) -} - func (p *Plugin) handleManagementRegister() ([]byte, error) { resp, err := RegisterManagement() if err != nil { diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index e92708a..e1f422f 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -39,8 +39,8 @@ api_base: "https://custom-api.commandcode.ai" if reg.Metadata.Version != PluginVersion { t.Errorf("Metadata.Version = %q, want %q", reg.Metadata.Version, PluginVersion) } - if !reg.Capabilities.AuthProvider { - t.Errorf("Capabilities.AuthProvider = false, want true") + if reg.Capabilities.AuthProvider { + t.Errorf("Capabilities.AuthProvider = true, want false") } if !reg.Capabilities.ManagementAPI { t.Errorf("Capabilities.ManagementAPI = false, want true") @@ -84,7 +84,7 @@ session_token: "new-token-abc" } } -func TestPluginAuthIdentifier(t *testing.T) { +func TestPluginAuthIdentifier_NotHandled(t *testing.T) { p := NewPlugin() raw, err := p.HandleMethod("auth.identifier", nil) if err != nil { @@ -92,16 +92,14 @@ func TestPluginAuthIdentifier(t *testing.T) { } var env Envelope - if err := json.Unmarshal(raw, &env); err != nil || !env.OK { + if err := json.Unmarshal(raw, &env); err != nil { t.Fatalf("envelope error: %+v", env) } - - var idResp IdentifierResponse - if err := json.Unmarshal(env.Result, &idResp); err != nil { - t.Fatalf("unmarshal idResp error: %v", err) + if env.OK { + t.Fatal("expected env.OK=false for auth.identifier") } - if idResp.Identifier != PluginID { - t.Errorf("Identifier = %q, want %q", idResp.Identifier, PluginID) + if env.Error == nil || env.Error.Code != "unknown_method" { + t.Errorf("Error = %+v, want code=unknown_method", env.Error) } } diff --git a/plugin/quota_page.go b/plugin/quota_page.go index 6d8b30d..44c708d 100644 --- a/plugin/quota_page.go +++ b/plugin/quota_page.go @@ -663,7 +663,7 @@ const QuotaPageHTML = `