Files
bot-bottle/claude_bottle/backend/docker/launch.py
T
didericis 21054212d4 feat(mitmproxy): wire the sidecar into the bottle launch lifecycle
Second step of PRD 0005. The mitmproxy sidecar from the previous
commit now actually runs alongside pipelock when a bottle launches.

- BottleBackend gains a non-abstract provision_ca with a default
  no-op so non-Docker backends aren't forced to implement TLS
  interception. provision() orchestrates ca → prompt → skills → ssh
  → git; CA goes first so trust is set up before anything else runs
  inside the agent.

- DockerBottlePlan gains `mitmproxy_plan: MitmproxyProxyPlan`. The
  prepare step builds it alongside the existing pipelock plan; no
  new manifest schema or host-side scratch files.

- DockerBottleBackend grows self._mitm, threads it through prepare
  and launch. Mirror of the existing self._proxy pattern.

- launch.py brings the mitmproxy sidecar up between pipelock and
  the agent container, passing pipelock's service-name URL via
  env. ExitStack callback handles teardown in reverse order.

- The agent's HTTPS_PROXY / HTTP_PROXY now point at mitmproxy (not
  pipelock directly). Three new -e flags inject the CA trust trio
  (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE) at
  docker run time; Docker propagates those into docker exec so the
  claude process sees them without per-exec threading.

- New provisioner backend/docker/provision/ca.py extracts the CA
  cert from the running mitmproxy sidecar, copies it into the agent
  at /usr/local/share/ca-certificates/claude-bottle-mitm.crt, runs
  update-ca-certificates, and emits a stderr line with the SHA-256
  fingerprint (stdlib ssl + hashlib; no subprocess).

Cleanup needs no change — `docker ps --filter name=^claude-bottle-`
already catches the new claude-bottle-mitm-<slug> containers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 13:38:51 -04:00

170 lines
6.4 KiB
Python

"""Launch step for the Docker bottle backend.
`launch` is a context manager: builds the image(s), creates the per-
agent networks, brings up the pipelock sidecar, starts the agent
container, then runs the provision step. Teardown is sequenced via an
ExitStack so callbacks fire in reverse-order of registration even if
something raises mid-bring-up.
"""
from __future__ import annotations
import dataclasses
import os
import subprocess
import sys
from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Callable, Generator
from ...log import die, info
from . import network as network_mod
from . import util as docker_mod
from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan
from .mitmproxy import DockerMitmproxyProxy, mitmproxy_proxy_url
from .pipelock import DockerPipelockProxy, pipelock_proxy_url
# Path inside the agent container where the mitmproxy CA cert lives
# after provision_ca runs. Exported as a module-level constant so
# both the agent's docker-run env trio and the provisioner agree.
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-mitm.crt"
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
# Where the repo root lives, for `docker build` context. Computed once.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
@contextmanager
def launch(
plan: DockerBottlePlan,
*,
proxy: DockerPipelockProxy,
mitm: DockerMitmproxyProxy,
provision: Callable[[DockerBottlePlan, str], str | None],
) -> Generator[DockerBottle, None, None]:
"""Build, launch, and provision a Docker bottle. Teardown on exit.
`provision` is the backend's provision orchestrator (passed in so
this module stays free of backend-class plumbing)."""
stack = ExitStack()
def teardown() -> None:
try:
stack.close()
except BaseException:
# Teardown must not raise; swallow so the caller's
# __exit__ path can still propagate the original error.
pass
try:
docker_mod.build_image(plan.image, _REPO_DIR)
if plan.derived_image:
docker_mod.build_image_with_cwd(
plan.derived_image, plan.image, plan.spec.user_cwd
)
internal_network = network_mod.network_create_internal(plan.slug)
stack.callback(network_mod.network_remove, internal_network)
egress_network = network_mod.network_create_egress(plan.slug)
stack.callback(network_mod.network_remove, egress_network)
proxy_plan = dataclasses.replace(
plan.proxy_plan,
internal_network=internal_network,
egress_network=egress_network,
)
pipelock_name = proxy.start(proxy_plan)
stack.callback(proxy.stop, pipelock_name)
# mitmproxy sits in front of pipelock on the agent's egress
# path. mitmproxy's `addon.py` reaches pipelock via the
# service-name URL we hand it here.
mitm_plan = dataclasses.replace(
plan.mitmproxy_plan,
internal_network=internal_network,
egress_network=egress_network,
)
mitm_name = mitm.start(mitm_plan, pipelock_url=pipelock_proxy_url(plan.slug))
stack.callback(mitm.stop, mitm_name)
container = _run_agent_container(plan, internal_network)
stack.callback(docker_mod.force_remove_container, container)
prompt_path = provision(plan, container)
yield DockerBottle(container, teardown, prompt_path)
finally:
teardown()
def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
"""Build the `docker run` argv and execute it, handling name-
conflict races by incrementing the suffix (unless the name was
user-pinned). Returns the resolved container name."""
# Agent traffic routes through mitmproxy, not pipelock directly.
# mitmproxy decrypts and hands the plaintext to pipelock via its
# addon; pipelock is unchanged from PRD 0001.
proxy_url = mitmproxy_proxy_url(plan.slug)
docker_args: list[str] = [
"--rm", "-d",
"--name", plan.container_name,
"--network", internal_network,
"-e", f"HTTPS_PROXY={proxy_url}",
"-e", f"HTTP_PROXY={proxy_url}",
"-e", "NO_PROXY=localhost,127.0.0.1",
# CA trust trio for the agent process. Docker propagates
# run-time env into `docker exec`, so `claude` sees these
# without per-exec threading. NODE_EXTRA_CA_CERTS points at
# the cert file (Node appends it to its bundled roots);
# SSL_CERT_FILE / REQUESTS_CA_BUNDLE point at the system
# bundle that `update-ca-certificates` rebuilds in
# provision_ca.
"-e", f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
"-e", f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
"-e", f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
]
if plan.use_runsc:
docker_args.extend(["--runtime", "runsc"])
if plan.env_file.stat().st_size > 0:
docker_args.extend(["--env-file", str(plan.env_file)])
for name in plan.forwarded_env:
docker_args.extend(["-e", name])
docker_args.extend([plan.runtime_image, "sleep", "infinity"])
info(f"starting container {plan.container_name} from {plan.runtime_image}")
# Inject forwarded values (secrets, interpolated host vars, the
# renamed OAuth token) into the docker-run child's env so the
# `-e NAME` flags above pick them up — without touching our own
# os.environ or putting values on argv.
child_env: dict[str, str] = {**os.environ, **plan.forwarded_env}
name_idx = docker_args.index("--name") + 1
for candidate in docker_mod.container_name_candidates(plan.container_name):
docker_args[name_idx] = candidate
run_result = subprocess.run(
["docker", "run", *docker_args],
capture_output=True,
text=True,
env=child_env,
check=False,
)
if run_result.returncode == 0:
return candidate
err_text = run_result.stderr
if plan.container_name_pinned or "is already in use" not in err_text:
sys.stderr.write(err_text + "\n")
die(f"docker run failed for container '{candidate}'")
info(f"name conflict on {candidate}; retrying with next candidate")
die(
f"could not find a free container name after "
f"{plan.container_name}-{docker_mod.MAX_CONTAINER_SUFFIX} retries; "
f"clean up old containers"
)