环境: 客户端 macOS 上的 Claude Desktop,远端 Ubuntu 26.04 + zsh 5.9,zj 用户登录 shell 是
/usr/bin/zsh。代理是远端 mihomolocalhost:7890,本来 export 写在~/.zshrc里。
症状:在 Claude Desktop 里通过 SSH 连到远端机器开会话,发消息下方飘红色框:
API Error
Failed to authenticate. API Error: 403 {"error":{"type":"forbidden","message":"Request not allowed"}}
但同一台远端机器:
- 普通 SSH(
ssh -p 50022 zj@host)进去跑claude—— ✅ 正常 - Desktop 本机的 Claude Code 会话(有 mihomo TUN) —— ✅ 正常
- 只有 Desktop 的"Remote SSH"模式 —— ❌ 403
差异收敛到这么细的边界,意味着是某个机制层面的差别——不是网络出口被 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
四条铁证:
SHLVL=0—— 从来没经过任何 shellPATH是系统默认的/usr/local/sbin:...,没有~/.local/bin、没有 conda 的 bin- 没有
CONDA_*、ATUIN_*、P9K_*这些 zsh 启动文件加的环境 CLAUDE_SSH_DAEMON_CHILD=1—— Desktop 自己打的内部标记
也就是说 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 也能拿到。
小结
- 普通 SSH 跑
claude走交互式 zsh,.zshrc加载,代理生效。 - Claude Desktop 的"Remote SSH"会推
~/.claude/remote/server二进制并以非 shell 方式 exec,.zshrc完全没机会跑。 - 调试这类问题的关键是
/proc/<pid>/environ——直接看进程实际拿到的环境,别猜。 - 凡是"希望进程一定能拿到"的环境变量(代理、PATH 加项、API key),写
~/.zshenv或~/.pam_environment,别只写在.zshrc。