Files
bot-bottle/claude_bottle/backend/docker/provision/ca.py
T
didericis 86a9b499bc
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 15s
feat(provision): install pipelock CA into the agent + add curl
Second step of PRD 0006. With pipelock now doing the bumping, the
agent's TLS library has to trust pipelock's per-bottle CA — or
every CONNECT to api.anthropic.com is a self-signed-cert error.

- BottleBackend.provision gains a non-abstract `provision_ca`
  with a default no-op (so non-Docker backends aren't forced to
  implement TLS interception) and orchestrates
  ca → prompt → skills → ssh → git. CA install runs first so the
  agent's trust store is rebuilt before anything else in the
  agent makes a TLS call.

- New backend/docker/provision/ca.py: docker-cp's the CA cert
  into the agent at /usr/local/share/ca-certificates/...,
  `update-ca-certificates`, then emits a one-line stderr log
  with the SHA-256 fingerprint (stdlib `ssl` + `hashlib`; no
  subprocess for crypto). Module-level constants AGENT_CA_PATH
  and AGENT_CA_BUNDLE are imported by launch.py so the env
  trio set at docker run time matches the paths the provisioner
  writes.

- launch.py: rebinds `plan` after `dataclasses.replace`s on the
  pipelock proxy plan so provision_ca (which reads
  `plan.proxy_plan.ca_cert_host_path`) sees the populated CA
  paths. Three new -e flags on the agent's docker run for the
  NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE trio.

- Dockerfile: adds curl to the apt-get install line. curl
  natively respects HTTPS_PROXY and sends CONNECT directly —
  the agent doesn't need OS-level DNS for external hostnames
  (pipelock resolves them on its side of the bumped tunnel).
  This is the "simple HTTPS request" path the earlier turn
  needed and Node's stdlib https.request couldn't provide.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:50:20 -04:00

80 lines
3.1 KiB
Python

"""Install pipelock's per-bottle CA into the agent container's trust
store (PRD 0006).
By the time this provisioner runs, `pipelock_tls_init` has generated
a fresh CA into `plan.stage_dir/pipelock-ca/` and the pipelock sidecar
is up with `tls_interception: { enabled: true }` referencing the
in-container CA paths. This step makes the agent trust certs signed
by that CA so the agent's TLS handshake with the bumped CONNECT
succeeds.
Cert lands on Debian's standard source path
(`/usr/local/share/ca-certificates/`); `update-ca-certificates`
rebuilds `/etc/ssl/certs/ca-certificates.crt`, which is what curl,
Python `ssl`, and OpenSSL-based tools all read by default. The env
trio set on the agent's `docker run` covers Node
(`NODE_EXTRA_CA_CERTS`) and Python `requests` /
`SSL_CERT_FILE`-honoring libraries that don't load the system
bundle.
The fingerprint is computed via stdlib (`ssl.PEM_cert_to_DER_cert`
+ `hashlib.sha256`) and logged once to stderr. The private key
stays on the host (under `stage_dir`) until teardown wipes the
stage dir; nothing in the agent ever sees it."""
from __future__ import annotations
import hashlib
import ssl
import subprocess
from ....log import info
from ..bottle_plan import DockerBottlePlan
# Debian-family path for sources that `update-ca-certificates` reads.
# Bundle path is what the command rebuilds and what every standard
# TLS consumer in the image reads.
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-pipelock-ca.crt"
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
def provision_ca(plan: DockerBottlePlan, target: str) -> None:
"""Copy pipelock's CA cert into the agent, rebuild the trust
bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the agent container is up."""
container = target
cert_host_path = plan.proxy_plan.ca_cert_host_path
if not cert_host_path or not cert_host_path.is_file():
# Defensive: provision runs after launch wires CA paths
# onto the plan via dataclasses.replace; an empty path here
# would mean that wiring was skipped.
from ....log import die
die(
f"pipelock CA cert missing at {cert_host_path or '(empty)'}; "
f"launch must have called pipelock_tls_init and re-bound "
f"the plan before provision"
)
subprocess.run(
["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"],
stdout=subprocess.DEVNULL,
check=True,
)
subprocess.run(
["docker", "exec", "-u", "0", container, "chmod", "644", AGENT_CA_PATH],
stdout=subprocess.DEVNULL,
check=True,
)
subprocess.run(
["docker", "exec", "-u", "0", container, "update-ca-certificates"],
stdout=subprocess.DEVNULL,
check=True,
)
# Stdlib SHA-256 of the cert's DER bytes — the standard
# fingerprint form. Never the private key.
der = ssl.PEM_cert_to_DER_cert(cert_host_path.read_text())
fingerprint = hashlib.sha256(der).hexdigest()
info(f"pipelock ca fingerprint: sha256:{fingerprint[:32]}...")