last30days-skill:让 AI Agent 的 Grok CLI 认证状态不再撒谎——Grok Auth Honesty 修复深度解析
本文基于 last30days-skill 仓库中的修复计划文档 docs/plans/2026-08-14-fix-grok-auth-honesty-plan.md,完整还原这次「Grok 认证诚实性」修复的问题现场、设计取舍与落地实现。读完本文,你将掌握:如何区分「从未登录」「已登录但会话过期」「会话中途被吊销」三种认证状态;如何在纯本地(无网络、无子进程)的前提下解析 expires_at 判断会话死活;以及如何用类型化失败(typed outcome)替代含糊的 PARTIAL 状态,让 AI Agent 宿主在转述失败原因时不误导用户。
问题现场:一次被「半真半假」状态误导的运行
2026-08-14,一次针对用户 Mac 的真实运行(Peter Steinberger 话题调研)暴露了如下故障链:
- 用户的
PATH上装有grok二进制,~/.grok/auth.json文件存在且包含 token 标记,doctor 探针缓存了「grok 状态 ok / 将使用 grok」的结论; - 但旧版
stored_auth_status()只做子串扫描(找refresh_token/access_token/auth_mode),从不解析expires_at; - 该文件里的
expires_at是2026-08-14T01:26:53Z,距运行时刻已过期数小时; - 早前 07:47:26 UTC 的运行
run_outcome还是ok(2 条结果),会话当时是活的; - 08:43 运行时,grok 加载凭证、判定
is_expired,触发 OIDC 刷新,刷新返回invalid_grant("Refresh token has been revoked"),grok 直接删除了auth.json; - 引擎以退出码 1("Not signed in")结束,X 源回退到 bird 后端(通过 Safari cookies 拿到 30 条),lane 被标记为 PARTIAL;
- 最终宿主(AI Agent)对用户说「Grok CLI is not signed in」——仿佛它从来没有登录过。
这个案例暴露的核心缺陷是:「曾经登录、会话已死」这一状态被报告为 ok,运行后又退化成泛化的 PARTIAL。用户看到的是一条自相矛盾的信息链——诊断说一切正常,运行说没登录,两者都不对。
三种必须区分的认证状态
修复计划把 Grok 后端的认证现实压缩为三个必须区分的状态:
| 状态 | 含义 | 应有的行为 |
|---|---|---|
| 1. 未安装 grok CLI | 无二进制 | 静默回退到其他 X 后端,不打扰用户,不要每次调研都唠叨安装 |
| 2. 已安装、从未登录 | 无有效凭证 | 同样静默回退 |
| 3. 已安装、曾登录、会话已死 | 凭证存在但过期/被吊销 | 这是本修复的目标:诊断层报告 degraded + 过期时间戳;运行层仍尝试刷新;刷新失败则给出类型化 auth-failed 结果并说明「会话过期后回退到了 X」,而不是「从未登录」 |
第 3 态之所以难,是因为它在不同时间点呈现不同面貌:诊断时刻它是「文件在、标记在」,看起来和正常登录无异;运行时刻它可能自我修复(OIDC 刷新成功),也可能彻底死亡(refresh token 被吊销,grok 删除凭证文件)。
实现一:stored_auth_status() 本地解析 expires_at
修复的核心是把 skills/last30days/scripts/lib/grok_x.py 中原本三值的认证状态扩展为四值:
AUTH_OK = "ok" # token store present with non-expired credentials
AUTH_EXPIRED = "expired" # credentials present but access_token expires_at is past
AUTH_MISSING = "missing" # no token store, or no credentials stored in it
AUTH_ERROR = "error" # token store exists but could not be read
stored_auth_status() 的关键实现约束有三条:
纯本地,永不 spawn 子进程。 函数 docstring 明确写道:这是 doctor / --diagnose / --preflight 使用的表面,绝不能启动进程——因为整条 doctor 路径的测试会把 subprocess.run patch 成抛异常来验证这一点。所有判断只基于对 ~/.grok/auth.json 的文件系统读取。
递归搜索 expires_at,不假设结构。 _find_expires_at() 在嵌套 dict/list 中递归查找任意深度的 expires_at 键。原因是 grok 的 auth.json 按 issuer 和 principal 分组,具体形状由厂商说了算,引擎不能依赖固定路径。
永不回显 token 值。 状态细节字符串只包含路径、过期时间戳和建议命令,token 内容绝不进入日志或诊断输出。
判断逻辑的完整流程:
~/.grok/auth.json不存在 →AUTH_MISSING;- 文件不可读(
OSError)→AUTH_ERROR; - 文件内容不含任何
_TOKEN_STORE_MARKERS(refresh_token/access_token/auth_mode/"key"子串)→AUTH_MISSING; - JSON 解析出
expires_at且早于当前 UTC 时间 →AUTH_EXPIRED,细节字符串附带过期时刻与提示:「refresh may restore it; if revoked, rungrok login --device-auth」; - 其余情况(含
expires_at缺失或不可解析)→AUTH_OK,即「保守乐观」。
对应测试在 tests/test_grok_x.py 中覆盖了全部四类 fixture(无文件、未来 expires_at、过去 expires_at、不可解析的 JSON),且全部离线运行。例如:
def test_stored_auth_status_past_expires_at_is_expired(monkeypatch, tmp_path):
"""Credentials with expires_at in the past report AUTH_EXPIRED, not AUTH_OK."""
store.write_text(f'{{"iss": {{"refresh_token": "tok", "expires_at": "{past}"}}}}')
status, detail, expires_at = grok_x.stored_auth_status()
assert status == grok_x.AUTH_EXPIRED
实现二:doctor 探针把 AUTH_EXPIRED 映射为 DEGRADED,而非 OK
skills/last30days/scripts/lib/backends.py 的 _probe_grok() 是 doctor 的诊断入口。修复后它对四种状态的映射为:
AUTH_OK→health.OK,但细节字符串始终带诚实注脚:"(not live-verified until a run)"——本地探针只读到「凭证存在且未过期」,不代表服务端会话仍然有效;AUTH_EXPIRED→health.DEGRADED(而非 OK),细节附带具体过期时刻:"Grok session expired at {expiry}; refresh happens at run time (if revoked, rungrok login --device-auth)",处方(prescription)直接给出grok login --device-auth;AUTH_ERROR→health.ERROR;AUTH_MISSING→health.MISSING(已装未登录)。
值得注意的是这个探针刻意不调用 health.probe_dependency()(后者会执行 subprocess.run([name, "--version"])),以遵守 doctor 的「无子进程」承诺。代价是:PATH 上能解析但实际无法执行的陈旧 shim 类二进制会在此报 OK,只在真实运行时才暴露——源码注释中明确承认了这个诚实的局限,并指出真正的执行探针在 doctor 的 CLI-health 块中运行。
这带来一个配套设计:active_backend 从此只是一个预测而非事实。当 run_outcome.at 过期或状态非 ok 时,doctor 的措辞变为 "will use grok, unverified since <time>"——从源码结构看,「诊断结论」与「运行实证」被有意解耦成两个字段。
实现三:调研时仍要尝试——过期不等于刷新已死
这是整个修复里最微妙的一条设计取舍:运行时的 is_available() 即使 access_token 已过期,只要存在 refresh_token 标记,仍然返回 True 并发起 grok 调用。
_is_available_uncached() 的注释把理由写得很直白:
expires_at being in the past does not prove the refresh_token is dead. The CLI will attempt OIDC refresh at run time and might succeed. Only a runtime failure ("Not signed in", invalid_grant) proves the session is truly revoked.
OIDC 的典型生命周期是:access token 短命(分钟到小时级),refresh token 长命。access token 过期是常态,refresh 是常态修复路径。如果因为本地看到 expires_at 已过去就跳过 grok,等于放弃所有「刷新可能成功」的运行。所以分层是:
- 诊断层(doctor):诚实地说「已过期,运行时会尝试刷新,若刷新被吊销请重新登录」——这是给用户的预警;
- 运行层(pipeline):照常尝试,让 grok 自己去刷新——这是实际机会;
- 失败层:只有运行时错误才证明会话真正死了,进入第四条防线。
实现四:吊销检测与类型化失败
运行时真正的防线在 grok_x.py 的三层传递上:
1. 吊销标记。 _AUTH_REVOKED_MARKERS 元组收集了会话死亡的特征子串:
_AUTH_REVOKED_MARKERS = (
"not signed in",
"not logged in",
"invalid_grant",
"refresh token has been revoked",
"session expired",
"authentication failed",
"unauthorized",
)
2. 调用层。 _invoke() 在 grok 以非零退出码结束时,对 stderr/stdout 前 300 字符做 is_auth_revoked_error() 检查;命中则在错误响应里附加 "auth_revoked": True。
3. 重试抑制。 _run_query() 返回三元组 (items, error, auth_revoked)。普通错误会重试(attempts=2),但 auth_revoked 会立即短路返回——同一运行内不重试 grok。原因很清楚:refresh token 已被吊销,重试只会重复触发 invalid_grant 并消耗用户预算。
4. 管道层分类。 pipeline.py 中:
- _fetch_x_backend() 看到
auth_revoked后,把错误串改写为带"grok session expired or was revoked"前缀的形式返回,使下游分类器能识别; - _classify_source_failure() 把
grok session expired、session expired or was revoked、invalid_grant、not signed in等标记统一映射为schema.AUTH_FAILED状态。
与 AUTH_FAILED 平行的还有 classify_run_failure(),它把 grok 失败细分为 AUTH_FAILED / TIMEOUT / ERROR 三档,供 doctor 和宿主展示对症的修复建议。类型化结果的价值在于:一个 auth-failed 的 X 源和「产品半吊子工作了」的泛化 PARTIAL 读起来完全不同——前者指向「请重新登录」,后者什么都没说。
测试侧对应 tests/test_grok_x.py 的 test_is_auth_revoked_error_detects_markers(正例含 Not signed in、invalid_grant: Refresh token has been revoked,负例含超时与空串)和 test_search_x_returns_auth_revoked_on_session_failure;backend 侧的过期状态测试在 tests/test_backend_descriptors.py。
实现五:宿主话术——「过期后回退」而非「从未登录」
SKILL.md 为宿主 Agent 写下了明确的转述规范。要点包括:
- doctor 报告 grok 为 degraded 且附过期时间戳时,宿主应说:「Grok session expired at {timestamp}; will attempt refresh at run time. If refresh fails, run
grok login --device-auth」——而不是「Grok CLI is not signed in」(后者歪曲了「之前登录过且成功过」的历史); - 当
sources.x.run_outcome.state是auth-failed而上一次运行的 outcome 是ok时,宿主应表述为「X used <fallback> after the Grok session expired」并附登录提示; - 宿主不得把
active_backend当作已验证的事实来引用(它只是预测); - 除非用户明确要求一手 X 数据,宿主不应消耗一个回合去安装 grok。
这条规则的本质是给 LLM 宿主一份「认证历史感知」的说话指南:run_outcome 里存的每次运行状态,让宿主能区分「从来没用过」和「用过后死了」,两种情况对用户意味着完全不同的修复动作(安装+登录 vs 一条 grok login --device-auth)。
边界、非目标与成功标准
计划文档划定的范围边界:X 查询构造、fanout、search_name、retrieve-judge-retry、handle 提升机制均不在本 PR 内变动——那些属于另一个独立 PR(见 docs/plans/2026-08-14-feat-x-retrieve-judge-retry-plan.md)。
最终验收的五条成功标准(计划标注全部达成):
- 过去
expires_at的 fixture → 不再报告 grok ok; - 未来
expires_at→ 仍报 ok(明确标注为「非在线验证」的本地判断); - 无 grok 二进制 → 不产生额外的用户可见失败;
- 模拟「先前 run_outcome 为 ok 之后出现 Not signed in」→ 输出类型化
auth-failed/ 回退话术,而非「从未登录」; - 无子进程的 doctor 测试(
subprocess.run被 patch 成抛异常)仍然通过。
变更文件清单
| 文件 | 职责 |
|---|---|
| skills/last30days/scripts/lib/grok_x.py | AUTH_EXPIRED 常量、stored_auth_status() 返回三元组、is_auth_revoked_error()、classify_run_failure()、_invoke() 设置 auth_revoked、_run_query() 返回三元组、search_x() 传播 auth_revoked |
| skills/last30days/scripts/lib/backends.py | _probe_grok() 把 AUTH_EXPIRED 映射为 DEGRADED |
| skills/last30days/scripts/lib/pipeline.py | _fetch_x_backend() 传播 auth_revoked;_classify_source_failure() 识别 grok 吊销标记 |
| skills/last30days/SKILL.md | Grok 会话过期的宿主话术指导 |
| tests/test_grok_x.py | expires_at 解析与吊销检测测试(全离线 fixture) |
| tests/test_backend_descriptors.py | grok 过期状态的探针测试 |
changelog.d/+grok-auth-expired.fixed.md |
发布说明片段(towncrier 风格) |
设计启示:给 Agent 工具链做「诚实性工程」
这次修复表面是修一个认证状态误报,实际沉淀了四条可复用的工程原则,对任何「LLM 宿主 + 外部 CLI 凭证」的工具链都适用:
- 诊断状态要穷举而非二分。 「有凭证 / 没凭证」的二值模型天然丢掉了「有凭证但过期」这一最大灰区;四值状态机(OK / EXPIRED / MISSING / ERROR)让每一层都能说人话;
- 本地静态判断与运行时动态验证解耦。 doctor 只承诺文件系统层的事实,并永远带上 "not live-verified" 注脚;运行层才允许产生真实验证。
active_backend是预测,run_outcome是证据; - 过期的 access token 不是死刑判决。 尊重 OIDC 双 token 语义:静态看到过期 → 预警但继续尝试;运行时报
invalid_grant→ 才判定吊销,并且同一运行内不再重试; - 失败必须类型化。
auth-failed与泛化 PARTIAL 的区别不在数据,在于宿主能否据此给出正确修复动作——而宿主恰恰是 LLM,话术模板(SKILL.md 中的指导)直接决定了用户最终看到什么。
这套「诚实性」约束完全离线可测:所有测试用 tmp_path 写入构造好的 auth.json fixture,无任何网络调用,uv run pytest 即可复验。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00