Renames the file and rewrites the body around what actually shipped: class-based BottleBackend ABC (not a free create_docker_bottle function), the two-phase prepare/launch split, the backend/docker/ subpackage layout, env.py reshaped into a backend-neutral ResolvedEnv, and PipelockProxy split between top-level and backend/docker/.
13 KiB
PRD 0003: Bottle Backend abstraction
- Status: Draft
- Author: didericis
- Created: 2026-05-10
Summary
Introduce a per-backend abstraction that owns the end-to-end lifecycle
of a "bottle" (a running, isolated environment with claude inside).
The first and only implementation lands as DockerBottleBackend. No
second backend ships in this PRD.
Problem
Today, "how to launch a bottle" is spread across roughly six modules
(claude_bottle/cli/start.py, pipelock.py, network.py, ssh.py,
skills.py, docker.py), each shelling out to docker directly via
subprocess.run(["docker", ...]). That coupling means:
- Adding a second backend (Apple's
container, fly.io, a remote SSH host, etc.) requires editing every one of those call sites. The research notedocs/research/apple-container-backend.mdalready flags this as a prerequisite for that work. - The pipelock sidecar topology — two networks, multi-attach, sidecar lifecycle — is a Docker implementation detail that has leaked into the top-level CLI orchestration. It reads as a core concept of the project, but a fly.io bottle would not need any of it.
- The manifest carries a Docker-specific
runtime: "runsc"field (bottles[].runtime). Anyone setting it has to know about gVisor, whether Docker has it registered, and what to do on macOS where it isn't available natively. The field has one valid non-default value and exists only because the current code can't decide on its own.
The shape that fits the project's actual goals (isolated agent runs
across multiple backends) is "one backend per platform," not "one
container-runtime SDK with N drivers." A previous draft of this PRD
considered a low-level runtime-primitive protocol (run, exec,
cp, network_connect, ...) and rejected it as the wrong layer —
it would have forced fly.io to pretend it's Docker.
Goals / Success Criteria
The feature works when all of the following are observable:
cli.py startworks identically for an existing manifest with no user-visible changes other than (a) a startup log line naming the Docker runtime in use, and (b)bottles[].runtimeno longer being a valid manifest field.- On a Linux host with gVisor registered, the agent container runs
under
runscwithout anything in the manifest requesting it. - On a host without gVisor (including macOS), the agent container runs
under the default
runcruntime; nothing fails, no warning is printed beyond the runtime-name log line. - The existing test suite passes with no behavior changes other than
the manifest-schema removal of
runtime.
The feature is done when all of the following ship:
- A new
claude_bottle/backend/package exists with abstract base classes (BottleBackend,BottlePlan,BottleCleanupPlan,Bottle) plus aclaude_bottle/backend/docker/subpackage containing theDockerBottleBackendimplementation. DockerBottleBackend.launch(plan)returns a context manager yielding aBottlehandle exposingexec_claude(argv, *, tty=True),cp_in(host, ctr), and teardown on context exit.- Every existing
subprocess.run(["docker", ...])call incli/start.py,pipelock.py,network.py,ssh.py, andskills.pyeither moves intoclaude_bottle/backend/docker/or is called from it. No top-level CLI code referencesdockerdirectly. bottles[].runtimeis removed from the manifest schema, the dataclass inmanifest.py, the example manifest, and any README / docs references.require_runsc()in the old top-levelclaude_bottle/docker.pyis deleted.- A single env var,
CLAUDE_BOTTLE_BACKEND(default"docker"), selects the backend. Unknown values die at startup with a list of known backends. - The y/N preflight in
cli.pyincludes the resolved Docker runtime alongside the allowlist summary.
Non-goals
- No second backend implementation. There is no
AppleContainerBottleBackend/FlyioBottleBackendin this PRD. The registry inbackend/__init__.pyships with one entry. - No retries, async, or streaming exec. The current code is
synchronous
subprocess.run; theBottlehandle matches. - No behavior change beyond the runsc auto-detect. Pipelock topology, network naming, container naming, image build flow, and SSH provisioning all stay byte-identical.
- No
--require-runscCLI escape hatch. If a user later wants "fail rather than silently downgrade," that's a follow-up. - No
bottles[].backendmanifest field. Backend is a property of the host environment, not the bottle definition (at least for now).
Scope
In scope
- New
claude_bottle/backend/package containing the abstract types and the registry, plus aclaude_bottle/backend/docker/subpackage containing the Docker implementation. - The
Bottle,BottleBackend,BottlePlan, andBottleCleanupPlanabstract base classes;BottleSpecdata carrier; andDockerBottleBackendimplementation. - Moving Docker-specific subprocess calls into the Docker subpackage.
- Removing
bottles[].runtimefrom the manifest schema and the dataclass. - Auto-detection of
runscregistration viadocker info. - Preflight integration: the existing y/N output names the resolved Docker runtime.
- Reshaping
env.py(formerlyenv_resolve.py) to return a backend-neutralResolvedEnv(forwardednames +literalsmap) rather than writing docker-shaped files directly. The Docker backend now owns the--env-file/-e NAMEserialization and the newline-rejection check. - Splitting
pipelock.pyinto a backend-neutralPipelockProxyABC (yaml + allowlist resolution) and aDockerPipelockProxysubclass (sidecar start/stop) under the Docker subpackage. - Test updates: any manifest fixtures referencing
runtimeare updated; tests that assert on--runtime=runscinstead seed the detection by mockingdocker info.
Out of scope
- Apple
containerand fly.io backends (separate PRDs, deferred until the Docker backend is the only thing shipping). - Generalizing the pipelock sidecar to other backends. Pipelock topology is, after this PRD, an implementation detail private to the Docker backend.
- Rewriting
pipelock.py's YAML generation. The allowlist→YAML translation stays where it is and is called by the Docker backend. - CLI flags for runtime selection / override.
Proposed Design
New services / components
A new package, claude_bottle/backend/, with an abstract base layer
and a Docker subpackage:
-
claude_bottle/backend/__init__.py— Defines the abstract base classes and the backend registry.BottleSpeccarries the CLI-supplied intent; the abstractBottlePlanandBottleCleanupPlanare the prepared-but-not-launched outputs of the twoprepare*phases;Bottleis the running-instance handle;BottleBackendis the dispatcher with five methods:class BottleBackend(ABC): name: str def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> BottlePlan: ... def launch(self, plan: BottlePlan) -> ContextManager[Bottle]: ... def prepare_cleanup(self) -> BottleCleanupPlan: ... def cleanup(self, plan: BottleCleanupPlan) -> None: ... def list_active(self) -> None: ...The
prepare/launchsplit lets the CLI render the y/N preflight off theBottlePlanbefore any container or network is created. The same split applies tocleanup.BottleBackend.provision(plan, target)orchestrates copying skills / SSH / prompt /.gitinto a running instance via four abstract sub-methods (provision_prompt,provision_skills,provision_ssh,provision_git); subclasses implement those four rather than overridingprovisionitself.Selection reads
CLAUDE_BOTTLE_BACKEND(default"docker"). Unknown values calldie()with the list of known backends:def get_bottle_backend() -> BottleBackend: ... -
claude_bottle/backend/docker/— Subpackage with the Docker implementation, split into:backend.py—DockerBottleBackend, owning all five abstract methods (prepare,launch,prepare_cleanup,cleanup,list_active) plus the fourprovision_*sub-methods. Probes forrunscavailability (docker info --format '{{json .Runtimes}}'), builds the base image and per-cwd derived image, creates the per-agent internal and egress networks, brings up the pipelock sidecar, runs the agent container with--runtime=runsciff available, copies skills / SSH keys / prompt /.gitinto the running container, and tears everything down on context exit.bottle.py—DockerBottle, the running-instance handle yielded bylaunch.bottle_plan.py—DockerBottlePlan, the prepared-but-not-launched output ofprepare. Carries resolved container/network/image names, scratch paths, anduse_runsc. Implementsprintfor the y/N preflight.bottle_cleanup_plan.py—DockerBottleCleanupPlan, the analog for orphan cleanup.network.py— Docker network helpers (create/destroy, naming).pipelock.py—DockerPipelockProxy(the sidecar start/stop lifecycle) and Docker-specific naming helpers. The backend-neutral yaml + allowlist resolution stays in the top-levelclaude_bottle/pipelock.py.util.py— Docker-specific helpers (slugify, image/container existence checks,runsc_available).
Existing code touched
claude_bottle/cli/start.py— replace the inline docker orchestration withbackend = get_bottle_backend(); plan = backend.prepare(spec, stage_dir=...); with backend.launch(plan) as bottle: bottle.exec_claude(...). The y/N preflight is rendered byplan.print(...).claude_bottle/manifest.py— drop theruntimefield from the Bottle dataclass and its validation. Existing manifests withruntime: "runsc"produce a clear "no longer supported; gVisor is now auto-detected by the backend; remove the 'runtime' field" error.claude_bottle/docker.py— module deleted.require_runsc(),slugify(),image_exists(),container_exists(), thebuild_image/build_image_with_cwdhelpers, andrequire_dockerall migrate intoclaude_bottle/backend/docker/util.py(orbackend.py).claude_bottle/pipelock.py— keeps the allowlist resolution and YAML generation. Becomes a thin abstract class (PipelockProxy) exposingprepare(writes the yaml) plus abstractstart/stopmethods. The Docker-specific subclassDockerPipelockProxylives underbackend/docker/pipelock.py.claude_bottle/network.py— folds entirely intobackend/docker/network.py. No top-level network module remains.claude_bottle/ssh.pyandclaude_bottle/skills.py— absorbed intoDockerBottleBackendasprovision_sshandprovision_skills. The host-side file-tree generation stays as private helpers on the backend class.claude_bottle/env.py(renamed fromenv_resolve.py) —resolve_env(manifest, agent) -> ResolvedEnvreturnsforwarded: list[str](names whose values were exported intoos.environfor inheritance) andliterals: dict[str, str](name → verbatim value). The Docker backend translates the result into--env-filecontent +-e NAMEargv fragments.claude_bottle/util.py— top-level cross-backend helpers (expand_tilde,is_ipv4_literal). Backend-specific helpers live in their backend'sutil.py.claude-bottle.example.json— remove theruntimefield from any example bottle.README.md— noteCLAUDE_BOTTLE_BACKENDand the runsc auto-detect; remove any mention ofruntime: "runsc"as a manifest field.
Data model changes
The bottle schema loses one field:
{
"bottles": {
"default": {
- "runtime": "runsc",
"env": { "...": "..." },
"ssh": [],
"egress": { "allowlist": [...] }
}
}
}
Any manifest carrying runtime produces a validation error on load
("bottle '<name>' has a 'runtime' field, which is no longer supported. gVisor (runsc) is now auto-detected by the backend; remove the 'runtime' field from the bottle definition.").
The agent schema is unchanged.
External dependencies
None new. This PRD reorganizes existing code; it does not pull in any new images, binaries, or libraries.
Behavior the runsc auto-detect introduces
DockerBottleBackend.prepare runs docker info --format '{{json .Runtimes}}' exactly once per call. If runsc is in the
output, use_runsc is set on the DockerBottlePlan and the
subsequent docker run adds --runtime=runsc. Otherwise it runs
without that flag. The choice is logged via the existing info()
helper as part of the preflight:
docker runtime: runsc (gVisor) # or: runc (default)
The y/N preflight (rendered by DockerBottlePlan.print) shows the
same line, so users can confirm what they're about to run under
before approving.
References
docs/research/apple-container-backend.md— original motivation; prior draft considered a low-levelBackendprotocol and rejected it as the wrong layer.docs/research/bash-vs-python-vs-go.md§Recommendation — argues that the backend abstraction matters independent of language choice.- PRD 0001 (
docs/prds/0001-per-agent-egress-proxy-via-pipelock.md) — defines the pipelock topology that becomes a private implementation detail of the Docker backend after this PRD ships.