diff --git a/README.md b/README.md index f2ad087..efcb22b 100644 --- a/README.md +++ b/README.md @@ -8,25 +8,40 @@ [![pylint](https://img.shields.io/badge/pylint-9.93%2F10-brightgreen)](https://github.com/PyCQA/pylint) [![pyright](https://img.shields.io/badge/pyright-0%20errors-brightgreen)](https://github.com/microsoft/pyright) -**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data. +**Run any coding agent like it might be compromised — and lose nothing when it is.** -**Solution:** Ephemeral, per agent "bottles" the agent cannot modify that scan all traffic for data exfiltration and limit capabilities and egress to only what the agent needs. +bot-bottle is a provider-neutral, security-first substrate for autonomous agents. Bring Claude Code, Codex, or your own harness; each one runs in an ephemeral, per-agent "bottle" it cannot modify, where every byte of egress is scanned for exfiltration and capabilities are narrowed to exactly what the task declares. -## Features +**Problem:** You want to let a coding agent run unsupervised, but a prompt-injected or misbehaving agent — or a poisoned repo, MCP server, or skill — can wreck your environment or exfiltrate your secrets. Locking yourself to one vendor's cloud doesn't fix that; it just moves the blast radius. + +**Solution:** A neutral control plane that runs *whatever agent you choose* inside an isolation boundary the agent can't touch: TLS-bumped egress allowlisting, outbound/inbound DLP, gitleaks-gated pushes, and host secrets the agent never sees. Swap the agent; keep the guarantees. + +## Why bot-bottle + +### A neutral substrate — bring your own agent + +- **Provider-agnostic by design** — Claude and Codex ship built in; any other agent (Gemini, Aider, a local-model wrapper) is a drop-in plugin at `~/.bot-bottle/contrib//` — no fork, no PR against this repo. The manifest accepts any provider template, and the isolation, egress, and git guarantees are identical across all of them. +- **One control plane, every harness** — the same bottle, egress policy, and supervise flow wrap whichever agent you run, so switching or mixing providers doesn't change your security posture. +- **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top. + +### An isolation boundary the agent can't touch - **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default. - **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only. - **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential. - **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load. - **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host. -- **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top. + +### Isolation that matches your host + - **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other. -- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding. - **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required. - **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network. - **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend. - **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. +Per-provider auth (Claude long-lived OAuth token; Codex opt-in host device-auth forwarding) and per-provider images (`Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile) are configured on the bottle — see [Manifest](#manifest). + ## Architecture On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a sidecar bundle attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the sidecar's internal-network IP, so HTTP/HTTPS traffic flows through the sidecar instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists. @@ -68,6 +83,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 9ad0011..794db12 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 879d067..a4869b9 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -1,6 +1,6 @@ """Main CLI dispatcher. -Commands: cleanup, commit, edit, info, init, list, resume, start, supervise +Commands: cleanup, commit, doctor, edit, info, init, list, resume, start, supervise """ from __future__ import annotations @@ -13,6 +13,7 @@ from ._common import PROG from . import list as _list_mod from .cleanup import cmd_cleanup from .commit import cmd_commit +from .doctor import cmd_doctor from .edit import cmd_edit from .info import cmd_info from .init import cmd_init @@ -25,6 +26,7 @@ cmd_list = _list_mod.cmd_list COMMANDS = { "cleanup": cmd_cleanup, "commit": cmd_commit, + "doctor": cmd_doctor, "edit": cmd_edit, "info": cmd_info, "init": cmd_init, @@ -40,6 +42,7 @@ def usage() -> None: sys.stderr.write("Commands:\n") sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n") sys.stderr.write(" commit snapshot a running bottle's container state to a Docker image\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/docs/prds/prd-new-install-script.md b/docs/prds/prd-new-install-script.md new file mode 100644 index 0000000..ff7fff1 --- /dev/null +++ b/docs/prds/prd-new-install-script.md @@ -0,0 +1,75 @@ +# PRD prd-new: Install script + +- **Status:** Active +- **Author:** didericis +- **Created:** 2026-06-06 +- **Issue:** #197 + +## Summary + +Add a proper Python package distribution and a thin `install.sh` bootstrapper so users can install bot-bottle with a single command without cloning the repo. + +## Problem + +There is currently no install path for new users. The only way to run bot-bottle is to clone the repo and invoke `cli.py` directly. This blocks any HN-style public demo: readers want `curl | sh` or `pipx install`, not a manual clone-and-configure flow. + +## Goals / Success Criteria + +- `curl -fsSL /install.sh | sh` (or equivalent) leaves a working `bot-bottle` command on PATH. +- Python-native users can install with `pipx install bot-bottle` or `uv tool install bot-bottle`. +- `install.sh` validates prerequisites (Python ≥ 3.11, Docker) and exits with a clear message if they are missing. It does not silently install Docker. +- `install.sh` runs `bot-bottle doctor` (or equivalent diagnostic) after install to confirm the environment is ready. +- The package has no runtime pip dependencies (stdlib-only, matching the existing constraint). + +## Non-goals + +- Bundling a Python runtime or producing a standalone binary. +- Automatic Docker installation. +- Plugin architecture changes (out of scope; see issue #197 for future direction). +- Publishing to PyPI in this PR — the package structure is the deliverable; publishing is a separate step. + +## Design + +### Package structure + +Add a minimal `pyproject.toml` at the repo root: + +```toml +[project] +name = "bot-bottle" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +bot-bottle = "bot_bottle.cli:main" +``` + +The existing `bot_bottle/` package and `cli.py` entry point already contain the logic; this just wires up the standard entry point. `cli.py` may need a small refactor to expose a `main()` callable if it uses `if __name__ == "__main__"` only. + +### `install.sh` + +A thin bootstrapper that: + +1. Checks `python3 --version` ≥ 3.11; exits with instructions if not met. +2. Checks `docker info` exits 0; exits with instructions if Docker is not running. +3. Installs via `pipx` if available, otherwise falls back to `pip install --user`. +4. Runs `bot-bottle doctor` to verify the install. + +The script must be idempotent (safe to re-run) and must not require `sudo`. + +### `bot-bottle doctor` + +A new subcommand that checks and reports: + +- Python version. +- Docker daemon reachability. +- Whether `~/.bot-bottle/` config directory exists. + +Exits 0 if all checks pass, non-zero otherwise. + +## Decisions + +- `install.sh` is hosted from the repo's raw Gitea URL for now: + `https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh`. +- Should `version` in `pyproject.toml` be driven by a git tag at build time (e.g. via `hatch-vcs`) or kept as a static string? Static is simpler for now. 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 8b10eec..8240c67 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -301,6 +301,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()