VFLIN
ВойтиСкачать

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

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 })

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.createPOST /profiles, profile.listGET /profiles, profile.getGET /profiles/{id}, profile.deleteDELETE /profiles/{id}.
  • Действие над ресурсом → подпуть: profile.startPOST /profiles/{id}/start, profile.stopPOST /profiles/{id}/stop.
  • Чтения синглтонов и коллекций: core.listGET /cores, system.healthGET /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.