PipelockProxy.prepare now accepts (bottle, slug, stage_dir) and derives
the yaml_path itself, so callers don't need to know the filename.
DockerBottleBackend.prepare_proxy becomes a one-line wrapper whose only
caller already has bottle and slug in scope, so it's inlined and
deleted.
Split pipelock config building from YAML rendering: pipelock_build_config
returns a dict, pipelock_render_yaml serializes it, and _build_pipelock_yaml
chains the two onto disk. Unchanged behavior — pipelock loads the same YAML.
The yaml test now asserts on the structured config dict, which is
robust to cosmetic YAML changes (key order, quoting). The two checks
that only make sense on the rendered output — file mode 0600 and
no-secret-leakage — stay against the on-disk content.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
resolve_env_into(...) becomes resolve_env(manifest, agent) -> ResolvedEnv
(forwarded names + literals). The docker backend now owns env-file /
argv serialization and the --env-file newline check. Also drops stray
Docker references from manifest.py, pipelock.py, util.py, and trims
the duplicated command list from cli.py's docstring (usage() in
claude_bottle/cli/__init__.py is now the only listing).
Matches the allowlist-resolution helpers' shape: the caller resolves
the bottle once and passes it in. Signature drops from
(manifest, bottle_name, slug, yaml_path) to (bottle, slug, yaml_path).
DockerBottleBackend.prepare_proxy uses manifest.bottle_for(agent_name)
to get the bottle directly. Tests pass fixture.bottles[name].
prepare's docstring also explains what `slug` is: the lowercased,
hyphen-normalized agent identifier used as the suffix in every
per-agent resource name (agent container, pipelock container, the
internal/egress networks). It's stored on the plan so start can
derive the sidecar's container name.
Top-level pipelock.py drops the Manifest import — no longer used.
Both constants were already only used by Docker-specific code (the
sidecar boot, the proxy_url/host_port naming helpers, the image
contract test). Move them next to DockerPipelockProxy.
Top-level pipelock.py drops the 'os' import along with the constants;
the two test files that pulled PIPELOCK_IMAGE retarget at the new
location.
The three slug-based naming helpers were nominally on pipelock.py but
each assumed a Docker container topology (the container name is
'claude-bottle-pipelock-<slug>', the proxy URL uses that container
name). Move them next to DockerPipelockProxy:
pipelock_container_name -> claude-bottle-pipelock-<slug>
pipelock_proxy_url -> http://<container>:<port>
pipelock_proxy_host_port -> <container>:<port>
backend.py imports them directly from .pipelock; the orphan-cleanup
test imports container_name from the same place.
PipelockProxy becomes an ABC with the platform-agnostic
prepare/_build_pipelock_yaml as concrete methods and start/stop as
abstract. Docker-specific sidecar lifecycle moves to a new sibling
file:
claude_bottle/backend/docker/pipelock.py
DockerPipelockProxy(PipelockProxy) — implements start (docker
create/cp/network connect/start) and stop (docker inspect/rm -f).
DockerBottleBackend._proxy is now a DockerPipelockProxy instance.
Tests that previously instantiated PipelockProxy() directly switch to
DockerPipelockProxy() (the base is no longer constructable).
Every function in the 'Allowlist resolution' section was doing
`manifest.bottles[bottle_name].X` as its first move. Push the lookup
to the caller and have each helper take a resolved Bottle:
pipelock_bottle_allowlist
pipelock_bottle_ssh_hostnames
pipelock_bottle_ssh_trusted_domains
pipelock_bottle_ssh_ip_cidrs
pipelock_effective_allowlist
pipelock_allowlist_summary
PipelockProxy._build_pipelock_yaml resolves bottle once at the top
and passes it through; DockerBottleBackend.prepare already had the
bottle in scope and now uses it directly. Tests pass the resolved
bottle from each fixture.
The classifier is a pure dotted-quad regex check — nothing
pipelock-specific about it. Pipelock now imports it from util.
test_pipelock_classify.py retargets at the new location.
Two manifest-accessor functions in pipelock.py
(pipelock_bottle_allowlist, pipelock_bottle_ssh_hostnames) look
generic but are 1-line wrappers used only internally; they stay
for now.
ProxyPlan -> PipelockProxyPlan, with two additional fields populated
in launch: internal_network, egress_network (default ""). prepare
fills yaml_path + slug; launch uses dataclasses.replace to populate
the networks before calling start.
pipelock_start -> PipelockProxy.start(plan). Reads yaml_path, slug,
internal_network, egress_network off the plan. Returns the resolved
container name.
pipelock_stop -> PipelockProxy.stop(proxy_target). Takes the resolved
container name directly (the value that start returned); no longer
needs to know about slugs or naming conventions.
Backend launch passes the running container name (state["pipelock"])
to stop. Test for stop's idempotency uses pipelock_container_name to
construct the proxy_target.
Add a frozen ProxyPlan dataclass to pipelock.py (currently one field:
yaml_path; kept as a class so future proxy-level state has a home).
- prepare_proxy(spec, stage_dir) now returns pipelock.ProxyPlan
instead of a raw Path.
- DockerBottlePlan replaces pipelock_yaml_path + pipelock_yaml_filename
with a single proxy: ProxyPlan field.
- launch reads plan.proxy.yaml_path.parent / .name when calling
pipelock_start. Eventually pipelock_start should just take a Path
but that's a separate change.
The YAML generation now lives on PipelockProxy.prepare(manifest,
bottle_name, yaml_path) in claude_bottle/pipelock.py. The class is the
natural home for any future proxy-level state.
DockerBottleBackend keeps an instance as a class attribute
(_proxy = PipelockProxy()) and its prepare_proxy becomes a thin
delegation. A future backend that wants a different egress proxy
(or none) plugs in its own strategy.
Tests retarget at the new home — PipelockProxy.prepare gets the
content-shape assertions; the sidecar smoke test uses the class
directly too. Same coverage.
The yaml generation logic moves wholesale onto DockerBottleBackend
where it's used. pipelock_write_yaml is deleted; pipelock.py keeps
the allowlist resolution helpers (still called by prepare_proxy and
by pipelock_allowlist_summary).
The pipelock_start error message that referenced "pipelock_write_yaml
must run first" now says "backend.prepare_proxy must run first."
tests/test_pipelock_yaml.py rewritten to drive DockerBottleBackend().
prepare_proxy(spec, yaml_path); test_pipelock_sidecar_smoke.py call
site updated similarly. Same coverage at the new location.
Replace the TypedDict + 14 manifest_* free functions with frozen
dataclasses (SshEntry, BottleEgress, Bottle, Agent, Manifest) carrying
their own validators and constructors. Call sites import Manifest and
chain attribute access; the manifest_* helpers and manifest_validate
are gone.
Behavior changes worth flagging:
- Agent.bottle is now required (was optional with a "(none)" fallback).
Manifest.from_json_obj dies if any agent lacks a 'bottle' field or
references an undefined bottle, where previously start.py raised the
error lazily for the specific agent being launched.
- ssh.py now takes SshEntry instances; Host/IdentityFile shape checks
moved upstream into Manifest construction, leaving only the IdentityFile
filesystem-existence check in ssh_validate_entries.
- pipelock_bottle_allowlist's per-element string check is dropped — the
Manifest validator enforces it at load.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces cli.sh + lib/*.sh with a claude_bottle/ Python package and a
cli.py entry point. No external dependencies — uses only Python's
stdlib (json, subprocess, getpass, tempfile, argparse, re, etc.).
- claude_bottle/{log,docker,manifest,env_resolve,network,pipelock,
skills,ssh,cli}.py mirror the previous lib/*.sh modules.
- Tests converted to unittest under tests/test_*.py with a stdlib
runner at tests/run_tests.py (unit | integration | path).
- .githooks/commit-msg ported to Python; same Conventional Commits rules.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>