# Переезд и передача контекста

Этот документ существует ради одного: чтобы разработка могла продолжиться на
другой машине и в другой сессии, не потеряв причин. Код рассказывает, **что**
сделано. Здесь — **почему** сделано именно так, что мы пробовали и от чего
отказались, и куда идём дальше.

Читать сверху вниз. **Если вы в этом проекте впервые — начните с раздела 9
«С чего начать в новой сессии»**, он в конце и самодостаточен. Если времени
мало — разделы 4 «Где мы сейчас» и 5 «Как переехать» тоже.

Самые дорогие разделы — 3 «Ошибки, которые нельзя потерять» и 6 «macOS»: там
то, что не выводится из чтения кода и уже стоило времени.

---

## 1. Что это

**Mantia** — антидетект-браузер, которым управляет агент. Не «браузер с
подменой отпечатка» и не «скрипт на Selenium»: два разных продукта, сведённые
в один, потому что по отдельности каждый из них проигрывает.

Обычный антидетект даёт личность, но действия в нём совершает человек руками.
Обычная автоматизация действует сама, но её видно на первом же шаге. Mantia
даёт личность **и** действует в ней так, как действовал бы человек: движения
мыши, паузы, ошибки прицеливания, чтение страницы через дерево доступности, а
не через селекторы вёрстки.

Нормативное техническое задание — [docs/PLAN-antidetect-browser.md](PLAN-antidetect-browser.md).
Оно нормативно: при расхождении прав план, а не код.

До 2026-08-05 оно лежало вне репозитория, на рабочем столе, и это было ошибкой
— единственный документ, которому обязан подчиняться весь код, не переживал бы
переезд. Теперь копия в репозитории каноническая; ту, что на рабочем столе,
править больше не нужно.

---

## 2. Откуда мы вышли и через что прошли

### 2.1. Почему сырой протокол, а не готовая библиотека

Первое, что было проверено спайком, а не принято на веру: **Playwright и
Selenium детектируются**. Не «могут детектироваться» — детектируются, и
дёшево. Поэтому вся работа с браузером идёт по Chrome DevTools Protocol на
голом websocket.

Из этого решения вытекли три жёстких правила, каждое из которых нарушить
соблазнительно и каждое стоит продукта:

**`Runtime.enable` не отправляется никогда.** Эта команда включает домен,
который страница видит. Один вызов — и все старания напрасны. Исполнение
JavaScript идёт только через `Page.createIsolatedWorld` плюс
`Runtime.evaluate` с явным `contextId`.

**Ввод только через `Input.dispatch*`.** События отправляются пачками с
растущим `timestamp` внутри одного кадра. Именно это даёт `isTrusted: true` —
свойство, которое подделать из JavaScript нельзя в принципе, и которое
проверяет каждый серьёзный антифрод.

**Восприятие через AX-дерево, а не через CSS-селекторы.** Замерено на живом
Grailed: классы там вида `UserItem_link__kgEWg` — хэш CSS-модуля, который
меняется при каждой пересборке фронтенда. Сценарий на селекторах умирает в
день ближайшего релиза площадки и умирает молча.

### 2.2. Машина как архитектурная сущность

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

Опыт был такой: площадка, которая жёстко проверяет входящих, **блокировала**
обычный антидетект-браузер и **пропускала** Windows-VPS с настоящим Chrome и
системным прокси. Отсюда вывод, который перестроил продукт: антифрод смотрит
не только на отпечаток вкладки, но и на **качество самой машины**. Виртуалка с
программным рендерингом, без видеокарты, с восемью одинаковыми профилями
внутри — это признак, который никакая подмена `navigator` не закрывает.

Поэтому `HostProfile` — не деталь конфигурации, а сущность, от которой зависят
решения: сколько профилей допустимо на этой машине, какие флаги запуска
корректны, что показывать человеку как предупреждение. Продукт обязан жить и
на VPS без видеокарты, и на домашней машине с ней — но обязан **знать**, где
он живёт, и не притворяться.

Отсюда же — `--enable-unsafe-swiftshader` на машинах без GPU: без него WebGL
просто не работает, а сломанный WebGL заметнее программного.

### 2.3. Согласованность вместо подмены

Мы не подменяем отпечаток инъекцией JavaScript. Подменённое значение почти
всегда противоречит соседнему, и антифрод ловит именно противоречие, а не
значение.

Вместо этого — **согласованность**: регион профиля задаёт часовой пояс,
локаль, языки; прокси обязан быть из той же страны; всё это проверяется и
показывается человеку числом. Реальный сценарий владельца (профиль «США» с
украинским прокси) движок оценивает как «не согласован, 55 из 100».

Отдельная тонкость, которая стоила ложных тревог: часовые пояса сравниваются
**по смещению UTC в четырёх точках года**, а не по названию. Иначе Detroit и
New York — «разные пояса», хотя это один и тот же пояс.

### 2.4. Пул сборок, а не одна версия

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

### 2.5. Прокси: локальный форвардер, fail-closed

У каждого профиля свой локальный SOCKS5-форвардер. Fail-closed: если апстрим
умер, трафик не идёт **никуда**, а не утекает напрямую. Утечка настоящего IP
на одну секунду стоит аккаунта целиком, а «тихо продолжить работу» — ровно тот
режим, в котором это происходит незаметно.

---

## 3. Ошибки, которые нельзя потерять

Каждая из этих ошибок стоила времени и каждая учит чему-то, что не выводится
из чтения кода. Они здесь именно поэтому.

**Иконка на рабочем столе не делала ничего.** Ни окна, ни ошибки, ни строки в
журнале. Причина: `pythonw.exe` не имеет консоли, и `sys.stdout` в нём равен
`None`. Настройка журнала звала `sys.stderr.isatty()` на импорте модуля и
падала до того, как появлялось хоть что-то, способное сообщить о падении.
Найти это можно было **только** кликнув мышью по ярлыку. Из терминала всё
работало всегда.
→ Урок: проверять как пользователь, а не как разработчик. Запуск из консоли не
проверяет запуск из консоли — он проверяет другой способ запуска.

**Белые полосы по краям окна.** Стиль `WS_THICKFRAME`, нужный системному
изменению размера, добавляет 7 пикселей неклиентской области. Замерено:
`(7,7,7,7)` со стилем, `(0,0,0,0)` без. Стиль убран, изменение размера считаем
сами.

**Белый текст на белом фоне в выпадающих списках.** Полупрозрачный фон поля
композитится **системным** выпадающим списком поверх белого, а не поверх
нашего тёмного стекла. Лечится только непрозрачным фоном и явным цветом
`option`. После починки прошли все пять разделов и шесть вкладок редактора —
ноль оставшихся мест.

**Один профиль считался чужим на своей же машине.** В таблице `hosts` оказались
две строки одного железа: формула идентификатора менялась (из неё убрали объём
памяти, потому что на гипервизоре с динамической памятью он плавает).
→ Урок: сравнивать не идентификатор, а то, из чего он считается. Теперь
сверяются процессор и набор видеоадаптеров.

**Мёртвый код с миной.** Метод писал `state='running'`, которого нет в
CHECK-ограничении таблицы. Первый же вызов упал бы. Не падал только потому, что
его никто не звал.
→ Урок: «работает» — состояние временное, оно выводится из активного прогона, а
не хранится в строке. Иначе после аварийного закрытия профиль остаётся вечно
работающим.

**Продукт не запускался без ключа модели.** Экран первого запуска существовал
ровно для этого случая, но показать его было некому: движок падал раньше.
→ Урок: то, что нужно для части функций, не имеет права мешать старту.

**Совет, результат которого продукт игнорирует.** Окно отвечало «нет эталона
UA-CH, выполните `mantia capture-ua`». Человек выполнял, эталон ложился на
диск — и окно продолжало отказывать: хранилище читалось один раз при старте
движка. Про перезапуск совет молчал.
→ Урок: если сообщение об ошибке называет действие, продукт обязан заметить
его результат. Иначе это не совет, а издевательство.

**Маячок, который браузер выбрасывает молча.** `navigator.sendBeacon` с типом
`application/json` не уходит никуда: этот тип не входит в список безопасных для
CORS и требует предварительного `OPTIONS`, которого маячок делать не умеет.
Страница грузится, скрипт отрабатывает, до сервера не доходит ничего, в консоли
пусто. Нашлось только сквозным прогоном.
→ Урок: «в консоли нет ошибок» не значит «работает». Проверять надо на том
конце, а не на этом.

**Пустой лог nginx при `return 204`.** nginx не читает тело запроса, если
обработчик — просто `return`: `$request_body` оказывается пустым, файл лога
создаётся и остаётся нулевым. Лечится `proxy_pass` на пустышку.
→ Урок сюда же: полчаса поисков ушло на соседнюю ошибку — лог удалялся **после**
перезагрузки nginx, и тот продолжал писать в удалённый inode. Снаружи это
выглядит как «не пишется», хотя пишется. Сначала удалять, потом перезагружать.

**Площадка может вывести наш пароль сама — и он уйдёт в модель.** Путь нашёл
Codex на ревью, и он не закрывается ничем из очевидного. Продукт вводит пароль
в настоящее поле `type=password`; страница одной строкой JavaScript читает
`input.value` и пишет его в свой текстовый узел — хоть в невидимый; узел
новый, нами не помеченный, и текст снимка уносит пароль в контекст модели.
Проверка вида поля здесь бесполезна (поле было правильным), маска по узлу —
тоже (узел другой).
→ Отсюда `core/scrub.py`: затирание идёт **по значению**, а не по узлу.
Проверено на стенде, который делает ровно это: в снимок ушло «Введено:
«скрыто»».
→ Урок общий: защита, привязанная к нашему объекту, не закрывает путь, который
целиком строит чужая сторона. Спрашивать надо «что уйдёт наружу», а не «что мы
пометили».

**Агент видел кнопки, но не видел текста.** Снимок состоял из одних
интерактивных элементов. На странице без них — личный кабинет, подтверждение,
сообщение об ошибке — он оказывался пустым: `элементов: 0` и больше ничего.
Замерено сквозным прогоном входа: агент успешно входил, попадал в личный
кабинет и отчитывался «не удалось войти», потому что подтвердить успех ему было
нечем. Он ещё и жёг остаток шагов, кликая по единственному запомненному `ref`.
→ Урок: «наблюдение через AX-дерево» — это всё дерево, а не только его
кликабельная часть. Отбор по ролям решает задачу выбора цели и молча отнимает
задачу чтения.

**Успешное действие, ничего не меняющее, не считалось застреванием.** Детект
требовал, чтобы действие ПРОВАЛИЛОСЬ. А типовой тупик выглядит иначе: клик по
кнопке отправки проходит успешно, форма не уходит (браузер сам её не пустил —
не прошла проверка поля), и так шесть раз подряд, до конца лимита шагов. Для
очереди из десяти аккаунтов это десять таких прогонов и десять счетов за
модель. Теперь замирание страницы считается отказом — но только для действий,
которые обязаны что-то менять: у прокрутки и ввода адрес законно прежний.

**Кириллица давала клавиши, которых не бывает.** `code` в событии клавиатуры —
это позиция клавиши, а не буква: под «т» лежит `KeyN`. Код выводился из символа,
и уходило `code="KeyТ"` с виртуальным кодом 1058 — за пределами диапазона
0–255, в котором эти коды определены. Страница, слушающая `keydown`, видела
клавиатуру, которой не существует.
→ Нашлось не тестом, а попыткой войти с кириллическим паролем. Там же
выяснилось, что «форма не отправляется» имело совсем другую причину — поле
`type="email"` не пускало кириллический адрес, и браузер был прав.

**Сутки замера подменялись днём разбора.** Оба приёмника корпуса пишут в лог
настоящее время визита (`$time_iso8601` у nginx, `toISOString()` у Next.js), а
разбор его не читал: `CorpusStore.add` штамповал `datetime.now`. Логи
забираются пачкой раз в неделю — вся неделя схлопнулась бы в одни сутки. Обе
величины, ради которых поле заведено, врали бы: `days` перестал бы показывать
изменение распределения во времени, а `first_day`/`last_day` у машины стали бы
значить «когда я запускал разбор». Пропала бы возвратность посетителя — самый
дешёвый признак антидетекта из тех, что даёт корпус.
→ Нашлось при чтении кода, пока данных ещё не было; починка стоила одной
команды, потому что **источник правды — логи, база из них выводится**. Через
месяц пришлось бы перебирать всё.
→ Урок: поле, которое некому проверить, врёт молча. Раз в схеме есть «когда»,
у него обязан быть тест, сверяющий его с источником.

**Панель массовых действий пережила удаление всех профилей.** Отрисовка списка
при пустом результате выходила **раньше**, чем обновляла панель. Пока профили
есть, всё сходится; удалили последний — выход сработал строкой выше, и наверху
осталось «4 выбрано» с живыми кнопками над пустой таблицей. Кнопки при этом
отправили бы действие по четырём несуществующим профилям.
→ Урок общий для интерфейса: ранний выход пропускает не только отрисовку, но и
всё, что стояло после неё. Автоматических проверок окна в проекте нет вовсе —
это отдельная дыра, из-за которой такая ошибка и прожила незамеченной.

**Тревога на совпадении.** Проверка прокси сообщала «тип сети не тот, что
заявлен: ожидалось „дата-центр“, получено „дата-центр“» — сравнивались коды
(`datacenter` из интерфейса против `hosting` из измерения), а не то, что они
означают.
→ Урок: после такого сообщения перестают читать и настоящие расхождения.

**Задача уходила в чужой аккаунт, и никто этого не показывал.** Владелец
завёл профиль, дал задачу — и сказал, что открылся «хз какой профиль, не тот».
Разбор по базе показал, что прогон шёл в правильном профиле, но по дороге
нашлись две настоящие причины так думать, обе — про окно:

* список профилей в «Задачах» **молча вставал на первый**. Новый профиль
  уходит в конец списка, и человек, заведя его и нажав «Запустить» не глядя,
  отправлял бы задачу в первый попавшийся аккаунт — на этой машине это живой
  магазин Etsy. Замерено в браузере: при четырёх профилях выбранным стоял
  «Etsy - Art Comfortic Колье». Теперь первый пункт пустой, а запуск без
  выбора отвергается;
* **проверить, чей профиль работает, было нечем.** Строка «Запускаю профиль …»
  уходит в ленту «Ход работы», а она в штатном окне не помещается и не
  прокручивается (замер — ниже). Имя работающего профиля теперь стоит рядом с
  кнопкой «Остановить», где его видно всегда, и остаётся после конца прогона.

**Таблица профилей не обновлялась сама — и «открыт» было не видно.** Продолжение
той же истории: владелец сказал, что не понимает, какой профиль открылся.
Замерено — `refresh()` вызывался только на действия человека и один раз при
загрузке; периодического опроса не было вовсе, а websocket кормит только ленту
событий прогона. Профиль, открытый задачей агента или внешней нейронкой через
MCP, навсегда оставался в списке «Готов» с кнопкой запуска. Подпись «Открыт» в
продукте была — показать её было некому. Добавлен опрос раз в четыре секунды с
перерисовкой только при изменении (выделение строк и набранный текст цели не
должны слетать — проверено).

По дороге две ловушки, обе видны только замером:

* первая версия опроса стояла под `if (document.hidden) return` — и не
  запускалась **ни разу**: во встроенной панели просмотра `document.hidden`
  равен `true` всегда. Выглядело как «просто не работает». Условие убрано:
  запрос местный и дешёвый, экономить на нём нечего;
* браузер душит таймеры в скрытом окне до **раза в минуту** — замерено по
  `performance.getEntriesByType('resource')`: между опросами ровно 60 секунд.
  Поэтому состояние перечитывается ещё и по возвращению фокуса, а не только по
  таймеру.

→ Урок: продукт не должен выбирать за человека сущность, действие над которой
необратимо. У двери для внешнего ИИ это правило уже соблюдалось — неоднозначное
имя профиля там отвергается с перечислением; окно оказалось менее осторожным,
чем MCP. И второй урок: сообщение, которое видно только в ленте, для человека
не существует, если лента не помещается на экран.

**«Медленный интернет» оказался порогом готовности.** Владелец жаловался, что
страницы грузятся долго при канале 90 Мбит/с. Замер разложил это на части:
снимок страницы — 0,2 с, а **переход на `example.com` — 45 секунд**, ровно
таймаут. Причина: `DEFAULT_READY` требует `min_interactive=8`, у `example.com`
интерактивный элемент один, сигнал стабильности не выставлялся никогда, и
ожидание досиживало до конца.

Порог убирать нельзя — он отличает «успокоилось» от «ещё не начиналось»:
скелет SPA стабилен так же, как готовая страница. Но отличие между ними не в
числе элементов, а в том, что скелет **ещё грузится**: у него не было `load` и
сеть не замолкала. Порог теперь выполняется и этим вторым способом.

Замерено после правки: `example.com` 45,08 → **1,28 с**, `grailed.com`
5,76 → 4,31 с. Тест `test_readiness_content.py` держит обе стороны и проверен
на откате: с прежним кодом краснеет.

Про Cloudflare из той же жалобы: в журнале **ни одной** проверки — все
неопознанные страницы оказались обычными разделами Grailed. Ощущение «постоянно
кидает на Cloudflare», судя по всему, и было этим сорокапятисекундным
ожиданием; но это гипотеза, а не замер, и если проверки появятся — их надо
ловить отдельно.

Сеть при этом замерена и оказалась ни при чём: до Grailed **5,8 мс и ноль
потерь**, системного прокси на машине нет. Тормозило исключительно наше
ожидание.

Ревьюер (Codex) нашёл по дороге настоящую несогласованность: `initialize`
включает `Page.setLifecycleEventsEnabled`, а `adopt` (усыновление новой
вкладки, которой площадка открывает карточку товара) — нет. Логика подсказывала
стойку в 45 секунд на каждый переход после первого клика по товару. **Замер
этого не подтвердил**: без правки 3,83 с, с правкой 4,70 с. Правка оставлена
как исправление самой несогласованности, но причиной медленной загрузки она не
была — и это записано здесь именно затем, чтобы следующий не принял
правдоподобное объяснение за проверенное.

Отдельно про `--disable-quic`: он стоит **всегда**, а обоснование у него
условное — «QUIC пошёл бы мимо SOCKS5 и отдал реальный IP». Без прокси утечки
нет. Замер показал, что для Grailed это почти ничего не стоит: настоящий Chrome
владельца грузит главную тоже по h2, и лишь 4 ресурса из 164 идут по h3.
Оставлено как есть, но помечено: у профиля с прокси флаг обязателен, у профиля
без прокси он бесполезен.

**Окно показывало UTC человеку.** В базе время хранится в UTC — и это верно,
машины обмениваются данными и должны понимать друг друга. Но окно просто резало
строку `2026-08-11T21:11` и печатало как есть, поэтому владелец в Киеве видел
в отчётах время на три часа назад и не мог сверить его с тем, что помнил сам.
Появился `fmtTs`, переводящий в часы машины; исправлено пять мест.
→ Урок: «хранить в UTC» и «показывать в UTC» — разные решения, и второе никто
не принимал, оно просто получилось.

**Предел шагов был зашит в окне, а под ним ещё два невидимых потолка.** Сорок
шагов стояли числом прямо в разметке; поля не было. Владелец справедливо
спросил, как уместить работу в сорок шагов. Но одного поля мало: под шагами
стояли жёсткие 20 минут и $0,5 на прогон, и поднятый предел упёрся бы в них
молча. Теперь предел выбирается в окне, а время и бюджет прогона считаются
от него (40 с и $0,02 на шаг — с запасом к замеренным 14 с и $0,0037).
→ Урок: снимая ограничение, ищи все ограничения того же рода. Одно снятое из
трёх выглядит для человека как «ничего не изменилось».

**Цены при подписке — выдумка, которую продукт показывал как факт.** Условная
стоимость считалась по тарифу API и печаталась везде: в подсказке, в плитке
«Стоимость», в истории прогонов, на полоске бюджета. Списаний при этом нет.
Теперь при подписке денег в окне нет вовсе, а суточный предохранитель показан
в шагах (5400 вместо $20) — в единицах, которые человеку о чём-то говорят.
Сам тормоз остался: зациклившийся агент за ночь выест лимит подписки молча.
→ Урок: показывать пересчитанную величину как настоящую — это тот же обман,
что и «работает» про неработающее, просто он выглядит аккуратно.

**Уровень журнала, который ничего не менял.** В команде стояло
`configure(level="WARNING")`, и строка выглядела работающей. Журнал к тому
моменту уже настроен: `get_logger` на импорте модуля зовёт `configure()` без
уровня, а она идемпотентна и второй вызов игнорирует. INFO шёл дальше.
Обнаружено замером stderr, а не чтением — читается такой код как верный.
→ Урок: идемпотентная настройка означает, что все параметры принадлежат
**первому** вызову. Отсюда `set_console_level`, которая переставляет уровень
после, а не притворяется, что настраивает.

**Чтение stdin, которого нет на Windows.** MCP-сервер читал трубу через
`loop.connect_read_pipe(…, sys.stdin)` — на macOS работает всегда. По
исходникам Python: `_make_read_pipe_transport` реализован только в
`unix_events` и в проакторном цикле Windows, у остальных это
`NotImplementedError`. То есть на боевой платформе команда не запустилась бы
вовсе, а вся приёмка на макбуке осталась бы зелёной. Замечено ревьюером,
подтверждено по исходникам. Теперь stdin читается обычным `readline` в
потоке-демоне — одинаково на обеих системах.
→ Урок ровно тот, что записан в CLAUDE.md: зелёный macOS-прогон ничего не
говорит про Windows. И ещё один, про потоки: `asyncio.to_thread` брать сюда
нельзя — пул дожидается своих работников при выходе, а этот навсегда заперт в
`readline`, и процесс не умирал бы по Ctrl+C. Замерено: с потоком-демоном
выход занимает 0,1 с.

---

## 4. Где мы сейчас

Работает и проверено живьём:

- полная вертикаль «задача → профиль → агент»; последний живой прогон —
  5 шагов, 17,2 секунды, $0,00124 на Grailed;
- программа из пяти разделов с полным редактированием профилей, прокси,
  отпечатков; своё безрамочное окно с нашей иконкой и заголовком; окна
  профилей тоже носят наше имя, а не «Chrome for Testing»;
- проверка прокси с определением типа сети (хостинг / мобильный /
  резидентский) — на живой настройке владельца она нашла два настоящих изъяна;
- проверка согласованности региона, часового пояса и прокси;
- альфа собирается в архив и ставится из него скриптом; проверено установкой
  с нуля;
- **аккаунт, парольная фраза и переносимый пакет метаданных** — то, ради чего
  затевался этот заход.

Добавлено 5–6 августа 2026, на макбуке:

- **профили запускаются на macOS** — не тестами, а руками: сборка в пуле,
  профиль через движок, окно, `navigator.webdriver === false`, аппаратный
  WebGL, навигация на живой сайт, задача агенту через Groq. Разбор — раздел 6;
- **номер профиля** (миграция 008): колонка в базе, столбец в списке и цифра
  на иконке открытого окна. Не позиция в списке — позиция меняется от
  сортировки, а номер попадает на иконку, и смена номера превратила бы
  «второй профиль» в чужой;
- **своё окно у каждого профиля в Dock**: клон бандла `.app` через
  `cp -Rc` (на APFS это `clonefile` — 0,2 секунды и ноль занятого места),
  подменённые `Info.plist` и `.icns` с номером;
- **ярлык `mantia install-app`** — запуск щелчком из Launchpad, а не командой;
- **часовой пояс** подменяется в каждой вкладке (было — только в первой) и
  выводится из **измеренного** выхода прокси, а не из страны. У существующих
  профилей — кнопка «≠ прокси» с предупреждением;
- **корпус отпечатков собран и стоит на пяти сайтах сетки** —
  `src/mantia/corpus/`, подробности в `docs/fingerprint-corpus.md`.

Добавлено 6 августа 2026 — **вход по списку аккаунтов**. То, ради чего флот и
заводится: список «логин:пароль», один вращаемый прокси, одна задача — и
очередь проходит все аккаунты по одному.

- **подсистемы сшиты с движком.** `policy`, `intervention`, предохранитель,
  журнал шагов и компилятор были написаны, покрыты тестами и **не вызывались
  ниоткуда**: движок строил `AgentRun` напрямую. Теперь политика спрашивается
  до запуска браузера (профиль в карантине получает отказ, не открыв окна),
  challenge превращается в задачу человеку с уликами, серия отказов размыкает
  предохранитель, а удачный прогон компилируется в сценарий. Ключом ко всему
  оказался `jobs`: журнал пишется на job, компилятор читает по job'у,
  `InterventionManager` без job'а отказывается работать вовсе;
- **агент умеет входить в аккаунт.** Действие `type_secret`: модель называет
  **поле**, значение достаётся из хранилища и подаётся прямо в слой ввода.
  В контекст модели, в журнал шагов и в отчёт оно не попадает. Пароль
  принимает только настоящее `<input type=password>` — проверяется через DOM,
  потому что по дереву доступности поле пароля и строка поиска неотличимы;
- **смена выходного адреса по ссылке** (миграция 009) — с подтверждением
  замером. Ответ ссылки ничего не доказывает: у тарифа минимальный интервал,
  внутри которого провайдер возвращает успех и прежний адрес;
- **очередь по списку** с разбросанными паузами, отчётом и выгрузкой в CSV;
- предохранитель снимается кнопкой в «Настройках» — иначе одна капча запирала
  бы флот на выдержку, а снять это можно было бы только правкой базы.

Числа: 1052 теста зелёные, `ruff` чист, приёмка этапов 1 и 3 зелёная на маке.

Не сделано и честно об этом сказано:

- облачной синхронизации нет — есть файл-пакет и заложенная под облако схема
  (мягкое удаление, отметки времени, машина-источник, журнал обменов);
- **приёмка этапа 2 не проверена**: ей нужен тестовый прокси в
  `MANTIA_HOME/.env`, а его на маке нет. Пять её тестов честно пропускаются,
  называя недостающую настройку;
- **зонд не стоит на exifexpert**: он на Windows-VPS с туннелем Cloudflare,
  доступа к той машине у сессии не было. Файлы в проекте лежат, нужен перенос;
- **много разных отпечатков на одной машине получить нельзя** — это замерено,
  а не предположено. Разбор с числами — `docs/fingerprint-diversity.md`;
- **ночное планирование не сшито.** `runner/scheduler.plan_night` раскладывает
  профили по слотам ночи, но раскладывать некуда: очереди по расписанию в
  продукте нет, есть очередь «по кнопке» (`Engine.run_batch`). Это не
  недоделка шва, а отдельная функция. Туда же `durable`: аренда job'а
  (`LeaseManager`) защищает от двух воркеров на одном прогоне, а воркер тут
  один; контрольные точки (`CheckpointStore`) нужны для продолжения
  прерванного прогона, а пути продолжения нет. Проверено grep'ом: вызовов вне
  своих пакетов — ноль;
- **приёмники результатов голодают.** `sinks/` поднимается движком и дренажит
  таблицу, которую наполняет только `durable.Outbox`, а его никто не зовёт.
  Агент структурированного результата не производит вовсе — «собери статистику
  и запиши в таблицу» из §2 ТЗ сегодня упирается сюда.

### Площадки: реестр вместо жёсткого условия

Добавлена **Etsy** (7 августа). До этого список площадок жил в трёх местах
сразу: жёсткое `if` в `api/engine._adapter_for`, единственный `<option>` в окне
и подсказка в поле импорта. Забыть одно из трёх значило завести профиль на
площадке, для которой у него нет адаптера, и узнать об этом в середине прогона.

Теперь всё в `sites/registry.py`: домен, имя, стартовый адрес, адрес входа и
путь к адаптеру. Окно берёт список ручкой `/api/sites` и честно пишет, есть ли
у площадки адаптер — профиль заводится и на незнакомой, просто без понятий и
без компиляции сценариев.

**Адаптер Etsy написан по замеру живой площадки**, и это дало две находки,
которых иначе не было бы:

* **дерево доступности не несёт адресов ссылок.** Первая версия скорера
  опознавала карточку товара по `/listing/<id>` — то есть не опознавала бы
  никогда. У Grailed это записано в докстринге, и правило то же: доступное имя
  ссылки — её ТЕКСТ. Карточка ловится парой «длинное имя + цена в соседнем
  узле», цена и отличает товар от навигационной ссылки;
* **Etsy отдаёт интерфейс по гео.** С украинского адреса кнопка входа —
  «Войти», сортировка — «Самые релевантные». Адаптер, знающий один язык,
  перестал бы узнавать понятия при смене прокси, причём молча. Регулярки
  двуязычные, тест гоняет оба языка.

Чего в адаптере **нет**: понятий формы входа. На `/signin` Etsy не отдаёт ни
одного видимого поля — форма появляется только после действий человека, и
замерить её состав не удалось. Вход идёт общим путём: агент читает дерево и
подаёт секрет через `type_secret`. Выдуманное понятие хуже отсутствующего:
лестница деградирует честно, а выдуманное молча резолвится не в тот узел.

### Модель: подписка вместо ключа, и выбор поставщика

Добавлено 11 августа 2026. Раньше поставщик был один — Groq по ключу API, и
в окне выбора не было вовсе: поле «Модель» стояло `disabled`. Для владельца
это значило платить за токены при живой подписке Claude, для будущих
альфа-тестеров — «сначала заведите ключ на стороннем сервисе».

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

* **`claude-cli` — подписка.** Вызов идёт через установленный `claude` в
  режиме `--print`; тот предъявляет вход владельца из своего хранилища. Ключа
  нет нигде: ни в `.env`, ни в окружении, ни в списке процессов. Промпт
  передаётся через stdin, а не аргументом, по той же причине;
* **`anthropic`** — тот же Claude по ключу, официальным SDK (он уже стоял);
* **`groq`** — как было;
* **`openai`** — любой другой OpenAI-совместимый адрес: Grok, OpenRouter,
  локальная модель. Отличается только адресом и ключом.

Замерено на живом вызове: подписка работает при полностью пустом окружении,
отдаёт текст и учёт токенов. Служебный контекст Claude Code — 17–21 тыс.
токенов на вызов, но повторные вызовы читаются из кэша, так что для агента,
делающего шаги подряд, это разовая плата.

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

**Честность доступности.** Шлюз для подписки собирается и на машине, где
`claude` не установлен, — ключа-то он не требует. Показать после этого
«модель доступна» значило бы изобразить работающим то, что не работает,
поэтому появился `unavailable_reason()`: он проверяет, что программа
действительно есть, и объясняет человеку, что делать.

Старые имена `MANTIA_GROQ_*` продолжают читаться, но **только при провайдере
`groq`**: иначе выбор Claude унаследовал бы `llama-3.3-70b-versatile` из
прежней настройки и запрос ушёл бы с несуществующей моделью.

### Отчёты: работа внешнего ИИ и старые прогоны

Тем же числом закрыты две дыры, названные раньше в этом документе.

**Работа через MCP теперь попадает в отчёты.** Раньше от неё не оставалось
ничего, кроме строки в журнале процесса: ни прогона, ни задачи, ни шагов, и
в «Истории прогонов» человек не видел, что чужая нейронка вообще работала в
его магазине. Теперь при первом действии над открытым профилем заводится
прогон, а каждое действие ложится шагом в ту же таблицу `steps`, что и у
своего агента, — но с подсистемой `external`, чтобы «кто это сделал» имело
ответ. Прогон закрывается вместе с профилем: незакрытый job компилятор
считает недоделанным.

**Старые прогоны открываются.** `run_history` отдавала шаги только для
текущего прогона, на любой прошлый отвечала «не найден» — при том что шаги всё
это время лежали в базе. Данные были, посмотреть их было нечем. Появился
`run_steps`, читающий из базы; проверено на прогоне от 6 августа.

### Профиль как инструмент для чужого ИИ: MCP и HTTP

Добавлено 11 августа 2026. Закрывает потребность, которая для владельца
оказалась главной: у него несколько магазинов Etsy, к каждому Pinterest,
и наполняет их **не он, а нейронка**. Раньше — расширением в обычном Chrome,
и ломалось это ровно там, где расширение слабо: доступ слетает, надо
переавторизовываться, одна личность на весь браузер. Здесь у каждого магазина
свой профиль со своими куками, а ввод даёт `isTrusted: true`.

**Одна реализация, две двери.** Примитивы живут в `api/control.py`, наружу
выходят двумя способами:

* `/api/control/*` — обычный HTTP, для Grok и любого другого API;
* `mantia mcp` — MCP-сервер поверх тех же маршрутов, для Claude Code.

Двери именно две, а не одна: MCP умеет только Claude Code и близкие,
а «дайте нам http и мы сами» — это все остальные. Разъехаться реализациям
некуда: вторая ходит в первую.

**Своя реализация MCP, без официального SDK.** SDK тянет пятнадцать пакетов,
включая веб-фреймворк и сервер приложений, ради того, чем мы пользуемся на
треть. Нужная часть протокола мала: JSON-RPC 2.0 построчно через stdin/stdout,
четыре метода. Контракт снят с самого SDK — он ставился, разбирался и был
удалён, — а не сочинён по памяти.

**MCP-сервер не поднимает браузер сам**, он ходит в уже работающую Mantia по
локальному HTTP. Причина не в лени: профили, окно и база живут в одном
процессе, и второй процесс, открывающий те же каталоги профилей, — это два
браузера на одном каталоге, то есть испорченные куки.

Решения, каждое из которых оплачено:

* **Профиль не открывается сам по себе.** Действие над закрытым профилем —
  внятный отказ, а не тихое открытие. Открытие видно площадке: новая сессия,
  новый вход в её журнале. Решать, случиться ему или нет, должен человек, а не
  чужая модель, наткнувшаяся на ошибку в середине своего цикла.
* **Профиль адресуется именем**, а не только `prf_01KZ…`: человек и его
  нейронка называют магазин словами. Но совпадение обязано быть однозначным —
  под «Etsy» подходят все три магазина, и ответ на это отказ с перечислением,
  а не «возьмём первый». Цена ошибки здесь — пост, ушедший не в тот Pinterest.
* **Действия над одним профилем идут по очереди.** Клиенты MCP — Claude Code
  в их числе — зовут несколько инструментов одним ходом, параллельно, и
  aiohttp честно исполняет их одновременно. У человека одна мышь: два действия
  внахлёст дают щелчок посреди набора и текст в поле, которое перестало быть
  активным. Для площадки это не быстрый пользователь, а невозможная моторика.
  Замок на профиль, не общий: магазины ведутся параллельно.
* **Список действий замкнут.** Имя приходит снаружи; «выполним всё, что
  найдём у сессии» отдало бы наружу весь CDP, включая `Runtime.enable`.
* **Пароль недостижим для внешней модели.** У инструмента `type_secret` в
  схеме просто нет поля для значения: модель называет ПОЛЕ, значение
  подставляет продукт. Путь тот же, что у внутреннего агента, — вызывается
  его же `AgentRun._type_secret` со всеми проверками, включая запрос к DOM
  «это действительно поле пароля». Две копии этой логики разъехались бы, и
  разъехалась бы именно защита. Контекст чужой модели нам не подконтролен
  вовсе, поэтому туда нечему попадать.
* **Снимок экрана уходит картинкой, а не строкой base64**, и качество выше,
  чем у превью в окне (70 против 35): превью — эскиз 200×130 для человека,
  здесь картинку читает модель, и на 35 мелкий текст рассыпается.

Проверено не тестами: `mantia mcp` запущен процессом, разговор шёл трубой.
Рукопожатие, `tools/list`, отказ по закрытому профилю, отказ по
неоднозначному имени. Затем живой прогон: профиль открыт, страница заполнена
через `type`, отправлена через `click`, и страница ответила
`isTrusted последнего клика: true`.

**Найденная по дороге ошибка того самого рода, ради которого написан
раздел 3.** В команде стояло `configure(level="WARNING")` — строка выглядела
работающей и не работала: журнал уже настроен раньше, на импорте модуля, где
`get_logger` зовёт `configure()` без уровня, а та идемпотентна. INFO шёл
дальше. Обнаружено не чтением, а замером stderr. Отсюда `set_console_level`
в `core/logging.py`.

**Что через эту дверь всё-таки видно наружу** — названо честно, а не
замолчано:

* **имя профиля**, а при импорте списком оно равно логину. Решение не новое:
  продукт и раньше считал `account_ref` открытым (он же в списке, он же в
  CSV-отчёте), секрет — только пароль. Убрать имя нельзя, к профилю иначе не
  обратиться;
* **кадр экрана мимо затирания секретов.** `Scrubber` работает по тексту; в
  пикселях затирать нечего. Дыра узкая — нужно, чтобы страница сама вывела
  пароль и чтобы модель именно в этот момент сняла кадр, — но она есть, и
  закрыть её нечем. `screenshot` вызывается только явно, сам не отправляется;
* **текст страницы целиком**, а значит и написанное чужими людьми. Для
  магазина с комментариями это не теория: указание может прийти со страницы.
  Отсюда предупреждение в `docs/mcp.md` о том, что содержимое страницы —
  данные, а не команды.

**Что закрыто по подсказке ревьюера.** Схема `about:` была разрешена целиком.
В Chrome часть таких адресов уводит на внутренние страницы браузера, а
`chrome://inspect` — это DevTools к живой вкладке, то есть `Runtime.enable`
мимо всего нашего кода. Замер на здешней сборке цепочку не подтвердил:
`about:inspect` открылся пустой страницей. Но защита, зависящая от версии и
флагов сборки, — не защита; разрешён ровно `about:blank`.

**Что закрыто замером.** `navigate` принимал любую схему. Проверено на
подставном файле: старый сервер сходил на `file:///…/decoy.env`, и обе метки
из файла вернулись в снимке — то есть в контекст внешней модели ушли бы
пароли прокси и ключи из `.mantia/.env`. Одной строки «перейди по адресу»
хватило бы, и попросить могла не только модель, но и страница. Теперь схемы
по замкнутому списку: `http`, `https`, `about`; отказ проверен на живом
сервере.

Как это подключают к Claude Code — `docs/mcp.md`.

### Окно: что изменилось 11–12 августа

Все правки этого дня родились из работы владельца, а не из плана. Каждая ниже
названа тем, чем она была для него, а не тем, чем была в коде.

* **Продукт больше не выбирает аккаунт за человека.** Список профилей в
  «Задачах» вставал на первый; теперь первый пункт пустой, а запуск без выбора
  отвергается. Разбор — §3;
* **имя работающего профиля стоит рядом с кнопкой «Остановить»** и остаётся
  после конца прогона. До этого оно уходило в ленту, которая не помещалась;
* **таблица профилей обновляется сама** раз в четыре секунды и по возвращению
  фокуса. Раньше профиль, открытый задачей или внешней нейронкой, навсегда
  оставался в списке «Готов»;
* **предел шагов вынесен в поле** (было зашито 40), а время и бюджет прогона
  считаются от него — иначе они становились новой невидимой стеной;
* **при подписке в окне нет долларов** — ни в подсказке, ни в плитках, ни в
  истории. Суточный предохранитель показан в шагах (5400 вместо $20);
* **время показывается в часах машины**, а не в UTC;
* **регион профиля доехал до списка** — колонка показывала прочерк, потому что
  поля `region` не было в модели профиля, хотя в базе оно есть и в карточке
  правится;
* **итог прогона словами агента сохраняется** (миграция 010) и виден в истории.
  Раньше «Выполнен» нельзя было ничем проверить;
* **убраны по просьбе владельца**: панель «Готовые сценарии» (механизм
  компиляции и подстановки при этом работает как работал) и три заготовленные
  формулировки задачи.

### Отпечаток профиля: замер против настоящего Chrome владельца

12 августа сравнили профиль `Grailed 1` с обычным Chrome на той же машине —
это самая сильная проверка из доступных, и её стоит повторять.

**Совпало побайтово:** UA, canvas (`56add3e3`, длина 6554), WebGL
(`ANGLE (Intel, ANGLE Metal Renderer: Intel(R) UHD Graphics 630)`), число ядер
(6), память (16), экран (1536×960, dpr 2), часовой пояс и смещение,
`webdriver: false`. Живой замер продукта: 12 проверок, 11 сошлись, критичных
нет. Экран совпал с двумя настоящими маками из корпуса, Chrome 151 там тоже
встречается.

**Не готов к бою по двум причинам, и обе не про отпечаток:**

1. у профиля **нет прокси** — выход с домашнего адреса владельца. Проверка
   согласованности даёт 65/100 и severity `bad`; карта покрытия пишет
   «политика отвергает такой запуск в бою». Все четыре профиля выходят из
   одной точки и склеиваются между собой и с владельцем;
2. **все четыре профиля — одна машина**: canvas у них общий по построению.
   Это то же, что измерено в `docs/fingerprint-diversity.md`: с одного
   компьютера получается **два** различимых отпечатка, не четыре.

**Расхождение внутри профиля:** `navigator.language` говорит `uk-UA`, а `Intl`
форматирует по `ru` — флаг `--lang` до рендерера не доехал. Настоящий Chrome
владельца в этом смысле согласован (`ru-RU` + `ru`). Не чинилось.

**Попутно исправлено:** карта покрытия писала «Число ядер: 12» (логические
ядра ОС), а страница видит **6** (физические — так Chrome считает на macOS).
Продукт показывал число, которого площадка не видит.

### Корпус: 31 устройство за сутки

Зонд заработал. На 7 августа, за сутки с небольшим:

| | |
|---|---|
| устройств всего | **31** |
| наш макбук + рукотворная заглушка | 3 |
| **чужих устройств** | **28** |
| из них мобильных | 21 |
| **десктопного Chrome** | **8** |

Сутки в `days` теперь настоящие — см. ошибку «сутки замера подменялись днём
разбора» в §3. Без той починки два дня наблюдений слиплись бы в один.

**Первые выводы, которые корпус уже даёт.**

Восемь чужих устройств из двадцати восьми не выглядят домашними машинами:
Windows с `Microsoft Basic Render Driver` (2 ядра, 4 ГБ), **мак с
`Google SwiftShader`** — такого железа не существует вовсе, Linux со
SwiftShader, браузер без внятного UA на 800×600. Первые три пришли на
**проверяльщик** — ровно там, где и ожидались антидетекты.

Первая настоящая связь: **из 20 устройств на Safari/iOS `deviceMemory` не
сообщает ни одно; из 10 на Chrome сообщают девять.** Отсюда готовая проверка
для наших же профилей: профиль, заявляющий Safari и отдающий `deviceMemory`,
невозможен. По отдельности оба значения безупречны.

**И один урок про сам метод.** Правило «`deviceMemory` больше 8 не бывает»
казалось очевидным и сработало на нашем же макбуке. В данных встречаются 4, 8
и **16** — все степени двойки, все от настоящих машин. Правило было неверным.
Ради этого корпус и собирается: он опровергает правдоподобные рассуждения
замером, а не спором.

### Что переносится между машинами, а что нет

Это ключевое решение всего раздела синхронизации, и оно неочевидно.

| | Переезжает | Почему |
|---|---|---|
| Метаданные профиля | да | килобайты; имя, площадка, регион, прокси, заметки |
| Пароли прокси | да | внутри шифрованной нагрузки, иначе переезд = завести всё заново |
| **Каталог профиля** | **нет** | сотни мегабайт кук — но дело не в размере |
| Сид моторики | нет | приехав, он позволил бы второй машине изображать ту же личность |
| Измеренные показатели прокси | нет | проверка из другой сети ничего не говорит об этой |

Каталог не переезжает автоматически потому, что **перенос каталога меняет
отпечаток под живой сессией**. Для площадки это вход в старую сессию с нового
устройства — самая заметная из возможных аномалий. Поэтому такой перенос
существует, но как отдельное действие с предупреждением, по одному профилю, и
человек выбирает: скопировать каталог руками (сессия цела, железо новое) или
начать здесь заново (отпечаток честный, входить руками).

---

## 5. Как переехать на макбук

### 5.1. Код

Два способа. Оба переносят **историю целиком**, а не снимок файлов: копирование
папки теряет её навсегда, и «почему тут так сделано» становится нечитаемым.

**Способ 1 — одним файлом, без всяких сервисов.** Годится, когда репозитория в
сети ещё нет, а переехать надо сейчас.

На этой машине:

```bash
git bundle create mantia.gitbundle --all
```

Получившийся файл переносится как любой другой. На макбуке:

```bash
git clone mantia.gitbundle mantia && cd mantia
```

**Способ 2 — через приватный репозиторий.** Нужен, если работа продолжится на
обеих машинах: файл-связка переносит в одну сторону, а удалённый репозиторий
работает в обе.

```bash
git remote add origin git@github.com:<ваш-аккаунт>/mantia.git && git push -u origin master
```

Репозиторий должен быть **приватным**. Секретов в нём нет (ключ модели живёт в
`.env` вне репозитория), но есть весь замысел продукта целиком.

Про переводы строк можно не думать: `.gitattributes` нормализует их в LF, иначе
первый же `git status` на второй машине показал бы «изменены все файлы».

### 5.2. Что НЕ едет в git и почему

Всего две вещи. Обе не едут не по недосмотру.

**Данные флота** — профили, прокси, заметки, метки. Они не код; они лежат в базе
`MANTIA_HOME/mantia.sqlite3`. Положить базу в git было бы не «проще», а неверно:
в ней машинно-локальные пути, привязки к железу и секреты, зашифрованные
средствами Windows, — на macOS они не расшифруются никогда. Для них и сделан
пакет (см. 5.3).

**Ключ модели** — строка `MANTIA_GROQ_API_KEY` в файле `MANTIA_HOME/.env`. Он в
`.gitignore`, и это единственное правильное место для ключа: коммит с ключом
остаётся в истории навсегда, даже если следующим коммитом его убрать. На
макбуке ключ вписывается руками, один раз.

Там же лежат `MANTIA_TEST_PROXY_*` — данные тестового прокси. Тоже секреты, тоже
переносятся руками. Файл-заготовка `.env` создаётся сам при первом запуске,
останется вписать значения.

### 5.3. Данные флота

1. На этой машине: Настройки → Аккаунт и машины → задать парольную фразу →
   «Выгрузить флот в файл». Программа назовёт точный путь.
2. Перенести файл `.mbundle` на макбук любым способом.
3. На макбуке: Настройки → «Загрузить файл с другой машины» → выбрать файл →
   ввести **ту же** фразу.

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

После загрузки макбук покажет весь флот. Профили этой машины будут помечены
«на другой машине» — у них не будет кнопки запуска, но будет кнопка «забрать
сюда». Это правда, а не ограничение: их куки остались здесь.

### 5.4. Сборки браузера

Пул сборок машинно-локален: `chromium.pool.json` в `MANTIA_HOME` содержит пути
к бинарям **этой** машины (копия в репозитории — образец, не рабочий файл). На
макбуке нужен свой пул со сборками под mac-arm64. Профиль пиннут к версии, и
если её нет в местном пуле, окно скажет об этом при попытке забрать профиль.

---

## 6. macOS

**Сделано 2026-08-05: профили на macOS запускаются.** Проверено не тестами, а
руками: сборка скачана в пул, профиль заведён через движок, окно открылось,
`navigator.webdriver === false`, WebGL отдаёт настоящую видеокарту
(`ANGLE (Intel, ANGLE Metal Renderer: Intel(R) UHD Graphics 630)`), навигация
на живой сайт прошла, агент отработал задачу через Groq.

Приёмка этапов 1 и 3 зелёная — включая три теста, которые до этого на
не-Windows пропускались или не содержали ни одного утверждения. **Этап 2
(восприятие через живой форвардер) не проверен**: ему нужен тестовый прокси в
`MANTIA_HOME/.env`, а его здесь пока нет. Раньше эти пять тестов пропускались
молча и по неверной причине — путь к `.env` был прибит как `C:/mantia/.env`, и
на маке они пропустились бы даже при настроенном прокси. Теперь путь верный, и
пропуск честно называет недостающую настройку.

Оценка «примерно 110 строк в шести файлах» оказалась заниженной примерно
втрое, и не потому, что список был неточен, а потому что он состоял из
отказов, которые **видно**. Дороже стоило второе множество — места, где на
macOS молча пропадала защита. Их разбор ниже; он и есть главная ценность
этого раздела.

Два продукта из этого не получается. Всё, что привязано к Windows, живёт за
узкими границами; ядро — протокол, ввод, восприятие, агент, сценарии, база —
платформонезависимо, и это большая часть кода.

### Что молча пропадало на macOS

Общее у всех пунктов одно: **на Windows защита работала, на macOS исчезала, и
ни один тест этого не показывал**. Отказ, который видно, стоит часа. Защита,
которой нет, стоит аккаунта.

**Чужой профиль запускался как свой.** Приехавшему пакетом профилю в
`user_data_dir` ставится заглушка `mantia-remote://<хост>/<профиль>`. Расчёт
был на то, что такой путь не заведётся: двоеточие в имени каталога Windows
запрещает, `mkdir` падает заметно. На macOS двоеточие легально, путь
оказывается **относительным**, и `mkdir(parents=True)` молча строит дерево в
текущем каталоге. Чужой профиль запустился бы с пустым каталогом, выглядел бы
рабочим и зашёл бы в аккаунт с чужого устройства. Теперь метка проверяется
строкой (`core.types.is_remote_placeholder`), а не поведением файловой системы.

**Два профиля на одном отладочном порту.** `_free_port` проверял свободность и
**отпускал** порт — тот самый TOCTOU, ради которого в `forwarder` порт
удерживается. На Windows последствие ловила проверка владельца порта
(`GetExtendedTcpTable`); на macOS её не было вовсе, а сверка версии не значит
ничего — пул сборок один на весь флот. Второй профиль молча присоединялся бы к
браузеру первого, и сценарий отработал бы «успешно» не от того аккаунта.
Теперь порт удерживается сокетом до самого `exec`, а владельца на POSIX
спрашивает `lsof`.

**Два одинаковых макбука — один `host_id`.** `platform.processor()` на macOS
возвращает `i386`. Значение уходит в `hosts.cpu_model` и в `_stable_host_id`, а
`Engine._detect_local_hosts` сравнивает машины по паре «процессор + набор
видеоадаптеров». Два одинаковых MacBook Pro 16" 2019 давали одно и то же — и
профили второго на первом считались местными, с кнопкой запуска. Теперь модель
берётся из `sysctl machdep.cpu.brand_string` (на Apple Silicon — `hw.model`).
Это **не** снимает вопрос целиком: два действительно одинаковых мака по-прежнему
неразличимы по этой формуле. Вопрос открыт и записан ниже.

**Ключ ко всем паролям прокси мимо `MANTIA_HOME`.** Путь был прибит как
`~/.mantia/secret.key`. При переопределённом `MANTIA_HOME` база оказывалась в
одном месте, а единственный ключ к ней — в другом; резервная копия
`MANTIA_HOME` ключа не содержала. Там же ключ создавался `write_bytes` и лишь
потом `chmod 0600` — при обычном umask он существовал доступным на чтение
всем. Теперь `os.open(..., 0o600)` и путь из настроек.

**Рабочий стол объявлялся существующим всегда.** `_interactive_session` на
macOS возвращала `True` безусловно. Запуск по ssh, из launchd или при
заблокированном экране — там WindowServer недоступен, headful-Chrome не
создаёт окно и выходит сразу, а предупреждение молчало. Теперь спрашивается
размер главного экрана: без графической сессии CoreGraphics отдаёт нули.

**Половина сборки принималась за целую.** Страж «установка либо полная, либо
отсутствующая» проверял `is_file()`. После `zipfile` бинарь — обычный файл,
просто без бита исполняемости; сломанный симлинк — тоже обычный файл. Оба
проходили, получали метку завершения и уезжали в пул. Теперь проверяются бит и
симлинки внутри `.app`, и та же проверка стоит в `_is_complete` — иначе сборка,
распакованная до перехода на `ditto`, осталась бы «уже установленной» навсегда,
и починка распаковки не починила бы ничего.

**Запрос к своему же порту уходил в апстрим.** `_probe_http` ходил голым
`urlopen`, который строит опенер с системными настройками прокси. На macOS
список исключений по умолчанию — только `*.local` и `169.254/16`: замерено,
что `proxy_bypass("127.0.0.1")` там отвечает `False`. У человека с системным
прокси — а это ровно наша аудитория — запуск падал бы по таймауту в 45 секунд
с сообщением про Chrome, который «не открыл порт». Теперь опенер без прокси.

**Приёмка читала пул чужой машины.** `tests/live` брали `chromium.pool.json` из
корня репозитория — образец с путями Windows-машины. Все живые тесты падали
«сборка из пула не найдена на диске» на машине, где сборка стоит.

**Два теста приёмки на не-Windows проверяли ноль.** `test_kill_leaves_no_zombies`
стоял под `pytest.skip`, а в `test_port_ownership_is_verified` единственное
утверждение было под `if sys.platform == "win32"` — то есть тест запускал
браузер и молча заканчивался. Оба теперь работают на обеих платформах, и к ним
добавлен третий: осиротевший Chrome добивается при следующем старте
(проверяется настоящим SIGKILL по процессу-родителю).

### Уже сделано

Все 117 модулей пакета импортируются на любой платформе, `tests/unit`
проходят. Это теперь не обещание, а проверка: `tests/unit/test_platform_portability.py`
следит, чтобы win32 не приезжал на верхнем уровне модуля и чтобы вне Windows
каждая функция WinAPI отвечала «ничего не сделано», а не падала.

Сторож пришлось усилить после ревизии: первая его версия не видела `ctypes.windll`
и `ctypes.wintypes` — то есть ровно тот способ, которым написан весь продукт.
Модуль с `USER32 = ctypes.windll.user32` на верхнем уровне проходил все проверки
зелёным, а на маке не импортировался бы вовсе.

Заодно закрыта настоящая дыра: `_screen_size()` на macOS отдавала нули, из-за
чего `doctor` показывал «Экран: 0×0», а окна профилей открывались запасным
размером независимо от монитора. Теперь `platform/macos.py` берёт размер
главного экрана через CoreGraphics — в точках, то есть в тех же числах, что
видит страница.

### Что мешало запуску и как закрыто (порядок = порядок отказов)

1. **Распаковка Chrome.** `zipfile.extractall` на macOS не восстанавливает
   симлинки внутри `.app` и не ставит бит исполняемости; на Apple Silicon
   симлинки `Framework/Versions/Current` — ещё и часть подписи.
   → `ditto -x -k` (`chromium_fetch._extract`). Замерено на живом архиве: пять
   симлинков внутри `.app` восстановлены, бит `755` на месте, браузер стартует.
   Отказ `ditto` сообщает кодом возврата, а не исключением, — разбирается.
   Заодно на Linux после `zipfile` доставляется бит: та ветка теряет его тоже,
   просто до переезда её никто не запускал.
2. **Платформа UA-эталона.** Было прибито `platform="Windows"` в пяти местах.
   → `ua_profile.local_ua_platform()`, умолчание `UaProfileStore.get` зависит от
   машины. Проверено живьём: `mantia capture-ua` положил эталон под `macOS:151`,
   `mantia check` его нашёл.
3. **Смерть Chrome вместе с программой.** Job Object'а на macOS нет.
   → `ChromeProcess._reap_orphans`: при старте профиля добиваются процессы с
   этим же `--user-data-dir`. Отбор по границе аргумента, а не подстрокой —
   путь `…/prf_1` содержится в `…/prf_10`, и добивание одного профиля убило бы
   браузер соседнего. Плюс `_clear_stale_singleton`: убитый Chrome оставляет
   `SingletonLock` вида `<хост>-<pid>`, и следующий старт упирался бы в «профиль
   занят» с ложной причиной через 45 секунд.
4. **Проверка «порт слушает чужой процесс»** → `platform/posix.listening_pids`
   через `lsof`. Ответ трёхзначный: `None` — спросить не удалось, пустое
   множество — никого нет, непустое — PID'ы. Различать обязательно: «не знаю»
   как «свой» открывает дорогу к чужой личности, а как «чужой» — запрещает
   запуск там, где всё в порядке. Множество, а не число: дескриптор наследуется
   потомками, поэтому «наш» — это наш PID или наш потомок.
5. Мелочи: `bootstrap` с прибитым `C:/mantia` (и заодно его копия писателя пула,
   знавшая только `chrome-win64/chrome.exe`), каталог `ua-capture` (на POSIX
   `Path("C:/mantia/ua-capture")` — относительный путь, `mkdir` создавал каталог
   с именем `C:` в репозитории), движок окна (`edgechromium` обязателен на
   Windows и вреден на macOS), проверка диска (мерила букву диска, вне Windows
   `Path.drive` пуст и запасное `C:\` не существует — `mantia check` показывал
   «Диск: не определено», то есть проверка не выполнялась, но выглядела
   выполненной).

### Что решено не делать и почему

**`--use-mock-keychain` не добавлен.** Ожидалось, что Chrome на macOS будет при
каждом запуске просить пароль от связки ключей, потому что бинарь CfT не
подписан и ACL к элементу связки не закрепляется, — Puppeteer и Playwright
передают этот флаг ровно поэтому. Замерено: элемент `Chrome for Testing Safe
Storage` не создаётся вовсе, модальное окно не всплывает, куки пишутся. Флаг
меняет шифрование кук в каталоге профиля, и добавлять его на основании
ожидания, а не замера, значило бы менять формат хранения без причины.

### Важное про секреты при переезде

`identity/secrets.py` на Windows шифрует DPAPI, на macOS — ключом в
`~/.mantia/secret.key`. **Скопированная с Windows база на маке не расшифруется
никогда.** Секреты обязаны ехать только пакетом: он их перешифровывает. Это ещё
одна причина, по которой копирование папки — не переезд.

### Открытый вопрос: два одинаковых мака неразличимы

Это не недоделка, а решение, которое надо принять, и принимать его лучше зная
цену.

`host_id` считается из класса железа — процессор и набор видеоадаптеров, — и
это сделано намеренно: серийник и MAC меняются при миграции ВМ, а класс железа
нет (§3, ошибка «один профиль считался чужим на своей же машине»). На разных
Windows-VPS строка процессора почти всегда разная, и совпадение было
маловероятным. С макбуками наоборот: два MacBook Pro 16" 2019 — это один и тот
же процессор и один и тот же набор адаптеров, то есть **один `host_id`**.

Последствие: профили, приехавшие пакетом со второй машины, на первой считаются
местными. `_require_local` пропускает, `foreign` не ставится, кнопка запуска
есть — а каталога с куками здесь нет. Спасает только заглушка
`mantia-remote://` (см. выше), и именно поэтому её починка была обязательной, а
не косметической.

Развилка: либо в идентификатор добавляется машинно-локальная составляющая
(и тогда возвращается риск «одна машина — две строки в `hosts`»), либо
`foreign` перестаёт опираться на `host_id` и начинает опираться на наличие
каталога. Второе честнее по сути — вопрос ровно в том, есть ли здесь куки, — но
меняет схему обмена.

### Платформа профиля против ОС хоста — ловушка для читателя

Здесь стояла запись: «`coherence.py` не сверяет заявленную платформу профиля с
ОС хоста; профиль, приехавший с Windows, на маке отдаст UA Windows под
маковским WebGL, маковскими шрифтами и `screen.availTop` = 25». Запись
**устарела и вводит в заблуждение**. Проверено чтением кода в августе 2026:

* `ProfileRepo.launch_spec` берёт эталон как `self._ua.get(profile.chromium_version)`
  — **без** указания платформы;
* `UaStore.get` при `platform=None` подставляет `local_ua_platform()`, то есть
  платформу той машины, на которой профиль запускается сейчас.

Значит приехавший с Windows профиль получает на маке маковский эталон, а не
windows'ный, и описанного расхождения не возникает. Оно возникало, когда
умолчанием в `UaStore.get` стояла строка `"Windows"`; это и починили, сделав
умолчание машинно-зависимым — об этом написано в докстроке самого `get`.

Если эталона для местной платформы нет, `get` бросает `ConfigError` и профиль
не запускается. Отказ громкий, а не тихое несоответствие.

**Чем это стоило.** По этой записи была написана проверка `host_platform` в
`coherence.py`, тесты к ней и проводка в `engine.py` — и всё это откачено, а
проводка вдобавок обращалась к `profile.ua_metadata`, которого у `Profile`
нет вовсе (поле принадлежит `LaunchSpec`). Полный прогон 1101 теста этого не
показал: `engine.coherence()` юнит-тестами не задет.

Урок ровно тот, что вынесен в начало `CLAUDE.md`, только с другой стороны:
**устаревший документ опаснее отсутствующего.** «Замечено, но не тронуто»
читается как приглашение чинить, и чинить начинают, не проверив, что описанное
всё ещё происходит. Прежде чем закрывать дыру из этого раздела — воспроизведи
её.

**Что здесь остаётся настоящим риском.** Не расхождение внутри сеанса, а
непрерывность аккаунта между машинами: профиль переносится вместе с куками и
историей, но UA-CH у него на новой машине **другой**. Для площадки это тот же
человек, у которого сменилась операционная система. Это отдельный вопрос, и
проверки на него нет.

### Где остановились 14 августа 2026

Разобраны семь чужих антидетект-браузеров, итог — [competitors.md](competitors.md).
Оттуда же выросли четыре задачи; они здесь по убыванию пользы. Раздел дописан
задним числом 15 августа: пункт 2 с тех пор закрыт, пункт 1 переехал в один файл
с боевым зондом. Остальное держится.

**1. Вторая версия зонда собрана, но сквозного прогона не было.** Отдельного
файла у неё нет: версия 2 — это сам `src/mantia/corpus/probe.js`
(`VERSION = 2`), а протоколы её замеров вынесены в
[probe-surfaces.md](probe-surfaces.md). Имя `probe.v2-unverified.js`, которое
стояло здесь раньше, не существует ни в дереве, ни в одной ревизии истории
(`git rev-list --all --objects` его не знает) — если встретите его ещё где-то,
это остаток той же ошибки. Приёмная сторона к новым полям готова (`ALLOWED`,
`NOT_IN_HASH`, колонка `probe_v`). Чего не хватает — прогона на настоящем
стенде: до него на шесть чужих сайтов с выручкой не выкатывать.

**2. Спящая ошибка в зонде — исправлена.** `sha256hex` бросал на странице без
TLS: `crypto.subtle` там отсутствует, а `run()` обёрнут try только вокруг
`collect()`, так что на http-странице зонд умирал целиком и не отправлял ничего.
Не проявлялось лишь потому, что все шесть площадок на https. Правка в одну строку
стоит в `probe.js`, строка 32. Коммита пока нет: в `git HEAD` её не видно, и
откат рабочего дерева вернёт ошибку вместе со старым зондом.

**3. Главный вывод разбора, меняющий порядок работ.** У всех шестерых
конкурентов настоящая библиотека отпечатков живёт на сервере, а локально почти
пусто (у AdsPower — 34 пары «вендор+рендерер» образца 2019 года, у 0detect
вообще ничего). Значит своя библиотека и есть продукт, а патч бинаря без неё
подставлять нечего. Двое — Vision и Octo — держат ровно наш корпус: подставляют
профилю НАСТОЯЩУЮ картинку живого человека, подобранную по хешу рисующего
скрипта. Проверено глазами: у Vision это 21 файл в `~/Library/Application
Support/Vision/cache/`.

**4. Есть ось, которую не закрывает ни один их форк** — TLS (JA3/JA4), ALPN,
порядок `SETTINGS` в HTTP/2, HTTP/3. Патчем Blink туда не дотянуться, а
площадка уровня Cloudflare смотрит именно туда. Зондом на JS не снять: нужен
приёмник на стороне сервера. У нас покрытия нет.

**Чего НЕ делать, проверено на своей шкуре в этой же сессии.** Не чинить дыры
из раздела «замечено, но не тронуто», не воспроизведя их сначала: одна такая
запись устарела, по ней была написана проверка, тесты, проводка — и всё
откачено. Разбор ошибки — выше, в разделе про платформу профиля.

### Лабораторный стенд: снять отпечаток любого браузера на этой машине

`scratchpad/lab/stand.py` (в скретчпаде сессии; при надобности перенести в
репозиторий). Поднимает страницу с боевым зондом на `127.0.0.1:8799`, метка
берётся из адреса: `?l=dolphin`. Каждый замер ложится отдельным файлом.

Смысл: в боевом корпусе метка популяции ставится по домену, то есть по
догадке. Здесь мы сами открываем браузер и поэтому знаем точно. Так снята
таблица «восемь чужих профилей против настоящей машины» в
[competitors.md](competitors.md) — самое ценное измерение разбора.

**Снято:** Undetectable (3 профиля), Dolphin (3), AdsPower (2).

**НЕ снято, требует платной подписки** — Vision, Octo, 0detect, Incogniton.
Хозяин отметил это как важное и отложил. Без них картина неполна: Vision и
Octo — единственные двое, у кого своя библиотека холстов, и как раз их
поведение интереснее всего.

Пять форков из шести **отказываются запускаться в обход своего приложения**
(`@dolphin: There's an error while reading browser config. Aborting` и
аналоги), поэтому автоматически, без учётной записи, их не снять — только
руками через их окно.

### Состояние на 15 августа 2026 — читать первым

**ГЛАВНОЕ РЕШЕНИЕ СЕССИИ: свой форк Chromium писать не пришлось.** Уровень 2
построен на Camoufox — открытом форке Firefox (MPL-2.0), который подменяет
отпечаток на уровне C++. Разбор, замеры и оговорки — в
[PRODUCT.md](PRODUCT.md), раздел «Уровень 2 сделан».

#### Два уровня, оба работают

| | Уровень 1 | Уровень 2 |
|---|---|---|
| Движок | наш Chromium, CDP | Camoufox (Firefox), патченый Playwright |
| Отпечаток | НАСТОЯЩИЙ этой машины | подменённый, пришпилен к профилю |
| Разных на машине | 4 (флагами запуска) | много |
| Звук | один на всех | разный на профиль |
| Агент | работает давно | **работает**, переходник готов |
| Флот | работает давно | **работает**, память считается отдельно |
| Памяти на профиль | 0,45 ГиБ (норматив) | 1,10 ГиБ (замерено) |
| Код | `runner/session.py` | `runner/camoufox_session.py` + `camoufox_engine.py` |

#### Что сделано этой сессией, по файлам

* `identity/gpu_classes.py` — четыре честных класса отпечатка для уровня 1.
  Класс считается по месту профиля **внутри площадки**, не по сквозному номеру:
  на живых данных сквозной номер дал двух близнецов на Etsy.
* `runner/camoufox_engine.py` — движок уровня 2: отпечаток считается ОДИН раз
  и хранится в `profiles.engine_config_json`, посевы шума выводятся из
  идентификатора профиля.
* `runner/camoufox_session.py` — переходник: `AgentRun` работает на уровне 2
  без единой правки. AX-дерево через `aria_snapshot`, адресация
  `aria-ref=eN`, ни одного CSS-селектора.
* `runner/camoufox_session.py`, функция `parse_aria_snapshot` — разбор дерева
  ролей Playwright. Отдельного модуля под это НЕТ: разбор нужен только
  второму движку и переезжает вместе с ним.
* Миграция `012_profile_engine.sql` — колонки `engine` и `engine_config_json`.
* `api/engine.py` — `_open_camoufox`, `close_camoufox`, `create_profiles_bulk`,
  `_gpu_flags`, `_site_ordinal`.
* Ручка `POST /api/profiles/bulk` и кнопка «Пачкой» в окне; выбор уровня при
  создании профиля с честным объяснением цены.
* `corpus/collector.py` — колонка `probe_v`, `NOT_IN_HASH`, `implausible()`.
* `corpus/probe.js` — вторая версия зонда: WebGPU, подсказки UA-CH, настоящий
  звук, голоса, структура и следы подделки, цветовой охват. 75 КБ (замерено),
  24 КБ сжатым; протоколы её замеров — в [probe-surfaces.md](probe-surfaces.md).
* `core/capacity.py` — предел флота считается ПО ДВИЖКУ, а не по одному
  числу: Chromium 0,45 ГиБ (прежний плановый норматив, сырого бенчмарка в
  репозитории нет), Camoufox **1,10 ГиБ** — замерено 14 августа на трёх
  одновременно открытых профилях, каждый через свой форвардер и с загруженной
  Etsy, RSS по дереву «драйвер + Firefox + plugin-container», три серии дали
  836,4–1107,8 МиБ. Берётся максимум, а не среднее: среднее продало бы память
  самого тяжёлого профиля второй раз.
* `api/engine.py` — `_open_profile_session`: один путь запуска на оба движка.
  До него уровень 2 открывался своей веткой, и любая правка политики (резервы,
  прокси, журнал) приезжала бы только на один уровень.

#### Проверено сквозным прогоном, а не тестами

* профиль уровня 2: создан ручкой → отпечаток в базе → открыт ручкой →
  трафик через немецкий прокси (6 соединений с апстримом) → закрыт начисто;
* четыре профиля Etsy уровня 1 дали **4 разных `combo_hash`**;
* пачка из 4 профилей уровня 2 — 2 секунды, все отпечатки разные;
* агент на уровне 2: **78 событий, `isTrusted: true` у всех**, цель найдена
  только по роли и имени, пароль в снимок не попал;
* CreepJS на профиле уровня 2: `0% stealth`, `0% headless`;
* флот с уровнем 2: `mantia serve --port 8757`, профиль Camoufox прошёл
  преднамеренную проверку (HTTP 200, `engine=camoufox`, `eligible=true`);
  движок переживает перезапуск программы — читается из durable-настроек, а не
  собирается заново;
* уровень 2 без прокси не запускается даже там, где прямой выход разрешён для
  разработки.

#### Готовность к альфе: что проверено и чем

Список сделан в конце дня 15 августа, и в нём только то, что проверено ЖИВЬЁМ,
а не тестами. Тесты сказано отдельно: 1359 прошло, 3 пропущено, ruff чист.

| Путь | Как проверен |
|---|---|
| Первый запуск на чистой машине | `mantia check` на пустом `MANTIA_HOME`: называет два блокирующих пункта, доступ к модели по подписке, готовность второго уровня |
| Создание профиля | окном, оба уровня, с честным объяснением цены каждого |
| Пачка профилей | окном; предупреждение про один прокси на пачку на месте |
| Своя площадка | заведена `vinted.com`, видна в списке с пометкой «без адаптера», подмена встроенной отбита |
| Агент на своей площадке | живая задача на польском Vinted: кликнул «Kobiety», второй цели не нашёл — не упал, пошёл листать и честно остановился по пределу шагов. Цель не достигнута, но путь работает |
| Агент на площадке с адаптером | живая задача на Etsy: кошелёк за 18,03 € найден, карточка открыта, 3 шага и 15 секунд |
| Флот | та же задача через флот, уровень 2 |
| Прокси | дата-центровый и резидентный: проверка, задержка (97 мс против 734), выходной адрес |
| Отпечаток уровня 2 | своя проверка через страницу. Первый прогон: 7 сходится, 3 расхождения, и все три настоящие (язык выбирался заново на каждом запуске). После починки — **10 сходится, 0 расхождений** |
| Утечки | WebRTC — `No Leak`; порт отладки с публичной страницы не виден |

**Чего в этом списке нет и почему.** Не проверялась работа с ВХОДОМ в
настоящий аккаунт: для неё нужны учётные данные владельца, и брать их нельзя.
Не проверялся Windows: там написан Job Object для второго уровня, но подтвердить
его можно только на самой Windows.

#### Что НЕ работает и что сказать альфа-тестерам

1. **Уровень 2 — это Firefox.** Где площадка ждёт Chrome — не подойдёт.

1а. **Первый уровень связывается по трём признакам, о которых мы не знали.**
   Найдено не нами: владелец построил антифрод-стенд на своём сайте
   (`~/Desktop/rareamour`, `docs/antifraud.md`), и там есть список признаков,
   которые доказывают общий источник САМИ ПО СЕБЕ. Три из них у нас не были
   закрыты и даже не назывались: **адаптер WebGPU**, **физическая раскладка**
   (`navigator.keyboard`) и **батарея** (`getBattery`).

   Замерено 15 августа на этой машине, оба класса отрисовки:

   | признак | класс `default` | класс `cpu2d` |
   |---|---|---|
   | WebGPU | `amd \| rdna-1` | то же |
   | раскладка | `KeyK=k,KeyG=g,…` | то же |
   | батарея | заряд 1, питание есть | то же |

   То есть **классы отрисовки их не различают**: два профиля первого уровня на
   одной машине связываются по ним, даже когда холст и WebGL у них разные.
   Подделать нечем — свойства бинаря и железа, а инъекция из JS обнаруживается.

   На втором уровне все три ОТСУТСТВУЮТ (замерено там же), и отсутствие не
   выдаёт: раскладка и батарея есть только у Chromium, а WebGPU у этой сборки
   Firefox выключен. Это ещё один довод брать второй уровень для флота.

   Это оказалось верхушкой. Полная перепись поверхностей (18 агентов по
   восьми областям, каждое утверждение проверено по коду стенда) — в
   [antifraud-stand-findings.md](antifraud-stand-findings.md). Там же три
   вывода, которые стоит помнить: (1) второй уровень для флота подтверждён
   враждой — стенд связал сетку «антидетект» слабее, чем «одну машину»;
   (2) надо ЗАМЕРИТЬ, что клонирование профиля не тащит сетевые супер-cookie
   (кэш favicon, HSTS, TLS-билеты); (3) fail-closed стоит расширить за HTTP —
   на DNS, QUIC и IPv6.

   Ловушка замера, стоившая двух заходов: все три — API защищённого контекста.
   На `about:blank` их нет НИ У КОГО, и замер там отвечает «нет API» независимо
   от правды. Мерить только на `http://127.0.0.1` или по https.

2. **Холст НЕ различает профили — и это хуже, чем считалось.** Замер 15
   августа на Linux: три профиля с разными наборами шрифтов дали побайтово
   один и тот же холст, и текстовый, и геометрический. То есть шума по холсту
   у Camoufox нет вовсе, а `canvas:seed` объявлен в его настройках, но не
   читается никем — в исходниках его не использует ни одна строка.

   Прежняя формулировка («холст меняется от страницы к странице») снята:
   замерено, что он стабилен и между страницами, и между запусками. Это
   вылечило закрепление набора шрифтов, сделанное в ночь на 15 августа.

   **Правка написана, собрана и ПРОВЕРЕНА** — но пока не в поставке. Шум по
   холсту в самом Firefox УЖЕ ЕСТЬ (`nsRFPService::RandomizePixels`) и
   включается только под их «сопротивление отпечаткам», которого Camoufox не
   использует. Мы не пишем шум заново: включаем их по нашему `canvas:seed` и
   им же сеем ключ. Ключ важен отдельно: у Firefox он меняется каждую сессию —
   правильно для приватного браузера и неверно для аккаунта, у которого при
   каждом входе был бы новый холст. Наш выводится из идентификатора профиля.

   Замер на собранном браузере (15 августа, вечер) — **половина победы, и
   выдавать её за целую нельзя.** По холсту БЕЗ текста всё как задумано:
   профили дают разный холст, а один профиль — один и тот же между двумя
   сайтами, между восемью загрузками, между запусками и в фоновом потоке
   (`OffscreenCanvas` в Worker). Последнее не мелочь: расхождение окна с
   потоком само по себе примета, и ровно на нём ловятся конкуренты с подменой
   через JS.

   А холст С ТЕКСТОМ после правки принимает **два** значения на одном сайте
   вместо одного. Полная картина по восьми загрузкам:

   | | с текстом | без текста |
   |---|---|---|
   | без `canvas:seed` (как в поставке) | 1 значение | 2 значения |
   | с `canvas:seed` (правка) | 2 значения | 1 значение |

   То есть неустойчив сам браузер, просто в разных местах, и правка переносит
   неустойчивость с одного холста на другой. Причина не установлена; что уже
   исключено замерами — в `build/camoufox/README.md`. Выдумывать причину
   вместо замера здесь нельзя, на этом дважды терялось время.

   Патч, рецепт сборки и скрипт проверки лежат в `build/camoufox/`.
   **В поставке правки нет** и до устранения дефекта быть не должно: собрана
   она под Linux, а продукт на Windows и macOS работает со сборкой Camoufox из
   их установщика. Пока это так, карта покрытия обязана показывать холст как
   общий — и показывает.

   На чём это чуть не сорвалось, чтобы не повторять. Первая сборка патч
   содержала, но он стоял ПОСЛЕ веток Firefox, а первая из них
   (`EfficientCanvasRandomization`) выигрывала и до нашей ветки дело не
   доходило. Внешне это выглядело как «патча нет вовсе». Отличить помог только
   замер: `toDataURL` менялся при каждом запуске, а `getImageData` был
   одинаков у всех профилей — то есть работала чужая ветка, шумящая лишь при
   кодировании картинки и сеансовым ключом.

   И отдельно — ловушка проверки. Отсутствие строки `canvas:seed` в собранной
   библиотеке ничего не доказывало: строка короткая, компилятор кладёт её
   байтами прямо в код и на литерал не ссылается, а компоновщик выбрасывает
   литерал как ненужный. Соседний ключ `fonts:spacing_seed` длиннее и потому
   виден. Проверять патч поиском строк нельзя — только поведением.
3. ~~Уровень 2 медленный.~~ **Было правдой до 15 августа, теперь нет.**
   Причина оказалась не в Camoufox как таковом, а в НАШЕЙ ошибке: мы включали
   его `humanize` поверх собственного слоя ввода, и он интерполировал каждый
   наш отрезок — работа не складывалась, а перемножалась. Замер: 19,1 и 18,9
   секунды на одно движение мыши против 0,1 без него. Их очеловечивание
   выключено, паузы ставит наша моторика, как на первом уровне. Живой прогон
   после правки: 5,1 секунды на действие вместо 120.
4. **Не-латиница на уровне 2 печатается вставкой текста**, ноль событий
   клавиатуры. Обход проверен и не работает.

   4a. **Сеть выдаёт прокси, а не профиль.** Отпечаток сетевого стека
   принадлежит выходному узлу: браузер говорит «Windows», а пакеты приходят
   со стека Linux-сервера. Из браузера это не чинится ничем — только выбором
   прокси. Резидентный проходит, но он в пять раз медленнее (2,0 секунды
   против 0,4 на запрос), и это надо говорить до обещаний о скорости.

   4b. **Система профиля равна системе машины.** Выдать Windows-профиль с
   макбука нельзя: шрифты чужой системы не подключаются, профиль остаётся
   вообще без шрифтов. Умеет только Linux, и только Windows — macOS-профилей
   не даёт и он (шрифт эмодзи Apple распространять нельзя).
5. **Флот на уровне 2 прогнан живой задачей 15 августа.** Задача «найди
   кожаный кошелёк дешевле 60 долларов и открой карточку» на живой Etsy через
   немецкий прокси: выполнена за 3 шага и 15 секунд, найден кошелёк за 18,03 €,
   карточка открыта. Площадка отдавала немецкий интерфейс — агент это
   переварил, потому что ищет по роли, а не по тексту.
   Чего по-прежнему НЕ было: задачи с ВХОДОМ в настоящий аккаунт (нужны
   учётные данные владельца) и повторного замера памяти Chromium (0,45 ГиБ
   остаётся плановым нормативом, а не результатом замера).

   Именно этот прогон и нашёл беду с `humanize`: до правки те же пять шагов
   подряд возвращали «не выполнено» ровно по 120 секунд каждый, агент
   зацикливался на поле поиска. Ни один из 1338 тестов этого не показывал —
   случай ровно про то, что зелёный прогон ничего не доказывает.
6. **Один прокси на пачку** связывает аккаунты по сети сильнее любого
   отпечатка. В окне об этом написано.

#### Ночь 15 августа: подмена ОС на втором уровне не работает вне Linux

Владелец начал проверять профили и прислал два замечания: вместо флагов
квадраты с кодами, и в доке чужой значок с подписью «Camoufox». Первое
оказалось верхушкой.

**Что нашлось.** Camoufox возит с собой шрифты всех трёх систем, но подключает
их через fontconfig, а в его же `utils.py` эта ветка стоит под
`if OS_NAME == 'lin'`. На macOS и Windows переменная `FONTCONFIG_FILE` не
выставляется вовсе. Итог для профиля, выдающего себя за Windows на макбуке:

* родные шрифты машины спрятаны (список подменён на виндовый);
* виндовые из бандла не подключились;
* **не виден ни один шрифт вообще** — замерено по Candara, Gabriola, Ink Free,
  Leelawadee UI из их же бандла и по родным Menlo с Zapfino;
* цветных эмодзи нет ни одного: 16 независимых запусков, ноль удачных
  (счёт цветов в отрисовке смайла — 3 против 831 у работающего).

Машины с пустым набором шрифтов не бывает. Это ровно то противоречие «сказал
одно, показал другое», по которому мы ловим все восемь чужих профилей в
`competitors.md`, — только у себя.

**Вторая находка, тише и хуже.** У профиля не был закреплён список шрифтов, и
Camoufox тянул новый случайный набор ПРИ КАЖДОМ ЗАПУСКЕ: от 183 до 451 шрифта,
два подряд совпадают лишь наполовину. Аккаунт, у которого при каждом входе
меняется набор шрифтов, выдаёт себя вернее, чем аккаунт с чужим набором. Это та
же беда, от которой мы пришпиливали посевы шума, — просто её не заметили,
потому что список шрифтов в конфигурацию не входил.

**Что сделано.**

* система профиля равна системе машины; чужая ОС на не-Linux отклоняется с
  объяснением, а не собирается молча (`HOST_CAN_SPOOF_OS`);
* список шрифтов пришпилен к профилю и выводится из его идентификатора, шрифт
  эмодзи добавляется всегда (их случайный набор для macOS обходится без него в
  44 случаях из 200);
* профили, заведённые до правила, не ломаются молча: `os_mismatch` называет
  беду человеку, а `mantia repair-fingerprints` показывает список и по
  `--apply` пересобирает, сложив прежние отпечатки рядом в файл;
* своя копия сборки Camoufox: в доке «Mantia» и наш значок, дочерние процессы
  тоже переодеты. Оригинал не трогается — правило из `branding.py`. На APFS это
  клон: 657 МБ за 0,3 секунды и без расхода места.

**Замерено после правки:** цветные эмодзи — 12 запусков из 12 (было 0 из 16 у
чужой ОС и 6 из 12 у своей без закреплённых шрифтов). Три профиля показали
странице 10, 20 и 15 разных шрифтов из 22 проверенных — разнообразие настоящее,
а не только в настройках. `lsappinfo` показывает «Mantia», «Mantia Web Content»
и остальные — ни одного чужого имени.

**Что это значит для продукта.** Выдавать Windows-профили с макбука нельзя.
Продавать Windows-профили можно с машины на Windows (там они родные) или с
Linux — там подмена шрифтов работает по-настоящему, и одна машина сможет
выдавать все три системы. Владелец даёт Linux 15 августа; это первое, что стоит
на нём проверить, и там же собирается правка `canvas:seed`.

#### Сеть: ось, по которой мы слепы (15 августа)

Прогнали наш профиль второго уровня через живой проверяльщик подключения
`proxydetect.live` — через продукт и немецкий прокси, не рассуждением.
Результат: `Proxy Detected, 70%`, красных две из девяти. Сошлись часовой пояс,
заголовки и списки; не сошлись отпечаток TCP/IP и их модель.

Причина одна и она физическая: **удалённый прокси завершает TCP у себя**,
поэтому площадка видит стек ВЫХОДНОГО УЗЛА, а не нашей машины. Браузер говорит
«Windows», пакеты приходят со стека Linux-сервера. Из браузера это не чинится
ничем — ни отпечатком, ни агентом.

Отсюда правило, которого у нас не было: система профиля обязана
согласовываться не только с машиной, но и с прокси. Разбор и список того, что
стоит забрать себе (задержки, MSS/MTU, WebRTC, DNS, заголовки), — в
[competitors.md](competitors.md), раздел «Проверяльщики», и в
[PRODUCT.md](PRODUCT.md), раздел «Сеть — вторая ось».

#### Карточка прокси может объявить рабочий прокси мёртвым

Найдено 15 августа на живых данных. **Запуск читает пароль прокси по ссылке
ПРОФИЛЯ** (`ProfileRepo.upstream` — так задумано, см.
`test_proxy_of_deleted_profile_hands_its_password_to_the_heir`), **а проверка —
по ссылке самого прокси** (`ProxyRepo._as_input`). Если у записи прокси своей
ссылки нет, а у профиля есть, получается:

* профиль открывается, трафик идёт, выходной адрес показывается;
* а кнопка «проверить» ставит прокси `degraded` с текстом «апстрим требует
  авторизацию, а креды не заданы».

Человек читает это как «прокси сломался» и идёт менять рабочий прокси. Это
ровно то, чего продукт делать не должен: он тут не врёт по букве (у записи
пароля правда нет), но вводит в заблуждение по смыслу.

**Половина починена 15 августа.** Проверка больше не ходит к апстриму без
пароля и не объявляет прокси мёртвым: она сразу говорит правду — «у этой
записи не хранится свой пароль, он остался у профиля, который ею пользовался;
сам прокси может быть жив, впишите строку подключения заново». Разница между
«прокси умер» и «мы не можем его проверить» здесь и есть вся суть.

Осталась вторая половина, и её надо делать осторожно: сама возможность
проверить такую запись. Пароль намеренно живёт у профиля, чтобы наследник
освободившегося прокси не остался без него (`_password_ref` перебирает три
кандидата, включая профиль-владельца). Ломается это, когда профиль ОТПУСКАЕТ
прокси: связь рвётся, и последний кандидат исчезает. Правильный ответ, скорее
всего, — при отвязке передавать пароль в собственную ссылку записи, тем же
приёмом, что и наследнику.

#### Что проверить следующим (нашлось при разборе проверяльщиков)

**Порт отладки: тревога была преувеличена, и вот точный ответ.**

Первый замер: страница, поданная С ЛОКАЛЬНОГО адреса (`http://127.0.0.1:8791`),
нашла порт профиля первого уровня обычным `fetch` с `mode:no-cors` — открытый
порт отвечает, закрытый даёт отказ соединения:

    НАЙДЕНЫ ПОРТЫ: 42000
    9222 закрыт 24мс   42000 ОТКРЫТ 34мс   43000 закрыт 23мс

Отсюда был сделан вывод «любая страница видит наш порт». **Вывод неверный, и
виноват был замер: настоящий сайт приходит не с локального адреса.**

Второй замер, честный. Та же страница подана с ПУБЛИЧНОГО адреса
(`http://5.180.82.132:8099`, сервер поднимался на время замера и снят), и
проверялись СРАЗУ ДВА способа — `fetch` и `new Image()`, потому что чужой
проверяльщик `fv.pro` сканирует именно картинками, а правила для них другие:

    открытых портов не видно ни одним способом
    fetch 42000 закрыт 2мс   image 42000 закрыт 4мс

Две-четыре миллисекунды — это не отказ соединения, а запрет ещё до сети:
Chrome не пускает публичную страницу в локальную сеть.

**Что отсюда следует.** Порт со страницы не виден, и обе идеи обхода
(`--remote-debugging-pipe`, `--remote-debugging-address`) можно не жалеть —
обе всё равно проверены и отвергнуты замером: канал включает
`navigator.webdriver = true`, а второго флага в сборке нет вовсе.

**Чего замер НЕ покрыл, и это надо закрыть.** Публичная страница подавалась по
HTTP. У страницы по HTTPS правила другие: там запрос в локальную сеть
допускается после предварительного запроса разрешения, и наш отладочный порт
на него, скорее всего, не ответит — то есть тоже будет закрыт. «Скорее всего»
здесь недостаточно: чтобы ответить точно, нужен публичный HTTPS-адрес с нашей
страницей. До тех пор считать вопрос закрытым нельзя.

Второй уровень этой беды не имеет вовсе: там управление идёт своим каналом
Camoufox, без порта.

**Сверка полей между контекстами.** Одни и те же величины (подсказки UA-CH,
строка видеокарты, ядра, память, `Intl`) снять в окне, в Worker и в
ServiceWorker и сравнить. Патч ядра это переживает, инъекция в главный мир —
нет. У нас такой проверки в зонде нет, а стоит она ничего.

#### Куда всё идёт дальше (замысел владельца, 15 августа)

Два решения, оба записаны подробно в [PRODUCT.md](PRODUCT.md).

**Стенд для фрода.** Отдельный проект: самодельный сайт со злой
антифрод-системой. Владелец натравливает на него агентов — регистрация,
ротация прокси, обычная активность, — а антифрод ловит, что всё это одна
машина. Это первая проверка продукта ЧУЖОЙ логикой: до сих пор он проверял
себя своим же зондом и своими представлениями о палеве, то есть не мог
увидеть того, о чём мы не догадались. Mantia про стенд ничего не знает; от
неё требуется лишь пачка профилей, ротация, задача агенту и честный отчёт.
Сигнал «можно натравливать» подаёт наша сторона.

**Строка «антидетект найден» на spoof.expert.** К показу адреса, DNS и
географии добавляется строка о том, видно ли подмену отпечатка. Написать её
можно только из того, что мы намерили: противоречие заявленной системы с
набором шрифтов, пустой набор, отсутствие цветных эмодзи, собственные
свойства у `navigator`, расхождение строки видеокарты с пикселями. Побочно
это поток живых замеров чужих антидетектов прямо в корпус.

**Вся переписка разработки выгружена** в
[dialogue/2026-08-полная-переписка.md](dialogue/2026-08-полная-переписка.md):
сообщения владельца, ответы, рассуждения и строка на каждый вызов
инструмента. Выводы команд не переносились (тридцать мегабайт, в основном
точки прогонов), секреты вычищены.

#### Чем пользоваться

    .venv/bin/mantia serve --port 8756     # окно продукта
    .venv/bin/python -m pytest tests/unit -q   # 1342 прошло, 3 пропущено
    .venv/bin/ruff check src tests             # чисто

Числа проверены своим прогоном 15 августа, а не приняты со слов помощника.

Лабораторный стенд для замера отпечатка ЛЮБОГО браузера на машине лежал в
скретчпаде сессии (`lab/stand.py`); при надобности написать заново — он
поднимает страницу с боевым зондом и метит замер именем из адреса `?l=`.

Прокси для проверок (датацентр, Германия) дал владелец. Строка подключения
**в этот документ не пишется**: репозиторий уезжает на другие машины, а правило
проекта — секреты живут только в `.env` внутри `MANTIA_HOME`. Ключ там
называется `MANTIA_TEST_PROXY_URL`; нет его — спросить у владельца.

Здесь это стоило одной строки, но урок общий: документ передачи контекста
соблазняет дописать в него «то, что понадобится следующему», и пароль попадает
в git незаметно — ровно потому, что документ не выглядит кодом.

#### Следующее по важности

1. **Linux проверен 15 августа — ответ получен, и он не тот, которого ждали.**
   Замер шёл на Ubuntu 24.04 (5 ядер, 7,7 ГБ), сборка Camoufox та же
   152.0.4-beta.28, что и на макбуке. Результат по системам:

   * **`windows` с Linux — ДА.** Профиль показывает семь именно виндовых
     шрифтов (Segoe UI, Calibri, Consolas, Cambria, Ink Free, Gabriola,
     Leelawadee UI), ни одного чужого, и цветные эмодзи рисуются. С нашим
     закреплённым набором — 8 из 8 и 4 из 4; без него 5 из 8, потому что их
     случайный набор иногда роняет шрифт эмодзи. **Это и есть ответ на вопрос,
     ради которого всё затевалось: Windows-профили продаются с Linux-машины.**
   * **`macos` с Linux — НЕТ.** Шрифты правильные (Helvetica Neue, Menlo,
     Zapfino), а цветные эмодзи не рисуются ни разу: 0 из 4 даже с
     закреплённым набором. Camoufox возит метрический дублёр «Apple Color
     Emoji» без рисунков — шрифт Apple распространять нельзя. Пустой квадрат
     вместо смайла выдаёт профиль так же верно, как отсутствие шрифтов.
   * **`linux` на самом Linux — тоже НЕТ, и это неожиданно.** Профиль видит
     РОВНО ОДИН шрифт, Twemoji Mozilla, и ни одного обычного. Не помогло ни
     доустановить шрифты на саму машину (fonts-liberation, fonts-dejavu,
     fonts-noto-color-emoji — проверено), ни отдать свой набор из 78 имён:
     Camoufox на Linux показывает странице только встроенные шрифты, а
     текстовых для Linux у него в комплекте нет.

   Отсюда правило в коде (`_PRESENTABLE` в `runner/camoufox_engine.py`):
   Linux-машина выдаёт ТОЛЬКО Windows-профили, macOS-машина — только свои.
   Про хост Windows замера нет вовсе, поэтому там разрешена только своя
   система: обещать непроверенное — то же самое, что врать.

   Попутно замер поймал ошибку в самой проверке: первая версия
   `mantia check-os-spoof` искала Arial и Verdana, которых на голом Linux нет
   вовсе (там Liberation и DejaVu), и обвиняла продукт в том, чего он не
   делал. Теперь она проверяет шрифты, характерные для ЗАЯВЛЕННОЙ системы, и
   меряет с нашим набором, а не с их случайным.

   Осталось на Linux: **сборка правки `canvas:seed`**. Нужен их build — 40+ ГБ
   диска и 8–16 ГБ памяти; это отдельная почасовая машина, дешёвая VPS не
   потянет.

2. Прогнать живую задачу агента через флот уровня 2 на настоящем аккаунте —
   единственное, что осталось непроверенным в цепочке.
3. **Windows: Job Object у второго уровня написан, но на Windows не
   проверен.** Порядок там обратный первому уровню: браузер поднимает не наш
   код, приостановить его нельзя, поэтому дерево берётся под Job уже ПОСЛЕ
   старта (`platform/win_job.py`), а всё, что родится позже, попадает туда по
   наследству. Щель остаётся: потомок, родившийся до обхода, в Job не попадёт.
   Поиск идёт по каталогу профиля — величине, уникальной у каждого профиля.
   Любая неудача возвращает прежнее поведение, а не ломает открытие профиля.
   Проверить на боевой машине: убить Mantia на Windows и посмотреть, умерло ли
   дерево Camoufox.
4. Не снято с Vision, Octo, 0detect, Incogniton — нужна платная подписка
   (владелец отложил).

### Можно потом

Заголовки и иконки окон профилей на macOS требуют Accessibility API и явного
разрешения пользователя в системных настройках — единственное место во всём
продукте, где придётся о чём-то просить операционную систему. Сегодня
`WindowBrander` там молча ничего не делает, и это честно. Туда же: `.icns` и
обёртка `.app`, `device_pixel_ratio` до браузерного зонда, и, возможно, перенос
`MANTIA_HOME` из `~/.mantia` в `~/Library/Application Support/Mantia` — скрытый
каталог в Finder человек не найдёт.

Отдельно: окно самой программы на macOS открывается и работает, но перетаскивание
и изменение размера за края не работают — `_WindowApi` ищет окно через
`find_window_by_title`, а это WinAPI. Окно безрамочное, поэтому системной рамки
для перетаскивания у него тоже нет.

---

## 7. Куда идём и почему

Порядок здесь — это и есть приоритет.

**0. Маховик провернулся 11–12 августа. Пункт закрыт — но не забыт.**

В §1 ТЗ записано, чем продукт отличается от Octo и от обычной автоматизации:
*модель работает один раз на задачу (компиляция), дальше крутится дешёвый
исполнитель*. Всё остальное — инфраструктура, которую продают и без нас.

**Сейчас в базе два сценария**, оба скомпилированы из удачных прогонов агента
на Grailed и оба в статусе «канарейка». Это первое, что продукт сделал ради
своего единственного отличия, и произошло оно само — как только у агента
появились задачи, которые он доводил до конца.

Что осталось от этого пункта: сценарии **никто ещё не проиграл в бою**. Статус
«канарейка» означает «прошёл один раз и не проверен»; до «основного» им нужны
повторные успешные прогоны той же задачи. Стоит дать ту же задачу второй раз и
убедиться, что она пошла без обращений к модели.

Ниже — история того, почему это не получалось раньше; она объясняет, чего
требует компилятор, и пригодится, когда сценарий снова откажется собираться.

Состояние на 7 августа было: **прогонов 12, дошедших до цели 5,
скомпилированных сценариев — ноль.** Компилятор сшит с движком и работает; он
отказывается сам, и обе причины видны в журнале:

* `действие без описания цели [op='click' seq=4]` — агент кликнул по элементу,
  которого не оказалось в снимке, и якорь из такого шага не построить;
* `скомпилированный сценарий не воспроизвёл исходный журнал` — гейт собрал
  сценарий, проиграл его по записи, не сошлось и **не сохранил**.

Второе — защита, работающая как задумано: негодный сценарий не попадает во
флот. Но пока ни один прогон не стал сценарием, продукт остаётся «агентом
поверх браузера со своими личностями», то есть половиной замысла: каждый
прогон платит модели заново.

**Что доказать.** Довести один удачный прогон до сценария, который пройдёт
гейт, и запустить вторым прогоном **без модели**. День работы. Это ответ
фактом, а не рассуждением: либо экономика есть, либо её нет — и от этого
зависит, стоит ли продолжать вообще.

**1. Дождаться данных корпуса и посмотреть на них.** Зонд стоит с 6 августа на
пяти сайтах и уже дал 28 чужих устройств за сутки (§4). Первый уровень пользы —
**список невозможных сочетаний** — достигнут, первые правила записаны там же.
Дальше нужен второй уровень: 30–60 устройств для распределений по осям, которыми
мы управляем. Ловушка описана в `docs/fingerprint-corpus.md`: **копировать
снятые отпечатки нельзя** — выданный дважды отпечаток связывает два аккаунта
намертво.

**2. Сказать человеку ёмкость его хоста.** `fingerprint_check.py` уже снимает
canvas- и аудио-хеши именно для сравнения профилей между собой, но человеку не
говорит: «профилей на этом устройстве семь, различимых отпечатков один».
Продукт обязан сказать это до запуска, а не после блокировки. Полдня работы, и
это прямое следствие правила «не изображать работающим то, что не работает».
Подробности и следующие шаги — `docs/fingerprint-diversity.md`.

**3. Облако.** Схема уже заложена, обмен файлом работает. Облако заменяет
файл транспортом; правила слияния, надгробия и разрешение конфликтов
переиспользуются целиком.

**4. Альфа-тест.** Продукт ставится и работает. Раздавать людям — после того,
как переезд перестанет быть ручной операцией.

### Форк Chromium: чего не хватает, чтобы начать

Вопрос встал 7 августа в форме «хватает ли информации, чтобы писать форк и
делать разные правдоподобные отпечатки на одной машине». Ответ: **не хватает,
и не той информации, о которой думают.**

Техническая сторона известна достаточно (§ `fingerprint-diversity.md`: цена,
почему инъекция не работает, что форк даёт меньше обещанного). Не хватает того,
чем форк питается.

**Главное недоразумение.** Форк не создаёт разное железо — он кладёт подделку
поверх одной машины. Замерено: открытый `fingerprint-chromium` в Chrome 144
**убрал** подмену GPU совсем, потому что пиксели по-прежнему рисует настоящая
видеокарта. Значит canvas в форке — детерминированный шум на профиль, как у
Octo и AdsPower. Это работает, но это подделка, и она ловится теми же
проверками, от которых продукт сейчас свободен: сегодня Mantia честно говорит
«нам нечего скрывать», с форком — не сможет. Это решение о том, каким продуктом
быть, и принимать его надо до первой строки кода.

**Чего не хватает по существу.**

*Корпуса.* Форк порождает значения; какие значения бывают **вместе**, знает
только корпус. Считано 7 августа: десктопного Chrome в корпусе восемь машин,
из них две наши и три — виртуалки с программным рендерингом. **Пригодных
эталонов — три.** По оси памяти три разных значения, по `devicePixelRatio` два.
Это не распределение, это три точки. Планка из `fingerprint-corpus.md` — сотни
устройств, потому что форк генерирует и те оси, которыми мы сейчас не
управляем. Темп сбора даёт около месяца до сотни.

*Замеров того, что труднее всего.* В проекте нет ни строчки про: отличимость
шума в canvas от честного значения (сердце форка, не проверено); **TLS и
HTTP/2** — JA3/JA4 и настройки H2, единственное, чего из JS не достать вовсе, и
главное преимущество форка; метрики шрифтов — `ClientRects` и измерение текста,
которые связывают профили сильнее canvas и у двух наших профилей совпали
побайтово. Без этих трёх замеров форк пишется вслепую, а оценка «160–400 часов»
остаётся нижней границей из чужого опыта.

*Машина для сборки.* На 16 ГБ не собрать. Не блокер: собирать можно на
арендованной, это единственное осмысленное применение облачных боксов в нашей
задаче (сам отпечаток арендованной машины не годится — см. ниже).

### Арендованное железо вместо своего: разобрано и отвергнуто

Вопрос вставал дважды — GPU-VPS и RunPod. Короткий итог, чтобы не разбирать
третий раз.

**Удалённая отрисовка** (браузер локально, canvas и WebGL считаются на чужой
видеокарте) — не работает. Убийственный довод: сайт даёт свежее случайное число
и гоняет цепочку «нарисовать → прочитать пиксели → нарисовать следующее». Её
нельзя ни закэшировать, ни отправить пачкой, а `toDataURL` и `getImageData`
синхронны — каждый шаг упирается в задержку до сервера. Плюс canvas в основном
рисует процессор через системные шрифты, так что чужая видеокарта хеш не
заменит, а смешает до уникального.

**Профиль целиком на VPS** — это просто второе устройство, арендованное. Годится
при потребительской GeForce в режиме WDDM, потребительской Windows (не Server),
постоянном виртуальном дисплее и жилом прокси. Не годится никогда: Tesla/A10/L4
(таких карт у домашних пользователей не бывает, а на Windows они по умолчанию
без D3D-адаптера, и Chrome отдаёт **отсутствие** WebGL), любой vGPU с суффиксом
профиля, Windows Server как есть. Экономика: $220–500 в месяц за одну личность
против $400–800 разово за железо — аренда догоняет покупку за полтора-три
месяца. Отдельный риск, который легко упустить: живые куки и секреты лежат на
чужом сервере, и один инцидент у хостера сжигает весь кластер аккаунтов сразу.

**RunPod** — хуже обычного VPS по всем осям: только Linux в Docker (доля
Linux-десктопов ~4%, а Linux + RTX 4090 — совсем крошечная корзина), дисплея
нет вовсе и WebGL уходит в SwiftShader без флагов, которые у нас запрещены,
шрифтов в базовом образе три-шесть семейств против сотен, число ядер может
показать все потоки физического хоста. Годится ровно для одного — **собирать
форк**.

**Бесплатный вариант, который уже есть.** В макбуке владельца две видеокарты.
`--use-angle=gl` переводит WebGL на AMD Radeon Pro 5500M целиком: меняются
рендерер, набор расширений (35→31), лимиты, точность и canvas. Это не подделка,
а работа второй карты. Флаг сейчас в списке запрещённых (`flags.py:67`) с
верной формулировкой «подмена бэкенда меняет строку рендерера» — но запрет
писался до того, как замерили, что за ним стоит вторая **настоящая** карта.
Решение стоит пересмотреть, с замером на Windows: там другие бэкенды ANGLE.
Даёт ровно +1 отпечаток, и оба делят шрифты, `ClientRects`, аудио, экран и
ядра, — но стоит ноль.

### Зачем этот продукт, если есть обычный браузер с ИИ

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

**Для одного аккаунта и разовой задачи агент в обычном Chrome не хуже — он
лучше:** ноль настройки, ноль прокси, ноль профилей. Всё, что здесь построено,
начинает что-то значить, только когда верно хотя бы одно: аккаунтов на одной
площадке больше одного; площадка сопротивляется автоматизации; задача
повторяется регулярно и цена прогона имеет значение.

**Чего агент в обычном Chrome не может.** Он работает в вашем браузере с вашими
куками и настоящим адресом — десять аккаунтов одной площадки в нём невозможны
нечем, а не неудобно. Его видно: расширение ходит через свой API, клики
мгновенные, движений мыши нет вовсе; наш ввод даёт `isTrusted: true` пачками с
растущим временем, чего из JavaScript не подделать. И он платит модели за
каждый шаг всегда — а маховик (§7 п. 0) существует ровно затем, чтобы сотый
прогон не стоил ничего.

**Всё упирается в одно число** — сколько аккаунтов одной площадки нужно
одновременно (см. §9, открытый вопрос 1). Один — тогда обычный браузер, и это
честный исход. Три-десять — обычный браузер не годится, но и форк не нужен.
Десятки и регулярно — работает всё построенное, и главное в нём маховик.

---

## 8. Границы

Мы строим универсальную автоматизацию браузера и управление профилями. Мы
**не** строим и не будем: обход CAPTCHA, обход проверки личности и живости,
подмену видеопотока для верификации, массовую автоматическую регистрацию
аккаунтов. Это не осторожность формулировок — это границы задачи, и код в них
не заходит.

Пароли не попадают в контекст модели никогда (§3 п.9 ТЗ). Секреты живут в
`.env` вне репозитория и не выводятся в журнал ни в каком виде.

---

## 9. С чего начать в новой сессии

Если вы читаете это впервые и не знаете, что происходит.

**Состояние на 12 августа 2026.** Продукт работает на Windows (боевая) и на
macOS (разработка). Профили запускаются, агент отрабатывает задачи и умеет
входить в аккаунты по списку. Площадки — Grailed и Etsy. Корпус отпечатков:
31 устройство. **1098 юнит-тестов зелёные.**

Что появилось за 11–12 августа, помимо правок окна (см. §4):

* **модель работает по подписке владельца**, а не только по ключу API:
  провайдер выбирается в окне, `claude-cli` ходит через установленный `claude`;
* **открытые профили отдаются наружу любому ИИ** — `mantia mcp` для Claude Code
  и `/api/control/*` для всего остального (`docs/mcp.md`);
* **маховик провернулся впервые за всю историю проекта**: из удачных прогонов
  скомпилировались два сценария. То, что в прошлой версии этого документа
  стояло как «первое, чем стоит заняться», — сделано;
* **работа внешнего ИИ и старые прогоны видны в отчётах**;
* **найдена и починена причина «медленного интернета»**: переход на простую
  страницу стоил 45 секунд из-за порога готовности (§3). Сеть замерена и ни
  при чём — 5,8 мс до Grailed, ноль потерь.

**Ближайшее, чем стоит заняться** (по убыванию пользы):

1. **Прокси для боевых профилей.** Сейчас все выходят с домашнего адреса
   владельца — самое дорогое, что есть в списке, и никакой отпечаток этого не
   компенсирует. Для трёх магазинов Etsy, которые легально его собственные,
   это не нужно;
2. **лента «Ход работы» не помещается в окне.** Колонка задач теперь
   прокручивается, но сама лента коротка; на macOS окно не растянуть;
3. **расхождение `navigator.language` / `Intl`** в профиле (§4, замер
   отпечатка) — `--lang` не доезжает до рендерера;
4. **находки Codex по производительности**, которые не проверялись замером:
   снимок делает до ~171 команды CDP на шаг; зонд готовности опрашивает
   страницу каждые 120 мс; `--disable-quic` безусловен. Полный разбор —
   в переписке 12 августа, каждую надо мерить, а не чинить по описанию.

**Проверить, что всё живо** — три команды, пять минут:

```bash
python3 -m venv .venv && .venv/bin/pip install -e . --group dev
.venv/bin/python -m pytest tests/unit -q     # ожидается 1098 passed, 2 skipped
.venv/bin/mantia check                       # готовность к запуску
```

Если `mantia check` жалуется — он назовёт, чего не хватает. Обычно это ключ
модели в `~/.mantia/.env` или отсутствующая сборка (`mantia fetch-chromium`).

**Порядок чтения.** `CLAUDE.md` (правила и команды) → этот файл целиком →
`docs/PLAN-antidetect-browser.md` при расхождениях. Разделы 3 и 6 здесь —
самые дорогие: там ошибки, которые не выводятся из чтения кода.

**Четыре открытых вопроса**, каждый требует решения владельца, а не кода:

1. **Сколько аккаунтов одной площадки нужно одновременно?** Главный вопрос
   проекта, стоит открытым с самого начала, и в него упирается всё: и нужен ли
   форк, и нужен ли продукт вообще вместо агента в обычном Chrome (§7, обе
   врезки). До трёх — хватит того, что есть; пять–тридцать — покупать железо;
   больше пятидесяти — тогда разговор про форк. Числа —
   `docs/fingerprint-diversity.md`.
2. **Каким продуктом быть.** Форк означает подделку отпечатка шумом — как у
   Octo и AdsPower. Сегодня Mantia может честно сказать площадке «нам нечего
   скрывать»; с форком не сможет. Это не техническое решение, и принимать его
   надо до первой строки кода форка.
3. **Два одинаковых мака дают один `host_id`** — см. раздел 6, «Открытый
   вопрос». С арендованными машинами это из редкого случая становится обычным:
   хостеры держат одинаковое железо. Развилка описана, решение не принято.
4. **Прокси владельца — дата-центр.** Это самостоятельный признак, важнее
   canvas для строгой площадки. Резидентские прокси дешевле любой разработки.
   Замечено на живых данных: две записи прокси в базе указывали на **один и тот
   же адрес и порт**, то есть два профиля выходили с одного IP.

**Что доделать руками** (не решения, а работа):

* **проверить сценарий в бою** — маховик провернулся, в базе два сценария в
  статусе «канарейка». Дать ту же задачу второй раз и убедиться, что она прошла
  без обращений к модели. §7 п. 0;
* **привязать прокси к боевым профилям.** Сейчас они выходят с домашнего адреса
  владельца; проверка согласованности это видит и говорит вслух. Замер
  отпечатка — §4. Для трёх магазинов Etsy, легально его собственных, не нужно;
* **забирать логи корпуса** раз в несколько дней. Повторный разбор безопасен,
  замеры схлопываются по хешу:

  ```bash
  for h in cansphere legitdress rareamour spoofexpert; do
    scp $h:/var/log/nginx/corpus.log /tmp/$h.log && mantia corpus-ingest /tmp/$h.log
  done
  scp -i ~/.ssh/emirates root@89.127.196.235:/var/log/nginx/corpus.log /tmp/emirates.log
  mantia corpus-ingest /tmp/emirates.log && mantia corpus-summary
  ```

* **прогнать свои профили тем же зондом** и сравнить с корпусом — это пункт 2
  раздела 7, даёт ответ «чем мы уже торчим» до всякого форка и почти даром;
* зонд корпуса не стоит на `exif.expert` — Windows-VPS с туннелем Cloudflare,
  доступа не было. Файлы в проекте лежат, см. `docs/fingerprint-corpus.md`;
* приёмка этапа 2 не проверена — нужен тестовый прокси в `~/.mantia/.env`;
* **скриншот статистики в отчёт очереди** — сегодня там исход, причина, шаги и
  стоимость, но не картинка. И «собери статистику и запиши в таблицу» из §2 ТЗ
  упирается в голодающие приёмники (§4);
* **скомпилированный сценарий входа не подставляет секрет в шаблон**: в файл
  пишется `{{site_password}}`, движок парсит без подстановки и падает на
  `SecretRef.parse`. Вход поэтому каждый раз идёт агентом, а не даром;
* `cansphere`, `rareamour`, `spoofexpert` живут **без git**. Любая выкатка там
  необратима и неотслеживаема. К Mantia отношения не имеет, но знать стоит;
* серверы `manager` (31.56.39.177) и `worker` (5.181.187.149) сменили ключ
  хоста — SSH предупреждает. Проверить, что это пересоздание, а не что-то ещё.

**Про помощников.** На машине стоят `codex`, `~/.kimi-code/bin/kimi` и вторая
учётка `claude` — владелец просит подключать их к проверке. Это окупилось: три
независимых агента нашли два серьёзных дефекта, которые сессия пропустила
(подстрочное сравнение пути профиля убило бы браузер соседнего профиля;
`lsof -iTCP@127.0.0.1` не видит слушателя на `0.0.0.0`). У Kimi маленький
лимит — давать по одному куску дифа и запрещать пользоваться инструментами.
Их утверждения проверять замером: Codex один раз объяснил прыжки курсора
коалесцированием событий, а измерение показало другую причину.
