Назначение: определить единую границу управления (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 })
5. События (push, по потоку)
profile.state_changed, automation.run_progress, sync.progress, core.crash,
policy.violation, proxy.health_changed. Клиенты подписываются; интерфейс, CLI и MCP их показывают.
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 |
FORBIDDEN |
403 | cloud | облако отказало этому актору |
QUOTA_EXCEEDED |
403 | cloud | достигнут предел тарифа, мест или хранилища |
NOT_FOUND |
404 | both | названного ресурса не существует |
CONFLICT |
409 | both | форма верна, а отказ — из-за СОСТОЯНИЯ, а не прав |
CORE_NOT_EXITED |
409 | daemon | браузер отказался закрыться, поэтому его данные не удалось запечатать |
ALREADY_EXISTS |
409 | cloud | дубликат, который облако не создаст дважды |
STALE |
409 | cloud | конфликт векторов версий (ADR-0016) |
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 | зависимость на мгновение не готова (включая «облако недостижимо») |
Два статуса стоит прочитать дважды, потому что оба здесь были неверны до 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.