Files
fnos-agent-checkin/test_backend.py
T
AgentCheckin 556e45d642 feat(qoder): 弄清国际版每日 100 Credits 的「按设备下发」规则 + 一键设备授权登录
排查结论(本机对真实接口做了 30+ 组对照实验)
- GET /sash/api/v1/me/campaigns 只返回一条常驻 VIEW_DETAILS 广告
  (act-20260901-493,BOGO 订阅推广,claimStatus=CLAIMED 与签到无关)
- 无论 Cosy-MachineToken/Code/Type 用 env 0/3/4/prod、裸调用/带 stdin、
  是否附 Cosy-MachineId/OS/Hostname/Origin/Referer/X-*、是否带 clientType,
  都拿不到 CLAIM_BEFIT 轮次;账号本人 balances/achievements 正常
- 三个社区实现(qoder2api-hub、dsh-connect-qoder、CreditDaddy)一致:
  每日轮次按「登录过官方客户端的那台设备」下发,NAS 侧另造的设备看不到它

修复
- _benefits() 改为只认 actionType=CLAIM_BEFIT(不再把 VIEW_DETAILS 当签到),
  窗口过期也排除;claimStatus 未知按可领处理(领取接口幂等)
- 两段式取活动:先裸取拿 uid → 按 uid 生成风控身份 → 再带设备身份重取
- 状态文案改为「未下发每日签到活动」而不是误导性的「今日已领取」

新功能
- Qoder 一键登录(OAuth 设备授权 / PKCE S256):应用内点按钮 → 浏览器确认一次,
  Token 直接绑定本机设备;同 uid 已存在则更新凭证而不是新增重复账号
- 手工导入桌面端设备身份接口 /api/qoder/import_device
- 诊断接口:/api/qoder/{probe,experiment,growth,matrix,desktop}

测试:39 后端 + 25 E2E + 9 前端全绿
2026-10-19 12:49:06 +08:00

296 lines
15 KiB
Python
Raw 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.
#!/usr/bin/env python3
"""Local dev tests for the AgentCheckin backend (no fnOS required).
Runs with AGENTCHECKIN_DEV=1 so all data lands in a throwaway devdata dir.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
BACKEND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "AgentCheckin", "app", "backend")
DEVROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "AgentCheckin", "devdata")
ENV = dict(os.environ)
ENV["AGENTCHECKIN_DEV"] = "1"
ENV["PYTHONPATH"] = BACKEND
PASS = []
FAIL = []
def check(name, cond, extra=""):
if cond:
PASS.append(name)
print(" PASS %s" % name)
else:
FAIL.append(name)
print(" FAIL %s %s" % (name, extra))
def run_py(code, env=None):
e = dict(ENV)
if env:
e.update(env)
r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
env=e, cwd=os.path.dirname(os.path.abspath(__file__)))
return r.returncode, r.stdout, r.stderr
def cgi(method, uri, body=None, query=""):
"""Invoke api.py the way the CGI bridge does."""
env = dict(ENV)
env["REQUEST_METHOD"] = method
env["REQUEST_URI"] = uri
env["QUERY_STRING"] = query
payload = b""
if body is not None:
payload = json.dumps(body).encode()
env["CONTENT_LENGTH"] = str(len(payload))
r = subprocess.run([sys.executable, os.path.join(BACKEND, "api.py")],
input=payload, capture_output=True, env=env)
out = r.stdout.decode("utf-8", "replace")
# strip CGI headers
if "\r\n\r\n" in out:
out = out.split("\r\n\r\n", 1)[1]
try:
return json.loads(out)
except ValueError:
return {"_raw": out, "_stderr": r.stderr.decode("utf-8", "replace")[-500:]}
def main():
shutil.rmtree(DEVROOT, ignore_errors=True)
print("== 1. common / storage ==")
rc, out, err = run_py(
"import common; print(common.DATA_DIR); print(common.today_str()); print(common.mask_token('abcdefghijklmnopqrstuvwxyz'))")
check("common imports", rc == 0, err[-300:])
if rc == 0:
lines = out.strip().splitlines()
check("dev data dir", lines[0].endswith("devdata"), lines)
check("mask token", lines[2].startswith("abcdef") and lines[2].endswith("wxyz"), lines)
print("== 2. engine account management ==")
rc, out, err = run_py(
"import engine, common;"
"a = engine.add_trae('测试 TRAE', 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyUmVnaW9uIjp7InJlZ2lvbiI6IkNOIn19.sig');"
"b = engine.add_workbuddy_manual('测试 WB', 'fake-access-token-123', 'fake-refresh', 'codebuddy.cn');"
"print(a['id'], b['id']);"
"print(len(engine.load_accounts()))")
check("add accounts", rc == 0 and "2" in out, err[-300:])
ids = out.split() if rc == 0 else []
trae_id, wb_id = (ids[0], ids[1]) if len(ids) >= 2 else ("", "")
print("== 3. dashboard masks tokens ==")
rc, out, err = run_py(
"import engine, json;"
"d = engine.dashboard();"
"s = json.dumps(d, ensure_ascii=False);"
"print('fake-access-token-123' in s, 'fake-refresh' in s);"
"print(len(d['accounts']))")
check("dashboard no raw tokens", rc == 0 and out.startswith("False False"), out + err[-200:])
check("dashboard account count", "2" in out, out)
print("== 4. CGI API routing ==")
r = cgi("GET", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/state")
check("GET /api/state", r.get("today") is not None and len(r.get("accounts", [])) == 2, r)
r = cgi("GET", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/accounts")
check("GET /api/accounts", len(r.get("accounts", [])) == 2, r)
r = cgi("POST", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/accounts/add_trae",
{"name": "API 添加", "access_token": "tok2"})
check("POST add_trae", r.get("ok") is True, r)
r = cgi("POST", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/accounts/update",
{"id": r["account"]["id"], "enabled": False})
check("POST update account", r.get("ok") is True and r["account"]["enabled"] is False, r)
r = cgi("POST", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/accounts/delete",
{"id": r["account"]["id"]})
check("POST delete account", r.get("ok") is True, r)
r = cgi("POST", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/settings",
{"schedule_times": ["08:00", "20:30"], "webhook_url": "", "notify_on": ["fail", "success"]})
check("POST settings", r.get("ok") is True and r["settings"]["schedule_times"] == ["08:00", "20:30"], r)
r = cgi("POST", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/settings",
{"schedule_times": ["bad"]})
check("POST settings rejects bad time", "error" in r, r)
r = cgi("GET", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/nope")
check("unknown route 404", "error" in r, r)
print("== 5. TRAE jwt region decode ==")
rc, out, err = run_py(
"import trae;"
"tok='eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyUmVnaW9uIjp7InJlZ2lvbiI6IkNOIn19.sig';"
"p=trae.jwt_payload(tok); print(p.get('userRegion'))")
check("jwt decode", rc == 0 and "CN" in out, out + err[-200:])
print("== 6. workbuddy token expiry logic ==")
rc, out, err = run_py(
"import workbuddy, time;"
"print(workbuddy.token_expiring({'expiresAt': int(time.time()) + 3600}));"
"print(workbuddy.token_expiring({'expiresAt': int(time.time()) + 30*86400}));"
"print(workbuddy.token_expiring({'expiresAt': 0}));"
"print(workbuddy.is_global({'domain':'workbuddy.ai'}), workbuddy.is_global({'domain':'codebuddy.cn'}))")
check("token expiry", rc == 0 and out.split() == ["True", "False", "False", "True", "False"], out + err[-200:])
print("== 7. live network: workbuddy oauth state endpoint ==")
rc, out, err = run_py(
"import workbuddy;"
"state, url, err = workbuddy.begin_oauth();"
"print(bool(state), bool(url), err)")
if rc == 0 and out.startswith("True True None"):
check("oauth begin (live)", True)
else:
check("oauth begin (live)", False, out + err[-300:])
print("== 8. live network: trae status with bogus token -> no_session ==")
rc, out, err = run_py(
"import trae;"
"acc={'id':'t1','auth':{'accessToken':'bogus.token.value'}};"
"r=trae.checkin_once(acc); print(r['status'], r['message'][:40])")
check("trae bogus token handled", rc == 0 and ("no_session" in out or "fail" in out or "retry_later" in out),
out + err[-300:])
print("== 9. live network: workbuddy status with bogus token ==")
rc, out, err = run_py(
"import engine;"
"acc=engine.add_workbuddy_manual('bogus','bogus-token');"
"r=engine._run_workbuddy(acc); print(r['status'], r['message'][:40])")
check("wb bogus token handled", rc == 0 and r_status_ok(out), out + err[-300:])
print("== 10. daemon tick (engine mocked, empty results) ==")
mock = (
"import daemon, engine, common\n"
"calls = []\n"
"def fake_run(account_id=None, reason='x'):\n"
" calls.append(reason)\n"
" st = common.load_state()\n"
" st.setdefault('today', {})[common.today_str()] = {}\n" # empty results (no accounts)
" common.save_state(st)\n"
" return {}\n"
"engine.run_all = fake_run\n"
"daemon.tick()\n"
"daemon.tick()\n"
"daemon.tick()\n"
"print(calls)\n"
)
rc, out, err = run_py(mock)
# first tick = catch-up (marks passed slots); later ticks must be silent
check("daemon catch-up runs once even with empty results",
rc == 0 and out.strip() == "['startup-catchup']", out + err[-300:])
print("== 11. webhook payload builders ==")
rc, out, err = run_py(
"import engine, json\n"
"cases = [\n"
" ('auto', 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x'),\n"
" ('auto', 'https://oapi.dingtalk.com/robot/send?access_token=x'),\n"
" ('auto', 'https://open.feishu.cn/open-apis/bot/v2/hook/x'),\n"
" ('auto', 'https://api.day.app/ABCKEY/'),\n"
" ('auto', 'https://example.com/hook'),\n"
" ('wecom', 'https://example.com/hook'),\n"
" ('bark', 'https://api.day.app/ABCKEY'),\n"
"]\n"
"for wt, u in cases:\n"
" url, body = engine.notify_payload(wt, u, 'TITLE', 'CONTENT')\n"
" print(url + '||' + (body.decode() if body else 'GET'))\n"
)
lines = out.strip().splitlines() if rc == 0 else []
check("webhook: wecom payload", len(lines) > 0 and json.loads(lines[0].split("||")[1]) == {"msgtype": "text", "text": {"content": "TITLE\nCONTENT"}}, out + err[-200:])
check("webhook: dingtalk payload", len(lines) > 1 and json.loads(lines[1].split("||")[1]) == {"msgtype": "text", "text": {"content": "TITLE\nCONTENT"}}, out)
check("webhook: feishu payload", len(lines) > 2 and json.loads(lines[2].split("||")[1]) == {"msg_type": "text", "content": {"text": "TITLE\nCONTENT"}}, out)
check("webhook: bark url-path GET", len(lines) > 3 and lines[3] == "https://api.day.app/ABCKEY/TITLE/CONTENT||GET", out)
check("webhook: generic payload", len(lines) > 4 and json.loads(lines[4].split("||")[1]).get("title") == "TITLE", out)
check("webhook: manual type override", len(lines) > 5 and json.loads(lines[5].split("||")[1]).get("msgtype") == "text", out)
check("webhook: bark without trailing slash", len(lines) > 6 and lines[6] == "https://api.day.app/ABCKEY/TITLE/CONTENT||GET", out)
print("== 12. qoder module (offline logic) ==")
rc, out, err = run_py(
"import qoder, json, base64, time\n"
"# device headers with defaults + overrides\n"
"acc = {'auth': {'accessToken': 'x'}, 'device': {'machineId': 'MID', 'version': '1.2.3'}}\n"
"h = qoder.device_headers(acc)\n"
"print('clientType=%s machineId=%s version=%s emptyToken=%r' % (h['Cosy-ClientType'], h['Cosy-MachineId'], h['Cosy-Version'], h['Cosy-MachineToken']))\n"
"# jwt helpers\n"
"payload = base64.urlsafe_b64encode(json.dumps({'exp': 9999999999, 'sub': 'u123'}).encode()).decode().rstrip('=')\n"
"tok = 'h.' + payload + '.s'\n"
"print('exp:', qoder.jwt_exp(tok), 'uid:', qoder.jwt_uid(tok))\n"
"# global detection\n"
"print('global:', qoder.is_global({'domain': 'openapi.qoder.sh'}), qoder.is_global({'domain': 'openapi.qoder.com.cn'}))\n"
"# 国际版同一账号还挂着 VIEW_DETAILS 的订阅推广(claimStatus=CLAIMED,无 benefit)\n"
"# —— 它不是签到活动,必须被排除\n"
"intl = [{'actionType': 'VIEW_DETAILS', 'claimStatus': 'CLAIMED', 'campaignId': 'i1'}]\n"
"print('intl_benefits:', len(qoder._benefits(intl)))\n"
"# 每日轮次:actionType=CLAIM_BENEFIT、窗口开放\n"
"now0 = int(time.time())\n"
"daily = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMABLE', 'campaignId': 'd1',\n"
" 'startAt': now0 - 60, 'endAt': now0 + 600}]\n"
"print('daily_benefits:', len(qoder._benefits(daily)))\n"
"# 过期轮次要排除\n"
"expired = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMABLE', 'campaignId': 'd2',\n"
" 'startAt': now0 - 600, 'endAt': now0 - 60}]\n"
"print('expired_benefits:', len(qoder._benefits(expired)))\n"
"# 国内版 schema\n"
"campaigns = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMABLE', 'campaignId': 'c1', 'benefit': {'amount': 100}}]\n"
"b = qoder._benefits(campaigns)\n"
"print('benefits:', len(b), 'claimable:', len([c for c in b if c.get('claimStatus') == 'CLAIMABLE']))\n"
"# already-claimed detection\n"
"now = int(time.time())\n"
"c2 = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMED', 'startAt': now - 100, 'endAt': now + 100}]\n"
"bb = qoder._benefits(c2)\n"
"print('claimed_today:', len([c for c in bb if c.get('claimStatus') == 'CLAIMED' and (c.get('startAt') or 0) <= now < (c.get('endAt') or 0)]))\n"
)
lines = out.splitlines() if rc == 0 else []
check("qoder device headers", lines and lines[0] == "clientType=10 machineId=MID version=1.2.3 emptyToken=''", out + err[-200:])
check("qoder jwt helpers", len(lines) > 1 and lines[1] == "exp: 9999999999 uid: u123", out)
check("qoder global detection", len(lines) > 2 and lines[2] == "global: True False", out)
check("qoder campaign parsing", "benefits: 1 claimable: 1" in out, out)
intl_line = next((l for l in lines if l.startswith("intl_benefits:")), "")
check("qoder intl: VIEW_DETAILS excluded", intl_line == "intl_benefits: 0", out)
check("qoder daily CLAIM_BENEFIT kept", "daily_benefits: 1" in out, out)
check("qoder expired round excluded", "expired_benefits: 0" in out, out)
check("qoder already-claimed detection", "claimed_today: 1" in out, out)
print("== 13. engine: add qoder account + token masking ==")
rc, out, err = run_py(
"import engine, json\n"
"a = engine.add_qoder('Qoder 测试', 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1MSJ9.sig', 'rt123', {'machineId': 'MID', 'machineToken': 'MTOK'})\n"
"print(a['id'], a['platform'], a['device']['machineId'])\n"
"d = engine.dashboard()\n"
"s = json.dumps(d, ensure_ascii=False)\n"
"print('masked:', 'eyJhbGciOiJSUzI1NiJ9' not in s and 'rt123' not in s)\n"
)
check("engine add qoder", rc == 0 and "qoder_" in out and "MID" in out, out + err[-200:])
check("qoder token masked in dashboard", "masked: True" in out, out)
print("== 14. umid module (offline) ==")
rc, out, err = run_py(
"import umid\n"
"st = umid.status()\n"
"print('supported:', st['supported'], '| arch:', st['cpu_arch'])\n"
"print('installed:', st['installed'])\n"
"import io, tarfile\n"
"buf = io.BytesIO()\n"
"with tarfile.open(fileobj=buf, mode='w') as tf:\n"
" info = tarfile.TarInfo('package/bundle/qodercli.js'); info.size = 5\n"
" tf.addfile(info, io.BytesIO(b'hello'))\n"
"print('tar_extract_ok:', umid._extract_tar_entry(buf.getvalue(), 'package/bundle/qodercli.js') == b'hello')\n"
)
lines = out.splitlines() if rc == 0 else []
check("umid: arch supported", len(lines) > 0 and "supported: True" in lines[0], out + err[-200:])
check("umid: tar extraction", len(lines) > 2 and "tar_extract_ok: True" in lines[2], out)
print()
print("PASSED: %d FAILED: %d" % (len(PASS), len(FAIL)))
if FAIL:
print("failures:", FAIL)
shutil.rmtree(DEVROOT, ignore_errors=True)
return 1 if FAIL else 0
def r_status_ok(out):
return any(s in out for s in ("no_session", "fail", "ok", "already"))
if __name__ == "__main__":
sys.exit(main())