mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户报障:「除了 codebuddy,其他的登陆都不太正常,尤其是有道那个」。逐平台审计
参考实现(vendor/channel-pack/src)后发现根因不是 12 个独立 bug,而是几个系统性偏差。
## ① callback 流的回调是回环地址 → 必须走服务端浏览器(核心认识)
Trae/Gemini/有道 的 redirect_uri 都是回环地址(127.0.0.1 / localhost)。回环地址由
「打开授权页的那台机器」解析:远程用户在自己电脑上打开,重定向就落在**用户机器**上,
回调永远到不了网关。CodeBuddy 一直是好的,因为它走 state-poll,**根本不需要回调**。
→ 插件自起 loopback 接收器(Python,绑回环、双栈,路径按各平台注册值),
由服务端浏览器(VNC)承载授权页,重定向落回本机。
## ② 三个平台的回调路径各是注册值(写错就是 redirect_uri_mismatch)
有道 /auth/callback、Trae /authorize、Gemini /oauth-callback(参考 GEMINI_CALLBACK_PATH)
且 Gemini 的 redirect_uri 主机是 **localhost** 而不是 127.0.0.1。
另:Trae 的回调**不带 state**(token 直接放 query),其余平台必须带 → 校验规则按形态分。
## ③ 有道版本号不能写死
exchange/续期/签到的 version 是必填。实测 2026-10-13 上游已是 2026.9.23,而参考的兜底
常量还停在 2026.9.4。新增 lobsteraiClientVersion:远端为主(data.value.version,**非统一
信封**)+ 失败用兜底但**不缓存**(照参考 resolver)。失败时把上游 message 带出来
(实测形如 code=40102「无效或已过期的授权码」)。
## ④ Gemini token 交换缺 client_secret(症状正是「授权成功但账号不出现」)
参考注释点名:token 端点强制校验客户端身份,只发 client_id 会 invalid_request。
同时补 access_type=offline / prompt=consent(缺了拿不到 refresh_token),
并去掉误标的 pkce(上游 Cloud Code 用的是最朴素的 authorization_code + client_secret)。
## ⑤ Trae 授权 URL 少了 5 个必需参数
参考列为「一个都不能少」:login_trace_id(回调**反查 pending 的唯一凭据**)、
machine_id/device_id(hex32,每账号必须不同,否则被「该设备已签到」拦)、
x_device_id/x_machine_id。此前**完全没造**设备身份 → 登录页停在「授权中」。
## ⑥ Trae 签到头与设备身份
授权方案是 `Cloud-IDE-JWT`(不是 Bearer);参考点明简化头**实测签到失败**,
需约 20 个客户端头,且 X-Device-Id 是**基于 user_id 确定性派生的 15 位数字**。
实现 seededStream(SHA256(utf8(salt:seed) ++ counterBE32) 串联)+ 三级派生,
并用参考实现(Node)生成期望值做**差分验证**逐字节对齐。
凭据改为 JSON 以保留 uid/device_id/machine_id(签到与轮换需要)。
## ⑦ 同段逻辑抽一个助手,防两条路漂移
lobsteraiExchange 被 oauth_callback 与 poll_login 两条路共用(原先各内联一份)。
## 判据(新增测试)
- TestLobsterLoopbackClosedLoop / TestLobsterExchangePreservesIdentity:loopback 闭环、
state 不匹配 400、重放 409、凭据含 uuid/first_keyfrom
- TestCallbackPlatformsLoopback:三个平台的路径与 state 规则、Trae 无 state 也能接住
- TestAuthorizeURLsHaveNoUnfilledPlaceholders:授权 URL 不留 {占位符}
- TestTraeDeriveMatchesReference:与参考实现**逐字节**差分(tools/trae_derive_expected.mjs)
- TestTraeCheckinHeaders:Cloud-IDE-JWT + 派生设备身份 + 客户端头齐全
- TestRemoteModelCatalogMergesWithFallback:远端为主、兜底补位、失败不清缓存
## 顺带
- set_endpoints 补 callback / client_version 键(否则测试无法把端点指到假上游)
- 删掉 spawnBrowser 里会把 redirect_uri 一律改写为插件挂载点的死代码(对有道致害)
- session 过期与插件卸载时回收 loopback 接收器进程(防进程泄漏)
- e2e/channels_flows_e2e_test.go:qoder 已从 manual 改为 device-poll,断言同步更新
75 lines
3.0 KiB
JavaScript
75 lines
3.0 KiB
JavaScript
// 生成 Trae 设备身份派生的差分期望值(供 internal/core/channels_trae_derive_test.go 比对)。
|
||
//
|
||
// 为什么要有这个文件:channels.lua 里的 traeDeriveStream 是**手工移植**的
|
||
// SHA-256 确定性伪随机流(Lua 5.1 没有 node:crypto)。移植错了不会报错 ——
|
||
// 上游只会返回风控/失败,看起来像"运气不好"。所以拿参考实现(参考仓库
|
||
// vendor/channel-pack/src/trae.ts 的 seededStream/seededDigits)生成期望值,
|
||
// 再让 Lua 侧算同一组输入做差分。
|
||
//
|
||
// 用法(改了参考或改了移植后都要重跑):
|
||
// node tools/trae_derive_expected.mjs > internal/core/trae_derive_expected.json
|
||
//
|
||
// 注意:这里刻意**逐行照抄**参考的 seededStream,而不是从参考仓库 import ——
|
||
// 参考是 TS/ESM 且依赖其构建链,直接抄这 20 行更能保证"同一算法"。
|
||
|
||
import { createHash } from 'node:crypto'
|
||
|
||
/** 照抄 trae.ts:seededStream —— SHA256(utf8(salt:seed) ++ counterBE32) 串联。 */
|
||
function seededStream(seed, salt, nbytes) {
|
||
const prefix = `${salt}:${seed}`
|
||
const result = []
|
||
let counter = 0
|
||
while (result.length < nbytes) {
|
||
const counterBuf = new Uint8Array(4)
|
||
counterBuf[0] = (counter >> 24) & 0xff
|
||
counterBuf[1] = (counter >> 16) & 0xff
|
||
counterBuf[2] = (counter >> 8) & 0xff
|
||
counterBuf[3] = counter & 0xff
|
||
const h = createHash('sha256')
|
||
h.update(prefix, 'utf8')
|
||
h.update(counterBuf)
|
||
for (const b of h.digest()) {
|
||
result.push(b)
|
||
if (result.length >= nbytes) break
|
||
}
|
||
counter++
|
||
}
|
||
return result.slice(0, nbytes)
|
||
}
|
||
|
||
/** 照抄 trae.ts:seededDigits —— 每字节 % 10,拼成 n 位数字串。 */
|
||
function seededDigits(n, seed, salt) {
|
||
return seededStream(seed, salt, n).map((b) => b % 10).join('')
|
||
}
|
||
|
||
/** 照抄 trae.ts:deriveDeviceId15。 */
|
||
function deriveDeviceId15(userId) {
|
||
return seededDigits(15, userId, 'devid')
|
||
}
|
||
|
||
/** 照抄 trae.ts:deriveMarketUserId —— UUID v4 形状。 */
|
||
function deriveMarketUserId(userId) {
|
||
const bs = seededStream(userId, 'market', 16)
|
||
bs[6] = (bs[6] & 0x0f) | 0x40
|
||
bs[8] = (bs[8] & 0x3f) | 0x80
|
||
const hex = bs.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
|
||
}
|
||
|
||
/** 照抄 trae.ts:deriveSessionId —— 64 位 hex。 */
|
||
function deriveSessionId(userId) {
|
||
return seededStream(userId, 'sess', 32).map((b) => b.toString(16).padStart(2, '0')).join('')
|
||
}
|
||
|
||
// 覆盖边界:纯数字 / 带连字符 / 空串(空串也要能算,不能崩)/ 多字节 UTF-8 /
|
||
// 超长(超过一个 SHA-256 块,逼出 counter 递增分支 —— 移植时最易错的地方)。
|
||
const ids = ['12345', 'UID-abc', '900000001', 'x', '', '用户1', '1234567890123456789012345']
|
||
|
||
const out = ids.map((u) => ({
|
||
u,
|
||
devid: deriveDeviceId15(u),
|
||
market: deriveMarketUserId(u),
|
||
sess: deriveSessionId(u),
|
||
}))
|
||
process.stdout.write(`${JSON.stringify(out, null, 1)}\n`)
|