前一篇 macOS SSH 連線 Claude Code 顯示未登入|keychain 未解鎖解法 講到 SSH 進 mac 時 login keychain 沒解鎖的問題,但仔細想會冒出一個更根本的疑問:同一台機器、同一個使用者帳號、載入的也是同一份 ~/.zshrc,為什麼 GUI 打開的 Terminal 跟 SSH 進來的 shell 行為差這麼多?答案藏在 macOS launchd 的 domain 設計裡,而 launchctl managername 就是最直接的觀測工具。

先做個實驗:同一個帳號,不同答案
在 mac 前面打開 Terminal.app,執行 launchctl managername,畫面會吐出 Aqua。接著從另一台機器 SSH 進來,同一個帳號、同一個指令,這次會看到 Background。僅僅是開 shell 的路徑不同,launchd 就把我們塞進了兩個完全不同的 domain。
# 在 GUI Terminal 執行
$ launchctl managername
Aqua
# 從 ssh 進來後執行
$ launchctl managername
Background
Aqua 代表我們正在一個圖形登入的使用者 session 裡,Background 則代表一個沒有 GUI、但仍然屬於這個 uid 的 launchd domain。這兩個字不是單純的標籤,而是 launchd 實際用來決定:哪些 service 會跑、keychain 有沒有被解鎖、TCC 權限怎麼繼承、pbcopy 能不能接到 pasteboard server 等等一連串行為。
launchd domain 是什麼
macOS 的 launchd(pid 1)把整個系統切成幾個不同的 domain,每個 domain 各自管理自己的 service 與環境。常見的幾個:
| Domain 指標 | managername | 說明 |
|---|---|---|
system | System | root daemon 的家,/Library/LaunchDaemons 跑在這裡 |
gui/<uid> | Aqua | 使用者從 loginwindow 完成 GUI 登入後才會產生,loginwindow 在這裡解鎖 login keychain |
user/<uid> | Background | 使用者等級但不綁 GUI,SSH 進來就是落在這裡 |
login/<asid> | LoginWindow / StandardIO | loginwindow 顯示中,或 stdio/getty 等特殊 session |
我們平常寫在 ~/Library/LaunchAgents 的設定檔是「使用者 agent」,但載入到 gui/<uid> 還是 user/<uid> 會有差。brew services 預設會把 service 裝進 gui/<uid>,這也是為什麼從 SSH 下 brew services list 看到的狀態有時會跟 GUI 不一致——兩個 domain 的 service 清單本來就是分開的。
# 看 GUI session 掛了哪些 service
launchctl print gui/$(id -u) | head
# 看非 GUI 的 user domain service
launchctl print user/$(id -u) | head
# 手動把一個 agent 載入 GUI domain
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.plist
GUI Terminal 與 SSH session 的實測差異
整理一份實際會踩到的差異清單,每一項都是 launchd domain 不同所導致,而不是 ~/.zshrc 寫得不一樣:
| 差異項目 | GUI Terminal(Aqua) | SSH Session(Background) |
|---|---|---|
| login keychain | loginwindow 已解鎖 | 鎖著,要 security unlock-keychain |
| TouchID for sudo | pam_tid.so 可用 | 無法使用(沒 GUI 彈視窗) |
| TCC 隱私權限 | 繼承 Terminal.app 的授權 | 由 sshd 自己的 TCC 條目決定,預設幾乎沒授權 |
pbcopy/pbpaste | 直接讀寫系統剪貼簿 | 有 GUI 登入時寫入 GUI 使用者的剪貼簿,沒登入會失敗 |
osascript 對話框 | 出現在使用者螢幕上 | 若有 GUI 登入要搭 launchctl asuser 才會顯示 |
通知中心 terminal-notifier | 正常運作 | 常因為連不到 NotificationCenter 失敗 |
| 環境變數 | 繼承 launchd user session(launchctl setenv 設的都在) | 只有 sshd 轉進來的 + shell 啟動檔 |
| 特徵變數 | 有 TERM_PROGRAM、__CFBundleIdentifier | 有 SSH_CLIENT、SSH_CONNECTION、SSH_TTY |
| 父行程 | Terminal.app → login → zsh | launchd → sshd-session → zsh |
音效 afplay、say | 從使用者揚聲器播出 | 有 GUI 登入時播在 GUI 那邊,否則靜音 |
最常踩到的其實是 TCC。從 GUI Terminal 打開後去讀 ~/Desktop、~/Documents,系統會彈授權視窗,按允許就記住了;但同樣操作透過 SSH 執行時,授權請求歸在 sshd 這個程式名下,而 sshd 在 TCC 表裡預設是空的,結果就是 ls ~/Desktop 直接回 Operation not permitted——即使帳號是同一個人。
要讓 sshd 也能讀保護區,得到「系統設定 → 隱私權與安全性 → 完整磁碟取用權限」把 /usr/libexec/sshd-keygen-wrapper 加進去(不同 macOS 版本路徑可能略有差異,有些版本要同時加 sshd)。這件事 Apple 官方文件著墨很少,大多得靠搜尋踩雷文章。

ls ~/Desktop,在 Aqua 與 Background 兩個 domain 得到完全不同的答案keychain 解鎖只是這個問題的一個分支
前面那篇 keychain 文章處理的就是表格的第一列——login keychain 只在 gui/<uid> domain 被 loginwindow 解鎖。SSH 進來落在 user/<uid>,keychain 維持鎖定狀態,所以 Claude Code、gh、git osxkeychain helper 這些依賴 keychain 讀 token 的工具通通會表現成「未登入」。其餘幾列差異本質上都是同一個設計造成的,只是表現形式不同。
換句話說,如果把 mac 當成有 GUI 使用者的個人機器,Apple 這套設計沒什麼問題——日常都在 GUI 登入下,所有權限與資源都解鎖,用起來很省事。但當我們把 mac 當伺服器、長時間只靠 SSH 操作時,這套設計就會變成一連串的意外。
SSH 權限卡關時的幾種繞法
知道根因之後,接下來整理幾種實務上常用的繞法,從最保險但最麻煩、到最自動化的排。每一種都有自己的代價,視 mac 是自己用還是多人共用、是不是放在身邊等因素選擇。
方法一:用螢幕共享連進 GUI,再開 Terminal(保險但麻煩)
最直接的做法——與其 SSH 進 Background domain 硬要繞權限,不如透過「螢幕共享」先連回 GUI。在「系統設定 → 一般 → 共享」打開「螢幕共享」之後,從另一台 mac 的 Finder 側欄點該機器按「共享螢幕」,或用 vnc:// 協定連線,直接看到 mac 的桌面。在那個遠端桌面裡打開 Terminal.app,執行 launchctl managername 會看到 Aqua,keychain、TCC、pbcopy 通通正常,等於把問題整個從源頭迴避掉。
這是目前我自己在 mac mini 上最常用的緊急手段,但日常不太想用——畫面要重新繪製、延遲比純文字 SSH 高、鍵盤快捷鍵會跟本機衝突、不能直接從本機編輯器跑命令、也沒辦法只為了跑一行指令就開整個遠端桌面。適合偶爾處理登入、輸入密碼這類必須有 GUI 的事情,不適合日常維運。
方法二:自動登入 + 拉長 keychain 閒置鎖定
如果 mac 是放家裡自己用的伺服器,最省事的做法是在「系統設定 → 使用者」打開自動登入。這樣開機後 loginwindow 會自己跑完 GUI 登入流程,gui/<uid> domain 建立起來,keychain 解鎖,相關的 TCC 授權也就位。之後 SSH 進來雖然落在 user/<uid>,但 keychain 是跨 domain 共享的狀態,所以能直接讀得到 token。這段在 keychain 未解鎖那篇 有更完整的說明。
重點:自動登入只解 keychain 與 GUI 相關資源的問題,並不會自動讓 sshd 拿到 TCC 權限,~/Desktop 那些保護區還是要走下面的方法三。
方法三:幫 sshd 補 TCC 權限
到「系統設定 → 隱私權與安全性 → 完整磁碟取用權限」把 sshd-keygen-wrapper 加進去(路徑在 /usr/libexec/sshd-keygen-wrapper,Finder 按 Cmd+Shift+G 輸入即可)。加完後 SSH session 讀 ~/Desktop、~/Library/Mail、~/Library/Messages 這些保護區就不會再回 Operation not permitted。
要注意這等於給「所有成功 SSH 進來的身份」完整磁碟權限,在公用或多人環境千萬別這樣做。配合只允許 key 登入、關閉密碼登入、限制 AllowUsers 才相對安全。如果只需要讀特定資料夾,也可以改用「檔案與資料夾」權限裡單獨授權,粒度比較細。
方法四:把需要 GUI 的指令丟進 launchctl asuser
如果 SSH 進來後要啟動一個 GUI 應用,或跑某個必須在 Aqua session 才有意義的 osascript,可以用 launchctl asuser 把指令丟到目標 uid 的 GUI session 執行:
# 以 uid 501 的 Aqua session 身份執行指令
launchctl asuser 501 osascript -e 'display notification "Hello" with title "From SSH"'
# 對比:直接在 SSH 裡跑通知,很可能什麼也收不到
osascript -e 'display notification "Hello" with title "From SSH"'
這招在寫維運腳本、部署 script、想從 SSH 觸發 GUI 行為時很好用。不過要注意,如果 mac 完全沒有人 GUI 登入,launchctl asuser 找不到對應的 GUI session,指令就會失敗——這也是為什麼前面會建議打開自動登入。
方法五:在 zshrc 印出 session 類型方便除錯
既然 launchctl managername 這麼關鍵,乾脆寫進 shell 啟動檔,每次進 shell 就直接看到目前是哪種 session,遇到怪事時一眼就知道是不是 domain 差異造成:
# 加進 ~/.zshrc
if [[ -o interactive ]]; then
printf "launchd domain: %s\n" "$(launchctl managername 2>/dev/null)"
fi
小結
GUI Terminal 跟 SSH terminal 最大的分水嶺不是 shell,而是 launchd domain:Aqua(gui/<uid>)還是 Background(user/<uid>)。從這一點延伸出 keychain、TCC、pbcopy、TouchID、osascript、通知等一連串差異。下次 SSH 進 mac 遇到「在 GUI 能跑但這裡不行」的狀況,先跑一次 launchctl managername,就能快速判斷問題是不是源自 domain 不同。想看 keychain 那一列具體怎麼踩,回去看 macOS SSH 連線 Claude Code 顯示未登入|keychain 未解鎖解法 那篇會比較完整。