Eson Wong  /  文章

终端、SSH、launchd、tmux:zsh 启动文件到底读了哪些

技术约 5 分钟

我平时在 MacBook 的终端里用 Claude Code。为了出门也能接着用,我把它放进 tmux 里跑:在 iPhone、iPad 上用 SSH 连回 MacBook,attach 上同一个 tmux 会话,桌面上的对话就能在手机上继续。

用起来第一个碰到的就是环境问题。同一台机器、同一个用户,手机 SSH 进 tmux 里启动的那份 Claude Code,跑 nodecommand not foundbrew 也找不到,写在 .zshrc 里的 API key 它也读不到;可我自己在同一个 tmux 窗口里手敲 node 又是有的。

我大概知道是怎么回事:Claude Code 不读 .zshrc,它只继承启动它的那个进程的环境。但「启动它的那个进程」在终端、SSH、launchd、tmux 里到底各是什么、各读哪些启动文件,我一直只有个模糊印象,每次都是碰到了改一下、能用就算。这次借机把这些场景逐个实测了一遍(macOS 26 / zsh 5.9 / tmux 3.6),彻底搞清楚,最后给出我现在的做法。

一、只需要问三个问题

  1. 经没经过 shell? 不经过 shell 的进程(launchd 直接启动的服务、Dock 点开的 app)什么启动文件都不读。
  2. 是 login 吗?是 interactive 吗? zsh 读哪几个文件只看这两个标志。
  3. 初始环境是谁给的? 每个进程一出生就带着一份环境变量,来自启动它的那个东西:launchd、sshd,或者某个 tmux 客户端。启动文件只是在这份初始环境上再加东西,初始环境不同,结果就不同。

zsh 的读取顺序:

flowchart TD
  E["/etc/zshenv → ~/.zshenv(永远读)"] --> Q1{login?}
  Q1 -- 是 --> P["/etc/zprofile(跑 path_helper)→ ~/.zprofile"] --> Q2{interactive?}
  Q1 -- 否 --> Q2
  Q2 -- 是 --> R["/etc/zshrc → ~/.zshrc"] --> Q3{login?}
  Q2 -- 否 --> Q3
  Q3 -- 是 --> L["/etc/zlogin → ~/.zlogin"]

macOS 有两个特殊点:图形界面终端里每个标签都是 login shell(Linux 上默认不是),所以 Homebrew 让你把 brew shellenv 写进 .zprofile/etc/zprofile 会跑 path_helper,按 /etc/paths 重排 PATH,把系统目录挪到最前面,你在 .zshenv 里排好的顺序到 login shell 里会被它打乱一次。

想知道自己在哪个场景里,把这几行塞进去跑:

print -r -- "\$0=$0"
[[ -o login ]] && print login || print not-login
[[ -o interactive ]] && print interactive || print not-interactive
print -r -- "PATH=$PATH"

二、逐个场景实测

场景 启动了什么 login interactive 读什么 初始环境谁给的
界面终端新标签 -zsh 八个全读 launchd
Dock 点开的 app 无 shell 不读 launchd
ssh host 交互登录 -zsh 八个全读 sshd
ssh host 命令 / scp / rsync zsh -c 只读 zshenv sshd
launchd 服务 无 shell 不读 launchd + plist 里写的
cron /bin/sh -c 不读 cron 进程自己那份
tmux server 本体 不是 shell tmux.conf 启动它的那个客户端
tmux 新窗口 / pane -zsh 八个全读 tmux server + session
tmux 带命令的 pane zsh -c 只读 zshenv 同上,但 PATH 来自发命令的客户端
Claude Code 的 Bash 工具 zsh -c + snapshot 只读 zshenv claude 进程自己的

几个场景值得多说两句。

界面终端读文件读得最全,初始环境却最少:launchd 只给系统四个目录的 PATH、HOME、SHELL、USER、TMPDIR 和一个 SSH_AUTH_SOCK,其余全靠八个文件重建。拿它当基准去推测别处,一定会错。

ssh host 命令、scp、rsync 是最多人栽跟头的场景:sshd 启动的是 zsh -c,只读 .zshenv。我在另一台 Mac 上用前面那四行试了一下,打印出来是 $0=zsh、not-login、not-interactive,PATH 是 ~/.cargo/bin:/usr/bin:/bin:/usr/sbin:/sbin。多出来的 ~/.cargo/bin 是 rustup 安装器写进 ~/.zshenv 的,所以 cargo 在非交互 SSH 里能用而 brew 不能:rustup 把路径放对了地方,Homebrew 按官方指引放在 .zprofile。另外 .zshenv 里有任何输出都会弄坏 scp / rsync,这个文件只能设变量。

launchd 服务没有 shell,什么都不读。launchctl getenv PATH 是空的,launchctl print gui/$(id -u)/<label> 能看到 default environment = { PATH => /usr/bin:/bin:/usr/sbin:/sbin },再加上 plist 里 EnvironmentVariables 写的。cron 也一样什么启动文件都不读,PATH 就是 cron 进程自己那份系统目录。

tmux 要分三层看。server 本体的环境是启动它的那个客户端的环境副本tmux show-environment -g 能看到)。谁第一个启动 tmux,就决定了之后所有窗口的初始环境;如果是 launchd 服务启动的,之后所有窗口都只有那份最简环境。不带命令的新窗口启动 -zsh,八个文件全读。带命令的 pane(new-session 'cmd'split-window 'cmd'respawn-pane)启动 zsh -c,只读 .zshenv。还有一个例外:只要命令是从 tmux 外面发的(脚本、守护进程里的 tmux new-window,而不是在 tmux 里按快捷键),pane 的 PATH 就来自发命令的那个进程,不是 server 的;new-session 更进一步,SSH_AUTH_SOCK、DISPLAY 这几个也会从发命令的进程复制过来,它没有的还会被删掉(加 -E 可以关掉)。这就是「守护进程经 tmux 拉起 CLI」的真实场景:别的变量都好,唯独 PATH 是守护进程那份最简的,翻 .zshrc 翻一天也翻不出来。

三、CLI 工具自己读什么

工具本身不读任何 shell 文件,只继承父进程的环境,再读自己的配置(.npmrc.gitconfig~/.claude/settings.json)。两类坑:

  • 靠 shell 钩子的:fnm、nvm、pyenv、conda、direnv 都要交互 shell 启动时跑一段钩子(eval "$(fnm env)"source nvm.sh 之类)才生效,launchd、zsh -c、ssh 命令里这段根本没跑过。「终端里好好的,服务里找不到」十有八九是这个。fnm 有个固定路径 ~/Library/Application Support/fnm/aliases/default/bin,放进 .zshenv 就不需要 eval "$(fnm env)";direnv 在服务里要显式 direnv exec
  • 会再启动子 shell 的npm run/bin/sh -c,macOS 的 sh 是 bash 3.2 的 sh 模式,非交互连 $ENV 都不读。Claude Code 的 Bash 工具每条命令一个 zsh -c,PATH 最终还是 claude 进程自己的那份,hooks 和 MCP 子进程直接继承 claude 进程环境:claude 从哪启动,工具就是什么环境。(它的 snapshot 里带了 setopt login,前面那四行在它里面跑会打印 login,其实并没读 .zprofile。)

四、速查

写在哪 界面终端 ssh 交互 ssh 命令 launchd tmux 窗口 tmux 命令 pane Claude Bash 工具
~/.zshrc 里 export ✗(除非 server 环境里已有) ✗(除非 claude 进程环境里已有)
~/.zshenv 里 export
plist EnvironmentVariables

.zshenv 那一行几乎全绿,唯一例外是 launchd,因为它不经过 shell。这张表就是结论。

五、我最后的做法

原则:以文件为准。所有 zsh 都从 .zshenv 拿同一份 PATH 和密钥,改完文件下一个 zsh 生效;launchd 服务要么在 plist 里写 EnvironmentVariables,要么把入口写成 /bin/zsh -c 'exec ...',让它也经过一次 zsh、读到 .zshenv

# ~/.zshenv —— 每个 zsh 都读;不能有任何输出
typeset -U path   # 去重,重复加也不会越积越长

_my_path() {
  path=(
    "$HOME/.local/bin"
    "$HOME/Library/Application Support/fnm/aliases/default/bin"
    /opt/homebrew/bin
    /opt/homebrew/sbin
    $path
  )
}
_my_path

# 密钥单独放 ~/.config/secrets.env(chmod 600),一行一个 KEY=value,
# 值可以整个用引号包起来,行尾不要写注释。
# 逐行解析而不是 source:只认 KEY=value 形状的行,剥掉一对外层引号后 export,
# 不做任何展开,文件里写 $(...) 或 rm -rf 都只是字符串。
_my_secrets() {
  local f="$HOME/.config/secrets.env" line k v
  [[ -r $f ]] || return 0
  while IFS= read -r line || [[ -n $line ]]; do
    k=${line%%=*} v=${line#*=}
    [[ $line == *=* && $k == [A-Za-z_]* && $k != *[^A-Za-z0-9_]* ]] || continue
    [[ $v == \"*\" || $v == \'*\' ]] && v=${v[2,-2]}
    export "$k=$v"
  done < "$f"
}
_my_secrets
# ~/.zprofile —— 只有 login shell 读;path_helper 已经重排过 PATH,这里再摆回来
_my_path

验证要在最简环境里做,终端里怎么试都是对的:

# .zshenv 能不能把 node 找回来
env -i HOME=$HOME PATH=/usr/bin:/bin zsh -c 'command -v node'
# 模拟守护进程启动 tmux,看 pane 里的 PATH(tmux 不在 /usr/bin,要写全路径)
env -i HOME=$HOME PATH=/usr/bin:/bin "$(command -v tmux)" -L probe new-session -d 'print -r -- $PATH > ~/probe.txt'

结尾

下次再遇到「终端里有、别处没有」,别急着翻 .zshrc。先问三句:经没经过 shell?login 和 interactive 各是什么?初始环境是谁给的?答完,问题基本就定位了。改完之后,手机上 SSH 进 tmux 里的 Claude Code,终于和 MacBook 终端里的一模一样。

我为什么开发 AI 英语听力生成器:Im-Listening →

评论 · 0