CC Switch 的崩溃日志怎么才能不写进你的 API Key

2026-08-31

前端崩了要写日志,这件事本身没什么可讲的。麻烦在于:CC Switch 这类工具管的就是各家 AI CLI 的供应商配置,API Key、base URL、请求头这些东西天天在内存里流转。一个 unhandledrejection 冒上来,reject 的原因可能是一个带完整请求 URL 的 Error,也可能是一个把整份配置 JSON.stringify 之后塞进 message 的字符串。日志一落盘,密钥就跟着落盘了——而且落的是用户自己的机器,谁也不知道他哪天会把这份日志贴到 issue 里。

src/lib/frontendLogger.ts 就是为这件事写的。它是 v3.20.1 前端基础设施层里单文件最复杂的一个(345 行),而且是全仓少见的、把设计理由整段写在注释里的模块。这篇就顺着它读一遍,再看测试是怎么把这套行为锁住的。

先说清楚这篇的依据

本文对应的是 cc-switch 仓库快照 3217f725(仓库内版本号 3.20.1),核对日 2026-08-31。所有行号、常量名、正则语义都是静态读源码读出来的:我们没有编译、没有运行,也从来没有安装过这个桌面应用。所以下面不会有任何一句「日志文件长这样」「实际跑起来会怎样」——那些我们没有依据。

三个入口,一个出口

先看谁会触发上报。

第一个是模块顶层的全局兜底:src/main.tsx:28 在 React 渲染之前就调了 installGlobalErrorHandlers(),实现在 frontendLogger.ts:325-345,给 window 挂 errorunhandledrejection 两个监听,返回值是一个卸载函数。放在渲染之前是有讲究的——比 React 还早的崩溃也能进日志。error 事件那一支还会把 filename:lineno:colno 拼成 details 一并带上(:329-332)。

第二个是 React 侧的错误边界。src/components/FrontendErrorBoundary.tsx 是个 class 组件(错误边界只能用 class),:21-27componentDidCatch 调的是 reportFrontendError("react.error_boundary", error, info.componentStack)

第三个是业务代码里的直接调用,用不同的 context 字符串区分来源。

三条路径最后都汇到同一个函数 reportFrontendError:296-323)。这个收口很关键:脱敏这种事只要留一个旁路,就等于没做。

顺序是「先截断、再脱敏、再截断」

reportFrontendError 的主体顺序值得单拎出来说,因为它反直觉。

第一步先对每一段原始输入做截断,:301 的注释给了理由:异常可能携带多 MiB 的文本,如果直接把整坨东西丢给一串全局正则去跑,会阻塞 UI。也就是说,截断在这里不是为了控制日志体积,是为了控制正则的输入规模。第二步才是跑脱敏。第三步是对脱敏后的结果再截一次,超长的尾部换成 [truncated] 标记。

配套的上限常量在 :3-8,一共六个:单条日志 MAX_LOG_MESSAGE_LENGTH = 12_000、单段原始输入 MAX_RAW_LOG_INPUT_LENGTH = 16_000、序列化时单字符串 MAX_SERIALIZED_STRING_LENGTH = 2_000、单层最多 MAX_SERIALIZED_ENTRIES = 32 个条目、总值预算 MAX_SERIALIZED_TOTAL_VALUES = 64、最大深度 MAX_SERIALIZATION_DEPTH = 4。这些是 v3.20.1 源码里写死的取值,会随版本变,别把它当成稳定契约来依赖。

最后一步是 :322@tauri-apps/plugin-logerror(...),带 { file: "frontend" } 标记,把前端日志和其它来源区分开。这行后面挂了 .catch(() => undefined):320-321 的注释解释得很直白:Web 开发和测试环境里没有 Tauri 的 invoke,上报失败如果再去 console.error 或者抛一个未处理的 Promise,就会被全局兜底监听接住、再触发一次上报——直接成环。兜底逻辑必须自己不产生错误,这是很容易漏掉的一步。

为什么必须是两层

真正的脱敏分两层,缺一层都不行。

文本层redactFrontendLogText:36-46),一条固定顺序的 .replace 链,在 v3.20.1 里是八步:URL query 的值、URL 里内嵌的 user:pass@ 凭据、敏感 header 行(覆盖 authorization / cookie / set-cookie / x-api-key / api-key 这几类键名)、认证方案值(Bearer、Basic、Token、Digest 这一类前缀后面跟的东西)、文本里裸露的密钥形态(若干常见凭据前缀写法,以及 JWT 那种三段式结构)、敏感键名后面紧跟的数组或对象整体、引号包裹的命名密钥、裸的命名密钥。顺序不是随手排的——先把结构性更强的形态换掉,再让宽松的兜底正则收尾,反过来会互相吃掉匹配。这条链的步数会随版本增删,重点是它的形状而不是数量。

结构层是序列化时的属性名判定。:70-93 是一张敏感键名集合,isSensitiveKey:95-101)在查表前做两步归一:先去掉所有非字母数字字符,再去掉结尾的 s。这一手很省事——集合里只存单数形式,tokensapiKeyscredentials 这些复数写法自动就被覆盖了,不用逐个枚举。命中之后的处理在 :179-183,注释只有一句:敏感属性名 → 整个值(含数组/对象)一律隐藏,不递归、不猜形状,直接换成 [REDACTED]

这两层的分工,源码 :15-20 那段注释说得很清楚:文本层的正则只能匹配 "name":"value" 这种标量形式,抓不到数组和嵌套。也就是说 "tokens":["..."] 这种写法,光靠文本正则是够不着里面元素的。后来文本层里补了一条专门匹配「敏感键名后跟容器」的正则作为兜底,但那是兜底,主力仍然是结构层。

结构层还叠了一条值级启发式 looksLikeSecretValue:48-65):命中密钥形态、命中 PEM 私钥头,或者「长度 ≥32、只含 base64 字符集、且同时含字母和数字」的不透明串,都按敏感处理。注释特意说明了为什么这条只放在结构化序列化器里而不放到文本层(:57-58)——在这里误报的代价只是丢一点诊断信息,不会改动任何应用数据。这是个很清醒的取舍表述:它承认了这条规则会误伤,然后论证误伤在这个位置是可接受的。

两个容易漏的边角

一个是字符串形态的 JSONthrow new Error(JSON.stringify(payload)) 是极常见的写法,这时候异常的 message 是一个字符串,不 parse 就只剩文本正则,数组和嵌套字段全漏。redactStructuredString:220-241)专门处理这种:以 {[ 开头才尝试 parse,parse 成功就走属性级脱敏,parse 失败或者解析出来是标量就交回文本层。

有意思的是超限的处理。输入超过 MAX_RAW_LOG_INPUT_LENGTH 时,它不是截断,而是把整段丢弃成 [oversized structured error omitted]:225-229)。注释给的理由是:合法的超大 JSON 一旦被截断必然变成非法 JSON,parse 会失败,于是退回到够不着数组字段的文本正则,反而泄漏。宁可丢掉整条诊断信息,也不留一个「看起来处理过了、实际有洞」的路径。

另一个是跨引擎的调用栈renderRedactedError:243-260)不去识别 at@ 这些引擎特有的栈帧格式,只按一个判据分流:message 是否出现在 stack 字符串里。出现了(V8 那类把 message 内嵌在栈首行的),就把 stack 里所有出现的原始 message 全局替换成脱敏后的版本;没出现的(WebKit、SpiderMonkey 那类纯栈帧的),就在栈前面补一个脱敏过的头。注释里写明了为什么走这条路——枚举引擎格式枚举不完,正是它把 WebKit 的栈整段丢掉过。这个取舍的结果是:各平台都能保留原生调用栈,同时保证不残留未脱敏的 message。

测试锁的是「该留的留下了」

对应的测试是 tests/lib/frontendLogger.test.ts,v3.20.1 里是 327 行,和被测模块一个量级(这个数字会随版本变)。它用 vi.hoistedvi.mock("@tauri-apps/plugin-log") 截获真正的写日志调用,然后对拿到的字符串做断言。

覆盖的输入形态相当细:URL query 参数、带空格的 api_key 值、三种认证方案写法、Cookie: 行、https://user:password@host 这种 URL 内嵌凭据、以及用 \n 转义后藏在 JSON 字符串里的密钥(:50-53)。截断那条用例(:56-70)塞了两百万个字符进去,断言输出里含 [truncated]、总长不超过 12_020、并且写日志时带着 { file: "frontend" }。文件里那些形似真实密钥的串都是构造出来的形状样本,不是任何真实凭据。

但这个文件里最值得学的是 :104-113 那一组:它断言 "code":500 原样保留、"session":{"activeTab":"providers","scrollPos":120} 原样保留。也就是说,同一条用例里,一半断言在证明「密钥没漏出去」,另一半断言在证明「良性的诊断字段没被误伤」。

这是脱敏测试真正难的地方。证明「该删的删了」很容易,无脑把所有值都换成 [REDACTED] 就能全绿;难的是同时证明「该留的留下了」——一个把上下文抹干净的日志,等于没有日志。这两向断言并排放在一个用例里,读起来就是一份可执行的规格说明。

一处口径不一致,说完就停

tests/lib/frontendLogger.test.ts:73 那条用例上方的注释,开头写的是「两层脱敏契约」,紧接着 :74-76 却分三条列出了「属性名一级」「值一级」「文本一级」。也就是同一段注释里,标题按两层说,条目按三层列。

源码侧 src/lib/frontendLogger.ts:15-20 的注释是按「文本层 / 结构层」两分讲的,值级启发式 looksLikeSecretValue:48-65 单独定义、只在结构化序列化器里调用。两处口径不一致,以我们实读的仓库状态为准:机制上是文本与结构两层,值级形状判定挂在结构层内部。至于这里到底该数两层还是三层,我们不做推断。

这套东西的边界

得说清楚它不保证什么。

它按「键名在集合里」和「值长得像密钥」两条规则识别敏感数据。反过来说,一个既不叫这些名字、形状也不像不透明串的自定义凭据字段,源码语义上就不会被这两条规则命中。集合与形状规则会随版本增补,但它天然是个黑名单式防护,不是白名单式的。

另外,这个模块管的只是前端 renderer 这一条上报路径。这个应用会读写 ~/.claude~/.codex 这类真实 CLI 配置,也会在本机保存 API Key,那些数据的落盘路径不在这个文件的职责范围内,本文也没有核实过。所以别把「前端日志有脱敏」推广成「这个应用的敏感数据都被处理过了」——这两件事没有关系。

真要自己核对,路径都在上面:src/lib/frontendLogger.ts:3-8 的常量表开始读,接着 :36-46 的正则链、:70-93 的键名集合与 :95-101 的归一化、:220-241 的字符串 JSON 分支、:296-323 的主流程,最后对着 tests/lib/frontendLogger.test.ts 看每条规则是被哪个用例锁住的。这份代码的注释密度足够高,读起来不费劲。


本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、 路由指南与发布说明,以及 src/src-tauri/tests/ 的源码整理, 核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。 本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用, 因此不涉及界面外观、操作手感与切换速度的任何描述。 文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。 该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。

留言讨论

评论发布后会被人工复核,违规内容将被删除。

    还没有人评论,来说说你的看法

    如果发表没有反应,可以前往联系我们告诉我们。

    在 CC Switch 里加一个国内直连的供应商

    力达云网关,注册送 ¥5 额度,一期提供 DeepSeek。

    去添加

    这个页面有问题?

    提交时会附带当前页面地址和浏览器信息,帮助我们定位问题。不填联系方式即为匿名。