bug: Claude Code 凭证读取失败被误判为不存在,登录时可能整块覆盖 mcpOAuth
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 401
- Avg merge
- 21h 48m
- Merged PRs (30d)
- 776
Description
### 问题描述 / What happened
macOS 上 Cindy 通过 `/usr/bin/security` 读写系统 Claude Code 的 `Claude Code-credentials` Keychain 条目。该条目是共享 JSON blob,除 `claudeAiOauth` 外还可能包含 Claude Code 写入的 `mcpOAuth` 等字段;代码注释与 API 设计都要求读改写时保留这些未知字段。
当前读取实现把“条目不存在、读取被拒、钥匙串锁定、子进程瞬时失败、JSON 损坏”等不同状态全部折叠为 `null`。Claude 浏览器登录完成后,写入路径使用 `readBlob() ?? {}`,因此在旧条目实际存在但当前读取失败时,可能从空对象开始整块覆盖同一个 Keychain 条目,丢失 `mcpOAuth` 等字段。
这是条件性数据丢失风险;目前没有证据证明某个用户的 `mcpOAuth` 已经被覆盖,也未证明 macOS 上“ACL 拒绝读取但允许 `-U` 改写”一定可同时发生。更直接的可达条件包括历史截断造成的非法 JSON,以及 `execFileSync` 的瞬时非 not-found 错误。
### 实际行为
`readBlobRawMac()` 捕获 `security find-generic-password` 的所有异常并返回 `null`;`readBlob()` 对 `JSON.parse` 失败也返回 `null`。随后:
```ts
export function writeClaudeAiOAuth(oauth: ClaudeAiOAuth): void {
const blob = readBlob() ?? {};
blob.claudeAiOauth = oauth;
writeBlob(blob);
}
```
如果读取失败,待写值只包含新的 `claudeAiOauth`。`writeBlob()` 先执行破坏性写入,再验证“新写值是否等于期望的新值”;它不能保护读取失败前的旧字段,也没有旧值备份或回滚。
refresh/backfill 调用方通常会在第一次读不到凭证时提前返回,但浏览器登录路径会直接写入;而 `writeClaudeAiOAuth()` 内部还会再次读取,因此调用方之前的一次成功读取也不能排除 TOCTOU。
### 期望行为
1. 读取结果区分三态:`value`、`absent`、`unreadable`。
2. 只有明确确认条目不存在时,`writeClaudeAiOAuth()` 才能从 `{}` 开始。
3. 权限拒绝、钥匙串锁定、执行失败或 JSON 损坏必须 fail closed:停止写入并把可诊断错误交给上层。
4. 写入或登录失败不能以牺牲旧 blob 为代价;至少要有可恢复备份或不会破坏旧条目的更新方案。
5. 错误信息不能继续把“不可读”描述成“无凭证”。
### 现有实现参考
仓库内 `nativeProviderAuthBinding.ts` 已实现类似的 `ok / unreadable / absent` 三态,并明确记录“不可读不能当空,否则随后写入会永久覆盖恢复依据”;本凭证库可以沿用同一模式。
### 环境 / Environment
- Cindy 版本或 commit / version or commit: v0.1.28;当前 main 仍有同一实现
- 平台与版本 / platform & OS version: macOS
- 安装方式 / install method: packaged app;同时进行了源码静态审查
### 复现步骤 / Steps to reproduce
1. 准备一个现有 `Claude Code-credentials` blob:
```json
{
"claudeAiOauth": { "accessToken": "old" },
"mcpOAuth": { "example": { "accessToken": "must-preserve" } }
}
```
2. 模拟 `security find-generic-password` 返回非“条目不存在”的读取错误,或让旧值成为包含 `mcpOAuth` 但无法解析的截断 JSON。
3. 完成 Cindy 的 Claude 浏览器登录。登录路径会无条件调用 `writeClaudeAiOAuth(newOauth)`。
4. 检查写入调用和最终 blob:当前实现会从 `{}` 起步,仅写回新的 `claudeAiOauth`。
### 必须补充的测试
- 非 not-found 读取错误 + 后续写操作可成功:断言不发生任何写入。
- 旧值是含 `mcpOAuth` 的非法/截断 JSON:断言不覆盖,或先生成可恢复备份。
- 明确的 item-not-found:允许正常创建只含 `claudeAiOauth` 的新 blob,防止修复过度。
- 成功读取包含未知字段的旧 blob:更新 `claudeAiOauth` 后未知字段逐字保留。
建议先为 `execFileSync` 引入可注入 seam;当前 `claudeCredentialsBlob.test.ts` 只覆盖纯函数,没有覆盖 `claude-credentials-store.ts` 的真实 IO 错误分类。
### 日志与截图 / Logs & screenshots
源码位置:`apps/desktop/src/main/maker-host/claude-credentials-store.ts`
关键路径:
- `readBlobRawMac()`:所有 `security` 异常均进入同一个 `catch { return null }`。
- `readBlob()`:JSON 解析失败也返回 `null`。
- `writeClaudeAiOAuth()`:`const blob = readBlob() ?? {}` 后整块写回。
- `writeBlob()`:写后回读只比较新值,无法证明旧未知字段被保留。
影响范围:macOS 的 `Claude Code-credentials`,可能使 Claude Code MCP 登录状态丢失。
证据边界:
- 当前确认的是代码层面的条件性完整性/可靠性缺陷。
- 没有证据证明本机或某个具体用户已经丢失 `mcpOAuth`。
- 不涉及凭证泄露或权限提升。
- 与另一个 `Cindy Safe Storage` / 远程设备列表 401 问题是不同 Keychain 条目和不同调用链。
Contributor guide
Research direction
Start in apps/desktop/src/main/maker-host/claude-credentials-store.ts, tracing readBlobRawMac(), readBlob(), writeClaudeAiOAuth(), and writeBlob(). Compare its error handling with nativeProviderAuthBinding.ts, then run or extend claudeCredentialsBlob.test.ts with injectable security-command failures and malformed JSON. Done means absent, unreadable, and valid values are distinct, unreadable values cause no write, and successful updates preserve mcpOAuth and other unknown fields.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- authentication, desktop, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 64/100