Инженерные статьи

Диагностика и безопасный сброс прав macOS TCC в облачном Mac CI

Диагностика и безопасный сброс прав macOS TCC в облачном Mac CI

Одна и та же команда автоматизации интерфейса может нормально выполняться вручную через 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», подтверждает лишь доступность оболочки, путей и файловых разрешений. Это не означает, что процессу разрешены запись экрана, специальные возможности или автоматизация приложений.

Как найти источник отказа в системном журнале

Сразу после воспроизведения сбоя проверьте журнал за короткий период, чтобы нужные события не затерялись среди посторонних записей:

log show --last 5m \
  --predicate 'subsystem == "com.apple.TCC"' \
  --style compact

Обратите особое внимание на запрошенную службу, путь клиента, ответственный процесс и результат отказа. Не ограничивайтесь поиском по имени скрипта. Скрипты оболочки часто запускаются терминалом, 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 запускает оболочку, оболочка — 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 нельзя считать надёжным способом восстановления. Это удалит решения о доступе для большего числа приложений и затруднит аудит процесса восстановления. Облачные Mac OrbVPS работают на выделенных физических узлах, однако выделенный узел не меняет модель разрешений macOS. Принцип минимально необходимых прав по-прежнему должен применяться к фактическому заданию и ответственному процессу.

Превращение процедуры восстановления в приёмочный чек-лист

После восстановления не возвращайте узел сразу в полный конвейер. Сначала запустите пробу, которая делает только один снимок экрана или отправляет одно событие автоматизации, а затем поэтапно восстановите тестовые задания. Рекомендуется включить в протокол приёмки узла следующие результаты:

  1. Пользователь runner совпадает с консольным пользователем.
  2. Домен gui/<uid> существует, а LaunchAgent имеет статус running.
  3. Реальный путь runner соответствует ожидаемой версии.
  4. Вывод codesign совпадает с базовым профилем.
  5. В журнале TCC больше нет отказов для целевой службы.
  6. После перезапуска runner минимальная проба по-прежнему проходит успешно.
  7. После завершения полного задания не остаются тестовые хосты или процессы автоматизации.

В завершение один раз смоделируйте восстановление после перезагрузки узла. Если графическая сессия ещё не создана, 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, но блокируется TCC внутри службы CI?

Запуски могут относиться к разным сеансам входа, доменам launchd или ответственным процессам. TCC не переносит разрешение только потому, что путь к сценарию остался прежним.

Можно ли удалить базу TCC напрямую, чтобы восстановить разрешения?

Не следует. Остановите затронутые задания, выполните tccutil reset только для нужной службы от имени рабочего пользователя и повторно выдайте разрешение в активной графической сессии.

OrbVPS облачный Mac

Запускайте сборки на выделенных физических узлах Apple Silicon

Настройте узел по модели, региону и сроку аренды. Фактическая доступность отображается в панели управления в реальном времени.

Арендовать облачный Mac