refactor: rename platform abstraction to backend
test / run tests/run_tests.py (pull_request) Successful in 21s

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.
This commit is contained in:
2026-05-10 23:59:38 -04:00
parent c79966731c
commit 70a22fa210
13 changed files with 91 additions and 87 deletions
@@ -1,7 +1,7 @@
"""Per-platform bottle factories.
"""Per-backend bottle factories.
A bottle is a running, isolated environment with claude inside. Each
platform exposes four methods:
backend exposes five methods:
prepare(spec, stage_dir=...) -> BottlePlan
Resolves names, validates host-side prerequisites, and writes
@@ -19,8 +19,11 @@ platform exposes four methods:
cleanup(plan) -> None
Actually removes everything described by the cleanup plan.
Selection is driven by CLAUDE_BOTTLE_PLATFORM (default "docker"). Per
PRD 0003 the manifest does not carry a platform field; the host
list_active() -> None
Print every currently-running bottle on this backend to stderr.
Selection is driven by CLAUDE_BOTTLE_BACKEND (default "docker"). Per
PRD 0003 the manifest does not carry a backend field; the host
environment picks.
"""
@@ -38,8 +41,8 @@ from ..manifest import Manifest
@dataclass(frozen=True)
class BottleSpec:
"""CLI-supplied intent. Platform-agnostic — each platform's prepare
step consumes it and produces its own platform-specific plan.
"""CLI-supplied intent. Backend-agnostic — each backend's prepare
step consumes it and produces its own backend-specific plan.
Resolved values (image names, container name, scratch paths, runsc
availability) live on the plan, not the spec."""
@@ -52,8 +55,8 @@ class BottleSpec:
@dataclass(frozen=True)
class BottlePlan(ABC):
"""Base output of a platform's prepare step. Concrete subclasses
(e.g. DockerBottlePlan) add platform-specific resolved fields and
"""Base output of a backend's prepare step. Concrete subclasses
(e.g. DockerBottlePlan) add backend-specific resolved fields and
implement `print`."""
spec: BottleSpec
@@ -66,8 +69,8 @@ class BottlePlan(ABC):
@dataclass(frozen=True)
class BottleCleanupPlan(ABC):
"""Base output of a platform's prepare_cleanup step. Concrete
subclasses (e.g. DockerBottleCleanupPlan) carry platform-specific
"""Base output of a backend's prepare_cleanup step. Concrete
subclasses (e.g. DockerBottleCleanupPlan) carry backend-specific
lists of resources to be removed and implement `print` + `empty`."""
@abstractmethod
@@ -82,7 +85,7 @@ class BottleCleanupPlan(ABC):
class Bottle(ABC):
"""Handle to a running bottle. Yielded by a platform's launch step.
"""Handle to a running bottle. Yielded by a backend's launch step.
`exec_claude` runs `claude` inside the bottle and blocks until the
session ends. `cp_in` copies a host path into the bottle. `close`
@@ -101,9 +104,9 @@ class Bottle(ABC):
def close(self) -> None: ...
class BottlePlatform(ABC):
"""Abstract base for selectable bottle platforms. Concrete subclasses
(e.g. DockerBottlePlatform) own their own prepare/launch impls.
class BottleBackend(ABC):
"""Abstract base for selectable bottle backends. Concrete subclasses
(e.g. DockerBottleBackend) own their own prepare/launch impls.
Symmetric with the BottlePlan DockerBottlePlan hierarchy."""
name: str
@@ -128,37 +131,37 @@ class BottlePlatform(ABC):
@abstractmethod
def list_active(self) -> None:
"""Print every currently-running bottle on this platform to
"""Print every currently-running bottle on this backend to
stderr (name + status)."""
# Import concrete platform classes AFTER the base types are defined, so
# each platform module can pull BottleSpec / BottlePlan / BottlePlatform
# Import concrete backend classes AFTER the base types are defined, so
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
# via `from . import ...` without hitting a partially-initialized module.
from .docker import DockerBottlePlatform # noqa: E402
from .docker import DockerBottleBackend # noqa: E402
_PLATFORMS: dict[str, BottlePlatform] = {
"docker": DockerBottlePlatform(),
_BACKENDS: dict[str, BottleBackend] = {
"docker": DockerBottleBackend(),
}
def get_bottle_platform() -> BottlePlatform:
"""Resolve the bottle platform for the active environment. Dies with
a pointer at the known platforms if CLAUDE_BOTTLE_PLATFORM names an
def get_bottle_backend() -> BottleBackend:
"""Resolve the bottle backend for the active environment. Dies with
a pointer at the known backends if CLAUDE_BOTTLE_BACKEND names an
unimplemented one."""
name = os.environ.get("CLAUDE_BOTTLE_PLATFORM", "docker")
if name not in _PLATFORMS:
known = ", ".join(sorted(_PLATFORMS))
die(f"unknown CLAUDE_BOTTLE_PLATFORM={name!r}; known platforms: {known}")
return _PLATFORMS[name]
name = os.environ.get("CLAUDE_BOTTLE_BACKEND", "docker")
if name not in _BACKENDS:
known = ", ".join(sorted(_BACKENDS))
die(f"unknown CLAUDE_BOTTLE_BACKEND={name!r}; known backends: {known}")
return _BACKENDS[name]
__all__ = [
"Bottle",
"BottleBackend",
"BottleCleanupPlan",
"BottlePlan",
"BottlePlatform",
"BottleSpec",
"get_bottle_platform",
"get_bottle_backend",
]
@@ -1,28 +1,29 @@
"""Docker bottle platform.
"""Docker bottle backend.
The bulk of the implementation lives in sibling modules:
- util: thin Docker subprocess wrappers
- network: Docker network plumbing
- bottle_plan: DockerBottlePlan
- bottle_cleanup_plan: DockerBottleCleanupPlan
- bottle: DockerBottle handle
- platform: DockerBottlePlatform
- backend: DockerBottleBackend
This file only re-exports the public names so
`from claude_bottle.platform.docker import DockerBottlePlatform` keeps
`from claude_bottle.backend.docker import DockerBottleBackend` keeps
working.
"""
from __future__ import annotations
from .backend import DockerBottleBackend
from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan
from .platform import DockerBottlePlatform
__all__ = [
"DockerBottle",
"DockerBottleBackend",
"DockerBottleCleanupPlan",
"DockerBottlePlan",
"DockerBottlePlatform",
]
@@ -1,4 +1,4 @@
"""DockerBottlePlatform — the Docker implementation of BottlePlatform.
"""DockerBottleBackend — the Docker implementation of BottleBackend.
Methods:
.prepare(spec, stage_dir=...) -> DockerBottlePlan
@@ -22,7 +22,7 @@ from ... import skills as skills_mod
from ... import ssh as ssh_mod
from ...env_resolve import env_resolve
from ...log import die, info
from .. import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec
from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec
from . import network as network_mod
from . import util as docker_mod
from .bottle import DockerBottle
@@ -34,8 +34,8 @@ from .bottle_plan import DockerBottlePlan
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
class DockerBottlePlatform(BottlePlatform):
"""Docker platform implementation. Selected by CLAUDE_BOTTLE_PLATFORM
class DockerBottleBackend(BottleBackend):
"""Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND
(default)."""
name = "docker"
@@ -131,7 +131,7 @@ class DockerBottlePlatform(BottlePlatform):
def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]:
"""Build, launch, and provision a Docker bottle. Teardown on exit."""
assert isinstance(plan, DockerBottlePlan), (
f"DockerBottlePlatform.launch expects DockerBottlePlan, "
f"DockerBottleBackend.launch expects DockerBottlePlan, "
f"got {type(plan).__name__}"
)
@@ -358,7 +358,7 @@ class DockerBottlePlatform(BottlePlatform):
Containers first; networks would refuse to delete while
containers are still attached."""
assert isinstance(plan, DockerBottleCleanupPlan), (
f"DockerBottlePlatform.cleanup expects DockerBottleCleanupPlan, "
f"DockerBottleBackend.cleanup expects DockerBottleCleanupPlan, "
f"got {type(plan).__name__}"
)
for name in plan.containers:
@@ -1,5 +1,5 @@
"""DockerBottle — concrete Bottle handle yielded by
DockerBottlePlatform.launch.
DockerBottleBackend.launch.
Holds the container name plus the in-container prompt path so
exec_claude can transparently add --append-system-prompt-file when a
@@ -1,7 +1,7 @@
"""DockerBottleCleanupPlan — concrete subclass of BottleCleanupPlan.
Holds the tuples of container and network names that
DockerBottlePlatform.cleanup will remove. The y/N preflight reads
DockerBottleBackend.cleanup will remove. The y/N preflight reads
these via `print`; the CLI short-circuits via `empty`.
"""
@@ -16,7 +16,7 @@ from .. import BottleCleanupPlan
@dataclass(frozen=True)
class DockerBottleCleanupPlan(BottleCleanupPlan):
"""Resources DockerBottlePlatform.cleanup will remove. Produced by
"""Resources DockerBottleBackend.cleanup will remove. Produced by
`prepare_cleanup` from a snapshot of `docker ps -a` + `docker
network ls`; sorted so the y/N output is stable."""
@@ -1,7 +1,7 @@
"""DockerBottlePlan — concrete subclass of BottlePlan.
Carries the Docker-specific resolved fields produced by
DockerBottlePlatform.prepare. The launch step consumes it without
DockerBottleBackend.prepare. The launch step consumes it without
further resolution; show_plan-style rendering is the `print` method.
"""
@@ -18,7 +18,7 @@ from .. import BottlePlan
@dataclass(frozen=True)
class DockerBottlePlan(BottlePlan):
"""Docker-specific resolved fields produced by
DockerBottlePlatform.prepare. Inherits `spec` and `stage_dir` from
DockerBottleBackend.prepare. Inherits `spec` and `stage_dir` from
BottlePlan."""
slug: str
@@ -1,4 +1,4 @@
"""Docker host-side primitives used by DockerBottlePlatform: probing
"""Docker host-side primitives used by DockerBottleBackend: probing
for docker on PATH, slugifying agent names, checking image/container
existence, and building images."""
+4 -4
View File
@@ -5,14 +5,14 @@ from __future__ import annotations
import sys
from ..platform import get_bottle_platform
from ..backend import get_bottle_backend
from ..log import info
from ._common import read_tty_line
def cmd_cleanup(_argv: list[str]) -> int:
platform = get_bottle_platform()
plan = platform.prepare_cleanup()
backend = get_bottle_backend()
plan = backend.prepare_cleanup()
if plan.empty:
info("no claude-bottle resources to clean up")
@@ -26,6 +26,6 @@ def cmd_cleanup(_argv: list[str]) -> int:
info("aborted")
return 0
platform.cleanup(plan)
backend.cleanup(plan)
info("done")
return 0
+2 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import argparse
from ..platform import get_bottle_platform
from ..backend import get_bottle_backend
from ..manifest import Manifest
from ._common import PROG, USER_CWD
@@ -20,5 +20,5 @@ def cmd_list(argv: list[str]) -> int:
print(name)
return 0
get_bottle_platform().list_active()
get_bottle_backend().list_active()
return 0
+4 -4
View File
@@ -11,7 +11,7 @@ import sys
import tempfile
from pathlib import Path
from ..platform import BottleSpec, get_bottle_platform
from ..backend import BottleSpec, get_bottle_backend
from ..log import info
from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line
@@ -38,8 +38,8 @@ def cmd_start(argv: list[str]) -> int:
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
try:
platform = get_bottle_platform()
plan = platform.prepare(spec, stage_dir=stage_dir)
backend = get_bottle_backend()
plan = backend.prepare(spec, stage_dir=stage_dir)
plan.print(remote_control=args.remote_control)
if dry_run:
@@ -53,7 +53,7 @@ def cmd_start(argv: list[str]) -> int:
info("aborted by user")
return 0
with platform.launch(plan) as bottle:
with backend.launch(plan) as bottle:
info(
"attaching interactive claude session "
"(Ctrl-D or 'exit' to leave; container will be removed)"
+29 -29
View File
@@ -6,10 +6,10 @@
## Summary
Introduce a per-platform factory function that owns the end-to-end
Introduce a per-backend factory function that owns the end-to-end
lifecycle of a "bottle" (a running, isolated environment with claude
inside). The first and only implementation lands as
`create_docker_bottle`. No second platform ships in this PRD.
`create_docker_bottle`. No second backend ships in this PRD.
## Problem
@@ -33,11 +33,11 @@ Today, "how to launch a bottle" is spread across roughly six modules
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 platforms) is "one factory per platform," not "one
across multiple backends) is "one factory per backend," not "one
container-runtime SDK with N drivers." A previous draft of this PRD
considered a low-level `Backend` protocol (`run`, `exec`, `cp`,
`network_connect`, ...) and rejected it as the wrong layer — it would
have forced fly.io to pretend it's Docker.
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
@@ -57,7 +57,7 @@ The feature works when all of the following are observable:
The feature is **done** when all of the following ship:
- A new `claude_bottle/bottles/` package exists with
- A new `claude_bottle/backend/` package exists with
`__init__.py` (factory selection) and `docker.py`
(`create_docker_bottle`).
- `create_docker_bottle` returns a context manager yielding a `Bottle`
@@ -65,23 +65,23 @@ The feature is **done** when all of the following ship:
and teardown on context exit.
- Every existing `subprocess.run(["docker", ...])` call in
`cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and
`skills.py` either moves into `bottles/docker.py` or is called from
`skills.py` either moves into `backend/docker.py` or is called from
it. No top-level CLI code references `docker` directly.
- `bottles[].runtime` is removed from the manifest schema, the
dataclass in `manifest.py`, the example manifest, and any README /
docs references. `require_runsc()` in `claude_bottle/docker.py` is
deleted.
- A single env var, `CLAUDE_BOTTLE_PLATFORM` (default `"docker"`),
- A single env var, `CLAUDE_BOTTLE_BACKEND` (default `"docker"`),
selects the factory. Unknown values die at startup with a list of
known platforms.
known backends.
- The y/N preflight in `cli.py` includes the resolved Docker runtime
alongside the allowlist summary.
## Non-goals
- No second platform implementation. `create_container_bottle` and
- No second backend implementation. `create_container_bottle` and
`create_flyio_bottle` are not in this PRD. The factory dict in
`bottles/__init__.py` ships with one entry.
`backend/__init__.py` ships with one entry.
- No retries, async, or streaming exec. The current code is
synchronous `subprocess.run`; the `Bottle` handle matches.
- No behavior change beyond the runsc auto-detect. Pipelock topology,
@@ -89,14 +89,14 @@ The feature is **done** when all of the following ship:
provisioning all stay byte-identical.
- No `--require-runsc` CLI escape hatch. If a user later wants "fail
rather than silently downgrade," that's a follow-up.
- No `bottles[].platform` manifest field. Platform is a property of
- No `bottles[].backend` manifest field. Backend is a property of
the host environment, not the bottle definition (at least for now).
## Scope
### In scope
- New `claude_bottle/bottles/` package containing `__init__.py` and
- New `claude_bottle/backend/` package containing `__init__.py` and
`docker.py`.
- The `Bottle` Protocol definition and `create_docker_bottle` factory.
- Moving Docker-specific subprocess calls into the factory.
@@ -113,9 +113,9 @@ The feature is **done** when all of the following ship:
- Apple `container` and fly.io factories (separate PRDs, deferred
until the Docker factory is the only thing shipping).
- Generalizing the pipelock sidecar to other platforms. Pipelock
- Generalizing the pipelock sidecar to other backends. Pipelock
topology is, after this PRD, an implementation detail private to
`bottles/docker.py`.
`backend/docker.py`.
- Rewriting `pipelock.py`'s YAML generation. The allowlist→YAML
translation stays where it is and is called by the Docker factory.
- Changes to `env_resolve.py`, `manifest.py` (beyond the `runtime`
@@ -126,13 +126,13 @@ The feature is **done** when all of the following ship:
### New services / components
A new package, `claude_bottle/bottles/`:
A new package, `claude_bottle/backend/`:
- **`claude_bottle/bottles/__init__.py`** — Defines the `Bottle`
- **`claude_bottle/backend/__init__.py`** — Defines the `Bottle`
Protocol and `get_bottle_factory()`. The factory registry is a
module-level dict mapping platform name → factory function.
Selection reads `CLAUDE_BOTTLE_PLATFORM` (default `"docker"`).
Unknown values call `die()` with the list of known platforms.
module-level dict mapping backend name → factory function.
Selection reads `CLAUDE_BOTTLE_BACKEND` (default `"docker"`).
Unknown values call `die()` with the list of known backends.
```python
class Bottle(Protocol):
@@ -145,7 +145,7 @@ A new package, `claude_bottle/bottles/`:
...
```
- **`claude_bottle/bottles/docker.py`** — `create_docker_bottle(...)`,
- **`claude_bottle/backend/docker.py`** — `create_docker_bottle(...)`,
the only factory implementation in this PRD. Owns:
- probing for `runsc` availability (`docker info --format
'{{json .Runtimes}}'`),
@@ -178,19 +178,19 @@ A new package, `claude_bottle/bottles/`:
consumes.
- **`claude_bottle/pipelock.py`** — keep all the allowlist resolution
and YAML generation. Remove `pipelock_start` / `pipelock_stop` (or
inline them into `bottles/docker.py` — decide during
inline them into `backend/docker.py` — decide during
implementation). Pipelock-the-sidecar becomes a Docker-factory
internal concept.
- **`claude_bottle/network.py`** — same call-sites moved into
`bottles/docker.py`. The module either becomes a thin set of pure
`backend/docker.py`. The module either becomes a thin set of pure
name-derivation helpers (`network_name_for_slug`, etc.) or folds
entirely into `bottles/docker.py`. Decide during implementation.
entirely into `backend/docker.py`. Decide during implementation.
- **`claude_bottle/ssh.py`** and **`claude_bottle/skills.py`** — the
`docker cp` and `docker exec` calls move into / are called from
`bottles/docker.py`. The host-side file-tree generation stays put.
`backend/docker.py`. The host-side file-tree generation stays put.
- **`claude-bottle.example.json`** — remove the `runtime` field from
any example bottle.
- **`README.md`** — note `CLAUDE_BOTTLE_PLATFORM` and the runsc
- **`README.md`** — note `CLAUDE_BOTTLE_BACKEND` and the runsc
auto-detect; remove any mention of `runtime: "runsc"` as a manifest
field.
@@ -241,9 +241,9 @@ they're about to run under before approving.
- **Where the pipelock sidecar lifecycle lives.** Two reasonable
splits: (a) `pipelock.py` keeps `pipelock_start` / `pipelock_stop`
and `bottles/docker.py` calls them; (b) the sidecar
and `backend/docker.py` calls them; (b) the sidecar
`docker create/cp/network connect/start` sequence moves entirely
into `bottles/docker.py` and `pipelock.py` shrinks to the YAML +
into `backend/docker.py` and `pipelock.py` shrinks to the YAML +
allowlist helpers. (a) keeps git blame intact and is the smaller
diff; (b) makes pipelock-as-an-implementation-detail more obvious.
Decide during implementation.
+1 -1
View File
@@ -7,7 +7,7 @@ import os
import subprocess
import unittest
from claude_bottle.platform.docker.network import (
from claude_bottle.backend.docker.network import (
network_create_egress,
network_create_internal,
network_remove,