VFLIN

docs · docs/20-control-plane/LOCAL_API_SPEC.md · перевод от 2026-11-05 · с английской ревизии b3ca79ee5834

Local API

localhost HTTP/JSON-контракт, на котором говорит каждый клиент; вместе с contract/openapi.yaml

Назначение: определить единую границу управления (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 / list
  • automation.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 с и удержал его через канал). Это «этому браузеру пришлось бы работать с токеном, с которым нельзя, и эту машину не удалось заставить выдать ему простой токен человека вместо него» — и отказ называет шаг, который не удался. У ограниченного токена нет пути к отфильтрованному, поэтому он по-прежнему отказывает.