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.
Drops 6 tests with no real coverage loss:
- tests/test_pipelock_naming.py — 4 tests asserting that f-string
format helpers return their f-string. Shape locks, not behavior
gates.
- tests/test_pipelock_image.py:test_entrypoint_contains_pipelock and
:test_cmd_contains_run — Docker image metadata inspection. The
remaining test_binary_runs already covers 'does the pinned image
actually work,' which is the only scenario these were really
guarding against.
31 tests -> 25.
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.
Across the package:
- claude_bottle/platform/ -> claude_bottle/backend/
- platform/docker/platform.py -> backend/docker/backend.py
- class BottlePlatform -> BottleBackend
- class DockerBottlePlatform -> DockerBottleBackend
- get_bottle_platform() -> get_bottle_backend()
- env var CLAUDE_BOTTLE_PLATFORM -> CLAUDE_BOTTLE_BACKEND
- dict _PLATFORMS -> _BACKENDS
"Backend" is shorter and more established as the term for a
pluggable strategy-pattern implementation. "Platform" was vague
(could mean OS, hardware, cloud) and mildly redundant — Docker is
itself a platform.
The previous PRD section claiming "the Backend protocol was
rejected" referred to a low-level run/exec/cp/network_connect
protocol; the name was never the reason. The PRD is updated to
describe that rejected design by shape rather than by name.
The bottle/agent concepts and the manifest schema are unchanged.
Introduce claude_bottle/bottles/ with a Bottle Protocol and a
get_bottle_factory() that dispatches on CLAUDE_BOTTLE_PLATFORM
(default "docker"). Move every Docker-specific subprocess.run call
from cli/start.py, plus the orchestration of build, networks, the
pipelock sidecar, container launch, and per-container provisioning
(prompt, skills, ssh, .git), into create_docker_bottle.
Drop bottles[].runtime from the manifest schema. Auto-detect whether
gVisor is registered with the daemon and pass --runtime=runsc when it
is; the preflight shows the resolved runtime so the choice is visible.
Manifests still carrying 'runtime' get a clear error pointing at the
auto-detect behavior, rather than silent ignore.
Out of scope: cli/cleanup.py and cli/list.py still call docker
directly. They enumerate active bottles across the host, which is a
separate concern from "create a bottle" and is left for a follow-up
that introduces a list_active/cleanup primitive on the factory.
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>
Move schema checks out of per-access getters into a single
manifest_validate pass invoked by manifest_resolve. Getters can now
assume bottles/agents are well-typed dicts and every agent has a
defined bottle, so the .get(...) or {} chains collapse. Behavior
change: a bad runtime / shape error anywhere in the manifest now
fails at load instead of on the N-th read.
Intermediate step toward replacing TypedDict with a dataclass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bottles can now set "runtime": "runsc" to launch the agent container
under gVisor instead of runc, adding a userspace syscall barrier
between the agent and the host kernel. Default is runc (Docker
default). Pipelock stays on the default runtime per the research doc's
minimum-diff prescription.
The launcher verifies runsc is registered with the daemon before
launch, surfaces the runtime in the preflight plan, and dies with an
install pointer (and a macOS-not-supported note) when runsc is
requested but unavailable.
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>