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/<slug>/.
- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 05:23:31 -04:00
parent de87f21ff8
commit 02811e0417
5 changed files with 183 additions and 2 deletions
@@ -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/<slug>/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 "
@@ -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/<slug>/
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-<slug>)
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/<slug>/...
_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 <agent>` 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",
]
+4 -1
View File
@@ -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
+17 -1
View File
@@ -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,
+72
View File
@@ -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()