Назначение: определить единую границу управления (ADR-0003). Это набросок контракта, а не финальный IDL: он фиксирует форму, именование, модель ошибок, события и версионирование, чтобы CLI, MCP и интерфейс уже сейчас проектировались под него.
1. Транспорт — РЕШЕНО (ADR-0006, заменяет ADR-0005)
- Канонически: localhost HTTP/1.1 + JSON, ресурсный REST, привязка к
127.0.0.1, описан в OpenAPI 3.1 (contract/openapi.yaml) → кодогенерация CLI/MCP/SDK и Postman. (3.1 предварительно — проба кодогенерации может опустить до 3.0.3 ради инструментов Rust; CODEGEN_PIPELINE §4.) - Аутентификация: bearer-токен API. Порт и токен публикуются в файл обнаружения на пользователя
(
~/.vflin/local-api.json) — как у AdsPower; клиенты читают его, чтобы подключиться. - События и поток: WebSocket на
/events(подписка, двусторонний); SSE как односторонний запасной путь. - Необязательный безопасный канал ОС: те же обработчики подаются и через UDS / именованный канал для локальных клиентов (интерфейс, CLI), которым не нужна поверхность TCP. Обработчики остаются независимыми от транспорта.
- Облака достигает только Sync Client ядра, никогда не клиенты Local API.
- Полное обоснование и альтернативы:
TRANSPORT_COMPARISON.md(internal:docs/20-control-plane/TRANSPORT_COMPARISON.md).
2. Соглашения
- Методы в пространствах имён:
domain.action(например,profile.start). - Все методы принимают один типизированный объект; все возвращают
{ ok, data?, error? }. - Идентификаторы — строки UUIDv4. Отметки времени — RFC3339 UTC.
- Каждый изменяющий вызов несёт ключ идемпотентности и
mode(режим политики).
3. Модель ошибок
error: { code: string, message: string }
Стабильные строковые коды, не числа. Полный список с HTTP-статусом, в который каждый отображается, — это таблица в §8; она ниоткуда не генерируется и сверяется с демоном тестом, так что это единственное место, где читать, и единственное, где править.
retryable и details были в этой форме и в конверте на проводе их нет: retryable убрали 2026-08-16, потому
что его выдавала каждая ветвь и ни один клиент его не читал, а details так и не построили. Четыре кода,
которые этот абзац раньше называл, — PROFILE_LOCKED_REMOTE, PROFILE_DIRTY, FINGERPRINT_INVALID,
VAULT_LOCKED — были написаны 2026-06-17 в архитектурной основе Phase-0, до того как появился демон, и ни один
из них не был реализован. Что демон возвращает на самом деле — в §8.
4. Каталог методов (набросок v0)
profile
profile.create({ name, fingerprintRef|fingerprint, proxyRef?, group? }) → { id }profile.list({ filter?, group? }) → { profiles[] }profile.get({ id }) → { profile, state }profile.start({ id, headless?, mode }) → { handle, cdpEndpoint }profile.stop({ id, sync? }) → { state }profile.materialize({ id }) → { path, state }profile.delete({ id, hard?, mode }) → { ok }(жёсткое удаление = режим danger)
profile.session (Feature c — модуль сессий, ADR-0034)
Привязать сохранённую сессию сервиса, чтобы профиль открывался уже вошедшим. Секреты едут в хранилище по
ссылке; эти методы секрет никогда не возвращают. Руководство оператора: SESSIONS_MODULE.md (internal: docs/20-control-plane/SESSIONS_MODULE.md).
session.recipe.list() → { recipes[] }(зелёный — каталог поддерживаемых сервисов)profile.session.list({ id }) → { sessions[] }·profile.session.get({ id, service }) → { attached, binding? }profile.session.set({ id, service, mode, secrets, label? }) → { attached, binding }(danger)profile.session.clear({ id, service }) → { attached }(danger — стирает и секреты из хранилища)
fingerprint
fingerprint.generate({ constraints }) → { config }fingerprint.validate({ config }) → { valid, issues[] }fingerprint.catalog.list() → { presets[] }fingerprint.generateFromHost({ timezone, language, screen* }) → { config }— инверсия хоста (ADR-0031 B-local): аппаратные измерения настоящие, свободные берутся из запроса.NOT_FOUND, пока хост не охарактеризован.node.characterize() → { NodeCapability }— снятие хоста для B-local через ЕДИНСТВЕННУЮ границу управления (инвариант 1): запускает ядро на ЭТОМ устройстве и кэширует отпечаток хоста, чтобыgenerateFromHost/fromHostвыводили согласованные с хостом личности (раньше это был отдельный бинарникcapture-profile).
proxy
proxy.create / proxy.list / proxy.test({ id }) → { ok, ip, latencyMs }proxy.bind({ profileId, proxyId })
vault (всё закрыто режимом и идёт в аудит)
vault.get({ key, mode }) · vault.put({ key, value }) · vault.export({ scope, mode })
automation
automation.script.upsert / listautomation.run({ scriptId, target: {profileIds|group}, vars? }) → { runId }automation.status({ runId }) · automation.cancel({ runId })automation.group.upsert / schedule
sync
sync.push({ id }) · sync.pull({ id }) · sync.status({ id })sync.lock.acquire({ id }) · sync.lock.release({ id })
core
core.list() → { providers[], versions[] }core.select({ providerId, version }) · core.diagnose() → { report }
system
system.health() · system.policy.get/set({ mode }) · log.tail({ filter })
transfer (A222, ISSUE-651 — профили из другой программы)
transfer.vision.read({ token }) → VisionCatalog— Vision человека, прочитанный его собственным токеном, толькоGET(https://api.browser.vision/api/v1,X-Token); в Vision ничего не меняется, токен нигде не записывается.transfer.vision.start({ token, profiles[], local }) → TransferJob(danger, опрашиваетtransfer.job) — каждый профиль делается так, как его делаетprofile.create, на своём выходе (логин и пароль — в хранилище), затем поверх — отпечаток из Vision поле за полем (K98), заявленный Chrome — версии этого движка; теги (если нет —Vision), заметка, статус, цвет и значок папки, cookies ждут в хранилище первого запуска. Один раз на профиль:fingerprint.provenance.realDeviceRef=vision:<id>. Сlocal(A225) — ещё и его файлы с этого компьютера:%APPDATA%/Vision/profiles/<id>.zipили живая папка, если она новее; профиль, открытый сейчас в Vision (егоlockfileзанят), переезжает без них. Файлы — это закладки, история, значки сайтов, Local/Session Storage, IndexedDB, вкладки, сохранённые пароли (Vision хранит их открытыми,K99; здесь они запечатываются собственным ключом OS-crypt профиля, созданным до его первого запуска; значение, которое запечатал браузер Vision, не переносится и считается) и его расширения из Chrome Web Store (скачиваются один раз на компьютер черезextension.addFromStore, привязываются к профилю, их данные едут с файлами). Не переносятся: cookies (их приносит API; оба счёта — в журнал),Web Data, собственные расширения Vision (их id — хэш пути Vision,K104). Что будет найдено, заранее говоритdiskв ответе чтения.transfer.job({ id }) · transfer.current() · transfer.stop({ id })- CLI:
vflin transfer vision-read --token-file <path>·vision-start --token-file <path> --profiles <id,id> [--local]·current·job <id>·stop <id>— токен из файла, никогда не из командной строки. MCP: создаётся из контракта (vflin_transfer_*).
5. События (push, по потоку)
profile.state_changed, automation.run_progress, sync.progress, core.crash,
policy.violation, proxy.health_changed. Клиенты подписываются; интерфейс, CLI и MCP их показывают.
Что шлётся сегодня (GET /v1/events, JSON с меткой event — список выше — это замысел, здесь — провод):
profile.state_changed {id, state}— запуск, остановка, падение.core.crash {id, pid}— браузер, который ушёл нечисто.app.shell_changed {connected}(A170, ISSUE-468) — окно программы подключилось к демону или ушло последнее, с числом окон, которое считается сейчас. Запустить установщик может только подключённое окно, поэтомуapp.update.status.shellConnectedрешает, предложит ли экран обновление внутри программы или скачивание через браузер; раньше смену ничто не объявляло, и окно, спросившее статус до того, как подключился его собственный мост событий, получалоfalseи оставалось на пути через браузер, пока что-нибудь другое не заставляло его перечитать.profile.start_failed {id, code, message}(A117, ISSUE-290) — запуск, который не удался, словами демона: тот жеcode, которым ответил вызов. Сюда входит и запуск, чью личность не удалось применить (FINGERPRINT_APPLY_FAILED— браузер закрывается, а не остаётся открытым с половиной личности).transfer.job_changed {id}(A222) — перенос сдвинулся: профиль начался или закончился, или закончился весь перенос; сама задача —transfer.job.automation.warmup.progress {warmupId, profileId, stage, done, total, url?}(A117, ISSUE-279) — робот cookie на одном профиле:stage— этоqueued·starting·visiting·done·failed·stopped;doneизtotalсайтов;url— страница, на которой он сейчас. Те же факты читаются обратно черезautomation.warmup.list.
6. Версионирование
system.health()возвращаетapiVersion(semver). Ломающие изменения поднимают мажор.- Добавленные методы и поля — это минор. Клиенты обязаны игнорировать незнакомые поля.
7. Жёсткие правила
- Ни один метод не возвращает сырой путь файловой системы в обход жизненного цикла. Единственный мост,
materialize, возвращает сырой путь только локальному интерфейсу или владельцу; клиенты CLI, MCP и скриптов получают непрозрачный дескриптор, а любая выдача сырого пути — это режим power и аудит (PROFILE_STORAGE_SYNC §6, SECURITY_POLICY §2). - Никакой бизнес-логики на клиентской стороне этого API. (См. MODULE_BOUNDARIES.md §5.)
8. Конкретный контракт (OpenAPI) — поверхность ходячего скелета
Машиночитаемый источник истины — contract/openapi.yaml (internal: contract/openapi.yaml)
(OpenAPI 3.1). В Phase 0 сквозь него смоделированы 6 методов ходячего скелета; остальное из §4
переносится в него по одному методу навыком local-api-contract-designer.
Отображение RPC на REST. Логические имена domain.action отображаются в ресурсные пути REST:
- CRUD → ресурс:
profile.create→POST /profiles,profile.list→GET /profiles,profile.get→GET /profiles/{id},profile.delete→DELETE /profiles/{id}. - Действие над ресурсом → подпуть:
profile.start→POST /profiles/{id}/start,profile.stop→POST /profiles/{id}/stop. - Чтения синглтонов и коллекций:
core.list→GET /cores,system.health→GET /system/health. - Базовый путь несёт мажорную версию:
/v1.
Конверт и статус. Успех = HTTP 200 + { ok:true, data }. Неудача = отображённый HTTP-статус +
{ ok:false, error: { code, message } }. contract/openapi.yaml проводит каждую неудачу через
default: $ref: Error и за статусом отсылает сюда, так что таблица ниже — это контракт статусов, а не
комментарий к нему.
Она ПОЛНАЯ, и она проверяется. Каждый код, который демон может выдать на провод, — это строка, и проверка
идёт в обе стороны: строка, называющая код, которого демон не строит, роняет гейт, и код, который демон строит
без строки, роняет его тоже. Гейт — это тест the_status_table_in_the_spec_is_what_the_daemon_returns в
vflin-localapi, и половину от демона он получает, КОНСТРУИРУЯ каждую ошибку и вызывая into_response(): он
читает ответ с того же пути кода, что и клиент, а не сопоставляет исходники с шаблоном. Новый вариант
ApiError тоже не проскочит: компилятор откажет варианту без кода (у ApiError::code() нет ветви _), а гейт
считает ветви этой функции и требует по образцу на каждую — потому что вариант с кодом, который гейт никогда
не КОНСТРУИРУЕТ, оставил бы гейт зелёным над кодом, который демон выдать может, а эта таблица не перечисляет.
Колонка source говорит, откуда код происходит: daemon — построен локально через ApiError; cloud —
передан от облачного бэкенда функцией map_cloud_err с сохранением исходного статуса (ISSUE-014), так что
неверный облачный пароль остаётся 401, а не сплющивается в 400; both — тот же код и статус на обоих путях.
| код | HTTP | источник | когда |
|---|---|---|---|
INVALID_ARGUMENT |
400 | both | запрос неправильной формы или названное значение непригодно |
UNAUTHENTICATED |
401 | both | нет bearer-токена или он неверен; либо облако отвергло учётные данные |
TOTP_REQUIRED |
401 | cloud | у облачного аккаунта включена 2FA, а код не передан |
TOTP_INVALID |
401 | cloud | переданный код 2FA не сошёлся |
POLICY_DENIED |
403 | daemon | отказано политикой — этому актору нельзя, ADR-0036 |
ACCOUNT_REQUIRED |
403 | daemon | маршрут профиля или запуск, когда у программы нет годной сессии аккаунта — вход не выполнен или сервер аккаунта недостижим дольше офлайн-запаса (A198, ADR-0051); в details — access, lastContactAt, offlineUntil, offlineAllowance. С A217 (ISSUE-653) — и любое ОБЛАЧНОЕ действие без сессии, с details.signedOut, когда сессию завершило облако |
DEVICES_FULL |
403 | cloud | A217: вход, когда все устройства, которые позволяет тариф, уже вошли (облако 0.0.23); details limit, used, devices {id, name, current, lastSeen, createdAt} — войти снова с replaceDeviceId |
SHARE_NEEDS_PAID_PLAN |
403 | cloud | A217: передача профиля с тарифа, на котором она закрыта (details.plan) |
KEYRING_PROGRAM_ONLY |
403 | cloud | A217: ключи аккаунта публикует только программа на устройстве со входом |
FORBIDDEN |
403 | cloud | облако отказало этому актору |
QUOTA_EXCEEDED |
403 | cloud | достигнут предел тарифа, мест или хранилища |
SEATS_FULL |
403 | cloud | A214: место для нового профиля отказано — места тарифа заняты (details used, limit, plan); с localAllowed демон создаёт профиль здесь, на компьютере |
CREATIONS_EXHAUSTED |
403 | cloud | A214: создания расчётного периода кончились (details used, ceiling, resetsAt) |
SEAT_IN_CLOUD |
409 | cloud | A214: место нельзя освободить, пока профиль в облаке, — он уходит через корзину |
PROFILE_TOO_LARGE |
413 | cloud | A214: отправка больше, чем облако принимает для одного профиля (details size, limit); профиль остаётся здесь |
PAYLOAD_TOO_LARGE |
413 | cloud | A217: тело запроса больше, чем принимает облако |
NOT_FOUND |
404 | both | названного ресурса не существует |
CONFLICT |
409 | both | форма верна, а отказ — из-за СОСТОЯНИЯ, а не прав |
CORE_NOT_EXITED |
409 | daemon | браузер отказался закрыться, поэтому его данные не удалось запечатать |
SEAL_REFUSED |
409 | daemon | живые данные потеряли ключевой файл, который был в предыдущей запечатанной копии (A152), — не запечатано, живая папка сохранена |
PROFILE_KEY_UNAVAILABLE |
409 | daemon | запуск не нашёл ключа, который открывает собственные cookies и пароли профиля, — ни ключа этой машины, ни того, что несёт его запечатанная копия, — или нашёл их под двумя ключами (A153, ADR-0047), или не смог записать для аккаунта ключ, который отдал бы браузеру (A154); не запущен. Хранилище, которое не удаётся ПРОЧИТАТЬ, — не отказ: запуск идёт дальше и говорит об этом в storesUnread (A155) |
SETTINGS_DAMAGED |
409 | daemon | запуск нашёл Default/Preferences на месте и нечитаемым (A152); не тронут, копия сохранена — profile.start с defaultSettings откладывает его в сторону (A153) |
APP_UPDATING |
409 | daemon | запуск, пока app.update.apply закрыл профили для установщика (A153) |
PROFILE_BUSY |
409 | daemon | запуск ждал дольше срока, пока шла другая операция с тем же профилем; сообщение её называет |
WALLET_NOT_IN_COPY |
409 | daemon | отправка отклонена: запись профиля называет настроенный кошелёк, а запечатывание его копии не нашло ни одного файла кошелька, и отправка заменила бы копию в облаке, где кошелёк есть (A223); спросит снова после следующего запечатывания |
ALREADY_EXISTS |
409 | cloud | дубликат, который облако не создаст дважды |
STALE |
409 | cloud | конфликт векторов версий (ADR-0016) |
PROFILE_TRASHED |
409 | cloud | A217: профиль лежит в корзине аккаунта в облаке |
FAILED_PRECONDITION |
409 | cloud | не выполнено предусловие облака — например, точное покрытие при ротации |
LEASE_LOST |
409 | cloud | синхронизационную блокировку перехватили или отгородили (ADR-0015) |
LOCKED_REMOTE |
409 | cloud | синхронизационную блокировку держит другое устройство |
BLOB_UNREADABLE |
422 | daemon | сохранённая нагрузка не открывается ключом, который держит эта сессия |
LOCKED |
423 | daemon | keyring запечатан — разблокируйте, прежде чем этот вызов пройдёт |
RATE_LIMITED |
429 | cloud | отступите и повторите |
CORE_LAUNCH_FAILED |
500 | daemon | браузерное ядро не запустилось |
INTERNAL |
500 | daemon | непредвиденный сбой; подробность — в daemon.log под показанным id |
CLOUD_ERROR |
502 | cloud | облако отказало так, что своего кода у этого нет |
BLOB_IO |
503 | daemon | файл в живой папке профиля не удалось прочитать или записать — обычно временно |
UNAVAILABLE |
503 | both | зависимость на мгновение не готова (включая «облако недостижимо») |
CONFLICT, чьё сообщение называет отказ, — НЕ строка этой таблицы (A156; этому научил гейт выше, который
покраснел ровно на двух таких строках). APP_UPDATE_NOT_READY, APP_SHELL_ABSENT, APP_UPDATE_BLOCKED,
APP_UPDATE_PROFILES_NOT_CLOSED, APP_UPDATE_PROFILE_BROKEN и APP_UPDATE_PROFILES_UNREADABLE — это префиксы
внутри сообщения 409 CONFLICT, а не варианты ApiError, поэтому код на проводе, по которому ветвится клиент, —
CONFLICT. Они описаны там, где сама операция: APP_UPDATE.md (internal: docs/20-control-plane/APP_UPDATE.md).
Таблица остаётся картой кодов, которые демон действительно строит, — именно поэтому её можно проверять гейтом в обе
стороны.
Два статуса стоит прочитать дважды, потому что оба здесь были неверны до 2026-08-23. LOCKED_REMOTE —
это 409, а не 423: это код, переданный от облака, и map_cloud_err сохраняет собственный статус облака,
а это и значит «передавать честно». 423 — это LOCKED, запечатанный keyring, совсем другая вещь. А
CORE_NOT_EXITED — это 409, а не 503 (A24, 2026-08-20): служба здорова, а предусловие не выполнено, так что
приглашать вызывающего вернуться было бы ложью.
Сквозные заголовки. Изменяющие вызовы шлют Idempotency-Key и X-Vflin-Mode
(normal|power|danger). Аутентификация — Authorization: Bearer <token> из ~/.vflin/local-api.json.
Lockstep (один контракт → три поверхности). У каждой операции есть поля x-vflin-*, чтобы CLI, MCP и SDK
порождались из этого файла (инвариант #8; обеспечивается api-contract-codegen-guard):
domain.action |
REST | CLI | инструмент MCP | режим | опасность |
|---|---|---|---|---|---|
system.health |
GET /system/health |
vflin system health |
vflin_system_health |
normal | 🟢 |
core.list |
GET /cores |
vflin core ls |
vflin_core_list |
normal | 🟢 |
profile.list |
GET /profiles |
vflin profile ls |
vflin_profile_list |
normal | 🟢 |
profile.create |
POST /profiles |
vflin profile create |
vflin_profile_create |
normal | 🟡 |
profile.start |
POST /profiles/{id}/start |
vflin profile start <id> |
vflin_profile_start |
normal | 🟡 |
profile.stop |
POST /profiles/{id}/stop |
vflin profile stop <id> |
vflin_profile_stop |
normal | 🟡 |
profile.session.set |
POST /profiles/{id}/sessions |
vflin profile session set <id> |
vflin_profile_session_set |
danger | 🔴 |
profile.session.clear |
DELETE /profiles/{id}/sessions/{service} |
vflin profile session clear <id> <svc> |
vflin_profile_session_clear |
danger | 🔴 |
Эта таблица — ВЫДЕРЖКА, а не каталог. Она написана, когда поверхность состояла из шести методов ходячего скелета; отгруженная поверхность теперь куда больше, а каждая запись здесь поддерживается руками, поэтому она гниёт. Достоверный список —
contract/openapi.yamlи его порождённая проекция (vflin surface/crates/vflin-client/src/generated.rs). Заметьте также, что ось режима живая: мутаторы кошелька и сессий —danger, а неnormal(аудит документов 2026-07-19).
События остаются на WebSocket /events (§5) — OpenAPI не умеет описывать WS; планируемый дом для них —
документ AsyncAPI (OPEN_QUESTIONS).
Открытые вопросы
Схема событий и потока (AsyncAPI?), время жизни и ротация токена аутентификации, пакетирование запросов — OPEN_QUESTIONS.md.
Токен, с которым работает демон, и тот, что получает браузер (A146, ISSUE-428)
system.health несёт об этом три поля, и они отвечают на разные вопросы:
| поле | говорит |
|---|---|
processToken |
normal / elevated / restricted — с чем работает ЭТОТ демон. Само по себе не причина, по которой запуск срывается |
browserToken |
as-us / plain / stuck — с чем работает браузер, который он запускает. plain: демон повышен, и браузеру вместо этого дан собственный отфильтрованный токен человека, так что он работает со средним уровнем целостности, как будто программу никто не запускал от администратора |
startRefusal |
точные слова, которыми profile.start отказал бы прямо сейчас; ОТСУТСТВУЕТ, когда отказа не будет |
Почему startRefusal — поле, а не только ошибка. Владелец несколько дней запускал установленную программу от
администратора; его журналы записали отказ пять раз за четыре дня, и ничто не сказало об этом, пока он не нажал
«Старт». Окно должно говорить это, когда программа ЗАПУСКАЕТСЯ, — поэтому демон публикует ту же фразу, которой ответил
бы profile.start, прочитанную из тех же двух функций, а клиент показывает её как есть. Присутствующий
startRefusal — никогда не причина для клиента прятать кнопку запуска: отказывает демон, и он говорит почему.
Каков отказ теперь. Не «демон повышен» — встречный флаг A110 это снял, что замерено в A146 (повышенный
демон с переопределением запустил профиль за 1.0 с и удержал его через канал). Это «этому браузеру пришлось бы
работать с токеном, с которым нельзя, и эту машину не удалось заставить выдать ему простой токен человека вместо
него» — и отказ называет шаг, который не удался. У ограниченного токена нет пути к отфильтрованному, поэтому он
по-прежнему отказывает.