同一条 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 拒绝?
两次执行通常属于不同的登录会话、启动域或责任进程。TCC 授权不会仅按脚本路径继承,应确认 CI 运行用户、gui 会话以及真正承载脚本的宿主进程是否与授权对象一致。
可以直接删除 TCC 数据库来修复权限吗?
不建议。应先停止相关任务,再由受影响用户使用 tccutil reset 重置指定服务,并在有效图形会话中重新授权。直接修改或删除数据库会扩大影响范围,也难以形成可审计的恢复步骤。
在独享 Apple Silicon 物理节点上运行构建
按机型、区域和租用周期配置节点,实际可用状态以控制台实时返回为准。