Compare commits

..

1 Commits

Author SHA1 Message Date
didericis bdca1c8bea Remove the supervise flag; supervise every bottle
lint / lint (push) Successful in 2m2s
test / unit (pull_request) Successful in 46s
test / integration (pull_request) Successful in 22s
Issue #249: in practice the per-bottle `supervise` flag was never
turned off — all bottles should be supervised. Remove the manifest
flag and make the supervise sidecar unconditional, mirroring egress.

- Reject `supervise:` as a removed bottle key with a migration hint.
- Drop the `supervise` field from ManifestBottle and the extends merge.
- prepare_supervise always returns a SupervisePlan; the plan type is
  now non-optional and the per-backend `is None` guards are gone, so
  the supervise daemon, current-config mount, aliases, and MCP
  registration always render.

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