Медиация разрешений для нескольких клиентов
Обзор
Когда агент дочернего процесса ACP вызывает requestPermission, демон не просто перенаправляет его одному клиенту. При sessionScope: 'single' каждый подключенный клиент видит запрос, и любой из них может ответить. Без механизма медиации запоздалые голоса теряются, два клиента могут одновременно обрабатывать один и тот же запрос, а один злонамеренный клиент может переопределить действия инициатора.
MultiClientPermissionMediator (packages/acp-bridge/src/permissionMediator.ts) реализует контракт PermissionMediator (packages/acp-bridge/src/permission.ts) и управляет всем состоянием ожидающих и разрешенных запросов разрешений для моста (bridge). Он распределяет голоса в соответствии с одной из четырех политик, объявленных в PermissionPolicy:
| Политика | Правило разрешения | Сценарий использования |
|---|---|---|
first-responder | Первый действительный голос побеждает; последующие голосующие получают permission_already_resolved. | Интерфейс совместной работы в реальном времени между клиентами (по умолчанию). |
designated | Только originatorClientId запроса может вынести решение; остальные видят permission_forbidden{designated_mismatch}. | Мультитенантные SaaS-приложения, где UI-поверхность должна сама управлять своими подтверждениями. |
consensus | Кворум N из M по снимку client-id v1; промежуточные события permission_partial_vote позволяют UI отображать прогресс. | Корпоративное ревью изменений, где два оператора должны прийти к согласию. |
local-only | Отклоняет любого голосующего не с loopback-адреса; блокирует до тех пор, пока клиент с loopback-адреса не вынесет решение. | Рабочие станции, где удаленное управление никогда не должно предоставлять повышение привилегий. |
Ограничение безопасности v1:
X-Qwen-Client-Idсообщается самим клиентом. Дляdesignatedиconsensusпока нет подтверждения владения (proof-of-possession). Клиент, перехватившийoriginatorClientId, может повторно использовать этот id.{outcome:'cancelled'}также маршрутизируется через cancel sentinel до распределения по политике, поэтому дажеlocal-onlyне может рассматривать отмену как защищенное политикой разрешение. Для строгой изоляции привяжите демон к loopback-адресу или поместите его за аутентифицированный обратный прокси. См. Security note: v1 client identity is self-reported.
Ответственность
- Отслеживание каждого ожидающего запроса (жизненный цикл
request → vote → resolved). - Установка и сброс таймаутов реального времени для каждого запроса (инвариант N1: таймаут должен устанавливаться синхронно внутри
request(), чтобы немедленно отмененная сессия не могла привести к утечке постоянно ожидающего замыкания). - Распределение голосов в соответствии с политикой, зафиксированной на момент
request()(изменение политики демона во время выполнения не влияет на уже выполняющиеся запросы). - Поддержка ограниченного FIFO (
MAX_RESOLVED_PERMISSION_RECORDS = 512) недавно разрешенных запросов, чтобы дубликаты голосов получали структурированный ответalready_resolved, а неunknown_request. - Генерация событий
permission_partial_vote(consensus) иpermission_forbidden(designated / consensus / local-only) в per-session EventBus. - Разрешение ожидающих запросов как
{kind: 'cancelled', reason: 'session_closed'}черезforgetSession(sessionId)при завершении сессии. - Отклонение злонамеренного или случайного внедрения
CANCEL_VOTE_SENTINELчерез сеть (InvalidPermissionOptionError) и через метки опций, публикуемые агентом (CancelSentinelCollisionError).
Архитектура
Публичный интерфейс
interface PermissionMediator {
readonly policy: PermissionPolicy;
request(
record: PermissionRequestRecord,
timeoutMs: number,
): Promise<PermissionResolution>;
vote(vote: PermissionVote): PermissionVoteOutcome;
forgetSession(sessionId: string): void;
}MultiClientPermissionMediator добавляет: peekSessionFor(requestId), pendingCount(sessionId), внутренний издатель аудита и т.д. BridgeClient зависит только от части request() (структурное подтипизирование — см. bridgeClient.ts).
PermissionPolicy и PermissionVoteOutcome
type PermissionPolicy =
| 'first-responder'
| 'designated'
| 'consensus'
| 'local-only';
type PermissionVoteOutcome =
| { kind: 'resolved'; resolvedOptionId: string }
| { kind: 'recorded'; votesNeeded: number } // consensus partial
| { kind: 'already_resolved'; resolvedOptionId: string }
| { kind: 'forbidden'; reason: 'designated_mismatch' | 'remote_not_allowed' }
| { kind: 'unknown_request' };
type PermissionResolution =
| { kind: 'option'; optionId: string }
| {
kind: 'cancelled';
reason: 'timeout' | 'session_closed' | 'agent_cancelled';
};Cancel sentinel
CANCEL_VOTE_SENTINEL = '__cancelled__'. Мост (bridge) сопоставляет голосующему {outcome:'cancelled'} этот сентинел до вызова mediator.vote. Медиатор маршрутизирует сентинел до распределения по политике — отмена голосующим работает при любой политике независимо от clientId / loopback / членства. Два защитных механизма:
bridge.tsотклоняет сетевые голоса, у которыхoptionId === CANCEL_VOTE_SENTINEL, с ошибкойInvalidPermissionOptionError(злонамеренный сетевой клиент не должен иметь возможности внедрить отмену, солгав обoptionId).mediator.requestотклоняет записи, у которыхallowedOptionIdsсодержит сентинел, с ошибкойCancelSentinelCollisionError(агент, легитимно публикующий'__cancelled__'как метку опции, не должен иметь возможности маскироваться).
Этот намеренный обход политик задокументирован в permissionMediator.ts, чтобы будущий сопровождающий разработчик случайно не удалил этот обходной путь.
Ожидающие запросы
Каждый ожидающий запрос индексируется по requestId и содержит:
policy— фиксируется на моментrequest().record: PermissionRequestRecord(requestId, sessionId, originatorClientId, allowedOptionIds, issuedAtMs).- Замыкания
resolve/reject. votesAtIssue(только для consensus) — снимок зарегистрированныхclientIdsдля сессии на момент выдачи; последующие голоса отклоняются, если их нет в этом наборе.tally(только для consensus) —Map<optionId, Set<clientId>>, подсчитывающая голоса для каждой опции.timeoutHandle— таймаут Node, установленный внутриrequest()(инвариант N1).auditTrail[]— записи аудита для каждого голоса.
Resolved FIFO
MAX_RESOLVED_PERMISSION_RECORDS = 512. Вытеснение происходит по FIFO через resolvedOrder.shift() (DeepSeek review #4335 / 3271627446 — аналогично PermissionAuditRing). Хранит только {requestId, sessionId, outcome}, поэтому 512 записей занимают менее 100 КБ даже при обычных окнах переподключения/состязаний UI.
Рабочий процесс
request() (N1 invariant)
Таймер устанавливается до того, как запись становится видимой где-либо еще. Без этого forgetSession, поступивший между pending.set и setTimeout, оставил бы запись в ожидании без таймаута — promptQueue моста для каждой сессии завис бы навсегда.
vote() dispatch
forgetSession()
Вызывается при закрытии сессии, вытеснении и завершении работы моста. Для каждой ожидающей записи, у которой record.sessionId === sessionId:
- Отменяет таймаут.
- Разрешает ожидающий Promise как
{kind: 'cancelled', reason: 'session_closed'}. - Добавляет запись аудита.
- Удаляет из
pending.
Путь завершения сессии в мосту всегда вызывает forgetSession до окна уничтожения канала, чтобы ожидающие разрешения не пережили свою сессию.
Состояние и жизненный цикл
policyфиксируется для каждого запроса. Изменение политики для всего демона (будущий функционал) не влияет на выполняющиеся запросы.votesAtIssue(consensus) фиксируется на моментrequest(); клиенты, подключившиеся после запроса, могут голосовать, но если ихclientIdне был зарегистрирован в сессии на момент выдачи, их голос отклоняется какdesignated_mismatch. Это намеренно повторно использует причину несовпадения политикиdesignated, чтобы сохранить контракт закрытым; будущие версии могут разделить объединение, если потребителям SDK потребуется их различать.- Разрешенные записи хранятся в FIFO не более
MAX_RESOLVED_PERMISSION_RECORDS(512). После вытеснения дублирующий голос по тому жеrequestIdвозвращает{unknown_request}. permission_partial_voteсрабатывает только дляconsensus. Не полагайтесь на него при других политиках.permission_forbiddenсрабатывает дляdesignated,consensusиlocal-only, но не дляfirst-responder.
Зависимости
03-acp-bridge.md— как мост связываетBridgeClient.requestPermissionсmediator.request.10-event-bus.md— как фреймы partial-vote и forbidden достигают клиентов.09-event-schema.md— контракты полезных нагрузок для событийpermission_*.08-session-lifecycle.md—forgetSession()вызывается при каждом завершении сессии.02-serve-runtime.md—PermissionAuditRing(FIFO на 512 записей аудита).
Конфигурация
| Источник | Параметр | Эффект |
|---|---|---|
settings.json | policy.permissionStrategy | Активная политика медиатора. |
settings.json | policy.consensusQuorum | N для consensus. |
BridgeOptions | permissionPolicy, permissionConsensusQuorum, permissionAudit | Программное переопределение. |
| Capability tag | permission_mediation (always; modes: ['first-responder', 'designated', 'consensus', 'local-only']) | Набор, поддерживаемый сборкой. |
| Capability envelope | policy.permission | Активная политика, с которой работает этот демон. |
Если policy.permissionStrategy не настроена явно, демон использует
first-responder. designated, consensus и local-only вступают в силу
только при установке в settings.json.
Кворум consensus: формула по умолчанию и граничный случай M=2
Когда активна политика consensus и policy.consensusQuorum не установлена,
медиатор вычисляет N = floor(M/2) + 1 с помощью consensusQuorumFor в
permissionMediator.ts:
Math.max(1, Math.floor(m / 2) + 1);M (votersAtIssue.size) | N по умолчанию | Поведение |
|---|---|---|
| 1 | 1 | Один голосующий сразу выносит решение. |
| 2 | 2 | Требуется единогласное согласие. |
| 3 | 2 | Большинство. |
| 4 | 3 | Более половины. |
| 5 | 3 | Большинство. |
| 6 | 4 | Более половины. |
При M = 2 разделившиеся голоса (A выбирает X, B выбирает Y) могут быть разрешены только
таймаутом для каждого разрешения: ни одна опция не достигает единогласия, поэтому запрос ожидает
до permissionResponseTimeoutMs (по умолчанию 5 мин) и разрешается как
{cancelled, timeout}. Путь продвижения голоса логирует это поведение («единогласие означает, что разделившиеся голоса истекают по таймауту»)
в stderr для операторов.
Операторы, которые хотят поведение «первый голос побеждает» для M = 2, могут явно установить
policy.consensusQuorum: 1. Более строгие конфигурации, такие как требование
единогласия для M = 4, используют то же поле.
Валидация политики при запуске
runQwenServe.validatePolicyConfig(policyConfig)
(packages/cli/src/serve/run-qwen-serve.ts) валидирует объединенные policy.* из settings.json
при запуске и выбрасывает InvalidPolicyConfigError при ошибках оператора:
policy.permissionStrategyустановлена, но не входит в четыре поддерживаемых режима. Допустимый набор выводится во время выполнения изSERVE_CAPABILITY_REGISTRY.permission_mediation.modes, который является единственным источником истины для анонсирования возможностей.policy.consensusQuorumустановлена, но не является положительным целым числом.
Также выводится мягкое предупреждение в stderr, если consensusQuorum установлена, при этом
permissionStrategy !== 'consensus'; в противном случае переопределение было бы молча
проигнорировано при политиках, отличных от consensus.
InvalidPolicyConfigError экспортируется для тестов instanceof. runQwenServe
использует его, чтобы отличать неправильную конфигурацию оператора, которая пробрасывается как явная ошибка загрузки, от ошибок ввода-вывода при чтении настроек, при которых происходит откат к
значениям по умолчанию.
Security note: v1 client identity is self-reported
X-Qwen-Client-Id предоставляется HTTP-клиентом. В v1 демон валидирует
формат ([A-Za-z0-9._:-]{1,128}) и отслеживает подключенные client id в
clientIds, но не выполняет подтверждение владения (proof-of-possession). Любой клиент, который может перехватить originatorClientId в SSE, может зарегистрироваться с тем же id и
выдавать себя за этого инициатора в последующих запросах.
Влияние на политики:
first-responderне затрагивается, так как не зависит от идентичности.designatedможет быть подделана удаленным клиентом, повторно использующимoriginatorClientId.consensusпроверяет снимокvotersAtIssueна момент выдачи; если поддельный id уже подключен на момент выдачи запроса, он может проголосовать.local-onlyневосприимчива к подделке id, посколькуfromLoopback: booleanустанавливается демоном на основе удаленного адреса подключения, а не предоставляется клиентом.
Будущий механизм парных токенов будет выдавать секрет для каждой сессии через
POST /session и требовать его при голосах designated / consensus. Этот
механизм не реализован в v1.
Маршрутизация голосов между соединениями
Пути доставки голосов
Голоса разрешений могут достигать медиатора моста через два независимых транспортных пути:
-
Транспорт ACP (ответ в том же соединении): Событие моста
permission_requestдоставляется в SSE/WS поток сессии принадлежащего соединения как JSON-RPC запросsession/request_permission. Клиент отвечает JSON-RPC ответом в том же соединении.resolveClientResponseдиспетчера сопоставляет локальный для соединения JSON-RPC id обратно сrequestIdмоста и вызываетbridge.respondToSessionPermission. -
REST API (между соединениями): Любой HTTP-клиент, включая клиенты на другом ACP-соединении или вообще без ACP-соединения, может проголосовать через
POST /session/:id/permission/:requestId. Устаревший маршрутPOST /permission/:requestId(без сессии в URL) используетpeekSessionFor(requestId)для определения сессии перед делегированием тому же путиrespondToSessionPermission.
Локальные для соединения ID запросов разрешений
Транспорт ACP использует двухуровневую схему ID для сопоставления между сетью и мостом:
| Уровень | Формат ID | Область действия | Назначение |
|---|---|---|---|
| JSON-RPC message id | _qwen_perm_N (строка, монотонная для каждого соединения) | Локальная для соединения | Сопоставляет пару JSON-RPC запрос→ответ в потоке сессии. |
| Bridge request id | Непрозрачная строка (UUID, сгенерированный агентом/медиатором) | Глобальная для демона | Идентифицирует запрос разрешения во всех маршрутах и в картах pending/resolved медиатора. |
Bridge request id передается через вендорное расширение _meta, чтобы клиент мог включить его при голосовании через REST-путь:
{
"method": "session/request_permission",
"id": "_qwen_perm_3",
"params": {
"sessionId": "<session-id>",
"toolCall": { "name": "shell" },
"options": [{ "optionId": "allow", "name": "Allow" }],
"_meta": { "qwen": { "requestId": "<bridge-request-id>" } }
}
}Соединение хранит сопоставление в conn.pending: Map<jsonRpcId, PendingClientRequest>, где PendingClientRequest.bridgeRequestId — это id уровня моста.
Правила авторизации голосов
respondToSessionPermission(sessionId, requestId, response, context) применяет следующие проверки по порядку:
-
Существование сессии — сессия, к которой обращаются по
sessionId, должна быть активной (byId.has(sessionId)). В противном случаеSessionNotFoundError. -
Отклонение межсессионных запросов —
peekSessionFor(requestId)определяет сессию, к которой на самом деле принадлежит запрос. Если он принадлежит другой сессии, голос отклоняется (возвращаетfalse/ 404) без раскрытия информации о членстве в сессии. -
Защита от неизвестных запросов — когда
peekSessionForвозвращаетundefined(истек таймаут запроса, вытеснен LRU или никогда не существовал), голос отклоняется (возвращаетfalse/ 404) до любой валидацииclientId. Это предотвращает атаку типа оракул: без нее зондирование с поддельнымclientIdмогло бы отличить «в сессии есть этот клиент» (проходит валидацию → 404) от «клиент неизвестен» (InvalidClientIdError→ 400). -
Валидация идентичности клиента —
resolveTrustedClientId(entry, context?.clientId)проверяет, что предоставленныйX-Qwen-Client-Id(REST) илиclientId, установленный мостом (ACP), зарегистрирован в картеclientIdsсессии. Анонимные голоса (clientId === undefined) проходят дальше — их обрабатывает распределение по политике. Незарегистрированные id выбрасываютInvalidClientIdError(обработчики маршрутов преобразуют его в 400). -
Применение cancel sentinel — сетевой голос
{ outcome: "selected", optionId: "__cancelled__" }отклоняется с ошибкойInvalidPermissionOptionErrorдля предотвращения внедрения сентинела. -
Распределение
vote()медиатора — провалидированный голос перенаправляется вpermissionMediator.vote(...), который применяет активную политику (см. Рабочий процесс →vote()dispatch).
Оценка loopback
Бит fromLoopback вычисляется для каждого запроса, а не для каждого соединения:
- Транспорт ACP:
reqLoopbackформируется на основеreq.socket.remoteAddressна уровне ядра для POST-запроса на HTTP-уровне и передается вdispatcher.handle(conn, msg, sessionHeader, isLoopbackReq(req)). Это означает, что POST-запрос с голосованием за разрешение, пришедший от другого пира, отличного от того, от которого пришел запросinitialize, получит собственную оценку loopback. - REST API:
detectFromLoopback(req)проверяет тот же удаленный адрес на уровне сокета.
Ни один из путей не определяет loopback на основе подделываемых заголовков (X-Forwarded-For, Forwarded и т. д.).
Формат ответа с голосованием для транспорта ACP
Клиент отвечает на session/request_permission стандартным JSON-RPC ответом:
Принять (выбрать опцию):
{
"jsonrpc": "2.0",
"id": "_qwen_perm_3",
"result": {
"outcome": { "outcome": "selected", "optionId": "allow" }
}
}Отменить:
{
"jsonrpc": "2.0",
"id": "_qwen_perm_3",
"result": {
"outcome": { "outcome": "cancelled" }
}
}Ответ с ошибкой (диспетчер преобразует его в отмену):
{
"jsonrpc": "2.0",
"id": "_qwen_perm_3",
"error": { "code": -32000, "message": "user declined" }
}Восстановление после сбоев в resolveClientResponse
Если bridge.respondToSessionPermission выбрасывает исключение (например, из-за некорректного тела голоса), диспетчер переходит к явной отмене (cancelAbandonedPermission), чтобы медиатор никогда не оставался в заблокированном состоянии. Если и голосование, и отмена выбрасывают исключение (двойной сбой), запись pending сохраняется, чтобы последующее завершение соединения (abandonPendingForSession) могло повторить попытку.
Оговорки и известные ограничения
- Маршруты отмены (Cancel sentinel) обрабатываются ДО диспетчеризации политик по замыслу — как демон
local-only, так и демонconsensusмогут быть отменены любым голосующим, отправившим{outcome: 'cancelled'}. Это задокументировано вpermissionMediator.tsи представляет собой путь прерывания на стороне агента. designatedиconsensusиспользуютdesignated_mismatchвPermissionVoteOutcome. Медиатор создает отдельные записи аудита, но сетевой формат остается единым. Будущие версии протокола могут разделить это объединение.- Анонимные голосующие (без
X-Qwen-Client-Id) принимаются только в режимахfirst-responderиlocal-only(loopback);designatedиconsensusих отклоняют. - Обходной путь для кросс-политик (Cross-policy escape hatch) означает, что отмена не может блокироваться политикой. Если развертыванию требуется отмена, управляемая политикой, это потребует изменения контракта в будущем — не пытайтесь закрыть это проверками на уровне маршрутов.
- Семантика снимка
votesAtIssueозначает, что в развертывании с консенсусом и часто меняющимся набором клиентов легитимные клиенты могут быть отклонены, если они подключились после отправки запроса. Операторам следует предварительно зарегистрировать client id соавторов перед отправкой запросов на проверку изменений.
Ссылки
packages/acp-bridge/src/permission.ts(неизменяемый контракт)packages/acp-bridge/src/permissionMediator.ts(реализация медиатора F3)packages/acp-bridge/src/bridgeClient.ts(использует структурное подтипирование дляPermissionMediator)packages/acp-bridge/src/bridge.ts(respondToSessionPermission— маршрутизация голосов и авторизация)packages/acp-bridge/src/bridgeErrors.ts(CancelSentinelCollisionError,InvalidPermissionOptionError,PermissionForbiddenError,InvalidClientIdError)packages/cli/src/serve/acp-http/dispatch.ts(resolveClientResponse— путь голоса транспорта ACP)packages/cli/src/serve/acp-http/connection-registry.ts(AcpConnection.pending— маппинг запросов в рамках соединения)packages/cli/src/serve/routes/permission.ts(REST-маршруты для голосов)packages/cli/src/serve/permission-audit.ts(кольцо аудита + издатель)- Issue: #4175 Серия F3.