Открытая поверхность api
Каждый зарегистрированный маршрут — с классификацией аутентификации и обоснованием тех, что остаются открытыми. Решения — в ADR 0027.
Этот документ — источник истины для теста, а не копия теста.
apps/api/test/interfaces/route-surface.spec.tsподнимает приложение, перечисляет маршруты, зарегистрированные в runtime, и сравнивает их с таблицей ниже. Новый маршрут без строки здесь проваливает тест; строка, классификация которой расходится с реальной аннотацией, проваливает тест; осиротевшая строка (не совпадающая ни с одним маршрутом) проваливает тест. Документация не устаревает молча.
Классификации
| значение | что означает |
|---|---|
public | @Public() — без токена. Каждый такой маршрут обоснован ниже |
engine-service | EngineServiceGuard: X-Brabo-Service-Token, равный общему секрету. Это внутренняя поверхность api↔engine, вне JWT |
role:<role> | аутентифицирован и ограничен RBAC домена (@RequireRole) |
jwt | аутентифицирован, без требуемой в маршруте роли — область видимости задаётся самим ресурсом |
Пятнадцать публичных маршрутов
До Фазы 6 их было четыре. Фаза 7a добавила сразу восемь — first-party
auth, — ADR 0084 ещё два, вход через соцсети, а волна ключа устройства
runner добавила прокси бинарника. Каждый обоснован ниже. Чтобы открыть ещё
какой-либо, по-прежнему нужно менять утверждение в
route-surface.spec.ts, которое перечисляет публичные маршруты буквально,
чтобы вынудить обсуждение.
Инфраструктура
GET /live — liveness. Намеренно не обращается к базе: отвечает,
пока процесс жив. Требование токена здесь заставило бы kubelet
перезапускать pod при первом сбое на пути auth, превращая деградацию в
полный отказ.
GET /health — readiness, с select 1. То же рассуждение: вызывает
kubelet, а он токена не несёт. Раскрывает только то, отвечает ли база.
GET /metrics — scrape Prometheus, который тоже не несёт никакого
токена. Доступ контролируется СЕТЬЮ, а не auth: NetworkPolicy открывает
его только для namespace monitoring, а Ingress production блокирует
путь. Без @Public() цель была бы down при всём остальном зелёном.
GET /git/oauth/:provider/callback — возврат OAuth от
GitHub/GitLab. Браузер пользователя приходит сюда от провайдера, без
сессии api. Но маршрут не безусловный: параметр state проверяется
HMAC (GIT_OAUTH_STATE_SECRET), и без валидного state вызов
отклоняется.
Эта гарантия стоит ровно столько, сколько стоит ключ, и поэтому у него
больше нет значения по умолчанию: в production api отказывается
стартовать с примерным ключом из репозитория, который публичен (ADR 0059,
RN-093). С известным ключом этот маршрут
на практике снова становится безусловным — любой может подписать state
для любого проекта.
GET /runner-releases/binary — проксирует standalone-бинарник
runner из GitHub Releases, чтобы браузер никогда не общался с GitHub
напрямую. Публичен по той же причине, что и /metrics/JWKS: сам бинарник
не секрет, а требовать сессию для скачивания того самого инструмента,
который позволяет аутентифицироваться, было бы шиворот-навыворот.
platform — закрытый список разрешённых значений (linux-x64/linux-arm64/darwin-arm64 — три
цели, которые собирает матрица релиза; darwin-x64 ушёл в
ADR 0174, а win32-x64 — в
ADR 0187, и оба отклоняются с
собственным сообщением, указывающим на npm-пакет), никогда не подставляется сырым в URL GitHub — это закрывает
вектор SSRF/инъекции пути, который оставил бы открытый параметр.
Разрешённый URL asset (никогда не байты бинарника) кэшируется в памяти
на несколько минут — только чтобы оставаться ниже rate limit GitHub для
неаутентифицированных запросов при конкурентных скачиваниях; начиная с
сессии 3 ФАЗЫ 29 разобранный checksums.txt — несколько сотен байт
текста — мемоизируется в той же записи, поэтому хэш и бинарник всегда
берутся из одного и того же релиза.
Начиная с RN-525 (ADR 0149)
маршрут больше не передаёт непроверенные байты: он сверяет sha256
скачанного с checksums.txt релиза и отвечает 502 с именованным
motivo, когда это не удаётся, — в том числе когда релиз вообще не
публикует манифест, что является отказом, а не байтами, отданными с
предупреждением. Понимать гарантию следует узко: это целостность
относительно манифеста, а не происхождение. Маршрут не проверяет
подпись cosign манифеста (checksums.txt.bundle), так что тот, кто
может переписать Release, переписывает оба файла и проходит. Оба способа
закрыть это были измерены и пока отклонены — cosign в образе занимает
155 MB, а @sigstore/verify сделал бы маршрут @Public() зависимым от
второго стороннего хоста (tuf-repo-cdn.sigstore.dev) для проверки того,
чего пока не несёт ни один Release. Потребитель, который проверяет
подпись, — это install.sh. Поскольку проверка хэша означает чтение всех
байтов, загрузка идёт во временный файл под /tmp (emptyDir pod,
смонтированный, потому что rootfs только для чтения) с потолком 256 MiB и
отдаётся только после совпадения хэша — никогда не буферизуется в
процессе на 512Mi.
First-party auth
Семь маршрутов /auth/* плюс JWKS. Все они должны быть публичными по
одной и той же структурной причине: это путь, по которому access token
ПОЛУЧАЮТ, а глобальный JwtAuthGuard его требует. Маршрут auth за guard
запрашивал бы учётные данные, которые сам же выдаёт. logout — единственный,
который мог бы быть аутентифицированным, и он намеренно таким не является:
он уже несёт значимые учётные данные — refresh в cookie, с парой CSRF, — а
выход должен работать даже с истёкшим access token.
Эти маршруты защищает не rate limit.
RateLimitGuardосвобождает маршруты@Public()— намеренно, чтобы не душить/healthдо перезапуска pod kubelet. Эту поверхность удерживает прогрессивная блокировка по email и по IP внутри сценариев использования. Это не необязательное усиление: это единственная защита, которая здесь есть. См. RN-030 и RN-031.
POST /auth/register — регистрация. Отвечает 202 как для нового
адреса, так и для уже зарегистрированного; во втором случае ничего не
создаётся, а владелец адреса получает уведомление. 409 Conflict, которого
требовал бы здравый смысл REST, выдал бы список пользователей любому со
словарём.
POST /auth/login — аутентификация. Несуществующий email, неверный
пароль и заблокированная учётная запись возвращают один и тот же ответ
401 и занимают одно и то же время (ветка без учётной записи сверяет с
фиктивным хэшем с идентичными параметрами).
POST /auth/refresh — ротация. Сам refresh token является учётными
данными, поэтому маршрут не может требовать других.
POST /auth/logout — отзывает семейство предъявленного токена. Всегда
204, даже для неизвестного токена: ответ 401 здесь был бы оракулом
валидности токена.
POST /auth/verify-email, POST /auth/request-password-reset,
POST /auth/reset-password — потоки учётной записи. У того, кто
приходит сюда, по определению ещё нет сессии. Учётные данные — одноразовый
токен, пришедший по email; недействительная, истёкшая или уже
использованная ссылка получают идентичный ответ.
GET /.well-known/jwks.json — публичная половина пары Ed25519,
которой подписываются access tokens. То же рассуждение, что и для
/metrics: у потребителя нет токена, и требовать его означало бы
запрашивать учётные данные для проверки учётных данных. Публикация
открытого ключа — весь смысл формата; то, что никогда не может отсюда
выйти, — компонент d JWK, зафиксированный тестом.
Вход через соцсети (ADR 0084)
GET /auth/oauth/:provider/start — перенаправляет прямо к
провайдеру (GitHub/GitLab). Публичен по той же структурной причине, что и
остальные семь: это сама точка входа, до появления сессии. state,
который он генерирует, подписывается HMAC с СОБСТВЕННЫМ назначением
(signSocialOauthState/RN-273) — никогда не
тот же state, что у GET /git/oauth/:provider/callback выше, хотя оба
маршрута подписывают ОДНИМ И ТЕМ ЖЕ ключом GIT_OAUTH_STATE_SECRET. Поле
purpose в payload — то, что не даёт state одного потока быть принятым
верификатором другого.
GET /auth/oauth/:provider/callback — принимает возврат от
провайдера. То же рассуждение, что и для callback подключения git:
браузер приходит без сессии, state проверяется HMAC, а маршрут никогда
не возвращает JSON — он всегда перенаправляет: на WEB_ORIGIN/ при успехе
(с уже записанными cookies сессии) и на WEB_ORIGIN/login?oauth_error=1
при неудаче, не раскрывая причину в URL.
Примечания
-
POST /workspaces/:workspaceId/projectsрешает, где агент будет писать, и именно поэтому это маршрут поверхности безопасности, а не только регистрации (ADR 0072/ ADR 0104, RN-169/RN-421/ RN-422). Тело получилоexecutionMode(container— по умолчанию и привычное поведение, —,mounted, бывшийlocal, переименованный, илиrunner) иworkspacePath. Вmounted/runnerуказанный абсолютный путь становится корнем области видимости терминала из ADR 0055: то, что здесь вводится, — то, что агент может читать и писать. Ни одного нового маршрута и никакой смены роли (RequireRole('maintainer'), как и было) — изменилось то, насколько широко то, что маршрут предоставляет.Начиная с ADR 0142 (RN-501) ОБА режима, отличные от
container, при создании проверяют ОДНО И ТО ЖЕ: только ЛЕКСИЧЕСКУЮ форму — абсолютный, без.., никогда не корень и не системная папка, никогда не пересекающийся с checkout Brabo ни в одном из направлений (RN-422/RN-423) — без какого-либо дискового I/O.mountedдобавляет собственное правило: путь должен находиться внутриBRABO_PROJECTS_BASE, единственной папки машины, которую видят оба контейнера (ADR 0141), а без настроенной базы отказ сообщает, что РЕЖИМ недоступен в этой установке, вместо того чтобы винить путь.С точки зрения безопасности в этом изменении важно то, что НЕ сдвинулось. Указанный путь по-прежнему является корнем области видимости терминала, а лексический предикат по-прежнему его ограничивает — он выполняется при каждом выводе корня, в том числе при ЧТЕНИЯХ, потому что запись напрямую в базу — единственный способ обойти создание. Отложена была проверка ДИСКА (существует? это каталог? может ли этот процесс в него писать?), которая никогда ничего не ограничивала: она отвечала «это будет работать», а не «это разрешено». Теперь она выполняется в
materializarWorkspaceMontado, когда Infra поднимает контейнер (и, как объявленное исключение, при конвертации режима), и правило базы применяется там повторно ДОmkdir -p— так что путь, записанный в обход продукта, не заставляет api создавать папку в любом месте, куда она дотягивается. Правило базы намеренно находится ВНЕ лексического предиката: тот выполняется при каждом чтении, и УНАСЛЕДОВАННЫЙ проектmountedвне базы начал бы падать только из-за того, что его прочитали.runnerпо-прежнему подтверждается самим runner, единственной стороной, имеющей власть над реальным хостом (RN-423, см.POST /internal/projects/:projectId/workspace-verificationниже);mountedтеперь подтверждается материализацией, которая точно так же проставляетworkspace_verified_at. После этого режим заморожен:UpdateProjectDtoнамеренно опускает оба поля, иначеPartialType(CreateProjectDto)открыл бы их вPATCHбез какой-либо защиты. -
GET /workspaces/:workspaceId/containersтеперь перечисляет КАЖДЫЙ проект workspace, а страница, которую он питает, стала человеческим путём ПОДНЯТЬ контейнер (RN-521, ADR 0136). Роль не изменилась (viewer, как и было), область видимости тоже: страница по-прежнему отвечает только за проекты workspace, которые видит вызывающий. Изменился набор строк — проект, который никогда не провизионировал контейнер, ОТСУТСТВОВАЛ, а теперь появляется сregistrado: null, ТРЕТЬИМ состоянием, которое не является ниstopped, ни «не удалось наблюдать». Чтение по-прежнему ограничено так же: три пакетных запроса при любом числе проектов и не болееTETO_DE_VERIFICACOES_POR_CARGA(20) вызовов broker за загрузку, причём строки без контейнера никогда не подлежат проверке и поэтому никогда не могут отнять очередь у настоящей.Ничто на этой странице не действует напрямую. Остановить, удалить и поднять — всё это
proposed_action, всегда с кликом ЧЕЛОВЕКА, никогда агента, и ни один потолокdecide.tsне затрагивается: оба типа подъёма требуютmaintainer, аcontainer_removeнаходится в абсолютном потолке семейства привилегированных команд (RN-418) — никогда не автоодобряется, «всегда разрешать» отклоняется у источника. Экран ветвит действие подъёма поexecution_modeровно так же, как backend (RN-497/RN-503):container/mountedпредлагаютcontainer_start(broker), аrunnerпредлагаетcontainer_start_via_runner(локальный агент), с payload, который схема каждого из них действительно принимает.Отказы самого экрана — это честность, а не принуждение: он не предлагает кнопку без выбранного образа (ворота RN-105, действующие во ВСЕХ ТРЁХ режимах начиная с RN-494), ни для проекта
runner, папку которого ни один локальный агент никогда не подтвердил, ни для роли нижеmaintainer— и говорит текстом, какой из трёх случаев имеет место. По-настоящему отказывают по-прежнемуRolesGuardи сценарии использования. Здесь читается роль WORKSPACE, а не эффективная роль проекта (projectRole ?? workspaceRole, RN-471): участник, понижённый внутри проекта, всё ещё видит кнопку и получает 403 — объявленная цена отказа от N+1 поproject_membersна странице, проходящей через проекты. -
PUT /projects/:projectId/mirror-pathзаписывает путь на машине ПОЛЬЗОВАТЕЛЯ, а api эту машину никогда не видит (RN-515, ADR 0147, пункт 4). Он хранит место назначения, куда возможностьespelhoкопирует работу проекта, — папку ВНЕ смонтированной базы, именно поэтому bind mount до неё не дотягивается, а локальный агент должен дотянуться.Минимум —
maintainer, как уexecution-modeиprojects-baseвыше, и по той же причине: этот маршрут имеет дело с путём в собственной файловой системе оператора, а не с метаданными проекта.Здесь api может проверить ТОЛЬКО ЛЕКСИЧЕСКУЮ форму, и сообщение об отказе это говорит. Он переиспользует
caminhoDeWorkspaceLocalValido— абсолютный, без../., никогда не корень, никогда не системная папка, никогда не пересекающийся с checkout самого Brabo — ровно так же, как это делает создание вrunner(RN-423), и по той же причине: здесь нет диска, у которого можно спросить. Он НЕ требуетBRABO_PROJECTS_BASE, потому что быть вне базы и есть цель.Два отказа присущи только этому маршруту. Место назначения не может находиться внутри
workspacePathили содержать его — одна и та же петля, увиденная с двух сторон, поскольку запись зеркала внутрь собственного источника заставляет зеркало копировать само себя, — и сравнение идёт по СЕГМЕНТУ (dentroDoEscopo), так что/base-outraне находится внутри/base. А проект вexecution_mode: containerвообще не может иметь места назначения: его источник — управляемый том НА СЕРВЕРЕ, а процесс, который копировал бы, работает на машине ПОЛЬЗОВАТЕЛЯ, которая его не видит. Оба — 400.Другая половина защиты — разрешение symlinks через
realpath, чтобы ссылка в любом сегменте места назначения не указывала обратно внутрь источника, — принадлежит RUNNER, на машине, где оба пути реально существуют. Этот маршрут такой гарантии не устанавливает; он лишь отбрасывает ЗАПИСАННУЮ петлю. Очистка — этоmirrorPath: null, и ключ обязателен: его пропуск был бы неотличим от просьбы очистить, а молчаливая очистка — это и есть дефект. Запись зеркала никогда не являетсяproposed_action— это конфигурация, объявленная пользователем, а не агент, просящий разрешения действовать. -
GET /projects/:projectId/mirror-state—viewer, на ступень ниже маршрута, который ЗАПИСЫВАЕТ место назначения (RN-517, ADR 0147, пункт 7). Он возвращает, что сделал последний прогон зеркала: какое из трёх состояний действует сейчас (never|synced|failed), последнюю успешную синхронизацию с её счётчиками, замороженное место назначения того прогона и последнюю ошибку.Асимметрия с
PUT .../mirror-path(maintainer) намеренна и является применённым правилом RN-102: минимум определяется ENDPOINT, а этот только ЧИТАЕТ. Само место назначения уже входит в каждое чтение проекта (GET /projects/:projectId,viewer), так что требованиеmaintainerздесь заперло бы информацию от тех, кто уже видит её в том же проекте, — худший из двух дефектов, потому что он невидим для того, кто утратил возможность.Стоит сказать, чего он НЕ раскрывает: ни одного имени файла, никакого содержимого, никакого пути, кроме места назначения, которое вызывающий уже может прочитать. Счётчики — это счётчики.
-
GET /workspaces/:workspaceId/projects-baseраскрывает часть топологии файловой системы оператора, и поэтому он неviewer(ADR 0141, RN-500). Он возвращает{ projectsBase: string | null }— единственную папку хоста, смонтированную по идентичности в контейнерыapiиengine, под которой живёт каждый проектmounted.null— нормальное состояние установки, которая не предлагает режим Montado, и именно так мастер проекта узнаёт, что не надо предлагать режим, который эта установка не может обеспечить.Минимум —
maintainer: тот же, что уPOST /workspaces/:workspaceId/projectsвыше, потому что решать, что предлагает тот маршрут, — единственное применение этого значения. Унаследоватьviewerот соседних чтенийprojects-*— они стоят рядом в одном controller — означало бы отдать абсолютный путь хоста каждому, кто может смотреть workspace, без какой-либо возможности взамен.workspaceIdв расчёте не участвует: база — конфигурация установки, одинаковая для каждого workspace, а параметр существует только для того, чтобы дать область видимостиRolesGuard.Начиная с ADR 0161 (RN-573) ответ также несёт
brokerConfigurado: boolean— задан лиBROKER_URL, считываемый черезContainerBrokerPort.configurado(). Он раскрывает один бит топологии установки (есть ли broker с сокетом Docker), никогда не его адрес, и минимум остаётсяmaintainer. Тот же бит входит в каждую строкуGET /workspaces/:workspaceId/containers(viewer, RN-574) — намеренное расширение: та страница должна сказать ДО клика, чтоcontainer/mountedздесь не поднимаются, а тот, кто может видеть страницу, уже мог вывести отсутствие поnaoObservado: broker-nao-configuradoвсякий раз, когда строка находится вprovisioning/running. Он говорит, что переменная существует, но никогда — что broker отвечает. -
GET /workspaces/:workspaceId/project-foldersотдаёт листинги каталогов по пути, переданному клиентом, и вся его безопасность — ОДНО ограничение (RN-504, ADR 0141). Он существует, потому что мастер проекта разом лишился обоих своих механизмов «найти папку»:FolderBrowserModalходил через websocket RUNNER, аRunnerOnboardingPanelиспользовалshowDirectoryPicker, который возвращает handle браузера и никогда — абсолютный путь. Когда runner ушёл из создания проекта, у браузера не стало способа перечислять файловую систему — поэтому перечисляет api, а api видит только одну папку: базу смонтированных проектов.Ограничение — это база и ничего больше.
pathнеобязателен, по умолчаниюBRABO_PROJECTS_BASE; каждый переданныйpathдолжен удовлетворятьdentroDaBaseDeProjetos— тому жеdentroDoEscopo, который использует область видимости терминала из ADR 0055, так что/home/you/brabo2НЕ находится внутри/home/you/brabo, даже если строка начинается так же. Выход за пределы базы — 400, а не 403: 403 намекал бы, что какая-то другая роль это увидит, а не видит никакая. Запрос некорректен, а не недостаточно авторизован...и.отклоняются сразу, а не разрешаются, по той же причине, по которой их отклоняет предикат создания: разрешать — значит соглашаться, что прочитанный путь не тот, что запрошен.Потолки — часть контракта, а не деталь реализации: только каталоги, не более 500 (отсортированных ДО отсечения), никогда рекурсивно, записи с префиксом точки исключены, а symlinks сообщаются, но никогда не обходятся — так что ссылка, указывающая за пределы базы, не является выходом из неё. Исключённое ПОДСЧИТЫВАЕТСЯ (
arquivos,simbolicos,truncado), так что папка, полная кода, никогда не возвращается похожей на пустую (RN-180). Парного POST нет: создание папки — дело материализации смонтированного workspace, никогда не селектора.Минимум —
maintainer, тот же, что уPOST /workspaces/:workspaceId/projectsиprojects-baseвыше, и по той же причине, доведённой на шаг дальше:projects-baseраскрывает ОДИН путь на машине оператора, этот маршрут раскрывает ТОПОЛОГИЮ под ним. Тому, кто не может создать проект, нечего делать со списком папок, где проект мог бы быть создан. -
GET /— это «Hello World!» из scaffold NestJS (src/app.controller.ts). Он стоит за guard и ничего не раскрывает, но ни для чего не нужен — кандидат на удаление. Он записан здесь, а не удалён, потому что это решение продукта, вне рамок этой сессии. -
GET /internal/projects/:projectId/git-remote— единственный маршрут продукта, возвращающий РАСШИФРОВАННЫЙ секрет — git-токен owner workspace (ADR 0056). Он существует, потому что engine работает с файловой системой и не имеет мастер-ключа; реплицировать его в engine удвоило бы радиус поражения самого чувствительного секрета продукта. Два свойства делают его защитимым:origin, который он возвращает, чистый (учётные данные приходят в отдельном поле, никогда не встроены в URL), а потребитель обязан внедрять их при каждом вызове, никогда не в файл — см.Engine.Actions.GitAuthи обоснование этого в RN-076. Если этот маршрут когда-нибудь начнёт возвращать уже аутентифицированный URL, токен окажется в.git/config, внутри папки, где у dev agent автоодобренные чтения.В режиме
runnerэто внедрение при каждом вызове проходит на один прыжок больше — полеenvсообщенияexec(RN-507), — и останавливается на границе контейнера: порт Docker продукта не имеет поляenv, намеренно (ADR 0130), так что команда, направленная внутрь контейнера черезdocker exec, никогда не может унести токен. Это ограничение удержалось; не удержалось сообщение об ошибке. До RN-558 runner выполнял эту команду всё равно, с установленным credential helper и пустыми переменными, и получавшаяся ошибка аутентификации была неотличима от плохого токена или сломанной сети — на обычном пути, поскольку зарегистрированный контейнерrunning, которого требует RN-507, существует только потому, что этот же runner его поднял. Теперь runner отклоняет пару (учётные данные присутствуют, контейнер активен) до выполнения чего-либо, а engine классифицирует отказ с источникомpoliticaвместоcodigo. Текст отказа не называет ни переменные, ни их значения, только их количество. Учётные данные по-прежнему не пересекают границу контейнера, и начиная с ADR 0193 (RN-676) им это и не нужно: engine помечает аутентифицированныйfetch(gitCredenciado: true, выставляется из одного места,RunnerRouter.exec_git_credenciado/5), и runner выполняет ЭТУ команду на хосте, гдеenvдоходит до дочернего процесса; команды dev agent по-прежнему идут вdocker exec. Граница доверия — пометка, а неenv: команда, несущаяenvБЕЗ пометки, по-прежнему отклоняется, пока есть активный контейнер, так что поле учётных данных никогда не становится выходом из контейнера. Ни один порт ADR 0130 не изменился. -
Три маршрута чтения PO —
GET /internal/projects/:projectId/business-rules,GET /internal/projects/:projectId/backlog(RN-164) иGET /internal/projects/:projectId/product-metrics(RN-407) — не возвращают никакого секрета и не принимают ничего, кроме id проекта: ни поискового термина, ни пагинации, ни фильтра. Это намеренно. Маршрут чтения для агента — это поверхность, которую модель решает вызвать, а параметр — место, куда модель пишет что угодно; здесь писать некуда. Область видимости замкнута на проект через путь, а стоимость вызова постоянна (три чтения в backlog, два в правилах, один запрос кproposed_actions, отфильтрованный по индексу, в метриках продукта). -
Чтение контрактов dev agent —
GET /internal/projects/:projectId/module-contracts(RN-684, ADR 0200) — следует тому же правилу: никаких секретов, ничего, кроме id проекта, одна запись на модуль действующегоmodule_map. Парный маршрут записи,POST /internal/sessions/:sessionId/module-contracts, принимает только декларацию Arquiteto (что каждый модуль предоставляет) и проверяет каждое имя по действующемуmodule_map; ни один из двух маршрутов не касается worktree. -
POST /internal/projects/:projectId/workspace-verification(RN-423, ADR 0104) вызывается только engine, после того как runner подключается и отправляетworkspace_confirmпо каналу, — никогда напрямую runner, у которого нет service token. Runner — ИСТОЧНИК ИСТИНЫ о пути (он ПЕРЕЗАПИСЫВАЕТworkspacePath, не требуя совпадения с тем, что было введено при создании), но переданный путь всё равно проходит ТУ ЖЕ лексическую проверку, что и при создании (caminhoDeWorkspaceLocalValido), — корень системы и пересечение с checkout Brabo по-прежнему запрещены, даже если они пришли от runner.400, если проект не в режимеrunner.Начиная с ADR 0151 (RN-529) runner может ТАКЖЕ появиться с базой — папкой той машины, под которой каждый проект является подпапкой. Две вещи о ней уместны на этой странице. Во-первых, база локальна и никогда не приходит по сети: она берётся из
--baseили из$XDG_CONFIG_HOME/brabo/runner.json, никогда не из поля сервера, так что инвариант ADR 0130/ ADR 0144 действует и на этой стороне — корнем владеет тот, кто исполняет, и передаётся только относительный сегмент.resolverPastaDoProjetoNaBaseлексически отклоняет АБСОЛЮТНЫЙ сегмент, а не переинтерпретирует его. Во-вторых, база не входит в проверку--dir, точно так же как правило базы находится вне лексического предиката api: проект, папка которого старше базы, продолжает работать. Защита — ТРЕТЬЯ сестраguard.ts, переиспользующаяdentroDoEscopo/realpathMaisProximo/semBarraFinalи тот же двойной проход лексика-затем-realpath, — и письменно наследует ту же оговорку о TOCTOU: наилучшее усилие, никогда не граница безопасности.Начиная с RN-532 (тот же ADR, пункты 3–6) у этой базы есть ПОТРЕБИТЕЛЬ: пара
workspace_create/workspace_create_result. Три вещи о ней уместны на этой странице. Во-первых, ни одного нового маршрута записи не появилось: создав папку, runner отправляет уже существовавшийworkspace_confirm, и именно он — через этот же endpoint — проставляетworkspace_verified_at. Engine по-прежнему не пишет в таблицу, и единственный путь проставления остаётся единственным. Во-вторых, передаётся относительный сегмент, никогда не абсолютный путь, и runner лексически отклоняет абсолютный. В-третьих, возможностьworkspace— единственная из четырёх, объявление которой зависит от СОСТОЯНИЯ runner, а не от его версии, — она объявляется только когда база была согласована, — так что именно по этому объявлению, и ни по чему другому, сервер узнаёт, что база существует. Никто не ТРЕБУЕТ её при join: runner без базы подключается и обслуживает свой проект как всегда, и отклоняется толькоworkspace_create, с ИМЕНОВАННЫМ ответом. Создание этой папки — согласованная конфигурация, а не агент, просящий разрешения действовать: это неproposed_action, и ни один потолокdecide.tsне получает исключения. -
POST /internal/projects/:projectId/container-exec(RN-492, ADR 0134) вызывается только engine, когдаEngine.Actions.TerminalExecutorрешил, что команда терминала относится к реальному контейнеру проекта. Он передаёт её вContainerBrokerPort.exec— api никогда не выполняет команду сама. В отличие отcontainer-specниже, здесь ENGINE вызывает api, а не broker; тело ответа никогда не выбрасывает исключение из-за отказа broker или недоступности broker ({ sucesso: false, motivo }— нормальная форма, согласно RN-486, — строкаrunningникогда не гарантирует, что контейнер работает сейчас), так что мёртвый контейнер — это обычная неудавшаяся команда, а не 5xx. -
POST /internal/projects/:projectId/mirror-sync-result(RN-517, ADR 0147, пункт 7) вызывается только engine, после того как runner отправляетmirror_sync_resultпо каналу, — никогда напрямую runner, у которого нет service token. Та же форма и тот же путь, что уworkspace-verificationвыше, и по той же причине: runner — единственная сторона, знающая, что произошло на машине пользователя, а engine передаёт дальше, а не пишет в таблицу сам.Он записывает ТЕЛЕМЕТРИЮ и ничего больше: строку в
project_mirror_states, никогда не event log (у прогона зеркала нет сессии, аsession_events.session_id—NOT NULL) и никогда неproposed_action. Тело несёт исход, три счётчика и сообщение об ошибке — ни одного пути, по которому api действовал бы, ничего не исполняется, ничего не предоставляется. Запись никогда не роняет то, что измеряет: копирование уже завершилось, когда это вызывается, а отказ здесь лишь записывается в лог engine. -
GET /internal/projects/:projectId/container-spec(ADR 0130, RN-485) — единственный маршрутengine-service, вызывающий которого НЕ engine, — это broker контейнеров, единственный процесс продукта с доступом к демону Docker. Классификация называет МЕХАНИЗМ (BRABO_SERVICE_TOKENв собственном заголовке, сравниваемый за постоянное время), а не отправителя, и секрет намеренно тот же: три сервиса работают в одном кластере и читают один и тот же Secret, так что второй секрет создавал бы впечатление разграничения, ничего не разграничивая (полное обоснование — вservice-token.ts). Ценность маршрута — в направлении вызова: broker не ПОЛУЧАЕТ спецификацию контейнера, он приходит сюда ПРОЧИТАТЬ идентичность проекта, режим исполнения и действующее решение Arquiteto об образе и составляет спецификацию сам. Спецификация, путешествующая в теле запроса, поставила бы ограничение процесса, эквивалентного root, в зависимость от правоты вызывающего. Он не возвращает ни одного абсолютного пути — источник bind разрешается демоном относительно файловой системы ХОСТА, так что путь изнутри контейнера api молча смонтировал бы пустую папку. Вместо этого передаётсяlocalizacao(RN-503, ADR 0144): локатор с дискриминатором, говорящий, какой из ДВУХ корней broker разрешает папку (gerenciada→PROJECT_WORKSPACES_HOST_ROOT,montada→BRABO_PROJECTS_HOST_BASEилиindisponivelс причиной), плюс ОТНОСИТЕЛЬНЫЙ сегмент, который этот корень не покрывает. Api никогда не узнаёт, где эти корни находятся на хосте, а broker никогда ничего не узнаёт о проекте — ни одна из сторон в одиночку не может записать произвольный путь, и в этом ограничение. Broker отклоняетstart, называя отсутствующий корень, никогда не переходя на другой, и повторно проверяет сегмент (.., абсолютный, пустой,NUL— всё отклоняется) плюс конкатенированный результат, прежде чем тот станет-v. Он также отбрасываетrationale, который существует для того, чтобы человек проверял решение, и не имеет потребителя вdocker run. -
GET /internal/projects/:projectId/container-specполучилusuarioDaPasta, а классификация не изменилась — по-прежнемуengine-service, по-прежнему вызывается только broker (ADR 0180, RN-627). Поле — это{ uid, gid }папки проекта, как её видит api черезstat, илиnull(режим runner, папка недоступна, папка root). Broker повторно его проверяет (целые числа в 1..2^31-1;0отклоняется по имени) и использует как--user; он никогда не читает пользователя из тела запроса, так что по-прежнему нет поля, в которое вызывающий мог бы записатьprivileged,cap_addили монтирование. Он запускает контейнер с МЕНЬШИМИ полномочиями, чем раньше (non-root вместо root), с нетронутым--cap-drop ALL. -
POST /projects/:projectId/runner-ticketклассифицированrole:developer, как любой другой маршрут, но НЕ принимает JWT сессии (ADR 0105, RN-424) — только Personal Access Token (brb_…) ИЛИ ключ устройства runner (Ed25519, см. ниже). Автоматическая классификация (route-surface.spec.ts) не различает механизмы, потому что ТРЕБОВАНИЕ роли то же; меняется лишь то, как устанавливаетсяrequest.user.PatAuthGuardработает вместоJwtAuthGuardна этом маршруте (@RequirePatAuth(), тот же структурный шаблон, что@ServiceRoute()/EngineServiceGuard, — обход по метаданным, никогда неif, который новый маршрут может забыть), и это ЕДИНСТВЕННОЕ место api, принимающее любой из этих форматов учётных данных: на любом другом маршрутеbrb_...обычным образом проваливает проверку JWT, как и JWT ключа устройства (егоkidникогда не разрешается среди ключей собственного эмитента токенов сессии). Пять маршрутов под/projects/:projectId/personal-access-tokens(выпустить/перечислить/отозвать собственный PAT, плюс два дляmaintainer— RN-427, перечислить/отозвать PAT ЛЮБОГО пользователя проекта) остаются обычным JWT сессии — механизм меняется только у маршрута, который аутентифицирует САМ ТОКЕН. -
GET /runner/projectsклассифицированjwtи НЕ принимает никакого JWT сессии (RN-543, ADR 0154). Это маршрут, по которому агент МАШИНЫ узнаёт проекты, которые обслуживает, и это первый маршрут@RequirePatAuth()без:projectIdв пути — что ровно то, ради чего он существует: вызывающий ещё не знает, какие проекты существуют. Два следствия, оба зафиксированы тестом:- Принимаются только учётные данные МАШИНЫ (
runner_device_keys.project_idNULL). Учётные данные, привязанные к проекту, — PAT или ключ устройства из потока ADR 0118, — описывают проект и им нечего обнаруживать;PatAuthGuardотклоняет их с 403 и СОБСТВЕННЫМ сообщением, никогда не сообщением «не тот проект», которое солгало бы о причине. Сравнение, отклоняющее ключ-от-A-в-B, НЕ было удалено: оно исчезает только дляnull, и оба направления зафиксированы. @RequireRoleнет, потому что нет проекта, относительно которого разрешать роль. Минимумdeveloper— ТОТ ЖЕ, что уrunner-ticket, — применяется ПОСТРОЧНО внутриListRunnerProjectsUseCase, единым правилом продукта (ResolveEffectiveRoleUseCase.forProject). Ничто не ослаблено: на выходе ровно тот набор проектов, для которыхrunner-ticketуже авторизовал бы этого пользователя. Автоматический классификатор читает@RequireRoleи не видит механизма, поэтому попадает вjwt— то же приближение, которое этот документ уже поправляет прозой дляrunner-ticketвыше. Маршрут никогда не касается JWT сессии:JwtAuthGuardвоздерживается на маршрутах@RequirePatAuth(), а открытый ключ, который проверяетPatAuthGuard, берётся изrunner_device_keys, никогда не от эмитента сессий.
Он возвращает id проекта, имя,
workspaceDirNameи состояние проверки — никогда абсолютный путь. По проводу передаётся СЕГМЕНТ, а корень принадлежит тому, кто исполняет, — тот же инвариант, что у broker (ADR 0144) и у базы runner (ADR 0151).Начиная с RN-544 у маршрута есть ПОТРЕБИТЕЛЬ: запущенный без
--project,brabo-runnerвызывает его в начале и открывает одно подключение на каждый перечисленный проект — а начиная с RN-550, когда этот список приходит пустым, он продолжает вызывать с объявленной периодичностью (15s, 30s, затем 60s с повтором), пока не появится первый проект. У этого ожидания есть потолок со стороны сбоя, а не со стороны ожидания: десять вызовов подряд с ошибкой — и процесс выходит с 1, называя счётчик; любой ответ (в том числе пустой) обнуляет счётчик, а в момент, когда есть живое подключение, ожидание прекращается — при живом подключении список по-прежнему читается только в начале, по причине ниже. Каждое подключение имеет корень в<base>/<workspaceDirName>через те же защиты (resolverPastaDoProjetoNaBase, затем RN-434/RN-435). Три вещи об этом принадлежат этой странице. Во-первых, в engine ничего не изменилось: топикterminal:<projectId>, id сокета и ticket описывают ПОДКЛЮЧЕНИЕ, и N подключений удовлетворяют им байт в байт — отказ второму runner в том же проекте не затронут, как и зеркало (RN-516) иworkspace_create(RN-532), которые уже передавались в разрешении join ТОГО подключения. Во-вторых, JWT обнаружения не несёт claimprojectId— защита сравниваетpayload.projectId !== request.params.projectId, и на маршруте без проекта в пути оба должны бытьundefined; подпись там выдуманного id проекта была бы отклонена с 403, говорящим не то. В-третьих, runner никогда не угадывает вид собственного ключа: на диске ключ машины и ключ проекта — один и тот же файл (приватный JWK сkid), знает сервер, и именованный 403 передаётся с собственным сообщением и собственным исправлением (--project), а не сплющивается в общую ошибку подключения. Новый режим требует ОБОИХ — учётных данных машины и согласованной базы: без базы неоткуда вывести папку каждого проекта, а выдумать её означало бы записать на диск пользователя путь, на который он никогда не соглашался; без обоих запуск без--projectпо-прежнему печатает справку об использовании. - Принимаются только учётные данные МАШИНЫ (
-
Клиент сокета
/runnerНИКОГДА не может позволитьphoenix.jsпереподключаться самостоятельно (RN-108). Ticket одноразовый, а встроенное автопереподключение повторяет ТЕ ЖЕparams— так что сокет, созданный безreconnectAfterMs, вечно повторяет мёртвый ticket. Это не гипотеза:apps/runner/src/channel.tsвышел таким, пока его собственный docblock утверждал обратное, и это было измерено в реальном использовании — один и тот же ticket отклонялся каждые ~5,13s (потолок внутреннего backoff библиотеки), 61REFUSED CONNECTION TO EngineWeb.RunnerSocketза несколько часов и, в сумме с СОБСТВЕННОЙ политикой повторов runner, 530 запросов за минуту при потолке 300 req/minRATE_LIMIT_USER. Лимит — на пользователя, так что отказ в обслуживании пришёлся на БРАУЗЕР владельца учётной записи, в виде 429, — неаутентифицированная третья сторона не участвует, но плохо построенного клиента достаточно, чтобы запереть собственного пользователя снаружи. Переподключение — всегда собственная политика вызывающего, с новым ticket при каждой попытке; опция теперь ОБЯЗАТЕЛЬНА в типе (OpcoesDoSocket) и проверяется тестом на опцию, переданную в конструктор, поскольку тест, проверявший только «подключается», всё время проходил. -
Два маршрута
/projects/:projectId/runner-device-keysЯВЛЯЮТСЯ обычным JWT сессии, в отличие отrunner-ticketвыше — вызывает экран (раздел ключей устройства в Настройках иRunnerOnboardingPanel, распознающий уже сопряжённую машину). До ADR 0203 (RN-687) был третий,POST, через который браузер регистрировал только что сгенерированный открытый ключ Ed25519 (ADR 0118). Этот поток был выведен из эксплуатации, и маршрут ушёл вместе с ним, потому что другого вызывающего у него не было: ключи теперь рождаются на машине —brabo-runner device-key create(RN-551) генерирует пару и записывает приватную половину на диск, режим 600, в$XDG_CONFIG_HOME/brabo/(иначе~/.config/brabo/), — и регистрируются установщиком через внутренний маршрут ниже. Приватная половина по-прежнему никогда не пересекает провод; пересекают только открытый JWK иidзаписи. Чего у этого CLI намеренно нет — так это учётных данных для регистрации: регистрация остаётся за тем, кто владеет service token (установщик, ADR 0155 пункт 1), так что каждая сторона владеет ровно одним секретом и ни одна не видит секрет другой. Файл, который читает runner, никогда не существует без своегоkid:createзаписывает имя.parcial, которое читатель игнорирует, аfinish --id <id>проставляет и переименовывает. Ключи проекта, зарегистрированные браузером ДО ADR 0203, продолжают действовать:PatAuthGuardвсё ещё их принимает,GETперечисляет, аDELETEотзывает.GETперечисляет ключи самого вызывающего, включая отозванные (RN-519), — это то, что делает отзыв достижимым, и до его появления осиротевший ключ (вкладка, закрытая посреди потока автоматической настройки) был невидим и вечен. Он никогда не возвращает открытый JWK: список существует для отзыва, аlastUsedAt: null— признак осиротевшего ключа.DELETEотзывает ключ самого вызывающего, идемпотентно, в том же формате, что и самостоятельный отзыв PAT.PatAuthGuard— это то, что ПОТОМ принимает JWT, подписанный приватной половиной этого ключа, вrunner-ticket, отыскиваемый по заголовкуkid, равномуidэтой таблицы; защита проверяет, что ключ не отозван, но никогда не срок действия — сам ключ не истекает, истекает только JWT с коротким TTL (≤60s,exp - iat), который runner каждый раз им подписывает. Начиная с RN-543GETвозвращает ДВА вида и ГОВОРИТ, какой есть какой (especie): ключ МАШИНЫ (projectId: null) обслуживает каждый проект своего владельца, поэтому появляется в листинге каждого проекта — без этого он был бы невидим и вечен на каждом экране, тот же дефект, который закрыла RN-519, возродившийся в новом виде. Маршрут, создающий ключи МАШИНЫ, — этоPOST /internal/machine-device-keys, ниже (RN-552) — другой маршрут, другие учётные данные и другой вызывающий. МАТЕРИАЛ ключа, который он регистрирует, производится на машине:brabo-runner device-key create(RN-551) генерирует пару Ed25519 локально и печатает только открытый JWK, так что приватная половина никогда не путешествует. Начиная с RN-547install.shсцепляет оба в конце установки и устанавливает unit машины с полученным ключом (ADR 0155) — именно это делает реальной, а не гипотетической, объявленную цену этого маршрута. Отзыв ключа МАШИНЫ просит engine сбросить живой runner в КАЖДОМ проекте в режиме runner, до которого дотягивается владелец, — один вызов{project, user}на проект, не затрагивая engine. -
Ключ МАШИНЫ также достижим по УЧЁТНОЙ ЗАПИСИ, без проекта в пути (RN-611):
GET /users/me/machine-device-keysиDELETE /users/me/machine-device-keys/:deviceKeyId, классифицированныеjwt, потому что область видимости — сам вызывающий, как и у остальных/users/me/*. Они существуют, потому что установка одной строкой создаёт учётную запись и ключ машины ДО какого-либо проекта, а каждый другой листинг требует:projectId: там ключ был жив и недостижим. Они намеренно узкие — только ключи МАШИНЫ (ключ проекта отвечает 404, так же как несуществующий или чужой ключ, так что ничего не утекает) и только ключи ВЫЗЫВАЮЩЕГО (userIdберётся из JWT сессии и входит вWHERE; ни один параметр не называет другого пользователя, а представлениеmaintainerостаётся за рамками, как решила RN-519).DELETE— тот же отзыв, что у маршрута по проекту, делегированный тому же сценарию использования: он сбрасывает живые подключения, открытые С этим ключом, в каждом проекте (начиная с RN-685 целью является ключ, а не{project, user}); пока проекта нет, он только регистрирует отзыв — это всё, что можно остановить: ожидающий агент машины (RN-550) не имеет подключения, а его следующий ticket отклоняется. -
Отзыв ключа устройства теперь достигает ЖИВОГО подключения, и цель —
{project, user}, никогда{key}(RN-520, ADR 0147 пункт 6).DELETEблокировал только СЛЕДУЮЩИЙ ticket: уже подключённыйbrabo-runnerдержал каналterminal:<projectId>живым, выполняя одобренные команды с отозванным ключом, пока не отваливался сам. Теперь api просит engine (POST /internal/projects/:projectId/runner/disconnect) его сбросить, и engine дотягивается до pid канала — api никогда не говорит с каналом, а engine никогда не читает таблицу ключей. Точности, которой НЕТ, — по учётным данным, и это свойство пути ticket, а не предпочтение:runner_socket_ticketsхранитproject_id/user_id/kindи ничего больше, так что какой PAT или какойkidоткрыл тот сокет, умирает вPatAuthGuardи никогда не доходит до engine. Объявленная цена: runner ТОГО ЖЕ пользователя, подключённый с PAT или с другим ключом того же проекта, тоже падает — и переподключается сам, потому что следующий раунд запрашивает новый ticket, а всё ещё действительные учётные данные его получают. Runner другого пользователя в том же проекте остаётся работать. Сброс подключения — ПОБОЧНЫЙ ЭФФЕКТ: недоступный engine, отсутствие подключённого runner или timeout никогда не могут заставитьDELETE(204, идемпотентный) упасть или стать 5xx — то же правило, что уrag_searches(RN-479) иmirror_sync_result(RN-517). Пересмотрено RN-685 (ADR 0201): целью теперь является КЛЮЧ. Ticket записывает, какие учётные данные его запросили (kidили id PAT), и отзыв просит engine (POST /internal/runner/disconnect-credential) аннулировать ожидающие tickets этих учётных данных и сбросить только runners, рождённые от них, — объявленная выше цена исчезла: runner того же пользователя с PAT или другим ключом остаётся работать. Отзыв PAT (собственного или в ролиmaintainer) теперь тоже сбрасывает его подключения. Подключение, открытое с ticket, предшествующим deploy (без учётных данных), по-прежнему падает по паре, а engine, не знающий маршрута, заставляет api вернуться кrunner/disconnect; маршрут пары также остаётся целью удаления участника. Представлениеmaintainer, которое есть у PAT (RN-427, перечислить/отозвать ЛЮБОГО пользователя), остаётся ЗА РАМКАМИ для ключей устройства — теперь по решению, а не по упущению: та пара родилась из реагирования на инцидент с циркулирующим ОБЩИМ секретом, а приватная половина ключа устройства никогда не покидает машину, которая её создала. Эта фраза говорила «браузер, который её создал», и RN-551 расширила изготовителя, не ослабив утверждения: терминал теперь второй генератор, а приватная половина по-прежнему никогда не путешествует — выходит открытый JWK. ИЗМЕНИЛОСЬ то, где она лежит: ключ, созданный в браузере, оседает в папке проекта, выбранной пользователем, ключ, созданный CLI, оседает в$XDG_CONFIG_HOME/brabo/с режимом 600. Ни один из них не достижим для api, и от этого зависит решение выше. -
Маршруты
engine-serviceне являются «внутренними» по соглашению об именовании. Их защищаетEngineServiceGuard, сравнивающийX-Brabo-Service-Tokenс общим секретом за постоянное время, плюс NetworkPolicy. Префикс/internal— сигнализация для людей. Они находятся вне JWT через@ServiceRoute(): пользовательский токен здесь не работает, а service token не работает ни на каком другом маршруте — два механизма никогда не пересекаются (RN-035). -
POST /internal/first-account— второй путь, создающий ПОЛЬЗОВАТЕЛЯ, и он работает в production (RN-546, ADR 0155). Объявлен здесь, а не оставлен на обнаружение. Обычная регистрация — первый путь, и она не затронута; этот существует, потому что новая установка одной строкой никого не впускает — сгенерированный.envне содержит переменной e-mail,MAIL_TRANSPORTпадает вlog, а регистрация ждёт e-mail, который никто не отправляет.Он узок по построению, по трём независимым причинам. Он
engine-service, так что открывает егоBRABO_SERVICE_TOKEN— который самinstall.shсгенерировал и записал с режимом600, делая его доказательством контроля над МАШИНОЙ, а не над почтовым ящиком. Отклоняет с409, когда в установке есть ХОТЬ ОДИН пользователь, — условие об установке, а не о запрошенном e-mail, так что его нельзя вызывать повторно с разными адресами, и он навсегда замолкает, как только кто-то существует (в том числе после восстановления миграции, RN-530). И ни одного публичного маршрута не появилось: публичный «создать первого owner» — это гонка между тем, кто установил, и тем, кто просканировал порт, аownerпервого workspace — не та роль, которую кто-то вернёт себе через HTTP.Учётная запись рождается с уже подтверждённым e-mail, и это единственное, что этот маршрут ослабляет, — для одного именованного случая, никогда для другого пути. Подтверждение доказывает «этот человек контролирует этот почтовый ящик»; тот, кто запускает установщик, уже доказал машину,
.envи демон Docker. Пароль вводится в TTY, читается без эха, хэшируется argon2id и отбрасывается: никогда в.env, никогда в маркере, никогда в логе и никогда не генерируется каким-либо кодом.Одно окно объявлено, а не закрыто: проверка «есть ли какой-нибудь пользователь?» выполняется внутри транзакции, но
READ COMMITTEDне мешает двум конкурентным вызовам с разными e-mail пройти оба. Закрытие потребовало бы advisory lock на отсутствие строк, а ограничение живёт не там — тот, кто дотягивается до этого маршрута, уже владеет service token, то есть уже контролирует установку. -
POST /internal/machine-device-keysЧЕКАНИТ ДОЛГОВЕЧНЫЕ УЧЁТНЫЕ ДАННЫЕ ПОЛЬЗОВАТЕЛЯ из секрета машины (RN-552, ADR 0155 пункт 4). Это самая острая грань service token, и она объявлена здесь по той же причине, что и сестра выше: следующий шаг установщика после создания первой учётной записи — сопряжение локального агента, а сопряжение требует учётных данных — единственные существовавшие были привязаны к ПРОЕКТУ, в установке, где проектов ещё нет.Чего это стоит, сказано прямо: утёкший
BRABO_SERVICE_TOKENтеперь может ИЗГОТОВИТЬ ключ устройства, который действует как пользователь против маршрутов с проверкой роли (POST .../runner-ticket, а через него — канал runner), а не только говорить с внутренними маршрутами. Альтернативные учётные данные — данные самого только что созданного пользователя — были рассмотрены и отклонены, потому что противоречат самому ADR 0155: пароль читается в TTY, используется и отбрасывается, а пункт 5 утверждает, что установщик «ни за кого не входит в систему», и именно поэтому ответ первой учётной записи намеренно не несёт токена сессии. Требование их здесь вынудило бы установщик создать на машине ровно ту живую сессию, которую тот ADR отказался создавать.Три ограничения живут в МАРШРУТЕ, а не в этом абзаце. В теле нет
userId: владелец — ЕДИНСТВЕННЫЙ пользователь установки, разрешаемый api, так что токен никогда не покупает выбор, какие учётные данные чеканить, — при нуле пользователей или более чем одном он отклоняет с409и ничего не записывает, та же форма условия, что у первой учётной записи (об установке, никогда об аргументе), и навсегда замолкает, как только у установки появляется команда. Регистрация ЗАМЕЩАЕТ: активные ключи машины владельца отзываются в той же транзакции, так что переустановленная машина — легитимный случай, а тысяча ключей машины невозможна — потолок по условию, а не по числу, которое стареет. Приватный JWK отклоняется по имени: присутствиеdотвечает400с указанием, что пришло, по тому же доменному правилу, которое использует регистрация браузера, потому что хранение приватной половины — худший исход, который есть у этого маршрута.То, что предоставляет ключ, не расширяется: он даёт локальному агенту ровно те проекты, до которых владелец уже дотягивается как
developer, разрешённые относительно запрошенного проекта (RN-543). Два пробела объявлены, а не спрятаны: здесь нет ниGET, ниDELETE(перечисление опубликовало бы инвентарь учётных данных человека тому, кто владеет только секретом машины; отзыв уже существует там, где есть владелец-человек), а в установке, где ещё нет ни одного проекта, ни один экран не дотягивается до ключа машины — именно поэтому регистрация замещает, а не оставляет сирот позади. -
/docsи/docs-jsonНЕ входят в таблицу, и это пробел известный. Swagger UI монтируетсяSwaggerModule.setup()на уровне Express, а не как controller, а тест перечисляет черезDiscoveryService— он структурно их не видит. Оба существуют только приNODE_ENV !== 'production'(main.ts), публичны и отдают тот же документ, который публикует сгенерированный справочник. Записано здесь, а не оставлено за рамками: то, до чего не дотягивается тест, должно быть в прозе. -
GET /projects/:projectId/agent-areasначал возвращать реальные данные, а классификация не изменилась — по-прежнемуrole:developer, тогда какPATCHпотолка остаётсяrole:maintainer. До ФАЗЫ 18 таблицаagent_areasникогда не записывалась, и маршрут отвечал[]всем, из-за чего классификация казалась разрешительной случайно, а не по решению. С областью, рождающейся вместе с проектом (RN-094), разделение возвращается к тому, что задумывала ФАЗА 14d: читать потолок — работа того, кто исполняет; менять его — решать, сколько продукт тратит без спроса, и поэтому это требует той же роли, что активирует исполнение. -
Пять маршрутов
/projects/:projectId/rag/*делят роль по тому же критерию, что и потолок параллелизма области (RN-083) (ПРОГРАММА 28, Волна 4 — RN-231..234, ADR 0080):searchиcoverage—role:viewer(чистое чтение того, что уже проиндексировано), аreindex—role:maintainer: он запускает N вызовов к репозиторию проекта и к провайдеру embedding, то же «меняет то, что продукт тратит, без спроса», которое уже оправдывает более высокую роль на других маршрутах дорогого запуска.local(RN-455, ADR 0113) тожеrole:maintainer, по тому же рассуждению: он вызывает провайдер embedding и заменяет то, что у проекта проиндексировано для этой области. Тело — ТЕКСТ, прочитанный браузером, никогда не путь хоста.feedback(RN-480) —role:viewer, та же роль, что уsearch, и критерий выше объясняет почему: голосование ничего не тратит и ничего не настраивает — это наблюдение, и это единственный сигнал истины, который есть у измерения RAG (medir:rag). Поднять его доmaintainerзначило бы опустошить этот сигнал, ничего не защитив. Голос по-прежнему ограничен на сервере:searchIdдругого проекта илиchunkId, которого не было среди результатов того поиска, — 400: голос без позиции ничего не измеряет. -
Четыре маршрута
/projects/:projectId/code/*—role:viewerи ТОЛЬКО ДЛЯ ЧТЕНИЯ (ФАЗА 26b). Видеть код проекта — то же разрешение, что видеть проект, — тот же срез, что уGET /projects/:id/git/repository. Три вещи делают эту кажущуюся разрешительность решением, а не небрежностью:- в controller нет глагола записи, и быть не может: вкладка Código
предназначена для чтения, а запись — внешний эффект, который рождается
proposed_actionи принадлежит более поздней фазе.@Postв этом файле — изменение фазы, а не маршрута; - путь ограничивается в ОДНОМ месте (RN-095),
той же центральной проверкой RN-092, — и
ограничение здесь важнее роли, потому что в удалённых провайдерах путь
становится сегментом URL API провайдера, и
../меняет endpoint, а не файл; - расходуются учётные данные owner workspace
(RN-058/RN-082),
как и при записи. Чтение стоит rate limit провайдера, и поэтому у поиска
есть бюджет: без потолка
viewerмог бы раздувать счёт owner сколько угодно.
- в controller нет глагола записи, и быть не может: вкладка Código
предназначена для чтения, а запись — внешний эффект, который рождается
-
GET /workspaces/:workspaceId/spend-reportначал возвращать срез по провайдеру, который является срезом по УЧЁТНЫМ ДАННЫМ (ADR 0076, RN-186/RN-187). Ни одного нового маршрута и никакой смены роли — по-прежнемуrole:owner, как и было, — но то, что он ПРЕДОСТАВЛЯЕТ, изменилось, и поэтому существует это примечание. ADR 0063 отклонил эту ось ровно по этой причине; 0076 пересматривает его по решению владельца продукта. Теперь границу удерживают ДВА независимых барьера:GET /projects/:projectId/spend/me(role:viewer) не имеет никакого параметра измерения, а ТИП отклоняет комбинацию — область сactorпринимает толькоExclude<SpendDimension, 'provider'>, так что запрос провайдера в представлении участника не компилируется. Второй барьер слабее прежней гарантии («измерения не существовало»), и поэтому их два. -
POST /projects/:projectId/sessions/:sessionId/socket-ticket—role:viewerв таблице, но это НИЖНЯЯ граница, а не потолок (RN-108).@RequireRole('viewer')покрываетscope: "heartbeat"— уже существующий сокет heartbeat/живых событий;scope: "terminal"требуетdeveloper, проверяемого ВНУТРИCreateSocketTicketUseCaseпоrequest.effectiveRole(тому же, что уже разрешилRolesGuard), потому что минимальная роль зависит от ТЕЛА запроса, а не только от маршрута, — тот же шаблон, чтоMIN_ROLE_FOR_ACTION_TYPE.terminalвdomain/actions/decide.ts. Сегодня ни один реальный путь не запрашиваетscope: "terminal"(сокет интерактивного терминала — ФАЗА 25); значение уже рождается правильным на тот момент, когда он появится. -
POST /projects/:projectId/members—role:maintainerв таблице, и эта роль НЕОБХОДИМА, но не ДОСТАТОЧНА (ADR 0127, RN-472). Это зеркало примечания оsocket-ticketвыше: там роль таблицы — нижняя граница, и тело может её поднять; здесь роль таблицы — все ворота, которые применяетRolesGuard, а сценарий использования затем отклоняет два движения с 403 даже для того, у кого она есть: понижение того, кто являетсяownerWORKSPACE, и понижение САМОГО СЕБЯ. Ни одного нового маршрута и никакой смены роли (RequireRole('maintainer'), как и было); изменилось то, что классификация перестала быть полным ответом на вопрос «кто что может здесь делать». Причина, по которой это не может жить вRolesGuard, та же, что приводится в примечании оsocket-ticket, — решение зависит от ТЕЛА запроса (dto.role) и его ЦЕЛИ (dto.userId), которых guard не видит, — поэтому правило является чистой функцией вdomain/iam/tetos-de-rebaixamento.ts, применяемойAddProjectMemberUseCase, по ОБРАЗЦУ абсолютных потолковdomain/actions/decide.ts(RN-418): никакого ключа конфигурации, ничего, что могло бы их включить. Защищённыйowner— этоworkspace_members.role, никогдаworkspaces.created_by. То, чего потолки НЕ покрывают, записано в ADR и в RN. -
DELETE /projects/:projectId/members/:userIdтожеrole:maintainerи тоже недостаточен — вторые ворота потолка самопонижения (ADR 0156, RN-556). ADR 0127 оставил этот маршрут без потолка на основании предпосылки, оказавшейся ложной, — «удаление безвредно». Удаление строкиproject_membersне удаляет роль: оно заменяет эффективную, потому чтоforProject— этоprojectRole ?? workspaceRole(RN-471).maintainerпо строке проекта, являющийсяviewerв workspace, понижал себя необратимо через экран — вернуть строку можно черезPOST :projectId/members, который требует только что оставленногоmaintainer; без какой-либо роли в workspace падение — до полного отсутствия доступа. Потолок теперь применяетсяRemoveProjectMemberUseCase, которому пришлось начать получать АКТОРА (маршрут его не передавал, так что ни один потолок не мог быть применён), и правило ПЕРЕИСПОЛЬЗУЕТehAutoRebaixamento—remocaoEhAutoRebaixamentoделегирует ему с ролью workspace вместо запрошенной, никогда не второе правило. Потолок 1 здесь пары не имеет, по решению, зафиксированному в тесте:owner— вершинаROLE_ORDER, так что удаление строки проекта может только ПОВЫСИТЬ эффективную роль этого пользователя — и именно так отменяется ограничение, создавать которое запрещает потолок 1. Объявленная цена, поскольку у потолка 2 нет порога: самоудалениеownerпроекта доmaintainerworkspace обратимо и тоже отклоняется — единственное безвредное движение, меняющее исход, всё ещё достижимое другимmaintainer. -
POST /workspaces/:workspaceId/members—role:ownerв таблице, и это тоже перестало быть полным ответом — третий маршрут этого семейства (ADR 0157, RN-557). ADR 0127 назвал этот маршрут тем же классом дефекта на одну область выше, а ADR 0156 оставил его отдельным решением; он был двенадцатистрочным передатчиком, который никогда не получал актора, так что ни один потолок не мог быть применён. Здесь хуже, чем в проекте, по двум причинам, которых там нет: никакой уровень выше не останавливает падение (в проекте понижение самого себя опускает эффективную роль до роли workspace, которая часто удерживает), и в этом controller нет никакого@Deleteучастника (измерено) — отменить можно только этим же маршрутом, требующим только что оставленногоowner. ПоэтомуAddWorkspaceMemberUseCaseтеперь получает актора и отклоняет изменение СВОЕЙ СОБСТВЕННОЙ роли с 403, в обоих направлениях. Потолок не считает owners, намеренно: у условия нет числа, которое могло бы устареть (критерий ADR 0127), и оно уже даёт инвариант, ради которого существовал бы подсчёт, — workspace никогда не доходит до нуля owners, поскольку удаление последнего потребовало бы, чтобы этот owner сделал это сам. Потолок 1 не имеет пары в этой области, рассмотрен и не отзеркален: это правило об инверсии иерархии, а@RequireRole('owner')уже делает инверсию невозможной — тот, кто может вызвать, никогда не ниже того, кого он затрагивает. Его добавление, вкупе с отсутствующим маршрутом удаления, сделало быownerпоглощающим состоянием, из которого никто не выходит по HTTP, — тот класс состояния, для устранения которого родился ADR 0127. Понижение другого owner, следовательно, по-прежнему разрешено — единственный способ отозвать владение, обратимый тем же маршрутом любым оставшимся owner. -
DELETE /workspaces/:workspaceId/members/:userId—role:owner, и это пятые ворота того же семейства (ADR 0173, RN-615). Его не существовало, когда писался ADR 0157, — фраза выше про «никакого@Deleteучастника» описывает тот момент. Он несёт тот же потолок без нового правила:remocaoEhAutoRebaixamentoс ролью workspace как сегодняшней эффективной и никакой как последующей, поскольку никакой уровень выше не останавливает падение, — так что удалить САМОГО СЕБЯ — всегда 403. Это же условие защищает последнего owner, без подсчёта: маршрут вызывает толькоowner, никто не удаляет сам себя, поэтому каждый успешный вызов оставляет как минимум вызывающего в ролиowner. Удаление другого owner разрешено (так владение отзывается полностью), а потолок 1 здесь по-прежнему без пары —@RequireRole('owner')всё так же предотвращает инверсию иерархии. Удаление также каскадируется в одной транзакции: строкиproject_membersцели в этом workspace (без этого правило проект-перекрывает-workspace удерживало бы его внутри каждого проекта, где у него была собственная строка), его ключи устройства ПРОЕКТА и его PAT здесь (каждый PAT принадлежит проекту); после commit его живое подключение runner в каждом проекте сбрасывается, по принципу наилучшего усилия, путём RN-520. Объявлено и не сделано: ключи машины (всей учётной записи; они перестают дотягиваться до этого workspace, потому что роль разрешается в никакую), уже подключённые сокеты сессии. Титульного владельца (workspaces.created_by, чьи учётные данные LLM и git тратят агенты, RN-058) удалить нельзя: 409criador_do_workspace, покаPUT /workspaces/:workspaceId/owner-of-record(role:owner, получатель уже должен бытьowner, иначе 409titular_precisa_ser_owner) её не передаст — RN-616. Любой owner может её передать, в том числе другому owner, который об этом не просил. -
GET /workspaces/:workspaceId/members—role:viewer, а неownerтрёх соседних маршрутов записи (RN-652, AT-335). Минимум определяется endpoint (RN-102): читать список не значит его вести.viewer— то, что используют соседние чтения (GET /workspaces/:workspaceId,/projects), и то, что используетGET /projects/:projectId/members, — чтение, которое этот маршрут дополняет, поскольку у того, кто входит в проект только по роли workspace, нет строки проекта. Тот, кто может открыть сессию, может видеть, кто в ней говорил, аviewerworkspace уже видит каждый проект workspace. Тело имеет ту же форму, что и чтение проекта, —userId,name,email,role— и ничего больше (ниcreatedAt, ни состояния учётной записи). -
Само-ПОВЫШЕНИЕ теперь отклоняется на обоих маршрутах членства, что меняет и
POST /projects/:projectId/members. ADR 0127 записал его как остающуюся возможность («потолки — о спуске»); ADR 0157 это пересматривает. Обе половины — одно движение — человек в одиночку решает, какими полномочиями обладает, — и половина подъёма единственная, которая повышает привилегии, — ровно то, чего, как мог утверждать ADR 0127, его потолки никогда не делали. Перезапись ТОЙ ЖЕ роли по-прежнему проходит: идемпотентный upsert — не движение. Сравнение оказалось в одном месте:autoMovimentoDoProprioPapelвозвращает НАПРАВЛЕНИЕ вместо булева значения (вызывающему оно нужно, чтобы выбрать сообщение), аehAutoRebaixamentoсохраняется как его прочтение, чтобы ворота УДАЛЕНИЯ продолжали видеть только нижнюю половину, как решил ADR 0156. -
jwtбез роли не означает без авторизации. В/users/me/*область видимости — сам пользователь; вGET /workspacesлистинг уже отфильтрован по членству вызывающего. -
X-Brabo-Service-Tokenтеперь редактируется в логе (ADR 0035). Он несёт весь трафик api↔engine и не был в спискеredactpino: попав в залогированное тело ошибки, он ушёл бы в Loki открытым текстом и с хранением. Вместе с ним вошлиserviceToken,privateKey,encryptedDekиdek. Полный список — вapps/api/src/infrastructure/observability/logger.config.ts, и есть тест, утверждающий каждый путь, — список является контрактом, а не удобством. -
allowedHeadersCORS задан явно, и список должен содержать каждый заголовок, который отправляет web:Content-Type,Authorization,X-CSRF-Tokenиtraceparent. Отсутствие одного не ломает ни одного теста (ни один тест не делает preflight) и ломает браузер. -
У engine CORS только на маршрутах здоровья (ADR 0037).
/health,/liveи/readyвозвращаютAccess-Control-Allow-Originдля источников изWEB_ORIGIN;/internal/*и/metrics— нет, и исключение — в этом суть. 13 внутренних маршрутов — сервер-к-серверу с общим секретом (RN-035); CORS там ничего бы не включил — HTTP-клиент api игнорирует эти заголовки, — но объявил бы браузеру, что он ожидаемый клиент этого канала. Есть тест, утверждающий отсутствие, и тест, утверждающий, что в списке путей ровно три записи, чтобы перемещение границы было видно в diff. -
Неизвестный источник получает ответ, а не
403. В обоих сервисах запрос обслуживается и уходит без заголовка; блокирует чтение браузер, которому принадлежит решение. Ответ403сломал бы каждого клиента, не отправляющегоOrigin, — probe kubelet,curl,docker/smoke.sh. -
POST .../delegations— engine-service, как и другие внутренние маршруты (QA Фазы 8b, Infra Фазы 8c — ADR 0038) — lead каждой области регистрирует исход каждого делегата (completed/failed/dispensed) ОТДЕЛЬНО от вызова, которым область сообщает консолидированный результат наружу (gates/verdictв QA,open_infra_prв Infra). Область видимости — сессия, а не задача:taskIdнеобязателен в теле. Делегирование никогда не видно как handoff. -
POST /projects/:projectId/execution/activateполучил необязательныйoriginSessionIdв теле, а классификация не изменилась — по-прежнемуrole:maintainer(RN-135). Поле позволяет тому, у кого уже есть эта роль, закрыть сессию ЧАТА, породившую запрос, но с двумя ограничениями, не дающими использовать его для закрытия сессии другого человека:findInProject(projectId, originSessionId)молча отклоняет id, не принадлежащий СОБСТВЕННОМУ проекту пути, а закрытие происходит только еслиGetSessionPendingWorkUseCase(та же защита, что у heartbeat бездействия, RN-073) подтвердит, что там нет висящего handoff, действия или хода. Он никогда не закрывает сессию исполнения, которую сам вызов только что активировал. -
GET /internal/sessions/:sessionId/pending-workполучилaguardandoUsuarioDesde, и ни один маршрут не сменил классификацию (RN-581). По-прежнемуengine-service: поле — момент, когда завершился ход разговорного агента, считанный из того же event log, который уже пишет engine, и оно лишь решает, как долго engine держит сессию открытой. Та же RN заставила маршруты сессии, добавляющие разговор (POST .../agents/:agent/message, чат, handoffs,POST .../agents/:agent/start,POST /internal/sessions/:sessionId/events), отвечать 409sessao_encerradaв терминальной сессии — отказ по СОСТОЯНИЮ, после проверки роли, никогда не новая роль.terminationReasonв ответах сессии получил задокументированное значениеconversation_idle_timeout. -
POST /projects/:projectId/execution/activateотклоняет с409, когда у проекта нет репозитория, а классификация не изменилась — по-прежнемуrole:maintainer(RN-582, ADR 0165). Отказ происходит до какого-либо эффекта. Сообщение выбирается по handoffs проекта, прочитанным во ВСЕХ его сессиях, — поэтому оно может сказать «примите handoff к Arquiteto/Dev Lead». Оно раскрывает лишь то, что этот handoff существует, и его статус, которые любойviewerпроекта уже читает черезGET .../sessions/:sessionId/handoffs; оно никогда не называет ни сессию, ни пользователя. -
Предложение handoff агенту, уже активному в проекте, —
409agente_ja_ativo, а классификация не изменилась — человеческий маршрутPOST .../sessions/:sessionId/handoffsостаётсяrole:developer, а внутренний —engine-service(RN-635, ADR 0182). В отличие от отказа выше, этот НАЗЫВАЕТ сессию: сообщение говорит, в какой сессии того же проекта получатель активен, потому что это текст, который агент читает как результат инструмента, и указатель, который нужен человеку, чтобы пойти с ним поговорить. Любойviewerпроекта уже перечисляет его сессии и читаетagent.activatedв каждой, так что ничто не пересекает границу проекта. Те же маршруты теперь отвечают ТЕКУЩИМ предложением вместо всегда новой строки (desfecho), а внутренний принимаетseAusente— ключ, который может заставить вызов только писать МЕНЬШЕ. -
Внутренний
POST /internal/sessions/:sessionId/handoffsтеперь может ПРИНЯТЬ создаваемое им предложение Criativo→PO, а классификация не изменилась — по-прежнемуengine-service(RN-658, ADR 0185). Engine не получает права активировать агента: решает api, на основанииreadiness.confirmed, записанного ЧЕЛОВЕКОМ черезPOST .../readiness(role:developer, тот же минимум, что и для принятия через карточку), и она записывает этого человека как актора. Engine не может подделать отметку — он никогда не пишетreadiness.confirmed, — а предложение должно нестиproduct_brief, рождённый после того клика, в той же сессии.POST .../agents/criativo/validate-necessityсохраняет маршрут и роль, без вызывающего в web (RN-657). -
GET /projects/:projectId/execution/session—role:viewer, та же роль, что уGET /sessions/:sessionId(RN-139). Возвращает ТЕКУЩУЮ сессию исполнения проекта —activeс записаннымexecution.activated— илиnull; никогда самую свежую сессию проекта, которую читала вкладка Executores и которая молча меняла сессию в тот момент, когда после неё рождалась другая. -
GET /workspaces/:workspaceId/projects-summaryполучилroster.activatedAgents, а классификация не изменилась — по-прежнемуrole:viewer. Поле — список id агентов сagent.activatedв самой свежей сессии проекта (RN-630), вычисленный в том же проходе по сессиям, который уже даётexecutionActivatedиgatesEverOpened(RN-568). Не называет ни пользователя, ни сессию, ни учётные данные и не добавляет запроса. -
POST .../llm-turnиPOST .../llm-turn-streamполучилиmodelNameв теле ответа/финальном кадре, а классификация не изменилась — по-прежнемуengine-service, как всегда (RN-146). Имя модели уже разрешалось для вызова провайдера; оно лишь начало путешествовать обратно в engine, который включает его в payloadagent.response. Никакие новые данные не читаются, никакие новые учётные данные не раскрываются — это то же имя, что уже фигурирует вtoken_usage. -
PUT /projects/:projectId/agent-autonomyначал приниматьactionType: "*"— «автоматический режим» (RN-153) — а классификация не изменилась: по-прежнемуrole:maintainer, как и соседнийGET. Разница в том, что тело теперь РАЗРЕШАЕТ, а не в том, кто может вызвать: подстановочный знак предоставляет автономию для ЛЮБОГО типа действия агента сразу, а не по одному типу, как раньше. Разрешение (КОНКРЕТНОЕ правило всегда побеждает подстановочный знак) целиком живёт в репозитории (DrizzleAgentAutonomyRepository.findMode), никогда вdecide()— которая по-прежнему получает только уже разрешённуюPermissionPolicy, ровно как до появления подстановочного знака. Именно поэтому три абсолютных потолка — merge в защищённую ветку,instruction_patch,parallelize/raise_max_parallel— продолжают блокировать даже при подстановочном знаке вauto_approve(RN-154): они реагируют наcurrent.policy === 'auto_approve', никогда на то, откуда она пришла, и ни одному исключению не пришлось войти вdecide(), чтобы это продолжало действовать.ApprovalCard.tsxпредлагает кнопку, которая записывает подстановочный знак, только клиенту, который уже знает, что у негоmaintainer/owner, — но реально роль гарантирует этот же@RequireRole('maintainer'), неизменный. -
Маршрутизатор инструментов Jev — новый ИСХОДЯЩИЙ вызов, несущий контекст агента (ADR 0179, RN-625). Перед вызовом чата к модели OpenRouter api делает POST на
https://openrouter.ai/api/alpha/decisions(Decisions API, альфа) с ключом OpenRouter owner workspace — ТЕМ ЖЕ ключом, который уже тратит чат, расшифрованным в том же сценарии использования, никогда не логируемым и никогда не возвращаемым. Передаётсяstate: id агента, последнее сообщение пользователя (обрезанное до 6.000 символов), начало системного сообщения (1.500) и до шести недавних вызовов инструментов с аргументами и результатами, обрезанными до 500 символов каждый, — плюс ИМЕНА и ОПИСАНИЯ инструментов, никогда ихparameters. Это содержимое, которое вызов чата уже отправляет в ту же учётную запись OpenRouter, в меньшем объёме, но теперь оно доходит до второй модели (typesafe/jev-1.13) за ней. Переключатель — на уровне workspace (PUT /workspaces/:workspaceId/tool-router,role:owner, включён по умолчанию) и срабатывает только с моделью чата OpenRouter. Маршрутизатор сужает меню инструментов и ничего больше: он не может одобрить, отклонить или расширить что-либо, а вызов, который модель делает вне меню, по-прежнему становится Proposed Action под той же политикой. Любой сбой вызова приводит к полному каталогу. -
POST .../actions/:actionId/approve_alwaysполучилdesfechoиpadraoGravadoв ответе, а классификация не изменилась — по-прежнемуrole:developer(RN-642). Одобрение и записанный шаблон теперь делят одну транзакцию; клик по действию, которое уже было ОДОБРЕНО, отвечает201сdesfecho: "ja_aprovada"вместо409, а ОТКЛОНЁННОЕ действие по-прежнему отвечает409(acao_ja_recusada) без какой-либо записи. Потолки RN-418 иcontainer_removeпо-прежнему отклоняются с400до чтения какого-либо состояния, так что идемпотентный путь никогда не записывает шаблон, который потолок запрещает. -
Ошибка git получила новый именованный ответ:
409 merge_conflict— ни маршрут, ни роль, ни guard не изменились (RN-704).GitProviderErrorFilterотображаетGitMergeConflictError(merge локального репозитория обнаружил конфликт) в409с конфликтующими файлами в сообщении; целевая ref остаётся нетронутой, а pull request — открытым. Через конвейер одобрения действиеgit_mergeзавершаетсяfailedс этим сообщением — тот же путь, что уGitPullRequestAlreadyMergedError. -
GET /projects/:projectId/actionsпринимаетstatus=failed— тот же маршрут, та же рольdeveloper(RN-705). До сих пор принимался толькоpending;failedперечисляет действия проекта, исполнение которых упало, и ТРЕБУЕТactionType(400без него), чтобы чтение было по одному типу и никогда — всей историей. Возвращает ту же формуProposedAction, что и чтение ожидающих; упавшее действие git теперь несёт собственныйkind, сообщение об ошибке и, при конфликте merge, имена конфликтующих файлов — пути репозитория, которые та же роль уже читает в diff.
Таблица
| método | caminho | classificação |
|---|---|---|
| GET | /.well-known/jwks.json | public |
| POST | /auth/login | public |
| POST | /auth/logout | public |
| GET | /auth/oauth/:provider/callback | public |
| GET | /auth/oauth/:provider/start | public |
| POST | /auth/refresh | public |
| POST | /auth/register | public |
| POST | /auth/request-password-reset | public |
| POST | /auth/reset-password | public |
| POST | /auth/verify-email | public |
| GET | /gates | jwt |
| GET | /git/oauth/:provider/callback | public |
| GET | /health | public |
| GET | /live | public |
| GET | /metrics | public |
| GET | /runner-releases/binary | public |
| POST | /internal/sessions/:sessionId/actions | engine-service |
| GET | /internal/sessions/:sessionId/anamnese-context | engine-service |
| POST | /internal/sessions/:sessionId/anamnese-window | engine-service |
| POST | /internal/sessions/:sessionId/delegations | engine-service |
| GET | /internal/sessions/:sessionId/dev-context | engine-service |
| POST | /internal/sessions/:sessionId/epics | engine-service |
| GET | /internal/sessions/:sessionId/events | engine-service |
| GET | /internal/sessions/:sessionId/pending-work | engine-service |
| POST | /internal/sessions/:sessionId/events | engine-service |
| POST | /internal/sessions/:sessionId/gates/verdict | engine-service |
| POST | /internal/sessions/:sessionId/handoffs | engine-service |
| POST | /internal/sessions/:sessionId/hypotheses | engine-service |
| GET | /internal/sessions/:sessionId/infra-artifacts/:prActionId/files | engine-service |
| GET | /internal/sessions/:sessionId/infra-context | engine-service |
| POST | /internal/sessions/:sessionId/infra-gates/verdict | engine-service |
| POST | /internal/sessions/:sessionId/instruction-patches | engine-service |
| POST | /internal/sessions/:sessionId/max-parallel-proposals | engine-service |
| POST | /internal/sessions/:sessionId/llm-turn | engine-service |
| POST | /internal/sessions/:sessionId/llm-turn-stream | engine-service |
| POST | /internal/sessions/:sessionId/c4-diagram | engine-service |
| POST | /internal/sessions/:sessionId/module-map | engine-service |
| POST | /internal/sessions/:sessionId/module-routing | engine-service |
| POST | /internal/sessions/:sessionId/module-contracts | engine-service |
| POST | /internal/sessions/:sessionId/project-image | engine-service |
| POST | /internal/sessions/:sessionId/proficiency | engine-service |
| POST | /internal/models/sync | engine-service |
| GET | /internal/graph/prompt-templates/:name | engine-service |
| POST | /internal/graph/prompt-templates | engine-service |
| POST | /internal/rag/search | engine-service |
| POST | /internal/rag/feedback | engine-service |
| GET | /internal/gates | engine-service |
| GET | /internal/projects/:projectId/git-remote | engine-service |
| GET | /internal/projects/:projectId/business-rules | engine-service |
| GET | /internal/projects/:projectId/backlog | engine-service |
| GET | /internal/projects/:projectId/product-metrics | engine-service |
| GET | /internal/projects/:projectId/module-contracts | engine-service |
| POST | /internal/projects/:projectId/workspace-verification | engine-service |
| POST | /internal/projects/:projectId/container-exec | engine-service |
| POST | /internal/projects/:projectId/mirror-sync-result | engine-service |
| GET | /internal/projects/:projectId/container-spec | engine-service |
| POST | /internal/first-account | engine-service |
| POST | /internal/machine-device-keys | engine-service |
| GET | /internal/sessions/:sessionId/psychologist-context | engine-service |
| POST | /internal/sessions/:sessionId/stories | engine-service |
| POST | /internal/sessions/:sessionId/stories/:storyId/complete | engine-service |
| POST | /internal/sessions/:sessionId/stories/:storyId/update | engine-service |
| POST | /internal/sessions/:sessionId/stories/:storyId/archive | engine-service |
| POST | /internal/sessions/:sessionId/semantic-duplicate-check | engine-service |
| POST | /internal/sessions/:sessionId/story-modules | engine-service |
| POST | /internal/sessions/:sessionId/tasks | engine-service |
| POST | /internal/sessions/:sessionId/tasks/:taskId/block | engine-service |
| POST | /internal/sessions/:sessionId/tasks/:taskId/unblock | engine-service |
| POST | /internal/sessions/:sessionId/tasks/:taskId/gate/open | engine-service |
| POST | /internal/sessions/:sessionId/tasks/:taskId/status | engine-service |
| POST | /internal/sessions/:sessionId/tasks/claim | engine-service |
| GET | /internal/sessions/:sessionId/tasks/in-progress-by-dev | engine-service |
| POST | /internal/sessions/:sessionId/termination | engine-service |
| GET | / | jwt |
| GET | /runner/projects | jwt |
| GET | /llm/provider-capabilities | jwt |
| GET | /users/me/credentials | jwt |
| POST | /users/me/credentials | jwt |
| POST | /users/me/credentials/:provider/test | jwt |
| DELETE | /users/me/credentials/:provider | jwt |
| POST | /users/me/git-credentials | jwt |
| GET | /users/me/machine-device-keys | jwt |
| DELETE | /users/me/machine-device-keys/:deviceKeyId | jwt |
| GET | /users/me/preferences | jwt |
| PATCH | /users/me/preferences | jwt |
| POST | /users/me/preferences/detected-language | jwt |
| GET | /workspaces | jwt |
| POST | /workspaces | jwt |
| DELETE | /projects/:projectId | role:maintainer |
| GET | /projects/:projectId | role:viewer |
| PATCH | /projects/:projectId | role:maintainer |
| PUT | /projects/:projectId/execution-mode | role:maintainer |
| PUT | /projects/:projectId/mirror-path | role:maintainer |
| GET | /projects/:projectId/mirror-state | role:viewer |
| GET | /projects/:projectId/models | role:viewer |
| GET | /projects/:projectId/actions | role:developer |
| GET | /projects/:projectId/agent-autonomy | role:maintainer |
| PUT | /projects/:projectId/agent-autonomy | role:maintainer |
| DELETE | /projects/:projectId/agent-bindings/:agentSlug | role:developer |
| GET | /projects/:projectId/agent-bindings/:agentSlug | role:viewer |
| PUT | /projects/:projectId/agent-bindings/:agentSlug | role:developer |
| DELETE | /projects/:projectId/area-bindings/:areaKey | role:maintainer |
| GET | /projects/:projectId/area-bindings/:areaKey | role:viewer |
| PUT | /projects/:projectId/area-bindings/:areaKey | role:maintainer |
| GET | /projects/:projectId/agent-costs | role:developer |
| GET | /projects/:projectId/agents/:agent/instruction-versions | role:viewer |
| POST | /projects/:projectId/agents/:agent/instruction-versions/:version/rollback | role:maintainer |
| POST | /projects/:projectId/anamnese/run | role:maintainer |
| GET | /projects/:projectId/architecture | role:viewer |
| GET | /projects/:projectId/backlog | role:viewer |
| GET | /projects/:projectId/budget | role:maintainer |
| PUT | /projects/:projectId/budget | role:maintainer |
| GET | /projects/:projectId/agent-areas | role:developer |
| PATCH | /projects/:projectId/agent-areas/:key/max-parallel | role:maintainer |
| PUT | /projects/:projectId/agent-areas/:key/budget | role:maintainer |
| GET | /projects/:projectId/code/blame | role:viewer |
| GET | /projects/:projectId/code/branches | role:viewer |
| GET | /projects/:projectId/code/file | role:viewer |
| GET | /projects/:projectId/code/pull-requests | role:viewer |
| GET | /projects/:projectId/code/pull-requests/:pullRequestId/diff | role:viewer |
| GET | /projects/:projectId/code/search | role:viewer |
| GET | /projects/:projectId/code/tree | role:viewer |
| POST | /projects/:projectId/rag/search | role:viewer |
| POST | /projects/:projectId/rag/reindex | role:maintainer |
| POST | /projects/:projectId/rag/local | role:maintainer |
| POST | /projects/:projectId/rag/feedback | role:viewer |
| GET | /projects/:projectId/rag/coverage | role:viewer |
| GET | /projects/:projectId/container | role:viewer |
| GET | /projects/:projectId/container/lifecycle | role:viewer |
| GET | /projects/:projectId/coverage | role:viewer |
| GET | /projects/:projectId/events/:eventId | role:viewer |
| POST | /projects/:projectId/execution/activate | role:maintainer |
| GET | /projects/:projectId/execution/session | role:viewer |
| GET | /projects/:projectId/git/:provider/connect | role:maintainer |
| POST | /projects/:projectId/git/:provider/repository | role:maintainer |
| POST | /projects/:projectId/git/:provider/repository/adopt | role:maintainer |
| GET | /projects/:projectId/git/bootstrap | role:viewer |
| GET | /projects/:projectId/git/bootstrap/plan | role:viewer |
| POST | /projects/:projectId/git/bootstrap/plan/approve | role:maintainer |
| POST | /projects/:projectId/git/bootstrap/plan/skip | role:maintainer |
| POST | /projects/:projectId/git/bootstrap/acknowledge-protection-failure | role:maintainer |
| GET | /projects/:projectId/git/repository | role:viewer |
| GET | /projects/:projectId/hypotheses | role:viewer |
| POST | /projects/:projectId/hypotheses/:hypothesisId/accept | role:developer |
| POST | /projects/:projectId/hypotheses/:hypothesisId/dismiss | role:developer |
| GET | /projects/:projectId/infra-artifacts | role:viewer |
| GET | /projects/:projectId/instruction-versions | role:viewer |
| GET | /projects/:projectId/members | role:viewer |
| POST | /projects/:projectId/members | role:maintainer |
| DELETE | /projects/:projectId/members/:userId | role:maintainer |
| GET | /projects/:projectId/model-binding | role:viewer |
| PUT | /projects/:projectId/model-binding | role:maintainer |
| GET | /projects/:projectId/model-bindings/resolved | role:viewer |
| GET | /projects/:projectId/permissions | role:maintainer |
| PUT | /projects/:projectId/permissions | role:maintainer |
| POST | /projects/:projectId/personal-access-tokens | role:developer |
| GET | /projects/:projectId/personal-access-tokens | role:developer |
| GET | /projects/:projectId/personal-access-tokens/all | role:maintainer |
| DELETE | /projects/:projectId/personal-access-tokens/:tokenId | role:developer |
| DELETE | /projects/:projectId/personal-access-tokens/:tokenId/admin | role:maintainer |
| GET | /projects/:projectId/open-questions | role:viewer |
| POST | /projects/:projectId/open-questions/:questionId/answer | role:viewer |
| GET | /projects/:projectId/run-instructions | role:viewer |
| GET | /projects/:projectId/runner-device-keys | role:developer |
| DELETE | /projects/:projectId/runner-device-keys/:deviceKeyId | role:developer |
| GET | /projects/:projectId/proficiency | role:viewer |
| DELETE | /projects/:projectId/proficiency/me | role:viewer |
| POST | /projects/:projectId/proficiency/me/opt-in | role:viewer |
| GET | /projects/:projectId/psychologist/analyses | role:viewer |
| GET | /projects/:projectId/psychologist/status | role:viewer |
| POST | /projects/:projectId/runner-ticket | role:developer |
| POST | /projects/:projectId/terminal-ticket | role:viewer |
| GET | /projects/:projectId/sessions | role:viewer |
| POST | /projects/:projectId/sessions | role:developer |
| GET | /projects/:projectId/sessions/:sessionId | role:viewer |
| PATCH | /projects/:projectId/sessions/:sessionId | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/actions | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/actions | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/actions/:actionId/approve | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/actions/:actionId/approve_always | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/actions/:actionId/deny | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agent/cancel | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agent/message | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agent/messages/:messageId/cancel | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agent/start | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agent/structured-question/:questionSetId/answer | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agentId/rearm | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/arquiteto/handoff-infra | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/criativo/validate-necessity | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/budget | role:developer |
| PUT | /projects/:projectId/sessions/:sessionId/budget | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/chat | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/events | role:viewer |
| POST | /projects/:projectId/sessions/:sessionId/events | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/events/:eventId | role:viewer |
| GET | /projects/:projectId/sessions/:sessionId/context-summary | role:viewer |
| POST | /projects/:projectId/sessions/:sessionId/execution/parallelize | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/handoffs | role:viewer |
| POST | /projects/:projectId/sessions/:sessionId/handoffs | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/handoffs/:handoffId/accept | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/model-binding | role:viewer |
| PUT | /projects/:projectId/sessions/:sessionId/model-binding | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/psychologist/reanalyze | role:maintainer |
| POST | /projects/:projectId/sessions/:sessionId/readiness | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/response-language | role:viewer |
| PUT | /projects/:projectId/sessions/:sessionId/response-language | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/socket-ticket | role:viewer |
| POST | /projects/:projectId/sessions/:sessionId/tasks/:taskId/unblock | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/token-usage | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/transition | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/reopen | role:developer |
| POST | /projects/:projectId/tasks/:taskId/gates/:gate/resume | role:developer |
| GET | /projects/:projectId/spend/me | role:viewer |
| POST | /projects/:projectId/stories/:storyId/return | role:developer |
| PATCH | /projects/:projectId/stories/:storyId/title | role:developer |
| POST | /projects/:projectId/stories/:storyId/archive | role:developer |
| POST | /projects/:projectId/stories/promote | role:developer |
| DELETE | /workspaces/:workspaceId | role:owner |
| GET | /workspaces/:workspaceId | role:viewer |
| PATCH | /workspaces/:workspaceId | role:maintainer |
| GET | /workspaces/:workspaceId/members | role:viewer |
| POST | /workspaces/:workspaceId/members | role:owner |
| DELETE | /workspaces/:workspaceId/members/:userId | role:owner |
| PUT | /workspaces/:workspaceId/owner-of-record | role:owner |
| PUT | /workspaces/:workspaceId/tool-router | role:owner |
| GET | /workspaces/:workspaceId/model-binding | role:viewer |
| PUT | /workspaces/:workspaceId/model-binding | role:maintainer |
| GET | /workspaces/:workspaceId/credential-spend | role:owner |
| GET | /workspaces/:workspaceId/spend-report | role:owner |
| GET | /workspaces/:workspaceId/huggingface/models | role:maintainer |
| POST | /workspaces/:workspaceId/huggingface/pull-requests | role:maintainer |
| POST | /workspaces/:workspaceId/huggingface/pull-requests/:id/confirm | role:maintainer |
| GET | /workspaces/:workspaceId/huggingface/pull-requests/:id | role:maintainer |
| POST | /workspaces/:workspaceId/models/activate | role:owner |
| GET | /workspaces/:workspaceId/models/catalog | role:maintainer |
| POST | /workspaces/:workspaceId/models/sync | role:owner |
| POST | /workspaces/:workspaceId/models/uses | role:owner |
| GET | /workspaces/:workspaceId/models/:modelId/price-changes | role:maintainer |
| PATCH | /workspaces/:workspaceId/models/:modelId/pricing | role:owner |
| GET | /workspaces/:workspaceId/projects | role:viewer |
| POST | /workspaces/:workspaceId/projects | role:maintainer |
| GET | /workspaces/:workspaceId/projects-base | role:maintainer |
| GET | /workspaces/:workspaceId/project-folders | role:maintainer |
| GET | /workspaces/:workspaceId/projects-status | role:viewer |
| GET | /workspaces/:workspaceId/projects-summary | role:viewer |
| GET | /workspaces/:workspaceId/summary | role:viewer |
| POST | /workspaces/:workspaceId/unread-events | role:viewer |
| GET | /workspaces/:workspaceId/containers | role:viewer |