diff --git a/internal/core/channels_plugin_test.go b/internal/core/channels_plugin_test.go index e217e21..7c06181 100644 --- a/internal/core/channels_plugin_test.go +++ b/internal/core/channels_plugin_test.go @@ -27,6 +27,9 @@ func TestChannelsPluginEndToEnd(t *testing.T) { if err := ps.LoadSource("channels", src); err != nil { t.Fatalf("加载 channels: %v", err) } + // 测试夹具经运行时注入:生产目录只含真实厂商,夹具不再内联进源码 + // (否则会出现在生产 UI 的平台列表里)。 + injectTestPlatforms(t, ps, examplePlatformFixture) // 1. 平台清单可用(未授权任何东西也应能列出) out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{ @@ -239,3 +242,101 @@ func stateHasSecret(ps *lua.Plugins, plugin, secret string) bool { } return contains(string(b), secret) } + +// examplePlatformFixture 是端到端测试用的占位平台(对应此前内联在生产源码里 +// 的 "example" 平台)。它经 inject_platforms 注入,且**只在测试进程内存在** —— +// 生产插件的 PLATFORMS 只含真实厂商。 +const examplePlatformFixture = `[ + { + "id": "example", + "name": "Example 平台(测试夹具)", + "flow": "link-poll", + "login_url": "https://auth.example.com/device", + "base_url": "https://api.example.com/v1", + "adapter": "openai", + "models": ["example-chat", "example-reason"], + "checkin": {"url": "https://api.example.com/v1/api/daily/claim", "method": "POST", "body": "{}"} + } +]` + +// injectTestPlatforms 把测试夹具注入插件(运行时,不落盘)。 +func injectTestPlatforms(t *testing.T, ps *lua.Plugins, fixtureJSON string) { + t.Helper() + out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{ + Method: "POST", Body: `{"platforms":` + fixtureJSON + `}`, Role: "admin", + }) + if err != nil { + t.Fatalf("inject_platforms: %v", err) + } + m, _ := out.JSON.(map[string]interface{}) + if m["ok"] != true { + t.Fatalf("注入测试平台失败: %v", m) + } +} + +// TestInjectPlatformsRejectsBadInput 锁定注入机制的三条边界: +// 1. 不得覆盖内建平台(注入不能劫持真实厂商) +// 2. 重复 id 拒绝 +// 3. 空数组 = 清空注入(夹具不跨测试残留) +// +// 这个机制存在的理由:夹具内联进生产源码会让它们出现在生产 UI 的平台列表里 +// (真实踩过 —— 16 个平台里有 4 个是夹具)。 +func TestInjectPlatformsRejectsBadInput(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := lua.ReadBundledPlugin("channels") + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + + call := func(body string) map[string]interface{} { + out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{ + Method: "POST", Body: body, Role: "admin", + }) + if err != nil { + t.Fatalf("inject_platforms(%s): %v", body, err) + } + return out.JSON.(map[string]interface{}) + } + platforms := func() []interface{} { + out, _ := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{Method: "GET", Role: "admin"}) + return out.JSON.(map[string]interface{})["platforms"].([]interface{}) + } + + base := len(platforms()) + if base == 0 { + t.Fatal("生产目录应有真实厂商平台") + } + + // 注入一个合法夹具 → 列表 +1 + if m := call(`{"platforms":[{"id":"fixture-a","name":"A","flow":"paste","base_url":"https://a.example/v1"}]}`); m["ok"] != true { + t.Fatalf("合法注入应成功: %v", m) + } + if got := len(platforms()); got != base+1 { + t.Fatalf("注入后平台数 = %d, 期望 %d", got, base+1) + } + + // 1. 覆盖内建平台 → 拒绝 + if m := call(`{"platforms":[{"id":"buddy","name":"劫持"}]}`); m["error"] == nil { + t.Fatal("覆盖内建平台必须被拒绝") + } + // 2. 重复 id → 拒绝 + if m := call(`{"platforms":[{"id":"fixture-b"},{"id":"fixture-b"}]}`); m["error"] == nil { + t.Fatal("重复 id 必须被拒绝") + } + // 无 id → 拒绝 + if m := call(`{"platforms":[{"name":"no-id"}]}`); m["error"] == nil { + t.Fatal("缺 id 必须被拒绝") + } + + // 3. 空数组 = 清空注入,回到基线 + if m := call(`{"platforms":[]}`); m["ok"] != true { + t.Fatalf("清空应成功: %v", m) + } + if got := len(platforms()); got != base { + t.Fatalf("清空后平台数 = %d, 期望回到基线 %d", got, base) + } +} diff --git a/internal/core/channels_stress_test.go b/internal/core/channels_stress_test.go index 040a6d2..8f32ab6 100644 --- a/internal/core/channels_stress_test.go +++ b/internal/core/channels_stress_test.go @@ -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"}, diff --git a/internal/core/channels_trae_test.go b/internal/core/channels_trae_test.go index 9666391..c27f687 100644 --- a/internal/core/channels_trae_test.go +++ b/internal/core/channels_trae_test.go @@ -171,3 +171,67 @@ func TestTraeCallbackBehavior(t *testing.T) { } }) } + +// TestTraeCallbackPoolPath 回归:callback 流的**池路径**(UI 真实流程)。 +// +// 与 buddy poll_login 的池路径同一缺陷:complete_login 不带 account_id 时, +// 凭据落到与占位账号无关的 `channels:` 键,池账号永远 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) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 5446c94..32aa4f4 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -77,28 +77,12 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda -- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua -- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。 local PLATFORMS = { - -- ===== 测试/示范用占位平台(example.com,不打真实厂商)===== - { - id = "example", - name = "Example 平台", - flow = "link-poll", - login_url = "https://auth.example.com/device", - base_url = "https://api.example.com/v1", - adapter = "openai", - models = { "example-chat", "example-reason" }, - checkin = { url = "https://api.example.com/v1/api/daily/claim", method = "POST", body = "{}" }, - }, - { - id = "googleish", - name = "Google 系(示范 callback)", - flow = "callback", - login_url = "https://accounts.google.com/o/oauth2/auth", - base_url = "https://api.googleish.example/v1", - adapter = "openai", - models = { "g-model-1" }, - }, - -- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)===== + -- + -- ⚠️ 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的 + -- 占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接 + -- 展示给运维,夹具写进来就是「生产 UI 里凭空多出 4 个假平台」。 + -- 夹具由测试经 channels/inject_platforms 端点运行时注入。 -- TRAE(字节跳动)—— callback 登录 + ExchangeToken 续期 + 每日签到。 -- 这是最标准的一条,作为"同类厂商"的实现模板。 @@ -386,27 +370,6 @@ local PLATFORMS = { }, }, - -- 服务端浏览器兜底示范(回环重定向类)。真实厂商若需要可改 flow="vnc"。 - { - id = "loopbackish", - name = "回环登录平台(示范 VNC 兜底)", - flow = "vnc", - needs_browser = true, - login_url = "https://auth.loopbackish.example/authorize?redirect_uri=http://127.0.0.1:%d/callback", - base_url = "https://api.loopbackish.example/v1", - adapter = "openai", - models = { "lb-model-1" }, - }, - { - id = "cookieish", - name = "Cookie 平台(示范 paste)", - flow = "paste", - base_url = "https://api.cookieish.example/v1", - adapter = "openai", - models = { "c-model-1" }, - checkin = { url = "https://api.cookieish.example/v1/api/credit/claim", method = "POST", body = "{}" }, - }, - -- Gemini(Google Cloud Code)—— 标准 OAuth 授权码流 + token 交换。 -- 照参考 gemini-oauth.ts:授权页 client_id/redirect_uri/scope 齐全,回环回调 -- 拿到 code 后 POST oauth2.googleapis.com/token 换 token(带 code_verifier)。 @@ -804,14 +767,39 @@ local function deviceRegister(plat, payload) return access, acct, nil end --- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表) +-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)。 +-- +-- 先查内建目录,再查运行时注入表(plugin.state.injected_platforms)。注入表的存在 +-- 是因为**测试夹具绝不能写成生产源码**:此前 example/googleish/loopbackish/ +-- cookieish 四个夹具直接内联在 PLATFORMS 里,于是它们真的出现在生产 UI(16 个 +-- 平台里有 4 个是夹具)供运维选择。夹具属于测试,不属于发行物 —— 正确形态是 +-- 测试用自己的那几行注入(见 channels/test_platforms 端点),生产目录只留真实厂商。 local function platformById(id) for _, p in ipairs(PLATFORMS) do if p.id == id then return p end end + local injected = plugin.state and plugin.state.injected_platforms + if type(injected) == "table" then + for _, p in ipairs(injected) do + if type(p) == "table" and p.id == id then return p end + end + end return nil end +-- allPlatforms 返回内建目录 + 注入表(列表/遍历用)。 +local function allPlatforms() + local out = {} + for _, p in ipairs(PLATFORMS) do out[#out + 1] = p end + local injected = plugin.state and plugin.state.injected_platforms + if type(injected) == "table" then + for _, p in ipairs(injected) do + if type(p) == "table" and p.id then out[#out + 1] = p end + end + end + return out +end + -- ============================================================================ -- 状态 -- ============================================================================ @@ -1631,7 +1619,7 @@ function plugin.auto_claim() -- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。 plugin.state.settings = plugin.state.settings or {} if plugin.state.settings.auto_checkin == false then return end - for _, plat in ipairs(PLATFORMS) do + for _, plat in ipairs(allPlatforms()) do local cap = CHANNEL_CREDITS[plat.id] if not cap or not cap.claim then goto continue end local legacy = accountOf(plat.id) @@ -1734,6 +1722,8 @@ plugin.http = { roles = { "admin" }, description = "新人任务状态" }, { path = "channels/onboarding/claim", method = "POST", handler = "onboarding_claim", roles = { "admin" }, description = "领取新人奖励" }, + { path = "channels/inject_platforms", method = "POST", handler = "inject_platforms", + roles = { "admin" }, description = "注入测试/扩展平台定义(运行时,不落盘)" }, } -- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、 @@ -1934,7 +1924,7 @@ end -- ============================================================================ function plugin.provider_status(req) local out = {} - for _, p in ipairs(PLATFORMS) do + for _, p in ipairs(allPlatforms()) do local accts = poolList(p.id) local enabledN = 0 for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end @@ -2024,7 +2014,7 @@ function plugin.backup_export(req) payload.credentials = {} payload.modelDisabled = plugin.state.modelDisabled or {} payload.autoCheckin = plugin.state.settings and plugin.state.settings.auto_checkin or true - for _, p in ipairs(PLATFORMS) do + for _, p in ipairs(allPlatforms()) do for _, a in ipairs(poolList(p.id)) do payload.accounts[#payload.accounts+1] = a local tok = credentialOf(a.credentialRef) @@ -2074,7 +2064,7 @@ function plugin.backup_import(req) plugin.state.settings.auto_checkin = payload.autoCheckin and true or false end -- 全部账号重新同步源 - for _, p in ipairs(PLATFORMS) do + for _, p in ipairs(allPlatforms()) do for _, a in ipairs(poolList(p.id)) do syncAccountSource(p.id, a.id) end end logEvent("备份已导入,恢复 " .. n .. " 条凭据") @@ -2296,7 +2286,7 @@ end -- 承担,这里改为读取当前接入状态做查询、写入复用 provider_set_enabled。 function plugin.gateway_get_enabled(req) local any = false - for _, p in ipairs(PLATFORMS) do + for _, p in ipairs(allPlatforms()) do local disabled = disabledModelsOf(p.id) local total = #(p.models or {}) local allOff = total > 0 @@ -2313,7 +2303,7 @@ function plugin.gateway_set_enabled(req) return { status = 400, json = { error = "非空布尔 enabled 必填" } } end -- 全平台整体开关(复用 provider_set_enabled 语义) - for _, p in ipairs(PLATFORMS) do + for _, p in ipairs(allPlatforms()) do plugin.provider_set_enabled({ platform = p.id, enabled = args.enabled }) end logEvent("网关整体" .. (args.enabled and "开启" or "关闭")) @@ -2323,7 +2313,7 @@ end -- backup.status:备份健康度(对齐参考 backup.status:账号数/无过期时间账号数) function plugin.backup_status(req) local total, noExpiry = 0, 0 - for _, p in ipairs(PLATFORMS) do + for _, p in ipairs(allPlatforms()) do for _, a in ipairs(poolList(p.id)) do total = total + 1 if a.expiresAt == nil then noExpiry = noExpiry + 1 end @@ -2510,7 +2500,7 @@ end function plugin.list_platforms(req) local out = {} - for _, p in ipairs(PLATFORMS) do + for _, p in ipairs(allPlatforms()) do local acct = accountOf(p.id) or {} local has = credentialOf("channels:" .. p.id) out[#out + 1] = { @@ -2524,6 +2514,55 @@ function plugin.list_platforms(req) return { status = 200, json = { platforms = out } } end +-- inject_platforms:**运行时**注入平台定义(测试夹具 / 运维扩展)。 +-- +-- 为何存在:测试夹具(打 example.com 的假平台、验证各 flow 的占位平台) +-- 绝不能写成生产源码 —— PLATFORMS 会被 UI/status/list_platforms 直接展示, +-- 夹具内联进去就是「生产 UI 里实际出现 4 个假平台」(真实踩过)。 +-- 正确分层:生产目录只留真实厂商,夹具由测试运行时注入。 +-- +-- 语义:整表替换(与 set_endpoints 的按字段覆盖不同)—— 注入是声明式的 +-- “这就是本轮我要用的目录”,避免测试之间残留状态。传空数组即清空。 +-- 容量上限:注入属于运维/测试能力,不承担生产目录职责,限 32 条够用。 +local INJECT_PLATFORM_LIMIT = 32 +function plugin.inject_platforms(req) + local args = bodyOf(req) + if args.platforms == nil then args = req end + local list = args and (args.platforms or args.items) + if type(list) ~= "table" then + return { status = 400, json = { error = "platforms 必须是数组" } } + end + if #list > INJECT_PLATFORM_LIMIT then + return { status = 400, json = { error = "最多注入 " .. INJECT_PLATFORM_LIMIT .. " 个平台" } } + end + -- 校验:每条必须有 id,且不得与内建目录重名(注入不能劫持真实厂商)。 + local builtin = {} + for _, p in ipairs(PLATFORMS) do builtin[p.id] = true end + local clean, seen = {}, {} + for _, p in ipairs(list) do + if type(p) ~= "table" or type(p.id) ~= "string" or p.id == "" then + return { status = 400, json = { error = "每个平台必须有 id" } } + end + if builtin[p.id] then + return { status = 400, json = { error = "不得覆盖内建平台: " .. p.id } } + end + if seen[p.id] then + return { status = 400, json = { error = "重复的 id: " .. p.id } } + end + seen[p.id] = true + clean[#clean + 1] = p + end + plugin.state.injected_platforms = clean + if #clean == 0 then + logEvent("已清空注入平台") + else + local ids = {} + for _, p in ipairs(clean) do ids[#ids + 1] = p.id end + logEvent("已注入平台 " .. #clean .. " 个: " .. table.concat(ids, ",")) + end + return { status = 200, json = { ok = true, injected = #clean } } +end + function plugin.status_page(req) local r = __llmsproxy_kernel.call("status", {}) return { status = 200, json = { @@ -2605,7 +2644,12 @@ function plugin.oauth_callback(req) return { status = 400, body = cerr } end - local res = plugin.complete_login({ platform = s.platform, credential = credential, account = account }) + -- 与 poll_login 的两条完成分支同一纪律:页面流程先建了占位账号 + -- (credentialRef=channels::),完成时必须带 account_id, + -- 否则凭据落到与占位账号无关的 `channels:` 键 → 池账号永远 + -- has_credential=false(同一缺陷在 callback 路径的漏网)。 + local res = plugin.complete_login({ platform = s.platform, credential = credential, + account = account, account_id = s.account_id }) if res.json and res.json.error then return { status = 400, body = res.json.error } end