From 8d23005f7df56bac330d6460b07fb945476783a1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 15 Oct 2026 22:04:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(trae):=20=E7=99=BB=E5=BD=95=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=9C=9F=E5=9B=A0=20=E2=80=94=E2=80=94=20=E8=99=9A?= =?UTF-8?q?=E6=9E=84=E7=9A=84=E3=80=8C=E6=8E=88=E6=9D=83=E7=A0=81=E4=BA=A4?= =?UTF-8?q?=E6=8D=A2=E3=80=8D+=20=E7=A9=BA=20client=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报「TRAE 登录后显示失败」。定位到三处偏差,其中一处是核心缺陷: ① ★ **虚构了一次不存在的「授权码交换」**(核心) 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 分支,必须明确报出来。 删除虚构交换,两条路径共用 traeCallback 内建的 PKCE 判定。 ② client_id 从空串改为实测真值 en1oxy7wnw8j9n(对齐参考 trae-product.ts 的 clientId;ExchangeToken 请求体里 ClientID 必填,留空稳定 400)。 ③ 两条路径(oauth_callback / poll_login)现在共用同一结论,不再漂移 —— 这正是缺陷能藏住的原因:修复只覆盖一条、另一条继续错。 测试支撑: - 新增 __set_loopback_probe(注入 loopback 回调文件),否则无法驱动 poll_login 的 callback 分支(那才是真实登录走的路径)。 - 判据 4 条,全部做变异验证: TestTraePkceCallbackIsReportedNotExchanged 打 poll_login,断言没打 ExchangeToken 且点名 PKCE TestTraeExchangeSendsRealClientID token 直传真调交换,请求体形状正确 TestTraeClientIDIsNotBlank client_id 必须是实测真值 TestTraeCallbackPathsAgreeOnPkce 两条路径必须共用 traeCallback - 变异 2/2 咬住:client_id 改回空串 → 报红;虚构交换加回去 → 「实际打了 1 次 ExchangeToken」。 ★ 我在过程中自己犯的两个错(均由变异测试揭穿,已修正): 1. **判据打错了路径** —— 第一版打 oauth_callback(那条一直是对的),注入缺陷后 判据**仍绿**。改成打 poll_login 才咬住。教训:判据必须打**真实被使用的那条 路径**,否则等于没测。 2. **判据写错了契约** —— poll_login 用 HTTP 200 + json.state 表达终态,我按 status != 200 断言,把正确的实现判成了错。 回归:go test -tags luajit ./... 10 包全绿。 --- internal/core/channels_trae_pkce_test.go | 169 +++++++++++++++++++++++ internal/lua/plugins/channels.lua | 53 ++++--- 2 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 internal/core/channels_trae_pkce_test.go diff --git a/internal/core/channels_trae_pkce_test.go b/internal/core/channels_trae_pkce_test.go new file mode 100644 index 0000000..0f02f11 --- /dev/null +++ b/internal/core/channels_trae_pkce_test.go @@ -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) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 7b746f8..a5342ba 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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 四个值,让判据能逐条钉住分档规则 -- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。