From dcf327d3c5f6316468c178cfba13fc84aa9f9f32 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 19 Oct 2026 13:43:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugins):=20=E4=B8=89=E4=B8=AA=E5=9D=8F?= =?UTF-8?q?=E6=B8=A0=E9=81=93=20+=20=E4=B8=89=E6=8F=92=E4=BB=B6=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=20+=20=E8=AE=A1=E8=B4=B9=E5=8F=AF=E8=A7=81=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报障:「检查一下,同时修复坏渠道,还有插件不是可以自己决定注册 哪些渠道吗?你怎么不修一下,还有除了 billing 以外的两个插件都没有正确的 图标,而且另外两个插件计费也不正确」 ## 一、坏渠道(都在插件里修,不动内核) ### 1. zcode —— base_url 吞掉了 endpoint 路径(404) `base_url = https://zcode.z.ai/api/v1/zcode-plan/anthropic` `endpoint = /api/v1/zcode-plan/anthropic/v1/messages` 内核 URL = base_url + endpoint(provider.go ChatURL)⇒ 路径拼两遍: `.../anthropic/api/v1/zcode-plan/anthropic/v1/messages` → 404 page not found。 修:base_url 只留 host,完整路径进 endpoint。与 v2.0.30 「12 平台 base_url 侵占 endpoint」同族。 ### 2. minimax —— 打到 Web 前端(404「简单指令,无限可能」) base_url 只到 host + adapter=openai ⇒ 请求 /chat/completions,那是 Web 前端。 照参考 minimax-product.ts: MINIMAX_INFER_PATH = /mavis/api/v1/llm/v1/messages(**Anthropic Messages**) 实测(2026-10-19): /mavis/api/v1/llm/v1/messages → 401 invalid access token(路径对) 裸 /chat/completions → 404 page not found /mavis/api/v1/chat/completions → 404 修:endpoint 指向真实推理路径 + adapter 改 anthropic(第二层:协议形状)。 修后从 404 变 401(凭据问题,不再是路由问题)。 ### 3. codearts —— 只声明"可续期"却没声明"怎么续" `account_refresh` 走 `refreshCredential()`,要求 `plat.refresh.url` 或 `refresh_fn` 存在。codearts 只有 accountView 里硬编码的 `refreshable=true` ⇒ 恒返回「该平台未声明续期方式」,自动续期链空转,日志反复 `模型刷新失败 HTTP 400`。而 STS security_token 只有 1 小时寿命,过期后 每个请求都是 `APIG.0602: the security token has expired`。 修法(三层,每层都有独立的坑): a) 加 `refresh_fn` 钩子。华为云 STS 要 **DPoP 头**(jwk 在凭据里), 声明式 REFRESH_STYLES 拿不到私钥 ⇒ 必须用钩子挂已有的 `codeartsRequestToken`。 b) **JWK → PEM 重建**。`codeartsNewDpop()` 返回 {pem,public,private},但 凭据只持久化了 private(JWK),pem 是登录临时文件。而 `codeartsDpopSign` 走 openssl 命令行要 `key.pem` 字符串 ⇒ 直接传会报 `bad argument #1 to 'write' (string expected, got nil)`。 手工组 SEC1 `EC PRIVATE KEY` 的 DER(INTEGER 1 / OCTET STRING d / [0] OID prime256v1 / [1] BIT STRING 04||X||Y)再 PEM 包装。 c) 绑定放在**文件末尾的绑定循环**(与 claim_fn 同处)。在 PLATFORMS 表里 内联会闭包捕获当时的全局 nil —— 与 trae headers_fn 那次同一坑。 ## 二、插件图标(3 个坏 1 个) billing 有 SVG 但**缺 stroke/fill=currentColor**(深色主题下不可见); channels 声明 `icon = ""`(侧边栏空白);quota 连 icon 字段都没有。 统一成 inline SVG + `stroke="currentColor"`(照参考 src/sponsor.js:52 的 图标工厂形状)。 ## 三、判据(4 条,全部做过变异验证) - `TestChannelBaseURLDoesNotSwallowEndpoint`:base_url 里的路径段不得在 endpoint 重复出现。**走真实加载插件**而非正则读源码 —— PLATFORMS 是 Lua 表,有注释/条件拼装/末尾绑定循环,正则会给出假绿。 - `TestMinimaxUsesAnthropicProtocolAndRealInferPath`:路径 + 协议双断言。 - `TestCodeartsDeclaresRefreshHook`:分开报 has_refresh_fn / has_refresh_decl。 只看 refreshable 不够 —— 那是"凭据类型可续",与"平台实现了续期"是两件事。 - `TestCodeartsRebuildsPemFromJwkAndSigns`:**必须真签名**。DER 结构错、 长度错、base64 换行错都得到"非空但无效"的 PEM,只断言非空等于没验。 - `TestB64urlDecodeHandlesLongInput`:补一个真实测漏 —— 我的 b64urlDecode 第一版在输出字节后没清高位(缺 `buf = buf % (2^nbits)`),43 字符的 JWK 字段(32 字节)测出来**完全正确**,于是 PEM 判据在变异后依然全绿。 只有 86 字符的长输入才暴露(double 精度耗尽 → string.char 抛 invalid value)。 这条判据就是为"能跑、能返回、产物错"那类缺陷写的。 - `TestEveryPagePluginDeclaresSVGIcon` / `TestIconUsesCurrentColor`: 按**页面插件清单**遍历而不是只覆盖 billing。billing 当年空串的判据只管 它自己,于是 channels 空串、quota 缺字段时全绿 —— 同类缺陷换文件复发 没有任何东西会响。这条测试还当场咬出了 billing 自己缺 currentColor。 变异验证:zcode base_url 回退 ✓ / minimax adapter 回退 ✓ / 删 refresh_fn 绑定 ✓ / 去掉 buf 掩码 ✓ / OID 多套一层 TLV ✓(5/5 全部咬住) ## 四、计费(诊断,未改价) 查清"计费不正确"的根因:`prices.models` 只配了 **2 条** (deepseek-v4.1-flash + 它的 / 前缀形式),而实际在用 **193 个模型** ⇒ 112006 次请求里 **35672 次(31.8%)成本按 $0 计**。 其中 28203 次集中在 2026-10-05 一天(历史一次性事件); 当前每天 27~137 次,且**渠道模型全部未定价** (Doubao-Seed-2.1-Pro / GLM-5.3-Flash / MiniMax-* / qwen3.6-plus / hy3)。 调研:参考插件从 **models.dev** 拉模型目录(含 cost.input/output)。 实测 models.dev 覆盖我们 139/193 模型(72%),但**不能取中位数** —— 同一模型不同 provider 价差可达数千倍(glm-5.2 从 0 到 1.82), 0 价多是订阅制/coding plan。按 host 匹配 provider 只覆盖 6/24 源。 ⇒ 价格口径需要用户确认,本轮不动价表。 6 个新探针(仅测试/诊断用):__platform_def_probe(平台定义真相)、 __codearts_jwk_pem_probe(PEM 重建 + 真签名)、__b64_probe(解码字节)。 --- internal/core/channels_codearts_pem_test.go | 184 ++++++++++++ internal/core/channels_endpoint_test.go | 146 ++++++++++ internal/lua/plugin_icons_test.go | 117 ++++++++ internal/lua/plugins/billing.lua | 2 +- internal/lua/plugins/channels.lua | 307 +++++++++++++++++++- internal/lua/plugins/quota.lua | 3 + 6 files changed, 751 insertions(+), 8 deletions(-) create mode 100644 internal/core/channels_codearts_pem_test.go create mode 100644 internal/core/channels_endpoint_test.go create mode 100644 internal/lua/plugin_icons_test.go diff --git a/internal/core/channels_codearts_pem_test.go b/internal/core/channels_codearts_pem_test.go new file mode 100644 index 0000000..ddd2207 --- /dev/null +++ b/internal/core/channels_codearts_pem_test.go @@ -0,0 +1,184 @@ +package core + +import ( + "encoding/base64" + "encoding/json" + "os" + "strings" + "testing" +) + +// codearts JWK→PEM 重建判据。 +// +// 背景(真实缺陷,2026-10-19 用户报「华为云源用不了」): +// +// 华为云 STS 的 security_token 只活 1 小时,续期(refresh_token 换新)需要 +// DPoP 请求签名。签名走 `codeartsDpopSign(key, ...)`,它要求 `key.pem` 是 +// **PEM 字符串**(内部用 openssl 命令行签)。而持久化的凭据里**只有** +// `dpop_private_key_jwk`(私钥的 JWK)—— PEM 是登录时的临时文件,没保存。 +// +// 于是续期必须先从 JWK 重建 PEM。这条判据钉住"重建出来的 PEM 真能被 openssl +// 接受并签出有效的 ES256 JWS",而不是"函数返回了非空字符串"。 +// +// 为什么必须真签名:DER 结构错、长度字段错、base64 换行错,都会得到 +// **非空但无效**的 PEM。只断言非空等于什么都没验。 +func TestCodeartsRebuildsPemFromJwkAndSigns(t *testing.T) { + // 一个结构合法的 P-256 私钥 JWK(生产凭据的真实形状:三个字段都是 + // 32 字节 base64url)。用固定值而非随机生成,是为了让失败可复现。 + jwk := map[string]any{ + "crv": "P-256", + "kty": "EC", + "d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U", + "x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4", + "y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM", + } + + _, ps := poolCore(t) + out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": jwk}) + if err != nil { + t.Fatalf("探针调用失败: %v", err) + } + wrapped, _ := out.(map[string]any) + inner, _ := wrapped["json"].(map[string]any) + if inner == nil { + t.Fatalf("探针返回缺 json 包装: %#v", out) + } + if e, _ := inner["error"].(string); e != "" { + t.Fatalf("JWK→PEM 重建失败: %s", e) + } + + // ① PEM 形状必须是 openssl 认的 SEC1 EC PRIVATE KEY + pem, _ := inner["pem"].(string) + if !strings.HasPrefix(pem, "-----BEGIN EC PRIVATE KEY-----") { + t.Fatalf("PEM 头部不对: %q", firstLine(pem)) + } + if !strings.Contains(pem, "-----END EC PRIVATE KEY-----") { + t.Fatalf("PEM 尾部不对: %q", lastLine(pem)) + } + // 每行(除首尾)不得超过 64 字符 —— openssl 对超长行会拒 + for _, ln := range strings.Split(strings.TrimSpace(pem), "\n") { + if strings.HasPrefix(ln, "-----") { + continue + } + if len(ln) > 64 { + t.Fatalf("PEM base64 行长度 %d > 64,openssl 会拒绝", len(ln)) + } + } + + // ② 最关键:真签名必须成功(DER/长度/换行任一错都在这里暴露) + if ok, _ := inner["signed"].(bool); !ok { + t.Fatalf("PEM 重建后 DPoP 签名失败: %v", inner["sign_error"]) + } + + // ③ 签名产物必须是三段式 JWS(header.payload.signature) + // 探针不直接回 JWS,但 signed=true 已表示 codeartsDpopSign 走完了 + // 整个 openssl 签名 + DER→raw 转换链路(任一环节失败都会返回 nil)。 +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +func lastLine(s string) string { + s = strings.TrimRight(s, "\n") + if i := strings.LastIndexByte(s, '\n'); i >= 0 { + return s[i+1:] + } + return s +} + +// TestCodeartsKeyFromJwkRejectsBadJwk 钉住"坏 JWK 要响亮报错"。 +// +// 为什么需要:JWK 字段长度不对(例如被截断、或根本不是 P-256)时, +// 若静默构造出一个畸形 PEM,openssl 只会说「签名失败」—— 运维无从判断 +// 是凭据坏了还是代码坏了。必须在这里给可诊断的消息。 +func TestCodeartsKeyFromJwkRejectsBadJwk(t *testing.T) { + cases := []struct { + name string + jwk map[string]any + }{ + {"d 太短", map[string]any{ + "kty": "EC", "crv": "P-256", + "d": "AAAA", "x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4", + "y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM"}}, + {"缺 x", map[string]any{ + "kty": "EC", "crv": "P-256", + "d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, ps := poolCore(t) + out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": c.jwk}) + if err != nil { + return // 调用层报错也算响亮 + } + wrapped, _ := out.(map[string]any) + inner, _ := wrapped["json"].(map[string]any) + if inner == nil { + return + } + if e, _ := inner["error"].(string); e == "" { + if ok, _ := inner["signed"].(bool); ok { + t.Fatalf("坏 JWK 竟然签出了名 —— 应当报错") + } + } + }) + } +} + +// 让 goimports/json 相关导入不被误判未使用(此文件仅用 json 做类型断言辅助)。 +var _ = json.Marshal +var _ = os.Getenv + +// TestB64urlDecodeHandlesLongInput 钉住 base64url 解码的**长输入**正确性。 +// +// 这个判据补的是一个真实的测漏:我第一版的 b64urlDecode 在输出字节后 +// **没有清除已用掉的高位**(缺 `buf = buf % (2^nbits)`)。用 43 字符的 +// JWK 字段(32 字节)测时**结果完全正确** —— 于是那条 PEM 判据在变异后 +// 依然全绿,什么也没验到。 +// +// 只有长输入才暴露:累积缓冲不清高位时,double 精度在约 11 个 6bit 组后 +// 耗尽,math.floor 给出越界值,string.char 直接抛 invalid value。 +// 实测:把 `string.rep(43字符JWK, 10)` 喂进去,无掩码版本当场崩。 +// +// 所以这里必须用**长串**(远超过 11 组),断言解出的字节数与内容都正确。 +func TestB64urlDecodeHandlesLongInput(t *testing.T) { + // 96 个字符 → 72 字节。远超溢出临界点(11 组 = 66 bit)。 + const in = "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7UBpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U" + // 期望值由 Go 标准库算出(不用手写的 Lua 结果做锚,否则自证) + want := base64.RawURLEncoding.EncodeToString + + _, ps := poolCore(t) + out, err := ps.Call("channels", "__b64_probe", map[string]any{"s": in}) + if err != nil { + t.Fatalf("探针调用失败(很可能是 string.char 越界 = 缺高位掩码): %v", err) + } + wrapped, _ := out.(map[string]any) + inner, _ := wrapped["json"].(map[string]any) + if inner == nil { + t.Fatalf("探针返回缺 json 包装: %#v", out) + } + got, _ := inner["hex"].(string) + n, _ := inner["len"].(float64) + + // 期望长度由 Go 自己算(不要手推 len/4*3:输入不一定是 4 的倍数) + dec, derr := base64.RawURLEncoding.DecodeString(in) + if derr != nil { + t.Fatal(derr) + } + if int(n) != len(dec) { + t.Fatalf("解码长度 = %v,期望 %d", n, len(dec)) + } + // 与 Go 解码结果逐字节比对 + wantHex := "" + for _, b := range dec { + wantHex += string("0123456789abcdef"[b>>4]) + string("0123456789abcdef"[b&0xf]) + } + if got != wantHex { + t.Fatalf("解码内容不符(长输入下高位未清除):\n got=%s\nwant=%s", got, wantHex) + } + _ = want +} diff --git a/internal/core/channels_endpoint_test.go b/internal/core/channels_endpoint_test.go new file mode 100644 index 0000000..91bad40 --- /dev/null +++ b/internal/core/channels_endpoint_test.go @@ -0,0 +1,146 @@ +package core + +import ( + "strings" + "testing" +) + +// 渠道端点判据 —— 钉住"base_url 不吞 endpoint 路径"这条铁律。 +// +// 为什么需要这个测试 +// ------------------ +// 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL)。把路径**同时** +// 写进 base_url 和 endpoint,拼出来就是重复路径。生产实测两次撞上同一族缺陷: +// +// zcode base_url=.../api/v1/zcode-plan/anthropic +// endpoint=/api/v1/zcode-plan/anthropic/v1/messages +// ⇒ .../anthropic/api/v1/zcode-plan/anthropic/v1/messages → 404 +// +// minimax base_url=https://agent.minimax.cn + adapter=openai(默认 endpoint +// /chat/completions)⇒ 打到 Web 前端首页,404「简单指令,无限可能」 +// +// 两者报错都是 404,但根因不同(路径重复 / 缺路径段)。单条断言咬不住两种, +// 所以下面分三条断言。 + +// platformDef 从**真实加载的插件**里读一个平台定义。 +// +// ★ 必须走真实加载而不是正则解析源码:PLATFORMS 是 Lua 表,源里有注释、 +// 条件拼装、以及文件末尾的绑定循环(refresh_fn / claim_fn / headers_fn 都在 +// 那里挂)。拿正则在源文本上判断"有没有 refresh 段"会被注释里的字样骗 —— +// 这个坑我上一轮差点踩进去。探针返回的是插件运行时看到的真相。 +func platformDef(t *testing.T, id string) map[string]any { + t.Helper() + _, ps := poolCore(t) + out, err := ps.Call("channels", "__platform_def_probe", map[string]any{"id": id}) + if err != nil { + t.Fatalf("__platform_def_probe(%s) 失败: %v", id, err) + } + // Plugins.Call 返回 {json:{...}, status:...} 包装(历史约定)。 + wrapped, ok := out.(map[string]any) + if !ok { + t.Fatalf("探针返回不是表: %T", out) + } + if e, ok := wrapped["error"].(string); ok && e != "" { + t.Fatalf("%s: %s", id, e) + } + inner, ok := wrapped["json"].(map[string]any) + if !ok { + t.Fatalf("探针返回缺 json 包装: %#v", wrapped) + } + return inner +} + +// TestChannelBaseURLDoesNotSwallowEndpoint 是最核心的一条: +// base_url 里**不得**出现 endpoint 也有的路径段。 +// +// 判别性:zcode 缺陷时 base_url 尾段是 "anthropic",endpoint 里也含 +// "/anthropic/" ⇒ 命中。修好后 base_url 只到 host,永不命中。 +func TestChannelBaseURLDoesNotSwallowEndpoint(t *testing.T) { + for _, id := range []string{"zcode", "minimax", "codearts", "trae", "lobsterai", "buddy"} { + t.Run(id, func(t *testing.T) { + p := platformDef(t, id) + base, _ := p["base_url"].(string) + ep, _ := p["endpoint"].(string) + base = strings.TrimRight(base, "/") + if base == "" { + t.Fatalf("%s 没有 base_url", id) + } + + // 从 base_url 里取出路径部分(host 之后),逐段看是否也出现在 endpoint 里。 + pathPart := "" + if i := strings.Index(base, "://"); i >= 0 { + if j := strings.Index(base[i+3:], "/"); j >= 0 { + pathPart = base[i+3+j:] + } + } + if pathPart == "" { + return // 只到 host,天然不会重复 + } + for _, seg := range strings.Split(strings.Trim(pathPart, "/"), "/") { + if seg == "" { + continue + } + if strings.Contains(ep, "/"+seg+"/") || strings.HasSuffix(ep, "/"+seg) { + t.Fatalf("%s: base_url 已含路径段 %q(base_url=%s),"+ + "endpoint 里又出现(endpoint=%s)⇒ 内核拼 base_url+endpoint 时路径重复", + id, seg, base, ep) + } + } + }) + } +} + +// TestMinimaxUsesAnthropicProtocolAndRealInferPath 钉住 minimax 的两层缺陷。 +// +// 参考 channel-pack/src/minimax-product.ts: +// +// MINIMAX_INFER_PATH = '/mavis/api/v1/llm/v1/messages' ← Anthropic Messages +// apiHost = 'https://agent.minimax.cn' +// +// 缺陷 A:base_url 只到 host + adapter=openai ⇒ 请求 /chat/completions +// ⇒ 那是 Web 前端,404 首页。实测(2026-10-19): +// /mavis/api/v1/llm/v1/messages → 401 invalid access token(路径对) +// 裸 /chat/completions → 404 page not found +// /mavis/api/v1/chat/completions → 404 +// 缺陷 B:即使路径改对,openai 适配器的**请求体形状**也不对 +// (minimax 推理端点是 Anthropic 协议)。 +func TestMinimaxUsesAnthropicProtocolAndRealInferPath(t *testing.T) { + p := platformDef(t, "minimax") + ep, _ := p["endpoint"].(string) + adapter, _ := p["adapter"].(string) + + if !strings.HasSuffix(ep, "/mavis/api/v1/llm/v1/messages") { + t.Errorf("minimax endpoint = %q,应为 /mavis/api/v1/llm/v1/messages"+ + "(照参考 MINIMAX_INFER_PATH);否则请求会打到 Web 前端", ep) + } + if adapter != "anthropic" { + t.Errorf("minimax adapter = %q,应为 anthropic —— 推理端点是 Anthropic "+ + "Messages 协议,用 openai 适配器会把请求体拼错", adapter) + } +} + +// TestCodeartsDeclaresRefreshHook 钉住"声明了可续期就必须声明怎么续"。 +// +// 真实缺陷(2026-10-19 用户报「华为云源用不了」): +// codearts 的 security_token 只活 1 小时;account_refresh 走 refreshCredential(), +// 它要求 plat.refresh.url 或 plat.refresh_fn 存在。当时只声明了 refreshable=true +// (那是 accountView 里硬编码的期望),**没声明怎么续** ⇒ 恒出 +// 「该平台未声明续期方式」,自动续期链空转,日志反复 `模型刷新失败 HTTP 400`。 +// +// 为什么不能只看 refreshable:那是"这个账号的凭据类型可续",与"平台实现了 +// 续期"是两件事。前者为真而后者缺失,正是这个 bug 的形状。 +func TestCodeartsDeclaresRefreshHook(t *testing.T) { + p := platformDef(t, "codearts") + hasHook, _ := p["has_refresh_fn"].(bool) + hasDecl, _ := p["has_refresh_decl"].(bool) + if !hasHook && !hasDecl { + t.Fatal("codearts 既无 refresh_fn 也无 refresh.url —— " + + "account_refresh 会恒返回「该平台未声明续期方式」," + + "而 STS token 只有 1 小时寿命,过期后源的每个请求都会 400") + } + // 华为云 STS 要 DPoP 头,声明式 style 拿不到私钥 ⇒ 必须是钩子 + if !hasHook { + t.Error("codearts 只有声明式 refresh.url,但华为云 STS 需要 DPoP 头" + + "(jwk 在凭据里),声明式 style 发不出来 ⇒ 必须用 refresh_fn") + } +} diff --git a/internal/lua/plugin_icons_test.go b/internal/lua/plugin_icons_test.go new file mode 100644 index 0000000..eebafc0 --- /dev/null +++ b/internal/lua/plugin_icons_test.go @@ -0,0 +1,117 @@ +package lua + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// 侧边栏图标判据 —— 覆盖**所有**带页面的插件,不只是 billing。 +// +// 为什么需要这个测试 +// ------------------ +// billing 当年声明了 icon = "",WebUI 把空串原样塞进按钮 ⇒ 侧边栏那条是空白。 +// 当时只给 billing 加了判据(TestPluginIconIsNotARawEmoji)。后来 channels +// 声明 icon = ""、quota 连 icon 字段都没有 ⇒ 同样的"空白条目"缺陷又一次出现, +// 而判据只管 billing,所以全绿。 +// +// 这就是"判据只覆盖出错的那一个文件"的代价:同类缺陷换个文件复发时没有任何 +// 东西会响。本文件把范围改成**按页面遍历**,新增带页面的插件自动纳入。 + +// pageIcons 返回 插件文件 -> 其 page.icon 的声明值。 +// +// 只认 page.icon(侧边栏读的就是它),不认顶层的其他 icon 字段 —— 那些不影响 +// 侧边栏渲染,混进来会给出假绿。 +func pageIcons(t *testing.T, path string) []string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读 %s 失败: %v", path, err) + } + src := string(b) + + // 短字符串:icon = "..." + shortRe := regexp.MustCompile(`(?m)^\s*icon\s*=\s*"([^"]*)"`) + // 长字符串:icon = [==[ ... ]==](inline SVG 没法写成短字符串而不转义所有引号) + longRe := regexp.MustCompile(`(?s)\bicon\s*=\s*\[==\[(.*?)\]==\]`) + + var out []string + for _, m := range shortRe.FindAllStringSubmatch(src, -1) { + out = append(out, m[1]) + } + for _, m := range longRe.FindAllStringSubmatch(src, -1) { + out = append(out, m[1]) + } + return out +} + +// 带页面(plugin.ui.page)的插件。新增页面插件要登记在这里 —— +// 这是一份"必须被覆盖的清单",漏登记会让新插件逃避判据。 +var pagePlugins = []string{ + "plugins/billing.lua", + "plugins/channels.lua", + "plugins/quota.lua", +} + +// TestEveryPagePluginDeclaresSVGIcon 钉住"每个带页面的插件都有 inline SVG 图标"。 +// +// 两个必查项,各自对应一种真实缺陷: +// - 缺 icon / 空串 ⇒ 侧边栏空白(channels 的实测症状) +// - emoji ⇒ 尺寸不跟原生 tab、忽略主题色(billing 的历史缺陷) +func TestEveryPagePluginDeclaresSVGIcon(t *testing.T) { + for _, p := range pagePlugins { + t.Run(p, func(t *testing.T) { + icons := pageIcons(t, p) + if len(icons) == 0 { + t.Fatalf("%s 有任何 page 却没有任何 icon 声明 —— 侧边栏那条会是空白", p) + } + // 只断言"至少有一个非空且是 SVG":一个文件可能有多个页面, + // 但不能为了通过而声明一个空图标。 + var ok bool + for _, ic := range icons { + if strings.TrimSpace(ic) == "" { + continue + } + if isEmojiIcon(ic) { + t.Errorf("%s 的 page icon 是 emoji %q —— 原生 tab 用 inline SVG"+ + "(stroke: currentColor),emoji 尺寸不一致且不跟主题", p, ic) + continue + } + if !strings.Contains(ic, "]==], + icon = [==[]==], order = 40, mount = [==[
diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 425b87b..d8c674d 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -116,7 +116,7 @@ plugin.kernel = { -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } @@ -385,13 +385,27 @@ local PLATFORMS = { }, hosts = { "account.minimax.cn", "agent.minimax.cn" }, base_url = "https://agent.minimax.cn", + -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: + -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL)。 + -- 此前 base_url 只写 host、adapter=openai,于是请求打到 + -- https://agent.minimax.cn/chat/completions —— 那是 **Web 前端**, + -- 返回 404 首页文案「MiniMax Agent: 简单指令,无限可能」。 + -- 实测(2026-10-19):/mavis/api/v1/llm/v1/messages → 401 invalid + -- access token(=路径正确、凭据问题);裸 /chat/completions 与 + -- /mavis/api/v1/chat/completions 都是 404。 + -- 照参考 minimax-product.ts MINIMAX_INFER_PATH。 + endpoint = "/mavis/api/v1/llm/v1/messages", -- 远端模型目录(照参考 minimax-product.ts MINIMAX_MODELS_PATH) models_remote = { endpoints = { { url = "https://www.minimaxi.com/mavis/api/v1/models", method = "GET" } }, auth = "bearer", parse = "id_list", }, - adapter = "openai", + -- ★ 推理端点是 **Anthropic Messages 协议**(不是 OpenAI): + -- 参考 minimax-product.ts 的 `MINIMAX_INFER_PATH = '/mavis/api/v1/llm/v1/messages'` + -- 与 anthropic 基座 `adapter.endpoint = "/v1/messages"` 同族。 + -- 此前写成 openai ⇒ 即使路径改对,请求体形状也不对。 + adapter = "anthropic", -- 照参考 MINIMAX_FALLBACK_MODELS(4 项)。远端权威: -- GET /mavis/api/v1/models?region=cn&buildEnv=prod models = { "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7" }, @@ -969,6 +983,27 @@ local PLATFORMS = { }, -- claim_fn 由下方 claimCodeArts 实现(引用前向声明,见文件末尾) claim_fn_name = "claimCodeArts", + -- ★ 续期:华为云 STS 的 security_token 只活 1 小时,不续就会 + -- `APIG.0602: the security token has expired` 拒掉每个请求。 + -- + -- 为什么必须在这里声明(真实缺陷,2026-10-19 用户报「华为云源用不了」): + -- account_refresh 走 refreshCredential(),它要求 `plat.refresh.url` 存在; + -- codearts 只声明了 refreshable=true(在 accountView 里硬编码)却没声明 + -- **怎么续** ⇒ 手动刷新恒出 `该平台未声明续期方式`,自动续期链空转, + -- 日志里反复 `模型刷新失败 codearts: HTTP 400`。 + -- + -- 为什么不能走 REFRESH_STYLES 的声明式风格: + -- 华为云 STS 要 **DPoP 头**(ES256 JWS,jwk 用凭据里存的 dpop_private_key_jwk + -- 签),而声明式 style 只发普通 JSON/form 且拿不到私钥。已有实现 + -- `codeartsRequestToken(body, key)` 做完整这件事 —— 故用 refresh_fn + -- 挂钩它,而非新增声明式 style(保持内核/通用层不理解厂商)。 + -- + -- 请求体真值(照参考 channel-pack/src/oauth.ts exchangeRefreshToken): + -- client_id=codearts-agent grant_type=refresh_token + -- refresh_token=<凭据里的> code_verifier=<登录时的> + -- ⚠️ client_id 是 **codearts-agent**,不是凭据里的 access_key_id + -- (我实测把 AK 当 client_id 会得到 `invalid client id`)。 + -- 实际绑定在文件末尾的绑定循环(refresh_fn),不在本表内联。 }, -- Qoder —— **标准 PKCE 设备码轮询**(照参考 qoder-oauth.ts / qoder.ts 逐行移植)。 @@ -1097,8 +1132,16 @@ local PLATFORMS = { -- 只有订阅制才走 api.z.ai。这里用积分制那条。 -- zcode 只存在 /api/v1/zcode-plan/anthropic/v1/messages 这一个 chat 路径 -- (参考 zcode-anthropic.ts:7 注释:openai / 裸 v1/chat/completions 一律 - -- 404 page not found)。适配器 anthropic 补 /v1/messages。 - base_url = "https://zcode.z.ai/api/v1/zcode-plan/anthropic", + -- 404 page not found)。 + -- + -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: + -- 内核 URL = `base_url + endpoint`(provider.go ChatURL)。 + -- 此前 base_url 已含 /api/v1/zcode-plan/anthropic,而下面又声明了 + -- endpoint = "/api/v1/zcode-plan/anthropic/v1/messages" ⇒ 路径被拼两遍: + -- https://zcode.z.ai/api/v1/zcode-plan/anthropic/api/v1/zcode-plan/anthropic/v1/messages + -- → 404 page not found(生产实测 2026-10-19)。 + -- 与 v2.0.30「12 平台 base_url 侵占 endpoint」同族缺陷。 + base_url = "https://zcode.z.ai", -- 远端模型目录(照参考 zcode-upstream.ts ZCODE_CLIENT_CONFIGS_URL) models_remote = { endpoints = { { url = "https://zcode.z.ai/api/v1/client/configs", method = "GET", @@ -1459,6 +1502,104 @@ local function b64urlRaw(bin) return (e:gsub("+", "-"):gsub("/", "_"):gsub("=", "")) end +-- b64urlDecode:base64url → 二进制。 +-- +-- 为什么自己写:内核只导出 `base64_encode`(vm.go:1363),**没有**decode。 +-- 而续期需要把 JWK 的 d/x/y(都是 base64url 字符串)转回原始字节。 +local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +local b64rev = {} +for i = 1, #b64chars do b64rev[b64chars:sub(i, i)] = i - 1 end +local function b64urlDecode(s) + s = tostring(s or ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "") + local out, buf, nbits = {}, 0, 0 + for i = 1, #s do + local v = b64rev[s:sub(i, i)] + if v then + buf = buf * 64 + v + nbits = nbits + 6 + -- ★★ `buf = buf % (2 ^ nbits)` 是必须的:只在取字节时 % 256 而不清掉 + -- 已用掉的高位,buf 会持续累积。短输入(32 字节)碰巧全对,长输入 + -- 则 double 精度耗尽、math.floor 给出越界值 ⇒ string.char 抛 + -- invalid value。判据 TestB64urlDecodeHandlesLongInput 专钉这条。 + while nbits >= 8 do + nbits = nbits - 8 + out[#out + 1] = string.char(math.floor(buf / (2 ^ nbits)) % 256) + buf = buf % (2 ^ nbits) + end + end + end + return table.concat(out) +end + +-- derLen:DER 长度字段(短/长格式)。 +local function derLen(n) + if n < 128 then return string.char(n) end + local bytes = {} + while n > 0 do +table.insert(bytes, 1, string.char(n % 256)) +n = math.floor(n / 256) + end + return string.char(0x80 + #bytes) .. table.concat(bytes) +end + +-- derTLV(tag, value):最小 DER 编码单元。 +local function derTLV(tag, value) + return tag .. derLen(#value) .. value +end + +-- codeartsKeyFromJwk(jwk):把持久化的 DPoP 私钥 JWK **重建**成可签名 key。 +-- +-- 为什么需要它(真实缺陷,2026-10-19): +-- 登录时 `codeartsNewDpop()` 生成 PEM,返回 { pem, public, private }; +-- 但凭据里**只持久化了 private(JWK)** —— pem 是临时文件,登录后即删。 +-- 而 `codeartsDpopSign(key,...)` 走 openssl 命令行,**必须**要 `key.pem` +-- 字符串。直接把 { private = jwk } 丢进去会在 `pf:write(key.pem)` 报 +-- `bad argument #1 to 'write' (string expected, got nil)`。 +-- +-- 做法:用 d/x/y 手工组 SEC1 `EC PRIVATE KEY` 的 DER,再 PEM 包装。 +-- 结构与 openssl 生成的一致(已用实测 PEM 对照过字段顺序): +-- SEQUENCE +-- INTEGER 1 (version) +-- OCTET STRING <32B d> (privateKey) +-- [0] OID 1.2.840.10045.3.1.7 (prime256v1) +-- [1] BIT STRING <04||X||Y> (publicKey) +-- +-- 输出形状与 codeartsNewDpop() **完全一致**(pem + public + private), +-- 于是 codeartsDpopSign / codeartsCredentialFrom 无需分叉。 +local function codeartsKeyFromJwk(jwk) + local d = b64urlDecode(jwk.d) + local x = b64urlDecode(jwk.x) + local y = b64urlDecode(jwk.y) + if #d ~= 32 or #x ~= 32 or #y ~= 32 then +return nil, "DPoP 私钥 JWK 字段长度异常(d=" .. #d .. " x=" .. #x .. " y=" .. #y .. ")" + end + -- OID 1.2.840.10045.3.1.7 (prime256v1) 的 DER 字节 + -- OID 1.2.840.10045.3.1.7(prime256v1)的**完整 TLV**(\6=tag \8=len)。 + -- ★ 这里只需一次 TLV:字符串里已有 tag+len,再套一层会得到 + -- 06 0a 06 08 … —— DER 长了 2 字节,openssl 报 + -- `Could not find private key` / `provider=default`,而**不**说结构错。 + -- 我第一版就是这样(真实 PEM 121 字节 vs 我的 123 字节)。 + local oid = "\6\8*\134H\206=\3\1\7" + local pub = "\4" .. x .. y -- 未压缩点:04 || X || Y + local body = + derTLV("\2", "\1") .. -- INTEGER 1 + derTLV("\4", d) .. -- OCTET STRING d + derTLV("\160", oid) .. -- [0] parameters(oid 已含 tag+len) + derTLV("\161", derTLV("\3", "\0" .. pub)) -- [1] publicKey(BIT STRING 前导 0 位) + local der = derTLV("\48", body) -- SEQUENCE + -- PEM 包装:EC PRIVATE KEY + base64 + 64 字符换行 + local b64 = base64_encode(der) + local lines = { "-----BEGIN EC PRIVATE KEY-----" } + for i = 1, #b64, 64 do lines[#lines + 1] = b64:sub(i, i + 63) end + lines[#lines + 1] = "-----END EC PRIVATE KEY-----" + return { +pem = table.concat(lines, "\n") .. "\n", +public = { kty = "EC", crv = "P-256", x = b64urlRaw(x), y = b64urlRaw(y) }, +private = { kty = "EC", crv = "P-256", x = b64urlRaw(x), y = b64urlRaw(y), +d = b64urlRaw(d) }, + }, nil +end + -- ============================================================================ -- CodeArts(华为)登录:新式 IAM OAuth(PKCE + DPoP ES256) -- ============================================================================ @@ -1676,6 +1817,63 @@ local function codeartsCredentialFrom(token, pkce, key) } end +-- refreshCodeArts(plat, cred):用已有 refresh_token 静默续一次华为云 STS token。 +-- +-- 照参考 channel-pack/src/oauth.ts `exchangeRefreshToken`: +-- requestToken({ client_id: CLIENT_ID, code_verifier, +-- grant_type: GRANT_REFRESH_TOKEN, refresh_token }, keyPair) +-- 请求体四个字段缺一不可(少 code_verifier 会被拒)。 +-- +-- ★ 两个易错点(我自己踩过,2026-10-19): +-- 1. client_id 是 **codearts-agent**(CODEARTS.client_id), +-- **不是**凭据里的 access_key_id —— 把 AK 当 client_id 会得到 +-- `STS5.1806 invalid refresh token: 'invalid client id: HSTACS...'`。 +-- 2. **refresh_token 是一次性的**:换成功一次后旧值立即作废 +-- (再发一次得 `the refresh token has been used`)⇒ 响应里的新 +-- refresh_token **必须**回写,否则下一轮永久失去续期能力。 +-- 这正是本函数用 codeartsCredentialFrom 重组整个凭据的原因。 +-- +-- 返回 (newCredTable, nil) | (nil, err)。失败信息要能定位(带上游 error_code)。 +local function refreshCodeArts(plat, cred) + local rtoken = cred.refresh_token + local verifier = cred.code_verifier + if type(rtoken) ~= "string" or rtoken == "" then + return nil, "无 refresh_token,需重新登录" + end + if type(verifier) ~= "string" or verifier == "" then + return nil, "无 code_verifier,需重新登录" + end + local jwk = cred.dpop_private_key_jwk + if type(jwk) ~= "table" then return nil, "无 DPoP 私钥,需重新登录" end + -- DPoP 私钥必须从凭据里恢复(同一个 key 才能让 STS 认这是同一客户端)。 + -- + -- ★ 这里有一个真实的形状差异(我自己踩过): + -- `codeartsNewDpop()` 返回 { pem, public, private },但凭据里 + -- **只保存了 private(JWK)** —— pem 是登录时的临时文件,没有持久化。 + -- 而 `codeartsDpopSign(key, ...)` 要的是 `key.pem` 字符串(它走 + -- openssl 命令行签名)。所以直接传 { private = jwk } 会在 + -- `pf:write(key.pem)` 报 `bad argument #1 to 'write' (string expected, got nil)`。 + -- + -- 修法:用 JWK 的 d/x/y 重建 PEM(EC PRIVATE KEY 的 SEC1 格式, + -- openssl 认得)。重建后的 key 同时含 pem / public / private, + -- 与 codeartsNewDpop() 的返回形状一致,后续逻辑无需分叉。 + local key, kerr = codeartsKeyFromJwk(jwk) + if not key then return nil, tostring(kerr) end + local token, terr = codeartsRequestToken({ + client_id = CODEARTS.client_id, + code_verifier = verifier, + grant_type = "refresh_token", + refresh_token = rtoken, + }, key) + if not token then return nil, tostring(terr) end + -- token.refresh_token 若上游给了新的就用新的;没给则沿用旧值 + -- (华为云实测会返回新的;保留旧值做兜底不会更差) + if type(token.refresh_token) ~= "string" or token.refresh_token == "" then + token.refresh_token = rtoken + end + return codeartsCredentialFrom(token, { verifier = verifier }, key) +end + -- buildCodeartsAuthorizeUrl(port, pkce, ticketId):照参考 buildOAuthLoginUrl 逐字拼。 -- ⚠️ 三处易错(参考注释都点名了): -- 1. `code_challenge_method=SHA-256` —— 不是 RFC 的 S256!portal 以此识别 @@ -2227,6 +2425,20 @@ do break end end + -- 绑定 codearts 的续期钩子。 + -- + -- ★ 为何必须在这里挂而不是在平台表里内联: + -- PLATFORMS 在第 ~900 行,而 refreshCodeArts 定义在第 ~1720 行。 + -- 在表里写 `refresh_fn = function(...) return refreshCodeArts(...) end` + -- 会闭包捕获**当时的全局 nil**(该名还是 nil)—— 调用时抛错, + -- 与 trae 那次 headers_fn 同一坑(当时只加 auth 无效、表内内联也无效)。 + -- 在文件末尾绑定则天然拿到已定义的值。 + for _, p in ipairs(PLATFORMS) do + if p.id == "codearts" then + p.refresh_fn = function(plat, cred) return refreshCodeArts(plat, cred) end + break + end + end end @@ -3272,11 +3484,31 @@ local REFRESH_STYLES = { -- refreshCredential(plat, acct):真发一次续期请求,成功则**回写凭据与索引**。 -- 返回 (ok, message)。失败原因必须可诊断(不能只说"失败了")。 +-- +-- 两种续期方式: +-- * `plat.refresh`(声明式):普通 JSON/form OAuth —— 大多数平台 +-- * `plat.refresh_fn`(钩子):需要签名的(华为云 STS 要 DPoP 头, +-- 而声明式 style 拿不到私钥) +-- 钩子签名:refresh_fn(plat, cred) -> (newCredTable, nil) | (nil, err) +-- 钩子自己负责写密文(因为写入路径只有一个:__llmsproxy_secrets.set)。 local function refreshCredential(plat, acct) - local r = plat.refresh - if type(r) ~= "table" or not r.url then return false, "该平台未声明续期方式" end local cred = credentialFull(acct.credentialRef) if type(cred) ~= "table" then return false, "凭据无法解析" end + -- ★ 钩子优先:需要签名的平台无法用声明式 style 表达 + -- (DPoP 私钥不在 style 的可见范围内)。 + if type(plat.refresh_fn) == "function" then +if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end +local newCred, ferr = plat.refresh_fn(plat, cred) +if not newCred then return false, tostring(ferr or "续期失败") end +__llmsproxy_secrets.set(acct.credentialRef, json.encode(newCred)) +if newCred.expires_at then +local ms = credentialExpiresAtMs(newCred) +if ms then acct.expiresAt = math.floor(ms / 1000) end +end +return true, "已续期" + end + local r = plat.refresh + if type(r) ~= "table" or not r.url then return false, "该平台未声明续期方式" end if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end local style = REFRESH_STYLES[r.style or "google"] if not style then return false, "未知续期风格: " .. tostring(r.style) end @@ -11180,7 +11412,12 @@ plugin.ui = { page = { page_id = "channels", title = "账号接入", - icon = "", + -- ★ 侧边栏图标必须是 **inline SVG**(不能用 emoji、不能用空串)。 + -- 空串会让侧边栏那条变成空白(billing 曾经就这样,被 + -- TestPluginIconIsNotARawEmoji 钉住);emoji 则在原生 tab 里 + -- 尺寸不一致且不跟主题色。形状照 billing:描边接 currentColor。 + -- 图案:三个节点 + 连线的「多路接入」语义。 + icon = [==[]==], order = 40, mount = [[
@@ -12142,6 +12379,62 @@ function plugin.__trae_entry_probe(args) } } end +-- plugin.__platform_def_probe:只给测试用 —— 把平台定义的**关键字段**暴露成值。 +-- +-- 为何必须走真实加载而不能正则搅源码: +-- PLATFORMS 是 Lua 表,源里有注释、条件拼装、以及文件末尾的绑定循环 +-- (refresh_fn / claim_fn / headers_fn 都在那里挂)。用正则在源文本上找 +-- "有没有 refresh 段"会被注释里的字样骗 —— 我上一轮就差点这么错。 +-- 本探针返回的是**插件运行时看到的真相**。 +function plugin.__platform_def_probe(args) +args = args or {} +local plat = platformById(tostring(args.id or "")) +if not plat then return { json = { error = "未知平台" } } end +return { json = { +id = plat.id, +base_url = plat.base_url or "", +endpoint = plat.endpoint or "", +endpoint_stream = plat.endpoint_stream or "", +adapter = plat.adapter or "openai", +-- 续期能力:**三种声明形态**分开报,别用一个布尔把两件事混在一起 +has_refresh_fn = type(plat.refresh_fn) == "function", +has_refresh_decl = type(plat.refresh) == "table" and plat.refresh.url ~= nil, +} } +end + +-- plugin.__b64_probe:只给测试用 —— 暴露 b64urlDecode 的字节输出(hex)。 +-- +-- 为什么需要它:b64urlDecode 的 bug 形状是「短输入正确、长输入崩/错」—— +-- 累积缓冲不清高位时,double 精度在约 11 个 6bit 组后耗尽, +-- math.floor 会给出越界值(string.char 直接报 invalid value)。 +-- 只用 43 字符的 JWK 字段测不出来,必须有一条**长输入**判据。 +function plugin.__b64_probe(args) + args = args or {} + local bin = b64urlDecode(tostring(args.s or "")) + local hx = {} + for i = 1, #bin do hx[#hx + 1] = string.format("%02x", bin:byte(i)) end + return { json = { len = #bin, hex = table.concat(hx) } } +end + +-- plugin.__codearts_jwk_pem_probe:只给测试/诊断用 —— 验证 JWK→PEM 重建。 +-- +-- 为什么需要它:PEM 重建是纯字节操作(DER 手工组 + base64 + 换行),出错时 +-- openssl 只会说「签名失败」,无法定位是 DER 结构错、长度错还是换行错。 +-- 这个探针把中间产物(PEM 文本)暴露出来,并直接跑一次真实签名。 +function plugin.__codearts_jwk_pem_probe(args) + args = args or {} + local jwk = args.jwk + if type(jwk) ~= "table" then return { json = { error = "need jwk" } } end + local key, kerr = codeartsKeyFromJwk(jwk) + if not key then return { json = { error = tostring(kerr) } } end + local sig, serr = codeartsDpopSign(key, "POST", CODEARTS.sts_token) + return { json = { + pem = key.pem, + signed = sig ~= nil, + sign_error = serr and tostring(serr) or nil, + } } +end + -- plugin.__zcode_diag_probe:只给测试/诊断用 —— 暴露共用的 3012 判据模块。 -- -- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在 diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 912cc56..0be679c 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -1065,6 +1065,9 @@ PLUGIN.ui = { page = { page_id = "quota", title = "Quota", + -- ★ 侧边栏图标必须是 **inline SVG**(空串=空白,emoji=尺寸/主题不一致)。 + -- 图案:三格电池 —— “套餐余量”的直接语义。 + icon = [==[]==], order = 45, mount = [==[