From b2894748b977965a8a2daa291c4bef6efe6032d2 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 10 Jun 2026 05:37:53 +0000 Subject: [PATCH] feat: add install script packaging --- README.md | 21 +++++ bot_bottle/Dockerfile.sidecars | 96 +++++++++++++++++++++ bot_bottle/backend/docker/compose.py | 11 ++- bot_bottle/backend/docker/sidecar_bundle.py | 7 +- bot_bottle/cli/__init__.py | 5 +- bot_bottle/cli/_common.py | 2 +- bot_bottle/cli/doctor.py | 73 ++++++++++++++++ install.sh | 50 +++++++++++ pyproject.toml | 27 ++++++ tests/unit/test_cli_doctor.py | 51 +++++++++++ tests/unit/test_compose.py | 13 +++ tests/unit/test_install_script.py | 34 ++++++++ tests/unit/test_pyproject.py | 27 ++++++ 13 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 bot_bottle/Dockerfile.sidecars create mode 100644 bot_bottle/cli/doctor.py create mode 100755 install.sh create mode 100644 pyproject.toml create mode 100644 tests/unit/test_cli_doctor.py create mode 100644 tests/unit/test_install_script.py create mode 100644 tests/unit/test_pyproject.py diff --git a/README.md b/README.md index 559ac42..7473334 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,27 @@ The Docker topology looks like this: When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs. +## Install + +Install the CLI with the bootstrap script: + +```sh +curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh +``` + +The script checks Python 3.11+, checks Docker daemon reachability, creates the `~/.bot-bottle/` config directories, installs the Python package with `pipx` when available or `pip --user` otherwise, then runs: + +```sh +bot-bottle doctor +``` + +Python-native installers can use the package metadata directly: + +```sh +pipx install git+https://gitea.dideric.is/didericis/bot-bottle.git +uv tool install git+https://gitea.dideric.is/didericis/bot-bottle.git +``` + ## Quickstart On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. diff --git a/bot_bottle/Dockerfile.sidecars b/bot_bottle/Dockerfile.sidecars new file mode 100644 index 0000000..6960848 --- /dev/null +++ b/bot_bottle/Dockerfile.sidecars @@ -0,0 +1,96 @@ +# Per-bottle sidecar bundle image (PRD 0024). +# +# Collapses the prior per-sidecar images (egress, git-gate, +# supervise) into one. A small stdlib-Python init supervisor at +# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and +# propagates per-daemon stdout/stderr to the container log with a +# `[name]` prefix. See PRD 0024 for the rationale. +# +# Layout: +# +# /usr/bin/gitleaks gitleaks binary +# /app/egress_addon.py + siblings mitmproxy addon (egress) +# /app/egress-entrypoint.sh mitmdump launcher +# /app/supervise_server.py + .py supervise MCP server +# /app/sidecar_init.py PID 1 supervisor +# /etc/egress/routes.yaml bind-mounted at run time +# /etc/git-gate/pre-receive docker-cp'd at start time +# /git-gate-entrypoint.sh docker-cp'd at start time +# /git-gate/creds/* docker-cp'd at start time +# /git/* bare repos, populated at runtime +# /run/supervise/queue/ bind-mounted at run time +# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir +# +# Exposed ports inside the container: +# 9099 egress (mitmproxy, agent-facing HTTPS proxy) +# 9418 git-gate (git-daemon) +# 9420 git-gate smart HTTP (smolmachines agent-facing transport) +# 9100 supervise (MCP HTTP) + +# Stage 1: gitleaks binary. The upstream gitleaks image is alpine +# with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep +# with Dockerfile.git-gate's prior base (now deleted at chunk 3). +FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src + +# Stage 2: assembly. mitmproxy/mitmproxy is debian-slim-based with +# Python + mitmdump pre-installed — heavier than the others, so +# this stage starts there and pulls the standalone binaries in. +FROM mitmproxy/mitmproxy:11.1.3 + +# Run as root inside the bundle. The bundle is the isolation +# boundary; per-daemon user separation inside it is not load-bearing +# and complicates the supervisor's spawn path. +USER root + +# Runtime system deps: +# git supplies the `git daemon` subcommand (no separate package) +# plus the core `git` binary the pre-receive hook invokes. +# openssh-client supplies the upstream SSH transport the +# pre-receive hook uses to forward accepted refs. +# ca-certificates is needed for mitmdump upstream TLS (the +# base image already has it; listed for explicitness). +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git openssh-client ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Pull the standalone binaries into the final image. +COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks + +# Project Python: addon + server modules + the init supervisor. +# Kept flat under /app/ so mitmdump's loader resolves them as +# top-level siblings (absolute imports), matching the prior +# Dockerfile.egress / Dockerfile.supervise layout. +COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py +COPY bot_bottle/egress_addon.py /app/egress_addon.py +COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py +COPY bot_bottle/yaml_subset.py /app/yaml_subset.py +COPY bot_bottle/supervise.py /app/supervise.py +COPY bot_bottle/supervise_server.py /app/supervise_server.py +COPY bot_bottle/sidecar_init.py /app/sidecar_init.py +COPY bot_bottle/git_http_backend.py /app/git_http_backend.py +COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh +RUN chmod +x /app/egress-entrypoint.sh + +# Pre-create runtime directories the compose renderer + start +# step expect to exist. `docker cp` does not create intermediate +# dirs, and bind mounts won't either if the parent is missing. +RUN mkdir -p \ + /etc/egress \ + /etc/git-gate \ + /git-gate/creds \ + /git \ + /run/supervise/queue \ + /home/mitmproxy/.mitmproxy + +# Documentation only — the compose renderer publishes whichever +# subset the bottle uses. +EXPOSE 8888 9099 9418 9420 9100 + +# WORKDIR matches Dockerfile.supervise's prior layout so the +# in-app same-dir import in supervise_server.py stays deterministic. +WORKDIR /app + +# PID 1 is the supervisor. It owns signal handling and exit-code +# propagation; no `exec` chain in the entrypoint itself. +ENTRYPOINT ["python3", "/app/sidecar_init.py"] diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index 16a67d4..94e0017 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -58,10 +58,17 @@ from .sidecar_bundle import ( ) -# Repo root, used as the build context for the bundle Dockerfile. +# Repo root or installed site-packages root, used as the build context for +# Dockerfiles that COPY bot_bottle source files. _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) +def _sidecar_bundle_dockerfile() -> str: + if (Path(_REPO_DIR) / SIDECAR_BUNDLE_DOCKERFILE).is_file(): + return SIDECAR_BUNDLE_DOCKERFILE + return f"bot_bottle/{SIDECAR_BUNDLE_DOCKERFILE}" + + def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]: """Render a Compose v2 spec dict from a fully-resolved DockerBottlePlan. @@ -183,7 +190,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: "image": SIDECAR_BUNDLE_IMAGE, "build": { "context": _REPO_DIR, - "dockerfile": SIDECAR_BUNDLE_DOCKERFILE, + "dockerfile": _sidecar_bundle_dockerfile(), }, "container_name": sidecar_bundle_container_name(plan.slug), "networks": { diff --git a/bot_bottle/backend/docker/sidecar_bundle.py b/bot_bottle/backend/docker/sidecar_bundle.py index af3d39e..713a3b0 100644 --- a/bot_bottle/backend/docker/sidecar_bundle.py +++ b/bot_bottle/backend/docker/sidecar_bundle.py @@ -12,9 +12,10 @@ from __future__ import annotations import os -# Bundle image. Defaults to a built-locally tag (built from the -# repo's Dockerfile.sidecars via compose `build:`). Operators -# pinning to a published digest can override via env. +# Bundle image. Defaults to a built-locally tag. Source checkouts +# build from the repo-root Dockerfile.sidecars; installed packages +# build from the packaged copy under bot_bottle/. +# Operators pinning to a published digest can override via env. SIDECAR_BUNDLE_IMAGE = os.environ.get( "BOT_BOTTLE_SIDECAR_IMAGE", "bot-bottle-sidecars:latest", diff --git a/bot_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py index a0ca633..ab6d8a4 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -1,6 +1,6 @@ """Main CLI dispatcher. -Commands: cleanup, edit, info, init, list, resume, start, supervise +Commands: cleanup, doctor, edit, info, init, list, resume, start, supervise """ from __future__ import annotations @@ -12,6 +12,7 @@ from ..manifest import ManifestError from ._common import PROG from . import list as _list_mod from .cleanup import cmd_cleanup +from .doctor import cmd_doctor from .edit import cmd_edit from .info import cmd_info from .init import cmd_init @@ -23,6 +24,7 @@ cmd_list = _list_mod.cmd_list COMMANDS = { "cleanup": cmd_cleanup, + "doctor": cmd_doctor, "edit": cmd_edit, "info": cmd_info, "init": cmd_init, @@ -37,6 +39,7 @@ def usage() -> None: sys.stderr.write(f"usage: {PROG} [args...]\n\n") sys.stderr.write("Commands:\n") sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n") + sys.stderr.write(" doctor check Python, Docker, and bot-bottle config prerequisites\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") sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n") diff --git a/bot_bottle/cli/_common.py b/bot_bottle/cli/_common.py index 0008a1e..85365d6 100644 --- a/bot_bottle/cli/_common.py +++ b/bot_bottle/cli/_common.py @@ -6,7 +6,7 @@ import os import sys from pathlib import Path -PROG = "cli.py" +PROG = Path(sys.argv[0]).name or "bot-bottle" USER_CWD = os.getcwd() REPO_DIR = str(Path(__file__).resolve().parent.parent.parent) diff --git a/bot_bottle/cli/doctor.py b/bot_bottle/cli/doctor.py new file mode 100644 index 0000000..d1c255a --- /dev/null +++ b/bot_bottle/cli/doctor.py @@ -0,0 +1,73 @@ +"""doctor: validate host prerequisites for running bot-bottle.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +from ._common import PROG + + +def _ok(label: str, detail: str) -> None: + print(f"ok: {label}: {detail}") + + +def _fail(label: str, detail: str) -> None: + print(f"fail: {label}: {detail}") + + +def _check_python() -> bool: + version = sys.version_info + detail = f"{version.major}.{version.minor}.{version.micro}" + if version >= (3, 11): + _ok("python", detail) + return True + _fail("python", f"{detail}; need 3.11 or newer") + return False + + +def _check_docker() -> bool: + docker = shutil.which("docker") + if not docker: + _fail("docker", "docker command not found") + return False + try: + result = subprocess.run( + [docker, "info"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + _fail("docker", f"daemon check failed: {exc}") + return False + if result.returncode == 0: + _ok("docker", "daemon reachable") + return True + _fail("docker", "daemon not reachable") + return False + + +def _check_config_dir() -> bool: + config = Path.home() / ".bot-bottle" + if config.is_dir(): + _ok("config", str(config)) + return True + _fail("config", f"{config} does not exist") + return False + + +def cmd_doctor(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog=f"{PROG} doctor", add_help=True) + parser.parse_args(argv) + + checks = ( + _check_python(), + _check_docker(), + _check_config_dir(), + ) + return 0 if all(checks) else 1 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..caa6f95 --- /dev/null +++ b/install.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu + +PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}" +MIN_PYTHON="3.11" + +say() { + printf 'bot-bottle install: %s\n' "$*" >&2 +} + +die() { + say "error: $*" + exit 1 +} + +command -v python3 >/dev/null 2>&1 || die "python3 is required (version ${MIN_PYTHON} or newer)" + +python3 - <<'PY' || die "python3 3.11 or newer is required" +import sys + +raise SystemExit(0 if sys.version_info >= (3, 11) else 1) +PY + +command -v docker >/dev/null 2>&1 || die "Docker is required; install Docker and start the daemon, then re-run this script" +docker info >/dev/null 2>&1 || die "Docker is installed but the daemon is not reachable; start Docker and re-run this script" + +mkdir -p \ + "${HOME}/.bot-bottle/agents" \ + "${HOME}/.bot-bottle/bottles" \ + "${HOME}/.bot-bottle/contrib" + +if command -v pipx >/dev/null 2>&1; then + say "installing with pipx" + pipx install --force "${PACKAGE_SPEC}" +else + say "pipx not found; installing with python3 -m pip --user" + python3 -m pip install --user --upgrade "${PACKAGE_SPEC}" +fi + +if command -v bot-bottle >/dev/null 2>&1; then + BOT_BOTTLE_BIN="bot-bottle" +elif [ -x "${HOME}/.local/bin/bot-bottle" ]; then + BOT_BOTTLE_BIN="${HOME}/.local/bin/bot-bottle" + say "using ${BOT_BOTTLE_BIN}; add ${HOME}/.local/bin to PATH for future shells" +else + die "bot-bottle was installed but is not on PATH" +fi + +say "running bot-bottle doctor" +"${BOT_BOTTLE_BIN}" doctor diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8a22941 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "bot-bottle" +version = "0.1.0" +description = "Self-hosted sandbox for AI coding agents with egress controls" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +dependencies = [] + +[project.scripts] +bot-bottle = "bot_bottle.cli:main" + +[tool.setuptools.packages.find] +include = ["bot_bottle*"] + +[tool.setuptools.package-data] +bot_bottle = [ + "Dockerfile.sidecars", + "egress_entrypoint.sh", + "contrib/claude/Dockerfile", + "contrib/codex/Dockerfile", + "contrib/pi/Dockerfile", +] diff --git a/tests/unit/test_cli_doctor.py b/tests/unit/test_cli_doctor.py new file mode 100644 index 0000000..06862f9 --- /dev/null +++ b/tests/unit/test_cli_doctor.py @@ -0,0 +1,51 @@ +"""Unit: `bot-bottle doctor` host prerequisite checks.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from bot_bottle.cli import doctor + + +class TestDoctor(unittest.TestCase): + def test_success_when_prerequisites_present(self): + with tempfile.TemporaryDirectory() as tmp, patch.object( + doctor.Path, "home", return_value=Path(tmp), + ), patch.object( + doctor.shutil, "which", return_value="/usr/bin/docker", + ), patch.object( + doctor.subprocess, "run", + return_value=MagicMock(returncode=0), + ): + Path(tmp, ".bot-bottle").mkdir() + self.assertEqual(0, doctor.cmd_doctor([])) + + def test_missing_config_fails(self): + with tempfile.TemporaryDirectory() as tmp, patch.object( + doctor.Path, "home", return_value=Path(tmp), + ), patch.object( + doctor.shutil, "which", return_value="/usr/bin/docker", + ), patch.object( + doctor.subprocess, "run", + return_value=MagicMock(returncode=0), + ): + self.assertEqual(1, doctor.cmd_doctor([])) + + def test_missing_docker_fails_before_daemon_check(self): + with tempfile.TemporaryDirectory() as tmp, patch.object( + doctor.Path, "home", return_value=Path(tmp), + ), patch.object( + doctor.shutil, "which", return_value=None, + ), patch.object( + doctor.subprocess, "run", + ) as run: + Path(tmp, ".bot-bottle").mkdir() + self.assertEqual(1, doctor.cmd_doctor([])) + run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 082f5ab..cab31a6 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -304,6 +304,19 @@ class TestSidecarBundleShape(unittest.TestCase): self.assertEqual("bot-bottle-sidecars:latest", sc["image"]) self.assertEqual("Dockerfile.sidecars", sc["build"]["dockerfile"]) + def test_bundle_uses_packaged_dockerfile_when_root_missing(self): + from bot_bottle.backend.docker import compose as compose_mod + + original = compose_mod._REPO_DIR + try: + compose_mod._REPO_DIR = "/tmp/does-not-exist" + self.assertEqual( + "bot_bottle/Dockerfile.sidecars", + compose_mod._sidecar_bundle_dockerfile(), + ) + finally: + compose_mod._REPO_DIR = original + def test_bundle_container_name_uses_sidecars_prefix(self): sc = self._render()["services"]["sidecars"] self.assertEqual(f"bot-bottle-sidecars-{SLUG}", sc["container_name"]) diff --git a/tests/unit/test_install_script.py b/tests/unit/test_install_script.py new file mode 100644 index 0000000..6cc13af --- /dev/null +++ b/tests/unit/test_install_script.py @@ -0,0 +1,34 @@ +"""Unit: install.sh static contract checks.""" + +from __future__ import annotations + +import subprocess +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +class TestInstallScript(unittest.TestCase): + def test_shell_syntax(self): + result = subprocess.run( + ["sh", "-n", str(ROOT / "install.sh")], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual("", result.stderr) + self.assertEqual(0, result.returncode) + + def test_contract_phrases(self): + script = (ROOT / "install.sh").read_text(encoding="utf-8") + self.assertIn("python3", script) + self.assertIn("docker info", script) + self.assertIn("pipx install --force", script) + self.assertIn("pip install --user --upgrade", script) + self.assertIn('"${BOT_BOTTLE_BIN}" doctor', script) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_pyproject.py b/tests/unit/test_pyproject.py new file mode 100644 index 0000000..a3c3986 --- /dev/null +++ b/tests/unit/test_pyproject.py @@ -0,0 +1,27 @@ +"""Unit: Python package metadata for install script PRD.""" + +from __future__ import annotations + +import tomllib +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +class TestPyproject(unittest.TestCase): + def test_console_script_and_no_runtime_dependencies(self): + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = data["project"] + self.assertEqual("bot-bottle", project["name"]) + self.assertEqual(">=3.11", project["requires-python"]) + self.assertEqual([], project["dependencies"]) + self.assertEqual( + "bot_bottle.cli:main", + project["scripts"]["bot-bottle"], + ) + + +if __name__ == "__main__": + unittest.main()