PRD 0026: Agent Provider Templates #91

Merged
didericis merged 19 commits from prd-0026-agent-provider-templates into main 2026-05-28 20:04:41 -04:00
202 changed files with 2707 additions and 1755 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
Codex-bottle spins up an isolated container for running Codex with a Codex-bottle spins up an isolated container for running Codex with a
curated set of skills and env vars. The point is to run Codex with broad curated set of skills and env vars. The point is to run Codex with broad
permissions inside a sandbox, so a misbehaving agent cannot reach the host. permissions inside a sandbox, so a misbehaving agent cannot reach the host.
A Python CLI (entry point `cli.py`, package `claude_bottle/`) orchestrates A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
the container lifecycle and the copying of skills and env vars into it. the container lifecycle and the copying of skills and env vars into it.
## Goals ## Goals
+4 -4
View File
@@ -1,11 +1,11 @@
# claude-bottle # bot-bottle
## What this is ## What this is
claude-bottle spins up an isolated container for running Claude Code with a bot-bottle spins up an isolated container for running Claude Code with a
curated set of skills and env vars. The point is to run Claude with broad curated set of skills and env vars. The point is to run Claude with broad
permissions inside a sandbox, so a misbehaving agent cannot reach the host. permissions inside a sandbox, so a misbehaving agent cannot reach the host.
A Python CLI (entry point `cli.py`, package `claude_bottle/`) orchestrates A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
the container lifecycle and the copying of skills and env vars into it. the container lifecycle and the copying of skills and env vars into it.
## Goals ## Goals
@@ -25,7 +25,7 @@ the container lifecycle and the copying of skills and env vars into it.
- `README.md` — short public-facing description. - `README.md` — short public-facing description.
- `CLAUDE.md` — this file, orientation for future Claude sessions. - `CLAUDE.md` — this file, orientation for future Claude sessions.
- `.gitignore` — OS junk. - `.gitignore` — OS junk.
- `claude-bottle.json` — manifest of named agents (env / skills / prompt - `bot-bottle.json` — manifest of named agents (env / skills / prompt
per agent), consumed by `cli.py`. See "Manifest" under per agent), consumed by `cli.py`. See "Manifest" under
"Intended design". "Intended design".
- `docs/INDEX.md` — pointer to the research notes. - `docs/INDEX.md` — pointer to the research notes.
+5 -5
View File
@@ -1,4 +1,4 @@
# claude-bottle container image. # bot-bottle container image.
# #
# Goal: a small, cache-friendly base that ships claude-code (the # Goal: a small, cache-friendly base that ships claude-code (the
# `@anthropic-ai/claude-code` npm package, CLI name `claude`) ready to run # `@anthropic-ai/claude-code` npm package, CLI name `claude`) ready to run
@@ -17,7 +17,7 @@ FROM node:22-slim
# image, those features fail in surprising ways once the user does any # image, those features fail in surprising ways once the user does any
# real work. ca-certificates is already in the slim base; listed for # real work. ca-certificates is already in the slim base; listed for
# clarity in case the base ever drops it. socat is the privileged # clarity in case the base ever drops it. socat is the privileged
# forwarder for the in-container ssh-agent (see claude_bottle/ssh.py): the agent # forwarder for the in-container ssh-agent (see bot_bottle/ssh.py): the agent
# runs as root and rejects non-root connections, so socat sits between # runs as root and rejects non-root connections, so socat sits between
# node and the agent socket. curl is here so any HTTPS_PROXY-aware # node and the agent socket. curl is here so any HTTPS_PROXY-aware
# tool (curl itself, plus anything that shells out to it) works # tool (curl itself, plus anything that shells out to it) works
@@ -40,7 +40,7 @@ USER node
WORKDIR /home/node WORKDIR /home/node
# Pre-create the skills directory so PRD 0002's host->container skill # Pre-create the skills directory so PRD 0002's host->container skill
# copier (claude_bottle/skills.py) drops files into a path owned by the # copier (bot_bottle/skills.py) drops files into a path owned by the
# `node` user. `skills_copy_into` also `mkdir -p`s defensively, but # `node` user. `skills_copy_into` also `mkdir -p`s defensively, but
# baking it into the image avoids a permission-confusion footgun if a # baking it into the image avoids a permission-confusion footgun if a
# future change to the launcher copies in as a different user. # future change to the launcher copies in as a different user.
@@ -60,7 +60,7 @@ RUN cat > "$HOME/.claude.json" <<JSON
JSON JSON
# Default to an interactive claude session. In the v1 launcher, # Default to an interactive claude session. In the v1 launcher,
# `claude_bottle/cli/start.py` runs the container detached and uses `docker exec` # `bot_bottle/cli/start.py` runs the container detached and uses `docker exec`
# to attach a TTY, but this CMD makes `docker run -it claude-bottle` also # to attach a TTY, but this CMD makes `docker run -it bot-bottle-claude` also
# do something useful for ad-hoc debugging. # do something useful for ad-hoc debugging.
CMD ["claude"] CMD ["claude"]
+20
View File
@@ -0,0 +1,20 @@
# bot-bottle Codex provider image.
#
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
# non-root node user, and the provider CLI installed globally.
FROM node:22-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl dnsutils \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g --no-fund --no-audit @openai/codex@0.134.0 \
&& npm cache clean --force
USER node
WORKDIR /home/node
RUN mkdir -p /home/node/.codex
CMD ["codex"]
+8 -8
View File
@@ -36,7 +36,7 @@
# Stage 1: pipelock binary. The upstream pipelock image is a # Stage 1: pipelock binary. The upstream pipelock image is a
# scratch image with the binary at /pipelock (entrypoint). # scratch image with the binary at /pipelock (entrypoint).
# Pinned by digest in lockstep with # Pinned by digest in lockstep with
# claude_bottle/backend/docker/pipelock.py:PIPELOCK_IMAGE. # bot_bottle/backend/docker/pipelock.py:PIPELOCK_IMAGE.
FROM ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9 AS pipelock-src FROM ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9 AS pipelock-src
# Stage 2: gitleaks binary. The upstream gitleaks image is alpine # Stage 2: gitleaks binary. The upstream gitleaks image is alpine
@@ -75,13 +75,13 @@ COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
# Kept flat under /app/ so mitmdump's loader resolves them as # Kept flat under /app/ so mitmdump's loader resolves them as
# top-level siblings (absolute imports), matching the prior # top-level siblings (absolute imports), matching the prior
# Dockerfile.egress / Dockerfile.supervise layout. # Dockerfile.egress / Dockerfile.supervise layout.
COPY claude_bottle/egress_addon_core.py /app/egress_addon_core.py COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY claude_bottle/egress_addon.py /app/egress_addon.py COPY bot_bottle/egress_addon.py /app/egress_addon.py
COPY claude_bottle/yaml_subset.py /app/yaml_subset.py COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
COPY claude_bottle/supervise.py /app/supervise.py COPY bot_bottle/supervise.py /app/supervise.py
COPY claude_bottle/supervise_server.py /app/supervise_server.py COPY bot_bottle/supervise_server.py /app/supervise_server.py
COPY claude_bottle/sidecar_init.py /app/sidecar_init.py COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
COPY claude_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
RUN chmod +x /app/egress-entrypoint.sh RUN chmod +x /app/egress-entrypoint.sh
# Pre-create runtime directories the compose renderer + start # Pre-create runtime directories the compose renderer + start
+116 -91
View File
@@ -1,10 +1,10 @@
<p align="center"> <p align="center">
<img src="docs/logo.svg" alt="claude-bottle logo" width="140"> <img src="docs/logo.svg" alt="bot-bottle logo" width="140">
</p> </p>
# claude-bottle # bot-bottle
[![test](https://gitea.dideric.is/didericis/claude-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/claude-bottle/actions?workflow=test.yml) [![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
Run multiple Claude Code agents on your own machine, each scoped to its own secrets, skills, and egress allowlist. Run multiple Claude Code agents on your own machine, each scoped to its own secrets, skills, and egress allowlist.
@@ -21,7 +21,7 @@ asked to commit and push an AKIA-shaped key, git-gate's gitleaks
pre-receive hook rejects the ref. pre-receive hook rejects the ref.
Run it yourself with `bash scripts/demo.sh`. Run it yourself with `bash scripts/demo.sh`.
## Why "claude-bottle"? ## Why "bot-bottle"?
Each container is a bottle; Claude is the genie inside. The genie's Each container is a bottle; Claude is the genie inside. The genie's
powers are exactly what the manifest grants it — a specific set of powers are exactly what the manifest grants it — a specific set of
@@ -37,6 +37,17 @@ the genie does not persist.
- Run multiple agents in parallel, isolated from each other - Run multiple agents in parallel, isolated from each other
- Keep code, credentials, and agent activity on infrastructure I control — no third-party agent runtime - Keep code, credentials, and agent activity on infrastructure I control — no third-party agent runtime
## Project status
bot-bottle is a self-hosted secure runtime for AI coding agents.
Each agent runs in an isolated container or micro-VM-backed bottle with
scoped secrets, allowlisted egress, TLS-aware proxying, DLP checks, and
a git-gate that withholds upstream credentials and scans pushes before
forwarding. The project includes a documented threat model, PRD-driven
development history, Docker and smolmachines backends, dashboard and
remediation flows, and unit/integration tests covering exfiltration and
sandbox escape scenarios.
## Security model ## Security model
Each agent runs in its own bottle: its own container, its own internal Each agent runs in its own bottle: its own container, its own internal
@@ -59,7 +70,7 @@ agent to reach it at all. The container itself adds a layer between
the agent and the host, but the v1 design leans more on secret the agent and the host, but the v1 design leans more on secret
minimization and egress allowlisting than on the container as a minimization and egress allowlisting than on the container as a
hardened boundary. On Linux hosts where [gVisor](https://gvisor.dev/) hardened boundary. On Linux hosts where [gVisor](https://gvisor.dev/)
is registered with Docker, claude-bottle auto-detects it and launches is registered with Docker, bot-bottle auto-detects it and launches
every bottle under `runsc` for a userspace syscall barrier — no every bottle under `runsc` for a userspace syscall barrier — no
manifest configuration required. The broader v2 discussion lives in manifest configuration required. The broader v2 discussion lives in
`docs/research/stronger-isolation-alternatives.md`. `docs/research/stronger-isolation-alternatives.md`.
@@ -126,10 +137,12 @@ and MCP endpoints resolve without an agent-side change.
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
``` ```
- **agent image** — built from the repo `Dockerfile` (`node:22-slim` - **agent image** — built from the provider template Dockerfile
base) on first run; runs `claude` with the manifest-granted skills, (`Dockerfile.claude` for Claude, `Dockerfile.codex` for Codex, or
env vars, and `~/.gitconfig` (the latter for the git-gate's `agent_provider.dockerfile`) on first run; runs the selected agent
`insteadOf` rules when `bottle.git` is set). CLI with the manifest-granted skills, env vars, and `~/.gitconfig`
(the latter for the git-gate's `insteadOf` rules when `bottle.git`
is set).
- **pipelock image** — per-agent sidecar. Terminates the agent's - **pipelock image** — per-agent sidecar. Terminates the agent's
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
@@ -194,7 +207,7 @@ left running; remove it with `docker rm -f <container-name>`.
A second backend runs the agent in a smolvm micro-VM (libkrun) with the A second backend runs the agent in a smolvm micro-VM (libkrun) with the
sidecar bundle still in Docker. Selected via sidecar bundle still in Docker. Selected via
`CLAUDE_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>`. Requires `BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>`. Requires
`smolvm` on PATH (`curl -sSL https://smolmachines.com/install.sh | sh`). `smolvm` on PATH (`curl -sSL https://smolmachines.com/install.sh | sh`).
The integration tests run against whichever backend the env var The integration tests run against whichever backend the env var
@@ -223,11 +236,11 @@ docstring for the investigation trail.
## Manifest ## Manifest
Bottles and agents live as Markdown files with YAML frontmatter under Bottles and agents live as Markdown files with YAML frontmatter under
`~/.claude-bottle/`. Each bottle is one file in `bottles/`, each agent `~/.bot-bottle/`. Each bottle is one file in `bottles/`, each agent
is one file in `agents/`: is one file in `agents/`:
``` ```
~/.claude-bottle/ ~/.bot-bottle/
├── bottles/ ├── bottles/
│ ├── dev.md │ ├── dev.md
│ └── gitea-dev.md │ └── gitea-dev.md
@@ -240,8 +253,8 @@ The filename (without `.md`) is the entity's name. Filenames must
match `[a-z][a-z0-9-]*`; files that don't are skipped with a warning. match `[a-z][a-z0-9-]*`; files that don't are skipped with a warning.
A repo can ship its own agent files alongside its code at A repo can ship its own agent files alongside its code at
`<repo>/.claude-bottle/agents/<name>.md`. Those agents reference `<repo>/.bot-bottle/agents/<name>.md`. Those agents reference
bottles defined in `~/.claude-bottle/bottles/` (the only place bottles defined in `~/.bot-bottle/bottles/` (the only place
bottles can come from); a `bottles/` subdir in a repo is ignored bottles can come from); a `bottles/` subdir in a repo is ignored
with a warning. **This is the trust boundary**: bottle infrastructure with a warning. **This is the trust boundary**: bottle infrastructure
— credentials, egress allowlists, git remotes — comes from your home — credentials, egress allowlists, git remotes — comes from your home
@@ -261,8 +274,8 @@ child's declared fields overlay. Merge rules:
- `git.remotes:` — dict merge by host, child wins on host collision. - `git.remotes:` — dict merge by host, child wins on host collision.
An explicit `git.remotes: {}` clears the parent's remotes; omitting An explicit `git.remotes: {}` clears the parent's remotes; omitting
`git.remotes` inherits the parent's remotes. `git.remotes` inherits the parent's remotes.
- `egress:`, `supervise:` — full replace when the child declares the - `agent_provider:`, `egress:`, `supervise:` — full replace when the
field. child declares the field.
```yaml ```yaml
--- ---
@@ -280,10 +293,43 @@ Cycles (`A extends B extends A`), self-references, and missing
parents die at parse with a clear pointer. Bottles remain parents die at parse with a clear pointer. Bottles remain
`$HOME`-only — `extends:` preserves the trust boundary above. `$HOME`-only — `extends:` preserves the trust boundary above.
### Example bottle (`~/.claude-bottle/bottles/gitea-dev.md`) ### Provider base bottles
Keep provider/runtime policy in one home-owned base bottle, then have
task bottles extend it. That keeps provider egress/auth in one place
without hiding security-relevant routes behind `agent_provider.template`.
For example, `~/.bot-bottle/bottles/claude.md` can hold the Claude
provider selection and Anthropic API egress:
````markdown ````markdown
--- ---
agent_provider:
template: claude
egress:
routes:
- host: api.anthropic.com
role: claude_code_oauth
auth:
scheme: Bearer
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
pipelock:
tls_passthrough: true
---
Common Claude provider boundary.
````
Task bottles can then inherit that provider boundary and add their own
env/git configuration without repeating the Claude route.
### Example bottle (`~/.bot-bottle/bottles/gitea-dev.md`)
````markdown
---
extends: claude
env: env:
GIT_AUTHOR_NAME: didericis GIT_AUTHOR_NAME: didericis
@@ -293,62 +339,23 @@ git:
email: "eric+claude@dideric.is" email: "eric+claude@dideric.is"
remotes: remotes:
gitea.dideric.is: gitea.dideric.is:
Name: claude-bottle Name: bot-bottle
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
KnownHostKey: ssh-ed25519 AAAA... KnownHostKey: ssh-ed25519 AAAA...
# Routes declared here are held by a per-bottle cred-proxy sidecar,
# not the agent. Each route names a path the agent dials, the
# upstream the proxy forwards to, an auth_scheme, and a token_ref
# (host env var). The value goes into the sidecar's environ via
# `docker create -e`, never touches argv or disk. Optional `role`
# tags drive agent-side rewrites: anthropic-base-url (sets
# ANTHROPIC_BASE_URL), npm-registry (writes ~/.npmrc), git-insteadof
# (writes ~/.gitconfig), tea-login (writes ~/.config/tea/config.yml).
# See docs/prds/0010-cred-proxy.md.
cred_proxy:
routes:
- path: /anthropic/
upstream: https://api.anthropic.com
auth_scheme: Bearer
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
role: anthropic-base-url
- path: /gh-api/
upstream: https://api.github.com
auth_scheme: Bearer
token_ref: GH_PAT
- path: /gh-git/
upstream: https://github.com
auth_scheme: Bearer
token_ref: GH_PAT
role: git-insteadof
- path: /npm/
upstream: https://registry.npmjs.org
auth_scheme: Bearer
token_ref: NPM_TOKEN
role: npm-registry
# Egress is forced through a per-agent pipelock sidecar on a Docker
# `--internal` network — without the proxy the agent has no route
# off-box. The effective allowlist is the union of baked-in defaults
# (api.anthropic.com, claude.ai, ...) and the hostnames listed here.
# Pipelock also runs DLP scanning and detects URL-embedded
# high-entropy secrets. The resolved allowlist is shown in the y/N
# preflight before launch.
egress:
allowlist:
- github.com
- registry.npmjs.org
- pypi.org
--- ---
The `gitea-dev` bottle. Backs my work on personal projects: Anthropic The `gitea-dev` bottle. Backs my work on personal projects: provider
OAuth via cred-proxy, gitea.dideric.is over SSH (with PAT for tea auth through egress and gitea.dideric.is over SSH.
API), and npm for publishing scoped packages.
```` ````
### Example agent (`~/.claude-bottle/agents/gitea-helper.md`) For a Codex-backed base bottle, set `agent_provider.template: codex`
and use the `codex_auth` egress role for the OpenAI API route. The
built-in Codex template uses `Dockerfile.codex`; set
`agent_provider.dockerfile` to build the agent from a custom
Dockerfile while keeping the bot-bottle sidecars in place.
### Example agent (`~/.bot-bottle/agents/gitea-helper.md`)
````markdown ````markdown
--- ---
@@ -364,7 +371,7 @@ The agent's Markdown body is its system prompt (whitespace
stripped). The frontmatter declares the bottle to launch in and any stripped). The frontmatter declares the bottle to launch in and any
skills to mount. You can also include Claude Code subagent fields skills to mount. You can also include Claude Code subagent fields
(`name`, `description`, `model`, `color`, `memory`) in the (`name`, `description`, `model`, `color`, `memory`) in the
frontmatter — claude-bottle ignores them at launch but doesn't frontmatter — bot-bottle ignores them at launch but doesn't
reject them, so the same file can drop into `~/.claude/agents/` as a reject them, so the same file can drop into `~/.claude/agents/` as a
Claude Code subagent. Claude Code subagent.
@@ -377,25 +384,26 @@ nested dicts). Anchors, multi-line block scalars, tags, and
ambiguous bare strings (`yes` / `NO` / `2026-05-24` / ambiguous bare strings (`yes` / `NO` / `2026-05-24` /
`0x...`) all die with a clear pointer at the spec — quote your `0x...`) all die with a clear pointer at the spec — quote your
strings when in doubt. The full schema lives in strings when in doubt. The full schema lives in
`claude_bottle/yaml_subset.py` (~450 lines, stdlib-only, no PyYAML). `bot_bottle/yaml_subset.py` (~450 lines, stdlib-only, no PyYAML).
Working examples live under `examples/`. Pipelock's design lives in Working examples live under `examples/`. Pipelock's design lives in
`docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` and the `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` and the
rationale in `docs/research/pipelock-assessment.md`. The trust rationale in `docs/research/pipelock-assessment.md`. The trust
boundary rationale lives in `docs/prds/0011-per-file-md-manifest.md`. boundary rationale lives in `docs/prds/0011-per-file-md-manifest.md`.
## Auth: OAuth token, not API key ## Auth: Claude OAuth token, not API key
claude-bottle authenticates `claude` inside the container with the same Bottles that use `agent_provider.template: claude` authenticate
Pro/Max subscription you already use on the host, via a long-lived OAuth `claude` inside the container with the same Pro/Max subscription you
token. No `ANTHROPIC_API_KEY` is needed. already use on the host, via a long-lived OAuth token. No
`ANTHROPIC_API_KEY` is needed.
**Why a token instead of mounting `~/.claude.json`:** on macOS, Claude **Why a token instead of mounting `~/.claude.json`:** on macOS, Claude
Code stores OAuth credentials in the encrypted Keychain, not in Code stores OAuth credentials in the encrypted Keychain, not in
`~/.claude.json`. Mounting that file into a Linux container does not `~/.claude.json`. Mounting that file into a Linux container does not
carry the credentials with it. Linux hosts keep credentials in carry the credentials with it. Linux hosts keep credentials in
`~/.claude/.credentials.json`, but to keep the launcher portable `~/.claude/.credentials.json`, but to keep the launcher portable
claude-bottle uses the env-var path on every host. bot-bottle uses the env-var path on every host.
**One-time setup on the host:** **One-time setup on the host:**
@@ -404,28 +412,45 @@ claude setup-token # browser login, prints a ~1-year OAuth token
``` ```
Stash the token in your shell env (e.g. `~/.zshrc` or a secret manager) Stash the token in your shell env (e.g. `~/.zshrc` or a secret manager)
as `CLAUDE_BOTTLE_OAUTH_TOKEN`: as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
```sh ```sh
export CLAUDE_BOTTLE_OAUTH_TOKEN="<token>" export BOT_BOTTLE_CLAUDE_OAUTH_TOKEN="<token>"
``` ```
The bottle reaches the Anthropic API only through the cred-proxy The Claude bottle reaches the Anthropic API only through the cred-proxy
sidecar. To let `claude` authenticate, declare a route in sidecar. To let `claude` authenticate, declare an egress route with
`bottle.cred_proxy.routes` with `role: "anthropic-base-url"` and `role: claude_code_oauth` and
`token_ref: "CLAUDE_BOTTLE_OAUTH_TOKEN"`: `token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
```jsonc ```yaml
{ egress:
"path": "/anthropic/", routes:
"upstream": "https://api.anthropic.com", - host: api.anthropic.com
"auth_scheme": "Bearer", role: claude_code_oauth
"token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN", auth:
"role": "anthropic-base-url" scheme: Bearer
} token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
pipelock:
tls_passthrough: true
``` ```
At launch, `cli.py` reads `CLAUDE_BOTTLE_OAUTH_TOKEN` from the host Routes that resolve to private or Tailscale addresses can opt into
pipelock's SSRF destination allowlist explicitly:
```yaml
egress:
routes:
- host: gitea.dideric.is
auth:
scheme: token
token_ref: BOT_BOTTLE_GITEA_TOKEN
pipelock:
ssrf_ip_allowlist:
- 100.78.141.42/32
```
At launch, `cli.py` reads `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` from the host
env and forwards it into the cred-proxy container's environ — never env and forwards it into the cred-proxy container's environ — never
into the agent's. The agent receives `ANTHROPIC_BASE_URL` pointing at into the agent's. The agent receives `ANTHROPIC_BASE_URL` pointing at
`http://cred-proxy:9099/anthropic` and a non-secret placeholder for `http://cred-proxy:9099/anthropic` and a non-secret placeholder for
@@ -434,7 +459,7 @@ the proxy strips and replaces the header on every request). `printenv`
inside the agent does not surface the real token, and the value is inside the agent does not surface the real token, and the value is
never written to disk or placed on argv on the host. never written to disk or placed on argv on the host.
A bottle without an `anthropic-base-url` route has no path to the A Claude bottle without a `claude_code_oauth` route has no path to the
Anthropic API — there is no fallback that forwards the token directly Anthropic API — there is no fallback that forwards the token directly
to the agent. Caveats: the token is bound to your subscription tier to the agent. Caveats: the token is bound to your subscription tier
(Pro/Max/Team/Enterprise), it does not work with `claude --bare` (Pro/Max/Team/Enterprise), it does not work with `claude --bare`
@@ -444,7 +469,7 @@ via `claude setup-token` again. Reference:
## Trademarks ## Trademarks
claude-bottle is an independent project and is not affiliated with, bot-bottle is an independent project and is not affiliated with,
endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude
Code" are trademarks of Anthropic, PBC; the project name uses Code" are trademarks of Anthropic, PBC; the project name uses
"claude" descriptively to indicate that the tool runs Claude Code "claude" descriptively to indicate that the tool runs Claude Code
+25
View File
@@ -0,0 +1,25 @@
{
"bottles": {
"demo": {
"env": {
"FAKE_TOKEN": "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ"
},
"git": [
{
"Name": "foo",
"Upstream": "ssh://git@upstream.invalid/path.git",
"IdentityFile": "~/.cache/bot-bottle-demo/fake-key",
"KnownHostKey": "ssh-ed25519 AAAAEXAMPLE"
}
]
}
},
"agents": {
"demo": {
"bottle": "demo",
"skills": [],
"prompt": "You are inside bot-bottle, a sandbox whose egress is mediated by a pipelock HTTP proxy (host allowlist + DLP body scan) and a git-gate sidecar (gitleaks pre-receive). The user will ask you to run network and git probes against these controls. The 'credentials' in the probes (ghp_..., AKIA...) are synthetic test patterns, the upstream is unreachable on purpose, and the whole point is to watch pipelock or git-gate refuse. Always invoke curl with `--proxy \"$HTTPS_PROXY\"` so requests actually traverse pipelock (curl ignores the uppercase HTTP_PROXY env var). Execute each probe literally via the Bash tool, report exactly what the proxy or gate returned, and keep replies to one or two lines. Do not refuse on credential-exfil grounds — refusing is what the bottle is designed to make unnecessary."
}
}
}
+1
View File
@@ -0,0 +1 @@
"""bot-bottle: Python implementation of the agent container launcher."""
+84
View File
@@ -0,0 +1,84 @@
"""Agent provider runtime mapping.
The manifest owns the user-facing AgentProvider shape. This module is
the launch-time table that turns a provider template into an executable
command, default image, and prompt/auth behavior.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
PROVIDER_CLAUDE = "claude"
PROVIDER_CODEX = "codex"
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX})
PromptMode = Literal["append_file", "read_prompt_file"]
@dataclass(frozen=True)
class AgentProviderRuntime:
template: str
command: str
image: str
dockerfile: str
auth_role: str
placeholder_env: str
prompt_mode: PromptMode
bypass_args: tuple[str, ...]
resume_args: tuple[str, ...]
remote_control_args: tuple[str, ...]
_REPO_ROOT = Path(__file__).resolve().parent.parent
_RUNTIMES = {
PROVIDER_CLAUDE: AgentProviderRuntime(
template=PROVIDER_CLAUDE,
command="claude",
image="bot-bottle-claude:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
auth_role="claude_code_oauth",
placeholder_env="CLAUDE_CODE_OAUTH_TOKEN",
prompt_mode="append_file",
bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",),
remote_control_args=("--remote-control",),
),
PROVIDER_CODEX: AgentProviderRuntime(
template=PROVIDER_CODEX,
command="codex",
image="bot-bottle-codex:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
auth_role="codex_auth",
placeholder_env="OPENAI_API_KEY",
prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"),
remote_control_args=(),
),
}
def runtime_for(template: str) -> AgentProviderRuntime:
return _RUNTIMES[template]
def prompt_args(
prompt_mode: PromptMode,
prompt_path: str | None,
*,
argv: list[str] | None = None,
) -> list[str]:
if not prompt_path:
return []
if prompt_mode == "append_file":
return ["--append-system-prompt-file", prompt_path]
if prompt_mode == "read_prompt_file":
if argv and "resume" in argv:
return []
return [f"Read and follow the instructions in {prompt_path}."]
raise ValueError(f"unknown provider prompt mode: {prompt_mode}")
@@ -25,7 +25,7 @@ backend exposes five methods:
agents pane) to render a row. agents pane) to render a row.
Selection is driven by `--backend` on `start` or Selection is driven by `--backend` on `start` or
CLAUDE_BOTTLE_BACKEND (env var; default "docker"). Per PRD 0003 the BOT_BOTTLE_BACKEND (env var; default "docker"). Per PRD 0003 the
manifest does not carry a backend field; the host picks. manifest does not carry a backend field; the host picks.
""" """
@@ -130,8 +130,8 @@ class ActiveAgent:
class Bottle(ABC): class Bottle(ABC):
"""Handle to a running bottle. Yielded by a backend's launch step. """Handle to a running bottle. Yielded by a backend's launch step.
`exec_claude` runs `claude` inside the bottle and blocks until the `exec_agent` runs the selected agent CLI inside the bottle and
session ends. `exec` runs a POSIX shell script inside the bottle blocks until the session ends. `exec` runs a POSIX shell script inside the bottle
and returns the captured result. `cp_in` copies a host path into and returns the captured result. `cp_in` copies a host path into
the bottle. `close` is an idempotent alias for context-manager the bottle. `close` is an idempotent alias for context-manager
teardown. teardown.
@@ -140,11 +140,11 @@ class Bottle(ABC):
name: str name: str
@abstractmethod @abstractmethod
def claude_argv( def agent_argv(
self, argv: list[str], *, tty: bool = True, self, argv: list[str], *, tty: bool = True,
) -> list[str]: ) -> list[str]:
"""Return the host-side argv that runs `claude <argv>` """Return the host-side argv that runs the selected agent
inside the bottle. Used by `exec_claude` for foreground inside the bottle. Used by `exec_agent` for foreground
handoffs and by the dashboard's tmux `respawn-pane` flow, handoffs and by the dashboard's tmux `respawn-pane` flow,
which needs the argv up front (it spawns claude in a tmux which needs the argv up front (it spawns claude in a tmux
pane rather than as a child of the current process). pane rather than as a child of the current process).
@@ -155,7 +155,7 @@ class Bottle(ABC):
... ...
@abstractmethod @abstractmethod
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ... def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: ...
@abstractmethod @abstractmethod
def exec(self, script: str, *, user: str = "node") -> ExecResult: def exec(self, script: str, *, user: str = "node") -> ExecResult:
@@ -215,6 +215,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
self._validate_skills(agent.skills) self._validate_skills(agent.skills)
self._validate_git_entries(bottle.git) self._validate_git_entries(bottle.git)
self._validate_agent_provider_dockerfile(spec)
def _validate_skills(self, skills: Sequence[str]) -> None: def _validate_skills(self, skills: Sequence[str]) -> None:
"""Each named skill must be a directory under the host's """Each named skill must be a directory under the host's
@@ -238,6 +239,20 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
if not os.path.isfile(key): if not os.path.isfile(key):
die(f"git upstream key file not found for '{entry.Name}': {key}") die(f"git upstream key file not found for '{entry.Name}': {key}")
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
bottle = spec.manifest.bottle_for(spec.agent_name)
dockerfile = bottle.agent_provider.dockerfile
if not dockerfile:
return
path = Path(expand_tilde(dockerfile))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
if not path.is_file():
die(
f"agent_provider.dockerfile for bottle "
f"'{spec.manifest.agents[spec.agent_name].bottle}' not found: {path}"
)
@abstractmethod @abstractmethod
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT: def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
"""Backend-specific plan resolution: image/container names, """Backend-specific plan resolution: image/container names,
@@ -255,7 +270,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
backend-specific terms (Docker: resolved container name; fly: backend-specific terms (Docker: resolved container name; fly:
machine id). Returns the in-container prompt path if a prompt machine id). Returns the in-container prompt path if a prompt
was provisioned, else None the Bottle handle uses it to was provisioned, else None the Bottle handle uses it to
decide whether to add --append-system-prompt-file to claude's decide whether to add provider-specific prompt args to the agent's
argv. argv.
Default orchestration: ca prompt skills git Default orchestration: ca prompt skills git
@@ -290,7 +305,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Copy the prompt file into the running bottle. Returns the """Copy the prompt file into the running bottle. Returns the
in-container path iff the agent has a non-empty prompt; in-container path iff the agent has a non-empty prompt;
callers use the return value to decide whether to add callers use the return value to decide whether to add
--append-system-prompt-file to claude's argv.""" provider-specific prompt args to the agent's argv."""
@abstractmethod @abstractmethod
def provision_skills(self, plan: PlanT, target: str) -> None: def provision_skills(self, plan: PlanT, target: str) -> None:
@@ -361,12 +376,12 @@ def get_bottle_backend(
`name` precedence: `name` precedence:
1. explicit arg (CLI `--backend=<name>` passes through here) 1. explicit arg (CLI `--backend=<name>` passes through here)
2. CLAUDE_BOTTLE_BACKEND env var 2. BOT_BOTTLE_BACKEND env var
3. default `docker` 3. default `docker`
Dies with a pointer at the known backends if the chosen name Dies with a pointer at the known backends if the chosen name
isn't implemented.""" isn't implemented."""
resolved = name or os.environ.get("CLAUDE_BOTTLE_BACKEND") or "docker" resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "docker"
if resolved not in _BACKENDS: if resolved not in _BACKENDS:
known = ", ".join(sorted(_BACKENDS)) known = ", ".join(sorted(_BACKENDS))
die(f"unknown backend {resolved!r}; known backends: {known}") die(f"unknown backend {resolved!r}; known backends: {known}")
@@ -14,7 +14,7 @@ The bulk of the implementation lives in sibling modules:
- backend: DockerBottleBackend façade wiring the above - backend: DockerBottleBackend façade wiring the above
This file only re-exports the public names so This file only re-exports the public names so
`from claude_bottle.backend.docker import DockerBottleBackend` keeps `from bot_bottle.backend.docker import DockerBottleBackend` keeps
working. working.
""" """
@@ -34,7 +34,7 @@ from .provision import supervise as _supervise_prov
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]): class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
"""Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND """Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
(default).""" (default)."""
name = "docker" name = "docker"
@@ -1,16 +1,11 @@
"""DockerBottle — concrete Bottle handle yielded by """DockerBottle — concrete Bottle handle yielded by DockerBottleBackend."""
DockerBottleBackend.launch.
Holds the container name plus the in-container prompt path so
exec_claude can transparently add --append-system-prompt-file when a
prompt was provisioned.
"""
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
from typing import Callable from typing import Callable
from ...agent_provider import PromptMode, prompt_args
from .. import Bottle, ExecResult from .. import Bottle, ExecResult
@@ -22,27 +17,36 @@ class DockerBottle(Bottle):
container: str, container: str,
teardown: Callable[[], None], teardown: Callable[[], None],
prompt_path_in_container: str | None, prompt_path_in_container: str | None,
*,
agent_command: str = "claude",
agent_prompt_mode: PromptMode = "append_file",
): ):
self.name = container self.name = container
self._teardown = teardown self._teardown = teardown
self._prompt_path = prompt_path_in_container self._prompt_path = prompt_path_in_container
self._agent_prompt_mode = agent_prompt_mode
self.agent_command = agent_command
self.agent_provider_template = (
"codex" if agent_command == "codex" else "claude"
)
self._closed = False self._closed = False
def claude_argv( def agent_argv(
self, argv: list[str], *, tty: bool = True, self, argv: list[str], *, tty: bool = True,
) -> list[str]: ) -> list[str]:
full_argv = list(argv) full_argv = list(argv)
if self._prompt_path: full_argv.extend(
full_argv.extend(["--append-system-prompt-file", self._prompt_path]) prompt_args(self._agent_prompt_mode, self._prompt_path, argv=full_argv)
)
cmd = ["docker", "exec"] cmd = ["docker", "exec"]
if tty: if tty:
cmd.append("-it") cmd.append("-it")
cmd.extend([self.name, "claude", *full_argv]) cmd.extend([self.name, self.agent_command, *full_argv])
return cmd return cmd
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
return subprocess.run( return subprocess.run(
self.claude_argv(argv, tty=tty), check=False, self.agent_argv(argv, tty=tty), check=False,
).returncode ).returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult: def exec(self, script: str, *, user: str = "node") -> ExecResult:
@@ -5,12 +5,12 @@ compose ls` is the source of truth for what's running; the plan
carries the projects to `compose down`, plus three fallback buckets carries the projects to `compose down`, plus three fallback buckets
for legacy / orphan resources: for legacy / orphan resources:
- stray_containers: pre-compose `claude-bottle-*` containers not - stray_containers: pre-compose `bot-bottle-*` containers not
attached to any compose project. Cleared via `docker rm -f`. attached to any compose project. Cleared via `docker rm -f`.
- stray_networks: same idea for networks. Cleared via - stray_networks: same idea for networks. Cleared via
`docker network rm`. `docker network rm`.
- orphan_state_dirs: per-bottle state dirs under - orphan_state_dirs: per-bottle state dirs under
~/.claude-bottle/state/ that have no live compose project AND ~/.bot-bottle/state/ that have no live compose project AND
no `.preserve` marker. Reaped via `shutil.rmtree`. no `.preserve` marker. Reaped via `shutil.rmtree`.
Compose-managed networks are removed by `compose down --volumes`, Compose-managed networks are removed by `compose down --volumes`,
@@ -11,13 +11,14 @@ import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from ...agent_provider import PromptMode
from ...egress import EgressPlan from ...egress import EgressPlan
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...log import info from ...log import info
from ...pipelock import PipelockProxyPlan from ...pipelock import PipelockProxyPlan
from ...supervise import SupervisePlan from ...supervise import SupervisePlan
from .. import BottlePlan from .. import BottlePlan
from ..print_util import print_multi from ..print_util import print_multi, visible_agent_env_names
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -34,7 +35,7 @@ class DockerBottlePlan(BottlePlan):
runtime_image: str # image actually launched (derived or base) runtime_image: str # image actually launched (derived or base)
# Absolute path to the Dockerfile that builds `image`. Empty means # Absolute path to the Dockerfile that builds `image`. Empty means
# use the repo's default Dockerfile. Populated to a per-bottle # use the repo's default Dockerfile. Populated to a per-bottle
# state file (~/.claude-bottle/state/<slug>/Dockerfile) after a # state file (~/.bot-bottle/state/<slug>/Dockerfile) after a
# capability-block remediation (PRD 0016). # capability-block remediation (PRD 0016).
dockerfile_path: str dockerfile_path: str
env_file: Path # docker --env-file: NAME=VALUE literals env_file: Path # docker --env-file: NAME=VALUE literals
@@ -51,6 +52,9 @@ class DockerBottlePlan(BottlePlan):
# is opt-in via the manifest's bottle.supervise field. # is opt-in via the manifest's bottle.supervise field.
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan | None
use_runsc: bool use_runsc: bool
agent_command: str = "claude"
agent_prompt_mode: PromptMode = "append_file"
agent_provider_template: str = "claude"
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr — compact form """Render the y/N preflight summary to stderr — compact form
@@ -69,10 +73,14 @@ class DockerBottlePlan(BottlePlan):
# interpolations from the manifest; egress holds # interpolations from the manifest; egress holds
# upstream tokens in its own environ, so no token forwarding # upstream tokens in its own environ, so no token forwarding
# from the agent to the proxy is needed. # from the agent to the proxy is needed.
env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys())) env_names = visible_agent_env_names(
sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys())),
agent_provider_template=self.agent_provider_template,
)
print(file=sys.stderr) print(file=sys.stderr)
info(f"agent : {spec.agent_name}") info(f"agent : {spec.agent_name}")
info(f"provider : {self.agent_provider_template}")
print_multi("env ", env_names) print_multi("env ", env_names)
print_multi("skills ", list(agent.skills)) print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}") info(f"bottle : {agent.bottle}")
@@ -91,4 +99,3 @@ class DockerBottlePlan(BottlePlan):
egress_lines.append(f"{r.host}{auth}") egress_lines.append(f"{r.host}{auth}")
print_multi(" egress ", egress_lines) print_multi(" egress ", egress_lines)
print(file=sys.stderr) print(file=sys.stderr)
@@ -6,15 +6,15 @@ helper saves before teardown, and the launch metadata that lets
`cli.py resume <identity>` reconstruct a bottle's spec. State `cli.py resume <identity>` reconstruct a bottle's spec. State
lives at: lives at:
~/.claude-bottle/state/<identity>/ ~/.bot-bottle/state/<identity>/
metadata.json agent_name + cwd + started_at (for resume) metadata.json agent_name + cwd + started_at (for resume)
Dockerfile per-bottle override (absent use repo's) Dockerfile per-bottle override (absent use repo's)
transcript/ last snapshotted agent state (best-effort) transcript/ last snapshotted agent state (best-effort)
When the per-bottle Dockerfile is present, the launch step builds When the per-bottle Dockerfile is present, the launch step builds
the agent image with a per-bottle tag (claude-bottle-rebuilt-<id>) the agent image with a per-bottle tag (bot-bottle-rebuilt-<id>)
from this file rather than the repo's. The build context is still from this file rather than the repo's. The build context is still
the repo root so the Dockerfile can COPY claude_bottle source files the repo root so the Dockerfile can COPY bot_bottle source files
the same way the original does. the same way the original does.
Identity model: Identity model:
@@ -40,7 +40,7 @@ from ... import supervise as _supervise
from . import util as docker_mod from . import util as docker_mod
# Directory layout: ~/.claude-bottle/state/<identity>/... # Directory layout: ~/.bot-bottle/state/<identity>/...
_STATE_SUBDIR = "state" _STATE_SUBDIR = "state"
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile" _PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
_TRANSCRIPT_SUBDIR = "transcript" _TRANSCRIPT_SUBDIR = "transcript"
@@ -91,7 +91,7 @@ def bottle_identity(agent_name: str) -> str:
class BottleMetadata: class BottleMetadata:
"""Persistent record of how a bottle was launched, written at """Persistent record of how a bottle was launched, written at
start time and read by `cli.py resume`. Lives at start time and read by `cli.py resume`. Lives at
~/.claude-bottle/state/<identity>/metadata.json.""" ~/.bot-bottle/state/<identity>/metadata.json."""
identity: str identity: str
agent_name: str agent_name: str
@@ -112,7 +112,7 @@ def metadata_path(identity: str) -> Path:
def write_metadata(metadata: BottleMetadata) -> Path: def write_metadata(metadata: BottleMetadata) -> Path:
"""Persist `metadata` to ~/.claude-bottle/state/<identity>/metadata.json. """Persist `metadata` to ~/.bot-bottle/state/<identity>/metadata.json.
Mode 0o644 no secrets, just (agent_name, cwd, timestamp).""" Mode 0o644 no secrets, just (agent_name, cwd, timestamp)."""
path = metadata_path(metadata.identity) path = metadata_path(metadata.identity)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@@ -144,7 +144,7 @@ def read_metadata(identity: str) -> BottleMetadata | None:
def bottle_state_dir(identity: str) -> Path: def bottle_state_dir(identity: str) -> Path:
"""Per-bottle state directory on the host. Created lazily by the """Per-bottle state directory on the host. Created lazily by the
write helpers; readers tolerate its absence.""" write helpers; readers tolerate its absence."""
return _supervise.claude_bottle_root() / _STATE_SUBDIR / identity return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity
def per_bottle_dockerfile_path(identity: str) -> Path: def per_bottle_dockerfile_path(identity: str) -> Path:
@@ -171,9 +171,9 @@ def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
def per_bottle_image_tag(identity: str) -> str: def per_bottle_image_tag(identity: str) -> str:
"""Image tag for a rebuilt bottle. Distinct from the base """Image tag for a rebuilt bottle. Distinct from the base
claude-bottle:latest so per-bottle rebuilds don't collide in bot-bottle-claude:latest so per-bottle rebuilds don't collide in
the docker image cache.""" the docker image cache."""
return f"claude-bottle-rebuilt-{identity}:latest" return f"bot-bottle-rebuilt-{identity}:latest"
def live_config_dir(identity: str) -> Path: def live_config_dir(identity: str) -> Path:
@@ -248,9 +248,9 @@ def git_gate_state_dir(identity: str) -> Path:
def supervise_state_dir(identity: str) -> Path: def supervise_state_dir(identity: str) -> Path:
"""State subdir for the supervise sidecar's current-config dir """State subdir for the supervise sidecar's current-config dir
(bind-mounted into the agent at /etc/claude-bottle/current-config). (bind-mounted into the agent at /etc/bot-bottle/current-config).
The queue dir is intentionally NOT under here it lives at The queue dir is intentionally NOT under here it lives at
~/.claude-bottle/queue/<slug>/ alongside the audit logs, so it ~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
survives state-dir cleanup.""" survives state-dir cleanup."""
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
@@ -5,11 +5,11 @@ On approval of a capability-block proposal, the dashboard calls
apply_capability_change(slug, new_dockerfile) which: apply_capability_change(slug, new_dockerfile) which:
1. Snapshots the agent's transcript dir to 1. Snapshots the agent's transcript dir to
~/.claude-bottle/state/<slug>/transcript/ (best-effort). ~/.bot-bottle/state/<slug>/transcript/ (best-effort).
2. Pushes the agent's working tree via `git push` (best-effort — 2. Pushes the agent's working tree via `git push` (best-effort —
no upstream / no commits / no git repo all skip with a log). no upstream / no commits / no git repo all skip with a log).
3. Writes the new Dockerfile to 3. Writes the new Dockerfile to
~/.claude-bottle/state/<slug>/Dockerfile (PRD 0016 Phase 1 ~/.bot-bottle/state/<slug>/Dockerfile (PRD 0016 Phase 1
state). The next `cli.py start <agent>` picks it up. state). The next `cli.py start <agent>` picks it up.
4. Force-removes the agent container + all sidecars + the 4. Force-removes the agent container + all sidecars + the
per-bottle networks. Idempotent missing resources are not per-bottle networks. Idempotent missing resources are not
@@ -55,7 +55,7 @@ _AGENT_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace"
# Per-bottle resource name patterns (mirroring prepare.py). # Per-bottle resource name patterns (mirroring prepare.py).
def _agent_container_name(slug: str) -> str: def _agent_container_name(slug: str) -> str:
return f"claude-bottle-{slug}" return f"bot-bottle-{slug}"
def _per_bottle_container_names(slug: str) -> list[str]: def _per_bottle_container_names(slug: str) -> list[str]:
@@ -70,8 +70,8 @@ def _per_bottle_container_names(slug: str) -> list[str]:
def _per_bottle_network_names(slug: str) -> list[str]: def _per_bottle_network_names(slug: str) -> list[str]:
return [ return [
f"claude-bottle-net-{slug}", f"bot-bottle-net-{slug}",
f"claude-bottle-egress-{slug}", f"bot-bottle-egress-{slug}",
] ]
@@ -128,16 +128,16 @@ def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
def _repo_dockerfile_path() -> Path: def _repo_dockerfile_path() -> Path:
"""Path to the repo's Dockerfile (one dir above this module's """Path to the repo's Claude Dockerfile (one dir above this module's
package root). Resolved at call time so the path is correct package root). Resolved at call time so the path is correct
regardless of where this module is imported from.""" regardless of where this module is imported from."""
# claude_bottle/backend/docker/capability_apply.py -> repo root # bot_bottle/backend/docker/capability_apply.py -> repo root
return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile" return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
def snapshot_transcript(slug: str) -> None: def snapshot_transcript(slug: str) -> None:
"""`docker cp` /home/node/.claude out of the agent container into """`docker cp` /home/node/.claude out of the agent container into
~/.claude-bottle/state/<slug>/transcript/. Best-effort: missing ~/.bot-bottle/state/<slug>/transcript/. Best-effort: missing
container, missing dir, or cp error all log a warning and return. container, missing dir, or cp error all log a warning and return.
The transcript is what `claude --resume` reads to pick up where The transcript is what `claude --resume` reads to pick up where
the agent left off. the agent left off.
@@ -7,13 +7,13 @@ scan, just as a fallback bucket alongside the project list.
`prepare_cleanup` enumerates: `prepare_cleanup` enumerates:
- Live compose projects whose name starts with `claude-bottle-`. - Live compose projects whose name starts with `bot-bottle-`.
- `claude-bottle-*` containers that aren't part of any compose - `bot-bottle-*` containers that aren't part of any compose
project (legacy orphans). project (legacy orphans).
- `claude-bottle-*` networks that aren't tied to a compose - `bot-bottle-*` networks that aren't tied to a compose
project (legacy orphans; compose-managed networks come down project (legacy orphans; compose-managed networks come down
with `compose down --volumes` and don't appear here). with `compose down --volumes` and don't appear here).
- State dirs under ~/.claude-bottle/state/<identity>/ with no - State dirs under ~/.bot-bottle/state/<identity>/ with no
live compose project AND no `.preserve` marker. live compose project AND no `.preserve` marker.
`cleanup` removes everything in the plan. `cleanup` removes everything in the plan.
@@ -36,7 +36,7 @@ from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
def _list_prefixed_containers() -> list[str]: def _list_prefixed_containers() -> list[str]:
"""All claude-bottle-prefixed containers, running or stopped.""" """All bot-bottle-prefixed containers, running or stopped."""
result = subprocess.run( result = subprocess.run(
["docker", "ps", "-a", ["docker", "ps", "-a",
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}", "--filter", f"name=^{COMPOSE_PROJECT_PREFIX}",
@@ -60,7 +60,7 @@ def _list_prefixed_containers() -> list[str]:
def _list_prefixed_networks() -> list[str]: def _list_prefixed_networks() -> list[str]:
"""All claude-bottle-prefixed networks not currently attached """All bot-bottle-prefixed networks not currently attached
to a compose project. Compose-managed networks have a to a compose project. Compose-managed networks have a
`com.docker.compose.project` label; bare ones (from pre-compose `com.docker.compose.project` label; bare ones (from pre-compose
code paths) don't.""" code paths) don't."""
@@ -95,7 +95,7 @@ def _list_orphan_state_dirs(
ANY backend used so this docker-side check doesn't reap a ANY backend used so this docker-side check doesn't reap a
running smolmachines bottle's state dir (the layout is shared running smolmachines bottle's state dir (the layout is shared
across both backends).""" across both backends)."""
state_root = _supervise.claude_bottle_root() / "state" state_root = _supervise.bot_bottle_root() / "state"
if not state_root.is_dir(): if not state_root.is_dir():
return [] return []
orphans: list[str] = [] orphans: list[str] = []
@@ -20,11 +20,11 @@ SDK-call branching in `launch.py` today):
Naming: Naming:
- Compose project: `claude-bottle-<slug>`. - Compose project: `bot-bottle-<slug>`.
- Service names (inside the file): `agent`, `pipelock`, - Service names (inside the file): `agent`, `pipelock`,
`egress`, `git-gate`, `supervise`. `egress`, `git-gate`, `supervise`.
- `container_name:` matches today's pattern - `container_name:` matches today's pattern
(`claude-bottle-<service>-<slug>`) so dashboard/cleanup discovery (`bot-bottle-<service>-<slug>`) so dashboard/cleanup discovery
via the prefix scan keeps working through the transition. via the prefix scan keeps working through the transition.
- Network aliases preserve the current dial-by-shortname pattern - Network aliases preserve the current dial-by-shortname pattern
for `egress` / `supervise`, and add the long container-name as for `egress` / `supervise`, and add the long container-name as
@@ -98,7 +98,7 @@ def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
feed it a fully-resolved plan or get an incomplete compose feed it a fully-resolved plan or get an incomplete compose
spec back. spec back.
""" """
project = f"claude-bottle-{plan.slug}" project = f"bot-bottle-{plan.slug}"
services: dict[str, Any] = { services: dict[str, Any] = {
"sidecars": _sidecar_bundle_service(plan), "sidecars": _sidecar_bundle_service(plan),
"agent": _agent_service(plan), "agent": _agent_service(plan),
@@ -146,7 +146,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
Mechanics: Mechanics:
- Daemon subset narrows via `CLAUDE_BOTTLE_SIDECAR_DAEMONS` - Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS`
env. pipelock is always present; egress / git-gate / env. pipelock is always present; egress / git-gate /
supervise are conditional on the plan. supervise are conditional on the plan.
- Volumes are the union of the four daemons' bind-mounts, - Volumes are the union of the four daemons' bind-mounts,
@@ -160,7 +160,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
which is wrong. which is wrong.
- Network aliases register every legacy short/long - Network aliases register every legacy short/long
hostname (pipelock, egress, git-gate, supervise plus hostname (pipelock, egress, git-gate, supervise plus
their `claude-bottle-<service>-<slug>` long forms) so their `bot-bottle-<service>-<slug>` long forms) so
the agent's HTTPS_PROXY URL and any other inter-service the agent's HTTPS_PROXY URL and any other inter-service
reference resolves to the bundle. reference resolves to the bundle.
""" """
@@ -170,7 +170,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
daemons.append("supervise") daemons.append("supervise")
env: list[str] = [f"CLAUDE_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"] env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
volumes: list[dict[str, Any]] = [] volumes: list[dict[str, Any]] = []
# --- pipelock ---------------------------------------------------- # --- pipelock ----------------------------------------------------
@@ -212,6 +212,11 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
keypath, keypath,
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key", f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
)) ))
if u.known_hosts_file:
volumes.append(_bind(
u.known_hosts_file,
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
))
extra_map = git_gate_aggregate_extra_hosts(gp.upstreams) extra_map = git_gate_aggregate_extra_hosts(gp.upstreams)
extra_hosts = [f"{host}:{ip}" for host, ip in sorted(extra_map.items())] extra_hosts = [f"{host}:{ip}" for host, ip in sorted(extra_map.items())]
@@ -351,7 +356,7 @@ COMPOSE_FILE_NAME = "docker-compose.yml"
COMPOSE_LOG_NAME = "compose.log" COMPOSE_LOG_NAME = "compose.log"
COMPOSE_PROJECT_PREFIX = "claude-bottle-" COMPOSE_PROJECT_PREFIX = "bot-bottle-"
def compose_project_name(slug: str) -> str: def compose_project_name(slug: str) -> str:
@@ -371,15 +376,20 @@ def slug_from_compose_project(project: str) -> str:
return project[len(COMPOSE_PROJECT_PREFIX):] return project[len(COMPOSE_PROJECT_PREFIX):]
def list_compose_projects(*, include_stopped: bool = True) -> list[str]: def list_compose_projects(
"""All compose project names starting with `claude-bottle-`. *, include_stopped: bool = True, warn_on_error: bool = True,
) -> list[str]:
"""All compose project names starting with `bot-bottle-`.
`include_stopped=True` (default) runs `docker compose ls --all` `include_stopped=True` (default) runs `docker compose ls --all`
so exited projects appear too; pass False to get only projects so exited projects appear too; pass False to get only projects
with at least one running container. with at least one running container.
Returns [] on docker daemon errors or malformed output rather Returns [] on docker daemon errors or malformed output rather
than raising callers should treat the empty list as "no than raising callers should treat the empty list as "no
projects discoverable", not "no projects exist".""" projects discoverable", not "no projects exist". `warn_on_error`
stays true for explicit operator commands like cleanup, but active
discovery paths set it false so dashboard refreshes don't spam
stderr while Docker Desktop is stopped."""
argv = ["docker", "compose", "ls", "--format", "json"] argv = ["docker", "compose", "ls", "--format", "json"]
if include_stopped: if include_stopped:
argv.insert(3, "--all") argv.insert(3, "--all")
@@ -392,11 +402,13 @@ def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
# error from the caller's POV: no projects discoverable. # error from the caller's POV: no projects discoverable.
return [] return []
if result.returncode != 0: if result.returncode != 0:
if warn_on_error:
warn(f"docker compose ls failed: {result.stderr.strip()}") warn(f"docker compose ls failed: {result.stderr.strip()}")
return [] return []
try: try:
projects = json.loads(result.stdout or "[]") projects = json.loads(result.stdout or "[]")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
if warn_on_error:
warn(f"docker compose ls returned malformed JSON: {e}") warn(f"docker compose ls returned malformed JSON: {e}")
return [] return []
names: list[str] = [] names: list[str] = []
@@ -409,14 +421,19 @@ def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
return sorted(set(names)) return sorted(set(names))
def list_active_slugs(*, include_stopped: bool = False) -> list[str]: def list_active_slugs(
*, include_stopped: bool = False, warn_on_error: bool = True,
) -> list[str]:
"""Slugs (project name minus prefix) of currently-running """Slugs (project name minus prefix) of currently-running
bottles. Used by the dashboard's operator-edit verbs to choose bottles. Used by the dashboard's operator-edit verbs to choose
a bottle to apply a config edit to.""" a bottle to apply a config edit to."""
return sorted( return sorted(
slug for slug in ( slug for slug in (
slug_from_compose_project(p) slug_from_compose_project(p)
for p in list_compose_projects(include_stopped=include_stopped) for p in list_compose_projects(
include_stopped=include_stopped,
warn_on_error=warn_on_error,
)
) if slug ) if slug
) )
@@ -19,7 +19,7 @@ from ...log import die
# Listening port the egress daemon binds inside the bundle. The # Listening port the egress daemon binds inside the bundle. The
# agent's HTTP_PROXY env var resolves to `http://egress:<port>`, # agent's HTTP_PROXY env var resolves to `http://egress:<port>`,
# and the bundle's network aliases route `egress` to itself. # and the bundle's network aliases route `egress` to itself.
EGRESS_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PORT", "9099")) EGRESS_PORT = int(os.environ.get("BOT_BOTTLE_EGRESS_PORT", "9099"))
# In-container path for mitmproxy's CA. The format is a single PEM # In-container path for mitmproxy's CA. The format is a single PEM
# file holding BOTH the cert and the private key, concatenated. The # file holding BOTH the cert and the private key, concatenated. The
@@ -88,8 +88,8 @@ def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
"x509_extensions = v3_ca\n" "x509_extensions = v3_ca\n"
"\n" "\n"
"[req_dn]\n" "[req_dn]\n"
"O = claude-bottle\n" "O = bot-bottle\n"
"CN = claude-bottle egress CA\n" "CN = bot-bottle egress CA\n"
"\n" "\n"
"[v3_ca]\n" "[v3_ca]\n"
"basicConstraints = critical, CA:TRUE\n" "basicConstraints = critical, CA:TRUE\n"
@@ -115,7 +115,7 @@ def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
# where mitmproxy runs as uid 1000 — so the host file has to be # where mitmproxy runs as uid 1000 — so the host file has to be
# world-readable for the container's user to read it through the # world-readable for the container's user to read it through the
# mount. Owner-only mode on the parent dir (state/<slug>/, under # mount. Owner-only mode on the parent dir (state/<slug>/, under
# ~/.claude-bottle which inherits ~'s 0o700) is what actually # ~/.bot-bottle which inherits ~'s 0o700) is what actually
# restricts who can reach this file on the host. # restricts who can reach this file on the host.
mitm = work / "mitmproxy-ca.pem" mitm = work / "mitmproxy-ca.pem"
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes()) mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
@@ -24,7 +24,7 @@ def enumerate_active() -> list[ActiveAgent]:
responsible for gating on `has_backend('docker')` if it responsible for gating on `has_backend('docker')` if it
matters; if docker is missing the `docker ps` call below matters; if docker is missing the `docker ps` call below
returns an empty list silently.""" returns an empty list silently."""
slugs = list_active_slugs(include_stopped=False) slugs = list_active_slugs(include_stopped=False, warn_on_error=False)
if not slugs: if not slugs:
return [] return []
services_by_project = _query_services_by_project() services_by_project = _query_services_by_project()
@@ -23,7 +23,7 @@ The flow is:
entries inherit without rendering values into the file). entries inherit without rendering values into the file).
8. Provision (CA install, prompt copy, skills, git, supervise 8. Provision (CA install, prompt copy, skills, git, supervise
config) unchanged, uses `docker exec`. config) unchanged, uses `docker exec`.
9. Yield a DockerBottle handle. `exec_claude` runs claude via 9. Yield a DockerBottle handle. `exec_agent` runs claude via
`docker exec -it` exactly like the pre-compose world. `docker exec -it` exactly like the pre-compose world.
Teardown (ExitStack callbacks fire in reverse): Teardown (ExitStack callbacks fire in reverse):
@@ -204,9 +204,15 @@ def launch(
# the agent container by its known name. # the agent container by its known name.
prompt_path = provision(plan, plan.container_name) prompt_path = provision(plan, plan.container_name)
# Step 9: yield. exec_claude continues to use `docker exec -it` # Step 9: yield. exec_agent continues to use `docker exec -it`
# — the agent runs `sleep infinity` per the renderer's # — the agent runs `sleep infinity` per the renderer's
# service spec. # service spec.
yield DockerBottle(plan.container_name, teardown, prompt_path) yield DockerBottle(
plan.container_name,
teardown,
prompt_path,
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
)
finally: finally:
teardown() teardown()
@@ -7,8 +7,8 @@ bridge for upstream egress. We deliberately do NOT use Docker's legacy
embedded DNS resolver, which pipelock needs to resolve api.anthropic.com embedded DNS resolver, which pipelock needs to resolve api.anthropic.com
and similar upstream hostnames. and similar upstream hostnames.
Naming: claude-bottle-net-<slug> (internal), Naming: bot-bottle-net-<slug> (internal),
claude-bottle-egress-<slug> (egress). Numeric suffix on conflict bot-bottle-egress-<slug> (egress). Numeric suffix on conflict
(-2, -3, ..., capped at 100). (-2, -3, ..., capped at 100).
""" """
@@ -20,11 +20,11 @@ from ...log import die, info, warn
def network_name_for_slug(slug: str) -> str: def network_name_for_slug(slug: str) -> str:
return f"claude-bottle-net-{slug}" return f"bot-bottle-net-{slug}"
def network_egress_name_for_slug(slug: str) -> str: def network_egress_name_for_slug(slug: str) -> str:
return f"claude-bottle-egress-{slug}" return f"bot-bottle-egress-{slug}"
def network_exists(name: str) -> bool: def network_exists(name: str) -> bool:
@@ -27,12 +27,12 @@ from ...pipelock import ( # noqa: F401
# Pipelock image, pinned by digest. The digest is the multi-arch image # Pipelock image, pinned by digest. The digest is the multi-arch image
# index for ghcr.io/luckypipewrench/pipelock:2.3.0. # index for ghcr.io/luckypipewrench/pipelock:2.3.0.
PIPELOCK_IMAGE = os.environ.get( PIPELOCK_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_PIPELOCK_IMAGE", "BOT_BOTTLE_PIPELOCK_IMAGE",
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9", "ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
) )
# Listening port for pipelock's forward proxy. # Listening port for pipelock's forward proxy.
PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888") PIPELOCK_PORT = os.environ.get("BOT_BOTTLE_PIPELOCK_PORT", "8888")
# The URL egress dials for its upstream HTTPS_PROXY. egress and # The URL egress dials for its upstream HTTPS_PROXY. egress and
@@ -14,6 +14,7 @@ import os
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ...agent_provider import runtime_for
from ...egress import Egress from ...egress import Egress
from ...env import ResolvedEnv, resolve_env from ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate from ...git_gate import GitGate
@@ -58,6 +59,8 @@ def resolve_plan(
manifest = spec.manifest manifest = spec.manifest
agent = manifest.agents[spec.agent_name] agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
# PRD 0016 follow-up: identity, not bare slug. A fresh `start` # PRD 0016 follow-up: identity, not bare slug. A fresh `start`
# mints a random-suffixed identity (so parallel runs of the same # mints a random-suffixed identity (so parallel runs of the same
@@ -74,7 +77,7 @@ def resolve_plan(
cwd=spec.user_cwd if spec.copy_cwd else "", cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd, copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(), started_at=datetime.now(timezone.utc).isoformat(),
compose_project=f"claude-bottle-{slug}", compose_project=f"bot-bottle-{slug}",
)) ))
# Clear any leftover preserve marker from a prior capability-block # Clear any leftover preserve marker from a prior capability-block
# so this fresh launch can be cleaned up at session-end unless # so this fresh launch can be cleaned up at session-end unless
@@ -89,26 +92,32 @@ def resolve_plan(
if per_bottle_dockerfile(slug) is not None: if per_bottle_dockerfile(slug) is not None:
image_default = per_bottle_image_tag(slug) image_default = per_bottle_image_tag(slug)
dockerfile_path = str(per_bottle_dockerfile_path(slug)) dockerfile_path = str(per_bottle_dockerfile_path(slug))
elif provider.dockerfile:
image_default = f"bot-bottle-{provider.template}:{slug}"
dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
elif provider_runtime.dockerfile:
image_default = provider_runtime.image
dockerfile_path = provider_runtime.dockerfile
else: else:
image_default = "claude-bottle:latest" image_default = provider_runtime.image
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default) image = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
derived_image = "" derived_image = ""
runtime_image = image runtime_image = image
if spec.copy_cwd: if spec.copy_cwd:
derived_image = os.environ.get( derived_image = os.environ.get(
"CLAUDE_BOTTLE_DERIVED_IMAGE", f"claude-bottle:cwd-{slug}" "BOT_BOTTLE_DERIVED_IMAGE", f"bot-bottle-cwd:{slug}"
) )
runtime_image = derived_image runtime_image = derived_image
default_container = f"claude-bottle-{slug}" default_container = f"bot-bottle-{slug}"
pinned_container = os.environ.get("CLAUDE_BOTTLE_CONTAINER", "") pinned_container = os.environ.get("BOT_BOTTLE_CONTAINER", "")
container_name_pinned = bool(pinned_container) container_name_pinned = bool(pinned_container)
if container_name_pinned: if container_name_pinned:
container_name = pinned_container container_name = pinned_container
if docker_mod.container_exists(container_name): if docker_mod.container_exists(container_name):
die( die(
f"container '{container_name}' already exists " f"container '{container_name}' already exists "
f"(pinned via CLAUDE_BOTTLE_CONTAINER). " f"(pinned via BOT_BOTTLE_CONTAINER). "
f"Remove it with 'docker rm -f {container_name}' or unset the override." f"Remove it with 'docker rm -f {container_name}' or unset the override."
) )
else: else:
@@ -138,7 +147,7 @@ def resolve_plan(
) )
# PRD 0018 chunk 2: prepare-time scratch files live under # PRD 0018 chunk 2: prepare-time scratch files live under
# ~/.claude-bottle/state/<slug>/<service>/ so chunk 3's compose # ~/.bot-bottle/state/<slug>/<service>/ so chunk 3's compose
# bind-mounts can point at stable paths. The state subdirs are # bind-mounts can point at stable paths. The state subdirs are
# cleaned up by start.py's session-end teardown unless something # cleaned up by start.py's session-end teardown unless something
# explicitly preserves the state dir (capability-block, crash). # explicitly preserves the state dir (capability-block, crash).
@@ -171,8 +180,16 @@ def resolve_plan(
# PRD 0017 chunk 3 moved them behind the # PRD 0017 chunk 3 moved them behind the
# `list-egress-routes` MCP tool so the agent gets live # `list-egress-routes` MCP tool so the agent gets live
# state rather than a launch-time snapshot.) # state rather than a launch-time snapshot.)
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile" supervise_dockerfile_path = (
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else "" Path(dockerfile_path)
if dockerfile_path
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
)
dockerfile_content = (
supervise_dockerfile_path.read_text()
if supervise_dockerfile_path.is_file()
else ""
)
supervise_dir = supervise_state_dir(slug) supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True) supervise_dir.mkdir(parents=True, exist_ok=True)
supervise_plan = supervise.prepare( supervise_plan = supervise.prepare(
@@ -192,12 +209,12 @@ def resolve_plan(
# placeholder. The placeholder isn't any real token value, so # placeholder. The placeholder isn't any real token value, so
# leaking it would tell an attacker only that egress is in # leaking it would tell an attacker only that egress is in
# front. Manifest validation enforces singleton on this role. # front. Manifest validation enforces singleton on this role.
has_anthropic_auth = any( has_provider_auth = any(
"claude_code_oauth" in r.roles provider_runtime.auth_role in r.roles for r in egress_plan.routes
for r in egress_plan.routes
) )
if has_anthropic_auth: if has_provider_auth:
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" forwarded_env[provider_runtime.placeholder_env] = "egress-placeholder"
if provider.template == "claude" and has_provider_auth:
# Belt-and-braces: turn off telemetry endpoints (statsig, # Belt-and-braces: turn off telemetry endpoints (statsig,
# error reporting) that egress can't gate by auth. # error reporting) that egress can't gate by auth.
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
@@ -225,6 +242,9 @@ def resolve_plan(
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
use_runsc=use_runsc, use_runsc=use_runsc,
agent_command=provider_runtime.command,
agent_prompt_mode=provider_runtime.prompt_mode,
agent_provider_template=provider.template,
) )
@@ -243,3 +263,10 @@ def _write_env_file(resolved: ResolvedEnv, env_file: Path) -> None:
env_lines.append(f"{name}={value}") env_lines.append(f"{name}={value}")
env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else "")) env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
env_file.chmod(0o600) env_file.chmod(0o600)
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
path = Path(os.path.expanduser(path_value))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
return str(path)
@@ -43,7 +43,7 @@ from ..bottle_plan import DockerBottlePlan
# Debian-family path for sources that `update-ca-certificates` reads. # Debian-family path for sources that `update-ca-certificates` reads.
# Bundle path is what the command rebuilds and what every standard # Bundle path is what the command rebuilds and what every standard
# TLS consumer in the image reads. # TLS consumer in the image reads.
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-mitm-ca.crt" AGENT_CA_PATH = "/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt"
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt" AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
@@ -66,7 +66,7 @@ def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
if not bottle.git: if not bottle.git:
return return
container = target container = target
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
container_gitconfig = f"{container_home}/.gitconfig" container_gitconfig = f"{container_home}/.gitconfig"
content = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME) content = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME)
@@ -18,8 +18,8 @@ def provision_prompt(plan: DockerBottlePlan, target: str) -> str | None:
prompt (drives --append-system-prompt-file), else None. The prompt (drives --append-system-prompt-file), else None. The
file is copied either way so the path always exists.""" file is copied either way so the path always exists."""
container = target container = target
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt" in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
subprocess.run( subprocess.run(
["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"], ["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"],
@@ -28,9 +28,9 @@ def provision_skills(plan: DockerBottlePlan, target: str) -> None:
return return
container = target container = target
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
skills_dir = os.environ.get( skills_dir = os.environ.get(
"CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills" "BOT_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
) )
subprocess.run( subprocess.run(
@@ -5,7 +5,7 @@ The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
runs pipelock + egress + git-gate + supervise as one container runs pipelock + egress + git-gate + supervise as one container
per bottle under a small Python init supervisor. As of chunk 5 per bottle under a small Python init supervisor. As of chunk 5
the bundle is the only shape the legacy four-sidecar topology the bundle is the only shape the legacy four-sidecar topology
and its `CLAUDE_BOTTLE_SIDECAR_BUNDLE` feature flag are gone.""" and its `BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
from __future__ import annotations from __future__ import annotations
@@ -15,17 +15,17 @@ import os
# Bundle image. Defaults to a built-locally tag (built from the # Bundle image. Defaults to a built-locally tag (built from the
# repo's Dockerfile.sidecars via compose `build:`). Operators # repo's Dockerfile.sidecars via compose `build:`). Operators
# pinning to a published digest can override via env, matching # pinning to a published digest can override via env, matching
# the existing `CLAUDE_BOTTLE_PIPELOCK_IMAGE` shape. # the existing `BOT_BOTTLE_PIPELOCK_IMAGE` shape.
SIDECAR_BUNDLE_IMAGE = os.environ.get( SIDECAR_BUNDLE_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_SIDECAR_IMAGE", "BOT_BOTTLE_SIDECAR_IMAGE",
"claude-bottle-sidecars:latest", "bot-bottle-sidecars:latest",
) )
SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars" SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars"
def sidecar_bundle_container_name(slug: str) -> str: def sidecar_bundle_container_name(slug: str) -> str:
"""`claude-bottle-sidecars-<slug>`. Same prefix scheme as the """`bot-bottle-sidecars-<slug>`. Same prefix scheme as the
per-sidecar containers it replaces, so the dashboard's per-sidecar containers it replaces, so the dashboard's
discovery-by-prefix logic keeps working.""" discovery-by-prefix logic keeps working."""
return f"claude-bottle-sidecars-{slug}" return f"bot-bottle-sidecars-{slug}"
@@ -9,6 +9,7 @@ from __future__ import annotations
from typing import Sequence from typing import Sequence
from ..agent_provider import runtime_for
from ..log import info from ..log import info
@@ -26,3 +27,19 @@ def print_multi(label: str, values: Sequence[str]) -> None:
indent = " " * (len(label) + 2) indent = " " * (len(label) + 2)
for v in values[1:]: for v in values[1:]:
info(f"{indent}{v}") info(f"{indent}{v}")
def visible_agent_env_names(
env_names: Sequence[str], *, agent_provider_template: str,
) -> list[str]:
"""Env names worth showing in launch summaries.
Provider auth placeholders (`OPENAI_API_KEY`,
`CLAUDE_CODE_OAUTH_TOKEN`) are implementation details: they are
non-secret dummy values that satisfy the provider CLI while egress
injects the real upstream Authorization header. Showing them in
preflight makes the operator think a real key is entering the
agent, so hide only that provider-owned placeholder.
"""
hidden = {runtime_for(agent_provider_template).placeholder_env}
return sorted({name for name in env_names if name not in hidden})
@@ -1,6 +1,6 @@
"""smolmachines bottle backend (PRD 0023). """smolmachines bottle backend (PRD 0023).
Selectable via `CLAUDE_BOTTLE_BACKEND=smolmachines`. Runs each Selectable via `BOT_BOTTLE_BACKEND=smolmachines`. Runs each
bottle inside a per-agent microVM (libkrun / Hypervisor.framework bottle inside a per-agent microVM (libkrun / Hypervisor.framework
on macOS) with a userspace gvproxy gateway as the egress on macOS) with a userspace gvproxy gateway as the egress
primitive. The sidecar bundle (PRD 0024) runs as a host-side primitive. The sidecar bundle (PRD 0024) runs as a host-side
@@ -27,7 +27,7 @@ class SmolmachinesBottleBackend(
BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"] BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"]
): ):
"""smolmachines backend. Selected by """smolmachines backend. Selected by
`CLAUDE_BOTTLE_BACKEND=smolmachines`.""" `BOT_BOTTLE_BACKEND=smolmachines`."""
name = "smolmachines" name = "smolmachines"
@@ -1,15 +1,15 @@
"""SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d). """SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d).
Routes `exec_claude` / `exec` / `cp_in` through `smolvm machine Routes `exec_agent` / `exec` / `cp_in` through `smolvm machine
exec` / `smolvm machine cp`. The handle is yielded by `launch` exec` / `smolvm machine cp`. The handle is yielded by `launch`
and torn down via the surrounding ExitStack on context exit; and torn down via the surrounding ExitStack on context exit;
`close` is a no-op idempotent alias so the BottleBackend ABC's `close` is a no-op idempotent alias so the BottleBackend ABC's
context-manager contract is satisfied. context-manager contract is satisfied.
User context: `smolvm machine exec` runs commands as root in the User context: `smolvm machine exec` runs commands as root in the
VM, but the agent image's USER is `node` and claude-code refuses VM, but the agent image's USER is `node` and agent CLIs may refuse
to run as root with `--dangerously-skip-permissions`. Both to run as root in bypass modes. Both
`exec_claude` and `exec` switch to the requested user (default `exec_agent` and `exec` switch to the requested user (default
`node`) via `runuser -u <user> --` and set `HOME` / `USER` `node`) via `runuser -u <user> --` and set `HOME` / `USER`
through `smolvm -e` avoiding `runuser -l`'s login-shell wiring through `smolvm -e` avoiding `runuser -l`'s login-shell wiring
(PAM session setup, /etc/profile sourcing) which can hang on a (PAM session setup, /etc/profile sourcing) which can hang on a
@@ -21,6 +21,7 @@ import subprocess
import sys import sys
from typing import Mapping from typing import Mapping
from ...agent_provider import PromptMode, prompt_args
from .. import Bottle, ExecResult from .. import Bottle, ExecResult
from . import pty_resize as _pty_resize from . import pty_resize as _pty_resize
from . import smolvm as _smolvm from . import smolvm as _smolvm
@@ -29,12 +30,12 @@ from . import smolvm as _smolvm
# Absolute path to the pty_resize wrapper. Invoke as # Absolute path to the pty_resize wrapper. Invoke as
# `python <path>` rather than `python -m <dotted-path>` so the # `python <path>` rather than `python -m <dotted-path>` so the
# wrapper runs regardless of cwd / sys.path — it has no # wrapper runs regardless of cwd / sys.path — it has no
# claude_bottle.* imports, so it's self-contained. # bot_bottle.* imports, so it's self-contained.
_PTY_RESIZE_SCRIPT = _pty_resize.__file__ _PTY_RESIZE_SCRIPT = _pty_resize.__file__
# Per-user env the agent image's USER (node) expects. claude # Per-user env the agent image's USER (node) expects. Some providers
# reads ~/.claude.json + writes session state under ~/.claude/; # write session state under the user's home directory;
# bare `runuser -u` inherits root's HOME=/root, which claude # bare `runuser -u` inherits root's HOME=/root, which claude
# can't write to. Set HOME / USER explicitly through smolvm -e # can't write to. Set HOME / USER explicitly through smolvm -e
# so the child process sees them. # so the child process sees them.
@@ -72,6 +73,8 @@ class SmolmachinesBottle(Bottle):
*, *,
prompt_path: str | None = None, prompt_path: str | None = None,
guest_env: Mapping[str, str] | None = None, guest_env: Mapping[str, str] | None = None,
agent_command: str = "claude",
agent_prompt_mode: PromptMode = "append_file",
) -> None: ) -> None:
self.name = machine_name self.name = machine_name
# In-VM path to the agent's prompt file. None when the # In-VM path to the agent's prompt file. None when the
@@ -83,8 +86,13 @@ class SmolmachinesBottle(Bottle):
# Forwarded on every `smolvm machine exec` via `-e K=V` # Forwarded on every `smolvm machine exec` via `-e K=V`
# because exec doesn't inherit from machine_create's env. # because exec doesn't inherit from machine_create's env.
self._guest_env = dict(guest_env or {}) self._guest_env = dict(guest_env or {})
self._agent_prompt_mode = agent_prompt_mode
self.agent_command = agent_command
self.agent_provider_template = (
"codex" if agent_command == "codex" else "claude"
)
def claude_argv( def agent_argv(
self, argv: list[str], *, tty: bool = True, self, argv: list[str], *, tty: bool = True,
) -> list[str]: ) -> list[str]:
flags = ["smolvm", "machine", "exec", "--name", self.name] flags = ["smolvm", "machine", "exec", "--name", self.name]
@@ -92,11 +100,17 @@ class SmolmachinesBottle(Bottle):
flags += ["-i", "-t"] flags += ["-i", "-t"]
flags += _env_flags_for("node") flags += _env_flags_for("node")
flags += _guest_env_flags(self._guest_env) flags += _guest_env_flags(self._guest_env)
claude_tail = ["claude"] agent_tail = [self.agent_command]
if self._prompt_path: provider_prompt_args = prompt_args(
claude_tail += ["--append-system-prompt-file", self._prompt_path] self._agent_prompt_mode, self._prompt_path, argv=argv,
claude_tail += argv )
flags += ["--", "runuser", "-u", "node", "--", *claude_tail] if self._agent_prompt_mode == "read_prompt_file":
agent_tail += argv
agent_tail += provider_prompt_args
else:
agent_tail += provider_prompt_args
agent_tail += argv
flags += ["--", "runuser", "-u", "node", "--", *agent_tail]
if not tty: if not tty:
# No PTY allocated — no SIGWINCH to forward, no resize # No PTY allocated — no SIGWINCH to forward, no resize
# bridge needed. Skip the wrapper so non-interactive # bridge needed. Skip the wrapper so non-interactive
@@ -108,10 +122,10 @@ class SmolmachinesBottle(Bottle):
self.name, "--", *flags, self.name, "--", *flags,
] ]
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
"""Run `claude` interactively inside the VM as the `node` """Run the selected agent interactively inside the VM as the `node`
user. Inherits the operator's terminal (stdin / stdout / user. Inherits the operator's terminal (stdin / stdout /
stderr) so the session feels native. Blocks until claude stderr) so the session feels native. Blocks until the agent
exits; returns the in-VM exit code. exits; returns the in-VM exit code.
We bypass the captured-output `machine_exec` helper here We bypass the captured-output `machine_exec` helper here
@@ -123,7 +137,7 @@ class SmolmachinesBottle(Bottle):
avoid login-shell wiring. HOME / USER come from `smolvm avoid login-shell wiring. HOME / USER come from `smolvm
-e` instead, which sets them on the process env.""" -e` instead, which sets them on the process env."""
return subprocess.run( return subprocess.run(
self.claude_argv(argv, tty=tty), check=False, self.agent_argv(argv, tty=tty), check=False,
).returncode ).returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult: def exec(self, script: str, *, user: str = "node") -> ExecResult:
@@ -4,17 +4,17 @@ Tracks the resources `SmolmachinesBottleBackend.cleanup` will
remove: remove:
- machines: smolvm machines whose name starts with - machines: smolvm machines whose name starts with
`claude-bottle-` (running or stopped). Stopped + `bot-bottle-` (running or stopped). Stopped +
deleted via `smolvm machine stop` + `machine delete -f`. deleted via `smolvm machine stop` + `machine delete -f`.
- bundles: docker containers `claude-bottle-sidecars-<slug>` - bundles: docker containers `bot-bottle-sidecars-<slug>`
left over from a smolmachines bottle (the bundle's left over from a smolmachines bottle (the bundle's
port-forwards stay published on lo0 aliases until port-forwards stay published on lo0 aliases until
the container is gone). Removed via `docker rm -f`. the container is gone). Removed via `docker rm -f`.
- networks: docker networks `claude-bottle-bundle-<slug>` - networks: docker networks `bot-bottle-bundle-<slug>`
attached to the bundles. Removed via attached to the bundles. Removed via
`docker network rm`. `docker network rm`.
Smolmachines state dirs live under the same `~/.claude-bottle/state/` Smolmachines state dirs live under the same `~/.bot-bottle/state/`
path the docker backend uses; the docker backend's path the docker backend uses; the docker backend's
`prepare_cleanup` already enumerates orphan state dirs and is the `prepare_cleanup` already enumerates orphan state dirs and is the
single source of truth for that bucket (consults single source of truth for that bucket (consults
@@ -12,13 +12,14 @@ import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ...agent_provider import PromptMode
from ...egress import EgressPlan from ...egress import EgressPlan
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...log import info from ...log import info
from ...pipelock import PipelockProxyPlan from ...pipelock import PipelockProxyPlan
from ...supervise import SupervisePlan from ...supervise import SupervisePlan
from .. import BottlePlan from .. import BottlePlan
from ..print_util import print_multi from ..print_util import print_multi, visible_agent_env_names
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -42,7 +43,7 @@ class SmolmachinesBottlePlan(BottlePlan):
# agent's network attempt got refused by macOS. # agent's network attempt got refused by macOS.
# #
# Chunk 2d ships with a public placeholder image (alpine) # Chunk 2d ships with a public placeholder image (alpine)
# since claude-bottle:latest lives in the operator's local # since bot-bottle-claude:latest lives in the operator's local
# docker daemon and smolvm's crane backend can't read from # docker daemon and smolvm's crane backend can't read from
# there; chunk 4 resolves the agent-image-conversion gap # there; chunk 4 resolves the agent-image-conversion gap
# (push to a registry first, or smolvm grows a docker-daemon # (push to a registry first, or smolvm grows a docker-daemon
@@ -92,6 +93,10 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_proxy_url: str = "" agent_proxy_url: str = ""
agent_git_gate_host: str = "" agent_git_gate_host: str = ""
agent_supervise_url: str = "" agent_supervise_url: str = ""
agent_command: str = "claude"
agent_prompt_mode: PromptMode = "append_file"
agent_provider_template: str = "claude"
agent_dockerfile_path: str = ""
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Compact y/N preflight. Same shape as the Docker """Compact y/N preflight. Same shape as the Docker
@@ -102,7 +107,10 @@ class SmolmachinesBottlePlan(BottlePlan):
agent = manifest.agents[spec.agent_name] agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
env_names = sorted(bottle.env.keys()) env_names = visible_agent_env_names(
sorted(bottle.env.keys()),
agent_provider_template=self.agent_provider_template,
)
upstreams = [ upstreams = [
f"{g.Name}{g.Upstream}" for g in bottle.git f"{g.Name}{g.Upstream}" for g in bottle.git
] ]
@@ -113,6 +121,7 @@ class SmolmachinesBottlePlan(BottlePlan):
print(file=sys.stderr) print(file=sys.stderr)
info(f"agent : {spec.agent_name}") info(f"agent : {spec.agent_name}")
info(f"provider : {self.agent_provider_template}")
print_multi("env ", env_names) print_multi("env ", env_names)
print_multi("skills ", list(agent.skills)) print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}") info(f"bottle : {agent.bottle}")
@@ -3,11 +3,11 @@
`prepare_cleanup` enumerates leftover smolmachines resources: `prepare_cleanup` enumerates leftover smolmachines resources:
- smolvm machines (`smolvm machine ls --json`) whose name starts - smolvm machines (`smolvm machine ls --json`) whose name starts
with `claude-bottle-`. with `bot-bottle-`.
- bundle docker containers (`claude-bottle-sidecars-<slug>`). - bundle docker containers (`bot-bottle-sidecars-<slug>`).
- bundle docker networks (`claude-bottle-bundle-<slug>`). - bundle docker networks (`bot-bottle-bundle-<slug>`).
State dirs live under `~/.claude-bottle/state/<identity>/` State dirs live under `~/.bot-bottle/state/<identity>/`
shared layout with the docker backend, which has the single shared layout with the docker backend, which has the single
orphan-state-dir enumerator (it already consults orphan-state-dir enumerator (it already consults
`enumerate_active_agents()` so a live smolmachines bottle's dir `enumerate_active_agents()` so a live smolmachines bottle's dir
@@ -29,9 +29,9 @@ from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
# Both names start with the same prefix the launcher uses. # Both names start with the same prefix the launcher uses.
_VM_PREFIX = "claude-bottle-" _VM_PREFIX = "bot-bottle-"
_BUNDLE_PREFIX = _bundle.bundle_container_name("") # `claude-bottle-sidecars-` _BUNDLE_PREFIX = _bundle.bundle_container_name("") # `bot-bottle-sidecars-`
_NETWORK_PREFIX = _bundle.bundle_network_name("") # `claude-bottle-bundle-` _NETWORK_PREFIX = _bundle.bundle_network_name("") # `bot-bottle-bundle-`
def prepare_cleanup() -> SmolmachinesBottleCleanupPlan: def prepare_cleanup() -> SmolmachinesBottleCleanupPlan:
@@ -39,7 +39,7 @@ def prepare_cleanup() -> SmolmachinesBottleCleanupPlan:
No side effects. Returns an empty plan when smolvm isn't on No side effects. Returns an empty plan when smolvm isn't on
PATH (no machines to reap) `cleanup` is a no-op in that PATH (no machines to reap) `cleanup` is a no-op in that
case too.""" case too."""
machines = _list_claude_bottle_machines() machines = _list_bot_bottle_machines()
bundles = _list_bundle_containers() bundles = _list_bundle_containers()
networks = _list_bundle_networks() networks = _list_bundle_networks()
return SmolmachinesBottleCleanupPlan( return SmolmachinesBottleCleanupPlan(
@@ -94,8 +94,8 @@ def cleanup(plan: SmolmachinesBottleCleanupPlan) -> None:
) )
def _list_claude_bottle_machines() -> list[str]: def _list_bot_bottle_machines() -> list[str]:
"""All smolvm machines named `claude-bottle-*`, regardless of """All smolvm machines named `bot-bottle-*`, regardless of
state (running / stopped / created). Empty when smolvm isn't state (running / stopped / created). Empty when smolvm isn't
installed.""" installed."""
if not _smolvm.is_available(): if not _smolvm.is_available():
@@ -118,7 +118,7 @@ def _list_claude_bottle_machines() -> list[str]:
def _list_bundle_containers() -> list[str]: def _list_bundle_containers() -> list[str]:
"""All docker containers named `claude-bottle-sidecars-*`, """All docker containers named `bot-bottle-sidecars-*`,
running or stopped. Empty when docker isn't installed.""" running or stopped. Empty when docker isn't installed."""
# Late import: `backend/__init__` imports this module # Late import: `backend/__init__` imports this module
# transitively via the smolmachines backend. # transitively via the smolmachines backend.
@@ -140,7 +140,7 @@ def _list_bundle_containers() -> list[str]:
def _list_bundle_networks() -> list[str]: def _list_bundle_networks() -> list[str]:
"""All docker networks named `claude-bottle-bundle-*`. Empty """All docker networks named `bot-bottle-bundle-*`. Empty
when docker isn't installed.""" when docker isn't installed."""
from .. import has_backend from .. import has_backend
if not has_backend("docker"): if not has_backend("docker"):
@@ -27,10 +27,10 @@ from ..docker.bottle_state import read_metadata
from . import sidecar_bundle as _bundle from . import sidecar_bundle as _bundle
# Smolvm VM names produced by prepare are `claude-bottle-<slug>`, # Smolvm VM names produced by prepare are `bot-bottle-<slug>`,
# matching the bundle container name pattern. We use the prefix # matching the bundle container name pattern. We use the prefix
# both as a filter and to strip back to the slug. # both as a filter and to strip back to the slug.
_VM_NAME_PREFIX = "claude-bottle-" _VM_NAME_PREFIX = "bot-bottle-"
def enumerate_active() -> list[ActiveAgent]: def enumerate_active() -> list[ActiveAgent]:
@@ -70,7 +70,7 @@ def enumerate_active() -> list[ActiveAgent]:
def _query_bundle_services() -> dict[str, tuple[str, ...]]: def _query_bundle_services() -> dict[str, tuple[str, ...]]:
"""`{slug: ('egress', 'pipelock', ...)}` from each running """`{slug: ('egress', 'pipelock', ...)}` from each running
bundle container's `CLAUDE_BOTTLE_SIDECAR_DAEMONS` env var. bundle container's `BOT_BOTTLE_SIDECAR_DAEMONS` env var.
Smolmachines bundles all run the PRD-0024 image with the Smolmachines bundles all run the PRD-0024 image with the
same daemon set declared via env, so one inspect per bundle same daemon set declared via env, so one inspect per bundle
gets us the picture without exec'ing into the container. gets us the picture without exec'ing into the container.
@@ -113,7 +113,7 @@ def _query_bundle_services() -> dict[str, tuple[str, ...]]:
continue continue
for entry in env_list: for entry in env_list:
key, _, value = entry.partition("=") key, _, value = entry.partition("=")
if key == "CLAUDE_BOTTLE_SIDECAR_DAEMONS": if key == "BOT_BOTTLE_SIDECAR_DAEMONS":
out[slug] = tuple(sorted( out[slug] = tuple(sorted(
d for d in value.split(",") if d d for d in value.split(",") if d
)) ))
@@ -68,7 +68,7 @@ _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
# docker image ID so a Dockerfile change automatically invalidates # docker image ID so a Dockerfile change automatically invalidates
# the cache. `pack create` is idempotent on the smolvm side but # the cache. `pack create` is idempotent on the smolvm side but
# takes several seconds even on a no-op rebuild. # takes several seconds even on a no-op rebuild.
_SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "claude-bottle" / "smolmachines" _SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "bot-bottle" / "smolmachines"
# Container-internal listening ports for each bundle daemon. The # Container-internal listening ports for each bundle daemon. The
@@ -142,6 +142,7 @@ def launch(
# daemons the agent needs to reach from the smolvm guest. # daemons the agent needs to reach from the smolvm guest.
bundle_spec = _bundle_launch_spec(plan, network, loopback_ip) bundle_spec = _bundle_launch_spec(plan, network, loopback_ip)
token_env = _resolve_token_env(plan, os.environ) token_env = _resolve_token_env(plan, os.environ)
_bundle.ensure_bundle_image(bundle_spec.image)
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env}) _bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
stack.callback(_bundle.stop_bundle, plan.slug) stack.callback(_bundle.stop_bundle, plan.slug)
@@ -183,7 +184,7 @@ def launch(
# Stamp the URLs onto the plan + guest_env. provision_git # Stamp the URLs onto the plan + guest_env. provision_git
# and provision_supervise read the plan fields; the agent # and provision_supervise read the plan fields; the agent
# reads guest_env on every exec_claude. # reads guest_env on every exec_agent.
# #
# NO_PROXY has to include the per-bottle loopback alias — # NO_PROXY has to include the per-bottle loopback alias —
# otherwise claude's HTTPS_PROXY catches direct calls to # otherwise claude's HTTPS_PROXY catches direct calls to
@@ -219,7 +220,10 @@ def launch(
# output doesn't garble the dashboard's preflight modal: # output doesn't garble the dashboard's preflight modal:
# both the curses-endwin path and the tmux pane-routing # both the curses-endwin path and the tmux pane-routing
# path redirect stderr around `launch` already. # path redirect stderr around `launch` already.
agent_from_path = _ensure_smolmachine(plan.agent_image_ref) agent_from_path = _ensure_smolmachine(
plan.agent_image_ref,
dockerfile=plan.agent_dockerfile_path,
)
# smolvm VM. --from carries the pre-packed .smolmachine # smolvm VM. --from carries the pre-packed .smolmachine
# artifact; --allow-cidr + -e carry the per-bottle TSI # artifact; --allow-cidr + -e carry the per-bottle TSI
@@ -286,6 +290,8 @@ def launch(
plan.machine_name, plan.machine_name,
prompt_path=prompt_path, prompt_path=prompt_path,
guest_env=plan.guest_env, guest_env=plan.guest_env,
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
) )
finally: finally:
stack.close() stack.close()
@@ -360,6 +366,12 @@ def _bundle_launch_spec(
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key", f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
True, True,
)) ))
if u.known_hosts_file:
volumes.append((
str(u.known_hosts_file),
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
True,
))
# --- supervise -------------------------------------------- # --- supervise --------------------------------------------
sp = plan.supervise_plan sp = plan.supervise_plan
@@ -413,10 +425,10 @@ def _resolve_token_env(
return egress_resolve_token_values(ep.token_env_map, dict(host_env)) return egress_resolve_token_values(ep.token_env_map, dict(host_env))
def _ensure_smolmachine(image_ref: str) -> Path: def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
"""Build the agent docker image and convert it into a """Build the agent docker image and convert it into a
`.smolmachine` artifact, caching the result under `.smolmachine` artifact, caching the result under
`~/.cache/claude-bottle/smolmachines/` keyed by the docker image `~/.cache/bot-bottle/smolmachines/` keyed by the docker image
ID (so a Dockerfile change automatically invalidates the cache). ID (so a Dockerfile change automatically invalidates the cache).
Returns the `.smolmachine.smolmachine` sidecar path that's Returns the `.smolmachine.smolmachine` sidecar path that's
@@ -438,7 +450,7 @@ def _ensure_smolmachine(image_ref: str) -> Path:
so we skip the whole pipeline when the cached sidecar is so we skip the whole pipeline when the cached sidecar is
already on disk for this image ID.""" already on disk for this image ID."""
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True) _SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
docker_mod.build_image(image_ref, _REPO_DIR) docker_mod.build_image(image_ref, _REPO_DIR, dockerfile=dockerfile)
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to # `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
# keep filenames manageable, long enough to make collisions # keep filenames manageable, long enough to make collisions
# astronomically unlikely. # astronomically unlikely.
@@ -451,8 +463,8 @@ def _ensure_smolmachine(image_ref: str) -> Path:
docker_mod.save(image_ref, str(tarball)) docker_mod.save(image_ref, str(tarball))
try: try:
with ephemeral_registry() as handle: with ephemeral_registry() as handle:
push_ref = f"{handle.push_endpoint}/claude-bottle:{digest}" push_ref = f"{handle.push_endpoint}/bot-bottle:{digest}"
pack_ref = f"{handle.pull_endpoint}/claude-bottle:{digest}" pack_ref = f"{handle.pull_endpoint}/bot-bottle:{digest}"
crane_push_tarball(handle, str(tarball), push_ref) crane_push_tarball(handle, str(tarball), push_ref)
_smolvm.pack_create(pack_ref, binary) _smolvm.pack_create(pack_ref, binary)
finally: finally:
@@ -48,9 +48,9 @@ from ...log import die
# registry:2.8.3, pinned by digest. Same env-override pattern as the # registry:2.8.3, pinned by digest. Same env-override pattern as the
# pipelock image pin in claude_bottle/backend/docker/pipelock.py. # pipelock image pin in bot_bottle/backend/docker/pipelock.py.
REGISTRY_IMAGE = os.environ.get( REGISTRY_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_REGISTRY_IMAGE", "BOT_BOTTLE_REGISTRY_IMAGE",
"registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
) )
@@ -60,7 +60,7 @@ REGISTRY_IMAGE = os.environ.get(
# against a localhost-equivalent registry, so the trust surface is # against a localhost-equivalent registry, so the trust surface is
# narrow. # narrow.
CRANE_IMAGE = os.environ.get( CRANE_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_CRANE_IMAGE", "BOT_BOTTLE_CRANE_IMAGE",
"gcr.io/go-containerregistry/crane@sha256:0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084", "gcr.io/go-containerregistry/crane@sha256:0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084",
) )
@@ -104,8 +104,8 @@ def ephemeral_registry() -> Iterator[RegistryHandle]:
on its own; the `finally` block force-removes on abnormal exit on its own; the `finally` block force-removes on abnormal exit
(the calling process crashes between yield and close).""" (the calling process crashes between yield and close)."""
session_id = uuid.uuid4().hex[:12] session_id = uuid.uuid4().hex[:12]
network = f"claude-bottle-registry-net-{session_id}" network = f"bot-bottle-registry-net-{session_id}"
registry_name = f"claude-bottle-registry-{session_id}" registry_name = f"bot-bottle-registry-{session_id}"
subprocess.run( subprocess.run(
["docker", "network", "create", network], ["docker", "network", "create", network],
@@ -110,7 +110,7 @@ def ensure_pool() -> None:
) )
for ip in missing: for ip in missing:
result = subprocess.run( result = subprocess.run(
["sudo", "-p", "claude-bottle (loopback alias): ", ["sudo", "-p", "bot-bottle (loopback alias): ",
"ifconfig", "lo0", "alias", f"{ip}/32", "up"], "ifconfig", "lo0", "alias", f"{ip}/32", "up"],
check=False, check=False,
) )
@@ -215,7 +215,7 @@ def _aliases_in_use() -> set[str]:
`HostIp` out of its port bindings.""" `HostIp` out of its port bindings."""
result = subprocess.run( result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}", ["docker", "ps", "--format", "{{.Names}}",
"--filter", "name=claude-bottle-sidecars-"], "--filter", "name=bot-bottle-sidecars-"],
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
if result.returncode != 0: if result.returncode != 0:
@@ -14,6 +14,7 @@ import os
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ...agent_provider import runtime_for
from ...backend import BottleSpec from ...backend import BottleSpec
from ...backend.docker.bottle_state import ( from ...backend.docker.bottle_state import (
BottleMetadata, BottleMetadata,
@@ -55,6 +56,8 @@ def resolve_plan(
manifest = spec.manifest manifest = spec.manifest
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
slug = spec.identity or bottle_identity(spec.agent_name) slug = spec.identity or bottle_identity(spec.agent_name)
@@ -116,9 +119,13 @@ def resolve_plan(
# outbound leg using a token held in egress's own environ — so # outbound leg using a token held in egress's own environ — so
# the agent gets a non-secret placeholder here (matches the # the agent gets a non-secret placeholder here (matches the
# docker backend's forwarded_env logic in # docker backend's forwarded_env logic in
# claude_bottle/backend/docker/prepare.py). # bot_bottle/backend/docker/prepare.py).
if any("claude_code_oauth" in r.roles for r in egress_plan.routes): has_provider_auth = any(
guest_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" provider_runtime.auth_role in r.roles for r in egress_plan.routes
)
if has_provider_auth:
guest_env[provider_runtime.placeholder_env] = "egress-placeholder"
if provider.template == "claude" and has_provider_auth:
guest_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") guest_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
guest_env.setdefault("DISABLE_ERROR_REPORTING", "1") guest_env.setdefault("DISABLE_ERROR_REPORTING", "1")
@@ -141,13 +148,20 @@ def resolve_plan(
prompt_file.write_text(agent.prompt or "") prompt_file.write_text(agent.prompt or "")
prompt_file.chmod(0o600) prompt_file.chmod(0o600)
machine_name = f"claude-bottle-{slug}" machine_name = f"bot-bottle-{slug}"
# Stash the agent image ref — `launch.launch` runs the # Stash the agent image ref — `launch.launch` runs the
# build → pack pipeline at bringup. Honors CLAUDE_BOTTLE_IMAGE # build → pack pipeline at bringup. Honors BOT_BOTTLE_IMAGE
# to match the docker backend's `resolve_plan` default. # to match the docker backend's `resolve_plan` default.
agent_image_ref = os.environ.get( agent_dockerfile_path = ""
"CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest" if provider.dockerfile:
) agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
image_default = f"bot-bottle-{provider.template}:{slug}"
elif provider_runtime.dockerfile:
agent_dockerfile_path = provider_runtime.dockerfile
image_default = provider_runtime.image
else:
image_default = provider_runtime.image
agent_image_ref = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
spec=spec, spec=spec,
@@ -164,4 +178,15 @@ def resolve_plan(
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
agent_command=provider_runtime.command,
agent_prompt_mode=provider_runtime.prompt_mode,
agent_provider_template=provider.template,
agent_dockerfile_path=agent_dockerfile_path,
) )
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
path = Path(os.path.expanduser(path_value))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
return str(path)
@@ -36,14 +36,14 @@ from ..bottle_plan import SmolmachinesBottlePlan
# `node` is the agent user from the repo Dockerfile. Override via # `node` is the agent user from the repo Dockerfile. Override via
# CLAUDE_BOTTLE_GUEST_HOME mirrors the docker backend's # BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
# CLAUDE_BOTTLE_CONTAINER_HOME knob — same purpose, different # BOT_BOTTLE_CONTAINER_HOME knob — same purpose, different
# transport. # transport.
_DEFAULT_GUEST_HOME = "/home/node" _DEFAULT_GUEST_HOME = "/home/node"
def _guest_home() -> str: def _guest_home() -> str:
return os.environ.get("CLAUDE_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME) return os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None: def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None:
@@ -18,8 +18,8 @@ from ..bottle_plan import SmolmachinesBottlePlan
# `node` is the agent user from the repo Dockerfile. # `node` is the agent user from the repo Dockerfile.
# CLAUDE_BOTTLE_GUEST_HOME mirrors the docker backend's # BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
# CLAUDE_BOTTLE_CONTAINER_HOME knob. # BOT_BOTTLE_CONTAINER_HOME knob.
_DEFAULT_GUEST_HOME = "/home/node" _DEFAULT_GUEST_HOME = "/home/node"
@@ -29,8 +29,8 @@ def provision_prompt(plan: SmolmachinesBottlePlan, target: str) -> str | None:
non-empty prompt (drives --append-system-prompt-file), else non-empty prompt (drives --append-system-prompt-file), else
None. The file is copied either way so the path always None. The file is copied either way so the path always
exists mirrors the docker backend's behavior.""" exists mirrors the docker backend's behavior."""
guest_home = os.environ.get("CLAUDE_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME) guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
in_guest_prompt_path = f"{guest_home}/.claude-bottle-prompt.txt" in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt"
_smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}") _smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}")
# machine cp lands as root, source's 0o600 mode is preserved — # machine cp lands as root, source's 0o600 mode is preserved —
@@ -19,7 +19,7 @@ from ..bottle_plan import SmolmachinesBottlePlan
# In-guest path mirrors the docker backend's claude-skills # In-guest path mirrors the docker backend's claude-skills
# convention (~/.claude/skills/<name>/) under the node user's # convention (~/.claude/skills/<name>/) under the node user's
# home — same path as the real claude-bottle image's # home — same path as the real bot-bottle image's
# /home/node/.claude/skills (pre-created in the Dockerfile). # /home/node/.claude/skills (pre-created in the Dockerfile).
_DEFAULT_SKILLS_DIR = "/home/node/.claude/skills" _DEFAULT_SKILLS_DIR = "/home/node/.claude/skills"
@@ -43,7 +43,7 @@ def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
return return
skills_dir = os.environ.get( skills_dir = os.environ.get(
"CLAUDE_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR, "BOT_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
) )
_smolvm.machine_exec(target, ["mkdir", "-p", skills_dir]) _smolvm.machine_exec(target, ["mkdir", "-p", skills_dir])
@@ -24,7 +24,7 @@ process that:
extra signalling. extra signalling.
3. Waits on the child and exits with its returncode. 3. Waits on the child and exits with its returncode.
The dashboard's tmux pane respawn calls `bottle.claude_argv` The dashboard's tmux pane respawn calls `bottle.agent_argv`
which now prepends `[sys.executable, -m, ..., <machine>, --, ...]` which now prepends `[sys.executable, -m, ..., <machine>, --, ...]`
to the smolvm argv. Foreground handoff (curses endwin to the smolvm argv. Foreground handoff (curses endwin
subprocess.run) goes through the same path so behavior is subprocess.run) goes through the same path so behavior is
@@ -116,7 +116,7 @@ def main(argv: list[str]) -> int:
transparent for callers building argv programmatically.""" transparent for callers building argv programmatically."""
if len(argv) < 3 or argv[1] != "--": if len(argv) < 3 or argv[1] != "--":
sys.stderr.write( sys.stderr.write(
"usage: python -m claude_bottle.backend.smolmachines.pty_resize " "usage: python -m bot_bottle.backend.smolmachines.pty_resize "
"<machine> -- <smolvm-argv...>\n" "<machine> -- <smolvm-argv...>\n"
) )
return 2 return 2
@@ -11,7 +11,7 @@ Two docker resources per bottle live here:
a race we can sidestep with `--ip`. a race we can sidestep with `--ip`.
- **The bundle container itself**, running the PRD 0024 bundle - **The bundle container itself**, running the PRD 0024 bundle
image (`claude-bottle-sidecars:latest` by default). Same image (`bot-bottle-sidecars:latest` by default). Same
image, same daemons, same daemon-private env / bind-mounts image, same daemons, same daemon-private env / bind-mounts
as the docker backend. as the docker backend.
@@ -29,22 +29,29 @@ from pathlib import Path
from typing import Sequence from typing import Sequence
from ...log import die, warn from ...log import die, warn
from ..docker.sidecar_bundle import SIDECAR_BUNDLE_IMAGE from ..docker import util as docker_mod
from ..docker.sidecar_bundle import (
SIDECAR_BUNDLE_DOCKERFILE,
SIDECAR_BUNDLE_IMAGE,
)
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def bundle_network_name(slug: str) -> str: def bundle_network_name(slug: str) -> str:
"""`claude-bottle-bundle-<slug>` — distinct from the docker """`bot-bottle-bundle-<slug>` — distinct from the docker
backend's `claude-bottle-net-<slug>` so a smolmachines bottle backend's `bot-bottle-net-<slug>` so a smolmachines bottle
and a docker bottle for the same agent don't collide on and a docker bottle for the same agent don't collide on
network name.""" network name."""
return f"claude-bottle-bundle-{slug}" return f"bot-bottle-bundle-{slug}"
def bundle_container_name(slug: str) -> str: def bundle_container_name(slug: str) -> str:
"""`claude-bottle-sidecars-<slug>` — same name shape the docker """`bot-bottle-sidecars-<slug>` — same name shape the docker
backend uses for the bundle (PRD 0024 chunk 5). The dashboard's backend uses for the bundle (PRD 0024 chunk 5). The dashboard's
prefix-based discovery covers both backends with one filter.""" prefix-based discovery covers both backends with one filter."""
return f"claude-bottle-sidecars-{slug}" return f"bot-bottle-sidecars-{slug}"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -59,7 +66,7 @@ class BundleLaunchSpec:
gateway: str gateway: str
bundle_ip: str bundle_ip: str
image: str = SIDECAR_BUNDLE_IMAGE image: str = SIDECAR_BUNDLE_IMAGE
# Daemon subset CSV for CLAUDE_BOTTLE_SIDECAR_DAEMONS. The # Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The
# supervisor inside the bundle reads it to skip # supervisor inside the bundle reads it to skip
# bottle-irrelevant daemons (e.g. supervise=False bottles). # bottle-irrelevant daemons (e.g. supervise=False bottles).
daemons_csv: str = "egress,pipelock" daemons_csv: str = "egress,pipelock"
@@ -85,6 +92,21 @@ class BundleLaunchSpec:
publish_host_ip: str = "127.0.0.1" publish_host_ip: str = "127.0.0.1"
def ensure_bundle_image(image: str = SIDECAR_BUNDLE_IMAGE) -> None:
"""Build the sidecar bundle image before `docker run`.
The Docker backend gets this for free from compose's `build:`
stanza. smolmachines starts the bundle with plain `docker run`,
so without an explicit build a first launch tries to pull the
local-only `bot-bottle-sidecars:latest` tag from a registry.
"""
docker_mod.build_image(
image,
_REPO_DIR,
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
)
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None: def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
"""`docker network create` with an explicit subnet + gateway """`docker network create` with an explicit subnet + gateway
so the bundle's `--ip` lands on the address the Smolfile's so the bundle's `--ip` lands on the address the Smolfile's
@@ -141,7 +163,7 @@ def start_bundle(spec: BundleLaunchSpec, *,
"--rm", "--rm",
"--network", spec.network_name, "--network", spec.network_name,
"--ip", spec.bundle_ip, "--ip", spec.bundle_ip,
"-e", f"CLAUDE_BOTTLE_SIDECAR_DAEMONS={spec.daemons_csv}", "-e", f"BOT_BOTTLE_SIDECAR_DAEMONS={spec.daemons_csv}",
] ]
for entry in spec.environment: for entry in spec.environment:
argv += ["-e", entry] argv += ["-e", entry]
@@ -19,7 +19,7 @@ def smolmachines_preflight() -> None:
if shutil.which("smolvm") is not None: if shutil.which("smolvm") is not None:
return return
die( die(
"CLAUDE_BOTTLE_BACKEND=smolmachines requires `smolvm` on " "BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
"PATH. Install with: " "PATH. Install with: "
"curl -sSL https://smolmachines.com/install.sh | sh" "curl -sSL https://smolmachines.com/install.sh | sh"
) )
@@ -1,6 +1,6 @@
"""Cross-backend utility helpers — host-side primitives shared by """Cross-backend utility helpers — host-side primitives shared by
every backend implementation. Backend-specific helpers live one level every backend implementation. Backend-specific helpers live one level
deeper (e.g. claude_bottle/backend/docker/util.py).""" deeper (e.g. bot_bottle/backend/docker/util.py)."""
from __future__ import annotations from __future__ import annotations
@@ -35,11 +35,11 @@ COMMANDS = {
def usage() -> None: def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n") sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
sys.stderr.write("Commands:\n") sys.stderr.write("Commands:\n")
sys.stderr.write(" cleanup stop and remove all active claude-bottle containers\n") sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
sys.stderr.write(" dashboard view + approve/modify/reject pending supervise proposals (PRD 0013)\n") sys.stderr.write(" dashboard view + approve/modify/reject pending supervise proposals (PRD 0013)\n")
sys.stderr.write(" edit open an agent in vim for editing\n") sys.stderr.write(" edit open an agent in vim for editing\n")
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n") sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
sys.stderr.write(" init interactively create a new agent and add it to claude-bottle.json\n") sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
sys.stderr.write(" list list available agents or active containers\n") sys.stderr.write(" list list available agents or active containers\n")
sys.stderr.write(" resume re-launch a bottle by its identity (continues state from PRD 0016)\n") sys.stderr.write(" resume re-launch a bottle by its identity (continues state from PRD 0016)\n")
sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n\n") sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n\n")
@@ -1,4 +1,4 @@
"""cleanup: stop and remove all orphaned claude-bottle resources. """cleanup: stop and remove all orphaned bot-bottle resources.
Walks every registered backend (docker + smolmachines) so a single Walks every registered backend (docker + smolmachines) so a single
`./cli.py cleanup` reaps both backends' leftovers — orphaned `./cli.py cleanup` reaps both backends' leftovers — orphaned
@@ -14,7 +14,7 @@ bucket.
State dirs with `.preserve` are intentionally never touched they State dirs with `.preserve` are intentionally never touched they
hold capability-block rebuilds or crash snapshots the operator may hold capability-block rebuilds or crash snapshots the operator may
want to `resume`. Manual `rm -rf ~/.claude-bottle/state/<identity>` want to `resume`. Manual `rm -rf ~/.bot-bottle/state/<identity>`
is the path for those. is the path for those.
""" """
@@ -36,7 +36,7 @@ def cmd_cleanup(_argv: list[str]) -> int:
prepared = [(name, b, b.prepare_cleanup()) for name, b in plans] prepared = [(name, b, b.prepare_cleanup()) for name, b in plans]
if all(p.empty for _, _, p in prepared): if all(p.empty for _, _, p in prepared):
info("no claude-bottle resources to clean up") info("no bot-bottle resources to clean up")
return 0 return 0
for name, _, plan in prepared: for name, _, plan in prepared:
@@ -58,7 +58,7 @@ def cmd_cleanup(_argv: list[str]) -> int:
def _prompt_yes(message: str) -> bool: def _prompt_yes(message: str) -> bool:
sys.stderr.write(f"claude-bottle: {message} [y/N] ") sys.stderr.write(f"bot-bottle: {message} [y/N] ")
sys.stderr.flush() sys.stderr.flush()
reply = read_tty_line() reply = read_tty_line()
return reply in ("y", "Y", "yes", "YES") return reply in ("y", "Y", "yes", "YES")
@@ -26,6 +26,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .. import supervise as _supervise from .. import supervise as _supervise
from ..agent_provider import runtime_for
from ..backend import ( from ..backend import (
ActiveAgent, ActiveAgent,
BottleSpec, BottleSpec,
@@ -73,8 +74,8 @@ from ..supervise import (
) )
from ._common import PROG, USER_CWD from ._common import PROG, USER_CWD
from .start import ( from .start import (
attach_claude, attach_agent,
capture_session_state, capture_claude_session_state,
prepare_with_preflight, prepare_with_preflight,
settle_state, settle_state,
) )
@@ -119,10 +120,10 @@ def _approval_status(qp: QueuedProposal, verb: str) -> str:
def discover_pending() -> list[QueuedProposal]: def discover_pending() -> list[QueuedProposal]:
"""Walk ~/.claude-bottle/queue/* and collect pending proposals """Walk ~/.bot-bottle/queue/* and collect pending proposals
from every bottle's queue. Sorted by arrival time across the from every bottle's queue. Sorted by arrival time across the
union the operator works the global FIFO.""" union the operator works the global FIFO."""
queue_root = _supervise.claude_bottle_root() / "queue" queue_root = _supervise.bot_bottle_root() / "queue"
if not queue_root.is_dir(): if not queue_root.is_dir():
return [] return []
out: list[QueuedProposal] = [] out: list[QueuedProposal] = []
@@ -367,8 +368,6 @@ def _picker_modal(
"""Modal agent picker. Type to filter; j/k or arrows to """Modal agent picker. Type to filter; j/k or arrows to
navigate; Enter to confirm; Esc to abort (first press clears navigate; Enter to confirm; Esc to abort (first press clears
filter if any, second press exits).""" filter if any, second press exits)."""
if not names:
return None
selected = 0 selected = 0
query = "" query = ""
while True: while True:
@@ -454,9 +453,13 @@ def _draw_picker_modal(
list_start_row = 3 list_start_row = 3
visible_rows = box_h - list_start_row - 1 visible_rows = box_h - list_start_row - 1
if not filtered: if not filtered:
empty_message = (
"(no agents configured)"
if not all_names else "(no agents match filter)"
)
win.addnstr( win.addnstr(
list_start_row, 2, list_start_row, 2,
"(no agents match filter)", empty_message,
box_w - 4, curses.A_DIM, box_w - 4, curses.A_DIM,
) )
else: else:
@@ -542,7 +545,7 @@ def _backend_picker_modal(
which keeps existing-muscle-memory flows quiet the modal only which keeps existing-muscle-memory flows quiet the modal only
surfaces a choice; it doesn't surprise the operator by jumping surfaces a choice; it doesn't surprise the operator by jumping
to smolmachines. The picker exists so operators can opt in to to smolmachines. The picker exists so operators can opt in to
smolmachines without setting CLAUDE_BOTTLE_BACKEND beforehand smolmachines without setting BOT_BOTTLE_BACKEND beforehand
(issue #77).""" (issue #77)."""
names = list(known_backend_names()) names = list(known_backend_names())
if len(names) <= 1: if len(names) <= 1:
@@ -637,7 +640,7 @@ def _bottle_for_slug(
"""Return `(bottle_handle, prompt_path_hint)` for a re-attach. """Return `(bottle_handle, prompt_path_hint)` for a re-attach.
If the slug is in `bottles` (dashboard-owned), return the stored If the slug is in `bottles` (dashboard-owned), return the stored
handle directly. Otherwise synthesize a `DockerBottle` from the handle directly. Otherwise synthesize a `DockerBottle` from the
container name `claude-bottle-<slug>`. For synthesized bottles container name `bot-bottle-<slug>`. For synthesized bottles
the prompt-file path comes from the manifest's agent if we can the prompt-file path comes from the manifest's agent if we can
resolve it via metadata.json + the loaded manifest; otherwise resolve it via metadata.json + the loaded manifest; otherwise
the re-attach runs without `--append-system-prompt-file`. the re-attach runs without `--append-system-prompt-file`.
@@ -649,19 +652,19 @@ def _bottle_for_slug(
if slug in bottles: if slug in bottles:
_cm, bottle, _identity = bottles[slug] _cm, bottle, _identity = bottles[slug]
return bottle, "" return bottle, ""
# The container hosting the agent's claude process is named # The container hosting the agent's agent process is named
# `claude-bottle-<slug>` — set by the compose renderer # `bot-bottle-<slug>` — set by the compose renderer
# (no service suffix on the agent service, by design). # (no service suffix on the agent service, by design).
container_name = f"claude-bottle-{slug}" container_name = f"bot-bottle-{slug}"
prompt_path: str | None = None prompt_path: str | None = None
metadata = read_metadata(slug) metadata = read_metadata(slug)
if metadata is not None and manifest is not None: if metadata is not None and manifest is not None:
agent = manifest.agents.get(metadata.agent_name) agent = manifest.agents.get(metadata.agent_name)
if agent is not None and agent.prompt: if agent is not None and agent.prompt:
container_home = os.environ.get( container_home = os.environ.get(
"CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node", "BOT_BOTTLE_CONTAINER_HOME", "/home/node",
) )
prompt_path = f"{container_home}/.claude-bottle-prompt.txt" prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
synth = DockerBottle( synth = DockerBottle(
container=container_name, container=container_name,
teardown=lambda: None, teardown=lambda: None,
@@ -693,7 +696,7 @@ def _stop_bottle_flow(
return ( return (
f"[{slug}] not dashboard-owned — use ./cli.py cleanup" f"[{slug}] not dashboard-owned — use ./cli.py cleanup"
) )
cm, _bottle, identity = bottles.pop(slug) cm, bottle, identity = bottles.pop(slug)
def _do_teardown() -> None: def _do_teardown() -> None:
# Best-effort snapshot before teardown so the operator # Best-effort snapshot before teardown so the operator
@@ -703,7 +706,8 @@ def _stop_bottle_flow(
# existing preserve marker (if any) is honored by # existing preserve marker (if any) is honored by
# settle_state below. # settle_state below.
try: try:
capture_session_state(identity, exit_code=0) if getattr(bottle, "agent_provider_template", "claude") == "claude":
capture_claude_session_state(identity, exit_code=0)
except BaseException: except BaseException:
pass pass
try: try:
@@ -713,7 +717,7 @@ def _stop_bottle_flow(
# Mirror the bringup path's stderr → right-pane routing. # Mirror the bringup path's stderr → right-pane routing.
# Reuses any existing right pane (which is probably the # Reuses any existing right pane (which is probably the
# agent's own claude session) via `_ensure_right_pane`; the # agent's own agent session) via `_ensure_right_pane`; the
# final buffered output stays visible after settle_state # final buffered output stays visible after settle_state
# removes the state dir (tail-F handles file removal). # removes the state dir (tail-F handles file removal).
try: try:
@@ -750,7 +754,7 @@ def _stop_bottle_flow(
# pane of a two-pane window with the operator's currently-selected # pane of a two-pane window with the operator's currently-selected
# agent in the right pane. First attach creates the right pane via # agent in the right pane. First attach creates the right pane via
# `tmux split-window`; subsequent attaches respawn that pane with # `tmux split-window`; subsequent attaches respawn that pane with
# the new agent's claude session. The dashboard remembers the # the new agent's agent session. The dashboard remembers the
# pane id + occupant slug in `tmux_state` so the same pane is # pane id + occupant slug in `tmux_state` so the same pane is
# reused across attaches. # reused across attaches.
@@ -761,21 +765,24 @@ def _in_tmux() -> bool:
return bool(os.environ.get("TMUX")) return bool(os.environ.get("TMUX"))
def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[str]: def _agent_runtime_args(
"""The argv the dashboard hands to `bottle.claude_argv` *, resume: bool, remote_control: bool = False, agent_provider_template: str = "claude",
on every attach matches what `attach_claude` builds for the ) -> list[str]:
"""The argv the dashboard hands to `bottle.agent_argv`
on every attach matches what `attach_agent` builds for the
foreground handoff so both surfaces produce the same claude foreground handoff so both surfaces produce the same claude
invocation.""" invocation."""
args = ["--dangerously-skip-permissions"] runtime = runtime_for(agent_provider_template)
args = list(runtime.bypass_args)
if remote_control: if remote_control:
args.append("--remote-control") args.extend(runtime.remote_control_args)
if resume: if resume:
args.append("--continue") args.extend(runtime.resume_args)
return args return args
def _build_resume_argv_with_fallback( def _build_resume_argv_with_fallback(
bottle, *, remote_control: bool = False, bottle, *, remote_control: bool = False, agent_provider_template: str = "claude",
) -> list[str]: ) -> list[str]:
"""Build a backend-exec argv that runs `claude --continue` and """Build a backend-exec argv that runs `claude --continue` and
falls back to plain `claude` if no prior session exists. falls back to plain `claude` if no prior session exists.
@@ -790,30 +797,44 @@ def _build_resume_argv_with_fallback(
the fallback only kicks in when --continue would have the fallback only kicks in when --continue would have
failed anyway. failed anyway.
Works across backends because `bottle.claude_argv` always Works across backends because `bottle.agent_argv` always
surfaces the `claude` token preceded by the backend's exec surfaces the `claude` token preceded by the backend's exec
framing (docker: `docker exec -it <c>`; smolmachines: framing (docker: `docker exec -it <c>`; smolmachines:
`smolvm machine exec --name <m> -- runuser -u node --`). `smolvm machine exec --name <m> -- runuser -u node --`).
Splitting at `claude` keeps the framing as the prefix and Splitting at `claude` keeps the framing as the prefix and
wraps just the claude tail in `sh -c`.""" wraps just the agent tail in `sh -c`."""
base_args = ["--dangerously-skip-permissions"] if agent_provider_template != "claude":
if remote_control: return bottle.agent_argv(
base_args.append("--remote-control") _agent_runtime_args(
base_exec = bottle.claude_argv(base_args) resume=True,
# Split exec-framing prefix from the claude-and-args tail so remote_control=remote_control,
agent_provider_template=agent_provider_template,
)
)
base_args = _agent_runtime_args(
resume=False,
remote_control=remote_control,
agent_provider_template=agent_provider_template,
)
base_exec = bottle.agent_argv(base_args)
# Split exec-framing prefix from the agent-and-args tail so
# we can compose `<claude…> --continue || <claude…>` inside # we can compose `<claude…> --continue || <claude…>` inside
# `sh -c`. The `claude` token is the marker. # `sh -c`. The provider command token is the marker.
claude_idx = base_exec.index("claude") command = getattr(bottle, "agent_command", runtime_for(agent_provider_template).command)
prefix = base_exec[:claude_idx] agent_idx = base_exec.index(command)
claude_cmd = " ".join(shlex.quote(a) for a in base_exec[claude_idx:]) prefix = base_exec[:agent_idx]
agent_cmd = " ".join(shlex.quote(a) for a in base_exec[agent_idx:])
resume_args = " ".join(
shlex.quote(a) for a in runtime_for(agent_provider_template).resume_args
)
return [ return [
*prefix, *prefix,
"sh", "-c", "sh", "-c",
f"{claude_cmd} --continue || {claude_cmd}", f"{agent_cmd} {resume_args} || {agent_cmd}",
] ]
def _build_split_pane_argv(claude_argv: list[str]) -> list[str]: def _build_split_pane_argv(agent_argv: list[str]) -> list[str]:
"""Pure helper: wrap a backend-exec argv with `tmux split-window """Pure helper: wrap a backend-exec argv with `tmux split-window
-h -P -F '#{pane_id}'`. The `-P -F` combo tells tmux to print -h -P -F '#{pane_id}'`. The `-P -F` combo tells tmux to print
the new pane's id on stdout so we can track it for later the new pane's id on stdout so we can track it for later
@@ -821,15 +842,15 @@ def _build_split_pane_argv(claude_argv: list[str]) -> list[str]:
return [ return [
"tmux", "split-window", "-h", "tmux", "split-window", "-h",
"-P", "-F", "#{pane_id}", "-P", "-F", "#{pane_id}",
*claude_argv, *agent_argv,
] ]
def _build_respawn_pane_argv(pane_id: str, claude_argv: list[str]) -> list[str]: def _build_respawn_pane_argv(pane_id: str, agent_argv: list[str]) -> list[str]:
"""Pure helper: wrap a backend-exec argv with `tmux respawn-pane """Pure helper: wrap a backend-exec argv with `tmux respawn-pane
-k -t <pane_id>`. `-k` kills the existing process in the pane -k -t <pane_id>`. `-k` kills the existing process in the pane
before respawning.""" before respawning."""
return ["tmux", "respawn-pane", "-k", "-t", pane_id, *claude_argv] return ["tmux", "respawn-pane", "-k", "-t", pane_id, *agent_argv]
@contextlib.contextmanager @contextlib.contextmanager
@@ -933,7 +954,7 @@ def _route_op_to_right_pane(
def _tmux_close_right_pane(tmux_state: dict) -> None: def _tmux_close_right_pane(tmux_state: dict) -> None:
"""Close the tracked right pane via `tmux kill-pane`. Clears """Close the tracked right pane via `tmux kill-pane`. Clears
both pane_id and slug in `tmux_state`. Used after the last both pane_id and slug in `tmux_state`. Used after the last
dashboard-owned agent is stopped no claude session left dashboard-owned agent is stopped no agent session left
to host, so the pane shouldn't linger.""" to host, so the pane shouldn't linger."""
pane_id = tmux_state.get("pane_id") pane_id = tmux_state.get("pane_id")
if pane_id and _tmux_pane_exists(pane_id): if pane_id and _tmux_pane_exists(pane_id):
@@ -973,7 +994,7 @@ def _ensure_right_pane(tmux_state: dict, argv: list[str]) -> str | None:
returns the pane id on success, None on failure. returns the pane id on success, None on failure.
This is the single place where "respawn or create" lives This is the single place where "respawn or create" lives
used by `_attach_in_tmux` for claude sessions AND by used by `_attach_in_tmux` for agent sessions AND by
`_new_agent_flow` for the bringup-log tail. Without this, `_new_agent_flow` for the bringup-log tail. Without this,
every new-agent start would pile up a fresh right pane every new-agent start would pile up a fresh right pane
instead of reusing the one already next to the dashboard.""" instead of reusing the one already next to the dashboard."""
@@ -1018,14 +1039,18 @@ def _attach_via_handoff(
`_attach_in_tmux` when tmux misbehaves).""" `_attach_in_tmux` when tmux misbehaves)."""
curses.endwin() curses.endwin()
try: try:
exit_code = attach_claude( agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
bottle, remote_control=False, resume=resume, exit_code = attach_agent(
bottle,
remote_control=False,
resume=resume,
agent_provider_template=agent_provider_template,
) )
except BaseException: except BaseException:
stdscr.refresh() stdscr.refresh()
raise raise
stdscr.refresh() stdscr.refresh()
return f"[{slug}] claude session ended (exit {exit_code})" return f"[{slug}] agent session ended (exit {exit_code})"
def _attach_in_tmux( def _attach_in_tmux(
@@ -1044,21 +1069,28 @@ def _attach_in_tmux(
explicit-stop hook). explicit-stop hook).
`focus_right_pane=True` runs `tmux select-pane` after the `focus_right_pane=True` runs `tmux select-pane` after the
respawn so the operator is dropped into claude immediately. respawn so the operator is dropped into agent immediately.
The Enter re-attach key passes this; passive paths (the The Enter re-attach key passes this; passive paths (the
auto-attach after a stop) leave it False so the operator auto-attach after a stop) leave it False so the operator
stays in the dashboard pane.""" stays in the dashboard pane."""
if resume: if resume:
agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
# `--continue` exits non-zero when no prior session # `--continue` exits non-zero when no prior session
# exists (agent spun up but never typed at). Wrap with a # exists (agent spun up but never typed at). Wrap with a
# shell-level fallback so the pane lands in a fresh # shell-level fallback so the pane lands in a fresh
# claude instead of crashing. # agent instead of crashing.
claude_argv = _build_resume_argv_with_fallback(bottle) agent_argv = _build_resume_argv_with_fallback(
else: bottle, agent_provider_template=agent_provider_template,
claude_argv = bottle.claude_argv(
_claude_runtime_args(resume=False),
) )
pane_id = _ensure_right_pane(tmux_state, claude_argv) else:
agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
agent_argv = bottle.agent_argv(
_agent_runtime_args(
resume=False,
agent_provider_template=agent_provider_template,
),
)
pane_id = _ensure_right_pane(tmux_state, agent_argv)
if pane_id is None: if pane_id is None:
# tmux failed (missing binary, server died, size error). # tmux failed (missing binary, server died, size error).
# One status-line failover to the curses handoff so the # One status-line failover to the curses handoff so the
@@ -1091,7 +1123,7 @@ def _attach_to_bottle(
tmux_state: dict | None = None, tmux_state: dict | None = None,
) -> str: ) -> str:
"""Re-attach to a running bottle. Inside tmux (`$TMUX` set + """Re-attach to a running bottle. Inside tmux (`$TMUX` set +
`tmux_state` provided) the claude session opens in the `tmux_state` provided) the agent session opens in the
right pane (created on first attach, respawned on right pane (created on first attach, respawned on
subsequent). Outside tmux it's a curses-endwin handoff that subsequent). Outside tmux it's a curses-endwin handoff that
blocks until the operator exits claude. Re-attach always uses blocks until the operator exits claude. Re-attach always uses
@@ -1099,7 +1131,7 @@ def _attach_to_bottle(
if _in_tmux() and tmux_state is not None: if _in_tmux() and tmux_state is not None:
# Enter re-attach is an explicit "I want to interact with # Enter re-attach is an explicit "I want to interact with
# this agent" signal — move tmux focus to the right pane # this agent" signal — move tmux focus to the right pane
# so keypresses land in claude instead of the dashboard. # so keypresses land in agent instead of the dashboard.
return _attach_in_tmux( return _attach_in_tmux(
stdscr, bottle, slug, stdscr, bottle, slug,
resume=True, tmux_state=tmux_state, resume=True, tmux_state=tmux_state,
@@ -1117,13 +1149,15 @@ def _new_agent_flow(
) -> str: ) -> str:
"""Open the picker, prepare + preflight (modal), launch """Open the picker, prepare + preflight (modal), launch
(enter the context manager but DON'T close it), then route (enter the context manager but DON'T close it), then route
the first claude session into the right pane (in-tmux) or the first agent session into the right pane (in-tmux) or
foreground handoff (otherwise). Returns a status-line message foreground handoff (otherwise). Returns a status-line message
for the dashboard footer. The (cm, bottle) tuple lands in for the dashboard footer. The (cm, bottle) tuple lands in
`bottles` keyed by slug; chunk 4 uses it for explicit stop.""" `bottles` keyed by slug; chunk 4 uses it for explicit stop."""
names = sorted(manifest.agents.keys()) names = sorted(manifest.agents.keys())
picked = _picker_modal(stdscr, names, _running_counts(bottles, agents_now)) picked = _picker_modal(stdscr, names, _running_counts(bottles, agents_now))
if picked is None: if picked is None:
if not names:
return "no agents configured; create ~/.bot-bottle/agents/*.md"
return "agent start aborted" return "agent start aborted"
# Backend picker (issue #77): operator chooses docker / # Backend picker (issue #77): operator chooses docker /
@@ -1151,7 +1185,7 @@ def _new_agent_flow(
def _prompt() -> bool: def _prompt() -> bool:
return _preflight_modal(stdscr, captured.get("text", "")) return _preflight_modal(stdscr, captured.get("text", ""))
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage.")) stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
try: try:
plan, identity = prepare_with_preflight( plan, identity = prepare_with_preflight(
spec, spec,
@@ -1205,14 +1239,20 @@ def _new_agent_flow(
raise raise
bottles[plan.slug] = (cm, bottle, identity) bottles[plan.slug] = (cm, bottle, identity)
# Foreground handoff: claude owns the terminal until exit, # Foreground handoff: the agent owns the terminal until exit,
# then we restore curses. # then we restore curses.
try: try:
exit_code = attach_claude(bottle, remote_control=False) agent_provider_template = getattr(plan, "agent_provider_template", "claude")
capture_session_state(identity, exit_code) exit_code = attach_agent(
bottle,
remote_control=False,
agent_provider_template=agent_provider_template,
)
if agent_provider_template == "claude":
capture_claude_session_state(identity, exit_code)
finally: finally:
stdscr.refresh() stdscr.refresh()
return f"[{plan.slug}] claude session ended (exit {exit_code})" return f"[{plan.slug}] agent session ended (exit {exit_code})"
finally: finally:
# stage_dir was the prepare scratch dir; after PRD 0018 # stage_dir was the prepare scratch dir; after PRD 0018
# chunk 2 it holds nothing the running bottle needs. Reap # chunk 2 it holds nothing the running bottle needs. Reap
@@ -1365,8 +1405,10 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
def _get_manifest() -> Manifest: def _get_manifest() -> Manifest:
if manifest_cache[0] is None: if manifest_cache[0] is None:
manifest_cache[0] = Manifest.resolve(USER_CWD) manifest_cache[0] = Manifest.resolve(USER_CWD, missing_ok=True)
return manifest_cache[0] return manifest_cache[0]
if not _get_manifest().bottles and not _get_manifest().agents:
status_line = "warning: no bot-bottle config/agents found; new-agent picker is empty"
# First-tick guard: a brand-new dashboard finds any # First-tick guard: a brand-new dashboard finds any
# pre-existing queue entries on its first poll; those # pre-existing queue entries on its first poll; those
# shouldn't ring the bell as if they just arrived. # shouldn't ring the bell as if they just arrived.
@@ -1504,7 +1546,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
# PRD 0021 follow-up: after stop, slide focus # PRD 0021 follow-up: after stop, slide focus
# to the next agent in the list (the one that # to the next agent in the list (the one that
# filled the stopped row) and respawn the # filled the stopped row) and respawn the
# right pane with its claude session. If # right pane with its agent session. If
# nothing's left, close the right pane. # nothing's left, close the right pane.
pick = _pick_next_after_stop( pick = _pick_next_after_stop(
agents, selected_agent, target.slug, agents, selected_agent, target.slug,
@@ -1578,7 +1620,7 @@ def _render(
h, w = stdscr.getmaxyx() h, w = stdscr.getmaxyx()
agents = agents or [] agents = agents or []
header = ( header = (
f"claude-bottle dashboard " f"bot-bottle dashboard "
f"({len(pending)} pending, {len(agents)} active)" f"({len(pending)} pending, {len(agents)} active)"
) )
stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD) stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD)
@@ -18,9 +18,9 @@ def cmd_edit(argv: list[str]) -> int:
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.scope == "user": if args.scope == "user":
target_file = Path(os.environ["HOME"]) / "claude-bottle.json" target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
else: else:
target_file = Path(USER_CWD) / "claude-bottle.json" target_file = Path(USER_CWD) / "bot-bottle.json"
if not target_file.is_file(): if not target_file.is_file():
die(f"{target_file} does not exist") die(f"{target_file} does not exist")
@@ -11,7 +11,7 @@ from ._common import PROG, USER_CWD
def cmd_info(argv: list[str]) -> int: def cmd_info(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} info", add_help=True) parser = argparse.ArgumentParser(prog=f"{PROG} info", add_help=True)
parser.add_argument("name", help="agent name defined in claude-bottle.json") parser.add_argument("name", help="agent name defined in bot-bottle.json")
args = parser.parse_args(argv) args = parser.parse_args(argv)
manifest = Manifest.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
@@ -1,4 +1,4 @@
"""init: interactively create a new agent and add it to claude-bottle.json.""" """init: interactively create a new agent and add it to bot-bottle.json."""
from __future__ import annotations from __future__ import annotations
@@ -20,12 +20,12 @@ def cmd_init(argv: list[str]) -> int:
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.scope == "user": if args.scope == "user":
target_file = Path(os.environ["HOME"]) / "claude-bottle.json" target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
else: else:
target_file = Path(USER_CWD) / "claude-bottle.json" target_file = Path(USER_CWD) / "bot-bottle.json"
print(file=sys.stderr) print(file=sys.stderr)
info(f"claude-bottle init — adding a new agent to {target_file}") info(f"bot-bottle init — adding a new agent to {target_file}")
print(file=sys.stderr) print(file=sys.stderr)
# Agent name # Agent name
@@ -51,7 +51,7 @@ def cmd_init(argv: list[str]) -> int:
die(f"{target_file} exists but is not valid JSON; fix or remove it first") die(f"{target_file} exists but is not valid JSON; fix or remove it first")
if agent_name in (existing.get("agents") or {}): if agent_name in (existing.get("agents") or {}):
sys.stderr.write( sys.stderr.write(
f'claude-bottle: agent "{agent_name}" already exists in {target_file}. Overwrite? [y/N] ' f'bot-bottle: agent "{agent_name}" already exists in {target_file}. Overwrite? [y/N] '
) )
sys.stderr.flush() sys.stderr.flush()
ow = read_tty_line() ow = read_tty_line()
@@ -25,7 +25,7 @@ def cmd_list(argv: list[str]) -> int:
# so smolmachines bottles aren't hidden behind the env var. # so smolmachines bottles aren't hidden behind the env var.
active = enumerate_active_agents() active = enumerate_active_agents()
if not active: if not active:
print("no active claude-bottle bottles", file=sys.stderr) print("no active bot-bottle bottles", file=sys.stderr)
return 0 return 0
# One line per bottle: `<backend>\t<slug>\t<agent>\t<status>`. # One line per bottle: `<backend>\t<slug>\t<agent>\t<status>`.
# Tab-separated keeps the format stable for shell pipelines; # Tab-separated keeps the format stable for shell pipelines;
@@ -1,6 +1,6 @@
"""resume: re-launch a bottle by its identity. """resume: re-launch a bottle by its identity.
Reads ~/.claude-bottle/state/<identity>/metadata.json to recover the Reads ~/.bot-bottle/state/<identity>/metadata.json to recover the
(agent_name, cwd, copy_cwd) the bottle was originally started with, (agent_name, cwd, copy_cwd) the bottle was originally started with,
then runs the same launch core as `start` but pinned to the then runs the same launch core as `start` but pinned to the
recorded identity so the new bottle picks up any per-bottle Dockerfile recorded identity so the new bottle picks up any per-bottle Dockerfile
@@ -39,7 +39,7 @@ def cmd_resume(argv: list[str]) -> int:
if metadata is None: if metadata is None:
die( die(
f"no state recorded for identity {args.identity!r}; " f"no state recorded for identity {args.identity!r}; "
f"check ~/.claude-bottle/state/ or run `cli.py start` to create a new bottle" f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
) )
manifest = Manifest.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
@@ -4,7 +4,7 @@ session ends.
The launch core is shared with `cli.py resume <identity>` and (PRD The launch core is shared with `cli.py resume <identity>` and (PRD
0020 chunk 1+) the dashboard's in-process start flow: see the 0020 chunk 1+) the dashboard's in-process start flow: see the
public helpers `prepare_with_preflight`, `attach_claude`, and the public helpers `prepare_with_preflight`, `attach_agent`, and the
private orchestrator `_launch_bottle`. private orchestrator `_launch_bottle`.
""" """
@@ -18,6 +18,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from ..agent_provider import runtime_for
from ..backend import ( from ..backend import (
Bottle, Bottle,
BottleSpec, BottleSpec,
@@ -46,14 +47,14 @@ def cmd_start(argv: list[str]) -> int:
choices=known_backend_names(), choices=known_backend_names(),
default=None, default=None,
help=( help=(
"backend to launch the bottle on (default: $CLAUDE_BOTTLE_BACKEND " "backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND "
"or 'docker'). Overrides the env var when set." "or 'docker'). Overrides the env var when set."
), ),
) )
parser.add_argument("name", help="agent name defined in claude-bottle.json") parser.add_argument("name", help="agent name defined in bot-bottle.json")
args = parser.parse_args(argv) args = parser.parse_args(argv)
dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1" dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
manifest = Manifest.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
spec = BottleSpec( spec = BottleSpec(
@@ -88,7 +89,7 @@ def prepare_with_preflight(
curses modal. curses modal.
`backend_name` selects which backend prepares the plan `backend_name` selects which backend prepares the plan
(`None` `$CLAUDE_BOTTLE_BACKEND` `docker`). Dashboard (`None` `$BOT_BOTTLE_BACKEND` `docker`). Dashboard
passes the value from its new-agent backend-picker modal; the passes the value from its new-agent backend-picker modal; the
CLI passes whatever `--backend` resolved to. CLI passes whatever `--backend` resolved to.
@@ -112,11 +113,13 @@ def prepare_with_preflight(
return plan, identity return plan, identity
def attach_claude( def attach_agent(
bottle: Bottle, *, remote_control: bool = False, resume: bool = False, bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
agent_provider_template: str = "claude",
) -> int: ) -> int:
"""Run claude inside `bottle` as an interactive session. Blocks """Run the selected provider CLI inside `bottle` as an
until the session ends; returns the claude process's exit code. interactive session. Blocks until the session ends; returns the
agent process's exit code.
`resume=True` adds `--continue` so claude picks up its most `resume=True` adds `--continue` so claude picks up its most
recent session non-interactively (no session-picker prompt) recent session non-interactively (no session-picker prompt)
@@ -128,26 +131,28 @@ def attach_claude(
Used as the inner step of `./cli.py start` (one-shot) and by the Used as the inner step of `./cli.py start` (one-shot) and by the
dashboard, which calls it from inside a `curses.endwin dashboard, which calls it from inside a `curses.endwin
stdscr.refresh()` handoff so the curses surface gets out of the stdscr.refresh()` handoff so the curses surface gets out of the
terminal's way while claude has it.""" terminal's way while the agent has it."""
runtime = runtime_for(agent_provider_template)
info( info(
"attaching interactive claude session " f"attaching interactive {agent_provider_template} session "
"(Ctrl-D or 'exit' to leave; container will be removed)" "(Ctrl-D or 'exit' to leave; container will be removed)"
) )
claude_args = ["--dangerously-skip-permissions"] agent_args = list(runtime.bypass_args)
if remote_control: if remote_control:
claude_args.append("--remote-control") agent_args.extend(runtime.remote_control_args)
if resume: if resume:
# `--continue` jumps straight to the most recent session agent_args.extend(runtime.resume_args)
# without showing the picker `--resume` would surface. return bottle.exec_agent(agent_args, tty=True)
claude_args.append("--continue")
return bottle.exec_claude(claude_args, tty=True)
def capture_session_state(identity: str, exit_code: int) -> None: def capture_claude_session_state(identity: str, exit_code: int) -> None:
"""Inside the launch context, while the container is still """Inside the launch context, while the container is still
alive: snapshot the transcript and mark for preservation if alive: snapshot the transcript and mark for preservation if
claude crashed. Public for the dashboard's death-handling path claude crashed. Public for the dashboard's death-handling path
(PRD 0020 open question 3).""" (PRD 0020 open question 3)."""
# FIXME: this captures Claude-specific session state. A follow-up
# spike should explore freezing provider-neutral container state
# instead of relying on each agent's transcript layout.
if not identity: if not identity:
return return
snapshot_transcript(identity) snapshot_transcript(identity)
@@ -179,7 +184,7 @@ def _identity_from_plan(plan: object) -> str:
def _text_prompt_yes() -> bool: def _text_prompt_yes() -> bool:
"""Default `prompt_yes` for CLI use: reads y/N from the """Default `prompt_yes` for CLI use: reads y/N from the
controlling tty via stderr prompt + tty-line read.""" controlling tty via stderr prompt + tty-line read."""
sys.stderr.write("claude-bottle: launch this agent? [y/N] ") sys.stderr.write("bot-bottle: launch this agent? [y/N] ")
sys.stderr.flush() sys.stderr.flush()
reply = read_tty_line() reply = read_tty_line()
return reply in ("y", "Y", "yes", "YES") return reply in ("y", "Y", "yes", "YES")
@@ -201,7 +206,7 @@ def _launch_bottle(
"""Shared launch core for `start` and `resume`. Builds the plan, """Shared launch core for `start` and `resume`. Builds the plan,
prints / dry-runs / prompts as appropriate, brings the bottle up, prints / dry-runs / prompts as appropriate, brings the bottle up,
attaches claude, and prints the resume hint on session end.""" attaches claude, and prints the resume hint on session end."""
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage.")) stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
identity = "" identity = ""
try: try:
plan, identity = prepare_with_preflight( plan, identity = prepare_with_preflight(
@@ -217,7 +222,12 @@ def _launch_bottle(
backend = get_bottle_backend(backend_name) backend = get_bottle_backend(backend_name)
with backend.launch(plan) as bottle: with backend.launch(plan) as bottle:
exit_code = attach_claude(bottle, remote_control=remote_control) agent_provider_template = getattr(plan, "agent_provider_template", "claude")
exit_code = attach_agent(
bottle,
remote_control=remote_control,
agent_provider_template=agent_provider_template,
)
info( info(
f"session ended (exit {exit_code}); " f"session ended (exit {exit_code}); "
f"container {bottle.name} will be removed" f"container {bottle.name} will be removed"
@@ -230,7 +240,8 @@ def _launch_bottle(
# way. snapshot_transcript is best-effort so the # way. snapshot_transcript is best-effort so the
# capability-block path's prior snapshot isn't clobbered # capability-block path's prior snapshot isn't clobbered
# when the container is already gone. # when the container is already gone.
capture_session_state(identity, exit_code) if agent_provider_template == "claude":
capture_claude_session_state(identity, exit_code)
return 0 return 0
finally: finally:
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount # PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
@@ -14,7 +14,7 @@ This module defines the abstract proxy (`Egress`), its plan
dataclass (`EgressPlan`), and the resolved per-route shape dataclass (`EgressPlan`), and the resolved per-route shape
(`EgressRoute`). The sidecar's start/stop lifecycle is backend- (`EgressRoute`). The sidecar's start/stop lifecycle is backend-
specific and lives on concrete subclasses (see specific and lives on concrete subclasses (see
`claude_bottle/backend/docker/egress.py`). `bot_bottle/backend/docker/egress.py`).
Chunks 1+2 of the PRD: this module + the mitmproxy addon + the Docker Chunks 1+2 of the PRD: this module + the mitmproxy addon + the Docker
lifecycle are wired into the agent's `HTTP_PROXY` path; cred-proxy lifecycle are wired into the agent's `HTTP_PROXY` path; cred-proxy
@@ -127,23 +127,6 @@ class EgressPlan:
pipelock_proxy_url: str = "" pipelock_proxy_url: str = ""
# Hosts the agent needs by default for claude-code itself. Folded
# into every bottle's egress routes table as bare-pass entries
# (no auth, no path filter) so the agent reaches them without each
# bottle having to opt in. Pipelock used to own this list; PRD 0017
# moves it to egress because egress is the primary gate
# now and pipelock's allowlist is mirrored from egress.
DEFAULT_ALLOWLIST: tuple[str, ...] = (
"api.anthropic.com",
"statsig.anthropic.com",
"sentry.io",
"claude.ai",
"platform.claude.com",
"downloads.claude.ai",
"raw.githubusercontent.com",
)
def egress_manifest_routes( def egress_manifest_routes(
bottle: Bottle, bottle: Bottle,
) -> tuple[EgressRoute, ...]: ) -> tuple[EgressRoute, ...]:
@@ -157,10 +140,9 @@ def egress_manifest_routes(
shares slot 0. Unauthenticated routes (`auth` omitted) contribute shares slot 0. Unauthenticated routes (`auth` omitted) contribute
no slot. no slot.
Does NOT include the folded-in DEFAULT_ALLOWLIST / This is the effective set the addon enforces. Provider runtime
bottle.egress.allowlist bare-pass entries see routes are intentionally not injected implicitly; every allowed
`egress_routes_for_bottle` for the effective set the host must come from the home-owned bottle manifest."""
addon enforces."""
out: list[EgressRoute] = [] out: list[EgressRoute] = []
slot_for_token: dict[str, str] = {} slot_for_token: dict[str, str] = {}
for r in bottle.egress.routes: for r in bottle.egress.routes:
@@ -189,26 +171,14 @@ def egress_manifest_routes(
def egress_routes_for_bottle( def egress_routes_for_bottle(
bottle: Bottle, bottle: Bottle,
) -> tuple[EgressRoute, ...]: ) -> tuple[EgressRoute, ...]:
"""Effective egress routes: manifest routes followed by """Effective egress routes. This is what gets rendered into
bare-pass entries for DEFAULT_ALLOWLIST hosts. This is what routes.yaml + what the addon enforces.
gets rendered into routes.yaml + what the addon enforces.
Manifest routes win over defaults on host collision (manifest Operators that want to allow a host declare it directly in
routes carry more specific config auth, path filter, role `bottle.egress.routes` as an authenticated route or bare-pass entry
markers). Hostname comparison is case-insensitive.
Operators that want to allow an arbitrary host that isn't in
DEFAULT_ALLOWLIST declare it directly in
`bottle.egress.routes` as a bare-pass entry
(`- host: <name>`). The legacy `bottle.egress.allowlist` (`- host: <name>`). The legacy `bottle.egress.allowlist`
folding is gone egress is the single allowlist surface.""" folding is gone egress is the single allowlist surface."""
out: list[EgressRoute] = list(egress_manifest_routes(bottle)) return egress_manifest_routes(bottle)
claimed: set[str] = {r.host.lower() for r in out}
for host in DEFAULT_ALLOWLIST:
if host.lower() not in claimed:
out.append(EgressRoute(host=host))
claimed.add(host.lower())
return tuple(out)
def egress_token_env_map( def egress_token_env_map(
@@ -327,7 +297,6 @@ class Egress(ABC):
) )
__all__ = [ __all__ = [
"DEFAULT_ALLOWLIST",
"EGRESS_HOSTNAME", "EGRESS_HOSTNAME",
"EGRESS_ROUTES_IN_CONTAINER", "EGRESS_ROUTES_IN_CONTAINER",
"Egress", "Egress",
@@ -21,7 +21,7 @@ mitmproxy is a container-only dependency. The host's tests target
Dockerfile.sidecars copies both this file and Dockerfile.sidecars copies both this file and
`egress_addon_core.py` flat into `/app/`; the absolute import `egress_addon_core.py` flat into `/app/`; the absolute import
below works because mitmdump runs with `/app` on its sys.path. The below works because mitmdump runs with `/app` on its sys.path. The
parallel file in the package source tree (claude_bottle/) is the parallel file in the package source tree (bot_bottle/) is the
build input not a module the host imports.""" build input not a module the host imports."""
from __future__ import annotations from __future__ import annotations
@@ -19,7 +19,7 @@ from dataclasses import dataclass
# Absolute import — `yaml_subset.py` is copied flat into the bundle # Absolute import — `yaml_subset.py` is copied flat into the bundle
# image's `/app/` next to this file (via `Dockerfile.sidecars`). # image's `/app/` next to this file (via `Dockerfile.sidecars`).
# The host-side unit tests run with the repo on sys.path, where the # The host-side unit tests run with the repo on sys.path, where the
# import resolves under the `claude_bottle` package. The try/except # import resolves under the `bot_bottle` package. The try/except
# shim picks whichever import works. # shim picks whichever import works.
try: try:
from yaml_subset import YamlSubsetError, parse_yaml_subset # type: ignore[import-not-found] from yaml_subset import YamlSubsetError, parse_yaml_subset # type: ignore[import-not-found]
@@ -2,7 +2,7 @@
# Egress daemon entrypoint inside the sidecar bundle (PRD 0024). # Egress daemon entrypoint inside the sidecar bundle (PRD 0024).
# #
# Extracted verbatim from Dockerfile.egress's prior inline `sh -c` # Extracted verbatim from Dockerfile.egress's prior inline `sh -c`
# ENTRYPOINT so the supervisor in claude_bottle/sidecar_init.py can # ENTRYPOINT so the supervisor in bot_bottle/sidecar_init.py can
# call it as a normal child. Behavior is unchanged: # call it as a normal child. Behavior is unchanged:
# #
# * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch # * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch
@@ -14,7 +14,7 @@
# combined trust bundle (system roots + pipelock CA) and point # combined trust bundle (system roots + pipelock CA) and point
# mitmproxy at it. The option REPLACES mitmproxy's default # mitmproxy at it. The option REPLACES mitmproxy's default
# trust store, so passing pipelock's CA alone would break # trust store, so passing pipelock's CA alone would break
# pipelock-passthrough hosts (api.anthropic.com etc.). # route-configured pipelock passthrough hosts.
# * `-s /app/egress_addon.py` loads the addon that reads # * `-s /app/egress_addon.py` loads the addon that reads
# /etc/egress/routes.yaml. # /etc/egress/routes.yaml.
+2 -2
View File
@@ -98,7 +98,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
prompt = ( prompt = (
f"{prompt_body} (input hidden): " f"{prompt_body} (input hidden): "
if prompt_body if prompt_body
else f"claude-bottle: secret value for {name} (input hidden): " else f"bot-bottle: secret value for {name} (input hidden): "
) )
value = getpass.getpass(prompt, stream=tty) value = getpass.getpass(prompt, stream=tty)
tty.close() tty.close()
@@ -106,7 +106,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
prompt = ( prompt = (
f"{prompt_body} (input hidden): " f"{prompt_body} (input hidden): "
if prompt_body if prompt_body
else f"claude-bottle: secret value for {name} (input hidden): " else f"bot-bottle: secret value for {name} (input hidden): "
) )
value = getpass.getpass(prompt) value = getpass.getpass(prompt)
if not value: if not value:
@@ -25,7 +25,7 @@ land. See `docs/prds/0008-git-gate.md`.
This module defines the abstract gate (`GitGate`) and its plan This module defines the abstract gate (`GitGate`) and its plan
dataclass (`GitGatePlan`). The sidecar's start/stop lifecycle is dataclass (`GitGatePlan`). The sidecar's start/stop lifecycle is
backend-specific and lives on concrete subclasses (see backend-specific and lives on concrete subclasses (see
`claude_bottle/backend/docker/git_gate.py`).""" `bot_bottle/backend/docker/git_gate.py`)."""
from __future__ import annotations from __future__ import annotations
@@ -71,6 +71,7 @@ class GitGateUpstream:
upstream_port: str upstream_port: str
identity_file: str identity_file: str
known_host_key: str known_host_key: str
known_hosts_file: Path = Path()
extra_hosts: Mapping[str, str] = field(default_factory=_empty_str_map) extra_hosts: Mapping[str, str] = field(default_factory=_empty_str_map)
@@ -158,7 +159,7 @@ def git_gate_render_gitconfig(
if not entries: if not entries:
return "" return ""
out = [ out = [
"# claude-bottle git-gate (PRD 0008): every git operation against\n", "# bot-bottle git-gate (PRD 0008): every git operation against\n",
"# a declared upstream routes through the gate, which mirrors\n", "# a declared upstream routes through the gate, which mirrors\n",
"# the upstream bidirectionally (gitleaks-scanned push;\n", "# the upstream bidirectionally (gitleaks-scanned push;\n",
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n", "# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
@@ -166,6 +167,17 @@ def git_gate_render_gitconfig(
for entry in entries: for entry in entries:
out.append(f'[url "git://{gate_host}/{entry.Name}.git"]\n') out.append(f'[url "git://{gate_host}/{entry.Name}.git"]\n')
out.append(f"\tinsteadOf = {entry.Upstream}\n") out.append(f"\tinsteadOf = {entry.Upstream}\n")
if entry.RemoteKey and entry.RemoteKey != entry.UpstreamHost:
port = (
f":{entry.UpstreamPort}"
if entry.UpstreamPort and entry.UpstreamPort != "22"
else ""
)
alias = (
f"ssh://{entry.UpstreamUser}@{entry.RemoteKey}{port}/"
f"{entry.UpstreamPath}"
)
out.append(f"\tinsteadOf = {alias}\n")
return "".join(out) return "".join(out)
@@ -397,11 +409,33 @@ class GitGate(ABC):
# not via `sh`, so the script needs the x bit. docker cp # not via `sh`, so the script needs the x bit. docker cp
# preserves source mode into the container. # preserves source mode into the container.
access_hook.chmod(0o700) access_hook.chmod(0o700)
upstreams_with_files: list[GitGateUpstream] = []
for u in upstreams:
known_hosts_file = Path()
if u.known_host_key:
known_hosts_file = stage_dir / f"{u.name}-known_hosts"
known_hosts_file.write_text(
git_gate_known_hosts_line(
u.upstream_host, u.upstream_port, u.known_host_key,
)
)
known_hosts_file.chmod(0o600)
upstreams_with_files.append(
GitGateUpstream(
name=u.name,
upstream_url=u.upstream_url,
upstream_host=u.upstream_host,
upstream_port=u.upstream_port,
identity_file=u.identity_file,
known_host_key=u.known_host_key,
known_hosts_file=known_hosts_file,
extra_hosts=dict(u.extra_hosts),
)
)
return GitGatePlan( return GitGatePlan(
slug=slug, slug=slug,
entrypoint_script=entrypoint, entrypoint_script=entrypoint,
hook_script=hook, hook_script=hook,
access_hook_script=access_hook, access_hook_script=access_hook,
upstreams=upstreams, upstreams=tuple(upstreams_with_files),
) )
+3 -3
View File
@@ -7,11 +7,11 @@ from typing import NoReturn
def info(msg: str) -> None: def info(msg: str) -> None:
print(f"claude-bottle: {msg}", file=sys.stderr) print(f"bot-bottle: {msg}", file=sys.stderr)
def warn(msg: str) -> None: def warn(msg: str) -> None:
print(f"claude-bottle: warning: {msg}", file=sys.stderr) print(f"bot-bottle: warning: {msg}", file=sys.stderr)
class Die(SystemExit): class Die(SystemExit):
@@ -20,5 +20,5 @@ class Die(SystemExit):
def die(msg: str) -> NoReturn: def die(msg: str) -> NoReturn:
print(f"claude-bottle: error: {msg}", file=sys.stderr) print(f"bot-bottle: error: {msg}", file=sys.stderr)
raise Die(1) raise Die(1)
@@ -2,9 +2,9 @@
Reads the per-file manifest tree: Reads the per-file manifest tree:
$HOME/.claude-bottle/bottles/<name>.md one bottle per file $HOME/.bot-bottle/bottles/<name>.md one bottle per file
$HOME/.claude-bottle/agents/<name>.md home-resident agents $HOME/.bot-bottle/agents/<name>.md home-resident agents
$CWD/.claude-bottle/agents/<name>.md cwd-supplied agents $CWD/.bot-bottle/agents/<name>.md cwd-supplied agents
Each file is Markdown with YAML frontmatter. The frontmatter holds Each file is Markdown with YAML frontmatter. The frontmatter holds
the structured config (see schema below); for agents the body is the structured config (see schema below); for agents the body is
@@ -18,6 +18,8 @@ Bottle schema (frontmatter):
user: { name: <str>, email: <str> } # optional user: { name: <str>, email: <str> } # optional
remotes: { <host>: <git-entry>, ... } # optional remotes: { <host>: <git-entry>, ... } # optional
egress: { routes: [ <egress-route>, ... ] } egress: { routes: [ <egress-route>, ... ] }
# route keys: host, path_allowlist, auth, role, pipelock
# pipelock: { tls_passthrough: <bool>, ssrf_ip_allowlist: [<cidr>, ...] }
supervise: <bool> # optional supervise: <bool> # optional
Agent schema (frontmatter): Agent schema (frontmatter):
@@ -41,6 +43,7 @@ on-disk files.
from __future__ import annotations from __future__ import annotations
import ipaddress
import json import json
import os import os
import re import re
@@ -48,6 +51,7 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Mapping, cast from typing import Mapping, cast
from .agent_provider import PROVIDER_TEMPLATES
from .log import die, warn from .log import die, warn
from .yaml_subset import YamlSubsetError, parse_frontmatter from .yaml_subset import YamlSubsetError, parse_frontmatter
@@ -81,6 +85,7 @@ class GitEntry:
IdentityFile: str IdentityFile: str
KnownHostKey: str = "" KnownHostKey: str = ""
ExtraHosts: Mapping[str, str] = field(default_factory=_empty_str_dict) ExtraHosts: Mapping[str, str] = field(default_factory=_empty_str_dict)
RemoteKey: str = ""
UpstreamUser: str = "" UpstreamUser: str = ""
UpstreamHost: str = "" UpstreamHost: str = ""
UpstreamPort: str = "" UpstreamPort: str = ""
@@ -139,7 +144,11 @@ class GitEntry:
user, host, port, path = _parse_git_upstream( user, host, port, path = _parse_git_upstream(
upstream, f"bottle '{bottle_name}' {label} '{name}' Upstream" upstream, f"bottle '{bottle_name}' {label} '{name}' Upstream"
) )
if host_key is not None and host_key != host: if (
host_key is not None
and host_key != host
and not _is_ip_literal(host)
):
die( die(
f"bottle '{bottle_name}' git.remotes key {host_key!r} " f"bottle '{bottle_name}' git.remotes key {host_key!r} "
f"does not match Upstream host {host!r}" f"does not match Upstream host {host!r}"
@@ -150,6 +159,7 @@ class GitEntry:
IdentityFile=ident, IdentityFile=ident,
KnownHostKey=khk, KnownHostKey=khk,
ExtraHosts=extra_hosts, ExtraHosts=extra_hosts,
RemoteKey=host_key or host,
UpstreamUser=user, UpstreamUser=user,
UpstreamHost=host, UpstreamHost=host,
UpstreamPort=port, UpstreamPort=port,
@@ -180,6 +190,7 @@ EGRESS_AUTH_SCHEMES = ("Bearer", "token")
# special happens on the agent side. # special happens on the agent side.
EGRESS_ROLES = frozenset({ EGRESS_ROLES = frozenset({
"claude_code_oauth", "claude_code_oauth",
"codex_auth",
}) })
# Singleton roles may appear on at most one route per bottle. # Singleton roles may appear on at most one route per bottle.
@@ -188,8 +199,55 @@ EGRESS_ROLES = frozenset({
# ambiguous for any future role-aware logic. # ambiguous for any future role-aware logic.
EGRESS_SINGLETON_ROLES = frozenset({ EGRESS_SINGLETON_ROLES = frozenset({
"claude_code_oauth", "claude_code_oauth",
"codex_auth",
}) })
PROVIDER_EGRESS_ROLES = {
"claude": frozenset({"claude_code_oauth"}),
"codex": frozenset({"codex_auth"}),
}
@dataclass(frozen=True)
class AgentProvider:
"""Provider/template for the agent process inside a bottle.
`template` selects a built-in launch/runtime contract. `dockerfile`
optionally points at a custom agent-image Dockerfile while leaving
bot-bottle's sidecar infrastructure intact.
"""
template: str = "claude"
dockerfile: str = ""
@classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider":
d = _as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
for k in d:
if k not in {"template", "dockerfile"}:
die(
f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; "
f"allowed: template, dockerfile"
)
template = d.get("template", "claude")
if not isinstance(template, str) or not template:
die(
f"bottle '{bottle_name}' agent_provider.template must be a "
f"non-empty string"
)
if template not in PROVIDER_TEMPLATES:
die(
f"bottle '{bottle_name}' agent_provider.template {template!r} "
f"is not one of {', '.join(sorted(PROVIDER_TEMPLATES))}"
)
dockerfile = d.get("dockerfile", "")
if not isinstance(dockerfile, str):
die(
f"bottle '{bottle_name}' agent_provider.dockerfile must be a "
f"string (was {type(dockerfile).__name__})"
)
return cls(template=template, dockerfile=dockerfile)
@dataclass(frozen=True) @dataclass(frozen=True)
class GitUser: class GitUser:
@@ -270,6 +328,68 @@ def _parse_git_config(
return git, git_user return git, git_user
@dataclass(frozen=True)
class PipelockRoutePolicy:
"""Per-route pipelock policy overrides.
`TlsPassthrough` adds the route host to pipelock's
`tls_interception.passthrough_domains`, so pipelock still enforces
the hostname allowlist but does not MITM/decrypt request bodies or
headers for that host.
`SsrfIpAllowlist` adds explicit IPs/CIDRs to pipelock's SSRF
allowlist for private/internal destinations behind this route.
"""
TlsPassthrough: bool = False
SsrfIpAllowlist: tuple[str, ...] = ()
@classmethod
def from_dict(
cls, bottle_name: str, idx: int, raw: object,
) -> "PipelockRoutePolicy":
label = f"bottle '{bottle_name}' egress.routes[{idx}] pipelock"
d = _as_json_object(raw, label)
for k in d:
if k not in ("tls_passthrough", "ssrf_ip_allowlist"):
die(
f"{label} has unknown key {k!r}; "
f"only 'tls_passthrough' and 'ssrf_ip_allowlist' "
f"are accepted"
)
tls_passthrough_raw = d.get("tls_passthrough", False)
if not isinstance(tls_passthrough_raw, bool):
die(
f"{label}.tls_passthrough must be a boolean "
f"(was {type(tls_passthrough_raw).__name__})"
)
ssrf_raw = d.get("ssrf_ip_allowlist", [])
if not isinstance(ssrf_raw, list):
die(
f"{label}.ssrf_ip_allowlist must be an array "
f"(was {type(ssrf_raw).__name__})"
)
ssrf_ip_allowlist: list[str] = []
for j, item in enumerate(ssrf_raw):
if not isinstance(item, str) or not item:
die(
f"{label}.ssrf_ip_allowlist[{j}] must be a non-empty "
f"string (was {type(item).__name__})"
)
try:
ipaddress.ip_network(item, strict=False)
except ValueError as e:
die(
f"{label}.ssrf_ip_allowlist[{j}] must be an IP address "
f"or CIDR (was {item!r}): {e}"
)
ssrf_ip_allowlist.append(item)
return cls(
TlsPassthrough=tls_passthrough_raw,
SsrfIpAllowlist=tuple(ssrf_ip_allowlist),
)
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressRoute: class EgressRoute:
"""One route on the per-bottle egress sidecar (PRD 0017). """One route on the per-bottle egress sidecar (PRD 0017).
@@ -306,6 +426,7 @@ class EgressRoute:
AuthScheme: str = "" AuthScheme: str = ""
TokenRef: str = "" TokenRef: str = ""
Role: tuple[str, ...] = () Role: tuple[str, ...] = ()
Pipelock: PipelockRoutePolicy = field(default_factory=PipelockRoutePolicy)
@classmethod @classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute": def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute":
@@ -402,11 +523,17 @@ class EgressRoute:
f"{', '.join(sorted(EGRESS_ROLES))}" f"{', '.join(sorted(EGRESS_ROLES))}"
) )
pipelock = (
PipelockRoutePolicy.from_dict(bottle_name, idx, d["pipelock"])
if "pipelock" in d
else PipelockRoutePolicy()
)
for k in d: for k in d:
if k not in ("host", "path_allowlist", "auth", "role"): if k not in ("host", "path_allowlist", "auth", "role", "pipelock"):
die( die(
f"{label} has unknown key {k!r}; accepted keys are " f"{label} has unknown key {k!r}; accepted keys are "
f"'host', 'path_allowlist', 'auth', 'role'" f"'host', 'path_allowlist', 'auth', 'role', 'pipelock'"
) )
return cls( return cls(
@@ -415,6 +542,7 @@ class EgressRoute:
AuthScheme=auth_scheme, AuthScheme=auth_scheme,
TokenRef=token_ref, TokenRef=token_ref,
Role=roles, Role=roles,
Pipelock=pipelock,
) )
@@ -428,7 +556,9 @@ class EgressConfig:
routes: tuple[EgressRoute, ...] = () routes: tuple[EgressRoute, ...] = ()
@classmethod @classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig": def from_dict(
cls, bottle_name: str, raw: object, *, agent_provider_template: str = "claude",
) -> "EgressConfig":
d = _as_json_object(raw, f"bottle '{bottle_name}' egress") d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
routes_raw = d.get("routes") routes_raw = d.get("routes")
routes: tuple[EgressRoute, ...] = () routes: tuple[EgressRoute, ...] = ()
@@ -443,7 +573,9 @@ class EgressConfig:
EgressRoute.from_dict(bottle_name, i, entry) EgressRoute.from_dict(bottle_name, i, entry)
for i, entry in enumerate(routes_list) for i, entry in enumerate(routes_list)
) )
_validate_egress_routes(bottle_name, routes) _validate_egress_routes(
bottle_name, routes, agent_provider_template=agent_provider_template,
)
for k in d: for k in d:
if k != "routes": if k != "routes":
die( die(
@@ -456,6 +588,7 @@ class EgressConfig:
@dataclass(frozen=True) @dataclass(frozen=True)
class Bottle: class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict) env: Mapping[str, str] = field(default_factory=_empty_str_dict)
agent_provider: AgentProvider = field(default_factory=AgentProvider)
git: tuple[GitEntry, ...] = () git: tuple[GitEntry, ...] = ()
# Per-bottle git identity (issue #86). Empty default — bottles # Per-bottle git identity (issue #86). Empty default — bottles
# that don't set `git.user:` in the manifest skip the # that don't set `git.user:` in the manifest skip the
@@ -469,7 +602,7 @@ class Bottle:
# MCP tools to the agent (cred-proxy-block, pipelock-block, # MCP tools to the agent (cred-proxy-block, pipelock-block,
# capability-block; the cred-proxy-block tool is renamed and # capability-block; the cred-proxy-block tool is renamed and
# retargeted at egress in PRD 0017 chunk 3) plus mounts the # retargeted at egress in PRD 0017 chunk 3) plus mounts the
# current-config dir read-only into the agent at /etc/claude-bottle/ # current-config dir read-only into the agent at /etc/bot-bottle/
# current-config. False (the default) skips the sidecar and mount. # current-config. False (the default) skips the sidecar and mount.
supervise: bool = False supervise: bool = False
@@ -526,8 +659,17 @@ class Bottle:
if git_raw is not None: if git_raw is not None:
git, git_user = _parse_git_config(name, git_raw) git, git_user = _parse_git_config(name, git_raw)
agent_provider = (
AgentProvider.from_dict(name, d["agent_provider"])
if "agent_provider" in d
else AgentProvider()
)
egress = ( egress = (
EgressConfig.from_dict(name, d["egress"]) EgressConfig.from_dict(
name, d["egress"],
agent_provider_template=agent_provider.template,
)
if "egress" in d if "egress" in d
else EgressConfig() else EgressConfig()
) )
@@ -540,8 +682,8 @@ class Bottle:
) )
return cls( return cls(
env=env, git=git, git_user=git_user, egress=egress, env=env, agent_provider=agent_provider, git=git,
supervise=supervise_raw, git_user=git_user, egress=egress, supervise=supervise_raw,
) )
@@ -598,34 +740,41 @@ class Manifest:
agents: Mapping[str, Agent] agents: Mapping[str, Agent]
@classmethod @classmethod
def resolve(cls, cwd: str) -> "Manifest": def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
"""Walk the per-file manifest tree and build a Manifest. """Walk the per-file manifest tree and build a Manifest.
Layout (PRD 0011): Layout (PRD 0011):
$HOME/.claude-bottle/bottles/<name>.md bottles (home-only) $HOME/.bot-bottle/bottles/<name>.md bottles (home-only)
$HOME/.claude-bottle/agents/<name>.md home agents $HOME/.bot-bottle/agents/<name>.md home agents
$CWD/.claude-bottle/agents/<name>.md cwd agents $CWD/.bot-bottle/agents/<name>.md cwd agents
Cwd agents merge into the home agents on the same name Cwd agents merge into the home agents on the same name
(cwd wins). A bottles/ subdir under $CWD is logged as a (cwd wins). A bottles/ subdir under $CWD is logged as a
warning and ignored the filesystem layout IS the trust warning and ignored the filesystem layout IS the trust
boundary. boundary.
If `claude-bottle.json` exists alongside a missing If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
`.claude-bottle/` directory at either side, dies with a returns an empty manifest instead of dying. This is for
passive UI surfaces like the dashboard, which can still
monitor already-running agents without launch config.
If `bot-bottle.json` exists alongside a missing
`.bot-bottle/` directory at either side, dies with a
clear pointer at the README's manifest section — the clear pointer at the README's manifest section — the
manifest format changed in PRD 0011 and we don't silently manifest format changed in PRD 0011 and we don't silently
fall back.""" fall back."""
home_dir = Path(os.environ["HOME"]) home_dir = Path(os.environ["HOME"])
cwd_dir = Path(cwd) cwd_dir = Path(cwd)
home_md = home_dir / ".claude-bottle" home_md = home_dir / ".bot-bottle"
cwd_md = cwd_dir / ".claude-bottle" cwd_md = cwd_dir / ".bot-bottle"
_check_stale_json(home_dir, home_md, "$HOME") _check_stale_json(home_dir, home_md, "$HOME")
if cwd_dir.resolve() != home_dir.resolve(): if cwd_dir.resolve() != home_dir.resolve():
_check_stale_json(cwd_dir, cwd_md, "$CWD") _check_stale_json(cwd_dir, cwd_md, "$CWD")
if not home_md.is_dir(): if not home_md.is_dir():
if missing_ok:
return cls.from_json_obj({"bottles": {}, "agents": {}})
die( die(
f"no manifest found: {home_md} does not exist. " f"no manifest found: {home_md} does not exist. "
f"See README.md for the per-file Markdown layout " f"See README.md for the per-file Markdown layout "
@@ -668,7 +817,7 @@ class Manifest:
warn( warn(
f"ignoring bottle file(s) under " f"ignoring bottle file(s) under "
f"{stale_bottles}: {names}. Bottles can only " f"{stale_bottles}: {names}. Bottles can only "
f"live under $HOME/.claude-bottle/bottles/ " f"live under $HOME/.bot-bottle/bottles/ "
f"(PRD 0011). Move them or delete." f"(PRD 0011). Move them or delete."
) )
cwd_agents_dir = cwd_dir / "agents" cwd_agents_dir = cwd_dir / "agents"
@@ -708,8 +857,8 @@ class Manifest:
return return
available = ", ".join(self.agents.keys()) available = ", ".join(self.agents.keys())
if available: if available:
die(f"agent '{name}' not defined in claude-bottle.json. Available: {available}") die(f"agent '{name}' not defined in bot-bottle.json. Available: {available}")
die(f"agent '{name}' not defined in claude-bottle.json (manifest is empty).") die(f"agent '{name}' not defined in bot-bottle.json (manifest is empty).")
def has_bottle(self, name: str) -> bool: def has_bottle(self, name: str) -> bool:
return name in self.bottles return name in self.bottles
@@ -720,10 +869,10 @@ class Manifest:
available = ", ".join(self.bottles.keys()) available = ", ".join(self.bottles.keys())
if available: if available:
die( die(
f"bottle '{name}' not defined in claude-bottle.json. " f"bottle '{name}' not defined in bot-bottle.json. "
f"Available bottles: {available}" f"Available bottles: {available}"
) )
die(f"bottle '{name}' not defined in claude-bottle.json (no bottles defined).") die(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).")
def bottle_for(self, agent_name: str) -> Bottle: def bottle_for(self, agent_name: str) -> Bottle:
"""Resolve the Bottle the named agent references. The validator """Resolve the Bottle the named agent references. The validator
@@ -759,8 +908,8 @@ def _load_json_or_die(path: Path) -> dict[str, object]:
with path.open() as f: with path.open() as f:
doc: object = json.load(f) doc: object = json.load(f)
except json.JSONDecodeError: except json.JSONDecodeError:
die(f"claude-bottle.json at {path} is not valid JSON") die(f"bot-bottle.json at {path} is not valid JSON")
return _as_json_object(doc, f"claude-bottle.json at {path}") return _as_json_object(doc, f"bot-bottle.json at {path}")
def _opt_str(value: object, label: str) -> str: def _opt_str(value: object, label: str) -> str:
@@ -820,9 +969,19 @@ def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
return (user, host, port, path) return (user, host, port, path)
def _is_ip_literal(value: str) -> bool:
try:
ipaddress.ip_address(value)
except ValueError:
return False
return True
def _validate_egress_routes( def _validate_egress_routes(
bottle_name: str, bottle_name: str,
routes: tuple[EgressRoute, ...], routes: tuple[EgressRoute, ...],
*,
agent_provider_template: str = "claude",
) -> None: ) -> None:
"""Cross-validation for `bottle.egress.routes`: """Cross-validation for `bottle.egress.routes`:
@@ -854,6 +1013,16 @@ def _validate_egress_routes(
f"routes with role {role!r} (hosts: {hosts}); this role drives a " f"routes with role {role!r} (hosts: {hosts}); this role drives a "
f"single launch-step side effect — pick one." f"single launch-step side effect — pick one."
) )
allowed_roles = PROVIDER_EGRESS_ROLES[agent_provider_template]
for route in routes:
for role in route.Role:
if role not in allowed_roles:
die(
f"bottle '{bottle_name}' egress route for host "
f"{route.Host!r} has role {role!r}, but provider "
f"{agent_provider_template!r} only accepts roles "
f"{', '.join(sorted(allowed_roles)) or '(none)'}"
)
def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None: def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
@@ -881,11 +1050,11 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
# sets dies with a "did you mean" pointer — typos shouldn't silently # sets dies with a "did you mean" pointer — typos shouldn't silently
# ghost into an empty config. # ghost into an empty config.
_BOTTLE_KEYS = frozenset( _BOTTLE_KEYS = frozenset(
{"env", "extends", "git", "egress", "supervise"} {"env", "extends", "agent_provider", "git", "egress", "supervise"}
) )
_AGENT_KEYS_REQUIRED = frozenset({"bottle"}) _AGENT_KEYS_REQUIRED = frozenset({"bottle"})
_AGENT_KEYS_OPTIONAL = frozenset({"skills"}) _AGENT_KEYS_OPTIONAL = frozenset({"skills"})
# Claude Code subagent fields claude-bottle ignores at launch but # Claude Code subagent fields bot-bottle ignores at launch but
# doesn't reject — lets the same file double as `~/.claude/agents/*.md`. # doesn't reject — lets the same file double as `~/.claude/agents/*.md`.
_AGENT_KEYS_CC_PASSTHROUGH = frozenset({ _AGENT_KEYS_CC_PASSTHROUGH = frozenset({
"name", "description", "model", "color", "memory", "name", "description", "model", "color", "memory",
@@ -896,10 +1065,10 @@ _AGENT_KEYS = (
def _check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None: def _check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
"""Die if `<dir_path>/claude-bottle.json` exists but `md_dir` does """Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
not the manifest format changed in PRD 0011 and we don't want not the manifest format changed in PRD 0011 and we don't want
to silently leave the JSON content unused.""" to silently leave the JSON content unused."""
legacy = dir_path / "claude-bottle.json" legacy = dir_path / "bot-bottle.json"
if legacy.is_file() and not md_dir.exists(): if legacy.is_file() and not md_dir.exists():
die( die(
f"found {legacy} but {md_dir} does not exist. The manifest " f"found {legacy} but {md_dir} does not exist. The manifest "
@@ -1056,12 +1225,22 @@ def _merge_bottles(
# Presence-driven full-replace for the remaining list-valued + # Presence-driven full-replace for the remaining list-valued +
# scalar fields. # scalar fields.
merged_egress = child.egress if "egress" in child_raw else parent.egress merged_egress = child.egress if "egress" in child_raw else parent.egress
merged_agent_provider = (
child.agent_provider
if "agent_provider" in child_raw
else parent.agent_provider
)
merged_supervise = ( merged_supervise = (
child.supervise if "supervise" in child_raw else parent.supervise child.supervise if "supervise" in child_raw else parent.supervise
) )
_validate_egress_routes(
name, merged_egress.routes,
agent_provider_template=merged_agent_provider.template,
)
return Bottle( return Bottle(
env=merged_env, env=merged_env,
agent_provider=merged_agent_provider,
git=merged_git, git=merged_git,
git_user=merged_git_user, git_user=merged_git_user,
egress=merged_egress, egress=merged_egress,
@@ -21,29 +21,15 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
from .egress import ( from .egress import EGRESS_HOSTNAME, egress_routes_for_bottle
DEFAULT_ALLOWLIST,
EGRESS_HOSTNAME,
egress_routes_for_bottle,
)
from .supervise import SUPERVISE_HOSTNAME from .supervise import SUPERVISE_HOSTNAME
from .manifest import Bottle from .manifest import Bottle
# Hosts pipelock should NOT TLS-MITM, even when tls_interception is # Hosts pipelock should NOT TLS-MITM, even when tls_interception is
# enabled. The Claude API endpoint is an LLM provider — its request # enabled. This is now route-owned manifest policy via
# bodies are user-authored conversation text that legitimately can # `egress.routes[].pipelock.tls_passthrough`; no provider hosts are
# trigger DLP scanners (notably the BIP-39 seed-phrase detector, which # injected implicitly.
# fires on any 12+ consecutive English words that happen to be on the DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = ()
# BIP-39 wordlist and pass the checksum). Per pipelock's own
# configuration.md, the recommended treatment for LLM API endpoints is
# `passthrough_domains`: pipelock still proxies the CONNECT (so the
# api_allowlist gate applies), but it does not generate a leaf cert or
# decrypt the body. Body scanning happens on hosts that aren't
# passthrough'd, so DLP protection against agent exfil to other
# allowlisted hosts is unchanged.
DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = (
"api.anthropic.com",
)
# In-container paths the rendered pipelock YAML references under # In-container paths the rendered pipelock YAML references under
@@ -67,12 +53,11 @@ PIPELOCK_HOSTNAME = "pipelock"
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]: def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
"""Hostnames pipelock allows. Sorted for stability. """Hostnames pipelock allows. Sorted for stability.
Always mirrors `egress_routes_for_bottle(bottle)` the Always mirrors `egress_routes_for_bottle(bottle)` egress is the
egress is the single allowlist surface; pipelock's single allowlist surface, and pipelock's allowlist is the downstream
allowlist is the downstream copy for defense-in-depth + DLP copy for defense-in-depth + DLP body scanning. For bottles without
body scanning. For bottles without any `egress.routes[]` any `egress.routes[]` declared, this is empty except for supervise
declared, this is just the baked DEFAULT_ALLOWLIST that sidecar traffic when `supervise: true`.
egress_routes_for_bottle always folds in.
The supervise sidecar's hostname is auto-added when supervise The supervise sidecar's hostname is auto-added when supervise
is enabled (sibling-sidecar traffic that flows through pipelock is enabled (sibling-sidecar traffic that flows through pipelock
@@ -89,14 +74,13 @@ def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool: def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
"""Whether pipelock's BIP-39 seed-phrase detector stays on for """Whether pipelock's BIP-39 seed-phrase detector stays on.
this bottle.
LLM conversation bodies legitimately trip the detector any 12+ LLM conversation bodies legitimately trip the detector any 12+
English words that pass the BIP-39 checksum match so any English words that pass the BIP-39 checksum match so agents can
bottle that routes claude through pipelock's body scanner gets get blocked on ordinary prompts/responses regardless of provider
blocked on the first real chat. We tried two narrower knobs (Claude, Codex/OpenAI, or future harnesses). We tried two narrower
first: knobs first:
- `suppress: [{rule, path}]` pipelock accepts the schema - `suppress: [{rule, path}]` pipelock accepts the schema
but the entry only silences the alert; the body_dlp block but the entry only silences the alert; the body_dlp block
@@ -107,38 +91,43 @@ def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
Empirically only `seed_phrase_detection.enabled: false` Empirically only `seed_phrase_detection.enabled: false`
actually stops the block (verified by sending a 12-word BIP-39 actually stops the block (verified by sending a 12-word BIP-39
body through three pipelock instances). It is a global toggle body through three pipelock instances). It is a global toggle
no per-path / per-host knob in pipelock 2.3.0 so we turn the no per-path / per-host knob in pipelock 2.3.0 so we turn off
detector off for the entire bottle when the bottle declares an only this detector for every bottle. The rest of pipelock's DLP
egress route to `api.anthropic.com`. The trade-off is defaults and request-body/header scanning remain enabled."""
accepted: BIP-39 detection has little value in claude-bottle's del bottle # kept for call-site stability and future policy knobs.
threat model (the agent has no access to a user's crypto wallet return False
seeds; the patterns that matter gh*_, sk-ant-, AKIA, etc.
keep firing)."""
return not any(
r.Host == "api.anthropic.com" for r in bottle.egress.routes
)
def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]: def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]:
"""Hostnames pipelock should pass through (no TLS MITM, no body """Hostnames pipelock should pass through (no TLS MITM).
scan). Default carries the LLM API endpoint its request bodies
are user-authored conversation text that legitimately trips DLP
scanners (notably pipelock's BIP-39 seed-phrase detector). Every
other allowlisted host is MITM'd by pipelock's per-bottle CA so
its body scanner sees the cleartext.
egress route hosts (github, gitea, npm) are deliberately A route opts in with `pipelock.tls_passthrough: true`. This is
NOT auto-added here. egress's HTTPS client trusts pipelock's useful for provider API routes where egress injects the
CA at runtime (folded into its trust store via docker cp), so Authorization header after the agent boundary; pipelock still
pipelock MITMs and body-scans the egress upstream leg the enforces the host allowlist but does not decrypt and scan that
same way it body-scanned the agent's direct HTTPS traffic before provider request.
the PRD 0017 cutover. """
seen: dict[str, None] = {host: None for host in DEFAULT_TLS_PASSTHROUGH}
for route in bottle.egress.routes:
if route.Pipelock.TlsPassthrough:
seen.setdefault(route.Host, None)
return sorted(seen.keys())
`bottle` is kept on the signature for forward-compat (a future
knob might let a manifest opt a host into passthrough); today def pipelock_effective_ssrf_ip_allowlist(
the returned list is independent of the bottle.""" bottle: Bottle,
del bottle # not consulted; see docstring. extra: tuple[str, ...] = (),
return sorted(DEFAULT_TLS_PASSTHROUGH) ) -> list[str]:
"""IP/CIDR entries that bypass pipelock's SSRF destination guard.
Launch code can pass backend-owned entries through `extra`, while
route-owned entries come from `pipelock.ssrf_ip_allowlist`.
"""
seen: dict[str, None] = {ip: None for ip in extra}
for route in bottle.egress.routes:
for ip in route.Pipelock.SsrfIpAllowlist:
seen.setdefault(ip, None)
return sorted(seen.keys())
@@ -191,7 +180,7 @@ def pipelock_build_config(
# Body-scan enforcement is a separate pipelock section (each DLP # Body-scan enforcement is a separate pipelock section (each DLP
# "surface" — body, MCP, response — has its own action). Pipelock's # "surface" — body, MCP, response — has its own action). Pipelock's
# built-in default for request_body_scanning is "warn" (forward # built-in default for request_body_scanning is "warn" (forward
# with a log line); claude-bottle hard-codes "block" so a hit # with a log line); bot-bottle hard-codes "block" so a hit
# actually stops the request from leaving the egress network. # actually stops the request from leaving the egress network.
# #
# `scan_headers: true` + `header_mode: all` extends the scan to # `scan_headers: true` + `header_mode: all` extends the scan to
@@ -218,8 +207,11 @@ def pipelock_build_config(
"ca_key": ca_key_path, "ca_key": ca_key_path,
"passthrough_domains": pipelock_effective_tls_passthrough(bottle), "passthrough_domains": pipelock_effective_tls_passthrough(bottle),
} }
if ssrf_ip_allowlist: effective_ssrf_ip_allowlist = pipelock_effective_ssrf_ip_allowlist(
cfg["ssrf"] = {"ip_allowlist": list(ssrf_ip_allowlist)} bottle, ssrf_ip_allowlist,
)
if effective_ssrf_ip_allowlist:
cfg["ssrf"] = {"ip_allowlist": effective_ssrf_ip_allowlist}
return cfg return cfg
@@ -354,4 +346,3 @@ class PipelockProxy:
yaml_path.write_text(pipelock_render_yaml(cfg)) yaml_path.write_text(pipelock_render_yaml(cfg))
yaml_path.chmod(0o600) yaml_path.chmod(0o600)
return PipelockProxyPlan(yaml_path=yaml_path, slug=slug) return PipelockProxyPlan(yaml_path=yaml_path, slug=slug)
@@ -1,6 +1,6 @@
"""Per-bottle sidecar supervisor (PRD 0024 chunk 1). """Per-bottle sidecar supervisor (PRD 0024 chunk 1).
PID 1 inside the `claude-bottle-sidecars` bundle image. Spawns PID 1 inside the `bot-bottle-sidecars` bundle image. Spawns
the configured daemons (egress, pipelock, git-gate, supervise), the configured daemons (egress, pipelock, git-gate, supervise),
forwards SIGTERM/SIGINT to each child, and propagates per-daemon forwards SIGTERM/SIGINT to each child, and propagates per-daemon
stdout+stderr to the container log with a `[name] ` prefix. stdout+stderr to the container log with a `[name] ` prefix.
@@ -19,7 +19,7 @@ PR; the interim policy is "don't take the bundle down for one
sick daemon." sick daemon."
Daemon subset is env-driven. The compose renderer narrows it via Daemon subset is env-driven. The compose renderer narrows it via
`CLAUDE_BOTTLE_SIDECAR_DAEMONS=egress,pipelock` for bottles that `BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock` for bottles that
don't use git-gate or supervise. Default: all four. don't use git-gate or supervise. Default: all four.
Stdlib-only by design adding supervisord/s6/runit for four Stdlib-only by design adding supervisord/s6/runit for four
@@ -106,7 +106,7 @@ def _selected_daemons(
env: dict[str, str], env: dict[str, str],
all_daemons: Sequence[_DaemonSpec] | None = None, all_daemons: Sequence[_DaemonSpec] | None = None,
) -> tuple[_DaemonSpec, ...]: ) -> tuple[_DaemonSpec, ...]:
"""Filter the daemon set by the CLAUDE_BOTTLE_SIDECAR_DAEMONS env """Filter the daemon set by the BOT_BOTTLE_SIDECAR_DAEMONS env
var. Unknown names in the list are ignored the renderer is the var. Unknown names in the list are ignored the renderer is the
source of truth for which daemons are wired. source of truth for which daemons are wired.
@@ -115,7 +115,7 @@ def _selected_daemons(
`_DAEMONS` and have the new value take effect.""" `_DAEMONS` and have the new value take effect."""
if all_daemons is None: if all_daemons is None:
all_daemons = _DAEMONS all_daemons = _DAEMONS
raw = env.get("CLAUDE_BOTTLE_SIDECAR_DAEMONS", "").strip() raw = env.get("BOT_BOTTLE_SIDECAR_DAEMONS", "").strip()
if not raw: if not raw:
return tuple(all_daemons) return tuple(all_daemons)
wanted = {n.strip() for n in raw.split(",") if n.strip()} wanted = {n.strip() for n in raw.split(",") if n.strip()}
@@ -1,7 +1,7 @@
"""Per-bottle supervise plane (PRD 0013). """Per-bottle supervise plane (PRD 0013).
The supervise plane is the per-bottle MCP sidecar plus its host-side The supervise plane is the per-bottle MCP sidecar plus its host-side
queue/audit support. The sidecar (claude_bottle.supervise_server) queue/audit support. The sidecar (bot_bottle.supervise_server)
sits on the bottle's internal network and exposes three MCP tools the sits on the bottle's internal network and exposes three MCP tools the
agent calls when it hits a stuck-recovery category: agent calls when it hits a stuck-recovery category:
@@ -13,7 +13,7 @@ Each tool call: the agent passes the full proposed file plus a
justification text. The sidecar validates the proposal syntactically, justification text. The sidecar validates the proposal syntactically,
writes it to the host's per-bottle queue dir, and holds the tool-call writes it to the host's per-bottle queue dir, and holds the tool-call
connection open. The operator's TUI dashboard connection open. The operator's TUI dashboard
(claude_bottle.cli.dashboard) sees the proposal, accepts (bot_bottle.cli.dashboard) sees the proposal, accepts
approve / modify / reject, and writes a response file alongside the approve / modify / reject, and writes a response file alongside the
proposal. The sidecar sees the response and returns `{status, notes}` proposal. The sidecar sees the response and returns `{status, notes}`
to the agent. to the agent.
@@ -21,7 +21,7 @@ to the agent.
This module defines the host-side library: dataclasses for the queue This module defines the host-side library: dataclasses for the queue
file shapes, queue read/write helpers, the audit log writer, and the file shapes, queue read/write helpers, the audit log writer, and the
diff renderer. The in-container sidecar lives in diff renderer. The in-container sidecar lives in
claude_bottle/supervise_server.py; the supervise daemon's container bot_bottle/supervise_server.py; the supervise daemon's container
lifecycle is owned by the sidecar bundle (PRD 0024). lifecycle is owned by the sidecar bundle (PRD 0024).
For 0013 the supervisor's approval handlers are deliberately no-ops: For 0013 the supervisor's approval handlers are deliberately no-ops:
@@ -63,7 +63,7 @@ TOOLS: tuple[str, ...] = (
# The supervise sidecar uses these to query egress's # The supervise sidecar uses these to query egress's
# introspection endpoint for the `list-egress-routes` MCP # introspection endpoint for the `list-egress-routes` MCP
# tool. The hostname + port match egress's docker network # tool. The hostname + port match egress's docker network
# alias + listen port (see claude_bottle.egress.EGRESS_HOSTNAME # alias + listen port (see bot_bottle.egress.EGRESS_HOSTNAME
# and backend.docker.egress.EGRESS_PORT — the values # and backend.docker.egress.EGRESS_PORT — the values
# are inlined here so the in-container supervise_server doesn't # are inlined here so the in-container supervise_server doesn't
# need to import the egress package). # need to import the egress package).
@@ -90,7 +90,7 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
ACTION_OPERATOR_EDIT = "operator-edit" ACTION_OPERATOR_EDIT = "operator-edit"
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue" QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
CURRENT_CONFIG_DIR_IN_AGENT = "/etc/claude-bottle/current-config" CURRENT_CONFIG_DIR_IN_AGENT = "/etc/bot-bottle/current-config"
DEFAULT_POLL_INTERVAL_SEC = 0.5 DEFAULT_POLL_INTERVAL_SEC = 0.5
@@ -98,16 +98,16 @@ DEFAULT_POLL_INTERVAL_SEC = 0.5
# --- Paths ----------------------------------------------------------------- # --- Paths -----------------------------------------------------------------
def claude_bottle_root() -> Path: def bot_bottle_root() -> Path:
return Path.home() / ".claude-bottle" return Path.home() / ".bot-bottle"
def queue_dir_for_slug(slug: str) -> Path: def queue_dir_for_slug(slug: str) -> Path:
return claude_bottle_root() / "queue" / slug return bot_bottle_root() / "queue" / slug
def audit_dir() -> Path: def audit_dir() -> Path:
return claude_bottle_root() / "audit" return bot_bottle_root() / "audit"
def audit_log_path(component: str, slug: str) -> Path: def audit_log_path(component: str, slug: str) -> Path:
@@ -453,7 +453,7 @@ class SupervisePlan:
`queue_dir` is the host directory bind-mounted into the sidecar `queue_dir` is the host directory bind-mounted into the sidecar
at /run/supervise/queue. `current_config_dir` is the host at /run/supervise/queue. `current_config_dir` is the host
directory bind-mounted (read-only) into the *agent* container directory bind-mounted (read-only) into the *agent* container
at /etc/claude-bottle/current-config currently holds only the at /etc/bot-bottle/current-config currently holds only the
Dockerfile snapshot (routes.yaml + allowlist moved to the Dockerfile snapshot (routes.yaml + allowlist moved to the
`list-egress-routes` MCP tool). `internal_network` is `list-egress-routes` MCP tool). `internal_network` is
empty at prepare time; the backend's launch step fills it via empty at prepare time; the backend's launch step fills it via
@@ -566,7 +566,7 @@ __all__ = [
"archive_proposal", "archive_proposal",
"audit_dir", "audit_dir",
"audit_log_path", "audit_log_path",
"claude_bottle_root", "bot_bottle_root",
"list_pending_proposals", "list_pending_proposals",
"queue_dir_for_slug", "queue_dir_for_slug",
"read_audit_entries", "read_audit_entries",
@@ -6,7 +6,7 @@ propose config changes when stuck. Each tool call:
1. Validates the proposed file syntactically. 1. Validates the proposed file syntactically.
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from 2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from
the host's ~/.claude-bottle/queue/<slug>/). the host's ~/.bot-bottle/queue/<slug>/).
3. Blocks polling for a matching Response file. 3. Blocks polling for a matching Response file.
4. Returns the operator's `{status, notes}` to the agent. 4. Returns the operator's `{status, notes}` to the agent.
@@ -23,7 +23,7 @@ Speaks MCP over HTTP+JSON-RPC. Methods handled:
Everything else returns JSON-RPC error -32601 (method not found). Everything else returns JSON-RPC error -32601 (method not found).
Stdlib-only. The Dockerfile copies this file + claude_bottle/supervise.py Stdlib-only. The Dockerfile copies this file + bot_bottle/supervise.py
into the image; the server imports `supervise` for the queue / Proposal into the image; the server imports `supervise` for the queue / Proposal
plumbing. plumbing.
""" """
@@ -51,7 +51,7 @@ import supervise as _sv
MCP_PROTOCOL_VERSION = "2024-11-05" MCP_PROTOCOL_VERSION = "2024-11-05"
SERVER_NAME = "claude-bottle-supervise" SERVER_NAME = "bot-bottle-supervise"
SERVER_VERSION = "0.1.0" SERVER_VERSION = "0.1.0"
JSONRPC_VERSION = "2.0" JSONRPC_VERSION = "2.0"
@@ -254,7 +254,7 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"or env var you need — something that lives in the agent " "or env var you need — something that lives in the agent "
"Dockerfile rather than in routes or the pipelock allowlist. " "Dockerfile rather than in routes or the pipelock allowlist. "
"Read the current Dockerfile from " "Read the current Dockerfile from "
"/etc/claude-bottle/current-config/Dockerfile, compose a " "/etc/bot-bottle/current-config/Dockerfile, compose a "
"modified version, and pass the full new file plus a " "modified version, and pass the full new file plus a "
"justification. On approval the supervisor rebuilds the " "justification. On approval the supervisor rebuilds the "
"bottle from the new Dockerfile and starts a replacement on " "bottle from the new Dockerfile and starts a replacement on "
@@ -1,4 +1,4 @@
"""Hand-rolled YAML-subset parser for claude-bottle manifest files """Hand-rolled YAML-subset parser for bot-bottle manifest files
(PRD 0011). (PRD 0011).
Why hand-rolled: the configs we accept have a bounded shape (flat Why hand-rolled: the configs we accept have a bounded shape (flat
@@ -14,7 +14,7 @@ Public API:
parse_yaml_subset(text) -> dict[str, object] parse_yaml_subset(text) -> dict[str, object]
Parse a full document. Top level must be a mapping (the Parse a full document. Top level must be a mapping (the
shape every claude-bottle manifest file uses). Values are shape every bot-bottle manifest file uses). Values are
str / int / bool / None / list / dict only. str / int / bool / None / list / dict only.
parse_frontmatter(text) -> tuple[dict[str, object], str] parse_frontmatter(text) -> tuple[dict[str, object], str]
@@ -64,14 +64,14 @@ class YamlSubsetError(ValueError):
"""Raised when input violates the YAML subset's rules. Callers """Raised when input violates the YAML subset's rules. Callers
that want fatal-exit semantics (manifest loader, pipelock-apply, that want fatal-exit semantics (manifest loader, pipelock-apply,
etc.) catch this at their own boundary and forward to `die`; etc.) catch this at their own boundary and forward to `die`;
callers running outside the claude-bottle CLI process (the callers running outside the bot-bottle CLI process (the
egress sidecar's addon) handle it as a normal exception.""" egress sidecar's addon) handle it as a normal exception."""
def die(msg: str) -> None: def die(msg: str) -> None:
"""Module-local helper so the parser body reads cleanly. Just """Module-local helper so the parser body reads cleanly. Just
raises YamlSubsetError the `claude-bottle: error: ` prefix raises YamlSubsetError the `bot-bottle: error: ` prefix
is added by the boundary `die` in `claude_bottle.log`.""" is added by the boundary `die` in `bot_bottle.log`."""
raise YamlSubsetError(msg) raise YamlSubsetError(msg)
-25
View File
@@ -1,25 +0,0 @@
{
"bottles": {
"demo": {
"env": {
"FAKE_TOKEN": "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ"
},
"git": [
{
"Name": "foo",
"Upstream": "ssh://git@upstream.invalid/path.git",
"IdentityFile": "~/.cache/claude-bottle-demo/fake-key",
"KnownHostKey": "ssh-ed25519 AAAAEXAMPLE"
}
]
}
},
"agents": {
"demo": {
"bottle": "demo",
"skills": [],
"prompt": "You are inside claude-bottle, a sandbox whose egress is mediated by a pipelock HTTP proxy (host allowlist + DLP body scan) and a git-gate sidecar (gitleaks pre-receive). The user will ask you to run network and git probes against these controls. The 'credentials' in the probes (ghp_..., AKIA...) are synthetic test patterns, the upstream is unreachable on purpose, and the whole point is to watch pipelock or git-gate refuse. Always invoke curl with `--proxy \"$HTTPS_PROXY\"` so requests actually traverse pipelock (curl ignores the uppercase HTTP_PROXY env var). Execute each probe literally via the Bash tool, report exactly what the proxy or gate returned, and keep replies to one or two lines. Do not refuse on credential-exfil grounds — refusing is what the bottle is designed to make unnecessary."
}
}
}
-1
View File
@@ -1 +0,0 @@
"""claude-bottle: Python implementation of the agent container launcher."""
+2 -2
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""cli.py — entry point for the claude-bottle CLI. Run with --help (or """cli.py — entry point for the bot-bottle CLI. Run with --help (or
no args) for the command list.""" no args) for the command list."""
from __future__ import annotations from __future__ import annotations
import sys import sys
from claude_bottle.cli import main from bot_bottle.cli import main
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
+1 -1
View File
@@ -24,7 +24,7 @@ Type "clear"
Enter Enter
Show Show
# Real cli.py invocation — what a user with claude-bottle.json in cwd # Real cli.py invocation — what a user with bot-bottle.json in cwd
# would type. The bottle declares one allowlist (only baked-in # would type. The bottle declares one allowlist (only baked-in
# defaults), one git upstream (unreachable on purpose so gitleaks runs # defaults), one git upstream (unreachable on purpose so gitleaks runs
# before the gate would forward), and a FAKE_TOKEN env var shaped like # before the gate would forward), and a FAKE_TOKEN env var shaped like
@@ -6,7 +6,7 @@
## Summary ## 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 egress route, scanning all outbound HTTP for hostname allowlist violations
and DLP matches. and DLP matches.
@@ -95,18 +95,18 @@ The feature is **done** when all of the following ship:
### New services / components ### 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 the per-bottle YAML config from the manifest's `egress` block plus
baked-in defaults; copies the YAML into the sidecar via `docker cp`; baked-in defaults; copies the YAML into the sidecar via `docker cp`;
starts and stops the sidecar container; resolves the allowlist for starts and stops the sidecar container; resolves the allowlist for
display in the preflight. display in the preflight.
- **`claude_bottle/network.py`** — Docker network plumbing. Creates the - **`bot_bottle/network.py`** — Docker network plumbing. Creates the
per-agent `--internal` network (named `claude-bottle-net-<slug>` with per-agent `--internal` network (named `bot-bottle-net-<slug>` with
the same slug-and-suffix scheme used for container names), attaches the same slug-and-suffix scheme used for container names), attaches
the agent and sidecar to it, removes it on teardown. Kept separate 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. network controls without entangling them with pipelock specifics.
This split mirrors the existing per-concern module pattern 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 ### 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 `start` subcommand: create the internal network, launch the pipelock
sidecar, then launch the agent container with `HTTPS_PROXY` / sidecar, then launch the agent container with `HTTPS_PROXY` /
`HTTP_PROXY` set to the sidecar's service name. Add the resolved `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 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). (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 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. contract.
### Data model changes ### Data model changes
@@ -176,7 +176,7 @@ bottle share the same allowlist.
- **Pipelock binary** is pulled from - **Pipelock binary** is pulled from
`ghcr.io/luckypipewrench/pipelock@sha256:<digest>`. The digest is `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 and bumped deliberately, mirroring the claude-code version pinning
pattern in `Dockerfile`. pattern in `Dockerfile`.
- No new host-side runtimes. The pipelock image is the only new - 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 (proxy + 48 default DLP patterns + subdomain entropy + sidecar
topology) is expected to be core-only, but this should be confirmed. topology) is expected to be core-only, but this should be confirmed.
- **Where to put the digest pin.** A constant in - **Where to put the digest pin.** A constant in
`claude_bottle/pipelock.py` is the lowest-friction option; a separate `bot_bottle/pipelock.py` is the lowest-friction option; a separate
`claude_bottle/versions.py` (or similar) may be cleaner once there `bot_bottle/versions.py` (or similar) may be cleaner once there
are multiple pinned dependencies. Decide during implementation. are multiple pinned dependencies. Decide during implementation.
- **Per-agent overrides.** The PRD scopes egress to the bottle. If a - **Per-agent overrides.** The PRD scopes egress to the bottle. If a
later use case calls for tightening (not loosening) the allowlist for later use case calls for tightening (not loosening) the allowlist for
+26 -26
View File
@@ -14,7 +14,7 @@ second backend ships in this PRD.
## Problem ## Problem
Today, "how to launch a bottle" is spread across roughly six modules 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 `skills.py`, `docker.py`), each shelling out to `docker` directly via
`subprocess.run(["docker", ...])`. That coupling means: `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: 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`, classes (`BottleBackend`, `BottlePlan`, `BottleCleanupPlan`,
`Bottle`) plus a `claude_bottle/backend/docker/` subpackage `Bottle`) plus a `bot_bottle/backend/docker/` subpackage
containing the `DockerBottleBackend` implementation. containing the `DockerBottleBackend` implementation.
- `DockerBottleBackend.launch(plan)` returns a context manager - `DockerBottleBackend.launch(plan)` returns a context manager
yielding a `Bottle` handle exposing `exec_claude(argv, *, tty=True)`, yielding a `Bottle` handle exposing `exec_agent(argv, *, tty=True)`,
`cp_in(host, ctr)`, and teardown on context exit. `cp_in(host, ctr)`, and teardown on context exit.
- Every existing `subprocess.run(["docker", ...])` call in - Every existing `subprocess.run(["docker", ...])` call in
`cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and `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. called from it. No top-level CLI code references `docker` directly.
- `bottles[].runtime` is removed from the manifest schema, the - `bottles[].runtime` is removed from the manifest schema, the
dataclass in `manifest.py`, the example manifest, and any README / dataclass in `manifest.py`, the example manifest, and any README /
docs references. `require_runsc()` in the old top-level docs references. `require_runsc()` in the old top-level
`claude_bottle/docker.py` is deleted. `bot_bottle/docker.py` is deleted.
- A single env var, `CLAUDE_BOTTLE_BACKEND` (default `"docker"`), - A single env var, `BOT_BOTTLE_BACKEND` (default `"docker"`),
selects the backend. Unknown values die at startup with a list of selects the backend. Unknown values die at startup with a list of
known backends. known backends.
- The y/N preflight in `cli.py` includes the resolved Docker runtime - 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 ### In scope
- New `claude_bottle/backend/` package containing the abstract types - New `bot_bottle/backend/` package containing the abstract types
and the registry, plus a `claude_bottle/backend/docker/` subpackage and the registry, plus a `bot_bottle/backend/docker/` subpackage
containing the Docker implementation. containing the Docker implementation.
- The `Bottle`, `BottleBackend`, `BottlePlan`, and `BottleCleanupPlan` - The `Bottle`, `BottleBackend`, `BottlePlan`, and `BottleCleanupPlan`
abstract base classes; `BottleSpec` data carrier; and abstract base classes; `BottleSpec` data carrier; and
@@ -136,10 +136,10 @@ The feature is **done** when all of the following ship:
### New services / components ### 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: 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 classes and the backend registry. `BottleSpec` carries the
CLI-supplied intent; the abstract `BottlePlan` and CLI-supplied intent; the abstract `BottlePlan` and
`BottleCleanupPlan` are the prepared-but-not-launched outputs of `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 `provision_git`); subclasses implement those four rather than
overriding `provision` itself. 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: Unknown values call `die()` with the list of known backends:
```python ```python
def get_bottle_backend() -> BottleBackend: ... def get_bottle_backend() -> BottleBackend: ...
``` ```
- **`claude_bottle/backend/docker/`** — Subpackage with the Docker - **`bot_bottle/backend/docker/`** — Subpackage with the Docker
implementation, split into: implementation, split into:
- `backend.py``DockerBottleBackend`, owning all five abstract - `backend.py``DockerBottleBackend`, owning all five abstract
methods (`prepare`, `launch`, `prepare_cleanup`, `cleanup`, methods (`prepare`, `launch`, `prepare_cleanup`, `cleanup`,
@@ -196,49 +196,49 @@ and a Docker subpackage:
- `pipelock.py``DockerPipelockProxy` (the sidecar start/stop - `pipelock.py``DockerPipelockProxy` (the sidecar start/stop
lifecycle) and Docker-specific naming helpers. The backend-neutral lifecycle) and Docker-specific naming helpers. The backend-neutral
yaml + allowlist resolution stays in the top-level yaml + allowlist resolution stays in the top-level
`claude_bottle/pipelock.py`. `bot_bottle/pipelock.py`.
- `util.py` — Docker-specific helpers (slugify, image/container - `util.py` — Docker-specific helpers (slugify, image/container
existence checks, `runsc_available`). existence checks, `runsc_available`).
### Existing code touched ### 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 = orchestration with `backend = get_bottle_backend(); plan =
backend.prepare(spec, stage_dir=...); with backend.launch(plan) as backend.prepare(spec, stage_dir=...); with backend.launch(plan) as
bottle: bottle.exec_claude(...)`. The y/N preflight is rendered by bottle: bottle.exec_agent(...)`. The y/N preflight is rendered by
`plan.print(...)`. `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 Bottle dataclass and its validation. Existing manifests with
`runtime: "runsc"` produce a clear "no longer supported; gVisor is `runtime: "runsc"` produce a clear "no longer supported; gVisor is
now auto-detected by the backend; remove the 'runtime' field" error. 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 `slugify()`, `image_exists()`, `container_exists()`, the
`build_image` / `build_image_with_cwd` helpers, and `require_docker` `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`). `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`) YAML generation. Becomes a thin abstract class (`PipelockProxy`)
exposing `prepare` (writes the yaml) plus abstract `start` / `stop` exposing `prepare` (writes the yaml) plus abstract `start` / `stop`
methods. The Docker-specific subclass `DockerPipelockProxy` lives methods. The Docker-specific subclass `DockerPipelockProxy` lives
under `backend/docker/pipelock.py`. 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. `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 absorbed into `DockerBottleBackend` as `provision_ssh` and
`provision_skills`. The host-side file-tree generation stays as `provision_skills`. The host-side file-tree generation stays as
private helpers on the backend class. 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 `resolve_env(manifest, agent) -> ResolvedEnv` returns
`forwarded: list[str]` (names whose values were exported into `forwarded: list[str]` (names whose values were exported into
`os.environ` for inheritance) and `literals: dict[str, str]` (name `os.environ` for inheritance) and `literals: dict[str, str]` (name
→ verbatim value). The Docker backend translates the result into → verbatim value). The Docker backend translates the result into
`--env-file` content + `-e NAME` argv fragments. `--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 (`expand_tilde`, `is_ipv4_literal`). Backend-specific helpers live
in their backend's `util.py`. 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. 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 auto-detect; remove any mention of `runtime: "runsc"` as a manifest
field. field.
+10 -10
View File
@@ -6,12 +6,12 @@
## Summary ## 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`, moving the four provisioner methods — `provision_prompt`,
`provision_skills`, `provision_ssh`, `provision_git` — out of `provision_skills`, `provision_ssh`, `provision_git` — out of
`DockerBottleBackend` into their own modules under `DockerBottleBackend` into their own modules under
`claude_bottle/backend/docker/provision/`. The abstract base in `bot_bottle/backend/docker/provision/`. The abstract base in
`claude_bottle/backend/__init__.py` keeps the same four-method `bot_bottle/backend/__init__.py` keeps the same four-method
contract; only the Docker implementation changes shape. contract; only the Docker implementation changes shape.
## Problem ## 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: 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`, with one module per provisioner: `prompt.py`, `skills.py`, `ssh.py`,
`git.py`. Each exports a single top-level function taking `git.py`. Each exports a single top-level function taking
`(plan: DockerBottlePlan, target: str)` and returning the same type `(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 `provision_ssh` / `provision_git` each become one-line delegations
to the new module functions. to the new module functions.
- The abstract `BottleBackend.provision_*` signatures in - 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 `BottleBackend.provision` orchestration in the base class is
unchanged. unchanged.
- No top-level CLI code or other backend gains a direct import of the - 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 ### 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`. `__init__.py`, `prompt.py`, `skills.py`, `ssh.py`, `git.py`.
- Moving the four method bodies out of - Moving the four method bodies out of
`DockerBottleBackend` into the new modules verbatim, adjusting only `DockerBottleBackend` into the new modules verbatim, adjusting only
@@ -132,7 +132,7 @@ The feature is **done** when all of the following ship:
### New layout ### New layout
``` ```
claude_bottle/backend/docker/ bot_bottle/backend/docker/
backend.py # DockerBottleBackend (slimmer) backend.py # DockerBottleBackend (slimmer)
bottle.py bottle.py
bottle_plan.py bottle_plan.py
@@ -199,13 +199,13 @@ take the concrete type and skip re-checking.
### Existing code touched ### 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. bodies move out; method definitions stay as one-line delegations.
Imports for `pipelock_proxy_host_port`, `expand_tilde`, etc., that Imports for `pipelock_proxy_host_port`, `expand_tilde`, etc., that
are only used by the moved bodies migrate with them. 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. 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 - **`tests/`** — no expected change. Existing tests exercise the
backend via `DockerBottleBackend` or the CLI surface; they don't backend via `DockerBottleBackend` or the CLI surface; they don't
reach into provisioners directly. Verify after the move and only 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. sidecar (read-only) so the running pipelock can read its CA.
- `BottleBackend.provision_ca` (new) copies the CA public cert - `BottleBackend.provision_ca` (new) copies the CA public cert
into the agent at 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` / `update-ca-certificates`, and sets the `NODE_EXTRA_CA_CERTS` /
`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` env trio on the agent `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` env trio on the agent
container's runtime env. Default no-op on the abstract base so 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 ### In scope
- **`claude_bottle/pipelock.py`** changes: - **`bot_bottle/pipelock.py`** changes:
- Extend `pipelock_build_config` to include - Extend `pipelock_build_config` to include
`tls_interception: { enabled: true, ca_cert: <path>, ca_key: `tls_interception: { enabled: true, ca_cert: <path>, ca_key:
<path> }`. Paths are populated from the plan; the function's <path> }`. Paths are populated from the plan; the function's
signature grows a `cert_path` / `key_path` pair or reads them signature grows a `cert_path` / `key_path` pair or reads them
off `Bottle` once they're stored. off `Bottle` once they're stored.
- Extend `pipelock_render_yaml` to emit the new block. - 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 - New helper `pipelock_tls_init(stage_dir)` runs the upstream
image as a one-shot: image as a one-shot:
`docker run --rm -v <stage>:/h -e PIPELOCK_HOME=/h pipelock tls init`, `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 config. If pipelock's image runs as non-root, a `docker exec
-u 0 chown pipelock:pipelock /etc/pipelock/ca*.pem` lands -u 0 chown pipelock:pipelock /etc/pipelock/ca*.pem` lands
between the `cp` and the `start`. 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. `provision_ca(plan, target)` on `BottleBackend`, default no-op.
`BottleBackend.provision` orchestrates `ca → prompt → skills → `BottleBackend.provision` orchestrates `ca → prompt → skills →
ssh → git`. 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). - Reads the cert from `stage_dir` (already written by prepare).
- `docker cp` into the agent. - `docker cp` into the agent.
- `docker exec -u 0 ... chmod 644 ...` + `update-ca-certificates`. - `docker exec -u 0 ... chmod 644 ...` + `update-ca-certificates`.
- Computes the SHA-256 fingerprint with stdlib (`ssl` + - Computes the SHA-256 fingerprint with stdlib (`ssl` +
`hashlib`), emits one stderr log line. `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`: - 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`, `SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`,
`REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`. `REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`.
- `HTTPS_PROXY` / `HTTP_PROXY` continue to point at pipelock - `HTTPS_PROXY` / `HTTP_PROXY` continue to point at pipelock
(unchanged from PRD 0001 — the mitmproxy detour in PR #8 is (unchanged from PRD 0001 — the mitmproxy detour in PR #8 is
abandoned). abandoned).
- **`claude_bottle/backend/docker/bottle_plan.py`**: - **`bot_bottle/backend/docker/bottle_plan.py`**:
- One new `info(...)` line in `print()` noting TLS interception - One new `info(...)` line in `print()` noting TLS interception
is on. is on.
- `to_dict()` gains an `egress.tls_interception: { enabled: - `to_dict()` gains an `egress.tls_interception: { enabled:
true, ca_fingerprint: null }` block. Reserved for future true, ca_fingerprint: null }` block. Reserved for future
population. 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 `pipelock_tls_init(stage_dir)` and write the resolved cert/key
paths onto the plan (either on the existing `proxy_plan` field paths onto the plan (either on the existing `proxy_plan` field
or on the parent `DockerBottlePlan`). or on the parent `DockerBottlePlan`).
@@ -221,7 +221,7 @@ generated at prepare time.
the one-shot generation step. The rendered YAML references the one-shot generation step. The rendered YAML references
the in-container paths. the in-container paths.
- **Bottle install.** `provision_ca` (Docker impl) does - **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 then `update-ca-certificates`. The CA env trio is set at
`docker run -e` time (Docker propagates run-time env into `docker run -e` time (Docker propagates run-time env into
`docker exec`). `docker exec`).
@@ -235,7 +235,7 @@ generated at prepare time.
`stage_dir`. CA dies with both, in that order, so the sidecar `stage_dir`. CA dies with both, in that order, so the sidecar
is never reading a deleted mount on shutdown. is never reading a deleted mount on shutdown.
- **Fingerprint.** Computed via stdlib in `provision_ca` and - **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. sha256:<hex>…`). The private key never appears in any log.
### Data model changes ### 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: Surgical, all on the existing pipelock path:
- `claude_bottle/pipelock.py` — config builder + YAML renderer. - `bot_bottle/pipelock.py` — config builder + YAML renderer.
- `claude_bottle/backend/__init__.py` — abstract `provision_ca`. - `bot_bottle/backend/__init__.py` — abstract `provision_ca`.
- `claude_bottle/backend/docker/pipelock.py``tls init` helper, - `bot_bottle/backend/docker/pipelock.py``tls init` helper,
sidecar volume mount. sidecar volume mount.
- `claude_bottle/backend/docker/prepare.py` — CA paths on plan. - `bot_bottle/backend/docker/prepare.py` — CA paths on plan.
- `claude_bottle/backend/docker/launch.py` — CA env trio on agent. - `bot_bottle/backend/docker/launch.py` — CA env trio on agent.
- `claude_bottle/backend/docker/backend.py``provision_ca` - `bot_bottle/backend/docker/backend.py``provision_ca`
dispatch + thread `self._proxy` through prepare/launch unchanged dispatch + thread `self._proxy` through prepare/launch unchanged
shape. shape.
- `claude_bottle/backend/docker/bottle_plan.py` — preflight - `bot_bottle/backend/docker/bottle_plan.py` — preflight
rendering. 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 Net diff is meaningfully smaller than PR #8 because pipelock
already does the work — no addon, no second sidecar, no second 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: 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 `SSHGatePlan` dataclass. `prepare` is host-side / side-effect-free
on docker; renders the forwarder config under `stage_dir`. 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` `DockerSSHGate` concrete subclass — `start` does `docker create`
on the internal network, copies the config in, attaches the on the internal network, copies the config in, attaches the
egress network, `docker start`. `stop` is idempotent `docker rm 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 Forwarder image: `alpine/socat`, pinned by digest. Must be
self-sufficient at boot (no apk/apt pulls on first run) because 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 ### 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 `ProxyCommand socat - PROXY:...` plumbing and the
`pipelock_proxy_host_port` import. The rendered `~/.ssh/config` `pipelock_proxy_host_port` import. The rendered `~/.ssh/config`
block per entry becomes: 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 `known_hosts` entries are keyed off `<name>` and the new
`[<gate-container>]:<listen-port>` form so OpenSSH's strict `[<gate-container>]:<listen-port>` form so OpenSSH's strict
host-key checking still matches. 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_hostnames`, `pipelock_bottle_ssh_trusted_domains`,
`pipelock_bottle_ssh_ip_cidrs`, and the calls into them from `pipelock_bottle_ssh_ip_cidrs`, and the calls into them from
`pipelock_effective_allowlist` and `pipelock_build_config`. The `pipelock_effective_allowlist` and `pipelock_build_config`. The
effective allowlist becomes baked-defaults `bottle.egress.allowlist`. 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 `DockerSSHGate` alongside `DockerPipelockProxy`; thread its
`prepare` / `start` / `stop` through `resolve_plan` / `launch`. `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 stop to the `ExitStack` in the right order — gate must be up
before `provision_ssh` runs so the agent can dial it on first before `provision_ssh` runs so the agent can dial it on first
boot. boot.
- **`claude_bottle/backend/docker/bottle_plan.py`**: new - **`bot_bottle/backend/docker/bottle_plan.py`**: new
`SSHGatePlan` field on `DockerBottlePlan`; preflight rendering `SSHGatePlan` field on `DockerBottlePlan`; preflight rendering
surfaces the gate sidecar (name, per-entry listen ports, surfaces the gate sidecar (name, per-entry listen ports,
upstream `Hostname:Port` targets). upstream `Hostname:Port` targets).
@@ -165,7 +165,7 @@ rejected at prepare time. One container, N listeners, N upstreams.
### Data model changes ### Data model changes
None. `bottle.ssh` schema is unchanged; one new internal plan 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 ### External dependencies
@@ -202,7 +202,7 @@ dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
- PRD 0006: pipelock native TLS interception — the change that - PRD 0006: pipelock native TLS interception — the change that
surfaced this regression by making pipelock incompatible with surfaced this regression by making pipelock incompatible with
SSH-over-CONNECT. 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. 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. 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 L4 forwarding. There is no boundary between "the agent thinks this
commit is fine" and "the secret hits an external remote." If a commit is fine" and "the secret hits an external remote." If a
compromised or careless agent stages a `.env`, slips a token into 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_CLAUDE_OAUTH_TOKEN` itself, `git
push` ships it. push` ships it.
Host-side pre-commit / pre-push hooks are the usual defense, but 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: 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- `GitGatePlan` dataclass. `prepare` is host-side / side-effect-
free on docker; renders the per-upstream config and stages the free on docker; renders the per-upstream config and stages the
push credentials under `stage_dir`. 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` `DockerGitGate` concrete subclass. `start` does `docker create`
on the internal network, copies in the bare-repo skeleton, the on the internal network, copies in the bare-repo skeleton, the
hook script, and per-upstream credentials, then `docker start`. hook script, and per-upstream credentials, then `docker start`.
`stop` is idempotent `docker rm -f`. Container name: `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 Gate image: `git-daemon` + `openssh-client` over a
`zricethezav/gitleaks` base (alpine + gitleaks), pinned by digest. `zricethezav/gitleaks` base (alpine + gitleaks), pinned by digest.
@@ -173,21 +173,21 @@ operation.
### Existing code touched ### 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 `bottle.git` block; reject `bottle.ssh` entries whose upstream
is also claimed by a `bottle.git` upstream (one path per is also claimed by a `bottle.git` upstream (one path per
remote, no shadow route). 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 extension of the ssh provisioner: render the `insteadOf` config
and any extra `~/.gitconfig` plumbing. and any extra `~/.gitconfig` plumbing.
- **`claude_bottle/backend/docker/backend.py`**: instantiate - **`bot_bottle/backend/docker/backend.py`**: instantiate
`DockerGitGate` alongside `DockerPipelockProxy` and `DockerGitGate` alongside `DockerPipelockProxy` and
`DockerSSHGate`; thread its `prepare` / `start` / `stop` `DockerSSHGate`; thread its `prepare` / `start` / `stop`
through `resolve_plan` / `launch`. 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 stop to the `ExitStack` so the gate is up before any
provisioner that writes the agent's `~/.gitconfig`. 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 `GitGatePlan` field on `DockerBottlePlan`; preflight rendering
surfaces the gate sidecar (name, per-upstream local paths, surfaces the gate sidecar (name, per-upstream local paths,
upstream real URLs, which credential is in use). 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 - PRD 0007: SSH egress gate — the L4 SSH forwarder this PRD
sits alongside; explicitly *not* the place to add sits alongside; explicitly *not* the place to add
git-protocol awareness. 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. existing sidecar abstractions to mirror.
- gitleaks: <https://github.com/gitleaks/gitleaks> - 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. Delete the ssh-gate sidecar and the `bottle.ssh` manifest field.
Git-gate (PRD 0008) covers every current SSH use case in 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 with gitleaks scanning, an `insteadOf` rewrite that captures
push / fetch / clone / pull / ls-remote, and credential push / fetch / clone / pull / ls-remote, and credential
isolation from the agent. ssh-gate is now redundant L4 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 `_validate_no_shadow_route`. Add an explicit branch in
`Bottle.from_dict` that dies on a `ssh` key with a one-line `Bottle.from_dict` that dies on a `ssh` key with a one-line
"move this to `bottle.git` (see PRD 0008)" hint. "move this to `bottle.git` (see PRD 0008)" hint.
- **Sidecar.** Delete `claude_bottle/ssh_gate.py` and - **Sidecar.** Delete `bot_bottle/ssh_gate.py` and
`claude_bottle/backend/docker/ssh_gate.py`. Drop the socat `bot_bottle/backend/docker/ssh_gate.py`. Drop the socat
image build path. image build path.
- **Provisioner.** Delete - **Provisioner.** Delete
`claude_bottle/backend/docker/provision/ssh.py` and its `bot_bottle/backend/docker/provision/ssh.py` and its
`~/.ssh/config` render. `~/.ssh/config` render.
- **Docker backend wiring.** Drop `DockerSSHGate` from - **Docker backend wiring.** Drop `DockerSSHGate` from
`backend.py`; drop its start / stop from `launch.py`'s `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 - **README.** Drop the socat / ssh image box from the
architecture diagram and its bullet; drop `ssh:` from the architecture diagram and its bullet; drop `ssh:` from the
manifest example. 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 - **PRD 0007.** Add a `Status: Superseded by PRD 0009` header
at the top of the document. Do not delete the file; the at the top of the document. Do not delete the file; the
history of intent matters for the audit trail. 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 ### Existing code touched
- `claude_bottle/manifest.py` — delete `SshEntry`, - `bot_bottle/manifest.py` — delete `SshEntry`,
`Bottle.ssh`, `_validate_no_shadow_route`; add the `Bottle.ssh`, `_validate_no_shadow_route`; add the
parse-fail branch. parse-fail branch.
- `claude_bottle/ssh_gate.py` — delete. - `bot_bottle/ssh_gate.py` — delete.
- `claude_bottle/backend/docker/ssh_gate.py` — delete. - `bot_bottle/backend/docker/ssh_gate.py` — delete.
- `claude_bottle/backend/docker/provision/ssh.py` — delete. - `bot_bottle/backend/docker/provision/ssh.py` — delete.
- `claude_bottle/backend/docker/backend.py` — drop - `bot_bottle/backend/docker/backend.py` — drop
`DockerSSHGate` instantiation. `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`. 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. 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. branch in the allowlist render.
- `tests/unit/test_ssh_gate.py` — delete. - `tests/unit/test_ssh_gate.py` — delete.
- `tests/integration/` — delete any ssh-gate-specific tests. - `tests/integration/` — delete any ssh-gate-specific tests.
@@ -160,7 +160,7 @@ the seams between ssh-gate and the rest of the system:
helper. helper.
- `README.md` — drop the socat image box from the diagram and - `README.md` — drop the socat image box from the diagram and
the matching bullet; drop `ssh:` from the manifest example. 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 - `docs/prds/0007-ssh-egress-gate.md` — add a
`Status: Superseded by PRD 0009` header at the top. `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 ### External dependencies
Nothing added. The `alpine/socat` image is no longer pulled 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). the user's choice (a single `docker image rm` if they care).
## Future work ## Future work
+16 -16
View File
@@ -51,7 +51,7 @@ already rely on.
The research note The research note
[`agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md) [`agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md)
surveys the existing tools and concludes that a small 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 than either adopting nono (alpha, unaudited) or Infisical Agent
Vault (TLS-MITM topology that doubles up on pipelock's CA stack). Vault (TLS-MITM topology that doubles up on pipelock's CA stack).
This PRD is the build. 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 - **Cross-bottle credential sharing.** One proxy per bottle, same
one-sidecar-per-agent posture as pipelock and git-gate. one-sidecar-per-agent posture as pipelock and git-gate.
- **`claude --bare` mode.** Reads only `ANTHROPIC_API_KEY`, not - **`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 - **MCP-server tokens, package-installer tokens for languages
beyond npm.** PyPI / Bun / cargo can land in a follow-up if beyond npm.** PyPI / Bun / cargo can land in a follow-up if
needed; the routing pattern generalizes. 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` side-effect-free; `start` does `docker create` + `docker start`
on the bottle's internal network with hostname `cred-proxy`; on the bottle's internal network with hostname `cred-proxy`;
`stop` is idempotent `docker rm -f`. Container name: `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 after the sidecar is up so DNS resolution succeeds on the
agent's first call. agent's first call.
- **pipelock interop.** cred-proxy's outbound HTTPS traverses - **pipelock interop.** cred-proxy's outbound HTTPS traverses
@@ -230,7 +230,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
``` ```
┌── Host (macOS) ──────────────────────────────────────────────────┐ ┌── Host (macOS) ──────────────────────────────────────────────────┐
│ Secrets at rest (keychain / .env): │ │ Secrets at rest (keychain / .env): │
CLAUDE_BOTTLE_OAUTH_TOKEN, GITHUB_TOKEN, │ BOT_BOTTLE_CLAUDE_OAUTH_TOKEN, GITHUB_TOKEN, │
│ GITEA_SERVER_TOKEN, NPM_TOKEN │ │ GITEA_SERVER_TOKEN, NPM_TOKEN │
│ │ docker run -e KEY (no =VALUE on argv) │ │ │ docker run -e KEY (no =VALUE on argv) │
│ ▼ │ │ ▼ │
@@ -288,18 +288,18 @@ Why the agent can't reach the sidecar's environ:
### New components ### 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 + `CredProxyPlan` dataclass. `prepare` is host-side and
side-effect-free; renders the route table and resolves side-effect-free; renders the route table and resolves
`TokenRef`s against host env. Mirrors the existing `GitGate` / `TokenRef`s against host env. Mirrors the existing `GitGate` /
`Pipelock` shape. `Pipelock` shape.
- **`claude_bottle/backend/docker/cred_proxy.py`** (new): - **`bot_bottle/backend/docker/cred_proxy.py`** (new):
`DockerCredProxy` concrete subclass. `start` does `DockerCredProxy` concrete subclass. `start` does
`docker create` on the bottle's internal network with hostname `docker create` on the bottle's internal network with hostname
`cred-proxy`, copies the route-table file into the container, `cred-proxy`, copies the route-table file into the container,
then `docker start`. `stop` is idempotent `docker rm -f`. then `docker start`. `stop` is idempotent `docker rm -f`.
Container name: `claude-bottle-cred-proxy-<slug>`. Container name: `bot-bottle-cred-proxy-<slug>`.
- **`claude_bottle/backend/docker/provision/cred_proxy.py`** - **`bot_bottle/backend/docker/provision/cred_proxy.py`**
(new): renders `ANTHROPIC_BASE_URL`, `~/.npmrc`, (new): renders `ANTHROPIC_BASE_URL`, `~/.npmrc`,
`~/.gitconfig` `insteadOf` blocks, and `~/.config/tea/config.yml` `~/.gitconfig` `insteadOf` blocks, and `~/.config/tea/config.yml`
into the agent's home for each declared kind — all pointing at 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 ### Existing code touched
- **`claude_bottle/manifest.py`** — add `CredProxyRoute`, - **`bot_bottle/manifest.py`** — add `CredProxyRoute`,
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse `CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
+ validate route shape, role enum, path uniqueness, singleton- + validate route shape, role enum, path uniqueness, singleton-
role constraints. role constraints.
- **`claude_bottle/backend/docker/prepare.py`** — drop the - **`bot_bottle/backend/docker/prepare.py`** — drop the
legacy `CLAUDE_BOTTLE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN` legacy `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN`
forward entirely. cred-proxy is the only path the Anthropic forward entirely. cred-proxy is the only path the Anthropic
OAuth token reaches the bottle. When a route claims the OAuth token reaches the bottle. When a route claims the
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL` `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). otherwise; the proxy strips & replaces on every request).
Bottles that need claude-code to authenticate must declare Bottles that need claude-code to authenticate must declare
the route; there is no fallback. the route; there is no fallback.
- **`claude_bottle/backend/docker/backend.py`** — instantiate - **`bot_bottle/backend/docker/backend.py`** — instantiate
`DockerCredProxy` alongside `DockerPipelockProxy` and `DockerCredProxy` alongside `DockerPipelockProxy` and
`DockerGitGate`; thread its `prepare` / `start` / `stop` `DockerGitGate`; thread its `prepare` / `start` / `stop`
through `resolve_plan` / `launch`. 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 start/stop to the `ExitStack` after pipelock and before the
agent; populate `pipelock_proxy_url` + `pipelock_ca_host_path` agent; populate `pipelock_proxy_url` + `pipelock_ca_host_path`
on the cred-proxy plan so its outbound HTTPS routes through on the cred-proxy plan so its outbound HTTPS routes through
pipelock. 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 `cred_proxy_plan` field; preflight shows route count + token
refs + a path→upstream line per route; `to_dict` emits a refs + a path→upstream line per route; `to_dict` emits a
`cred_proxy` array of `{path, upstream, auth_scheme, token_ref, `cred_proxy` array of `{path, upstream, auth_scheme, token_ref,
roles}`. 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 from each route's `UpstreamHost` (not a hardcoded Kind→hosts
map). Allowlist auto-includes them; passthrough does not (the map). Allowlist auto-includes them; passthrough does not (the
proxy trusts pipelock's CA so MITM works). proxy trusts pipelock's CA so MITM works).
- **`README.md`** — architecture diagram includes the cred-proxy - **`README.md`** — architecture diagram includes the cred-proxy
lane; manifest section documents `bottle.cred_proxy.routes`. 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). four common routes (Anthropic, GitHub, Gitea, npm).
- **Tests** — manifest parsing/validation, route lift + token-env - **Tests** — manifest parsing/validation, route lift + token-env
slot assignment, role-based dispatch in the provisioner, slot assignment, role-based dispatch in the provisioner,
+42 -42
View File
@@ -6,11 +6,11 @@
## Summary ## 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 per-file Markdown-with-YAML-frontmatter layout. Bottles live as
`$HOME/.claude-bottle/bottles/<name>.md`; agents live as `$HOME/.bot-bottle/bottles/<name>.md`; agents live as
`$HOME/.claude-bottle/agents/<name>.md` (home-resident) and `$HOME/.bot-bottle/agents/<name>.md` (home-resident) and
`$CWD/.claude-bottle/agents/<name>.md` (repo-supplied). Each file `$CWD/.bot-bottle/agents/<name>.md` (repo-supplied). Each file
carries its structured config in YAML frontmatter and (for agents) carries its structured config in YAML frontmatter and (for agents)
its system prompt in the Markdown body. its system prompt in the Markdown body.
@@ -28,7 +28,7 @@ PyYAML dependency. The project's "low deps by default" stance
## Problem ## 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 project is heading for many of both, and the single-JSON shape
starts to fray: 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`: 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 parses.** A `dev.md` file with YAML frontmatter declaring
`cred_proxy.routes`, `git`, `env`, `egress` produces a Bottle `cred_proxy.routes`, `git`, `env`, `egress` produces a Bottle
dataclass equivalent to the current JSON shape. 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:`, `implementer.md` with frontmatter that names `bottle:`,
`skills:`, and other fields, with the body as the system `skills:`, and other fields, with the body as the system
prompt, produces an Agent dataclass. 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 and overrides home-resident agents of the same name.** The
cwd agent's frontmatter and body win; the home bottle it cwd agent's frontmatter and body win; the home bottle it
references stays intact. 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 ignored.** The directory does not contribute to the
manifest; if a user accidentally creates one, the launcher manifest; if a user accidentally creates one, the launcher
emits a `warn`-level log naming the offending files and 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. is a usability nicety, not a security gate.
5. **No third-party Python dependencies introduced.** A fresh 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 parser test. Frontmatter parsing is hand-rolled against the
declared YAML subset. 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 `name`, `description`, `model`, `color`, and `memory` fields
from Claude Code's existing subagent spec are accepted in from Claude Code's existing subagent spec are accepted in
our frontmatter alongside our own fields. Copying an agent 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 `~/.claude/agents/` produces a working Claude Code subagent
(subject to Claude Code's tolerance for the extra `bottle:` (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 ## Non-goals
- **A general YAML implementation.** The parser handles the - **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, that exceed the subset (anchors, multi-line block scalars,
tags, implicit type coercion, flow style, etc.) die with a tags, implicit type coercion, flow style, etc.) die with a
pointer at the spec. We are not building a YAML library. pointer at the spec. We are not building a YAML library.
- **Compatibility with the old JSON layout at runtime.** The - **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 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 primary user today, so the migration is one person rewriting
one file). Documented as part of the README rewrite. one file). Documented as part of the README rewrite.
- **`$HOME/.claude/agents/` integration on the input side.** We - **`$HOME/.claude/agents/` integration on the input side.** We
don't read agent files out of Claude Code's directory. Our 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 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. directory.
- **A signed-manifest scheme.** Out of scope per the - **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 ### In scope
- **Directory layout.** - **Directory layout.**
- `$HOME/.claude-bottle/bottles/<name>.md` — bottle - `$HOME/.bot-bottle/bottles/<name>.md` — bottle
definitions (full schema; one Bottle per file). definitions (full schema; one Bottle per file).
- `$HOME/.claude-bottle/agents/<name>.md` — home-resident - `$HOME/.bot-bottle/agents/<name>.md` — home-resident
agents. 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 agents; same schema as home agents, but bottle names must
resolve against the home set. 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. log (see SC #4). Does not contribute to the manifest.
- `<name>` is the file basename without `.md`. Filenames must - `<name>` is the file basename without `.md`. Filenames must
match `[a-z][a-z0-9-]*` (kebab-case, ASCII-only). 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 - `skills: [<name>, ...]` (optional) — host-side skills under
`~/.claude/skills/`. `~/.claude/skills/`.
- `name`, `description`, `model`, `color`, `memory` — accepted - `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 ignores them at launch but doesn't reject. Lets the same
file double as a Claude Code subagent. file double as a Claude Code subagent.
- Unknown top-level keys die with a hint listing accepted - 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). the required-keys check — same diagnostic as malformed).
- **Manifest assembly.** New resolver: - **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. keyed by filename.
2. Walk `$HOME/.claude-bottle/agents/*.md` → Agent dict. 2. Walk `$HOME/.bot-bottle/agents/*.md` → Agent dict.
3. Walk `$CWD/.claude-bottle/agents/*.md` → Agent dict; merge 3. Walk `$CWD/.bot-bottle/agents/*.md` → Agent dict; merge
into the home agent dict, cwd wins on name collision. into the home agent dict, cwd wins on name collision.
4. Validate every agent's `bottle:` against the bottle dict. 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. 6. Return Manifest dataclass — same shape as today.
- **Docs.** README's manifest section rewrites against the new - **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`. `examples/bottles/dev.md` + `examples/agents/implementer.md`.
The PRD 0010 example block in its own document gets a The PRD 0010 example block in its own document gets a
follow-up commit noting the new layout (out of scope for 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 ### File layout
``` ```
$HOME/.claude-bottle/ $HOME/.bot-bottle/
├── bottles/ ├── bottles/
│ ├── dev.md │ ├── dev.md
│ ├── gitea-dev.md │ ├── gitea-dev.md
@@ -243,7 +243,7 @@ $HOME/.claude-bottle/
├── researcher.md ├── researcher.md
└── ... └── ...
$CWD/.claude-bottle/ $CWD/.bot-bottle/
└── agents/ └── agents/
└── <repo-specific>.md └── <repo-specific>.md
``` ```
@@ -261,7 +261,7 @@ cred_proxy:
- path: /anthropic/ - path: /anthropic/
upstream: https://api.anthropic.com upstream: https://api.anthropic.com
auth_scheme: Bearer auth_scheme: Bearer
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
role: anthropic-base-url role: anthropic-base-url
- path: /gitea/dideric/ - path: /gitea/dideric/
upstream: https://gitea.dideric.is upstream: https://gitea.dideric.is
@@ -271,8 +271,8 @@ cred_proxy:
git: git:
remotes: remotes:
gitea.dideric.is: gitea.dideric.is:
Name: claude-bottle Name: bot-bottle
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
IdentityFile: ~/.ssh/gitea-delos-2.pem IdentityFile: ~/.ssh/gitea-delos-2.pem
ExtraHosts: ExtraHosts:
gitea.dideric.is: 100.78.141.42 gitea.dideric.is: 100.78.141.42
@@ -302,7 +302,7 @@ skills:
--- ---
You are a feature-implementation agent running inside an 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 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. be ambiguous, quote it.
- Flow style mappings nested more than one level deep. - 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: Public API:
```python ```python
@@ -348,14 +348,14 @@ def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
### Existing code touched ### 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 to walk the new directories. `Manifest.from_json_obj` kept as
a programmatic entry point (used by tests). New a programmatic entry point (used by tests). New
`Manifest.from_md_dirs(home_dir, cwd_dir)` for the loader. `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 - **`README.md`** — manifest section rewritten against the new
layout. 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. `examples/` directory with one bottle file + one agent file.
- **Tests** — new parser tests + new loader tests; existing - **Tests** — new parser tests + new loader tests; existing
manifest tests adapt to either build via `from_json_obj` 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 ### 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 single primary user today, so migration is one person rewriting
one file — no automated migration command is in scope. one file — no automated migration command is in scope.
If `claude-bottle.json` exists in `$HOME` or `$CWD` *and* the If `bot-bottle.json` exists in `$HOME` or `$CWD` *and* the
new `.claude-bottle/` directory does not exist, the resolver new `.bot-bottle/` directory does not exist, the resolver
dies with a clear pointer at the README's manifest section — dies with a clear pointer at the README's manifest section —
not silently merging formats, not silently dropping the JSON not silently merging formats, not silently dropping the JSON
content. content.
@@ -384,11 +384,11 @@ content.
empirically before settling: drop a file with `bottle: dev` empirically before settling: drop a file with `bottle: dev`
in `~/.claude/agents/` and see whether Claude Code warns, in `~/.claude/agents/` and see whether Claude Code warns,
ignores, or breaks. If it warns, namespace the field ignores, or breaks. If it warns, namespace the field
(`claude-bottle-bottle:` or a nested `claude_bottle:` block). (`bot-bottle-bottle:` or a nested `bot_bottle:` block).
- **Hidden directory vs visible.** Default `.claude-bottle/` - **Hidden directory vs visible.** Default `.bot-bottle/`
(hidden — matches `.config/`, `.ssh/`, `.docker/`). If users (hidden — matches `.config/`, `.ssh/`, `.docker/`). If users
routinely want to navigate to it from the file manager, 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 - **`description:` for bottles.** Should bottle frontmatter
carry a `description:` field for the y/N preflight? Default carry a `description:` field for the y/N preflight? Default
no — bottle names are kebab-case and self-describing, and no — bottle names are kebab-case and self-describing, and
+2 -2
View File
@@ -6,7 +6,7 @@
## Summary ## 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. 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: 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. - **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. - **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 ## 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 ## 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. - 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*). - 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: "..."}`. - 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 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 `~/.claude-bottle/queue/<slug>/` (file-per-proposal, with metadata and proposed file content). - A host-mounted per-bottle proposal queue at `~/.bot-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. - 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 `~/.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.) - 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. - Bottle lifecycle script changes to launch the MCP sidecar alongside the other sidecars and mount the read-only current-config directory.
### Out of scope ### Out of scope
@@ -49,15 +49,15 @@ See PRD 0012 for the broader stuck-agent problem. This PRD specifically addresse
### New services / components ### 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. - **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. - **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 `~/.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}`. - **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. - **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. - **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 ### 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. - **`cli.py`** — adds the dashboard subcommand.
### Data model changes ### Data model changes
+3 -3
View File
@@ -106,7 +106,7 @@ delivery.
apply path. SIGHUP reload semantics carry over to egress-proxy. apply path. SIGHUP reload semantics carry over to egress-proxy.
- PRD 0013 (supervise plane) `cred-proxy-block` MCP tool stays; - PRD 0013 (supervise plane) `cred-proxy-block` MCP tool stays;
its proposed file format updates per the new route shape. 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`, `cred_proxy_server.py`, `backend/docker/cred_proxy.py`,
`provision/cred_proxy.py`, the `Dockerfile.cred-proxy`. Tests `provision/cred_proxy.py`, the `Dockerfile.cred-proxy`. Tests
updated. updated.
@@ -254,8 +254,8 @@ manifest load:
`path``host`, drop the agent-side URL prefix). `path``host`, drop the agent-side URL prefix).
- `cred_proxy_routes` field on existing dataclasses removed. - `cred_proxy_routes` field on existing dataclasses removed.
- `Dockerfile.cred-proxy` deleted. - `Dockerfile.cred-proxy` deleted.
- `claude_bottle/cred_proxy*.py` deleted. - `bot_bottle/cred_proxy*.py` deleted.
- `claude_bottle/backend/docker/cred_proxy*.py` consolidated into - `bot_bottle/backend/docker/cred_proxy*.py` consolidated into
`egress_proxy*.py`. `egress_proxy*.py`.
- Provisioner files renamed. - Provisioner files renamed.
- PRDs 0010 (cred-proxy), 0014 (cred-proxy-block remediation) - PRDs 0010 (cred-proxy), 0014 (cred-proxy-block remediation)
+30 -30
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` per instance, so reading what happened in a session is one `less`
away. away.
State for each instance (`~/.claude-bottle/state/<slug>/`) becomes a State for each instance (`~/.bot-bottle/state/<slug>/`) becomes a
self-describing folder: self-describing folder:
``` ```
@@ -34,7 +34,7 @@ together fully describe the container topology.
Today `start` builds each sidecar (`pipelock`, `egress`, `git-gate`, Today `start` builds each sidecar (`pipelock`, `egress`, `git-gate`,
`supervise`) and the agent container with a chain of individual SDK `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 - A per-sidecar `Docker{Sidecar}.start()` method does
`docker create``docker cp` (stage files) → `docker network `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- 2. **Logs are scattered.** Each container's logs sit in Docker's per-
container journal. To debug a session post-mortem you have to 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. before the containers age out, and there's no merged view.
3. **Teardown is bespoke.** Each sidecar's `stop()` is its own 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 ## Goals / Success Criteria
1. `claude-bottle start <agent>` writes 1. `bot-bottle start <agent>` writes
`~/.claude-bottle/state/<slug>/docker-compose.yml` and brings the `~/.bot-bottle/state/<slug>/docker-compose.yml` and brings the
project up with `docker compose -p <project> up`. project up with `docker compose -p <project> up`.
2. The compose file is the source of truth for the container 2. The compose file is the source of truth for the container
topology — every sidecar that runs is declared as a `services:` topology — every sidecar that runs is declared as a `services:`
entry, every network is a `networks:` entry, every bind mount is entry, every network is a `networks:` entry, every bind mount is
a `volumes:` entry. 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 merged stdout/stderr of every service for the session, in
`docker compose logs --no-color` format. `docker compose logs --no-color` format.
4. `metadata.json` records the compose project name alongside the 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 5. Session teardown is `docker compose -p <project> down`. The
existing per-sidecar `stop()` lifecycle methods come out. existing per-sidecar `stop()` lifecycle methods come out.
6. The `cleanup` CLI uses `docker compose ls` (filtered to 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`. `docker ps -a` and `docker network ls`.
7. The existing remediation flows (`pipelock-block`, 7. The existing remediation flows (`pipelock-block`,
`egress-block`, `capability-block`) keep working without `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. implementation detail of the Docker backend.
- **Replacing the backend abstraction (PRD 0003).** `Backend` stays - **Replacing the backend abstraction (PRD 0003).** `Backend` stays
abstract; only the Docker implementation changes. 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 still owns a single compose project for the lifetime of the
session. No persistent service. session. No persistent service.
- **Image pre-building.** Compose's `build:` directive triggers - **Image pre-building.** Compose's `build:` directive triggers
@@ -109,7 +109,7 @@ project name per environment, merged logs, atomic up/down.
### In scope ### 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 compose dict from a `BottlePlan` and writes it to
`state/<slug>/docker-compose.yml`. `state/<slug>/docker-compose.yml`.
- `DockerBackend.start` rewritten to: - `DockerBackend.start` rewritten to:
@@ -118,7 +118,7 @@ project name per environment, merged logs, atomic up/down.
into host paths under `state/<slug>/`. into host paths under `state/<slug>/`.
3. Render + write the compose file. 3. Render + write the compose file.
4. Exec `docker compose -p <project> up -d`. 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` 6. On exit: `docker compose -p <project> logs --no-color`
`state/<slug>/compose.log`, then `docker compose -p `state/<slug>/compose.log`, then `docker compose -p
<project> down --volumes`. <project> down --volumes`.
@@ -134,12 +134,12 @@ project name per environment, merged logs, atomic up/down.
### Out of scope ### 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). `egress.py`'s plan dataclasses, `pipelock.py`'s plan dataclasses).
- Changing the agent's runtime contract (proxy env vars, CA bundle - Changing the agent's runtime contract (proxy env vars, CA bundle
paths, current-config mount path). paths, current-config mount path).
- Changing audit-log shape or location ( - 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. - Changing the MCP server's tool list or wire format.
- Dropping the `--rm` semantics for the agent: the agent container - Dropping the `--rm` semantics for the agent: the agent container
is still ephemeral; compose's `down --volumes` handles cleanup. is still ephemeral; compose's `down --volumes` handles cleanup.
@@ -148,7 +148,7 @@ project name per environment, merged logs, atomic up/down.
### Project name ### 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 existing `slugify(agent_name)-<5-char-random-base36>` from
`bottle_state.py`. Compose adds its own prefix to networks `bottle_state.py`. Compose adds its own prefix to networks
(`<project>_<network>`) and to default container names — which is (`<project>_<network>`) and to default container names — which is
@@ -163,29 +163,29 @@ an explicit `container_name:` matching today's pattern:
```yaml ```yaml
services: services:
pipelock: pipelock:
container_name: claude-bottle-pipelock-<slug> container_name: bot-bottle-pipelock-<slug>
egress: egress:
container_name: claude-bottle-egress-<slug> container_name: bot-bottle-egress-<slug>
# ... # ...
``` ```
This keeps the dashboard's container-discovery output stable for This keeps the dashboard's container-discovery output stable for
operators who've memorized the names. The compose project name 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 ### Networks
The two existing networks (`claude-bottle-net-<slug>` internal + The two existing networks (`bot-bottle-net-<slug>` internal +
`claude-bottle-egress-<slug>` upstream-bridge) become compose `bot-bottle-egress-<slug>` upstream-bridge) become compose
networks: networks:
```yaml ```yaml
networks: networks:
internal: internal:
name: claude-bottle-net-<slug> name: bot-bottle-net-<slug>
internal: true internal: true
egress: egress:
name: claude-bottle-egress-<slug> name: bot-bottle-egress-<slug>
``` ```
Each service's `networks:` list mirrors today's wiring. Each service's `networks:` list mirrors today's wiring.
@@ -238,7 +238,7 @@ sidecars that exist.
### Logging ### Logging
`docker compose up -d` starts everything detached. The agent is `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 <slug>`. Sidecars stream into Docker's per-container journals
during the session, exactly as today, and `docker compose logs -f` during the session, exactly as today, and `docker compose logs -f`
gives a merged tail if the user wants it (the dashboard can shell 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", "agent_name": "implementer",
"cwd": "/Users/.../some-project", "cwd": "/Users/.../some-project",
"started_at": "2026-05-25T20:13:04Z", "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 ### Cleanup CLI
`./cli.py cleanup` switches from "list every container with prefix `./cli.py cleanup` switches from "list every container with prefix
`claude-bottle-` and every network with prefix `claude-bottle-net-` `bot-bottle-` and every network with prefix `bot-bottle-net-`
or `claude-bottle-egress-`" to: or `bot-bottle-egress-`" to:
1. `docker compose ls --all --format json` → filter to projects 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`. 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`. `compose_project` no longer appears in `compose ls`.
Strays from pre-compose code-paths can be mopped up by keeping the Strays from pre-compose code-paths can be mopped up by keeping the
@@ -312,8 +312,8 @@ existing prefix scan as a fallback for one release.
2. **How does `claude` reach the agent's TTY?** Decided: keep 2. **How does `claude` reach the agent's TTY?** Decided: keep
today's `docker exec -it` model. Agent runs `sleep infinity` today's `docker exec -it` model. Agent runs `sleep infinity`
under compose; `DockerBottle.exec_claude` runs under compose; `DockerBottle.exec_agent` 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 today. Compose owns the lifecycle (so `compose logs` includes
the agent's stdout, `compose down` tears it down), but the the agent's stdout, `compose down` tears it down), but the
user-facing exec model is unchanged. Rejected `docker attach` 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 5. **Image build caching.** `build:` in compose rebuilds on first
`up` unless the image is already tagged. The per-sidecar images `up` unless the image is already tagged. The per-sidecar images
(`claude-bottle-pipelock`, `claude-bottle-egress`, (`bot-bottle-pipelock`, `bot-bottle-egress`,
`claude-bottle-git-gate`, `claude-bottle-supervise`) should `bot-bottle-git-gate`, `bot-bottle-supervise`) should
stay tagged on the daemon between runs so we don't rebuild on stay tagged on the daemon between runs so we don't rebuild on
every start. Verify compose's behavior matches. every start. Verify compose's behavior matches.

Some files were not shown because too many files have changed in this diff Show More