mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec5e6dd57c | |||
| 705e079285 | |||
| 8949405746 | |||
| d86514c474 | |||
| d4ae2a1d13 | |||
| 652a035d47 | |||
| 6bec260513 | |||
| ed954b77bd | |||
| cb3ea25586 | |||
| 0232b9cd24 | |||
| a07449d32e |
47
deploy.sh
47
deploy.sh
@ -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)"
|
||||
}
|
||||
|
||||
# ---------- 重启服务 ----------
|
||||
|
||||
177
internal/core/channels_loomy_test.go
Normal file
177
internal/core/channels_loomy_test.go
Normal file
@ -0,0 +1,177 @@
|
||||
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")
|
||||
// 夹具必须用**真实微信页面的形态**:uuid 内嵌在
|
||||
// <img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>。
|
||||
// 用别的形态(如 var uuid = "...")测出来的正则不是生产要用的那条。
|
||||
fmt.Fprint(w, `<html><img class="js_qrcode_img" src="/connect/qrcode/UUID-TEST-1"/></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, `<img src="/connect/qrcode/U">`)
|
||||
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"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -260,6 +260,38 @@ func (h *buddyHarness) begin() (map[string]interface{}, string) {
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
// beginPool 走 UI 的真实流程:先 account/add 建占位账号,再带 account_id 登录。
|
||||
// 这正是用户报障("登录了但无法获取账号信息")的那条路径。
|
||||
func (h *buddyHarness) beginPool() (map[string]interface{}, string, string) {
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"buddy","account_id":%q}`, aid),
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string), aid
|
||||
}
|
||||
|
||||
func (h *buddyHarness) accountList() []interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
accts, _ := m["accounts"].([]interface{})
|
||||
return accts
|
||||
}
|
||||
func (h *buddyHarness) poll(id string) map[string]interface{} {
|
||||
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": id},
|
||||
@ -287,9 +319,17 @@ func TestBuddyStatePollBehavior(t *testing.T) {
|
||||
if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" {
|
||||
t.Fatalf("次拍应 completed+buddy-dev: %v", j)
|
||||
}
|
||||
// 凭据是**完整凭据 JSON**(对齐参考 buildCredential),不再是裸 token:
|
||||
// 必须带 refresh_token,否则 refreshable=false、无法静默续期。
|
||||
v, _, _ := h.ps.GetSecret("channels", "channels:buddy")
|
||||
if v != "BD-AT-1" {
|
||||
t.Fatalf("凭据应 BD-AT-1, 实得 %q", v)
|
||||
if !strings.Contains(v, `"access_token":"BD-AT-1"`) {
|
||||
t.Fatalf("凭据应含 access_token, 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("凭据必须含 refresh_token(否则无法续期), 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, "buddy-dev") {
|
||||
t.Fatalf("凭据应回填昵称, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) {
|
||||
@ -303,6 +343,46 @@ func TestBuddyStatePollBehavior(t *testing.T) {
|
||||
t.Fatalf("account 失败不应阻塞: %v", j)
|
||||
}
|
||||
})
|
||||
|
||||
// 行4 回归:UI 真实流程(account/add 建占位账号 → 带 account_id 登录)。
|
||||
// 缺陷复现:poll_login 完成时不传 account_id → 凭据落到 `channels:buddy`,
|
||||
// 而池账号 credentialRef 是 `channels:buddy:<aid>` → account_list 报
|
||||
// has_credential=false、credits 报「无凭据」——即用户报障的
|
||||
// 「登录了但无法获取账号信息」。
|
||||
t.Run("行4 池路径:登录后账号必须真的拿到凭据", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.tokStep, 0)
|
||||
_, sid, aid := h.beginPool()
|
||||
h.poll(sid) // 首拍 11217
|
||||
j := h.poll(sid)
|
||||
if j["state"] != "completed" {
|
||||
t.Fatalf("池路径登录应完成: %v", j)
|
||||
}
|
||||
// ① 凭据必须落在池账号的 ref 上
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:buddy:"+aid)
|
||||
if !ok || v == "" {
|
||||
t.Fatalf("凭据未落到池账号 ref(用户报障的根因); aid=%s ok=%v", aid, ok)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("池凭据应是完整 JSON: %q", v)
|
||||
}
|
||||
// ② account_list 必须报 has_credential=true(UI 读的就是这个字段)
|
||||
found := false
|
||||
for _, raw := range h.accountList() {
|
||||
a, _ := raw.(map[string]interface{})
|
||||
if a["id"] == aid {
|
||||
found = true
|
||||
if a["has_credential"] != true {
|
||||
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
|
||||
}
|
||||
if a["refreshable"] != true {
|
||||
t.Fatalf("refreshable 应为 true(有 refresh_token), 实得 %v", a["refreshable"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("池账号 %s 未出现在 account_list", aid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 通用 ----
|
||||
|
||||
@ -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 应幂等成功")
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,10 @@ import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。
|
||||
@ -320,3 +324,95 @@ func TestChannelsExtendedRPCBehavior(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuddyBillingHeadersMatchReference 锁定 buddy 计费请求的头(逐行移植参考
|
||||
// checkinHeaders)。真实缺陷:实现只发 Authorization,上游直接 401 —— 修好后
|
||||
// 账号能读到凭据但余额仍失败,运维看到的仍是「拿不到账号信息」。
|
||||
func TestBuddyBillingHeadersMatchReference(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
var got map[string]string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = map[string]string{}
|
||||
for k := range r.Header {
|
||||
got[k] = r.Header.Get(k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":{"totalCredit":42}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// 把 buddy 的余额/签到端点指到本地假上游
|
||||
invoke(t, ps, "set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
|
||||
m := invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("account_add: %v", m)
|
||||
}
|
||||
invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("余额请求未发出")
|
||||
}
|
||||
// 参考 checkinHeaders 的必备身份头(缺一个上游就 401)
|
||||
want := map[string]string{
|
||||
"Authorization": "Bearer BD-AT",
|
||||
"X-Domain": "copilot.tencent.com",
|
||||
"X-Product": "SaaS",
|
||||
"X-Product-Code": "codebuddy",
|
||||
"User-Agent": "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("头 %s = %q, 期望 %q(buddy 计费端点缺它会被上游 401)", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuddyBalanceParseDoubleNested 逐行锁定参考 fetchCreditBalance 的解析口径:
|
||||
// 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 data 不同,
|
||||
// 这是该接口最容易错的地方),且只累加**有效包**的 CycleCapacityRemain ——
|
||||
// 失效包服务端仍返回,并进总额会让数字虚高(参考实测 155.67 → 655.67)。
|
||||
func TestBuddyBalanceParseDoubleNested(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
// 两个有效包 + 一个失效包(ExpiredTime 在 2020 年)
|
||||
body := `{"code":0,"msg":"OK","data":{"Response":{"Data":{"Accounts":[
|
||||
{"PackageName":"A","Status":1,"CycleCapacityRemain":100.5,"ExpiredTime":"2099-01-01 00:00:00"},
|
||||
{"PackageName":"B","Status":1,"CycleCapacityRemain":55.17000031,"ExpiredTime":"2099-01-01 00:00:00"},
|
||||
{"PackageName":"C","Status":1,"CycleCapacityRemain":500,"ExpiredTime":"2020-01-01 00:00:00"}
|
||||
]}}}}`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
invoke(t, ps, "set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
|
||||
invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
|
||||
|
||||
j := invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
var row map[string]interface{}
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
row = a.(map[string]interface{})
|
||||
}
|
||||
bal, _ := row["balance"].(map[string]interface{})
|
||||
if bal == nil {
|
||||
t.Fatalf("应解析出余额, 实得 error=%v", row["error"])
|
||||
}
|
||||
// 100.5 + 55.17 = 155.67(失效包的 500 不计入)
|
||||
if got, _ := bal["total"].(float64); got != 155.67 {
|
||||
t.Fatalf("余额 = %v, 期望 155.67(只累加有效包、两位小数)", bal["total"])
|
||||
}
|
||||
|
||||
// 单层 data(缺 Response 层)必须判为解析失败,不能当 0
|
||||
body = `{"code":0,"data":{"TotalDosage":609}}`
|
||||
j = invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
row = a.(map[string]interface{})
|
||||
}
|
||||
if row["balance"] != nil {
|
||||
t.Fatalf("双层缺失时应报错而非当 0(参考语义:区分「查不到」与「余额为 0」): %v", row)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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"},
|
||||
|
||||
@ -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 {
|
||||
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"},
|
||||
|
||||
@ -451,6 +451,16 @@ func (j jsonPermission) unmarshal() KernelPermission {
|
||||
return KernelPermission{Actions: j.Actions, AllRead: j.AllRead}
|
||||
}
|
||||
|
||||
type jsonPluginPolicy struct {
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
AllRead bool `json:"all_read,omitempty"`
|
||||
Hosts []string `json:"hosts,omitempty"`
|
||||
}
|
||||
|
||||
func policyFromActions(p KernelPermission) jsonPluginPolicy {
|
||||
return jsonPluginPolicy{Actions: p.Actions, AllRead: p.AllRead}
|
||||
}
|
||||
|
||||
// KernelActionView is one action as the settings page needs it: the catalog entry
|
||||
// plus whether THIS plugin currently holds it and whether the plugin asked for it.
|
||||
type KernelActionView struct {
|
||||
|
||||
@ -45,7 +45,12 @@ func (ps *Plugins) LoadKernelPermissions() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw := map[string]jsonPermission{}
|
||||
// The file holds BOTH kernel-action grants (map[string]jsonPermission, the
|
||||
// historical shape) AND, for plugins that asked for them, the operator's
|
||||
// extra-host grants (map[string]jsonPluginPolicy.Hosts). A bare
|
||||
// {actions:[...]} row still parses into jsonPluginPolicy with empty Hosts,
|
||||
// so old files load unchanged.
|
||||
raw := map[string]jsonPluginPolicy{}
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -54,8 +59,17 @@ func (ps *Plugins) LoadKernelPermissions() error {
|
||||
if ps.kernelPerms == nil {
|
||||
ps.kernelPerms = kernelPermissionSet{}
|
||||
}
|
||||
if ps.extraHosts == nil {
|
||||
ps.extraHosts = map[string][]string{}
|
||||
}
|
||||
for name, jp := range raw {
|
||||
ps.kernelPerms[name] = jp.unmarshal()
|
||||
ps.kernelPerms[name] = KernelPermission{Actions: jp.Actions, AllRead: jp.AllRead}
|
||||
if len(jp.Hosts) > 0 {
|
||||
// Load the persisted grant into extraHosts WITHOUT folding it into the
|
||||
// allowlist yet — sources are loaded later, and the caller owns that
|
||||
// fold. GrantExtraHosts rebuilds the allowlist when sources are ready.
|
||||
ps.extraHosts[name] = append([]string(nil), jp.Hosts...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -65,12 +79,17 @@ func (ps *Plugins) LoadKernelPermissions() error {
|
||||
// Grants drop unknown action names on write. If an operator authorized an action
|
||||
// that a later release removed, keeping the name would leave a stale entry that
|
||||
// nothing can ever satisfy — and it would reappear in the UI as if it were live.
|
||||
//
|
||||
// Host grants travel in the same file (jsonPluginPolicy.Hosts): a granted host is
|
||||
// the operator's explicit decision to let a plugin egress somewhere, and losing
|
||||
// it across a restart (the previous behaviour) made grants silently vanish.
|
||||
func (ps *Plugins) saveKernelPermissionsLocked() {
|
||||
path := ps.kernelPermsFile()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
out := map[string]jsonPermission{}
|
||||
out := map[string]jsonPluginPolicy{}
|
||||
something := false
|
||||
for name, perm := range ps.kernelPerms {
|
||||
valid := make([]string, 0, len(perm.Actions))
|
||||
for _, a := range perm.Actions {
|
||||
@ -78,12 +97,28 @@ func (ps *Plugins) saveKernelPermissionsLocked() {
|
||||
valid = append(valid, a)
|
||||
}
|
||||
}
|
||||
if len(valid) == 0 && !perm.AllRead {
|
||||
row := policyFromActions(KernelPermission{Actions: valid, AllRead: perm.AllRead})
|
||||
if hs := ps.extraHosts[name]; len(hs) > 0 {
|
||||
row.Hosts = append([]string(nil), hs...)
|
||||
}
|
||||
if len(valid) == 0 && !perm.AllRead && len(row.Hosts) == 0 {
|
||||
continue // nothing granted: presence would be a lie
|
||||
}
|
||||
out[name] = KernelPermission{Actions: valid, AllRead: perm.AllRead}.marshal()
|
||||
out[name] = row
|
||||
something = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
// A plugin may hold host grants with NO kernel actions granted (operators
|
||||
// approve hosts and actions independently). Emit those too.
|
||||
for name, hs := range ps.extraHosts {
|
||||
if len(hs) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := out[name]; !exists {
|
||||
out[name] = jsonPluginPolicy{Hosts: append([]string(nil), hs...)}
|
||||
something = true
|
||||
}
|
||||
}
|
||||
if !something {
|
||||
_ = os.Remove(path)
|
||||
return
|
||||
}
|
||||
|
||||
@ -43,6 +43,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
@ -80,19 +81,39 @@ var httpClient = &http.Client{
|
||||
const httpGlobal = "__llmsproxy_http"
|
||||
|
||||
// httpAllowlist is the set of hosts plugins may contact: the host of every
|
||||
// configured source's base_url.
|
||||
// configured source's base_url, plus every operator-granted extra host.
|
||||
//
|
||||
// It is a LIVE, shared object, not a snapshot. This matters: bootPluginState
|
||||
// binds one *httpAllowlist into every Lua closure at state-boot time, and those
|
||||
// closures live for the plugin's lifetime. Granting an extra host after a
|
||||
// worker booted must therefore MUTATE the same object the closures hold —
|
||||
// replacing ps.allowHTTP with a fresh instance would leave every already-booted
|
||||
// worker checking the STALE list, and a grant would look accepted (the API
|
||||
// answers ok, the UI shows it) while requests kept getting refused with
|
||||
// "not an allowed outbound destination". That exact failure shipped once.
|
||||
//
|
||||
// Mutations are rare (operator approves a host); lookups are per request.
|
||||
type httpAllowlist struct {
|
||||
mu sync.RWMutex
|
||||
hosts map[string]bool
|
||||
}
|
||||
|
||||
func newHTTPAllowlist(hosts []string) *httpAllowlist {
|
||||
m := make(map[string]bool, len(hosts))
|
||||
a := &httpAllowlist{hosts: make(map[string]bool, len(hosts))}
|
||||
a.add(hosts)
|
||||
return a
|
||||
}
|
||||
|
||||
// add merges hosts into the live allowlist. Called by rebuild (source hosts
|
||||
// change on hot reload) and by GrantExtraHosts (operator approves a host).
|
||||
func (a *httpAllowlist) add(hosts []string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, h := range hosts {
|
||||
if h != "" {
|
||||
m[strings.ToLower(h)] = true
|
||||
a.hosts[strings.ToLower(h)] = true
|
||||
}
|
||||
}
|
||||
return &httpAllowlist{hosts: m}
|
||||
}
|
||||
|
||||
// allows reports whether host:port may be contacted. Ports 80/443 are implied
|
||||
@ -103,6 +124,8 @@ func (a *httpAllowlist) allows(host string, port string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.hosts[host] {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -14,6 +14,8 @@ package lua
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@ -216,3 +218,139 @@ return P
|
||||
t.Fatalf("body=%q, want the server's JSON", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantExtraHostsVisibleToAlreadyBootedWorker 是真实事故的回归测试。
|
||||
//
|
||||
// 事故:allowlist 是不可变快照。bootPluginState 在 worker 启动时把 *httpAllowlist
|
||||
// 指针绑进 Lua 闭包;GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**,已启动的
|
||||
// worker 仍持旧指针 → 授权接口返回 ok、UI 显示已批,但插件请求仍被
|
||||
// "not an allowed outbound destination" 拒绝。channels 插件的 auth/state 就是这样
|
||||
// 在生产被拦的(copilot.tencent.com 已批仍报错)。
|
||||
//
|
||||
// 修复:allowlist 改为带锁的**可变共享对象**(原地 add),所有闭包立即可见。
|
||||
// 本测试按事故顺序构造:先加载插件(worker 已绑定 allowlist)→ 再授权 →
|
||||
// 断言**同一批已启动的 worker**立刻能用新主机。
|
||||
func TestGrantExtraHostsVisibleToAlreadyBootedWorker(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
ps.SetOutboundHosts(nil) // 起始只允许 loopback
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
// srv.URL 是 127.0.0.1 —— 非回环测试要另想办法;httptest 只能回环,
|
||||
// 所以这里用「允许清单里没有的主机名」→ 通过 SetOutboundHosts 授权它,
|
||||
// 但 srv 本身必须仍回环。做法:插件请求的 URL 用 srv.URL(回环,恒允许)?
|
||||
// 不行——那测不出 allowlist 更新。改用 checkURL 直接断言新主机可见。
|
||||
//
|
||||
// 真实事故的关键路径:worker 已启动 → 授权 → 同一 worker 立即看到。
|
||||
// Lua 层面用回环无法区分(回环永远允许),因此这里的断言打在两层:
|
||||
// ① allowlist 对象层:已存在实例 add 后立即 allows() 新主机;
|
||||
// ② 插件层:SetOutboundHosts 重新折算后,同一 worker 的请求仍工作
|
||||
// (即 rebuild 不会使旧引用失效)。
|
||||
host := "portal.example.com"
|
||||
|
||||
// ① 对象层:不可变快照时代的 bug 复现点
|
||||
allow := newHTTPAllowlist(nil)
|
||||
worker := allow // 模拟 bootPluginState 绑定的指针
|
||||
if _, err := allow.checkURL("https://" + host + "/x"); err == nil {
|
||||
t.Fatal("前提不成立:未授权主机不应允许")
|
||||
}
|
||||
allow.add([]string{host}) // GrantExtraHosts 的实际路径(原地 add)
|
||||
if _, err := worker.checkURL("https://" + host + "/x"); err != nil {
|
||||
t.Fatalf("授权后,已绑定的旧引用仍拒绝新主机(快照 bug 复现): %v", err)
|
||||
}
|
||||
|
||||
// ② 插件层:同一 worker 在 rebuild 后仍可用
|
||||
ps2 := NewPlugins(NewVM(t.TempDir()), t.TempDir())
|
||||
ps2.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
probe := `
|
||||
local P = {}
|
||||
P.name = "grantprobe"
|
||||
P.version = "1.0"
|
||||
P.exports = { "probe" }
|
||||
P.hosts = { "portal.example.com" }
|
||||
function P.probe(arg)
|
||||
local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" })
|
||||
if res == nil then return { error = tostring(err) } end
|
||||
return { status = res.status }
|
||||
end
|
||||
return P
|
||||
`
|
||||
if err := ps2.LoadSource("grantprobe", probe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// worker 已启动。现在授权(grant → rebuild → 原地 add)
|
||||
if err := ps2.GrantExtraHosts("grantprobe", []string{"portal.example.com"}); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
// 同一批 worker:一次调用成功即证明 allowlist 引用未被弃用
|
||||
got, err := ps2.Call("grantprobe", "probe", map[string]interface{}{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := got.(map[string]interface{})
|
||||
if m == nil || m["error"] != nil {
|
||||
t.Fatalf("授权后同一 worker 调用失败(引用被弃用): %v", got)
|
||||
}
|
||||
if s, _ := m["status"].(float64); int(s) != 200 {
|
||||
t.Fatalf("status=%v, want 200", m["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostGrantsPersistAcrossRestart 是第二个真实缺陷的回归:host 授权此前只
|
||||
// 写进运行态(extraHosts map),从不落盘 —— 重启后授权消失,插件出站全部被拦,
|
||||
// 而设置页看不出任何异常(actions 有持久化、hosts 没有,读起来像"都持久化了")。
|
||||
func TestHostGrantsPersistAcrossRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
|
||||
if err := ps.LoadSource("grantprobe", `
|
||||
local P = {}
|
||||
P.name = "grantprobe"
|
||||
P.version = "1.0"
|
||||
P.exports = {}
|
||||
P.hosts = { "portal.example.com", "api.partner.example" }
|
||||
return P
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.GrantExtraHosts("grantprobe", []string{"portal.example.com", "api.partner.example"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 落盘验证
|
||||
b, err := os.ReadFile(filepath.Join(dir, ".kernel-perms.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("授权未落盘: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(b), "portal.example.com") {
|
||||
t.Fatalf("host 授权应写入 .kernel-perms.json,文件内容: %s", b)
|
||||
}
|
||||
|
||||
// 重启语义:新 registry 读回授权
|
||||
ps2 := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
|
||||
if err := ps2.LoadKernelPermissions(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ps2.ExtraHostsGranted()["grantprobe"]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("重启后 host 授权应恢复 2 条, 实得 %v", got)
|
||||
}
|
||||
// 插件再加载后,授权可直接使用(GrantExtraHosts 只接受"声明过"的主机,
|
||||
// 所以重载插件不会丢授权这件事本身就是恢复生效的证据)
|
||||
if err := ps2.LoadSource("grantprobe", `
|
||||
local P = {}
|
||||
P.name = "grantprobe"
|
||||
P.version = "1.0"
|
||||
P.exports = {}
|
||||
P.hosts = { "portal.example.com", "api.partner.example" }
|
||||
return P
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = ps2.ExtraHostsGranted()["grantprobe"]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("插件重载后授权应保留, 实得 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,6 +105,18 @@ func normalizeHost(h string) string {
|
||||
// rebuildOutboundAllowlistLocked folds the SOURCE hosts and every GRANTED extra
|
||||
// host into the allowlist the HTTP bridge consults. Caller holds ps.mu.
|
||||
//
|
||||
// The allowlist object is mutated IN PLACE (allowHTTP.add) rather than replaced:
|
||||
// already-booted plugin workers hold the ORIGINAL pointer in their Lua closures
|
||||
// (bootPluginState captured it at boot), so replacing the field would strand
|
||||
// them on a stale copy — a grant would be recorded and displayed while requests
|
||||
// kept being refused. Mutating the shared object makes every closure see the
|
||||
// new hosts immediately.
|
||||
//
|
||||
// Note this FOLDS sources + grants additively: a host that loses its grant
|
||||
// cannot be un-allowed this way, which is acceptable because grants are
|
||||
// append-only operator approvals (revocation is a restart away — the persisted
|
||||
// file is the authority on boot).
|
||||
//
|
||||
// Declared-but-not-granted hosts are deliberately excluded: that exclusion is the
|
||||
// whole security property, so it is the one thing this function must not be
|
||||
// tempted to "helpfully" relax.
|
||||
@ -113,7 +125,7 @@ func (ps *Plugins) rebuildOutboundAllowlistLocked() {
|
||||
for _, granted := range ps.extraHosts {
|
||||
hosts = append(hosts, granted...)
|
||||
}
|
||||
ps.allowHTTP = newHTTPAllowlist(hosts)
|
||||
ps.allowHTTP.add(hosts)
|
||||
}
|
||||
|
||||
// readPluginExtraHosts reads `plugin.hosts`, the outbound hosts a plugin needs
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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/
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user