Compare commits
2 Commits
be840606d2
..
pr-211
| Author | SHA1 | Date | |
|---|---|---|---|
| df469b2f47 | |||
| d1d9e7a105 |
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default.
|
- **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/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
|
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` 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.
|
- **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.
|
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
||||||
@@ -68,27 +68,6 @@ 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.
|
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
|
## 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`.
|
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`.
|
||||||
@@ -127,8 +106,15 @@ egress:
|
|||||||
routes:
|
routes:
|
||||||
- host: gitea.dideric.is
|
- host: gitea.dideric.is
|
||||||
auth:
|
auth:
|
||||||
scheme: token
|
scheme: token # Bearer | token
|
||||||
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
||||||
|
matches: # optional — restrict to specific paths/methods/headers
|
||||||
|
- paths:
|
||||||
|
- {type: prefix, value: /api/v1/}
|
||||||
|
methods: [GET, POST, PATCH, DELETE]
|
||||||
|
dlp: # optional — per-route detector overrides (default: all on)
|
||||||
|
outbound_detectors: [token_patterns, known_secrets]
|
||||||
|
inbound_detectors: false # disable response scanning for this host
|
||||||
---
|
---
|
||||||
|
|
||||||
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
||||||
@@ -147,6 +133,23 @@ skills:
|
|||||||
You help maintain Gitea-hosted projects.
|
You help maintain Gitea-hosted projects.
|
||||||
````
|
````
|
||||||
|
|
||||||
|
**Egress route fields:**
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `host` | yes | Hostname to allowlist. One entry per host. |
|
||||||
|
| `role` | no | Provider-specific role string (e.g. `claude_code_oauth`). Wires built-in auth flows; set by provider templates, not manually. |
|
||||||
|
| `auth.scheme` | when `auth` present | `Bearer` or `token`. Injected by the proxy; the agent never sees the value. |
|
||||||
|
| `auth.token_ref` | when `auth` present | Env-var name holding the secret on the host. |
|
||||||
|
| `matches` | no | Array of `{paths, methods, headers}` filters. A request must match at least one entry (if any are given) to be forwarded. |
|
||||||
|
| `matches[].paths` | no | Array of `{type, value}`. `type` is `prefix` (default), `exact`, or `regex`. |
|
||||||
|
| `matches[].methods` | no | Array of HTTP method strings, e.g. `[GET, POST]`. |
|
||||||
|
| `matches[].headers` | no | Array of `{name, value, type}`. `type` is `exact` (default) or `regex`. |
|
||||||
|
| `dlp` | no | Per-route DLP overrides. Omit to use defaults (all detectors on). |
|
||||||
|
| `dlp.outbound_detectors` | no | `false` disables outbound scanning; list restricts to named detectors (`token_patterns`, `known_secrets`). |
|
||||||
|
| `dlp.inbound_detectors` | no | `false` disables inbound scanning; list restricts to named detectors (`naive_injection_detection`). |
|
||||||
|
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
|
||||||
|
|
||||||
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
@@ -58,17 +58,10 @@ from .sidecar_bundle import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Repo root or installed site-packages root, used as the build context for
|
# Repo root, used as the build context for the bundle Dockerfile.
|
||||||
# Dockerfiles that COPY bot_bottle source files.
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
_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]:
|
def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||||
"""Render a Compose v2 spec dict from a fully-resolved
|
"""Render a Compose v2 spec dict from a fully-resolved
|
||||||
DockerBottlePlan.
|
DockerBottlePlan.
|
||||||
@@ -190,7 +183,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
"image": SIDECAR_BUNDLE_IMAGE,
|
"image": SIDECAR_BUNDLE_IMAGE,
|
||||||
"build": {
|
"build": {
|
||||||
"context": _REPO_DIR,
|
"context": _REPO_DIR,
|
||||||
"dockerfile": _sidecar_bundle_dockerfile(),
|
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
|
||||||
},
|
},
|
||||||
"container_name": sidecar_bundle_container_name(plan.slug),
|
"container_name": sidecar_bundle_container_name(plan.slug),
|
||||||
"networks": {
|
"networks": {
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
# Bundle image. Defaults to a built-locally tag. Source checkouts
|
# Bundle image. Defaults to a built-locally tag (built from the
|
||||||
# build from the repo-root Dockerfile.sidecars; installed packages
|
# repo's Dockerfile.sidecars via compose `build:`). Operators
|
||||||
# build from the packaged copy under bot_bottle/.
|
# pinning to a published digest can override via env.
|
||||||
# Operators pinning to a published digest can override via env.
|
|
||||||
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
||||||
"BOT_BOTTLE_SIDECAR_IMAGE",
|
"BOT_BOTTLE_SIDECAR_IMAGE",
|
||||||
"bot-bottle-sidecars:latest",
|
"bot-bottle-sidecars:latest",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Main CLI dispatcher.
|
"""Main CLI dispatcher.
|
||||||
|
|
||||||
Commands: cleanup, doctor, edit, info, init, list, resume, start, supervise
|
Commands: cleanup, edit, info, init, list, resume, start, supervise
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,7 +12,6 @@ from ..manifest import ManifestError
|
|||||||
from ._common import PROG
|
from ._common import PROG
|
||||||
from . import list as _list_mod
|
from . import list as _list_mod
|
||||||
from .cleanup import cmd_cleanup
|
from .cleanup import cmd_cleanup
|
||||||
from .doctor import cmd_doctor
|
|
||||||
from .edit import cmd_edit
|
from .edit import cmd_edit
|
||||||
from .info import cmd_info
|
from .info import cmd_info
|
||||||
from .init import cmd_init
|
from .init import cmd_init
|
||||||
@@ -24,7 +23,6 @@ cmd_list = _list_mod.cmd_list
|
|||||||
|
|
||||||
COMMANDS = {
|
COMMANDS = {
|
||||||
"cleanup": cmd_cleanup,
|
"cleanup": cmd_cleanup,
|
||||||
"doctor": cmd_doctor,
|
|
||||||
"edit": cmd_edit,
|
"edit": cmd_edit,
|
||||||
"info": cmd_info,
|
"info": cmd_info,
|
||||||
"init": cmd_init,
|
"init": cmd_init,
|
||||||
@@ -39,7 +37,6 @@ def usage() -> None:
|
|||||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||||
sys.stderr.write("Commands:\n")
|
sys.stderr.write("Commands:\n")
|
||||||
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\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(" 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(" 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")
|
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROG = Path(sys.argv[0]).name or "bot-bottle"
|
PROG = "cli.py"
|
||||||
USER_CWD = os.getcwd()
|
USER_CWD = os.getcwd()
|
||||||
REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
|
REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# 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 <url>/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.
|
|
||||||
@@ -5,10 +5,15 @@ agent_provider:
|
|||||||
egress:
|
egress:
|
||||||
routes:
|
routes:
|
||||||
- host: api.anthropic.com
|
- host: api.anthropic.com
|
||||||
role: claude_code_oauth
|
role: claude_code_oauth # wires Claude Code OAuth; do not change
|
||||||
auth:
|
auth:
|
||||||
scheme: Bearer
|
scheme: Bearer
|
||||||
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||||
|
# dlp is omitted → all detectors on by default (token_patterns,
|
||||||
|
# known_secrets outbound; naive_injection_detection inbound).
|
||||||
|
# To disable inbound scanning for this route:
|
||||||
|
# dlp:
|
||||||
|
# inbound_detectors: false
|
||||||
---
|
---
|
||||||
|
|
||||||
Common Claude provider boundary. Drop this file into
|
Common Claude provider boundary. Drop this file into
|
||||||
|
|||||||
-50
@@ -1,50 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
[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",
|
|
||||||
]
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -304,19 +304,6 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
self.assertEqual("bot-bottle-sidecars:latest", sc["image"])
|
self.assertEqual("bot-bottle-sidecars:latest", sc["image"])
|
||||||
self.assertEqual("Dockerfile.sidecars", sc["build"]["dockerfile"])
|
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):
|
def test_bundle_container_name_uses_sidecars_prefix(self):
|
||||||
sc = self._render()["services"]["sidecars"]
|
sc = self._render()["services"]["sidecars"]
|
||||||
self.assertEqual(f"bot-bottle-sidecars-{SLUG}", sc["container_name"])
|
self.assertEqual(f"bot-bottle-sidecars-{SLUG}", sc["container_name"])
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""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()
|
|
||||||
Reference in New Issue
Block a user