ADR 0134 — Dev agents выполняются ВНУТРИ реального контейнера проекта, а terminal получает там нижнюю границу автоодобрения
Контекст
ADR 0133 (PR 1.5) замкнул путь container_start (proposed_action →
одобрение → broker → project_containers) от начала до конца и прямым текстом
объявил: «dev agents ПОКА не работают внутри поднимаемого
контейнера [...] то, что выполняется внутри контейнера, пока — только то, что поднимает broker,
— пока без потребителя». Это PR, который даёт потребителя.
Сегодня, даже с реально запущенным контейнером, Engine.Actions.TerminalExecutor
продолжает выполнять КАЖДУЮ команду terminal dev agent через System.cmd
ВНУТРИ собственного процесса engine, против общей папки
(PROJECT_WORKSPACES_ROOT) — ровно как делал всегда. Контейнер простаивает:
в нём никогда ничего не выполняется через docker exec.
Решение
Когда проект находится в execution_mode: container с ЗАРЕГИСТРИРОВАННОЙ строкой
running в project_containers, команда terminal dev
agent начинает выполняться ВНУТРИ контейнера, через docker exec (через broker), —
а не через локальный System.cmd.
Пятый выход decisao_de_execucao/1
У Engine.Actions.TerminalExecutor уже было ЧЕТЫРЕ выхода (RN-423, ADR 0104,
маршрутизация к локальному runner). Он получает пятый, :executar_no_container,
обусловленный execution_mode == "container" И
Engine.Containers.ProjectContainerLifecycle.running?/1 — новым чтением
только для чтения, напрямую из таблицы project_containers (тот же шаблон, что
Engine.Projects.Project, читающий projects: engine читает колонку, которую api
записывает, не дублируя вывод).
mounted НИКОГДА не попадает в этот выход: broker отказывается поднимать контейнер для этого
режима (ModoDeExecucaoNaoSuportadoError, намеренная политика, пока PR 1.7 не
пересмотрит три режима), поэтому вопрос для него даже не имеет смысла.
Новое плечо: engine → api → broker.exec
ContainerBrokerPort.exec (ADR 0130) уже существовал, со всей проверкой области
на стороне broker (DiretorioForaDoEscopoError вне /work), —
но у него было НОЛЬ вызывающих. Это замыкается так:
- Новый внутренний маршрут,
POST internal/projects/:projectId/container-exec, вInternalProjectsController(не вInternalContainersController— см. «Почему не в контроллере broker» ниже) — всегдашнийEngineServiceGuard, вызываемый ENGINE, а не broker. ExecutarComandoNoContainerUseCase: вызываетContainerBrokerPort.execи обрабатываетBrokerRecusouError/BrokerIndisponivelError, возвращая ТИПИЗИРОВАННЫЙ результат сбоя ({ sucesso: false, motivo }) — никогда не даёт исключению утечь через HTTP-маршрут. Любая ДРУГАЯ ошибка (настоящий дефект) продолжает распространяться — она не маскируется под сбой команды.Engine.Sessions.EngineApiClient.executar_comando_no_container/4— всегдашний чек-лист (callback + делегатор + реализацияReq.post+ тестовая фальшивка), вызывающий новый маршрут.Engine.Actions.TerminalExecutorполучаетrun_no_container/4(шаблон:run_via_runner/4), который вызывает клиента выше и отображает результат вbuild_result/failed_result.
Сбой broker.exec НИКОГДА не становится крахом или молчаливым откатом на
System.cmd вне контейнера. sucesso: false (broker отказал или не
ответил) и транспортный сбой engine→api становятся обычным failed_result —
ясное сообщение, exit_code: nil, ровно как любая другая
упавшая команда. Контейнер, убитый или удалённый извне между зарегистрированным
running и моментом вызова (RN-486: зарегистрированное и наблюдаемое никогда не
сливаются), — ЭТО и есть этот случай, не баг, а цена того, что «зарегистрированное» никогда не
гарантия. Откат на System.cmd вне контейнера заново открыл бы
ровно тот вектор побега из изоляции, ради закрытия которого существует этот PR.
Почему не в контроллере broker
У InternalContainersController (маршрут container-spec) есть
явный комментарий: «sem @Post aqui, e isso não é esquecimento» — потому что
жизненный цикл контейнера ПИШЕТ RegistrarTransicaoDeContainer, а
дать там BROKER полномочия на запись смешало бы, кто решает состояние.
Новый маршрут отличается по обеим осям, которые охраняет тот комментарий: ВЫЗЫВАЕТ
engine (а не broker, читающий из api), и делает он не запись в
project_containers, а выполнение команды и возврат результата.
Поэтому он живёт в InternalProjectsController, рядом с
confirm-workspace-verification (ещё одним маршрутом, который вызывает engine, а не
broker).
Worktree dev agent УЖЕ появляется в /work — без изменения Workspace.ensure!/4
Гипотеза проверена: подтверждена. apps/broker/src/operacoes.ts,
especificacaoDoProjeto/2, составляет raizDoProjeto как
${PROJECT_WORKSPACES_HOST_ROOT}/${workspaceDirName} — ТОТ ЖЕ корень
(workspace_dir_name, RN-109), который Engine.Actions.Workspace.workspace_dir/2
уже использует, чтобы разрешать <PROJECT_WORKSPACES_ROOT>/<workspace_dir_name> внутри
процесса engine. packages/docker-port/src/docker-cli.ts монтирует этот
raizDoProjeto как ЕДИНСТВЕННЫЙ bind (${spec.raizDoProjeto}:${PONTO_DE_MONTAGEM}:rw,
PONTO_DE_MONTAGEM = '/work'). То есть: это ТОТ ЖЕ физический каталог, лишь
достигаемый двумя путями — изнутри engine, через
PROJECT_WORKSPACES_ROOT (bind/том на стороне engine); изнутри
только что поднятого контейнера, через /work (bind, разрешаемый демоном против
PROJECT_WORKSPACES_HOST_ROOT на ХОСТЕ). Worktree, который dev agent уже использует
сегодня, — созданный как всегда, Workspace.ensure!/4, выполняющий
System.cmd("git", ...) внутри процесса engine и пишущий в общий
том, — уже появляется ВНУТРИ контейнера в /work, без того чтобы
Workspace.ensure!/4/ensure_remoto/2 менялись в ЧЁМ-ЛИБО. Контейнер — лишь
ещё один наблюдатель ТОГО ЖЕ каталога.
Это зависит от того, чтобы PROJECT_WORKSPACES_HOST_ROOT была настроена
правильно и указывала на путь ХОСТА, дающий ту же физическую папку,
которую PROJECT_WORKSPACES_ROOT разрешает внутри контейнера engine, — это
ответственность оператора (уже задокументированная в комментариях
docker-compose.yml), и остаётся ею: здесь ничего не меняется.
cwd: трансляция с ХОСТА в /work
opts[:cwd] приходит в TerminalExecutor.run/3 как АБСОЛЮТНЫЙ путь
ХОСТА (внутри project_workspaces_root — общего корня, или
подкаталога .worktrees/<agent_id> конкретного dev agent, через
Engine.Dev.WorktreeManager). run_no_container/4 транслирует этот путь
внутрь /work ДО отправки в broker: заменяет префикс
<project_workspaces_root>/<workspace_dir_name> на /work, сохраняя
суффикс. Отсутствующий cwd (выполнение в корне workspace) становится nil — broker
по умолчанию использует /work. cwd, который не находится внутри этого корня (не
должно случаться для execution_mode: container), отправляется как пришёл,
без попытки угадать, — broker отклоняет с DiretorioForaDoEscopoError,
если он не внутри /work, — эшелонированная защита вместо
молча сфабрикованного пути.
Решение 3: автономия внутри контейнера
Команда terminal, чья область — папка проекта ВНУТРИ реального
контейнера, одобряется автоматически — но это НИЖНЯЯ ГРАНИЦА, а не новый потолок, и пять
абсолютных потолков decide.ts остаются байт в байт какими были.
Механизм: ProposeActionUseCase.execute обращается к ТОМУ ЖЕ
ObterCicloDeVidaDoContainerUseCase, что использует выполнение (только когда
actionType === 'terminal' && project.executionMode === 'container' —
экономя запрос во всём остальном), и собирает containerExecutionActive в
DecideContext. Внутри decide() (которая остаётся ЧИСТОЙ — ноль IO)
НАЧАЛЬНОЕ значение current перестаёт быть всегда { policy: 'require_approval', reason: 'default' } и становится { policy: 'auto_approve', ... }, когда action.actionType === 'terminal' && ctx.containerExecutionActive.
Почему это безопасно без дублирования уже существующего механизма потолков:
каждый последующий этап (agent_autonomy, permissions.json) уже ЗАМЕНЯЕТ
current, когда у него есть явное мнение, — именно так всегдашний
require_approval по умолчанию уже мог быть понижен явным deny или сохранён
через ask. Смена НАЧАЛЬНОГО значения не меняет этого свойства: явный
agent_autonomy: deny или permissions.json с ask/deny,
совпадающими с командой, продолжают понижать нижнюю границу контейнера ровно так, как
уже понижали значение по умолчанию require_approval, — это не новый путь, а
тот же current с другой точкой отсчёта. А абсолютные потолки
(область, git push/привилегированная команда, защищённый merge, instruction_patch,
параллелизм) продолжают выполняться ПОСЛЕ, над current.policy === 'auto_approve' — в том числе когда этот auto_approve пришёл от нижней границы
контейнера, а не от явного правила.
Обоснование безопасности — это смена границы, а не отсутствие
её. Вне контейнера граница области terminal (ADR 0055)
ЛЕКСИЧЕСКАЯ: terminalNoEscopo сравнивает cwd/command с
ctx.projectScopeRoot, путём ХОСТА, — и именно эта проверка делает
безопасным автоодобрение через permissions.json allow сегодня. Внутри реального
контейнера mount namespace Docker — БОЛЕЕ сильная граница: процесс,
выполняющий команду, физически не видит ничего вне /work (закрывает вектор
symlink, который комментарий path-scope.ts фиксирует как открытый для
mounted/runner, находка U — там нет никакого mount namespace, здесь есть).
Проверка /work, которую broker уже делает (DiretorioForaDoEscopoError,
apps/broker/src/operacoes.ts), — ВТОРОЙ слой, над уже
транслированным cwd. Лексический потолок области (terminalNoEscopo) продолжает выполняться
поверх нижней границы, без каких-либо исключений — он работает с ТЕМИ ЖЕ
путями ХОСТА, что и всегда (cwd/command, приходящие в decide(),
НИКОГДА не транслируются в /work; эта трансляция происходит только потом, в
engine, при сборке вызова к broker), — это эшелонированная защита, а не
замена: команда, которую decide() сегодня пометила бы вне области,
остаётся помеченной вне области при включённой нижней границе и снова требует человеческого
решения.
Почему НИЖНЯЯ ГРАНИЦА, а не засеянное правило agent_autonomy: засеянное правило
(как auto mode у open_adr_pr) пришлось бы записывать/удалять
каждый раз, когда контейнер поднимается/опускается, и оно разошлось бы с ЗАРЕГИСТРИРОВАННЫМ в
project_containers в тот момент, когда контейнер упал бы извне, — та же
проблема двух источников истины, которую ADR 0130 уже избегает, никогда не
сливая зарегистрированное и наблюдаемое (RN-486). Обращение к жизненному циклу в момент
решения — ТОТ ЖЕ источник, к которому обращается выполнение в момент выполнения, —
ноль синхронизации между ними.
Последствия
ContainerBrokerPort.exec получает ПЕРВОГО реального вызывающего — последняя
из пяти операций ADR 0128/0130 без него. stop/remove остаются без
собственного proposed_action, этим PR не изменено.
mounted/runner по-прежнему без какого-либо контейнера — RN-421/RN-423
остаются как были; структурный барьер сдерживания join по-прежнему
отсутствует для этих двух режимов (находка ADR 0072, здесь не закрыта).
Вопрос «сколько модулей, один контейнер» остаётся как есть —
project_id UNIQUE в project_containers, без изменений в этой поставке.
Лексическая область terminal (ADR 0055) не теряет ни одного потребителя.
Она остаётся единственной границей для mounted/runner (у которых нет защищающего
mount namespace) и продолжает выполняться ПОВЕРХ нижней границы
контейнера как второй слой — нигде не заменена.
Рассмотренная и отброшенная альтернатива: трансляция cwd и внутри
decide(), для сравнения с /work. Это заставило бы потолок области работать
над ИТОГОВЫМ путём, внешне более «правильным», — но перенесло бы
решение ИНФРАСТРУКТУРЫ (где broker смонтировал том) внутрь
функции, которую остальной файл намеренно держит чистой и не знающей
ни о каком контейнере, и продублировало бы трансляцию cwd в двух
местах (decide.ts и TerminalExecutor), которым пришлось бы соглашаться
навсегда. Отброшено: потолок области остаётся полезным, работая над
путями ХОСТА, над которыми он всегда работал, — он ИЗБЫТОЧЕН по отношению к mount namespace
внутри контейнера, а не заменён им, а избыточность, не
требующая синхронизации, — это эшелонированная защита, которой добивается этот ADR.