Merge pull request 'PRD 0003: Bottle Backend abstraction' (#5) from add-bottle-factory-abstraction into main
test / run tests/run_tests.py (push) Successful in 17s
test / run tests/run_tests.py (push) Successful in 17s
This commit was merged in pull request #5.
This commit is contained in:
@@ -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}",
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
},
|
||||
|
||||
"gitea-dev": {
|
||||
"runtime": "runsc",
|
||||
"env": {
|
||||
"GITEA_TOKEN": "?paste your Gitea API token",
|
||||
"GITHUB_TOKEN": "${GH_PAT}",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Per-backend bottle factories.
|
||||
|
||||
A bottle is a running, isolated environment with claude inside. Each
|
||||
backend exposes five methods:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..log import die
|
||||
from ..manifest import Manifest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BottleSpec:
|
||||
"""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."""
|
||||
|
||||
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 backend's prepare step. Concrete subclasses
|
||||
(e.g. DockerBottlePlan) add backend-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."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BottleCleanupPlan(ABC):
|
||||
"""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
|
||||
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(ABC):
|
||||
"""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`
|
||||
is an idempotent alias for context-manager teardown.
|
||||
"""
|
||||
|
||||
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: ...
|
||||
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
@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."""
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
"""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."""
|
||||
|
||||
@abstractmethod
|
||||
def list_active(self) -> None:
|
||||
"""Print every currently-running bottle on this backend to
|
||||
stderr (name + status)."""
|
||||
|
||||
|
||||
# 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 DockerBottleBackend # noqa: E402
|
||||
|
||||
|
||||
_BACKENDS: dict[str, BottleBackend] = {
|
||||
"docker": DockerBottleBackend(),
|
||||
}
|
||||
|
||||
|
||||
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_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",
|
||||
"BottleSpec",
|
||||
"get_bottle_backend",
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
"""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
|
||||
- backend: DockerBottleBackend
|
||||
|
||||
This file only re-exports the public names so
|
||||
`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
|
||||
|
||||
__all__ = [
|
||||
"DockerBottle",
|
||||
"DockerBottleBackend",
|
||||
"DockerBottleCleanupPlan",
|
||||
"DockerBottlePlan",
|
||||
]
|
||||
@@ -0,0 +1,674 @@
|
||||
"""DockerBottleBackend — the Docker implementation of BottleBackend.
|
||||
|
||||
Methods:
|
||||
.prepare(spec, stage_dir=...) -> DockerBottlePlan
|
||||
.launch(plan) -> ContextManager[DockerBottle]
|
||||
.prepare_cleanup() -> DockerBottleCleanupPlan
|
||||
.cleanup(plan) -> None
|
||||
.list_active() -> None
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Sequence
|
||||
|
||||
from ... import pipelock
|
||||
from ...env import ResolvedEnv, resolve_env
|
||||
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
|
||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .pipelock import (
|
||||
DockerPipelockProxy,
|
||||
pipelock_proxy_host_port,
|
||||
pipelock_proxy_url,
|
||||
)
|
||||
|
||||
|
||||
# Where the repo root lives, for `docker build` context. Computed once.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
class DockerBottleBackend(BottleBackend):
|
||||
"""Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND
|
||||
(default)."""
|
||||
|
||||
name = "docker"
|
||||
_proxy: DockerPipelockProxy = DockerPipelockProxy()
|
||||
|
||||
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)
|
||||
|
||||
slug = docker_mod.slugify(spec.agent_name)
|
||||
|
||||
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest")
|
||||
derived_image = ""
|
||||
runtime_image = image
|
||||
if spec.copy_cwd:
|
||||
derived_image = os.environ.get(
|
||||
"CLAUDE_BOTTLE_DERIVED_IMAGE", f"claude-bottle:cwd-{slug}"
|
||||
)
|
||||
runtime_image = derived_image
|
||||
|
||||
default_container = f"claude-bottle-{slug}"
|
||||
pinned_container = os.environ.get("CLAUDE_BOTTLE_CONTAINER", "")
|
||||
container_name = pinned_container or default_container
|
||||
container_name_pinned = bool(pinned_container)
|
||||
suffix = 2
|
||||
if container_name_pinned:
|
||||
if docker_mod.container_exists(container_name):
|
||||
die(
|
||||
f"container '{container_name}' already exists "
|
||||
f"(pinned via CLAUDE_BOTTLE_CONTAINER). "
|
||||
f"Remove it with 'docker rm -f {container_name}' or unset the override."
|
||||
)
|
||||
else:
|
||||
while docker_mod.container_exists(container_name):
|
||||
container_name = f"{default_container}-{suffix}"
|
||||
suffix += 1
|
||||
if suffix > 100:
|
||||
die(
|
||||
f"could not find a free container name after "
|
||||
f"{default_container}-99; clean up old containers with "
|
||||
f"'docker rm -f <name>'"
|
||||
)
|
||||
|
||||
if agent.skills:
|
||||
self.validate_skills(list(agent.skills))
|
||||
if bottle.ssh:
|
||||
self.validate_ssh_entries(bottle.ssh)
|
||||
|
||||
env_file = stage_dir / "agent.env"
|
||||
args_file = stage_dir / "docker-args"
|
||||
prompt_file = stage_dir / "prompt.txt"
|
||||
prompt_file.write_text("")
|
||||
prompt_file.chmod(0o600)
|
||||
|
||||
proxy_plan = self.prepare_proxy(spec, stage_dir)
|
||||
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)
|
||||
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,
|
||||
proxy_plan=proxy_plan,
|
||||
allowlist_summary=allowlist_summary,
|
||||
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
|
||||
PipelockProxyPlan for the launch step to consume. Stage-only:
|
||||
no Docker resources created yet."""
|
||||
yaml_path = stage_dir / "pipelock.yaml"
|
||||
bottle = spec.manifest.bottle_for(spec.agent_name)
|
||||
slug = docker_mod.slugify(spec.agent_name)
|
||||
return self._proxy.prepare(bottle, slug, yaml_path)
|
||||
|
||||
@contextmanager
|
||||
def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]:
|
||||
"""Build, launch, and provision a Docker bottle. Teardown on exit."""
|
||||
assert isinstance(plan, DockerBottlePlan), (
|
||||
f"DockerBottleBackend.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"]:
|
||||
self._proxy.stop(state["pipelock"])
|
||||
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)
|
||||
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
|
||||
|
||||
prompt_path = self.provision(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_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_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"
|
||||
|
||||
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]
|
||||
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 = 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 provision_skills(self, plan: BottlePlan, target: str) -> None:
|
||||
"""Copy each of the agent's named skills from the host's
|
||||
~/.claude/skills/<name>/ into the container's equivalent path.
|
||||
For each skill: ensure parent dir, wipe any prior copy, then
|
||||
`docker cp <host>/. <container>:<dst>/` 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
|
||||
|
||||
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 = 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 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 = 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:
|
||||
"""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/<pid>/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_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"
|
||||
public_socket = "/run/claude-bottle-agent-public.sock"
|
||||
keys_dir = "/root/.claude-bottle-keys"
|
||||
|
||||
# ~/.ssh for node (700, owned by node).
|
||||
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).
|
||||
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"
|
||||
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 = 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_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)
|
||||
|
||||
# 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 >/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_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")
|
||||
subprocess.run(
|
||||
["docker", "cp", str(known_hosts_file), f"{container}:{container_ssh}/known_hosts"],
|
||||
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
|
||||
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"],
|
||||
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:
|
||||
"""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"DockerBottleBackend.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()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""DockerBottle — concrete Bottle handle yielded by
|
||||
DockerBottleBackend.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
|
||||
|
||||
from .. import Bottle
|
||||
|
||||
|
||||
class DockerBottle(Bottle):
|
||||
"""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()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""DockerBottleCleanupPlan — concrete subclass of BottleCleanupPlan.
|
||||
|
||||
Holds the tuples of container and network names that
|
||||
DockerBottleBackend.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 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."""
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""DockerBottlePlan — concrete subclass of BottlePlan.
|
||||
|
||||
Carries the Docker-specific resolved fields produced by
|
||||
DockerBottleBackend.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 ...pipelock import PipelockProxyPlan
|
||||
from .. import BottlePlan
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DockerBottlePlan(BottlePlan):
|
||||
"""Docker-specific resolved fields produced by
|
||||
DockerBottleBackend.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
|
||||
proxy_plan: PipelockProxyPlan
|
||||
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)
|
||||
@@ -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:
|
||||
@@ -0,0 +1,114 @@
|
||||
"""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 os
|
||||
import subprocess
|
||||
|
||||
from ...log import die, info, warn
|
||||
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}"
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
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:<port>`.
|
||||
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}'"
|
||||
)
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Docker helpers. Build/inspect primitives shared by the CLI."""
|
||||
"""Docker host-side primitives used by DockerBottleBackend: probing
|
||||
for docker on PATH, slugifying agent names, checking image/container
|
||||
existence, and building images."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,7 +9,18 @@ import shutil
|
||||
import subprocess
|
||||
from typing import Iterable
|
||||
|
||||
from .log import die, info
|
||||
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:
|
||||
@@ -19,22 +32,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
|
||||
|
||||
@@ -51,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]+")
|
||||
|
||||
|
||||
@@ -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/<name>`. Dies if HOME is unset."""
|
||||
home = os.environ.get("HOME")
|
||||
if not home:
|
||||
die("HOME not set")
|
||||
return f"{home}/.claude/skills/{name}"
|
||||
@@ -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} <command> [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")
|
||||
|
||||
@@ -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
|
||||
@@ -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 ..backend import get_bottle_backend
|
||||
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")
|
||||
backend = get_bottle_backend()
|
||||
plan = backend.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,
|
||||
)
|
||||
|
||||
backend.cleanup(plan)
|
||||
info("done")
|
||||
return 0
|
||||
|
||||
@@ -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 ..backend import get_bottle_backend
|
||||
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_backend().list_active()
|
||||
return 0
|
||||
|
||||
+24
-278
@@ -7,20 +7,14 @@ 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 ..env_resolve import env_resolve
|
||||
from ..log import die, info
|
||||
from ..backend import BottleSpec, get_bottle_backend
|
||||
from ..log import 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:
|
||||
@@ -33,145 +27,23 @@ 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 <name>'"
|
||||
)
|
||||
|
||||
# --- 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))
|
||||
|
||||
runtime = bottle.runtime
|
||||
if runtime == "runsc":
|
||||
docker_mod.require_runsc()
|
||||
|
||||
ssh_entries = bottle.ssh
|
||||
if ssh_entries:
|
||||
ssh_mod.ssh_validate_entries(ssh_entries)
|
||||
spec = BottleSpec(
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
env_resolve(manifest, name, env_file, args_file)
|
||||
|
||||
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)")
|
||||
)
|
||||
info("skills : " + (" ".join(agent.skills) if agent.skills else "(none)"))
|
||||
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}")
|
||||
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)
|
||||
backend = get_bottle_backend()
|
||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||
plan.print(remote_control=args.remote_control)
|
||||
|
||||
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 +51,18 @@ 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,
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
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,
|
||||
with backend.launch(plan) as bottle:
|
||||
info(
|
||||
"attaching interactive claude session "
|
||||
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
||||
)
|
||||
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
|
||||
claude_args = ["--dangerously-skip-permissions"]
|
||||
if args.remote_control:
|
||||
claude_args.append("--remote-control")
|
||||
bottle.exec_claude(claude_args, tty=True)
|
||||
info(f"session ended; container {bottle.name} will be removed")
|
||||
return 0
|
||||
finally:
|
||||
cleanup_all()
|
||||
shutil.rmtree(stage_dir, ignore_errors=True)
|
||||
|
||||
@@ -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;
|
||||
"?<message>" uses <message> 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 env_resolve(
|
||||
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 env_resolve(
|
||||
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 env_resolve(
|
||||
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)
|
||||
+11
-23
@@ -7,8 +7,7 @@ Schema (see CLAUDE.md "Intended design"):
|
||||
"<bottle-name>": {
|
||||
"env": { "<NAME>": <env-entry>, ... },
|
||||
"ssh": [ <ssh-entry>, ... ],
|
||||
"egress": { "allowlist": [ "<hostname>", ... ] },
|
||||
"runtime": "runc" | "runsc"
|
||||
"egress": { "allowlist": [ "<hostname>", ... ] }
|
||||
}
|
||||
},
|
||||
"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 by the "
|
||||
f"backend; remove the 'runtime' field from the bottle "
|
||||
f"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)
|
||||
|
||||
+104
-170
@@ -12,23 +12,12 @@ Image pin: ghcr.io/luckypipewrench/pipelock@sha256:<digest> for tag 2.3.0.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .log import die, info, warn
|
||||
from .manifest import Manifest
|
||||
|
||||
# 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")
|
||||
from .manifest import Bottle
|
||||
from .util import is_ipv4_literal
|
||||
|
||||
# Baked-in default allowlist for hosts Claude Code itself needs.
|
||||
DEFAULT_ALLOWLIST: tuple[str, ...] = (
|
||||
@@ -42,69 +31,45 @@ 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 --------------------------------------------------
|
||||
|
||||
|
||||
def pipelock_bottle_allowlist(manifest: Manifest, bottle_name: str) -> list[str]:
|
||||
"""Hostnames in bottles[<bottle_name>].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]
|
||||
|
||||
|
||||
_IPV4_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$")
|
||||
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 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_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_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_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_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:
|
||||
"<N> 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)"
|
||||
@@ -119,129 +84,98 @@ def pipelock_allowlist_summary(manifest: Manifest, bottle_name: str) -> str:
|
||||
return f"{count} hosts allowed ({joined})"
|
||||
|
||||
|
||||
# --- YAML generation -------------------------------------------------------
|
||||
|
||||
# --- Proxy class -----------------------------------------------------------
|
||||
|
||||
|
||||
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.
|
||||
@dataclass(frozen=True)
|
||||
class PipelockProxyPlan:
|
||||
"""Output of PipelockProxy.prepare; consumed by .start when the
|
||||
sidecar needs to be brought up.
|
||||
|
||||
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)
|
||||
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."""
|
||||
|
||||
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}"')
|
||||
yaml_path: Path
|
||||
slug: str
|
||||
internal_network: str = ""
|
||||
egress_network: str = ""
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
def prepare(
|
||||
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-<slug>`), 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
|
||||
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(bottle)
|
||||
trusted = pipelock_bottle_ssh_trusted_domains(bottle)
|
||||
ip_cidrs = pipelock_bottle_ssh_ip_cidrs(bottle)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("version: 1")
|
||||
lines.append("mode: strict")
|
||||
lines.append("enforce: true")
|
||||
lines.append("")
|
||||
if ip_cidrs:
|
||||
lines.append("ssrf:")
|
||||
lines.append(" ip_allowlist:")
|
||||
for cidr in ip_cidrs:
|
||||
lines.append(f' - "{cidr}"')
|
||||
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("dlp:")
|
||||
lines.append(" include_defaults: true")
|
||||
lines.append(" scan_env: true")
|
||||
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)
|
||||
yaml_path.write_text("\n".join(lines) + "\n")
|
||||
yaml_path.chmod(0o600)
|
||||
|
||||
@abstractmethod
|
||||
def start(self, plan: PipelockProxyPlan) -> str:
|
||||
"""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."""
|
||||
|
||||
# --- Sidecar lifecycle -----------------------------------------------------
|
||||
|
||||
|
||||
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:<port>`.
|
||||
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}; pipelock_write_yaml must run first")
|
||||
|
||||
info(f"starting pipelock sidecar {name} on network {internal_network}")
|
||||
|
||||
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],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).returncode != 0:
|
||||
warn(f"failed to remove pipelock sidecar {name}; clean up with 'docker rm -f {name}'")
|
||||
@abstractmethod
|
||||
def stop(self, proxy_target: str) -> None:
|
||||
"""Tear down the pipelock sidecar identified by `proxy_target`
|
||||
(the value `.start` returned). Idempotent: a missing target is
|
||||
success. Backend-specific."""
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Skill copier: host's ~/.claude/skills/<name>/ -> container's
|
||||
~/.claude/skills/<name>/, preserving directory structure."""
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 <host>/. <container>:<dst>/` 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,
|
||||
)
|
||||
@@ -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/<pid>/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 >/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
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Cross-cutting utility helpers used by multiple modules.
|
||||
|
||||
Top-level (i.e. backend-agnostic) — backend-specific helpers live one
|
||||
level deeper, under their backend package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
_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))
|
||||
@@ -1,19 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""cli.py — manage claude-bottle containers.
|
||||
|
||||
usage: cli.py <command> [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
|
||||
|
||||
|
||||
@@ -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 '<name>' 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.
|
||||
@@ -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__":
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
"""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
|
||||
import unittest
|
||||
|
||||
from claude_bottle.network import (
|
||||
from claude_bottle.backend.docker.network import (
|
||||
network_create_egress,
|
||||
network_create_internal,
|
||||
network_remove,
|
||||
)
|
||||
from claude_bottle.pipelock import pipelock_stop
|
||||
from claude_bottle.backend.docker.pipelock import (
|
||||
DockerPipelockProxy,
|
||||
pipelock_container_name,
|
||||
)
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
@@ -68,7 +72,7 @@ class TestOrphanCleanup(unittest.TestCase):
|
||||
|
||||
def test_pipelock_stop_missing_sidecar(self):
|
||||
# Should not raise.
|
||||
pipelock_stop(f"missing-{self.slug}")
|
||||
DockerPipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
"""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
|
||||
|
||||
from claude_bottle.pipelock import PIPELOCK_IMAGE
|
||||
from claude_bottle.backend.docker.pipelock import PIPELOCK_IMAGE
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
@@ -12,7 +12,10 @@ import unittest
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from claude_bottle.pipelock import PIPELOCK_IMAGE, pipelock_write_yaml
|
||||
from claude_bottle.backend.docker.pipelock import (
|
||||
PIPELOCK_IMAGE,
|
||||
DockerPipelockProxy,
|
||||
)
|
||||
from tests._docker import skip_unless_docker
|
||||
from tests.fixtures import fixture_minimal
|
||||
|
||||
@@ -38,7 +41,7 @@ class TestPipelockSidecarSmoke(unittest.TestCase):
|
||||
)
|
||||
def test_smoke(self):
|
||||
yaml_path = self.work_dir / "pipelock.yaml"
|
||||
pipelock_write_yaml(fixture_minimal(), "dev", yaml_path)
|
||||
DockerPipelockProxy().prepare(fixture_minimal().bottles["dev"], "demo", yaml_path)
|
||||
|
||||
create = subprocess.run(
|
||||
[
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
"""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: 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.docker.pipelock import DockerPipelockProxy
|
||||
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):
|
||||
class TestPipelockProxyPrepare(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.out_dir = Path(tempfile.mkdtemp())
|
||||
self.proxy = DockerPipelockProxy()
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
@@ -22,7 +23,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.proxy.prepare(fixture_minimal().bottles["dev"], "demo", yaml_path)
|
||||
content = yaml_path.read_text()
|
||||
self.assertIn("mode: strict", content)
|
||||
self.assertIn("enforce: true", content)
|
||||
@@ -40,7 +41,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.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)
|
||||
@@ -64,7 +65,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.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)
|
||||
@@ -72,7 +73,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.proxy.prepare(fixture_minimal().bottles["dev"], "demo", yaml_path)
|
||||
mode = os.stat(yaml_path).st_mode & 0o777
|
||||
self.assertEqual(0o600, mode)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user