← 返回首页

Claude Desktop 远程 SSH 模式下 API 403:HTTPS_PROXY 没传给 daemon 的排查

2026-05-26 · Claude CodeClaude DesktopLinuxzsh

环境: 客户端 macOS 上的 Claude Desktop,远端 Ubuntu 26.04 + zsh 5.9,zj 用户登录 shell 是 /usr/bin/zsh。代理是远端 mihomo localhost:7890,本来 export 写在 ~/.zshrc 里。

症状:在 Claude Desktop 里通过 SSH 连到远端机器开会话,发消息下方飘红色框:

API Error
Failed to authenticate. API Error: 403 {"error":{"type":"forbidden","message":"Request not allowed"}}

但同一台远端机器:

差异收敛到这么细的边界,意味着是某个机制层面的差别——不是网络出口被 region 拦(那两边都该挂),也不是凭据(同一台机器同一个 ~/.claude/)。这篇记录怎么从"看不出区别"一步步定位到根因。

第一刀:/proc/<pid>/environ 看实际拿到的代理

HTTPS_PROXY 是不是真传到 claude 进程里了?最直接的办法是看 /proc/<pid>/environ(只能用户自己读自己的进程,无需 root):

# 拿正在跑的 claude 进程 PID
CLAUDE_PID=$(pgrep -u $USER -nx claude)
# 看代理相关变量
tr '\0' '\n' < /proc/$CLAUDE_PID/environ | grep -iE 'proxy|no_proxy'

输出:

HTTPS_PROXY=http://localhost:7890
HTTP_PROXY=http://localhost:7890
ALL_PROXY=socks5://localhost:7890
no_proxy=localhost,127.0.0.1,...

进程确实拿到了代理。再确认这个 claude 是不是真的在用代理出连接(看 socket):

ss -tnp 2>/dev/null | grep -E '(:7890|api\.anthropic)' | head

看到 ESTAB 127.0.0.1:<port> → 127.0.0.1:7890 users:(("claude",pid=...,fd=43))——这个 claude 正经走代理在跟 Anthropic 通信

可那 403 哪来的?

第二刀:两个 claude 进程对比

发现机器上同时跑着两个 claude(一个是普通 SSH 那个,一个是 Desktop SSH 那个),完整环境 diff 一下:

diff \
  <(tr '\0' '\n' < /proc/<普通 claude pid>/environ | sort) \
  <(tr '\0' '\n' < /proc/<Desktop claude pid>/environ | sort)

结果除了 ATUIN session ID、conda env、shell history index 这些跟"现在哪个 shell 在跑"相关的变量之外,关键环境变量(包括 HTTPS_PROXY、PATH、API 相关)完全一致

cmdline 也都只是 claude 一个 token,没有特殊参数。

两个进程看起来一模一样,那 403 凭啥只挂一边?

关键发现:Desktop 远程模式不是简单 SSH 转发

~/.claude/ 时注意到一个之前没见过的目录:

$ ls -la ~/.claude/remote/
drwx------ 4 zj zj    4096 Apr 29 16:38 .
drwx------ 2 zj zj    4096 Apr 29 16:35 ccd-cli
drwx------ 3 zj zj    4096 Apr 29 16:38 plugins
-rw------- 1 zj zj   61145 Apr 29 17:13 remote-server.log
srw------- 1 zj zj       0 Apr 29 16:35 rpc.sock
-rwxrwxr-x 1 zj zj 6045848 Apr 29 16:34 server         ← 6MB 二进制
-rw------- 1 zj zj      32 Apr 29 16:34 token.88f9...

这是个完整的子系统:Desktop 通过 SSH 连过来时,把 6MB 的 server 二进制推到远端,然后用 Unix socket(rpc.sock)通信。这才是 Desktop"Remote SSH"模式真正干活的进程组,不是普通的 claude

看进程:

$ pgrep -af 'remote/(server|ccd-cli)'
1640433 /home/zj/.claude/remote/server --serve --socket .../rpc.sock --token-file .../token.88f9...
1640483 /home/zj/.claude/remote/server --bridge --socket .../rpc.sock
1646071 /home/zj/.claude/remote/ccd-cli/2.1.121 --output-format stream-json --verbose --input-format stream-json --effort medium --model claude-opus-4-6 ...

三件套:daemon(--serve)、SSH 桥(--bridge)、真正的 LLM 客户端(ccd-cli)ccd-cli 跟普通交互式 claude 是不同的二进制——--output-format stream-json --input-format stream-json 这套是 headless 模式专用的协议。

直奔关键:看 daemon 的环境

立刻看这三个进程的代理环境变量:

for PID in 1640433 1640483 1646071; do
  echo "=== pid=$PID ==="
  tr '\0' '\n' < /proc/$PID/environ | grep -iE 'proxy|no_proxy' \
    || echo ">>>> 没有 proxy 环境变量 <<<<"
done

三个全是:

>>>> 没有 proxy 环境变量 <<<<

那 403 的原因找到了:三个进程都没拿到 HTTPS_PROXY,直连 api.anthropic.com 被对方按 region / IP 拒了。

为啥没拿到?看 daemon 启动时的环境

完整 dump 一下 daemon 的环境:

$ tr '\0' '\n' < /proc/1640433/environ | sort
CLAUDE_SSH_DAEMON_CHILD=1
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
HOME=/home/zj
LANG=en_US.UTF-8
LOGNAME=zj
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
SHELL=/usr/bin/zsh
SHLVL=0
SSH_CLIENT=192.168.2.134 64237 50022
USER=zj
_=/home/zj/.claude/remote/server

四条铁证:

也就是说 Desktop 启动远端 daemon 时不是用 ssh host '...' 跑命令,而是用类似 ssh host /home/zj/.claude/remote/server --serve ... 这种直接 exec 二进制的方式,sshd 起一个非交互非登录 session,根本不调用 shell。我的代理 export 在 ~/.zshrc 里,而 .zshrc 只在交互式 shell 启动时加载——daemon 完全错过了这一关。

修法:挪到 ~/.zshenv

zsh 的启动文件矩阵是这样的:

场景.zshenv.zprofile.zshrc.zlogin
ssh host(交互式登录)
ssh host command(非交互非登录)
ssh -t host command(强制 tty)

只有 .zshenv 在所有场景都会加载。即便像本案的 daemon 这种"完全不走 shell"的进程,也有可能在某个 fork 出来的子进程里(比如 zsh -c)读到它。

修法分两步:

1)新建 ~/.zshenv,把代理 export 放进去:

cat > ~/.zshenv <<'EOF'
export HTTP_PROXY=http://localhost:7890
export HTTPS_PROXY=http://localhost:7890
export ALL_PROXY=socks5://localhost:7890
export no_proxy="localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12"
export NO_PROXY="$no_proxy"
EOF

2)删掉 .zshrc 里原来那几行 export,避免重复。

模拟测试一下"空环境 + 非交互非登录 zsh"能不能拿到代理:

env -i HOME=$HOME PATH=/usr/bin:/bin /usr/bin/zsh -c 'echo $HTTPS_PROXY'

输出 http://localhost:7890 说明 .zshenv 生效了。

让 Desktop 重连,验证

daemon 内存里没代理,改完 .zshenv 也不会自动重启。手动结束让 Desktop 重连:

pkill -f '\.claude/remote/server'
# Desktop 那边会自动重连,几秒后新 daemon 起来

⚠️ 一个我撞到的小坑:pkill -f 会匹配 pkill 命令行自身——只要命令行里出现 .claude/remote/server 字符串,grep 出来包含调用者本身,某些情况下会把刚启动的脚本 / shell 一起干掉。要稳一点用 pgrep -f 先看一眼,或者用 pgrep | grep -v $$ 过滤掉自己。

再看新 daemon:

NEW=$(pgrep -f 'remote/server --serve' | head -1)
tr '\0' '\n' < /proc/$NEW/environ | grep -i proxy

期望看到:

HTTPS_PROXY=http://localhost:7890
HTTP_PROXY=http://localhost:7890
ALL_PROXY=socks5://localhost:7890
NO_PROXY=...

回 Desktop 重发消息,403 消失。

兜底:~/.pam_environment

万一你的环境下 Desktop 真的彻底绕过 zsh(不调 zsh -c 也不 zsh -i,直接 execve() 二进制),.zshenv 不会被读。这时上 PAM 兜底:

cat >> ~/.pam_environment <<'EOF'
HTTP_PROXY=http://localhost:7890
HTTPS_PROXY=http://localhost:7890
ALL_PROXY=socks5://localhost:7890
NO_PROXY=localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12
EOF

pam_env 是 PAM 在用户认证完成后注入的环境(Ubuntu / Debian 的 sshd 默认启用),发生在 shell 之前,即便 SSH session 后续完全跳过 shell 也能拿到。

小结