refactor(state): write prepare-time scratch files under state/<slug>/
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/<slug>/<service>/ 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).
This commit is contained in:
@@ -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/<slug>/ 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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<slug>/<service>/ 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)
|
||||
|
||||
@@ -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/<slug>/. 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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user