From cd82a483995844703e25220883c47262edb23657 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 22:53:47 -0400 Subject: [PATCH] refactor(state): write prepare-time scratch files under state// MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0018 chunk 2. Each sidecar's prepare-time output (pipelock yaml + CAs, egress routes.yaml + CAs, git-gate entrypoint + hooks, supervise current-config, agent env + prompt) now lands in ~/.claude-bottle/state/// instead of an ephemeral mktemp dir. The state subdirs become the stable bind-mount sources that chunk 3's docker compose project will reference. The SDK launch path is unchanged — `docker cp` still copies from the plan-held paths into containers, just from new locations. start.py's session-end cleanup is now in `finally`, which also reaps state dirs left behind by dry-run / preflight-N / prepare-exception paths (previously only the post-launch path settled state). --- claude_bottle/backend/docker/bottle_state.py | 57 ++++++++++++++++++++ claude_bottle/backend/docker/launch.py | 9 +++- claude_bottle/backend/docker/prepare.py | 35 +++++++++--- claude_bottle/cli/start.py | 16 +++--- 4 files changed, 102 insertions(+), 15 deletions(-) diff --git a/claude_bottle/backend/docker/bottle_state.py b/claude_bottle/backend/docker/bottle_state.py index 854192b..136c1cf 100644 --- a/claude_bottle/backend/docker/bottle_state.py +++ b/claude_bottle/backend/docker/bottle_state.py @@ -44,6 +44,15 @@ from . import util as docker_mod _STATE_SUBDIR = "state" _PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile" _TRANSCRIPT_SUBDIR = "transcript" +# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources +# live here so chunk 3's `docker compose up` can find them at stable +# paths. Each sidecar's `prepare()` writes config + CAs into its own +# subdir; the launch step is unchanged today (still `docker cp`). +_PIPELOCK_SUBDIR = "pipelock" +_EGRESS_SUBDIR = "egress" +_GIT_GATE_SUBDIR = "git-gate" +_SUPERVISE_SUBDIR = "supervise" +_AGENT_SUBDIR = "agent" _METADATA_NAME = "metadata.json" # Live-config dir bind-mounted into the supervise sidecar (read-only). # Host's apply paths keep these files fresh so supervise's @@ -201,6 +210,49 @@ def transcript_snapshot_dir(identity: str) -> Path: return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR +# --- Per-sidecar scratch subdirs (PRD 0018 chunk 2) ------------------------ +# +# Each sidecar gets its own subdir under the bottle's state dir for +# bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes +# land here; the state dir's normal cleanup (`cleanup_state`) reaps +# them along with everything else when the bottle session ends and +# nothing requested preservation. + + +def pipelock_state_dir(identity: str) -> Path: + """State subdir for the pipelock sidecar: pipelock.yaml + the + per-bottle CA cert/key. Bind-mount source from chunk 3 onward.""" + return bottle_state_dir(identity) / _PIPELOCK_SUBDIR + + +def egress_state_dir(identity: str) -> Path: + """State subdir for the egress sidecar: routes.yaml + the + per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward.""" + return bottle_state_dir(identity) / _EGRESS_SUBDIR + + +def git_gate_state_dir(identity: str) -> Path: + """State subdir for the git-gate sidecar: entrypoint + hooks + + per-upstream known_hosts. Bind-mount source from chunk 3 + onward.""" + return bottle_state_dir(identity) / _GIT_GATE_SUBDIR + + +def supervise_state_dir(identity: str) -> Path: + """State subdir for the supervise sidecar's current-config dir + (bind-mounted into the agent at /etc/claude-bottle/current-config). + The queue dir is intentionally NOT under here — it lives at + ~/.claude-bottle/queue// alongside the audit logs, so it + survives state-dir cleanup.""" + return bottle_state_dir(identity) / _SUPERVISE_SUBDIR + + +def agent_state_dir(identity: str) -> Path: + """State subdir for the agent's prepare-time scratch files: the + env file (docker --env-file source) and the prompt file.""" + return bottle_state_dir(identity) / _AGENT_SUBDIR + + # --- Preserve-on-close marker ---------------------------------------------- @@ -245,18 +297,23 @@ def cleanup_state(identity: str) -> None: __all__ = [ "BottleMetadata", + "agent_state_dir", "bottle_identity", "bottle_state_dir", "cleanup_state", "clear_preserve_marker", + "egress_state_dir", + "git_gate_state_dir", "is_preserved", "mark_preserved", "metadata_path", "per_bottle_dockerfile", "per_bottle_dockerfile_path", "per_bottle_image_tag", + "pipelock_state_dir", "preserve_marker_path", "read_metadata", + "supervise_state_dir", "transcript_snapshot_dir", "write_metadata", "write_per_bottle_dockerfile", diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index 09db597..4745781 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -24,6 +24,7 @@ from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle from .bottle_plan import DockerBottlePlan +from .bottle_state import egress_state_dir, pipelock_state_dir from .egress import ( DockerEgress, egress_tls_init, @@ -105,9 +106,13 @@ def launch( # the .start steps docker-cp the files in. Private keys never # leave the host stage dir, which start.py's outer finally # `shutil.rmtree`s after the sidecars are torn down. - ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir) + # PRD 0018 chunk 2: CAs live under the bottle's state subdirs + # so chunk 3's compose bind-mounts have stable sources. The + # subdirs were created by prepare; tls_init makes the + # `pipelock-ca/` and `egress-ca/` children under them. + ca_cert_host, ca_key_host = pipelock_tls_init(pipelock_state_dir(plan.slug)) egress_ca_host, egress_ca_cert_only = egress_tls_init( - plan.stage_dir, + egress_state_dir(plan.slug), ) # Re-render the pipelock yaml with the SSRF allowlist now that diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index b0cd69f..555ac14 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -23,11 +23,16 @@ from .egress import DockerEgress, egress_container_name from .git_gate import DockerGitGate, git_gate_container_name from .bottle_state import ( BottleMetadata, + agent_state_dir, bottle_identity, clear_preserve_marker, + egress_state_dir, + git_gate_state_dir, per_bottle_dockerfile, per_bottle_dockerfile_path, per_bottle_image_tag, + pipelock_state_dir, + supervise_state_dir, write_metadata, ) from .pipelock import DockerPipelockProxy, pipelock_container_name @@ -141,14 +146,30 @@ def resolve_plan( f"retry." ) - env_file = stage_dir / "agent.env" - prompt_file = stage_dir / "prompt.txt" + # PRD 0018 chunk 2: prepare-time scratch files live under + # ~/.claude-bottle/state/// so chunk 3's compose + # bind-mounts can point at stable paths. The state subdirs are + # cleaned up by start.py's session-end teardown unless something + # explicitly preserves the state dir (capability-block, crash). + agent_dir = agent_state_dir(slug) + agent_dir.mkdir(parents=True, exist_ok=True) + env_file = agent_dir / "agent.env" + prompt_file = agent_dir / "prompt.txt" prompt_file.write_text("") prompt_file.chmod(0o600) - proxy_plan = proxy.prepare(bottle, slug, stage_dir) - git_gate_plan = git_gate.prepare(bottle, slug, stage_dir) - egress_plan = egress.prepare(bottle, slug, stage_dir) + pipelock_dir = pipelock_state_dir(slug) + pipelock_dir.mkdir(parents=True, exist_ok=True) + proxy_plan = proxy.prepare(bottle, slug, pipelock_dir) + + git_gate_dir = git_gate_state_dir(slug) + git_gate_dir.mkdir(parents=True, exist_ok=True) + git_gate_plan = git_gate.prepare(bottle, slug, git_gate_dir) + + egress_dir = egress_state_dir(slug) + egress_dir.mkdir(parents=True, exist_ok=True) + egress_plan = egress.prepare(bottle, slug, egress_dir) + supervise_plan = None if bottle.supervise: # Current Dockerfile for the agent image. Read from the repo @@ -161,8 +182,10 @@ def resolve_plan( # state rather than a launch-time snapshot.) dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile" dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else "" + supervise_dir = supervise_state_dir(slug) + supervise_dir.mkdir(parents=True, exist_ok=True) supervise_plan = supervise.prepare( - slug, stage_dir, + slug, supervise_dir, dockerfile_content=dockerfile_content, ) resolved = resolve_env(manifest, spec.agent_name) diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index 561975f..0a9901a 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -61,9 +61,11 @@ def _launch_bottle( prints / dry-runs / prompts as appropriate, brings the bottle up, attaches claude, and prints the resume hint on session end.""" stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage.")) + identity = "" try: backend = get_bottle_backend() plan = backend.prepare(spec, stage_dir=stage_dir) + identity = _identity_from_plan(plan) plan.print(remote_control=remote_control) @@ -78,7 +80,6 @@ def _launch_bottle( info("aborted by user") return 0 - identity = _identity_from_plan(plan) with backend.launch(plan) as bottle: info( "attaching interactive claude session " @@ -101,14 +102,15 @@ def _launch_bottle( # capability-block path's prior snapshot isn't clobbered # when the container is already gone. _capture_session_state(identity, exit_code) - # Context exited → containers + networks gone. Now decide - # what to do with the per-bottle state dir on the host: any - # preserve marker (capability-block OR crash) keeps it; a - # clean exit cleans it up so ~/.claude-bottle/state/ doesn't - # accumulate per-launch debris. - _settle_state(identity) return 0 finally: + # PRD 0018 chunk 2: prepare now writes the bottle's bind-mount + # sources under state//. If we never reached the + # launch context (dry-run, preflight-N, prepare exception), or + # we did but nothing requested preservation, reap them along + # with everything else. _settle_state subsumes the prior + # post-launch settlement and the new pre-launch cleanup. + _settle_state(identity) shutil.rmtree(stage_dir, ignore_errors=True)