Перейти к основному содержимому

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), — но у него было НОЛЬ вызывающих. Это замыкается так:

  1. Новый внутренний маршрут, POST internal/projects/:projectId/container-exec, в InternalProjectsController (не в InternalContainersController — см. «Почему не в контроллере broker» ниже) — всегдашний EngineServiceGuard, вызываемый ENGINE, а не broker.
  2. ExecutarComandoNoContainerUseCase: вызывает ContainerBrokerPort.exec и обрабатывает BrokerRecusouError/BrokerIndisponivelError, возвращая ТИПИЗИРОВАННЫЙ результат сбоя ({ sucesso: false, motivo }) — никогда не даёт исключению утечь через HTTP-маршрут. Любая ДРУГАЯ ошибка (настоящий дефект) продолжает распространяться — она не маскируется под сбой команды.
  3. Engine.Sessions.EngineApiClient.executar_comando_no_container/4 — всегдашний чек-лист (callback + делегатор + реализация Req.post + тестовая фальшивка), вызывающий новый маршрут.
  4. 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.