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

GUI Terminal 與 SSH 兩條路徑分別落入 Aqua 與 Background 兩個 launchd domain,對應不同的資源存取權
圖 1:同一個帳號走不同入口,launchd 會把 session 塞進不同 domain,連帶影響 keychain、TCC、pbcopy 等資源是否可用

先做個實驗:同一個帳號,不同答案

在 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說明
systemSystemroot daemon 的家,/Library/LaunchDaemons 跑在這裡
gui/<uid>Aqua使用者從 loginwindow 完成 GUI 登入後才會產生,loginwindow 在這裡解鎖 login keychain
user/<uid>Background使用者等級但不綁 GUI,SSH 進來就是落在這裡
login/<asid>LoginWindow / StandardIOloginwindow 顯示中,或 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 keychainloginwindow 已解鎖鎖著,要 security unlock-keychain
TouchID for sudopam_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__CFBundleIdentifierSSH_CLIENTSSH_CONNECTIONSSH_TTY
父行程Terminal.apploginzshlaunchdsshd-sessionzsh
音效 afplaysay從使用者揚聲器播出有 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 指令,從 GUI Terminal 發起時 TCC 主體是 Terminal.app 因此放行;從 SSH 發起時 TCC 主體是 sshd 因此被拒絕
圖 2:TCC 看的是「發起請求的程式」而不是「使用者」——同一個 ls ~/Desktop,在 Aqua 與 Background 兩個 domain 得到完全不同的答案

keychain 解鎖只是這個問題的一個分支

前面那篇 keychain 文章處理的就是表格的第一列——login keychain 只在 gui/<uid> domain 被 loginwindow 解鎖。SSH 進來落在 user/<uid>,keychain 維持鎖定狀態,所以 Claude Code、ghgit 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 未解鎖解法 那篇會比較完整。