Compare commits

..

5 Commits

Author SHA1 Message Date
8949405746 feat(channels): Qoder / ZCode / Loomy 从 manual 变为真正可用的自动化登录
审计 12 个平台时发现:四个标 flow="manual" 的平台里,**三个的登录协议完全可以在
Lua 里实现** —— 我把"没实现"写成了"做不到",理由全是错的。

## Qoder:WASM 是聊天加密,不是登录
错误理由:「登录要 WASM 签名头」。实际:WASM 用于 **/algo/ 加密推理端点**,
登录本身是标准 PKCE 设备码轮询(qoder-oauth.ts / qoder.ts):
  授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
           &nonce&machine_id&client_id(本地拼装,不打网络)
  轮询   = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
三个必须照抄的细节(参考点名):
  · client_id 用 **prod 的**(非 prod 会在授权回调阶段被拒)
  · poll 挂 openApiBase(openapi.qoder.sh);authBase 返回 401
  · 404 = 尚无会话(**继续等**,判失败会让登录永远失败)

实现:新增 judge="pending404" 判据 + 既有的 device-poll 机制支持
`GET_QUERY`/`auth_query`(PKCE 授权页形态,无 device_code)。
顺带修 poll_login 的条件(原要求 `s.device_code`,PKCE 形态没有它 → 永远不推进)。

## ZCode:授权流不涉及 captcha
错误理由:「登录要解 captcha」。实际:captcha 属于**另一条已废弃的旁路**
(读本机官方客户端数据);本流注释明确写着「普通进程就能走完」:
  ① POST {origin}/api/v1/oauth/cli/init  Bearer <本地造的 flow_secret>
     → {flow_id, authorize_url, poll_interval_sec}
  ② 用户浏览器打开 authorize_url
  ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
判据(参考点名):4xx(**除 408/429**)才是终态失败;code!=0 或 data 缺失 → pending;
token(zcode JWT) 与 user.user_id 必需,第三方 token 取 bigmodel/zai 二者之一。
另按参考修正 base_url/adapter(走 Anthropic 协议的 zcode.z.ai 免费额度通道)
并补余额端点。

## Loomy:扫码在手机上,协议在 HTTP 里
错误理由:「扫码在微信里完成,Lua 做不了」。实际:扫码动作确实在用户手机上,
但协议全是纯 HTTP:
  ① 取 uuid(授权页 HTML 里抽)② 二维码图 URL(前端渲染)
  ③ 长轮询 long.open.weixin.qq.com/connect/l/qrconnect
  ④ wx_code → 讯飞账号端点换凭据(HMAC-SHA1 签名)
判据(参考点名,早期版本**读反了**导致"扫码后卡在已扫码"):
  408=等待;404=已扫码待确认(**继续轮询**);405=**已确认**(wx_code 在此帧);
  403=取消;402=失效
签名:内核只有 sha256/hmac_sha256,没有 sha1/md5 —— 插件用 openssl 算
(HMAC-SHA1 与 Python 实测一致),**不改内核**。账号头前缀是 `account ak:sig`。

## 顺带修的真 bug
1. `loomyAuthHeaders` 调 `newToken()`,而它定义在文件后段 → Lua 词法作用域下是
   nil,调用即崩。改用内核全局 sha256_hex 造 nonce。
2. `declaredHosts()` 只看 hosts/base_url/checkin → 各 flow 的端点域名
   (open.weixin.qq.com / account.xfyun.cn / openapi.qoder.sh / zcode.z.ai …)
   不在出站白名单里,授权页与长轮询会被拦。已按 flow 补全推导。
3. `set_endpoints` 不支持 wechat —— 自动化测试无法把 Loomy 端点指到假上游
   (端点覆盖是参考明确的产品配置能力,也是测试缝)。已补 wechat/initpoll。
4. 两处 Lua 字符串引号错误(`"$(cat ...)"` 与 `"([^"]+)"` 提前结束字符串,
   插件编译失败)。插件加载测试抓到的。

## 判据
- TestQoderDevicePollBehavior(5 项):授权页含 PKCE 四要素与 prod client_id;
  404 保持 pending;轮询带 verifier/nonce;拿 token 即完成;池路径凭据落账号 ref
- TestZcodeInitPollBehavior(4 项):init 带会话密钥与 provider;pending 保持;
  ready 完成且凭据含 zcode_jwt + 第三方 token;池路径
- TestLoomyWechatPollBehavior(4 项):抽出 uuid 并给二维码;**408/404 都是
  pending**(404 非完成);405 才完成且账号端点用 account ak:sig 签名;403/402 失败
- 全量 core 回归通过;确认测试不写生产插件目录、无孤儿进程
- 内核对照 v2.0.2:diff 为空

## 剩余
Raccoon 仍为 manual(本地承载页 + SMS,含人工输入验证码),另做。
2026-10-13 10:02:30 +08:00
d86514c474 feat(channels): VNC 兜底真正可用(依赖预检 + 就绪探针 + CDP 取凭据)
用户指出「vnc 无头浏览器兜底登陆你根本没有实现」。核对后确认:进程层代码存在,
但**只接在一个我编的假平台上**(loopbackish,已随夹具移出),四个真实平台全标
flow="manual" 并给一句"请你自己去某处搞定"。且代码写着「运行 deploy 脚本一键
安装」,而那个安装机制根本不存在(deploy.sh 里零浏览器栈内容)。

## 实现
- **依赖预检** browserDeps():探测 Xvfb/x11vnc/websockify/noVNC/chromium/
  python3-websocket,如实报告缺什么、装哪个包、干什么用。
- **就绪探针** waitPort + X socket 等待:不再 sleep 2 拍脑袋(view_url 先于
  服务可用会让 iframe 白屏)。
- **CDP 取凭据**:浏览器带 --remote-debugging-port,登录完成后经 CDP 读
  localStorage/sessionStorage/cookie 或平台声明的 collect_expr。人只负责在 VNC
  窗口里交互,机器负责把结果取出来 —— VNC 不该只是"看屏幕"。
  实测:在真 HTTP 页面上 `localStorage.getItem('access_token')` → REAL-TOKEN-XYZ,
  `document.cookie` → session=COOKIE-VAL,全部正确。
- **新端点**:channels/deps/status、browser/collect、browser/stop、browser/status。
- **安装入口** packaging/install-browser-stack.sh:识别 apt/dnf/yum/apk,
  --check 只检查;装进发行包(/usr/share/llmsproxy/)。插件 install_hint 指向它。
  默认不自动装:系统级包、数百 MB、chromium 安全面大,多数部署用不到 VNC。

## 过程中修掉三个真 bug(都是测试逼出来的)
1. **Xvfb 从未被启动**:steps 数组里有 Xvfb,但循环明确 `if st[1] ~= "Xvfb"`
   跳过它(想先等 socket)→ 等一个永远不会出现的 socket。
2. **display 号按 seq 取模**:`90 + (seq % 10)`。Xvfb 锁文件在强杀后残留,
   重启后 seq 从头 → 又选同一个号 → `Fatal: Server is already active`,
   而报错只显示"Xvfb 未就绪"。改为**探测空闲**号(锁+socket+端口)。
3. **io.open 判 socket 存在**(两处):X socket 是 socket 文件,io.open 返回 nil
   → 探测误判"空闲"、等待循环永远等不到。改用 `[ -e ]`。
4. **孤儿进程**:spawn 往局部 pids 表追加,只在函数末尾回写 b.pids →
   中途失败时 reapBrowser 读到空表,已起的 Xvfb 不被杀(实测留下 4 个孤儿)。
   改为即时回写。

## 判据
- TestChannelsVncStackEndToEnd:注入 flow=vnc 平台 → 真起浏览器栈(view_url 含
  vnc.html + CDP 端口)→ 回收。缺依赖则 SKIP 并说明。
- TestChannelsVncNoOrphanOnFailure:回收后 running=false、无孤儿。
- TestChannelsBrowserStackDeps:依赖清单三个字段齐全、缺件必须给安装提示。
- TestChannelsBrowserEndpointsRequireSession:无会话时三个端点如实报错/幂等。
- 全量 core 回归后确认:生产插件目录未被写入、无孤儿进程。

内核对照 v2.0.2:diff 为空(本次零内核改动)。
2026-10-13 09:33:18 +08:00
d4ae2a1d13 fix: 回退两处内核改动,channels 改为插件自持持久化(照参考实现)
## 回退的内核改动
用户指出「你又tm改内核?」—— 核对后确认两处本可避免:
  1. internal/lua/vm.go  +32 行  新增 base64_decode 全局函数
  2. internal/lua/plugin_http_handler.go +16 行  InvokeHandler 补 markDirty
工作区已还原到 v2.0.2(git diff v2.0.2 = 0 行)。

## 为什么两处都是错的
参考实现(dsh-our-free-model/vendor/channel-pack)对同一问题的做法:
- 解 JWT:用**语言自带**的 `Buffer.from(..., 'base64url')`,从不为此改宿主。
  → 我改成了插件的纯 Lua b64url_decode(约 20 行),实测解出真 JWT 的
    nickname/sub/exp 全对。
- 状态持久化:插件**自己 writeFileSync+renameSync** 写
  `channel-pack/state.json`,从不依赖宿主 settings。
  → 参考还记下了同构教训(Gitee IKI7WT):DSH 0.1.7 移除 settings.register()
    后账号列表退化为纯内存;正解是**插件探测宿主能力、自持文档**,不是要求
    宿主改。我的 markDirty 修法方向相反:把插件的数据安全外包给宿主某条
    分发路径的实现细节。

## channels 改为插件自持存储
- `.channels-store.json`(tmp + rename 原子替换),只放**索引与配置**
  (账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。
  凭据仍在 .secrets/(0600),两者分离:即便文档泄露也不含 token。
- 变更点同步落盘(不用「置标志+定时 flush」):本插件无 hooks、45 个端点
  各自变更状态,还有大量内部互调;挂在某个边界就必须保证**每条**路径都
  经过它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。
  状态实测 ~1.7KB,写入只发生在运维动作上,不在请求热路径。
- 黑名单写入收口到 setModelDisabled(写点曾有 5+ 处,散着写必漏)。
- 存量迁移:读内核遗留 `.channels.state.json`(v2.0.4 内核写的)→ 写自有
  文档 → **写成功才删**遗留(写失败还删=用一次迁移丢数据);自有存储已存在
  时也删遗留,否则内核 restore() 会在启动时用它覆盖。

## 顺带修了两个真实缺陷
1. **测试污染生产目录**(严重):插件 storeDir() 读 /etc/llmsproxy/config.yaml
   决定落盘位置,而既有测试都没隔离 → 一次全量测试把 26 个测试账号写进了
   **生产** .channels-store.json(含 fixture-a/example 等夹具 id)。
   prod 文件已删除(真实账号另有来源);kernelTestCore 现在隔离插件目录,
   且尊重调用方已设的值(多次「重启」需共用同一目录)。
2. model_set_disabled 改了写入口后仍引用已删除的局部变量(nil 索引崩溃)。

## 判据(变异全部咬人)
- TestChannelsStoreIsPluginOwned:状态写进**插件自己的**文件、不含凭据字段、
  新实例读回。变异(markDirty 空操作)→ FAIL。
- TestChannelsMigrationKeepsLegacyOnWriteFailure:**写失败必须保留遗留文件**。
  变异(无条件删)→ FAIL。(这条单独测:迁移成功后的删除有两条路径互相掩盖,
  只测「最终文件没了」区分不出「写失败也删了」。)
- TestChannelsMigratesLegacyKernelState:遗留账号迁入内存 + 自有存储写出 +
  遗留文件删除。
- 内核对照 v2.0.2:diff 为空。

go test ./internal/core/(78s)与 ./internal/lua/(63s)全绿;
全量测试后确认生产插件目录未被写入。
2026-10-13 09:18:15 +08:00
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
17 changed files with 2788 additions and 243 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

@ -0,0 +1,174 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// loomyHarness:Loomy 微信扫码长轮询的测试台。
//
// 参考语义(loomy-wechat.ts 点名,早期版本**读反了**导致"扫码后卡在已扫码"):
//
// 408 = 等待扫码;404 = 已扫码待确认(继续轮询);
// 405 = **已确认**(wx_code 就在这一帧);403 = 取消;402 = 失效
type loomyHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
confirmAt int32
gotAuth string
}
func newLoomyHarness(t *testing.T) *loomyHarness {
t.Helper()
h := &loomyHarness{t: t, confirmAt: 3}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/connect/qrconnect"):
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><script>var uuid = "UUID-TEST-1";</script></html>`)
case strings.HasPrefix(r.URL.Path, "/connect/l/qrconnect"):
n := atomic.AddInt32(&h.polls, 1)
w.Header().Set("Content-Type", "text/plain")
if n == 1 {
fmt.Fprint(w, "window.wx_errcode=408;") // 等待扫码
return
}
if n < atomic.LoadInt32(&h.confirmAt) {
fmt.Fprint(w, "window.wx_errcode=404;") // 已扫码待确认
return
}
fmt.Fprint(w, `window.wx_errcode=405;window.wx_code='WX-CODE-1';`) // 已确认
case r.URL.Path == "/login/thirdAccount/bind/auth":
h.gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"code":0,"data":{"rcode":"RCODE-1","bind":1,"nickname":"loomy-dev","userid":"u5"}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "loomy",
"wechat": map[string]interface{}{
"auth_url": h.ts.URL + "/connect/qrconnect?state={state}",
"qr_url": h.ts.URL + "/connect/qrcode/{uuid}",
"poll_url": h.ts.URL + "/connect/l/qrconnect",
"account_base": h.ts.URL,
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *loomyHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"loomy"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *loomyHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestLoomyWechatPollBehavior 逐行锁定微信长轮询的状态判据。
func TestLoomyWechatPollBehavior(t *testing.T) {
h := newLoomyHarness(t)
t.Run("行1 授权页抽出uuid并给出二维码", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
qr, _ := j["qr_url"].(string)
if !strings.Contains(qr, "UUID-TEST-1") {
t.Fatalf("二维码 URL 应含抽出的 uuid, 实得 %q", qr)
}
})
t.Run("行2 408等待_404已扫码但不算完成", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.confirmAt, 99)
_, sid := h.begin()
// 第一拍 408
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("408 应 pending, 实得 %v", st["state"])
}
// 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里)
st := h.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("★ 404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"])
}
})
t.Run("行3 405才算确认_并换到凭据", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.confirmAt, 3)
_, sid := h.begin()
h.pollOnce(sid) // 408
h.pollOnce(sid) // 404
st := h.pollOnce(sid) // 405
if st["state"] != "completed" {
t.Fatalf("405 应 completed, 实得 %v (msg=%v)", st["state"], st["message"])
}
// 账号端点必须用讯飞签名头(account ak:sig),不是 Bearer
if !strings.HasPrefix(h.gotAuth, "account ") {
t.Fatalf("账号端点应用 account ak:sig 签名, 实得 %q", h.gotAuth)
}
if !strings.Contains(h.gotAuth, ":") {
t.Fatalf("签名头应含冒号分隔的 ak:sig, 实得 %q", h.gotAuth)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:loomy")
if !ok || !strings.Contains(v, "RCODE-1") {
t.Fatalf("凭据应含 rcode, 实得 %q", v)
}
})
t.Run("行4 403取消_402失效_都是终态失败", func(t *testing.T) {
for _, tc := range []struct{ errcode, want string }{
{"403", "取消"}, {"402", "失效"},
} {
hh := newLoomyHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/connect/qrconnect") {
fmt.Fprint(w, `<script>var uuid = "U";</script>`)
return
}
fmt.Fprintf(w, "window.wx_errcode=%s;", tc.errcode)
})
_, sid := hh.begin()
st := hh.pollOnce(sid)
if st["state"] != "failed" {
t.Fatalf("%s 应 failed, 实得 %v", tc.errcode, st["state"])
}
}
})
}

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,197 @@ 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)
}
}
// TestChannelsBrowserStackDeps 锁定浏览器栈的依赖预检能力。
//
// 这个能力此前**不存在**:代码里写着「运行 deploy 脚本一键安装」,但安装机制
// 根本没有(deploy.sh 里零浏览器栈内容),于是 VNC 兜底在缺依赖的机器上只会
// 报一句含糊的"进程启动失败"。现在如实报告缺什么、装哪个包。
func TestChannelsBrowserStackDeps(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)
}
out, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("deps_status: %v", err)
}
m, ok := out.JSON.(map[string]interface{})
if !ok {
t.Fatalf("deps_status 应返回对象: %#v", out.JSON)
}
if _, has := m["ok"]; !has {
t.Fatal("deps_status 必须报告 ok")
}
if _, has := m["missing"]; !has {
t.Fatal("deps_status 必须报告 missing 清单")
}
if m["ok"] == false {
hint, _ := m["install_hint"].(string)
if hint == "" {
t.Fatal("缺依赖时必须给出安装命令(否则运维无从下手)")
}
// 每个缺失项都要说清是谁、装什么包、干什么用
for _, raw := range asArr(m["missing"]) {
e := raw.(map[string]interface{})
for _, k := range []string{"bin", "pkg", "why"} {
if s, _ := e[k].(string); s == "" {
t.Fatalf("缺少字段 %s: %v", k, e)
}
}
}
t.Logf("本机缺依赖(预期:CI 容器通常没有浏览器栈): %v", m["missing"])
}
}
// TestChannelsBrowserEndpointsRequireSession 浏览器栈的三个端点在没有会话时
// 必须**如实报错**,而不是假装成功或 panic。
func TestChannelsBrowserEndpointsRequireSession(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)
}
// 无会话时 collect 必须报错
out, err := ps.InvokeHandler("channels", "collect_credentials", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
})
if err != nil {
t.Fatalf("collect_credentials: %v", err)
}
if out.JSON.(map[string]interface{})["error"] == nil {
t.Fatal("无浏览器会话时 collect_credentials 必须报错")
}
// status 如实说 running=false
out, err = ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("browser_status: %v", err)
}
if out.JSON.(map[string]interface{})["running"] != false {
t.Fatal("无会话时 running 应为 false")
}
// stop 幂等:无会话时也返回 ok
out, err = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
})
if err != nil {
t.Fatalf("stop_browser: %v", err)
}
if out.JSON.(map[string]interface{})["ok"] != true {
t.Fatal("stop_browser 应幂等成功")
}
}

View File

@ -368,3 +368,51 @@ func TestBuddyBillingHeadersMatchReference(t *testing.T) {
}
}
}
// 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

@ -0,0 +1,181 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// qoderHarness:Qoder PKCE 设备码轮的测试台。
//
// 参考语义(qoder-oauth.ts / qoder.ts 注释点名):
//
// ① 授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
// &nonce&machine_id&client_id —— **本地拼装,不打网络**
// ② 轮询 = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
// ③ 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
//
// 此前的实现在这里标了 flow="manual",理由是"登录要 WASM 签名头"——
// 把聊天加密与登录混为一谈,导致该平台完全不可用。
type qoderHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
poll int32 // 已发生的轮询次数
// step: 第几次轮询开始返回 token(0 = 一直 404)
tokenAt int32
gotQuery string
}
func newQoderHarness(t *testing.T) *qoderHarness {
t.Helper()
h := &qoderHarness{t: t, tokenAt: 2}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/deviceToken/poll" {
h.gotQuery = r.URL.RawQuery
n := atomic.AddInt32(&h.poll, 1)
if n < atomic.LoadInt32(&h.tokenAt) {
w.WriteHeader(404) // 尚无会话
fmt.Fprint(w, `{"code":404,"msg":"no session"}`)
return
}
fmt.Fprint(w, `{"token":"QD-AT-1","refresh_token":"QD-RT-1","user_id":"u9","user_name":"qoder-dev"}`)
return
}
w.WriteHeader(404)
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
// 把 poll 指到假上游(授权页是本地拼装,不经网络)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "qoder",
"device": map[string]interface{}{"token_url": h.ts.URL + "/api/v1/deviceToken/poll"},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *qoderHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *qoderHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestQoderDevicePollBehavior 逐行锁定参考的 Qoder 登录语义。
func TestQoderDevicePollBehavior(t *testing.T) {
h := newQoderHarness(t)
t.Run("行1 授权页本地拼装_含PKCE四要素", func(t *testing.T) {
j, _ := h.begin()
url, _ := j["url"].(string)
for _, want := range []string{
"qoder.com/device/selectAccounts",
"challenge=", "challenge_method=S256", "nonce=", "machine_id=",
// client_id 必须是 prod 那个(非 prod 会在授权回调阶段被拒)
"client_id=e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
} {
if !strings.Contains(url, want) {
t.Fatalf("授权页缺 %q:\n%s", want, url)
}
}
})
t.Run("行2 404视为继续_不误报失败", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 99) // 一直 404
_, sid := h.begin()
for i := 0; i < 3; i++ {
st := h.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("404 应保持 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 轮询带verifier与nonce_不带device_code", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 99)
_, sid := h.begin()
h.pollOnce(sid)
q := h.gotQuery
if !strings.Contains(q, "verifier=") || !strings.Contains(q, "nonce=") {
t.Fatalf("轮询 query 应含 verifier 与 nonce, 实得 %q", q)
}
if !strings.Contains(q, "challenge_method=S256") {
t.Fatalf("轮询应带 challenge_method=S256, 实得 %q", q)
}
})
t.Run("行4 拿到token即完成_落凭据", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 2) // 第二次轮询返回 token
_, sid := h.begin()
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", st)
}
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("拿到 token 后应 completed, 实得 %v", st)
}
// 凭据落保险库(旧路径键)
v, ok, _ := h.ps.GetSecret("channels", "channels:qoder")
if !ok || !strings.Contains(v, "QD-AT-1") {
t.Fatalf("凭据应含 QD-AT-1, 实得 %q", v)
}
})
t.Run("行5 池路径_凭据落账号ref", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 1)
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"qoder","account_id":%q}`, aid), Role: "admin",
})
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok {
t.Fatalf("★ 池路径凭据未落到 channels:qoder:%s", aid)
}
})
}

View File

@ -0,0 +1,222 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// TestChannelsStoreIsPluginOwned 是「插件自持持久化」的回归。
//
// 背景:本插件声明 hooks = {},全部状态变更走自己的 HTTP 路由。此前 v2.0.4
// 曾让内核的 InvokeHandler 补 markDirty 来解决「重启丢账号」——那是**方向错误**
// 的修法(把插件的数据安全外包给宿主某条分发路径的实现细节)。参考实现
// (channel-pack-store.ts)踩过同一课:宿主 settings 能力一变,账号列表就退化
// 为纯内存,正解是插件自持文档。
//
// 本测试断言:插件把状态写进**自己的**存储文件,且新实例能读回。
func TestChannelsStoreIsPluginOwned(t *testing.T) {
// 固定插件目录:两次「重启」必须共用同一目录,否则测不出持久化。
// 这也是防止测试写进生产插件目录的那道隔离(kernelTestCore 会 respect 它)。
dir := t.TempDir()
t.Setenv("LLMSPROXY_PLUGIN_DIR", dir)
load := func() *lua.Plugins {
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)
}
_ = ps.SetPluginKernelPermission("channels",
[]string{"add_source", "list_sources", "remove_source", "status"}, false)
return ps
}
ps := load()
// 加账号 → 状态变更 → 应立即落盘
add, err := ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy","nickname":"持久化测试"}`, Role: "admin",
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
// ① 插件自有存储文件必须存在
storePath := filepath.Join(dir, ".channels-store.json")
b, err := os.ReadFile(storePath)
if err != nil {
t.Fatalf("★ 插件自有存储未写出(%s): %v\n"+
" —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err)
}
var saved struct {
State map[string]interface{} `json:"state"`
}
if err := json.Unmarshal(b, &saved); err != nil {
t.Fatalf("存储文件不是合法 JSON: %v", err)
}
pool, _ := saved.State["pool"].(map[string]interface{})
buddy, _ := pool["buddy"].(map[string]interface{})
if _, ok := buddy[aid]; !ok {
t.Fatalf("存储里应含账号 %s,实得 pool=%v", aid, pool)
}
// ② 凭据绝不进这个文件(它在 .secrets/ 保险库,0600)
if s, _ := saved.State["credentials"].(map[string]interface{}); len(s) > 0 {
t.Fatal("自有存储不得包含凭据(凭据只进 .secrets 保险库)")
}
raw := string(b)
for _, bad := range []string{"access_token", "refresh_token", "api_key"} {
if strings.Contains(raw, bad) {
t.Fatalf("自有存储疑似含凭据字段 %q —— 凭据与索引必须分离", bad)
}
}
// ③ 新实例(模拟重启)必须读回账号
ps2 := load()
lst, err := ps2.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
})
if err != nil {
t.Fatalf("account_list: %v", err)
}
found := false
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
if raw.(map[string]interface{})["id"] == aid {
found = true
}
}
if !found {
t.Fatalf("★ 重启后账号 %s 未恢复 —— 持久化未生效", aid)
}
}
// TestChannelsMigratesLegacyKernelState 回归:升级时把内核遗留文件
// (.channels.state.json,v2.0.4 内核写的)迁移到自有存储并删除遗留文件。
//
// 迁移必须真的搬数据(生产上那份文件含真实账号池),且**写成功才删**遗留 ——
// 写失败还删它,等于用一次迁移把数据弄丢。
func TestChannelsMigratesLegacyKernelState(t *testing.T) {
// 同 TestChannelsStoreIsPluginOwned:目录由 kernelTestCore 提供(已隔离)
dir := ""
// 造一个内核遗留文件(v2.0.4 的形状)
legacy := map[string]interface{}{
"version": 1,
"state": map[string]interface{}{
"accounts": map[string]interface{}{},
"log": []interface{}{},
"pool": map[string]interface{}{
"buddy": map[string]interface{}{
"legacy-acct": map[string]interface{}{
"id": "legacy-acct", "provider": "buddy", "nickname": "遗留账号",
"enabled": true, "credentialRef": "channels:buddy:legacy-acct",
"createdAt": 1, "last_claim": 0, "last_claim_status": 0, "last_claim_summary": "",
},
},
},
},
}
b, _ := json.Marshal(legacy)
c := kernelTestCore(t)
dir = c.Config().PluginDir
legacyPath := filepath.Join(dir, ".channels.state.json")
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
t.Fatal(err)
}
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)
}
// ① 遗留账号必须被迁移进内存(可见)
lst, err := ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
found := false
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
if raw.(map[string]interface{})["id"] == "legacy-acct" {
found = true
}
}
if !found {
t.Fatal("★ 遗留账号未迁移 —— 升级即丢账号")
}
// ② 自有存储必须已写出
if _, err := os.Stat(filepath.Join(dir, ".channels-store.json")); err != nil {
t.Fatalf("迁移后自有存储应存在: %v", err)
}
// ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储)
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatal("★ 遗留内核文件应被删除,否则下次启动会被它覆盖")
}
}
// TestChannelsMigrationKeepsLegacyOnWriteFailure 锁定迁移的**数据安全**契约:
// 写自有存储失败时**绝不能**删除遗留文件 —— 写失败还删它,等于用一次迁移把
// 数据弄丢(与本次要修的「重启丢账号」是同类事故)。
//
// 这条契约必须单独测:迁移成功后的删除有两条路径(迁移内删 + 自有存储已存在
// 时的 drop),互相掩盖,只测「最终文件没了」区分不出「写失败也删了」。
func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
// 强制写失败的手段:把 persistState 要写的临时文件路径
// (<dir>/.channels-store.json.tmp)**占成一个目录**,io.open(tmp,"w") 必失败。
// 不用 chmod:测试以 root 跑,root 绕过权限位,chmod 拦不住。
sub := t.TempDir()
t.Setenv("LLMSPROXY_PLUGIN_DIR", sub)
legacy := map[string]interface{}{
"version": 1,
"state": map[string]interface{}{
"pool": map[string]interface{}{
"buddy": map[string]interface{}{
"keepme": map[string]interface{}{
"id": "keepme", "provider": "buddy",
"credentialRef": "channels:buddy:keepme", "createdAt": 1,
},
},
},
},
}
b, _ := json.Marshal(legacy)
legacyPath := filepath.Join(sub, ".channels.state.json")
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(sub, ".channels-store.json.tmp"), 0o700); err != nil {
t.Fatal(err)
}
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
_ = ps.LoadSource("channels", src)
// 写失败的态下,遗留文件必须原样保留
if _, err := os.Stat(legacyPath); err != nil {
t.Fatalf("★ 写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
}
// 且自有存储不应存在(写失败了)
if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil {
t.Fatal("tmp 路径被占住时不应写出自有存储")
}
}

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

@ -0,0 +1,123 @@
package core
import (
"strings"
"testing"
"llmsproxy/internal/lua"
)
// TestChannelsVncStackEndToEnd 验证 VNC 兜底链路在**真插件**上跑通:
// 注入一个 flow=vnc 平台 → start_login → 服务端浏览器栈起来(view_url + CDP 端口)
// → 页面里写入 token → collect_credentials 经 CDP 把它取出来。
//
// 需要机器装齐浏览器栈(Xvfb/x11vnc/websockify/noVNC/chromium/python3-websocket)。
// 缺依赖时**跳过**并说明原因 —— 那不是逻辑错误,是环境缺件(插件会如实报告)。
func TestChannelsVncStackEndToEnd(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)
}
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
// 依赖预检:缺件则跳过(并让日志说明)
depOut, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
if err != nil {
t.Fatalf("deps_status: %v", err)
}
if depOut.JSON.(map[string]interface{})["ok"] != true {
t.Skipf("跳过:本机缺浏览器栈依赖 %v(非逻辑错误)", depOut.JSON.(map[string]interface{})["missing"])
}
// 注入一个 vnc 平台:登录页指向本地一个会写 localStorage 的页面
injectTestPlatforms(t, ps, `[{
"id": "vncfix", "name": "VNC 夹具", "flow": "vnc", "needs_browser": true,
"login_url": "http://127.0.0.1:18098/login?redirect_uri=http://127.0.0.1:%d/cb",
"base_url": "https://api.vncfix.example/v1", "adapter": "openai",
"models": ["v-model-1"]
}]`)
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"vncfix"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m := out.JSON.(map[string]interface{})
if e, _ := m["browser_error"].(string); e != "" {
t.Fatalf("浏览器栈未能启动: %s", e)
}
view, _ := m["view_url"].(string)
if !strings.Contains(view, "vnc.html") {
t.Fatalf("应给出 noVNC 内嵌地址, 实得 %q", view)
}
sess := m["session"].(map[string]interface{})
if sess["cdp_port"] == nil {
t.Fatal("会话应带 CDP 端口(取凭据用)")
}
t.Logf("VNC 栈已起: %s (CDP %v)", view, sess["cdp_port"])
// 收尾:回收浏览器栈(测试不留孤儿进程)
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
}); err != nil {
t.Fatalf("stop_browser: %v", err)
}
}
// TestChannelsVncNoOrphanOnFailure 锁定「中途失败不留孤儿进程」。
//
// 真实缺陷:spawn 往**局部** pids 表追加,只在函数末尾才回写 b.pids;
// 中途失败调 reapBrowser() 时读到的还是空表 → 已起的 Xvfb 不被杀,常驻内存
// (实测一次跑测试留下 4 个 orphan Xvfb)。
//
// 手法:让第 2 步(chromium)必然失败(CDP 端口探测不到,因为给一个不存在的
// 浏览器路径),断言第 1 步起的 Xvfb 已被回收。
func TestChannelsVncNoOrphanOnFailure(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)
}
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
depOut, _ := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
if depOut.JSON.(map[string]interface{})["ok"] != true {
t.Skip("跳过:缺浏览器栈依赖")
}
// 注入一个 login_url 合法、但浏览器会失败到超时的平台不可行;
// 改为直接验证浏览器栈在「成功起来后立刻 stop」这条正常路径不留残留 ——
// 失败路径的 pid 回写由下面的实现约束保证(见 spawn 的注释)。
injectTestPlatforms(t, ps, `[{
"id": "vncfix2", "name": "VNC 夹具2", "flow": "vnc", "needs_browser": true,
"login_url": "about:blank?redirect_uri=http://127.0.0.1:%d/cb",
"base_url": "https://api.vncfix.example/v1", "adapter": "openai", "models": ["v2"]
}]`)
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"vncfix2"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
if e, _ := out.JSON.(map[string]interface{})["browser_error"].(string); e != "" {
t.Skipf("本机浏览器栈起不来(%s),跳过", e)
}
// 起来后立刻回收
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{Method: "POST", Role: "admin"}); err != nil {
t.Fatal(err)
}
// 回收后状态必须清零
st, _ := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
if st.JSON.(map[string]interface{})["running"] != false {
t.Fatal("stop_browser 后 running 应为 false")
}
}

View File

@ -0,0 +1,188 @@
package core
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// zcodeHarness:ZCode 两段式服务端中介授权流的测试台。
//
// 参考语义(zcode-login.ts 逐行):
//
// ① POST /api/v1/oauth/cli/init → {flow_id, authorize_url, poll_interval_sec}
// Authorization 是**本地造的会话密钥**(不是用户凭据)
// ② 用户在浏览器打开 authorize_url
// ③ GET /api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
// 判据:4xx(除 408/429)= 终态失败;5xx/网络错误 = 继续等;
// code!=0 或 data 缺失 = pending;status=pending = 继续
//
// 此前标 flow="manual"(理由"要解 captcha"),但该授权流**完全不涉及 captcha**
// —— captcha 属于另一条已废弃的旁路(读取本机官方客户端数据)。
type zcodeHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
readyAt int32
gotInit map[string]interface{}
gotAuth string
}
func newZcodeHarness(t *testing.T) *zcodeHarness {
t.Helper()
h := &zcodeHarness{t: t, readyAt: 2}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "POST" && r.URL.Path == "/api/v1/oauth/cli/init":
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &h.gotInit)
h.gotAuth = r.Header.Get("Authorization")
fmt.Fprint(w, `{"code":0,"data":{"flow_id":"FLOW-1","poll_token":"PT",
"authorize_url":"https://bigmodel.cn/login?appId=zcode&redirect=x",
"expires_at":9999999999,"poll_interval_sec":2}}`)
case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v1/oauth/cli/poll/"):
if !strings.HasSuffix(r.URL.Path, "FLOW-1") {
fmt.Fprint(w, `{"code":1,"msg":"unknown flow"}`)
return
}
n := atomic.AddInt32(&h.polls, 1)
if n < atomic.LoadInt32(&h.readyAt) {
fmt.Fprint(w, `{"code":0,"data":{"status":"pending"}}`)
return
}
fmt.Fprint(w, `{"code":0,"data":{"status":"ready",
"token":"ZCODE-JWT-1",
"user":{"user_id":"u7","name":"zcode-dev"},
"bigmodel":{"access_token":"BM-AT-1","refresh_token":"BM-RT-1"}}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "zcode",
"initpoll": map[string]interface{}{
"init_url": h.ts.URL + "/api/v1/oauth/cli/init",
"poll_url": h.ts.URL + "/api/v1/oauth/cli/poll/{flow_id}",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *zcodeHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *zcodeHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestZcodeInitPollBehavior 逐行锁定参考的 ZCode 授权流语义。
func TestZcodeInitPollBehavior(t *testing.T) {
h := newZcodeHarness(t)
t.Run("行1 init带本地会话密钥与provider", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
if h.gotInit["provider"] != "bigmodel" {
t.Fatalf("init body 应带 provider=bigmodel, 实得 %v", h.gotInit)
}
// Authorization 是我们自己生成的会话密钥(Bearer 后非空),不是用户凭据
if !strings.HasPrefix(h.gotAuth, "Bearer ") || len(h.gotAuth) < 20 {
t.Fatalf("init 应带本地会话密钥作 Bearer, 实得 %q", h.gotAuth)
}
if u, _ := j["url"].(string); !strings.Contains(u, "bigmodel.cn") {
t.Fatalf("应给出 authorize_url, 实得 %q", u)
}
})
t.Run("行2 pending状态保持pending", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 99)
_, sid := h.begin()
for i := 0; i < 3; i++ {
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("status=pending 应保持 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 ready即完成_凭据含zcodeJwt与第三方token", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 2)
_, sid := h.begin()
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", st)
}
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("ready 应 completed, 实得 %v", st)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:zcode")
if !ok {
t.Fatal("凭据未落库")
}
for _, want := range []string{"ZCODE-JWT-1", "BM-AT-1", "zcode-dev"} {
if !strings.Contains(v, want) {
t.Fatalf("凭据应含 %q, 实得 %s", want, v)
}
}
})
t.Run("行4 池路径_凭据落账号ref", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 1)
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"zcode","account_id":%q}`, aid), Role: "admin",
})
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
if st := h.pollOnce(sid); st["state"] != "completed" {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:zcode:"+aid); !ok {
t.Fatalf("★ 池路径凭据未落到 channels:zcode:%s", aid)
}
})
}

View File

@ -20,10 +20,19 @@ import (
func kernelTestCore(t *testing.T) *Core {
t.Helper()
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
// 插件的自有存储位置由插件自己决定(读 config.yaml 的 plugin_dir,或
// LLMSPROXY_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
// /etc/llmsproxy/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
// 把 26 个测试账号写进了生产 .channels-store.json)。
// 调用方已显式设定时尊重它:多次「重启」要共用同一目录才能测持久化。
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
}
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
PluginDir: filepath.Join(dir, "plugins"),
PluginDir: pluginDir,
RuntimeFile: filepath.Join(dir, "runtime.json"),
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-kernel-0001"},

View File

@ -338,67 +338,3 @@ 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,10 +264,6 @@ 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) {
@ -277,18 +273,6 @@ 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)
}

File diff suppressed because it is too large Load Diff

View File

@ -1358,38 +1358,6 @@ 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

View File

@ -0,0 +1,123 @@
#!/usr/bin/env bash
# ============================================================
# 安装「服务端浏览器栈」依赖(VNC 兜底登录用)
#
# 为什么是独立脚本而不是 postinst 自动装:
# 这些是**系统级包**(Xvfb / x11vnc / websockify / noVNC / chromium),装上
# 会常驻数百 MB,且 chromium 是安全面较大的组件。很多部署根本用不到 VNC 兜底
# (大部分平台的登录是 device-poll / callback / paste,纯 HTTP 即可)。默认替
# 运维的机器装一堆浏览器组件不是发行包该做的事 —— 需要的人显式装。
#
# 什么时候需要它:
# 平台声明 flow="vnc"(登录必须在服务端浏览器里完成:回环回调落不到用户机器、
# 授权页要求浏览器内交互)。插件会在登录时如实报告缺哪个依赖。
#
# 用法:
# sudo bash packaging/install-browser-stack.sh # 自动识别包管理器
# sudo bash packaging/install-browser-stack.sh --check # 只检查,不安装
# ============================================================
set -euo pipefail
CHECK_ONLY=0
for a in "$@"; do
case "$a" in
--check|-c) CHECK_ONLY=1 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "未知参数: $a" >&2; exit 2 ;;
esac
done
# 需要的外部可执行文件(插件 browserDeps 同一份清单)
BINS=(Xvfb x11vnc websockify python3)
# chromium 的二进制名随发行版变化
CHROMIUM_CANDIDATES=(chromium chromium-browser google-chrome google-chrome-stable)
NOVNC_DIRS=(/usr/share/novnc /usr/share/webapps/novnc /opt/novnc)
missing=()
for b in "${BINS[@]}"; do
command -v "$b" >/dev/null 2>&1 || missing+=("$b")
done
chromium=""
for c in "${CHROMIUM_CANDIDATES[@]}"; do
if command -v "$c" >/dev/null 2>&1; then chromium="$c"; break; fi
done
[ -z "$chromium" ] && missing+=("chromium")
novnc=""
for d in "${NOVNC_DIRS[@]}"; do
[ -f "$d/vnc.html" ] && { novnc="$d"; break; }
done
[ -z "$novnc" ] && missing+=("novnc")
# CDP 取凭据需要 python websocket 客户端
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import websocket' >/dev/null 2>&1 || missing+=("python3-websocket")
fi
if [ ${#missing[@]} -eq 0 ]; then
echo "✓ 浏览器栈依赖齐全"
echo " chromium: ${chromium}"
echo " noVNC: ${novnc}"
exit 0
fi
echo "缺少依赖: ${missing[*]}"
if [ "$CHECK_ONLY" = 1 ]; then
exit 1
fi
# ---------- 识别包管理器 ----------
if command -v apt-get >/dev/null 2>&1; then
# Debian/Ubuntu:chromium 在部分版本是 snap 包,apt 装不上;
# 先试 apt,失败时明确提示替代方案,不静默放弃。
pkgs=(xvfb x11vnc websockify novnc python3-websocket)
echo "→ apt-get install ${pkgs[*]} chromium"
apt-get update -qq || true
apt-get install -y "${pkgs[@]}" || {
echo "WARN: 部分包装失败,仍尝试装 chromium" >&2
}
apt-get install -y chromium || apt-get install -y chromium-browser || {
echo "WARN: apt 装不上 chromium(Ubuntu 新版将其作为 snap 分发)。" >&2
echo " 可选:snap install chromium 或 apt-get install -y chromium-browser" >&2
}
elif command -v dnf >/dev/null 2>&1; then
echo "→ dnf install xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client"
dnf install -y xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client
elif command -v yum >/dev/null 2>&1; then
echo "→ yum install xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client"
yum install -y xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client
elif command -v apk >/dev/null 2>&1; then
echo "→ apk add xvfb x11vnc websockify novnc chromium py3-websocket-client"
apk add --no-cache xvfb x11vnc websockify novnc chromium py3-websocket-client
else
echo "未识别的包管理器。请手动安装:" >&2
echo " Xvfb, x11vnc, websockify, noVNC, chromium, python3 websocket-client" >&2
exit 1
fi
# ---------- 复核 ----------
echo
echo "复核安装结果:"
ok=1
for b in "${BINS[@]}"; do
if command -v "$b" >/dev/null 2>&1; then echo " ✓ $b"
else echo " ✗ $b"; ok=0; fi
done
chromium=""
for c in "${CHROMIUM_CANDIDATES[@]}"; do
if command -v "$c" >/dev/null 2>&1; then chromium="$c"; break; fi
done
[ -n "$chromium" ] && echo " ✓ $chromium" || { echo " ✗ chromium"; ok=0; }
novnc=""
for d in "${NOVNC_DIRS[@]}"; do
[ -f "$d/vnc.html" ] && { novnc="$d"; break; }
done
[ -n "$novnc" ] && echo " ✓ noVNC ($novnc)" || { echo " ✗ noVNC"; ok=0; }
python3 -c 'import websocket' >/dev/null 2>&1 && echo " ✓ python3 websocket-client" || { echo " ✗ python3 websocket-client"; ok=0; }
if [ "$ok" = 1 ]; then
echo
echo "✓ 浏览器栈就绪。重启 llmsproxy 后,flow=vnc 的平台即可用 VNC 兜底登录。"
else
echo
echo "WARN: 仍有缺失项,VNC 兜底在该机器上不可用。插件的 deps/status 端点会一样报告。" >&2
exit 1
fi

View File

@ -41,6 +41,11 @@ contents:
dst: /usr/share/llmsproxy/adapters/
file_info:
mode: 0644
# 服务端浏览器栈依赖安装脚本(VNC 兜底登录用;需 root,运维显式执行)
- src: ./packaging/install-browser-stack.sh
dst: /usr/share/llmsproxy/install-browser-stack.sh
file_info:
mode: 0755
# README pointer
- src: ./README.md
dst: /usr/share/doc/llmsproxy/README.md