From d5c056f36e78409194c81ba8b06d3080ceea98e0 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 21:56:10 -0400 Subject: [PATCH 01/44] docs(prd): add 0003 bottle factory abstraction --- docs/prds/0003-bottle-factory-abstraction.md | 279 +++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 docs/prds/0003-bottle-factory-abstraction.md diff --git a/docs/prds/0003-bottle-factory-abstraction.md b/docs/prds/0003-bottle-factory-abstraction.md new file mode 100644 index 0000000..0a8e080 --- /dev/null +++ b/docs/prds/0003-bottle-factory-abstraction.md @@ -0,0 +1,279 @@ +# PRD 0003: Bottle factory abstraction + +- **Status:** Draft +- **Author:** didericis +- **Created:** 2026-05-10 + +## Summary + +Introduce a per-platform factory function that owns the end-to-end +lifecycle of a "bottle" (a running, isolated environment with claude +inside). The first and only implementation lands as +`create_docker_bottle`. No second platform ships in this PRD. + +## Problem + +Today, "how to launch a bottle" is spread across roughly six modules +(`claude_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, +`skills.py`, `docker.py`), each shelling out to `docker` directly via +`subprocess.run(["docker", ...])`. That coupling means: + +- Adding a second backend (Apple's `container`, fly.io, a remote SSH + host, etc.) requires editing every one of those call sites. The + research note `docs/research/apple-container-backend.md` already + flags this as a prerequisite for that work. +- The pipelock sidecar topology — two networks, multi-attach, sidecar + lifecycle — is a Docker implementation detail that has leaked into + the top-level CLI orchestration. It reads as a core concept of the + project, but a fly.io bottle would not need any of it. +- The manifest carries a Docker-specific `runtime: "runsc"` field + (`bottles[].runtime`). Anyone setting it has to know about gVisor, + whether Docker has it registered, and what to do on macOS where it + isn't available natively. The field has one valid non-default value + and exists only because the current code can't decide on its own. + +The shape that fits the project's actual goals (isolated agent runs +across multiple platforms) is "one factory per platform," not "one +container-runtime SDK with N drivers." A previous draft of this PRD +considered a low-level `Backend` protocol (`run`, `exec`, `cp`, +`network_connect`, ...) and rejected it as the wrong layer — it would +have forced fly.io to pretend it's Docker. + +## Goals / Success Criteria + +The feature works when all of the following are observable: + +- `cli.py start` works identically for an existing manifest with no + user-visible changes other than (a) a startup log line naming the + Docker runtime in use, and (b) `bottles[].runtime` no longer being a + valid manifest field. +- On a Linux host with gVisor registered, the agent container runs + under `runsc` without anything in the manifest requesting it. +- On a host without gVisor (including macOS), the agent container runs + under the default `runc` runtime; nothing fails, no warning is + printed beyond the runtime-name log line. +- The existing test suite passes with no behavior changes other than + the manifest-schema removal of `runtime`. + +The feature is **done** when all of the following ship: + +- A new `claude_bottle/bottles/` package exists with + `__init__.py` (factory selection) and `docker.py` + (`create_docker_bottle`). +- `create_docker_bottle` returns a context manager yielding a `Bottle` + handle exposing `exec_claude(argv, *, tty=True)`, `cp_in(host, ctr)`, + and teardown on context exit. +- Every existing `subprocess.run(["docker", ...])` call in + `cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and + `skills.py` either moves into `bottles/docker.py` or is called from + it. No top-level CLI code references `docker` directly. +- `bottles[].runtime` is removed from the manifest schema, the + dataclass in `manifest.py`, the example manifest, and any README / + docs references. `require_runsc()` in `claude_bottle/docker.py` is + deleted. +- A single env var, `CLAUDE_BOTTLE_PLATFORM` (default `"docker"`), + selects the factory. Unknown values die at startup with a list of + known platforms. +- The y/N preflight in `cli.py` includes the resolved Docker runtime + alongside the allowlist summary. + +## Non-goals + +- No second platform implementation. `create_container_bottle` and + `create_flyio_bottle` are not in this PRD. The factory dict in + `bottles/__init__.py` ships with one entry. +- No retries, async, or streaming exec. The current code is + synchronous `subprocess.run`; the `Bottle` handle matches. +- No behavior change beyond the runsc auto-detect. Pipelock topology, + network naming, container naming, image build flow, and SSH + provisioning all stay byte-identical. +- No `--require-runsc` CLI escape hatch. If a user later wants "fail + rather than silently downgrade," that's a follow-up. +- No `bottles[].platform` manifest field. Platform is a property of + the host environment, not the bottle definition (at least for now). + +## Scope + +### In scope + +- New `claude_bottle/bottles/` package containing `__init__.py` and + `docker.py`. +- The `Bottle` Protocol definition and `create_docker_bottle` factory. +- Moving Docker-specific subprocess calls into the factory. +- Removing `bottles[].runtime` from the manifest schema and the + dataclass. +- Auto-detection of `runsc` registration via `docker info`. +- Preflight integration: the existing y/N output names the resolved + Docker runtime. +- Test updates: any manifest fixtures referencing `runtime` are + updated; tests that assert on `--runtime=runsc` instead seed the + detection by mocking `docker info`. + +### Out of scope + +- Apple `container` and fly.io factories (separate PRDs, deferred + until the Docker factory is the only thing shipping). +- Generalizing the pipelock sidecar to other platforms. Pipelock + topology is, after this PRD, an implementation detail private to + `bottles/docker.py`. +- Rewriting `pipelock.py`'s YAML generation. The allowlist→YAML + translation stays where it is and is called by the Docker factory. +- Changes to `env_resolve.py`, `manifest.py` (beyond the `runtime` + removal), or the agent schema. +- CLI flags for runtime selection / override. + +## Proposed Design + +### New services / components + +A new package, `claude_bottle/bottles/`: + +- **`claude_bottle/bottles/__init__.py`** — Defines the `Bottle` + Protocol and `get_bottle_factory()`. The factory registry is a + module-level dict mapping platform name → factory function. + Selection reads `CLAUDE_BOTTLE_PLATFORM` (default `"docker"`). + Unknown values call `die()` with the list of known platforms. + + ```python + class Bottle(Protocol): + name: str + def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ... + def cp_in(self, host_path: str, ctr_path: str) -> None: ... + def close(self) -> None: ... + + def get_bottle_factory() -> Callable[..., AbstractContextManager[Bottle]]: + ... + ``` + +- **`claude_bottle/bottles/docker.py`** — `create_docker_bottle(...)`, + the only factory implementation in this PRD. Owns: + - probing for `runsc` availability (`docker info --format + '{{json .Runtimes}}'`), + - building the base image and the per-cwd derived image, + - creating the per-agent internal and egress networks, + - launching the pipelock sidecar (calls `pipelock.py` for YAML + generation, but the sidecar's `docker create / cp / network + connect / start` sequence moves into this module), + - running the agent container with `--runtime=runsc` iff available, + - copying skills / SSH keys / prompt / `.git` into the running + container, + - tearing everything down (container, sidecar, two networks) on + context exit. + +### Existing code touched + +- **`claude_bottle/cli/start.py`** — replace the inline docker + orchestration with `with get_bottle_factory()(manifest, ...) as + bottle:` and call `bottle.exec_claude(...)`. The preflight stays + here but is extended to render the resolved Docker runtime alongside + the allowlist summary. +- **`claude_bottle/manifest.py`** — drop the `runtime` field from the + Bottle dataclass and its validation. Existing manifests with + `runtime: "runsc"` should produce a clear "unknown field" error so + users know to remove it. +- **`claude_bottle/docker.py`** — `require_runsc()` deleted. + `require_docker()`, `slugify()`, `image_exists()`, + `container_exists()`, and the `build_image` / `build_image_with_cwd` + helpers stay; they're host-side utilities that the Docker factory + consumes. +- **`claude_bottle/pipelock.py`** — keep all the allowlist resolution + and YAML generation. Remove `pipelock_start` / `pipelock_stop` (or + inline them into `bottles/docker.py` — decide during + implementation). Pipelock-the-sidecar becomes a Docker-factory + internal concept. +- **`claude_bottle/network.py`** — same call-sites moved into + `bottles/docker.py`. The module either becomes a thin set of pure + name-derivation helpers (`network_name_for_slug`, etc.) or folds + entirely into `bottles/docker.py`. Decide during implementation. +- **`claude_bottle/ssh.py`** and **`claude_bottle/skills.py`** — the + `docker cp` and `docker exec` calls move into / are called from + `bottles/docker.py`. The host-side file-tree generation stays put. +- **`claude-bottle.example.json`** — remove the `runtime` field from + any example bottle. +- **`README.md`** — note `CLAUDE_BOTTLE_PLATFORM` and the runsc + auto-detect; remove any mention of `runtime: "runsc"` as a manifest + field. + +### Data model changes + +The bottle schema loses one field: + +```diff + { + "bottles": { + "default": { +- "runtime": "runsc", + "env": { "...": "..." }, + "ssh": [], + "egress": { "allowlist": [...] } + } + } + } +``` + +Any manifest carrying `runtime` produces a validation error on load +("unknown bottle field 'runtime' — gVisor is now auto-detected; +remove this field"). + +The agent schema is unchanged. + +### External dependencies + +None new. This PRD reorganizes existing code; it does not pull in any +new images, binaries, or libraries. + +### Behavior the runsc auto-detect introduces + +The Docker factory runs `docker info --format '{{json .Runtimes}}'` +exactly once per `create_docker_bottle` call. If `runsc` is in the +output, the subsequent `docker run` adds `--runtime=runsc`. Otherwise +it runs without that flag. The choice is logged via the existing +`info()` helper as part of the preflight: + +``` +docker runtime: runsc (gVisor) # or: runc (default) +``` + +The y/N preflight shows the same line, so users can confirm what +they're about to run under before approving. + +## Open questions + +- **Where the pipelock sidecar lifecycle lives.** Two reasonable + splits: (a) `pipelock.py` keeps `pipelock_start` / `pipelock_stop` + and `bottles/docker.py` calls them; (b) the sidecar + `docker create/cp/network connect/start` sequence moves entirely + into `bottles/docker.py` and `pipelock.py` shrinks to the YAML + + allowlist helpers. (a) keeps git blame intact and is the smaller + diff; (b) makes pipelock-as-an-implementation-detail more obvious. + Decide during implementation. + +- **Whether `bottles/__init__.py` re-exports `create_docker_bottle`.** + Importing `from claude_bottle.bottles import create_docker_bottle` + vs. `from claude_bottle.bottles.docker import create_docker_bottle`. + Doesn't matter for v1 (only the registry consumes it), but worth + picking a convention before a second factory lands. + +- **Manifest-error wording when `runtime` is seen.** "Unknown field" + is technically correct but unhelpful. A targeted error message + ("runtime: was removed; gVisor is now auto-detected when the Docker + daemon has it registered") is more useful and worth the extra few + lines. + +- **Test fixtures.** Some tests mock `docker info` or seed + `--runtime=runsc` expectations. Audit and update as part of the + implementation; not expected to be a large change. + +- **Future `--require-runsc` flag.** Not in this PRD; flagged here so + it's findable when the question comes up. + +## References + +- `docs/research/apple-container-backend.md` — original motivation; + prior draft considered a low-level `Backend` protocol and rejected + it as the wrong layer. +- `docs/research/bash-vs-python-vs-go.md` §Recommendation — argues + that the factory abstraction matters independent of language choice. +- PRD 0001 (`docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`) + — defines the pipelock topology that becomes a private + implementation detail of the Docker factory after this PRD ships. From d75cc9325f6382628b20ec14262b3ab7cf94ecf4 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 22:15:05 -0400 Subject: [PATCH 02/44] feat(bottles): implement bottle factory abstraction per PRD 0003 Introduce claude_bottle/bottles/ with a Bottle Protocol and a get_bottle_factory() that dispatches on CLAUDE_BOTTLE_PLATFORM (default "docker"). Move every Docker-specific subprocess.run call from cli/start.py, plus the orchestration of build, networks, the pipelock sidecar, container launch, and per-container provisioning (prompt, skills, ssh, .git), into create_docker_bottle. Drop bottles[].runtime from the manifest schema. Auto-detect whether gVisor is registered with the daemon and pass --runtime=runsc when it is; the preflight shows the resolved runtime so the choice is visible. Manifests still carrying 'runtime' get a clear error pointing at the auto-detect behavior, rather than silent ignore. Out of scope: cli/cleanup.py and cli/list.py still call docker directly. They enumerate active bottles across the host, which is a separate concern from "create a bottle" and is left for a follow-up that introduces a list_active/cleanup primitive on the factory. --- README.md | 14 +- claude-bottle.example.json | 1 - claude_bottle/bottles/__init__.py | 54 +++++ claude_bottle/bottles/docker.py | 320 ++++++++++++++++++++++++++++++ claude_bottle/cli/start.py | 211 ++++---------------- claude_bottle/docker.py | 16 -- claude_bottle/manifest.py | 34 +--- tests/test_manifest_runtime.py | 58 ++++-- 8 files changed, 468 insertions(+), 240 deletions(-) create mode 100644 claude_bottle/bottles/__init__.py create mode 100644 claude_bottle/bottles/docker.py diff --git a/README.md b/README.md index 56c88e8..7dae22a 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,10 @@ like `cloudflare-dns.com` would have to be on the allowlist for the agent to reach it at all. The container itself adds a layer between the agent and the host, but the v1 design leans more on secret minimization and egress allowlisting than on the container as a -hardened boundary. Linux hosts can opt into [gVisor](https://gvisor.dev/) -per bottle (see `runtime` in the manifest below) for a userspace -syscall barrier; the broader v2 discussion lives in +hardened boundary. On Linux hosts where [gVisor](https://gvisor.dev/) +is registered with Docker, claude-bottle auto-detects it and launches +every bottle under `runsc` for a userspace syscall barrier — no +manifest configuration required. The broader v2 discussion lives in `docs/research/stronger-isolation-alternatives.md`. The egress proxy and OAuth-token handling below are the load-bearing @@ -76,13 +77,6 @@ project entries overriding home entries on key conflict). { "bottles": { "gitea-dev": { - // Container runtime for the agent. Default "runc"; set to - // "runsc" on Linux hosts to launch the agent under gVisor for - // a userspace syscall barrier between the agent and the host - // kernel. claude-bottle verifies the runtime is registered with - // Docker before launch; gVisor is not available on macOS. - "runtime": "runsc", - "env": { "GITEA_TOKEN": "?paste your Gitea API token", "GITHUB_TOKEN": "${GH_PAT}", diff --git a/claude-bottle.example.json b/claude-bottle.example.json index 20431dd..dbfd93c 100644 --- a/claude-bottle.example.json +++ b/claude-bottle.example.json @@ -13,7 +13,6 @@ }, "gitea-dev": { - "runtime": "runsc", "env": { "GITEA_TOKEN": "?paste your Gitea API token", "GITHUB_TOKEN": "${GH_PAT}", diff --git a/claude_bottle/bottles/__init__.py b/claude_bottle/bottles/__init__.py new file mode 100644 index 0000000..4f48768 --- /dev/null +++ b/claude_bottle/bottles/__init__.py @@ -0,0 +1,54 @@ +"""Per-platform bottle factories. + +A bottle is a running, isolated environment with claude inside. Each +platform exposes a factory (currently only Docker) that owns the +end-to-end lifecycle: image build, container/sidecar launch, file +provisioning, and teardown. + +Selection is driven by the CLAUDE_BOTTLE_PLATFORM env var (default +"docker"). Per PRD 0003 the manifest does not carry a platform field; +the host environment picks. +""" + +from __future__ import annotations + +import os +from contextlib import AbstractContextManager +from typing import Callable, Protocol + +from ..log import die +from .docker import create_docker_bottle + + +class Bottle(Protocol): + """Handle to a running bottle. Yielded by a factory's context manager. + + `exec_claude` runs `claude` inside the bottle and blocks until the + session ends. `cp_in` copies a host path into the bottle. `close` + is an idempotent alias for context-manager teardown. + """ + + name: str + + def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ... + def cp_in(self, host_path: str, container_path: str) -> None: ... + def close(self) -> None: ... + + +BottleFactory = Callable[..., AbstractContextManager[Bottle]] + + +_FACTORIES: dict[str, BottleFactory] = { + "docker": create_docker_bottle, +} + + +def get_bottle_factory() -> BottleFactory: + """Resolve the bottle factory for the active platform. Dies with a + pointer at the known platforms if CLAUDE_BOTTLE_PLATFORM names an + unimplemented one.""" + name = os.environ.get("CLAUDE_BOTTLE_PLATFORM", "docker") + if name not in _FACTORIES: + known = ", ".join(sorted(_FACTORIES)) + die(f"unknown CLAUDE_BOTTLE_PLATFORM={name!r}; known platforms: {known}") + return _FACTORIES[name] diff --git a/claude_bottle/bottles/docker.py b/claude_bottle/bottles/docker.py new file mode 100644 index 0000000..6cbd5b3 --- /dev/null +++ b/claude_bottle/bottles/docker.py @@ -0,0 +1,320 @@ +"""Docker bottle factory. + +`create_docker_bottle` owns the end-to-end Docker lifecycle: + + 1. Probe whether gVisor (`runsc`) is registered with the daemon. + 2. Build the base image (and a per-cwd derived image if --cwd). + 3. Create the per-agent internal + egress networks. + 4. Boot the pipelock sidecar on both networks. + 5. Launch the agent container, with `--runtime=runsc` iff available. + 6. Copy the prompt, skills, SSH keys, and (optionally) .git into the + running container. + 7. Yield a `Bottle` handle for `exec_claude` / `cp_in`. + 8. Tear everything down (container, sidecar, both networks) on exit. + +The Bottle Protocol lives in `claude_bottle.bottles.__init__`. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator + +from .. import docker as docker_mod +from .. import network as network_mod +from .. import pipelock +from .. import skills as skills_mod +from .. import ssh as ssh_mod +from ..log import die, info +from ..manifest import Manifest + + +# --- Runtime detection ----------------------------------------------------- + + +def runsc_available() -> bool: + """Return True if the Docker daemon has the gVisor (`runsc`) runtime + registered. Called twice per `start`: once during the preflight to + render the runtime label, once inside the factory to set `--runtime`. + `docker info` is cheap; the duplication is not worth caching.""" + r = subprocess.run( + ["docker", "info", "--format", "{{json .Runtimes}}"], + capture_output=True, + text=True, + ) + return r.returncode == 0 and "runsc" in r.stdout + + +def docker_runtime_label() -> str: + """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) +class DockerBottleSpec: + """Host-side inputs assembled by the CLI before factory entry. Every + field is a value the factory consumes; nothing here is platform- + agnostic enough yet to lift into a shared spec (only Docker exists).""" + + agent_name: str + slug: str + manifest: Manifest + container_name: str + container_name_pinned: bool + image: str + derived_image: str # "" -> no derived image + runtime_image: str # image to actually launch (derived or base) + user_cwd: str + copy_cwd_git: bool + stage_dir: Path + prompt_file: Path + env_file: Path + args_file: Path + pipelock_yaml_path: Path + pipelock_yaml_filename: str + forward_oauth_token: bool + + +# --- Bottle handle --------------------------------------------------------- + + +class _DockerBottle: + """Concrete Bottle for Docker. Holds the resolved container name and + a teardown closure. Not exported — the factory yields it via the + Bottle Protocol.""" + + def __init__(self, container: str, teardown): + self.name = container + self._teardown = teardown + self._closed = False + + def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: + cmd = ["docker", "exec"] + if tty: + cmd.append("-it") + cmd.extend([self.name, "claude", *argv]) + return subprocess.run(cmd).returncode + + def cp_in(self, host_path: str, container_path: str) -> None: + subprocess.run( + ["docker", "cp", host_path, f"{self.name}:{container_path}"], + stdout=subprocess.DEVNULL, + check=True, + ) + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._teardown() + + +# --- Factory --------------------------------------------------------------- + + +# Where the repo root lives, for `docker build` context. Computed once. +_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent) + + +@contextmanager +def create_docker_bottle(spec: DockerBottleSpec) -> Iterator[_DockerBottle]: + """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] = { + "container": "", + "pipelock": "", + "internal_network": "", + "egress_network": "", + } + + def teardown() -> None: + try: + if state["container"] and docker_mod.container_exists(state["container"]): + subprocess.run( + ["docker", "rm", "-f", state["container"]], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + state["container"] = "" + if state["pipelock"]: + pipelock.pipelock_stop(spec.slug) + state["pipelock"] = "" + if state["internal_network"]: + network_mod.network_remove(state["internal_network"]) + state["internal_network"] = "" + if state["egress_network"]: + network_mod.network_remove(state["egress_network"]) + state["egress_network"] = "" + except BaseException: + # Teardown must not raise; swallow so the caller's __exit__ + # path can still propagate the original error. + pass + + try: + use_runsc = runsc_available() + + docker_mod.build_image(spec.image, _REPO_DIR) + if spec.derived_image: + 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( + spec.slug, + state["internal_network"], + state["egress_network"], + spec.stage_dir, + spec.pipelock_yaml_filename, + ) + + container = _run_agent_container(spec, state["internal_network"], use_runsc) + state["container"] = container + + _provision_container(spec, container) + + bottle = _DockerBottle(container, teardown) + yield bottle + finally: + teardown() + + +# --- Internals ------------------------------------------------------------- + + +def _run_agent_container( + spec: DockerBottleSpec, + internal_network: str, + use_runsc: bool, +) -> str: + """Build the `docker run` argv and execute it, handling name-conflict + races by incrementing the suffix (unless the name was user-pinned). + Returns the resolved container name.""" + proxy_url = pipelock.pipelock_proxy_url(spec.slug) + docker_args: list[str] = [ + "--rm", "-d", + "--name", spec.container_name, + "--network", internal_network, + "-e", f"HTTPS_PROXY={proxy_url}", + "-e", f"HTTP_PROXY={proxy_url}", + "-e", "NO_PROXY=localhost,127.0.0.1", + ] + if use_runsc: + docker_args.extend(["--runtime", "runsc"]) + if spec.env_file.stat().st_size > 0: + docker_args.extend(["--env-file", str(spec.env_file)]) + + # ARGS_FILE pairs (-e, NAME) line-by-line. + args_lines = spec.args_file.read_text().splitlines() + i = 0 + while i < len(args_lines): + flag = args_lines[i] + i += 1 + if not flag: + continue + if i >= len(args_lines): + break + vname = args_lines[i] + i += 1 + docker_args.extend([flag, vname]) + + if spec.forward_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([spec.runtime_image, "sleep", "infinity"]) + + info(f"starting container {spec.container_name} from {spec.runtime_image}") + + container = spec.container_name + base_name = spec.container_name + suffix = 2 + while True: + run_result = subprocess.run( + ["docker", "run", *docker_args], + capture_output=True, + text=True, + ) + if run_result.returncode == 0: + return container + err_text = run_result.stderr + if spec.container_name_pinned or "is already in use" not in err_text: + sys.stderr.write(err_text + "\n") + die(f"docker run failed for container '{container}'") + if suffix > 100: + die( + f"could not find a free container name after " + f"{base_name}-99 retries; clean up old containers" + ) + container = f"{base_name}-{suffix}" + suffix += 1 + name_idx = docker_args.index("--name") + 1 + docker_args[name_idx] = container + info(f"name conflict; retrying as {container}") + + +def _provision_container(spec: DockerBottleSpec, container: str) -> None: + """Copy prompt, skills, ssh keys, and (optionally) .git into the + running container, fixing up ownership/mode where the host UID + would otherwise leave files unreadable by the in-container `node`.""" + container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") + container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" + + subprocess.run( + ["docker", "cp", str(spec.prompt_file), f"{container}:{container_prompt_path}"], + stdout=subprocess.DEVNULL, + check=True, + ) + # `docker cp` preserves host UID; re-own/mode as root so node can + # read its own mode-600 prompt regardless of host UID. + subprocess.run( + ["docker", "exec", "-u", "0", container, "chown", "node:node", container_prompt_path], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", container, "chmod", "600", container_prompt_path], + stdout=subprocess.DEVNULL, + check=True, + ) + + agent = spec.manifest.agents[spec.agent_name] + if agent.skills: + skills_mod.skills_copy_into(container, list(agent.skills)) + + bottle = spec.manifest.bottle_for(spec.agent_name) + if bottle.ssh: + proxy_host_port = pipelock.pipelock_proxy_host_port(spec.slug) + ssh_mod.ssh_setup(container, spec.stage_dir, proxy_host_port, bottle.ssh) + + if spec.copy_cwd_git: + info(f"copying {spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") + subprocess.run( + ["docker", "cp", f"{spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + [ + "docker", "exec", "-u", "0", container, + "chown", "-R", "node:node", "/home/node/workspace/.git", + ], + stdout=subprocess.DEVNULL, + check=True, + ) + + +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" diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index 2685df6..680471a 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -7,20 +7,24 @@ from __future__ import annotations import argparse import os import shutil -import subprocess import sys import tempfile from pathlib import Path from .. import docker as docker_mod -from .. import network as network_mod from .. import pipelock from .. import skills as skills_mod 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 ._common import PROG, REPO_DIR, USER_CWD, read_tty_line +from ._common import PROG, USER_CWD, read_tty_line def cmd_start(argv: list[str]) -> int: @@ -86,10 +90,6 @@ def cmd_start(argv: list[str]) -> int: if agent.skills: skills_mod.skills_validate_all(list(agent.skills)) - runtime = bottle.runtime - if runtime == "runsc": - docker_mod.require_runsc() - ssh_entries = bottle.ssh if ssh_entries: ssh_mod.ssh_validate_entries(ssh_entries) @@ -106,31 +106,6 @@ def cmd_start(argv: list[str]) -> int: prompt_file.write_text("") prompt_file.chmod(0o600) - # cleanup state — populated as resources come up. - state: dict[str, str] = { - "container": "", - "pipelock": "", - "internal_network": "", - "egress_network": "", - } - - def cleanup_all() -> None: - try: - if state["container"] and docker_mod.container_exists(state["container"]): - subprocess.run( - ["docker", "rm", "-f", state["container"]], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - if state["pipelock"]: - pipelock.pipelock_stop(slug) - if state["internal_network"]: - network_mod.network_remove(state["internal_network"]) - if state["egress_network"]: - network_mod.network_remove(state["egress_network"]) - finally: - shutil.rmtree(stage_dir, ignore_errors=True) - try: pipelock.pipelock_write_yaml(manifest, bottle_name, pipelock_yaml) allowlist_summary = pipelock.pipelock_allowlist_summary(manifest, bottle_name) @@ -154,8 +129,8 @@ def cmd_start(argv: list[str]) -> int: + (", ".join(display_env_names) if display_env_names else "(none)") ) info("skills : " + (" ".join(agent.skills) if agent.skills else "(none)")) + info(f"docker runtime : {docker_runtime_label()}") info(f"bottle : {bottle_name}") - info(f" runtime : {runtime}{' (gVisor)' if runtime == 'runsc' else ''}") if ssh_entries: ssh_names = ", ".join(e.Host for e in ssh_entries) info(f" ssh hosts : {ssh_names}") @@ -171,7 +146,6 @@ def cmd_start(argv: list[str]) -> int: if dry_run: info("dry-run requested; not starting container.") - cleanup_all() return 0 sys.stderr.write("claude-bottle: launch this agent? [y/N] ") @@ -179,144 +153,43 @@ def cmd_start(argv: list[str]) -> int: reply = read_tty_line() if reply not in ("y", "Y", "yes", "YES"): info("aborted by user") - cleanup_all() return 0 - # --- Build & launch --- - docker_mod.build_image(image, REPO_DIR) - if derived_image: - docker_mod.build_image_with_cwd(derived_image, image, USER_CWD) - - state["internal_network"] = network_mod.network_create_internal(slug) - state["egress_network"] = network_mod.network_create_egress(slug) - state["pipelock"] = pipelock.pipelock_start( - slug, - state["internal_network"], - state["egress_network"], - stage_dir, - pipelock_yaml_filename, + 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, ) - proxy_url = pipelock.pipelock_proxy_url(slug) - docker_args: list[str] = [ - "--rm", "-d", - "--name", container, - "--network", state["internal_network"], - "-e", f"HTTPS_PROXY={proxy_url}", - "-e", f"HTTP_PROXY={proxy_url}", - "-e", "NO_PROXY=localhost,127.0.0.1", - ] - if runtime != "runc": - docker_args.extend(["--runtime", runtime]) - if env_file.stat().st_size > 0: - docker_args.extend(["--env-file", str(env_file)]) - - # ARGS_FILE pairs (-e, NAME) line-by-line. - args_lines = args_file.read_text().splitlines() - i = 0 - while i < len(args_lines): - flag = args_lines[i] - i += 1 - if not flag: - continue - if i >= len(args_lines): - break - vname = args_lines[i] - i += 1 - docker_args.extend([flag, vname]) - - if forward_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([runtime_image, "sleep", "infinity"]) - - info(f"starting container {container} from {runtime_image}") - # Retry-on-name-conflict loop to mirror the bash version. - while True: - full_argv = ["docker", "run", *docker_args] - run_result = subprocess.run(full_argv, capture_output=True, text=True) - if run_result.returncode == 0: - state["container"] = container - break - err_text = run_result.stderr - if pinned_container or "is already in use" not in err_text: - sys.stderr.write(err_text + "\n") - die(f"docker run failed for container '{container}'") - if suffix > 100: - die( - f"could not find a free container name after " - f"{default_container}-99 retries; clean up old containers" + factory = get_bottle_factory() + with factory(spec) as bottle_handle: + info( + "attaching interactive claude session " + "(Ctrl-D or 'exit' to leave; container will be removed)" + ) + claude_args = ["--dangerously-skip-permissions"] + if args.remote_control: + claude_args.append("--remote-control") + if prompt_content: + claude_args.extend( + ["--append-system-prompt-file", container_prompt_path()] ) - container = f"{default_container}-{suffix}" - suffix += 1 - # Replace --name slot in docker_args. - name_idx = docker_args.index("--name") + 1 - docker_args[name_idx] = container - info(f"name conflict; retrying as {container}") - - container_prompt_path = ( - os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") - + "/.claude-bottle-prompt.txt" - ) - subprocess.run( - ["docker", "cp", str(prompt_file), f"{container}:{container_prompt_path}"], - stdout=subprocess.DEVNULL, - check=True, - ) - # `docker cp` preserves host UID; re-own/mode as root in the container - # so node can read its own mode-600 prompt regardless of host UID. - subprocess.run( - ["docker", "exec", "-u", "0", container, "chown", "node:node", container_prompt_path], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - ["docker", "exec", "-u", "0", container, "chmod", "600", container_prompt_path], - stdout=subprocess.DEVNULL, - check=True, - ) - - if agent.skills: - skills_mod.skills_copy_into(container, list(agent.skills)) - - if ssh_entries: - proxy_host_port = pipelock.pipelock_proxy_host_port(slug) - ssh_mod.ssh_setup(container, stage_dir, proxy_host_port, ssh_entries) - - if args.cwd and Path(USER_CWD, ".git").is_dir(): - info(f"copying {USER_CWD}/.git -> {container}:/home/node/workspace/.git") - subprocess.run( - ["docker", "cp", f"{USER_CWD}/.git", f"{container}:/home/node/workspace/.git"], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - ["docker", "exec", "-u", "0", container, "chown", "-R", "node:node", "/home/node/workspace/.git"], - stdout=subprocess.DEVNULL, - check=True, - ) - - info( - "attaching interactive claude session " - "(Ctrl-D or 'exit' to leave; container will be removed)" - ) - claude_args = ["--dangerously-skip-permissions"] - if args.remote_control: - claude_args.append("--remote-control") - if prompt_content: - subprocess.run( - [ - "docker", "exec", "-it", container, "claude", - *claude_args, - "--append-system-prompt-file", container_prompt_path, - ] - ) - else: - subprocess.run( - ["docker", "exec", "-it", container, "claude", *claude_args] - ) - info(f"session ended; container {container} will be removed") - return 0 + bottle_handle.exec_claude(claude_args, tty=True) + info(f"session ended; container {bottle_handle.name} will be removed") + return 0 finally: - cleanup_all() + shutil.rmtree(stage_dir, ignore_errors=True) diff --git a/claude_bottle/docker.py b/claude_bottle/docker.py index 0cd7b5f..80304ec 100644 --- a/claude_bottle/docker.py +++ b/claude_bottle/docker.py @@ -19,22 +19,6 @@ def require_docker() -> None: die("docker not found") -def require_runsc() -> None: - """Fail with an install pointer if the `runsc` (gVisor) runtime is - not registered with the local Docker daemon. Called when a bottle - sets `runtime: "runsc"`.""" - result = subprocess.run( - ["docker", "info", "--format", "{{json .Runtimes}}"], - capture_output=True, - text=True, - ) - if result.returncode != 0 or "runsc" not in result.stdout: - info("This bottle requested runtime 'runsc' but the gVisor runtime is not registered with Docker.") - info("Install gVisor and register it with the daemon: https://gvisor.dev/docs/user_guide/install/") - info("On macOS, gVisor is not available natively; remove 'runtime' from the bottle or run on Linux.") - die("runsc runtime not available") - - def image_exists(ref: str) -> bool: return _silent_run(["docker", "image", "inspect", ref]) == 0 diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index 20b6975..a65d981 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -7,8 +7,7 @@ Schema (see CLAUDE.md "Intended design"): "": { "env": { "": , ... }, "ssh": [ , ... ], - "egress": { "allowlist": [ "", ... ] }, - "runtime": "runc" | "runsc" + "egress": { "allowlist": [ "", ... ] } } }, "agents": { @@ -33,15 +32,11 @@ import json import os from dataclasses import dataclass, field from pathlib import Path -from typing import Literal, Mapping, cast +from typing import Mapping, cast from .log import die -Runtime = Literal["runc", "runsc"] -_SUPPORTED_RUNTIMES: tuple[Runtime, ...] = ("runc", "runsc") - - def _empty_str_dict() -> dict[str, str]: return {} @@ -116,12 +111,19 @@ class Bottle: env: Mapping[str, str] = field(default_factory=_empty_str_dict) ssh: tuple[SshEntry, ...] = () egress: BottleEgress = field(default_factory=BottleEgress) - runtime: Runtime = "runc" @classmethod def from_dict(cls, name: str, raw: object) -> "Bottle": d = _as_json_object(raw, f"bottle '{name}'") + if "runtime" in d: + die( + f"bottle '{name}' has a 'runtime' field, which is no longer " + f"supported. gVisor (runsc) is now auto-detected when " + f"registered with Docker; remove the 'runtime' field from " + f"the bottle definition." + ) + env: dict[str, str] = {} env_raw = d.get("env") if env_raw is not None: @@ -152,21 +154,7 @@ class Bottle: else BottleEgress() ) - runtime_raw = d.get("runtime") - runtime: Runtime - if runtime_raw is None: - runtime = "runc" - else: - if not isinstance(runtime_raw, str): - die(f"bottle '{name}' runtime must be a string (was {type(runtime_raw).__name__})") - if runtime_raw not in _SUPPORTED_RUNTIMES: - die( - f"bottle '{name}' runtime '{runtime_raw}' is not supported. " - f"Use one of: {', '.join(_SUPPORTED_RUNTIMES)}." - ) - runtime = runtime_raw - - return cls(env=env, ssh=ssh, egress=egress, runtime=runtime) + return cls(env=env, ssh=ssh, egress=egress) @dataclass(frozen=True) diff --git a/tests/test_manifest_runtime.py b/tests/test_manifest_runtime.py index 829ce67..963fabf 100644 --- a/tests/test_manifest_runtime.py +++ b/tests/test_manifest_runtime.py @@ -1,16 +1,21 @@ -"""Unit: bottle runtime — Manifest.from_json_obj defaults runtime to runc, -accepts runsc, and rejects unknown values, non-strings, and empty strings.""" +"""Unit: bottle 'runtime' field is no longer supported (PRD 0003). +gVisor is now auto-detected by the Docker factory. A manifest carrying +the legacy 'runtime' field must fail loudly with a message pointing the +user at the auto-detect behavior, rather than silently ignoring.""" + +import io +import sys import unittest from claude_bottle.log import Die -from claude_bottle.manifest import Manifest +from claude_bottle.manifest import Bottle, Manifest _ABSENT = object() -def _bottle(runtime_value: object) -> dict: +def _manifest(runtime_value: object) -> dict: """Build a minimal manifest JSON shape with one bottle whose runtime field is set (or absent if `runtime_value is _ABSENT`).""" bottle: dict = {} @@ -22,30 +27,41 @@ def _bottle(runtime_value: object) -> dict: } -class TestManifestBottleRuntime(unittest.TestCase): - def test_default_runc_when_absent(self): - m = Manifest.from_json_obj(_bottle(_ABSENT)) - self.assertEqual("runc", m.bottles["dev"].runtime) +class TestManifestRuntimeRemoved(unittest.TestCase): + def test_loads_when_runtime_absent(self): + m = Manifest.from_json_obj(_manifest(_ABSENT)) + self.assertIn("dev", m.bottles) - def test_explicit_runc(self): - m = Manifest.from_json_obj(_bottle("runc")) - self.assertEqual("runc", m.bottles["dev"].runtime) + def test_bottle_dataclass_has_no_runtime_attribute(self): + """Structural check: the field has been removed from the dataclass.""" + b = Bottle() + self.assertFalse(hasattr(b, "runtime")) - def test_explicit_runsc(self): - m = Manifest.from_json_obj(_bottle("runsc")) - self.assertEqual("runsc", m.bottles["dev"].runtime) + def test_rejects_runsc_value_with_helpful_message(self): + captured = io.StringIO() + old_stderr = sys.stderr + sys.stderr = captured + try: + with self.assertRaises(Die): + Manifest.from_json_obj(_manifest("runsc")) + finally: + sys.stderr = old_stderr + msg = captured.getvalue() + self.assertIn("'runtime'", msg, "error names the field") + self.assertIn("auto-detect", msg, "error points at the new behavior") - def test_rejects_unknown_runtime(self): + def test_rejects_runc_value(self): with self.assertRaises(Die): - Manifest.from_json_obj(_bottle("kata-runtime")) + Manifest.from_json_obj(_manifest("runc")) + + def test_rejects_unknown_value(self): + with self.assertRaises(Die): + Manifest.from_json_obj(_manifest("kata-runtime")) def test_rejects_non_string(self): + """Any presence of the field is an error; type is not consulted.""" with self.assertRaises(Die): - Manifest.from_json_obj(_bottle(42)) - - def test_rejects_empty_string(self): - with self.assertRaises(Die): - Manifest.from_json_obj(_bottle("")) + Manifest.from_json_obj(_manifest(42)) if __name__ == "__main__": From 7500ba230c2437c94bcd5fdf216ebe3d0ab610fd Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 22:20:33 -0400 Subject: [PATCH 03/44] refactor(start): extract show_plan from cmd_start --- claude_bottle/cli/start.py | 87 ++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index 680471a..e777b01 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -10,6 +10,7 @@ import shutil import sys import tempfile from pathlib import Path +from typing import Sequence from .. import docker as docker_mod from .. import pipelock @@ -27,6 +28,50 @@ from ..manifest import Manifest from ._common import PROG, USER_CWD, read_tty_line +def show_plan( + *, + agent_name: str, + image: str, + derived_image: str, + user_cwd: str, + container: str, + stage_dir: Path, + env_names: Sequence[str], + skills: Sequence[str], + docker_runtime: str, + bottle_name: str, + ssh_hosts: Sequence[str], + allowlist_summary: str, + prompt_content: str, + remote_control: bool, +) -> None: + """Render the y/N preflight summary to stderr. Pure presentation; no + side effects beyond writing to stderr.""" + prompt_first_line = prompt_content.splitlines()[0] if prompt_content else "" + print(file=sys.stderr) + info(f"agent : {agent_name}") + info(f"image : {image}") + if derived_image: + info(f"cwd : {user_cwd} -> /home/node/workspace (derived: {derived_image})") + info(f"container : {container}") + info(f"stage dir : {stage_dir}") + info("env (names only): " + (", ".join(env_names) if env_names else "(none)")) + info("skills : " + (" ".join(skills) if skills else "(none)")) + info(f"docker runtime : {docker_runtime}") + info(f"bottle : {bottle_name}") + if ssh_hosts: + info(f" ssh hosts : {', '.join(ssh_hosts)}") + else: + info(" ssh hosts : (none)") + info(f" egress : {allowlist_summary}") + info( + f"prompt : {len(prompt_content)} chars; " + f"first line: {prompt_first_line or '(empty)'}" + ) + info("remote-control : " + ("enabled" if remote_control else "disabled")) + print(file=sys.stderr) + + def cmd_start(argv: list[str]) -> int: parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True) parser.add_argument("--dry-run", action="store_true") @@ -114,35 +159,23 @@ def cmd_start(argv: list[str]) -> int: prompt_content = agent.prompt prompt_file.write_text(prompt_content) - prompt_first_line = prompt_content.splitlines()[0] if prompt_content else "" - # --- Plan + confirm --- - print(file=sys.stderr) - info(f"agent : {name}") - info(f"image : {image}") - if derived_image: - info(f"cwd : {USER_CWD} -> /home/node/workspace (derived: {derived_image})") - info(f"container : {container}") - info(f"stage dir : {stage_dir}") - info( - "env (names only): " - + (", ".join(display_env_names) if display_env_names else "(none)") + show_plan( + agent_name=name, + image=image, + derived_image=derived_image, + user_cwd=USER_CWD, + container=container, + stage_dir=stage_dir, + env_names=display_env_names, + skills=agent.skills, + docker_runtime=docker_runtime_label(), + bottle_name=bottle_name, + ssh_hosts=[e.Host for e in ssh_entries], + allowlist_summary=allowlist_summary, + prompt_content=prompt_content, + remote_control=args.remote_control, ) - info("skills : " + (" ".join(agent.skills) if agent.skills else "(none)")) - info(f"docker runtime : {docker_runtime_label()}") - info(f"bottle : {bottle_name}") - if ssh_entries: - ssh_names = ", ".join(e.Host for e in ssh_entries) - info(f" ssh hosts : {ssh_names}") - else: - info(" ssh hosts : (none)") - info(f" egress : {allowlist_summary}") - info( - f"prompt : {len(prompt_content)} chars; " - f"first line: {prompt_first_line or '(empty)'}" - ) - info("remote-control : " + ("enabled" if args.remote_control else "disabled")) - print(file=sys.stderr) if dry_run: info("dry-run requested; not starting container.") From a284d85296dfe7511369d0e8a6efdfc063bfb05c Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 22:23:40 -0400 Subject: [PATCH 04/44] refactor(start): show_plan now takes DockerBottleSpec --- claude_bottle/cli/start.py | 114 +++++++++++++++---------------------- 1 file changed, 45 insertions(+), 69 deletions(-) diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index e777b01..089c614 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -10,7 +10,6 @@ import shutil import sys import tempfile from pathlib import Path -from typing import Sequence from .. import docker as docker_mod from .. import pipelock @@ -28,44 +27,44 @@ from ..manifest import Manifest from ._common import PROG, USER_CWD, read_tty_line -def show_plan( - *, - agent_name: str, - image: str, - derived_image: str, - user_cwd: str, - container: str, - stage_dir: Path, - env_names: Sequence[str], - skills: Sequence[str], - docker_runtime: str, - bottle_name: str, - ssh_hosts: Sequence[str], - allowlist_summary: str, - prompt_content: str, - remote_control: bool, -) -> None: - """Render the y/N preflight summary to stderr. Pure presentation; no - side effects beyond writing to stderr.""" - prompt_first_line = prompt_content.splitlines()[0] if prompt_content else "" +def show_plan(spec: DockerBottleSpec, *, remote_control: bool) -> None: + """Render the y/N preflight summary to stderr. Pure presentation; + reads manifest-backed fields off `spec` and probes the Docker + runtime label. `remote_control` is the only field not already on + the spec — it's a claude CLI flag, not a bottle property.""" + manifest = spec.manifest + agent = manifest.agents[spec.agent_name] + bottle = manifest.bottle_for(spec.agent_name) + + env_names = list(bottle.env.keys()) + if spec.forward_oauth_token: + env_names.append("CLAUDE_CODE_OAUTH_TOKEN") + + 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 "" + print(file=sys.stderr) - info(f"agent : {agent_name}") - info(f"image : {image}") - if derived_image: - info(f"cwd : {user_cwd} -> /home/node/workspace (derived: {derived_image})") - info(f"container : {container}") - info(f"stage dir : {stage_dir}") + info(f"agent : {spec.agent_name}") + info(f"image : {spec.image}") + if spec.derived_image: + info( + f"cwd : {spec.user_cwd} -> /home/node/workspace " + f"(derived: {spec.derived_image})" + ) + info(f"container : {spec.container_name}") + info(f"stage dir : {spec.stage_dir}") info("env (names only): " + (", ".join(env_names) if env_names else "(none)")) - info("skills : " + (" ".join(skills) if skills else "(none)")) - info(f"docker runtime : {docker_runtime}") - info(f"bottle : {bottle_name}") + info("skills : " + (" ".join(agent.skills) if agent.skills else "(none)")) + info(f"docker runtime : {docker_runtime_label()}") + info(f"bottle : {agent.bottle}") if ssh_hosts: info(f" ssh hosts : {', '.join(ssh_hosts)}") else: info(" ssh hosts : (none)") info(f" egress : {allowlist_summary}") info( - f"prompt : {len(prompt_content)} chars; " + f"prompt : {len(agent.prompt)} chars; " f"first line: {prompt_first_line or '(empty)'}" ) info("remote-control : " + ("enabled" if remote_control else "disabled")) @@ -122,22 +121,15 @@ def cmd_start(argv: list[str]) -> int: f"'docker rm -f '" ) - # --- Plan resolution (host-only, no container yet) --- - env_names = list(bottle.env.keys()) - # 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")) - display_env_names = list(env_names) - if forward_oauth_token: - display_env_names.append("CLAUDE_CODE_OAUTH_TOKEN") if agent.skills: skills_mod.skills_validate_all(list(agent.skills)) - ssh_entries = bottle.ssh - if ssh_entries: - ssh_mod.ssh_validate_entries(ssh_entries) + if bottle.ssh: + ssh_mod.ssh_validate_entries(bottle.ssh) stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage.")) env_file = stage_dir / "agent.env" @@ -153,41 +145,12 @@ def cmd_start(argv: list[str]) -> int: try: pipelock.pipelock_write_yaml(manifest, bottle_name, pipelock_yaml) - allowlist_summary = pipelock.pipelock_allowlist_summary(manifest, bottle_name) env_resolve(manifest, name, env_file, args_file) prompt_content = agent.prompt prompt_file.write_text(prompt_content) - show_plan( - agent_name=name, - image=image, - derived_image=derived_image, - user_cwd=USER_CWD, - container=container, - stage_dir=stage_dir, - env_names=display_env_names, - skills=agent.skills, - docker_runtime=docker_runtime_label(), - bottle_name=bottle_name, - ssh_hosts=[e.Host for e in ssh_entries], - allowlist_summary=allowlist_summary, - prompt_content=prompt_content, - remote_control=args.remote_control, - ) - - if dry_run: - info("dry-run requested; not starting container.") - return 0 - - sys.stderr.write("claude-bottle: launch this agent? [y/N] ") - sys.stderr.flush() - reply = read_tty_line() - if reply not in ("y", "Y", "yes", "YES"): - info("aborted by user") - return 0 - spec = DockerBottleSpec( agent_name=name, slug=slug, @@ -208,6 +171,19 @@ def cmd_start(argv: list[str]) -> int: forward_oauth_token=forward_oauth_token, ) + show_plan(spec, remote_control=args.remote_control) + + if dry_run: + info("dry-run requested; not starting container.") + return 0 + + sys.stderr.write("claude-bottle: launch this agent? [y/N] ") + sys.stderr.flush() + reply = read_tty_line() + if reply not in ("y", "Y", "yes", "YES"): + info("aborted by user") + return 0 + factory = get_bottle_factory() with factory(spec) as bottle_handle: info( From 4f16b3a9e1b8965c7aa8151c8c89a540130a5db7 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 22:36:26 -0400 Subject: [PATCH 05/44] refactor(bottles): split factory into prepare + launch phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- claude_bottle/bottles/__init__.py | 52 ++++-- claude_bottle/bottles/docker.py | 272 ++++++++++++++++++++---------- claude_bottle/cli/start.py | 148 +++------------- 3 files changed, 244 insertions(+), 228 deletions(-) diff --git a/claude_bottle/bottles/__init__.py b/claude_bottle/bottles/__init__.py index 4f48768..0654ed2 100644 --- a/claude_bottle/bottles/__init__.py +++ b/claude_bottle/bottles/__init__.py @@ -1,27 +1,35 @@ """Per-platform bottle factories. A bottle is a running, isolated environment with claude inside. Each -platform exposes a factory (currently only Docker) that owns the -end-to-end lifecycle: image build, container/sidecar launch, file -provisioning, and teardown. +platform exposes two functions: -Selection is driven by the CLAUDE_BOTTLE_PLATFORM env var (default -"docker"). Per PRD 0003 the manifest does not carry a platform field; -the host environment picks. + prepare(spec, stage_dir=...) -> Plan + Resolves names, validates host-side prerequisites, and writes + 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 import os from contextlib import AbstractContextManager +from dataclasses import dataclass from typing import Callable, Protocol from ..log import die -from .docker import create_docker_bottle +from .docker import launch_docker_bottle, prepare_docker_bottle 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 session ends. `cp_in` copies a host path into the bottle. `close` @@ -35,20 +43,30 @@ class Bottle(Protocol): 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] = { - "docker": create_docker_bottle, +_PLATFORMS: dict[str, BottlePlatform] = { + "docker": BottlePlatform( + name="docker", + prepare=prepare_docker_bottle, + launch=launch_docker_bottle, + ), } -def get_bottle_factory() -> BottleFactory: - """Resolve the bottle factory for the active platform. Dies with a - pointer at the known platforms if CLAUDE_BOTTLE_PLATFORM names an +def get_bottle_platform() -> BottlePlatform: + """Resolve the bottle platform for the active environment. Dies with + a pointer at the known platforms if CLAUDE_BOTTLE_PLATFORM names an unimplemented one.""" name = os.environ.get("CLAUDE_BOTTLE_PLATFORM", "docker") - if name not in _FACTORIES: - known = ", ".join(sorted(_FACTORIES)) + if name not in _PLATFORMS: + known = ", ".join(sorted(_PLATFORMS)) die(f"unknown CLAUDE_BOTTLE_PLATFORM={name!r}; known platforms: {known}") - return _FACTORIES[name] + return _PLATFORMS[name] diff --git a/claude_bottle/bottles/docker.py b/claude_bottle/bottles/docker.py index 6cbd5b3..0a0b614 100644 --- a/claude_bottle/bottles/docker.py +++ b/claude_bottle/bottles/docker.py @@ -1,16 +1,18 @@ """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. - 2. Build the base image (and a per-cwd derived image if --cwd). - 3. Create the per-agent internal + egress networks. - 4. Boot the pipelock sidecar on both networks. - 5. Launch the agent container, with `--runtime=runsc` iff available. - 6. Copy the prompt, skills, SSH keys, and (optionally) .git into the - running container. - 7. Yield a `Bottle` handle for `exec_claude` / `cp_in`. - 8. Tear everything down (container, sidecar, both networks) on exit. + prepare_docker_bottle(spec, stage_dir=...) -> DockerBottlePlan + Resolve names, validate host-side prerequisites, and write + scratch files (env_file, args_file, prompt, pipelock yaml) to + stage_dir. No Docker resources are created yet. Suitable to call + before the y/N preflight. + + launch_docker_bottle(plan) -> ContextManager[Bottle] + Build the image, create networks, boot the pipelock sidecar, + 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__`. """ @@ -30,6 +32,7 @@ from .. import network as network_mod from .. import pipelock from .. import skills as skills_mod from .. import ssh as ssh_mod +from ..env_resolve import env_resolve from ..log import die, info from ..manifest import Manifest @@ -39,9 +42,7 @@ from ..manifest import Manifest def runsc_available() -> bool: """Return True if the Docker daemon has the gVisor (`runsc`) runtime - registered. Called twice per `start`: once during the preflight to - render the runtime label, once inside the factory to set `--runtime`. - `docker info` is cheap; the duplication is not worth caching.""" + registered. Called once per prepare; the result lives on the plan.""" r = subprocess.run( ["docker", "info", "--format", "{{json .Runtimes}}"], capture_output=True, @@ -50,58 +51,66 @@ def runsc_available() -> bool: return r.returncode == 0 and "runsc" in r.stdout -def docker_runtime_label() -> str: - """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 ------------------------------------------------------------------ +# --- Spec + Plan ----------------------------------------------------------- @dataclass(frozen=True) class DockerBottleSpec: - """Host-side inputs assembled by the CLI before factory entry. Every - field is a value the factory consumes; nothing here is platform- - agnostic enough yet to lift into a shared spec (only Docker exists).""" + """CLI-supplied inputs to the Docker factory. Small and intent-only; + everything else (image names, container name, scratch file paths, + runsc availability) is resolved by prepare_docker_bottle.""" - agent_name: str - slug: str 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_pinned: bool image: str - derived_image: str # "" -> no derived image - runtime_image: str # image to actually launch (derived or base) - user_cwd: str - copy_cwd_git: bool + derived_image: str # "" -> no derived image + runtime_image: str # image actually launched (derived or base) stage_dir: Path - prompt_file: Path env_file: Path args_file: Path + prompt_file: Path pipelock_yaml_path: Path pipelock_yaml_filename: str - forward_oauth_token: bool + allowlist_summary: str + use_runsc: bool # --- Bottle handle --------------------------------------------------------- class _DockerBottle: - """Concrete Bottle for Docker. Holds the resolved container name and - a teardown closure. Not exported — the factory yields it via the - Bottle Protocol.""" + """Concrete Bottle for Docker. Holds the container name plus the + in-container prompt path so exec_claude can transparently add + --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._teardown = teardown + self._prompt_path = prompt_path_in_container self._closed = False 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"] if tty: cmd.append("-it") - cmd.extend([self.name, "claude", *argv]) + cmd.extend([self.name, "claude", *full_argv]) return subprocess.run(cmd).returncode def cp_in(self, host_path: str, container_path: str) -> None: @@ -118,7 +127,98 @@ class _DockerBottle: 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 '" + ) + + 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. @@ -126,10 +226,8 @@ _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent) @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.""" - # Teardown bookkeeping. Each entry is populated as the matching - # resource comes up; teardown walks them in reverse, idempotently. state: dict[str, str] = { "container": "", "pipelock": "", @@ -147,7 +245,7 @@ def create_docker_bottle(spec: DockerBottleSpec) -> Iterator[_DockerBottle]: ) state["container"] = "" if state["pipelock"]: - pipelock.pipelock_stop(spec.slug) + pipelock.pipelock_stop(plan.slug) state["pipelock"] = "" if state["internal_network"]: network_mod.network_remove(state["internal_network"]) @@ -161,28 +259,28 @@ def create_docker_bottle(spec: DockerBottleSpec) -> Iterator[_DockerBottle]: pass 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) - if spec.derived_image: - 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["internal_network"] = network_mod.network_create_internal(plan.slug) + state["egress_network"] = network_mod.network_create_egress(plan.slug) state["pipelock"] = pipelock.pipelock_start( - spec.slug, + plan.slug, state["internal_network"], state["egress_network"], - spec.stage_dir, - spec.pipelock_yaml_filename, + plan.stage_dir, + 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 - _provision_container(spec, container) + prompt_path = _provision_container(plan, container) - bottle = _DockerBottle(container, teardown) + bottle = _DockerBottle(container, teardown, prompt_path) yield bottle finally: teardown() @@ -191,30 +289,26 @@ def create_docker_bottle(spec: DockerBottleSpec) -> Iterator[_DockerBottle]: # --- Internals ------------------------------------------------------------- -def _run_agent_container( - spec: DockerBottleSpec, - internal_network: str, - use_runsc: bool, -) -> str: +def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str: """Build the `docker run` argv and execute it, handling name-conflict races by incrementing the suffix (unless the name was user-pinned). 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] = [ "--rm", "-d", - "--name", spec.container_name, + "--name", plan.container_name, "--network", internal_network, "-e", f"HTTPS_PROXY={proxy_url}", "-e", f"HTTP_PROXY={proxy_url}", "-e", "NO_PROXY=localhost,127.0.0.1", ] - if use_runsc: + if plan.use_runsc: docker_args.extend(["--runtime", "runsc"]) - if spec.env_file.stat().st_size > 0: - docker_args.extend(["--env-file", str(spec.env_file)]) + if plan.env_file.stat().st_size > 0: + docker_args.extend(["--env-file", str(plan.env_file)]) # 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 while i < len(args_lines): flag = args_lines[i] @@ -227,16 +321,16 @@ def _run_agent_container( i += 1 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"] 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 - base_name = spec.container_name + container = plan.container_name + base_name = plan.container_name suffix = 2 while True: run_result = subprocess.run( @@ -247,7 +341,7 @@ def _run_agent_container( if run_result.returncode == 0: return container 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") die(f"docker run failed for container '{container}'") if suffix > 100: @@ -262,44 +356,45 @@ def _run_agent_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 - running container, fixing up ownership/mode where the host UID - would otherwise leave files unreadable by the in-container `node`.""" + running container. Returns the in-container prompt path if a prompt + 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_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" + in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" 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, check=True, ) # `docker cp` preserves host UID; re-own/mode as root so node can # read its own mode-600 prompt regardless of host UID. 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, check=True, ) 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, check=True, ) - agent = spec.manifest.agents[spec.agent_name] + agent = plan.spec.manifest.agents[plan.spec.agent_name] if 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: - proxy_host_port = pipelock.pipelock_proxy_host_port(spec.slug) - ssh_mod.ssh_setup(container, spec.stage_dir, proxy_host_port, bottle.ssh) + proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) + ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) - if spec.copy_cwd_git: - info(f"copying {spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") + if plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir(): + info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") 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, check=True, ) @@ -312,9 +407,4 @@ def _provision_container(spec: DockerBottleSpec, container: str) -> None: check=True, ) - -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" + return in_container_prompt_path if agent.prompt else None diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index 089c614..c63a3c9 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -11,27 +11,17 @@ import sys import tempfile from pathlib import Path -from .. import docker as docker_mod -from .. import pipelock -from .. import skills as skills_mod -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 ..bottles import get_bottle_platform +from ..bottles.docker import DockerBottlePlan, DockerBottleSpec +from ..log import info from ..manifest import Manifest from ._common import PROG, USER_CWD, read_tty_line -def show_plan(spec: DockerBottleSpec, *, remote_control: bool) -> None: - """Render the y/N preflight summary to stderr. Pure presentation; - reads manifest-backed fields off `spec` and probes the Docker - runtime label. `remote_control` is the only field not already on - the spec — it's a claude CLI flag, not a bottle property.""" +def show_plan(plan: DockerBottlePlan, *, remote_control: bool) -> None: + """Render the y/N preflight summary to stderr. Reads everything off + the plan; pure presentation.""" + spec = plan.spec manifest = spec.manifest agent = manifest.agents[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") 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 "" + runtime_label = "runsc (gVisor)" if plan.use_runsc else "runc (default)" print(file=sys.stderr) info(f"agent : {spec.agent_name}") - info(f"image : {spec.image}") - if spec.derived_image: + info(f"image : {plan.image}") + if plan.derived_image: info( 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"stage dir : {spec.stage_dir}") + info(f"container : {plan.container_name}") + info(f"stage dir : {plan.stage_dir}") info("env (names only): " + (", ".join(env_names) if env_names 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}") if ssh_hosts: info(f" ssh hosts : {', '.join(ssh_hosts)}") else: info(" ssh hosts : (none)") - info(f" egress : {allowlist_summary}") + info(f" egress : {plan.allowlist_summary}") info( f"prompt : {len(agent.prompt)} chars; " 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" - 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.require_agent(name) - agent = manifest.agents[name] - bottle_name = agent.bottle - bottle = manifest.bottle_for(name) - - container = pinned_container or default_container - 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 '" - ) - - # 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) + spec = DockerBottleSpec( + manifest=manifest, + agent_name=args.name, + copy_cwd=args.cwd, + user_cwd=USER_CWD, + forward_oauth_token=bool(os.environ.get("CLAUDE_BOTTLE_OAUTH_TOKEN")), + ) 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: - pipelock.pipelock_write_yaml(manifest, bottle_name, pipelock_yaml) - - env_resolve(manifest, name, env_file, args_file) - - 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) + platform = get_bottle_platform() + plan = platform.prepare(spec, stage_dir=stage_dir) + show_plan(plan, remote_control=args.remote_control) if dry_run: info("dry-run requested; not starting container.") @@ -184,8 +97,7 @@ def cmd_start(argv: list[str]) -> int: info("aborted by user") return 0 - factory = get_bottle_factory() - with factory(spec) as bottle_handle: + with platform.launch(plan) as bottle: info( "attaching interactive claude session " "(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"] if args.remote_control: claude_args.append("--remote-control") - if prompt_content: - claude_args.extend( - ["--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") + bottle.exec_claude(claude_args, tty=True) + info(f"session ended; container {bottle.name} will be removed") return 0 finally: shutil.rmtree(stage_dir, ignore_errors=True) From 236c4fa50cd64715c9b25dd476d04e785491bb8a Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 22:40:19 -0400 Subject: [PATCH 06/44] refactor(bottles): rename DockerBottleSpec to BottleSpec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec is intent-only and platform-agnostic — only the plan carries Docker-specific fields. Drop the 'Docker' prefix and re-export from claude_bottle.bottles so callers see it as cross-platform. --- claude_bottle/bottles/__init__.py | 4 +++- claude_bottle/bottles/docker.py | 13 +++++++------ claude_bottle/cli/start.py | 6 +++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/claude_bottle/bottles/__init__.py b/claude_bottle/bottles/__init__.py index 0654ed2..bc8203e 100644 --- a/claude_bottle/bottles/__init__.py +++ b/claude_bottle/bottles/__init__.py @@ -25,7 +25,9 @@ from dataclasses import dataclass from typing import Callable, Protocol from ..log import die -from .docker import launch_docker_bottle, prepare_docker_bottle +from .docker import BottleSpec, launch_docker_bottle, prepare_docker_bottle + +__all__ = ["Bottle", "BottlePlatform", "BottleSpec", "get_bottle_platform"] class Bottle(Protocol): diff --git a/claude_bottle/bottles/docker.py b/claude_bottle/bottles/docker.py index 0a0b614..751b1c1 100644 --- a/claude_bottle/bottles/docker.py +++ b/claude_bottle/bottles/docker.py @@ -55,10 +55,11 @@ def runsc_available() -> bool: @dataclass(frozen=True) -class DockerBottleSpec: - """CLI-supplied inputs to the Docker factory. Small and intent-only; - everything else (image names, container name, scratch file paths, - runsc availability) is resolved by prepare_docker_bottle.""" +class BottleSpec: + """CLI-supplied intent. Platform-agnostic — each platform's prepare + step consumes it and produces its own platform-specific plan. + Resolved values (image names, container name, scratch paths, runsc + availability) live on the plan, not the spec.""" manifest: Manifest agent_name: str @@ -72,7 +73,7 @@ class DockerBottlePlan: """Output of prepare_docker_bottle. Frozen; the launch step consumes it without further resolution. show_plan reads from it directly.""" - spec: DockerBottleSpec + spec: BottleSpec slug: str container_name: str container_name_pinned: bool @@ -130,7 +131,7 @@ class _DockerBottle: # --- Prepare --------------------------------------------------------------- -def prepare_docker_bottle(spec: DockerBottleSpec, *, stage_dir: Path) -> DockerBottlePlan: +def prepare_docker_bottle(spec: BottleSpec, *, 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`.""" diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index c63a3c9..8c7168c 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -11,8 +11,8 @@ import sys import tempfile from pathlib import Path -from ..bottles import get_bottle_platform -from ..bottles.docker import DockerBottlePlan, DockerBottleSpec +from ..bottles import BottleSpec, get_bottle_platform +from ..bottles.docker import DockerBottlePlan from ..log import info from ..manifest import Manifest from ._common import PROG, USER_CWD, read_tty_line @@ -72,7 +72,7 @@ def cmd_start(argv: list[str]) -> int: dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1" manifest = Manifest.resolve(USER_CWD) - spec = DockerBottleSpec( + spec = BottleSpec( manifest=manifest, agent_name=args.name, copy_cwd=args.cwd, From 2827d9b89968c3ac551d9ee44382b706c589a804 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 22:49:57 -0400 Subject: [PATCH 07/44] refactor(bottles): introduce BottlePlan base + move print onto plan - Add BottlePlan (frozen dataclass + ABC) with spec, stage_dir, and an abstract `print(*, remote_control)` method. - DockerBottlePlan now inherits from BottlePlan; spec/stage_dir come from the base, Docker-specific fields stay on the subclass. - Move BottleSpec from bottles/docker.py to bottles/__init__.py so the cross-platform types live together. docker.py pulls them via `from . import ...`. - Move show_plan from cli/start.py to `DockerBottlePlan.print`. Caller becomes `plan.print(remote_control=...)`. The CLI no longer reads any Docker-specific fields. - BottlePlatform.prepare is now typed `Callable[..., BottlePlan]`. cmd_start drops ~46 more lines. --- claude_bottle/bottles/__init__.py | 51 +++++++++++++++++++++-- claude_bottle/bottles/docker.py | 67 +++++++++++++++++++++---------- claude_bottle/cli/start.py | 46 +-------------------- 3 files changed, 94 insertions(+), 70 deletions(-) diff --git a/claude_bottle/bottles/__init__.py b/claude_bottle/bottles/__init__.py index bc8203e..d629516 100644 --- a/claude_bottle/bottles/__init__.py +++ b/claude_bottle/bottles/__init__.py @@ -3,7 +3,7 @@ A bottle is a running, isolated environment with claude inside. Each platform exposes two functions: - prepare(spec, stage_dir=...) -> Plan + prepare(spec, stage_dir=...) -> BottlePlan Resolves names, validates host-side prerequisites, and writes scratch files. No remote/runtime resources are created yet. Safe to call before the y/N preflight. @@ -20,14 +20,48 @@ environment picks. from __future__ import annotations import os +from abc import ABC, abstractmethod from contextlib import AbstractContextManager from dataclasses import dataclass +from pathlib import Path from typing import Callable, Protocol from ..log import die -from .docker import BottleSpec, launch_docker_bottle, prepare_docker_bottle +from ..manifest import Manifest -__all__ = ["Bottle", "BottlePlatform", "BottleSpec", "get_bottle_platform"] + +@dataclass(frozen=True) +class BottleSpec: + """CLI-supplied intent. Platform-agnostic — each platform's prepare + step consumes it and produces its own platform-specific plan. + Resolved values (image names, container name, scratch paths, runsc + availability) live on the plan, not the spec.""" + + manifest: Manifest + agent_name: str + copy_cwd: bool + user_cwd: str + forward_oauth_token: bool + + +@dataclass(frozen=True) +class BottlePlan(ABC): + """Base output of a platform's prepare step. Concrete subclasses + (e.g. DockerBottlePlan) add platform-specific resolved fields and + implement `print`.""" + + spec: BottleSpec + stage_dir: Path + + @abstractmethod + def print(self, *, remote_control: bool) -> None: + """Render the y/N preflight summary to stderr.""" + + +# Import concrete platform factories AFTER the base types are defined, +# so each platform module can pull BottleSpec / BottlePlan via +# `from . import ...` without hitting a partially-initialized module. +from .docker import launch_docker_bottle, prepare_docker_bottle # noqa: E402 class Bottle(Protocol): @@ -50,7 +84,7 @@ class BottlePlatform: """Bundles a platform's two-phase factory under one selectable name.""" name: str - prepare: Callable[..., object] + prepare: Callable[..., BottlePlan] launch: Callable[..., AbstractContextManager[Bottle]] @@ -72,3 +106,12 @@ def get_bottle_platform() -> BottlePlatform: known = ", ".join(sorted(_PLATFORMS)) die(f"unknown CLAUDE_BOTTLE_PLATFORM={name!r}; known platforms: {known}") return _PLATFORMS[name] + + +__all__ = [ + "Bottle", + "BottlePlan", + "BottlePlatform", + "BottleSpec", + "get_bottle_platform", +] diff --git a/claude_bottle/bottles/docker.py b/claude_bottle/bottles/docker.py index 751b1c1..dffc8c4 100644 --- a/claude_bottle/bottles/docker.py +++ b/claude_bottle/bottles/docker.py @@ -34,7 +34,7 @@ from .. import skills as skills_mod from .. import ssh as ssh_mod from ..env_resolve import env_resolve from ..log import die, info -from ..manifest import Manifest +from . import BottlePlan, BottleSpec # --- Runtime detection ----------------------------------------------------- @@ -51,36 +51,20 @@ def runsc_available() -> bool: return r.returncode == 0 and "runsc" in r.stdout -# --- Spec + Plan ----------------------------------------------------------- +# --- Plan ------------------------------------------------------------------ @dataclass(frozen=True) -class BottleSpec: - """CLI-supplied intent. Platform-agnostic — each platform's prepare - step consumes it and produces its own platform-specific plan. - Resolved values (image names, container name, scratch paths, runsc - availability) live on the plan, not the spec.""" +class DockerBottlePlan(BottlePlan): + """Docker-specific resolved fields produced by prepare_docker_bottle. + Inherits `spec` and `stage_dir` from BottlePlan.""" - 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: BottleSpec slug: str container_name: str container_name_pinned: bool image: str derived_image: str # "" -> no derived image runtime_image: str # image actually launched (derived or base) - stage_dir: Path env_file: Path args_file: Path prompt_file: Path @@ -89,6 +73,47 @@ class DockerBottlePlan: allowlist_summary: str use_runsc: bool + def print(self, *, remote_control: bool) -> None: + """Render the y/N preflight summary to stderr. Pure presentation.""" + spec = self.spec + manifest = spec.manifest + agent = manifest.agents[spec.agent_name] + bottle = manifest.bottle_for(spec.agent_name) + + env_names = list(bottle.env.keys()) + if spec.forward_oauth_token: + env_names.append("CLAUDE_CODE_OAUTH_TOKEN") + + ssh_hosts = [e.Host for e in bottle.ssh] + prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else "" + runtime_label = "runsc (gVisor)" if self.use_runsc else "runc (default)" + + print(file=sys.stderr) + info(f"agent : {spec.agent_name}") + info(f"image : {self.image}") + if self.derived_image: + info( + f"cwd : {spec.user_cwd} -> /home/node/workspace " + f"(derived: {self.derived_image})" + ) + info(f"container : {self.container_name}") + info(f"stage dir : {self.stage_dir}") + info("env (names only): " + (", ".join(env_names) if env_names else "(none)")) + info("skills : " + (" ".join(agent.skills) if agent.skills else "(none)")) + info(f"docker runtime : {runtime_label}") + info(f"bottle : {agent.bottle}") + if ssh_hosts: + info(f" ssh hosts : {', '.join(ssh_hosts)}") + else: + info(" ssh hosts : (none)") + info(f" egress : {self.allowlist_summary}") + info( + f"prompt : {len(agent.prompt)} chars; " + f"first line: {prompt_first_line or '(empty)'}" + ) + info("remote-control : " + ("enabled" if remote_control else "disabled")) + print(file=sys.stderr) + # --- Bottle handle --------------------------------------------------------- diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index 8c7168c..962afb9 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -12,55 +12,11 @@ import tempfile from pathlib import Path from ..bottles import BottleSpec, get_bottle_platform -from ..bottles.docker import DockerBottlePlan from ..log import info from ..manifest import Manifest from ._common import PROG, USER_CWD, read_tty_line -def show_plan(plan: DockerBottlePlan, *, remote_control: bool) -> None: - """Render the y/N preflight summary to stderr. Reads everything off - the plan; pure presentation.""" - spec = plan.spec - manifest = spec.manifest - agent = manifest.agents[spec.agent_name] - bottle = manifest.bottle_for(spec.agent_name) - - env_names = list(bottle.env.keys()) - if spec.forward_oauth_token: - env_names.append("CLAUDE_CODE_OAUTH_TOKEN") - - ssh_hosts = [e.Host for e in bottle.ssh] - 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) - info(f"agent : {spec.agent_name}") - info(f"image : {plan.image}") - if plan.derived_image: - info( - f"cwd : {spec.user_cwd} -> /home/node/workspace " - f"(derived: {plan.derived_image})" - ) - info(f"container : {plan.container_name}") - info(f"stage dir : {plan.stage_dir}") - info("env (names only): " + (", ".join(env_names) if env_names else "(none)")) - info("skills : " + (" ".join(agent.skills) if agent.skills else "(none)")) - info(f"docker runtime : {runtime_label}") - info(f"bottle : {agent.bottle}") - if ssh_hosts: - info(f" ssh hosts : {', '.join(ssh_hosts)}") - else: - info(" ssh hosts : (none)") - info(f" egress : {plan.allowlist_summary}") - info( - f"prompt : {len(agent.prompt)} chars; " - f"first line: {prompt_first_line or '(empty)'}" - ) - info("remote-control : " + ("enabled" if remote_control else "disabled")) - print(file=sys.stderr) - - def cmd_start(argv: list[str]) -> int: parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True) parser.add_argument("--dry-run", action="store_true") @@ -84,7 +40,7 @@ def cmd_start(argv: list[str]) -> int: try: platform = get_bottle_platform() plan = platform.prepare(spec, stage_dir=stage_dir) - show_plan(plan, remote_control=args.remote_control) + plan.print(remote_control=args.remote_control) if dry_run: info("dry-run requested; not starting container.") From e22a96e511b54200ce497472a8faea59d372d557 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 22:56:47 -0400 Subject: [PATCH 08/44] refactor(bottles): BottlePlatform becomes ABC; DockerBottlePlatform in docker.py Mirror the BottlePlan -> DockerBottlePlan hierarchy at the platform layer. BottlePlatform is now an abstract base with abstract `prepare` and `launch` methods; DockerBottlePlatform lives alongside the rest of the Docker code in bottles/docker.py and supplies the concrete impls. The registry in bottles/__init__.py now holds an instance of each concrete platform class. Future per-platform state (region, api token, cleanup primitives) has a natural home on the subclass rather than being stitched onto a dataclass struct. No behavior change. Tests pass; dry-run output unchanged. --- claude_bottle/bottles/__init__.py | 38 +++++++++++++++++-------------- claude_bottle/bottles/docker.py | 25 ++++++++++++++++++-- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/claude_bottle/bottles/__init__.py b/claude_bottle/bottles/__init__.py index d629516..0c7c575 100644 --- a/claude_bottle/bottles/__init__.py +++ b/claude_bottle/bottles/__init__.py @@ -24,7 +24,7 @@ from abc import ABC, abstractmethod from contextlib import AbstractContextManager from dataclasses import dataclass from pathlib import Path -from typing import Callable, Protocol +from typing import Protocol from ..log import die from ..manifest import Manifest @@ -58,12 +58,6 @@ class BottlePlan(ABC): """Render the y/N preflight summary to stderr.""" -# Import concrete platform factories AFTER the base types are defined, -# so each platform module can pull BottleSpec / BottlePlan via -# `from . import ...` without hitting a partially-initialized module. -from .docker import launch_docker_bottle, prepare_docker_bottle # noqa: E402 - - class Bottle(Protocol): """Handle to a running bottle. Yielded by a platform's launch step. @@ -79,21 +73,31 @@ class Bottle(Protocol): def close(self) -> None: ... -@dataclass(frozen=True) -class BottlePlatform: - """Bundles a platform's two-phase factory under one selectable name.""" +class BottlePlatform(ABC): + """Abstract base for selectable bottle platforms. Concrete subclasses + (e.g. DockerBottlePlatform) own their own prepare/launch impls. + Symmetric with the BottlePlan → DockerBottlePlan hierarchy.""" name: str - prepare: Callable[..., BottlePlan] - launch: Callable[..., AbstractContextManager[Bottle]] + + @abstractmethod + def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> BottlePlan: + """Resolve names, validate host-side prerequisites, write + scratch files. No remote/runtime resources created yet.""" + + @abstractmethod + def launch(self, plan: BottlePlan) -> AbstractContextManager[Bottle]: + """Build/run the bottle and yield a handle; tear down on exit.""" + + +# Import concrete platform classes AFTER the base types are defined, so +# each platform module can pull BottleSpec / BottlePlan / BottlePlatform +# via `from . import ...` without hitting a partially-initialized module. +from .docker import DockerBottlePlatform # noqa: E402 _PLATFORMS: dict[str, BottlePlatform] = { - "docker": BottlePlatform( - name="docker", - prepare=prepare_docker_bottle, - launch=launch_docker_bottle, - ), + "docker": DockerBottlePlatform(), } diff --git a/claude_bottle/bottles/docker.py b/claude_bottle/bottles/docker.py index dffc8c4..c41a56b 100644 --- a/claude_bottle/bottles/docker.py +++ b/claude_bottle/bottles/docker.py @@ -22,7 +22,7 @@ from __future__ import annotations import os import subprocess import sys -from contextlib import contextmanager +from contextlib import AbstractContextManager, contextmanager from dataclasses import dataclass from pathlib import Path from typing import Iterator @@ -34,7 +34,7 @@ from .. import skills as skills_mod from .. import ssh as ssh_mod from ..env_resolve import env_resolve from ..log import die, info -from . import BottlePlan, BottleSpec +from . import BottlePlan, BottlePlatform, BottleSpec # --- Runtime detection ----------------------------------------------------- @@ -434,3 +434,24 @@ def _provision_container(plan: DockerBottlePlan, container: str) -> str | None: ) return in_container_prompt_path if agent.prompt else None + + +# --- Platform -------------------------------------------------------------- + + +class DockerBottlePlatform(BottlePlatform): + """Docker platform implementation. Selected by CLAUDE_BOTTLE_PLATFORM + (default). The methods delegate to the module-level prepare/launch + functions so the platform class itself stays a thin dispatch layer.""" + + name = "docker" + + def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> BottlePlan: + return prepare_docker_bottle(spec, stage_dir=stage_dir) + + def launch(self, plan: BottlePlan) -> AbstractContextManager[_DockerBottle]: + assert isinstance(plan, DockerBottlePlan), ( + f"DockerBottlePlatform.launch expects DockerBottlePlan, " + f"got {type(plan).__name__}" + ) + return launch_docker_bottle(plan) From 7ab35a5e2ac3b8c8c9cbe72a7012a13471ae9f77 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 23:00:07 -0400 Subject: [PATCH 09/44] refactor(bottles): absorb prepare/launch fns into DockerBottlePlatform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module-level prepare_docker_bottle and launch_docker_bottle were trivial delegations from the platform class. Move their bodies onto DockerBottlePlatform.prepare and .launch directly. The launch method keeps the @contextmanager decorator; isinstance narrows BottlePlan to DockerBottlePlan at the entry point. Internal helpers (_run_agent_container, _provision_container, runsc_available) stay at module scope — they're stateless and don't benefit from self. No behavior change. Tests pass; dry-run output unchanged. --- claude_bottle/bottles/docker.py | 333 +++++++++++++++----------------- 1 file changed, 160 insertions(+), 173 deletions(-) diff --git a/claude_bottle/bottles/docker.py b/claude_bottle/bottles/docker.py index c41a56b..17cd985 100644 --- a/claude_bottle/bottles/docker.py +++ b/claude_bottle/bottles/docker.py @@ -1,14 +1,14 @@ -"""Docker bottle factory. +"""Docker bottle platform. -Two phases: +DockerBottlePlatform owns the two-phase factory: - prepare_docker_bottle(spec, stage_dir=...) -> DockerBottlePlan + .prepare(spec, stage_dir=...) -> DockerBottlePlan Resolve names, validate host-side prerequisites, and write scratch files (env_file, args_file, prompt, pipelock yaml) to stage_dir. No Docker resources are created yet. Suitable to call before the y/N preflight. - launch_docker_bottle(plan) -> ContextManager[Bottle] + .launch(plan) -> ContextManager[_DockerBottle] Build the image, create networks, boot the pipelock sidecar, launch the agent container (with `--runtime=runsc` iff the daemon has gVisor registered), and copy prompt/skills/ssh/.git @@ -22,7 +22,7 @@ from __future__ import annotations import os import subprocess import sys -from contextlib import AbstractContextManager, contextmanager +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from typing import Iterator @@ -37,6 +37,10 @@ from ..log import die, info from . import BottlePlan, BottlePlatform, BottleSpec +# Where the repo root lives, for `docker build` context. Computed once. +_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent) + + # --- Runtime detection ----------------------------------------------------- @@ -56,8 +60,9 @@ def runsc_available() -> bool: @dataclass(frozen=True) class DockerBottlePlan(BottlePlan): - """Docker-specific resolved fields produced by prepare_docker_bottle. - Inherits `spec` and `stage_dir` from BottlePlan.""" + """Docker-specific resolved fields produced by + DockerBottlePlatform.prepare. Inherits `spec` and `stage_dir` from + BottlePlan.""" slug: str container_name: str @@ -153,166 +158,7 @@ class _DockerBottle: self._teardown() -# --- Prepare --------------------------------------------------------------- - - -def prepare_docker_bottle(spec: BottleSpec, *, 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 '" - ) - - 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. -_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent) - - -@contextmanager -def launch_docker_bottle(plan: DockerBottlePlan) -> Iterator[_DockerBottle]: - """Build, launch, and provision a Docker bottle. Teardown on exit.""" - state: dict[str, str] = { - "container": "", - "pipelock": "", - "internal_network": "", - "egress_network": "", - } - - def teardown() -> None: - try: - if state["container"] and docker_mod.container_exists(state["container"]): - subprocess.run( - ["docker", "rm", "-f", state["container"]], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - state["container"] = "" - if state["pipelock"]: - pipelock.pipelock_stop(plan.slug) - state["pipelock"] = "" - if state["internal_network"]: - network_mod.network_remove(state["internal_network"]) - state["internal_network"] = "" - if state["egress_network"]: - network_mod.network_remove(state["egress_network"]) - state["egress_network"] = "" - except BaseException: - # Teardown must not raise; swallow so the caller's __exit__ - # path can still propagate the original error. - pass - - try: - 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 - ) - - state["internal_network"] = network_mod.network_create_internal(plan.slug) - state["egress_network"] = network_mod.network_create_egress(plan.slug) - state["pipelock"] = pipelock.pipelock_start( - plan.slug, - state["internal_network"], - state["egress_network"], - plan.stage_dir, - plan.pipelock_yaml_filename, - ) - - container = _run_agent_container(plan, state["internal_network"]) - state["container"] = container - - prompt_path = _provision_container(plan, container) - - bottle = _DockerBottle(container, teardown, prompt_path) - yield bottle - finally: - teardown() - - -# --- Internals ------------------------------------------------------------- +# --- Container internals --------------------------------------------------- def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str: @@ -441,17 +287,158 @@ def _provision_container(plan: DockerBottlePlan, container: str) -> str | None: class DockerBottlePlatform(BottlePlatform): """Docker platform implementation. Selected by CLAUDE_BOTTLE_PLATFORM - (default). The methods delegate to the module-level prepare/launch - functions so the platform class itself stays a thin dispatch layer.""" + (default).""" name = "docker" - def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> BottlePlan: - return prepare_docker_bottle(spec, stage_dir=stage_dir) + def prepare(self, spec: BottleSpec, *, 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() - def launch(self, plan: BottlePlan) -> AbstractContextManager[_DockerBottle]: + 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 '" + ) + + 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, + stage_dir=stage_dir, + slug=slug, + container_name=container_name, + container_name_pinned=container_name_pinned, + image=image, + derived_image=derived_image, + runtime_image=runtime_image, + 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, + ) + + @contextmanager + def launch(self, plan: BottlePlan) -> Iterator[_DockerBottle]: + """Build, launch, and provision a Docker bottle. Teardown on exit.""" assert isinstance(plan, DockerBottlePlan), ( f"DockerBottlePlatform.launch expects DockerBottlePlan, " f"got {type(plan).__name__}" ) - return launch_docker_bottle(plan) + + state: dict[str, str] = { + "container": "", + "pipelock": "", + "internal_network": "", + "egress_network": "", + } + + def teardown() -> None: + try: + if state["container"] and docker_mod.container_exists(state["container"]): + subprocess.run( + ["docker", "rm", "-f", state["container"]], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + state["container"] = "" + if state["pipelock"]: + pipelock.pipelock_stop(plan.slug) + state["pipelock"] = "" + if state["internal_network"]: + network_mod.network_remove(state["internal_network"]) + state["internal_network"] = "" + if state["egress_network"]: + network_mod.network_remove(state["egress_network"]) + state["egress_network"] = "" + except BaseException: + # Teardown must not raise; swallow so the caller's + # __exit__ path can still propagate the original error. + pass + + try: + 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 + ) + + state["internal_network"] = network_mod.network_create_internal(plan.slug) + state["egress_network"] = network_mod.network_create_egress(plan.slug) + state["pipelock"] = pipelock.pipelock_start( + plan.slug, + state["internal_network"], + state["egress_network"], + plan.stage_dir, + plan.pipelock_yaml_filename, + ) + + container = _run_agent_container(plan, state["internal_network"]) + state["container"] = container + + prompt_path = _provision_container(plan, container) + + bottle = _DockerBottle(container, teardown, prompt_path) + yield bottle + finally: + teardown() From 5f8204440380c18c89e75bee01c46ad26a43e5e8 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 23:02:21 -0400 Subject: [PATCH 10/44] refactor(bottles): move _run_agent_container and _provision_container onto the platform class They were the only remaining module-level helpers in docker.py besides runsc_available(); promote them to private methods on DockerBottlePlatform so all the Docker orchestration logic lives in one place. --- claude_bottle/bottles/docker.py | 248 ++++++++++++++++---------------- 1 file changed, 122 insertions(+), 126 deletions(-) diff --git a/claude_bottle/bottles/docker.py b/claude_bottle/bottles/docker.py index 17cd985..0e1b0ce 100644 --- a/claude_bottle/bottles/docker.py +++ b/claude_bottle/bottles/docker.py @@ -158,130 +158,6 @@ class _DockerBottle: self._teardown() -# --- Container internals --------------------------------------------------- - - -def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str: - """Build the `docker run` argv and execute it, handling name-conflict - races by incrementing the suffix (unless the name was user-pinned). - Returns the resolved container name.""" - proxy_url = pipelock.pipelock_proxy_url(plan.slug) - docker_args: list[str] = [ - "--rm", "-d", - "--name", plan.container_name, - "--network", internal_network, - "-e", f"HTTPS_PROXY={proxy_url}", - "-e", f"HTTP_PROXY={proxy_url}", - "-e", "NO_PROXY=localhost,127.0.0.1", - ] - if plan.use_runsc: - docker_args.extend(["--runtime", "runsc"]) - if plan.env_file.stat().st_size > 0: - docker_args.extend(["--env-file", str(plan.env_file)]) - - # ARGS_FILE pairs (-e, NAME) line-by-line. - args_lines = plan.args_file.read_text().splitlines() - i = 0 - while i < len(args_lines): - flag = args_lines[i] - i += 1 - if not flag: - continue - if i >= len(args_lines): - break - vname = args_lines[i] - i += 1 - docker_args.extend([flag, vname]) - - if plan.spec.forward_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([plan.runtime_image, "sleep", "infinity"]) - - info(f"starting container {plan.container_name} from {plan.runtime_image}") - - container = plan.container_name - base_name = plan.container_name - suffix = 2 - while True: - run_result = subprocess.run( - ["docker", "run", *docker_args], - capture_output=True, - text=True, - ) - if run_result.returncode == 0: - return container - err_text = run_result.stderr - if plan.container_name_pinned or "is already in use" not in err_text: - sys.stderr.write(err_text + "\n") - die(f"docker run failed for container '{container}'") - if suffix > 100: - die( - f"could not find a free container name after " - f"{base_name}-99 retries; clean up old containers" - ) - container = f"{base_name}-{suffix}" - suffix += 1 - name_idx = docker_args.index("--name") + 1 - docker_args[name_idx] = container - info(f"name conflict; retrying as {container}") - - -def _provision_container(plan: DockerBottlePlan, container: str) -> str | None: - """Copy prompt, skills, ssh keys, and (optionally) .git into the - running container. Returns the in-container prompt path if a prompt - 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") - in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" - - subprocess.run( - ["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"], - stdout=subprocess.DEVNULL, - check=True, - ) - # `docker cp` preserves host UID; re-own/mode as root so node can - # read its own mode-600 prompt regardless of host UID. - subprocess.run( - ["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - ["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path], - stdout=subprocess.DEVNULL, - check=True, - ) - - agent = plan.spec.manifest.agents[plan.spec.agent_name] - if agent.skills: - skills_mod.skills_copy_into(container, list(agent.skills)) - - bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) - if bottle.ssh: - proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) - ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) - - if plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir(): - info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") - subprocess.run( - ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - [ - "docker", "exec", "-u", "0", container, - "chown", "-R", "node:node", "/home/node/workspace/.git", - ], - stdout=subprocess.DEVNULL, - check=True, - ) - - return in_container_prompt_path if agent.prompt else None - - # --- Platform -------------------------------------------------------------- @@ -433,12 +309,132 @@ class DockerBottlePlatform(BottlePlatform): plan.pipelock_yaml_filename, ) - container = _run_agent_container(plan, state["internal_network"]) + container = self._run_agent_container(plan, state["internal_network"]) state["container"] = container - prompt_path = _provision_container(plan, container) + prompt_path = self._provision_container(plan, container) bottle = _DockerBottle(container, teardown, prompt_path) yield bottle finally: teardown() + + def _run_agent_container(self, plan: DockerBottlePlan, internal_network: str) -> str: + """Build the `docker run` argv and execute it, handling + name-conflict races by incrementing the suffix (unless the name + was user-pinned). Returns the resolved container name.""" + proxy_url = pipelock.pipelock_proxy_url(plan.slug) + docker_args: list[str] = [ + "--rm", "-d", + "--name", plan.container_name, + "--network", internal_network, + "-e", f"HTTPS_PROXY={proxy_url}", + "-e", f"HTTP_PROXY={proxy_url}", + "-e", "NO_PROXY=localhost,127.0.0.1", + ] + if plan.use_runsc: + docker_args.extend(["--runtime", "runsc"]) + if plan.env_file.stat().st_size > 0: + docker_args.extend(["--env-file", str(plan.env_file)]) + + # ARGS_FILE pairs (-e, NAME) line-by-line. + args_lines = plan.args_file.read_text().splitlines() + i = 0 + while i < len(args_lines): + flag = args_lines[i] + i += 1 + if not flag: + continue + if i >= len(args_lines): + break + vname = args_lines[i] + i += 1 + docker_args.extend([flag, vname]) + + if plan.spec.forward_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([plan.runtime_image, "sleep", "infinity"]) + + info(f"starting container {plan.container_name} from {plan.runtime_image}") + + container = plan.container_name + base_name = plan.container_name + suffix = 2 + while True: + run_result = subprocess.run( + ["docker", "run", *docker_args], + capture_output=True, + text=True, + ) + if run_result.returncode == 0: + return container + err_text = run_result.stderr + if plan.container_name_pinned or "is already in use" not in err_text: + sys.stderr.write(err_text + "\n") + die(f"docker run failed for container '{container}'") + if suffix > 100: + die( + f"could not find a free container name after " + f"{base_name}-99 retries; clean up old containers" + ) + container = f"{base_name}-{suffix}" + suffix += 1 + name_idx = docker_args.index("--name") + 1 + docker_args[name_idx] = container + info(f"name conflict; retrying as {container}") + + def _provision_container(self, plan: DockerBottlePlan, container: str) -> str | None: + """Copy prompt, skills, ssh keys, and (optionally) .git into the + running container. Returns the in-container prompt path if a + prompt 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") + in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" + + subprocess.run( + ["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"], + stdout=subprocess.DEVNULL, + check=True, + ) + # `docker cp` preserves host UID; re-own/mode as root so node + # can read its own mode-600 prompt regardless of host UID. + subprocess.run( + ["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path], + stdout=subprocess.DEVNULL, + check=True, + ) + + agent = plan.spec.manifest.agents[plan.spec.agent_name] + if agent.skills: + skills_mod.skills_copy_into(container, list(agent.skills)) + + bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) + if bottle.ssh: + proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) + ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) + + if plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir(): + info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") + subprocess.run( + ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + [ + "docker", "exec", "-u", "0", container, + "chown", "-R", "node:node", "/home/node/workspace/.git", + ], + stdout=subprocess.DEVNULL, + check=True, + ) + + return in_container_prompt_path if agent.prompt else None From 4a45c267f309b19f71045050f612645975ac00f1 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 23:05:24 -0400 Subject: [PATCH 11/44] refactor(cli): remove redundant 'build' command DockerBottlePlatform.launch already runs 'docker build' on every start, and the layer cache makes no-change rebuilds nearly free. Anything 'cli.py build' did, 'cli.py start' does for you. --- claude_bottle/cli/__init__.py | 5 +---- claude_bottle/cli/build.py | 19 ------------------- 2 files changed, 1 insertion(+), 23 deletions(-) delete mode 100644 claude_bottle/cli/build.py diff --git a/claude_bottle/cli/__init__.py b/claude_bottle/cli/__init__.py index f114a87..71711fe 100644 --- a/claude_bottle/cli/__init__.py +++ b/claude_bottle/cli/__init__.py @@ -1,6 +1,6 @@ """Main CLI dispatcher. -Commands: build, cleanup, edit, info, init, list, start +Commands: cleanup, edit, info, init, list, start """ from __future__ import annotations @@ -9,7 +9,6 @@ import sys from ..log import Die, die from ._common import PROG -from .build import cmd_build from .cleanup import cmd_cleanup from .edit import cmd_edit from .info import cmd_info @@ -18,7 +17,6 @@ from .list import cmd_list from .start import cmd_start COMMANDS = { - "build": cmd_build, "cleanup": cmd_cleanup, "edit": cmd_edit, "info": cmd_info, @@ -31,7 +29,6 @@ COMMANDS = { def usage() -> None: sys.stderr.write(f"usage: {PROG} [args...]\n\n") sys.stderr.write("Commands:\n") - sys.stderr.write(" build build (or rebuild) the claude-bottle Docker image\n") sys.stderr.write(" cleanup stop and remove all active claude-bottle containers\n") sys.stderr.write(" edit open an agent in vim for editing\n") sys.stderr.write(" info print env, skills, and prompt details for a named agent\n") diff --git a/claude_bottle/cli/build.py b/claude_bottle/cli/build.py deleted file mode 100644 index 88fe716..0000000 --- a/claude_bottle/cli/build.py +++ /dev/null @@ -1,19 +0,0 @@ -"""build: build (or rebuild) the claude-bottle Docker image.""" - -from __future__ import annotations - -import argparse -import os - -from .. import docker as docker_mod -from ._common import PROG, REPO_DIR - - -def cmd_build(argv: list[str]) -> int: - parser = argparse.ArgumentParser(prog=f"{PROG} build", add_help=True) - parser.parse_args(argv) - - docker_mod.require_docker() - image = os.environ.get("CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest") - docker_mod.build_image(image, REPO_DIR) - return 0 From 18d29fc23fce91f6e2f63d891e97c50280ea024d Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 23:14:54 -0400 Subject: [PATCH 12/44] refactor(bottles): two-phase cleanup parallel to prepare/launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_cleanup used to only sweep running containers via `docker ps`, missing stopped pipelock sidecars and orphaned networks entirely. On my host the new version surfaced ~10 stranded networks left behind by SIGKILLed sessions — the kind of thing the old command implied it was handling. New shape, symmetric with start: - BottleCleanupPlan (abstract, in bottles/__init__.py) with `print` + `empty` abstract members. - DockerBottleCleanupPlan (concrete, in bottles/docker.py) carrying the resolved tuples of containers and networks. - BottlePlatform gains abstract prepare_cleanup() + cleanup(plan). DockerBottlePlatform implements both: - prepare_cleanup: docker ps -a + docker network ls, both filtered to ^claude-bottle-, sorted for stable output. - cleanup: docker rm -f containers first (they hold the network attachment), then docker network rm. - cmd_cleanup is now ~25 lines: prepare → print → y/N → cleanup. --- claude_bottle/bottles/__init__.py | 36 +++++++++++- claude_bottle/bottles/docker.py | 91 ++++++++++++++++++++++++++++++- claude_bottle/cli/cleanup.py | 35 ++++-------- 3 files changed, 137 insertions(+), 25 deletions(-) diff --git a/claude_bottle/bottles/__init__.py b/claude_bottle/bottles/__init__.py index 0c7c575..2589640 100644 --- a/claude_bottle/bottles/__init__.py +++ b/claude_bottle/bottles/__init__.py @@ -1,7 +1,7 @@ """Per-platform bottle factories. A bottle is a running, isolated environment with claude inside. Each -platform exposes two functions: +platform exposes four methods: prepare(spec, stage_dir=...) -> BottlePlan Resolves names, validates host-side prerequisites, and writes @@ -12,6 +12,13 @@ platform exposes two functions: Brings up the container (or VM, or remote machine), provisions it, yields a Bottle handle, and tears everything down on exit. + prepare_cleanup() -> BottleCleanupPlan + Enumerates orphaned resources left behind by previous bottles + (containers, networks, ...). Idempotent; no side effects. + + cleanup(plan) -> None + Actually removes everything described by the cleanup plan. + Selection is driven by CLAUDE_BOTTLE_PLATFORM (default "docker"). Per PRD 0003 the manifest does not carry a platform field; the host environment picks. @@ -58,6 +65,23 @@ class BottlePlan(ABC): """Render the y/N preflight summary to stderr.""" +@dataclass(frozen=True) +class BottleCleanupPlan(ABC): + """Base output of a platform's prepare_cleanup step. Concrete + subclasses (e.g. DockerBottleCleanupPlan) carry platform-specific + lists of resources to be removed and implement `print` + `empty`.""" + + @abstractmethod + def print(self) -> None: + """Render the cleanup y/N summary to stderr.""" + + @property + @abstractmethod + def empty(self) -> bool: + """True iff there is nothing to clean up; the CLI uses this to + short-circuit before showing the y/N.""" + + class Bottle(Protocol): """Handle to a running bottle. Yielded by a platform's launch step. @@ -89,6 +113,15 @@ class BottlePlatform(ABC): def launch(self, plan: BottlePlan) -> AbstractContextManager[Bottle]: """Build/run the bottle and yield a handle; tear down on exit.""" + @abstractmethod + def prepare_cleanup(self) -> BottleCleanupPlan: + """Enumerate orphaned resources from previous bottles. No side + effects; safe to call before the y/N.""" + + @abstractmethod + def cleanup(self, plan: BottleCleanupPlan) -> None: + """Remove everything described by the cleanup plan.""" + # Import concrete platform classes AFTER the base types are defined, so # each platform module can pull BottleSpec / BottlePlan / BottlePlatform @@ -114,6 +147,7 @@ def get_bottle_platform() -> BottlePlatform: __all__ = [ "Bottle", + "BottleCleanupPlan", "BottlePlan", "BottlePlatform", "BottleSpec", diff --git a/claude_bottle/bottles/docker.py b/claude_bottle/bottles/docker.py index 0e1b0ce..0278a2d 100644 --- a/claude_bottle/bottles/docker.py +++ b/claude_bottle/bottles/docker.py @@ -34,7 +34,7 @@ from .. import skills as skills_mod from .. import ssh as ssh_mod from ..env_resolve import env_resolve from ..log import die, info -from . import BottlePlan, BottlePlatform, BottleSpec +from . import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec # Where the repo root lives, for `docker build` context. Computed once. @@ -120,6 +120,31 @@ class DockerBottlePlan(BottlePlan): print(file=sys.stderr) +# --- Cleanup plan ---------------------------------------------------------- + + +@dataclass(frozen=True) +class DockerBottleCleanupPlan(BottleCleanupPlan): + """Resources DockerBottlePlatform.cleanup will remove. Produced by + `prepare_cleanup` from a snapshot of `docker ps -a` + `docker + network ls`; sorted so the y/N output is stable.""" + + containers: tuple[str, ...] + networks: tuple[str, ...] + + @property + def empty(self) -> bool: + return not self.containers and not self.networks + + def print(self) -> None: + print(file=sys.stderr) + for name in self.containers: + info(f"container: {name}") + for name in self.networks: + info(f"network: {name}") + print(file=sys.stderr) + + # --- Bottle handle --------------------------------------------------------- @@ -438,3 +463,67 @@ class DockerBottlePlatform(BottlePlatform): ) return in_container_prompt_path if agent.prompt else None + + # --- Cleanup --- + + def prepare_cleanup(self) -> DockerBottleCleanupPlan: + """Enumerate all claude-bottle-prefixed containers (running or + stopped) and networks. No removals — caller confirms first.""" + docker_mod.require_docker() + + # `docker ps -a --filter name=...` uses regex matching; anchor at + # the start so we don't pick up containers that merely contain + # "claude-bottle-" mid-name. + cr = subprocess.run( + [ + "docker", "ps", "-a", + "--filter", "name=^claude-bottle-", + "--format", "{{.Names}}", + ], + capture_output=True, + text=True, + ) + containers = tuple(sorted( + line for line in (cr.stdout or "").splitlines() if line + )) + + # `docker network ls --filter name=...` uses substring matching. + # "claude-bottle-" is specific enough that false positives are + # not a concern. + nr = subprocess.run( + [ + "docker", "network", "ls", + "--filter", "name=claude-bottle-", + "--format", "{{.Name}}", + ], + capture_output=True, + text=True, + ) + networks = tuple(sorted( + line for line in (nr.stdout or "").splitlines() if line + )) + + return DockerBottleCleanupPlan(containers=containers, networks=networks) + + def cleanup(self, plan: BottleCleanupPlan) -> None: + """Remove the containers and networks listed in the plan. + Containers first; networks would refuse to delete while + containers are still attached.""" + assert isinstance(plan, DockerBottleCleanupPlan), ( + f"DockerBottlePlatform.cleanup expects DockerBottleCleanupPlan, " + f"got {type(plan).__name__}" + ) + for name in plan.containers: + info(f"removing container {name}") + subprocess.run( + ["docker", "rm", "-f", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + for name in plan.networks: + info(f"removing network {name}") + subprocess.run( + ["docker", "network", "rm", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) diff --git a/claude_bottle/cli/cleanup.py b/claude_bottle/cli/cleanup.py index 8902432..b06a018 100644 --- a/claude_bottle/cli/cleanup.py +++ b/claude_bottle/cli/cleanup.py @@ -1,42 +1,31 @@ -"""cleanup: stop and remove all active claude-bottle containers.""" +"""cleanup: stop and remove all orphaned claude-bottle resources +(containers + networks) left behind by previous bottles.""" from __future__ import annotations -import subprocess import sys -from .. import docker as docker_mod +from ..bottles import get_bottle_platform from ..log import info from ._common import read_tty_line def cmd_cleanup(_argv: list[str]) -> int: - docker_mod.require_docker() - result = subprocess.run( - ["docker", "ps", "--filter", "name=^claude-bottle-", "--format", "{{.Names}}"], - capture_output=True, - text=True, - ) - containers = (result.stdout or "").strip() - if not containers: - info("no active claude-bottle containers") + platform = get_bottle_platform() + plan = platform.prepare_cleanup() + + if plan.empty: + info("no claude-bottle resources to clean up") return 0 - print(file=sys.stderr) - for name in containers.splitlines(): - info(f"found: {name}") - print(file=sys.stderr) + + plan.print() sys.stderr.write("claude-bottle: remove all of the above? [y/N] ") sys.stderr.flush() reply = read_tty_line() if reply not in ("y", "Y", "yes", "YES"): info("aborted") return 0 - for name in containers.splitlines(): - info(f"removing {name}") - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + + platform.cleanup(plan) info("done") return 0 From 47b882f6346d2cc381653bb99b05bd4034b97876 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 23:19:22 -0400 Subject: [PATCH 13/44] refactor(bottles): move 'list active' onto DockerBottlePlatform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add list_active() abstract method on BottlePlatform; DockerBottlePlatform implements it with the existing docker ps logic. cli/list.py no longer calls docker directly — it just dispatches the active branch to the platform. --- claude_bottle/bottles/__init__.py | 5 +++++ claude_bottle/bottles/docker.py | 25 +++++++++++++++++++++++++ claude_bottle/cli/list.py | 26 +++----------------------- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/claude_bottle/bottles/__init__.py b/claude_bottle/bottles/__init__.py index 2589640..740a3f2 100644 --- a/claude_bottle/bottles/__init__.py +++ b/claude_bottle/bottles/__init__.py @@ -122,6 +122,11 @@ class BottlePlatform(ABC): def cleanup(self, plan: BottleCleanupPlan) -> None: """Remove everything described by the cleanup plan.""" + @abstractmethod + def list_active(self) -> None: + """Print every currently-running bottle on this platform to + stderr (name + status).""" + # Import concrete platform classes AFTER the base types are defined, so # each platform module can pull BottleSpec / BottlePlan / BottlePlatform diff --git a/claude_bottle/bottles/docker.py b/claude_bottle/bottles/docker.py index 0278a2d..f08af61 100644 --- a/claude_bottle/bottles/docker.py +++ b/claude_bottle/bottles/docker.py @@ -527,3 +527,28 @@ class DockerBottlePlatform(BottlePlatform): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) + + # --- List --- + + def list_active(self) -> None: + """Print all running claude-bottle containers (name + status). + Prints a single-line banner if there are none.""" + docker_mod.require_docker() + result = subprocess.run( + [ + "docker", "ps", + "--filter", "name=^claude-bottle-", + "--format", "{{.Names}}\t{{.Status}}", + ], + capture_output=True, + text=True, + ) + containers = (result.stdout or "").strip() + if not containers: + info("no active claude-bottle containers") + return + print() + for line in containers.splitlines(): + name, _, status = line.partition("\t") + info(f"container: {name} status: {status}") + print() diff --git a/claude_bottle/cli/list.py b/claude_bottle/cli/list.py index a60143b..af40292 100644 --- a/claude_bottle/cli/list.py +++ b/claude_bottle/cli/list.py @@ -1,12 +1,10 @@ -"""list: list available agents or active containers.""" +"""list: list available agents or active bottles.""" from __future__ import annotations import argparse -import subprocess -from .. import docker as docker_mod -from ..log import info +from ..bottles import get_bottle_platform from ..manifest import Manifest from ._common import PROG, USER_CWD @@ -22,23 +20,5 @@ def cmd_list(argv: list[str]) -> int: print(name) return 0 - docker_mod.require_docker() - result = subprocess.run( - [ - "docker", "ps", - "--filter", "name=^claude-bottle-", - "--format", "{{.Names}}\t{{.Status}}", - ], - capture_output=True, - text=True, - ) - containers = (result.stdout or "").strip() - if not containers: - info("no active claude-bottle containers") - return 0 - print() - for line in containers.splitlines(): - name, _, status = line.partition("\t") - info(f"container: {name} status: {status}") - print() + get_bottle_platform().list_active() return 0 From e20f8af05ad1515ee429670062d079df4f5844e9 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 23:25:42 -0400 Subject: [PATCH 14/44] refactor(bottles): make docker a package; absorb top-level docker.py - claude_bottle/bottles/docker.py -> claude_bottle/bottles/docker/__init__.py - claude_bottle/docker.py -> claude_bottle/bottles/docker/util.py The host-side Docker primitives (require_docker, slugify, image_exists, container_exists, build_image, build_image_with_cwd) lived at the top of the package as if cross-cutting, but they are Docker-specific. Moving them into bottles/docker/util.py colocates them with the platform that uses them and clears the top-level namespace. Imports in bottles/docker/__init__.py shift to `from . import util as docker_mod` so the existing call sites (`docker_mod.require_docker()`, etc.) keep working unchanged. --- .../bottles/{docker.py => docker/__init__.py} | 18 +++++++++--------- .../{docker.py => bottles/docker/util.py} | 6 ++++-- 2 files changed, 13 insertions(+), 11 deletions(-) rename claude_bottle/bottles/{docker.py => docker/__init__.py} (98%) rename claude_bottle/{docker.py => bottles/docker/util.py} (93%) diff --git a/claude_bottle/bottles/docker.py b/claude_bottle/bottles/docker/__init__.py similarity index 98% rename from claude_bottle/bottles/docker.py rename to claude_bottle/bottles/docker/__init__.py index f08af61..c9470c9 100644 --- a/claude_bottle/bottles/docker.py +++ b/claude_bottle/bottles/docker/__init__.py @@ -27,18 +27,18 @@ from dataclasses import dataclass from pathlib import Path from typing import Iterator -from .. import docker as docker_mod -from .. import network as network_mod -from .. import pipelock -from .. import skills as skills_mod -from .. import ssh as ssh_mod -from ..env_resolve import env_resolve -from ..log import die, info -from . import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec +from ... import network as network_mod +from ... import pipelock +from ... import skills as skills_mod +from ... import ssh as ssh_mod +from ...env_resolve import env_resolve +from ...log import die, info +from .. import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec +from . import util as docker_mod # Where the repo root lives, for `docker build` context. Computed once. -_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent) +_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) # --- Runtime detection ----------------------------------------------------- diff --git a/claude_bottle/docker.py b/claude_bottle/bottles/docker/util.py similarity index 93% rename from claude_bottle/docker.py rename to claude_bottle/bottles/docker/util.py index 80304ec..1573a79 100644 --- a/claude_bottle/docker.py +++ b/claude_bottle/bottles/docker/util.py @@ -1,4 +1,6 @@ -"""Docker helpers. Build/inspect primitives shared by the CLI.""" +"""Docker host-side primitives used by DockerBottlePlatform: probing +for docker on PATH, slugifying agent names, checking image/container +existence, and building images.""" from __future__ import annotations @@ -7,7 +9,7 @@ import shutil import subprocess from typing import Iterable -from .log import die, info +from ...log import die, info def require_docker() -> None: From d28f0e6d9b23dc71d798153ad5930efcf68e502c Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 23:29:38 -0400 Subject: [PATCH 15/44] refactor(docker): split bottles/docker/__init__.py into sibling modules The single docker/__init__.py grew to ~555 lines holding the platform, its plan classes, the bottle handle, and the runsc probe. Split into: - util.py : Docker subprocess primitives + runsc_available - bottle_plan.py : DockerBottlePlan (+ its print method) - bottle_cleanup_plan.py : DockerBottleCleanupPlan - bottle.py : _DockerBottle handle class - platform.py : DockerBottlePlatform (the bulk) docker/__init__.py becomes a thin re-export shim so existing imports (claude_bottle.bottles.docker.DockerBottlePlatform, etc.) keep working. --- claude_bottle/bottles/docker/__init__.py | 564 +----------------- claude_bottle/bottles/docker/bottle.py | 44 ++ .../bottles/docker/bottle_cleanup_plan.py | 36 ++ claude_bottle/bottles/docker/bottle_plan.py | 77 +++ claude_bottle/bottles/docker/platform.py | 402 +++++++++++++ claude_bottle/bottles/docker/util.py | 11 + 6 files changed, 588 insertions(+), 546 deletions(-) create mode 100644 claude_bottle/bottles/docker/bottle.py create mode 100644 claude_bottle/bottles/docker/bottle_cleanup_plan.py create mode 100644 claude_bottle/bottles/docker/bottle_plan.py create mode 100644 claude_bottle/bottles/docker/platform.py diff --git a/claude_bottle/bottles/docker/__init__.py b/claude_bottle/bottles/docker/__init__.py index c9470c9..53d5783 100644 --- a/claude_bottle/bottles/docker/__init__.py +++ b/claude_bottle/bottles/docker/__init__.py @@ -1,554 +1,26 @@ """Docker bottle platform. -DockerBottlePlatform owns the two-phase factory: +The bulk of the implementation lives in sibling modules: - .prepare(spec, stage_dir=...) -> DockerBottlePlan - Resolve names, validate host-side prerequisites, and write - scratch files (env_file, args_file, prompt, pipelock yaml) to - stage_dir. No Docker resources are created yet. Suitable to call - before the y/N preflight. + - util: thin Docker subprocess wrappers + - bottle_plan: DockerBottlePlan + - bottle_cleanup_plan: DockerBottleCleanupPlan + - bottle: _DockerBottle handle + - platform: DockerBottlePlatform - .launch(plan) -> ContextManager[_DockerBottle] - Build the image, create networks, boot the pipelock sidecar, - 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__`. +This file only re-exports the platform class so +`from claude_bottle.bottles.docker import DockerBottlePlatform` keeps +working. """ from __future__ import annotations -import os -import subprocess -import sys -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path -from typing import Iterator - -from ... import network as network_mod -from ... import pipelock -from ... import skills as skills_mod -from ... import ssh as ssh_mod -from ...env_resolve import env_resolve -from ...log import die, info -from .. import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec -from . import util as docker_mod - - -# Where the repo root lives, for `docker build` context. Computed once. -_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) - - -# --- Runtime detection ----------------------------------------------------- - - -def runsc_available() -> bool: - """Return True if the Docker daemon has the gVisor (`runsc`) runtime - registered. Called once per prepare; the result lives on the plan.""" - r = subprocess.run( - ["docker", "info", "--format", "{{json .Runtimes}}"], - capture_output=True, - text=True, - ) - return r.returncode == 0 and "runsc" in r.stdout - - -# --- Plan ------------------------------------------------------------------ - - -@dataclass(frozen=True) -class DockerBottlePlan(BottlePlan): - """Docker-specific resolved fields produced by - DockerBottlePlatform.prepare. Inherits `spec` and `stage_dir` from - BottlePlan.""" - - slug: str - container_name: str - container_name_pinned: bool - image: str - derived_image: str # "" -> no derived image - runtime_image: str # image actually launched (derived or base) - env_file: Path - args_file: Path - prompt_file: Path - pipelock_yaml_path: Path - pipelock_yaml_filename: str - allowlist_summary: str - use_runsc: bool - - def print(self, *, remote_control: bool) -> None: - """Render the y/N preflight summary to stderr. Pure presentation.""" - spec = self.spec - manifest = spec.manifest - agent = manifest.agents[spec.agent_name] - bottle = manifest.bottle_for(spec.agent_name) - - env_names = list(bottle.env.keys()) - if spec.forward_oauth_token: - env_names.append("CLAUDE_CODE_OAUTH_TOKEN") - - ssh_hosts = [e.Host for e in bottle.ssh] - prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else "" - runtime_label = "runsc (gVisor)" if self.use_runsc else "runc (default)" - - print(file=sys.stderr) - info(f"agent : {spec.agent_name}") - info(f"image : {self.image}") - if self.derived_image: - info( - f"cwd : {spec.user_cwd} -> /home/node/workspace " - f"(derived: {self.derived_image})" - ) - info(f"container : {self.container_name}") - info(f"stage dir : {self.stage_dir}") - info("env (names only): " + (", ".join(env_names) if env_names else "(none)")) - info("skills : " + (" ".join(agent.skills) if agent.skills else "(none)")) - info(f"docker runtime : {runtime_label}") - info(f"bottle : {agent.bottle}") - if ssh_hosts: - info(f" ssh hosts : {', '.join(ssh_hosts)}") - else: - info(" ssh hosts : (none)") - info(f" egress : {self.allowlist_summary}") - info( - f"prompt : {len(agent.prompt)} chars; " - f"first line: {prompt_first_line or '(empty)'}" - ) - info("remote-control : " + ("enabled" if remote_control else "disabled")) - print(file=sys.stderr) - - -# --- Cleanup plan ---------------------------------------------------------- - - -@dataclass(frozen=True) -class DockerBottleCleanupPlan(BottleCleanupPlan): - """Resources DockerBottlePlatform.cleanup will remove. Produced by - `prepare_cleanup` from a snapshot of `docker ps -a` + `docker - network ls`; sorted so the y/N output is stable.""" - - containers: tuple[str, ...] - networks: tuple[str, ...] - - @property - def empty(self) -> bool: - return not self.containers and not self.networks - - def print(self) -> None: - print(file=sys.stderr) - for name in self.containers: - info(f"container: {name}") - for name in self.networks: - info(f"network: {name}") - print(file=sys.stderr) - - -# --- Bottle handle --------------------------------------------------------- - - -class _DockerBottle: - """Concrete Bottle for Docker. Holds the container name plus the - in-container prompt path so exec_claude can transparently add - --append-system-prompt-file when a prompt was provisioned.""" - - def __init__(self, container: str, teardown, prompt_path_in_container: str | None): - self.name = container - self._teardown = teardown - self._prompt_path = prompt_path_in_container - self._closed = False - - 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"] - if tty: - cmd.append("-it") - cmd.extend([self.name, "claude", *full_argv]) - return subprocess.run(cmd).returncode - - def cp_in(self, host_path: str, container_path: str) -> None: - subprocess.run( - ["docker", "cp", host_path, f"{self.name}:{container_path}"], - stdout=subprocess.DEVNULL, - check=True, - ) - - def close(self) -> None: - if self._closed: - return - self._closed = True - self._teardown() - - -# --- Platform -------------------------------------------------------------- - - -class DockerBottlePlatform(BottlePlatform): - """Docker platform implementation. Selected by CLAUDE_BOTTLE_PLATFORM - (default).""" - - name = "docker" - - def prepare(self, spec: BottleSpec, *, 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 '" - ) - - 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, - stage_dir=stage_dir, - slug=slug, - container_name=container_name, - container_name_pinned=container_name_pinned, - image=image, - derived_image=derived_image, - runtime_image=runtime_image, - 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, - ) - - @contextmanager - def launch(self, plan: BottlePlan) -> Iterator[_DockerBottle]: - """Build, launch, and provision a Docker bottle. Teardown on exit.""" - assert isinstance(plan, DockerBottlePlan), ( - f"DockerBottlePlatform.launch expects DockerBottlePlan, " - f"got {type(plan).__name__}" - ) - - state: dict[str, str] = { - "container": "", - "pipelock": "", - "internal_network": "", - "egress_network": "", - } - - def teardown() -> None: - try: - if state["container"] and docker_mod.container_exists(state["container"]): - subprocess.run( - ["docker", "rm", "-f", state["container"]], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - state["container"] = "" - if state["pipelock"]: - pipelock.pipelock_stop(plan.slug) - state["pipelock"] = "" - if state["internal_network"]: - network_mod.network_remove(state["internal_network"]) - state["internal_network"] = "" - if state["egress_network"]: - network_mod.network_remove(state["egress_network"]) - state["egress_network"] = "" - except BaseException: - # Teardown must not raise; swallow so the caller's - # __exit__ path can still propagate the original error. - pass - - try: - 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 - ) - - state["internal_network"] = network_mod.network_create_internal(plan.slug) - state["egress_network"] = network_mod.network_create_egress(plan.slug) - state["pipelock"] = pipelock.pipelock_start( - plan.slug, - state["internal_network"], - state["egress_network"], - plan.stage_dir, - plan.pipelock_yaml_filename, - ) - - container = self._run_agent_container(plan, state["internal_network"]) - state["container"] = container - - prompt_path = self._provision_container(plan, container) - - bottle = _DockerBottle(container, teardown, prompt_path) - yield bottle - finally: - teardown() - - def _run_agent_container(self, plan: DockerBottlePlan, internal_network: str) -> str: - """Build the `docker run` argv and execute it, handling - name-conflict races by incrementing the suffix (unless the name - was user-pinned). Returns the resolved container name.""" - proxy_url = pipelock.pipelock_proxy_url(plan.slug) - docker_args: list[str] = [ - "--rm", "-d", - "--name", plan.container_name, - "--network", internal_network, - "-e", f"HTTPS_PROXY={proxy_url}", - "-e", f"HTTP_PROXY={proxy_url}", - "-e", "NO_PROXY=localhost,127.0.0.1", - ] - if plan.use_runsc: - docker_args.extend(["--runtime", "runsc"]) - if plan.env_file.stat().st_size > 0: - docker_args.extend(["--env-file", str(plan.env_file)]) - - # ARGS_FILE pairs (-e, NAME) line-by-line. - args_lines = plan.args_file.read_text().splitlines() - i = 0 - while i < len(args_lines): - flag = args_lines[i] - i += 1 - if not flag: - continue - if i >= len(args_lines): - break - vname = args_lines[i] - i += 1 - docker_args.extend([flag, vname]) - - if plan.spec.forward_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([plan.runtime_image, "sleep", "infinity"]) - - info(f"starting container {plan.container_name} from {plan.runtime_image}") - - container = plan.container_name - base_name = plan.container_name - suffix = 2 - while True: - run_result = subprocess.run( - ["docker", "run", *docker_args], - capture_output=True, - text=True, - ) - if run_result.returncode == 0: - return container - err_text = run_result.stderr - if plan.container_name_pinned or "is already in use" not in err_text: - sys.stderr.write(err_text + "\n") - die(f"docker run failed for container '{container}'") - if suffix > 100: - die( - f"could not find a free container name after " - f"{base_name}-99 retries; clean up old containers" - ) - container = f"{base_name}-{suffix}" - suffix += 1 - name_idx = docker_args.index("--name") + 1 - docker_args[name_idx] = container - info(f"name conflict; retrying as {container}") - - def _provision_container(self, plan: DockerBottlePlan, container: str) -> str | None: - """Copy prompt, skills, ssh keys, and (optionally) .git into the - running container. Returns the in-container prompt path if a - prompt 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") - in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" - - subprocess.run( - ["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"], - stdout=subprocess.DEVNULL, - check=True, - ) - # `docker cp` preserves host UID; re-own/mode as root so node - # can read its own mode-600 prompt regardless of host UID. - subprocess.run( - ["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - ["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path], - stdout=subprocess.DEVNULL, - check=True, - ) - - agent = plan.spec.manifest.agents[plan.spec.agent_name] - if agent.skills: - skills_mod.skills_copy_into(container, list(agent.skills)) - - bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) - if bottle.ssh: - proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) - ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) - - if plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir(): - info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") - subprocess.run( - ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - [ - "docker", "exec", "-u", "0", container, - "chown", "-R", "node:node", "/home/node/workspace/.git", - ], - stdout=subprocess.DEVNULL, - check=True, - ) - - return in_container_prompt_path if agent.prompt else None - - # --- Cleanup --- - - def prepare_cleanup(self) -> DockerBottleCleanupPlan: - """Enumerate all claude-bottle-prefixed containers (running or - stopped) and networks. No removals — caller confirms first.""" - docker_mod.require_docker() - - # `docker ps -a --filter name=...` uses regex matching; anchor at - # the start so we don't pick up containers that merely contain - # "claude-bottle-" mid-name. - cr = subprocess.run( - [ - "docker", "ps", "-a", - "--filter", "name=^claude-bottle-", - "--format", "{{.Names}}", - ], - capture_output=True, - text=True, - ) - containers = tuple(sorted( - line for line in (cr.stdout or "").splitlines() if line - )) - - # `docker network ls --filter name=...` uses substring matching. - # "claude-bottle-" is specific enough that false positives are - # not a concern. - nr = subprocess.run( - [ - "docker", "network", "ls", - "--filter", "name=claude-bottle-", - "--format", "{{.Name}}", - ], - capture_output=True, - text=True, - ) - networks = tuple(sorted( - line for line in (nr.stdout or "").splitlines() if line - )) - - return DockerBottleCleanupPlan(containers=containers, networks=networks) - - def cleanup(self, plan: BottleCleanupPlan) -> None: - """Remove the containers and networks listed in the plan. - Containers first; networks would refuse to delete while - containers are still attached.""" - assert isinstance(plan, DockerBottleCleanupPlan), ( - f"DockerBottlePlatform.cleanup expects DockerBottleCleanupPlan, " - f"got {type(plan).__name__}" - ) - for name in plan.containers: - info(f"removing container {name}") - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - for name in plan.networks: - info(f"removing network {name}") - subprocess.run( - ["docker", "network", "rm", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - # --- List --- - - def list_active(self) -> None: - """Print all running claude-bottle containers (name + status). - Prints a single-line banner if there are none.""" - docker_mod.require_docker() - result = subprocess.run( - [ - "docker", "ps", - "--filter", "name=^claude-bottle-", - "--format", "{{.Names}}\t{{.Status}}", - ], - capture_output=True, - text=True, - ) - containers = (result.stdout or "").strip() - if not containers: - info("no active claude-bottle containers") - return - print() - for line in containers.splitlines(): - name, _, status = line.partition("\t") - info(f"container: {name} status: {status}") - print() +from .bottle_cleanup_plan import DockerBottleCleanupPlan +from .bottle_plan import DockerBottlePlan +from .platform import DockerBottlePlatform + +__all__ = [ + "DockerBottleCleanupPlan", + "DockerBottlePlan", + "DockerBottlePlatform", +] diff --git a/claude_bottle/bottles/docker/bottle.py b/claude_bottle/bottles/docker/bottle.py new file mode 100644 index 0000000..45c8361 --- /dev/null +++ b/claude_bottle/bottles/docker/bottle.py @@ -0,0 +1,44 @@ +"""_DockerBottle — concrete Bottle handle yielded by +DockerBottlePlatform.launch. + +Holds the container name plus the in-container prompt path so +exec_claude can transparently add --append-system-prompt-file when a +prompt was provisioned. +""" + +from __future__ import annotations + +import subprocess + + +class _DockerBottle: + """Concrete Bottle for Docker.""" + + def __init__(self, container: str, teardown, prompt_path_in_container: str | None): + self.name = container + self._teardown = teardown + self._prompt_path = prompt_path_in_container + self._closed = False + + 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"] + if tty: + cmd.append("-it") + cmd.extend([self.name, "claude", *full_argv]) + return subprocess.run(cmd).returncode + + def cp_in(self, host_path: str, container_path: str) -> None: + subprocess.run( + ["docker", "cp", host_path, f"{self.name}:{container_path}"], + stdout=subprocess.DEVNULL, + check=True, + ) + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._teardown() diff --git a/claude_bottle/bottles/docker/bottle_cleanup_plan.py b/claude_bottle/bottles/docker/bottle_cleanup_plan.py new file mode 100644 index 0000000..1cfa766 --- /dev/null +++ b/claude_bottle/bottles/docker/bottle_cleanup_plan.py @@ -0,0 +1,36 @@ +"""DockerBottleCleanupPlan — concrete subclass of BottleCleanupPlan. + +Holds the tuples of container and network names that +DockerBottlePlatform.cleanup will remove. The y/N preflight reads +these via `print`; the CLI short-circuits via `empty`. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass + +from ...log import info +from .. import BottleCleanupPlan + + +@dataclass(frozen=True) +class DockerBottleCleanupPlan(BottleCleanupPlan): + """Resources DockerBottlePlatform.cleanup will remove. Produced by + `prepare_cleanup` from a snapshot of `docker ps -a` + `docker + network ls`; sorted so the y/N output is stable.""" + + containers: tuple[str, ...] + networks: tuple[str, ...] + + @property + def empty(self) -> bool: + return not self.containers and not self.networks + + def print(self) -> None: + print(file=sys.stderr) + for name in self.containers: + info(f"container: {name}") + for name in self.networks: + info(f"network: {name}") + print(file=sys.stderr) diff --git a/claude_bottle/bottles/docker/bottle_plan.py b/claude_bottle/bottles/docker/bottle_plan.py new file mode 100644 index 0000000..f729c9c --- /dev/null +++ b/claude_bottle/bottles/docker/bottle_plan.py @@ -0,0 +1,77 @@ +"""DockerBottlePlan — concrete subclass of BottlePlan. + +Carries the Docker-specific resolved fields produced by +DockerBottlePlatform.prepare. The launch step consumes it without +further resolution; show_plan-style rendering is the `print` method. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path + +from ...log import info +from .. import BottlePlan + + +@dataclass(frozen=True) +class DockerBottlePlan(BottlePlan): + """Docker-specific resolved fields produced by + DockerBottlePlatform.prepare. Inherits `spec` and `stage_dir` from + BottlePlan.""" + + slug: str + container_name: str + container_name_pinned: bool + image: str + derived_image: str # "" -> no derived image + runtime_image: str # image actually launched (derived or base) + env_file: Path + args_file: Path + prompt_file: Path + pipelock_yaml_path: Path + pipelock_yaml_filename: str + allowlist_summary: str + use_runsc: bool + + def print(self, *, remote_control: bool) -> None: + """Render the y/N preflight summary to stderr. Pure presentation.""" + spec = self.spec + manifest = spec.manifest + agent = manifest.agents[spec.agent_name] + bottle = manifest.bottle_for(spec.agent_name) + + env_names = list(bottle.env.keys()) + if spec.forward_oauth_token: + env_names.append("CLAUDE_CODE_OAUTH_TOKEN") + + ssh_hosts = [e.Host for e in bottle.ssh] + prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else "" + runtime_label = "runsc (gVisor)" if self.use_runsc else "runc (default)" + + print(file=sys.stderr) + info(f"agent : {spec.agent_name}") + info(f"image : {self.image}") + if self.derived_image: + info( + f"cwd : {spec.user_cwd} -> /home/node/workspace " + f"(derived: {self.derived_image})" + ) + info(f"container : {self.container_name}") + info(f"stage dir : {self.stage_dir}") + info("env (names only): " + (", ".join(env_names) if env_names else "(none)")) + info("skills : " + (" ".join(agent.skills) if agent.skills else "(none)")) + info(f"docker runtime : {runtime_label}") + info(f"bottle : {agent.bottle}") + if ssh_hosts: + info(f" ssh hosts : {', '.join(ssh_hosts)}") + else: + info(" ssh hosts : (none)") + info(f" egress : {self.allowlist_summary}") + info( + f"prompt : {len(agent.prompt)} chars; " + f"first line: {prompt_first_line or '(empty)'}" + ) + info("remote-control : " + ("enabled" if remote_control else "disabled")) + print(file=sys.stderr) diff --git a/claude_bottle/bottles/docker/platform.py b/claude_bottle/bottles/docker/platform.py new file mode 100644 index 0000000..3a0db74 --- /dev/null +++ b/claude_bottle/bottles/docker/platform.py @@ -0,0 +1,402 @@ +"""DockerBottlePlatform — the Docker implementation of BottlePlatform. + +Methods: + .prepare(spec, stage_dir=...) -> DockerBottlePlan + .launch(plan) -> ContextManager[_DockerBottle] + .prepare_cleanup() -> DockerBottleCleanupPlan + .cleanup(plan) -> None + .list_active() -> None +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +from ... import network as network_mod +from ... import pipelock +from ... import skills as skills_mod +from ... import ssh as ssh_mod +from ...env_resolve import env_resolve +from ...log import die, info +from .. import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec +from . import util as docker_mod +from .bottle import _DockerBottle +from .bottle_cleanup_plan import DockerBottleCleanupPlan +from .bottle_plan import DockerBottlePlan + + +# Where the repo root lives, for `docker build` context. Computed once. +_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) + + +class DockerBottlePlatform(BottlePlatform): + """Docker platform implementation. Selected by CLAUDE_BOTTLE_PLATFORM + (default).""" + + name = "docker" + + def prepare(self, spec: BottleSpec, *, 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 '" + ) + + 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 = docker_mod.runsc_available() + + return DockerBottlePlan( + spec=spec, + stage_dir=stage_dir, + slug=slug, + container_name=container_name, + container_name_pinned=container_name_pinned, + image=image, + derived_image=derived_image, + runtime_image=runtime_image, + 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, + ) + + @contextmanager + def launch(self, plan: BottlePlan) -> Iterator[_DockerBottle]: + """Build, launch, and provision a Docker bottle. Teardown on exit.""" + assert isinstance(plan, DockerBottlePlan), ( + f"DockerBottlePlatform.launch expects DockerBottlePlan, " + f"got {type(plan).__name__}" + ) + + state: dict[str, str] = { + "container": "", + "pipelock": "", + "internal_network": "", + "egress_network": "", + } + + def teardown() -> None: + try: + if state["container"] and docker_mod.container_exists(state["container"]): + subprocess.run( + ["docker", "rm", "-f", state["container"]], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + state["container"] = "" + if state["pipelock"]: + pipelock.pipelock_stop(plan.slug) + state["pipelock"] = "" + if state["internal_network"]: + network_mod.network_remove(state["internal_network"]) + state["internal_network"] = "" + if state["egress_network"]: + network_mod.network_remove(state["egress_network"]) + state["egress_network"] = "" + except BaseException: + # Teardown must not raise; swallow so the caller's + # __exit__ path can still propagate the original error. + pass + + try: + 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 + ) + + state["internal_network"] = network_mod.network_create_internal(plan.slug) + state["egress_network"] = network_mod.network_create_egress(plan.slug) + state["pipelock"] = pipelock.pipelock_start( + plan.slug, + state["internal_network"], + state["egress_network"], + plan.stage_dir, + plan.pipelock_yaml_filename, + ) + + container = self._run_agent_container(plan, state["internal_network"]) + state["container"] = container + + prompt_path = self._provision_container(plan, container) + + bottle = _DockerBottle(container, teardown, prompt_path) + yield bottle + finally: + teardown() + + def _run_agent_container(self, plan: DockerBottlePlan, internal_network: str) -> str: + """Build the `docker run` argv and execute it, handling + name-conflict races by incrementing the suffix (unless the name + was user-pinned). Returns the resolved container name.""" + proxy_url = pipelock.pipelock_proxy_url(plan.slug) + docker_args: list[str] = [ + "--rm", "-d", + "--name", plan.container_name, + "--network", internal_network, + "-e", f"HTTPS_PROXY={proxy_url}", + "-e", f"HTTP_PROXY={proxy_url}", + "-e", "NO_PROXY=localhost,127.0.0.1", + ] + if plan.use_runsc: + docker_args.extend(["--runtime", "runsc"]) + if plan.env_file.stat().st_size > 0: + docker_args.extend(["--env-file", str(plan.env_file)]) + + # ARGS_FILE pairs (-e, NAME) line-by-line. + args_lines = plan.args_file.read_text().splitlines() + i = 0 + while i < len(args_lines): + flag = args_lines[i] + i += 1 + if not flag: + continue + if i >= len(args_lines): + break + vname = args_lines[i] + i += 1 + docker_args.extend([flag, vname]) + + if plan.spec.forward_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([plan.runtime_image, "sleep", "infinity"]) + + info(f"starting container {plan.container_name} from {plan.runtime_image}") + + container = plan.container_name + base_name = plan.container_name + suffix = 2 + while True: + run_result = subprocess.run( + ["docker", "run", *docker_args], + capture_output=True, + text=True, + ) + if run_result.returncode == 0: + return container + err_text = run_result.stderr + if plan.container_name_pinned or "is already in use" not in err_text: + sys.stderr.write(err_text + "\n") + die(f"docker run failed for container '{container}'") + if suffix > 100: + die( + f"could not find a free container name after " + f"{base_name}-99 retries; clean up old containers" + ) + container = f"{base_name}-{suffix}" + suffix += 1 + name_idx = docker_args.index("--name") + 1 + docker_args[name_idx] = container + info(f"name conflict; retrying as {container}") + + def _provision_container(self, plan: DockerBottlePlan, container: str) -> str | None: + """Copy prompt, skills, ssh keys, and (optionally) .git into the + running container. Returns the in-container prompt path if a + prompt 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") + in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" + + subprocess.run( + ["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"], + stdout=subprocess.DEVNULL, + check=True, + ) + # `docker cp` preserves host UID; re-own/mode as root so node + # can read its own mode-600 prompt regardless of host UID. + subprocess.run( + ["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path], + stdout=subprocess.DEVNULL, + check=True, + ) + + agent = plan.spec.manifest.agents[plan.spec.agent_name] + if agent.skills: + skills_mod.skills_copy_into(container, list(agent.skills)) + + bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) + if bottle.ssh: + proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) + ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) + + if plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir(): + info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") + subprocess.run( + ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + [ + "docker", "exec", "-u", "0", container, + "chown", "-R", "node:node", "/home/node/workspace/.git", + ], + stdout=subprocess.DEVNULL, + check=True, + ) + + return in_container_prompt_path if agent.prompt else None + + # --- Cleanup --- + + def prepare_cleanup(self) -> DockerBottleCleanupPlan: + """Enumerate all claude-bottle-prefixed containers (running or + stopped) and networks. No removals — caller confirms first.""" + docker_mod.require_docker() + + # `docker ps -a --filter name=...` uses regex matching; anchor at + # the start so we don't pick up containers that merely contain + # "claude-bottle-" mid-name. + cr = subprocess.run( + [ + "docker", "ps", "-a", + "--filter", "name=^claude-bottle-", + "--format", "{{.Names}}", + ], + capture_output=True, + text=True, + ) + containers = tuple(sorted( + line for line in (cr.stdout or "").splitlines() if line + )) + + # `docker network ls --filter name=...` uses substring matching. + # "claude-bottle-" is specific enough that false positives are + # not a concern. + nr = subprocess.run( + [ + "docker", "network", "ls", + "--filter", "name=claude-bottle-", + "--format", "{{.Name}}", + ], + capture_output=True, + text=True, + ) + networks = tuple(sorted( + line for line in (nr.stdout or "").splitlines() if line + )) + + return DockerBottleCleanupPlan(containers=containers, networks=networks) + + def cleanup(self, plan: BottleCleanupPlan) -> None: + """Remove the containers and networks listed in the plan. + Containers first; networks would refuse to delete while + containers are still attached.""" + assert isinstance(plan, DockerBottleCleanupPlan), ( + f"DockerBottlePlatform.cleanup expects DockerBottleCleanupPlan, " + f"got {type(plan).__name__}" + ) + for name in plan.containers: + info(f"removing container {name}") + subprocess.run( + ["docker", "rm", "-f", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + for name in plan.networks: + info(f"removing network {name}") + subprocess.run( + ["docker", "network", "rm", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + # --- List --- + + def list_active(self) -> None: + """Print all running claude-bottle containers (name + status). + Prints a single-line banner if there are none.""" + docker_mod.require_docker() + result = subprocess.run( + [ + "docker", "ps", + "--filter", "name=^claude-bottle-", + "--format", "{{.Names}}\t{{.Status}}", + ], + capture_output=True, + text=True, + ) + containers = (result.stdout or "").strip() + if not containers: + info("no active claude-bottle containers") + return + print() + for line in containers.splitlines(): + name, _, status = line.partition("\t") + info(f"container: {name} status: {status}") + print() diff --git a/claude_bottle/bottles/docker/util.py b/claude_bottle/bottles/docker/util.py index 1573a79..221e8a2 100644 --- a/claude_bottle/bottles/docker/util.py +++ b/claude_bottle/bottles/docker/util.py @@ -12,6 +12,17 @@ from typing import Iterable from ...log import die, info +def runsc_available() -> bool: + """Return True if the Docker daemon has the gVisor (`runsc`) runtime + registered. Called once per prepare; the result lives on the plan.""" + r = subprocess.run( + ["docker", "info", "--format", "{{json .Runtimes}}"], + capture_output=True, + text=True, + ) + return r.returncode == 0 and "runsc" in r.stdout + + def require_docker() -> None: """Fail with an install pointer if `docker` is not on PATH.""" if shutil.which("docker") is None: From aaed390953a2cd2555bfe0713f842c02dd24f573 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 23:32:33 -0400 Subject: [PATCH 16/44] refactor(bottles): Bottle becomes an ABC; DockerBottle inherits Bottle was the only Protocol in an otherwise-ABC family (BottlePlan, BottleCleanupPlan, BottlePlatform are all ABCs). Convert to an ABC with abstract exec_claude / cp_in / close, matching the rest of the hierarchy. Rename _DockerBottle -> DockerBottle: the underscore was a default-Python-private instinct that doesn't match the sibling plan classes (DockerBottlePlan, DockerBottleCleanupPlan), all of which are equally "only constructed by the platform" and yet public-by-name. Re-export DockerBottle from claude_bottle.bottles.docker. --- claude_bottle/bottles/__init__.py | 8 ++++++-- claude_bottle/bottles/docker/__init__.py | 6 ++++-- claude_bottle/bottles/docker/bottle.py | 6 ++++-- claude_bottle/bottles/docker/platform.py | 8 ++++---- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/claude_bottle/bottles/__init__.py b/claude_bottle/bottles/__init__.py index 740a3f2..893eb5b 100644 --- a/claude_bottle/bottles/__init__.py +++ b/claude_bottle/bottles/__init__.py @@ -31,7 +31,6 @@ from abc import ABC, abstractmethod from contextlib import AbstractContextManager from dataclasses import dataclass from pathlib import Path -from typing import Protocol from ..log import die from ..manifest import Manifest @@ -82,7 +81,7 @@ class BottleCleanupPlan(ABC): short-circuit before showing the y/N.""" -class Bottle(Protocol): +class Bottle(ABC): """Handle to a running bottle. Yielded by a platform's launch step. `exec_claude` runs `claude` inside the bottle and blocks until the @@ -92,8 +91,13 @@ class Bottle(Protocol): name: str + @abstractmethod def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ... + + @abstractmethod def cp_in(self, host_path: str, container_path: str) -> None: ... + + @abstractmethod def close(self) -> None: ... diff --git a/claude_bottle/bottles/docker/__init__.py b/claude_bottle/bottles/docker/__init__.py index 53d5783..3a34d7d 100644 --- a/claude_bottle/bottles/docker/__init__.py +++ b/claude_bottle/bottles/docker/__init__.py @@ -5,21 +5,23 @@ The bulk of the implementation lives in sibling modules: - util: thin Docker subprocess wrappers - bottle_plan: DockerBottlePlan - bottle_cleanup_plan: DockerBottleCleanupPlan - - bottle: _DockerBottle handle + - bottle: DockerBottle handle - platform: DockerBottlePlatform -This file only re-exports the platform class so +This file only re-exports the public names so `from claude_bottle.bottles.docker import DockerBottlePlatform` keeps working. """ from __future__ import annotations +from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan from .platform import DockerBottlePlatform __all__ = [ + "DockerBottle", "DockerBottleCleanupPlan", "DockerBottlePlan", "DockerBottlePlatform", diff --git a/claude_bottle/bottles/docker/bottle.py b/claude_bottle/bottles/docker/bottle.py index 45c8361..32a3770 100644 --- a/claude_bottle/bottles/docker/bottle.py +++ b/claude_bottle/bottles/docker/bottle.py @@ -1,4 +1,4 @@ -"""_DockerBottle — concrete Bottle handle yielded by +"""DockerBottle — concrete Bottle handle yielded by DockerBottlePlatform.launch. Holds the container name plus the in-container prompt path so @@ -10,8 +10,10 @@ from __future__ import annotations import subprocess +from .. import Bottle -class _DockerBottle: + +class DockerBottle(Bottle): """Concrete Bottle for Docker.""" def __init__(self, container: str, teardown, prompt_path_in_container: str | None): diff --git a/claude_bottle/bottles/docker/platform.py b/claude_bottle/bottles/docker/platform.py index 3a0db74..f4952b6 100644 --- a/claude_bottle/bottles/docker/platform.py +++ b/claude_bottle/bottles/docker/platform.py @@ -2,7 +2,7 @@ Methods: .prepare(spec, stage_dir=...) -> DockerBottlePlan - .launch(plan) -> ContextManager[_DockerBottle] + .launch(plan) -> ContextManager[DockerBottle] .prepare_cleanup() -> DockerBottleCleanupPlan .cleanup(plan) -> None .list_active() -> None @@ -25,7 +25,7 @@ from ...env_resolve import env_resolve from ...log import die, info from .. import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec from . import util as docker_mod -from .bottle import _DockerBottle +from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan @@ -128,7 +128,7 @@ class DockerBottlePlatform(BottlePlatform): ) @contextmanager - def launch(self, plan: BottlePlan) -> Iterator[_DockerBottle]: + def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]: """Build, launch, and provision a Docker bottle. Teardown on exit.""" assert isinstance(plan, DockerBottlePlan), ( f"DockerBottlePlatform.launch expects DockerBottlePlan, " @@ -187,7 +187,7 @@ class DockerBottlePlatform(BottlePlatform): prompt_path = self._provision_container(plan, container) - bottle = _DockerBottle(container, teardown, prompt_path) + bottle = DockerBottle(container, teardown, prompt_path) yield bottle finally: teardown() From 1d2c18eaae988b54865c82c8db55c46392ed1e4a Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 23:37:28 -0400 Subject: [PATCH 17/44] refactor(platform): rename claude_bottle/bottles -> claude_bottle/platform 'bottles' was the package name when it held a single Bottle Protocol; since we added BottlePlatform / BottlePlan / BottleCleanupPlan and made it the home of platform dispatch, 'platform' describes the package better. The 'bottle' concept (and the manifest field) stays. CLI imports update from ..bottles to ..platform; internal relative imports inside the package survive the rename unchanged. Git detected all 7 file renames. --- claude_bottle/cli/cleanup.py | 2 +- claude_bottle/cli/list.py | 2 +- claude_bottle/cli/start.py | 2 +- claude_bottle/{bottles => platform}/__init__.py | 0 claude_bottle/{bottles => platform}/docker/__init__.py | 2 +- claude_bottle/{bottles => platform}/docker/bottle.py | 0 .../{bottles => platform}/docker/bottle_cleanup_plan.py | 0 claude_bottle/{bottles => platform}/docker/bottle_plan.py | 0 claude_bottle/{bottles => platform}/docker/platform.py | 0 claude_bottle/{bottles => platform}/docker/util.py | 0 10 files changed, 4 insertions(+), 4 deletions(-) rename claude_bottle/{bottles => platform}/__init__.py (100%) rename claude_bottle/{bottles => platform}/docker/__init__.py (91%) rename claude_bottle/{bottles => platform}/docker/bottle.py (100%) rename claude_bottle/{bottles => platform}/docker/bottle_cleanup_plan.py (100%) rename claude_bottle/{bottles => platform}/docker/bottle_plan.py (100%) rename claude_bottle/{bottles => platform}/docker/platform.py (100%) rename claude_bottle/{bottles => platform}/docker/util.py (100%) diff --git a/claude_bottle/cli/cleanup.py b/claude_bottle/cli/cleanup.py index b06a018..1228d9e 100644 --- a/claude_bottle/cli/cleanup.py +++ b/claude_bottle/cli/cleanup.py @@ -5,7 +5,7 @@ from __future__ import annotations import sys -from ..bottles import get_bottle_platform +from ..platform import get_bottle_platform from ..log import info from ._common import read_tty_line diff --git a/claude_bottle/cli/list.py b/claude_bottle/cli/list.py index af40292..5ce8079 100644 --- a/claude_bottle/cli/list.py +++ b/claude_bottle/cli/list.py @@ -4,7 +4,7 @@ from __future__ import annotations import argparse -from ..bottles import get_bottle_platform +from ..platform import get_bottle_platform from ..manifest import Manifest from ._common import PROG, USER_CWD diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index 962afb9..ba01baa 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -11,7 +11,7 @@ import sys import tempfile from pathlib import Path -from ..bottles import BottleSpec, get_bottle_platform +from ..platform import BottleSpec, get_bottle_platform from ..log import info from ..manifest import Manifest from ._common import PROG, USER_CWD, read_tty_line diff --git a/claude_bottle/bottles/__init__.py b/claude_bottle/platform/__init__.py similarity index 100% rename from claude_bottle/bottles/__init__.py rename to claude_bottle/platform/__init__.py diff --git a/claude_bottle/bottles/docker/__init__.py b/claude_bottle/platform/docker/__init__.py similarity index 91% rename from claude_bottle/bottles/docker/__init__.py rename to claude_bottle/platform/docker/__init__.py index 3a34d7d..f3c4cf1 100644 --- a/claude_bottle/bottles/docker/__init__.py +++ b/claude_bottle/platform/docker/__init__.py @@ -9,7 +9,7 @@ The bulk of the implementation lives in sibling modules: - platform: DockerBottlePlatform This file only re-exports the public names so -`from claude_bottle.bottles.docker import DockerBottlePlatform` keeps +`from claude_bottle.platform.docker import DockerBottlePlatform` keeps working. """ diff --git a/claude_bottle/bottles/docker/bottle.py b/claude_bottle/platform/docker/bottle.py similarity index 100% rename from claude_bottle/bottles/docker/bottle.py rename to claude_bottle/platform/docker/bottle.py diff --git a/claude_bottle/bottles/docker/bottle_cleanup_plan.py b/claude_bottle/platform/docker/bottle_cleanup_plan.py similarity index 100% rename from claude_bottle/bottles/docker/bottle_cleanup_plan.py rename to claude_bottle/platform/docker/bottle_cleanup_plan.py diff --git a/claude_bottle/bottles/docker/bottle_plan.py b/claude_bottle/platform/docker/bottle_plan.py similarity index 100% rename from claude_bottle/bottles/docker/bottle_plan.py rename to claude_bottle/platform/docker/bottle_plan.py diff --git a/claude_bottle/bottles/docker/platform.py b/claude_bottle/platform/docker/platform.py similarity index 100% rename from claude_bottle/bottles/docker/platform.py rename to claude_bottle/platform/docker/platform.py diff --git a/claude_bottle/bottles/docker/util.py b/claude_bottle/platform/docker/util.py similarity index 100% rename from claude_bottle/bottles/docker/util.py rename to claude_bottle/platform/docker/util.py From c79966731c4fde654c12fcdd8329e9a133e25e5f Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 23:40:58 -0400 Subject: [PATCH 18/44] refactor(docker): move network.py into platform/docker/ The Docker bridge / internal network primitives are Docker-specific; they belong inside the Docker platform package alongside util.py and the rest. Same logic the earlier top-level docker.py move followed. Imports: - platform.py: `from ... import network as network_mod` -> `from . import network as network_mod` - network.py: `from .log import ...` -> `from ...log import ...` - tests/test_orphan_cleanup.py: from claude_bottle.network -> from claude_bottle.platform.docker.network --- claude_bottle/{ => platform/docker}/network.py | 2 +- claude_bottle/platform/docker/platform.py | 2 +- tests/test_orphan_cleanup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename claude_bottle/{ => platform/docker}/network.py (99%) diff --git a/claude_bottle/network.py b/claude_bottle/platform/docker/network.py similarity index 99% rename from claude_bottle/network.py rename to claude_bottle/platform/docker/network.py index 2a60eb7..f6d1e68 100644 --- a/claude_bottle/network.py +++ b/claude_bottle/platform/docker/network.py @@ -16,7 +16,7 @@ from __future__ import annotations import subprocess -from .log import die, info, warn +from ...log import die, info, warn def network_name_for_slug(slug: str) -> str: diff --git a/claude_bottle/platform/docker/platform.py b/claude_bottle/platform/docker/platform.py index f4952b6..9434efa 100644 --- a/claude_bottle/platform/docker/platform.py +++ b/claude_bottle/platform/docker/platform.py @@ -17,13 +17,13 @@ from contextlib import contextmanager from pathlib import Path from typing import Iterator -from ... import network as network_mod from ... import pipelock from ... import skills as skills_mod from ... import ssh as ssh_mod from ...env_resolve import env_resolve from ...log import die, info from .. import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec +from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan diff --git a/tests/test_orphan_cleanup.py b/tests/test_orphan_cleanup.py index 847f63b..3b9ae79 100644 --- a/tests/test_orphan_cleanup.py +++ b/tests/test_orphan_cleanup.py @@ -7,7 +7,7 @@ import os import subprocess import unittest -from claude_bottle.network import ( +from claude_bottle.platform.docker.network import ( network_create_egress, network_create_internal, network_remove, From 70a22fa21055bfb841365c558a3d5235d07411ce Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 23:59:38 -0400 Subject: [PATCH 19/44] refactor: rename platform abstraction to backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Across the package: - claude_bottle/platform/ -> claude_bottle/backend/ - platform/docker/platform.py -> backend/docker/backend.py - class BottlePlatform -> BottleBackend - class DockerBottlePlatform -> DockerBottleBackend - get_bottle_platform() -> get_bottle_backend() - env var CLAUDE_BOTTLE_PLATFORM -> CLAUDE_BOTTLE_BACKEND - dict _PLATFORMS -> _BACKENDS "Backend" is shorter and more established as the term for a pluggable strategy-pattern implementation. "Platform" was vague (could mean OS, hardware, cloud) and mildly redundant — Docker is itself a platform. The previous PRD section claiming "the Backend protocol was rejected" referred to a low-level run/exec/cp/network_connect protocol; the name was never the reason. The PRD is updated to describe that rejected design by shape rather than by name. The bottle/agent concepts and the manifest schema are unchanged. --- .../{platform => backend}/__init__.py | 63 ++++++++++--------- .../{platform => backend}/docker/__init__.py | 11 ++-- .../platform.py => backend/docker/backend.py} | 12 ++-- .../{platform => backend}/docker/bottle.py | 2 +- .../docker/bottle_cleanup_plan.py | 4 +- .../docker/bottle_plan.py | 4 +- .../{platform => backend}/docker/network.py | 0 .../{platform => backend}/docker/util.py | 2 +- claude_bottle/cli/cleanup.py | 8 +-- claude_bottle/cli/list.py | 4 +- claude_bottle/cli/start.py | 8 +-- docs/prds/0003-bottle-factory-abstraction.md | 58 ++++++++--------- tests/test_orphan_cleanup.py | 2 +- 13 files changed, 91 insertions(+), 87 deletions(-) rename claude_bottle/{platform => backend}/__init__.py (66%) rename claude_bottle/{platform => backend}/docker/__init__.py (68%) rename claude_bottle/{platform/docker/platform.py => backend/docker/backend.py} (97%) rename claude_bottle/{platform => backend}/docker/bottle.py (97%) rename claude_bottle/{platform => backend}/docker/bottle_cleanup_plan.py (87%) rename claude_bottle/{platform => backend}/docker/bottle_plan.py (95%) rename claude_bottle/{platform => backend}/docker/network.py (100%) rename claude_bottle/{platform => backend}/docker/util.py (98%) diff --git a/claude_bottle/platform/__init__.py b/claude_bottle/backend/__init__.py similarity index 66% rename from claude_bottle/platform/__init__.py rename to claude_bottle/backend/__init__.py index 893eb5b..ea67c52 100644 --- a/claude_bottle/platform/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -1,7 +1,7 @@ -"""Per-platform bottle factories. +"""Per-backend bottle factories. A bottle is a running, isolated environment with claude inside. Each -platform exposes four methods: +backend exposes five methods: prepare(spec, stage_dir=...) -> BottlePlan Resolves names, validates host-side prerequisites, and writes @@ -19,8 +19,11 @@ platform exposes four methods: cleanup(plan) -> None Actually removes everything described by the cleanup plan. -Selection is driven by CLAUDE_BOTTLE_PLATFORM (default "docker"). Per -PRD 0003 the manifest does not carry a platform field; the host + list_active() -> None + Print every currently-running bottle on this backend to stderr. + +Selection is driven by CLAUDE_BOTTLE_BACKEND (default "docker"). Per +PRD 0003 the manifest does not carry a backend field; the host environment picks. """ @@ -38,8 +41,8 @@ from ..manifest import Manifest @dataclass(frozen=True) class BottleSpec: - """CLI-supplied intent. Platform-agnostic — each platform's prepare - step consumes it and produces its own platform-specific plan. + """CLI-supplied intent. Backend-agnostic — each backend's prepare + step consumes it and produces its own backend-specific plan. Resolved values (image names, container name, scratch paths, runsc availability) live on the plan, not the spec.""" @@ -52,8 +55,8 @@ class BottleSpec: @dataclass(frozen=True) class BottlePlan(ABC): - """Base output of a platform's prepare step. Concrete subclasses - (e.g. DockerBottlePlan) add platform-specific resolved fields and + """Base output of a backend's prepare step. Concrete subclasses + (e.g. DockerBottlePlan) add backend-specific resolved fields and implement `print`.""" spec: BottleSpec @@ -66,8 +69,8 @@ class BottlePlan(ABC): @dataclass(frozen=True) class BottleCleanupPlan(ABC): - """Base output of a platform's prepare_cleanup step. Concrete - subclasses (e.g. DockerBottleCleanupPlan) carry platform-specific + """Base output of a backend's prepare_cleanup step. Concrete + subclasses (e.g. DockerBottleCleanupPlan) carry backend-specific lists of resources to be removed and implement `print` + `empty`.""" @abstractmethod @@ -82,7 +85,7 @@ class BottleCleanupPlan(ABC): class Bottle(ABC): - """Handle to a running bottle. Yielded by a platform's launch step. + """Handle to a running bottle. Yielded by a backend's launch step. `exec_claude` runs `claude` inside the bottle and blocks until the session ends. `cp_in` copies a host path into the bottle. `close` @@ -101,9 +104,9 @@ class Bottle(ABC): def close(self) -> None: ... -class BottlePlatform(ABC): - """Abstract base for selectable bottle platforms. Concrete subclasses - (e.g. DockerBottlePlatform) own their own prepare/launch impls. +class BottleBackend(ABC): + """Abstract base for selectable bottle backends. Concrete subclasses + (e.g. DockerBottleBackend) own their own prepare/launch impls. Symmetric with the BottlePlan → DockerBottlePlan hierarchy.""" name: str @@ -128,37 +131,37 @@ class BottlePlatform(ABC): @abstractmethod def list_active(self) -> None: - """Print every currently-running bottle on this platform to + """Print every currently-running bottle on this backend to stderr (name + status).""" -# Import concrete platform classes AFTER the base types are defined, so -# each platform module can pull BottleSpec / BottlePlan / BottlePlatform +# Import concrete backend classes AFTER the base types are defined, so +# each backend module can pull BottleSpec / BottlePlan / BottleBackend # via `from . import ...` without hitting a partially-initialized module. -from .docker import DockerBottlePlatform # noqa: E402 +from .docker import DockerBottleBackend # noqa: E402 -_PLATFORMS: dict[str, BottlePlatform] = { - "docker": DockerBottlePlatform(), +_BACKENDS: dict[str, BottleBackend] = { + "docker": DockerBottleBackend(), } -def get_bottle_platform() -> BottlePlatform: - """Resolve the bottle platform for the active environment. Dies with - a pointer at the known platforms if CLAUDE_BOTTLE_PLATFORM names an +def get_bottle_backend() -> BottleBackend: + """Resolve the bottle backend for the active environment. Dies with + a pointer at the known backends if CLAUDE_BOTTLE_BACKEND names an unimplemented one.""" - name = os.environ.get("CLAUDE_BOTTLE_PLATFORM", "docker") - if name not in _PLATFORMS: - known = ", ".join(sorted(_PLATFORMS)) - die(f"unknown CLAUDE_BOTTLE_PLATFORM={name!r}; known platforms: {known}") - return _PLATFORMS[name] + name = os.environ.get("CLAUDE_BOTTLE_BACKEND", "docker") + if name not in _BACKENDS: + known = ", ".join(sorted(_BACKENDS)) + die(f"unknown CLAUDE_BOTTLE_BACKEND={name!r}; known backends: {known}") + return _BACKENDS[name] __all__ = [ "Bottle", + "BottleBackend", "BottleCleanupPlan", "BottlePlan", - "BottlePlatform", "BottleSpec", - "get_bottle_platform", + "get_bottle_backend", ] diff --git a/claude_bottle/platform/docker/__init__.py b/claude_bottle/backend/docker/__init__.py similarity index 68% rename from claude_bottle/platform/docker/__init__.py rename to claude_bottle/backend/docker/__init__.py index f3c4cf1..7af34a0 100644 --- a/claude_bottle/platform/docker/__init__.py +++ b/claude_bottle/backend/docker/__init__.py @@ -1,28 +1,29 @@ -"""Docker bottle platform. +"""Docker bottle backend. The bulk of the implementation lives in sibling modules: - util: thin Docker subprocess wrappers + - network: Docker network plumbing - bottle_plan: DockerBottlePlan - bottle_cleanup_plan: DockerBottleCleanupPlan - bottle: DockerBottle handle - - platform: DockerBottlePlatform + - backend: DockerBottleBackend This file only re-exports the public names so -`from claude_bottle.platform.docker import DockerBottlePlatform` keeps +`from claude_bottle.backend.docker import DockerBottleBackend` keeps working. """ from __future__ import annotations +from .backend import DockerBottleBackend from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan -from .platform import DockerBottlePlatform __all__ = [ "DockerBottle", + "DockerBottleBackend", "DockerBottleCleanupPlan", "DockerBottlePlan", - "DockerBottlePlatform", ] diff --git a/claude_bottle/platform/docker/platform.py b/claude_bottle/backend/docker/backend.py similarity index 97% rename from claude_bottle/platform/docker/platform.py rename to claude_bottle/backend/docker/backend.py index 9434efa..c425874 100644 --- a/claude_bottle/platform/docker/platform.py +++ b/claude_bottle/backend/docker/backend.py @@ -1,4 +1,4 @@ -"""DockerBottlePlatform — the Docker implementation of BottlePlatform. +"""DockerBottleBackend — the Docker implementation of BottleBackend. Methods: .prepare(spec, stage_dir=...) -> DockerBottlePlan @@ -22,7 +22,7 @@ from ... import skills as skills_mod from ... import ssh as ssh_mod from ...env_resolve import env_resolve from ...log import die, info -from .. import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec +from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle @@ -34,8 +34,8 @@ from .bottle_plan import DockerBottlePlan _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) -class DockerBottlePlatform(BottlePlatform): - """Docker platform implementation. Selected by CLAUDE_BOTTLE_PLATFORM +class DockerBottleBackend(BottleBackend): + """Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND (default).""" name = "docker" @@ -131,7 +131,7 @@ class DockerBottlePlatform(BottlePlatform): def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]: """Build, launch, and provision a Docker bottle. Teardown on exit.""" assert isinstance(plan, DockerBottlePlan), ( - f"DockerBottlePlatform.launch expects DockerBottlePlan, " + f"DockerBottleBackend.launch expects DockerBottlePlan, " f"got {type(plan).__name__}" ) @@ -358,7 +358,7 @@ class DockerBottlePlatform(BottlePlatform): Containers first; networks would refuse to delete while containers are still attached.""" assert isinstance(plan, DockerBottleCleanupPlan), ( - f"DockerBottlePlatform.cleanup expects DockerBottleCleanupPlan, " + f"DockerBottleBackend.cleanup expects DockerBottleCleanupPlan, " f"got {type(plan).__name__}" ) for name in plan.containers: diff --git a/claude_bottle/platform/docker/bottle.py b/claude_bottle/backend/docker/bottle.py similarity index 97% rename from claude_bottle/platform/docker/bottle.py rename to claude_bottle/backend/docker/bottle.py index 32a3770..a93e64d 100644 --- a/claude_bottle/platform/docker/bottle.py +++ b/claude_bottle/backend/docker/bottle.py @@ -1,5 +1,5 @@ """DockerBottle — concrete Bottle handle yielded by -DockerBottlePlatform.launch. +DockerBottleBackend.launch. Holds the container name plus the in-container prompt path so exec_claude can transparently add --append-system-prompt-file when a diff --git a/claude_bottle/platform/docker/bottle_cleanup_plan.py b/claude_bottle/backend/docker/bottle_cleanup_plan.py similarity index 87% rename from claude_bottle/platform/docker/bottle_cleanup_plan.py rename to claude_bottle/backend/docker/bottle_cleanup_plan.py index 1cfa766..fd54ad4 100644 --- a/claude_bottle/platform/docker/bottle_cleanup_plan.py +++ b/claude_bottle/backend/docker/bottle_cleanup_plan.py @@ -1,7 +1,7 @@ """DockerBottleCleanupPlan — concrete subclass of BottleCleanupPlan. Holds the tuples of container and network names that -DockerBottlePlatform.cleanup will remove. The y/N preflight reads +DockerBottleBackend.cleanup will remove. The y/N preflight reads these via `print`; the CLI short-circuits via `empty`. """ @@ -16,7 +16,7 @@ from .. import BottleCleanupPlan @dataclass(frozen=True) class DockerBottleCleanupPlan(BottleCleanupPlan): - """Resources DockerBottlePlatform.cleanup will remove. Produced by + """Resources DockerBottleBackend.cleanup will remove. Produced by `prepare_cleanup` from a snapshot of `docker ps -a` + `docker network ls`; sorted so the y/N output is stable.""" diff --git a/claude_bottle/platform/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py similarity index 95% rename from claude_bottle/platform/docker/bottle_plan.py rename to claude_bottle/backend/docker/bottle_plan.py index f729c9c..187987e 100644 --- a/claude_bottle/platform/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -1,7 +1,7 @@ """DockerBottlePlan — concrete subclass of BottlePlan. Carries the Docker-specific resolved fields produced by -DockerBottlePlatform.prepare. The launch step consumes it without +DockerBottleBackend.prepare. The launch step consumes it without further resolution; show_plan-style rendering is the `print` method. """ @@ -18,7 +18,7 @@ from .. import BottlePlan @dataclass(frozen=True) class DockerBottlePlan(BottlePlan): """Docker-specific resolved fields produced by - DockerBottlePlatform.prepare. Inherits `spec` and `stage_dir` from + DockerBottleBackend.prepare. Inherits `spec` and `stage_dir` from BottlePlan.""" slug: str diff --git a/claude_bottle/platform/docker/network.py b/claude_bottle/backend/docker/network.py similarity index 100% rename from claude_bottle/platform/docker/network.py rename to claude_bottle/backend/docker/network.py diff --git a/claude_bottle/platform/docker/util.py b/claude_bottle/backend/docker/util.py similarity index 98% rename from claude_bottle/platform/docker/util.py rename to claude_bottle/backend/docker/util.py index 221e8a2..75318e8 100644 --- a/claude_bottle/platform/docker/util.py +++ b/claude_bottle/backend/docker/util.py @@ -1,4 +1,4 @@ -"""Docker host-side primitives used by DockerBottlePlatform: probing +"""Docker host-side primitives used by DockerBottleBackend: probing for docker on PATH, slugifying agent names, checking image/container existence, and building images.""" diff --git a/claude_bottle/cli/cleanup.py b/claude_bottle/cli/cleanup.py index 1228d9e..cfaafa1 100644 --- a/claude_bottle/cli/cleanup.py +++ b/claude_bottle/cli/cleanup.py @@ -5,14 +5,14 @@ from __future__ import annotations import sys -from ..platform import get_bottle_platform +from ..backend import get_bottle_backend from ..log import info from ._common import read_tty_line def cmd_cleanup(_argv: list[str]) -> int: - platform = get_bottle_platform() - plan = platform.prepare_cleanup() + backend = get_bottle_backend() + plan = backend.prepare_cleanup() if plan.empty: info("no claude-bottle resources to clean up") @@ -26,6 +26,6 @@ def cmd_cleanup(_argv: list[str]) -> int: info("aborted") return 0 - platform.cleanup(plan) + backend.cleanup(plan) info("done") return 0 diff --git a/claude_bottle/cli/list.py b/claude_bottle/cli/list.py index 5ce8079..74f52ee 100644 --- a/claude_bottle/cli/list.py +++ b/claude_bottle/cli/list.py @@ -4,7 +4,7 @@ from __future__ import annotations import argparse -from ..platform import get_bottle_platform +from ..backend import get_bottle_backend from ..manifest import Manifest from ._common import PROG, USER_CWD @@ -20,5 +20,5 @@ def cmd_list(argv: list[str]) -> int: print(name) return 0 - get_bottle_platform().list_active() + get_bottle_backend().list_active() return 0 diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index ba01baa..81b2c5e 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -11,7 +11,7 @@ import sys import tempfile from pathlib import Path -from ..platform import BottleSpec, get_bottle_platform +from ..backend import BottleSpec, get_bottle_backend from ..log import info from ..manifest import Manifest from ._common import PROG, USER_CWD, read_tty_line @@ -38,8 +38,8 @@ def cmd_start(argv: list[str]) -> int: stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage.")) try: - platform = get_bottle_platform() - plan = platform.prepare(spec, stage_dir=stage_dir) + backend = get_bottle_backend() + plan = backend.prepare(spec, stage_dir=stage_dir) plan.print(remote_control=args.remote_control) if dry_run: @@ -53,7 +53,7 @@ def cmd_start(argv: list[str]) -> int: info("aborted by user") return 0 - with platform.launch(plan) as bottle: + with backend.launch(plan) as bottle: info( "attaching interactive claude session " "(Ctrl-D or 'exit' to leave; container will be removed)" diff --git a/docs/prds/0003-bottle-factory-abstraction.md b/docs/prds/0003-bottle-factory-abstraction.md index 0a8e080..35b41e0 100644 --- a/docs/prds/0003-bottle-factory-abstraction.md +++ b/docs/prds/0003-bottle-factory-abstraction.md @@ -6,10 +6,10 @@ ## Summary -Introduce a per-platform factory function that owns the end-to-end +Introduce a per-backend factory function that owns the end-to-end lifecycle of a "bottle" (a running, isolated environment with claude inside). The first and only implementation lands as -`create_docker_bottle`. No second platform ships in this PRD. +`create_docker_bottle`. No second backend ships in this PRD. ## Problem @@ -33,11 +33,11 @@ Today, "how to launch a bottle" is spread across roughly six modules and exists only because the current code can't decide on its own. The shape that fits the project's actual goals (isolated agent runs -across multiple platforms) is "one factory per platform," not "one +across multiple backends) is "one factory per backend," not "one container-runtime SDK with N drivers." A previous draft of this PRD -considered a low-level `Backend` protocol (`run`, `exec`, `cp`, -`network_connect`, ...) and rejected it as the wrong layer — it would -have forced fly.io to pretend it's Docker. +considered a low-level runtime-primitive protocol (`run`, `exec`, +`cp`, `network_connect`, ...) and rejected it as the wrong layer — +it would have forced fly.io to pretend it's Docker. ## Goals / Success Criteria @@ -57,7 +57,7 @@ The feature works when all of the following are observable: The feature is **done** when all of the following ship: -- A new `claude_bottle/bottles/` package exists with +- A new `claude_bottle/backend/` package exists with `__init__.py` (factory selection) and `docker.py` (`create_docker_bottle`). - `create_docker_bottle` returns a context manager yielding a `Bottle` @@ -65,23 +65,23 @@ The feature is **done** when all of the following ship: and teardown on context exit. - Every existing `subprocess.run(["docker", ...])` call in `cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and - `skills.py` either moves into `bottles/docker.py` or is called from + `skills.py` either moves into `backend/docker.py` or is called from it. No top-level CLI code references `docker` directly. - `bottles[].runtime` is removed from the manifest schema, the dataclass in `manifest.py`, the example manifest, and any README / docs references. `require_runsc()` in `claude_bottle/docker.py` is deleted. -- A single env var, `CLAUDE_BOTTLE_PLATFORM` (default `"docker"`), +- A single env var, `CLAUDE_BOTTLE_BACKEND` (default `"docker"`), selects the factory. Unknown values die at startup with a list of - known platforms. + known backends. - The y/N preflight in `cli.py` includes the resolved Docker runtime alongside the allowlist summary. ## Non-goals -- No second platform implementation. `create_container_bottle` and +- No second backend implementation. `create_container_bottle` and `create_flyio_bottle` are not in this PRD. The factory dict in - `bottles/__init__.py` ships with one entry. + `backend/__init__.py` ships with one entry. - No retries, async, or streaming exec. The current code is synchronous `subprocess.run`; the `Bottle` handle matches. - No behavior change beyond the runsc auto-detect. Pipelock topology, @@ -89,14 +89,14 @@ The feature is **done** when all of the following ship: provisioning all stay byte-identical. - No `--require-runsc` CLI escape hatch. If a user later wants "fail rather than silently downgrade," that's a follow-up. -- No `bottles[].platform` manifest field. Platform is a property of +- No `bottles[].backend` manifest field. Backend is a property of the host environment, not the bottle definition (at least for now). ## Scope ### In scope -- New `claude_bottle/bottles/` package containing `__init__.py` and +- New `claude_bottle/backend/` package containing `__init__.py` and `docker.py`. - The `Bottle` Protocol definition and `create_docker_bottle` factory. - Moving Docker-specific subprocess calls into the factory. @@ -113,9 +113,9 @@ The feature is **done** when all of the following ship: - Apple `container` and fly.io factories (separate PRDs, deferred until the Docker factory is the only thing shipping). -- Generalizing the pipelock sidecar to other platforms. Pipelock +- Generalizing the pipelock sidecar to other backends. Pipelock topology is, after this PRD, an implementation detail private to - `bottles/docker.py`. + `backend/docker.py`. - Rewriting `pipelock.py`'s YAML generation. The allowlist→YAML translation stays where it is and is called by the Docker factory. - Changes to `env_resolve.py`, `manifest.py` (beyond the `runtime` @@ -126,13 +126,13 @@ The feature is **done** when all of the following ship: ### New services / components -A new package, `claude_bottle/bottles/`: +A new package, `claude_bottle/backend/`: -- **`claude_bottle/bottles/__init__.py`** — Defines the `Bottle` +- **`claude_bottle/backend/__init__.py`** — Defines the `Bottle` Protocol and `get_bottle_factory()`. The factory registry is a - module-level dict mapping platform name → factory function. - Selection reads `CLAUDE_BOTTLE_PLATFORM` (default `"docker"`). - Unknown values call `die()` with the list of known platforms. + module-level dict mapping backend name → factory function. + Selection reads `CLAUDE_BOTTLE_BACKEND` (default `"docker"`). + Unknown values call `die()` with the list of known backends. ```python class Bottle(Protocol): @@ -145,7 +145,7 @@ A new package, `claude_bottle/bottles/`: ... ``` -- **`claude_bottle/bottles/docker.py`** — `create_docker_bottle(...)`, +- **`claude_bottle/backend/docker.py`** — `create_docker_bottle(...)`, the only factory implementation in this PRD. Owns: - probing for `runsc` availability (`docker info --format '{{json .Runtimes}}'`), @@ -178,19 +178,19 @@ A new package, `claude_bottle/bottles/`: consumes. - **`claude_bottle/pipelock.py`** — keep all the allowlist resolution and YAML generation. Remove `pipelock_start` / `pipelock_stop` (or - inline them into `bottles/docker.py` — decide during + inline them into `backend/docker.py` — decide during implementation). Pipelock-the-sidecar becomes a Docker-factory internal concept. - **`claude_bottle/network.py`** — same call-sites moved into - `bottles/docker.py`. The module either becomes a thin set of pure + `backend/docker.py`. The module either becomes a thin set of pure name-derivation helpers (`network_name_for_slug`, etc.) or folds - entirely into `bottles/docker.py`. Decide during implementation. + entirely into `backend/docker.py`. Decide during implementation. - **`claude_bottle/ssh.py`** and **`claude_bottle/skills.py`** — the `docker cp` and `docker exec` calls move into / are called from - `bottles/docker.py`. The host-side file-tree generation stays put. + `backend/docker.py`. The host-side file-tree generation stays put. - **`claude-bottle.example.json`** — remove the `runtime` field from any example bottle. -- **`README.md`** — note `CLAUDE_BOTTLE_PLATFORM` and the runsc +- **`README.md`** — note `CLAUDE_BOTTLE_BACKEND` and the runsc auto-detect; remove any mention of `runtime: "runsc"` as a manifest field. @@ -241,9 +241,9 @@ they're about to run under before approving. - **Where the pipelock sidecar lifecycle lives.** Two reasonable splits: (a) `pipelock.py` keeps `pipelock_start` / `pipelock_stop` - and `bottles/docker.py` calls them; (b) the sidecar + and `backend/docker.py` calls them; (b) the sidecar `docker create/cp/network connect/start` sequence moves entirely - into `bottles/docker.py` and `pipelock.py` shrinks to the YAML + + into `backend/docker.py` and `pipelock.py` shrinks to the YAML + allowlist helpers. (a) keeps git blame intact and is the smaller diff; (b) makes pipelock-as-an-implementation-detail more obvious. Decide during implementation. diff --git a/tests/test_orphan_cleanup.py b/tests/test_orphan_cleanup.py index 3b9ae79..462c40a 100644 --- a/tests/test_orphan_cleanup.py +++ b/tests/test_orphan_cleanup.py @@ -7,7 +7,7 @@ import os import subprocess import unittest -from claude_bottle.platform.docker.network import ( +from claude_bottle.backend.docker.network import ( network_create_egress, network_create_internal, network_remove, From 7b5a79818670e510d672fe0fd6a093a021c18356 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 00:04:12 -0400 Subject: [PATCH 20/44] refactor(backend): introduce BottleProvisioner ABC + DockerBottleProvisioner Lift the file-copying-into-the-running-container step out of DockerBottleBackend._provision_container into its own class. The backend now holds a DockerBottleProvisioner instance and delegates the post-launch provisioning to it. - BottleProvisioner (abstract) in backend/__init__.py with a `provision(plan, target) -> str | None` method. - DockerBottleProvisioner (concrete) in backend/docker/provisioner.py inheriting from the base, narrowing plan to DockerBottlePlan via isinstance, and carrying the prompt/skills/SSH/.git copy logic unchanged. - DockerBottleBackend keeps a class-level DockerBottleProvisioner() and calls self._provisioner.provision(plan, container) from launch. _provision_container method removed. No behavior change. --- claude_bottle/backend/__init__.py | 17 +++++ claude_bottle/backend/docker/__init__.py | 3 + claude_bottle/backend/docker/backend.py | 58 +-------------- claude_bottle/backend/docker/provisioner.py | 79 +++++++++++++++++++++ 4 files changed, 102 insertions(+), 55 deletions(-) create mode 100644 claude_bottle/backend/docker/provisioner.py diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index ea67c52..96a175b 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -104,6 +104,22 @@ class Bottle(ABC): def close(self) -> None: ... +class BottleProvisioner(ABC): + """Copies host-side files (prompt, skills, SSH keys, .git) into a + running bottle after the container/machine is up. Owned by a + BottleBackend; called from its launch step before yielding the + Bottle handle.""" + + @abstractmethod + def provision(self, plan: BottlePlan, target: str) -> str | None: + """Provision the running bottle described by `plan`. `target` + identifies the running instance in backend-specific terms + (Docker: resolved container name; fly: machine id). Returns the + in-container prompt path if a prompt was provisioned, else + None — the Bottle handle uses it to decide whether to add + --append-system-prompt-file to claude's argv.""" + + class BottleBackend(ABC): """Abstract base for selectable bottle backends. Concrete subclasses (e.g. DockerBottleBackend) own their own prepare/launch impls. @@ -162,6 +178,7 @@ __all__ = [ "BottleBackend", "BottleCleanupPlan", "BottlePlan", + "BottleProvisioner", "BottleSpec", "get_bottle_backend", ] diff --git a/claude_bottle/backend/docker/__init__.py b/claude_bottle/backend/docker/__init__.py index 7af34a0..8f912a8 100644 --- a/claude_bottle/backend/docker/__init__.py +++ b/claude_bottle/backend/docker/__init__.py @@ -7,6 +7,7 @@ The bulk of the implementation lives in sibling modules: - bottle_plan: DockerBottlePlan - bottle_cleanup_plan: DockerBottleCleanupPlan - bottle: DockerBottle handle + - provisioner: DockerBottleProvisioner - backend: DockerBottleBackend This file only re-exports the public names so @@ -20,10 +21,12 @@ from .backend import DockerBottleBackend from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan +from .provisioner import DockerBottleProvisioner __all__ = [ "DockerBottle", "DockerBottleBackend", "DockerBottleCleanupPlan", "DockerBottlePlan", + "DockerBottleProvisioner", ] diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index c425874..074b500 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -28,6 +28,7 @@ from . import util as docker_mod from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan +from .provisioner import DockerBottleProvisioner # Where the repo root lives, for `docker build` context. Computed once. @@ -39,6 +40,7 @@ class DockerBottleBackend(BottleBackend): (default).""" name = "docker" + _provisioner: DockerBottleProvisioner = DockerBottleProvisioner() def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: """Resolve names, validate, write scratch files. No Docker @@ -185,7 +187,7 @@ class DockerBottleBackend(BottleBackend): container = self._run_agent_container(plan, state["internal_network"]) state["container"] = container - prompt_path = self._provision_container(plan, container) + prompt_path = self._provisioner.provision(plan, container) bottle = DockerBottle(container, teardown, prompt_path) yield bottle @@ -258,60 +260,6 @@ class DockerBottleBackend(BottleBackend): docker_args[name_idx] = container info(f"name conflict; retrying as {container}") - def _provision_container(self, plan: DockerBottlePlan, container: str) -> str | None: - """Copy prompt, skills, ssh keys, and (optionally) .git into the - running container. Returns the in-container prompt path if a - prompt 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") - in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" - - subprocess.run( - ["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"], - stdout=subprocess.DEVNULL, - check=True, - ) - # `docker cp` preserves host UID; re-own/mode as root so node - # can read its own mode-600 prompt regardless of host UID. - subprocess.run( - ["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - ["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path], - stdout=subprocess.DEVNULL, - check=True, - ) - - agent = plan.spec.manifest.agents[plan.spec.agent_name] - if agent.skills: - skills_mod.skills_copy_into(container, list(agent.skills)) - - bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) - if bottle.ssh: - proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) - ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) - - if plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir(): - info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") - subprocess.run( - ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - [ - "docker", "exec", "-u", "0", container, - "chown", "-R", "node:node", "/home/node/workspace/.git", - ], - stdout=subprocess.DEVNULL, - check=True, - ) - - return in_container_prompt_path if agent.prompt else None - # --- Cleanup --- def prepare_cleanup(self) -> DockerBottleCleanupPlan: diff --git a/claude_bottle/backend/docker/provisioner.py b/claude_bottle/backend/docker/provisioner.py new file mode 100644 index 0000000..538b48a --- /dev/null +++ b/claude_bottle/backend/docker/provisioner.py @@ -0,0 +1,79 @@ +"""DockerBottleProvisioner — copies prompt, skills, SSH keys, and +.git into a running Docker container. + +Called by DockerBottleBackend.launch after the agent container is up +but before the DockerBottle handle is yielded. The returned in- +container prompt path tells the handle whether to add +--append-system-prompt-file to claude's argv. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from ... import pipelock +from ... import skills as skills_mod +from ... import ssh as ssh_mod +from ...log import info +from .. import BottlePlan, BottleProvisioner +from .bottle_plan import DockerBottlePlan + + +class DockerBottleProvisioner(BottleProvisioner): + """Docker implementation of BottleProvisioner.""" + + def provision(self, plan: BottlePlan, target: str) -> str | None: + assert isinstance(plan, DockerBottlePlan), ( + f"DockerBottleProvisioner.provision expects DockerBottlePlan, " + f"got {type(plan).__name__}" + ) + container = target + container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") + in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" + + subprocess.run( + ["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"], + stdout=subprocess.DEVNULL, + check=True, + ) + # `docker cp` preserves host UID; re-own/mode as root so node + # can read its own mode-600 prompt regardless of host UID. + subprocess.run( + ["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path], + stdout=subprocess.DEVNULL, + check=True, + ) + + agent = plan.spec.manifest.agents[plan.spec.agent_name] + if agent.skills: + skills_mod.skills_copy_into(container, list(agent.skills)) + + bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) + if bottle.ssh: + proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) + ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) + + if plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir(): + info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") + subprocess.run( + ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + [ + "docker", "exec", "-u", "0", container, + "chown", "-R", "node:node", "/home/node/workspace/.git", + ], + stdout=subprocess.DEVNULL, + check=True, + ) + + return in_container_prompt_path if agent.prompt else None From 133a7a39e7cd8df2532318067fa1f3ba5eb5f16f Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 00:13:36 -0400 Subject: [PATCH 21/44] refactor(backend): fold BottleProvisioner back into BottleBackend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BottleProvisioner had no independent identity — no state, only one caller, never selected, never crossed a method boundary as data. It was a method dressed up as a class. Reverting that turn: - BottleBackend gains an abstract provision(plan, target). - DockerBottleBackend.provision absorbs the body that lived on DockerBottleProvisioner. - backend/docker/provisioner.py deleted. - BottleProvisioner ABC removed from backend/__init__.py. - launch now calls self.provision(plan, container) directly. Net: -1 file, -1 class, -1 ABC. Same behavior; tests pass. --- claude_bottle/backend/__init__.py | 26 +++---- claude_bottle/backend/docker/__init__.py | 3 - claude_bottle/backend/docker/backend.py | 63 +++++++++++++++- claude_bottle/backend/docker/provisioner.py | 79 --------------------- 4 files changed, 71 insertions(+), 100 deletions(-) delete mode 100644 claude_bottle/backend/docker/provisioner.py diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index 96a175b..7342695 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -104,20 +104,6 @@ class Bottle(ABC): def close(self) -> None: ... -class BottleProvisioner(ABC): - """Copies host-side files (prompt, skills, SSH keys, .git) into a - running bottle after the container/machine is up. Owned by a - BottleBackend; called from its launch step before yielding the - Bottle handle.""" - - @abstractmethod - def provision(self, plan: BottlePlan, target: str) -> str | None: - """Provision the running bottle described by `plan`. `target` - identifies the running instance in backend-specific terms - (Docker: resolved container name; fly: machine id). Returns the - in-container prompt path if a prompt was provisioned, else - None — the Bottle handle uses it to decide whether to add - --append-system-prompt-file to claude's argv.""" class BottleBackend(ABC): @@ -136,6 +122,17 @@ class BottleBackend(ABC): def launch(self, plan: BottlePlan) -> AbstractContextManager[Bottle]: """Build/run the bottle and yield a handle; tear down on exit.""" + @abstractmethod + def provision(self, plan: BottlePlan, target: str) -> str | None: + """Copy host-side files (prompt, skills, SSH keys, .git) into + the running bottle. Called from `launch` after the container/ + machine is up. `target` identifies the running instance in + backend-specific terms (Docker: resolved container name; fly: + machine id). Returns the in-container prompt path if a prompt + was provisioned, else None — the Bottle handle uses it to + decide whether to add --append-system-prompt-file to claude's + argv.""" + @abstractmethod def prepare_cleanup(self) -> BottleCleanupPlan: """Enumerate orphaned resources from previous bottles. No side @@ -178,7 +175,6 @@ __all__ = [ "BottleBackend", "BottleCleanupPlan", "BottlePlan", - "BottleProvisioner", "BottleSpec", "get_bottle_backend", ] diff --git a/claude_bottle/backend/docker/__init__.py b/claude_bottle/backend/docker/__init__.py index 8f912a8..7af34a0 100644 --- a/claude_bottle/backend/docker/__init__.py +++ b/claude_bottle/backend/docker/__init__.py @@ -7,7 +7,6 @@ The bulk of the implementation lives in sibling modules: - bottle_plan: DockerBottlePlan - bottle_cleanup_plan: DockerBottleCleanupPlan - bottle: DockerBottle handle - - provisioner: DockerBottleProvisioner - backend: DockerBottleBackend This file only re-exports the public names so @@ -21,12 +20,10 @@ from .backend import DockerBottleBackend from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan -from .provisioner import DockerBottleProvisioner __all__ = [ "DockerBottle", "DockerBottleBackend", "DockerBottleCleanupPlan", "DockerBottlePlan", - "DockerBottleProvisioner", ] diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 074b500..1cb50d9 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -28,7 +28,6 @@ from . import util as docker_mod from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan -from .provisioner import DockerBottleProvisioner # Where the repo root lives, for `docker build` context. Computed once. @@ -40,7 +39,6 @@ class DockerBottleBackend(BottleBackend): (default).""" name = "docker" - _provisioner: DockerBottleProvisioner = DockerBottleProvisioner() def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: """Resolve names, validate, write scratch files. No Docker @@ -187,7 +185,7 @@ class DockerBottleBackend(BottleBackend): container = self._run_agent_container(plan, state["internal_network"]) state["container"] = container - prompt_path = self._provisioner.provision(plan, container) + prompt_path = self.provision(plan, container) bottle = DockerBottle(container, teardown, prompt_path) yield bottle @@ -260,6 +258,65 @@ class DockerBottleBackend(BottleBackend): docker_args[name_idx] = container info(f"name conflict; retrying as {container}") + def provision(self, plan: BottlePlan, target: str) -> str | None: + """Copy prompt, skills, ssh keys, and (optionally) .git into + the running container. `target` is the resolved container + name. Returns the in-container prompt path if a prompt was + provisioned, else None — the Bottle handle uses it to decide + whether to add --append-system-prompt-file to claude's argv.""" + assert isinstance(plan, DockerBottlePlan), ( + f"DockerBottleBackend.provision expects DockerBottlePlan, " + f"got {type(plan).__name__}" + ) + container = target + container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") + in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" + + subprocess.run( + ["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"], + stdout=subprocess.DEVNULL, + check=True, + ) + # `docker cp` preserves host UID; re-own/mode as root so node + # can read its own mode-600 prompt regardless of host UID. + subprocess.run( + ["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path], + stdout=subprocess.DEVNULL, + check=True, + ) + + agent = plan.spec.manifest.agents[plan.spec.agent_name] + if agent.skills: + skills_mod.skills_copy_into(container, list(agent.skills)) + + bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) + if bottle.ssh: + proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) + ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) + + if plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir(): + info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") + subprocess.run( + ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + [ + "docker", "exec", "-u", "0", container, + "chown", "-R", "node:node", "/home/node/workspace/.git", + ], + stdout=subprocess.DEVNULL, + check=True, + ) + + return in_container_prompt_path if agent.prompt else None + # --- Cleanup --- def prepare_cleanup(self) -> DockerBottleCleanupPlan: diff --git a/claude_bottle/backend/docker/provisioner.py b/claude_bottle/backend/docker/provisioner.py deleted file mode 100644 index 538b48a..0000000 --- a/claude_bottle/backend/docker/provisioner.py +++ /dev/null @@ -1,79 +0,0 @@ -"""DockerBottleProvisioner — copies prompt, skills, SSH keys, and -.git into a running Docker container. - -Called by DockerBottleBackend.launch after the agent container is up -but before the DockerBottle handle is yielded. The returned in- -container prompt path tells the handle whether to add ---append-system-prompt-file to claude's argv. -""" - -from __future__ import annotations - -import os -import subprocess -from pathlib import Path - -from ... import pipelock -from ... import skills as skills_mod -from ... import ssh as ssh_mod -from ...log import info -from .. import BottlePlan, BottleProvisioner -from .bottle_plan import DockerBottlePlan - - -class DockerBottleProvisioner(BottleProvisioner): - """Docker implementation of BottleProvisioner.""" - - def provision(self, plan: BottlePlan, target: str) -> str | None: - assert isinstance(plan, DockerBottlePlan), ( - f"DockerBottleProvisioner.provision expects DockerBottlePlan, " - f"got {type(plan).__name__}" - ) - container = target - container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") - in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" - - subprocess.run( - ["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"], - stdout=subprocess.DEVNULL, - check=True, - ) - # `docker cp` preserves host UID; re-own/mode as root so node - # can read its own mode-600 prompt regardless of host UID. - subprocess.run( - ["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - ["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path], - stdout=subprocess.DEVNULL, - check=True, - ) - - agent = plan.spec.manifest.agents[plan.spec.agent_name] - if agent.skills: - skills_mod.skills_copy_into(container, list(agent.skills)) - - bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) - if bottle.ssh: - proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) - ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) - - if plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir(): - info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") - subprocess.run( - ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - [ - "docker", "exec", "-u", "0", container, - "chown", "-R", "node:node", "/home/node/workspace/.git", - ], - stdout=subprocess.DEVNULL, - check=True, - ) - - return in_container_prompt_path if agent.prompt else None From 5a024259a6572a2e518f6ed481cf45347512524a Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 00:20:22 -0400 Subject: [PATCH 22/44] refactor(docker): split provision into provision_prompt / _ssh / _git provision now orchestrates three focused sub-methods. Each sub-method self-gates: provision_ssh is a no-op when the bottle has no SSH entries; provision_git is a no-op when --cwd was not set. The prompt copy + chown always runs (so the path always exists in-container); the return is gated on whether the agent has a non-empty prompt. --- claude_bottle/backend/docker/backend.py | 69 ++++++++++++++++--------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 1cb50d9..6421da4 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -269,6 +269,20 @@ class DockerBottleBackend(BottleBackend): f"got {type(plan).__name__}" ) container = target + + prompt_path = self.provision_prompt(plan, container) + agent = plan.spec.manifest.agents[plan.spec.agent_name] + if agent.skills: + skills_mod.skills_copy_into(container, list(agent.skills)) + self.provision_ssh(plan, container) + self.provision_git(plan, container) + return prompt_path + + def provision_prompt(self, plan: DockerBottlePlan, container: str) -> str | None: + """Copy the prompt file into the container, fix ownership/mode. + Returns the in-container path if the agent has a non-empty + prompt (drives --append-system-prompt-file), else None. The + file is copied either way so the path always exists.""" container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" @@ -291,32 +305,39 @@ class DockerBottleBackend(BottleBackend): ) agent = plan.spec.manifest.agents[plan.spec.agent_name] - if agent.skills: - skills_mod.skills_copy_into(container, list(agent.skills)) - - bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) - if bottle.ssh: - proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) - ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) - - if plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir(): - info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") - subprocess.run( - ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - [ - "docker", "exec", "-u", "0", container, - "chown", "-R", "node:node", "/home/node/workspace/.git", - ], - stdout=subprocess.DEVNULL, - check=True, - ) - return in_container_prompt_path if agent.prompt else None + def provision_ssh(self, plan: DockerBottlePlan, container: str) -> None: + """If the bottle has SSH entries, set up the in-container + ssh-agent and config so node can authenticate without ever + seeing the key bytes. No-op when the bottle has no SSH.""" + bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) + if not bottle.ssh: + return + proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) + ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) + + def provision_git(self, plan: DockerBottlePlan, container: str) -> None: + """If --cwd was set and the host cwd has a .git directory, copy + it into /home/node/workspace/.git and fix ownership. No-op + otherwise.""" + if not (plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir()): + return + info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") + subprocess.run( + ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + [ + "docker", "exec", "-u", "0", container, + "chown", "-R", "node:node", "/home/node/workspace/.git", + ], + stdout=subprocess.DEVNULL, + check=True, + ) + # --- Cleanup --- def prepare_cleanup(self) -> DockerBottleCleanupPlan: From 5d46d1bea43b14e8215010f83c212d0bbbef02d7 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 00:26:10 -0400 Subject: [PATCH 23/44] refactor(docker): extract provision_skills to mirror the others Four symmetric provision sub-methods now: provision_prompt, provision_skills, provision_ssh, provision_git. Each self-gates with an early return; provision is pure orchestration. --- claude_bottle/backend/docker/backend.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 6421da4..cb4610d 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -271,9 +271,7 @@ class DockerBottleBackend(BottleBackend): container = target prompt_path = self.provision_prompt(plan, container) - agent = plan.spec.manifest.agents[plan.spec.agent_name] - if agent.skills: - skills_mod.skills_copy_into(container, list(agent.skills)) + self.provision_skills(plan, container) self.provision_ssh(plan, container) self.provision_git(plan, container) return prompt_path @@ -307,6 +305,15 @@ class DockerBottleBackend(BottleBackend): agent = plan.spec.manifest.agents[plan.spec.agent_name] return in_container_prompt_path if agent.prompt else None + def provision_skills(self, plan: DockerBottlePlan, container: str) -> None: + """Copy each of the agent's named skills from the host's + ~/.claude/skills// into the container's equivalent path. + No-op when the agent has no skills.""" + agent = plan.spec.manifest.agents[plan.spec.agent_name] + if not agent.skills: + return + skills_mod.skills_copy_into(container, list(agent.skills)) + def provision_ssh(self, plan: DockerBottlePlan, container: str) -> None: """If the bottle has SSH entries, set up the in-container ssh-agent and config so node can authenticate without ever From 054dc09b3836fc1f49fbbd2b8c7b011c3f03e04a Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 00:31:36 -0400 Subject: [PATCH 24/44] refactor(backend): make provision_* abstract; provision lives on the base Template Method pattern. BottleBackend.provision is now concrete and orchestrates four abstract sub-methods: provision_prompt -> str | None (only one with a meaningful return) provision_skills -> None provision_ssh -> None provision_git -> None Each is self-gating: skills/ssh/git short-circuit on empty inputs; prompt always copies (the path must exist) and returns None when the agent has no prompt content. DockerBottleBackend drops its own `provision` (inherited from the base) and now just implements the four sub-methods. Each sub-method takes `plan: BottlePlan` (matching the abstract) and asserts isinstance to narrow to DockerBottlePlan internally, same pattern as `launch`. A future fly.io backend implements the four sub-methods; provision works for it unchanged. --- claude_bottle/backend/__init__.py | 35 ++++++++++++++++++++++-- claude_bottle/backend/docker/backend.py | 36 +++++++++---------------- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index 7342695..cb0600a 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -122,7 +122,6 @@ class BottleBackend(ABC): def launch(self, plan: BottlePlan) -> AbstractContextManager[Bottle]: """Build/run the bottle and yield a handle; tear down on exit.""" - @abstractmethod def provision(self, plan: BottlePlan, target: str) -> str | None: """Copy host-side files (prompt, skills, SSH keys, .git) into the running bottle. Called from `launch` after the container/ @@ -131,7 +130,39 @@ class BottleBackend(ABC): machine id). Returns the in-container prompt path if a prompt was provisioned, else None — the Bottle handle uses it to decide whether to add --append-system-prompt-file to claude's - argv.""" + argv. + + Default orchestration: prompt → skills → ssh → git. Subclasses + typically don't override this; they implement the four + sub-methods below.""" + prompt_path = self.provision_prompt(plan, target) + self.provision_skills(plan, target) + self.provision_ssh(plan, target) + self.provision_git(plan, target) + return prompt_path + + @abstractmethod + def provision_prompt(self, plan: BottlePlan, target: str) -> str | None: + """Copy the prompt file into the running bottle. Returns the + in-container path iff the agent has a non-empty prompt; + callers use the return value to decide whether to add + --append-system-prompt-file to claude's argv.""" + + @abstractmethod + def provision_skills(self, plan: BottlePlan, target: str) -> None: + """Copy the agent's named skills from the host into the + running bottle. No-op when the agent has no skills.""" + + @abstractmethod + def provision_ssh(self, plan: BottlePlan, target: str) -> None: + """Set up SSH in the running bottle (config, agent, keys) + so the bottle can reach the manifest's declared SSH hosts. + No-op when the bottle has no SSH entries.""" + + @abstractmethod + def provision_git(self, plan: BottlePlan, target: str) -> None: + """Copy the host's cwd `.git` directory into the running + bottle if the user requested --cwd. No-op otherwise.""" @abstractmethod def prepare_cleanup(self) -> BottleCleanupPlan: diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index cb4610d..d699ae9 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -258,29 +258,13 @@ class DockerBottleBackend(BottleBackend): docker_args[name_idx] = container info(f"name conflict; retrying as {container}") - def provision(self, plan: BottlePlan, target: str) -> str | None: - """Copy prompt, skills, ssh keys, and (optionally) .git into - the running container. `target` is the resolved container - name. Returns the in-container prompt path if a prompt was - provisioned, else None — the Bottle handle uses it to decide - whether to add --append-system-prompt-file to claude's argv.""" - assert isinstance(plan, DockerBottlePlan), ( - f"DockerBottleBackend.provision expects DockerBottlePlan, " - f"got {type(plan).__name__}" - ) - container = target - - prompt_path = self.provision_prompt(plan, container) - self.provision_skills(plan, container) - self.provision_ssh(plan, container) - self.provision_git(plan, container) - return prompt_path - - def provision_prompt(self, plan: DockerBottlePlan, container: str) -> str | None: + def provision_prompt(self, plan: BottlePlan, target: str) -> str | None: """Copy the prompt file into the container, fix ownership/mode. Returns the in-container path if the agent has a non-empty prompt (drives --append-system-prompt-file), else None. The file is copied either way so the path always exists.""" + assert isinstance(plan, DockerBottlePlan) + container = target container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" @@ -305,31 +289,35 @@ class DockerBottleBackend(BottleBackend): agent = plan.spec.manifest.agents[plan.spec.agent_name] return in_container_prompt_path if agent.prompt else None - def provision_skills(self, plan: DockerBottlePlan, container: str) -> None: + def provision_skills(self, plan: BottlePlan, target: str) -> None: """Copy each of the agent's named skills from the host's ~/.claude/skills// into the container's equivalent path. No-op when the agent has no skills.""" + assert isinstance(plan, DockerBottlePlan) agent = plan.spec.manifest.agents[plan.spec.agent_name] if not agent.skills: return - skills_mod.skills_copy_into(container, list(agent.skills)) + skills_mod.skills_copy_into(target, list(agent.skills)) - def provision_ssh(self, plan: DockerBottlePlan, container: str) -> None: + def provision_ssh(self, plan: BottlePlan, target: str) -> None: """If the bottle has SSH entries, set up the in-container ssh-agent and config so node can authenticate without ever seeing the key bytes. No-op when the bottle has no SSH.""" + assert isinstance(plan, DockerBottlePlan) bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) if not bottle.ssh: return proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) - ssh_mod.ssh_setup(container, plan.stage_dir, proxy_host_port, bottle.ssh) + ssh_mod.ssh_setup(target, plan.stage_dir, proxy_host_port, bottle.ssh) - def provision_git(self, plan: DockerBottlePlan, container: str) -> None: + def provision_git(self, plan: BottlePlan, target: str) -> None: """If --cwd was set and the host cwd has a .git directory, copy it into /home/node/workspace/.git and fix ownership. No-op otherwise.""" + assert isinstance(plan, DockerBottlePlan) if not (plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir()): return + container = target info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") subprocess.run( ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], From d45d4fec8af5d77b3bbd5a4b342f19a26bf21a6e Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 00:38:25 -0400 Subject: [PATCH 25/44] refactor(docker): inline skills_copy_into into provision_skills The copy logic was Docker-specific (docker exec mkdir / rm -rf, docker cp); it had no reason to live in a top-level skills module. Pull the body into DockerBottleBackend.provision_skills. skills.py keeps the host-side discovery + validation (host_skill_dir, host_skill_exists, require_host_skill, skills_validate_all). The orphaned CONTAINER_HOME / CONTAINER_SKILLS_DIR constants and the now-unused subprocess + info imports are removed. --- claude_bottle/backend/docker/backend.py | 40 +++++++++++++++++++-- claude_bottle/skills.py | 48 ++----------------------- 2 files changed, 41 insertions(+), 47 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index d699ae9..ca7719e 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -292,12 +292,48 @@ class DockerBottleBackend(BottleBackend): def provision_skills(self, plan: BottlePlan, target: str) -> None: """Copy each of the agent's named skills from the host's ~/.claude/skills// into the container's equivalent path. - No-op when the agent has no skills.""" + For each skill: ensure parent dir, wipe any prior copy, then + `docker cp /. :/` so the contents are + copied into a freshly-created destination dir. No-op when the + agent has no skills.""" assert isinstance(plan, DockerBottlePlan) agent = plan.spec.manifest.agents[plan.spec.agent_name] if not agent.skills: return - skills_mod.skills_copy_into(target, list(agent.skills)) + + container = target + container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") + skills_dir = os.environ.get( + "CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills" + ) + + subprocess.run( + ["docker", "exec", container, "mkdir", "-p", skills_dir], + stdout=subprocess.DEVNULL, + check=True, + ) + + for n in agent.skills: + src = skills_mod.host_skill_dir(n) + if not os.path.isdir(src): + die(f"skill '{n}' disappeared from host between validation and copy at {src}.") + dst = f"{skills_dir}/{n}" + info(f"copying skill {n} into {container}:{dst}") + subprocess.run( + ["docker", "exec", container, "rm", "-rf", dst], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", container, "mkdir", "-p", dst], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "cp", f"{src}/.", f"{container}:{dst}/"], + stdout=subprocess.DEVNULL, + check=True, + ) def provision_ssh(self, plan: BottlePlan, target: str) -> None: """If the bottle has SSH entries, set up the in-container diff --git a/claude_bottle/skills.py b/claude_bottle/skills.py index 4efee86..a879ec8 100644 --- a/claude_bottle/skills.py +++ b/claude_bottle/skills.py @@ -1,17 +1,11 @@ -"""Skill copier: host's ~/.claude/skills// -> container's -~/.claude/skills//, preserving directory structure.""" +"""Skill discovery and host-side validation. The copy step itself +lives on the backend (e.g. DockerBottleBackend.provision_skills).""" from __future__ import annotations import os -import subprocess -from .log import die, info - -CONTAINER_HOME = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") -CONTAINER_SKILLS_DIR = os.environ.get( - "CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR", f"{CONTAINER_HOME}/.claude/skills" -) +from .log import die def host_skill_dir(name: str) -> str: @@ -38,39 +32,3 @@ def skills_validate_all(names: list[str]) -> None: that's already known to fail.""" for n in names: require_host_skill(n) - - -def skills_copy_into(container: str, names: list[str]) -> None: - """For each named skill, ensure the parent dir exists, wipe any - prior copy, then `docker cp /. :/` so the - contents are copied into a freshly-created destination dir.""" - if not names: - return - - subprocess.run( - ["docker", "exec", container, "mkdir", "-p", CONTAINER_SKILLS_DIR], - stdout=subprocess.DEVNULL, - check=True, - ) - - for n in names: - src = host_skill_dir(n) - if not os.path.isdir(src): - die(f"skill '{n}' disappeared from host between validation and copy at {src}.") - dst = f"{CONTAINER_SKILLS_DIR}/{n}" - info(f"copying skill {n} into {container}:{dst}") - subprocess.run( - ["docker", "exec", container, "rm", "-rf", dst], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - ["docker", "exec", container, "mkdir", "-p", dst], - stdout=subprocess.DEVNULL, - check=True, - ) - subprocess.run( - ["docker", "cp", f"{src}/.", f"{container}:{dst}/"], - stdout=subprocess.DEVNULL, - check=True, - ) From c9fe23a043999550d20eeae6a299fc98fa9eddef Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 00:44:34 -0400 Subject: [PATCH 26/44] refactor(docker): absorb claude_bottle/skills.py into DockerBottleBackend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole module folds into two methods on the backend: validate_skills(skills) — called from prepare; fails loudly when a named skill is missing on the host so the user doesn't get a y/N for a plan that's already known to break. _host_skill_dir(name) — private helper used by both validate_skills and provision_skills. skills.py is deleted; the four prior functions (host_skill_dir, host_skill_exists, require_host_skill, skills_validate_all) collapse into the two above without losing the pre-y/N validation. --- claude_bottle/backend/docker/backend.py | 24 ++++++++++++++--- claude_bottle/skills.py | 34 ------------------------- 2 files changed, 21 insertions(+), 37 deletions(-) delete mode 100644 claude_bottle/skills.py diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index ca7719e..145eb9d 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -18,7 +18,6 @@ from pathlib import Path from typing import Iterator from ... import pipelock -from ... import skills as skills_mod from ... import ssh as ssh_mod from ...env_resolve import env_resolve from ...log import die, info @@ -87,7 +86,7 @@ class DockerBottleBackend(BottleBackend): ) if agent.skills: - skills_mod.skills_validate_all(list(agent.skills)) + self.validate_skills(list(agent.skills)) if bottle.ssh: ssh_mod.ssh_validate_entries(bottle.ssh) @@ -289,6 +288,25 @@ class DockerBottleBackend(BottleBackend): agent = plan.spec.manifest.agents[plan.spec.agent_name] return in_container_prompt_path if agent.prompt else None + def validate_skills(self, skills: list[str]) -> None: + """Fail loudly if any named skill is missing from the host's + ~/.claude/skills/. Called from `prepare` before the y/N so the + user doesn't get a launch prompt for a plan that's already + known to break.""" + for name in skills: + path = self._host_skill_dir(name) + if not os.path.isdir(path): + die( + f"skill '{name}' not found on host at {path}. " + f"Create it under ~/.claude/skills/, then re-run." + ) + + def _host_skill_dir(self, name: str) -> str: + home = os.environ.get("HOME") + if not home: + die("HOME not set") + return f"{home}/.claude/skills/{name}" + def provision_skills(self, plan: BottlePlan, target: str) -> None: """Copy each of the agent's named skills from the host's ~/.claude/skills// into the container's equivalent path. @@ -314,7 +332,7 @@ class DockerBottleBackend(BottleBackend): ) for n in agent.skills: - src = skills_mod.host_skill_dir(n) + src = self._host_skill_dir(n) if not os.path.isdir(src): die(f"skill '{n}' disappeared from host between validation and copy at {src}.") dst = f"{skills_dir}/{n}" diff --git a/claude_bottle/skills.py b/claude_bottle/skills.py deleted file mode 100644 index a879ec8..0000000 --- a/claude_bottle/skills.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Skill discovery and host-side validation. The copy step itself -lives on the backend (e.g. DockerBottleBackend.provision_skills).""" - -from __future__ import annotations - -import os - -from .log import die - - -def host_skill_dir(name: str) -> str: - home = os.environ.get("HOME") - if not home: - die("HOME not set") - return f"{home}/.claude/skills/{name}" - - -def host_skill_exists(name: str) -> bool: - return os.path.isdir(host_skill_dir(name)) - - -def require_host_skill(name: str) -> None: - if not host_skill_exists(name): - die( - f"skill '{name}' not found on host at {host_skill_dir(name)}. " - f"Create it under ~/.claude/skills/, then re-run." - ) - - -def skills_validate_all(names: list[str]) -> None: - """Use BEFORE the y/N so the user does not get asked about a plan - that's already known to fail.""" - for n in names: - require_host_skill(n) From 6298d33c319b197ba2d1dc26020ed58aca22351c Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 00:49:05 -0400 Subject: [PATCH 27/44] refactor(docker): absorb claude_bottle/ssh.py into DockerBottleBackend Mirrors the skills.py absorb. ssh_validate_entries -> validate_ssh_entries (called from prepare); ssh_setup body -> provision_ssh; the _docker_exec_root and _expand_tilde helpers become private methods on the backend. The detailed isolation-model docstring from the old module moves to provision_ssh, where the code lives now. ssh.py deleted. --- claude_bottle/backend/docker/backend.py | 196 ++++++++++++++++++++++- claude_bottle/ssh.py | 204 ------------------------ 2 files changed, 189 insertions(+), 211 deletions(-) delete mode 100644 claude_bottle/ssh.py diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 145eb9d..96f091d 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -15,12 +15,12 @@ import subprocess import sys from contextlib import contextmanager from pathlib import Path -from typing import Iterator +from typing import Iterator, Sequence from ... import pipelock -from ... import ssh as ssh_mod from ...env_resolve import env_resolve from ...log import die, info +from ...manifest import SshEntry from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec from . import network as network_mod from . import util as docker_mod @@ -88,7 +88,7 @@ class DockerBottleBackend(BottleBackend): if agent.skills: self.validate_skills(list(agent.skills)) if bottle.ssh: - ssh_mod.ssh_validate_entries(bottle.ssh) + self.validate_ssh_entries(bottle.ssh) env_file = stage_dir / "agent.env" args_file = stage_dir / "docker-args" @@ -353,16 +353,198 @@ class DockerBottleBackend(BottleBackend): check=True, ) + def validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None: + """Each entry's IdentityFile must exist on the host (after + expanding leading ~). Host and IdentityFile shape are already + enforced by Manifest validation. Called from `prepare` before + the y/N so the user doesn't get prompted for a plan with a + missing key.""" + for entry in entries: + key = self._expand_tilde(entry.IdentityFile) + if not os.path.isfile(key): + die(f"ssh key file not found for host '{entry.Host}': {key}") + def provision_ssh(self, plan: BottlePlan, target: str) -> None: - """If the bottle has SSH entries, set up the in-container - ssh-agent and config so node can authenticate without ever - seeing the key bytes. No-op when the bottle has no SSH.""" + """Set up SSH in the container so node can authenticate using + each entry's key without the key file being readable by node. + No-op when the bottle has no SSH entries. + + Isolation strategy: + - Keys live at /root/.claude-bottle-keys/ (mode 700, + root-owned). /root is mode 700 in node:22-slim, so node + (uid 1000) can't even traverse in. + - ssh-agent runs as root, listening on + /run/claude-bottle-agent.sock. Each key is loaded with + ssh-add, then deleted; the bytes now live only in the + agent process's memory. + - ssh-agent's SO_PEERCRED-based UID match rejects every + connection whose peer euid is neither 0 nor the agent's. + To bridge that, a root-owned socat forwarder listens on + /run/claude-bottle-agent-public.sock (mode 666) and + proxies bytes to the real agent socket. + - node can't ptrace root-owned agent or socat, so + /proc//mem is off-limits and key bytes never leave + root-owned memory. + - ~/.ssh/config in node's home points each Host at the + public socket via IdentityAgent. + + Why an in-container agent (not bind-mounted from host): + Docker Desktop on macOS does not forward Unix-domain socket + connect() across the VM boundary — connect() returns + ENOTSUP. Running ssh-agent inside the container sidesteps + that entirely. + + Limitation: keys must be passphrase-less. ssh-add prompts on + /dev/tty for passphrases, but our docker exec has no TTY.""" assert isinstance(plan, DockerBottlePlan) bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) if not bottle.ssh: return + + container = target proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) - ssh_mod.ssh_setup(target, plan.stage_dir, proxy_host_port, bottle.ssh) + container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") + container_ssh = f"{container_home}/.ssh" + agent_socket = "/run/claude-bottle-agent.sock" + public_socket = "/run/claude-bottle-agent-public.sock" + keys_dir = "/root/.claude-bottle-keys" + + # ~/.ssh for node (700, owned by node). + self._docker_exec_root(container, ["mkdir", "-p", container_ssh]) + self._docker_exec_root(container, ["chown", "node:node", container_ssh]) + self._docker_exec_root(container, ["chmod", "700", container_ssh]) + + # /root/.claude-bottle-keys for root (700, root-owned). + self._docker_exec_root(container, ["mkdir", "-p", keys_dir]) + self._docker_exec_root(container, ["chown", "root:root", keys_dir]) + self._docker_exec_root(container, ["chmod", "700", keys_dir]) + + config_file = plan.stage_dir / "ssh_config" + known_hosts_file = plan.stage_dir / "ssh_known_hosts" + config_file.write_text("") + config_file.chmod(0o600) + known_hosts_file.write_text("") + known_hosts_file.chmod(0o600) + + proxy_host, _, proxy_port = proxy_host_port.partition(":") + + container_key_paths: list[str] = [] + for entry in bottle.ssh: + name = entry.Host + key = self._expand_tilde(entry.IdentityFile) + hostname = entry.Hostname + user = entry.User + port = entry.Port + known_host_key = entry.KnownHostKey + + key_basename = os.path.basename(key) + container_key_path = f"{keys_dir}/{key_basename}" + + info(f"copying ssh key for '{name}' -> {container} (root-only staging)") + subprocess.run( + ["docker", "cp", key, f"{container}:{container_key_path}"], + stdout=subprocess.DEVNULL, + check=True, + ) + self._docker_exec_root(container, ["chown", "root:root", container_key_path]) + self._docker_exec_root(container, ["chmod", "600", container_key_path]) + + container_key_paths.append(container_key_path) + + # ProxyCommand tunnels SSH through pipelock via HTTP + # CONNECT. %h / %p expand to this block's HostName / + # Port. socat's PROXY: mode does CONNECT host:port to + # the proxy. + block = ( + f"Host {name}\n" + f" HostName {hostname}\n" + f" User {user}\n" + f" Port {port}\n" + f" IdentityAgent {public_socket}\n" + f" ProxyCommand socat - PROXY:{proxy_host}:%h:%p,proxyport={proxy_port}\n" + f"\n" + ) + with config_file.open("a") as f: + f.write(block) + + if known_host_key: + entries_to_write: list[str] = [] + if port == "22": + entries_to_write.append(f"{name} {known_host_key}\n") + if hostname != name: + entries_to_write.append(f"{hostname} {known_host_key}\n") + else: + entries_to_write.append(f"[{name}]:{port} {known_host_key}\n") + if hostname != name: + entries_to_write.append(f"[{hostname}]:{port} {known_host_key}\n") + with known_hosts_file.open("a") as f: + for e in entries_to_write: + f.write(e) + + # Boot the agent, load each key, delete the key files, then + # start the root-owned socat forwarder. One docker exec so the + # whole sequence is atomic. + info(f"starting in-container ssh-agent at {agent_socket} (forwarded via {public_socket})") + setup_lines = [ + "set -eu", + f"ssh-agent -a {agent_socket} >/dev/null", + ] + for kp in container_key_paths: + setup_lines.append(f"SSH_AUTH_SOCK={agent_socket} ssh-add {kp}") + setup_lines.append(f"rm -f {kp}") + setup_lines.append(f"rmdir {keys_dir} 2>/dev/null || true") + # Forwarder: socat (uid 0) connects to the agent on node's behalf. + setup_lines.append( + f"nohup socat UNIX-LISTEN:{public_socket},fork,reuseaddr,mode=666 " + f"UNIX-CONNECT:{agent_socket} /dev/null 2>&1 &" + ) + # Wait briefly for the forwarder to bind. + setup_lines.extend([ + "i=0", + "while [ $i -lt 20 ]; do", + f" [ -S {public_socket} ] && break", + " i=$((i + 1))", + " sleep 0.1", + "done", + f"[ -S {public_socket} ] || {{ echo 'claude-bottle: socat forwarder failed to bind {public_socket}' >&2; exit 1; }}", + ]) + setup_script = "\n".join(setup_lines) + "\n" + subprocess.run( + ["docker", "exec", "-u", "0", container, "sh", "-c", setup_script], + check=True, + ) + + info(f"writing {container_ssh}/config") + subprocess.run( + ["docker", "cp", str(config_file), f"{container}:{container_ssh}/config"], + stdout=subprocess.DEVNULL, + check=True, + ) + self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"]) + self._docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"]) + + if known_hosts_file.stat().st_size > 0: + info(f"writing {container_ssh}/known_hosts") + subprocess.run( + ["docker", "cp", str(known_hosts_file), f"{container}:{container_ssh}/known_hosts"], + stdout=subprocess.DEVNULL, + check=True, + ) + self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"]) + self._docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"]) + + def _docker_exec_root(self, container: str, argv: list[str]) -> None: + subprocess.run( + ["docker", "exec", "-u", "0", container, *argv], + stdout=subprocess.DEVNULL, + check=True, + ) + + def _expand_tilde(self, path: str) -> str: + if path.startswith("~"): + home = os.environ.get("HOME", "") + return home + path[1:] + return path def provision_git(self, plan: BottlePlan, target: str) -> None: """If --cwd was set and the host cwd has a .git directory, copy diff --git a/claude_bottle/ssh.py b/claude_bottle/ssh.py deleted file mode 100644 index 634958f..0000000 --- a/claude_bottle/ssh.py +++ /dev/null @@ -1,204 +0,0 @@ -"""SSH helpers. Validates ssh entries from claude-bottle.json, then sets -up SSH inside the container via a root-owned ssh-agent so the `node` -user can use the keys for SSH but cannot read the key bytes. - -Why an in-container agent (not bind-mounted from host): Docker Desktop -on macOS does not forward Unix-domain socket connect() across the VM -boundary — connect() returns ENOTSUP. Running ssh-agent inside the -container sidesteps that entirely. - -Isolation: - - Keys live at /root/.claude-bottle-keys/ (mode 700, root-owned). - /root is mode 700 in node:22-slim, so node (uid 1000) can't even - traverse in. - - ssh-agent runs as root, listening on /run/claude-bottle-agent.sock. - Each key is loaded with ssh-add, then deleted; the bytes now live - only in the agent process's memory. - - ssh-agent's SO_PEERCRED-based UID match rejects every connection - whose peer euid is neither 0 nor the agent's. To bridge that, a - root-owned socat forwarder listens on - /run/claude-bottle-agent-public.sock (mode 666) and proxies bytes - to the real agent socket. - - node can't ptrace root-owned agent or socat, so /proc//mem is - off-limits and key bytes never leave root-owned memory. - - ~/.ssh/config in node's home points each Host at the public socket - via IdentityAgent. - -Limitation: keys must be passphrase-less. ssh-add prompts on /dev/tty -for passphrases, but our docker exec has no TTY. - -Each ssh entry has keys: Host, IdentityFile, Hostname, User, Port -(required); KnownHostKey (optional). -""" - -from __future__ import annotations - -import os -import subprocess -from pathlib import Path -from typing import Sequence - -from .log import die, info -from .manifest import SshEntry - - -def ssh_validate_entries(entries: Sequence[SshEntry]) -> None: - """The IdentityFile must exist on the host (after expanding leading ~). - Host and IdentityFile shape are already enforced by Manifest validation.""" - for entry in entries: - key = _expand_tilde(entry.IdentityFile) - if not os.path.isfile(key): - die(f"ssh key file not found for host '{entry.Host}': {key}") - - -def ssh_setup( - container: str, - stage_dir: Path, - proxy_host_port: str, - entries: Sequence[SshEntry], -) -> None: - """Set up SSH in the container so node can authenticate using each - entry's key without the key file being readable by node.""" - container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") - container_ssh = f"{container_home}/.ssh" - agent_socket = "/run/claude-bottle-agent.sock" - public_socket = "/run/claude-bottle-agent-public.sock" - keys_dir = "/root/.claude-bottle-keys" - - # ~/.ssh for node (700, owned by node). - _docker_exec_root(container, ["mkdir", "-p", container_ssh]) - _docker_exec_root(container, ["chown", "node:node", container_ssh]) - _docker_exec_root(container, ["chmod", "700", container_ssh]) - - # /root/.claude-bottle-keys for root (700, root-owned). - _docker_exec_root(container, ["mkdir", "-p", keys_dir]) - _docker_exec_root(container, ["chown", "root:root", keys_dir]) - _docker_exec_root(container, ["chmod", "700", keys_dir]) - - config_file = stage_dir / "ssh_config" - known_hosts_file = stage_dir / "ssh_known_hosts" - config_file.write_text("") - config_file.chmod(0o600) - known_hosts_file.write_text("") - known_hosts_file.chmod(0o600) - - proxy_host, _, proxy_port = proxy_host_port.partition(":") - - container_key_paths: list[str] = [] - for entry in entries: - name = entry.Host - key = _expand_tilde(entry.IdentityFile) - hostname = entry.Hostname - user = entry.User - port = entry.Port - known_host_key = entry.KnownHostKey - - key_basename = os.path.basename(key) - container_key_path = f"{keys_dir}/{key_basename}" - - info(f"copying ssh key for '{name}' -> {container} (root-only staging)") - subprocess.run( - ["docker", "cp", key, f"{container}:{container_key_path}"], - stdout=subprocess.DEVNULL, - check=True, - ) - _docker_exec_root(container, ["chown", "root:root", container_key_path]) - _docker_exec_root(container, ["chmod", "600", container_key_path]) - - container_key_paths.append(container_key_path) - - # ProxyCommand tunnels SSH through pipelock via HTTP CONNECT. - # %h / %p expand to this block's HostName / Port. socat's - # PROXY: mode does CONNECT host:port to the proxy. - block = ( - f"Host {name}\n" - f" HostName {hostname}\n" - f" User {user}\n" - f" Port {port}\n" - f" IdentityAgent {public_socket}\n" - f" ProxyCommand socat - PROXY:{proxy_host}:%h:%p,proxyport={proxy_port}\n" - f"\n" - ) - with config_file.open("a") as f: - f.write(block) - - if known_host_key: - entries_to_write: list[str] = [] - if port == "22": - entries_to_write.append(f"{name} {known_host_key}\n") - if hostname != name: - entries_to_write.append(f"{hostname} {known_host_key}\n") - else: - entries_to_write.append(f"[{name}]:{port} {known_host_key}\n") - if hostname != name: - entries_to_write.append(f"[{hostname}]:{port} {known_host_key}\n") - with known_hosts_file.open("a") as f: - for e in entries_to_write: - f.write(e) - - # Boot the agent, load each key, delete the key files, then start - # the root-owned socat forwarder. One docker exec so the whole - # sequence is atomic. - info(f"starting in-container ssh-agent at {agent_socket} (forwarded via {public_socket})") - setup_lines = [ - "set -eu", - f"ssh-agent -a {agent_socket} >/dev/null", - ] - for kp in container_key_paths: - setup_lines.append(f"SSH_AUTH_SOCK={agent_socket} ssh-add {kp}") - setup_lines.append(f"rm -f {kp}") - setup_lines.append(f"rmdir {keys_dir} 2>/dev/null || true") - # Forwarder: socat (uid 0) connects to the agent on node's behalf. - setup_lines.append( - f"nohup socat UNIX-LISTEN:{public_socket},fork,reuseaddr,mode=666 " - f"UNIX-CONNECT:{agent_socket} /dev/null 2>&1 &" - ) - # Wait briefly for the forwarder to bind. - setup_lines.extend([ - "i=0", - "while [ $i -lt 20 ]; do", - f" [ -S {public_socket} ] && break", - " i=$((i + 1))", - " sleep 0.1", - "done", - f"[ -S {public_socket} ] || {{ echo 'claude-bottle: socat forwarder failed to bind {public_socket}' >&2; exit 1; }}", - ]) - setup_script = "\n".join(setup_lines) + "\n" - subprocess.run( - ["docker", "exec", "-u", "0", container, "sh", "-c", setup_script], - check=True, - ) - - info(f"writing {container_ssh}/config") - subprocess.run( - ["docker", "cp", str(config_file), f"{container}:{container_ssh}/config"], - stdout=subprocess.DEVNULL, - check=True, - ) - _docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"]) - _docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"]) - - if known_hosts_file.stat().st_size > 0: - info(f"writing {container_ssh}/known_hosts") - subprocess.run( - ["docker", "cp", str(known_hosts_file), f"{container}:{container_ssh}/known_hosts"], - stdout=subprocess.DEVNULL, - check=True, - ) - _docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"]) - _docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"]) - - -def _docker_exec_root(container: str, argv: list[str]) -> None: - subprocess.run( - ["docker", "exec", "-u", "0", container, *argv], - stdout=subprocess.DEVNULL, - check=True, - ) - - -def _expand_tilde(path: str) -> str: - if path.startswith("~"): - home = os.environ.get("HOME", "") - return home + path[1:] - return path From 8457869dcd52a6cec3f987bfbdcee6b58f5ae06e Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 00:52:33 -0400 Subject: [PATCH 28/44] refactor(util): move expand_tilde to top-level claude_bottle/util.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _expand_tilde was a path-string helper on DockerBottleBackend but nothing about it was Docker-specific — any future backend reading host paths from manifest entries would want it. Lift to claude_bottle/util.py (sibling of log.py / manifest.py) as a public expand_tilde() function. Docker-specific primitives stay in claude_bottle/backend/docker/util.py. --- claude_bottle/backend/docker/backend.py | 11 +++-------- claude_bottle/util.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 claude_bottle/util.py diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 96f091d..f66972a 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -21,6 +21,7 @@ from ... import pipelock from ...env_resolve import env_resolve from ...log import die, info from ...manifest import SshEntry +from ...util import expand_tilde from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec from . import network as network_mod from . import util as docker_mod @@ -360,7 +361,7 @@ class DockerBottleBackend(BottleBackend): the y/N so the user doesn't get prompted for a plan with a missing key.""" for entry in entries: - key = self._expand_tilde(entry.IdentityFile) + key = expand_tilde(entry.IdentityFile) if not os.path.isfile(key): die(f"ssh key file not found for host '{entry.Host}': {key}") @@ -431,7 +432,7 @@ class DockerBottleBackend(BottleBackend): container_key_paths: list[str] = [] for entry in bottle.ssh: name = entry.Host - key = self._expand_tilde(entry.IdentityFile) + key = expand_tilde(entry.IdentityFile) hostname = entry.Hostname user = entry.User port = entry.Port @@ -540,12 +541,6 @@ class DockerBottleBackend(BottleBackend): check=True, ) - def _expand_tilde(self, path: str) -> str: - if path.startswith("~"): - home = os.environ.get("HOME", "") - return home + path[1:] - return path - def provision_git(self, plan: BottlePlan, target: str) -> None: """If --cwd was set and the host cwd has a .git directory, copy it into /home/node/workspace/.git and fix ownership. No-op diff --git a/claude_bottle/util.py b/claude_bottle/util.py new file mode 100644 index 0000000..b936c02 --- /dev/null +++ b/claude_bottle/util.py @@ -0,0 +1,18 @@ +"""Cross-cutting utility helpers used by multiple modules. + +Top-level (i.e. backend-agnostic) — Docker-specific helpers live in +claude_bottle/backend/docker/util.py.""" + +from __future__ import annotations + +import os + + +def expand_tilde(path: str) -> str: + """Expand a leading '~' to $HOME. Leaves paths without a leading + tilde unchanged. Falls back to the empty string if $HOME is unset + (callers should already have checked HOME if they care).""" + if path.startswith("~"): + home = os.environ.get("HOME", "") + return home + path[1:] + return path From 11f17d792753c6cdfdd93b0aae252f7eb60ef94c Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 01:04:47 -0400 Subject: [PATCH 29/44] refactor(docker): inline pipelock_write_yaml body into prepare_proxy The yaml generation logic moves wholesale onto DockerBottleBackend where it's used. pipelock_write_yaml is deleted; pipelock.py keeps the allowlist resolution helpers (still called by prepare_proxy and by pipelock_allowlist_summary). The pipelock_start error message that referenced "pipelock_write_yaml must run first" now says "backend.prepare_proxy must run first." tests/test_pipelock_yaml.py rewritten to drive DockerBottleBackend(). prepare_proxy(spec, yaml_path); test_pipelock_sidecar_smoke.py call site updated similarly. Same coverage at the new location. --- claude_bottle/backend/docker/backend.py | 47 +++++++++++++++++++++++- claude_bottle/pipelock.py | 49 +------------------------ tests/test_pipelock_sidecar_smoke.py | 13 ++++++- tests/test_pipelock_yaml.py | 32 +++++++++++----- 4 files changed, 81 insertions(+), 60 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index f66972a..6d2f5c4 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -102,7 +102,7 @@ class DockerBottleBackend(BottleBackend): prompt_file.write_text("") prompt_file.chmod(0o600) - pipelock.pipelock_write_yaml(manifest, bottle_name, pipelock_yaml) + self.prepare_proxy(spec, pipelock_yaml) env_resolve(manifest, spec.agent_name, env_file, args_file) prompt_file.write_text(agent.prompt) @@ -127,6 +127,51 @@ class DockerBottleBackend(BottleBackend): use_runsc=use_runsc, ) + def prepare_proxy(self, spec: BottleSpec, yaml_path: Path) -> None: + """Write the pipelock proxy's yaml config (mode 600) to + `yaml_path` for the sidecar to consume when it boots in + `launch`. Carries the effective allowlist (bottle.egress.allowlist + UNION claude-bottle defaults UNION ssh hostnames), a fixed + listen port, strict mode + forward_proxy + DLP defaults + + scan_env. Deliberately contains no env values, no secrets, no + per-agent customization beyond the hostname list.""" + bottle_name = spec.manifest.agents[spec.agent_name].bottle + allowlist = pipelock.pipelock_effective_allowlist(spec.manifest, bottle_name) + trusted = pipelock.pipelock_bottle_ssh_trusted_domains(spec.manifest, bottle_name) + ip_cidrs = pipelock.pipelock_bottle_ssh_ip_cidrs(spec.manifest, bottle_name) + + lines: list[str] = [] + lines.append("version: 1") + lines.append("mode: strict") + lines.append("enforce: true") + lines.append("") + lines.append("# Hostnames the agent is allowed to reach. Effective list is") + lines.append("# claude-bottle defaults UNION bottle.egress.allowlist (sorted, deduped).") + lines.append("api_allowlist:") + for h in allowlist: + lines.append(f' - "{h}"') + lines.append("") + lines.append("forward_proxy:") + lines.append(" enabled: true") + lines.append("") + if trusted: + lines.append("trusted_domains:") + for td in trusted: + lines.append(f' - "{td}"') + lines.append("") + if ip_cidrs: + lines.append("ssrf:") + lines.append(" ip_allowlist:") + for cidr in ip_cidrs: + lines.append(f' - "{cidr}"') + lines.append("") + lines.append("dlp:") + lines.append(" include_defaults: true") + lines.append(" scan_env: true") + + yaml_path.write_text("\n".join(lines) + "\n") + yaml_path.chmod(0o600) + @contextmanager def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]: """Build, launch, and provision a Docker bottle. Teardown on exit.""" diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index e6f2bb9..ba94416 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -119,53 +119,6 @@ def pipelock_allowlist_summary(manifest: Manifest, bottle_name: str) -> str: return f"{count} hosts allowed ({joined})" -# --- YAML generation ------------------------------------------------------- - - -def pipelock_write_yaml(manifest: Manifest, bottle_name: str, out_path: Path) -> None: - """Write a pipelock YAML config (mode 600) carrying: - - the effective allowlist (hostnames), - - a fixed listen port, - - strict mode + forward_proxy.enabled + DLP defaults + scan_env. - - Deliberately contains no env values, no secrets, no per-agent - customization beyond the hostname list.""" - allowlist = pipelock_effective_allowlist(manifest, bottle_name) - trusted = pipelock_bottle_ssh_trusted_domains(manifest, bottle_name) - ip_cidrs = pipelock_bottle_ssh_ip_cidrs(manifest, bottle_name) - - lines: list[str] = [] - lines.append("version: 1") - lines.append("mode: strict") - lines.append("enforce: true") - lines.append("") - lines.append("# Hostnames the agent is allowed to reach. Effective list is") - lines.append("# claude-bottle defaults UNION bottle.egress.allowlist (sorted, deduped).") - lines.append("api_allowlist:") - for h in allowlist: - lines.append(f' - "{h}"') - lines.append("") - lines.append("forward_proxy:") - lines.append(" enabled: true") - lines.append("") - if trusted: - lines.append("trusted_domains:") - for td in trusted: - lines.append(f' - "{td}"') - lines.append("") - if ip_cidrs: - lines.append("ssrf:") - lines.append(" ip_allowlist:") - for cidr in ip_cidrs: - lines.append(f' - "{cidr}"') - lines.append("") - lines.append("dlp:") - lines.append(" include_defaults: true") - lines.append(" scan_env: true") - - out_path.write_text("\n".join(lines) + "\n") - out_path.chmod(0o600) - # --- Sidecar lifecycle ----------------------------------------------------- @@ -188,7 +141,7 @@ def pipelock_start( name = pipelock_container_name(slug) host_yaml = yaml_dir / yaml_filename if not host_yaml.is_file(): - die(f"pipelock yaml not found at {host_yaml}; pipelock_write_yaml must run first") + die(f"pipelock yaml not found at {host_yaml}; backend.prepare_proxy must run first") info(f"starting pipelock sidecar {name} on network {internal_network}") diff --git a/tests/test_pipelock_sidecar_smoke.py b/tests/test_pipelock_sidecar_smoke.py index 8fe5101..873e1c2 100644 --- a/tests/test_pipelock_sidecar_smoke.py +++ b/tests/test_pipelock_sidecar_smoke.py @@ -12,7 +12,9 @@ import unittest import urllib.request from pathlib import Path -from claude_bottle.pipelock import PIPELOCK_IMAGE, pipelock_write_yaml +from claude_bottle.backend import BottleSpec +from claude_bottle.backend.docker import DockerBottleBackend +from claude_bottle.pipelock import PIPELOCK_IMAGE from tests._docker import skip_unless_docker from tests.fixtures import fixture_minimal @@ -38,7 +40,14 @@ class TestPipelockSidecarSmoke(unittest.TestCase): ) def test_smoke(self): yaml_path = self.work_dir / "pipelock.yaml" - pipelock_write_yaml(fixture_minimal(), "dev", yaml_path) + spec = BottleSpec( + manifest=fixture_minimal(), + agent_name="demo", + copy_cwd=False, + user_cwd="/tmp", + forward_oauth_token=False, + ) + DockerBottleBackend().prepare_proxy(spec, yaml_path) create = subprocess.run( [ diff --git a/tests/test_pipelock_yaml.py b/tests/test_pipelock_yaml.py index 9602458..3647921 100644 --- a/tests/test_pipelock_yaml.py +++ b/tests/test_pipelock_yaml.py @@ -1,20 +1,34 @@ -"""Unit: pipelock_write_yaml — produces a YAML config containing the -expected top-level keys and per-bottle entries. We don't fully parse -YAML; we grep for content shape.""" +"""Unit: DockerBottleBackend.prepare_proxy — produces a pipelock YAML +config containing the expected top-level keys and per-bottle entries. +We don't fully parse YAML; we grep for content shape.""" import os import tempfile import unittest from pathlib import Path +from claude_bottle.backend import BottleSpec +from claude_bottle.backend.docker import DockerBottleBackend from claude_bottle.manifest import Manifest -from claude_bottle.pipelock import pipelock_write_yaml from tests.fixtures import fixture_minimal, fixture_with_ssh -class TestPipelockYaml(unittest.TestCase): +def _spec(manifest: Manifest) -> BottleSpec: + """Construct a minimal BottleSpec around a fixture manifest. The + fixtures all define an agent named 'demo' on a bottle named 'dev'.""" + return BottleSpec( + manifest=manifest, + agent_name="demo", + copy_cwd=False, + user_cwd="/tmp", + forward_oauth_token=False, + ) + + +class TestPrepareProxyYaml(unittest.TestCase): def setUp(self): self.out_dir = Path(tempfile.mkdtemp()) + self.backend = DockerBottleBackend() def tearDown(self): import shutil @@ -22,7 +36,7 @@ class TestPipelockYaml(unittest.TestCase): def test_minimal(self): yaml_path = self.out_dir / "min.yaml" - pipelock_write_yaml(fixture_minimal(), "dev", yaml_path) + self.backend.prepare_proxy(_spec(fixture_minimal()), yaml_path) content = yaml_path.read_text() self.assertIn("mode: strict", content) self.assertIn("enforce: true", content) @@ -40,7 +54,7 @@ class TestPipelockYaml(unittest.TestCase): def test_ssh_blocks(self): yaml_path = self.out_dir / "ssh.yaml" - pipelock_write_yaml(fixture_with_ssh(), "dev", yaml_path) + self.backend.prepare_proxy(_spec(fixture_with_ssh()), yaml_path) content = yaml_path.read_text() self.assertIn("trusted_domains:", content) self.assertIn("github.com", content) @@ -64,7 +78,7 @@ class TestPipelockYaml(unittest.TestCase): "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) yaml_path = self.out_dir / "secret.yaml" - pipelock_write_yaml(manifest, "dev", yaml_path) + self.backend.prepare_proxy(_spec(manifest), yaml_path) content = yaml_path.read_text() self.assertNotIn("literal-value-should-not-appear", content) self.assertNotIn("MY_SECRET", content) @@ -72,7 +86,7 @@ class TestPipelockYaml(unittest.TestCase): def test_file_mode_is_600(self): yaml_path = self.out_dir / "min.yaml" - pipelock_write_yaml(fixture_minimal(), "dev", yaml_path) + self.backend.prepare_proxy(_spec(fixture_minimal()), yaml_path) mode = os.stat(yaml_path).st_mode & 0o777 self.assertEqual(0o600, mode) From f344c8cd9dcdded537cee6aeb087cd64e448763f Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 01:11:59 -0400 Subject: [PATCH 30/44] test(pipelock): cut low-value tests (naming + entrypoint/cmd inspection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops 6 tests with no real coverage loss: - tests/test_pipelock_naming.py — 4 tests asserting that f-string format helpers return their f-string. Shape locks, not behavior gates. - tests/test_pipelock_image.py:test_entrypoint_contains_pipelock and :test_cmd_contains_run — Docker image metadata inspection. The remaining test_binary_runs already covers 'does the pinned image actually work,' which is the only scenario these were really guarding against. 31 tests -> 25. --- tests/test_pipelock_image.py | 27 +++------------------------ tests/test_pipelock_naming.py | 33 --------------------------------- 2 files changed, 3 insertions(+), 57 deletions(-) delete mode 100644 tests/test_pipelock_naming.py diff --git a/tests/test_pipelock_image.py b/tests/test_pipelock_image.py index 68f32a9..c8c1213 100644 --- a/tests/test_pipelock_image.py +++ b/tests/test_pipelock_image.py @@ -1,11 +1,7 @@ -"""Integration: verify the pinned pipelock image. Requires docker. - - Pinned digest is reachable on the registry. - - Image's ENTRYPOINT/CMD match what claude_bottle.pipelock assumes - (`/pipelock` and `run --listen 0.0.0.0:8888`). - - The /pipelock binary actually runs (--version succeeds).""" +"""Integration: the pinned pipelock image's binary actually runs. +Catches a broken upstream packaging at the pinned digest. Requires +docker.""" -import json -import re import subprocess import unittest @@ -17,7 +13,6 @@ from tests._docker import skip_unless_docker class TestPipelockImage(unittest.TestCase): @classmethod def setUpClass(cls): - # Pull the pinned image (cheap if cached). result = subprocess.run( ["docker", "pull", PIPELOCK_IMAGE], stdout=subprocess.DEVNULL, @@ -26,22 +21,6 @@ class TestPipelockImage(unittest.TestCase): if result.returncode != 0: raise unittest.SkipTest(f"could not pull {PIPELOCK_IMAGE}") - def test_entrypoint_contains_pipelock(self): - result = subprocess.run( - ["docker", "image", "inspect", PIPELOCK_IMAGE, - "--format", "{{json .Config.Entrypoint}}"], - capture_output=True, text=True, - ) - self.assertIn("/pipelock", result.stdout) - - def test_cmd_contains_run(self): - result = subprocess.run( - ["docker", "image", "inspect", PIPELOCK_IMAGE, - "--format", "{{json .Config.Cmd}}"], - capture_output=True, text=True, - ) - self.assertIn("run", result.stdout) - def test_binary_runs(self): result = subprocess.run( ["docker", "run", "--rm", PIPELOCK_IMAGE, "--version"], diff --git a/tests/test_pipelock_naming.py b/tests/test_pipelock_naming.py deleted file mode 100644 index a547a32..0000000 --- a/tests/test_pipelock_naming.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Unit: pipelock naming helpers (container_name, proxy_url, proxy_host_port).""" - -import unittest - -from claude_bottle.pipelock import ( - pipelock_container_name, - pipelock_proxy_host_port, - pipelock_proxy_url, -) - - -class TestPipelockNaming(unittest.TestCase): - def test_container_name_simple(self): - self.assertEqual("claude-bottle-pipelock-foo", pipelock_container_name("foo")) - - def test_container_name_with_hyphens(self): - self.assertEqual( - "claude-bottle-pipelock-some-slug", pipelock_container_name("some-slug") - ) - - def test_proxy_url_default_port(self): - self.assertEqual( - "http://claude-bottle-pipelock-foo:8888", pipelock_proxy_url("foo") - ) - - def test_proxy_host_port_default_port(self): - self.assertEqual( - "claude-bottle-pipelock-foo:8888", pipelock_proxy_host_port("foo") - ) - - -if __name__ == "__main__": - unittest.main() From 30ead9102a73257b29594cbdf454a7aa2029fb06 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 01:18:53 -0400 Subject: [PATCH 31/44] refactor(pipelock): introduce PipelockProxy class housing the yaml body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The YAML generation now lives on PipelockProxy.prepare(manifest, bottle_name, yaml_path) in claude_bottle/pipelock.py. The class is the natural home for any future proxy-level state. DockerBottleBackend keeps an instance as a class attribute (_proxy = PipelockProxy()) and its prepare_proxy becomes a thin delegation. A future backend that wants a different egress proxy (or none) plugs in its own strategy. Tests retarget at the new home — PipelockProxy.prepare gets the content-shape assertions; the sidecar smoke test uses the class directly too. Same coverage. --- claude_bottle/backend/docker/backend.py | 46 ++------------------- claude_bottle/pipelock.py | 54 +++++++++++++++++++++++++ tests/test_pipelock_sidecar_smoke.py | 13 +----- tests/test_pipelock_yaml.py | 33 +++++---------- 4 files changed, 70 insertions(+), 76 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 6d2f5c4..0a34288 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -39,6 +39,7 @@ class DockerBottleBackend(BottleBackend): (default).""" name = "docker" + _proxy: pipelock.PipelockProxy = pipelock.PipelockProxy() def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: """Resolve names, validate, write scratch files. No Docker @@ -128,49 +129,10 @@ class DockerBottleBackend(BottleBackend): ) def prepare_proxy(self, spec: BottleSpec, yaml_path: Path) -> None: - """Write the pipelock proxy's yaml config (mode 600) to - `yaml_path` for the sidecar to consume when it boots in - `launch`. Carries the effective allowlist (bottle.egress.allowlist - UNION claude-bottle defaults UNION ssh hostnames), a fixed - listen port, strict mode + forward_proxy + DLP defaults + - scan_env. Deliberately contains no env values, no secrets, no - per-agent customization beyond the hostname list.""" + """Delegate to PipelockProxy to write the sidecar's yaml + config. Stage-only: no Docker resources created yet.""" bottle_name = spec.manifest.agents[spec.agent_name].bottle - allowlist = pipelock.pipelock_effective_allowlist(spec.manifest, bottle_name) - trusted = pipelock.pipelock_bottle_ssh_trusted_domains(spec.manifest, bottle_name) - ip_cidrs = pipelock.pipelock_bottle_ssh_ip_cidrs(spec.manifest, bottle_name) - - lines: list[str] = [] - lines.append("version: 1") - lines.append("mode: strict") - lines.append("enforce: true") - lines.append("") - lines.append("# Hostnames the agent is allowed to reach. Effective list is") - lines.append("# claude-bottle defaults UNION bottle.egress.allowlist (sorted, deduped).") - lines.append("api_allowlist:") - for h in allowlist: - lines.append(f' - "{h}"') - lines.append("") - lines.append("forward_proxy:") - lines.append(" enabled: true") - lines.append("") - if trusted: - lines.append("trusted_domains:") - for td in trusted: - lines.append(f' - "{td}"') - lines.append("") - if ip_cidrs: - lines.append("ssrf:") - lines.append(" ip_allowlist:") - for cidr in ip_cidrs: - lines.append(f' - "{cidr}"') - lines.append("") - lines.append("dlp:") - lines.append(" include_defaults: true") - lines.append(" scan_env: true") - - yaml_path.write_text("\n".join(lines) + "\n") - yaml_path.chmod(0o600) + self._proxy.prepare(spec.manifest, bottle_name, yaml_path) @contextmanager def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]: diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index ba94416..10afcce 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -120,6 +120,60 @@ def pipelock_allowlist_summary(manifest: Manifest, bottle_name: str) -> str: +# --- Proxy class ----------------------------------------------------------- + + +class PipelockProxy: + """The pipelock egress proxy. Encapsulates the YAML-config + generation (and is the natural home for any future proxy-level + state). Backends that use pipelock hold a PipelockProxy instance + and delegate the prepare step to it.""" + + def prepare(self, manifest: Manifest, bottle_name: str, yaml_path: Path) -> None: + """Write the pipelock yaml config (mode 600) to `yaml_path` + for the sidecar to consume when it boots. Carries the + effective allowlist (bottle.egress.allowlist UNION + claude-bottle defaults UNION ssh hostnames), a fixed listen + port, strict mode + forward_proxy + DLP defaults + scan_env. + Deliberately contains no env values, no secrets, no per-agent + customization beyond the hostname list.""" + allowlist = pipelock_effective_allowlist(manifest, bottle_name) + trusted = pipelock_bottle_ssh_trusted_domains(manifest, bottle_name) + ip_cidrs = pipelock_bottle_ssh_ip_cidrs(manifest, bottle_name) + + lines: list[str] = [] + lines.append("version: 1") + lines.append("mode: strict") + lines.append("enforce: true") + lines.append("") + lines.append("# Hostnames the agent is allowed to reach. Effective list is") + lines.append("# claude-bottle defaults UNION bottle.egress.allowlist (sorted, deduped).") + lines.append("api_allowlist:") + for h in allowlist: + lines.append(f' - "{h}"') + lines.append("") + lines.append("forward_proxy:") + lines.append(" enabled: true") + lines.append("") + if trusted: + lines.append("trusted_domains:") + for td in trusted: + lines.append(f' - "{td}"') + lines.append("") + if ip_cidrs: + lines.append("ssrf:") + lines.append(" ip_allowlist:") + for cidr in ip_cidrs: + lines.append(f' - "{cidr}"') + lines.append("") + lines.append("dlp:") + lines.append(" include_defaults: true") + lines.append(" scan_env: true") + + yaml_path.write_text("\n".join(lines) + "\n") + yaml_path.chmod(0o600) + + # --- Sidecar lifecycle ----------------------------------------------------- diff --git a/tests/test_pipelock_sidecar_smoke.py b/tests/test_pipelock_sidecar_smoke.py index 873e1c2..1e5bfea 100644 --- a/tests/test_pipelock_sidecar_smoke.py +++ b/tests/test_pipelock_sidecar_smoke.py @@ -12,9 +12,7 @@ import unittest import urllib.request from pathlib import Path -from claude_bottle.backend import BottleSpec -from claude_bottle.backend.docker import DockerBottleBackend -from claude_bottle.pipelock import PIPELOCK_IMAGE +from claude_bottle.pipelock import PIPELOCK_IMAGE, PipelockProxy from tests._docker import skip_unless_docker from tests.fixtures import fixture_minimal @@ -40,14 +38,7 @@ class TestPipelockSidecarSmoke(unittest.TestCase): ) def test_smoke(self): yaml_path = self.work_dir / "pipelock.yaml" - spec = BottleSpec( - manifest=fixture_minimal(), - agent_name="demo", - copy_cwd=False, - user_cwd="/tmp", - forward_oauth_token=False, - ) - DockerBottleBackend().prepare_proxy(spec, yaml_path) + PipelockProxy().prepare(fixture_minimal(), "dev", yaml_path) create = subprocess.run( [ diff --git a/tests/test_pipelock_yaml.py b/tests/test_pipelock_yaml.py index 3647921..9514110 100644 --- a/tests/test_pipelock_yaml.py +++ b/tests/test_pipelock_yaml.py @@ -1,34 +1,21 @@ -"""Unit: DockerBottleBackend.prepare_proxy — produces a pipelock YAML -config containing the expected top-level keys and per-bottle entries. -We don't fully parse YAML; we grep for content shape.""" +"""Unit: PipelockProxy.prepare — produces a pipelock YAML config +containing the expected top-level keys and per-bottle entries. We +don't fully parse YAML; we grep for content shape.""" import os import tempfile import unittest from pathlib import Path -from claude_bottle.backend import BottleSpec -from claude_bottle.backend.docker import DockerBottleBackend from claude_bottle.manifest import Manifest +from claude_bottle.pipelock import PipelockProxy from tests.fixtures import fixture_minimal, fixture_with_ssh -def _spec(manifest: Manifest) -> BottleSpec: - """Construct a minimal BottleSpec around a fixture manifest. The - fixtures all define an agent named 'demo' on a bottle named 'dev'.""" - return BottleSpec( - manifest=manifest, - agent_name="demo", - copy_cwd=False, - user_cwd="/tmp", - forward_oauth_token=False, - ) - - -class TestPrepareProxyYaml(unittest.TestCase): +class TestPipelockProxyPrepare(unittest.TestCase): def setUp(self): self.out_dir = Path(tempfile.mkdtemp()) - self.backend = DockerBottleBackend() + self.proxy = PipelockProxy() def tearDown(self): import shutil @@ -36,7 +23,7 @@ class TestPrepareProxyYaml(unittest.TestCase): def test_minimal(self): yaml_path = self.out_dir / "min.yaml" - self.backend.prepare_proxy(_spec(fixture_minimal()), yaml_path) + self.proxy.prepare(fixture_minimal(), "dev", yaml_path) content = yaml_path.read_text() self.assertIn("mode: strict", content) self.assertIn("enforce: true", content) @@ -54,7 +41,7 @@ class TestPrepareProxyYaml(unittest.TestCase): def test_ssh_blocks(self): yaml_path = self.out_dir / "ssh.yaml" - self.backend.prepare_proxy(_spec(fixture_with_ssh()), yaml_path) + self.proxy.prepare(fixture_with_ssh(), "dev", yaml_path) content = yaml_path.read_text() self.assertIn("trusted_domains:", content) self.assertIn("github.com", content) @@ -78,7 +65,7 @@ class TestPrepareProxyYaml(unittest.TestCase): "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) yaml_path = self.out_dir / "secret.yaml" - self.backend.prepare_proxy(_spec(manifest), yaml_path) + self.proxy.prepare(manifest, "dev", yaml_path) content = yaml_path.read_text() self.assertNotIn("literal-value-should-not-appear", content) self.assertNotIn("MY_SECRET", content) @@ -86,7 +73,7 @@ class TestPrepareProxyYaml(unittest.TestCase): def test_file_mode_is_600(self): yaml_path = self.out_dir / "min.yaml" - self.backend.prepare_proxy(_spec(fixture_minimal()), yaml_path) + self.proxy.prepare(fixture_minimal(), "dev", yaml_path) mode = os.stat(yaml_path).st_mode & 0o777 self.assertEqual(0o600, mode) From 1b8d3bbb94b850ee12e0e3562f8fa319cf42edd0 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 01:22:26 -0400 Subject: [PATCH 32/44] refactor(docker): prepare_proxy takes stage_dir and owns the yaml path prepare_proxy(spec, stage_dir) -> Path now decides where the pipelock yaml lives inside stage_dir (currently 'pipelock.yaml'), writes it via PipelockProxy.prepare, and returns the resolved path. The caller (prepare) drops its 'pipelock_yaml_filename' / 'pipelock_yaml = stage_dir / filename' setup and just consumes the returned Path; the plan's pipelock_yaml_filename is derived from .name on the path. --- claude_bottle/backend/docker/backend.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 0a34288..a1f5da2 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -95,15 +95,13 @@ class DockerBottleBackend(BottleBackend): 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) - self.prepare_proxy(spec, pipelock_yaml) + pipelock_yaml = self.prepare_proxy(spec, stage_dir) env_resolve(manifest, spec.agent_name, env_file, args_file) prompt_file.write_text(agent.prompt) @@ -123,16 +121,19 @@ class DockerBottleBackend(BottleBackend): args_file=args_file, prompt_file=prompt_file, pipelock_yaml_path=pipelock_yaml, - pipelock_yaml_filename=pipelock_yaml_filename, + pipelock_yaml_filename=pipelock_yaml.name, allowlist_summary=allowlist_summary, use_runsc=use_runsc, ) - def prepare_proxy(self, spec: BottleSpec, yaml_path: Path) -> None: - """Delegate to PipelockProxy to write the sidecar's yaml - config. Stage-only: no Docker resources created yet.""" + def prepare_proxy(self, spec: BottleSpec, stage_dir: Path) -> Path: + """Decide where the pipelock yaml lives in `stage_dir`, delegate + to PipelockProxy to write it, and return the resolved path. + Stage-only: no Docker resources created yet.""" + yaml_path = stage_dir / "pipelock.yaml" bottle_name = spec.manifest.agents[spec.agent_name].bottle self._proxy.prepare(spec.manifest, bottle_name, yaml_path) + return yaml_path @contextmanager def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]: From c2cdb7777d1f19ca2a2876632bc8f70957c4f35f Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 01:26:38 -0400 Subject: [PATCH 33/44] refactor(pipelock): prepare_proxy returns a ProxyPlan Add a frozen ProxyPlan dataclass to pipelock.py (currently one field: yaml_path; kept as a class so future proxy-level state has a home). - prepare_proxy(spec, stage_dir) now returns pipelock.ProxyPlan instead of a raw Path. - DockerBottlePlan replaces pipelock_yaml_path + pipelock_yaml_filename with a single proxy: ProxyPlan field. - launch reads plan.proxy.yaml_path.parent / .name when calling pipelock_start. Eventually pipelock_start should just take a Path but that's a separate change. --- claude_bottle/backend/docker/backend.py | 18 +++++++++--------- claude_bottle/backend/docker/bottle_plan.py | 4 ++-- claude_bottle/pipelock.py | 10 ++++++++++ 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index a1f5da2..68ed110 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -101,7 +101,7 @@ class DockerBottleBackend(BottleBackend): prompt_file.write_text("") prompt_file.chmod(0o600) - pipelock_yaml = self.prepare_proxy(spec, stage_dir) + proxy_plan = self.prepare_proxy(spec, stage_dir) env_resolve(manifest, spec.agent_name, env_file, args_file) prompt_file.write_text(agent.prompt) @@ -120,20 +120,20 @@ class DockerBottleBackend(BottleBackend): env_file=env_file, args_file=args_file, prompt_file=prompt_file, - pipelock_yaml_path=pipelock_yaml, - pipelock_yaml_filename=pipelock_yaml.name, + proxy_plan=proxy_plan, allowlist_summary=allowlist_summary, use_runsc=use_runsc, ) - def prepare_proxy(self, spec: BottleSpec, stage_dir: Path) -> Path: + def prepare_proxy(self, spec: BottleSpec, stage_dir: Path) -> pipelock.ProxyPlan: """Decide where the pipelock yaml lives in `stage_dir`, delegate - to PipelockProxy to write it, and return the resolved path. - Stage-only: no Docker resources created yet.""" + to PipelockProxy to write it, and return the resolved ProxyPlan + for the launch step to consume. Stage-only: no Docker resources + created yet.""" yaml_path = stage_dir / "pipelock.yaml" bottle_name = spec.manifest.agents[spec.agent_name].bottle self._proxy.prepare(spec.manifest, bottle_name, yaml_path) - return yaml_path + return pipelock.ProxyPlan(yaml_path=yaml_path) @contextmanager def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]: @@ -186,8 +186,8 @@ class DockerBottleBackend(BottleBackend): plan.slug, state["internal_network"], state["egress_network"], - plan.stage_dir, - plan.pipelock_yaml_filename, + plan.proxy_plan.yaml_path.parent, + plan.proxy_plan.yaml_path.name, ) container = self._run_agent_container(plan, state["internal_network"]) diff --git a/claude_bottle/backend/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py index 187987e..acca735 100644 --- a/claude_bottle/backend/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -12,6 +12,7 @@ from dataclasses import dataclass from pathlib import Path from ...log import info +from ...pipelock import ProxyPlan from .. import BottlePlan @@ -30,8 +31,7 @@ class DockerBottlePlan(BottlePlan): env_file: Path args_file: Path prompt_file: Path - pipelock_yaml_path: Path - pipelock_yaml_filename: str + proxy_plan: ProxyPlan allowlist_summary: str use_runsc: bool diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 10afcce..c3b3e10 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -15,6 +15,7 @@ from __future__ import annotations import os import re import subprocess +from dataclasses import dataclass from pathlib import Path from .log import die, info, warn @@ -123,6 +124,15 @@ def pipelock_allowlist_summary(manifest: Manifest, bottle_name: str) -> str: # --- Proxy class ----------------------------------------------------------- +@dataclass(frozen=True) +class ProxyPlan: + """Output of a proxy's prepare step; consumed by launch when the + proxy needs to be brought up. Currently single-field (the on-host + yaml path); kept as a dataclass so future proxy state has a home.""" + + yaml_path: Path + + class PipelockProxy: """The pipelock egress proxy. Encapsulates the YAML-config generation (and is the natural home for any future proxy-level From ff962d2893bd3f6d69470c426f5f60127ca410bc Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 10:57:07 -0400 Subject: [PATCH 34/44] refactor(pipelock): start/stop become methods on PipelockProxy ProxyPlan -> PipelockProxyPlan, with two additional fields populated in launch: internal_network, egress_network (default ""). prepare fills yaml_path + slug; launch uses dataclasses.replace to populate the networks before calling start. pipelock_start -> PipelockProxy.start(plan). Reads yaml_path, slug, internal_network, egress_network off the plan. Returns the resolved container name. pipelock_stop -> PipelockProxy.stop(proxy_target). Takes the resolved container name directly (the value that start returned); no longer needs to know about slugs or naming conventions. Backend launch passes the running container name (state["pipelock"]) to stop. Test for stop's idempotency uses pipelock_container_name to construct the proxy_target. --- claude_bottle/backend/docker/backend.py | 26 ++-- claude_bottle/backend/docker/bottle_plan.py | 4 +- claude_bottle/pipelock.py | 161 ++++++++++---------- tests/test_orphan_cleanup.py | 7 +- tests/test_pipelock_sidecar_smoke.py | 2 +- tests/test_pipelock_yaml.py | 8 +- 6 files changed, 107 insertions(+), 101 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 68ed110..3e1d4a8 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -10,6 +10,7 @@ Methods: from __future__ import annotations +import dataclasses import os import subprocess import sys @@ -125,15 +126,15 @@ class DockerBottleBackend(BottleBackend): use_runsc=use_runsc, ) - def prepare_proxy(self, spec: BottleSpec, stage_dir: Path) -> pipelock.ProxyPlan: + def prepare_proxy(self, spec: BottleSpec, stage_dir: Path) -> pipelock.PipelockProxyPlan: """Decide where the pipelock yaml lives in `stage_dir`, delegate - to PipelockProxy to write it, and return the resolved ProxyPlan - for the launch step to consume. Stage-only: no Docker resources - created yet.""" + to PipelockProxy to write it, and return the resolved + PipelockProxyPlan for the launch step to consume. Stage-only: + no Docker resources created yet.""" yaml_path = stage_dir / "pipelock.yaml" bottle_name = spec.manifest.agents[spec.agent_name].bottle - self._proxy.prepare(spec.manifest, bottle_name, yaml_path) - return pipelock.ProxyPlan(yaml_path=yaml_path) + slug = docker_mod.slugify(spec.agent_name) + return self._proxy.prepare(spec.manifest, bottle_name, slug, yaml_path) @contextmanager def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]: @@ -160,7 +161,7 @@ class DockerBottleBackend(BottleBackend): ) state["container"] = "" if state["pipelock"]: - pipelock.pipelock_stop(plan.slug) + self._proxy.stop(state["pipelock"]) state["pipelock"] = "" if state["internal_network"]: network_mod.network_remove(state["internal_network"]) @@ -182,13 +183,12 @@ class DockerBottleBackend(BottleBackend): state["internal_network"] = network_mod.network_create_internal(plan.slug) state["egress_network"] = network_mod.network_create_egress(plan.slug) - state["pipelock"] = pipelock.pipelock_start( - plan.slug, - state["internal_network"], - state["egress_network"], - plan.proxy_plan.yaml_path.parent, - plan.proxy_plan.yaml_path.name, + proxy_plan = dataclasses.replace( + plan.proxy_plan, + internal_network=state["internal_network"], + egress_network=state["egress_network"], ) + state["pipelock"] = self._proxy.start(proxy_plan) container = self._run_agent_container(plan, state["internal_network"]) state["container"] = container diff --git a/claude_bottle/backend/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py index acca735..63651e5 100644 --- a/claude_bottle/backend/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from pathlib import Path from ...log import info -from ...pipelock import ProxyPlan +from ...pipelock import PipelockProxyPlan from .. import BottlePlan @@ -31,7 +31,7 @@ class DockerBottlePlan(BottlePlan): env_file: Path args_file: Path prompt_file: Path - proxy_plan: ProxyPlan + proxy_plan: PipelockProxyPlan allowlist_summary: str use_runsc: bool diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index c3b3e10..408f4d7 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -125,21 +125,28 @@ def pipelock_allowlist_summary(manifest: Manifest, bottle_name: str) -> str: @dataclass(frozen=True) -class ProxyPlan: - """Output of a proxy's prepare step; consumed by launch when the - proxy needs to be brought up. Currently single-field (the on-host - yaml path); kept as a dataclass so future proxy state has a home.""" +class PipelockProxyPlan: + """Output of PipelockProxy.prepare; consumed by .start when the + sidecar needs to be brought up. + + yaml_path + slug are filled in at prepare time. internal_network + and egress_network default to empty and are populated by the + backend's launch step (via dataclasses.replace) once those networks + have actually been created.""" yaml_path: Path + slug: str + internal_network: str = "" + egress_network: str = "" class PipelockProxy: """The pipelock egress proxy. Encapsulates the YAML-config - generation (and is the natural home for any future proxy-level - state). Backends that use pipelock hold a PipelockProxy instance - and delegate the prepare step to it.""" + generation and the sidecar's start/stop lifecycle.""" - def prepare(self, manifest: Manifest, bottle_name: str, yaml_path: Path) -> None: + def prepare( + self, manifest: Manifest, bottle_name: str, slug: str, yaml_path: Path + ) -> PipelockProxyPlan: """Write the pipelock yaml config (mode 600) to `yaml_path` for the sidecar to consume when it boots. Carries the effective allowlist (bottle.egress.allowlist UNION @@ -183,82 +190,80 @@ class PipelockProxy: yaml_path.write_text("\n".join(lines) + "\n") yaml_path.chmod(0o600) + return PipelockProxyPlan(yaml_path=yaml_path, slug=slug) -# --- Sidecar lifecycle ----------------------------------------------------- + def start(self, plan: PipelockProxyPlan) -> str: + """Boot the pipelock sidecar: + 1. `docker create` on the internal network with the canonical + name and argv `run --config /etc/pipelock.yaml --listen + 0.0.0.0:`. + 2. `docker cp` the YAML config to /etc/pipelock.yaml in the + writable layer (parent dir must already exist; image is + distroless). + 3. Attach to the per-agent egress network. + 4. `docker start`. + Returns the container name (the proxy_target passed to .stop).""" + name = pipelock_container_name(plan.slug) + if not plan.yaml_path.is_file(): + die( + f"pipelock yaml not found at {plan.yaml_path}; " + f"PipelockProxy.prepare must run first" + ) + info(f"starting pipelock sidecar {name} on network {plan.internal_network}") -def pipelock_start( - slug: str, - internal_network: str, - egress_network: str, - yaml_dir: Path, - yaml_filename: str, -) -> str: - """Boot the pipelock sidecar: - 1. `docker create` on the internal network with the canonical name - and argv `run --config /etc/pipelock.yaml --listen 0.0.0.0:`. - 2. `docker cp` the YAML config to /etc/pipelock.yaml in the - writable layer (parent dir must already exist; image is distroless). - 3. Attach to the per-agent egress network. - 4. `docker start`. - Returns the container name.""" - name = pipelock_container_name(slug) - host_yaml = yaml_dir / yaml_filename - if not host_yaml.is_file(): - die(f"pipelock yaml not found at {host_yaml}; backend.prepare_proxy must run first") + create_args = [ + "docker", "create", + "--name", name, + "--network", plan.internal_network, + PIPELOCK_IMAGE, + "run", "--config", "/etc/pipelock.yaml", + "--listen", f"0.0.0.0:{PIPELOCK_PORT}", + ] + if subprocess.run(create_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: + die(f"failed to create pipelock sidecar {name}") - info(f"starting pipelock sidecar {name} on network {internal_network}") + cp_result = subprocess.run( + ["docker", "cp", str(plan.yaml_path), f"{name}:/etc/pipelock.yaml"], + capture_output=True, + text=True, + ) + if cp_result.returncode != 0: + subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + die(f"failed to copy pipelock yaml into {name}: {cp_result.stderr.strip()}") - create_args = [ - "docker", "create", - "--name", name, - "--network", internal_network, - PIPELOCK_IMAGE, - "run", "--config", "/etc/pipelock.yaml", - "--listen", f"0.0.0.0:{PIPELOCK_PORT}", - ] - if subprocess.run(create_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: - die(f"failed to create pipelock sidecar {name}") - - cp_result = subprocess.run( - ["docker", "cp", str(host_yaml), f"{name}:/etc/pipelock.yaml"], - capture_output=True, - text=True, - ) - if cp_result.returncode != 0: - subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - die(f"failed to copy pipelock yaml into {name}: {cp_result.stderr.strip()}") - - if subprocess.run( - ["docker", "network", "connect", egress_network, name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ).returncode != 0: - subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - die(f"failed to attach pipelock sidecar {name} to egress network {egress_network}") - - if subprocess.run( - ["docker", "start", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ).returncode != 0: - subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - die(f"failed to start pipelock sidecar {name}") - - return name - - -def pipelock_stop(slug: str) -> None: - """Idempotent: missing container is success.""" - name = pipelock_container_name(slug) - if subprocess.run( - ["docker", "inspect", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ).returncode == 0: if subprocess.run( - ["docker", "rm", "-f", name], + ["docker", "network", "connect", plan.egress_network, name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ).returncode != 0: - warn(f"failed to remove pipelock sidecar {name}; clean up with 'docker rm -f {name}'") + subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + die(f"failed to attach pipelock sidecar {name} to egress network {plan.egress_network}") + + if subprocess.run( + ["docker", "start", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode != 0: + subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + die(f"failed to start pipelock sidecar {name}") + + return name + + def stop(self, proxy_target: str) -> None: + """Idempotent: missing container is success. `proxy_target` is + the container name returned by .start.""" + if subprocess.run( + ["docker", "inspect", proxy_target], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode == 0: + if subprocess.run( + ["docker", "rm", "-f", proxy_target], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode != 0: + warn( + f"failed to remove pipelock sidecar {proxy_target}; " + f"clean up with 'docker rm -f {proxy_target}'" + ) diff --git a/tests/test_orphan_cleanup.py b/tests/test_orphan_cleanup.py index 462c40a..1227ca6 100644 --- a/tests/test_orphan_cleanup.py +++ b/tests/test_orphan_cleanup.py @@ -1,7 +1,8 @@ """Integration: the cleanup primitives the start-flow trap depends on are idempotent. The original orphan-network bug was a trap-ordering issue; the fix moved the install earlier. The trap is only safe if -network_remove and pipelock_stop are no-ops against missing resources.""" +network_remove and PipelockProxy.stop are no-ops against missing +resources.""" import os import subprocess @@ -12,7 +13,7 @@ from claude_bottle.backend.docker.network import ( network_create_internal, network_remove, ) -from claude_bottle.pipelock import pipelock_stop +from claude_bottle.pipelock import PipelockProxy, pipelock_container_name from tests._docker import skip_unless_docker @@ -68,7 +69,7 @@ class TestOrphanCleanup(unittest.TestCase): def test_pipelock_stop_missing_sidecar(self): # Should not raise. - pipelock_stop(f"missing-{self.slug}") + PipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}")) if __name__ == "__main__": diff --git a/tests/test_pipelock_sidecar_smoke.py b/tests/test_pipelock_sidecar_smoke.py index 1e5bfea..a853769 100644 --- a/tests/test_pipelock_sidecar_smoke.py +++ b/tests/test_pipelock_sidecar_smoke.py @@ -38,7 +38,7 @@ class TestPipelockSidecarSmoke(unittest.TestCase): ) def test_smoke(self): yaml_path = self.work_dir / "pipelock.yaml" - PipelockProxy().prepare(fixture_minimal(), "dev", yaml_path) + PipelockProxy().prepare(fixture_minimal(), "dev", "demo", yaml_path) create = subprocess.run( [ diff --git a/tests/test_pipelock_yaml.py b/tests/test_pipelock_yaml.py index 9514110..215783d 100644 --- a/tests/test_pipelock_yaml.py +++ b/tests/test_pipelock_yaml.py @@ -23,7 +23,7 @@ class TestPipelockProxyPrepare(unittest.TestCase): def test_minimal(self): yaml_path = self.out_dir / "min.yaml" - self.proxy.prepare(fixture_minimal(), "dev", yaml_path) + self.proxy.prepare(fixture_minimal(), "dev", "demo", yaml_path) content = yaml_path.read_text() self.assertIn("mode: strict", content) self.assertIn("enforce: true", content) @@ -41,7 +41,7 @@ class TestPipelockProxyPrepare(unittest.TestCase): def test_ssh_blocks(self): yaml_path = self.out_dir / "ssh.yaml" - self.proxy.prepare(fixture_with_ssh(), "dev", yaml_path) + self.proxy.prepare(fixture_with_ssh(), "dev", "demo", yaml_path) content = yaml_path.read_text() self.assertIn("trusted_domains:", content) self.assertIn("github.com", content) @@ -65,7 +65,7 @@ class TestPipelockProxyPrepare(unittest.TestCase): "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) yaml_path = self.out_dir / "secret.yaml" - self.proxy.prepare(manifest, "dev", yaml_path) + self.proxy.prepare(manifest, "dev", "demo", yaml_path) content = yaml_path.read_text() self.assertNotIn("literal-value-should-not-appear", content) self.assertNotIn("MY_SECRET", content) @@ -73,7 +73,7 @@ class TestPipelockProxyPrepare(unittest.TestCase): def test_file_mode_is_600(self): yaml_path = self.out_dir / "min.yaml" - self.proxy.prepare(fixture_minimal(), "dev", yaml_path) + self.proxy.prepare(fixture_minimal(), "dev", "demo", yaml_path) mode = os.stat(yaml_path).st_mode & 0o777 self.assertEqual(0o600, mode) From c62b3204a8bc1dac9210e63f266e30441443878e Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 13:37:31 -0400 Subject: [PATCH 35/44] refactor(util): move is_ipv4_literal out of pipelock.py into util.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classifier is a pure dotted-quad regex check — nothing pipelock-specific about it. Pipelock now imports it from util. test_pipelock_classify.py retargets at the new location. Two manifest-accessor functions in pipelock.py (pipelock_bottle_allowlist, pipelock_bottle_ssh_hostnames) look generic but are 1-line wrappers used only internally; they stay for now. --- claude_bottle/pipelock.py | 14 +------------- claude_bottle/util.py | 13 +++++++++++++ tests/test_pipelock_classify.py | 6 +++--- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 408f4d7..b19bd1a 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -13,13 +13,13 @@ Image pin: ghcr.io/luckypipewrench/pipelock@sha256: for tag 2.3.0. from __future__ import annotations import os -import re import subprocess from dataclasses import dataclass from pathlib import Path from .log import die, info, warn from .manifest import Manifest +from .util import is_ipv4_literal # Pipelock image, pinned by digest. The digest is the multi-arch image # index for ghcr.io/luckypipewrench/pipelock:2.3.0. @@ -67,18 +67,6 @@ def pipelock_bottle_ssh_hostnames(manifest: Manifest, bottle_name: str) -> list[ return [e.Hostname for e in manifest.bottles[bottle_name].ssh if e.Hostname] -_IPV4_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$") - - -def is_ipv4_literal(s: str) -> bool: - """Pipelock's SSRF check fires on resolved IP, so an IP-literal - Hostname goes to ssrf.ip_allowlist while a hostname goes to - trusted_domains.""" - if not s: - return False - return bool(_IPV4_RE.match(s)) - - def pipelock_bottle_ssh_trusted_domains(manifest: Manifest, bottle_name: str) -> list[str]: return [h for h in pipelock_bottle_ssh_hostnames(manifest, bottle_name) if not is_ipv4_literal(h)] diff --git a/claude_bottle/util.py b/claude_bottle/util.py index b936c02..1fb877d 100644 --- a/claude_bottle/util.py +++ b/claude_bottle/util.py @@ -6,6 +6,7 @@ claude_bottle/backend/docker/util.py.""" from __future__ import annotations import os +import re def expand_tilde(path: str) -> str: @@ -16,3 +17,15 @@ def expand_tilde(path: str) -> str: home = os.environ.get("HOME", "") return home + path[1:] return path + + +_IPV4_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$") + + +def is_ipv4_literal(s: str) -> bool: + """True iff `s` looks like a dotted-quad IPv4 literal. Does not + validate octet ranges; consumers that care about that should run + a stricter check. Empty input returns False.""" + if not s: + return False + return bool(_IPV4_RE.match(s)) diff --git a/tests/test_pipelock_classify.py b/tests/test_pipelock_classify.py index 749a732..3c08ddf 100644 --- a/tests/test_pipelock_classify.py +++ b/tests/test_pipelock_classify.py @@ -1,10 +1,10 @@ """Unit: is_ipv4_literal — the classifier that decides whether -bottle.ssh[].Hostname goes into ssrf.ip_allowlist (IPv4 literal) or -trusted_domains (hostname).""" +bottle.ssh[].Hostname goes into pipelock's ssrf.ip_allowlist (IPv4 +literal) or trusted_domains (hostname).""" import unittest -from claude_bottle.pipelock import is_ipv4_literal +from claude_bottle.util import is_ipv4_literal class TestIPv4Classify(unittest.TestCase): From 25e67137f2f7a1f099e63d27a3921e22ae3785c6 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 13:44:58 -0400 Subject: [PATCH 36/44] refactor(pipelock): allowlist-resolution helpers take a Bottle, not (manifest, name) Every function in the 'Allowlist resolution' section was doing `manifest.bottles[bottle_name].X` as its first move. Push the lookup to the caller and have each helper take a resolved Bottle: pipelock_bottle_allowlist pipelock_bottle_ssh_hostnames pipelock_bottle_ssh_trusted_domains pipelock_bottle_ssh_ip_cidrs pipelock_effective_allowlist pipelock_allowlist_summary PipelockProxy._build_pipelock_yaml resolves bottle once at the top and passes it through; DockerBottleBackend.prepare already had the bottle in scope and now uses it directly. Tests pass the resolved bottle from each fixture. --- claude_bottle/backend/docker/backend.py | 3 +- claude_bottle/pipelock.py | 42 ++++++++++++++----------- tests/test_pipelock_allowlist.py | 12 +++---- 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 3e1d4a8..8b053ac 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -52,7 +52,6 @@ class DockerBottleBackend(BottleBackend): 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) @@ -106,7 +105,7 @@ class DockerBottleBackend(BottleBackend): 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) + allowlist_summary = pipelock.pipelock_allowlist_summary(bottle) use_runsc = docker_mod.runsc_available() return DockerBottlePlan( diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index b19bd1a..f8454ab 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -18,7 +18,7 @@ from dataclasses import dataclass from pathlib import Path from .log import die, info, warn -from .manifest import Manifest +from .manifest import Bottle, Manifest from .util import is_ipv4_literal # Pipelock image, pinned by digest. The digest is the multi-arch image @@ -58,42 +58,42 @@ def pipelock_proxy_host_port(slug: str) -> str: # --- Allowlist resolution -------------------------------------------------- -def pipelock_bottle_allowlist(manifest: Manifest, bottle_name: str) -> list[str]: - """Hostnames in bottles[].egress.allowlist.""" - return list(manifest.bottles[bottle_name].egress.allowlist) +def pipelock_bottle_allowlist(bottle: Bottle) -> list[str]: + """Hostnames in bottle.egress.allowlist.""" + return list(bottle.egress.allowlist) -def pipelock_bottle_ssh_hostnames(manifest: Manifest, bottle_name: str) -> list[str]: - return [e.Hostname for e in manifest.bottles[bottle_name].ssh if e.Hostname] +def pipelock_bottle_ssh_hostnames(bottle: Bottle) -> list[str]: + return [e.Hostname for e in bottle.ssh if e.Hostname] -def pipelock_bottle_ssh_trusted_domains(manifest: Manifest, bottle_name: str) -> list[str]: - return [h for h in pipelock_bottle_ssh_hostnames(manifest, bottle_name) if not is_ipv4_literal(h)] +def pipelock_bottle_ssh_trusted_domains(bottle: Bottle) -> list[str]: + return [h for h in pipelock_bottle_ssh_hostnames(bottle) if not is_ipv4_literal(h)] -def pipelock_bottle_ssh_ip_cidrs(manifest: Manifest, bottle_name: str) -> list[str]: - return [f"{h}/32" for h in pipelock_bottle_ssh_hostnames(manifest, bottle_name) if is_ipv4_literal(h)] +def pipelock_bottle_ssh_ip_cidrs(bottle: Bottle) -> list[str]: + return [f"{h}/32" for h in pipelock_bottle_ssh_hostnames(bottle) if is_ipv4_literal(h)] -def pipelock_effective_allowlist(manifest: Manifest, bottle_name: str) -> list[str]: +def pipelock_effective_allowlist(bottle: Bottle) -> list[str]: """Deduplicated union of: baked-in defaults, bottle.egress.allowlist, bottle.ssh[].Hostname. Sorted for stability.""" seen: dict[str, None] = {} for h in DEFAULT_ALLOWLIST: seen.setdefault(h, None) - for h in pipelock_bottle_allowlist(manifest, bottle_name): + for h in pipelock_bottle_allowlist(bottle): if h: seen.setdefault(h, None) - for h in pipelock_bottle_ssh_hostnames(manifest, bottle_name): + for h in pipelock_bottle_ssh_hostnames(bottle): if h: seen.setdefault(h, None) return sorted(seen.keys()) -def pipelock_allowlist_summary(manifest: Manifest, bottle_name: str) -> str: +def pipelock_allowlist_summary(bottle: Bottle) -> str: """One-line summary for the y/N preflight display: " hosts allowed (host1, host2, host3, +M more)".""" - hosts = pipelock_effective_allowlist(manifest, bottle_name) + hosts = pipelock_effective_allowlist(bottle) count = len(hosts) if count == 0: return "0 hosts allowed (none)" @@ -142,9 +142,15 @@ class PipelockProxy: port, strict mode + forward_proxy + DLP defaults + scan_env. Deliberately contains no env values, no secrets, no per-agent customization beyond the hostname list.""" - allowlist = pipelock_effective_allowlist(manifest, bottle_name) - trusted = pipelock_bottle_ssh_trusted_domains(manifest, bottle_name) - ip_cidrs = pipelock_bottle_ssh_ip_cidrs(manifest, bottle_name) + return self._build_pipelock_yaml(manifest, bottle_name, slug, yaml_path) + + def _build_pipelock_yaml( + self, manifest: Manifest, bottle_name: str, slug: str, yaml_path: Path + ) -> PipelockProxyPlan: + bottle = manifest.bottles[bottle_name] + allowlist = pipelock_effective_allowlist(bottle) + trusted = pipelock_bottle_ssh_trusted_domains(bottle) + ip_cidrs = pipelock_bottle_ssh_ip_cidrs(bottle) lines: list[str] = [] lines.append("version: 1") diff --git a/tests/test_pipelock_allowlist.py b/tests/test_pipelock_allowlist.py index 887cfcf..90847e3 100644 --- a/tests/test_pipelock_allowlist.py +++ b/tests/test_pipelock_allowlist.py @@ -18,13 +18,13 @@ from tests.fixtures import fixture_minimal, fixture_with_egress, fixture_with_ss class TestBottleAllowlist(unittest.TestCase): def test_egress_allowlist_present(self): - out = pipelock_bottle_allowlist(fixture_with_egress(), "dev") + out = pipelock_bottle_allowlist(fixture_with_egress().bottles["dev"]) self.assertIn("github.com", out) self.assertIn("gitlab.com", out) self.assertIn("registry.npmjs.org", out) def test_empty_when_no_egress_block(self): - out = pipelock_bottle_allowlist(fixture_minimal(), "dev") + out = pipelock_bottle_allowlist(fixture_minimal().bottles["dev"]) self.assertEqual([], out) def test_rejects_non_string_entry(self): @@ -38,17 +38,17 @@ class TestBottleAllowlist(unittest.TestCase): class TestSSHHostnames(unittest.TestCase): def test_hostnames_include_both(self): - hosts = pipelock_bottle_ssh_hostnames(fixture_with_ssh(), "dev") + hosts = pipelock_bottle_ssh_hostnames(fixture_with_ssh().bottles["dev"]) self.assertIn("100.78.141.42", hosts) self.assertIn("github.com", hosts) def test_ip_cidrs_only_ipv4(self): - cidrs = pipelock_bottle_ssh_ip_cidrs(fixture_with_ssh(), "dev") + cidrs = pipelock_bottle_ssh_ip_cidrs(fixture_with_ssh().bottles["dev"]) self.assertIn("100.78.141.42/32", cidrs) self.assertNotIn("github.com", cidrs) def test_trusted_domains_only_hostnames(self): - trusted = pipelock_bottle_ssh_trusted_domains(fixture_with_ssh(), "dev") + trusted = pipelock_bottle_ssh_trusted_domains(fixture_with_ssh().bottles["dev"]) self.assertIn("github.com", trusted) self.assertNotIn("100.78.141.42", trusted) @@ -69,7 +69,7 @@ class TestEffectiveAllowlist(unittest.TestCase): }, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) - eff = pipelock_effective_allowlist(manifest, "dev") + eff = pipelock_effective_allowlist(manifest.bottles["dev"]) self.assertIn("api.anthropic.com", eff) self.assertIn("registry.npmjs.org", eff) self.assertIn("100.78.141.42", eff) From edd8b444a6f0459659bf62471017474f8795e33c Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 13:53:45 -0400 Subject: [PATCH 37/44] refactor(pipelock): split sidecar lifecycle into DockerPipelockProxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PipelockProxy becomes an ABC with the platform-agnostic prepare/_build_pipelock_yaml as concrete methods and start/stop as abstract. Docker-specific sidecar lifecycle moves to a new sibling file: claude_bottle/backend/docker/pipelock.py DockerPipelockProxy(PipelockProxy) — implements start (docker create/cp/network connect/start) and stop (docker inspect/rm -f). DockerBottleBackend._proxy is now a DockerPipelockProxy instance. Tests that previously instantiated PipelockProxy() directly switch to DockerPipelockProxy() (the base is no longer constructable). --- claude_bottle/backend/docker/backend.py | 3 +- claude_bottle/backend/docker/pipelock.py | 96 ++++++++++++++++++++++++ claude_bottle/pipelock.py | 88 +++------------------- tests/test_orphan_cleanup.py | 5 +- tests/test_pipelock_sidecar_smoke.py | 5 +- tests/test_pipelock_yaml.py | 4 +- 6 files changed, 118 insertions(+), 83 deletions(-) create mode 100644 claude_bottle/backend/docker/pipelock.py diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 8b053ac..b0e50ce 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -29,6 +29,7 @@ from . import util as docker_mod from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan +from .pipelock import DockerPipelockProxy # Where the repo root lives, for `docker build` context. Computed once. @@ -40,7 +41,7 @@ class DockerBottleBackend(BottleBackend): (default).""" name = "docker" - _proxy: pipelock.PipelockProxy = pipelock.PipelockProxy() + _proxy: DockerPipelockProxy = DockerPipelockProxy() def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: """Resolve names, validate, write scratch files. No Docker diff --git a/claude_bottle/backend/docker/pipelock.py b/claude_bottle/backend/docker/pipelock.py new file mode 100644 index 0000000..82bcebd --- /dev/null +++ b/claude_bottle/backend/docker/pipelock.py @@ -0,0 +1,96 @@ +"""DockerPipelockProxy — the Docker-specific implementation of the +sidecar's start/stop lifecycle. Inherits the platform-agnostic +YAML-config generation from PipelockProxy.""" + +from __future__ import annotations + +import subprocess + +from ...log import die, info, warn +from ...pipelock import ( + PIPELOCK_IMAGE, + PIPELOCK_PORT, + PipelockProxy, + PipelockProxyPlan, + pipelock_container_name, +) + + +class DockerPipelockProxy(PipelockProxy): + """Brings the pipelock sidecar up and down via Docker.""" + + def start(self, plan: PipelockProxyPlan) -> str: + """Boot the pipelock sidecar: + 1. `docker create` on the internal network with the canonical + name and argv `run --config /etc/pipelock.yaml --listen + 0.0.0.0:`. + 2. `docker cp` the YAML config to /etc/pipelock.yaml in the + writable layer (parent dir must already exist; image is + distroless). + 3. Attach to the per-agent egress network. + 4. `docker start`. + Returns the container name (the proxy_target passed to .stop).""" + name = pipelock_container_name(plan.slug) + if not plan.yaml_path.is_file(): + die( + f"pipelock yaml not found at {plan.yaml_path}; " + f"PipelockProxy.prepare must run first" + ) + + info(f"starting pipelock sidecar {name} on network {plan.internal_network}") + + create_args = [ + "docker", "create", + "--name", name, + "--network", plan.internal_network, + PIPELOCK_IMAGE, + "run", "--config", "/etc/pipelock.yaml", + "--listen", f"0.0.0.0:{PIPELOCK_PORT}", + ] + if subprocess.run(create_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: + die(f"failed to create pipelock sidecar {name}") + + cp_result = subprocess.run( + ["docker", "cp", str(plan.yaml_path), f"{name}:/etc/pipelock.yaml"], + capture_output=True, + text=True, + ) + if cp_result.returncode != 0: + subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + die(f"failed to copy pipelock yaml into {name}: {cp_result.stderr.strip()}") + + if subprocess.run( + ["docker", "network", "connect", plan.egress_network, name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode != 0: + subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + die(f"failed to attach pipelock sidecar {name} to egress network {plan.egress_network}") + + if subprocess.run( + ["docker", "start", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode != 0: + subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + die(f"failed to start pipelock sidecar {name}") + + return name + + def stop(self, proxy_target: str) -> None: + """Idempotent: missing container is success. `proxy_target` is + the container name returned by .start.""" + if subprocess.run( + ["docker", "inspect", proxy_target], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode == 0: + if subprocess.run( + ["docker", "rm", "-f", proxy_target], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode != 0: + warn( + f"failed to remove pipelock sidecar {proxy_target}; " + f"clean up with 'docker rm -f {proxy_target}'" + ) diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index f8454ab..0835aca 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -13,11 +13,10 @@ Image pin: ghcr.io/luckypipewrench/pipelock@sha256: for tag 2.3.0. from __future__ import annotations import os -import subprocess +from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path -from .log import die, info, warn from .manifest import Bottle, Manifest from .util import is_ipv4_literal @@ -128,9 +127,10 @@ class PipelockProxyPlan: egress_network: str = "" -class PipelockProxy: +class PipelockProxy(ABC): """The pipelock egress proxy. Encapsulates the YAML-config - generation and the sidecar's start/stop lifecycle.""" + generation; the sidecar's start/stop lifecycle is backend-specific + and lives on concrete subclasses (e.g. DockerPipelockProxy).""" def prepare( self, manifest: Manifest, bottle_name: str, slug: str, yaml_path: Path @@ -186,78 +186,14 @@ class PipelockProxy: return PipelockProxyPlan(yaml_path=yaml_path, slug=slug) + @abstractmethod def start(self, plan: PipelockProxyPlan) -> str: - """Boot the pipelock sidecar: - 1. `docker create` on the internal network with the canonical - name and argv `run --config /etc/pipelock.yaml --listen - 0.0.0.0:`. - 2. `docker cp` the YAML config to /etc/pipelock.yaml in the - writable layer (parent dir must already exist; image is - distroless). - 3. Attach to the per-agent egress network. - 4. `docker start`. - Returns the container name (the proxy_target passed to .stop).""" - name = pipelock_container_name(plan.slug) - if not plan.yaml_path.is_file(): - die( - f"pipelock yaml not found at {plan.yaml_path}; " - f"PipelockProxy.prepare must run first" - ) - - info(f"starting pipelock sidecar {name} on network {plan.internal_network}") - - create_args = [ - "docker", "create", - "--name", name, - "--network", plan.internal_network, - PIPELOCK_IMAGE, - "run", "--config", "/etc/pipelock.yaml", - "--listen", f"0.0.0.0:{PIPELOCK_PORT}", - ] - if subprocess.run(create_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: - die(f"failed to create pipelock sidecar {name}") - - cp_result = subprocess.run( - ["docker", "cp", str(plan.yaml_path), f"{name}:/etc/pipelock.yaml"], - capture_output=True, - text=True, - ) - if cp_result.returncode != 0: - subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - die(f"failed to copy pipelock yaml into {name}: {cp_result.stderr.strip()}") - - if subprocess.run( - ["docker", "network", "connect", plan.egress_network, name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ).returncode != 0: - subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - die(f"failed to attach pipelock sidecar {name} to egress network {plan.egress_network}") - - if subprocess.run( - ["docker", "start", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ).returncode != 0: - subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - die(f"failed to start pipelock sidecar {name}") - - return name + """Bring up the pipelock sidecar according to `plan`. Returns + the proxy_target string identifying the running instance — the + same value to pass to `.stop`. Backend-specific.""" + @abstractmethod def stop(self, proxy_target: str) -> None: - """Idempotent: missing container is success. `proxy_target` is - the container name returned by .start.""" - if subprocess.run( - ["docker", "inspect", proxy_target], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ).returncode == 0: - if subprocess.run( - ["docker", "rm", "-f", proxy_target], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ).returncode != 0: - warn( - f"failed to remove pipelock sidecar {proxy_target}; " - f"clean up with 'docker rm -f {proxy_target}'" - ) + """Tear down the pipelock sidecar identified by `proxy_target` + (the value `.start` returned). Idempotent: a missing target is + success. Backend-specific.""" diff --git a/tests/test_orphan_cleanup.py b/tests/test_orphan_cleanup.py index 1227ca6..40218e2 100644 --- a/tests/test_orphan_cleanup.py +++ b/tests/test_orphan_cleanup.py @@ -13,7 +13,8 @@ from claude_bottle.backend.docker.network import ( network_create_internal, network_remove, ) -from claude_bottle.pipelock import PipelockProxy, pipelock_container_name +from claude_bottle.backend.docker.pipelock import DockerPipelockProxy +from claude_bottle.pipelock import pipelock_container_name from tests._docker import skip_unless_docker @@ -69,7 +70,7 @@ class TestOrphanCleanup(unittest.TestCase): def test_pipelock_stop_missing_sidecar(self): # Should not raise. - PipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}")) + DockerPipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}")) if __name__ == "__main__": diff --git a/tests/test_pipelock_sidecar_smoke.py b/tests/test_pipelock_sidecar_smoke.py index a853769..30fe27f 100644 --- a/tests/test_pipelock_sidecar_smoke.py +++ b/tests/test_pipelock_sidecar_smoke.py @@ -12,7 +12,8 @@ import unittest import urllib.request from pathlib import Path -from claude_bottle.pipelock import PIPELOCK_IMAGE, PipelockProxy +from claude_bottle.backend.docker.pipelock import DockerPipelockProxy +from claude_bottle.pipelock import PIPELOCK_IMAGE from tests._docker import skip_unless_docker from tests.fixtures import fixture_minimal @@ -38,7 +39,7 @@ class TestPipelockSidecarSmoke(unittest.TestCase): ) def test_smoke(self): yaml_path = self.work_dir / "pipelock.yaml" - PipelockProxy().prepare(fixture_minimal(), "dev", "demo", yaml_path) + DockerPipelockProxy().prepare(fixture_minimal(), "dev", "demo", yaml_path) create = subprocess.run( [ diff --git a/tests/test_pipelock_yaml.py b/tests/test_pipelock_yaml.py index 215783d..afddfbb 100644 --- a/tests/test_pipelock_yaml.py +++ b/tests/test_pipelock_yaml.py @@ -7,15 +7,15 @@ import tempfile import unittest from pathlib import Path +from claude_bottle.backend.docker.pipelock import DockerPipelockProxy from claude_bottle.manifest import Manifest -from claude_bottle.pipelock import PipelockProxy from tests.fixtures import fixture_minimal, fixture_with_ssh class TestPipelockProxyPrepare(unittest.TestCase): def setUp(self): self.out_dir = Path(tempfile.mkdtemp()) - self.proxy = PipelockProxy() + self.proxy = DockerPipelockProxy() def tearDown(self): import shutil From b49281800a07068ffd79de78ffbd673a6f0b9ca8 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 13:57:18 -0400 Subject: [PATCH 38/44] refactor(pipelock): move Docker-specific naming helpers to docker/pipelock.py The three slug-based naming helpers were nominally on pipelock.py but each assumed a Docker container topology (the container name is 'claude-bottle-pipelock-', the proxy URL uses that container name). Move them next to DockerPipelockProxy: pipelock_container_name -> claude-bottle-pipelock- pipelock_proxy_url -> http://: pipelock_proxy_host_port -> : backend.py imports them directly from .pipelock; the orphan-cleanup test imports container_name from the same place. --- claude_bottle/backend/docker/backend.py | 10 +++++++--- claude_bottle/backend/docker/pipelock.py | 13 ++++++++++++- claude_bottle/pipelock.py | 12 ------------ tests/test_orphan_cleanup.py | 6 ++++-- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index b0e50ce..e47a82e 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -29,7 +29,11 @@ from . import util as docker_mod from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan -from .pipelock import DockerPipelockProxy +from .pipelock import ( + DockerPipelockProxy, + pipelock_proxy_host_port, + pipelock_proxy_url, +) # Where the repo root lives, for `docker build` context. Computed once. @@ -204,7 +208,7 @@ class DockerBottleBackend(BottleBackend): """Build the `docker run` argv and execute it, handling name-conflict races by incrementing the suffix (unless the name was user-pinned). Returns the resolved container name.""" - proxy_url = pipelock.pipelock_proxy_url(plan.slug) + proxy_url = pipelock_proxy_url(plan.slug) docker_args: list[str] = [ "--rm", "-d", "--name", plan.container_name, @@ -411,7 +415,7 @@ class DockerBottleBackend(BottleBackend): return container = target - proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) + proxy_host_port = pipelock_proxy_host_port(plan.slug) container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") container_ssh = f"{container_home}/.ssh" agent_socket = "/run/claude-bottle-agent.sock" diff --git a/claude_bottle/backend/docker/pipelock.py b/claude_bottle/backend/docker/pipelock.py index 82bcebd..0e0bdc1 100644 --- a/claude_bottle/backend/docker/pipelock.py +++ b/claude_bottle/backend/docker/pipelock.py @@ -12,10 +12,21 @@ from ...pipelock import ( PIPELOCK_PORT, PipelockProxy, PipelockProxyPlan, - pipelock_container_name, ) +def pipelock_container_name(slug: str) -> str: + return f"claude-bottle-pipelock-{slug}" + + +def pipelock_proxy_url(slug: str) -> str: + return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}" + + +def pipelock_proxy_host_port(slug: str) -> str: + return f"{pipelock_container_name(slug)}:{PIPELOCK_PORT}" + + class DockerPipelockProxy(PipelockProxy): """Brings the pipelock sidecar up and down via Docker.""" diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 0835aca..9d799d8 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -42,18 +42,6 @@ DEFAULT_ALLOWLIST: tuple[str, ...] = ( ) -def pipelock_container_name(slug: str) -> str: - return f"claude-bottle-pipelock-{slug}" - - -def pipelock_proxy_url(slug: str) -> str: - return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}" - - -def pipelock_proxy_host_port(slug: str) -> str: - return f"{pipelock_container_name(slug)}:{PIPELOCK_PORT}" - - # --- Allowlist resolution -------------------------------------------------- diff --git a/tests/test_orphan_cleanup.py b/tests/test_orphan_cleanup.py index 40218e2..928fbf3 100644 --- a/tests/test_orphan_cleanup.py +++ b/tests/test_orphan_cleanup.py @@ -13,8 +13,10 @@ from claude_bottle.backend.docker.network import ( network_create_internal, network_remove, ) -from claude_bottle.backend.docker.pipelock import DockerPipelockProxy -from claude_bottle.pipelock import pipelock_container_name +from claude_bottle.backend.docker.pipelock import ( + DockerPipelockProxy, + pipelock_container_name, +) from tests._docker import skip_unless_docker From 1b3254bf376b2c42582043b683554a0c79b7b044 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 13:59:43 -0400 Subject: [PATCH 39/44] refactor(pipelock): move PIPELOCK_IMAGE and PIPELOCK_PORT to docker/pipelock.py Both constants were already only used by Docker-specific code (the sidecar boot, the proxy_url/host_port naming helpers, the image contract test). Move them next to DockerPipelockProxy. Top-level pipelock.py drops the 'os' import along with the constants; the two test files that pulled PIPELOCK_IMAGE retarget at the new location. --- claude_bottle/backend/docker/pipelock.py | 17 ++++++++++++----- claude_bottle/pipelock.py | 11 ----------- tests/test_pipelock_image.py | 2 +- tests/test_pipelock_sidecar_smoke.py | 6 ++++-- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/claude_bottle/backend/docker/pipelock.py b/claude_bottle/backend/docker/pipelock.py index 0e0bdc1..7b322a8 100644 --- a/claude_bottle/backend/docker/pipelock.py +++ b/claude_bottle/backend/docker/pipelock.py @@ -4,16 +4,23 @@ YAML-config generation from PipelockProxy.""" from __future__ import annotations +import os import subprocess from ...log import die, info, warn -from ...pipelock import ( - PIPELOCK_IMAGE, - PIPELOCK_PORT, - PipelockProxy, - PipelockProxyPlan, +from ...pipelock import PipelockProxy, PipelockProxyPlan + + +# Pipelock image, pinned by digest. The digest is the multi-arch image +# index for ghcr.io/luckypipewrench/pipelock:2.3.0. +PIPELOCK_IMAGE = os.environ.get( + "CLAUDE_BOTTLE_PIPELOCK_IMAGE", + "ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9", ) +# Listening port for pipelock's forward proxy. +PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888") + def pipelock_container_name(slug: str) -> str: return f"claude-bottle-pipelock-{slug}" diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 9d799d8..371f6b6 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -12,7 +12,6 @@ Image pin: ghcr.io/luckypipewrench/pipelock@sha256: for tag 2.3.0. from __future__ import annotations -import os from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path @@ -20,16 +19,6 @@ from pathlib import Path from .manifest import Bottle, Manifest from .util import is_ipv4_literal -# Pipelock image, pinned by digest. The digest is the multi-arch image -# index for ghcr.io/luckypipewrench/pipelock:2.3.0. -PIPELOCK_IMAGE = os.environ.get( - "CLAUDE_BOTTLE_PIPELOCK_IMAGE", - "ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9", -) - -# Listening port for pipelock's forward proxy. -PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888") - # Baked-in default allowlist for hosts Claude Code itself needs. DEFAULT_ALLOWLIST: tuple[str, ...] = ( "api.anthropic.com", diff --git a/tests/test_pipelock_image.py b/tests/test_pipelock_image.py index c8c1213..ffb23b1 100644 --- a/tests/test_pipelock_image.py +++ b/tests/test_pipelock_image.py @@ -5,7 +5,7 @@ docker.""" import subprocess import unittest -from claude_bottle.pipelock import PIPELOCK_IMAGE +from claude_bottle.backend.docker.pipelock import PIPELOCK_IMAGE from tests._docker import skip_unless_docker diff --git a/tests/test_pipelock_sidecar_smoke.py b/tests/test_pipelock_sidecar_smoke.py index 30fe27f..dc0251e 100644 --- a/tests/test_pipelock_sidecar_smoke.py +++ b/tests/test_pipelock_sidecar_smoke.py @@ -12,8 +12,10 @@ import unittest import urllib.request from pathlib import Path -from claude_bottle.backend.docker.pipelock import DockerPipelockProxy -from claude_bottle.pipelock import PIPELOCK_IMAGE +from claude_bottle.backend.docker.pipelock import ( + PIPELOCK_IMAGE, + DockerPipelockProxy, +) from tests._docker import skip_unless_docker from tests.fixtures import fixture_minimal From 1269edf3112a7f35b07e856307bb292f4d0617c5 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 14:05:48 -0400 Subject: [PATCH 40/44] refactor(pipelock): PipelockProxy.prepare takes a Bottle, not (manifest, name) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the allowlist-resolution helpers' shape: the caller resolves the bottle once and passes it in. Signature drops from (manifest, bottle_name, slug, yaml_path) to (bottle, slug, yaml_path). DockerBottleBackend.prepare_proxy uses manifest.bottle_for(agent_name) to get the bottle directly. Tests pass fixture.bottles[name]. prepare's docstring also explains what `slug` is: the lowercased, hyphen-normalized agent identifier used as the suffix in every per-agent resource name (agent container, pipelock container, the internal/egress networks). It's stored on the plan so start can derive the sidecar's container name. Top-level pipelock.py drops the Manifest import — no longer used. --- claude_bottle/backend/docker/backend.py | 4 ++-- claude_bottle/pipelock.py | 25 +++++++++++++++---------- tests/test_pipelock_sidecar_smoke.py | 2 +- tests/test_pipelock_yaml.py | 8 ++++---- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index e47a82e..023a0d3 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -136,9 +136,9 @@ class DockerBottleBackend(BottleBackend): PipelockProxyPlan for the launch step to consume. Stage-only: no Docker resources created yet.""" yaml_path = stage_dir / "pipelock.yaml" - bottle_name = spec.manifest.agents[spec.agent_name].bottle + bottle = spec.manifest.bottle_for(spec.agent_name) slug = docker_mod.slugify(spec.agent_name) - return self._proxy.prepare(spec.manifest, bottle_name, slug, yaml_path) + return self._proxy.prepare(bottle, slug, yaml_path) @contextmanager def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]: diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 371f6b6..7e6cfa5 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -16,7 +16,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path -from .manifest import Bottle, Manifest +from .manifest import Bottle from .util import is_ipv4_literal # Baked-in default allowlist for hosts Claude Code itself needs. @@ -110,8 +110,21 @@ class PipelockProxy(ABC): and lives on concrete subclasses (e.g. DockerPipelockProxy).""" def prepare( - self, manifest: Manifest, bottle_name: str, slug: str, yaml_path: Path + self, bottle: Bottle, slug: str, yaml_path: Path ) -> PipelockProxyPlan: + """Write the pipelock yaml config (mode 600) to `yaml_path` + and return the plan for `.start`. + + `slug` is the agent-derived identifier (lowercased, + hyphen-normalized) used as the suffix in every per-agent + resource name — the agent container, the pipelock container + (`claude-bottle-pipelock-`), the internal/egress + networks. It's stored on the returned plan so the backend's + start step can derive the sidecar's container name.""" + self._build_pipelock_yaml(bottle, yaml_path) + return PipelockProxyPlan(yaml_path=yaml_path, slug=slug) + + def _build_pipelock_yaml(self, bottle: Bottle, yaml_path: Path): """Write the pipelock yaml config (mode 600) to `yaml_path` for the sidecar to consume when it boots. Carries the effective allowlist (bottle.egress.allowlist UNION @@ -119,12 +132,6 @@ class PipelockProxy(ABC): port, strict mode + forward_proxy + DLP defaults + scan_env. Deliberately contains no env values, no secrets, no per-agent customization beyond the hostname list.""" - return self._build_pipelock_yaml(manifest, bottle_name, slug, yaml_path) - - def _build_pipelock_yaml( - self, manifest: Manifest, bottle_name: str, slug: str, yaml_path: Path - ) -> PipelockProxyPlan: - bottle = manifest.bottles[bottle_name] allowlist = pipelock_effective_allowlist(bottle) trusted = pipelock_bottle_ssh_trusted_domains(bottle) ip_cidrs = pipelock_bottle_ssh_ip_cidrs(bottle) @@ -161,8 +168,6 @@ class PipelockProxy(ABC): yaml_path.write_text("\n".join(lines) + "\n") yaml_path.chmod(0o600) - return PipelockProxyPlan(yaml_path=yaml_path, slug=slug) - @abstractmethod def start(self, plan: PipelockProxyPlan) -> str: """Bring up the pipelock sidecar according to `plan`. Returns diff --git a/tests/test_pipelock_sidecar_smoke.py b/tests/test_pipelock_sidecar_smoke.py index dc0251e..06131a2 100644 --- a/tests/test_pipelock_sidecar_smoke.py +++ b/tests/test_pipelock_sidecar_smoke.py @@ -41,7 +41,7 @@ class TestPipelockSidecarSmoke(unittest.TestCase): ) def test_smoke(self): yaml_path = self.work_dir / "pipelock.yaml" - DockerPipelockProxy().prepare(fixture_minimal(), "dev", "demo", yaml_path) + DockerPipelockProxy().prepare(fixture_minimal().bottles["dev"], "demo", yaml_path) create = subprocess.run( [ diff --git a/tests/test_pipelock_yaml.py b/tests/test_pipelock_yaml.py index afddfbb..ae6a80b 100644 --- a/tests/test_pipelock_yaml.py +++ b/tests/test_pipelock_yaml.py @@ -23,7 +23,7 @@ class TestPipelockProxyPrepare(unittest.TestCase): def test_minimal(self): yaml_path = self.out_dir / "min.yaml" - self.proxy.prepare(fixture_minimal(), "dev", "demo", yaml_path) + self.proxy.prepare(fixture_minimal().bottles["dev"], "demo", yaml_path) content = yaml_path.read_text() self.assertIn("mode: strict", content) self.assertIn("enforce: true", content) @@ -41,7 +41,7 @@ class TestPipelockProxyPrepare(unittest.TestCase): def test_ssh_blocks(self): yaml_path = self.out_dir / "ssh.yaml" - self.proxy.prepare(fixture_with_ssh(), "dev", "demo", yaml_path) + self.proxy.prepare(fixture_with_ssh().bottles["dev"], "demo", yaml_path) content = yaml_path.read_text() self.assertIn("trusted_domains:", content) self.assertIn("github.com", content) @@ -65,7 +65,7 @@ class TestPipelockProxyPrepare(unittest.TestCase): "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) yaml_path = self.out_dir / "secret.yaml" - self.proxy.prepare(manifest, "dev", "demo", yaml_path) + self.proxy.prepare(manifest.bottles["dev"], "demo", yaml_path) content = yaml_path.read_text() self.assertNotIn("literal-value-should-not-appear", content) self.assertNotIn("MY_SECRET", content) @@ -73,7 +73,7 @@ class TestPipelockProxyPrepare(unittest.TestCase): def test_file_mode_is_600(self): yaml_path = self.out_dir / "min.yaml" - self.proxy.prepare(fixture_minimal(), "dev", "demo", yaml_path) + self.proxy.prepare(fixture_minimal().bottles["dev"], "demo", yaml_path) mode = os.stat(yaml_path).st_mode & 0o777 self.assertEqual(0o600, mode) From a786ca3391c547c6e3f2c3266a5152db2d77181e Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 14:09:55 -0400 Subject: [PATCH 41/44] refactor(util): split private helpers off DockerBottleBackend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New file claude_bottle/backend/util.py for cross-backend host-side helpers: host_skill_dir(name) — resolves $HOME/.claude/skills/ docker/util.py gains: docker_exec_root(container, argv) — `docker exec -u 0` wrapper used by SSH provisioning DockerBottleBackend drops the two methods that wrapped these (`_host_skill_dir`, `_docker_exec_root`) — they had no instance state and just lived on the class for organizational reasons. Call sites now use the imported functions directly. --- claude_bottle/backend/docker/backend.py | 42 +++++++++---------------- claude_bottle/backend/docker/util.py | 10 ++++++ claude_bottle/backend/util.py | 18 +++++++++++ 3 files changed, 43 insertions(+), 27 deletions(-) create mode 100644 claude_bottle/backend/util.py diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 023a0d3..f6c2071 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -24,6 +24,7 @@ from ...log import die, info from ...manifest import SshEntry from ...util import expand_tilde from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec +from ..util import host_skill_dir from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle @@ -307,19 +308,13 @@ class DockerBottleBackend(BottleBackend): user doesn't get a launch prompt for a plan that's already known to break.""" for name in skills: - path = self._host_skill_dir(name) + path = host_skill_dir(name) if not os.path.isdir(path): die( f"skill '{name}' not found on host at {path}. " f"Create it under ~/.claude/skills/, then re-run." ) - def _host_skill_dir(self, name: str) -> str: - home = os.environ.get("HOME") - if not home: - die("HOME not set") - return f"{home}/.claude/skills/{name}" - def provision_skills(self, plan: BottlePlan, target: str) -> None: """Copy each of the agent's named skills from the host's ~/.claude/skills// into the container's equivalent path. @@ -345,7 +340,7 @@ class DockerBottleBackend(BottleBackend): ) for n in agent.skills: - src = self._host_skill_dir(n) + src = host_skill_dir(n) if not os.path.isdir(src): die(f"skill '{n}' disappeared from host between validation and copy at {src}.") dst = f"{skills_dir}/{n}" @@ -423,14 +418,14 @@ class DockerBottleBackend(BottleBackend): keys_dir = "/root/.claude-bottle-keys" # ~/.ssh for node (700, owned by node). - self._docker_exec_root(container, ["mkdir", "-p", container_ssh]) - self._docker_exec_root(container, ["chown", "node:node", container_ssh]) - self._docker_exec_root(container, ["chmod", "700", container_ssh]) + docker_mod.docker_exec_root(container, ["mkdir", "-p", container_ssh]) + docker_mod.docker_exec_root(container, ["chown", "node:node", container_ssh]) + docker_mod.docker_exec_root(container, ["chmod", "700", container_ssh]) # /root/.claude-bottle-keys for root (700, root-owned). - self._docker_exec_root(container, ["mkdir", "-p", keys_dir]) - self._docker_exec_root(container, ["chown", "root:root", keys_dir]) - self._docker_exec_root(container, ["chmod", "700", keys_dir]) + docker_mod.docker_exec_root(container, ["mkdir", "-p", keys_dir]) + docker_mod.docker_exec_root(container, ["chown", "root:root", keys_dir]) + docker_mod.docker_exec_root(container, ["chmod", "700", keys_dir]) config_file = plan.stage_dir / "ssh_config" known_hosts_file = plan.stage_dir / "ssh_known_hosts" @@ -459,8 +454,8 @@ class DockerBottleBackend(BottleBackend): stdout=subprocess.DEVNULL, check=True, ) - self._docker_exec_root(container, ["chown", "root:root", container_key_path]) - self._docker_exec_root(container, ["chmod", "600", container_key_path]) + docker_mod.docker_exec_root(container, ["chown", "root:root", container_key_path]) + docker_mod.docker_exec_root(container, ["chmod", "600", container_key_path]) container_key_paths.append(container_key_path) @@ -533,8 +528,8 @@ class DockerBottleBackend(BottleBackend): stdout=subprocess.DEVNULL, check=True, ) - self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"]) - self._docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"]) + docker_mod.docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"]) + docker_mod.docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"]) if known_hosts_file.stat().st_size > 0: info(f"writing {container_ssh}/known_hosts") @@ -543,15 +538,8 @@ class DockerBottleBackend(BottleBackend): stdout=subprocess.DEVNULL, check=True, ) - self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"]) - self._docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"]) - - def _docker_exec_root(self, container: str, argv: list[str]) -> None: - subprocess.run( - ["docker", "exec", "-u", "0", container, *argv], - stdout=subprocess.DEVNULL, - check=True, - ) + docker_mod.docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"]) + docker_mod.docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"]) def provision_git(self, plan: BottlePlan, target: str) -> None: """If --cwd was set and the host cwd has a .git directory, copy diff --git a/claude_bottle/backend/docker/util.py b/claude_bottle/backend/docker/util.py index 75318e8..532635a 100644 --- a/claude_bottle/backend/docker/util.py +++ b/claude_bottle/backend/docker/util.py @@ -48,6 +48,16 @@ def container_exists(name: str) -> bool: return bool(result.stdout.strip()) +def docker_exec_root(container: str, argv: list[str]) -> None: + """Run `docker exec -u 0` in the named container, check=True. Used + by SSH provisioning to chown/chmod files that need root.""" + subprocess.run( + ["docker", "exec", "-u", "0", container, *argv], + stdout=subprocess.DEVNULL, + check=True, + ) + + _SLUG_RE = re.compile(r"[^a-z0-9]+") diff --git a/claude_bottle/backend/util.py b/claude_bottle/backend/util.py new file mode 100644 index 0000000..bb26f49 --- /dev/null +++ b/claude_bottle/backend/util.py @@ -0,0 +1,18 @@ +"""Cross-backend utility helpers — host-side primitives shared by +every backend implementation. Backend-specific helpers live one level +deeper (e.g. claude_bottle/backend/docker/util.py).""" + +from __future__ import annotations + +import os + +from ..log import die + + +def host_skill_dir(name: str) -> str: + """Return the host-side path for a named skill: + `$HOME/.claude/skills/`. Dies if HOME is unset.""" + home = os.environ.get("HOME") + if not home: + die("HOME not set") + return f"{home}/.claude/skills/{name}" From 988c0bdad3cbce1c12270c6f9e300bd946adaf04 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 14:17:50 -0400 Subject: [PATCH 42/44] refactor(env): rename env_resolve.py -> env.py; env_resolve() -> resolve_env_into() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module name aligns with the others (manifest, pipelock, network, log) — nouns/noun-y, not verb phrases. The function name now reads naturally at the call site: resolve_env_into(manifest, agent, env_file, args_file). --- claude_bottle/backend/docker/backend.py | 4 ++-- claude_bottle/{env_resolve.py => env.py} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename claude_bottle/{env_resolve.py => env.py} (99%) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index f6c2071..89668d5 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import Iterator, Sequence from ... import pipelock -from ...env_resolve import env_resolve +from ...env import resolve_env_into from ...log import die, info from ...manifest import SshEntry from ...util import expand_tilde @@ -108,7 +108,7 @@ class DockerBottleBackend(BottleBackend): prompt_file.chmod(0o600) proxy_plan = self.prepare_proxy(spec, stage_dir) - env_resolve(manifest, spec.agent_name, env_file, args_file) + resolve_env_into(manifest, spec.agent_name, env_file, args_file) prompt_file.write_text(agent.prompt) allowlist_summary = pipelock.pipelock_allowlist_summary(bottle) diff --git a/claude_bottle/env_resolve.py b/claude_bottle/env.py similarity index 99% rename from claude_bottle/env_resolve.py rename to claude_bottle/env.py index 0e0b3e7..0e70a3a 100644 --- a/claude_bottle/env_resolve.py +++ b/claude_bottle/env.py @@ -97,7 +97,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str: return value -def env_resolve( +def resolve_env_into( manifest: Manifest, agent: str, env_file: Path, From 656dc88d769fc8e586ec9efbac22897004121b8a Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 14:39:44 -0400 Subject: [PATCH 43/44] refactor(env): make env resolution backend-agnostic resolve_env_into(...) becomes resolve_env(manifest, agent) -> ResolvedEnv (forwarded names + literals). The docker backend now owns env-file / argv serialization and the --env-file newline check. Also drops stray Docker references from manifest.py, pipelock.py, util.py, and trims the duplicated command list from cli.py's docstring (usage() in claude_bottle/cli/__init__.py is now the only listing). --- claude_bottle/backend/docker/backend.py | 30 ++++++++-- claude_bottle/env.py | 73 +++++++++++++------------ claude_bottle/manifest.py | 6 +- claude_bottle/pipelock.py | 2 +- claude_bottle/util.py | 4 +- cli.py | 17 +----- 6 files changed, 70 insertions(+), 62 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 89668d5..70a880a 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import Iterator, Sequence from ... import pipelock -from ...env import resolve_env_into +from ...env import ResolvedEnv, resolve_env from ...log import die, info from ...manifest import SshEntry from ...util import expand_tilde @@ -101,14 +101,12 @@ class DockerBottleBackend(BottleBackend): env_file = stage_dir / "agent.env" args_file = stage_dir / "docker-args" prompt_file = stage_dir / "prompt.txt" - env_file.write_text("") - env_file.chmod(0o600) - args_file.write_text("") prompt_file.write_text("") prompt_file.chmod(0o600) proxy_plan = self.prepare_proxy(spec, stage_dir) - resolve_env_into(manifest, spec.agent_name, env_file, args_file) + resolved = resolve_env(manifest, spec.agent_name) + self._write_env_files(resolved, env_file, args_file) prompt_file.write_text(agent.prompt) allowlist_summary = pipelock.pipelock_allowlist_summary(bottle) @@ -131,6 +129,28 @@ class DockerBottleBackend(BottleBackend): use_runsc=use_runsc, ) + def _write_env_files( + self, resolved: ResolvedEnv, env_file: Path, args_file: Path + ) -> None: + """Serialize a ResolvedEnv into the two on-disk formats the launch + step consumes: `--env-file` syntax for literals (NAME=VALUE per + line) and a paired `-e\\nNAME\\n` stream for forwarded names. + Both files are created here (mode 600 on the literals file, + which may carry sensitive verbatim values from the manifest).""" + env_lines: list[str] = [] + for name, value in resolved.literals.items(): + if "\n" in value: + die( + f"env entry {name} (literal) contains a newline; " + f"docker --env-file cannot represent multi-line values." + ) + env_lines.append(f"{name}={value}") + env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else "")) + env_file.chmod(0o600) + + args_lines = [f"-e\n{name}" for name in resolved.forwarded] + args_file.write_text("\n".join(args_lines) + ("\n" if args_lines else "")) + def prepare_proxy(self, spec: BottleSpec, stage_dir: Path) -> pipelock.PipelockProxyPlan: """Decide where the pipelock yaml lives in `stage_dir`, delegate to PipelockProxy to write it, and return the resolved diff --git a/claude_bottle/env.py b/claude_bottle/env.py index 0e70a3a..e8a5739 100644 --- a/claude_bottle/env.py +++ b/claude_bottle/env.py @@ -1,28 +1,27 @@ -"""Env resolver. Walks the env entries for one agent and produces: +"""Env resolver. Walks the env entries for one agent and produces a +backend-neutral ResolvedEnv describing how the bottle should receive +each variable: - 1. The list of `docker run` arg fragments needed to forward each var. - Both `secret` and `interpolated` entries become `-e NAME` (no - `=value`) so Docker inherits the value from this process env - without rendering it on argv or persisting it to disk. - Only `literal` entries are written to a host-disk env-file. - 2. The export side-effect of populating this process's env with - secret values prompted from the user, and with interpolated - values copied from the matching host var, so `-e NAME` actually - has something to inherit. + - `forwarded` — names whose values have been placed into this + process's env (from a tty prompt for `secret`, from the matching + host var for `interpolated`). The backend is expected to pass + these to the bottle by-name so the value never appears on argv, + in a file, or in a log line. + - `literals` — name→value pairs that the manifest carries verbatim. + The backend serializes these however its launcher accepts env + (an env-file, an API payload, etc.). Each env entry is a string. Mode is selected by sentinel prefix: - "?" → secret (prompt at runtime). Bare "?" uses default prompt; + "?" -> secret (prompt at runtime). Bare "?" uses default prompt; "?" uses as the prompt body. - "${HOST_VAR}" → interpolated from $HOST_VAR in the host process env - any other str → literal (the string is the value verbatim) + "${HOST_VAR}" -> interpolated from $HOST_VAR in the host process env + any other str -> literal (the string is the value verbatim) Critical rules: - NEVER echo, log, or interpolate the value of a secret or interpolated env var. Both are treated as potentially sensitive: nothing about their value (other than presence) ever lands on disk, in a log line, or on argv. - - The env-file written for literals lives under mktemp -d with mode - 600, removed by the caller's cleanup. - Errors mention only the variable NAME, never any portion of the value. """ @@ -32,7 +31,7 @@ import getpass import os import re import sys -from pathlib import Path +from dataclasses import dataclass, field from .log import die from .manifest import Manifest @@ -40,6 +39,18 @@ from .manifest import Manifest _INTERPOLATED_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$") +@dataclass(frozen=True) +class ResolvedEnv: + """Backend-neutral env resolution result. + + `forwarded` names have already been exported into os.environ by + resolve_env; the backend forwards by-name. `literals` carry their + values verbatim and are serialized by the backend.""" + + forwarded: list[str] = field(default_factory=list) + literals: dict[str, str] = field(default_factory=dict) + + def env_entry_kind(raw: str) -> str: """Returns 'secret', 'interpolated', or 'literal'.""" if raw.startswith("?"): @@ -97,17 +108,14 @@ def _read_secret_silent(name: str, prompt_body: str) -> str: return value -def resolve_env_into( - manifest: Manifest, - agent: str, - env_file: Path, - out_args: Path, -) -> None: +def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv: """Iterate the agent's env entries: - - secret: always prompt; export into this process; append `-e NAME` to out_args - - interpolated: copy host value; export under target name; append `-e NAME` - - literal: append `NAME=VALUE` to env_file + - secret: always prompt; export into this process; mark forwarded + - interpolated: copy host value; export under target name; mark forwarded + - literal: include in the literals map verbatim """ + forwarded: list[str] = [] + literals: dict[str, str] = {} bottle = manifest.bottle_for(agent) for name, raw in bottle.env.items(): if not name: @@ -117,8 +125,7 @@ def resolve_env_into( prompt_body = env_entry_secret_prompt(raw) value = _read_secret_silent(name, prompt_body) os.environ[name] = value - with out_args.open("a") as f: - f.write(f"-e\n{name}\n") + forwarded.append(name) elif kind == "interpolated": host_var = env_entry_interpolated_from(raw) host_value = os.environ.get(host_var, "") @@ -128,13 +135,7 @@ def resolve_env_into( f"but ${host_var} is unset or empty in the host environment." ) os.environ[name] = host_value - with out_args.open("a") as f: - f.write(f"-e\n{name}\n") + forwarded.append(name) else: # literal - if "\n" in raw: - die( - f"env entry {name} (literal) contains a newline; " - f"docker --env-file cannot represent multi-line values." - ) - with env_file.open("a") as f: - f.write(f"{name}={raw}\n") + literals[name] = raw + return ResolvedEnv(forwarded=forwarded, literals=literals) diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index a65d981..d86bd4f 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -119,9 +119,9 @@ class Bottle: if "runtime" in d: die( f"bottle '{name}' has a 'runtime' field, which is no longer " - f"supported. gVisor (runsc) is now auto-detected when " - f"registered with Docker; remove the 'runtime' field from " - f"the bottle definition." + f"supported. gVisor (runsc) is now auto-detected by the " + f"backend; remove the 'runtime' field from the bottle " + f"definition." ) env: dict[str, str] = {} diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 7e6cfa5..4d9967f 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -107,7 +107,7 @@ class PipelockProxyPlan: class PipelockProxy(ABC): """The pipelock egress proxy. Encapsulates the YAML-config generation; the sidecar's start/stop lifecycle is backend-specific - and lives on concrete subclasses (e.g. DockerPipelockProxy).""" + and lives on concrete subclasses.""" def prepare( self, bottle: Bottle, slug: str, yaml_path: Path diff --git a/claude_bottle/util.py b/claude_bottle/util.py index 1fb877d..c8108f3 100644 --- a/claude_bottle/util.py +++ b/claude_bottle/util.py @@ -1,7 +1,7 @@ """Cross-cutting utility helpers used by multiple modules. -Top-level (i.e. backend-agnostic) — Docker-specific helpers live in -claude_bottle/backend/docker/util.py.""" +Top-level (i.e. backend-agnostic) — backend-specific helpers live one +level deeper, under their backend package.""" from __future__ import annotations diff --git a/cli.py b/cli.py index a7c21c6..8ad8158 100755 --- a/cli.py +++ b/cli.py @@ -1,19 +1,6 @@ #!/usr/bin/env python3 -"""cli.py — manage claude-bottle containers. - -usage: cli.py [args...] - -Commands: - build build (or rebuild) the claude-bottle Docker image. - cleanup stop and remove all active claude-bottle containers. - edit open an agent in vim for editing. - info print env, skills, and prompt details for a named agent. - init interactively create a new agent and add it to claude-bottle.json. - list list available agents or active containers. - start boot a sandboxed container for a named agent and attach an - interactive claude-code session. The container is torn down - when the session ends. -""" +"""cli.py — entry point for the claude-bottle CLI. Run with --help (or +no args) for the command list.""" from __future__ import annotations From f0b67a3e9400a7d469812de157e3497514658ddc Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 14:47:17 -0400 Subject: [PATCH 44/44] docs(prd): update PRD 0003 to reflect the shipped design Renames the file and rewrites the body around what actually shipped: class-based BottleBackend ABC (not a free create_docker_bottle function), the two-phase prepare/launch split, the backend/docker/ subpackage layout, env.py reshaped into a backend-neutral ResolvedEnv, and PipelockProxy split between top-level and backend/docker/. --- docs/prds/0003-bottle-backend-abstraction.md | 300 +++++++++++++++++++ docs/prds/0003-bottle-factory-abstraction.md | 279 ----------------- 2 files changed, 300 insertions(+), 279 deletions(-) create mode 100644 docs/prds/0003-bottle-backend-abstraction.md delete mode 100644 docs/prds/0003-bottle-factory-abstraction.md diff --git a/docs/prds/0003-bottle-backend-abstraction.md b/docs/prds/0003-bottle-backend-abstraction.md new file mode 100644 index 0000000..556c4cf --- /dev/null +++ b/docs/prds/0003-bottle-backend-abstraction.md @@ -0,0 +1,300 @@ +# PRD 0003: Bottle Backend abstraction + +- **Status:** Draft +- **Author:** didericis +- **Created:** 2026-05-10 + +## Summary + +Introduce a per-backend abstraction that owns the end-to-end lifecycle +of a "bottle" (a running, isolated environment with claude inside). +The first and only implementation lands as `DockerBottleBackend`. No +second backend ships in this PRD. + +## Problem + +Today, "how to launch a bottle" is spread across roughly six modules +(`claude_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, +`skills.py`, `docker.py`), each shelling out to `docker` directly via +`subprocess.run(["docker", ...])`. That coupling means: + +- Adding a second backend (Apple's `container`, fly.io, a remote SSH + host, etc.) requires editing every one of those call sites. The + research note `docs/research/apple-container-backend.md` already + flags this as a prerequisite for that work. +- The pipelock sidecar topology — two networks, multi-attach, sidecar + lifecycle — is a Docker implementation detail that has leaked into + the top-level CLI orchestration. It reads as a core concept of the + project, but a fly.io bottle would not need any of it. +- The manifest carries a Docker-specific `runtime: "runsc"` field + (`bottles[].runtime`). Anyone setting it has to know about gVisor, + whether Docker has it registered, and what to do on macOS where it + isn't available natively. The field has one valid non-default value + and exists only because the current code can't decide on its own. + +The shape that fits the project's actual goals (isolated agent runs +across multiple backends) is "one backend per platform," not "one +container-runtime SDK with N drivers." A previous draft of this PRD +considered a low-level runtime-primitive protocol (`run`, `exec`, +`cp`, `network_connect`, ...) and rejected it as the wrong layer — +it would have forced fly.io to pretend it's Docker. + +## Goals / Success Criteria + +The feature works when all of the following are observable: + +- `cli.py start` works identically for an existing manifest with no + user-visible changes other than (a) a startup log line naming the + Docker runtime in use, and (b) `bottles[].runtime` no longer being a + valid manifest field. +- On a Linux host with gVisor registered, the agent container runs + under `runsc` without anything in the manifest requesting it. +- On a host without gVisor (including macOS), the agent container runs + under the default `runc` runtime; nothing fails, no warning is + printed beyond the runtime-name log line. +- The existing test suite passes with no behavior changes other than + the manifest-schema removal of `runtime`. + +The feature is **done** when all of the following ship: + +- A new `claude_bottle/backend/` package exists with abstract base + classes (`BottleBackend`, `BottlePlan`, `BottleCleanupPlan`, + `Bottle`) plus a `claude_bottle/backend/docker/` subpackage + containing the `DockerBottleBackend` implementation. +- `DockerBottleBackend.launch(plan)` returns a context manager + yielding a `Bottle` handle exposing `exec_claude(argv, *, tty=True)`, + `cp_in(host, ctr)`, and teardown on context exit. +- Every existing `subprocess.run(["docker", ...])` call in + `cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and + `skills.py` either moves into `claude_bottle/backend/docker/` or is + called from it. No top-level CLI code references `docker` directly. +- `bottles[].runtime` is removed from the manifest schema, the + dataclass in `manifest.py`, the example manifest, and any README / + docs references. `require_runsc()` in the old top-level + `claude_bottle/docker.py` is deleted. +- A single env var, `CLAUDE_BOTTLE_BACKEND` (default `"docker"`), + selects the backend. Unknown values die at startup with a list of + known backends. +- The y/N preflight in `cli.py` includes the resolved Docker runtime + alongside the allowlist summary. + +## Non-goals + +- No second backend implementation. There is no + `AppleContainerBottleBackend` / `FlyioBottleBackend` in this PRD. + The registry in `backend/__init__.py` ships with one entry. +- No retries, async, or streaming exec. The current code is + synchronous `subprocess.run`; the `Bottle` handle matches. +- No behavior change beyond the runsc auto-detect. Pipelock topology, + network naming, container naming, image build flow, and SSH + provisioning all stay byte-identical. +- No `--require-runsc` CLI escape hatch. If a user later wants "fail + rather than silently downgrade," that's a follow-up. +- No `bottles[].backend` manifest field. Backend is a property of + the host environment, not the bottle definition (at least for now). + +## Scope + +### In scope + +- New `claude_bottle/backend/` package containing the abstract types + and the registry, plus a `claude_bottle/backend/docker/` subpackage + containing the Docker implementation. +- The `Bottle`, `BottleBackend`, `BottlePlan`, and `BottleCleanupPlan` + abstract base classes; `BottleSpec` data carrier; and + `DockerBottleBackend` implementation. +- Moving Docker-specific subprocess calls into the Docker subpackage. +- Removing `bottles[].runtime` from the manifest schema and the + dataclass. +- Auto-detection of `runsc` registration via `docker info`. +- Preflight integration: the existing y/N output names the resolved + Docker runtime. +- Reshaping `env.py` (formerly `env_resolve.py`) to return a + backend-neutral `ResolvedEnv` (`forwarded` names + `literals` map) + rather than writing docker-shaped files directly. The Docker + backend now owns the `--env-file` / `-e NAME` serialization and the + newline-rejection check. +- Splitting `pipelock.py` into a backend-neutral `PipelockProxy` ABC + (yaml + allowlist resolution) and a `DockerPipelockProxy` subclass + (sidecar start/stop) under the Docker subpackage. +- Test updates: any manifest fixtures referencing `runtime` are + updated; tests that assert on `--runtime=runsc` instead seed the + detection by mocking `docker info`. + +### Out of scope + +- Apple `container` and fly.io backends (separate PRDs, deferred + until the Docker backend is the only thing shipping). +- Generalizing the pipelock sidecar to other backends. Pipelock + topology is, after this PRD, an implementation detail private to + the Docker backend. +- Rewriting `pipelock.py`'s YAML generation. The allowlist→YAML + translation stays where it is and is called by the Docker backend. +- CLI flags for runtime selection / override. + +## Proposed Design + +### New services / components + +A new package, `claude_bottle/backend/`, with an abstract base layer +and a Docker subpackage: + +- **`claude_bottle/backend/__init__.py`** — Defines the abstract base + classes and the backend registry. `BottleSpec` carries the + CLI-supplied intent; the abstract `BottlePlan` and + `BottleCleanupPlan` are the prepared-but-not-launched outputs of + the two `prepare*` phases; `Bottle` is the running-instance handle; + `BottleBackend` is the dispatcher with five methods: + + ```python + class BottleBackend(ABC): + name: str + def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> BottlePlan: ... + def launch(self, plan: BottlePlan) -> ContextManager[Bottle]: ... + def prepare_cleanup(self) -> BottleCleanupPlan: ... + def cleanup(self, plan: BottleCleanupPlan) -> None: ... + def list_active(self) -> None: ... + ``` + + The `prepare` / `launch` split lets the CLI render the y/N preflight + off the `BottlePlan` *before* any container or network is created. + The same split applies to `cleanup`. `BottleBackend.provision(plan, + target)` orchestrates copying skills / SSH / prompt / `.git` into a + running instance via four abstract sub-methods + (`provision_prompt`, `provision_skills`, `provision_ssh`, + `provision_git`); subclasses implement those four rather than + overriding `provision` itself. + + Selection reads `CLAUDE_BOTTLE_BACKEND` (default `"docker"`). + Unknown values call `die()` with the list of known backends: + + ```python + def get_bottle_backend() -> BottleBackend: ... + ``` + +- **`claude_bottle/backend/docker/`** — Subpackage with the Docker + implementation, split into: + - `backend.py` — `DockerBottleBackend`, owning all five abstract + methods (`prepare`, `launch`, `prepare_cleanup`, `cleanup`, + `list_active`) plus the four `provision_*` sub-methods. Probes + for `runsc` availability (`docker info --format + '{{json .Runtimes}}'`), builds the base image and per-cwd derived + image, creates the per-agent internal and egress networks, brings + up the pipelock sidecar, runs the agent container with + `--runtime=runsc` iff available, copies skills / SSH keys / + prompt / `.git` into the running container, and tears everything + down on context exit. + - `bottle.py` — `DockerBottle`, the running-instance handle yielded + by `launch`. + - `bottle_plan.py` — `DockerBottlePlan`, the prepared-but-not-launched + output of `prepare`. Carries resolved container/network/image + names, scratch paths, and `use_runsc`. Implements `print` for the + y/N preflight. + - `bottle_cleanup_plan.py` — `DockerBottleCleanupPlan`, the analog + for orphan cleanup. + - `network.py` — Docker network helpers (create/destroy, naming). + - `pipelock.py` — `DockerPipelockProxy` (the sidecar start/stop + lifecycle) and Docker-specific naming helpers. The backend-neutral + yaml + allowlist resolution stays in the top-level + `claude_bottle/pipelock.py`. + - `util.py` — Docker-specific helpers (slugify, image/container + existence checks, `runsc_available`). + +### Existing code touched + +- **`claude_bottle/cli/start.py`** — replace the inline docker + orchestration with `backend = get_bottle_backend(); plan = + backend.prepare(spec, stage_dir=...); with backend.launch(plan) as + bottle: bottle.exec_claude(...)`. The y/N preflight is rendered by + `plan.print(...)`. +- **`claude_bottle/manifest.py`** — drop the `runtime` field from the + Bottle dataclass and its validation. Existing manifests with + `runtime: "runsc"` produce a clear "no longer supported; gVisor is + now auto-detected by the backend; remove the 'runtime' field" error. +- **`claude_bottle/docker.py`** — module deleted. `require_runsc()`, + `slugify()`, `image_exists()`, `container_exists()`, the + `build_image` / `build_image_with_cwd` helpers, and `require_docker` + all migrate into `claude_bottle/backend/docker/util.py` (or + `backend.py`). +- **`claude_bottle/pipelock.py`** — keeps the allowlist resolution and + YAML generation. Becomes a thin abstract class (`PipelockProxy`) + exposing `prepare` (writes the yaml) plus abstract `start` / `stop` + methods. The Docker-specific subclass `DockerPipelockProxy` lives + under `backend/docker/pipelock.py`. +- **`claude_bottle/network.py`** — folds entirely into + `backend/docker/network.py`. No top-level network module remains. +- **`claude_bottle/ssh.py`** and **`claude_bottle/skills.py`** — + absorbed into `DockerBottleBackend` as `provision_ssh` and + `provision_skills`. The host-side file-tree generation stays as + private helpers on the backend class. +- **`claude_bottle/env.py`** (renamed from `env_resolve.py`) — + `resolve_env(manifest, agent) -> ResolvedEnv` returns + `forwarded: list[str]` (names whose values were exported into + `os.environ` for inheritance) and `literals: dict[str, str]` (name + → verbatim value). The Docker backend translates the result into + `--env-file` content + `-e NAME` argv fragments. +- **`claude_bottle/util.py`** — top-level cross-backend helpers + (`expand_tilde`, `is_ipv4_literal`). Backend-specific helpers live + in their backend's `util.py`. +- **`claude-bottle.example.json`** — remove the `runtime` field from + any example bottle. +- **`README.md`** — note `CLAUDE_BOTTLE_BACKEND` and the runsc + auto-detect; remove any mention of `runtime: "runsc"` as a manifest + field. + +### Data model changes + +The bottle schema loses one field: + +```diff + { + "bottles": { + "default": { +- "runtime": "runsc", + "env": { "...": "..." }, + "ssh": [], + "egress": { "allowlist": [...] } + } + } + } +``` + +Any manifest carrying `runtime` produces a validation error on load +(`"bottle '' has a 'runtime' field, which is no longer +supported. gVisor (runsc) is now auto-detected by the backend; +remove the 'runtime' field from the bottle definition."`). + +The agent schema is unchanged. + +### External dependencies + +None new. This PRD reorganizes existing code; it does not pull in any +new images, binaries, or libraries. + +### Behavior the runsc auto-detect introduces + +`DockerBottleBackend.prepare` runs `docker info --format +'{{json .Runtimes}}'` exactly once per call. If `runsc` is in the +output, `use_runsc` is set on the `DockerBottlePlan` and the +subsequent `docker run` adds `--runtime=runsc`. Otherwise it runs +without that flag. The choice is logged via the existing `info()` +helper as part of the preflight: + +``` +docker runtime: runsc (gVisor) # or: runc (default) +``` + +The y/N preflight (rendered by `DockerBottlePlan.print`) shows the +same line, so users can confirm what they're about to run under +before approving. + +## References + +- `docs/research/apple-container-backend.md` — original motivation; + prior draft considered a low-level `Backend` protocol and rejected + it as the wrong layer. +- `docs/research/bash-vs-python-vs-go.md` §Recommendation — argues + that the backend abstraction matters independent of language choice. +- PRD 0001 (`docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`) + — defines the pipelock topology that becomes a private + implementation detail of the Docker backend after this PRD ships. diff --git a/docs/prds/0003-bottle-factory-abstraction.md b/docs/prds/0003-bottle-factory-abstraction.md deleted file mode 100644 index 35b41e0..0000000 --- a/docs/prds/0003-bottle-factory-abstraction.md +++ /dev/null @@ -1,279 +0,0 @@ -# PRD 0003: Bottle factory abstraction - -- **Status:** Draft -- **Author:** didericis -- **Created:** 2026-05-10 - -## Summary - -Introduce a per-backend factory function that owns the end-to-end -lifecycle of a "bottle" (a running, isolated environment with claude -inside). The first and only implementation lands as -`create_docker_bottle`. No second backend ships in this PRD. - -## Problem - -Today, "how to launch a bottle" is spread across roughly six modules -(`claude_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, -`skills.py`, `docker.py`), each shelling out to `docker` directly via -`subprocess.run(["docker", ...])`. That coupling means: - -- Adding a second backend (Apple's `container`, fly.io, a remote SSH - host, etc.) requires editing every one of those call sites. The - research note `docs/research/apple-container-backend.md` already - flags this as a prerequisite for that work. -- The pipelock sidecar topology — two networks, multi-attach, sidecar - lifecycle — is a Docker implementation detail that has leaked into - the top-level CLI orchestration. It reads as a core concept of the - project, but a fly.io bottle would not need any of it. -- The manifest carries a Docker-specific `runtime: "runsc"` field - (`bottles[].runtime`). Anyone setting it has to know about gVisor, - whether Docker has it registered, and what to do on macOS where it - isn't available natively. The field has one valid non-default value - and exists only because the current code can't decide on its own. - -The shape that fits the project's actual goals (isolated agent runs -across multiple backends) is "one factory per backend," not "one -container-runtime SDK with N drivers." A previous draft of this PRD -considered a low-level runtime-primitive protocol (`run`, `exec`, -`cp`, `network_connect`, ...) and rejected it as the wrong layer — -it would have forced fly.io to pretend it's Docker. - -## Goals / Success Criteria - -The feature works when all of the following are observable: - -- `cli.py start` works identically for an existing manifest with no - user-visible changes other than (a) a startup log line naming the - Docker runtime in use, and (b) `bottles[].runtime` no longer being a - valid manifest field. -- On a Linux host with gVisor registered, the agent container runs - under `runsc` without anything in the manifest requesting it. -- On a host without gVisor (including macOS), the agent container runs - under the default `runc` runtime; nothing fails, no warning is - printed beyond the runtime-name log line. -- The existing test suite passes with no behavior changes other than - the manifest-schema removal of `runtime`. - -The feature is **done** when all of the following ship: - -- A new `claude_bottle/backend/` package exists with - `__init__.py` (factory selection) and `docker.py` - (`create_docker_bottle`). -- `create_docker_bottle` returns a context manager yielding a `Bottle` - handle exposing `exec_claude(argv, *, tty=True)`, `cp_in(host, ctr)`, - and teardown on context exit. -- Every existing `subprocess.run(["docker", ...])` call in - `cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and - `skills.py` either moves into `backend/docker.py` or is called from - it. No top-level CLI code references `docker` directly. -- `bottles[].runtime` is removed from the manifest schema, the - dataclass in `manifest.py`, the example manifest, and any README / - docs references. `require_runsc()` in `claude_bottle/docker.py` is - deleted. -- A single env var, `CLAUDE_BOTTLE_BACKEND` (default `"docker"`), - selects the factory. Unknown values die at startup with a list of - known backends. -- The y/N preflight in `cli.py` includes the resolved Docker runtime - alongside the allowlist summary. - -## Non-goals - -- No second backend implementation. `create_container_bottle` and - `create_flyio_bottle` are not in this PRD. The factory dict in - `backend/__init__.py` ships with one entry. -- No retries, async, or streaming exec. The current code is - synchronous `subprocess.run`; the `Bottle` handle matches. -- No behavior change beyond the runsc auto-detect. Pipelock topology, - network naming, container naming, image build flow, and SSH - provisioning all stay byte-identical. -- No `--require-runsc` CLI escape hatch. If a user later wants "fail - rather than silently downgrade," that's a follow-up. -- No `bottles[].backend` manifest field. Backend is a property of - the host environment, not the bottle definition (at least for now). - -## Scope - -### In scope - -- New `claude_bottle/backend/` package containing `__init__.py` and - `docker.py`. -- The `Bottle` Protocol definition and `create_docker_bottle` factory. -- Moving Docker-specific subprocess calls into the factory. -- Removing `bottles[].runtime` from the manifest schema and the - dataclass. -- Auto-detection of `runsc` registration via `docker info`. -- Preflight integration: the existing y/N output names the resolved - Docker runtime. -- Test updates: any manifest fixtures referencing `runtime` are - updated; tests that assert on `--runtime=runsc` instead seed the - detection by mocking `docker info`. - -### Out of scope - -- Apple `container` and fly.io factories (separate PRDs, deferred - until the Docker factory is the only thing shipping). -- Generalizing the pipelock sidecar to other backends. Pipelock - topology is, after this PRD, an implementation detail private to - `backend/docker.py`. -- Rewriting `pipelock.py`'s YAML generation. The allowlist→YAML - translation stays where it is and is called by the Docker factory. -- Changes to `env_resolve.py`, `manifest.py` (beyond the `runtime` - removal), or the agent schema. -- CLI flags for runtime selection / override. - -## Proposed Design - -### New services / components - -A new package, `claude_bottle/backend/`: - -- **`claude_bottle/backend/__init__.py`** — Defines the `Bottle` - Protocol and `get_bottle_factory()`. The factory registry is a - module-level dict mapping backend name → factory function. - Selection reads `CLAUDE_BOTTLE_BACKEND` (default `"docker"`). - Unknown values call `die()` with the list of known backends. - - ```python - class Bottle(Protocol): - name: str - def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ... - def cp_in(self, host_path: str, ctr_path: str) -> None: ... - def close(self) -> None: ... - - def get_bottle_factory() -> Callable[..., AbstractContextManager[Bottle]]: - ... - ``` - -- **`claude_bottle/backend/docker.py`** — `create_docker_bottle(...)`, - the only factory implementation in this PRD. Owns: - - probing for `runsc` availability (`docker info --format - '{{json .Runtimes}}'`), - - building the base image and the per-cwd derived image, - - creating the per-agent internal and egress networks, - - launching the pipelock sidecar (calls `pipelock.py` for YAML - generation, but the sidecar's `docker create / cp / network - connect / start` sequence moves into this module), - - running the agent container with `--runtime=runsc` iff available, - - copying skills / SSH keys / prompt / `.git` into the running - container, - - tearing everything down (container, sidecar, two networks) on - context exit. - -### Existing code touched - -- **`claude_bottle/cli/start.py`** — replace the inline docker - orchestration with `with get_bottle_factory()(manifest, ...) as - bottle:` and call `bottle.exec_claude(...)`. The preflight stays - here but is extended to render the resolved Docker runtime alongside - the allowlist summary. -- **`claude_bottle/manifest.py`** — drop the `runtime` field from the - Bottle dataclass and its validation. Existing manifests with - `runtime: "runsc"` should produce a clear "unknown field" error so - users know to remove it. -- **`claude_bottle/docker.py`** — `require_runsc()` deleted. - `require_docker()`, `slugify()`, `image_exists()`, - `container_exists()`, and the `build_image` / `build_image_with_cwd` - helpers stay; they're host-side utilities that the Docker factory - consumes. -- **`claude_bottle/pipelock.py`** — keep all the allowlist resolution - and YAML generation. Remove `pipelock_start` / `pipelock_stop` (or - inline them into `backend/docker.py` — decide during - implementation). Pipelock-the-sidecar becomes a Docker-factory - internal concept. -- **`claude_bottle/network.py`** — same call-sites moved into - `backend/docker.py`. The module either becomes a thin set of pure - name-derivation helpers (`network_name_for_slug`, etc.) or folds - entirely into `backend/docker.py`. Decide during implementation. -- **`claude_bottle/ssh.py`** and **`claude_bottle/skills.py`** — the - `docker cp` and `docker exec` calls move into / are called from - `backend/docker.py`. The host-side file-tree generation stays put. -- **`claude-bottle.example.json`** — remove the `runtime` field from - any example bottle. -- **`README.md`** — note `CLAUDE_BOTTLE_BACKEND` and the runsc - auto-detect; remove any mention of `runtime: "runsc"` as a manifest - field. - -### Data model changes - -The bottle schema loses one field: - -```diff - { - "bottles": { - "default": { -- "runtime": "runsc", - "env": { "...": "..." }, - "ssh": [], - "egress": { "allowlist": [...] } - } - } - } -``` - -Any manifest carrying `runtime` produces a validation error on load -("unknown bottle field 'runtime' — gVisor is now auto-detected; -remove this field"). - -The agent schema is unchanged. - -### External dependencies - -None new. This PRD reorganizes existing code; it does not pull in any -new images, binaries, or libraries. - -### Behavior the runsc auto-detect introduces - -The Docker factory runs `docker info --format '{{json .Runtimes}}'` -exactly once per `create_docker_bottle` call. If `runsc` is in the -output, the subsequent `docker run` adds `--runtime=runsc`. Otherwise -it runs without that flag. The choice is logged via the existing -`info()` helper as part of the preflight: - -``` -docker runtime: runsc (gVisor) # or: runc (default) -``` - -The y/N preflight shows the same line, so users can confirm what -they're about to run under before approving. - -## Open questions - -- **Where the pipelock sidecar lifecycle lives.** Two reasonable - splits: (a) `pipelock.py` keeps `pipelock_start` / `pipelock_stop` - and `backend/docker.py` calls them; (b) the sidecar - `docker create/cp/network connect/start` sequence moves entirely - into `backend/docker.py` and `pipelock.py` shrinks to the YAML + - allowlist helpers. (a) keeps git blame intact and is the smaller - diff; (b) makes pipelock-as-an-implementation-detail more obvious. - Decide during implementation. - -- **Whether `bottles/__init__.py` re-exports `create_docker_bottle`.** - Importing `from claude_bottle.bottles import create_docker_bottle` - vs. `from claude_bottle.bottles.docker import create_docker_bottle`. - Doesn't matter for v1 (only the registry consumes it), but worth - picking a convention before a second factory lands. - -- **Manifest-error wording when `runtime` is seen.** "Unknown field" - is technically correct but unhelpful. A targeted error message - ("runtime: was removed; gVisor is now auto-detected when the Docker - daemon has it registered") is more useful and worth the extra few - lines. - -- **Test fixtures.** Some tests mock `docker info` or seed - `--runtime=runsc` expectations. Audit and update as part of the - implementation; not expected to be a large change. - -- **Future `--require-runsc` flag.** Not in this PRD; flagged here so - it's findable when the question comes up. - -## References - -- `docs/research/apple-container-backend.md` — original motivation; - prior draft considered a low-level `Backend` protocol and rejected - it as the wrong layer. -- `docs/research/bash-vs-python-vs-go.md` §Recommendation — argues - that the factory abstraction matters independent of language choice. -- PRD 0001 (`docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`) - — defines the pipelock topology that becomes a private - implementation detail of the Docker factory after this PRD ships.