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

261 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 凭证与认证生命周期
> 同一份"我的令牌存在哪、什么时候过期、改了 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.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 支持四种登录路径,选择哪一种取决于你有什么:
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.json``workspaceApiKey`"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.json`Claude 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.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` 的语义是"回到默认 ProviderfirstParty",不是"清空所有认证"。如果你想彻底清掉某个 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` 会做这一串:
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. `saveGlobalConfig``oauthAccount: undefined`(清账号关联)
8. **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`**
三个理由,重要性递减:
1. **凭证生命周期不一样**。Anthropic OAuth 令牌会自动刷新、文件会被多进程并发写(`auth.ts:1443` 的 lockfile 锁),它需要独立的文件做 mtime 检测(`invalidateOAuthCacheIfDiskChanged``auth.ts:1316`。ChatGPT OAuth 也会刷新但走完全不同的 OAuth 端点(`auth.openai.com` vs Anthropic 的 OAuth 服务器),它有自己的刷新逻辑(`refreshTokens``chatgptAuth.ts:289`。如果塞同一个文件两种刷新逻辑要协调文件锁、mtime、原子写——复杂度爆炸。**按 Provider 分文件,让每个 Provider 自己管自己的生命周期**,是最干净的切分。
2. **跨工具复用要求路径兼容**。ChatGPT 订阅路径回退读 `~/.codex/auth.json``chatgptAuth.ts:339-346`)是为了**复用 Codex CLI 已登录的凭证**——用户在 Codex 登过Claude Code 就能用,不用重复登录。这个设计的前提是"不修改 Codex 的文件"——Claude Code 只读它,写还是写自己的 `~/.claude/openai-chatgpt-auth.json`。如果两个工具共用一个文件,谁刷新令牌、谁负责写、文件锁怎么共享都会变成跨工具协调问题。**只读对方、写自己**是最低耦合的复用方式。
3. **环境变量与 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 分钟
打开两处刷新判断的代码:
```ts
// 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` 分支:
```ts
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` 回调在登录成功后会做这一串:
```ts
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 订阅。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.ts``workspaceApiKey`Anthropic 的自定义 API Key在 macOS 上会优先走 keychain`src/utils/auth.ts``getApiKeyFromApiKeyHelper` 流程)。但 OpenAI / Gemini / Grok 的 key 直接写在 settings.json 的 `env` 字段、明文存储。
**为什么不对称?** 两个原因:
1. **历史路径依赖**。Anthropic 的 API Key 存储从早期就走 keychain因为 Anthropic 是默认 Provider它的 key 是核心凭证。OpenAI 兼容层是后加的(反编译重建时恢复的),它复用了 `settings.json``env` 字段——这个字段本来就是"明文环境变量配置",加 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 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 偏差窗口改了会出问题"——而不是把每个决策都重新走一遍、甚至不小心破坏跨工具凭证复用或多进程刷新安全。