From 8a3059740f2941193caaeef27a988674a03f25c4 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 13 Oct 2026 07:41:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20=E7=99=BB=E5=BD=95=E5=90=8E?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=8B=BF=E4=B8=8D=E5=88=B0=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E5=AE=8C=E6=88=90=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B8=8D=E4=BC=A0=20account=5Fid=20+=20=E5=8F=AA=E5=AD=98?= =?UTF-8?q?=E8=A3=B8=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报障:「登录了,但插件这边无法获取账号信息」。实证: account_list → has_credential=false,credits/balances → 「无凭据」, 而 UI 上账号显示已登录。 ## 根因一:完成路径没把凭据写进池账号的键 UI 的真实流程是「account/add 建占位账号(credentialRef=channels::) → login 带 account_id → 轮询 login/status」。但 poll_login 的两条完成分支 (device-poll / state-poll)调用 complete_login 时**没有传 account_id**, 于是走了兼容旧路径,凭据落到 `channels:` 这个与占位账号无关的键; 池账号的 credentialRef 指向 `channels::`,从未写入 → account_list 的 has_credential 恒为 false。 修:两条分支都传 `account_id = s.account_id`(对齐参考 BuddyAuth.login 的 `flowOptions.accountId` 语义:多账号场景下凭据按账号 ref 写入)。 ## 根因二:只存裸 access_token,丢掉 refresh_token 与过期信息 state-poll 分支只取 `data.accessToken` 当凭据存,而参考 `runBuddyLoginFlow` 存的是 `buildCredential(token, account)` 的**完整 JSON** (access_token + refresh_token + expires_at + refresh_expires_at + token_type + scope + domain + user_id + nickname + enterprise_id + account_type)。 只存裸 token 的后果:refreshable 恒为 false → 无静默续期;expires_at 未知。 修:新增 buildCredential(逐字段对齐参考 buddy.ts)+ jwtClaim/absExpiryMs (参照实现:token 端点只回 expiresIn 不回 expiresAt,昵称只在 JWT 声明里) + credentialExpiresAtMs。state-poll 完成时存完整凭据 JSON 并回报 expires_at/refreshable。 ## 配套 - 新增 credentialOf(ref):凭据取值助手,**裸 token 与完整 JSON 两种形态都认** (历史数据/device-poll/paste 是裸 token,buddy 是 JSON)。所有消费点 (上游源 api_key、Bearer 头、HMAC 签名、备份)改走它,无须关心存储形态。 - complete_login 从凭据自动补齐 nickname/expiresAt/refreshable,调用方漏传也不丢。 - 内核新增 base64_decode(标准/base64url/无填充都认):插件读 JWT payload 必需,否则要在 Lua 里重写 base64。通用能力,无厂商知识。 ## 判据 TestBuddyStatePollBehavior 行2/行4: - 行2 断言凭据是完整 JSON(含 refresh_token + 昵称),非裸 token - 行4 回归用户报障:走 account/add → 带 account_id 登录,断言 ① 凭据落在 channels:buddy: ② account_list 报 has_credential=true ③ refreshable=true 变异验证:去掉 account_id → 行4 立即 FAIL(已实测)。 go test ./internal/core/ 与 ./internal/lua/ 全绿。 --- internal/core/channels_more_test.go | 84 ++++++++- internal/lua/plugins/channels.lua | 268 +++++++++++++++++++++++++--- internal/lua/vm.go | 32 ++++ 3 files changed, 355 insertions(+), 29 deletions(-) diff --git a/internal/core/channels_more_test.go b/internal/core/channels_more_test.go index 2793139..21d8714 100644 --- a/internal/core/channels_more_test.go +++ b/internal/core/channels_more_test.go @@ -260,6 +260,38 @@ func (h *buddyHarness) begin() (map[string]interface{}, string) { j := out.JSON.(map[string]interface{}) return j, j["session"].(map[string]interface{})["id"].(string) } + +// beginPool 走 UI 的真实流程:先 account/add 建占位账号,再带 account_id 登录。 +// 这正是用户报障("登录了但无法获取账号信息")的那条路径。 +func (h *buddyHarness) beginPool() (map[string]interface{}, string, string) { + add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"buddy"}`, + }) + if err != nil { + h.t.Fatal(err) + } + aid := add.JSON.(map[string]interface{})["account_id"].(string) + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: fmt.Sprintf(`{"platform":"buddy","account_id":%q}`, aid), + }) + if err != nil { + h.t.Fatal(err) + } + j := out.JSON.(map[string]interface{}) + return j, j["session"].(map[string]interface{})["id"].(string), aid +} + +func (h *buddyHarness) accountList() []interface{} { + out, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"buddy"}`, + }) + if err != nil { + h.t.Fatal(err) + } + m := out.JSON.(map[string]interface{}) + accts, _ := m["accounts"].([]interface{}) + return accts +} func (h *buddyHarness) poll(id string) map[string]interface{} { out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ Method: "GET", Query: map[string]string{"id": id}, @@ -287,9 +319,17 @@ func TestBuddyStatePollBehavior(t *testing.T) { if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" { t.Fatalf("次拍应 completed+buddy-dev: %v", j) } + // 凭据是**完整凭据 JSON**(对齐参考 buildCredential),不再是裸 token: + // 必须带 refresh_token,否则 refreshable=false、无法静默续期。 v, _, _ := h.ps.GetSecret("channels", "channels:buddy") - if v != "BD-AT-1" { - t.Fatalf("凭据应 BD-AT-1, 实得 %q", v) + if !strings.Contains(v, `"access_token":"BD-AT-1"`) { + t.Fatalf("凭据应含 access_token, 实得 %q", v) + } + if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) { + t.Fatalf("凭据必须含 refresh_token(否则无法续期), 实得 %q", v) + } + if !strings.Contains(v, "buddy-dev") { + t.Fatalf("凭据应回填昵称, 实得 %q", v) } }) t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) { @@ -303,6 +343,46 @@ func TestBuddyStatePollBehavior(t *testing.T) { t.Fatalf("account 失败不应阻塞: %v", j) } }) + + // 行4 回归:UI 真实流程(account/add 建占位账号 → 带 account_id 登录)。 + // 缺陷复现:poll_login 完成时不传 account_id → 凭据落到 `channels:buddy`, + // 而池账号 credentialRef 是 `channels:buddy:` → account_list 报 + // has_credential=false、credits 报「无凭据」——即用户报障的 + // 「登录了但无法获取账号信息」。 + t.Run("行4 池路径:登录后账号必须真的拿到凭据", func(t *testing.T) { + atomic.StoreInt32(&h.tokStep, 0) + _, sid, aid := h.beginPool() + h.poll(sid) // 首拍 11217 + j := h.poll(sid) + if j["state"] != "completed" { + t.Fatalf("池路径登录应完成: %v", j) + } + // ① 凭据必须落在池账号的 ref 上 + v, ok, _ := h.ps.GetSecret("channels", "channels:buddy:"+aid) + if !ok || v == "" { + t.Fatalf("凭据未落到池账号 ref(用户报障的根因); aid=%s ok=%v", aid, ok) + } + if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) { + t.Fatalf("池凭据应是完整 JSON: %q", v) + } + // ② account_list 必须报 has_credential=true(UI 读的就是这个字段) + found := false + for _, raw := range h.accountList() { + a, _ := raw.(map[string]interface{}) + if a["id"] == aid { + found = true + if a["has_credential"] != true { + t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"]) + } + if a["refreshable"] != true { + t.Fatalf("refreshable 应为 true(有 refresh_token), 实得 %v", a["refreshable"]) + } + } + } + if !found { + t.Fatalf("池账号 %s 未出现在 account_list", aid) + } + }) } // ---- 通用 ---- diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 8399cec..9def959 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -803,6 +803,171 @@ local function poolRemove(provider, accountId) poolAccounts(provider)[accountId] = nil end +-- 凭据取值:保险库里存的可能是**裸 token**(历史/device-poll/paste 路径), +-- 也可能是**完整凭据 JSON**( buddy / workbuddy 等 state-poll 平台,对齐参考 +-- buildCredential 的结构:access_token + refresh_token + expires_at + domain + ...)。 +-- +-- 本助手统一取出可用的 access token(及 refresh_token),使所有 HTTP 消费点 +-- (Bearer 头、HMAC 签名)无需关心存的是哪种形态: +-- 裸 token → 原样返回 +-- 完整凭据 JSON 字符串 → 取 .access_token +-- 已经是 table → 取 .access_token +-- 返回 (accessToken, refreshToken, fullTableOrNil)。两者都空时返回 (nil, nil, nil)。 +local function credentialOf(ref) + local raw = ref and __llmsproxy_secrets.get(ref) + if not raw or raw == "" then return nil, nil, nil end + -- 裸 token 快路:不含 '{' 的字符串直接当 token(绝大多数历史数据) + if type(raw) == "string" and raw:sub(1, 1) ~= "{" then return raw, nil, nil end + local ok, rec = pcall(json.decode, type(raw) == "string" and raw or "") + if ok and type(rec) == "table" then + local at = rec.access_token or rec.accessToken or rec.token + local rt = rec.refresh_token or rec.refreshToken + if at and at ~= "" then return at, rt, rec end + -- 是 JSON 但没有 access_token:回退原样(可能是别的形状的凭据) + return raw, rt, rec + end + return raw, nil, nil +end + +-- credentialFull 取完整凭据(供需要 refresh_token/expires_at 的路径用)。 +local function credentialFull(ref) + local _, _, full = credentialOf(ref) + return full +end + +-- jwtClaim 从 JWT 的 payload 读一个声明(不验签,仅用于读取展示/续期元数据)。 +-- 对齐参考 buddy.ts 的 jwtNickname / jwtExpiresAtMs / jwtSubject: +-- CodeBuddy 的 login/account 响应**不含昵称**,昵称只在 access_token 的声明里; +-- token 端又只返回 expiresIn(相对秒数)而不含 expiresAt。两处都得从 JWT 兜底。 +-- 解析失败返回 nil(调用方自行处理)。 +local function jwtClaim(token, name) + if type(token) ~= "string" or token == "" then return nil end + local payload = token:match("^[^%.]+%.([^%.]+)") + if not payload then return nil end + local bin, derr = base64_decode(payload) -- 内核新增:base64url 兼容 + if not bin or derr then return nil end + local ok, rec = pcall(json.decode, bin) + if not ok or type(rec) ~= "table" then return nil end + return rec[name] +end + +-- stripControl 去掉控制字符(含 CR/LF/Tab)并把连续空白折叠为单个空格。 +-- 对齐参考 stripControlChars:CodeBuddy 的 scope 字段有时返回多行文本, +-- 原样落盘会在序列化时被当作多行标量,破坏后续 JSON 解析。 +local function stripControl(v) + if type(v) ~= "string" then return v end + v = v:gsub("[%z\1-\31\127]+", " ") + v = v:gsub("%s%s+", " ") + return (v:gsub("^%s+", ""):gsub("%s+$", "")) +end + +-- readStr 从响应表安全读字符串字段(兼容后端把数字返为数字)。 +local function readStr(rec, key) + if type(rec) ~= "table" then return "" end + local v = rec[key] + if type(v) == "string" then return stripControl(v) end + if type(v) == "number" then return tostring(v) end + return "" +end + +-- absExpiryMs 把绝对字段或相对秒数换算为毫秒时间戳字符串。 +-- 对齐参考 absoluteExpiryMs: +-- ① 绝对字段(expiresAt/refreshExpiresAt)存在 → 归一化为毫秒字符串 +-- ② 否则用相对秒数(expiresIn/refreshExpiresIn)+ access_token 的 JWT iat 兜底 +-- ③ 都没有 → 返回空串(由 credentialExpiresAtMs 再从 JWT exp 兜底) +-- 实测:CodeBuddy 的 /v2/plugin/auth/token 只返回 expiresIn,没有 expiresAt; +-- 不换算的话凭据的 expires_at 永远是空串,UI 一直显示"有效期未知"。 +local function absExpiryMs(rec, absKey, relKey, accessToken) + local absolute = readStr(rec, absKey) + if absolute ~= "" then + if absolute:match("^%d+$") then + local n = tonumber(absolute) + if n then + return tostring(n > 1e12 and n or n * 1000) + end + end + return absolute + end + local rel = rec[relKey] + local relSec = nil + if type(rel) == "number" then relSec = rel + elseif type(rel) == "string" and rel:match("^%-?%d+%.?%d*$") then relSec = tonumber(rel) end + if relSec == nil then return "" end + -- 基准:access_token 的 iat(签发时刻)优先,缺失时用当前时刻。 + local iat = jwtClaim(accessToken, "iat") + local baseMs = (type(iat) == "number" and iat * 1000) or (os.time and os.time() * 1000 or 0) + return tostring(baseMs + relSec * 1000) +end + +-- buildCredential 把 token 响应 + account 响应组合为可持久化的完整凭据 JSON。 +-- 逐字段对齐参考 buddy.ts 的 buildCredential / parseTokenData / parseAccountData: +-- access_token / refresh_token / expires_at / refresh_expires_at / +-- token_type / scope / domain / user_id / nickname / enterprise_id / account_type +-- 昵称回填顺序:account.nickname → JWT.nickname → JWT.preferred_username。 +-- expires_at 同理:token.expiresAt 为空时由 absExpiryMs 用 expiresIn 或 JWT exp 兜底。 +local function buildCredential(data, accountData) + local access = readStr(data, "accessToken") + if access == "" then access = readStr(data, "access_token") end + local refresh = readStr(data, "refreshToken") + if refresh == "" then refresh = readStr(data, "refresh_token") end + local tokenType = readStr(data, "tokenType") + if tokenType == "" then tokenType = "Bearer" end + local acct = type(accountData) == "table" and accountData or {} + local nickname = readStr(acct, "nickname") + if nickname == "" then nickname = readStr(acct, "nickName") end + if nickname == "" then + local jn = jwtClaim(access, "nickname") + if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "preferred_username") end + if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "name") end + if type(jn) == "string" then nickname = stripControl(jn) end + end + local uid = readStr(acct, "uid") + if uid == "" then + local sub = jwtClaim(access, "sub") + if type(sub) == "string" then uid = sub end + end + local acctType = readStr(acct, "type") + if acctType == "" then acctType = "personal" end + return { + access_token = access, + refresh_token = refresh, + expires_at = absExpiryMs(data, "expiresAt", "expiresIn", access), + refresh_expires_at = absExpiryMs(data, "refreshExpiresAt", "refreshExpiresIn", access), + token_type = tokenType, + scope = readStr(data, "scope"), + domain = readStr(data, "domain"), + user_id = uid, + nickname = nickname, + enterprise_id = readStr(acct, "enterpriseId"), + account_type = acctType, + } +end + +-- credentialExpiresAtMs 从凭据表解析毫秒过期时刻(兼容毫秒/秒级时间戳/ISO)。 +-- 对齐参考 credentialExpiresAtMs:expires_at 为空时回退解析 access_token 的 JWT exp。 +local function credentialExpiresAtMs(cred) + if type(cred) ~= "table" then return nil end + local raw = cred.expires_at + if type(raw) == "string" and raw ~= "" then + if raw:match("^%d+$") then + local n = tonumber(raw) + if n then return n > 1e12 and n or n * 1000 end + end + -- ISO 8601:解析 "YYYY-MM-DDThh:mm:ss" 形态(无时区偏移时按 UTC) + local Y, Mo, D, H, Mi, S = raw:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)") + if Y then return os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D), + hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false }) * 1000 end + end + local exp = jwtClaim(cred.access_token, "exp") + if type(exp) == "number" then return exp * 1000 end + return nil +end + +-- credentialIsRefreshable 凭据是否携带可静默续期的 refresh_token。 +local function credentialIsRefreshable(cred) + return type(cred) == "table" and type(cred.refresh_token) == "string" and cred.refresh_token ~= "" +end + -- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true } local function disabledModelsOf(provider) if not plugin.state.modelDisabled then plugin.state.modelDisabled = {} end @@ -840,7 +1005,8 @@ local function syncAccountSource(provider, accountId) local plat = platformById(provider) local acct = poolGet(provider, accountId) if not plat or not acct then return end - local tok = __llmsproxy_secrets.get(acct.credentialRef) + -- 凭据可能是裸 token 或完整凭据 JSON;上游源只吃 access token。 + local tok = credentialOf(acct.credentialRef) if not tok or tok == "" then return end local name = accountSourceName(provider, accountId) local models = {} @@ -1102,6 +1268,14 @@ end -- 对 device-poll 会话,每次轮询**推进一步**设备码换 token(deviceOneStep): -- 轮询由页面驱动而不在 Lua 里阻塞,因为插件是单 VM 单锁,阻塞循环会卡死 -- 本插件全部钩子。成功则落凭据+接入源(走 complete_login)。 +-- +-- ⚠️ **必须把 session 上的 account_id 传给 complete_login**(真实缺陷): +-- 页面流程是「先 account/add 建占位账号(credentialRef=channels::) +-- → login 带 account_id → 轮询」。若这里不带 account_id,complete_login 会走 +-- 兼容旧路径,凭据落到 `channels:` 这个**与占位账号无关的键**,而账号池 +-- 条目的 credentialRef 指向 `channels::` —— 于是 account/list 报 +-- has_credential=false、credits/balances 报「无凭据」,表现为「登录成功但无法 +-- 获取账号信息」。对齐参考 BuddyAuth.login 的 `flowOptions.accountId` 语义。 function plugin.poll_login(req) local id = (req and req.query and req.query.id) or "" sweepSessions() @@ -1119,7 +1293,10 @@ function plugin.poll_login(req) s.state = "failed" s.message = rerr else - local res = plugin.complete_login({ platform = s.platform, credential = cred, account = acct or payload.account }) + -- ⚠️ 必须带 account_id:页面流程先 account/add 建了占位账号, + -- 不带就会落到与占位账号无关的 `channels:` 键(见 poll_login 顶部注释)。 + local res = plugin.complete_login({ platform = s.platform, credential = cred, + account = acct or payload.account, account_id = s.account_id }) if res.json and res.json.ok then s.state = "completed" s.account = acct or payload.account @@ -1149,21 +1326,36 @@ function plugin.poll_login(req) local code = rec.code local data = rec.data if resp.status == 200 and data ~= nil and type(data) == "table" and (data.accessToken or data.access_token) then - -- token 就绪。再拿账号(失败不阻塞,回退默认名) - local tok = data.accessToken or data.access_token - local acct = "codebuddy" + -- token 就绪。再拿账号(失败不阻塞,回退默认名)。 + local access = data.accessToken or data.access_token + local accountData = nil local aresp, aerr = __llmsproxy_http({ url = (sp.account_url or "") .. "?state=" .. tostring(s.state_token), method = "GET", - headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. tok }, + headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. access }, }) if not aerr and aresp and aresp.status == 200 then local aok, arec = pcall(json.decode, aresp.body or "") if aok and type(arec) == "table" and type(arec.data) == "table" then - acct = tostring(arec.data.nickName or arec.data.nickname or arec.data.username or acct) + accountData = arec.data end end - local res = plugin.complete_login({ platform = s.platform, credential = tok, account = acct }) + -- ① 组合完整凭据 JSON(对齐参考 buildCredential):带 refresh_token / + -- expires_at / domain / user_id / nickname,而非只存裸 access_token。 + -- 只存裸 token 会丢掉 refresh_token,导致 refreshable=false、无法静默 + -- 续期,且 expires_at 未知。 + local credTable = buildCredential(data, accountData) + local credJson = json.encode(credTable) + local acct = credTable.nickname + if acct == nil or acct == "" then + acct = credTable.user_id + end + if acct == nil or acct == "" then acct = "codebuddy" end + -- ② 必须带 account_id(页面流程的占位账号);否则凭据落到无关的 + -- `channels:` 键,账号池查不到。 + local res = plugin.complete_login({ platform = s.platform, credential = credJson, + account = acct, account_id = s.account_id, + expires_at = credTable.expires_at, refreshable = credentialIsRefreshable(credTable) }) if res.json and res.json.ok then s.state = "completed" s.account = acct @@ -1209,9 +1401,27 @@ function plugin.complete_login(req) local ref = acct.credentialRef local _, serr = __llmsproxy_secrets.set(ref, token) if serr then return { status = 400, json = { error = serr } } end - acct.nickname = args.account or acct.nickname or aid - acct.expiresAt = args.expires_at or acct.expiresAt - acct.refreshable = args.refreshable or acct.refreshable or false + -- 从凭据自动补齐展示/续期字段(调用方漏传也不丢)。 + -- 对齐参考 saveCredential:昵称取 credential.nickname, + -- expiresAt 由 credentialExpiresAtMs 推断(expires_at 或 JWT exp), + -- refreshable 看是否携带 refresh_token。 + local credTbl = nil + if type(token) == "string" and token:sub(1, 1) == "{" then + local dok, drec = pcall(json.decode, token) + if dok and type(drec) == "table" then credTbl = drec end + end + local derivedNick = credTbl and credTbl.nickname + if derivedNick == nil or derivedNick == "" then derivedNick = credTbl and credTbl.user_id end + local derivedExp = credTbl and credentialExpiresAtMs(credTbl) + acct.nickname = args.account or derivedNick or acct.nickname or aid + acct.expiresAt = args.expires_at or (derivedExp and tostring(derivedExp)) or acct.expiresAt + if args.refreshable ~= nil then + acct.refreshable = args.refreshable and true or false + elseif credTbl ~= nil then + acct.refreshable = credentialIsRefreshable(credTbl) + else + acct.refreshable = acct.refreshable or false + end poolAdd(plat.id, acct) -- 终结该账号的未决会话 for id, sess in pairs(sessions()) do @@ -1228,6 +1438,9 @@ function plugin.complete_login(req) -- 兼容旧路径:单账号模式(e2e 与既有端点仍走它)。 local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token) if err then return { status = 400, json = { error = err } } end + -- 上游源只吃 access token:凭据可能是完整 JSON(buddy 等 state-poll 平台), + -- 必须经 credentialOf 取出 access_token,不能把整个 JSON 当 api_key 发上去。 + local legacyTok = credentialOf("channels:" .. plat.id) or token plugin.state.accounts[plat.id] = { account = args.account or "已登录", @@ -1246,7 +1459,7 @@ function plugin.complete_login(req) name = "channels-" .. plat.id, base_url = plat.base_url, adapter = "openai", - api_key = token, + api_key = legacyTok, max_concurrent = 2, models = (function() local ms = {} @@ -1286,9 +1499,9 @@ function plugin.claim_daily(req) if args.account_id and args.account_id ~= "" then acctEntry = poolGet(plat.id, args.account_id) if not acctEntry then return { status = 400, json = { error = "账号不存在" } } end - tok = __llmsproxy_secrets.get(acctEntry.credentialRef) + tok = credentialOf(acctEntry.credentialRef) else - tok = __llmsproxy_secrets.get("channels:" .. plat.id) + tok = credentialOf("channels:" .. plat.id) end if not tok then return { status = 400, json = { error = "未登录" } } end @@ -1357,7 +1570,7 @@ function plugin.auto_claim() plugin.claim_daily({ platform = plat.id }) end for _, a in ipairs(poolList(plat.id)) do - if a.enabled ~= false and __llmsproxy_secrets.get(a.credentialRef) then + if a.enabled ~= false and credentialOf(a.credentialRef) then plugin.claim_daily({ platform = plat.id, account_id = a.id }) end end @@ -1503,7 +1716,7 @@ function plugin.account_list(req) if not plat then return { status = 400, json = { error = "未知平台" } } end local out = {} for _, a in ipairs(poolList(plat.id)) do - local has = __llmsproxy_secrets.get(a.credentialRef) + local has = credentialOf(a.credentialRef) out[#out+1] = { id = a.id, nickname = a.nickname, enabled = a.enabled ~= false, credentialRef = a.credentialRef, refreshable = a.refreshable or false, @@ -1561,7 +1774,7 @@ function plugin.account_test(req) if not plat then return { status = 400, json = { error = "未知平台" } } end local acct = poolGet(plat.id, args and args.account_id) if not acct then return { status = 400, json = { error = "账号不存在" } } end - local tok = __llmsproxy_secrets.get(acct.credentialRef) + local tok = credentialOf(acct.credentialRef) if not tok or tok == "" then return { status = 200, json = { ok = false, error = "无凭据" } } end -- 同步一次源(确保最新凭据已入),返回同步结果 syncAccountSource(plat.id, acct.id) @@ -1582,7 +1795,7 @@ function plugin.account_refresh(req) if not plat then return { status = 400, json = { error = "未知平台" } } end local acct = poolGet(plat.id, args and args.account_id) if not acct then return { status = 400, json = { error = "账号不存在" } } end - local tok = __llmsproxy_secrets.get(acct.credentialRef) + local tok = credentialOf(acct.credentialRef) if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end return { status = 200, json = { ok = true } } end @@ -1661,7 +1874,7 @@ function plugin.provider_status(req) for _, m in ipairs(p.models or {}) do if disabled[m] then disabledN = disabledN + 1 end end local closed = (p.models and #p.models > 0) and disabledN == #p.models local state_accounts = plugin.state.accounts[p.id] - local legacyN = (state_accounts and __llmsproxy_secrets.get("channels:" .. p.id)) and 1 or 0 + local legacyN = (state_accounts and credentialOf("channels:" .. p.id)) and 1 or 0 out[#out+1] = { id = p.id, name = p.name, flow = p.flow, closed = closed, models = { total = #(p.models or {}), disabled = disabledN }, @@ -1684,10 +1897,11 @@ function plugin.provider_set_enabled(req) for _, a in ipairs(poolList(plat.id)) do a.enabled = true poolAdd(plat.id, a) syncAccountSource(plat.id, a.id) end -- 旧路径也启用 local legacy = plugin.state.accounts[plat.id] - if legacy and __llmsproxy_secrets.get("channels:" .. plat.id) then + local legacyTok = credentialOf("channels:" .. plat.id) + if legacy and legacyTok then __llmsproxy_kernel.call("add_source", { source = { name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai", - api_key = __llmsproxy_secrets.get("channels:" .. plat.id), max_concurrent = 2, + api_key = legacyTok, max_concurrent = 2, models = (function() local ms = {} for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(), } }) end @@ -1744,11 +1958,11 @@ function plugin.backup_export(req) for _, p in ipairs(PLATFORMS) do for _, a in ipairs(poolList(p.id)) do payload.accounts[#payload.accounts+1] = a - local tok = __llmsproxy_secrets.get(a.credentialRef) + local tok = credentialOf(a.credentialRef) if tok then payload.credentials[a.credentialRef] = tok end end -- 旧路径单账号也备份 - local lt = __llmsproxy_secrets.get("channels:" .. p.id) + local lt = credentialOf("channels:" .. p.id) if lt then payload.credentials["channels:" .. p.id] = lt end end return { status = 200, json = { ok = true, payload = payload } } @@ -1838,7 +2052,7 @@ function plugin.credits_status(req) local status = nil local cs = plat.checkin_status if cs then - local tok = __llmsproxy_secrets.get(a.credentialRef) + local tok = credentialOf(a.credentialRef) if tok then local rec, err = fetchAccountJSON(cs, tok) if rec and plat.checkin_status.status_fn then @@ -1870,7 +2084,7 @@ function plugin.credits_balances(req) if not b then entry.error = "平台未声明余额端点" else - local tok = __llmsproxy_secrets.get(a.credentialRef) + local tok = credentialOf(a.credentialRef) if not tok then entry.error = "无凭据" else @@ -2132,7 +2346,7 @@ function plugin.onboarding_claim(req) return { status = 400, json = { error = "该平台无新人任务" } } end -- raccoon 实测端点:POST {apiBase}/login/points/grant - local tok = __llmsproxy_secrets.get("channels:" .. plat.id) + local tok = credentialOf("channels:" .. plat.id) if not tok then return { status = 400, json = { error = "未登录" } } end local resp, err = __llmsproxy_http({ url = plat.base_url .. "/login/points/grant", method = "POST", @@ -2199,7 +2413,7 @@ function plugin.list_platforms(req) local out = {} for _, p in ipairs(PLATFORMS) do local acct = accountOf(p.id) or {} - local has = __llmsproxy_secrets.get("channels:" .. p.id) + local has = credentialOf("channels:" .. p.id) out[#out + 1] = { id = p.id, name = p.name, flow = p.flow, logged_in = has ~= nil, diff --git a/internal/lua/vm.go b/internal/lua/vm.go index f8ca65c..3dc7d56 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -1358,6 +1358,38 @@ func setupGlobals(L *golua.State) { L.PushString(base64.StdEncoding.EncodeToString([]byte(L.ToString(1)))) return 1 }) + // base64_decode accepts standard, URL-safe and unpadded alphabets. It exists + // because a plugin reading a JWT payload (e.g. to read `exp`/`nickname` when + // the token endpoint omits them) must decode base64url, and hardcoding that + // in Lua would mean reimplementing base64 in a plugin. Generic, no vendor + // knowledge: an invalid string yields (nil, err), never a partial result. + registerFn(L, "base64_decode", func(L *golua.State) int { + s := strings.TrimSpace(L.ToString(1)) + if s == "" { + L.PushString("") + L.PushNil() + return 2 + } + // Normalise URL-safe alphabet and re-pad to a multiple of 4. + s = strings.NewReplacer("-", "+", "_", "/").Replace(s) + if pad := len(s) % 4; pad != 0 { + if pad == 1 { + L.PushNil() + L.PushString("invalid base64 length") + return 2 + } + s += strings.Repeat("=", 4-pad) + } + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + L.PushString(string(b)) + L.PushNil() + return 2 + }) registerFn(L, "tohex", func(L *golua.State) int { L.PushString(hex.EncodeToString([]byte(L.ToString(1)))) return 1