refactor!: rename project to bot-bottle
Assisted-by: Codex
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
-5
@@ -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" <<JSON
|
||||
JSON
|
||||
|
||||
# Default to an interactive claude session. In the v1 launcher,
|
||||
# `claude_bottle/cli/start.py` runs the container detached and uses `docker exec`
|
||||
# to attach a TTY, but this CMD makes `docker run -it claude-bottle` also
|
||||
# `bot_bottle/cli/start.py` runs the container detached and uses `docker exec`
|
||||
# to attach a TTY, but this CMD makes `docker run -it bot-bottle-claude` also
|
||||
# do something useful for ad-hoc debugging.
|
||||
CMD ["claude"]
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# claude-bottle Codex provider image.
|
||||
# bot-bottle Codex provider image.
|
||||
#
|
||||
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
|
||||
# non-root node user, and the provider CLI installed globally.
|
||||
|
||||
+8
-8
@@ -36,7 +36,7 @@
|
||||
# Stage 1: pipelock binary. The upstream pipelock image is a
|
||||
# scratch image with the binary at /pipelock (entrypoint).
|
||||
# Pinned by digest in lockstep with
|
||||
# claude_bottle/backend/docker/pipelock.py:PIPELOCK_IMAGE.
|
||||
# bot_bottle/backend/docker/pipelock.py:PIPELOCK_IMAGE.
|
||||
FROM ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9 AS pipelock-src
|
||||
|
||||
# Stage 2: gitleaks binary. The upstream gitleaks image is alpine
|
||||
@@ -75,13 +75,13 @@ COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
|
||||
# Kept flat under /app/ so mitmdump's loader resolves them as
|
||||
# top-level siblings (absolute imports), matching the prior
|
||||
# Dockerfile.egress / Dockerfile.supervise layout.
|
||||
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 claude_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||
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
|
||||
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||
RUN chmod +x /app/egress-entrypoint.sh
|
||||
|
||||
# Pre-create runtime directories the compose renderer + start
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<p align="center">
|
||||
<img src="docs/logo.svg" alt="claude-bottle logo" width="140">
|
||||
<img src="docs/logo.svg" alt="bot-bottle logo" width="140">
|
||||
</p>
|
||||
|
||||
# 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
|
||||
@@ -39,7 +39,7 @@ the genie does not persist.
|
||||
|
||||
## Project status
|
||||
|
||||
claude-bottle is a self-hosted secure runtime for AI coding agents.
|
||||
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
|
||||
@@ -70,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`.
|
||||
@@ -207,7 +207,7 @@ left running; remove it with `docker rm -f <container-name>`.
|
||||
|
||||
A second backend runs the agent in a smolvm micro-VM (libkrun) with the
|
||||
sidecar bundle still in Docker. Selected via
|
||||
`CLAUDE_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>`. Requires
|
||||
`BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>`. Requires
|
||||
`smolvm` on PATH (`curl -sSL https://smolmachines.com/install.sh | sh`).
|
||||
|
||||
The integration tests run against whichever backend the env var
|
||||
@@ -236,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
|
||||
@@ -253,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
|
||||
`<repo>/.claude-bottle/agents/<name>.md`. Those agents reference
|
||||
bottles defined in `~/.claude-bottle/bottles/` (the only place
|
||||
`<repo>/.bot-bottle/agents/<name>.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
|
||||
@@ -293,7 +293,7 @@ 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`)
|
||||
### Example bottle (`~/.bot-bottle/bottles/gitea-dev.md`)
|
||||
|
||||
````markdown
|
||||
---
|
||||
@@ -310,8 +310,8 @@ 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...
|
||||
|
||||
@@ -325,7 +325,7 @@ egress:
|
||||
role: claude_code_oauth
|
||||
auth:
|
||||
scheme: Bearer
|
||||
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
|
||||
token_ref: BOT_BOTTLE_OAUTH_TOKEN
|
||||
- host: api.github.com
|
||||
auth:
|
||||
scheme: Bearer
|
||||
@@ -340,9 +340,9 @@ For a Codex-backed 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
|
||||
claude-bottle sidecars in place.
|
||||
bot-bottle sidecars in place.
|
||||
|
||||
### Example agent (`~/.claude-bottle/agents/gitea-helper.md`)
|
||||
### Example agent (`~/.bot-bottle/agents/gitea-helper.md`)
|
||||
|
||||
````markdown
|
||||
---
|
||||
@@ -358,7 +358,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.
|
||||
|
||||
@@ -371,7 +371,7 @@ 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
|
||||
@@ -380,7 +380,7 @@ boundary rationale lives in `docs/prds/0011-per-file-md-manifest.md`.
|
||||
|
||||
## Auth: OAuth token, not API key
|
||||
|
||||
claude-bottle authenticates `claude` inside the container with the same
|
||||
bot-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.
|
||||
|
||||
@@ -389,7 +389,7 @@ 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:**
|
||||
|
||||
@@ -398,28 +398,28 @@ 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_OAUTH_TOKEN`:
|
||||
|
||||
```sh
|
||||
export CLAUDE_BOTTLE_OAUTH_TOKEN="<token>"
|
||||
export BOT_BOTTLE_OAUTH_TOKEN="<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"`:
|
||||
`token_ref: "BOT_BOTTLE_OAUTH_TOKEN"`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"path": "/anthropic/",
|
||||
"upstream": "https://api.anthropic.com",
|
||||
"auth_scheme": "Bearer",
|
||||
"token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
|
||||
"token_ref": "BOT_BOTTLE_OAUTH_TOKEN",
|
||||
"role": "anthropic-base-url"
|
||||
}
|
||||
```
|
||||
|
||||
At launch, `cli.py` reads `CLAUDE_BOTTLE_OAUTH_TOKEN` from the host
|
||||
At launch, `cli.py` reads `BOT_BOTTLE_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
|
||||
@@ -438,7 +438,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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""bot-bottle: Python implementation of the agent container launcher."""
|
||||
@@ -37,7 +37,7 @@ _RUNTIMES = {
|
||||
PROVIDER_CLAUDE: AgentProviderRuntime(
|
||||
template=PROVIDER_CLAUDE,
|
||||
command="claude",
|
||||
image="claude-bottle:latest",
|
||||
image="bot-bottle-claude:latest",
|
||||
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
|
||||
auth_role="claude_code_oauth",
|
||||
placeholder_env="CLAUDE_CODE_OAUTH_TOKEN",
|
||||
@@ -49,7 +49,7 @@ _RUNTIMES = {
|
||||
PROVIDER_CODEX: AgentProviderRuntime(
|
||||
template=PROVIDER_CODEX,
|
||||
command="codex",
|
||||
image="claude-bottle-codex:latest",
|
||||
image="bot-bottle-codex:latest",
|
||||
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
|
||||
auth_role="codex_auth",
|
||||
placeholder_env="OPENAI_API_KEY",
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -376,12 +376,12 @@ def get_bottle_backend(
|
||||
|
||||
`name` precedence:
|
||||
1. explicit arg (CLI `--backend=<name>` 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}")
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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"
|
||||
+2
-2
@@ -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`,
|
||||
+1
-1
@@ -34,7 +34,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/<slug>/Dockerfile) after a
|
||||
# state file (~/.bot-bottle/state/<slug>/Dockerfile) after a
|
||||
# capability-block remediation (PRD 0016).
|
||||
dockerfile_path: str
|
||||
env_file: Path # docker --env-file: NAME=VALUE literals
|
||||
+11
-11
@@ -6,15 +6,15 @@ helper saves before teardown, and the launch metadata that lets
|
||||
`cli.py resume <identity>` reconstruct a bottle's spec. State
|
||||
lives at:
|
||||
|
||||
~/.claude-bottle/state/<identity>/
|
||||
~/.bot-bottle/state/<identity>/
|
||||
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-<id>)
|
||||
the agent image with a per-bottle tag (bot-bottle-rebuilt-<id>)
|
||||
from this file rather than the repo's. The build context is still
|
||||
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/<identity>/...
|
||||
# Directory layout: ~/.bot-bottle/state/<identity>/...
|
||||
_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/<identity>/metadata.json."""
|
||||
~/.bot-bottle/state/<identity>/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/<identity>/metadata.json.
|
||||
"""Persist `metadata` to ~/.bot-bottle/state/<identity>/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/<slug>/ alongside the audit logs, so it
|
||||
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
|
||||
survives state-dir cleanup."""
|
||||
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
||||
|
||||
+7
-7
@@ -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/<slug>/transcript/ (best-effort).
|
||||
~/.bot-bottle/state/<slug>/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/<slug>/Dockerfile (PRD 0016 Phase 1
|
||||
~/.bot-bottle/state/<slug>/Dockerfile (PRD 0016 Phase 1
|
||||
state). The next `cli.py start <agent>` picks it up.
|
||||
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}",
|
||||
]
|
||||
|
||||
|
||||
@@ -131,13 +131,13 @@ def _repo_dockerfile_path() -> Path:
|
||||
"""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
|
||||
# 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/<slug>/transcript/. Best-effort: missing
|
||||
~/.bot-bottle/state/<slug>/transcript/. Best-effort: missing
|
||||
container, missing dir, or cp error all log a warning and return.
|
||||
The transcript is what `claude --resume` reads to pick up where
|
||||
the agent left off.
|
||||
@@ -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/<identity>/ with no
|
||||
- State dirs under ~/.bot-bottle/state/<identity>/ 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] = []
|
||||
@@ -20,11 +20,11 @@ SDK-call branching in `launch.py` today):
|
||||
|
||||
Naming:
|
||||
|
||||
- Compose project: `claude-bottle-<slug>`.
|
||||
- Compose project: `bot-bottle-<slug>`.
|
||||
- Service names (inside the file): `agent`, `pipelock`,
|
||||
`egress`, `git-gate`, `supervise`.
|
||||
- `container_name:` matches today's pattern
|
||||
(`claude-bottle-<service>-<slug>`) so dashboard/cleanup discovery
|
||||
(`bot-bottle-<service>-<slug>`) so dashboard/cleanup discovery
|
||||
via the prefix scan keeps working through the transition.
|
||||
- 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-<service>-<slug>` long forms) so
|
||||
their `bot-bottle-<service>-<slug>` 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 ----------------------------------------------------
|
||||
@@ -351,7 +351,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:
|
||||
@@ -372,7 +372,7 @@ def slug_from_compose_project(project: str) -> str:
|
||||
|
||||
|
||||
def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
|
||||
"""All compose project names starting with `claude-bottle-`.
|
||||
"""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.
|
||||
@@ -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:<port>`,
|
||||
# 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/<slug>/, under
|
||||
# ~/.claude-bottle which inherits ~'s 0o700) is what actually
|
||||
# ~/.bot-bottle which inherits ~'s 0o700) is what actually
|
||||
# restricts who can reach this file on the host.
|
||||
mitm = work / "mitmproxy-ca.pem"
|
||||
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
|
||||
@@ -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-<slug> (internal),
|
||||
claude-bottle-egress-<slug> (egress). Numeric suffix on conflict
|
||||
Naming: bot-bottle-net-<slug> (internal),
|
||||
bot-bottle-egress-<slug> (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:
|
||||
@@ -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
|
||||
@@ -77,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
|
||||
@@ -93,31 +93,31 @@ def resolve_plan(
|
||||
image_default = per_bottle_image_tag(slug)
|
||||
dockerfile_path = str(per_bottle_dockerfile_path(slug))
|
||||
elif provider.dockerfile:
|
||||
image_default = f"claude-bottle:{provider.template}-{slug}"
|
||||
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 = provider_runtime.image
|
||||
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default)
|
||||
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:
|
||||
@@ -147,7 +147,7 @@ def resolve_plan(
|
||||
)
|
||||
|
||||
# PRD 0018 chunk 2: prepare-time scratch files live under
|
||||
# ~/.claude-bottle/state/<slug>/<service>/ so chunk 3's compose
|
||||
# ~/.bot-bottle/state/<slug>/<service>/ so chunk 3's compose
|
||||
# bind-mounts can point at stable paths. The state subdirs are
|
||||
# cleaned up by start.py's session-end teardown unless something
|
||||
# explicitly preserves the state dir (capability-block, crash).
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
+2
-2
@@ -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}"],
|
||||
+2
-2
@@ -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(
|
||||
+6
-6
@@ -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-<slug>`. Same prefix scheme as the
|
||||
"""`bot-bottle-sidecars-<slug>`. 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}"
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -27,7 +27,7 @@ class SmolmachinesBottleBackend(
|
||||
BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"]
|
||||
):
|
||||
"""smolmachines backend. Selected by
|
||||
`CLAUDE_BOTTLE_BACKEND=smolmachines`."""
|
||||
`BOT_BOTTLE_BACKEND=smolmachines`."""
|
||||
|
||||
name = "smolmachines"
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ from . import smolvm as _smolvm
|
||||
# Absolute path to the pty_resize wrapper. Invoke as
|
||||
# `python <path>` rather than `python -m <dotted-path>` 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__
|
||||
|
||||
|
||||
+4
-4
@@ -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-<slug>`
|
||||
- bundles: docker containers `bot-bottle-sidecars-<slug>`
|
||||
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-<slug>`
|
||||
- networks: docker networks `bot-bottle-bundle-<slug>`
|
||||
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
|
||||
+1
-1
@@ -42,7 +42,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
|
||||
+12
-12
@@ -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-<slug>`).
|
||||
- bundle docker networks (`claude-bottle-bundle-<slug>`).
|
||||
with `bot-bottle-`.
|
||||
- bundle docker containers (`bot-bottle-sidecars-<slug>`).
|
||||
- bundle docker networks (`bot-bottle-bundle-<slug>`).
|
||||
|
||||
State dirs live under `~/.claude-bottle/state/<identity>/` —
|
||||
State dirs live under `~/.bot-bottle/state/<identity>/` —
|
||||
shared layout with the docker backend, which has the single
|
||||
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"):
|
||||
+4
-4
@@ -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-<slug>`,
|
||||
# Smolvm VM names produced by prepare are `bot-bottle-<slug>`,
|
||||
# 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
|
||||
))
|
||||
+4
-4
@@ -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
|
||||
@@ -421,7 +421,7 @@ def _resolve_token_env(
|
||||
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
|
||||
@@ -456,8 +456,8 @@ def _ensure_smolmachine(image_ref: str, *, dockerfile: 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:
|
||||
+5
-5
@@ -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],
|
||||
+2
-2
@@ -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:
|
||||
+5
-5
@@ -119,7 +119,7 @@ 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).
|
||||
# bot_bottle/backend/docker/prepare.py).
|
||||
has_provider_auth = any(
|
||||
provider_runtime.auth_role in r.roles for r in egress_plan.routes
|
||||
)
|
||||
@@ -148,20 +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_dockerfile_path = ""
|
||||
if provider.dockerfile:
|
||||
agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
|
||||
image_default = f"claude-bottle:{provider.template}-{slug}"
|
||||
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("CLAUDE_BOTTLE_IMAGE", image_default)
|
||||
agent_image_ref = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
|
||||
|
||||
return SmolmachinesBottlePlan(
|
||||
spec=spec,
|
||||
+3
-3
@@ -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:
|
||||
+4
-4
@@ -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 —
|
||||
+2
-2
@@ -19,7 +19,7 @@ from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
# In-guest path mirrors the docker backend's claude-skills
|
||||
# convention (~/.claude/skills/<name>/) under the node user's
|
||||
# home — same path as the real claude-bottle image's
|
||||
# home — same path as the real bot-bottle image's
|
||||
# /home/node/.claude/skills (pre-created in the Dockerfile).
|
||||
_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])
|
||||
+1
-1
@@ -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 "
|
||||
"<machine> -- <smolvm-argv...>\n"
|
||||
)
|
||||
return 2
|
||||
+8
-8
@@ -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.
|
||||
|
||||
@@ -33,18 +33,18 @@ from ..docker.sidecar_bundle import SIDECAR_BUNDLE_IMAGE
|
||||
|
||||
|
||||
def bundle_network_name(slug: str) -> str:
|
||||
"""`claude-bottle-bundle-<slug>` — distinct from the docker
|
||||
backend's `claude-bottle-net-<slug>` so a smolmachines bottle
|
||||
"""`bot-bottle-bundle-<slug>` — distinct from the docker
|
||||
backend's `bot-bottle-net-<slug>` 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-<slug>` — same name shape the docker
|
||||
"""`bot-bottle-sidecars-<slug>` — same name shape the docker
|
||||
backend uses for the bundle (PRD 0024 chunk 5). The dashboard's
|
||||
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 +59,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"
|
||||
@@ -141,7 +141,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]
|
||||
+1
-1
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -35,11 +35,11 @@ COMMANDS = {
|
||||
def usage() -> None:
|
||||
sys.stderr.write(f"usage: {PROG} <command> [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")
|
||||
@@ -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/<identity>`
|
||||
want to `resume`. Manual `rm -rf ~/.bot-bottle/state/<identity>`
|
||||
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")
|
||||
@@ -120,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] = []
|
||||
@@ -543,7 +543,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:
|
||||
@@ -638,7 +638,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-<slug>`. For synthesized bottles
|
||||
container name `bot-bottle-<slug>`. For synthesized bottles
|
||||
the prompt-file path comes from the manifest's agent if we can
|
||||
resolve it via metadata.json + the loaded manifest; otherwise
|
||||
the re-attach runs without `--append-system-prompt-file`.
|
||||
@@ -651,18 +651,18 @@ def _bottle_for_slug(
|
||||
_cm, bottle, _identity = bottles[slug]
|
||||
return bottle, ""
|
||||
# The container hosting the agent's claude process is named
|
||||
# `claude-bottle-<slug>` — set by the compose renderer
|
||||
# `bot-bottle-<slug>` — set by the compose renderer
|
||||
# (no service suffix on the agent service, by design).
|
||||
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,
|
||||
@@ -1181,7 +1181,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,
|
||||
@@ -1614,7 +1614,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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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: `<backend>\t<slug>\t<agent>\t<status>`.
|
||||
# Tab-separated keeps the format stable for shell pipelines;
|
||||
@@ -1,6 +1,6 @@
|
||||
"""resume: re-launch a bottle by its identity.
|
||||
|
||||
Reads ~/.claude-bottle/state/<identity>/metadata.json to recover the
|
||||
Reads ~/.bot-bottle/state/<identity>/metadata.json to recover the
|
||||
(agent_name, cwd, copy_cwd) the bottle was originally started with,
|
||||
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)
|
||||
@@ -47,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(
|
||||
@@ -89,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.
|
||||
|
||||
@@ -180,7 +180,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")
|
||||
@@ -202,7 +202,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(
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
|
||||
@@ -158,7 +158,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",
|
||||
@@ -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)
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Reads the per-file manifest tree:
|
||||
|
||||
$HOME/.claude-bottle/bottles/<name>.md — one bottle per file
|
||||
$HOME/.claude-bottle/agents/<name>.md — home-resident agents
|
||||
$CWD/.claude-bottle/agents/<name>.md — cwd-supplied agents
|
||||
$HOME/.bot-bottle/bottles/<name>.md — one bottle per file
|
||||
$HOME/.bot-bottle/agents/<name>.md — home-resident agents
|
||||
$CWD/.bot-bottle/agents/<name>.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
|
||||
@@ -205,7 +205,7 @@ class AgentProvider:
|
||||
|
||||
`template` selects a built-in launch/runtime contract. `dockerfile`
|
||||
optionally points at a custom agent-image Dockerfile while leaving
|
||||
claude-bottle's sidecar infrastructure intact.
|
||||
bot-bottle's sidecar infrastructure intact.
|
||||
"""
|
||||
|
||||
template: str = "claude"
|
||||
@@ -523,7 +523,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
|
||||
|
||||
@@ -665,24 +665,24 @@ class Manifest:
|
||||
"""Walk the per-file manifest tree and build a Manifest.
|
||||
|
||||
Layout (PRD 0011):
|
||||
$HOME/.claude-bottle/bottles/<name>.md — bottles (home-only)
|
||||
$HOME/.claude-bottle/agents/<name>.md — home agents
|
||||
$CWD/.claude-bottle/agents/<name>.md — cwd agents
|
||||
$HOME/.bot-bottle/bottles/<name>.md — bottles (home-only)
|
||||
$HOME/.bot-bottle/agents/<name>.md — home agents
|
||||
$CWD/.bot-bottle/agents/<name>.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 `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():
|
||||
@@ -731,7 +731,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"
|
||||
@@ -771,8 +771,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
|
||||
@@ -783,10 +783,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
|
||||
@@ -822,8 +822,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:
|
||||
@@ -960,7 +960,7 @@ _BOTTLE_KEYS = frozenset(
|
||||
)
|
||||
_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",
|
||||
@@ -971,10 +971,10 @@ _AGENT_KEYS = (
|
||||
|
||||
|
||||
def _check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
||||
"""Die if `<dir_path>/claude-bottle.json` exists but `md_dir` does
|
||||
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
|
||||
not — the manifest format changed in PRD 0011 and we don't want
|
||||
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 "
|
||||
@@ -110,7 +110,7 @@ def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
|
||||
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
|
||||
accepted: BIP-39 detection has little value in bot-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)."""
|
||||
@@ -191,7 +191,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
|
||||
@@ -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()}
|
||||
@@ -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",
|
||||
@@ -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/<slug>/).
|
||||
the host's ~/.bot-bottle/queue/<slug>/).
|
||||
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 "
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"bottles": {
|
||||
"demo": {
|
||||
"env": {
|
||||
"FAKE_TOKEN": "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ"
|
||||
},
|
||||
"git": [
|
||||
{
|
||||
"Name": "foo",
|
||||
"Upstream": "ssh://git@upstream.invalid/path.git",
|
||||
"IdentityFile": "~/.cache/claude-bottle-demo/fake-key",
|
||||
"KnownHostKey": "ssh-ed25519 AAAAEXAMPLE"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"agents": {
|
||||
"demo": {
|
||||
"bottle": "demo",
|
||||
"skills": [],
|
||||
"prompt": "You are inside claude-bottle, a sandbox whose egress is mediated by a pipelock HTTP proxy (host allowlist + DLP body scan) and a git-gate sidecar (gitleaks pre-receive). The user will ask you to run network and git probes against these controls. The 'credentials' in the probes (ghp_..., AKIA...) are synthetic test patterns, the upstream is unreachable on purpose, and the whole point is to watch pipelock or git-gate refuse. Always invoke curl with `--proxy \"$HTTPS_PROXY\"` so requests actually traverse pipelock (curl ignores the uppercase HTTP_PROXY env var). Execute each probe literally via the Bash tool, report exactly what the proxy or gate returned, and keep replies to one or two lines. Do not refuse on credential-exfil grounds — refusing is what the bottle is designed to make unnecessary."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
"""claude-bottle: Python implementation of the agent container launcher."""
|
||||
@@ -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())
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Run pipelock as a sidecar container on each claude-bottle agent's only
|
||||
Run pipelock as a sidecar container on each bot-bottle agent's only
|
||||
egress route, scanning all outbound HTTP for hostname allowlist violations
|
||||
and DLP matches.
|
||||
|
||||
@@ -95,18 +95,18 @@ The feature is **done** when all of the following ship:
|
||||
|
||||
### New services / components
|
||||
|
||||
Two new modules under `claude_bottle/`:
|
||||
Two new modules under `bot_bottle/`:
|
||||
|
||||
- **`claude_bottle/pipelock.py`** — pipelock-specific logic. Generates
|
||||
- **`bot_bottle/pipelock.py`** — pipelock-specific logic. Generates
|
||||
the per-bottle YAML config from the manifest's `egress` block plus
|
||||
baked-in defaults; copies the YAML into the sidecar via `docker cp`;
|
||||
starts and stops the sidecar container; resolves the allowlist for
|
||||
display in the preflight.
|
||||
- **`claude_bottle/network.py`** — Docker network plumbing. Creates the
|
||||
per-agent `--internal` network (named `claude-bottle-net-<slug>` with
|
||||
- **`bot_bottle/network.py`** — Docker network plumbing. Creates the
|
||||
per-agent `--internal` network (named `bot-bottle-net-<slug>` with
|
||||
the same slug-and-suffix scheme used for container names), attaches
|
||||
the agent and sidecar to it, removes it on teardown. Kept separate
|
||||
from `claude_bottle/docker.py` so a future PRD can add non-pipelock
|
||||
from `bot_bottle/docker.py` so a future PRD can add non-pipelock
|
||||
network controls without entangling them with pipelock specifics.
|
||||
|
||||
This split mirrors the existing per-concern module pattern
|
||||
@@ -114,7 +114,7 @@ This split mirrors the existing per-concern module pattern
|
||||
|
||||
### Existing code touched
|
||||
|
||||
- **`claude_bottle/cli/start.py`** — wire the new lifecycle into the
|
||||
- **`bot_bottle/cli/start.py`** — wire the new lifecycle into the
|
||||
`start` subcommand: create the internal network, launch the pipelock
|
||||
sidecar, then launch the agent container with `HTTPS_PROXY` /
|
||||
`HTTP_PROXY` set to the sidecar's service name. Add the resolved
|
||||
@@ -129,9 +129,9 @@ This split mirrors the existing per-concern module pattern
|
||||
the image. This keeps the image agnostic to whether a sidecar is in use
|
||||
(useful if a future bottle definition opts out of the proxy for testing).
|
||||
|
||||
`claude_bottle/docker.py` may grow one or two helpers if there is a
|
||||
`bot_bottle/docker.py` may grow one or two helpers if there is a
|
||||
clean place for shared primitives, but the network-specific helpers
|
||||
live in `claude_bottle/network.py`. Decide during implementation; not a
|
||||
live in `bot_bottle/network.py`. Decide during implementation; not a
|
||||
contract.
|
||||
|
||||
### Data model changes
|
||||
@@ -176,7 +176,7 @@ bottle share the same allowlist.
|
||||
|
||||
- **Pipelock binary** is pulled from
|
||||
`ghcr.io/luckypipewrench/pipelock@sha256:<digest>`. The digest is
|
||||
pinned in `claude_bottle/pipelock.py` (or a sibling constants module)
|
||||
pinned in `bot_bottle/pipelock.py` (or a sibling constants module)
|
||||
and bumped deliberately, mirroring the claude-code version pinning
|
||||
pattern in `Dockerfile`.
|
||||
- No new host-side runtimes. The pipelock image is the only new
|
||||
@@ -192,8 +192,8 @@ bottle share the same allowlist.
|
||||
(proxy + 48 default DLP patterns + subdomain entropy + sidecar
|
||||
topology) is expected to be core-only, but this should be confirmed.
|
||||
- **Where to put the digest pin.** A constant in
|
||||
`claude_bottle/pipelock.py` is the lowest-friction option; a separate
|
||||
`claude_bottle/versions.py` (or similar) may be cleaner once there
|
||||
`bot_bottle/pipelock.py` is the lowest-friction option; a separate
|
||||
`bot_bottle/versions.py` (or similar) may be cleaner once there
|
||||
are multiple pinned dependencies. Decide during implementation.
|
||||
- **Per-agent overrides.** The PRD scopes egress to the bottle. If a
|
||||
later use case calls for tightening (not loosening) the allowlist for
|
||||
|
||||
@@ -14,7 +14,7 @@ second backend ships in this PRD.
|
||||
## Problem
|
||||
|
||||
Today, "how to launch a bottle" is spread across roughly six modules
|
||||
(`claude_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`,
|
||||
(`bot_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`,
|
||||
`skills.py`, `docker.py`), each shelling out to `docker` directly via
|
||||
`subprocess.run(["docker", ...])`. That coupling means:
|
||||
|
||||
@@ -57,22 +57,22 @@ The feature works when all of the following are observable:
|
||||
|
||||
The feature is **done** when all of the following ship:
|
||||
|
||||
- A new `claude_bottle/backend/` package exists with abstract base
|
||||
- A new `bot_bottle/backend/` package exists with abstract base
|
||||
classes (`BottleBackend`, `BottlePlan`, `BottleCleanupPlan`,
|
||||
`Bottle`) plus a `claude_bottle/backend/docker/` subpackage
|
||||
`Bottle`) plus a `bot_bottle/backend/docker/` subpackage
|
||||
containing the `DockerBottleBackend` implementation.
|
||||
- `DockerBottleBackend.launch(plan)` returns a context manager
|
||||
yielding a `Bottle` handle exposing `exec_claude(argv, *, tty=True)`,
|
||||
`cp_in(host, ctr)`, and teardown on context exit.
|
||||
- Every existing `subprocess.run(["docker", ...])` call in
|
||||
`cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and
|
||||
`skills.py` either moves into `claude_bottle/backend/docker/` or is
|
||||
`skills.py` either moves into `bot_bottle/backend/docker/` or is
|
||||
called from it. No top-level CLI code references `docker` directly.
|
||||
- `bottles[].runtime` is removed from the manifest schema, the
|
||||
dataclass in `manifest.py`, the example manifest, and any README /
|
||||
docs references. `require_runsc()` in the old top-level
|
||||
`claude_bottle/docker.py` is deleted.
|
||||
- A single env var, `CLAUDE_BOTTLE_BACKEND` (default `"docker"`),
|
||||
`bot_bottle/docker.py` is deleted.
|
||||
- A single env var, `BOT_BOTTLE_BACKEND` (default `"docker"`),
|
||||
selects the backend. Unknown values die at startup with a list of
|
||||
known backends.
|
||||
- The y/N preflight in `cli.py` includes the resolved Docker runtime
|
||||
@@ -97,8 +97,8 @@ The feature is **done** when all of the following ship:
|
||||
|
||||
### In scope
|
||||
|
||||
- New `claude_bottle/backend/` package containing the abstract types
|
||||
and the registry, plus a `claude_bottle/backend/docker/` subpackage
|
||||
- New `bot_bottle/backend/` package containing the abstract types
|
||||
and the registry, plus a `bot_bottle/backend/docker/` subpackage
|
||||
containing the Docker implementation.
|
||||
- The `Bottle`, `BottleBackend`, `BottlePlan`, and `BottleCleanupPlan`
|
||||
abstract base classes; `BottleSpec` data carrier; and
|
||||
@@ -136,10 +136,10 @@ The feature is **done** when all of the following ship:
|
||||
|
||||
### New services / components
|
||||
|
||||
A new package, `claude_bottle/backend/`, with an abstract base layer
|
||||
A new package, `bot_bottle/backend/`, with an abstract base layer
|
||||
and a Docker subpackage:
|
||||
|
||||
- **`claude_bottle/backend/__init__.py`** — Defines the abstract base
|
||||
- **`bot_bottle/backend/__init__.py`** — Defines the abstract base
|
||||
classes and the backend registry. `BottleSpec` carries the
|
||||
CLI-supplied intent; the abstract `BottlePlan` and
|
||||
`BottleCleanupPlan` are the prepared-but-not-launched outputs of
|
||||
@@ -165,14 +165,14 @@ and a Docker subpackage:
|
||||
`provision_git`); subclasses implement those four rather than
|
||||
overriding `provision` itself.
|
||||
|
||||
Selection reads `CLAUDE_BOTTLE_BACKEND` (default `"docker"`).
|
||||
Selection reads `BOT_BOTTLE_BACKEND` (default `"docker"`).
|
||||
Unknown values call `die()` with the list of known backends:
|
||||
|
||||
```python
|
||||
def get_bottle_backend() -> BottleBackend: ...
|
||||
```
|
||||
|
||||
- **`claude_bottle/backend/docker/`** — Subpackage with the Docker
|
||||
- **`bot_bottle/backend/docker/`** — Subpackage with the Docker
|
||||
implementation, split into:
|
||||
- `backend.py` — `DockerBottleBackend`, owning all five abstract
|
||||
methods (`prepare`, `launch`, `prepare_cleanup`, `cleanup`,
|
||||
@@ -196,49 +196,49 @@ and a Docker subpackage:
|
||||
- `pipelock.py` — `DockerPipelockProxy` (the sidecar start/stop
|
||||
lifecycle) and Docker-specific naming helpers. The backend-neutral
|
||||
yaml + allowlist resolution stays in the top-level
|
||||
`claude_bottle/pipelock.py`.
|
||||
`bot_bottle/pipelock.py`.
|
||||
- `util.py` — Docker-specific helpers (slugify, image/container
|
||||
existence checks, `runsc_available`).
|
||||
|
||||
### Existing code touched
|
||||
|
||||
- **`claude_bottle/cli/start.py`** — replace the inline docker
|
||||
- **`bot_bottle/cli/start.py`** — replace the inline docker
|
||||
orchestration with `backend = get_bottle_backend(); plan =
|
||||
backend.prepare(spec, stage_dir=...); with backend.launch(plan) as
|
||||
bottle: bottle.exec_claude(...)`. The y/N preflight is rendered by
|
||||
`plan.print(...)`.
|
||||
- **`claude_bottle/manifest.py`** — drop the `runtime` field from the
|
||||
- **`bot_bottle/manifest.py`** — drop the `runtime` field from the
|
||||
Bottle dataclass and its validation. Existing manifests with
|
||||
`runtime: "runsc"` produce a clear "no longer supported; gVisor is
|
||||
now auto-detected by the backend; remove the 'runtime' field" error.
|
||||
- **`claude_bottle/docker.py`** — module deleted. `require_runsc()`,
|
||||
- **`bot_bottle/docker.py`** — module deleted. `require_runsc()`,
|
||||
`slugify()`, `image_exists()`, `container_exists()`, the
|
||||
`build_image` / `build_image_with_cwd` helpers, and `require_docker`
|
||||
all migrate into `claude_bottle/backend/docker/util.py` (or
|
||||
all migrate into `bot_bottle/backend/docker/util.py` (or
|
||||
`backend.py`).
|
||||
- **`claude_bottle/pipelock.py`** — keeps the allowlist resolution and
|
||||
- **`bot_bottle/pipelock.py`** — keeps the allowlist resolution and
|
||||
YAML generation. Becomes a thin abstract class (`PipelockProxy`)
|
||||
exposing `prepare` (writes the yaml) plus abstract `start` / `stop`
|
||||
methods. The Docker-specific subclass `DockerPipelockProxy` lives
|
||||
under `backend/docker/pipelock.py`.
|
||||
- **`claude_bottle/network.py`** — folds entirely into
|
||||
- **`bot_bottle/network.py`** — folds entirely into
|
||||
`backend/docker/network.py`. No top-level network module remains.
|
||||
- **`claude_bottle/ssh.py`** and **`claude_bottle/skills.py`** —
|
||||
- **`bot_bottle/ssh.py`** and **`bot_bottle/skills.py`** —
|
||||
absorbed into `DockerBottleBackend` as `provision_ssh` and
|
||||
`provision_skills`. The host-side file-tree generation stays as
|
||||
private helpers on the backend class.
|
||||
- **`claude_bottle/env.py`** (renamed from `env_resolve.py`) —
|
||||
- **`bot_bottle/env.py`** (renamed from `env_resolve.py`) —
|
||||
`resolve_env(manifest, agent) -> ResolvedEnv` returns
|
||||
`forwarded: list[str]` (names whose values were exported into
|
||||
`os.environ` for inheritance) and `literals: dict[str, str]` (name
|
||||
→ verbatim value). The Docker backend translates the result into
|
||||
`--env-file` content + `-e NAME` argv fragments.
|
||||
- **`claude_bottle/util.py`** — top-level cross-backend helpers
|
||||
- **`bot_bottle/util.py`** — top-level cross-backend helpers
|
||||
(`expand_tilde`, `is_ipv4_literal`). Backend-specific helpers live
|
||||
in their backend's `util.py`.
|
||||
- **`claude-bottle.example.json`** — remove the `runtime` field from
|
||||
- **`bot-bottle.example.json`** — remove the `runtime` field from
|
||||
any example bottle.
|
||||
- **`README.md`** — note `CLAUDE_BOTTLE_BACKEND` and the runsc
|
||||
- **`README.md`** — note `BOT_BOTTLE_BACKEND` and the runsc
|
||||
auto-detect; remove any mention of `runtime: "runsc"` as a manifest
|
||||
field.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,7 +75,7 @@ The feature is **done** when all of the following ship:
|
||||
sidecar (read-only) so the running pipelock can read its CA.
|
||||
- `BottleBackend.provision_ca` (new) copies the CA public cert
|
||||
into the agent at
|
||||
`/usr/local/share/ca-certificates/claude-bottle-mitm.crt`, runs
|
||||
`/usr/local/share/ca-certificates/bot-bottle-mitm.crt`, runs
|
||||
`update-ca-certificates`, and sets the `NODE_EXTRA_CA_CERTS` /
|
||||
`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` env trio on the agent
|
||||
container's runtime env. Default no-op on the abstract base so
|
||||
@@ -122,14 +122,14 @@ The feature is **done** when all of the following ship:
|
||||
|
||||
### In scope
|
||||
|
||||
- **`claude_bottle/pipelock.py`** changes:
|
||||
- **`bot_bottle/pipelock.py`** changes:
|
||||
- Extend `pipelock_build_config` to include
|
||||
`tls_interception: { enabled: true, ca_cert: <path>, ca_key:
|
||||
<path> }`. Paths are populated from the plan; the function's
|
||||
signature grows a `cert_path` / `key_path` pair or reads them
|
||||
off `Bottle` once they're stored.
|
||||
- Extend `pipelock_render_yaml` to emit the new block.
|
||||
- **`claude_bottle/backend/docker/pipelock.py`** changes:
|
||||
- **`bot_bottle/backend/docker/pipelock.py`** changes:
|
||||
- New helper `pipelock_tls_init(stage_dir)` runs the upstream
|
||||
image as a one-shot:
|
||||
`docker run --rm -v <stage>:/h -e PIPELOCK_HOME=/h pipelock tls init`,
|
||||
@@ -143,31 +143,31 @@ The feature is **done** when all of the following ship:
|
||||
config. If pipelock's image runs as non-root, a `docker exec
|
||||
-u 0 chown pipelock:pipelock /etc/pipelock/ca*.pem` lands
|
||||
between the `cp` and the `start`.
|
||||
- **`claude_bottle/backend/__init__.py`**: new abstract method
|
||||
- **`bot_bottle/backend/__init__.py`**: new abstract method
|
||||
`provision_ca(plan, target)` on `BottleBackend`, default no-op.
|
||||
`BottleBackend.provision` orchestrates `ca → prompt → skills →
|
||||
ssh → git`.
|
||||
- **`claude_bottle/backend/docker/provision/ca.py`** (new):
|
||||
- **`bot_bottle/backend/docker/provision/ca.py`** (new):
|
||||
- Reads the cert from `stage_dir` (already written by prepare).
|
||||
- `docker cp` into the agent.
|
||||
- `docker exec -u 0 ... chmod 644 ...` + `update-ca-certificates`.
|
||||
- Computes the SHA-256 fingerprint with stdlib (`ssl` +
|
||||
`hashlib`), emits one stderr log line.
|
||||
- **`claude_bottle/backend/docker/launch.py`**:
|
||||
- **`bot_bottle/backend/docker/launch.py`**:
|
||||
- Three new `-e` flags on the agent's `docker run`:
|
||||
`NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/claude-bottle-mitm.crt`,
|
||||
`NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm.crt`,
|
||||
`SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`,
|
||||
`REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`.
|
||||
- `HTTPS_PROXY` / `HTTP_PROXY` continue to point at pipelock
|
||||
(unchanged from PRD 0001 — the mitmproxy detour in PR #8 is
|
||||
abandoned).
|
||||
- **`claude_bottle/backend/docker/bottle_plan.py`**:
|
||||
- **`bot_bottle/backend/docker/bottle_plan.py`**:
|
||||
- One new `info(...)` line in `print()` noting TLS interception
|
||||
is on.
|
||||
- `to_dict()` gains an `egress.tls_interception: { enabled:
|
||||
true, ca_fingerprint: null }` block. Reserved for future
|
||||
population.
|
||||
- **`claude_bottle/backend/docker/prepare.py`**: call
|
||||
- **`bot_bottle/backend/docker/prepare.py`**: call
|
||||
`pipelock_tls_init(stage_dir)` and write the resolved cert/key
|
||||
paths onto the plan (either on the existing `proxy_plan` field
|
||||
or on the parent `DockerBottlePlan`).
|
||||
@@ -221,7 +221,7 @@ generated at prepare time.
|
||||
the one-shot generation step. The rendered YAML references
|
||||
the in-container paths.
|
||||
- **Bottle install.** `provision_ca` (Docker impl) does
|
||||
`docker cp <stage>/ca.pem agent:/usr/local/share/ca-certificates/claude-bottle-mitm.crt`,
|
||||
`docker cp <stage>/ca.pem agent:/usr/local/share/ca-certificates/bot-bottle-mitm.crt`,
|
||||
then `update-ca-certificates`. The CA env trio is set at
|
||||
`docker run -e` time (Docker propagates run-time env into
|
||||
`docker exec`).
|
||||
@@ -235,7 +235,7 @@ generated at prepare time.
|
||||
`stage_dir`. CA dies with both, in that order, so the sidecar
|
||||
is never reading a deleted mount on shutdown.
|
||||
- **Fingerprint.** Computed via stdlib in `provision_ca` and
|
||||
logged once to stderr (`claude-bottle: mitm ca fingerprint:
|
||||
logged once to stderr (`bot-bottle: mitm ca fingerprint:
|
||||
sha256:<hex>…`). The private key never appears in any log.
|
||||
|
||||
### Data model changes
|
||||
@@ -248,18 +248,18 @@ always null at dry-run because the CA doesn't exist yet.
|
||||
|
||||
Surgical, all on the existing pipelock path:
|
||||
|
||||
- `claude_bottle/pipelock.py` — config builder + YAML renderer.
|
||||
- `claude_bottle/backend/__init__.py` — abstract `provision_ca`.
|
||||
- `claude_bottle/backend/docker/pipelock.py` — `tls init` helper,
|
||||
- `bot_bottle/pipelock.py` — config builder + YAML renderer.
|
||||
- `bot_bottle/backend/__init__.py` — abstract `provision_ca`.
|
||||
- `bot_bottle/backend/docker/pipelock.py` — `tls init` helper,
|
||||
sidecar volume mount.
|
||||
- `claude_bottle/backend/docker/prepare.py` — CA paths on plan.
|
||||
- `claude_bottle/backend/docker/launch.py` — CA env trio on agent.
|
||||
- `claude_bottle/backend/docker/backend.py` — `provision_ca`
|
||||
- `bot_bottle/backend/docker/prepare.py` — CA paths on plan.
|
||||
- `bot_bottle/backend/docker/launch.py` — CA env trio on agent.
|
||||
- `bot_bottle/backend/docker/backend.py` — `provision_ca`
|
||||
dispatch + thread `self._proxy` through prepare/launch unchanged
|
||||
shape.
|
||||
- `claude_bottle/backend/docker/bottle_plan.py` — preflight
|
||||
- `bot_bottle/backend/docker/bottle_plan.py` — preflight
|
||||
rendering.
|
||||
- `claude_bottle/backend/docker/provision/ca.py` (new).
|
||||
- `bot_bottle/backend/docker/provision/ca.py` (new).
|
||||
|
||||
Net diff is meaningfully smaller than PR #8 because pipelock
|
||||
already does the work — no addon, no second sidecar, no second
|
||||
|
||||
@@ -95,14 +95,14 @@ back to green is the test.
|
||||
|
||||
Mirror the pipelock layout:
|
||||
|
||||
- **`claude_bottle/ssh_gate.py`** (new): abstract `SSHGate` +
|
||||
- **`bot_bottle/ssh_gate.py`** (new): abstract `SSHGate` +
|
||||
`SSHGatePlan` dataclass. `prepare` is host-side / side-effect-free
|
||||
on docker; renders the forwarder config under `stage_dir`.
|
||||
- **`claude_bottle/backend/docker/ssh_gate.py`** (new):
|
||||
- **`bot_bottle/backend/docker/ssh_gate.py`** (new):
|
||||
`DockerSSHGate` concrete subclass — `start` does `docker create`
|
||||
on the internal network, copies the config in, attaches the
|
||||
egress network, `docker start`. `stop` is idempotent `docker rm
|
||||
-f`. Container name: `claude-bottle-ssh-gate-<slug>`.
|
||||
-f`. Container name: `bot-bottle-ssh-gate-<slug>`.
|
||||
|
||||
Forwarder image: `alpine/socat`, pinned by digest. Must be
|
||||
self-sufficient at boot (no apk/apt pulls on first run) because
|
||||
@@ -126,7 +126,7 @@ rejected at prepare time. One container, N listeners, N upstreams.
|
||||
|
||||
### Existing code touched
|
||||
|
||||
- **`claude_bottle/backend/docker/provision/ssh.py`**: drop the
|
||||
- **`bot_bottle/backend/docker/provision/ssh.py`**: drop the
|
||||
`ProxyCommand socat - PROXY:...` plumbing and the
|
||||
`pipelock_proxy_host_port` import. The rendered `~/.ssh/config`
|
||||
block per entry becomes:
|
||||
@@ -140,19 +140,19 @@ rejected at prepare time. One container, N listeners, N upstreams.
|
||||
`known_hosts` entries are keyed off `<name>` and the new
|
||||
`[<gate-container>]:<listen-port>` form so OpenSSH's strict
|
||||
host-key checking still matches.
|
||||
- **`claude_bottle/pipelock.py`**: delete
|
||||
- **`bot_bottle/pipelock.py`**: delete
|
||||
`pipelock_bottle_ssh_hostnames`, `pipelock_bottle_ssh_trusted_domains`,
|
||||
`pipelock_bottle_ssh_ip_cidrs`, and the calls into them from
|
||||
`pipelock_effective_allowlist` and `pipelock_build_config`. The
|
||||
effective allowlist becomes baked-defaults ∪ `bottle.egress.allowlist`.
|
||||
- **`claude_bottle/backend/docker/backend.py`**: instantiate
|
||||
- **`bot_bottle/backend/docker/backend.py`**: instantiate
|
||||
`DockerSSHGate` alongside `DockerPipelockProxy`; thread its
|
||||
`prepare` / `start` / `stop` through `resolve_plan` / `launch`.
|
||||
- **`claude_bottle/backend/docker/launch.py`**: add gate start /
|
||||
- **`bot_bottle/backend/docker/launch.py`**: add gate start /
|
||||
stop to the `ExitStack` in the right order — gate must be up
|
||||
before `provision_ssh` runs so the agent can dial it on first
|
||||
boot.
|
||||
- **`claude_bottle/backend/docker/bottle_plan.py`**: new
|
||||
- **`bot_bottle/backend/docker/bottle_plan.py`**: new
|
||||
`SSHGatePlan` field on `DockerBottlePlan`; preflight rendering
|
||||
surfaces the gate sidecar (name, per-entry listen ports,
|
||||
upstream `Hostname:Port` targets).
|
||||
@@ -165,7 +165,7 @@ rejected at prepare time. One container, N listeners, N upstreams.
|
||||
### Data model changes
|
||||
|
||||
None. `bottle.ssh` schema is unchanged; one new internal plan
|
||||
dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
|
||||
dataclass (`SSHGatePlan`) under `bot_bottle/ssh_gate.py`.
|
||||
|
||||
### External dependencies
|
||||
|
||||
@@ -202,7 +202,7 @@ dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
|
||||
- PRD 0006: pipelock native TLS interception — the change that
|
||||
surfaced this regression by making pipelock incompatible with
|
||||
SSH-over-CONNECT.
|
||||
- `claude_bottle/backend/docker/provision/ssh.py` — current SSH
|
||||
- `bot_bottle/backend/docker/provision/ssh.py` — current SSH
|
||||
provisioning that this PRD rewrites.
|
||||
- `claude_bottle/pipelock.py` — current pipelock config builder
|
||||
- `bot_bottle/pipelock.py` — current pipelock config builder
|
||||
that gains the `bottle.ssh`-derived fields this PRD removes.
|
||||
|
||||
+10
-10
@@ -26,7 +26,7 @@ entry and pushes straight at gitea/github with ssh-gate doing dumb
|
||||
L4 forwarding. There is no boundary between "the agent thinks this
|
||||
commit is fine" and "the secret hits an external remote." If a
|
||||
compromised or careless agent stages a `.env`, slips a token into
|
||||
a fixture, or commits the `CLAUDE_BOTTLE_OAUTH_TOKEN` itself, `git
|
||||
a fixture, or commits the `BOT_BOTTLE_OAUTH_TOKEN` itself, `git
|
||||
push` ships it.
|
||||
|
||||
Host-side pre-commit / pre-push hooks are the usual defense, but
|
||||
@@ -131,16 +131,16 @@ for a declared upstream:
|
||||
|
||||
Mirror the existing sidecar layout:
|
||||
|
||||
- **`claude_bottle/git_gate.py`** (new): abstract `GitGate` +
|
||||
- **`bot_bottle/git_gate.py`** (new): abstract `GitGate` +
|
||||
`GitGatePlan` dataclass. `prepare` is host-side / side-effect-
|
||||
free on docker; renders the per-upstream config and stages the
|
||||
push credentials under `stage_dir`.
|
||||
- **`claude_bottle/backend/docker/git_gate.py`** (new):
|
||||
- **`bot_bottle/backend/docker/git_gate.py`** (new):
|
||||
`DockerGitGate` concrete subclass. `start` does `docker create`
|
||||
on the internal network, copies in the bare-repo skeleton, the
|
||||
hook script, and per-upstream credentials, then `docker start`.
|
||||
`stop` is idempotent `docker rm -f`. Container name:
|
||||
`claude-bottle-git-gate-<slug>`.
|
||||
`bot-bottle-git-gate-<slug>`.
|
||||
|
||||
Gate image: `git-daemon` + `openssh-client` over a
|
||||
`zricethezav/gitleaks` base (alpine + gitleaks), pinned by digest.
|
||||
@@ -173,21 +173,21 @@ operation.
|
||||
|
||||
### Existing code touched
|
||||
|
||||
- **`claude_bottle/manifest.py`**: parse and validate the new
|
||||
- **`bot_bottle/manifest.py`**: parse and validate the new
|
||||
`bottle.git` block; reject `bottle.ssh` entries whose upstream
|
||||
is also claimed by a `bottle.git` upstream (one path per
|
||||
remote, no shadow route).
|
||||
- **`claude_bottle/backend/docker/provision/git.py`** (new) or an
|
||||
- **`bot_bottle/backend/docker/provision/git.py`** (new) or an
|
||||
extension of the ssh provisioner: render the `insteadOf` config
|
||||
and any extra `~/.gitconfig` plumbing.
|
||||
- **`claude_bottle/backend/docker/backend.py`**: instantiate
|
||||
- **`bot_bottle/backend/docker/backend.py`**: instantiate
|
||||
`DockerGitGate` alongside `DockerPipelockProxy` and
|
||||
`DockerSSHGate`; thread its `prepare` / `start` / `stop`
|
||||
through `resolve_plan` / `launch`.
|
||||
- **`claude_bottle/backend/docker/launch.py`**: add gate start /
|
||||
- **`bot_bottle/backend/docker/launch.py`**: add gate start /
|
||||
stop to the `ExitStack` so the gate is up before any
|
||||
provisioner that writes the agent's `~/.gitconfig`.
|
||||
- **`claude_bottle/backend/docker/bottle_plan.py`**: new
|
||||
- **`bot_bottle/backend/docker/bottle_plan.py`**: new
|
||||
`GitGatePlan` field on `DockerBottlePlan`; preflight rendering
|
||||
surfaces the gate sidecar (name, per-upstream local paths,
|
||||
upstream real URLs, which credential is in use).
|
||||
@@ -249,6 +249,6 @@ exposes it as, and the credential the gate uses to push upstream
|
||||
- PRD 0007: SSH egress gate — the L4 SSH forwarder this PRD
|
||||
sits alongside; explicitly *not* the place to add
|
||||
git-protocol awareness.
|
||||
- `claude_bottle/ssh_gate.py` / `claude_bottle/pipelock.py` —
|
||||
- `bot_bottle/ssh_gate.py` / `bot_bottle/pipelock.py` —
|
||||
existing sidecar abstractions to mirror.
|
||||
- gitleaks: <https://github.com/gitleaks/gitleaks>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,7 @@ already rely on.
|
||||
The research note
|
||||
[`agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md)
|
||||
surveys the existing tools and concludes that a small
|
||||
claude-bottle-specific reverse proxy is less work and less risk
|
||||
bot-bottle-specific reverse proxy is less work and less risk
|
||||
than either adopting nono (alpha, unaudited) or Infisical Agent
|
||||
Vault (TLS-MITM topology that doubles up on pipelock's CA stack).
|
||||
This PRD is the build.
|
||||
@@ -118,7 +118,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
|
||||
- **Cross-bottle credential sharing.** One proxy per bottle, same
|
||||
one-sidecar-per-agent posture as pipelock and git-gate.
|
||||
- **`claude --bare` mode.** Reads only `ANTHROPIC_API_KEY`, not
|
||||
the OAuth token. Not in claude-bottle's flow today.
|
||||
the OAuth token. Not in bot-bottle's flow today.
|
||||
- **MCP-server tokens, package-installer tokens for languages
|
||||
beyond npm.** PyPI / Bun / cargo can land in a follow-up if
|
||||
needed; the routing pattern generalizes.
|
||||
@@ -175,7 +175,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
|
||||
side-effect-free; `start` does `docker create` + `docker start`
|
||||
on the bottle's internal network with hostname `cred-proxy`;
|
||||
`stop` is idempotent `docker rm -f`. Container name:
|
||||
`claude-bottle-cred-proxy-<slug>`. The agent container starts
|
||||
`bot-bottle-cred-proxy-<slug>`. The agent container starts
|
||||
after the sidecar is up so DNS resolution succeeds on the
|
||||
agent's first call.
|
||||
- **pipelock interop.** cred-proxy's outbound HTTPS traverses
|
||||
@@ -230,7 +230,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
|
||||
```
|
||||
┌── Host (macOS) ──────────────────────────────────────────────────┐
|
||||
│ Secrets at rest (keychain / .env): │
|
||||
│ CLAUDE_BOTTLE_OAUTH_TOKEN, GITHUB_TOKEN, │
|
||||
│ BOT_BOTTLE_OAUTH_TOKEN, GITHUB_TOKEN, │
|
||||
│ GITEA_SERVER_TOKEN, NPM_TOKEN │
|
||||
│ │ docker run -e KEY (no =VALUE on argv) │
|
||||
│ ▼ │
|
||||
@@ -288,18 +288,18 @@ Why the agent can't reach the sidecar's environ:
|
||||
|
||||
### New components
|
||||
|
||||
- **`claude_bottle/cred_proxy.py`** (new): abstract `CredProxy`
|
||||
- **`bot_bottle/cred_proxy.py`** (new): abstract `CredProxy`
|
||||
+ `CredProxyPlan` dataclass. `prepare` is host-side and
|
||||
side-effect-free; renders the route table and resolves
|
||||
`TokenRef`s against host env. Mirrors the existing `GitGate` /
|
||||
`Pipelock` shape.
|
||||
- **`claude_bottle/backend/docker/cred_proxy.py`** (new):
|
||||
- **`bot_bottle/backend/docker/cred_proxy.py`** (new):
|
||||
`DockerCredProxy` concrete subclass. `start` does
|
||||
`docker create` on the bottle's internal network with hostname
|
||||
`cred-proxy`, copies the route-table file into the container,
|
||||
then `docker start`. `stop` is idempotent `docker rm -f`.
|
||||
Container name: `claude-bottle-cred-proxy-<slug>`.
|
||||
- **`claude_bottle/backend/docker/provision/cred_proxy.py`**
|
||||
Container name: `bot-bottle-cred-proxy-<slug>`.
|
||||
- **`bot_bottle/backend/docker/provision/cred_proxy.py`**
|
||||
(new): renders `ANTHROPIC_BASE_URL`, `~/.npmrc`,
|
||||
`~/.gitconfig` `insteadOf` blocks, and `~/.config/tea/config.yml`
|
||||
into the agent's home for each declared kind — all pointing at
|
||||
@@ -310,12 +310,12 @@ Why the agent can't reach the sidecar's environ:
|
||||
|
||||
### Existing code touched
|
||||
|
||||
- **`claude_bottle/manifest.py`** — add `CredProxyRoute`,
|
||||
- **`bot_bottle/manifest.py`** — add `CredProxyRoute`,
|
||||
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
|
||||
+ validate route shape, role enum, path uniqueness, singleton-
|
||||
role constraints.
|
||||
- **`claude_bottle/backend/docker/prepare.py`** — drop the
|
||||
legacy `CLAUDE_BOTTLE_OAUTH_TOKEN` → `CLAUDE_CODE_OAUTH_TOKEN`
|
||||
- **`bot_bottle/backend/docker/prepare.py`** — drop the
|
||||
legacy `BOT_BOTTLE_OAUTH_TOKEN` → `CLAUDE_CODE_OAUTH_TOKEN`
|
||||
forward entirely. cred-proxy is the only path the Anthropic
|
||||
OAuth token reaches the bottle. When a route claims the
|
||||
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL`
|
||||
@@ -324,27 +324,27 @@ Why the agent can't reach the sidecar's environ:
|
||||
otherwise; the proxy strips & replaces on every request).
|
||||
Bottles that need claude-code to authenticate must declare
|
||||
the route; there is no fallback.
|
||||
- **`claude_bottle/backend/docker/backend.py`** — instantiate
|
||||
- **`bot_bottle/backend/docker/backend.py`** — instantiate
|
||||
`DockerCredProxy` alongside `DockerPipelockProxy` and
|
||||
`DockerGitGate`; thread its `prepare` / `start` / `stop`
|
||||
through `resolve_plan` / `launch`.
|
||||
- **`claude_bottle/backend/docker/launch.py`** — add cred-proxy
|
||||
- **`bot_bottle/backend/docker/launch.py`** — add cred-proxy
|
||||
start/stop to the `ExitStack` after pipelock and before the
|
||||
agent; populate `pipelock_proxy_url` + `pipelock_ca_host_path`
|
||||
on the cred-proxy plan so its outbound HTTPS routes through
|
||||
pipelock.
|
||||
- **`claude_bottle/backend/docker/bottle_plan.py`** — new
|
||||
- **`bot_bottle/backend/docker/bottle_plan.py`** — new
|
||||
`cred_proxy_plan` field; preflight shows route count + token
|
||||
refs + a path→upstream line per route; `to_dict` emits a
|
||||
`cred_proxy` array of `{path, upstream, auth_scheme, token_ref,
|
||||
roles}`.
|
||||
- **`claude_bottle/pipelock.py`** — `pipelock_token_hosts` derives
|
||||
- **`bot_bottle/pipelock.py`** — `pipelock_token_hosts` derives
|
||||
from each route's `UpstreamHost` (not a hardcoded Kind→hosts
|
||||
map). Allowlist auto-includes them; passthrough does not (the
|
||||
proxy trusts pipelock's CA so MITM works).
|
||||
- **`README.md`** — architecture diagram includes the cred-proxy
|
||||
lane; manifest section documents `bottle.cred_proxy.routes`.
|
||||
- **`claude-bottle.example.json`** — one bottle demonstrates the
|
||||
- **`bot-bottle.example.json`** — one bottle demonstrates the
|
||||
four common routes (Anthropic, GitHub, Gitea, npm).
|
||||
- **Tests** — manifest parsing/validation, route lift + token-env
|
||||
slot assignment, role-based dispatch in the provisioner,
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the single-file `claude-bottle.json` manifest with a
|
||||
Replace the single-file `bot-bottle.json` manifest with a
|
||||
per-file Markdown-with-YAML-frontmatter layout. Bottles live as
|
||||
`$HOME/.claude-bottle/bottles/<name>.md`; agents live as
|
||||
`$HOME/.claude-bottle/agents/<name>.md` (home-resident) and
|
||||
`$CWD/.claude-bottle/agents/<name>.md` (repo-supplied). Each file
|
||||
`$HOME/.bot-bottle/bottles/<name>.md`; agents live as
|
||||
`$HOME/.bot-bottle/agents/<name>.md` (home-resident) and
|
||||
`$CWD/.bot-bottle/agents/<name>.md` (repo-supplied). Each file
|
||||
carries its structured config in YAML frontmatter and (for agents)
|
||||
its system prompt in the Markdown body.
|
||||
|
||||
@@ -28,7 +28,7 @@ PyYAML dependency. The project's "low deps by default" stance
|
||||
|
||||
## Problem
|
||||
|
||||
`claude-bottle.json` works fine at one bottle and one agent. The
|
||||
`bot-bottle.json` works fine at one bottle and one agent. The
|
||||
project is heading for many of both, and the single-JSON shape
|
||||
starts to fray:
|
||||
|
||||
@@ -60,22 +60,22 @@ axes (grouping × format) and lands on this design.
|
||||
|
||||
Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
||||
|
||||
1. **A bottle file under `$HOME/.claude-bottle/bottles/`
|
||||
1. **A bottle file under `$HOME/.bot-bottle/bottles/`
|
||||
parses.** A `dev.md` file with YAML frontmatter declaring
|
||||
`cred_proxy.routes`, `git`, `env`, `egress` produces a Bottle
|
||||
dataclass equivalent to the current JSON shape.
|
||||
|
||||
2. **An agent file under `$HOME/.claude-bottle/agents/` parses.**
|
||||
2. **An agent file under `$HOME/.bot-bottle/agents/` parses.**
|
||||
`implementer.md` with frontmatter that names `bottle:`,
|
||||
`skills:`, and other fields, with the body as the system
|
||||
prompt, produces an Agent dataclass.
|
||||
|
||||
3. **An agent file under `$CWD/.claude-bottle/agents/` parses
|
||||
3. **An agent file under `$CWD/.bot-bottle/agents/` parses
|
||||
and overrides home-resident agents of the same name.** The
|
||||
cwd agent's frontmatter and body win; the home bottle it
|
||||
references stays intact.
|
||||
|
||||
4. **A bottle file under `$CWD/.claude-bottle/bottles/` is
|
||||
4. **A bottle file under `$CWD/.bot-bottle/bottles/` is
|
||||
ignored.** The directory does not contribute to the
|
||||
manifest; if a user accidentally creates one, the launcher
|
||||
emits a `warn`-level log naming the offending files and
|
||||
@@ -83,7 +83,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
||||
is a usability nicety, not a security gate.
|
||||
|
||||
5. **No third-party Python dependencies introduced.** A fresh
|
||||
clone with only stdlib + claude-bottle's own code runs every
|
||||
clone with only stdlib + bot-bottle's own code runs every
|
||||
parser test. Frontmatter parsing is hand-rolled against the
|
||||
declared YAML subset.
|
||||
|
||||
@@ -97,30 +97,30 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
||||
`name`, `description`, `model`, `color`, and `memory` fields
|
||||
from Claude Code's existing subagent spec are accepted in
|
||||
our frontmatter alongside our own fields. Copying an agent
|
||||
file from `$HOME/.claude-bottle/agents/` to
|
||||
file from `$HOME/.bot-bottle/agents/` to
|
||||
`~/.claude/agents/` produces a working Claude Code subagent
|
||||
(subject to Claude Code's tolerance for the extra `bottle:`
|
||||
and `claude_bottle:` fields — see Open Questions).
|
||||
and `bot_bottle:` fields — see Open Questions).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **A general YAML implementation.** The parser handles the
|
||||
subset claude-bottle's frontmatter actually uses; documents
|
||||
subset bot-bottle's frontmatter actually uses; documents
|
||||
that exceed the subset (anchors, multi-line block scalars,
|
||||
tags, implicit type coercion, flow style, etc.) die with a
|
||||
pointer at the spec. We are not building a YAML library.
|
||||
|
||||
- **Compatibility with the old JSON layout at runtime.** The
|
||||
resolver no longer reads `claude-bottle.json` files. This is
|
||||
resolver no longer reads `bot-bottle.json` files. This is
|
||||
a breaking change; existing users hand-rewrite their JSON
|
||||
into the new per-file layout (claude-bottle has a single
|
||||
into the new per-file layout (bot-bottle has a single
|
||||
primary user today, so the migration is one person rewriting
|
||||
one file). Documented as part of the README rewrite.
|
||||
|
||||
- **`$HOME/.claude/agents/` integration on the input side.** We
|
||||
don't read agent files out of Claude Code's directory. Our
|
||||
files can be copied into Claude Code's tree by the user if
|
||||
they want, but the input path for claude-bottle is its own
|
||||
they want, but the input path for bot-bottle is its own
|
||||
directory.
|
||||
|
||||
- **A signed-manifest scheme.** Out of scope per the
|
||||
@@ -139,14 +139,14 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
||||
### In scope
|
||||
|
||||
- **Directory layout.**
|
||||
- `$HOME/.claude-bottle/bottles/<name>.md` — bottle
|
||||
- `$HOME/.bot-bottle/bottles/<name>.md` — bottle
|
||||
definitions (full schema; one Bottle per file).
|
||||
- `$HOME/.claude-bottle/agents/<name>.md` — home-resident
|
||||
- `$HOME/.bot-bottle/agents/<name>.md` — home-resident
|
||||
agents.
|
||||
- `$CWD/.claude-bottle/agents/<name>.md` — cwd-resident
|
||||
- `$CWD/.bot-bottle/agents/<name>.md` — cwd-resident
|
||||
agents; same schema as home agents, but bottle names must
|
||||
resolve against the home set.
|
||||
- `$CWD/.claude-bottle/bottles/` — ignored with a warn-level
|
||||
- `$CWD/.bot-bottle/bottles/` — ignored with a warn-level
|
||||
log (see SC #4). Does not contribute to the manifest.
|
||||
- `<name>` is the file basename without `.md`. Filenames must
|
||||
match `[a-z][a-z0-9-]*` (kebab-case, ASCII-only).
|
||||
@@ -162,7 +162,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
||||
- `skills: [<name>, ...]` (optional) — host-side skills under
|
||||
`~/.claude/skills/`.
|
||||
- `name`, `description`, `model`, `color`, `memory` — accepted
|
||||
but treated as Claude Code passthrough; claude-bottle
|
||||
but treated as Claude Code passthrough; bot-bottle
|
||||
ignores them at launch but doesn't reject. Lets the same
|
||||
file double as a Claude Code subagent.
|
||||
- Unknown top-level keys die with a hint listing accepted
|
||||
@@ -191,17 +191,17 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
||||
the required-keys check — same diagnostic as malformed).
|
||||
|
||||
- **Manifest assembly.** New resolver:
|
||||
1. Walk `$HOME/.claude-bottle/bottles/*.md` → Bottle dict
|
||||
1. Walk `$HOME/.bot-bottle/bottles/*.md` → Bottle dict
|
||||
keyed by filename.
|
||||
2. Walk `$HOME/.claude-bottle/agents/*.md` → Agent dict.
|
||||
3. Walk `$CWD/.claude-bottle/agents/*.md` → Agent dict; merge
|
||||
2. Walk `$HOME/.bot-bottle/agents/*.md` → Agent dict.
|
||||
3. Walk `$CWD/.bot-bottle/agents/*.md` → Agent dict; merge
|
||||
into the home agent dict, cwd wins on name collision.
|
||||
4. Validate every agent's `bottle:` against the bottle dict.
|
||||
5. Warn if `$CWD/.claude-bottle/bottles/` exists with files.
|
||||
5. Warn if `$CWD/.bot-bottle/bottles/` exists with files.
|
||||
6. Return Manifest dataclass — same shape as today.
|
||||
|
||||
- **Docs.** README's manifest section rewrites against the new
|
||||
layout. `claude-bottle.example.json` becomes
|
||||
layout. `bot-bottle.example.json` becomes
|
||||
`examples/bottles/dev.md` + `examples/agents/implementer.md`.
|
||||
The PRD 0010 example block in its own document gets a
|
||||
follow-up commit noting the new layout (out of scope for
|
||||
@@ -233,7 +233,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
||||
### File layout
|
||||
|
||||
```
|
||||
$HOME/.claude-bottle/
|
||||
$HOME/.bot-bottle/
|
||||
├── bottles/
|
||||
│ ├── dev.md
|
||||
│ ├── gitea-dev.md
|
||||
@@ -243,7 +243,7 @@ $HOME/.claude-bottle/
|
||||
├── researcher.md
|
||||
└── ...
|
||||
|
||||
$CWD/.claude-bottle/
|
||||
$CWD/.bot-bottle/
|
||||
└── agents/
|
||||
└── <repo-specific>.md
|
||||
```
|
||||
@@ -261,7 +261,7 @@ cred_proxy:
|
||||
- path: /anthropic/
|
||||
upstream: https://api.anthropic.com
|
||||
auth_scheme: Bearer
|
||||
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
|
||||
token_ref: BOT_BOTTLE_OAUTH_TOKEN
|
||||
role: anthropic-base-url
|
||||
- path: /gitea/dideric/
|
||||
upstream: https://gitea.dideric.is
|
||||
@@ -271,8 +271,8 @@ cred_proxy:
|
||||
git:
|
||||
remotes:
|
||||
gitea.dideric.is:
|
||||
Name: claude-bottle
|
||||
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
|
||||
Name: bot-bottle
|
||||
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
||||
IdentityFile: ~/.ssh/gitea-delos-2.pem
|
||||
ExtraHosts:
|
||||
gitea.dideric.is: 100.78.141.42
|
||||
@@ -302,7 +302,7 @@ skills:
|
||||
---
|
||||
|
||||
You are a feature-implementation agent running inside an
|
||||
ephemeral claude-bottle sandbox...
|
||||
ephemeral bot-bottle sandbox...
|
||||
```
|
||||
|
||||
Drop the same file into `~/.claude/agents/implementer.md` and
|
||||
@@ -336,7 +336,7 @@ Notable rejections (each dies with a specific error):
|
||||
be ambiguous, quote it.
|
||||
- Flow style mappings nested more than one level deep.
|
||||
|
||||
Parser lives at `claude_bottle/yaml_subset.py`, ~300 lines.
|
||||
Parser lives at `bot_bottle/yaml_subset.py`, ~300 lines.
|
||||
Public API:
|
||||
|
||||
```python
|
||||
@@ -348,14 +348,14 @@ def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
|
||||
|
||||
### Existing code touched
|
||||
|
||||
- **`claude_bottle/manifest.py`** — `Manifest.resolve` rewritten
|
||||
- **`bot_bottle/manifest.py`** — `Manifest.resolve` rewritten
|
||||
to walk the new directories. `Manifest.from_json_obj` kept as
|
||||
a programmatic entry point (used by tests). New
|
||||
`Manifest.from_md_dirs(home_dir, cwd_dir)` for the loader.
|
||||
- **`claude_bottle/yaml_subset.py`** — new. The parser.
|
||||
- **`bot_bottle/yaml_subset.py`** — new. The parser.
|
||||
- **`README.md`** — manifest section rewritten against the new
|
||||
layout.
|
||||
- **`claude-bottle.example.json`** — removed; replaced by an
|
||||
- **`bot-bottle.example.json`** — removed; replaced by an
|
||||
`examples/` directory with one bottle file + one agent file.
|
||||
- **Tests** — new parser tests + new loader tests; existing
|
||||
manifest tests adapt to either build via `from_json_obj`
|
||||
@@ -368,12 +368,12 @@ etc. all stay the same shape. Only the loader changes.
|
||||
|
||||
### Backward compatibility
|
||||
|
||||
This is a breaking change for v1 users. claude-bottle has a
|
||||
This is a breaking change for v1 users. bot-bottle has a
|
||||
single primary user today, so migration is one person rewriting
|
||||
one file — no automated migration command is in scope.
|
||||
|
||||
If `claude-bottle.json` exists in `$HOME` or `$CWD` *and* the
|
||||
new `.claude-bottle/` directory does not exist, the resolver
|
||||
If `bot-bottle.json` exists in `$HOME` or `$CWD` *and* the
|
||||
new `.bot-bottle/` directory does not exist, the resolver
|
||||
dies with a clear pointer at the README's manifest section —
|
||||
not silently merging formats, not silently dropping the JSON
|
||||
content.
|
||||
@@ -384,11 +384,11 @@ content.
|
||||
empirically before settling: drop a file with `bottle: dev`
|
||||
in `~/.claude/agents/` and see whether Claude Code warns,
|
||||
ignores, or breaks. If it warns, namespace the field
|
||||
(`claude-bottle-bottle:` or a nested `claude_bottle:` block).
|
||||
- **Hidden directory vs visible.** Default `.claude-bottle/`
|
||||
(`bot-bottle-bottle:` or a nested `bot_bottle:` block).
|
||||
- **Hidden directory vs visible.** Default `.bot-bottle/`
|
||||
(hidden — matches `.config/`, `.ssh/`, `.docker/`). If users
|
||||
routinely want to navigate to it from the file manager,
|
||||
switch to `claude-bottle/`. Lean hidden.
|
||||
switch to `bot-bottle/`. Lean hidden.
|
||||
- **`description:` for bottles.** Should bottle frontmatter
|
||||
carry a `description:` field for the y/N preflight? Default
|
||||
no — bottle names are kebab-case and self-describing, and
|
||||
|
||||
@@ -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: <new file>, justification: "..."}`. The operator reviews the diff in the TUI; on approval, the supervisor writes the new `routes.json` and cred-proxy SIGHUP-reloads. In-flight connections are not dropped. The tool returns `{status: "approved", notes: "..."}` and the agent retries. Implementation: PRD 0014.
|
||||
- **cred-proxy block.** Tool: `cred-proxy-block`. The agent's request was refused by cred-proxy — missing route, expired token, wrong scope. The agent reads the current `routes.json` from `/etc/bot-bottle/current-config/`, composes a modified version, and calls the tool with `{routes: <new file>, justification: "..."}`. The operator reviews the diff in the TUI; on approval, the supervisor writes the new `routes.json` and cred-proxy SIGHUP-reloads. In-flight connections are not dropped. The tool returns `{status: "approved", notes: "..."}` and the agent retries. Implementation: PRD 0014.
|
||||
- **pipelock block.** Tool: `pipelock-block`. The agent's outbound request was refused by pipelock — host not in the allowlist, protocol not permitted. The agent reads the current allowlist, composes a modified version, and calls the tool with `{allowlist: <new file>, justification: "..."}`. On approval, the supervisor writes the new allowlist and restarts pipelock; in-flight outbound calls may drop and rely on retry. The tool returns the same approve/reject shape. Implementation: PRD 0015.
|
||||
- **capability block.** Tool: `capability-block`. The bottle is missing a tool, skill, permission, or env var the agent needs — something that lives in the agent Dockerfile rather than in routes or the pipelock allowlist. The agent reads the current Dockerfile, composes a modified version, and calls the tool with `{dockerfile: <new file>, justification: "..."}`. On approval, the rebuild orchestrator tears down the bottle, builds from the new Dockerfile, and starts a replacement bottle on the same branch via the state-preservation helper. Because the current agent is about to be replaced, the tool's return is best-effort — the replacement agent inherits the approval record via the preserved transcript. Implementation: PRD 0016.
|
||||
|
||||
|
||||
@@ -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/<slug>/` (file-per-proposal, with metadata and proposed file content).
|
||||
- A `claude-bottle dashboard` (or similarly named) TUI that lists running bottles and pending proposals across all of them; supports approve, modify-then-approve, and reject-with-reason for each pending proposal.
|
||||
- Audit log files at `~/.claude-bottle/audit/cred-proxy-<slug>.log` and `~/.claude-bottle/audit/pipelock-<slug>.log` with the agreed-upon format (timestamp, diff before/after, justification text, operator action with notes). Entries are written by the supervisor on each approve/modify/reject decision. (capability-block has no separate audit log — capability changes are captured by the bottle's rebuild record / git history.)
|
||||
- A read-only mount at `/etc/bot-bottle/current-config/` in the agent container exposing the current `routes.json`, pipelock allowlist, and Dockerfile.
|
||||
- A host-mounted per-bottle proposal queue at `~/.bot-bottle/queue/<slug>/` (file-per-proposal, with metadata and proposed file content).
|
||||
- A `bot-bottle dashboard` (or similarly named) TUI that lists running bottles and pending proposals across all of them; supports approve, modify-then-approve, and reject-with-reason for each pending proposal.
|
||||
- Audit log files at `~/.bot-bottle/audit/cred-proxy-<slug>.log` and `~/.bot-bottle/audit/pipelock-<slug>.log` with the agreed-upon format (timestamp, diff before/after, justification text, operator action with notes). Entries are written by the supervisor on each approve/modify/reject decision. (capability-block has no separate audit log — capability changes are captured by the bottle's rebuild record / git history.)
|
||||
- Bottle lifecycle script changes to launch the MCP sidecar alongside the other sidecars and mount the read-only current-config directory.
|
||||
|
||||
### Out of scope
|
||||
@@ -49,15 +49,15 @@ See PRD 0012 for the broader stuck-agent problem. This PRD specifically addresse
|
||||
### New services / components
|
||||
|
||||
- **MCP sidecar.** New per-bottle container on the bottle's internal network. Exposes the three tools to the agent. On a tool call: validates the proposed file syntactically (valid JSON for `routes.json`, parseable Dockerfile, etc.), writes the proposal to the queue, and holds the tool-call connection open until the supervisor responds. Returns `{status, notes}` to the agent on response.
|
||||
- **Read-only current-config mount.** `/etc/claude-bottle/current-config/` in the agent container exposes `routes.json`, the pipelock allowlist, and the agent Dockerfile from the host. Read-only — the agent proposes changes via the tool call, never by writing the file directly.
|
||||
- **Proposal queue.** Per-bottle directory under `~/.claude-bottle/queue/<slug>/` on the host. One file per pending proposal with `{id, tool, proposed_file, justification, arrival_timestamp, current_file_hash, bottle_slug}`.
|
||||
- **Read-only current-config mount.** `/etc/bot-bottle/current-config/` in the agent container exposes `routes.json`, the pipelock allowlist, and the agent Dockerfile from the host. Read-only — the agent proposes changes via the tool call, never by writing the file directly.
|
||||
- **Proposal queue.** Per-bottle directory under `~/.bot-bottle/queue/<slug>/` on the host. One file per pending proposal with `{id, tool, proposed_file, justification, arrival_timestamp, current_file_hash, bottle_slug}`.
|
||||
- **Minimal TUI dashboard.** Lists running bottles and pending proposals. For each proposal: shows current vs. proposed diff and justification. Operator actions: approve / modify-then-approve / reject-with-reason. Stdlib only (curses) unless that proves painful.
|
||||
- **Audit log format.** Append-only files at `~/.claude-bottle/audit/<component>-<slug>.log`. Each entry: timestamp, diff before/after, agent justification (if from a tool call), operator action + notes. Defines the format; the per-component PRDs (0014, 0015) fill in real entries.
|
||||
- **Audit log format.** Append-only files at `~/.bot-bottle/audit/<component>-<slug>.log`. Each entry: timestamp, diff before/after, agent justification (if from a tool call), operator action + notes. Defines the format; the per-component PRDs (0014, 0015) fill in real entries.
|
||||
- **No-op approval handlers.** Each tool's approve path in 0013 writes an audit entry and returns `{status: "approved"}` to the agent but doesn't actually change any config. 0014 / 0015 / 0016 replace these with real handlers.
|
||||
|
||||
### Existing code touched
|
||||
|
||||
- **Bottle lifecycle scripts** — launch the MCP sidecar alongside other sidecars; mount `/etc/claude-bottle/current-config/` read-only into the agent container.
|
||||
- **Bottle lifecycle scripts** — launch the MCP sidecar alongside other sidecars; mount `/etc/bot-bottle/current-config/` read-only into the agent container.
|
||||
- **`cli.py`** — adds the dashboard subcommand.
|
||||
|
||||
### Data model changes
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,7 +15,7 @@ down`. Logs come from `docker compose logs` and land in a single file
|
||||
per instance, so reading what happened in a session is one `less`
|
||||
away.
|
||||
|
||||
State for each instance (`~/.claude-bottle/state/<slug>/`) becomes a
|
||||
State for each instance (`~/.bot-bottle/state/<slug>/`) becomes a
|
||||
self-describing folder:
|
||||
|
||||
```
|
||||
@@ -34,7 +34,7 @@ together fully describe the container topology.
|
||||
|
||||
Today `start` builds each sidecar (`pipelock`, `egress`, `git-gate`,
|
||||
`supervise`) and the agent container with a chain of individual SDK
|
||||
calls in `claude_bottle/backend/docker/launch.py`:
|
||||
calls in `bot_bottle/backend/docker/launch.py`:
|
||||
|
||||
- A per-sidecar `Docker{Sidecar}.start()` method does
|
||||
`docker create` → `docker cp` (stage files) → `docker network
|
||||
@@ -50,7 +50,7 @@ This is fine, but it has three rough edges:
|
||||
|
||||
2. **Logs are scattered.** Each container's logs sit in Docker's per-
|
||||
container journal. To debug a session post-mortem you have to
|
||||
remember to run `docker logs claude-bottle-pipelock-<slug>` etc.
|
||||
remember to run `docker logs bot-bottle-pipelock-<slug>` etc.
|
||||
before the containers age out, and there's no merged view.
|
||||
|
||||
3. **Teardown is bespoke.** Each sidecar's `stop()` is its own
|
||||
@@ -62,14 +62,14 @@ project name per environment, merged logs, atomic up/down.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. `claude-bottle start <agent>` writes
|
||||
`~/.claude-bottle/state/<slug>/docker-compose.yml` and brings the
|
||||
1. `bot-bottle start <agent>` writes
|
||||
`~/.bot-bottle/state/<slug>/docker-compose.yml` and brings the
|
||||
project up with `docker compose -p <project> up`.
|
||||
2. The compose file is the source of truth for the container
|
||||
topology — every sidecar that runs is declared as a `services:`
|
||||
entry, every network is a `networks:` entry, every bind mount is
|
||||
a `volumes:` entry.
|
||||
3. `~/.claude-bottle/state/<slug>/compose.log` contains the full
|
||||
3. `~/.bot-bottle/state/<slug>/compose.log` contains the full
|
||||
merged stdout/stderr of every service for the session, in
|
||||
`docker compose logs --no-color` format.
|
||||
4. `metadata.json` records the compose project name alongside the
|
||||
@@ -79,7 +79,7 @@ project name per environment, merged logs, atomic up/down.
|
||||
5. Session teardown is `docker compose -p <project> down`. The
|
||||
existing per-sidecar `stop()` lifecycle methods come out.
|
||||
6. The `cleanup` CLI uses `docker compose ls` (filtered to
|
||||
`claude-bottle-*` projects) instead of name-prefix scans across
|
||||
`bot-bottle-*` projects) instead of name-prefix scans across
|
||||
`docker ps -a` and `docker network ls`.
|
||||
7. The existing remediation flows (`pipelock-block`,
|
||||
`egress-block`, `capability-block`) keep working without
|
||||
@@ -95,7 +95,7 @@ project name per environment, merged logs, atomic up/down.
|
||||
implementation detail of the Docker backend.
|
||||
- **Replacing the backend abstraction (PRD 0003).** `Backend` stays
|
||||
abstract; only the Docker implementation changes.
|
||||
- **A long-lived "claude-bottle daemon."** Each `start` invocation
|
||||
- **A long-lived "bot-bottle daemon."** Each `start` invocation
|
||||
still owns a single compose project for the lifetime of the
|
||||
session. No persistent service.
|
||||
- **Image pre-building.** Compose's `build:` directive triggers
|
||||
@@ -109,7 +109,7 @@ project name per environment, merged logs, atomic up/down.
|
||||
|
||||
### In scope
|
||||
|
||||
- New module `claude_bottle/backend/docker/compose.py` that renders a
|
||||
- New module `bot_bottle/backend/docker/compose.py` that renders a
|
||||
compose dict from a `BottlePlan` and writes it to
|
||||
`state/<slug>/docker-compose.yml`.
|
||||
- `DockerBackend.start` rewritten to:
|
||||
@@ -118,7 +118,7 @@ project name per environment, merged logs, atomic up/down.
|
||||
into host paths under `state/<slug>/`.
|
||||
3. Render + write the compose file.
|
||||
4. Exec `docker compose -p <project> up -d`.
|
||||
5. `docker attach claude-bottle-<slug>` for the agent's TTY.
|
||||
5. `docker attach bot-bottle-<slug>` for the agent's TTY.
|
||||
6. On exit: `docker compose -p <project> logs --no-color`
|
||||
→ `state/<slug>/compose.log`, then `docker compose -p
|
||||
<project> down --volumes`.
|
||||
@@ -134,12 +134,12 @@ project name per environment, merged logs, atomic up/down.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Changing the manifest layer (`claude_bottle/manifest.py`,
|
||||
- Changing the manifest layer (`bot_bottle/manifest.py`,
|
||||
`egress.py`'s plan dataclasses, `pipelock.py`'s plan dataclasses).
|
||||
- Changing the agent's runtime contract (proxy env vars, CA bundle
|
||||
paths, current-config mount path).
|
||||
- Changing audit-log shape or location (
|
||||
`~/.claude-bottle/audit/<component>-<slug>.log` stays).
|
||||
`~/.bot-bottle/audit/<component>-<slug>.log` stays).
|
||||
- Changing the MCP server's tool list or wire format.
|
||||
- Dropping the `--rm` semantics for the agent: the agent container
|
||||
is still ephemeral; compose's `down --volumes` handles cleanup.
|
||||
@@ -148,7 +148,7 @@ project name per environment, merged logs, atomic up/down.
|
||||
|
||||
### Project name
|
||||
|
||||
`compose_project = f"claude-bottle-{slug}"`. The slug stays the
|
||||
`compose_project = f"bot-bottle-{slug}"`. The slug stays the
|
||||
existing `slugify(agent_name)-<5-char-random-base36>` from
|
||||
`bottle_state.py`. Compose adds its own prefix to networks
|
||||
(`<project>_<network>`) and to default container names — which is
|
||||
@@ -163,29 +163,29 @@ an explicit `container_name:` matching today's pattern:
|
||||
```yaml
|
||||
services:
|
||||
pipelock:
|
||||
container_name: claude-bottle-pipelock-<slug>
|
||||
container_name: bot-bottle-pipelock-<slug>
|
||||
egress:
|
||||
container_name: claude-bottle-egress-<slug>
|
||||
container_name: bot-bottle-egress-<slug>
|
||||
# ...
|
||||
```
|
||||
|
||||
This keeps the dashboard's container-discovery output stable for
|
||||
operators who've memorized the names. The compose project name
|
||||
(`claude-bottle-<slug>`) is the only new identifier.
|
||||
(`bot-bottle-<slug>`) is the only new identifier.
|
||||
|
||||
### Networks
|
||||
|
||||
The two existing networks (`claude-bottle-net-<slug>` internal +
|
||||
`claude-bottle-egress-<slug>` upstream-bridge) become compose
|
||||
The two existing networks (`bot-bottle-net-<slug>` internal +
|
||||
`bot-bottle-egress-<slug>` upstream-bridge) become compose
|
||||
networks:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
internal:
|
||||
name: claude-bottle-net-<slug>
|
||||
name: bot-bottle-net-<slug>
|
||||
internal: true
|
||||
egress:
|
||||
name: claude-bottle-egress-<slug>
|
||||
name: bot-bottle-egress-<slug>
|
||||
```
|
||||
|
||||
Each service's `networks:` list mirrors today's wiring.
|
||||
@@ -238,7 +238,7 @@ sidecars that exist.
|
||||
### Logging
|
||||
|
||||
`docker compose up -d` starts everything detached. The agent is
|
||||
attached for the user's TTY via `docker attach claude-bottle-
|
||||
attached for the user's TTY via `docker attach bot-bottle-
|
||||
<slug>`. Sidecars stream into Docker's per-container journals
|
||||
during the session, exactly as today, and `docker compose logs -f`
|
||||
gives a merged tail if the user wants it (the dashboard can shell
|
||||
@@ -265,7 +265,7 @@ Add one field; everything else is unchanged.
|
||||
"agent_name": "implementer",
|
||||
"cwd": "/Users/.../some-project",
|
||||
"started_at": "2026-05-25T20:13:04Z",
|
||||
"compose_project": "claude-bottle-implementer-a7k3f"
|
||||
"compose_project": "bot-bottle-implementer-a7k3f"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -291,13 +291,13 @@ After this PRD:
|
||||
### Cleanup CLI
|
||||
|
||||
`./cli.py cleanup` switches from "list every container with prefix
|
||||
`claude-bottle-` and every network with prefix `claude-bottle-net-`
|
||||
or `claude-bottle-egress-`" to:
|
||||
`bot-bottle-` and every network with prefix `bot-bottle-net-`
|
||||
or `bot-bottle-egress-`" to:
|
||||
|
||||
1. `docker compose ls --all --format json` → filter to projects
|
||||
whose name starts with `claude-bottle-`.
|
||||
whose name starts with `bot-bottle-`.
|
||||
2. For each: `docker compose -p <project> down --volumes`.
|
||||
3. Reap any state dirs under `~/.claude-bottle/state/` whose
|
||||
3. Reap any state dirs under `~/.bot-bottle/state/` whose
|
||||
`compose_project` no longer appears in `compose ls`.
|
||||
|
||||
Strays from pre-compose code-paths can be mopped up by keeping the
|
||||
@@ -313,7 +313,7 @@ existing prefix scan as a fallback for one release.
|
||||
2. **How does `claude` reach the agent's TTY?** Decided: keep
|
||||
today's `docker exec -it` model. Agent runs `sleep infinity`
|
||||
under compose; `DockerBottle.exec_claude` runs
|
||||
`docker exec -it claude-bottle-<slug> claude ...` exactly like
|
||||
`docker exec -it bot-bottle-<slug> claude ...` exactly like
|
||||
today. Compose owns the lifecycle (so `compose logs` includes
|
||||
the agent's stdout, `compose down` tears it down), but the
|
||||
user-facing exec model is unchanged. Rejected `docker attach`
|
||||
@@ -332,8 +332,8 @@ existing prefix scan as a fallback for one release.
|
||||
|
||||
5. **Image build caching.** `build:` in compose rebuilds on first
|
||||
`up` unless the image is already tagged. The per-sidecar images
|
||||
(`claude-bottle-pipelock`, `claude-bottle-egress`,
|
||||
`claude-bottle-git-gate`, `claude-bottle-supervise`) should
|
||||
(`bot-bottle-pipelock`, `bot-bottle-egress`,
|
||||
`bot-bottle-git-gate`, `bot-bottle-supervise`) should
|
||||
stay tagged on the daemon between runs so we don't rebuild on
|
||||
every start. Verify compose's behavior matches.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user