refactor!: rename project to bot-bottle

Assisted-by: Codex
This commit is contained in:
2026-05-28 17:56:14 -04:00
parent 8875d8cc17
commit c08b09dc9f
200 changed files with 1271 additions and 1271 deletions
@@ -6,7 +6,7 @@
## Summary
Run pipelock as a sidecar container on each claude-bottle agent's only
Run pipelock as a sidecar container on each bot-bottle agent's only
egress route, scanning all outbound HTTP for hostname allowlist violations
and DLP matches.
@@ -95,18 +95,18 @@ The feature is **done** when all of the following ship:
### New services / components
Two new modules under `claude_bottle/`:
Two new modules under `bot_bottle/`:
- **`claude_bottle/pipelock.py`** — pipelock-specific logic. Generates
- **`bot_bottle/pipelock.py`** — pipelock-specific logic. Generates
the per-bottle YAML config from the manifest's `egress` block plus
baked-in defaults; copies the YAML into the sidecar via `docker cp`;
starts and stops the sidecar container; resolves the allowlist for
display in the preflight.
- **`claude_bottle/network.py`** — Docker network plumbing. Creates the
per-agent `--internal` network (named `claude-bottle-net-<slug>` with
- **`bot_bottle/network.py`** — Docker network plumbing. Creates the
per-agent `--internal` network (named `bot-bottle-net-<slug>` with
the same slug-and-suffix scheme used for container names), attaches
the agent and sidecar to it, removes it on teardown. Kept separate
from `claude_bottle/docker.py` so a future PRD can add non-pipelock
from `bot_bottle/docker.py` so a future PRD can add non-pipelock
network controls without entangling them with pipelock specifics.
This split mirrors the existing per-concern module pattern
@@ -114,7 +114,7 @@ This split mirrors the existing per-concern module pattern
### Existing code touched
- **`claude_bottle/cli/start.py`** — wire the new lifecycle into the
- **`bot_bottle/cli/start.py`** — wire the new lifecycle into the
`start` subcommand: create the internal network, launch the pipelock
sidecar, then launch the agent container with `HTTPS_PROXY` /
`HTTP_PROXY` set to the sidecar's service name. Add the resolved
@@ -129,9 +129,9 @@ This split mirrors the existing per-concern module pattern
the image. This keeps the image agnostic to whether a sidecar is in use
(useful if a future bottle definition opts out of the proxy for testing).
`claude_bottle/docker.py` may grow one or two helpers if there is a
`bot_bottle/docker.py` may grow one or two helpers if there is a
clean place for shared primitives, but the network-specific helpers
live in `claude_bottle/network.py`. Decide during implementation; not a
live in `bot_bottle/network.py`. Decide during implementation; not a
contract.
### Data model changes
@@ -176,7 +176,7 @@ bottle share the same allowlist.
- **Pipelock binary** is pulled from
`ghcr.io/luckypipewrench/pipelock@sha256:<digest>`. The digest is
pinned in `claude_bottle/pipelock.py` (or a sibling constants module)
pinned in `bot_bottle/pipelock.py` (or a sibling constants module)
and bumped deliberately, mirroring the claude-code version pinning
pattern in `Dockerfile`.
- No new host-side runtimes. The pipelock image is the only new
@@ -192,8 +192,8 @@ bottle share the same allowlist.
(proxy + 48 default DLP patterns + subdomain entropy + sidecar
topology) is expected to be core-only, but this should be confirmed.
- **Where to put the digest pin.** A constant in
`claude_bottle/pipelock.py` is the lowest-friction option; a separate
`claude_bottle/versions.py` (or similar) may be cleaner once there
`bot_bottle/pipelock.py` is the lowest-friction option; a separate
`bot_bottle/versions.py` (or similar) may be cleaner once there
are multiple pinned dependencies. Decide during implementation.
- **Per-agent overrides.** The PRD scopes egress to the bottle. If a
later use case calls for tightening (not loosening) the allowlist for
+24 -24
View File
@@ -14,7 +14,7 @@ second backend ships in this PRD.
## Problem
Today, "how to launch a bottle" is spread across roughly six modules
(`claude_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`,
(`bot_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`,
`skills.py`, `docker.py`), each shelling out to `docker` directly via
`subprocess.run(["docker", ...])`. That coupling means:
@@ -57,22 +57,22 @@ The feature works when all of the following are observable:
The feature is **done** when all of the following ship:
- A new `claude_bottle/backend/` package exists with abstract base
- A new `bot_bottle/backend/` package exists with abstract base
classes (`BottleBackend`, `BottlePlan`, `BottleCleanupPlan`,
`Bottle`) plus a `claude_bottle/backend/docker/` subpackage
`Bottle`) plus a `bot_bottle/backend/docker/` subpackage
containing the `DockerBottleBackend` implementation.
- `DockerBottleBackend.launch(plan)` returns a context manager
yielding a `Bottle` handle exposing `exec_claude(argv, *, tty=True)`,
`cp_in(host, ctr)`, and teardown on context exit.
- Every existing `subprocess.run(["docker", ...])` call in
`cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and
`skills.py` either moves into `claude_bottle/backend/docker/` or is
`skills.py` either moves into `bot_bottle/backend/docker/` or is
called from it. No top-level CLI code references `docker` directly.
- `bottles[].runtime` is removed from the manifest schema, the
dataclass in `manifest.py`, the example manifest, and any README /
docs references. `require_runsc()` in the old top-level
`claude_bottle/docker.py` is deleted.
- A single env var, `CLAUDE_BOTTLE_BACKEND` (default `"docker"`),
`bot_bottle/docker.py` is deleted.
- A single env var, `BOT_BOTTLE_BACKEND` (default `"docker"`),
selects the backend. Unknown values die at startup with a list of
known backends.
- The y/N preflight in `cli.py` includes the resolved Docker runtime
@@ -97,8 +97,8 @@ The feature is **done** when all of the following ship:
### In scope
- New `claude_bottle/backend/` package containing the abstract types
and the registry, plus a `claude_bottle/backend/docker/` subpackage
- New `bot_bottle/backend/` package containing the abstract types
and the registry, plus a `bot_bottle/backend/docker/` subpackage
containing the Docker implementation.
- The `Bottle`, `BottleBackend`, `BottlePlan`, and `BottleCleanupPlan`
abstract base classes; `BottleSpec` data carrier; and
@@ -136,10 +136,10 @@ The feature is **done** when all of the following ship:
### New services / components
A new package, `claude_bottle/backend/`, with an abstract base layer
A new package, `bot_bottle/backend/`, with an abstract base layer
and a Docker subpackage:
- **`claude_bottle/backend/__init__.py`** — Defines the abstract base
- **`bot_bottle/backend/__init__.py`** — Defines the abstract base
classes and the backend registry. `BottleSpec` carries the
CLI-supplied intent; the abstract `BottlePlan` and
`BottleCleanupPlan` are the prepared-but-not-launched outputs of
@@ -165,14 +165,14 @@ and a Docker subpackage:
`provision_git`); subclasses implement those four rather than
overriding `provision` itself.
Selection reads `CLAUDE_BOTTLE_BACKEND` (default `"docker"`).
Selection reads `BOT_BOTTLE_BACKEND` (default `"docker"`).
Unknown values call `die()` with the list of known backends:
```python
def get_bottle_backend() -> BottleBackend: ...
```
- **`claude_bottle/backend/docker/`** — Subpackage with the Docker
- **`bot_bottle/backend/docker/`** — Subpackage with the Docker
implementation, split into:
- `backend.py` — `DockerBottleBackend`, owning all five abstract
methods (`prepare`, `launch`, `prepare_cleanup`, `cleanup`,
@@ -196,49 +196,49 @@ and a Docker subpackage:
- `pipelock.py` — `DockerPipelockProxy` (the sidecar start/stop
lifecycle) and Docker-specific naming helpers. The backend-neutral
yaml + allowlist resolution stays in the top-level
`claude_bottle/pipelock.py`.
`bot_bottle/pipelock.py`.
- `util.py` — Docker-specific helpers (slugify, image/container
existence checks, `runsc_available`).
### Existing code touched
- **`claude_bottle/cli/start.py`** — replace the inline docker
- **`bot_bottle/cli/start.py`** — replace the inline docker
orchestration with `backend = get_bottle_backend(); plan =
backend.prepare(spec, stage_dir=...); with backend.launch(plan) as
bottle: bottle.exec_claude(...)`. The y/N preflight is rendered by
`plan.print(...)`.
- **`claude_bottle/manifest.py`** — drop the `runtime` field from the
- **`bot_bottle/manifest.py`** — drop the `runtime` field from the
Bottle dataclass and its validation. Existing manifests with
`runtime: "runsc"` produce a clear "no longer supported; gVisor is
now auto-detected by the backend; remove the 'runtime' field" error.
- **`claude_bottle/docker.py`** — module deleted. `require_runsc()`,
- **`bot_bottle/docker.py`** — module deleted. `require_runsc()`,
`slugify()`, `image_exists()`, `container_exists()`, the
`build_image` / `build_image_with_cwd` helpers, and `require_docker`
all migrate into `claude_bottle/backend/docker/util.py` (or
all migrate into `bot_bottle/backend/docker/util.py` (or
`backend.py`).
- **`claude_bottle/pipelock.py`** — keeps the allowlist resolution and
- **`bot_bottle/pipelock.py`** — keeps the allowlist resolution and
YAML generation. Becomes a thin abstract class (`PipelockProxy`)
exposing `prepare` (writes the yaml) plus abstract `start` / `stop`
methods. The Docker-specific subclass `DockerPipelockProxy` lives
under `backend/docker/pipelock.py`.
- **`claude_bottle/network.py`** — folds entirely into
- **`bot_bottle/network.py`** — folds entirely into
`backend/docker/network.py`. No top-level network module remains.
- **`claude_bottle/ssh.py`** and **`claude_bottle/skills.py`** —
- **`bot_bottle/ssh.py`** and **`bot_bottle/skills.py`** —
absorbed into `DockerBottleBackend` as `provision_ssh` and
`provision_skills`. The host-side file-tree generation stays as
private helpers on the backend class.
- **`claude_bottle/env.py`** (renamed from `env_resolve.py`) —
- **`bot_bottle/env.py`** (renamed from `env_resolve.py`) —
`resolve_env(manifest, agent) -> ResolvedEnv` returns
`forwarded: list[str]` (names whose values were exported into
`os.environ` for inheritance) and `literals: dict[str, str]` (name
→ verbatim value). The Docker backend translates the result into
`--env-file` content + `-e NAME` argv fragments.
- **`claude_bottle/util.py`** — top-level cross-backend helpers
- **`bot_bottle/util.py`** — top-level cross-backend helpers
(`expand_tilde`, `is_ipv4_literal`). Backend-specific helpers live
in their backend's `util.py`.
- **`claude-bottle.example.json`** — remove the `runtime` field from
- **`bot-bottle.example.json`** — remove the `runtime` field from
any example bottle.
- **`README.md`** — note `CLAUDE_BOTTLE_BACKEND` and the runsc
- **`README.md`** — note `BOT_BOTTLE_BACKEND` and the runsc
auto-detect; remove any mention of `runtime: "runsc"` as a manifest
field.
+10 -10
View File
@@ -6,12 +6,12 @@
## Summary
Break `claude_bottle/backend/docker/backend.py` (664 lines) apart by
Break `bot_bottle/backend/docker/backend.py` (664 lines) apart by
moving the four provisioner methods — `provision_prompt`,
`provision_skills`, `provision_ssh`, `provision_git` — out of
`DockerBottleBackend` into their own modules under
`claude_bottle/backend/docker/provision/`. The abstract base in
`claude_bottle/backend/__init__.py` keeps the same four-method
`bot_bottle/backend/docker/provision/`. The abstract base in
`bot_bottle/backend/__init__.py` keeps the same four-method
contract; only the Docker implementation changes shape.
## Problem
@@ -56,7 +56,7 @@ The feature works when all of the following are observable:
The feature is **done** when all of the following ship:
- A new `claude_bottle/backend/docker/provision/` subpackage exists
- A new `bot_bottle/backend/docker/provision/` subpackage exists
with one module per provisioner: `prompt.py`, `skills.py`, `ssh.py`,
`git.py`. Each exports a single top-level function taking
`(plan: DockerBottlePlan, target: str)` and returning the same type
@@ -66,7 +66,7 @@ The feature is **done** when all of the following ship:
`provision_ssh` / `provision_git` each become one-line delegations
to the new module functions.
- The abstract `BottleBackend.provision_*` signatures in
`claude_bottle/backend/__init__.py` are unchanged. The
`bot_bottle/backend/__init__.py` are unchanged. The
`BottleBackend.provision` orchestration in the base class is
unchanged.
- No top-level CLI code or other backend gains a direct import of the
@@ -99,7 +99,7 @@ The feature is **done** when all of the following ship:
### In scope
- New `claude_bottle/backend/docker/provision/` subpackage with
- New `bot_bottle/backend/docker/provision/` subpackage with
`__init__.py`, `prompt.py`, `skills.py`, `ssh.py`, `git.py`.
- Moving the four method bodies out of
`DockerBottleBackend` into the new modules verbatim, adjusting only
@@ -132,7 +132,7 @@ The feature is **done** when all of the following ship:
### New layout
```
claude_bottle/backend/docker/
bot_bottle/backend/docker/
backend.py # DockerBottleBackend (slimmer)
bottle.py
bottle_plan.py
@@ -199,13 +199,13 @@ take the concrete type and skip re-checking.
### Existing code touched
- **`claude_bottle/backend/docker/backend.py`** — four method
- **`bot_bottle/backend/docker/backend.py`** — four method
bodies move out; method definitions stay as one-line delegations.
Imports for `pipelock_proxy_host_port`, `expand_tilde`, etc., that
are only used by the moved bodies migrate with them.
- **`claude_bottle/backend/docker/__init__.py`** — no change. The
- **`bot_bottle/backend/docker/__init__.py`** — no change. The
public surface (`DockerBottleBackend`) is unchanged.
- **`claude_bottle/backend/__init__.py`** — no change.
- **`bot_bottle/backend/__init__.py`** — no change.
- **`tests/`** — no expected change. Existing tests exercise the
backend via `DockerBottleBackend` or the CLI surface; they don't
reach into provisioners directly. Verify after the move and only
+19 -19
View File
@@ -75,7 +75,7 @@ The feature is **done** when all of the following ship:
sidecar (read-only) so the running pipelock can read its CA.
- `BottleBackend.provision_ca` (new) copies the CA public cert
into the agent at
`/usr/local/share/ca-certificates/claude-bottle-mitm.crt`, runs
`/usr/local/share/ca-certificates/bot-bottle-mitm.crt`, runs
`update-ca-certificates`, and sets the `NODE_EXTRA_CA_CERTS` /
`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` env trio on the agent
container's runtime env. Default no-op on the abstract base so
@@ -122,14 +122,14 @@ The feature is **done** when all of the following ship:
### In scope
- **`claude_bottle/pipelock.py`** changes:
- **`bot_bottle/pipelock.py`** changes:
- Extend `pipelock_build_config` to include
`tls_interception: { enabled: true, ca_cert: <path>, ca_key:
<path> }`. Paths are populated from the plan; the function's
signature grows a `cert_path` / `key_path` pair or reads them
off `Bottle` once they're stored.
- Extend `pipelock_render_yaml` to emit the new block.
- **`claude_bottle/backend/docker/pipelock.py`** changes:
- **`bot_bottle/backend/docker/pipelock.py`** changes:
- New helper `pipelock_tls_init(stage_dir)` runs the upstream
image as a one-shot:
`docker run --rm -v <stage>:/h -e PIPELOCK_HOME=/h pipelock tls init`,
@@ -143,31 +143,31 @@ The feature is **done** when all of the following ship:
config. If pipelock's image runs as non-root, a `docker exec
-u 0 chown pipelock:pipelock /etc/pipelock/ca*.pem` lands
between the `cp` and the `start`.
- **`claude_bottle/backend/__init__.py`**: new abstract method
- **`bot_bottle/backend/__init__.py`**: new abstract method
`provision_ca(plan, target)` on `BottleBackend`, default no-op.
`BottleBackend.provision` orchestrates `ca → prompt → skills →
ssh → git`.
- **`claude_bottle/backend/docker/provision/ca.py`** (new):
- **`bot_bottle/backend/docker/provision/ca.py`** (new):
- Reads the cert from `stage_dir` (already written by prepare).
- `docker cp` into the agent.
- `docker exec -u 0 ... chmod 644 ...` + `update-ca-certificates`.
- Computes the SHA-256 fingerprint with stdlib (`ssl` +
`hashlib`), emits one stderr log line.
- **`claude_bottle/backend/docker/launch.py`**:
- **`bot_bottle/backend/docker/launch.py`**:
- Three new `-e` flags on the agent's `docker run`:
`NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/claude-bottle-mitm.crt`,
`NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm.crt`,
`SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`,
`REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`.
- `HTTPS_PROXY` / `HTTP_PROXY` continue to point at pipelock
(unchanged from PRD 0001 — the mitmproxy detour in PR #8 is
abandoned).
- **`claude_bottle/backend/docker/bottle_plan.py`**:
- **`bot_bottle/backend/docker/bottle_plan.py`**:
- One new `info(...)` line in `print()` noting TLS interception
is on.
- `to_dict()` gains an `egress.tls_interception: { enabled:
true, ca_fingerprint: null }` block. Reserved for future
population.
- **`claude_bottle/backend/docker/prepare.py`**: call
- **`bot_bottle/backend/docker/prepare.py`**: call
`pipelock_tls_init(stage_dir)` and write the resolved cert/key
paths onto the plan (either on the existing `proxy_plan` field
or on the parent `DockerBottlePlan`).
@@ -221,7 +221,7 @@ generated at prepare time.
the one-shot generation step. The rendered YAML references
the in-container paths.
- **Bottle install.** `provision_ca` (Docker impl) does
`docker cp <stage>/ca.pem agent:/usr/local/share/ca-certificates/claude-bottle-mitm.crt`,
`docker cp <stage>/ca.pem agent:/usr/local/share/ca-certificates/bot-bottle-mitm.crt`,
then `update-ca-certificates`. The CA env trio is set at
`docker run -e` time (Docker propagates run-time env into
`docker exec`).
@@ -235,7 +235,7 @@ generated at prepare time.
`stage_dir`. CA dies with both, in that order, so the sidecar
is never reading a deleted mount on shutdown.
- **Fingerprint.** Computed via stdlib in `provision_ca` and
logged once to stderr (`claude-bottle: mitm ca fingerprint:
logged once to stderr (`bot-bottle: mitm ca fingerprint:
sha256:<hex>…`). The private key never appears in any log.
### Data model changes
@@ -248,18 +248,18 @@ always null at dry-run because the CA doesn't exist yet.
Surgical, all on the existing pipelock path:
- `claude_bottle/pipelock.py` — config builder + YAML renderer.
- `claude_bottle/backend/__init__.py` — abstract `provision_ca`.
- `claude_bottle/backend/docker/pipelock.py` — `tls init` helper,
- `bot_bottle/pipelock.py` — config builder + YAML renderer.
- `bot_bottle/backend/__init__.py` — abstract `provision_ca`.
- `bot_bottle/backend/docker/pipelock.py` — `tls init` helper,
sidecar volume mount.
- `claude_bottle/backend/docker/prepare.py` — CA paths on plan.
- `claude_bottle/backend/docker/launch.py` — CA env trio on agent.
- `claude_bottle/backend/docker/backend.py` — `provision_ca`
- `bot_bottle/backend/docker/prepare.py` — CA paths on plan.
- `bot_bottle/backend/docker/launch.py` — CA env trio on agent.
- `bot_bottle/backend/docker/backend.py` — `provision_ca`
dispatch + thread `self._proxy` through prepare/launch unchanged
shape.
- `claude_bottle/backend/docker/bottle_plan.py` — preflight
- `bot_bottle/backend/docker/bottle_plan.py` — preflight
rendering.
- `claude_bottle/backend/docker/provision/ca.py` (new).
- `bot_bottle/backend/docker/provision/ca.py` (new).
Net diff is meaningfully smaller than PR #8 because pipelock
already does the work — no addon, no second sidecar, no second
+11 -11
View File
@@ -95,14 +95,14 @@ back to green is the test.
Mirror the pipelock layout:
- **`claude_bottle/ssh_gate.py`** (new): abstract `SSHGate` +
- **`bot_bottle/ssh_gate.py`** (new): abstract `SSHGate` +
`SSHGatePlan` dataclass. `prepare` is host-side / side-effect-free
on docker; renders the forwarder config under `stage_dir`.
- **`claude_bottle/backend/docker/ssh_gate.py`** (new):
- **`bot_bottle/backend/docker/ssh_gate.py`** (new):
`DockerSSHGate` concrete subclass — `start` does `docker create`
on the internal network, copies the config in, attaches the
egress network, `docker start`. `stop` is idempotent `docker rm
-f`. Container name: `claude-bottle-ssh-gate-<slug>`.
-f`. Container name: `bot-bottle-ssh-gate-<slug>`.
Forwarder image: `alpine/socat`, pinned by digest. Must be
self-sufficient at boot (no apk/apt pulls on first run) because
@@ -126,7 +126,7 @@ rejected at prepare time. One container, N listeners, N upstreams.
### Existing code touched
- **`claude_bottle/backend/docker/provision/ssh.py`**: drop the
- **`bot_bottle/backend/docker/provision/ssh.py`**: drop the
`ProxyCommand socat - PROXY:...` plumbing and the
`pipelock_proxy_host_port` import. The rendered `~/.ssh/config`
block per entry becomes:
@@ -140,19 +140,19 @@ rejected at prepare time. One container, N listeners, N upstreams.
`known_hosts` entries are keyed off `<name>` and the new
`[<gate-container>]:<listen-port>` form so OpenSSH's strict
host-key checking still matches.
- **`claude_bottle/pipelock.py`**: delete
- **`bot_bottle/pipelock.py`**: delete
`pipelock_bottle_ssh_hostnames`, `pipelock_bottle_ssh_trusted_domains`,
`pipelock_bottle_ssh_ip_cidrs`, and the calls into them from
`pipelock_effective_allowlist` and `pipelock_build_config`. The
effective allowlist becomes baked-defaults `bottle.egress.allowlist`.
- **`claude_bottle/backend/docker/backend.py`**: instantiate
- **`bot_bottle/backend/docker/backend.py`**: instantiate
`DockerSSHGate` alongside `DockerPipelockProxy`; thread its
`prepare` / `start` / `stop` through `resolve_plan` / `launch`.
- **`claude_bottle/backend/docker/launch.py`**: add gate start /
- **`bot_bottle/backend/docker/launch.py`**: add gate start /
stop to the `ExitStack` in the right order — gate must be up
before `provision_ssh` runs so the agent can dial it on first
boot.
- **`claude_bottle/backend/docker/bottle_plan.py`**: new
- **`bot_bottle/backend/docker/bottle_plan.py`**: new
`SSHGatePlan` field on `DockerBottlePlan`; preflight rendering
surfaces the gate sidecar (name, per-entry listen ports,
upstream `Hostname:Port` targets).
@@ -165,7 +165,7 @@ rejected at prepare time. One container, N listeners, N upstreams.
### Data model changes
None. `bottle.ssh` schema is unchanged; one new internal plan
dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
dataclass (`SSHGatePlan`) under `bot_bottle/ssh_gate.py`.
### External dependencies
@@ -202,7 +202,7 @@ dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
- PRD 0006: pipelock native TLS interception — the change that
surfaced this regression by making pipelock incompatible with
SSH-over-CONNECT.
- `claude_bottle/backend/docker/provision/ssh.py` — current SSH
- `bot_bottle/backend/docker/provision/ssh.py` — current SSH
provisioning that this PRD rewrites.
- `claude_bottle/pipelock.py` — current pipelock config builder
- `bot_bottle/pipelock.py` — current pipelock config builder
that gains the `bottle.ssh`-derived fields this PRD removes.
+10 -10
View File
@@ -26,7 +26,7 @@ entry and pushes straight at gitea/github with ssh-gate doing dumb
L4 forwarding. There is no boundary between "the agent thinks this
commit is fine" and "the secret hits an external remote." If a
compromised or careless agent stages a `.env`, slips a token into
a fixture, or commits the `CLAUDE_BOTTLE_OAUTH_TOKEN` itself, `git
a fixture, or commits the `BOT_BOTTLE_OAUTH_TOKEN` itself, `git
push` ships it.
Host-side pre-commit / pre-push hooks are the usual defense, but
@@ -131,16 +131,16 @@ for a declared upstream:
Mirror the existing sidecar layout:
- **`claude_bottle/git_gate.py`** (new): abstract `GitGate` +
- **`bot_bottle/git_gate.py`** (new): abstract `GitGate` +
`GitGatePlan` dataclass. `prepare` is host-side / side-effect-
free on docker; renders the per-upstream config and stages the
push credentials under `stage_dir`.
- **`claude_bottle/backend/docker/git_gate.py`** (new):
- **`bot_bottle/backend/docker/git_gate.py`** (new):
`DockerGitGate` concrete subclass. `start` does `docker create`
on the internal network, copies in the bare-repo skeleton, the
hook script, and per-upstream credentials, then `docker start`.
`stop` is idempotent `docker rm -f`. Container name:
`claude-bottle-git-gate-<slug>`.
`bot-bottle-git-gate-<slug>`.
Gate image: `git-daemon` + `openssh-client` over a
`zricethezav/gitleaks` base (alpine + gitleaks), pinned by digest.
@@ -173,21 +173,21 @@ operation.
### Existing code touched
- **`claude_bottle/manifest.py`**: parse and validate the new
- **`bot_bottle/manifest.py`**: parse and validate the new
`bottle.git` block; reject `bottle.ssh` entries whose upstream
is also claimed by a `bottle.git` upstream (one path per
remote, no shadow route).
- **`claude_bottle/backend/docker/provision/git.py`** (new) or an
- **`bot_bottle/backend/docker/provision/git.py`** (new) or an
extension of the ssh provisioner: render the `insteadOf` config
and any extra `~/.gitconfig` plumbing.
- **`claude_bottle/backend/docker/backend.py`**: instantiate
- **`bot_bottle/backend/docker/backend.py`**: instantiate
`DockerGitGate` alongside `DockerPipelockProxy` and
`DockerSSHGate`; thread its `prepare` / `start` / `stop`
through `resolve_plan` / `launch`.
- **`claude_bottle/backend/docker/launch.py`**: add gate start /
- **`bot_bottle/backend/docker/launch.py`**: add gate start /
stop to the `ExitStack` so the gate is up before any
provisioner that writes the agent's `~/.gitconfig`.
- **`claude_bottle/backend/docker/bottle_plan.py`**: new
- **`bot_bottle/backend/docker/bottle_plan.py`**: new
`GitGatePlan` field on `DockerBottlePlan`; preflight rendering
surfaces the gate sidecar (name, per-upstream local paths,
upstream real URLs, which credential is in use).
@@ -249,6 +249,6 @@ exposes it as, and the credential the gate uses to push upstream
- PRD 0007: SSH egress gate — the L4 SSH forwarder this PRD
sits alongside; explicitly *not* the place to add
git-protocol awareness.
- `claude_bottle/ssh_gate.py` / `claude_bottle/pipelock.py`
- `bot_bottle/ssh_gate.py` / `bot_bottle/pipelock.py`
existing sidecar abstractions to mirror.
- gitleaks: <https://github.com/gitleaks/gitleaks>
+15 -15
View File
@@ -8,7 +8,7 @@
Delete the ssh-gate sidecar and the `bottle.ssh` manifest field.
Git-gate (PRD 0008) covers every current SSH use case in
claude-bottle: each declared upstream gets a per-bottle gate
bot-bottle: each declared upstream gets a per-bottle gate
with gitleaks scanning, an `insteadOf` rewrite that captures
push / fetch / clone / pull / ls-remote, and credential
isolation from the agent. ssh-gate is now redundant L4
@@ -76,11 +76,11 @@ the unused path.
`_validate_no_shadow_route`. Add an explicit branch in
`Bottle.from_dict` that dies on a `ssh` key with a one-line
"move this to `bottle.git` (see PRD 0008)" hint.
- **Sidecar.** Delete `claude_bottle/ssh_gate.py` and
`claude_bottle/backend/docker/ssh_gate.py`. Drop the socat
- **Sidecar.** Delete `bot_bottle/ssh_gate.py` and
`bot_bottle/backend/docker/ssh_gate.py`. Drop the socat
image build path.
- **Provisioner.** Delete
`claude_bottle/backend/docker/provision/ssh.py` and its
`bot_bottle/backend/docker/provision/ssh.py` and its
`~/.ssh/config` render.
- **Docker backend wiring.** Drop `DockerSSHGate` from
`backend.py`; drop its start / stop from `launch.py`'s
@@ -98,7 +98,7 @@ the unused path.
- **README.** Drop the socat / ssh image box from the
architecture diagram and its bullet; drop `ssh:` from the
manifest example.
- **Example manifest.** Drop `ssh:` from `claude-bottle.example.json`.
- **Example manifest.** Drop `ssh:` from `bot-bottle.example.json`.
- **PRD 0007.** Add a `Status: Superseded by PRD 0009` header
at the top of the document. Do not delete the file; the
history of intent matters for the audit trail.
@@ -138,19 +138,19 @@ the seams between ssh-gate and the rest of the system:
### Existing code touched
- `claude_bottle/manifest.py` — delete `SshEntry`,
- `bot_bottle/manifest.py` — delete `SshEntry`,
`Bottle.ssh`, `_validate_no_shadow_route`; add the
parse-fail branch.
- `claude_bottle/ssh_gate.py` — delete.
- `claude_bottle/backend/docker/ssh_gate.py` — delete.
- `claude_bottle/backend/docker/provision/ssh.py` — delete.
- `claude_bottle/backend/docker/backend.py` — drop
- `bot_bottle/ssh_gate.py` — delete.
- `bot_bottle/backend/docker/ssh_gate.py` — delete.
- `bot_bottle/backend/docker/provision/ssh.py` — delete.
- `bot_bottle/backend/docker/backend.py` — drop
`DockerSSHGate` instantiation.
- `claude_bottle/backend/docker/launch.py` — drop the
- `bot_bottle/backend/docker/launch.py` — drop the
ssh-gate start / stop from the `ExitStack`.
- `claude_bottle/backend/docker/bottle_plan.py` — drop the
- `bot_bottle/backend/docker/bottle_plan.py` — drop the
ssh-gate plan field.
- `claude_bottle/pipelock.py` — drop the `bottle.ssh`-derived
- `bot_bottle/pipelock.py` — drop the `bottle.ssh`-derived
branch in the allowlist render.
- `tests/unit/test_ssh_gate.py` — delete.
- `tests/integration/` — delete any ssh-gate-specific tests.
@@ -160,7 +160,7 @@ the seams between ssh-gate and the rest of the system:
helper.
- `README.md` — drop the socat image box from the diagram and
the matching bullet; drop `ssh:` from the manifest example.
- `claude-bottle.example.json` — drop the `ssh` field.
- `bot-bottle.example.json` — drop the `ssh` field.
- `docs/prds/0007-ssh-egress-gate.md` — add a
`Status: Superseded by PRD 0009` header at the top.
@@ -173,7 +173,7 @@ the seams between ssh-gate and the rest of the system:
### External dependencies
Nothing added. The `alpine/socat` image is no longer pulled
by claude-bottle; the cleanup of any existing local image is
by bot-bottle; the cleanup of any existing local image is
the user's choice (a single `docker image rm` if they care).
## Future work
+16 -16
View File
@@ -51,7 +51,7 @@ already rely on.
The research note
[`agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md)
surveys the existing tools and concludes that a small
claude-bottle-specific reverse proxy is less work and less risk
bot-bottle-specific reverse proxy is less work and less risk
than either adopting nono (alpha, unaudited) or Infisical Agent
Vault (TLS-MITM topology that doubles up on pipelock's CA stack).
This PRD is the build.
@@ -118,7 +118,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
- **Cross-bottle credential sharing.** One proxy per bottle, same
one-sidecar-per-agent posture as pipelock and git-gate.
- **`claude --bare` mode.** Reads only `ANTHROPIC_API_KEY`, not
the OAuth token. Not in claude-bottle's flow today.
the OAuth token. Not in bot-bottle's flow today.
- **MCP-server tokens, package-installer tokens for languages
beyond npm.** PyPI / Bun / cargo can land in a follow-up if
needed; the routing pattern generalizes.
@@ -175,7 +175,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
side-effect-free; `start` does `docker create` + `docker start`
on the bottle's internal network with hostname `cred-proxy`;
`stop` is idempotent `docker rm -f`. Container name:
`claude-bottle-cred-proxy-<slug>`. The agent container starts
`bot-bottle-cred-proxy-<slug>`. The agent container starts
after the sidecar is up so DNS resolution succeeds on the
agent's first call.
- **pipelock interop.** cred-proxy's outbound HTTPS traverses
@@ -230,7 +230,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
```
┌── Host (macOS) ──────────────────────────────────────────────────┐
│ Secrets at rest (keychain / .env): │
CLAUDE_BOTTLE_OAUTH_TOKEN, GITHUB_TOKEN, │
BOT_BOTTLE_OAUTH_TOKEN, GITHUB_TOKEN, │
│ GITEA_SERVER_TOKEN, NPM_TOKEN │
│ │ docker run -e KEY (no =VALUE on argv) │
│ ▼ │
@@ -288,18 +288,18 @@ Why the agent can't reach the sidecar's environ:
### New components
- **`claude_bottle/cred_proxy.py`** (new): abstract `CredProxy`
- **`bot_bottle/cred_proxy.py`** (new): abstract `CredProxy`
+ `CredProxyPlan` dataclass. `prepare` is host-side and
side-effect-free; renders the route table and resolves
`TokenRef`s against host env. Mirrors the existing `GitGate` /
`Pipelock` shape.
- **`claude_bottle/backend/docker/cred_proxy.py`** (new):
- **`bot_bottle/backend/docker/cred_proxy.py`** (new):
`DockerCredProxy` concrete subclass. `start` does
`docker create` on the bottle's internal network with hostname
`cred-proxy`, copies the route-table file into the container,
then `docker start`. `stop` is idempotent `docker rm -f`.
Container name: `claude-bottle-cred-proxy-<slug>`.
- **`claude_bottle/backend/docker/provision/cred_proxy.py`**
Container name: `bot-bottle-cred-proxy-<slug>`.
- **`bot_bottle/backend/docker/provision/cred_proxy.py`**
(new): renders `ANTHROPIC_BASE_URL`, `~/.npmrc`,
`~/.gitconfig` `insteadOf` blocks, and `~/.config/tea/config.yml`
into the agent's home for each declared kind — all pointing at
@@ -310,12 +310,12 @@ Why the agent can't reach the sidecar's environ:
### Existing code touched
- **`claude_bottle/manifest.py`** — add `CredProxyRoute`,
- **`bot_bottle/manifest.py`** — add `CredProxyRoute`,
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
+ validate route shape, role enum, path uniqueness, singleton-
role constraints.
- **`claude_bottle/backend/docker/prepare.py`** — drop the
legacy `CLAUDE_BOTTLE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN`
- **`bot_bottle/backend/docker/prepare.py`** — drop the
legacy `BOT_BOTTLE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN`
forward entirely. cred-proxy is the only path the Anthropic
OAuth token reaches the bottle. When a route claims the
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL`
@@ -324,27 +324,27 @@ Why the agent can't reach the sidecar's environ:
otherwise; the proxy strips & replaces on every request).
Bottles that need claude-code to authenticate must declare
the route; there is no fallback.
- **`claude_bottle/backend/docker/backend.py`** — instantiate
- **`bot_bottle/backend/docker/backend.py`** — instantiate
`DockerCredProxy` alongside `DockerPipelockProxy` and
`DockerGitGate`; thread its `prepare` / `start` / `stop`
through `resolve_plan` / `launch`.
- **`claude_bottle/backend/docker/launch.py`** — add cred-proxy
- **`bot_bottle/backend/docker/launch.py`** — add cred-proxy
start/stop to the `ExitStack` after pipelock and before the
agent; populate `pipelock_proxy_url` + `pipelock_ca_host_path`
on the cred-proxy plan so its outbound HTTPS routes through
pipelock.
- **`claude_bottle/backend/docker/bottle_plan.py`** — new
- **`bot_bottle/backend/docker/bottle_plan.py`** — new
`cred_proxy_plan` field; preflight shows route count + token
refs + a path→upstream line per route; `to_dict` emits a
`cred_proxy` array of `{path, upstream, auth_scheme, token_ref,
roles}`.
- **`claude_bottle/pipelock.py`** — `pipelock_token_hosts` derives
- **`bot_bottle/pipelock.py`** — `pipelock_token_hosts` derives
from each route's `UpstreamHost` (not a hardcoded Kind→hosts
map). Allowlist auto-includes them; passthrough does not (the
proxy trusts pipelock's CA so MITM works).
- **`README.md`** — architecture diagram includes the cred-proxy
lane; manifest section documents `bottle.cred_proxy.routes`.
- **`claude-bottle.example.json`** — one bottle demonstrates the
- **`bot-bottle.example.json`** — one bottle demonstrates the
four common routes (Anthropic, GitHub, Gitea, npm).
- **Tests** — manifest parsing/validation, route lift + token-env
slot assignment, role-based dispatch in the provisioner,
+42 -42
View File
@@ -6,11 +6,11 @@
## Summary
Replace the single-file `claude-bottle.json` manifest with a
Replace the single-file `bot-bottle.json` manifest with a
per-file Markdown-with-YAML-frontmatter layout. Bottles live as
`$HOME/.claude-bottle/bottles/<name>.md`; agents live as
`$HOME/.claude-bottle/agents/<name>.md` (home-resident) and
`$CWD/.claude-bottle/agents/<name>.md` (repo-supplied). Each file
`$HOME/.bot-bottle/bottles/<name>.md`; agents live as
`$HOME/.bot-bottle/agents/<name>.md` (home-resident) and
`$CWD/.bot-bottle/agents/<name>.md` (repo-supplied). Each file
carries its structured config in YAML frontmatter and (for agents)
its system prompt in the Markdown body.
@@ -28,7 +28,7 @@ PyYAML dependency. The project's "low deps by default" stance
## Problem
`claude-bottle.json` works fine at one bottle and one agent. The
`bot-bottle.json` works fine at one bottle and one agent. The
project is heading for many of both, and the single-JSON shape
starts to fray:
@@ -60,22 +60,22 @@ axes (grouping × format) and lands on this design.
Each test runs against a temporary `$HOME` and a temporary `$CWD`:
1. **A bottle file under `$HOME/.claude-bottle/bottles/`
1. **A bottle file under `$HOME/.bot-bottle/bottles/`
parses.** A `dev.md` file with YAML frontmatter declaring
`cred_proxy.routes`, `git`, `env`, `egress` produces a Bottle
dataclass equivalent to the current JSON shape.
2. **An agent file under `$HOME/.claude-bottle/agents/` parses.**
2. **An agent file under `$HOME/.bot-bottle/agents/` parses.**
`implementer.md` with frontmatter that names `bottle:`,
`skills:`, and other fields, with the body as the system
prompt, produces an Agent dataclass.
3. **An agent file under `$CWD/.claude-bottle/agents/` parses
3. **An agent file under `$CWD/.bot-bottle/agents/` parses
and overrides home-resident agents of the same name.** The
cwd agent's frontmatter and body win; the home bottle it
references stays intact.
4. **A bottle file under `$CWD/.claude-bottle/bottles/` is
4. **A bottle file under `$CWD/.bot-bottle/bottles/` is
ignored.** The directory does not contribute to the
manifest; if a user accidentally creates one, the launcher
emits a `warn`-level log naming the offending files and
@@ -83,7 +83,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
is a usability nicety, not a security gate.
5. **No third-party Python dependencies introduced.** A fresh
clone with only stdlib + claude-bottle's own code runs every
clone with only stdlib + bot-bottle's own code runs every
parser test. Frontmatter parsing is hand-rolled against the
declared YAML subset.
@@ -97,30 +97,30 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
`name`, `description`, `model`, `color`, and `memory` fields
from Claude Code's existing subagent spec are accepted in
our frontmatter alongside our own fields. Copying an agent
file from `$HOME/.claude-bottle/agents/` to
file from `$HOME/.bot-bottle/agents/` to
`~/.claude/agents/` produces a working Claude Code subagent
(subject to Claude Code's tolerance for the extra `bottle:`
and `claude_bottle:` fields — see Open Questions).
and `bot_bottle:` fields — see Open Questions).
## Non-goals
- **A general YAML implementation.** The parser handles the
subset claude-bottle's frontmatter actually uses; documents
subset bot-bottle's frontmatter actually uses; documents
that exceed the subset (anchors, multi-line block scalars,
tags, implicit type coercion, flow style, etc.) die with a
pointer at the spec. We are not building a YAML library.
- **Compatibility with the old JSON layout at runtime.** The
resolver no longer reads `claude-bottle.json` files. This is
resolver no longer reads `bot-bottle.json` files. This is
a breaking change; existing users hand-rewrite their JSON
into the new per-file layout (claude-bottle has a single
into the new per-file layout (bot-bottle has a single
primary user today, so the migration is one person rewriting
one file). Documented as part of the README rewrite.
- **`$HOME/.claude/agents/` integration on the input side.** We
don't read agent files out of Claude Code's directory. Our
files can be copied into Claude Code's tree by the user if
they want, but the input path for claude-bottle is its own
they want, but the input path for bot-bottle is its own
directory.
- **A signed-manifest scheme.** Out of scope per the
@@ -139,14 +139,14 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
### In scope
- **Directory layout.**
- `$HOME/.claude-bottle/bottles/<name>.md` — bottle
- `$HOME/.bot-bottle/bottles/<name>.md` — bottle
definitions (full schema; one Bottle per file).
- `$HOME/.claude-bottle/agents/<name>.md` — home-resident
- `$HOME/.bot-bottle/agents/<name>.md` — home-resident
agents.
- `$CWD/.claude-bottle/agents/<name>.md` — cwd-resident
- `$CWD/.bot-bottle/agents/<name>.md` — cwd-resident
agents; same schema as home agents, but bottle names must
resolve against the home set.
- `$CWD/.claude-bottle/bottles/` — ignored with a warn-level
- `$CWD/.bot-bottle/bottles/` — ignored with a warn-level
log (see SC #4). Does not contribute to the manifest.
- `<name>` is the file basename without `.md`. Filenames must
match `[a-z][a-z0-9-]*` (kebab-case, ASCII-only).
@@ -162,7 +162,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
- `skills: [<name>, ...]` (optional) — host-side skills under
`~/.claude/skills/`.
- `name`, `description`, `model`, `color`, `memory` — accepted
but treated as Claude Code passthrough; claude-bottle
but treated as Claude Code passthrough; bot-bottle
ignores them at launch but doesn't reject. Lets the same
file double as a Claude Code subagent.
- Unknown top-level keys die with a hint listing accepted
@@ -191,17 +191,17 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
the required-keys check — same diagnostic as malformed).
- **Manifest assembly.** New resolver:
1. Walk `$HOME/.claude-bottle/bottles/*.md` → Bottle dict
1. Walk `$HOME/.bot-bottle/bottles/*.md` → Bottle dict
keyed by filename.
2. Walk `$HOME/.claude-bottle/agents/*.md` → Agent dict.
3. Walk `$CWD/.claude-bottle/agents/*.md` → Agent dict; merge
2. Walk `$HOME/.bot-bottle/agents/*.md` → Agent dict.
3. Walk `$CWD/.bot-bottle/agents/*.md` → Agent dict; merge
into the home agent dict, cwd wins on name collision.
4. Validate every agent's `bottle:` against the bottle dict.
5. Warn if `$CWD/.claude-bottle/bottles/` exists with files.
5. Warn if `$CWD/.bot-bottle/bottles/` exists with files.
6. Return Manifest dataclass — same shape as today.
- **Docs.** README's manifest section rewrites against the new
layout. `claude-bottle.example.json` becomes
layout. `bot-bottle.example.json` becomes
`examples/bottles/dev.md` + `examples/agents/implementer.md`.
The PRD 0010 example block in its own document gets a
follow-up commit noting the new layout (out of scope for
@@ -233,7 +233,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
### File layout
```
$HOME/.claude-bottle/
$HOME/.bot-bottle/
├── bottles/
│ ├── dev.md
│ ├── gitea-dev.md
@@ -243,7 +243,7 @@ $HOME/.claude-bottle/
├── researcher.md
└── ...
$CWD/.claude-bottle/
$CWD/.bot-bottle/
└── agents/
└── <repo-specific>.md
```
@@ -261,7 +261,7 @@ cred_proxy:
- path: /anthropic/
upstream: https://api.anthropic.com
auth_scheme: Bearer
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
token_ref: BOT_BOTTLE_OAUTH_TOKEN
role: anthropic-base-url
- path: /gitea/dideric/
upstream: https://gitea.dideric.is
@@ -271,8 +271,8 @@ cred_proxy:
git:
remotes:
gitea.dideric.is:
Name: claude-bottle
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
Name: bot-bottle
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
IdentityFile: ~/.ssh/gitea-delos-2.pem
ExtraHosts:
gitea.dideric.is: 100.78.141.42
@@ -302,7 +302,7 @@ skills:
---
You are a feature-implementation agent running inside an
ephemeral claude-bottle sandbox...
ephemeral bot-bottle sandbox...
```
Drop the same file into `~/.claude/agents/implementer.md` and
@@ -336,7 +336,7 @@ Notable rejections (each dies with a specific error):
be ambiguous, quote it.
- Flow style mappings nested more than one level deep.
Parser lives at `claude_bottle/yaml_subset.py`, ~300 lines.
Parser lives at `bot_bottle/yaml_subset.py`, ~300 lines.
Public API:
```python
@@ -348,14 +348,14 @@ def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
### Existing code touched
- **`claude_bottle/manifest.py`** — `Manifest.resolve` rewritten
- **`bot_bottle/manifest.py`** — `Manifest.resolve` rewritten
to walk the new directories. `Manifest.from_json_obj` kept as
a programmatic entry point (used by tests). New
`Manifest.from_md_dirs(home_dir, cwd_dir)` for the loader.
- **`claude_bottle/yaml_subset.py`** — new. The parser.
- **`bot_bottle/yaml_subset.py`** — new. The parser.
- **`README.md`** — manifest section rewritten against the new
layout.
- **`claude-bottle.example.json`** — removed; replaced by an
- **`bot-bottle.example.json`** — removed; replaced by an
`examples/` directory with one bottle file + one agent file.
- **Tests** — new parser tests + new loader tests; existing
manifest tests adapt to either build via `from_json_obj`
@@ -368,12 +368,12 @@ etc. all stay the same shape. Only the loader changes.
### Backward compatibility
This is a breaking change for v1 users. claude-bottle has a
This is a breaking change for v1 users. bot-bottle has a
single primary user today, so migration is one person rewriting
one file — no automated migration command is in scope.
If `claude-bottle.json` exists in `$HOME` or `$CWD` *and* the
new `.claude-bottle/` directory does not exist, the resolver
If `bot-bottle.json` exists in `$HOME` or `$CWD` *and* the
new `.bot-bottle/` directory does not exist, the resolver
dies with a clear pointer at the README's manifest section —
not silently merging formats, not silently dropping the JSON
content.
@@ -384,11 +384,11 @@ content.
empirically before settling: drop a file with `bottle: dev`
in `~/.claude/agents/` and see whether Claude Code warns,
ignores, or breaks. If it warns, namespace the field
(`claude-bottle-bottle:` or a nested `claude_bottle:` block).
- **Hidden directory vs visible.** Default `.claude-bottle/`
(`bot-bottle-bottle:` or a nested `bot_bottle:` block).
- **Hidden directory vs visible.** Default `.bot-bottle/`
(hidden — matches `.config/`, `.ssh/`, `.docker/`). If users
routinely want to navigate to it from the file manager,
switch to `claude-bottle/`. Lean hidden.
switch to `bot-bottle/`. Lean hidden.
- **`description:` for bottles.** Should bottle frontmatter
carry a `description:` field for the y/N preflight? Default
no — bottle names are kebab-case and self-describing, and
+2 -2
View File
@@ -6,7 +6,7 @@
## Summary
When an agent running inside a claude-bottle container gets blocked, it invokes one of three MCP tool calls — `cred-proxy-block`, `pipelock-block`, or `capability-block` — passing a *proposed* config change (modified `routes.json`, modified pipelock allowlist, or modified agent Dockerfile) plus text describing why the change is justified. The supervisor sees the proposal in a host-side TUI, approves / modifies / rejects it, and the corresponding remediation runs: SIGHUP-reload cred-proxy with the new routes; restart pipelock with the new allowlist; rebuild the bottle from the new Dockerfile on the same branch. The agent's tool call blocks until the operator acts. The supervisor never opens a live channel into a running bottle; all signal flow goes through a per-bottle MCP sidecar on the existing internal network.
When an agent running inside a bot-bottle container gets blocked, it invokes one of three MCP tool calls — `cred-proxy-block`, `pipelock-block`, or `capability-block` — passing a *proposed* config change (modified `routes.json`, modified pipelock allowlist, or modified agent Dockerfile) plus text describing why the change is justified. The supervisor sees the proposal in a host-side TUI, approves / modifies / rejects it, and the corresponding remediation runs: SIGHUP-reload cred-proxy with the new routes; restart pipelock with the new allowlist; rebuild the bottle from the new Dockerfile on the same branch. The agent's tool call blocks until the operator acts. The supervisor never opens a live channel into a running bottle; all signal flow goes through a per-bottle MCP sidecar on the existing internal network.
This PRD is the overview. Implementation is split across four follow-on PRDs (00130016); see *Implementation chunks* below.
@@ -29,7 +29,7 @@ A real stuck agent recovers end-to-end in each of the three categories: a **cred
Three named categories, each with its own MCP tool. Ordered by remediation cost:
- **cred-proxy block.** Tool: `cred-proxy-block`. The agent's request was refused by cred-proxy — missing route, expired token, wrong scope. The agent reads the current `routes.json` from `/etc/claude-bottle/current-config/`, composes a modified version, and calls the tool with `{routes: <new file>, justification: "..."}`. The operator reviews the diff in the TUI; on approval, the supervisor writes the new `routes.json` and cred-proxy SIGHUP-reloads. In-flight connections are not dropped. The tool returns `{status: "approved", notes: "..."}` and the agent retries. Implementation: PRD 0014.
- **cred-proxy block.** Tool: `cred-proxy-block`. The agent's request was refused by cred-proxy — missing route, expired token, wrong scope. The agent reads the current `routes.json` from `/etc/bot-bottle/current-config/`, composes a modified version, and calls the tool with `{routes: <new file>, justification: "..."}`. The operator reviews the diff in the TUI; on approval, the supervisor writes the new `routes.json` and cred-proxy SIGHUP-reloads. In-flight connections are not dropped. The tool returns `{status: "approved", notes: "..."}` and the agent retries. Implementation: PRD 0014.
- **pipelock block.** Tool: `pipelock-block`. The agent's outbound request was refused by pipelock — host not in the allowlist, protocol not permitted. The agent reads the current allowlist, composes a modified version, and calls the tool with `{allowlist: <new file>, justification: "..."}`. On approval, the supervisor writes the new allowlist and restarts pipelock; in-flight outbound calls may drop and rely on retry. The tool returns the same approve/reject shape. Implementation: PRD 0015.
- **capability block.** Tool: `capability-block`. The bottle is missing a tool, skill, permission, or env var the agent needs — something that lives in the agent Dockerfile rather than in routes or the pipelock allowlist. The agent reads the current Dockerfile, composes a modified version, and calls the tool with `{dockerfile: <new file>, justification: "..."}`. On approval, the rebuild orchestrator tears down the bottle, builds from the new Dockerfile, and starts a replacement bottle on the same branch via the state-preservation helper. Because the current agent is about to be replaced, the tool's return is best-effort — the replacement agent inherits the approval record via the preserved transcript. Implementation: PRD 0016.
+9 -9
View File
@@ -7,7 +7,7 @@
## Summary
The shared infrastructure that PRDs 00140016 build on. Adds a per-bottle MCP sidecar that exposes three tools (`cred-proxy-block`, `pipelock-block`, `capability-block`) to the agent; a read-only `/etc/claude-bottle/current-config/` mount in the agent container that exposes the current `routes.json`, pipelock allowlist, and Dockerfile; a host-mounted proposal queue; a minimal TUI dashboard that lists pending proposals and supports approve / modify / reject; and the audit log format. After this PRD, an operator can see proposals and approve/reject them — but the approval handlers are no-ops. The remediation engines that actually act on approvals land in 0014, 0015, and 0016.
The shared infrastructure that PRDs 00140016 build on. Adds a per-bottle MCP sidecar that exposes three tools (`cred-proxy-block`, `pipelock-block`, `capability-block`) to the agent; a read-only `/etc/bot-bottle/current-config/` mount in the agent container that exposes the current `routes.json`, pipelock allowlist, and Dockerfile; a host-mounted proposal queue; a minimal TUI dashboard that lists pending proposals and supports approve / modify / reject; and the audit log format. After this PRD, an operator can see proposals and approve/reject them — but the approval handlers are no-ops. The remediation engines that actually act on approvals land in 0014, 0015, and 0016.
## Problem
@@ -33,10 +33,10 @@ See PRD 0012 for the broader stuck-agent problem. This PRD specifically addresse
- A per-bottle MCP sidecar container on the bottle's internal network.
- MCP tool definitions for `cred-proxy-block`, `pipelock-block`, `capability-block` (input schemas as defined in PRD 0012 *Stuck categories*).
- Tool output: `{status: "approved"|"modified"|"rejected", notes: "..."}`.
- A read-only mount at `/etc/claude-bottle/current-config/` in the agent container exposing the current `routes.json`, pipelock allowlist, and Dockerfile.
- A host-mounted per-bottle proposal queue at `~/.claude-bottle/queue/<slug>/` (file-per-proposal, with metadata and proposed file content).
- A `claude-bottle dashboard` (or similarly named) TUI that lists running bottles and pending proposals across all of them; supports approve, modify-then-approve, and reject-with-reason for each pending proposal.
- Audit log files at `~/.claude-bottle/audit/cred-proxy-<slug>.log` and `~/.claude-bottle/audit/pipelock-<slug>.log` with the agreed-upon format (timestamp, diff before/after, justification text, operator action with notes). Entries are written by the supervisor on each approve/modify/reject decision. (capability-block has no separate audit log — capability changes are captured by the bottle's rebuild record / git history.)
- A read-only mount at `/etc/bot-bottle/current-config/` in the agent container exposing the current `routes.json`, pipelock allowlist, and Dockerfile.
- A host-mounted per-bottle proposal queue at `~/.bot-bottle/queue/<slug>/` (file-per-proposal, with metadata and proposed file content).
- A `bot-bottle dashboard` (or similarly named) TUI that lists running bottles and pending proposals across all of them; supports approve, modify-then-approve, and reject-with-reason for each pending proposal.
- Audit log files at `~/.bot-bottle/audit/cred-proxy-<slug>.log` and `~/.bot-bottle/audit/pipelock-<slug>.log` with the agreed-upon format (timestamp, diff before/after, justification text, operator action with notes). Entries are written by the supervisor on each approve/modify/reject decision. (capability-block has no separate audit log — capability changes are captured by the bottle's rebuild record / git history.)
- Bottle lifecycle script changes to launch the MCP sidecar alongside the other sidecars and mount the read-only current-config directory.
### Out of scope
@@ -49,15 +49,15 @@ See PRD 0012 for the broader stuck-agent problem. This PRD specifically addresse
### New services / components
- **MCP sidecar.** New per-bottle container on the bottle's internal network. Exposes the three tools to the agent. On a tool call: validates the proposed file syntactically (valid JSON for `routes.json`, parseable Dockerfile, etc.), writes the proposal to the queue, and holds the tool-call connection open until the supervisor responds. Returns `{status, notes}` to the agent on response.
- **Read-only current-config mount.** `/etc/claude-bottle/current-config/` in the agent container exposes `routes.json`, the pipelock allowlist, and the agent Dockerfile from the host. Read-only — the agent proposes changes via the tool call, never by writing the file directly.
- **Proposal queue.** Per-bottle directory under `~/.claude-bottle/queue/<slug>/` on the host. One file per pending proposal with `{id, tool, proposed_file, justification, arrival_timestamp, current_file_hash, bottle_slug}`.
- **Read-only current-config mount.** `/etc/bot-bottle/current-config/` in the agent container exposes `routes.json`, the pipelock allowlist, and the agent Dockerfile from the host. Read-only — the agent proposes changes via the tool call, never by writing the file directly.
- **Proposal queue.** Per-bottle directory under `~/.bot-bottle/queue/<slug>/` on the host. One file per pending proposal with `{id, tool, proposed_file, justification, arrival_timestamp, current_file_hash, bottle_slug}`.
- **Minimal TUI dashboard.** Lists running bottles and pending proposals. For each proposal: shows current vs. proposed diff and justification. Operator actions: approve / modify-then-approve / reject-with-reason. Stdlib only (curses) unless that proves painful.
- **Audit log format.** Append-only files at `~/.claude-bottle/audit/<component>-<slug>.log`. Each entry: timestamp, diff before/after, agent justification (if from a tool call), operator action + notes. Defines the format; the per-component PRDs (0014, 0015) fill in real entries.
- **Audit log format.** Append-only files at `~/.bot-bottle/audit/<component>-<slug>.log`. Each entry: timestamp, diff before/after, agent justification (if from a tool call), operator action + notes. Defines the format; the per-component PRDs (0014, 0015) fill in real entries.
- **No-op approval handlers.** Each tool's approve path in 0013 writes an audit entry and returns `{status: "approved"}` to the agent but doesn't actually change any config. 0014 / 0015 / 0016 replace these with real handlers.
### Existing code touched
- **Bottle lifecycle scripts** — launch the MCP sidecar alongside other sidecars; mount `/etc/claude-bottle/current-config/` read-only into the agent container.
- **Bottle lifecycle scripts** — launch the MCP sidecar alongside other sidecars; mount `/etc/bot-bottle/current-config/` read-only into the agent container.
- **`cli.py`** — adds the dashboard subcommand.
### Data model changes
+3 -3
View File
@@ -106,7 +106,7 @@ delivery.
apply path. SIGHUP reload semantics carry over to egress-proxy.
- PRD 0013 (supervise plane) `cred-proxy-block` MCP tool stays;
its proposed file format updates per the new route shape.
- Removal of the old cred-proxy code: `claude_bottle/cred_proxy.py`,
- Removal of the old cred-proxy code: `bot_bottle/cred_proxy.py`,
`cred_proxy_server.py`, `backend/docker/cred_proxy.py`,
`provision/cred_proxy.py`, the `Dockerfile.cred-proxy`. Tests
updated.
@@ -254,8 +254,8 @@ manifest load:
`path``host`, drop the agent-side URL prefix).
- `cred_proxy_routes` field on existing dataclasses removed.
- `Dockerfile.cred-proxy` deleted.
- `claude_bottle/cred_proxy*.py` deleted.
- `claude_bottle/backend/docker/cred_proxy*.py` consolidated into
- `bot_bottle/cred_proxy*.py` deleted.
- `bot_bottle/backend/docker/cred_proxy*.py` consolidated into
`egress_proxy*.py`.
- Provisioner files renamed.
- PRDs 0010 (cred-proxy), 0014 (cred-proxy-block remediation)
+29 -29
View File
@@ -15,7 +15,7 @@ down`. Logs come from `docker compose logs` and land in a single file
per instance, so reading what happened in a session is one `less`
away.
State for each instance (`~/.claude-bottle/state/<slug>/`) becomes a
State for each instance (`~/.bot-bottle/state/<slug>/`) becomes a
self-describing folder:
```
@@ -34,7 +34,7 @@ together fully describe the container topology.
Today `start` builds each sidecar (`pipelock`, `egress`, `git-gate`,
`supervise`) and the agent container with a chain of individual SDK
calls in `claude_bottle/backend/docker/launch.py`:
calls in `bot_bottle/backend/docker/launch.py`:
- A per-sidecar `Docker{Sidecar}.start()` method does
`docker create``docker cp` (stage files) → `docker network
@@ -50,7 +50,7 @@ This is fine, but it has three rough edges:
2. **Logs are scattered.** Each container's logs sit in Docker's per-
container journal. To debug a session post-mortem you have to
remember to run `docker logs claude-bottle-pipelock-<slug>` etc.
remember to run `docker logs bot-bottle-pipelock-<slug>` etc.
before the containers age out, and there's no merged view.
3. **Teardown is bespoke.** Each sidecar's `stop()` is its own
@@ -62,14 +62,14 @@ project name per environment, merged logs, atomic up/down.
## Goals / Success Criteria
1. `claude-bottle start <agent>` writes
`~/.claude-bottle/state/<slug>/docker-compose.yml` and brings the
1. `bot-bottle start <agent>` writes
`~/.bot-bottle/state/<slug>/docker-compose.yml` and brings the
project up with `docker compose -p <project> up`.
2. The compose file is the source of truth for the container
topology — every sidecar that runs is declared as a `services:`
entry, every network is a `networks:` entry, every bind mount is
a `volumes:` entry.
3. `~/.claude-bottle/state/<slug>/compose.log` contains the full
3. `~/.bot-bottle/state/<slug>/compose.log` contains the full
merged stdout/stderr of every service for the session, in
`docker compose logs --no-color` format.
4. `metadata.json` records the compose project name alongside the
@@ -79,7 +79,7 @@ project name per environment, merged logs, atomic up/down.
5. Session teardown is `docker compose -p <project> down`. The
existing per-sidecar `stop()` lifecycle methods come out.
6. The `cleanup` CLI uses `docker compose ls` (filtered to
`claude-bottle-*` projects) instead of name-prefix scans across
`bot-bottle-*` projects) instead of name-prefix scans across
`docker ps -a` and `docker network ls`.
7. The existing remediation flows (`pipelock-block`,
`egress-block`, `capability-block`) keep working without
@@ -95,7 +95,7 @@ project name per environment, merged logs, atomic up/down.
implementation detail of the Docker backend.
- **Replacing the backend abstraction (PRD 0003).** `Backend` stays
abstract; only the Docker implementation changes.
- **A long-lived "claude-bottle daemon."** Each `start` invocation
- **A long-lived "bot-bottle daemon."** Each `start` invocation
still owns a single compose project for the lifetime of the
session. No persistent service.
- **Image pre-building.** Compose's `build:` directive triggers
@@ -109,7 +109,7 @@ project name per environment, merged logs, atomic up/down.
### In scope
- New module `claude_bottle/backend/docker/compose.py` that renders a
- New module `bot_bottle/backend/docker/compose.py` that renders a
compose dict from a `BottlePlan` and writes it to
`state/<slug>/docker-compose.yml`.
- `DockerBackend.start` rewritten to:
@@ -118,7 +118,7 @@ project name per environment, merged logs, atomic up/down.
into host paths under `state/<slug>/`.
3. Render + write the compose file.
4. Exec `docker compose -p <project> up -d`.
5. `docker attach claude-bottle-<slug>` for the agent's TTY.
5. `docker attach bot-bottle-<slug>` for the agent's TTY.
6. On exit: `docker compose -p <project> logs --no-color`
`state/<slug>/compose.log`, then `docker compose -p
<project> down --volumes`.
@@ -134,12 +134,12 @@ project name per environment, merged logs, atomic up/down.
### Out of scope
- Changing the manifest layer (`claude_bottle/manifest.py`,
- Changing the manifest layer (`bot_bottle/manifest.py`,
`egress.py`'s plan dataclasses, `pipelock.py`'s plan dataclasses).
- Changing the agent's runtime contract (proxy env vars, CA bundle
paths, current-config mount path).
- Changing audit-log shape or location (
`~/.claude-bottle/audit/<component>-<slug>.log` stays).
`~/.bot-bottle/audit/<component>-<slug>.log` stays).
- Changing the MCP server's tool list or wire format.
- Dropping the `--rm` semantics for the agent: the agent container
is still ephemeral; compose's `down --volumes` handles cleanup.
@@ -148,7 +148,7 @@ project name per environment, merged logs, atomic up/down.
### Project name
`compose_project = f"claude-bottle-{slug}"`. The slug stays the
`compose_project = f"bot-bottle-{slug}"`. The slug stays the
existing `slugify(agent_name)-<5-char-random-base36>` from
`bottle_state.py`. Compose adds its own prefix to networks
(`<project>_<network>`) and to default container names — which is
@@ -163,29 +163,29 @@ an explicit `container_name:` matching today's pattern:
```yaml
services:
pipelock:
container_name: claude-bottle-pipelock-<slug>
container_name: bot-bottle-pipelock-<slug>
egress:
container_name: claude-bottle-egress-<slug>
container_name: bot-bottle-egress-<slug>
# ...
```
This keeps the dashboard's container-discovery output stable for
operators who've memorized the names. The compose project name
(`claude-bottle-<slug>`) is the only new identifier.
(`bot-bottle-<slug>`) is the only new identifier.
### Networks
The two existing networks (`claude-bottle-net-<slug>` internal +
`claude-bottle-egress-<slug>` upstream-bridge) become compose
The two existing networks (`bot-bottle-net-<slug>` internal +
`bot-bottle-egress-<slug>` upstream-bridge) become compose
networks:
```yaml
networks:
internal:
name: claude-bottle-net-<slug>
name: bot-bottle-net-<slug>
internal: true
egress:
name: claude-bottle-egress-<slug>
name: bot-bottle-egress-<slug>
```
Each service's `networks:` list mirrors today's wiring.
@@ -238,7 +238,7 @@ sidecars that exist.
### Logging
`docker compose up -d` starts everything detached. The agent is
attached for the user's TTY via `docker attach claude-bottle-
attached for the user's TTY via `docker attach bot-bottle-
<slug>`. Sidecars stream into Docker's per-container journals
during the session, exactly as today, and `docker compose logs -f`
gives a merged tail if the user wants it (the dashboard can shell
@@ -265,7 +265,7 @@ Add one field; everything else is unchanged.
"agent_name": "implementer",
"cwd": "/Users/.../some-project",
"started_at": "2026-05-25T20:13:04Z",
"compose_project": "claude-bottle-implementer-a7k3f"
"compose_project": "bot-bottle-implementer-a7k3f"
}
```
@@ -291,13 +291,13 @@ After this PRD:
### Cleanup CLI
`./cli.py cleanup` switches from "list every container with prefix
`claude-bottle-` and every network with prefix `claude-bottle-net-`
or `claude-bottle-egress-`" to:
`bot-bottle-` and every network with prefix `bot-bottle-net-`
or `bot-bottle-egress-`" to:
1. `docker compose ls --all --format json` → filter to projects
whose name starts with `claude-bottle-`.
whose name starts with `bot-bottle-`.
2. For each: `docker compose -p <project> down --volumes`.
3. Reap any state dirs under `~/.claude-bottle/state/` whose
3. Reap any state dirs under `~/.bot-bottle/state/` whose
`compose_project` no longer appears in `compose ls`.
Strays from pre-compose code-paths can be mopped up by keeping the
@@ -313,7 +313,7 @@ existing prefix scan as a fallback for one release.
2. **How does `claude` reach the agent's TTY?** Decided: keep
today's `docker exec -it` model. Agent runs `sleep infinity`
under compose; `DockerBottle.exec_claude` runs
`docker exec -it claude-bottle-<slug> claude ...` exactly like
`docker exec -it bot-bottle-<slug> claude ...` exactly like
today. Compose owns the lifecycle (so `compose logs` includes
the agent's stdout, `compose down` tears it down), but the
user-facing exec model is unchanged. Rejected `docker attach`
@@ -332,8 +332,8 @@ existing prefix scan as a fallback for one release.
5. **Image build caching.** `build:` in compose rebuilds on first
`up` unless the image is already tagged. The per-sidecar images
(`claude-bottle-pipelock`, `claude-bottle-egress`,
`claude-bottle-git-gate`, `claude-bottle-supervise`) should
(`bot-bottle-pipelock`, `bot-bottle-egress`,
`bot-bottle-git-gate`, `bot-bottle-supervise`) should
stay tagged on the daemon between runs so we don't rebuild on
every start. Verify compose's behavior matches.
+1 -1
View File
@@ -128,7 +128,7 @@ the "operator wants to make an unprompted change" case.
### Layout
```
claude-bottle dashboard (3 pending, 2 active)
bot-bottle dashboard (3 pending, 2 active)
─────────────────────────────────────────────────────────
proposals:
03:14:22 [implementer-cy7a6] egress-block abc123…
@@ -63,7 +63,7 @@ captures full-merged logs per bottle (PRD 0018). It already
→ restore`, matching the existing editor-flow pattern.
3. On launch success, the dashboard performs a handoff (option
1 from the research doc): `curses.endwin()` → `docker exec
-it claude-bottle-<slug> claude --dangerously-skip-permissions`
-it bot-bottle-<slug> claude --dangerously-skip-permissions`
→ on exit, `stdscr.refresh()` and re-render with the new
bottle in the agents pane.
4. The bottle's lifetime is owned by the dashboard process, NOT
@@ -268,7 +268,7 @@ dashboard started this session, the dashboard holds the
`bottle.exec_claude(...)`. For an agent it discovered via
`list_active_slugs` (previous-dashboard or external start),
the dashboard synthesizes a one-shot `DockerBottle` from the
slug — container name is `claude-bottle-<slug>`, no prompt
slug — container name is `bot-bottle-<slug>`, no prompt
path because the agent's claude config already has `--append-
system-prompt-file` baked in from the original launch —
and runs the same exec. Either way, Enter drops to
@@ -284,7 +284,7 @@ agents pane.
`x` on a non-owned agent (discovered via `list_active_slugs`
but not in `bottles` dict): no-op with status hint pointing
at `./cli.py cleanup` (the existing path that tears down
ANY claude-bottle compose project plus reaps state dirs).
ANY bot-bottle compose project plus reaps state dirs).
### Dashboard quit
@@ -392,5 +392,5 @@ Sized for one PR each.
- `docs/research/claude-code-pane-in-dashboard.md` — option 1
(handoff) is what `attach_claude` implements here; options 2
/ 3 are out of scope for this PRD
- `claude_bottle/cli/start.py:_launch_bottle` — the function
- `bot_bottle/cli/start.py:_launch_bottle` — the function
chunk 1 extracts the prepare + attach pieces out of
+1 -1
View File
@@ -46,7 +46,7 @@ window, two panes, no terminal handoff.
two-pane layout: dashboard in the left pane, an initially-
empty right pane reserved for claude sessions.
2. Pressing Enter on a focused agent row spawns / respawns the
right pane with `docker exec -it claude-bottle-<slug> claude
right pane with `docker exec -it bot-bottle-<slug> claude
--continue --dangerously-skip-permissions`. The right pane's
prior content (if any) is replaced.
3. Pressing `n` to start a new agent (the existing chunk-2 flow
@@ -26,7 +26,7 @@ The suite is the backend-agnostic gate the smolmachines work
has to pass before that backend can be considered a viable
substitute for Docker. Today's Docker backend is the
backend-under-test; the suite runs against whatever
`CLAUDE_BOTTLE_BACKEND` resolves to.
`BOT_BOTTLE_BACKEND` resolves to.
## Problem
@@ -121,7 +121,7 @@ destination outside the bottle.
suite is written against the abstract `BottleBackend` API
via the existing `get_bottle_backend()` selector; future
smolmachines work flips
`CLAUDE_BOTTLE_BACKEND=smolmachines` and reruns. No
`BOT_BOTTLE_BACKEND=smolmachines` and reruns. No
smolmachines-specific code here.
## Scope
@@ -212,7 +212,7 @@ scan happens first.
### Per-attack scaffolding
Each test calls `bottle.exec(script)` (the existing
`Bottle.exec` from `claude_bottle.backend.Bottle`) and
`Bottle.exec` from `bot_bottle.backend.Bottle`) and
asserts on the returncode + stdout + stderr.
The agent container has `curl`, `git`, `dig`, etc. pre-
@@ -331,7 +331,7 @@ def test_5_readme_push_blocked(self):
f'echo "[click](https://attacker.example.com/?leak={env_ref})" > README.md && '
'git add . && git commit -m "leak" && '
'git remote add origin '
'git://claude-bottle-git-gate-<slug>/throwaway.git && '
'git://bot-bottle-git-gate-<slug>/throwaway.git && '
'git push origin master'
)
self.assertNotEqual(0, result.returncode)
@@ -460,7 +460,7 @@ Sized small.
already dies with a clear message naming the unknown
backend; the test surfaces that as a hard error
rather than a skip. Forces the developer to set
`CLAUDE_BOTTLE_BACKEND` to a real implementation —
`BOT_BOTTLE_BACKEND` to a real implementation —
surprise-skips on smolmachines branches that forgot to
set the env var are worse than a loud failure.
+22 -22
View File
@@ -8,7 +8,7 @@
Ship a second concrete `BottleBackend`
`SmolmachinesBottleBackend`, selected via
`CLAUDE_BOTTLE_BACKEND=smolmachines` — that runs each bottle inside
`BOT_BOTTLE_BACKEND=smolmachines` — that runs each bottle inside
a per-agent libkrun microVM via `smolvm`. Egress is enforced by
libkrun's TSI ("Transport Socket Interface") allowlist set to a
**single /32** — the docker IP of the per-bottle sidecar bundle
@@ -28,7 +28,7 @@ port-granular.
The Docker backend ships unchanged; this is opt-in via the existing
env-var selector. The acceptance gate is PRD 0022's
`tests/integration/test_sandbox_escape.py` running green against
`CLAUDE_BOTTLE_BACKEND=smolmachines`.
`BOT_BOTTLE_BACKEND=smolmachines`.
### Design pivot from the first draft
@@ -63,7 +63,7 @@ with significantly less code.
container-based bottles on macOS; `smolmachines-as-vm-backend.md`
evaluates smolmachines as the lifecycle wrapper. Today, the only
backend in the registry is Docker
(`claude_bottle/backend/__init__.py:_BACKENDS = {"docker": ...}`),
(`bot_bottle/backend/__init__.py:_BACKENDS = {"docker": ...}`),
and four things motivate a second one now:
- **Network reach beyond pipelock.** The threat model is a malicious
@@ -85,7 +85,7 @@ and four things motivate a second one now:
enforced by the CPU's MMU instead of namespace bookkeeping.
- **PRD 0022 is backend-agnostic by design** but currently only
exercises the Docker backend. The suite was written with
`CLAUDE_BOTTLE_BACKEND` selection in mind precisely so the
`BOT_BOTTLE_BACKEND` selection in mind precisely so the
smolmachines path could be validated against the same five
attacks. Until a second backend exists, the abstraction is
unproven.
@@ -143,7 +143,7 @@ virtio-net carve-out smolvm doesn't expose anyway).
The feature works when all of the following are observable on a
macOS host with smolmachines installed:
- `CLAUDE_BOTTLE_BACKEND=smolmachines python3 cli.py start <agent>`
- `BOT_BOTTLE_BACKEND=smolmachines python3 cli.py start <agent>`
brings up a microVM, runs claude-code inside it, and tears it
down on exit. Same y/N preflight UX as Docker — only the
resolved-runtime line differs.
@@ -160,14 +160,14 @@ macOS host with smolmachines installed:
The feature is **done** when all of the following ship:
- A new `claude_bottle/backend/smolmachines/` subpackage exists,
mirroring the layout of `claude_bottle/backend/docker/`
- A new `bot_bottle/backend/smolmachines/` subpackage exists,
mirroring the layout of `bot_bottle/backend/docker/`
(`backend.py`, `bottle.py`, `bottle_plan.py`,
`bottle_cleanup_plan.py`, `prepare.py`, `launch.py`,
`cleanup.py`, `util.py`, and a `provision/` subpackage for the
five `provision_*` methods).
- `SmolmachinesBottleBackend` registered under the
`"smolmachines"` key in `claude_bottle/backend/__init__.py:_BACKENDS`.
`"smolmachines"` key in `bot_bottle/backend/__init__.py:_BACKENDS`.
- Per-bottle Smolfile generation: a runtime-rendered TOML written
to the bottle's stage dir using smolvm 0.8.0's actual schema
(`image`, `entrypoint`, `cmd`, `env = ["K=V", …]`, `[network]
@@ -197,7 +197,7 @@ The feature is **done** when all of the following ship:
step is part of `prepare`, analogous to
`docker_mod.build_image`.
- The PRD 0022 sandbox-escape suite, run with
`CLAUDE_BOTTLE_BACKEND=smolmachines`, passes locally on a
`BOT_BOTTLE_BACKEND=smolmachines`, passes locally on a
smolmachines-capable host. The suite is updated to skip cleanly
on hosts that can't reach smolmachines (same shape as the
existing `GITEA_ACTIONS == "true"` skip), not to fail.
@@ -215,7 +215,7 @@ The feature is **done** when all of the following ship:
side. Selection stays env-driven; the manifest does not gain a
`backend` field.
- **No default-backend change.** `docker` remains the default
value of `CLAUDE_BOTTLE_BACKEND`; smolmachines is strictly
value of `BOT_BOTTLE_BACKEND`; smolmachines is strictly
opt-in until it has been load-bearing on at least one operator's
workflow for a release cycle.
- **No `--outbound-localhost-only`.** That TSI flag opens the
@@ -251,7 +251,7 @@ The feature is **done** when all of the following ship:
### In scope
- New `claude_bottle/backend/smolmachines/` subpackage with the
- New `bot_bottle/backend/smolmachines/` subpackage with the
full set of `BottleBackend` overrides.
- Smolfile generator (TOML) emitting the smolvm 0.8.0 schema:
top-level `image`, `entrypoint`, `cmd`, `env = [...]`,
@@ -267,7 +267,7 @@ The feature is **done** when all of the following ship:
- Per-bottle CA install path: the bundle's CA cert lands inside
the microVM via `smolvm machine exec` after start
(analogous to the existing `provision_ca` for Docker).
- Per-bottle docker bridge: a `claude-bottle-bundle-<slug>`
- Per-bottle docker bridge: a `bot-bottle-bundle-<slug>`
network with a /24 subnet derived from the slug hash; the
bundle gets a pinned IP at `.2` (gateway is `.1`). Pinning the
IP at start time avoids a race between the bundle's IP being
@@ -314,7 +314,7 @@ The feature is **done** when all of the following ship:
### Backend layout
```
claude_bottle/backend/smolmachines/
bot_bottle/backend/smolmachines/
__init__.py re-exports SmolmachinesBottleBackend
backend.py SmolmachinesBottleBackend façade
bottle.py SmolmachinesBottle (exec_claude / exec / cp_in / close)
@@ -339,7 +339,7 @@ design needs neither.
```
┌── macOS host ─────────────────────────────────────────────────────┐
│ │
│ ┌── per-bottle docker bridge claude-bottle-bundle-<slug> ──┐ │
│ ┌── per-bottle docker bridge bot-bottle-bundle-<slug> ──┐ │
│ │ subnet: 192.168.X.0/24 (X = hash(slug) mod 254) │ │
│ │ │ │
│ │ ┌── bundle container (pinned --ip 192.168.X.2) ────────┐ │ │
@@ -401,9 +401,9 @@ Three changes vs. the Docker backend:
transport tested. The conversion path is a registry hop: bring
up an ephemeral `registry:2.8.3` container bound to
`127.0.0.1:<random>`, `docker tag` + `docker push` into it,
`smolvm pack create --image localhost:<port>/claude-bottle:<id>`,
`smolvm pack create --image localhost:<port>/bot-bottle:<id>`,
tear down the registry. The `.smolmachine` is cached under
`~/.cache/claude-bottle/smolmachines/` keyed by the docker
`~/.cache/bot-bottle/smolmachines/` keyed by the docker
image ID, so Dockerfile changes invalidate the cache and
unchanged rebuilds skip the whole pipeline.
4. Render the per-bottle Smolfile to `stage_dir/smolfile.toml`
@@ -424,7 +424,7 @@ Three changes vs. the Docker backend:
`SmolmachinesBottleBackend.launch(plan)`:
1. Create the per-bottle docker bridge network
(`claude-bottle-bundle-<slug>` with the resolved subnet) and
(`bot-bottle-bundle-<slug>` with the resolved subnet) and
start the sidecar bundle container with `docker run --network
... --ip <bundle-ip> ...`. Wait for its daemons to bind:
pipelock on 8888, git-gate on 9418 (conditional), supervise
@@ -457,7 +457,7 @@ The `BottleSpec` dataclass and the `Bottle` ABC do not change.
### Selection wiring
In `claude_bottle/backend/__init__.py`:
In `bot_bottle/backend/__init__.py`:
```python
from .docker import DockerBottleBackend
@@ -508,7 +508,7 @@ The existing "unknown backend" `die()` path stays as-is.
(egress's port) is refused — confirming the bundle-internal
bind of egress to `127.0.0.1` works as the port-granularity
layer TSI doesn't provide.
- **PRD 0022 re-run:** with `CLAUDE_BOTTLE_BACKEND=smolmachines`,
- **PRD 0022 re-run:** with `BOT_BOTTLE_BACKEND=smolmachines`,
all five attack categories return sandbox-block markers and the
suite passes. The test code does not change beyond the env-var
flip — that's the contract the PRD 0022 abstraction was
@@ -517,7 +517,7 @@ The existing "unknown backend" `die()` path stays as-is.
## Sizing — into chunks
PRD 0024's bundle image is a prerequisite — this PRD assumes
`claude-bottle-sidecars:<pinned>` is available when chunk 3 lands.
`bot-bottle-sidecars:<pinned>` is available when chunk 3 lands.
1. **Backend skeleton + selection + Smolfile/gvproxy renderers.**
*Shipped (PR #62), but under the now-rejected gvproxy design.*
@@ -598,7 +598,7 @@ PRD 0024's bundle image is a prerequisite — this PRD assumes
enumerate active bottles (`list_active` queries the daemon).
The microVM enumeration story is `smolvm machine ls --json`;
the plan is to filter on a deterministic name prefix
`claude-bottle-<slug>` + cross-reference with on-disk metadata
`bot-bottle-<slug>` + cross-reference with on-disk metadata
under `state/<slug>/`.
8. **Loopback scoping (Docker Desktop pivot).** The original
design pinned the bundle at a docker bridge IP and set TSI's
@@ -667,5 +667,5 @@ PRD 0024's bundle image is a prerequisite — this PRD assumes
needed to exercise the smolmachines path.
- PRD 0024
(`docs/prds/0024-consolidate-sidecar-bundle.md`) — defines the
single bundle image (`claude-bottle-sidecars`) this PRD
single bundle image (`bot-bottle-sidecars`) this PRD
consumes. Prerequisite for chunk 3 of this PRD.
+30 -30
View File
@@ -8,7 +8,7 @@
Replace the four per-bottle sidecar containers in the Docker
backend (pipelock, egress, git-gate, supervise) with a single
container image — `claude-bottle-sidecars` — that runs all four
container image — `bot-bottle-sidecars` — that runs all four
daemons under a small stdlib-Python init supervisor. Same
per-bottle lifetime, same scope, fewer containers per bottle,
one Dockerfile to maintain instead of three. Outcome: the
@@ -64,7 +64,7 @@ The feature works when all of the following are observable:
speak the same protocols on the same well-known in-container
ports as before; only the container hostname changes.
- The sandbox-escape suite from PRD 0022 stays green.
- `docker logs claude-bottle-sidecars-<slug>` shows interleaved
- `docker logs bot-bottle-sidecars-<slug>` shows interleaved
output from all four daemons, prefixed by the supervisor with
the daemon name. Each daemon's exit propagates through the
supervisor to the container's exit code.
@@ -77,18 +77,18 @@ The feature is **done** when all of the following ship:
- A new `Dockerfile.sidecars` (multi-stage) that:
- Copies the `pipelock` binary from the upstream pipelock
image (currently `ghcr.io/luckypipewrench/pipelock` pinned
by digest in `claude_bottle/backend/docker/pipelock.py`).
by digest in `bot_bottle/backend/docker/pipelock.py`).
- Copies the `gitleaks` binary from `zricethezav/gitleaks`
(currently pinned by digest in `Dockerfile.git-gate`).
- Installs `mitmdump` (via `pip install mitmproxy==<pinned>`).
- Installs the system deps `git-daemon` + `openssh-client`
that git-gate needs.
- Copies the existing addon + server Python from
`claude_bottle/egress_addon.py`, `egress_addon_core.py`,
`bot_bottle/egress_addon.py`, `egress_addon_core.py`,
`yaml_subset.py`, `supervise.py`, `supervise_server.py`.
- Drops in a new `claude_bottle/sidecar_init.py` (stdlib
- Drops in a new `bot_bottle/sidecar_init.py` (stdlib
Python) as the container's `ENTRYPOINT`.
- A new `claude_bottle/sidecar_init.py` — a small Python init
- A new `bot_bottle/sidecar_init.py` — a small Python init
supervisor that:
- Reads which daemons to run from env (defaults: all four).
- Spawns each as a `subprocess.Popen` with prefixed
@@ -99,13 +99,13 @@ The feature is **done** when all of the following ship:
- Exits with code 0 only if every child exited 0; otherwise
exits 1. (Or: any-child-died → tear down the rest and exit
that child's code — see open question 2.)
- `claude_bottle/backend/docker/compose.py` renderer updated to
- `bot_bottle/backend/docker/compose.py` renderer updated to
emit one `sidecars` service in place of the four. The four
in-container ports (8888 / 9099 / 9418 / 9100, today) all
land on the same container; the agent-facing ports
(HTTPS_PROXY, git-gate-SSH, supervise-MCP) are published as
before, just from one container instead of three.
- `claude_bottle/backend/docker/{pipelock,egress,git_gate,supervise}.py`
- `bot_bottle/backend/docker/{pipelock,egress,git_gate,supervise}.py`
collapsed: the platform-neutral pieces stay
(`PipelockProxy`, `Egress`, `GitGate`, `Supervise` ABCs and
their plans), the docker-specific subclasses lose their
@@ -161,7 +161,7 @@ The feature is **done** when all of the following ship:
- New `Dockerfile.sidecars` (multi-stage) bringing pipelock,
mitmproxy, gitleaks, git-daemon, openssh-client, and the
project's addon + server Python into one image.
- New `claude_bottle/sidecar_init.py` supervising the four
- New `bot_bottle/sidecar_init.py` supervising the four
daemons.
- `backend/docker/compose.py` renderer collapse (five services
→ two).
@@ -217,12 +217,12 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
# Drop in the project's Python addon + server code
COPY claude_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY claude_bottle/egress_addon.py /app/egress_addon.py
COPY claude_bottle/yaml_subset.py /app/yaml_subset.py
COPY claude_bottle/supervise.py /app/supervise.py
COPY claude_bottle/supervise_server.py /app/supervise_server.py
COPY claude_bottle/sidecar_init.py /app/sidecar_init.py
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/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
# Pull the standalone binaries into the final stage
COPY --from=pipelock-src /usr/local/bin/pipelock /usr/local/bin/pipelock
@@ -252,7 +252,7 @@ existing Dockerfiles.
### Init supervisor
`claude_bottle/sidecar_init.py` (sketch — actual code lands as
`bot_bottle/sidecar_init.py` (sketch — actual code lands as
part of implementation):
```python
@@ -287,7 +287,7 @@ of the four. The service inherits the union of the four's
existing bind mounts; environment variables get prefixed by
daemon name where they clash (none clash today, but the renderer
becomes the central place to enforce that). Container hostname
becomes `sidecars` (or `claude-bottle-sidecars-<slug>` for the
becomes `sidecars` (or `bot-bottle-sidecars-<slug>` for the
externally-visible name). The agent service's HTTPS_PROXY and
git-gate URL move from per-sidecar hostnames to the single
`sidecars` hostname:
@@ -301,9 +301,9 @@ services:
GIT_GATE_URL: "git://git-gate:9418/repo"
MCP_SUPERVISE_URL: "http://supervise:9100"
pipelock: { image: ghcr.io/luckypipewrench/pipelock:... }
egress: { image: claude-bottle-egress:latest }
git-gate: { image: claude-bottle-git-gate:latest }
supervise:{ image: claude-bottle-supervise:latest }
egress: { image: bot-bottle-egress:latest }
git-gate: { image: bot-bottle-git-gate:latest }
supervise:{ image: bot-bottle-supervise:latest }
# After (two services)
services:
@@ -313,7 +313,7 @@ services:
GIT_GATE_URL: "git://sidecars:9418/repo"
MCP_SUPERVISE_URL: "http://sidecars:9100"
sidecars:
image: claude-bottle-sidecars:<pinned>
image: bot-bottle-sidecars:<pinned>
# union of the four prior services' volumes / env / ports
```
@@ -321,7 +321,7 @@ services:
### Backend Python collapse
The four `claude_bottle/backend/docker/<sidecar>.py` files keep
The four `bot_bottle/backend/docker/<sidecar>.py` files keep
their platform-neutral abstractions (proxy/plan classes) but
shed the docker-container-lifecycle code that compose-up
already owns. Container-name helpers consolidate:
@@ -335,7 +335,7 @@ def supervise_container_name(slug): ...
# becomes:
def sidecar_bundle_container_name(slug: str) -> str:
return f"claude-bottle-sidecars-{slug}"
return f"bot-bottle-sidecars-{slug}"
```
Per-daemon "is the container up?" helpers used by orphan
@@ -356,7 +356,7 @@ This PRD's change is large but mechanical. A pre-merge dry-run:
and the resulting container runs all four daemons.
2. Switch the renderer to emit the two-service shape behind an
env-var feature flag (e.g.
`CLAUDE_BOTTLE_SIDECAR_BUNDLE=1`).
`BOT_BOTTLE_SIDECAR_BUNDLE=1`).
3. Update integration tests in-place; flip the default once
green; delete the flag and the old Dockerfiles in a
follow-up commit on the same branch.
@@ -379,7 +379,7 @@ rewrite.
3. **Backend Python collapse.** Drop the vestigial per-container
`.start()` / `.stop()` methods from `DockerPipelockProxy`,
`DockerEgress`, `DockerGitGate`, `DockerSupervise` (and from
the ABCs in `claude_bottle/{pipelock,egress,git_gate,supervise}.py`).
the ABCs in `bot_bottle/{pipelock,egress,git_gate,supervise}.py`).
These were already documented as vestigial in PRD 0018 ch3.
Strip vestigial sidecar-instance parameters from
`launch.launch()` and `prepare.resolve_plan()`. Delete the
@@ -419,10 +419,10 @@ rewrite.
is signal-killed (negative returncode) so the max is 0; a
crashed-before-signal daemon's nonzero code wins and reaches
the operator on container exit.
3. **Image pin policy.** Pin `claude-bottle-sidecars` by tag
3. **Image pin policy.** Pin `bot-bottle-sidecars` by tag
(`:latest` rebuilt per-release) or by digest written into a
`CLAUDE_BOTTLE_SIDECAR_IMAGE` env var like the existing
`CLAUDE_BOTTLE_PIPELOCK_IMAGE`? Default to env-var override
`BOT_BOTTLE_SIDECAR_IMAGE` env var like the existing
`BOT_BOTTLE_PIPELOCK_IMAGE`? Default to env-var override
+ a documented tag; digest pinning is an operator opt-in.
4. **Healthcheck aggregation.** Today each sidecar service has
its own compose healthcheck and `agent.depends_on:
@@ -448,9 +448,9 @@ rewrite.
- `Dockerfile.egress`, `Dockerfile.git-gate`,
`Dockerfile.supervise` — the three Dockerfiles this PRD
collapses into `Dockerfile.sidecars`.
- `claude_bottle/backend/docker/compose.py` — the renderer this
- `bot_bottle/backend/docker/compose.py` — the renderer this
PRD slims down.
- `claude_bottle/backend/docker/pipelock.py` — current home of
- `bot_bottle/backend/docker/pipelock.py` — current home of
`PIPELOCK_IMAGE` and the pinned digest the bundle's first
stage reuses.
- PRD 0017
+3 -3
View File
@@ -28,7 +28,7 @@ silently absent from `staging` until someone notices.
Issue #88 proposed inlining a `bottle_config:` block in agent files
that would merge with (and override) the referenced bottle. That
design lets a `$CWD/.claude-bottle/agents/<name>.md` file from a
design lets a `$CWD/.bot-bottle/agents/<name>.md` file from a
cloned repo redeclare egress routes, env mappings, and git remotes
— breaking the existing security model where bottles are
`$HOME`-only specifically so cloned repos can't influence them
@@ -93,7 +93,7 @@ egress:
`extends:` is a string — the name of another bottle (without the
`.md`). Required to be one of the bottles loaded from
`$HOME/.claude-bottle/bottles/`. Self-reference (`extends: self`
`$HOME/.bot-bottle/bottles/`. Self-reference (`extends: self`
in `self.md`) and longer cycles die at parse.
### Merge rules
@@ -163,7 +163,7 @@ the full chain so operators can find the offending file.
### Trust boundary preservation
Bottles continue to be loaded from `$HOME/.claude-bottle/bottles/`
Bottles continue to be loaded from `$HOME/.bot-bottle/bottles/`
only (`Manifest.from_md_dirs` is unchanged). The `extends:` field
references another file in that same directory. No cwd-readable
file gains the ability to declare or modify bottle config — the
+3 -3
View File
@@ -10,7 +10,7 @@ Support Claude and Codex agent providers while keeping agent files provider-agno
## Problem
Today claude-bottle is hard-wired around Claude Code assumptions. When Claude runs out or is otherwise unavailable, the operator cannot spin up an equivalent Codex-backed bottle from the dashboard or `start` path. Agent files should remain purpose/guidance documents, while bottle files define security boundaries and provider/runtime choices.
Today bot-bottle is hard-wired around Claude Code assumptions. When Claude runs out or is otherwise unavailable, the operator cannot spin up an equivalent Codex-backed bottle from the dashboard or `start` path. Agent files should remain purpose/guidance documents, while bottle files define security boundaries and provider/runtime choices.
## Goals / Success Criteria
@@ -24,7 +24,7 @@ Today claude-bottle is hard-wired around Claude Code assumptions. When Claude ru
- Do not implement support for providers beyond Claude and Codex.
- Do not move security boundaries into agent files.
- Do not allow custom Dockerfiles to remove or bypass required claude-bottle infrastructure.
- Do not allow custom Dockerfiles to remove or bypass required bot-bottle infrastructure.
- Do not add new runtime dependencies unless the existing Docker/Codex tooling cannot satisfy the minimum cut.
## Scope
@@ -63,7 +63,7 @@ agent_provider:
### Existing code touched
- `claude_bottle/manifest.py` for provider schema and role validation.
- `bot_bottle/manifest.py` for provider schema and role validation.
- Docker and smolmachines prepare/launch/provision paths for provider-specific image, command, auth, and state behavior.
- Dashboard/start display paths so the selected provider is visible and usable.
- README and PRD docs for provider/template configuration.