동일한 UI 자동화 명령이 SSH에서 수동으로 실행할 때는 정상적으로 작동하지만, CI runner에 넣으면 스크린샷을 찍거나 창을 클릭하거나 다른 애플리케이션에 이벤트를 보내지 못하는 경우가 있습니다. 작업을 다시 실행해도 대개 해결되지 않습니다. 실패 원인이 스크립트 로직이 아니라 macOS의 투명성, 동의 및 제어 메커니즘인 TCC에 있기 때문입니다. TCC는 단순히 “어떤 사용자가 실행했는가”만 확인하지 않습니다. 명령이 속한 GUI 세션, 책임 프로세스, 해당 프로세스의 코드 ID가 변경되었는지도 함께 판단합니다.
TCC 장애인지 먼저 판별하기
TCC 문제는 드라이버 오작동, 창 실행 실패, 명령 시간 초과로 오인되기 쉽습니다. 먼저 작업을 순수 명령줄 단계와 GUI 권한이 필요한 단계로 나누십시오. 빌드, 저장소 읽기, 일반 네트워크 요청은 정상인데 스크린샷, 손쉬운 사용 제어, 자동화 이벤트만 실패할 때 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> 자체가 존재하지 않는다면, 해당 작업에는 사용할 수 있는 GUI 로그인 도메인이 없습니다. 이 상태에서는 권한을 반복해서 초기화해도 의미가 없습니다. 먼저 작업이 시작되는 위치를 바로잡아야 합니다.
“SSH에서 실행된다”는 사실은 셸, 경로, 파일 권한을 사용할 수 있다는 의미일 뿐입니다. 해당 프로세스에 화면 기록, 손쉬운 사용 또는 애플리케이션 자동화 권한이 있다는 뜻은 아닙니다.
시스템 로그에서 거부 주체 확인하기
실패를 재현한 직후 짧은 시간 범위의 로그를 조회하여 관련 없는 기록에 묻히지 않도록 합니다.
log show --last 5m \
--predicate 'subsystem == "com.apple.TCC"' \
--style compact
요청된 서비스, 클라이언트 경로, 책임 프로세스, 거부 결과를 중점적으로 확인하십시오. 스크립트 이름만 검색해서는 안 됩니다. 셸 스크립트는 터미널, runner, osascript 또는 테스트 호스트에서 시작되는 경우가 많으며, 실제로 권한이 필요한 대상은 이를 호스팅하는 상위 프로세스일 수 있습니다.
네 가지 ID 기준선 만들기
각 클라우드 Mac에는 비밀 정보를 포함하지 않는 권한 기준선을 저장해야 합니다. 최소한 실행 사용자, 시작 도메인, 실행 파일의 실제 경로, 코드 서명 ID를 포함하십시오. 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"
경로가 같다고 해서 ID도 같은 것은 아닙니다. 파일을 같은 위치에서 덮어쓰거나, 임시로 내려받은 서명되지 않은 도구를 사용하거나, 여러 버전이 동일한 심볼릭 링크를 공유하게 하면 TCC가 인식하는 클라이언트가 달라질 수 있습니다. 더 안정적인 방법은 각 버전을 별도 디렉터리에 배치하고, 제어된 전환 절차로 진입점을 업데이트한 다음 서명 검사를 다시 실행하는 것입니다.
“책임 프로세스”가 무엇인지도 명확히 해야 합니다. 예를 들어 runner가 셸을 시작하고 셸이 다시 osascript를 실행하여 GUI 애플리케이션을 제어한다면, 권한 부여 대상은 저장소 안의 스크립트가 아닐 수 있습니다. 문제를 조사할 때는 관련 없는 여러 도구의 권한을 한꺼번에 완화하지 말고 PPID를 따라 상위 프로세스를 추적하십시오.
올바른 GUI 세션에서 작업 실행하기
데스크톱을 조작해야 하는 작업을 LaunchDaemon으로 실행해서는 안 됩니다. LaunchDaemon은 시스템 도메인에 속하므로 지정된 사용자로 실행하더라도 해당 사용자의 Aqua 세션에 진입한 것과 같지 않습니다. runner를 해당 사용자의 LaunchAgent로 등록하고 GUI 로그인 후 로드하는 방식이 더 적절합니다.
<?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에서 도메인을 찾을 수 없다는 오류가 반환되면 먼저 해당 사용자에게 활성 GUI 세션이 있는지 확인하십시오. 작업을 다른 사용자의 세션에 억지로 넣거나, 일회성 sudo 호출로 잘못된 소유 관계를 감추지 마십시오.
꼭 필요한 권한만 초기화하기
사용자, 세션, 프로세스 ID가 모두 올바른지 확인한 후에만 초기화를 고려하십시오. 초기화 도중 runner가 계속 권한을 요청하여 혼란을 일으키지 않도록 먼저 runner를 중지합니다. 그런 다음 영향을 받는 사용자가 필요한 서비스만 지정하여 초기화합니다.
launchctl bootout \
"gui/$(id -u)/com.example.ci-runner"
tccutil reset Accessibility
tccutil reset ScreenCapture
tccutil reset AppleEvents
이 세 명령을 항상 함께 실행하는 고정 절차로 취급하지 마십시오. 작업에 화면 기록만 필요하면 ScreenCapture만 처리합니다. UI를 제어해야 할 때 Accessibility를 처리하고, 특정 애플리케이션에 자동화 이벤트를 보내야 할 때만 AppleEvents를 처리하십시오. 초기화 후에는 유효한 GUI 세션에서 최소 테스트를 한 번 실행하여 필요한 권한 부여를 완료한 뒤 runner를 다시 시작합니다.
화면 기록 권한이 변경된 후에도 기존 프로세스가 이전 권한 상태를 계속 유지할 수 있습니다. 저장소 스크립트만 다시 실행하지 말고 책임 프로세스를 완전히 종료한 후 재시작해야 합니다. 자동화 권한은 대상 애플리케이션별로 기록될 수도 있으므로 “애플리케이션 A를 제어할 수 있다”는 사실만으로 “애플리케이션 B도 제어할 수 있다”고 판단해서는 안 됩니다.
사용자 디렉터리의 TCC 데이터베이스를 직접 삭제하거나 SQLite 레코드를 수정하는 방식은 신뢰할 수 있는 복구 방법이 아닙니다. 이 방식은 더 많은 애플리케이션의 권한 결정을 제거하고 복구 과정의 감사도 어렵게 만듭니다. OrbVPS 클라우드 Mac은 전용 물리 노드이지만, 전용 환경이라는 사실이 macOS의 권한 모델을 바꾸지는 않습니다. 최소 권한 원칙은 여전히 실제 작업과 책임 프로세스에 맞게 적용해야 합니다.
복구 절차를 검수 항목으로 만들기
복구 직후 전체 파이프라인에 다시 투입하지 마십시오. 먼저 스크린샷 한 번 또는 자동화 이벤트 한 번만 수행하는 프로브를 실행한 다음 테스트 작업을 단계적으로 복원합니다. 다음 결과를 노드 검수 기록에 남기는 것이 좋습니다.
- runner 사용자와 콘솔 사용자가 일치합니다.
gui/<uid>도메인이 존재하고 LaunchAgent 상태가 running입니다.- runner의 실제 경로가 예상 버전과 일치합니다.
codesign출력이 기준선과 일치합니다.- TCC 로그에 대상 서비스에 대한 거부가 더 이상 나타나지 않습니다.
- runner를 재시작한 후에도 최소 프로브가 통과합니다.
- 전체 작업이 끝난 뒤 테스트 호스트나 자동화 프로세스가 남아 있지 않습니다.
마지막으로 노드 재시작 후의 복구 과정도 한 번 시뮬레이션합니다. GUI 세션이 아직 생성되지 않았다면 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 시작 도메인, 실행 파일 경로, 서명 ID를 고정하면 권한 문제를 간헐적인 장애가 아니라 검증하고 롤백할 수 있는 실행 조건으로 관리할 수 있습니다.
자주 묻는 질문
SSH에서는 성공하는 명령이 CI 서비스에서 TCC에 거부되는 이유는 무엇인가요?
두 실행이 서로 다른 로그인 세션, launchd 도메인 또는 책임 프로세스에 속할 수 있기 때문입니다. TCC 권한은 스크립트 경로만 같다고 자동으로 이어지지 않습니다.
TCC 데이터베이스를 직접 삭제해도 되나요?
권장하지 않습니다. 관련 작업을 중지한 뒤 영향을 받는 사용자로 필요한 서비스만 tccutil reset하고, 활성 GUI 세션에서 권한을 다시 승인해야 합니다.
전용 Apple Silicon 물리 노드에서 빌드 실행
기종, 지역 및 대여 기간에 따라 노드를 구성할 수 있으며, 실제 사용 가능 여부는 콘솔에서 실시간으로 확인되는 상태를 따릅니다.