PRD 0026: Agent Provider Templates #91
@@ -5,7 +5,7 @@
|
|||||||
Codex-bottle spins up an isolated container for running Codex with a
|
Codex-bottle spins up an isolated container for running Codex with a
|
||||||
curated set of skills and env vars. The point is to run Codex with broad
|
curated set of skills and env vars. The point is to run Codex with broad
|
||||||
permissions inside a sandbox, so a misbehaving agent cannot reach the host.
|
permissions inside a sandbox, so a misbehaving agent cannot reach the host.
|
||||||
A Python CLI (entry point `cli.py`, package `claude_bottle/`) orchestrates
|
A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
|
||||||
the container lifecycle and the copying of skills and env vars into it.
|
the container lifecycle and the copying of skills and env vars into it.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# claude-bottle
|
# bot-bottle
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
claude-bottle spins up an isolated container for running Claude Code with a
|
bot-bottle spins up an isolated container for running Claude Code with a
|
||||||
curated set of skills and env vars. The point is to run Claude with broad
|
curated set of skills and env vars. The point is to run Claude with broad
|
||||||
permissions inside a sandbox, so a misbehaving agent cannot reach the host.
|
permissions inside a sandbox, so a misbehaving agent cannot reach the host.
|
||||||
A Python CLI (entry point `cli.py`, package `claude_bottle/`) orchestrates
|
A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
|
||||||
the container lifecycle and the copying of skills and env vars into it.
|
the container lifecycle and the copying of skills and env vars into it.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
@@ -25,7 +25,7 @@ the container lifecycle and the copying of skills and env vars into it.
|
|||||||
- `README.md` — short public-facing description.
|
- `README.md` — short public-facing description.
|
||||||
- `CLAUDE.md` — this file, orientation for future Claude sessions.
|
- `CLAUDE.md` — this file, orientation for future Claude sessions.
|
||||||
- `.gitignore` — OS junk.
|
- `.gitignore` — OS junk.
|
||||||
- `claude-bottle.json` — manifest of named agents (env / skills / prompt
|
- `bot-bottle.json` — manifest of named agents (env / skills / prompt
|
||||||
per agent), consumed by `cli.py`. See "Manifest" under
|
per agent), consumed by `cli.py`. See "Manifest" under
|
||||||
"Intended design".
|
"Intended design".
|
||||||
- `docs/INDEX.md` — pointer to the research notes.
|
- `docs/INDEX.md` — pointer to the research notes.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# claude-bottle container image.
|
# bot-bottle container image.
|
||||||
#
|
#
|
||||||
# Goal: a small, cache-friendly base that ships claude-code (the
|
# Goal: a small, cache-friendly base that ships claude-code (the
|
||||||
# `@anthropic-ai/claude-code` npm package, CLI name `claude`) ready to run
|
# `@anthropic-ai/claude-code` npm package, CLI name `claude`) ready to run
|
||||||
@@ -17,7 +17,7 @@ FROM node:22-slim
|
|||||||
# image, those features fail in surprising ways once the user does any
|
# image, those features fail in surprising ways once the user does any
|
||||||
# real work. ca-certificates is already in the slim base; listed for
|
# real work. ca-certificates is already in the slim base; listed for
|
||||||
# clarity in case the base ever drops it. socat is the privileged
|
# clarity in case the base ever drops it. socat is the privileged
|
||||||
# forwarder for the in-container ssh-agent (see claude_bottle/ssh.py): the agent
|
# forwarder for the in-container ssh-agent (see bot_bottle/ssh.py): the agent
|
||||||
# runs as root and rejects non-root connections, so socat sits between
|
# runs as root and rejects non-root connections, so socat sits between
|
||||||
# node and the agent socket. curl is here so any HTTPS_PROXY-aware
|
# node and the agent socket. curl is here so any HTTPS_PROXY-aware
|
||||||
# tool (curl itself, plus anything that shells out to it) works
|
# tool (curl itself, plus anything that shells out to it) works
|
||||||
@@ -40,7 +40,7 @@ USER node
|
|||||||
WORKDIR /home/node
|
WORKDIR /home/node
|
||||||
|
|
||||||
# Pre-create the skills directory so PRD 0002's host->container skill
|
# Pre-create the skills directory so PRD 0002's host->container skill
|
||||||
# copier (claude_bottle/skills.py) drops files into a path owned by the
|
# copier (bot_bottle/skills.py) drops files into a path owned by the
|
||||||
# `node` user. `skills_copy_into` also `mkdir -p`s defensively, but
|
# `node` user. `skills_copy_into` also `mkdir -p`s defensively, but
|
||||||
# baking it into the image avoids a permission-confusion footgun if a
|
# baking it into the image avoids a permission-confusion footgun if a
|
||||||
# future change to the launcher copies in as a different user.
|
# future change to the launcher copies in as a different user.
|
||||||
@@ -60,7 +60,7 @@ RUN cat > "$HOME/.claude.json" <<JSON
|
|||||||
JSON
|
JSON
|
||||||
|
|
||||||
# Default to an interactive claude session. In the v1 launcher,
|
# Default to an interactive claude session. In the v1 launcher,
|
||||||
# `claude_bottle/cli/start.py` runs the container detached and uses `docker exec`
|
# `bot_bottle/cli/start.py` runs the container detached and uses `docker exec`
|
||||||
# to attach a TTY, but this CMD makes `docker run -it claude-bottle` also
|
# to attach a TTY, but this CMD makes `docker run -it bot-bottle-claude` also
|
||||||
# do something useful for ad-hoc debugging.
|
# do something useful for ad-hoc debugging.
|
||||||
CMD ["claude"]
|
CMD ["claude"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# bot-bottle Codex provider image.
|
||||||
|
#
|
||||||
|
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
|
||||||
|
# non-root node user, and the provider CLI installed globally.
|
||||||
|
|
||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl dnsutils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN npm install -g --no-fund --no-audit @openai/codex@0.134.0 \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
USER node
|
||||||
|
WORKDIR /home/node
|
||||||
|
|
||||||
|
RUN mkdir -p /home/node/.codex
|
||||||
|
|
||||||
|
CMD ["codex"]
|
||||||
+8
-8
@@ -36,7 +36,7 @@
|
|||||||
# Stage 1: pipelock binary. The upstream pipelock image is a
|
# Stage 1: pipelock binary. The upstream pipelock image is a
|
||||||
# scratch image with the binary at /pipelock (entrypoint).
|
# scratch image with the binary at /pipelock (entrypoint).
|
||||||
# Pinned by digest in lockstep with
|
# Pinned by digest in lockstep with
|
||||||
# claude_bottle/backend/docker/pipelock.py:PIPELOCK_IMAGE.
|
# bot_bottle/backend/docker/pipelock.py:PIPELOCK_IMAGE.
|
||||||
FROM ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9 AS pipelock-src
|
FROM ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9 AS pipelock-src
|
||||||
|
|
||||||
# Stage 2: gitleaks binary. The upstream gitleaks image is alpine
|
# Stage 2: gitleaks binary. The upstream gitleaks image is alpine
|
||||||
@@ -75,13 +75,13 @@ COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
|
|||||||
# Kept flat under /app/ so mitmdump's loader resolves them as
|
# Kept flat under /app/ so mitmdump's loader resolves them as
|
||||||
# top-level siblings (absolute imports), matching the prior
|
# top-level siblings (absolute imports), matching the prior
|
||||||
# Dockerfile.egress / Dockerfile.supervise layout.
|
# Dockerfile.egress / Dockerfile.supervise layout.
|
||||||
COPY claude_bottle/egress_addon_core.py /app/egress_addon_core.py
|
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
||||||
COPY claude_bottle/egress_addon.py /app/egress_addon.py
|
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
||||||
COPY claude_bottle/yaml_subset.py /app/yaml_subset.py
|
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||||
COPY claude_bottle/supervise.py /app/supervise.py
|
COPY bot_bottle/supervise.py /app/supervise.py
|
||||||
COPY claude_bottle/supervise_server.py /app/supervise_server.py
|
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||||
COPY claude_bottle/sidecar_init.py /app/sidecar_init.py
|
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
||||||
COPY claude_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||||
RUN chmod +x /app/egress-entrypoint.sh
|
RUN chmod +x /app/egress-entrypoint.sh
|
||||||
|
|
||||||
# Pre-create runtime directories the compose renderer + start
|
# Pre-create runtime directories the compose renderer + start
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/logo.svg" alt="claude-bottle logo" width="140">
|
<img src="docs/logo.svg" alt="bot-bottle logo" width="140">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# claude-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](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.
|
Run multiple Claude Code agents on your own machine, each scoped to its own secrets, skills, and egress allowlist.
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ asked to commit and push an AKIA-shaped key, git-gate's gitleaks
|
|||||||
pre-receive hook rejects the ref.
|
pre-receive hook rejects the ref.
|
||||||
Run it yourself with `bash scripts/demo.sh`.
|
Run it yourself with `bash scripts/demo.sh`.
|
||||||
|
|
||||||
## Why "claude-bottle"?
|
## Why "bot-bottle"?
|
||||||
|
|
||||||
Each container is a bottle; Claude is the genie inside. The genie's
|
Each container is a bottle; Claude is the genie inside. The genie's
|
||||||
powers are exactly what the manifest grants it — a specific set of
|
powers are exactly what the manifest grants it — a specific set of
|
||||||
@@ -37,6 +37,17 @@ the genie does not persist.
|
|||||||
- Run multiple agents in parallel, isolated from each other
|
- Run multiple agents in parallel, isolated from each other
|
||||||
- Keep code, credentials, and agent activity on infrastructure I control — no third-party agent runtime
|
- Keep code, credentials, and agent activity on infrastructure I control — no third-party agent runtime
|
||||||
|
|
||||||
|
## Project status
|
||||||
|
|
||||||
|
bot-bottle is a self-hosted secure runtime for AI coding agents.
|
||||||
|
Each agent runs in an isolated container or micro-VM-backed bottle with
|
||||||
|
scoped secrets, allowlisted egress, TLS-aware proxying, DLP checks, and
|
||||||
|
a git-gate that withholds upstream credentials and scans pushes before
|
||||||
|
forwarding. The project includes a documented threat model, PRD-driven
|
||||||
|
development history, Docker and smolmachines backends, dashboard and
|
||||||
|
remediation flows, and unit/integration tests covering exfiltration and
|
||||||
|
sandbox escape scenarios.
|
||||||
|
|
||||||
## Security model
|
## Security model
|
||||||
|
|
||||||
Each agent runs in its own bottle: its own container, its own internal
|
Each agent runs in its own bottle: its own container, its own internal
|
||||||
@@ -59,7 +70,7 @@ agent to reach it at all. The container itself adds a layer between
|
|||||||
the agent and the host, but the v1 design leans more on secret
|
the agent and the host, but the v1 design leans more on secret
|
||||||
minimization and egress allowlisting than on the container as a
|
minimization and egress allowlisting than on the container as a
|
||||||
hardened boundary. On Linux hosts where [gVisor](https://gvisor.dev/)
|
hardened boundary. On Linux hosts where [gVisor](https://gvisor.dev/)
|
||||||
is registered with Docker, claude-bottle auto-detects it and launches
|
is registered with Docker, bot-bottle auto-detects it and launches
|
||||||
every bottle under `runsc` for a userspace syscall barrier — no
|
every bottle under `runsc` for a userspace syscall barrier — no
|
||||||
manifest configuration required. The broader v2 discussion lives in
|
manifest configuration required. The broader v2 discussion lives in
|
||||||
`docs/research/stronger-isolation-alternatives.md`.
|
`docs/research/stronger-isolation-alternatives.md`.
|
||||||
@@ -126,10 +137,12 @@ and MCP endpoints resolve without an agent-side change.
|
|||||||
└─────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **agent image** — built from the repo `Dockerfile` (`node:22-slim`
|
- **agent image** — built from the provider template Dockerfile
|
||||||
base) on first run; runs `claude` with the manifest-granted skills,
|
(`Dockerfile.claude` for Claude, `Dockerfile.codex` for Codex, or
|
||||||
env vars, and `~/.gitconfig` (the latter for the git-gate's
|
`agent_provider.dockerfile`) on first run; runs the selected agent
|
||||||
`insteadOf` rules when `bottle.git` is set).
|
CLI with the manifest-granted skills, env vars, and `~/.gitconfig`
|
||||||
|
(the latter for the git-gate's `insteadOf` rules when `bottle.git`
|
||||||
|
is set).
|
||||||
- **pipelock image** — per-agent sidecar. Terminates the agent's
|
- **pipelock image** — per-agent sidecar. Terminates the agent's
|
||||||
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
|
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
|
||||||
scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
|
scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
|
||||||
@@ -194,7 +207,7 @@ left running; remove it with `docker rm -f <container-name>`.
|
|||||||
|
|
||||||
A second backend runs the agent in a smolvm micro-VM (libkrun) with the
|
A second backend runs the agent in a smolvm micro-VM (libkrun) with the
|
||||||
sidecar bundle still in Docker. Selected via
|
sidecar bundle still in Docker. Selected via
|
||||||
`CLAUDE_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>`. Requires
|
`BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>`. Requires
|
||||||
`smolvm` on PATH (`curl -sSL https://smolmachines.com/install.sh | sh`).
|
`smolvm` on PATH (`curl -sSL https://smolmachines.com/install.sh | sh`).
|
||||||
|
|
||||||
The integration tests run against whichever backend the env var
|
The integration tests run against whichever backend the env var
|
||||||
@@ -223,11 +236,11 @@ docstring for the investigation trail.
|
|||||||
## Manifest
|
## Manifest
|
||||||
|
|
||||||
Bottles and agents live as Markdown files with YAML frontmatter under
|
Bottles and agents live as Markdown files with YAML frontmatter under
|
||||||
`~/.claude-bottle/`. Each bottle is one file in `bottles/`, each agent
|
`~/.bot-bottle/`. Each bottle is one file in `bottles/`, each agent
|
||||||
is one file in `agents/`:
|
is one file in `agents/`:
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.claude-bottle/
|
~/.bot-bottle/
|
||||||
├── bottles/
|
├── bottles/
|
||||||
│ ├── dev.md
|
│ ├── dev.md
|
||||||
│ └── gitea-dev.md
|
│ └── gitea-dev.md
|
||||||
@@ -240,8 +253,8 @@ The filename (without `.md`) is the entity's name. Filenames must
|
|||||||
match `[a-z][a-z0-9-]*`; files that don't are skipped with a warning.
|
match `[a-z][a-z0-9-]*`; files that don't are skipped with a warning.
|
||||||
|
|
||||||
A repo can ship its own agent files alongside its code at
|
A repo can ship its own agent files alongside its code at
|
||||||
`<repo>/.claude-bottle/agents/<name>.md`. Those agents reference
|
`<repo>/.bot-bottle/agents/<name>.md`. Those agents reference
|
||||||
bottles defined in `~/.claude-bottle/bottles/` (the only place
|
bottles defined in `~/.bot-bottle/bottles/` (the only place
|
||||||
bottles can come from); a `bottles/` subdir in a repo is ignored
|
bottles can come from); a `bottles/` subdir in a repo is ignored
|
||||||
with a warning. **This is the trust boundary**: bottle infrastructure
|
with a warning. **This is the trust boundary**: bottle infrastructure
|
||||||
— credentials, egress allowlists, git remotes — comes from your home
|
— credentials, egress allowlists, git remotes — comes from your home
|
||||||
@@ -261,8 +274,8 @@ child's declared fields overlay. Merge rules:
|
|||||||
- `git.remotes:` — dict merge by host, child wins on host collision.
|
- `git.remotes:` — dict merge by host, child wins on host collision.
|
||||||
An explicit `git.remotes: {}` clears the parent's remotes; omitting
|
An explicit `git.remotes: {}` clears the parent's remotes; omitting
|
||||||
`git.remotes` inherits the parent's remotes.
|
`git.remotes` inherits the parent's remotes.
|
||||||
- `egress:`, `supervise:` — full replace when the child declares the
|
- `agent_provider:`, `egress:`, `supervise:` — full replace when the
|
||||||
field.
|
child declares the field.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
@@ -280,10 +293,43 @@ Cycles (`A extends B extends A`), self-references, and missing
|
|||||||
parents die at parse with a clear pointer. Bottles remain
|
parents die at parse with a clear pointer. Bottles remain
|
||||||
`$HOME`-only — `extends:` preserves the trust boundary above.
|
`$HOME`-only — `extends:` preserves the trust boundary above.
|
||||||
|
|
||||||
### Example bottle (`~/.claude-bottle/bottles/gitea-dev.md`)
|
### Provider base bottles
|
||||||
|
|
||||||
|
Keep provider/runtime policy in one home-owned base bottle, then have
|
||||||
|
task bottles extend it. That keeps provider egress/auth in one place
|
||||||
|
without hiding security-relevant routes behind `agent_provider.template`.
|
||||||
|
|
||||||
|
For example, `~/.bot-bottle/bottles/claude.md` can hold the Claude
|
||||||
|
provider selection and Anthropic API egress:
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
---
|
---
|
||||||
|
agent_provider:
|
||||||
|
template: claude
|
||||||
|
|
||||||
|
egress:
|
||||||
|
routes:
|
||||||
|
- host: api.anthropic.com
|
||||||
|
role: claude_code_oauth
|
||||||
|
auth:
|
||||||
|
scheme: Bearer
|
||||||
|
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||||
|
pipelock:
|
||||||
|
tls_passthrough: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Common Claude provider boundary.
|
||||||
|
````
|
||||||
|
|
||||||
|
Task bottles can then inherit that provider boundary and add their own
|
||||||
|
env/git configuration without repeating the Claude route.
|
||||||
|
|
||||||
|
### Example bottle (`~/.bot-bottle/bottles/gitea-dev.md`)
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
---
|
||||||
|
extends: claude
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GIT_AUTHOR_NAME: didericis
|
GIT_AUTHOR_NAME: didericis
|
||||||
|
|
||||||
@@ -293,62 +339,23 @@ git:
|
|||||||
email: "eric+claude@dideric.is"
|
email: "eric+claude@dideric.is"
|
||||||
remotes:
|
remotes:
|
||||||
gitea.dideric.is:
|
gitea.dideric.is:
|
||||||
Name: claude-bottle
|
Name: bot-bottle
|
||||||
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
|
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
||||||
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
|
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
|
||||||
KnownHostKey: ssh-ed25519 AAAA...
|
KnownHostKey: ssh-ed25519 AAAA...
|
||||||
|
|
||||||
# Routes declared here are held by a per-bottle cred-proxy sidecar,
|
|
||||||
# not the agent. Each route names a path the agent dials, the
|
|
||||||
# upstream the proxy forwards to, an auth_scheme, and a token_ref
|
|
||||||
# (host env var). The value goes into the sidecar's environ via
|
|
||||||
# `docker create -e`, never touches argv or disk. Optional `role`
|
|
||||||
# tags drive agent-side rewrites: anthropic-base-url (sets
|
|
||||||
# ANTHROPIC_BASE_URL), npm-registry (writes ~/.npmrc), git-insteadof
|
|
||||||
# (writes ~/.gitconfig), tea-login (writes ~/.config/tea/config.yml).
|
|
||||||
# See docs/prds/0010-cred-proxy.md.
|
|
||||||
cred_proxy:
|
|
||||||
routes:
|
|
||||||
- path: /anthropic/
|
|
||||||
upstream: https://api.anthropic.com
|
|
||||||
auth_scheme: Bearer
|
|
||||||
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
|
|
||||||
role: anthropic-base-url
|
|
||||||
- path: /gh-api/
|
|
||||||
upstream: https://api.github.com
|
|
||||||
auth_scheme: Bearer
|
|
||||||
token_ref: GH_PAT
|
|
||||||
- path: /gh-git/
|
|
||||||
upstream: https://github.com
|
|
||||||
auth_scheme: Bearer
|
|
||||||
token_ref: GH_PAT
|
|
||||||
role: git-insteadof
|
|
||||||
- path: /npm/
|
|
||||||
upstream: https://registry.npmjs.org
|
|
||||||
auth_scheme: Bearer
|
|
||||||
token_ref: NPM_TOKEN
|
|
||||||
role: npm-registry
|
|
||||||
|
|
||||||
# Egress is forced through a per-agent pipelock sidecar on a Docker
|
|
||||||
# `--internal` network — without the proxy the agent has no route
|
|
||||||
# off-box. The effective allowlist is the union of baked-in defaults
|
|
||||||
# (api.anthropic.com, claude.ai, ...) and the hostnames listed here.
|
|
||||||
# Pipelock also runs DLP scanning and detects URL-embedded
|
|
||||||
# high-entropy secrets. The resolved allowlist is shown in the y/N
|
|
||||||
# preflight before launch.
|
|
||||||
egress:
|
|
||||||
allowlist:
|
|
||||||
- github.com
|
|
||||||
- registry.npmjs.org
|
|
||||||
- pypi.org
|
|
||||||
---
|
---
|
||||||
|
|
||||||
The `gitea-dev` bottle. Backs my work on personal projects: Anthropic
|
The `gitea-dev` bottle. Backs my work on personal projects: provider
|
||||||
OAuth via cred-proxy, gitea.dideric.is over SSH (with PAT for tea
|
auth through egress and gitea.dideric.is over SSH.
|
||||||
API), and npm for publishing scoped packages.
|
|
||||||
````
|
````
|
||||||
|
|
||||||
### Example agent (`~/.claude-bottle/agents/gitea-helper.md`)
|
For a Codex-backed base bottle, set `agent_provider.template: codex`
|
||||||
|
and use the `codex_auth` egress role for the OpenAI API route. The
|
||||||
|
built-in Codex template uses `Dockerfile.codex`; set
|
||||||
|
`agent_provider.dockerfile` to build the agent from a custom
|
||||||
|
Dockerfile while keeping the bot-bottle sidecars in place.
|
||||||
|
|
||||||
|
### Example agent (`~/.bot-bottle/agents/gitea-helper.md`)
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
---
|
---
|
||||||
@@ -364,7 +371,7 @@ The agent's Markdown body is its system prompt (whitespace
|
|||||||
stripped). The frontmatter declares the bottle to launch in and any
|
stripped). The frontmatter declares the bottle to launch in and any
|
||||||
skills to mount. You can also include Claude Code subagent fields
|
skills to mount. You can also include Claude Code subagent fields
|
||||||
(`name`, `description`, `model`, `color`, `memory`) in the
|
(`name`, `description`, `model`, `color`, `memory`) in the
|
||||||
frontmatter — claude-bottle ignores them at launch but doesn't
|
frontmatter — bot-bottle ignores them at launch but doesn't
|
||||||
reject them, so the same file can drop into `~/.claude/agents/` as a
|
reject them, so the same file can drop into `~/.claude/agents/` as a
|
||||||
Claude Code subagent.
|
Claude Code subagent.
|
||||||
|
|
||||||
@@ -377,25 +384,26 @@ nested dicts). Anchors, multi-line block scalars, tags, and
|
|||||||
ambiguous bare strings (`yes` / `NO` / `2026-05-24` /
|
ambiguous bare strings (`yes` / `NO` / `2026-05-24` /
|
||||||
`0x...`) all die with a clear pointer at the spec — quote your
|
`0x...`) all die with a clear pointer at the spec — quote your
|
||||||
strings when in doubt. The full schema lives in
|
strings when in doubt. The full schema lives in
|
||||||
`claude_bottle/yaml_subset.py` (~450 lines, stdlib-only, no PyYAML).
|
`bot_bottle/yaml_subset.py` (~450 lines, stdlib-only, no PyYAML).
|
||||||
|
|
||||||
Working examples live under `examples/`. Pipelock's design lives in
|
Working examples live under `examples/`. Pipelock's design lives in
|
||||||
`docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` and the
|
`docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` and the
|
||||||
rationale in `docs/research/pipelock-assessment.md`. The trust
|
rationale in `docs/research/pipelock-assessment.md`. The trust
|
||||||
boundary rationale lives in `docs/prds/0011-per-file-md-manifest.md`.
|
boundary rationale lives in `docs/prds/0011-per-file-md-manifest.md`.
|
||||||
|
|
||||||
## Auth: OAuth token, not API key
|
## Auth: Claude OAuth token, not API key
|
||||||
|
|
||||||
claude-bottle authenticates `claude` inside the container with the same
|
Bottles that use `agent_provider.template: claude` authenticate
|
||||||
Pro/Max subscription you already use on the host, via a long-lived OAuth
|
`claude` inside the container with the same Pro/Max subscription you
|
||||||
token. No `ANTHROPIC_API_KEY` is needed.
|
already use on the host, via a long-lived OAuth token. No
|
||||||
|
`ANTHROPIC_API_KEY` is needed.
|
||||||
|
|
||||||
**Why a token instead of mounting `~/.claude.json`:** on macOS, Claude
|
**Why a token instead of mounting `~/.claude.json`:** on macOS, Claude
|
||||||
Code stores OAuth credentials in the encrypted Keychain, not in
|
Code stores OAuth credentials in the encrypted Keychain, not in
|
||||||
`~/.claude.json`. Mounting that file into a Linux container does not
|
`~/.claude.json`. Mounting that file into a Linux container does not
|
||||||
carry the credentials with it. Linux hosts keep credentials in
|
carry the credentials with it. Linux hosts keep credentials in
|
||||||
`~/.claude/.credentials.json`, but to keep the launcher portable
|
`~/.claude/.credentials.json`, but to keep the launcher portable
|
||||||
claude-bottle uses the env-var path on every host.
|
bot-bottle uses the env-var path on every host.
|
||||||
|
|
||||||
**One-time setup on the host:**
|
**One-time setup on the host:**
|
||||||
|
|
||||||
@@ -404,28 +412,45 @@ claude setup-token # browser login, prints a ~1-year OAuth token
|
|||||||
```
|
```
|
||||||
|
|
||||||
Stash the token in your shell env (e.g. `~/.zshrc` or a secret manager)
|
Stash the token in your shell env (e.g. `~/.zshrc` or a secret manager)
|
||||||
as `CLAUDE_BOTTLE_OAUTH_TOKEN`:
|
as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export CLAUDE_BOTTLE_OAUTH_TOKEN="<token>"
|
export BOT_BOTTLE_CLAUDE_OAUTH_TOKEN="<token>"
|
||||||
```
|
```
|
||||||
|
|
||||||
The bottle reaches the Anthropic API only through the cred-proxy
|
The Claude bottle reaches the Anthropic API only through the cred-proxy
|
||||||
sidecar. To let `claude` authenticate, declare a route in
|
sidecar. To let `claude` authenticate, declare an egress route with
|
||||||
`bottle.cred_proxy.routes` with `role: "anthropic-base-url"` and
|
`role: claude_code_oauth` and
|
||||||
`token_ref: "CLAUDE_BOTTLE_OAUTH_TOKEN"`:
|
`token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
|
||||||
|
|
||||||
```jsonc
|
```yaml
|
||||||
{
|
egress:
|
||||||
"path": "/anthropic/",
|
routes:
|
||||||
"upstream": "https://api.anthropic.com",
|
- host: api.anthropic.com
|
||||||
"auth_scheme": "Bearer",
|
role: claude_code_oauth
|
||||||
"token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
|
auth:
|
||||||
"role": "anthropic-base-url"
|
scheme: Bearer
|
||||||
}
|
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||||
|
pipelock:
|
||||||
|
tls_passthrough: true
|
||||||
```
|
```
|
||||||
|
|
||||||
At launch, `cli.py` reads `CLAUDE_BOTTLE_OAUTH_TOKEN` from the host
|
Routes that resolve to private or Tailscale addresses can opt into
|
||||||
|
pipelock's SSRF destination allowlist explicitly:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
egress:
|
||||||
|
routes:
|
||||||
|
- host: gitea.dideric.is
|
||||||
|
auth:
|
||||||
|
scheme: token
|
||||||
|
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
||||||
|
pipelock:
|
||||||
|
ssrf_ip_allowlist:
|
||||||
|
- 100.78.141.42/32
|
||||||
|
```
|
||||||
|
|
||||||
|
At launch, `cli.py` reads `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` from the host
|
||||||
env and forwards it into the cred-proxy container's environ — never
|
env and forwards it into the cred-proxy container's environ — never
|
||||||
into the agent's. The agent receives `ANTHROPIC_BASE_URL` pointing at
|
into the agent's. The agent receives `ANTHROPIC_BASE_URL` pointing at
|
||||||
`http://cred-proxy:9099/anthropic` and a non-secret placeholder for
|
`http://cred-proxy:9099/anthropic` and a non-secret placeholder for
|
||||||
@@ -434,7 +459,7 @@ the proxy strips and replaces the header on every request). `printenv`
|
|||||||
inside the agent does not surface the real token, and the value is
|
inside the agent does not surface the real token, and the value is
|
||||||
never written to disk or placed on argv on the host.
|
never written to disk or placed on argv on the host.
|
||||||
|
|
||||||
A bottle without an `anthropic-base-url` route has no path to the
|
A Claude bottle without a `claude_code_oauth` route has no path to the
|
||||||
Anthropic API — there is no fallback that forwards the token directly
|
Anthropic API — there is no fallback that forwards the token directly
|
||||||
to the agent. Caveats: the token is bound to your subscription tier
|
to the agent. Caveats: the token is bound to your subscription tier
|
||||||
(Pro/Max/Team/Enterprise), it does not work with `claude --bare`
|
(Pro/Max/Team/Enterprise), it does not work with `claude --bare`
|
||||||
@@ -444,7 +469,7 @@ via `claude setup-token` again. Reference:
|
|||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|
||||||
claude-bottle is an independent project and is not affiliated with,
|
bot-bottle is an independent project and is not affiliated with,
|
||||||
endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude
|
endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude
|
||||||
Code" are trademarks of Anthropic, PBC; the project name uses
|
Code" are trademarks of Anthropic, PBC; the project name uses
|
||||||
"claude" descriptively to indicate that the tool runs Claude Code
|
"claude" descriptively to indicate that the tool runs Claude Code
|
||||||
|
|||||||
@@ -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."""
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Agent provider runtime mapping.
|
||||||
|
|
||||||
|
The manifest owns the user-facing AgentProvider shape. This module is
|
||||||
|
the launch-time table that turns a provider template into an executable
|
||||||
|
command, default image, and prompt/auth behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER_CLAUDE = "claude"
|
||||||
|
PROVIDER_CODEX = "codex"
|
||||||
|
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX})
|
||||||
|
PromptMode = Literal["append_file", "read_prompt_file"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentProviderRuntime:
|
||||||
|
template: str
|
||||||
|
command: str
|
||||||
|
image: str
|
||||||
|
dockerfile: str
|
||||||
|
auth_role: str
|
||||||
|
placeholder_env: str
|
||||||
|
prompt_mode: PromptMode
|
||||||
|
bypass_args: tuple[str, ...]
|
||||||
|
resume_args: tuple[str, ...]
|
||||||
|
remote_control_args: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
_RUNTIMES = {
|
||||||
|
PROVIDER_CLAUDE: AgentProviderRuntime(
|
||||||
|
template=PROVIDER_CLAUDE,
|
||||||
|
command="claude",
|
||||||
|
image="bot-bottle-claude:latest",
|
||||||
|
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
|
||||||
|
auth_role="claude_code_oauth",
|
||||||
|
placeholder_env="CLAUDE_CODE_OAUTH_TOKEN",
|
||||||
|
prompt_mode="append_file",
|
||||||
|
bypass_args=("--dangerously-skip-permissions",),
|
||||||
|
resume_args=("--continue",),
|
||||||
|
remote_control_args=("--remote-control",),
|
||||||
|
),
|
||||||
|
PROVIDER_CODEX: AgentProviderRuntime(
|
||||||
|
template=PROVIDER_CODEX,
|
||||||
|
command="codex",
|
||||||
|
image="bot-bottle-codex:latest",
|
||||||
|
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
|
||||||
|
auth_role="codex_auth",
|
||||||
|
placeholder_env="OPENAI_API_KEY",
|
||||||
|
prompt_mode="read_prompt_file",
|
||||||
|
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||||
|
resume_args=("resume", "--last"),
|
||||||
|
remote_control_args=(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_for(template: str) -> AgentProviderRuntime:
|
||||||
|
return _RUNTIMES[template]
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_args(
|
||||||
|
prompt_mode: PromptMode,
|
||||||
|
prompt_path: str | None,
|
||||||
|
*,
|
||||||
|
argv: list[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
if not prompt_path:
|
||||||
|
return []
|
||||||
|
if prompt_mode == "append_file":
|
||||||
|
return ["--append-system-prompt-file", prompt_path]
|
||||||
|
if prompt_mode == "read_prompt_file":
|
||||||
|
if argv and "resume" in argv:
|
||||||
|
return []
|
||||||
|
return [f"Read and follow the instructions in {prompt_path}."]
|
||||||
|
raise ValueError(f"unknown provider prompt mode: {prompt_mode}")
|
||||||
@@ -25,7 +25,7 @@ backend exposes five methods:
|
|||||||
agents pane) to render a row.
|
agents pane) to render a row.
|
||||||
|
|
||||||
Selection is driven by `--backend` on `start` or
|
Selection is driven by `--backend` on `start` or
|
||||||
CLAUDE_BOTTLE_BACKEND (env var; default "docker"). Per PRD 0003 the
|
BOT_BOTTLE_BACKEND (env var; default "docker"). Per PRD 0003 the
|
||||||
manifest does not carry a backend field; the host picks.
|
manifest does not carry a backend field; the host picks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -130,8 +130,8 @@ class ActiveAgent:
|
|||||||
class Bottle(ABC):
|
class Bottle(ABC):
|
||||||
"""Handle to a running bottle. Yielded by a backend's launch step.
|
"""Handle to a running bottle. Yielded by a backend's launch step.
|
||||||
|
|
||||||
`exec_claude` runs `claude` inside the bottle and blocks until the
|
`exec_agent` runs the selected agent CLI inside the bottle and
|
||||||
session ends. `exec` runs a POSIX shell script inside the bottle
|
blocks until the session ends. `exec` runs a POSIX shell script inside the bottle
|
||||||
and returns the captured result. `cp_in` copies a host path into
|
and returns the captured result. `cp_in` copies a host path into
|
||||||
the bottle. `close` is an idempotent alias for context-manager
|
the bottle. `close` is an idempotent alias for context-manager
|
||||||
teardown.
|
teardown.
|
||||||
@@ -140,11 +140,11 @@ class Bottle(ABC):
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def claude_argv(
|
def agent_argv(
|
||||||
self, argv: list[str], *, tty: bool = True,
|
self, argv: list[str], *, tty: bool = True,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Return the host-side argv that runs `claude <argv>`
|
"""Return the host-side argv that runs the selected agent
|
||||||
inside the bottle. Used by `exec_claude` for foreground
|
inside the bottle. Used by `exec_agent` for foreground
|
||||||
handoffs and by the dashboard's tmux `respawn-pane` flow,
|
handoffs and by the dashboard's tmux `respawn-pane` flow,
|
||||||
which needs the argv up front (it spawns claude in a tmux
|
which needs the argv up front (it spawns claude in a tmux
|
||||||
pane rather than as a child of the current process).
|
pane rather than as a child of the current process).
|
||||||
@@ -155,7 +155,7 @@ class Bottle(ABC):
|
|||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ...
|
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||||
@@ -215,6 +215,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
self._validate_skills(agent.skills)
|
self._validate_skills(agent.skills)
|
||||||
self._validate_git_entries(bottle.git)
|
self._validate_git_entries(bottle.git)
|
||||||
|
self._validate_agent_provider_dockerfile(spec)
|
||||||
|
|
||||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
def _validate_skills(self, skills: Sequence[str]) -> None:
|
||||||
"""Each named skill must be a directory under the host's
|
"""Each named skill must be a directory under the host's
|
||||||
@@ -238,6 +239,20 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
if not os.path.isfile(key):
|
if not os.path.isfile(key):
|
||||||
die(f"git upstream key file not found for '{entry.Name}': {key}")
|
die(f"git upstream key file not found for '{entry.Name}': {key}")
|
||||||
|
|
||||||
|
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
|
||||||
|
bottle = spec.manifest.bottle_for(spec.agent_name)
|
||||||
|
dockerfile = bottle.agent_provider.dockerfile
|
||||||
|
if not dockerfile:
|
||||||
|
return
|
||||||
|
path = Path(expand_tilde(dockerfile))
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = Path(spec.user_cwd) / path
|
||||||
|
if not path.is_file():
|
||||||
|
die(
|
||||||
|
f"agent_provider.dockerfile for bottle "
|
||||||
|
f"'{spec.manifest.agents[spec.agent_name].bottle}' not found: {path}"
|
||||||
|
)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
|
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
|
||||||
"""Backend-specific plan resolution: image/container names,
|
"""Backend-specific plan resolution: image/container names,
|
||||||
@@ -255,7 +270,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
backend-specific terms (Docker: resolved container name; fly:
|
backend-specific terms (Docker: resolved container name; fly:
|
||||||
machine id). Returns the in-container prompt path if a prompt
|
machine id). Returns the in-container prompt path if a prompt
|
||||||
was provisioned, else None — the Bottle handle uses it to
|
was provisioned, else None — the Bottle handle uses it to
|
||||||
decide whether to add --append-system-prompt-file to claude's
|
decide whether to add provider-specific prompt args to the agent's
|
||||||
argv.
|
argv.
|
||||||
|
|
||||||
Default orchestration: ca → prompt → skills → git →
|
Default orchestration: ca → prompt → skills → git →
|
||||||
@@ -290,7 +305,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
"""Copy the prompt file into the running bottle. Returns the
|
"""Copy the prompt file into the running bottle. Returns the
|
||||||
in-container path iff the agent has a non-empty prompt;
|
in-container path iff the agent has a non-empty prompt;
|
||||||
callers use the return value to decide whether to add
|
callers use the return value to decide whether to add
|
||||||
--append-system-prompt-file to claude's argv."""
|
provider-specific prompt args to the agent's argv."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def provision_skills(self, plan: PlanT, target: str) -> None:
|
def provision_skills(self, plan: PlanT, target: str) -> None:
|
||||||
@@ -361,12 +376,12 @@ def get_bottle_backend(
|
|||||||
|
|
||||||
`name` precedence:
|
`name` precedence:
|
||||||
1. explicit arg (CLI `--backend=<name>` passes through here)
|
1. explicit arg (CLI `--backend=<name>` passes through here)
|
||||||
2. CLAUDE_BOTTLE_BACKEND env var
|
2. BOT_BOTTLE_BACKEND env var
|
||||||
3. default `docker`
|
3. default `docker`
|
||||||
|
|
||||||
Dies with a pointer at the known backends if the chosen name
|
Dies with a pointer at the known backends if the chosen name
|
||||||
isn't implemented."""
|
isn't implemented."""
|
||||||
resolved = name or os.environ.get("CLAUDE_BOTTLE_BACKEND") or "docker"
|
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "docker"
|
||||||
if resolved not in _BACKENDS:
|
if resolved not in _BACKENDS:
|
||||||
known = ", ".join(sorted(_BACKENDS))
|
known = ", ".join(sorted(_BACKENDS))
|
||||||
die(f"unknown backend {resolved!r}; known backends: {known}")
|
die(f"unknown backend {resolved!r}; known backends: {known}")
|
||||||
@@ -14,7 +14,7 @@ The bulk of the implementation lives in sibling modules:
|
|||||||
- backend: DockerBottleBackend façade wiring the above
|
- backend: DockerBottleBackend façade wiring the above
|
||||||
|
|
||||||
This file only re-exports the public names so
|
This file only re-exports the public names so
|
||||||
`from claude_bottle.backend.docker import DockerBottleBackend` keeps
|
`from bot_bottle.backend.docker import DockerBottleBackend` keeps
|
||||||
working.
|
working.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ from .provision import supervise as _supervise_prov
|
|||||||
|
|
||||||
|
|
||||||
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||||
"""Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND
|
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
|
||||||
(default)."""
|
(default)."""
|
||||||
|
|
||||||
name = "docker"
|
name = "docker"
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
"""DockerBottle — concrete Bottle handle yielded by
|
"""DockerBottle — concrete Bottle handle yielded by DockerBottleBackend."""
|
||||||
DockerBottleBackend.launch.
|
|
||||||
|
|
||||||
Holds the container name plus the in-container prompt path so
|
|
||||||
exec_claude can transparently add --append-system-prompt-file when a
|
|
||||||
prompt was provisioned.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from ...agent_provider import PromptMode, prompt_args
|
||||||
from .. import Bottle, ExecResult
|
from .. import Bottle, ExecResult
|
||||||
|
|
||||||
|
|
||||||
@@ -22,27 +17,36 @@ class DockerBottle(Bottle):
|
|||||||
container: str,
|
container: str,
|
||||||
teardown: Callable[[], None],
|
teardown: Callable[[], None],
|
||||||
prompt_path_in_container: str | None,
|
prompt_path_in_container: str | None,
|
||||||
|
*,
|
||||||
|
agent_command: str = "claude",
|
||||||
|
agent_prompt_mode: PromptMode = "append_file",
|
||||||
):
|
):
|
||||||
self.name = container
|
self.name = container
|
||||||
self._teardown = teardown
|
self._teardown = teardown
|
||||||
self._prompt_path = prompt_path_in_container
|
self._prompt_path = prompt_path_in_container
|
||||||
|
self._agent_prompt_mode = agent_prompt_mode
|
||||||
|
self.agent_command = agent_command
|
||||||
|
self.agent_provider_template = (
|
||||||
|
"codex" if agent_command == "codex" else "claude"
|
||||||
|
)
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
||||||
def claude_argv(
|
def agent_argv(
|
||||||
self, argv: list[str], *, tty: bool = True,
|
self, argv: list[str], *, tty: bool = True,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
full_argv = list(argv)
|
full_argv = list(argv)
|
||||||
if self._prompt_path:
|
full_argv.extend(
|
||||||
full_argv.extend(["--append-system-prompt-file", self._prompt_path])
|
prompt_args(self._agent_prompt_mode, self._prompt_path, argv=full_argv)
|
||||||
|
)
|
||||||
cmd = ["docker", "exec"]
|
cmd = ["docker", "exec"]
|
||||||
if tty:
|
if tty:
|
||||||
cmd.append("-it")
|
cmd.append("-it")
|
||||||
cmd.extend([self.name, "claude", *full_argv])
|
cmd.extend([self.name, self.agent_command, *full_argv])
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
|
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
self.claude_argv(argv, tty=tty), check=False,
|
self.agent_argv(argv, tty=tty), check=False,
|
||||||
).returncode
|
).returncode
|
||||||
|
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||||
+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
|
carries the projects to `compose down`, plus three fallback buckets
|
||||||
for legacy / orphan resources:
|
for legacy / orphan resources:
|
||||||
|
|
||||||
- stray_containers: pre-compose `claude-bottle-*` containers not
|
- stray_containers: pre-compose `bot-bottle-*` containers not
|
||||||
attached to any compose project. Cleared via `docker rm -f`.
|
attached to any compose project. Cleared via `docker rm -f`.
|
||||||
- stray_networks: same idea for networks. Cleared via
|
- stray_networks: same idea for networks. Cleared via
|
||||||
`docker network rm`.
|
`docker network rm`.
|
||||||
- orphan_state_dirs: per-bottle state dirs under
|
- orphan_state_dirs: per-bottle state dirs under
|
||||||
~/.claude-bottle/state/ that have no live compose project AND
|
~/.bot-bottle/state/ that have no live compose project AND
|
||||||
no `.preserve` marker. Reaped via `shutil.rmtree`.
|
no `.preserve` marker. Reaped via `shutil.rmtree`.
|
||||||
|
|
||||||
Compose-managed networks are removed by `compose down --volumes`,
|
Compose-managed networks are removed by `compose down --volumes`,
|
||||||
+11
-4
@@ -11,13 +11,14 @@ import sys
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...agent_provider import PromptMode
|
||||||
from ...egress import EgressPlan
|
from ...egress import EgressPlan
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...log import info
|
from ...log import info
|
||||||
from ...pipelock import PipelockProxyPlan
|
from ...pipelock import PipelockProxyPlan
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
from .. import BottlePlan
|
from .. import BottlePlan
|
||||||
from ..print_util import print_multi
|
from ..print_util import print_multi, visible_agent_env_names
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -34,7 +35,7 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
runtime_image: str # image actually launched (derived or base)
|
runtime_image: str # image actually launched (derived or base)
|
||||||
# Absolute path to the Dockerfile that builds `image`. Empty means
|
# Absolute path to the Dockerfile that builds `image`. Empty means
|
||||||
# use the repo's default Dockerfile. Populated to a per-bottle
|
# use the repo's default Dockerfile. Populated to a per-bottle
|
||||||
# state file (~/.claude-bottle/state/<slug>/Dockerfile) after a
|
# state file (~/.bot-bottle/state/<slug>/Dockerfile) after a
|
||||||
# capability-block remediation (PRD 0016).
|
# capability-block remediation (PRD 0016).
|
||||||
dockerfile_path: str
|
dockerfile_path: str
|
||||||
env_file: Path # docker --env-file: NAME=VALUE literals
|
env_file: Path # docker --env-file: NAME=VALUE literals
|
||||||
@@ -51,6 +52,9 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
# is opt-in via the manifest's bottle.supervise field.
|
# is opt-in via the manifest's bottle.supervise field.
|
||||||
supervise_plan: SupervisePlan | None
|
supervise_plan: SupervisePlan | None
|
||||||
use_runsc: bool
|
use_runsc: bool
|
||||||
|
agent_command: str = "claude"
|
||||||
|
agent_prompt_mode: PromptMode = "append_file"
|
||||||
|
agent_provider_template: str = "claude"
|
||||||
|
|
||||||
def print(self, *, remote_control: bool) -> None:
|
def print(self, *, remote_control: bool) -> None:
|
||||||
"""Render the y/N preflight summary to stderr — compact form
|
"""Render the y/N preflight summary to stderr — compact form
|
||||||
@@ -69,10 +73,14 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
# interpolations from the manifest; egress holds
|
# interpolations from the manifest; egress holds
|
||||||
# upstream tokens in its own environ, so no token forwarding
|
# upstream tokens in its own environ, so no token forwarding
|
||||||
# from the agent to the proxy is needed.
|
# from the agent to the proxy is needed.
|
||||||
env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys()))
|
env_names = visible_agent_env_names(
|
||||||
|
sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys())),
|
||||||
|
agent_provider_template=self.agent_provider_template,
|
||||||
|
)
|
||||||
|
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
info(f"agent : {spec.agent_name}")
|
info(f"agent : {spec.agent_name}")
|
||||||
|
info(f"provider : {self.agent_provider_template}")
|
||||||
print_multi("env ", env_names)
|
print_multi("env ", env_names)
|
||||||
print_multi("skills ", list(agent.skills))
|
print_multi("skills ", list(agent.skills))
|
||||||
info(f"bottle : {agent.bottle}")
|
info(f"bottle : {agent.bottle}")
|
||||||
@@ -91,4 +99,3 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
egress_lines.append(f"{r.host}{auth}")
|
egress_lines.append(f"{r.host}{auth}")
|
||||||
print_multi(" egress ", egress_lines)
|
print_multi(" egress ", egress_lines)
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
|
|
||||||
+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
|
`cli.py resume <identity>` reconstruct a bottle's spec. State
|
||||||
lives at:
|
lives at:
|
||||||
|
|
||||||
~/.claude-bottle/state/<identity>/
|
~/.bot-bottle/state/<identity>/
|
||||||
metadata.json — agent_name + cwd + started_at (for resume)
|
metadata.json — agent_name + cwd + started_at (for resume)
|
||||||
Dockerfile — per-bottle override (absent → use repo's)
|
Dockerfile — per-bottle override (absent → use repo's)
|
||||||
transcript/ — last snapshotted agent state (best-effort)
|
transcript/ — last snapshotted agent state (best-effort)
|
||||||
|
|
||||||
When the per-bottle Dockerfile is present, the launch step builds
|
When the per-bottle Dockerfile is present, the launch step builds
|
||||||
the agent image with a per-bottle tag (claude-bottle-rebuilt-<id>)
|
the agent image with a per-bottle tag (bot-bottle-rebuilt-<id>)
|
||||||
from this file rather than the repo's. The build context is still
|
from this file rather than the repo's. The build context is still
|
||||||
the repo root so the Dockerfile can COPY claude_bottle source files
|
the repo root so the Dockerfile can COPY bot_bottle source files
|
||||||
the same way the original does.
|
the same way the original does.
|
||||||
|
|
||||||
Identity model:
|
Identity model:
|
||||||
@@ -40,7 +40,7 @@ from ... import supervise as _supervise
|
|||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
|
|
||||||
|
|
||||||
# Directory layout: ~/.claude-bottle/state/<identity>/...
|
# Directory layout: ~/.bot-bottle/state/<identity>/...
|
||||||
_STATE_SUBDIR = "state"
|
_STATE_SUBDIR = "state"
|
||||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||||
_TRANSCRIPT_SUBDIR = "transcript"
|
_TRANSCRIPT_SUBDIR = "transcript"
|
||||||
@@ -91,7 +91,7 @@ def bottle_identity(agent_name: str) -> str:
|
|||||||
class BottleMetadata:
|
class BottleMetadata:
|
||||||
"""Persistent record of how a bottle was launched, written at
|
"""Persistent record of how a bottle was launched, written at
|
||||||
start time and read by `cli.py resume`. Lives at
|
start time and read by `cli.py resume`. Lives at
|
||||||
~/.claude-bottle/state/<identity>/metadata.json."""
|
~/.bot-bottle/state/<identity>/metadata.json."""
|
||||||
|
|
||||||
identity: str
|
identity: str
|
||||||
agent_name: str
|
agent_name: str
|
||||||
@@ -112,7 +112,7 @@ def metadata_path(identity: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def write_metadata(metadata: BottleMetadata) -> Path:
|
def write_metadata(metadata: BottleMetadata) -> Path:
|
||||||
"""Persist `metadata` to ~/.claude-bottle/state/<identity>/metadata.json.
|
"""Persist `metadata` to ~/.bot-bottle/state/<identity>/metadata.json.
|
||||||
Mode 0o644 — no secrets, just (agent_name, cwd, timestamp)."""
|
Mode 0o644 — no secrets, just (agent_name, cwd, timestamp)."""
|
||||||
path = metadata_path(metadata.identity)
|
path = metadata_path(metadata.identity)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -144,7 +144,7 @@ def read_metadata(identity: str) -> BottleMetadata | None:
|
|||||||
def bottle_state_dir(identity: str) -> Path:
|
def bottle_state_dir(identity: str) -> Path:
|
||||||
"""Per-bottle state directory on the host. Created lazily by the
|
"""Per-bottle state directory on the host. Created lazily by the
|
||||||
write helpers; readers tolerate its absence."""
|
write helpers; readers tolerate its absence."""
|
||||||
return _supervise.claude_bottle_root() / _STATE_SUBDIR / identity
|
return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity
|
||||||
|
|
||||||
|
|
||||||
def per_bottle_dockerfile_path(identity: str) -> Path:
|
def per_bottle_dockerfile_path(identity: str) -> Path:
|
||||||
@@ -171,9 +171,9 @@ def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
|
|||||||
|
|
||||||
def per_bottle_image_tag(identity: str) -> str:
|
def per_bottle_image_tag(identity: str) -> str:
|
||||||
"""Image tag for a rebuilt bottle. Distinct from the base
|
"""Image tag for a rebuilt bottle. Distinct from the base
|
||||||
claude-bottle:latest so per-bottle rebuilds don't collide in
|
bot-bottle-claude:latest so per-bottle rebuilds don't collide in
|
||||||
the docker image cache."""
|
the docker image cache."""
|
||||||
return f"claude-bottle-rebuilt-{identity}:latest"
|
return f"bot-bottle-rebuilt-{identity}:latest"
|
||||||
|
|
||||||
|
|
||||||
def live_config_dir(identity: str) -> Path:
|
def live_config_dir(identity: str) -> Path:
|
||||||
@@ -248,9 +248,9 @@ def git_gate_state_dir(identity: str) -> Path:
|
|||||||
|
|
||||||
def supervise_state_dir(identity: str) -> Path:
|
def supervise_state_dir(identity: str) -> Path:
|
||||||
"""State subdir for the supervise sidecar's current-config dir
|
"""State subdir for the supervise sidecar's current-config dir
|
||||||
(bind-mounted into the agent at /etc/claude-bottle/current-config).
|
(bind-mounted into the agent at /etc/bot-bottle/current-config).
|
||||||
The queue dir is intentionally NOT under here — it lives at
|
The queue dir is intentionally NOT under here — it lives at
|
||||||
~/.claude-bottle/queue/<slug>/ alongside the audit logs, so it
|
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
|
||||||
survives state-dir cleanup."""
|
survives state-dir cleanup."""
|
||||||
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
||||||
|
|
||||||
+9
-9
@@ -5,11 +5,11 @@ On approval of a capability-block proposal, the dashboard calls
|
|||||||
apply_capability_change(slug, new_dockerfile) which:
|
apply_capability_change(slug, new_dockerfile) which:
|
||||||
|
|
||||||
1. Snapshots the agent's transcript dir to
|
1. Snapshots the agent's transcript dir to
|
||||||
~/.claude-bottle/state/<slug>/transcript/ (best-effort).
|
~/.bot-bottle/state/<slug>/transcript/ (best-effort).
|
||||||
2. Pushes the agent's working tree via `git push` (best-effort —
|
2. Pushes the agent's working tree via `git push` (best-effort —
|
||||||
no upstream / no commits / no git repo all skip with a log).
|
no upstream / no commits / no git repo all skip with a log).
|
||||||
3. Writes the new Dockerfile to
|
3. Writes the new Dockerfile to
|
||||||
~/.claude-bottle/state/<slug>/Dockerfile (PRD 0016 Phase 1
|
~/.bot-bottle/state/<slug>/Dockerfile (PRD 0016 Phase 1
|
||||||
state). The next `cli.py start <agent>` picks it up.
|
state). The next `cli.py start <agent>` picks it up.
|
||||||
4. Force-removes the agent container + all sidecars + the
|
4. Force-removes the agent container + all sidecars + the
|
||||||
per-bottle networks. Idempotent — missing resources are not
|
per-bottle networks. Idempotent — missing resources are not
|
||||||
@@ -55,7 +55,7 @@ _AGENT_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace"
|
|||||||
|
|
||||||
# Per-bottle resource name patterns (mirroring prepare.py).
|
# Per-bottle resource name patterns (mirroring prepare.py).
|
||||||
def _agent_container_name(slug: str) -> str:
|
def _agent_container_name(slug: str) -> str:
|
||||||
return f"claude-bottle-{slug}"
|
return f"bot-bottle-{slug}"
|
||||||
|
|
||||||
|
|
||||||
def _per_bottle_container_names(slug: str) -> list[str]:
|
def _per_bottle_container_names(slug: str) -> list[str]:
|
||||||
@@ -70,8 +70,8 @@ def _per_bottle_container_names(slug: str) -> list[str]:
|
|||||||
|
|
||||||
def _per_bottle_network_names(slug: str) -> list[str]:
|
def _per_bottle_network_names(slug: str) -> list[str]:
|
||||||
return [
|
return [
|
||||||
f"claude-bottle-net-{slug}",
|
f"bot-bottle-net-{slug}",
|
||||||
f"claude-bottle-egress-{slug}",
|
f"bot-bottle-egress-{slug}",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -128,16 +128,16 @@ def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def _repo_dockerfile_path() -> Path:
|
def _repo_dockerfile_path() -> Path:
|
||||||
"""Path to the repo's Dockerfile (one dir above this module's
|
"""Path to the repo's Claude Dockerfile (one dir above this module's
|
||||||
package root). Resolved at call time so the path is correct
|
package root). Resolved at call time so the path is correct
|
||||||
regardless of where this module is imported from."""
|
regardless of where this module is imported from."""
|
||||||
# claude_bottle/backend/docker/capability_apply.py -> repo root
|
# bot_bottle/backend/docker/capability_apply.py -> repo root
|
||||||
return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
|
return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
|
||||||
|
|
||||||
|
|
||||||
def snapshot_transcript(slug: str) -> None:
|
def snapshot_transcript(slug: str) -> None:
|
||||||
"""`docker cp` /home/node/.claude out of the agent container into
|
"""`docker cp` /home/node/.claude out of the agent container into
|
||||||
~/.claude-bottle/state/<slug>/transcript/. Best-effort: missing
|
~/.bot-bottle/state/<slug>/transcript/. Best-effort: missing
|
||||||
container, missing dir, or cp error all log a warning and return.
|
container, missing dir, or cp error all log a warning and return.
|
||||||
The transcript is what `claude --resume` reads to pick up where
|
The transcript is what `claude --resume` reads to pick up where
|
||||||
the agent left off.
|
the agent left off.
|
||||||
@@ -7,13 +7,13 @@ scan, just as a fallback bucket alongside the project list.
|
|||||||
|
|
||||||
`prepare_cleanup` enumerates:
|
`prepare_cleanup` enumerates:
|
||||||
|
|
||||||
- Live compose projects whose name starts with `claude-bottle-`.
|
- Live compose projects whose name starts with `bot-bottle-`.
|
||||||
- `claude-bottle-*` containers that aren't part of any compose
|
- `bot-bottle-*` containers that aren't part of any compose
|
||||||
project (legacy orphans).
|
project (legacy orphans).
|
||||||
- `claude-bottle-*` networks that aren't tied to a compose
|
- `bot-bottle-*` networks that aren't tied to a compose
|
||||||
project (legacy orphans; compose-managed networks come down
|
project (legacy orphans; compose-managed networks come down
|
||||||
with `compose down --volumes` and don't appear here).
|
with `compose down --volumes` and don't appear here).
|
||||||
- State dirs under ~/.claude-bottle/state/<identity>/ with no
|
- State dirs under ~/.bot-bottle/state/<identity>/ with no
|
||||||
live compose project AND no `.preserve` marker.
|
live compose project AND no `.preserve` marker.
|
||||||
|
|
||||||
`cleanup` removes everything in the plan.
|
`cleanup` removes everything in the plan.
|
||||||
@@ -36,7 +36,7 @@ from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
|
|||||||
|
|
||||||
|
|
||||||
def _list_prefixed_containers() -> list[str]:
|
def _list_prefixed_containers() -> list[str]:
|
||||||
"""All claude-bottle-prefixed containers, running or stopped."""
|
"""All bot-bottle-prefixed containers, running or stopped."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "-a",
|
["docker", "ps", "-a",
|
||||||
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}",
|
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}",
|
||||||
@@ -60,7 +60,7 @@ def _list_prefixed_containers() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _list_prefixed_networks() -> list[str]:
|
def _list_prefixed_networks() -> list[str]:
|
||||||
"""All claude-bottle-prefixed networks not currently attached
|
"""All bot-bottle-prefixed networks not currently attached
|
||||||
to a compose project. Compose-managed networks have a
|
to a compose project. Compose-managed networks have a
|
||||||
`com.docker.compose.project` label; bare ones (from pre-compose
|
`com.docker.compose.project` label; bare ones (from pre-compose
|
||||||
code paths) don't."""
|
code paths) don't."""
|
||||||
@@ -95,7 +95,7 @@ def _list_orphan_state_dirs(
|
|||||||
ANY backend — used so this docker-side check doesn't reap a
|
ANY backend — used so this docker-side check doesn't reap a
|
||||||
running smolmachines bottle's state dir (the layout is shared
|
running smolmachines bottle's state dir (the layout is shared
|
||||||
across both backends)."""
|
across both backends)."""
|
||||||
state_root = _supervise.claude_bottle_root() / "state"
|
state_root = _supervise.bot_bottle_root() / "state"
|
||||||
if not state_root.is_dir():
|
if not state_root.is_dir():
|
||||||
return []
|
return []
|
||||||
orphans: list[str] = []
|
orphans: list[str] = []
|
||||||
@@ -20,11 +20,11 @@ SDK-call branching in `launch.py` today):
|
|||||||
|
|
||||||
Naming:
|
Naming:
|
||||||
|
|
||||||
- Compose project: `claude-bottle-<slug>`.
|
- Compose project: `bot-bottle-<slug>`.
|
||||||
- Service names (inside the file): `agent`, `pipelock`,
|
- Service names (inside the file): `agent`, `pipelock`,
|
||||||
`egress`, `git-gate`, `supervise`.
|
`egress`, `git-gate`, `supervise`.
|
||||||
- `container_name:` matches today's pattern
|
- `container_name:` matches today's pattern
|
||||||
(`claude-bottle-<service>-<slug>`) so dashboard/cleanup discovery
|
(`bot-bottle-<service>-<slug>`) so dashboard/cleanup discovery
|
||||||
via the prefix scan keeps working through the transition.
|
via the prefix scan keeps working through the transition.
|
||||||
- Network aliases preserve the current dial-by-shortname pattern
|
- Network aliases preserve the current dial-by-shortname pattern
|
||||||
for `egress` / `supervise`, and add the long container-name as
|
for `egress` / `supervise`, and add the long container-name as
|
||||||
@@ -98,7 +98,7 @@ def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
feed it a fully-resolved plan or get an incomplete compose
|
feed it a fully-resolved plan or get an incomplete compose
|
||||||
spec back.
|
spec back.
|
||||||
"""
|
"""
|
||||||
project = f"claude-bottle-{plan.slug}"
|
project = f"bot-bottle-{plan.slug}"
|
||||||
services: dict[str, Any] = {
|
services: dict[str, Any] = {
|
||||||
"sidecars": _sidecar_bundle_service(plan),
|
"sidecars": _sidecar_bundle_service(plan),
|
||||||
"agent": _agent_service(plan),
|
"agent": _agent_service(plan),
|
||||||
@@ -146,7 +146,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
|
|
||||||
Mechanics:
|
Mechanics:
|
||||||
|
|
||||||
- Daemon subset narrows via `CLAUDE_BOTTLE_SIDECAR_DAEMONS`
|
- Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS`
|
||||||
env. pipelock is always present; egress / git-gate /
|
env. pipelock is always present; egress / git-gate /
|
||||||
supervise are conditional on the plan.
|
supervise are conditional on the plan.
|
||||||
- Volumes are the union of the four daemons' bind-mounts,
|
- Volumes are the union of the four daemons' bind-mounts,
|
||||||
@@ -160,7 +160,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
which is wrong.
|
which is wrong.
|
||||||
- Network aliases register every legacy short/long
|
- Network aliases register every legacy short/long
|
||||||
hostname (pipelock, egress, git-gate, supervise plus
|
hostname (pipelock, egress, git-gate, supervise plus
|
||||||
their `claude-bottle-<service>-<slug>` long forms) so
|
their `bot-bottle-<service>-<slug>` long forms) so
|
||||||
the agent's HTTPS_PROXY URL and any other inter-service
|
the agent's HTTPS_PROXY URL and any other inter-service
|
||||||
reference resolves to the bundle.
|
reference resolves to the bundle.
|
||||||
"""
|
"""
|
||||||
@@ -170,7 +170,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
if plan.supervise_plan is not None:
|
if plan.supervise_plan is not None:
|
||||||
daemons.append("supervise")
|
daemons.append("supervise")
|
||||||
|
|
||||||
env: list[str] = [f"CLAUDE_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
|
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
|
||||||
volumes: list[dict[str, Any]] = []
|
volumes: list[dict[str, Any]] = []
|
||||||
|
|
||||||
# --- pipelock ----------------------------------------------------
|
# --- pipelock ----------------------------------------------------
|
||||||
@@ -212,6 +212,11 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
keypath,
|
keypath,
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
||||||
))
|
))
|
||||||
|
if u.known_hosts_file:
|
||||||
|
volumes.append(_bind(
|
||||||
|
u.known_hosts_file,
|
||||||
|
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
|
||||||
|
))
|
||||||
extra_map = git_gate_aggregate_extra_hosts(gp.upstreams)
|
extra_map = git_gate_aggregate_extra_hosts(gp.upstreams)
|
||||||
extra_hosts = [f"{host}:{ip}" for host, ip in sorted(extra_map.items())]
|
extra_hosts = [f"{host}:{ip}" for host, ip in sorted(extra_map.items())]
|
||||||
|
|
||||||
@@ -351,7 +356,7 @@ COMPOSE_FILE_NAME = "docker-compose.yml"
|
|||||||
COMPOSE_LOG_NAME = "compose.log"
|
COMPOSE_LOG_NAME = "compose.log"
|
||||||
|
|
||||||
|
|
||||||
COMPOSE_PROJECT_PREFIX = "claude-bottle-"
|
COMPOSE_PROJECT_PREFIX = "bot-bottle-"
|
||||||
|
|
||||||
|
|
||||||
def compose_project_name(slug: str) -> str:
|
def compose_project_name(slug: str) -> str:
|
||||||
@@ -371,15 +376,20 @@ def slug_from_compose_project(project: str) -> str:
|
|||||||
return project[len(COMPOSE_PROJECT_PREFIX):]
|
return project[len(COMPOSE_PROJECT_PREFIX):]
|
||||||
|
|
||||||
|
|
||||||
def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
|
def list_compose_projects(
|
||||||
"""All compose project names starting with `claude-bottle-`.
|
*, include_stopped: bool = True, warn_on_error: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
|
"""All compose project names starting with `bot-bottle-`.
|
||||||
`include_stopped=True` (default) runs `docker compose ls --all`
|
`include_stopped=True` (default) runs `docker compose ls --all`
|
||||||
so exited projects appear too; pass False to get only projects
|
so exited projects appear too; pass False to get only projects
|
||||||
with at least one running container.
|
with at least one running container.
|
||||||
|
|
||||||
Returns [] on docker daemon errors or malformed output rather
|
Returns [] on docker daemon errors or malformed output rather
|
||||||
than raising — callers should treat the empty list as "no
|
than raising — callers should treat the empty list as "no
|
||||||
projects discoverable", not "no projects exist"."""
|
projects discoverable", not "no projects exist". `warn_on_error`
|
||||||
|
stays true for explicit operator commands like cleanup, but active
|
||||||
|
discovery paths set it false so dashboard refreshes don't spam
|
||||||
|
stderr while Docker Desktop is stopped."""
|
||||||
argv = ["docker", "compose", "ls", "--format", "json"]
|
argv = ["docker", "compose", "ls", "--format", "json"]
|
||||||
if include_stopped:
|
if include_stopped:
|
||||||
argv.insert(3, "--all")
|
argv.insert(3, "--all")
|
||||||
@@ -392,11 +402,13 @@ def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
|
|||||||
# error from the caller's POV: no projects discoverable.
|
# error from the caller's POV: no projects discoverable.
|
||||||
return []
|
return []
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
if warn_on_error:
|
||||||
warn(f"docker compose ls failed: {result.stderr.strip()}")
|
warn(f"docker compose ls failed: {result.stderr.strip()}")
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
projects = json.loads(result.stdout or "[]")
|
projects = json.loads(result.stdout or "[]")
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
|
if warn_on_error:
|
||||||
warn(f"docker compose ls returned malformed JSON: {e}")
|
warn(f"docker compose ls returned malformed JSON: {e}")
|
||||||
return []
|
return []
|
||||||
names: list[str] = []
|
names: list[str] = []
|
||||||
@@ -409,14 +421,19 @@ def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
|
|||||||
return sorted(set(names))
|
return sorted(set(names))
|
||||||
|
|
||||||
|
|
||||||
def list_active_slugs(*, include_stopped: bool = False) -> list[str]:
|
def list_active_slugs(
|
||||||
|
*, include_stopped: bool = False, warn_on_error: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
"""Slugs (project name minus prefix) of currently-running
|
"""Slugs (project name minus prefix) of currently-running
|
||||||
bottles. Used by the dashboard's operator-edit verbs to choose
|
bottles. Used by the dashboard's operator-edit verbs to choose
|
||||||
a bottle to apply a config edit to."""
|
a bottle to apply a config edit to."""
|
||||||
return sorted(
|
return sorted(
|
||||||
slug for slug in (
|
slug for slug in (
|
||||||
slug_from_compose_project(p)
|
slug_from_compose_project(p)
|
||||||
for p in list_compose_projects(include_stopped=include_stopped)
|
for p in list_compose_projects(
|
||||||
|
include_stopped=include_stopped,
|
||||||
|
warn_on_error=warn_on_error,
|
||||||
|
)
|
||||||
) if slug
|
) if slug
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ from ...log import die
|
|||||||
# Listening port the egress daemon binds inside the bundle. The
|
# Listening port the egress daemon binds inside the bundle. The
|
||||||
# agent's HTTP_PROXY env var resolves to `http://egress:<port>`,
|
# agent's HTTP_PROXY env var resolves to `http://egress:<port>`,
|
||||||
# and the bundle's network aliases route `egress` to itself.
|
# and the bundle's network aliases route `egress` to itself.
|
||||||
EGRESS_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PORT", "9099"))
|
EGRESS_PORT = int(os.environ.get("BOT_BOTTLE_EGRESS_PORT", "9099"))
|
||||||
|
|
||||||
# In-container path for mitmproxy's CA. The format is a single PEM
|
# In-container path for mitmproxy's CA. The format is a single PEM
|
||||||
# file holding BOTH the cert and the private key, concatenated. The
|
# file holding BOTH the cert and the private key, concatenated. The
|
||||||
@@ -88,8 +88,8 @@ def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
|||||||
"x509_extensions = v3_ca\n"
|
"x509_extensions = v3_ca\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[req_dn]\n"
|
"[req_dn]\n"
|
||||||
"O = claude-bottle\n"
|
"O = bot-bottle\n"
|
||||||
"CN = claude-bottle egress CA\n"
|
"CN = bot-bottle egress CA\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[v3_ca]\n"
|
"[v3_ca]\n"
|
||||||
"basicConstraints = critical, CA:TRUE\n"
|
"basicConstraints = critical, CA:TRUE\n"
|
||||||
@@ -115,7 +115,7 @@ def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
|||||||
# where mitmproxy runs as uid 1000 — so the host file has to be
|
# where mitmproxy runs as uid 1000 — so the host file has to be
|
||||||
# world-readable for the container's user to read it through the
|
# world-readable for the container's user to read it through the
|
||||||
# mount. Owner-only mode on the parent dir (state/<slug>/, under
|
# mount. Owner-only mode on the parent dir (state/<slug>/, under
|
||||||
# ~/.claude-bottle which inherits ~'s 0o700) is what actually
|
# ~/.bot-bottle which inherits ~'s 0o700) is what actually
|
||||||
# restricts who can reach this file on the host.
|
# restricts who can reach this file on the host.
|
||||||
mitm = work / "mitmproxy-ca.pem"
|
mitm = work / "mitmproxy-ca.pem"
|
||||||
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
|
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
|
||||||
@@ -24,7 +24,7 @@ def enumerate_active() -> list[ActiveAgent]:
|
|||||||
responsible for gating on `has_backend('docker')` if it
|
responsible for gating on `has_backend('docker')` if it
|
||||||
matters; if docker is missing the `docker ps` call below
|
matters; if docker is missing the `docker ps` call below
|
||||||
returns an empty list silently."""
|
returns an empty list silently."""
|
||||||
slugs = list_active_slugs(include_stopped=False)
|
slugs = list_active_slugs(include_stopped=False, warn_on_error=False)
|
||||||
if not slugs:
|
if not slugs:
|
||||||
return []
|
return []
|
||||||
services_by_project = _query_services_by_project()
|
services_by_project = _query_services_by_project()
|
||||||
@@ -23,7 +23,7 @@ The flow is:
|
|||||||
entries inherit without rendering values into the file).
|
entries inherit without rendering values into the file).
|
||||||
8. Provision (CA install, prompt copy, skills, git, supervise
|
8. Provision (CA install, prompt copy, skills, git, supervise
|
||||||
config) — unchanged, uses `docker exec`.
|
config) — unchanged, uses `docker exec`.
|
||||||
9. Yield a DockerBottle handle. `exec_claude` runs claude via
|
9. Yield a DockerBottle handle. `exec_agent` runs claude via
|
||||||
`docker exec -it` exactly like the pre-compose world.
|
`docker exec -it` exactly like the pre-compose world.
|
||||||
|
|
||||||
Teardown (ExitStack callbacks fire in reverse):
|
Teardown (ExitStack callbacks fire in reverse):
|
||||||
@@ -204,9 +204,15 @@ def launch(
|
|||||||
# the agent container by its known name.
|
# the agent container by its known name.
|
||||||
prompt_path = provision(plan, plan.container_name)
|
prompt_path = provision(plan, plan.container_name)
|
||||||
|
|
||||||
# Step 9: yield. exec_claude continues to use `docker exec -it`
|
# Step 9: yield. exec_agent continues to use `docker exec -it`
|
||||||
# — the agent runs `sleep infinity` per the renderer's
|
# — the agent runs `sleep infinity` per the renderer's
|
||||||
# service spec.
|
# service spec.
|
||||||
yield DockerBottle(plan.container_name, teardown, prompt_path)
|
yield DockerBottle(
|
||||||
|
plan.container_name,
|
||||||
|
teardown,
|
||||||
|
prompt_path,
|
||||||
|
agent_command=plan.agent_command,
|
||||||
|
agent_prompt_mode=plan.agent_prompt_mode,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
teardown()
|
teardown()
|
||||||
@@ -7,8 +7,8 @@ bridge for upstream egress. We deliberately do NOT use Docker's legacy
|
|||||||
embedded DNS resolver, which pipelock needs to resolve api.anthropic.com
|
embedded DNS resolver, which pipelock needs to resolve api.anthropic.com
|
||||||
and similar upstream hostnames.
|
and similar upstream hostnames.
|
||||||
|
|
||||||
Naming: claude-bottle-net-<slug> (internal),
|
Naming: bot-bottle-net-<slug> (internal),
|
||||||
claude-bottle-egress-<slug> (egress). Numeric suffix on conflict
|
bot-bottle-egress-<slug> (egress). Numeric suffix on conflict
|
||||||
(-2, -3, ..., capped at 100).
|
(-2, -3, ..., capped at 100).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -20,11 +20,11 @@ from ...log import die, info, warn
|
|||||||
|
|
||||||
|
|
||||||
def network_name_for_slug(slug: str) -> str:
|
def network_name_for_slug(slug: str) -> str:
|
||||||
return f"claude-bottle-net-{slug}"
|
return f"bot-bottle-net-{slug}"
|
||||||
|
|
||||||
|
|
||||||
def network_egress_name_for_slug(slug: str) -> str:
|
def network_egress_name_for_slug(slug: str) -> str:
|
||||||
return f"claude-bottle-egress-{slug}"
|
return f"bot-bottle-egress-{slug}"
|
||||||
|
|
||||||
|
|
||||||
def network_exists(name: str) -> bool:
|
def network_exists(name: str) -> bool:
|
||||||
@@ -27,12 +27,12 @@ from ...pipelock import ( # noqa: F401
|
|||||||
# Pipelock image, pinned by digest. The digest is the multi-arch image
|
# Pipelock image, pinned by digest. The digest is the multi-arch image
|
||||||
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
|
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
|
||||||
PIPELOCK_IMAGE = os.environ.get(
|
PIPELOCK_IMAGE = os.environ.get(
|
||||||
"CLAUDE_BOTTLE_PIPELOCK_IMAGE",
|
"BOT_BOTTLE_PIPELOCK_IMAGE",
|
||||||
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
|
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Listening port for pipelock's forward proxy.
|
# Listening port for pipelock's forward proxy.
|
||||||
PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888")
|
PIPELOCK_PORT = os.environ.get("BOT_BOTTLE_PIPELOCK_PORT", "8888")
|
||||||
|
|
||||||
|
|
||||||
# The URL egress dials for its upstream HTTPS_PROXY. egress and
|
# The URL egress dials for its upstream HTTPS_PROXY. egress and
|
||||||
@@ -14,6 +14,7 @@ import os
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...agent_provider import runtime_for
|
||||||
from ...egress import Egress
|
from ...egress import Egress
|
||||||
from ...env import ResolvedEnv, resolve_env
|
from ...env import ResolvedEnv, resolve_env
|
||||||
from ...git_gate import GitGate
|
from ...git_gate import GitGate
|
||||||
@@ -58,6 +59,8 @@ def resolve_plan(
|
|||||||
manifest = spec.manifest
|
manifest = spec.manifest
|
||||||
agent = manifest.agents[spec.agent_name]
|
agent = manifest.agents[spec.agent_name]
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
|
provider = bottle.agent_provider
|
||||||
|
provider_runtime = runtime_for(provider.template)
|
||||||
|
|
||||||
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
|
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
|
||||||
# mints a random-suffixed identity (so parallel runs of the same
|
# mints a random-suffixed identity (so parallel runs of the same
|
||||||
@@ -74,7 +77,7 @@ def resolve_plan(
|
|||||||
cwd=spec.user_cwd if spec.copy_cwd else "",
|
cwd=spec.user_cwd if spec.copy_cwd else "",
|
||||||
copy_cwd=spec.copy_cwd,
|
copy_cwd=spec.copy_cwd,
|
||||||
started_at=datetime.now(timezone.utc).isoformat(),
|
started_at=datetime.now(timezone.utc).isoformat(),
|
||||||
compose_project=f"claude-bottle-{slug}",
|
compose_project=f"bot-bottle-{slug}",
|
||||||
))
|
))
|
||||||
# Clear any leftover preserve marker from a prior capability-block
|
# Clear any leftover preserve marker from a prior capability-block
|
||||||
# so this fresh launch can be cleaned up at session-end unless
|
# so this fresh launch can be cleaned up at session-end unless
|
||||||
@@ -89,26 +92,32 @@ def resolve_plan(
|
|||||||
if per_bottle_dockerfile(slug) is not None:
|
if per_bottle_dockerfile(slug) is not None:
|
||||||
image_default = per_bottle_image_tag(slug)
|
image_default = per_bottle_image_tag(slug)
|
||||||
dockerfile_path = str(per_bottle_dockerfile_path(slug))
|
dockerfile_path = str(per_bottle_dockerfile_path(slug))
|
||||||
|
elif provider.dockerfile:
|
||||||
|
image_default = f"bot-bottle-{provider.template}:{slug}"
|
||||||
|
dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
|
||||||
|
elif provider_runtime.dockerfile:
|
||||||
|
image_default = provider_runtime.image
|
||||||
|
dockerfile_path = provider_runtime.dockerfile
|
||||||
else:
|
else:
|
||||||
image_default = "claude-bottle:latest"
|
image_default = provider_runtime.image
|
||||||
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default)
|
image = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
|
||||||
derived_image = ""
|
derived_image = ""
|
||||||
runtime_image = image
|
runtime_image = image
|
||||||
if spec.copy_cwd:
|
if spec.copy_cwd:
|
||||||
derived_image = os.environ.get(
|
derived_image = os.environ.get(
|
||||||
"CLAUDE_BOTTLE_DERIVED_IMAGE", f"claude-bottle:cwd-{slug}"
|
"BOT_BOTTLE_DERIVED_IMAGE", f"bot-bottle-cwd:{slug}"
|
||||||
)
|
)
|
||||||
runtime_image = derived_image
|
runtime_image = derived_image
|
||||||
|
|
||||||
default_container = f"claude-bottle-{slug}"
|
default_container = f"bot-bottle-{slug}"
|
||||||
pinned_container = os.environ.get("CLAUDE_BOTTLE_CONTAINER", "")
|
pinned_container = os.environ.get("BOT_BOTTLE_CONTAINER", "")
|
||||||
container_name_pinned = bool(pinned_container)
|
container_name_pinned = bool(pinned_container)
|
||||||
if container_name_pinned:
|
if container_name_pinned:
|
||||||
container_name = pinned_container
|
container_name = pinned_container
|
||||||
if docker_mod.container_exists(container_name):
|
if docker_mod.container_exists(container_name):
|
||||||
die(
|
die(
|
||||||
f"container '{container_name}' already exists "
|
f"container '{container_name}' already exists "
|
||||||
f"(pinned via CLAUDE_BOTTLE_CONTAINER). "
|
f"(pinned via BOT_BOTTLE_CONTAINER). "
|
||||||
f"Remove it with 'docker rm -f {container_name}' or unset the override."
|
f"Remove it with 'docker rm -f {container_name}' or unset the override."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -138,7 +147,7 @@ def resolve_plan(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# PRD 0018 chunk 2: prepare-time scratch files live under
|
# PRD 0018 chunk 2: prepare-time scratch files live under
|
||||||
# ~/.claude-bottle/state/<slug>/<service>/ so chunk 3's compose
|
# ~/.bot-bottle/state/<slug>/<service>/ so chunk 3's compose
|
||||||
# bind-mounts can point at stable paths. The state subdirs are
|
# bind-mounts can point at stable paths. The state subdirs are
|
||||||
# cleaned up by start.py's session-end teardown unless something
|
# cleaned up by start.py's session-end teardown unless something
|
||||||
# explicitly preserves the state dir (capability-block, crash).
|
# explicitly preserves the state dir (capability-block, crash).
|
||||||
@@ -171,8 +180,16 @@ def resolve_plan(
|
|||||||
# PRD 0017 chunk 3 moved them behind the
|
# PRD 0017 chunk 3 moved them behind the
|
||||||
# `list-egress-routes` MCP tool so the agent gets live
|
# `list-egress-routes` MCP tool so the agent gets live
|
||||||
# state rather than a launch-time snapshot.)
|
# state rather than a launch-time snapshot.)
|
||||||
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
|
supervise_dockerfile_path = (
|
||||||
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
|
Path(dockerfile_path)
|
||||||
|
if dockerfile_path
|
||||||
|
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
|
||||||
|
)
|
||||||
|
dockerfile_content = (
|
||||||
|
supervise_dockerfile_path.read_text()
|
||||||
|
if supervise_dockerfile_path.is_file()
|
||||||
|
else ""
|
||||||
|
)
|
||||||
supervise_dir = supervise_state_dir(slug)
|
supervise_dir = supervise_state_dir(slug)
|
||||||
supervise_dir.mkdir(parents=True, exist_ok=True)
|
supervise_dir.mkdir(parents=True, exist_ok=True)
|
||||||
supervise_plan = supervise.prepare(
|
supervise_plan = supervise.prepare(
|
||||||
@@ -192,12 +209,12 @@ def resolve_plan(
|
|||||||
# placeholder. The placeholder isn't any real token value, so
|
# placeholder. The placeholder isn't any real token value, so
|
||||||
# leaking it would tell an attacker only that egress is in
|
# leaking it would tell an attacker only that egress is in
|
||||||
# front. Manifest validation enforces singleton on this role.
|
# front. Manifest validation enforces singleton on this role.
|
||||||
has_anthropic_auth = any(
|
has_provider_auth = any(
|
||||||
"claude_code_oauth" in r.roles
|
provider_runtime.auth_role in r.roles for r in egress_plan.routes
|
||||||
for r in egress_plan.routes
|
|
||||||
)
|
)
|
||||||
if has_anthropic_auth:
|
if has_provider_auth:
|
||||||
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
forwarded_env[provider_runtime.placeholder_env] = "egress-placeholder"
|
||||||
|
if provider.template == "claude" and has_provider_auth:
|
||||||
# Belt-and-braces: turn off telemetry endpoints (statsig,
|
# Belt-and-braces: turn off telemetry endpoints (statsig,
|
||||||
# error reporting) that egress can't gate by auth.
|
# error reporting) that egress can't gate by auth.
|
||||||
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
||||||
@@ -225,6 +242,9 @@ def resolve_plan(
|
|||||||
egress_plan=egress_plan,
|
egress_plan=egress_plan,
|
||||||
supervise_plan=supervise_plan,
|
supervise_plan=supervise_plan,
|
||||||
use_runsc=use_runsc,
|
use_runsc=use_runsc,
|
||||||
|
agent_command=provider_runtime.command,
|
||||||
|
agent_prompt_mode=provider_runtime.prompt_mode,
|
||||||
|
agent_provider_template=provider.template,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -243,3 +263,10 @@ def _write_env_file(resolved: ResolvedEnv, env_file: Path) -> None:
|
|||||||
env_lines.append(f"{name}={value}")
|
env_lines.append(f"{name}={value}")
|
||||||
env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
|
env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
|
||||||
env_file.chmod(0o600)
|
env_file.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
|
||||||
|
path = Path(os.path.expanduser(path_value))
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = Path(spec.user_cwd) / path
|
||||||
|
return str(path)
|
||||||
+1
-1
@@ -43,7 +43,7 @@ from ..bottle_plan import DockerBottlePlan
|
|||||||
# Debian-family path for sources that `update-ca-certificates` reads.
|
# Debian-family path for sources that `update-ca-certificates` reads.
|
||||||
# Bundle path is what the command rebuilds and what every standard
|
# Bundle path is what the command rebuilds and what every standard
|
||||||
# TLS consumer in the image reads.
|
# TLS consumer in the image reads.
|
||||||
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-mitm-ca.crt"
|
AGENT_CA_PATH = "/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt"
|
||||||
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
||||||
|
|
||||||
|
|
||||||
+1
-1
@@ -66,7 +66,7 @@ def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
if not bottle.git:
|
if not bottle.git:
|
||||||
return
|
return
|
||||||
container = target
|
container = target
|
||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
container_gitconfig = f"{container_home}/.gitconfig"
|
container_gitconfig = f"{container_home}/.gitconfig"
|
||||||
|
|
||||||
content = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME)
|
content = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME)
|
||||||
+2
-2
@@ -18,8 +18,8 @@ def provision_prompt(plan: DockerBottlePlan, target: str) -> str | None:
|
|||||||
prompt (drives --append-system-prompt-file), else None. The
|
prompt (drives --append-system-prompt-file), else None. The
|
||||||
file is copied either way so the path always exists."""
|
file is copied either way so the path always exists."""
|
||||||
container = target
|
container = target
|
||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt"
|
in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
|
||||||
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"],
|
["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"],
|
||||||
+2
-2
@@ -28,9 +28,9 @@ def provision_skills(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
container = target
|
container = target
|
||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
skills_dir = os.environ.get(
|
skills_dir = os.environ.get(
|
||||||
"CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
|
"BOT_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
|
||||||
)
|
)
|
||||||
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
+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
|
runs pipelock + egress + git-gate + supervise as one container
|
||||||
per bottle under a small Python init supervisor. As of chunk 5
|
per bottle under a small Python init supervisor. As of chunk 5
|
||||||
the bundle is the only shape — the legacy four-sidecar topology
|
the bundle is the only shape — the legacy four-sidecar topology
|
||||||
and its `CLAUDE_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
|
and its `BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,17 +15,17 @@ import os
|
|||||||
# Bundle image. Defaults to a built-locally tag (built from the
|
# Bundle image. Defaults to a built-locally tag (built from the
|
||||||
# repo's Dockerfile.sidecars via compose `build:`). Operators
|
# repo's Dockerfile.sidecars via compose `build:`). Operators
|
||||||
# pinning to a published digest can override via env, matching
|
# pinning to a published digest can override via env, matching
|
||||||
# the existing `CLAUDE_BOTTLE_PIPELOCK_IMAGE` shape.
|
# the existing `BOT_BOTTLE_PIPELOCK_IMAGE` shape.
|
||||||
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
||||||
"CLAUDE_BOTTLE_SIDECAR_IMAGE",
|
"BOT_BOTTLE_SIDECAR_IMAGE",
|
||||||
"claude-bottle-sidecars:latest",
|
"bot-bottle-sidecars:latest",
|
||||||
)
|
)
|
||||||
|
|
||||||
SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars"
|
SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars"
|
||||||
|
|
||||||
|
|
||||||
def sidecar_bundle_container_name(slug: str) -> str:
|
def sidecar_bundle_container_name(slug: str) -> str:
|
||||||
"""`claude-bottle-sidecars-<slug>`. Same prefix scheme as the
|
"""`bot-bottle-sidecars-<slug>`. Same prefix scheme as the
|
||||||
per-sidecar containers it replaces, so the dashboard's
|
per-sidecar containers it replaces, so the dashboard's
|
||||||
discovery-by-prefix logic keeps working."""
|
discovery-by-prefix logic keeps working."""
|
||||||
return f"claude-bottle-sidecars-{slug}"
|
return f"bot-bottle-sidecars-{slug}"
|
||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
from ..agent_provider import runtime_for
|
||||||
from ..log import info
|
from ..log import info
|
||||||
|
|
||||||
|
|
||||||
@@ -26,3 +27,19 @@ def print_multi(label: str, values: Sequence[str]) -> None:
|
|||||||
indent = " " * (len(label) + 2)
|
indent = " " * (len(label) + 2)
|
||||||
for v in values[1:]:
|
for v in values[1:]:
|
||||||
info(f"{indent}{v}")
|
info(f"{indent}{v}")
|
||||||
|
|
||||||
|
|
||||||
|
def visible_agent_env_names(
|
||||||
|
env_names: Sequence[str], *, agent_provider_template: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Env names worth showing in launch summaries.
|
||||||
|
|
||||||
|
Provider auth placeholders (`OPENAI_API_KEY`,
|
||||||
|
`CLAUDE_CODE_OAUTH_TOKEN`) are implementation details: they are
|
||||||
|
non-secret dummy values that satisfy the provider CLI while egress
|
||||||
|
injects the real upstream Authorization header. Showing them in
|
||||||
|
preflight makes the operator think a real key is entering the
|
||||||
|
agent, so hide only that provider-owned placeholder.
|
||||||
|
"""
|
||||||
|
hidden = {runtime_for(agent_provider_template).placeholder_env}
|
||||||
|
return sorted({name for name in env_names if name not in hidden})
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
"""smolmachines bottle backend (PRD 0023).
|
"""smolmachines bottle backend (PRD 0023).
|
||||||
|
|
||||||
Selectable via `CLAUDE_BOTTLE_BACKEND=smolmachines`. Runs each
|
Selectable via `BOT_BOTTLE_BACKEND=smolmachines`. Runs each
|
||||||
bottle inside a per-agent microVM (libkrun / Hypervisor.framework
|
bottle inside a per-agent microVM (libkrun / Hypervisor.framework
|
||||||
on macOS) with a userspace gvproxy gateway as the egress
|
on macOS) with a userspace gvproxy gateway as the egress
|
||||||
primitive. The sidecar bundle (PRD 0024) runs as a host-side
|
primitive. The sidecar bundle (PRD 0024) runs as a host-side
|
||||||
+1
-1
@@ -27,7 +27,7 @@ class SmolmachinesBottleBackend(
|
|||||||
BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"]
|
BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"]
|
||||||
):
|
):
|
||||||
"""smolmachines backend. Selected by
|
"""smolmachines backend. Selected by
|
||||||
`CLAUDE_BOTTLE_BACKEND=smolmachines`."""
|
`BOT_BOTTLE_BACKEND=smolmachines`."""
|
||||||
|
|
||||||
name = "smolmachines"
|
name = "smolmachines"
|
||||||
|
|
||||||
+31
-17
@@ -1,15 +1,15 @@
|
|||||||
"""SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d).
|
"""SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d).
|
||||||
|
|
||||||
Routes `exec_claude` / `exec` / `cp_in` through `smolvm machine
|
Routes `exec_agent` / `exec` / `cp_in` through `smolvm machine
|
||||||
exec` / `smolvm machine cp`. The handle is yielded by `launch`
|
exec` / `smolvm machine cp`. The handle is yielded by `launch`
|
||||||
and torn down via the surrounding ExitStack on context exit;
|
and torn down via the surrounding ExitStack on context exit;
|
||||||
`close` is a no-op idempotent alias so the BottleBackend ABC's
|
`close` is a no-op idempotent alias so the BottleBackend ABC's
|
||||||
context-manager contract is satisfied.
|
context-manager contract is satisfied.
|
||||||
|
|
||||||
User context: `smolvm machine exec` runs commands as root in the
|
User context: `smolvm machine exec` runs commands as root in the
|
||||||
VM, but the agent image's USER is `node` and claude-code refuses
|
VM, but the agent image's USER is `node` and agent CLIs may refuse
|
||||||
to run as root with `--dangerously-skip-permissions`. Both
|
to run as root in bypass modes. Both
|
||||||
`exec_claude` and `exec` switch to the requested user (default
|
`exec_agent` and `exec` switch to the requested user (default
|
||||||
`node`) via `runuser -u <user> --` and set `HOME` / `USER`
|
`node`) via `runuser -u <user> --` and set `HOME` / `USER`
|
||||||
through `smolvm -e` — avoiding `runuser -l`'s login-shell wiring
|
through `smolvm -e` — avoiding `runuser -l`'s login-shell wiring
|
||||||
(PAM session setup, /etc/profile sourcing) which can hang on a
|
(PAM session setup, /etc/profile sourcing) which can hang on a
|
||||||
@@ -21,6 +21,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
|
from ...agent_provider import PromptMode, prompt_args
|
||||||
from .. import Bottle, ExecResult
|
from .. import Bottle, ExecResult
|
||||||
from . import pty_resize as _pty_resize
|
from . import pty_resize as _pty_resize
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
@@ -29,12 +30,12 @@ from . import smolvm as _smolvm
|
|||||||
# Absolute path to the pty_resize wrapper. Invoke as
|
# Absolute path to the pty_resize wrapper. Invoke as
|
||||||
# `python <path>` rather than `python -m <dotted-path>` so the
|
# `python <path>` rather than `python -m <dotted-path>` so the
|
||||||
# wrapper runs regardless of cwd / sys.path — it has no
|
# wrapper runs regardless of cwd / sys.path — it has no
|
||||||
# claude_bottle.* imports, so it's self-contained.
|
# bot_bottle.* imports, so it's self-contained.
|
||||||
_PTY_RESIZE_SCRIPT = _pty_resize.__file__
|
_PTY_RESIZE_SCRIPT = _pty_resize.__file__
|
||||||
|
|
||||||
|
|
||||||
# Per-user env the agent image's USER (node) expects. claude
|
# Per-user env the agent image's USER (node) expects. Some providers
|
||||||
# reads ~/.claude.json + writes session state under ~/.claude/;
|
# write session state under the user's home directory;
|
||||||
# bare `runuser -u` inherits root's HOME=/root, which claude
|
# bare `runuser -u` inherits root's HOME=/root, which claude
|
||||||
# can't write to. Set HOME / USER explicitly through smolvm -e
|
# can't write to. Set HOME / USER explicitly through smolvm -e
|
||||||
# so the child process sees them.
|
# so the child process sees them.
|
||||||
@@ -72,6 +73,8 @@ class SmolmachinesBottle(Bottle):
|
|||||||
*,
|
*,
|
||||||
prompt_path: str | None = None,
|
prompt_path: str | None = None,
|
||||||
guest_env: Mapping[str, str] | None = None,
|
guest_env: Mapping[str, str] | None = None,
|
||||||
|
agent_command: str = "claude",
|
||||||
|
agent_prompt_mode: PromptMode = "append_file",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.name = machine_name
|
self.name = machine_name
|
||||||
# In-VM path to the agent's prompt file. None when the
|
# In-VM path to the agent's prompt file. None when the
|
||||||
@@ -83,8 +86,13 @@ class SmolmachinesBottle(Bottle):
|
|||||||
# Forwarded on every `smolvm machine exec` via `-e K=V`
|
# Forwarded on every `smolvm machine exec` via `-e K=V`
|
||||||
# because exec doesn't inherit from machine_create's env.
|
# because exec doesn't inherit from machine_create's env.
|
||||||
self._guest_env = dict(guest_env or {})
|
self._guest_env = dict(guest_env or {})
|
||||||
|
self._agent_prompt_mode = agent_prompt_mode
|
||||||
|
self.agent_command = agent_command
|
||||||
|
self.agent_provider_template = (
|
||||||
|
"codex" if agent_command == "codex" else "claude"
|
||||||
|
)
|
||||||
|
|
||||||
def claude_argv(
|
def agent_argv(
|
||||||
self, argv: list[str], *, tty: bool = True,
|
self, argv: list[str], *, tty: bool = True,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
flags = ["smolvm", "machine", "exec", "--name", self.name]
|
flags = ["smolvm", "machine", "exec", "--name", self.name]
|
||||||
@@ -92,11 +100,17 @@ class SmolmachinesBottle(Bottle):
|
|||||||
flags += ["-i", "-t"]
|
flags += ["-i", "-t"]
|
||||||
flags += _env_flags_for("node")
|
flags += _env_flags_for("node")
|
||||||
flags += _guest_env_flags(self._guest_env)
|
flags += _guest_env_flags(self._guest_env)
|
||||||
claude_tail = ["claude"]
|
agent_tail = [self.agent_command]
|
||||||
if self._prompt_path:
|
provider_prompt_args = prompt_args(
|
||||||
claude_tail += ["--append-system-prompt-file", self._prompt_path]
|
self._agent_prompt_mode, self._prompt_path, argv=argv,
|
||||||
claude_tail += argv
|
)
|
||||||
flags += ["--", "runuser", "-u", "node", "--", *claude_tail]
|
if self._agent_prompt_mode == "read_prompt_file":
|
||||||
|
agent_tail += argv
|
||||||
|
agent_tail += provider_prompt_args
|
||||||
|
else:
|
||||||
|
agent_tail += provider_prompt_args
|
||||||
|
agent_tail += argv
|
||||||
|
flags += ["--", "runuser", "-u", "node", "--", *agent_tail]
|
||||||
if not tty:
|
if not tty:
|
||||||
# No PTY allocated — no SIGWINCH to forward, no resize
|
# No PTY allocated — no SIGWINCH to forward, no resize
|
||||||
# bridge needed. Skip the wrapper so non-interactive
|
# bridge needed. Skip the wrapper so non-interactive
|
||||||
@@ -108,10 +122,10 @@ class SmolmachinesBottle(Bottle):
|
|||||||
self.name, "--", *flags,
|
self.name, "--", *flags,
|
||||||
]
|
]
|
||||||
|
|
||||||
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
|
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||||
"""Run `claude` interactively inside the VM as the `node`
|
"""Run the selected agent interactively inside the VM as the `node`
|
||||||
user. Inherits the operator's terminal (stdin / stdout /
|
user. Inherits the operator's terminal (stdin / stdout /
|
||||||
stderr) so the session feels native. Blocks until claude
|
stderr) so the session feels native. Blocks until the agent
|
||||||
exits; returns the in-VM exit code.
|
exits; returns the in-VM exit code.
|
||||||
|
|
||||||
We bypass the captured-output `machine_exec` helper here
|
We bypass the captured-output `machine_exec` helper here
|
||||||
@@ -123,7 +137,7 @@ class SmolmachinesBottle(Bottle):
|
|||||||
avoid login-shell wiring. HOME / USER come from `smolvm
|
avoid login-shell wiring. HOME / USER come from `smolvm
|
||||||
-e` instead, which sets them on the process env."""
|
-e` instead, which sets them on the process env."""
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
self.claude_argv(argv, tty=tty), check=False,
|
self.agent_argv(argv, tty=tty), check=False,
|
||||||
).returncode
|
).returncode
|
||||||
|
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||||
+4
-4
@@ -4,17 +4,17 @@ Tracks the resources `SmolmachinesBottleBackend.cleanup` will
|
|||||||
remove:
|
remove:
|
||||||
|
|
||||||
- machines: smolvm machines whose name starts with
|
- machines: smolvm machines whose name starts with
|
||||||
`claude-bottle-` (running or stopped). Stopped +
|
`bot-bottle-` (running or stopped). Stopped +
|
||||||
deleted via `smolvm machine stop` + `machine delete -f`.
|
deleted via `smolvm machine stop` + `machine delete -f`.
|
||||||
- bundles: docker containers `claude-bottle-sidecars-<slug>`
|
- bundles: docker containers `bot-bottle-sidecars-<slug>`
|
||||||
left over from a smolmachines bottle (the bundle's
|
left over from a smolmachines bottle (the bundle's
|
||||||
port-forwards stay published on lo0 aliases until
|
port-forwards stay published on lo0 aliases until
|
||||||
the container is gone). Removed via `docker rm -f`.
|
the container is gone). Removed via `docker rm -f`.
|
||||||
- networks: docker networks `claude-bottle-bundle-<slug>`
|
- networks: docker networks `bot-bottle-bundle-<slug>`
|
||||||
attached to the bundles. Removed via
|
attached to the bundles. Removed via
|
||||||
`docker network rm`.
|
`docker network rm`.
|
||||||
|
|
||||||
Smolmachines state dirs live under the same `~/.claude-bottle/state/`
|
Smolmachines state dirs live under the same `~/.bot-bottle/state/`
|
||||||
path the docker backend uses; the docker backend's
|
path the docker backend uses; the docker backend's
|
||||||
`prepare_cleanup` already enumerates orphan state dirs and is the
|
`prepare_cleanup` already enumerates orphan state dirs and is the
|
||||||
single source of truth for that bucket (consults
|
single source of truth for that bucket (consults
|
||||||
+12
-3
@@ -12,13 +12,14 @@ import sys
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...agent_provider import PromptMode
|
||||||
from ...egress import EgressPlan
|
from ...egress import EgressPlan
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...log import info
|
from ...log import info
|
||||||
from ...pipelock import PipelockProxyPlan
|
from ...pipelock import PipelockProxyPlan
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
from .. import BottlePlan
|
from .. import BottlePlan
|
||||||
from ..print_util import print_multi
|
from ..print_util import print_multi, visible_agent_env_names
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -42,7 +43,7 @@ class SmolmachinesBottlePlan(BottlePlan):
|
|||||||
# agent's network attempt got refused by macOS.
|
# agent's network attempt got refused by macOS.
|
||||||
#
|
#
|
||||||
# Chunk 2d ships with a public placeholder image (alpine)
|
# Chunk 2d ships with a public placeholder image (alpine)
|
||||||
# since claude-bottle:latest lives in the operator's local
|
# since bot-bottle-claude:latest lives in the operator's local
|
||||||
# docker daemon and smolvm's crane backend can't read from
|
# docker daemon and smolvm's crane backend can't read from
|
||||||
# there; chunk 4 resolves the agent-image-conversion gap
|
# there; chunk 4 resolves the agent-image-conversion gap
|
||||||
# (push to a registry first, or smolvm grows a docker-daemon
|
# (push to a registry first, or smolvm grows a docker-daemon
|
||||||
@@ -92,6 +93,10 @@ class SmolmachinesBottlePlan(BottlePlan):
|
|||||||
agent_proxy_url: str = ""
|
agent_proxy_url: str = ""
|
||||||
agent_git_gate_host: str = ""
|
agent_git_gate_host: str = ""
|
||||||
agent_supervise_url: str = ""
|
agent_supervise_url: str = ""
|
||||||
|
agent_command: str = "claude"
|
||||||
|
agent_prompt_mode: PromptMode = "append_file"
|
||||||
|
agent_provider_template: str = "claude"
|
||||||
|
agent_dockerfile_path: str = ""
|
||||||
|
|
||||||
def print(self, *, remote_control: bool) -> None:
|
def print(self, *, remote_control: bool) -> None:
|
||||||
"""Compact y/N preflight. Same shape as the Docker
|
"""Compact y/N preflight. Same shape as the Docker
|
||||||
@@ -102,7 +107,10 @@ class SmolmachinesBottlePlan(BottlePlan):
|
|||||||
agent = manifest.agents[spec.agent_name]
|
agent = manifest.agents[spec.agent_name]
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
|
|
||||||
env_names = sorted(bottle.env.keys())
|
env_names = visible_agent_env_names(
|
||||||
|
sorted(bottle.env.keys()),
|
||||||
|
agent_provider_template=self.agent_provider_template,
|
||||||
|
)
|
||||||
upstreams = [
|
upstreams = [
|
||||||
f"{g.Name} → {g.Upstream}" for g in bottle.git
|
f"{g.Name} → {g.Upstream}" for g in bottle.git
|
||||||
]
|
]
|
||||||
@@ -113,6 +121,7 @@ class SmolmachinesBottlePlan(BottlePlan):
|
|||||||
|
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
info(f"agent : {spec.agent_name}")
|
info(f"agent : {spec.agent_name}")
|
||||||
|
info(f"provider : {self.agent_provider_template}")
|
||||||
print_multi("env ", env_names)
|
print_multi("env ", env_names)
|
||||||
print_multi("skills ", list(agent.skills))
|
print_multi("skills ", list(agent.skills))
|
||||||
info(f"bottle : {agent.bottle}")
|
info(f"bottle : {agent.bottle}")
|
||||||
+12
-12
@@ -3,11 +3,11 @@
|
|||||||
`prepare_cleanup` enumerates leftover smolmachines resources:
|
`prepare_cleanup` enumerates leftover smolmachines resources:
|
||||||
|
|
||||||
- smolvm machines (`smolvm machine ls --json`) whose name starts
|
- smolvm machines (`smolvm machine ls --json`) whose name starts
|
||||||
with `claude-bottle-`.
|
with `bot-bottle-`.
|
||||||
- bundle docker containers (`claude-bottle-sidecars-<slug>`).
|
- bundle docker containers (`bot-bottle-sidecars-<slug>`).
|
||||||
- bundle docker networks (`claude-bottle-bundle-<slug>`).
|
- bundle docker networks (`bot-bottle-bundle-<slug>`).
|
||||||
|
|
||||||
State dirs live under `~/.claude-bottle/state/<identity>/` —
|
State dirs live under `~/.bot-bottle/state/<identity>/` —
|
||||||
shared layout with the docker backend, which has the single
|
shared layout with the docker backend, which has the single
|
||||||
orphan-state-dir enumerator (it already consults
|
orphan-state-dir enumerator (it already consults
|
||||||
`enumerate_active_agents()` so a live smolmachines bottle's dir
|
`enumerate_active_agents()` so a live smolmachines bottle's dir
|
||||||
@@ -29,9 +29,9 @@ from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
|
|||||||
|
|
||||||
|
|
||||||
# Both names start with the same prefix the launcher uses.
|
# Both names start with the same prefix the launcher uses.
|
||||||
_VM_PREFIX = "claude-bottle-"
|
_VM_PREFIX = "bot-bottle-"
|
||||||
_BUNDLE_PREFIX = _bundle.bundle_container_name("") # `claude-bottle-sidecars-`
|
_BUNDLE_PREFIX = _bundle.bundle_container_name("") # `bot-bottle-sidecars-`
|
||||||
_NETWORK_PREFIX = _bundle.bundle_network_name("") # `claude-bottle-bundle-`
|
_NETWORK_PREFIX = _bundle.bundle_network_name("") # `bot-bottle-bundle-`
|
||||||
|
|
||||||
|
|
||||||
def prepare_cleanup() -> SmolmachinesBottleCleanupPlan:
|
def prepare_cleanup() -> SmolmachinesBottleCleanupPlan:
|
||||||
@@ -39,7 +39,7 @@ def prepare_cleanup() -> SmolmachinesBottleCleanupPlan:
|
|||||||
No side effects. Returns an empty plan when smolvm isn't on
|
No side effects. Returns an empty plan when smolvm isn't on
|
||||||
PATH (no machines to reap) — `cleanup` is a no-op in that
|
PATH (no machines to reap) — `cleanup` is a no-op in that
|
||||||
case too."""
|
case too."""
|
||||||
machines = _list_claude_bottle_machines()
|
machines = _list_bot_bottle_machines()
|
||||||
bundles = _list_bundle_containers()
|
bundles = _list_bundle_containers()
|
||||||
networks = _list_bundle_networks()
|
networks = _list_bundle_networks()
|
||||||
return SmolmachinesBottleCleanupPlan(
|
return SmolmachinesBottleCleanupPlan(
|
||||||
@@ -94,8 +94,8 @@ def cleanup(plan: SmolmachinesBottleCleanupPlan) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _list_claude_bottle_machines() -> list[str]:
|
def _list_bot_bottle_machines() -> list[str]:
|
||||||
"""All smolvm machines named `claude-bottle-*`, regardless of
|
"""All smolvm machines named `bot-bottle-*`, regardless of
|
||||||
state (running / stopped / created). Empty when smolvm isn't
|
state (running / stopped / created). Empty when smolvm isn't
|
||||||
installed."""
|
installed."""
|
||||||
if not _smolvm.is_available():
|
if not _smolvm.is_available():
|
||||||
@@ -118,7 +118,7 @@ def _list_claude_bottle_machines() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _list_bundle_containers() -> list[str]:
|
def _list_bundle_containers() -> list[str]:
|
||||||
"""All docker containers named `claude-bottle-sidecars-*`,
|
"""All docker containers named `bot-bottle-sidecars-*`,
|
||||||
running or stopped. Empty when docker isn't installed."""
|
running or stopped. Empty when docker isn't installed."""
|
||||||
# Late import: `backend/__init__` imports this module
|
# Late import: `backend/__init__` imports this module
|
||||||
# transitively via the smolmachines backend.
|
# transitively via the smolmachines backend.
|
||||||
@@ -140,7 +140,7 @@ def _list_bundle_containers() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _list_bundle_networks() -> list[str]:
|
def _list_bundle_networks() -> list[str]:
|
||||||
"""All docker networks named `claude-bottle-bundle-*`. Empty
|
"""All docker networks named `bot-bottle-bundle-*`. Empty
|
||||||
when docker isn't installed."""
|
when docker isn't installed."""
|
||||||
from .. import has_backend
|
from .. import has_backend
|
||||||
if not has_backend("docker"):
|
if not has_backend("docker"):
|
||||||
+4
-4
@@ -27,10 +27,10 @@ from ..docker.bottle_state import read_metadata
|
|||||||
from . import sidecar_bundle as _bundle
|
from . import sidecar_bundle as _bundle
|
||||||
|
|
||||||
|
|
||||||
# Smolvm VM names produced by prepare are `claude-bottle-<slug>`,
|
# Smolvm VM names produced by prepare are `bot-bottle-<slug>`,
|
||||||
# matching the bundle container name pattern. We use the prefix
|
# matching the bundle container name pattern. We use the prefix
|
||||||
# both as a filter and to strip back to the slug.
|
# both as a filter and to strip back to the slug.
|
||||||
_VM_NAME_PREFIX = "claude-bottle-"
|
_VM_NAME_PREFIX = "bot-bottle-"
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
def enumerate_active() -> list[ActiveAgent]:
|
||||||
@@ -70,7 +70,7 @@ def enumerate_active() -> list[ActiveAgent]:
|
|||||||
|
|
||||||
def _query_bundle_services() -> dict[str, tuple[str, ...]]:
|
def _query_bundle_services() -> dict[str, tuple[str, ...]]:
|
||||||
"""`{slug: ('egress', 'pipelock', ...)}` from each running
|
"""`{slug: ('egress', 'pipelock', ...)}` from each running
|
||||||
bundle container's `CLAUDE_BOTTLE_SIDECAR_DAEMONS` env var.
|
bundle container's `BOT_BOTTLE_SIDECAR_DAEMONS` env var.
|
||||||
Smolmachines bundles all run the PRD-0024 image with the
|
Smolmachines bundles all run the PRD-0024 image with the
|
||||||
same daemon set declared via env, so one inspect per bundle
|
same daemon set declared via env, so one inspect per bundle
|
||||||
gets us the picture without exec'ing into the container.
|
gets us the picture without exec'ing into the container.
|
||||||
@@ -113,7 +113,7 @@ def _query_bundle_services() -> dict[str, tuple[str, ...]]:
|
|||||||
continue
|
continue
|
||||||
for entry in env_list:
|
for entry in env_list:
|
||||||
key, _, value = entry.partition("=")
|
key, _, value = entry.partition("=")
|
||||||
if key == "CLAUDE_BOTTLE_SIDECAR_DAEMONS":
|
if key == "BOT_BOTTLE_SIDECAR_DAEMONS":
|
||||||
out[slug] = tuple(sorted(
|
out[slug] = tuple(sorted(
|
||||||
d for d in value.split(",") if d
|
d for d in value.split(",") if d
|
||||||
))
|
))
|
||||||
+20
-8
@@ -68,7 +68,7 @@ _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|||||||
# docker image ID so a Dockerfile change automatically invalidates
|
# docker image ID so a Dockerfile change automatically invalidates
|
||||||
# the cache. `pack create` is idempotent on the smolvm side but
|
# the cache. `pack create` is idempotent on the smolvm side but
|
||||||
# takes several seconds even on a no-op rebuild.
|
# takes several seconds even on a no-op rebuild.
|
||||||
_SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "claude-bottle" / "smolmachines"
|
_SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "bot-bottle" / "smolmachines"
|
||||||
|
|
||||||
|
|
||||||
# Container-internal listening ports for each bundle daemon. The
|
# Container-internal listening ports for each bundle daemon. The
|
||||||
@@ -142,6 +142,7 @@ def launch(
|
|||||||
# daemons the agent needs to reach from the smolvm guest.
|
# daemons the agent needs to reach from the smolvm guest.
|
||||||
bundle_spec = _bundle_launch_spec(plan, network, loopback_ip)
|
bundle_spec = _bundle_launch_spec(plan, network, loopback_ip)
|
||||||
token_env = _resolve_token_env(plan, os.environ)
|
token_env = _resolve_token_env(plan, os.environ)
|
||||||
|
_bundle.ensure_bundle_image(bundle_spec.image)
|
||||||
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
|
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
|
||||||
stack.callback(_bundle.stop_bundle, plan.slug)
|
stack.callback(_bundle.stop_bundle, plan.slug)
|
||||||
|
|
||||||
@@ -183,7 +184,7 @@ def launch(
|
|||||||
|
|
||||||
# Stamp the URLs onto the plan + guest_env. provision_git
|
# Stamp the URLs onto the plan + guest_env. provision_git
|
||||||
# and provision_supervise read the plan fields; the agent
|
# and provision_supervise read the plan fields; the agent
|
||||||
# reads guest_env on every exec_claude.
|
# reads guest_env on every exec_agent.
|
||||||
#
|
#
|
||||||
# NO_PROXY has to include the per-bottle loopback alias —
|
# NO_PROXY has to include the per-bottle loopback alias —
|
||||||
# otherwise claude's HTTPS_PROXY catches direct calls to
|
# otherwise claude's HTTPS_PROXY catches direct calls to
|
||||||
@@ -219,7 +220,10 @@ def launch(
|
|||||||
# output doesn't garble the dashboard's preflight modal:
|
# output doesn't garble the dashboard's preflight modal:
|
||||||
# both the curses-endwin path and the tmux pane-routing
|
# both the curses-endwin path and the tmux pane-routing
|
||||||
# path redirect stderr around `launch` already.
|
# path redirect stderr around `launch` already.
|
||||||
agent_from_path = _ensure_smolmachine(plan.agent_image_ref)
|
agent_from_path = _ensure_smolmachine(
|
||||||
|
plan.agent_image_ref,
|
||||||
|
dockerfile=plan.agent_dockerfile_path,
|
||||||
|
)
|
||||||
|
|
||||||
# smolvm VM. --from carries the pre-packed .smolmachine
|
# smolvm VM. --from carries the pre-packed .smolmachine
|
||||||
# artifact; --allow-cidr + -e carry the per-bottle TSI
|
# artifact; --allow-cidr + -e carry the per-bottle TSI
|
||||||
@@ -286,6 +290,8 @@ def launch(
|
|||||||
plan.machine_name,
|
plan.machine_name,
|
||||||
prompt_path=prompt_path,
|
prompt_path=prompt_path,
|
||||||
guest_env=plan.guest_env,
|
guest_env=plan.guest_env,
|
||||||
|
agent_command=plan.agent_command,
|
||||||
|
agent_prompt_mode=plan.agent_prompt_mode,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
stack.close()
|
stack.close()
|
||||||
@@ -360,6 +366,12 @@ def _bundle_launch_spec(
|
|||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
||||||
True,
|
True,
|
||||||
))
|
))
|
||||||
|
if u.known_hosts_file:
|
||||||
|
volumes.append((
|
||||||
|
str(u.known_hosts_file),
|
||||||
|
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
|
||||||
|
True,
|
||||||
|
))
|
||||||
|
|
||||||
# --- supervise --------------------------------------------
|
# --- supervise --------------------------------------------
|
||||||
sp = plan.supervise_plan
|
sp = plan.supervise_plan
|
||||||
@@ -413,10 +425,10 @@ def _resolve_token_env(
|
|||||||
return egress_resolve_token_values(ep.token_env_map, dict(host_env))
|
return egress_resolve_token_values(ep.token_env_map, dict(host_env))
|
||||||
|
|
||||||
|
|
||||||
def _ensure_smolmachine(image_ref: str) -> Path:
|
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||||
"""Build the agent docker image and convert it into a
|
"""Build the agent docker image and convert it into a
|
||||||
`.smolmachine` artifact, caching the result under
|
`.smolmachine` artifact, caching the result under
|
||||||
`~/.cache/claude-bottle/smolmachines/` keyed by the docker image
|
`~/.cache/bot-bottle/smolmachines/` keyed by the docker image
|
||||||
ID (so a Dockerfile change automatically invalidates the cache).
|
ID (so a Dockerfile change automatically invalidates the cache).
|
||||||
|
|
||||||
Returns the `.smolmachine.smolmachine` sidecar path — that's
|
Returns the `.smolmachine.smolmachine` sidecar path — that's
|
||||||
@@ -438,7 +450,7 @@ def _ensure_smolmachine(image_ref: str) -> Path:
|
|||||||
so we skip the whole pipeline when the cached sidecar is
|
so we skip the whole pipeline when the cached sidecar is
|
||||||
already on disk for this image ID."""
|
already on disk for this image ID."""
|
||||||
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
docker_mod.build_image(image_ref, _REPO_DIR)
|
docker_mod.build_image(image_ref, _REPO_DIR, dockerfile=dockerfile)
|
||||||
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
|
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
|
||||||
# keep filenames manageable, long enough to make collisions
|
# keep filenames manageable, long enough to make collisions
|
||||||
# astronomically unlikely.
|
# astronomically unlikely.
|
||||||
@@ -451,8 +463,8 @@ def _ensure_smolmachine(image_ref: str) -> Path:
|
|||||||
docker_mod.save(image_ref, str(tarball))
|
docker_mod.save(image_ref, str(tarball))
|
||||||
try:
|
try:
|
||||||
with ephemeral_registry() as handle:
|
with ephemeral_registry() as handle:
|
||||||
push_ref = f"{handle.push_endpoint}/claude-bottle:{digest}"
|
push_ref = f"{handle.push_endpoint}/bot-bottle:{digest}"
|
||||||
pack_ref = f"{handle.pull_endpoint}/claude-bottle:{digest}"
|
pack_ref = f"{handle.pull_endpoint}/bot-bottle:{digest}"
|
||||||
crane_push_tarball(handle, str(tarball), push_ref)
|
crane_push_tarball(handle, str(tarball), push_ref)
|
||||||
_smolvm.pack_create(pack_ref, binary)
|
_smolvm.pack_create(pack_ref, binary)
|
||||||
finally:
|
finally:
|
||||||
+5
-5
@@ -48,9 +48,9 @@ from ...log import die
|
|||||||
|
|
||||||
|
|
||||||
# registry:2.8.3, pinned by digest. Same env-override pattern as the
|
# registry:2.8.3, pinned by digest. Same env-override pattern as the
|
||||||
# pipelock image pin in claude_bottle/backend/docker/pipelock.py.
|
# pipelock image pin in bot_bottle/backend/docker/pipelock.py.
|
||||||
REGISTRY_IMAGE = os.environ.get(
|
REGISTRY_IMAGE = os.environ.get(
|
||||||
"CLAUDE_BOTTLE_REGISTRY_IMAGE",
|
"BOT_BOTTLE_REGISTRY_IMAGE",
|
||||||
"registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
|
"registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ REGISTRY_IMAGE = os.environ.get(
|
|||||||
# against a localhost-equivalent registry, so the trust surface is
|
# against a localhost-equivalent registry, so the trust surface is
|
||||||
# narrow.
|
# narrow.
|
||||||
CRANE_IMAGE = os.environ.get(
|
CRANE_IMAGE = os.environ.get(
|
||||||
"CLAUDE_BOTTLE_CRANE_IMAGE",
|
"BOT_BOTTLE_CRANE_IMAGE",
|
||||||
"gcr.io/go-containerregistry/crane@sha256:0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084",
|
"gcr.io/go-containerregistry/crane@sha256:0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,8 +104,8 @@ def ephemeral_registry() -> Iterator[RegistryHandle]:
|
|||||||
on its own; the `finally` block force-removes on abnormal exit
|
on its own; the `finally` block force-removes on abnormal exit
|
||||||
(the calling process crashes between yield and close)."""
|
(the calling process crashes between yield and close)."""
|
||||||
session_id = uuid.uuid4().hex[:12]
|
session_id = uuid.uuid4().hex[:12]
|
||||||
network = f"claude-bottle-registry-net-{session_id}"
|
network = f"bot-bottle-registry-net-{session_id}"
|
||||||
registry_name = f"claude-bottle-registry-{session_id}"
|
registry_name = f"bot-bottle-registry-{session_id}"
|
||||||
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "network", "create", network],
|
["docker", "network", "create", network],
|
||||||
+2
-2
@@ -110,7 +110,7 @@ def ensure_pool() -> None:
|
|||||||
)
|
)
|
||||||
for ip in missing:
|
for ip in missing:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["sudo", "-p", "claude-bottle (loopback alias): ",
|
["sudo", "-p", "bot-bottle (loopback alias): ",
|
||||||
"ifconfig", "lo0", "alias", f"{ip}/32", "up"],
|
"ifconfig", "lo0", "alias", f"{ip}/32", "up"],
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
@@ -215,7 +215,7 @@ def _aliases_in_use() -> set[str]:
|
|||||||
`HostIp` out of its port bindings."""
|
`HostIp` out of its port bindings."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "--format", "{{.Names}}",
|
["docker", "ps", "--format", "{{.Names}}",
|
||||||
"--filter", "name=claude-bottle-sidecars-"],
|
"--filter", "name=bot-bottle-sidecars-"],
|
||||||
capture_output=True, text=True, check=False,
|
capture_output=True, text=True, check=False,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
+33
-8
@@ -14,6 +14,7 @@ import os
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...agent_provider import runtime_for
|
||||||
from ...backend import BottleSpec
|
from ...backend import BottleSpec
|
||||||
from ...backend.docker.bottle_state import (
|
from ...backend.docker.bottle_state import (
|
||||||
BottleMetadata,
|
BottleMetadata,
|
||||||
@@ -55,6 +56,8 @@ def resolve_plan(
|
|||||||
|
|
||||||
manifest = spec.manifest
|
manifest = spec.manifest
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
|
provider = bottle.agent_provider
|
||||||
|
provider_runtime = runtime_for(provider.template)
|
||||||
|
|
||||||
slug = spec.identity or bottle_identity(spec.agent_name)
|
slug = spec.identity or bottle_identity(spec.agent_name)
|
||||||
|
|
||||||
@@ -116,9 +119,13 @@ def resolve_plan(
|
|||||||
# outbound leg using a token held in egress's own environ — so
|
# outbound leg using a token held in egress's own environ — so
|
||||||
# the agent gets a non-secret placeholder here (matches the
|
# the agent gets a non-secret placeholder here (matches the
|
||||||
# docker backend's forwarded_env logic in
|
# docker backend's forwarded_env logic in
|
||||||
# claude_bottle/backend/docker/prepare.py).
|
# bot_bottle/backend/docker/prepare.py).
|
||||||
if any("claude_code_oauth" in r.roles for r in egress_plan.routes):
|
has_provider_auth = any(
|
||||||
guest_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
provider_runtime.auth_role in r.roles for r in egress_plan.routes
|
||||||
|
)
|
||||||
|
if has_provider_auth:
|
||||||
|
guest_env[provider_runtime.placeholder_env] = "egress-placeholder"
|
||||||
|
if provider.template == "claude" and has_provider_auth:
|
||||||
guest_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
guest_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
||||||
guest_env.setdefault("DISABLE_ERROR_REPORTING", "1")
|
guest_env.setdefault("DISABLE_ERROR_REPORTING", "1")
|
||||||
|
|
||||||
@@ -141,13 +148,20 @@ def resolve_plan(
|
|||||||
prompt_file.write_text(agent.prompt or "")
|
prompt_file.write_text(agent.prompt or "")
|
||||||
prompt_file.chmod(0o600)
|
prompt_file.chmod(0o600)
|
||||||
|
|
||||||
machine_name = f"claude-bottle-{slug}"
|
machine_name = f"bot-bottle-{slug}"
|
||||||
# Stash the agent image ref — `launch.launch` runs the
|
# Stash the agent image ref — `launch.launch` runs the
|
||||||
# build → pack pipeline at bringup. Honors CLAUDE_BOTTLE_IMAGE
|
# build → pack pipeline at bringup. Honors BOT_BOTTLE_IMAGE
|
||||||
# to match the docker backend's `resolve_plan` default.
|
# to match the docker backend's `resolve_plan` default.
|
||||||
agent_image_ref = os.environ.get(
|
agent_dockerfile_path = ""
|
||||||
"CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest"
|
if provider.dockerfile:
|
||||||
)
|
agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
|
||||||
|
image_default = f"bot-bottle-{provider.template}:{slug}"
|
||||||
|
elif provider_runtime.dockerfile:
|
||||||
|
agent_dockerfile_path = provider_runtime.dockerfile
|
||||||
|
image_default = provider_runtime.image
|
||||||
|
else:
|
||||||
|
image_default = provider_runtime.image
|
||||||
|
agent_image_ref = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
|
||||||
|
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
@@ -164,4 +178,15 @@ def resolve_plan(
|
|||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=git_gate_plan,
|
||||||
egress_plan=egress_plan,
|
egress_plan=egress_plan,
|
||||||
supervise_plan=supervise_plan,
|
supervise_plan=supervise_plan,
|
||||||
|
agent_command=provider_runtime.command,
|
||||||
|
agent_prompt_mode=provider_runtime.prompt_mode,
|
||||||
|
agent_provider_template=provider.template,
|
||||||
|
agent_dockerfile_path=agent_dockerfile_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
|
||||||
|
path = Path(os.path.expanduser(path_value))
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = Path(spec.user_cwd) / path
|
||||||
|
return str(path)
|
||||||
+3
-3
@@ -36,14 +36,14 @@ from ..bottle_plan import SmolmachinesBottlePlan
|
|||||||
|
|
||||||
|
|
||||||
# `node` is the agent user from the repo Dockerfile. Override via
|
# `node` is the agent user from the repo Dockerfile. Override via
|
||||||
# CLAUDE_BOTTLE_GUEST_HOME mirrors the docker backend's
|
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
|
||||||
# CLAUDE_BOTTLE_CONTAINER_HOME knob — same purpose, different
|
# BOT_BOTTLE_CONTAINER_HOME knob — same purpose, different
|
||||||
# transport.
|
# transport.
|
||||||
_DEFAULT_GUEST_HOME = "/home/node"
|
_DEFAULT_GUEST_HOME = "/home/node"
|
||||||
|
|
||||||
|
|
||||||
def _guest_home() -> str:
|
def _guest_home() -> str:
|
||||||
return os.environ.get("CLAUDE_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
return os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
||||||
|
|
||||||
|
|
||||||
def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||||
+4
-4
@@ -18,8 +18,8 @@ from ..bottle_plan import SmolmachinesBottlePlan
|
|||||||
|
|
||||||
|
|
||||||
# `node` is the agent user from the repo Dockerfile.
|
# `node` is the agent user from the repo Dockerfile.
|
||||||
# CLAUDE_BOTTLE_GUEST_HOME mirrors the docker backend's
|
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
|
||||||
# CLAUDE_BOTTLE_CONTAINER_HOME knob.
|
# BOT_BOTTLE_CONTAINER_HOME knob.
|
||||||
_DEFAULT_GUEST_HOME = "/home/node"
|
_DEFAULT_GUEST_HOME = "/home/node"
|
||||||
|
|
||||||
|
|
||||||
@@ -29,8 +29,8 @@ def provision_prompt(plan: SmolmachinesBottlePlan, target: str) -> str | None:
|
|||||||
non-empty prompt (drives --append-system-prompt-file), else
|
non-empty prompt (drives --append-system-prompt-file), else
|
||||||
None. The file is copied either way so the path always
|
None. The file is copied either way so the path always
|
||||||
exists — mirrors the docker backend's behavior."""
|
exists — mirrors the docker backend's behavior."""
|
||||||
guest_home = os.environ.get("CLAUDE_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
||||||
in_guest_prompt_path = f"{guest_home}/.claude-bottle-prompt.txt"
|
in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt"
|
||||||
|
|
||||||
_smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}")
|
_smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}")
|
||||||
# machine cp lands as root, source's 0o600 mode is preserved —
|
# machine cp lands as root, source's 0o600 mode is preserved —
|
||||||
+2
-2
@@ -19,7 +19,7 @@ from ..bottle_plan import SmolmachinesBottlePlan
|
|||||||
|
|
||||||
# In-guest path mirrors the docker backend's claude-skills
|
# In-guest path mirrors the docker backend's claude-skills
|
||||||
# convention (~/.claude/skills/<name>/) under the node user's
|
# convention (~/.claude/skills/<name>/) under the node user's
|
||||||
# home — same path as the real claude-bottle image's
|
# home — same path as the real bot-bottle image's
|
||||||
# /home/node/.claude/skills (pre-created in the Dockerfile).
|
# /home/node/.claude/skills (pre-created in the Dockerfile).
|
||||||
_DEFAULT_SKILLS_DIR = "/home/node/.claude/skills"
|
_DEFAULT_SKILLS_DIR = "/home/node/.claude/skills"
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
skills_dir = os.environ.get(
|
skills_dir = os.environ.get(
|
||||||
"CLAUDE_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
|
"BOT_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
|
||||||
)
|
)
|
||||||
|
|
||||||
_smolvm.machine_exec(target, ["mkdir", "-p", skills_dir])
|
_smolvm.machine_exec(target, ["mkdir", "-p", skills_dir])
|
||||||
+2
-2
@@ -24,7 +24,7 @@ process that:
|
|||||||
extra signalling.
|
extra signalling.
|
||||||
3. Waits on the child and exits with its returncode.
|
3. Waits on the child and exits with its returncode.
|
||||||
|
|
||||||
The dashboard's tmux pane respawn calls `bottle.claude_argv`
|
The dashboard's tmux pane respawn calls `bottle.agent_argv`
|
||||||
which now prepends `[sys.executable, -m, ..., <machine>, --, ...]`
|
which now prepends `[sys.executable, -m, ..., <machine>, --, ...]`
|
||||||
to the smolvm argv. Foreground handoff (curses endwin →
|
to the smolvm argv. Foreground handoff (curses endwin →
|
||||||
subprocess.run) goes through the same path so behavior is
|
subprocess.run) goes through the same path so behavior is
|
||||||
@@ -116,7 +116,7 @@ def main(argv: list[str]) -> int:
|
|||||||
transparent for callers building argv programmatically."""
|
transparent for callers building argv programmatically."""
|
||||||
if len(argv) < 3 or argv[1] != "--":
|
if len(argv) < 3 or argv[1] != "--":
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"usage: python -m claude_bottle.backend.smolmachines.pty_resize "
|
"usage: python -m bot_bottle.backend.smolmachines.pty_resize "
|
||||||
"<machine> -- <smolvm-argv...>\n"
|
"<machine> -- <smolvm-argv...>\n"
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
+31
-9
@@ -11,7 +11,7 @@ Two docker resources per bottle live here:
|
|||||||
— a race we can sidestep with `--ip`.
|
— a race we can sidestep with `--ip`.
|
||||||
|
|
||||||
- **The bundle container itself**, running the PRD 0024 bundle
|
- **The bundle container itself**, running the PRD 0024 bundle
|
||||||
image (`claude-bottle-sidecars:latest` by default). Same
|
image (`bot-bottle-sidecars:latest` by default). Same
|
||||||
image, same daemons, same daemon-private env / bind-mounts
|
image, same daemons, same daemon-private env / bind-mounts
|
||||||
as the docker backend.
|
as the docker backend.
|
||||||
|
|
||||||
@@ -29,22 +29,29 @@ from pathlib import Path
|
|||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
from ...log import die, warn
|
from ...log import die, warn
|
||||||
from ..docker.sidecar_bundle import SIDECAR_BUNDLE_IMAGE
|
from ..docker import util as docker_mod
|
||||||
|
from ..docker.sidecar_bundle import (
|
||||||
|
SIDECAR_BUNDLE_DOCKERFILE,
|
||||||
|
SIDECAR_BUNDLE_IMAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||||
|
|
||||||
|
|
||||||
def bundle_network_name(slug: str) -> str:
|
def bundle_network_name(slug: str) -> str:
|
||||||
"""`claude-bottle-bundle-<slug>` — distinct from the docker
|
"""`bot-bottle-bundle-<slug>` — distinct from the docker
|
||||||
backend's `claude-bottle-net-<slug>` so a smolmachines bottle
|
backend's `bot-bottle-net-<slug>` so a smolmachines bottle
|
||||||
and a docker bottle for the same agent don't collide on
|
and a docker bottle for the same agent don't collide on
|
||||||
network name."""
|
network name."""
|
||||||
return f"claude-bottle-bundle-{slug}"
|
return f"bot-bottle-bundle-{slug}"
|
||||||
|
|
||||||
|
|
||||||
def bundle_container_name(slug: str) -> str:
|
def bundle_container_name(slug: str) -> str:
|
||||||
"""`claude-bottle-sidecars-<slug>` — same name shape the docker
|
"""`bot-bottle-sidecars-<slug>` — same name shape the docker
|
||||||
backend uses for the bundle (PRD 0024 chunk 5). The dashboard's
|
backend uses for the bundle (PRD 0024 chunk 5). The dashboard's
|
||||||
prefix-based discovery covers both backends with one filter."""
|
prefix-based discovery covers both backends with one filter."""
|
||||||
return f"claude-bottle-sidecars-{slug}"
|
return f"bot-bottle-sidecars-{slug}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -59,7 +66,7 @@ class BundleLaunchSpec:
|
|||||||
gateway: str
|
gateway: str
|
||||||
bundle_ip: str
|
bundle_ip: str
|
||||||
image: str = SIDECAR_BUNDLE_IMAGE
|
image: str = SIDECAR_BUNDLE_IMAGE
|
||||||
# Daemon subset CSV for CLAUDE_BOTTLE_SIDECAR_DAEMONS. The
|
# Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The
|
||||||
# supervisor inside the bundle reads it to skip
|
# supervisor inside the bundle reads it to skip
|
||||||
# bottle-irrelevant daemons (e.g. supervise=False bottles).
|
# bottle-irrelevant daemons (e.g. supervise=False bottles).
|
||||||
daemons_csv: str = "egress,pipelock"
|
daemons_csv: str = "egress,pipelock"
|
||||||
@@ -85,6 +92,21 @@ class BundleLaunchSpec:
|
|||||||
publish_host_ip: str = "127.0.0.1"
|
publish_host_ip: str = "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_bundle_image(image: str = SIDECAR_BUNDLE_IMAGE) -> None:
|
||||||
|
"""Build the sidecar bundle image before `docker run`.
|
||||||
|
|
||||||
|
The Docker backend gets this for free from compose's `build:`
|
||||||
|
stanza. smolmachines starts the bundle with plain `docker run`,
|
||||||
|
so without an explicit build a first launch tries to pull the
|
||||||
|
local-only `bot-bottle-sidecars:latest` tag from a registry.
|
||||||
|
"""
|
||||||
|
docker_mod.build_image(
|
||||||
|
image,
|
||||||
|
_REPO_DIR,
|
||||||
|
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
|
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
|
||||||
"""`docker network create` with an explicit subnet + gateway
|
"""`docker network create` with an explicit subnet + gateway
|
||||||
so the bundle's `--ip` lands on the address the Smolfile's
|
so the bundle's `--ip` lands on the address the Smolfile's
|
||||||
@@ -141,7 +163,7 @@ def start_bundle(spec: BundleLaunchSpec, *,
|
|||||||
"--rm",
|
"--rm",
|
||||||
"--network", spec.network_name,
|
"--network", spec.network_name,
|
||||||
"--ip", spec.bundle_ip,
|
"--ip", spec.bundle_ip,
|
||||||
"-e", f"CLAUDE_BOTTLE_SIDECAR_DAEMONS={spec.daemons_csv}",
|
"-e", f"BOT_BOTTLE_SIDECAR_DAEMONS={spec.daemons_csv}",
|
||||||
]
|
]
|
||||||
for entry in spec.environment:
|
for entry in spec.environment:
|
||||||
argv += ["-e", entry]
|
argv += ["-e", entry]
|
||||||
+1
-1
@@ -19,7 +19,7 @@ def smolmachines_preflight() -> None:
|
|||||||
if shutil.which("smolvm") is not None:
|
if shutil.which("smolvm") is not None:
|
||||||
return
|
return
|
||||||
die(
|
die(
|
||||||
"CLAUDE_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
||||||
"PATH. Install with: "
|
"PATH. Install with: "
|
||||||
"curl -sSL https://smolmachines.com/install.sh | sh"
|
"curl -sSL https://smolmachines.com/install.sh | sh"
|
||||||
)
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Cross-backend utility helpers — host-side primitives shared by
|
"""Cross-backend utility helpers — host-side primitives shared by
|
||||||
every backend implementation. Backend-specific helpers live one level
|
every backend implementation. Backend-specific helpers live one level
|
||||||
deeper (e.g. claude_bottle/backend/docker/util.py)."""
|
deeper (e.g. bot_bottle/backend/docker/util.py)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -35,11 +35,11 @@ COMMANDS = {
|
|||||||
def usage() -> None:
|
def usage() -> None:
|
||||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||||
sys.stderr.write("Commands:\n")
|
sys.stderr.write("Commands:\n")
|
||||||
sys.stderr.write(" cleanup stop and remove all active claude-bottle containers\n")
|
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
||||||
sys.stderr.write(" dashboard view + approve/modify/reject pending supervise proposals (PRD 0013)\n")
|
sys.stderr.write(" dashboard view + approve/modify/reject pending supervise proposals (PRD 0013)\n")
|
||||||
sys.stderr.write(" edit open an agent in vim for editing\n")
|
sys.stderr.write(" edit open an agent in vim for editing\n")
|
||||||
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
||||||
sys.stderr.write(" init interactively create a new agent and add it to claude-bottle.json\n")
|
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||||
sys.stderr.write(" list list available agents or active containers\n")
|
sys.stderr.write(" list list available agents or active containers\n")
|
||||||
sys.stderr.write(" resume re-launch a bottle by its identity (continues state from PRD 0016)\n")
|
sys.stderr.write(" resume re-launch a bottle by its identity (continues state from PRD 0016)\n")
|
||||||
sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n\n")
|
sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n\n")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""cleanup: stop and remove all orphaned claude-bottle resources.
|
"""cleanup: stop and remove all orphaned bot-bottle resources.
|
||||||
|
|
||||||
Walks every registered backend (docker + smolmachines) so a single
|
Walks every registered backend (docker + smolmachines) so a single
|
||||||
`./cli.py cleanup` reaps both backends' leftovers — orphaned
|
`./cli.py cleanup` reaps both backends' leftovers — orphaned
|
||||||
@@ -14,7 +14,7 @@ bucket.
|
|||||||
|
|
||||||
State dirs with `.preserve` are intentionally never touched — they
|
State dirs with `.preserve` are intentionally never touched — they
|
||||||
hold capability-block rebuilds or crash snapshots the operator may
|
hold capability-block rebuilds or crash snapshots the operator may
|
||||||
want to `resume`. Manual `rm -rf ~/.claude-bottle/state/<identity>`
|
want to `resume`. Manual `rm -rf ~/.bot-bottle/state/<identity>`
|
||||||
is the path for those.
|
is the path for those.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ def cmd_cleanup(_argv: list[str]) -> int:
|
|||||||
prepared = [(name, b, b.prepare_cleanup()) for name, b in plans]
|
prepared = [(name, b, b.prepare_cleanup()) for name, b in plans]
|
||||||
|
|
||||||
if all(p.empty for _, _, p in prepared):
|
if all(p.empty for _, _, p in prepared):
|
||||||
info("no claude-bottle resources to clean up")
|
info("no bot-bottle resources to clean up")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
for name, _, plan in prepared:
|
for name, _, plan in prepared:
|
||||||
@@ -58,7 +58,7 @@ def cmd_cleanup(_argv: list[str]) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _prompt_yes(message: str) -> bool:
|
def _prompt_yes(message: str) -> bool:
|
||||||
sys.stderr.write(f"claude-bottle: {message} [y/N] ")
|
sys.stderr.write(f"bot-bottle: {message} [y/N] ")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
reply = read_tty_line()
|
reply = read_tty_line()
|
||||||
return reply in ("y", "Y", "yes", "YES")
|
return reply in ("y", "Y", "yes", "YES")
|
||||||
@@ -26,6 +26,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .. import supervise as _supervise
|
from .. import supervise as _supervise
|
||||||
|
from ..agent_provider import runtime_for
|
||||||
from ..backend import (
|
from ..backend import (
|
||||||
ActiveAgent,
|
ActiveAgent,
|
||||||
BottleSpec,
|
BottleSpec,
|
||||||
@@ -73,8 +74,8 @@ from ..supervise import (
|
|||||||
)
|
)
|
||||||
from ._common import PROG, USER_CWD
|
from ._common import PROG, USER_CWD
|
||||||
from .start import (
|
from .start import (
|
||||||
attach_claude,
|
attach_agent,
|
||||||
capture_session_state,
|
capture_claude_session_state,
|
||||||
prepare_with_preflight,
|
prepare_with_preflight,
|
||||||
settle_state,
|
settle_state,
|
||||||
)
|
)
|
||||||
@@ -119,10 +120,10 @@ def _approval_status(qp: QueuedProposal, verb: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def discover_pending() -> list[QueuedProposal]:
|
def discover_pending() -> list[QueuedProposal]:
|
||||||
"""Walk ~/.claude-bottle/queue/* and collect pending proposals
|
"""Walk ~/.bot-bottle/queue/* and collect pending proposals
|
||||||
from every bottle's queue. Sorted by arrival time across the
|
from every bottle's queue. Sorted by arrival time across the
|
||||||
union — the operator works the global FIFO."""
|
union — the operator works the global FIFO."""
|
||||||
queue_root = _supervise.claude_bottle_root() / "queue"
|
queue_root = _supervise.bot_bottle_root() / "queue"
|
||||||
if not queue_root.is_dir():
|
if not queue_root.is_dir():
|
||||||
return []
|
return []
|
||||||
out: list[QueuedProposal] = []
|
out: list[QueuedProposal] = []
|
||||||
@@ -367,8 +368,6 @@ def _picker_modal(
|
|||||||
"""Modal agent picker. Type to filter; j/k or arrows to
|
"""Modal agent picker. Type to filter; j/k or arrows to
|
||||||
navigate; Enter to confirm; Esc to abort (first press clears
|
navigate; Enter to confirm; Esc to abort (first press clears
|
||||||
filter if any, second press exits)."""
|
filter if any, second press exits)."""
|
||||||
if not names:
|
|
||||||
return None
|
|
||||||
selected = 0
|
selected = 0
|
||||||
query = ""
|
query = ""
|
||||||
while True:
|
while True:
|
||||||
@@ -454,9 +453,13 @@ def _draw_picker_modal(
|
|||||||
list_start_row = 3
|
list_start_row = 3
|
||||||
visible_rows = box_h - list_start_row - 1
|
visible_rows = box_h - list_start_row - 1
|
||||||
if not filtered:
|
if not filtered:
|
||||||
|
empty_message = (
|
||||||
|
"(no agents configured)"
|
||||||
|
if not all_names else "(no agents match filter)"
|
||||||
|
)
|
||||||
win.addnstr(
|
win.addnstr(
|
||||||
list_start_row, 2,
|
list_start_row, 2,
|
||||||
"(no agents match filter)",
|
empty_message,
|
||||||
box_w - 4, curses.A_DIM,
|
box_w - 4, curses.A_DIM,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -542,7 +545,7 @@ def _backend_picker_modal(
|
|||||||
which keeps existing-muscle-memory flows quiet — the modal only
|
which keeps existing-muscle-memory flows quiet — the modal only
|
||||||
surfaces a choice; it doesn't surprise the operator by jumping
|
surfaces a choice; it doesn't surprise the operator by jumping
|
||||||
to smolmachines. The picker exists so operators can opt in to
|
to smolmachines. The picker exists so operators can opt in to
|
||||||
smolmachines without setting CLAUDE_BOTTLE_BACKEND beforehand
|
smolmachines without setting BOT_BOTTLE_BACKEND beforehand
|
||||||
(issue #77)."""
|
(issue #77)."""
|
||||||
names = list(known_backend_names())
|
names = list(known_backend_names())
|
||||||
if len(names) <= 1:
|
if len(names) <= 1:
|
||||||
@@ -637,7 +640,7 @@ def _bottle_for_slug(
|
|||||||
"""Return `(bottle_handle, prompt_path_hint)` for a re-attach.
|
"""Return `(bottle_handle, prompt_path_hint)` for a re-attach.
|
||||||
If the slug is in `bottles` (dashboard-owned), return the stored
|
If the slug is in `bottles` (dashboard-owned), return the stored
|
||||||
handle directly. Otherwise synthesize a `DockerBottle` from the
|
handle directly. Otherwise synthesize a `DockerBottle` from the
|
||||||
container name `claude-bottle-<slug>`. For synthesized bottles
|
container name `bot-bottle-<slug>`. For synthesized bottles
|
||||||
the prompt-file path comes from the manifest's agent if we can
|
the prompt-file path comes from the manifest's agent if we can
|
||||||
resolve it via metadata.json + the loaded manifest; otherwise
|
resolve it via metadata.json + the loaded manifest; otherwise
|
||||||
the re-attach runs without `--append-system-prompt-file`.
|
the re-attach runs without `--append-system-prompt-file`.
|
||||||
@@ -649,19 +652,19 @@ def _bottle_for_slug(
|
|||||||
if slug in bottles:
|
if slug in bottles:
|
||||||
_cm, bottle, _identity = bottles[slug]
|
_cm, bottle, _identity = bottles[slug]
|
||||||
return bottle, ""
|
return bottle, ""
|
||||||
# The container hosting the agent's claude process is named
|
# The container hosting the agent's agent process is named
|
||||||
# `claude-bottle-<slug>` — set by the compose renderer
|
# `bot-bottle-<slug>` — set by the compose renderer
|
||||||
# (no service suffix on the agent service, by design).
|
# (no service suffix on the agent service, by design).
|
||||||
container_name = f"claude-bottle-{slug}"
|
container_name = f"bot-bottle-{slug}"
|
||||||
prompt_path: str | None = None
|
prompt_path: str | None = None
|
||||||
metadata = read_metadata(slug)
|
metadata = read_metadata(slug)
|
||||||
if metadata is not None and manifest is not None:
|
if metadata is not None and manifest is not None:
|
||||||
agent = manifest.agents.get(metadata.agent_name)
|
agent = manifest.agents.get(metadata.agent_name)
|
||||||
if agent is not None and agent.prompt:
|
if agent is not None and agent.prompt:
|
||||||
container_home = os.environ.get(
|
container_home = os.environ.get(
|
||||||
"CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node",
|
"BOT_BOTTLE_CONTAINER_HOME", "/home/node",
|
||||||
)
|
)
|
||||||
prompt_path = f"{container_home}/.claude-bottle-prompt.txt"
|
prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
|
||||||
synth = DockerBottle(
|
synth = DockerBottle(
|
||||||
container=container_name,
|
container=container_name,
|
||||||
teardown=lambda: None,
|
teardown=lambda: None,
|
||||||
@@ -693,7 +696,7 @@ def _stop_bottle_flow(
|
|||||||
return (
|
return (
|
||||||
f"[{slug}] not dashboard-owned — use ./cli.py cleanup"
|
f"[{slug}] not dashboard-owned — use ./cli.py cleanup"
|
||||||
)
|
)
|
||||||
cm, _bottle, identity = bottles.pop(slug)
|
cm, bottle, identity = bottles.pop(slug)
|
||||||
|
|
||||||
def _do_teardown() -> None:
|
def _do_teardown() -> None:
|
||||||
# Best-effort snapshot before teardown so the operator
|
# Best-effort snapshot before teardown so the operator
|
||||||
@@ -703,7 +706,8 @@ def _stop_bottle_flow(
|
|||||||
# existing preserve marker (if any) is honored by
|
# existing preserve marker (if any) is honored by
|
||||||
# settle_state below.
|
# settle_state below.
|
||||||
try:
|
try:
|
||||||
capture_session_state(identity, exit_code=0)
|
if getattr(bottle, "agent_provider_template", "claude") == "claude":
|
||||||
|
capture_claude_session_state(identity, exit_code=0)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
@@ -713,7 +717,7 @@ def _stop_bottle_flow(
|
|||||||
|
|
||||||
# Mirror the bringup path's stderr → right-pane routing.
|
# Mirror the bringup path's stderr → right-pane routing.
|
||||||
# Reuses any existing right pane (which is probably the
|
# Reuses any existing right pane (which is probably the
|
||||||
# agent's own claude session) via `_ensure_right_pane`; the
|
# agent's own agent session) via `_ensure_right_pane`; the
|
||||||
# final buffered output stays visible after settle_state
|
# final buffered output stays visible after settle_state
|
||||||
# removes the state dir (tail-F handles file removal).
|
# removes the state dir (tail-F handles file removal).
|
||||||
try:
|
try:
|
||||||
@@ -750,7 +754,7 @@ def _stop_bottle_flow(
|
|||||||
# pane of a two-pane window with the operator's currently-selected
|
# pane of a two-pane window with the operator's currently-selected
|
||||||
# agent in the right pane. First attach creates the right pane via
|
# agent in the right pane. First attach creates the right pane via
|
||||||
# `tmux split-window`; subsequent attaches respawn that pane with
|
# `tmux split-window`; subsequent attaches respawn that pane with
|
||||||
# the new agent's claude session. The dashboard remembers the
|
# the new agent's agent session. The dashboard remembers the
|
||||||
# pane id + occupant slug in `tmux_state` so the same pane is
|
# pane id + occupant slug in `tmux_state` so the same pane is
|
||||||
# reused across attaches.
|
# reused across attaches.
|
||||||
|
|
||||||
@@ -761,21 +765,24 @@ def _in_tmux() -> bool:
|
|||||||
return bool(os.environ.get("TMUX"))
|
return bool(os.environ.get("TMUX"))
|
||||||
|
|
||||||
|
|
||||||
def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[str]:
|
def _agent_runtime_args(
|
||||||
"""The argv the dashboard hands to `bottle.claude_argv`
|
*, resume: bool, remote_control: bool = False, agent_provider_template: str = "claude",
|
||||||
on every attach — matches what `attach_claude` builds for the
|
) -> list[str]:
|
||||||
|
"""The argv the dashboard hands to `bottle.agent_argv`
|
||||||
|
on every attach — matches what `attach_agent` builds for the
|
||||||
foreground handoff so both surfaces produce the same claude
|
foreground handoff so both surfaces produce the same claude
|
||||||
invocation."""
|
invocation."""
|
||||||
args = ["--dangerously-skip-permissions"]
|
runtime = runtime_for(agent_provider_template)
|
||||||
|
args = list(runtime.bypass_args)
|
||||||
if remote_control:
|
if remote_control:
|
||||||
args.append("--remote-control")
|
args.extend(runtime.remote_control_args)
|
||||||
if resume:
|
if resume:
|
||||||
args.append("--continue")
|
args.extend(runtime.resume_args)
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
def _build_resume_argv_with_fallback(
|
def _build_resume_argv_with_fallback(
|
||||||
bottle, *, remote_control: bool = False,
|
bottle, *, remote_control: bool = False, agent_provider_template: str = "claude",
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Build a backend-exec argv that runs `claude --continue` and
|
"""Build a backend-exec argv that runs `claude --continue` and
|
||||||
falls back to plain `claude` if no prior session exists.
|
falls back to plain `claude` if no prior session exists.
|
||||||
@@ -790,30 +797,44 @@ def _build_resume_argv_with_fallback(
|
|||||||
the fallback only kicks in when --continue would have
|
the fallback only kicks in when --continue would have
|
||||||
failed anyway.
|
failed anyway.
|
||||||
|
|
||||||
Works across backends because `bottle.claude_argv` always
|
Works across backends because `bottle.agent_argv` always
|
||||||
surfaces the `claude` token preceded by the backend's exec
|
surfaces the `claude` token preceded by the backend's exec
|
||||||
framing (docker: `docker exec -it <c>`; smolmachines:
|
framing (docker: `docker exec -it <c>`; smolmachines:
|
||||||
`smolvm machine exec --name <m> -- runuser -u node --`).
|
`smolvm machine exec --name <m> -- runuser -u node --`).
|
||||||
Splitting at `claude` keeps the framing as the prefix and
|
Splitting at `claude` keeps the framing as the prefix and
|
||||||
wraps just the claude tail in `sh -c`."""
|
wraps just the agent tail in `sh -c`."""
|
||||||
base_args = ["--dangerously-skip-permissions"]
|
if agent_provider_template != "claude":
|
||||||
if remote_control:
|
return bottle.agent_argv(
|
||||||
base_args.append("--remote-control")
|
_agent_runtime_args(
|
||||||
base_exec = bottle.claude_argv(base_args)
|
resume=True,
|
||||||
# Split exec-framing prefix from the claude-and-args tail so
|
remote_control=remote_control,
|
||||||
|
agent_provider_template=agent_provider_template,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
base_args = _agent_runtime_args(
|
||||||
|
resume=False,
|
||||||
|
remote_control=remote_control,
|
||||||
|
agent_provider_template=agent_provider_template,
|
||||||
|
)
|
||||||
|
base_exec = bottle.agent_argv(base_args)
|
||||||
|
# Split exec-framing prefix from the agent-and-args tail so
|
||||||
# we can compose `<claude…> --continue || <claude…>` inside
|
# we can compose `<claude…> --continue || <claude…>` inside
|
||||||
# `sh -c`. The `claude` token is the marker.
|
# `sh -c`. The provider command token is the marker.
|
||||||
claude_idx = base_exec.index("claude")
|
command = getattr(bottle, "agent_command", runtime_for(agent_provider_template).command)
|
||||||
prefix = base_exec[:claude_idx]
|
agent_idx = base_exec.index(command)
|
||||||
claude_cmd = " ".join(shlex.quote(a) for a in base_exec[claude_idx:])
|
prefix = base_exec[:agent_idx]
|
||||||
|
agent_cmd = " ".join(shlex.quote(a) for a in base_exec[agent_idx:])
|
||||||
|
resume_args = " ".join(
|
||||||
|
shlex.quote(a) for a in runtime_for(agent_provider_template).resume_args
|
||||||
|
)
|
||||||
return [
|
return [
|
||||||
*prefix,
|
*prefix,
|
||||||
"sh", "-c",
|
"sh", "-c",
|
||||||
f"{claude_cmd} --continue || {claude_cmd}",
|
f"{agent_cmd} {resume_args} || {agent_cmd}",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _build_split_pane_argv(claude_argv: list[str]) -> list[str]:
|
def _build_split_pane_argv(agent_argv: list[str]) -> list[str]:
|
||||||
"""Pure helper: wrap a backend-exec argv with `tmux split-window
|
"""Pure helper: wrap a backend-exec argv with `tmux split-window
|
||||||
-h -P -F '#{pane_id}'`. The `-P -F` combo tells tmux to print
|
-h -P -F '#{pane_id}'`. The `-P -F` combo tells tmux to print
|
||||||
the new pane's id on stdout so we can track it for later
|
the new pane's id on stdout so we can track it for later
|
||||||
@@ -821,15 +842,15 @@ def _build_split_pane_argv(claude_argv: list[str]) -> list[str]:
|
|||||||
return [
|
return [
|
||||||
"tmux", "split-window", "-h",
|
"tmux", "split-window", "-h",
|
||||||
"-P", "-F", "#{pane_id}",
|
"-P", "-F", "#{pane_id}",
|
||||||
*claude_argv,
|
*agent_argv,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _build_respawn_pane_argv(pane_id: str, claude_argv: list[str]) -> list[str]:
|
def _build_respawn_pane_argv(pane_id: str, agent_argv: list[str]) -> list[str]:
|
||||||
"""Pure helper: wrap a backend-exec argv with `tmux respawn-pane
|
"""Pure helper: wrap a backend-exec argv with `tmux respawn-pane
|
||||||
-k -t <pane_id>`. `-k` kills the existing process in the pane
|
-k -t <pane_id>`. `-k` kills the existing process in the pane
|
||||||
before respawning."""
|
before respawning."""
|
||||||
return ["tmux", "respawn-pane", "-k", "-t", pane_id, *claude_argv]
|
return ["tmux", "respawn-pane", "-k", "-t", pane_id, *agent_argv]
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
@@ -933,7 +954,7 @@ def _route_op_to_right_pane(
|
|||||||
def _tmux_close_right_pane(tmux_state: dict) -> None:
|
def _tmux_close_right_pane(tmux_state: dict) -> None:
|
||||||
"""Close the tracked right pane via `tmux kill-pane`. Clears
|
"""Close the tracked right pane via `tmux kill-pane`. Clears
|
||||||
both pane_id and slug in `tmux_state`. Used after the last
|
both pane_id and slug in `tmux_state`. Used after the last
|
||||||
dashboard-owned agent is stopped — no claude session left
|
dashboard-owned agent is stopped — no agent session left
|
||||||
to host, so the pane shouldn't linger."""
|
to host, so the pane shouldn't linger."""
|
||||||
pane_id = tmux_state.get("pane_id")
|
pane_id = tmux_state.get("pane_id")
|
||||||
if pane_id and _tmux_pane_exists(pane_id):
|
if pane_id and _tmux_pane_exists(pane_id):
|
||||||
@@ -973,7 +994,7 @@ def _ensure_right_pane(tmux_state: dict, argv: list[str]) -> str | None:
|
|||||||
returns the pane id on success, None on failure.
|
returns the pane id on success, None on failure.
|
||||||
|
|
||||||
This is the single place where "respawn or create" lives —
|
This is the single place where "respawn or create" lives —
|
||||||
used by `_attach_in_tmux` for claude sessions AND by
|
used by `_attach_in_tmux` for agent sessions AND by
|
||||||
`_new_agent_flow` for the bringup-log tail. Without this,
|
`_new_agent_flow` for the bringup-log tail. Without this,
|
||||||
every new-agent start would pile up a fresh right pane
|
every new-agent start would pile up a fresh right pane
|
||||||
instead of reusing the one already next to the dashboard."""
|
instead of reusing the one already next to the dashboard."""
|
||||||
@@ -1018,14 +1039,18 @@ def _attach_via_handoff(
|
|||||||
`_attach_in_tmux` when tmux misbehaves)."""
|
`_attach_in_tmux` when tmux misbehaves)."""
|
||||||
curses.endwin()
|
curses.endwin()
|
||||||
try:
|
try:
|
||||||
exit_code = attach_claude(
|
agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
|
||||||
bottle, remote_control=False, resume=resume,
|
exit_code = attach_agent(
|
||||||
|
bottle,
|
||||||
|
remote_control=False,
|
||||||
|
resume=resume,
|
||||||
|
agent_provider_template=agent_provider_template,
|
||||||
)
|
)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
raise
|
raise
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
return f"[{slug}] claude session ended (exit {exit_code})"
|
return f"[{slug}] agent session ended (exit {exit_code})"
|
||||||
|
|
||||||
|
|
||||||
def _attach_in_tmux(
|
def _attach_in_tmux(
|
||||||
@@ -1044,21 +1069,28 @@ def _attach_in_tmux(
|
|||||||
explicit-stop hook).
|
explicit-stop hook).
|
||||||
|
|
||||||
`focus_right_pane=True` runs `tmux select-pane` after the
|
`focus_right_pane=True` runs `tmux select-pane` after the
|
||||||
respawn so the operator is dropped into claude immediately.
|
respawn so the operator is dropped into agent immediately.
|
||||||
The Enter re-attach key passes this; passive paths (the
|
The Enter re-attach key passes this; passive paths (the
|
||||||
auto-attach after a stop) leave it False so the operator
|
auto-attach after a stop) leave it False so the operator
|
||||||
stays in the dashboard pane."""
|
stays in the dashboard pane."""
|
||||||
if resume:
|
if resume:
|
||||||
|
agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
|
||||||
# `--continue` exits non-zero when no prior session
|
# `--continue` exits non-zero when no prior session
|
||||||
# exists (agent spun up but never typed at). Wrap with a
|
# exists (agent spun up but never typed at). Wrap with a
|
||||||
# shell-level fallback so the pane lands in a fresh
|
# shell-level fallback so the pane lands in a fresh
|
||||||
# claude instead of crashing.
|
# agent instead of crashing.
|
||||||
claude_argv = _build_resume_argv_with_fallback(bottle)
|
agent_argv = _build_resume_argv_with_fallback(
|
||||||
else:
|
bottle, agent_provider_template=agent_provider_template,
|
||||||
claude_argv = bottle.claude_argv(
|
|
||||||
_claude_runtime_args(resume=False),
|
|
||||||
)
|
)
|
||||||
pane_id = _ensure_right_pane(tmux_state, claude_argv)
|
else:
|
||||||
|
agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
|
||||||
|
agent_argv = bottle.agent_argv(
|
||||||
|
_agent_runtime_args(
|
||||||
|
resume=False,
|
||||||
|
agent_provider_template=agent_provider_template,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pane_id = _ensure_right_pane(tmux_state, agent_argv)
|
||||||
if pane_id is None:
|
if pane_id is None:
|
||||||
# tmux failed (missing binary, server died, size error).
|
# tmux failed (missing binary, server died, size error).
|
||||||
# One status-line failover to the curses handoff so the
|
# One status-line failover to the curses handoff so the
|
||||||
@@ -1091,7 +1123,7 @@ def _attach_to_bottle(
|
|||||||
tmux_state: dict | None = None,
|
tmux_state: dict | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Re-attach to a running bottle. Inside tmux (`$TMUX` set +
|
"""Re-attach to a running bottle. Inside tmux (`$TMUX` set +
|
||||||
`tmux_state` provided) the claude session opens in the
|
`tmux_state` provided) the agent session opens in the
|
||||||
right pane (created on first attach, respawned on
|
right pane (created on first attach, respawned on
|
||||||
subsequent). Outside tmux it's a curses-endwin handoff that
|
subsequent). Outside tmux it's a curses-endwin handoff that
|
||||||
blocks until the operator exits claude. Re-attach always uses
|
blocks until the operator exits claude. Re-attach always uses
|
||||||
@@ -1099,7 +1131,7 @@ def _attach_to_bottle(
|
|||||||
if _in_tmux() and tmux_state is not None:
|
if _in_tmux() and tmux_state is not None:
|
||||||
# Enter re-attach is an explicit "I want to interact with
|
# Enter re-attach is an explicit "I want to interact with
|
||||||
# this agent" signal — move tmux focus to the right pane
|
# this agent" signal — move tmux focus to the right pane
|
||||||
# so keypresses land in claude instead of the dashboard.
|
# so keypresses land in agent instead of the dashboard.
|
||||||
return _attach_in_tmux(
|
return _attach_in_tmux(
|
||||||
stdscr, bottle, slug,
|
stdscr, bottle, slug,
|
||||||
resume=True, tmux_state=tmux_state,
|
resume=True, tmux_state=tmux_state,
|
||||||
@@ -1117,13 +1149,15 @@ def _new_agent_flow(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Open the picker, prepare + preflight (modal), launch
|
"""Open the picker, prepare + preflight (modal), launch
|
||||||
(enter the context manager but DON'T close it), then route
|
(enter the context manager but DON'T close it), then route
|
||||||
the first claude session into the right pane (in-tmux) or
|
the first agent session into the right pane (in-tmux) or
|
||||||
foreground handoff (otherwise). Returns a status-line message
|
foreground handoff (otherwise). Returns a status-line message
|
||||||
for the dashboard footer. The (cm, bottle) tuple lands in
|
for the dashboard footer. The (cm, bottle) tuple lands in
|
||||||
`bottles` keyed by slug; chunk 4 uses it for explicit stop."""
|
`bottles` keyed by slug; chunk 4 uses it for explicit stop."""
|
||||||
names = sorted(manifest.agents.keys())
|
names = sorted(manifest.agents.keys())
|
||||||
picked = _picker_modal(stdscr, names, _running_counts(bottles, agents_now))
|
picked = _picker_modal(stdscr, names, _running_counts(bottles, agents_now))
|
||||||
if picked is None:
|
if picked is None:
|
||||||
|
if not names:
|
||||||
|
return "no agents configured; create ~/.bot-bottle/agents/*.md"
|
||||||
return "agent start aborted"
|
return "agent start aborted"
|
||||||
|
|
||||||
# Backend picker (issue #77): operator chooses docker /
|
# Backend picker (issue #77): operator chooses docker /
|
||||||
@@ -1151,7 +1185,7 @@ def _new_agent_flow(
|
|||||||
def _prompt() -> bool:
|
def _prompt() -> bool:
|
||||||
return _preflight_modal(stdscr, captured.get("text", ""))
|
return _preflight_modal(stdscr, captured.get("text", ""))
|
||||||
|
|
||||||
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
|
stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
|
||||||
try:
|
try:
|
||||||
plan, identity = prepare_with_preflight(
|
plan, identity = prepare_with_preflight(
|
||||||
spec,
|
spec,
|
||||||
@@ -1205,14 +1239,20 @@ def _new_agent_flow(
|
|||||||
raise
|
raise
|
||||||
bottles[plan.slug] = (cm, bottle, identity)
|
bottles[plan.slug] = (cm, bottle, identity)
|
||||||
|
|
||||||
# Foreground handoff: claude owns the terminal until exit,
|
# Foreground handoff: the agent owns the terminal until exit,
|
||||||
# then we restore curses.
|
# then we restore curses.
|
||||||
try:
|
try:
|
||||||
exit_code = attach_claude(bottle, remote_control=False)
|
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||||
capture_session_state(identity, exit_code)
|
exit_code = attach_agent(
|
||||||
|
bottle,
|
||||||
|
remote_control=False,
|
||||||
|
agent_provider_template=agent_provider_template,
|
||||||
|
)
|
||||||
|
if agent_provider_template == "claude":
|
||||||
|
capture_claude_session_state(identity, exit_code)
|
||||||
finally:
|
finally:
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
return f"[{plan.slug}] claude session ended (exit {exit_code})"
|
return f"[{plan.slug}] agent session ended (exit {exit_code})"
|
||||||
finally:
|
finally:
|
||||||
# stage_dir was the prepare scratch dir; after PRD 0018
|
# stage_dir was the prepare scratch dir; after PRD 0018
|
||||||
# chunk 2 it holds nothing the running bottle needs. Reap
|
# chunk 2 it holds nothing the running bottle needs. Reap
|
||||||
@@ -1365,8 +1405,10 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
|
|
||||||
def _get_manifest() -> Manifest:
|
def _get_manifest() -> Manifest:
|
||||||
if manifest_cache[0] is None:
|
if manifest_cache[0] is None:
|
||||||
manifest_cache[0] = Manifest.resolve(USER_CWD)
|
manifest_cache[0] = Manifest.resolve(USER_CWD, missing_ok=True)
|
||||||
return manifest_cache[0]
|
return manifest_cache[0]
|
||||||
|
if not _get_manifest().bottles and not _get_manifest().agents:
|
||||||
|
status_line = "warning: no bot-bottle config/agents found; new-agent picker is empty"
|
||||||
# First-tick guard: a brand-new dashboard finds any
|
# First-tick guard: a brand-new dashboard finds any
|
||||||
# pre-existing queue entries on its first poll; those
|
# pre-existing queue entries on its first poll; those
|
||||||
# shouldn't ring the bell as if they just arrived.
|
# shouldn't ring the bell as if they just arrived.
|
||||||
@@ -1504,7 +1546,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
# PRD 0021 follow-up: after stop, slide focus
|
# PRD 0021 follow-up: after stop, slide focus
|
||||||
# to the next agent in the list (the one that
|
# to the next agent in the list (the one that
|
||||||
# filled the stopped row) and respawn the
|
# filled the stopped row) and respawn the
|
||||||
# right pane with its claude session. If
|
# right pane with its agent session. If
|
||||||
# nothing's left, close the right pane.
|
# nothing's left, close the right pane.
|
||||||
pick = _pick_next_after_stop(
|
pick = _pick_next_after_stop(
|
||||||
agents, selected_agent, target.slug,
|
agents, selected_agent, target.slug,
|
||||||
@@ -1578,7 +1620,7 @@ def _render(
|
|||||||
h, w = stdscr.getmaxyx()
|
h, w = stdscr.getmaxyx()
|
||||||
agents = agents or []
|
agents = agents or []
|
||||||
header = (
|
header = (
|
||||||
f"claude-bottle dashboard "
|
f"bot-bottle dashboard "
|
||||||
f"({len(pending)} pending, {len(agents)} active)"
|
f"({len(pending)} pending, {len(agents)} active)"
|
||||||
)
|
)
|
||||||
stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD)
|
stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD)
|
||||||
@@ -18,9 +18,9 @@ def cmd_edit(argv: list[str]) -> int:
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.scope == "user":
|
if args.scope == "user":
|
||||||
target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
|
target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
|
||||||
else:
|
else:
|
||||||
target_file = Path(USER_CWD) / "claude-bottle.json"
|
target_file = Path(USER_CWD) / "bot-bottle.json"
|
||||||
|
|
||||||
if not target_file.is_file():
|
if not target_file.is_file():
|
||||||
die(f"{target_file} does not exist")
|
die(f"{target_file} does not exist")
|
||||||
@@ -11,7 +11,7 @@ from ._common import PROG, USER_CWD
|
|||||||
|
|
||||||
def cmd_info(argv: list[str]) -> int:
|
def cmd_info(argv: list[str]) -> int:
|
||||||
parser = argparse.ArgumentParser(prog=f"{PROG} info", add_help=True)
|
parser = argparse.ArgumentParser(prog=f"{PROG} info", add_help=True)
|
||||||
parser.add_argument("name", help="agent name defined in claude-bottle.json")
|
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
manifest = Manifest.resolve(USER_CWD)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""init: interactively create a new agent and add it to claude-bottle.json."""
|
"""init: interactively create a new agent and add it to bot-bottle.json."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -20,12 +20,12 @@ def cmd_init(argv: list[str]) -> int:
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.scope == "user":
|
if args.scope == "user":
|
||||||
target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
|
target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
|
||||||
else:
|
else:
|
||||||
target_file = Path(USER_CWD) / "claude-bottle.json"
|
target_file = Path(USER_CWD) / "bot-bottle.json"
|
||||||
|
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
info(f"claude-bottle init — adding a new agent to {target_file}")
|
info(f"bot-bottle init — adding a new agent to {target_file}")
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
|
|
||||||
# Agent name
|
# Agent name
|
||||||
@@ -51,7 +51,7 @@ def cmd_init(argv: list[str]) -> int:
|
|||||||
die(f"{target_file} exists but is not valid JSON; fix or remove it first")
|
die(f"{target_file} exists but is not valid JSON; fix or remove it first")
|
||||||
if agent_name in (existing.get("agents") or {}):
|
if agent_name in (existing.get("agents") or {}):
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f'claude-bottle: agent "{agent_name}" already exists in {target_file}. Overwrite? [y/N] '
|
f'bot-bottle: agent "{agent_name}" already exists in {target_file}. Overwrite? [y/N] '
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
ow = read_tty_line()
|
ow = read_tty_line()
|
||||||
@@ -25,7 +25,7 @@ def cmd_list(argv: list[str]) -> int:
|
|||||||
# so smolmachines bottles aren't hidden behind the env var.
|
# so smolmachines bottles aren't hidden behind the env var.
|
||||||
active = enumerate_active_agents()
|
active = enumerate_active_agents()
|
||||||
if not active:
|
if not active:
|
||||||
print("no active claude-bottle bottles", file=sys.stderr)
|
print("no active bot-bottle bottles", file=sys.stderr)
|
||||||
return 0
|
return 0
|
||||||
# One line per bottle: `<backend>\t<slug>\t<agent>\t<status>`.
|
# One line per bottle: `<backend>\t<slug>\t<agent>\t<status>`.
|
||||||
# Tab-separated keeps the format stable for shell pipelines;
|
# Tab-separated keeps the format stable for shell pipelines;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""resume: re-launch a bottle by its identity.
|
"""resume: re-launch a bottle by its identity.
|
||||||
|
|
||||||
Reads ~/.claude-bottle/state/<identity>/metadata.json to recover the
|
Reads ~/.bot-bottle/state/<identity>/metadata.json to recover the
|
||||||
(agent_name, cwd, copy_cwd) the bottle was originally started with,
|
(agent_name, cwd, copy_cwd) the bottle was originally started with,
|
||||||
then runs the same launch core as `start` — but pinned to the
|
then runs the same launch core as `start` — but pinned to the
|
||||||
recorded identity so the new bottle picks up any per-bottle Dockerfile
|
recorded identity so the new bottle picks up any per-bottle Dockerfile
|
||||||
@@ -39,7 +39,7 @@ def cmd_resume(argv: list[str]) -> int:
|
|||||||
if metadata is None:
|
if metadata is None:
|
||||||
die(
|
die(
|
||||||
f"no state recorded for identity {args.identity!r}; "
|
f"no state recorded for identity {args.identity!r}; "
|
||||||
f"check ~/.claude-bottle/state/ or run `cli.py start` to create a new bottle"
|
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
|
||||||
)
|
)
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
manifest = Manifest.resolve(USER_CWD)
|
||||||
@@ -4,7 +4,7 @@ session ends.
|
|||||||
|
|
||||||
The launch core is shared with `cli.py resume <identity>` and (PRD
|
The launch core is shared with `cli.py resume <identity>` and (PRD
|
||||||
0020 chunk 1+) the dashboard's in-process start flow: see the
|
0020 chunk 1+) the dashboard's in-process start flow: see the
|
||||||
public helpers `prepare_with_preflight`, `attach_claude`, and the
|
public helpers `prepare_with_preflight`, `attach_agent`, and the
|
||||||
private orchestrator `_launch_bottle`.
|
private orchestrator `_launch_bottle`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from ..agent_provider import runtime_for
|
||||||
from ..backend import (
|
from ..backend import (
|
||||||
Bottle,
|
Bottle,
|
||||||
BottleSpec,
|
BottleSpec,
|
||||||
@@ -46,14 +47,14 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
choices=known_backend_names(),
|
choices=known_backend_names(),
|
||||||
default=None,
|
default=None,
|
||||||
help=(
|
help=(
|
||||||
"backend to launch the bottle on (default: $CLAUDE_BOTTLE_BACKEND "
|
"backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND "
|
||||||
"or 'docker'). Overrides the env var when set."
|
"or 'docker'). Overrides the env var when set."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument("name", help="agent name defined in claude-bottle.json")
|
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1"
|
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
manifest = Manifest.resolve(USER_CWD)
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
@@ -88,7 +89,7 @@ def prepare_with_preflight(
|
|||||||
curses modal.
|
curses modal.
|
||||||
|
|
||||||
`backend_name` selects which backend prepares the plan
|
`backend_name` selects which backend prepares the plan
|
||||||
(`None` → `$CLAUDE_BOTTLE_BACKEND` → `docker`). Dashboard
|
(`None` → `$BOT_BOTTLE_BACKEND` → `docker`). Dashboard
|
||||||
passes the value from its new-agent backend-picker modal; the
|
passes the value from its new-agent backend-picker modal; the
|
||||||
CLI passes whatever `--backend` resolved to.
|
CLI passes whatever `--backend` resolved to.
|
||||||
|
|
||||||
@@ -112,11 +113,13 @@ def prepare_with_preflight(
|
|||||||
return plan, identity
|
return plan, identity
|
||||||
|
|
||||||
|
|
||||||
def attach_claude(
|
def attach_agent(
|
||||||
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
|
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
|
||||||
|
agent_provider_template: str = "claude",
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run claude inside `bottle` as an interactive session. Blocks
|
"""Run the selected provider CLI inside `bottle` as an
|
||||||
until the session ends; returns the claude process's exit code.
|
interactive session. Blocks until the session ends; returns the
|
||||||
|
agent process's exit code.
|
||||||
|
|
||||||
`resume=True` adds `--continue` so claude picks up its most
|
`resume=True` adds `--continue` so claude picks up its most
|
||||||
recent session non-interactively (no session-picker prompt) —
|
recent session non-interactively (no session-picker prompt) —
|
||||||
@@ -128,26 +131,28 @@ def attach_claude(
|
|||||||
Used as the inner step of `./cli.py start` (one-shot) and by the
|
Used as the inner step of `./cli.py start` (one-shot) and by the
|
||||||
dashboard, which calls it from inside a `curses.endwin → … →
|
dashboard, which calls it from inside a `curses.endwin → … →
|
||||||
stdscr.refresh()` handoff so the curses surface gets out of the
|
stdscr.refresh()` handoff so the curses surface gets out of the
|
||||||
terminal's way while claude has it."""
|
terminal's way while the agent has it."""
|
||||||
|
runtime = runtime_for(agent_provider_template)
|
||||||
info(
|
info(
|
||||||
"attaching interactive claude session "
|
f"attaching interactive {agent_provider_template} session "
|
||||||
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
||||||
)
|
)
|
||||||
claude_args = ["--dangerously-skip-permissions"]
|
agent_args = list(runtime.bypass_args)
|
||||||
if remote_control:
|
if remote_control:
|
||||||
claude_args.append("--remote-control")
|
agent_args.extend(runtime.remote_control_args)
|
||||||
if resume:
|
if resume:
|
||||||
# `--continue` jumps straight to the most recent session
|
agent_args.extend(runtime.resume_args)
|
||||||
# without showing the picker `--resume` would surface.
|
return bottle.exec_agent(agent_args, tty=True)
|
||||||
claude_args.append("--continue")
|
|
||||||
return bottle.exec_claude(claude_args, tty=True)
|
|
||||||
|
|
||||||
|
|
||||||
def capture_session_state(identity: str, exit_code: int) -> None:
|
def capture_claude_session_state(identity: str, exit_code: int) -> None:
|
||||||
"""Inside the launch context, while the container is still
|
"""Inside the launch context, while the container is still
|
||||||
alive: snapshot the transcript and mark for preservation if
|
alive: snapshot the transcript and mark for preservation if
|
||||||
claude crashed. Public for the dashboard's death-handling path
|
claude crashed. Public for the dashboard's death-handling path
|
||||||
(PRD 0020 open question 3)."""
|
(PRD 0020 open question 3)."""
|
||||||
|
# FIXME: this captures Claude-specific session state. A follow-up
|
||||||
|
# spike should explore freezing provider-neutral container state
|
||||||
|
# instead of relying on each agent's transcript layout.
|
||||||
if not identity:
|
if not identity:
|
||||||
return
|
return
|
||||||
snapshot_transcript(identity)
|
snapshot_transcript(identity)
|
||||||
@@ -179,7 +184,7 @@ def _identity_from_plan(plan: object) -> str:
|
|||||||
def _text_prompt_yes() -> bool:
|
def _text_prompt_yes() -> bool:
|
||||||
"""Default `prompt_yes` for CLI use: reads y/N from the
|
"""Default `prompt_yes` for CLI use: reads y/N from the
|
||||||
controlling tty via stderr prompt + tty-line read."""
|
controlling tty via stderr prompt + tty-line read."""
|
||||||
sys.stderr.write("claude-bottle: launch this agent? [y/N] ")
|
sys.stderr.write("bot-bottle: launch this agent? [y/N] ")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
reply = read_tty_line()
|
reply = read_tty_line()
|
||||||
return reply in ("y", "Y", "yes", "YES")
|
return reply in ("y", "Y", "yes", "YES")
|
||||||
@@ -201,7 +206,7 @@ def _launch_bottle(
|
|||||||
"""Shared launch core for `start` and `resume`. Builds the plan,
|
"""Shared launch core for `start` and `resume`. Builds the plan,
|
||||||
prints / dry-runs / prompts as appropriate, brings the bottle up,
|
prints / dry-runs / prompts as appropriate, brings the bottle up,
|
||||||
attaches claude, and prints the resume hint on session end."""
|
attaches claude, and prints the resume hint on session end."""
|
||||||
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
|
stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
|
||||||
identity = ""
|
identity = ""
|
||||||
try:
|
try:
|
||||||
plan, identity = prepare_with_preflight(
|
plan, identity = prepare_with_preflight(
|
||||||
@@ -217,7 +222,12 @@ def _launch_bottle(
|
|||||||
|
|
||||||
backend = get_bottle_backend(backend_name)
|
backend = get_bottle_backend(backend_name)
|
||||||
with backend.launch(plan) as bottle:
|
with backend.launch(plan) as bottle:
|
||||||
exit_code = attach_claude(bottle, remote_control=remote_control)
|
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||||
|
exit_code = attach_agent(
|
||||||
|
bottle,
|
||||||
|
remote_control=remote_control,
|
||||||
|
agent_provider_template=agent_provider_template,
|
||||||
|
)
|
||||||
info(
|
info(
|
||||||
f"session ended (exit {exit_code}); "
|
f"session ended (exit {exit_code}); "
|
||||||
f"container {bottle.name} will be removed"
|
f"container {bottle.name} will be removed"
|
||||||
@@ -230,7 +240,8 @@ def _launch_bottle(
|
|||||||
# way. snapshot_transcript is best-effort so the
|
# way. snapshot_transcript is best-effort so the
|
||||||
# capability-block path's prior snapshot isn't clobbered
|
# capability-block path's prior snapshot isn't clobbered
|
||||||
# when the container is already gone.
|
# when the container is already gone.
|
||||||
capture_session_state(identity, exit_code)
|
if agent_provider_template == "claude":
|
||||||
|
capture_claude_session_state(identity, exit_code)
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
||||||
@@ -14,7 +14,7 @@ This module defines the abstract proxy (`Egress`), its plan
|
|||||||
dataclass (`EgressPlan`), and the resolved per-route shape
|
dataclass (`EgressPlan`), and the resolved per-route shape
|
||||||
(`EgressRoute`). The sidecar's start/stop lifecycle is backend-
|
(`EgressRoute`). The sidecar's start/stop lifecycle is backend-
|
||||||
specific and lives on concrete subclasses (see
|
specific and lives on concrete subclasses (see
|
||||||
`claude_bottle/backend/docker/egress.py`).
|
`bot_bottle/backend/docker/egress.py`).
|
||||||
|
|
||||||
Chunks 1+2 of the PRD: this module + the mitmproxy addon + the Docker
|
Chunks 1+2 of the PRD: this module + the mitmproxy addon + the Docker
|
||||||
lifecycle are wired into the agent's `HTTP_PROXY` path; cred-proxy
|
lifecycle are wired into the agent's `HTTP_PROXY` path; cred-proxy
|
||||||
@@ -127,23 +127,6 @@ class EgressPlan:
|
|||||||
pipelock_proxy_url: str = ""
|
pipelock_proxy_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
# Hosts the agent needs by default for claude-code itself. Folded
|
|
||||||
# into every bottle's egress routes table as bare-pass entries
|
|
||||||
# (no auth, no path filter) so the agent reaches them without each
|
|
||||||
# bottle having to opt in. Pipelock used to own this list; PRD 0017
|
|
||||||
# moves it to egress because egress is the primary gate
|
|
||||||
# now and pipelock's allowlist is mirrored from egress.
|
|
||||||
DEFAULT_ALLOWLIST: tuple[str, ...] = (
|
|
||||||
"api.anthropic.com",
|
|
||||||
"statsig.anthropic.com",
|
|
||||||
"sentry.io",
|
|
||||||
"claude.ai",
|
|
||||||
"platform.claude.com",
|
|
||||||
"downloads.claude.ai",
|
|
||||||
"raw.githubusercontent.com",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def egress_manifest_routes(
|
def egress_manifest_routes(
|
||||||
bottle: Bottle,
|
bottle: Bottle,
|
||||||
) -> tuple[EgressRoute, ...]:
|
) -> tuple[EgressRoute, ...]:
|
||||||
@@ -157,10 +140,9 @@ def egress_manifest_routes(
|
|||||||
shares slot 0. Unauthenticated routes (`auth` omitted) contribute
|
shares slot 0. Unauthenticated routes (`auth` omitted) contribute
|
||||||
no slot.
|
no slot.
|
||||||
|
|
||||||
Does NOT include the folded-in DEFAULT_ALLOWLIST /
|
This is the effective set the addon enforces. Provider runtime
|
||||||
bottle.egress.allowlist bare-pass entries — see
|
routes are intentionally not injected implicitly; every allowed
|
||||||
`egress_routes_for_bottle` for the effective set the
|
host must come from the home-owned bottle manifest."""
|
||||||
addon enforces."""
|
|
||||||
out: list[EgressRoute] = []
|
out: list[EgressRoute] = []
|
||||||
slot_for_token: dict[str, str] = {}
|
slot_for_token: dict[str, str] = {}
|
||||||
for r in bottle.egress.routes:
|
for r in bottle.egress.routes:
|
||||||
@@ -189,26 +171,14 @@ def egress_manifest_routes(
|
|||||||
def egress_routes_for_bottle(
|
def egress_routes_for_bottle(
|
||||||
bottle: Bottle,
|
bottle: Bottle,
|
||||||
) -> tuple[EgressRoute, ...]:
|
) -> tuple[EgressRoute, ...]:
|
||||||
"""Effective egress routes: manifest routes followed by
|
"""Effective egress routes. This is what gets rendered into
|
||||||
bare-pass entries for DEFAULT_ALLOWLIST hosts. This is what
|
routes.yaml + what the addon enforces.
|
||||||
gets rendered into routes.yaml + what the addon enforces.
|
|
||||||
|
|
||||||
Manifest routes win over defaults on host collision (manifest
|
Operators that want to allow a host declare it directly in
|
||||||
routes carry more specific config — auth, path filter, role
|
`bottle.egress.routes` as an authenticated route or bare-pass entry
|
||||||
markers). Hostname comparison is case-insensitive.
|
|
||||||
|
|
||||||
Operators that want to allow an arbitrary host that isn't in
|
|
||||||
DEFAULT_ALLOWLIST declare it directly in
|
|
||||||
`bottle.egress.routes` as a bare-pass entry
|
|
||||||
(`- host: <name>`). The legacy `bottle.egress.allowlist`
|
(`- host: <name>`). The legacy `bottle.egress.allowlist`
|
||||||
folding is gone — egress is the single allowlist surface."""
|
folding is gone — egress is the single allowlist surface."""
|
||||||
out: list[EgressRoute] = list(egress_manifest_routes(bottle))
|
return egress_manifest_routes(bottle)
|
||||||
claimed: set[str] = {r.host.lower() for r in out}
|
|
||||||
for host in DEFAULT_ALLOWLIST:
|
|
||||||
if host.lower() not in claimed:
|
|
||||||
out.append(EgressRoute(host=host))
|
|
||||||
claimed.add(host.lower())
|
|
||||||
return tuple(out)
|
|
||||||
|
|
||||||
|
|
||||||
def egress_token_env_map(
|
def egress_token_env_map(
|
||||||
@@ -327,7 +297,6 @@ class Egress(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_ALLOWLIST",
|
|
||||||
"EGRESS_HOSTNAME",
|
"EGRESS_HOSTNAME",
|
||||||
"EGRESS_ROUTES_IN_CONTAINER",
|
"EGRESS_ROUTES_IN_CONTAINER",
|
||||||
"Egress",
|
"Egress",
|
||||||
@@ -21,7 +21,7 @@ mitmproxy is a container-only dependency. The host's tests target
|
|||||||
Dockerfile.sidecars copies both this file and
|
Dockerfile.sidecars copies both this file and
|
||||||
`egress_addon_core.py` flat into `/app/`; the absolute import
|
`egress_addon_core.py` flat into `/app/`; the absolute import
|
||||||
below works because mitmdump runs with `/app` on its sys.path. The
|
below works because mitmdump runs with `/app` on its sys.path. The
|
||||||
parallel file in the package source tree (claude_bottle/) is the
|
parallel file in the package source tree (bot_bottle/) is the
|
||||||
build input — not a module the host imports."""
|
build input — not a module the host imports."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -19,7 +19,7 @@ from dataclasses import dataclass
|
|||||||
# Absolute import — `yaml_subset.py` is copied flat into the bundle
|
# Absolute import — `yaml_subset.py` is copied flat into the bundle
|
||||||
# image's `/app/` next to this file (via `Dockerfile.sidecars`).
|
# image's `/app/` next to this file (via `Dockerfile.sidecars`).
|
||||||
# The host-side unit tests run with the repo on sys.path, where the
|
# The host-side unit tests run with the repo on sys.path, where the
|
||||||
# import resolves under the `claude_bottle` package. The try/except
|
# import resolves under the `bot_bottle` package. The try/except
|
||||||
# shim picks whichever import works.
|
# shim picks whichever import works.
|
||||||
try:
|
try:
|
||||||
from yaml_subset import YamlSubsetError, parse_yaml_subset # type: ignore[import-not-found]
|
from yaml_subset import YamlSubsetError, parse_yaml_subset # type: ignore[import-not-found]
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
# Egress daemon entrypoint inside the sidecar bundle (PRD 0024).
|
# Egress daemon entrypoint inside the sidecar bundle (PRD 0024).
|
||||||
#
|
#
|
||||||
# Extracted verbatim from Dockerfile.egress's prior inline `sh -c`
|
# Extracted verbatim from Dockerfile.egress's prior inline `sh -c`
|
||||||
# ENTRYPOINT so the supervisor in claude_bottle/sidecar_init.py can
|
# ENTRYPOINT so the supervisor in bot_bottle/sidecar_init.py can
|
||||||
# call it as a normal child. Behavior is unchanged:
|
# call it as a normal child. Behavior is unchanged:
|
||||||
#
|
#
|
||||||
# * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch
|
# * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
# combined trust bundle (system roots + pipelock CA) and point
|
# combined trust bundle (system roots + pipelock CA) and point
|
||||||
# mitmproxy at it. The option REPLACES mitmproxy's default
|
# mitmproxy at it. The option REPLACES mitmproxy's default
|
||||||
# trust store, so passing pipelock's CA alone would break
|
# trust store, so passing pipelock's CA alone would break
|
||||||
# pipelock-passthrough hosts (api.anthropic.com etc.).
|
# route-configured pipelock passthrough hosts.
|
||||||
# * `-s /app/egress_addon.py` loads the addon that reads
|
# * `-s /app/egress_addon.py` loads the addon that reads
|
||||||
# /etc/egress/routes.yaml.
|
# /etc/egress/routes.yaml.
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
|
|||||||
prompt = (
|
prompt = (
|
||||||
f"{prompt_body} (input hidden): "
|
f"{prompt_body} (input hidden): "
|
||||||
if prompt_body
|
if prompt_body
|
||||||
else f"claude-bottle: secret value for {name} (input hidden): "
|
else f"bot-bottle: secret value for {name} (input hidden): "
|
||||||
)
|
)
|
||||||
value = getpass.getpass(prompt, stream=tty)
|
value = getpass.getpass(prompt, stream=tty)
|
||||||
tty.close()
|
tty.close()
|
||||||
@@ -106,7 +106,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
|
|||||||
prompt = (
|
prompt = (
|
||||||
f"{prompt_body} (input hidden): "
|
f"{prompt_body} (input hidden): "
|
||||||
if prompt_body
|
if prompt_body
|
||||||
else f"claude-bottle: secret value for {name} (input hidden): "
|
else f"bot-bottle: secret value for {name} (input hidden): "
|
||||||
)
|
)
|
||||||
value = getpass.getpass(prompt)
|
value = getpass.getpass(prompt)
|
||||||
if not value:
|
if not value:
|
||||||
@@ -25,7 +25,7 @@ land. See `docs/prds/0008-git-gate.md`.
|
|||||||
This module defines the abstract gate (`GitGate`) and its plan
|
This module defines the abstract gate (`GitGate`) and its plan
|
||||||
dataclass (`GitGatePlan`). The sidecar's start/stop lifecycle is
|
dataclass (`GitGatePlan`). The sidecar's start/stop lifecycle is
|
||||||
backend-specific and lives on concrete subclasses (see
|
backend-specific and lives on concrete subclasses (see
|
||||||
`claude_bottle/backend/docker/git_gate.py`)."""
|
`bot_bottle/backend/docker/git_gate.py`)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -71,6 +71,7 @@ class GitGateUpstream:
|
|||||||
upstream_port: str
|
upstream_port: str
|
||||||
identity_file: str
|
identity_file: str
|
||||||
known_host_key: str
|
known_host_key: str
|
||||||
|
known_hosts_file: Path = Path()
|
||||||
extra_hosts: Mapping[str, str] = field(default_factory=_empty_str_map)
|
extra_hosts: Mapping[str, str] = field(default_factory=_empty_str_map)
|
||||||
|
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ def git_gate_render_gitconfig(
|
|||||||
if not entries:
|
if not entries:
|
||||||
return ""
|
return ""
|
||||||
out = [
|
out = [
|
||||||
"# claude-bottle git-gate (PRD 0008): every git operation against\n",
|
"# bot-bottle git-gate (PRD 0008): every git operation against\n",
|
||||||
"# a declared upstream routes through the gate, which mirrors\n",
|
"# a declared upstream routes through the gate, which mirrors\n",
|
||||||
"# the upstream bidirectionally (gitleaks-scanned push;\n",
|
"# the upstream bidirectionally (gitleaks-scanned push;\n",
|
||||||
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
||||||
@@ -166,6 +167,17 @@ def git_gate_render_gitconfig(
|
|||||||
for entry in entries:
|
for entry in entries:
|
||||||
out.append(f'[url "git://{gate_host}/{entry.Name}.git"]\n')
|
out.append(f'[url "git://{gate_host}/{entry.Name}.git"]\n')
|
||||||
out.append(f"\tinsteadOf = {entry.Upstream}\n")
|
out.append(f"\tinsteadOf = {entry.Upstream}\n")
|
||||||
|
if entry.RemoteKey and entry.RemoteKey != entry.UpstreamHost:
|
||||||
|
port = (
|
||||||
|
f":{entry.UpstreamPort}"
|
||||||
|
if entry.UpstreamPort and entry.UpstreamPort != "22"
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
alias = (
|
||||||
|
f"ssh://{entry.UpstreamUser}@{entry.RemoteKey}{port}/"
|
||||||
|
f"{entry.UpstreamPath}"
|
||||||
|
)
|
||||||
|
out.append(f"\tinsteadOf = {alias}\n")
|
||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
@@ -397,11 +409,33 @@ class GitGate(ABC):
|
|||||||
# not via `sh`, so the script needs the x bit. docker cp
|
# not via `sh`, so the script needs the x bit. docker cp
|
||||||
# preserves source mode into the container.
|
# preserves source mode into the container.
|
||||||
access_hook.chmod(0o700)
|
access_hook.chmod(0o700)
|
||||||
|
upstreams_with_files: list[GitGateUpstream] = []
|
||||||
|
for u in upstreams:
|
||||||
|
known_hosts_file = Path()
|
||||||
|
if u.known_host_key:
|
||||||
|
known_hosts_file = stage_dir / f"{u.name}-known_hosts"
|
||||||
|
known_hosts_file.write_text(
|
||||||
|
git_gate_known_hosts_line(
|
||||||
|
u.upstream_host, u.upstream_port, u.known_host_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
known_hosts_file.chmod(0o600)
|
||||||
|
upstreams_with_files.append(
|
||||||
|
GitGateUpstream(
|
||||||
|
name=u.name,
|
||||||
|
upstream_url=u.upstream_url,
|
||||||
|
upstream_host=u.upstream_host,
|
||||||
|
upstream_port=u.upstream_port,
|
||||||
|
identity_file=u.identity_file,
|
||||||
|
known_host_key=u.known_host_key,
|
||||||
|
known_hosts_file=known_hosts_file,
|
||||||
|
extra_hosts=dict(u.extra_hosts),
|
||||||
|
)
|
||||||
|
)
|
||||||
return GitGatePlan(
|
return GitGatePlan(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
entrypoint_script=entrypoint,
|
entrypoint_script=entrypoint,
|
||||||
hook_script=hook,
|
hook_script=hook,
|
||||||
access_hook_script=access_hook,
|
access_hook_script=access_hook,
|
||||||
upstreams=upstreams,
|
upstreams=tuple(upstreams_with_files),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -7,11 +7,11 @@ from typing import NoReturn
|
|||||||
|
|
||||||
|
|
||||||
def info(msg: str) -> None:
|
def info(msg: str) -> None:
|
||||||
print(f"claude-bottle: {msg}", file=sys.stderr)
|
print(f"bot-bottle: {msg}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def warn(msg: str) -> None:
|
def warn(msg: str) -> None:
|
||||||
print(f"claude-bottle: warning: {msg}", file=sys.stderr)
|
print(f"bot-bottle: warning: {msg}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
class Die(SystemExit):
|
class Die(SystemExit):
|
||||||
@@ -20,5 +20,5 @@ class Die(SystemExit):
|
|||||||
|
|
||||||
|
|
||||||
def die(msg: str) -> NoReturn:
|
def die(msg: str) -> NoReturn:
|
||||||
print(f"claude-bottle: error: {msg}", file=sys.stderr)
|
print(f"bot-bottle: error: {msg}", file=sys.stderr)
|
||||||
raise Die(1)
|
raise Die(1)
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Reads the per-file manifest tree:
|
Reads the per-file manifest tree:
|
||||||
|
|
||||||
$HOME/.claude-bottle/bottles/<name>.md — one bottle per file
|
$HOME/.bot-bottle/bottles/<name>.md — one bottle per file
|
||||||
$HOME/.claude-bottle/agents/<name>.md — home-resident agents
|
$HOME/.bot-bottle/agents/<name>.md — home-resident agents
|
||||||
$CWD/.claude-bottle/agents/<name>.md — cwd-supplied agents
|
$CWD/.bot-bottle/agents/<name>.md — cwd-supplied agents
|
||||||
|
|
||||||
Each file is Markdown with YAML frontmatter. The frontmatter holds
|
Each file is Markdown with YAML frontmatter. The frontmatter holds
|
||||||
the structured config (see schema below); for agents the body is
|
the structured config (see schema below); for agents the body is
|
||||||
@@ -18,6 +18,8 @@ Bottle schema (frontmatter):
|
|||||||
user: { name: <str>, email: <str> } # optional
|
user: { name: <str>, email: <str> } # optional
|
||||||
remotes: { <host>: <git-entry>, ... } # optional
|
remotes: { <host>: <git-entry>, ... } # optional
|
||||||
egress: { routes: [ <egress-route>, ... ] }
|
egress: { routes: [ <egress-route>, ... ] }
|
||||||
|
# route keys: host, path_allowlist, auth, role, pipelock
|
||||||
|
# pipelock: { tls_passthrough: <bool>, ssrf_ip_allowlist: [<cidr>, ...] }
|
||||||
supervise: <bool> # optional
|
supervise: <bool> # optional
|
||||||
|
|
||||||
Agent schema (frontmatter):
|
Agent schema (frontmatter):
|
||||||
@@ -41,6 +43,7 @@ on-disk files.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -48,6 +51,7 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Mapping, cast
|
from typing import Mapping, cast
|
||||||
|
|
||||||
|
from .agent_provider import PROVIDER_TEMPLATES
|
||||||
from .log import die, warn
|
from .log import die, warn
|
||||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||||
|
|
||||||
@@ -81,6 +85,7 @@ class GitEntry:
|
|||||||
IdentityFile: str
|
IdentityFile: str
|
||||||
KnownHostKey: str = ""
|
KnownHostKey: str = ""
|
||||||
ExtraHosts: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
ExtraHosts: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||||
|
RemoteKey: str = ""
|
||||||
UpstreamUser: str = ""
|
UpstreamUser: str = ""
|
||||||
UpstreamHost: str = ""
|
UpstreamHost: str = ""
|
||||||
UpstreamPort: str = ""
|
UpstreamPort: str = ""
|
||||||
@@ -139,7 +144,11 @@ class GitEntry:
|
|||||||
user, host, port, path = _parse_git_upstream(
|
user, host, port, path = _parse_git_upstream(
|
||||||
upstream, f"bottle '{bottle_name}' {label} '{name}' Upstream"
|
upstream, f"bottle '{bottle_name}' {label} '{name}' Upstream"
|
||||||
)
|
)
|
||||||
if host_key is not None and host_key != host:
|
if (
|
||||||
|
host_key is not None
|
||||||
|
and host_key != host
|
||||||
|
and not _is_ip_literal(host)
|
||||||
|
):
|
||||||
die(
|
die(
|
||||||
f"bottle '{bottle_name}' git.remotes key {host_key!r} "
|
f"bottle '{bottle_name}' git.remotes key {host_key!r} "
|
||||||
f"does not match Upstream host {host!r}"
|
f"does not match Upstream host {host!r}"
|
||||||
@@ -150,6 +159,7 @@ class GitEntry:
|
|||||||
IdentityFile=ident,
|
IdentityFile=ident,
|
||||||
KnownHostKey=khk,
|
KnownHostKey=khk,
|
||||||
ExtraHosts=extra_hosts,
|
ExtraHosts=extra_hosts,
|
||||||
|
RemoteKey=host_key or host,
|
||||||
UpstreamUser=user,
|
UpstreamUser=user,
|
||||||
UpstreamHost=host,
|
UpstreamHost=host,
|
||||||
UpstreamPort=port,
|
UpstreamPort=port,
|
||||||
@@ -180,6 +190,7 @@ EGRESS_AUTH_SCHEMES = ("Bearer", "token")
|
|||||||
# special happens on the agent side.
|
# special happens on the agent side.
|
||||||
EGRESS_ROLES = frozenset({
|
EGRESS_ROLES = frozenset({
|
||||||
"claude_code_oauth",
|
"claude_code_oauth",
|
||||||
|
"codex_auth",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Singleton roles may appear on at most one route per bottle.
|
# Singleton roles may appear on at most one route per bottle.
|
||||||
@@ -188,8 +199,55 @@ EGRESS_ROLES = frozenset({
|
|||||||
# ambiguous for any future role-aware logic.
|
# ambiguous for any future role-aware logic.
|
||||||
EGRESS_SINGLETON_ROLES = frozenset({
|
EGRESS_SINGLETON_ROLES = frozenset({
|
||||||
"claude_code_oauth",
|
"claude_code_oauth",
|
||||||
|
"codex_auth",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
PROVIDER_EGRESS_ROLES = {
|
||||||
|
"claude": frozenset({"claude_code_oauth"}),
|
||||||
|
"codex": frozenset({"codex_auth"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentProvider:
|
||||||
|
"""Provider/template for the agent process inside a bottle.
|
||||||
|
|
||||||
|
`template` selects a built-in launch/runtime contract. `dockerfile`
|
||||||
|
optionally points at a custom agent-image Dockerfile while leaving
|
||||||
|
bot-bottle's sidecar infrastructure intact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template: str = "claude"
|
||||||
|
dockerfile: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider":
|
||||||
|
d = _as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
|
||||||
|
for k in d:
|
||||||
|
if k not in {"template", "dockerfile"}:
|
||||||
|
die(
|
||||||
|
f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; "
|
||||||
|
f"allowed: template, dockerfile"
|
||||||
|
)
|
||||||
|
template = d.get("template", "claude")
|
||||||
|
if not isinstance(template, str) or not template:
|
||||||
|
die(
|
||||||
|
f"bottle '{bottle_name}' agent_provider.template must be a "
|
||||||
|
f"non-empty string"
|
||||||
|
)
|
||||||
|
if template not in PROVIDER_TEMPLATES:
|
||||||
|
die(
|
||||||
|
f"bottle '{bottle_name}' agent_provider.template {template!r} "
|
||||||
|
f"is not one of {', '.join(sorted(PROVIDER_TEMPLATES))}"
|
||||||
|
)
|
||||||
|
dockerfile = d.get("dockerfile", "")
|
||||||
|
if not isinstance(dockerfile, str):
|
||||||
|
die(
|
||||||
|
f"bottle '{bottle_name}' agent_provider.dockerfile must be a "
|
||||||
|
f"string (was {type(dockerfile).__name__})"
|
||||||
|
)
|
||||||
|
return cls(template=template, dockerfile=dockerfile)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GitUser:
|
class GitUser:
|
||||||
@@ -270,6 +328,68 @@ def _parse_git_config(
|
|||||||
return git, git_user
|
return git, git_user
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PipelockRoutePolicy:
|
||||||
|
"""Per-route pipelock policy overrides.
|
||||||
|
|
||||||
|
`TlsPassthrough` adds the route host to pipelock's
|
||||||
|
`tls_interception.passthrough_domains`, so pipelock still enforces
|
||||||
|
the hostname allowlist but does not MITM/decrypt request bodies or
|
||||||
|
headers for that host.
|
||||||
|
|
||||||
|
`SsrfIpAllowlist` adds explicit IPs/CIDRs to pipelock's SSRF
|
||||||
|
allowlist for private/internal destinations behind this route.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TlsPassthrough: bool = False
|
||||||
|
SsrfIpAllowlist: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(
|
||||||
|
cls, bottle_name: str, idx: int, raw: object,
|
||||||
|
) -> "PipelockRoutePolicy":
|
||||||
|
label = f"bottle '{bottle_name}' egress.routes[{idx}] pipelock"
|
||||||
|
d = _as_json_object(raw, label)
|
||||||
|
for k in d:
|
||||||
|
if k not in ("tls_passthrough", "ssrf_ip_allowlist"):
|
||||||
|
die(
|
||||||
|
f"{label} has unknown key {k!r}; "
|
||||||
|
f"only 'tls_passthrough' and 'ssrf_ip_allowlist' "
|
||||||
|
f"are accepted"
|
||||||
|
)
|
||||||
|
tls_passthrough_raw = d.get("tls_passthrough", False)
|
||||||
|
if not isinstance(tls_passthrough_raw, bool):
|
||||||
|
die(
|
||||||
|
f"{label}.tls_passthrough must be a boolean "
|
||||||
|
f"(was {type(tls_passthrough_raw).__name__})"
|
||||||
|
)
|
||||||
|
ssrf_raw = d.get("ssrf_ip_allowlist", [])
|
||||||
|
if not isinstance(ssrf_raw, list):
|
||||||
|
die(
|
||||||
|
f"{label}.ssrf_ip_allowlist must be an array "
|
||||||
|
f"(was {type(ssrf_raw).__name__})"
|
||||||
|
)
|
||||||
|
ssrf_ip_allowlist: list[str] = []
|
||||||
|
for j, item in enumerate(ssrf_raw):
|
||||||
|
if not isinstance(item, str) or not item:
|
||||||
|
die(
|
||||||
|
f"{label}.ssrf_ip_allowlist[{j}] must be a non-empty "
|
||||||
|
f"string (was {type(item).__name__})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(item, strict=False)
|
||||||
|
except ValueError as e:
|
||||||
|
die(
|
||||||
|
f"{label}.ssrf_ip_allowlist[{j}] must be an IP address "
|
||||||
|
f"or CIDR (was {item!r}): {e}"
|
||||||
|
)
|
||||||
|
ssrf_ip_allowlist.append(item)
|
||||||
|
return cls(
|
||||||
|
TlsPassthrough=tls_passthrough_raw,
|
||||||
|
SsrfIpAllowlist=tuple(ssrf_ip_allowlist),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EgressRoute:
|
class EgressRoute:
|
||||||
"""One route on the per-bottle egress sidecar (PRD 0017).
|
"""One route on the per-bottle egress sidecar (PRD 0017).
|
||||||
@@ -306,6 +426,7 @@ class EgressRoute:
|
|||||||
AuthScheme: str = ""
|
AuthScheme: str = ""
|
||||||
TokenRef: str = ""
|
TokenRef: str = ""
|
||||||
Role: tuple[str, ...] = ()
|
Role: tuple[str, ...] = ()
|
||||||
|
Pipelock: PipelockRoutePolicy = field(default_factory=PipelockRoutePolicy)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute":
|
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute":
|
||||||
@@ -402,11 +523,17 @@ class EgressRoute:
|
|||||||
f"{', '.join(sorted(EGRESS_ROLES))}"
|
f"{', '.join(sorted(EGRESS_ROLES))}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pipelock = (
|
||||||
|
PipelockRoutePolicy.from_dict(bottle_name, idx, d["pipelock"])
|
||||||
|
if "pipelock" in d
|
||||||
|
else PipelockRoutePolicy()
|
||||||
|
)
|
||||||
|
|
||||||
for k in d:
|
for k in d:
|
||||||
if k not in ("host", "path_allowlist", "auth", "role"):
|
if k not in ("host", "path_allowlist", "auth", "role", "pipelock"):
|
||||||
die(
|
die(
|
||||||
f"{label} has unknown key {k!r}; accepted keys are "
|
f"{label} has unknown key {k!r}; accepted keys are "
|
||||||
f"'host', 'path_allowlist', 'auth', 'role'"
|
f"'host', 'path_allowlist', 'auth', 'role', 'pipelock'"
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
@@ -415,6 +542,7 @@ class EgressRoute:
|
|||||||
AuthScheme=auth_scheme,
|
AuthScheme=auth_scheme,
|
||||||
TokenRef=token_ref,
|
TokenRef=token_ref,
|
||||||
Role=roles,
|
Role=roles,
|
||||||
|
Pipelock=pipelock,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -428,7 +556,9 @@ class EgressConfig:
|
|||||||
routes: tuple[EgressRoute, ...] = ()
|
routes: tuple[EgressRoute, ...] = ()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig":
|
def from_dict(
|
||||||
|
cls, bottle_name: str, raw: object, *, agent_provider_template: str = "claude",
|
||||||
|
) -> "EgressConfig":
|
||||||
d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
|
d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
|
||||||
routes_raw = d.get("routes")
|
routes_raw = d.get("routes")
|
||||||
routes: tuple[EgressRoute, ...] = ()
|
routes: tuple[EgressRoute, ...] = ()
|
||||||
@@ -443,7 +573,9 @@ class EgressConfig:
|
|||||||
EgressRoute.from_dict(bottle_name, i, entry)
|
EgressRoute.from_dict(bottle_name, i, entry)
|
||||||
for i, entry in enumerate(routes_list)
|
for i, entry in enumerate(routes_list)
|
||||||
)
|
)
|
||||||
_validate_egress_routes(bottle_name, routes)
|
_validate_egress_routes(
|
||||||
|
bottle_name, routes, agent_provider_template=agent_provider_template,
|
||||||
|
)
|
||||||
for k in d:
|
for k in d:
|
||||||
if k != "routes":
|
if k != "routes":
|
||||||
die(
|
die(
|
||||||
@@ -456,6 +588,7 @@ class EgressConfig:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Bottle:
|
class Bottle:
|
||||||
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||||
|
agent_provider: AgentProvider = field(default_factory=AgentProvider)
|
||||||
git: tuple[GitEntry, ...] = ()
|
git: tuple[GitEntry, ...] = ()
|
||||||
# Per-bottle git identity (issue #86). Empty default — bottles
|
# Per-bottle git identity (issue #86). Empty default — bottles
|
||||||
# that don't set `git.user:` in the manifest skip the
|
# that don't set `git.user:` in the manifest skip the
|
||||||
@@ -469,7 +602,7 @@ class Bottle:
|
|||||||
# MCP tools to the agent (cred-proxy-block, pipelock-block,
|
# MCP tools to the agent (cred-proxy-block, pipelock-block,
|
||||||
# capability-block; the cred-proxy-block tool is renamed and
|
# capability-block; the cred-proxy-block tool is renamed and
|
||||||
# retargeted at egress in PRD 0017 chunk 3) plus mounts the
|
# retargeted at egress in PRD 0017 chunk 3) plus mounts the
|
||||||
# current-config dir read-only into the agent at /etc/claude-bottle/
|
# current-config dir read-only into the agent at /etc/bot-bottle/
|
||||||
# current-config. False (the default) skips the sidecar and mount.
|
# current-config. False (the default) skips the sidecar and mount.
|
||||||
supervise: bool = False
|
supervise: bool = False
|
||||||
|
|
||||||
@@ -526,8 +659,17 @@ class Bottle:
|
|||||||
if git_raw is not None:
|
if git_raw is not None:
|
||||||
git, git_user = _parse_git_config(name, git_raw)
|
git, git_user = _parse_git_config(name, git_raw)
|
||||||
|
|
||||||
|
agent_provider = (
|
||||||
|
AgentProvider.from_dict(name, d["agent_provider"])
|
||||||
|
if "agent_provider" in d
|
||||||
|
else AgentProvider()
|
||||||
|
)
|
||||||
|
|
||||||
egress = (
|
egress = (
|
||||||
EgressConfig.from_dict(name, d["egress"])
|
EgressConfig.from_dict(
|
||||||
|
name, d["egress"],
|
||||||
|
agent_provider_template=agent_provider.template,
|
||||||
|
)
|
||||||
if "egress" in d
|
if "egress" in d
|
||||||
else EgressConfig()
|
else EgressConfig()
|
||||||
)
|
)
|
||||||
@@ -540,8 +682,8 @@ class Bottle:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
env=env, git=git, git_user=git_user, egress=egress,
|
env=env, agent_provider=agent_provider, git=git,
|
||||||
supervise=supervise_raw,
|
git_user=git_user, egress=egress, supervise=supervise_raw,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -598,34 +740,41 @@ class Manifest:
|
|||||||
agents: Mapping[str, Agent]
|
agents: Mapping[str, Agent]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def resolve(cls, cwd: str) -> "Manifest":
|
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
|
||||||
"""Walk the per-file manifest tree and build a Manifest.
|
"""Walk the per-file manifest tree and build a Manifest.
|
||||||
|
|
||||||
Layout (PRD 0011):
|
Layout (PRD 0011):
|
||||||
$HOME/.claude-bottle/bottles/<name>.md — bottles (home-only)
|
$HOME/.bot-bottle/bottles/<name>.md — bottles (home-only)
|
||||||
$HOME/.claude-bottle/agents/<name>.md — home agents
|
$HOME/.bot-bottle/agents/<name>.md — home agents
|
||||||
$CWD/.claude-bottle/agents/<name>.md — cwd agents
|
$CWD/.bot-bottle/agents/<name>.md — cwd agents
|
||||||
|
|
||||||
Cwd agents merge into the home agents on the same name
|
Cwd agents merge into the home agents on the same name
|
||||||
(cwd wins). A bottles/ subdir under $CWD is logged as a
|
(cwd wins). A bottles/ subdir under $CWD is logged as a
|
||||||
warning and ignored — the filesystem layout IS the trust
|
warning and ignored — the filesystem layout IS the trust
|
||||||
boundary.
|
boundary.
|
||||||
|
|
||||||
If `claude-bottle.json` exists alongside a missing
|
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
|
||||||
`.claude-bottle/` directory at either side, dies with a
|
returns an empty manifest instead of dying. This is for
|
||||||
|
passive UI surfaces like the dashboard, which can still
|
||||||
|
monitor already-running agents without launch config.
|
||||||
|
|
||||||
|
If `bot-bottle.json` exists alongside a missing
|
||||||
|
`.bot-bottle/` directory at either side, dies with a
|
||||||
clear pointer at the README's manifest section — the
|
clear pointer at the README's manifest section — the
|
||||||
manifest format changed in PRD 0011 and we don't silently
|
manifest format changed in PRD 0011 and we don't silently
|
||||||
fall back."""
|
fall back."""
|
||||||
home_dir = Path(os.environ["HOME"])
|
home_dir = Path(os.environ["HOME"])
|
||||||
cwd_dir = Path(cwd)
|
cwd_dir = Path(cwd)
|
||||||
home_md = home_dir / ".claude-bottle"
|
home_md = home_dir / ".bot-bottle"
|
||||||
cwd_md = cwd_dir / ".claude-bottle"
|
cwd_md = cwd_dir / ".bot-bottle"
|
||||||
|
|
||||||
_check_stale_json(home_dir, home_md, "$HOME")
|
_check_stale_json(home_dir, home_md, "$HOME")
|
||||||
if cwd_dir.resolve() != home_dir.resolve():
|
if cwd_dir.resolve() != home_dir.resolve():
|
||||||
_check_stale_json(cwd_dir, cwd_md, "$CWD")
|
_check_stale_json(cwd_dir, cwd_md, "$CWD")
|
||||||
|
|
||||||
if not home_md.is_dir():
|
if not home_md.is_dir():
|
||||||
|
if missing_ok:
|
||||||
|
return cls.from_json_obj({"bottles": {}, "agents": {}})
|
||||||
die(
|
die(
|
||||||
f"no manifest found: {home_md} does not exist. "
|
f"no manifest found: {home_md} does not exist. "
|
||||||
f"See README.md for the per-file Markdown layout "
|
f"See README.md for the per-file Markdown layout "
|
||||||
@@ -668,7 +817,7 @@ class Manifest:
|
|||||||
warn(
|
warn(
|
||||||
f"ignoring bottle file(s) under "
|
f"ignoring bottle file(s) under "
|
||||||
f"{stale_bottles}: {names}. Bottles can only "
|
f"{stale_bottles}: {names}. Bottles can only "
|
||||||
f"live under $HOME/.claude-bottle/bottles/ "
|
f"live under $HOME/.bot-bottle/bottles/ "
|
||||||
f"(PRD 0011). Move them or delete."
|
f"(PRD 0011). Move them or delete."
|
||||||
)
|
)
|
||||||
cwd_agents_dir = cwd_dir / "agents"
|
cwd_agents_dir = cwd_dir / "agents"
|
||||||
@@ -708,8 +857,8 @@ class Manifest:
|
|||||||
return
|
return
|
||||||
available = ", ".join(self.agents.keys())
|
available = ", ".join(self.agents.keys())
|
||||||
if available:
|
if available:
|
||||||
die(f"agent '{name}' not defined in claude-bottle.json. Available: {available}")
|
die(f"agent '{name}' not defined in bot-bottle.json. Available: {available}")
|
||||||
die(f"agent '{name}' not defined in claude-bottle.json (manifest is empty).")
|
die(f"agent '{name}' not defined in bot-bottle.json (manifest is empty).")
|
||||||
|
|
||||||
def has_bottle(self, name: str) -> bool:
|
def has_bottle(self, name: str) -> bool:
|
||||||
return name in self.bottles
|
return name in self.bottles
|
||||||
@@ -720,10 +869,10 @@ class Manifest:
|
|||||||
available = ", ".join(self.bottles.keys())
|
available = ", ".join(self.bottles.keys())
|
||||||
if available:
|
if available:
|
||||||
die(
|
die(
|
||||||
f"bottle '{name}' not defined in claude-bottle.json. "
|
f"bottle '{name}' not defined in bot-bottle.json. "
|
||||||
f"Available bottles: {available}"
|
f"Available bottles: {available}"
|
||||||
)
|
)
|
||||||
die(f"bottle '{name}' not defined in claude-bottle.json (no bottles defined).")
|
die(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).")
|
||||||
|
|
||||||
def bottle_for(self, agent_name: str) -> Bottle:
|
def bottle_for(self, agent_name: str) -> Bottle:
|
||||||
"""Resolve the Bottle the named agent references. The validator
|
"""Resolve the Bottle the named agent references. The validator
|
||||||
@@ -759,8 +908,8 @@ def _load_json_or_die(path: Path) -> dict[str, object]:
|
|||||||
with path.open() as f:
|
with path.open() as f:
|
||||||
doc: object = json.load(f)
|
doc: object = json.load(f)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
die(f"claude-bottle.json at {path} is not valid JSON")
|
die(f"bot-bottle.json at {path} is not valid JSON")
|
||||||
return _as_json_object(doc, f"claude-bottle.json at {path}")
|
return _as_json_object(doc, f"bot-bottle.json at {path}")
|
||||||
|
|
||||||
|
|
||||||
def _opt_str(value: object, label: str) -> str:
|
def _opt_str(value: object, label: str) -> str:
|
||||||
@@ -820,9 +969,19 @@ def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
|
|||||||
return (user, host, port, path)
|
return (user, host, port, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ip_literal(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _validate_egress_routes(
|
def _validate_egress_routes(
|
||||||
bottle_name: str,
|
bottle_name: str,
|
||||||
routes: tuple[EgressRoute, ...],
|
routes: tuple[EgressRoute, ...],
|
||||||
|
*,
|
||||||
|
agent_provider_template: str = "claude",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Cross-validation for `bottle.egress.routes`:
|
"""Cross-validation for `bottle.egress.routes`:
|
||||||
|
|
||||||
@@ -854,6 +1013,16 @@ def _validate_egress_routes(
|
|||||||
f"routes with role {role!r} (hosts: {hosts}); this role drives a "
|
f"routes with role {role!r} (hosts: {hosts}); this role drives a "
|
||||||
f"single launch-step side effect — pick one."
|
f"single launch-step side effect — pick one."
|
||||||
)
|
)
|
||||||
|
allowed_roles = PROVIDER_EGRESS_ROLES[agent_provider_template]
|
||||||
|
for route in routes:
|
||||||
|
for role in route.Role:
|
||||||
|
if role not in allowed_roles:
|
||||||
|
die(
|
||||||
|
f"bottle '{bottle_name}' egress route for host "
|
||||||
|
f"{route.Host!r} has role {role!r}, but provider "
|
||||||
|
f"{agent_provider_template!r} only accepts roles "
|
||||||
|
f"{', '.join(sorted(allowed_roles)) or '(none)'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
|
def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
|
||||||
@@ -881,11 +1050,11 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
|
|||||||
# sets dies with a "did you mean" pointer — typos shouldn't silently
|
# sets dies with a "did you mean" pointer — typos shouldn't silently
|
||||||
# ghost into an empty config.
|
# ghost into an empty config.
|
||||||
_BOTTLE_KEYS = frozenset(
|
_BOTTLE_KEYS = frozenset(
|
||||||
{"env", "extends", "git", "egress", "supervise"}
|
{"env", "extends", "agent_provider", "git", "egress", "supervise"}
|
||||||
)
|
)
|
||||||
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
||||||
_AGENT_KEYS_OPTIONAL = frozenset({"skills"})
|
_AGENT_KEYS_OPTIONAL = frozenset({"skills"})
|
||||||
# Claude Code subagent fields claude-bottle ignores at launch but
|
# Claude Code subagent fields bot-bottle ignores at launch but
|
||||||
# doesn't reject — lets the same file double as `~/.claude/agents/*.md`.
|
# doesn't reject — lets the same file double as `~/.claude/agents/*.md`.
|
||||||
_AGENT_KEYS_CC_PASSTHROUGH = frozenset({
|
_AGENT_KEYS_CC_PASSTHROUGH = frozenset({
|
||||||
"name", "description", "model", "color", "memory",
|
"name", "description", "model", "color", "memory",
|
||||||
@@ -896,10 +1065,10 @@ _AGENT_KEYS = (
|
|||||||
|
|
||||||
|
|
||||||
def _check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
def _check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
||||||
"""Die if `<dir_path>/claude-bottle.json` exists but `md_dir` does
|
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
|
||||||
not — the manifest format changed in PRD 0011 and we don't want
|
not — the manifest format changed in PRD 0011 and we don't want
|
||||||
to silently leave the JSON content unused."""
|
to silently leave the JSON content unused."""
|
||||||
legacy = dir_path / "claude-bottle.json"
|
legacy = dir_path / "bot-bottle.json"
|
||||||
if legacy.is_file() and not md_dir.exists():
|
if legacy.is_file() and not md_dir.exists():
|
||||||
die(
|
die(
|
||||||
f"found {legacy} but {md_dir} does not exist. The manifest "
|
f"found {legacy} but {md_dir} does not exist. The manifest "
|
||||||
@@ -1056,12 +1225,22 @@ def _merge_bottles(
|
|||||||
# Presence-driven full-replace for the remaining list-valued +
|
# Presence-driven full-replace for the remaining list-valued +
|
||||||
# scalar fields.
|
# scalar fields.
|
||||||
merged_egress = child.egress if "egress" in child_raw else parent.egress
|
merged_egress = child.egress if "egress" in child_raw else parent.egress
|
||||||
|
merged_agent_provider = (
|
||||||
|
child.agent_provider
|
||||||
|
if "agent_provider" in child_raw
|
||||||
|
else parent.agent_provider
|
||||||
|
)
|
||||||
merged_supervise = (
|
merged_supervise = (
|
||||||
child.supervise if "supervise" in child_raw else parent.supervise
|
child.supervise if "supervise" in child_raw else parent.supervise
|
||||||
)
|
)
|
||||||
|
_validate_egress_routes(
|
||||||
|
name, merged_egress.routes,
|
||||||
|
agent_provider_template=merged_agent_provider.template,
|
||||||
|
)
|
||||||
|
|
||||||
return Bottle(
|
return Bottle(
|
||||||
env=merged_env,
|
env=merged_env,
|
||||||
|
agent_provider=merged_agent_provider,
|
||||||
git=merged_git,
|
git=merged_git,
|
||||||
git_user=merged_git_user,
|
git_user=merged_git_user,
|
||||||
egress=merged_egress,
|
egress=merged_egress,
|
||||||
@@ -21,29 +21,15 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from .egress import (
|
from .egress import EGRESS_HOSTNAME, egress_routes_for_bottle
|
||||||
DEFAULT_ALLOWLIST,
|
|
||||||
EGRESS_HOSTNAME,
|
|
||||||
egress_routes_for_bottle,
|
|
||||||
)
|
|
||||||
from .supervise import SUPERVISE_HOSTNAME
|
from .supervise import SUPERVISE_HOSTNAME
|
||||||
from .manifest import Bottle
|
from .manifest import Bottle
|
||||||
|
|
||||||
# Hosts pipelock should NOT TLS-MITM, even when tls_interception is
|
# Hosts pipelock should NOT TLS-MITM, even when tls_interception is
|
||||||
# enabled. The Claude API endpoint is an LLM provider — its request
|
# enabled. This is now route-owned manifest policy via
|
||||||
# bodies are user-authored conversation text that legitimately can
|
# `egress.routes[].pipelock.tls_passthrough`; no provider hosts are
|
||||||
# trigger DLP scanners (notably the BIP-39 seed-phrase detector, which
|
# injected implicitly.
|
||||||
# fires on any 12+ consecutive English words that happen to be on the
|
DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = ()
|
||||||
# BIP-39 wordlist and pass the checksum). Per pipelock's own
|
|
||||||
# configuration.md, the recommended treatment for LLM API endpoints is
|
|
||||||
# `passthrough_domains`: pipelock still proxies the CONNECT (so the
|
|
||||||
# api_allowlist gate applies), but it does not generate a leaf cert or
|
|
||||||
# decrypt the body. Body scanning happens on hosts that aren't
|
|
||||||
# passthrough'd, so DLP protection against agent exfil to other
|
|
||||||
# allowlisted hosts is unchanged.
|
|
||||||
DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = (
|
|
||||||
"api.anthropic.com",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# In-container paths the rendered pipelock YAML references under
|
# In-container paths the rendered pipelock YAML references under
|
||||||
@@ -67,12 +53,11 @@ PIPELOCK_HOSTNAME = "pipelock"
|
|||||||
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
||||||
"""Hostnames pipelock allows. Sorted for stability.
|
"""Hostnames pipelock allows. Sorted for stability.
|
||||||
|
|
||||||
Always mirrors `egress_routes_for_bottle(bottle)` — the
|
Always mirrors `egress_routes_for_bottle(bottle)` — egress is the
|
||||||
egress is the single allowlist surface; pipelock's
|
single allowlist surface, and pipelock's allowlist is the downstream
|
||||||
allowlist is the downstream copy for defense-in-depth + DLP
|
copy for defense-in-depth + DLP body scanning. For bottles without
|
||||||
body scanning. For bottles without any `egress.routes[]`
|
any `egress.routes[]` declared, this is empty except for supervise
|
||||||
declared, this is just the baked DEFAULT_ALLOWLIST that
|
sidecar traffic when `supervise: true`.
|
||||||
egress_routes_for_bottle always folds in.
|
|
||||||
|
|
||||||
The supervise sidecar's hostname is auto-added when supervise
|
The supervise sidecar's hostname is auto-added when supervise
|
||||||
is enabled (sibling-sidecar traffic that flows through pipelock
|
is enabled (sibling-sidecar traffic that flows through pipelock
|
||||||
@@ -89,14 +74,13 @@ def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
|
def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
|
||||||
"""Whether pipelock's BIP-39 seed-phrase detector stays on for
|
"""Whether pipelock's BIP-39 seed-phrase detector stays on.
|
||||||
this bottle.
|
|
||||||
|
|
||||||
LLM conversation bodies legitimately trip the detector — any 12+
|
LLM conversation bodies legitimately trip the detector — any 12+
|
||||||
English words that pass the BIP-39 checksum match — so any
|
English words that pass the BIP-39 checksum match — so agents can
|
||||||
bottle that routes claude through pipelock's body scanner gets
|
get blocked on ordinary prompts/responses regardless of provider
|
||||||
blocked on the first real chat. We tried two narrower knobs
|
(Claude, Codex/OpenAI, or future harnesses). We tried two narrower
|
||||||
first:
|
knobs first:
|
||||||
|
|
||||||
- `suppress: [{rule, path}]` — pipelock accepts the schema
|
- `suppress: [{rule, path}]` — pipelock accepts the schema
|
||||||
but the entry only silences the alert; the body_dlp block
|
but the entry only silences the alert; the body_dlp block
|
||||||
@@ -107,38 +91,43 @@ def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
|
|||||||
Empirically only `seed_phrase_detection.enabled: false`
|
Empirically only `seed_phrase_detection.enabled: false`
|
||||||
actually stops the block (verified by sending a 12-word BIP-39
|
actually stops the block (verified by sending a 12-word BIP-39
|
||||||
body through three pipelock instances). It is a global toggle —
|
body through three pipelock instances). It is a global toggle —
|
||||||
no per-path / per-host knob in pipelock 2.3.0 — so we turn the
|
no per-path / per-host knob in pipelock 2.3.0 — so we turn off
|
||||||
detector off for the entire bottle when the bottle declares an
|
only this detector for every bottle. The rest of pipelock's DLP
|
||||||
egress route to `api.anthropic.com`. The trade-off is
|
defaults and request-body/header scanning remain enabled."""
|
||||||
accepted: BIP-39 detection has little value in claude-bottle's
|
del bottle # kept for call-site stability and future policy knobs.
|
||||||
threat model (the agent has no access to a user's crypto wallet
|
return False
|
||||||
seeds; the patterns that matter — gh*_, sk-ant-, AKIA, etc. —
|
|
||||||
keep firing)."""
|
|
||||||
return not any(
|
|
||||||
r.Host == "api.anthropic.com" for r in bottle.egress.routes
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]:
|
def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]:
|
||||||
"""Hostnames pipelock should pass through (no TLS MITM, no body
|
"""Hostnames pipelock should pass through (no TLS MITM).
|
||||||
scan). Default carries the LLM API endpoint — its request bodies
|
|
||||||
are user-authored conversation text that legitimately trips DLP
|
|
||||||
scanners (notably pipelock's BIP-39 seed-phrase detector). Every
|
|
||||||
other allowlisted host is MITM'd by pipelock's per-bottle CA so
|
|
||||||
its body scanner sees the cleartext.
|
|
||||||
|
|
||||||
egress route hosts (github, gitea, npm) are deliberately
|
A route opts in with `pipelock.tls_passthrough: true`. This is
|
||||||
NOT auto-added here. egress's HTTPS client trusts pipelock's
|
useful for provider API routes where egress injects the
|
||||||
CA at runtime (folded into its trust store via docker cp), so
|
Authorization header after the agent boundary; pipelock still
|
||||||
pipelock MITMs and body-scans the egress → upstream leg the
|
enforces the host allowlist but does not decrypt and scan that
|
||||||
same way it body-scanned the agent's direct HTTPS traffic before
|
provider request.
|
||||||
the PRD 0017 cutover.
|
"""
|
||||||
|
seen: dict[str, None] = {host: None for host in DEFAULT_TLS_PASSTHROUGH}
|
||||||
|
for route in bottle.egress.routes:
|
||||||
|
if route.Pipelock.TlsPassthrough:
|
||||||
|
seen.setdefault(route.Host, None)
|
||||||
|
return sorted(seen.keys())
|
||||||
|
|
||||||
`bottle` is kept on the signature for forward-compat (a future
|
|
||||||
knob might let a manifest opt a host into passthrough); today
|
def pipelock_effective_ssrf_ip_allowlist(
|
||||||
the returned list is independent of the bottle."""
|
bottle: Bottle,
|
||||||
del bottle # not consulted; see docstring.
|
extra: tuple[str, ...] = (),
|
||||||
return sorted(DEFAULT_TLS_PASSTHROUGH)
|
) -> list[str]:
|
||||||
|
"""IP/CIDR entries that bypass pipelock's SSRF destination guard.
|
||||||
|
|
||||||
|
Launch code can pass backend-owned entries through `extra`, while
|
||||||
|
route-owned entries come from `pipelock.ssrf_ip_allowlist`.
|
||||||
|
"""
|
||||||
|
seen: dict[str, None] = {ip: None for ip in extra}
|
||||||
|
for route in bottle.egress.routes:
|
||||||
|
for ip in route.Pipelock.SsrfIpAllowlist:
|
||||||
|
seen.setdefault(ip, None)
|
||||||
|
return sorted(seen.keys())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -191,7 +180,7 @@ def pipelock_build_config(
|
|||||||
# Body-scan enforcement is a separate pipelock section (each DLP
|
# Body-scan enforcement is a separate pipelock section (each DLP
|
||||||
# "surface" — body, MCP, response — has its own action). Pipelock's
|
# "surface" — body, MCP, response — has its own action). Pipelock's
|
||||||
# built-in default for request_body_scanning is "warn" (forward
|
# built-in default for request_body_scanning is "warn" (forward
|
||||||
# with a log line); claude-bottle hard-codes "block" so a hit
|
# with a log line); bot-bottle hard-codes "block" so a hit
|
||||||
# actually stops the request from leaving the egress network.
|
# actually stops the request from leaving the egress network.
|
||||||
#
|
#
|
||||||
# `scan_headers: true` + `header_mode: all` extends the scan to
|
# `scan_headers: true` + `header_mode: all` extends the scan to
|
||||||
@@ -218,8 +207,11 @@ def pipelock_build_config(
|
|||||||
"ca_key": ca_key_path,
|
"ca_key": ca_key_path,
|
||||||
"passthrough_domains": pipelock_effective_tls_passthrough(bottle),
|
"passthrough_domains": pipelock_effective_tls_passthrough(bottle),
|
||||||
}
|
}
|
||||||
if ssrf_ip_allowlist:
|
effective_ssrf_ip_allowlist = pipelock_effective_ssrf_ip_allowlist(
|
||||||
cfg["ssrf"] = {"ip_allowlist": list(ssrf_ip_allowlist)}
|
bottle, ssrf_ip_allowlist,
|
||||||
|
)
|
||||||
|
if effective_ssrf_ip_allowlist:
|
||||||
|
cfg["ssrf"] = {"ip_allowlist": effective_ssrf_ip_allowlist}
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -354,4 +346,3 @@ class PipelockProxy:
|
|||||||
yaml_path.write_text(pipelock_render_yaml(cfg))
|
yaml_path.write_text(pipelock_render_yaml(cfg))
|
||||||
yaml_path.chmod(0o600)
|
yaml_path.chmod(0o600)
|
||||||
return PipelockProxyPlan(yaml_path=yaml_path, slug=slug)
|
return PipelockProxyPlan(yaml_path=yaml_path, slug=slug)
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Per-bottle sidecar supervisor (PRD 0024 chunk 1).
|
"""Per-bottle sidecar supervisor (PRD 0024 chunk 1).
|
||||||
|
|
||||||
PID 1 inside the `claude-bottle-sidecars` bundle image. Spawns
|
PID 1 inside the `bot-bottle-sidecars` bundle image. Spawns
|
||||||
the configured daemons (egress, pipelock, git-gate, supervise),
|
the configured daemons (egress, pipelock, git-gate, supervise),
|
||||||
forwards SIGTERM/SIGINT to each child, and propagates per-daemon
|
forwards SIGTERM/SIGINT to each child, and propagates per-daemon
|
||||||
stdout+stderr to the container log with a `[name] ` prefix.
|
stdout+stderr to the container log with a `[name] ` prefix.
|
||||||
@@ -19,7 +19,7 @@ PR; the interim policy is "don't take the bundle down for one
|
|||||||
sick daemon."
|
sick daemon."
|
||||||
|
|
||||||
Daemon subset is env-driven. The compose renderer narrows it via
|
Daemon subset is env-driven. The compose renderer narrows it via
|
||||||
`CLAUDE_BOTTLE_SIDECAR_DAEMONS=egress,pipelock` for bottles that
|
`BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock` for bottles that
|
||||||
don't use git-gate or supervise. Default: all four.
|
don't use git-gate or supervise. Default: all four.
|
||||||
|
|
||||||
Stdlib-only by design — adding supervisord/s6/runit for four
|
Stdlib-only by design — adding supervisord/s6/runit for four
|
||||||
@@ -106,7 +106,7 @@ def _selected_daemons(
|
|||||||
env: dict[str, str],
|
env: dict[str, str],
|
||||||
all_daemons: Sequence[_DaemonSpec] | None = None,
|
all_daemons: Sequence[_DaemonSpec] | None = None,
|
||||||
) -> tuple[_DaemonSpec, ...]:
|
) -> tuple[_DaemonSpec, ...]:
|
||||||
"""Filter the daemon set by the CLAUDE_BOTTLE_SIDECAR_DAEMONS env
|
"""Filter the daemon set by the BOT_BOTTLE_SIDECAR_DAEMONS env
|
||||||
var. Unknown names in the list are ignored — the renderer is the
|
var. Unknown names in the list are ignored — the renderer is the
|
||||||
source of truth for which daemons are wired.
|
source of truth for which daemons are wired.
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ def _selected_daemons(
|
|||||||
`_DAEMONS` and have the new value take effect."""
|
`_DAEMONS` and have the new value take effect."""
|
||||||
if all_daemons is None:
|
if all_daemons is None:
|
||||||
all_daemons = _DAEMONS
|
all_daemons = _DAEMONS
|
||||||
raw = env.get("CLAUDE_BOTTLE_SIDECAR_DAEMONS", "").strip()
|
raw = env.get("BOT_BOTTLE_SIDECAR_DAEMONS", "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return tuple(all_daemons)
|
return tuple(all_daemons)
|
||||||
wanted = {n.strip() for n in raw.split(",") if n.strip()}
|
wanted = {n.strip() for n in raw.split(",") if n.strip()}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Per-bottle supervise plane (PRD 0013).
|
"""Per-bottle supervise plane (PRD 0013).
|
||||||
|
|
||||||
The supervise plane is the per-bottle MCP sidecar plus its host-side
|
The supervise plane is the per-bottle MCP sidecar plus its host-side
|
||||||
queue/audit support. The sidecar (claude_bottle.supervise_server)
|
queue/audit support. The sidecar (bot_bottle.supervise_server)
|
||||||
sits on the bottle's internal network and exposes three MCP tools the
|
sits on the bottle's internal network and exposes three MCP tools the
|
||||||
agent calls when it hits a stuck-recovery category:
|
agent calls when it hits a stuck-recovery category:
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Each tool call: the agent passes the full proposed file plus a
|
|||||||
justification text. The sidecar validates the proposal syntactically,
|
justification text. The sidecar validates the proposal syntactically,
|
||||||
writes it to the host's per-bottle queue dir, and holds the tool-call
|
writes it to the host's per-bottle queue dir, and holds the tool-call
|
||||||
connection open. The operator's TUI dashboard
|
connection open. The operator's TUI dashboard
|
||||||
(claude_bottle.cli.dashboard) sees the proposal, accepts
|
(bot_bottle.cli.dashboard) sees the proposal, accepts
|
||||||
approve / modify / reject, and writes a response file alongside the
|
approve / modify / reject, and writes a response file alongside the
|
||||||
proposal. The sidecar sees the response and returns `{status, notes}`
|
proposal. The sidecar sees the response and returns `{status, notes}`
|
||||||
to the agent.
|
to the agent.
|
||||||
@@ -21,7 +21,7 @@ to the agent.
|
|||||||
This module defines the host-side library: dataclasses for the queue
|
This module defines the host-side library: dataclasses for the queue
|
||||||
file shapes, queue read/write helpers, the audit log writer, and the
|
file shapes, queue read/write helpers, the audit log writer, and the
|
||||||
diff renderer. The in-container sidecar lives in
|
diff renderer. The in-container sidecar lives in
|
||||||
claude_bottle/supervise_server.py; the supervise daemon's container
|
bot_bottle/supervise_server.py; the supervise daemon's container
|
||||||
lifecycle is owned by the sidecar bundle (PRD 0024).
|
lifecycle is owned by the sidecar bundle (PRD 0024).
|
||||||
|
|
||||||
For 0013 the supervisor's approval handlers are deliberately no-ops:
|
For 0013 the supervisor's approval handlers are deliberately no-ops:
|
||||||
@@ -63,7 +63,7 @@ TOOLS: tuple[str, ...] = (
|
|||||||
# The supervise sidecar uses these to query egress's
|
# The supervise sidecar uses these to query egress's
|
||||||
# introspection endpoint for the `list-egress-routes` MCP
|
# introspection endpoint for the `list-egress-routes` MCP
|
||||||
# tool. The hostname + port match egress's docker network
|
# tool. The hostname + port match egress's docker network
|
||||||
# alias + listen port (see claude_bottle.egress.EGRESS_HOSTNAME
|
# alias + listen port (see bot_bottle.egress.EGRESS_HOSTNAME
|
||||||
# and backend.docker.egress.EGRESS_PORT — the values
|
# and backend.docker.egress.EGRESS_PORT — the values
|
||||||
# are inlined here so the in-container supervise_server doesn't
|
# are inlined here so the in-container supervise_server doesn't
|
||||||
# need to import the egress package).
|
# need to import the egress package).
|
||||||
@@ -90,7 +90,7 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
|
|||||||
ACTION_OPERATOR_EDIT = "operator-edit"
|
ACTION_OPERATOR_EDIT = "operator-edit"
|
||||||
|
|
||||||
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
|
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
|
||||||
CURRENT_CONFIG_DIR_IN_AGENT = "/etc/claude-bottle/current-config"
|
CURRENT_CONFIG_DIR_IN_AGENT = "/etc/bot-bottle/current-config"
|
||||||
|
|
||||||
DEFAULT_POLL_INTERVAL_SEC = 0.5
|
DEFAULT_POLL_INTERVAL_SEC = 0.5
|
||||||
|
|
||||||
@@ -98,16 +98,16 @@ DEFAULT_POLL_INTERVAL_SEC = 0.5
|
|||||||
# --- Paths -----------------------------------------------------------------
|
# --- Paths -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def claude_bottle_root() -> Path:
|
def bot_bottle_root() -> Path:
|
||||||
return Path.home() / ".claude-bottle"
|
return Path.home() / ".bot-bottle"
|
||||||
|
|
||||||
|
|
||||||
def queue_dir_for_slug(slug: str) -> Path:
|
def queue_dir_for_slug(slug: str) -> Path:
|
||||||
return claude_bottle_root() / "queue" / slug
|
return bot_bottle_root() / "queue" / slug
|
||||||
|
|
||||||
|
|
||||||
def audit_dir() -> Path:
|
def audit_dir() -> Path:
|
||||||
return claude_bottle_root() / "audit"
|
return bot_bottle_root() / "audit"
|
||||||
|
|
||||||
|
|
||||||
def audit_log_path(component: str, slug: str) -> Path:
|
def audit_log_path(component: str, slug: str) -> Path:
|
||||||
@@ -453,7 +453,7 @@ class SupervisePlan:
|
|||||||
`queue_dir` is the host directory bind-mounted into the sidecar
|
`queue_dir` is the host directory bind-mounted into the sidecar
|
||||||
at /run/supervise/queue. `current_config_dir` is the host
|
at /run/supervise/queue. `current_config_dir` is the host
|
||||||
directory bind-mounted (read-only) into the *agent* container
|
directory bind-mounted (read-only) into the *agent* container
|
||||||
at /etc/claude-bottle/current-config — currently holds only the
|
at /etc/bot-bottle/current-config — currently holds only the
|
||||||
Dockerfile snapshot (routes.yaml + allowlist moved to the
|
Dockerfile snapshot (routes.yaml + allowlist moved to the
|
||||||
`list-egress-routes` MCP tool). `internal_network` is
|
`list-egress-routes` MCP tool). `internal_network` is
|
||||||
empty at prepare time; the backend's launch step fills it via
|
empty at prepare time; the backend's launch step fills it via
|
||||||
@@ -566,7 +566,7 @@ __all__ = [
|
|||||||
"archive_proposal",
|
"archive_proposal",
|
||||||
"audit_dir",
|
"audit_dir",
|
||||||
"audit_log_path",
|
"audit_log_path",
|
||||||
"claude_bottle_root",
|
"bot_bottle_root",
|
||||||
"list_pending_proposals",
|
"list_pending_proposals",
|
||||||
"queue_dir_for_slug",
|
"queue_dir_for_slug",
|
||||||
"read_audit_entries",
|
"read_audit_entries",
|
||||||
@@ -6,7 +6,7 @@ propose config changes when stuck. Each tool call:
|
|||||||
|
|
||||||
1. Validates the proposed file syntactically.
|
1. Validates the proposed file syntactically.
|
||||||
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from
|
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from
|
||||||
the host's ~/.claude-bottle/queue/<slug>/).
|
the host's ~/.bot-bottle/queue/<slug>/).
|
||||||
3. Blocks polling for a matching Response file.
|
3. Blocks polling for a matching Response file.
|
||||||
4. Returns the operator's `{status, notes}` to the agent.
|
4. Returns the operator's `{status, notes}` to the agent.
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
|||||||
|
|
||||||
Everything else returns JSON-RPC error -32601 (method not found).
|
Everything else returns JSON-RPC error -32601 (method not found).
|
||||||
|
|
||||||
Stdlib-only. The Dockerfile copies this file + claude_bottle/supervise.py
|
Stdlib-only. The Dockerfile copies this file + bot_bottle/supervise.py
|
||||||
into the image; the server imports `supervise` for the queue / Proposal
|
into the image; the server imports `supervise` for the queue / Proposal
|
||||||
plumbing.
|
plumbing.
|
||||||
"""
|
"""
|
||||||
@@ -51,7 +51,7 @@ import supervise as _sv
|
|||||||
|
|
||||||
|
|
||||||
MCP_PROTOCOL_VERSION = "2024-11-05"
|
MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||||
SERVER_NAME = "claude-bottle-supervise"
|
SERVER_NAME = "bot-bottle-supervise"
|
||||||
SERVER_VERSION = "0.1.0"
|
SERVER_VERSION = "0.1.0"
|
||||||
|
|
||||||
JSONRPC_VERSION = "2.0"
|
JSONRPC_VERSION = "2.0"
|
||||||
@@ -254,7 +254,7 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
|||||||
"or env var you need — something that lives in the agent "
|
"or env var you need — something that lives in the agent "
|
||||||
"Dockerfile rather than in routes or the pipelock allowlist. "
|
"Dockerfile rather than in routes or the pipelock allowlist. "
|
||||||
"Read the current Dockerfile from "
|
"Read the current Dockerfile from "
|
||||||
"/etc/claude-bottle/current-config/Dockerfile, compose a "
|
"/etc/bot-bottle/current-config/Dockerfile, compose a "
|
||||||
"modified version, and pass the full new file plus a "
|
"modified version, and pass the full new file plus a "
|
||||||
"justification. On approval the supervisor rebuilds the "
|
"justification. On approval the supervisor rebuilds the "
|
||||||
"bottle from the new Dockerfile and starts a replacement on "
|
"bottle from the new Dockerfile and starts a replacement on "
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Hand-rolled YAML-subset parser for claude-bottle manifest files
|
"""Hand-rolled YAML-subset parser for bot-bottle manifest files
|
||||||
(PRD 0011).
|
(PRD 0011).
|
||||||
|
|
||||||
Why hand-rolled: the configs we accept have a bounded shape (flat
|
Why hand-rolled: the configs we accept have a bounded shape (flat
|
||||||
@@ -14,7 +14,7 @@ Public API:
|
|||||||
|
|
||||||
parse_yaml_subset(text) -> dict[str, object]
|
parse_yaml_subset(text) -> dict[str, object]
|
||||||
Parse a full document. Top level must be a mapping (the
|
Parse a full document. Top level must be a mapping (the
|
||||||
shape every claude-bottle manifest file uses). Values are
|
shape every bot-bottle manifest file uses). Values are
|
||||||
str / int / bool / None / list / dict only.
|
str / int / bool / None / list / dict only.
|
||||||
|
|
||||||
parse_frontmatter(text) -> tuple[dict[str, object], str]
|
parse_frontmatter(text) -> tuple[dict[str, object], str]
|
||||||
@@ -64,14 +64,14 @@ class YamlSubsetError(ValueError):
|
|||||||
"""Raised when input violates the YAML subset's rules. Callers
|
"""Raised when input violates the YAML subset's rules. Callers
|
||||||
that want fatal-exit semantics (manifest loader, pipelock-apply,
|
that want fatal-exit semantics (manifest loader, pipelock-apply,
|
||||||
etc.) catch this at their own boundary and forward to `die`;
|
etc.) catch this at their own boundary and forward to `die`;
|
||||||
callers running outside the claude-bottle CLI process (the
|
callers running outside the bot-bottle CLI process (the
|
||||||
egress sidecar's addon) handle it as a normal exception."""
|
egress sidecar's addon) handle it as a normal exception."""
|
||||||
|
|
||||||
|
|
||||||
def die(msg: str) -> None:
|
def die(msg: str) -> None:
|
||||||
"""Module-local helper so the parser body reads cleanly. Just
|
"""Module-local helper so the parser body reads cleanly. Just
|
||||||
raises YamlSubsetError — the `claude-bottle: error: ` prefix
|
raises YamlSubsetError — the `bot-bottle: error: ` prefix
|
||||||
is added by the boundary `die` in `claude_bottle.log`."""
|
is added by the boundary `die` in `bot_bottle.log`."""
|
||||||
raise YamlSubsetError(msg)
|
raise YamlSubsetError(msg)
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
#!/usr/bin/env python3
|
||||||
"""cli.py — entry point for the claude-bottle CLI. Run with --help (or
|
"""cli.py — entry point for the bot-bottle CLI. Run with --help (or
|
||||||
no args) for the command list."""
|
no args) for the command list."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from claude_bottle.cli import main
|
from bot_bottle.cli import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ Type "clear"
|
|||||||
Enter
|
Enter
|
||||||
Show
|
Show
|
||||||
|
|
||||||
# Real cli.py invocation — what a user with claude-bottle.json in cwd
|
# Real cli.py invocation — what a user with bot-bottle.json in cwd
|
||||||
# would type. The bottle declares one allowlist (only baked-in
|
# would type. The bottle declares one allowlist (only baked-in
|
||||||
# defaults), one git upstream (unreachable on purpose so gitleaks runs
|
# defaults), one git upstream (unreachable on purpose so gitleaks runs
|
||||||
# before the gate would forward), and a FAKE_TOKEN env var shaped like
|
# before the gate would forward), and a FAKE_TOKEN env var shaped like
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Run pipelock as a sidecar container on each claude-bottle agent's only
|
Run pipelock as a sidecar container on each bot-bottle agent's only
|
||||||
egress route, scanning all outbound HTTP for hostname allowlist violations
|
egress route, scanning all outbound HTTP for hostname allowlist violations
|
||||||
and DLP matches.
|
and DLP matches.
|
||||||
|
|
||||||
@@ -95,18 +95,18 @@ The feature is **done** when all of the following ship:
|
|||||||
|
|
||||||
### New services / components
|
### New services / components
|
||||||
|
|
||||||
Two new modules under `claude_bottle/`:
|
Two new modules under `bot_bottle/`:
|
||||||
|
|
||||||
- **`claude_bottle/pipelock.py`** — pipelock-specific logic. Generates
|
- **`bot_bottle/pipelock.py`** — pipelock-specific logic. Generates
|
||||||
the per-bottle YAML config from the manifest's `egress` block plus
|
the per-bottle YAML config from the manifest's `egress` block plus
|
||||||
baked-in defaults; copies the YAML into the sidecar via `docker cp`;
|
baked-in defaults; copies the YAML into the sidecar via `docker cp`;
|
||||||
starts and stops the sidecar container; resolves the allowlist for
|
starts and stops the sidecar container; resolves the allowlist for
|
||||||
display in the preflight.
|
display in the preflight.
|
||||||
- **`claude_bottle/network.py`** — Docker network plumbing. Creates the
|
- **`bot_bottle/network.py`** — Docker network plumbing. Creates the
|
||||||
per-agent `--internal` network (named `claude-bottle-net-<slug>` with
|
per-agent `--internal` network (named `bot-bottle-net-<slug>` with
|
||||||
the same slug-and-suffix scheme used for container names), attaches
|
the same slug-and-suffix scheme used for container names), attaches
|
||||||
the agent and sidecar to it, removes it on teardown. Kept separate
|
the agent and sidecar to it, removes it on teardown. Kept separate
|
||||||
from `claude_bottle/docker.py` so a future PRD can add non-pipelock
|
from `bot_bottle/docker.py` so a future PRD can add non-pipelock
|
||||||
network controls without entangling them with pipelock specifics.
|
network controls without entangling them with pipelock specifics.
|
||||||
|
|
||||||
This split mirrors the existing per-concern module pattern
|
This split mirrors the existing per-concern module pattern
|
||||||
@@ -114,7 +114,7 @@ This split mirrors the existing per-concern module pattern
|
|||||||
|
|
||||||
### Existing code touched
|
### Existing code touched
|
||||||
|
|
||||||
- **`claude_bottle/cli/start.py`** — wire the new lifecycle into the
|
- **`bot_bottle/cli/start.py`** — wire the new lifecycle into the
|
||||||
`start` subcommand: create the internal network, launch the pipelock
|
`start` subcommand: create the internal network, launch the pipelock
|
||||||
sidecar, then launch the agent container with `HTTPS_PROXY` /
|
sidecar, then launch the agent container with `HTTPS_PROXY` /
|
||||||
`HTTP_PROXY` set to the sidecar's service name. Add the resolved
|
`HTTP_PROXY` set to the sidecar's service name. Add the resolved
|
||||||
@@ -129,9 +129,9 @@ This split mirrors the existing per-concern module pattern
|
|||||||
the image. This keeps the image agnostic to whether a sidecar is in use
|
the image. This keeps the image agnostic to whether a sidecar is in use
|
||||||
(useful if a future bottle definition opts out of the proxy for testing).
|
(useful if a future bottle definition opts out of the proxy for testing).
|
||||||
|
|
||||||
`claude_bottle/docker.py` may grow one or two helpers if there is a
|
`bot_bottle/docker.py` may grow one or two helpers if there is a
|
||||||
clean place for shared primitives, but the network-specific helpers
|
clean place for shared primitives, but the network-specific helpers
|
||||||
live in `claude_bottle/network.py`. Decide during implementation; not a
|
live in `bot_bottle/network.py`. Decide during implementation; not a
|
||||||
contract.
|
contract.
|
||||||
|
|
||||||
### Data model changes
|
### Data model changes
|
||||||
@@ -176,7 +176,7 @@ bottle share the same allowlist.
|
|||||||
|
|
||||||
- **Pipelock binary** is pulled from
|
- **Pipelock binary** is pulled from
|
||||||
`ghcr.io/luckypipewrench/pipelock@sha256:<digest>`. The digest is
|
`ghcr.io/luckypipewrench/pipelock@sha256:<digest>`. The digest is
|
||||||
pinned in `claude_bottle/pipelock.py` (or a sibling constants module)
|
pinned in `bot_bottle/pipelock.py` (or a sibling constants module)
|
||||||
and bumped deliberately, mirroring the claude-code version pinning
|
and bumped deliberately, mirroring the claude-code version pinning
|
||||||
pattern in `Dockerfile`.
|
pattern in `Dockerfile`.
|
||||||
- No new host-side runtimes. The pipelock image is the only new
|
- No new host-side runtimes. The pipelock image is the only new
|
||||||
@@ -192,8 +192,8 @@ bottle share the same allowlist.
|
|||||||
(proxy + 48 default DLP patterns + subdomain entropy + sidecar
|
(proxy + 48 default DLP patterns + subdomain entropy + sidecar
|
||||||
topology) is expected to be core-only, but this should be confirmed.
|
topology) is expected to be core-only, but this should be confirmed.
|
||||||
- **Where to put the digest pin.** A constant in
|
- **Where to put the digest pin.** A constant in
|
||||||
`claude_bottle/pipelock.py` is the lowest-friction option; a separate
|
`bot_bottle/pipelock.py` is the lowest-friction option; a separate
|
||||||
`claude_bottle/versions.py` (or similar) may be cleaner once there
|
`bot_bottle/versions.py` (or similar) may be cleaner once there
|
||||||
are multiple pinned dependencies. Decide during implementation.
|
are multiple pinned dependencies. Decide during implementation.
|
||||||
- **Per-agent overrides.** The PRD scopes egress to the bottle. If a
|
- **Per-agent overrides.** The PRD scopes egress to the bottle. If a
|
||||||
later use case calls for tightening (not loosening) the allowlist for
|
later use case calls for tightening (not loosening) the allowlist for
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ second backend ships in this PRD.
|
|||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Today, "how to launch a bottle" is spread across roughly six modules
|
Today, "how to launch a bottle" is spread across roughly six modules
|
||||||
(`claude_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`,
|
(`bot_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`,
|
||||||
`skills.py`, `docker.py`), each shelling out to `docker` directly via
|
`skills.py`, `docker.py`), each shelling out to `docker` directly via
|
||||||
`subprocess.run(["docker", ...])`. That coupling means:
|
`subprocess.run(["docker", ...])`. That coupling means:
|
||||||
|
|
||||||
@@ -57,22 +57,22 @@ The feature works when all of the following are observable:
|
|||||||
|
|
||||||
The feature is **done** when all of the following ship:
|
The feature is **done** when all of the following ship:
|
||||||
|
|
||||||
- A new `claude_bottle/backend/` package exists with abstract base
|
- A new `bot_bottle/backend/` package exists with abstract base
|
||||||
classes (`BottleBackend`, `BottlePlan`, `BottleCleanupPlan`,
|
classes (`BottleBackend`, `BottlePlan`, `BottleCleanupPlan`,
|
||||||
`Bottle`) plus a `claude_bottle/backend/docker/` subpackage
|
`Bottle`) plus a `bot_bottle/backend/docker/` subpackage
|
||||||
containing the `DockerBottleBackend` implementation.
|
containing the `DockerBottleBackend` implementation.
|
||||||
- `DockerBottleBackend.launch(plan)` returns a context manager
|
- `DockerBottleBackend.launch(plan)` returns a context manager
|
||||||
yielding a `Bottle` handle exposing `exec_claude(argv, *, tty=True)`,
|
yielding a `Bottle` handle exposing `exec_agent(argv, *, tty=True)`,
|
||||||
`cp_in(host, ctr)`, and teardown on context exit.
|
`cp_in(host, ctr)`, and teardown on context exit.
|
||||||
- Every existing `subprocess.run(["docker", ...])` call in
|
- Every existing `subprocess.run(["docker", ...])` call in
|
||||||
`cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and
|
`cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and
|
||||||
`skills.py` either moves into `claude_bottle/backend/docker/` or is
|
`skills.py` either moves into `bot_bottle/backend/docker/` or is
|
||||||
called from it. No top-level CLI code references `docker` directly.
|
called from it. No top-level CLI code references `docker` directly.
|
||||||
- `bottles[].runtime` is removed from the manifest schema, the
|
- `bottles[].runtime` is removed from the manifest schema, the
|
||||||
dataclass in `manifest.py`, the example manifest, and any README /
|
dataclass in `manifest.py`, the example manifest, and any README /
|
||||||
docs references. `require_runsc()` in the old top-level
|
docs references. `require_runsc()` in the old top-level
|
||||||
`claude_bottle/docker.py` is deleted.
|
`bot_bottle/docker.py` is deleted.
|
||||||
- A single env var, `CLAUDE_BOTTLE_BACKEND` (default `"docker"`),
|
- A single env var, `BOT_BOTTLE_BACKEND` (default `"docker"`),
|
||||||
selects the backend. Unknown values die at startup with a list of
|
selects the backend. Unknown values die at startup with a list of
|
||||||
known backends.
|
known backends.
|
||||||
- The y/N preflight in `cli.py` includes the resolved Docker runtime
|
- The y/N preflight in `cli.py` includes the resolved Docker runtime
|
||||||
@@ -97,8 +97,8 @@ The feature is **done** when all of the following ship:
|
|||||||
|
|
||||||
### In scope
|
### In scope
|
||||||
|
|
||||||
- New `claude_bottle/backend/` package containing the abstract types
|
- New `bot_bottle/backend/` package containing the abstract types
|
||||||
and the registry, plus a `claude_bottle/backend/docker/` subpackage
|
and the registry, plus a `bot_bottle/backend/docker/` subpackage
|
||||||
containing the Docker implementation.
|
containing the Docker implementation.
|
||||||
- The `Bottle`, `BottleBackend`, `BottlePlan`, and `BottleCleanupPlan`
|
- The `Bottle`, `BottleBackend`, `BottlePlan`, and `BottleCleanupPlan`
|
||||||
abstract base classes; `BottleSpec` data carrier; and
|
abstract base classes; `BottleSpec` data carrier; and
|
||||||
@@ -136,10 +136,10 @@ The feature is **done** when all of the following ship:
|
|||||||
|
|
||||||
### New services / components
|
### New services / components
|
||||||
|
|
||||||
A new package, `claude_bottle/backend/`, with an abstract base layer
|
A new package, `bot_bottle/backend/`, with an abstract base layer
|
||||||
and a Docker subpackage:
|
and a Docker subpackage:
|
||||||
|
|
||||||
- **`claude_bottle/backend/__init__.py`** — Defines the abstract base
|
- **`bot_bottle/backend/__init__.py`** — Defines the abstract base
|
||||||
classes and the backend registry. `BottleSpec` carries the
|
classes and the backend registry. `BottleSpec` carries the
|
||||||
CLI-supplied intent; the abstract `BottlePlan` and
|
CLI-supplied intent; the abstract `BottlePlan` and
|
||||||
`BottleCleanupPlan` are the prepared-but-not-launched outputs of
|
`BottleCleanupPlan` are the prepared-but-not-launched outputs of
|
||||||
@@ -165,14 +165,14 @@ and a Docker subpackage:
|
|||||||
`provision_git`); subclasses implement those four rather than
|
`provision_git`); subclasses implement those four rather than
|
||||||
overriding `provision` itself.
|
overriding `provision` itself.
|
||||||
|
|
||||||
Selection reads `CLAUDE_BOTTLE_BACKEND` (default `"docker"`).
|
Selection reads `BOT_BOTTLE_BACKEND` (default `"docker"`).
|
||||||
Unknown values call `die()` with the list of known backends:
|
Unknown values call `die()` with the list of known backends:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def get_bottle_backend() -> BottleBackend: ...
|
def get_bottle_backend() -> BottleBackend: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`claude_bottle/backend/docker/`** — Subpackage with the Docker
|
- **`bot_bottle/backend/docker/`** — Subpackage with the Docker
|
||||||
implementation, split into:
|
implementation, split into:
|
||||||
- `backend.py` — `DockerBottleBackend`, owning all five abstract
|
- `backend.py` — `DockerBottleBackend`, owning all five abstract
|
||||||
methods (`prepare`, `launch`, `prepare_cleanup`, `cleanup`,
|
methods (`prepare`, `launch`, `prepare_cleanup`, `cleanup`,
|
||||||
@@ -196,49 +196,49 @@ and a Docker subpackage:
|
|||||||
- `pipelock.py` — `DockerPipelockProxy` (the sidecar start/stop
|
- `pipelock.py` — `DockerPipelockProxy` (the sidecar start/stop
|
||||||
lifecycle) and Docker-specific naming helpers. The backend-neutral
|
lifecycle) and Docker-specific naming helpers. The backend-neutral
|
||||||
yaml + allowlist resolution stays in the top-level
|
yaml + allowlist resolution stays in the top-level
|
||||||
`claude_bottle/pipelock.py`.
|
`bot_bottle/pipelock.py`.
|
||||||
- `util.py` — Docker-specific helpers (slugify, image/container
|
- `util.py` — Docker-specific helpers (slugify, image/container
|
||||||
existence checks, `runsc_available`).
|
existence checks, `runsc_available`).
|
||||||
|
|
||||||
### Existing code touched
|
### Existing code touched
|
||||||
|
|
||||||
- **`claude_bottle/cli/start.py`** — replace the inline docker
|
- **`bot_bottle/cli/start.py`** — replace the inline docker
|
||||||
orchestration with `backend = get_bottle_backend(); plan =
|
orchestration with `backend = get_bottle_backend(); plan =
|
||||||
backend.prepare(spec, stage_dir=...); with backend.launch(plan) as
|
backend.prepare(spec, stage_dir=...); with backend.launch(plan) as
|
||||||
bottle: bottle.exec_claude(...)`. The y/N preflight is rendered by
|
bottle: bottle.exec_agent(...)`. The y/N preflight is rendered by
|
||||||
`plan.print(...)`.
|
`plan.print(...)`.
|
||||||
- **`claude_bottle/manifest.py`** — drop the `runtime` field from the
|
- **`bot_bottle/manifest.py`** — drop the `runtime` field from the
|
||||||
Bottle dataclass and its validation. Existing manifests with
|
Bottle dataclass and its validation. Existing manifests with
|
||||||
`runtime: "runsc"` produce a clear "no longer supported; gVisor is
|
`runtime: "runsc"` produce a clear "no longer supported; gVisor is
|
||||||
now auto-detected by the backend; remove the 'runtime' field" error.
|
now auto-detected by the backend; remove the 'runtime' field" error.
|
||||||
- **`claude_bottle/docker.py`** — module deleted. `require_runsc()`,
|
- **`bot_bottle/docker.py`** — module deleted. `require_runsc()`,
|
||||||
`slugify()`, `image_exists()`, `container_exists()`, the
|
`slugify()`, `image_exists()`, `container_exists()`, the
|
||||||
`build_image` / `build_image_with_cwd` helpers, and `require_docker`
|
`build_image` / `build_image_with_cwd` helpers, and `require_docker`
|
||||||
all migrate into `claude_bottle/backend/docker/util.py` (or
|
all migrate into `bot_bottle/backend/docker/util.py` (or
|
||||||
`backend.py`).
|
`backend.py`).
|
||||||
- **`claude_bottle/pipelock.py`** — keeps the allowlist resolution and
|
- **`bot_bottle/pipelock.py`** — keeps the allowlist resolution and
|
||||||
YAML generation. Becomes a thin abstract class (`PipelockProxy`)
|
YAML generation. Becomes a thin abstract class (`PipelockProxy`)
|
||||||
exposing `prepare` (writes the yaml) plus abstract `start` / `stop`
|
exposing `prepare` (writes the yaml) plus abstract `start` / `stop`
|
||||||
methods. The Docker-specific subclass `DockerPipelockProxy` lives
|
methods. The Docker-specific subclass `DockerPipelockProxy` lives
|
||||||
under `backend/docker/pipelock.py`.
|
under `backend/docker/pipelock.py`.
|
||||||
- **`claude_bottle/network.py`** — folds entirely into
|
- **`bot_bottle/network.py`** — folds entirely into
|
||||||
`backend/docker/network.py`. No top-level network module remains.
|
`backend/docker/network.py`. No top-level network module remains.
|
||||||
- **`claude_bottle/ssh.py`** and **`claude_bottle/skills.py`** —
|
- **`bot_bottle/ssh.py`** and **`bot_bottle/skills.py`** —
|
||||||
absorbed into `DockerBottleBackend` as `provision_ssh` and
|
absorbed into `DockerBottleBackend` as `provision_ssh` and
|
||||||
`provision_skills`. The host-side file-tree generation stays as
|
`provision_skills`. The host-side file-tree generation stays as
|
||||||
private helpers on the backend class.
|
private helpers on the backend class.
|
||||||
- **`claude_bottle/env.py`** (renamed from `env_resolve.py`) —
|
- **`bot_bottle/env.py`** (renamed from `env_resolve.py`) —
|
||||||
`resolve_env(manifest, agent) -> ResolvedEnv` returns
|
`resolve_env(manifest, agent) -> ResolvedEnv` returns
|
||||||
`forwarded: list[str]` (names whose values were exported into
|
`forwarded: list[str]` (names whose values were exported into
|
||||||
`os.environ` for inheritance) and `literals: dict[str, str]` (name
|
`os.environ` for inheritance) and `literals: dict[str, str]` (name
|
||||||
→ verbatim value). The Docker backend translates the result into
|
→ verbatim value). The Docker backend translates the result into
|
||||||
`--env-file` content + `-e NAME` argv fragments.
|
`--env-file` content + `-e NAME` argv fragments.
|
||||||
- **`claude_bottle/util.py`** — top-level cross-backend helpers
|
- **`bot_bottle/util.py`** — top-level cross-backend helpers
|
||||||
(`expand_tilde`, `is_ipv4_literal`). Backend-specific helpers live
|
(`expand_tilde`, `is_ipv4_literal`). Backend-specific helpers live
|
||||||
in their backend's `util.py`.
|
in their backend's `util.py`.
|
||||||
- **`claude-bottle.example.json`** — remove the `runtime` field from
|
- **`bot-bottle.example.json`** — remove the `runtime` field from
|
||||||
any example bottle.
|
any example bottle.
|
||||||
- **`README.md`** — note `CLAUDE_BOTTLE_BACKEND` and the runsc
|
- **`README.md`** — note `BOT_BOTTLE_BACKEND` and the runsc
|
||||||
auto-detect; remove any mention of `runtime: "runsc"` as a manifest
|
auto-detect; remove any mention of `runtime: "runsc"` as a manifest
|
||||||
field.
|
field.
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Break `claude_bottle/backend/docker/backend.py` (664 lines) apart by
|
Break `bot_bottle/backend/docker/backend.py` (664 lines) apart by
|
||||||
moving the four provisioner methods — `provision_prompt`,
|
moving the four provisioner methods — `provision_prompt`,
|
||||||
`provision_skills`, `provision_ssh`, `provision_git` — out of
|
`provision_skills`, `provision_ssh`, `provision_git` — out of
|
||||||
`DockerBottleBackend` into their own modules under
|
`DockerBottleBackend` into their own modules under
|
||||||
`claude_bottle/backend/docker/provision/`. The abstract base in
|
`bot_bottle/backend/docker/provision/`. The abstract base in
|
||||||
`claude_bottle/backend/__init__.py` keeps the same four-method
|
`bot_bottle/backend/__init__.py` keeps the same four-method
|
||||||
contract; only the Docker implementation changes shape.
|
contract; only the Docker implementation changes shape.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
@@ -56,7 +56,7 @@ The feature works when all of the following are observable:
|
|||||||
|
|
||||||
The feature is **done** when all of the following ship:
|
The feature is **done** when all of the following ship:
|
||||||
|
|
||||||
- A new `claude_bottle/backend/docker/provision/` subpackage exists
|
- A new `bot_bottle/backend/docker/provision/` subpackage exists
|
||||||
with one module per provisioner: `prompt.py`, `skills.py`, `ssh.py`,
|
with one module per provisioner: `prompt.py`, `skills.py`, `ssh.py`,
|
||||||
`git.py`. Each exports a single top-level function taking
|
`git.py`. Each exports a single top-level function taking
|
||||||
`(plan: DockerBottlePlan, target: str)` and returning the same type
|
`(plan: DockerBottlePlan, target: str)` and returning the same type
|
||||||
@@ -66,7 +66,7 @@ The feature is **done** when all of the following ship:
|
|||||||
`provision_ssh` / `provision_git` each become one-line delegations
|
`provision_ssh` / `provision_git` each become one-line delegations
|
||||||
to the new module functions.
|
to the new module functions.
|
||||||
- The abstract `BottleBackend.provision_*` signatures in
|
- The abstract `BottleBackend.provision_*` signatures in
|
||||||
`claude_bottle/backend/__init__.py` are unchanged. The
|
`bot_bottle/backend/__init__.py` are unchanged. The
|
||||||
`BottleBackend.provision` orchestration in the base class is
|
`BottleBackend.provision` orchestration in the base class is
|
||||||
unchanged.
|
unchanged.
|
||||||
- No top-level CLI code or other backend gains a direct import of the
|
- No top-level CLI code or other backend gains a direct import of the
|
||||||
@@ -99,7 +99,7 @@ The feature is **done** when all of the following ship:
|
|||||||
|
|
||||||
### In scope
|
### In scope
|
||||||
|
|
||||||
- New `claude_bottle/backend/docker/provision/` subpackage with
|
- New `bot_bottle/backend/docker/provision/` subpackage with
|
||||||
`__init__.py`, `prompt.py`, `skills.py`, `ssh.py`, `git.py`.
|
`__init__.py`, `prompt.py`, `skills.py`, `ssh.py`, `git.py`.
|
||||||
- Moving the four method bodies out of
|
- Moving the four method bodies out of
|
||||||
`DockerBottleBackend` into the new modules verbatim, adjusting only
|
`DockerBottleBackend` into the new modules verbatim, adjusting only
|
||||||
@@ -132,7 +132,7 @@ The feature is **done** when all of the following ship:
|
|||||||
### New layout
|
### New layout
|
||||||
|
|
||||||
```
|
```
|
||||||
claude_bottle/backend/docker/
|
bot_bottle/backend/docker/
|
||||||
backend.py # DockerBottleBackend (slimmer)
|
backend.py # DockerBottleBackend (slimmer)
|
||||||
bottle.py
|
bottle.py
|
||||||
bottle_plan.py
|
bottle_plan.py
|
||||||
@@ -199,13 +199,13 @@ take the concrete type and skip re-checking.
|
|||||||
|
|
||||||
### Existing code touched
|
### Existing code touched
|
||||||
|
|
||||||
- **`claude_bottle/backend/docker/backend.py`** — four method
|
- **`bot_bottle/backend/docker/backend.py`** — four method
|
||||||
bodies move out; method definitions stay as one-line delegations.
|
bodies move out; method definitions stay as one-line delegations.
|
||||||
Imports for `pipelock_proxy_host_port`, `expand_tilde`, etc., that
|
Imports for `pipelock_proxy_host_port`, `expand_tilde`, etc., that
|
||||||
are only used by the moved bodies migrate with them.
|
are only used by the moved bodies migrate with them.
|
||||||
- **`claude_bottle/backend/docker/__init__.py`** — no change. The
|
- **`bot_bottle/backend/docker/__init__.py`** — no change. The
|
||||||
public surface (`DockerBottleBackend`) is unchanged.
|
public surface (`DockerBottleBackend`) is unchanged.
|
||||||
- **`claude_bottle/backend/__init__.py`** — no change.
|
- **`bot_bottle/backend/__init__.py`** — no change.
|
||||||
- **`tests/`** — no expected change. Existing tests exercise the
|
- **`tests/`** — no expected change. Existing tests exercise the
|
||||||
backend via `DockerBottleBackend` or the CLI surface; they don't
|
backend via `DockerBottleBackend` or the CLI surface; they don't
|
||||||
reach into provisioners directly. Verify after the move and only
|
reach into provisioners directly. Verify after the move and only
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ The feature is **done** when all of the following ship:
|
|||||||
sidecar (read-only) so the running pipelock can read its CA.
|
sidecar (read-only) so the running pipelock can read its CA.
|
||||||
- `BottleBackend.provision_ca` (new) copies the CA public cert
|
- `BottleBackend.provision_ca` (new) copies the CA public cert
|
||||||
into the agent at
|
into the agent at
|
||||||
`/usr/local/share/ca-certificates/claude-bottle-mitm.crt`, runs
|
`/usr/local/share/ca-certificates/bot-bottle-mitm.crt`, runs
|
||||||
`update-ca-certificates`, and sets the `NODE_EXTRA_CA_CERTS` /
|
`update-ca-certificates`, and sets the `NODE_EXTRA_CA_CERTS` /
|
||||||
`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` env trio on the agent
|
`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` env trio on the agent
|
||||||
container's runtime env. Default no-op on the abstract base so
|
container's runtime env. Default no-op on the abstract base so
|
||||||
@@ -122,14 +122,14 @@ The feature is **done** when all of the following ship:
|
|||||||
|
|
||||||
### In scope
|
### In scope
|
||||||
|
|
||||||
- **`claude_bottle/pipelock.py`** changes:
|
- **`bot_bottle/pipelock.py`** changes:
|
||||||
- Extend `pipelock_build_config` to include
|
- Extend `pipelock_build_config` to include
|
||||||
`tls_interception: { enabled: true, ca_cert: <path>, ca_key:
|
`tls_interception: { enabled: true, ca_cert: <path>, ca_key:
|
||||||
<path> }`. Paths are populated from the plan; the function's
|
<path> }`. Paths are populated from the plan; the function's
|
||||||
signature grows a `cert_path` / `key_path` pair or reads them
|
signature grows a `cert_path` / `key_path` pair or reads them
|
||||||
off `Bottle` once they're stored.
|
off `Bottle` once they're stored.
|
||||||
- Extend `pipelock_render_yaml` to emit the new block.
|
- Extend `pipelock_render_yaml` to emit the new block.
|
||||||
- **`claude_bottle/backend/docker/pipelock.py`** changes:
|
- **`bot_bottle/backend/docker/pipelock.py`** changes:
|
||||||
- New helper `pipelock_tls_init(stage_dir)` runs the upstream
|
- New helper `pipelock_tls_init(stage_dir)` runs the upstream
|
||||||
image as a one-shot:
|
image as a one-shot:
|
||||||
`docker run --rm -v <stage>:/h -e PIPELOCK_HOME=/h pipelock tls init`,
|
`docker run --rm -v <stage>:/h -e PIPELOCK_HOME=/h pipelock tls init`,
|
||||||
@@ -143,31 +143,31 @@ The feature is **done** when all of the following ship:
|
|||||||
config. If pipelock's image runs as non-root, a `docker exec
|
config. If pipelock's image runs as non-root, a `docker exec
|
||||||
-u 0 chown pipelock:pipelock /etc/pipelock/ca*.pem` lands
|
-u 0 chown pipelock:pipelock /etc/pipelock/ca*.pem` lands
|
||||||
between the `cp` and the `start`.
|
between the `cp` and the `start`.
|
||||||
- **`claude_bottle/backend/__init__.py`**: new abstract method
|
- **`bot_bottle/backend/__init__.py`**: new abstract method
|
||||||
`provision_ca(plan, target)` on `BottleBackend`, default no-op.
|
`provision_ca(plan, target)` on `BottleBackend`, default no-op.
|
||||||
`BottleBackend.provision` orchestrates `ca → prompt → skills →
|
`BottleBackend.provision` orchestrates `ca → prompt → skills →
|
||||||
ssh → git`.
|
ssh → git`.
|
||||||
- **`claude_bottle/backend/docker/provision/ca.py`** (new):
|
- **`bot_bottle/backend/docker/provision/ca.py`** (new):
|
||||||
- Reads the cert from `stage_dir` (already written by prepare).
|
- Reads the cert from `stage_dir` (already written by prepare).
|
||||||
- `docker cp` into the agent.
|
- `docker cp` into the agent.
|
||||||
- `docker exec -u 0 ... chmod 644 ...` + `update-ca-certificates`.
|
- `docker exec -u 0 ... chmod 644 ...` + `update-ca-certificates`.
|
||||||
- Computes the SHA-256 fingerprint with stdlib (`ssl` +
|
- Computes the SHA-256 fingerprint with stdlib (`ssl` +
|
||||||
`hashlib`), emits one stderr log line.
|
`hashlib`), emits one stderr log line.
|
||||||
- **`claude_bottle/backend/docker/launch.py`**:
|
- **`bot_bottle/backend/docker/launch.py`**:
|
||||||
- Three new `-e` flags on the agent's `docker run`:
|
- Three new `-e` flags on the agent's `docker run`:
|
||||||
`NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/claude-bottle-mitm.crt`,
|
`NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm.crt`,
|
||||||
`SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`,
|
`SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`,
|
||||||
`REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`.
|
`REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`.
|
||||||
- `HTTPS_PROXY` / `HTTP_PROXY` continue to point at pipelock
|
- `HTTPS_PROXY` / `HTTP_PROXY` continue to point at pipelock
|
||||||
(unchanged from PRD 0001 — the mitmproxy detour in PR #8 is
|
(unchanged from PRD 0001 — the mitmproxy detour in PR #8 is
|
||||||
abandoned).
|
abandoned).
|
||||||
- **`claude_bottle/backend/docker/bottle_plan.py`**:
|
- **`bot_bottle/backend/docker/bottle_plan.py`**:
|
||||||
- One new `info(...)` line in `print()` noting TLS interception
|
- One new `info(...)` line in `print()` noting TLS interception
|
||||||
is on.
|
is on.
|
||||||
- `to_dict()` gains an `egress.tls_interception: { enabled:
|
- `to_dict()` gains an `egress.tls_interception: { enabled:
|
||||||
true, ca_fingerprint: null }` block. Reserved for future
|
true, ca_fingerprint: null }` block. Reserved for future
|
||||||
population.
|
population.
|
||||||
- **`claude_bottle/backend/docker/prepare.py`**: call
|
- **`bot_bottle/backend/docker/prepare.py`**: call
|
||||||
`pipelock_tls_init(stage_dir)` and write the resolved cert/key
|
`pipelock_tls_init(stage_dir)` and write the resolved cert/key
|
||||||
paths onto the plan (either on the existing `proxy_plan` field
|
paths onto the plan (either on the existing `proxy_plan` field
|
||||||
or on the parent `DockerBottlePlan`).
|
or on the parent `DockerBottlePlan`).
|
||||||
@@ -221,7 +221,7 @@ generated at prepare time.
|
|||||||
the one-shot generation step. The rendered YAML references
|
the one-shot generation step. The rendered YAML references
|
||||||
the in-container paths.
|
the in-container paths.
|
||||||
- **Bottle install.** `provision_ca` (Docker impl) does
|
- **Bottle install.** `provision_ca` (Docker impl) does
|
||||||
`docker cp <stage>/ca.pem agent:/usr/local/share/ca-certificates/claude-bottle-mitm.crt`,
|
`docker cp <stage>/ca.pem agent:/usr/local/share/ca-certificates/bot-bottle-mitm.crt`,
|
||||||
then `update-ca-certificates`. The CA env trio is set at
|
then `update-ca-certificates`. The CA env trio is set at
|
||||||
`docker run -e` time (Docker propagates run-time env into
|
`docker run -e` time (Docker propagates run-time env into
|
||||||
`docker exec`).
|
`docker exec`).
|
||||||
@@ -235,7 +235,7 @@ generated at prepare time.
|
|||||||
`stage_dir`. CA dies with both, in that order, so the sidecar
|
`stage_dir`. CA dies with both, in that order, so the sidecar
|
||||||
is never reading a deleted mount on shutdown.
|
is never reading a deleted mount on shutdown.
|
||||||
- **Fingerprint.** Computed via stdlib in `provision_ca` and
|
- **Fingerprint.** Computed via stdlib in `provision_ca` and
|
||||||
logged once to stderr (`claude-bottle: mitm ca fingerprint:
|
logged once to stderr (`bot-bottle: mitm ca fingerprint:
|
||||||
sha256:<hex>…`). The private key never appears in any log.
|
sha256:<hex>…`). The private key never appears in any log.
|
||||||
|
|
||||||
### Data model changes
|
### Data model changes
|
||||||
@@ -248,18 +248,18 @@ always null at dry-run because the CA doesn't exist yet.
|
|||||||
|
|
||||||
Surgical, all on the existing pipelock path:
|
Surgical, all on the existing pipelock path:
|
||||||
|
|
||||||
- `claude_bottle/pipelock.py` — config builder + YAML renderer.
|
- `bot_bottle/pipelock.py` — config builder + YAML renderer.
|
||||||
- `claude_bottle/backend/__init__.py` — abstract `provision_ca`.
|
- `bot_bottle/backend/__init__.py` — abstract `provision_ca`.
|
||||||
- `claude_bottle/backend/docker/pipelock.py` — `tls init` helper,
|
- `bot_bottle/backend/docker/pipelock.py` — `tls init` helper,
|
||||||
sidecar volume mount.
|
sidecar volume mount.
|
||||||
- `claude_bottle/backend/docker/prepare.py` — CA paths on plan.
|
- `bot_bottle/backend/docker/prepare.py` — CA paths on plan.
|
||||||
- `claude_bottle/backend/docker/launch.py` — CA env trio on agent.
|
- `bot_bottle/backend/docker/launch.py` — CA env trio on agent.
|
||||||
- `claude_bottle/backend/docker/backend.py` — `provision_ca`
|
- `bot_bottle/backend/docker/backend.py` — `provision_ca`
|
||||||
dispatch + thread `self._proxy` through prepare/launch unchanged
|
dispatch + thread `self._proxy` through prepare/launch unchanged
|
||||||
shape.
|
shape.
|
||||||
- `claude_bottle/backend/docker/bottle_plan.py` — preflight
|
- `bot_bottle/backend/docker/bottle_plan.py` — preflight
|
||||||
rendering.
|
rendering.
|
||||||
- `claude_bottle/backend/docker/provision/ca.py` (new).
|
- `bot_bottle/backend/docker/provision/ca.py` (new).
|
||||||
|
|
||||||
Net diff is meaningfully smaller than PR #8 because pipelock
|
Net diff is meaningfully smaller than PR #8 because pipelock
|
||||||
already does the work — no addon, no second sidecar, no second
|
already does the work — no addon, no second sidecar, no second
|
||||||
|
|||||||
@@ -95,14 +95,14 @@ back to green is the test.
|
|||||||
|
|
||||||
Mirror the pipelock layout:
|
Mirror the pipelock layout:
|
||||||
|
|
||||||
- **`claude_bottle/ssh_gate.py`** (new): abstract `SSHGate` +
|
- **`bot_bottle/ssh_gate.py`** (new): abstract `SSHGate` +
|
||||||
`SSHGatePlan` dataclass. `prepare` is host-side / side-effect-free
|
`SSHGatePlan` dataclass. `prepare` is host-side / side-effect-free
|
||||||
on docker; renders the forwarder config under `stage_dir`.
|
on docker; renders the forwarder config under `stage_dir`.
|
||||||
- **`claude_bottle/backend/docker/ssh_gate.py`** (new):
|
- **`bot_bottle/backend/docker/ssh_gate.py`** (new):
|
||||||
`DockerSSHGate` concrete subclass — `start` does `docker create`
|
`DockerSSHGate` concrete subclass — `start` does `docker create`
|
||||||
on the internal network, copies the config in, attaches the
|
on the internal network, copies the config in, attaches the
|
||||||
egress network, `docker start`. `stop` is idempotent `docker rm
|
egress network, `docker start`. `stop` is idempotent `docker rm
|
||||||
-f`. Container name: `claude-bottle-ssh-gate-<slug>`.
|
-f`. Container name: `bot-bottle-ssh-gate-<slug>`.
|
||||||
|
|
||||||
Forwarder image: `alpine/socat`, pinned by digest. Must be
|
Forwarder image: `alpine/socat`, pinned by digest. Must be
|
||||||
self-sufficient at boot (no apk/apt pulls on first run) because
|
self-sufficient at boot (no apk/apt pulls on first run) because
|
||||||
@@ -126,7 +126,7 @@ rejected at prepare time. One container, N listeners, N upstreams.
|
|||||||
|
|
||||||
### Existing code touched
|
### Existing code touched
|
||||||
|
|
||||||
- **`claude_bottle/backend/docker/provision/ssh.py`**: drop the
|
- **`bot_bottle/backend/docker/provision/ssh.py`**: drop the
|
||||||
`ProxyCommand socat - PROXY:...` plumbing and the
|
`ProxyCommand socat - PROXY:...` plumbing and the
|
||||||
`pipelock_proxy_host_port` import. The rendered `~/.ssh/config`
|
`pipelock_proxy_host_port` import. The rendered `~/.ssh/config`
|
||||||
block per entry becomes:
|
block per entry becomes:
|
||||||
@@ -140,19 +140,19 @@ rejected at prepare time. One container, N listeners, N upstreams.
|
|||||||
`known_hosts` entries are keyed off `<name>` and the new
|
`known_hosts` entries are keyed off `<name>` and the new
|
||||||
`[<gate-container>]:<listen-port>` form so OpenSSH's strict
|
`[<gate-container>]:<listen-port>` form so OpenSSH's strict
|
||||||
host-key checking still matches.
|
host-key checking still matches.
|
||||||
- **`claude_bottle/pipelock.py`**: delete
|
- **`bot_bottle/pipelock.py`**: delete
|
||||||
`pipelock_bottle_ssh_hostnames`, `pipelock_bottle_ssh_trusted_domains`,
|
`pipelock_bottle_ssh_hostnames`, `pipelock_bottle_ssh_trusted_domains`,
|
||||||
`pipelock_bottle_ssh_ip_cidrs`, and the calls into them from
|
`pipelock_bottle_ssh_ip_cidrs`, and the calls into them from
|
||||||
`pipelock_effective_allowlist` and `pipelock_build_config`. The
|
`pipelock_effective_allowlist` and `pipelock_build_config`. The
|
||||||
effective allowlist becomes baked-defaults ∪ `bottle.egress.allowlist`.
|
effective allowlist becomes baked-defaults ∪ `bottle.egress.allowlist`.
|
||||||
- **`claude_bottle/backend/docker/backend.py`**: instantiate
|
- **`bot_bottle/backend/docker/backend.py`**: instantiate
|
||||||
`DockerSSHGate` alongside `DockerPipelockProxy`; thread its
|
`DockerSSHGate` alongside `DockerPipelockProxy`; thread its
|
||||||
`prepare` / `start` / `stop` through `resolve_plan` / `launch`.
|
`prepare` / `start` / `stop` through `resolve_plan` / `launch`.
|
||||||
- **`claude_bottle/backend/docker/launch.py`**: add gate start /
|
- **`bot_bottle/backend/docker/launch.py`**: add gate start /
|
||||||
stop to the `ExitStack` in the right order — gate must be up
|
stop to the `ExitStack` in the right order — gate must be up
|
||||||
before `provision_ssh` runs so the agent can dial it on first
|
before `provision_ssh` runs so the agent can dial it on first
|
||||||
boot.
|
boot.
|
||||||
- **`claude_bottle/backend/docker/bottle_plan.py`**: new
|
- **`bot_bottle/backend/docker/bottle_plan.py`**: new
|
||||||
`SSHGatePlan` field on `DockerBottlePlan`; preflight rendering
|
`SSHGatePlan` field on `DockerBottlePlan`; preflight rendering
|
||||||
surfaces the gate sidecar (name, per-entry listen ports,
|
surfaces the gate sidecar (name, per-entry listen ports,
|
||||||
upstream `Hostname:Port` targets).
|
upstream `Hostname:Port` targets).
|
||||||
@@ -165,7 +165,7 @@ rejected at prepare time. One container, N listeners, N upstreams.
|
|||||||
### Data model changes
|
### Data model changes
|
||||||
|
|
||||||
None. `bottle.ssh` schema is unchanged; one new internal plan
|
None. `bottle.ssh` schema is unchanged; one new internal plan
|
||||||
dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
|
dataclass (`SSHGatePlan`) under `bot_bottle/ssh_gate.py`.
|
||||||
|
|
||||||
### External dependencies
|
### External dependencies
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
|
|||||||
- PRD 0006: pipelock native TLS interception — the change that
|
- PRD 0006: pipelock native TLS interception — the change that
|
||||||
surfaced this regression by making pipelock incompatible with
|
surfaced this regression by making pipelock incompatible with
|
||||||
SSH-over-CONNECT.
|
SSH-over-CONNECT.
|
||||||
- `claude_bottle/backend/docker/provision/ssh.py` — current SSH
|
- `bot_bottle/backend/docker/provision/ssh.py` — current SSH
|
||||||
provisioning that this PRD rewrites.
|
provisioning that this PRD rewrites.
|
||||||
- `claude_bottle/pipelock.py` — current pipelock config builder
|
- `bot_bottle/pipelock.py` — current pipelock config builder
|
||||||
that gains the `bottle.ssh`-derived fields this PRD removes.
|
that gains the `bottle.ssh`-derived fields this PRD removes.
|
||||||
|
|||||||
+10
-10
@@ -26,7 +26,7 @@ entry and pushes straight at gitea/github with ssh-gate doing dumb
|
|||||||
L4 forwarding. There is no boundary between "the agent thinks this
|
L4 forwarding. There is no boundary between "the agent thinks this
|
||||||
commit is fine" and "the secret hits an external remote." If a
|
commit is fine" and "the secret hits an external remote." If a
|
||||||
compromised or careless agent stages a `.env`, slips a token into
|
compromised or careless agent stages a `.env`, slips a token into
|
||||||
a fixture, or commits the `CLAUDE_BOTTLE_OAUTH_TOKEN` itself, `git
|
a fixture, or commits the `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` itself, `git
|
||||||
push` ships it.
|
push` ships it.
|
||||||
|
|
||||||
Host-side pre-commit / pre-push hooks are the usual defense, but
|
Host-side pre-commit / pre-push hooks are the usual defense, but
|
||||||
@@ -131,16 +131,16 @@ for a declared upstream:
|
|||||||
|
|
||||||
Mirror the existing sidecar layout:
|
Mirror the existing sidecar layout:
|
||||||
|
|
||||||
- **`claude_bottle/git_gate.py`** (new): abstract `GitGate` +
|
- **`bot_bottle/git_gate.py`** (new): abstract `GitGate` +
|
||||||
`GitGatePlan` dataclass. `prepare` is host-side / side-effect-
|
`GitGatePlan` dataclass. `prepare` is host-side / side-effect-
|
||||||
free on docker; renders the per-upstream config and stages the
|
free on docker; renders the per-upstream config and stages the
|
||||||
push credentials under `stage_dir`.
|
push credentials under `stage_dir`.
|
||||||
- **`claude_bottle/backend/docker/git_gate.py`** (new):
|
- **`bot_bottle/backend/docker/git_gate.py`** (new):
|
||||||
`DockerGitGate` concrete subclass. `start` does `docker create`
|
`DockerGitGate` concrete subclass. `start` does `docker create`
|
||||||
on the internal network, copies in the bare-repo skeleton, the
|
on the internal network, copies in the bare-repo skeleton, the
|
||||||
hook script, and per-upstream credentials, then `docker start`.
|
hook script, and per-upstream credentials, then `docker start`.
|
||||||
`stop` is idempotent `docker rm -f`. Container name:
|
`stop` is idempotent `docker rm -f`. Container name:
|
||||||
`claude-bottle-git-gate-<slug>`.
|
`bot-bottle-git-gate-<slug>`.
|
||||||
|
|
||||||
Gate image: `git-daemon` + `openssh-client` over a
|
Gate image: `git-daemon` + `openssh-client` over a
|
||||||
`zricethezav/gitleaks` base (alpine + gitleaks), pinned by digest.
|
`zricethezav/gitleaks` base (alpine + gitleaks), pinned by digest.
|
||||||
@@ -173,21 +173,21 @@ operation.
|
|||||||
|
|
||||||
### Existing code touched
|
### Existing code touched
|
||||||
|
|
||||||
- **`claude_bottle/manifest.py`**: parse and validate the new
|
- **`bot_bottle/manifest.py`**: parse and validate the new
|
||||||
`bottle.git` block; reject `bottle.ssh` entries whose upstream
|
`bottle.git` block; reject `bottle.ssh` entries whose upstream
|
||||||
is also claimed by a `bottle.git` upstream (one path per
|
is also claimed by a `bottle.git` upstream (one path per
|
||||||
remote, no shadow route).
|
remote, no shadow route).
|
||||||
- **`claude_bottle/backend/docker/provision/git.py`** (new) or an
|
- **`bot_bottle/backend/docker/provision/git.py`** (new) or an
|
||||||
extension of the ssh provisioner: render the `insteadOf` config
|
extension of the ssh provisioner: render the `insteadOf` config
|
||||||
and any extra `~/.gitconfig` plumbing.
|
and any extra `~/.gitconfig` plumbing.
|
||||||
- **`claude_bottle/backend/docker/backend.py`**: instantiate
|
- **`bot_bottle/backend/docker/backend.py`**: instantiate
|
||||||
`DockerGitGate` alongside `DockerPipelockProxy` and
|
`DockerGitGate` alongside `DockerPipelockProxy` and
|
||||||
`DockerSSHGate`; thread its `prepare` / `start` / `stop`
|
`DockerSSHGate`; thread its `prepare` / `start` / `stop`
|
||||||
through `resolve_plan` / `launch`.
|
through `resolve_plan` / `launch`.
|
||||||
- **`claude_bottle/backend/docker/launch.py`**: add gate start /
|
- **`bot_bottle/backend/docker/launch.py`**: add gate start /
|
||||||
stop to the `ExitStack` so the gate is up before any
|
stop to the `ExitStack` so the gate is up before any
|
||||||
provisioner that writes the agent's `~/.gitconfig`.
|
provisioner that writes the agent's `~/.gitconfig`.
|
||||||
- **`claude_bottle/backend/docker/bottle_plan.py`**: new
|
- **`bot_bottle/backend/docker/bottle_plan.py`**: new
|
||||||
`GitGatePlan` field on `DockerBottlePlan`; preflight rendering
|
`GitGatePlan` field on `DockerBottlePlan`; preflight rendering
|
||||||
surfaces the gate sidecar (name, per-upstream local paths,
|
surfaces the gate sidecar (name, per-upstream local paths,
|
||||||
upstream real URLs, which credential is in use).
|
upstream real URLs, which credential is in use).
|
||||||
@@ -249,6 +249,6 @@ exposes it as, and the credential the gate uses to push upstream
|
|||||||
- PRD 0007: SSH egress gate — the L4 SSH forwarder this PRD
|
- PRD 0007: SSH egress gate — the L4 SSH forwarder this PRD
|
||||||
sits alongside; explicitly *not* the place to add
|
sits alongside; explicitly *not* the place to add
|
||||||
git-protocol awareness.
|
git-protocol awareness.
|
||||||
- `claude_bottle/ssh_gate.py` / `claude_bottle/pipelock.py` —
|
- `bot_bottle/ssh_gate.py` / `bot_bottle/pipelock.py` —
|
||||||
existing sidecar abstractions to mirror.
|
existing sidecar abstractions to mirror.
|
||||||
- gitleaks: <https://github.com/gitleaks/gitleaks>
|
- gitleaks: <https://github.com/gitleaks/gitleaks>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
Delete the ssh-gate sidecar and the `bottle.ssh` manifest field.
|
Delete the ssh-gate sidecar and the `bottle.ssh` manifest field.
|
||||||
Git-gate (PRD 0008) covers every current SSH use case in
|
Git-gate (PRD 0008) covers every current SSH use case in
|
||||||
claude-bottle: each declared upstream gets a per-bottle gate
|
bot-bottle: each declared upstream gets a per-bottle gate
|
||||||
with gitleaks scanning, an `insteadOf` rewrite that captures
|
with gitleaks scanning, an `insteadOf` rewrite that captures
|
||||||
push / fetch / clone / pull / ls-remote, and credential
|
push / fetch / clone / pull / ls-remote, and credential
|
||||||
isolation from the agent. ssh-gate is now redundant L4
|
isolation from the agent. ssh-gate is now redundant L4
|
||||||
@@ -76,11 +76,11 @@ the unused path.
|
|||||||
`_validate_no_shadow_route`. Add an explicit branch in
|
`_validate_no_shadow_route`. Add an explicit branch in
|
||||||
`Bottle.from_dict` that dies on a `ssh` key with a one-line
|
`Bottle.from_dict` that dies on a `ssh` key with a one-line
|
||||||
"move this to `bottle.git` (see PRD 0008)" hint.
|
"move this to `bottle.git` (see PRD 0008)" hint.
|
||||||
- **Sidecar.** Delete `claude_bottle/ssh_gate.py` and
|
- **Sidecar.** Delete `bot_bottle/ssh_gate.py` and
|
||||||
`claude_bottle/backend/docker/ssh_gate.py`. Drop the socat
|
`bot_bottle/backend/docker/ssh_gate.py`. Drop the socat
|
||||||
image build path.
|
image build path.
|
||||||
- **Provisioner.** Delete
|
- **Provisioner.** Delete
|
||||||
`claude_bottle/backend/docker/provision/ssh.py` and its
|
`bot_bottle/backend/docker/provision/ssh.py` and its
|
||||||
`~/.ssh/config` render.
|
`~/.ssh/config` render.
|
||||||
- **Docker backend wiring.** Drop `DockerSSHGate` from
|
- **Docker backend wiring.** Drop `DockerSSHGate` from
|
||||||
`backend.py`; drop its start / stop from `launch.py`'s
|
`backend.py`; drop its start / stop from `launch.py`'s
|
||||||
@@ -98,7 +98,7 @@ the unused path.
|
|||||||
- **README.** Drop the socat / ssh image box from the
|
- **README.** Drop the socat / ssh image box from the
|
||||||
architecture diagram and its bullet; drop `ssh:` from the
|
architecture diagram and its bullet; drop `ssh:` from the
|
||||||
manifest example.
|
manifest example.
|
||||||
- **Example manifest.** Drop `ssh:` from `claude-bottle.example.json`.
|
- **Example manifest.** Drop `ssh:` from `bot-bottle.example.json`.
|
||||||
- **PRD 0007.** Add a `Status: Superseded by PRD 0009` header
|
- **PRD 0007.** Add a `Status: Superseded by PRD 0009` header
|
||||||
at the top of the document. Do not delete the file; the
|
at the top of the document. Do not delete the file; the
|
||||||
history of intent matters for the audit trail.
|
history of intent matters for the audit trail.
|
||||||
@@ -138,19 +138,19 @@ the seams between ssh-gate and the rest of the system:
|
|||||||
|
|
||||||
### Existing code touched
|
### Existing code touched
|
||||||
|
|
||||||
- `claude_bottle/manifest.py` — delete `SshEntry`,
|
- `bot_bottle/manifest.py` — delete `SshEntry`,
|
||||||
`Bottle.ssh`, `_validate_no_shadow_route`; add the
|
`Bottle.ssh`, `_validate_no_shadow_route`; add the
|
||||||
parse-fail branch.
|
parse-fail branch.
|
||||||
- `claude_bottle/ssh_gate.py` — delete.
|
- `bot_bottle/ssh_gate.py` — delete.
|
||||||
- `claude_bottle/backend/docker/ssh_gate.py` — delete.
|
- `bot_bottle/backend/docker/ssh_gate.py` — delete.
|
||||||
- `claude_bottle/backend/docker/provision/ssh.py` — delete.
|
- `bot_bottle/backend/docker/provision/ssh.py` — delete.
|
||||||
- `claude_bottle/backend/docker/backend.py` — drop
|
- `bot_bottle/backend/docker/backend.py` — drop
|
||||||
`DockerSSHGate` instantiation.
|
`DockerSSHGate` instantiation.
|
||||||
- `claude_bottle/backend/docker/launch.py` — drop the
|
- `bot_bottle/backend/docker/launch.py` — drop the
|
||||||
ssh-gate start / stop from the `ExitStack`.
|
ssh-gate start / stop from the `ExitStack`.
|
||||||
- `claude_bottle/backend/docker/bottle_plan.py` — drop the
|
- `bot_bottle/backend/docker/bottle_plan.py` — drop the
|
||||||
ssh-gate plan field.
|
ssh-gate plan field.
|
||||||
- `claude_bottle/pipelock.py` — drop the `bottle.ssh`-derived
|
- `bot_bottle/pipelock.py` — drop the `bottle.ssh`-derived
|
||||||
branch in the allowlist render.
|
branch in the allowlist render.
|
||||||
- `tests/unit/test_ssh_gate.py` — delete.
|
- `tests/unit/test_ssh_gate.py` — delete.
|
||||||
- `tests/integration/` — delete any ssh-gate-specific tests.
|
- `tests/integration/` — delete any ssh-gate-specific tests.
|
||||||
@@ -160,7 +160,7 @@ the seams between ssh-gate and the rest of the system:
|
|||||||
helper.
|
helper.
|
||||||
- `README.md` — drop the socat image box from the diagram and
|
- `README.md` — drop the socat image box from the diagram and
|
||||||
the matching bullet; drop `ssh:` from the manifest example.
|
the matching bullet; drop `ssh:` from the manifest example.
|
||||||
- `claude-bottle.example.json` — drop the `ssh` field.
|
- `bot-bottle.example.json` — drop the `ssh` field.
|
||||||
- `docs/prds/0007-ssh-egress-gate.md` — add a
|
- `docs/prds/0007-ssh-egress-gate.md` — add a
|
||||||
`Status: Superseded by PRD 0009` header at the top.
|
`Status: Superseded by PRD 0009` header at the top.
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ the seams between ssh-gate and the rest of the system:
|
|||||||
### External dependencies
|
### External dependencies
|
||||||
|
|
||||||
Nothing added. The `alpine/socat` image is no longer pulled
|
Nothing added. The `alpine/socat` image is no longer pulled
|
||||||
by claude-bottle; the cleanup of any existing local image is
|
by bot-bottle; the cleanup of any existing local image is
|
||||||
the user's choice (a single `docker image rm` if they care).
|
the user's choice (a single `docker image rm` if they care).
|
||||||
|
|
||||||
## Future work
|
## Future work
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ already rely on.
|
|||||||
The research note
|
The research note
|
||||||
[`agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md)
|
[`agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md)
|
||||||
surveys the existing tools and concludes that a small
|
surveys the existing tools and concludes that a small
|
||||||
claude-bottle-specific reverse proxy is less work and less risk
|
bot-bottle-specific reverse proxy is less work and less risk
|
||||||
than either adopting nono (alpha, unaudited) or Infisical Agent
|
than either adopting nono (alpha, unaudited) or Infisical Agent
|
||||||
Vault (TLS-MITM topology that doubles up on pipelock's CA stack).
|
Vault (TLS-MITM topology that doubles up on pipelock's CA stack).
|
||||||
This PRD is the build.
|
This PRD is the build.
|
||||||
@@ -118,7 +118,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
|
|||||||
- **Cross-bottle credential sharing.** One proxy per bottle, same
|
- **Cross-bottle credential sharing.** One proxy per bottle, same
|
||||||
one-sidecar-per-agent posture as pipelock and git-gate.
|
one-sidecar-per-agent posture as pipelock and git-gate.
|
||||||
- **`claude --bare` mode.** Reads only `ANTHROPIC_API_KEY`, not
|
- **`claude --bare` mode.** Reads only `ANTHROPIC_API_KEY`, not
|
||||||
the OAuth token. Not in claude-bottle's flow today.
|
the OAuth token. Not in bot-bottle's flow today.
|
||||||
- **MCP-server tokens, package-installer tokens for languages
|
- **MCP-server tokens, package-installer tokens for languages
|
||||||
beyond npm.** PyPI / Bun / cargo can land in a follow-up if
|
beyond npm.** PyPI / Bun / cargo can land in a follow-up if
|
||||||
needed; the routing pattern generalizes.
|
needed; the routing pattern generalizes.
|
||||||
@@ -175,7 +175,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
|
|||||||
side-effect-free; `start` does `docker create` + `docker start`
|
side-effect-free; `start` does `docker create` + `docker start`
|
||||||
on the bottle's internal network with hostname `cred-proxy`;
|
on the bottle's internal network with hostname `cred-proxy`;
|
||||||
`stop` is idempotent `docker rm -f`. Container name:
|
`stop` is idempotent `docker rm -f`. Container name:
|
||||||
`claude-bottle-cred-proxy-<slug>`. The agent container starts
|
`bot-bottle-cred-proxy-<slug>`. The agent container starts
|
||||||
after the sidecar is up so DNS resolution succeeds on the
|
after the sidecar is up so DNS resolution succeeds on the
|
||||||
agent's first call.
|
agent's first call.
|
||||||
- **pipelock interop.** cred-proxy's outbound HTTPS traverses
|
- **pipelock interop.** cred-proxy's outbound HTTPS traverses
|
||||||
@@ -230,7 +230,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
|
|||||||
```
|
```
|
||||||
┌── Host (macOS) ──────────────────────────────────────────────────┐
|
┌── Host (macOS) ──────────────────────────────────────────────────┐
|
||||||
│ Secrets at rest (keychain / .env): │
|
│ Secrets at rest (keychain / .env): │
|
||||||
│ CLAUDE_BOTTLE_OAUTH_TOKEN, GITHUB_TOKEN, │
|
│ BOT_BOTTLE_CLAUDE_OAUTH_TOKEN, GITHUB_TOKEN, │
|
||||||
│ GITEA_SERVER_TOKEN, NPM_TOKEN │
|
│ GITEA_SERVER_TOKEN, NPM_TOKEN │
|
||||||
│ │ docker run -e KEY (no =VALUE on argv) │
|
│ │ docker run -e KEY (no =VALUE on argv) │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
@@ -288,18 +288,18 @@ Why the agent can't reach the sidecar's environ:
|
|||||||
|
|
||||||
### New components
|
### New components
|
||||||
|
|
||||||
- **`claude_bottle/cred_proxy.py`** (new): abstract `CredProxy`
|
- **`bot_bottle/cred_proxy.py`** (new): abstract `CredProxy`
|
||||||
+ `CredProxyPlan` dataclass. `prepare` is host-side and
|
+ `CredProxyPlan` dataclass. `prepare` is host-side and
|
||||||
side-effect-free; renders the route table and resolves
|
side-effect-free; renders the route table and resolves
|
||||||
`TokenRef`s against host env. Mirrors the existing `GitGate` /
|
`TokenRef`s against host env. Mirrors the existing `GitGate` /
|
||||||
`Pipelock` shape.
|
`Pipelock` shape.
|
||||||
- **`claude_bottle/backend/docker/cred_proxy.py`** (new):
|
- **`bot_bottle/backend/docker/cred_proxy.py`** (new):
|
||||||
`DockerCredProxy` concrete subclass. `start` does
|
`DockerCredProxy` concrete subclass. `start` does
|
||||||
`docker create` on the bottle's internal network with hostname
|
`docker create` on the bottle's internal network with hostname
|
||||||
`cred-proxy`, copies the route-table file into the container,
|
`cred-proxy`, copies the route-table file into the container,
|
||||||
then `docker start`. `stop` is idempotent `docker rm -f`.
|
then `docker start`. `stop` is idempotent `docker rm -f`.
|
||||||
Container name: `claude-bottle-cred-proxy-<slug>`.
|
Container name: `bot-bottle-cred-proxy-<slug>`.
|
||||||
- **`claude_bottle/backend/docker/provision/cred_proxy.py`**
|
- **`bot_bottle/backend/docker/provision/cred_proxy.py`**
|
||||||
(new): renders `ANTHROPIC_BASE_URL`, `~/.npmrc`,
|
(new): renders `ANTHROPIC_BASE_URL`, `~/.npmrc`,
|
||||||
`~/.gitconfig` `insteadOf` blocks, and `~/.config/tea/config.yml`
|
`~/.gitconfig` `insteadOf` blocks, and `~/.config/tea/config.yml`
|
||||||
into the agent's home for each declared kind — all pointing at
|
into the agent's home for each declared kind — all pointing at
|
||||||
@@ -310,12 +310,12 @@ Why the agent can't reach the sidecar's environ:
|
|||||||
|
|
||||||
### Existing code touched
|
### Existing code touched
|
||||||
|
|
||||||
- **`claude_bottle/manifest.py`** — add `CredProxyRoute`,
|
- **`bot_bottle/manifest.py`** — add `CredProxyRoute`,
|
||||||
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
|
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
|
||||||
+ validate route shape, role enum, path uniqueness, singleton-
|
+ validate route shape, role enum, path uniqueness, singleton-
|
||||||
role constraints.
|
role constraints.
|
||||||
- **`claude_bottle/backend/docker/prepare.py`** — drop the
|
- **`bot_bottle/backend/docker/prepare.py`** — drop the
|
||||||
legacy `CLAUDE_BOTTLE_OAUTH_TOKEN` → `CLAUDE_CODE_OAUTH_TOKEN`
|
legacy `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` → `CLAUDE_CODE_OAUTH_TOKEN`
|
||||||
forward entirely. cred-proxy is the only path the Anthropic
|
forward entirely. cred-proxy is the only path the Anthropic
|
||||||
OAuth token reaches the bottle. When a route claims the
|
OAuth token reaches the bottle. When a route claims the
|
||||||
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL`
|
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL`
|
||||||
@@ -324,27 +324,27 @@ Why the agent can't reach the sidecar's environ:
|
|||||||
otherwise; the proxy strips & replaces on every request).
|
otherwise; the proxy strips & replaces on every request).
|
||||||
Bottles that need claude-code to authenticate must declare
|
Bottles that need claude-code to authenticate must declare
|
||||||
the route; there is no fallback.
|
the route; there is no fallback.
|
||||||
- **`claude_bottle/backend/docker/backend.py`** — instantiate
|
- **`bot_bottle/backend/docker/backend.py`** — instantiate
|
||||||
`DockerCredProxy` alongside `DockerPipelockProxy` and
|
`DockerCredProxy` alongside `DockerPipelockProxy` and
|
||||||
`DockerGitGate`; thread its `prepare` / `start` / `stop`
|
`DockerGitGate`; thread its `prepare` / `start` / `stop`
|
||||||
through `resolve_plan` / `launch`.
|
through `resolve_plan` / `launch`.
|
||||||
- **`claude_bottle/backend/docker/launch.py`** — add cred-proxy
|
- **`bot_bottle/backend/docker/launch.py`** — add cred-proxy
|
||||||
start/stop to the `ExitStack` after pipelock and before the
|
start/stop to the `ExitStack` after pipelock and before the
|
||||||
agent; populate `pipelock_proxy_url` + `pipelock_ca_host_path`
|
agent; populate `pipelock_proxy_url` + `pipelock_ca_host_path`
|
||||||
on the cred-proxy plan so its outbound HTTPS routes through
|
on the cred-proxy plan so its outbound HTTPS routes through
|
||||||
pipelock.
|
pipelock.
|
||||||
- **`claude_bottle/backend/docker/bottle_plan.py`** — new
|
- **`bot_bottle/backend/docker/bottle_plan.py`** — new
|
||||||
`cred_proxy_plan` field; preflight shows route count + token
|
`cred_proxy_plan` field; preflight shows route count + token
|
||||||
refs + a path→upstream line per route; `to_dict` emits a
|
refs + a path→upstream line per route; `to_dict` emits a
|
||||||
`cred_proxy` array of `{path, upstream, auth_scheme, token_ref,
|
`cred_proxy` array of `{path, upstream, auth_scheme, token_ref,
|
||||||
roles}`.
|
roles}`.
|
||||||
- **`claude_bottle/pipelock.py`** — `pipelock_token_hosts` derives
|
- **`bot_bottle/pipelock.py`** — `pipelock_token_hosts` derives
|
||||||
from each route's `UpstreamHost` (not a hardcoded Kind→hosts
|
from each route's `UpstreamHost` (not a hardcoded Kind→hosts
|
||||||
map). Allowlist auto-includes them; passthrough does not (the
|
map). Allowlist auto-includes them; passthrough does not (the
|
||||||
proxy trusts pipelock's CA so MITM works).
|
proxy trusts pipelock's CA so MITM works).
|
||||||
- **`README.md`** — architecture diagram includes the cred-proxy
|
- **`README.md`** — architecture diagram includes the cred-proxy
|
||||||
lane; manifest section documents `bottle.cred_proxy.routes`.
|
lane; manifest section documents `bottle.cred_proxy.routes`.
|
||||||
- **`claude-bottle.example.json`** — one bottle demonstrates the
|
- **`bot-bottle.example.json`** — one bottle demonstrates the
|
||||||
four common routes (Anthropic, GitHub, Gitea, npm).
|
four common routes (Anthropic, GitHub, Gitea, npm).
|
||||||
- **Tests** — manifest parsing/validation, route lift + token-env
|
- **Tests** — manifest parsing/validation, route lift + token-env
|
||||||
slot assignment, role-based dispatch in the provisioner,
|
slot assignment, role-based dispatch in the provisioner,
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Replace the single-file `claude-bottle.json` manifest with a
|
Replace the single-file `bot-bottle.json` manifest with a
|
||||||
per-file Markdown-with-YAML-frontmatter layout. Bottles live as
|
per-file Markdown-with-YAML-frontmatter layout. Bottles live as
|
||||||
`$HOME/.claude-bottle/bottles/<name>.md`; agents live as
|
`$HOME/.bot-bottle/bottles/<name>.md`; agents live as
|
||||||
`$HOME/.claude-bottle/agents/<name>.md` (home-resident) and
|
`$HOME/.bot-bottle/agents/<name>.md` (home-resident) and
|
||||||
`$CWD/.claude-bottle/agents/<name>.md` (repo-supplied). Each file
|
`$CWD/.bot-bottle/agents/<name>.md` (repo-supplied). Each file
|
||||||
carries its structured config in YAML frontmatter and (for agents)
|
carries its structured config in YAML frontmatter and (for agents)
|
||||||
its system prompt in the Markdown body.
|
its system prompt in the Markdown body.
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ PyYAML dependency. The project's "low deps by default" stance
|
|||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
`claude-bottle.json` works fine at one bottle and one agent. The
|
`bot-bottle.json` works fine at one bottle and one agent. The
|
||||||
project is heading for many of both, and the single-JSON shape
|
project is heading for many of both, and the single-JSON shape
|
||||||
starts to fray:
|
starts to fray:
|
||||||
|
|
||||||
@@ -60,22 +60,22 @@ axes (grouping × format) and lands on this design.
|
|||||||
|
|
||||||
Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
||||||
|
|
||||||
1. **A bottle file under `$HOME/.claude-bottle/bottles/`
|
1. **A bottle file under `$HOME/.bot-bottle/bottles/`
|
||||||
parses.** A `dev.md` file with YAML frontmatter declaring
|
parses.** A `dev.md` file with YAML frontmatter declaring
|
||||||
`cred_proxy.routes`, `git`, `env`, `egress` produces a Bottle
|
`cred_proxy.routes`, `git`, `env`, `egress` produces a Bottle
|
||||||
dataclass equivalent to the current JSON shape.
|
dataclass equivalent to the current JSON shape.
|
||||||
|
|
||||||
2. **An agent file under `$HOME/.claude-bottle/agents/` parses.**
|
2. **An agent file under `$HOME/.bot-bottle/agents/` parses.**
|
||||||
`implementer.md` with frontmatter that names `bottle:`,
|
`implementer.md` with frontmatter that names `bottle:`,
|
||||||
`skills:`, and other fields, with the body as the system
|
`skills:`, and other fields, with the body as the system
|
||||||
prompt, produces an Agent dataclass.
|
prompt, produces an Agent dataclass.
|
||||||
|
|
||||||
3. **An agent file under `$CWD/.claude-bottle/agents/` parses
|
3. **An agent file under `$CWD/.bot-bottle/agents/` parses
|
||||||
and overrides home-resident agents of the same name.** The
|
and overrides home-resident agents of the same name.** The
|
||||||
cwd agent's frontmatter and body win; the home bottle it
|
cwd agent's frontmatter and body win; the home bottle it
|
||||||
references stays intact.
|
references stays intact.
|
||||||
|
|
||||||
4. **A bottle file under `$CWD/.claude-bottle/bottles/` is
|
4. **A bottle file under `$CWD/.bot-bottle/bottles/` is
|
||||||
ignored.** The directory does not contribute to the
|
ignored.** The directory does not contribute to the
|
||||||
manifest; if a user accidentally creates one, the launcher
|
manifest; if a user accidentally creates one, the launcher
|
||||||
emits a `warn`-level log naming the offending files and
|
emits a `warn`-level log naming the offending files and
|
||||||
@@ -83,7 +83,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
|||||||
is a usability nicety, not a security gate.
|
is a usability nicety, not a security gate.
|
||||||
|
|
||||||
5. **No third-party Python dependencies introduced.** A fresh
|
5. **No third-party Python dependencies introduced.** A fresh
|
||||||
clone with only stdlib + claude-bottle's own code runs every
|
clone with only stdlib + bot-bottle's own code runs every
|
||||||
parser test. Frontmatter parsing is hand-rolled against the
|
parser test. Frontmatter parsing is hand-rolled against the
|
||||||
declared YAML subset.
|
declared YAML subset.
|
||||||
|
|
||||||
@@ -97,30 +97,30 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
|||||||
`name`, `description`, `model`, `color`, and `memory` fields
|
`name`, `description`, `model`, `color`, and `memory` fields
|
||||||
from Claude Code's existing subagent spec are accepted in
|
from Claude Code's existing subagent spec are accepted in
|
||||||
our frontmatter alongside our own fields. Copying an agent
|
our frontmatter alongside our own fields. Copying an agent
|
||||||
file from `$HOME/.claude-bottle/agents/` to
|
file from `$HOME/.bot-bottle/agents/` to
|
||||||
`~/.claude/agents/` produces a working Claude Code subagent
|
`~/.claude/agents/` produces a working Claude Code subagent
|
||||||
(subject to Claude Code's tolerance for the extra `bottle:`
|
(subject to Claude Code's tolerance for the extra `bottle:`
|
||||||
and `claude_bottle:` fields — see Open Questions).
|
and `bot_bottle:` fields — see Open Questions).
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
- **A general YAML implementation.** The parser handles the
|
- **A general YAML implementation.** The parser handles the
|
||||||
subset claude-bottle's frontmatter actually uses; documents
|
subset bot-bottle's frontmatter actually uses; documents
|
||||||
that exceed the subset (anchors, multi-line block scalars,
|
that exceed the subset (anchors, multi-line block scalars,
|
||||||
tags, implicit type coercion, flow style, etc.) die with a
|
tags, implicit type coercion, flow style, etc.) die with a
|
||||||
pointer at the spec. We are not building a YAML library.
|
pointer at the spec. We are not building a YAML library.
|
||||||
|
|
||||||
- **Compatibility with the old JSON layout at runtime.** The
|
- **Compatibility with the old JSON layout at runtime.** The
|
||||||
resolver no longer reads `claude-bottle.json` files. This is
|
resolver no longer reads `bot-bottle.json` files. This is
|
||||||
a breaking change; existing users hand-rewrite their JSON
|
a breaking change; existing users hand-rewrite their JSON
|
||||||
into the new per-file layout (claude-bottle has a single
|
into the new per-file layout (bot-bottle has a single
|
||||||
primary user today, so the migration is one person rewriting
|
primary user today, so the migration is one person rewriting
|
||||||
one file). Documented as part of the README rewrite.
|
one file). Documented as part of the README rewrite.
|
||||||
|
|
||||||
- **`$HOME/.claude/agents/` integration on the input side.** We
|
- **`$HOME/.claude/agents/` integration on the input side.** We
|
||||||
don't read agent files out of Claude Code's directory. Our
|
don't read agent files out of Claude Code's directory. Our
|
||||||
files can be copied into Claude Code's tree by the user if
|
files can be copied into Claude Code's tree by the user if
|
||||||
they want, but the input path for claude-bottle is its own
|
they want, but the input path for bot-bottle is its own
|
||||||
directory.
|
directory.
|
||||||
|
|
||||||
- **A signed-manifest scheme.** Out of scope per the
|
- **A signed-manifest scheme.** Out of scope per the
|
||||||
@@ -139,14 +139,14 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
|||||||
### In scope
|
### In scope
|
||||||
|
|
||||||
- **Directory layout.**
|
- **Directory layout.**
|
||||||
- `$HOME/.claude-bottle/bottles/<name>.md` — bottle
|
- `$HOME/.bot-bottle/bottles/<name>.md` — bottle
|
||||||
definitions (full schema; one Bottle per file).
|
definitions (full schema; one Bottle per file).
|
||||||
- `$HOME/.claude-bottle/agents/<name>.md` — home-resident
|
- `$HOME/.bot-bottle/agents/<name>.md` — home-resident
|
||||||
agents.
|
agents.
|
||||||
- `$CWD/.claude-bottle/agents/<name>.md` — cwd-resident
|
- `$CWD/.bot-bottle/agents/<name>.md` — cwd-resident
|
||||||
agents; same schema as home agents, but bottle names must
|
agents; same schema as home agents, but bottle names must
|
||||||
resolve against the home set.
|
resolve against the home set.
|
||||||
- `$CWD/.claude-bottle/bottles/` — ignored with a warn-level
|
- `$CWD/.bot-bottle/bottles/` — ignored with a warn-level
|
||||||
log (see SC #4). Does not contribute to the manifest.
|
log (see SC #4). Does not contribute to the manifest.
|
||||||
- `<name>` is the file basename without `.md`. Filenames must
|
- `<name>` is the file basename without `.md`. Filenames must
|
||||||
match `[a-z][a-z0-9-]*` (kebab-case, ASCII-only).
|
match `[a-z][a-z0-9-]*` (kebab-case, ASCII-only).
|
||||||
@@ -162,7 +162,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
|||||||
- `skills: [<name>, ...]` (optional) — host-side skills under
|
- `skills: [<name>, ...]` (optional) — host-side skills under
|
||||||
`~/.claude/skills/`.
|
`~/.claude/skills/`.
|
||||||
- `name`, `description`, `model`, `color`, `memory` — accepted
|
- `name`, `description`, `model`, `color`, `memory` — accepted
|
||||||
but treated as Claude Code passthrough; claude-bottle
|
but treated as Claude Code passthrough; bot-bottle
|
||||||
ignores them at launch but doesn't reject. Lets the same
|
ignores them at launch but doesn't reject. Lets the same
|
||||||
file double as a Claude Code subagent.
|
file double as a Claude Code subagent.
|
||||||
- Unknown top-level keys die with a hint listing accepted
|
- Unknown top-level keys die with a hint listing accepted
|
||||||
@@ -191,17 +191,17 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
|||||||
the required-keys check — same diagnostic as malformed).
|
the required-keys check — same diagnostic as malformed).
|
||||||
|
|
||||||
- **Manifest assembly.** New resolver:
|
- **Manifest assembly.** New resolver:
|
||||||
1. Walk `$HOME/.claude-bottle/bottles/*.md` → Bottle dict
|
1. Walk `$HOME/.bot-bottle/bottles/*.md` → Bottle dict
|
||||||
keyed by filename.
|
keyed by filename.
|
||||||
2. Walk `$HOME/.claude-bottle/agents/*.md` → Agent dict.
|
2. Walk `$HOME/.bot-bottle/agents/*.md` → Agent dict.
|
||||||
3. Walk `$CWD/.claude-bottle/agents/*.md` → Agent dict; merge
|
3. Walk `$CWD/.bot-bottle/agents/*.md` → Agent dict; merge
|
||||||
into the home agent dict, cwd wins on name collision.
|
into the home agent dict, cwd wins on name collision.
|
||||||
4. Validate every agent's `bottle:` against the bottle dict.
|
4. Validate every agent's `bottle:` against the bottle dict.
|
||||||
5. Warn if `$CWD/.claude-bottle/bottles/` exists with files.
|
5. Warn if `$CWD/.bot-bottle/bottles/` exists with files.
|
||||||
6. Return Manifest dataclass — same shape as today.
|
6. Return Manifest dataclass — same shape as today.
|
||||||
|
|
||||||
- **Docs.** README's manifest section rewrites against the new
|
- **Docs.** README's manifest section rewrites against the new
|
||||||
layout. `claude-bottle.example.json` becomes
|
layout. `bot-bottle.example.json` becomes
|
||||||
`examples/bottles/dev.md` + `examples/agents/implementer.md`.
|
`examples/bottles/dev.md` + `examples/agents/implementer.md`.
|
||||||
The PRD 0010 example block in its own document gets a
|
The PRD 0010 example block in its own document gets a
|
||||||
follow-up commit noting the new layout (out of scope for
|
follow-up commit noting the new layout (out of scope for
|
||||||
@@ -233,7 +233,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
|||||||
### File layout
|
### File layout
|
||||||
|
|
||||||
```
|
```
|
||||||
$HOME/.claude-bottle/
|
$HOME/.bot-bottle/
|
||||||
├── bottles/
|
├── bottles/
|
||||||
│ ├── dev.md
|
│ ├── dev.md
|
||||||
│ ├── gitea-dev.md
|
│ ├── gitea-dev.md
|
||||||
@@ -243,7 +243,7 @@ $HOME/.claude-bottle/
|
|||||||
├── researcher.md
|
├── researcher.md
|
||||||
└── ...
|
└── ...
|
||||||
|
|
||||||
$CWD/.claude-bottle/
|
$CWD/.bot-bottle/
|
||||||
└── agents/
|
└── agents/
|
||||||
└── <repo-specific>.md
|
└── <repo-specific>.md
|
||||||
```
|
```
|
||||||
@@ -261,7 +261,7 @@ cred_proxy:
|
|||||||
- path: /anthropic/
|
- path: /anthropic/
|
||||||
upstream: https://api.anthropic.com
|
upstream: https://api.anthropic.com
|
||||||
auth_scheme: Bearer
|
auth_scheme: Bearer
|
||||||
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
|
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||||
role: anthropic-base-url
|
role: anthropic-base-url
|
||||||
- path: /gitea/dideric/
|
- path: /gitea/dideric/
|
||||||
upstream: https://gitea.dideric.is
|
upstream: https://gitea.dideric.is
|
||||||
@@ -271,8 +271,8 @@ cred_proxy:
|
|||||||
git:
|
git:
|
||||||
remotes:
|
remotes:
|
||||||
gitea.dideric.is:
|
gitea.dideric.is:
|
||||||
Name: claude-bottle
|
Name: bot-bottle
|
||||||
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
|
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
||||||
IdentityFile: ~/.ssh/gitea-delos-2.pem
|
IdentityFile: ~/.ssh/gitea-delos-2.pem
|
||||||
ExtraHosts:
|
ExtraHosts:
|
||||||
gitea.dideric.is: 100.78.141.42
|
gitea.dideric.is: 100.78.141.42
|
||||||
@@ -302,7 +302,7 @@ skills:
|
|||||||
---
|
---
|
||||||
|
|
||||||
You are a feature-implementation agent running inside an
|
You are a feature-implementation agent running inside an
|
||||||
ephemeral claude-bottle sandbox...
|
ephemeral bot-bottle sandbox...
|
||||||
```
|
```
|
||||||
|
|
||||||
Drop the same file into `~/.claude/agents/implementer.md` and
|
Drop the same file into `~/.claude/agents/implementer.md` and
|
||||||
@@ -336,7 +336,7 @@ Notable rejections (each dies with a specific error):
|
|||||||
be ambiguous, quote it.
|
be ambiguous, quote it.
|
||||||
- Flow style mappings nested more than one level deep.
|
- Flow style mappings nested more than one level deep.
|
||||||
|
|
||||||
Parser lives at `claude_bottle/yaml_subset.py`, ~300 lines.
|
Parser lives at `bot_bottle/yaml_subset.py`, ~300 lines.
|
||||||
Public API:
|
Public API:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -348,14 +348,14 @@ def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
|
|||||||
|
|
||||||
### Existing code touched
|
### Existing code touched
|
||||||
|
|
||||||
- **`claude_bottle/manifest.py`** — `Manifest.resolve` rewritten
|
- **`bot_bottle/manifest.py`** — `Manifest.resolve` rewritten
|
||||||
to walk the new directories. `Manifest.from_json_obj` kept as
|
to walk the new directories. `Manifest.from_json_obj` kept as
|
||||||
a programmatic entry point (used by tests). New
|
a programmatic entry point (used by tests). New
|
||||||
`Manifest.from_md_dirs(home_dir, cwd_dir)` for the loader.
|
`Manifest.from_md_dirs(home_dir, cwd_dir)` for the loader.
|
||||||
- **`claude_bottle/yaml_subset.py`** — new. The parser.
|
- **`bot_bottle/yaml_subset.py`** — new. The parser.
|
||||||
- **`README.md`** — manifest section rewritten against the new
|
- **`README.md`** — manifest section rewritten against the new
|
||||||
layout.
|
layout.
|
||||||
- **`claude-bottle.example.json`** — removed; replaced by an
|
- **`bot-bottle.example.json`** — removed; replaced by an
|
||||||
`examples/` directory with one bottle file + one agent file.
|
`examples/` directory with one bottle file + one agent file.
|
||||||
- **Tests** — new parser tests + new loader tests; existing
|
- **Tests** — new parser tests + new loader tests; existing
|
||||||
manifest tests adapt to either build via `from_json_obj`
|
manifest tests adapt to either build via `from_json_obj`
|
||||||
@@ -368,12 +368,12 @@ etc. all stay the same shape. Only the loader changes.
|
|||||||
|
|
||||||
### Backward compatibility
|
### Backward compatibility
|
||||||
|
|
||||||
This is a breaking change for v1 users. claude-bottle has a
|
This is a breaking change for v1 users. bot-bottle has a
|
||||||
single primary user today, so migration is one person rewriting
|
single primary user today, so migration is one person rewriting
|
||||||
one file — no automated migration command is in scope.
|
one file — no automated migration command is in scope.
|
||||||
|
|
||||||
If `claude-bottle.json` exists in `$HOME` or `$CWD` *and* the
|
If `bot-bottle.json` exists in `$HOME` or `$CWD` *and* the
|
||||||
new `.claude-bottle/` directory does not exist, the resolver
|
new `.bot-bottle/` directory does not exist, the resolver
|
||||||
dies with a clear pointer at the README's manifest section —
|
dies with a clear pointer at the README's manifest section —
|
||||||
not silently merging formats, not silently dropping the JSON
|
not silently merging formats, not silently dropping the JSON
|
||||||
content.
|
content.
|
||||||
@@ -384,11 +384,11 @@ content.
|
|||||||
empirically before settling: drop a file with `bottle: dev`
|
empirically before settling: drop a file with `bottle: dev`
|
||||||
in `~/.claude/agents/` and see whether Claude Code warns,
|
in `~/.claude/agents/` and see whether Claude Code warns,
|
||||||
ignores, or breaks. If it warns, namespace the field
|
ignores, or breaks. If it warns, namespace the field
|
||||||
(`claude-bottle-bottle:` or a nested `claude_bottle:` block).
|
(`bot-bottle-bottle:` or a nested `bot_bottle:` block).
|
||||||
- **Hidden directory vs visible.** Default `.claude-bottle/`
|
- **Hidden directory vs visible.** Default `.bot-bottle/`
|
||||||
(hidden — matches `.config/`, `.ssh/`, `.docker/`). If users
|
(hidden — matches `.config/`, `.ssh/`, `.docker/`). If users
|
||||||
routinely want to navigate to it from the file manager,
|
routinely want to navigate to it from the file manager,
|
||||||
switch to `claude-bottle/`. Lean hidden.
|
switch to `bot-bottle/`. Lean hidden.
|
||||||
- **`description:` for bottles.** Should bottle frontmatter
|
- **`description:` for bottles.** Should bottle frontmatter
|
||||||
carry a `description:` field for the y/N preflight? Default
|
carry a `description:` field for the y/N preflight? Default
|
||||||
no — bottle names are kebab-case and self-describing, and
|
no — bottle names are kebab-case and self-describing, and
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
When an agent running inside a claude-bottle container gets blocked, it invokes one of three MCP tool calls — `cred-proxy-block`, `pipelock-block`, or `capability-block` — passing a *proposed* config change (modified `routes.json`, modified pipelock allowlist, or modified agent Dockerfile) plus text describing why the change is justified. The supervisor sees the proposal in a host-side TUI, approves / modifies / rejects it, and the corresponding remediation runs: SIGHUP-reload cred-proxy with the new routes; restart pipelock with the new allowlist; rebuild the bottle from the new Dockerfile on the same branch. The agent's tool call blocks until the operator acts. The supervisor never opens a live channel into a running bottle; all signal flow goes through a per-bottle MCP sidecar on the existing internal network.
|
When an agent running inside a bot-bottle container gets blocked, it invokes one of three MCP tool calls — `cred-proxy-block`, `pipelock-block`, or `capability-block` — passing a *proposed* config change (modified `routes.json`, modified pipelock allowlist, or modified agent Dockerfile) plus text describing why the change is justified. The supervisor sees the proposal in a host-side TUI, approves / modifies / rejects it, and the corresponding remediation runs: SIGHUP-reload cred-proxy with the new routes; restart pipelock with the new allowlist; rebuild the bottle from the new Dockerfile on the same branch. The agent's tool call blocks until the operator acts. The supervisor never opens a live channel into a running bottle; all signal flow goes through a per-bottle MCP sidecar on the existing internal network.
|
||||||
|
|
||||||
This PRD is the overview. Implementation is split across four follow-on PRDs (0013–0016); see *Implementation chunks* below.
|
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:
|
Three named categories, each with its own MCP tool. Ordered by remediation cost:
|
||||||
|
|
||||||
- **cred-proxy block.** Tool: `cred-proxy-block`. The agent's request was refused by cred-proxy — missing route, expired token, wrong scope. The agent reads the current `routes.json` from `/etc/claude-bottle/current-config/`, composes a modified version, and calls the tool with `{routes: <new file>, justification: "..."}`. The operator reviews the diff in the TUI; on approval, the supervisor writes the new `routes.json` and cred-proxy SIGHUP-reloads. In-flight connections are not dropped. The tool returns `{status: "approved", notes: "..."}` and the agent retries. Implementation: PRD 0014.
|
- **cred-proxy block.** Tool: `cred-proxy-block`. The agent's request was refused by cred-proxy — missing route, expired token, wrong scope. The agent reads the current `routes.json` from `/etc/bot-bottle/current-config/`, composes a modified version, and calls the tool with `{routes: <new file>, justification: "..."}`. The operator reviews the diff in the TUI; on approval, the supervisor writes the new `routes.json` and cred-proxy SIGHUP-reloads. In-flight connections are not dropped. The tool returns `{status: "approved", notes: "..."}` and the agent retries. Implementation: PRD 0014.
|
||||||
- **pipelock block.** Tool: `pipelock-block`. The agent's outbound request was refused by pipelock — host not in the allowlist, protocol not permitted. The agent reads the current allowlist, composes a modified version, and calls the tool with `{allowlist: <new file>, justification: "..."}`. On approval, the supervisor writes the new allowlist and restarts pipelock; in-flight outbound calls may drop and rely on retry. The tool returns the same approve/reject shape. Implementation: PRD 0015.
|
- **pipelock block.** Tool: `pipelock-block`. The agent's outbound request was refused by pipelock — host not in the allowlist, protocol not permitted. The agent reads the current allowlist, composes a modified version, and calls the tool with `{allowlist: <new file>, justification: "..."}`. On approval, the supervisor writes the new allowlist and restarts pipelock; in-flight outbound calls may drop and rely on retry. The tool returns the same approve/reject shape. Implementation: PRD 0015.
|
||||||
- **capability block.** Tool: `capability-block`. The bottle is missing a tool, skill, permission, or env var the agent needs — something that lives in the agent Dockerfile rather than in routes or the pipelock allowlist. The agent reads the current Dockerfile, composes a modified version, and calls the tool with `{dockerfile: <new file>, justification: "..."}`. On approval, the rebuild orchestrator tears down the bottle, builds from the new Dockerfile, and starts a replacement bottle on the same branch via the state-preservation helper. Because the current agent is about to be replaced, the tool's return is best-effort — the replacement agent inherits the approval record via the preserved transcript. Implementation: PRD 0016.
|
- **capability block.** Tool: `capability-block`. The bottle is missing a tool, skill, permission, or env var the agent needs — something that lives in the agent Dockerfile rather than in routes or the pipelock allowlist. The agent reads the current Dockerfile, composes a modified version, and calls the tool with `{dockerfile: <new file>, justification: "..."}`. On approval, the rebuild orchestrator tears down the bottle, builds from the new Dockerfile, and starts a replacement bottle on the same branch via the state-preservation helper. Because the current agent is about to be replaced, the tool's return is best-effort — the replacement agent inherits the approval record via the preserved transcript. Implementation: PRD 0016.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
## Summary
|
## 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
|
## Problem
|
||||||
|
|
||||||
@@ -33,10 +33,10 @@ See PRD 0012 for the broader stuck-agent problem. This PRD specifically addresse
|
|||||||
- A per-bottle MCP sidecar container on the bottle's internal network.
|
- A per-bottle MCP sidecar container on the bottle's internal network.
|
||||||
- MCP tool definitions for `cred-proxy-block`, `pipelock-block`, `capability-block` (input schemas as defined in PRD 0012 *Stuck categories*).
|
- MCP tool definitions for `cred-proxy-block`, `pipelock-block`, `capability-block` (input schemas as defined in PRD 0012 *Stuck categories*).
|
||||||
- Tool output: `{status: "approved"|"modified"|"rejected", notes: "..."}`.
|
- Tool output: `{status: "approved"|"modified"|"rejected", notes: "..."}`.
|
||||||
- A read-only mount at `/etc/claude-bottle/current-config/` in the agent container exposing the current `routes.json`, pipelock allowlist, and Dockerfile.
|
- A read-only mount at `/etc/bot-bottle/current-config/` in the agent container exposing the current `routes.json`, pipelock allowlist, and Dockerfile.
|
||||||
- A host-mounted per-bottle proposal queue at `~/.claude-bottle/queue/<slug>/` (file-per-proposal, with metadata and proposed file content).
|
- A host-mounted per-bottle proposal queue at `~/.bot-bottle/queue/<slug>/` (file-per-proposal, with metadata and proposed file content).
|
||||||
- A `claude-bottle dashboard` (or similarly named) TUI that lists running bottles and pending proposals across all of them; supports approve, modify-then-approve, and reject-with-reason for each pending proposal.
|
- A `bot-bottle dashboard` (or similarly named) TUI that lists running bottles and pending proposals across all of them; supports approve, modify-then-approve, and reject-with-reason for each pending proposal.
|
||||||
- Audit log files at `~/.claude-bottle/audit/cred-proxy-<slug>.log` and `~/.claude-bottle/audit/pipelock-<slug>.log` with the agreed-upon format (timestamp, diff before/after, justification text, operator action with notes). Entries are written by the supervisor on each approve/modify/reject decision. (capability-block has no separate audit log — capability changes are captured by the bottle's rebuild record / git history.)
|
- Audit log files at `~/.bot-bottle/audit/cred-proxy-<slug>.log` and `~/.bot-bottle/audit/pipelock-<slug>.log` with the agreed-upon format (timestamp, diff before/after, justification text, operator action with notes). Entries are written by the supervisor on each approve/modify/reject decision. (capability-block has no separate audit log — capability changes are captured by the bottle's rebuild record / git history.)
|
||||||
- Bottle lifecycle script changes to launch the MCP sidecar alongside the other sidecars and mount the read-only current-config directory.
|
- Bottle lifecycle script changes to launch the MCP sidecar alongside the other sidecars and mount the read-only current-config directory.
|
||||||
|
|
||||||
### Out of scope
|
### Out of scope
|
||||||
@@ -49,15 +49,15 @@ See PRD 0012 for the broader stuck-agent problem. This PRD specifically addresse
|
|||||||
### New services / components
|
### New services / components
|
||||||
|
|
||||||
- **MCP sidecar.** New per-bottle container on the bottle's internal network. Exposes the three tools to the agent. On a tool call: validates the proposed file syntactically (valid JSON for `routes.json`, parseable Dockerfile, etc.), writes the proposal to the queue, and holds the tool-call connection open until the supervisor responds. Returns `{status, notes}` to the agent on response.
|
- **MCP sidecar.** New per-bottle container on the bottle's internal network. Exposes the three tools to the agent. On a tool call: validates the proposed file syntactically (valid JSON for `routes.json`, parseable Dockerfile, etc.), writes the proposal to the queue, and holds the tool-call connection open until the supervisor responds. Returns `{status, notes}` to the agent on response.
|
||||||
- **Read-only current-config mount.** `/etc/claude-bottle/current-config/` in the agent container exposes `routes.json`, the pipelock allowlist, and the agent Dockerfile from the host. Read-only — the agent proposes changes via the tool call, never by writing the file directly.
|
- **Read-only current-config mount.** `/etc/bot-bottle/current-config/` in the agent container exposes `routes.json`, the pipelock allowlist, and the agent Dockerfile from the host. Read-only — the agent proposes changes via the tool call, never by writing the file directly.
|
||||||
- **Proposal queue.** Per-bottle directory under `~/.claude-bottle/queue/<slug>/` on the host. One file per pending proposal with `{id, tool, proposed_file, justification, arrival_timestamp, current_file_hash, bottle_slug}`.
|
- **Proposal queue.** Per-bottle directory under `~/.bot-bottle/queue/<slug>/` on the host. One file per pending proposal with `{id, tool, proposed_file, justification, arrival_timestamp, current_file_hash, bottle_slug}`.
|
||||||
- **Minimal TUI dashboard.** Lists running bottles and pending proposals. For each proposal: shows current vs. proposed diff and justification. Operator actions: approve / modify-then-approve / reject-with-reason. Stdlib only (curses) unless that proves painful.
|
- **Minimal TUI dashboard.** Lists running bottles and pending proposals. For each proposal: shows current vs. proposed diff and justification. Operator actions: approve / modify-then-approve / reject-with-reason. Stdlib only (curses) unless that proves painful.
|
||||||
- **Audit log format.** Append-only files at `~/.claude-bottle/audit/<component>-<slug>.log`. Each entry: timestamp, diff before/after, agent justification (if from a tool call), operator action + notes. Defines the format; the per-component PRDs (0014, 0015) fill in real entries.
|
- **Audit log format.** Append-only files at `~/.bot-bottle/audit/<component>-<slug>.log`. Each entry: timestamp, diff before/after, agent justification (if from a tool call), operator action + notes. Defines the format; the per-component PRDs (0014, 0015) fill in real entries.
|
||||||
- **No-op approval handlers.** Each tool's approve path in 0013 writes an audit entry and returns `{status: "approved"}` to the agent but doesn't actually change any config. 0014 / 0015 / 0016 replace these with real handlers.
|
- **No-op approval handlers.** Each tool's approve path in 0013 writes an audit entry and returns `{status: "approved"}` to the agent but doesn't actually change any config. 0014 / 0015 / 0016 replace these with real handlers.
|
||||||
|
|
||||||
### Existing code touched
|
### Existing code touched
|
||||||
|
|
||||||
- **Bottle lifecycle scripts** — launch the MCP sidecar alongside other sidecars; mount `/etc/claude-bottle/current-config/` read-only into the agent container.
|
- **Bottle lifecycle scripts** — launch the MCP sidecar alongside other sidecars; mount `/etc/bot-bottle/current-config/` read-only into the agent container.
|
||||||
- **`cli.py`** — adds the dashboard subcommand.
|
- **`cli.py`** — adds the dashboard subcommand.
|
||||||
|
|
||||||
### Data model changes
|
### Data model changes
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ delivery.
|
|||||||
apply path. SIGHUP reload semantics carry over to egress-proxy.
|
apply path. SIGHUP reload semantics carry over to egress-proxy.
|
||||||
- PRD 0013 (supervise plane) `cred-proxy-block` MCP tool stays;
|
- PRD 0013 (supervise plane) `cred-proxy-block` MCP tool stays;
|
||||||
its proposed file format updates per the new route shape.
|
its proposed file format updates per the new route shape.
|
||||||
- Removal of the old cred-proxy code: `claude_bottle/cred_proxy.py`,
|
- Removal of the old cred-proxy code: `bot_bottle/cred_proxy.py`,
|
||||||
`cred_proxy_server.py`, `backend/docker/cred_proxy.py`,
|
`cred_proxy_server.py`, `backend/docker/cred_proxy.py`,
|
||||||
`provision/cred_proxy.py`, the `Dockerfile.cred-proxy`. Tests
|
`provision/cred_proxy.py`, the `Dockerfile.cred-proxy`. Tests
|
||||||
updated.
|
updated.
|
||||||
@@ -254,8 +254,8 @@ manifest load:
|
|||||||
`path` → `host`, drop the agent-side URL prefix).
|
`path` → `host`, drop the agent-side URL prefix).
|
||||||
- `cred_proxy_routes` field on existing dataclasses removed.
|
- `cred_proxy_routes` field on existing dataclasses removed.
|
||||||
- `Dockerfile.cred-proxy` deleted.
|
- `Dockerfile.cred-proxy` deleted.
|
||||||
- `claude_bottle/cred_proxy*.py` deleted.
|
- `bot_bottle/cred_proxy*.py` deleted.
|
||||||
- `claude_bottle/backend/docker/cred_proxy*.py` consolidated into
|
- `bot_bottle/backend/docker/cred_proxy*.py` consolidated into
|
||||||
`egress_proxy*.py`.
|
`egress_proxy*.py`.
|
||||||
- Provisioner files renamed.
|
- Provisioner files renamed.
|
||||||
- PRDs 0010 (cred-proxy), 0014 (cred-proxy-block remediation)
|
- PRDs 0010 (cred-proxy), 0014 (cred-proxy-block remediation)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ down`. Logs come from `docker compose logs` and land in a single file
|
|||||||
per instance, so reading what happened in a session is one `less`
|
per instance, so reading what happened in a session is one `less`
|
||||||
away.
|
away.
|
||||||
|
|
||||||
State for each instance (`~/.claude-bottle/state/<slug>/`) becomes a
|
State for each instance (`~/.bot-bottle/state/<slug>/`) becomes a
|
||||||
self-describing folder:
|
self-describing folder:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -34,7 +34,7 @@ together fully describe the container topology.
|
|||||||
|
|
||||||
Today `start` builds each sidecar (`pipelock`, `egress`, `git-gate`,
|
Today `start` builds each sidecar (`pipelock`, `egress`, `git-gate`,
|
||||||
`supervise`) and the agent container with a chain of individual SDK
|
`supervise`) and the agent container with a chain of individual SDK
|
||||||
calls in `claude_bottle/backend/docker/launch.py`:
|
calls in `bot_bottle/backend/docker/launch.py`:
|
||||||
|
|
||||||
- A per-sidecar `Docker{Sidecar}.start()` method does
|
- A per-sidecar `Docker{Sidecar}.start()` method does
|
||||||
`docker create` → `docker cp` (stage files) → `docker network
|
`docker create` → `docker cp` (stage files) → `docker network
|
||||||
@@ -50,7 +50,7 @@ This is fine, but it has three rough edges:
|
|||||||
|
|
||||||
2. **Logs are scattered.** Each container's logs sit in Docker's per-
|
2. **Logs are scattered.** Each container's logs sit in Docker's per-
|
||||||
container journal. To debug a session post-mortem you have to
|
container journal. To debug a session post-mortem you have to
|
||||||
remember to run `docker logs claude-bottle-pipelock-<slug>` etc.
|
remember to run `docker logs bot-bottle-pipelock-<slug>` etc.
|
||||||
before the containers age out, and there's no merged view.
|
before the containers age out, and there's no merged view.
|
||||||
|
|
||||||
3. **Teardown is bespoke.** Each sidecar's `stop()` is its own
|
3. **Teardown is bespoke.** Each sidecar's `stop()` is its own
|
||||||
@@ -62,14 +62,14 @@ project name per environment, merged logs, atomic up/down.
|
|||||||
|
|
||||||
## Goals / Success Criteria
|
## Goals / Success Criteria
|
||||||
|
|
||||||
1. `claude-bottle start <agent>` writes
|
1. `bot-bottle start <agent>` writes
|
||||||
`~/.claude-bottle/state/<slug>/docker-compose.yml` and brings the
|
`~/.bot-bottle/state/<slug>/docker-compose.yml` and brings the
|
||||||
project up with `docker compose -p <project> up`.
|
project up with `docker compose -p <project> up`.
|
||||||
2. The compose file is the source of truth for the container
|
2. The compose file is the source of truth for the container
|
||||||
topology — every sidecar that runs is declared as a `services:`
|
topology — every sidecar that runs is declared as a `services:`
|
||||||
entry, every network is a `networks:` entry, every bind mount is
|
entry, every network is a `networks:` entry, every bind mount is
|
||||||
a `volumes:` entry.
|
a `volumes:` entry.
|
||||||
3. `~/.claude-bottle/state/<slug>/compose.log` contains the full
|
3. `~/.bot-bottle/state/<slug>/compose.log` contains the full
|
||||||
merged stdout/stderr of every service for the session, in
|
merged stdout/stderr of every service for the session, in
|
||||||
`docker compose logs --no-color` format.
|
`docker compose logs --no-color` format.
|
||||||
4. `metadata.json` records the compose project name alongside the
|
4. `metadata.json` records the compose project name alongside the
|
||||||
@@ -79,7 +79,7 @@ project name per environment, merged logs, atomic up/down.
|
|||||||
5. Session teardown is `docker compose -p <project> down`. The
|
5. Session teardown is `docker compose -p <project> down`. The
|
||||||
existing per-sidecar `stop()` lifecycle methods come out.
|
existing per-sidecar `stop()` lifecycle methods come out.
|
||||||
6. The `cleanup` CLI uses `docker compose ls` (filtered to
|
6. The `cleanup` CLI uses `docker compose ls` (filtered to
|
||||||
`claude-bottle-*` projects) instead of name-prefix scans across
|
`bot-bottle-*` projects) instead of name-prefix scans across
|
||||||
`docker ps -a` and `docker network ls`.
|
`docker ps -a` and `docker network ls`.
|
||||||
7. The existing remediation flows (`pipelock-block`,
|
7. The existing remediation flows (`pipelock-block`,
|
||||||
`egress-block`, `capability-block`) keep working without
|
`egress-block`, `capability-block`) keep working without
|
||||||
@@ -95,7 +95,7 @@ project name per environment, merged logs, atomic up/down.
|
|||||||
implementation detail of the Docker backend.
|
implementation detail of the Docker backend.
|
||||||
- **Replacing the backend abstraction (PRD 0003).** `Backend` stays
|
- **Replacing the backend abstraction (PRD 0003).** `Backend` stays
|
||||||
abstract; only the Docker implementation changes.
|
abstract; only the Docker implementation changes.
|
||||||
- **A long-lived "claude-bottle daemon."** Each `start` invocation
|
- **A long-lived "bot-bottle daemon."** Each `start` invocation
|
||||||
still owns a single compose project for the lifetime of the
|
still owns a single compose project for the lifetime of the
|
||||||
session. No persistent service.
|
session. No persistent service.
|
||||||
- **Image pre-building.** Compose's `build:` directive triggers
|
- **Image pre-building.** Compose's `build:` directive triggers
|
||||||
@@ -109,7 +109,7 @@ project name per environment, merged logs, atomic up/down.
|
|||||||
|
|
||||||
### In scope
|
### In scope
|
||||||
|
|
||||||
- New module `claude_bottle/backend/docker/compose.py` that renders a
|
- New module `bot_bottle/backend/docker/compose.py` that renders a
|
||||||
compose dict from a `BottlePlan` and writes it to
|
compose dict from a `BottlePlan` and writes it to
|
||||||
`state/<slug>/docker-compose.yml`.
|
`state/<slug>/docker-compose.yml`.
|
||||||
- `DockerBackend.start` rewritten to:
|
- `DockerBackend.start` rewritten to:
|
||||||
@@ -118,7 +118,7 @@ project name per environment, merged logs, atomic up/down.
|
|||||||
into host paths under `state/<slug>/`.
|
into host paths under `state/<slug>/`.
|
||||||
3. Render + write the compose file.
|
3. Render + write the compose file.
|
||||||
4. Exec `docker compose -p <project> up -d`.
|
4. Exec `docker compose -p <project> up -d`.
|
||||||
5. `docker attach claude-bottle-<slug>` for the agent's TTY.
|
5. `docker attach bot-bottle-<slug>` for the agent's TTY.
|
||||||
6. On exit: `docker compose -p <project> logs --no-color`
|
6. On exit: `docker compose -p <project> logs --no-color`
|
||||||
→ `state/<slug>/compose.log`, then `docker compose -p
|
→ `state/<slug>/compose.log`, then `docker compose -p
|
||||||
<project> down --volumes`.
|
<project> down --volumes`.
|
||||||
@@ -134,12 +134,12 @@ project name per environment, merged logs, atomic up/down.
|
|||||||
|
|
||||||
### Out of scope
|
### Out of scope
|
||||||
|
|
||||||
- Changing the manifest layer (`claude_bottle/manifest.py`,
|
- Changing the manifest layer (`bot_bottle/manifest.py`,
|
||||||
`egress.py`'s plan dataclasses, `pipelock.py`'s plan dataclasses).
|
`egress.py`'s plan dataclasses, `pipelock.py`'s plan dataclasses).
|
||||||
- Changing the agent's runtime contract (proxy env vars, CA bundle
|
- Changing the agent's runtime contract (proxy env vars, CA bundle
|
||||||
paths, current-config mount path).
|
paths, current-config mount path).
|
||||||
- Changing audit-log shape or location (
|
- Changing audit-log shape or location (
|
||||||
`~/.claude-bottle/audit/<component>-<slug>.log` stays).
|
`~/.bot-bottle/audit/<component>-<slug>.log` stays).
|
||||||
- Changing the MCP server's tool list or wire format.
|
- Changing the MCP server's tool list or wire format.
|
||||||
- Dropping the `--rm` semantics for the agent: the agent container
|
- Dropping the `--rm` semantics for the agent: the agent container
|
||||||
is still ephemeral; compose's `down --volumes` handles cleanup.
|
is still ephemeral; compose's `down --volumes` handles cleanup.
|
||||||
@@ -148,7 +148,7 @@ project name per environment, merged logs, atomic up/down.
|
|||||||
|
|
||||||
### Project name
|
### Project name
|
||||||
|
|
||||||
`compose_project = f"claude-bottle-{slug}"`. The slug stays the
|
`compose_project = f"bot-bottle-{slug}"`. The slug stays the
|
||||||
existing `slugify(agent_name)-<5-char-random-base36>` from
|
existing `slugify(agent_name)-<5-char-random-base36>` from
|
||||||
`bottle_state.py`. Compose adds its own prefix to networks
|
`bottle_state.py`. Compose adds its own prefix to networks
|
||||||
(`<project>_<network>`) and to default container names — which is
|
(`<project>_<network>`) and to default container names — which is
|
||||||
@@ -163,29 +163,29 @@ an explicit `container_name:` matching today's pattern:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
pipelock:
|
pipelock:
|
||||||
container_name: claude-bottle-pipelock-<slug>
|
container_name: bot-bottle-pipelock-<slug>
|
||||||
egress:
|
egress:
|
||||||
container_name: claude-bottle-egress-<slug>
|
container_name: bot-bottle-egress-<slug>
|
||||||
# ...
|
# ...
|
||||||
```
|
```
|
||||||
|
|
||||||
This keeps the dashboard's container-discovery output stable for
|
This keeps the dashboard's container-discovery output stable for
|
||||||
operators who've memorized the names. The compose project name
|
operators who've memorized the names. The compose project name
|
||||||
(`claude-bottle-<slug>`) is the only new identifier.
|
(`bot-bottle-<slug>`) is the only new identifier.
|
||||||
|
|
||||||
### Networks
|
### Networks
|
||||||
|
|
||||||
The two existing networks (`claude-bottle-net-<slug>` internal +
|
The two existing networks (`bot-bottle-net-<slug>` internal +
|
||||||
`claude-bottle-egress-<slug>` upstream-bridge) become compose
|
`bot-bottle-egress-<slug>` upstream-bridge) become compose
|
||||||
networks:
|
networks:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
networks:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
name: claude-bottle-net-<slug>
|
name: bot-bottle-net-<slug>
|
||||||
internal: true
|
internal: true
|
||||||
egress:
|
egress:
|
||||||
name: claude-bottle-egress-<slug>
|
name: bot-bottle-egress-<slug>
|
||||||
```
|
```
|
||||||
|
|
||||||
Each service's `networks:` list mirrors today's wiring.
|
Each service's `networks:` list mirrors today's wiring.
|
||||||
@@ -238,7 +238,7 @@ sidecars that exist.
|
|||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
`docker compose up -d` starts everything detached. The agent is
|
`docker compose up -d` starts everything detached. The agent is
|
||||||
attached for the user's TTY via `docker attach claude-bottle-
|
attached for the user's TTY via `docker attach bot-bottle-
|
||||||
<slug>`. Sidecars stream into Docker's per-container journals
|
<slug>`. Sidecars stream into Docker's per-container journals
|
||||||
during the session, exactly as today, and `docker compose logs -f`
|
during the session, exactly as today, and `docker compose logs -f`
|
||||||
gives a merged tail if the user wants it (the dashboard can shell
|
gives a merged tail if the user wants it (the dashboard can shell
|
||||||
@@ -265,7 +265,7 @@ Add one field; everything else is unchanged.
|
|||||||
"agent_name": "implementer",
|
"agent_name": "implementer",
|
||||||
"cwd": "/Users/.../some-project",
|
"cwd": "/Users/.../some-project",
|
||||||
"started_at": "2026-05-25T20:13:04Z",
|
"started_at": "2026-05-25T20:13:04Z",
|
||||||
"compose_project": "claude-bottle-implementer-a7k3f"
|
"compose_project": "bot-bottle-implementer-a7k3f"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -291,13 +291,13 @@ After this PRD:
|
|||||||
### Cleanup CLI
|
### Cleanup CLI
|
||||||
|
|
||||||
`./cli.py cleanup` switches from "list every container with prefix
|
`./cli.py cleanup` switches from "list every container with prefix
|
||||||
`claude-bottle-` and every network with prefix `claude-bottle-net-`
|
`bot-bottle-` and every network with prefix `bot-bottle-net-`
|
||||||
or `claude-bottle-egress-`" to:
|
or `bot-bottle-egress-`" to:
|
||||||
|
|
||||||
1. `docker compose ls --all --format json` → filter to projects
|
1. `docker compose ls --all --format json` → filter to projects
|
||||||
whose name starts with `claude-bottle-`.
|
whose name starts with `bot-bottle-`.
|
||||||
2. For each: `docker compose -p <project> down --volumes`.
|
2. For each: `docker compose -p <project> down --volumes`.
|
||||||
3. Reap any state dirs under `~/.claude-bottle/state/` whose
|
3. Reap any state dirs under `~/.bot-bottle/state/` whose
|
||||||
`compose_project` no longer appears in `compose ls`.
|
`compose_project` no longer appears in `compose ls`.
|
||||||
|
|
||||||
Strays from pre-compose code-paths can be mopped up by keeping the
|
Strays from pre-compose code-paths can be mopped up by keeping the
|
||||||
@@ -312,8 +312,8 @@ existing prefix scan as a fallback for one release.
|
|||||||
|
|
||||||
2. **How does `claude` reach the agent's TTY?** Decided: keep
|
2. **How does `claude` reach the agent's TTY?** Decided: keep
|
||||||
today's `docker exec -it` model. Agent runs `sleep infinity`
|
today's `docker exec -it` model. Agent runs `sleep infinity`
|
||||||
under compose; `DockerBottle.exec_claude` runs
|
under compose; `DockerBottle.exec_agent` runs
|
||||||
`docker exec -it claude-bottle-<slug> claude ...` exactly like
|
`docker exec -it bot-bottle-<slug> claude ...` exactly like
|
||||||
today. Compose owns the lifecycle (so `compose logs` includes
|
today. Compose owns the lifecycle (so `compose logs` includes
|
||||||
the agent's stdout, `compose down` tears it down), but the
|
the agent's stdout, `compose down` tears it down), but the
|
||||||
user-facing exec model is unchanged. Rejected `docker attach`
|
user-facing exec model is unchanged. Rejected `docker attach`
|
||||||
@@ -332,8 +332,8 @@ existing prefix scan as a fallback for one release.
|
|||||||
|
|
||||||
5. **Image build caching.** `build:` in compose rebuilds on first
|
5. **Image build caching.** `build:` in compose rebuilds on first
|
||||||
`up` unless the image is already tagged. The per-sidecar images
|
`up` unless the image is already tagged. The per-sidecar images
|
||||||
(`claude-bottle-pipelock`, `claude-bottle-egress`,
|
(`bot-bottle-pipelock`, `bot-bottle-egress`,
|
||||||
`claude-bottle-git-gate`, `claude-bottle-supervise`) should
|
`bot-bottle-git-gate`, `bot-bottle-supervise`) should
|
||||||
stay tagged on the daemon between runs so we don't rebuild on
|
stay tagged on the daemon between runs so we don't rebuild on
|
||||||
every start. Verify compose's behavior matches.
|
every start. Verify compose's behavior matches.
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user