feat(channels): Qoder 设备身份(Cosy-MachineToken/Type)—— 活动可见性的前提

## 参考依据
`qoder-machine.ts`。参考用**抓包 + 消融实验**定位过一个真实缺陷:用户报
「Qoder 没领过积分却显示『今日已领取』,去 IDE 看还能领」。

根因:`/sash/` 端点不只要 `Cosy-ClientType`,还校验**设备身份**。消融表(同账号、只改头):

| 请求头 | /sash/api/v1/me/campaigns |
|---|---|
| 仅 Cosy-ClientType: 10 | `claimable:false`,1 条 VIEW_DETAILS |
| + MachineToken + MachineType | **`claimable:true`,2 条,含 CLAIM_BENEFIT/amount:100** |
| 全套 − MachineToken(或 − MachineType) | 退回 1 条(失效) |

⇒ 两值**必须成对**,`Cosy-ClientType` 是必要但不充分条件。

## 实现(三级,与参考一致)
1. **主路径**:实时调 `runtime-info`,参数顺序必须是 `[environment, '--account-stdin']`,
   `environment='3'`(对应 asar 的 `'global'` 分支)。**漏环境值会拿到另一套身份**
   (参考实测:漏了只回 1 条 VIEW_DETAILS)。stdin 传 `{"account":""}`,
   从输出第一个 `{` 起解析 `machineToken`/`machineType`。
   定位方式照参考:`<home>/.qoder[|-cn]/.bin/umid-*/runtime-info`(目录名带哈希,必须枚举)。
2. **退路**:磁盘缓存 `machine_token.json` 的 `token`/`type`(候选路径含 Linux
   `~/.config/Qoder/…`,并支持 `QODER_MACHINE_TOKEN_PATH` 覆盖)。
3. **降级**:都拿不到时**照常发请求、不带这两个头**(保守降级)——
   纯插件登录、未装桌面端的账号没有该文件,不能让整个积分功能报错。
   注意:**不写空串**(发空头可能被按非法设备处理)。

结果缓存(拿不到也记 null),不反复读盘/spawn。

## 接线
qoder 平台加 `headers_fn`:`Cosy-ClientType: 10` + (拿到时)成对的身份头,
余额与(后续的)活动领取共用。

## 判据
`TestQoderMachineIdentityPairingAndDegradation`:有缓存 → 成对带上;无任何来源 →
降级且不报错、仍带 clienttype。变异验证:只发 token(不成对)、拿不到时写空串
—— 两个都被抓。

## Lua 前向引用(本文件第 N 次)
`qoderMachineIdentity` 定义在文件后段,而 qoder 平台表在前面 —— 前向声明必须
放在 **PLATFORMS 之前**(第一次放错位置,等于没声明)。
This commit is contained in:
JianFeeeee
2026-10-13 15:52:26 +08:00
parent d1b7d94043
commit 5b433b3266
2 changed files with 245 additions and 1 deletions

View File

@ -0,0 +1,84 @@
package core
import (
"os"
"path/filepath"
"testing"
)
// Qoder 的设备身份两个头**必须成对**出现:参考用抓包 + 消融实验定位过 ——
// 缺任一个,服务端就不下发「可领取」的活动(只回 VIEW_DETAILS),
// 症状是用户报的「没领过却显示今日已领取」。
// 同时:拿不到身份时必须**保守降级**(照常发请求、不带这两个头),
// 不能让纯插件登录(未装桌面端)的账号整个积分功能报错。
func TestQoderMachineIdentityPairingAndDegradation(t *testing.T) {
t.Run("行1 有 machine_token.json → 成对带上两个头", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "machine_token.json")
if err := os.WriteFile(p, []byte(`{"token":"T-abc","type":"TY-def","updateAt":1}`), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("QODER_MACHINE_TOKEN_PATH", p)
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("probe: %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("无 json: %#v", got)
}
if m["token"] != "T-abc" || m["type"] != "TY-def" {
t.Fatalf("应从磁盘缓存读出 token/type,实得 %v", m)
}
if m["client_type"] != "10" {
t.Fatalf("Cosy-ClientType 应为 10(参考实测值),实得 %v", m["client_type"])
}
})
t.Run("行2 无任何来源 → 降级:不带这两个头,且不报错", func(t *testing.T) {
// 指向不存在的路径,且把 HOME 指到空目录(找不到 runtime-info)
t.Setenv("QODER_MACHINE_TOKEN_PATH", filepath.Join(t.TempDir(), "nope.json"))
t.Setenv("HOME", t.TempDir())
t.Setenv("APPDATA", "")
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("probe 不应报错(必须保守降级): %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("无 json: %#v", got)
}
if m["token"] != nil && m["token"] != "" {
t.Errorf("拿不到身份时不应带 token,实得 %v", m["token"])
}
if m["type"] != nil && m["type"] != "" {
t.Errorf("拿不到身份时不应带 type,实得 %v", m["type"])
}
// 头仍然要有 clienttype(那是必要但不充分条件,与身份无关)
if m["client_type"] != "10" {
t.Errorf("降级时仍应带 Cosy-ClientType=10,实得 %v", m["client_type"])
}
})
}

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", "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" }
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "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" }
@ -76,6 +76,11 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda
-- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。
-- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua
-- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。
-- 前向声明:qoder 平台表里的 headers_fn 要在运行时调用它,而真实定义在
-- 文件后段。Lua 的 local 作用域看不到后面的定义 —— 必须在**使用点之前**声明
-- (此处就是 PLATFORMS 表之前),否则 headers_fn 里拿到 nil(真实踩过)。
local qoderMachineIdentity
local PLATFORMS = {
-- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)=====
-- -- 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的
@ -625,6 +630,24 @@ local PLATFORMS = {
},
-- 余额(对齐参考 qoder-credits.ts:GET /sash/api/v2/me/usage,余额=userQuota.remaining
-- + addOnQuota.remaining,走 openApiBase)
-- 身份头:cosy-clienttype 是必要但**不充分**条件,服务端还校验设备身份
-- (见 qoderMachineIdentity 的函数注释:缺 Cosy-MachineToken/MachineType
-- 时不下发可领取的活动)。拿不到身份时照常发(保守降级)。
headers_fn = function(accessToken, cred)
local h = {
["Authorization"] = "Bearer " .. accessToken,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["Cosy-ClientType"] = "10",
}
local id = qoderMachineIdentity()
if id then
-- 必须成对出现(消融表:缺一即失效)
h["Cosy-MachineToken"] = id.token
h["Cosy-MachineType"] = id.type
end
return h
end,
balance = {
url = "https://openapi.qoder.sh/sash/api/v2/me/usage",
method = "GET",
@ -994,6 +1017,130 @@ end
-- actions 不含 check_in → inactive
-- ③ POST .../{activityCode}/actions/check_in(带客户端幂等键)→ 成功
-- 参考注释特别强调:无可用活动是**正常业务状态**,不能显示成"领取失败"。
-- ---- Qoder 设备身份(Cosy-MachineToken / Cosy-MachineType)----
--
-- 逐行对齐参考 qoder-machine.ts。参考用抓包 + 消融实验定位过:**缺这对头时
-- 服务端不下发「可领取」的活动**(只回 1 条 VIEW_DETAILS 而非
-- CLAIM_BENEFIT/CLAIMABLE/amount:100),症状正是用户报的
-- 「没领过却显示今日已领取,去 IDE 看还能领」。要点:
-- 1. 两值**必须成对**出现,缺一即失效(消融表:全套 − 任一个 → 退回 1 条)。
-- 2. 主路径是实时调 `runtime-info`:参数顺序**必须是 `[environment, '--account-stdin']`**,
-- environment 取 '3'(对应 asar 的 'global' 分支)。漏环境值会拿到另一套身份。
-- stdin 传 `{"account":""}`,输出是含 `{` 的单行 JSON(取 machineToken/machineType)。
-- 3. 退路是磁盘缓存 `machine_token.json`(token/type 字段)。
-- 4. 两者都拿不到时**照常发请求**(不带这两个头)—— 保守降级,
-- 纯插件登录、未装桌面端的用户没有该文件,不能让整个积分功能报错。
-- 5. 缓存解析结果:拿不到时也记 null,不反复读盘/反复 spawn。
local QODER_MACHINE_ENVIRONMENT = "3"
local function qoderMachineCache()
plugin.state.qoderMachine = plugin.state.qoderMachine or { done = false }
return plugin.state.qoderMachine
end
-- 读单个 machine_token.json(形状不符/读取失败返回 nil)。
local function qoderParseMachineFile(path)
local f = io.open(path, "r")
if not f then return nil end
local raw = f:read("*a")
f:close()
local ok, rec = pcall(json.decode, raw or "")
if not ok or type(rec) ~= "table" then return nil end
local token, typ = rec.token, rec.type
if type(token) ~= "string" or token == "" then return nil end
if type(typ) ~= "string" or typ == "" then return nil end
return { token = token, type = typ }
end
-- 候选路径(照参考 candidatePaths:env 覆盖 → APPDATA → macOS → Linux)
local function qoderMachineCandidatePaths()
local out = {}
local override = os.getenv and os.getenv("QODER_MACHINE_TOKEN_PATH")
if type(override) == "string" and override ~= "" then return { override } end
local appdata = os.getenv and os.getenv("APPDATA")
if type(appdata) == "string" and appdata ~= "" then
for _, d in ipairs({ "Qoder", "Qoder CN" }) do
out[#out + 1] = appdata .. "/" .. d .. "/SharedClientCache/cache/machine_token.json"
end
end
local home = os.getenv and (os.getenv("HOME") or os.getenv("USERPROFILE")) or nil
if type(home) == "string" and home ~= "" then
for _, d in ipairs({ "Qoder", "Qoder CN" }) do
out[#out + 1] = home .. "/Library/Application Support/" .. d
.. "/SharedClientCache/cache/machine_token.json"
out[#out + 1] = home .. "/.config/" .. d .. "/SharedClientCache/cache/machine_token.json"
end
end
return out
end
-- 定位 runtime-info:<home>/<dataDir>/.bin/umid-*/runtime-info(目录名带哈希,必须枚举)
local function qoderFindRuntimeInfo()
if not (os.getenv and os.getenv("HOME")) then return nil end
local exeName = "runtime-info"
for _, dirName in ipairs({ ".qoder", ".qoder-cn" }) do
local binDir = os.getenv("HOME") .. "/" .. dirName .. "/.bin"
local h = io.popen("ls -1 " .. binDir .. " 2>/dev/null")
if h then
local found
for line in h:lines() do
if line:sub(1, 5) == "umid-" then found = line break end
end
h:close()
if found then
local exe = binDir .. "/" .. found .. "/" .. exeName
local f = io.open(exe, "rb")
if f then f:close() return exe end
end
end
end
return nil
end
-- 实时生成(照参考:参数顺序 [env, '--account-stdin'],stdin 传 {"account":""})
local function qoderReadFromRuntimeInfo()
local exe = qoderFindRuntimeInfo()
if not exe then return nil end
-- 用临时文件喂 stdin:命令行传 JSON 的引号在 shell 下不可靠
local tmp = "/tmp/llmsproxy-qoder-stdin-" .. tostring(math.random(1e12))
local f = io.open(tmp, "w")
if not f then return nil end
f:write('{"account":""}')
f:close()
local cmd = ("timeout 20 %s %s %s < %s 2>/dev/null"):format(
exe, QODER_MACHINE_ENVIRONMENT, "--account-stdin", tmp)
local h = io.popen(cmd)
local out = h and h:read("*a") or ""
if h then h:close() end
os.remove(tmp)
if out == "" then return nil end
-- 输出形如(单行 JSON):取第一个 '{' 起
local i = out:find("{", 1, true)
if not i then return nil end
local ok, rec = pcall(json.decode, out:sub(i))
if not ok or type(rec) ~= "table" then return nil end
local token, typ = rec.machineToken, rec.machineType
if type(token) ~= "string" or token == "" then return nil end
if type(typ) ~= "string" or typ == "" then return nil end
return { token = token, type = typ }
end
-- qoderMachineIdentity():返回 {token,type} 或 nil(拿不到时降级,不报错)。
qoderMachineIdentity = function()
local c = qoderMachineCache()
if c.done then return c.identity end
local id = qoderReadFromRuntimeInfo()
if not id then
for _, path in ipairs(qoderMachineCandidatePaths()) do
id = qoderParseMachineFile(path)
if id then break end
end
end
c.done = true
c.identity = id
return id
end
-- 前向声明:本段(活动领取)要用到的这几个助手都定义在文件后段。
-- Lua 的 local 作用域**看不到后面的定义**,不声明就是
-- "attempt to call global 'xxx' (a nil value)"(本文件已在这上面踩过多次:
@ -5646,6 +5793,19 @@ do
end
end
-- plugin.__qoder_machine_probe:只给测试用 —— 走真实的 headers_fn 路径,
-- 返回它给出的设备身份头,便于验证「成对出现」与「拿不到时降级」。
function plugin.__qoder_machine_probe(args)
local ph = nil
for _, p in ipairs(PLATFORMS) do
if p.id == "qoder" then ph = p break end
end
if not ph or not ph.headers_fn then return { json = { error = "qoder headers_fn 缺失" } } end
local h = ph.headers_fn("TOK", {})
return { json = { token = h["Cosy-MachineToken"], type = h["Cosy-MachineType"],
client_type = h["Cosy-ClientType"] } }
end
-- plugin.__codearts_sign_probe:只给测试做差分(与参考生成的期望值比对)。
-- 允许传入 date 以对齐参考的固定时间戳。
function plugin.__codearts_sign_probe(args)