feat(bottles): implement bottle factory abstraction per PRD 0003
test / run tests/run_tests.py (pull_request) Successful in 16s

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.
This commit is contained in:
2026-05-10 22:15:05 -04:00
parent d5c056f36e
commit d75cc9325f
8 changed files with 468 additions and 240 deletions
+11 -23
View File
@@ -7,8 +7,7 @@ Schema (see CLAUDE.md "Intended design"):
"<bottle-name>": {
"env": { "<NAME>": <env-entry>, ... },
"ssh": [ <ssh-entry>, ... ],
"egress": { "allowlist": [ "<hostname>", ... ] },
"runtime": "runc" | "runsc"
"egress": { "allowlist": [ "<hostname>", ... ] }
}
},
"agents": {
@@ -33,15 +32,11 @@ import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal, Mapping, cast
from typing import Mapping, cast
from .log import die
Runtime = Literal["runc", "runsc"]
_SUPPORTED_RUNTIMES: tuple[Runtime, ...] = ("runc", "runsc")
def _empty_str_dict() -> dict[str, str]:
return {}
@@ -116,12 +111,19 @@ class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
ssh: tuple[SshEntry, ...] = ()
egress: BottleEgress = field(default_factory=BottleEgress)
runtime: Runtime = "runc"
@classmethod
def from_dict(cls, name: str, raw: object) -> "Bottle":
d = _as_json_object(raw, f"bottle '{name}'")
if "runtime" in d:
die(
f"bottle '{name}' has a 'runtime' field, which is no longer "
f"supported. gVisor (runsc) is now auto-detected when "
f"registered with Docker; remove the 'runtime' field from "
f"the bottle definition."
)
env: dict[str, str] = {}
env_raw = d.get("env")
if env_raw is not None:
@@ -152,21 +154,7 @@ class Bottle:
else BottleEgress()
)
runtime_raw = d.get("runtime")
runtime: Runtime
if runtime_raw is None:
runtime = "runc"
else:
if not isinstance(runtime_raw, str):
die(f"bottle '{name}' runtime must be a string (was {type(runtime_raw).__name__})")
if runtime_raw not in _SUPPORTED_RUNTIMES:
die(
f"bottle '{name}' runtime '{runtime_raw}' is not supported. "
f"Use one of: {', '.join(_SUPPORTED_RUNTIMES)}."
)
runtime = runtime_raw
return cls(env=env, ssh=ssh, egress=egress, runtime=runtime)
return cls(env=env, ssh=ssh, egress=egress)
@dataclass(frozen=True)