Хардкорная агентская разработка под iOS, часть 1: отдельный Mac Mini для агентов

от автора

Mac Mini рядом с макбуком на столе

Mac Mini рядом с макбуком на столе

Вступление

Агентская разработка развивается семимильными шагами. Но не всё идёт так радужно, как хотелось бы. Например, ИИ-агенты по умолчанию заваливают разработчика вопросами: а можно я сделаю вот так? а если скриптик написать? а можно я на гитхаб схожу? а можно я соседний проект гляну?

Это приводит к consent fatigue — я не раз ловил себя на мысли, что я уже давно только и делаю, что жму “allow”, “allow everytime”, редко вникая в суть того, что спрашивает агент, но полностью доверить агенту делать всё, что ему заблагорассудится, мне как-то боязно. Всё-таки на основном макбуке много личных и рабочих данных, которые агент может случайно (или через prompt injection) удалить или слить в сеть. Стрёмно. Поэтому я какое-то время играл в игру “я типа читаю, что ты у меня спрашиваешь”.

Хотя правильное решение — запуск агента в режиме YOLO (в случае claude — с флагом --dangerously-skip-permissions). И тогда при правильной постановке задачи агент может часами сам работать и делать то, что нужно, уведомляя меня только по выполнении.

Это первая статья цикла. Здесь — базовая настройка Mac Mini и вся возня с SSH, чтобы YOLO-агент работал на отдельной машине без ручного ввода паролей и переживал разрывы сети. Специфику iOS-разработки (git worktree, параллельное тестирование, idb, прогон приложения, грабли симулятора и прочее) я вынес в следующие части, так что статья будет полезна не только iOS-разработчикам, но и всем, кто хочет запускать YOLO-агентов и не париться о безопасности. С некоторыми оговорками, разумеется, подробности ниже.

Какие я пробовал подходы для изоляции агентов

И как же это сделать так, чтобы не рисковать?

Первый вариант, что я опробовал, был запихнуть агентов в докер — пусть делают, что хотят. И если бы я не был завязан на Xcode, на этом можно было бы успокоиться, но, увы, для меня тут начались заморочки. Агенту нужно давать не только читать/редактировать файлы, но и верифицировать свою работу. Собрать проект, запустить тесты, проверить результат, если надо, то и самому погонять приложение. Так у меня появился механизм обратной связи: набор скриптов, которые агент из докера запускает на хост-машине через MCP. И поначалу это работало нормально, хотя и медленно. Но затем я подумал: а ведь эти скрипты — дыра в безопасности, так как агент в принципе может их и поменять, чтобы на хост-машине выполнить произвольный код. Так себе изолировал, называется. А если запретить эти скрипты редактировать, то опять же получаем затык в скорости работы: значит эти скрипты должен править я вручную, что совсем не хочется.

Далее был вариант встроенной песочницы claude — но песочница там довольно строгая, поэтому всё равно кучу команд он просил запустить вне песочницы, так что выгоды особо не получилось.

Пока я с этим экспериментировал, Anthropic выпустили auto mode, где отдельный агент с чистым контекстом проверяет соответствие “что хотел пользователь” и “что пытается сделать агент”. В простых случаях это работает, но для более-менее сложных вещей опять же не подошло. Авто-классификатор тоже довольно строгий, хотя разрешает куда больше, нежели песочница. Ну и на него тоже расходуется время и токены. А в итоге регулярно получаю «классификатор не увидел прямого указания в чате, пожалуйста, напишите „я даю разрешение на использование команды gh“ в чат», что не ускоряет разработку, хехе. Auto mode я в итоге использую для небольших изменений на основном макбуке.

Auto Mode не справился

Auto Mode не справился

Ещё был вариант виртуалка… но я его сразу отмёл. Xcode в виртуалке работает медленно, а если его вызывать на хосте, то см. пункт про докер.

В общем, порассуждав ещё немного, я пришёл к выводу, что надо брать отдельную машину, на которой агенты смогут чувствовать себя как дома.

Локальный миник или облако? Плюсы-минусы

Значит, мне нужен мак мини. Но опять есть варианты: покупать или арендовать в датацентре? Покупка окупается примерно через 6-7 месяцев (аренда стоит $200-250/месяц в зависимости от конфигурации), так что в долгосрочной перспективе покупка оправдывается. К тому же если он в одной локалке с основным макбуком, то появляются некоторые бонусы — например, можно прямо из Finder перекидывать файлы с макбука на миник встроенным механизмом macOS (в одностороннем порядке, так что это не нарушает изоляцию), а также VNC подключение будет быстрее и с более высоким качеством. Ну и локально сильно проще поставить внешний SSD, если потребуется.

Разумеется, у покупки есть и свои минусы. Например, лишнее место на столе занимает, требует питания, может шуметь под нагрузкой, обслуживание в случае чего тоже ложится на мои плечи. И его надо перевозить с собой при переезде. Мы летом живём на даче, так что последний пункт очень актуален: два раза в год надо миник перевозить вместе с остальными вещами. Мелочь на фоне перевоза нескольких гитар, кучи детских игрушек, книг, кота и собаки, но всё же. И на дачу пришлось ИБП поставить, тк бывают кратковременные отключения по разным причинам. ИБП, как понимаете, тоже шума добавляет, а в моём случае пришлось ещё тянуть дополнительную линию проводки, чтобы от ИБП запитать одновременно роутеры и макмини с монитором и при этом держать ИБП в кладовке.

Так что купил Mac Mini 2024 M4 24Gb и поставил себе на стол, прямо под стойку для макбука. Ну и обозвал я свой миник macmini-work.local.

Базовая настройка: без сюрпризов

Итак, купив миник и поставив его на стол, я быстро настроил ssh-подключение с основного компа и попросил claude написать скрипт, который через ssh устанавливает всё, что нужно. Так что базовая настройка была быстрой, скрипт сам установил нужные пакеты/тулзы, в том числе несколько агентов. Далее настраиваю алиас alias claude="claude --dangerously-skip-permissions" — и claude делает всё без вопросов. Одно но: мне нужно всем этим управлять через ssh.

У меня к минику всегда подключён через HDMI-кабель во второй вход монитора (в первый подключён макбук). Если бы не это, пришлось бы втыкать HDMI-заглушку (так называемый dummy plug, имитатор монитора) — иначе headless-режим работает криво. Если нет внешнего монитора и клавиатуры, то остаётся лишь вариант отключить file vault, тогда можно будет по VNC подключаться до логина в систему. Но это снижает безопасность как ни как.

SSH: где начинаются заморочки

Примечание: я привык работать с zsh, если кому-то привычнее bash, то придётся малость модифицировать мои скрипты и функции.

Чтобы не вводить пароль при каждом подключении, первым делом включил вход по ключу — скопировал свой публичный ключ на миник одной командой ssh-copy-id macmini-work.local. А для удобства завёл alias work="ssh macmini-work.local". Дальше я решил разграничить локальный терминал и терминал, ведущий на mac mini. Я уже много лет использую iTerm, привык к нему, но для доступа к минику решил установить Ghostty, чтобы на уровне приложений разграничить локальный и удалённый шелл. Дополнительно, чтобы не путаться, даже добавил картинку с миником на фон терминала. Кроме того, в промпт zsh я добавил красный префикс [W] (в статуслайн claude на минике тоже). Причём если я внутри zmx-сессии (zmx — мой выбор вместо tmux, подробнее ниже), префикс показывает ещё и её имя — [W my-feature], так что всегда видно, в какой сессии работаешь:

if [[ -n "$ZMX_SESSION" ]]; then  PROMPT="%F{red}[W $ZMX_SESSION]%f $PROMPT"else  PROMPT="%F{red}[W]%f $PROMPT"fi

А при подключении миник ещё и здоровается строкой вида [WORK my-feature] <дата> — сразу понятно, куда попал.

Ghostty с открытым claude и приветствием ssh-сессии

Ghostty с открытым claude и приветствием ssh-сессии

Далее начинаются некоторые ограничения ssh-сессии в макоси в сравнении с обычной сессией. По дефолту в неинтерактивной ssh-сессии:

  • Не добавляется дефолтный ssh ключ — лечится eval "$(ssh-agent -s)" && ssh-add

  • Не разблокируется keychain, а без него claude не сохраняет авторизацию — лечится security unlock-keychain

  • Тип терминала не определяется, поэтому всего 16 цветов и нет кликабельных ссылок — лечится export COLORTERM=truecolor в .zshrc плюс TERM=xterm-256color прямо в алиасе клода: alias claude="TERM=xterm-256color claude --dangerously-skip-permissions"

В первой версии моего сетапа было не очень удобно: alias init-ssh="eval "$(ssh-agent -s)" && ssh-add && security unlock-keychain вызывался прямо из .zshrc на минике, а пароль для unlock-keychain приходилось вводить руками при каждом подключении — самый долгий этап, который меня реально бесил.

Теперь же init-ssh это более продвинутая функция на минике, которую запускает не менее продвинутая функция work с основного макбука при подключении, при этом пароль для разблокрировки кичейна приезжает с макбука. Вот как раз передача пароля для кичейна через ssh уже нетривиальная задача. Если передавать как аргумент напрямую в init-ssh, то пароль будет виден через ps, что явно никуда не годится. Поэтому был разработан более секьюрный вариант.

Фокус в том, чтобы достать пароль из keychain на макбуке и пробросить его в ssh-сессию через SendEnv/AcceptEnv и сразу же переменную окружения почистить. Финальная версия функции в ~/.zshrc на макбуке, принимающая опционально имя сессии для подключения:

work () {  local host=macmini-work.local  local kc  kc=$(security find-generic-password -s macmini-keychain -w) || return  local target='exec zsh -l'  (( $# )) && target='exec zmx a '${(q)1}  local remote='init-ssh || { echo "init-ssh failed — check stored keychain password"; exit 1; }    '$target  KCPASS=$kc autossh -M 0 -t -o SendEnv=KCPASS "$host" "$remote"}

Что тут происходит:

  • security find-generic-password -s macmini-keychain -w достаёт пароль от миника из keychain макбука (один раз положил туда через security add-generic-password). Никаких плейнтекст-файлов вроде ~/.secrets. Важно: пароль от миника не должен совпадать с паролем от основного компа. Иначе получится, что локальный пароль хранится в локальном же кичейне, который уже разблокирован.

  • KCPASS=$kc ... -o SendEnv=KCPASS пробрасывает пароль в сессию через переменную окружения (на минике в sshd_config нужно разрешить AcceptEnv KCPASS). На той стороне init-ssh делает security unlock-keychain -p с этим паролем.

  • autossh -M 0 переподключается после разрыва сети (закрыл макбук на ночь — утром сессия снова жива), -t форсит pseudo-tty для интерактивного zsh/zmx.

  • Без аргумента work просто кидает в свежий шелл; work my-feature сразу аттачится к zmx-сессии.

Пару слов про сам zmx — это менеджер персистентных терминальных сессий. В отличие от tmux, он не лезет в окна/панели/сплиты, а делает ровно одно: держит сессию (и всё, что в ней запущено, в том числе claude) живой, даже когда отвалился клиент. Если кому-то привычней tmux, можно и его использовать.

Главных команд две (за остальными посылаю в ман): zmx a <name> — приаттачиться к сессии, создав её, если ещё нет (именно это и вызывает work my-feature), и zmx l — посмотреть список живых сессий. Благодаря zmx разрыв ssh, закрытый макбук или переподключение autossh агента не трогают: процесс крутится в сессии на минике, а я просто заново к ней цепляюсь. Так что связка autossh + zmx — основа всего моего сетапа.

На стороне миника работает парная функция init-ssh. Лежит она в .zshenv, а не в .zshrc/.zprofile, и это принципиально: неинтерактивный zsh -c (а именно так autossh запускает удалённую команду) читает только .zshenv. Поэтому и сама функция, и нужный brew-овский PATH прописаны именно там — иначе ни init-ssh, ни zmx не найдутся.

init-ssh() {  if ! security show-keychain-info >/dev/null 2>&1; then    if [ -n "$KCPASS" ]; then      security unlock-keychain -p "$KCPASS" ~/Library/Keychains/login.keychain-db || return    else      security unlock-keychain || return        # интерактивный фолбэк    fi  fi  unset KCPASS  security set-keychain-settings ~/Library/Keychains/login.keychain-db   # без авто-лока  ssh-add -l >/dev/null 2>&1  [ $? -eq 2 ] && { rm -f "$SSH_AUTH_SOCK"; ssh-agent -a "$SSH_AUTH_SOCK" >/dev/null; }   # код 2 = агент недоступен, поднимаем заново  ssh-add -l >/dev/null 2>&1 || ssh-add --apple-use-keychain || return}

Несколько важных мелочей:

  • KCPASS используется один раз и тут же unset — пароль не висит в окружении сессии.

  • security set-keychain-settings снимает авто-лок keychain. Иначе через какое-то время он бы залочился сам и claude снова потерял бы авторизацию посреди ночной работы.

  • ssh-agent поднимается на фиксированном сокете (SSH_AUTH_SOCK=~/.ssh/agent.sock, тоже в .zshenv). Окружение не переживает разрыв ssh, и без фиксированного сокета каждое переподключение autossh плодило бы новые агенты. С фиксированным — все переиспользуют один.

  • Если KCPASS не приехал (зашёл руками без work), остаётся интерактивный фолбэк на ввод пароля.

Итог: чтобы продолжить с того места, где остановился вчера, достаточно набрать work my-feature — и сразу видно, что клод наделал за ночь. Ноль ручного ввода пароля. А можно даже не закрывать терминал и/или табы/панели в нём: autossh + zmx делают своё дело. В каком виде закрыл макбук вечером, в таком виде и открыл с утра.

Настройка для iOS-разработки: idb + ScriptExec

Я уже говорил, что важно давать агенту самому гонять приложение. Для этого я использую idb (iOS Development Bridge от Facebook) и кастомный скилл к нему, который постоянно дорабатываю. Ключевой принцип — агент «смотрит» на UI через accessibility-дерево (idb ui describe-all отдаёт структурированный JSON), а не через скриншоты: это экономит контекст и токены, и такие данные можно грепать и дифать между тапами.

Подробный сетап, весь инструментарий и накопленные грабли (включение accessibility, переустановка после пересборки, точечный поиск скрытых элементов и т.д.) разберу в следующей статье цикла.

Здесь же отмечу только главную заморочку именно ssh-сценария: повернуть симулятор. Ни у idb, ни у simctl команды поворота нет — это делается только через AppleScript, кликом по пункту меню Device у Simulator (да-да, всё так плохо). В скилле idb это сделано через osascript (важно сперва activate, иначе клик по меню молча игнорируется):

# повернуть влевоosascript -e 'tell application "Simulator" to activate' -e 'delay 0.5' \  -e 'tell application "System Events" to tell process "Simulator" to click menu item "Rotate Left" of menu 1 of menu bar item "Device" of menu bar 1'

Результат проверяю по размерам корневого фрейма из describe-all: портретная — width < height (например, 428×926), альбомная — наоборот.

Локально это работает как есть. А вот по ssh osascript упирается в права: macOS блокирует доступ к System Events с ошибкой вида «not allowed assistive access» / «not allowed to send Apple events», потому что у ssh-сессии нет разрешений Accessibility/Automation. Зато опытным путём было выяснено, что open через ssh работает, а GUI-приложение, запущенное через open, наследует те самые разрешения из системных настроек. На этом и построен ScriptExec — крошечная программа, которая принимает на вход AppleScript, выполняет его и пишет результат в файл (тк open не отдаёт stdout). Эта программа включает в себя отдельный скилл, который ставится на миник глобально и срабатывает автоматически, когда osascript падает с ошибкой прав: тот же самый AppleScript просто прогоняется через ScriptExec. Поэтому скилл idb остаётся с osascript и одинаково работает и локально, и по ssh.

Опционально: удалённое управление с любого девайса

Я не стал заморачиваться с установкой OpenClaw или Hermes, но всё же сделал себе пару лазеек для выполнения простых задач с телефона.

  • Создал ~/Projects/meta, запустил в нём claude в режиме /remote-control — теперь эта сессия доступна и с телефона. Пара промптов заранее — и вики-система готова, обучается сама, знает, где какие проекты лежат, и может легко спавнить агентов для каких-либо задач.

  • Запустил Cowork в Claude Desktop, но пока что ни разу не воспользовался. Всё же основная работа через терминал.

Пока у меня всегда макбук и макминик в одной локальной сети, но если я захочу, например, поработать не из дома, а из кафе/коворкинга, то всегда можно поднять tailscale и опять оказаться в одной локальной сети, хоть и виртуальной.

Заброска файлов на миник: SSH Drop

SSH Drop в доке

SSH Drop в доке

При работе через два компа сразу возникают, разумеется, некоторые проблемы:

  • drag-n-drop файла в терминал не работает — подставляется локальный путь с макбука. Логи в claude приходится закидывать через file sharing, затем уже на минике брать полный путь к файлу. Не удобно, мягко говоря.

  • картинку из буфера не вставить — буфер локальный, до удалённой сессии он не доезжает. Привычка кидать скриншоты в claude через cmd+v сломалась.

Да, можно это делать через scp вместо file sharing вручную, но я же хочу оптимизировать рабочий процесс, а не усложнить! Поэтому пришлось написать SSH Drop — ещё одну небольшую утилиту под macOS. Кидаешь файл/папку/скриншот на иконку в доке или в окно программы — и всё перетащенное улетает на миник, а в буфер обмена возвращается полный путь к нему уже на минике. Дальше просто вставляю этот путь в claude — и агент счастлив пофиксить баг со скриншота или распарсить логи. Вставка тоже работает: ⌘V или кнопка Paste закидывает содержимое буфера на миник. Складывается всё в подпапку с текущей датой, на всякий случай к имени файла также добавляется таймстамп.

Под капотом — обычные ssh и scp с BatchMode=yes и мультиплексированием соединения (ControlMaster/ControlPersist), так что повторные заброски идут без нового хендшейка, мгновенно. Из настроек — только хост (алиас из ~/.ssh/config или user@host) и удалённый путь; passwordless ssh у меня и так уже настроен (см. выше).

По-хорошему было бы ещё удобнее форкнуть Ghostty и встроить это прямо в терминал, чтобы перетаскивать сразу в окно сессии. Возможно, до этого ещё доберусь.

Окно SSH Drop

Окно SSH Drop

Пока не решённые проблемы

Ещё есть один нерешённый нюанс — вход после перезагрузки. Каждый раз, когда миник перезагружается (или при переезде на дачу), к нему нужно подключить клавиатуру и монитор и залогиниться руками: до первого входа в систему ssh не работает в современных macOS. Вроде бы это тоже должно решиться отключением file vault, но я не пробовал, тк это базовая защита файлов, не хочется отрубать её. Пока мне редко приходится делать ребут, а раз в месяц можно и переподключить клавиатуру.

Модель угроз: от чего защищаемся, а от чего нет

Тут стоит притормозить и проговорить, что именно даёт этот сетап. Потому что соблазн сказать «вынес агента на отдельную машину — и я в безопасности» большой, но это будет неправдой. Изоляция файлов и изоляция вообще — разные вещи.

Полезно разделить угрозы на два класса:

  • Разрушение. Агент (сам или через prompt injection) сносит данные на минике. Вот тут всё ок: личных файлов на нём нет, а rm -rf / лечится переустановкой и бэкапом. Это восстановимо.

  • Утечка и боковое перемещение. Агент сливает наружу токены, ключи, исходники или пушит вредоносный код в реальные репозитории. А ещё миник стоит в той же локалке, что и макбук, роутер, NAS — и скомпрометированный миник всё это видит. Вот это бэкапами уже не откатишь: утёкший токен утёк (и обнаружить это непросто), вредный коммит уехал в origin (тут авторевью нейронкой частично спасает), Mythos обнуляет сетевую безопасность и делает что угодно в локалке.

Так что бэкапы закрывают только первый класс. И когда я в начале писал «не париться о безопасности» — это про consent fatigue и про то, что личные данные на макбуке агенту недоступны, а вовсе не про то, что YOLO-агент с доступом в сеть стал безопасным. Не стал. От второго класса угроз отдельная машина сама по себе не спасает, а кое-где я её и подставил:

  • На минике разлочен keychain и выключен авто-лок. Удобно — но это значит, что весь login-keychain постоянно доступен любому процессу, не только claude-токен.

  • gh авторизован моим аккаунтом — агент может пушить и читать всё, что видит токен.

  • Tailscale, который я предлагаю поднять для работы из кафе, растягивает этот периметр ещё дальше.

Что с этим реально делать (часть уже сделал, часть — на очереди):

  • Сегментировать сеть. Вынести миник в отдельный VLAN / гостевую сеть, чтобы он не видел макбук и остальной LAN. Finder-передачу при этом можно оставить точечно. Это самое сильное реальное улучшение, и упирается оно не в миник, а в роутер.

  • Сузить права GitHub. Не полный аккаунт, а выделенный machine-user или fine-grained PAT с доступом только к нужным репам. Тогда худший случай пуша/утечки ограничен парой репозиториев, а не всем, что у меня есть. Также на github стоит включить защиту основных веток от удаления, если вы этого ещё не сделали. Ведь не сделали, верно?

  • Отдельный keychain под агента. Разлочивать не весь login.keychain, а отдельный, куда положить только то, что агенту реально нужно. Личные и рабочие секреты тогда вне досягаемости YOLO-процесса. Ну и вообще не стоит в кичейн макмини вносить что-то, что не нужно для работы.

  • Защитить ~/.zshenv. Там лежит init-ssh, в который приходит пароль в виде KCPASS, — лакомая цель: получив запись в этот файл, агент подменит init-ssh и стащит пароль от keychain при следующем же work. Рута у агента нет, так что достаточно отдать файл root’у:

    sudo chown root:wheel ~/.zshenvsudo chmod 644 ~/.zshenv
.zshenv под защитой

.zshenv под защитой

Читать его zsh по-прежнему может, а вот изменить его могу лишь я через sudo.

  • Подумать про egress — что миник вообще может вытащить наружу и нужен ли ему доступ, например, к рабочему VPN.

Резюме без иллюзий: я не сделал YOLO-агента безопасным — это в принципе недостижимо для машины с доступом в сеть. Я сократил и сегментировал blast radius и осознанно разменял часть безопасности на скорость. Главное — понимать, на какой именно размен идёшь, а не прятаться за словом «изолировал».

Заключение

Данный сетап позволил мне значительно ускорить разработку, избавиться от навязчивых подтверждений и почувствовать себя в относительной безопасности. Теперь для работы над новой задачей я просто открываю в Ghostty новую вкладку, ввожу work new-feature, запускаю claude — и всё, агент может работать часами, гонять UI тесты или само приложение, устанавливать дополнительные тулзы, если нужно, ходить в сеть, искать код в соседних проектах и прочее. А мой макбук при этом изолирован: личные и рабочие данные на нём агенту недоступны. А если внезапно агент на минике выполнит rm -rf /, то восстановление будет быстрым. Вы же делаете бэкапы, верно? 😉 Про остальные классы угроз — см. раздел про модель угроз выше: бэкапы тут не панацея.

Стоила ли игра свеч? Ведь и макмини денег стоит, и настройка не за полчаса была сделана…

Считаю, что да. Я устранил множество узких мест в своём рабочем процессе вроде подтверждения на каждый чих и прерывания работы при закрытии макбука. А потом и оптимизировал ввод пароля (макбук по отпечатку пальца, миник автоматом по ssh). Могу параллельно гонять несколько агентов в разных проектах (да и в одном тоже), не заботясь о том, что если агент не успел за рабочий день выполнить задачу, придётся его прерывать и назавтра восстанавливать контекст (а контекст в голове стоит дороже токенов).

Параллельно с приключением, итогом которого стала эта статья, шла настройка окружения для эффективной агентской разработки под iOS — об этом в следующих частях цикла.

PS: Если вы просто хотите настроить так же у себя, а статью читать лень, скиньте ссылку своему агенту и попросите “сделай мне так же”. 😉

ссылка на оригинал статьи https://habr.com/ru/articles/1052174/