feat(provision): install pipelock CA into the agent + add curl
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 15s

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>
This commit is contained in:
2026-05-12 14:50:20 -04:00
parent 3755e66abe
commit 86a9b499bc
5 changed files with 126 additions and 14 deletions
+23 -11
View File
@@ -204,24 +204,36 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Build/run the bottle and yield a handle; tear down on exit."""
def provision(self, plan: PlanT, target: str) -> str | None:
"""Copy host-side files (prompt, skills, SSH keys, .git) into
the running bottle. Called from `launch` after the container/
machine is up. `target` identifies the running instance in
backend-specific terms (Docker: resolved container name; fly:
machine id). Returns the in-container prompt path if a prompt
was provisioned, else None — the Bottle handle uses it to
decide whether to add --append-system-prompt-file to claude's
argv.
"""Copy host-side files (CA cert, prompt, skills, SSH keys,
.git) into the running bottle. Called from `launch` after the
container/machine is up. `target` identifies the running
instance in backend-specific terms (Docker: resolved
container name; fly: machine id). Returns the in-container
prompt path if a prompt was provisioned, else None — the
Bottle handle uses it to decide whether to add
--append-system-prompt-file to claude's argv.
Default orchestration: prompt → skills → ssh → git. Subclasses
typically don't override this; they implement the four
sub-methods below."""
Default orchestration: ca → prompt → skills → ssh → git.
CA install runs first so the agent's trust store is rebuilt
before anything inside the agent makes a TLS call. Subclasses
typically don't override this; they implement the sub-methods
below."""
self.provision_ca(plan, target)
prompt_path = self.provision_prompt(plan, target)
self.provision_skills(plan, target)
self.provision_ssh(plan, target)
self.provision_git(plan, target)
return prompt_path
def provision_ca(self, plan: PlanT, target: str) -> None:
"""Install pipelock's per-bottle CA into the agent's trust
store so the agent trusts the bumped CONNECT cert pipelock
presents. Default impl is a no-op so backends that don't
yet support TLS interception (every backend except Docker
today) aren't forced to implement it. The Docker backend
overrides to docker-cp the cert in and run
`update-ca-certificates`."""
@abstractmethod
def provision_prompt(self, plan: PlanT, target: str) -> str | None:
"""Copy the prompt file into the running bottle. Returns the