fix(channels): CodeArts 签名按参考重写(华为云 SDK-HMAC-SHA256)+ 完整 4 步领取

## 问题:签名是实现者凭印象编的
原实现用 `SDK-HMAC-SHA256 Credential={ak}/{ts}/{nonce}/sdk_request` —— 那是另一套
协议(阿里风格)的形状,与参考的**华为云 SDK-HMAC-SHA256** 完全不同:
- canonical request 拼装错(参考是 METHOD\nURI\nQUERY\n排序后的 k:v 行\n\nSIGNEDHEADERS\nPAYLOADHASH)
- string-to-sign 格式错(参考是 "SDK-HMAC-SHA256\n{dateStamp}\n{sha256(canonical)}")
- Authorization 头用 Credential= 而非 **Access=**
- 少了 x-sdk-content-sha256 / x-security-token 参与签名
- URI 末尾未补 '/'
⇒ 服务端只会回 `APIG.0301 ... verify ak sk signature fail`,看起来像"账号/权限问题",
这个平台注定领不到额度。

## 修
逐行对齐参考 `sign.ts:signRequestHuawei` + `codearts-credits.ts`:
1. canonical request / string-to-sign / signature 全部照参考
2. 参与签名的头:host / x-sdk-date / x-sdk-content-sha256 / x-security-token
   (非 GET 再加 content-type),按字典序拼 SignedHeaders
3. Authorization = `SDK-HMAC-SHA256 Access={ak},SignedHeaders=…,Signature=…`
4. Agent-Type / X-Language 是**签名后**追加的,绝不进 canonical(否则验签失败)
5. URI 末尾补 '/'
6. 完整领取 4 步(参考的 6 步判定顺序):账户类型 → 活动列表 → claim →(benefit.id
   非空时)confirm;用 done 哨兵回给 claim_daily(它自己发多个请求,契约只发一个)
   - 非积分账户 → inactive(Token 账户不该被报成"领取失败")
   - activity.claimable=false 且状态属已领取态 → already-claimed(**唯一的幂等保护**,
     本协议没有幂等键)
   - confirm 失败不算整体失败(积分已在待确认态),只是降级提示

## 判据(差分,与参考逐字节一致)
`TestCodeArtsSignatureMatchesReference` + `tools/codearts_sig_expected.mjs`
(照抄参考函数生成期望值)。覆盖 canonical / string_to_sign / signature /
SignedHeaders / Authorization 形状 / canonical 不得含签名后追加的头。
变异验证:`Access=`→`Credential=`、去掉 content-type 参与签名、URI 不补斜杠
—— 三个全部被抓。

★ 修判据自身缺陷:首版探针**自己重算** canonical,导致真实签名被改坏时判据仍绿
(去掉 content-type 的变异存活)。改为让签名函数返回真实中间值,探针直接取用。

## 文档
新增 docs/channels-parity.md:与参考逐平台对齐清单(登录 / 额度 / 推理链路三张表
+ 差分判据表 + 未对齐项按影响排序),未对齐项**明示**而非含糊。
This commit is contained in:
JianFeeeee
2026-10-13 15:38:35 +08:00
parent 41ff6d1be3
commit 1586a051da
5 changed files with 542 additions and 37 deletions

68
docs/channels-parity.md Normal file
View File

@ -0,0 +1,68 @@
# channels 插件与参考实现(dsh-our-free-model / channel-pack)的对齐清单
参考位置:`vendor/channel-pack/src/`(本地克隆 `/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model/`)。
对齐口径:**照对方代码行为逐行移植**,并配「与参考逐字节一致」的差分判据。
状态口径:
- ✅ 已对齐(有实现 + 有判据,关键算法与参考做逐字节差分)
- ⚠️ 部分对齐(主链路可用,高阶环节缺失)
- ❌ 未对齐(参考有的环节没有实现)
## 一、登录(登录 = 拿到凭据并接入上游源)
| 平台 | flow | 参考模块 | 状态 | 说明 |
|---|---|---|---|---|
| trae | callback | trae-oauth.ts | ✅ | 17 参数、machine/device_id(hex32)、login_trace_id=末16位、Cloud-IDE-JWT |
| lobsterai | callback | lobsterai-oauth.ts | ✅ | loopback /auth/callback、5 字段交换、版本号动态解析(远端 `data.value.version`) |
| gemini | callback | gemini-oauth.ts | ✅ | client_secret、/oauth-callback、localhost 主机、6 scope、access_type=offline |
| minimax | device-poll | minimax-oauth.ts | ✅ | PKCE S256 设备码、scope/audience |
| cline | device-poll | cline-oauth.ts | ✅ | WorkOS 设备码 → register 两跳 |
| qoder | device-poll | qoder-oauth.ts | ⚠️ | 登录是纯 HTTP PKCE(已对齐);**加密推理**需 WASM(见三) |
| zcode | init-poll | zcode-login.ts | ⚠️ | 登录链路已对齐;**领取**需滑块验证码(见三) |
| loomy | wechat-poll | loomy-wechat-login.ts | ✅ | 真 appId/uuid 正则、HMAC-SHA1 账号签名(openssl) |
| raccoon | qr-poll | raccoon-oauth.ts | ✅ | 本地 code、状态机 pending/logging/canceled/success |
| buddy | state-poll | buddy-oauth.ts | ✅ | state→token 轮询 |
| opencode | paste | opencode-auth.ts | ✅ | 手动 key |
| codearts | paste | — | ✅ | 手动凭据 |
## 二、额度(领额度 = 每日领取 + 余额查询)
| 平台 | 参考模块 | 状态 | 说明 |
|---|---|---|---|
| codearts | codearts-credits.ts | ✅ | 华为云 SDK-HMAC-SHA256 逐字节对齐(差分判据)+ 4 步领取(账户→活动→claim→confirm) |
| buddy | credits.ts | ✅ | 双层嵌套余额、身份头、只累加有效包 |
| trae | trae-credits.ts | ✅ | Cloud-IDE-JWT + 派生设备身份(差分判据) |
| lobsterai | lobsterai-credits.ts | ⚠️ | 余额已对齐(profile-summary);**每日活动领取缺**(`/api/client-activities[/slot]`) |
| qoder | qoder-credits.ts | ⚠️ | 余额已对齐(`/sash/api/v2/me/usage`);**活动领取缺**(`/sash/api/v1/me/campaigns`) |
| minimax | minimax-credits.ts | ✅ | 两步签到(status + claim) |
| cline | cline-quota.ts | ✅ | plan usage-limits |
| 其余 | — | ⚠️ | 参考无额度端点,如实显示"不可用"(不编造) |
## 三、推理链路(高阶,参考有专门模块)
| 平台 | 参考模块 | 状态 | 影响 |
|---|---|---|---|
| qoder | qoder-wasm.ts (706) + qoder-envelope.ts (226) + qoder-machine.ts (510) | ❌ | 公开端点只认少量通用模型名;**模型目录里的 key(qfmodel/dmodel…)需 WASM 加密端点** |
| zcode | zcode-captcha.ts (2119) + zcode-carrier*.ts | ❌ | 模型请求自 3.14.4 起**不再索要**验证码;**领取始终索要**(code 3007),且一次性 |
| gemini | gemini-sigstore.ts (268) | ❌ | thoughtSignature 缓存;缺失**不报错但可能悄悄降质** |
| loomy | loomy-sign.ts | ✅ | 已对齐(仅账号端点用,唯一调用不带 query) |
| 各平台 | captcha-*.ts (6 模块 1223 行) | ❌ | 验证码池/载体/退避 |
## 四、差分判据(关键算法的"与参考一致"证据)
| 判据 | 覆盖 | 生成器 |
|---|---|---|
| `TestCodeArtsSignatureMatchesReference` | 华为云 canonical / string-to-sign / signature / SignedHeaders / Authorization | `tools/codearts_sig_expected.mjs` |
| `TestTraeDeriveMatchesReference` | seededStream / device_id15 / market uuid / session id | `tools/trae_derive_expected.mjs` |
| `TestTraeCheckinHeaders` | Cloud-IDE-JWT + 派生设备身份 + 客户端头 |
| `TestCallbackPlatformsLoopback` | 三平台回调路径 + state 规则(Trae 无 state) |
| `TestLobsterLoopbackClosedLoop` | 有道 loopback 闭环 + state 拒绝 + 凭据完整性 |
| `TestXDisplaySelectionReclaimsStaleLocks` | 浏览器栈显示器锁回收(防号段烧穿) |
## 五、已知未对齐项汇总(按对用户的影响排序)
1. **qoder 加密推理(WASM)** — 影响:拿不到模型目录里的 key(如 Qwen 系列)
2. **zcode 领取验证码** — 影响:模型可用,但**每日额度领不到**(3007)
3. **lobsterai / qoder 活动领取** — 影响:余额能看,活动奖励领不到
4. **gemini thoughtSignature 缓存** — 影响:工具调用质量可能悄悄下降
5. **captcha 池/载体(6 模块)** — 影响:zcode 领取的前置基础设施

View File

@ -0,0 +1,38 @@
[
{
"method": "GET",
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
"body": "",
"ak": "AK1",
"sk": "SK1",
"st": "TOK1",
"canonical": "GET\n/snap-manager/v1/statistics/plugin/\n\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:TOK1\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\nf40918a3339c9db2251933dcce410db05ff8a403de2767bc642140ea93cdc182",
"signature": "5332d627c3b8f14973afe41905f23dce75eaf9a34134dff670cb19835beb0549",
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
},
{
"method": "GET",
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE",
"body": "",
"ak": "AK2",
"sk": "SK2",
"st": "",
"canonical": "GET\n/v1/ops/delivery/\nchannel=IDE\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n3c849b40bbad69b981eaf280e162878bf00687d08cb45df43ac792fc23add4f1",
"signature": "a01248a4121fd943f0f3f07aac101cd649ddf34d162729bf06a6a12ae9916b1a",
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
},
{
"method": "POST",
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim",
"body": "{\"campaignId\":\"C1\",\"channel\":\"IDE\"}",
"ak": "AK3",
"sk": "SK3",
"st": "T3",
"canonical": "POST\n/v1/ops/claim/\n\ncontent-type:application/json\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19\nx-sdk-date:20261013T150405Z\nx-security-token:T3\n\ncontent-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token\n85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19",
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n554c2607cff324a770b55e78e2a1c58afe466abf5e4eaa104a6e1fa96acfc733",
"signature": "e5faa52ec51c36b38804aac45a8a19876314cec465ab8f905100e657e8ea68d5",
"signed_headers": "content-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token"
}
]

View File

@ -0,0 +1,97 @@
package core
import (
"encoding/json"
"os"
"strings"
"testing"
)
// CodeArts 的华为云 SDK-HMAC-SHA256 签名必须与参考**逐字节一致**,否则服务端
// 返回 `verify ak sk signature fail`,而这个平台的领取就永远失败 —— 且失败原因
// 会被误读成"账号问题"。此前插件里的实现是凭印象编的另一套形状
// (Credential={ak}/{ts}/{nonce}/sdk_request),必然验签失败。
//
// 期望值由参考实现(sign.ts:signRequestHuawei,Node + node:crypto)生成:
//
// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json
//
// 判据覆盖:canonical request 的拼装、string-to-sign 格式、最终 signature、
// 以及参与签名的头列表(顺序与内容)。
func TestCodeArtsSignatureMatchesReference(t *testing.T) {
raw, err := os.ReadFile("codearts_sig_expected.json")
if err != nil {
t.Skipf("缺差分数据(跳过): %v", err)
}
var cases []struct {
Method string `json:"method"`
URL string `json:"url"`
Body string `json:"body"`
AK string `json:"ak"`
SK string `json:"sk"`
ST string `json:"st"`
Canonical string `json:"canonical"`
StringToSign string `json:"string_to_sign"`
Signature string `json:"signature"`
SignedHeaders string `json:"signed_headers"`
}
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
if len(cases) == 0 {
t.Fatal("差分数据为空")
}
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)
}
for i, tc := range cases {
got, err := ps.Call("channels", "__codearts_sign_probe", map[string]interface{}{
"method": tc.Method, "url": tc.URL, "body": tc.Body,
"ak": tc.AK, "sk": tc.SK, "st": tc.ST, "date": "20261013T150405Z",
})
if err != nil {
t.Fatalf("case %d probe: %v", i, err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("case %d 无 json: %#v", i, got)
}
if m["error"] != nil {
t.Fatalf("case %d 探针报错: %v", i, m["error"])
}
if s, _ := m["canonical"].(string); s != tc.Canonical {
t.Errorf("case %d canonical 不一致\n 实得: %q\n 参考: %q", i, s, tc.Canonical)
}
if s, _ := m["string_to_sign"].(string); s != tc.StringToSign {
t.Errorf("case %d string-to-sign 不一致\n 实得: %q\n 参考: %q", i, s, tc.StringToSign)
}
if s, _ := m["signature"].(string); s != tc.Signature {
t.Errorf("case %d signature 不一致: 实得 %s 参考 %s", i, s, tc.Signature)
}
if s, _ := m["signed_headers"].(string); s != tc.SignedHeaders {
t.Errorf("case %d SignedHeaders 不一致: 实得 %q 参考 %q", i, s, tc.SignedHeaders)
}
// Authorization 头必须是 `SDK-HMAC-SHA256 Access=...`(不是 Credential=)
if s, _ := m["authorization"].(string); len(s) == 0 ||
s[:len("SDK-HMAC-SHA256 Access=")] != "SDK-HMAC-SHA256 Access=" {
t.Errorf("case %d Authorization 形状应为 SDK-HMAC-SHA256 Access=…,实得 %q", i, s)
}
// 签名后追加的头绝不能进 canonical
if s, _ := m["canonical"].(string); len(s) > 0 {
for _, bad := range []string{"Agent-Type", "X-Language", "agent-type", "x-language"} {
if strings.Contains(s, bad) {
t.Errorf("case %d canonical 里出现了签名后追加的头 %s(会导致验签失败)", i, bad)
}
}
}
}
}

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" }
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" }
@ -799,43 +799,204 @@ local PLATFORMS = {
},
}
-- CodeArts 的 SDK-HMAC-SHA256 签到钩子(照参考 codearts-credits.ts 逆向)。
-- 绑在 PLATFORMS 表定义之后:claim_daily 读取 plat.claim_fn,checkin 表驱动
-- 不动签名请求(参考注释也点明这一点),所以用钩子生成签名后的请求形状。
do
local ca
for _, p in ipairs(PLATFORMS) do
if p.id == "codearts" then ca = p break end
-- 华为云 snap-access 引擎域名(连接与签名都用它)。
local CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com"
-- CodeArts(华为云 snap-access)的请求签名与每日领取。
--
-- 逐行对齐参考 `sign.ts:signRequestHuawei` + `codearts-credits.ts`。
-- 此前这里的实现是**凭印象编的**(用 `Credential={ak}/{ts}/{nonce}/sdk_request`
-- 那套阿里风格形状),与参考的华为云 SDK-HMAC-SHA256 完全不同 —— 签名不可能通过,
-- 这个平台注定领不到额度。对齐要点:
-- 1. canonical request = METHOD\nURI\nQUERY\n(排序后的 k:v 行)\n\nSIGNEDHEADERS\nPAYLOADHASH
-- 2. string to sign = "SDK-HMAC-SHA256\n{dateStamp}\n{sha256(canonical)}"
-- 3. signature = HMAC-SHA256(sk, stringToSign)
-- 4. Authorization = "SDK-HMAC-SHA256 Access={ak},SignedHeaders=...,Signature=..."
-- (**是 Access= 不是 Credential=**)
-- 5. 参与签名的头:host / x-sdk-date / x-sdk-content-sha256 / x-security-token
-- (+ 非 GET 时的 content-type);按字典序拼 SignedHeaders。
-- 6. Agent-Type / X-Language 是**签名后**追加的,绝不能进 canonical(否则验签失败)。
-- 7. URI 末尾必须补 '/'。
local function codeartsDateStamp()
-- 参考用 ISO8601 去冒号与短横:20261013T150405Z
return os.date("!%Y%m%dT%H%M%SZ")
end
if ca then
-- SDK-HMAC-SHA256 派生签名密钥链(照参考 canonical request 形状)。
ca.claim_fn = function(plat, tok, args)
local url = plat.checkin.url
local method = plat.checkin.method or "GET"
local host = url:match("^https?://([^/:]+)")
local path = url:match("^https?://[^/]+(/[^?]*)")
local ts = tostring(os.time and os.time() or 0)
local nonce = tostring(math.random(1e12))
local canonical = method .. "\n" .. (path or "/") .. "\n" .. ts .. "\n" .. nonce
local secret = tok or ""
local kDate = hmac_sha256_hex("SDK" .. secret, ts)
local kNonce = hmac_sha256_hex(kDate, nonce)
local kSigning = hmac_sha256_hex(kNonce, "sdk_request")
local signature = hmac_sha256_hex(kSigning, canonical)
return {
url = url,
method = method,
headers = {
["Host"] = host,
["X-Sdk-Date"] = ts,
["Authorization"] = "SDK-HMAC-SHA256 Credential="
.. (args and args.access_key or "") .. "/" .. ts .. "/" .. nonce
.. "/sdk_request, SignedHeaders=host, Signature=" .. signature,
},
-- codeartsSignedRequest(method, url, cred, body):返回可直接发出的 {url,method,headers,body}。
local function codeartsSignedRequest(method, url, cred, body)
local ak = cred and (cred.access_key_id or cred.access_key) or ""
local sk = cred and (cred.secret_access_key or cred.secret_key) or ""
local st = cred and (cred.security_token or "") or ""
if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end
local host = url:match("^https?://([^/]+)")
local uri = url:match("^https?://[^/]+(/[^?]*)") or "/"
if uri:sub(-1) ~= "/" then uri = uri .. "/" end
local query = url:match("%?([^#]*)$") or ""
local payload = body or ""
local payloadHash = sha256_hex(payload)
local dateStamp = codeartsDateStamp()
-- 参与签名的头(小写键),按字典序
local h = {
["host"] = host,
["x-sdk-date"] = dateStamp,
["x-sdk-content-sha256"] = payloadHash,
["x-security-token"] = st,
}
if method:upper() ~= "GET" then h["content-type"] = "application/json" end
local keys = {}
for k in pairs(h) do keys[#keys + 1] = k end
table.sort(keys)
local lines, signedNames = {}, {}
for _, k in ipairs(keys) do
lines[#lines + 1] = k .. ":" .. h[k]
signedNames[#signedNames + 1] = k
end
local signedHeaders = table.concat(signedNames, ";")
local canonical = table.concat({
method:upper(), uri, query, table.concat(lines, "\n"), "", signedHeaders, payloadHash,
}, "\n")
local stringToSign = "SDK-HMAC-SHA256\n" .. dateStamp .. "\n" .. sha256_hex(canonical)
local signature = hmac_sha256_hex(sk, stringToSign)
-- 把中间值一并带出:判据必须比对**真实函数算出的** canonical / string-to-sign,
-- 而不是自己重算一遍 —— 否则真实签名被改坏时判据照样通过(判据与被测对象脱节,
-- 实测踩过:去掉 content-type 参与签名,探针重算版本仍绿)。
-- 发出去的头:签名头 + Authorization(host 由 HTTP 栈生成,不手填)
local out = {}
for _, k in ipairs(keys) do
if k ~= "host" then out[k] = h[k] end
end
out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak
.. ",SignedHeaders=" .. signedHeaders .. ",Signature=" .. signature
-- 签名后追加(不参与 canonical)
out["Agent-Type"] = "PromptCenter"
out["X-Language"] = "zh-cn"
return { url = url, method = method:upper(), headers = out, body = payload,
canonical = canonical, string_to_sign = stringToSign,
signed_headers = signedHeaders }
end
-- codeartsAccountInfo(cred):账号信息(statistics/plugin),判断是否积分账户。
local function codeartsAccountInfo(cred)
local req, err = codeartsSignedRequest("GET", CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil)
if not req then return nil, err end
local resp, herr = __llmsproxy_http(req)
if herr or not resp or resp.status ~= 200 then
return nil, "账户信息查询失败: " .. tostring(herr or (resp and resp.status))
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil, "账户信息响应无法解析" end
local d = rec.data or rec
local metrics = d.metrics or {}
local total = nil
for _, m in ipairs(metrics) do
-- IDE 从 usageTotalPackageCredit 读总额(参考 codearts-credits.ts 注释)
if type(m) == "table" and m.name == "usageTotalPackageCredit" then
total = tonumber(m.value) or tonumber(m.credit) or total
end
end
local billing = d.billingMode or d.billing_mode or d.chargingMode or ""
return {
isCreditPackage = tostring(billing):upper():find("CREDIT") ~= nil,
isTokenPackage = tostring(billing):upper():find("TOKEN") ~= nil,
total = total,
raw = d,
}, nil
end
-- codeartsClaim(plat, tok, args):完整领取流程(照参考的 6 步判定顺序)。
-- 返回 (requestSpec, nil) 给通用发送路径,或 (nil, err) 提前失败。
-- 注意:参考的流程要发**多个**请求(账户→活动→claim→confirm),而 claim_daily
-- 的契约只发一个。故这里把整段流程跑完并直接落结果,返回一个「不发送」的哨兵,
-- 由 claim_daily 识别 —— 见 CLAIM_DONE 约定。
local function codeartsClaim(plat, tok, args)
local cred = (args and args.credential) or nil
if type(cred) ~= "table" then return nil, "缺少凭据对象(AK/SK 需要结构化字段)" end
-- ① 账户类型:非积分账户 → inactive(Token 账户不该被报成"领取失败")
local info, ierr = codeartsAccountInfo(cred)
if not info then return nil, ierr end
if not info.isCreditPackage then
return { done = true, kind = "inactive",
message = info.isTokenPackage and "Token 计费账户,不在积分活动范围"
or "非积分计费账户,不在积分活动范围" }
end
-- ② 活动列表
local areq = codeartsSignedRequest("GET",
CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil)
if not areq then return nil, "活动列表请求构造失败" end
local aresp, aerr = __llmsproxy_http(areq)
if aerr or not aresp or aresp.status ~= 200 then
return { done = true, kind = "failed",
message = "活动列表查询失败: " .. tostring(aerr or (aresp and aresp.status)) }
end
local aok, arec = pcall(json.decode, aresp.body or "")
local items = aok and type(arec) == "table" and ((arec.data or {}).items) or nil
if type(items) ~= "table" then
return { done = true, kind = "failed", message = "活动列表响应缺少 items" }
end
local act = nil
for _, it in ipairs(items) do
if type(it) == "table" and (it.type or "") == "USER_LOGIN" then act = it break end
end
if not act then
return { done = true, kind = "inactive", message = "未找到每日签到活动" }
end
-- ③ 幂等保护:本协议没有幂等键,预检不可省
if act.claimable ~= true then
local st = tostring(act.status or "")
-- 已领取态(参考 CLAIMED_STATUSES)
local claimed = st == "CLAIMED" or st == "RECEIVED" or st == "COMPLETED"
return { done = true, kind = claimed and "already-claimed" or "inactive",
message = claimed and "今天已领取" or ("当前不可领取(status=" .. st .. ")") }
end
local campaignId = act.campaignId or act.campaign_id or ""
if campaignId == "" then
return { done = true, kind = "failed", message = "活动缺少 campaignId,无法领取" }
end
-- ④ claim
local body = json.encode({ campaignId = campaignId, channel = "IDE" })
local creq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/claim", cred, body)
if not creq then return nil, "claim 请求构造失败" end
local cresp, cerr = __llmsproxy_http(creq)
if cerr or not cresp or cresp.status ~= 200 then
return { done = true, kind = "failed",
message = "领取失败: " .. tostring(cerr or (cresp and cresp.status)) }
end
-- ⑤ 服务端要求确认时补 confirm(判据 benefit.id 非空);confirm 失败不算整体失败
local msg = "已领取"
local cok, crec = pcall(json.decode, cresp.body or "")
local benefit = cok and type(crec) == "table" and (((crec.data or {}).benefit) or nil) or nil
if type(benefit) == "table" and benefit.id then
local fbody = json.encode({ id = benefit.id, channel = "IDE" })
local freq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody)
if freq then
local fresp, ferr = __llmsproxy_http(freq)
-- 只在真正失败时降级提示,不改变整体成败(积分已在待确认态)
if ferr or not fresp or fresp.status ~= 200 then
msg = "已领取(确认步骤失败,可能停在待确认)"
end
end
end
return { done = true, kind = "success", message = msg }
end
-- 绑定 codearts 的领取钩子。它自己跑完 4 步流程(账户→活动→claim→confirm),
-- 用 done 哨兵返回,claim_daily 识别后不再发送单个请求。
do
for _, p in ipairs(PLATFORMS) do
if p.id == "codearts" then
p.claim_fn = function(plat, tok, args) return codeartsClaim(plat, tok, args) end
break
end
end
end
-- 申请的额外出站主机:从平台声明推导(登录域 + 上游域),避免两份清单漂移。
-- 内核里**不含**任何厂商名 —— 这些只存在于本插件,且需运维在设置页逐个批准。
@ -3735,6 +3896,19 @@ function plugin.claim_daily(req)
if custom then spec = custom end
end
-- done 哨兵:平台自己跑完了多步流程(如 codearts 的 账户→活动→claim→confirm),
-- 不再由通用路径发送单个请求。
if type(spec) == "table" and spec.done then
local acct0 = acctEntry or plugin.state.accounts[plat.id] or {}
acct0.last_claim = os.time and os.time() or 0
acct0.last_claim_status = spec.kind == "success" and 200 or 0
acct0.last_claim_summary = spec.message
if acctEntry then poolAdd(plat.id, acctEntry) else plugin.state.accounts[plat.id] = acct0 end
logEvent("领取结束 " .. plat.id .. ": " .. tostring(spec.kind))
return { status = 200, json = { ok = spec.kind == "success",
outcome = { kind = spec.kind, message = spec.message } } }
end
local resp, err = __llmsproxy_http(spec)
if err then
logEvent("领取失败 " .. plat.id .. ": " .. err)
@ -5350,9 +5524,24 @@ do
end
end
-- plugin.__trae_derive_probe 只给测试做差分用(拿参考实现生成的期望值比对)。
-- 不挂 HTTP 路由、不暴露给 UI;它必须在派生函数**定义之后**才存在,
-- 因为它们都是 local,插件的 plugin 表又建得早。
-- plugin.__codearts_sign_probe:只给测试做差分(与参考生成的期望值比对)。
-- 允许传入 date 以对齐参考的固定时间戳。
function plugin.__codearts_sign_probe(args)
args = args or {}
local date = tostring(args.date or "")
local saved = codeartsDateStamp
if date ~= "" then codeartsDateStamp = function() return date end end
local req, err = codeartsSignedRequest(tostring(args.method or "GET"), tostring(args.url or ""),
{ access_key_id = tostring(args.ak or ""), secret_access_key = tostring(args.sk or ""),
security_token = tostring(args.st or "") }, tostring(args.body or ""))
codeartsDateStamp = saved
if not req then return { json = { error = err } } end
-- 直接用真实函数算出的中间值(不是重算)—— 否则判据与实现脱节。
return { json = { canonical = req.canonical, string_to_sign = req.string_to_sign,
signature = hmac_sha256_hex(tostring(args.sk or ""), req.string_to_sign),
signed_headers = req.signed_headers,
authorization = req.headers["Authorization"] } }
end
function plugin.__trae_derive_probe(args)
local uid = tostring((type(args) == "table" and args.uid) or "")
return { json = { devid = traeDeriveDeviceId15(uid),

View File

@ -0,0 +1,113 @@
// 生成 CodeArts 华为云签名派生的差分期望值(供 internal/core/codearts_sign_test.go 比对)。
//
// 为什么需要:channels.lua 里的华为云 SDK-HMAC-SHA256 签名是**手工移植**
// (Lua 没有 webcrypto)。签名错一处,服务端只回一句
// `{"error_code":"APIG.0301","error_msg":"...verify ak sk signature fail"}`,
// 看起来像"账号/权限问题",极难定位。所以拿参考实现生成期望值做逐字节差分。
//
// 参考真值:vendor/channel-pack/src/sign.ts 的 signRequestHuawei。
// 这里**逐行照抄**那个函数(而非 import)—— 它依赖参考仓库的构建链,
// 抄这 40 行更能保证"同一算法"。
//
// 用法(改了参考或改了移植后都要重跑):
// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json
import { createHash, createHmac } from 'node:crypto'
const sha256Hex = (d) => createHash('sha256').update(d).digest('hex')
const hmacSha256Hex = (key, data) => createHmac('sha256', key).update(data).digest('hex')
/** 照抄 sign.ts:signRequestHuawei(dateStamp 提为参数以便固定时间)。 */
function signRequestHuawei(ak, sk, securityToken, method, urlStr, body, dateStamp) {
const url = new URL(urlStr)
let uri = url.pathname
if (!uri.endsWith('/')) uri += '/'
const query = url.search.slice(1)
const payloadHash = sha256Hex(body)
const headers = new Map()
headers.set('host', url.host)
headers.set('x-sdk-date', dateStamp)
headers.set('x-sdk-content-sha256', payloadHash)
headers.set('x-security-token', securityToken)
if (method.toUpperCase() !== 'GET') headers.set('content-type', 'application/json')
const signedHeaders = []
headers.forEach((_, k) => signedHeaders.push(k))
signedHeaders.sort()
const headerLines = signedHeaders.map((k) => `${k}:${headers.get(k) ?? ''}`)
const canonical = [
method,
uri,
query,
headerLines.join('\n'),
'',
signedHeaders.join(';'),
payloadHash,
].join('\n')
const stringToSign = `SDK-HMAC-SHA256\n${dateStamp}\n${sha256Hex(new TextEncoder().encode(canonical))}`
const signature = hmacSha256Hex(
new TextEncoder().encode(sk),
new TextEncoder().encode(stringToSign),
)
return {
canonical,
stringToSign,
signature,
signedHeaders: signedHeaders.join(';'),
}
}
// 覆盖三条真实路径(账户查询 / 活动列表带 query / 领取带 body),
// 以及有/无 security_token 两种形态。
const cases = [
{
m: 'GET',
u: 'https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin',
b: '',
ak: 'AK1',
sk: 'SK1',
st: 'TOK1',
},
{
m: 'GET',
u: 'https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE',
b: '',
ak: 'AK2',
sk: 'SK2',
st: '',
},
{
m: 'POST',
u: 'https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim',
b: '{"campaignId":"C1","channel":"IDE"}',
ak: 'AK3',
sk: 'SK3',
st: 'T3',
},
]
const out = cases.map((c) => {
const r = signRequestHuawei(
c.ak,
c.sk,
c.st,
c.m,
c.u,
Buffer.from(c.b, 'utf8'),
'20261013T150405Z',
)
return {
method: c.m,
url: c.u,
body: c.b,
ak: c.ak,
sk: c.sk,
st: c.st,
canonical: r.canonical,
string_to_sign: r.stringToSign,
signature: r.signature,
signed_headers: r.signedHeaders,
}
})
process.stdout.write(`${JSON.stringify(out, null, 1)}\n`)