mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
docs: 添加文档大纲及 superpowers/outline 目录
Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
This commit is contained in:
230
docs/outline-output/cross/01-troubleshooting.md
Normal file
230
docs/outline-output/cross/01-troubleshooting.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# 排错与错误对照
|
||||
|
||||
> 同一条 429 在使用者眼里是"我流量打太多了吗?",在开发者眼里是"响应头里那串 `x-ratelimit-*` 该被哪个适配器解析";同一份 Bedrock 400 在使用者眼里是"为什么 Opus 4.7 调不通",在开发者眼里是"SDK 0.28.1 那个 `anthropic_beta` 体重植漏洞还要打补丁打多久"。排错天生是双视角主题,所以单独成章。
|
||||
|
||||
## 产品视角(写给使用者)
|
||||
|
||||
这一节回答两个问题:**当 Claude 报错时第一步该做什么**,以及**看到具体错误码该怎么自救**。读完之后,你不需要去翻源码,就能把九成的常见问题处理掉。
|
||||
|
||||
### 第一步永远先跑两条命令
|
||||
|
||||
当 Claude 报错、卡住、行为异常时,按下面顺序排查。两条命令分工很明确:
|
||||
|
||||
- `claude doctor` —— 一张屏幕显示版本信息(含远端 npm/GCS 上的 stable 与 latest 版本号)、配置文件路径、settings 校验错误、keybindings 警告、MCP 解析警告、沙箱状态、安装锁文件状态。它的源码在 `src/screens/Doctor.tsx`(命令注册在 `src/commands/doctor/doctor.tsx`),相当于一次"全身体检"。
|
||||
- `bun run health` —— 跑 `scripts/health-check.ts`,更偏工程化自检(依赖完整性、构建产物完整性等)。开发模式下比 `claude doctor` 更底层,适合"刚 clone 下来跑不起来"的场景。
|
||||
|
||||
90% 的"莫名其妙不工作"在这两条命令的输出里都能看到线索——版本落后、settings.json 写错字段、keybindings 语法错、MCP 配置文件 JSON 解析失败。**先看这两条输出再问别人**,能省掉一大半来回。
|
||||
|
||||
### Provider 报错对照表
|
||||
|
||||
下面这张表覆盖最常见的 API 报错。Provider 切换方式详见产品第二章;这里只讲"切完之后出错了怎么办"。
|
||||
|
||||
| HTTP 状态 / 错误类型 | 含义 | 用户侧怎么办 |
|
||||
| --- | --- | --- |
|
||||
| **401**(`authentication_error`) | API key 无效或已过期 | 跑 `/login` 重新登录;OpenAI 兼容层检查 `OPENAI_API_KEY`,Anthropic 直连检查 OAuth 令牌或 `ANTHROPIC_API_KEY`。**注意**:OpenAI/Grok 客户端是会话级缓存的(详见下文"我改了 key 但没生效") |
|
||||
| **403** | 地区限制 / 权限不足 | 中国大陆直连 Anthropic 通常会 403;用 OpenAI 兼容层(DeepSeek / 智谱 / 通义 / Moonshot 等)或 Bedrock / Vertex 中转 |
|
||||
| **429** | 限流 | 看状态栏的限流指示;如果用 Claude.ai 订阅,可跑 `/rate-limit-options` 看升级 / 加包选项;OpenAI 兼容层会自动解析 `x-ratelimit-*` 响应头展示在 `/usage` 里 |
|
||||
| **529 / `"type":"overloaded_error"`** | 上游服务过载 | 稍等几秒重试。如果开了 fast mode(`/fast`),系统会自动切回标准模型并进入冷却期,状态栏会写 "Fast mode overloaded and is temporarily unavailable · resets in N" |
|
||||
| **模型不存在** | Provider 不认识你传的模型名 | 检查环境变量:OpenAI 看 `OPENAI_MODEL`,Gemini 看 `GEMINI_MODEL` 或 `GEMINI_DEFAULT_{HAIKU|SONNET|OPUS}_MODEL`,Grok 看 `XAI_API_KEY` / `GROK_*`。Gemini 缺配置时会**直接抛异常**,不会静默回退 |
|
||||
| **`max_output_tokens` 扣留** | 单轮输出超过模型上限 | 系统会自动最多重试 3 次(源码常量 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3`,见 `src/query.ts:194`);如果三轮还没收敛,本轮会以 `apiError === 'max_output_tokens'` 的 assistant 消息结束 |
|
||||
|
||||
`claude.ts` 把 `error.status === 529` 和消息体里包含 `"type":"overloaded_error"` 的情况都归到 `server_overload`(见 `src/services/api/errors.ts:1004-1011`),所以同一个上游过载事件,不管是用 HTTP 状态码表达还是用错误体表达,对用户而言是同一件事——稍等重试。
|
||||
|
||||
### 兼容层特有坑(OpenAI / Gemini / Grok)
|
||||
|
||||
下面这些是兼容层才会遇到的,Anthropic 直连不会出现:
|
||||
|
||||
- **我改了 API key 但没生效** —— 这是兼容层最高频的坑。`getOpenAIClient()`(`src/services/api/openai/client.ts:39`)和 Grok 客户端(`src/services/api/grok/client.ts`)都会把首次创建的客户端实例缓存到模块级变量(`cachedClient`,见 `openai/client.ts:15`)。中途改 `OPENAI_API_KEY` 环境变量不会让客户端重建。**解决办法**:重启 CLI;如果你在自己写脚本嵌入 Claude,必须显式调用 `clearOpenAIClientCache()`(`openai/client.ts:76`)清缓存。
|
||||
- **DeepSeek / 自托管模型报 400** —— DeepSeek 思维模式(`deepseek-reasoner`)会返回 `reasoning_content` 字段。把它原样回传给非思维模型变体会被服务端拒绝。系统在 `src/services/providerRegistry/providerCompatMatrix.ts` 里维护了一张兼容矩阵:`strip` 模式(Cerebras / Groq / strict-openai)总是剥掉 `reasoning_content`;`drop-on-non-thinking`(permissive)只在模型名匹配 `/reason|think/i` 时才保留;只有 DeepSeek 自己走 `always-preserve`。如果你用的是 DeepSeek 自托管端点且模型名不含 `reason` / `think` 字样,要么改模型名让正则命中,要么用 `permissive` 兼容规则。
|
||||
- **Bedrock Opus 4.7 报 400 `invalid beta flag`** —— 这是 `@anthropic-ai/bedrock-sdk` 0.26.4–0.28.1 的已知漏洞:SDK 把 `anthropic-beta` HTTP 头的值重植到请求体里成为 `anthropic_beta`,Bedrock 的 Opus 4.7 端点会拒绝任何带 `anthropic_beta` 体的请求。Claude Code 通过自定义 `BedrockClient` 类(`src/services/api/bedrockClient.ts`)在签名前剥离 `body.anthropic_beta` 解决。**普通用户不需要做什么**——这个补丁默认就生效。
|
||||
- **Gemini 报"requires GEMINI_MODEL"** —— Gemini 是唯一在模型映射全失败时**硬抛异常**的 Provider(`packages/@ant/model-provider/src/providers/gemini/modelMapping.ts:32`)。其它 Provider 找不到映射就原样返回模型名,Gemini 不行。看到这条报错就设一下 `GEMINI_MODEL` 或 `GEMINI_DEFAULT_SONNET_MODEL`(取决于你的家族)。
|
||||
- **限流信息看不到** —— OpenAI 兼容层的限流是从响应头 `x-ratelimit-remaining-requests` / `x-ratelimit-remaining-tokens` / `x-ratelimit-reset-*` 解析出来的(`src/services/providerUsage/adapters/openai.ts:62`)。如果你用的自托管端点不返回这些头,状态栏就拿不到限流信息——这不是 bug,是端点没实现。`/usage` 命令会展示已知 bucket。
|
||||
|
||||
### MCP 连不上的排查清单
|
||||
|
||||
MCP server 报"连接失败"时按下面顺序查:
|
||||
|
||||
1. **stdio 类型**:命令路径对不对、参数对不对、本地能否手动跑起来。
|
||||
2. **SSE / HTTP 类型**:URL 能否 curl 通、是否需要 token、是否在 `claude mcp list` 里显示为已连接。
|
||||
3. **OAuth 失败**:跑 `/mcp-auth` 重新走授权流程。
|
||||
4. **MCP 配置文件 JSON 解析错误**:`claude doctor` 会显示 `MCP parsing warnings`,直接定位到具体文件和行号。
|
||||
5. **权限被拒**:检查 `/permissions` 里是否把工具 deny 掉了;deferred tool(不在 `CORE_TOOLS` 白名单里)需要通过 `SearchExtraTools` 按需加载。
|
||||
|
||||
### 长会话变卡怎么办
|
||||
|
||||
长会话内存膨胀有两类来源,处理方式不同:
|
||||
|
||||
- **上下文太长** —— 跑 `/compact` 自动压缩;还不行就 `/force-snip` 强制剪裁历史;最彻底的是 `/clear` 重开。
|
||||
- **JSC 内存累积** —— 即使上下文压缩了,进程 RSS 也可能不下降。这是 JavaScriptCore 的已知特性(详见下文设计视角与设计第三章)。最快的解法是退出 CLI 重开。后台长跑场景(`/loop` / daemon)这个坑会更明显。
|
||||
|
||||
### 我想看看 Claude 到底在做什么
|
||||
|
||||
下面这几条命令按"侵入性"从低到高排:
|
||||
|
||||
- `claude --dump-system-prompt` —— 把当前会话渲染出的完整 system prompt 打到 stdout(需要 build 时启用 `DUMP_SYSTEM_PROMPT` feature,见 `src/entrypoints/cli.tsx:90`)。排查"为什么 Claude 不按 CLAUDE.md 行事"时最有用。
|
||||
- `/debug-tool-call` —— 读取最近一次工具调用的请求 / 响应明细,源码在 `src/commands/debug-tool-call/index.ts`。
|
||||
- `BUN_INSPECT=9229 bun run dev:inspect` —— 把 Bun 调试器挂在 9229 端口,用 Chrome DevTools 连进去打断点。这是最重的手段,但对"卡死但没报错"类问题非常有效。
|
||||
- Langfuse 追踪 —— 如果你的部署启用了 Langfuse(详见 `docs/features/tools/langfuse-monitoring.md`),每次 API 调用都会被记录为一个 observation,包含模型名、Provider、token 用量、输入输出消息。
|
||||
|
||||
### 反馈与上报 bug
|
||||
|
||||
- `/feedback` —— 弹出反馈表单,源码 `src/commands/feedback/feedback.tsx`。
|
||||
- `/perf-issue` —— 性能问题专用通道,源码 `src/commands/perf-issue/index.ts`。
|
||||
- `/bughunter` —— 实验性 bug 自动归因工具(隐藏命令)。
|
||||
|
||||
## 设计视角(写给开发者)
|
||||
|
||||
设计大纲原本没有排错章——这是最大的缺口。补这一节是因为排错本身就是"被约束逼出来的工程化"的最好案例:每一个看似奇怪的兼容代码、每一条 TODO、每一个 probe 脚本,背后都对应着一个用户会碰到的具体错误。这一节按"这个错误的根因是 Y 设计决策"的思路展开。
|
||||
|
||||
### 为什么 Bedrock 补丁必须配 probe 脚本
|
||||
|
||||
打开 `src/services/api/bedrockClient.ts`,你会看到一个看起来有点啰嗦的类继承:
|
||||
|
||||
```ts
|
||||
export class BedrockClient extends AnthropicBedrock {
|
||||
async buildRequest(options: BuildRequestArg): Promise<BuildRequestRet> {
|
||||
const req = await super.buildRequest(options)
|
||||
// ... 解析 inner.body,删掉 parsed.anthropic_beta,重写 content-length
|
||||
return req
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个类的唯一作用是:**让 SDK 把请求构造完,然后在它签名之前把 `anthropic_beta` 从请求体里删掉**。注释(`bedrockClient.ts:1-25`)写得极其详尽——直接点名了 SDK 的具体文件和行号(`packages/bedrock-sdk/src/client.ts:193-198`)、相关 issue(`anthropics/claude-code#49238`,2026-04-16 提出)、漏洞版本范围(0.26.4 至少到 0.28.1)。
|
||||
|
||||
为什么不直接给上游提 PR?因为上游修了之后,这段兼容代码也必须能被安全删除。注释最后一段写明了删除流程:
|
||||
|
||||
> When upstream ships a fix, verify the probe in scripts/probe-bedrock-beta-fix.ts shows "bug reproduced: false", then delete this class and change services/api/client.ts to instantiate AnthropicBedrock directly.
|
||||
|
||||
`scripts/probe-bedrock-beta-fix.ts` 这个文件在源码注释里被点名引用,目的是"装个探针,等上游修了就跑一下,确认 false 就删类"。这是一种"针对性补丁 + 自动退役"的工程范式——和一般补丁的区别在于它**自带退役机制**:probe 脚本本身就是"这个补丁该不该继续存在"的判据。
|
||||
|
||||
> **诚实核对**:注释里点名的 `scripts/probe-bedrock-beta-fix.ts` 目前在仓库里**找不到**(仓库里现存的 probe 脚本是 `scripts/probe-local-wiring.ts` 和 `scripts/probe-subscription-endpoints.ts`)。这意味着这个"自动退役机制"目前只是注释里的口头约定,并没有真的自动化。这是反编译重建工作的一个典型痕迹:原版可能有这个脚本,重建时没还原。
|
||||
|
||||
### 为什么 DeepSeek 必须把 reasoning_content 分三种模式处理
|
||||
|
||||
DeepSeek 的思维模型(`deepseek-reasoner`)会在 assistant 消息里返回 `reasoning_content` 字段。但同样一个字段,对三个不同的接收端会触发完全不同的行为:
|
||||
|
||||
- **DeepSeek 自己**:期望被原样回传(`always-preserve`)。
|
||||
- **Cerebras / Groq / 标准 OpenAI 协议端点**:拒绝任何非标准字段(`strip`)。
|
||||
- **permissive 端点(非 DeepSeek)**:思维模型变体可以保留,非思维变体会拒绝(`drop-on-non-thinking`,靠模型名正则 `/reason|think/i` 判断)。
|
||||
|
||||
这套规则定义在 `src/services/providerRegistry/providerCompatMatrix.ts:43-76` 的 `COMPAT_PROFILES` 表里,由 `applyCompatRule`(同文件 `:104`)实施。打开 `getDeepSeekReasoningMode`(`:86`)你能看到三种模式的判定:`thinking-only`(有 reasoning_content 无 tool_calls)、`thinking+tools`(两者都有)、`normal`(都没有)。
|
||||
|
||||
**根因**:DeepSeek 的 API 把"模型上一轮想了什么"塞回 `reasoning_content` 字段,期望客户端在下一次请求里回传。但标准 OpenAI 协议没有这个字段,严格端点(Cerebras / Qwen)会直接 400。所以兼容矩阵本质上是一张"哪些端点容忍哪些非标准字段"的合约表——这是"多 Provider 兼容"工程化的必然产物。
|
||||
|
||||
反事实推演:如果只写一种策略(比如永远 strip),DeepSeek 思维模式就彻底用不了;如果只写 always-preserve,严格端点全炸。三种模式是兼容性 / 功能性的最小必要切分。
|
||||
|
||||
### 为什么 isFirstPartyAnthropicBaseUrl 的 TODO 是个真陷阱
|
||||
|
||||
打开 `src/utils/model/providers.ts:43`:
|
||||
|
||||
```ts
|
||||
export function isFirstPartyAnthropicBaseUrl(): boolean {
|
||||
const baseUrl = process.env.ANTHROPIC_BASE_URL
|
||||
// TODO: 这里会有问题, 只配置了 openai 协议的用户, 按理说会为 true 导致问题
|
||||
if (!baseUrl) {
|
||||
return true
|
||||
}
|
||||
// ... 检查 host 是否为 api.anthropic.com
|
||||
}
|
||||
```
|
||||
|
||||
这条 TODO 的含义是:**如果用户只配了 OpenAI 兼容层(`CLAUDE_CODE_USE_OPENAI=1` + `OPENAI_BASE_URL=...`),但没有配 `ANTHROPIC_BASE_URL`,那么这个函数返回 `true`**。也就是说系统会误以为"现在是 Anthropic 直连模式",从而触发一些只该在 firstParty 模式下才生效的行为。
|
||||
|
||||
这个函数在 `src/services/api/client.ts:367`(`buildFetch`)被用来决定是否注入 `x-client-request-id` 头。注释(`client.ts:365`)写得很谨慎:"Only send to the first-party API — Bedrock/Vertex/Foundry don't log it and unknown headers risk rejection by strict proxies (inc-4029 class)."
|
||||
|
||||
**根因**:函数判定的输入只有 `ANTHROPIC_BASE_URL` 一个变量,但"用户在用哪家 Provider"实际上由 `getAPIProvider()`(同文件 `:15`)综合 `modelType` / `CLAUDE_CODE_USE_*` 环境变量决定。两个判定来源脱节就会导致 firstParty 行为泄漏到兼容层场景。
|
||||
|
||||
修复方向(TODO 没明说,但隐含)是把判定改成"先看 `getAPIProvider()` 是不是 `firstParty`,再看 base URL 是不是 anthropic 域"。但这是一个**有副作用的改动**——会改变 firstParty 路径下注入 header 的行为,需要回归测试,所以至今挂在 TODO 上。
|
||||
|
||||
### 为什么 OpenAI 客户端是模块级缓存,而 Anthropic 客户端不是
|
||||
|
||||
对比两个客户端工厂函数:
|
||||
|
||||
| | Anthropic | OpenAI | Grok |
|
||||
| --- | --- | --- | --- |
|
||||
| 入口 | `getAnthropicClient`(`client.ts:84`) | `getOpenAIClient`(`openai/client.ts:39`) | `getGrokClient`(`grok/client.ts`) |
|
||||
| 缓存 | 不缓存,每次按 model / region 参数化新建 | 模块级 `cachedClient` 单例 | 模块级单例 |
|
||||
| 改 key 后果 | 下次调用立刻生效 | 必须重启或 `clearOpenAIClientCache()` | 必须重启 |
|
||||
|
||||
为什么设计不一致?看 `client.ts:153-298` 就明白了:Anthropic 路径每次构造客户端时要做 AWS / GCP / Azure 凭证刷新、按模型选 region、注入几十个 header——这些都是**会话过程中可能变化的参数**,所以必须每次重新构造。OpenAI / Grok 路径简单得多:一个 key、一个 base URL,理论上整个会话都不变,所以缓存能省掉重复初始化的开销。
|
||||
|
||||
代价就是"改 key 不生效"这个高频用户困惑。`clearOpenAIClientCache`(`openai/client.ts:76`)是项目给用户留的逃生口——但这要求用户**知道这个函数存在**,对一般使用者完全不可见。这是"性能 vs 可调试性"的典型权衡。
|
||||
|
||||
### 为什么错误归类要绕一圈通过错误消息字符串匹配
|
||||
|
||||
打开 `src/services/api/errors.ts:1004-1011`,你会看到这种判定:
|
||||
|
||||
```ts
|
||||
if (
|
||||
error instanceof APIError &&
|
||||
(error.status === 529 ||
|
||||
error.message?.includes('"type":"overloaded_error"'))
|
||||
) {
|
||||
return 'server_overload'
|
||||
}
|
||||
```
|
||||
|
||||
为什么不光看 `status === 529`,还要扫消息文本?因为 Anthropic API 在某些路径下会用其它状态码(比如 503)配 `"type":"overloaded_error"` 错误体表达同一个"上游过载"事件。SDK 的 `APIError` 不一定把错误类型暴露成结构化字段,错误体只能从 `message` 里捞。
|
||||
|
||||
`withRetry.ts:612-616` 和 `:716-720` 用同样的字符串匹配判定 529 / overloaded。这种基于字符串的错误匹配**天然脆弱**——上游改一个字段名整个判定就失效。但目前没有更好的方案:上游 SDK 的错误类型抽象不够细,自己重写又会让兼容层耦合到具体 SDK 版本。这是"用 SDK 但 SDK 抽象不到位"的典型代价。
|
||||
|
||||
### 为什么 performanceShim 必须最先 import
|
||||
|
||||
打开 `src/entrypoints/cli.tsx:5`:
|
||||
|
||||
```ts
|
||||
// Performance shim MUST be the first import — it replaces globalThis.performance
|
||||
// with a JS-backed implementation before React/OTel capture the native reference.
|
||||
import '../utils/performanceShim.js';
|
||||
```
|
||||
|
||||
注释里的"MUST be the first import"不是审美,而是**顺序依赖**。`src/utils/performanceShim.ts:1-17` 解释了原因:JSC 原生的 `performance` 对象把 marks / measures / resource timings 存进一个永不收缩的 C++ Vector。长会话(daemon、`/loop`)会累积几百 MB 的死容量。
|
||||
|
||||
shim 做的事是:保留 `performance.now()` 走原生(快、不占内存),但把 `mark` / `measure` / `getEntries` 重定向到 GC 可回收的 JS Map。**为什么必须最先 import**:因为 React reconciler 和 OTel / Langfuse 客户端会**捕获 `globalThis.performance` 的引用**。一旦它们拿到原生引用,shim 再装上也没用——它们调用的是自己缓存的原生对象。
|
||||
|
||||
`src/query.ts:367-380` 在每次 query 的 finally 块里调用 `gPerf.clearMarks()` / `clearMeasures()` / `clearResourceTimings()`,作为兜底——防止某些 sub-agent 路径直接 `import query` 而 shim 没装上的情况。这是一个"shim 没生效时的保险栓"。
|
||||
|
||||
**这条和排错的交集**:用户报告"长会话越用越卡,RSS 涨到 1GB"时,根因往往就是某个 import 路径绕过了 shim、或者某个第三方库缓存了原生 performance 引用。排查方向是去看最近一次新增的依赖有没有在顶层捕获 performance。
|
||||
|
||||
### 为什么 Langfuse 追踪必须从 getAPIProvider() 取 provider
|
||||
|
||||
打开 `src/services/api/claude.ts:2997`:
|
||||
|
||||
```ts
|
||||
recordLLMObservation(options.langfuseTrace ?? null, {
|
||||
model: resolvedModel,
|
||||
provider: getAPIProvider(),
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
`provider` 字段直接调 `getAPIProvider()`(`src/utils/model/providers.ts:15`)取值——不读缓存、不信变量、单一真相源。**为什么这么严格**:Langfuse 上游的报表按 Provider 分组聚合(openai / gemini / grok / firstParty / bedrock / vertex / foundry)。如果不同代码路径用了不同的 Provider 判定(比如有的读 `CLAUDE_CODE_USE_OPENAI`、有的读 `settings.modelType`),同一类请求会被分到不同桶,统计就废了。
|
||||
|
||||
`getAPIProvider()` 把判定逻辑收敛到一处:先看 `modelType`,再看 `CLAUDE_CODE_USE_*` 环境变量,最后默认 `firstParty`。**任何**想读"当前在用哪家 Provider"的代码——`/provider` 命令、Langfuse 观测、模型映射——都必须走这个函数。这是"单一真相源"原则的硬执行。
|
||||
|
||||
### 为什么 errors.ts 要写 1000+ 行
|
||||
|
||||
`src/services/api/errors.ts` 是一个超过 1000 行的文件,里面几乎全是错误归类逻辑(`return 'rate_limit'` / `return 'server_overload'` / `return 'prompt_too_long'` ...)。为什么错误归类要写这么多?
|
||||
|
||||
因为每一个归类结果都对应**不同的用户提示 / 不同的重试策略 / 不同的 UI 反馈**:
|
||||
|
||||
- `rate_limit` → 展示剩余配额、提示升级
|
||||
- `server_overload` → 静默重试 + cooldown
|
||||
- `prompt_too_long` → 提示用户 `/compact`
|
||||
- `pdf_too_large` → 提示用户拆分 PDF
|
||||
|
||||
而归类的输入五花八门:HTTP 状态码、错误消息字符串、SDK 错误类型、自定义 off-switch 消息(见 `errors.ts:991-997`)。同一个"上游过载"语义可以用 `status === 529`、`status === 503 + overloaded_error`、甚至 emergency off-switch 消息表达。把所有这些判定集中到一个文件,是**避免错误处理碎片化**的工程实践——否则每个调用点都得自己写一遍字符串匹配,必然漂移。
|
||||
|
||||
## 两视角如何呼应
|
||||
|
||||
用户视角的痛点几乎都能在设计视角找到对应的设计决策:
|
||||
|
||||
- **"我改了 API key 但没生效"**(产品视角)对应**"OpenAI/Grok 客户端为什么是模块级缓存"**(设计视角)——这是性能优化带来的副作用。设计视角给出逃生口 `clearOpenAIClientCache`,但这个逃生口对一般用户不可见,所以产品视角必须明说"重启 CLI"。
|
||||
- **"Bedrock Opus 4.7 报 400"**(产品视角)对应**"为什么 Bedrock 补丁必须配 probe 脚本"**(设计视角)——补丁默认就生效,用户什么都不用做;但 probe 脚本的缺失是反编译重建的诚实边界。
|
||||
- **"Gemini 报 requires GEMINI_MODEL"**(产品视角)对应**"Gemini 为什么在映射全失败时硬抛异常"**(设计视角)——这是 Gemini Provider 唯一不静默回退的设计选择,产品视角必须把"必须配置环境变量"讲清楚。
|
||||
- **"长会话越用越卡"**(产品视角)对应**"performanceShim 必须最先 import"**(设计视角)——用户看到的是 RSS 上涨,根因在 JSC C++ Vector 永不收缩。
|
||||
- **"529 / overloaded 怎么处理"**(产品视角)对应**"为什么错误归类要绕一圈通过字符串匹配"**(设计视角)——用户只需要知道"稍等重试",开发者必须理解字符串匹配的脆弱性。
|
||||
- **"Langfuse 里 Provider 分桶不对"**(产品视角)对应**"为什么 provider 字段必须从 getAPIProvider() 取"**(设计视角)——单一真相源是统计正确性的前提。
|
||||
|
||||
这种呼应关系是排错章必须双视角覆盖的核心原因:用户视角告诉你**遇到这个错误怎么办**,设计视角告诉你**为什么会有这个错误**。两个视角合在一起,才能让使用者和维护者用同一套词汇对话。
|
||||
207
docs/outline-output/cross/02-performance-memory.md
Normal file
207
docs/outline-output/cross/02-performance-memory.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# 性能与内存
|
||||
|
||||
> 同一个"长会话越用越卡"在使用者眼里是"我该怎么压上下文",在开发者眼里是"JavaScriptCore 的 C++ Vector 为什么永不收缩"。性能与内存是双视角主题里因果链最长的一个:用户能观察到的每一个 RSS 数字、每一次"重启就好",背后都对应着一条具体的运行时约束或一段反编译留下的工程妥协。
|
||||
|
||||
## 产品视角(写给使用者)
|
||||
|
||||
这一节回答两个问题:**日常用着用着变卡了怎么办**,以及**怎么从一开始就把内存预算控制住**。读完之后你不需要去看源码,就能把九成长会话性能问题处理掉。
|
||||
|
||||
### 先分清两类"卡"
|
||||
|
||||
长会话变慢几乎总是下面两类原因之一,处理方式完全不同:
|
||||
|
||||
- **上下文太长** —— 每一轮对话都把历史消息塞进 prompt,模型推理时间和 token 账单随上下文线性增长。这种"卡"是**可逆的**:压一下上下文,立刻就快。
|
||||
- **进程内存累积** —— 即使上下文压缩了,进程的 RSS(常驻内存)也可能不下降。这种"卡"是**渐进的**:压缩上下文救不了,最快的解法是退出 CLI 重开。
|
||||
|
||||
判断方式:跑 `/compact` 之后看响应速度。如果明显变快,说明是上下文问题;如果还是慢、状态栏或 `ps aux | grep claude` 看到的 RSS 数字还在涨,就是内存累积问题。
|
||||
|
||||
### 上下文变长的三条解法,从轻到重
|
||||
|
||||
按下面顺序试,越往下越彻底:
|
||||
|
||||
1. **`/compact`** —— 让 Claude 用一个小模型把历史对话总结成一段摘要,再用摘要替换原始消息。源码在 `src/commands/compact/compact.ts`。它会先尝试 session memory 压缩(保留结构化记忆),失败再走通用压缩模型。带自定义指令也行:`/compact 只保留与测试相关的部分`。
|
||||
2. **`/force-snip`** —— 直接在消息数组里插一条 `snip_boundary` 系统消息,把当前位置之前的历史标记为"已剪裁"。下一次 query 时 `snipCompactIfNeeded` 会把这些消息从模型视角下移除,但 REPL 里依然能看到完整滚动历史。源码在 `src/commands/force-snip.ts:18`。比 `/compact` 更暴力:不总结、直接砍。
|
||||
3. **`/clear`** —— 整个会话清空重开。源码在 `src/commands/clear/`。
|
||||
|
||||
日常推荐顺序是 `/compact` → `/force-snip` → `/clear`。`/force-snip` 适合"前面那段讨论已经跑偏了,我想从干净状态继续"的场景。
|
||||
|
||||
### 自动 compact 什么时候触发
|
||||
|
||||
系统会在上下文接近模型窗口上限时自动触发 compact,不需要你手动盯。如果你发现自动触发太频繁(每次刚聊几句就被压缩),说明你的 CLAUDE.md 或工具调用本身就在贡献大量上下文——可以跑 `/context` 或 `/ctx_viz` 看看上下文都被什么占满了。
|
||||
|
||||
### 长跑场景特别留意:daemon、/loop、容器
|
||||
|
||||
短会话几乎不会撞上内存累积问题,但下面这些长跑场景会:
|
||||
|
||||
- **`/loop`** —— 每 N 分钟自动跑一次任务,进程常驻。
|
||||
- **daemon 模式** —— `claude daemon start` 启动的长驻 supervisor + worker。
|
||||
- **容器 / CI** —— `CLAUDE_CODE_REMOTE=true` 时,`cli.tsx:44-49` 会自动给子进程注入 `--max-old-space-size=8192`(前提是容器有 16GB)。这是项目对容器环境的硬编码假设:你的容器至少要有 8GB 余量给 Node.js 堆。
|
||||
|
||||
在长跑场景下,建议每隔几小时主动重启一次进程,或者把任务拆成多次独立会话而不是一条无限循环。
|
||||
|
||||
### 我想知道 Claude 现在吃了多少内存
|
||||
|
||||
- macOS / Linux:`ps aux | grep claude`,看 RSS 列(单位 KB)。
|
||||
- daemon / background session:`claude ps` 看进程列表,`claude logs` 看输出。
|
||||
- 性能问题专用反馈通道:`/perf-issue`(源码 `src/commands/perf-issue/`)。
|
||||
|
||||
### 为什么有时候重启 CLI 是唯一解
|
||||
|
||||
如果压缩了上下文、清了消息,进程 RSS 还是下不去,这是 JavaScriptCore(Bun 的 JS 引擎)的已知特性:某些内部缓冲区一旦分配就不再收缩。详细原因见下面的设计视角。**用户侧能做的就是退出重开**——这不是 bug,是运行时的硬约束。
|
||||
|
||||
## 设计视角(写给开发者)
|
||||
|
||||
设计大纲里性能主题分布在第一、三、四章,是全书最深的几章。这一节把数据链串起来讲:从 17MB 单文件的灾难,到 `performanceShim` 的运行时补丁,到 6,889 个 `_debugStack` 的"看不见的内存",再到 `cli.tsx:48` 那条看似随意的 `--max-old-space-size` 注入。
|
||||
|
||||
### JSC 的贪婪解析:17MB 单文件为什么能让 RSS 涨到 1GB
|
||||
|
||||
这是全书最戏剧性的设计动机。打开 `vite.config.ts:94-102`:
|
||||
|
||||
```ts
|
||||
output: {
|
||||
format: 'es',
|
||||
// Code splitting: Bun/JSC parses the entire single-file bundle eagerly,
|
||||
// consuming ~1 GB RSS for a 17 MB output (vs ~220 MB on Node/V8 which
|
||||
// lazy-parses). Splitting into chunks allows Bun to load modules on demand,
|
||||
// bringing RSS down to ~300 MB.
|
||||
entryFileNames: 'cli.js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||
},
|
||||
```
|
||||
|
||||
JavaScriptCore(Bun 用的 JS 引擎)和 V8(Node.js 用的)在解析策略上有根本差异:**JSC 全量解析 + 全量 JIT**,V8 懒解析。同样一份 17MB 的单文件 bundle,JSC 会把整份 bytecode 和 JIT 编译结果一次性吃进内存,RSS 直接冲到 ~1GB;V8 只在函数被调用时才解析,RSS 只要 ~220MB。
|
||||
|
||||
CLAUDE.md 里记录的实测数据更细:单文件 17MB 产物导致 RSS 暴涨至 ~1GB;切成 600+ chunks 后,Bun 按需加载,`--version` 的 RSS 从 966MB 骤降到 35MB,完整加载从 1GB+ 降到 ~500MB。
|
||||
|
||||
**为什么 Vite 必须代码分割而不是单文件**——这不是性能优化,是**生存需求**。Bun.build(`build.ts:23` 的 `splitting: true`)和 Vite(`vite.config.ts:94` 的 `chunkFileNames: 'chunks/[name]-[hash].js'`)两条构建管线都默认走代码分割,原因就是这条。
|
||||
|
||||
`scripts/post-build.ts` 还要在分割后做两件事:(1) 把 `import.meta.require` 替换成 Node.js 兼容的 `createRequire` 探测,让产物同时能在 bun 和 node 上跑;(2) patch 掉第三方依赖(`@anthropic-ai/sandbox-runtime`)里未受保护的 `var { ... } = globalThis.Bun` 解构——否则在 Node.js 启动会崩。这两步都是"代码分割 + 双运行时兼容"的下游工程代价。
|
||||
|
||||
### performanceShim:JSC 原生 Performance 的 C++ Vector 永不收缩
|
||||
|
||||
打开 `src/utils/performanceShim.ts:1-17`,文件头注释直接写明了根因:
|
||||
|
||||
> In Bun, globalThis.performance is JSC's native Performance object. It stores marks, measures, and resource timings in a C++ Vector that never shrinks even after clearMarks(). Long-running sessions (daemon, /loop) accumulate hundreds of MB of dead capacity.
|
||||
|
||||
JSC 的原生 `performance` 对象把 `mark()` / `measure()` / resource timings 存进一个 C++ Vector,这个 Vector **只增不减**——即使你调 `clearMarks()`,C++ 那头的容量也不会释放。React reconciler 和 OpenTelemetry / Langfuse 客户端都会反复调用 `mark` / `measure` 做时间打点,长会话里这些死容量能累积几百 MB。
|
||||
|
||||
shim 做的事(`performanceShim.ts:19-155`)很克制:
|
||||
|
||||
- **`performance.now()` 继续走原生**(`performanceShim.ts:28-30`)—— 高频调用、不占内存,没必要劫持。
|
||||
- **`mark` / `measure` / `getEntries*` 重定向到 GC 可回收的 JS Map**(`performanceShim.ts:22-26` 的 `marks` / `measures`)—— Map 是普通 JS 对象,GC 能正常回收。
|
||||
- **不继承 Performance.prototype**(`performanceShim.ts:124-126`)—— 因为原生 getter(`timeOrigin` / `onresourcetimingbufferfull` / `toJSON`)会检查 `this` 是不是真正的 JSC Performance 实例,继承就抛错。
|
||||
- **提供 `markResourceTiming` 空函数**(`performanceShim.ts:140`)—— Node.js v22 的 undici 内部每次 fetch 后都会调这个方法,不存在就 TypeError。
|
||||
|
||||
**为什么必须最先 import**——这是整段代码里最脆弱的顺序依赖。打开 `src/entrypoints/cli.tsx:1-5`:
|
||||
|
||||
```ts
|
||||
#!/usr/bin/env bun
|
||||
// Performance shim MUST be the first import — it replaces globalThis.performance
|
||||
// with a JS-backed implementation before React/OTel capture the native reference.
|
||||
// Without this, JSC's C++ Vector grows without bound in long-running sessions.
|
||||
import '../utils/performanceShim.js';
|
||||
```
|
||||
|
||||
原因(`performanceShim.ts:14-16`):React reconciler 和 OTel / Langfuse 在 import 时会**捕获 `globalThis.performance` 的引用**。一旦它们拿到原生引用,shim 再装上也没用——它们调用的是自己缓存的原生对象。所以 shim 必须在 React / OTel 加载**之前**就把 `globalThis.performance` 换掉。`installPerformanceShim()`(`performanceShim.ts:162-166`)用 `globalThis.__performanceShimInstalled` 守护幂等性,并且文件末尾(`:169`)自动调用一次,保证"import 即安装"。
|
||||
|
||||
### query.ts:367 的兜底:防 sub-agent 绕过 shim
|
||||
|
||||
`src/query.ts:367-380` 在每次 query 的收尾位置写了这段:
|
||||
|
||||
```ts
|
||||
const gPerf = globalThis.performance
|
||||
if (gPerf && typeof gPerf.clearMarks === 'function') {
|
||||
try {
|
||||
gPerf.clearMarks()
|
||||
gPerf.clearMeasures?.()
|
||||
gPerf.clearResourceTimings?.()
|
||||
} catch {
|
||||
// Non-critical — some environments may not support all methods
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注释(`query.ts:367-370`)解释了为什么需要兜底:"OTel references globalThis.performance which stores marks/measures/resource timings in a C++ Vector that never shrinks. Long-running sessions accumulate hundreds of MB of dead capacity even after spans are flushed and nullified."
|
||||
|
||||
**为什么有了 shim 还要兜底**:某些 sub-agent 路径会**直接 `import query`**,而不经过 `cli.tsx` 的入口。如果那个进程的 shim 没装上(比如测试环境、嵌入式调用),原生的 `performance` 还在,每次 query 累积的 marks 就会泄漏。这段兜底调的是 `globalThis.performance`(已经被 shim 替换过的话就是 shim 的 `clearMarks`,没有的话就是原生的),作为"shim 没生效时的保险栓"。
|
||||
|
||||
注意这个兜底是**尽力而为**:原生 `clearMarks()` 在 JSC 上即使能调,C++ Vector 也不收缩(见上面 shim 注释)。所以兜底主要救的是 shim 已装但 Map 需要清空的场景,以及"sub-agent 没装 shim 但又想尽力"的场景。
|
||||
|
||||
### 6,889 个 _debugStack Error 对象:开发模式下看不见的 12MB
|
||||
|
||||
打开 `build.ts:26-31`:
|
||||
|
||||
```ts
|
||||
define: {
|
||||
...getMacroDefines(),
|
||||
// React production mode — eliminates _debugStack Error objects
|
||||
// (6,889 objects × ~1.7KB = 12MB in dev builds) and removes
|
||||
// prop-type / key warnings not useful in a production CLI tool.
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
```
|
||||
|
||||
React 在开发模式下(`process.env.NODE_ENV !== 'production'`)会为每次组件渲染构造一个 `Error` 对象,用于捕获调用栈、生成 `_debugStack` 字段。这在浏览器开发工具里有用,但在 CLI 工具里就是纯内存浪费:6,889 个 `Error` 对象,每个约 1.7KB,合计约 12MB。
|
||||
|
||||
`vite.config.ts:124` 的对应位置注释("6,889 objects × ~1.7KB = 12MB in dev builds")和 `build.ts` 的注释互相印证。这就是为什么 build 强制 `NODE_ENV='production'`——不是审美,是实打实的 12MB。
|
||||
|
||||
### cli.tsx:44 的 CLAUDE_CODE_REMOTE 内存注入
|
||||
|
||||
打开 `src/entrypoints/cli.tsx:42-49`:
|
||||
|
||||
```ts
|
||||
// Set max heap size for child processes in CCR environments (containers have 16GB)
|
||||
if (process.env.CLAUDE_CODE_REMOTE === 'true') {
|
||||
const existing = process.env.NODE_OPTIONS || '';
|
||||
process.env.NODE_OPTIONS = existing
|
||||
? `${existing} --max-old-space-size=8192`
|
||||
: '--max-old-space-size=8192';
|
||||
}
|
||||
```
|
||||
|
||||
注释写得很直白:"containers have 16GB"。这是项目对容器环境(Claude Code Remote / CCR)的**硬编码假设**:容器至少有 16GB 内存,所以子进程堆上限可以放心设到 8GB。
|
||||
|
||||
**为什么硬编码 8GB 而不是按容器实际内存动态算**:因为 `NODE_OPTIONS` 必须在子进程启动前设置,而那时还没有可靠的"当前容器内存上限"查询方式(cgroup 接口在不同运行时下行为不一)。8GB 是一个保守的"16GB 容器的一半给堆"的工程经验值。
|
||||
|
||||
**为什么这段代码在 cli.tsx 顶层而不是 init.ts**:和 `CLAUDE_CODE_ABLATION_BASELINE`(`cli.tsx:56`)是同一个原因——子进程一启动就要读 `NODE_OPTIONS`,`init()` 跑得太晚。这是入口文件的"副作用顶层化"模式。
|
||||
|
||||
### distRoot.ts:vendor 二进制路径解析
|
||||
|
||||
打开 `src/utils/distRoot.ts:15-27`:
|
||||
|
||||
```ts
|
||||
const distRoot = (() => {
|
||||
const parts = __dirname.split(path.sep)
|
||||
const distIdx = parts.lastIndexOf('dist')
|
||||
if (distIdx !== -1) {
|
||||
return parts.slice(0, distIdx + 1).join(path.sep)
|
||||
}
|
||||
// Dev mode: from src/utils/ → project root
|
||||
const srcIdx = parts.lastIndexOf('src')
|
||||
if (srcIdx !== -1) {
|
||||
return parts.slice(0, srcIdx).join(path.sep)
|
||||
}
|
||||
return __dirname
|
||||
})()
|
||||
```
|
||||
|
||||
代码分割之后,chunk 文件散落在 `dist/` 或 `dist/chunks/` 下,但 vendor 二进制(ripgrep、audio-capture)在 `dist/vendor/`。chunk 文件需要能在运行时定位到 vendor 目录。`distRoot` 用 `lastIndexOf('dist')` 或 `lastIndexOf('src')`(dev 模式)反向定位根目录。
|
||||
|
||||
**为什么不用 `import.meta.url` 的相对路径推算**:因为 chunk 文件名带 hash(`chunks/[name]-[hash].js`),嵌套层级不固定;`ripgrep.ts` / `computerUse/setup.ts` / `claudeInChrome/setup.ts` / `updateCCB.ts` 都依赖这个共享函数。CLAUDE.md 的"尾声"章节提到一个相关坑:`vendor/ripgrep/arm64-darwin` 二进制如果缺失,Grep 工具会 spawn 该路径并 ENOENT——`distRoot` 的 vendor 复制逻辑(`build.ts:91-93`)就是为了保证构建产物里 vendor 二进制存在。
|
||||
|
||||
### 性能预算与 token 预算的耦合
|
||||
|
||||
内存预算之外还有 token 预算:`TOKEN_BUDGET` feature 与 `/cost` / `/usage` 联动。token 预算直接影响单轮 API 调用的延迟和费用,但它和内存预算是**正交**的——压缩上下文(省 token)不一定释放内存(JSC Vector 不收缩),释放内存(重启进程)也不一定省 token(上下文还在持久化存储里)。
|
||||
|
||||
用户看到"卡"时,往往分不清是哪一类预算耗尽。这正是性能主题必须双视角覆盖的原因:产品视角教用户**按症状分流**(上下文卡 vs 内存卡),设计视角解释**为什么分流之后内存卡还是救不回来**。
|
||||
|
||||
## 两视角如何呼应
|
||||
|
||||
用户视角的痛点几乎都能在设计视角找到对应的运行时约束:
|
||||
|
||||
- **"长会话越用越卡,重启就好"**(产品视角)对应 **"JSC 的 C++ Vector 永不收缩 + performanceShim 必须最先 import"**(设计视角)——用户看到的是 RSS 上涨,根因在 JSC 原生 Performance 对象的内存模型。设计视角的 shim 把大部分 `mark` / `measure` 重定向到 GC 可回收的 JS Map,但兜底代码(`query.ts:367`)承认 shim 可能被 sub-agent 绕过,所以用户侧的"重启就好"是最诚实的解法。
|
||||
- **"`/compact` 之后还是慢"**(产品视角)对应 **"token 预算与内存预算正交"**(设计视角)——`/compact` 压的是模型视角的上下文(省 token、省推理时间),但 REPL 里的消息对象、JSC Vector 里的 marks 都还在内存里。这是为什么产品视角必须教用户区分"上下文卡"和"内存卡"。
|
||||
- **"容器里跑 Claude 会不会 OOM"**(产品视角)对应 **"cli.tsx:44 的 CLAUDE_CODE_REMOTE 内存注入硬编码 8GB"**(设计视角)——产品视角告诉用户"容器至少给 16GB",设计视角解释为什么是 8GB 而不是动态算。
|
||||
- **"启动 `--version` 为什么也要几百 MB"**(隐含的工程好奇)对应 **"17MB 单文件让 RSS 涨到 1GB,必须代码分割"**(设计视角)——`--version` RSS 从 966MB 降到 35MB 是代码分割的直接收益,用户感知到的是"CLI 启动飞快",背后是 JSC 全量解析 vs V8 懒解析的根本差异。
|
||||
|
||||
这种呼应关系是性能章必须双视角覆盖的核心原因:产品视角告诉用户**遇到卡顿怎么办**,设计视角告诉用户**为什么有些卡顿只能重启**。两个视角合在一起,才能让使用者在"压缩、剪裁、清空、重启"之间做出正确选择,也让维护者在改性能相关代码时知道哪些约束是硬的、不能碰。
|
||||
221
docs/outline-output/cross/03-security.md
Normal file
221
docs/outline-output/cross/03-security.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# 安全
|
||||
|
||||
> 同一份 `sk-ant-...` 在使用者眼里是"我的密钥去了哪里、谁能看到",在开发者眼里是"为什么用 0o600 写文件、为什么 ChatGPT 订阅要复用 `~/.codex/auth.json`、为什么 `bypassPermissions` 必须先检测是不是 root 或 sandbox"。安全天生是双视角主题——用户担心泄漏,开发者负责把每一处存储、刷新、传输、共享都设计成"即使被泄漏也尽量不致命"。
|
||||
|
||||
## 产品视角(写给使用者)
|
||||
|
||||
这一节回答三个问题:**我的密钥和令牌存在哪里**、**它们什么时候会被刷新或销毁**、**我把对话分享出去时哪些东西会跟着泄漏**。读完之后,你应该能判断"我能不能把这台机器借给同事"、"我能不能把这份 transcript 发到群里"。
|
||||
|
||||
### 凭证存储位置清单
|
||||
|
||||
Claude Code 把不同来源的凭证分散存在几个地方,不要把它们当成一个文件。下面这张表覆盖最常见的几类:
|
||||
|
||||
| 凭证类型 | 存储位置 | 谁能读到 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| Anthropic OAuth 令牌 / 自定义 API key | `~/.claude/` 下的 secure storage(macOS Keychain / Windows Credential Manager / Linux libsecret) | 只有当前用户的操作系统账户 | `/logout` 会清掉它(见 `src/commands/logout/logout.tsx:24` 调 `removeApiKey()`) |
|
||||
| ChatGPT 订阅凭证(`OPENAI_AUTH_MODE=chatgpt`) | `~/.claude/openai-chatgpt-auth.json` | 任何能读这个文件的进程 | 文件用 `mode: 0o600` 写入(见 `src/services/api/openai/chatgptAuth.ts:162`),但仍然是明文 JSON |
|
||||
| Codex CLI 共享凭证 | `~/.codex/auth.json`(即 `CODEX_HOME/auth.json`) | 任何能读这个文件的进程 | Claude Code **只读不写**这个文件(`chatgptAuth.ts:342`);如果 `~/.claude/openai-chatgpt-auth.json` 不存在,会回退去读它 |
|
||||
| Provider 环境变量(`OPENAI_API_KEY` 等) | 写进 `settings.json` 的 `env` 字段或 shell rc 文件 | 任何能读 settings 的进程 | `/provider` 命令切换 Provider 不清这些 key(见下文) |
|
||||
| 团队共享设置 | `<项目>/.claude/settings.json` | 仓库的所有 collaborator | **不要**把 key 写进团队 settings.json,写到 `settings.local.json` 或环境变量里 |
|
||||
| 个人覆盖设置 | `<项目>/.claude/settings.local.json` | 当前用户 | 默认被 git ignore,适合放本地 API key 之类 |
|
||||
|
||||
一个高频误用:把 `OPENAI_API_KEY` 提交到了项目根目录的 `.claude/settings.json`,结果 push 到团队仓库所有人都看到了。**正确做法**是放到 `.claude/settings.local.json`(git ignored)或者用 `apiKeyHelper`(`src/utils/settings/types.ts:255`,指向一个能输出 key 的本地脚本)。
|
||||
|
||||
### 权限模式:让 Claude 在沙箱里干活
|
||||
|
||||
权限模式控制 Claude 在执行工具调用之前是否需要按一次回车。用 `/permissions` 命令(`src/commands/permissions/permissions.tsx`)或 `settings.json` 的 `permissions.defaultMode` 字段切换:
|
||||
|
||||
- `default` —— 文件写入、shell 命令等危险操作按规则匹配后**问你**(最常见)。
|
||||
- `acceptEdits` —— 文件编辑直接放行,shell 仍然问。
|
||||
- `plan` —— 只读分析,不允许任何写操作。
|
||||
- `auto` —— 自动分类器判定(需要 `TRANSCRIPT_CLASSIFIER` feature)。
|
||||
- `bypassPermissions` —— 全部放行,**不要在普通环境用**。
|
||||
|
||||
`bypassPermissions` 是这条链上最危险的模式,所以代码里有专门的"环境硬性检测"(`src/setup.ts:391-435`):在你以 root/sudo 身份启动它、或者环境既不是 Docker 也不是 Bubblewrap 也不是 `IS_SANDBOX=1`、还连着外网的情况下,CLI 会**直接退出**并报错 `--dangerously-skip-permissions cannot be used in Docker/sandbox containers with no internet access`。换句话说,bypass 只允许在"无网 + 沙箱容器"的组合里用。这是有意把滥用路径堵死。
|
||||
|
||||
权限规则本身写在 `settings.json` 的 `permissions.allow` / `deny` / `ask` 里(schema 在 `src/utils/settings/types.ts:42-55`),用 `/permissions` 命令可视化编辑。规则按"工具名 + glob 路径"匹配,比如 `Bash(npm install:*)` 表示允许所有 `npm install ...` 命令;`Read(~/.ssh/**)` 表示禁止读 ssh 目录。**deny 永远赢过 allow**,这是优先级铁律(详见 `src/utils/permissions/permissions.ts`)。
|
||||
|
||||
### OAuth 令牌什么时候刷新、什么时候过期
|
||||
|
||||
两种 OAuth 路径,各自有自己的刷新窗口:
|
||||
|
||||
- **ChatGPT 订阅路径** —— `chatgptAuth.ts:9` 定义了 `REFRESH_SKEW_MS = 5 * 60 * 1000`,意思是"令牌距离过期不到 5 分钟时就主动刷新"。每次调用 `getValidChatGPTAuth()`(`chatgptAuth.ts:339`)都会先 `getTokenExpiryMs` 检查,到点就 `refreshTokens` + `saveStoredAuth`。**用户侧含义**:只要你的网络能通到 `auth.openai.com`,令牌永远不会过期;如果断网超过令牌寿命(通常 1 小时),下一次调用会失败,需要重新 `/login`。
|
||||
- **Bridge 模式的会话 JWT** —— `src/bridge/jwtUtils.ts:52` 同样定义了 `TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000`,加上 `FALLBACK_REFRESH_INTERVAL_MS = 30 * 60 * 1000` 和 `MAX_REFRESH_FAILURES = 3`。`createTokenRefreshScheduler` 会"在令牌过期前 5 分钟排一个 setTimeout",失败 3 次后放弃。**用户侧含义**:Bridge 长会话(自托管 RCS、远程控制)理论上一周不掉线,但如果你看到 `bridge_token_refresh_no_oauth` 这种 diagnostic log,说明刷新链断了。
|
||||
|
||||
**`/logout` 会做什么**:不止删 key。它会 `flushTelemetry()` 先把还没上报的埋点冲掉(防止组织数据泄漏,见 `logout.tsx:21` 的注释),然后 `removeApiKey()` + `removeChatGPTAuth()` + 清掉 secure storage + 清一堆缓存(betas、toolSchema、Grove、policyLimits),最后 `gracefulShutdownSync(0, 'logout')` 让进程退出。所以 `/logout` 是"重置到初次安装状态"的快捷方式。
|
||||
|
||||
### `/share` 与 `/export` 的隐私边界
|
||||
|
||||
这两个命令都把会话内容写到外部,但隐私处理完全不同:
|
||||
|
||||
- **`/export`**(`src/commands/export/export.tsx`)—— 把会话渲染成纯文本**写到本地文件**。**没有任何脱敏**——你说了什么、Claude 回了什么、API key 是不是出现在消息里,全部原样写出去。这个命令的隐私边界就是"你自己机器上的文件系统",把它交给同事之前请自己检查一遍。
|
||||
- **`/share`**(`src/commands/share/index.ts`)—— 把会话日志**上传到 GitHub Gist**(或 `0x0.st` 兜底)。默认 `--private`(私有 Gist),但 GitHub 的 private Gist 对**任何知道 URL 的人**都可读,所以本质上还是"URL 即权限"。`--mask-secrets` 旗标会触发 `maskSecrets()`(`share/index.ts:98`),用一组正则把 `sk-ant-*` / `sk-*` / `Bearer xxx` / `AKIA*`(AWS)/ `ghp_*` / `xoxb-*`(Slack)等常见 token 替换成 `[REDACTED_*]`(模式表在 `share/index.ts:53-92`)。
|
||||
|
||||
**关键提醒**:`/share --mask-secrets` **不是银弹**。源码里那条 NOTE 写得很明确(`share/index.ts:89-91`):
|
||||
|
||||
> We intentionally do NOT redact generic ≥32-char hex strings because they match legitimate git commit SHAs and base64 content, producing garbled share output.
|
||||
|
||||
也就是说,如果你的 token 长得像 32 位以上的 hex(比如某些自建服务的 token),它**不会被脱敏**。私有信息(内部文档片段、同事姓名、内部 URL)也完全不在脱敏范围里。**最稳的做法**:分享前用 `/export` 导到本地,自己过一遍再决定怎么发。
|
||||
|
||||
### 跨工具凭证共享:和 Codex CLI 复用 auth
|
||||
|
||||
如果你机器上同时装了 Codex CLI(OpenAI 官方 CLI),你会发现 ChatGPT 订阅登录会在两边都生效。这是因为 `getValidChatGPTAuth()`(`chatgptAuth.ts:339-346`)在 `~/.claude/openai-chatgpt-auth.json` 不存在时会**回退去读 `~/.codex/auth.json`**(`codexAuthFilePath()`,`chatgptAuth.ts:52`)。注释里写得很坦诚(`:344`):`Using ChatGPT auth from Codex auth.json`。
|
||||
|
||||
**隐私含义**:
|
||||
|
||||
- 你在 Codex CLI 登录 ChatGPT,Claude Code 也能直接用,不需要再登一次。
|
||||
- 反过来不成立:Claude Code 的 `saveStoredAuth` 只写 `~/.claude/openai-chatgpt-auth.json`,不写 `~/.codex/auth.json`。
|
||||
- 如果你想完全隔离两个工具的凭证,设 `CODEX_HOME` 环境变量把 Codex 的目录指到别处(`chatgptAuth.ts:54`)。
|
||||
|
||||
### `/provider unset` 只清 Provider 不清 key
|
||||
|
||||
一个高频困惑:跑了 `/provider unset`,以为已经把 OpenAI 凭证清干净了。看 `src/commands/provider.ts:49-62`,它做的事是:清 `modelType` 设置 + 删 `CLAUDE_CODE_USE_*` 环境变量。**它不动**:
|
||||
|
||||
- `OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROK_API_KEY` 这些 key 环境变量(仍在 shell 或 settings.json 里)。
|
||||
- `~/.claude/openai-chatgpt-auth.json`(仍在磁盘上)。
|
||||
- OpenAI/Grok 客户端的模块级缓存(见设计视角)。
|
||||
|
||||
要彻底清,必须跑 `/logout`(清凭证文件 + secure storage)+ 手动从 settings.json 删 key 环境变量 + 重启 CLI(清缓存)。
|
||||
|
||||
## 设计视角(写给开发者)
|
||||
|
||||
设计大纲原本没有"安全"章节,相关决策散落在 Provider、Bridge、权限系统各处。这一节把它们串起来,按"为什么这么存、为什么这么检、为什么这么共享"展开。每个决策背后都有一个具体的威胁模型或约束。
|
||||
|
||||
### 为什么 ChatGPT 凭证用明文 JSON + 0o600,而不是 secure storage
|
||||
|
||||
打开 `src/services/api/openai/chatgptAuth.ts:148-164`:
|
||||
|
||||
```ts
|
||||
async function saveStoredAuth(tokens: ChatGPTAuthTokens): Promise<void> {
|
||||
const path = authFilePath()
|
||||
await mkdir(getClaudeConfigHomeDirLocal(), { recursive: true })
|
||||
const body: StoredAuthFile = { auth_mode: 'chatgpt', tokens: { ... }, last_refresh: ... }
|
||||
await writeFile(path, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 })
|
||||
await chmod(path, 0o600).catch(() => undefined)
|
||||
}
|
||||
```
|
||||
|
||||
明文 JSON,文件权限 `0o600`(只有文件 owner 能读写)。**为什么不像 Anthropic OAuth 那样走 secure storage**?因为这套凭证要和 **Codex CLI 互操作**——Codex CLI 的存储格式就是 `~/.codex/auth.json` 明文 JSON(见 OpenAI 官方设计)。如果 Claude Code 把凭证塞进 macOS Keychain,Codex CLI 读不到,跨工具共享就做不到。
|
||||
|
||||
`chmod 0o600` 是这个权衡下的最大补偿:文件本身明文(互操作需求),但 OS 层面把读权限收紧到当前用户。注意 `chmod` 那行有 `.catch(() => undefined)`——某些文件系统(比如 FAT32 挂载点)不支持 chmod,这种情况会静默失败但文件还是会被写出来。这是一个**优先可用性而非绝对安全**的设计选择。
|
||||
|
||||
**根因**:跨工具互操作和强凭证存储在本地文件系统层面是冲突的。OpenAI 选择了明文 JSON,Claude Code 跟随这个选择才能复用凭证。
|
||||
|
||||
### 为什么 `bypassPermissions` 必须先检测 root 和 sandbox
|
||||
|
||||
`src/setup.ts:391-435` 是一段看起来啰嗦的检测代码,但它精确对应一个威胁模型:"用户图省事用 `sudo claude --dangerously-skip-permissions` 启动"。在这种情况下,Claude 拿到的是 root 权限,所有文件(包括 `/etc/passwd`、其它用户的 home)都可读写可执行——bypass 模式就变成了"任意代码执行 root"。
|
||||
|
||||
检测逻辑按"威胁递进"排:
|
||||
|
||||
1. **第一道(`:397-408`)**:`process.getuid() === 0` 且不是 sandbox(`IS_SANDBOX !== '1'` 且 `CLAUDE_CODE_BUBBLEWRAP` 未设)——直接 `process.exit(1)`。这是"绝对禁止"层。注释里特意提到"TPU devspaces 要求 root",所以留了 `IS_SANDBOX=1` 的逃生口。
|
||||
2. **第二道(`:410-434`,仅 `USER_TYPE === 'ant'`)**:进一步要求"必须是 Docker / Bubblewrap / IS_SANDBOX 容器"**且** "无外网"。`hasInternet` 这一条特别严:即使你套了 Docker,只要还能 ping 通外网,bypass 就被拒。
|
||||
|
||||
**为什么对 `USER_TYPE === 'ant'` 特别严格**:Anthropic 内部用户的默认部署环境更复杂,代码里特意为内部用户加了"容器 + 无网"的双重要求(`:411` 那行 `process.env.USER_TYPE === 'ant'` 判断)。外部用户的判断只走第一道。
|
||||
|
||||
**根因**:bypassPermissions 模式下整个权限管线被跳过,所以必须在它生效**之前**做环境断言。一旦放进去,再想限制就晚了——Claude 已经能跑任意 shell 命令了。这是一个"防御必须在威胁生效前完成"的典型例子。
|
||||
|
||||
### 为什么 ACP 权限走"本地管线 + 远端委托"两段式
|
||||
|
||||
`src/services/acp/permissions.ts:32-173` 的 `createAcpCanUseTool` 是 ACP 模式下所有工具调用的权限闸门。它不直接把每个调用都甩给远端客户端,而是分两段:
|
||||
|
||||
1. **本地管线(`:79-106`)**:先跑 `hasPermissionsToUseTool`,让 deny / allow / bypassPermissions / acceptEdits 这些本地规则自己消化。如果本地已经能决定 allow 或 deny,**直接返回,不打扰远端**。
|
||||
2. **远端委托(`:108-172`)**:本地规则判定为 `ask` 时,才通过 `conn.requestPermission()` 把 `allow_always` / `allow_once` / `reject_once` 三个选项发给 ACP 客户端(VS Code、Cursor 等)。
|
||||
|
||||
**为什么这么设计**:ACP 客户端可能是 IDE、Web UI、自研工具,它们不一定都有良好的权限 UI,而且每次 round-trip 都有延迟。如果连"用户已经 deny 的工具"都要去远端问一遍,体验会很糟。本地管线是"快速短路",远端委托只在"真的需要人决策"时才触发。
|
||||
|
||||
注意 `forceDecision !== undefined` 那一段(`:71-73`):coordinator / swarm worker 场景会预绑定一个决策,跳过本地管线直接返回。这是"信任父进程已经做了决策"的快捷路径,避免子 worker 重复打断用户。
|
||||
|
||||
### 为什么 `HasAppStateContext` 主动 throw 防嵌套
|
||||
|
||||
打开 `src/state/AppState.tsx:57-64`:
|
||||
|
||||
```ts
|
||||
const HasAppStateContext = React.createContext<boolean>(false);
|
||||
|
||||
export function AppStateProvider({ children, ... }: Props): React.ReactNode {
|
||||
const hasAppStateContext = useContext(HasAppStateContext);
|
||||
if (hasAppStateContext) {
|
||||
throw new Error('AppStateProvider can not be nested within another AppStateProvider');
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
第一眼看起来像"开发者警告",但它其实有**安全含义**。AppState 是整个应用的单一 store,包含 messages、tools、permissions、MCP 连接等敏感字段。如果允许嵌套,外层 Provider 的 children 里某个子组件 mount 了一个内层 Provider,内层的 store 就和外层**脱钩**——内层的 useAppState 拿到的是内层 store,permission 决策、消息历史、凭证状态全部错乱。
|
||||
|
||||
具体的安全风险场景:一个恶意 MCP 工具或者插件组件如果不小心(或故意)渲染了一个 AppStateProvider,就有可能让一部分 UI 用着"被隔离的、权限被偷偷放宽"的 store。React Context 本身没有"防重复嵌套"机制,所以项目用 `HasAppStateContext` 这个布尔 context 主动 throw——**第一次 mount 时它从 false 变 true,第二次 mount 时读到 true 就抛错**。
|
||||
|
||||
**根因**:单一 store 是"权限决策单一真相源"的前提。一旦允许多 store 嵌套,权限规则、bypass 状态、secure storage 引用都可能错配。这是"防御性编程"在 React Context 层的落地。
|
||||
|
||||
### 为什么 Bridge 的 JWT 不验签
|
||||
|
||||
`src/bridge/jwtUtils.ts:21-32` 的 `decodeJwtPayload` 函数注释里写得很坦诚:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Decode a JWT's payload segment without verifying the signature.
|
||||
* Strips the `sk-ant-si-` session-ingress prefix if present.
|
||||
*/
|
||||
```
|
||||
|
||||
只解码 payload,不验签。**为什么**?因为 Bridge 模式(自托管 RCS、远程控制)用的是"会话级 JWT",签发和验证都在**同一进程**里完成(Anthropic 服务端签发,Bridge 进程消费)。签名校验在 TLS 层已经做了——Bridge 客户端到服务端的 WebSocket 是 `wss://`,传输层防了 MITM。在这个信任模型下,再做一次 JWT 验签只是徒增 CPU 开销。
|
||||
|
||||
但这套设计的**前提**是"Bridge 进程本身没被入侵"。如果攻击者拿到了 Bridge 进程的内存,他们可以直接调 `getAccessToken()`(`jwtUtils.ts:168`)拿到 OAuth 令牌,根本不用伪造 JWT。所以威胁模型是"防网络层攻击,不防进程被入侵"。
|
||||
|
||||
`createTokenRefreshScheduler`(`:72-256`)那 200 行的"失败重试 + generation counter + 30 分钟兜底 + 3 次失败放弃"逻辑,本质上是在防"刷新链断裂后会话静默掉线"——这是**可用性**防御,不是机密性防御。
|
||||
|
||||
### 为什么 share 的脱敏用正则而不是结构化扫描
|
||||
|
||||
`src/commands/share/index.ts:53-92` 的 `SECRET_PATTERNS` 表是一组正则,按"前缀 + 长度"匹配各类 token。**为什么不用 AST 解析 JSON、扫所有字符串字段**?
|
||||
|
||||
因为 transcript 的内容**不是结构化的**——它是用户和 Claude 的自由对话,token 可能出现在 markdown 代码块里、可能出现在错误消息里、可能被 Claude 引用又转述了一遍。结构化扫描要么扫不到(被文本包裹),要么扫到太多(合法的长字符串被误判)。
|
||||
|
||||
正则方案的优势是**精准按已知前缀匹配**:`sk-ant-` 是 Anthropic key 的固定前缀,`ghp_` 是 GitHub PAT 的固定前缀,`AKIA` 是 AWS key 的固定前缀。这些前缀是上游服务设计的"防误识别"机制,复用它们比自创规则更可靠。
|
||||
|
||||
但代价就是 `share/index.ts:89-91` 那条 NOTE 承认的局限:**没有固定前缀的 token(hex、base64)无法脱敏**,因为它们和合法的 git SHA、文件 hash 无法区分。这是"宁可漏过,不可误杀"的设计选择——误杀会把 transcript 弄成 `[REDACTED]` 满屏飞,比漏掉少数 token 还糟。
|
||||
|
||||
**根因**:在自由文本上做凭证脱敏是一个"召回率 vs 精确率"的权衡。share 选择了高精确率(固定前缀匹配),牺牲召回率(无前缀 token 漏过)。如果需要更强的脱敏,应该在源头(写入 transcript 之前)做,而不是在导出时亡羊补牢。
|
||||
|
||||
### 为什么 `/logout` 必须先 flushTelemetry
|
||||
|
||||
`src/commands/logout/logout.tsx:19-22` 的顺序看起来很奇怪:
|
||||
|
||||
```ts
|
||||
export async function performLogout({ clearOnboarding = false }): Promise<void> {
|
||||
// Flush telemetry BEFORE clearing credentials to prevent org data leakage
|
||||
const { flushTelemetry } = await import('../../utils/telemetry/instrumentation.js');
|
||||
await flushTelemetry();
|
||||
await removeApiKey();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
注释里的"prevent org data leakage"是关键。OpenTelemetry 的 instrumentation 在用户登录状态下会带上"当前组织 ID、用户 ID"等元数据,这些数据要发到 Anthropic 的 telemetry 后端。如果你先 `removeApiKey()` 再 flush,flush 出去的 telemetry 是"未登录状态"的,但这些事件实际上发生在"登录状态"下——属性不匹配。
|
||||
|
||||
更严重的场景:用户从 Org A 切到 Org B。如果先 clear 再 flush,A 状态下的事件可能被错误归因到 B 组织,泄漏 A 的活动给 B 管理员。先 flush 保证 A 状态下的事件还带着 A 的身份信息发出去,再 clear 切换身份。
|
||||
|
||||
**根因**:telemetry 的"身份绑定"必须和"事件发生时机"一致。`/logout` 不是单纯的"删 key",而是一次"身份切换的状态机迁移",必须按正确顺序:flush(保留旧身份) → clear(切换到匿名) → reset caches(清旧身份相关的缓存) → shutdown(进程退出)。
|
||||
|
||||
### 为什么 OpenAI 客户端是模块级缓存(设计取舍回顾)
|
||||
|
||||
这个点在 cross/01-troubleshooting.md 已经详细讲过,这里只补充**安全含义**。`getOpenAIClient`(`src/services/api/openai/client.ts:39`)把首次创建的客户端缓存到模块级 `cachedClient`,整个会话不重建。
|
||||
|
||||
**安全副作用**:会话中改 `OPENAI_API_KEY` 环境变量,**新 key 不会生效**,旧 key 仍在用。这听起来是 bug,但在另一个角度是**安全特性**:如果某个恶意脚本在会话中途改了 `OPENAI_API_KEY` 想劫持流量,它做不到——客户端已经被缓存,绑定的是原始 key。
|
||||
|
||||
代价是"用户合法换 key"也得重启 CLI,这是性能优化(避免每次调用都重建 axios 实例)和安全性(绑定首次凭证)的共同产物。`clearOpenAIClientCache()`(`openai/client.ts:76`)是逃生口,但只在 SDK 嵌入场景(用户自己写脚本)才可见——普通 CLI 用户根本不知道这个函数存在,只能通过重启来清缓存。
|
||||
|
||||
对比 `getAnthropicClient`(`client.ts:84`):每次按 model/region 参数化新建,因为 AWS / GCP / Azure 凭证刷新、region 选择、header 注入都是**会话过程中可能变化的参数**。Anthropic 路径必须每次重新构造,所以它的"换 key 立即生效"行为是被动得到的,不是有意设计的。
|
||||
|
||||
## 两视角如何呼应
|
||||
|
||||
用户视角的每一个安全焦虑,几乎都能在设计视角找到对应的设计决策:
|
||||
|
||||
- **"我的密钥存在哪里"**(产品视角)对应 **"ChatGPT 凭证为什么用明文 JSON + 0o600"**(设计视角)——明文是为了和 Codex CLI 互操作,0o600 是这个权衡下的补偿。用户看到的是"明文 JSON",开发者看到的是"互操作和强存储的冲突"。
|
||||
- **"bypassPermissions 为什么被拒了"**(产品视角)对应 **"为什么 bypass 必须先检测 root 和 sandbox"**(设计视角)——用户看到的是"启动失败报错",开发者看到的是"防御必须在威胁生效前完成"。
|
||||
- **"令牌什么时候过期"**(产品视角)对应 **"为什么 OAuth 用 5 分钟刷新窗口"**(设计视角)——用户看到的是"自动续期",开发者看到的是"刷新链断裂后的 3 次重试 + 30 分钟兜底"。
|
||||
- **"`/share --mask-secrets` 会不会泄漏"**(产品视角)对应 **"为什么脱敏用正则而不是结构化扫描"**(设计视角)——用户看到的是"已脱敏"标签,开发者看到的是"召回率 vs 精确率权衡 + 无前缀 token 漏过的诚实交代"。
|
||||
- **"`/logout` 真的清干净了吗"**(产品视角)对应 **"为什么必须先 flushTelemetry 再清凭证"**(设计视角)——用户看到的是"重置到初次安装",开发者看到的是"telemetry 身份绑定的状态机迁移"。
|
||||
- **"我把项目 settings.json push 到团队仓库会怎样"**(产品视角)对应 **"settings.json vs settings.local.json 的分层"**(设计视角)——用户看到的是"哪些文件会被共享",开发者看到的是"团队设置和个人覆盖的优先级"。
|
||||
- **"Codex CLI 登录的 ChatGPT 凭证 Claude 能用吗"**(产品视角)对应 **"为什么 chatgptAuth 回退读 `~/.codex/auth.json`"**(设计视角)——用户看到的是"两边都生效",开发者看到的是"跨工具凭证互操作的有意设计"。
|
||||
|
||||
这种呼应关系是安全章必须双视角覆盖的核心原因:用户视角告诉你**怎么用才安全**,设计视角告诉你**这个安全机制覆盖了什么、没覆盖什么**。两个视角合在一起,才能让使用者正确评估"我能把这台机器借给同事吗"、"我能把这份 transcript 发到群里吗"这类问题——不会盲目信任某个"已脱敏"标签,也不会因为某个明文 JSON 就以为整套凭证管理都不安全。
|
||||
187
docs/outline-output/cross/04-upgrade-versioning.md
Normal file
187
docs/outline-output/cross/04-upgrade-versioning.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# 升级与版本管理
|
||||
|
||||
> 同一个 `2.7.0` 在使用者眼里是"该不该 `claude update`、`claude doctor` 里那个 latest 是不是真的比我新",在开发者眼里是"为什么 `MACRO.VERSION` 必须从 `package.json` 反推、为什么 `--version` 走零模块加载 fast-path、为什么 Bedrock 那段针对性补丁必须留一段写着 probe 文件路径的注释"。升级和版本管理天生是双视角主题——用户想知道"怎么升、升完会不会坏",开发者想知道"版本号从哪里来、补丁什么时候才能拆"。
|
||||
|
||||
## 产品视角(写给使用者)
|
||||
|
||||
这一节回答三个问题:**我怎么知道该不该升级**、**怎么升**、**升完之后老的行为会不会变**。读完之后,你应该能判断"我现在跑的版本是不是最新的"、"这次升级会不会把我正在用的 Provider 弄坏"。
|
||||
|
||||
### 我怎么知道该不该升级
|
||||
|
||||
两条路,任选其一:
|
||||
|
||||
- **跑 `claude doctor`**。这是最稳的诊断入口,对应 `src/commands/doctor/doctor.tsx`(命令本身在 `src/commands/doctor/index.ts` 注册)。它会渲染一个 `Doctor` 屏幕(`src/screens/Doctor.tsx`),里面分三段对你最有用的信息:
|
||||
- **Diagnostics** 段:`Currently running: <type> (<version>)`、安装路径、被哪个二进制调用、ripgrep 是否可用(`Doctor.tsx:218-232`)。如果你装了多个版本(npm-global + native + package-manager 混着装),这里会显式 warn `Multiple installations found` 并把每个安装的 type 和 path 列出来(`Doctor.tsx:244-254`)。多安装是升级后行为飘移最常见的根因——你 `claude update` 升的是某一个,shell 里 `claude` 还指向另一个。
|
||||
- **Updates** 段:`Auto-updates` 的开关、`Update permissions: Yes/No (requires sudo)`、`Auto-update channel`(`latest` 或 `stable`),以及从远端拉下来的 `Stable version` / `Latest version`(`Doctor.tsx:279-289`,远端版本走 `getGcsDistTags` 或 `getNpmDistTags`,见 `Doctor.tsx:91-98`)。
|
||||
- **Version Locks** 段(仅当 PID-based locking 启用时):列出当前被锁住的版本和持有它的 PID(`Doctor.tsx:311-328`)。如果你看到某个 lock 标了 `(stale)`,说明上次升级被中断了,残留了一个进程没清掉的锁。
|
||||
- **直接跑 `claude --version`**(或 `claude -v` / `claude -V`)。这是最快的路径,只打印一行 `<version> (Claude Code)` 就退出(`src/entrypoints/cli.tsx:80-84`)。**注意**:它只告诉你"当前跑的是几",不会告诉你"远端最新是几"——要对比必须用 `claude doctor`。
|
||||
|
||||
`claude doctor` 还会顺带帮你把一堆"升级之后可能出问题"的信号检查一遍:env 变量是否超上限(`BASH_MAX_OUTPUT_LENGTH` / `TASK_MAX_OUTPUT_LENGTH` / `CLAUDE_CODE_MAX_OUTPUT_TOKENS`,见 `Doctor.tsx:103-128`)、settings 有没有 schema 错误、agent 文件有没有解析失败、MCP server 有没有 parsing warning、keybindings 有没有冲突。升级前先跑一次 `claude doctor`、升级后再跑一次对比,是排错最高效的姿势。
|
||||
|
||||
### 怎么升
|
||||
|
||||
跑 `claude update`(注册在 `src/main.tsx:5346-5353`,实现是 `src/cli/updateCCB.ts` 的 `updateCCB()`)。它会做这几件事:
|
||||
|
||||
1. 读当前版本:先尝试从 `distRoot` 上层的 `package.json` 读 `version`,读不到就退回 `MACRO.VERSION`(`updateCCB.ts:18-29`)。这一步保证"全局装的 ccb"和"开发模式下跑的 cli.tsx"看到的是同一个版本号。
|
||||
2. 探测包管理器:先看当前进程是不是从 bun 起的(`process.execPath` 含 `bun`,或者 `~/.bun/install/global/node_modules/claude-code-best` 存在),是就用 bun;否则用 npm(`updateCCB.ts:56-77`)。
|
||||
3. 从 npm registry 拉 latest 版本号:`npm view claude-code-best@latest version --prefer-online`(`updateCCB.ts:79-90`),10 秒超时。
|
||||
4. 比较:如果 `current >= latest`,直接打印 `ccb is up to date (<version>)` 退出;否则继续(`updateCCB.ts:113-122`)。
|
||||
5. 实际装:`bun install -g claude-code-best@latest` 或 `npm install -g claude-code-best@latest`,120 秒超时(`updateCCB.ts:131-152`)。
|
||||
|
||||
升级完成之后**必须重启 `claude`**。原因有两条:
|
||||
|
||||
- `claude update` 只动磁盘上的文件,不动当前正在运行的进程内存。你的 REPL 还跑着旧代码。
|
||||
- 多个兼容层的客户端(OpenAI / Grok)走的是模块级缓存(见 cross/03-security.md 的"为什么 OpenAI 客户端是模块级缓存"),重启之外没有任何方式让它们重新读 key 和 endpoint。
|
||||
|
||||
如果 `claude update` 失败,错误信息会直接建议你手动跑对应的 `bun install -g claude-code-best@latest` 或 `npm install -g claude-code-best@latest`(`updateCCB.ts:155-173`)。这两个命令本质上和 `claude update` 跑的是同一条 shell,区别只是 `claude update` 多了一层"探测包管理器 + 比较版本"的逻辑——失败时跳过这层逻辑直接装 latest 是最快的恢复方式。
|
||||
|
||||
### 升级之后老的行为会不会变
|
||||
|
||||
会,但只有两种情况值得你担心:
|
||||
|
||||
- **版本号最小限制**。`assertMinVersion()`(`src/utils/autoUpdater.ts:79-111`)会在启动时从远端 Statsig config `tengu_version_config` 读 `minVersion`,如果你跑的版本低于这个值,CLI 会**直接退出**并打印 `It looks like your version of Claude Code (<version>) needs to update`。这是服务端 kill switch——某些重大变更(API schema 不兼容、安全修复)上线时,官方会把这个值推高,强制所有人升级。**用户侧含义**:如果你某天打开 `claude` 发现它拒绝启动并提示要 update,先 `claude update` 再说。
|
||||
- **最大版本回退**。`getMaxVersion()`(`autoUpdater.ts:125-141`)从同一个远端 config 读 `external` / `ant` 字段,作为"当前允许的最高版本"。这是 incident 时的紧急刹车——如果新版本被发现有严重 bug,官方会把 max 版本设到上一个稳定版,auto-updater 就不会把用户升到坏版本。**用户侧含义**:你手动 `claude update` 后看到的版本可能比 npm registry 上的 `latest` 旧,这是有意的回退,不是你装错了。
|
||||
|
||||
注意 `assertMinVersion` 的注释(`autoUpdater.ts:46-60`)专门讲了一处容易混淆的设计:版本号格式 `X.X.X+SHA`(continuous deployment 用的带 build metadata 的 semver)里,**比较版本大小**(`assertMinVersion`)会忽略 `+SHA`,**检测是否有更新**(`claude update`)会用精确字符串比较不忽略。所以你可能看到 `claude --version` 显示 `2.7.0+abc123`、npm 上 latest 也是 `2.7.0`,但 `claude update` 还是会重新装一遍——因为它在比 SHA,发现你本地的 SHA 不是最新的。这不是 bug,是为了让 continuous deployment 的每次 commit 都能推到用户。
|
||||
|
||||
### 升级前自检清单
|
||||
|
||||
- `claude doctor` 看一下 `Auto-update channel`、`Update permissions`、有没有 `Multiple installations found` 警告。多安装的情况下先想清楚 shell 里 `which claude` 指向哪一个。
|
||||
- 如果你在用 OpenAI / Gemini / Grok 兼容层,记录一下当前 `OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROK_API_KEY` 的值(升级本身不动 key,但万一升级过程中断了重装,可能要重设)。
|
||||
- 如果你在 Bridge / Daemon / 后台 session 模式下长跑,升级前先 `claude daemon stop` / `claude kill` 把它们停掉——升级会替换二进制,但不会通知正在跑的进程。
|
||||
|
||||
## 设计视角(写给开发者)
|
||||
|
||||
设计大纲原本只在第二章入口链里点了一句"版本号单一来源 `package.json`"。这一节把版本号怎么流到运行时、针对性补丁什么时候该拆、双构建管线的版本一致性这三件事讲透。每个决策背后都有一个具体的约束(漂移、SDK 漏洞、bun/node 双运行时)。
|
||||
|
||||
### 为什么版本号必须从 `package.json` 反推,而不是 hardcoded
|
||||
|
||||
打开 `scripts/defines.ts:7-24`:
|
||||
|
||||
```ts
|
||||
const pkgPath = resolve(__dirname, '..', 'package.json')
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||
|
||||
export function getMacroDefines(): Record<string, string> {
|
||||
return {
|
||||
'MACRO.VERSION': JSON.stringify(pkg.version),
|
||||
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注释里写得很直白:`VERSION is read from package.json to avoid version drift`。版本号如果既写在 `package.json`、又写在 `defines.ts`、又出现在某处字符串字面量,发版时一定有人忘了同步其中一个,用户看到的 `claude --version` 就会和 npm 上的版本对不上。
|
||||
|
||||
但"单一来源"的实现路径很有意思——它必须穿过三层 MACRO 注入才能到达运行时:
|
||||
|
||||
1. **dev 模式**:`scripts/dev.ts:18-29` 把 `getMacroDefines()` 的返回值用 `-d` flag 一条条传给 `bun run`。注释(`dev.ts:5-9`)专门解释了为什么不用 `bunfig.toml` 的 `[define]`——因为它不会传播到 dynamically imported modules。
|
||||
2. **build 模式**:`build.ts` 把同样的 defines 喂给 `Bun.build({ define })`,由 Bun 编译器在 transpile 阶段做字面量替换。
|
||||
3. **运行时兜底**:如果有人直接跑 `bun src/entrypoints/cli.tsx`(既不走 `bun run dev` 也不走 dist/),`cli.tsx:9-21` 会检测 `globalThis.MACRO === undefined` 并填一个 fallback,`VERSION` 从 `process.env.CLAUDE_CODE_VERSION || '2.1.888'` 取。这个 `'2.1.888'` 是写死的 fallback——它只在"完全脱离工具链直接跑源码"时才出现,正常使用路径上永远不会看到这个版本号。
|
||||
|
||||
**为什么 `--version` fast-path 必须零模块加载**:`cli.tsx:79-84` 的逻辑只有一行 `console.log(\`${MACRO.VERSION} (Claude Code)\`)`。这之所以能做到"零模块加载",恰恰是因为 `MACRO.VERSION` 在 transpile 阶段就已经被替换成了字面量字符串——运行时不需要 import 任何东西就能拿到版本号。如果版本号是从某个模块的 `getVersion()` 函数读出来的,`--version` 就必须 import 那个模块,fast-path 就破了。**版本号的单一来源约束反过来塑造了 fast-path 的实现方式**——这是约束驱动设计的一个干净例子。
|
||||
|
||||
### `claude update` 为什么自己重新发明了版本比较,而不是用现成的 semver 库
|
||||
|
||||
看 `src/cli/updateCCB.ts:124-134`:
|
||||
|
||||
```ts
|
||||
function gte(a: string, b: string): boolean {
|
||||
const parseVer = (v: string) => v.replace(/^\D/, '').split('.').map(Number)
|
||||
const pa = parseVer(a)
|
||||
const pb = parseVer(b)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true
|
||||
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
一个手写的、只有 8 行的 `gte`。**为什么不复用 `src/utils/semver.ts`**?因为 `updateCCB.ts` 是一个**必须能独立运行的子命令**——它从 `getCurrentVersion()` 开始就要能在"用户刚装好 ccb、还没装依赖"的极简环境下工作。它 import 的全是 `node:child_process` / `node:fs` / `node:os` 这种 zero-dependency 标准库,加上项目内部的 `distRoot` / `execFileNoThrowWithCwd` / `gracefulShutdown` / `process` / `debug` / `chalk`。`semver.ts` 依赖的图更大,引入它会让 updateCCB 的启动时间变长、潜在故障面变大。
|
||||
|
||||
代价是这个 `gte` **不处理 build metadata**:`2.7.0+abc` 和 `2.7.0+def` 在这个比较里是相等的。`updateCCB.ts:120` 那条 `latestVersion === currentVersion || gte(currentVersion, latestVersion)` 的 `||` 短路就是补偿——先用精确字符串比较(能区分 SHA),相等了再退到手写 semver 比较(防 latest 比当前旧这种边界情况)。这个组合策略和 `autoUpdater.ts:46-60` 那段注释承认的"两套比较逻辑并存"是同一个权衡的延伸。
|
||||
|
||||
### Bedrock 补丁为什么必须留一段写着 probe 文件路径的注释
|
||||
|
||||
这是整个项目里最有"工程纪律"感的一段代码。打开 `src/services/api/bedrockClient.ts:1-35`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Extends AnthropicBedrock to work around an upstream bug where the SDK
|
||||
* re-plants the `anthropic-beta` HTTP header value into the request body
|
||||
* as `anthropic_beta`. Bedrock's Opus 4.7 endpoint rejects any request with
|
||||
* `anthropic_beta` in the body with a 400 "invalid beta flag" error.
|
||||
*
|
||||
* Source of the bug (SDK 0.26.4, still present through 0.28.1):
|
||||
* node_modules/@anthropic-ai/bedrock-sdk/client.js lines 122-127
|
||||
*
|
||||
* When upstream ships a fix, verify the probe in scripts/probe-bedrock-beta-fix.ts
|
||||
* shows "bug reproduced: false", then delete this class and change
|
||||
* services/api/client.ts to instantiate `AnthropicBedrock` directly.
|
||||
*/
|
||||
```
|
||||
|
||||
这段注释干了两件不寻常的事:
|
||||
|
||||
1. **精确锁定漏洞的范围**:SDK 版本(0.26.4-0.28.1)、出问题的源码行号(`client.js` 122-127)、错误现象(body 里多了 `anthropic_beta` 字段、Opus 4.7 返回 400)、上游 issue 编号(`anthropics/claude-code#49238`)。所有信息都精确到能在 5 秒内验证。
|
||||
2. **指明补丁的拆除条件**:当上游修复后,跑某个 probe 脚本确认 bug 不再复现,就可以**删掉整个 `BedrockClient` 类**,把 `services/api/client.ts` 改回直接 `new AnthropicBedrock(...)`。
|
||||
|
||||
**值得注意的事实**:注释里提到的 `scripts/probe-bedrock-beta-fix.ts` **目前并不存在于仓库里**(`find scripts -name '*probe*'` 只能找到 `probe-local-wiring.ts` 和 `probe-subscription-endpoints.ts`)。这不是文档错——这是注释作者留下的**意图标记**:补丁本身写了,但配套的"自动检测修复后能否拆除"的 probe 脚本还没补。读者看到这段注释时,应该理解成"这个补丁是临时的,未来某天上游修了就要拆,但目前没人持续监控上游 SDK 的变化"。
|
||||
|
||||
这正是 probe 模式的**价值与代价**:
|
||||
|
||||
- **价值**:每个针对性补丁都明确标注"我为什么存在、什么时候可以消失"。两年后某个新人接手代码,看到 `BedrockClient` 不会一脸懵——他能从注释里立刻判断"这个补丁还要不要留"。
|
||||
- **代价**:probe 脚本必须有人维护。注释里写的那个文件不存在,意味着拆除条件目前**没有自动验证**——上游 SDK 升级到修复版之后,没有人会被自动通知"现在可以删 BedrockClient 了"。补丁会一直留着,直到某次 code review 有人手动翻到这段注释、手动验证、手动拆。
|
||||
|
||||
**根因**:针对性补丁是技术债的一种特殊形态——它承认"我在等上游修"。probe 模式是把这种"等"变得**可追踪**:每段补丁都自带拆除说明书。但说明书本身不会自动执行,所以 probe 模式的实际效果取决于团队是否真的定期跑 probe。这个项目目前的状态是"说明书有了,自动化还没跟上"。
|
||||
|
||||
### 为什么 MACRO 必须用编译期字面量替换,而不是运行时函数
|
||||
|
||||
版本号和构建时间这种常量,理论上完全可以写成一个普通的 `export const VERSION = pkg.version`。为什么非要走 MACRO 编译期替换?
|
||||
|
||||
答案藏在 `--version` 的 fast-path 设计里。如果 VERSION 是普通 export,`cli.tsx:80-84` 那段代码就必须 `import { VERSION } from '...constants...'`,这次 import 会触发常量模块所在依赖图的解析——`constants/` 里如果还有别的导出、还有别的副作用,fast-path 就不再是"零模块加载"。
|
||||
|
||||
MACRO 替换绕开了这个问题:`MACRO.VERSION` 在 transpile 阶段被替换成字符串字面量 `'2.7.0'`,运行时 `cli.tsx` 里那行就是 `console.log(\`2.7.0 (Claude Code)\`)`——没有任何 import、没有任何模块解析、没有任何副作用。`--version` 的 RSS 因此能从"加载整个 CLI"降到几十 MB(见 cross/02-performance-memory.md)。
|
||||
|
||||
这个选择还顺手解决了**dev 和 build 的版本号一致性**:`dev.ts` 和 `build.ts` 都从同一个 `getMacroDefines()` 读 defines(`defines.ts:14`),所以 dev 模式跑出来的 `--version` 和 build 出来的 dist 跑出来的 `--version` 一定是同一个值。如果走 `export const VERSION`,dev 模式读源码 `package.json`、build 模式读 build 时打包进去的 `package.json`,两边就有漂移风险。
|
||||
|
||||
**根因**:MACRO 不是"为了语义清晰而引入的抽象",而是"为了让 fast-path 真的快、为了让 dev/build 版本一致而被迫引入的编译期机制"。它是性能和一致性约束的共同产物。
|
||||
|
||||
### 双构建管线(Bun.build vs Vite)的版本号一致性
|
||||
|
||||
项目有两套构建管线(详见设计大纲第一章):`build.ts` 跑 `Bun.build()`、`vite.config.ts` 跑 Vite。两者都从 `scripts/defines.ts` 读 MACRO defines:
|
||||
|
||||
- **Bun.build 路径**:`build.ts` 直接调 `getMacroDefines()` 喂给 `Bun.build({ define })`。
|
||||
- **Vite 路径**:`scripts/vite-plugin-feature-flags.ts` 在 transform 阶段做字面量替换。
|
||||
|
||||
两条路径用的是同一个 defines 函数,所以产物的版本号一致。这看起来是显然的,但它是**有意设计**——如果两条路径各自硬编码版本号、或各自从不同地方读,就会有"Vite 构建的 `--version` 和 Bun 构建的 `--version` 不一致"这种诡异 bug。`defines.ts` 既是单一来源,也是两条管线的契约。
|
||||
|
||||
构建后还有一道独立的 post-process(`build.ts:43-46`):把 `import.meta.require` 替换成 `typeof import.meta.require === "function" ? import.meta.require : (await import("module")).createRequire(import.meta.url)`。这道 patch 让产物**同时兼容 bun 和 node**——同一份 dist 文件,bun 跑用 `import.meta.require`(Bun 原生支持),node 跑用 `createRequire`(Node 标准 API)。这是双入口 `cli-bun.js` / `cli-node.js` 能共用同一份 chunk 的前提。
|
||||
|
||||
### 升级流程为什么不走"热替换"
|
||||
|
||||
`claude update` 装完新版本后,**当前进程不会被替换**。REPL 还跑着旧代码,直到用户手动退出重开。为什么不像浏览器那样做热替换?
|
||||
|
||||
打开 `cli/updateCCB.ts:131-152` 看实际逻辑:它跑的是 `execSync('bun install -g ...@latest')` 或 `execSync('npm install -g ...@latest')`。这是**子进程同步执行**,完成后新文件就位,但**父进程(当前 REPL)的 require 缓存、模块级 const、模块级 client 缓存全部不动**。
|
||||
|
||||
热替换需要解决三个难题:
|
||||
|
||||
1. **模块级缓存的失效**。`getOpenAIClient` / `getGrokClient`(见 cross/03-security.md)把客户端实例缓存到模块级变量,热替换要遍历所有这些模块、清掉缓存。
|
||||
2. **模块级 const 的重捕获**。`cli.tsx:56-69` 那段 ablation 逻辑,`BashTool` / `AgentTool` / `PowerShellTool` 在 import 时就把环境变量捕获进模块级 `const`。热替换要重新 import 这些模块,让 const 重新捕获——但这意味着工具实例全部重建,正在跑的 agent / 后台 task 全部丢失。
|
||||
3. **React 状态树的保留**。REPL 是 Ink 渲染的 React 树,messages / tools / MCP 连接全是 state。热替换要保证 state 不丢——但新版代码的 state shape 可能变了(schema migration)。
|
||||
|
||||
三个难题都没好解。所以项目选择了一个朴素但鲁棒的方案:**升级只动磁盘,重启靠用户**。代价是多了一次手动重启,收益是绝对不会出现"半新半旧"的不一致状态。这个权衡和 `/logout` 必须先 flushTelemetry 再清凭证(见 cross/03-security.md)是同一种风格——**宁可让用户多做一步,也不接受状态不一致**。
|
||||
|
||||
## 两视角如何呼应
|
||||
|
||||
用户视角的每一个升级困惑,几乎都能在设计视角找到对应的设计决策:
|
||||
|
||||
- **"我怎么知道该不该升"**(产品视角)对应 **"`--version` 为什么是零模块加载 fast-path"**(设计视角)——用户看到的是"一行命令秒出",开发者看到的是"MACRO 编译期替换让版本号成为字面量、绕开 import 触发的模块解析"。
|
||||
- **"`claude update` 装的是哪个版本"**(产品视角)对应 **"为什么版本号必须从 `package.json` 反推"**(设计视角)——用户看到的是"升级提示很准",开发者看到的是"`scripts/defines.ts` 的单一来源约束 + dev/build 双管线共用同一个 defines 函数"。
|
||||
- **"为什么 `claude update` 之后还要手动重启"**(产品视角)对应 **"为什么升级不走热替换"**(设计视角)——用户看到的是"多一步操作",开发者看到的是"模块级缓存 + 模块级 const + React state 三重难题的工程权衡"。
|
||||
- **"为什么我的版本号带 `+SHA` 后缀,npm 上的 latest 看起来一样却还是要重装"**(产品视角)对应 **"`assertMinVersion` 的两套比较逻辑"**(设计视角)——用户看到的是"莫名其妙的重复升级",开发者看到的是"continuous deployment 的 SHA 比较与 semver 比较并存的诚实设计"。
|
||||
- **"Bedrock 报 400 invalid beta flag 怎么办"**(产品视角,详见 cross/01-troubleshooting.md)对应 **"BedrockClient 为什么必须留 probe 注释"**(设计视角)——用户看到的是"升级 SDK 之后某个错误消失了或出现了",开发者看到的是"针对性补丁的拆除条件被写成注释、probe 脚本作为意图标记但当前仓库里还没建"。
|
||||
- **"升级之后 key 还在不在"**(产品视角)对应 **"升级为什么只动磁盘不动进程"**(设计视角)——用户看到的是"key 不变、设置不变",开发者看到的是"`updateCCB.ts` 只跑 npm/bun install、完全不碰 ~/.claude/ 下的凭证文件"。
|
||||
|
||||
这种呼应关系是升级与版本管理章必须双视角覆盖的核心原因:用户视角告诉你**怎么升才安全**,设计视角告诉你**这个升级机制覆盖了什么、没覆盖什么**。两个视角合在一起,才能让使用者正确评估"我现在该不该升、升完之后哪些东西会变、哪些不会变"——不会盲目相信"升级就是好的",也不会因为某次升级出过 bug 就永远不敢再升。
|
||||
170
docs/outline-output/cross/05-tool-integration.md
Normal file
170
docs/outline-output/cross/05-tool-integration.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 与其他工具集成
|
||||
|
||||
> 同一个"接入外部工具"的动作,在使用者眼里是"我能在 VS Code / Zed / Cursor / GitHub Actions / Codex CLI 里用 Claude 吗、要装什么、凭证怎么走",在开发者眼里是"为什么 IDE 走 MCP 的 `sse-ide` / `ws-ide` 子类型、为什么 ACP agent 用 stdio NDJSON、为什么 ChatGPT 订阅凭证要 fallback 读 `~/.codex/auth.json`、为什么 `install-github-app` 是 React 多步表单而不是一行 shell"。集成天然是双视角主题——用户想知道"能不能接、怎么接",开发者想知道"边界在哪、契约长什么样、为什么这样切"。
|
||||
|
||||
## 产品视角(写给使用者)
|
||||
|
||||
这一节回答一个最高频的问题:**我能在 X 里用 Claude 吗?** 答案按"接入形态"分成五类,每类给一个清单式的"做什么 → 怎么做"。
|
||||
|
||||
### 第一类:把 Claude 接进 IDE(VS Code / Cursor / Windsurf / JetBrains / Zed)
|
||||
|
||||
你能在主流 IDE 里得到一个能看见当前工作区、能开 diff、能跑工具的 Claude。两条路径,按 IDE 选:
|
||||
|
||||
- **VS Code 家族(VS Code / Cursor / Windsurf)+ JetBrains 家族**:装官方扩展或插件,然后在 `claude` REPL 里跑 `/ide`(命令在 `src/commands/ide/index.ts` 注册,实现在 `src/commands/ide/ide.tsx`)。它会扫描当前在跑的 IDE、列出带扩展的实例、让你选一个连过去。`/ide open`(`ide.tsx:277-329`)还会把当前 worktree 或 cwd 在选中的 IDE 里打开。注意 VS Code 系列有一条限制:同一时刻只能有一个 Claude 实例连过去(`ide.tsx:127-131` 的告警)。
|
||||
- **Zed / Cursor 等 ACP 客户端**:ACP(Agent Client Protocol)是 stdio NDJSON 协议。Claude 自身就是一个 ACP agent,跑 `claude --acp`(`src/entrypoints/cli.tsx:123-124` 的 fast-path,受 `feature('ACP')` 门控)就会进入 stdio 模式,由 IDE 直接 spawn。Zed 侧的配置方式见 `docs/features/agents/acp.md`:在 Zed 的 `settings.json` 里加 `agent_servers`,`command` 指向 `claude`,`args` 写 `["--acp"]`。
|
||||
|
||||
**这两条路径的区别**:`/ide` 是 Claude 主动连过去(Claude 作为 MCP client 反向连 IDE 的 MCP server),适合在终端 REPL 里把 IDE 当作"上下文源";`--acp` 是 IDE 把 Claude 当 agent 调起来(Claude 作为 ACP server),适合 IDE 内置的 Agent Panel。两种方向都支持,挑你顺手的。
|
||||
|
||||
**自动连接**:`/ide` 第一次手动选完之后,会在 `IdeAutoConnectDialog`(`src/components/IdeAutoConnectDialog.js`)里问你要不要"以后自动连"。开了之后下次启动 REPL 会自动连上同一台 IDE,不用每次 `/ide`。要关掉就再跑 `/ide` 选 `None`,会弹 `IdeDisableAutoConnectDialog`。
|
||||
|
||||
### 第二类:把 Claude 暴露成可以被远程调用的服务(ACP / Bridge / RCS)
|
||||
|
||||
"我有一台跑 Claude 的机器、想让另一台机器(或浏览器、或团队同事)调用它"——三类方案:
|
||||
|
||||
- **ACP agent 远程化**:`claude --acp` 默认是本地 stdio。要让 WebSocket 客户端也能调,跑 `acp-link`(`packages/acp-link/`,README 在 `packages/acp-link/README.md`)。它把 WebSocket 连接桥接到 ACP agent 的 stdin/stdout。默认端口 9315,默认会自动生成一个 token;要固定 token 用 `ACP_AUTH_TOKEN` 环境变量,要禁用认证(不推荐)用 `--no-auth`。详细 CLI 选项见 README。
|
||||
- **Bridge / Remote Control 快速路径**:`claude remote-control` / `claude rc` / `claude remote` / `claude sync` / `claude bridge`(`cli.tsx:178-188`,五个别名都进同一条 fast-path,受 `feature('BRIDGE_MODE')` 门控)。这条路径把当前进程接到一个 Remote Control 后端,让你的 REPL 能被远端控制。
|
||||
- **自托管 RCS(Remote Control Server)**:如果你要给一个团队或长期跑的后端,用 `packages/remote-control-server/`(Docker 部署 + Web UI 控制面板,启动用 `bun run rcs`)。它的 README(`packages/remote-control-server/README.md`)列了五项能力:会话管理、实时消息流(WebSocket / SSE 双向)、权限审批(在 Web UI 里点同意/拒绝)、多环境管理(注册多台运行环境、心跳和断线重连)、API Key + JWT 双层认证。acp-link 也能注册到 RCS:设 `ACP_RCS_URL` / `ACP_RCS_TOKEN` / `ACP_RCS_GROUP`(或 `--group <id>` flag),就能在 RCS Web UI 里看到这个 ACP agent。
|
||||
|
||||
**这三类的取舍**:acp-link 适合"我有一台机器、想让外部 WebSocket 调一下";`claude remote-control` 适合"我正在 REPL 里干活、临时让远端接入";自托管 RCS 适合"团队级长期跑"。同一个底(query loop + 工具系统)三种接入形态,见设计视角的"集成边界"一节。
|
||||
|
||||
### 第三类:把 Claude 嵌进 GitHub 工作流(issue / PR review / 自动修复)
|
||||
|
||||
两条入口:
|
||||
|
||||
- **手动一键装**:`claude install-github-app`(实现在 `src/commands/install-github-app/install-github-app.tsx`,命令注册在 `src/commands/install-github-app/index.ts`)。它是一个多步 React 表单(不是 shell 命令),会带你走完:检测 `gh` 是否装了、选 repo、检测现有 workflow、装 GitHub App、写 API key 到 GitHub Secret、装 workflow 文件。装完之后,在你的 GitHub repo 里 `@claude` 提一句,就会触发 `claude-code-action` 跑一轮。具体能触发什么事件、workflow 模板长什么样,看 `src/constants/github-app.ts`——`WORKFLOW_CONTENT` 是写进你 repo 的 workflow 文件内容,`GITHUB_ACTION_SETUP_DOCS_URL` 指向 `anthropics/claude-code-action` 仓库的 setup 文档。
|
||||
- **直接 commit + push + 开 PR**:`/commit-push-pr`(`src/commands/commit-push-pr.ts`)。这不是 GitHub App,是你本地 `claude` 直接用 `gh` CLI 帮你开 PR。它内部有一个 `ALLOWED_TOOLS` 白名单(`commit-push-pr.ts:11-23`),只允许 `Bash(git ...)` / `Bash(gh pr ...)` / `SearchExtraTools` 和两个 Slack 工具。如果你的 CLAUDE.md 提到要往 Slack 发 PR 链接,它还会用 `SearchExtraTools` 找 Slack 工具问你要不要发(`commit-push-pr.ts` 的 `slackStep`)。
|
||||
- **PR 自动修复**:`/autofix-pr`(`src/commands/autofix-pr/`,入口 `launchAutofixPr.ts`)。这是给 CI 上跑的——PR 触发后 Claude 看一遍、发现明显问题就自动提交一个修复 commit。
|
||||
|
||||
### 第四类:和 Codex CLI 共享 ChatGPT 订阅凭证
|
||||
|
||||
如果你同时在用 Codex CLI 和 Claude,并且想用 ChatGPT 订阅当后端(`OPENAI_AUTH_MODE=chatgpt`),你**不需要在两边各登录一次**。Claude 会先读自己的 `~/.claude/openai-chatgpt-auth.json`;如果不存在,会 fallback 读 Codex CLI 的 `~/.codex/auth.json`(`src/services/api/openai/chatgptAuth.ts:339-344`)。所以你在 Codex CLI 里登录过、Claude 这边就能直接复用。
|
||||
|
||||
反过来不成立:Codex CLI 不会读 Claude 的凭证文件。如果你只想在 Claude 里用,就只在 Claude 这边 `/login` 走 ChatGPT 设备码流程;如果你想在两边都用,去 Codex CLI 登录一次更省事。
|
||||
|
||||
凭证刷新有 5 分钟的偏差窗口(`REFRESH_SKEW_MS = 5 * 60 * 1000`,`chatgptAuth.ts:7`)——令牌过期前 5 分钟内任意一次请求都会触发刷新,避免边界 race。详见 cross/03-security.md 的凭证章节。
|
||||
|
||||
### 第五类:跨工具凭证共享(其他 Provider)
|
||||
|
||||
**只有 ChatGPT 订阅路径**会跨工具读 Codex 的凭证文件。其他 Provider(Anthropic / 普通 OpenAI API key / Gemini / Grok / Bedrock / Vertex / Foundry)的 key 都存在 Claude 自己的 `~/.claude/` 下或 `settings.json` 里,不与任何外部工具共享。
|
||||
|
||||
如果你同时在别的工具(比如 Aider、Continue)里用 Anthropic API,那些工具各自读自己的配置——你需要在每个工具里都配一遍 `ANTHROPIC_API_KEY` 或对应的环境变量。这不是 bug,是有意的隔离:一个工具的凭证泄露不应该顺带把另一个工具的也带出去。
|
||||
|
||||
## 设计视角(写给开发者)
|
||||
|
||||
设计大纲原本完全没有"跨工具集成视角"。这一节补上"集成边界"——每一类集成背后都有一组明确的契约和决策:协议形态、凭证流向、feature 门控、命令路径。读完之后你应该能回答:"如果我要加一个新的 IDE 集成、或一个新的 CI 平台,边界在哪、哪些约束是必须遵守的"。
|
||||
|
||||
### 为什么 IDE 集成走 MCP 的 `sse-ide` / `ws-ide` 子类型,而不是普通 MCP
|
||||
|
||||
打开 `src/commands/ide/ide.tsx:463-472`,看连接 IDE 时写入 `dynamicMcpConfig` 的逻辑:
|
||||
|
||||
```ts
|
||||
const url = selectedIDE.url
|
||||
newConfig.ide = {
|
||||
type: url.startsWith('ws:') ? 'ws-ide' : 'sse-ide',
|
||||
url: url,
|
||||
ideName: selectedIDE.name,
|
||||
authToken: selectedIDE.authToken,
|
||||
ideRunningInWindows: selectedIDE.ideRunningInWindows,
|
||||
scope: 'dynamic' as const,
|
||||
} as ScopedMcpServerConfig
|
||||
```
|
||||
|
||||
IDE 在 MCP config 里是一个特殊的 `ide` key,type 是 `sse-ide` 或 `ws-ide`——不是普通的 `sse` / `websocket`。这两个子类型在 `src/services/mcp/` 里有专门的处理路径。**为什么不给 IDE 用普通 MCP?** 因为 IDE 提供的不只是工具(`mcp__ide__*` 工具前缀,见 `ide.tsx:455-456` 的 `filter` 清理逻辑),还有 diff 显示、当前选中文件、diagnostics 推送这些"非工具形态"的能力。给 IDE 单独留一个 type,让 MCP client 知道"这个连接除了普通工具调用,还有 IDE 专有的副作用通道"。
|
||||
|
||||
**另一个有意思的设计**:`dynamicMcpConfig` 的 scope 是 `'dynamic'`。这意味着 IDE 配置不写进 `settings.json`,而是活在 React state 里——下次启动 REPL 不会自动恢复。自动恢复靠 `IdeAutoConnectDialog` 单独存的标志位("以后自动连"),连接动作本身每次都要重新走一遍。这个设计的代价是:用户换一台机器、或者把 settings 同步到另一台,IDE 自动连不会跨机器带过去。收益是:IDE 的端口和 token 是会话期会变的(IDE 重启端口就变),写进持久化 settings 反而会读到过期值。
|
||||
|
||||
**disconnect 的细节**(`ide.tsx:446-460`):断开连接时除了清 config,还主动 `ideClient.client.onclose = () => {}` 把 onclose 置空。**为什么?** MCP client 有自动重连机制,正常关闭会触发重连。置空 onclose 是"我说了要断、别再自己连回来"的信号——这是 RPC 类连接很容易踩的坑,`/ide` 选 None 的时候必须做这一步,否则用户会看到"我明明断了它又自己连上"。
|
||||
|
||||
### 为什么 ACP agent 是 stdio NDJSON,而 acp-link 要做 WebSocket → stdio 桥接
|
||||
|
||||
ACP 的协议形态选择写在 `docs/features/agents/acp.md`:stdin/stdout 的 NDJSON 流。**为什么是 stdio?** 因为 stdio 是 IDE 调子进程最简单的形态——IDE spawn `claude --acp`,往 stdin 写 NDJSON,从 stdout 读 NDJSON。不需要开端口、不需要握手、不需要网络配置。代价是"只能本地调用"——IDE 和 agent 必须在同一台机器上同一个进程树里。
|
||||
|
||||
acp-link(`packages/acp-link/`)就是为突破这个限制存在的。看 README 的 "How It Works":它监听 WebSocket、收到 `connect` 消息就 spawn 配置好的 ACP agent 子进程、把 WebSocket 帧和 agent 的 stdin/stdout 双向桥接。**为什么不直接给 ACP agent 加一个 WebSocket 模式?** 因为 stdio 和 WebSocket 是两种完全不同的 I/O 模型——stdio 是阻塞 read、WebSocket 是事件回调。把它们塞进同一个 agent 进程会让 agent 的代码复杂度爆炸。acp-link 作为独立进程承担"协议翻译",agent 自己保持纯 stdio,**单一职责**。
|
||||
|
||||
**这个设计的代价**:多了一层进程。acp-link 进程崩了,agent 和 WebSocket 客户端都会失联。RCS 的多环境管理(README 提到"心跳和断线重连")部分就是为了缓解这个——acp-link 进程挂了 RCS 能检测到、能重启。`packages/acp-link/src/manager/`(README 的 "Manager UI" 段)进一步提供了"一台机器跑多个 acp-link 子进程、统一管理"的形态,这是为团队场景设计的。
|
||||
|
||||
**凭证透传**:ACP agent 启动时会读 `settings.json` 里的环境变量(见 `docs/features/agents/acp.md` 第 58 行,`ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` 等)。Zed 这种 IDE 还能在 `agent_servers` 配置里显式传 `env`。**为什么不让 ACP 协议自己带凭证?** 因为 ACP 是协议、凭证是部署期决策——协议只规定"怎么对话",凭证由调用方(IDE 的 `agent_servers.env` / RCS 的环境变量 / acp-link 启动时的环境)决定。这种分离让同一个 ACP agent 能在不同 IDE、不同部署形态下复用,不需要改 agent 代码。
|
||||
|
||||
### 为什么 ChatGPT 订阅凭证要 fallback 读 `~/.codex/auth.json`
|
||||
|
||||
打开 `src/services/api/openai/chatgptAuth.ts:42-57`:
|
||||
|
||||
```ts
|
||||
function authFilePath(): string {
|
||||
return join(getClaudeConfigHomeDirLocal(), AUTH_FILE)
|
||||
}
|
||||
|
||||
function codexAuthFilePath(): string {
|
||||
return join(
|
||||
process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex'),
|
||||
'auth.json',
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
两个路径函数。`getValidChatGPTAuth`(`chatgptAuth.ts:339-344`)的读取顺序是:**先读 Claude 自己的 `~/.claude/openai-chatgpt-auth.json`,读不到再 fallback 读 Codex CLI 的 `~/.codex/auth.json`**,并打一条 debug 日志 `[OpenAI] Using ChatGPT auth from Codex auth.json`。
|
||||
|
||||
**为什么这么设计?** ChatGPT 订阅的 OAuth 设备码流程是 OpenAI 自己发的(`ISSUER = 'https://auth.openai.com'`,`CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'`,`chatgptAuth.ts:5-6`)。Codex CLI 用的是同一个 issuer 同一个 client_id(验证:`verificationUrl` 用 `${ISSUER}/codex/device`,`chatgptAuth.ts:217`)。两边走的是同一套令牌体系,令牌可以互换——所以让 Claude 复用 Codex 的凭证是合法的、不是"借用"。
|
||||
|
||||
**为什么不强制让用户在 Claude 这边也登录一次?** 因为 ChatGPT 订阅用户已经为这个 token 付过费、已经走完设备码握手了。让他在每个工具里都重做一次设备码登录(打开浏览器、输 userCode、等待授权)是明显的体验灾难。fallback 读 Codex 凭证把"一次登录、多个工具复用"变成可能。
|
||||
|
||||
**反向不成立**:Codex CLI 不会读 Claude 的凭证文件。这是有意的非对称——Claude 这边承认"我是后来的、我读你的",但 Codex CLI 作为 OpenAI 自家工具不知道 Claude 的存在。这种非对称在跨工具凭证共享里很常见:后入场的一方做兼容,先入场的一方保持简单。
|
||||
|
||||
**风险**:这个 fallback 假设 Codex CLI 的凭证文件格式稳定。如果某天 Codex CLI 改了 `auth.json` 的 schema(加字段、改字段名、嵌套层级变化),Claude 这边的 `readStoredAuth`(`chatgptAuth.ts:123`)就要跟着改。这是跨工具集成的固有脆弱性——**两边的格式没有契约约束,只靠"碰巧一致"维持**。如果 Codex CLI 那边改了,Claude 这边不会自动收到通知,要靠用户报"我用 ChatGPT 模式登录不了了"才会被发现。
|
||||
|
||||
### `install-github-app` 为什么是 React 多步表单,而不是一行 shell
|
||||
|
||||
打开 `src/commands/install-github-app/install-github-app.tsx`,它 import 了 11 个 Step 组件:`ApiKeyStep` / `CheckExistingSecretStep` / `CheckGitHubStep` / `ChooseRepoStep` / `CreatingStep` / `ErrorStep` / `ExistingWorkflowStep` / `InstallAppStep` / `OAuthFlowStep` / `SuccessStep` / `WarningsStep`。一个简单的"装 GitHub App"为什么要拆这么多步?
|
||||
|
||||
因为"装一个 GitHub App"在生产环境里至少有 11 个分支:
|
||||
|
||||
- `gh` 装了吗?没装怎么办?(`CheckGitHubStep`)
|
||||
- 用户想装到当前 repo 还是别的 repo?当前 repo 探测到了吗?(`ChooseRepoStep`)
|
||||
- API key 用现有的还是新建?用 OAuth 还是 API key?(`ApiKeyStep`,`selectedApiKeyOption: 'new' | 'existing' | 'oauth'`,见 `install-github-app.tsx:36`)
|
||||
- repo 里已经有同名 secret 了吗?要覆盖还是保留?(`CheckExistingSecretStep`)
|
||||
- repo 里已经有 workflow 文件了吗?要装哪几个?(`ExistingWorkflowStep`,默认 `['claude', 'claude-review']`,见 `install-github-app.tsx:35`)
|
||||
- 创建过程中出错了?错误长什么样、能不能重试?(`ErrorStep`、`CreatingStep`)
|
||||
- 装完了有哪些警告?比如权限不够、repo 是 fork、org policy 限制?(`WarningsStep`)
|
||||
|
||||
每一个分支都需要用户决策、都要展示状态。**用一行 shell 解决不了**——shell 是"我已知所有参数、一次性执行",而 GitHub App 安装是"边探测边问边装"。React 多步表单是这种"探测-决策-执行-反馈"循环的自然形态。
|
||||
|
||||
**契约**:`install-github-app` 写进用户 repo 的 workflow 文件内容是写死在 `src/constants/github-app.ts` 的 `WORKFLOW_CONTENT` 常量里——这是一个 GitHub Actions YAML 字符串,定义了 `issue_comment` / `pull_request_review_comment` / `issues` / `pull_request_review` 四类事件的触发条件(都是 `@claude` mention),跑在 `ubuntu-latest` 上,permissions 是 `contents: read` / `pull-requests: read` / `issues: read` / `id-token: write`。PR 标题也是常量 `PR_TITLE = 'Add Claude Code GitHub Workflow'`。**这些常量就是 Claude ↔ GitHub 的契约**——改 `WORKFLOW_CONTENT` 等于改所有未来用户装上去的 workflow 模板,要非常小心向后兼容。
|
||||
|
||||
### `/commit-push-pr` 的 `ALLOWED_TOOLS` 白名单为什么这么窄
|
||||
|
||||
看 `src/commands/commit-push-pr.ts:11-23`:只允许 `Bash(git ...)` 几条、`Bash(gh pr ...)` 几条、`SearchExtraTools`、两个 Slack 工具。**为什么不给它 `FileEdit` / `FileWrite`?** 因为 `/commit-push-pr` 的语义是"把已经做好的改动提交、推送、开 PR"——它不应该再修改代码。如果允许 `FileEdit`,这个命令就会从"提交工具"退化成"提交 + 顺手再改点代码"的工具,权限边界就乱了。
|
||||
|
||||
**为什么 Slack 工具要单列?** 看 `commit-push-pr.ts` 后面的 `slackStep` 逻辑:如果你的 CLAUDE.md 里写了"开完 PR 往 Slack 发链接",这个命令会尝试用 `SearchExtraTools` 找 Slack 工具,问你要不要发。这是一条**条件性集成**——不是所有用户都装了 Slack MCP,所以不能硬编码 `mcp__slack__send_message`,要走延迟工具搜索。如果没找到,silent skip,不打扰用户。
|
||||
|
||||
**`getPromptContent` 里的 `process.env.USER_TYPE === 'ant' && isUndercover()`**(`commit-push-pr.ts:33-40` 附近)是一个有意思的边界:Anthropic 内部用户跑这个命令时,会切到 "undercover" 模式——不暴露自己在用 Claude。这是 dogfooding 的产物:内部用户用自家工具时,对外(比如在公开的开源 repo 里开 PR)不能露出"这是 AI 写的"的痕迹。这个分支只在 `USER_TYPE === 'ant'` 时生效,普通用户看不到。
|
||||
|
||||
### 三种长驻模式(ACP / Bridge / Daemon)共享底层 query loop 但各有独立 entry
|
||||
|
||||
这是设计大纲第十二章的核心论点在集成视角下的具体化。三者的关系:
|
||||
|
||||
- **ACP**(`src/services/acp/`):`cli.tsx:123-124` 的 `--acp` fast-path,受 `feature('ACP')` 门控。进入 `src/services/acp/entry.ts`,spawn 一个 `AcpAgent`(`agent.ts`)。agent 把 ACP 客户端的请求桥接到内部的 query loop(`src/services/acp/bridge.ts`),权限决策走 `createAcpCanUseTool`(`src/services/acp/permissions.ts`)。
|
||||
- **Bridge**(`src/bridge/`):`cli.tsx:178-188` 的 `remote-control` / `rc` / `remote` / `sync` / `bridge` 五个别名 fast-path,受 `feature('BRIDGE_MODE')` 门控。进入 `src/bridge/bridgeMain.ts`,JWT 认证(`jwtUtils.ts`)、消息传输(`bridgeMessaging.ts`)、权限回调(`bridgePermissionCallbacks.ts`)。
|
||||
- **Daemon**(`src/daemon/`):`cli.tsx` 的 `daemon` 子命令,受 `feature('DAEMON')` 门控。`src/daemon/main.ts` 是 entry,`workerRegistry.ts` 管 worker,`--daemon-worker=<kind>` 派生精简 worker。
|
||||
|
||||
**共享的部分**:三者都最终调用 `src/query.ts` 的 `query()` async generator(见设计大纲第五章)。工具系统、Provider 路由、流式响应——这些都是共用的。**各自增加的编排层**:ACP 加了"会话管理 + 权限桥接 + prompt 排队",Bridge 加了"JWT 认证 + 远端消息传输 + 权限远程审批",Daemon 加了"worker 注册表 + 心跳 + 精简 worker 派生"。
|
||||
|
||||
**为什么三个要分开**:因为它们的**调用方不同**。ACP 的调用方是 IDE(同机 stdio),Bridge 的调用方是 RCS 后端(远端 JWT),Daemon 的调用方是 CI 或 supervisor(进程级 spawn)。三种调用方对认证、传输、生命周期的要求完全不同——IDE 不需要认证(已经在用户机器上)、RCS 必须认证(暴露在网络上)、Daemon 必须支持后台 + 心跳(长跑)。把这些塞进同一个 entry 会让代码变成"if (acp) {...} else if (bridge) {...} else if (daemon) {...}"的分支地狱。分开三个 entry、各自 feature-gated,是**用 entry 数量换 entry 简单度**的权衡。
|
||||
|
||||
**BYOC runner 是三条线的交汇点**:`claude environment-runner` / `claude self-hosted-runner`(见设计大纲第十二章)是这三条线和 CI(产品大纲第十一章)的交汇——它能让外部 CI 系统以 Bring-Your-Own-Compute 的方式调用 Claude,背后可能用 ACP(同机)、Bridge(远端)、或 Daemon(长跑)任意一种。这是"集成边界"最抽象的一层:用户不直接选 ACP/Bridge/Daemon,他选的是 environment-runner,由 runner 决定底下用哪种长驻模式。
|
||||
|
||||
### VS Code 桥接(`vscode-ide-bridge/`)的现状
|
||||
|
||||
CLAUDE.md 提到 `vscode-ide-bridge/` 是"VS Code 桥接"辅助目录。**但这个目录在当前仓库里实际不存在**(`ls` 返回空)。VS Code 集成实际走的是 `/ide` 命令 + VS Code 扩展(扩展是独立分发的,不在本仓库里),不是通过这个目录里的代码。`vscode-ide-bridge/` 在仓库的某个历史版本里存在过、后来被移除或合并到 `src/commands/ide/`——`CLAUDE.md` 的描述滞后了。**这是反编译重建工作的典型痕迹**:文档描述的是"原本应该有什么",代码里实际是"重建后剩下了什么"。
|
||||
|
||||
## 两视角如何呼应
|
||||
|
||||
用户视角的每一个"我能接什么"的清单,几乎都能在设计视角找到对应的契约和决策:
|
||||
|
||||
- **"我能在 VS Code / Zed / Cursor 里用 Claude 吗"**(产品视角)对应 **"为什么 IDE 走 MCP 的 `sse-ide` / `ws-ide` 子类型、为什么 ACP agent 用 stdio NDJSON"**(设计视角)——用户看到的是"装个扩展、`/ide` 一连就行",开发者看到的是"`dynamicMcpConfig` 的 `ide` key 用了专门的 type、ACP 协议形态选择 stdio 是为了 IDE spawn 子进程最简单"。
|
||||
- **"我能不能让远端调用我机器上的 Claude"**(产品视角)对应 **"acp-link 为什么是 WebSocket → stdio 桥接、自托管 RCS 为什么是 Docker + Web UI"**(设计视角)——用户看到的是"`claude remote-control` 一跑、Web UI 一开就能用",开发者看到的是"三种长驻模式(ACP / Bridge / Daemon)共享 query loop 但各有独立 entry、用 entry 数量换 entry 简单度"。
|
||||
- **"我在 Codex CLI 登录过、Claude 这边能复用吗"**(产品视角)对应 **"为什么 ChatGPT 订阅凭证要 fallback 读 `~/.codex/auth.json`"**(设计视角)——用户看到的是"不用再登录一次",开发者看到的是"两边用同一 issuer 同一 client_id、令牌可互换、但 schema 没有契约约束只靠碰巧一致"。
|
||||
- **"我能在 GitHub Actions 里用 Claude 吗"**(产品视角)对应 **"`install-github-app` 为什么是 React 多步表单、`/commit-push-pr` 的 `ALLOWED_TOOLS` 白名单为什么这么窄"**(设计视角)——用户看到的是"`claude install-github-app` 一键装、`@claude` 一 at 就触发",开发者看到的是"11 个 Step 组件对应 11 个分支、`WORKFLOW_CONTENT` 常量是 Claude ↔ GitHub 的契约、白名单用'允许什么'定义命令的语义边界"。
|
||||
- **"我的 key 会不会被别的工具读到"**(产品视角)对应 **"跨工具凭证共享为什么只有 ChatGPT 订阅路径、为什么反向不成立"**(设计视角)——用户看到的是"除了 ChatGPT 订阅路径、其他 key 都不共享",开发者看到的是"后入场的一方做兼容、先入场的一方保持简单的非对称设计"。
|
||||
- **"`vscode-ide-bridge/` 是什么"**(产品视角用户翻 CLAUDE.md 看到的)对应 **"反编译重建工作的典型痕迹——文档描述原本应该有什么、代码里实际剩下了什么"**(设计视角)——用户看到的是"文档里提到了一个目录",开发者看到的是"那个目录在当前仓库里实际不存在、VS Code 集成走的是 `/ide` + 独立扩展"。
|
||||
|
||||
这种呼应关系是"与其他工具集成"必须双视角覆盖的核心原因:用户视角告诉你**怎么接**,设计视角告诉你**接的边界在哪、契约长什么样、哪些描述滞后于代码**。两个视角合在一起,才能让使用者正确判断"我现在的接法是不是最优、要不要换一种",也让开发者在加新集成时知道"哪些约束(凭证隔离、协议形态、feature 门控、entry 分离)是必须遵守的"——而不是把每个集成都重新发明一遍。
|
||||
157
docs/outline-output/cross/06-observability.md
Normal file
157
docs/outline-output/cross/06-observability.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 可观测性
|
||||
|
||||
> 同一个"我想知道 Claude 在做什么"的诉求,在使用者眼里是"它现在到底卡在哪一步、这次回答烧了多少 token、能不能把这次对话导出来给同事看",在开发者眼里是"为什么 Langfuse 追踪必须从 `getAPIProvider()` 取单一真相源、为什么 `performanceShim` 必须抢在 React/OTel 之前装上、为什么 `--dump-system-prompt` 要被 feature flag 锁死"。可观测性天然是双视角主题——用户想知道"我能不能看见、怎么看",开发者想知道"探针插在哪、插这个位置要付出什么代价、会不会反过来把会话拖垮"。
|
||||
|
||||
## 产品视角(写给使用者)
|
||||
|
||||
这一节回答一个高频但被低估的问题:**Claude 在帮我跑任务的时候,我自己怎么知道它正在干什么、干得对不对、花了多少?** 答案按"你想看什么"分四类工具,从轻到重排列。
|
||||
|
||||
### 第一类:我想看它现在在做什么(实时观测)
|
||||
|
||||
你在 REPL 里发完一条消息,最直接的观测就是屏幕本身——流式回复、工具调用、权限弹窗、token 状态栏,这些都是"被动观测":你不主动做什么,它们自己会显示。但当会话变长、工具链变深(比如一个 Agent 派了三个子代理、每个子代理又跑了若干次 Bash + FileEdit),光靠屏幕就不够了。这时候有两条主动路径:
|
||||
|
||||
- **`/debug-tool-call [N]`**:列出本会话最后 N 次工具调用(默认 5)的输入与输出。源码在 `src/commands/debug-tool-call/index.ts`,它不依赖任何远程服务,直接读会话日志(JSONL transcript,路径由 `getTranscriptPath()` 在 `index.ts:33` 决定,位于 `~/.claude/projects/<sanitize(cwd)>/<sessionId>.jsonl`)。用法场景很具体——"刚才那次 FileEdit 把哪一行改错了"、"Agent 派的子代理到底跑了什么命令",不用翻整个 transcript 文件。注意它只显示 tool_use + tool_result 配对,纯文本回复不在这张表里。
|
||||
- **状态栏的 token 数字**:每次 API 调用结束,REPL 状态栏会刷新 input/output/cache token。想看历史累积、单次费用估算,用 `/cost`(本次会话总费用)、`/usage`(按模型拆分的用量)、`/stats`(更细的统计)。这三个命令读的都是同一份 usage 累加器,区别只是聚合粒度。
|
||||
|
||||
### 第二类:我想把每次 API 调用、每个工具调用都记下来(Langfuse 追踪)
|
||||
|
||||
如果你在做长任务、调试 prompt、或者想把 Claude 的行为变成可回放的训练数据,屏幕不够用——你需要结构化的请求链路。这就是 Langfuse 集成的用途。打开 `docs/features/tools/langfuse-monitoring.md`,它是一个开源 LLM 可观测性平台,CCB 通过 OpenTelemetry 桥接进去。**核心只需要三个环境变量**:
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
|---------|------|
|
||||
| `LANGFUSE_PUBLIC_KEY` | Langfuse 公钥(必填) |
|
||||
| `LANGFUSE_SECRET_KEY` | Langfuse 密钥(必填) |
|
||||
| `LANGFUSE_BASE_URL` | 服务地址,默认 `https://cloud.langfuse.com`;自部署时改成你的地址 |
|
||||
|
||||
推荐写进 `.claude/settings.json` 的 `env` 字段,每次启动自动生效。**没配这三个变量时所有追踪函数都是 no-op、零开销**——不用担心开了它拖慢响应。配齐之后,每次 API 请求、每次工具调用都会被打成 span 发到 Langfuse,你在面板里能看到:
|
||||
|
||||
- **LLM 调用**:模型名、Provider、输入/输出消息、token 用量(含 cache_creation / cache_read)、首 token 耗时(TTFT)、总耗时
|
||||
- **工具执行**:工具名、输入、输出、耗时、错误
|
||||
- **多 Agent 链路**:主 Agent 和子 Agent 各有独立 trace,能在面板里看到父子关系
|
||||
- **自动脱敏**:API key、文件内容片段、shell 输出里的敏感字段会被遮蔽(实现见 `src/services/langfuse/sanitize.ts`)
|
||||
|
||||
其他可选参数(`LANGFUSE_TRACING_ENVIRONMENT` / `LANGFUSE_FLUSH_AT` / `LANGFUSE_FLUSH_INTERVAL` / `LANGFUSE_EXPORT_MODE` / `LANGFUSE_TIMEOUT`)见 `docs/features/tools/langfuse-monitoring.md:49-57` 的表格,按需调。
|
||||
|
||||
### 第三类:我想知道系统提示长什么样(`--dump-system-prompt`)
|
||||
|
||||
一个常见疑问:"Claude 每次开头那长长一串系统提示到底是什么?CLAUDE.md 真的被读进去了吗?" `claude --dump-system-prompt` 会渲染并打印当前模型对应的系统提示,然后直接退出——不进入 REPL、不发任何 API 请求。可选 `--model <name>` 指定模型。用法:
|
||||
|
||||
```bash
|
||||
claude --dump-system-prompt
|
||||
claude --dump-system-prompt --model claude-sonnet-4-5
|
||||
```
|
||||
|
||||
**注意**:这条 fast-path 受 `feature('DUMP_SYSTEM_PROMPT')` 门控(`src/entrypoints/cli.tsx:93`),主要用于 prompt sensitivity eval 在特定 commit 上提取系统提示。**外部构建产物里这条路径会被编译期剔除**,dev 模式默认开启。如果你跑 `claude --dump-system-prompt` 没有任何输出,多半是当前构建禁用了这个 feature。
|
||||
|
||||
### 第四类:我想用调试器接进去(`BUN_INSPECT` + `dev:inspect`)
|
||||
|
||||
当 Claude 行为异常、你想看运行时变量值或断点单步,用 Bun 内置的 V8 inspector。两条路径:
|
||||
|
||||
- **开发模式**:`bun run dev:inspect`(实际跑 `scripts/dev-debug.ts`)。它读 `BUN_INSPECT` 环境变量作为端口,默认会 await inspector 连上再继续执行,适合断在启动早期。
|
||||
- **指定端口**:`BUN_INSPECT=9229 bun run dev:inspect`。然后用 Chrome `chrome://inspect` 或 VS Code 的 Bun 调试器连 `ws://localhost:9229`。
|
||||
|
||||
注意这是开发自检工具,不是给最终用户的——它要求你能在仓库里 `bun install` 后跑 dev 模式。普通使用者想看"它在做什么",用前两类的命令就够了。
|
||||
|
||||
### 一句话总结这四类
|
||||
|
||||
| 我想看 | 用什么 | 代价 |
|
||||
|--------|--------|------|
|
||||
| 当前会话的工具调用 | `/debug-tool-call` | 零(读本地 transcript) |
|
||||
| 历次 API 调用 + token 用量 | `/cost` `/usage` `/stats` | 零(读本地累加器) |
|
||||
| 完整请求链路(可回放) | Langfuse(`LANGFUSE_*` 环境变量) | 配齐才启用,未配零开销 |
|
||||
| 系统提示长什么样 | `claude --dump-system-prompt` | feature-gated,外部构建可能被剔除 |
|
||||
| 运行时变量 / 断点 | `BUN_INSPECT=9229 bun run dev:inspect` | 需要开发环境 |
|
||||
|
||||
## 设计视角(写给开发者)
|
||||
|
||||
设计大纲原本几乎没有"观测的注入点"这一节——只有第七章锚点提到 `claude.ts:2999`。这一节补上:探针插在哪、为什么插在那里、插这个位置要付出什么代价。读完之后你应该能回答:"如果我要加一个新的观测维度(比如工具执行的 p99 latency),应该挂在哪一行、为什么不能挂在那行之前"。
|
||||
|
||||
### 为什么 Langfuse 追踪的 `provider` 字段必须从 `getAPIProvider()` 取单一真相源
|
||||
|
||||
打开 `src/services/api/claude.ts:2997-2999`:
|
||||
|
||||
```ts
|
||||
// Record LLM observation in Langfuse (no-op if not configured)
|
||||
recordLLMObservation(options.langfuseTrace ?? null, {
|
||||
model: resolvedModel,
|
||||
provider: getAPIProvider(),
|
||||
```
|
||||
|
||||
`provider` 字段的值直接来自 `getAPIProvider()`——整个项目里唯一一个"当前用哪个 Provider"的真相源。`getAPIProvider()`(`src/utils/model/providers.ts:15`)按 `modelType` 参数 > `CLAUDE_CODE_USE_*` 环境变量 > firstParty 默认 这条优先级链返回字符串。
|
||||
|
||||
**为什么不另起一个变量、不读 `process.env.CLAUDE_CODE_USE_OPENAI` 这种直接环境变量?** 因为 Provider 选择有运行时动态性。`/provider openai` 命令会清掉所有 `CLAUDE_CODE_USE_*` 然后写新的配置(`src/commands/provider.ts:39`),这一步走 `applyConfigEnvironmentVariables` 把配置反推回 `process.env`。如果在 Langfuse 这边直接读 `process.env.CLAUDE_CODE_USE_OPENAI`,就有两个风险:一是和 `/provider` 命令的写入时机产生 race,二是兼容层(OpenAI / Gemini / Grok)各自有不同的 env var 名,硬编码会漏。
|
||||
|
||||
**`getAPIProvider()` 作为单一真相源的设计红利**:`/provider` 命令、模型映射(`resolveOpenAIModel` / `resolveGeminiModel` / `resolveGrokModel`)、Langfuse 追踪——三个看似不相关的子系统都从同一个函数取值。只要 `getAPIProvider()` 正确,这三个地方的 Provider 字段必然一致。这是"单一真相源"原则的教科书例子:观测数据天然就应该和决策数据同源,否则面板上看到的 Provider 和实际跑的不一致,追踪就失去了意义。
|
||||
|
||||
**代价**:`getAPIProvider()` 不是纯函数,它每次调用都要走一遍优先级链解析。在 `claude.ts:2997` 这个位置(每次 API 响应结束后调用一次)是可接受的——一次 turn 调一次,不在热路径里。但如果你想把 provider 字段加到更高频的观测点(比如每个流式 chunk),就不能再调 `getAPIProvider()` 了,得缓存结果。
|
||||
|
||||
### 为什么 `recordLLMObservation` 是 fire-and-forget,不是 await
|
||||
|
||||
看 `claude.ts:2997` 的调用——它没有 `await`。`recordLLMObservation` 在 `src/services/langfuse/tracing.ts:85` 是 async function,但调用方不等它。
|
||||
|
||||
**为什么?** 观测不该阻塞主路径。Langfuse 走 OTel exporter,批量异步发到远端(`LANGFUSE_FLUSH_AT=20` 默认 20 条 span 攒一批)。如果 `await recordLLMObservation(...)`,每次 API 响应都要等网络 round-trip,用户看到的 TTFT 会暴涨。fire-and-forget 让观测在后台跑,主路径零延迟。
|
||||
|
||||
**代价**:观测失败用户感知不到。`tracing.ts:178` 里有一行 `logForDebugging('[langfuse] recordLLMObservation failed: ...')`——失败只打 debug 日志,不抛、不告警。这是有意的:观测是辅助、不是必需。如果 Langfuse 挂了,Claude 本身必须照常工作。`isLangfuseEnabled()`(`src/services/langfuse/client.ts:13`)只检查 `LANGFUSE_PUBLIC_KEY` 和 `LANGFUSE_SECRET_KEY` 是否存在——未配置时整条链路是 no-op,连 fire-and-forget 的开销都没有。
|
||||
|
||||
### 为什么 `performanceShim` 必须最先 import,OTel 才能正常工作又不会撑爆内存
|
||||
|
||||
打开 `src/utils/performanceShim.ts:1-17` 的文件头注释——这是整个项目最强烈的"必须最先 import"约束(在 `src/entrypoints/cli.tsx` 的第一行 import)。背景:Bun 的 `globalThis.performance` 是 JSC 原生 Performance 对象,它的 marks / measures / resource timings 存在一个**永不收缩的 C++ Vector**。长会话(daemon / `/loop`)持续累积,能撑出几百 MB 死容量。
|
||||
|
||||
**这跟可观测性有什么关系?** 因为 Langfuse 走 OTel,OTel 的 performance exporter(`otperformance`)会大量调用 `performance.mark()` 和 `performance.measure()` 来打 span 计时。**如果没有 shim**,每个 OTel span 都会在 C++ Vector 里留一条永不释放的 entry——观测越勤,内存爆得越快。这是"观测反向拖垮被观测对象"的经典反例。
|
||||
|
||||
`performanceShim` 的解决方案(`performanceShim.ts:127-155`):保留 `performance.now()` 走原生(快、零内存成本——OTel 用它打时间戳),劫持 `mark` / `measure` / `getEntries` / `clearMarks` 走 JS Map(GC 能回收)。**必须在 React reconciler 和 OTel import 之前装上**,否则它们会捕获原生 Performance 的引用,shim 装了也劫持不到。
|
||||
|
||||
**这条约束的代价**:`performanceShim` 永远是 `cli.tsx` 的第一行。如果你写了一个新模块、它在 import 阶段就碰 performance(比如模块顶层 `performance.mark('foo')`),你必须保证它 import 在 shim 之后。这就是为什么 `cli.tsx` 的 import 顺序不能随便调。
|
||||
|
||||
### 为什么 query.ts 的 finally 块要兜底 clearMarks
|
||||
|
||||
打开 `src/query.ts:367-379`:
|
||||
|
||||
```ts
|
||||
// Clear JSC's native Performance buffers. OTel (otperformance) references
|
||||
// globalThis.performance which stores marks/measures/resource timings in a
|
||||
// C++ Vector that never shrinks. Long-running sessions accumulate hundreds
|
||||
// of MB of dead capacity even after spans are flushed and nullified.
|
||||
const gPerf = globalThis.performance
|
||||
if (gPerf && typeof gPerf.clearMarks === 'function') {
|
||||
try {
|
||||
gPerf.clearMarks()
|
||||
gPerf.clearMeasures?.()
|
||||
gPerf.clearResourceTimings?.()
|
||||
} catch { ... }
|
||||
```
|
||||
|
||||
这是 performanceShim 的第二道防线。**为什么有了 shim 还要在这里兜底?** 因为 sub-agent 会直接 `import query from 'src/query.ts'`,不走 `cli.tsx` 的入口。如果某个 sub-agent 启动路径上 shim 没装上(比如测试环境、或某种奇怪的 import 顺序),原生的 C++ Vector 就会开始累积。`query()` 是所有 turn 的共同出口,在它的 finally 块兜底一次 `clearMarks`,是"shim 万一没装上"的最后保险。
|
||||
|
||||
**注释里有意思的一句话**:"even after spans are flushed and nullified"——OTel 自己 flush span 之后会把自己持有的引用置空,但**原生 Performance 的 Vector 不会被 OTel 清**。OTel 和 Performance 是两个独立的累积源,OTel 的清理不覆盖 Performance。这是 JSC 实现的细节,也是 shim 必须劫持 mark/measure 而不是依赖 OTel 自己清理的根因。
|
||||
|
||||
### 为什么 `--dump-system-prompt` 必须 feature-gated
|
||||
|
||||
看 `cli.tsx:90-104` 的 fast-path:`feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt'`。注释说得很清楚:"Used by prompt sensitivity evals to extract the system prompt at a specific commit. Ant-only: eliminated from external builds via feature flag."
|
||||
|
||||
**为什么这么谨慎?** 系统提示是产品的核心 IP——它定义了 Claude 的行为、约束、工具使用风格。`--dump-system-prompt` 把它原样 stdout 出来,等于把 IP 暴露给任何能跑这个命令的人。feature flag 让这条路径在内部 eval 场景(CI 跑 prompt 回归)可用、在外部构建产物里编译期剔除——DCE 直接把整段 if 删掉,连字符串"`--dump-system-prompt`"都不出现在外部产物里。
|
||||
|
||||
**这条路径本身的设计也很克制**:它不发任何 API 请求,只渲染系统提示然后 exit(`cli.tsx:102-103`)。`getSystemPrompt([], model)` 传空 messages 数组——因为系统提示不依赖对话内容,只依赖模型(不同模型的 prompt 略有差异)。如果你想 debug "我的 CLAUDE.md 到底有没有被读进去",`--dump-system-prompt` 是最直接的工具,但前提是你跑的构建启用了这个 feature。
|
||||
|
||||
### 为什么 `/debug-tool-call` 不走远程服务、只读本地 transcript
|
||||
|
||||
打开 `src/commands/debug-tool-call/index.ts`——整个命令没有任何网络调用。`getTranscriptPath()`(`index.ts:33-43`)返回本会话的 JSONL 路径,`parseToolCallsFromLog()`(`index.ts:85-119`)逐行 parse JSON、按 `tool_use_id` 配对 use 和 result。
|
||||
|
||||
**为什么不走 Langfuse?** 两个原因:
|
||||
|
||||
1. **零依赖原则**:`/debug-tool-call` 是诊断工具,诊断工具不能依赖被诊断的东西。如果 Langfuse 挂了、网络断了、配置错了,用户跑 `/debug-tool-call` 还得能看到工具调用——这是排错最后一道防线,必须本地可用。
|
||||
2. **新鲜度**:transcript 是本会话刚写下去的,Langfuse 是批量异步发的(`LANGFUSE_FLUSH_AT=20`),有延迟。"`/debug-tool-call` 显示的就是刚才那一次"和"显示的是 20 个 span 之前那一次",对排错体验差别巨大。
|
||||
|
||||
**代价**:transcript 文件格式是会话私有的 JSONL schema,没有跨工具兼容承诺。如果未来 transcript 格式改了,`parseToolCallsFromLog` 的字段访问(`block.type === 'tool_use'` / `block.tool_use_id` 等)要同步改。这是"零依赖"换"零网络"的固有成本。
|
||||
|
||||
## 两视角如何呼应
|
||||
|
||||
用户视角的每一个"我想看什么",在设计视角都能找到对应的注入点决策:
|
||||
|
||||
- **"我想看这次 API 调用烧了多少 token、用的哪个 Provider"**(产品视角的 `/cost` `/usage` + Langfuse 面板)对应 **"`provider` 字段为什么必须从 `getAPIProvider()` 取、`recordLLMObservation` 为什么是 fire-and-forget"**(设计视角)——用户看到的是面板里一行清晰的 `provider: openai`,开发者看到的是"单一真相源 + 异步不阻塞主路径"的双重决策,否则要么面板字段和实际跑的不一致,要么 TTFT 被观测拖慢。
|
||||
- **"我想看 Claude 的完整请求链路,可回放"**(产品视角的 Langfuse)对应 **"performanceShim 为什么必须最先 import、query.ts 的 finally 块为什么兜底 clearMarks"**(设计视角)——用户看到的是"开了 Langfuse 长跑也不卡",开发者看到的是"OTel 越勤、JSC 原生 Performance 的 C++ Vector 撑得越快,shim + finally 双保险把累积源掐死在 GC 能回收的 JS 内存里"。如果这个决策做错了,观测本身会把会话拖崩——这是可观测性章节必须双视角覆盖的最强理由。
|
||||
- **"我想知道系统提示到底长什么样"**(产品视角的 `--dump-system-prompt`)对应 **"为什么这条 fast-path 必须 feature-gated、为什么外部构建编译期剔除"**(设计视角)——用户看到的是"`claude --dump-system-prompt` 一跑就有",开发者看到的是"系统提示是核心 IP、DCE 在编译期把整段 if 删掉、外部产物连这个字符串都不出现"。
|
||||
- **"我想看刚才那次工具调用的输入输出"**(产品视角的 `/debug-tool-call`)对应 **"为什么它只读本地 transcript、不走 Langfuse"**(设计视角)——用户看到的是"零延迟、零配置就能用",开发者看到的是"诊断工具不能依赖被诊断的东西 + 新鲜度优先于跨工具兼容性"的双重原则。
|
||||
- **"我想断点单步看运行时变量"**(产品视角的 `BUN_INSPECT=9229 bun run dev:inspect`)对应 **"`bun run dev:inspect` 走 `scripts/dev-debug.ts`、读 `BUN_INSPECT` 环境变量决定端口"**(设计视角)——用户看到的是"端口一连、断点就生效",开发者看到的是"开发自检工具要求仓库可 `bun install`、普通使用者用前几类命令就够了"。
|
||||
|
||||
这种呼应关系是"可观测性"必须双视角覆盖的核心原因:用户视角告诉你**怎么看**,设计视角告诉你**探针插在哪里、这个位置会不会反过来把会话拖垮、哪些观测路径受 feature 门控**。两个视角合在一起,才能让使用者正确选择观测工具的层级(被动看屏幕 → `/debug-tool-call` → Langfuse → `--dump-system-prompt` → `dev:inspect`,按介入深度递增),也让开发者在加新观测维度时知道"挂在 `getAPIProvider()` 同源、走 fire-and-forget、注意 performanceShim 已经装好"——而不是把每个探针都重新设计一遍、甚至不小心把观测路径变成新的内存泄漏源。
|
||||
260
docs/outline-output/cross/07-credentials-auth.md
Normal file
260
docs/outline-output/cross/07-credentials-auth.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# 凭证与认证生命周期
|
||||
|
||||
> 同一份"我的令牌存在哪、什么时候过期、改了 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 支持四种登录路径,选择哪一种取决于你有什么:
|
||||
|
||||
1. **claude.ai 订阅账号(Anthropic OAuth)**:在 `/login` 的 ConsoleOAuthFlow 里走 OAuth 设备码流程——它会给你一个 URL 和一个 code,浏览器打开、授权、回来。成功后令牌写进 `~/.claude/.credentials.json`。这是推荐路径,因为它走 Anthropic 官方 OAuth,token 自动刷新、不需要你管过期。
|
||||
|
||||
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 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` 会做这一串:
|
||||
|
||||
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 订阅。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` 字段、明文存储。
|
||||
|
||||
**为什么不对称?** 两个原因:
|
||||
|
||||
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 偏差窗口改了会出问题"——而不是把每个决策都重新走一遍、甚至不小心破坏跨工具凭证复用或多进程刷新安全。
|
||||
Reference in New Issue
Block a user