4 Commits
Author SHA1 Message Date
yuez d7733552ee feat: commandcode usage query via /alpha endpoints with Provider API key (v0.5.0)
- New config commandcode_api_key (user_... provider key, long-lived);
  when set, usage queries hit /alpha/billing/credits + /alpha/usage/summary
  with Bearer auth — no more 7-day session cookie rotation
- session_token kept as optional fallback (legacy /internal + cookie path)
- alpha credits lack opensourceMonthlyCredits; formatter tolerates
- alpha non-200 passes through status without echoing upstream body
2026-09-23 17:13:01 +08:00
yuez c2b8d89ba6 fix(quota): declare monthlyTargetTime (undeclared since v0.4.2 refactor), ReferenceError when Command Code data absent; add undeclared-identifier scan to pagecheck (v0.4.5) 2026-09-23 10:09:23 +08:00
yuez 9d1884cf20 feat(quota): All tab Command Code card shows Plan + 5h/weekly/monthly windows with reset times (v0.4.4) 2026-09-20 15:25:29 +08:00
yuez 447ce65c9b feat(quota): All tab first in tab bar; remove header usage-alert badge (v0.4.3)
- Tab order: All | Command Code | OpenCode Go (All remains the default)
- Drop the global status badge in the header action row and its
  updateGlobalBadge aggregation; per-card chips and All-tab provider
  badges are unaffected
2026-09-20 14:52:24 +08:00
9 changed files with 902 additions and 73 deletions
+3 -2
View File
@@ -40,7 +40,7 @@
- 支持在 `config.yaml` 配置或在配额页面上直接输入。
- 支持纯 token 或完整 Cookie 字符串(自动提取 `__Secure-commandcode_prod_.session_token`)。
4. **精确用量与双滑动窗口限额解析**:
- 上游接口:`GET https://api.commandcode.ai/internal/billing/credits`。
- 上游接口(v0.5.0+):配置 `commandcode_api_key`(Provider API key,长期凭据)时走 `GET https://api.commandcode.ai/alpha/billing/credits` 与 `/alpha/usage/summary`(Bearer 认证);否则回退 session cookie 查 `/internal/billing/credits`。
- 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。
- 全面解析 `credits`(月度基础额度、开源奖励额度、总可用额度)与 `windowLimits`(5小时短期滑动窗口、周度窗口限额,计算已用量、上限、剩余量、使用百分比及重置时间)。
5. **嵌入式纯单文件 QuotaCard 资源页**:
@@ -132,7 +132,8 @@ plugins:
commandcode:
enabled: true
priority: 1
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # v0.4.5 前唯一凭据;v0.5.0 起为可选回退
commandcode_api_key: "user_YOUR_COMMANDCODE_PROVIDER_KEY" # v0.5.0+ 推荐:非空则用量查询走 /alpha 端点(Bearer),无需 session cookie
api_base: "https://api.commandcode.ai" # 可选,默认为官方接口
opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # 可选(单 key 兑底,v0.3.0+)
# v0.4.0+ 多 key:list 优先于单 key 字段,每 key 独立账号独立配额窗口
+64 -17
View File
@@ -157,14 +157,23 @@ func handleGetUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
apiBase = cfg.GetAPIBase()
}
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
// commandcode_api_key is NOT overridable via query parameters (same
// secrets-out-of-URLs policy as the OpenCode handler): the plugin
// config is the only credential source on GET.
apiKey := ""
if cfg != nil {
apiKey = cfg.GetCommandCodeAPIKey()
}
return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID)
}
func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
var body struct {
SessionToken string `json:"session_token"`
Token string `json:"token"`
APIBase string `json:"api_base"`
SessionToken string `json:"session_token"`
Token string `json:"token"`
APIBase string `json:"api_base"`
CommandCodeAPIKey string `json:"commandcode_api_key"`
}
if len(req.Body) > 0 {
@@ -176,6 +185,7 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
sessionToken = body.Token
}
apiBase := body.APIBase
apiKey := body.CommandCodeAPIKey
// Fallback to plugin config if body didn't specify
if sessionToken == "" && cfg != nil {
@@ -184,12 +194,22 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
if apiBase == "" && cfg != nil {
apiBase = cfg.GetAPIBase()
}
if apiKey == "" && cfg != nil {
apiKey = cfg.GetCommandCodeAPIKey()
}
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID)
}
func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackID string) (ManagementResponse, error) {
if strings.TrimSpace(sessionToken) == "" {
func executeUsageQuery(ctx context.Context, apiBase, apiKey, sessionToken, hostCallbackID string) (ManagementResponse, error) {
apiKey = strings.TrimSpace(apiKey)
// Credential priority: commandcode_api_key non-empty → /alpha + Bearer
// (Provider API key, no cookie); otherwise session_token → /internal
// + Cookie (legacy fallback). Neither present → 400 with the
// "session_token is required" prefix (isLocalCredentialError in the
// /all aggregate depends on this message).
if strings.TrimSpace(sessionToken) == "" && apiKey == "" {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"error": "session_token is required. Configure session_token in plugin config, provide a credential file, or pass session_token in request",
@@ -203,7 +223,14 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
}, nil
}
raw, statusCode, errFetch := FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID)
var raw []byte
var statusCode int
var errFetch error
if apiKey != "" {
raw, statusCode, errFetch = FetchCommandCodeCreditsAlphaRaw(ctx, apiBase, apiKey, hostCallbackID)
} else {
raw, statusCode, errFetch = FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID)
}
if errFetch != nil {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
@@ -223,12 +250,19 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
}
if statusCode != http.StatusOK {
resBytes, _ := json.Marshal(map[string]any{
payload := map[string]any{
"ok": false,
"status_code": statusCode,
"error": "upstream returned non-200 status",
"body": string(raw),
})
}
if apiKey != "" {
// Alpha path: do NOT echo the upstream body; point at the
// configured Provider API key instead.
payload["error"] = fmt.Sprintf("commandcode upstream returned %d: check commandcode_api_key", statusCode)
} else {
payload["error"] = "upstream returned non-200 status"
payload["body"] = string(raw)
}
resBytes, _ := json.Marshal(payload)
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
@@ -240,7 +274,14 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
// Fetch billing-period (monthly) usage totals; non-fatal if unavailable.
var summary *UpstreamUsageSummaryResponse
if sumRaw, sumStatus, sumErr := FetchUsageSummaryRaw(ctx, apiBase, sessionToken, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
if apiKey != "" {
if sumRaw, sumStatus, sumErr := FetchCommandCodeUsageSummaryAlphaRaw(ctx, apiBase, apiKey, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
var parsed UpstreamUsageSummaryResponse
if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 {
summary = &parsed
}
}
} else if sumRaw, sumStatus, sumErr := FetchUsageSummaryRaw(ctx, apiBase, sessionToken, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
var parsed UpstreamUsageSummaryResponse
if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 {
summary = &parsed
@@ -369,16 +410,19 @@ func handleOpenCodeUsage(ctx context.Context, req ManagementRequest, cfg *Plugin
// all failed due to upstream errors → 502.
func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
sessionToken := ""
commandcodeAPIKey := ""
opencodeKeys := []string{}
if req.Method == http.MethodPost && len(req.Body) > 0 {
var body struct {
SessionToken string `json:"session_token"`
OpencodeAPIKeys []string `json:"opencode_api_keys"`
OpencodeAPIKey string `json:"opencode_api_key"`
SessionToken string `json:"session_token"`
CommandCodeAPIKey string `json:"commandcode_api_key"`
OpencodeAPIKeys []string `json:"opencode_api_keys"`
OpencodeAPIKey string `json:"opencode_api_key"`
}
_ = json.Unmarshal(req.Body, &body)
sessionToken = body.SessionToken
commandcodeAPIKey = body.CommandCodeAPIKey
opencodeKeys = normalizeOpenCodeKeys(body.OpencodeAPIKeys)
if len(opencodeKeys) == 0 {
if single := strings.TrimSpace(body.OpencodeAPIKey); single != "" {
@@ -391,6 +435,9 @@ func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
if sessionToken == "" && cfg != nil {
sessionToken = cfg.GetSessionToken()
}
if commandcodeAPIKey == "" && cfg != nil {
commandcodeAPIKey = cfg.GetCommandCodeAPIKey()
}
if len(opencodeKeys) == 0 && cfg != nil {
opencodeKeys = cfg.GetOpenCodeAPIKeys()
}
@@ -411,7 +458,7 @@ func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
succeeded := 0
// Provider 1: Command Code (reuses executeUsageQuery).
ccResp, _ := executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
ccResp, _ := executeUsageQuery(ctx, apiBase, commandcodeAPIKey, sessionToken, req.HostCallbackID)
if ccResp.StatusCode == http.StatusOK {
resp.CommandCode = ccResp.Body
succeeded++
+366 -3
View File
@@ -3,6 +3,7 @@ package plugin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -74,8 +75,8 @@ func TestHandleManagement_QuotaResource(t *testing.T) {
if !strings.Contains(bodyStr, "用量配额") {
t.Errorf("Body does not contain expected menu text 用量配额")
}
if !strings.Contains(bodyStr, "v0.4.2") {
t.Errorf("Body does not contain version badge v0.4.2")
if !strings.Contains(bodyStr, "v0.5.0") {
t.Errorf("Body does not contain version badge v0.5.0")
}
}
}
@@ -175,6 +176,24 @@ const mockOpencodeUsageJSON = `{"usage":{
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
}}`
// mockOpencodeUsageJSONFuture returns the same usage envelope but with reset
// timestamps relative to now. The hardcoded dates in mockOpencodeUsageJSON
// eventually fall into the past (weekly 2026-09-21 did), which makes
// weekly.ResetInSeconds = 0 and breaks the `want > 0` assertion in
// TestHandleManagement_OpencodeUsageRoute. Use this fixture for tests that
// assert positive reset_in_seconds.
func mockOpencodeUsageJSONFuture() string {
now := time.Now().UTC()
ts := func(d time.Duration) string {
return now.Add(d).Format("2006-01-02T15:04:05.000Z")
}
return fmt.Sprintf(`{"usage":{
"rolling": {"status":"ok","percent":4, "resetsAt":"%s"},
"weekly": {"status":"ok","percent":46,"resetsAt":"%s"},
"monthly": {"status":"ok","percent":23,"resetsAt":"%s"}
}}`, ts(2*time.Hour), ts(72*time.Hour), ts(30*24*time.Hour))
}
// Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated
// OpenCode handler and NOT swallowed by the generic "/usage" suffix match
// (which would route it to the Command Code handler).
@@ -194,7 +213,7 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
}
sawAuthHeader = true
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
_, _ = w.Write([]byte(mockOpencodeUsageJSONFuture()))
}))
defer ts.Close()
@@ -801,3 +820,347 @@ func TestHandleManagement_UnknownPath(t *testing.T) {
t.Fatalf("StatusCode = %d, want 404", resp.StatusCode)
}
}
// ---- v0.5.0: commandcode_api_key → /alpha + Bearer path ----
// mockAlphaCreditsJSON omits opensourceMonthlyCredits, matching the real
// /alpha/billing/credits payload shape (field difference vs /internal).
const mockAlphaCreditsJSON = `{"credits":{"monthlyCredits":555},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`
// newAlphaTestServer: /alpha/billing/credits and /alpha/usage/summary both
// assert Bearer auth and the absence of a Cookie header; every other path
// fails the test (regression guard against falling back to /internal).
func newAlphaTestServer(t *testing.T, wantKey string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer "+wantKey {
t.Errorf("upstream Authorization = %q, want Bearer %s", got, wantKey)
}
if got := r.Header.Get("Cookie"); got != "" {
t.Errorf("upstream Cookie = %q, want none on the /alpha path", got)
}
switch r.URL.Path {
case "/alpha/billing/credits":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(mockAlphaCreditsJSON))
case "/alpha/usage/summary":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 40}`))
default:
t.Errorf("unexpected upstream request: %s %s (internal path must not be hit when commandcode_api_key is set)", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
}
// GET /usage with commandcode_api_key configured → both upstream calls hit
// /alpha/* with Bearer auth and no Cookie; response parses with the alpha
// payload (total = monthly when opensource field is absent) and the monthly
// window is derived from the /alpha summary.
func TestHandleManagement_GetUsage_AlphaKey(t *testing.T) {
ts := newAlphaTestServer(t, "user_cfg-key")
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
cfg := &PluginConfig{
CommandCodeAPIKey: "user_cfg-key",
APIBase: ts.URL,
}
req := ManagementRequest{
Method: http.MethodGet,
Path: "/v0/management/plugins/commandcode/usage",
}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
}
var usage FormattedUsageResponse
if err := json.Unmarshal(resp.Body, &usage); err != nil {
t.Fatalf("unmarshal body error: %v", err)
}
if !usage.OK {
t.Fatal("expected OK=true")
}
if usage.Credits.MonthlyCredits != 555 || usage.Credits.TotalCredits != 555 {
t.Errorf("credits = %+v, want monthly=555 total=555 (opensource absent in alpha payload)", usage.Credits)
}
}
// POST /usage: body commandcode_api_key overrides the configured key (the
// config key gets a 401 from the mock, so a 200 proves the body key won).
func TestHandleManagement_PostUsage_AlphaKeyBodyOverridesConfig(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("Authorization") {
case "Bearer user_body-key":
switch r.URL.Path {
case "/alpha/billing/credits":
_, _ = w.Write([]byte(mockAlphaCreditsJSON))
case "/alpha/usage/summary":
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 40}`))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
}
default:
t.Errorf("upstream got Authorization %q — config key must lose to the POST body key", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusUnauthorized)
}
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
reqBody, _ := json.Marshal(map[string]string{
"commandcode_api_key": "user_body-key",
"api_base": ts.URL,
})
req := ManagementRequest{
Method: http.MethodPost,
Path: "/plugins/commandcode/usage",
Body: reqBody,
}
cfg := &PluginConfig{CommandCodeAPIKey: "user_cfg-must-lose", APIBase: ts.URL}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200 (body key overrides config), body=%s", resp.StatusCode, string(resp.Body))
}
var usage FormattedUsageResponse
if err := json.Unmarshal(resp.Body, &usage); err != nil || !usage.OK {
t.Errorf("unexpected response: err=%v usage=%+v", err, usage)
}
}
// Regression: without commandcode_api_key the legacy /internal + Cookie path
// is preserved; the /alpha endpoints must never be requested.
func TestHandleManagement_Usage_FallbackToInternalCookie(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/alpha/") {
t.Errorf("unexpected /alpha request %s — must stay on /internal without commandcode_api_key", r.URL.Path)
}
switch r.URL.Path {
case "/internal/billing/credits":
if !strings.Contains(r.Header.Get("Cookie"), "legacy-cookie-token") {
t.Errorf("Cookie = %q, want the session token cookie", r.Header.Get("Cookie"))
}
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits": 321},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
case "/internal/usage/summary":
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 11}`))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
}
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
cfg := &PluginConfig{SessionToken: "legacy-cookie-token", APIBase: ts.URL}
t.Run("GET falls back to internal", func(t *testing.T) {
req := ManagementRequest{Method: http.MethodGet, Path: "/plugins/commandcode/usage"}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
}
})
t.Run("POST falls back to internal", func(t *testing.T) {
reqBody, _ := json.Marshal(map[string]string{"session_token": "legacy-cookie-token", "api_base": ts.URL})
req := ManagementRequest{Method: http.MethodPost, Path: "/plugins/commandcode/usage", Body: reqBody}
resp, err := HandleManagement(context.Background(), req, &PluginConfig{})
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
}
})
}
// GET must NOT honor a commandcode_api_key query parameter (same
// secrets-out-of-URLs policy as the OpenCode handler): the config's
// session_token path is used and the query-provided key is ignored.
func TestHandleManagement_GetUsage_NoQueryKeyOverride(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/alpha/billing/credits" || r.URL.Path == "/alpha/usage/summary" {
t.Errorf("unexpected /alpha request %s — query override of commandcode_api_key is not supported", r.URL.Path)
}
if r.URL.Path != "/internal/billing/credits" {
return
}
if r.Header.Get("Cookie") == "" {
t.Errorf("Cookie = %q, want the configured session token cookie", r.Header.Get("Cookie"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits": 77},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
cfg := &PluginConfig{SessionToken: "configured-cookie-token", APIBase: ts.URL}
req := ManagementRequest{
Method: http.MethodGet,
Path: "/v0/management/plugins/commandcode/usage",
Query: map[string][]string{
"commandcode_api_key": {"user_query-must-be-ignored"},
},
}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
}
}
// Alpha upstream non-200 → statusCode passed through, message points at
// commandcode_api_key, and the upstream body is NOT echoed (unlike the
// internal path).
func TestHandleManagement_Usage_AlphaUpstreamNon200(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"upstream secret detail xyzzy"}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
cfg := &PluginConfig{CommandCodeAPIKey: "user_bad-key", APIBase: ts.URL}
resp, err := HandleManagement(context.Background(), ManagementRequest{
Method: http.MethodGet,
Path: "/plugins/commandcode/usage",
}, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("StatusCode = %d, want 401 (upstream status passed through)", resp.StatusCode)
}
var errResp struct {
OK bool `json:"ok"`
StatusCode int `json:"status_code"`
Error string `json:"error"`
Body string `json:"body"`
}
if err := json.Unmarshal(resp.Body, &errResp); err != nil {
t.Fatalf("unmarshal error body: %v", err)
}
if errResp.OK || errResp.StatusCode != http.StatusUnauthorized {
t.Errorf("error payload = %+v, want ok=false status_code=401", errResp)
}
if !strings.Contains(errResp.Error, "commandcode upstream returned 401: check commandcode_api_key") {
t.Errorf("error = %q, want the commandcode_api_key hint message", errResp.Error)
}
if strings.Contains(string(resp.Body), "upstream secret detail") {
t.Errorf("alpha branch must not echo the upstream body, got: %s", string(resp.Body))
}
}
// Neither credential → 400 with the preserved "session_token is required"
// prefix (isLocalCredentialError in /all depends on it).
func TestHandleManagement_Usage_NoCredentialsStillSessionTokenMessage(t *testing.T) {
SetHostCaller(nil)
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
resp, err := HandleManagement(context.Background(), ManagementRequest{
Method: http.MethodGet,
Path: "/plugins/commandcode/usage",
}, &PluginConfig{})
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("StatusCode = %d, want 400, body=%s", resp.StatusCode, string(resp.Body))
}
if msg := extractErrorResponseMessage(resp.Body); !strings.HasPrefix(msg, "session_token is required") {
t.Errorf("error = %q, want the preserved 'session_token is required' prefix", msg)
}
}
// /all: POST body commandcode_api_key overrides the configured session_token
// (and config key) — the Command Code provider goes through /alpha + Bearer.
func TestHandleManagement_AllUsage_CommandCodeAPIKeyOverride(t *testing.T) {
ts := newAlphaTestServer(t, "user_all-key")
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
cfg := &PluginConfig{
SessionToken: "cfg-token-must-lose",
APIBase: ts.URL,
}
reqBody, _ := json.Marshal(map[string]string{"commandcode_api_key": "user_all-key"})
req := ManagementRequest{
Method: http.MethodPost,
Path: "/plugins/commandcode/all",
Body: reqBody,
}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200 (commandcode succeeded via /alpha), body=%s", resp.StatusCode, string(resp.Body))
}
var all AllUsageResponse
if err := json.Unmarshal(resp.Body, &all); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !all.OK {
t.Error("expected ok=true")
}
var ccUsage FormattedUsageResponse
if err := json.Unmarshal(all.CommandCode, &ccUsage); err != nil || !ccUsage.OK {
t.Errorf("commandcode payload invalid: err=%v usage=%+v", err, ccUsage)
}
if ccUsage.Credits.TotalCredits != 555 {
t.Errorf("commandcode total_credits = %v, want 555 (alpha payload)", ccUsage.Credits.TotalCredits)
}
// OpenCode key missing → local-credential error for that provider only.
if _, present := all.Errors["opencode"]; !present {
t.Errorf("expected errors[opencode], got %v", all.Errors)
}
}
+33 -12
View File
@@ -13,7 +13,7 @@ import (
const (
PluginID = "commandcode"
PluginName = "commandcode"
PluginVersion = "0.4.2"
PluginVersion = "0.5.0"
PluginAuthor = "zgs225"
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
@@ -22,12 +22,13 @@ const (
// PluginConfig holds the runtime configuration parsed from YAML.
type PluginConfig struct {
mu sync.RWMutex
SessionToken string `yaml:"session_token" json:"session_token"`
APIBase string `yaml:"api_base" json:"api_base"`
OpenCodeAPIKey string `yaml:"opencode_api_key" json:"opencode_api_key"`
OpenCodeAPIKeys []string `yaml:"opencode_api_keys" json:"opencode_api_keys"`
OpenCodeAPIBase string `yaml:"opencode_api_base" json:"opencode_api_base"`
mu sync.RWMutex
SessionToken string `yaml:"session_token" json:"session_token"`
CommandCodeAPIKey string `yaml:"commandcode_api_key" json:"commandcode_api_key"`
APIBase string `yaml:"api_base" json:"api_base"`
OpenCodeAPIKey string `yaml:"opencode_api_key" json:"opencode_api_key"`
OpenCodeAPIKeys []string `yaml:"opencode_api_keys" json:"opencode_api_keys"`
OpenCodeAPIBase string `yaml:"opencode_api_base" json:"opencode_api_base"`
}
// UpdateFromYAML updates the configuration from raw YAML bytes.
@@ -36,11 +37,12 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
return nil
}
var tmp struct {
SessionToken string `yaml:"session_token"`
APIBase string `yaml:"api_base"`
OpenCodeAPIKey string `yaml:"opencode_api_key"`
OpenCodeAPIKeys []string `yaml:"opencode_api_keys"`
OpenCodeAPIBase string `yaml:"opencode_api_base"`
SessionToken string `yaml:"session_token"`
CommandCodeAPIKey string `yaml:"commandcode_api_key"`
APIBase string `yaml:"api_base"`
OpenCodeAPIKey string `yaml:"opencode_api_key"`
OpenCodeAPIKeys []string `yaml:"opencode_api_keys"`
OpenCodeAPIBase string `yaml:"opencode_api_base"`
}
if err := yaml.Unmarshal(raw, &tmp); err != nil {
return fmt.Errorf("unmarshal config_yaml: %w", err)
@@ -52,6 +54,11 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
if tmp.SessionToken != "" {
c.SessionToken = ExtractSessionToken(tmp.SessionToken)
}
if tmp.CommandCodeAPIKey != "" {
// Provider API key is a plain Bearer token; do not run it through
// ExtractSessionToken (that is Command Code cookie specific).
c.CommandCodeAPIKey = strings.TrimSpace(tmp.CommandCodeAPIKey)
}
if tmp.APIBase != "" {
c.APIBase = strings.TrimRight(tmp.APIBase, "/")
}
@@ -85,6 +92,15 @@ func (c *PluginConfig) GetSessionToken() string {
return c.SessionToken
}
// GetCommandCodeAPIKey safely returns the configured Command Code Provider
// API key. When non-empty, usage queries go through the /alpha endpoints
// with Bearer auth instead of the session-cookie /internal endpoints.
func (c *PluginConfig) GetCommandCodeAPIKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.CommandCodeAPIKey
}
// SetSessionToken safely sets the session token.
func (c *PluginConfig) SetSessionToken(token string) {
c.mu.Lock()
@@ -219,6 +235,11 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) {
Type: "string",
Description: "Command Code session token (__Secure-commandcode_prod_.session_token cookie value)",
},
{
Name: "commandcode_api_key",
Type: "string",
Description: "Command Code Provider API key (user_…); when set, usage queries go through the /alpha endpoints with Bearer auth — no session cookie needed",
},
{
Name: "api_base",
Type: "string",
+42 -4
View File
@@ -46,15 +46,15 @@ api_base: "https://custom-api.commandcode.ai"
t.Errorf("Capabilities.ManagementAPI = false, want true")
}
// Verify config fields (v0.4.0: 4 → 5, adds opencode_api_keys)
if len(reg.Metadata.ConfigFields) != 5 {
t.Fatalf("ConfigFields len = %d, want 5", len(reg.Metadata.ConfigFields))
// Verify config fields (v0.5.0: 5 → 6, adds commandcode_api_key)
if len(reg.Metadata.ConfigFields) != 6 {
t.Fatalf("ConfigFields len = %d, want 6", len(reg.Metadata.ConfigFields))
}
fieldNames := map[string]bool{}
for _, f := range reg.Metadata.ConfigFields {
fieldNames[f.Name] = true
}
if !fieldNames["session_token"] || !fieldNames["api_base"] || !fieldNames["opencode_api_key"] || !fieldNames["opencode_api_keys"] || !fieldNames["opencode_api_base"] {
if !fieldNames["session_token"] || !fieldNames["commandcode_api_key"] || !fieldNames["api_base"] || !fieldNames["opencode_api_key"] || !fieldNames["opencode_api_keys"] || !fieldNames["opencode_api_base"] {
t.Errorf("ConfigFields missing expected fields: %+v", reg.Metadata.ConfigFields)
}
@@ -83,6 +83,44 @@ session_token: "new-token-abc"
}
}
func TestPluginConfig_CommandCodeAPIKey(t *testing.T) {
p := NewPlugin()
// Trimmed, and NOT run through ExtractSessionToken (it is a plain
// Bearer token, not a Command Code cookie string).
configYAML := []byte("commandcode_api_key: \" user_abc123xyz \"\n")
lifecycleReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: configYAML})
if _, err := p.HandleMethod("plugin.register", lifecycleReq); err != nil {
t.Fatalf("handleMethod(plugin.register) error: %v", err)
}
if got := p.config.GetCommandCodeAPIKey(); got != "user_abc123xyz" {
t.Errorf("CommandCodeAPIKey = %q, want user_abc123xyz (trimmed, raw)", got)
}
// Whitespace-only value clears the field.
p2 := NewPlugin()
spaceReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("commandcode_api_key: \" \"\n")})
if _, err := p2.HandleMethod("plugin.register", spaceReq); err != nil {
t.Fatalf("handleMethod(plugin.register) error: %v", err)
}
if got := p2.config.GetCommandCodeAPIKey(); got != "" {
t.Errorf("CommandCodeAPIKey = %q, want empty (whitespace-only)", got)
}
// Omitting the key in a reconfigure must not clear a configured value.
reconfReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("session_token: \"tok\"\n")})
if _, err := p.HandleMethod("plugin.reconfigure", reconfReq); err != nil {
t.Fatalf("handleMethod(plugin.reconfigure) error: %v", err)
}
if got := p.config.GetCommandCodeAPIKey(); got != "user_abc123xyz" {
t.Errorf("CommandCodeAPIKey after reconfigure = %q, want kept user_abc123xyz", got)
}
// Default is empty.
if got := NewPlugin().config.GetCommandCodeAPIKey(); got != "" {
t.Errorf("default CommandCodeAPIKey = %q, want empty", got)
}
}
func TestPluginAuthIdentifier_NotHandled(t *testing.T) {
p := NewPlugin()
raw, err := p.HandleMethod("auth.identifier", nil)
+40 -35
View File
@@ -918,17 +918,12 @@ const QuotaPageHTML = `<!DOCTYPE html>
<div>
<div class="brand-title">
用量配额
<span class="version-tag">v0.4.2</span>
<span class="version-tag">v0.5.0</span>
</div>
</div>
</div>
<div class="action-group">
<div id="statusBadge" class="status-badge">
<span class="status-dot"></span>
<span id="statusText">正在检查...</span>
</div>
<button id="btnSettings" class="btn" title="配置选项">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
@@ -949,9 +944,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
<!-- Tab Bar -->
<div id="tabBar" class="tab-bar">
<button type="button" class="tab-btn active" data-tab="all">All</button>
<button type="button" class="tab-btn" data-tab="commandcode">Command Code</button>
<button type="button" class="tab-btn" data-tab="opencode">OpenCode Go</button>
<button type="button" class="tab-btn active" data-tab="all">All</button>
</div>
<!-- Alert Message -->
@@ -1191,8 +1186,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
const inputOpenCodeKeys = document.getElementById("inputOpenCodeKeys");
const alertBox = document.getElementById("alertBox");
const alertMsg = document.getElementById("alertMsg");
const statusBadge = document.getElementById("statusBadge");
const statusText = document.getElementById("statusText");
const planBadge = document.getElementById("planBadge");
// Tab sections and per-provider error cards
@@ -1241,6 +1234,8 @@ const QuotaPageHTML = `<!DOCTYPE html>
const lastUpdated = document.getElementById("lastUpdated");
let fiveHourTargetTime = null;
let monthlyTargetTime = null;
let weeklyTargetTime = null;
let timerInterval = null;
@@ -1345,26 +1340,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
return level === "error" ? "exceeded" : level === "unknown" ? "" : level;
}
function updateGlobalBadge() {
let p;
if (activeTab === "commandcode") {
p = providerState.commandcode;
} else if (activeTab === "opencode") {
p = providerState.opencode;
} else {
const a = providerState.commandcode;
const b = providerState.opencode;
p = LEVEL_RANK[a.level] >= LEVEL_RANK[b.level] ? a : b;
}
const visual = badgeVisualClass(p.level);
statusBadge.className = visual ? "status-badge " + visual : "status-badge";
statusText.textContent = BADGE_TEXT[p.level] || "正在检查...";
}
function setProviderStatus(provider, level) {
providerState[provider].level = level;
providerState[provider].err = null;
updateGlobalBadge();
}
function summaryRow(label, value) {
@@ -1390,7 +1368,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
ocErrorMsg.textContent = msg;
ocErrorCard.classList.add("show");
}
updateGlobalBadge();
}
// Multi-window countdown elements carry their reset info in data
@@ -1500,7 +1477,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
weeklyTargetTime = null;
}
// Per-provider status; the header badge is aggregated in updateGlobalBadge()
// Per-provider status; consumed by All tab provider badges
let ccLevel;
if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) {
ccLevel = "exceeded";
@@ -1650,12 +1627,43 @@ const QuotaPageHTML = `<!DOCTYPE html>
});
allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level);
allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-";
// Plan + 三个限额窗口(百分比/进度条/重置时间),行结构与 OpenCode 卡片一致,
// 倒计时走 data-reset-at/data-reset-secs 属性,由 updateTimers() 统一刷新
const ccWins = [
{ name: "Rolling 5h", o: limits.five_hour || {} },
{ name: "Weekly", o: limits.weekly || {} },
{ name: "Monthly", o: limits.monthly || {} }
];
let ccRows = "";
ccWins.forEach(function (d) {
const w = d.o;
const pct = clampPercent(w.percentage);
const level = levelOf(pct, w.exceeded, "");
let resetAt = "";
let resetSecs = "";
if (w.reset_at) {
const t = new Date(w.reset_at);
if (!isNaN(t.getTime())) resetAt = t.toISOString();
}
if (!resetAt && w.reset_in_seconds > 0) {
resetSecs = String(w.reset_in_seconds);
}
const resetAttr = resetAt
? " data-reset-at=\"" + esc(resetAt) + "\""
: (resetSecs ? " data-reset-secs=\"" + esc(resetSecs) + "\"" : "");
const pctText = pct === null ? "-" : Math.round(pct) + "%";
const pctCls = level === "exceeded" ? " bad" : pct !== null && pct >= 80 ? " warn" : "";
const barCls = level === "exceeded" ? " danger" : level === "warning" ? " warning" : "";
ccRows += '<div class="oc-win-row">' +
'<span class="oc-win-name">' + d.name + '</span>' +
'<div class="progress-track oc-win-bar"><div class="progress-bar' + barCls + '" style="width:' + (pct === null ? 0 : pct) + '%"></div></div>' +
'<span class="oc-win-pct' + pctCls + '">' + pctText + '</span>' +
'<span class="oc-win-reset countdown-timer"' + resetAttr + '>-</span>' +
'</div>';
});
allBodyCommandcode.innerHTML =
summaryRow("Plan", esc(planName)) +
summaryRow("Total Credits", formatUSD(credits.total_credits)) +
summaryRow("Monthly Credits", formatUSD(credits.monthly_credits)) +
summaryRow("最差窗口", esc(worstName) + " " + (worstPct === null ? "-" : worstPct.toFixed(1) + "%")) +
miniBar(worstPct, worstLevel);
'<div class="oc-key-body">' + ccRows + '</div>';
} else {
allBadgeCommandcode.className = "status-badge";
allBadgeTextCommandcode.textContent = "尚未加载";
@@ -1692,7 +1700,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
document.getElementById("sectionCommandcode").classList.toggle("active", tab === "commandcode");
document.getElementById("sectionOpencode").classList.toggle("active", tab === "opencode");
document.getElementById("sectionAll").classList.toggle("active", tab === "all");
updateGlobalBadge();
if (updateHash) {
if (tab === "all") {
// All 为默认 tab:激活 All 时清掉 hash(刷新回到默认)
@@ -1741,8 +1748,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
if (res.status === 401 || res.status === 403) {
settingsDrawer.classList.add("open");
showAlert("需要 CLIProxyAPI 管理密钥 (401/403)。请在上方输入框填入 Management Key 并保存。", true);
statusBadge.className = "status-badge warning";
statusText.textContent = "未授权";
return;
}
+35
View File
@@ -142,6 +142,41 @@ func FetchUsageSummaryRaw(ctx context.Context, apiBase, sessionToken string, hos
return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID)
}
// fetchUpstreamAlpha performs a GET on a Command Code /alpha endpoint using a
// Provider API key (Bearer auth) instead of a session cookie, reusing the
// host.http.do bridge when available, else falling back to net/http.
func fetchUpstreamAlpha(ctx context.Context, apiBase, endpoint, apiKey, hostCallbackID string) ([]byte, int, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return nil, http.StatusBadRequest, errors.New("missing commandcode_api_key: please provide a valid Command Code Provider API key")
}
if apiBase == "" {
apiBase = DefaultAPIBase
}
url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/"))
headers := map[string][]string{
"Authorization": {"Bearer " + apiKey},
"Accept": {"application/json"},
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
}
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
}
// FetchCommandCodeCreditsAlphaRaw fetches raw credit data from
// {apiBase}/alpha/billing/credits with Bearer auth (Provider API key),
// via host.http.do or net/http fallback.
func FetchCommandCodeCreditsAlphaRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
return fetchUpstreamAlpha(ctx, apiBase, "alpha/billing/credits", apiKey, hostCallbackID)
}
// FetchCommandCodeUsageSummaryAlphaRaw fetches the billing-period (monthly)
// usage totals from {apiBase}/alpha/usage/summary with Bearer auth.
func FetchCommandCodeUsageSummaryAlphaRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
return fetchUpstreamAlpha(ctx, apiBase, "alpha/usage/summary", apiKey, hostCallbackID)
}
// FetchOpenCodeUsageRaw fetches raw OpenCode Go usage data from
// {apiBase}/usage with Bearer auth, via host.http.do or net/http fallback.
func FetchOpenCodeUsageRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
+125
View File
@@ -790,3 +790,128 @@ func TestFetchOpenCodeUsageRaw_HostCaller(t *testing.T) {
t.Errorf("host request Authorization = %v, want Bearer sk-host-key", auth)
}
}
// v0.5.0: the /alpha endpoints authenticate with a Bearer Provider API key
// instead of the session cookie. The URL must be /alpha/billing/credits and
// no Cookie header may be sent.
func TestFetchCommandCodeCreditsAlphaRaw_FallbackHTTP(t *testing.T) {
var sawAuth, sawCookie, sawAccept, sawUA string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/alpha/billing/credits" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
sawAuth = r.Header.Get("Authorization")
sawCookie = r.Header.Get("Cookie")
sawAccept = r.Header.Get("Accept")
sawUA = r.Header.Get("User-Agent")
// Alpha credits omit opensourceMonthlyCredits (field difference vs
// the internal endpoint); formatCredits must tolerate that.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":700},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
body, status, err := FetchCommandCodeCreditsAlphaRaw(context.Background(), ts.URL, "user_test-key", "")
if err != nil {
t.Fatalf("FetchCommandCodeCreditsAlphaRaw error: %v", err)
}
if status != http.StatusOK {
t.Errorf("status = %d, want 200", status)
}
if sawAuth != "Bearer user_test-key" {
t.Errorf("Authorization = %q, want Bearer user_test-key", sawAuth)
}
if sawCookie != "" {
t.Errorf("Cookie = %q, want no Cookie header on the /alpha path", sawCookie)
}
if sawAccept != "application/json" {
t.Errorf("Accept = %q, want application/json", sawAccept)
}
if !strings.Contains(sawUA, "cliproxy-plugin-commandcode/") {
t.Errorf("User-Agent = %q, want cliproxy-plugin-commandcode/<version>", sawUA)
}
usage, errParse := ParseAndFormatUsage(body, nil, time.Time{})
if errParse != nil {
t.Fatalf("ParseAndFormatUsage error: %v", errParse)
}
if usage.Credits.MonthlyCredits != 700 {
t.Errorf("MonthlyCredits = %v, want 700", usage.Credits.MonthlyCredits)
}
if usage.Credits.OpensourceMonthlyCredits != 0 {
t.Errorf("OpensourceMonthlyCredits = %v, want 0 (field absent in alpha payload)", usage.Credits.OpensourceMonthlyCredits)
}
// total = monthly + 0 when opensourceMonthlyCredits is missing.
if usage.Credits.TotalCredits != 700 {
t.Errorf("TotalCredits = %v, want 700 (= monthly when opensource field absent)", usage.Credits.TotalCredits)
}
}
func TestFetchCommandCodeUsageSummaryAlphaRaw_FallbackHTTP(t *testing.T) {
var sawAuth, sawCookie string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/alpha/usage/summary" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
sawAuth = r.Header.Get("Authorization")
sawCookie = r.Header.Get("Cookie")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 123}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
body, status, err := FetchCommandCodeUsageSummaryAlphaRaw(context.Background(), ts.URL+"/", "user_test-key", "")
if err != nil {
t.Fatalf("FetchCommandCodeUsageSummaryAlphaRaw error: %v", err)
}
if status != http.StatusOK {
t.Errorf("status = %d, want 200", status)
}
if sawAuth != "Bearer user_test-key" {
t.Errorf("Authorization = %q, want Bearer user_test-key", sawAuth)
}
if sawCookie != "" {
t.Errorf("Cookie = %q, want no Cookie header on the /alpha path", sawCookie)
}
var summary UpstreamUsageSummaryResponse
if err := json.Unmarshal(body, &summary); err != nil {
t.Fatalf("unmarshal summary error: %v", err)
}
if summary.TotalMonthlyCredits != 123 {
t.Errorf("TotalMonthlyCredits = %v, want 123", summary.TotalMonthlyCredits)
}
}
func TestFetchCommandCodeCreditsAlphaRaw_MissingKey(t *testing.T) {
SetHostCaller(nil)
for _, key := range []string{"", " "} {
_, status, err := FetchCommandCodeCreditsAlphaRaw(context.Background(), "", key, "")
if err == nil {
t.Fatalf("key %q: expected error for missing key", key)
}
if status != http.StatusBadRequest {
t.Errorf("key %q: status = %d, want 400", key, status)
}
if !strings.Contains(err.Error(), "missing commandcode_api_key") {
t.Errorf("key %q: error = %q, want it to mention missing commandcode_api_key", key, err.Error())
}
}
}
+194
View File
@@ -21,3 +21,197 @@ try {
process.exit(1);
}
console.log("pagecheck: embedded JS syntax OK");
// --- Undeclared-identifier audit (guards against ReferenceErrors like the
// v0.4.4 `monthlyTargetTime is not defined` bug: a bare identifier read in
// updateTimers() that was never declared and only existed as a global
// property accidentally created by renderUsage()).
//
// Approach (deliberately simple/grep-style, no DOM execution):
// 1. strip comments and string literals
// 2. collect every var/let/const/function declaration name + function/
// callback/catch parameter
// 3. flag candidates: bare assignment targets (x =, x +=, x++, ...),
// bare if()/while() condition identifiers, and for-loop init identifiers
// 4. whitelist known globals; anything left is reported and fails the check
const whitelist = new Set([
// browser builtins referenced by the page
"document", "window", "localStorage", "sessionStorage", "fetch",
"setInterval", "setTimeout", "clearInterval", "clearTimeout", "console",
"alert", "Date", "Math", "Number", "String", "Boolean", "Array",
"Object", "JSON", "parseInt", "parseFloat", "isNaN", "Promise", "Error",
"escape", "unescape", "navigator", "location", "history", "URL",
"URLSearchParams", "FormData", "Headers", "Request", "Response",
"Intl", "Map", "Set", "AbortController", "requestAnimationFrame",
"cancelAnimationFrame", "structuredClone", "globalThis", "arguments",
]);
function stripLiterals(source) {
// Remove comments, string literals, and regex literals; replace with
// harmless placeholders so identifier scanning never sees string content.
// Template literals are NOT fully discarded: their ${...} interpolations
// are kept as "( ... )" so identifiers inside them stay visible.
//
// A "/" only starts a regex literal when it appears in expression
// position (previous significant token is an operator/open bracket or a
// keyword such as return/typeof). Otherwise it is division.
let out = "";
let i = 0;
const n = source.length;
// Stack of lexer contexts. Each entry: { type: "expr", depth: number } or
// { type: "tmpl" }. The initial code runs in a never-ending expr context.
const stack = [{ type: "expr", depth: Infinity }];
// Last significant (non-whitespace) emitted char + last identifier word,
// used for the regex-vs-division heuristic.
let lastSig = "";
let lastWord = "";
const KEYWORDS_BEFORE_REGEX = new Set([
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
"do", "else", "case", "throw", "await", "yield",
]);
const emit = (text) => {
for (const ch of text) {
if (/\s/.test(ch)) continue;
if (/[A-Za-z0-9_$]/.test(ch)) {
lastWord = /[A-Za-z0-9_$]/.test(lastSig) ? lastWord + ch : ch;
} else {
lastWord = "";
}
lastSig = ch;
}
out += text;
};
const regexAllowed = () =>
lastSig === "" ||
"(,=:[!&|?+-*/%<>~^;{".includes(lastSig) ||
KEYWORDS_BEFORE_REGEX.has(lastWord);
while (i < n) {
const ctx = stack[stack.length - 1];
const c = source[i];
const next = source[i + 1];
if (ctx.type === "tmpl") {
// Inside template literal text: skip until ` or ${ ... }
if (c === "\\") { i += 2; continue; }
if (c === "`") { i++; stack.pop(); emit(" "); continue; }
if (c === "$" && next === "{") {
i += 2;
stack.push({ type: "expr", depth: 0 });
emit(" ( ");
continue;
}
i++;
continue;
}
// code context (top-level or template ${...} expression)
if (c === "/" && next === "/") {
while (i < n && source[i] !== "\n") i++;
} else if (c === "/" && next === "*") {
i += 2;
while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++;
i += 2;
} else if (c === "/" && regexAllowed()) {
// regex literal: skip to unescaped closing / (not inside [...])
i++;
let inClass = false;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === "[") { inClass = true; i++; continue; }
if (source[i] === "]") { inClass = false; i++; continue; }
if (source[i] === "/" && !inClass) { i++; break; }
if (source[i] === "\n") break; // malformed; bail out safely
i++;
}
while (i < n && /[a-z]/i.test(source[i])) i++; // flags
emit(" / ");
} else if (c === '"' || c === "'") {
const quote = c;
i++;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === quote) { i++; break; }
if (source[i] === "\n") break;
i++;
}
emit(" " + quote + quote + " ");
} else if (c === "`") {
i++;
stack.push({ type: "tmpl" });
emit(" ");
} else {
if (c === "{") ctx.depth++;
if (c === "}") {
if (ctx.depth === 0) {
// closes a template interpolation: back into template text
stack.pop();
i++;
emit(" ) ");
continue;
}
ctx.depth--;
}
emit(c);
i++;
}
}
return out;
}
function collectDeclarations(clean) {
const declared = new Set();
const addParamList = (raw) => {
for (const p of raw.split(",")) {
const name = p.trim().split(/[\s=]/)[0].replace(/^\.\.\./, "");
if (/^[A-Za-z_$][\w$]*$/.test(name)) declared.add(name);
}
};
for (const m of clean.matchAll(/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)/g))
declared.add(m[1]);
// function declarations/expressions: name + params
for (const m of clean.matchAll(/\bfunction\s*([A-Za-z_$][\w$]*)?\s*\(([^()]*)\)/g)) {
if (m[1]) declared.add(m[1]);
addParamList(m[2]);
}
// arrow functions: (a, b) => and a =>
for (const m of clean.matchAll(/\(\s*([^()]*?)\s*\)\s*=>/g)) addParamList(m[1]);
for (const m of clean.matchAll(/(?<![\w$.(])\b([A-Za-z_$][\w$]*)\s*=>/g)) declared.add(m[1]);
// catch (e) and destructuring catch
for (const m of clean.matchAll(/\bcatch\s*\(?\s*\{?\s*([A-Za-z_$][\w$]*)/g)) declared.add(m[1]);
return declared;
}
const clean = stripLiterals(js);
const declared = collectDeclarations(clean);
const flagged = new Set();
// 1. bare assignment targets / updates: x =, x +=, x++, x--, x ??=
for (const m of clean.matchAll(/(?:^|[{};\n])\s*([A-Za-z_$][\w$]*)\s*(?:=[^=>]|[+*\/%-]?=[^=]|\+\+|\-\-)/gm)) {
const name = m[1];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
// 2. bare if()/while() condition identifiers
for (const m of clean.matchAll(/\b(?:if|while)\s*\(\s*(!*)\s*([A-Za-z_$][\w$]*)\s*(?:\)|&&|\|\||\?)/g)) {
const name = m[2];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
// 3. for-loop init without let/var: for (i = 0; ...)
for (const m of clean.matchAll(/\bfor\s*\(\s*([A-Za-z_$][\w$]*)\s*=[^=]/g)) {
const name = m[1];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
if (flagged.size > 0) {
console.error("pagecheck: undeclared identifier(s) referenced in embedded JS:");
for (const name of [...flagged].sort()) console.error(" - " + name);
console.error(
"pagecheck: fix by declaring with let/const (see v0.4.5 monthlyTargetTime bug); " +
"if this is a false positive, extend scripts/pagecheck.js"
);
process.exit(1);
}
console.log(
"pagecheck: undeclared-identifier scan OK (" + declared.size + " declarations checked)"
);