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 挂了”,也不是“账号挂了”。中间一定多了一层东西。

这里有两条线需要分开看:

  1. ConnectionRefused 的直接修复点在 Desktop 启动的 local Claude Code 子进程链路上。
  2. 日志和 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 请求:

进一步打开 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 里能看到 AgentEditReadWriteWebSearch,还有 mcp__workspace__bashmcp__workspace__web_fetchmcp__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 模式启动时,日志里会同时出现这些组件:

日志里确实看到过:

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

其中:

这就是为什么一个看起来像“普通 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

所以生命周期是:

这也解释了为什么有时候第一条消息会慢,后面快很多。第一条可能在等这套 runtime 初始化。

VM 里的用户模型

guest 里面真正执行命令的不是 root,而是一个 per-session 的普通 Linux 用户。日志里这一轮叫:

<session-user>

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 里当然有 rootubuntulxd 这类系统用户。历史上也能看到别的 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 里还有:

Shared directory: /

这更像 Apple Virtualization 的 directory sharing。guest 里看到的是一个类似 virtiofs 的根共享点:

/mnt/.virtiofs-root

然后 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 会被卸掉。

所以这里能确认的是:

底层具体是不是 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 随便出网”的模型。更像:

日志里没有看到默认的 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 会话里需要:

就会走 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 上,而是加了几层边界:

代价也很明显:故障点变多了。

以前 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_URLANTHROPIC_API_KEYNODE_EXTRA_CA_CERTS,是另一件事。

这次 ConnectionRefused 最后就落在这条链路上:Desktop 启动的 local Claude Code 子进程没有正确吃到证书信任环境。修的是这层,不是 VM 里的 CA。

第二条是工作区执行链路。

Cowork 会准备一个 Linux VM,把授权过的本地目录挂进去,再通过 managed MCP tools 让模型读文件、写文件、跑 shell。cowork_vm_node.logcowork_vm_swift.logvzgvisor.logcoworkd.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 启动流程了。