Files
ModelRouter/tools/trae_derive_expected.mjs
JianFeeeee 5ab047e48b fix(channels): 修复 12 平台登录的系统性偏差(callback 闭环 + 动态模型 + Trae 设备身份)
用户报障:「除了 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,断言同步更新
2026-10-13 13:42:45 +08:00

75 lines
3.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 生成 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`)