From 749603160c184901a918a8f48a79b719431181ae Mon Sep 17 00:00:00 2001 From: yunwang2442-creator Date: Sat, 7 Nov 2026 12:14:35 +0800 Subject: [PATCH] autoclaw 0.2.17: name the campaign points on the card, and stop the check-in from signing out healthy accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things: a usage-card feature for campaign (activity) points, and a set of post-merge check-in fixes that 0.2.15 shipped without — found by review, each guarded by a test that goes red when the guard is removed. The card (when an account has campaign ledger activity) - new aside row: 剩 4,000(AutoClaw Weekly Perks) · 已领 14,000 · 过期 7,711(近期) reads GET /agent-assetmgr/api/v1/ledgers_std?wallet_scope=campaign (same headers as the wallets call, 8s timeout per host, redirect blocked). The server may ignore the filter, so entries are re-filtered by wallet_scope client-side; the server also opens one wallet instance per activity, so remaining is per instance: credit − debit − expire, clamped at 0, and an instance whose credit left the ~151-entry window counts 0 (its expiry still lands in 过期), so the number errs low, never high. - 已领/过期 are window sums (~18 days), hence (近期). Numbers are grouped in threes by a deterministic formatter (no toLocaleString). - cached per account for 10 minutes; a failed read is not cached and the next usage() retries; an in-flight read landing after the invalidation is not written back; the cache is capped at 100 entries, evicting the oldest settled entry and never an in-flight one. - the row never carries `balance`: a window balance is a prepaid routing pool (magpie #3950) and would double-count what 积分余额 reports. The check-in fixes (0.2.15 regressions, from review) - dailySignin no longer throws: its 401/403 throw escaped maybeSignin out of auth.refresh and usage() after the refresh token had rotated, and magpie's renewLocked then signed out a healthy account and dropped the fresh tokens. It returns null on any failure; the classification (quota 403 vs a real auth refusal) lives in taskList alone. - the CAS write-back writes only when the record is still the same oauth record, so a logged-out account is never resurrected. - a quota-exhausted 403 at task-list keeps the sign-in and burns no rotation; a second 401 right after a successful renewal degrades to a retryable failure (no `transient` marker); a mixed host failure keeps the expired marker either order; an empty task list on the first host no longer ends the day as inactive. - refreshShared records every rotation into the in-memory memo, so a stale holder of a spent RT gets the rotated pair instead of burning a network call on a dead token; an expired memo entry falls through to a real refresh. - refreshTokens survives a network error on the first host (bans still throw at once). - maybeSignin dedupes by the Beijing day (the sign-in's own boundary), not the UTC day. - the config dir resolves XDG → HOME → USERPROFILE → os.homedir(), so on Windows (HOME unset) the usage state no longer lands at C:\.config\magpie. - user-info reaching the server requires res.ok AND code===0, and the 活动奖励未触发 note is appended to the check-in message instead of replacing it. - the invented "+200" on an unsigned day is gone. Version 0.2.17: #800 took 0.2.16. This branch is based on origin/main 1d6aa50. Tests: 100 pass / 0 fail (bun test packages/autoclaw). Every fix above is mutation-tested red — the Beijing-day swap, the memo recording, the failed-read cache, the eviction cap and its in-flight exemption, the CAS logout, the quota-403 classification, the post-renewal degradation, and the fake-clock guard all turn at least one test red when broken. bun scripts/check.mjs passes. Live read-only check on two accounts (Windows): the row renders as specified, the check-in is idempotent done with zero refresh calls. macOS untested here; the reviewer ran the repo suite on macOS and WSL. --- packages/autoclaw/README.md | 38 +- packages/autoclaw/index.mjs | 420 +++++++++++--- packages/autoclaw/index.test.mjs | 937 ++++++++++++++++++++++++++++++- packages/autoclaw/package.json | 2 +- 4 files changed, 1295 insertions(+), 102 deletions(-) diff --git a/packages/autoclaw/README.md b/packages/autoclaw/README.md index f1e527ab9..f81742af4 100644 --- a/packages/autoclaw/README.md +++ b/packages/autoclaw/README.md @@ -39,16 +39,18 @@ OpenCode 同样可加载(`opencode auth login`)。登录信息保存在 magp 同时作为预付钱包窗口 `{ name: "积分余额", balance, unit: "积分" }` 上报(magpie ≥ v0.1.1790, #3950):路由按真实余额认识和排位账号,不发明百分比,余额 0 算用完;更早的 magpie 不认识 `balance`,账号维持原状。 - 上游没有积分流水/用量接口(wallets.v2 只有余额),「本轮消耗」窗口**由本地余额差值算**: + 上游没有按模型的积分流水接口(wallets.v2 只有余额)。「本轮消耗」窗口**由本地余额差值算**—— + 活动行另从 `ledgers_std` 拉取(见下文「每日签到」一节)。 每次 `usage()` 记下本轮峰值,`峰值 − 当前 = 本轮已消耗`,**任何上涨**(相对上次 观察到的余额,含从 0 回升——每日签到、礼包、兑换)都开新一轮,不等涨过旧峰值(签到常在花费 之后到)。因为每天签到都会开新一轮,「本轮」≈「自今天签到以来」。这个窗口带 `aside: true`,只展示、不参与路由:它的百分比是「自上次上涨以来花掉的比例」,不是池子剩余。 `total_balance` 为 null/空串/非数时按未知处理(「积分响应异常」),不记账。 - 状态按账号(键复用 `signinAccountKey`,#254:uid,没 uid 按 refresh token 哈希)存在 - `$XDG_CONFIG_HOME/magpie/autoclaw-usage.json`(或 `~/.config/magpie/`,`AUTOCLAW_USAGE_STATE_FILE` - 可改址),临时文件 + rename 写入,30 天没动的条目清走;插件更新不会丢。 - 测试可用 `_internal.setUsageStatePath` 把状态文件指到临时目录。 + 状态按账号(键复用 `signinAccountKey`,#254:uid,没 uid 按 refresh token 哈希)存 JSON, + 地址优先级:`AUTOCLAW_USAGE_STATE_FILE`(显式覆盖)→ `$XDG_CONFIG_HOME/magpie/` → + `$HOME/.config/magpie/`(Windows 上 HOME 常未设,改用 `USERPROFILE`,再兜底 `os.homedir()`, + 绝不落在盘根 `C:\.config\magpie`)→ 文件名 `autoclaw-usage.json`。写入走临时文件 + rename, + 30 天没动的条目清走;插件更新不会丢。测试可用 `_internal.setUsageStatePath` 指到临时目录。 - **国际版登录是完整本地实现**:插件在 127.0.0.1 上取官方 TokenServer 端口池中的一个端口 (18432/19654/19723/53699,OAuth client 回调白名单;被占自动顺延)起临时服务器,内嵌引导页完成 阿里滑块验证(`AliyunCaptcha` SDK,`ali_captcha_verify_param` 回执)→ 获取授权页 → @@ -62,14 +64,36 @@ OpenCode 同样可加载(`opencode auth login`)。登录信息保存在 magp **① magpie 原生签到(magpie ≥ v0.1.1083,推荐)**:插件实现 `auth.checkin` 钩子,走 magpie 统一调度——Settings → Usage → Plugins 里每个供应商有 **Daily check-in** 开关(默认关),打开后每天每账号自动签一次(北京时间;启动 2 分钟后首查、每 30 分钟轮询,失败 30 分钟后重试),账户 Usage 卡片显示签到结果,也可 **Check in now** 手动签。 -**② 旧链路自动补签(默认关)**:查额度(`magpie quota`、GUI 刷新)与 token 刷新时发现未签就补签(服务端幂等,本地按天去重、失败按小时重试)。旧版 magpie / OpenCode 宿主或偏好该行为的用户开启 `signinOnUsage`。 +**② 旧链路自动补签(默认关)**:查额度(`magpie quota`、GUI 刷新)与 token 刷新时发现未签就补签(服务端幂等,本地按北京日去重;失败无节流——下次查额度或刷新时立即重试)。旧版 magpie / OpenCode 宿主或偏好该行为的用户开启 `signinOnUsage`。 -`magpie quota` 的额度窗口在查询成功时显示**每日签到**状态行(今日已签 ✓ / 今日未签 +N)。 +`magpie quota` 的额度窗口在查询成功时显示**每日签到**状态行(今日已签 ✓ / 今日未签(+N))。 > **「登录领积分」类活动奖励**:服务端只为带整套官方桌面端业务头(`X-Channel: AutoClaw20`、`X-Version: 2.2.0`、UA `AutoClaw/2.2.0 (win-x64)`、`X-Tm: win-x64`、`X-Product` 等;所有平台都按这套 Windows 指纹上报)的 `POST /userapi/v1/user-info` 记录 `autoclaw_user_active` 活跃事件——没有这次调用,这类活动就领不到(yetone/magpie#3681)。本次实测到账:国际「AutoClaw Weekly Perks」+4000、国内「AutoClaw 用户登录奖励」+5000。 > > **只有 ① 原生签到链路会调用 `user-info`**:用当前 access token 直接请求,access token 被上游拒绝时才刷新重试;`user-info` 本身不完成每日签到,每日签到仍由 `task-complete` 完成,两者相互独立。② 旧链路补签与不执行 `checkin` 钩子的宿主(OpenCode 1.x/2.x)不会领取活动奖励。 +### 活动余额行(活动积分) + +账号有活动流水时(领过、花过或失效过活动积分),`usage()` 返回的窗口列表末尾多一行: + +```text +剩 4,000(AutoClaw Weekly Perks) · 已领 14,000 · 过期 7,711(近期) +``` + +- **数据来源**:`GET /agent-assetmgr/api/v1/ledgers_std?biz_app_id=autoclaw&page_size=200&wallet_scope=campaign`。 + 服务端给每个活动开独立的 `wallet` 实例(`wallet_id`),credit / debit / expire 三种流水 + 都带 `wallet_scope=campaign`;服务端可能忽略过滤参数,客户端按 scope 再过滤一遍。 +- **剩 的算法**:按实例 `credit − debit − expire`(截 0)。入账掉出窗口的旧实例按 0 计 + (其失效额仍计入「过期」)——**「剩」永远不会虚高**,但窗口外的旧入账不参与计算。 +- **已领 / 过期**是窗口内(最近 ~151 条、约 18 天,翻页参数无效)的合计,标注(近期)。 +- **缓存**:按账号键(`signinAccountKey`)10 分钟;`checkin` 的 user-info 到达服务端时 + 立即失效(活动可能刚到账);读取失败不缓存(下次 `usage()` 立即重试);协议变更 + (entries 非数组)按未知处理、缓存 10 分钟不重试。任何读取失败只省略该行, + 绝不影响余额与签到行。 +- **活动行不携带 `balance`**:窗口的 `balance` 会被 magpie(≥ v0.1.1790)当作预付路由池 + (#3950)重复计入路由。 +- **数字三位分组**(`fmtPoints`,不依赖 locale);余额行里的细分钱包数值暂不分组。 + 旧链路的开关有**两个通道**(插件 options 优先,配置文件兜底;原生签到开关只在 GUI 的 Settings 里): ```sh diff --git a/packages/autoclaw/index.mjs b/packages/autoclaw/index.mjs index d08361024..b2153fc0f 100644 --- a/packages/autoclaw/index.mjs +++ b/packages/autoclaw/index.mjs @@ -609,22 +609,28 @@ function tokenFields(data, fallbackRefresh) { // 签到与积分 // --------------------------------------------------------------------------- -// 上游没有积分流水/用量接口(wallets.v2 只有余额,task-list 只有签到), -// 「已用」只能用本地余额差值算:每次 usage() 观察一次余额,记下本轮峰值, -// 峰值 − 当前 = 本轮已消耗;任何上涨(相对上次余额,含从 0 回升——池子花光 -// 后签到恰恰最常见)都开新一轮,峰值即当前、used 归零。 +// 上游没有按模型的用量接口(wallets.v2 只有余额;ledgers_std 只有按 wallet +// 实例的积分流水,没有模型维度),「已用」只能用本地余额差值算:每次 usage() +// 观察一次余额,记下本轮峰值,峰值 − 当前 = 本轮已消耗;任何上涨(相对上次 +// 余额,含从 0 回升——池子花光后签到恰恰最常见)都开新一轮,峰值即当前、 +// used 归零。 // 状态放 magpie 的配置目录(~/.config/magpie 或 $XDG_CONFIG_HOME/magpie,跟 // codearts 的台账一个思路):插件更新不会删掉它,CI 的 HOME=$(mktemp -d) 也 // 自然隔离;AUTOCLAW_USAGE_STATE_FILE 可改址。写入走临时文件 + rename,并发 // 写不会留下半份文件;30 天没动的条目顺手清走(没 uid 的账号键是 refresh // 哈希,token 一刷新就换键,旧条目靠这个兜底)。北京日界另记当日 −消耗/+入账。 let usageStatePath; +function magpieCfgDir() { + // Windows 上 HOME 常未设(magpie 启动的进程只有 USERPROFILE),裸 `${HOME ?? ""}` + // 会把状态文件写到盘根 C:\.config\magpie(uid 可读、任意本地用户可改)。 + // 顺序:显式覆盖 → XDG → HOME → USERPROFILE → os.homedir()。 + if (process.env.XDG_CONFIG_HOME) return `${process.env.XDG_CONFIG_HOME}/magpie`; + const home = nonEmptyString(process.env.HOME) ?? nonEmptyString(process.env.USERPROFILE) ?? homedir(); + return `${home}/.config/magpie`; +} function usageStatePathDefault() { if (process.env.AUTOCLAW_USAGE_STATE_FILE) return process.env.AUTOCLAW_USAGE_STATE_FILE; - const cfgDir = process.env.XDG_CONFIG_HOME - ? `${process.env.XDG_CONFIG_HOME}/magpie` - : `${process.env.HOME ?? ""}/.config/magpie`; - return `${cfgDir}/autoclaw-usage.json`; + return `${magpieCfgDir()}/autoclaw-usage.json`; } let usageState; /** 测试注入:指向临时目录,并丢掉内存缓存,测试之间互不沿用。 */ @@ -719,7 +725,150 @@ async function fetchWallets(accessToken, hosts) { return null; } -/** 每日签到(幂等)。返回解析后的结果(可能为 null)。 */ +// --------------------------------------------------------------------------- +// 活动积分(「登录领积分」类活动按 wallet 实例对账) +// --------------------------------------------------------------------------- + +/** + * GET /agent-assetmgr/api/v1/ledgers_std?wallet_scope=campaign —— 活动钱包的流水。 + * + * 服务端给每个活动开独立的 wallet 实例(wallet_id),credit / debit / expire + * 三种流水各带顶层 wallet_scope(2026-11-06 真机:消耗也按实例扣),所以 + * 「活动还剩多少」能按实例精确算。窗口只有最近 ~151 条(page_size 上限 200、 + * 翻页参数无效),活动都限时 ≤7 天,活着的活动的完整生命周期都在窗口内。 + * 服务端可能忽略 wallet_scope 过滤参数 → campaignBreakdown 必须再过滤一次。 + * 返回 entries 数组(可为空)或 null(查询失败/协议变更)——绝不抛错: + * 活动行只是展示,读不到就省略,余额/签到行不受影响。 + */ +const CAMPAIGN_LEDGER_PATH = `/agent-assetmgr/api/v1/ledgers_std?biz_app_id=${PRODUCT}&page_size=200&wallet_scope=campaign`; +/** + * 拉取活动流水。三个 host × 一个请求(不翻页:next_page_token 是摆设, + * 窗口就是最近 ~151 条)。 + * 成功:返回 entries 数组(可为空 = 真没有活动历史)。 + * 失败:**throw**——campaignCached 把这次读当作「未缓存」,下次 usage() 重试; + * 绝不把「查不到」伪装成「0 条」,也没必要让读不到的账号被卡片骗。 + */ +async function fetchCampaignLedger(accessToken, hosts) { + let lastErr = null; + for (const host of dedupe(hosts)) { + try { + const res = await fetch(`${host}${CAMPAIGN_LEDGER_PATH}`, { + method: "GET", + headers: bizHeaders(accessToken), + redirect: "error", + signal: AbortSignal.timeout(8_000), + }); + if (!res.ok) { lastErr = new Error(`HTTP ${res.status}`); continue; } + const env = await res.json().catch(() => null); + if (!env || env.code !== 0 || !env.data) { lastErr = new Error(`envelope code ${env?.code ?? "?"}`); continue; } + if (!Array.isArray(env.data.entries)) return null; // 协议变了:按未知处理(不当错误重试) + return env.data.entries; // 成功(含空数组:真的没有活动历史) + } catch (e) { + lastErr = e; // 网络/超时:换下一个 host + } + } + throw lastErr ?? new Error("AutoClaw campaign ledger: all hosts failed"); +} + +/** 活动名标签:「AutoClaw Weekly Perks +4000 credits」→「AutoClaw Weekly Perks」。 */ +function campaignLabel(entry, md) { + const raw = String(md?.description ?? entry?.description ?? "").trim(); + return raw.replace(/\s*\+\s*[\d.,]+\s*(?:credits?|积分)?\s*$/i, "").trim(); +} + +/** + * 活动流水 → { claimed, expired, remaining, labels }(窗口内事实)。 + * 按 wallet 实例分组:每实例 remaining = credit − debit − expire(截 0)。 + * 入账掉出窗口的旧实例(窗口内只剩消耗/失效)按 0 计,其失效额仍进「过期」。 + */ +function campaignBreakdown(entries) { + const instances = new Map(); // wallet_id → { credit, debit, expire, label } + let claimed = 0; + let expired = 0; + for (const e of Array.isArray(entries) ? entries : []) { + if (!e || typeof e !== "object") continue; + const md = e.metadata && typeof e.metadata === "object" ? e.metadata : {}; + const scope = String(e.wallet_scope ?? md.wallet_scope ?? "").trim().toLowerCase(); + if (scope !== "campaign") continue; // 服务端可能忽略过滤参数,必须再过滤 + const type = String(e.mutation_type ?? "").trim().toLowerCase(); + if (type !== "credit" && type !== "debit" && type !== "expire") continue; + const amount = Number(e.amount); + if (!Number.isFinite(amount)) continue; + const id = String(e.wallet_id ?? "?"); + let inst = instances.get(id); + if (!inst) { inst = { credit: 0, debit: 0, expire: 0, label: "" }; instances.set(id, inst); } + if (type === "credit") { + inst.credit += amount; + claimed += amount; + const label = campaignLabel(e, md); + if (label) inst.label = label; + } else if (type === "debit") { + inst.debit += Math.abs(amount); + } else { + inst.expire += Math.abs(amount); + expired += Math.abs(amount); + } + } + if (instances.size === 0) return null; // 没有活动历史 + let remaining = 0; + const labels = []; + for (const inst of instances.values()) { + const left = inst.credit - inst.debit - inst.expire; + if (left > 0) { remaining += left; if (inst.label) labels.push(inst.label); } + } + return { claimed, expired, remaining, labels: [...new Set(labels)] }; +} + +/** 活动积分的三位分组(确定性手写,不用 toLocaleString——CI/locale 无关)。 */ +const fmtPoints = (n) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ","); + +/** + * 活动流水缓存:按账号键(signinAccountKey),10 分钟 TTL(codearts + * CREDIT_BATCHES_TTL_MS 同款节流)。**读取失败(reject)不缓存**—— + * 下一次 usage() 立即重试;成功(含空数组:真的没有活动历史)才缓存; + * 协议变更(entries 非数组)按 null 缓存(重试也一样,不值得反复打)。 + * checkin 里 user-info 到达服务端时立即失效(活动可能刚到账);在途读 + * 迟于失效返回时**不写回**(刚领的奖励不能被旧读覆盖回缓存)。 + */ +const CAMPAIGN_TTL_MS = 10 * 60 * 1000; +const CAMPAIGN_CACHE_MAX = 100; +const campaignCache = new Map(); // accountKey → { at, value, promise } +function campaignCached(key, loader) { + const held = campaignCache.get(key); + if (held?.promise) return held.promise; + if (held && Date.now() - held.at < CAMPAIGN_TTL_MS) return Promise.resolve(held.value); + // 淘汰最旧的「已定」条目(跳过在途读——撕掉在途 gate 会让该次读的结果无处 + // 写回,白打一次网络);全在途时宁可暂时超上限。长驻进程里账号数量不定, + // 不设上限会无限堆积。 + while (campaignCache.size >= CAMPAIGN_CACHE_MAX) { + let evicted = false; + for (const [k, g] of campaignCache) { + if (!g.promise) { campaignCache.delete(k); evicted = true; break; } + } + if (!evicted) break; + } + const gate = { at: 0, value: null, promise: null }; + gate.promise = loader().then( + (v) => { + gate.promise = null; + gate.at = Date.now(); + gate.value = v; + // 只在仍是持有者时写回:失效可能发生在读取途中。 + if (campaignCache.get(key) === gate) campaignCache.set(key, gate); + return v; + }, + () => { + if (campaignCache.get(key) === gate) campaignCache.delete(key); + return null; + }, + ); + campaignCache.set(key, gate); + return gate.promise; +} + +/** 每日签到(幂等)。失败(含封禁/登录失效/额度用尽)一律返回 null—— + * 补签只是尽力而为,封禁/失效由 taskList 的检查先行暴露(checkin 里任务列表 + * 先于 task-complete 调用),这里无需再抛。 */ async function dailySignin(accessToken, host) { try { const res = await fetch(`${host}/autoclaw-proxy/proxy/autoclaw-task-complete`, { @@ -728,6 +877,7 @@ async function dailySignin(accessToken, host) { body: JSON.stringify({ task_id: "daily_signin", source_id: PRODUCT }), signal: AbortSignal.timeout(15_000), }); + if (!res.ok) return null; const env = await res.json().catch(() => null); return env?.data ?? null; // { success, already_completed, reward_points, continuous_days } } catch { @@ -743,8 +893,17 @@ async function taskList(accessToken, host) { signal: AbortSignal.timeout(15_000), }); if (res.status === 401 || res.status === 403) { - const banned = bannedText(await res.text().catch(() => "")); + const text = await res.text().catch(() => ""); + const banned = bannedText(text); if (banned) throw Object.assign(new Error(`AutoClaw: ${banned}`), { banned: true, signIn: "kept" }); + // 额度用尽的 403(810000 /「额度已用完」)是账号的量用完了,不是登录失效: + // 与对话链路同一口径(QUOTA_CODES/QUOTA_WORDS),keep 登录、429 原样带文案。 + let d; + try { d = JSON.parse(text); } catch { d = null; } + const { code, message } = said(d); + if (res.status === 403 && (QUOTA_CODES.has(String(code)) || (message && QUOTA_WORDS.test(message)))) { + throw Object.assign(new Error(`AutoClaw: ${message || "quota exhausted"}`), { quota: true, signIn: "kept" }); + } // 非封禁的登录失效:标记 expired,让 checkin 能用 refresh token 重试一次, // 也让 magpie 不再把过期账号当成普通失败重试一整天。 throw Object.assign(new Error(`登录已失效(HTTP ${res.status})`), { signIn: "expired" }); @@ -805,33 +964,39 @@ function checkinOutcome(r) { */ async function callUserInfo(accessToken, deviceId, base) { const ts = String(Math.floor(Date.now() / 1000)); - const res = await fetch(`${base}/userapi/v1/user-info`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "*/*", - "User-Agent": "AutoClaw/2.2.0 (win-x64)", - "X-Version": "2.2.0", - "X-Tm": "win-x64", - "X-Product": PRODUCT, - "X-Auth-Appid": APP_ID, - "X-Auth-TimeStamp": ts, - "X-Auth-Sign": md5Hex(`${APP_ID}&${ts}&${APP_KEY}`), - "X-Trace-Id": randomHex(16), - "X-Lang": "zh-CN", - "X-Channel": "AutoClaw20", - "Authorization": `Bearer ${accessToken}`, - }, - body: JSON.stringify({ source_id: PRODUCT, device_id: deviceId }), - signal: AbortSignal.timeout(15_000), - }); + let res; + try { + res = await fetch(`${base}/userapi/v1/user-info`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "*/*", + "User-Agent": "AutoClaw/2.2.0 (win-x64)", + "X-Version": "2.2.0", + "X-Tm": "win-x64", + "X-Product": PRODUCT, + "X-Auth-Appid": APP_ID, + "X-Auth-TimeStamp": ts, + "X-Auth-Sign": md5Hex(`${APP_ID}&${ts}&${APP_KEY}`), + "X-Trace-Id": randomHex(16), + "X-Lang": "zh-CN", + "X-Channel": "AutoClaw20", + "Authorization": `Bearer ${accessToken}`, + }, + body: JSON.stringify({ source_id: PRODUCT, device_id: deviceId }), + signal: AbortSignal.timeout(15_000), + }); + } catch { + return null; // 网络/超时:换下一个 host + } let parsed; try { parsed = JSON.parse(await res.text()); } catch { parsed = null; } - return parsed; + // code=0 且 HTTP 2xx 才算到达;401 带 code:0 的怪响应不算(假成功)。 + return { ok: res.ok, code: parsed?.code, msg: parsed?.msg ?? parsed?.message }; } -/** 本地去重键(成功才记;按小时允许重试)。 */ -const signinTriedLocal = new Map(); // key → hour-stamp of last SUCCESSFUL signin +/** 本地去重键(成功才记;按北京日允许重试)。 */ +const signinTriedLocal = new Map(); // key → 北京日戳 of last SUCCESSFUL signin /** * 补签去重键:每个登录一个(#254)。显示名可以重复(AutoClaw 默认昵称改不了), @@ -856,10 +1021,7 @@ function signinConfig(options) { let fileCfg = {}; try { // magpie 配置目录固定(文档:~/.config/magpie 或 $XDG_CONFIG_HOME/magpie) - const cfgDir = process.env.XDG_CONFIG_HOME - ? `${process.env.XDG_CONFIG_HOME}/magpie` - : `${process.env.HOME ?? ""}/.config/magpie`; - fileCfg = JSON.parse(readFileSync(`${cfgDir}/autoclaw.json`, "utf8")); + fileCfg = JSON.parse(readFileSync(`${magpieCfgDir()}/autoclaw.json`, "utf8")); } catch { /* 无文件/解析失败 → 用默认 */ } const merged = { ...fileCfg, ...(options ?? {}) }; return { @@ -875,13 +1037,15 @@ async function maybeSignin(accessToken, host, options, reason, accountKey) { const cfg = signinConfig(options); if (!cfg.signin || !cfg.signinOnUsage) return; if (cfg.signinHour !== null && new Date().getHours() !== cfg.signinHour) return; - const hourStamp = `${new Date().toISOString().slice(0, 13)}`; // YYYY-MM-DDTHH(每小时可重试一次) + // 日界与签到活动一致(北京,beijingDayOf):按 UTC 切日会让北京 0–8 点的 + // 夜间账号被「前一个 UTC 日」的成功挡掉一整天,断签。 + const dayStamp = beijingDayOf(); const dayKey = `${accountKey ?? "x"}`; const lastOk = signinTriedLocal.get(dayKey); - if (typeof lastOk === "string" && lastOk.slice(0, 10) === hourStamp.slice(0, 10)) return; // 今天已成功 + if (lastOk === dayStamp) return; // 今天(北京)已成功 const r = await dailySignin(accessToken, host); if (r && (r.success || r.already_completed)) { - signinTriedLocal.set(dayKey, hourStamp); + signinTriedLocal.set(dayKey, dayStamp); } } @@ -946,14 +1110,23 @@ async function refreshTokens(refreshToken, preferredHosts) { : [GLOBAL_HOST, CN_HOST, ...preferredHosts]; let result = null; + let lastErr = null; for (const base of dedupe(ordered)) { - result = await callEnvelope("/userapi/v1/refresh", { method: "POST", body, base }); - if (result.code !== 0 && result.code === 400002) { - result = await callEnvelope("/userapi/v1/agent-refresh", { method: "POST", body, base }); + try { + result = await callEnvelope("/userapi/v1/refresh", { method: "POST", body, base }); + if (result.code !== 0 && result.code === 400002) { + result = await callEnvelope("/userapi/v1/agent-refresh", { method: "POST", body, base }); + } + } catch (e) { + // 网络错误/非 envelope(20s 超时、代理断连):换下一个 host 再试。 + // 封禁是确定性的(每个 host 都一样),立即上抛。 + if (e?.banned || e?.signIn === "kept") throw e; + lastErr = e; + continue; } if (result.code !== 400000) break; } - if (!result) throw new Error("AutoClaw refresh: no host attempted"); + if (!result) throw lastErr ?? new Error("AutoClaw refresh: no host attempted"); if (result.code !== 0 || !result.data) { // a banned account is refused, not signed out: the sign-in is kept if (result.banned) throw Object.assign(new Error(`AutoClaw: ${result.banned}`), { signIn: "kept" }); @@ -990,10 +1163,20 @@ function rememberRotated(spent, fresh) { rotatedMemo.delete(oldest); } } + function refreshShared(refreshToken, hosts) { - if (!refreshToken) throw new Error("AutoClaw: no refresh token to renew"); + if (!refreshToken) { + throw Object.assign(new Error("AutoClaw: no refresh token to renew"), { signIn: "expired" }); + } const memo = rotatedMemo.get(refreshToken); - if (memo) return Promise.resolve(memo); + if (memo) { + // 兜底的新 access token 也会到期:到期就别再喂给调用方—— + // 用兜底里轮换出的新 RT 走一次真刷新(若该 RT 已被宿主花掉,会失败重抛, + // 下一次刷新会照常走网络路径)。 + if (typeof memo.expires === "number" && memo.expires > Date.now()) return Promise.resolve(memo); + rotatedMemo.delete(refreshToken); + return refreshShared(memo.refresh, hosts); + } const held = refreshGates.get(refreshToken); if (held && (held.pending || Date.now() - held.at < REFRESH_SHARE_MS)) return held.promise; // 顺手淘汰已过期且不在途的条目,避免长驻进程里无限堆积(含令牌)。 @@ -1002,7 +1185,15 @@ function refreshShared(refreshToken, hosts) { } const gate = { at: Date.now(), pending: true, promise: null }; gate.promise = refreshTokens(refreshToken, hosts).then( - (t) => { gate.pending = false; gate.at = Date.now(); return t; }, + (t) => { + gate.pending = false; + gate.at = Date.now(); + // 成功即记:旧 RT 已被服务端作废。>60s 后(gate 已清)任何还持有旧 RT + // 的调用方(同一登录的第二条记录、并发的宿主续期)必须直接拿到这对 + // 令牌,而不是拿死 RT 再打一次网络被判 invalid_grant 登出健康账号。 + rememberRotated(refreshToken, t); + return t; + }, (e) => { // 只摘自己:并发重建的 gate 不能被旧的失败回调误删(第三次烧 RT)。 if (refreshGates.get(refreshToken) === gate) refreshGates.delete(refreshToken); @@ -1186,8 +1377,7 @@ function makeAutoclawPlugin({ providerId, providerName, homeHost, loginMethod }) fetchWallets(token, hosts), signinStatus(token, hosts[0]).then((s) => s ?? signinStatus(token, hosts[1] ?? hosts[0])), ]); - // 旧链路自动补签(默认关,需 signinOnUsage=true):查额度即触发。 - // 放在状态查询之后:未签才补,补完重查一次状态展示。 + // 旧链路自动补签(默认关,需 signinOnUsage=true):查额度且未签才触发。 if (signin && signin.status !== "completed" && (cfg.signin && cfg.signinOnUsage)) { await maybeSignin(token, hosts[0], options, "usage", signinAccountKey(providerId, auth)); } @@ -1217,14 +1407,14 @@ function makeAutoclawPlugin({ providerId, providerName, homeHost, loginMethod }) // used 是 0–100 的百分比;签到行只表达状态,状态在 display 里 used: 0, display: signinFinal.status === "completed" - ? `今日已签 ✓${signinFinal.reward ? ` +${signinFinal.reward}` : ""}` - : `今日未签(+${signinFinal.reward || 200})`, + ? `今日已签 ✓${signinFinal.reward ? ` +${fmtPoints(signinFinal.reward)}` : ""}` + : `今日未签${signinFinal.reward ? `(+${fmtPoints(signinFinal.reward)})` : ""}`, aside: true, }] : []; - // 本轮消耗窗口:上游没有用量/流水接口,用本地余额差值算 - // (峰值 − 当前;任何上涨即新一轮)。首日只有一次观察时不出这行。 + // 本轮消耗窗口:用本地余额差值算(峰值 − 当前;任何上涨即新一轮)。 + // 首日只有一次观察时不出这行。 // 键复用 signinAccountKey(#254):显示名可以重复,没 uid 的旧账号按 // refresh token 哈希,同名的两个账号不能共用一本账。 const track = balanceObserve(signinAccountKey(providerId, auth), total); @@ -1244,18 +1434,34 @@ function makeAutoclawPlugin({ providerId, providerName, homeHost, loginMethod }) remaining: Math.max(0, 100 - pct), amount: Math.round(track.used), limit: Math.round(track.peak), - display: `${Math.round(track.used)} / ${Math.round(track.peak)} 积分` - + (track.daySpent > 0 ? ` · 今日 -${Math.round(track.daySpent)}` : "") - + (track.dayGained > 0 ? ` · +${Math.round(track.dayGained)}` : ""), + display: `${fmtPoints(track.used)} / ${fmtPoints(track.peak)} 积分` + + (track.daySpent > 0 ? ` · 今日 -${fmtPoints(track.daySpent)}` : "") + + (track.dayGained > 0 ? ` · 今日 +${fmtPoints(track.dayGained)}` : ""), }); } - // 积分是纯余额,没有总量也没有已用量:只放在 balance 里。做成 - // used: 0 的窗口,卡片会给它配上「剩余 100%」(#376)。细分钱包 - // 一并写进 balance。 + // 活动积分:流水按 wallet 实例对账(credit − debit − expire), + // 精确可算。只读、失败静默省略——绝不抛错影响余额/签到行。 + // 缓存按账号键;checkin 里 user-info 成功时会清掉(活动可能刚到账)。 + let campaignRow = null; + try { + const entries = await campaignCached(signinAccountKey(providerId, auth), () => fetchCampaignLedger(token, hosts)); + const camp = campaignBreakdown(entries); + if (camp && (camp.claimed > 0 || camp.expired > 0)) { + const label = camp.labels.length === 1 ? `(${camp.labels[0].slice(0, 40)})` + : camp.labels.length > 1 ? `(${camp.labels.length} 个活动)` : ""; + const text = [`剩 ${fmtPoints(camp.remaining)}${label}`, `已领 ${fmtPoints(camp.claimed)}`, `过期 ${fmtPoints(camp.expired)}`].join(" · "); + campaignRow = { name: "活动积分", used: 0, aside: true, display: `${text}(近期)` }; + } + } catch { /* 活动行是展示性质:读不到就省略 */ } + + // 积分是纯余额,没有总量也没有已用量:只放在 balance 里。细分钱包 + // 一并写进 balance。活动行放最后(text 行;绝不带 balance—— + // magpie ≥ v0.1.1790 会把窗口的 balance 当资金池,重复计入路由)。 const parts = named.map((w) => `${w.name} ${Math.round(w.balance)}`); + if (campaignRow) windows.push(campaignRow); return { - balance: `${Math.round(total)} 积分${parts.length ? `(${parts.join(" · ")})` : ""}`, + balance: `${fmtPoints(total)} 积分${parts.length ? `(${parts.join(" · ")})` : ""}`, windows, }; } catch (e) { @@ -1287,84 +1493,116 @@ function makeAutoclawPlugin({ providerId, providerName, homeHost, loginMethod }) const renewOnce = async () => { const spent = bareToken(auth.refresh); if (!spent) throw Object.assign(new Error("AutoClaw: 没有可用的 refresh token"), { signIn: "expired" }); + // refreshShared 成功即记内存兜底(rememberRotated),写回成功与否都不影响: + // 旧 RT 已作废,还拿着它的调用方直接拿兜底令牌。 const fresh = await refreshShared(spent, [homeHost]); - let saved = false; if (client?.auth?.set) { try { - // CAS:签到期间用户可能重新登录/登出。只有记录仍是当时那份才写回, - // 否则把新令牌交给内存兜底(rememberRotated),不覆盖别人的记录。 + // CAS:签到期间用户可能重新登录/登出。只有记录仍是当时那份(存在、 + // 同为 oauth、同一个 RT)才写回;已登出或已换成别的记录时绝不覆盖, + // 新令牌交给内存兜底(rememberRotated)。 const now = await getAuth(); - const unchanged = !now || now.type !== "oauth" || bareToken(now.refresh ?? "") === spent; + const unchanged = !!now && now.type === "oauth" && bareToken(now.refresh ?? "") === spent; if (unchanged) { const written = await client.auth.set({ path: { id: providerId }, body: { ...auth, access: fresh.access, refresh: fresh.refresh, expires: fresh.expires }, - }); - saved = !written?.error; + }).catch(() => null); + // 写回失败(宿主拒绝/异常)无所谓:内存兜底已经记了这对令牌。 + void written; } - } catch { /* 写回失败:走下面的内存兜底 */ } + } catch { /* 写回失败:走上面的内存兜底 */ } } - if (!saved) rememberRotated(spent, fresh); auth = { ...auth, access: fresh.access, refresh: fresh.refresh, expires: fresh.expires }; token = bareToken(fresh.access); }; // 一轮:活动奖励(尽力而为)→ 每日签到。返回契约里的 outcome。 + // uiReached 由外层记录:无论签到以哪种 outcome 收尾(done/inactive/ + // claimed/failed),只要 user-info 没成功就要让卡片知道活动没触发。 const attempt = async () => { // ① 官方登录事件:user-info 带桌面端指纹。单 host 网络错误不能挡住 // 备用 host;全部失败也绝不阻断每日签到(活动奖励领不到要如实告知)。 const deviceId = accountDeviceId(undefined, token, auth.refresh); - let uiReached = false; + let reached = false; for (const h of hosts) { try { const ui = await callUserInfo(token, deviceId, h); - if (ui?.code === 0) { uiReached = true; break; } + if (ui?.ok && ui?.code === 0) { reached = true; break; } } catch { /* 换下一个 host */ } } + uiReached = uiReached || reached; + // user-info 一旦到达服务端,活动可能已到账:立即失效流水缓存。 + // 放在 attempt 内而不是收尾处——签到随后抛错(401/网络)也不能让 + // 刚领的奖励在缓存里 stale 10 分钟。两次 attempt 的账号键可能不同 + // (无 uid 账号键含 RT 哈希,renewOnce 换 RT),各删各的,覆盖新旧。 + if (uiReached) campaignCache.delete(signinAccountKey(providerId, auth)); // ② 每日签到:双 host 都查不到任务时抛错(failed → 30 分钟后重试), // 绝不把查询失败误报成「当前没有签到活动」把当天判死(#3326)。 + // 空数组也算「host 回答了」(真没有签到任务),但 500/非 envelope + // 不算——两个 host 都哑了就抛错。主 host 空列表时继续问备用 host + // (备用 host 上可能有任务;主 host 空 ≠ 整个账号没有)。 let task = null; let lastErr = null; - let answered = false; + let expiredErr = null; + let answered = 0; for (const h of hosts) { try { const tasks = await taskList(token, h); - if (tasks) { - answered = true; - task = tasks.find((x) => x?.task_id === "daily_signin") ?? null; - break; - } + if (!Array.isArray(tasks)) continue; // 本 host 无响应(5xx/非 envelope) + answered++; + const t = tasks.find((x) => x?.task_id === "daily_signin") ?? null; + if (t) { task = t; break; } } catch (e) { - lastErr = e; + lastErr = lastErr ?? e; + // 混合失败时保住 expired 标记:主 host 401、备用 host 网络抖动, + // 丢了标记就永远不会 refresh(账号被误标失败一整天)。 + if (e?.signIn === "expired") expiredErr = expiredErr ?? e; if (e?.banned) break; } } - if (!task && !answered) throw lastErr ?? new Error("AutoClaw task-list 双 host 无响应"); - if (!task) return { outcome: "inactive", message: "当前没有签到活动" }; + if (!task && expiredErr) throw expiredErr; + if (!task && answered === 0) throw lastErr ?? new Error("AutoClaw task-list 双 host 无响应"); + // 「活动奖励未触发」只追加、不覆盖:签到的真实结果(已签到 +N / 失败 + // 原因 / 当前没有活动)必须原样上卡,登录事件的缺失只补在后面。 + const note = (result) => { + if (uiReached) return result; + const extra = "活动奖励未触发(登录未上报)"; + return { ...result, message: result.message ? `${result.message} · ${extra}` : extra }; + }; + if (!task) return note({ outcome: "inactive", message: "当前没有签到活动" }); if (String(task.status ?? "") === "completed") { - return { outcome: "done", credit: optNumber(task.reward_points) }; + return note({ outcome: "done", credit: optNumber(task.reward_points) }); } - // 补签(主 host 优先,无响应再试备用 host;服务端幂等) + // 补签(主 host 优先,null/网络错误换备用 host;服务端幂等)。dailySignin + // 对一切失败一律返回 null(封禁/失效的判定在 taskList,先于此处发生), + // 两个 host 都拿不到结果 → checkinOutcome(null) → failed,30 分钟后重试。 const r = await dailySignin(token, hosts[0]).catch(() => null) ?? await dailySignin(token, hosts[1] ?? hosts[0]).catch(() => null); - const result = checkinOutcome(r); - if (!uiReached && result.message) { - result.message = `${result.message}(登录活动奖励未触发)`; - } - return result; + return note(checkinOutcome(r)); }; + let uiReached = false; try { return await attempt(); } catch (e) { - // 封禁是账号问题、不是登录失效:不重试,让失败消息(含封禁文案)原样上卡; - // 账号状态标记仍由 usage / 请求链路完成。 + // 封禁/额度用尽是账号问题、不是登录失效:不刷新重试,失败文案原样上卡。 if (e?.banned || e?.signIn === "kept") throw e; if (e?.signIn !== "expired") throw e; // 当前 AT 被拒:refresh 一次(单飞 + CAS 写回 + 轮换兜底),整轮重试一次。 await renewOnce(); - return await attempt(); + try { + return await attempt(); + } catch (e2) { + // 刚刷新成功的账号再遇到 401/403 是上游瞬时行为,绝不能把它判成 + // 登录失效让 magpie 登出健康账号——降级成普通 failed(30 分钟后重试)。 + if (e2?.banned || e2?.signIn === "kept") throw e2; + if (e2?.signIn === "expired") { + throw new Error(`AutoClaw: 续期后仍被拒(${e2.message}),稍后重试`); + } + throw e2; + } } }, }, @@ -1844,7 +2082,11 @@ export const _internal = { makePluginForTest: makeAutoclawPlugin, platformTm, setOfficialChatHeaders, conversationSessionId, stainlessOs, stainlessArch, md5Hex, jwtClaim, tokenFields, codeValue, bizHeaders, bareToken, newDeviceId, dailySignin, signinStatus, maybeSignin, signinAccountKey, signinConfig, checkinOutcome, refreshTokens, + refreshShared, rememberRotated, magpieCfgDir, clearRefreshGates: () => refreshGates.clear(), + clearRotatedMemo: () => rotatedMemo.clear(), + clearCampaignLedger: () => campaignCache.clear(), + campaignBreakdown, fetchCampaignLedger, fmtPoints, campaignCached, balanceObserve, usageStatePath: () => usageStatePath ?? usageStatePathDefault(), setUsageStatePath, beijingDayOf, fetchUserProfile, accountDeviceId, accountDisplayName, startLocalOAuth, FLAGGED, unflagged, unflag, tidyBody, rewriteManagedBody, autoclawRewriteBody, explained, OFFICIAL_SYSTEM_PROMPT, REFUSED_HINT, diff --git a/packages/autoclaw/index.test.mjs b/packages/autoclaw/index.test.mjs index 109a086e0..08f022412 100644 --- a/packages/autoclaw/index.test.mjs +++ b/packages/autoclaw/index.test.mjs @@ -1,7 +1,7 @@ // The check-in mapping is magpie's contract (provider-plugins.md): every // answer the upstream gives lands on one of its outcomes, and the plugin // factory registers both providers with every hook check.mjs looks for. -import { expect, test } from "bun:test" +import { expect, test, setSystemTime } from "bun:test" import { existsSync, mkdtempSync, mkdirSync, statSync, utimesSync, writeFileSync, readFileSync, readdirSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" @@ -994,6 +994,8 @@ test("usage: the credit balance and its wallets are the balance, never a window if (u.includes("wallets")) return Response.json({ code: 0, data: { total_balance: 1234.4, wallets: [ { display_name: "赠送积分", balance: 500 }, { display_name: "会员积分", balance: 734.4 }, { display_name: "空", balance: 0 }, ] } }) + // 活动流水查询失败/无数据:活动行省略,余额照常 + if (u.includes("ledgers_std")) return Response.json({ code: 500001, msg: "服务异常" }) return Response.json({ code: 0, data: null }) } const dir = mkdtempSync(join(tmpdir(), "autoclaw-usage-")) @@ -1001,12 +1003,13 @@ test("usage: the credit balance and its wallets are the balance, never a window try { const hooks = await AutoclawAuthPlugin({ client: { auth: { set: async () => {} } } }) const u = await hooks.auth.usage(async () => ({ type: "oauth", access: "a1" })) - expect(u.balance).toBe("1234 积分(赠送积分 500 · 会员积分 734)") + expect(u.balance).toBe("1,234 积分(赠送积分 500 · 会员积分 734)") // the prepaid wallet reading (magpie #3950): the balance as told, in its // unit, and no share invented - a first read has no spend window yet expect(u.windows.filter((w) => w.name !== "每日签到")).toEqual([ { name: "积分余额", balance: 1234.4, unit: "积分" }, ]) + expect(u.windows.some((w) => w.name === "活动积分")).toBe(false) } finally { globalThis.fetch = realFetch rmSync(dir, { recursive: true, force: true }) @@ -1411,7 +1414,7 @@ test("usage: the spend window across two reads, and a check-in that reopens the expect(w.remaining).toBe(84.4) expect(w.amount).toBe(3123) expect(w.limit).toBe(20000) - expect(w.display).toBe("3123 / 20000 积分 · 今日 -3123") + expect(w.display).toBe("3,123 / 20,000 积分 · 今日 -3,123") // a check-in +200 lands below the old peak and still reopens the cycle balance = 17077 const third = await hooks.auth.usage(async () => auth) @@ -1564,7 +1567,7 @@ test("checkin: a user-info network failure never blocks the daily signin and is expect(hosts).toEqual(["autoglm-api.autoglm.ai", "autoglm-api.zhipuai.cn"]) expect(result.outcome).toBe("claimed") expect(result.credit).toBe(400) - expect(result.message).toContain("登录活动奖励未触发") + expect(result.message).toContain("活动奖励未触发") } finally { globalThis.fetch = realFetch } @@ -1584,7 +1587,7 @@ test("checkin: both task-list hosts failing throws (failed), never misreports in let threw try { await hooks.auth.checkin(async () => claimAuth("qc-tasklistfail")) } catch (e) { threw = e } expect(threw).toBeInstanceOf(Error) - expect(threw.message).toContain("task-list") + expect(threw.message).toContain("无响应") } finally { globalThis.fetch = realFetch } @@ -1855,3 +1858,927 @@ test("usage: an unreadable total_balance is unknown, not a drained pool", async rmSync(dir, { recursive: true, force: true }) } }) + + +// ── 活动积分(0.2.16)────────────────────────────────────────────────── + +// 真实流水条目形状(2026-11-06 真机抓包)。metadata 按事件类型有/无; +// expire 带顶层+metadata 的 wallet_scope 但没有 activity_id;debit 同。 +const ledEntry = (o = {}) => ({ + id: String(o.id ?? 1), + asset_type: "point", + wallet_type: "earned", + wallet_id: o.wallet ?? "wlt_a1", + wallet_scope: o.scope ?? "campaign", + mutation_type: o.type ?? "credit", + amount: o.amount ?? 4000, + description: o.desc ?? "AutoClaw Weekly Perks +4000 credits", + occurred_at: "2026-11-06T01:00:11.16+08:00", + ...(o.noMeta ? {} : { metadata: o.meta ?? { + activity_id: o.act ?? "autoclaw_weekly_perks_20261013", + event_type: "autoclaw_user_active", + wallet_scope: o.scope ?? "campaign", + reward_points: String(o.amount ?? 4000), + grant_date: "2026-11-02", + period_id: "454", + wallet_instance_balance_after: "5000", + } }), +}) +const led = (entries, extra = {}) => + Response.json({ code: 0, msg: "SUCCESS", data: { entries, next_page_token: "", ...extra } }) + +// 活动测试统一路由:按 pathname 分支("wallet_scope" 会撞 includes("wallet"))。 +const campFetch = ({ total = 5000, ledger, calls = [], signin = null, complete = null } = {}) => (input, init) => { + const url = new URL(String(input)) + if (url.pathname.endsWith("/wallets")) { + return Response.json({ code: 0, data: { total_balance: total, wallets: [] } }) + } + if (url.pathname === "/agent-assetmgr/api/v1/ledgers_std") { + calls.push({ url, headers: new Headers(init?.headers) }) + return typeof ledger === "function" ? ledger(url) : led(ledger ?? []) + } + if (url.pathname.endsWith("/autoclaw-task-list")) { + return typeof signin === "function" ? signin() : (signin ?? Response.json({ code: 0, data: [] })) + } + if (url.pathname.endsWith("/autoclaw-task-complete")) { + return typeof complete === "function" ? complete() : (complete ?? signedTask(400, 1)) + } + return Response.json({ code: 0, data: {} }) +} +const campAuth = (tag, extra = {}) => ({ + type: "oauth", access: `at-camp-${tag}`, refresh: claimJwt(`camp-${tag}`), + accountId: "测试账号", uid: `u-camp-${tag}`, ...extra, +}) +const campHooks = async (client = { auth: { set: async () => {} } }) => + AutoclawAuthPlugin({ client }, {}) + +const campCleanup = (realFetch, dir) => { + globalThis.fetch = realFetch + _internal.clearCampaignLedger() + if (dir) rmSync(dir, { recursive: true, force: true }) +} + +// A+B+C 全量:按实例对账的剩余、已领、过期,以及活动名标签。 +test("campaign: the card names live campaign points, claims and expiries (A+B+C)", async () => { + const realFetch = globalThis.fetch + const dir = mkdtempSync(join(tmpdir(), "autoclaw-camp-")) + _internal.setUsageStatePath(join(dir, ".usage-state.json")) + const calls = [] + globalThis.fetch = campFetch({ + total: 6800, + calls, + ledger: [ + ledEntry({ id: 1, wallet: "wlt_live", amount: 4000, desc: "AutoClaw Weekly Perks +4000 credits" }), + ledEntry({ id: 2, wallet: "wlt_dead", amount: 10000, desc: "New-user credits (Day 1/7) +10000", act: "autoclaw_new_user_day_1_to_7_bonus_new_20260922" }), + ledEntry({ id: 3, wallet: "wlt_dead", type: "debit", amount: -2289, noMeta: true }), + ledEntry({ id: 4, wallet: "wlt_dead", type: "expire", amount: -7711, noMeta: true, desc: "活动积分到期失效" }), + ], + }) + try { + const hooks = await campHooks() + const u = await hooks.auth.usage(async () => campAuth("happy")) + const row = u.windows.find((w) => w.name === "活动积分") + expect(row).toBeDefined() + expect(row.aside).toBe(true) + expect(row.used).toBe(0) + expect(row.balance).toBeUndefined() + // Weekly Perks 实例活着:4000 − 0 − 0;新用户礼实例清零:不进剩余 + expect(row.display).toBe("剩 4,000(AutoClaw Weekly Perks) · 已领 14,000 · 过期 7,711(近期)") + // 余额行保持干净的总额(绝不被活动标签污染) + expect(u.balance).toBe("6,800 积分") + // 请求契约:过滤参数 + 账号令牌 + 无 X-Authorization + expect(calls.length).toBe(1) + const q = calls[0].url.searchParams + expect(q.get("biz_app_id")).toBe("autoclaw") + expect(q.get("wallet_scope")).toBe("campaign") + expect(calls[0].headers.get("Authorization")).toBe("Bearer at-camp-happy") + expect(calls[0].headers.get("X-Channel")).toBe("AutoClaw4") // bizHeaders(同 fetchWallets) + expect(calls[0].headers.get("X-Authorization")).toBeNull() + } finally { + campCleanup(realFetch, typeof dir !== "undefined" ? dir : undefined) + } +}) + +// 服务端忽略过滤参数时(返回了 annual 条目),客户端必须再过滤。 +test("campaign: a server that ignores wallet_scope is re-filtered client-side", async () => { + const realFetch = globalThis.fetch + const dir = mkdtempSync(join(tmpdir(), "autoclaw-camp-")) + _internal.setUsageStatePath(join(dir, ".usage-state.json")) + globalThis.fetch = campFetch({ + total: 4000, + ledger: [ + ledEntry({ id: 1, wallet: "wlt_c", amount: 4000, scope: "campaign" }), + ledEntry({ id: 2, wallet: "wlt_d", amount: 400, scope: "annual", desc: "每日签到" }), + ledEntry({ id: 3, wallet: "wlt_d", type: "debit", amount: -45, scope: "annual", noMeta: true }), + ], + }) + try { + const hooks = await campHooks() + const u = await hooks.auth.usage(async () => campAuth("refilter")) + const row = u.windows.find((w) => w.name === "活动积分") + expect(row.display).toBe("剩 4,000(AutoClaw Weekly Perks) · 已领 4,000 · 过期 0(近期)") + } finally { + campCleanup(realFetch, typeof dir !== "undefined" ? dir : undefined) + } +}) + +// 没有活动历史(entries 空 / 全是别的 scope / 读取失败)→ 整行省略。 +test("campaign: no campaign history or a failed read omits the row, never breaks the card", async () => { + const realFetch = globalThis.fetch + const dir = mkdtempSync(join(tmpdir(), "autoclaw-camp-")) + _internal.setUsageStatePath(join(dir, ".usage-state.json")) + globalThis.fetch = campFetch({ total: 1000, ledger: [] }) + try { + const hooks = await campHooks() + const u1 = await hooks.auth.usage(async () => campAuth("empty")) + expect(u1.windows.some((w) => w.name === "活动积分")).toBe(false) + expect(u1.balance).toBe("1,000 积分") + expect(u1.error).toBeUndefined() + _internal.clearCampaignLedger() + // 流水接口整体 500:余额照常 + globalThis.fetch = campFetch({ total: 1000, ledger: () => new Response("boom", { status: 500 }) }) + const u2 = await hooks.auth.usage(async () => campAuth("empty")) + expect(u2.balance).toBe("1,000 积分") + expect(u2.windows.some((w) => w.name === "活动积分")).toBe(false) + } finally { + campCleanup(realFetch, typeof dir !== "undefined" ? dir : undefined) + } +}) + +// TTL 缓存:10 分钟内第二次 usage() 不再打流水;两个账号各查各的。 +test("campaign: the ledger read is cached per account for 10 minutes", async () => { + const realFetch = globalThis.fetch + const dir = mkdtempSync(join(tmpdir(), "autoclaw-camp-")) + _internal.setUsageStatePath(join(dir, ".usage-state.json")) + const calls = [] + globalThis.fetch = campFetch({ + total: 4000, + calls, + ledger: [ledEntry({ id: 1, wallet: "wlt_c", amount: 4000 })], + }) + try { + const hooks = await campHooks() + const a = campAuth("cache-a") + const b = campAuth("cache-b") + await hooks.auth.usage(async () => a) + await hooks.auth.usage(async () => a) + await hooks.auth.usage(async () => b) + const ledgerCalls = calls.filter((c) => c.url.pathname === "/agent-assetmgr/api/v1/ledgers_std").length + expect(ledgerCalls).toBe(2) // a 一次 + b 一次 + } finally { + campCleanup(realFetch, typeof dir !== "undefined" ? dir : undefined) + } +}) + +// checkin 里 user-info 成功(活动可能刚到账)→ 缓存立即失效。 +test("campaign: a checkin whose user-info reached the server invalidates the cache", async () => { + const realFetch = globalThis.fetch + const dir = mkdtempSync(join(tmpdir(), "autoclaw-camp-")) + _internal.setUsageStatePath(join(dir, ".usage-state.json")) + const calls = [] + globalThis.fetch = campFetch({ + total: 4000, + calls, + signin: () => pendingTask(), + ledger: [ledEntry({ id: 1, wallet: "wlt_c", amount: 4000 })], + }) + try { + const hooks = await campHooks() + const auth = campAuth("invalidate") + await hooks.auth.usage(async () => auth) + await hooks.auth.checkin(async () => auth) // user-info code=0 → uiReached + await hooks.auth.usage(async () => auth) + const ledgerCalls = calls.filter((c) => c.url.pathname === "/agent-assetmgr/api/v1/ledgers_std").length + expect(ledgerCalls).toBe(2) // 失效后重新拉取 + } finally { + campCleanup(realFetch, typeof dir !== "undefined" ? dir : undefined) + } +}) + +// 0.2.15 遗留 P0-2:CAS 把「已登出」当「未变」会覆盖用户凭证——现在必须跳过写回。 +// 场景:task-list 401 → refresh 轮换 → CAS 复读时发现用户已登出(getAuth 返回 null)。 +test("checkin: a logged-out account is never resurrected by the rotation write-back", async () => { + const realFetch = globalThis.fetch + const auth = campAuth("cas-logout") + const sets = [] + globalThis.fetch = campFetch({ + total: 4000, + ledger: [ledEntry({ id: 1, wallet: "wlt_c", amount: 4000 })], + }) + const staleFetch = globalThis.fetch + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("/userapi/v1/refresh") || url.includes("/agent-refresh")) { + return Response.json({ code: 0, msg: "SUCCESS", data: { access_token: claimJwt("cas-logout-fresh"), refresh_token: "rt-cas-fresh" } }) + } + return staleFetch(input) + } + try { + let reads = 0 + const hooks = await campHooks({ auth: { set: async (o) => { sets.push(o); return { data: true } } } }) + const expiredTaskList = () => new Response("{}", { status: 401 }) + const prev = globalThis.fetch + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("autoclaw-task-list")) return expiredTaskList() + return prev(input) + } + let threw + try { await hooks.auth.checkin(async () => (++reads === 1 ? auth : null)) } catch (e) { threw = e } + expect(reads).toBeGreaterThanOrEqual(2) // CAS 复读确实发生了 + expect(sets.length).toBe(0) // 绝不复活已登出的记录 + expect(threw?.signIn ?? "none").toBe("none") // 不标 expired(用户已登出是用户的行为) + } finally { + campCleanup(realFetch, typeof dir !== "undefined" ? dir : undefined) + } +}) + +// 0.2.15 遗留 P0-3:额度用尽的 403 不是登录失效——不烧 RT、不标 expired。 +test("checkin: a quota-exhausted 403 at task-list keeps the sign-in and never refreshes", async () => { + const realFetch = globalThis.fetch + let refreshes = 0 + globalThis.fetch = campFetch({ + total: 4000, + ledger: [ledEntry({ id: 1, wallet: "wlt_c", amount: 4000 })], + }) + const staleFetch = globalThis.fetch + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("autoclaw-task-list")) { + return new Response(String.raw`{"code":810000,"message":"GLM-5.3 免费额度已用完,请购买充值"}`, { status: 403 }) + } + if (url.includes("/userapi/v1/refresh") || url.includes("/agent-refresh")) refreshes++ + return staleFetch(input) + } + try { + const hooks = await campHooks() + let threw + try { await hooks.auth.checkin(async () => campAuth("quota403")) } catch (e) { threw = e } + expect(threw).toBeInstanceOf(Error) + expect(threw.message).toContain("额度已用完") + expect(threw.signIn).toBe("kept") // 登录保留,不是 expired + expect(refreshes).toBe(0) // 不烧 RT + } finally { + campCleanup(realFetch, typeof dir !== "undefined" ? dir : undefined) + } +}) + +// 0.2.15 遗留 P0-1:刷新成功后的重试再遇 401,绝不能把健康账号标成 expired。 +test("checkin: a second 401 right after a successful renewal degrades to a retryable failure", async () => { + const realFetch = globalThis.fetch + const auth = campAuth("reexpired") + const sets = [] + let oldReads = 0 + let stage = "old" + globalThis.fetch = campFetch({ + total: 4000, + ledger: [ledEntry({ id: 1, wallet: "wlt_c", amount: 4000 })], + }) + const staleFetch = globalThis.fetch + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("/userapi/v1/refresh") || url.includes("/agent-refresh")) { + stage = "new" + return Response.json({ code: 0, msg: "SUCCESS", data: { access_token: claimJwt("reexpired-fresh"), refresh_token: "rt-reexpired-fresh" } }) + } + if (url.includes("autoclaw-task-list")) return new Response("{}", { status: 401 }) // 两个阶段都 401 + return staleFetch(input) + } + try { + const hooks = await campHooks({ auth: { set: async (o) => { sets.push(o); return { data: true } } } }) + let threw + try { await hooks.auth.checkin(async () => (++oldReads <= 2 ? auth : null)) } catch (e) { threw = e } + expect(threw).toBeInstanceOf(Error) + expect(threw.signIn).toBeUndefined() // 关键:不能带 expired + expect(sets.length).toBe(1) // 刷新已写回,账号仍是健康的 + } finally { + campCleanup(realFetch, typeof dir !== "undefined" ? dir : undefined) + } +}) + +// 0.2.15 遗留 P1-4:混合 host 失败必须保住 expired 标记。两种顺序都测: +// ① 401 在前、网络错在后(401 自带标记);② 网络错在前、401 在后 +// (标记只能靠 expiredErr 单独保存——这是它的存在意义)。 +test("checkin: a mixed host failure keeps the expired marker and refreshes, in both orders", async () => { + const realFetch = globalThis.fetch + for (const order of ["expired-first", "network-first"]) { + const auth = campAuth(`mixedfail-${order}`) + let refreshes = 0 + globalThis.fetch = campFetch({ total: 4000, ledger: [ledEntry({ id: 1, wallet: "wlt_c", amount: 4000 })] }) + const staleFetch = globalThis.fetch + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("autoclaw-task-list")) { + const host = new URL(String(input)).host + const first = host === "autoglm-api.autoglm.ai" + const isExpiredTurn = order === "expired-first" ? first : !first + return isExpiredTurn ? new Response("{}", { status: 401 }) : (() => { throw new Error("ECONNRESET") })() + } + if (url.includes("/userapi/v1/refresh") || url.includes("/agent-refresh")) { + refreshes++ + return Response.json({ code: 0, msg: "SUCCESS", data: { access_token: claimJwt(`mixedfail-${order}-fresh`), refresh_token: `rt-mixedfail-${order}-fresh` } }) + } + return staleFetch(input) + } + try { + const hooks = await campHooks() + let threw + try { await hooks.auth.checkin(async () => auth) } catch (e) { threw = e } + expect(refreshes).toBe(1) // expired 标记没丢 → 刷新发生了 + expect(threw?.message ?? "").not.toBeUndefined() + } finally { + _internal.clearRefreshGates() + campCleanup(realFetch, typeof dir !== "undefined" ? dir : undefined) + } + } +}) + +// 0.2.15 遗留 P1-5 姐妹案:主 host 空 []、备用 host 有任务 → 必须用备用 host 签上。 +test("checkin: an empty task list on host[0] still finds the task on host[1]", async () => { + const realFetch = globalThis.fetch + const auth = campAuth("emptyfirst") + globalThis.fetch = campFetch({ total: 4000, ledger: [ledEntry({ id: 1, wallet: "wlt_c", amount: 4000 })] }) + const staleFetch = globalThis.fetch + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("autoclaw-task-list")) { + const host = new URL(String(input)).host + return host === "autoglm-api.autoglm.ai" + ? Response.json({ code: 0, data: [] }) + : pendingTask() + } + if (url.includes("autoclaw-task-complete")) return signedTask(400, 2) + return staleFetch(input) + } + try { + const hooks = await campHooks() + const r = await hooks.auth.checkin(async () => auth) + expect(r.outcome).toBe("claimed") + expect(r.credit).toBe(400) + } finally { + campCleanup(realFetch, typeof dir !== "undefined" ? dir : undefined) + } +}) + +// 0.2.15 遗留 P1-6:user-info 失败时 done 路径也要说「活动没触发」。 +test("campaign: a done checkin still reports when the login event never reached the server", async () => { + const realFetch = globalThis.fetch + const dir = mkdtempSync(join(tmpdir(), "autoclaw-camp-")) + _internal.setUsageStatePath(join(dir, ".usage-state.json")) + globalThis.fetch = campFetch({ + total: 4000, + signin: () => completedTask(), + ledger: [ledEntry({ id: 1, wallet: "wlt_c", amount: 4000 })], + }) + const staleFetch = globalThis.fetch + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("/userapi/v1/user-info")) return Response.json({ code: 410000, msg: "用户未登录" }) + return staleFetch(input) + } + try { + const hooks = await campHooks() + const r = await hooks.auth.checkin(async () => campAuth("donenote")) + expect(r.outcome).toBe("done") + expect(r.message).toContain("活动奖励未触发") + } finally { + campCleanup(realFetch, typeof dir !== "undefined" ? dir : undefined) + } +}) + +// 0.2.15 遗留 P1-8:401 带 code:0 的怪响应不算「活动已触发」(假成功)。 +test("campaign: a 401 with a zero code is not a reached login event", async () => { + const realFetch = globalThis.fetch + const dir = mkdtempSync(join(tmpdir(), "autoclaw-camp-")) + _internal.setUsageStatePath(join(dir, ".usage-state.json")) + globalThis.fetch = campFetch({ + total: 4000, + signin: () => completedTask(), + ledger: [ledEntry({ id: 1, wallet: "wlt_c", amount: 4000 })], + }) + const staleFetch = globalThis.fetch + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("/userapi/v1/user-info")) return Response.json({ code: 0, msg: "SUCCESS" }, { status: 401 }) + return staleFetch(input) + } + try { + const hooks = await campHooks() + const r = await hooks.auth.checkin(async () => campAuth("fakeok")) + expect(r.outcome).toBe("done") + expect(r.message).toContain("活动奖励未触发") // 假成功被识破:活动其实没触发 + } finally { + campCleanup(realFetch, dir) + } +}) + +// fmtPoints:确定性三位分组(不依赖 locale)。 +test("campaign: numbers are grouped in threes, deterministically", () => { + expect(_internal.fmtPoints(0)).toBe("0") + expect(_internal.fmtPoints(999)).toBe("999") + expect(_internal.fmtPoints(1000)).toBe("1,000") + expect(_internal.fmtPoints(14000)).toBe("14,000") + expect(_internal.fmtPoints(1234567)).toBe("1,234,567") + expect(_internal.fmtPoints(1234.6)).toBe("1,235") +}) + +// 聚合的数字边界:字符串金额、缺失 metadata、未知 mutation_type。 +// mutation_type 大小写不敏感(服务端约定小写,防御性归一后照算)。 +test("campaign: the breakdown survives weird entries without miscounting", () => { + const b = _internal.campaignBreakdown([ + ledEntry({ id: 1, wallet: "w1", amount: "4000" }), // 字符串金额(防御) + ledEntry({ id: 2, wallet: "w1", type: "debit", amount: -1000, noMeta: true }), + ledEntry({ id: 3, wallet: "w2", amount: 500, type: "Credit" }), // 大小写归一后照算 + ledEntry({ id: 4, wallet: "w3", type: "refund", amount: -99, noMeta: true }), // 未知类型 → 忽略 + ledEntry({ id: 5, wallet: "w4", type: "expire", amount: -800, noMeta: true, desc: "活动积分到期失效" }), + { id: "6", mutation_type: "credit", amount: "not-a-number", wallet_id: "w5", metadata: { wallet_scope: "campaign" } }, // 非数字 → 忽略 + null, + "junk", + ]) + expect(b.claimed).toBe(4500) + expect(b.expired).toBe(800) + expect(b.remaining).toBe(3500) // w1: 4000−1000 = 3000;w2: 500 → 3500 + expect(b.labels).toEqual(["AutoClaw Weekly Perks"]) +}) + + +// ── yetone #774 review:作者亲测「改坏也不红」的缺件 ────────────────────────── + +// review 1+2:auth.refresh 带 signinOnUsage 时,task-complete 的 403-quota +// 必须照常返回轮换后的令牌,绝不抛 signIn:expired(旧链路 maybeSignin 会把 +// 403 标成登录失效,magpie 的 renewLocked 读到就登出账号、新令牌丢失)。 +test("review#1: auth.refresh with signinOnUsage and a quota 403 at task-complete returns the rotated tokens", async () => { + const realFetch = globalThis.fetch + const freshAccess = claimJwt("rev-fresh") + let refreshes = 0 + globalThis.fetch = (input) => { + const url = String(input) + if (url.includes("/refresh") || url.includes("/agent-refresh")) { + refreshes++ + return Response.json({ code: 0, msg: "SUCCESS", data: { access_token: freshAccess, refresh_token: "rt-rev-fresh" } }) + } + if (url.includes("/autoclaw-task-complete")) { + return new Response(String.raw`{"code":810000,"message":"GLM-5.3 免费额度已用完"}`, { status: 403 }) + } + return Response.json({ code: 0, data: {} }) + } + try { + const hooks = await AutoclawAuthPlugin( + { client: { auth: { set: async () => ({ data: true }) } } }, + { signin: true, signinOnUsage: true }, + ) + const auth = claimAuth("rev-quota", { access: "at-rev-quota" }) + const t = await hooks.auth.refresh(auth) + expect(t.refresh).toBe("rt-rev-fresh") + expect(t.access).toBe(freshAccess) + expect(refreshes).toBe(1) + } finally { + _internal.clearRefreshGates() + _internal.clearRotatedMemo() + globalThis.fetch = realFetch + } +}) + +// 同一链路走 usage():403-quota 不能把整张卡变成「积分查询失败」。 +test("review#1: usage() with signinOnUsage and a quota 403 at task-complete still shows the balance", async () => { + const realFetch = globalThis.fetch + const dir = mkdtempSync(join(tmpdir(), "autoclaw-rev-")) + _internal.setUsageStatePath(join(dir, ".usage-state.json")) + globalThis.fetch = (input) => { + const url = new URL(String(input)) + if (url.pathname.endsWith("/wallets")) return Response.json({ code: 0, data: { total_balance: 4000, wallets: [] } }) + if (url.pathname.endsWith("/autoclaw-task-list")) return Response.json({ code: 0, data: [{ task_id: "daily_signin", status: "pending", reward_points: 400 }] }) + if (url.pathname.endsWith("/autoclaw-task-complete")) { + return new Response(String.raw`{"code":810000,"message":"GLM-5.3 免费额度已用完"}`, { status: 403 }) + } + if (url.pathname === "/agent-assetmgr/api/v1/ledgers_std") return led([]) + return Response.json({ code: 0, data: {} }) + } + try { + const hooks = await AutoclawAuthPlugin( + { client: { auth: { set: async () => ({ data: true }) } } }, + { signin: true, signinOnUsage: true }, + ) + const u = await hooks.auth.usage(async () => claimAuth("rev-usage", { access: "at-rev-usage" })) + expect(u.error).toBeUndefined() + expect(u.windows.some((w) => w.name === "积分余额")).toBe(true) + } finally { + _internal.clearCampaignLedger() + globalThis.fetch = realFetch + rmSync(dir, { recursive: true, force: true }) + } +}) + +// review 4a:Windows 上 HOME 常未设,配置目录必须落到用户目录,不能是盘根 +// (旧实现 `${HOME ?? ""}/.config/magpie` → C:\.config\magpie,任意本地用户可写)。 +test("review#4: the magpie config dir falls back to USERPROFILE/homedir when HOME is unset", () => { + const saved = { HOME: process.env.HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, USERPROFILE: process.env.USERPROFILE } + try { + delete process.env.HOME + delete process.env.XDG_CONFIG_HOME + process.env.USERPROFILE = "C:\\Users\\Someone" + const p = _internal.magpieCfgDir() + expect(p).toBe("C:\\Users\\Someone/.config/magpie") + expect(p).not.toBe("/.config/magpie") + expect(p).not.toContain("C:\\.config") + // USERPROFILE 也没有 → os.homedir() + delete process.env.USERPROFILE + const h = _internal.magpieCfgDir() + expect(h.endsWith("/.config/magpie")).toBe(true) + expect(h).not.toBe("/.config/magpie") + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; else process.env[k] = v + } + } +}) + +// review 4b:内存兜底里的 access token 过期后,绝不能继续喂给调用方—— +// 必须拿兜底里的新 RT 走一次真刷新。 +test("review#4: an expired rotated-memo entry is dropped and refreshed over the wire", async () => { + const realFetch = globalThis.fetch + const freshAccess = claimJwt("memo-fresh") + const bodies = [] + globalThis.fetch = async (input, init) => { + const url = String(input) + if (url.includes("/refresh") || url.includes("/agent-refresh")) { + bodies.push(JSON.parse(String(init?.body ?? "{}"))) + return Response.json({ code: 0, msg: "SUCCESS", data: { access_token: freshAccess, refresh_token: "rt-memo-next" } }) + } + return Response.json({ code: 0, data: {} }) + } + try { + _internal.clearRefreshGates() + _internal.clearRotatedMemo() + // 已花费的 RT → 一对令牌,但其中的 access 已经过期 + _internal.rememberRotated("rt-memo-spent", { access: claimJwt("memo-stale"), refresh: "rt-memo-live", expires: Date.now() - 1_000 }) + const t = await _internal.refreshShared("rt-memo-spent", []) + expect(t.access).toBe(freshAccess) + // 真的走了网络,而且用的是兜底里的新 RT(不是已花费的旧 RT) + expect(bodies.length).toBe(1) + expect(bodies[0].refresh_token).toBe("rt-memo-live") + // 对照:未过期的兜底直接命中内存,不再打网络 + _internal.rememberRotated("rt-memo-spent-2", { access: claimJwt("memo-live"), refresh: "rt-memo-live-2", expires: Date.now() + 60_000 }) + const before = bodies.length + const t2 = await _internal.refreshShared("rt-memo-spent-2", []) + expect(bodies.length).toBe(before) + expect(t2.refresh).toBe("rt-memo-live-2") + } finally { + _internal.clearRefreshGates() + _internal.clearRotatedMemo() + globalThis.fetch = realFetch + } +}) + +// review 4c:refreshTokens 在首个 host 网络错误时必须继续试下一个 host +// (旧实现第一个 host 断连就整体中止,账号永远续不上期)。 +test("review#4: a network failure on the first refresh host falls through to the next", async () => { + const realFetch = globalThis.fetch + const freshAccess = claimJwt("host-fb-fresh") + const hosts = [] + globalThis.fetch = async (input) => { + const u = new URL(String(input)) + hosts.push(u.host) + if (u.host === "autoglm-api.autoglm.ai") throw new Error("ECONNRESET") + return Response.json({ code: 0, msg: "SUCCESS", data: { access_token: freshAccess, refresh_token: "rt-host-fb" } }) + } + try { + _internal.clearRefreshGates() + const rt = claimJwt("host-fb") // 非短信账号:GLOBAL 在前、CN 在后 + const t = await _internal.refreshTokens(rt, []) + expect(hosts).toEqual(["autoglm-api.autoglm.ai", "autoglm-api.zhipuai.cn"]) + expect(t.access).toBe(freshAccess) + expect(t.refresh).toBe("rt-host-fb") + } finally { + _internal.clearRefreshGates() + globalThis.fetch = realFetch + } +}) + +// review 4d:task-complete 的 403(额度用尽)在 checkin 里必须落成 failed、 +// 不抛、不烧 RT——登录保留(dailySignin 不再把 HTTP 状态变成 signIn:expired)。 +test("review#2: a 403 at task-complete ends the day as failed, keeps the sign-in and never refreshes", async () => { + const realFetch = globalThis.fetch + let refreshes = 0 + globalThis.fetch = campFetch({ + total: 4000, + signin: () => pendingTask(), + complete: () => new Response(String.raw`{"code":810000,"message":"GLM-5.3 免费额度已用完,请购买充值"}`, { status: 403 }), + ledger: [ledEntry({ id: 1, wallet: "wlt_c", amount: 4000 })], + }) + const staleFetch = globalThis.fetch + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("/refresh") || url.includes("/agent-refresh")) refreshes++ + return staleFetch(input) + } + try { + const hooks = await campHooks() + let threw + let result + try { result = await hooks.auth.checkin(async () => campAuth("keep403")) } catch (e) { threw = e } + expect(threw).toBeUndefined() + expect(result.outcome).toBe("failed") + expect(result.signIn).toBeUndefined() + expect(refreshes).toBe(0) + } finally { + campCleanup(realFetch) + } +}) + +// review 5:campaignCached 的注释说「失败不缓存」,实现也必须如此—— +// 一次瞬时失败不能让活动行消失 10 分钟。 +test("review#5: a ledger read failure is not cached — the next usage() retries the wire", async () => { + const realFetch = globalThis.fetch + const dir = mkdtempSync(join(tmpdir(), "autoclaw-rev2-")) + _internal.setUsageStatePath(join(dir, ".usage-state.json")) + let calls = 0 + globalThis.fetch = campFetch({ total: 4000, ledger: () => { calls++; return new Response("boom", { status: 500 }) } }) + try { + const hooks = await campHooks() + const auth = campAuth("rev-retry") + const u1 = await hooks.auth.usage(async () => auth) + expect(u1.balance).toBe("4,000 积分") + expect(u1.windows.some((w) => w.name === "活动积分")).toBe(false) // 第一次读失败 → 省略活动行 + expect(calls).toBe(2) // 两个 host 都试过,都失败 + const u2 = await hooks.auth.usage(async () => auth) + expect(calls).toBe(4) // 失败未被缓存:第二次又重新打了两个 host + expect(u2.error).toBeUndefined() + expect(u2.balance).toBe("4,000 积分") + } finally { + campCleanup(realFetch, dir) + } +}) + + +// ── 代理审查修复(0.2.16 第三轮):memo 全路径、缓存复活、note 追加 ────────── + +// P1:refreshShared 成功即记 memo——auth.refresh 路径轮换后,>60s(gate 已清) +// 还持有旧 RT 的第二条记录必须拿到兜底令牌,而不是拿死 RT 打网络被判 expired。 +test("agent: a stale RT holder gets the memo'd pair after the gate is swept", async () => { + const realFetch = globalThis.fetch + let rotations = 0 + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("/refresh") || url.includes("/agent-refresh")) { + rotations++ + return Response.json({ code: 0, msg: "SUCCESS", data: { access_token: claimJwt(`memo-all-${rotations}`), refresh_token: `rt-all-${rotations}` } }) + } + return Response.json({ code: 0, data: {} }) + } + try { + _internal.clearRefreshGates() + _internal.clearRotatedMemo() + const t1 = await _internal.refreshShared("rt-spent-0", []) + expect(t1.refresh).toBe("rt-all-1") + // 模拟 60 秒后 gate 被清理(不真等:直接清 gates,memo 保留) + _internal.clearRefreshGates() + // 另一条记录拿着同一个已花费的旧 RT 续期 → 命中 memo,零额外网络 + const t2 = await _internal.refreshShared("rt-spent-0", []) + expect(t2.refresh).toBe("rt-all-1") + expect(rotations).toBe(1) + } finally { + _internal.clearRefreshGates() + _internal.clearRotatedMemo() + globalThis.fetch = realFetch + } +}) + +// P2:campaignCached 的在途读迟于失效返回时不得写回——checkin 刚删的缓存 +// 不能被旧读复活(刚领的奖励要立即可见)。 +test("agent: an in-flight ledger read that lands after invalidation is not re-cached", async () => { + _internal.clearCampaignLedger() + let release + const pending = new Promise((r) => { release = r }) + const key = "agent|uid:resurrect" + let loads = 0 + const slow = () => { loads++; return pending.then(() => ["old-entry"]) } + const p = pendingRead(key, slow) + // 在途期间:失效(checkin 里 user-info 到达时的行为) + _internal.clearCampaignLedger() + release() + expect(await p).toEqual(["old-entry"]) // 在途的调用方照常拿到它要的数据 + // 缓存必须仍是空:下一次走 loader,而不是读被复活的旧条目 + const fresh = await pendingRead(key, () => Promise.resolve(["new-entry"])) + expect(fresh).toEqual(["new-entry"]) + expect(loads).toBe(1) // 只有第一次真正加载;第二次用的是新 loader(说明没缓存旧值) +}) +// 直接驱动 campaignCached(不经过 usage,避免多路径干扰) +function pendingRead(key, loader) { + return _internal.campaignCached(key, loader) +} + +// note 追加而非覆盖:签到成功消息必须保留,活动提示只接在后面。 +test("agent: a claimed checkin keeps its own message and appends the campaign note", async () => { + const realFetch = globalThis.fetch + globalThis.fetch = campFetch({ + total: 4000, + signin: () => pendingTask(), + complete: () => signedTask(400, 3), + }) + const staleFetch = globalThis.fetch + globalThis.fetch = async (input) => { + const url = String(input) + // user-info 完全失败:登录事件没到服务端 + if (url.includes("/userapi/v1/user-info")) throw new Error("ECONNRESET") + return staleFetch(input) + } + try { + const hooks = await campHooks() + const r = await hooks.auth.checkin(async () => campAuth("appendnote")) + expect(r.outcome).toBe("claimed") + expect(r.credit).toBe(400) + // 两段都在:真实签到结果 + 活动提示(顺序固定) + expect(r.message).toContain("已签到") + expect(r.message).toContain("活动奖励未触发") + expect(r.message.indexOf("已签到")).toBeLessThan(r.message.indexOf("活动奖励未触发")) + } finally { + campCleanup(realFetch) + } +}) + +// 失败路径同样保留原因(不能被活动提示吞掉)。 +test("agent: a failed checkin keeps its failure reason when the login event missed", async () => { + const realFetch = globalThis.fetch + globalThis.fetch = campFetch({ + total: 4000, + signin: () => pendingTask(), + complete: () => new Response("boom", { status: 500 }), + }) + const staleFetch = globalThis.fetch + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("/userapi/v1/user-info")) throw new Error("ECONNRESET") + return staleFetch(input) + } + try { + const hooks = await campHooks() + const r = await hooks.auth.checkin(async () => campAuth("appendfail")) + expect(r.outcome).toBe("failed") + expect(r.message).toContain("签到接口无响应") // 真实原因保留 + expect(r.message).toContain("活动奖励未触发") + } finally { + campCleanup(realFetch) + } +}) + +// A2:md5Hex 已知向量——防止签名算法被换掉而测试仍绿(自证陷阱)。 +test("agent: the request signature is real MD5, pinned by a known vector", () => { + expect(_internal.md5Hex("abc")).toBe("900150983cd24fb0d6963f7d28e17f72") + expect(_internal.md5Hex("")).toBe("d41d8cd98f00b204e9800998ecf8427e") + expect(_internal.md5Hex("100003&1700000000&38d2391985e2369a5fb8227d8e6cd5e5")).toHaveLength(32) +}) + +// A5:北京日界(UTC+8)——16:00Z 之后已经是北京的第二天。 +test("agent: the signin day boundary is Beijing time, not UTC", () => { + expect(_internal.beijingDayOf(new Date("2026-11-06T15:30:00Z"))).toBe("2026-11-06") // 北京 23:30 + expect(_internal.beijingDayOf(new Date("2026-11-06T16:30:00Z"))).toBe("2026-11-07") // 北京次日 00:30 + expect(_internal.beijingDayOf(new Date("2026-11-06T00:30:00Z"))).toBe("2026-11-06") // 北京 08:30 +}) + +// C2:signinOnUsage 默认关——usage() 看到未签任务也绝不能自动签。 +test("agent: usage() never signs in when signinOnUsage is off (the default)", async () => { + const realFetch = globalThis.fetch + const dir = mkdtempSync(join(tmpdir(), "autoclaw-ag2-")) + _internal.setUsageStatePath(join(dir, ".usage-state.json")) + const completes = [] + globalThis.fetch = campFetch({ + total: 5000, + ledger: [], + signin: () => pendingTask(), + complete: () => { completes.push(1); return signedTask(400, 1) }, + }) + try { + const hooks = await campHooks() // 默认 options + const u = await hooks.auth.usage(async () => campAuth("defaultoff")) + expect(completes.length).toBe(0) // 没打 task-complete + expect(u.windows.find((w) => w.name === "每日签到")?.display).toBe("今日未签(+400)") + } finally { + campCleanup(realFetch, dir) + } +}) + +// C12:maybeSignin 同日去重(北京日),且第二次调用不再打网络。 +test("agent: maybeSignin signs once per Beijing day per account", async () => { + const realFetch = globalThis.fetch + let signs = 0 + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("/autoclaw-task-complete")) { + signs++ + return Response.json({ code: 0, data: { success: true, reward_points: 400, continuous_days: 1 } }) + } + return Response.json({ code: 0, data: {} }) + } + try { + const opts = { signin: true, signinOnUsage: true } + const key = `agent-dedupe-${Date.now()}` + await _internal.maybeSignin("at-x", "https://autoglm-api.autoglm.ai", opts, "usage", key) + await _internal.maybeSignin("at-x", "https://autoglm-api.autoglm.ai", opts, "usage", key) + expect(signs).toBe(1) + } finally { + globalThis.fetch = realFetch + } +}) + + +// ── maintainer 第三轮 review(4 项):日界测试、淘汰语义 ──────────────────── + +// 北京日界在 maybeSignin 里的使用(他亲手把 beijingDayOf() 改成 UTC 日后 90 测试 +// 仍全绿)。用 bun 的 setSystemTime 停在「两套日界结论不同」的时刻,双向验证: +// ① 同一北京日、跨 UTC 日 → 绝不重签(UTC 版会签两次);② 同一 UTC 日、 +// 跨北京日 → 必须重签(UTC 版会断签)。 +// 恢复必须用无参的 setSystemTime():带参调用(含 setSystemTime(Date.now())) +// 只是把时钟冻结在另一个常量上,不会交还真实时间(review #4 实测, +// qoder 的 checkin 测试的 afterEach 也是这么做的)。 +test("maintainer: maybeSignin dedupes by the Beijing day, in both directions", async () => { + const realFetch = globalThis.fetch + let signs = 0 + globalThis.fetch = async (input) => { + const url = String(input) + if (url.includes("/autoclaw-task-complete")) { + signs++ + return Response.json({ code: 0, data: { success: true, reward_points: 400, continuous_days: 1 } }) + } + return Response.json({ code: 0, data: {} }) + } + const opts = { signin: true, signinOnUsage: true } + const host = "https://autoglm-api.autoglm.ai" + const key = "bj-day" + try { + // 起点:北京 11-07 07:30(UTC 11-06 23:30)——UTC 日 11-06、北京日 11-07 + setSystemTime(new Date("2026-11-06T23:30:00Z")) + await _internal.maybeSignin("at-bj", host, opts, "usage", key) + expect(signs).toBe(1) + // +1h:北京 11-07 08:30(UTC 11-07 00:30)——UTC 日翻了、北京日没翻 → 不重签 + setSystemTime(new Date("2026-11-07T00:30:00Z")) + await _internal.maybeSignin("at-bj", host, opts, "usage", key) + expect(signs).toBe(1) + // +24h:北京 11-08 07:30(UTC 11-07 23:30)——北京日翻了、UTC 日没翻 → 必须重签 + setSystemTime(new Date("2026-11-07T23:30:00Z")) + await _internal.maybeSignin("at-bj", host, opts, "usage", key) + expect(signs).toBe(2) + } finally { + setSystemTime() // 无参 = 交还真实时钟(带参会冻结,见上) + globalThis.fetch = realFetch + } +}) + +// 淘汰只动「已定」条目:在途读被撕掉的话,它的结果无处写回(白打一次网络)。 +// 100 个在途读占满缓存 → 插入第 101 条不得撕任何在途读 → 在途读完成后写回生效。 +test("maintainer: cache eviction never evicts an in-flight read", async () => { + _internal.clearCampaignLedger() + const resolvers = [] + const inFlight = [] + try { + for (let i = 0; i < 100; i++) { + inFlight.push(_internal.campaignCached(`if-${i}`, () => new Promise((r) => resolvers.push(() => r([`v${i}`]))))) + } + const v = await _internal.campaignCached("if-new", () => Promise.resolve(["new"])) + expect(v).toEqual(["new"]) + // 放行所有在途读,并等它们自己的写成落定(不猜微任务跳数) + for (const rel of resolvers) rel() + await Promise.all(inFlight) + let loads = 0 + const again = await _internal.campaignCached("if-0", () => { loads++; return Promise.resolve(["again"]) }) + expect(again).toEqual(["v0"]) // 读到的是写回的缓存值,不是新加载 + expect(loads).toBe(0) // 若 if-0 被淘汰,这里会重新加载(loads=1 → 红) + } finally { + _internal.clearCampaignLedger() + } +}) + +// 上限本身也要守卫(review #4:删掉 while (size >= MAX) 循环这条测试必须红): +// 100 条已定 + 第 101 条 → 最旧的 cap-0 被挤出(读它要走网络),而新的头部还在。 +// 注意 FIFO:读回 cap-0 又是一次插入,会把当时的次旧条目 cap-1 挤掉—— +// 所以第二段断言选 cap-2(读 cap-0 时它还在)。 +test("maintainer: a settled entry is evicted once the cache passes its cap", async () => { + _internal.clearCampaignLedger() + try { + for (let i = 0; i < 100; i++) await _internal.campaignCached(`cap-${i}`, () => Promise.resolve([`v${i}`])) + const v = await _internal.campaignCached("cap-new", () => Promise.resolve(["new"])) + expect(v).toEqual(["new"]) + // cap-0 已被挤出:读它必须重新加载(loads=1);这次重读又会挤掉下一旧的 + let loads0 = 0 + const first = await _internal.campaignCached("cap-0", () => { loads0++; return Promise.resolve(["v0-again"]) }) + expect(first).toEqual(["v0-again"]) + expect(loads0).toBe(1) + // 刚插入的 cap-new 是最新条目,仍在缓存(loads=0)——证明淘汰只挤最旧的 + let loadsNew = 0 + const again = await _internal.campaignCached("cap-new", () => { loadsNew++; return Promise.resolve(["nope"]) }) + expect(again).toEqual(["new"]) + expect(loadsNew).toBe(0) + } finally { + _internal.clearCampaignLedger() + } +}) + +// 进程时钟必须归还:setSystemTime(Date.now) 这类带参调用会把时钟冻在常量上, +// 全仓单进程跑测试时后面所有包基于时钟的等待会死等(CI 曾挂 55 分钟)。 +// 守卫:上面的测试恢复后(无参 setSystemTime()),时钟必须照常流逝; +// 若有人改回带参恢复,这里会红。 +test("maintainer: the fake clock never leaks into the rest of the suite", async () => { + const t0 = Date.now() + await Bun.sleep(60) + expect(Date.now() - t0).toBeGreaterThanOrEqual(40) +}) diff --git a/packages/autoclaw/package.json b/packages/autoclaw/package.json index 25b15d95d..4f4d9fce0 100644 --- a/packages/autoclaw/package.json +++ b/packages/autoclaw/package.json @@ -1,6 +1,6 @@ { "name": "@magpie-community/opencode-autoclaw-auth", - "version": "0.2.16", + "version": "0.2.17", "description": "OpenCode provider plugin for AutoClaw (Zhipu OpenClaw): oversea web sign-in (Google/z.ai) and CN phone SMS, multi-account pool, credit balance and daily check-in", "type": "module", "main": "./opencode.mjs",