mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1be34557dd |
169
internal/core/channels_trae_pkce_test.go
Normal file
169
internal/core/channels_trae_pkce_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 回调只带 code(PKCE 形态)时必须**明确报出**「暂不支持 PKCE」,
|
||||
// 而不是去调 ExchangeToken 换一个不存在的码。
|
||||
//
|
||||
// 真实缺陷(用户报「登录后显示失败」):
|
||||
// poll_login 的 trae 分支曾自己发明了一段「授权码交换」——把 {code} 塞进
|
||||
// exchange_body 再打 ExchangeToken。但:
|
||||
//
|
||||
// · ExchangeToken 的签名是 RefreshToken,不是 code;
|
||||
// · exchange_body 里根本没有 {code} 占位符,那次替换是空操作;
|
||||
// · client_id 当时是空串。
|
||||
//
|
||||
// 于是稳定得到 400,日志回一句误导性的「Trae 授权码交换失败: 400」,
|
||||
// 把真因「上游走了 PKCE 流程」掩盖成「交换出错了」——排查方向完全错。
|
||||
//
|
||||
// 参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法是:带 code 的回调
|
||||
// 是**合法回调**,只是不支持的 PKCE 分支,必须明确报出来。
|
||||
func TestTraePkceCallbackIsReportedNotExchanged(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// ★ 必须打**真实登录用的那条路径**:poll_login(loopback 回调文件)。
|
||||
// oauth_callback 那条一直是对的,判据打在那里会漏掉缺陷 —— 我第一版就
|
||||
// 犯了这个错(变异测试揭穿:注入缺陷后那条判据仍绿)。
|
||||
//
|
||||
// poll_login 从 plugin.state.loopbacks[sessionId].file 读 {code,state},
|
||||
// 故判据直接摆好那个文件再调 poll_login。
|
||||
status, body := h.beginAndPollWithCallback(map[string]string{
|
||||
"code": "PKCE_AUTH_CODE_XYZ",
|
||||
})
|
||||
|
||||
// poll_login 的契约:终态用 HTTP 200 + json.state 表达(不是非 200 状态码)。
|
||||
// 所以「失败」的判据是 state=failed,而不是 status != 200 —— 我第一版把
|
||||
// 前者写成了后者,于是把正确的实现判成错。
|
||||
if !strings.Contains(body, "state:failed") {
|
||||
t.Fatalf("PKCE 回调应落定为 failed(poll_login 用 200+state 表达终态):status=%d body=%s",
|
||||
status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
|
||||
t.Fatalf("不得去调 ExchangeToken —— 那是拿 code 换一个不存在的码(真实缺陷)。"+
|
||||
"实际打了 %d 次", n)
|
||||
}
|
||||
// 错误信息必须指向真因(PKCE),而不是含糊的交换失败
|
||||
blob := body
|
||||
if !strings.Contains(blob, "PKCE") {
|
||||
t.Fatalf("错误信息应点名 PKCE(让排查方向正确),实得 %q", blob)
|
||||
}
|
||||
if strings.Contains(blob, "授权码交换失败") {
|
||||
t.Fatalf("不应再出现误导性的「授权码交换失败」:%q", blob)
|
||||
}
|
||||
t.Logf("✓ PKCE 回调被正确识别(poll_login 路径): %s", strings.TrimSpace(blob))
|
||||
}
|
||||
|
||||
// ★ client_id 必须是实测真值 —— ExchangeToken 的请求体里 ClientID 是必填。
|
||||
// 留空会让真实 token 直传场景的交换稳定 400(夹具自身也校验 ClientID 存在)。
|
||||
func TestTraeExchangeSendsRealClientID(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// 真实回调形态:带 refreshToken(token 直传)⇒ 会走 ExchangeToken。
|
||||
// 夹具的 handler 已校验请求体含 ClientID / RefreshToken:<回调值> / ClientSecret。
|
||||
status, body := h.beginAndCallback(map[string]string{
|
||||
"refreshToken": "RT-FROM-CALLBACK",
|
||||
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
|
||||
})
|
||||
if status != 200 {
|
||||
t.Fatalf("token 直传回调应成功: status=%d body=%s", status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n == 0 {
|
||||
t.Fatal("token 直传场景必须真的调用 ExchangeToken")
|
||||
}
|
||||
t.Logf("✓ ExchangeToken 被调用 %d 次,请求体形状正确", atomic.LoadInt32(&h.exchHit))
|
||||
}
|
||||
|
||||
// client_id 的真值必须是实测值 —— 这是「交换必然 400」的两个成因之一。
|
||||
func TestTraeClientIDIsNotBlank(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
if !strings.Contains(src, `client_id = "en1oxy7wnw8j9n"`) {
|
||||
t.Fatal("trae 的 client_id 必须是实测真值 en1oxy7wnw8j9n —— " +
|
||||
"留空/占位会让 ExchangeToken 的 ClientID 为空而稳定 400")
|
||||
}
|
||||
}
|
||||
|
||||
// 两条路径(oauth_callback 与 poll_login 的 loopback)必须给**同一个**结论,
|
||||
// 否则修复只覆盖一条、另一条继续漂移(这正是缺陷能藏住的原因)。
|
||||
func TestTraeCallbackPathsAgreeOnPkce(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
// ① 不得再出现自己发明的交换(注释里说明历史的那句除外)
|
||||
if strings.Contains(src, `cerr = "Trae 授权码交换失败`) {
|
||||
t.Fatal("已删除的「授权码交换」又回来了 —— 那是拿 code 去 ExchangeToken," +
|
||||
"必然 400 且把真因掩盖成交换失败")
|
||||
}
|
||||
// ② 两条路径都必须交给 traeCallback(它内建 PKCE 判定)
|
||||
if n := strings.Count(src, "traeCallback(plat,"); n < 2 {
|
||||
t.Fatalf("traeCallback 应被两条路径共用(当前 %d 处)—— 只有一条会漂移", n)
|
||||
}
|
||||
// ③ PKCE 判定必须仍在 traeCallback 内
|
||||
if !strings.Contains(src, "本实现暂不支持该流程") {
|
||||
t.Fatal("traeCallback 必须保留 PKCE 明确报错(参考实现的语义)")
|
||||
}
|
||||
}
|
||||
|
||||
// beginAndPollWithCallback 走**真实登录用的那条路径**:
|
||||
// start_login 拿会话 → 摆好 loopback 回调文件(平台重定向的落地)→ poll_login。
|
||||
//
|
||||
// 与 beginAndCallback(打 oauth_callback)的区别很关键:poll_login 才是
|
||||
// callback 流在服务端浏览器(VNC)场景下真正走的那条,真实缺陷就藏在它的
|
||||
// trae 分支里。
|
||||
func (h *traeHarness) beginAndPollWithCallback(cbQuery map[string]string) (int, string) {
|
||||
h.t.Helper()
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j, ok := out.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
h.t.Fatalf("start_login 返回非对象: %#v", out.JSON)
|
||||
}
|
||||
sess := j["session"].(map[string]interface{})
|
||||
sid, _ := sess["id"].(string)
|
||||
nonce, _ := sess["nonce"].(string)
|
||||
if sid == "" {
|
||||
h.t.Fatalf("会话缺 id: %#v", sess)
|
||||
}
|
||||
|
||||
// 摆好 loopback 回调文件(内容 = 平台重定向带回的 query)
|
||||
rec := map[string]string{"state": nonce}
|
||||
for k, v := range cbQuery {
|
||||
rec[k] = v
|
||||
}
|
||||
b, _ := json.Marshal(rec)
|
||||
cbPath := filepath.Join(h.t.TempDir(), "cb-"+sid+".json")
|
||||
if err := os.WriteFile(cbPath, b, 0600); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
// 告诉插件「这个会话的 loopback 接收器把结果写在这里了」
|
||||
if _, err := h.ps.Call("channels", "__set_loopback_probe", map[string]interface{}{
|
||||
"session_id": sid, "file": cbPath,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("设置 loopback 文件: %v", err)
|
||||
}
|
||||
|
||||
res, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid},
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
|
||||
}
|
||||
@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
|
||||
|
||||
|
||||
@ -194,7 +194,9 @@ local PLATFORMS = {
|
||||
mode = "trae",
|
||||
exchange_url = "https://api.trae.com.cn/cloudide/api/v3/trae/oauth/ExchangeToken",
|
||||
exchange_body = '{"ClientID":"{client_id}","RefreshToken":"{refresh_token}","ClientSecret":"-","UserID":""}',
|
||||
client_id = "", -- 参考用 product.clientId;TRAE 回调直传形态不强制,留空
|
||||
client_id = "en1oxy7wnw8j9n", -- 实测值(对齐参考 trae-product.ts 的 clientId)
|
||||
-- ↑ 这个值**不是**可选的:ExchangeToken 的请求体里 ClientID 是必填,
|
||||
-- 留空会让交换稳定 400(详见文件内 traeCallback 的注释)。
|
||||
},
|
||||
},
|
||||
|
||||
@ -4843,25 +4845,19 @@ function plugin.poll_login(req)
|
||||
elseif s.platform == "trae" then
|
||||
-- Trae:回调把 token 直接放在 query 里(refreshToken/userJwt/userInfo),
|
||||
-- 故把整个回调 query 交给 traeCallback(它在两条路径上共用)。
|
||||
--
|
||||
-- ★ 这里曾经有一段**自己发明的**「授权码交换」:回调只带 code 时,拿
|
||||
-- exchange_body 去 ExchangeToken。那是错的,而且是稳定 400:
|
||||
-- · ExchangeToken 的签名是 RefreshToken,不是 code;
|
||||
-- · exchange_body 里根本没有 {code} 占位符,替换是空操作。
|
||||
-- 结果是日志回一句误导性的「授权码交换失败: 400」,把真因
|
||||
-- 「上游走了 PKCE 流程」掩盖成「交换出错了」。
|
||||
--
|
||||
-- 按参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法:
|
||||
-- 带 code 的回调是**合法回调**,只是我们不支持的 PKCE 分支,
|
||||
-- 要**明确报出来**而不是尝试一个不存在的交换。
|
||||
-- traeCallback 已内建这个判定(返回「上游返回了 PKCE 授权码…」)。
|
||||
credential, account, cerr = traeCallback(plat, cb, s)
|
||||
if not credential and code ~= "" then
|
||||
-- 回调只给了 code(PKCE 形态)→ 用 exchange 端点的标准交换
|
||||
local body = (plat.callback.exchange_body or "")
|
||||
:gsub("{client_id}", plat.callback.client_id or "")
|
||||
:gsub("{code}", code)
|
||||
:gsub("{port}", tostring(gatewayPort()))
|
||||
local resp, err = __llmsproxy_http({ url = plat.callback.exchange_url,
|
||||
method = "POST", headers = { ["Content-Type"] = "application/json" }, body = body })
|
||||
if err or not resp or resp.status ~= 200 then
|
||||
cerr = "Trae 授权码交换失败: " .. tostring(err or (resp and resp.status))
|
||||
else
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
local access = ok and type(rec) == "table"
|
||||
and (rec.Token or rec.token or rec.AccessToken or rec.access_token)
|
||||
if not access then cerr = "Trae 授权码交换响应缺少 Token"
|
||||
else credential, account, cerr = json.encode({ access_token = access }), "trae", nil end
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Gemini 等标准 OAuth:表单交换(必带 client_secret,见 token_body 注释)
|
||||
local body = (plat.callback.token_body or "")
|
||||
@ -9559,6 +9555,23 @@ function plugin.__store_dir_probe()
|
||||
return { json = { store_dir = storeDir() } }
|
||||
end
|
||||
|
||||
-- plugin.__set_loopback_probe:只给测试用 —— 把某会话的 loopback 回调文件
|
||||
-- 指到测试摆好的路径,以便直接驱动 poll_login 的 callback 分支。
|
||||
--
|
||||
-- 为什么需要它:callback 流的真实登录走 poll_login(读 loopback 文件),
|
||||
-- 而那个文件平时由插件自己起的 python 接收器写入。判据要验的正是**这条路径**
|
||||
-- 的转码/交换行为(真实缺陷曾藏在它的 trae 分支里),但测试不起真实接收器,
|
||||
-- 故需要这个注入点。生产代码不会调用它。
|
||||
function plugin.__set_loopback_probe(args)
|
||||
args = args or {}
|
||||
local sid = tostring(args.session_id or "")
|
||||
local file = tostring(args.file or "")
|
||||
if sid == "" or file == "" then return { json = { error = "need session_id and file" } } end
|
||||
if type(plugin.state.loopbacks) ~= "table" then plugin.state.loopbacks = {} end
|
||||
plugin.state.loopbacks[sid] = { file = file }
|
||||
return { json = { ok = true, session_id = sid, file = file } }
|
||||
end
|
||||
|
||||
-- plugin.credits_tier_probe:只给测试用 —— 单账号的余额分档结果。
|
||||
-- 暴露 expiring/permanent/tier/usable 四个值,让判据能逐条钉住分档规则
|
||||
-- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。
|
||||
|
||||
Reference in New Issue
Block a user