From 02811e041747ef4c27050f351c7de3342f643521 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 05:23:31 -0400 Subject: [PATCH] feat(bottle): per-bottle Dockerfile state + image build hook (PRD 0016) Phase 1 of PRD 0016. Lays the per-bottle state plumbing that capability-block remediation will write into: - claude_bottle/backend/docker/bottle_state.py: bottle_state_dir, per_bottle_dockerfile (read), write_per_bottle_dockerfile, per_bottle_image_tag (unique per slug), transcript_snapshot_dir. Stores under ~/.claude-bottle/state//. - prepare.py: when a per-bottle Dockerfile exists, use per_bottle_image_tag(slug) as the base image and pass the per-bottle Dockerfile path through DockerBottlePlan.dockerfile_path. --cwd still layers a derived image on top. - launch.py: passes plan.dockerfile_path to build_image so the per-bottle Dockerfile is what docker build reads. - DockerBottlePlan gains dockerfile_path field; print() surfaces it in the preflight summary so the operator can see at-a-glance that this bottle is running on a rebuilt image. Phase 2 will write to write_per_bottle_dockerfile (capability-block approval); Phase 3 wires it into the dashboard. Co-Authored-By: Claude Opus 4.7 --- claude_bottle/backend/docker/bottle_plan.py | 10 +++ claude_bottle/backend/docker/bottle_state.py | 80 ++++++++++++++++++++ claude_bottle/backend/docker/launch.py | 5 +- claude_bottle/backend/docker/prepare.py | 18 ++++- tests/unit/test_bottle_state.py | 72 ++++++++++++++++++ 5 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 claude_bottle/backend/docker/bottle_state.py create mode 100644 tests/unit/test_bottle_state.py diff --git a/claude_bottle/backend/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py index ce0d26f..9349753 100644 --- a/claude_bottle/backend/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -44,6 +44,11 @@ class DockerBottlePlan(BottlePlan): image: str derived_image: str # "" -> no derived image runtime_image: str # image actually launched (derived or base) + # Absolute path to the Dockerfile that builds `image`. Empty means + # use the repo's default Dockerfile. Populated to a per-bottle + # state file (~/.claude-bottle/state//Dockerfile) after a + # capability-block remediation (PRD 0016). + dockerfile_path: str env_file: Path # docker --env-file: NAME=VALUE literals # name -> value for vars forwarded into the docker-run child process # via subprocess env (so values never land on argv or in a file). @@ -89,6 +94,11 @@ class DockerBottlePlan(BottlePlan): print(file=sys.stderr) info(f"agent : {spec.agent_name}") info(f"image : {self.image}") + if self.dockerfile_path: + info( + f"dockerfile : {self.dockerfile_path} " + f"(per-bottle override from PRD 0016 capability rebuild)" + ) if self.derived_image: info( f"cwd : {spec.user_cwd} -> /home/node/workspace " diff --git a/claude_bottle/backend/docker/bottle_state.py b/claude_bottle/backend/docker/bottle_state.py new file mode 100644 index 0000000..55f635c --- /dev/null +++ b/claude_bottle/backend/docker/bottle_state.py @@ -0,0 +1,80 @@ +"""Per-bottle persistent state (PRD 0016). + +Holds the per-bottle Dockerfile override that capability-block +remediation writes, plus the transcript snapshot the +state-preservation helper saves before teardown. State lives at: + + ~/.claude-bottle/state// + Dockerfile — per-bottle override (absent → use repo's) + transcript/ — last snapshotted agent state (best-effort) + +When the per-bottle Dockerfile is present, the launch step builds +the agent image with a per-bottle tag (claude-bottle-rebuilt-) +from this file rather than the repo's. The build context is still +the repo root so the Dockerfile can COPY claude_bottle source files +the same way the original does. +""" + +from __future__ import annotations + +from pathlib import Path + +from ... import supervise as _supervise + + +# Directory layout: ~/.claude-bottle/state//... +_STATE_SUBDIR = "state" +_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile" +_TRANSCRIPT_SUBDIR = "transcript" + + +def bottle_state_dir(slug: str) -> Path: + """Per-bottle state directory on the host. Created lazily by the + write helpers; readers tolerate its absence.""" + return _supervise.claude_bottle_root() / _STATE_SUBDIR / slug + + +def per_bottle_dockerfile_path(slug: str) -> Path: + return bottle_state_dir(slug) / _PER_BOTTLE_DOCKERFILE_NAME + + +def per_bottle_dockerfile(slug: str) -> str | None: + """Return the per-bottle Dockerfile content if present, else + None. None means: use the repo's Dockerfile (the original + pre-capability-block behavior).""" + p = per_bottle_dockerfile_path(slug) + if p.is_file(): + return p.read_text() + return None + + +def write_per_bottle_dockerfile(slug: str, content: str) -> Path: + p = per_bottle_dockerfile_path(slug) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + p.chmod(0o644) + return p + + +def per_bottle_image_tag(slug: str) -> str: + """Image tag for a rebuilt bottle. Distinct from the base + claude-bottle:latest so per-bottle rebuilds don't collide in + the docker image cache.""" + return f"claude-bottle-rebuilt-{slug}:latest" + + +def transcript_snapshot_dir(slug: str) -> Path: + """Where capability_apply stashes the agent's transcript before + teardown, so the next `cli.py start ` can offer to + resume from it.""" + return bottle_state_dir(slug) / _TRANSCRIPT_SUBDIR + + +__all__ = [ + "bottle_state_dir", + "per_bottle_dockerfile", + "per_bottle_dockerfile_path", + "per_bottle_image_tag", + "transcript_snapshot_dir", + "write_per_bottle_dockerfile", +] diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index f8a6def..b2da057 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -66,7 +66,10 @@ def launch( pass try: - docker_mod.build_image(plan.image, _REPO_DIR) + docker_mod.build_image( + plan.image, _REPO_DIR, + dockerfile=plan.dockerfile_path, + ) if plan.derived_image: docker_mod.build_image_with_cwd( plan.derived_image, plan.image, plan.spec.user_cwd diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index 531eb43..e879960 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -26,6 +26,11 @@ from .cred_proxy import ( cred_proxy_url, ) from .git_gate import DockerGitGate, git_gate_container_name +from .bottle_state import ( + per_bottle_dockerfile, + per_bottle_dockerfile_path, + per_bottle_image_tag, +) from .pipelock import DockerPipelockProxy, pipelock_container_name from .supervise import DockerSupervise, supervise_container_name @@ -50,7 +55,17 @@ def resolve_plan( slug = docker_mod.slugify(spec.agent_name) - image = os.environ.get("CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest") + # PRD 0016 capability-block: if a per-bottle Dockerfile has been + # written (via apply_capability_change), the base image becomes + # per_bottle_image_tag(slug) built from that file. --cwd still + # layers a derived image on top. + dockerfile_path = "" + if per_bottle_dockerfile(slug) is not None: + image_default = per_bottle_image_tag(slug) + dockerfile_path = str(per_bottle_dockerfile_path(slug)) + else: + image_default = "claude-bottle:latest" + image = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default) derived_image = "" runtime_image = image if spec.copy_cwd: @@ -184,6 +199,7 @@ def resolve_plan( image=image, derived_image=derived_image, runtime_image=runtime_image, + dockerfile_path=dockerfile_path, env_file=env_file, forwarded_env=forwarded_env, prompt_file=prompt_file, diff --git a/tests/unit/test_bottle_state.py b/tests/unit/test_bottle_state.py new file mode 100644 index 0000000..59f6ee2 --- /dev/null +++ b/tests/unit/test_bottle_state.py @@ -0,0 +1,72 @@ +"""Unit: per-bottle state helpers (PRD 0016 Phase 1).""" + +import tempfile +import unittest +from pathlib import Path + +from claude_bottle import supervise +from claude_bottle.backend.docker import bottle_state + + +class _FakeHomeMixin: + def _setup_fake_home(self): + self._tmp = tempfile.TemporaryDirectory(prefix="bottle-state-test.") + original = supervise.claude_bottle_root + + def fake_root() -> Path: + return Path(self._tmp.name) / ".claude-bottle" + + supervise.claude_bottle_root = fake_root # type: ignore[assignment] + self._restore = lambda: setattr(supervise, "claude_bottle_root", original) + + def _teardown_fake_home(self): + self._restore() + self._tmp.cleanup() + + +class TestPerBottleDockerfile(_FakeHomeMixin, unittest.TestCase): + def setUp(self): + self._setup_fake_home() + + def tearDown(self): + self._teardown_fake_home() + + def test_returns_none_when_absent(self): + self.assertIsNone(bottle_state.per_bottle_dockerfile("dev")) + + def test_write_then_read_roundtrip(self): + bottle_state.write_per_bottle_dockerfile( + "dev", "FROM python:3.13\nRUN apk add ripgrep\n", + ) + self.assertEqual( + "FROM python:3.13\nRUN apk add ripgrep\n", + bottle_state.per_bottle_dockerfile("dev"), + ) + + def test_isolated_per_slug(self): + bottle_state.write_per_bottle_dockerfile("dev", "FROM dev\n") + bottle_state.write_per_bottle_dockerfile("api", "FROM api\n") + self.assertEqual("FROM dev\n", bottle_state.per_bottle_dockerfile("dev")) + self.assertEqual("FROM api\n", bottle_state.per_bottle_dockerfile("api")) + + def test_dockerfile_path_under_state_dir(self): + path = bottle_state.per_bottle_dockerfile_path("dev") + self.assertTrue(str(path).endswith("/.claude-bottle/state/dev/Dockerfile")) + + def test_image_tag_unique_per_slug(self): + self.assertEqual( + "claude-bottle-rebuilt-dev:latest", + bottle_state.per_bottle_image_tag("dev"), + ) + self.assertNotEqual( + bottle_state.per_bottle_image_tag("dev"), + bottle_state.per_bottle_image_tag("api"), + ) + + def test_transcript_dir_under_state_dir(self): + path = bottle_state.transcript_snapshot_dir("dev") + self.assertTrue(str(path).endswith("/.claude-bottle/state/dev/transcript")) + + +if __name__ == "__main__": + unittest.main()