Этот документ описывает сетевой протокол Remcli в том виде, в каком он реализован в P2P-сервере (packages/remcli-cli/src/daemon/p2p/). Протокол намеренно мал: JSON поверх HTTP для чтения/действий и Socket.IO для синхронизации в реальном времени. Большинство payload сквозно шифруется на стороне клиента; границы шифрования и детали кодирования — см. encryption.md.
Границы шифрования и request integrity описаны в encryption.md и p2p-security.md. Direct LAN через HTTP остаётся режимом доверенной сети; request proof не заменяет HTTPS.
- HTTP API: JSON-запросы/ответы на роутах
/v1и/v2. - WebSocket: Socket.IO-сервер на пути
/v1/updates(транспорты: websocket, polling). - CORS:
*(на стороне сервера).
Протокол спроектирован минимальным, явным и устойчивым к нестабильной связи. Несколько руководящих принципов определяют нейминг, payload и версионирование:
- Малая поверхность вместо полноты. Роуты и события существуют, только если дают чёткий примитив синхронизации (например, сессии, машины, KV). Если возможность можно выразить как данные внутри существующего примитива — так и следует делать.
- Явные типы событий и короткие ключи. Payload обновлений использует
tдля типа события и лаконичные имена полей (sid,id,seq), чтобы уменьшить размер сообщений без потери смысла. Эти имена стабильны, потому что используются всеми клиентами. - Разделение персистентного и эфемерного. Всё, что должно восстанавливаться после переподключения, — событие
updateс номером последовательности. Присутствие и usage —ephemeral, чтобы избежать путаницы состояния и минимизировать хранение. - Монотонное упорядочивание на уровне пользователя.
UpdatePayload.seq— единый счётчик на пользователя. Это упрощает согласование на клиенте: применяй обновления по порядку — и ты консистентен для этого пользователя. - Оптимистичный контроль конкурентности по умолчанию. Версионируемые поля (metadata, agent state, daemon state, KV) требуют
expectedVersion. Это предотвращает молчаливые перезаписи и оставляет разрешение конфликтов за клиентом. - Границы шифрования на стороне клиента. Серверу никогда не нужно понимать plaintext. Поэтому протокол трактует большинство payload как непрозрачные строки или base64-блобы, что упрощает серверную логику и усиливает гарантии приватности.
- Обратная совместимость вместо ломающих изменений. Новые роуты/события добавляются, а не мутируют существующие формы несовместимым образом.
- Отказ от полного набора REST-глаголов. Чтение — преимущественно
GET, запись/действия — преимущественноPOST,DELETE— когда намерение однозначно. Полная палитра REST не используется, потому что многие мутации не привязаны чисто к одной сущности или выходят за рамки CRUD-логики. ОграничениеGET+POST(плюс изредкаDELETE) упрощает клиент и делает протокол яснее.
Если предлагается новое поле или событие протокола, оно должно ответить на вопрос: создаёт ли это долговечный примитив синхронизации, или это можно закодировать внутри существующих зашифрованных payload без расширения поверхности API?
API-endpoint'ы (/v1/*, /v2/*) требуют Authorization: Bearer <token>. Тот же токен используется и в handshake Socket.IO. Роуты статических файлов (ассеты веб-приложения) и /health аутентификации не требуют. Единственное исключение — одноразовый opaque ticket GET /v1/pairing-rekey/:ticket: ответ не содержит открытого ключа и зашифрован на ephemeral public key браузера.
Bearer аутентифицирует соединение. Все изменяющие HTTP и Socket.IO запросы,
работающие по bearer, дополнительно несут одноразовый request proof, построенный
от authSecret и привязанный к transport, операции, request id и payload.
Исключение — daemon-issued session runner с проверенным runnerCredential и
ограниченной session scope. Proof содержит подписанный expiresAt; daemon
отклоняет отсутствующий, неверный или повторно использованный proof до
handler/store side effect, хранит replay id только до expiry и временно
fail-closed при заполненном живыми id bounded cache. Контракт
заголовков/полей и canonical payload должен оставаться общим для CLI и web;
security model и ограничения — в p2p-security.md.
Для JSON HTTP endpoint canonical payload — parsed JSON body. Для multipart
POST /v1/voice/transcribe proof имеет action-level payload null: он
одноразово привязывает operation/request id, но не хеширует audio stream.
Демон показывает в терминале QR-код, кодирующий URL:
http://<LAN_IP>:<PORT>/terminal/connect#<base64(JSON)> (LAN)
https://<subdomain>.trycloudflare.com/terminal/connect#<base64(JSON)> (tunnel)
Хэш-фрагмент — компактный JSON в base64 {k: <pairing material>, v: <version>}; host/port выводятся из URL. v1 содержит один 32-byte secret. v2 содержит 64 bytes: authSecret || contentSecret. authSecret формирует bearer HMAC-SHA512, а contentSecret остаётся ключом legacy content encryption. Веб-клиент принимает только соответствующие пары длина/версия.
Если QR недоступен, веб-клиент принимает эту же полную ссылку одной вставкой из terminal output. Отдельные поля адреса и ключа не используются. Принимаются только http/https; hash-ключ остаётся в браузере и не показывается в статусе или ошибке подключения.
Pairing хранится в ~/.remcli/p2p-pairing.json с правами 0600: { v: 2, authSecret, contentSecret, port, createdAt }. Старый файл { secret, ... } мигрируется как одинаковые auth/content secrets. Материал pairing не пишется в daemon.state.json, heartbeat, machine metadata или диагностические логи.
Перед публикацией machine, QR или tunnel daemon сначала поднимает собственный
machine-scoped Socket.IO client и ожидает подтверждённую регистрацию всех
стартовых machine RPC handlers. Поэтому первый экран New Session не обращается
к ещё неготовому dispatcher. Веб-клиент, со своей стороны, ждёт
аутентифицированный Socket.IO handshake до initial RPC. Пока self-machine
переподключается, machine RPC возвращает временную недоступность вместо ложного
No handler registered.
Собственный localhost machine client дополнительно предъявляет
daemonMachineCredential: это случайная capability, действующая только до
завершения процесса daemon. Она не является полем pairing, не попадает в QR,
логи или browser state и нужна только для отличия self-machine от клиента,
который знает pairing bearer.
Show QR запрашивает QR через зашифрованный machine RPC и отображает его только в React state текущего браузера. Он не попадает в URL, history.state, toast или P2P event.
Rekey создаёт короткоживущий pending request с browser ephemeral public key. Демон не меняет ключ до локального подтверждения:
remcli daemon rekey approve <request-id> <code>
Команда обращается только к loopback control server. При подтверждении daemon
сначала готовит replacement bearer только для своего localhost machine socket,
ждёт его RPC readiness и повторно проверяет TTL/state перед persistence и перед
commit. Только затем он записывает новый authSecret, отключает user/machine
sockets со старым bearer и оставляет contentSecret прежним. Временная
способность self-machine живёт только в памяти daemon, не входит в QR/pairing
file и не попадает в логи. При любой ошибке или expiry transaction восстанавливает
прежний pairing и machine RPC. Актуальный QR запечатывается tweetnacl.box для
инициировавшего браузера; endpoint ticket отдаёт только sealed payload с
Cache-Control: no-store. Закрытие pending dialog посылает cancel с request ID
и approval code; coordinator повторно сверяет TTL/state перед самой ротацией.
После расшифровки replacement browser сохраняет его как recovery credential до
Socket.IO handshake: старый bearer уже отозван, а Socket.IO продолжает reconnect.
ACK-capable session runner может продолжить или переподключиться только с
валидным daemon-issued runnerCredential. Если сохранённый порт занят при
запуске, daemon выбирает новый случайный порт и новый QR всё равно требуется.
Quick-tunnel cloudflared меняет URL после каждого старта, поэтому tunnel QR
пересканируется после рестарта. Public QR публикуется только после подтверждения
соединения cloudflared с edge. Если регистрация не состоялась или соединение
позднее потеряно, daemon очищает public endpoint и остаётся доступен только по
LAN; после исправления сети нужен новый запуск start:tunnel и новый QR.
Rekey ротирует только authSecret: старые bearer и request proof сразу
отзываются, а contentSecret остаётся у активных runners ради непрерывности.
Это revoke remote control, а не полная ротация конфиденциальности; детали и
границы этой операции описаны в p2p-security.md.
Подключение через Socket.IO:
path: "/v1/updates"
auth: {
token: "<bearer token>",
clientType: "user-scoped" | "session-scoped" | "machine-scoped",
sessionId?: "<session id>",
machineId?: "<machine id>"
}
Внутренний self-machine daemon добавляет daemonMachineCredential; внешние
клиенты никогда не должны отправлять или хранить это поле.
Правила, проверяемые на сервере:
tokenобязателен.session-scopedтребуетsessionId.machine-scopedтребуетmachineId.session-scopedможет читать и менять только свойsessionId:message, metadata/state, lifecycle, usage и ACK проверяются до обращения к store/router. Его RPC method обязан начинаться с<sessionId>:. Для чужого или несуществующего scope возвращается нейтральная ошибка без раскрытия существования сессии.
user-scoped: получает обновления всего аккаунта.session-scoped: получает обновления только конкретной сессии.machine-scoped: используется демонами; получает обновления машины и отправляет её состояние.
Сервер отправляет два типа событий:
Персистентные события синхронизации. Форма payload:
{
id: string,
seq: number,
body: { t: string, ... },
createdAt: number
}
Транзиентные события присутствия/usage. Форма payload:
{
type: string,
...
}
Имена полей ниже соответствуют payload на проводе.
-
new-sessionbody:{ t: "new-session", id, seq, metadata, metadataVersion, agentState, agentStateVersion, dataEncryptionKey, active, activeAt, createdAt, updatedAt }
-
update-sessionbody:{ t: "update-session", id, metadata?, agentState? }metadata:{ value, version }или nullagentState:{ value, version }или null
-
delete-sessionbody:{ t: "delete-session", sid }
-
new-messagebody:{ t: "new-message", sid, message: { id, seq, content, localId, createdAt, updatedAt } }
-
new-machinebody:{ t: "new-machine", machineId, seq, metadata, metadataVersion, daemonState, daemonStateVersion, dataEncryptionKey, active, activeAt, createdAt, updatedAt }
-
update-machinebody:{ t: "update-machine", machineId, metadata?, daemonState?, activeAt? }
metadata.executionOutcome — опциональный типизированный watermark результата
исполнения. Его форма строго ограничена двумя полями:
{
"kind": "error" | "success",
"occurredAt": 0
}
occurredAt — timestamp в миллисекундах Unix-времени. Outcome-обновление не
помещает в metadata текст ошибки, stack trace или исходный provider payload.
Текст ошибки живёт отдельно в зашифрованном chat event. В Codex app-server
потоке runCodex публикует event вида { type: "message", message: "<redacted>", isError: true } только после redaction текста; generic
ApiSessionClient записывает в metadata лишь kind и occurredAt и сам не
является универсальным redactor-ом для произвольных сообщений адаптеров.
Правила записи:
errorпоявляется только по явному error-сигналу (isError: trueв session event или ACP message).successпоявляется только после live agent output с непустым текстом. Для ACP message требуется явныйisError: false; история при resume, reasoning, tool/status events и пустые сообщения outcome не меняют.- При конфликте или запаздывающем событии побеждает только более новый
watermark: кандидат с
occurredAt <=текущего значения игнорируется. - После
session-end(завершённая terminal-сессия) новые outcome-обновления не запускаются; приmetadata.lifecycleState === "archived"кандидат блокируется на этапе merge. Уже поставленная в очередь metadata-операция отдельно не отменяется. successзаменяет предыдущийerrorwatermark, но не является отдельным UI-статусом. Для online-сессии UI применяет приоритетoffline > permission > thinking > error > idle;successприводит кidle, если более приоритетное состояние отсутствует.summary.textи другие свободные metadata-поля не устанавливают и не очищают outcome. Summary может отображаться как текст, но сам по себе не делает сессию ошибочной.
activity:{ type: "activity", id: sessionId, active, activeAt, thinking }machine-activity:{ type: "machine-activity", id: machineId, active, activeAt }usage:{ type: "usage", id: sessionId, key, tokens, cost, timestamp }
-
ping-> callback{} -
update-metadata{ sid, metadata, expectedVersion }- Ответ:
{ result: "success", version, metadata }или{ result: "version-mismatch", version, metadata }
-
update-state{ sid, agentState, expectedVersion }- Ответ:
{ result: "success", version, agentState }или{ result: "version-mismatch", version, agentState }
-
message{ sid, message, localId? }- Создаёт новое сообщение сессии (зашифрованный payload) и отправляет обновление
new-messageостальным соединениям.
-
session-alive{ sid, time, thinking?, mode? }- Отправляет
ephemeralactivity user-scoped соединениям.
-
session-end{ sid, time }- Помечает сессию неактивной и отправляет
ephemeralactivity.
-
usage-report{ key, sessionId?, tokens, cost }- Сохраняет отчёт об использовании и опционально отправляет
ephemeralusage для сессии.
-
machine-alive{ machineId, time }- Отправляет
ephemeralmachine-activity.
-
machine-update-metadata{ machineId, metadata, expectedVersion }- Ответ:
{ result: "success", version, metadata }или{ result: "version-mismatch", version, metadata }
-
machine-update-state{ machineId, daemonState, expectedVersion }- Ответ:
{ result: "success", version, daemonState }или{ result: "version-mismatch", version, daemonState }
-
rpc-register{ method }-> сервер отправляетrpc-registered
-
rpc-unregister{ method }-> сервер отправляетrpc-unregistered
-
rpc-call{ method, params }-> callback{ ok, result? | error? }- Сервер пересылает вызов зарегистрированному сокету через
rpc-request(на основе ack). get-session-execution { sessionId }возвращает daemon-owned{ sessionId, provider, revision, current, pending? }только для активной Codex/Cursor wrapper-сессии.set-session-execution { sessionId, expectedRevision, execution }принимает provider-discriminated selection, повторно проверяет свежий catalog и возвращает новый snapshot. Raw selection из chat message не используется.- Session-scoped
codex-structured-input-responseпринимает только{ requestKey, submissionId, action, answers? | content? }. Daemon повторно валидирует типы и scope; ответ —{ status: "submitted" | "already-resolved" }. Поля формы и secret values не пишутся в session state, messages или completed history. - Session-scoped
codex-structured-input-url { requestKey }возвращает raw HTTPS URL только для живого MCP URL request. ВagentStateхранится только URL без credentials, query и fragment; web запрашивает raw URL после явного действия пользователя. - Session-scoped
cursor-structured-input-responseпринимает{ requestKey, submissionId, action, answers? }. Question submit валидируется против provider option IDs. Wire action всегдаsubmit/decline/cancel; для plan broker переводит его в native ACP outcomeaccepted/rejected/cancelled. Ответ —{ status: "submitted" | "already-resolved" }.
agentState.codexStructuredRequests — зашифрованная безопасная проекция живых
Codex structured requests. Native JSON-RPC id и provider payload остаются
private в runner. Запись удаляется при ответе, provider resolution, timeout,
turn completion/interruption, transport loss или cleanup.
agentState.cursorStructuredRequests — отдельная зашифрованная проекция живых
cursor/ask_question и cursor/create_plan. Provider toolCallId, исходный
ACP payload и ответы не сохраняются в state или историю. Запись удаляется при
ответе, timeout, turn completion/interruption, transport loss или cleanup;
reconnect повторно публикует только ещё живой request.
| Область | Endpoint | Описание |
|---|---|---|
| Health | GET /health |
Liveness-проба (без аутентификации) |
| Account | GET /v1/account/settings, GET /v1/account/profile, POST /v1/account/settings |
Заглушки аккаунта для совместимости с клиентом |
| KV | GET /v1/kv, GET /v1/kv/:key, POST /v1/kv/bulk, POST /v1/kv |
Зашифрованное key-value хранилище (мутация с version для оптимистичного контроля конкурентности) |
| Voice (STT) | GET /v1/whisper/status, POST /v1/voice/transcribe |
Локальная multipart-транскрипция Whisper; action-level proof с payload null, без хеширования audio stream |
| Voice (TTS) | GET /v1/tts/status, POST /v1/voice/synthesize |
Статус TTS + синтез: { text, voice?, lang? } → audio/ogg (OGG Opus) |
| Concierge | GET /v1/concierge/status, POST /v1/concierge/chat |
Опциональный локальный LLM-ассистент (LM Studio); тело chat: { messages: [{ role, content }] } → { reply, actions } |
| Sessions | GET /v1/sessions, GET /v2/sessions, GET /v2/sessions/active, POST /v1/sessions, GET /v1/sessions/:sessionId/messages, DELETE /v1/sessions/:sessionId |
CRUD сессий + история сообщений |
| Machines | POST /v1/machines, GET /v1/machines, GET /v1/machines/:id |
Регистрация и листинг машин |
UpdatePayload.seq— последовательность обновлений на пользователя (монотонная), используется для порядка синхронизации.- У сессий и машин есть собственные поля
seq, используемые клиентами для упорядочивания. - Версионируемые поля (metadata, agentState, daemonState, KV) используют оптимистичный контроль конкурентности с
expectedVersionи возвращают ответ version-mismatch с текущей версией/данными.
- API-роуты:
packages/remcli-cli/src/daemon/p2p/p2pRestRoutes.ts - Socket-обработчики:
packages/remcli-cli/src/daemon/p2p/p2pSocketHandlers.ts - Маршрутизация событий:
packages/remcli-cli/src/daemon/p2p/p2pEventRouter.ts