Claude Desktop ConnectionRefused 背后的本地 VM runtime
事情一开始很普通:Claude Desktop 发消息失败。
UI 上是一句非常没有信息量的:
Something went wrong
Try sending your message again. If it keeps happening, share feedback so we can investigate.
API Error: Unable to connect to API (ConnectionRefused)
You can restart the conversation from an earlier message.
但它又不是普通的网络失败。服务端那边已经能看到 token 消耗,说明请求至少有一部分已经打到了云端。更奇怪的是,同一台机器上 claude CLI 没问题,只有 Claude Desktop 出问题。
如果 CLI 正常,Desktop 不正常,那大概率不是“Claude 挂了”,也不是“账号挂了”。中间一定多了一层东西。
这里有两条线需要分开看:
ConnectionRefused 的直接修复点在 Desktop 启动的 local Claude Code 子进程链路上。- 日志和 bundle 里同时暴露出来的,是 Claude Desktop Cowork 模式背后一整套本地 VM runtime。
本文按这两条线展开:先定位这次连接失败落在哪个进程链路上,再看 Cowork 的 VM、目录挂载和网络托管大致是怎么组织的。
版本信息如下。本文基于 2026-06-10 左右的 macOS 版 Claude Desktop Cowork:
Claude Desktop: 1.11847.5
local Claude Code: 2.1.170
这种本地 runtime 细节很容易随版本变。后面看到的路径、日志名、启动链路,都按这个版本范围理解。
先说结论
这次问题最后和证书链有关。
这个环境里的 AI 基础设施用到了自签名证书,模型请求会经过自建网关或中转层。问题出在 Claude Desktop 启动出来的某条本地子进程链路没有正确信任这张 CA。
真正关键的进程链路是这条:
Claude Desktop / HostLoop
-> /Applications/Claude.app/Contents/Helpers/disclaimer
-> /Users/<user>/Library/Application Support/Claude-3p/claude-code/2.1.170/claude.app/Contents/MacOS/claude
也就是说,Claude Desktop 在 Cowork 模式下不是直接跑 claude-code,而是先通过 Helpers/disclaimer 这个 helper 去 spawn 真正的 local Claude Code binary。
日志里的 sdkOptions after patch 很直白:
executable: '/Applications/Claude.app/Contents/Helpers/disclaimer'
executableArgs: [
'/Users/<user>/Library/Application Support/Claude-3p/claude-code/2.1.170/claude.app/Contents/MacOS/claude'
]
envKeys: [
...
'ANTHROPIC_BASE_URL',
'ANTHROPIC_CUSTOM_HEADERS',
'NODE_EXTRA_CA_CERTS',
...
]
Helpers/disclaimer 值得单独看,是因为它正好卡在 Desktop 和 local Claude Code 之间。原始文件是一个很小的 Mach-O spawn wrapper,usage 是:
disclaimer <command> [args...]
最终的修复方式是改这个入口,让它在 spawn 真正的 claude-code 前带上根证书:
#!/bin/sh
root_ca="$HOME/.claude-root-ca.crt"
if [ -r "$root_ca" ]; then
export NODE_EXTRA_CA_CERTS="$root_ca"
fi
exec "$@"
之后 Desktop 的 Cowork 链路恢复,日志里能看到 turn succeeded,cycle health 也回到 healthy。
这个 wrapper 影响的范围其实很窄:它只改变 Desktop 启动 local Claude Code 时的进程环境。
HostLoop -> disclaimer -> local Claude Code
真正需要补证书的是 local Claude Code 发起 API 请求这一段:
local Claude Code --HTTPS/TLS--> ANTHROPIC_BASE_URL / gateway
也就是说,Cowork 里虽然同时有 Desktop UI、HostLoop、VM、workspace MCP 这些组件,但这次改 wrapper 解决的是 local Claude Code 子进程自己的 Node TLS 信任问题。补完以后,它再去连 ANTHROPIC_BASE_URL 时,证书链就能走通。
请求到了服务端,Desktop 为什么还判失败
这里是最容易误判的点。
如果同一个 HTTPS 请求在 TLS 握手阶段因为证书失败,服务端一般收不到 HTTP 请求,更不应该产生 token 消耗。但实际现象是:服务端已经能看到请求和计费。
因此这次问题不能简单写成“证书不对,所以 hello 请求没发出去”。
那失败的 HTTP 请求到底发了什么?
这里要收着点说:本地日志没有把失败请求的 URL / body 打出来。能看到的是 local Claude Code 的 audit 记录:
{"type":"user","message":{"role":"user","content":"hello"}}
{"type":"system","subtype":"status","status":"requesting"}
{"type":"system","subtype":"api_retry","attempt":1,"max_retries":10,"error_status":null,"error":"unknown"}
...
{"type":"system","subtype":"api_retry","attempt":10,"max_retries":10,"error_status":null,"error":"unknown"}
{"type":"assistant","message":{"model":"<synthetic>","content":[{"type":"text","text":"API Error: Unable to connect to API (ConnectionRefused)"}]}}
也就是说,这轮在本地看来是:local Claude Code 收到了 hello,进入 requesting,然后在 API 层重试了 10 次,最后没有拿到真正的 assistant message,只能写入一条 <synthetic> 的错误消息。这个 synthetic message 的 usage 全是 0,说明它不是模型返回的内容,而是本地 runtime 自己补出来的失败结果。
同一轮的 init 记录里还能看到 model、tool 列表、MCP server 列表、entrypoint、cwd 这些东西。按 Claude Code turn 的形态推断,真实发给模型的 payload 不会只是裸的 hello,而是一整个 agent turn:系统上下文、工具声明、会话状态,再加上用户消息 hello。但具体 HTTP path、headers、body,本地这些日志没有留下。
还可以继续往下看。Desktop 侧的 3P / gateway 配置会把这些东西放进 local Claude Code 的环境:
ANTHROPIC_BASE_URL
ANTHROPIC_API_KEY
ANTHROPIC_CUSTOM_HEADERS
CLAUDE_CODE_ENTRYPOINT=local-agent
NODE_USE_SYSTEM_CA=1
其中 inferenceCustomHeaders 的说明也很直白:这些 header 会被合并到 CLI 的 ANTHROPIC_CUSTOM_HEADERS,发到每一次 inference 和 model-discovery 请求里。也就是说,真正发请求的是 local Claude Code,不是 Electron renderer 直接拿 fetch() 打出去。
api_retry 的 schema 说明里写得很清楚:error_status 为 null 表示 connection error,比如 timeout,这种情况下没有 HTTP response。失败记录里 10 次 retry 都是:
error_status = null
error = unknown
单看这些日志,只能确认 local Claude Code 进入了 API request 层,又在连接层面重试到失败。它不会把完整 URL、headers、body 都吐出来。
为了把链路拆开,可以做一个本机 HTTP reverse proxy 实验:服务器端网关不改,只在本机起一个 proxy,然后把 Desktop 启动 local Claude Code 时用到的 ANTHROPIC_BASE_URL 临时指到这个本机 proxy:
REAL_ANTHROPIC_BASE_URL=<gateway>
ANTHROPIC_BASE_URL=http://127.0.0.1:18443
这样 local Claude Code 会把请求先打到本机 proxy;proxy 只记录摘要和耗时,再转发到真实 gateway。这个观察点放在 local Claude Code 和本机 proxy 之间,所以能看到明文请求摘要;真正的 HTTPS 发生在 proxy 到 gateway 这一段。
这个实验等于把原始路径:
local Claude Code --HTTPS/TLS--> gateway
临时改成了:
local Claude Code --HTTP--> 127.0.0.1:18443 proxy --HTTPS/TLS--> gateway
这个实验的价值在于把路径拆成了两段。只要 proxy 到 gateway 能正常拿到 SSE 返回,就能排除掉一批服务端和模型映射问题;剩下更可疑的,就是 local Claude Code 直连 gateway 时的 TLS 信任链。
发送一个带 marker 的测试消息后,proxy 抓到的是这样:
12:07:18.226 HEAD / -> 200
12:07:18.240 HEAD / -> 200
12:07:18.303 POST /v1/messages?beta=true claude-sonnet-4-20250514
body=28 KB, system=25 KB, messages=1, tools=0
upstream model=glm-5-turbo, SSE 200, first chunk=2.2s
12:07:18.375 POST /v1/messages?beta=true claude-opus-4-8
body=143 KB, system=64 KB, messages=2, tools=52
upstream model=glm-5.1, SSE 200, first chunk=8.5s
两个 POST 都带着同一个 marker。也就是说,这次实验里同一个用户动作至少触发了两条 inference 请求:
- 一条很轻的 side request,走
claude-sonnet-4-20250514,上游映射到 glm-5-turbo; - 一条真正的 Cowork 主 turn,走
claude-opus-4-8,上游映射到 glm-5.1,带 52 个工具声明。
进一步打开 full body dump 后,可以看到完整 JSON 请求体。顶层字段很标准:
model
messages
system
tools
metadata
max_tokens
thinking
stream
那条 Sonnet side request 是:
model=claude-sonnet-4-20250514
max_tokens=32000
thinking=enabled, budget_tokens=31999
system blocks=3
messages=1
tools=0
真正的 Cowork 主 turn 是:
model=claude-opus-4-8
max_tokens=64000
thinking=adaptive
system blocks=3
messages=2
tools=52
主 turn 里能看到 Agent、Edit、Read、Write、WebSearch,还有 mcp__workspace__bash、mcp__workspace__web_fetch、mcp__cowork__...、mcp__Claude_in_Chrome__... 这些工具声明。也就是说,Desktop 发出去的不是“用户输入的一句话”,而是一个带完整系统上下文、工具 schema、会话状态和用户消息的 agent request。
这也解释了一个容易误判的现象:如果在服务端按 input_tokens <= 10 查 “hello” 类请求,看到的主要是 capability test、title、ack 这种轻请求。真正的 Cowork 主 turn 根本不是“小 hello”。用户只输入一句话,它发出去也可能是十几万字节的 agent payload。
所以 token 消耗和 UI 报错可以同时成立:
token 消耗 = 某条 inference 请求已经走到 gateway / 上游
UI 报错 = Desktop 本地没有把它认为关键的 turn 收完整
这次 proxy 实验还校正了一个判断:当前修复后,claude-opus-4-8 -> glm-5.1 这条主 turn 链路是能完整返回 SSE 的,clientAborted=false。所以最早看到的 glm-4.5-air token 消耗,不一定就是失败的那条主 turn;它可能只是 Desktop 同时发出的旁路轻量请求。
同一时间,Electron 侧还会发 event logging 请求到 claude.ai/api/event_logging/v2/batch,甚至可能遇到 Cloudflare challenge。但那是产品遥测接口,不是 /v1/messages 这条推理请求。它就算失败,也不能解释这轮 Cowork turn 为什么没有完整返回。
在这条链路里,能定位到的关键位置就是 HostLoop 启动 local Claude Code 的子进程。这个子进程负责 Cowork turn,并且它的环境里有:
ANTHROPIC_BASE_URL
ANTHROPIC_CUSTOM_HEADERS
NODE_EXTRA_CA_CERTS
所以这个问题不是“hello 有没有触发命令执行”,而是“Desktop 有没有把这个 Cowork turn 完整收回来”。这也解释了为什么 CLI 没事:CLI 的路径更直接;Desktop Cowork 多了 HostLoop、local Claude Code、workspace MCP server、VM readiness、managed network 这一整套本地 runtime 状态。
VM 之所以会进入分析范围,是因为 Cowork 模式启动时,日志里会同时出现这些组件:
- local Claude Code process;
- HostLoop;
- workspace MCP server;
- VM readiness;
- tool aliases;
- managed network;
- CA / proxy 注入;
- SDK install / postConnect;
- API reachability check。
日志里确实看到过:
Network status: NOT_CONNECTED
Network status: CONNECTED
API reachability: PROBABLY_UNREACHABLE
API reachability: UNREACHABLE
所以 VM 是这套 runtime 的一部分,但不是“hello 请求发出去”的必要条件。它之所以值得展开,是因为同一套 Cowork 日志把 VM、HostLoop、managed network 和 CA 注入都摆在了一起。
Cowork 的本地链路
Claude Desktop 的 Cowork 模式已经不是“本机直接跑个 shell”了。
更像是这样:
flowchart TD
UI["Claude Desktop UI"]
Main["Electron main / Desktop host"]
HostLoop["HostLoop"]
Code["local Claude Code process"]
MCP["workspace MCP server"]
VM["Linux VM"]
Shell["bash in VM"]
Net["managed network / gateway"]
API["API / gateway"]
UI --> Main --> HostLoop --> Code
Code --> MCP
MCP --> VM --> Shell
Code --> Net --> API
其中:
- 对话 turn 由 Desktop/HostLoop 驱动 local Claude Code;
- 直接
Bash / WebFetch 会被替换成 workspace 版工具; - workspace 版 bash 最终在 VM 里执行;
- 本地目录通过授权和挂载暴露给 VM;
- 网络、CA、proxy、allowed domains 由 Desktop/Cowork 管理。
这就是为什么一个看起来像“普通 API 连接失败”的错误,会牵出 HostLoop、VM、gVisor、CA forward 和目录挂载这些组件。
VM bundle 和运行时痕迹
Claude Desktop 的 VM bundle 在这里:
/Users/<user>/Library/Application Support/Claude-3p/vm_bundles/claudevm.bundle
里面有这些文件:
rootfs.img
rootfs.img.zst
sessiondata.img
efivars.fd
machineIdentifier
gvisorMacAddress
vmIP
其中:
gvisorMacAddress = 02:00:00:00:00:01
vmIP = 172.16.10.3
rootfs.img 是一个 10GB 左右的 GPT 磁盘镜像,里面有 EFI 分区和 Linux ext4 rootfs。不是 chroot,不是随便 untar 出来的 rootfs,而是一块完整的 VM disk。
运行时进程里还能看到 Apple 的 VM XPC:
/System/Library/Frameworks/Virtualization.framework/Versions/A/XPCServices/com.apple.Virtualization.VirtualMachine.xpc/Contents/MacOS/com.apple.Virtualization.VirtualMachine
Claude 自己的 native addon 在这里:
/Applications/Claude.app/Contents/Resources/app.asar.unpacked/node_modules/@ant/claude-swift/build/Release/swift_addon.node
它链接了:
Virtualization.framework
vmnet.framework
strings 里面能看到一堆熟悉的符号:
VZVirtualMachine
VZDirectorySharingDeviceConfiguration
VZVirtioSocketDevice
VZVirtioSocketConnection
VZEFIBootLoader
VZGenericMachineIdentifier
所以 Claude Desktop 在 macOS 上确实是用 Apple Virtualization.framework 起了一个 Linux VM。
VM 什么时候起来
不是每条命令都新建一个 VM。
日志在:
/Users/<user>/Library/Logs/Claude-3p/cowork_vm_node.log
/Users/<user>/Library/Logs/Claude/cowork_vm_swift.log
启动过程大概长这样:
download_and_sdk_prepare
load_swift_api
stop_existing_vm
create_vm_config
create_network
vm_boot
guest_vsock_connect
static_ip_assignment
guest_ready
install_ca_certificates
sdk_install
Swift 侧更直白:
startVM called ... memoryGB=4 cpuCount=auto networkMode=gvisor
Using gvisor user-mode networking
Created gvisor network socket pair
Starting gvisor usernet ... mac=02:00:00:00:00:01
Sent static IP assignment: ip=172.16.10.3/24 gateway=172.16.10.1
Forwarding 21 trusted CA certificates to guest
真正跑 shell 的时候,只是在这个 VM 里启动一个 one-shot bash:
[vmOneShot] Running: bash [2 arg(s)] as <session-user>
Process spawned: id=oneshot-<id> name=<session-user> command=bash
所以生命周期是:
- Desktop / Cowork 初始化时启动 VM。
- VM 在会话里复用。
- 每次
mcp__workspace__bash 是一个新的 bash 进程。 - Desktop 重启、VM crash、bundle 更新、环境重建时才会重新起 VM。
这也解释了为什么有时候第一条消息会慢,后面快很多。第一条可能在等这套 runtime 初始化。
VM 里的用户模型
guest 里面真正执行命令的不是 root,而是一个 per-session 的普通 Linux 用户。日志里这一轮叫:
coworkd.log 里有完整过程:
creating user <session-user> with home /sessions/<session-user>
new group: name=<session-user>, GID=1004
_CMDLINE=useradd -m -d /sessions/<session-user> -s /bin/bash <session-user>
new user: name=<session-user>, UID=1004, GID=1004, home=/sessions/<session-user>, shell=/bin/bash, from=none
spawn: name=<session-user> cmd=bash ... uid=1004 gid=1004
base image 里当然有 root、ubuntu、lxd 这类系统用户。历史上也能看到别的 session user:
<old-session-user-1>
<old-session-user-2>
<old-session-user-3>
<session-user>
但日志里没有看到 <session-user> 在 sudo/admin 组里的证据。能确认的是,bash 进程是 UID/GID 1004/1004。
同时 coworkd 自己权限肯定更高,因为它负责 useradd、mount、unmount。也就是 daemon 管理环境,任务进程降权执行。
这个设计比较清楚:模型拿到的是一个普通用户 shell,不是 macOS 上的直接 shell,更不是 VM root。
本地目录怎么进 VM
本地目录进 VM 这块,日志里能看到 Apple Virtualization 的 directory sharing 痕迹。
guest 里有:
mounted shared directory at /mnt/.virtiofs-root (root only)
native addon 里有:
VZDirectorySharingDeviceConfiguration
setDirectorySharingDevices:
Including shared directory:
Swift VM config 里还有:
这更像 Apple Virtualization 的 directory sharing。guest 里看到的是一个类似 virtiofs 的根共享点:
然后 coworkd 每次执行命令前,把这次允许访问的 host 子目录映射到 session 目录:
/sessions/<session>/mnt/<name>
比如授权 ~/Downloads 后,HostLoop 记录的是:
Added user selected folder: /Users/<user>/Downloads
[HostLoop] Folder added: /Users/<user>/Downloads -> /mnt/Downloads - next bash call will mount it
工具返回给模型的提示是:
host tools use /Users/<user>/Downloads
mcp__workspace__bash uses /sessions/<session-user>/mnt/Downloads
Do not pass /sessions/... to host Read/Write/Edit/Grep/Glob
然后模型实际调用:
{
"name": "mcp__workspace__bash",
"input": {
"command": "ls -lhS /sessions/<session-user>/mnt/Downloads/ | head -20"
}
}
日志:
[workspaceMcpServer] bash: vmStatus=ready after 1ms wait, cmdLen=62, vmCwd=/sessions/<session-user>/mnt/Downloads, mounts=Downloads,uploads,.claude/skills,.claude/projects,outputs,.auto-memory
[workspaceMcpServer] bash done: exit=0, duration=507ms, outputBytes=449
那次命令里挂进去的东西包括:
Downloads rw
outputs rw
uploads ro
.claude/skills ro
.claude/projects ro
.auto-memory ro
命令结束后,这些 mountpoint 会被卸掉。
所以这里能确认的是:
- 不是把整个 macOS 文件系统直接暴露给 shell。
- host 到 guest 用的是 Apple Virtualization directory sharing / virtiofs-like 机制。
coworkd 再做 per-command subpath mount。- ro/rw 是分开的。
底层具体是不是 bind mount,日志没有直接给 syscall 和 mount options,这里不展开成更具体的 mount 类型。
网络也不是普通 NAT VM
VM 有自己的 MAC 和 IP:
MAC = 02:00:00:00:00:01
IP = 172.16.10.3
GW = 172.16.10.1
但 host 上没有普通的 172.16.10.0/24 路由,也不像常规 VM 那样可以直接连 172.16.10.3:22。
关键点在 gVisor。
Swift log:
networkMode=gvisor
Using gvisor user-mode networking
Created gvisor network socket pair
Starting gvisor usernet ...
gVisor log:
usernet: starting with sockFD=88
usernet: vmMAC=02:00:00:00:00:01
usernet: virtual network created successfully
net.Conn created from socket, type=*net.UnixConn
network stack started successfully
native addon 里还有:
Using gvisor user-mode networking
Falling back to NAT networking (DHCP)
vmnet has no host-loopback NAT
github.com/containers/gvisor-tap-vsock
gvisor.dev/gvisor
DNATTarget
SNATTarget
所以网络大概是:
flowchart LR
Guest["Linux guest<br/>172.16.10.3"]
NIC["virtual NIC<br/>MAC 02:00:00:00:00:01"]
GV["gVisor user-mode network stack"]
Host["macOS host network"]
Out["API / Internet"]
Guest --> NIC --> GV --> Host --> Out
guest 里看起来是一张虚拟网卡、一个 gateway、一个 IP 网络。但从 host 和外部看,它不是透明桥接。Claude Desktop/native 侧用 gVisor user-mode networking 接住这张虚拟网卡后面的流量,再做 NAT / proxy / policy。也就是说,guest 里模拟的是一套普通 IP 网络;到了 host 侧,它已经变成 Desktop 接管的用户态三层/四层转发。
Desktop 还会同步 CA 和 proxy 配置
把网络出口交给 Desktop 管之后,guest 里的信任链和代理配置也需要跟着处理。日志里出现 CA、proxy、PAC 这些关键词,不是偶然的噪声,而是这套托管方式的一部分。
日志里明确有:
Forwarding 21 trusted CA certificates to guest
addon 字符串里也有:
trusted CA certificates to guest
Sent host proxy config to guest: PAC script
http=
no proxy configured
也就是说 Desktop 不只是启动 VM。它还会把 host 侧的 CA / proxy 信息同步给 guest。
同时 Desktop 启动 local Claude Code 进程时,还会附带 managed settings。进程参数里能看到类似:
{
"sandbox": {
"network": {
"allowedDomains": ["<internal-gateway.example>", "api.anthropic.com"],
"allowManagedDomainsOnly": true
},
"enabled": true,
"allowUnsandboxedCommands": false
}
}
所以这不是一个“VM 随便出网”的模型。更像:
- Desktop 管网络出口。
- Desktop 注入可信 CA。
- Desktop 注入 proxy / PAC。
- Desktop 控制 allowed domains。
日志里没有看到默认的 per-flow 网络审计,比如每个连接目标、端口、字节数、SNI 的完整流水。能确认的是 operational log 很多,网络策略也存在;不能确认默认保存完整流量审计。
哪些工具调用会走 VM
把 Claude Desktop 的 asar 解出来后,可以看这个文件:
/tmp/claude-app-asar-analysis/.vite/build/index.js
里面有默认配置:
secureVmFeaturesEnabled: !0,
vmMemoryGB: 0,
vmCpuCount: 0,
coworkDisabledTools: [],
coworkWebSearchEnabled: !0,
还有工具 alias:
{
Bash: "mcp__workspace__bash",
WebFetch: "mcp__workspace__web_fetch"
}
Desktop 启动 local Claude Code 的时候,会禁用这些直接工具:
--disallowedTools Bash,NotebookEdit,REPL,JavaScript,WebFetch
然后允许 workspace MCP 版本:
mcp__workspace__bash
mcp__workspace__web_fetch
换句话说,模型不是直接拿到 macOS 的 Bash。Desktop 在 HostLoop 这一层把它换成了 VM 里的 Bash。
mcp__workspace__bash 的描述也很直:
Run a shell command in the session's isolated Linux workspace...
connected folders are mounted under /sessions/<session>/mnt/ ...
Each bash call is independent ...
调用前还会做:
ensureVmReady()
computeBashMounts()
最后才是:
command: "bash",
args: ["-c", n.command],
cwd: u.vmCwd,
additionalMounts: u.mounts
所以哪些操作会走 VM?
当模型在 Desktop Cowork 会话里需要:
- 跑 shell;
- 用 workspace 版 WebFetch;
- 访问授权给 Cowork 的目录;
- 做需要本地 workspace execution 的动作;
就会走 VM / HostLoop 这条路。
host 侧的 Read / Edit / Write / Grep / Glob 还是 host 工具,用的是 macOS 路径。VM shell 用的是 /sessions/... 路径。Desktop 代码里甚至有 pre-tool hook 专门拦截这种混用:
/sessions/... is VM path;
host Read/Edit/Write should use host path;
use bash for VM path.
这就是为什么同一个 Downloads 会同时有两种名字:
macOS: /Users/<user>/Downloads
VM: /sessions/<session-user>/mnt/Downloads
这套设计的边界在哪里
Claude Desktop 这个设计比较激进,但思路很清楚。
它没有把模型直接接到 macOS shell 上,而是加了几层边界:
- shell 在 Linux VM 里,不在 macOS host 上;
- shell 是 session 普通用户,不是 root;
- 本地目录要用户授权;
- 授权目录按命令挂载;
- mount 有 ro/rw;
- WebFetch / Bash 被替换成 managed MCP 工具;
- 网络出口经过 gVisor user-mode stack;
- CA / proxy / allowed domains 由 Desktop 管。
代价也很明显:故障点变多了。
以前 CLI 出问题,大概查环境变量、证书、网络、代理就够了。Desktop Cowork 出问题,还需要看:
/Users/<user>/Library/Logs/Claude-3p/main.log
/Users/<user>/Library/Logs/Claude-3p/cowork_vm_node.log
/Users/<user>/Library/Logs/Claude/cowork_vm_swift.log
/Users/<user>/Library/Logs/Claude/coworkd.log
/Users/<user>/Library/Logs/Claude/vzgvisor.log
以及这些东西:
/Applications/Claude.app/Contents/Resources/app.asar
/Applications/Claude.app/Contents/Resources/app.asar.unpacked/node_modules/@ant/claude-swift/build/Release/swift_addon.node
/Users/<user>/Library/Application Support/Claude-3p/vm_bundles/claudevm.bundle
/Users/<user>/Library/Application Support/Claude-3p/claude-code/2.1.170/claude.app/Contents/MacOS/claude
这就从“应用网络错误”变成了“本地 agent runtime 调试”。
Claude Desktop Cowork 到底是个啥
综合来看,Claude Desktop Cowork 大概可以这么理解:
它不是一个聊天框加几个工具按钮。它是在 macOS 上启动 local Claude Code 和 HostLoop,再准备一个 Linux VM,把本地目录有选择地挂进去,让模型通过受控工具和普通用户 shell 去干活。
这也是为什么 “CLI 正常,但 Claude Desktop 报 ConnectionRefused” 这个现象不能只按普通网络错误看。
同一台机器、同一个账号、同一个网关,CLI 能走通,说明问题大概率不在最外层的账号、模型服务或者基础网络。真正的差异是 Desktop Cowork 不是“另一个 Claude 客户端”,它多了一套本地执行层。
从外面看,它是 Desktop UI 里的一次对话。往里看,大概是这样:
Desktop UI
-> HostLoop
-> Helpers/disclaimer
-> local Claude Code
-> ANTHROPIC_BASE_URL / gateway
-> Cowork VM / managed MCP tools
这里面有两条链路要分开看。
第一条是模型请求链路。
Desktop 不是直接从 UI 发一个简单的 HTTPS 请求出去,而是通过 HostLoop 和 Helpers/disclaimer 拉起 local Claude Code,再由这个子进程去连 ANTHROPIC_BASE_URL。所以 CLI 能连,只能说明当前交互 shell 里的 claude 能连;Desktop 启出来的那个 local Claude Code 有没有同样的 ANTHROPIC_BASE_URL、ANTHROPIC_API_KEY、NODE_EXTRA_CA_CERTS,是另一件事。
这次 ConnectionRefused 最后就落在这条链路上:Desktop 启动的 local Claude Code 子进程没有正确吃到证书信任环境。修的是这层,不是 VM 里的 CA。
第二条是工作区执行链路。
Cowork 会准备一个 Linux VM,把授权过的本地目录挂进去,再通过 managed MCP tools 让模型读文件、写文件、跑 shell。cowork_vm_node.log、cowork_vm_swift.log、vzgvisor.log、coworkd.log 这些日志,主要是在解释这套工作区 runtime 怎么启动、怎么联网、怎么挂载目录。
所以 VM 会出现在这次分析里,但它不是这次 ConnectionRefused 的直接修复点。它暴露的是 Cowork 的另一面:Claude Desktop 不是把模型直接接到 macOS shell 上,而是在本机搭了一套带边界的执行环境。
还有一个容易误导人的地方是服务端日志。服务端看到 token 消耗,不等于用户那条消息已经完整成功。Desktop/Cowork 可能同时发 title、capability、side request 和主 agent request。只搜 hello,或者只按 input token 很小的请求过滤,很容易把旁路请求当成主请求。
所以那个 “Something went wrong” 背后,不是一句单纯的应用报错,而是一整条本地执行链路里某一段没接上。
暂时到这里。再往下就该抓包、看 syscall、继续拆 guest 启动流程了。