Files
bot-bottle/claude_bottle/backend/docker/launch.py
T
didericis 307400f08a
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m36s
fix(supervise): bypass pipelock for agent → supervise MCP traffic
`/mcp` showed the supervise server as ✔ connected (initialize is
fast), but any actual tool call failed because the supervise
MCP design is long-poll — the sidecar holds the HTTP request open
until the operator approves in the dashboard (potentially minutes)
and only then returns the response.

Pipelock is a forward proxy with idle timeouts; it cut the long-
polled HTTPS-style request well before the operator could act, and
claude-code reported the tool as ✘ failed.

Fix: add `supervise` to the agent's NO_PROXY when bottle.supervise
is true. The supervise sidecar is on the bottle's internal network
with the `supervise` network-alias, so the agent can dial it
directly via docker DNS — no proxy, no idle timeout.

Body-scanning supervise traffic isn't critical because the operator
reviews every proposal in the TUI before approving. The earlier
pipelock allowlist auto-add for `supervise` stays as belt-and-
braces (handles any proxy-respecting client other than claude-code
that might dial supervise).

Existing bottles need a restart to pick up the new NO_PROXY value
(env can't be changed on a running container). The dashboard's
pipelock-edit workaround from PR #25 unblocks short-running tool
calls in the meantime but won't survive the pipelock idle timeout
on a long-polled call.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 07:36:27 -04:00

285 lines
12 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 ...pipelock import pipelock_build_config, pipelock_render_yaml
from ...supervise import CURRENT_CONFIG_DIR_IN_AGENT, SUPERVISE_HOSTNAME
from . import network as network_mod
from . import util as docker_mod
from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan
from .cred_proxy import DockerCredProxy
from .git_gate import DockerGitGate
from .pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
DockerPipelockProxy,
pipelock_proxy_url,
pipelock_tls_init,
)
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
from .supervise import DockerSupervise
# 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,
git_gate: DockerGitGate,
cred_proxy: DockerCredProxy,
supervise: DockerSupervise,
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,
dockerfile=plan.dockerfile_path,
)
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)
# Docker assigns a CIDR to the new internal network. Pipelock's
# SSRF guard otherwise rejects any destination resolving into
# RFC1918 space — which includes the cred-proxy / git-gate /
# pipelock sidecars themselves. Allowlist the bottle's own
# internal subnet so the agent can reach its sidecars via
# pipelock; api_allowlist + body-scanning still apply.
internal_cidr = network_mod.network_inspect_cidr(internal_network)
# Per-bottle ephemeral CA for pipelock's TLS interception
# (PRD 0006). One-shot pipelock container writes ca.pem +
# ca-key.pem under plan.stage_dir; .start docker-cp's them
# into the sidecar. The private key never leaves the host
# stage dir, which start.py's outer finally `shutil.rmtree`s
# after the sidecar is torn down.
ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir)
# Re-render the pipelock yaml with the SSRF allowlist now that
# we know the internal CIDR. Prepare wrote the yaml without
# the ssrf block (CIDR wasn't known yet); overwrite the same
# path so .start docker-cp's the updated content.
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
cfg = pipelock_build_config(
bottle,
ca_cert_path=PIPELOCK_CA_CERT_IN_CONTAINER,
ca_key_path=PIPELOCK_CA_KEY_IN_CONTAINER,
ssrf_ip_allowlist=(internal_cidr,),
)
plan.proxy_plan.yaml_path.write_text(pipelock_render_yaml(cfg))
plan.proxy_plan.yaml_path.chmod(0o600)
proxy_plan = dataclasses.replace(
plan.proxy_plan,
internal_network=internal_network,
internal_network_cidr=internal_cidr,
egress_network=egress_network,
ca_cert_host_path=ca_cert_host,
ca_key_host_path=ca_key_host,
)
# Re-bind the outer plan so provision_ca (which runs later
# from `provision(plan, container)`) can read the populated
# CA paths off plan.proxy_plan.
plan = dataclasses.replace(plan, proxy_plan=proxy_plan)
pipelock_name = proxy.start(plan.proxy_plan)
stack.callback(proxy.stop, pipelock_name)
# Git gate (PRD 0008). One sidecar per agent, only brought up
# when the bottle has git entries. Same internal + egress
# network attachment as the other sidecars; agent dials it as
# `git://<container-name>/<name>.git` via the pushInsteadOf
# rules provision_git writes into ~/.gitconfig.
if plan.git_gate_plan.upstreams:
git_gate_plan = dataclasses.replace(
plan.git_gate_plan,
internal_network=internal_network,
egress_network=egress_network,
)
plan = dataclasses.replace(plan, git_gate_plan=git_gate_plan)
git_gate_name = git_gate.start(plan.git_gate_plan)
stack.callback(git_gate.stop, git_gate_name)
# Cred-proxy (PRD 0010). One sidecar per bottle when
# bottle.cred_proxy.routes is non-empty. Must come up AFTER pipelock
# — cred-proxy routes its outbound HTTPS through pipelock
# (HTTPS_PROXY in environ + the per-bottle CA in its trust
# store) so the egress allowlist + body scanner sit in the
# cred-proxy path too. Must come up BEFORE the agent so DNS
# resolution for `cred-proxy` succeeds on the agent's first
# call; tokens flow from the host env into the sidecar's
# environ, not the agent's.
if plan.cred_proxy_plan.routes:
cred_proxy_plan = dataclasses.replace(
plan.cred_proxy_plan,
internal_network=internal_network,
egress_network=egress_network,
pipelock_ca_host_path=ca_cert_host,
pipelock_proxy_url=pipelock_proxy_url(plan.slug),
)
plan = dataclasses.replace(plan, cred_proxy_plan=cred_proxy_plan)
cred_proxy_name = cred_proxy.start(plan.cred_proxy_plan)
stack.callback(cred_proxy.stop, cred_proxy_name)
# Supervise sidecar (PRD 0013). Opt-in via bottle.supervise.
# Internal-network only — the sidecar makes no outbound calls.
# Must come up BEFORE the agent so DNS resolution for
# `supervise` succeeds on the agent's first tool call.
if plan.supervise_plan is not None:
supervise_plan = dataclasses.replace(
plan.supervise_plan,
internal_network=internal_network,
)
plan = dataclasses.replace(plan, supervise_plan=supervise_plan)
supervise_name = supervise.start(plan.supervise_plan)
stack.callback(supervise.stop, supervise_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 _agent_no_proxy(plan: DockerBottlePlan) -> str:
"""NO_PROXY value for the agent container. Standard loopback +
`supervise` when the supervise sidecar is enabled.
Supervise needs to bypass pipelock because the MCP tool-call
pattern is long-poll: claude-code opens an HTTPS-style request to
http://supervise:9100/, the sidecar holds it open until the
operator approves (potentially minutes), then returns the
response. Pipelock is a forward proxy with idle timeouts;
pipelock cuts the long-polled connection well before the operator
can act, and claude-code reports the tool as ✘ failed even
though /mcp shows ✔ connected.
The supervise sidecar is on the bottle's internal network with
the `supervise` network-alias, so the agent can dial it
directly via docker DNS. Body-scanning the supervise traffic
isn't critical — the operator reviews every proposal in the TUI."""
hosts = ["localhost", "127.0.0.1"]
if plan.supervise_plan is not None:
hosts.append(SUPERVISE_HOSTNAME)
return ",".join(hosts)
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."""
proxy_url = pipelock_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", f"NO_PROXY={_agent_no_proxy(plan)}",
# 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])
# PRD 0013: read-only current-config mount so the agent can read
# routes.json / allowlist / Dockerfile before composing a
# supervise tool-call proposal. Mounted from the per-bottle
# stage_dir/current-config/ populated at prepare time.
if plan.supervise_plan is not None:
docker_args.extend([
"-v",
f"{plan.supervise_plan.current_config_dir}:{CURRENT_CONFIG_DIR_IN_AGENT}:ro",
])
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"
)