首页
/ last30days-skill:让 AI Agent 的 Grok CLI 认证状态不再撒谎——Grok Auth Honesty 修复深度解析

last30days-skill:让 AI Agent 的 Grok CLI 认证状态不再撒谎——Grok Auth Honesty 修复深度解析

2026-09-04 12:17:19作者:乔或婵

本文基于 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 话题调研)暴露了如下故障链:

  1. 用户的 PATH 上装有 grok 二进制,~/.grok/auth.json 文件存在且包含 token 标记,doctor 探针缓存了「grok 状态 ok / 将使用 grok」的结论;
  2. 但旧版 stored_auth_status() 只做子串扫描(找 refresh_token / access_token / auth_mode),从不解析 expires_at
  3. 该文件里的 expires_at2026-08-14T01:26:53Z,距运行时刻已过期数小时;
  4. 早前 07:47:26 UTC 的运行 run_outcome 还是 ok(2 条结果),会话当时是活的;
  5. 08:43 运行时,grok 加载凭证、判定 is_expired,触发 OIDC 刷新,刷新返回 invalid_grant("Refresh token has been revoked"),grok 直接删除了 auth.json
  6. 引擎以退出码 1("Not signed in")结束,X 源回退到 bird 后端(通过 Safari cookies 拿到 30 条),lane 被标记为 PARTIAL;
  7. 最终宿主(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 内容绝不进入日志或诊断输出。

判断逻辑的完整流程:

  1. ~/.grok/auth.json 不存在 → AUTH_MISSING
  2. 文件不可读(OSError)→ AUTH_ERROR
  3. 文件内容不含任何 _TOKEN_STORE_MARKERSrefresh_token / access_token / auth_mode / "key" 子串)→ AUTH_MISSING
  4. JSON 解析出 expires_at 且早于当前 UTC 时间 → AUTH_EXPIRED,细节字符串附带过期时刻与提示:「refresh may restore it; if revoked, run grok login --device-auth」;
  5. 其余情况(含 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_OKhealth.OK,但细节字符串始终带诚实注脚:"(not live-verified until a run)"——本地探针只读到「凭证存在且未过期」,不代表服务端会话仍然有效;
  • AUTH_EXPIREDhealth.DEGRADED(而非 OK),细节附带具体过期时刻:"Grok session expired at {expiry}; refresh happens at run time (if revoked, run grok login --device-auth)",处方(prescription)直接给出 grok login --device-auth
  • AUTH_ERRORhealth.ERROR
  • AUTH_MISSINGhealth.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 expiredsession expired or was revokedinvalid_grantnot signed in 等标记统一映射为 schema.AUTH_FAILED 状态。

AUTH_FAILED 平行的还有 classify_run_failure(),它把 grok 失败细分为 AUTH_FAILED / TIMEOUT / ERROR 三档,供 doctor 和宿主展示对症的修复建议。类型化结果的价值在于:一个 auth-failed 的 X 源和「产品半吊子工作了」的泛化 PARTIAL 读起来完全不同——前者指向「请重新登录」,后者什么都没说。

测试侧对应 tests/test_grok_x.pytest_is_auth_revoked_error_detects_markers(正例含 Not signed ininvalid_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.stateauth-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)。

最终验收的五条成功标准(计划标注全部达成):

  1. 过去 expires_at 的 fixture → 不再报告 grok ok;
  2. 未来 expires_at → 仍报 ok(明确标注为「非在线验证」的本地判断);
  3. 无 grok 二进制 → 不产生额外的用户可见失败;
  4. 模拟「先前 run_outcome 为 ok 之后出现 Not signed in」→ 输出类型化 auth-failed / 回退话术,而非「从未登录」;
  5. 无子进程的 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 凭证」的工具链都适用:

  1. 诊断状态要穷举而非二分。 「有凭证 / 没凭证」的二值模型天然丢掉了「有凭证但过期」这一最大灰区;四值状态机(OK / EXPIRED / MISSING / ERROR)让每一层都能说人话;
  2. 本地静态判断与运行时动态验证解耦。 doctor 只承诺文件系统层的事实,并永远带上 "not live-verified" 注脚;运行层才允许产生真实验证。active_backend 是预测,run_outcome 是证据;
  3. 过期的 access token 不是死刑判决。 尊重 OIDC 双 token 语义:静态看到过期 → 预警但继续尝试;运行时报 invalid_grant → 才判定吊销,并且同一运行内不再重试
  4. 失败必须类型化。 auth-failed 与泛化 PARTIAL 的区别不在数据,在于宿主能否据此给出正确修复动作——而宿主恰恰是 LLM,话术模板(SKILL.md 中的指导)直接决定了用户最终看到什么。

这套「诚实性」约束完全离线可测:所有测试用 tmp_path 写入构造好的 auth.json fixture,无任何网络调用,uv run pytest 即可复验。

登录后查看全文
热门项目推荐
相关项目推荐