Files
claude-code/docs/outline-output/cross/07-credentials-auth.md
2026-06-15 16:51:29 +08:00

33 KiB
Raw Blame History

凭证与认证生命周期

同一份"我的令牌存在哪、什么时候过期、改了 key 为什么没生效"的困惑,在使用者眼里是"我刚才输的那串 sk-... 到底被写到了哪个文件、能不能给同事看、明天还会不会自动登录",在开发者眼里是"为什么 getOpenAIClient 要做模块级缓存、为什么 ChatGPT 订阅路径要去读 Codex CLI 的 ~/.codex/auth.json、为什么 OAuth 刷新要留 5 分钟偏差窗口、为什么 /provider unset 只清 Provider 不清 key"。凭证生命周期天然是双视角主题——用户想知道"我的密钥去了哪里、安不安全",开发者想知道"为什么 token 这么存、这个缓存策略逼出了哪些权衡、跨工具复用凭证是怎么落到代码里的"。

产品视角(写给使用者)

这一节回答一个几乎每个新用户都会撞上的问题:我的密钥和登录令牌,到底去了哪里?什么时候会过期?我改了 key 为什么有时候不生效? 我们按"凭证存哪 → 怎么登录 → 怎么刷新 → 怎么排错"四段走,每段都给你能直接照做的步骤。

第一件事:搞清楚你的凭证存在哪个文件

Claude Code 的凭证不是一个统一的地方,而是按 Provider 分散在好几个文件。下面这张清单是你需要知道的全部位置(默认 CLAUDE_CONFIG_DIR 没被改写时,它等于 ~/.claude

凭证类型 存储位置 谁会写它 谁会读它
Anthropic OAuth 令牌claude.ai 订阅) ~/.claude/.credentials.json /login OAuth 流程、自动刷新 getAnthropicClient 每次 API 调用前
自定义 Anthropic API Keyworkspace key ~/.claude.jsonuserSettings 的 workspaceApiKey 字段) /login 里按 W 输入 getAuthStatus / getAnthropicApiKey
ANTHROPIC_API_KEY 环境变量 你的 shell 配置(.zshrc / .bashrc / CI secrets 你自己 优先级低于 settings 里的 workspaceApiKey
ChatGPT 订阅令牌(用 ChatGPT 订阅当后端) ~/.claude/openai-chatgpt-auth.json /login 选 "ChatGPT account" 后写 getValidChatGPTAuth 每次 OpenAI 请求前
Codex CLI 共享令牌(跨工具复用) ~/.codex/auth.json OpenAI 官方 Codex CLI Claude Code 找不到自己的 chatgpt 凭证时会回退读它
OpenAI / Gemini / Grok 兼容层 API Key ~/.claude/settings.jsonenv 字段(OPENAI_API_KEY / GEMINI_API_KEY / GROK_API_KEYXAI_API_KEY /login 表单填写 各 Provider 的 client 实例化时读 process.env
Bridge 模式的会话 JWT 运行时签发,sk-ant-si- 前缀 Remote Control 服务端 Bridge 每次请求带在 Authorization 头
个人覆盖配置(settings.local.json ~/.claude/settings.local.json 你手动编辑 不进 git覆盖 settings.json

怎么自查:跑 /login 命令,第一屏的 AuthPlaneSummarysrc/commands/login/AuthPlaneSummary.tsx)会把当前生效的凭证来源摘要给你看——是 env var 还是 settings、有没有 workspace key、是不是 claude.ai 订阅。这个摘要永远不会回显密钥原文getAuthStatus 的注释明确写了 "ANTHROPIC_API_KEY / workspaceApiKey values are NEVER returned raw; only their presence and source"),所以你截图给同事看是安全的。

第二件事:用 /login 还是手动改配置?四种登录方式怎么选

Claude Code 支持四种登录路径,选择哪一种取决于你有什么:

  1. claude.ai 订阅账号Anthropic OAuth:在 /login 的 ConsoleOAuthFlow 里走 OAuth 设备码流程——它会给你一个 URL 和一个 code浏览器打开、授权、回来。成功后令牌写进 ~/.claude/.credentials.json。这是推荐路径,因为它走 Anthropic 官方 OAuthtoken 自动刷新、不需要你管过期。

  2. Anthropic API Key直连 API:两种方式。一是 export ANTHROPIC_API_KEY=sk-ant-... 写进 shell二是在 /login 里按 W输入 key它会存到 ~/.claude.jsonworkspaceApiKey"workspace" 是因为按工作目录可覆盖)。settings 里的 key 优先级高于 env var——如果你两个都设了settings 赢。

  3. ChatGPT 订阅当后端(复用 OpenAI 订阅)OPENAI_AUTH_MODE=chatgpt 打开后,/login 会走 OpenAI 的设备码流程(https://auth.openai.com/codex/device),成功后令牌写进 ~/.claude/openai-chatgpt-auth.json这条路径最大的彩蛋是跨工具共享:如果你之前装过 OpenAI 官方的 Codex CLI它的令牌存在 ~/.codex/auth.jsonClaude Code 在自己的文件找不到时会自动回退读 Codex 的(getValidChatGPTAuth 的第二段,src/services/api/openai/chatgptAuth.ts:339-346)。换句话说:你在 Codex CLI 登录过Claude Code 直接就能用,不用重复登录

  4. OpenAI 兼容 / Gemini / Grok / 中国 LLM:全部走 /login 的表单填写流程。选 Provider、填 Base URLOpenAI 兼容层必填)、填 Key、选模型。提交后写入 ~/.claude/settings.jsonenv 字段,同时把 modelType 改成对应的 Provider。中国 LLM 是这条路径的一个精巧分支:在 ConsoleOAuthFlow 里选 "China LLM Provider"src/components/ConsoleOAuthFlow.tsx:1294china_provider_select 表单),会给你一个预设列表,目前包含 DeepSeek、智谱 GLM、通义千问、小米 MiMo 四家(src/utils/chinaLlmProviders.ts:44CHINA_LLM_PROVIDERS),每家还分"按量计费 API"和"包月 Coding Plan"两档 base URL。选完之后它自动填好 base URL、你只需要填 key不用记地址。

一个重要差别前三种claude.ai 订阅 / API Key / ChatGPT 订阅)属于"认证"后一种OpenAI 兼容层 / Gemini / Grok属于"换 Provider"。/login 命令同时处理两件事,但 /provider 只处理后者——见下文排错段。

第三件事:令牌什么时候过期、怎么自动刷新

如果你用 claude.ai 订阅或 ChatGPT 订阅,你不需要手动刷新令牌。Claude Code 在每次 API 调用前会检查令牌是否快过期,快过期就自动刷新。

关键的时间窗口是 5 分钟偏差。无论是 Anthropic OAuth 还是 ChatGPT OAuth代码都用同一个常量

  • Anthropic OAuthisOAuthTokenExpiredsrc/services/oauth/client.ts:344)用 bufferTime = 5 * 60 * 10005 分钟)。当前时间 + 5 分钟 ≥ 过期时间就认为"快过期",触发刷新。
  • ChatGPT OAuthREFRESH_SKEW_MS = 5 * 60 * 1000src/services/api/openai/chatgptAuth.ts:9),同样的 5 分钟窗口。

为什么是 5 分钟不是 1 分钟? 这是容错设计API 请求的端到端延迟(包括网络、排队、模型推理)可能就有几秒到几十秒。如果你卡在"过期前 10 秒才刷新"刷新完成时令牌可能已经过期了请求被拒。5 分钟窗口给整个请求链路留出足够余量——刷新完拿到新令牌,再用它发请求,时间上稳稳的。

多进程场景:如果你同时开了几个 Claude Code 终端,它们都会发现令牌过期、都想去刷新。checkAndRefreshOAuthTokenIfNeededImplsrc/utils/auth.ts:1443)用了 lockfile.lock(claudeDir) 文件锁——谁先抢到锁谁刷新,其他进程等锁、拿到锁后再检查一次令牌是否已被刷新("double-checked locking"),是的话直接用新令牌、不重复刷新。还有一个跨进程失效机制invalidateOAuthCacheIfDiskChangedauth.ts:1316):进程 A 的 /login 写了新令牌到 .credentials.json,进程 B 通过 mtime 检测到文件变了,清掉自己的内存缓存、重读——避免"B 用 A 早就 revoke 掉的旧令牌反复 401"的死循环。

第四件事:我改了 API key 但没生效?三个最常见的"为什么"

这是排错章节里最高频的三个困惑,全部跟凭证生命周期有关。

困惑 A我在 /login 输了新 key为什么下一个请求还在用旧的

如果你切的是 claude.ai 订阅或 Anthropic API KeyworkspaceApiKey/loginonDone 回调(src/commands/login/login.tsx:33-65)会做一连串副作用:stripSignatureBlocks(清掉绑旧 key 的签名块)、resetCostState(重置费用统计)、authVersion++(强制 hook 重新拉取 auth 相关数据)。这些做完之后下一次请求就是新 key。

但如果你切的是 OpenAI 兼容层 / Grok,就要小心了:getOpenAIClientsrc/services/api/openai/client.ts:39)和 getGrokClientsrc/services/api/grok/client.ts:15)都是模块级缓存客户端实例——首次调用读 process.env.OPENAI_API_KEY 创建 OpenAI SDK 实例,之后整个会话直接返回这个缓存的实例。你在会话中途改了 process.env.OPENAI_API_KEY,缓存里的 client 还握着旧 key。

解决办法:要么重启 Claude Code最简单要么代码层面调一次 clearOpenAIClientCache()client.ts:76)或 clearGrokClientCache()grok/client.ts:42)。注意/login 表单改 key 的流程会同步更新 process.envConsoleOAuthFlow.tsx:1464-1470process.env[k] = v 循环),但不会自动 clear client cache——这是已知的"改 key 必须重启"陷阱,尤其影响 dev 模式下的迭代调试。

困惑 B我跑了 /provider unset,为什么 key 还在?

/provider unsetsrc/commands/provider.ts:49-62)只清 Provider 选择本身——它 delete 的是 CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_USE_VERTEX / CLAUDE_CODE_USE_FOUNDRY / CLAUDE_CODE_USE_OPENAI / CLAUDE_CODE_USE_GEMINI / CLAUDE_CODE_USE_GROK 这一组 Provider 触发变量,并把 settings.jsonmodelType 清空。它不会清 OPENAI_API_KEY / GEMINI_API_KEY / GROK_API_KEY 这些 key 本身

这是有意为之——unset 的语义是"回到默认 ProviderfirstParty",不是"清空所有认证"。如果你想彻底清掉某个 Provider 的 key要手动编辑 ~/.claude/settings.jsonenv 字段,或者 /logout(见下文)。

例外:如果你切到的是 bedrock / vertex / foundry 这三个云 Providerprovider.ts:147-161 的 else 分支),代码会顺手 delete process.env.OPENAI_API_KEYdelete process.env.OPENAI_BASE_URL——因为这些云 Provider 不应该带着 OpenAI 的 key 跑。但 gemini 和 grok 的 key 不会被清。

困惑 C我设了 OPENAI_BASE_URL 指向自己的端点,为什么有些行为还像在调官方 API

这是 isFirstPartyAnthropicBaseUrl() 的 TODO 陷阱(src/utils/model/providers.ts:43-58)。代码注释直白地写着:"这里会有问题, 只配置了 openai 协议的用户, 按理说会为 true 导致问题"。

具体症状:buildFetchsrc/services/api/client.ts:366-367)会在 getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl() 都为真时,给每个请求注入一个 x-client-request-id header用于服务端日志关联。但 isFirstPartyAnthropicBaseUrl() 只看 ANTHROPIC_BASE_URL,不看 OPENAI_BASE_URL。如果你只设了 OPENAI_BASE_URL 指向自托管端点、没设 ANTHROPIC_BASE_URLisFirstPartyAnthropicBaseUrl() 会因为 ANTHROPIC_BASE_URL 不存在而返回 true,然后这个注入逻辑就被错误地激活了。目前没有完美绕过,只能同时设 ANTHROPIC_BASE_URL 显式指向你的端点(哪怕你不调 Anthropic 协议)来让判断走 host 比较分支。

第五件事:/logout 到底清掉了什么

/logoutsrc/commands/logout/logout.tsx)是"全部清空"按钮。performLogout 会做这一串:

  1. flushTelemetry flush 再清凭证,避免清了之后还拿着旧 org 的 telemetry 数据往外发)
  2. removeApiKey(清 Anthropic API Key
  3. removeChatGPTAuth(删 ~/.claude/openai-chatgpt-auth.json
  4. clearChatGPTSettingsAuthMode(清 OPENAI_AUTH_MODE env 和 settings
  5. secureStorage.delete()清安全存储——macOS keychain 或 fallback
  6. clearAuthRelatedCaches(清 OAuth token 缓存、betas 缓存、tool schema 缓存、user cache、Grove 配置缓存、远程管理 settings 缓存、policy limits 缓存)
  7. saveGlobalConfigoauthAccount: undefined(清账号关联)
  8. 2 秒后 gracefulShutdownSync(0, 'logout')——logout 之后进程会退出

所以 /logout 之后你必须重新 /login。它不像 /provider unset 那样保留 key、只切 Provider。

给同事分享对话前要注意什么

/share/export 的产物默认不包含凭证原文,但有几个隐私边界要注意:

  • /sharesrc/commands/share/index.ts)会把错误信息里的 home 目录路径替换成 ~、把长 stack trace 截断到 200 字符(sanitizeErrorMessageshare/index.ts:31-39)。这是为了避免在分享链接里泄漏你的本地路径结构。但它不会扫描对话内容里的 key——如果你在对话里粘贴过密钥"帮我调试一下,我的 key 是 sk-..."),那段文本会被原样分享出去。分享前自己搜一下 sk- 之类的敏感前缀。
  • /export 导出的是 transcript 的子集(消息、工具调用、结果),同样不主动扫密钥。导出的 JSON 里不会有 ~/.claude/.credentials.json 的内容,但会有你在对话里手动输入过的任何东西。

最稳的做法:分享前 /clear 开一个干净会话复现问题,避免把历史对话里可能含的敏感信息带出去。

设计视角(写给开发者)

这一节回答一组环环相扣的设计问题:为什么 Claude Code 的凭证存储是分散的而不是统一的?为什么 getOpenAIClient 做模块级缓存、getAnthropicClient 不做?为什么 ChatGPT 订阅路径要去读 Codex CLI 的凭证文件?为什么 OAuth 刷新的偏差窗口两边都是 5 分钟?为什么 /provider unset 的清理边界画在"Provider 触发变量"而不是"全部凭证" 每个决策都不是随手做的——它们各自回应一个具体的约束或权衡。

为什么凭证存储是按 Provider 分散的,而不是统一一个文件

打开凭证文件清单你会发现Anthropic OAuth 在 ~/.claude/.credentials.json、ChatGPT OAuth 在 ~/.claude/openai-chatgpt-auth.json、Codex CLI 共享在 ~/.codex/auth.json、各兼容层 key 在 ~/.claude/settings.jsonenv、workspace key 在 ~/.claude.json为什么不收敛到一个 ~/.claude/credentials.json

三个理由,重要性递减:

  1. 凭证生命周期不一样。Anthropic OAuth 令牌会自动刷新、文件会被多进程并发写(auth.ts:1443 的 lockfile 锁),它需要独立的文件做 mtime 检测(invalidateOAuthCacheIfDiskChangedauth.ts:1316。ChatGPT OAuth 也会刷新但走完全不同的 OAuth 端点(auth.openai.com vs Anthropic 的 OAuth 服务器),它有自己的刷新逻辑(refreshTokenschatgptAuth.ts:289。如果塞同一个文件两种刷新逻辑要协调文件锁、mtime、原子写——复杂度爆炸。按 Provider 分文件,让每个 Provider 自己管自己的生命周期,是最干净的切分。

  2. 跨工具复用要求路径兼容。ChatGPT 订阅路径回退读 ~/.codex/auth.jsonchatgptAuth.ts:339-346)是为了复用 Codex CLI 已登录的凭证——用户在 Codex 登过Claude Code 就能用,不用重复登录。这个设计的前提是"不修改 Codex 的文件"——Claude Code 只读它,写还是写自己的 ~/.claude/openai-chatgpt-auth.json。如果两个工具共用一个文件,谁刷新令牌、谁负责写、文件锁怎么共享都会变成跨工具协调问题。只读对方、写自己是最低耦合的复用方式。

  3. 环境变量与 settings 的分层。OpenAI / Gemini / Grok 的 key 是通过 process.env 读的(getOpenAIClientprocess.env.OPENAI_API_KEYclient.ts:46),但 /login 把它们写到 settings.jsonenv 字段是为了持久化 + 跨会话生效applyConfigEnvironmentVariables(在 /provider 命令末尾调用,provider.ts:145)负责把 settings.json 的 env 字段反推回 process.env,这样 client 实例化时就能读到。为什么不直接写 shell rc 文件? 因为 Claude Code 不应该改你的 shell 环境——那会把它的配置泄漏到所有终端会话。settings.json 的 env 字段是"只在 Claude Code 进程内生效的 env var",作用域正确。

这条分散设计的代价:用户(和文档)需要记住五个不同的文件位置。这是清晰的复杂度——集中式存储看似简洁,但要把五种不同的刷新策略、并发安全、跨工具兼容塞进一个文件,复杂度只会更高、更难调试。

为什么 getOpenAIClient 做模块级缓存,getAnthropicClient 不做

打开两个 client 工厂对比:

  • getOpenAIClientsrc/services/api/openai/client.ts:39let cachedClient: OpenAI | null = null,首次调用创建实例后赋给 cachedClient,之后直接 return。需要清空时调 clearOpenAIClientCache()client.ts:76)把 cachedClient = null
  • getGrokClientsrc/services/api/grok/client.ts:15):完全相同的模式,cachedClient + clearGrokClientCache()
  • getAnthropicClientsrc/services/api/client.ts:84没有模块级缓存。每次调用都走完整的 client 构造流程——读 env、检查 OAuth、动态 import Bedrock/Foundry/Vertex SDK、构造 new Anthropic(...)new BedrockClient(...) 等。

为什么这种不对称? 因为两个家族的 client 构造代价完全不同。

OpenAI / Grok 的 client 构造很便宜——读三个 env var、new OpenAI({ apiKey, baseURL, ... }) 就完了。但每次 API 请求都重新构造一个 OpenAI SDK 实例会有隐性开销SDK 内部会建立 HTTP agent、连接池、重试策略。缓存这个实例让连接池能复用,是合理的性能优化。

Anthropic 路径的 client 构造代价高且动态:它要根据 CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_USE_VERTEX / CLAUDE_CODE_USE_FOUNDRY 动态 import 不同的 SDKclient.ts:153-298),还要 await checkAndRefreshOAuthTokenIfNeeded()await refreshAndGetAwsCredentials()await refreshGcpCredentialsIfNeeded()——这些都是异步、有副作用的。每次调用都走一遍这套流程,相当于每次 API 请求都触发一次凭证刷新检查。关键在于 Anthropic 路径的 client 实例按参数化构造——getAnthropicClient({ apiKey, model, ... }) 接收 model/region 参数,不同 model比如 Haiku vs Sonnet可能要走不同的 AWS regionANTHROPIC_SMALL_FAST_MODEL_AWS_REGIONclient.ts:157-160)。模块级单例缓存根本对不上这种参数化需求。

这条不对称的代价就是产品视角提到的"困惑 A"——会话中途改 OpenAI/Grok key缓存里的 client 握着旧 key。clearOpenAIClientCache 是逃生口,但 /login 表单流程没调它。这是"性能优化 vs 配置变更"的固有张力:缓存越激进,改配置越要手动清缓存。

为什么 clearOpenAIClientCache 还存在? 因为它服务于 dev/调试场景——开发者在 REPL 里 process.env.OPENAI_API_KEY = '...' 手动改环境变量做实验,调一次 clear 就能强制重建 client。生产用户的等价操作是重启进程。

为什么 OAuth 刷新偏差窗口两边都是 5 分钟

打开两处刷新判断的代码:

// Anthropic OAuth —— src/services/oauth/client.ts:344
export function isOAuthTokenExpired(expiresAt: number | null): boolean {
  if (expiresAt === null) return false;
  const bufferTime = 5 * 60 * 1000;  // 5 分钟
  const now = Date.now();
  const expiresWithBuffer = now + bufferTime;
  return expiresWithBuffer >= expiresAt;
}

// ChatGPT OAuth —— src/services/api/openai/chatgptAuth.ts:9
const REFRESH_SKEW_MS = 5 * 60 * 1000;  // 同样 5 分钟
// ...
if (expiresAt !== null && expiresAt <= Date.now() + REFRESH_SKEW_MS) {
  tokens = await refreshTokens(tokens);
  await saveStoredAuth(tokens);
}

两边都是 5 分钟,不是巧合。这个数字回应一个共同的约束:API 请求的端到端延迟不可忽略

考虑这条时间线:getValidChatGPTAuth 判断"快过期"→ 触发 refreshTokens(一次 OAuth 端点的网络 round-trip可能 200ms-2s→ 拿到新 access_token → 用它发 API 请求(排队 + 模型推理,几秒到几十秒)。如果偏差窗口留得太短(比如 10 秒),就会出现:判断"还没过期"→ 用旧 token 发请求 → 请求到达服务端时 token 已经过期 → 401。5 分钟窗口给整个请求链路(刷新 + 排队 + 推理)留出了充足余量。

为什么不更长,比如 30 分钟? 因为偏差窗口越长刷新越频繁OAuth 服务端承受的 refresh 请求越多。对 Anthropic 这种用户量级,每个用户每 25 分钟刷一次 vs 每 55 分钟刷一次服务端负载差一倍。5 分钟是"请求链路延迟的上界估计 + 余量"的工程取舍——它不会卡到过期边界,也不会刷新得太勤。

ChatGPT 路径的额外复杂度getValidChatGPTAuthchatgptAuth.ts:339-361)还有一条读 Codex 文件的回退逻辑。先读 ~/.claude/openai-chatgpt-auth.json,读不到再读 ~/.codex/auth.json为什么这么做? 因为 OpenAI 官方 Codex CLI 用的是同一个 OAuth client_idCLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'chatgptAuth.ts:7)——也就是说 Codex CLI 和 Claude Code 在 OpenAI 那边注册的是同一个应用。用户在 Codex 登录拿到的令牌Claude Code 拿来直接能用,因为对 OpenAI 服务端来说是同一个 client。这是一个相当大胆的跨工具复用决策——它把"Codex 装了 → Claude Code 免登录"做成了零配置体验,代价是两个工具的 OAuth client_id 必须永远保持一致。

为什么 /provider unset 的清理边界画在 Provider 触发变量,而不清 key

打开 src/commands/provider.ts:49-62unset 分支:

if (arg === 'unset') {
  updateSettingsForSource('userSettings', { modelType: undefined });
  // Also clear all provider-specific env vars to prevent conflicts
  delete process.env.CLAUDE_CODE_USE_BEDROCK;
  delete process.env.CLAUDE_CODE_USE_VERTEX;
  delete process.env.CLAUDE_CODE_USE_FOUNDRY;
  delete process.env.CLAUDE_CODE_USE_OPENAI;
  delete process.env.CLAUDE_CODE_USE_GEMINI;
  delete process.env.CLAUDE_CODE_USE_GROK;
  return {
    type: 'text',
    value: 'API provider cleared (will use environment variables).',
  };
}

它清的是 modelType 和六个 CLAUDE_CODE_USE_*——全部是"Provider 选择"层。它不清 OPENAI_API_KEY / GEMINI_API_KEY / GROK_API_KEY / OPENAI_BASE_URL / 任何 settings.json env 字段里实际存的 key。

为什么这么画边界? 因为"切换 Provider"和"清空凭证"是两个独立的用户意图。/provider unset 的返回文案说得很清楚:"API provider cleared (will use environment variables)"——它的语义是"回到 firstParty 默认,接下来按 env var 决定行为"不是"把我所有的 key 都删了"。如果 unset 顺手清了 key用户切个 Provider 试一下、再切回来key 就没了——这是不可接受的数据丢失。

真正"清凭证"的命令是 /logout(见产品视角)——它做完整的清空 + 进程退出。unsetlogout 的分工是:unset 改 Provider 选择(可逆,不动凭证),logout 清认证身份(不可逆,进程退出)。

有意思的对比/provider 切换到 bedrock / vertex / foundry云 Providerprovider.ts:147-161),代码会顺手 delete process.env.OPENAI_API_KEYdelete process.env.OPENAI_BASE_URL为什么这三个 Provider 特殊? 因为云 Provider 走的是 Anthropic 协议Bedrock / Vertex / Foundry 都是 Anthropic 模型在云厂商的托管),不应该带着 OpenAI 协议的 key 跑——带了反而可能让 SDK 误判走错路径。Gemini / Grok 的 key 不被清,是因为它们和 firstParty 之间不存在协议混淆风险Provider 选择本身就是排他的)。

为什么 /loginonDone 要做那么多副作用

打开 src/commands/login/login.tsx:33-65——onDone 回调在登录成功后会做这一串:

context.onChangeAPIKey();
context.setMessages(stripSignatureBlocks);  // 清掉绑旧 key 的签名块
resetCostState();                            // 重置费用统计
void refreshRemoteManagedSettings();         // 拉新的远程管理 settings
void refreshPolicyLimits();                  // 拉新的 policy limits
resetUserCache();                            // 清 user 数据缓存
refreshGrowthBookAfterAuthChange();          // 刷 GrowthBook feature flags
clearTrustedDeviceToken();                   // 清旧的 trusted device token
void enrollTrustedDevice();                  // 重新注册 trusted device
resetAutoModeGateCheck();                    // 重置 auto mode 检查
context.setAppState(prev => ({ ...prev, authVersion: prev.authVersion + 1 }));

为什么这么多副作用? 因为登录本质上是"切换身份"——身份变了,所有跟身份绑定的状态都得跟着刷新,否则就会出现"用 A 身份登录、UI 上显示的还是 B 身份的数据"的撕裂。

逐条看:

  • stripSignatureBlocksthinking blocks 和 connector_text 这些字段在 API 响应里是带签名的(绑 API key。新 key 不能验证旧 key 的签名,所以必须清掉,否则下一次请求会被服务端拒。
  • resetCostState:费用统计是按账号累计的,换账号必须清零。
  • refreshRemoteManagedSettings / refreshPolicyLimits:远程管理的 settings 和 policy limits 是按 org/account 下发的,换账号要重新拉。
  • resetUserCache + refreshGrowthBookAfterAuthChange顺序很重要——必须先清 user cache 再刷 GrowthBook否则 GrowthBook 会拿到旧账号的 user 数据去判 feature flag。注释login.tsx:46-48)专门写了这一点。
  • clearTrustedDeviceToken + enrollTrustedDevice也必须先清再注册login.tsx:51-54 注释)——否则异步的 enrollTrustedDevice 还在飞行中时bridge 调用可能拿着旧账号的 trusted device token 发出去。
  • authVersion++:这是一个"脏检查"版本号。useAppState 的 hook 订阅这个字段,它变了就触发重新拉取 auth 相关数据(比如 MCP server 列表是按账号不同的)。

这条设计的核心原则:登录不是"换一个字符串",而是"切换一整套绑身份的状态"。onDone 这串副作用是在明确枚举所有跟身份绑定的子系统,确保它们同步更新。代价是这条回调很长、修改时要小心——加一个新的"绑身份"子系统,必须在这里加对应的刷新调用,否则就会出现状态撕裂。这是"集中式身份切换"的维护成本。

为什么凭证文件要 chmod 0o600settings.json 不要

打开 saveStoredAuthchatgptAuth.ts:148-165)——写 openai-chatgpt-auth.json 时显式 mode: 0o600,然后 chmod(path, 0o600) 兜底(chatgptAuth.ts:164)。为什么这么严格?

因为这个文件包含 access_token / refresh_token / id_token——任何能读这个文件的人都能冒用你的 ChatGPT 订阅。0600owner 读写,其他人无权限)是文件系统层面的最低保护。兜底的 chmod 是为了应付 umask 没生效或跨平台差异——某些系统 writeFile({ mode: 0o600 }) 会被 umask 削成 0644显式 chmod 把权限补回去。

对比settings.json 里的 OPENAI_API_KEY 没有这种保护——它就是普通 JSON 文件,按你的 umask 走。为什么差别对待? 因为 API key 是可以撤销的(去服务商面板 revoke泄露后的止损路径清晰。OAuth refresh_token 撤销要复杂得多(要走 OAuth revocation endpoint、还可能影响其他用同一 OAuth 应用的工具)。敏感度越高,文件权限越严——这是一个朴素但被严格执行的原则。

为什么 Anthropic 的 workspace key 走 macOS keychainOpenAI 兼容层的 key 走明文 settings

打开 src/utils/secureStorage/——有 macOsKeychainStorage.ts / plainTextStorage.ts / fallbackStorage.tsworkspaceApiKeyAnthropic 的自定义 API Key在 macOS 上会优先走 keychainsrc/utils/auth.tsgetApiKeyFromApiKeyHelper 流程)。但 OpenAI / Gemini / Grok 的 key 直接写在 settings.json 的 env 字段、明文存储。

为什么不对称? 两个原因:

  1. 历史路径依赖。Anthropic 的 API Key 存储从早期就走 keychain因为 Anthropic 是默认 Provider它的 key 是核心凭证。OpenAI 兼容层是后加的(反编译重建时恢复的),它复用了 settings.jsonenv 字段——这个字段本来就是"明文环境变量配置",加 key 进去是最低改造成本。
  2. 跨平台。macOS keychain 是平台特性Linux / Windows 没有等价物(fallbackStorage.ts 是降级方案。OpenAI 兼容层要在所有平台一致工作,最简单就是不用 keychain。Anthropic 路径在非 macOS 平台也会降级到 fallback 存储。

这条不对称的安全含义:你的 OPENAI_API_KEY / GEMINI_API_KEY / GROK_API_KEY明文存在 ~/.claude/settings.json 里的。任何能读这个文件的进程(包括你运行的任何脚本、任何被攻破的进程)都能拿到这些 key。实践建议:如果你在共享机器上用,把 key 放 shell env varexport OPENAI_API_KEY=...)而不是 /login 表单——shell 配置文件至少权限是 0600 默认、不进 git。

两视角如何呼应

用户视角的每一个"凭证相关的痛点",在设计视角都能找到对应的边界决策:

  • "我的密钥去了哪里"(产品视角的凭证文件清单)对应 "为什么凭证存储按 Provider 分散、为什么不收敛到一个文件"设计视角——用户要记五个文件位置是因为三种凭证生命周期OAuth 自动刷新 / API Key 手动管理 / 兼容层 env 配置)的并发安全和跨工具复用要求不同的存储策略,强行合并只会让复杂度从"五个文件"变成"一个文件里的五种锁"。
  • "我改了 key 为什么没生效"(产品视角困惑 A对应 "getOpenAIClient 为什么做模块级缓存、getAnthropicClient 为什么不做"(设计视角)——用户遇到的是"改了 key 还在用旧的",开发者看到的是"连接池复用的性能优化 vs 配置变更的缓存失效"的固有张力。clearOpenAIClientCache 是逃生口,但 /login 表单没调它——这是已知的设计缺口,不是 bug。
  • "令牌什么时候过期、怎么自动刷新"(产品视角第三段)对应 "为什么两边偏差窗口都是 5 分钟、为什么有跨进程 lockfile"(设计视角)——用户看到的是"不用手动刷新,自动续期",开发者看到的是"API 请求端到端延迟的工程余量 + 多进程并发刷新的 double-checked locking + 跨进程 mtime 失效"的三重设计。
  • "/provider unset 为什么 key 还在"(产品视角困惑 B对应 "为什么 unset 的清理边界画在 Provider 触发变量、不清 key 本身"(设计视角)——用户期望 unset 是"全部清空",开发者把它定位成"可逆的 Provider 切换",把"不可逆的凭证清空"留给 /logout。两个命令的分工是明确且有意的。
  • "用 Codex CLI 登过Claude Code 为什么不用再登"(产品视角第三种登录路径)对应 "ChatGPT 路径为什么读 ~/.codex/auth.json、为什么两个工具共用一个 OAuth client_id"(设计视角)——用户看到的是"零配置跨工具体验",开发者看到的是"两个工具注册为同一个 OAuth 应用、只读对方凭证、写自己凭证"的最低耦合复用,代价是 client_id 永远不能改。
  • "分享对话前要注意什么"(产品视角末段)对应 "sanitizeErrorMessage 为什么只清路径不清 key、为什么 /share/export 不主动扫密钥"(设计视角)——用户被告知"分享前自己搜一下 sk-",开发者看到的是"自动扫密钥的误报风险(误伤合法的 sk- 前缀 demo key和实现成本要支持几十种 Provider 的 key 格式识别),所以只做路径清理这种零误报的操作,把 key 识别留给用户"。

这种呼应关系是"凭证与认证生命周期"必须双视角覆盖的核心原因:用户视角告诉你密钥去哪了、怎么管理、出了问题怎么自救,设计视角告诉你为什么 token 这么存、这个缓存策略逼出了什么权衡、跨工具复用是怎么落到代码里的。两个视角合在一起,才能让使用者正确选择登录方式(订阅 OAuth / API Key / 兼容层表单 / 跨工具复用)并知道每种方式的凭证文件位置和过期行为,也让开发者在改 Provider 系统时知道"为什么不能把所有 key 塞一个文件、为什么 client 缓存策略要按 Provider 家族区分、为什么 OAuth 偏差窗口改了会出问题"——而不是把每个决策都重新走一遍、甚至不小心破坏跨工具凭证复用或多进程刷新安全。