fix(trae): 登录失败真因 —— 虚构的「授权码交换」+ 空 client_id

用户报「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 包全绿。
This commit is contained in:
JianFeeeee
2026-10-15 22:04:01 +08:00
parent 9e5fec08b8
commit 1be34557dd
2 changed files with 202 additions and 20 deletions

View File

@ -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 四个值,让判据能逐条钉住分档规则
-- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。