Compare commits

..

5 Commits

Author SHA1 Message Date
652a035d47 fix(channels): 测试夹具不再内联进生产目录 + callback 池路径漏网
用户指出「为什么生产插件会有实例平台?」—— 确实是设计错误。

## ① 夹具内联进生产源码(根因)
`PLATFORMS` 里内联了 4 个测试夹具:
  example / googleish / loopbackish / cookieish
后果:生产 `/api/plugins/http/channels/platforms` 返回 **16 个平台,其中 4 个是
夹具**,运维在 UI 上真的能选到。夹具属于测试,不属于发行物。

修法:
- 生产 `PLATFORMS` 只留 12 个真实厂商。
- 新增 `plugin.state.injected_platforms` + `allPlatforms()`;`platformById`
  先查内建再查注入表。
- 新增端点 `channels/inject_platforms`(roles=admin):运行时整表替换注入,
  边界校验 —— 不得覆盖内建平台(注入不能劫持真实厂商)、拒绝重复 id、
  拒绝无 id、限 32 条;空数组 = 清空。
- 8 处运行时遍历点(list_platforms/provider_status/backup/auto_checkin/
  gateway 开关等)改用 `allPlatforms()`;4 处**加载期**遍历(HMAC 钩子绑定、
  declaredHosts)保持 `PLATFORMS` —— 它们在 plugin.state 建立之前、注入尚未
  发生,改了无意义。
- 测试改为经 `injectTestPlatforms` 注入夹具(channels_plugin_test /
  channels_stress_test);e2e 本就用真实平台(minimax/cline/opencode/qoder),
  无需改动。

## ② callback 路径的 account_id 漏网
UI 流程 = account/add 建占位账号 → login 带 account_id → 完成时回填。
`oauth_callback`(trae/gemini/lobsterai 的 callback 流)调 complete_login 时
**没传 account_id** —— 与 buddy poll_login 同一缺陷,此前只修了 device-poll /
state-poll 两条分支,这条是漏网:凭据落到与占位账号无关的 `channels:<plat>`,
池账号永远 has_credential=false。

## 判据
- TestInjectPlatformsRejectsBadInput:注入生效 + 三条拒绝规则 + 空数组清空
- TestTraeCallbackPoolPath:callback 池路径凭据落 `channels:trae:<aid>` 且
  account_list 报 has_credential=true
- 变异:callback 去掉 account_id → TestTraeCallbackPoolPath 立即 FAIL(已实测)

go test ./internal/core/ 全绿(119s)。
2026-10-13 08:33:28 +08:00
6bec260513 fix(deploy): 内置插件的修复无法下发 + buddy 余额解析口径
## deploy.sh:内置插件修复本该覆盖
sync_plugins 原为「只补缺,已存在一律不动」。看着安全,实际造成功能性缺口:
**内置插件自身的修复永远下发不了**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版
(实测 sha256 不同、headers_fn 计数 0),生产行为毫无变化。我自己的验证还被
这个"静默不生效"骗过一轮:测试全绿、部署成功,线上仍是旧逻辑。

修法:与 sync_adapters 同纪律 —— **内置同名文件覆盖(带备份)**,
运行时新增/运维自加的非内置名字一律不动。内置源码是发行物的一部分,
部署必须与二进制内容对齐;"只补缺"想保护的是非内置文件,两者不是一回事。

判据:部署日志出现 `~ 更新内置插件: channels.lua`;部署后生产文件 sha256
与仓库一致。

## buddy 余额:双层嵌套 + 只累加有效包
余额端点响应是 **data.Response.Data.Accounts[]**(与签到端点的单层 data
不同),原实现按顶层找 totalCredit → 永远解析不出、回落 0 或报错。
且刻意**不用**服务端的 TotalDosage:那是终身口径且取整(参考实测同一响应
TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts 现算:
只累加有效包的 CycleCapacityRemain(失效包服务端仍返回,并入会虚高),
按两位小数规整;双层缺失时如实报错,不把「查不到」当「余额为 0」。

判据 TestBuddyBalanceParseDoubleNested:100.5+55.17=155.67(失效的 500
不计入)+ 单层 data 判为失败。变异:改用 TotalDosage → 立即 FAIL(已实测)。

go test ./internal/core/ 与 ./internal/lua/ 全绿。
2026-10-13 08:05:39 +08:00
ed954b77bd fix(channels): buddy 计费请求缺身份头 → 上游 401(逐行移植参考 checkinHeaders)
修好凭据读取后,余额端点仍报 HTTP 401。逐行核对参考 checkinHeaders
(credits.ts) 后发现我们只发了 Authorization,而 buddy 要求身份标识头齐全:

  X-Domain: copilot.tencent.com   (以产品配置为准,不用凭据里的 domain 快照)
  X-Product: SaaS                 (部署类型)
  X-Product-Code: codebuddy       (产品码)
  User-Agent: CodeBuddyIDE/1.106.1
  X-User-Id / X-Enterprise-Id / X-Tenant-Id(凭据携带时)

参考的注释特别警告 X-Domain 的方向:凭据里的 domain 是「登录时那个域名」的
快照,跨产品迁移后会指错区域,所以必须以产品端点为准。

## 实现
- 平台级声明 headers_fn(accessToken, cred) → 返回请求头表;buddy 据此逐行移植。
- fetchAccountJSON 增加头钩子位:spec 自己的 headers_fn 优先,否则回退平台级。
  未声明的平台仍走通用默认头(Bearer + JSON),不改变既有行为。
- set_endpoints 补齐 checkin_status / balance 端点覆盖(此前只覆盖旧的
  plat.checkin,buddy 这类平台无从改指,自动化测试也指不到假上游)。
  端点覆盖是参考明确的产品配置能力,也是测试缝。

## 判据
TestBuddyBillingHeadersMatchReference:把 buddy 余额端点指到本地假上游,
断言四个必备身份头逐字段正确。变异:改名 headers_fn → 立即 FAIL(已实测)。

go test ./internal/core/ 与 ./internal/lua/ 全绿。
2026-10-13 07:57:46 +08:00
cb3ea25586 fix(lua): InvokeHandler 从不 markDirty —— 无 hooks 的插件状态永不落盘
## 现象
channels 插件重启后账号池静默清空:status 返回 accounts={}、pool=null、log=0 条,
磁盘上 `.channels.state.json` 从未存在过(billing/quota 的 .state.json 都在)。
用户侧表现为「登录了,但重启一次账号就没了」。

## 根因
插件状态持久化有三条分发路径,其中两条会 markDirty:
  - hook 路径(invoke)        → markDirtyLocked ✓
  - 导出函数路径(Call)       → markDirtyLocked ✓
  - HTTP handler 路径(InvokeHandler) → **从不 markDirty** ✗

channels 声明 `hooks = {}`,它的一切状态变更(账号入池、凭据引用、模型黑名单、
事件日志)都只经过自己的 HTTP 路由。于是状态从未被标记为脏,
`.channels.state.json` 从未写出,重启即全部丢失。

billing/quota 侥幸没踩到:billing 有 hooks,quota 走 Call。

## 修复
InvokeHandler 在成功返回前 markDirtyLocked(p),并在 handler 抛错的早退分支
也标记(抛错前可能已改过状态,那次改动仍然是真实的)。与 Call / hook 路径同模式,
只碰 stateSaver 自己的锁,不重入 p.mu(调用方已持锁)。

## 判据
internal/lua/persist_test.go::TestInvokeHandlerIsPersisted
用「无 hooks、只走 HTTP handler」的插件复现 channels 形态:两次 handler 调用后
Close()(等最后一次 flush),断言 .state.json 存在且含 2 个账号。
变异:撤掉 markDirtyLocked → 测试立即 FAIL(已实测)。

go test ./internal/lua/ 与 ./internal/core/ 全绿。
2026-10-13 07:48:15 +08:00
0232b9cd24 fix(channels): 登录后账号拿不到凭据 —— 完成路径不传 account_id + 只存裸 token
用户报障:「登录了,但插件这边无法获取账号信息」。实证:
  account_list → has_credential=false,credits/balances → 「无凭据」,
  而 UI 上账号显示已登录。

## 根因一:完成路径没把凭据写进池账号的键
UI 的真实流程是「account/add 建占位账号(credentialRef=channels:<plat>:<aid>)
→ login 带 account_id → 轮询 login/status」。但 poll_login 的两条完成分支
(device-poll / state-poll)调用 complete_login 时**没有传 account_id**,
于是走了兼容旧路径,凭据落到 `channels:<plat>` 这个与占位账号无关的键;
池账号的 credentialRef 指向 `channels:<plat>:<aid>`,从未写入
→ 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:<aid> ② account_list 报 has_credential=true
  ③ refreshable=true
变异验证:去掉 account_id → 行4 立即 FAIL(已实测)。

go test ./internal/core/ 与 ./internal/lua/ 全绿。
2026-10-13 07:41:42 +08:00
10 changed files with 941 additions and 103 deletions

View File

@ -393,13 +393,17 @@ sync_adapters() {
# 同步内置插件到 plugin_dir。
#
# 为什么需要它:新内置插件(如 channels.lua)过去只能靠内核的 SeedBundled 落地,
# 而旧版 SeedBundled "目录已存在就什么都不做",于是升级后新插件永远不会出现在
# 生产 —— 它在二进制里、却不在 /api/plugins。内核端已修(改为按文件补齐),
# 这里再显式同步一道,让部署与二进制内容对齐、不依赖内核那一侧的行为。
# 内置插件是**内核发行物的一部分**(go:embed 进二进制,随版本演进),与内置适配器
# 同性质。因此这里的纪律与 sync_adapters 完全一致:**覆盖内置同名文件**(带备份),
# 保留目录里运行时新增/运维自加的 .lua。
#
# 只补缺:已存在的插件文件一律不动。运维自己改过、或运行时新增的插件必须跨
# 部署存活;覆盖它们会静默抹掉线上正在用的逻辑。
# 早期实现是「只补缺,已存在一律不动」,看着安全,实际造成一个功能性缺口:
# 内置插件本身的**修复无法下发**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
# 全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版,
# 生产行为毫无变化。内置源码的正确性由仓库保证,部署必须与二进制内容对齐。
#
# 「只补缺」原本想保护的是「运维改过 / 运行时新增的插件」,但那些都是**非内置
# 名字**:内置名一律以发行物为准,其余名字一律不动。两者不是一回事。
sync_plugins() {
log "同步内置插件"
@ -408,24 +412,35 @@ sync_plugins() {
mkdir -p "$TARGET_PLUGINS"
# 备份将被触及的文件(只会是本次新增的那些)
# 备份将被覆盖/新增的内置插件(非内置名字不进入循环,天然不动)
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
local added=0
mkdir -p "$BACKUP_DIR"
local added=0 updated=0 same=0
for src in "$SRC_PLUGINS"/*.lua; do
[[ -f "$src" ]] || continue
local name; name="$(basename "$src")"
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
continue # 已存在:运维版本优先,不覆盖
if cmp -s "$src" "$TARGET_PLUGINS/$name"; then
((same++)) || true
continue
fi
cp -f "$TARGET_PLUGINS/$name" "$BACKUP_DIR/$name.bak" 2>/dev/null || true
cp -f "$src" "$TARGET_PLUGINS/$name"
chmod 0644 "$TARGET_PLUGINS/$name"
log " ~ 更新内置插件: $name"
((updated++)) || true
else
cp -f "$src" "$TARGET_PLUGINS/$name"
chmod 0644 "$TARGET_PLUGINS/$name"
log " + 新增内置插件: $name"
((added++)) || true
fi
cp -f "$src" "$TARGET_PLUGINS/$name"
chmod 0644 "$TARGET_PLUGINS/$name"
log " + 新增内置插件: $name"
((added++)) || true
done
if (( added == 0 )); then
log " 无新增内置插件(均已存在)"
# 没有任何改动时不必留空备份目录
if (( updated == 0 && added == 0 )); then
rmdir "$BACKUP_DIR" 2>/dev/null || true
else
log " 旧插件已备份到 $BACKUP_DIR"
fi
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
@ -434,7 +449,7 @@ sync_plugins() {
local name; name="$(basename "$src")"
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
done
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,已有文件未改动)"
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
}
# ---------- 重启服务 ----------

View File

@ -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:<aid>` → 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)
}
})
}
// ---- 通用 ----

View File

@ -27,6 +27,9 @@ func TestChannelsPluginEndToEnd(t *testing.T) {
if err := ps.LoadSource("channels", src); err != nil {
t.Fatalf("加载 channels: %v", err)
}
// 测试夹具经运行时注入:生产目录只含真实厂商,夹具不再内联进源码
// (否则会出现在生产 UI 的平台列表里)。
injectTestPlatforms(t, ps, examplePlatformFixture)
// 1. 平台清单可用(未授权任何东西也应能列出)
out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{
@ -239,3 +242,101 @@ func stateHasSecret(ps *lua.Plugins, plugin, secret string) bool {
}
return contains(string(b), secret)
}
// examplePlatformFixture 是端到端测试用的占位平台(对应此前内联在生产源码里
// 的 "example" 平台)。它经 inject_platforms 注入,且**只在测试进程内存在** ——
// 生产插件的 PLATFORMS 只含真实厂商。
const examplePlatformFixture = `[
{
"id": "example",
"name": "Example 平台(测试夹具)",
"flow": "link-poll",
"login_url": "https://auth.example.com/device",
"base_url": "https://api.example.com/v1",
"adapter": "openai",
"models": ["example-chat", "example-reason"],
"checkin": {"url": "https://api.example.com/v1/api/daily/claim", "method": "POST", "body": "{}"}
}
]`
// injectTestPlatforms 把测试夹具注入插件(运行时,不落盘)。
func injectTestPlatforms(t *testing.T, ps *lua.Plugins, fixtureJSON string) {
t.Helper()
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
Method: "POST", Body: `{"platforms":` + fixtureJSON + `}`, Role: "admin",
})
if err != nil {
t.Fatalf("inject_platforms: %v", err)
}
m, _ := out.JSON.(map[string]interface{})
if m["ok"] != true {
t.Fatalf("注入测试平台失败: %v", m)
}
}
// TestInjectPlatformsRejectsBadInput 锁定注入机制的三条边界:
// 1. 不得覆盖内建平台(注入不能劫持真实厂商)
// 2. 重复 id 拒绝
// 3. 空数组 = 清空注入(夹具不跨测试残留)
//
// 这个机制存在的理由:夹具内联进生产源码会让它们出现在生产 UI 的平台列表里
// (真实踩过 —— 16 个平台里有 4 个是夹具)。
func TestInjectPlatformsRejectsBadInput(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
call := func(body string) map[string]interface{} {
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
Method: "POST", Body: body, Role: "admin",
})
if err != nil {
t.Fatalf("inject_platforms(%s): %v", body, err)
}
return out.JSON.(map[string]interface{})
}
platforms := func() []interface{} {
out, _ := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{Method: "GET", Role: "admin"})
return out.JSON.(map[string]interface{})["platforms"].([]interface{})
}
base := len(platforms())
if base == 0 {
t.Fatal("生产目录应有真实厂商平台")
}
// 注入一个合法夹具 → 列表 +1
if m := call(`{"platforms":[{"id":"fixture-a","name":"A","flow":"paste","base_url":"https://a.example/v1"}]}`); m["ok"] != true {
t.Fatalf("合法注入应成功: %v", m)
}
if got := len(platforms()); got != base+1 {
t.Fatalf("注入后平台数 = %d, 期望 %d", got, base+1)
}
// 1. 覆盖内建平台 → 拒绝
if m := call(`{"platforms":[{"id":"buddy","name":"劫持"}]}`); m["error"] == nil {
t.Fatal("覆盖内建平台必须被拒绝")
}
// 2. 重复 id → 拒绝
if m := call(`{"platforms":[{"id":"fixture-b"},{"id":"fixture-b"}]}`); m["error"] == nil {
t.Fatal("重复 id 必须被拒绝")
}
// 无 id → 拒绝
if m := call(`{"platforms":[{"name":"no-id"}]}`); m["error"] == nil {
t.Fatal("缺 id 必须被拒绝")
}
// 3. 空数组 = 清空注入,回到基线
if m := call(`{"platforms":[]}`); m["ok"] != true {
t.Fatalf("清空应成功: %v", m)
}
if got := len(platforms()); got != base {
t.Fatalf("清空后平台数 = %d, 期望回到基线 %d", got, base)
}
}

View File

@ -5,6 +5,10 @@ import (
"testing"
"llmsproxy/internal/lua"
"fmt"
"net/http"
"net/http/httptest"
)
// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。
@ -320,3 +324,95 @@ func TestChannelsExtendedRPCBehavior(t *testing.T) {
}
})
}
// TestBuddyBillingHeadersMatchReference 锁定 buddy 计费请求的头(逐行移植参考
// checkinHeaders)。真实缺陷:实现只发 Authorization,上游直接 401 —— 修好后
// 账号能读到凭据但余额仍失败,运维看到的仍是「拿不到账号信息」。
func TestBuddyBillingHeadersMatchReference(t *testing.T) {
_, ps := poolCore(t)
var got map[string]string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = map[string]string{}
for k := range r.Header {
got[k] = r.Header.Get(k)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"totalCredit":42}}`)
}))
defer ts.Close()
// 把 buddy 的余额/签到端点指到本地假上游
invoke(t, ps, "set_endpoints", fmt.Sprintf(
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
m := invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
if m["ok"] != true {
t.Fatalf("account_add: %v", m)
}
invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
if got == nil {
t.Fatal("余额请求未发出")
}
// 参考 checkinHeaders 的必备身份头(缺一个上游就 401)
want := map[string]string{
"Authorization": "Bearer BD-AT",
"X-Domain": "copilot.tencent.com",
"X-Product": "SaaS",
"X-Product-Code": "codebuddy",
"User-Agent": "CodeBuddyIDE/1.106.1",
}
for k, v := range want {
if got[k] != v {
t.Errorf("头 %s = %q, 期望 %q(buddy 计费端点缺它会被上游 401)", k, got[k], v)
}
}
}
// TestBuddyBalanceParseDoubleNested 逐行锁定参考 fetchCreditBalance 的解析口径:
// 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 data 不同,
// 这是该接口最容易错的地方),且只累加**有效包**的 CycleCapacityRemain ——
// 失效包服务端仍返回,并进总额会让数字虚高(参考实测 155.67 → 655.67)。
func TestBuddyBalanceParseDoubleNested(t *testing.T) {
_, ps := poolCore(t)
// 两个有效包 + 一个失效包(ExpiredTime 在 2020 年)
body := `{"code":0,"msg":"OK","data":{"Response":{"Data":{"Accounts":[
{"PackageName":"A","Status":1,"CycleCapacityRemain":100.5,"ExpiredTime":"2099-01-01 00:00:00"},
{"PackageName":"B","Status":1,"CycleCapacityRemain":55.17000031,"ExpiredTime":"2099-01-01 00:00:00"},
{"PackageName":"C","Status":1,"CycleCapacityRemain":500,"ExpiredTime":"2020-01-01 00:00:00"}
]}}}}`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
defer ts.Close()
invoke(t, ps, "set_endpoints", fmt.Sprintf(
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
j := invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
var row map[string]interface{}
for _, a := range asArr(j["accounts"]) {
row = a.(map[string]interface{})
}
bal, _ := row["balance"].(map[string]interface{})
if bal == nil {
t.Fatalf("应解析出余额, 实得 error=%v", row["error"])
}
// 100.5 + 55.17 = 155.67(失效包的 500 不计入)
if got, _ := bal["total"].(float64); got != 155.67 {
t.Fatalf("余额 = %v, 期望 155.67(只累加有效包、两位小数)", bal["total"])
}
// 单层 data(缺 Response 层)必须判为解析失败,不能当 0
body = `{"code":0,"data":{"TotalDosage":609}}`
j = invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
for _, a := range asArr(j["accounts"]) {
row = a.(map[string]interface{})
}
if row["balance"] != nil {
t.Fatalf("双层缺失时应报错而非当 0(参考语义:区分「查不到」与「余额为 0」): %v", row)
}
}

View File

@ -38,6 +38,18 @@ func channelsCore(t *testing.T, src string) (*Core, *lua.Plugins) {
return c, ps
}
// cookieishPlatformFixture 是压力测试用的占位平台(对应此前内联在生产源码里的
// "cookieish")。夹具经运行时注入,生产目录只含真实厂商。
const cookieishPlatformFixture = `[{
"id": "cookieish",
"name": "Cookie 平台(测试夹具)",
"flow": "paste",
"base_url": "https://api.cookieish.example/v1",
"adapter": "openai",
"models": ["c-model-1"],
"checkin": {"url": "https://api.cookieish.example/v1/api/credit/claim", "method": "POST", "body": "{}"}
}]`
// TestChannelsConcurrentLogins 压力:并发走 device-poll 登录。
//
// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、
@ -134,6 +146,7 @@ func TestChannelsClaimStress(t *testing.T) {
defer ts.Close()
_, ps := channelsCore(t, mustChannelsSrc(t))
injectTestPlatforms(t, ps, cookieishPlatformFixture)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "cookieish",
"checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"},

View File

@ -171,3 +171,67 @@ func TestTraeCallbackBehavior(t *testing.T) {
}
})
}
// TestTraeCallbackPoolPath 回归:callback 流的**池路径**(UI 真实流程)。
//
// 与 buddy poll_login 的池路径同一缺陷:complete_login 不带 account_id 时,
// 凭据落到与占位账号无关的 `channels:<plat>` 键,池账号永远 has_credential=false。
// 此前只有 device-poll / state-poll 分支修了,callback 分支是漏网。
func TestTraeCallbackPoolPath(t *testing.T) {
h := newTraeHarness(t)
atomic.StoreInt32(&h.exchOK, 1)
// UI 流程:先建占位账号,再带 account_id 发起登录
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"trae"}`,
})
if err != nil {
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":"trae","account_id":%q}`, aid),
})
if err != nil {
t.Fatal(err)
}
nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
st, body := h.invokeCallback(map[string]string{
"state": nonce, "refreshToken": "RT-FROM-CALLBACK", "userInfo": `{"UserID":"u9","ScreenName":"池用户"}`,
})
if st != 200 {
t.Fatalf("回调应 200: %d %s", st, body)
}
// ① 凭据必须落在池账号的 ref
v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid)
if !ok || v == "" {
t.Fatalf("★ callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid)
}
// ② account_list 必须报 has_credential=true
lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"trae"}`,
})
if err != nil {
t.Fatal(err)
}
for _, raw := range lst.JSON.(map[string]interface{})["accounts"].([]interface{}) {
a := raw.(map[string]interface{})
if a["id"] == aid && a["has_credential"] != true {
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
}
}
}
// invokeCallback 直打 oauth_callback(Query 已含 state)。
func (h *traeHarness) invokeCallback(q map[string]string) (int, string) {
res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{
Method: "GET", Query: q,
})
if err != nil {
h.t.Fatal(err)
}
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
}

View File

@ -338,3 +338,67 @@ return plugin
t.Errorf("persisted n = %v after three exported calls, want 3", saved.State["n"])
}
}
// TestInvokeHandlerIsPersisted 是真实事故的回归:channels 插件声明 hooks=[],
// 一切状态变更(账号入池、凭据引用、模型黑名单、事件日志)都经过自己的 HTTP
// handler。而 InvokeHandler 是唯一一条**从不 markDirty** 的分发路径 —— hook 路径
// 有、Call 路径有,只有它漏了。后果:
//
// .channels.state.json 从未被写出 → 重启后账号池静默清空。
// 运维在升级前的重启里丢掉了刚登录的账号,界面显示"已登录"但列表为空。
//
// billing/quota 侥幸没踩到,只因为它们有 hooks(billing)或用 Call(quota)。
// 本测试用**无 hooks、只走 handler** 的插件复现该形态。
func TestInvokeHandlerIsPersisted(t *testing.T) {
ps, _ := newPersistVM(t)
if err := ps.LoadSource("handlersonly", `
local plugin = { name = "handlersonly", version = "1.0" }
plugin.state = { accounts = {} }
-- 不声明任何 hooks:状态只能经 HTTP handler 变更(channels 的形态)
plugin.hooks = {}
plugin.http = {
{ path = "add", method = "POST", handler = "add" },
}
function plugin.add(req)
local body = req.body or ""
local id = body:match('"id":"([^"]+)"') or "unknown"
plugin.state.accounts[id] = { id = id, enabled = true }
return { status = 200, json = { ok = true } }
end
return plugin
`); err != nil {
t.Fatal(err)
}
for _, id := range []string{"acct-1", "acct-2"} {
resp, err := ps.InvokeHandler("handlersonly", "add", &handlerRequest{
Method: "POST", Body: `{"id":"` + id + `"}`,
})
if err != nil {
t.Fatalf("handler: %v", err)
}
if resp == nil {
t.Fatal("handler returned nil response")
}
}
// Close() waits for the final flush, so no timing is involved: anything the
// handlers did NOT mark dirty cannot appear here.
ps.Close()
b, err := os.ReadFile(ps.stateFile("handlersonly"))
if err != nil {
t.Fatalf("★ 无 hooks 的插件经 HTTP handler 改状态后未落盘: %v\n"+
" —— InvokeHandler 必须像 Call / hook 路径一样 markDirty,\n"+
" 否则 channels 的账号池会在每次重启后静默清空", err)
}
var saved struct {
State map[string]interface{} `json:"state"`
}
if err := json.Unmarshal(b, &saved); err != nil {
t.Fatal(err)
}
accts, _ := saved.State["accounts"].(map[string]interface{})
if len(accts) != 2 {
t.Fatalf("落盘的 accounts = %v,应为 2 个账号", saved.State["accounts"])
}
}

View File

@ -264,6 +264,10 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
L.Pop(1)
}
if errMsg != "" {
// A handler that raised partway may already have mutated plugin.state
// (an account added before a later step failed), so mark it dirty before
// bailing: the mutation it managed to make is still real.
ps.markDirtyLocked(p)
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
}
if L.GetTop() < 1 || L.IsNil(-1) {
@ -273,6 +277,18 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
if err := luaToJSON(L, -1, &raw); err != nil {
return nil, err
}
// A handler mutates plugin.state just as a hook or an exported Call does, and
// this was the one dispatch path that never marked the plugin dirty.
//
// Consequence (real defect): a plugin with NO hooks — channels is exactly
// that, it declares hooks=[] and drives everything through its own HTTP
// routes — accumulated accounts, credential refs, model blacklists and its
// event log entirely in memory. Nothing ever marked it dirty, so
// .channels.state.json was never written and a restart silently wiped the
// account pool: the operator saw "logged in", restarted for an upgrade, and
// the account was gone. billing/quota escape this only because they declare
// hooks and/or use Call, both of which already mark dirty.
ps.markDirtyLocked(p)
return parseHandlerResponse(raw)
}

View File

@ -77,28 +77,12 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda
-- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua
-- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。
local PLATFORMS = {
-- ===== 测试/示范用占位平台(example.com,不打真实厂商)=====
{
id = "example",
name = "Example 平台",
flow = "link-poll",
login_url = "https://auth.example.com/device",
base_url = "https://api.example.com/v1",
adapter = "openai",
models = { "example-chat", "example-reason" },
checkin = { url = "https://api.example.com/v1/api/daily/claim", method = "POST", body = "{}" },
},
{
id = "googleish",
name = "Google 系(示范 callback)",
flow = "callback",
login_url = "https://accounts.google.com/o/oauth2/auth",
base_url = "https://api.googleish.example/v1",
adapter = "openai",
models = { "g-model-1" },
},
-- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)=====
--
-- ⚠️ 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的
-- 占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接
-- 展示给运维,夹具写进来就是「生产 UI 里凭空多出 4 个假平台」。
-- 夹具由测试经 channels/inject_platforms 端点运行时注入。
-- TRAE(字节跳动)—— callback 登录 + ExchangeToken 续期 + 每日签到。
-- 这是最标准的一条,作为"同类厂商"的实现模板。
@ -287,6 +271,30 @@ local PLATFORMS = {
hosts = { "copilot.tencent.com", "www.codebuddy.cn" },
base_url = "https://copilot.tencent.com",
adapter = "openai",
-- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。
-- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。
-- X-Domain 以产品配置为准(不用凭据里的 domain 快照,那是登录时的区域,
-- 跨产品迁移后会指错区);X-Product 是部署类型(SaaS);
-- X-Product-Code 是产品码(codebuddy);X-User-Id 取凭据的 user_id。
headers_fn = function(accessToken, cred)
local h = {
["Authorization"] = "Bearer " .. accessToken,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["X-Domain"] = "copilot.tencent.com",
["X-Product"] = "SaaS",
["X-Product-Code"] = "codebuddy",
["User-Agent"] = "CodeBuddyIDE/1.106.1",
}
local uid = cred and (cred.user_id or cred.uid)
if type(uid) == "string" and uid ~= "" then h["X-User-Id"] = uid end
local eid = cred and cred.enterprise_id
if type(eid) == "string" and eid ~= "" then
h["X-Enterprise-Id"] = eid
h["X-Tenant-Id"] = eid
end
return h
end,
models = {
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x",
"glm-5.3", "glm-5.3-flash", "glm-5.2",
@ -307,37 +315,61 @@ local PLATFORMS = {
end,
},
-- 余额(对齐参考 USER_RESOURCE_PATH /v2/billing/meter/get-user-resource)
--
-- ⚠️ 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层
-- data 不同),这是本接口最容易解析错的地方。
--
-- ⚠️ 刻意**不用**服务端的 TotalDosage:它是**终身口径**且取整(实测同一
-- 响应里 TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts[]
-- 现算:只累加**有效包**的 CycleCapacityRemain(失效包服务端仍会返回,
-- 但不能用于扣费,并进总额会让数字虚高),最后按两位小数规整。
balance = {
url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource",
method = "POST", body = "{}",
total_fn = function(rec)
local d = rec.data or rec
return tonumber(d.totalCredit or d.total_credit or d.balance or d.amount) or 0
local data = rec.data
if type(data) ~= "table" then return nil end
local resp = data.Response
if type(resp) ~= "table" then return nil end
local inner = resp.Data
if type(inner) ~= "table" then return nil end
local accounts = inner.Accounts
if type(accounts) ~= "table" then return nil end
local now = os.time and os.time() or 0
local sum = 0
for _, pkg in ipairs(accounts) do
if type(pkg) == "table" then
-- 失效判定:Status 显式已过期,或 ExpiredTime / DeductionEndTime 已过。
local active = true
local st = pkg.Status
if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end
local function past(t)
if type(t) ~= "string" or t == "" then return false end
local Y, Mo, D, H, Mi, S = t:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)")
if Y then
local ts = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D),
hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false })
return ts and now >= ts
end
return false
end
if past(pkg.ExpiredTime) then active = false end
local ded = tonumber(pkg.DeductionEndTime)
if ded and ded > 0 then
-- 毫秒时间戳
if now >= math.floor(ded / 1000) then active = false end
end
if active then
sum = sum + (tonumber(pkg.CycleCapacityRemain) or 0)
end
end
end
-- 两位小数规整(服务端精确值带浮点噪声,如 55.67000031)
return math.floor(sum * 100 + 0.5) / 100
end,
},
},
-- 服务端浏览器兜底示范(回环重定向类)。真实厂商若需要可改 flow="vnc"。
{
id = "loopbackish",
name = "回环登录平台(示范 VNC 兜底)",
flow = "vnc",
needs_browser = true,
login_url = "https://auth.loopbackish.example/authorize?redirect_uri=http://127.0.0.1:%d/callback",
base_url = "https://api.loopbackish.example/v1",
adapter = "openai",
models = { "lb-model-1" },
},
{
id = "cookieish",
name = "Cookie 平台(示范 paste)",
flow = "paste",
base_url = "https://api.cookieish.example/v1",
adapter = "openai",
models = { "c-model-1" },
checkin = { url = "https://api.cookieish.example/v1/api/credit/claim", method = "POST", body = "{}" },
},
-- Gemini(Google Cloud Code)—— 标准 OAuth 授权码流 + token 交换。
-- 照参考 gemini-oauth.ts:授权页 client_id/redirect_uri/scope 齐全,回环回调
-- 拿到 code 后 POST oauth2.googleapis.com/token 换 token(带 code_verifier)。
@ -735,14 +767,39 @@ local function deviceRegister(plat, payload)
return access, acct, nil
end
-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)
-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)。
--
-- 先查内建目录,再查运行时注入表(plugin.state.injected_platforms)。注入表的存在
-- 是因为**测试夹具绝不能写成生产源码**:此前 example/googleish/loopbackish/
-- cookieish 四个夹具直接内联在 PLATFORMS 里,于是它们真的出现在生产 UI(16 个
-- 平台里有 4 个是夹具)供运维选择。夹具属于测试,不属于发行物 —— 正确形态是
-- 测试用自己的那几行注入(见 channels/test_platforms 端点),生产目录只留真实厂商。
local function platformById(id)
for _, p in ipairs(PLATFORMS) do
if p.id == id then return p end
end
local injected = plugin.state and plugin.state.injected_platforms
if type(injected) == "table" then
for _, p in ipairs(injected) do
if type(p) == "table" and p.id == id then return p end
end
end
return nil
end
-- allPlatforms 返回内建目录 + 注入表(列表/遍历用)。
local function allPlatforms()
local out = {}
for _, p in ipairs(PLATFORMS) do out[#out + 1] = p end
local injected = plugin.state and plugin.state.injected_platforms
if type(injected) == "table" then
for _, p in ipairs(injected) do
if type(p) == "table" and p.id then out[#out + 1] = p end
end
end
return out
end
-- ============================================================================
-- 状态
-- ============================================================================
@ -803,6 +860,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 +1062,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 +1325,14 @@ end
-- 对 device-poll 会话,每次轮询**推进一步**设备码换 token(deviceOneStep):
-- 轮询由页面驱动而不在 Lua 里阻塞,因为插件是单 VM 单锁,阻塞循环会卡死
-- 本插件全部钩子。成功则落凭据+接入源(走 complete_login)。
--
-- ⚠️ **必须把 session 上的 account_id 传给 complete_login**(真实缺陷):
-- 页面流程是「先 account/add 建占位账号(credentialRef=channels:<plat>:<aid>)
-- → login 带 account_id → 轮询」。若这里不带 account_id,complete_login 会走
-- 兼容旧路径,凭据落到 `channels:<plat>` 这个**与占位账号无关的键**,而账号池
-- 条目的 credentialRef 指向 `channels:<plat>:<aid>` —— 于是 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 +1350,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:<plat>` 键(见 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 +1383,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:<plat>` 键,账号池查不到。
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 +1458,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 +1495,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 +1516,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 +1556,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
@ -1349,7 +1619,7 @@ function plugin.auto_claim()
-- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。
plugin.state.settings = plugin.state.settings or {}
if plugin.state.settings.auto_checkin == false then return end
for _, plat in ipairs(PLATFORMS) do
for _, plat in ipairs(allPlatforms()) do
local cap = CHANNEL_CREDITS[plat.id]
if not cap or not cap.claim then goto continue end
local legacy = accountOf(plat.id)
@ -1357,7 +1627,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
@ -1452,6 +1722,8 @@ plugin.http = {
roles = { "admin" }, description = "新人任务状态" },
{ path = "channels/onboarding/claim", method = "POST", handler = "onboarding_claim",
roles = { "admin" }, description = "领取新人奖励" },
{ path = "channels/inject_platforms", method = "POST", handler = "inject_platforms",
roles = { "admin" }, description = "注入测试/扩展平台定义(运行时,不落盘)" },
}
-- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、
@ -1503,7 +1775,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 +1833,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 +1854,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
@ -1652,7 +1924,7 @@ end
-- ============================================================================
function plugin.provider_status(req)
local out = {}
for _, p in ipairs(PLATFORMS) do
for _, p in ipairs(allPlatforms()) do
local accts = poolList(p.id)
local enabledN = 0
for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end
@ -1661,7 +1933,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 +1956,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
@ -1741,14 +2014,14 @@ function plugin.backup_export(req)
payload.credentials = {}
payload.modelDisabled = plugin.state.modelDisabled or {}
payload.autoCheckin = plugin.state.settings and plugin.state.settings.auto_checkin or true
for _, p in ipairs(PLATFORMS) do
for _, p in ipairs(allPlatforms()) 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 } }
@ -1791,7 +2064,7 @@ function plugin.backup_import(req)
plugin.state.settings.auto_checkin = payload.autoCheckin and true or false
end
-- 全部账号重新同步源
for _, p in ipairs(PLATFORMS) do
for _, p in ipairs(allPlatforms()) do
for _, a in ipairs(poolList(p.id)) do syncAccountSource(p.id, a.id) end
end
logEvent("备份已导入,恢复 " .. n .. " 条凭据")
@ -1813,8 +2086,18 @@ end
-- 签到状态 + 逐账号余额(对齐参考 credits.status / credits.balances)
-- ============================================================================
-- fetchAccountJSON 用某账号凭据发一次请求,返回 (rec_table, nil) 或 (nil, err)。
local function fetchAccountJSON(spec, tok)
local headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }
local function fetchAccountJSON(spec, tok, credTable, plat)
local headers
-- 头钩子位:spec(checkin_status / balance)自己的优先,否则用平台级的。
-- buddy 在平台层声明一次,签到与余额复用同一套身份头。
local hfn = spec.headers_fn or (plat and plat.headers_fn)
if hfn then
local okf, h = pcall(hfn, tok, credTable or {})
if okf and type(h) == "table" then headers = h end
end
if not headers then
headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }
end
local resp, err = __llmsproxy_http({
url = spec.url, method = spec.method or "GET",
headers = headers, body = spec.body,
@ -1838,9 +2121,9 @@ 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)
local rec, err = fetchAccountJSON(cs, tok, credentialFull(a.credentialRef), plat)
if rec and plat.checkin_status.status_fn then
local okf, st = pcall(plat.checkin_status.status_fn, rec)
if okf then status = st end
@ -1870,11 +2153,11 @@ 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
local rec, err = fetchAccountJSON(b, tok)
local rec, err = fetchAccountJSON(b, tok, credentialFull(a.credentialRef), plat)
if err then
entry.error = err
elseif b.total_fn then
@ -2003,7 +2286,7 @@ end
-- 承担,这里改为读取当前接入状态做查询、写入复用 provider_set_enabled。
function plugin.gateway_get_enabled(req)
local any = false
for _, p in ipairs(PLATFORMS) do
for _, p in ipairs(allPlatforms()) do
local disabled = disabledModelsOf(p.id)
local total = #(p.models or {})
local allOff = total > 0
@ -2020,7 +2303,7 @@ function plugin.gateway_set_enabled(req)
return { status = 400, json = { error = "非空布尔 enabled 必填" } }
end
-- 全平台整体开关(复用 provider_set_enabled 语义)
for _, p in ipairs(PLATFORMS) do
for _, p in ipairs(allPlatforms()) do
plugin.provider_set_enabled({ platform = p.id, enabled = args.enabled })
end
logEvent("网关整体" .. (args.enabled and "开启" or "关闭"))
@ -2030,7 +2313,7 @@ end
-- backup.status:备份健康度(对齐参考 backup.status:账号数/无过期时间账号数)
function plugin.backup_status(req)
local total, noExpiry = 0, 0
for _, p in ipairs(PLATFORMS) do
for _, p in ipairs(allPlatforms()) do
for _, a in ipairs(poolList(p.id)) do
total = total + 1
if a.expiresAt == nil then noExpiry = noExpiry + 1 end
@ -2132,7 +2415,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",
@ -2185,6 +2468,26 @@ function plugin.set_endpoints(req)
if args.checkin[k] then plat.checkin[k] = tostring(args.checkin[k]) changed[#changed+1] = "checkin." .. k end
end
end
-- checkin_status / balance:声明式端点覆盖。参考把各平台的计费/状态端点当
-- 产品配置,运维要能整体改指(自建代理、镜像、测试假上游)。此前只覆盖了
-- 旧的 plat.checkin,buddy 这类"声明 checkin_status/balance"的平台无从覆盖 ——
-- 自动化测试也就无法把它们的端点指到假上游。
if type(args.checkin_status) == "table" and plat.checkin_status then
for _, k in ipairs({ "url", "method", "body" }) do
if args.checkin_status[k] then
plat.checkin_status[k] = tostring(args.checkin_status[k])
changed[#changed+1] = "checkin_status." .. k
end
end
end
if type(args.balance) == "table" and plat.balance then
for _, k in ipairs({ "url", "method", "body" }) do
if args.balance[k] then
plat.balance[k] = tostring(args.balance[k])
changed[#changed+1] = "balance." .. k
end
end
end
if type(args.callback) == "table" and plat.callback then
for _, k in ipairs({ "exchange_url" }) do
if args.callback[k] then plat.callback[k] = tostring(args.callback[k]) changed[#changed+1] = "callback." .. k end
@ -2197,9 +2500,9 @@ end
function plugin.list_platforms(req)
local out = {}
for _, p in ipairs(PLATFORMS) do
for _, p in ipairs(allPlatforms()) 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,
@ -2211,6 +2514,55 @@ function plugin.list_platforms(req)
return { status = 200, json = { platforms = out } }
end
-- inject_platforms:**运行时**注入平台定义(测试夹具 / 运维扩展)。
--
-- 为何存在:测试夹具(打 example.com 的假平台、验证各 flow 的占位平台)
-- 绝不能写成生产源码 —— PLATFORMS 会被 UI/status/list_platforms 直接展示,
-- 夹具内联进去就是「生产 UI 里实际出现 4 个假平台」(真实踩过)。
-- 正确分层:生产目录只留真实厂商,夹具由测试运行时注入。
--
-- 语义:整表替换(与 set_endpoints 的按字段覆盖不同)—— 注入是声明式的
-- “这就是本轮我要用的目录”,避免测试之间残留状态。传空数组即清空。
-- 容量上限:注入属于运维/测试能力,不承担生产目录职责,限 32 条够用。
local INJECT_PLATFORM_LIMIT = 32
function plugin.inject_platforms(req)
local args = bodyOf(req)
if args.platforms == nil then args = req end
local list = args and (args.platforms or args.items)
if type(list) ~= "table" then
return { status = 400, json = { error = "platforms 必须是数组" } }
end
if #list > INJECT_PLATFORM_LIMIT then
return { status = 400, json = { error = "最多注入 " .. INJECT_PLATFORM_LIMIT .. " 个平台" } }
end
-- 校验:每条必须有 id,且不得与内建目录重名(注入不能劫持真实厂商)。
local builtin = {}
for _, p in ipairs(PLATFORMS) do builtin[p.id] = true end
local clean, seen = {}, {}
for _, p in ipairs(list) do
if type(p) ~= "table" or type(p.id) ~= "string" or p.id == "" then
return { status = 400, json = { error = "每个平台必须有 id" } }
end
if builtin[p.id] then
return { status = 400, json = { error = "不得覆盖内建平台: " .. p.id } }
end
if seen[p.id] then
return { status = 400, json = { error = "重复的 id: " .. p.id } }
end
seen[p.id] = true
clean[#clean + 1] = p
end
plugin.state.injected_platforms = clean
if #clean == 0 then
logEvent("已清空注入平台")
else
local ids = {}
for _, p in ipairs(clean) do ids[#ids + 1] = p.id end
logEvent("已注入平台 " .. #clean .. " 个: " .. table.concat(ids, ","))
end
return { status = 200, json = { ok = true, injected = #clean } }
end
function plugin.status_page(req)
local r = __llmsproxy_kernel.call("status", {})
return { status = 200, json = {
@ -2292,7 +2644,12 @@ function plugin.oauth_callback(req)
return { status = 400, body = cerr }
end
local res = plugin.complete_login({ platform = s.platform, credential = credential, account = account })
-- 与 poll_login 的两条完成分支同一纪律:页面流程先建了占位账号
-- (credentialRef=channels:<plat>:<aid>),完成时必须带 account_id,
-- 否则凭据落到与占位账号无关的 `channels:<plat>` 键 → 池账号永远
-- has_credential=false(同一缺陷在 callback 路径的漏网)。
local res = plugin.complete_login({ platform = s.platform, credential = credential,
account = account, account_id = s.account_id })
if res.json and res.json.error then
return { status = 400, body = res.json.error }
end

View File

@ -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