Commit Graph

3 Commits

Author SHA1 Message Date
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
652a035d47 fix(channels): 测试夹具不再内联进生产目录 + callback 池路径漏网
用户指出「为什么生产插件会有实例平台?」—— 确实是设计错误。

## ① 夹具内联进生产源码(根因)
`PLATFORMS` 里内联了 4 个测试夹具:
  example / googleish / loopbackish / cookieish
后果:生产 `/api/plugins/http/channels/platforms` 返回 **16 个平台,其中 4 个是
夹具**,运维在 UI 上真的能选到。夹具属于测试,不属于发行物。

修法:
- 生产 `PLATFORMS` 只留 12 个真实厂商。
- 新增 `plugin.state.injected_platforms` + `allPlatforms()`;`platformById`
  先查内建再查注入表。
- 新增端点 `channels/inject_platforms`(roles=admin):运行时整表替换注入,
  边界校验 —— 不得覆盖内建平台(注入不能劫持真实厂商)、拒绝重复 id、
  拒绝无 id、限 32 条;空数组 = 清空。
- 8 处运行时遍历点(list_platforms/provider_status/backup/auto_checkin/
  gateway 开关等)改用 `allPlatforms()`;4 处**加载期**遍历(HMAC 钩子绑定、
  declaredHosts)保持 `PLATFORMS` —— 它们在 plugin.state 建立之前、注入尚未
  发生,改了无意义。
- 测试改为经 `injectTestPlatforms` 注入夹具(channels_plugin_test /
  channels_stress_test);e2e 本就用真实平台(minimax/cline/opencode/qoder),
  无需改动。

## ② callback 路径的 account_id 漏网
UI 流程 = account/add 建占位账号 → login 带 account_id → 完成时回填。
`oauth_callback`(trae/gemini/lobsterai 的 callback 流)调 complete_login 时
**没传 account_id** —— 与 buddy poll_login 同一缺陷,此前只修了 device-poll /
state-poll 两条分支,这条是漏网:凭据落到与占位账号无关的 `channels:<plat>`,
池账号永远 has_credential=false。

## 判据
- TestInjectPlatformsRejectsBadInput:注入生效 + 三条拒绝规则 + 空数组清空
- TestTraeCallbackPoolPath:callback 池路径凭据落 `channels:trae:<aid>` 且
  account_list 报 has_credential=true
- 变异:callback 去掉 account_id → TestTraeCallbackPoolPath 立即 FAIL(已实测)

go test ./internal/core/ 全绿(119s)。
2026-10-13 08:33:28 +08:00
893857464d feat(channels): TRAE callback 真实移植 —— token 直传 + ExchangeToken(照参考逐行)
此前 oauth_callback 是通用 OAuth 形态(直接拿 code 当凭据),与 TRAE 真实
行为不符。参考实现 trae-oauth.ts 的回调是 **token 直传**,本次照其行为移植:

parseTraeCallbackDetailed(决策表 5 行):
  行1 refreshToken 直传(或 userJwt.RefreshToken 回退)→ ExchangeToken 换新
      access(请求体 ClientID/RefreshToken/ClientSecret:'-'/UserID 照抄,
      响应取 Token)→ 凭据 = 新 access
  行2 无 refreshToken 但有 userJwt.Token → 直接兜底,**不打** ExchangeToken
  行3 只有 code/authCodeInfo → PKCE 流,明确报"暂不支持"而非含糊失败
      (参考注释强调:判为无效会让用户往错误方向排查)
  行4 全空 → 报"回调未携带 refreshToken / userJwt.Token / code"
  行5 ExchangeToken HTTP 失败 → 错误指明 ExchangeToken 阶段

配套:
- PLATFORMS.trae 增加 callback = {mode="trae", exchange_url, exchange_body};
- oauth_callback 按平台 callback.mode 分派(nil→code 旧路径不变,googleish 不受影响);
- traeCallback/jsonField 新函数;set_endpoints 支持 callback.exchange_url 覆盖
  (既是运维能力也是测试缝,与 device 端点同语义);
- TestTraeCallbackBehavior:5 行决策表 + 行1 断言凭据来自假上游新 Token
  (防"把回调原值当凭据")、行2 断言 ExchangeToken 零调用(防多余上游请求)。

回归:channels/trae/device 变异/压力/legacy/保险库/e2e 全绿。
2026-10-11 22:35:18 +08:00