systemd 로 띄운 AI CLI 의 bash 도구에서만 명령이 PATH 에서 사라진다

터미널에선 잡히는 명령이 에이전트 bash 도구에선 not found. 원인은 systemd → 데몬 → 셸 스냅샷으로 이어지는 4중 PATH 레이어였다.

에이전트(AI 코딩 CLI)한테 glab 로 MR 을 열라고 시켰더니 명령을 못 찾는다고 했다. 내가 같은 터미널에서 직접 치면 멀쩡히 나온다. 에이전트 bash 도구에서만 사라진다. 이게 왜 되는지 알아내는 데 예상보다 오래 걸렸고, 원인이 한 곳이 아니라 네 겹이었다.

증상

에이전트 bash 도구에서:

$ which glab
glab not found

내 터미널에서 직접:

$ which glab
/home/linuxbrew/.linuxbrew/bin/glab

glab 만이 아니라 Homebrew(linuxbrew)로 깐 것 전부가 그랬다 — fzf, uv, brew 자체까지. 반면 node/npm/gh 는 잡힌다. 그것들은 ~/.local/bin 이나 버전 매니저 경로에 있어서다. 즉 특정 디렉터리 하나(/home/linuxbrew/.linuxbrew/bin)만 PATH 에서 통째로 빠져 있었다.

재현 조건은 이 한 줄로 압축된다.

zsh -c  'command -v glab'   # 비대화형 → MISSING 이면 재현
zsh -ic 'command -v glab'   # 대화형   → 여기선 나옴

비대화형 셸에서만 없다. 에이전트 bash 도구가 쓰는 게 바로 비대화형 셸이다.

실패했던 접근

처음엔 당연히 셸 rc 문제라고 봤다. eval "$(brew shellenv)"~/.zshrc 에 있으니 그걸 손보면 될 줄 알았다. 안 됐다. 비대화형 zsh 는 ~/.zshrc 를 안 읽기 때문이다 (~/.zshenv 만 읽는다).

그래서 ~/.zshenv 로 옮겼다. 그래도 에이전트 셸에선 여전히 없었다. 이번엔 셸 rc 를 아무리 고쳐도 뭔가가 뒤에서 PATH 를 다시 덮고 있다는 뜻이었다. 여기서부터 프로세스 계보를 거슬러 올라가야 했다.

근본 원인 — 레이어가 4개다

에이전트 CLI 를 systemd user 서비스(데몬)로 띄워 놓은 구성이었고, PATH 는 이 순서로 흐른다. 아래로 갈수록 나중에 이기고, 어느 한 층만 고치면 위층이 다시 덮는다.

systemd --user  (환경: environment.d)
  └─ agent-daemon.service   ← 유닛이 Environment=PATH= 로 통째로 고정
       └─ CLI 프로세스        ← 부모 PATH 그대로 상속
            └─ shell snapshot  ← CLI 가 자기 PATH 를 export PATH= 로 박아 저장
                 └─ zsh -c "source snapshot.sh && <명령>"   ← 에이전트가 실제 쓰는 셸

1. bash 도구는 bash 가 아니라 비대화형 zsh 다. 프로세스 인자를 까 보면 드러난다.

tr '\0' ' ' < /proc/$$/cmdline
# /usr/bin/zsh -c source ~/.claude/shell-snapshots/snapshot-zsh-....sh ...

비대화형 zsh 는 ~/.zshrc 를 건너뛰고 ~/.zshenv 만 읽는다. rc 에만 있으면 영영 안 잡힌다.

2. CLI 가 셸 스냅샷에 PATH 를 하드코딩한다. 세션 시작 때 ~/.claude/shell-snapshots/snapshot-zsh-<ts>-<id>.sh 를 만들고 매 bash 호출마다 source 한다. 그 안에 이런 줄이 있다.

export PATH=~/.local/bin:...:/bin:<플러그인 bin 들>

값은 CLI 프로세스 자신의 PATH 다. 그러니 .zshenv 를 고쳐도 스냅샷이 뒤에 덮는다. → CLI 프로세스의 PATH 가 틀리면 셸 rc 를 아무리 고쳐도 소용없다.

3. CLI 를 띄운 게 셸이 아니라 systemd 다.

ps -o pid=,ppid=,args= -p <cli pid>
# CLI ← agent-daemon ← ... ← systemd --user

systemd user 유닛은 .zshrc/.zshenv/.profile하나도 안 읽는다. 셸 초기화 파일은 이 경로에서 애초에 무의미하다.

4. environment.d 는 파일명 정렬 함정, 유닛 Environment= 는 그마저 무시. ~/.config/environment.d/10-path.conf 로 PATH 를 넣어도 반영이 안 되는데, 조용히 실패해서 원인이 안 보인다. 제너레이터를 디버그로 돌리면 답이 나온다.

SYSTEMD_LOG_LEVEL=debug \
  /usr/lib/systemd/user-environment-generators/30-systemd-environment-d-generator
Reading ~/.config/environment.d/10-path.conf…
  10-path.conf: setting PATH=...linuxbrew...
Reading /usr/lib/environment.d/99-environment.conf…
  99-environment.conf: setting PATH=/usr/local/sbin:...      ← 덮음
Reading /usr/lib/environment.d/990-snapd.conf…
  990-snapd.conf: setting PATH=/usr/local/sbin:...:/snap/bin ← 또 덮음

environment.d모든 디렉터리의 .conf 를 파일명 순으로 읽고 나중 것이 이긴다. 10-99-990-snapd.conf 보다 앞서므로 진다 (99-zz 도 진다 — -(0x2D) < 0(0x30)). 그리고 데몬 유닛은 본문에 Environment=PATH=... 를 통째로 박고 있어서, 유닛의 Environment= 가 매니저 환경을 완전히 대체한다 → environment.d 를 고쳐도 이 서비스엔 무의미하다.

실제 해결 — 네 곳 전부

(1) ~/.zshenv — 비대화형 zsh 용. rc 가 아니라 env 파일에 둔다.

typeset -U path PATH fpath   # 중복 항목 자동 제거

if [[ -x /home/linuxbrew/.linuxbrew/bin/brew ]]; then
  eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
fi
[[ -d "$HOME/.local/bin" ]] && path=("$HOME/.local/bin" $path)

.zshrc 의 중복 brew shellenv 줄은 지운다. 규칙: PATH 등 모든 셸이 필요로 하는 환경변수는 .zshenv, 프롬프트·alias·completion 은 .zshrc.

(2) ~/.bashrc — PATH 설정을 interactive 판정 로 올린다. ssh <host> <cmd> 같은 비대화형 bash 는 early-return 뒤 코드를 못 본다.

if [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then
    eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
fi

# If not running interactively, don't do anything
case $- in
    *i*) ;;
      *) return;;
esac

(3) ~/.config/environment.d/999-brew-path.conf — systemd user 서비스 전반. 파일명 999- 가 핵심이다.

PATH=/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:$PATH
HOMEBREW_PREFIX=/home/linuxbrew/.linuxbrew

$PATH 확장은 앞서 읽힌 .conf 가 세팅한 값만 가리킨다. 990-snapd.conf 뒤에 오는 이 파일에선 정상 동작하지만, 10- 이었으면 확장할 값이 없어 줄 자체가 무시된다.

(4) ~/.config/systemd/user/agent-daemon.service.d/10-brew-path.conf — 유닛 PATH 를 드롭인으로 덮는다.

[Service]
Environment=PATH=/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin

드롭인이 본문보다 나중에 읽히고 Environment= 는 마지막 지정이 이긴다. 유닛 본문 PATH 를 바꿀 일이 생기면 드롭인도 같이 고쳐야 한다 — 전체 나열 방식이라 자동 병합이 안 된다.

적용은 반드시 재시작까지.

systemctl --user daemon-reload
systemctl --user restart agent-daemon   # ⚠️ 진행 중인 에이전트 세션 전부 끊김

daemon-reload 만으로는 이미 떠 있는 데몬의 PATH 가 안 바뀌고, 그 아래 새로 뜨는 CLI 세션도 낡은 PATH 를 물려받는다.

검증

층별로 따로 찍어 봐야 어디서 새는지가 보인다.

# 설정 층
/usr/lib/systemd/user-environment-generators/30-systemd-environment-d-generator | grep ^PATH
systemctl --user show -p Environment agent-daemon.service | tr ' ' '\n' | grep PATH
systemd-run --user --pipe --wait --quiet /bin/sh -c 'command -v glab'

# 셸 층
zsh  -c 'command -v glab'   # .zshenv
bash -lc 'command -v glab'  # .bashrc

# 최종 — 재시작 뒤 새 에이전트 세션에서
which glab fzf uv

타환경 주의점

  • 데몬으로 안 띄우고 터미널에서 직접 실행한다면 (3)(4)는 필요 없다. 이 문제는 프로세스 부모가 셸이 아니라 systemd 라서 생긴다. ps 로 부모를 확인해서 systemd 가 나오면 유닛/environment.d 층까지 손봐야 한다.
  • 버전 매니저(fnm/nvm/asdf)로 깐 도구가 안 잡히는 것도 같은 구조다. 그 경로가 셸 스냅샷 시점의 PATH 에 없으면 똑같이 사라진다.
  • 셸 스냅샷을 쓰는 CLI 라면 rc 를 고친 뒤 세션을 새로 열어야 스냅샷이 새 PATH 로 다시 찍힌다. 기존 세션 안에서 rc 를 고쳐 봐야 이미 source 된 스냅샷은 안 바뀐다.
  • environment.d 파일명은 큰 숫자일수록 이긴다. 배포판 기본 .conf(snapd 등)가 세 자리 숫자를 쓰므로, 내 것은 그보다 큰 999- 로 둔다.