同一條 UI 自動化命令透過 SSH 手動執行時一切正常,放進 CI runner 後卻無法截圖、點按視窗,或向另一個應用程式傳送事件。重新執行工作通常無濟於事,因為問題不在指令碼邏輯,而在 macOS 的「透明度、同意與控制」機制,也就是 TCC。它判斷的不只是「由哪個使用者執行」,還包括命令屬於哪個圖形工作階段、由哪個程序負責,以及該程序的程式碼身分是否發生變化。
先判斷是否為 TCC 故障
TCC 問題經常被誤判為驅動程式失效、視窗未啟動或命令逾時。首先將工作拆成兩層:純命令列步驟,以及需要圖形權限的步驟。如果編譯、讀取儲存庫和一般網路請求都正常,只有截圖、輔助使用控制或自動化事件失敗,才值得從 TCC 方向著手檢查。
先記錄執行環境:
id
whoami
printf 'console_user=%s
' "$(stat -f '%Su' /dev/console)"
printf 'uid=%s
' "$(id -u)"
launchctl print "gui/$(id -u)" >/tmp/gui-domain.txt 2>&1
ps -axo user,pid,ppid,command | grep -E 'runner|xcodebuild|osascript' | grep -v grep
如果 /dev/console 的使用者不是 runner 使用者,或 gui/<uid> 根本不存在,工作就沒有可用的圖形登入網域。此時反覆重設權限沒有意義,應先修正工作的啟動位置。
「可透過 SSH 執行」只能證明 shell、路徑和檔案權限可用,並不代表該程序擁有螢幕錄製、輔助使用或應用程式自動化權限。
從系統日誌確認拒絕來源
重現失敗後,立即查詢較短時間範圍內的日誌,以免被無關記錄淹沒:
log show --last 5m \
--predicate 'subsystem == "com.apple.TCC"' \
--style compact
重點查看所要求的服務、用戶端路徑、責任程序與拒絕結果。不要只搜尋指令碼名稱。Shell 指令碼通常由終端機、runner、osascript 或測試宿主發起,真正需要授權的可能是承載它的上游程序。
建立四項身分基準
每台雲端 Mac 都應保存一份不含金鑰的權限基準,至少涵蓋執行使用者、啟動網域、執行檔真實路徑與程式碼簽署身分。升級 runner 或替換二進位檔後,再與基準進行比對。
RUNNER="/opt/ci/bin/runner"
ls -l "$RUNNER"
realpath "$RUNNER"
codesign -dv --verbose=4 "$RUNNER" 2>&1
codesign -dr - "$RUNNER" 2>&1
spctl --assess --type execute --verbose=4 "$RUNNER"
路徑相同不代表身分相同。直接覆寫原檔、暫時下載未簽署的工具,或讓不同版本共用同一個符號連結,都可能改變 TCC 所辨識的用戶端。更穩妥的做法是將不同版本放入各自獨立的目錄,再透過受控切換更新入口,並在切換後重新執行簽署檢查。
此外,也必須釐清誰是「責任程序」。例如 runner 啟動 shell,shell 再啟動 osascript;最終控制圖形應用程式時,授權對象可能並不是儲存庫裡的指令碼。排查時應沿著 PPID 向上追蹤,而不是同時放寬多個無關工具的權限。
讓工作進入正確的圖形工作階段
需要操作桌面的工作不應以 LaunchDaemon 執行。LaunchDaemon 屬於系統網域,即使指定使用者啟動,也不代表程序已進入該使用者的 Aqua 工作階段。較合適的方式是將 runner 註冊為該使用者的 LaunchAgent,並在圖形登入後載入。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.ci-runner</string>
<key>ProgramArguments</key>
<array>
<string>/opt/ci/bin/runner</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>StandardOutPath</key>
<string>/Users/ci/Library/Logs/ci-runner.log</string>
<key>StandardErrorPath</key>
<string>/Users/ci/Library/Logs/ci-runner-error.log</string>
</dict>
</plist>
先使用 plutil -lint 驗證檔案,再由目標使用者載入:
plutil -lint "$HOME/Library/LaunchAgents/com.example.ci-runner.plist"
launchctl bootstrap "gui/$(id -u)" \
"$HOME/Library/LaunchAgents/com.example.ci-runner.plist"
launchctl print "gui/$(id -u)/com.example.ci-runner"
如果 bootstrap 回報找不到網域,應先確認該使用者已有作用中的圖形工作階段。不要強行將工作塞進其他使用者的工作階段,也不要依賴一次性的 sudo 呼叫來掩蓋歸屬錯誤。
只重設真正需要的權限
確認使用者、工作階段與程序身分都正確後,再考慮重設。先停止 runner,避免它在重設期間持續提出要求而造成混亂。接著由受影響的使用者執行僅限特定服務的重設:
launchctl bootout \
"gui/$(id -u)/com.example.ci-runner"
tccutil reset Accessibility
tccutil reset ScreenCapture
tccutil reset AppleEvents
不要將這三項命令視為固定組合。如果工作只需要螢幕錄製,就只處理 ScreenCapture;需要操控介面時再處理 Accessibility;只有在需要向特定應用程式傳送自動化事件時,才處理 AppleEvents。重設後,應在有效的圖形工作階段中觸發一次最小化測試,完成必要授權,再重新啟動 runner。
螢幕錄製權限變更後,舊程序可能仍保留先前的權限狀態。應完全結束並重新啟動責任程序,而不是只重新執行儲存庫中的指令碼。自動化權限也可能依目標應用程式分別記錄,因此「可以控制應用程式 A」並不代表「也可以控制應用程式 B」。
直接刪除使用者目錄下的 TCC 資料庫,或修改 SQLite 記錄,都不是可靠的修復方式。這麼做會清除更多應用程式的授權決定,也會使復原流程難以稽核。OrbVPS 雲端 Mac 採用獨享實體節點,但獨享並不會改變 macOS 的權限模型;最小權限原則仍應落實到實際工作與責任程序。
將復原流程納入驗收項目
復原後不要立即重新投入完整流水線。先執行一個只進行單次截圖或單次自動化事件的探針,再逐步恢復測試工作。建議將下列結果寫入節點驗收記錄:
- runner 使用者與主控台使用者一致。
gui/<uid>網域存在,LaunchAgent 狀態為 running。- runner 真實路徑與預期版本一致。
codesign輸出與基準一致。- TCC 日誌不再出現目標服務的拒絕記錄。
- runner 重新啟動後,最小化探針仍能通過。
- 完整工作結束後,沒有遺留測試宿主或自動化程序。
最後再模擬一次節點重新啟動後的復原流程。如果圖形工作階段尚未建立,runner 應保持無法接收工作的狀態,而不是先接收工作,再卡在截圖或視窗控制階段。可在工作入口加入工作階段檢查,失敗時輸出明確診斷並結束:
uid="$(id -u)"
if ! launchctl print "gui/$uid" >/dev/null 2>&1; then
printf '%s
' "No active GUI session for CI user"
exit 75
fi
TCC 故障最難處理的部分不是按下授權按鈕,而是找出權限究竟應授予誰。固定執行使用者、LaunchAgent 啟動網域、執行檔路徑與簽署身分後,權限問題就能從偶發故障轉化為一套可驗證、可回復的執行條件。
常見問題
為什麼透過 SSH 執行成功,同一命令在 CI 服務中卻被 TCC 拒絕?
兩次執行可能屬於不同的登入工作階段、launchd 網域或責任程序。即使使用者相同,TCC 也不會只根據腳本路徑繼承授權。
可以直接刪除 TCC 資料庫來修復權限嗎?
不建議。應先停止相關工作,再由受影響使用者使用 tccutil reset 重設指定服務,最後在有效的圖形工作階段中重新授權。
在獨享 Apple Silicon 實體節點上執行建置
依機型、區域與租用週期設定節點,實際可用狀態以控制台即時回傳結果為準。