DeepSeek Harness 报 SELF_SIGNED_CERT_IN_CHAIN:杀毒软件换签证书链
DSH 调模型突然开始报 SELF_SIGNED_CERT_IN_CHAIN,或界面只显示一行「DeepSeek Messages transport failed」——这两条报错的根因经常是同一个:本机某段链路(卡巴斯基等安全软件的「加密连接扫描」、或 Clash 这类做透明接管的代理)把 TLS 连接中间人换签了,而 Node 的 fetch 默认不读系统信任库**,于是握手失败。** 好消息是它完全可诊断、可修复,而且不必在「保留杀毒扫描」和「用 DSH」之间二选一。本文按「先分诊 → 再定位 → 三种解法」展开:先教你一眼分清报错落在 TLS 层还是传输层,再给出证书级证据与「时好时坏」的 keep-alive 机制,最后给三种解法——推荐单变量的 NODE_USE_SYSTEM_CA、最小改动的 NODE_EXTRA_CA_CERTS,以及留在安全软件侧加排除项。
先分诊:两条报错,落在两条不同的链
动手之前先看报错原文——SELF_SIGNED_CERT_IN_CHAIN 落在 TLS 握手层,transport failed 落在传输层,两者的排查路径完全不同。
报错一:SELF_SIGNED_CERT_IN_CHAIN(TLS 握手层)
最小复现命令(社区在 Windows 与 macOS 上都用它一步定位,#6338、#6420):
node -e "fetch('https://api.deepseek.com').then(r => console.log('HTTP', r.status)).catch(e => { console.error(e, e.cause); process.exit(1) })"
命中时输出:
TypeError: fetch failed
[cause]: Error: self-signed certificate in certificate chain
code: 'SELF_SIGNED_CERT_IN_CHAIN'
关键判断:这个错误码不是 DSH 生成的。 全仓搜不到对 SELF_SIGNED 的任何处理或映射,它是 Node TLS 层的内置错误;DSH 只是把它包进 LlmError("DeepSeek API request to ... failed", 'TRANSPORT', { cause })(packages/llm/llm-deepseek/src/adapter.ts:657-663),原始 cause 链里就是 TLS 报的那句(#6338)。
⚠️ 一个容易把结论带偏的实验陷阱:如果你是在卸载安全软件之后才跑诊断命令、看到链是 TrustAsia → DigiCert 的正常链,那不能反推「安全软件不是中间人」——你只是测错了时刻。rejectUnauthorized: false 并不是「不校验」,它让连接在校验失败时继续建立,但校验照跑、结果仍记在 authorized / authorizationError 里。只有在扫描开启时跑,才能看到 issuer 变成 Kaspersky 且 authError 变成 SELF_SIGNED_CERT_IN_CHAIN(#6338)。
报错二:DeepSeek Messages transport failed(传输层)
这条串是 DSH 自己的分类标签,不是上游原文,所以它能告诉你的信息比看上去多(#6987)。
它只在 packages/llm/llm-deepseek/src/protocols/messages/adapter.ts:79 产生,条件是:请求生成器抛出的不是 LlmError(:76-79)。同一条生成器里,所有协议层/HTTP 层失败都有自己的文案与错误码,会原样抛出而不会落进这个分支:
| 触发条件 | 报错文案 / 错误码 | 位置 |
|---|---|---|
| 空闲看门狗触发 | DeepSeek Messages stream idle timeout / TIMEOUT | adapter.ts:76 |
| 用户中断 | DeepSeek Messages request aborted / ABORTED | adapter.ts:77 |
| HTTP 非 2xx | AUTH / QUOTA / RATE_LIMIT / CONTEXT_WINDOW_EXCEEDED / INVALID_REQUEST / SERVER / HTTP_<status> | .../messages/transport.ts:29-35 |
SSE 帧坏 JSON、或事件类型与 type 不符 | MALFORMED_RESPONSE | .../messages/sse.ts:19、:23 |
流结束仍未见到 message_stop | STREAM_CLOSED | .../messages/translate.ts:165 |
| 响应没有 body | EMPTY_RESPONSE | adapter.ts:147 |
所以这条报错的含义是:请求本身或响应体流在 SSE/协议层之下断了(连接被重置、链路/代理中断、TLS 或 HTTP2 层失败等)。它不是「网关少发终态事件」,不是限流,也不是鉴权。
为什么你看不到真正的原因
已重试模型请求(4/4) 说明它被判为可重试并已重试到上限——TRANSPORT 属于默认可重试码(packages/llm/llm/src/retry-policy.ts:18-24),默认 5 次重试、退避 500ms 起倍增封顶 10s、±10% 抖动(retry-policy.ts:14-17;算式 packages/llm/llm-retry/src/index.ts:59-64,上限判定 :223)。你那条路由的上限看起来是 4,退避也可能被 llm-deepseek: { retryPolicy: … } 覆盖(键在 packages/llm/llm-deepseek/src/config.ts:100,mode: always 可无限重试)(#6987)。
关键一点:被包装的底层错误不会进入会话日志。 turn/end 只保留结构化的 {message, code}(packages/core/agent-loop/src/agent.ts:329-336:LlmError 保留自身 facts,其他才折成 cause 链文本),所以界面上你永远只看得到这一层标签——真正的 cause 链只在启动终端的输出里(#6987)。
分诊后,用只读命令确认证书链(不改任何设置)
node -e "const t=require('tls');const s=t.connect({host:'api.deepseek.com',port:443,servername:'api.deepseek.com',rejectUnauthorized:false},()=>{const p=s.getPeerCertificate(true);console.log('authorized:',s.authorized,'| authError:',s.authorizationError||'(none)');console.log('leaf:',p.subject.CN,'<- issuer:',p.issuer.CN||p.issuer.O);let x=p,g=0;while(x&&g++<8){if(!x.issuerCertificate||x.issuerCertificate===x){console.log('root:',x.subject.CN||x.subject.O);console.log('rootValidFrom:',x.valid_from);console.log('rootSHA256:',x.fingerprint256);break}x=x.issuerCertificate}s.end()})"
怎么读(#6338):
leaf: … <- issuer: Kaspersky…⇒ 安全软件正在拦截(这一步本身是正常的);authError: SELF_SIGNED_CERT_IN_CHAIN且 issuer 是 Kaspersky ⇒ 就是本文这个问题,按下面的解法处理;authorized: true⇒ 已经好了;- 把
rootSHA256抄下来:下次复发再跑一次,指纹相同 ⇒ 不是根的问题(去查安全软件的拦截规则);指纹不同 ⇒ 根换了,重新导出覆盖即可。
根因:本地 HTTPS 扫描换签证书链,而 Node 不读系统信任库
一句话:安全软件的 HTTPS 扫描必须做中间人才能扫内容,而 Node 故意不用系统信任库、自带一份跨平台一致的根列表——两边的设计撞在一起,就表现为「浏览器正常、DSH 报证书错」。
先分清三张不同的证书
很多「几个 AI 答案不一样」的混乱,都源于把三张证书混在一起讲(#6338):
| 证书 | 谁签发 | 会不会变 |
|---|---|---|
api.deepseek.com 的服务端证书 | 公共 CA(TrustAsia / DigiCert 等) | 几个月轮换一次,和本次问题无关 |
| 安全软件的本地根证书 | 它自己(自签名) | 安装时生成并存进系统信任库;重装 / 大版本升级会换 |
| 每次连接现场签发的叶证书 | 上面那张根 | 随时在变 |
NODE_EXTRA_CA_CERTS 信任的是第二张(根):只要根没换,它签发的所有叶证书(第三张)自动被信任,不管变多少次。这也解释了为什么「昨天能用、今天不能用」——变的不是 DeepSeek 的证书,而是安全软件的拦截行为(例如开始拦以前不拦的域名)。
证书级证据:根在 Windows 里,却不在 Node 里
社区在 Windows 11(Node v26.7.0、dsh 0.1.5-rc.1)上拿到了完整对照(#6338):
- 带 SNI 连接
api.deepseek.com,叶证书是CN=api.deepseek.com,签发者是Kaspersky Anti-Virus Personal Root Certificate(O=AO Kaspersky Lab),两个解析地址上都一样; - 这张根确实在 Windows 信任库里(
Cert:\LocalMachine\Root/Cert:\CurrentUser\Root,本机共 82 张); - 而
require('tls').rootCertificates在 Node v26.7.0 上列出 118 张根、0 张匹配 "Kaspersky"——本地拦截根根本不在 Node 自带的 CA 集里; - 默认信任下
tls.connect({...rejectUnauthorized:true})抛SELF_SIGNED_CERT_IN_CHAIN,fetch同样失败。
所以浏览器正常、DSH 失败——同一个 root,两个信任库,结论完全自洽。
为什么它「时好时坏」而不是每次必现
这是最让报告者困惑的一点,机制其实很清晰(#6420):
是否被换签取决于「这条 TCP 连接」。 undici 连接池里的 keep-alive 连接握手早已完成,不会被劫持;只有新建连接才可能撞上。同一条命令连打多次,结果会不同:
connect 1: authorized=false issuer=[Kaspersky Anti-Virus Personal Root Certificate]
connect 2: authorized=true issuer=[TrustAsia DV TLS RSA CA 2025]
connect 3: authorized=false issuer=[Kaspersky Anti-Virus Personal Root Certificate]
实测统计:
| 场景 | 结果 |
|---|---|
| 原始 TLS 握手(直连) | 6/10 报 SELF_SIGNED_CERT_IN_CHAIN |
| 复用连接发 POST | 16/20 成功 |
| 每个请求都新建连接 | 仅 1–2/20 成功 |
⇒ 程序空闲一段时间后(连接池需要开新连接)最容易失败,短时间内连续操作反而正常——这就是「没规律」的来源。
同类的「代理链路」问题也会伪装成它:如果你的 Clash 开着 TUN / 增强模式,它是在网络层透明接管流量的,Node 根本不需要任何代理配置,所以「node 没配代理」不代表 clash 不在链路里(#6420)。报告里 ubuntu + clash 关掉就失败、打开就正常、clash 退出后台就失败 都是同一类。
有些版本线是「连接被重置」而非「证书不被信任」
还有一条独立但同源的链:安全软件的加密连接扫描在转发大请求时重置连接。证据是——安全软件暂停的那一刻,同一会话、同一进程、同一网络路径上的传输错误立即归零,此后多个连续请求步无一次重试(#6987)。这类不由证书错误码呈现,而是直接落成 transport failed,所以看到 transport failed 也要把安全软件/代理列为嫌疑。
解法:三种,外加一条禁用项
按「改动范围」从小到大排:单变量读系统库 → 只多信一张根 → 安全软件侧加排除项。不要用关掉全部校验那条路。
方案一(推荐,单变量):NODE_USE_SYSTEM_CA=1
让 Node 直接读系统信任库(安全软件的根本来就在里面)。Node v22.15.0 / v23.8.0 起支持(#6338)。
- 确认版本:
node -v # 需要 >= v22.15.0
- 当前会话临时启用(用于立刻验证):
$env:NODE_USE_SYSTEM_CA="1"
- 长期生效(写进系统环境变量,然后新开终端):
setx NODE_USE_SYSTEM_CA 1
- 重启 DSH(该变量在进程启动时读取)。
为什么推荐它(#6338):
- 本机实测:不开这个开关时 Node 默认信任 145 张根(全是它自带的),开了之后是 305 张 = 145 张自带 + 160 张系统库——是追加,不是替换;
- 好处:安全软件以后换根时 Windows 信任库会跟着更新,你什么都不用做;
- 代价:信任范围从「只多一张根」扩大到「整个系统库」。
社区在不同 Windows 机器上独立复现并验证了同一结论:开启后同一连接 authorized: true,fetch 返回 HTTP 401(没带 key 的正常结果),说明 TLS 已通、请求已到达 API(#6338)。
方案二(最小改动):NODE_EXTRA_CA_CERTS 指向导出的根
只想多信那一张根、不想动系统库时用它。
- 在 Windows 上导出拦截根证书:
Win+R打开certlm.msc,展开「受信任的根证书颁发机构」→「证书」;- 找名称含
Kaspersky的那张根证书(通常在列表靠前的厂商区); - 右键 → 所有任务 → 导出 → 选 Base64 编码 X.509 (.CER) → 存为
kaspersky-root.pem。
- 启动 DSH 之前指向它:
$env:NODE_EXTRA_CA_CERTS = "C:\path\to\kaspersky-root.pem"
dsh
- 要让所有终端长期生效,就把
NODE_EXTRA_CA_CERTS写进系统环境变量;PowerShell 永久写法(#6420):
[Environment]::SetEnvironmentVariable(
'NODE_EXTRA_CA_CERTS',
"$env:USERPROFILE\.dsh\certs\interception-roots.pem",
'User')
它确实生效,不是经验之谈:社区在 Node v22.22.3 上起了一个用自签名证书的本地 HTTPS 服务,同一条命令只改这一个环境变量(#6338):
| 环境 | fetch('https://localhost:8443') |
|---|---|
不设 NODE_EXTRA_CA_CERTS | fetch failed / DEPTH_ZERO_SELF_SIGNED_CERT |
| 设了,指向该证书的 PEM | SUCCESS: {"ok":true,"via":"d5-tls-test"} |
⇒ Node 的 fetch(undici)确实读取 NODE_EXTRA_CA_CERTS,所以你不需要在「保留扫描」和「访问 API」之间二选一。修复后的真实统计:原始握手 0/10 报错,新建连接请求 20/20 成功(#6420)。
两个坑(#6338):
- 路径写错不会报错。 指向不存在的文件时,Node 只打印一条 warning(
Ignoring extra certs from …),然后照常失败,错误码还是SELF_SIGNED_CERT_IN_CHAIN。所以设了仍然失败,先确认路径可达,且文件是 PEM 文本(-----BEGIN CERTIFICATE-----开头),不是二进制.cer。 - 中间态是里程碑:如果错误码从
SELF_SIGNED_CERT_IN_CHAIN变成了ERR_TLS_CERT_ALTNAME_INVALID,说明证书已经被信任了,只剩主机名不匹配——那是另一个问题(证书 SAN 与访问域名/IP 不一致),与安全软件无关。
方案三(留在安全软件侧):加排除项 / 受信任应用
不想碰 Node 配置,也可以让这段流量不被扫描(#6338):
- 把
dsh/node加入安全软件的受信任应用(跳过其加密连接扫描); - 或关闭「加密连接扫描 / HTTPS 扫描」;
- 或在扫描设置里把
api.deepseek.com加进排除项,让这一段不被扫描(菜单名以你的版本为准,卡巴斯基官方说明:https://support.kaspersky.com/kaspersky-for-windows/21.23/157530)。
说明:第 1、2 项是安全软件 UI 侧配置,社区作者坦承没有安全软件环境逐一实测;第 3 项的官方文档链接可用作入口。
⛔ 不要用 NODE_TLS_REJECT_UNAUTHORIZED=0
实测它确实能连通(fetch 返回 200),但它关闭的是全部证书校验,Node 自己会打印安全警告。救急可以,不该当成解法(#6338)。
macOS 用户:持久化用 LaunchAgent
macOS 上同样会遇到(报错常是 UNABLE_TO_GET_ISSUER_CERT_LOCALLY),因为 DSH 内置的 Node 没有使用 macOS 系统 CA。社区给出的完整修复记录是(#6987):
- 在当前登录会话启用
NODE_USE_SYSTEM_CA=1; - 安装持久化 LaunchAgent:
~/Library/LaunchAgents/com.deepseek.harness.system-ca.plist; - 重启 DeepSeek Harness;
- 未修改 API Key、模型配置或会话数据。
验证结果:内置 Node 能访问 Messages API 并返回预期的 HTTP 401(而非 TLS 失败),实际会话恢复流式输出 约 250–280 tok/s,新一轮执行 20 多个步骤、TRANSPORT 错误数为 0。
排查注意事项
这类问题的核心是「先确定报错落在哪一层」,最忌讳一上来就重装 DSH 或删 .dsh 目录。 八条要点:
- 先跑最小复现命令再动任何东西:
node -e "fetch('https://api.deepseek.com')..."。出现SELF_SIGNED_CERT_IN_CHAIN⇒ TLS 信任链问题;出现其他 ⇒ 另说(#6338)。 - 别把「时好时坏」当随机故障:「空闲后失败、连续操作正常」正是 keep-alive 连接的指纹——只有新建连接才会撞上换签(#6420)。
- 「node 没配代理」不等于代理不在链路里:Clash 的 TUN / 增强模式在网络层透明接管,Node 不需要任何代理配置也会被路由;给域名加一条 DIRECT 规则即可验证(#6420)。
- 卸载安全软件后的实验不能回溯故障时刻:
rejectUnauthorized:false仍会记录校验结果,但你在「干净状态」下跑只能证明「现在是干净的」(#6338)。 - 两种报错都要想到安全软件:证书不被信任报
SELF_SIGNED_CERT_IN_CHAIN;加密扫描转发大请求时重置连接则直接落成transport failed,没有证书错误码(#6987)。 - transport failed 的真正 cause 在启动终端:会话日志的
turn/end只留{message, code},被包装的底层错误不会写进日志,界面永远只显示最外一层标签(#6987)。 - 这不是 bug,两边都不会「自己修好」:安全软件的 HTTPS 扫描必须做中间人才能扫内容,而 Node 故意使用自带的跨平台根列表。同类问题会出现在任何自带信任库的程序(Python / Java / Go …)搭配任何做 HTTPS 拦截的软件(ESET / Bitdefender / 企业代理如 Zscaler)上(#6338)。
- 别忘了看 DSH 版本与 baseURL:若 curl 直连成功、只有 DSH 失败,检查 settings 里
llm-deepseek的baseURL是否被改过、环境里有没有DEEPSEEK_BASE_URL、key 是否被DEEPSEEK_API_KEY正确注入(#6420)。
排查这类问题时,用 DSH Plugin Hub 的已安装列表确认 DSH 侧版本与插件状态、并在系统日志页把诊断信息导出来,能先把「DSH 没装好」这一层快速排除,把精力留给证书链本身。

常见问题
不是。这个错误码不是 DSH 生成的,而是 Node TLS 层的内置错误,说明被替换的是你本机这一段证书链:安全软件或代理做了 HTTPS 中间人扫描,用它自己的自签根证书重新签发了证书链,而 Node 的 fetch(undici)默认不信任这张根。实测关掉扫描就恢复直连、错误消失,正好反证问题在本地这一段,与 api.deepseek.com 服务端证书无关。
因为是否被换签取决于「这条 TCP 连接」。undici 连接池里已建立的 keep-alive 连接握手早已完成、不会被劫持;只有新建连接才可能撞上。实测原始 TLS 握手 6/10 报错、复用连接发 POST 16/20 成功、每个请求都新建连接则只有 1-2/20 成功。所以程序空闲一段时间后(连接池要开新连接)最容易失败,短时间内连续操作反而正常——这就是「没规律」的来源。
想省事、以后不想再管,选 NODE_USE_SYSTEM_CA=1(Node v22.15.0 / v23.8.0 起支持):Node 直接读系统信任库,本机实测根证书从 145 张(自带的)追加到 305 张(+160 张系统库),是追加不是替换;安全软件以后换根时系统库会跟着更新,你什么都不用做。只想做最小改动、或 Node 版本较低,就用 NODE_EXTRA_CA_CERTS 指向导出的那张 PEM,只多信这一张。两个选一个即可,长期生效都要写进系统环境变量或持久化配置。
最常见是路径或文件格式问题:指向不存在的文件时 Node 只打印一条 warning(Ignoring extra certs from …)然后照常失败,错误码还是 SELF_SIGNED_CERT_IN_CHAIN。请确认路径可达,且文件是 **PEM 文本**(以 -----BEGIN CERTIFICATE----- 开头),不是二进制 .cer。另一个有用的中间态:若错误码变成 ERR_TLS_CERT_ALTNAME_INVALID,说明证书**已经被信任**,只剩主机名不匹配,那是另一个问题。注意该变量只在进程启动时读取,改完必须重启 DSH。
这条串是 DSH 自己的分类标签:当请求生成器抛出的**不是 LlmError** 时才会落到这个分支(adapter.ts:76-79),含义是请求或响应流在 SSE/协议层之下断了——连接被重置、链路/代理中断、TLS 或 HTTP2 层失败。协议层的其他失败都有自己的错误码(TIMEOUT / ABORTED / AUTH / MALFORMED_RESPONSE / STREAM_CLOSED 等),不会落进这条。被包装的底层错误不会进入会话日志,所以要去**启动终端的输出**里找 cause 链。
相关术语
- SELF_SIGNED_CERT_IN_CHAIN
- Node TLS 层的内置错误码,表示握手中收到的证书链里出现一张自签名证书,且它不在本地信任库里。在本文语境下典型来源是安全软件的 HTTPS 扫描用其自签根证书重签了证书链;DSH 自己并不生成也不映射这个码,只是把它包进 TRANSPORT 类错误。— https://github.com/deepseek-ai/deepseek-harness/discussions/6338
- NODE_USE_SYSTEM_CA
- Node 环境变量,取值 1 时让 Node 直接使用操作系统信任库(Windows 证书存储 / macOS 钥匙串),而不是只用自带的跨平台根列表。Node v22.15.0 / v23.8.0 起支持;本机实测开启后信任根从 145 张追加为 305 张。好处是企业/安全软件的拦截根随系统库自动更新,代价是信任范围扩大到整个系统库。— https://github.com/deepseek-ai/deepseek-harness/discussions/6338
- NODE_EXTRA_CA_CERTS
- Node 环境变量,指向一个额外的 PEM 证书文件,让 Node 在该进程内额外信任其中的根证书。它在进程启动时读取一次,改完必须重启程序;指向不存在的文件时只打印 warning、不会报错。相比 NODE_USE_SYSTEM_CA 是更小范围的改动,只多信指定的那一张根。— https://github.com/deepseek-ai/deepseek-harness/discussions/6338
- DeepSeek Messages transport failed
- DSH 对 DeepSeek Messages 协议失败的分类文案,只在请求生成器抛出非 LlmError 时产生(`packages/llm/llm-deepseek/src/protocols/messages/adapter.ts:76-79`),对应可重试的 TRANSPORT 类错误。协议层/HTTP 层失败各有自己的错误码,不会落进这条;被包装的底层 cause 不会写入会话日志,只会出现在启动终端。— https://github.com/deepseek-ai/deepseek-harness/discussions/6987
来源
- #6338 — 关于疑似卡巴斯基 HTTPS 扫描导致 Node.js 访问 api.deepseek.com 报 SELF_SIGNED_CERT_IN_CHAIN 的反馈· deepseek-ai(GitHub Discussions)
- #6420 — 在反复安装 DSH 后,我的 DSH 仍会频繁出现 DeepSeek API request to https://api.deepseek.com failed 问题· deepseek-ai(GitHub Discussions)
- #6987 — 本轮运行失败 DeepSeek Messages transport failed· deepseek-ai(GitHub Discussions)