Skip to content

A streaming popen on the Executor, and ACP in the sandbox

The Executor gains one streaming primitive — popen(argv, cwd, env) — alongside the request/response run. It returns a long-lived process with line-buffered stdio pipes, which is what the ACP harness needs (it speaks JSON-RPC 2.0 over the agent's stdio). Routing ACP's process launch through popen lifts ADR 0014's last deferral: ACP agents now run inside a docker sandbox, not just on the host.

Status

accepted (extends ADR 0014; completes the harness-in-sandbox set)

Context

ADR 0014 routed every harness's effects through the Executor so they land wherever the Sandbox is. It worked for the CLI family and the in-process openai loop, but explicitly deferred ACP: ACP is a bidirectional, long-lived JSON-RPC conversation over the agent subprocess's stdin/stdout, and the Executor's only process primitive was run — fire a command, wait, collect stdout. You cannot drive a session over that. So AcpConnection called subprocess.Popen directly, pinning the ACP agent to the host even when the rest of the Cell ran in a container.

Decision

Add Executor.popen(argv, *, cwd, env) -> Popen: spawn a long-lived process in the sandbox with text, line-buffered stdin/stdout/stderr pipes.

  • Host default (base class / LocalExecutor): subprocess.Popen — today's behavior.
  • ContainerExecutor: docker exec -i into the running container, env forwarded by the same delta+passthrough filter as run. The agent runs in the sandbox; its JSON-RPC stdio is piped to the host driver, and its fs/* and permission callbacks come back over that channel exactly as before (the bind mount keeps the host-side fs handlers correct).
  • HarborExecutor: raises NotImplementedError — Harbor's exec is request/response with no persistent-stdio channel, so an ACP session can't be driven through it. Failing clearly beats hanging; the message points at backend: docker or a non-ACP harness.

AcpConnection takes an optional executor (default LocalExecutor) and spawns through popen; the ACP harness passes ctx.executor, so the same container.harness: true switch that sandboxes every other harness now sandboxes ACP too. Whether ACP runs in the sandbox follows the same rule as the rest (ADR 0014's _harness_runtime): host by default, container when opted in.

Considered options

  • Stream over run. run is buffered request/response; there is no way to interleave reads and writes mid-command. Rejected — wrong shape for a session protocol.
  • A separate ACP-specific transport abstraction. A second seam beside the Executor, duplicating cwd/env/teardown. Rejected — popen is one method on the seam touchstone already has.
  • Leave ACP on the host (ADR 0014 status quo). Leaves untrusted ACP-agent commands on the orchestrator and makes ACP the one family that can't be isolated. Rejected.

Consequences

  • Every harness family can run in a docker sandbox: output-only CLI, claude-code-stream, openai, and now ACP. container.harness: true is the single switch.
  • ACP on Harbor is explicitly unsupported, with an actionable error rather than a hang — an honest boundary, since Harbor offers no stdio stream. (A future Harbor streaming primitive, or a websocket ACP transport, would lift it; out of scope here.)
  • The container path is validated by a docker exec -i stdio round-trip test; the host path is covered by the existing ACP suite (which now spawns through LocalExecutor.popen).
  • popen is a small, general addition — any future long-lived in-sandbox process (a language server, a debugger bridge) composes it without a new seam.