ADR 0134 — dev agent 在项目的真实容器内部执行,终端在其中获得一个自动批准的下限
背景
ADR 0133(PR 1.5)端到端地关闭了 container_start 路径(proposed_action → 批准 → broker →
project_containers),并白纸黑字地声明:“os dev agents AINDA não trabalham dentro do container que sobe
[...] o que roda dentro do container, por enquanto, é só o que o broker sobe — sem consumidor ainda.”本 PR 就是
提供这个消费者的 PR。
如今,即使有一个真实的容器在运行,Engine.Actions.TerminalExecutor 仍然在 engine 自己的进程内部通过
System.cmd 运行 dev agent 的所有终端命令,针对共享文件夹(PROJECT_WORKSPACES_ROOT)——与一贯的做法
完全一样。容器处于空闲:其中从来没有任何东西被 docker exec。
决策
当项目处于 execution_mode: container 并且在 project_containers 中有一行登记为 running 时,dev
agent 的终端命令改为在容器内部运行,通过(经由 broker 的)docker exec——不再通过本地 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 一侧有完整的作用域校验(/work 之外为
DiretorioForaDoEscopoError)——但它有零个调用者。这样闭合:
- 新的内部路由
POST internal/projects/:projectId/container-exec,放在InternalProjectsController中 (而不是InternalContainersController——见下文“为什么不放在 broker 的 controller 中”)——一贯的EngineServiceGuard,由 engine 调用,而不是由 broker 调用。 ExecutarComandoNoContainerUseCase:调用ContainerBrokerPort.exec,并处理BrokerRecusouError/BrokerIndisponivelError,返回类型化的失败结果({ sucesso: false, motivo })—— 从不让异常经由 HTTP 路由泄漏。任何其他错误(真正的缺陷)继续向上传播——不会被伪装成命令失败。Engine.Sessions.EngineApiClient.executar_comando_no_container/4——一贯的清单(callback + delegator + 实现Req.post+ 测试用 fake),调用新路由。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:登记与观察永不合并)就是
这种情况——不是 bug,是“登记”从来不是保证的代价。回退到容器外的 System.cmd,会重新打开本 PR 存在要关闭的那个
隔离逃逸向量。
为什么不放在 broker 的 controller 中
InternalContainersController(路由 container-spec)有一条明确的注释:“sem @Post aqui, e isso não é
esquecimento”——因为写入容器生命周期的是 RegistrarTransicaoDeContainer,而在那里给 broker 写入权会
混淆由谁决定状态。新路由在那条注释所保护的两个维度上都不同:调用者是 engine(而不是从 api 读取的 broker),
它做的事也不是写 project_containers——而是运行一条命令并返回结果。因此它放在 InternalProjectsController 中,
与 confirm-workspace-verification(另一条由 engine、而不是 broker 调用的路由)并列。
dev agent 的 worktree 已经出现在 /work 中——无需改动 Workspace.ensure!/4
已验证的假设:确认成立。 apps/broker/src/operacoes.ts 中的 especificacaoDoProjeto/2 把 raizDoProjeto
组成为 ${PROJECT_WORKSPACES_HOST_ROOT}/${workspaceDirName}——与
Engine.Actions.Workspace.workspace_dir/2 在 engine 进程内部解析
<PROJECT_WORKSPACES_ROOT>/<workspace_dir_name> 时已在使用的同一个根(workspace_dir_name,RN-109)。
packages/docker-port/src/docker-cli.ts 把这个 raizDoProjeto 挂载为唯一的 bind
(${spec.raizDoProjeto}:${PONTO_DE_MONTAGEM}:rw,PONTO_DE_MONTAGEM = '/work')。也就是说:这是同一个
物理目录,只是通过两条路径到达——从 engine 内部,经由 PROJECT_WORKSPACES_ROOT(engine 一侧的 bind/卷);
从刚启动的容器内部,经由 /work(由 daemon 针对宿主机上的 PROJECT_WORKSPACES_HOST_ROOT 解析的 bind)。
dev agent 如今已在使用的 worktree——以一贯的方式创建,Workspace.ensure!/4 在 engine 进程内运行
System.cmd("git", ...),写入共享卷——已经出现在容器内部的 /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 之内——共享根,
或某个 dev agent 的 .worktrees/<agent_id> 子目录,经由 Engine.Dev.WorktreeManager)。run_no_container/4
在发送给 broker 之前把这个路径转换到 /work 之内:把前缀 <project_workspaces_root>/<workspace_dir_name>
替换为 /work,保留后缀。缺失的 cwd(在 workspace 根运行)变为 nil——broker 默认使用 /work。不在该根之内
的 cwd(对 execution_mode: container 不应发生)按原样发送,不尝试猜测——如果它不在 /work 之内,broker 会以
DiretorioForaDoEscopoError 拒绝,这是纵深防御,而不是悄悄伪造一个路径。
决策 3:容器内的自主
作用域是真实容器内部项目文件夹的终端命令会自动批准——但这是一个下限,不是新的上限,decide.ts 的五个
绝对上限逐字节保持原样。
机制:ProposeActionUseCase.execute 查询执行所使用的同一个 ObterCicloDeVidaDoContainerUseCase(只在
actionType === 'terminal' && project.executionMode === 'container' 时——其余情况都省掉这次查询),并在
DecideContext 中组装 containerExecutionActive。在 decide()(仍然是纯的——零 IO)中,current 的
初始值不再总是 { policy: 'require_approval', reason: 'default' },而在 action.actionType === 'terminal' && ctx.containerExecutionActive 时变为 { policy: 'auto_approve', ... }。
为什么这在不重复已有上限机制的情况下是安全的: 随后的每个阶段(agent_autonomy、permissions.json)在
有明确意见时都已经会替换 current——正是这样,一贯的默认 require_approval 已经可以被显式的 deny 降级,
或被 ask 保持。改变初始值不会改变这一属性:显式的 agent_autonomy: deny,或匹配该命令的带 ask/deny
的 permissions.json,会像以前降级默认 require_approval 一样降级容器的下限——这不是新路径,是同一个
current,只是起点不同。而绝对上限(作用域、git push/特权命令、受保护合并、instruction_patch、并行)仍然在
之后运行,作用于 current.policy === 'auto_approve'——包括当这个 auto_approve 来自容器下限、而不是显式规则时。
安全理由是边界的交换,而不是边界的缺失。 在容器之外,终端作用域(ADR 0055)的边界是词法的:
terminalNoEscopo 把 cwd/command 与 ctx.projectScopeRoot(一个宿主机路径)比较——正是这项检查让如今按
permissions.json allow 自动批准变得安全。在真实容器内部,Docker 的 mount namespace 是更强的边界:运行
命令的进程在物理上看不到 /work 之外的任何东西(关闭了 path-scope.ts 的注释为 mounted/runner 记录为开放
的 symlink 向量,发现 U——那里根本没有 mount namespace,这里有)。broker 已经做的 /work 校验
(DiretorioForaDoEscopoError,apps/broker/src/operacoes.ts)是作用于已转换 cwd 的第二层。词法作用域
上限(terminalNoEscopo)继续在下限之上运行,没有任何例外——它作用于一贯的宿主机路径(到达 decide()
的 cwd/command 从不被转换为 /work;这一转换只在之后、在 engine 中组装对 broker 的调用时才发生)——这是
纵深防御,而不是替代:一个今天 decide() 会标为超出作用域的命令,在下限开启时仍会被标为超出作用域,并重新需要
人的决定。
为什么是下限而不是预置的 agent_autonomy 规则: 一条预置规则(像 open_adr_pr 的 auto mode 那样)
需要在每次容器启动/停止时写入/删除,并会在容器被外部停掉的那一刻与 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_containers 中的 project_id UNIQUE,本次交付没有变化。
词法终端作用域(ADR 0055)没有失去任何消费者。 它仍然是 mounted/runner(没有任何 mount namespace 保护)
唯一的边界,并继续作为第二层在容器下限之上运行——在任何地方都没有被替代。
考虑过并被放弃的替代方案:在 decide() 内部也转换 cwd,以便与 /work 比较。 这会让作用域上限作用于
最终路径,表面上更“正确”——但它会把一个基础设施决定(broker 把卷挂载在哪里)搬进一个文件其余部分有意
保持纯净、对任何容器一无所知的函数中,并会在两个必须永远一致的地方(decide.ts 和 TerminalExecutor)重复
cwd 的转换。被放弃:作用域上限作用于它一直作用的宿主机路径时仍然有用——它与容器内部的 mount namespace 是
冗余的,而不是被它取代,而不需要同步的冗余,正是本 ADR 所追求的纵深防御。