the KodeLab

macOS の GUI Terminal と SSH セッションは何が違うか|launchctl managername で読み解く

6,205 文字 16 分で読めます
macOS の GUI Terminal と SSH セッションは何が違うか|launchctl managername で読み解く

前回の記事 macOS で SSH した Claude Code が未ログインになる|keychain 解錠の解決策 では、SSH 経由で Mac に入ったときに login キーチェーンが解錠されていない問題を扱いました。ここで一歩立ち止まると、もっと根本的な疑問が浮かびます。同じマシン、同じユーザー、読み込まれる ~/.zshrc も同じなのに、なぜ GUI で開いた Terminal と SSH 経由のシェルでは挙動がここまで違うのでしょうか。答えは macOS の launchd の domain 設計にあり、launchctl managername がそれを観察する最も直接的なツールです。

GUI Terminal と SSH はそれぞれ Aqua と Background という別の launchd domain に入り、利用可能なリソースが変わる
GUI Terminal と SSH はそれぞれ Aqua と Background という別の launchd domain に入り、利用可能なリソースが変わる

まず実験:同じアカウントなのに、答えは 2 つ

Mac の前で Terminal.app を開き、launchctl managername を実行すると Aqua と表示されます。次に別のマシンから SSH 接続して同じユーザーで同じコマンドを叩くと、今度は Background と返ってきます。シェルを開く入り口が違うだけで、launchd は私たちを完全に別の domain に振り分けているわけです。

# GUI Terminal で実行
$ launchctl managername
Aqua

# SSH 接続後に実行
$ launchctl managername
Background

Aqua はグラフィカルなユーザーセッション内にいることを意味し、Background は同じ uid に属するけれど GUI を持たない launchd domain にいることを意味します。これらは単なるラベルではなく、launchd が実際にどのサービスを起動するか、キーチェーンを解錠するか、TCC 権限がどう継承されるか、pbcopy が pasteboard server に到達できるか、といった一連の挙動を決定するために使う識別子です。

launchd domain とは

macOS の launchd(pid 1)はシステムを複数の domain に分割し、それぞれが自身のサービスと環境を管理します。よく登場するのは次の 4 つです。

Domain 識別子managername説明
systemSystemroot デーモンの居場所。/Library/LaunchDaemons はここで動く
gui/<uid>Aquaユーザーが loginwindow から GUI ログインを完了したときにのみ生成される。loginwindow がここで login キーチェーンを解錠する
user/<uid>Backgroundユーザーレベルだが GUI に紐付かない。SSH で入るとここに落ちる
login/<asid>LoginWindow / StandardIOloginwindow 表示中、あるいは stdio/getty などの特殊セッション

~/Library/LaunchAgents に置く plist は「ユーザーエージェント」ですが、gui/<uid> に読み込むか user/<uid> に読み込むかで挙動は変わります。brew services はデフォルトで gui/<uid> にサービスを登録するため、SSH 経由で brew services list を打つと GUI と状態が食い違って見えることがあります。これは domain ごとにサービステーブルが完全に分かれているためです。

# GUI セッションに読み込まれているサービス一覧
launchctl print gui/$(id -u) | head

# 非 GUI のユーザー domain のサービス一覧
launchctl print user/$(id -u) | head

# エージェントを GUI domain に手動で読み込む
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.plist

GUI Terminal と SSH セッションの実測差分

実際に踏みやすい差分を一覧にまとめます。どの行も ~/.zshrc の違いではなく、launchd domain が違うことから来ています。

挙動項目GUI Terminal(Aqua)SSH セッション(Background)
login キーチェーンloginwindow が解錠済みロック中。security unlock-keychain が必要
sudo の TouchIDpam_tid.so が使えるGUI ダイアログを出せず利用不可
TCC のプライバシー権限Terminal.app の許可を継承sshd 自身の TCC エントリで判断、デフォルトはほぼ空
pbcopy / pbpasteシステムクリップボードに直接アクセスGUI ログインがあればそのクリップボードに書き込む。なければ失敗
osascript のダイアログユーザーの画面に表示されるGUI ログインがある場合、launchctl asuser を介す必要あり
通知センター(terminal-notifier正常に動作NotificationCenter に到達できず失敗することが多い
環境変数launchd ユーザーセッションを継承(launchctl setenv で設定したものも見える)sshd が転送した変数とシェルの起動ファイルから export されたものだけ
特徴的な変数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 の公式ドキュメントにはほとんど記載がなく、たいていは他人の落とし穴記事を経由してたどり着きます。

GUI Terminal から ls ~/Desktop を実行すると TCC は Terminal.app を主体として許可するが、SSH から実行すると主体は sshd になり拒否される
GUI Terminal から ls ~/Desktop を実行すると TCC は Terminal.app を主体として許可するが、SSH から実行すると主体は sshd になり拒否される

キーチェーン解錠はこの問題のひとつの分岐にすぎない

前回のキーチェーン記事で扱ったのは、上の表の最初の行 — login キーチェーンは gui/<uid> domain でしか loginwindow に解錠されない、という話でした。SSH で入ると user/<uid> に落ち、キーチェーンはロック状態のままなので、Claude Code、ghgit の osxkeychain helper など、キーチェーンからトークンを読み出すツールはことごとく「未ログイン」と表示されます。表の他の行も本質的には同じ設計から派生しており、見た目が違うだけです。

言い換えると、Mac を GUI ユーザーがいる個人のマシンとして使う限り、Apple のこの設計は問題ありません。日常は GUI ログイン下に存在し、すべての権限・リソースが解錠されているので、何も気になりません。しかし Mac をサーバーとして長期間 SSH で操作するようになると、この設計は予想外の落とし穴の連続になります。

SSH の権限が引っかかったときの回避策

根本原因が分かったところで、実務でよく使う回避策を「安全だが面倒」から「最も自動化された」順に並べます。それぞれにコストがあり、Mac が個人用か共有か、手元にあるかどうかで選び分けます。

方法 1:画面共有で GUI に入って Terminal を開く(安全だが面倒)

最も直接的なやり方 — Background domain に SSH して権限を回避するくらいなら、画面共有で GUI に入ってしまえば話が早いです。システム設定 → 一般 → 共有 で「画面共有」を有効にし、別の Mac の Finder サイドバーから「画面を共有」をクリック、または vnc:// プロトコルで接続すれば、対象 Mac のデスクトップがそのまま表示されます。そこで Terminal.app を開けば launchctl managernameAqua を返し、キーチェーン・TCC・pbcopy はすべて正常に動作します。問題そのものを根本から回避できます。

私自身の mac mini ではこれを緊急時の手段としてよく使いますが、日常的には使いたくありません — 画面を再描画する必要があり、純粋な SSH より遅延が大きく、キーボードショートカットがローカルマシンと衝突し、ローカルのエディタからコマンドを直接走らせることもできず、1 行のコマンドを叩くためにリモートデスクトップを開くのは大げさです。ログインやパスワード入力など GUI が必須の作業に向き、日常運用には向きません。

方法 2:自動ログイン + キーチェーンの自動ロック時間を延ばす

Mac が自宅で個人用に動かすサーバーなら、最も省力なのはシステム設定 → ユーザー で自動ログインを有効にする方法です。起動後に loginwindow が自動的に GUI ログインを完了させ、gui/<uid> domain が立ち上がり、キーチェーンが解錠され、関連する TCC の許可も揃います。その後 SSH で入っても user/<uid> に落ちますが、キーチェーンの解錠状態は domain 間で共有されているため、トークンを問題なく読み取れます。詳しくは キーチェーン未解錠の記事 で解説しています。

注意:自動ログインはキーチェーンと GUI 関連リソースの問題だけを解決し、sshd に TCC 権限を自動付与するわけではありません。~/Desktop などの保護領域は方法 3 が必要です。

方法 3:sshd に TCC 権限を補う

システム設定 → プライバシーとセキュリティ → フルディスクアクセスを開き、sshd-keygen-wrapper(パスは /usr/libexec/sshd-keygen-wrapper、Finder で Cmd+Shift+G を押して入力)を追加します。これで SSH セッションから ~/Desktop~/Library/Mail~/Library/Messages などの保護領域にアクセスしても Operation not permitted は出なくなります。

ただし、これは「SSH に成功した身元すべて」にフルディスクアクセスを与えることに等しいので、共有マシンや多人数環境では絶対に避けてください。鍵認証のみ、パスワードログイン無効、AllowUsers の制限を組み合わせて初めて妥当な安全性が得られます。特定フォルダだけが必要であれば、「ファイルとフォルダ」権限から個別に許可した方が粒度が細かくなります。

方法 4:GUI が必要なコマンドは launchctl asuser に渡す

SSH 接続後に GUI アプリを起動したい、または Aqua セッションでないと意味のない osascript を走らせたい場合は、launchctl asuser で対象 uid の GUI セッションにコマンドをディスパッチできます。

# uid 501 の Aqua セッションとして実行
launchctl asuser 501 osascript -e 'display notification "Hello" with title "From SSH"'

# 比較:SSH から直接通知を出してもたいてい何も届かない
osascript -e 'display notification "Hello" with title "From SSH"'

運用スクリプトやデプロイスクリプトから GUI 動作を呼び出したいときに便利です。注意点として、GUI ログインが誰もいない状態だと launchctl asuser は対象セッションを見つけられず失敗します。これも自動ログインを推奨する理由のひとつです。

方法 5:zshrc にセッション種別を表示してデバッグしやすくする

launchctl managername がこれほど重要な指標なら、いっそシェルの起動ファイルに書いておき、シェルを開くたびに今のセッション種別を見えるようにしておけば、奇妙な挙動に出会ったときに domain の違いが原因かを一目で判断できます。

# ~/.zshrc に追加
if [[ -o interactive ]]; then
  printf "launchd domain: %s\n" "$(launchctl managername 2>/dev/null)"
fi

まとめ

GUI Terminal と SSH terminal の最大の境界線はシェルではなく、launchd domain です — Aqua(gui/<uid>)か Background(user/<uid>)か。そこからキーチェーン、TCC、pbcopy、TouchID、osascript、通知など一連の差分が枝分かれします。次に SSH で Mac に入って「GUI では動くのにここでは動かない」状況に遭遇したら、まず launchctl managername を打てば、domain の違いが原因かどうかを瞬時に判断できます。キーチェーンの行を具体的にどう踏むかは、macOS で SSH した Claude Code が未ログインになる|keychain 解錠の解決策 を参照してください。