33 KiB
凭证与认证生命周期
同一份"我的令牌存在哪、什么时候过期、改了 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 Key(workspace key) | ~/.claude.json(userSettings 的 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.json 的 env 字段(OPENAI_API_KEY / GEMINI_API_KEY / GROK_API_KEY 或 XAI_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 命令,第一屏的 AuthPlaneSummary(src/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 支持四种登录路径,选择哪一种取决于你有什么:
-
claude.ai 订阅账号(Anthropic OAuth):在
/login的 ConsoleOAuthFlow 里走 OAuth 设备码流程——它会给你一个 URL 和一个 code,浏览器打开、授权、回来。成功后令牌写进~/.claude/.credentials.json。这是推荐路径,因为它走 Anthropic 官方 OAuth,token 自动刷新、不需要你管过期。 -
Anthropic API Key(直连 API):两种方式。一是
export ANTHROPIC_API_KEY=sk-ant-...写进 shell;二是在/login里按 W,输入 key,它会存到~/.claude.json的workspaceApiKey("workspace" 是因为按工作目录可覆盖)。settings 里的 key 优先级高于 env var——如果你两个都设了,settings 赢。 -
ChatGPT 订阅当后端(复用 OpenAI 订阅):
OPENAI_AUTH_MODE=chatgpt打开后,/login会走 OpenAI 的设备码流程(https://auth.openai.com/codex/device),成功后令牌写进~/.claude/openai-chatgpt-auth.json。这条路径最大的彩蛋是跨工具共享:如果你之前装过 OpenAI 官方的 Codex CLI,它的令牌存在~/.codex/auth.json,Claude Code 在自己的文件找不到时会自动回退读 Codex 的(getValidChatGPTAuth的第二段,src/services/api/openai/chatgptAuth.ts:339-346)。换句话说:你在 Codex CLI 登录过,Claude Code 直接就能用,不用重复登录。 -
OpenAI 兼容 / Gemini / Grok / 中国 LLM:全部走
/login的表单填写流程。选 Provider、填 Base URL(OpenAI 兼容层必填)、填 Key、选模型。提交后写入~/.claude/settings.json的env字段,同时把modelType改成对应的 Provider。中国 LLM 是这条路径的一个精巧分支:在 ConsoleOAuthFlow 里选 "China LLM Provider"(src/components/ConsoleOAuthFlow.tsx:1294的china_provider_select表单),会给你一个预设列表,目前包含 DeepSeek、智谱 GLM、通义千问、小米 MiMo 四家(src/utils/chinaLlmProviders.ts:44的CHINA_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 OAuth:
isOAuthTokenExpired(src/services/oauth/client.ts:344)用bufferTime = 5 * 60 * 1000(5 分钟)。当前时间 + 5 分钟 ≥ 过期时间就认为"快过期",触发刷新。 - ChatGPT OAuth:
REFRESH_SKEW_MS = 5 * 60 * 1000(src/services/api/openai/chatgptAuth.ts:9),同样的 5 分钟窗口。
为什么是 5 分钟不是 1 分钟? 这是容错设计:API 请求的端到端延迟(包括网络、排队、模型推理)可能就有几秒到几十秒。如果你卡在"过期前 10 秒才刷新",刷新完成时令牌可能已经过期了,请求被拒。5 分钟窗口给整个请求链路留出足够余量——刷新完拿到新令牌,再用它发请求,时间上稳稳的。
多进程场景:如果你同时开了几个 Claude Code 终端,它们都会发现令牌过期、都想去刷新。checkAndRefreshOAuthTokenIfNeededImpl(src/utils/auth.ts:1443)用了 lockfile.lock(claudeDir) 文件锁——谁先抢到锁谁刷新,其他进程等锁、拿到锁后再检查一次令牌是否已被刷新("double-checked locking"),是的话直接用新令牌、不重复刷新。还有一个跨进程失效机制(invalidateOAuthCacheIfDiskChanged,auth.ts:1316):进程 A 的 /login 写了新令牌到 .credentials.json,进程 B 通过 mtime 检测到文件变了,清掉自己的内存缓存、重读——避免"B 用 A 早就 revoke 掉的旧令牌反复 401"的死循环。
第四件事:我改了 API key 但没生效?三个最常见的"为什么"
这是排错章节里最高频的三个困惑,全部跟凭证生命周期有关。
困惑 A:我在 /login 输了新 key,为什么下一个请求还在用旧的?
如果你切的是 claude.ai 订阅或 Anthropic API Key(workspaceApiKey),/login 的 onDone 回调(src/commands/login/login.tsx:33-65)会做一连串副作用:stripSignatureBlocks(清掉绑旧 key 的签名块)、resetCostState(重置费用统计)、authVersion++(强制 hook 重新拉取 auth 相关数据)。这些做完之后下一次请求就是新 key。
但如果你切的是 OpenAI 兼容层 / Grok,就要小心了:getOpenAIClient(src/services/api/openai/client.ts:39)和 getGrokClient(src/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.env(ConsoleOAuthFlow.tsx:1464-1470 的 process.env[k] = v 循环),但不会自动 clear client cache——这是已知的"改 key 必须重启"陷阱,尤其影响 dev 模式下的迭代调试。
困惑 B:我跑了 /provider unset,为什么 key 还在?
/provider unset(src/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.json 的 modelType 清空。它不会清 OPENAI_API_KEY / GEMINI_API_KEY / GROK_API_KEY 这些 key 本身。
这是有意为之——unset 的语义是"回到默认 Provider(firstParty)",不是"清空所有认证"。如果你想彻底清掉某个 Provider 的 key,要手动编辑 ~/.claude/settings.json 的 env 字段,或者 /logout(见下文)。
例外:如果你切到的是 bedrock / vertex / foundry 这三个云 Provider(provider.ts:147-161 的 else 分支),代码会顺手 delete process.env.OPENAI_API_KEY 和 delete 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 导致问题"。
具体症状:buildFetch(src/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_URL,isFirstPartyAnthropicBaseUrl() 会因为 ANTHROPIC_BASE_URL 不存在而返回 true,然后这个注入逻辑就被错误地激活了。目前没有完美绕过,只能同时设 ANTHROPIC_BASE_URL 显式指向你的端点(哪怕你不调 Anthropic 协议)来让判断走 host 比较分支。
第五件事:/logout 到底清掉了什么
/logout(src/commands/logout/logout.tsx)是"全部清空"按钮。performLogout 会做这一串:
flushTelemetry(先 flush 再清凭证,避免清了之后还拿着旧 org 的 telemetry 数据往外发)removeApiKey(清 Anthropic API Key)removeChatGPTAuth(删~/.claude/openai-chatgpt-auth.json)clearChatGPTSettingsAuthMode(清OPENAI_AUTH_MODEenv 和 settings)secureStorage.delete()(清安全存储——macOS keychain 或 fallback)clearAuthRelatedCaches(清 OAuth token 缓存、betas 缓存、tool schema 缓存、user cache、Grove 配置缓存、远程管理 settings 缓存、policy limits 缓存)saveGlobalConfig改oauthAccount: undefined(清账号关联)- 2 秒后
gracefulShutdownSync(0, 'logout')——logout 之后进程会退出
所以 /logout 之后你必须重新 /login。它不像 /provider unset 那样保留 key、只切 Provider。
给同事分享对话前要注意什么
/share 和 /export 的产物默认不包含凭证原文,但有几个隐私边界要注意:
/share(src/commands/share/index.ts)会把错误信息里的 home 目录路径替换成~、把长 stack trace 截断到 200 字符(sanitizeErrorMessage,share/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.json 的 env、workspace key 在 ~/.claude.json。为什么不收敛到一个 ~/.claude/credentials.json?
三个理由,重要性递减:
-
凭证生命周期不一样。Anthropic OAuth 令牌会自动刷新、文件会被多进程并发写(
auth.ts:1443的 lockfile 锁),它需要独立的文件做 mtime 检测(invalidateOAuthCacheIfDiskChanged,auth.ts:1316)。ChatGPT OAuth 也会刷新但走完全不同的 OAuth 端点(auth.openai.comvs Anthropic 的 OAuth 服务器),它有自己的刷新逻辑(refreshTokens,chatgptAuth.ts:289)。如果塞同一个文件,两种刷新逻辑要协调文件锁、mtime、原子写——复杂度爆炸。按 Provider 分文件,让每个 Provider 自己管自己的生命周期,是最干净的切分。 -
跨工具复用要求路径兼容。ChatGPT 订阅路径回退读
~/.codex/auth.json(chatgptAuth.ts:339-346)是为了复用 Codex CLI 已登录的凭证——用户在 Codex 登过,Claude Code 就能用,不用重复登录。这个设计的前提是"不修改 Codex 的文件"——Claude Code 只读它,写还是写自己的~/.claude/openai-chatgpt-auth.json。如果两个工具共用一个文件,谁刷新令牌、谁负责写、文件锁怎么共享都会变成跨工具协调问题。只读对方、写自己是最低耦合的复用方式。 -
环境变量与 settings 的分层。OpenAI / Gemini / Grok 的 key 是通过
process.env读的(getOpenAIClient的process.env.OPENAI_API_KEY,client.ts:46),但/login把它们写到settings.json的env字段是为了持久化 + 跨会话生效。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 工厂对比:
getOpenAIClient(src/services/api/openai/client.ts:39):let cachedClient: OpenAI | null = null,首次调用创建实例后赋给cachedClient,之后直接 return。需要清空时调clearOpenAIClientCache()(client.ts:76)把cachedClient = null。getGrokClient(src/services/api/grok/client.ts:15):完全相同的模式,cachedClient+clearGrokClientCache()。getAnthropicClient(src/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 不同的 SDK(client.ts:153-298),还要 await checkAndRefreshOAuthTokenIfNeeded()、await refreshAndGetAwsCredentials()、await refreshGcpCredentialsIfNeeded()——这些都是异步、有副作用的。每次调用都走一遍这套流程,相当于每次 API 请求都触发一次凭证刷新检查。关键在于 Anthropic 路径的 client 实例按参数化构造——getAnthropicClient({ apiKey, model, ... }) 接收 model/region 参数,不同 model(比如 Haiku vs Sonnet)可能要走不同的 AWS region(ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION,client.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 路径的额外复杂度:getValidChatGPTAuth(chatgptAuth.ts:339-361)还有一条读 Codex 文件的回退逻辑。先读 ~/.claude/openai-chatgpt-auth.json,读不到再读 ~/.codex/auth.json。为什么这么做? 因为 OpenAI 官方 Codex CLI 用的是同一个 OAuth client_id(CLIENT_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-62 的 unset 分支:
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(见产品视角)——它做完整的清空 + 进程退出。unset 和 logout 的分工是:unset 改 Provider 选择(可逆,不动凭证),logout 清认证身份(不可逆,进程退出)。
有意思的对比:/provider 切换到 bedrock / vertex / foundry(云 Provider)时(provider.ts:147-161),代码会顺手 delete process.env.OPENAI_API_KEY 和 delete process.env.OPENAI_BASE_URL。为什么这三个 Provider 特殊? 因为云 Provider 走的是 Anthropic 协议(Bedrock / Vertex / Foundry 都是 Anthropic 模型在云厂商的托管),不应该带着 OpenAI 协议的 key 跑——带了反而可能让 SDK 误判走错路径。Gemini / Grok 的 key 不被清,是因为它们和 firstParty 之间不存在协议混淆风险(Provider 选择本身就是排他的)。
为什么 /login 的 onDone 要做那么多副作用
打开 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 身份的数据"的撕裂。
逐条看:
stripSignatureBlocks:thinking 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 0o600,settings.json 不要
打开 saveStoredAuth(chatgptAuth.ts:148-165)——写 openai-chatgpt-auth.json 时显式 mode: 0o600,然后 chmod(path, 0o600) 兜底(chatgptAuth.ts:164)。为什么这么严格?
因为这个文件包含 access_token / refresh_token / id_token——任何能读这个文件的人都能冒用你的 ChatGPT 订阅。0600(owner 读写,其他人无权限)是文件系统层面的最低保护。兜底的 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 keychain,OpenAI 兼容层的 key 走明文 settings
打开 src/utils/secureStorage/——有 macOsKeychainStorage.ts / plainTextStorage.ts / fallbackStorage.ts。workspaceApiKey(Anthropic 的自定义 API Key)在 macOS 上会优先走 keychain(src/utils/auth.ts 的 getApiKeyFromApiKeyHelper 流程)。但 OpenAI / Gemini / Grok 的 key 直接写在 settings.json 的 env 字段、明文存储。
为什么不对称? 两个原因:
- 历史路径依赖。Anthropic 的 API Key 存储从早期就走 keychain(因为 Anthropic 是默认 Provider,它的 key 是核心凭证)。OpenAI 兼容层是后加的(反编译重建时恢复的),它复用了
settings.json的env字段——这个字段本来就是"明文环境变量配置",加 key 进去是最低改造成本。 - 跨平台。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 var(export 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 偏差窗口改了会出问题"——而不是把每个决策都重新走一遍、甚至不小心破坏跨工具凭证复用或多进程刷新安全。