refactor(bottles): split factory into prepare + launch phases
test / run tests/run_tests.py (pull_request) Successful in 15s

The Docker factory had absorbed live container ops but left the
host-side prep (image-name resolution, container-name collision
retry, pipelock yaml generation, env_resolve writes, host
validation) in cli/start.py. That kept ~half the Docker-specific
logic outside the abstraction.

Split the factory into two phases:

  prepare_docker_bottle(spec, stage_dir=...) -> DockerBottlePlan
      Resolves names, validates skills/SSH, writes scratch files.
      No Docker resources created yet.

  launch_docker_bottle(plan) -> ContextManager[Bottle]
      Builds image, creates networks, boots pipelock, runs the
      agent container, provisions files. Teardown on exit.

DockerBottleSpec shrinks to intent-only inputs (manifest, agent
name, --cwd flag, user_cwd, forward_oauth_token). The CLI no longer
references docker_mod, pipelock, skills, ssh, or env_resolve.

get_bottle_factory becomes get_bottle_platform returning a
BottlePlatform with .prepare and .launch — one selectable thing per
platform.

The Bottle handle now remembers the in-container prompt path and
adds --append-system-prompt-file to claude's argv when present, so
the CLI no longer needs to know the path.

cmd_start: ~148 lines down from 229. Tests pass; dry-run output
byte-identical.
This commit is contained in:
2026-05-10 22:36:26 -04:00
parent a284d85296
commit 4f16b3a9e1
3 changed files with 244 additions and 228 deletions
+35 -17
View File
@@ -1,27 +1,35 @@
"""Per-platform bottle factories. """Per-platform bottle factories.
A bottle is a running, isolated environment with claude inside. Each A bottle is a running, isolated environment with claude inside. Each
platform exposes a factory (currently only Docker) that owns the platform exposes two functions:
end-to-end lifecycle: image build, container/sidecar launch, file
provisioning, and teardown.
Selection is driven by the CLAUDE_BOTTLE_PLATFORM env var (default prepare(spec, stage_dir=...) -> Plan
"docker"). Per PRD 0003 the manifest does not carry a platform field; Resolves names, validates host-side prerequisites, and writes
the host environment picks. scratch files. No remote/runtime resources are created yet.
Safe to call before the y/N preflight.
launch(plan) -> ContextManager[Bottle]
Brings up the container (or VM, or remote machine), provisions
it, yields a Bottle handle, and tears everything down on exit.
Selection is driven by CLAUDE_BOTTLE_PLATFORM (default "docker"). Per
PRD 0003 the manifest does not carry a platform field; the host
environment picks.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from dataclasses import dataclass
from typing import Callable, Protocol from typing import Callable, Protocol
from ..log import die from ..log import die
from .docker import create_docker_bottle from .docker import launch_docker_bottle, prepare_docker_bottle
class Bottle(Protocol): class Bottle(Protocol):
"""Handle to a running bottle. Yielded by a factory's context manager. """Handle to a running bottle. Yielded by a platform's launch step.
`exec_claude` runs `claude` inside the bottle and blocks until the `exec_claude` runs `claude` inside the bottle and blocks until the
session ends. `cp_in` copies a host path into the bottle. `close` session ends. `cp_in` copies a host path into the bottle. `close`
@@ -35,20 +43,30 @@ class Bottle(Protocol):
def close(self) -> None: ... def close(self) -> None: ...
BottleFactory = Callable[..., AbstractContextManager[Bottle]] @dataclass(frozen=True)
class BottlePlatform:
"""Bundles a platform's two-phase factory under one selectable name."""
name: str
prepare: Callable[..., object]
launch: Callable[..., AbstractContextManager[Bottle]]
_FACTORIES: dict[str, BottleFactory] = { _PLATFORMS: dict[str, BottlePlatform] = {
"docker": create_docker_bottle, "docker": BottlePlatform(
name="docker",
prepare=prepare_docker_bottle,
launch=launch_docker_bottle,
),
} }
def get_bottle_factory() -> BottleFactory: def get_bottle_platform() -> BottlePlatform:
"""Resolve the bottle factory for the active platform. Dies with a """Resolve the bottle platform for the active environment. Dies with
pointer at the known platforms if CLAUDE_BOTTLE_PLATFORM names an a pointer at the known platforms if CLAUDE_BOTTLE_PLATFORM names an
unimplemented one.""" unimplemented one."""
name = os.environ.get("CLAUDE_BOTTLE_PLATFORM", "docker") name = os.environ.get("CLAUDE_BOTTLE_PLATFORM", "docker")
if name not in _FACTORIES: if name not in _PLATFORMS:
known = ", ".join(sorted(_FACTORIES)) known = ", ".join(sorted(_PLATFORMS))
die(f"unknown CLAUDE_BOTTLE_PLATFORM={name!r}; known platforms: {known}") die(f"unknown CLAUDE_BOTTLE_PLATFORM={name!r}; known platforms: {known}")
return _FACTORIES[name] return _PLATFORMS[name]
+181 -91
View File
@@ -1,16 +1,18 @@
"""Docker bottle factory. """Docker bottle factory.
`create_docker_bottle` owns the end-to-end Docker lifecycle: Two phases:
1. Probe whether gVisor (`runsc`) is registered with the daemon. prepare_docker_bottle(spec, stage_dir=...) -> DockerBottlePlan
2. Build the base image (and a per-cwd derived image if --cwd). Resolve names, validate host-side prerequisites, and write
3. Create the per-agent internal + egress networks. scratch files (env_file, args_file, prompt, pipelock yaml) to
4. Boot the pipelock sidecar on both networks. stage_dir. No Docker resources are created yet. Suitable to call
5. Launch the agent container, with `--runtime=runsc` iff available. before the y/N preflight.
6. Copy the prompt, skills, SSH keys, and (optionally) .git into the
running container. launch_docker_bottle(plan) -> ContextManager[Bottle]
7. Yield a `Bottle` handle for `exec_claude` / `cp_in`. Build the image, create networks, boot the pipelock sidecar,
8. Tear everything down (container, sidecar, both networks) on exit. launch the agent container (with `--runtime=runsc` iff the
daemon has gVisor registered), and copy prompt/skills/ssh/.git
into the running container. Teardown on exit.
The Bottle Protocol lives in `claude_bottle.bottles.__init__`. The Bottle Protocol lives in `claude_bottle.bottles.__init__`.
""" """
@@ -30,6 +32,7 @@ from .. import network as network_mod
from .. import pipelock from .. import pipelock
from .. import skills as skills_mod from .. import skills as skills_mod
from .. import ssh as ssh_mod from .. import ssh as ssh_mod
from ..env_resolve import env_resolve
from ..log import die, info from ..log import die, info
from ..manifest import Manifest from ..manifest import Manifest
@@ -39,9 +42,7 @@ from ..manifest import Manifest
def runsc_available() -> bool: def runsc_available() -> bool:
"""Return True if the Docker daemon has the gVisor (`runsc`) runtime """Return True if the Docker daemon has the gVisor (`runsc`) runtime
registered. Called twice per `start`: once during the preflight to registered. Called once per prepare; the result lives on the plan."""
render the runtime label, once inside the factory to set `--runtime`.
`docker info` is cheap; the duplication is not worth caching."""
r = subprocess.run( r = subprocess.run(
["docker", "info", "--format", "{{json .Runtimes}}"], ["docker", "info", "--format", "{{json .Runtimes}}"],
capture_output=True, capture_output=True,
@@ -50,58 +51,66 @@ def runsc_available() -> bool:
return r.returncode == 0 and "runsc" in r.stdout return r.returncode == 0 and "runsc" in r.stdout
def docker_runtime_label() -> str: # --- Spec + Plan -----------------------------------------------------------
"""Human-readable label for the runtime that `create_docker_bottle`
would select right now. Shown in the y/N preflight."""
return "runsc (gVisor)" if runsc_available() else "runc (default)"
# --- Spec ------------------------------------------------------------------
@dataclass(frozen=True) @dataclass(frozen=True)
class DockerBottleSpec: class DockerBottleSpec:
"""Host-side inputs assembled by the CLI before factory entry. Every """CLI-supplied inputs to the Docker factory. Small and intent-only;
field is a value the factory consumes; nothing here is platform- everything else (image names, container name, scratch file paths,
agnostic enough yet to lift into a shared spec (only Docker exists).""" runsc availability) is resolved by prepare_docker_bottle."""
agent_name: str
slug: str
manifest: Manifest manifest: Manifest
agent_name: str
copy_cwd: bool
user_cwd: str
forward_oauth_token: bool
@dataclass(frozen=True)
class DockerBottlePlan:
"""Output of prepare_docker_bottle. Frozen; the launch step consumes
it without further resolution. show_plan reads from it directly."""
spec: DockerBottleSpec
slug: str
container_name: str container_name: str
container_name_pinned: bool container_name_pinned: bool
image: str image: str
derived_image: str # "" -> no derived image derived_image: str # "" -> no derived image
runtime_image: str # image to actually launch (derived or base) runtime_image: str # image actually launched (derived or base)
user_cwd: str
copy_cwd_git: bool
stage_dir: Path stage_dir: Path
prompt_file: Path
env_file: Path env_file: Path
args_file: Path args_file: Path
prompt_file: Path
pipelock_yaml_path: Path pipelock_yaml_path: Path
pipelock_yaml_filename: str pipelock_yaml_filename: str
forward_oauth_token: bool allowlist_summary: str
use_runsc: bool
# --- Bottle handle --------------------------------------------------------- # --- Bottle handle ---------------------------------------------------------
class _DockerBottle: class _DockerBottle:
"""Concrete Bottle for Docker. Holds the resolved container name and """Concrete Bottle for Docker. Holds the container name plus the
a teardown closure. Not exported — the factory yields it via the in-container prompt path so exec_claude can transparently add
Bottle Protocol.""" --append-system-prompt-file when a prompt was provisioned."""
def __init__(self, container: str, teardown): def __init__(self, container: str, teardown, prompt_path_in_container: str | None):
self.name = container self.name = container
self._teardown = teardown self._teardown = teardown
self._prompt_path = prompt_path_in_container
self._closed = False self._closed = False
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
full_argv = list(argv)
if self._prompt_path:
full_argv.extend(["--append-system-prompt-file", self._prompt_path])
cmd = ["docker", "exec"] cmd = ["docker", "exec"]
if tty: if tty:
cmd.append("-it") cmd.append("-it")
cmd.extend([self.name, "claude", *argv]) cmd.extend([self.name, "claude", *full_argv])
return subprocess.run(cmd).returncode return subprocess.run(cmd).returncode
def cp_in(self, host_path: str, container_path: str) -> None: def cp_in(self, host_path: str, container_path: str) -> None:
@@ -118,7 +127,98 @@ class _DockerBottle:
self._teardown() self._teardown()
# --- Factory --------------------------------------------------------------- # --- Prepare ---------------------------------------------------------------
def prepare_docker_bottle(spec: DockerBottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
"""Resolve names, validate, write scratch files. No Docker resources
are created; the only side effects are host-side files under
stage_dir and a probe of `docker info`."""
docker_mod.require_docker()
manifest = spec.manifest
manifest.require_agent(spec.agent_name)
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
bottle_name = agent.bottle
slug = docker_mod.slugify(spec.agent_name)
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest")
derived_image = ""
runtime_image = image
if spec.copy_cwd:
derived_image = os.environ.get(
"CLAUDE_BOTTLE_DERIVED_IMAGE", f"claude-bottle:cwd-{slug}"
)
runtime_image = derived_image
default_container = f"claude-bottle-{slug}"
pinned_container = os.environ.get("CLAUDE_BOTTLE_CONTAINER", "")
container_name = pinned_container or default_container
container_name_pinned = bool(pinned_container)
suffix = 2
if container_name_pinned:
if docker_mod.container_exists(container_name):
die(
f"container '{container_name}' already exists "
f"(pinned via CLAUDE_BOTTLE_CONTAINER). "
f"Remove it with 'docker rm -f {container_name}' or unset the override."
)
else:
while docker_mod.container_exists(container_name):
container_name = f"{default_container}-{suffix}"
suffix += 1
if suffix > 100:
die(
f"could not find a free container name after "
f"{default_container}-99; clean up old containers with "
f"'docker rm -f <name>'"
)
if agent.skills:
skills_mod.skills_validate_all(list(agent.skills))
if bottle.ssh:
ssh_mod.ssh_validate_entries(bottle.ssh)
env_file = stage_dir / "agent.env"
args_file = stage_dir / "docker-args"
prompt_file = stage_dir / "prompt.txt"
pipelock_yaml_filename = "pipelock.yaml"
pipelock_yaml = stage_dir / pipelock_yaml_filename
env_file.write_text("")
env_file.chmod(0o600)
args_file.write_text("")
prompt_file.write_text("")
prompt_file.chmod(0o600)
pipelock.pipelock_write_yaml(manifest, bottle_name, pipelock_yaml)
env_resolve(manifest, spec.agent_name, env_file, args_file)
prompt_file.write_text(agent.prompt)
allowlist_summary = pipelock.pipelock_allowlist_summary(manifest, bottle_name)
use_runsc = runsc_available()
return DockerBottlePlan(
spec=spec,
slug=slug,
container_name=container_name,
container_name_pinned=container_name_pinned,
image=image,
derived_image=derived_image,
runtime_image=runtime_image,
stage_dir=stage_dir,
env_file=env_file,
args_file=args_file,
prompt_file=prompt_file,
pipelock_yaml_path=pipelock_yaml,
pipelock_yaml_filename=pipelock_yaml_filename,
allowlist_summary=allowlist_summary,
use_runsc=use_runsc,
)
# --- Launch ----------------------------------------------------------------
# Where the repo root lives, for `docker build` context. Computed once. # Where the repo root lives, for `docker build` context. Computed once.
@@ -126,10 +226,8 @@ _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
@contextmanager @contextmanager
def create_docker_bottle(spec: DockerBottleSpec) -> Iterator[_DockerBottle]: def launch_docker_bottle(plan: DockerBottlePlan) -> Iterator[_DockerBottle]:
"""Build, launch, and provision a Docker bottle. Teardown on exit.""" """Build, launch, and provision a Docker bottle. Teardown on exit."""
# Teardown bookkeeping. Each entry is populated as the matching
# resource comes up; teardown walks them in reverse, idempotently.
state: dict[str, str] = { state: dict[str, str] = {
"container": "", "container": "",
"pipelock": "", "pipelock": "",
@@ -147,7 +245,7 @@ def create_docker_bottle(spec: DockerBottleSpec) -> Iterator[_DockerBottle]:
) )
state["container"] = "" state["container"] = ""
if state["pipelock"]: if state["pipelock"]:
pipelock.pipelock_stop(spec.slug) pipelock.pipelock_stop(plan.slug)
state["pipelock"] = "" state["pipelock"] = ""
if state["internal_network"]: if state["internal_network"]:
network_mod.network_remove(state["internal_network"]) network_mod.network_remove(state["internal_network"])
@@ -161,28 +259,28 @@ def create_docker_bottle(spec: DockerBottleSpec) -> Iterator[_DockerBottle]:
pass pass
try: try:
use_runsc = runsc_available() docker_mod.build_image(plan.image, _REPO_DIR)
if plan.derived_image:
docker_mod.build_image_with_cwd(
plan.derived_image, plan.image, plan.spec.user_cwd
)
docker_mod.build_image(spec.image, _REPO_DIR) state["internal_network"] = network_mod.network_create_internal(plan.slug)
if spec.derived_image: state["egress_network"] = network_mod.network_create_egress(plan.slug)
docker_mod.build_image_with_cwd(spec.derived_image, spec.image, spec.user_cwd)
state["internal_network"] = network_mod.network_create_internal(spec.slug)
state["egress_network"] = network_mod.network_create_egress(spec.slug)
state["pipelock"] = pipelock.pipelock_start( state["pipelock"] = pipelock.pipelock_start(
spec.slug, plan.slug,
state["internal_network"], state["internal_network"],
state["egress_network"], state["egress_network"],
spec.stage_dir, plan.stage_dir,
spec.pipelock_yaml_filename, plan.pipelock_yaml_filename,
) )
container = _run_agent_container(spec, state["internal_network"], use_runsc) container = _run_agent_container(plan, state["internal_network"])
state["container"] = container state["container"] = container
_provision_container(spec, container) prompt_path = _provision_container(plan, container)
bottle = _DockerBottle(container, teardown) bottle = _DockerBottle(container, teardown, prompt_path)
yield bottle yield bottle
finally: finally:
teardown() teardown()
@@ -191,30 +289,26 @@ def create_docker_bottle(spec: DockerBottleSpec) -> Iterator[_DockerBottle]:
# --- Internals ------------------------------------------------------------- # --- Internals -------------------------------------------------------------
def _run_agent_container( def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
spec: DockerBottleSpec,
internal_network: str,
use_runsc: bool,
) -> str:
"""Build the `docker run` argv and execute it, handling name-conflict """Build the `docker run` argv and execute it, handling name-conflict
races by incrementing the suffix (unless the name was user-pinned). races by incrementing the suffix (unless the name was user-pinned).
Returns the resolved container name.""" Returns the resolved container name."""
proxy_url = pipelock.pipelock_proxy_url(spec.slug) proxy_url = pipelock.pipelock_proxy_url(plan.slug)
docker_args: list[str] = [ docker_args: list[str] = [
"--rm", "-d", "--rm", "-d",
"--name", spec.container_name, "--name", plan.container_name,
"--network", internal_network, "--network", internal_network,
"-e", f"HTTPS_PROXY={proxy_url}", "-e", f"HTTPS_PROXY={proxy_url}",
"-e", f"HTTP_PROXY={proxy_url}", "-e", f"HTTP_PROXY={proxy_url}",
"-e", "NO_PROXY=localhost,127.0.0.1", "-e", "NO_PROXY=localhost,127.0.0.1",
] ]
if use_runsc: if plan.use_runsc:
docker_args.extend(["--runtime", "runsc"]) docker_args.extend(["--runtime", "runsc"])
if spec.env_file.stat().st_size > 0: if plan.env_file.stat().st_size > 0:
docker_args.extend(["--env-file", str(spec.env_file)]) docker_args.extend(["--env-file", str(plan.env_file)])
# ARGS_FILE pairs (-e, NAME) line-by-line. # ARGS_FILE pairs (-e, NAME) line-by-line.
args_lines = spec.args_file.read_text().splitlines() args_lines = plan.args_file.read_text().splitlines()
i = 0 i = 0
while i < len(args_lines): while i < len(args_lines):
flag = args_lines[i] flag = args_lines[i]
@@ -227,16 +321,16 @@ def _run_agent_container(
i += 1 i += 1
docker_args.extend([flag, vname]) docker_args.extend([flag, vname])
if spec.forward_oauth_token: if plan.spec.forward_oauth_token:
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = os.environ["CLAUDE_BOTTLE_OAUTH_TOKEN"] os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = os.environ["CLAUDE_BOTTLE_OAUTH_TOKEN"]
docker_args.extend(["-e", "CLAUDE_CODE_OAUTH_TOKEN"]) docker_args.extend(["-e", "CLAUDE_CODE_OAUTH_TOKEN"])
docker_args.extend([spec.runtime_image, "sleep", "infinity"]) docker_args.extend([plan.runtime_image, "sleep", "infinity"])
info(f"starting container {spec.container_name} from {spec.runtime_image}") info(f"starting container {plan.container_name} from {plan.runtime_image}")
container = spec.container_name container = plan.container_name
base_name = spec.container_name base_name = plan.container_name
suffix = 2 suffix = 2
while True: while True:
run_result = subprocess.run( run_result = subprocess.run(
@@ -247,7 +341,7 @@ def _run_agent_container(
if run_result.returncode == 0: if run_result.returncode == 0:
return container return container
err_text = run_result.stderr err_text = run_result.stderr
if spec.container_name_pinned or "is already in use" not in err_text: if plan.container_name_pinned or "is already in use" not in err_text:
sys.stderr.write(err_text + "\n") sys.stderr.write(err_text + "\n")
die(f"docker run failed for container '{container}'") die(f"docker run failed for container '{container}'")
if suffix > 100: if suffix > 100:
@@ -262,44 +356,45 @@ def _run_agent_container(
info(f"name conflict; retrying as {container}") info(f"name conflict; retrying as {container}")
def _provision_container(spec: DockerBottleSpec, container: str) -> None: def _provision_container(plan: DockerBottlePlan, container: str) -> str | None:
"""Copy prompt, skills, ssh keys, and (optionally) .git into the """Copy prompt, skills, ssh keys, and (optionally) .git into the
running container, fixing up ownership/mode where the host UID running container. Returns the in-container prompt path if a prompt
would otherwise leave files unreadable by the in-container `node`.""" was provisioned, else None — the Bottle handle uses it to decide
whether to add --append-system-prompt-file to claude's argv."""
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt"
subprocess.run( subprocess.run(
["docker", "cp", str(spec.prompt_file), f"{container}:{container_prompt_path}"], ["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
check=True, check=True,
) )
# `docker cp` preserves host UID; re-own/mode as root so node can # `docker cp` preserves host UID; re-own/mode as root so node can
# read its own mode-600 prompt regardless of host UID. # read its own mode-600 prompt regardless of host UID.
subprocess.run( subprocess.run(
["docker", "exec", "-u", "0", container, "chown", "node:node", container_prompt_path], ["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
check=True, check=True,
) )
subprocess.run( subprocess.run(
["docker", "exec", "-u", "0", container, "chmod", "600", container_prompt_path], ["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
check=True, check=True,
) )
agent = spec.manifest.agents[spec.agent_name] agent = plan.spec.manifest.agents[plan.spec.agent_name]
if agent.skills: if agent.skills:
skills_mod.skills_copy_into(container, list(agent.skills)) skills_mod.skills_copy_into(container, list(agent.skills))
bottle = spec.manifest.bottle_for(spec.agent_name) bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if bottle.ssh: if bottle.ssh:
proxy_host_port = pipelock.pipelock_proxy_host_port(spec.slug) proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug)
ssh_mod.ssh_setup(container, spec.stage_dir, proxy_host_port, bottle.ssh) ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh)
if spec.copy_cwd_git: if plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir():
info(f"copying {spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git")
subprocess.run( subprocess.run(
["docker", "cp", f"{spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
check=True, check=True,
) )
@@ -312,9 +407,4 @@ def _provision_container(spec: DockerBottleSpec, container: str) -> None:
check=True, check=True,
) )
return in_container_prompt_path if agent.prompt else None
def container_prompt_path() -> str:
"""The path inside the container where the prompt file lands. Used
by start.py to pass `--append-system-prompt-file` to claude."""
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
return f"{container_home}/.claude-bottle-prompt.txt"
+28 -120
View File
@@ -11,27 +11,17 @@ import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from .. import docker as docker_mod from ..bottles import get_bottle_platform
from .. import pipelock from ..bottles.docker import DockerBottlePlan, DockerBottleSpec
from .. import skills as skills_mod from ..log import info
from .. import ssh as ssh_mod
from ..bottles import get_bottle_factory
from ..bottles.docker import (
DockerBottleSpec,
container_prompt_path,
docker_runtime_label,
)
from ..env_resolve import env_resolve
from ..log import die, info
from ..manifest import Manifest from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line from ._common import PROG, USER_CWD, read_tty_line
def show_plan(spec: DockerBottleSpec, *, remote_control: bool) -> None: def show_plan(plan: DockerBottlePlan, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr. Pure presentation; """Render the y/N preflight summary to stderr. Reads everything off
reads manifest-backed fields off `spec` and probes the Docker the plan; pure presentation."""
runtime label. `remote_control` is the only field not already on spec = plan.spec
the spec — it's a claude CLI flag, not a bottle property."""
manifest = spec.manifest manifest = spec.manifest
agent = manifest.agents[spec.agent_name] agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
@@ -41,28 +31,28 @@ def show_plan(spec: DockerBottleSpec, *, remote_control: bool) -> None:
env_names.append("CLAUDE_CODE_OAUTH_TOKEN") env_names.append("CLAUDE_CODE_OAUTH_TOKEN")
ssh_hosts = [e.Host for e in bottle.ssh] ssh_hosts = [e.Host for e in bottle.ssh]
allowlist_summary = pipelock.pipelock_allowlist_summary(manifest, agent.bottle)
prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else "" prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else ""
runtime_label = "runsc (gVisor)" if plan.use_runsc else "runc (default)"
print(file=sys.stderr) print(file=sys.stderr)
info(f"agent : {spec.agent_name}") info(f"agent : {spec.agent_name}")
info(f"image : {spec.image}") info(f"image : {plan.image}")
if spec.derived_image: if plan.derived_image:
info( info(
f"cwd : {spec.user_cwd} -> /home/node/workspace " f"cwd : {spec.user_cwd} -> /home/node/workspace "
f"(derived: {spec.derived_image})" f"(derived: {plan.derived_image})"
) )
info(f"container : {spec.container_name}") info(f"container : {plan.container_name}")
info(f"stage dir : {spec.stage_dir}") info(f"stage dir : {plan.stage_dir}")
info("env (names only): " + (", ".join(env_names) if env_names else "(none)")) info("env (names only): " + (", ".join(env_names) if env_names else "(none)"))
info("skills : " + (" ".join(agent.skills) if agent.skills else "(none)")) info("skills : " + (" ".join(agent.skills) if agent.skills else "(none)"))
info(f"docker runtime : {docker_runtime_label()}") info(f"docker runtime : {runtime_label}")
info(f"bottle : {agent.bottle}") info(f"bottle : {agent.bottle}")
if ssh_hosts: if ssh_hosts:
info(f" ssh hosts : {', '.join(ssh_hosts)}") info(f" ssh hosts : {', '.join(ssh_hosts)}")
else: else:
info(" ssh hosts : (none)") info(" ssh hosts : (none)")
info(f" egress : {allowlist_summary}") info(f" egress : {plan.allowlist_summary}")
info( info(
f"prompt : {len(agent.prompt)} chars; " f"prompt : {len(agent.prompt)} chars; "
f"first line: {prompt_first_line or '(empty)'}" f"first line: {prompt_first_line or '(empty)'}"
@@ -81,97 +71,20 @@ def cmd_start(argv: list[str]) -> int:
dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1" dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1"
name = args.name
slug = docker_mod.slugify(name)
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest")
default_container = f"claude-bottle-{slug}"
pinned_container = os.environ.get("CLAUDE_BOTTLE_CONTAINER", "")
runtime_image = image
derived_image = ""
if args.cwd:
derived_image = os.environ.get("CLAUDE_BOTTLE_DERIVED_IMAGE", f"claude-bottle:cwd-{slug}")
runtime_image = derived_image
docker_mod.require_docker()
manifest = Manifest.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
manifest.require_agent(name) spec = DockerBottleSpec(
agent = manifest.agents[name] manifest=manifest,
bottle_name = agent.bottle agent_name=args.name,
bottle = manifest.bottle_for(name) copy_cwd=args.cwd,
user_cwd=USER_CWD,
container = pinned_container or default_container forward_oauth_token=bool(os.environ.get("CLAUDE_BOTTLE_OAUTH_TOKEN")),
suffix = 2 )
if pinned_container:
if docker_mod.container_exists(container):
die(
f"container '{container}' already exists "
f"(pinned via CLAUDE_BOTTLE_CONTAINER). "
f"Remove it with 'docker rm -f {container}' or unset the override."
)
else:
while docker_mod.container_exists(container):
container = f"{default_container}-{suffix}"
suffix += 1
if suffix > 100:
die(
f"could not find a free container name after "
f"{default_container}-99; clean up old containers with "
f"'docker rm -f <name>'"
)
# CLAUDE_BOTTLE_OAUTH_TOKEN → CLAUDE_CODE_OAUTH_TOKEN forwarding.
# Host-side token is always forwarded so every container can authenticate.
forward_oauth_token = bool(os.environ.get("CLAUDE_BOTTLE_OAUTH_TOKEN"))
if agent.skills:
skills_mod.skills_validate_all(list(agent.skills))
if bottle.ssh:
ssh_mod.ssh_validate_entries(bottle.ssh)
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage.")) stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
env_file = stage_dir / "agent.env"
args_file = stage_dir / "docker-args"
prompt_file = stage_dir / "prompt.txt"
pipelock_yaml_filename = "pipelock.yaml"
pipelock_yaml = stage_dir / pipelock_yaml_filename
env_file.write_text("")
env_file.chmod(0o600)
args_file.write_text("")
prompt_file.write_text("")
prompt_file.chmod(0o600)
try: try:
pipelock.pipelock_write_yaml(manifest, bottle_name, pipelock_yaml) platform = get_bottle_platform()
plan = platform.prepare(spec, stage_dir=stage_dir)
env_resolve(manifest, name, env_file, args_file) show_plan(plan, remote_control=args.remote_control)
prompt_content = agent.prompt
prompt_file.write_text(prompt_content)
spec = DockerBottleSpec(
agent_name=name,
slug=slug,
manifest=manifest,
container_name=container,
container_name_pinned=bool(pinned_container),
image=image,
derived_image=derived_image,
runtime_image=runtime_image,
user_cwd=USER_CWD,
copy_cwd_git=bool(args.cwd and Path(USER_CWD, ".git").is_dir()),
stage_dir=stage_dir,
prompt_file=prompt_file,
env_file=env_file,
args_file=args_file,
pipelock_yaml_path=pipelock_yaml,
pipelock_yaml_filename=pipelock_yaml_filename,
forward_oauth_token=forward_oauth_token,
)
show_plan(spec, remote_control=args.remote_control)
if dry_run: if dry_run:
info("dry-run requested; not starting container.") info("dry-run requested; not starting container.")
@@ -184,8 +97,7 @@ def cmd_start(argv: list[str]) -> int:
info("aborted by user") info("aborted by user")
return 0 return 0
factory = get_bottle_factory() with platform.launch(plan) as bottle:
with factory(spec) as bottle_handle:
info( info(
"attaching interactive claude session " "attaching interactive claude session "
"(Ctrl-D or 'exit' to leave; container will be removed)" "(Ctrl-D or 'exit' to leave; container will be removed)"
@@ -193,12 +105,8 @@ def cmd_start(argv: list[str]) -> int:
claude_args = ["--dangerously-skip-permissions"] claude_args = ["--dangerously-skip-permissions"]
if args.remote_control: if args.remote_control:
claude_args.append("--remote-control") claude_args.append("--remote-control")
if prompt_content: bottle.exec_claude(claude_args, tty=True)
claude_args.extend( info(f"session ended; container {bottle.name} will be removed")
["--append-system-prompt-file", container_prompt_path()]
)
bottle_handle.exec_claude(claude_args, tty=True)
info(f"session ended; container {bottle_handle.name} will be removed")
return 0 return 0
finally: finally:
shutil.rmtree(stage_dir, ignore_errors=True) shutil.rmtree(stage_dir, ignore_errors=True)