Compare commits

..

7 Commits

Author SHA1 Message Date
didericis bd663196dc docs: reposition README around provider-neutral secure substrate
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 17s
Lead with the agnostic + security story instead of the single-user
security framing. New hero positions bot-bottle as a neutral control
plane that runs any agent (Claude, Codex, or a drop-in contrib plugin)
inside an isolation boundary the agent can't touch.

Restructure Features into three pillars — neutral substrate, isolation
boundary, host-matched isolation — promoting provider-agnosticism (PRD
0053 user plugins) from a buried bullet to a headline. No capability
claims changed; per-provider auth/image detail preserved as a note
linking to Manifest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcU7nerbg8cVj9R4EkpfLJ
2026-06-24 01:21:05 -04:00
didericis-codex 6b0de88be6 docs: activate install script prd
lint / lint (push) Successful in 1m39s
test / unit (pull_request) Successful in 31s
test / integration (pull_request) Successful in 16s
2026-06-23 21:47:12 -04:00
didericis-codex 9a941e59be feat: add install script packaging 2026-06-23 21:47:12 -04:00
didericis d7a3539755 ci(prd): rename PRD to prd-new placeholder per new convention 2026-06-23 21:46:44 -04:00
didericis cfe57a50d0 docs(prd): renumber PRD 0054 → 0057 (0054 slot taken by named-labelled-agents) 2026-06-23 21:46:44 -04:00
didericis e5d551861c docs(prd): PRD 0054 - install script 2026-06-23 21:46:44 -04:00
didericis 369d332204 Default the supervise flag to true
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 17s
lint / lint (push) Successful in 1m40s
test / unit (push) Successful in 30s
test / integration (push) Successful in 15s
Update Quality Badges / update-badges (push) Successful in 1m44s
Issue #249: bottles should be supervised by default. Rather than
remove the flag (which would make supervision mandatory and is the
wrong plane for cost-control enforcement — see #251), keep the
opt-out and flip the default. Bottles that omit `supervise:` now get
the stuck-recovery sidecar; `supervise: false` still skips it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcU7nerbg8cVj9R4EkpfLJ
2026-06-23 20:48:04 -04:00
44 changed files with 744 additions and 182 deletions
+41 -5
View File
@@ -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/<name>/` — 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/<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.
- **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`.
+96
View File
@@ -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"]
+2 -1
View File
@@ -207,7 +207,8 @@ class AgentProvider(ABC):
) -> None:
"""Register the per-bottle supervise sidecar as an MCP server
in the provider's in-guest config. Called by the backend after
the supervise sidecar is reachable."""
the supervise sidecar is reachable. No-op when
`plan.supervise_plan is None`."""
def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None:
"""Install the egress MITM CA into the agent's trust store.
+3 -3
View File
@@ -102,7 +102,7 @@ class BottlePlan(ABC):
over a published host port)."""
return "git"
egress_plan: EgressPlan
supervise_plan: SupervisePlan
supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan
@property
@@ -332,7 +332,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
)
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
supervise_plan = prepare_supervise(slug)
supervise_plan = prepare_supervise(manifest_bottle, slug)
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
return self._resolve_plan(
@@ -405,7 +405,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path) -> PlanT:
"""Backend-specific plan resolution: image/container names,
env-file, prompt-file, proxy plan, runtime detection. Called by
+3 -1
View File
@@ -70,7 +70,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path,
) -> DockerBottlePlan:
return _resolve_plan.resolve_plan(
@@ -94,6 +94,8 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
"""Docker bottles reach the supervise sidecar via the
compose-network alias `supervise:9100`. No per-bottle URL
plumbing needed; the alias resolves inside the bridge."""
if plan.supervise_plan is None:
return ""
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
+43 -24
View File
@@ -14,7 +14,7 @@ Conditional services follow the plan content:
- agent + sidecars bundle: always.
- git-gate: iff plan.git_gate_plan.upstreams.
- egress: iff plan.egress_plan.routes.
- supervise: always (every bottle is supervised, issue #249).
- supervise: iff plan.supervise_plan is not None.
"""
from __future__ import annotations
@@ -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.
@@ -119,11 +126,13 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
image, all daemons under a Python init supervisor.
Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env.
egress and supervise are always present; git-gate is conditional.
egress is always present; git-gate / supervise are conditional.
"""
daemons: list[str] = ["egress", "supervise"]
daemons: list[str] = ["egress"]
if plan.git_gate_plan.upstreams:
daemons.append("git-gate")
if plan.supervise_plan is not None:
daemons.append("supervise")
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
volumes: list[dict[str, Any]] = []
@@ -158,27 +167,30 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
# --- supervise ----------------------------------------------------
sp = plan.supervise_plan
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
volumes.append({
"type": "bind",
"source": str(sp.queue_dir),
"target": QUEUE_DIR_IN_CONTAINER,
"read_only": False,
})
if sp is not None:
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
volumes.append({
"type": "bind",
"source": str(sp.queue_dir),
"target": QUEUE_DIR_IN_CONTAINER,
"read_only": False,
})
internal_aliases = [EGRESS_HOSTNAME, SUPERVISE_HOSTNAME]
internal_aliases = [EGRESS_HOSTNAME]
if gp.upstreams:
internal_aliases.append(GIT_GATE_HOSTNAME)
if sp is not None:
internal_aliases.append(SUPERVISE_HOSTNAME)
service: 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": {
@@ -226,10 +238,14 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
if plan.use_runsc:
service["runtime"] = "runsc"
service["volumes"] = [_bind(
plan.supervise_plan.current_config_dir,
CURRENT_CONFIG_DIR_IN_AGENT,
)]
volumes: list[dict[str, Any]] = []
if plan.supervise_plan is not None:
volumes.append(_bind(
plan.supervise_plan.current_config_dir,
CURRENT_CONFIG_DIR_IN_AGENT,
))
if volumes:
service["volumes"] = volumes
# The init supervisor inside the bundle owns intra-bundle
# daemon ordering, so the agent only waits for the bundle
@@ -245,9 +261,12 @@ def _agent_proxy_url(plan: DockerBottlePlan) -> str:
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
"""NO_PROXY for the agent: loopback plus the supervise hostname
(MCP long-poll must bypass the egress proxy)."""
hosts = ["localhost", "127.0.0.1", SUPERVISE_HOSTNAME]
"""NO_PROXY for the agent: loopback always; supervise hostname
when the supervise sidecar is up (MCP long-poll must bypass
the egress proxy)."""
hosts = ["localhost", "127.0.0.1"]
if plan.supervise_plan is not None:
hosts.append(SUPERVISE_HOSTNAME)
return ",".join(hosts)
+6 -4
View File
@@ -130,10 +130,12 @@ def launch(
mitmproxy_ca_host_path=egress_ca_host,
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
)
supervise_plan = dataclasses.replace(
plan.supervise_plan,
internal_network=internal_network,
)
supervise_plan = plan.supervise_plan
if supervise_plan is not None:
supervise_plan = dataclasses.replace(
supervise_plan,
internal_network=internal_network,
)
plan = dataclasses.replace(
plan,
git_gate_plan=git_gate_plan,
+1 -1
View File
@@ -37,7 +37,7 @@ def resolve_plan(
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
supervise_plan: SupervisePlan,
supervise_plan: SupervisePlan | None,
git_gate_plan: GitGatePlan,
stage_dir: Path,
) -> DockerBottlePlan:
+4 -3
View File
@@ -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",
@@ -52,7 +52,7 @@ class MacosContainerBottleBackend(
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path,
) -> MacosContainerBottlePlan:
return _resolve_plan.resolve_plan(
+14 -8
View File
@@ -222,7 +222,9 @@ def _stamp_agent_urls(
sidecar_ip: str,
) -> MacosContainerBottlePlan:
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/"
supervise_url = ""
if plan.supervise_plan is not None:
supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/"
git_gate_url = ""
if plan.git_gate_plan.upstreams:
git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}"
@@ -339,9 +341,11 @@ def _sidecar_dns() -> str:
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
daemons = ["egress", "supervise"]
daemons = ["egress"]
if plan.git_gate_plan.upstreams:
daemons += ["git-gate", "git-http"]
if plan.supervise_plan is not None:
daemons.append("supervise")
return tuple(daemons)
@@ -351,11 +355,12 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
env.extend(sorted(plan.egress_plan.token_env_map.keys()))
if plan.git_gate_plan.upstreams:
env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}")
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
if plan.supervise_plan is not None:
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
return tuple(env)
@@ -378,7 +383,8 @@ def _sidecar_mounts(
))
sp = plan.supervise_plan
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
if sp is not None:
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
return tuple(mounts)
@@ -30,7 +30,7 @@ def resolve_plan(
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
supervise_plan: SupervisePlan,
supervise_plan: SupervisePlan | None,
git_gate_plan: GitGatePlan,
stage_dir: Path,
) -> MacosContainerBottlePlan:
+5 -3
View File
@@ -92,9 +92,11 @@ def prepare_egress(
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
def prepare_supervise(slug: str) -> SupervisePlan:
"""Prepare the supervise sidecar state dir. Every bottle is
supervised (issue #249), so this always returns a plan."""
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
"""Prepare the supervise sidecar state dir. Returns None when
bottle.supervise is falsy."""
if not bottle.supervise:
return None
supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True)
return Supervise().prepare(slug, supervise_dir)
+1 -1
View File
@@ -62,7 +62,7 @@ class SmolmachinesBottleBackend(
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path,
) -> SmolmachinesBottlePlan:
return _resolve_plan.resolve_plan(
+20 -13
View File
@@ -206,10 +206,12 @@ def _discover_urls(
)
agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
supervise_host_port = _bundle.bundle_host_port(
plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
)
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
agent_supervise_url = ""
if plan.supervise_plan is not None:
supervise_host_port = _bundle.bundle_host_port(
plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
)
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
no_proxy = f"{existing_no_proxy},{loopback_ip}"
@@ -297,14 +299,15 @@ def _bundle_launch_spec(
"""Build a BundleLaunchSpec from the resolved inner Plans.
Daemons in the CSV:
- egress and supervise are always present.
- egress is always present.
- git-gate + git-http are conditional on plan.git_gate_plan.upstreams.
- supervise is conditional on plan.supervise_plan.
Env + volumes are the union of the sidecar daemons' needs, with
daemon-private values only (HTTPS_PROXY is scoped to the
egress process by egress_entrypoint.sh see PRD 0024's bundle
bind-address PR)."""
daemons: list[str] = ["egress", "supervise"]
daemons: list[str] = ["egress"]
env: list[str] = []
volumes: list[tuple[str, str, bool]] = []
@@ -344,19 +347,23 @@ def _bundle_launch_spec(
# --- supervise --------------------------------------------
sp = plan.supervise_plan
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
if sp is not None:
daemons.append("supervise")
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
# Container ports the agent reaches from the smolvm guest —
# published on host loopback so the guest can dial via TSI +
# macOS networking. Egress is always the agent's HTTP/HTTPS proxy.
ports_to_publish: list[int] = [_EGRESS_PORT, _SUPERVISE_PORT]
ports_to_publish: list[int] = [_EGRESS_PORT]
if gp.upstreams:
ports_to_publish.append(_GIT_HTTP_PORT)
if sp is not None:
ports_to_publish.append(_SUPERVISE_PORT)
return _bundle.BundleLaunchSpec(
slug=plan.slug,
@@ -52,7 +52,7 @@ def resolve_plan(
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
supervise_plan: SupervisePlan,
supervise_plan: SupervisePlan | None,
git_gate_plan: GitGatePlan,
stage_dir: Path,
) -> SmolmachinesBottlePlan:
@@ -68,8 +68,7 @@ class BundleLaunchSpec:
image: str = SIDECAR_BUNDLE_IMAGE
# Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The
# supervisor inside the bundle reads it to skip
# bottle-irrelevant daemons (e.g. git-gate when a bottle
# declares no upstreams).
# bottle-irrelevant daemons (e.g. supervise=False bottles).
daemons_csv: str = "egress"
# Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name
# form inherits the value from the docker-run subprocess env,
+4 -1
View File
@@ -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")
+1 -1
View File
@@ -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)
+73
View File
@@ -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
@@ -291,6 +291,8 @@ class ClaudeAgentProvider(AgentProvider):
Failure is logged but not fatal the bottle still works without
the entry; the operator can register it manually."""
if plan.supervise_plan is None:
return
info(f"registering supervise MCP server in agent claude config → {supervise_url}")
r = bottle.exec(
f"claude mcp add --scope user --transport http "
@@ -257,6 +257,8 @@ class CodexAgentProvider(AgentProvider):
Mirrors the Claude provider's `claude mcp add` flow — failure
is logged but not fatal."""
if plan.supervise_plan is None:
return
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
r = bottle.exec(
f"codex mcp add {_SUPERVISE_MCP_NAME} --url "
+16 -8
View File
@@ -19,6 +19,7 @@ Bottle schema (frontmatter):
repos: { <name>: <git-gate-entry>, ... } # optional
egress: { routes: [ <egress-route>, ... ] }
# route keys: host, matches, auth, role, dlp
supervise: <bool> # optional (default true)
Agent schema (frontmatter):
bottle: <bottle-name> # required
@@ -110,6 +111,13 @@ class ManifestBottle:
# identity without any git-gate.repos upstreams, and vice versa.
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
# default, issue #249), the launch step brings up a supervise
# sidecar that exposes MCP tools to the agent (egress-block,
# capability-block) plus mounts the current-config dir read-only
# into the agent at /etc/bot-bottle/current-config. Set
# `supervise: false` to skip the sidecar and mount.
supervise: bool = True
@classmethod
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
@@ -144,13 +152,6 @@ class ManifestBottle:
f"removed. Move it under 'git-gate.user'."
)
if "supervise" in d:
raise ManifestError(
f"bottle '{name}' has a 'supervise' field, which has been "
f"removed (issue #249). All bottles are now supervised; the "
f"flag was always-on in practice. Delete the field."
)
unknown = set(d.keys()) - BOTTLE_KEYS
if unknown:
allowed = ", ".join(sorted(BOTTLE_KEYS))
@@ -189,9 +190,16 @@ class ManifestBottle:
else ManifestEgressConfig()
)
supervise_raw = d.get("supervise", True)
if not isinstance(supervise_raw, bool):
raise ManifestError(
f"bottle '{name}' supervise must be a boolean "
f"(was {type(supervise_raw).__name__})"
)
return cls(
env=env, agent_provider=agent_provider, git=git,
git_user=git_user, egress=egress,
git_user=git_user, egress=egress, supervise=supervise_raw,
)
+4
View File
@@ -134,6 +134,9 @@ def _merge_bottles(
if "agent_provider" in child_raw
else parent.agent_provider
)
merged_supervise = (
child.supervise if "supervise" in child_raw else parent.supervise
)
validate_egress_routes(name, merged_egress.routes)
return ManifestBottle(
@@ -142,6 +145,7 @@ def _merge_bottles(
git=merged_git,
git_user=merged_git_user,
egress=merged_egress,
supervise=merged_supervise,
)
+1 -1
View File
@@ -16,7 +16,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
# sets dies with a "did you mean" pointer: typos should not silently
# ghost into an empty config.
BOTTLE_KEYS = frozenset(
{"env", "extends", "agent_provider", "git-gate", "egress"}
{"env", "extends", "agent_provider", "git-gate", "egress", "supervise"}
)
AGENT_KEYS_REQUIRED = frozenset({"bottle"})
AGENT_KEYS_OPTIONAL = frozenset({"skills", "git-gate"})
+75
View File
@@ -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 <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.
Executable
+50
View File
@@ -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
+27
View File
@@ -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",
]
@@ -27,13 +27,14 @@ from tests._docker import skip_unless_docker
def _manifest() -> ManifestIndex:
"""Minimal bottle so the bundle exercises egress + supervise
(every bottle is supervised, issue #249). Git is off because a
meaningful git-gate test needs a real upstream and SSH keys
out of scope for a bundle smoke."""
"""Bottle with supervise on so the bundle exercises egress +
supervise. Git is off because a meaningful git-gate test needs
a real upstream and SSH keys out of scope for a bundle smoke."""
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {},
"dev": {
"supervise": True,
},
},
"agents": {
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
+51
View File
@@ -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()
+46 -27
View File
@@ -40,11 +40,13 @@ STAGE = Path("/tmp/cb-stage")
STATE = Path("/tmp/cb-state")
def _manifest(*, with_git: bool, with_egress: bool) -> ManifestIndex:
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex:
"""Minimal manifest with the toggles the chunk-1 matrix needs.
The renderer only reads from the plan, not the manifest, so this
is just here to back BottleSpec."""
bottle: dict[str, object] = {}
if supervise:
bottle["supervise"] = True
if with_git:
bottle["git-gate"] = {"repos": {
"upstream": {
@@ -109,11 +111,10 @@ def _plan(
*,
with_git: bool = False,
with_egress: bool = False,
supervise: bool = False,
) -> DockerBottlePlan:
"""Build a fully-resolved DockerBottlePlan. Toggles cover the
matrix the renderer's conditional-service logic branches on.
Every bottle is supervised (issue #249), so the supervise plan
is always present."""
matrix the renderer's conditional-service logic branches on."""
upstreams: tuple[GitGateUpstream, ...] = ()
if with_git:
upstreams = (GitGateUpstream(
@@ -135,7 +136,7 @@ def _plan(
roles=(),
),)
index = _manifest(with_git=with_git, with_egress=with_egress)
index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress)
spec = BottleSpec(
manifest=index,
agent_name="demo",
@@ -150,7 +151,7 @@ def _plan(
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
git_gate_plan=_git_gate_plan(upstreams),
egress_plan=_egress_plan(routes),
supervise_plan=_supervise_plan(),
supervise_plan=_supervise_plan() if supervise else None,
use_runsc=False,
agent_provision=AgentProvisionPlan(
template="claude",
@@ -219,8 +220,10 @@ class TestAgentAlwaysPresent(unittest.TestCase):
proxy = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")][0]
self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy)
def test_agent_no_proxy_includes_supervise(self):
s = bottle_plan_to_compose(_plan())["services"]["agent"]
def test_agent_no_proxy_adds_supervise_when_enabled(self):
s = bottle_plan_to_compose(
_plan(supervise=True)
)["services"]["agent"]
no_proxy = [e for e in s["environment"] if e.startswith("NO_PROXY=")][0]
self.assertIn("supervise", no_proxy)
@@ -256,18 +259,22 @@ class TestAgentAlwaysPresent(unittest.TestCase):
def test_agent_depends_only_on_sidecars(self):
# Bundle shape: the init supervisor owns intra-bundle daemon
# ordering, so the agent waits on the bundle container alone.
for kwargs in [{}, {"with_git": True, "with_egress": True}]:
for kwargs in [{}, {"with_git": True, "with_egress": True, "supervise": True}]:
with self.subTest(**kwargs):
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
self.assertEqual(["sidecars"], s["depends_on"])
def test_agent_current_config_always_mounted(self):
# Every bottle is supervised (issue #249), so the read-only
# current-config mount is always present in the agent.
agent = bottle_plan_to_compose(_plan())["services"]["agent"]
def test_agent_current_config_mount_only_with_supervise(self):
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"]
self.assertTrue(any(
v["target"] == "/etc/bot-bottle/current-config"
for v in agent.get("volumes", [])
for v in with_sv.get("volumes", [])
))
without_sv = bottle_plan_to_compose(_plan(supervise=False))["services"]["agent"]
# Either no volumes key at all, or no current-config target.
self.assertFalse(any(
v["target"] == "/etc/bot-bottle/current-config"
for v in without_sv.get("volumes", [])
))
@@ -285,7 +292,7 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
def test_emits_two_services_full_matrix(self):
spec = self._render(with_git=True, with_egress=True)
spec = self._render(with_git=True, with_egress=True, supervise=True)
# Still two services — the bundle absorbs git-gate/egress/supervise.
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
@@ -294,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"])
@@ -308,16 +328,16 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertIn("egress", aliases)
def test_internal_aliases_omit_inactive_sidecars(self):
# With no git-gate, that name is NOT aliased — keeps the alias
# list honest about what's actually listening inside the bundle.
# supervise is always present (issue #249).
# With no git-gate / supervise, those names are NOT aliased
# — keeps the alias list honest about what's actually
# listening inside the bundle.
sc = self._render()["services"]["sidecars"]
aliases = set(sc["networks"]["internal"]["aliases"])
self.assertNotIn("git-gate", aliases)
self.assertIn("supervise", aliases)
self.assertNotIn("supervise", aliases)
def test_internal_aliases_include_active_sidecars(self):
sc = self._render(with_git=True)["services"]["sidecars"]
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
aliases = set(sc["networks"]["internal"]["aliases"])
self.assertIn("git-gate", aliases)
self.assertIn("supervise", aliases)
@@ -329,11 +349,10 @@ class TestSidecarBundleShape(unittest.TestCase):
for line in sc["environment"]
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=")
}
# egress + supervise are always present (issue #249).
self.assertEqual({"egress,supervise"}, daemons)
self.assertEqual({"egress"}, daemons)
def test_daemons_csv_expands_with_optional_sidecars(self):
sc = self._render(with_git=True)["services"]["sidecars"]
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
for line in sc["environment"]:
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS="):
csv = line.split("=", 1)[1]
@@ -341,7 +360,7 @@ class TestSidecarBundleShape(unittest.TestCase):
else:
self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env")
self.assertEqual(
["egress", "supervise", "git-gate"],
["egress", "git-gate", "supervise"],
csv.split(","),
)
@@ -370,7 +389,7 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertNotIn("EGRESS_TOKEN_0", env_strings)
def test_supervise_env_present_when_active(self):
sc = self._render()["services"]["sidecars"]
sc = self._render(supervise=True)["services"]["sidecars"]
env_strings = sc["environment"]
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings)
self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings))
@@ -382,7 +401,7 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
def test_volumes_union_full_matrix(self):
sc = self._render(with_git=True, with_egress=True)[
sc = self._render(with_git=True, with_egress=True, supervise=True)[
"services"]["sidecars"]
targets = {v["target"] for v in sc["volumes"]}
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
@@ -397,7 +416,7 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertNotIn("extra_hosts", sc)
def test_agent_depends_on_bundle_only(self):
sc = self._render(with_git=True, with_egress=True)[
sc = self._render(with_git=True, with_egress=True, supervise=True)[
"services"]["agent"]
self.assertEqual(["sidecars"], sc["depends_on"])
+19 -7
View File
@@ -50,8 +50,11 @@ def _plan(
agent_prompt: str = "",
skills: list[str] | None = None,
agent_provision: AgentProvisionPlan | None = None,
supervise: bool = False,
) -> DockerBottlePlan:
bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore
if supervise:
bottle_json["supervise"] = True
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
@@ -67,11 +70,13 @@ def _plan(
manifest=index, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
)
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
)
supervise_plan = None
if supervise:
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
@@ -309,10 +314,17 @@ class TestClaudeUiProvision(unittest.TestCase):
class TestClaudeSuperviseMcp(unittest.TestCase):
def test_noop_when_supervise_disabled(self):
bottle = _make_bottle()
ClaudeAgentProvider().provision_supervise_mcp(
_plan(supervise=False), bottle, _URL,
)
bottle.exec.assert_not_called()
def test_runs_claude_mcp_add_as_node(self):
bottle = _make_bottle()
ClaudeAgentProvider().provision_supervise_mcp(
_plan(), bottle, _URL,
_plan(supervise=True), bottle, _URL,
)
bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0]
@@ -328,7 +340,7 @@ class TestClaudeSuperviseMcp(unittest.TestCase):
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
)
ClaudeAgentProvider().provision_supervise_mcp(
_plan(), bottle, _URL,
_plan(supervise=True), bottle, _URL,
)
+19 -7
View File
@@ -50,8 +50,11 @@ def _plan(
agent_prompt: str = "",
skills: list[str] | None = None,
agent_provision: AgentProvisionPlan | None = None,
supervise: bool = False,
) -> DockerBottlePlan:
bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore
if supervise:
bottle_json["supervise"] = True
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
@@ -67,11 +70,13 @@ def _plan(
manifest=index, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
)
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
)
supervise_plan = None
if supervise:
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
@@ -272,10 +277,17 @@ class TestCodexProvision(unittest.TestCase):
class TestCodexSuperviseMcp(unittest.TestCase):
def test_noop_when_supervise_disabled(self):
bottle = _make_bottle()
CodexAgentProvider().provision_supervise_mcp(
_plan(supervise=False), bottle, _URL,
)
bottle.exec.assert_not_called()
def test_runs_codex_mcp_add_as_node(self):
bottle = _make_bottle()
CodexAgentProvider().provision_supervise_mcp(
_plan(), bottle, _URL,
_plan(supervise=True), bottle, _URL,
)
bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0]
@@ -290,7 +302,7 @@ class TestCodexSuperviseMcp(unittest.TestCase):
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
)
CodexAgentProvider().provision_supervise_mcp(
_plan(), bottle, _URL,
_plan(supervise=True), bottle, _URL,
)
+1 -6
View File
@@ -16,7 +16,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.pi.agent_provider import PiAgentProvider
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.supervise import SupervisePlan
from bot_bottle.manifest import ManifestIndex
@@ -78,11 +77,7 @@ def _plan(
routes=(),
token_env_map={},
),
supervise_plan=SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
),
supervise_plan=None,
use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan(
template="pi", command="pi", prompt_mode="append_system_prompt",
@@ -16,7 +16,6 @@ from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.supervise import SupervisePlan
from bot_bottle.manifest import ManifestIndex
@@ -56,11 +55,7 @@ def _plan(tmp: str) -> DockerBottlePlan:
routes=(),
token_env_map={},
),
supervise_plan=SupervisePlan(
slug=_SLUG,
queue_dir=stage / "supervise" / "queue",
current_config_dir=stage / "supervise" / "current-config",
),
supervise_plan=None,
agent_provision=AgentProvisionPlan(
template="claude",
command="claude",
+1 -6
View File
@@ -21,7 +21,6 @@ from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.supervise import SupervisePlan
from bot_bottle.manifest import ManifestIndex
_INDEX = ManifestIndex.from_json_obj({
@@ -57,11 +56,7 @@ def _plan(tmp: str) -> DockerBottlePlan:
routes=(),
token_env_map={},
),
supervise_plan=SupervisePlan(
slug="test-teardown-00001",
queue_dir=stage / "supervise" / "queue",
current_config_dir=stage / "supervise" / "current-config",
),
supervise_plan=None,
agent_provision=AgentProvisionPlan(
template="claude",
command="claude",
+1 -6
View File
@@ -21,7 +21,6 @@ from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.supervise import SupervisePlan
from bot_bottle.manifest import ManifestIndex
@@ -80,11 +79,7 @@ def _plan(*, git_user: dict | None = None, # type: ignore
routes=(),
token_env_map={},
),
supervise_plan=SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
),
supervise_plan=None,
use_runsc=False,
agent_provision=AgentProvisionPlan(
template="claude",
+34
View File
@@ -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()
+9 -10
View File
@@ -15,7 +15,6 @@ from bot_bottle.backend.macos_container import launch
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.supervise import SupervisePlan
from bot_bottle.manifest import ManifestIndex
_MANIFEST = ManifestIndex.from_json_obj({
@@ -28,6 +27,7 @@ def _plan(
*,
stage_dir: Path,
git: bool = False,
supervise: bool = False,
agent_git_gate_url: str = "",
agent_supervise_url: str = "",
) -> MacosContainerBottlePlan:
@@ -67,8 +67,10 @@ def _plan(
)
else:
git_gate_plan = SimpleNamespace(upstreams=())
# Every bottle is supervised (issue #249).
supervise_plan = SimpleNamespace(queue_dir=Path("/state/supervise/queue"))
supervise_plan = (
SimpleNamespace(queue_dir=Path("/state/supervise/queue"))
if supervise else None
)
agent_provision = SimpleNamespace(
guest_env={"LITERAL": "value"},
provisioned_env={"CODEX_HOME": "/run/codex-home"},
@@ -99,7 +101,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
self._tmp.cleanup()
def test_sidecar_argv_uses_egress_network_first_and_explicit_dns(self):
plan = _plan(stage_dir=self.stage_dir)
plan = _plan(stage_dir=self.stage_dir, supervise=True)
with patch.object(launch.os, "environ", {
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
}):
@@ -170,7 +172,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
def test_git_gate_daemons_are_ready_gated(self):
plan = _plan(stage_dir=self.stage_dir, git=True)
self.assertEqual(
("egress", "supervise", "git-gate", "git-http"),
("egress", "git-gate", "git-http"),
launch._sidecar_daemons(plan),
)
self.assertIn(
@@ -179,7 +181,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
)
def test_stamp_agent_urls_includes_git_http_when_git_gate_exists(self):
plan = _plan(stage_dir=self.stage_dir, git=True)
plan = _plan(stage_dir=self.stage_dir, git=True, supervise=True)
with patch.object(launch.dataclasses, "replace") as replace:
launch._stamp_agent_urls(plan, "192.168.128.2")
replace.assert_called_once_with(
@@ -270,10 +272,7 @@ def _build_plan(stage_dir: Path) -> MacosContainerBottlePlan:
stage_dir=stage_dir,
git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())),
egress_plan=cast(EgressPlan, SimpleNamespace()),
supervise_plan=cast(
SupervisePlan,
SimpleNamespace(queue_dir=Path("/state/supervise/queue")),
),
supervise_plan=None,
agent_provision=AgentProvisionPlan(
template="claude",
command="claude",
@@ -116,6 +116,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
idx = ManifestIndex.from_json_obj({
"bottles": {"dev": {
"env": {"FOO": "bar"},
"supervise": True,
"git-gate": {"user": {"name": "B"}},
}},
"agents": {"impl": {
@@ -126,6 +127,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
b = idx.load_for_agent("impl").bottle
self.assertEqual("a", b.git_user.name)
self.assertEqual({"FOO": "bar"}, dict(b.env))
self.assertTrue(b.supervise)
class TestAgentGitUserRejections(unittest.TestCase):
+16 -1
View File
@@ -42,26 +42,38 @@ class TestExtendsBasic(unittest.TestCase):
# same way they did before the resolver landed.
m = _build(dev={
"env": {"FOO": "bar"},
"supervise": True,
})
b = m.bottles["dev"]
self.assertEqual({"FOO": "bar"}, dict(b.env))
self.assertTrue(b.supervise)
def test_child_inherits_parent_fields_unchanged(self):
m = _build(
base={
"env": {"BASE": "1"},
"supervise": True,
},
child={"extends": "base"},
)
c = m.bottles["child"]
self.assertEqual({"BASE": "1"}, dict(c.env))
self.assertTrue(c.supervise)
def test_child_overrides_supervise_scalar(self):
m = _build(
base={"supervise": True},
off={"extends": "base", "supervise": False},
)
self.assertTrue(m.bottles["base"].supervise)
self.assertFalse(m.bottles["off"].supervise)
def test_parent_resolved_once_for_multiple_children(self):
# Two children sharing one parent: both inherit; the parent
# is resolved once + cached. (Cache behavior is internal; we
# observe correctness on both children.)
m = _build(
base={"env": {"BASE": "1"}},
base={"env": {"BASE": "1"}, "supervise": True},
a={"extends": "base", "env": {"A": "1"}},
b={"extends": "base", "env": {"B": "1"}},
)
@@ -354,6 +366,7 @@ class TestExtendsChain(unittest.TestCase):
m = _build(
grandparent={
"env": {"GP": "1"},
"supervise": True,
},
parent={
"extends": "grandparent",
@@ -368,6 +381,8 @@ class TestExtendsChain(unittest.TestCase):
{"GP": "1", "P": "1", "C": "1"},
dict(m.bottles["child"].env),
)
# supervise threads through unchanged.
self.assertTrue(m.bottles["child"].supervise)
def test_intermediate_can_override(self):
m = _build(
+2 -12
View File
@@ -19,7 +19,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.supervise import SupervisePlan
from bot_bottle.manifest import Manifest, ManifestIndex
@@ -78,15 +77,6 @@ def _egress_plan(tmp: str) -> EgressPlan:
)
def _supervise_plan(tmp: str) -> SupervisePlan:
stage = Path(tmp)
return SupervisePlan(
slug="test-00001",
queue_dir=stage / "supervise" / "queue",
current_config_dir=stage / "supervise" / "current-config",
)
def _agent_provision(tmp: str) -> AgentProvisionPlan:
return AgentProvisionPlan(
template="claude",
@@ -109,7 +99,7 @@ def _docker_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> DockerBottle
stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp),
supervise_plan=_supervise_plan(tmp),
supervise_plan=None,
agent_provision=_agent_provision(tmp),
slug="test-00001",
forwarded_env={},
@@ -125,7 +115,7 @@ def _smolmachines_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> Smolma
stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp),
supervise_plan=_supervise_plan(tmp),
supervise_plan=None,
agent_provision=_agent_provision(tmp),
slug="test-00001",
bundle_subnet="10.99.0.0/24",
+27
View File
@@ -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()
+11 -6
View File
@@ -86,6 +86,7 @@ def _plan(
stage_dir: Path | None = None,
egress_routes: tuple[EgressRoute, ...] = (),
egress_ca_path: Path = Path(),
supervise: bool = False,
bundle_ip: str = "192.168.50.2",
agent_git_gate_host: str = "127.0.0.1:55555",
agent_supervise_url: str = "http://127.0.0.1:55556/",
@@ -107,6 +108,8 @@ def _plan(
git_gate_json["user"] = git_user
if git_gate_json:
bottle_json["git-gate"] = git_gate_json
if supervise:
bottle_json["supervise"] = True
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
@@ -124,11 +127,13 @@ def _plan(
copy_cwd=copy_cwd,
user_cwd=user_cwd,
)
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
)
supervise_plan = None
if supervise:
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
)
return SmolmachinesBottlePlan(
spec=spec,
manifest=manifest,
@@ -400,7 +405,7 @@ class TestBundleLaunchSpec(unittest.TestCase):
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
self.assertEqual(
"egress,supervise,git-gate,git-http",
"egress,git-gate,git-http",
spec.daemons_csv,
)
self.assertIn(9420, spec.ports_to_publish)