mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-09 18:57:21 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 705e079285 | |||
| 8949405746 | |||
| d86514c474 | |||
| d4ae2a1d13 | |||
| 652a035d47 | |||
| 6bec260513 | |||
| ed954b77bd | |||
| cb3ea25586 |
47
deploy.sh
47
deploy.sh
@ -393,13 +393,17 @@ sync_adapters() {
|
|||||||
|
|
||||||
# 同步内置插件到 plugin_dir。
|
# 同步内置插件到 plugin_dir。
|
||||||
#
|
#
|
||||||
# 为什么需要它:新内置插件(如 channels.lua)过去只能靠内核的 SeedBundled 落地,
|
# 内置插件是**内核发行物的一部分**(go:embed 进二进制,随版本演进),与内置适配器
|
||||||
# 而旧版 SeedBundled "目录已存在就什么都不做",于是升级后新插件永远不会出现在
|
# 同性质。因此这里的纪律与 sync_adapters 完全一致:**覆盖内置同名文件**(带备份),
|
||||||
# 生产 —— 它在二进制里、却不在 /api/plugins。内核端已修(改为按文件补齐),
|
# 保留目录里运行时新增/运维自加的 .lua。
|
||||||
# 这里再显式同步一道,让部署与二进制内容对齐、不依赖内核那一侧的行为。
|
|
||||||
#
|
#
|
||||||
# 只补缺:已存在的插件文件一律不动。运维自己改过、或运行时新增的插件必须跨
|
# 早期实现是「只补缺,已存在一律不动」,看着安全,实际造成一个功能性缺口:
|
||||||
# 部署存活;覆盖它们会静默抹掉线上正在用的逻辑。
|
# 内置插件本身的**修复无法下发**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
|
||||||
|
# 全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版,
|
||||||
|
# 生产行为毫无变化。内置源码的正确性由仓库保证,部署必须与二进制内容对齐。
|
||||||
|
#
|
||||||
|
# 「只补缺」原本想保护的是「运维改过 / 运行时新增的插件」,但那些都是**非内置
|
||||||
|
# 名字**:内置名一律以发行物为准,其余名字一律不动。两者不是一回事。
|
||||||
sync_plugins() {
|
sync_plugins() {
|
||||||
log "同步内置插件"
|
log "同步内置插件"
|
||||||
|
|
||||||
@ -408,24 +412,35 @@ sync_plugins() {
|
|||||||
|
|
||||||
mkdir -p "$TARGET_PLUGINS"
|
mkdir -p "$TARGET_PLUGINS"
|
||||||
|
|
||||||
# 备份将被触及的文件(只会是本次新增的那些)
|
# 备份将被覆盖/新增的内置插件(非内置名字不进入循环,天然不动)
|
||||||
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
|
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
|
||||||
local added=0
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
local added=0 updated=0 same=0
|
||||||
for src in "$SRC_PLUGINS"/*.lua; do
|
for src in "$SRC_PLUGINS"/*.lua; do
|
||||||
[[ -f "$src" ]] || continue
|
[[ -f "$src" ]] || continue
|
||||||
local name; name="$(basename "$src")"
|
local name; name="$(basename "$src")"
|
||||||
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
|
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
|
fi
|
||||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
|
||||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
|
||||||
log " + 新增内置插件: $name"
|
|
||||||
((added++)) || true
|
|
||||||
done
|
done
|
||||||
if (( added == 0 )); then
|
# 没有任何改动时不必留空备份目录
|
||||||
log " 无新增内置插件(均已存在)"
|
if (( updated == 0 && added == 0 )); then
|
||||||
rmdir "$BACKUP_DIR" 2>/dev/null || true
|
rmdir "$BACKUP_DIR" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
log " 旧插件已备份到 $BACKUP_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
|
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
|
||||||
@ -434,7 +449,7 @@ sync_plugins() {
|
|||||||
local name; name="$(basename "$src")"
|
local name; name="$(basename "$src")"
|
||||||
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
|
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
|
||||||
done
|
done
|
||||||
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,已有文件未改动)"
|
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------- 重启服务 ----------
|
# ---------- 重启服务 ----------
|
||||||
|
|||||||
174
internal/core/channels_loomy_test.go
Normal file
174
internal/core/channels_loomy_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -27,6 +27,9 @@ func TestChannelsPluginEndToEnd(t *testing.T) {
|
|||||||
if err := ps.LoadSource("channels", src); err != nil {
|
if err := ps.LoadSource("channels", src); err != nil {
|
||||||
t.Fatalf("加载 channels: %v", err)
|
t.Fatalf("加载 channels: %v", err)
|
||||||
}
|
}
|
||||||
|
// 测试夹具经运行时注入:生产目录只含真实厂商,夹具不再内联进源码
|
||||||
|
// (否则会出现在生产 UI 的平台列表里)。
|
||||||
|
injectTestPlatforms(t, ps, examplePlatformFixture)
|
||||||
|
|
||||||
// 1. 平台清单可用(未授权任何东西也应能列出)
|
// 1. 平台清单可用(未授权任何东西也应能列出)
|
||||||
out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{
|
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)
|
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 应幂等成功")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"llmsproxy/internal/lua"
|
"llmsproxy/internal/lua"
|
||||||
|
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
181
internal/core/channels_qoder_test.go
Normal file
181
internal/core/channels_qoder_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
165
internal/core/channels_raccoon_test.go
Normal file
165
internal/core/channels_raccoon_test.go
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"llmsproxy/internal/lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
// raccoonHarness:Raccoon 内置二维码扫码登录的测试台。
|
||||||
|
//
|
||||||
|
// 参考语义(raccoon-oauth.ts 点名):
|
||||||
|
//
|
||||||
|
// ① qrcode_code **本地随机生成**(16 字节 hex),服务端接受任意自造 code
|
||||||
|
// ② 轮询 POST /login_with_qrcode_code {qrcode_code}
|
||||||
|
// → data.status ∈ pending / logging / canceled / success
|
||||||
|
// ③ success 但**缺 token** 视为未完成(否则产出空凭据并卡死)
|
||||||
|
// ④ **任何异常降级为 pending**(网络抖动/畸形响应/未知 status)
|
||||||
|
type raccoonHarness struct {
|
||||||
|
t *testing.T
|
||||||
|
ps *lua.Plugins
|
||||||
|
ts *httptest.Server
|
||||||
|
polls int32
|
||||||
|
step int32 // 第几次轮询开始 success
|
||||||
|
gotCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRaccoonHarness(t *testing.T) *raccoonHarness {
|
||||||
|
t.Helper()
|
||||||
|
h := &raccoonHarness{t: t, step: 3}
|
||||||
|
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") {
|
||||||
|
h.polls++
|
||||||
|
if h.polls < atomic.LoadInt32(&h.step) {
|
||||||
|
fmt.Fprint(w, `{"code":0,"data":{"status":"logging"}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `{"code":0,"data":{"status":"success","access_token":"RC-AT-1","refresh_token":"RC-RT-1"}}`)
|
||||||
|
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)
|
||||||
|
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||||
|
"platform": "raccoon",
|
||||||
|
"qr": map[string]interface{}{
|
||||||
|
"api_base": h.ts.URL,
|
||||||
|
"auth_prefix": "/api/web/auth/v1",
|
||||||
|
"poll_path": "/login_with_qrcode_code",
|
||||||
|
"login_page": "/login/mp",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("set_endpoints: %v", err)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *raccoonHarness) begin() (map[string]interface{}, string) {
|
||||||
|
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||||
|
Method: "POST", Body: `{"platform":"raccoon"}`, 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 *raccoonHarness) 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{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRaccoonQrPollBehavior 逐行锁定参考的扫码登录语义。
|
||||||
|
func TestRaccoonQrPollBehavior(t *testing.T) {
|
||||||
|
h := newRaccoonHarness(t)
|
||||||
|
|
||||||
|
t.Run("行1 本地生成code并给出二维码URL", func(t *testing.T) {
|
||||||
|
atomic.StoreInt32(&h.polls, 0)
|
||||||
|
j, _ := h.begin()
|
||||||
|
qr, _ := j["qr_url"].(string)
|
||||||
|
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "login/mp") {
|
||||||
|
t.Fatalf("应给出带 code 的二维码 URL, 实得 %q", qr)
|
||||||
|
}
|
||||||
|
// code 应是 32 位 hex(本地随机生成)
|
||||||
|
code := qr[strings.Index(qr, "code=")+5:]
|
||||||
|
if i := strings.IndexAny(code, "&"); i > 0 {
|
||||||
|
code = code[:i]
|
||||||
|
}
|
||||||
|
if len(code) != 32 {
|
||||||
|
t.Fatalf("qrcode_code 应是 32 位 hex, 实得 %q", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("行2 logging保持pending", func(t *testing.T) {
|
||||||
|
atomic.StoreInt32(&h.polls, 0)
|
||||||
|
atomic.StoreInt32(&h.step, 99)
|
||||||
|
_, sid := h.begin()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||||||
|
t.Fatalf("logging 应 pending, 实得 %v", st["state"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("行3 success且带token才完成", func(t *testing.T) {
|
||||||
|
atomic.StoreInt32(&h.polls, 0)
|
||||||
|
atomic.StoreInt32(&h.step, 2)
|
||||||
|
_, sid := h.begin()
|
||||||
|
h.pollOnce(sid)
|
||||||
|
st := h.pollOnce(sid)
|
||||||
|
if st["state"] != "completed" {
|
||||||
|
t.Fatalf("success+token 应 completed, 实得 %v", st)
|
||||||
|
}
|
||||||
|
v, ok, _ := h.ps.GetSecret("channels", "channels:raccoon")
|
||||||
|
if !ok || !strings.Contains(v, "RC-AT-1") {
|
||||||
|
t.Fatalf("凭据应含 RC-AT-1, 实得 %q", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("行4 canceled是终态失败", func(t *testing.T) {
|
||||||
|
hh := newRaccoonHarness(t)
|
||||||
|
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"code":0,"data":{"status":"canceled"}}`)
|
||||||
|
})
|
||||||
|
hh.ps.SetSecret("channels", "channels:raccoon", "")
|
||||||
|
_, sid := hh.begin()
|
||||||
|
if st := hh.pollOnce(sid); st["state"] != "failed" {
|
||||||
|
t.Fatalf("canceled 应 failed, 实得 %v", st["state"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("行5 success缺token视为未完成", func(t *testing.T) {
|
||||||
|
hh := newRaccoonHarness(t)
|
||||||
|
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"code":0,"data":{"status":"success"}}`)
|
||||||
|
})
|
||||||
|
_, sid := hh.begin()
|
||||||
|
st := hh.pollOnce(sid)
|
||||||
|
if st["state"] != "pending" {
|
||||||
|
t.Fatalf("★ 缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
222
internal/core/channels_store_test.go
Normal file
222
internal/core/channels_store_test.go
Normal 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 路径被占住时不应写出自有存储")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,6 +38,18 @@ func channelsCore(t *testing.T, src string) (*Core, *lua.Plugins) {
|
|||||||
return c, ps
|
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 登录。
|
// TestChannelsConcurrentLogins 压力:并发走 device-poll 登录。
|
||||||
//
|
//
|
||||||
// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、
|
// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、
|
||||||
@ -134,6 +146,7 @@ func TestChannelsClaimStress(t *testing.T) {
|
|||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
_, ps := channelsCore(t, mustChannelsSrc(t))
|
_, ps := channelsCore(t, mustChannelsSrc(t))
|
||||||
|
injectTestPlatforms(t, ps, cookieishPlatformFixture)
|
||||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||||
"platform": "cookieish",
|
"platform": "cookieish",
|
||||||
"checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"},
|
"checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"},
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
123
internal/core/channels_vnc_test.go
Normal file
123
internal/core/channels_vnc_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
188
internal/core/channels_zcode_test.go
Normal file
188
internal/core/channels_zcode_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -20,10 +20,19 @@ import (
|
|||||||
func kernelTestCore(t *testing.T) *Core {
|
func kernelTestCore(t *testing.T) *Core {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
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{
|
cfg := &config.Config{
|
||||||
Path: filepath.Join(dir, "config.yaml"),
|
Path: filepath.Join(dir, "config.yaml"),
|
||||||
AdapterDir: filepath.Join(dir, "adapters"),
|
AdapterDir: filepath.Join(dir, "adapters"),
|
||||||
PluginDir: filepath.Join(dir, "plugins"),
|
PluginDir: pluginDir,
|
||||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||||
DefaultModel: "AUTO",
|
DefaultModel: "AUTO",
|
||||||
GatewayKeys: []string{"sk-kernel-0001"},
|
GatewayKeys: []string{"sk-kernel-0001"},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1358,38 +1358,6 @@ func setupGlobals(L *golua.State) {
|
|||||||
L.PushString(base64.StdEncoding.EncodeToString([]byte(L.ToString(1))))
|
L.PushString(base64.StdEncoding.EncodeToString([]byte(L.ToString(1))))
|
||||||
return 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 {
|
registerFn(L, "tohex", func(L *golua.State) int {
|
||||||
L.PushString(hex.EncodeToString([]byte(L.ToString(1))))
|
L.PushString(hex.EncodeToString([]byte(L.ToString(1))))
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
123
packaging/install-browser-stack.sh
Executable file
123
packaging/install-browser-stack.sh
Executable 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
|
||||||
@ -41,6 +41,11 @@ contents:
|
|||||||
dst: /usr/share/llmsproxy/adapters/
|
dst: /usr/share/llmsproxy/adapters/
|
||||||
file_info:
|
file_info:
|
||||||
mode: 0644
|
mode: 0644
|
||||||
|
# 服务端浏览器栈依赖安装脚本(VNC 兜底登录用;需 root,运维显式执行)
|
||||||
|
- src: ./packaging/install-browser-stack.sh
|
||||||
|
dst: /usr/share/llmsproxy/install-browser-stack.sh
|
||||||
|
file_info:
|
||||||
|
mode: 0755
|
||||||
# README pointer
|
# README pointer
|
||||||
- src: ./README.md
|
- src: ./README.md
|
||||||
dst: /usr/share/doc/llmsproxy/README.md
|
dst: /usr/share/doc/llmsproxy/README.md
|
||||||
|
|||||||
Reference in New Issue
Block a user