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 = [==[