diff --git a/AGENTS.md b/AGENTS.md
index 41855c8..471315f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,7 +5,7 @@
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
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.
## Goals
diff --git a/CLAUDE.md b/CLAUDE.md
index ce798a3..b025145 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,11 +1,11 @@
-# claude-bottle
+# bot-bottle
## 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
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.
## Goals
@@ -25,7 +25,7 @@ the container lifecycle and the copying of skills and env vars into it.
- `README.md` — short public-facing description.
- `CLAUDE.md` — this file, orientation for future Claude sessions.
- `.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
"Intended design".
- `docs/INDEX.md` — pointer to the research notes.
diff --git a/Dockerfile b/Dockerfile.claude
similarity index 88%
rename from Dockerfile
rename to Dockerfile.claude
index 02cd4c5..0528a2b 100644
--- a/Dockerfile
+++ b/Dockerfile.claude
@@ -1,4 +1,4 @@
-# claude-bottle container image.
+# bot-bottle container image.
#
# Goal: a small, cache-friendly base that ships claude-code (the
# `@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
# real work. ca-certificates is already in the slim base; listed for
# 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
# node and the agent socket. curl is here so any HTTPS_PROXY-aware
# tool (curl itself, plus anything that shells out to it) works
@@ -40,7 +40,7 @@ USER node
WORKDIR /home/node
# 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
# baking it into the image avoids a permission-confusion footgun if a
# future change to the launcher copies in as a different user.
@@ -60,7 +60,7 @@ RUN cat > "$HOME/.claude.json" <
-
+
-# claude-bottle
+# bot-bottle
-[](https://gitea.dideric.is/didericis/claude-bottle/actions?workflow=test.yml)
+[](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.
@@ -21,7 +21,7 @@ asked to commit and push an AKIA-shaped key, git-gate's gitleaks
pre-receive hook rejects the ref.
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
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
- 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
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
minimization and egress allowlisting than on the container as a
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
manifest configuration required. The broader v2 discussion lives in
`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`
- base) on first run; runs `claude` with the manifest-granted skills,
- env vars, and `~/.gitconfig` (the latter for the git-gate's
- `insteadOf` rules when `bottle.git` is set).
+- **agent image** — built from the provider template Dockerfile
+ (`Dockerfile.claude` for Claude, `Dockerfile.codex` for Codex, or
+ `agent_provider.dockerfile`) on first run; runs the selected agent
+ 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
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
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 `.
A second backend runs the agent in a smolvm micro-VM (libkrun) with the
sidecar bundle still in Docker. Selected via
-`CLAUDE_BOTTLE_BACKEND=smolmachines ./cli.py start `. Requires
+`BOT_BOTTLE_BACKEND=smolmachines ./cli.py start `. Requires
`smolvm` on PATH (`curl -sSL https://smolmachines.com/install.sh | sh`).
The integration tests run against whichever backend the env var
@@ -223,11 +236,11 @@ docstring for the investigation trail.
## Manifest
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/`:
```
-~/.claude-bottle/
+~/.bot-bottle/
├── bottles/
│ ├── 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.
A repo can ship its own agent files alongside its code at
-`/.claude-bottle/agents/.md`. Those agents reference
-bottles defined in `~/.claude-bottle/bottles/` (the only place
+`/.bot-bottle/agents/.md`. Those agents reference
+bottles defined in `~/.bot-bottle/bottles/` (the only place
bottles can come from); a `bottles/` subdir in a repo is ignored
with a warning. **This is the trust boundary**: bottle infrastructure
— 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.
An explicit `git.remotes: {}` clears the parent's remotes; omitting
`git.remotes` inherits the parent's remotes.
-- `egress:`, `supervise:` — full replace when the child declares the
- field.
+- `agent_provider:`, `egress:`, `supervise:` — full replace when the
+ child declares the field.
```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
`$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
---
+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:
GIT_AUTHOR_NAME: didericis
@@ -293,62 +339,23 @@ git:
email: "eric+claude@dideric.is"
remotes:
gitea.dideric.is:
- Name: claude-bottle
- Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
+ Name: bot-bottle
+ Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
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
-OAuth via cred-proxy, gitea.dideric.is over SSH (with PAT for tea
-API), and npm for publishing scoped packages.
+The `gitea-dev` bottle. Backs my work on personal projects: provider
+auth through egress and gitea.dideric.is over SSH.
````
-### 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
---
@@ -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
skills to mount. You can also include Claude Code subagent fields
(`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
Claude Code subagent.
@@ -377,25 +384,26 @@ nested dicts). Anchors, multi-line block scalars, tags, and
ambiguous bare strings (`yes` / `NO` / `2026-05-24` /
`0x...`) all die with a clear pointer at the spec — quote your
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
`docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` and the
rationale in `docs/research/pipelock-assessment.md`. The trust
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
-Pro/Max subscription you already use on the host, via a long-lived OAuth
-token. No `ANTHROPIC_API_KEY` is needed.
+Bottles that use `agent_provider.template: claude` authenticate
+`claude` inside the container with the same Pro/Max subscription you
+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
Code stores OAuth credentials in the encrypted Keychain, not in
`~/.claude.json`. Mounting that file into a Linux container does not
carry the credentials with it. Linux hosts keep credentials in
`~/.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:**
@@ -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)
-as `CLAUDE_BOTTLE_OAUTH_TOKEN`:
+as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
```sh
-export CLAUDE_BOTTLE_OAUTH_TOKEN=""
+export BOT_BOTTLE_CLAUDE_OAUTH_TOKEN=""
```
-The bottle reaches the Anthropic API only through the cred-proxy
-sidecar. To let `claude` authenticate, declare a route in
-`bottle.cred_proxy.routes` with `role: "anthropic-base-url"` and
-`token_ref: "CLAUDE_BOTTLE_OAUTH_TOKEN"`:
+The Claude bottle reaches the Anthropic API only through the cred-proxy
+sidecar. To let `claude` authenticate, declare an egress route with
+`role: claude_code_oauth` and
+`token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
-```jsonc
-{
- "path": "/anthropic/",
- "upstream": "https://api.anthropic.com",
- "auth_scheme": "Bearer",
- "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
- "role": "anthropic-base-url"
-}
+```yaml
+egress:
+ routes:
+ - host: api.anthropic.com
+ role: claude_code_oauth
+ auth:
+ 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
into the agent's. The agent receives `ANTHROPIC_BASE_URL` pointing at
`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
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
to the agent. Caveats: the token is bound to your subscription tier
(Pro/Max/Team/Enterprise), it does not work with `claude --bare`
@@ -444,7 +469,7 @@ via `claude setup-token` again. Reference:
## 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
Code" are trademarks of Anthropic, PBC; the project name uses
"claude" descriptively to indicate that the tool runs Claude Code
diff --git a/bot-bottle.demo.json b/bot-bottle.demo.json
new file mode 100644
index 0000000..3aa9152
--- /dev/null
+++ b/bot-bottle.demo.json
@@ -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."
+ }
+ }
+}
diff --git a/bot_bottle/__init__.py b/bot_bottle/__init__.py
new file mode 100644
index 0000000..011ec29
--- /dev/null
+++ b/bot_bottle/__init__.py
@@ -0,0 +1 @@
+"""bot-bottle: Python implementation of the agent container launcher."""
diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py
new file mode 100644
index 0000000..b557c3e
--- /dev/null
+++ b/bot_bottle/agent_provider.py
@@ -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}")
diff --git a/claude_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py
similarity index 92%
rename from claude_bottle/backend/__init__.py
rename to bot_bottle/backend/__init__.py
index 01bf5f0..bd680de 100644
--- a/claude_bottle/backend/__init__.py
+++ b/bot_bottle/backend/__init__.py
@@ -25,7 +25,7 @@ backend exposes five methods:
agents pane) to render a row.
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.
"""
@@ -130,8 +130,8 @@ class ActiveAgent:
class Bottle(ABC):
"""Handle to a running bottle. Yielded by a backend's launch step.
- `exec_claude` runs `claude` inside the bottle and blocks until the
- session ends. `exec` runs a POSIX shell script inside the bottle
+ `exec_agent` runs the selected agent CLI inside the bottle and
+ 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
the bottle. `close` is an idempotent alias for context-manager
teardown.
@@ -140,11 +140,11 @@ class Bottle(ABC):
name: str
@abstractmethod
- def claude_argv(
+ def agent_argv(
self, argv: list[str], *, tty: bool = True,
) -> list[str]:
- """Return the host-side argv that runs `claude `
- inside the bottle. Used by `exec_claude` for foreground
+ """Return the host-side argv that runs the selected agent
+ inside the bottle. Used by `exec_agent` for foreground
handoffs and by the dashboard's tmux `respawn-pane` flow,
which needs the argv up front (it spawns claude in a tmux
pane rather than as a child of the current process).
@@ -155,7 +155,7 @@ class Bottle(ABC):
...
@abstractmethod
- def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ...
+ def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: ...
@abstractmethod
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)
self._validate_skills(agent.skills)
self._validate_git_entries(bottle.git)
+ self._validate_agent_provider_dockerfile(spec)
def _validate_skills(self, skills: Sequence[str]) -> None:
"""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):
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
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
"""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:
machine id). Returns the in-container prompt path if a prompt
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.
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
in-container path iff the agent has a non-empty prompt;
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
def provision_skills(self, plan: PlanT, target: str) -> None:
@@ -361,12 +376,12 @@ def get_bottle_backend(
`name` precedence:
1. explicit arg (CLI `--backend=` passes through here)
- 2. CLAUDE_BOTTLE_BACKEND env var
+ 2. BOT_BOTTLE_BACKEND env var
3. default `docker`
Dies with a pointer at the known backends if the chosen name
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:
known = ", ".join(sorted(_BACKENDS))
die(f"unknown backend {resolved!r}; known backends: {known}")
diff --git a/claude_bottle/backend/docker/__init__.py b/bot_bottle/backend/docker/__init__.py
similarity index 93%
rename from claude_bottle/backend/docker/__init__.py
rename to bot_bottle/backend/docker/__init__.py
index d6647dc..9ad729d 100644
--- a/claude_bottle/backend/docker/__init__.py
+++ b/bot_bottle/backend/docker/__init__.py
@@ -14,7 +14,7 @@ The bulk of the implementation lives in sibling modules:
- backend: DockerBottleBackend façade wiring the above
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.
"""
diff --git a/claude_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py
similarity index 97%
rename from claude_bottle/backend/docker/backend.py
rename to bot_bottle/backend/docker/backend.py
index 24f1944..195f924 100644
--- a/claude_bottle/backend/docker/backend.py
+++ b/bot_bottle/backend/docker/backend.py
@@ -34,7 +34,7 @@ from .provision import supervise as _supervise_prov
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
- """Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND
+ """Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
(default)."""
name = "docker"
diff --git a/claude_bottle/backend/docker/bottle.py b/bot_bottle/backend/docker/bottle.py
similarity index 71%
rename from claude_bottle/backend/docker/bottle.py
rename to bot_bottle/backend/docker/bottle.py
index 02cf4f3..7c94c40 100644
--- a/claude_bottle/backend/docker/bottle.py
+++ b/bot_bottle/backend/docker/bottle.py
@@ -1,16 +1,11 @@
-"""DockerBottle — concrete Bottle handle yielded by
-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.
-"""
+"""DockerBottle — concrete Bottle handle yielded by DockerBottleBackend."""
from __future__ import annotations
import subprocess
from typing import Callable
+from ...agent_provider import PromptMode, prompt_args
from .. import Bottle, ExecResult
@@ -22,27 +17,36 @@ class DockerBottle(Bottle):
container: str,
teardown: Callable[[], None],
prompt_path_in_container: str | None,
+ *,
+ agent_command: str = "claude",
+ agent_prompt_mode: PromptMode = "append_file",
):
self.name = container
self._teardown = teardown
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
- def claude_argv(
+ def agent_argv(
self, argv: list[str], *, tty: bool = True,
) -> list[str]:
full_argv = list(argv)
- if self._prompt_path:
- full_argv.extend(["--append-system-prompt-file", self._prompt_path])
+ full_argv.extend(
+ prompt_args(self._agent_prompt_mode, self._prompt_path, argv=full_argv)
+ )
cmd = ["docker", "exec"]
if tty:
cmd.append("-it")
- cmd.extend([self.name, "claude", *full_argv])
+ cmd.extend([self.name, self.agent_command, *full_argv])
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(
- self.claude_argv(argv, tty=tty), check=False,
+ self.agent_argv(argv, tty=tty), check=False,
).returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult:
diff --git a/claude_bottle/backend/docker/bottle_cleanup_plan.py b/bot_bottle/backend/docker/bottle_cleanup_plan.py
similarity index 93%
rename from claude_bottle/backend/docker/bottle_cleanup_plan.py
rename to bot_bottle/backend/docker/bottle_cleanup_plan.py
index fe5b605..13dea06 100644
--- a/claude_bottle/backend/docker/bottle_cleanup_plan.py
+++ b/bot_bottle/backend/docker/bottle_cleanup_plan.py
@@ -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
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`.
- stray_networks: same idea for networks. Cleared via
`docker network rm`.
- 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`.
Compose-managed networks are removed by `compose down --volumes`,
diff --git a/claude_bottle/backend/docker/bottle_plan.py b/bot_bottle/backend/docker/bottle_plan.py
similarity index 86%
rename from claude_bottle/backend/docker/bottle_plan.py
rename to bot_bottle/backend/docker/bottle_plan.py
index 1efb14e..2a65c74 100644
--- a/claude_bottle/backend/docker/bottle_plan.py
+++ b/bot_bottle/backend/docker/bottle_plan.py
@@ -11,13 +11,14 @@ import sys
from dataclasses import dataclass, field
from pathlib import Path
+from ...agent_provider import PromptMode
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...log import info
from ...pipelock import PipelockProxyPlan
from ...supervise import SupervisePlan
from .. import BottlePlan
-from ..print_util import print_multi
+from ..print_util import print_multi, visible_agent_env_names
@dataclass(frozen=True)
@@ -34,7 +35,7 @@ class DockerBottlePlan(BottlePlan):
runtime_image: str # image actually launched (derived or base)
# Absolute path to the Dockerfile that builds `image`. Empty means
# use the repo's default Dockerfile. Populated to a per-bottle
- # state file (~/.claude-bottle/state//Dockerfile) after a
+ # state file (~/.bot-bottle/state//Dockerfile) after a
# capability-block remediation (PRD 0016).
dockerfile_path: str
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.
supervise_plan: SupervisePlan | None
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:
"""Render the y/N preflight summary to stderr — compact form
@@ -69,10 +73,14 @@ class DockerBottlePlan(BottlePlan):
# interpolations from the manifest; egress holds
# upstream tokens in its own environ, so no token forwarding
# 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)
info(f"agent : {spec.agent_name}")
+ info(f"provider : {self.agent_provider_template}")
print_multi("env ", env_names)
print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}")
@@ -91,4 +99,3 @@ class DockerBottlePlan(BottlePlan):
egress_lines.append(f"{r.host}{auth}")
print_multi(" egress ", egress_lines)
print(file=sys.stderr)
-
diff --git a/claude_bottle/backend/docker/bottle_state.py b/bot_bottle/backend/docker/bottle_state.py
similarity index 94%
rename from claude_bottle/backend/docker/bottle_state.py
rename to bot_bottle/backend/docker/bottle_state.py
index 1ea2c6b..89e525f 100644
--- a/claude_bottle/backend/docker/bottle_state.py
+++ b/bot_bottle/backend/docker/bottle_state.py
@@ -6,15 +6,15 @@ helper saves before teardown, and the launch metadata that lets
`cli.py resume ` reconstruct a bottle's spec. State
lives at:
- ~/.claude-bottle/state//
+ ~/.bot-bottle/state//
metadata.json — agent_name + cwd + started_at (for resume)
Dockerfile — per-bottle override (absent → use repo's)
transcript/ — last snapshotted agent state (best-effort)
When the per-bottle Dockerfile is present, the launch step builds
-the agent image with a per-bottle tag (claude-bottle-rebuilt-)
+the agent image with a per-bottle tag (bot-bottle-rebuilt-)
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.
Identity model:
@@ -40,7 +40,7 @@ from ... import supervise as _supervise
from . import util as docker_mod
-# Directory layout: ~/.claude-bottle/state//...
+# Directory layout: ~/.bot-bottle/state//...
_STATE_SUBDIR = "state"
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
_TRANSCRIPT_SUBDIR = "transcript"
@@ -91,7 +91,7 @@ def bottle_identity(agent_name: str) -> str:
class BottleMetadata:
"""Persistent record of how a bottle was launched, written at
start time and read by `cli.py resume`. Lives at
- ~/.claude-bottle/state//metadata.json."""
+ ~/.bot-bottle/state//metadata.json."""
identity: str
agent_name: str
@@ -112,7 +112,7 @@ def metadata_path(identity: str) -> Path:
def write_metadata(metadata: BottleMetadata) -> Path:
- """Persist `metadata` to ~/.claude-bottle/state//metadata.json.
+ """Persist `metadata` to ~/.bot-bottle/state//metadata.json.
Mode 0o644 — no secrets, just (agent_name, cwd, timestamp)."""
path = metadata_path(metadata.identity)
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:
"""Per-bottle state directory on the host. Created lazily by the
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:
@@ -171,9 +171,9 @@ def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
def per_bottle_image_tag(identity: str) -> str:
"""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."""
- return f"claude-bottle-rebuilt-{identity}:latest"
+ return f"bot-bottle-rebuilt-{identity}:latest"
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:
"""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
- ~/.claude-bottle/queue// alongside the audit logs, so it
+ ~/.bot-bottle/queue// alongside the audit logs, so it
survives state-dir cleanup."""
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
diff --git a/claude_bottle/backend/docker/capability_apply.py b/bot_bottle/backend/docker/capability_apply.py
similarity index 94%
rename from claude_bottle/backend/docker/capability_apply.py
rename to bot_bottle/backend/docker/capability_apply.py
index 6b69dcd..1e4856d 100644
--- a/claude_bottle/backend/docker/capability_apply.py
+++ b/bot_bottle/backend/docker/capability_apply.py
@@ -5,11 +5,11 @@ On approval of a capability-block proposal, the dashboard calls
apply_capability_change(slug, new_dockerfile) which:
1. Snapshots the agent's transcript dir to
- ~/.claude-bottle/state//transcript/ (best-effort).
+ ~/.bot-bottle/state//transcript/ (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).
3. Writes the new Dockerfile to
- ~/.claude-bottle/state//Dockerfile (PRD 0016 Phase 1
+ ~/.bot-bottle/state//Dockerfile (PRD 0016 Phase 1
state). The next `cli.py start ` picks it up.
4. Force-removes the agent container + all sidecars + the
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).
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]:
@@ -70,8 +70,8 @@ def _per_bottle_container_names(slug: str) -> list[str]:
def _per_bottle_network_names(slug: str) -> list[str]:
return [
- f"claude-bottle-net-{slug}",
- f"claude-bottle-egress-{slug}",
+ f"bot-bottle-net-{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:
- """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
regardless of where this module is imported from."""
- # claude_bottle/backend/docker/capability_apply.py -> repo root
- return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
+ # bot_bottle/backend/docker/capability_apply.py -> repo root
+ return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
def snapshot_transcript(slug: str) -> None:
"""`docker cp` /home/node/.claude out of the agent container into
- ~/.claude-bottle/state//transcript/. Best-effort: missing
+ ~/.bot-bottle/state//transcript/. Best-effort: missing
container, missing dir, or cp error all log a warning and return.
The transcript is what `claude --resume` reads to pick up where
the agent left off.
diff --git a/claude_bottle/backend/docker/cleanup.py b/bot_bottle/backend/docker/cleanup.py
similarity index 93%
rename from claude_bottle/backend/docker/cleanup.py
rename to bot_bottle/backend/docker/cleanup.py
index eda7ac5..57f365d 100644
--- a/claude_bottle/backend/docker/cleanup.py
+++ b/bot_bottle/backend/docker/cleanup.py
@@ -7,13 +7,13 @@ scan, just as a fallback bucket alongside the project list.
`prepare_cleanup` enumerates:
- - Live compose projects whose name starts with `claude-bottle-`.
- - `claude-bottle-*` containers that aren't part of any compose
+ - Live compose projects whose name starts with `bot-bottle-`.
+ - `bot-bottle-*` containers that aren't part of any compose
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
with `compose down --volumes` and don't appear here).
- - State dirs under ~/.claude-bottle/state// with no
+ - State dirs under ~/.bot-bottle/state// with no
live compose project AND no `.preserve` marker.
`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]:
- """All claude-bottle-prefixed containers, running or stopped."""
+ """All bot-bottle-prefixed containers, running or stopped."""
result = subprocess.run(
["docker", "ps", "-a",
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}",
@@ -60,7 +60,7 @@ def _list_prefixed_containers() -> 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
`com.docker.compose.project` label; bare ones (from pre-compose
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
running smolmachines bottle's state dir (the layout is shared
across both backends)."""
- state_root = _supervise.claude_bottle_root() / "state"
+ state_root = _supervise.bot_bottle_root() / "state"
if not state_root.is_dir():
return []
orphans: list[str] = []
diff --git a/claude_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py
similarity index 92%
rename from claude_bottle/backend/docker/compose.py
rename to bot_bottle/backend/docker/compose.py
index e09614c..a7f850c 100644
--- a/claude_bottle/backend/docker/compose.py
+++ b/bot_bottle/backend/docker/compose.py
@@ -20,11 +20,11 @@ SDK-call branching in `launch.py` today):
Naming:
- - Compose project: `claude-bottle-`.
+ - Compose project: `bot-bottle-`.
- Service names (inside the file): `agent`, `pipelock`,
`egress`, `git-gate`, `supervise`.
- `container_name:` matches today's pattern
- (`claude-bottle--`) so dashboard/cleanup discovery
+ (`bot-bottle--`) so dashboard/cleanup discovery
via the prefix scan keeps working through the transition.
- Network aliases preserve the current dial-by-shortname pattern
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
spec back.
"""
- project = f"claude-bottle-{plan.slug}"
+ project = f"bot-bottle-{plan.slug}"
services: dict[str, Any] = {
"sidecars": _sidecar_bundle_service(plan),
"agent": _agent_service(plan),
@@ -146,7 +146,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
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 /
supervise are conditional on the plan.
- 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.
- Network aliases register every legacy short/long
hostname (pipelock, egress, git-gate, supervise plus
- their `claude-bottle--` long forms) so
+ their `bot-bottle--` long forms) so
the agent's HTTPS_PROXY URL and any other inter-service
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:
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]] = []
# --- pipelock ----------------------------------------------------
@@ -212,6 +212,11 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
keypath,
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_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_PROJECT_PREFIX = "claude-bottle-"
+COMPOSE_PROJECT_PREFIX = "bot-bottle-"
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):]
-def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
- """All compose project names starting with `claude-bottle-`.
+def list_compose_projects(
+ *, 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`
so exited projects appear too; pass False to get only projects
with at least one running container.
Returns [] on docker daemon errors or malformed output rather
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"]
if include_stopped:
argv.insert(3, "--all")
@@ -392,12 +402,14 @@ def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
# error from the caller's POV: no projects discoverable.
return []
if result.returncode != 0:
- warn(f"docker compose ls failed: {result.stderr.strip()}")
+ if warn_on_error:
+ warn(f"docker compose ls failed: {result.stderr.strip()}")
return []
try:
projects = json.loads(result.stdout or "[]")
except json.JSONDecodeError as e:
- warn(f"docker compose ls returned malformed JSON: {e}")
+ if warn_on_error:
+ warn(f"docker compose ls returned malformed JSON: {e}")
return []
names: list[str] = []
for p in projects:
@@ -409,14 +421,19 @@ def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
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
bottles. Used by the dashboard's operator-edit verbs to choose
a bottle to apply a config edit to."""
return sorted(
slug for slug in (
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
)
diff --git a/claude_bottle/backend/docker/egress.py b/bot_bottle/backend/docker/egress.py
similarity index 95%
rename from claude_bottle/backend/docker/egress.py
rename to bot_bottle/backend/docker/egress.py
index beeb47c..a025c15 100644
--- a/claude_bottle/backend/docker/egress.py
+++ b/bot_bottle/backend/docker/egress.py
@@ -19,7 +19,7 @@ from ...log import die
# Listening port the egress daemon binds inside the bundle. The
# agent's HTTP_PROXY env var resolves to `http://egress:`,
# 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
# 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"
"\n"
"[req_dn]\n"
- "O = claude-bottle\n"
- "CN = claude-bottle egress CA\n"
+ "O = bot-bottle\n"
+ "CN = bot-bottle egress CA\n"
"\n"
"[v3_ca]\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
# world-readable for the container's user to read it through the
# mount. Owner-only mode on the parent dir (state//, 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.
mitm = work / "mitmproxy-ca.pem"
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
diff --git a/claude_bottle/backend/docker/egress_apply.py b/bot_bottle/backend/docker/egress_apply.py
similarity index 100%
rename from claude_bottle/backend/docker/egress_apply.py
rename to bot_bottle/backend/docker/egress_apply.py
diff --git a/claude_bottle/backend/docker/enumerate.py b/bot_bottle/backend/docker/enumerate.py
similarity index 97%
rename from claude_bottle/backend/docker/enumerate.py
rename to bot_bottle/backend/docker/enumerate.py
index d0179a9..b57fc83 100644
--- a/claude_bottle/backend/docker/enumerate.py
+++ b/bot_bottle/backend/docker/enumerate.py
@@ -24,7 +24,7 @@ def enumerate_active() -> list[ActiveAgent]:
responsible for gating on `has_backend('docker')` if it
matters; if docker is missing the `docker ps` call below
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:
return []
services_by_project = _query_services_by_project()
diff --git a/claude_bottle/backend/docker/git_gate.py b/bot_bottle/backend/docker/git_gate.py
similarity index 100%
rename from claude_bottle/backend/docker/git_gate.py
rename to bot_bottle/backend/docker/git_gate.py
diff --git a/claude_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py
similarity index 95%
rename from claude_bottle/backend/docker/launch.py
rename to bot_bottle/backend/docker/launch.py
index 14b3442..37dfa65 100644
--- a/claude_bottle/backend/docker/launch.py
+++ b/bot_bottle/backend/docker/launch.py
@@ -23,7 +23,7 @@ The flow is:
entries inherit without rendering values into the file).
8. Provision (CA install, prompt copy, skills, git, supervise
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.
Teardown (ExitStack callbacks fire in reverse):
@@ -204,9 +204,15 @@ def launch(
# the agent container by its known 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
# 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:
teardown()
diff --git a/claude_bottle/backend/docker/network.py b/bot_bottle/backend/docker/network.py
similarity index 95%
rename from claude_bottle/backend/docker/network.py
rename to bot_bottle/backend/docker/network.py
index 408f75f..3247636 100644
--- a/claude_bottle/backend/docker/network.py
+++ b/bot_bottle/backend/docker/network.py
@@ -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
and similar upstream hostnames.
-Naming: claude-bottle-net- (internal),
-claude-bottle-egress- (egress). Numeric suffix on conflict
+Naming: bot-bottle-net- (internal),
+bot-bottle-egress- (egress). Numeric suffix on conflict
(-2, -3, ..., capped at 100).
"""
@@ -20,11 +20,11 @@ from ...log import die, info, warn
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:
- return f"claude-bottle-egress-{slug}"
+ return f"bot-bottle-egress-{slug}"
def network_exists(name: str) -> bool:
diff --git a/claude_bottle/backend/docker/pipelock.py b/bot_bottle/backend/docker/pipelock.py
similarity index 96%
rename from claude_bottle/backend/docker/pipelock.py
rename to bot_bottle/backend/docker/pipelock.py
index c0a9821..d0c9979 100644
--- a/claude_bottle/backend/docker/pipelock.py
+++ b/bot_bottle/backend/docker/pipelock.py
@@ -27,12 +27,12 @@ from ...pipelock import ( # noqa: F401
# Pipelock image, pinned by digest. The digest is the multi-arch image
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
PIPELOCK_IMAGE = os.environ.get(
- "CLAUDE_BOTTLE_PIPELOCK_IMAGE",
+ "BOT_BOTTLE_PIPELOCK_IMAGE",
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
)
# 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
diff --git a/claude_bottle/backend/docker/pipelock_apply.py b/bot_bottle/backend/docker/pipelock_apply.py
similarity index 100%
rename from claude_bottle/backend/docker/pipelock_apply.py
rename to bot_bottle/backend/docker/pipelock_apply.py
diff --git a/claude_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py
similarity index 82%
rename from claude_bottle/backend/docker/prepare.py
rename to bot_bottle/backend/docker/prepare.py
index 1738fd0..d885d9c 100644
--- a/claude_bottle/backend/docker/prepare.py
+++ b/bot_bottle/backend/docker/prepare.py
@@ -14,6 +14,7 @@ import os
from datetime import datetime, timezone
from pathlib import Path
+from ...agent_provider import runtime_for
from ...egress import Egress
from ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate
@@ -58,6 +59,8 @@ def resolve_plan(
manifest = spec.manifest
agent = manifest.agents[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`
# 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 "",
copy_cwd=spec.copy_cwd,
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
# 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:
image_default = per_bottle_image_tag(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:
- image_default = "claude-bottle:latest"
- image = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default)
+ image_default = provider_runtime.image
+ image = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
derived_image = ""
runtime_image = image
if spec.copy_cwd:
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
- default_container = f"claude-bottle-{slug}"
- pinned_container = os.environ.get("CLAUDE_BOTTLE_CONTAINER", "")
+ default_container = f"bot-bottle-{slug}"
+ pinned_container = os.environ.get("BOT_BOTTLE_CONTAINER", "")
container_name_pinned = bool(pinned_container)
if container_name_pinned:
container_name = pinned_container
if docker_mod.container_exists(container_name):
die(
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."
)
else:
@@ -138,7 +147,7 @@ def resolve_plan(
)
# PRD 0018 chunk 2: prepare-time scratch files live under
- # ~/.claude-bottle/state/// so chunk 3's compose
+ # ~/.bot-bottle/state/// so chunk 3's compose
# bind-mounts can point at stable paths. The state subdirs are
# cleaned up by start.py's session-end teardown unless something
# explicitly preserves the state dir (capability-block, crash).
@@ -171,8 +180,16 @@ def resolve_plan(
# PRD 0017 chunk 3 moved them behind the
# `list-egress-routes` MCP tool so the agent gets live
# state rather than a launch-time snapshot.)
- dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
- dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
+ supervise_dockerfile_path = (
+ 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.mkdir(parents=True, exist_ok=True)
supervise_plan = supervise.prepare(
@@ -192,12 +209,12 @@ def resolve_plan(
# placeholder. The placeholder isn't any real token value, so
# leaking it would tell an attacker only that egress is in
# front. Manifest validation enforces singleton on this role.
- has_anthropic_auth = any(
- "claude_code_oauth" in r.roles
- for r in egress_plan.routes
+ has_provider_auth = any(
+ provider_runtime.auth_role in r.roles for r in egress_plan.routes
)
- if has_anthropic_auth:
- forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
+ if has_provider_auth:
+ forwarded_env[provider_runtime.placeholder_env] = "egress-placeholder"
+ if provider.template == "claude" and has_provider_auth:
# Belt-and-braces: turn off telemetry endpoints (statsig,
# error reporting) that egress can't gate by auth.
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
@@ -225,6 +242,9 @@ def resolve_plan(
egress_plan=egress_plan,
supervise_plan=supervise_plan,
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_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
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)
diff --git a/claude_bottle/backend/docker/provision/__init__.py b/bot_bottle/backend/docker/provision/__init__.py
similarity index 100%
rename from claude_bottle/backend/docker/provision/__init__.py
rename to bot_bottle/backend/docker/provision/__init__.py
diff --git a/claude_bottle/backend/docker/provision/ca.py b/bot_bottle/backend/docker/provision/ca.py
similarity index 98%
rename from claude_bottle/backend/docker/provision/ca.py
rename to bot_bottle/backend/docker/provision/ca.py
index 453fc11..f3a4717 100644
--- a/claude_bottle/backend/docker/provision/ca.py
+++ b/bot_bottle/backend/docker/provision/ca.py
@@ -43,7 +43,7 @@ from ..bottle_plan import DockerBottlePlan
# Debian-family path for sources that `update-ca-certificates` reads.
# Bundle path is what the command rebuilds and what every standard
# 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"
diff --git a/claude_bottle/backend/docker/provision/git.py b/bot_bottle/backend/docker/provision/git.py
similarity index 98%
rename from claude_bottle/backend/docker/provision/git.py
rename to bot_bottle/backend/docker/provision/git.py
index 29c63a1..59e738b 100644
--- a/claude_bottle/backend/docker/provision/git.py
+++ b/bot_bottle/backend/docker/provision/git.py
@@ -66,7 +66,7 @@ def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
if not bottle.git:
return
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"
content = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME)
diff --git a/claude_bottle/backend/docker/provision/prompt.py b/bot_bottle/backend/docker/provision/prompt.py
similarity index 90%
rename from claude_bottle/backend/docker/provision/prompt.py
rename to bot_bottle/backend/docker/provision/prompt.py
index 29df62c..06b930c 100644
--- a/claude_bottle/backend/docker/provision/prompt.py
+++ b/bot_bottle/backend/docker/provision/prompt.py
@@ -18,8 +18,8 @@ def provision_prompt(plan: DockerBottlePlan, target: str) -> str | None:
prompt (drives --append-system-prompt-file), else None. The
file is copied either way so the path always exists."""
container = target
- container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
- in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt"
+ container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
+ in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
subprocess.run(
["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"],
diff --git a/claude_bottle/backend/docker/provision/skills.py b/bot_bottle/backend/docker/provision/skills.py
similarity index 92%
rename from claude_bottle/backend/docker/provision/skills.py
rename to bot_bottle/backend/docker/provision/skills.py
index 63c2a8d..22cd739 100644
--- a/claude_bottle/backend/docker/provision/skills.py
+++ b/bot_bottle/backend/docker/provision/skills.py
@@ -28,9 +28,9 @@ def provision_skills(plan: DockerBottlePlan, target: str) -> None:
return
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(
- "CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
+ "BOT_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
)
subprocess.run(
diff --git a/claude_bottle/backend/docker/provision/supervise.py b/bot_bottle/backend/docker/provision/supervise.py
similarity index 100%
rename from claude_bottle/backend/docker/provision/supervise.py
rename to bot_bottle/backend/docker/provision/supervise.py
diff --git a/claude_bottle/backend/docker/sidecar_bundle.py b/bot_bottle/backend/docker/sidecar_bundle.py
similarity index 73%
rename from claude_bottle/backend/docker/sidecar_bundle.py
rename to bot_bottle/backend/docker/sidecar_bundle.py
index a404489..85a2402 100644
--- a/claude_bottle/backend/docker/sidecar_bundle.py
+++ b/bot_bottle/backend/docker/sidecar_bundle.py
@@ -5,7 +5,7 @@ The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
runs pipelock + egress + git-gate + supervise as one container
per bottle under a small Python init supervisor. As of chunk 5
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
@@ -15,17 +15,17 @@ import os
# Bundle image. Defaults to a built-locally tag (built from the
# repo's Dockerfile.sidecars via compose `build:`). Operators
# pinning to a published digest can override via env, matching
-# the existing `CLAUDE_BOTTLE_PIPELOCK_IMAGE` shape.
+# the existing `BOT_BOTTLE_PIPELOCK_IMAGE` shape.
SIDECAR_BUNDLE_IMAGE = os.environ.get(
- "CLAUDE_BOTTLE_SIDECAR_IMAGE",
- "claude-bottle-sidecars:latest",
+ "BOT_BOTTLE_SIDECAR_IMAGE",
+ "bot-bottle-sidecars:latest",
)
SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars"
def sidecar_bundle_container_name(slug: str) -> str:
- """`claude-bottle-sidecars-`. Same prefix scheme as the
+ """`bot-bottle-sidecars-`. Same prefix scheme as the
per-sidecar containers it replaces, so the dashboard's
discovery-by-prefix logic keeps working."""
- return f"claude-bottle-sidecars-{slug}"
+ return f"bot-bottle-sidecars-{slug}"
diff --git a/claude_bottle/backend/docker/util.py b/bot_bottle/backend/docker/util.py
similarity index 100%
rename from claude_bottle/backend/docker/util.py
rename to bot_bottle/backend/docker/util.py
diff --git a/claude_bottle/backend/print_util.py b/bot_bottle/backend/print_util.py
similarity index 55%
rename from claude_bottle/backend/print_util.py
rename to bot_bottle/backend/print_util.py
index f37e22c..9615882 100644
--- a/claude_bottle/backend/print_util.py
+++ b/bot_bottle/backend/print_util.py
@@ -9,6 +9,7 @@ from __future__ import annotations
from typing import Sequence
+from ..agent_provider import runtime_for
from ..log import info
@@ -26,3 +27,19 @@ def print_multi(label: str, values: Sequence[str]) -> None:
indent = " " * (len(label) + 2)
for v in values[1:]:
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})
diff --git a/claude_bottle/backend/smolmachines/__init__.py b/bot_bottle/backend/smolmachines/__init__.py
similarity index 90%
rename from claude_bottle/backend/smolmachines/__init__.py
rename to bot_bottle/backend/smolmachines/__init__.py
index 39f8654..6b65870 100644
--- a/claude_bottle/backend/smolmachines/__init__.py
+++ b/bot_bottle/backend/smolmachines/__init__.py
@@ -1,6 +1,6 @@
"""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
on macOS) with a userspace gvproxy gateway as the egress
primitive. The sidecar bundle (PRD 0024) runs as a host-side
diff --git a/claude_bottle/backend/smolmachines/backend.py b/bot_bottle/backend/smolmachines/backend.py
similarity index 98%
rename from claude_bottle/backend/smolmachines/backend.py
rename to bot_bottle/backend/smolmachines/backend.py
index 9947ee5..b1d054a 100644
--- a/claude_bottle/backend/smolmachines/backend.py
+++ b/bot_bottle/backend/smolmachines/backend.py
@@ -27,7 +27,7 @@ class SmolmachinesBottleBackend(
BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"]
):
"""smolmachines backend. Selected by
- `CLAUDE_BOTTLE_BACKEND=smolmachines`."""
+ `BOT_BOTTLE_BACKEND=smolmachines`."""
name = "smolmachines"
diff --git a/claude_bottle/backend/smolmachines/bottle.py b/bot_bottle/backend/smolmachines/bottle.py
similarity index 79%
rename from claude_bottle/backend/smolmachines/bottle.py
rename to bot_bottle/backend/smolmachines/bottle.py
index 3ecc828..5cebc4c 100644
--- a/claude_bottle/backend/smolmachines/bottle.py
+++ b/bot_bottle/backend/smolmachines/bottle.py
@@ -1,15 +1,15 @@
"""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`
and torn down via the surrounding ExitStack on context exit;
`close` is a no-op idempotent alias so the BottleBackend ABC's
context-manager contract is satisfied.
User context: `smolvm machine exec` runs commands as root in the
-VM, but the agent image's USER is `node` and claude-code refuses
-to run as root with `--dangerously-skip-permissions`. Both
-`exec_claude` and `exec` switch to the requested user (default
+VM, but the agent image's USER is `node` and agent CLIs may refuse
+to run as root in bypass modes. Both
+`exec_agent` and `exec` switch to the requested user (default
`node`) via `runuser -u --` and set `HOME` / `USER`
through `smolvm -e` — avoiding `runuser -l`'s login-shell wiring
(PAM session setup, /etc/profile sourcing) which can hang on a
@@ -21,6 +21,7 @@ import subprocess
import sys
from typing import Mapping
+from ...agent_provider import PromptMode, prompt_args
from .. import Bottle, ExecResult
from . import pty_resize as _pty_resize
from . import smolvm as _smolvm
@@ -29,12 +30,12 @@ from . import smolvm as _smolvm
# Absolute path to the pty_resize wrapper. Invoke as
# `python ` rather than `python -m ` so the
# 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__
-# Per-user env the agent image's USER (node) expects. claude
-# reads ~/.claude.json + writes session state under ~/.claude/;
+# Per-user env the agent image's USER (node) expects. Some providers
+# write session state under the user's home directory;
# bare `runuser -u` inherits root's HOME=/root, which claude
# can't write to. Set HOME / USER explicitly through smolvm -e
# so the child process sees them.
@@ -72,6 +73,8 @@ class SmolmachinesBottle(Bottle):
*,
prompt_path: str | None = None,
guest_env: Mapping[str, str] | None = None,
+ agent_command: str = "claude",
+ agent_prompt_mode: PromptMode = "append_file",
) -> None:
self.name = machine_name
# 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`
# because exec doesn't inherit from machine_create's env.
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,
) -> list[str]:
flags = ["smolvm", "machine", "exec", "--name", self.name]
@@ -92,11 +100,17 @@ class SmolmachinesBottle(Bottle):
flags += ["-i", "-t"]
flags += _env_flags_for("node")
flags += _guest_env_flags(self._guest_env)
- claude_tail = ["claude"]
- if self._prompt_path:
- claude_tail += ["--append-system-prompt-file", self._prompt_path]
- claude_tail += argv
- flags += ["--", "runuser", "-u", "node", "--", *claude_tail]
+ agent_tail = [self.agent_command]
+ provider_prompt_args = prompt_args(
+ self._agent_prompt_mode, self._prompt_path, argv=argv,
+ )
+ 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:
# No PTY allocated — no SIGWINCH to forward, no resize
# bridge needed. Skip the wrapper so non-interactive
@@ -108,10 +122,10 @@ class SmolmachinesBottle(Bottle):
self.name, "--", *flags,
]
- def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
- """Run `claude` interactively inside the VM as the `node`
+ def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
+ """Run the selected agent interactively inside the VM as the `node`
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.
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
-e` instead, which sets them on the process env."""
return subprocess.run(
- self.claude_argv(argv, tty=tty), check=False,
+ self.agent_argv(argv, tty=tty), check=False,
).returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult:
diff --git a/claude_bottle/backend/smolmachines/bottle_cleanup_plan.py b/bot_bottle/backend/smolmachines/bottle_cleanup_plan.py
similarity index 86%
rename from claude_bottle/backend/smolmachines/bottle_cleanup_plan.py
rename to bot_bottle/backend/smolmachines/bottle_cleanup_plan.py
index 664fc0d..e78b97a 100644
--- a/claude_bottle/backend/smolmachines/bottle_cleanup_plan.py
+++ b/bot_bottle/backend/smolmachines/bottle_cleanup_plan.py
@@ -4,17 +4,17 @@ Tracks the resources `SmolmachinesBottleBackend.cleanup` will
remove:
- 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`.
- - bundles: docker containers `claude-bottle-sidecars-`
+ - bundles: docker containers `bot-bottle-sidecars-`
left over from a smolmachines bottle (the bundle's
port-forwards stay published on lo0 aliases until
the container is gone). Removed via `docker rm -f`.
- - networks: docker networks `claude-bottle-bundle-`
+ - networks: docker networks `bot-bottle-bundle-`
attached to the bundles. Removed via
`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
`prepare_cleanup` already enumerates orphan state dirs and is the
single source of truth for that bucket (consults
diff --git a/claude_bottle/backend/smolmachines/bottle_plan.py b/bot_bottle/backend/smolmachines/bottle_plan.py
similarity index 90%
rename from claude_bottle/backend/smolmachines/bottle_plan.py
rename to bot_bottle/backend/smolmachines/bottle_plan.py
index 76a1d6b..4af4214 100644
--- a/claude_bottle/backend/smolmachines/bottle_plan.py
+++ b/bot_bottle/backend/smolmachines/bottle_plan.py
@@ -12,13 +12,14 @@ import sys
from dataclasses import dataclass
from pathlib import Path
+from ...agent_provider import PromptMode
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...log import info
from ...pipelock import PipelockProxyPlan
from ...supervise import SupervisePlan
from .. import BottlePlan
-from ..print_util import print_multi
+from ..print_util import print_multi, visible_agent_env_names
@dataclass(frozen=True)
@@ -42,7 +43,7 @@ class SmolmachinesBottlePlan(BottlePlan):
# agent's network attempt got refused by macOS.
#
# 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
# there; chunk 4 resolves the agent-image-conversion gap
# (push to a registry first, or smolvm grows a docker-daemon
@@ -92,6 +93,10 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_proxy_url: str = ""
agent_git_gate_host: 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:
"""Compact y/N preflight. Same shape as the Docker
@@ -102,7 +107,10 @@ class SmolmachinesBottlePlan(BottlePlan):
agent = manifest.agents[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 = [
f"{g.Name} → {g.Upstream}" for g in bottle.git
]
@@ -113,6 +121,7 @@ class SmolmachinesBottlePlan(BottlePlan):
print(file=sys.stderr)
info(f"agent : {spec.agent_name}")
+ info(f"provider : {self.agent_provider_template}")
print_multi("env ", env_names)
print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}")
diff --git a/claude_bottle/backend/smolmachines/cleanup.py b/bot_bottle/backend/smolmachines/cleanup.py
similarity index 87%
rename from claude_bottle/backend/smolmachines/cleanup.py
rename to bot_bottle/backend/smolmachines/cleanup.py
index b5bd01a..dc2993f 100644
--- a/claude_bottle/backend/smolmachines/cleanup.py
+++ b/bot_bottle/backend/smolmachines/cleanup.py
@@ -3,11 +3,11 @@
`prepare_cleanup` enumerates leftover smolmachines resources:
- smolvm machines (`smolvm machine ls --json`) whose name starts
- with `claude-bottle-`.
- - bundle docker containers (`claude-bottle-sidecars-`).
- - bundle docker networks (`claude-bottle-bundle-`).
+ with `bot-bottle-`.
+ - bundle docker containers (`bot-bottle-sidecars-`).
+ - bundle docker networks (`bot-bottle-bundle-`).
-State dirs live under `~/.claude-bottle/state//` —
+State dirs live under `~/.bot-bottle/state//` —
shared layout with the docker backend, which has the single
orphan-state-dir enumerator (it already consults
`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.
-_VM_PREFIX = "claude-bottle-"
-_BUNDLE_PREFIX = _bundle.bundle_container_name("") # `claude-bottle-sidecars-`
-_NETWORK_PREFIX = _bundle.bundle_network_name("") # `claude-bottle-bundle-`
+_VM_PREFIX = "bot-bottle-"
+_BUNDLE_PREFIX = _bundle.bundle_container_name("") # `bot-bottle-sidecars-`
+_NETWORK_PREFIX = _bundle.bundle_network_name("") # `bot-bottle-bundle-`
def prepare_cleanup() -> SmolmachinesBottleCleanupPlan:
@@ -39,7 +39,7 @@ def prepare_cleanup() -> SmolmachinesBottleCleanupPlan:
No side effects. Returns an empty plan when smolvm isn't on
PATH (no machines to reap) — `cleanup` is a no-op in that
case too."""
- machines = _list_claude_bottle_machines()
+ machines = _list_bot_bottle_machines()
bundles = _list_bundle_containers()
networks = _list_bundle_networks()
return SmolmachinesBottleCleanupPlan(
@@ -94,8 +94,8 @@ def cleanup(plan: SmolmachinesBottleCleanupPlan) -> None:
)
-def _list_claude_bottle_machines() -> list[str]:
- """All smolvm machines named `claude-bottle-*`, regardless of
+def _list_bot_bottle_machines() -> list[str]:
+ """All smolvm machines named `bot-bottle-*`, regardless of
state (running / stopped / created). Empty when smolvm isn't
installed."""
if not _smolvm.is_available():
@@ -118,7 +118,7 @@ def _list_claude_bottle_machines() -> 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."""
# Late import: `backend/__init__` imports this module
# transitively via the smolmachines backend.
@@ -140,7 +140,7 @@ def _list_bundle_containers() -> 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."""
from .. import has_backend
if not has_backend("docker"):
diff --git a/claude_bottle/backend/smolmachines/enumerate.py b/bot_bottle/backend/smolmachines/enumerate.py
similarity index 95%
rename from claude_bottle/backend/smolmachines/enumerate.py
rename to bot_bottle/backend/smolmachines/enumerate.py
index 05f9217..668c6e9 100644
--- a/claude_bottle/backend/smolmachines/enumerate.py
+++ b/bot_bottle/backend/smolmachines/enumerate.py
@@ -27,10 +27,10 @@ from ..docker.bottle_state import read_metadata
from . import sidecar_bundle as _bundle
-# Smolvm VM names produced by prepare are `claude-bottle-`,
+# Smolvm VM names produced by prepare are `bot-bottle-`,
# matching the bundle container name pattern. We use the prefix
# 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]:
@@ -70,7 +70,7 @@ def enumerate_active() -> list[ActiveAgent]:
def _query_bundle_services() -> dict[str, tuple[str, ...]]:
"""`{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
same daemon set declared via env, so one inspect per bundle
gets us the picture without exec'ing into the container.
@@ -113,7 +113,7 @@ def _query_bundle_services() -> dict[str, tuple[str, ...]]:
continue
for entry in env_list:
key, _, value = entry.partition("=")
- if key == "CLAUDE_BOTTLE_SIDECAR_DAEMONS":
+ if key == "BOT_BOTTLE_SIDECAR_DAEMONS":
out[slug] = tuple(sorted(
d for d in value.split(",") if d
))
diff --git a/claude_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py
similarity index 95%
rename from claude_bottle/backend/smolmachines/launch.py
rename to bot_bottle/backend/smolmachines/launch.py
index db8c1da..d133e7c 100644
--- a/claude_bottle/backend/smolmachines/launch.py
+++ b/bot_bottle/backend/smolmachines/launch.py
@@ -68,7 +68,7 @@ _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
# docker image ID so a Dockerfile change automatically invalidates
# the cache. `pack create` is idempotent on the smolvm side but
# 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
@@ -142,6 +142,7 @@ def launch(
# daemons the agent needs to reach from the smolvm guest.
bundle_spec = _bundle_launch_spec(plan, network, loopback_ip)
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})
stack.callback(_bundle.stop_bundle, plan.slug)
@@ -183,7 +184,7 @@ def launch(
# Stamp the URLs onto the plan + guest_env. provision_git
# 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 —
# otherwise claude's HTTPS_PROXY catches direct calls to
@@ -219,7 +220,10 @@ def launch(
# output doesn't garble the dashboard's preflight modal:
# both the curses-endwin path and the tmux pane-routing
# 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
# artifact; --allow-cidr + -e carry the per-bottle TSI
@@ -286,6 +290,8 @@ def launch(
plan.machine_name,
prompt_path=prompt_path,
guest_env=plan.guest_env,
+ agent_command=plan.agent_command,
+ agent_prompt_mode=plan.agent_prompt_mode,
)
finally:
stack.close()
@@ -360,6 +366,12 @@ def _bundle_launch_spec(
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
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 --------------------------------------------
sp = plan.supervise_plan
@@ -413,10 +425,10 @@ def _resolve_token_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
`.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).
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
already on disk for this image ID."""
_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
# keep filenames manageable, long enough to make collisions
# astronomically unlikely.
@@ -451,8 +463,8 @@ def _ensure_smolmachine(image_ref: str) -> Path:
docker_mod.save(image_ref, str(tarball))
try:
with ephemeral_registry() as handle:
- push_ref = f"{handle.push_endpoint}/claude-bottle:{digest}"
- pack_ref = f"{handle.pull_endpoint}/claude-bottle:{digest}"
+ push_ref = f"{handle.push_endpoint}/bot-bottle:{digest}"
+ pack_ref = f"{handle.pull_endpoint}/bot-bottle:{digest}"
crane_push_tarball(handle, str(tarball), push_ref)
_smolvm.pack_create(pack_ref, binary)
finally:
diff --git a/claude_bottle/backend/smolmachines/local_registry.py b/bot_bottle/backend/smolmachines/local_registry.py
similarity index 97%
rename from claude_bottle/backend/smolmachines/local_registry.py
rename to bot_bottle/backend/smolmachines/local_registry.py
index 8977f37..5ca3f04 100644
--- a/claude_bottle/backend/smolmachines/local_registry.py
+++ b/bot_bottle/backend/smolmachines/local_registry.py
@@ -48,9 +48,9 @@ from ...log import die
# 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(
- "CLAUDE_BOTTLE_REGISTRY_IMAGE",
+ "BOT_BOTTLE_REGISTRY_IMAGE",
"registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
)
@@ -60,7 +60,7 @@ REGISTRY_IMAGE = os.environ.get(
# against a localhost-equivalent registry, so the trust surface is
# narrow.
CRANE_IMAGE = os.environ.get(
- "CLAUDE_BOTTLE_CRANE_IMAGE",
+ "BOT_BOTTLE_CRANE_IMAGE",
"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
(the calling process crashes between yield and close)."""
session_id = uuid.uuid4().hex[:12]
- network = f"claude-bottle-registry-net-{session_id}"
- registry_name = f"claude-bottle-registry-{session_id}"
+ network = f"bot-bottle-registry-net-{session_id}"
+ registry_name = f"bot-bottle-registry-{session_id}"
subprocess.run(
["docker", "network", "create", network],
diff --git a/claude_bottle/backend/smolmachines/loopback_alias.py b/bot_bottle/backend/smolmachines/loopback_alias.py
similarity index 98%
rename from claude_bottle/backend/smolmachines/loopback_alias.py
rename to bot_bottle/backend/smolmachines/loopback_alias.py
index 7897e4c..7fc65e6 100644
--- a/claude_bottle/backend/smolmachines/loopback_alias.py
+++ b/bot_bottle/backend/smolmachines/loopback_alias.py
@@ -110,7 +110,7 @@ def ensure_pool() -> None:
)
for ip in missing:
result = subprocess.run(
- ["sudo", "-p", "claude-bottle (loopback alias): ",
+ ["sudo", "-p", "bot-bottle (loopback alias): ",
"ifconfig", "lo0", "alias", f"{ip}/32", "up"],
check=False,
)
@@ -215,7 +215,7 @@ def _aliases_in_use() -> set[str]:
`HostIp` out of its port bindings."""
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}",
- "--filter", "name=claude-bottle-sidecars-"],
+ "--filter", "name=bot-bottle-sidecars-"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
diff --git a/claude_bottle/backend/smolmachines/prepare.py b/bot_bottle/backend/smolmachines/prepare.py
similarity index 81%
rename from claude_bottle/backend/smolmachines/prepare.py
rename to bot_bottle/backend/smolmachines/prepare.py
index b43191b..18bdfc6 100644
--- a/claude_bottle/backend/smolmachines/prepare.py
+++ b/bot_bottle/backend/smolmachines/prepare.py
@@ -14,6 +14,7 @@ import os
from datetime import datetime, timezone
from pathlib import Path
+from ...agent_provider import runtime_for
from ...backend import BottleSpec
from ...backend.docker.bottle_state import (
BottleMetadata,
@@ -55,6 +56,8 @@ def resolve_plan(
manifest = spec.manifest
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)
@@ -116,9 +119,13 @@ def resolve_plan(
# outbound leg using a token held in egress's own environ — so
# the agent gets a non-secret placeholder here (matches the
# docker backend's forwarded_env logic in
- # claude_bottle/backend/docker/prepare.py).
- if any("claude_code_oauth" in r.roles for r in egress_plan.routes):
- guest_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
+ # bot_bottle/backend/docker/prepare.py).
+ has_provider_auth = any(
+ 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("DISABLE_ERROR_REPORTING", "1")
@@ -141,13 +148,20 @@ def resolve_plan(
prompt_file.write_text(agent.prompt or "")
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
- # 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.
- agent_image_ref = os.environ.get(
- "CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest"
- )
+ agent_dockerfile_path = ""
+ 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(
spec=spec,
@@ -164,4 +178,15 @@ def resolve_plan(
git_gate_plan=git_gate_plan,
egress_plan=egress_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)
diff --git a/claude_bottle/backend/smolmachines/provision/__init__.py b/bot_bottle/backend/smolmachines/provision/__init__.py
similarity index 100%
rename from claude_bottle/backend/smolmachines/provision/__init__.py
rename to bot_bottle/backend/smolmachines/provision/__init__.py
diff --git a/claude_bottle/backend/smolmachines/provision/ca.py b/bot_bottle/backend/smolmachines/provision/ca.py
similarity index 100%
rename from claude_bottle/backend/smolmachines/provision/ca.py
rename to bot_bottle/backend/smolmachines/provision/ca.py
diff --git a/claude_bottle/backend/smolmachines/provision/git.py b/bot_bottle/backend/smolmachines/provision/git.py
similarity index 96%
rename from claude_bottle/backend/smolmachines/provision/git.py
rename to bot_bottle/backend/smolmachines/provision/git.py
index 975d981..7968d23 100644
--- a/claude_bottle/backend/smolmachines/provision/git.py
+++ b/bot_bottle/backend/smolmachines/provision/git.py
@@ -36,14 +36,14 @@ from ..bottle_plan import SmolmachinesBottlePlan
# `node` is the agent user from the repo Dockerfile. Override via
-# CLAUDE_BOTTLE_GUEST_HOME mirrors the docker backend's
-# CLAUDE_BOTTLE_CONTAINER_HOME knob — same purpose, different
+# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
+# BOT_BOTTLE_CONTAINER_HOME knob — same purpose, different
# transport.
_DEFAULT_GUEST_HOME = "/home/node"
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:
diff --git a/claude_bottle/backend/smolmachines/provision/prompt.py b/bot_bottle/backend/smolmachines/provision/prompt.py
similarity index 86%
rename from claude_bottle/backend/smolmachines/provision/prompt.py
rename to bot_bottle/backend/smolmachines/provision/prompt.py
index fb0cf5d..1a5276c 100644
--- a/claude_bottle/backend/smolmachines/provision/prompt.py
+++ b/bot_bottle/backend/smolmachines/provision/prompt.py
@@ -18,8 +18,8 @@ from ..bottle_plan import SmolmachinesBottlePlan
# `node` is the agent user from the repo Dockerfile.
-# CLAUDE_BOTTLE_GUEST_HOME mirrors the docker backend's
-# CLAUDE_BOTTLE_CONTAINER_HOME knob.
+# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
+# BOT_BOTTLE_CONTAINER_HOME knob.
_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
None. The file is copied either way so the path always
exists — mirrors the docker backend's behavior."""
- guest_home = os.environ.get("CLAUDE_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
- in_guest_prompt_path = f"{guest_home}/.claude-bottle-prompt.txt"
+ guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
+ in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt"
_smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}")
# machine cp lands as root, source's 0o600 mode is preserved —
diff --git a/claude_bottle/backend/smolmachines/provision/skills.py b/bot_bottle/backend/smolmachines/provision/skills.py
similarity index 95%
rename from claude_bottle/backend/smolmachines/provision/skills.py
rename to bot_bottle/backend/smolmachines/provision/skills.py
index 8eb310f..d870ec3 100644
--- a/claude_bottle/backend/smolmachines/provision/skills.py
+++ b/bot_bottle/backend/smolmachines/provision/skills.py
@@ -19,7 +19,7 @@ from ..bottle_plan import SmolmachinesBottlePlan
# In-guest path mirrors the docker backend's claude-skills
# convention (~/.claude/skills//) 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).
_DEFAULT_SKILLS_DIR = "/home/node/.claude/skills"
@@ -43,7 +43,7 @@ def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
return
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])
diff --git a/claude_bottle/backend/smolmachines/provision/supervise.py b/bot_bottle/backend/smolmachines/provision/supervise.py
similarity index 100%
rename from claude_bottle/backend/smolmachines/provision/supervise.py
rename to bot_bottle/backend/smolmachines/provision/supervise.py
diff --git a/claude_bottle/backend/smolmachines/pty_resize.py b/bot_bottle/backend/smolmachines/pty_resize.py
similarity index 97%
rename from claude_bottle/backend/smolmachines/pty_resize.py
rename to bot_bottle/backend/smolmachines/pty_resize.py
index 5603ca0..311836b 100644
--- a/claude_bottle/backend/smolmachines/pty_resize.py
+++ b/bot_bottle/backend/smolmachines/pty_resize.py
@@ -24,7 +24,7 @@ process that:
extra signalling.
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, ..., , --, ...]`
to the smolvm argv. Foreground handoff (curses endwin →
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."""
if len(argv) < 3 or argv[1] != "--":
sys.stderr.write(
- "usage: python -m claude_bottle.backend.smolmachines.pty_resize "
+ "usage: python -m bot_bottle.backend.smolmachines.pty_resize "
" -- \n"
)
return 2
diff --git a/claude_bottle/backend/smolmachines/sidecar_bundle.py b/bot_bottle/backend/smolmachines/sidecar_bundle.py
similarity index 87%
rename from claude_bottle/backend/smolmachines/sidecar_bundle.py
rename to bot_bottle/backend/smolmachines/sidecar_bundle.py
index e4899af..553a972 100644
--- a/claude_bottle/backend/smolmachines/sidecar_bundle.py
+++ b/bot_bottle/backend/smolmachines/sidecar_bundle.py
@@ -11,7 +11,7 @@ Two docker resources per bottle live here:
— a race we can sidestep with `--ip`.
- **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
as the docker backend.
@@ -29,22 +29,29 @@ from pathlib import Path
from typing import Sequence
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:
- """`claude-bottle-bundle-` — distinct from the docker
- backend's `claude-bottle-net-` so a smolmachines bottle
+ """`bot-bottle-bundle-` — distinct from the docker
+ backend's `bot-bottle-net-` so a smolmachines bottle
and a docker bottle for the same agent don't collide on
network name."""
- return f"claude-bottle-bundle-{slug}"
+ return f"bot-bottle-bundle-{slug}"
def bundle_container_name(slug: str) -> str:
- """`claude-bottle-sidecars-` — same name shape the docker
+ """`bot-bottle-sidecars-` — same name shape the docker
backend uses for the bundle (PRD 0024 chunk 5). The dashboard's
prefix-based discovery covers both backends with one filter."""
- return f"claude-bottle-sidecars-{slug}"
+ return f"bot-bottle-sidecars-{slug}"
@dataclass(frozen=True)
@@ -59,7 +66,7 @@ class BundleLaunchSpec:
gateway: str
bundle_ip: str
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
# bottle-irrelevant daemons (e.g. supervise=False bottles).
daemons_csv: str = "egress,pipelock"
@@ -85,6 +92,21 @@ class BundleLaunchSpec:
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:
"""`docker network create` with an explicit subnet + gateway
so the bundle's `--ip` lands on the address the Smolfile's
@@ -141,7 +163,7 @@ def start_bundle(spec: BundleLaunchSpec, *,
"--rm",
"--network", spec.network_name,
"--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:
argv += ["-e", entry]
diff --git a/claude_bottle/backend/smolmachines/smolvm.py b/bot_bottle/backend/smolmachines/smolvm.py
similarity index 100%
rename from claude_bottle/backend/smolmachines/smolvm.py
rename to bot_bottle/backend/smolmachines/smolvm.py
diff --git a/claude_bottle/backend/smolmachines/util.py b/bot_bottle/backend/smolmachines/util.py
similarity index 96%
rename from claude_bottle/backend/smolmachines/util.py
rename to bot_bottle/backend/smolmachines/util.py
index b6fa3eb..b28f451 100644
--- a/claude_bottle/backend/smolmachines/util.py
+++ b/bot_bottle/backend/smolmachines/util.py
@@ -19,7 +19,7 @@ def smolmachines_preflight() -> None:
if shutil.which("smolvm") is not None:
return
die(
- "CLAUDE_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
+ "BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
"PATH. Install with: "
"curl -sSL https://smolmachines.com/install.sh | sh"
)
diff --git a/claude_bottle/backend/util.py b/bot_bottle/backend/util.py
similarity index 89%
rename from claude_bottle/backend/util.py
rename to bot_bottle/backend/util.py
index bb26f49..1bc8bec 100644
--- a/claude_bottle/backend/util.py
+++ b/bot_bottle/backend/util.py
@@ -1,6 +1,6 @@
"""Cross-backend utility helpers — host-side primitives shared by
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
diff --git a/claude_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py
similarity index 94%
rename from claude_bottle/cli/__init__.py
rename to bot_bottle/cli/__init__.py
index f6ee37a..ebcc139 100644
--- a/claude_bottle/cli/__init__.py
+++ b/bot_bottle/cli/__init__.py
@@ -35,11 +35,11 @@ COMMANDS = {
def usage() -> None:
sys.stderr.write(f"usage: {PROG} [args...]\n\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(" edit open an agent in vim for editing\n")
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
- sys.stderr.write(" init interactively create a new agent and add it to 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(" 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")
diff --git a/claude_bottle/cli/_common.py b/bot_bottle/cli/_common.py
similarity index 100%
rename from claude_bottle/cli/_common.py
rename to bot_bottle/cli/_common.py
diff --git a/claude_bottle/cli/cleanup.py b/bot_bottle/cli/cleanup.py
similarity index 87%
rename from claude_bottle/cli/cleanup.py
rename to bot_bottle/cli/cleanup.py
index eb225f0..9f9563c 100644
--- a/claude_bottle/cli/cleanup.py
+++ b/bot_bottle/cli/cleanup.py
@@ -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
`./cli.py cleanup` reaps both backends' leftovers — orphaned
@@ -14,7 +14,7 @@ bucket.
State dirs with `.preserve` are intentionally never touched — they
hold capability-block rebuilds or crash snapshots the operator may
-want to `resume`. Manual `rm -rf ~/.claude-bottle/state/`
+want to `resume`. Manual `rm -rf ~/.bot-bottle/state/`
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]
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
for name, _, plan in prepared:
@@ -58,7 +58,7 @@ def cmd_cleanup(_argv: list[str]) -> int:
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()
reply = read_tty_line()
return reply in ("y", "Y", "yes", "YES")
diff --git a/claude_bottle/cli/dashboard.py b/bot_bottle/cli/dashboard.py
similarity index 92%
rename from claude_bottle/cli/dashboard.py
rename to bot_bottle/cli/dashboard.py
index 71fedc8..a317108 100644
--- a/claude_bottle/cli/dashboard.py
+++ b/bot_bottle/cli/dashboard.py
@@ -26,6 +26,7 @@ from datetime import datetime, timezone
from pathlib import Path
from .. import supervise as _supervise
+from ..agent_provider import runtime_for
from ..backend import (
ActiveAgent,
BottleSpec,
@@ -73,8 +74,8 @@ from ..supervise import (
)
from ._common import PROG, USER_CWD
from .start import (
- attach_claude,
- capture_session_state,
+ attach_agent,
+ capture_claude_session_state,
prepare_with_preflight,
settle_state,
)
@@ -119,10 +120,10 @@ def _approval_status(qp: QueuedProposal, verb: str) -> str:
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
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():
return []
out: list[QueuedProposal] = []
@@ -367,8 +368,6 @@ def _picker_modal(
"""Modal agent picker. Type to filter; j/k or arrows to
navigate; Enter to confirm; Esc to abort (first press clears
filter if any, second press exits)."""
- if not names:
- return None
selected = 0
query = ""
while True:
@@ -454,9 +453,13 @@ def _draw_picker_modal(
list_start_row = 3
visible_rows = box_h - list_start_row - 1
if not filtered:
+ empty_message = (
+ "(no agents configured)"
+ if not all_names else "(no agents match filter)"
+ )
win.addnstr(
list_start_row, 2,
- "(no agents match filter)",
+ empty_message,
box_w - 4, curses.A_DIM,
)
else:
@@ -542,7 +545,7 @@ def _backend_picker_modal(
which keeps existing-muscle-memory flows quiet — the modal only
surfaces a choice; it doesn't surprise the operator by jumping
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)."""
names = list(known_backend_names())
if len(names) <= 1:
@@ -637,7 +640,7 @@ def _bottle_for_slug(
"""Return `(bottle_handle, prompt_path_hint)` for a re-attach.
If the slug is in `bottles` (dashboard-owned), return the stored
handle directly. Otherwise synthesize a `DockerBottle` from the
- container name `claude-bottle-`. For synthesized bottles
+ container name `bot-bottle-`. For synthesized bottles
the prompt-file path comes from the manifest's agent if we can
resolve it via metadata.json + the loaded manifest; otherwise
the re-attach runs without `--append-system-prompt-file`.
@@ -649,19 +652,19 @@ def _bottle_for_slug(
if slug in bottles:
_cm, bottle, _identity = bottles[slug]
return bottle, ""
- # The container hosting the agent's claude process is named
- # `claude-bottle-` — set by the compose renderer
+ # The container hosting the agent's agent process is named
+ # `bot-bottle-` — set by the compose renderer
# (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
metadata = read_metadata(slug)
if metadata is not None and manifest is not None:
agent = manifest.agents.get(metadata.agent_name)
if agent is not None and agent.prompt:
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(
container=container_name,
teardown=lambda: None,
@@ -693,7 +696,7 @@ def _stop_bottle_flow(
return (
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:
# Best-effort snapshot before teardown so the operator
@@ -703,7 +706,8 @@ def _stop_bottle_flow(
# existing preserve marker (if any) is honored by
# settle_state below.
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:
pass
try:
@@ -713,7 +717,7 @@ def _stop_bottle_flow(
# Mirror the bringup path's stderr → right-pane routing.
# 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
# removes the state dir (tail-F handles file removal).
try:
@@ -750,7 +754,7 @@ def _stop_bottle_flow(
# pane of a two-pane window with the operator's currently-selected
# agent in the right pane. First attach creates the right pane via
# `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
# reused across attaches.
@@ -761,21 +765,24 @@ def _in_tmux() -> bool:
return bool(os.environ.get("TMUX"))
-def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[str]:
- """The argv the dashboard hands to `bottle.claude_argv`
- on every attach — matches what `attach_claude` builds for the
+def _agent_runtime_args(
+ *, resume: bool, remote_control: bool = False, agent_provider_template: str = "claude",
+) -> 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
invocation."""
- args = ["--dangerously-skip-permissions"]
+ runtime = runtime_for(agent_provider_template)
+ args = list(runtime.bypass_args)
if remote_control:
- args.append("--remote-control")
+ args.extend(runtime.remote_control_args)
if resume:
- args.append("--continue")
+ args.extend(runtime.resume_args)
return args
def _build_resume_argv_with_fallback(
- bottle, *, remote_control: bool = False,
+ bottle, *, remote_control: bool = False, agent_provider_template: str = "claude",
) -> list[str]:
"""Build a backend-exec argv that runs `claude --continue` and
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
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
framing (docker: `docker exec -it `; smolmachines:
`smolvm machine exec --name -- runuser -u node --`).
Splitting at `claude` keeps the framing as the prefix and
- wraps just the claude tail in `sh -c`."""
- base_args = ["--dangerously-skip-permissions"]
- if remote_control:
- base_args.append("--remote-control")
- base_exec = bottle.claude_argv(base_args)
- # Split exec-framing prefix from the claude-and-args tail so
+ wraps just the agent tail in `sh -c`."""
+ if agent_provider_template != "claude":
+ return bottle.agent_argv(
+ _agent_runtime_args(
+ resume=True,
+ 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 ` --continue || ` inside
- # `sh -c`. The `claude` token is the marker.
- claude_idx = base_exec.index("claude")
- prefix = base_exec[:claude_idx]
- claude_cmd = " ".join(shlex.quote(a) for a in base_exec[claude_idx:])
+ # `sh -c`. The provider command token is the marker.
+ command = getattr(bottle, "agent_command", runtime_for(agent_provider_template).command)
+ agent_idx = base_exec.index(command)
+ 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 [
*prefix,
"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
-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
@@ -821,15 +842,15 @@ def _build_split_pane_argv(claude_argv: list[str]) -> list[str]:
return [
"tmux", "split-window", "-h",
"-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
-k -t `. `-k` kills the existing process in the pane
before respawning."""
- return ["tmux", "respawn-pane", "-k", "-t", pane_id, *claude_argv]
+ return ["tmux", "respawn-pane", "-k", "-t", pane_id, *agent_argv]
@contextlib.contextmanager
@@ -933,7 +954,7 @@ def _route_op_to_right_pane(
def _tmux_close_right_pane(tmux_state: dict) -> None:
"""Close the tracked right pane via `tmux kill-pane`. Clears
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."""
pane_id = tmux_state.get("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.
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,
every new-agent start would pile up a fresh right pane
instead of reusing the one already next to the dashboard."""
@@ -1018,14 +1039,18 @@ def _attach_via_handoff(
`_attach_in_tmux` when tmux misbehaves)."""
curses.endwin()
try:
- exit_code = attach_claude(
- bottle, remote_control=False, resume=resume,
+ agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
+ exit_code = attach_agent(
+ bottle,
+ remote_control=False,
+ resume=resume,
+ agent_provider_template=agent_provider_template,
)
except BaseException:
stdscr.refresh()
raise
stdscr.refresh()
- return f"[{slug}] claude session ended (exit {exit_code})"
+ return f"[{slug}] agent session ended (exit {exit_code})"
def _attach_in_tmux(
@@ -1044,21 +1069,28 @@ def _attach_in_tmux(
explicit-stop hook).
`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
auto-attach after a stop) leave it False so the operator
stays in the dashboard pane."""
if resume:
+ agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
# `--continue` exits non-zero when no prior session
# exists (agent spun up but never typed at). Wrap with a
# shell-level fallback so the pane lands in a fresh
- # claude instead of crashing.
- claude_argv = _build_resume_argv_with_fallback(bottle)
- else:
- claude_argv = bottle.claude_argv(
- _claude_runtime_args(resume=False),
+ # agent instead of crashing.
+ agent_argv = _build_resume_argv_with_fallback(
+ bottle, agent_provider_template=agent_provider_template,
)
- 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:
# tmux failed (missing binary, server died, size error).
# One status-line failover to the curses handoff so the
@@ -1091,7 +1123,7 @@ def _attach_to_bottle(
tmux_state: dict | None = None,
) -> str:
"""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
subsequent). Outside tmux it's a curses-endwin handoff that
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:
# Enter re-attach is an explicit "I want to interact with
# 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(
stdscr, bottle, slug,
resume=True, tmux_state=tmux_state,
@@ -1117,13 +1149,15 @@ def _new_agent_flow(
) -> str:
"""Open the picker, prepare + preflight (modal), launch
(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
for the dashboard footer. The (cm, bottle) tuple lands in
`bottles` keyed by slug; chunk 4 uses it for explicit stop."""
names = sorted(manifest.agents.keys())
picked = _picker_modal(stdscr, names, _running_counts(bottles, agents_now))
if picked is None:
+ if not names:
+ return "no agents configured; create ~/.bot-bottle/agents/*.md"
return "agent start aborted"
# Backend picker (issue #77): operator chooses docker /
@@ -1151,7 +1185,7 @@ def _new_agent_flow(
def _prompt() -> bool:
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:
plan, identity = prepare_with_preflight(
spec,
@@ -1205,14 +1239,20 @@ def _new_agent_flow(
raise
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.
try:
- exit_code = attach_claude(bottle, remote_control=False)
- capture_session_state(identity, exit_code)
+ agent_provider_template = getattr(plan, "agent_provider_template", "claude")
+ 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:
stdscr.refresh()
- return f"[{plan.slug}] claude session ended (exit {exit_code})"
+ return f"[{plan.slug}] agent session ended (exit {exit_code})"
finally:
# stage_dir was the prepare scratch dir; after PRD 0018
# 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:
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]
+ 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
# pre-existing queue entries on its first poll; those
# 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
# to the next agent in the list (the one that
# 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.
pick = _pick_next_after_stop(
agents, selected_agent, target.slug,
@@ -1578,7 +1620,7 @@ def _render(
h, w = stdscr.getmaxyx()
agents = agents or []
header = (
- f"claude-bottle dashboard "
+ f"bot-bottle dashboard "
f"({len(pending)} pending, {len(agents)} active)"
)
stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD)
diff --git a/claude_bottle/cli/edit.py b/bot_bottle/cli/edit.py
similarity index 89%
rename from claude_bottle/cli/edit.py
rename to bot_bottle/cli/edit.py
index 9f41870..8c412d9 100644
--- a/claude_bottle/cli/edit.py
+++ b/bot_bottle/cli/edit.py
@@ -18,9 +18,9 @@ def cmd_edit(argv: list[str]) -> int:
args = parser.parse_args(argv)
if args.scope == "user":
- target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
+ target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
else:
- target_file = Path(USER_CWD) / "claude-bottle.json"
+ target_file = Path(USER_CWD) / "bot-bottle.json"
if not target_file.is_file():
die(f"{target_file} does not exist")
diff --git a/claude_bottle/cli/info.py b/bot_bottle/cli/info.py
similarity index 94%
rename from claude_bottle/cli/info.py
rename to bot_bottle/cli/info.py
index 228ffbc..db74464 100644
--- a/claude_bottle/cli/info.py
+++ b/bot_bottle/cli/info.py
@@ -11,7 +11,7 @@ from ._common import PROG, USER_CWD
def cmd_info(argv: list[str]) -> int:
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)
manifest = Manifest.resolve(USER_CWD)
diff --git a/claude_bottle/cli/init.py b/bot_bottle/cli/init.py
similarity index 94%
rename from claude_bottle/cli/init.py
rename to bot_bottle/cli/init.py
index 50c6f0f..ac78ef1 100644
--- a/claude_bottle/cli/init.py
+++ b/bot_bottle/cli/init.py
@@ -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
@@ -20,12 +20,12 @@ def cmd_init(argv: list[str]) -> int:
args = parser.parse_args(argv)
if args.scope == "user":
- target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
+ target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
else:
- target_file = Path(USER_CWD) / "claude-bottle.json"
+ target_file = Path(USER_CWD) / "bot-bottle.json"
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)
# 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")
if agent_name in (existing.get("agents") or {}):
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()
ow = read_tty_line()
diff --git a/claude_bottle/cli/list.py b/bot_bottle/cli/list.py
similarity index 94%
rename from claude_bottle/cli/list.py
rename to bot_bottle/cli/list.py
index 2428282..715b983 100644
--- a/claude_bottle/cli/list.py
+++ b/bot_bottle/cli/list.py
@@ -25,7 +25,7 @@ def cmd_list(argv: list[str]) -> int:
# so smolmachines bottles aren't hidden behind the env var.
active = enumerate_active_agents()
if not active:
- print("no active claude-bottle bottles", file=sys.stderr)
+ print("no active bot-bottle bottles", file=sys.stderr)
return 0
# One line per bottle: `\t\t\t`.
# Tab-separated keeps the format stable for shell pipelines;
diff --git a/claude_bottle/cli/resume.py b/bot_bottle/cli/resume.py
similarity index 91%
rename from claude_bottle/cli/resume.py
rename to bot_bottle/cli/resume.py
index 05e3082..2ce5ccd 100644
--- a/claude_bottle/cli/resume.py
+++ b/bot_bottle/cli/resume.py
@@ -1,6 +1,6 @@
"""resume: re-launch a bottle by its identity.
-Reads ~/.claude-bottle/state//metadata.json to recover the
+Reads ~/.bot-bottle/state//metadata.json to recover the
(agent_name, cwd, copy_cwd) the bottle was originally started with,
then runs the same launch core as `start` — but pinned to the
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:
die(
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)
diff --git a/claude_bottle/cli/start.py b/bot_bottle/cli/start.py
similarity index 81%
rename from claude_bottle/cli/start.py
rename to bot_bottle/cli/start.py
index 1373efa..8c1871b 100644
--- a/claude_bottle/cli/start.py
+++ b/bot_bottle/cli/start.py
@@ -4,7 +4,7 @@ session ends.
The launch core is shared with `cli.py resume ` and (PRD
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`.
"""
@@ -18,6 +18,7 @@ import tempfile
from pathlib import Path
from typing import Callable
+from ..agent_provider import runtime_for
from ..backend import (
Bottle,
BottleSpec,
@@ -46,14 +47,14 @@ def cmd_start(argv: list[str]) -> int:
choices=known_backend_names(),
default=None,
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."
),
)
- 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)
- 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)
spec = BottleSpec(
@@ -88,7 +89,7 @@ def prepare_with_preflight(
curses modal.
`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
CLI passes whatever `--backend` resolved to.
@@ -112,11 +113,13 @@ def prepare_with_preflight(
return plan, identity
-def attach_claude(
+def attach_agent(
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
+ agent_provider_template: str = "claude",
) -> int:
- """Run claude inside `bottle` as an interactive session. Blocks
- until the session ends; returns the claude process's exit code.
+ """Run the selected provider CLI inside `bottle` as an
+ interactive session. Blocks until the session ends; returns the
+ agent process's exit code.
`resume=True` adds `--continue` so claude picks up its most
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
dashboard, which calls it from inside a `curses.endwin → … →
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(
- "attaching interactive claude session "
+ f"attaching interactive {agent_provider_template} session "
"(Ctrl-D or 'exit' to leave; container will be removed)"
)
- claude_args = ["--dangerously-skip-permissions"]
+ agent_args = list(runtime.bypass_args)
if remote_control:
- claude_args.append("--remote-control")
+ agent_args.extend(runtime.remote_control_args)
if resume:
- # `--continue` jumps straight to the most recent session
- # without showing the picker `--resume` would surface.
- claude_args.append("--continue")
- return bottle.exec_claude(claude_args, tty=True)
+ agent_args.extend(runtime.resume_args)
+ return bottle.exec_agent(agent_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
alive: snapshot the transcript and mark for preservation if
claude crashed. Public for the dashboard's death-handling path
(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:
return
snapshot_transcript(identity)
@@ -179,7 +184,7 @@ def _identity_from_plan(plan: object) -> str:
def _text_prompt_yes() -> bool:
"""Default `prompt_yes` for CLI use: reads y/N from the
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()
reply = read_tty_line()
return reply in ("y", "Y", "yes", "YES")
@@ -201,7 +206,7 @@ def _launch_bottle(
"""Shared launch core for `start` and `resume`. Builds the plan,
prints / dry-runs / prompts as appropriate, brings the bottle up,
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 = ""
try:
plan, identity = prepare_with_preflight(
@@ -217,7 +222,12 @@ def _launch_bottle(
backend = get_bottle_backend(backend_name)
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(
f"session ended (exit {exit_code}); "
f"container {bottle.name} will be removed"
@@ -230,7 +240,8 @@ def _launch_bottle(
# way. snapshot_transcript is best-effort so the
# capability-block path's prior snapshot isn't clobbered
# 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
finally:
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
diff --git a/claude_bottle/egress.py b/bot_bottle/egress.py
similarity index 87%
rename from claude_bottle/egress.py
rename to bot_bottle/egress.py
index 58504a7..5f93b24 100644
--- a/claude_bottle/egress.py
+++ b/bot_bottle/egress.py
@@ -14,7 +14,7 @@ This module defines the abstract proxy (`Egress`), its plan
dataclass (`EgressPlan`), and the resolved per-route shape
(`EgressRoute`). The sidecar's start/stop lifecycle is backend-
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
lifecycle are wired into the agent's `HTTP_PROXY` path; cred-proxy
@@ -127,23 +127,6 @@ class EgressPlan:
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(
bottle: Bottle,
) -> tuple[EgressRoute, ...]:
@@ -157,10 +140,9 @@ def egress_manifest_routes(
shares slot 0. Unauthenticated routes (`auth` omitted) contribute
no slot.
- Does NOT include the folded-in DEFAULT_ALLOWLIST /
- bottle.egress.allowlist bare-pass entries — see
- `egress_routes_for_bottle` for the effective set the
- addon enforces."""
+ This is the effective set the addon enforces. Provider runtime
+ routes are intentionally not injected implicitly; every allowed
+ host must come from the home-owned bottle manifest."""
out: list[EgressRoute] = []
slot_for_token: dict[str, str] = {}
for r in bottle.egress.routes:
@@ -189,26 +171,14 @@ def egress_manifest_routes(
def egress_routes_for_bottle(
bottle: Bottle,
) -> tuple[EgressRoute, ...]:
- """Effective egress routes: manifest routes followed by
- bare-pass entries for DEFAULT_ALLOWLIST hosts. This is what
- gets rendered into routes.yaml + what the addon enforces.
+ """Effective egress routes. This is what gets rendered into
+ routes.yaml + what the addon enforces.
- Manifest routes win over defaults on host collision (manifest
- routes carry more specific config — auth, path filter, role
- 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
+ Operators that want to allow a host declare it directly in
+ `bottle.egress.routes` as an authenticated route or bare-pass entry
(`- host: `). The legacy `bottle.egress.allowlist`
folding is gone — egress is the single allowlist surface."""
- out: list[EgressRoute] = list(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)
+ return egress_manifest_routes(bottle)
def egress_token_env_map(
@@ -327,7 +297,6 @@ class Egress(ABC):
)
__all__ = [
- "DEFAULT_ALLOWLIST",
"EGRESS_HOSTNAME",
"EGRESS_ROUTES_IN_CONTAINER",
"Egress",
diff --git a/claude_bottle/egress_addon.py b/bot_bottle/egress_addon.py
similarity index 99%
rename from claude_bottle/egress_addon.py
rename to bot_bottle/egress_addon.py
index 8180c89..24a7ec1 100644
--- a/claude_bottle/egress_addon.py
+++ b/bot_bottle/egress_addon.py
@@ -21,7 +21,7 @@ mitmproxy is a container-only dependency. The host's tests target
Dockerfile.sidecars copies both this file and
`egress_addon_core.py` flat into `/app/`; the absolute import
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."""
from __future__ import annotations
diff --git a/claude_bottle/egress_addon_core.py b/bot_bottle/egress_addon_core.py
similarity index 99%
rename from claude_bottle/egress_addon_core.py
rename to bot_bottle/egress_addon_core.py
index c99c547..9cacb85 100644
--- a/claude_bottle/egress_addon_core.py
+++ b/bot_bottle/egress_addon_core.py
@@ -19,7 +19,7 @@ from dataclasses import dataclass
# Absolute import — `yaml_subset.py` is copied flat into the bundle
# image's `/app/` next to this file (via `Dockerfile.sidecars`).
# 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.
try:
from yaml_subset import YamlSubsetError, parse_yaml_subset # type: ignore[import-not-found]
diff --git a/claude_bottle/egress_entrypoint.sh b/bot_bottle/egress_entrypoint.sh
similarity index 96%
rename from claude_bottle/egress_entrypoint.sh
rename to bot_bottle/egress_entrypoint.sh
index 10556f0..c56de23 100644
--- a/claude_bottle/egress_entrypoint.sh
+++ b/bot_bottle/egress_entrypoint.sh
@@ -2,7 +2,7 @@
# Egress daemon entrypoint inside the sidecar bundle (PRD 0024).
#
# 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:
#
# * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch
@@ -14,7 +14,7 @@
# combined trust bundle (system roots + pipelock CA) and point
# mitmproxy at it. The option REPLACES mitmproxy's default
# 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
# /etc/egress/routes.yaml.
diff --git a/claude_bottle/env.py b/bot_bottle/env.py
similarity index 97%
rename from claude_bottle/env.py
rename to bot_bottle/env.py
index a5ed287..35fe505 100644
--- a/claude_bottle/env.py
+++ b/bot_bottle/env.py
@@ -98,7 +98,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
prompt = (
f"{prompt_body} (input hidden): "
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)
tty.close()
@@ -106,7 +106,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
prompt = (
f"{prompt_body} (input hidden): "
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)
if not value:
diff --git a/claude_bottle/git_gate.py b/bot_bottle/git_gate.py
similarity index 90%
rename from claude_bottle/git_gate.py
rename to bot_bottle/git_gate.py
index d01c4c0..e3cad28 100644
--- a/claude_bottle/git_gate.py
+++ b/bot_bottle/git_gate.py
@@ -25,7 +25,7 @@ land. See `docs/prds/0008-git-gate.md`.
This module defines the abstract gate (`GitGate`) and its plan
dataclass (`GitGatePlan`). The sidecar's start/stop lifecycle is
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
@@ -71,6 +71,7 @@ class GitGateUpstream:
upstream_port: str
identity_file: str
known_host_key: str
+ known_hosts_file: Path = Path()
extra_hosts: Mapping[str, str] = field(default_factory=_empty_str_map)
@@ -158,7 +159,7 @@ def git_gate_render_gitconfig(
if not entries:
return ""
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",
"# the upstream bidirectionally (gitleaks-scanned push;\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:
out.append(f'[url "git://{gate_host}/{entry.Name}.git"]\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)
@@ -397,11 +409,33 @@ class GitGate(ABC):
# not via `sh`, so the script needs the x bit. docker cp
# preserves source mode into the container.
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(
slug=slug,
entrypoint_script=entrypoint,
hook_script=hook,
access_hook_script=access_hook,
- upstreams=upstreams,
+ upstreams=tuple(upstreams_with_files),
)
-
diff --git a/claude_bottle/log.py b/bot_bottle/log.py
similarity index 69%
rename from claude_bottle/log.py
rename to bot_bottle/log.py
index 4fc93cf..8e1abc7 100644
--- a/claude_bottle/log.py
+++ b/bot_bottle/log.py
@@ -7,11 +7,11 @@ from typing import NoReturn
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:
- print(f"claude-bottle: warning: {msg}", file=sys.stderr)
+ print(f"bot-bottle: warning: {msg}", file=sys.stderr)
class Die(SystemExit):
@@ -20,5 +20,5 @@ class Die(SystemExit):
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)
diff --git a/claude_bottle/manifest.py b/bot_bottle/manifest.py
similarity index 82%
rename from claude_bottle/manifest.py
rename to bot_bottle/manifest.py
index 6d2ae8d..fb5d776 100644
--- a/claude_bottle/manifest.py
+++ b/bot_bottle/manifest.py
@@ -2,9 +2,9 @@
Reads the per-file manifest tree:
- $HOME/.claude-bottle/bottles/.md — one bottle per file
- $HOME/.claude-bottle/agents/.md — home-resident agents
- $CWD/.claude-bottle/agents/.md — cwd-supplied agents
+ $HOME/.bot-bottle/bottles/.md — one bottle per file
+ $HOME/.bot-bottle/agents/.md — home-resident agents
+ $CWD/.bot-bottle/agents/.md — cwd-supplied agents
Each file is Markdown with YAML frontmatter. The frontmatter holds
the structured config (see schema below); for agents the body is
@@ -18,6 +18,8 @@ Bottle schema (frontmatter):
user: { name: , email: } # optional
remotes: { : , ... } # optional
egress: { routes: [ , ... ] }
+ # route keys: host, path_allowlist, auth, role, pipelock
+ # pipelock: { tls_passthrough: , ssrf_ip_allowlist: [, ...] }
supervise: # optional
Agent schema (frontmatter):
@@ -41,6 +43,7 @@ on-disk files.
from __future__ import annotations
+import ipaddress
import json
import os
import re
@@ -48,6 +51,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Mapping, cast
+from .agent_provider import PROVIDER_TEMPLATES
from .log import die, warn
from .yaml_subset import YamlSubsetError, parse_frontmatter
@@ -81,6 +85,7 @@ class GitEntry:
IdentityFile: str
KnownHostKey: str = ""
ExtraHosts: Mapping[str, str] = field(default_factory=_empty_str_dict)
+ RemoteKey: str = ""
UpstreamUser: str = ""
UpstreamHost: str = ""
UpstreamPort: str = ""
@@ -139,7 +144,11 @@ class GitEntry:
user, host, port, path = _parse_git_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(
f"bottle '{bottle_name}' git.remotes key {host_key!r} "
f"does not match Upstream host {host!r}"
@@ -150,6 +159,7 @@ class GitEntry:
IdentityFile=ident,
KnownHostKey=khk,
ExtraHosts=extra_hosts,
+ RemoteKey=host_key or host,
UpstreamUser=user,
UpstreamHost=host,
UpstreamPort=port,
@@ -180,6 +190,7 @@ EGRESS_AUTH_SCHEMES = ("Bearer", "token")
# special happens on the agent side.
EGRESS_ROLES = frozenset({
"claude_code_oauth",
+ "codex_auth",
})
# 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.
EGRESS_SINGLETON_ROLES = frozenset({
"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)
class GitUser:
@@ -270,6 +328,68 @@ def _parse_git_config(
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)
class EgressRoute:
"""One route on the per-bottle egress sidecar (PRD 0017).
@@ -306,6 +426,7 @@ class EgressRoute:
AuthScheme: str = ""
TokenRef: str = ""
Role: tuple[str, ...] = ()
+ Pipelock: PipelockRoutePolicy = field(default_factory=PipelockRoutePolicy)
@classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute":
@@ -402,11 +523,17 @@ class EgressRoute:
f"{', '.join(sorted(EGRESS_ROLES))}"
)
+ pipelock = (
+ PipelockRoutePolicy.from_dict(bottle_name, idx, d["pipelock"])
+ if "pipelock" in d
+ else PipelockRoutePolicy()
+ )
+
for k in d:
- if k not in ("host", "path_allowlist", "auth", "role"):
+ if k not in ("host", "path_allowlist", "auth", "role", "pipelock"):
die(
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(
@@ -415,6 +542,7 @@ class EgressRoute:
AuthScheme=auth_scheme,
TokenRef=token_ref,
Role=roles,
+ Pipelock=pipelock,
)
@@ -428,7 +556,9 @@ class EgressConfig:
routes: tuple[EgressRoute, ...] = ()
@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")
routes_raw = d.get("routes")
routes: tuple[EgressRoute, ...] = ()
@@ -443,7 +573,9 @@ class EgressConfig:
EgressRoute.from_dict(bottle_name, i, entry)
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:
if k != "routes":
die(
@@ -456,6 +588,7 @@ class EgressConfig:
@dataclass(frozen=True)
class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
+ agent_provider: AgentProvider = field(default_factory=AgentProvider)
git: tuple[GitEntry, ...] = ()
# Per-bottle git identity (issue #86). Empty default — bottles
# 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,
# capability-block; the cred-proxy-block tool is renamed and
# 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.
supervise: bool = False
@@ -526,8 +659,17 @@ class Bottle:
if git_raw is not None:
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 = (
- EgressConfig.from_dict(name, d["egress"])
+ EgressConfig.from_dict(
+ name, d["egress"],
+ agent_provider_template=agent_provider.template,
+ )
if "egress" in d
else EgressConfig()
)
@@ -540,8 +682,8 @@ class Bottle:
)
return cls(
- env=env, git=git, git_user=git_user, egress=egress,
- supervise=supervise_raw,
+ env=env, agent_provider=agent_provider, git=git,
+ git_user=git_user, egress=egress, supervise=supervise_raw,
)
@@ -598,34 +740,41 @@ class Manifest:
agents: Mapping[str, Agent]
@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.
Layout (PRD 0011):
- $HOME/.claude-bottle/bottles/.md — bottles (home-only)
- $HOME/.claude-bottle/agents/.md — home agents
- $CWD/.claude-bottle/agents/.md — cwd agents
+ $HOME/.bot-bottle/bottles/.md — bottles (home-only)
+ $HOME/.bot-bottle/agents/.md — home agents
+ $CWD/.bot-bottle/agents/.md — cwd agents
Cwd agents merge into the home agents on the same name
(cwd wins). A bottles/ subdir under $CWD is logged as a
warning and ignored — the filesystem layout IS the trust
boundary.
- If `claude-bottle.json` exists alongside a missing
- `.claude-bottle/` directory at either side, dies with a
+ If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
+ 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
manifest format changed in PRD 0011 and we don't silently
fall back."""
home_dir = Path(os.environ["HOME"])
cwd_dir = Path(cwd)
- home_md = home_dir / ".claude-bottle"
- cwd_md = cwd_dir / ".claude-bottle"
+ home_md = home_dir / ".bot-bottle"
+ cwd_md = cwd_dir / ".bot-bottle"
_check_stale_json(home_dir, home_md, "$HOME")
if cwd_dir.resolve() != home_dir.resolve():
_check_stale_json(cwd_dir, cwd_md, "$CWD")
if not home_md.is_dir():
+ if missing_ok:
+ return cls.from_json_obj({"bottles": {}, "agents": {}})
die(
f"no manifest found: {home_md} does not exist. "
f"See README.md for the per-file Markdown layout "
@@ -668,7 +817,7 @@ class Manifest:
warn(
f"ignoring bottle file(s) under "
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."
)
cwd_agents_dir = cwd_dir / "agents"
@@ -708,8 +857,8 @@ class Manifest:
return
available = ", ".join(self.agents.keys())
if available:
- die(f"agent '{name}' not defined in claude-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. Available: {available}")
+ die(f"agent '{name}' not defined in bot-bottle.json (manifest is empty).")
def has_bottle(self, name: str) -> bool:
return name in self.bottles
@@ -720,10 +869,10 @@ class Manifest:
available = ", ".join(self.bottles.keys())
if available:
die(
- f"bottle '{name}' not defined in claude-bottle.json. "
+ f"bottle '{name}' not defined in bot-bottle.json. "
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:
"""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:
doc: object = json.load(f)
except json.JSONDecodeError:
- die(f"claude-bottle.json at {path} is not valid JSON")
- return _as_json_object(doc, f"claude-bottle.json at {path}")
+ die(f"bot-bottle.json at {path} is not valid JSON")
+ return _as_json_object(doc, f"bot-bottle.json at {path}")
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)
+def _is_ip_literal(value: str) -> bool:
+ try:
+ ipaddress.ip_address(value)
+ except ValueError:
+ return False
+ return True
+
+
def _validate_egress_routes(
bottle_name: str,
routes: tuple[EgressRoute, ...],
+ *,
+ agent_provider_template: str = "claude",
) -> None:
"""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"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:
@@ -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
# ghost into an empty config.
_BOTTLE_KEYS = frozenset(
- {"env", "extends", "git", "egress", "supervise"}
+ {"env", "extends", "agent_provider", "git", "egress", "supervise"}
)
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
_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`.
_AGENT_KEYS_CC_PASSTHROUGH = frozenset({
"name", "description", "model", "color", "memory",
@@ -896,10 +1065,10 @@ _AGENT_KEYS = (
def _check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
- """Die if `/claude-bottle.json` exists but `md_dir` does
+ """Die if `/bot-bottle.json` exists but `md_dir` does
not — the manifest format changed in PRD 0011 and we don't want
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():
die(
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 +
# scalar fields.
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 = (
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(
env=merged_env,
+ agent_provider=merged_agent_provider,
git=merged_git,
git_user=merged_git_user,
egress=merged_egress,
diff --git a/claude_bottle/pipelock.py b/bot_bottle/pipelock.py
similarity index 79%
rename from claude_bottle/pipelock.py
rename to bot_bottle/pipelock.py
index 96c779f..793a2aa 100644
--- a/claude_bottle/pipelock.py
+++ b/bot_bottle/pipelock.py
@@ -21,29 +21,15 @@ from dataclasses import dataclass
from pathlib import Path
from typing import cast
-from .egress import (
- DEFAULT_ALLOWLIST,
- EGRESS_HOSTNAME,
- egress_routes_for_bottle,
-)
+from .egress import EGRESS_HOSTNAME, egress_routes_for_bottle
from .supervise import SUPERVISE_HOSTNAME
from .manifest import Bottle
# Hosts pipelock should NOT TLS-MITM, even when tls_interception is
-# enabled. The Claude API endpoint is an LLM provider — its request
-# bodies are user-authored conversation text that legitimately can
-# trigger DLP scanners (notably the BIP-39 seed-phrase detector, which
-# fires on any 12+ consecutive English words that happen to be on the
-# 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",
-)
+# enabled. This is now route-owned manifest policy via
+# `egress.routes[].pipelock.tls_passthrough`; no provider hosts are
+# injected implicitly.
+DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = ()
# In-container paths the rendered pipelock YAML references under
@@ -67,12 +53,11 @@ PIPELOCK_HOSTNAME = "pipelock"
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
"""Hostnames pipelock allows. Sorted for stability.
- Always mirrors `egress_routes_for_bottle(bottle)` — the
- egress is the single allowlist surface; pipelock's
- allowlist is the downstream copy for defense-in-depth + DLP
- body scanning. For bottles without any `egress.routes[]`
- declared, this is just the baked DEFAULT_ALLOWLIST that
- egress_routes_for_bottle always folds in.
+ Always mirrors `egress_routes_for_bottle(bottle)` — egress is the
+ single allowlist surface, and pipelock's allowlist is the downstream
+ copy for defense-in-depth + DLP body scanning. For bottles without
+ any `egress.routes[]` declared, this is empty except for supervise
+ sidecar traffic when `supervise: true`.
The supervise sidecar's hostname is auto-added when supervise
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:
- """Whether pipelock's BIP-39 seed-phrase detector stays on for
- this bottle.
+ """Whether pipelock's BIP-39 seed-phrase detector stays on.
LLM conversation bodies legitimately trip the detector — any 12+
- English words that pass the BIP-39 checksum match — so any
- bottle that routes claude through pipelock's body scanner gets
- blocked on the first real chat. We tried two narrower knobs
- first:
+ English words that pass the BIP-39 checksum match — so agents can
+ get blocked on ordinary prompts/responses regardless of provider
+ (Claude, Codex/OpenAI, or future harnesses). We tried two narrower
+ knobs first:
- `suppress: [{rule, path}]` — pipelock accepts the schema
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`
actually stops the block (verified by sending a 12-word BIP-39
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
- detector off for the entire bottle when the bottle declares an
- egress route to `api.anthropic.com`. The trade-off is
- accepted: BIP-39 detection has little value in claude-bottle's
- threat model (the agent has no access to a user's crypto wallet
- 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
- )
+ no per-path / per-host knob in pipelock 2.3.0 — so we turn off
+ only this detector for every bottle. The rest of pipelock's DLP
+ defaults and request-body/header scanning remain enabled."""
+ del bottle # kept for call-site stability and future policy knobs.
+ return False
def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]:
- """Hostnames pipelock should pass through (no TLS MITM, no body
- 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.
+ """Hostnames pipelock should pass through (no TLS MITM).
- egress route hosts (github, gitea, npm) are deliberately
- NOT auto-added here. egress's HTTPS client trusts pipelock's
- CA at runtime (folded into its trust store via docker cp), so
- pipelock MITMs and body-scans the egress → upstream leg the
- same way it body-scanned the agent's direct HTTPS traffic before
- the PRD 0017 cutover.
+ A route opts in with `pipelock.tls_passthrough: true`. This is
+ useful for provider API routes where egress injects the
+ Authorization header after the agent boundary; pipelock still
+ enforces the host allowlist but does not decrypt and scan that
+ provider request.
+ """
+ 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
- the returned list is independent of the bottle."""
- del bottle # not consulted; see docstring.
- return sorted(DEFAULT_TLS_PASSTHROUGH)
+
+def pipelock_effective_ssrf_ip_allowlist(
+ bottle: Bottle,
+ extra: tuple[str, ...] = (),
+) -> 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
# "surface" — body, MCP, response — has its own action). Pipelock's
# 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.
#
# `scan_headers: true` + `header_mode: all` extends the scan to
@@ -218,8 +207,11 @@ def pipelock_build_config(
"ca_key": ca_key_path,
"passthrough_domains": pipelock_effective_tls_passthrough(bottle),
}
- if ssrf_ip_allowlist:
- cfg["ssrf"] = {"ip_allowlist": list(ssrf_ip_allowlist)}
+ effective_ssrf_ip_allowlist = pipelock_effective_ssrf_ip_allowlist(
+ bottle, ssrf_ip_allowlist,
+ )
+ if effective_ssrf_ip_allowlist:
+ cfg["ssrf"] = {"ip_allowlist": effective_ssrf_ip_allowlist}
return cfg
@@ -354,4 +346,3 @@ class PipelockProxy:
yaml_path.write_text(pipelock_render_yaml(cfg))
yaml_path.chmod(0o600)
return PipelockProxyPlan(yaml_path=yaml_path, slug=slug)
-
diff --git a/claude_bottle/sidecar_init.py b/bot_bottle/sidecar_init.py
similarity index 98%
rename from claude_bottle/sidecar_init.py
rename to bot_bottle/sidecar_init.py
index 2ccfe86..a9d9457 100644
--- a/claude_bottle/sidecar_init.py
+++ b/bot_bottle/sidecar_init.py
@@ -1,6 +1,6 @@
"""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),
forwards SIGTERM/SIGINT to each child, and propagates per-daemon
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."
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.
Stdlib-only by design — adding supervisord/s6/runit for four
@@ -106,7 +106,7 @@ def _selected_daemons(
env: dict[str, str],
all_daemons: Sequence[_DaemonSpec] | None = None,
) -> 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
source of truth for which daemons are wired.
@@ -115,7 +115,7 @@ def _selected_daemons(
`_DAEMONS` and have the new value take effect."""
if all_daemons is None:
all_daemons = _DAEMONS
- raw = env.get("CLAUDE_BOTTLE_SIDECAR_DAEMONS", "").strip()
+ raw = env.get("BOT_BOTTLE_SIDECAR_DAEMONS", "").strip()
if not raw:
return tuple(all_daemons)
wanted = {n.strip() for n in raw.split(",") if n.strip()}
diff --git a/claude_bottle/supervise.py b/bot_bottle/supervise.py
similarity index 96%
rename from claude_bottle/supervise.py
rename to bot_bottle/supervise.py
index 11b1256..bdf4cdb 100644
--- a/claude_bottle/supervise.py
+++ b/bot_bottle/supervise.py
@@ -1,7 +1,7 @@
"""Per-bottle supervise plane (PRD 0013).
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
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,
writes it to the host's per-bottle queue dir, and holds the tool-call
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
proposal. The sidecar sees the response and returns `{status, notes}`
to the agent.
@@ -21,7 +21,7 @@ to the agent.
This module defines the host-side library: dataclasses for the queue
file shapes, queue read/write helpers, the audit log writer, and the
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).
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
# introspection endpoint for the `list-egress-routes` MCP
# 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
# are inlined here so the in-container supervise_server doesn't
# need to import the egress package).
@@ -90,7 +90,7 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
ACTION_OPERATOR_EDIT = "operator-edit"
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
@@ -98,16 +98,16 @@ DEFAULT_POLL_INTERVAL_SEC = 0.5
# --- Paths -----------------------------------------------------------------
-def claude_bottle_root() -> Path:
- return Path.home() / ".claude-bottle"
+def bot_bottle_root() -> Path:
+ return Path.home() / ".bot-bottle"
def queue_dir_for_slug(slug: str) -> Path:
- return claude_bottle_root() / "queue" / slug
+ return bot_bottle_root() / "queue" / slug
def audit_dir() -> Path:
- return claude_bottle_root() / "audit"
+ return bot_bottle_root() / "audit"
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
at /run/supervise/queue. `current_config_dir` is the host
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
`list-egress-routes` MCP tool). `internal_network` is
empty at prepare time; the backend's launch step fills it via
@@ -566,7 +566,7 @@ __all__ = [
"archive_proposal",
"audit_dir",
"audit_log_path",
- "claude_bottle_root",
+ "bot_bottle_root",
"list_pending_proposals",
"queue_dir_for_slug",
"read_audit_entries",
diff --git a/claude_bottle/supervise_server.py b/bot_bottle/supervise_server.py
similarity index 99%
rename from claude_bottle/supervise_server.py
rename to bot_bottle/supervise_server.py
index 7a1f164..7600925 100644
--- a/claude_bottle/supervise_server.py
+++ b/bot_bottle/supervise_server.py
@@ -6,7 +6,7 @@ propose config changes when stuck. Each tool call:
1. Validates the proposed file syntactically.
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from
- the host's ~/.claude-bottle/queue//).
+ the host's ~/.bot-bottle/queue//).
3. Blocks polling for a matching Response file.
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).
-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
plumbing.
"""
@@ -51,7 +51,7 @@ import supervise as _sv
MCP_PROTOCOL_VERSION = "2024-11-05"
-SERVER_NAME = "claude-bottle-supervise"
+SERVER_NAME = "bot-bottle-supervise"
SERVER_VERSION = "0.1.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 "
"Dockerfile rather than in routes or the pipelock allowlist. "
"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 "
"justification. On approval the supervisor rebuilds the "
"bottle from the new Dockerfile and starts a replacement on "
diff --git a/claude_bottle/util.py b/bot_bottle/util.py
similarity index 100%
rename from claude_bottle/util.py
rename to bot_bottle/util.py
diff --git a/claude_bottle/yaml_subset.py b/bot_bottle/yaml_subset.py
similarity index 98%
rename from claude_bottle/yaml_subset.py
rename to bot_bottle/yaml_subset.py
index f6cb777..1ab7694 100644
--- a/claude_bottle/yaml_subset.py
+++ b/bot_bottle/yaml_subset.py
@@ -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).
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 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.
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
that want fatal-exit semantics (manifest loader, pipelock-apply,
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."""
def die(msg: str) -> None:
"""Module-local helper so the parser body reads cleanly. Just
- raises YamlSubsetError — the `claude-bottle: error: ` prefix
- is added by the boundary `die` in `claude_bottle.log`."""
+ raises YamlSubsetError — the `bot-bottle: error: ` prefix
+ is added by the boundary `die` in `bot_bottle.log`."""
raise YamlSubsetError(msg)
diff --git a/claude-bottle.demo.json b/claude-bottle.demo.json
deleted file mode 100644
index 0d7d949..0000000
--- a/claude-bottle.demo.json
+++ /dev/null
@@ -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."
- }
- }
-}
diff --git a/claude_bottle/__init__.py b/claude_bottle/__init__.py
deleted file mode 100644
index e909a27..0000000
--- a/claude_bottle/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""claude-bottle: Python implementation of the agent container launcher."""
diff --git a/cli.py b/cli.py
index 8ad8158..84e07e0 100755
--- a/cli.py
+++ b/cli.py
@@ -1,12 +1,12 @@
#!/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."""
from __future__ import annotations
import sys
-from claude_bottle.cli import main
+from bot_bottle.cli import main
if __name__ == "__main__":
sys.exit(main())
diff --git a/docs/demo.tape b/docs/demo.tape
index 2fc06f6..5a9e2ef 100644
--- a/docs/demo.tape
+++ b/docs/demo.tape
@@ -24,7 +24,7 @@ Type "clear"
Enter
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
# defaults), one git upstream (unreachable on purpose so gitleaks runs
# before the gate would forward), and a FAKE_TOKEN env var shaped like
diff --git a/docs/prds/0001-per-agent-egress-proxy-via-pipelock.md b/docs/prds/0001-per-agent-egress-proxy-via-pipelock.md
index 0f1c6cb..6258551 100644
--- a/docs/prds/0001-per-agent-egress-proxy-via-pipelock.md
+++ b/docs/prds/0001-per-agent-egress-proxy-via-pipelock.md
@@ -6,7 +6,7 @@
## Summary
-Run pipelock as a sidecar container on each claude-bottle agent's only
+Run pipelock as a sidecar container on each bot-bottle agent's only
egress route, scanning all outbound HTTP for hostname allowlist violations
and DLP matches.
@@ -95,18 +95,18 @@ The feature is **done** when all of the following ship:
### New services / components
-Two new modules under `claude_bottle/`:
+Two new modules under `bot_bottle/`:
-- **`claude_bottle/pipelock.py`** — pipelock-specific logic. Generates
+- **`bot_bottle/pipelock.py`** — pipelock-specific logic. Generates
the per-bottle YAML config from the manifest's `egress` block plus
baked-in defaults; copies the YAML into the sidecar via `docker cp`;
starts and stops the sidecar container; resolves the allowlist for
display in the preflight.
-- **`claude_bottle/network.py`** — Docker network plumbing. Creates the
- per-agent `--internal` network (named `claude-bottle-net-` with
+- **`bot_bottle/network.py`** — Docker network plumbing. Creates the
+ per-agent `--internal` network (named `bot-bottle-net-` with
the same slug-and-suffix scheme used for container names), attaches
the agent and sidecar to it, removes it on teardown. Kept separate
- from `claude_bottle/docker.py` so a future PRD can add non-pipelock
+ from `bot_bottle/docker.py` so a future PRD can add non-pipelock
network controls without entangling them with pipelock specifics.
This split mirrors the existing per-concern module pattern
@@ -114,7 +114,7 @@ This split mirrors the existing per-concern module pattern
### Existing code touched
-- **`claude_bottle/cli/start.py`** — wire the new lifecycle into the
+- **`bot_bottle/cli/start.py`** — wire the new lifecycle into the
`start` subcommand: create the internal network, launch the pipelock
sidecar, then launch the agent container with `HTTPS_PROXY` /
`HTTP_PROXY` set to the sidecar's service name. Add the resolved
@@ -129,9 +129,9 @@ This split mirrors the existing per-concern module pattern
the image. This keeps the image agnostic to whether a sidecar is in use
(useful if a future bottle definition opts out of the proxy for testing).
-`claude_bottle/docker.py` may grow one or two helpers if there is a
+`bot_bottle/docker.py` may grow one or two helpers if there is a
clean place for shared primitives, but the network-specific helpers
-live in `claude_bottle/network.py`. Decide during implementation; not a
+live in `bot_bottle/network.py`. Decide during implementation; not a
contract.
### Data model changes
@@ -176,7 +176,7 @@ bottle share the same allowlist.
- **Pipelock binary** is pulled from
`ghcr.io/luckypipewrench/pipelock@sha256:`. The digest is
- pinned in `claude_bottle/pipelock.py` (or a sibling constants module)
+ pinned in `bot_bottle/pipelock.py` (or a sibling constants module)
and bumped deliberately, mirroring the claude-code version pinning
pattern in `Dockerfile`.
- No new host-side runtimes. The pipelock image is the only new
@@ -192,8 +192,8 @@ bottle share the same allowlist.
(proxy + 48 default DLP patterns + subdomain entropy + sidecar
topology) is expected to be core-only, but this should be confirmed.
- **Where to put the digest pin.** A constant in
- `claude_bottle/pipelock.py` is the lowest-friction option; a separate
- `claude_bottle/versions.py` (or similar) may be cleaner once there
+ `bot_bottle/pipelock.py` is the lowest-friction option; a separate
+ `bot_bottle/versions.py` (or similar) may be cleaner once there
are multiple pinned dependencies. Decide during implementation.
- **Per-agent overrides.** The PRD scopes egress to the bottle. If a
later use case calls for tightening (not loosening) the allowlist for
diff --git a/docs/prds/0003-bottle-backend-abstraction.md b/docs/prds/0003-bottle-backend-abstraction.md
index 556c4cf..cca8d4d 100644
--- a/docs/prds/0003-bottle-backend-abstraction.md
+++ b/docs/prds/0003-bottle-backend-abstraction.md
@@ -14,7 +14,7 @@ second backend ships in this PRD.
## Problem
Today, "how to launch a bottle" is spread across roughly six modules
-(`claude_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`,
+(`bot_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`,
`skills.py`, `docker.py`), each shelling out to `docker` directly via
`subprocess.run(["docker", ...])`. That coupling means:
@@ -57,22 +57,22 @@ The feature works when all of the following are observable:
The feature is **done** when all of the following ship:
-- A new `claude_bottle/backend/` package exists with abstract base
+- A new `bot_bottle/backend/` package exists with abstract base
classes (`BottleBackend`, `BottlePlan`, `BottleCleanupPlan`,
- `Bottle`) plus a `claude_bottle/backend/docker/` subpackage
+ `Bottle`) plus a `bot_bottle/backend/docker/` subpackage
containing the `DockerBottleBackend` implementation.
- `DockerBottleBackend.launch(plan)` returns a context manager
- yielding a `Bottle` handle exposing `exec_claude(argv, *, tty=True)`,
+ yielding a `Bottle` handle exposing `exec_agent(argv, *, tty=True)`,
`cp_in(host, ctr)`, and teardown on context exit.
- Every existing `subprocess.run(["docker", ...])` call in
`cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and
- `skills.py` either moves into `claude_bottle/backend/docker/` or is
+ `skills.py` either moves into `bot_bottle/backend/docker/` or is
called from it. No top-level CLI code references `docker` directly.
- `bottles[].runtime` is removed from the manifest schema, the
dataclass in `manifest.py`, the example manifest, and any README /
docs references. `require_runsc()` in the old top-level
- `claude_bottle/docker.py` is deleted.
-- A single env var, `CLAUDE_BOTTLE_BACKEND` (default `"docker"`),
+ `bot_bottle/docker.py` is deleted.
+- A single env var, `BOT_BOTTLE_BACKEND` (default `"docker"`),
selects the backend. Unknown values die at startup with a list of
known backends.
- The y/N preflight in `cli.py` includes the resolved Docker runtime
@@ -97,8 +97,8 @@ The feature is **done** when all of the following ship:
### In scope
-- New `claude_bottle/backend/` package containing the abstract types
- and the registry, plus a `claude_bottle/backend/docker/` subpackage
+- New `bot_bottle/backend/` package containing the abstract types
+ and the registry, plus a `bot_bottle/backend/docker/` subpackage
containing the Docker implementation.
- The `Bottle`, `BottleBackend`, `BottlePlan`, and `BottleCleanupPlan`
abstract base classes; `BottleSpec` data carrier; and
@@ -136,10 +136,10 @@ The feature is **done** when all of the following ship:
### New services / components
-A new package, `claude_bottle/backend/`, with an abstract base layer
+A new package, `bot_bottle/backend/`, with an abstract base layer
and a Docker subpackage:
-- **`claude_bottle/backend/__init__.py`** — Defines the abstract base
+- **`bot_bottle/backend/__init__.py`** — Defines the abstract base
classes and the backend registry. `BottleSpec` carries the
CLI-supplied intent; the abstract `BottlePlan` and
`BottleCleanupPlan` are the prepared-but-not-launched outputs of
@@ -165,14 +165,14 @@ and a Docker subpackage:
`provision_git`); subclasses implement those four rather than
overriding `provision` itself.
- Selection reads `CLAUDE_BOTTLE_BACKEND` (default `"docker"`).
+ Selection reads `BOT_BOTTLE_BACKEND` (default `"docker"`).
Unknown values call `die()` with the list of known backends:
```python
def get_bottle_backend() -> BottleBackend: ...
```
-- **`claude_bottle/backend/docker/`** — Subpackage with the Docker
+- **`bot_bottle/backend/docker/`** — Subpackage with the Docker
implementation, split into:
- `backend.py` — `DockerBottleBackend`, owning all five abstract
methods (`prepare`, `launch`, `prepare_cleanup`, `cleanup`,
@@ -196,49 +196,49 @@ and a Docker subpackage:
- `pipelock.py` — `DockerPipelockProxy` (the sidecar start/stop
lifecycle) and Docker-specific naming helpers. The backend-neutral
yaml + allowlist resolution stays in the top-level
- `claude_bottle/pipelock.py`.
+ `bot_bottle/pipelock.py`.
- `util.py` — Docker-specific helpers (slugify, image/container
existence checks, `runsc_available`).
### Existing code touched
-- **`claude_bottle/cli/start.py`** — replace the inline docker
+- **`bot_bottle/cli/start.py`** — replace the inline docker
orchestration with `backend = get_bottle_backend(); plan =
backend.prepare(spec, stage_dir=...); with backend.launch(plan) as
- bottle: bottle.exec_claude(...)`. The y/N preflight is rendered by
+ bottle: bottle.exec_agent(...)`. The y/N preflight is rendered by
`plan.print(...)`.
-- **`claude_bottle/manifest.py`** — drop the `runtime` field from the
+- **`bot_bottle/manifest.py`** — drop the `runtime` field from the
Bottle dataclass and its validation. Existing manifests with
`runtime: "runsc"` produce a clear "no longer supported; gVisor is
now auto-detected by the backend; remove the 'runtime' field" error.
-- **`claude_bottle/docker.py`** — module deleted. `require_runsc()`,
+- **`bot_bottle/docker.py`** — module deleted. `require_runsc()`,
`slugify()`, `image_exists()`, `container_exists()`, the
`build_image` / `build_image_with_cwd` helpers, and `require_docker`
- all migrate into `claude_bottle/backend/docker/util.py` (or
+ all migrate into `bot_bottle/backend/docker/util.py` (or
`backend.py`).
-- **`claude_bottle/pipelock.py`** — keeps the allowlist resolution and
+- **`bot_bottle/pipelock.py`** — keeps the allowlist resolution and
YAML generation. Becomes a thin abstract class (`PipelockProxy`)
exposing `prepare` (writes the yaml) plus abstract `start` / `stop`
methods. The Docker-specific subclass `DockerPipelockProxy` lives
under `backend/docker/pipelock.py`.
-- **`claude_bottle/network.py`** — folds entirely into
+- **`bot_bottle/network.py`** — folds entirely into
`backend/docker/network.py`. No top-level network module remains.
-- **`claude_bottle/ssh.py`** and **`claude_bottle/skills.py`** —
+- **`bot_bottle/ssh.py`** and **`bot_bottle/skills.py`** —
absorbed into `DockerBottleBackend` as `provision_ssh` and
`provision_skills`. The host-side file-tree generation stays as
private helpers on the backend class.
-- **`claude_bottle/env.py`** (renamed from `env_resolve.py`) —
+- **`bot_bottle/env.py`** (renamed from `env_resolve.py`) —
`resolve_env(manifest, agent) -> ResolvedEnv` returns
`forwarded: list[str]` (names whose values were exported into
`os.environ` for inheritance) and `literals: dict[str, str]` (name
→ verbatim value). The Docker backend translates the result into
`--env-file` content + `-e NAME` argv fragments.
-- **`claude_bottle/util.py`** — top-level cross-backend helpers
+- **`bot_bottle/util.py`** — top-level cross-backend helpers
(`expand_tilde`, `is_ipv4_literal`). Backend-specific helpers live
in their backend's `util.py`.
-- **`claude-bottle.example.json`** — remove the `runtime` field from
+- **`bot-bottle.example.json`** — remove the `runtime` field from
any example bottle.
-- **`README.md`** — note `CLAUDE_BOTTLE_BACKEND` and the runsc
+- **`README.md`** — note `BOT_BOTTLE_BACKEND` and the runsc
auto-detect; remove any mention of `runtime: "runsc"` as a manifest
field.
diff --git a/docs/prds/0004-split-out-provisioners.md b/docs/prds/0004-split-out-provisioners.md
index 38bf4f5..1378c07 100644
--- a/docs/prds/0004-split-out-provisioners.md
+++ b/docs/prds/0004-split-out-provisioners.md
@@ -6,12 +6,12 @@
## Summary
-Break `claude_bottle/backend/docker/backend.py` (664 lines) apart by
+Break `bot_bottle/backend/docker/backend.py` (664 lines) apart by
moving the four provisioner methods — `provision_prompt`,
`provision_skills`, `provision_ssh`, `provision_git` — out of
`DockerBottleBackend` into their own modules under
-`claude_bottle/backend/docker/provision/`. The abstract base in
-`claude_bottle/backend/__init__.py` keeps the same four-method
+`bot_bottle/backend/docker/provision/`. The abstract base in
+`bot_bottle/backend/__init__.py` keeps the same four-method
contract; only the Docker implementation changes shape.
## Problem
@@ -56,7 +56,7 @@ The feature works when all of the following are observable:
The feature is **done** when all of the following ship:
-- A new `claude_bottle/backend/docker/provision/` subpackage exists
+- A new `bot_bottle/backend/docker/provision/` subpackage exists
with one module per provisioner: `prompt.py`, `skills.py`, `ssh.py`,
`git.py`. Each exports a single top-level function taking
`(plan: DockerBottlePlan, target: str)` and returning the same type
@@ -66,7 +66,7 @@ The feature is **done** when all of the following ship:
`provision_ssh` / `provision_git` each become one-line delegations
to the new module functions.
- The abstract `BottleBackend.provision_*` signatures in
- `claude_bottle/backend/__init__.py` are unchanged. The
+ `bot_bottle/backend/__init__.py` are unchanged. The
`BottleBackend.provision` orchestration in the base class is
unchanged.
- No top-level CLI code or other backend gains a direct import of the
@@ -99,7 +99,7 @@ The feature is **done** when all of the following ship:
### In scope
-- New `claude_bottle/backend/docker/provision/` subpackage with
+- New `bot_bottle/backend/docker/provision/` subpackage with
`__init__.py`, `prompt.py`, `skills.py`, `ssh.py`, `git.py`.
- Moving the four method bodies out of
`DockerBottleBackend` into the new modules verbatim, adjusting only
@@ -132,7 +132,7 @@ The feature is **done** when all of the following ship:
### New layout
```
-claude_bottle/backend/docker/
+bot_bottle/backend/docker/
backend.py # DockerBottleBackend (slimmer)
bottle.py
bottle_plan.py
@@ -199,13 +199,13 @@ take the concrete type and skip re-checking.
### Existing code touched
-- **`claude_bottle/backend/docker/backend.py`** — four method
+- **`bot_bottle/backend/docker/backend.py`** — four method
bodies move out; method definitions stay as one-line delegations.
Imports for `pipelock_proxy_host_port`, `expand_tilde`, etc., that
are only used by the moved bodies migrate with them.
-- **`claude_bottle/backend/docker/__init__.py`** — no change. The
+- **`bot_bottle/backend/docker/__init__.py`** — no change. The
public surface (`DockerBottleBackend`) is unchanged.
-- **`claude_bottle/backend/__init__.py`** — no change.
+- **`bot_bottle/backend/__init__.py`** — no change.
- **`tests/`** — no expected change. Existing tests exercise the
backend via `DockerBottleBackend` or the CLI surface; they don't
reach into provisioners directly. Verify after the move and only
diff --git a/docs/prds/0006-pipelock-tls-interception.md b/docs/prds/0006-pipelock-tls-interception.md
index d416252..6b1b17b 100644
--- a/docs/prds/0006-pipelock-tls-interception.md
+++ b/docs/prds/0006-pipelock-tls-interception.md
@@ -75,7 +75,7 @@ The feature is **done** when all of the following ship:
sidecar (read-only) so the running pipelock can read its CA.
- `BottleBackend.provision_ca` (new) copies the CA public cert
into the agent at
- `/usr/local/share/ca-certificates/claude-bottle-mitm.crt`, runs
+ `/usr/local/share/ca-certificates/bot-bottle-mitm.crt`, runs
`update-ca-certificates`, and sets the `NODE_EXTRA_CA_CERTS` /
`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` env trio on the agent
container's runtime env. Default no-op on the abstract base so
@@ -122,14 +122,14 @@ The feature is **done** when all of the following ship:
### In scope
-- **`claude_bottle/pipelock.py`** changes:
+- **`bot_bottle/pipelock.py`** changes:
- Extend `pipelock_build_config` to include
`tls_interception: { enabled: true, ca_cert: , ca_key:
}`. Paths are populated from the plan; the function's
signature grows a `cert_path` / `key_path` pair or reads them
off `Bottle` once they're stored.
- Extend `pipelock_render_yaml` to emit the new block.
-- **`claude_bottle/backend/docker/pipelock.py`** changes:
+- **`bot_bottle/backend/docker/pipelock.py`** changes:
- New helper `pipelock_tls_init(stage_dir)` runs the upstream
image as a one-shot:
`docker run --rm -v :/h -e PIPELOCK_HOME=/h pipelock tls init`,
@@ -143,31 +143,31 @@ The feature is **done** when all of the following ship:
config. If pipelock's image runs as non-root, a `docker exec
-u 0 chown pipelock:pipelock /etc/pipelock/ca*.pem` lands
between the `cp` and the `start`.
-- **`claude_bottle/backend/__init__.py`**: new abstract method
+- **`bot_bottle/backend/__init__.py`**: new abstract method
`provision_ca(plan, target)` on `BottleBackend`, default no-op.
`BottleBackend.provision` orchestrates `ca → prompt → skills →
ssh → git`.
-- **`claude_bottle/backend/docker/provision/ca.py`** (new):
+- **`bot_bottle/backend/docker/provision/ca.py`** (new):
- Reads the cert from `stage_dir` (already written by prepare).
- `docker cp` into the agent.
- `docker exec -u 0 ... chmod 644 ...` + `update-ca-certificates`.
- Computes the SHA-256 fingerprint with stdlib (`ssl` +
`hashlib`), emits one stderr log line.
-- **`claude_bottle/backend/docker/launch.py`**:
+- **`bot_bottle/backend/docker/launch.py`**:
- Three new `-e` flags on the agent's `docker run`:
- `NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/claude-bottle-mitm.crt`,
+ `NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm.crt`,
`SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`,
`REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`.
- `HTTPS_PROXY` / `HTTP_PROXY` continue to point at pipelock
(unchanged from PRD 0001 — the mitmproxy detour in PR #8 is
abandoned).
-- **`claude_bottle/backend/docker/bottle_plan.py`**:
+- **`bot_bottle/backend/docker/bottle_plan.py`**:
- One new `info(...)` line in `print()` noting TLS interception
is on.
- `to_dict()` gains an `egress.tls_interception: { enabled:
true, ca_fingerprint: null }` block. Reserved for future
population.
-- **`claude_bottle/backend/docker/prepare.py`**: call
+- **`bot_bottle/backend/docker/prepare.py`**: call
`pipelock_tls_init(stage_dir)` and write the resolved cert/key
paths onto the plan (either on the existing `proxy_plan` field
or on the parent `DockerBottlePlan`).
@@ -221,7 +221,7 @@ generated at prepare time.
the one-shot generation step. The rendered YAML references
the in-container paths.
- **Bottle install.** `provision_ca` (Docker impl) does
- `docker cp /ca.pem agent:/usr/local/share/ca-certificates/claude-bottle-mitm.crt`,
+ `docker cp /ca.pem agent:/usr/local/share/ca-certificates/bot-bottle-mitm.crt`,
then `update-ca-certificates`. The CA env trio is set at
`docker run -e` time (Docker propagates run-time env into
`docker exec`).
@@ -235,7 +235,7 @@ generated at prepare time.
`stage_dir`. CA dies with both, in that order, so the sidecar
is never reading a deleted mount on shutdown.
- **Fingerprint.** Computed via stdlib in `provision_ca` and
- logged once to stderr (`claude-bottle: mitm ca fingerprint:
+ logged once to stderr (`bot-bottle: mitm ca fingerprint:
sha256:…`). The private key never appears in any log.
### Data model changes
@@ -248,18 +248,18 @@ always null at dry-run because the CA doesn't exist yet.
Surgical, all on the existing pipelock path:
-- `claude_bottle/pipelock.py` — config builder + YAML renderer.
-- `claude_bottle/backend/__init__.py` — abstract `provision_ca`.
-- `claude_bottle/backend/docker/pipelock.py` — `tls init` helper,
+- `bot_bottle/pipelock.py` — config builder + YAML renderer.
+- `bot_bottle/backend/__init__.py` — abstract `provision_ca`.
+- `bot_bottle/backend/docker/pipelock.py` — `tls init` helper,
sidecar volume mount.
-- `claude_bottle/backend/docker/prepare.py` — CA paths on plan.
-- `claude_bottle/backend/docker/launch.py` — CA env trio on agent.
-- `claude_bottle/backend/docker/backend.py` — `provision_ca`
+- `bot_bottle/backend/docker/prepare.py` — CA paths on plan.
+- `bot_bottle/backend/docker/launch.py` — CA env trio on agent.
+- `bot_bottle/backend/docker/backend.py` — `provision_ca`
dispatch + thread `self._proxy` through prepare/launch unchanged
shape.
-- `claude_bottle/backend/docker/bottle_plan.py` — preflight
+- `bot_bottle/backend/docker/bottle_plan.py` — preflight
rendering.
-- `claude_bottle/backend/docker/provision/ca.py` (new).
+- `bot_bottle/backend/docker/provision/ca.py` (new).
Net diff is meaningfully smaller than PR #8 because pipelock
already does the work — no addon, no second sidecar, no second
diff --git a/docs/prds/0007-ssh-egress-gate.md b/docs/prds/0007-ssh-egress-gate.md
index f23f428..4e8dc65 100644
--- a/docs/prds/0007-ssh-egress-gate.md
+++ b/docs/prds/0007-ssh-egress-gate.md
@@ -95,14 +95,14 @@ back to green is the test.
Mirror the pipelock layout:
-- **`claude_bottle/ssh_gate.py`** (new): abstract `SSHGate` +
+- **`bot_bottle/ssh_gate.py`** (new): abstract `SSHGate` +
`SSHGatePlan` dataclass. `prepare` is host-side / side-effect-free
on docker; renders the forwarder config under `stage_dir`.
-- **`claude_bottle/backend/docker/ssh_gate.py`** (new):
+- **`bot_bottle/backend/docker/ssh_gate.py`** (new):
`DockerSSHGate` concrete subclass — `start` does `docker create`
on the internal network, copies the config in, attaches the
egress network, `docker start`. `stop` is idempotent `docker rm
- -f`. Container name: `claude-bottle-ssh-gate-`.
+ -f`. Container name: `bot-bottle-ssh-gate-`.
Forwarder image: `alpine/socat`, pinned by digest. Must be
self-sufficient at boot (no apk/apt pulls on first run) because
@@ -126,7 +126,7 @@ rejected at prepare time. One container, N listeners, N upstreams.
### Existing code touched
-- **`claude_bottle/backend/docker/provision/ssh.py`**: drop the
+- **`bot_bottle/backend/docker/provision/ssh.py`**: drop the
`ProxyCommand socat - PROXY:...` plumbing and the
`pipelock_proxy_host_port` import. The rendered `~/.ssh/config`
block per entry becomes:
@@ -140,19 +140,19 @@ rejected at prepare time. One container, N listeners, N upstreams.
`known_hosts` entries are keyed off `` and the new
`[]:` form so OpenSSH's strict
host-key checking still matches.
-- **`claude_bottle/pipelock.py`**: delete
+- **`bot_bottle/pipelock.py`**: delete
`pipelock_bottle_ssh_hostnames`, `pipelock_bottle_ssh_trusted_domains`,
`pipelock_bottle_ssh_ip_cidrs`, and the calls into them from
`pipelock_effective_allowlist` and `pipelock_build_config`. The
effective allowlist becomes baked-defaults ∪ `bottle.egress.allowlist`.
-- **`claude_bottle/backend/docker/backend.py`**: instantiate
+- **`bot_bottle/backend/docker/backend.py`**: instantiate
`DockerSSHGate` alongside `DockerPipelockProxy`; thread its
`prepare` / `start` / `stop` through `resolve_plan` / `launch`.
-- **`claude_bottle/backend/docker/launch.py`**: add gate start /
+- **`bot_bottle/backend/docker/launch.py`**: add gate start /
stop to the `ExitStack` in the right order — gate must be up
before `provision_ssh` runs so the agent can dial it on first
boot.
-- **`claude_bottle/backend/docker/bottle_plan.py`**: new
+- **`bot_bottle/backend/docker/bottle_plan.py`**: new
`SSHGatePlan` field on `DockerBottlePlan`; preflight rendering
surfaces the gate sidecar (name, per-entry listen ports,
upstream `Hostname:Port` targets).
@@ -165,7 +165,7 @@ rejected at prepare time. One container, N listeners, N upstreams.
### Data model changes
None. `bottle.ssh` schema is unchanged; one new internal plan
-dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
+dataclass (`SSHGatePlan`) under `bot_bottle/ssh_gate.py`.
### External dependencies
@@ -202,7 +202,7 @@ dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
- PRD 0006: pipelock native TLS interception — the change that
surfaced this regression by making pipelock incompatible with
SSH-over-CONNECT.
-- `claude_bottle/backend/docker/provision/ssh.py` — current SSH
+- `bot_bottle/backend/docker/provision/ssh.py` — current SSH
provisioning that this PRD rewrites.
-- `claude_bottle/pipelock.py` — current pipelock config builder
+- `bot_bottle/pipelock.py` — current pipelock config builder
that gains the `bottle.ssh`-derived fields this PRD removes.
diff --git a/docs/prds/0008-git-gate.md b/docs/prds/0008-git-gate.md
index d24e316..77d7230 100644
--- a/docs/prds/0008-git-gate.md
+++ b/docs/prds/0008-git-gate.md
@@ -26,7 +26,7 @@ entry and pushes straight at gitea/github with ssh-gate doing dumb
L4 forwarding. There is no boundary between "the agent thinks this
commit is fine" and "the secret hits an external remote." If a
compromised or careless agent stages a `.env`, slips a token into
-a fixture, or commits the `CLAUDE_BOTTLE_OAUTH_TOKEN` itself, `git
+a fixture, or commits the `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` itself, `git
push` ships it.
Host-side pre-commit / pre-push hooks are the usual defense, but
@@ -131,16 +131,16 @@ for a declared upstream:
Mirror the existing sidecar layout:
-- **`claude_bottle/git_gate.py`** (new): abstract `GitGate` +
+- **`bot_bottle/git_gate.py`** (new): abstract `GitGate` +
`GitGatePlan` dataclass. `prepare` is host-side / side-effect-
free on docker; renders the per-upstream config and stages the
push credentials under `stage_dir`.
-- **`claude_bottle/backend/docker/git_gate.py`** (new):
+- **`bot_bottle/backend/docker/git_gate.py`** (new):
`DockerGitGate` concrete subclass. `start` does `docker create`
on the internal network, copies in the bare-repo skeleton, the
hook script, and per-upstream credentials, then `docker start`.
`stop` is idempotent `docker rm -f`. Container name:
- `claude-bottle-git-gate-`.
+ `bot-bottle-git-gate-`.
Gate image: `git-daemon` + `openssh-client` over a
`zricethezav/gitleaks` base (alpine + gitleaks), pinned by digest.
@@ -173,21 +173,21 @@ operation.
### Existing code touched
-- **`claude_bottle/manifest.py`**: parse and validate the new
+- **`bot_bottle/manifest.py`**: parse and validate the new
`bottle.git` block; reject `bottle.ssh` entries whose upstream
is also claimed by a `bottle.git` upstream (one path per
remote, no shadow route).
-- **`claude_bottle/backend/docker/provision/git.py`** (new) or an
+- **`bot_bottle/backend/docker/provision/git.py`** (new) or an
extension of the ssh provisioner: render the `insteadOf` config
and any extra `~/.gitconfig` plumbing.
-- **`claude_bottle/backend/docker/backend.py`**: instantiate
+- **`bot_bottle/backend/docker/backend.py`**: instantiate
`DockerGitGate` alongside `DockerPipelockProxy` and
`DockerSSHGate`; thread its `prepare` / `start` / `stop`
through `resolve_plan` / `launch`.
-- **`claude_bottle/backend/docker/launch.py`**: add gate start /
+- **`bot_bottle/backend/docker/launch.py`**: add gate start /
stop to the `ExitStack` so the gate is up before any
provisioner that writes the agent's `~/.gitconfig`.
-- **`claude_bottle/backend/docker/bottle_plan.py`**: new
+- **`bot_bottle/backend/docker/bottle_plan.py`**: new
`GitGatePlan` field on `DockerBottlePlan`; preflight rendering
surfaces the gate sidecar (name, per-upstream local paths,
upstream real URLs, which credential is in use).
@@ -249,6 +249,6 @@ exposes it as, and the credential the gate uses to push upstream
- PRD 0007: SSH egress gate — the L4 SSH forwarder this PRD
sits alongside; explicitly *not* the place to add
git-protocol awareness.
-- `claude_bottle/ssh_gate.py` / `claude_bottle/pipelock.py` —
+- `bot_bottle/ssh_gate.py` / `bot_bottle/pipelock.py` —
existing sidecar abstractions to mirror.
- gitleaks:
diff --git a/docs/prds/0009-remove-ssh-gate.md b/docs/prds/0009-remove-ssh-gate.md
index a85052e..8bb98ec 100644
--- a/docs/prds/0009-remove-ssh-gate.md
+++ b/docs/prds/0009-remove-ssh-gate.md
@@ -8,7 +8,7 @@
Delete the ssh-gate sidecar and the `bottle.ssh` manifest field.
Git-gate (PRD 0008) covers every current SSH use case in
-claude-bottle: each declared upstream gets a per-bottle gate
+bot-bottle: each declared upstream gets a per-bottle gate
with gitleaks scanning, an `insteadOf` rewrite that captures
push / fetch / clone / pull / ls-remote, and credential
isolation from the agent. ssh-gate is now redundant L4
@@ -76,11 +76,11 @@ the unused path.
`_validate_no_shadow_route`. Add an explicit branch in
`Bottle.from_dict` that dies on a `ssh` key with a one-line
"move this to `bottle.git` (see PRD 0008)" hint.
-- **Sidecar.** Delete `claude_bottle/ssh_gate.py` and
- `claude_bottle/backend/docker/ssh_gate.py`. Drop the socat
+- **Sidecar.** Delete `bot_bottle/ssh_gate.py` and
+ `bot_bottle/backend/docker/ssh_gate.py`. Drop the socat
image build path.
- **Provisioner.** Delete
- `claude_bottle/backend/docker/provision/ssh.py` and its
+ `bot_bottle/backend/docker/provision/ssh.py` and its
`~/.ssh/config` render.
- **Docker backend wiring.** Drop `DockerSSHGate` from
`backend.py`; drop its start / stop from `launch.py`'s
@@ -98,7 +98,7 @@ the unused path.
- **README.** Drop the socat / ssh image box from the
architecture diagram and its bullet; drop `ssh:` from the
manifest example.
-- **Example manifest.** Drop `ssh:` from `claude-bottle.example.json`.
+- **Example manifest.** Drop `ssh:` from `bot-bottle.example.json`.
- **PRD 0007.** Add a `Status: Superseded by PRD 0009` header
at the top of the document. Do not delete the file; the
history of intent matters for the audit trail.
@@ -138,19 +138,19 @@ the seams between ssh-gate and the rest of the system:
### Existing code touched
-- `claude_bottle/manifest.py` — delete `SshEntry`,
+- `bot_bottle/manifest.py` — delete `SshEntry`,
`Bottle.ssh`, `_validate_no_shadow_route`; add the
parse-fail branch.
-- `claude_bottle/ssh_gate.py` — delete.
-- `claude_bottle/backend/docker/ssh_gate.py` — delete.
-- `claude_bottle/backend/docker/provision/ssh.py` — delete.
-- `claude_bottle/backend/docker/backend.py` — drop
+- `bot_bottle/ssh_gate.py` — delete.
+- `bot_bottle/backend/docker/ssh_gate.py` — delete.
+- `bot_bottle/backend/docker/provision/ssh.py` — delete.
+- `bot_bottle/backend/docker/backend.py` — drop
`DockerSSHGate` instantiation.
-- `claude_bottle/backend/docker/launch.py` — drop the
+- `bot_bottle/backend/docker/launch.py` — drop the
ssh-gate start / stop from the `ExitStack`.
-- `claude_bottle/backend/docker/bottle_plan.py` — drop the
+- `bot_bottle/backend/docker/bottle_plan.py` — drop the
ssh-gate plan field.
-- `claude_bottle/pipelock.py` — drop the `bottle.ssh`-derived
+- `bot_bottle/pipelock.py` — drop the `bottle.ssh`-derived
branch in the allowlist render.
- `tests/unit/test_ssh_gate.py` — delete.
- `tests/integration/` — delete any ssh-gate-specific tests.
@@ -160,7 +160,7 @@ the seams between ssh-gate and the rest of the system:
helper.
- `README.md` — drop the socat image box from the diagram and
the matching bullet; drop `ssh:` from the manifest example.
-- `claude-bottle.example.json` — drop the `ssh` field.
+- `bot-bottle.example.json` — drop the `ssh` field.
- `docs/prds/0007-ssh-egress-gate.md` — add a
`Status: Superseded by PRD 0009` header at the top.
@@ -173,7 +173,7 @@ the seams between ssh-gate and the rest of the system:
### External dependencies
Nothing added. The `alpine/socat` image is no longer pulled
-by claude-bottle; the cleanup of any existing local image is
+by bot-bottle; the cleanup of any existing local image is
the user's choice (a single `docker image rm` if they care).
## Future work
diff --git a/docs/prds/0010-cred-proxy.md b/docs/prds/0010-cred-proxy.md
index 7716519..145a066 100644
--- a/docs/prds/0010-cred-proxy.md
+++ b/docs/prds/0010-cred-proxy.md
@@ -51,7 +51,7 @@ already rely on.
The research note
[`agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md)
surveys the existing tools and concludes that a small
-claude-bottle-specific reverse proxy is less work and less risk
+bot-bottle-specific reverse proxy is less work and less risk
than either adopting nono (alpha, unaudited) or Infisical Agent
Vault (TLS-MITM topology that doubles up on pipelock's CA stack).
This PRD is the build.
@@ -118,7 +118,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
- **Cross-bottle credential sharing.** One proxy per bottle, same
one-sidecar-per-agent posture as pipelock and git-gate.
- **`claude --bare` mode.** Reads only `ANTHROPIC_API_KEY`, not
- the OAuth token. Not in claude-bottle's flow today.
+ the OAuth token. Not in bot-bottle's flow today.
- **MCP-server tokens, package-installer tokens for languages
beyond npm.** PyPI / Bun / cargo can land in a follow-up if
needed; the routing pattern generalizes.
@@ -175,7 +175,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
side-effect-free; `start` does `docker create` + `docker start`
on the bottle's internal network with hostname `cred-proxy`;
`stop` is idempotent `docker rm -f`. Container name:
- `claude-bottle-cred-proxy-`. The agent container starts
+ `bot-bottle-cred-proxy-`. The agent container starts
after the sidecar is up so DNS resolution succeeds on the
agent's first call.
- **pipelock interop.** cred-proxy's outbound HTTPS traverses
@@ -230,7 +230,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
```
┌── Host (macOS) ──────────────────────────────────────────────────┐
│ Secrets at rest (keychain / .env): │
-│ CLAUDE_BOTTLE_OAUTH_TOKEN, GITHUB_TOKEN, │
+│ BOT_BOTTLE_CLAUDE_OAUTH_TOKEN, GITHUB_TOKEN, │
│ GITEA_SERVER_TOKEN, NPM_TOKEN │
│ │ docker run -e KEY (no =VALUE on argv) │
│ ▼ │
@@ -288,18 +288,18 @@ Why the agent can't reach the sidecar's environ:
### New components
-- **`claude_bottle/cred_proxy.py`** (new): abstract `CredProxy`
+- **`bot_bottle/cred_proxy.py`** (new): abstract `CredProxy`
+ `CredProxyPlan` dataclass. `prepare` is host-side and
side-effect-free; renders the route table and resolves
`TokenRef`s against host env. Mirrors the existing `GitGate` /
`Pipelock` shape.
-- **`claude_bottle/backend/docker/cred_proxy.py`** (new):
+- **`bot_bottle/backend/docker/cred_proxy.py`** (new):
`DockerCredProxy` concrete subclass. `start` does
`docker create` on the bottle's internal network with hostname
`cred-proxy`, copies the route-table file into the container,
then `docker start`. `stop` is idempotent `docker rm -f`.
- Container name: `claude-bottle-cred-proxy-`.
-- **`claude_bottle/backend/docker/provision/cred_proxy.py`**
+ Container name: `bot-bottle-cred-proxy-`.
+- **`bot_bottle/backend/docker/provision/cred_proxy.py`**
(new): renders `ANTHROPIC_BASE_URL`, `~/.npmrc`,
`~/.gitconfig` `insteadOf` blocks, and `~/.config/tea/config.yml`
into the agent's home for each declared kind — all pointing at
@@ -310,12 +310,12 @@ Why the agent can't reach the sidecar's environ:
### Existing code touched
-- **`claude_bottle/manifest.py`** — add `CredProxyRoute`,
+- **`bot_bottle/manifest.py`** — add `CredProxyRoute`,
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
+ validate route shape, role enum, path uniqueness, singleton-
role constraints.
-- **`claude_bottle/backend/docker/prepare.py`** — drop the
- legacy `CLAUDE_BOTTLE_OAUTH_TOKEN` → `CLAUDE_CODE_OAUTH_TOKEN`
+- **`bot_bottle/backend/docker/prepare.py`** — drop the
+ legacy `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` → `CLAUDE_CODE_OAUTH_TOKEN`
forward entirely. cred-proxy is the only path the Anthropic
OAuth token reaches the bottle. When a route claims the
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL`
@@ -324,27 +324,27 @@ Why the agent can't reach the sidecar's environ:
otherwise; the proxy strips & replaces on every request).
Bottles that need claude-code to authenticate must declare
the route; there is no fallback.
-- **`claude_bottle/backend/docker/backend.py`** — instantiate
+- **`bot_bottle/backend/docker/backend.py`** — instantiate
`DockerCredProxy` alongside `DockerPipelockProxy` and
`DockerGitGate`; thread its `prepare` / `start` / `stop`
through `resolve_plan` / `launch`.
-- **`claude_bottle/backend/docker/launch.py`** — add cred-proxy
+- **`bot_bottle/backend/docker/launch.py`** — add cred-proxy
start/stop to the `ExitStack` after pipelock and before the
agent; populate `pipelock_proxy_url` + `pipelock_ca_host_path`
on the cred-proxy plan so its outbound HTTPS routes through
pipelock.
-- **`claude_bottle/backend/docker/bottle_plan.py`** — new
+- **`bot_bottle/backend/docker/bottle_plan.py`** — new
`cred_proxy_plan` field; preflight shows route count + token
refs + a path→upstream line per route; `to_dict` emits a
`cred_proxy` array of `{path, upstream, auth_scheme, token_ref,
roles}`.
-- **`claude_bottle/pipelock.py`** — `pipelock_token_hosts` derives
+- **`bot_bottle/pipelock.py`** — `pipelock_token_hosts` derives
from each route's `UpstreamHost` (not a hardcoded Kind→hosts
map). Allowlist auto-includes them; passthrough does not (the
proxy trusts pipelock's CA so MITM works).
- **`README.md`** — architecture diagram includes the cred-proxy
lane; manifest section documents `bottle.cred_proxy.routes`.
-- **`claude-bottle.example.json`** — one bottle demonstrates the
+- **`bot-bottle.example.json`** — one bottle demonstrates the
four common routes (Anthropic, GitHub, Gitea, npm).
- **Tests** — manifest parsing/validation, route lift + token-env
slot assignment, role-based dispatch in the provisioner,
diff --git a/docs/prds/0011-per-file-md-manifest.md b/docs/prds/0011-per-file-md-manifest.md
index 813ac0c..afc3f90 100644
--- a/docs/prds/0011-per-file-md-manifest.md
+++ b/docs/prds/0011-per-file-md-manifest.md
@@ -6,11 +6,11 @@
## Summary
-Replace the single-file `claude-bottle.json` manifest with a
+Replace the single-file `bot-bottle.json` manifest with a
per-file Markdown-with-YAML-frontmatter layout. Bottles live as
-`$HOME/.claude-bottle/bottles/.md`; agents live as
-`$HOME/.claude-bottle/agents/.md` (home-resident) and
-`$CWD/.claude-bottle/agents/.md` (repo-supplied). Each file
+`$HOME/.bot-bottle/bottles/.md`; agents live as
+`$HOME/.bot-bottle/agents/.md` (home-resident) and
+`$CWD/.bot-bottle/agents/.md` (repo-supplied). Each file
carries its structured config in YAML frontmatter and (for agents)
its system prompt in the Markdown body.
@@ -28,7 +28,7 @@ PyYAML dependency. The project's "low deps by default" stance
## Problem
-`claude-bottle.json` works fine at one bottle and one agent. The
+`bot-bottle.json` works fine at one bottle and one agent. The
project is heading for many of both, and the single-JSON shape
starts to fray:
@@ -60,22 +60,22 @@ axes (grouping × format) and lands on this design.
Each test runs against a temporary `$HOME` and a temporary `$CWD`:
-1. **A bottle file under `$HOME/.claude-bottle/bottles/`
+1. **A bottle file under `$HOME/.bot-bottle/bottles/`
parses.** A `dev.md` file with YAML frontmatter declaring
`cred_proxy.routes`, `git`, `env`, `egress` produces a Bottle
dataclass equivalent to the current JSON shape.
-2. **An agent file under `$HOME/.claude-bottle/agents/` parses.**
+2. **An agent file under `$HOME/.bot-bottle/agents/` parses.**
`implementer.md` with frontmatter that names `bottle:`,
`skills:`, and other fields, with the body as the system
prompt, produces an Agent dataclass.
-3. **An agent file under `$CWD/.claude-bottle/agents/` parses
+3. **An agent file under `$CWD/.bot-bottle/agents/` parses
and overrides home-resident agents of the same name.** The
cwd agent's frontmatter and body win; the home bottle it
references stays intact.
-4. **A bottle file under `$CWD/.claude-bottle/bottles/` is
+4. **A bottle file under `$CWD/.bot-bottle/bottles/` is
ignored.** The directory does not contribute to the
manifest; if a user accidentally creates one, the launcher
emits a `warn`-level log naming the offending files and
@@ -83,7 +83,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
is a usability nicety, not a security gate.
5. **No third-party Python dependencies introduced.** A fresh
- clone with only stdlib + claude-bottle's own code runs every
+ clone with only stdlib + bot-bottle's own code runs every
parser test. Frontmatter parsing is hand-rolled against the
declared YAML subset.
@@ -97,30 +97,30 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
`name`, `description`, `model`, `color`, and `memory` fields
from Claude Code's existing subagent spec are accepted in
our frontmatter alongside our own fields. Copying an agent
- file from `$HOME/.claude-bottle/agents/` to
+ file from `$HOME/.bot-bottle/agents/` to
`~/.claude/agents/` produces a working Claude Code subagent
(subject to Claude Code's tolerance for the extra `bottle:`
- and `claude_bottle:` fields — see Open Questions).
+ and `bot_bottle:` fields — see Open Questions).
## Non-goals
- **A general YAML implementation.** The parser handles the
- subset claude-bottle's frontmatter actually uses; documents
+ subset bot-bottle's frontmatter actually uses; documents
that exceed the subset (anchors, multi-line block scalars,
tags, implicit type coercion, flow style, etc.) die with a
pointer at the spec. We are not building a YAML library.
- **Compatibility with the old JSON layout at runtime.** The
- resolver no longer reads `claude-bottle.json` files. This is
+ resolver no longer reads `bot-bottle.json` files. This is
a breaking change; existing users hand-rewrite their JSON
- into the new per-file layout (claude-bottle has a single
+ into the new per-file layout (bot-bottle has a single
primary user today, so the migration is one person rewriting
one file). Documented as part of the README rewrite.
- **`$HOME/.claude/agents/` integration on the input side.** We
don't read agent files out of Claude Code's directory. Our
files can be copied into Claude Code's tree by the user if
- they want, but the input path for claude-bottle is its own
+ they want, but the input path for bot-bottle is its own
directory.
- **A signed-manifest scheme.** Out of scope per the
@@ -139,14 +139,14 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
### In scope
- **Directory layout.**
- - `$HOME/.claude-bottle/bottles/.md` — bottle
+ - `$HOME/.bot-bottle/bottles/.md` — bottle
definitions (full schema; one Bottle per file).
- - `$HOME/.claude-bottle/agents/.md` — home-resident
+ - `$HOME/.bot-bottle/agents/.md` — home-resident
agents.
- - `$CWD/.claude-bottle/agents/.md` — cwd-resident
+ - `$CWD/.bot-bottle/agents/.md` — cwd-resident
agents; same schema as home agents, but bottle names must
resolve against the home set.
- - `$CWD/.claude-bottle/bottles/` — ignored with a warn-level
+ - `$CWD/.bot-bottle/bottles/` — ignored with a warn-level
log (see SC #4). Does not contribute to the manifest.
- `` is the file basename without `.md`. Filenames must
match `[a-z][a-z0-9-]*` (kebab-case, ASCII-only).
@@ -162,7 +162,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
- `skills: [, ...]` (optional) — host-side skills under
`~/.claude/skills/`.
- `name`, `description`, `model`, `color`, `memory` — accepted
- but treated as Claude Code passthrough; claude-bottle
+ but treated as Claude Code passthrough; bot-bottle
ignores them at launch but doesn't reject. Lets the same
file double as a Claude Code subagent.
- Unknown top-level keys die with a hint listing accepted
@@ -191,17 +191,17 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
the required-keys check — same diagnostic as malformed).
- **Manifest assembly.** New resolver:
- 1. Walk `$HOME/.claude-bottle/bottles/*.md` → Bottle dict
+ 1. Walk `$HOME/.bot-bottle/bottles/*.md` → Bottle dict
keyed by filename.
- 2. Walk `$HOME/.claude-bottle/agents/*.md` → Agent dict.
- 3. Walk `$CWD/.claude-bottle/agents/*.md` → Agent dict; merge
+ 2. Walk `$HOME/.bot-bottle/agents/*.md` → Agent dict.
+ 3. Walk `$CWD/.bot-bottle/agents/*.md` → Agent dict; merge
into the home agent dict, cwd wins on name collision.
4. Validate every agent's `bottle:` against the bottle dict.
- 5. Warn if `$CWD/.claude-bottle/bottles/` exists with files.
+ 5. Warn if `$CWD/.bot-bottle/bottles/` exists with files.
6. Return Manifest dataclass — same shape as today.
- **Docs.** README's manifest section rewrites against the new
- layout. `claude-bottle.example.json` becomes
+ layout. `bot-bottle.example.json` becomes
`examples/bottles/dev.md` + `examples/agents/implementer.md`.
The PRD 0010 example block in its own document gets a
follow-up commit noting the new layout (out of scope for
@@ -233,7 +233,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
### File layout
```
-$HOME/.claude-bottle/
+$HOME/.bot-bottle/
├── bottles/
│ ├── dev.md
│ ├── gitea-dev.md
@@ -243,7 +243,7 @@ $HOME/.claude-bottle/
├── researcher.md
└── ...
-$CWD/.claude-bottle/
+$CWD/.bot-bottle/
└── agents/
└── .md
```
@@ -261,7 +261,7 @@ cred_proxy:
- path: /anthropic/
upstream: https://api.anthropic.com
auth_scheme: Bearer
- token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
+ token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
role: anthropic-base-url
- path: /gitea/dideric/
upstream: https://gitea.dideric.is
@@ -271,8 +271,8 @@ cred_proxy:
git:
remotes:
gitea.dideric.is:
- Name: claude-bottle
- Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
+ Name: bot-bottle
+ Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
IdentityFile: ~/.ssh/gitea-delos-2.pem
ExtraHosts:
gitea.dideric.is: 100.78.141.42
@@ -302,7 +302,7 @@ skills:
---
You are a feature-implementation agent running inside an
-ephemeral claude-bottle sandbox...
+ephemeral bot-bottle sandbox...
```
Drop the same file into `~/.claude/agents/implementer.md` and
@@ -336,7 +336,7 @@ Notable rejections (each dies with a specific error):
be ambiguous, quote it.
- Flow style mappings nested more than one level deep.
-Parser lives at `claude_bottle/yaml_subset.py`, ~300 lines.
+Parser lives at `bot_bottle/yaml_subset.py`, ~300 lines.
Public API:
```python
@@ -348,14 +348,14 @@ def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
### Existing code touched
-- **`claude_bottle/manifest.py`** — `Manifest.resolve` rewritten
+- **`bot_bottle/manifest.py`** — `Manifest.resolve` rewritten
to walk the new directories. `Manifest.from_json_obj` kept as
a programmatic entry point (used by tests). New
`Manifest.from_md_dirs(home_dir, cwd_dir)` for the loader.
-- **`claude_bottle/yaml_subset.py`** — new. The parser.
+- **`bot_bottle/yaml_subset.py`** — new. The parser.
- **`README.md`** — manifest section rewritten against the new
layout.
-- **`claude-bottle.example.json`** — removed; replaced by an
+- **`bot-bottle.example.json`** — removed; replaced by an
`examples/` directory with one bottle file + one agent file.
- **Tests** — new parser tests + new loader tests; existing
manifest tests adapt to either build via `from_json_obj`
@@ -368,12 +368,12 @@ etc. all stay the same shape. Only the loader changes.
### Backward compatibility
-This is a breaking change for v1 users. claude-bottle has a
+This is a breaking change for v1 users. bot-bottle has a
single primary user today, so migration is one person rewriting
one file — no automated migration command is in scope.
-If `claude-bottle.json` exists in `$HOME` or `$CWD` *and* the
-new `.claude-bottle/` directory does not exist, the resolver
+If `bot-bottle.json` exists in `$HOME` or `$CWD` *and* the
+new `.bot-bottle/` directory does not exist, the resolver
dies with a clear pointer at the README's manifest section —
not silently merging formats, not silently dropping the JSON
content.
@@ -384,11 +384,11 @@ content.
empirically before settling: drop a file with `bottle: dev`
in `~/.claude/agents/` and see whether Claude Code warns,
ignores, or breaks. If it warns, namespace the field
- (`claude-bottle-bottle:` or a nested `claude_bottle:` block).
-- **Hidden directory vs visible.** Default `.claude-bottle/`
+ (`bot-bottle-bottle:` or a nested `bot_bottle:` block).
+- **Hidden directory vs visible.** Default `.bot-bottle/`
(hidden — matches `.config/`, `.ssh/`, `.docker/`). If users
routinely want to navigate to it from the file manager,
- switch to `claude-bottle/`. Lean hidden.
+ switch to `bot-bottle/`. Lean hidden.
- **`description:` for bottles.** Should bottle frontmatter
carry a `description:` field for the y/N preflight? Default
no — bottle names are kebab-case and self-describing, and
diff --git a/docs/prds/0012-stuck-agent-recovery-flow.md b/docs/prds/0012-stuck-agent-recovery-flow.md
index 3639ed2..48314b4 100644
--- a/docs/prds/0012-stuck-agent-recovery-flow.md
+++ b/docs/prds/0012-stuck-agent-recovery-flow.md
@@ -6,7 +6,7 @@
## Summary
-When an agent running inside a claude-bottle container gets blocked, it invokes one of three MCP tool calls — `cred-proxy-block`, `pipelock-block`, or `capability-block` — passing a *proposed* config change (modified `routes.json`, modified pipelock allowlist, or modified agent Dockerfile) plus text describing why the change is justified. The supervisor sees the proposal in a host-side TUI, approves / modifies / rejects it, and the corresponding remediation runs: SIGHUP-reload cred-proxy with the new routes; restart pipelock with the new allowlist; rebuild the bottle from the new Dockerfile on the same branch. The agent's tool call blocks until the operator acts. The supervisor never opens a live channel into a running bottle; all signal flow goes through a per-bottle MCP sidecar on the existing internal network.
+When an agent running inside a bot-bottle container gets blocked, it invokes one of three MCP tool calls — `cred-proxy-block`, `pipelock-block`, or `capability-block` — passing a *proposed* config change (modified `routes.json`, modified pipelock allowlist, or modified agent Dockerfile) plus text describing why the change is justified. The supervisor sees the proposal in a host-side TUI, approves / modifies / rejects it, and the corresponding remediation runs: SIGHUP-reload cred-proxy with the new routes; restart pipelock with the new allowlist; rebuild the bottle from the new Dockerfile on the same branch. The agent's tool call blocks until the operator acts. The supervisor never opens a live channel into a running bottle; all signal flow goes through a per-bottle MCP sidecar on the existing internal network.
This PRD is the overview. Implementation is split across four follow-on PRDs (0013–0016); see *Implementation chunks* below.
@@ -29,7 +29,7 @@ A real stuck agent recovers end-to-end in each of the three categories: a **cred
Three named categories, each with its own MCP tool. Ordered by remediation cost:
-- **cred-proxy block.** Tool: `cred-proxy-block`. The agent's request was refused by cred-proxy — missing route, expired token, wrong scope. The agent reads the current `routes.json` from `/etc/claude-bottle/current-config/`, composes a modified version, and calls the tool with `{routes: , 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: , 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: , 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: , 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.
diff --git a/docs/prds/0013-supervise-plane-foundation.md b/docs/prds/0013-supervise-plane-foundation.md
index bafd246..f3e75a3 100644
--- a/docs/prds/0013-supervise-plane-foundation.md
+++ b/docs/prds/0013-supervise-plane-foundation.md
@@ -7,7 +7,7 @@
## Summary
-The shared infrastructure that PRDs 0014–0016 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 0014–0016 build on. Adds a per-bottle MCP sidecar that exposes three tools (`cred-proxy-block`, `pipelock-block`, `capability-block`) to the agent; a read-only `/etc/bot-bottle/current-config/` mount in the agent container that exposes the current `routes.json`, pipelock allowlist, and Dockerfile; a host-mounted proposal queue; a minimal TUI dashboard that lists pending proposals and supports approve / modify / reject; and the audit log format. After this PRD, an operator can see proposals and approve/reject them — but the approval handlers are no-ops. The remediation engines that actually act on approvals land in 0014, 0015, and 0016.
## Problem
@@ -33,10 +33,10 @@ See PRD 0012 for the broader stuck-agent problem. This PRD specifically addresse
- A per-bottle MCP sidecar container on the bottle's internal network.
- MCP tool definitions for `cred-proxy-block`, `pipelock-block`, `capability-block` (input schemas as defined in PRD 0012 *Stuck categories*).
- Tool output: `{status: "approved"|"modified"|"rejected", notes: "..."}`.
-- A read-only mount at `/etc/claude-bottle/current-config/` in the agent container exposing the current `routes.json`, pipelock allowlist, and Dockerfile.
-- A host-mounted per-bottle proposal queue at `~/.claude-bottle/queue//` (file-per-proposal, with metadata and proposed file content).
-- A `claude-bottle dashboard` (or similarly named) TUI that lists running bottles and pending proposals across all of them; supports approve, modify-then-approve, and reject-with-reason for each pending proposal.
-- Audit log files at `~/.claude-bottle/audit/cred-proxy-.log` and `~/.claude-bottle/audit/pipelock-.log` with the agreed-upon format (timestamp, diff before/after, justification text, operator action with notes). Entries are written by the supervisor on each approve/modify/reject decision. (capability-block has no separate audit log — capability changes are captured by the bottle's rebuild record / git history.)
+- A read-only mount at `/etc/bot-bottle/current-config/` in the agent container exposing the current `routes.json`, pipelock allowlist, and Dockerfile.
+- A host-mounted per-bottle proposal queue at `~/.bot-bottle/queue//` (file-per-proposal, with metadata and proposed file content).
+- A `bot-bottle dashboard` (or similarly named) TUI that lists running bottles and pending proposals across all of them; supports approve, modify-then-approve, and reject-with-reason for each pending proposal.
+- Audit log files at `~/.bot-bottle/audit/cred-proxy-.log` and `~/.bot-bottle/audit/pipelock-.log` with the agreed-upon format (timestamp, diff before/after, justification text, operator action with notes). Entries are written by the supervisor on each approve/modify/reject decision. (capability-block has no separate audit log — capability changes are captured by the bottle's rebuild record / git history.)
- Bottle lifecycle script changes to launch the MCP sidecar alongside the other sidecars and mount the read-only current-config directory.
### Out of scope
@@ -49,15 +49,15 @@ See PRD 0012 for the broader stuck-agent problem. This PRD specifically addresse
### New services / components
- **MCP sidecar.** New per-bottle container on the bottle's internal network. Exposes the three tools to the agent. On a tool call: validates the proposed file syntactically (valid JSON for `routes.json`, parseable Dockerfile, etc.), writes the proposal to the queue, and holds the tool-call connection open until the supervisor responds. Returns `{status, notes}` to the agent on response.
-- **Read-only current-config mount.** `/etc/claude-bottle/current-config/` in the agent container exposes `routes.json`, the pipelock allowlist, and the agent Dockerfile from the host. Read-only — the agent proposes changes via the tool call, never by writing the file directly.
-- **Proposal queue.** Per-bottle directory under `~/.claude-bottle/queue//` on the host. One file per pending proposal with `{id, tool, proposed_file, justification, arrival_timestamp, current_file_hash, bottle_slug}`.
+- **Read-only current-config mount.** `/etc/bot-bottle/current-config/` in the agent container exposes `routes.json`, the pipelock allowlist, and the agent Dockerfile from the host. Read-only — the agent proposes changes via the tool call, never by writing the file directly.
+- **Proposal queue.** Per-bottle directory under `~/.bot-bottle/queue//` on the host. One file per pending proposal with `{id, tool, proposed_file, justification, arrival_timestamp, current_file_hash, bottle_slug}`.
- **Minimal TUI dashboard.** Lists running bottles and pending proposals. For each proposal: shows current vs. proposed diff and justification. Operator actions: approve / modify-then-approve / reject-with-reason. Stdlib only (curses) unless that proves painful.
-- **Audit log format.** Append-only files at `~/.claude-bottle/audit/-.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/-.log`. Each entry: timestamp, diff before/after, agent justification (if from a tool call), operator action + notes. Defines the format; the per-component PRDs (0014, 0015) fill in real entries.
- **No-op approval handlers.** Each tool's approve path in 0013 writes an audit entry and returns `{status: "approved"}` to the agent but doesn't actually change any config. 0014 / 0015 / 0016 replace these with real handlers.
### Existing code touched
-- **Bottle lifecycle scripts** — launch the MCP sidecar alongside other sidecars; mount `/etc/claude-bottle/current-config/` read-only into the agent container.
+- **Bottle lifecycle scripts** — launch the MCP sidecar alongside other sidecars; mount `/etc/bot-bottle/current-config/` read-only into the agent container.
- **`cli.py`** — adds the dashboard subcommand.
### Data model changes
diff --git a/docs/prds/0017-egress-proxy-via-mitmproxy.md b/docs/prds/0017-egress-proxy-via-mitmproxy.md
index ef723cf..9ba2f7c 100644
--- a/docs/prds/0017-egress-proxy-via-mitmproxy.md
+++ b/docs/prds/0017-egress-proxy-via-mitmproxy.md
@@ -106,7 +106,7 @@ delivery.
apply path. SIGHUP reload semantics carry over to egress-proxy.
- PRD 0013 (supervise plane) `cred-proxy-block` MCP tool stays;
its proposed file format updates per the new route shape.
-- Removal of the old cred-proxy code: `claude_bottle/cred_proxy.py`,
+- Removal of the old cred-proxy code: `bot_bottle/cred_proxy.py`,
`cred_proxy_server.py`, `backend/docker/cred_proxy.py`,
`provision/cred_proxy.py`, the `Dockerfile.cred-proxy`. Tests
updated.
@@ -254,8 +254,8 @@ manifest load:
`path` → `host`, drop the agent-side URL prefix).
- `cred_proxy_routes` field on existing dataclasses removed.
- `Dockerfile.cred-proxy` deleted.
-- `claude_bottle/cred_proxy*.py` deleted.
-- `claude_bottle/backend/docker/cred_proxy*.py` consolidated into
+- `bot_bottle/cred_proxy*.py` deleted.
+- `bot_bottle/backend/docker/cred_proxy*.py` consolidated into
`egress_proxy*.py`.
- Provisioner files renamed.
- PRDs 0010 (cred-proxy), 0014 (cred-proxy-block remediation)
diff --git a/docs/prds/0018-compose-per-instance.md b/docs/prds/0018-compose-per-instance.md
index 9539a53..c1a555d 100644
--- a/docs/prds/0018-compose-per-instance.md
+++ b/docs/prds/0018-compose-per-instance.md
@@ -15,7 +15,7 @@ down`. Logs come from `docker compose logs` and land in a single file
per instance, so reading what happened in a session is one `less`
away.
-State for each instance (`~/.claude-bottle/state//`) becomes a
+State for each instance (`~/.bot-bottle/state//`) becomes a
self-describing folder:
```
@@ -34,7 +34,7 @@ together fully describe the container topology.
Today `start` builds each sidecar (`pipelock`, `egress`, `git-gate`,
`supervise`) and the agent container with a chain of individual SDK
-calls in `claude_bottle/backend/docker/launch.py`:
+calls in `bot_bottle/backend/docker/launch.py`:
- A per-sidecar `Docker{Sidecar}.start()` method does
`docker create` → `docker cp` (stage files) → `docker network
@@ -50,7 +50,7 @@ This is fine, but it has three rough edges:
2. **Logs are scattered.** Each container's logs sit in Docker's per-
container journal. To debug a session post-mortem you have to
- remember to run `docker logs claude-bottle-pipelock-` etc.
+ remember to run `docker logs bot-bottle-pipelock-` etc.
before the containers age out, and there's no merged view.
3. **Teardown is bespoke.** Each sidecar's `stop()` is its own
@@ -62,14 +62,14 @@ project name per environment, merged logs, atomic up/down.
## Goals / Success Criteria
-1. `claude-bottle start ` writes
- `~/.claude-bottle/state//docker-compose.yml` and brings the
+1. `bot-bottle start ` writes
+ `~/.bot-bottle/state//docker-compose.yml` and brings the
project up with `docker compose -p up`.
2. The compose file is the source of truth for the container
topology — every sidecar that runs is declared as a `services:`
entry, every network is a `networks:` entry, every bind mount is
a `volumes:` entry.
-3. `~/.claude-bottle/state//compose.log` contains the full
+3. `~/.bot-bottle/state//compose.log` contains the full
merged stdout/stderr of every service for the session, in
`docker compose logs --no-color` format.
4. `metadata.json` records the compose project name alongside the
@@ -79,7 +79,7 @@ project name per environment, merged logs, atomic up/down.
5. Session teardown is `docker compose -p down`. The
existing per-sidecar `stop()` lifecycle methods come out.
6. The `cleanup` CLI uses `docker compose ls` (filtered to
- `claude-bottle-*` projects) instead of name-prefix scans across
+ `bot-bottle-*` projects) instead of name-prefix scans across
`docker ps -a` and `docker network ls`.
7. The existing remediation flows (`pipelock-block`,
`egress-block`, `capability-block`) keep working without
@@ -95,7 +95,7 @@ project name per environment, merged logs, atomic up/down.
implementation detail of the Docker backend.
- **Replacing the backend abstraction (PRD 0003).** `Backend` stays
abstract; only the Docker implementation changes.
-- **A long-lived "claude-bottle daemon."** Each `start` invocation
+- **A long-lived "bot-bottle daemon."** Each `start` invocation
still owns a single compose project for the lifetime of the
session. No persistent service.
- **Image pre-building.** Compose's `build:` directive triggers
@@ -109,7 +109,7 @@ project name per environment, merged logs, atomic up/down.
### In scope
-- New module `claude_bottle/backend/docker/compose.py` that renders a
+- New module `bot_bottle/backend/docker/compose.py` that renders a
compose dict from a `BottlePlan` and writes it to
`state//docker-compose.yml`.
- `DockerBackend.start` rewritten to:
@@ -118,7 +118,7 @@ project name per environment, merged logs, atomic up/down.
into host paths under `state//`.
3. Render + write the compose file.
4. Exec `docker compose -p up -d`.
- 5. `docker attach claude-bottle-` for the agent's TTY.
+ 5. `docker attach bot-bottle-` for the agent's TTY.
6. On exit: `docker compose -p logs --no-color`
→ `state//compose.log`, then `docker compose -p
down --volumes`.
@@ -134,12 +134,12 @@ project name per environment, merged logs, atomic up/down.
### Out of scope
-- Changing the manifest layer (`claude_bottle/manifest.py`,
+- Changing the manifest layer (`bot_bottle/manifest.py`,
`egress.py`'s plan dataclasses, `pipelock.py`'s plan dataclasses).
- Changing the agent's runtime contract (proxy env vars, CA bundle
paths, current-config mount path).
- Changing audit-log shape or location (
- `~/.claude-bottle/audit/-.log` stays).
+ `~/.bot-bottle/audit/-.log` stays).
- Changing the MCP server's tool list or wire format.
- Dropping the `--rm` semantics for the agent: the agent container
is still ephemeral; compose's `down --volumes` handles cleanup.
@@ -148,7 +148,7 @@ project name per environment, merged logs, atomic up/down.
### Project name
-`compose_project = f"claude-bottle-{slug}"`. The slug stays the
+`compose_project = f"bot-bottle-{slug}"`. The slug stays the
existing `slugify(agent_name)-<5-char-random-base36>` from
`bottle_state.py`. Compose adds its own prefix to networks
(`_`) and to default container names — which is
@@ -163,29 +163,29 @@ an explicit `container_name:` matching today's pattern:
```yaml
services:
pipelock:
- container_name: claude-bottle-pipelock-
+ container_name: bot-bottle-pipelock-
egress:
- container_name: claude-bottle-egress-
+ container_name: bot-bottle-egress-
# ...
```
This keeps the dashboard's container-discovery output stable for
operators who've memorized the names. The compose project name
-(`claude-bottle-`) is the only new identifier.
+(`bot-bottle-`) is the only new identifier.
### Networks
-The two existing networks (`claude-bottle-net-` internal +
-`claude-bottle-egress-` upstream-bridge) become compose
+The two existing networks (`bot-bottle-net-` internal +
+`bot-bottle-egress-` upstream-bridge) become compose
networks:
```yaml
networks:
internal:
- name: claude-bottle-net-
+ name: bot-bottle-net-
internal: true
egress:
- name: claude-bottle-egress-
+ name: bot-bottle-egress-
```
Each service's `networks:` list mirrors today's wiring.
@@ -238,7 +238,7 @@ sidecars that exist.
### Logging
`docker compose up -d` starts everything detached. The agent is
-attached for the user's TTY via `docker attach claude-bottle-
+attached for the user's TTY via `docker attach bot-bottle-
`. Sidecars stream into Docker's per-container journals
during the session, exactly as today, and `docker compose logs -f`
gives a merged tail if the user wants it (the dashboard can shell
@@ -265,7 +265,7 @@ Add one field; everything else is unchanged.
"agent_name": "implementer",
"cwd": "/Users/.../some-project",
"started_at": "2026-05-25T20:13:04Z",
- "compose_project": "claude-bottle-implementer-a7k3f"
+ "compose_project": "bot-bottle-implementer-a7k3f"
}
```
@@ -291,13 +291,13 @@ After this PRD:
### Cleanup CLI
`./cli.py cleanup` switches from "list every container with prefix
-`claude-bottle-` and every network with prefix `claude-bottle-net-`
-or `claude-bottle-egress-`" to:
+`bot-bottle-` and every network with prefix `bot-bottle-net-`
+or `bot-bottle-egress-`" to:
1. `docker compose ls --all --format json` → filter to projects
- whose name starts with `claude-bottle-`.
+ whose name starts with `bot-bottle-`.
2. For each: `docker compose -p down --volumes`.
-3. Reap any state dirs under `~/.claude-bottle/state/` whose
+3. Reap any state dirs under `~/.bot-bottle/state/` whose
`compose_project` no longer appears in `compose ls`.
Strays from pre-compose code-paths can be mopped up by keeping the
@@ -312,8 +312,8 @@ existing prefix scan as a fallback for one release.
2. **How does `claude` reach the agent's TTY?** Decided: keep
today's `docker exec -it` model. Agent runs `sleep infinity`
- under compose; `DockerBottle.exec_claude` runs
- `docker exec -it claude-bottle- claude ...` exactly like
+ under compose; `DockerBottle.exec_agent` runs
+ `docker exec -it bot-bottle- claude ...` exactly like
today. Compose owns the lifecycle (so `compose logs` includes
the agent's stdout, `compose down` tears it down), but the
user-facing exec model is unchanged. Rejected `docker attach`
@@ -332,8 +332,8 @@ existing prefix scan as a fallback for one release.
5. **Image build caching.** `build:` in compose rebuilds on first
`up` unless the image is already tagged. The per-sidecar images
- (`claude-bottle-pipelock`, `claude-bottle-egress`,
- `claude-bottle-git-gate`, `claude-bottle-supervise`) should
+ (`bot-bottle-pipelock`, `bot-bottle-egress`,
+ `bot-bottle-git-gate`, `bot-bottle-supervise`) should
stay tagged on the daemon between runs so we don't rebuild on
every start. Verify compose's behavior matches.
diff --git a/docs/prds/0019-active-agents-in-dashboard.md b/docs/prds/0019-active-agents-in-dashboard.md
index 548b9dc..2536e09 100644
--- a/docs/prds/0019-active-agents-in-dashboard.md
+++ b/docs/prds/0019-active-agents-in-dashboard.md
@@ -128,7 +128,7 @@ the "operator wants to make an unprompted change" case.
### Layout
```
-claude-bottle dashboard (3 pending, 2 active)
+bot-bottle dashboard (3 pending, 2 active)
─────────────────────────────────────────────────────────
proposals:
03:14:22 [implementer-cy7a6] egress-block abc123…
diff --git a/docs/prds/0020-start-and-attach-from-dashboard.md b/docs/prds/0020-start-and-attach-from-dashboard.md
index 235f201..297fadd 100644
--- a/docs/prds/0020-start-and-attach-from-dashboard.md
+++ b/docs/prds/0020-start-and-attach-from-dashboard.md
@@ -63,7 +63,7 @@ captures full-merged logs per bottle (PRD 0018). It already
→ restore`, matching the existing editor-flow pattern.
3. On launch success, the dashboard performs a handoff (option
1 from the research doc): `curses.endwin()` → `docker exec
- -it claude-bottle- claude --dangerously-skip-permissions`
+ -it bot-bottle- claude --dangerously-skip-permissions`
→ on exit, `stdscr.refresh()` and re-render with the new
bottle in the agents pane.
4. The bottle's lifetime is owned by the dashboard process, NOT
@@ -154,7 +154,7 @@ Today's flow:
```
./cli.py start agent
└─ with backend.launch(plan) as bottle: ← bottle alive while inside `with`
- bottle.exec_claude([...], tty=True) ← blocks until claude exits
+ bottle.exec_agent([...], tty=True) ← blocks until claude exits
# context exits → compose down → state cleanup
```
@@ -171,7 +171,7 @@ The proposed dashboard-driven flow:
# operator interacts via:
curses.endwin()
- bottle.exec_claude([...], tty=True) ← blocks; returns on Ctrl-D
+ bottle.exec_agent([...], tty=True) ← blocks; returns on Ctrl-D
stdscr.refresh()
# bottle is STILL ALIVE — only the claude process exited
@@ -265,10 +265,10 @@ if modal proves fiddly.
Same handoff pattern the new-agent flow uses. For an agent the
dashboard started this session, the dashboard holds the
`DockerBottle` handle in its `bottles` dict and calls
-`bottle.exec_claude(...)`. For an agent it discovered via
+`bottle.exec_agent(...)`. For an agent it discovered via
`list_active_slugs` (previous-dashboard or external start),
the dashboard synthesizes a one-shot `DockerBottle` from the
-slug — container name is `claude-bottle-`, no prompt
+slug — container name is `bot-bottle-`, no prompt
path because the agent's claude config already has `--append-
system-prompt-file` baked in from the original launch —
and runs the same exec. Either way, Enter drops to
@@ -284,7 +284,7 @@ agents pane.
`x` on a non-owned agent (discovered via `list_active_slugs`
but not in `bottles` dict): no-op with status hint pointing
at `./cli.py cleanup` (the existing path that tears down
-ANY claude-bottle compose project plus reaps state dirs).
+ANY bot-bottle compose project plus reaps state dirs).
### Dashboard quit
@@ -304,17 +304,17 @@ acting surface, not a lifetime owner.
Sized for one PR each.
-1. **Refactor `_launch_bottle` so the launch + exec_claude
+1. **Refactor `_launch_bottle` so the launch + exec_agent
pieces are separable.** Today's `cli/start.py` runs both
inside one function. Extract `prepare_with_preflight(spec,
- *, render_preflight, prompt_yes)` and `attach_claude(bottle,
+ *, render_preflight, prompt_yes)` and `attach_agent(bottle,
*, remote_control)`. The CLI's existing one-shot use binds
them as before; the dashboard binds them with curses-aware
render + prompt callables. No behavior change.
2. **Agent picker modal + new-agent flow.** New key `n` opens
the picker; `prepare_with_preflight` runs against the
selected agent; on Y, `backend.launch(plan)` enters the
- dashboard's ExitStack; handoff invokes `attach_claude`.
+ dashboard's ExitStack; handoff invokes `attach_agent`.
3. **Re-attach via Enter on owned agents-pane row.** Looks up
the slug in the dashboard's `bottles` map; if present →
handoff; else → status-line hint pointing at `./cli.py
@@ -390,7 +390,7 @@ Sized for one PR each.
- PRD 0019 — active-agents pane + selection model (the
agents-pane row the re-attach + stop verbs hook into)
- `docs/research/claude-code-pane-in-dashboard.md` — option 1
- (handoff) is what `attach_claude` implements here; options 2
+ (handoff) is what `attach_agent` implements here; options 2
/ 3 are out of scope for this PRD
-- `claude_bottle/cli/start.py:_launch_bottle` — the function
+- `bot_bottle/cli/start.py:_launch_bottle` — the function
chunk 1 extracts the prepare + attach pieces out of
diff --git a/docs/prds/0021-dashboard-tmux-split-pane.md b/docs/prds/0021-dashboard-tmux-split-pane.md
index f4f8ebe..e6c00ab 100644
--- a/docs/prds/0021-dashboard-tmux-split-pane.md
+++ b/docs/prds/0021-dashboard-tmux-split-pane.md
@@ -46,7 +46,7 @@ window, two panes, no terminal handoff.
two-pane layout: dashboard in the left pane, an initially-
empty right pane reserved for claude sessions.
2. Pressing Enter on a focused agent row spawns / respawns the
- right pane with `docker exec -it claude-bottle- claude
+ right pane with `docker exec -it bot-bottle- claude
--continue --dangerously-skip-permissions`. The right pane's
prior content (if any) is replaced.
3. Pressing `n` to start a new agent (the existing chunk-2 flow
@@ -163,7 +163,7 @@ def _attach_in_tmux(bottle, slug, *, resume) -> str:
The non-tmux path is unchanged from PRD 0020 — `_attach_via_
handoff` is what those two flows already do today (curses.
-endwin → attach_claude → stdscr.refresh).
+endwin → attach_agent → stdscr.refresh).
### Pane creation
@@ -230,10 +230,10 @@ def _tmux_pane_exists(pane_id) -> bool:
The tmux helpers need the full docker-exec argv for claude —
specifically including the `--append-system-prompt-file `
-flag that `DockerBottle.exec_claude` appends today when the
-bottle has a prompt path. Refactor: split `exec_claude` into a
+flag that `DockerBottle.exec_agent` appends today when the
+bottle has a prompt path. Refactor: split `exec_agent` into a
pure `claude_docker_argv(args, *, tty)` that returns the argv
-and a thin `exec_claude` that calls `subprocess.run` on it.
+and a thin `exec_agent` that calls `subprocess.run` on it.
Both the tmux path AND the existing foreground path use the
same argv builder.
@@ -272,7 +272,7 @@ Three failure modes worth handling:
Sized small.
1. **`claude_docker_argv` refactor.** Pure-ish split of
- `DockerBottle.exec_claude` so both foreground and tmux
+ `DockerBottle.exec_agent` so both foreground and tmux
paths build on the same argv. No behavior change for the
existing tests.
2. **tmux helpers + pane state.** Add `_in_tmux`,
diff --git a/docs/prds/0022-sandbox-escape-integration-test.md b/docs/prds/0022-sandbox-escape-integration-test.md
index 48035f2..6b92c79 100644
--- a/docs/prds/0022-sandbox-escape-integration-test.md
+++ b/docs/prds/0022-sandbox-escape-integration-test.md
@@ -26,7 +26,7 @@ The suite is the backend-agnostic gate the smolmachines work
has to pass before that backend can be considered a viable
substitute for Docker. Today's Docker backend is the
backend-under-test; the suite runs against whatever
-`CLAUDE_BOTTLE_BACKEND` resolves to.
+`BOT_BOTTLE_BACKEND` resolves to.
## Problem
@@ -121,7 +121,7 @@ destination outside the bottle.
suite is written against the abstract `BottleBackend` API
via the existing `get_bottle_backend()` selector; future
smolmachines work flips
- `CLAUDE_BOTTLE_BACKEND=smolmachines` and reruns. No
+ `BOT_BOTTLE_BACKEND=smolmachines` and reruns. No
smolmachines-specific code here.
## Scope
@@ -176,7 +176,7 @@ bottle so the suite is ~15s wall-clock total instead of
bottle: dev
---
-(no prompt — exec_claude isn't called)
+(no prompt — exec_agent isn't called)
```
```yaml
@@ -212,7 +212,7 @@ scan happens first.
### Per-attack scaffolding
Each test calls `bottle.exec(script)` (the existing
-`Bottle.exec` from `claude_bottle.backend.Bottle`) and
+`Bottle.exec` from `bot_bottle.backend.Bottle`) and
asserts on the returncode + stdout + stderr.
The agent container has `curl`, `git`, `dig`, etc. pre-
@@ -331,7 +331,7 @@ def test_5_readme_push_blocked(self):
f'echo "[click](https://attacker.example.com/?leak={env_ref})" > README.md && '
'git add . && git commit -m "leak" && '
'git remote add origin '
- 'git://claude-bottle-git-gate-/throwaway.git && '
+ 'git://bot-bottle-git-gate-/throwaway.git && '
'git push origin master'
)
self.assertNotEqual(0, result.returncode)
@@ -460,7 +460,7 @@ Sized small.
already dies with a clear message naming the unknown
backend; the test surfaces that as a hard error
rather than a skip. Forces the developer to set
- `CLAUDE_BOTTLE_BACKEND` to a real implementation —
+ `BOT_BOTTLE_BACKEND` to a real implementation —
surprise-skips on smolmachines branches that forgot to
set the env var are worse than a loud failure.
diff --git a/docs/prds/0023-smolmachines-backend.md b/docs/prds/0023-smolmachines-backend.md
index 4bf2b6b..6a66038 100644
--- a/docs/prds/0023-smolmachines-backend.md
+++ b/docs/prds/0023-smolmachines-backend.md
@@ -8,7 +8,7 @@
Ship a second concrete `BottleBackend` —
`SmolmachinesBottleBackend`, selected via
-`CLAUDE_BOTTLE_BACKEND=smolmachines` — that runs each bottle inside
+`BOT_BOTTLE_BACKEND=smolmachines` — that runs each bottle inside
a per-agent libkrun microVM via `smolvm`. Egress is enforced by
libkrun's TSI ("Transport Socket Interface") allowlist set to a
**single /32** — the docker IP of the per-bottle sidecar bundle
@@ -28,7 +28,7 @@ port-granular.
The Docker backend ships unchanged; this is opt-in via the existing
env-var selector. The acceptance gate is PRD 0022's
`tests/integration/test_sandbox_escape.py` running green against
-`CLAUDE_BOTTLE_BACKEND=smolmachines`.
+`BOT_BOTTLE_BACKEND=smolmachines`.
### Design pivot from the first draft
@@ -63,7 +63,7 @@ with significantly less code.
container-based bottles on macOS; `smolmachines-as-vm-backend.md`
evaluates smolmachines as the lifecycle wrapper. Today, the only
backend in the registry is Docker
-(`claude_bottle/backend/__init__.py:_BACKENDS = {"docker": ...}`),
+(`bot_bottle/backend/__init__.py:_BACKENDS = {"docker": ...}`),
and four things motivate a second one now:
- **Network reach beyond pipelock.** The threat model is a malicious
@@ -85,7 +85,7 @@ and four things motivate a second one now:
enforced by the CPU's MMU instead of namespace bookkeeping.
- **PRD 0022 is backend-agnostic by design** but currently only
exercises the Docker backend. The suite was written with
- `CLAUDE_BOTTLE_BACKEND` selection in mind precisely so the
+ `BOT_BOTTLE_BACKEND` selection in mind precisely so the
smolmachines path could be validated against the same five
attacks. Until a second backend exists, the abstraction is
unproven.
@@ -143,7 +143,7 @@ virtio-net carve-out smolvm doesn't expose anyway).
The feature works when all of the following are observable on a
macOS host with smolmachines installed:
-- `CLAUDE_BOTTLE_BACKEND=smolmachines python3 cli.py start `
+- `BOT_BOTTLE_BACKEND=smolmachines python3 cli.py start `
brings up a microVM, runs claude-code inside it, and tears it
down on exit. Same y/N preflight UX as Docker — only the
resolved-runtime line differs.
@@ -160,14 +160,14 @@ macOS host with smolmachines installed:
The feature is **done** when all of the following ship:
-- A new `claude_bottle/backend/smolmachines/` subpackage exists,
- mirroring the layout of `claude_bottle/backend/docker/`
+- A new `bot_bottle/backend/smolmachines/` subpackage exists,
+ mirroring the layout of `bot_bottle/backend/docker/`
(`backend.py`, `bottle.py`, `bottle_plan.py`,
`bottle_cleanup_plan.py`, `prepare.py`, `launch.py`,
`cleanup.py`, `util.py`, and a `provision/` subpackage for the
five `provision_*` methods).
- `SmolmachinesBottleBackend` registered under the
- `"smolmachines"` key in `claude_bottle/backend/__init__.py:_BACKENDS`.
+ `"smolmachines"` key in `bot_bottle/backend/__init__.py:_BACKENDS`.
- Per-bottle Smolfile generation: a runtime-rendered TOML written
to the bottle's stage dir using smolvm 0.8.0's actual schema
(`image`, `entrypoint`, `cmd`, `env = ["K=V", …]`, `[network]
@@ -197,7 +197,7 @@ The feature is **done** when all of the following ship:
step is part of `prepare`, analogous to
`docker_mod.build_image`.
- The PRD 0022 sandbox-escape suite, run with
- `CLAUDE_BOTTLE_BACKEND=smolmachines`, passes locally on a
+ `BOT_BOTTLE_BACKEND=smolmachines`, passes locally on a
smolmachines-capable host. The suite is updated to skip cleanly
on hosts that can't reach smolmachines (same shape as the
existing `GITEA_ACTIONS == "true"` skip), not to fail.
@@ -215,7 +215,7 @@ The feature is **done** when all of the following ship:
side. Selection stays env-driven; the manifest does not gain a
`backend` field.
- **No default-backend change.** `docker` remains the default
- value of `CLAUDE_BOTTLE_BACKEND`; smolmachines is strictly
+ value of `BOT_BOTTLE_BACKEND`; smolmachines is strictly
opt-in until it has been load-bearing on at least one operator's
workflow for a release cycle.
- **No `--outbound-localhost-only`.** That TSI flag opens the
@@ -251,7 +251,7 @@ The feature is **done** when all of the following ship:
### In scope
-- New `claude_bottle/backend/smolmachines/` subpackage with the
+- New `bot_bottle/backend/smolmachines/` subpackage with the
full set of `BottleBackend` overrides.
- Smolfile generator (TOML) emitting the smolvm 0.8.0 schema:
top-level `image`, `entrypoint`, `cmd`, `env = [...]`,
@@ -267,7 +267,7 @@ The feature is **done** when all of the following ship:
- Per-bottle CA install path: the bundle's CA cert lands inside
the microVM via `smolvm machine exec` after start
(analogous to the existing `provision_ca` for Docker).
-- Per-bottle docker bridge: a `claude-bottle-bundle-`
+- Per-bottle docker bridge: a `bot-bottle-bundle-`
network with a /24 subnet derived from the slug hash; the
bundle gets a pinned IP at `.2` (gateway is `.1`). Pinning the
IP at start time avoids a race between the bundle's IP being
@@ -314,10 +314,10 @@ The feature is **done** when all of the following ship:
### Backend layout
```
-claude_bottle/backend/smolmachines/
+bot_bottle/backend/smolmachines/
__init__.py re-exports SmolmachinesBottleBackend
backend.py SmolmachinesBottleBackend façade
- bottle.py SmolmachinesBottle (exec_claude / exec / cp_in / close)
+ bottle.py SmolmachinesBottle (exec_agent / exec / cp_in / close)
bottle_plan.py SmolmachinesBottlePlan + .print()
bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan
prepare.py resolve_plan(spec, stage_dir, ...) -> SmolmachinesBottlePlan
@@ -339,7 +339,7 @@ design needs neither.
```
┌── macOS host ─────────────────────────────────────────────────────┐
│ │
- │ ┌── per-bottle docker bridge claude-bottle-bundle- ──┐ │
+ │ ┌── per-bottle docker bridge bot-bottle-bundle- ──┐ │
│ │ subnet: 192.168.X.0/24 (X = hash(slug) mod 254) │ │
│ │ │ │
│ │ ┌── bundle container (pinned --ip 192.168.X.2) ────────┐ │ │
@@ -401,9 +401,9 @@ Three changes vs. the Docker backend:
transport tested. The conversion path is a registry hop: bring
up an ephemeral `registry:2.8.3` container bound to
`127.0.0.1:`, `docker tag` + `docker push` into it,
- `smolvm pack create --image localhost:/claude-bottle:`,
+ `smolvm pack create --image localhost:/bot-bottle:`,
tear down the registry. The `.smolmachine` is cached under
- `~/.cache/claude-bottle/smolmachines/` keyed by the docker
+ `~/.cache/bot-bottle/smolmachines/` keyed by the docker
image ID, so Dockerfile changes invalidate the cache and
unchanged rebuilds skip the whole pipeline.
4. Render the per-bottle Smolfile to `stage_dir/smolfile.toml`
@@ -424,7 +424,7 @@ Three changes vs. the Docker backend:
`SmolmachinesBottleBackend.launch(plan)`:
1. Create the per-bottle docker bridge network
- (`claude-bottle-bundle-` with the resolved subnet) and
+ (`bot-bottle-bundle-` with the resolved subnet) and
start the sidecar bundle container with `docker run --network
... --ip ...`. Wait for its daemons to bind:
pipelock on 8888, git-gate on 9418 (conditional), supervise
@@ -436,7 +436,7 @@ Three changes vs. the Docker backend:
layer enforces it.
3. Provisioning: CA install → prompt → skills → git → supervise
config, each via `smolvm machine exec` / `smolvm machine cp`.
-4. Yield a `SmolmachinesBottle` whose `exec_claude` / `exec` /
+4. Yield a `SmolmachinesBottle` whose `exec_agent` / `exec` /
`cp_in` all funnel through `smolvm machine exec` /
`smolvm machine cp`.
5. Teardown: stop and delete the VM → stop + remove the bundle
@@ -457,7 +457,7 @@ The `BottleSpec` dataclass and the `Bottle` ABC do not change.
### Selection wiring
-In `claude_bottle/backend/__init__.py`:
+In `bot_bottle/backend/__init__.py`:
```python
from .docker import DockerBottleBackend
@@ -508,7 +508,7 @@ The existing "unknown backend" `die()` path stays as-is.
(egress's port) is refused — confirming the bundle-internal
bind of egress to `127.0.0.1` works as the port-granularity
layer TSI doesn't provide.
-- **PRD 0022 re-run:** with `CLAUDE_BOTTLE_BACKEND=smolmachines`,
+- **PRD 0022 re-run:** with `BOT_BOTTLE_BACKEND=smolmachines`,
all five attack categories return sandbox-block markers and the
suite passes. The test code does not change beyond the env-var
flip — that's the contract the PRD 0022 abstraction was
@@ -517,7 +517,7 @@ The existing "unknown backend" `die()` path stays as-is.
## Sizing — into chunks
PRD 0024's bundle image is a prerequisite — this PRD assumes
-`claude-bottle-sidecars:` is available when chunk 3 lands.
+`bot-bottle-sidecars:` is available when chunk 3 lands.
1. **Backend skeleton + selection + Smolfile/gvproxy renderers.**
*Shipped (PR #62), but under the now-rejected gvproxy design.*
@@ -598,7 +598,7 @@ PRD 0024's bundle image is a prerequisite — this PRD assumes
enumerate active bottles (`list_active` queries the daemon).
The microVM enumeration story is `smolvm machine ls --json`;
the plan is to filter on a deterministic name prefix
- `claude-bottle-` + cross-reference with on-disk metadata
+ `bot-bottle-` + cross-reference with on-disk metadata
under `state//`.
8. **Loopback scoping (Docker Desktop pivot).** The original
design pinned the bundle at a docker bridge IP and set TSI's
@@ -667,5 +667,5 @@ PRD 0024's bundle image is a prerequisite — this PRD assumes
needed to exercise the smolmachines path.
- PRD 0024
(`docs/prds/0024-consolidate-sidecar-bundle.md`) — defines the
- single bundle image (`claude-bottle-sidecars`) this PRD
+ single bundle image (`bot-bottle-sidecars`) this PRD
consumes. Prerequisite for chunk 3 of this PRD.
diff --git a/docs/prds/0024-consolidate-sidecar-bundle.md b/docs/prds/0024-consolidate-sidecar-bundle.md
index 6ebf385..2df249f 100644
--- a/docs/prds/0024-consolidate-sidecar-bundle.md
+++ b/docs/prds/0024-consolidate-sidecar-bundle.md
@@ -8,7 +8,7 @@
Replace the four per-bottle sidecar containers in the Docker
backend (pipelock, egress, git-gate, supervise) with a single
-container image — `claude-bottle-sidecars` — that runs all four
+container image — `bot-bottle-sidecars` — that runs all four
daemons under a small stdlib-Python init supervisor. Same
per-bottle lifetime, same scope, fewer containers per bottle,
one Dockerfile to maintain instead of three. Outcome: the
@@ -64,7 +64,7 @@ The feature works when all of the following are observable:
speak the same protocols on the same well-known in-container
ports as before; only the container hostname changes.
- The sandbox-escape suite from PRD 0022 stays green.
-- `docker logs claude-bottle-sidecars-` shows interleaved
+- `docker logs bot-bottle-sidecars-` shows interleaved
output from all four daemons, prefixed by the supervisor with
the daemon name. Each daemon's exit propagates through the
supervisor to the container's exit code.
@@ -77,18 +77,18 @@ The feature is **done** when all of the following ship:
- A new `Dockerfile.sidecars` (multi-stage) that:
- Copies the `pipelock` binary from the upstream pipelock
image (currently `ghcr.io/luckypipewrench/pipelock` pinned
- by digest in `claude_bottle/backend/docker/pipelock.py`).
+ by digest in `bot_bottle/backend/docker/pipelock.py`).
- Copies the `gitleaks` binary from `zricethezav/gitleaks`
(currently pinned by digest in `Dockerfile.git-gate`).
- Installs `mitmdump` (via `pip install mitmproxy==`).
- Installs the system deps `git-daemon` + `openssh-client`
that git-gate needs.
- Copies the existing addon + server Python from
- `claude_bottle/egress_addon.py`, `egress_addon_core.py`,
+ `bot_bottle/egress_addon.py`, `egress_addon_core.py`,
`yaml_subset.py`, `supervise.py`, `supervise_server.py`.
- - Drops in a new `claude_bottle/sidecar_init.py` (stdlib
+ - Drops in a new `bot_bottle/sidecar_init.py` (stdlib
Python) as the container's `ENTRYPOINT`.
-- A new `claude_bottle/sidecar_init.py` — a small Python init
+- A new `bot_bottle/sidecar_init.py` — a small Python init
supervisor that:
- Reads which daemons to run from env (defaults: all four).
- Spawns each as a `subprocess.Popen` with prefixed
@@ -99,13 +99,13 @@ The feature is **done** when all of the following ship:
- Exits with code 0 only if every child exited 0; otherwise
exits 1. (Or: any-child-died → tear down the rest and exit
that child's code — see open question 2.)
-- `claude_bottle/backend/docker/compose.py` renderer updated to
+- `bot_bottle/backend/docker/compose.py` renderer updated to
emit one `sidecars` service in place of the four. The four
in-container ports (8888 / 9099 / 9418 / 9100, today) all
land on the same container; the agent-facing ports
(HTTPS_PROXY, git-gate-SSH, supervise-MCP) are published as
before, just from one container instead of three.
-- `claude_bottle/backend/docker/{pipelock,egress,git_gate,supervise}.py`
+- `bot_bottle/backend/docker/{pipelock,egress,git_gate,supervise}.py`
collapsed: the platform-neutral pieces stay
(`PipelockProxy`, `Egress`, `GitGate`, `Supervise` ABCs and
their plans), the docker-specific subclasses lose their
@@ -161,7 +161,7 @@ The feature is **done** when all of the following ship:
- New `Dockerfile.sidecars` (multi-stage) bringing pipelock,
mitmproxy, gitleaks, git-daemon, openssh-client, and the
project's addon + server Python into one image.
-- New `claude_bottle/sidecar_init.py` supervising the four
+- New `bot_bottle/sidecar_init.py` supervising the four
daemons.
- `backend/docker/compose.py` renderer collapse (five services
→ two).
@@ -217,12 +217,12 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
# Drop in the project's Python addon + server code
-COPY claude_bottle/egress_addon_core.py /app/egress_addon_core.py
-COPY claude_bottle/egress_addon.py /app/egress_addon.py
-COPY claude_bottle/yaml_subset.py /app/yaml_subset.py
-COPY claude_bottle/supervise.py /app/supervise.py
-COPY claude_bottle/supervise_server.py /app/supervise_server.py
-COPY claude_bottle/sidecar_init.py /app/sidecar_init.py
+COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
+COPY bot_bottle/egress_addon.py /app/egress_addon.py
+COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
+COPY bot_bottle/supervise.py /app/supervise.py
+COPY bot_bottle/supervise_server.py /app/supervise_server.py
+COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
# Pull the standalone binaries into the final stage
COPY --from=pipelock-src /usr/local/bin/pipelock /usr/local/bin/pipelock
@@ -252,7 +252,7 @@ existing Dockerfiles.
### Init supervisor
-`claude_bottle/sidecar_init.py` (sketch — actual code lands as
+`bot_bottle/sidecar_init.py` (sketch — actual code lands as
part of implementation):
```python
@@ -287,7 +287,7 @@ of the four. The service inherits the union of the four's
existing bind mounts; environment variables get prefixed by
daemon name where they clash (none clash today, but the renderer
becomes the central place to enforce that). Container hostname
-becomes `sidecars` (or `claude-bottle-sidecars-